feat: dashboard legacy-migration popup + app-state migration marker (#2219)

* docs: design for dashboard legacy-migration popup + app-state marker

Spec for the app-side migration trigger (Approach A): projects-only import
daemon API + a migration marker in ~/.ao/app-state.json, with a launch-time
popup (Proceed / Skip / Don't Migrate). Settings redo path deferred to #2205.
Includes the projects-only import-offer backend plan it consumes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs: implementation plan for legacy-migration popup + marker

Part A reuses the projects-only import API (import-offer plan) with an
availability-only Status; Part B adds the app-state migration marker (schema v2),
IPC, the useMigrationOffer gate, and the MigrationPopup (Proceed/Skip/Don't
Migrate). Settings redo path deferred to #2205.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* refactor(legacyimport): scope import to projects + settings only

Remove orchestrator/transcript import code (orchestrator.go, claude.go,
session_import_store.go and their tests). Trim Store, Options, Report to
projects-only fields. Drop defaultClaudeProjectsDir and projectSessionsDir
from paths.go. Add yaml.TypeError robustness in config.go. Update cli/import.go
confirm prompt and summary. Update importer_test.go to projects-only assertions.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(importer): availability probe + projects-only run

Create service/importer.Manager with Status (physical availability check only,
no DB heuristic) and Run (delegates to legacyimport.Run). The app-state.json
marker governs whether to prompt; this service only answers whether legacy data
is physically present.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(httpd): import controller + DTOs (GET/POST /api/v1/import)

Add ImportStatusResponse/ImportRunResponse DTOs to dto.go. Create
ImportController with GET (status probe) and POST (run) handlers, both
returning 501 when Svc is nil. Wire APIDeps.Import + API.imports in api.go.
Add controller tests (status, status error, run, run error).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(apispec): describe /api/v1/import; regenerate openapi + schema.ts

Add import tag, importOperations() (GET + POST /api/v1/import), and schema
name mappings (ImportStatusResponse, ImportRunResponse, ImportReport) to
build.go. Regenerate openapi.yaml and frontend/src/api/schema.ts. Route-spec
parity test passes. Restore the nil-svc-501 import controller test now that
the spec includes the operation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(daemon): mount import service on the API

Wire importsvc.New(importsvc.Deps{Store: store}) into APIDeps.Import in
daemon.go so the daemon serves GET/POST /api/v1/import backed by the live
sqlite store. Projects-only; no DataDir.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore(cli): drop resolved §6.4 first-boot-import TODO

The legacy import is now a daemon API (GET/POST /api/v1/import) served by
the importer service and the desktop app handles the popup prompt via the
app-state.json migration marker. The TODO comment is resolved.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore(legacyimport): remove dead isDir helper

isDir was only used by the deleted projectSessionsDir function.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore: update package-lock.json after npm install for api:ts

openapi-typescript was missing from root node_modules; npm install
populated it so npm run api:ts could regenerate schema.ts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(httpd): drop em-dash comment + unused ImportStatusResult alias

Review findings M1/M2 from G1 task review.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(app-state): migration marker (schema v2) + updateMigration

Bump SCHEMA_VERSION to 2, add MigrationStatus/MigrationState types and
migration? field to AppStateMarker. Extract atomicWriteMarker helper and
reuse it. writeAppStateMarker now preserves an existing migration block
across launch writes. Add updateMigration (IPC setter) and readMigrationState
(IPC getter) exports. TDD: tests added first (red), then implementation (green).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(ipc): expose app-state migration getter/setter to the renderer

Add appState:getMigration / appState:setMigration IPC handlers in main.ts.
Add ao.appState.getMigration / setMigration to preload.ts (typed via AoBridge).
Add appState preview fallback in bridge.ts and test setup so AoBridge stays
satisfied in both browser preview and test environments.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(app-state): cover corrupt-marker case + clean up temp dirs

G2 review findings I2 (corrupt-JSON branch untested) and m1 (temp dirs not cleaned).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(renderer): useMigrationOffer gate (marker + availability)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(renderer): MigrationPopup (Proceed / Skip / Don't Migrate)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(renderer): surface MigrationPopup on the dashboard

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore: format with prettier [skip ci]

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
This commit is contained in:
Harshit Singh Bhandari 2026-06-27 02:24:36 +05:30 committed by GitHub
parent 0bccaacdc9
commit 572093de9d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
37 changed files with 2730 additions and 1509 deletions

View File

@ -27,12 +27,11 @@ func newImportCommand(ctx *commandContext) *cobra.Command {
var opts importOptions
cmd := &cobra.Command{
Use: "import",
Short: "Import projects and orchestrator sessions from a legacy AO install",
Short: "Import projects from a legacy AO install",
Long: "Import reads the legacy Agent Orchestrator flat-file store " +
"(~/.agent-orchestrator) read-only and ports its projects, per-project " +
"settings, and each project's live orchestrator session into the rewrite " +
"database. Legacy files are never modified, and a re-run skips rows that " +
"already exist, so it is safe to run more than once.\n\n" +
"(~/.agent-orchestrator) read-only and ports its projects and per-project " +
"settings into the rewrite database. Legacy files are never modified, and " +
"a re-run skips rows that already exist, so it is safe to run more than once.\n\n" +
"The daemon must be stopped: it is the sole writer of the database.",
Args: noArgs,
RunE: func(cmd *cobra.Command, args []string) error {
@ -71,7 +70,7 @@ func (c *commandContext) runImport(cmd *cobra.Command, opts importOptions) error
if !opts.dryRun && !opts.yes {
ok, err := confirm(c.deps.In, cmd.OutOrStdout(),
fmt.Sprintf("Import projects and orchestrator sessions from %s?", root), true)
fmt.Sprintf("Import projects from %s?", root), true)
if err != nil {
return err
}
@ -82,9 +81,8 @@ func (c *commandContext) runImport(cmd *cobra.Command, opts importOptions) error
}
rep, err := c.executeImport(cmd.Context(), cfg, legacyimport.Options{
Root: root,
DataDir: cfg.DataDir,
DryRun: opts.dryRun,
Root: root,
DryRun: opts.dryRun,
})
if err != nil {
return err
@ -112,11 +110,9 @@ func (c *commandContext) executeImport(ctx context.Context, cfg config.Config, o
func writeImportSummary(w io.Writer, rep legacyimport.Report) error {
var b strings.Builder
if rep.DryRun {
b.WriteString("Dry run no changes written.\n")
b.WriteString("Dry run -- no changes written.\n")
}
fmt.Fprintf(&b, "Projects: %d imported, %d already present\n", rep.ProjectsImported, rep.ProjectsSkipped)
fmt.Fprintf(&b, "Orchestrators: %d imported, %d skipped, %d absent\n", rep.OrchestratorsImported, rep.OrchestratorsSkipped, rep.OrchestratorsAbsent)
fmt.Fprintf(&b, "Transcripts: %d relocated\n", rep.TranscriptsRelocated)
fmt.Fprintf(&b, "Projects: %d imported, %d already present\n", rep.ProjectsImported, rep.ProjectsSkipped)
if len(rep.Notes) > 0 {
b.WriteString("\nNotes:\n")
for _, n := range rep.Notes {

View File

@ -75,8 +75,6 @@ func newStartCommand(ctx *commandContext) *cobra.Command {
return cmd
}
// TODO(spec §6.4): legacy first-boot import now belongs to the desktop app; standalone `ao import` still available.
// runStart implements the spec §6.1 algorithm: resolve the installed app, fetch
// it if absent, open it, then print the deprecation notice. It never blocks or
// supervises the launched app.

View File

@ -21,6 +21,7 @@ import (
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
"github.com/aoagents/agent-orchestrator/backend/internal/preview"
"github.com/aoagents/agent-orchestrator/backend/internal/runfile"
importsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/importer"
notificationsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/notification"
projectsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/project"
"github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite"
@ -127,6 +128,7 @@ func Run() error {
Reviews: reviewSvc,
Notifications: notifier,
NotificationStream: notificationHub,
Import: importsvc.New(importsvc.Deps{Store: store}),
CDC: store,
Events: cdcPipe.Broadcaster,
Activity: lcStack.LCM,

View File

@ -26,6 +26,7 @@ type APIDeps struct {
Reviews reviewsvc.Manager
Notifications controllers.NotificationService
NotificationStream controllers.NotificationStream
Import controllers.ImportService
CDC cdc.Source
Events cdcSubscriber
Telemetry ports.EventSink
@ -40,6 +41,7 @@ type API struct {
prs *controllers.PRsController
reviews *controllers.ReviewsController
notifications *controllers.NotificationsController
imports *controllers.ImportController
events *EventsController
}
@ -59,6 +61,7 @@ func NewAPI(cfg config.Config, deps APIDeps) *API {
prs: &controllers.PRsController{Svc: deps.PRs},
reviews: &controllers.ReviewsController{Svc: deps.Reviews},
notifications: &controllers.NotificationsController{Svc: deps.Notifications, Stream: deps.NotificationStream},
imports: &controllers.ImportController{Svc: deps.Import},
events: &EventsController{Source: deps.CDC, Live: deps.Events},
}
}
@ -82,6 +85,7 @@ func (a *API) Register(root chi.Router) {
a.prs.Register(r)
a.reviews.Register(r)
a.notifications.Register(r)
a.imports.Register(r)
// Sibling REST controllers plug in here.
})
// Long-lived streams intentionally bypass the REST timeout middleware.

View File

@ -51,6 +51,55 @@ paths:
summary: Stream CDC events with durable replay
tags:
- events
/api/v1/import:
get:
operationId: getImportStatus
responses:
"200":
content:
application/json:
schema:
$ref: '#/components/schemas/ImportStatusResponse'
description: OK
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/APIError'
description: Internal Server Error
"501":
content:
application/json:
schema:
$ref: '#/components/schemas/APIError'
description: Not Implemented
summary: Check whether a legacy AO install is available to import
tags:
- import
post:
operationId: runImport
responses:
"200":
content:
application/json:
schema:
$ref: '#/components/schemas/ImportRunResponse'
description: OK
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/APIError'
description: Internal Server Error
"501":
content:
application/json:
schema:
$ref: '#/components/schemas/APIError'
description: Not Implemented
summary: Run the legacy AO project import through the daemon store
tags:
- import
/api/v1/notifications:
get:
operationId: listNotifications
@ -1600,6 +1649,40 @@ components:
required:
- harness
type: object
ImportReport:
properties:
dryRun:
type: boolean
notes:
items:
type: string
type: array
projectsImported:
type: integer
projectsSkipped:
type: integer
required:
- dryRun
- projectsImported
- projectsSkipped
type: object
ImportRunResponse:
properties:
report:
$ref: '#/components/schemas/ImportReport'
required:
- report
type: object
ImportStatusResponse:
properties:
available:
type: boolean
legacyRoot:
type: string
required:
- available
- legacyRoot
type: object
KillSessionResponse:
properties:
freed:
@ -2429,3 +2512,5 @@ tags:
name: notifications
- description: Server-sent CDC event stream with durable replay
name: events
- description: Legacy AO project import (availability probe and run)
name: import

View File

@ -67,6 +67,8 @@ func Build() ([]byte, error) {
"Durable dashboard notifications"),
*(&openapi31.Tag{Name: "events"}).WithDescription(
"Server-sent CDC event stream with durable replay"),
*(&openapi31.Tag{Name: "import"}).WithDescription(
"Legacy AO project import (availability probe and run)"),
}
for _, op := range operations() {
@ -186,6 +188,11 @@ var schemaNames = map[string]string{
"ControllersSubmitReviewInput": "SubmitReviewInput",
// domain review entities
"DomainReviewRun": "ReviewRun",
// httpd/controllers: import wire envelopes
"ControllersImportStatusResponse": "ImportStatusResponse",
"ControllersImportRunResponse": "ImportRunResponse",
// legacyimport report
"LegacyimportReport": "ImportReport",
// service/project entities + DTOs
"ProjectProject": "Project",
"ProjectSummary": "ProjectSummary",
@ -273,9 +280,35 @@ func operations() []operation {
ops = append(ops, prOperations()...)
ops = append(ops, reviewOperations()...)
ops = append(ops, notificationOperations()...)
ops = append(ops, importOperations()...)
return ops
}
// importOperations declares the 2 /import operations. Must stay 1:1 with
// the routes ImportController.Register mounts (enforced by the parity test).
func importOperations() []operation {
return []operation{
{
method: http.MethodGet, path: "/api/v1/import", id: "getImportStatus", tag: "import",
summary: "Check whether a legacy AO install is available to import",
resps: []respUnit{
{http.StatusOK, controllers.ImportStatusResponse{}},
{http.StatusInternalServerError, envelope.APIError{}},
{http.StatusNotImplemented, envelope.APIError{}},
},
},
{
method: http.MethodPost, path: "/api/v1/import", id: "runImport", tag: "import",
summary: "Run the legacy AO project import through the daemon store",
resps: []respUnit{
{http.StatusOK, controllers.ImportRunResponse{}},
{http.StatusInternalServerError, envelope.APIError{}},
{http.StatusNotImplemented, envelope.APIError{}},
},
},
}
}
func notificationOperations() []operation {
return []operation{
{

View File

@ -6,6 +6,7 @@ import (
"time"
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
"github.com/aoagents/agent-orchestrator/backend/internal/legacyimport"
projectsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/project"
sessionsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/session"
)
@ -485,6 +486,19 @@ type MarkAllNotificationsReadResponse struct {
Notifications []NotificationResponse `json:"notifications"`
}
// ImportStatusResponse is the body of GET /api/v1/import: whether a legacy AO
// install is available to import, and the root the daemon would read from.
type ImportStatusResponse struct {
Available bool `json:"available"`
LegacyRoot string `json:"legacyRoot"`
}
// ImportRunResponse is the body of POST /api/v1/import: the structured outcome
// of the import run (counts + notes), reused verbatim from the import engine.
type ImportRunResponse struct {
Report legacyimport.Report `json:"report"`
}
// PRIDParam is the {id} path parameter shared by the /prs/{id} routes.
type PRIDParam struct {
ID string `path:"id" description:"PR number."`

View File

@ -0,0 +1,59 @@
package controllers
import (
"context"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/aoagents/agent-orchestrator/backend/internal/httpd/apispec"
"github.com/aoagents/agent-orchestrator/backend/internal/httpd/envelope"
"github.com/aoagents/agent-orchestrator/backend/internal/legacyimport"
importsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/importer"
)
// ImportService is the controller-facing import service contract.
type ImportService interface {
Status(ctx context.Context) (importsvc.Status, error)
Run(ctx context.Context) (legacyimport.Report, error)
}
// ImportController owns the /import routes.
type ImportController struct {
Svc ImportService
}
// Register mounts import REST routes on the supplied router.
func (c *ImportController) Register(r chi.Router) {
r.Get("/import", c.status)
r.Post("/import", c.run)
}
func (c *ImportController) status(w http.ResponseWriter, r *http.Request) {
if c.Svc == nil {
apispec.NotImplemented(w, r, "GET", "/api/v1/import")
return
}
st, err := c.Svc.Status(r.Context())
if err != nil {
envelope.WriteError(w, r, err)
return
}
envelope.WriteJSON(w, http.StatusOK, ImportStatusResponse{
Available: st.Available,
LegacyRoot: st.LegacyRoot,
})
}
func (c *ImportController) run(w http.ResponseWriter, r *http.Request) {
if c.Svc == nil {
apispec.NotImplemented(w, r, "POST", "/api/v1/import")
return
}
rep, err := c.Svc.Run(r.Context())
if err != nil {
envelope.WriteError(w, r, err)
return
}
envelope.WriteJSON(w, http.StatusOK, ImportRunResponse{Report: rep})
}

View File

@ -0,0 +1,99 @@
package controllers_test
import (
"context"
"errors"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"testing"
"github.com/aoagents/agent-orchestrator/backend/internal/config"
"github.com/aoagents/agent-orchestrator/backend/internal/httpd"
"github.com/aoagents/agent-orchestrator/backend/internal/httpd/controllers"
"github.com/aoagents/agent-orchestrator/backend/internal/legacyimport"
importsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/importer"
)
// fakeImportService is a test double for controllers.ImportService.
type fakeImportService struct {
statusResult importsvc.Status
statusErr error
runResult legacyimport.Report
runErr error
}
func (f *fakeImportService) Status(_ context.Context) (importsvc.Status, error) {
return f.statusResult, f.statusErr
}
func (f *fakeImportService) Run(_ context.Context) (legacyimport.Report, error) {
return f.runResult, f.runErr
}
func newImportTestServer(t *testing.T, svc controllers.ImportService) *httptest.Server {
t.Helper()
log := slog.New(slog.NewTextHandler(io.Discard, nil))
srv := httptest.NewServer(httpd.NewRouterWithControl(config.Config{}, log, nil, httpd.APIDeps{Import: svc}, httpd.ControlDeps{}))
t.Cleanup(srv.Close)
return srv
}
func TestImportAPI_Status(t *testing.T) {
svc := &fakeImportService{statusResult: importsvc.Status{Available: true, LegacyRoot: "/home/u/.agent-orchestrator"}}
srv := newImportTestServer(t, svc)
body, status, headers := doRequest(t, srv, "GET", "/api/v1/import", "")
if status != http.StatusOK {
t.Fatalf("GET /import = %d, want 200; body=%s", status, body)
}
assertJSON(t, headers)
var resp controllers.ImportStatusResponse
mustJSON(t, body, &resp)
if !resp.Available || resp.LegacyRoot != "/home/u/.agent-orchestrator" {
t.Fatalf("status = %+v", resp)
}
}
func TestImportAPI_StatusError(t *testing.T) {
svc := &fakeImportService{statusErr: errors.New("store error")}
srv := newImportTestServer(t, svc)
_, status, _ := doRequest(t, srv, "GET", "/api/v1/import", "")
if status != http.StatusInternalServerError {
t.Fatalf("GET /import with error = %d, want 500", status)
}
}
func TestImportAPI_Run(t *testing.T) {
svc := &fakeImportService{runResult: legacyimport.Report{ProjectsImported: 3}}
srv := newImportTestServer(t, svc)
body, status, headers := doRequest(t, srv, "POST", "/api/v1/import", "")
if status != http.StatusOK {
t.Fatalf("POST /import = %d, want 200; body=%s", status, body)
}
assertJSON(t, headers)
var resp controllers.ImportRunResponse
mustJSON(t, body, &resp)
if resp.Report.ProjectsImported != 3 {
t.Fatalf("report = %+v", resp.Report)
}
}
func TestImportAPI_RunError(t *testing.T) {
svc := &fakeImportService{runErr: errors.New("disk full")}
srv := newImportTestServer(t, svc)
_, status, _ := doRequest(t, srv, "POST", "/api/v1/import", "")
if status != http.StatusInternalServerError {
t.Fatalf("POST /import with error = %d, want 500", status)
}
}
func TestImportAPI_NilSvcReturns501(t *testing.T) {
log := slog.New(slog.NewTextHandler(io.Discard, nil))
srv := httptest.NewServer(httpd.NewRouterWithControl(config.Config{}, log, nil, httpd.APIDeps{}, httpd.ControlDeps{}))
t.Cleanup(srv.Close)
body, status, _ := doRequest(t, srv, "GET", "/api/v1/import", "")
assertErrorCode(t, body, status, http.StatusNotImplemented, "NOT_IMPLEMENTED")
body, status, _ = doRequest(t, srv, "POST", "/api/v1/import", "")
assertErrorCode(t, body, status, http.StatusNotImplemented, "NOT_IMPLEMENTED")
}

View File

@ -1,137 +0,0 @@
package legacyimport
import (
"io"
"os"
"path/filepath"
"regexp"
)
// claudeSlugRE matches every character Claude Code replaces with "-" when it
// buckets a cwd's transcripts under ~/.claude/projects/<slug>/. The rule
// (empirically verified, issue #2129 §9) is: realpath(cwd) with every char
// outside [a-zA-Z0-9-] replaced by "-". A leading "/" therefore becomes a
// leading "-".
var claudeSlugRE = regexp.MustCompile(`[^a-zA-Z0-9-]`)
func claudeSlug(path string) string {
return claudeSlugRE.ReplaceAllString(path, "-")
}
// transcriptCopyPlan is the resolved source + destination of a transcript copy.
type transcriptCopyPlan struct {
uuid string
sourcePath string // ~/.claude/projects/<sourceSlug>/<uuid>.jsonl
destPath string // ~/.claude/projects/<destSlug>/<uuid>.jsonl
}
// planTranscriptCopy computes the source + destination transcript paths.
//
// Claude Code buckets a transcript under ~/.claude/projects/<slug>/ where the
// slug is derived from the REALPATH of the session's cwd. Both slugs are
// therefore computed from symlink-resolved paths:
//
// - source: the legacy worktree the orchestrator last ran in (exists on disk).
// - destination: the orchestrator worktree the rewrite materialises on first
// resume — {dataDir}/worktrees/{projectID}/orchestrator/{prefix}-orchestrator.
// The daemon resolves that path through physicalAbs before cd-ing into it
// (gitworktree New + validateManagedPath), so we resolve it the same way; a
// literal-path slug would miss the resume bucket whenever any component of
// dataDir (e.g. a custom AO_DATA_DIR, or macOS /tmp → /private/tmp) is a
// symlink. The leaf does not exist yet, so resolvePhysical resolves the
// longest existing ancestor and appends the literal tail — exactly what the
// daemon's physicalAbs does.
func planTranscriptCopy(dataDir, projectID, prefix, worktree, uuid, claudeProjectsDir string) transcriptCopyPlan {
if claudeProjectsDir == "" {
claudeProjectsDir = defaultClaudeProjectsDir()
}
sourceSlug := claudeSlug(resolvePhysical(worktree))
destTemplate := filepath.Join(dataDir, "worktrees", projectID, "orchestrator", prefix+"-orchestrator")
destSlug := claudeSlug(resolvePhysical(destTemplate))
return transcriptCopyPlan{
uuid: uuid,
sourcePath: filepath.Join(claudeProjectsDir, sourceSlug, uuid+".jsonl"),
destPath: filepath.Join(claudeProjectsDir, destSlug, uuid+".jsonl"),
}
}
// resolvePhysical resolves path to an absolute, symlink-free path, mirroring the
// daemon's gitworktree.physicalAbs so the transcript destination slug matches the
// cwd the resumed orchestrator actually runs in. When the leaf does not exist
// yet it resolves the longest existing ancestor and appends the literal tail.
func resolvePhysical(path string) string {
abs, err := filepath.Abs(path)
if err != nil {
return filepath.Clean(path)
}
abs = filepath.Clean(abs)
if resolved, err := filepath.EvalSymlinks(abs); err == nil {
return filepath.Clean(resolved)
}
parent := filepath.Dir(abs)
base := filepath.Base(abs)
for parent != "." && parent != string(os.PathSeparator) {
if resolved, err := filepath.EvalSymlinks(parent); err == nil {
return filepath.Join(resolved, base)
}
base = filepath.Join(filepath.Base(parent), base)
parent = filepath.Dir(parent)
}
if resolved, err := filepath.EvalSymlinks(parent); err == nil {
return filepath.Join(resolved, base)
}
return abs
}
// transcriptOutcome reports what relocateTranscript did.
type transcriptOutcome string
const (
transcriptCopied transcriptOutcome = "copied"
transcriptAlreadyPresent transcriptOutcome = "already-present"
transcriptSourceMissing transcriptOutcome = "source-missing"
)
// relocateTranscript executes a transcript copy. Idempotent: an existing
// destination is left as-is (already-present); a missing source is skipped
// (source-missing). Only "copied" counts as a relocation. The legacy source is
// never modified.
func relocateTranscript(plan transcriptCopyPlan) (transcriptOutcome, error) {
if pathExists(plan.destPath) {
return transcriptAlreadyPresent, nil
}
if !pathExists(plan.sourcePath) {
return transcriptSourceMissing, nil
}
if err := os.MkdirAll(filepath.Dir(plan.destPath), 0o750); err != nil {
return "", err
}
if err := copyFile(plan.sourcePath, plan.destPath); err != nil {
return "", err
}
return transcriptCopied, nil
}
func pathExists(p string) bool {
_, err := os.Stat(p)
return err == nil
}
func copyFile(src, dst string) error {
in, err := os.Open(src) //nolint:gosec // src is a resolved transcript path under ~/.claude
if err != nil {
return err
}
defer func() { _ = in.Close() }()
out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600)
if err != nil {
return err
}
if _, err := io.Copy(out, in); err != nil {
_ = out.Close()
return err
}
return out.Close()
}

View File

@ -1,92 +0,0 @@
package legacyimport
import (
"os"
"path/filepath"
"testing"
yaml "gopkg.in/yaml.v3"
)
func nonNilNode() *yaml.Node { return &yaml.Node{Kind: yaml.ScalarNode, Value: "x"} }
func TestClaudeSlug(t *testing.T) {
if got := claudeSlug("/Users/me/Code/proj.x"); got != "-Users-me-Code-proj-x" {
t.Fatalf("slug = %q", got)
}
}
func TestPlanTranscriptCopy_DestUsesOrchestratorTemplate(t *testing.T) {
plan := planTranscriptCopy("/data", "proj", "pre", "/legacy/wt", "uuid-1", "/claude")
// Destination slug = slug({dataDir}/worktrees/{projectID}/orchestrator/{prefix}-orchestrator).
wantDest := filepath.Join("/claude", claudeSlug("/data/worktrees/proj/orchestrator/pre-orchestrator"), "uuid-1.jsonl")
if plan.destPath != wantDest {
t.Fatalf("destPath = %q, want %q", plan.destPath, wantDest)
}
}
func TestRelocateTranscript_CopiesAndIsIdempotent(t *testing.T) {
dir := t.TempDir()
claudeDir := filepath.Join(dir, "claude")
worktree := filepath.Join(dir, "wt")
if err := os.MkdirAll(worktree, 0o750); err != nil {
t.Fatal(err)
}
// Seed the legacy transcript at the source slug. planTranscriptCopy
// realpath-resolves the worktree, so seed under the resolved slug (matters on
// macOS where /var/folders is a symlink to /private/var/folders).
resolvedWt, err := filepath.EvalSymlinks(worktree)
if err != nil {
t.Fatal(err)
}
srcSlug := claudeSlug(resolvedWt)
srcDir := filepath.Join(claudeDir, srcSlug)
if err := os.MkdirAll(srcDir, 0o750); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(srcDir, "uuid-1.jsonl"), []byte("hello"), 0o600); err != nil {
t.Fatal(err)
}
plan := planTranscriptCopy(filepath.Join(dir, "data"), "proj", "pre", worktree, "uuid-1", claudeDir)
out, err := relocateTranscript(plan)
if err != nil || out != transcriptCopied {
t.Fatalf("relocate = (%s,%v), want copied", out, err)
}
if b, err := os.ReadFile(plan.destPath); err != nil || string(b) != "hello" {
t.Fatalf("dest content = %q err=%v", b, err)
}
// Re-run: destination already present.
if out, _ := relocateTranscript(plan); out != transcriptAlreadyPresent {
t.Fatalf("second relocate = %s, want already-present", out)
}
}
func TestPlanTranscriptCopy_DestResolvesSymlinkedDataDir(t *testing.T) {
// The daemon resolves the orchestrator worktree through physicalAbs before
// cd-ing into it, so the dest slug must use the symlink-resolved data dir —
// not the literal one — or `claude --resume` misses the bucket.
realData := t.TempDir()
linkDir := filepath.Join(t.TempDir(), "data-link")
if err := os.Symlink(realData, linkDir); err != nil {
t.Skipf("symlink unsupported: %v", err)
}
plan := planTranscriptCopy(linkDir, "proj", "pre", "/legacy/wt", "uuid-1", "/claude")
resolvedReal, err := filepath.EvalSymlinks(realData)
if err != nil {
t.Fatal(err)
}
wantSlug := claudeSlug(filepath.Join(resolvedReal, "worktrees", "proj", "orchestrator", "pre-orchestrator"))
wantDest := filepath.Join("/claude", wantSlug, "uuid-1.jsonl")
if plan.destPath != wantDest {
t.Fatalf("destPath = %q,\n want %q (resolved, not the symlinked %q)", plan.destPath, wantDest, linkDir)
}
}
func TestRelocateTranscript_SourceMissing(t *testing.T) {
plan := planTranscriptCopy(t.TempDir(), "proj", "pre", "/nope/wt", "uuid-x", filepath.Join(t.TempDir(), "claude"))
if out, err := relocateTranscript(plan); err != nil || out != transcriptSourceMissing {
t.Fatalf("relocate = (%s,%v), want source-missing", out, err)
}
}

View File

@ -2,6 +2,7 @@ package legacyimport
import (
"encoding/json"
"errors"
"fmt"
"os"
@ -22,7 +23,9 @@ type legacyConfig struct {
type legacyProjectConfig struct {
Path string `yaml:"path"`
Name string `yaml:"name"`
Repo string `yaml:"repo"`
// Repo is captured as a raw YAML node but never consumed; the origin URL is
// re-resolved from the repo path at import time.
Repo *yaml.Node `yaml:"repo"`
DefaultBranch string `yaml:"defaultBranch"`
SessionPrefix string `yaml:"sessionPrefix"`
Env map[string]string `yaml:"env"`
@ -65,7 +68,12 @@ func loadLegacyConfig(root string) (legacyConfig, error) {
}
var cfg legacyConfig
if err := yaml.Unmarshal(data, &cfg); err != nil {
return legacyConfig{}, fmt.Errorf("parse legacy config.yaml: %w", err)
var typeErr *yaml.TypeError
if !errors.As(err, &typeErr) {
return legacyConfig{}, fmt.Errorf("parse legacy config.yaml: %w", err)
}
// A type mismatch (e.g. a scalar where a mapping is expected) is a
// partial decode: keep the decoded fields and continue.
}
return cfg, nil
}

View File

@ -15,42 +15,31 @@ import (
// Store is the narrow slice of the rewrite's native storage layer the importer
// writes through. *sqlite.Store satisfies it. Idempotency lives here: a project
// or orchestrator whose id already exists is skipped, never overwritten, so a
// re-run is safe and legacy files stay the sole source of truth.
// whose id already exists is skipped, never overwritten, so a re-run is safe
// and legacy files stay the sole source of truth.
type Store interface {
GetProject(ctx context.Context, id string) (domain.ProjectRecord, bool, error)
UpsertProject(ctx context.Context, r domain.ProjectRecord) error
GetSession(ctx context.Context, id domain.SessionID) (domain.SessionRecord, bool, error)
ImportSession(ctx context.Context, rec domain.SessionRecord, num int64) (bool, error)
}
// Options configure one import run.
type Options struct {
// Root is the legacy state root to read (default ~/.agent-orchestrator).
Root string
// DataDir is the rewrite data dir, used only to compute the destination
// transcript slug. It must match the daemon's AO_DATA_DIR.
DataDir string
// DryRun parses + plans every row and relocation but writes nothing.
// DryRun parses + plans every row but writes nothing.
DryRun bool
// ClaudeProjectsDir overrides ~/.claude/projects (tests).
ClaudeProjectsDir string
// Now is the fallback registered_at timestamp. Zero → time.Now().UTC().
// Now is the fallback registered_at timestamp. Zero -> time.Now().UTC().
Now time.Time
// RepoOriginURL resolves a repo's git origin. Nil the real git resolver.
// RepoOriginURL resolves a repo's git origin. Nil -> the real git resolver.
RepoOriginURL func(path string) string
}
// Report is the structured outcome of an import run.
type Report struct {
DryRun bool `json:"dryRun"`
ProjectsImported int `json:"projectsImported"`
ProjectsSkipped int `json:"projectsSkipped"` // already present
OrchestratorsImported int `json:"orchestratorsImported"`
OrchestratorsSkipped int `json:"orchestratorsSkipped"` // terminal / non-importable / already present
OrchestratorsAbsent int `json:"orchestratorsAbsent"`
TranscriptsRelocated int `json:"transcriptsRelocated"`
Notes []string `json:"notes,omitempty"`
DryRun bool `json:"dryRun"`
ProjectsImported int `json:"projectsImported"`
ProjectsSkipped int `json:"projectsSkipped"` // already present
Notes []string `json:"notes,omitempty"`
}
// HasLegacyData reports whether root holds an importable legacy store: a
@ -76,11 +65,10 @@ func isValidRewriteProjectID(id string) bool {
!strings.ContainsAny(id, `/\`) && rewriteProjectID.MatchString(id)
}
// Run reads the legacy store and writes projects (then orchestrator sessions)
// into store, relocating claude-code transcripts. It never modifies legacy
// files. It is idempotent: existing rows are skipped. A per-project parse or
// write failure is recorded as a note and does not abort the whole run, except a
// store write error, which is returned.
// Run reads the legacy store and writes projects into store. It never modifies
// legacy files. It is idempotent: existing rows are skipped. A per-project
// parse or write failure is recorded as a note and does not abort the whole
// run, except a store write error, which is returned.
func Run(ctx context.Context, store Store, opts Options) (Report, error) {
root := opts.Root
if root == "" {
@ -113,7 +101,7 @@ func Run(ctx context.Context, store Store, opts Options) (Report, error) {
prefs := loadPreferences(root)
reg := loadRegistered(root)
// Deterministic order: projects before sessions, ids sorted.
// Deterministic order: ids sorted.
ids := make([]string, 0, len(cfg.Projects))
for id := range cfg.Projects {
ids = append(ids, id)
@ -135,23 +123,6 @@ func Run(ctx context.Context, store Store, opts Options) (Report, error) {
if err := importProject(ctx, store, record, opts.DryRun, &rep); err != nil {
return rep, err
}
// Orchestrator session for this project.
sessionsDir := projectSessionsDir(root, id)
mapping := readOrchestratorMapping(sessionsDir, id, pc)
if mapping.note != "" {
rep.Notes = append(rep.Notes, id+": "+mapping.note)
}
switch mapping.status {
case orchAbsent:
rep.OrchestratorsAbsent++
case orchSkipped:
rep.OrchestratorsSkipped++
case orchMapped:
if err := importOrchestrator(ctx, store, mapping, opts, &rep); err != nil {
return rep, err
}
}
}
return rep, nil
}
@ -176,52 +147,6 @@ func importProject(ctx context.Context, store Store, record domain.ProjectRecord
return nil
}
func importOrchestrator(ctx context.Context, store Store, mapping orchestratorMapping, opts Options, rep *Report) error {
rec := mapping.record
_, exists, err := store.GetSession(ctx, rec.ID)
if err != nil {
return fmt.Errorf("lookup orchestrator %s: %w", rec.ID, err)
}
if exists {
rep.OrchestratorsSkipped++
} else if opts.DryRun {
rep.OrchestratorsImported++
} else {
inserted, err := store.ImportSession(ctx, rec, 0)
if err != nil {
return fmt.Errorf("write orchestrator %s: %w", rec.ID, err)
}
if inserted {
rep.OrchestratorsImported++
} else {
rep.OrchestratorsSkipped++
}
}
// Relocate the claude-code transcript (codex/opencode resume by global id).
if mapping.transcript == nil {
return nil
}
plan := planTranscriptCopy(opts.DataDir, mapping.projectID, mapping.prefix,
mapping.transcript.worktree, mapping.transcript.uuid, opts.ClaudeProjectsDir)
if opts.DryRun {
if _, err := os.Stat(plan.sourcePath); err == nil {
rep.TranscriptsRelocated++
}
return nil
}
// Relocation is best-effort: a failure is noted, not fatal — the orchestrator
// still resumes, just without prior context.
outcome, relocErr := relocateTranscript(plan)
switch {
case relocErr != nil:
rep.Notes = append(rep.Notes, mapping.projectID+": transcript relocation failed: "+relocErr.Error())
case outcome == transcriptCopied:
rep.TranscriptsRelocated++
}
return nil
}
func appendPrefixed(dst []string, id string, notes []string) []string {
for _, n := range notes {
dst = append(dst, id+": "+n)
@ -229,6 +154,15 @@ func appendPrefixed(dst []string, id string, notes []string) []string {
return dst
}
// quote wraps s in double quotes for note messages, rendering an empty string as
// "?" so a missing value is still legible.
func quote(s string) string {
if s == "" {
return `"?"`
}
return `"` + s + `"`
}
// defaultRepoOriginURL resolves a repo's git origin URL, "" when the repo is
// absent or has no origin. Matches the rewrite's resolveGitOriginURL.
func defaultRepoOriginURL(path string) string {

View File

@ -13,11 +13,10 @@ import (
// fakeStore is an in-memory Store with the importer's idempotency semantics.
type fakeStore struct {
projects map[string]domain.ProjectRecord
sessions map[domain.SessionID]domain.SessionRecord
}
func newFakeStore() *fakeStore {
return &fakeStore{projects: map[string]domain.ProjectRecord{}, sessions: map[domain.SessionID]domain.SessionRecord{}}
return &fakeStore{projects: map[string]domain.ProjectRecord{}}
}
func (f *fakeStore) GetProject(_ context.Context, id string) (domain.ProjectRecord, bool, error) {
@ -28,25 +27,12 @@ func (f *fakeStore) UpsertProject(_ context.Context, r domain.ProjectRecord) err
f.projects[r.ID] = r
return nil
}
func (f *fakeStore) GetSession(_ context.Context, id domain.SessionID) (domain.SessionRecord, bool, error) {
r, ok := f.sessions[id]
return r, ok, nil
}
func (f *fakeStore) ImportSession(_ context.Context, rec domain.SessionRecord, _ int64) (bool, error) {
if _, ok := f.sessions[rec.ID]; ok {
return false, nil
}
f.sessions[rec.ID] = rec
return true, nil
}
// writeLegacyRoot builds a minimal legacy store: two projects, an importable
// claude-code orchestrator for alpha (with a seeded transcript), an aider
// orchestrator for beta (skipped). Returns the legacy root and the claude dir.
func writeLegacyRoot(t *testing.T) (root, claudeDir string) {
// writeLegacyRoot builds a minimal legacy store: two projects. Returns the
// legacy root.
func writeLegacyRoot(t *testing.T) string {
t.Helper()
root = filepath.Join(t.TempDir(), ".agent-orchestrator")
claudeDir = filepath.Join(t.TempDir(), "claude")
root := filepath.Join(t.TempDir(), ".agent-orchestrator")
mustMkdir(t, filepath.Join(root, "projects", "alpha", "sessions"))
mustMkdir(t, filepath.Join(root, "projects", "beta", "sessions"))
@ -58,72 +44,29 @@ func writeLegacyRoot(t *testing.T) (root, claudeDir string) {
beta:
path: /repos/beta
`)
worktree := filepath.Join(t.TempDir(), "alpha-wt")
mustMkdir(t, worktree)
mustWrite(t, filepath.Join(root, "projects", "alpha", "sessions", "orchestrator.json"), `{
"role": "orchestrator",
"agent": "claude-code",
"worktree": "`+worktree+`",
"claudeSessionUuid": "uuid-alpha",
"userPrompt": "go",
"createdAt": "2026-01-01T00:00:00Z",
"lifecycle": {"session": {"state": "working", "lastTransitionAt": "2026-01-02T00:00:00Z"}}
}`)
// Seed the transcript at the legacy source slug so relocation copies it
// (resolve symlinks to match planTranscriptCopy's realpath of the worktree).
resolvedWt, err := filepath.EvalSymlinks(worktree)
if err != nil {
t.Fatal(err)
}
srcDir := filepath.Join(claudeDir, claudeSlug(resolvedWt))
mustMkdir(t, srcDir)
mustWrite(t, filepath.Join(srcDir, "uuid-alpha.jsonl"), "transcript")
mustWrite(t, filepath.Join(root, "projects", "beta", "sessions", "orchestrator.json"), `{
"role": "orchestrator",
"agent": "aider",
"lifecycle": {"session": {"state": "working"}}
}`)
return root, claudeDir
return root
}
func runOpts(root, claudeDir string) Options {
func runOpts(root string) Options {
return Options{
Root: root,
DataDir: filepath.Join(filepath.Dir(root), "data"),
ClaudeProjectsDir: claudeDir,
Now: time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC),
RepoOriginURL: func(string) string { return "" },
Root: root,
Now: time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC),
RepoOriginURL: func(string) string { return "" },
}
}
func TestRun_EndToEnd(t *testing.T) {
root, claudeDir := writeLegacyRoot(t)
root := writeLegacyRoot(t)
store := newFakeStore()
ctx := context.Background()
rep, err := Run(ctx, store, runOpts(root, claudeDir))
rep, err := Run(ctx, store, runOpts(root))
if err != nil {
t.Fatalf("run: %v", err)
}
if rep.ProjectsImported != 2 {
t.Fatalf("projectsImported = %d, want 2", rep.ProjectsImported)
}
if rep.OrchestratorsImported != 1 {
t.Fatalf("orchestratorsImported = %d, want 1 (alpha)", rep.OrchestratorsImported)
}
if rep.OrchestratorsSkipped != 1 {
t.Fatalf("orchestratorsSkipped = %d, want 1 (beta/aider)", rep.OrchestratorsSkipped)
}
if rep.TranscriptsRelocated != 1 {
t.Fatalf("transcriptsRelocated = %d, want 1", rep.TranscriptsRelocated)
}
// The alpha orchestrator row landed verbatim.
o, ok := store.sessions["alpha-orchestrator"]
if !ok || o.Kind != domain.KindOrchestrator || o.Metadata.AgentSessionID != "uuid-alpha" {
t.Fatalf("alpha orchestrator = %+v ok=%v", o, ok)
}
// develop branch survives into the config blob.
if store.projects["alpha"].Config.DefaultBranch != "develop" {
t.Fatalf("alpha config = %+v", store.projects["alpha"].Config)
@ -131,37 +74,34 @@ func TestRun_EndToEnd(t *testing.T) {
}
func TestRun_Idempotent(t *testing.T) {
root, claudeDir := writeLegacyRoot(t)
root := writeLegacyRoot(t)
store := newFakeStore()
ctx := context.Background()
if _, err := Run(ctx, store, runOpts(root, claudeDir)); err != nil {
if _, err := Run(ctx, store, runOpts(root)); err != nil {
t.Fatalf("first run: %v", err)
}
rep, err := Run(ctx, store, runOpts(root, claudeDir))
rep, err := Run(ctx, store, runOpts(root))
if err != nil {
t.Fatalf("second run: %v", err)
}
if rep.ProjectsImported != 0 || rep.ProjectsSkipped != 2 {
t.Fatalf("re-run projects: imported=%d skipped=%d, want 0/2", rep.ProjectsImported, rep.ProjectsSkipped)
}
if rep.OrchestratorsImported != 0 {
t.Fatalf("re-run orchestratorsImported = %d, want 0", rep.OrchestratorsImported)
}
}
func TestRun_DryRunWritesNothing(t *testing.T) {
root, claudeDir := writeLegacyRoot(t)
root := writeLegacyRoot(t)
store := newFakeStore()
opts := runOpts(root, claudeDir)
opts := runOpts(root)
opts.DryRun = true
rep, err := Run(context.Background(), store, opts)
if err != nil {
t.Fatalf("dry run: %v", err)
}
if rep.ProjectsImported != 2 || rep.OrchestratorsImported != 1 {
if rep.ProjectsImported != 2 {
t.Fatalf("dry-run plan = %+v", rep)
}
if len(store.projects) != 0 || len(store.sessions) != 0 {
if len(store.projects) != 0 {
t.Fatal("dry run must not write to the store")
}
}
@ -178,7 +118,7 @@ func TestRun_NoLegacyData(t *testing.T) {
}
func TestHasLegacyData(t *testing.T) {
root, _ := writeLegacyRoot(t)
root := writeLegacyRoot(t)
if !HasLegacyData(root) {
t.Fatal("HasLegacyData = false, want true")
}

View File

@ -1,355 +0,0 @@
package legacyimport
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"time"
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
)
// migratableHarnesses are the orchestrator harnesses the importer ports. aider
// (and anything else) is skipped with a note (gist §6).
var migratableHarnesses = map[string]bool{
"claude-code": true,
"codex": true,
"opencode": true,
}
// terminalStates are the legacy canonical states that mean "do not import".
var terminalStates = map[string]bool{"done": true, "terminated": true}
// orchestratorStatus is the outcome of mapping one project's orchestrator.
type orchestratorStatus string
const (
orchMapped orchestratorStatus = "mapped"
orchSkipped orchestratorStatus = "skipped"
orchAbsent orchestratorStatus = "absent"
)
// transcriptRelocation carries the inputs to relocate a claude-code transcript.
type transcriptRelocation struct {
worktree string // legacy worktree path on disk (realpath-resolved by the relocator)
uuid string // claudeSessionUuid = the transcript filename stem
}
// orchestratorMapping is the mapped orchestrator session plus its transcript
// relocation (claude-code only) and any skip/lossy note.
type orchestratorMapping struct {
projectID string
prefix string
status orchestratorStatus
record domain.SessionRecord // valid when status == orchMapped
transcript *transcriptRelocation
note string
}
// asObject coerces a JSON value that may be an object OR a JSON-encoded string
// into a decoded map, mirroring the legacy reader's double-decode.
func asObject(v any) map[string]any {
switch t := v.(type) {
case map[string]any:
return t
case string:
s := strings.TrimSpace(t)
if s == "" {
return nil
}
var parsed any
if err := json.Unmarshal([]byte(s), &parsed); err == nil {
if m, ok := parsed.(map[string]any); ok {
return m
}
}
}
return nil
}
func asString(v any) string {
if s, ok := v.(string); ok {
return s
}
return ""
}
// isStateVersion2 reports whether a legacy stateVersion marks a V2 record. It
// accepts both the string "2" the legacy writer emits and a numeric 2, since
// JSON numbers decode to float64 through the untyped map.
func isStateVersion2(v any) bool {
switch t := v.(type) {
case string:
return t == "2"
case float64:
return t == 2
}
return false
}
// legacyLifecycle is the decoded session/runtime halves of the V2 lifecycle.
type legacyLifecycle struct {
session map[string]any
runtime map[string]any
}
// extractLifecycle pulls the lifecycle, double-decoding stringified nested
// fields. It prefers the V2 "lifecycle" key, falling back to "statePayload"
// when stateVersion == "2" (mirrors parseLifecycleField).
func extractLifecycle(raw map[string]any) (legacyLifecycle, bool) {
lc := asObject(raw["lifecycle"])
if lc == nil && isStateVersion2(raw["stateVersion"]) {
lc = asObject(raw["statePayload"])
}
if lc == nil {
return legacyLifecycle{}, false
}
return legacyLifecycle{
session: asObject(lc["session"]),
runtime: asObject(lc["runtime"]),
}, true
}
// mapActivityState maps the legacy 8-state enum to a rewrite activity_state
// (issue #247 §2.1). Only non-terminal states reach here (terminal orchestrators
// are skipped upstream), so done/terminated need no mapping.
func mapActivityState(state string) domain.ActivityState {
switch state {
case "working":
return domain.ActivityActive
case "needs_input":
return domain.ActivityWaitingInput
default:
// not_started / idle / detecting / stuck / unknown → idle.
return domain.ActivityIdle
}
}
// resumeID picks the rewrite agent_session_id by harness (issue #247 §2.2).
// codex carries codexModel and any harness may carry restoreFallbackReason in
// the legacy record; neither has a rewrite column (the single agent_session_id
// holds only the resume id), so both are dropped — the importer notes them.
func resumeID(harness string, raw map[string]any) string {
switch harness {
case "claude-code":
return asString(raw["claudeSessionUuid"])
case "codex":
return asString(raw["codexThreadId"])
case "opencode":
return asString(raw["opencodeSessionId"])
default:
return ""
}
}
// mapOrchestratorRecord maps a parsed legacy orchestrator record to a rewrite
// session record. Pure. fileMtime is the last-resort created_at when the record
// carries neither createdAt nor lifecycle.session.startedAt.
func mapOrchestratorRecord(raw map[string]any, projectID, prefix string, fileMtime time.Time) orchestratorMapping {
base := orchestratorMapping{projectID: projectID, prefix: prefix}
lc, _ := extractLifecycle(raw)
state := asString(lc.session["state"])
_, hasTerminatedAt := lc.session["terminatedAt"]
terminatedAtNonNull := hasTerminatedAt && lc.session["terminatedAt"] != nil
// Import ONLY non-terminal, non-terminated orchestrators (gist §6).
if (state != "" && terminalStates[state]) || terminatedAtNonNull {
base.status = orchSkipped
base.note = "orchestrator is terminal (state=" + emptyDash(state) + ")"
return base
}
agent := asString(raw["agent"])
if !migratableHarnesses[agent] {
base.status = orchSkipped
base.note = "harness " + quote(agent) + " is not importable (only claude-code, codex, opencode)"
return base
}
createdAt := firstTime(asString(raw["createdAt"]), asString(lc.session["startedAt"]))
if createdAt.IsZero() {
createdAt = fileMtime
}
activityLastAt := firstTime(asString(lc.session["lastTransitionAt"]), asString(lc.runtime["lastObservedAt"]))
if activityLastAt.IsZero() {
activityLastAt = createdAt
}
updatedAt := firstTime(asString(lc.session["lastTransitionAt"]))
if updatedAt.IsZero() {
updatedAt = createdAt
}
worktree := asString(raw["worktree"])
rec := domain.SessionRecord{
ID: domain.SessionID(prefix + "-orchestrator"),
ProjectID: domain.ProjectID(projectID),
Kind: domain.KindOrchestrator,
Harness: domain.AgentHarness(agent),
DisplayName: asString(raw["displayName"]),
Activity: domain.Activity{
State: mapActivityState(state),
LastActivityAt: activityLastAt,
},
FirstSignalAt: activityLastAt, // backfill mirrors migration 0010 (#247 §2.1)
IsTerminated: false,
Metadata: domain.SessionMetadata{
Branch: asString(raw["branch"]),
WorkspacePath: worktree,
AgentSessionID: resumeID(agent, raw),
Prompt: asString(raw["userPrompt"]),
},
CreatedAt: createdAt,
UpdatedAt: updatedAt,
}
base.status = orchMapped
base.record = rec
// Note resume metadata the single agent_session_id column cannot hold.
var dropped []string
if agent == "codex" {
if m := asString(raw["codexModel"]); m != "" {
dropped = append(dropped, "codexModel "+quote(m)+" dropped (no rewrite column; codex resumes by thread id)")
}
}
if r := asString(raw["restoreFallbackReason"]); r != "" {
dropped = append(dropped, "restoreFallbackReason dropped (forensic only)")
}
base.note = strings.Join(dropped, "; ")
// claude-code orchestrators carry a transcript to relocate (needs both a
// uuid and a worktree to compute source + destination slugs).
if agent == "claude-code" {
if uuid := asString(raw["claudeSessionUuid"]); uuid != "" && worktree != "" {
base.transcript = &transcriptRelocation{worktree: worktree, uuid: uuid}
}
}
return base
}
// resolveOrchestratorPrefix resolves the import prefix: configured sessionPrefix,
// else the first 12 chars of the project id (matching the rewrite's
// resolvedSessionPrefix and the display-prefix convention).
func resolveOrchestratorPrefix(projectID string, pc legacyProjectConfig) string {
if p := strings.TrimSpace(pc.SessionPrefix); p != "" {
return p
}
if len(projectID) <= 12 {
return projectID
}
return projectID[:12]
}
// parseJSONRecord parses JSON; nil on invalid/non-object content.
func parseJSONRecord(content string) map[string]any {
var parsed any
if err := json.Unmarshal([]byte(content), &parsed); err != nil {
return nil
}
if m, ok := parsed.(map[string]any); ok {
return m
}
return nil
}
// findOrchestratorFile locates a project's orchestrator metadata file: the
// sessions-dir record whose raw role == "orchestrator", else the one named
// "{prefix}-orchestrator.json", else the legacy "orchestrator.json". Skips
// 0-byte and "*.corrupt-*" files (issue #2129 §8.1).
func findOrchestratorFile(sessionsDir, prefix string) string {
if sessionsDir == "" {
return ""
}
entries, err := os.ReadDir(sessionsDir)
if err != nil {
return ""
}
var byName string
for _, e := range entries {
name := e.Name()
if e.IsDir() || !strings.HasSuffix(name, ".json") || strings.Contains(name, ".corrupt-") {
continue
}
file := filepath.Join(sessionsDir, name)
content, err := os.ReadFile(file)
if err != nil {
continue
}
trimmed := strings.TrimSpace(string(content))
if trimmed == "" {
continue // 0-byte / reserved id
}
raw := parseJSONRecord(trimmed)
if raw == nil {
continue
}
if asString(raw["role"]) == "orchestrator" {
return file
}
if strings.TrimSuffix(name, ".json") == prefix+"-orchestrator" {
byName = file
}
}
if byName != "" {
return byName
}
// Defensive: the pre-V2 standalone orchestrator file.
legacy := filepath.Join(filepath.Dir(sessionsDir), "orchestrator.json")
if content, err := os.ReadFile(legacy); err == nil && strings.TrimSpace(string(content)) != "" {
return legacy
}
return ""
}
// readOrchestratorMapping reads + maps a project's orchestrator. It returns
// absent when there is no orchestrator file, skipped for terminal/non-importable
// ones, and mapped (with the record and any transcript) otherwise.
func readOrchestratorMapping(sessionsDir, projectID string, pc legacyProjectConfig) orchestratorMapping {
prefix := resolveOrchestratorPrefix(projectID, pc)
file := findOrchestratorFile(sessionsDir, prefix)
if file == "" {
return orchestratorMapping{projectID: projectID, prefix: prefix, status: orchAbsent}
}
content, err := os.ReadFile(file)
if err != nil {
return orchestratorMapping{projectID: projectID, prefix: prefix, status: orchAbsent}
}
raw := parseJSONRecord(strings.TrimSpace(string(content)))
if raw == nil {
return orchestratorMapping{projectID: projectID, prefix: prefix, status: orchAbsent}
}
mtime := time.Unix(0, 0).UTC()
if info, err := os.Stat(file); err == nil {
mtime = info.ModTime().UTC()
}
return mapOrchestratorRecord(raw, projectID, prefix, mtime)
}
// firstTime returns the first RFC3339-parseable timestamp, or zero time.
func firstTime(candidates ...string) time.Time {
for _, c := range candidates {
if c == "" {
continue
}
if t, err := time.Parse(time.RFC3339, c); err == nil {
return t.UTC()
}
}
return time.Time{}
}
func emptyDash(s string) string {
if s == "" {
return "?"
}
return s
}
func quote(s string) string {
if s == "" {
return `"?"`
}
return `"` + s + `"`
}

View File

@ -1,171 +0,0 @@
package legacyimport
import (
"testing"
"time"
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
)
func mtime() time.Time { return time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC) }
func TestMapOrchestrator_ClaudeMapped(t *testing.T) {
raw := map[string]any{
"agent": "claude-code",
"role": "orchestrator",
"branch": "main",
"worktree": "/legacy/wt",
"userPrompt": "orchestrate",
"displayName": "Orch",
"claudeSessionUuid": "uuid-123",
"createdAt": "2026-01-01T00:00:00Z",
"lifecycle": map[string]any{
"session": map[string]any{
"state": "working",
"lastTransitionAt": "2026-01-01T01:00:00Z",
},
},
}
m := mapOrchestratorRecord(raw, "proj", "proj", mtime())
if m.status != orchMapped {
t.Fatalf("status = %s, want mapped (note=%q)", m.status, m.note)
}
r := m.record
if r.ID != "proj-orchestrator" || r.Kind != domain.KindOrchestrator {
t.Fatalf("id/kind = %s/%s", r.ID, r.Kind)
}
if r.Activity.State != domain.ActivityActive {
t.Fatalf("activity = %s, want active", r.Activity.State)
}
if r.Metadata.AgentSessionID != "uuid-123" {
t.Fatalf("agentSessionID = %q, want uuid-123", r.Metadata.AgentSessionID)
}
if r.CreatedAt.Format(time.RFC3339) != "2026-01-01T00:00:00Z" {
t.Fatalf("createdAt = %s", r.CreatedAt)
}
if m.transcript == nil || m.transcript.uuid != "uuid-123" || m.transcript.worktree != "/legacy/wt" {
t.Fatalf("transcript = %+v", m.transcript)
}
}
func TestMapOrchestrator_DoubleDecodedLifecycle(t *testing.T) {
// lifecycle stored as a JSON-encoded string (legacy double-encoding).
raw := map[string]any{
"agent": "codex",
"worktree": "/wt",
"createdAt": "2026-01-01T00:00:00Z",
"lifecycle": `{"session":{"state":"needs_input","lastTransitionAt":"2026-01-01T02:00:00Z"}}`,
"codexThreadId": "thread-9",
"codexModel": "o3",
}
m := mapOrchestratorRecord(raw, "p", "pre", mtime())
if m.status != orchMapped {
t.Fatalf("status = %s", m.status)
}
if m.record.Activity.State != domain.ActivityWaitingInput {
t.Fatalf("state = %s, want waiting_input", m.record.Activity.State)
}
if m.record.Metadata.AgentSessionID != "thread-9" {
t.Fatalf("agentSessionID = %q, want thread-9", m.record.Metadata.AgentSessionID)
}
if m.transcript != nil {
t.Fatal("codex must not carry a transcript relocation")
}
if m.note == "" {
t.Fatal("expected a note about dropped codexModel")
}
}
func TestMapOrchestrator_StatePayloadFallback(t *testing.T) {
raw := map[string]any{
"agent": "opencode",
"stateVersion": "2",
"statePayload": map[string]any{"session": map[string]any{"state": "idle"}},
"opencodeSessionId": "oc-1",
}
m := mapOrchestratorRecord(raw, "p", "p", mtime())
if m.status != orchMapped || m.record.Activity.State != domain.ActivityIdle {
t.Fatalf("mapping = %+v", m)
}
if m.record.Metadata.AgentSessionID != "oc-1" {
t.Fatalf("agentSessionID = %q", m.record.Metadata.AgentSessionID)
}
}
func TestMapOrchestrator_StatePayloadFallbackNumericVersion(t *testing.T) {
// stateVersion as a JSON number (decodes to float64) must still trigger the
// statePayload fallback.
raw := map[string]any{
"agent": "opencode",
"stateVersion": float64(2),
"statePayload": map[string]any{"session": map[string]any{"state": "needs_input"}},
"opencodeSessionId": "oc-2",
}
m := mapOrchestratorRecord(raw, "p", "p", mtime())
if m.status != orchMapped || m.record.Activity.State != domain.ActivityWaitingInput {
t.Fatalf("mapping = %+v", m)
}
}
func TestMapOrchestrator_SkipTerminal(t *testing.T) {
for _, st := range []string{"done", "terminated"} {
raw := map[string]any{
"agent": "claude-code",
"lifecycle": map[string]any{"session": map[string]any{"state": st}},
}
if m := mapOrchestratorRecord(raw, "p", "p", mtime()); m.status != orchSkipped {
t.Fatalf("state %s: status = %s, want skipped", st, m.status)
}
}
}
func TestMapOrchestrator_SkipTerminatedAt(t *testing.T) {
raw := map[string]any{
"agent": "claude-code",
"lifecycle": map[string]any{"session": map[string]any{
"state": "working", "terminatedAt": "2026-01-01T00:00:00Z",
}},
}
if m := mapOrchestratorRecord(raw, "p", "p", mtime()); m.status != orchSkipped {
t.Fatalf("status = %s, want skipped (terminatedAt set)", m.status)
}
}
func TestMapOrchestrator_SkipAiderAndUnknown(t *testing.T) {
for _, agent := range []string{"aider", "grok", "", "bogus"} {
raw := map[string]any{
"agent": agent,
"lifecycle": map[string]any{"session": map[string]any{"state": "working"}},
}
if m := mapOrchestratorRecord(raw, "p", "p", mtime()); m.status != orchSkipped {
t.Fatalf("agent %q: status = %s, want skipped", agent, m.status)
}
}
}
func TestMapOrchestrator_TimestampFallbacks(t *testing.T) {
// No createdAt/startedAt → file mtime; no lastTransitionAt → createdAt.
raw := map[string]any{
"agent": "claude-code",
"lifecycle": map[string]any{"session": map[string]any{"state": "idle"}},
}
m := mapOrchestratorRecord(raw, "p", "p", mtime())
if !m.record.CreatedAt.Equal(mtime()) {
t.Fatalf("createdAt = %s, want file mtime", m.record.CreatedAt)
}
if !m.record.Activity.LastActivityAt.Equal(mtime()) {
t.Fatalf("activityLastAt = %s, want createdAt fallback", m.record.Activity.LastActivityAt)
}
}
func TestResolveOrchestratorPrefix(t *testing.T) {
if got := resolveOrchestratorPrefix("short", legacyProjectConfig{}); got != "short" {
t.Fatalf("prefix = %q, want short", got)
}
if got := resolveOrchestratorPrefix("averylongprojectid", legacyProjectConfig{}); got != "averylongpro" {
t.Fatalf("prefix = %q, want first 12 chars", got)
}
if got := resolveOrchestratorPrefix("proj", legacyProjectConfig{SessionPrefix: "custom"}); got != "custom" {
t.Fatalf("prefix = %q, want custom", got)
}
}

View File

@ -1,8 +1,6 @@
// Package legacyimport reads the legacy Agent Orchestrator flat-file store
// (~/.agent-orchestrator) read-only and ports it into the rewrite's native
// SQLite store. It maps the legacy project registry, per-project settings, and
// each project's single live orchestrator session, relocating the orchestrator's
// Claude transcript so a claude-code orchestrator resumes with context.
// SQLite store. It maps the legacy project registry and per-project settings.
//
// This is the Go port of the legacy-side TypeScript reader (AgentWrapper PR
// #2144 / issue #2129); the field mapping is ReverbCode issue #247. The legacy
@ -13,7 +11,6 @@ package legacyimport
import (
"os"
"path/filepath"
"strings"
)
// userHomeDir is indirected so tests can pin the home directory without mutating
@ -30,16 +27,6 @@ func DefaultLegacyRootDir() string {
return filepath.Join(home, ".agent-orchestrator")
}
// defaultClaudeProjectsDir returns ~/.claude/projects, the directory Claude Code
// buckets per-cwd transcripts under. "" when home cannot be resolved.
func defaultClaudeProjectsDir() string {
home, err := userHomeDir()
if err != nil {
return ""
}
return filepath.Join(home, ".claude", "projects")
}
// globalConfigPath is the legacy global config file, root/config.yaml.
func globalConfigPath(root string) string {
return filepath.Join(root, "config.yaml")
@ -54,42 +41,3 @@ func preferencesPath(root string) string {
func registeredPath(root string) string {
return filepath.Join(root, "portfolio", "registered.json")
}
// projectSessionsDir locates a project's sessions directory, accepting both the
// current layout (root/projects/{id}/sessions) and the older hashed layout
// (root/{hash}-{id}/sessions). It returns "" when neither exists.
func projectSessionsDir(root, projectID string) string {
primary := filepath.Join(root, "projects", projectID, "sessions")
if isDir(primary) {
return primary
}
// Older layout: a top-level "{hash}-{id}" directory. Match by the "-{id}"
// suffix; the id itself may contain "-", but the hashed form always prefixes
// it, so a suffix match is the faithful locator the legacy reader used.
entries, err := os.ReadDir(root)
if err != nil {
return ""
}
suffix := "-" + projectID
for _, e := range entries {
if !e.IsDir() {
continue
}
name := e.Name()
if name == "projects" || name == "portfolio" {
continue
}
if strings.HasSuffix(name, suffix) {
cand := filepath.Join(root, name, "sessions")
if isDir(cand) {
return cand
}
}
}
return ""
}
func isDir(path string) bool {
info, err := os.Stat(path)
return err == nil && info.IsDir()
}

View File

@ -5,8 +5,15 @@ import (
"time"
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
yaml "gopkg.in/yaml.v3"
)
// nonNilNode returns a non-nil *yaml.Node for struct fields that are captured
// as raw nodes (tracker, scm, etc.), used to trigger the "dropped" note path.
func nonNilNode() *yaml.Node {
return &yaml.Node{Kind: yaml.ScalarNode, Value: "x"}
}
func TestMapPermission(t *testing.T) {
cases := []struct {
in string

View File

@ -0,0 +1,67 @@
// 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})
}

View File

@ -0,0 +1,76 @@
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")
}
}

View File

@ -1,60 +0,0 @@
package store
import (
"context"
"fmt"
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
)
// ImportSession inserts a session with a caller-supplied id and num, bypassing
// CreateSession's per-project num generation so the legacy importer can preserve
// a verbatim id (e.g. "{prefix}-orchestrator", num 0). It is idempotent: an id
// that already exists is left untouched and inserted=false is returned, so a
// re-run of the importer never clobbers a row the daemon may since have evolved.
//
// Like CreateSession this is a single INSERT under writeMu; the ON CONFLICT
// guard makes the existence check and the insert atomic on the writer
// connection. It uses raw ExecContext to attach the ON CONFLICT clause the
// generated InsertSession query does not carry (the same raw-exec approach
// DeleteSession uses to work around sqlc's DELETE handling).
func (s *Store) ImportSession(ctx context.Context, rec domain.SessionRecord, num int64) (bool, error) {
activity := normalActivity(rec.Activity, rec.CreatedAt)
s.writeMu.Lock()
defer s.writeMu.Unlock()
res, err := s.writeDB.ExecContext(ctx, `
INSERT INTO sessions (
id, project_id, num, issue_id, kind, harness, display_name,
activity_state, activity_last_at, first_signal_at, is_terminated,
branch, workspace_path, runtime_handle_id, agent_session_id, prompt,
created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO NOTHING`,
rec.ID,
rec.ProjectID,
num,
rec.IssueID,
rec.Kind,
rec.Harness,
rec.DisplayName,
activity.State,
activity.LastActivityAt,
timeToNullTime(rec.FirstSignalAt),
rec.IsTerminated,
rec.Metadata.Branch,
rec.Metadata.WorkspacePath,
rec.Metadata.RuntimeHandleID,
rec.Metadata.AgentSessionID,
rec.Metadata.Prompt,
rec.CreatedAt,
rec.UpdatedAt,
)
if err != nil {
return false, fmt.Errorf("import session %s: %w", rec.ID, err)
}
n, err := res.RowsAffected()
if err != nil {
return false, fmt.Errorf("import session %s: rows affected: %w", rec.ID, err)
}
return n > 0, nil
}

View File

@ -1,58 +0,0 @@
package store_test
import (
"context"
"testing"
"time"
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
)
func TestImportSessionVerbatimAndIdempotent(t *testing.T) {
s := newTestStore(t)
ctx := context.Background()
seedProject(t, s, "mer")
now := time.Now().UTC().Truncate(time.Second)
rec := domain.SessionRecord{
ID: "mer-orchestrator",
ProjectID: "mer",
Kind: domain.KindOrchestrator,
Harness: domain.HarnessClaudeCode,
Activity: domain.Activity{State: domain.ActivityIdle, LastActivityAt: now},
Metadata: domain.SessionMetadata{AgentSessionID: "uuid-1", Prompt: "go"},
CreatedAt: now,
UpdatedAt: now,
}
inserted, err := s.ImportSession(ctx, rec, 0)
if err != nil || !inserted {
t.Fatalf("first import: inserted=%v err=%v", inserted, err)
}
got, ok, err := s.GetSession(ctx, "mer-orchestrator")
if err != nil || !ok {
t.Fatalf("get: ok=%v err=%v", ok, err)
}
if got.Kind != domain.KindOrchestrator || got.Metadata.AgentSessionID != "uuid-1" {
t.Fatalf("imported row = %+v", got)
}
// Re-import is a no-op: the existing row is left untouched.
inserted, err = s.ImportSession(ctx, rec, 0)
if err != nil {
t.Fatalf("re-import err: %v", err)
}
if inserted {
t.Fatal("re-import reported inserted=true; want false (idempotent skip)")
}
// num=0 leaves the next store-generated session at num=1 with no collision.
w, err := s.CreateSession(ctx, sampleRecord("mer"))
if err != nil {
t.Fatalf("create worker: %v", err)
}
if w.ID != "mer-1" {
t.Fatalf("worker id = %s, want mer-1 (orchestrator at num 0 must not collide)", w.ID)
}
}

View File

@ -0,0 +1,371 @@
# Dashboard-Surfaced Legacy Import Offer — Implementation Plan
> **For agentic workers:** implement task-by-task; each task ends with a green test + a commit. Steps use `- [ ]`.
**Goal:** Replace the first-boot CLI import prompt with a daemon API (`GET`/`POST /api/v1/import`) and a dashboard banner. **Scope: projects + per-project settings only** (no orchestrator sessions, no transcript relocation), a faithful port of `aoagents/ReverbCode` PR #320.
**Architecture:** `ao start` becomes headless. The `legacyimport` engine is simplified to import projects only. A new `service/importer` wraps it with a detection probe (`Status`) and a trigger (`Run`) that writes through the daemon's shared store. An HTTP controller exposes both; the Electron renderer polls status and shows an `ImportOffer` banner. Because startup is now headless, the Electron main process also gains always-on discovery of a daemon it didn't spawn.
**Tech Stack:** Go (chi, sqlc, code-first OpenAPI via `cmd/genspec`), React + @tanstack/react-query + openapi-fetch, Electron, vitest.
## Global Constraints
- Module path: `github.com/aoagents/agent-orchestrator/backend`.
- **No em dashes** anywhere (prose, comments, copy). Use `.`/`,`/`(...)`.
- `openapi.yaml` and `frontend/src/api/schema.ts` are **generated** (never hand-edit); change the Go reflection source then run `npm run api:spec && npm run api:ts`.
- All app state under `~/.ao` only (already enforced; don't regress).
- Branch: `ao/agent-orchestrator-3/import-offer` (sibling of `…/root`). PR target `main` on `AgentWrapper/agent-orchestrator`.
- **Reference:** this repo sits exactly at PR #320's base, so the change set lines up 1:1 with that PR. Where a step says "PR-verbatim," copy the PR's content for that file.
- Commit immediately after each task (AO worktrees can be force-removed).
## File Structure
| File | Status | Responsibility |
| ----------------------------------------------------------------------------- | ---------- | ---------------------------------------------------------------------- |
| `backend/internal/legacyimport/orchestrator.go` (+`_test.go`) | delete | orchestrator-session mapping/import (out of scope) |
| `backend/internal/legacyimport/claude.go` (+`_test.go`) | delete | Claude transcript relocation (out of scope) |
| `backend/internal/storage/sqlite/store/session_import_store.go` (+`_test.go`) | delete | `ImportSession` (only used by orchestrator import) |
| `backend/internal/legacyimport/importer.go` | modify | trim `Store`/`Options`/`Report`; drop orchestrator loop; add `quote()` |
| `backend/internal/legacyimport/config.go` | modify | `Repo` -> `*yaml.Node`; tolerate `*yaml.TypeError` |
| `backend/internal/legacyimport/paths.go` | modify | drop `defaultClaudeProjectsDir` + `projectSessionsDir` |
| `backend/internal/legacyimport/{importer,config,project}_test.go` | modify | projects-only assertions |
| `backend/internal/cli/import.go` | modify | drop orchestrator/transcript copy + summary lines |
| `backend/internal/service/importer/importer.go` (+`_test.go`) | create | `Status`/`Run` over the daemon store |
| `backend/internal/httpd/controllers/imports.go` (+`_test.go`) | create | `GET`/`POST /import`, 501 on nil svc |
| `backend/internal/httpd/controllers/dto.go` | modify | `ImportStatusResponse`, `ImportRunResponse` |
| `backend/internal/httpd/apispec/specgen/build.go` | modify | `import` tag + ops + schemaNames |
| `backend/internal/httpd/api.go` | modify | `APIDeps.Import`, `API.imports`, wire + Register |
| `backend/internal/daemon/daemon.go` | modify | `Import: importsvc.New(...)` |
| `backend/internal/cli/start.go` | modify | delete `maybeFirstBootImport` + 2 imports |
| `backend/internal/httpd/apispec/openapi.yaml` | regenerate | — |
| `frontend/src/api/schema.ts` | regenerate | — |
| `frontend/src/shared/daemon-discovery.ts` (+`.test.ts`) | modify | `shouldAdoptDiscoveredPort` |
| `frontend/src/main.ts` | modify | external daemon discovery loop |
| `frontend/src/renderer/hooks/useImportStatus.ts` | create | `useImportStatus`/`useRunImport` |
| `frontend/src/renderer/components/ImportOffer.tsx` (+`.test.tsx`) | create | the banner |
| `frontend/src/renderer/components/SessionsBoard.tsx` | modify | render `<ImportOffer/>` |
---
## Task 1: Simplify the import engine to projects-only
**Files:** Delete `legacyimport/orchestrator.go`, `orchestrator_test.go`, `claude.go`, `claude_test.go`, `storage/sqlite/store/session_import_store.go`, `session_import_store_test.go`. Modify `legacyimport/importer.go`, `config.go`, `paths.go`, and their tests; `cli/import.go`.
**Interfaces:**
- Produces: trimmed `legacyimport.Store` (`GetProject`, `UpsertProject` only), `legacyimport.Options{Root, DryRun, Now, RepoOriginURL}`, `legacyimport.Report{DryRun, ProjectsImported, ProjectsSkipped, Notes}`. **Tasks 2 and 3 depend on this `Report` shape.**
- [ ] **Step 1: Delete the orchestrator/transcript files.**
```bash
git rm backend/internal/legacyimport/orchestrator.go backend/internal/legacyimport/orchestrator_test.go \
backend/internal/legacyimport/claude.go backend/internal/legacyimport/claude_test.go \
backend/internal/storage/sqlite/store/session_import_store.go \
backend/internal/storage/sqlite/store/session_import_store_test.go
```
- [ ] **Step 2: Trim `legacyimport/importer.go`.**
- `Store` interface: keep only `GetProject` and `UpsertProject` (drop `GetSession`, `ImportSession`).
- `Options`: drop `DataDir` and `ClaudeProjectsDir`; keep `Root`, `DryRun`, `Now`, `RepoOriginURL`.
- `Report`: reduce to `{DryRun bool, ProjectsImported int, ProjectsSkipped int, Notes []string}`.
- In `Run`, delete the orchestrator block (the `sessionsDir`/`readOrchestratorMapping`/`switch mapping.status` that follows `importProject`), so the loop body ends after `importProject`.
- Delete the `importOrchestrator` function.
- Add the `quote()` helper used by the project-id note:
```go
// quote wraps s in double quotes for note messages, rendering an empty string as
// "?" so a missing value is still legible.
func quote(s string) string {
if s == "" {
return `"?"`
}
return `"` + s + `"`
}
```
- [ ] **Step 3: `legacyimport/config.go` parsing-robustness fix (PR-bundled, improves project import).**
- Add `"errors"` import.
- Change the project config's `Repo string \`yaml:"repo"\``field to`Repo \*yaml.Node \`yaml:"repo"\`` with a comment that it is captured but never consumed (origin is re-resolved from the repo path).
- In `loadLegacyConfig`, when `yaml.Unmarshal` errors, keep the partial decode on a `*yaml.TypeError` instead of failing the whole registry:
```go
var typeErr *yaml.TypeError
if !errors.As(err, &typeErr) {
return legacyConfig{}, fmt.Errorf("parse legacy config.yaml: %w", err)
}
```
- [ ] **Step 4: `legacyimport/paths.go`.** Delete `defaultClaudeProjectsDir` and `projectSessionsDir`; drop the now-unused `"strings"` import; update the package doc to "maps the legacy project registry and per-project settings."
- [ ] **Step 5: Update tests.** Rewrite `importer_test.go` (drop `sessions` from `fakeStore`, drop orchestrator/transcript assertions, `writeLegacyRoot`/`runOpts` take only `root`), `config_test.go`, and `project_test.go` (add the `nonNilNode()` helper returning a populated `*yaml.Node`) per the PR.
- [ ] **Step 6: `cli/import.go`.** Update the command `Short`/`Long` to say "projects" only, the confirm prompt to "Import projects from %s?", and delete the `Orchestrators:`/`Transcripts:` lines from `writeImportSummary`. Remove the `DataDir` field from the `legacyimport.Options{...}` literal it builds.
- [ ] **Step 7:** `cd backend && go build ./... && go test ./internal/legacyimport/... ./internal/cli/... ./internal/storage/sqlite/...` → expect PASS.
- [ ] **Step 8:** Commit: `refactor(legacyimport): scope import to projects + settings only`
---
## Task 2: Import service (`service/importer`)
**Files:** Create `backend/internal/service/importer/importer.go`, `…/importer_test.go`
**Interfaces:**
- Consumes: trimmed `legacyimport.Store`, `legacyimport.Run`, `legacyimport.HasLegacyData`, `legacyimport.DefaultLegacyRootDir`, `legacyimport.Report`, `legacyimport.Options{Root}`; `domain.ProjectRecord`.
- Produces: `importer.Status{Available bool; LegacyRoot string}`, `importer.Service` (`Status(ctx)`, `Run(ctx)`), `importer.Deps{Store, Root}`, `importer.New(Deps) *Manager`. **Controller (Task 3) and daemon (Task 5) depend on these exact names.**
- [ ] **Step 1: Write `importer.go`** (projects-only: no `DataDir`):
```go
// Package importer is the controller-facing service for the legacy-AO import.
// It wraps the internal/legacyimport engine with the two operations the
// dashboard needs: a detection probe ("is a legacy install available?") and a
// trigger that runs the import through the live daemon's store, so the daemon
// stays the sole writer. The engine is reused verbatim; this package adds no
// import logic of its own, only the daemon-side detection and the store wiring.
package importer
import (
"context"
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
"github.com/aoagents/agent-orchestrator/backend/internal/legacyimport"
)
// Store is the storage slice the import service needs: the legacy importer's
// write surface plus a project listing for the "already imported" check.
// *sqlite.Store satisfies it, so the daemon passes its single shared store and
// the import runs through the same write path as every other mutation.
type Store interface {
legacyimport.Store
ListProjects(ctx context.Context) ([]domain.ProjectRecord, error)
}
// Status reports whether a legacy AO install is available to import. Available
// is true only when legacy data is present AND the rewrite database holds no
// projects yet (the first-boot condition); a populated database is assumed
// already imported (or started fresh on purpose), so the offer is not surfaced.
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 and config.
type Manager struct {
store Store
root string
}
var _ Service = (*Manager)(nil)
// New constructs the import service. An empty Root falls back to the default
// legacy root so callers that don't override it get the standard location.
func New(deps Deps) *Manager {
root := deps.Root
if root == "" {
root = legacyimport.DefaultLegacyRootDir()
}
return &Manager{store: deps.Store, root: root}
}
// Status reports import availability without touching legacy or rewrite data
// beyond a project count. It never errors on a missing legacy store; that is
// simply "not available".
func (m *Manager) Status(ctx context.Context) (Status, error) {
st := Status{LegacyRoot: m.root}
if !legacyimport.HasLegacyData(m.root) {
return st, nil
}
projects, err := m.store.ListProjects(ctx)
if err != nil {
return Status{}, err
}
st.Available = len(projects) == 0
return st, nil
}
// Run executes the import through the daemon's store. It is idempotent: the
// engine skips rows that already exist, so a re-run (or a run against a
// partially-populated database) is safe and never overwrites. 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`** PR-verbatim: `fakeStore` implements only `GetProject`/`UpsertProject`/`ListProjects` (the trimmed `Store` needs nothing else); tests `TestStatus_NoLegacyData`, `TestStatus_LegacyPresentEmptyDB`, `TestStatus_AlreadyPopulated`, `TestStatus_ListError`, `TestRun_ImportsThenStatusFlipsUnavailable`, `TestNew_DefaultsRoot`.
- [ ] **Step 3:** `cd backend && go test ./internal/service/importer/...` → expect PASS.
- [ ] **Step 4:** Commit: `feat(importer): import service over daemon store (status + run)`
---
## Task 3: HTTP controller + DTOs
**Files:** Create `backend/internal/httpd/controllers/imports.go`, `…/imports_test.go`; Modify `…/controllers/dto.go`
**Interfaces:**
- Consumes: `importsvc.Status`, `legacyimport.Report`, `apispec.NotImplemented`, `envelope.WriteJSON`/`WriteError`, `httpd.NewRouterWithControl(cfg, log, termMgr, APIDeps, ControlDeps)`.
- Produces: `controllers.ImportService`, `controllers.ImportController{Svc}`, `controllers.ImportStatusResponse`, `controllers.ImportRunResponse`. **api.go (Task 5) and specgen (Task 4) depend on these.**
- [ ] **Step 1:** Add to `dto.go` (add the `legacyimport` import):
```go
// ImportStatusResponse is the body of GET /api/v1/import: whether a legacy AO
// install is available to import, and the root the daemon would read from.
type ImportStatusResponse struct {
Available bool `json:"available"`
LegacyRoot string `json:"legacyRoot"`
}
// ImportRunResponse is the body of POST /api/v1/import: the structured outcome
// of the import run (counts + notes), reused verbatim from the import engine.
type ImportRunResponse struct {
Report legacyimport.Report `json:"report"`
}
```
- [ ] **Step 2:** Create `imports.go` PR-verbatim (`ImportService` interface, `ImportController`, `Register`, `status`, `run`; nil `Svc` answers `apispec.NotImplemented`).
- [ ] **Step 3:** Create `imports_test.go` PR-verbatim (`fakeImportService`, `newImportTestServer` using `httpd.NewRouterWithControl(config.Config{}, log, nil, httpd.APIDeps{Import: svc}, httpd.ControlDeps{})`, tests `TestImportAPI_Status/StatusError/Run/RunError`). The `doRequest` helper already exists in the `controllers_test` package.
- [ ] **Step 4:** `go build ./...` fails until Task 5 wires `APIDeps.Import` (expected). Parse-check now: `gofmt -l internal/httpd/controllers/imports.go`. Test gate runs at end of Task 5.
- [ ] **Step 5:** Commit: `feat(httpd): import controller + DTOs (GET/POST /api/v1/import)`
---
## Task 4: OpenAPI spec generation
**Files:** Modify `backend/internal/httpd/apispec/specgen/build.go`; regenerate `openapi.yaml` + `frontend/src/api/schema.ts`
- [ ] **Step 1:** In `build.go` add, PR-verbatim: the `import` tag (in `Tags`), the schemaName mappings (`ControllersImportStatusResponse`->`ImportStatusResponse`, `ControllersImportRunResponse`->`ImportRunResponse`, `LegacyimportReport`->`ImportReport`), the `importOperations()` func (2 ops), and `ops = append(ops, importOperations()...)`.
- [ ] **Step 2:** Regenerate: `npm run api:spec` then `npm run api:ts`.
- [ ] **Step 3:** Inspect the diff. The generated `ImportReport` schema must be the projects-only 4-field shape (`dryRun`, `projectsImported`, `projectsSkipped`, `notes`), matching the PR's `schema.ts` verbatim. If orchestrator fields appear, Task 1's `Report` trim was incomplete; fix it before continuing.
- [ ] **Step 4:** `cd backend && go test ./internal/httpd/apispec/...` (route<->spec parity) → expect PASS.
- [ ] **Step 5:** Commit: `feat(apispec): describe /api/v1/import; regenerate openapi + schema.ts`
---
## Task 5: Wire controller into API + daemon
**Files:** Modify `backend/internal/httpd/api.go`, `backend/internal/daemon/daemon.go`
- [ ] **Step 1:** `api.go` edits:
- `APIDeps`: add `Import controllers.ImportService` (after `NotificationStream`).
- `API` struct: add `imports *controllers.ImportController` (after `notifications`).
- `NewAPI`: add `imports: &controllers.ImportController{Svc: deps.Import},` (after the `notifications:` line).
- `Register` timeout group: add `a.imports.Register(r)` (after `a.notifications.Register(r)`).
- [ ] **Step 2:** `daemon.go` edits:
- Add import: `importsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/importer"`.
- In the `httpd.APIDeps{...}` literal, add: `Import: importsvc.New(importsvc.Deps{Store: store}),` (projects-only: **no** `DataDir`).
- [ ] **Step 3:** `cd backend && go build ./... && go test ./internal/httpd/... ./internal/service/importer/...` → expect PASS (gate for Task 3's tests too).
- [ ] **Step 4:** Commit: `feat(daemon): mount import service on the API`
---
## Task 6: Make `ao start` headless
**Files:** Modify `backend/internal/cli/start.go`
- [ ] **Step 1:** Delete the `maybeFirstBootImport` method and its call. Replace the call + the comment above it with:
```go
// `ao start` is headless: it only launches the daemon. Detecting a legacy AO
// install and offering to import it is the dashboard's job (it polls
// GET /api/v1/import and POSTs to run the import through the live daemon).
// `ao import` remains for explicit offline imports.
```
- [ ] **Step 2:** Remove now-unused imports `…/internal/legacyimport` and `…/internal/storage/sqlite`. Keep `config`. Leave `cli/import.go`, `confirm`, `stdinIsInteractive`, `writeImportSummary` untouched.
- [ ] **Step 3:** `cd backend && go build ./... && go test ./internal/cli/...` → expect PASS.
- [ ] **Step 4:** Commit: `refactor(cli): ao start no longer prompts; import moves to the dashboard`
---
## Task 7: External daemon discovery (Electron main)
**Files:** Modify `frontend/src/shared/daemon-discovery.ts`, `…/daemon-discovery.test.ts`, `frontend/src/main.ts`
**Interfaces:** Produces `shouldAdoptDiscoveredPort(current: DaemonStatus, discoveredPort: number): boolean`.
- [ ] **Step 1 (TDD):** Add the `shouldAdoptDiscoveredPort` describe block to `daemon-discovery.test.ts` (PR-verbatim). Run `npm --prefix frontend run test -- daemon-discovery` → expect FAIL (not exported).
- [ ] **Step 2:** Add `shouldAdoptDiscoveredPort` + the `DaemonStatus` type import to `daemon-discovery.ts` (PR-verbatim). Re-run → expect PASS.
- [ ] **Step 3:** In `main.ts`: import `shouldAdoptDiscoveredPort`, add `EXTERNAL_DISCOVERY_POLL_MS = 1_000`, `externalDiscoveryTimer`, `discoverExternalDaemonOnce()`, `startExternalDaemonDiscovery()` (PR-verbatim); call `startExternalDaemonDiscovery()` in `app.whenReady().then(...)` and clear the timer in `before-quit`. Skip the prettier-only reflows the PR carries.
- [ ] **Step 4:** `npm --prefix frontend run typecheck` → expect PASS.
- [ ] **Step 5:** Commit: `feat(electron): discover an externally-started daemon from running.json`
---
## Task 8: Import status hooks
**Files:** Create `frontend/src/renderer/hooks/useImportStatus.ts`
**Interfaces:** Consumes `apiClient`/`apiErrorMessage` (`renderer/lib/api-client`), `workspaceQueryKey` (`renderer/hooks/useWorkspaceQuery`, value `["workspaces"]`). Produces `useImportStatus()`, `useRunImport()`, `importStatusQueryKey`, types `ImportStatus`/`ImportReport`.
- [ ] **Step 1:** Create the file PR-verbatim (`ImportReport` TS type = `{projectsImported, projectsSkipped, notes?}`). `useImportStatus` polls every 30s with `throwOnError: false`; `useRunImport` invalidates `importStatusQueryKey` + `workspaceQueryKey` on success.
- [ ] **Step 2:** `npm --prefix frontend run typecheck` → expect PASS.
- [ ] **Step 3:** Commit: `feat(renderer): useImportStatus / useRunImport hooks`
---
## Task 9: ImportOffer banner
**Files:** Create `frontend/src/renderer/components/ImportOffer.tsx`, `…/ImportOffer.test.tsx`
- [ ] **Step 1 (TDD):** Create `ImportOffer.test.tsx` PR-verbatim (mocks `../lib/api-client`; asserts offer-shown/hidden/accept/decline/error). Run `npm --prefix frontend run test -- ImportOffer` → expect FAIL (component missing).
- [ ] **Step 2:** Create `ImportOffer.tsx` PR-verbatim. Heading "Import projects from your earlier AO?"; body copy "Importing brings in your projects. Your old files are never modified, and you can do this later instead." (projects-only, no orchestrator mention). Built from `ui/button` (`primary`/`ghost`, size `sm`). Re-run → expect PASS.
- [ ] **Step 3:** Commit: `feat(renderer): ImportOffer dashboard banner`
---
## Task 10: Render the banner on the board
**Files:** Modify `frontend/src/renderer/components/SessionsBoard.tsx`
- [ ] **Step 1:** Add `import { ImportOffer } from "./ImportOffer";` and, directly under the `<DashboardSubhead .../>` line, insert:
```tsx
{
/* First-run legacy-AO import opt-in. Renders only when the daemon
reports an importable install, and only on the top-level board. */
}
{
!projectId && <ImportOffer />;
}
```
- [ ] **Step 2:** `npm --prefix frontend run typecheck && npm --prefix frontend run test` → expect PASS.
- [ ] **Step 3:** Commit: `feat(renderer): surface ImportOffer on the dashboard board`
---
## Task 11: 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.
- [ ] Confirm `git status` shows **no** uncommitted drift in `openapi.yaml`/`schema.ts` after a fresh `npm run api:spec && npm run api:ts`.
- [ ] **Full build** (per the build-verification rule; rollup tree-shaking can hide missing emits): run the frontend production build.
- [ ] `ao preview` the dashboard against a daemon pointed at a seeded `~/.agent-orchestrator` legacy root with an empty rewrite DB; verify the banner appears, Import imports the projects + retires the banner, Not now dismisses.
---
## Self-Review
**Spec coverage:** ✅ engine simplified to projects-only (T1); `service/importer` status+run (T2); `GET`/`POST /api/v1/import` + 501 (T3); OpenAPI + schema.ts regen (T4); api/daemon wiring (T5); headless `ao start` (T6); external daemon discovery (T7); `useImportStatus`/`useRunImport` (T8); `ImportOffer` + board render (T9/T10). Matches PR #320 1:1.
**Placeholder scan:** none — every new file has full source or is "PR-verbatim" for an existing PR file; every edit names the symbol and location.
**Type consistency:** `importer.Deps{Store, Root}` (T2) ↔ daemon `importsvc.New(importsvc.Deps{Store: store})` (T5) ✅ (no `DataDir` anywhere). `legacyimport.Report` 4-field shape (T1) ↔ `ImportRunResponse` (T3) ↔ generated `ImportReport` (T4) ↔ `ImportReport` TS type (T8) ✅. `controllers.ImportService` (T3) ↔ `APIDeps.Import` (T5) ✅. `importStatusQueryKey`/`workspaceQueryKey` (T8) ↔ `ImportOffer` (T9) ✅. `Status{Available, LegacyRoot}` consistent across service/controller/DTO/schema ✅.
**Cross-task coupling to watch:** (a) Task 3's controller test only goes green after Task 5 wires `APIDeps.Import` (gate = end of Task 5); (b) Task 4 must run after `dto.go` (Task 3) exists so the reflector sees the new types; (c) Task 4 Step 3 is the guard that Task 1's `Report` trim actually took.

View File

@ -0,0 +1,706 @@
# 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`.

View File

@ -0,0 +1,269 @@
# Dashboard Legacy-Migration Popup + `app-state.json` Marker: Design
> **Status:** ready for plan. Grounded against `upstream/main` @ `514946fd8`
> (`feat(cli): ao start fetches + opens the desktop app … (#2201)`) on 2026-06-26.
> Every "current state" claim carries a `file:line` reference.
>
> Builds on the marker concept from the `ao start` bootstrapper spec
> (`docs/ao-start-bootstrapper-and-npm-deprecation.md`, §5). Deferred Settings
> work is tracked in AgentWrapper/agent-orchestrator#2205.
---
## 0. Goal
`ao start` no longer runs the legacy import (it now fetches+opens the desktop
app; the daemon-spawn path and `maybeFirstBootImport` are gone). The spec's §6.4
left an open decision: where does the legacy first-boot import go? This design
answers it: **the desktop app offers the import on launch via a popup, gated on a
persisted `migration` marker in `~/.ao/app-state.json`.** If the user hasn't
migrated (and legacy data exists), the app prompts. The import runs through the
app-owned daemon and is idempotent; legacy files are never modified, so a failed
or declined import loses nothing.
---
## 1. Ground truth (what the code is today)
| Fact | Value | Source |
| ------------------------ | -------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------- |
| Marker file | `~/.ao/app-state.json`, **app is sole writer**, written every launch | `frontend/src/main/app-state.ts`; spec §5, invariant 3 |
| Marker fields today | `schemaVersion, appPath, version, installedAt, lastReconciledAt, installSource` | `frontend/src/main/app-state.ts` `AppStateMarker` |
| Marker write call site | `app.whenReady()` before `createWindow()` | `frontend/src/main.ts:859` (inside `whenReady`, `:868`) |
| Go reader of marker | read-only, ignores unknown JSON fields, does NOT gate on `schemaVersion` | `backend/internal/cli/start.go` `appState` struct (~`:38`) |
| Import engine | `internal/legacyimport` (projects + per-project settings + orchestrator + transcripts) + `ao import` CLI | present from #314 |
| Import **daemon API** | **does not exist on main** (no `service/importer`, no `httpd/controllers/imports.go`) | verified |
| Daemon ownership | the app spawns + owns the daemon | `frontend/src/main.ts` `startDaemon` |
| HTTP client (renderer) | `apiClient` over the daemon loopback API | `frontend/src/renderer/lib/api-client.ts:81` |
| IPC bridge | `contextBridge.exposeInMainWorld("ao", api)`; renderer calls `window.ao.<ns>.*` | `frontend/src/preload.ts:73` |
| IPC handler pattern | `ipcMain.handle("<ns>:<action>", …)` in main | `frontend/src/main.ts:773-796` |
| Dashboard route | `_shell.index.tsx` (the board) | `frontend/src/renderer/routes/` |
| **Global Settings page** | **none** — only per-project `_shell.projects.$projectId_.settings.tsx` | verified |
---
## 2. Decisions locked
1. **Approach A:** daemon import API (detect + run) + app-side marker. The
renderer uses `apiClient`; the app stores only the decision in
`app-state.json`. (User-approved.)
2. **App is the sole writer of `app-state.json`** (invariant 3); the **daemon is
the sole writer of the DB**. The import runs through the daemon; the app
records the marker.
3. **Import scope = projects + per-project settings only** (no orchestrator
sessions, no transcripts). The daemon API is the projects-only engine from the
import-offer plan (`docs/plans/2026-06-26-import-offer.md`).
4. **Popup actions:** **Proceed** (run), **Skip** (re-prompt next launch),
**Don't Migrate** (red; permanently declines). Small print points to a future
Settings redo path.
5. **Settings "Migration" section is deferred** to AgentWrapper/agent-orchestrator#2205.
v1 ships the popup only. Until #2205 lands, `declined` is effectively
permanent, so v1 copy must NOT promise a working Settings path (see §6).
---
## 3. Scope
**In scope:**
- Backend: the projects-only import daemon API: `GET /api/v1/import` (availability)
and `POST /api/v1/import` (run), plus `service/importer`, DTOs, OpenAPI regen.
(= the import-offer plan, with the status-semantics tweak in §5.2.)
- App marker: add a `migration` block to `app-state.json` (`schemaVersion` → 2),
preserved across launches; an IPC getter/setter.
- Renderer: a launch-time `MigrationPopup` gated on (marker not terminal) AND
(daemon reports legacy data available); the three-action UX; failure
reassurance.
**Out of scope (deferred / separate):**
- Global Settings page + the Migration section / redo entry point → #2205.
- The engine simplification details themselves live in the import-offer plan; this
design consumes that API, it does not re-specify the engine internals.
- Track B (signing/auto-update) and anything in the bootstrapper spec's out-of-scope.
---
## 4. Invariants (load-bearing)
1. **Filesystem/DB is the source of truth; the marker is a hint.** A `completed`
marker is never trusted to mean the rows exist; re-running is always safe
(idempotent engine). (Mirrors bootstrapper invariant 2.)
2. **App is the sole writer of `app-state.json`.** The renderer never writes it
directly; it goes through the main process over IPC.
3. **The import never deletes or modifies legacy files.** This is what makes a
failed/declined import lossless, and it is the promise the failure copy makes.
4. **The import is idempotent.** Existing rows are skipped, so Skip-then-Proceed,
double-clicks, and Settings re-runs never duplicate or clobber.
---
## 5. Backend: the import daemon API
### 5.1 Surface (from the import-offer plan)
- `GET /api/v1/import``{ available: bool, legacyRoot: string }`.
- `POST /api/v1/import``{ report: { dryRun, projectsImported, projectsSkipped, notes? } }`.
- `internal/service/importer` wraps `legacyimport` (projects-only); wired into
`daemon.go` as `Import: importsvc.New(importsvc.Deps{Store: store})`.
- A nil service answers OpenAPI-backed `501`. OpenAPI + `schema.ts` regenerated.
### 5.2 Status semantics (the one change vs the import-offer plan)
The import-offer plan computed `available = HasLegacyData(root) && len(projects)==0`
(the empty-DB heuristic). **Here the marker governs whether to prompt**, so the DB
heuristic is redundant and can be wrong (a user who added projects but never
imported legacy still has legacy data). Define:
```
available = legacyimport.HasLegacyData(root)
```
The "already decided / don't nag" logic moves entirely to the app marker;
idempotency keeps a re-run safe regardless of DB contents.
---
## 6. App marker: `migration` block in `app-state.json`
Bump `SCHEMA_VERSION` to `2` and extend `AppStateMarker`
(`frontend/src/main/app-state.ts`):
```ts
export type MigrationStatus = "pending" | "completed" | "declined" | "failed";
export interface MigrationState {
status: MigrationStatus; // absent file => treated as "pending"
lastAttemptAt?: string; // last Proceed attempt (success or failure)
completedAt?: string; // set when status -> completed
report?: { projectsImported: number; projectsSkipped: number };
error?: string; // last failure message (status === "failed")
}
export interface AppStateMarker {
schemaVersion: number;
appPath: string;
version: string;
installedAt: string;
lastReconciledAt: string;
installSource: string;
migration?: MigrationState; // new; preserved across launches
}
```
- **Preservation:** the launch-time `writeAppStateMarker` must carry an existing
`migration` block through untouched (the same way it preserves `installedAt` /
`installSource`). It never sets `migration` itself.
- **Setter:** a new `updateMigration(stateDir, partial, now)` does an atomic
read-modify-write (temp + rename, same as `writeAppStateMarker`) so the marker
is updated without clobbering the launch-written fields.
- **Go reader:** unchanged. `start.go`'s `appState` ignores the unknown
`migration` field and does not gate on `schemaVersion`, so bumping to 2 is safe
(verified). Adding a mirrored `Migration` field there is optional and not needed
for v1.
Terminal states (never auto-prompt): `completed`, `declined`. Prompting states:
`pending`, `failed`.
---
## 7. Renderer: detection, gate, popup
### 7.1 IPC additions
- main: `ipcMain.handle("appState:getMigration", …)` → returns `MigrationState`
(or `{status:"pending"}` when absent); `ipcMain.handle("appState:setMigration",
(_e, partial) => updateMigration(...))`.
- preload: extend the `ao` bridge with
`appState: { getMigration, setMigration }`.
### 7.2 Gate (where the popup decides to show)
A `useMigrationOffer()` hook, consumed on the dashboard (`_shell.index.tsx`):
1. read `window.ao.appState.getMigration()`,
2. if `status ∈ {completed, declined}` → render nothing,
3. else `GET /api/v1/import`; show the popup only when `available === true`
(a 501 / unreachable daemon resolves to "not available", never an error).
`Skip` sets an in-memory dismissed flag for the session (no marker write) so the
popup returns next launch. This matches "re-prompt until complete" for Skip while
keeping `Don't Migrate` permanent.
### 7.3 Popup actions / data flow
| Action | Effect |
| ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Proceed** | `POST /api/v1/import`. On success: `setMigration({status:"completed", completedAt, report})`, then invalidate the workspace query so projects appear. On failure: `setMigration({status:"failed", lastAttemptAt, error})` and show the reassurance inline. |
| **Skip** | session-only dismiss; no marker write; re-prompts next launch. |
| **Don't Migrate** (red) | `setMigration({status:"declined", lastAttemptAt})`; never auto-prompts again. |
### 7.4 Copy
- Heading: "Import projects from your earlier AO?"
- Body: names the legacy root; "Importing brings in your projects. Your old files
are never modified, and you can do this later." (projects-only; **no orchestrator
mention**.)
- Failure: "Migration failed: `<error>`. Your legacy projects are untouched
(nothing is ever deleted). You can retry." with a Retry that re-POSTs.
- Small print: a neutral "You can run this again later." **v1 must not promise a
Settings location** (Settings is deferred to #2205); the Settings wording lands
with that issue.
---
## 8. Components / files
**Backend** (per the import-offer plan, projects-only; status tweak §5.2):
`internal/service/importer/*`, `internal/httpd/controllers/imports.go`,
`dto.go`, `apispec/specgen/build.go`, `api.go`, `daemon.go`, regenerated
`openapi.yaml` + `frontend/src/api/schema.ts`.
**App / renderer (new in this design):**
- `frontend/src/main/app-state.ts``MigrationState`, schema v2, preserve +
`updateMigration`.
- `frontend/src/main.ts``appState:getMigration` / `appState:setMigration`
handlers.
- `frontend/src/preload.ts``ao.appState` bridge methods.
- `frontend/src/renderer/hooks/useMigrationOffer.ts` — gate (IPC marker + daemon
GET).
- `frontend/src/renderer/components/MigrationPopup.tsx` — the three-action dialog
(built from `components/ui/*`).
- `frontend/src/renderer/routes/_shell.index.tsx` — mount the popup on the board.
---
## 9. Error handling
- Daemon unreachable / 501 on `GET` → "not available" → no popup (never blocks).
- `POST` failure → `failed` marker + lossless-reassurance copy + Retry.
- Corrupt/missing marker → treated as `pending` (self-healing, like the existing
reader).
- Atomic temp+rename for every marker write → a concurrent `ao start` reader never
sees a partial file.
---
## 10. Testing
- **`app-state.ts`:** v2 round-trip; launch write preserves an existing
`migration` block; `updateMigration` merges without clobbering `appPath`/etc.;
corrupt file → `pending`.
- **Backend:** import service status (`available` = `HasLegacyData` only) + run +
idempotency; controller GET/POST + 501; route↔spec parity. (From the plan.)
- **`useMigrationOffer` / `MigrationPopup`:** popup shows only when not-terminal +
available; Proceed success → completed + workspace invalidated + popup retires;
Proceed failure → reassurance + Retry; Skip → no marker write; Don't Migrate →
`declined` and stays hidden.
---
## 11. Open decisions
1. **Settings redo path** → deferred to #2205 (locked: not in v1).
2. **Mirror `migration` into the Go `appState` struct?** Not needed for v1; add
only if a CLI surface ever needs to read migration status.
3. **Where exactly to mount the popup** — board (`_shell.index.tsx`) vs the shell
layout (`_shell.tsx`) so it shows on any route. Default: board, since that is
the first-run landing surface; revisit if it should be shell-wide.

View File

@ -21,6 +21,24 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/v1/import": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Check whether a legacy AO install is available to import */
get: operations["getImportStatus"];
put?: never;
/** Run the legacy AO project import through the daemon store */
post: operations["runImport"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/notifications": {
parameters: {
query?: never;
@ -551,6 +569,19 @@ export interface components {
DomainReviewerConfig: {
harness: string;
};
ImportReport: {
dryRun: boolean;
notes?: string[];
projectsImported: number;
projectsSkipped: number;
};
ImportRunResponse: {
report: components["schemas"]["ImportReport"];
};
ImportStatusResponse: {
available: boolean;
legacyRoot: string;
};
KillSessionResponse: {
freed?: boolean;
ok: boolean;
@ -918,6 +949,82 @@ export interface operations {
};
};
};
getImportStatus: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ImportStatusResponse"];
};
};
/** @description Internal Server Error */
500: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["APIError"];
};
};
/** @description Not Implemented */
501: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["APIError"];
};
};
};
};
runImport: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ImportRunResponse"];
};
};
/** @description Internal Server Error */
500: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["APIError"];
};
};
/** @description Not Implemented */
501: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["APIError"];
};
};
};
};
listNotifications: {
parameters: {
query?: {

View File

@ -35,7 +35,7 @@ import { buildTelemetryBootstrap } from "./shared/telemetry";
import { createBrowserViewHost, type BrowserViewHost } from "./main/browser-view-host";
import { connectSupervisor, type SupervisorLinkHandle } from "./main/supervisor-link";
import { shouldLinkOnAttach } from "./main/daemon-owner";
import { writeAppStateMarker } from "./main/app-state";
import { readMigrationState, updateMigration, writeAppStateMarker, type MigrationState } from "./main/app-state";
// Globals injected at compile time by @electron-forge/plugin-vite.
declare const MAIN_WINDOW_VITE_DEV_SERVER_URL: string | undefined;
@ -795,6 +795,17 @@ ipcMain.handle("clipboard:writeText", (_event, text: string) => {
});
ipcMain.handle("clipboard:readText", () => clipboard.readText());
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() });
});
ipcMain.handle("notifications:show", (_event, notification: { id: string; title: string; body?: string }) => {
if (!notification.id || !notification.title || !ElectronNotification.isSupported()) return;
const toast = new ElectronNotification({

View File

@ -1,9 +1,15 @@
// @vitest-environment node
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { mkdtemp, readFile, readdir, rm } from "node:fs/promises";
import { mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { APP_STATE_FILE_NAME, writeAppStateMarker, type AppStateMarker } from "./app-state";
import {
APP_STATE_FILE_NAME,
writeAppStateMarker,
readMigrationState,
updateMigration,
type AppStateMarker,
} from "./app-state";
// The exact key set the Go reader (start.go `appState`) unmarshals.
const GO_READER_KEYS = ["schemaVersion", "appPath", "version", "installedAt", "lastReconciledAt", "installSource"];
@ -35,7 +41,7 @@ describe("writeAppStateMarker", () => {
});
const m = await readMarker(dir);
expect(m.schemaVersion).toBe(1);
expect(m.schemaVersion).toBe(2);
expect(m.appPath).toBe("/Applications/Agent Orchestrator.app");
expect(m.version).toBe("0.0.0");
expect(m.installedAt).toBe("2026-06-26T10:00:00.000Z");
@ -126,3 +132,55 @@ describe("writeAppStateMarker", () => {
expect(m.appPath).toBe("/Applications/Agent Orchestrator.app");
});
});
// ---- migration marker tests (B1) ----
const fixedNow = () => new Date("2026-06-26T10:00:00.000Z");
describe("migration marker", () => {
const dirs: string[] = [];
async function tmp() {
const dir = await mkdtemp(path.join(os.tmpdir(), "ao-appstate-"));
dirs.push(dir);
return dir;
}
afterEach(async () => {
await Promise.all(dirs.splice(0).map((d) => rm(d, { recursive: true, force: true })));
});
it("readMigrationState defaults to pending when the file is absent", async () => {
expect(await readMigrationState(await tmp())).toEqual({ status: "pending" });
});
it("readMigrationState defaults to pending when the file is corrupt", async () => {
const dir = await tmp();
await writeFile(path.join(dir, APP_STATE_FILE_NAME), "{ not valid json", "utf8");
expect(await readMigrationState(dir)).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" });
});
});

View File

@ -7,6 +7,17 @@ import path from "node:path";
* The Go reader is backend/internal/cli/start.go `appState`; the JSON keys
* below MUST match its struct tags exactly (camelCase).
*/
export type MigrationStatus = "pending" | "completed" | "declined" | "failed";
export interface MigrationState {
status: MigrationStatus;
lastAttemptAt?: string;
completedAt?: string;
report?: { projectsImported: number; projectsSkipped: number };
error?: string;
}
export interface AppStateMarker {
schemaVersion: number;
appPath: string;
@ -14,10 +25,11 @@ export interface AppStateMarker {
installedAt: string;
lastReconciledAt: string;
installSource: string;
migration?: MigrationState;
}
/** Current marker format version (spec §5, schemaVersion field). */
const SCHEMA_VERSION = 1;
const SCHEMA_VERSION = 2;
/** File name of the marker under the ~/.ao state dir. */
export const APP_STATE_FILE_NAME = "app-state.json";
@ -58,6 +70,20 @@ async function readExisting(file: string): Promise<AppStateMarker | null> {
}
}
/**
* Atomic write: temp file in the same dir, then rename. Mirrors the daemon's
* proven atomic write (backend/internal/runfile/runfile.go Write) so a
* concurrent `ao start` reader never observes a partial file.
*/
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);
}
/**
* Write ~/.ao/app-state.json. The app is the SOLE writer (invariant 3) and
* writes on every launch. Mirrors the daemon's proven atomic write
@ -68,11 +94,11 @@ async function readExisting(file: string): Promise<AppStateMarker | null> {
* On first creation, installedAt and installSource are captured and then
* preserved across all later launches; appPath, version, and lastReconciledAt
* are refreshed every launch (spec §5 field table).
*
* An existing `migration` block is preserved unchanged so a launch write does
* not erase a prior decision recorded by updateMigration.
*/
export async function writeAppStateMarker(opts: WriteAppStateOptions): Promise<void> {
// 0o750: owner rwx, group r-x, world none — matches runfile.Write's dir mode.
await mkdir(opts.stateDir, { recursive: true, mode: 0o750 });
const file = path.join(opts.stateDir, APP_STATE_FILE_NAME);
const existing = await readExisting(file);
const nowIso = opts.now().toISOString();
@ -86,14 +112,42 @@ export async function writeAppStateMarker(opts: WriteAppStateOptions): Promise<v
// Refreshed on every launch that touches the marker.
lastReconciledAt: nowIso,
installSource: existing?.installSource ?? opts.installedVia ?? "unknown",
// Preserve a migration block written before this launch write.
...(existing?.migration !== undefined ? { migration: existing.migration } : {}),
};
// Pretty-print + trailing newline to match runfile.Write's MarshalIndent style.
const data = `${JSON.stringify(marker, null, 2)}\n`;
// Temp file in the SAME dir so the rename is atomic (same filesystem). The
// random suffix avoids a collision if two launches race.
const tmp = path.join(opts.stateDir, `.app-state-${process.pid}-${Date.now()}.json`);
await writeFile(tmp, data, { mode: 0o600 });
await rename(tmp, file);
await atomicWriteMarker(opts.stateDir, marker);
}
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" };
}

View File

@ -2,6 +2,7 @@ import { contextBridge, ipcRenderer } from "electron";
import type { BrowserNavState, BrowserRect } from "./main/browser-view-host";
import type { DaemonStatus } from "./shared/daemon-status";
import type { TelemetryBootstrap } from "./shared/telemetry";
import type { MigrationState } from "./main/app-state";
export type BrowserBoundsInput = {
viewId: string;
@ -68,6 +69,11 @@ const api = {
};
},
},
appState: {
getMigration: () => ipcRenderer.invoke("appState:getMigration") as Promise<MigrationState>,
setMigration: (migration: MigrationState) =>
ipcRenderer.invoke("appState:setMigration", migration) as Promise<void>,
},
};
contextBridge.exposeInMainWorld("ao", api);

View File

@ -0,0 +1,89 @@
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" }));
});
});

View File

@ -0,0 +1,101 @@
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 });
setSkipped(true);
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>
);
}

View File

@ -0,0 +1,38 @@
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,
});
}

View File

@ -92,4 +92,8 @@ export const aoBridge: AoBridge =
show: async () => undefined,
onClick: () => () => undefined,
},
appState: {
getMigration: async () => ({ status: "pending" }),
setMigration: async () => undefined,
},
} satisfies AoBridge);

View File

@ -1,6 +1,12 @@
import { createFileRoute } from "@tanstack/react-router";
import { MigrationPopup } from "../components/MigrationPopup";
import { SessionsBoard } from "../components/SessionsBoard";
export const Route = createFileRoute("/_shell/")({
component: () => <SessionsBoard />,
component: () => (
<>
<MigrationPopup />
<SessionsBoard />
</>
),
});

View File

@ -137,5 +137,9 @@ if (typeof window !== "undefined") {
show: async () => undefined,
onClick: () => () => undefined,
},
appState: {
getMigration: async () => ({ status: "pending" }),
setMigration: async () => undefined,
},
};
} // end if (typeof window !== "undefined")

760
package-lock.json generated
View File

@ -1,382 +1,382 @@
{
"name": "agent-orchestrator",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "agent-orchestrator",
"devDependencies": {
"openapi-typescript": "7.4.4"
}
},
"node_modules/@babel/code-frame": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
"integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-validator-identifier": "^7.29.7",
"js-tokens": "^4.0.0",
"picocolors": "^1.1.1"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-validator-identifier": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
"integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@redocly/ajv": {
"version": "8.11.2",
"resolved": "https://registry.npmjs.org/@redocly/ajv/-/ajv-8.11.2.tgz",
"integrity": "sha512-io1JpnwtIcvojV7QKDUSIuMN/ikdOUd1ReEnUnMKGfDVridQZ31J0MmIuqwuRjWDZfmvr+Q0MqCcfHM2gTivOg==",
"dev": true,
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.1",
"json-schema-traverse": "^1.0.0",
"require-from-string": "^2.0.2",
"uri-js-replace": "^1.0.1"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/@redocly/config": {
"version": "0.22.0",
"resolved": "https://registry.npmjs.org/@redocly/config/-/config-0.22.0.tgz",
"integrity": "sha512-gAy93Ddo01Z3bHuVdPWfCwzgfaYgMdaZPcfL7JZ7hWJoK9V0lXDbigTWkhiPFAaLWzbOJ+kbUQG1+XwIm0KRGQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@redocly/openapi-core": {
"version": "1.34.15",
"resolved": "https://registry.npmjs.org/@redocly/openapi-core/-/openapi-core-1.34.15.tgz",
"integrity": "sha512-HAwCnNyKcs5XGQqms+9t7OdAPM/5TDstmhF+0i7tdCFato2QKuYIlyWETwkXd8c5zbltr1oB+6y9NTeQLr2d6Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@redocly/ajv": "8.11.2",
"@redocly/config": "0.22.0",
"colorette": "1.4.0",
"https-proxy-agent": "7.0.6",
"js-levenshtein": "1.1.6",
"js-yaml": "4.1.1",
"minimatch": "5.1.9",
"pluralize": "8.0.0",
"yaml-ast-parser": "0.0.43"
},
"engines": {
"node": ">=18.17.0",
"npm": ">=9.5.0"
}
},
"node_modules/agent-base": {
"version": "7.1.4",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 14"
}
},
"node_modules/ansi-colors": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz",
"integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/argparse": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"dev": true,
"license": "Python-2.0"
},
"node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"dev": true,
"license": "MIT"
},
"node_modules/brace-expansion": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz",
"integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
}
},
"node_modules/change-case": {
"version": "5.4.4",
"resolved": "https://registry.npmjs.org/change-case/-/change-case-5.4.4.tgz",
"integrity": "sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==",
"dev": true,
"license": "MIT"
},
"node_modules/colorette": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/colorette/-/colorette-1.4.0.tgz",
"integrity": "sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==",
"dev": true,
"license": "MIT"
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"dev": true,
"license": "MIT"
},
"node_modules/https-proxy-agent": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
"dev": true,
"license": "MIT",
"dependencies": {
"agent-base": "^7.1.2",
"debug": "4"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/index-to-position": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/index-to-position/-/index-to-position-1.2.0.tgz",
"integrity": "sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/js-levenshtein": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/js-levenshtein/-/js-levenshtein-1.1.6.tgz",
"integrity": "sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
"dev": true,
"license": "MIT"
},
"node_modules/js-yaml": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
"dev": true,
"license": "MIT",
"dependencies": {
"argparse": "^2.0.1"
},
"bin": {
"js-yaml": "bin/js-yaml.js"
}
},
"node_modules/json-schema-traverse": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"dev": true,
"license": "MIT"
},
"node_modules/minimatch": {
"version": "5.1.9",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz",
"integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.1"
},
"engines": {
"node": ">=10"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true,
"license": "MIT"
},
"node_modules/openapi-typescript": {
"version": "7.4.4",
"resolved": "https://registry.npmjs.org/openapi-typescript/-/openapi-typescript-7.4.4.tgz",
"integrity": "sha512-7j3nktnRzlQdlHnHsrcr6Gqz8f80/RhfA2I8s1clPI+jkY0hLNmnYVKBfuUEli5EEgK1B6M+ibdS5REasPlsUw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@redocly/openapi-core": "^1.25.9",
"ansi-colors": "^4.1.3",
"change-case": "^5.4.4",
"parse-json": "^8.1.0",
"supports-color": "^9.4.0",
"yargs-parser": "^21.1.1"
},
"bin": {
"openapi-typescript": "bin/cli.js"
},
"peerDependencies": {
"typescript": "^5.x"
}
},
"node_modules/parse-json": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/parse-json/-/parse-json-8.3.0.tgz",
"integrity": "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.26.2",
"index-to-position": "^1.1.0",
"type-fest": "^4.39.1"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"dev": true,
"license": "ISC"
},
"node_modules/pluralize": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz",
"integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/require-from-string": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/supports-color": {
"version": "9.4.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-9.4.0.tgz",
"integrity": "sha512-VL+lNrEoIXww1coLPOmiEmK/0sGigko5COxI09KzHc2VJXJsQ37UaQ+8quuxjDeA7+KnLGTWRyOXSLLR2Wb4jw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/supports-color?sponsor=1"
}
},
"node_modules/type-fest": {
"version": "4.41.0",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz",
"integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==",
"dev": true,
"license": "(MIT OR CC0-1.0)",
"engines": {
"node": ">=16"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/uri-js-replace": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/uri-js-replace/-/uri-js-replace-1.0.1.tgz",
"integrity": "sha512-W+C9NWNLFOoBI2QWDp4UT9pv65r2w5Cx+3sTYFvtMdDBxkKt1syCqsUdSFAChbEe1uK5TfS04wt/nGwmaeIQ0g==",
"dev": true,
"license": "MIT"
},
"node_modules/yaml-ast-parser": {
"version": "0.0.43",
"resolved": "https://registry.npmjs.org/yaml-ast-parser/-/yaml-ast-parser-0.0.43.tgz",
"integrity": "sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A==",
"dev": true,
"license": "Apache-2.0"
},
"node_modules/yargs-parser": {
"version": "21.1.1",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
"integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=12"
}
}
}
"name": "agent-orchestrator",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "agent-orchestrator",
"devDependencies": {
"openapi-typescript": "7.4.4"
}
},
"node_modules/@babel/code-frame": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
"integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-validator-identifier": "^7.29.7",
"js-tokens": "^4.0.0",
"picocolors": "^1.1.1"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-validator-identifier": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
"integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@redocly/ajv": {
"version": "8.11.2",
"resolved": "https://registry.npmjs.org/@redocly/ajv/-/ajv-8.11.2.tgz",
"integrity": "sha512-io1JpnwtIcvojV7QKDUSIuMN/ikdOUd1ReEnUnMKGfDVridQZ31J0MmIuqwuRjWDZfmvr+Q0MqCcfHM2gTivOg==",
"dev": true,
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.1",
"json-schema-traverse": "^1.0.0",
"require-from-string": "^2.0.2",
"uri-js-replace": "^1.0.1"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/@redocly/config": {
"version": "0.22.0",
"resolved": "https://registry.npmjs.org/@redocly/config/-/config-0.22.0.tgz",
"integrity": "sha512-gAy93Ddo01Z3bHuVdPWfCwzgfaYgMdaZPcfL7JZ7hWJoK9V0lXDbigTWkhiPFAaLWzbOJ+kbUQG1+XwIm0KRGQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@redocly/openapi-core": {
"version": "1.34.15",
"resolved": "https://registry.npmjs.org/@redocly/openapi-core/-/openapi-core-1.34.15.tgz",
"integrity": "sha512-HAwCnNyKcs5XGQqms+9t7OdAPM/5TDstmhF+0i7tdCFato2QKuYIlyWETwkXd8c5zbltr1oB+6y9NTeQLr2d6Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@redocly/ajv": "8.11.2",
"@redocly/config": "0.22.0",
"colorette": "1.4.0",
"https-proxy-agent": "7.0.6",
"js-levenshtein": "1.1.6",
"js-yaml": "4.1.1",
"minimatch": "5.1.9",
"pluralize": "8.0.0",
"yaml-ast-parser": "0.0.43"
},
"engines": {
"node": ">=18.17.0",
"npm": ">=9.5.0"
}
},
"node_modules/agent-base": {
"version": "7.1.4",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 14"
}
},
"node_modules/ansi-colors": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz",
"integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/argparse": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"dev": true,
"license": "Python-2.0"
},
"node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"dev": true,
"license": "MIT"
},
"node_modules/brace-expansion": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz",
"integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
}
},
"node_modules/change-case": {
"version": "5.4.4",
"resolved": "https://registry.npmjs.org/change-case/-/change-case-5.4.4.tgz",
"integrity": "sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==",
"dev": true,
"license": "MIT"
},
"node_modules/colorette": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/colorette/-/colorette-1.4.0.tgz",
"integrity": "sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==",
"dev": true,
"license": "MIT"
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"dev": true,
"license": "MIT"
},
"node_modules/https-proxy-agent": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
"dev": true,
"license": "MIT",
"dependencies": {
"agent-base": "^7.1.2",
"debug": "4"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/index-to-position": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/index-to-position/-/index-to-position-1.2.0.tgz",
"integrity": "sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/js-levenshtein": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/js-levenshtein/-/js-levenshtein-1.1.6.tgz",
"integrity": "sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
"dev": true,
"license": "MIT"
},
"node_modules/js-yaml": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
"dev": true,
"license": "MIT",
"dependencies": {
"argparse": "^2.0.1"
},
"bin": {
"js-yaml": "bin/js-yaml.js"
}
},
"node_modules/json-schema-traverse": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"dev": true,
"license": "MIT"
},
"node_modules/minimatch": {
"version": "5.1.9",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz",
"integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.1"
},
"engines": {
"node": ">=10"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true,
"license": "MIT"
},
"node_modules/openapi-typescript": {
"version": "7.4.4",
"resolved": "https://registry.npmjs.org/openapi-typescript/-/openapi-typescript-7.4.4.tgz",
"integrity": "sha512-7j3nktnRzlQdlHnHsrcr6Gqz8f80/RhfA2I8s1clPI+jkY0hLNmnYVKBfuUEli5EEgK1B6M+ibdS5REasPlsUw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@redocly/openapi-core": "^1.25.9",
"ansi-colors": "^4.1.3",
"change-case": "^5.4.4",
"parse-json": "^8.1.0",
"supports-color": "^9.4.0",
"yargs-parser": "^21.1.1"
},
"bin": {
"openapi-typescript": "bin/cli.js"
},
"peerDependencies": {
"typescript": "^5.x"
}
},
"node_modules/parse-json": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/parse-json/-/parse-json-8.3.0.tgz",
"integrity": "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.26.2",
"index-to-position": "^1.1.0",
"type-fest": "^4.39.1"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"dev": true,
"license": "ISC"
},
"node_modules/pluralize": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz",
"integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/require-from-string": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/supports-color": {
"version": "9.4.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-9.4.0.tgz",
"integrity": "sha512-VL+lNrEoIXww1coLPOmiEmK/0sGigko5COxI09KzHc2VJXJsQ37UaQ+8quuxjDeA7+KnLGTWRyOXSLLR2Wb4jw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/supports-color?sponsor=1"
}
},
"node_modules/type-fest": {
"version": "4.41.0",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz",
"integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==",
"dev": true,
"license": "(MIT OR CC0-1.0)",
"engines": {
"node": ">=16"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/uri-js-replace": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/uri-js-replace/-/uri-js-replace-1.0.1.tgz",
"integrity": "sha512-W+C9NWNLFOoBI2QWDp4UT9pv65r2w5Cx+3sTYFvtMdDBxkKt1syCqsUdSFAChbEe1uK5TfS04wt/nGwmaeIQ0g==",
"dev": true,
"license": "MIT"
},
"node_modules/yaml-ast-parser": {
"version": "0.0.43",
"resolved": "https://registry.npmjs.org/yaml-ast-parser/-/yaml-ast-parser-0.0.43.tgz",
"integrity": "sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A==",
"dev": true,
"license": "Apache-2.0"
},
"node_modules/yargs-parser": {
"version": "21.1.1",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
"integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=12"
}
}
}
}