diff --git a/Backend/cmd/worker/main.go b/Backend/cmd/worker/main.go index 55363f8..6f719ad 100644 --- a/Backend/cmd/worker/main.go +++ b/Backend/cmd/worker/main.go @@ -1,10 +1,16 @@ package main import ( + "context" + "encoding/json" "log" + "os/signal" + "syscall" + "time" "moku-backend/internal/bootstrap" - "moku-backend/internal/process" + "moku-backend/internal/jobs" + "moku-backend/internal/worker" ) func main() { @@ -18,9 +24,25 @@ func main() { } }() - app.Logger.Info("worker ready", "service", app.ServiceName, "environment", app.Config.Environment) + jobStore := jobs.NewStore(app.Database) + runner := worker.NewRunner(jobStore, app.Logger, time.Second) + runner.Register(jobs.KindBootstrapStructureMaterialize, func(ctx context.Context, job jobs.Job) error { + var payload jobs.BootstrapStructureMaterializePayload + if len(job.Payload) > 0 { + if err := json.Unmarshal(job.Payload, &payload); err != nil { + return err + } + } - if err := process.WaitForShutdown(app.ServiceName, app.Logger); err != nil { + return bootstrap.NewService(app.Database, app.Config.POSIXRoot).ProcessBootstrapStructureMaterialization(ctx, payload.InstallationID) + }) + + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + + app.Logger.Info("worker ready", "service", app.ServiceName, "environment", app.Config.Environment, "pollInterval", time.Second) + + if err := runner.Run(ctx); err != nil { app.Logger.Error("worker stopped", "error", err) log.Fatal(err) } diff --git a/Backend/db/migrations/000005_background_jobs.sql b/Backend/db/migrations/000005_background_jobs.sql new file mode 100644 index 0000000..22d8b39 --- /dev/null +++ b/Backend/db/migrations/000005_background_jobs.sql @@ -0,0 +1,64 @@ +-- +goose Up + +CREATE TYPE bootstrap_materialization_status AS ENUM ('not_started', 'pending', 'running', 'succeeded', 'failed'); +CREATE TYPE background_job_status AS ENUM ('pending', 'running', 'succeeded', 'failed'); + +ALTER TABLE installations + ADD COLUMN IF NOT EXISTS materialization_status bootstrap_materialization_status NOT NULL DEFAULT 'not_started', + ADD COLUMN IF NOT EXISTS materialization_error TEXT, + ADD COLUMN IF NOT EXISTS materialization_requested_at TIMESTAMPTZ, + ADD COLUMN IF NOT EXISTS materialization_started_at TIMESTAMPTZ, + ADD COLUMN IF NOT EXISTS materialization_finished_at TIMESTAMPTZ; + +UPDATE installations +SET + materialization_status = CASE + WHEN is_bootstrapped THEN 'succeeded'::bootstrap_materialization_status + ELSE 'not_started'::bootstrap_materialization_status + END, + materialization_error = NULL, + materialization_requested_at = CASE + WHEN is_bootstrapped THEN COALESCE(bootstrapped_at, created_at, NOW()) + ELSE NULL + END, + materialization_started_at = CASE + WHEN is_bootstrapped THEN COALESCE(bootstrapped_at, created_at, NOW()) + ELSE NULL + END, + materialization_finished_at = CASE + WHEN is_bootstrapped THEN COALESCE(bootstrapped_at, created_at, NOW()) + ELSE NULL + END +WHERE materialization_status = 'not_started'::bootstrap_materialization_status; + +CREATE TABLE IF NOT EXISTS background_jobs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + kind TEXT NOT NULL, + status background_job_status NOT NULL DEFAULT 'pending', + payload JSONB NOT NULL DEFAULT '{}'::jsonb, + attempts INTEGER NOT NULL DEFAULT 0, + max_attempts INTEGER NOT NULL DEFAULT 1, + available_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + started_at TIMESTAMPTZ, + finished_at TIMESTAMPTZ, + last_error TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_background_jobs_claim ON background_jobs (status, available_at, created_at); + +-- +goose Down + +DROP INDEX IF EXISTS idx_background_jobs_claim; +DROP TABLE IF EXISTS background_jobs; + +ALTER TABLE installations + DROP COLUMN IF EXISTS materialization_finished_at, + DROP COLUMN IF EXISTS materialization_started_at, + DROP COLUMN IF EXISTS materialization_requested_at, + DROP COLUMN IF EXISTS materialization_error, + DROP COLUMN IF EXISTS materialization_status; + +DROP TYPE IF EXISTS background_job_status; +DROP TYPE IF EXISTS bootstrap_materialization_status; diff --git a/Backend/internal/bootstrap/bootstrap_installation.go b/Backend/internal/bootstrap/bootstrap_installation.go new file mode 100644 index 0000000..1f654dc --- /dev/null +++ b/Backend/internal/bootstrap/bootstrap_installation.go @@ -0,0 +1,159 @@ +package bootstrap + +import ( + "context" + "errors" + "strings" + + "github.com/jackc/pgx/v5" +) + +const ( + materializationNotStarted = "not_started" + materializationPending = "pending" + materializationRunning = "running" + materializationSucceeded = "succeeded" + materializationFailed = "failed" +) + +type bootstrapStructurePrerequisites struct { + installation InstallationRecord + admin AdminSummary +} + +func (service *Service) GetInstallation(ctx context.Context) (*InstallationRecord, error) { + record, err := scanInstallationRecord(service.db.Pool.QueryRow(ctx, ` + SELECT + id::text, + name, + mode::text, + access::text, + protocol::text, + host, + is_bootstrapped, + materialization_status::text, + materialization_error + FROM installations + WHERE singleton = TRUE + LIMIT 1; + `)) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + + return nil, err + } + + return &record, nil +} + +func loadInstallation(ctx context.Context, tx pgx.Tx) (InstallationRecord, error) { + return scanInstallationRecord(tx.QueryRow(ctx, ` + SELECT + id::text, + name, + mode::text, + access::text, + protocol::text, + host, + is_bootstrapped, + materialization_status::text, + materialization_error + FROM installations + WHERE singleton = TRUE + LIMIT 1; + `)) +} + +func updateBootstrappedInstallation(ctx context.Context, tx pgx.Tx) (InstallationRecord, error) { + return scanInstallationRecord(tx.QueryRow(ctx, ` + UPDATE installations + SET + is_bootstrapped = TRUE, + bootstrapped_at = COALESCE(bootstrapped_at, NOW()), + materialization_status = 'pending'::bootstrap_materialization_status, + materialization_error = NULL, + materialization_requested_at = NOW(), + materialization_started_at = NULL, + materialization_finished_at = NULL, + updated_at = NOW() + WHERE singleton = TRUE + RETURNING + id::text, + name, + mode::text, + access::text, + protocol::text, + host, + is_bootstrapped, + materialization_status::text, + materialization_error; + `)) +} + +func scanInstallationRecord(row pgx.Row) (InstallationRecord, error) { + var record InstallationRecord + if err := row.Scan( + &record.ID, + &record.Name, + &record.Mode, + &record.Access, + &record.Protocol, + &record.Host, + &record.IsBootstrapped, + &record.MaterializationStatus, + &record.MaterializationError, + ); err != nil { + return InstallationRecord{}, err + } + + if strings.TrimSpace(record.MaterializationStatus) == "" { + record.MaterializationStatus = materializationNotStarted + } + + return record, nil +} + +func loadPrimaryAdmin(ctx context.Context, tx pgx.Tx) (AdminSummary, error) { + var admin AdminSummary + if err := tx.QueryRow(ctx, ` + SELECT id::text, email, display_name + FROM users + WHERE is_instance_admin = TRUE + ORDER BY created_at ASC + LIMIT 1; + `).Scan(&admin.ID, &admin.Email, &admin.DisplayName); err != nil { + return AdminSummary{}, err + } + + return admin, nil +} + +func (service *Service) loadBootstrapStructurePrerequisites( + ctx context.Context, + tx pgx.Tx, +) (bootstrapStructurePrerequisites, error) { + installation, err := loadInstallation(ctx, tx) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return bootstrapStructurePrerequisites{}, ErrInstallationNotConfigured + } + + return bootstrapStructurePrerequisites{}, err + } + + admin, err := loadPrimaryAdmin(ctx, tx) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return bootstrapStructurePrerequisites{}, ErrAdminNotConfigured + } + + return bootstrapStructurePrerequisites{}, err + } + + return bootstrapStructurePrerequisites{ + installation: installation, + admin: admin, + }, nil +} diff --git a/Backend/internal/bootstrap/bootstrap_materialization.go b/Backend/internal/bootstrap/bootstrap_materialization.go new file mode 100644 index 0000000..e1996e4 --- /dev/null +++ b/Backend/internal/bootstrap/bootstrap_materialization.go @@ -0,0 +1,180 @@ +package bootstrap + +import ( + "context" + "errors" + "fmt" + "strings" + + "moku-backend/internal/jobs" + "moku-backend/internal/posixproj" +) + +func (service *Service) enqueueBootstrapStructureMaterialization( + ctx context.Context, + installation *InstallationRecord, +) error { + if installation == nil { + return nil + } + + jobStore := jobs.NewStore(service.db) + if _, err := jobStore.Enqueue(ctx, jobs.EnqueueInput{ + Kind: jobs.KindBootstrapStructureMaterialize, + Payload: jobs.BootstrapStructureMaterializePayload{ + InstallationID: installation.ID, + }, + }); err != nil { + failure := fmt.Sprintf("enqueue bootstrap materialization job: %v", err) + if markErr := service.markBootstrapMaterializationFailed(ctx, installation.ID, failure); markErr != nil { + return errors.Join(err, markErr) + } + + // The relational bootstrap write already committed successfully, so keep the + // response successful and surface the enqueue problem via materialization state. + installation.MaterializationStatus = materializationFailed + installation.MaterializationError = &failure + } + + return nil +} + +func (service *Service) ProcessBootstrapStructureMaterialization(ctx context.Context, installationID string) error { + trimmedInstallationID := strings.TrimSpace(installationID) + if trimmedInstallationID == "" { + return ErrInstallationNotConfigured + } + + installation, err := service.GetInstallation(ctx) + if err != nil { + return err + } + if installation == nil || installation.ID != trimmedInstallationID { + return ErrInstallationNotConfigured + } + + if err := service.markBootstrapMaterializationRunning(ctx, trimmedInstallationID); err != nil { + return err + } + + if err := service.materializeBootstrapStructure(ctx, *installation); err != nil { + failure := strings.TrimSpace(err.Error()) + if failure == "" { + failure = "bootstrap materialization failed" + } + + if markErr := service.markBootstrapMaterializationFailed(ctx, trimmedInstallationID, failure); markErr != nil { + return errors.Join(err, markErr) + } + + return err + } + + return service.markBootstrapMaterializationSucceeded(ctx, trimmedInstallationID) +} + +func (service *Service) materializeBootstrapStructure(ctx context.Context, installation InstallationRecord) error { + admin, err := service.GetAdmin(ctx) + if err != nil { + return err + } + if admin == nil { + return ErrAdminNotConfigured + } + + organization, err := service.loadPrimaryOrganization(ctx) + if err != nil { + return err + } + if organization == nil { + return ErrBootstrapStructureMissing + } + + department, err := service.loadPrimaryDepartment(ctx) + if err != nil { + return err + } + if department == nil { + return ErrBootstrapStructureMissing + } + + team, err := service.loadPrimaryTeam(ctx) + if err != nil { + return err + } + if team == nil { + return ErrBootstrapStructureMissing + } + + project, err := service.loadPrimaryProject(ctx) + if err != nil { + return err + } + if project == nil { + return ErrBootstrapStructureMissing + } + + if err := service.ensureBootstrapPOSIXSkeleton( + installation, + AdminSummary{ID: admin.ID, Email: admin.Email, DisplayName: admin.DisplayName}, + namedRecord{ID: organization.ID, Name: organization.Name, Slug: organization.Slug}, + namedRecord{ID: department.ID, Name: department.Name, Slug: department.Slug}, + namedRecord{ID: team.ID, Name: team.Name, Slug: team.Slug}, + namedRecord{ID: project.ID, Name: project.Name, Slug: project.Slug}, + ); err != nil { + return err + } + + return service.rebuildProjection(ctx) +} + +func (service *Service) rebuildProjection(ctx context.Context) error { + if err := posixproj.NewProjector(service.db, service.posixRoot).Rebuild(ctx); err != nil { + return fmt.Errorf("rebuild POSIX projection: %w", err) + } + + return nil +} + +func (service *Service) markBootstrapMaterializationRunning(ctx context.Context, installationID string) error { + _, err := service.db.Pool.Exec(ctx, ` + UPDATE installations + SET + materialization_status = 'running'::bootstrap_materialization_status, + materialization_error = NULL, + materialization_started_at = NOW(), + materialization_finished_at = NULL, + updated_at = NOW() + WHERE id = $1::uuid; + `, strings.TrimSpace(installationID)) + + return err +} + +func (service *Service) markBootstrapMaterializationSucceeded(ctx context.Context, installationID string) error { + _, err := service.db.Pool.Exec(ctx, ` + UPDATE installations + SET + materialization_status = 'succeeded'::bootstrap_materialization_status, + materialization_error = NULL, + materialization_finished_at = NOW(), + updated_at = NOW() + WHERE id = $1::uuid; + `, strings.TrimSpace(installationID)) + + return err +} + +func (service *Service) markBootstrapMaterializationFailed(ctx context.Context, installationID, failure string) error { + _, err := service.db.Pool.Exec(ctx, ` + UPDATE installations + SET + materialization_status = 'failed'::bootstrap_materialization_status, + materialization_error = $2, + materialization_finished_at = NOW(), + updated_at = NOW() + WHERE id = $1::uuid; + `, strings.TrimSpace(installationID), strings.TrimSpace(failure)) + + return err +} diff --git a/Backend/internal/bootstrap/bootstrap_state.go b/Backend/internal/bootstrap/bootstrap_state.go new file mode 100644 index 0000000..3b0be72 --- /dev/null +++ b/Backend/internal/bootstrap/bootstrap_state.go @@ -0,0 +1,73 @@ +package bootstrap + +import "context" + +func (service *Service) GetState(ctx context.Context) (BootstrapState, error) { + installation, err := service.GetInstallation(ctx) + if err != nil { + return BootstrapState{}, err + } + + admin, err := service.GetAdmin(ctx) + if err != nil { + return BootstrapState{}, err + } + + structure, err := service.GetStructure(ctx) + if err != nil { + return BootstrapState{}, err + } + + return BootstrapState{ + Installation: installation, + Admin: admin, + Structure: structure, + }, nil +} + +func (service *Service) GetAppShellState(ctx context.Context) (AppShellState, error) { + installation, err := service.GetInstallation(ctx) + if err != nil { + return AppShellState{}, err + } + + admin, err := service.GetAdmin(ctx) + if err != nil { + return AppShellState{}, err + } + + organizations, err := service.listOrganizations(ctx) + if err != nil { + return AppShellState{}, err + } + + departments, err := service.listDepartments(ctx) + if err != nil { + return AppShellState{}, err + } + + teams, err := service.listTeams(ctx) + if err != nil { + return AppShellState{}, err + } + + projects, err := service.listProjects(ctx) + if err != nil { + return AppShellState{}, err + } + + workspaces, err := service.listWorkspaces(ctx) + if err != nil { + return AppShellState{}, err + } + + return AppShellState{ + Installation: installation, + Admin: admin, + Organizations: organizations, + Departments: departments, + Teams: teams, + Projects: projects, + Workspaces: workspaces, + }, nil +} diff --git a/Backend/internal/bootstrap/service.go b/Backend/internal/bootstrap/service.go index eedb36d..fca6a4c 100644 --- a/Backend/internal/bootstrap/service.go +++ b/Backend/internal/bootstrap/service.go @@ -17,7 +17,6 @@ import ( "github.com/jackc/pgx/v5" "moku-backend/internal/database" - "moku-backend/internal/posixproj" ) const ( @@ -48,6 +47,7 @@ const ( var ( ErrInstallationNotConfigured = errors.New("bootstrap installation step has not been completed") ErrAdminNotConfigured = errors.New("bootstrap admin step has not been completed") + ErrBootstrapStructureMissing = errors.New("bootstrap structure is incomplete") ErrProjectNotFound = errors.New("project not found") ErrProjectFolderNotFound = errors.New("project folder not found") ErrInvalidProjectFolderMove = errors.New("invalid project folder move") @@ -83,13 +83,15 @@ type SaveStructureInput struct { } type InstallationRecord struct { - ID string `json:"id"` - Name string `json:"name"` - Mode string `json:"mode"` - Access string `json:"access"` - Protocol string `json:"protocol"` - Host string `json:"host"` - IsBootstrapped bool `json:"isBootstrapped"` + ID string `json:"id"` + Name string `json:"name"` + Mode string `json:"mode"` + Access string `json:"access"` + Protocol string `json:"protocol"` + Host string `json:"host"` + IsBootstrapped bool `json:"isBootstrapped"` + MaterializationStatus string `json:"materializationStatus"` + MaterializationError *string `json:"materializationError,omitempty"` } type AdminRecord struct { @@ -194,29 +196,29 @@ type ProjectHierarchyFolderRecord struct { } type CreateProjectFolderInput struct { - ProjectID string - ParentFolderID string - Name string + ProjectID string + ParentFolderPath string + Name string } type DeleteProjectFolderInput struct { - ProjectID string - FolderID string + ProjectID string + FolderPath string } type RenameProjectFolderInput struct { - ProjectID string - FolderID string - Name string + ProjectID string + FolderPath string + Name string } type MoveProjectFolderInput struct { - ProjectID string - FolderID string - FolderNodeID string - ParentFolderID string - ParentNodeID string - TargetIndex int + ProjectID string + FolderPath string + FolderStableID string + ParentFolderPath string + ParentStableID string + TargetIndex int } type CreateProjectFolderResult struct { @@ -226,26 +228,26 @@ type CreateProjectFolderResult struct { } type DeleteProjectFolderResult struct { - ProjectID string `json:"projectId"` - DeletedFolderID string `json:"deletedFolderId"` - DeletedFolderPath string `json:"deletedFolderPath"` - Folders []ProjectHierarchyFolderRecord `json:"folders"` + ProjectID string `json:"projectId"` + DeletedFolderStableID string `json:"deletedFolderId"` + DeletedFolderPath string `json:"deletedFolderPath"` + Folders []ProjectHierarchyFolderRecord `json:"folders"` } type RenameProjectFolderResult struct { - ProjectID string `json:"projectId"` - PreviousFolderID string `json:"previousFolderId"` - PreviousFolderPath string `json:"previousFolderPath"` - RenamedFolder ProjectHierarchyFolderRecord `json:"renamedFolder"` - Folders []ProjectHierarchyFolderRecord `json:"folders"` + ProjectID string `json:"projectId"` + PreviousFolderStableID string `json:"previousFolderId"` + PreviousFolderPath string `json:"previousFolderPath"` + RenamedFolder ProjectHierarchyFolderRecord `json:"renamedFolder"` + Folders []ProjectHierarchyFolderRecord `json:"folders"` } type MoveProjectFolderResult struct { - ProjectID string `json:"projectId"` - PreviousFolderID string `json:"previousFolderId"` - PreviousFolderPath string `json:"previousFolderPath"` - MovedFolder ProjectHierarchyFolderRecord `json:"movedFolder"` - Folders []ProjectHierarchyFolderRecord `json:"folders"` + ProjectID string `json:"projectId"` + PreviousFolderStableID string `json:"previousFolderId"` + PreviousFolderPath string `json:"previousFolderPath"` + MovedFolder ProjectHierarchyFolderRecord `json:"movedFolder"` + Folders []ProjectHierarchyFolderRecord `json:"folders"` } type projectHierarchyFolderRow struct { @@ -276,7 +278,16 @@ func (service *Service) SaveInstance(ctx context.Context, input SaveInstanceInpu protocol = EXCLUDED.protocol, host = EXCLUDED.host, updated_at = NOW() - RETURNING id::text, name, mode::text, access::text, protocol::text, host, is_bootstrapped; + RETURNING + id::text, + name, + mode::text, + access::text, + protocol::text, + host, + is_bootstrapped, + materialization_status::text, + materialization_error; `, input.Access, input.Protocol, input.Host) return scanInstallationRecord(row) @@ -298,7 +309,16 @@ func (service *Service) SaveMode(ctx context.Context, input SaveModeInput) (Inst name = EXCLUDED.name, mode = EXCLUDED.mode, updated_at = NOW() - RETURNING id::text, name, mode::text, access::text, protocol::text, host, is_bootstrapped; + RETURNING + id::text, + name, + mode::text, + access::text, + protocol::text, + host, + is_bootstrapped, + materialization_status::text, + materialization_error; `, input.Mode, input.Name, defaultInstallationHost) return scanInstallationRecord(row) @@ -360,6 +380,11 @@ func (service *Service) SaveAdmin(ctx context.Context, input SaveAdminInput) (Ad return record, nil } +// SaveStructure persists the bootstrap domain records synchronously, then hands the +// slow POSIX/projector materialization work to the background worker. +// +// This keeps the API request responsible for validation and durable relational writes, +// while the worker owns retryable filesystem/projection side effects. func (service *Service) SaveStructure(ctx context.Context, input SaveStructureInput) (StructureRecord, error) { tx, err := service.db.Pool.BeginTx(ctx, pgx.TxOptions{}) if err != nil { @@ -369,27 +394,19 @@ func (service *Service) SaveStructure(ctx context.Context, input SaveStructureIn _ = tx.Rollback(ctx) }() - installation, err := loadInstallation(ctx, tx) + prerequisites, err := service.loadBootstrapStructurePrerequisites(ctx, tx) if err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return StructureRecord{}, ErrInstallationNotConfigured - } - - return StructureRecord{}, err - } - - admin, err := loadPrimaryAdmin(ctx, tx) - if err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return StructureRecord{}, ErrAdminNotConfigured - } - return StructureRecord{}, err } organizationName := strings.TrimSpace(input.OrganizationName) if organizationName == "" { - organizationName = defaultRootOrganizationName(installation.Name, installation.Mode, installation.Host, admin.DisplayName) + organizationName = defaultRootOrganizationName( + prerequisites.installation.Name, + prerequisites.installation.Mode, + prerequisites.installation.Host, + prerequisites.admin.DisplayName, + ) } organization, err := upsertNamedRecord(ctx, tx, ` @@ -398,7 +415,7 @@ func (service *Service) SaveStructure(ctx context.Context, input SaveStructureIn ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name, created_by_user_id = EXCLUDED.created_by_user_id, updated_at = NOW() RETURNING id::text, name, slug; - `, organizationName, primaryOrganizationSlug, admin.ID) + `, organizationName, primaryOrganizationSlug, prerequisites.admin.ID) if err != nil { return StructureRecord{}, err } @@ -408,7 +425,7 @@ func (service *Service) SaveStructure(ctx context.Context, input SaveStructureIn VALUES ($1::uuid, $2::uuid, 'owner'::membership_role) ON CONFLICT (organization_id, user_id) DO UPDATE SET role = EXCLUDED.role; - `, organization.ID, admin.ID); err != nil { + `, organization.ID, prerequisites.admin.ID); err != nil { return StructureRecord{}, err } @@ -418,7 +435,7 @@ func (service *Service) SaveStructure(ctx context.Context, input SaveStructureIn ON CONFLICT (organization_id, slug) DO UPDATE SET name = EXCLUDED.name, created_by_user_id = EXCLUDED.created_by_user_id, updated_at = NOW() RETURNING id::text, name, slug; - `, organization.ID, input.DepartmentName, primaryDepartmentSlug, admin.ID) + `, organization.ID, input.DepartmentName, primaryDepartmentSlug, prerequisites.admin.ID) if err != nil { return StructureRecord{}, err } @@ -429,7 +446,7 @@ func (service *Service) SaveStructure(ctx context.Context, input SaveStructureIn ON CONFLICT (organization_id, slug) DO UPDATE SET department_id = EXCLUDED.department_id, name = EXCLUDED.name, created_by_user_id = EXCLUDED.created_by_user_id, updated_at = NOW() RETURNING id::text, name, slug; - `, organization.ID, department.ID, input.TeamName, primaryTeamSlug, admin.ID) + `, organization.ID, department.ID, input.TeamName, primaryTeamSlug, prerequisites.admin.ID) if err != nil { return StructureRecord{}, err } @@ -439,7 +456,7 @@ func (service *Service) SaveStructure(ctx context.Context, input SaveStructureIn VALUES ($1::uuid, $2::uuid, 'owner'::membership_role) ON CONFLICT (team_id, user_id) DO UPDATE SET role = EXCLUDED.role; - `, team.ID, admin.ID); err != nil { + `, team.ID, prerequisites.admin.ID); err != nil { return StructureRecord{}, err } @@ -449,7 +466,7 @@ func (service *Service) SaveStructure(ctx context.Context, input SaveStructureIn ON CONFLICT (organization_id, slug) DO UPDATE SET department_id = EXCLUDED.department_id, team_id = EXCLUDED.team_id, name = EXCLUDED.name, created_by_user_id = EXCLUDED.created_by_user_id, updated_at = NOW() RETURNING id::text, name, slug; - `, organization.ID, department.ID, team.ID, input.ProjectName, primaryProjectSlug, admin.ID) + `, organization.ID, department.ID, team.ID, input.ProjectName, primaryProjectSlug, prerequisites.admin.ID) if err != nil { return StructureRecord{}, err } @@ -459,27 +476,27 @@ func (service *Service) SaveStructure(ctx context.Context, input SaveStructureIn VALUES ($1::uuid, $2::uuid, 'owner'::membership_role) ON CONFLICT (project_id, user_id) DO UPDATE SET role = EXCLUDED.role; - `, project.ID, admin.ID); err != nil { + `, project.ID, prerequisites.admin.ID); err != nil { return StructureRecord{}, err } - if err := upsertWorkspace(ctx, tx, organization.ID, organization.Name, organizationWorkspaceSlug, bootstrapWorkspaceKindOrg, admin.ID, nil, nil, nil); err != nil { + if err := upsertWorkspace(ctx, tx, organization.ID, organization.Name, organizationWorkspaceSlug, bootstrapWorkspaceKindOrg, prerequisites.admin.ID, nil, nil, nil); err != nil { return StructureRecord{}, err } - if err := upsertWorkspace(ctx, tx, organization.ID, department.Name, departmentWorkspaceSlug, bootstrapWorkspaceKindDept, admin.ID, &department.ID, nil, nil); err != nil { + if err := upsertWorkspace(ctx, tx, organization.ID, department.Name, departmentWorkspaceSlug, bootstrapWorkspaceKindDept, prerequisites.admin.ID, &department.ID, nil, nil); err != nil { return StructureRecord{}, err } - if err := upsertWorkspace(ctx, tx, organization.ID, team.Name, teamWorkspaceSlug, bootstrapWorkspaceKindTeam, admin.ID, &department.ID, &team.ID, nil); err != nil { + if err := upsertWorkspace(ctx, tx, organization.ID, team.Name, teamWorkspaceSlug, bootstrapWorkspaceKindTeam, prerequisites.admin.ID, &department.ID, &team.ID, nil); err != nil { return StructureRecord{}, err } - if err := upsertWorkspace(ctx, tx, organization.ID, project.Name, projectWorkspaceSlug, bootstrapWorkspaceKindProject, admin.ID, &department.ID, &team.ID, &project.ID); err != nil { + if err := upsertWorkspace(ctx, tx, organization.ID, project.Name, projectWorkspaceSlug, bootstrapWorkspaceKindProject, prerequisites.admin.ID, &department.ID, &team.ID, &project.ID); err != nil { return StructureRecord{}, err } - installation, err = updateBootstrappedInstallation(ctx, tx) + installation, err := updateBootstrappedInstallation(ctx, tx) if err != nil { return StructureRecord{}, err } @@ -488,21 +505,17 @@ func (service *Service) SaveStructure(ctx context.Context, input SaveStructureIn return StructureRecord{}, err } - if err := service.ensureBootstrapPOSIXSkeleton(installation, admin, organization, department, team, project); err != nil { + if err := service.enqueueBootstrapStructureMaterialization(ctx, &installation); err != nil { return StructureRecord{}, err } - if err := posixproj.NewProjector(service.db, service.posixRoot).Rebuild(ctx); err != nil { - return StructureRecord{}, fmt.Errorf("rebuild POSIX projection: %w", err) - } - return StructureRecord{ Installation: installation, Organization: organization, Department: department, Team: team, Project: project, - Admin: admin, + Admin: prerequisites.admin, }, nil } @@ -527,6 +540,7 @@ func (service *Service) ResetDevelopmentState(ctx context.Context) error { user_homes, users, organizations, + background_jobs, installations RESTART IDENTITY; `); err != nil { @@ -536,24 +550,6 @@ func (service *Service) ResetDevelopmentState(ctx context.Context) error { return tx.Commit(ctx) } -func (service *Service) GetInstallation(ctx context.Context) (*InstallationRecord, error) { - record, err := scanInstallationRecord(service.db.Pool.QueryRow(ctx, ` - SELECT id::text, name, mode::text, access::text, protocol::text, host, is_bootstrapped - FROM installations - WHERE singleton = TRUE - LIMIT 1; - `)) - if err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return nil, nil - } - - return nil, err - } - - return &record, nil -} - func (service *Service) GetAdmin(ctx context.Context) (*AdminRecord, error) { var record AdminRecord err := service.db.Pool.QueryRow(ctx, ` @@ -620,86 +616,6 @@ func (service *Service) GetStructure(ctx context.Context) (BootstrapStructureSta Workspaces: workspaces, }, nil } - -func (service *Service) GetState(ctx context.Context) (BootstrapState, error) { - installation, err := service.GetInstallation(ctx) - if err != nil { - return BootstrapState{}, err - } - - admin, err := service.GetAdmin(ctx) - if err != nil { - return BootstrapState{}, err - } - - structure, err := service.GetStructure(ctx) - if err != nil { - return BootstrapState{}, err - } - - return BootstrapState{ - Installation: installation, - Admin: admin, - Structure: structure, - }, nil -} - -func (service *Service) GetAppShellState(ctx context.Context) (AppShellState, error) { - installation, err := service.GetInstallation(ctx) - if err != nil { - return AppShellState{}, err - } - - admin, err := service.GetAdmin(ctx) - if err != nil { - return AppShellState{}, err - } - - organizations, err := service.listOrganizations(ctx) - if err != nil { - return AppShellState{}, err - } - - departments, err := service.listDepartments(ctx) - if err != nil { - return AppShellState{}, err - } - - teams, err := service.listTeams(ctx) - if err != nil { - return AppShellState{}, err - } - - projects, err := service.listProjects(ctx) - if err != nil { - return AppShellState{}, err - } - - workspaces, err := service.listWorkspaces(ctx) - if err != nil { - return AppShellState{}, err - } - - return AppShellState{ - Installation: installation, - Admin: admin, - Organizations: organizations, - Departments: departments, - Teams: teams, - Projects: projects, - Workspaces: workspaces, - }, nil -} - -func scanInstallationRecord(row pgx.Row) (InstallationRecord, error) { - var record InstallationRecord - if err := row.Scan(&record.ID, &record.Name, &record.Mode, &record.Access, &record.Protocol, &record.Host, &record.IsBootstrapped); err != nil { - return InstallationRecord{}, err - } - - return record, nil -} - func (service *Service) loadPrimaryOrganization(ctx context.Context) (*OrganizationRecord, error) { var record OrganizationRecord err := service.db.Pool.QueryRow(ctx, ` @@ -945,6 +861,9 @@ func (service *Service) getProjectHierarchyFoldersByRootPath( return nil, err } + // The projection gives us an unordered tree snapshot. Sibling order is stored in + // the project settings file, so the read path has to rebuild the tree first and + // then apply persisted ordering on top. folders := buildProjectHierarchyFolderTree(folderRows, rootParentPath) folderOrder := service.readProjectFolderOrder(project.Slug, rootParentPath) @@ -987,8 +906,14 @@ func (service *Service) createProjectHierarchyFolder( ctx context.Context, input CreateProjectFolderInput, rootPath func(projectSlug string) string, - createOnDisk func(projectSlug, parentFolderID, name string) (string, string, error), + createOnDisk func(projectSlug, parentFolderPath, name string) (string, string, error), ) (CreateProjectFolderResult, error) { + // Folder mutations follow the same pattern: + // 1. validate/resolve against the current ordered tree + // 2. mutate POSIX on disk + // 3. rebuild the projection snapshot + // 4. rewrite sibling ordering metadata + // 5. re-read the ordered tree that the frontend should trust project, err := service.loadProjectByID(ctx, strings.TrimSpace(input.ProjectID)) if err != nil { return CreateProjectFolderResult{}, err @@ -1000,22 +925,22 @@ func (service *Service) createProjectHierarchyFolder( } parentOrderID := "" - trimmedParentFolderID := strings.TrimSpace(input.ParentFolderID) - if trimmedParentFolderID != "" { - parentFolder, found := findProjectHierarchyFolderByPath(currentFolders, trimmedParentFolderID) + trimmedParentFolderPath := strings.TrimSpace(input.ParentFolderPath) + if trimmedParentFolderPath != "" { + parentFolder, found := findProjectHierarchyFolderByPath(currentFolders, trimmedParentFolderPath) if !found { return CreateProjectFolderResult{}, ErrProjectFolderNotFound } parentOrderID = parentFolder.ID } - createdPath, _, err := createOnDisk(project.Slug, strings.TrimSpace(input.ParentFolderID), input.Name) + createdPath, _, err := createOnDisk(project.Slug, strings.TrimSpace(input.ParentFolderPath), input.Name) if err != nil { return CreateProjectFolderResult{}, err } - if err := posixproj.NewProjector(service.db, service.posixRoot).Rebuild(ctx); err != nil { - return CreateProjectFolderResult{}, fmt.Errorf("rebuild POSIX projection: %w", err) + if err := service.rebuildProjection(ctx); err != nil { + return CreateProjectFolderResult{}, err } folders, err := service.getProjectHierarchyFoldersByRootPath(ctx, project.ID, rootPath) @@ -1056,7 +981,7 @@ func (service *Service) deleteProjectHierarchyFolder( ctx context.Context, input DeleteProjectFolderInput, rootPath func(projectSlug string) string, - deleteOnDisk func(projectSlug, folderID string) (string, error), + deleteOnDisk func(projectSlug, folderPath string) (string, error), ) (DeleteProjectFolderResult, error) { project, err := service.loadProjectByID(ctx, strings.TrimSpace(input.ProjectID)) if err != nil { @@ -1068,18 +993,18 @@ func (service *Service) deleteProjectHierarchyFolder( return DeleteProjectFolderResult{}, err } - deletedFolder, found := findProjectHierarchyFolderByPath(currentFolders, strings.TrimSpace(input.FolderID)) + deletedFolder, found := findProjectHierarchyFolderByPath(currentFolders, strings.TrimSpace(input.FolderPath)) if !found { return DeleteProjectFolderResult{}, ErrProjectFolderNotFound } - deletedFolderID, err := deleteOnDisk(project.Slug, input.FolderID) + deletedFolderPath, err := deleteOnDisk(project.Slug, input.FolderPath) if err != nil { return DeleteProjectFolderResult{}, err } - if err := posixproj.NewProjector(service.db, service.posixRoot).Rebuild(ctx); err != nil { - return DeleteProjectFolderResult{}, fmt.Errorf("rebuild POSIX projection: %w", err) + if err := service.rebuildProjection(ctx); err != nil { + return DeleteProjectFolderResult{}, err } folders, err := service.getProjectHierarchyFoldersByRootPath(ctx, project.ID, rootPath) @@ -1087,7 +1012,7 @@ func (service *Service) deleteProjectHierarchyFolder( return DeleteProjectFolderResult{}, err } - if _, found := findProjectHierarchyFolderByPath(folders, deletedFolderID); found { + if _, found := findProjectHierarchyFolderByPath(folders, deletedFolderPath); found { return DeleteProjectFolderResult{}, fmt.Errorf("deleted project folder still present in projection") } @@ -1103,10 +1028,10 @@ func (service *Service) deleteProjectHierarchyFolder( } return DeleteProjectFolderResult{ - ProjectID: project.ID, - DeletedFolderID: deletedFolder.ID, - DeletedFolderPath: deletedFolderID, - Folders: folders, + ProjectID: project.ID, + DeletedFolderStableID: deletedFolder.ID, + DeletedFolderPath: deletedFolderPath, + Folders: folders, }, nil } @@ -1114,20 +1039,20 @@ func (service *Service) renameProjectHierarchyFolder( ctx context.Context, input RenameProjectFolderInput, rootPath func(projectSlug string) string, - renameOnDisk func(projectSlug, folderID, name string) (string, string, error), + renameOnDisk func(projectSlug, folderPath, name string) (string, string, error), ) (RenameProjectFolderResult, error) { project, err := service.loadProjectByID(ctx, strings.TrimSpace(input.ProjectID)) if err != nil { return RenameProjectFolderResult{}, err } - previousFolderID, renamedFolderID, err := renameOnDisk(project.Slug, input.FolderID, input.Name) + previousFolderPath, renamedFolderPath, err := renameOnDisk(project.Slug, input.FolderPath, input.Name) if err != nil { return RenameProjectFolderResult{}, err } - if err := posixproj.NewProjector(service.db, service.posixRoot).Rebuild(ctx); err != nil { - return RenameProjectFolderResult{}, fmt.Errorf("rebuild POSIX projection: %w", err) + if err := service.rebuildProjection(ctx); err != nil { + return RenameProjectFolderResult{}, err } folders, err := service.getProjectHierarchyFoldersByRootPath(ctx, project.ID, rootPath) @@ -1135,21 +1060,21 @@ func (service *Service) renameProjectHierarchyFolder( return RenameProjectFolderResult{}, err } - renamedFolder, found := findProjectHierarchyFolderByPath(folders, renamedFolderID) + renamedFolder, found := findProjectHierarchyFolderByPath(folders, renamedFolderPath) if !found { return RenameProjectFolderResult{}, fmt.Errorf("renamed project folder missing from projection") } - if _, found := findProjectHierarchyFolderByPath(folders, previousFolderID); found { + if _, found := findProjectHierarchyFolderByPath(folders, previousFolderPath); found { return RenameProjectFolderResult{}, fmt.Errorf("previous project folder path still present in projection") } return RenameProjectFolderResult{ - ProjectID: project.ID, - PreviousFolderID: renamedFolder.ID, - PreviousFolderPath: previousFolderID, - RenamedFolder: renamedFolder, - Folders: folders, + ProjectID: project.ID, + PreviousFolderStableID: renamedFolder.ID, + PreviousFolderPath: previousFolderPath, + RenamedFolder: renamedFolder, + Folders: folders, }, nil } @@ -1157,7 +1082,7 @@ func (service *Service) moveProjectHierarchyFolder( ctx context.Context, input MoveProjectFolderInput, rootPath func(projectSlug string) string, - moveOnDisk func(projectSlug, folderID, parentFolderID string) (string, string, error), + moveOnDisk func(projectSlug, folderPath, parentFolderPath string) (string, string, error), ) (MoveProjectFolderResult, error) { project, err := service.loadProjectByID(ctx, strings.TrimSpace(input.ProjectID)) if err != nil { @@ -1169,40 +1094,40 @@ func (service *Service) moveProjectHierarchyFolder( return MoveProjectFolderResult{}, err } - currentFolder, found := findProjectHierarchyFolderByPath(currentFolders, strings.TrimSpace(input.FolderID)) + currentFolder, found := findProjectHierarchyFolderByPath(currentFolders, strings.TrimSpace(input.FolderPath)) if !found { return MoveProjectFolderResult{}, ErrProjectFolderNotFound } - movedFolderOrderID := currentFolder.ID - providedFolderNodeID := strings.TrimSpace(input.FolderNodeID) - if providedFolderNodeID != "" && providedFolderNodeID != movedFolderOrderID { + movedFolderStableID := currentFolder.ID + providedFolderStableID := strings.TrimSpace(input.FolderStableID) + if providedFolderStableID != "" && providedFolderStableID != movedFolderStableID { return MoveProjectFolderResult{}, ErrInvalidProjectFolderMove } parentOrderID := "" - trimmedParentFolderID := strings.TrimSpace(input.ParentFolderID) - providedParentNodeID := strings.TrimSpace(input.ParentNodeID) - if trimmedParentFolderID != "" { - parentFolder, found := findProjectHierarchyFolderByPath(currentFolders, trimmedParentFolderID) + trimmedParentFolderPath := strings.TrimSpace(input.ParentFolderPath) + providedParentStableID := strings.TrimSpace(input.ParentStableID) + if trimmedParentFolderPath != "" { + parentFolder, found := findProjectHierarchyFolderByPath(currentFolders, trimmedParentFolderPath) if !found { return MoveProjectFolderResult{}, ErrProjectFolderNotFound } parentOrderID = parentFolder.ID - if providedParentNodeID != "" && providedParentNodeID != parentOrderID { + if providedParentStableID != "" && providedParentStableID != parentOrderID { return MoveProjectFolderResult{}, ErrInvalidProjectFolderMove } - } else if providedParentNodeID != "" { + } else if providedParentStableID != "" { return MoveProjectFolderResult{}, ErrInvalidProjectFolderMove } - previousFolderID, movedFolderID, err := moveOnDisk(project.Slug, input.FolderID, input.ParentFolderID) + previousFolderPath, movedFolderPath, err := moveOnDisk(project.Slug, input.FolderPath, input.ParentFolderPath) if err != nil { return MoveProjectFolderResult{}, err } - if err := posixproj.NewProjector(service.db, service.posixRoot).Rebuild(ctx); err != nil { - return MoveProjectFolderResult{}, fmt.Errorf("rebuild POSIX projection: %w", err) + if err := service.rebuildProjection(ctx); err != nil { + return MoveProjectFolderResult{}, err } folders, err := service.getProjectHierarchyFoldersByRootPath(ctx, project.ID, rootPath) @@ -1210,20 +1135,20 @@ func (service *Service) moveProjectHierarchyFolder( return MoveProjectFolderResult{}, err } - movedFolder, found := findProjectHierarchyFolderByPath(folders, movedFolderID) + movedFolder, found := findProjectHierarchyFolderByPath(folders, movedFolderPath) if !found { return MoveProjectFolderResult{}, fmt.Errorf("moved project folder missing from projection") } - if previousFolderID != movedFolderID { - if _, found := findProjectHierarchyFolderByPath(folders, previousFolderID); found { + if previousFolderPath != movedFolderPath { + if _, found := findProjectHierarchyFolderByPath(folders, previousFolderPath); found { return MoveProjectFolderResult{}, fmt.Errorf("previous project folder path still present in projection") } } folderOrder := service.readProjectFolderOrder(project.Slug, rootPath(project.Slug)) seedFolderOrderParent(folderOrder, currentFolders, parentOrderID) - removeFolderOrderReference(folderOrder, movedFolderOrderID) + removeFolderOrderReference(folderOrder, movedFolderStableID) removeFolderOrderReference(folderOrder, movedFolder.ID) insertFolderOrder(folderOrder, parentOrderID, movedFolder.ID, input.TargetIndex) if err := service.writeProjectFolderOrder(project.Slug, rootPath(project.Slug), folderOrder); err != nil { @@ -1235,17 +1160,17 @@ func (service *Service) moveProjectHierarchyFolder( return MoveProjectFolderResult{}, err } - movedFolder, found = findProjectHierarchyFolderByPath(folders, movedFolderID) + movedFolder, found = findProjectHierarchyFolderByPath(folders, movedFolderPath) if !found { return MoveProjectFolderResult{}, fmt.Errorf("moved project folder missing from ordered projection") } return MoveProjectFolderResult{ - ProjectID: project.ID, - PreviousFolderID: movedFolder.ID, - PreviousFolderPath: previousFolderID, - MovedFolder: movedFolder, - Folders: folders, + ProjectID: project.ID, + PreviousFolderStableID: movedFolder.ID, + PreviousFolderPath: previousFolderPath, + MovedFolder: movedFolder, + Folders: folders, }, nil } @@ -1273,39 +1198,6 @@ func (service *Service) listWorkspaces(ctx context.Context) ([]WorkspaceRecord, return records, rows.Err() } -func loadInstallation(ctx context.Context, tx pgx.Tx) (InstallationRecord, error) { - return scanInstallationRecord(tx.QueryRow(ctx, ` - SELECT id::text, name, mode::text, access::text, protocol::text, host, is_bootstrapped - FROM installations - WHERE singleton = TRUE - LIMIT 1; - `)) -} - -func loadPrimaryAdmin(ctx context.Context, tx pgx.Tx) (AdminSummary, error) { - var admin AdminSummary - if err := tx.QueryRow(ctx, ` - SELECT id::text, email, display_name - FROM users - WHERE is_instance_admin = TRUE - ORDER BY created_at ASC - LIMIT 1; - `).Scan(&admin.ID, &admin.Email, &admin.DisplayName); err != nil { - return AdminSummary{}, err - } - - return admin, nil -} - -func updateBootstrappedInstallation(ctx context.Context, tx pgx.Tx) (InstallationRecord, error) { - return scanInstallationRecord(tx.QueryRow(ctx, ` - UPDATE installations - SET is_bootstrapped = TRUE, bootstrapped_at = COALESCE(bootstrapped_at, NOW()), updated_at = NOW() - WHERE singleton = TRUE - RETURNING id::text, name, mode::text, access::text, protocol::text, host, is_bootstrapped; - `)) -} - func upsertNamedRecord(ctx context.Context, tx pgx.Tx, query string, args ...any) (namedRecord, error) { var record namedRecord if err := tx.QueryRow(ctx, query, args...).Scan(&record.ID, &record.Name, &record.Slug); err != nil { diff --git a/Backend/internal/bootstrap/service_test.go b/Backend/internal/bootstrap/service_test.go index f88fc14..fb9b56e 100644 --- a/Backend/internal/bootstrap/service_test.go +++ b/Backend/internal/bootstrap/service_test.go @@ -9,6 +9,66 @@ import ( "testing" ) +type fakeRow struct { + scan func(dest ...any) error +} + +func (row fakeRow) Scan(dest ...any) error { + return row.scan(dest...) +} + +func TestScanInstallationRecordDefaultsMaterializationStatus(t *testing.T) { + record, err := scanInstallationRecord(fakeRow{scan: func(dest ...any) error { + *(dest[0].(*string)) = "installation-1" + *(dest[1].(*string)) = "MangoPig" + *(dest[2].(*string)) = "personal" + *(dest[3].(*string)) = "local" + *(dest[4].(*string)) = "http" + *(dest[5].(*string)) = "localhost" + *(dest[6].(*bool)) = true + *(dest[7].(*string)) = "" + *(dest[8].(**string)) = nil + return nil + }}) + if err != nil { + t.Fatalf("scanInstallationRecord: %v", err) + } + + if record.MaterializationStatus != materializationNotStarted { + t.Fatalf("expected default materialization status %q, got %q", materializationNotStarted, record.MaterializationStatus) + } + if record.MaterializationError != nil { + t.Fatalf("expected nil materialization error, got %#v", record.MaterializationError) + } +} + +func TestScanInstallationRecordPreservesMaterializationFields(t *testing.T) { + failure := "projection rebuild failed" + + record, err := scanInstallationRecord(fakeRow{scan: func(dest ...any) error { + *(dest[0].(*string)) = "installation-2" + *(dest[1].(*string)) = "MangoPig" + *(dest[2].(*string)) = "personal" + *(dest[3].(*string)) = "local" + *(dest[4].(*string)) = "http" + *(dest[5].(*string)) = "localhost" + *(dest[6].(*bool)) = true + *(dest[7].(*string)) = materializationFailed + *(dest[8].(**string)) = &failure + return nil + }}) + if err != nil { + t.Fatalf("scanInstallationRecord: %v", err) + } + + if record.MaterializationStatus != materializationFailed { + t.Fatalf("expected materialization status %q, got %q", materializationFailed, record.MaterializationStatus) + } + if record.MaterializationError == nil || *record.MaterializationError != failure { + t.Fatalf("expected materialization error %q, got %#v", failure, record.MaterializationError) + } +} + func TestEnsureBootstrapPOSIXSkeletonInitializesEmptyRoot(t *testing.T) { rootPath := filepath.Join(t.TempDir(), "POSIX") t.Setenv("POSIX_ROOT", rootPath) diff --git a/Backend/internal/httpx/api_project_routes.go b/Backend/internal/httpx/api_project_routes.go index acc2530..0a48cf8 100644 --- a/Backend/internal/httpx/api_project_routes.go +++ b/Backend/internal/httpx/api_project_routes.go @@ -13,25 +13,27 @@ import ( ) type createProjectFolderRequest struct { - Name string `json:"name"` - ParentFolderID string `json:"parentFolderId"` + Name string `json:"name"` + ParentFolderPath string `json:"parentFolderId"` } type renameProjectFolderRequest struct { - FolderID string `json:"folderId"` - Name string `json:"name"` + FolderPath string `json:"folderId"` + Name string `json:"name"` } type deleteProjectFolderRequest struct { - FolderID string `json:"folderId"` + FolderPath string `json:"folderId"` } +// Keep the existing JSON contract for the frontend, but use clearer path-vs-stable-ID +// names internally so the move flow is easier to reason about. type moveProjectFolderRequest struct { - FolderID string `json:"folderId"` - FolderNodeID string `json:"folderNodeId"` - ParentFolderID string `json:"parentFolderId"` - ParentNodeID string `json:"parentNodeId"` - TargetIndex int `json:"targetIndex"` + FolderPath string `json:"folderId"` + FolderStableID string `json:"folderNodeId"` + ParentFolderPath string `json:"parentFolderId"` + ParentStableID string `json:"parentNodeId"` + TargetIndex int `json:"targetIndex"` } func (routes apiRoutes) handleProjectFolders(w http.ResponseWriter, r *http.Request) { @@ -71,16 +73,16 @@ func (routes apiRoutes) handleCreateProjectFolder(w http.ResponseWriter, r *http } payload.Name = strings.TrimSpace(payload.Name) - payload.ParentFolderID = strings.TrimSpace(payload.ParentFolderID) + payload.ParentFolderPath = strings.TrimSpace(payload.ParentFolderPath) if payload.Name == "" { WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Folder name is required.") return } result, err := routes.bootstrapService().CreateProjectFolder(r.Context(), bootstrapservice.CreateProjectFolderInput{ - ProjectID: projectID, - ParentFolderID: payload.ParentFolderID, - Name: payload.Name, + ProjectID: projectID, + ParentFolderPath: payload.ParentFolderPath, + Name: payload.Name, }) if err != nil { routes.writeProjectFolderError(w, r, err, "persist") @@ -104,14 +106,14 @@ func (routes apiRoutes) handleDeleteProjectFolder(w http.ResponseWriter, r *http } payload := decodeDeleteProjectFolderRequest(r) - if strings.TrimSpace(payload.FolderID) == "" { + if strings.TrimSpace(payload.FolderPath) == "" { WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Folder ID is required.") return } result, err := routes.bootstrapService().DeleteProjectFolder(r.Context(), bootstrapservice.DeleteProjectFolderInput{ - ProjectID: projectID, - FolderID: payload.FolderID, + ProjectID: projectID, + FolderPath: payload.FolderPath, }) if err != nil { routes.writeProjectFolderError(w, r, err, "delete") @@ -139,9 +141,9 @@ func (routes apiRoutes) handleRenameProjectFolder(w http.ResponseWriter, r *http return } - payload.FolderID = strings.TrimSpace(payload.FolderID) + payload.FolderPath = strings.TrimSpace(payload.FolderPath) payload.Name = strings.TrimSpace(payload.Name) - if payload.FolderID == "" { + if payload.FolderPath == "" { WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Folder ID is required.") return } @@ -151,9 +153,9 @@ func (routes apiRoutes) handleRenameProjectFolder(w http.ResponseWriter, r *http } result, err := routes.bootstrapService().RenameProjectFolder(r.Context(), bootstrapservice.RenameProjectFolderInput{ - ProjectID: projectID, - FolderID: payload.FolderID, - Name: payload.Name, + ProjectID: projectID, + FolderPath: payload.FolderPath, + Name: payload.Name, }) if err != nil { routes.writeProjectFolderError(w, r, err, "rename") @@ -181,22 +183,22 @@ func (routes apiRoutes) handleMoveProjectFolder(w http.ResponseWriter, r *http.R return } - payload.FolderID = strings.TrimSpace(payload.FolderID) - payload.FolderNodeID = strings.TrimSpace(payload.FolderNodeID) - payload.ParentFolderID = strings.TrimSpace(payload.ParentFolderID) - payload.ParentNodeID = strings.TrimSpace(payload.ParentNodeID) - if payload.FolderID == "" { + payload.FolderPath = strings.TrimSpace(payload.FolderPath) + payload.FolderStableID = strings.TrimSpace(payload.FolderStableID) + payload.ParentFolderPath = strings.TrimSpace(payload.ParentFolderPath) + payload.ParentStableID = strings.TrimSpace(payload.ParentStableID) + if payload.FolderPath == "" { WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Folder ID is required.") return } result, err := routes.bootstrapService().MoveProjectFolder(r.Context(), bootstrapservice.MoveProjectFolderInput{ - ProjectID: projectID, - FolderID: payload.FolderID, - FolderNodeID: payload.FolderNodeID, - ParentFolderID: payload.ParentFolderID, - ParentNodeID: payload.ParentNodeID, - TargetIndex: payload.TargetIndex, + ProjectID: projectID, + FolderPath: payload.FolderPath, + FolderStableID: payload.FolderStableID, + ParentFolderPath: payload.ParentFolderPath, + ParentStableID: payload.ParentStableID, + TargetIndex: payload.TargetIndex, }) if err != nil { routes.writeProjectFolderError(w, r, err, "move") @@ -249,16 +251,16 @@ func (routes apiRoutes) handleCreateProjectTreeFolder(w http.ResponseWriter, r * } payload.Name = strings.TrimSpace(payload.Name) - payload.ParentFolderID = strings.TrimSpace(payload.ParentFolderID) + payload.ParentFolderPath = strings.TrimSpace(payload.ParentFolderPath) if payload.Name == "" { WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Folder name is required.") return } result, err := routes.bootstrapService().CreateProjectTreeFolder(r.Context(), bootstrapservice.CreateProjectFolderInput{ - ProjectID: projectID, - ParentFolderID: payload.ParentFolderID, - Name: payload.Name, + ProjectID: projectID, + ParentFolderPath: payload.ParentFolderPath, + Name: payload.Name, }) if err != nil { routes.writeProjectFolderError(w, r, err, "persist") @@ -282,14 +284,14 @@ func (routes apiRoutes) handleDeleteProjectTreeFolder(w http.ResponseWriter, r * } payload := decodeDeleteProjectFolderRequest(r) - if strings.TrimSpace(payload.FolderID) == "" { + if strings.TrimSpace(payload.FolderPath) == "" { WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Folder ID is required.") return } result, err := routes.bootstrapService().DeleteProjectTreeFolder(r.Context(), bootstrapservice.DeleteProjectFolderInput{ - ProjectID: projectID, - FolderID: payload.FolderID, + ProjectID: projectID, + FolderPath: payload.FolderPath, }) if err != nil { routes.writeProjectFolderError(w, r, err, "delete") @@ -317,9 +319,9 @@ func (routes apiRoutes) handleRenameProjectTreeFolder(w http.ResponseWriter, r * return } - payload.FolderID = strings.TrimSpace(payload.FolderID) + payload.FolderPath = strings.TrimSpace(payload.FolderPath) payload.Name = strings.TrimSpace(payload.Name) - if payload.FolderID == "" { + if payload.FolderPath == "" { WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Folder ID is required.") return } @@ -329,9 +331,9 @@ func (routes apiRoutes) handleRenameProjectTreeFolder(w http.ResponseWriter, r * } result, err := routes.bootstrapService().RenameProjectTreeFolder(r.Context(), bootstrapservice.RenameProjectFolderInput{ - ProjectID: projectID, - FolderID: payload.FolderID, - Name: payload.Name, + ProjectID: projectID, + FolderPath: payload.FolderPath, + Name: payload.Name, }) if err != nil { routes.writeProjectFolderError(w, r, err, "rename") @@ -359,22 +361,22 @@ func (routes apiRoutes) handleMoveProjectTreeFolder(w http.ResponseWriter, r *ht return } - payload.FolderID = strings.TrimSpace(payload.FolderID) - payload.FolderNodeID = strings.TrimSpace(payload.FolderNodeID) - payload.ParentFolderID = strings.TrimSpace(payload.ParentFolderID) - payload.ParentNodeID = strings.TrimSpace(payload.ParentNodeID) - if payload.FolderID == "" { + payload.FolderPath = strings.TrimSpace(payload.FolderPath) + payload.FolderStableID = strings.TrimSpace(payload.FolderStableID) + payload.ParentFolderPath = strings.TrimSpace(payload.ParentFolderPath) + payload.ParentStableID = strings.TrimSpace(payload.ParentStableID) + if payload.FolderPath == "" { WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Folder ID is required.") return } result, err := routes.bootstrapService().MoveProjectTreeFolder(r.Context(), bootstrapservice.MoveProjectFolderInput{ - ProjectID: projectID, - FolderID: payload.FolderID, - FolderNodeID: payload.FolderNodeID, - ParentFolderID: payload.ParentFolderID, - ParentNodeID: payload.ParentNodeID, - TargetIndex: payload.TargetIndex, + ProjectID: projectID, + FolderPath: payload.FolderPath, + FolderStableID: payload.FolderStableID, + ParentFolderPath: payload.ParentFolderPath, + ParentStableID: payload.ParentStableID, + TargetIndex: payload.TargetIndex, }) if err != nil { routes.writeProjectFolderError(w, r, err, "move") @@ -432,7 +434,7 @@ func decodeMoveProjectFolderRequest(w http.ResponseWriter, r *http.Request) (mov func decodeDeleteProjectFolderRequest(r *http.Request) deleteProjectFolderRequest { return deleteProjectFolderRequest{ - FolderID: strings.TrimSpace(r.URL.Query().Get("folderId")), + FolderPath: strings.TrimSpace(r.URL.Query().Get("folderId")), } } diff --git a/Backend/internal/jobs/store.go b/Backend/internal/jobs/store.go new file mode 100644 index 0000000..6958cd4 --- /dev/null +++ b/Backend/internal/jobs/store.go @@ -0,0 +1,196 @@ +package jobs + +import ( + "context" + "encoding/json" + "strings" + "time" + + "github.com/jackc/pgx/v5" + + "moku-backend/internal/database" +) + +const ( + KindBootstrapStructureMaterialize = "bootstrap.structure.materialize" +) + +type Status string + +const ( + StatusPending Status = "pending" + StatusRunning Status = "running" + StatusSucceeded Status = "succeeded" + StatusFailed Status = "failed" +) + +type BootstrapStructureMaterializePayload struct { + InstallationID string `json:"installationId"` +} + +type Job struct { + ID string + Kind string + Status Status + Payload json.RawMessage + Attempts int + MaxAttempts int + AvailableAt time.Time + StartedAt *time.Time + FinishedAt *time.Time + LastError *string + CreatedAt time.Time + UpdatedAt time.Time +} + +type EnqueueInput struct { + Kind string + Payload any + AvailableAt time.Time + MaxAttempts int +} + +type Store struct { + db *database.DB +} + +func NewStore(db *database.DB) *Store { + return &Store{db: db} +} + +func (store *Store) Enqueue(ctx context.Context, input EnqueueInput) (Job, error) { + payload := json.RawMessage([]byte(`{}`)) + if input.Payload != nil { + encoded, err := json.Marshal(input.Payload) + if err != nil { + return Job{}, err + } + payload = encoded + } + + availableAt := input.AvailableAt + if availableAt.IsZero() { + availableAt = time.Now().UTC() + } + + maxAttempts := input.MaxAttempts + if maxAttempts < 1 { + maxAttempts = 1 + } + + return scanJob(store.db.Pool.QueryRow(ctx, ` + INSERT INTO background_jobs (kind, status, payload, attempts, max_attempts, available_at) + VALUES ($1, 'pending'::background_job_status, $2::jsonb, 0, $3, $4) + RETURNING + id::text, + kind, + status::text, + payload, + attempts, + max_attempts, + available_at, + started_at, + finished_at, + last_error, + created_at, + updated_at; + `, strings.TrimSpace(input.Kind), payload, maxAttempts, availableAt)) +} + +func (store *Store) ClaimNext(ctx context.Context) (*Job, error) { + job, err := scanJob(store.db.Pool.QueryRow(ctx, ` + WITH next_job AS ( + SELECT id + FROM background_jobs + WHERE status = 'pending'::background_job_status + AND available_at <= NOW() + ORDER BY created_at ASC + LIMIT 1 + FOR UPDATE SKIP LOCKED + ) + UPDATE background_jobs AS jobs + SET + status = 'running'::background_job_status, + attempts = jobs.attempts + 1, + started_at = NOW(), + finished_at = NULL, + last_error = NULL, + updated_at = NOW() + FROM next_job + WHERE jobs.id = next_job.id + RETURNING + jobs.id::text, + jobs.kind, + jobs.status::text, + jobs.payload, + jobs.attempts, + jobs.max_attempts, + jobs.available_at, + jobs.started_at, + jobs.finished_at, + jobs.last_error, + jobs.created_at, + jobs.updated_at; + `)) + if err != nil { + if err == pgx.ErrNoRows { + return nil, nil + } + + return nil, err + } + + return &job, nil +} + +func (store *Store) MarkSucceeded(ctx context.Context, jobID string) error { + _, err := store.db.Pool.Exec(ctx, ` + UPDATE background_jobs + SET + status = 'succeeded'::background_job_status, + finished_at = NOW(), + last_error = NULL, + updated_at = NOW() + WHERE id = $1::uuid; + `, strings.TrimSpace(jobID)) + + return err +} + +func (store *Store) MarkFailed(ctx context.Context, jobID, failure string) error { + _, err := store.db.Pool.Exec(ctx, ` + UPDATE background_jobs + SET + status = 'failed'::background_job_status, + finished_at = NOW(), + last_error = $2, + updated_at = NOW() + WHERE id = $1::uuid; + `, strings.TrimSpace(jobID), strings.TrimSpace(failure)) + + return err +} + +func scanJob(row pgx.Row) (Job, error) { + var job Job + var status string + if err := row.Scan( + &job.ID, + &job.Kind, + &status, + &job.Payload, + &job.Attempts, + &job.MaxAttempts, + &job.AvailableAt, + &job.StartedAt, + &job.FinishedAt, + &job.LastError, + &job.CreatedAt, + &job.UpdatedAt, + ); err != nil { + return Job{}, err + } + + job.Status = Status(status) + return job, nil +} diff --git a/Backend/internal/worker/runner.go b/Backend/internal/worker/runner.go new file mode 100644 index 0000000..c4f0cc2 --- /dev/null +++ b/Backend/internal/worker/runner.go @@ -0,0 +1,118 @@ +package worker + +import ( + "context" + "fmt" + "log/slog" + "strings" + "time" + + "moku-backend/internal/jobs" +) + +type JobStore interface { + ClaimNext(ctx context.Context) (*jobs.Job, error) + MarkSucceeded(ctx context.Context, jobID string) error + MarkFailed(ctx context.Context, jobID, failure string) error +} + +type Handler func(ctx context.Context, job jobs.Job) error + +type Runner struct { + store JobStore + logger *slog.Logger + pollInterval time.Duration + handlers map[string]Handler +} + +func NewRunner(store JobStore, logger *slog.Logger, pollInterval time.Duration) *Runner { + interval := pollInterval + if interval <= 0 { + interval = time.Second + } + + return &Runner{ + store: store, + logger: logger, + pollInterval: interval, + handlers: make(map[string]Handler), + } +} + +func (runner *Runner) Register(kind string, handler Handler) { + runner.handlers[strings.TrimSpace(kind)] = handler +} + +func (runner *Runner) Run(ctx context.Context) error { + for { + select { + case <-ctx.Done(): + return nil + default: + } + + job, err := runner.store.ClaimNext(ctx) + if err != nil { + if ctx.Err() != nil { + return nil + } + + return err + } + + if job == nil { + if err := waitForNextPoll(ctx, runner.pollInterval); err != nil { + return nil + } + + continue + } + + handler, ok := runner.handlers[job.Kind] + if !ok { + failure := fmt.Sprintf("no handler registered for job kind %q", job.Kind) + if err := runner.store.MarkFailed(ctx, job.ID, failure); err != nil { + return err + } + + runner.logger.Error("worker job failed", "jobID", job.ID, "kind", job.Kind, "error", failure) + continue + } + + if err := handler(ctx, *job); err != nil { + if ctx.Err() != nil { + return nil + } + + failure := strings.TrimSpace(err.Error()) + if failure == "" { + failure = "job handler returned an empty error" + } + + if markErr := runner.store.MarkFailed(ctx, job.ID, failure); markErr != nil { + return markErr + } + + runner.logger.Error("worker job failed", "jobID", job.ID, "kind", job.Kind, "error", failure) + continue + } + + if err := runner.store.MarkSucceeded(ctx, job.ID); err != nil { + return err + } + + runner.logger.Info("worker job succeeded", "jobID", job.ID, "kind", job.Kind) + } +} + +func waitForNextPoll(ctx context.Context, interval time.Duration) error { + timer := time.NewTimer(interval) + defer timer.Stop() + + select { + case <-ctx.Done(): + return ctx.Err() + case <-timer.C: + return nil + } +} diff --git a/Backend/internal/worker/runner_test.go b/Backend/internal/worker/runner_test.go new file mode 100644 index 0000000..e0c33ca --- /dev/null +++ b/Backend/internal/worker/runner_test.go @@ -0,0 +1,161 @@ +package worker + +import ( + "context" + "errors" + "io" + "log/slog" + "strings" + "sync" + "testing" + + "moku-backend/internal/jobs" +) + +func TestRunnerProcessesRegisteredJob(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + store := &fakeJobStore{ + job: &jobs.Job{ + ID: "job-1", + Kind: jobs.KindBootstrapStructureMaterialize, + Payload: []byte(`{"installationId":"installation-1"}`), + }, + cancel: cancel, + } + runner := NewRunner(store, slog.New(slog.NewTextHandler(io.Discard, nil)), 0) + + handlerCalled := false + runner.Register(jobs.KindBootstrapStructureMaterialize, func(ctx context.Context, job jobs.Job) error { + handlerCalled = true + if job.ID != "job-1" { + t.Fatalf("expected job id job-1, got %s", job.ID) + } + return nil + }) + + if err := runner.Run(ctx); err != nil { + t.Fatalf("runner returned error: %v", err) + } + + if !handlerCalled { + t.Fatal("expected handler to be called") + } + if len(store.succeeded) != 1 || store.succeeded[0] != "job-1" { + t.Fatalf("expected job to be marked succeeded once, got %#v", store.succeeded) + } + if len(store.failed) != 0 { + t.Fatalf("expected no failed jobs, got %#v", store.failed) + } +} + +func TestRunnerMarksFailedWhenHandlerErrors(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + store := &fakeJobStore{ + job: &jobs.Job{ + ID: "job-2", + Kind: jobs.KindBootstrapStructureMaterialize, + }, + cancel: cancel, + } + runner := NewRunner(store, slog.New(slog.NewTextHandler(io.Discard, nil)), 0) + runner.Register(jobs.KindBootstrapStructureMaterialize, func(ctx context.Context, job jobs.Job) error { + return errors.New("boom") + }) + + if err := runner.Run(ctx); err != nil { + t.Fatalf("runner returned error: %v", err) + } + + if len(store.succeeded) != 0 { + t.Fatalf("expected no succeeded jobs, got %#v", store.succeeded) + } + if len(store.failed) != 1 { + t.Fatalf("expected one failed job, got %#v", store.failed) + } + if store.failed[0].jobID != "job-2" { + t.Fatalf("expected failed job id job-2, got %#v", store.failed[0]) + } + if !strings.Contains(store.failed[0].failure, "boom") { + t.Fatalf("expected failure to mention handler error, got %#v", store.failed[0]) + } +} + +func TestRunnerMarksFailedWhenHandlerMissing(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + store := &fakeJobStore{ + job: &jobs.Job{ + ID: "job-3", + Kind: "unknown.kind", + }, + cancel: cancel, + } + runner := NewRunner(store, slog.New(slog.NewTextHandler(io.Discard, nil)), 0) + + if err := runner.Run(ctx); err != nil { + t.Fatalf("runner returned error: %v", err) + } + + if len(store.failed) != 1 { + t.Fatalf("expected one failed job, got %#v", store.failed) + } + if !strings.Contains(store.failed[0].failure, "no handler registered") { + t.Fatalf("expected missing handler failure, got %#v", store.failed[0]) + } +} + +type fakeJobStore struct { + mu sync.Mutex + job *jobs.Job + claimed bool + succeeded []string + failed []fakeFailure + cancel context.CancelFunc +} + +type fakeFailure struct { + jobID string + failure string +} + +func (store *fakeJobStore) ClaimNext(ctx context.Context) (*jobs.Job, error) { + store.mu.Lock() + defer store.mu.Unlock() + + if store.claimed || store.job == nil { + return nil, nil + } + + store.claimed = true + job := *store.job + return &job, nil +} + +func (store *fakeJobStore) MarkSucceeded(ctx context.Context, jobID string) error { + store.mu.Lock() + store.succeeded = append(store.succeeded, jobID) + store.mu.Unlock() + + if store.cancel != nil { + store.cancel() + } + + return nil +} + +func (store *fakeJobStore) MarkFailed(ctx context.Context, jobID, failure string) error { + store.mu.Lock() + store.failed = append(store.failed, fakeFailure{jobID: jobID, failure: failure}) + store.mu.Unlock() + + if store.cancel != nil { + store.cancel() + } + + return nil +} diff --git a/Frontend/src/components/shell/ProjectSelector/ProjectSelector.tsx b/Frontend/src/components/shell/ProjectSelector/ProjectSelector.tsx index 5898080..683ddf4 100644 --- a/Frontend/src/components/shell/ProjectSelector/ProjectSelector.tsx +++ b/Frontend/src/components/shell/ProjectSelector/ProjectSelector.tsx @@ -103,6 +103,9 @@ const buildProjectTree = ( items: readonly ProjectItem[], folders: readonly PersistedProjectFolderRecord[] = [], ): ProjectTreeNode[] => [ + // The selector still presents scaffold project leaves beside persisted folders. + // Keep that mixed root shape explicit here so the drag/drop logic can account + // for folder-only ordering when we persist sibling positions. ...items.map((item) => ({ kind: "project" as const, item, @@ -110,6 +113,11 @@ const buildProjectTree = ( ...buildPersistedFolderNodes(folders), ]; +const countProjectFolderSiblingsBeforeIndex = ( + siblings: readonly ProjectTreeNode[], + index: number, +): number => siblings.slice(0, index).filter((node) => node.kind === "folder").length; + const readPersistedFolders = (body: ProjectFoldersResponse): PersistedProjectFolderRecord[] => Array.isArray(body.data?.folders) ? body.data.folders : []; @@ -596,10 +604,11 @@ export const ProjectSelector = (props: ProjectSelectorProps): JSX.Element => { ? persistedParentLocation.node.children : [] : previewNodes; + // The preview tree includes project leaves and folders, but the backend only + // stores sibling order for folders. Persist a folder-only index so the server + // can reapply the same position against the authoritative ordered tree. const targetIndex = previewLocation - ? previewSiblings - .slice(0, previewLocation.index) - .filter((node) => node.kind === "folder").length + ? countProjectFolderSiblingsBeforeIndex(previewSiblings, previewLocation.index) : 0; if ( @@ -795,12 +804,12 @@ export const ProjectSelector = (props: ProjectSelectorProps): JSX.Element => { const movePersistedFolder = async ( folderPath: string, parentFolderPath: string | null, - folderNodeId: string, - parentNodeId: string | null, + folderStableId: string, + parentStableId: string | null, targetIndex: number, ): Promise => { const projectId = selectedProject().id; - if (!folderPath || !folderNodeId || !isUuidString(projectId)) { + if (!folderPath || !folderStableId || !isUuidString(projectId)) { return; } @@ -813,9 +822,9 @@ export const ProjectSelector = (props: ProjectSelectorProps): JSX.Element => { }, body: JSON.stringify({ folderId: folderPath, - folderNodeId, + folderNodeId: folderStableId, parentFolderId: parentFolderPath, - parentNodeId, + parentNodeId: parentStableId, targetIndex, }), }); diff --git a/Frontend/src/components/shell/WorkspaceSidebar/WorkspaceSidebar.tsx b/Frontend/src/components/shell/WorkspaceSidebar/WorkspaceSidebar.tsx index 7ac65cc..99d8609 100644 --- a/Frontend/src/components/shell/WorkspaceSidebar/WorkspaceSidebar.tsx +++ b/Frontend/src/components/shell/WorkspaceSidebar/WorkspaceSidebar.tsx @@ -93,6 +93,11 @@ const buildPersistedWorkspaceFolderNodes = ( const readPersistedWorkspaceFolders = (body: WorkspaceFoldersResponse): PersistedWorkspaceFolderRecord[] => Array.isArray(body.data?.folders) ? body.data.folders : []; +const countWorkspaceFolderSiblingsBeforeIndex = ( + siblings: readonly WorkspaceTreeNode[], + index: number, +): number => siblings.slice(0, index).filter((node) => node.kind === "folder").length; + const workspaceTreeAdapter: NavTreeAdapter = { getNodeId: getWorkspaceTreeNodeId, isBranchNode: (node) => node.kind === "folder", @@ -548,10 +553,11 @@ export const WorkspaceSidebar = (props: WorkspaceSidebarProps): JSX.Element => { ? persistedParentLocation.node.children ?? [] : [] : previewNodes; + // The tree preview can include static/workspace items, but persisted ordering + // only applies to folder siblings. Convert the preview position into a folder- + // only index before sending it to the backend move endpoint. const targetIndex = previewLocation - ? previewSiblings - .slice(0, previewLocation.index) - .filter((node) => node.kind === "folder").length + ? countWorkspaceFolderSiblingsBeforeIndex(previewSiblings, previewLocation.index) : 0; if ( @@ -716,12 +722,12 @@ export const WorkspaceSidebar = (props: WorkspaceSidebarProps): JSX.Element => { const movePersistedFolder = async ( folderPath: string, parentFolderPath: string | null, - folderNodeId: string, - parentNodeId: string | null, + folderStableId: string, + parentStableId: string | null, targetIndex: number, ): Promise => { const projectId = activeProject()?.id ?? ""; - if (!folderPath || !folderNodeId || !projectId || !isUuidString(projectId)) { + if (!folderPath || !folderStableId || !projectId || !isUuidString(projectId)) { return; } @@ -734,9 +740,9 @@ export const WorkspaceSidebar = (props: WorkspaceSidebarProps): JSX.Element => { }, body: JSON.stringify({ folderId: folderPath, - folderNodeId, + folderNodeId: folderStableId, parentFolderId: parentFolderPath, - parentNodeId, + parentNodeId: parentStableId, targetIndex, }), }); diff --git a/Frontend/src/components/shell/data/app-shell.context.tsx b/Frontend/src/components/shell/data/app-shell.context.tsx index 5b00c16..66198e7 100644 --- a/Frontend/src/components/shell/data/app-shell.context.tsx +++ b/Frontend/src/components/shell/data/app-shell.context.tsx @@ -40,6 +40,8 @@ type AppShellInstallation = { protocol: string; host: string; isBootstrapped: boolean; + materializationStatus: "not_started" | "pending" | "running" | "succeeded" | "failed" | string; + materializationError?: string; }; type AppShellAdmin = { @@ -101,8 +103,29 @@ type AppShellPayload = { workspaces: AppShellWorkspace[]; }; +const normalizeInstallation = ( + installation: AppShellInstallation | null | undefined, +): AppShellInstallation | undefined => { + if (!installation) { + return undefined; + } + + const materializationStatus = installation.materializationStatus?.trim() + ? installation.materializationStatus + : installation.isBootstrapped + ? "succeeded" + : "not_started"; + const materializationError = installation.materializationError?.trim() || undefined; + + return { + ...installation, + materializationStatus, + materializationError, + }; +}; + const normalizeAppShellPayload = (payload: AppShellPayload | null | undefined): AppShellPayload => ({ - installation: payload?.installation, + installation: normalizeInstallation(payload?.installation), admin: payload?.admin, organizations: Array.isArray(payload?.organizations) ? payload.organizations : [], departments: Array.isArray(payload?.departments) ? payload.departments : [], diff --git a/Frontend/src/components/workspace-home/WorkspaceHome/WorkspaceHome.module.scss b/Frontend/src/components/workspace-home/WorkspaceHome/WorkspaceHome.module.scss index 33131bd..cca8a94 100644 --- a/Frontend/src/components/workspace-home/WorkspaceHome/WorkspaceHome.module.scss +++ b/Frontend/src/components/workspace-home/WorkspaceHome/WorkspaceHome.module.scss @@ -92,6 +92,21 @@ flex-wrap: wrap; } +.heroStatus { + display: grid; + gap: var(--space-2); + justify-items: start; +} + +.heroStatusMessage { + max-width: 64ch; + color: var(--color-text-muted); +} + +.heroStatusMessage[data-status="failed"] { + color: var(--color-danger-text, var(--color-text)); +} + .title { @include text-display; font-family: var(--font-family-display); @@ -194,6 +209,19 @@ background: color-mix(in srgb, var(--color-success-surface, var(--color-surface-secondary)) 80%, transparent); } +.statusBadge[data-status="pending"], +.statusBadge[data-status="running"] { + color: var(--bootstrap-accent); + border-color: color-mix(in srgb, var(--bootstrap-accent) 38%, transparent); + background: color-mix(in srgb, var(--bootstrap-accent) 10%, var(--color-surface-secondary)); +} + +.statusBadge[data-status="failed"] { + color: var(--color-danger-text, var(--color-text)); + border-color: color-mix(in srgb, var(--color-danger-border, var(--color-border)) 68%, transparent); + background: color-mix(in srgb, var(--color-danger-surface, var(--color-surface-secondary)) 80%, transparent); +} + .statusBadge[data-status="error"] { color: var(--color-danger-text, var(--color-text)); border-color: color-mix(in srgb, var(--color-danger-border, var(--color-border)) 68%, transparent); diff --git a/Frontend/src/components/workspace-home/WorkspaceHome/WorkspaceHome.tsx b/Frontend/src/components/workspace-home/WorkspaceHome/WorkspaceHome.tsx index a9731e8..6bfbd41 100644 --- a/Frontend/src/components/workspace-home/WorkspaceHome/WorkspaceHome.tsx +++ b/Frontend/src/components/workspace-home/WorkspaceHome/WorkspaceHome.tsx @@ -1,6 +1,6 @@ // Path: Frontend/src/components/workspace-home/WorkspaceHome/WorkspaceHome.tsx -import { For, Show, createEffect, createMemo, createSignal, type JSX } from "solid-js"; +import { For, Show, createEffect, createMemo, createSignal, onCleanup, type JSX } from "solid-js"; import { Portal } from "solid-js/web"; import { createStore } from "solid-js/store"; import { resolveAPIBase } from "../../../lib/api"; @@ -44,6 +44,8 @@ type StructureForm = { projectName: string; }; +type MaterializationState = "not_started" | "pending" | "running" | "succeeded" | "failed"; + const bootstrapStepDefinitions: readonly BootstrapStepDefinition[] = [ { id: "instance", @@ -104,6 +106,8 @@ const initialSubmissionState = (): BootstrapSubmissionState => ({ error: "", }); +const materializationPollIntervalMs = 2000; + const readResponseBody = async (response: Response): Promise => { const raw = await response.text(); @@ -185,6 +189,51 @@ export const WorkspaceHome = (props: WorkspaceHomeProps): JSX.Element => { const [isBootstrapComplete, setIsBootstrapComplete] = createSignal(false); const [isWizardOpen, setIsWizardOpen] = createSignal(false); const [currentStepIndex, setCurrentStepIndex] = createSignal(0); + const installation = createMemo(() => appShellData.installation()); + const materializationState = createMemo(() => { + const status = installation()?.materializationStatus; + + switch (status) { + case "pending": + case "running": + case "failed": + case "succeeded": + case "not_started": + return status; + default: + return installation()?.isBootstrapped ? "succeeded" : "not_started"; + } + }); + const isBootstrapPersisted = createMemo(() => installation()?.isBootstrapped ?? false); + const isMaterializationInFlight = createMemo( + () => materializationState() === "pending" || materializationState() === "running", + ); + const hasMaterializationFailed = createMemo(() => materializationState() === "failed"); + const materializationStatusLabel = createMemo(() => { + switch (materializationState()) { + case "pending": + return "Materialization queued"; + case "running": + return "Materialization running"; + case "failed": + return "Materialization failed"; + case "succeeded": + return "Ready"; + default: + return "Not started"; + } + }); + const materializationMessage = createMemo(() => { + if (isMaterializationInFlight()) { + return "Your bootstrap is saved. The worker is still creating the POSIX skeleton and rebuilding the app shell index."; + } + + if (hasMaterializationFailed()) { + return installation()?.materializationError || "Bootstrap saved, but background materialization did not finish cleanly."; + } + + return ""; + }); createEffect(() => { if (modeForm.mode === "personal") { @@ -213,19 +262,51 @@ export const WorkspaceHome = (props: WorkspaceHomeProps): JSX.Element => { return; } - const installationAccessor = appShellData.installation; - const installation = typeof installationAccessor === "function" ? installationAccessor() : undefined; - const isPersistedBootstrap = installation?.isBootstrapped ?? false; - - if (!isPersistedBootstrap) { + if (!isBootstrapPersisted()) { resetWizardState(); } - setIsBootstrapComplete(isPersistedBootstrap); - setIsWizardOpen(!isPersistedBootstrap); + setIsBootstrapComplete(isBootstrapPersisted() && !isMaterializationInFlight()); + setIsWizardOpen(!isBootstrapPersisted()); setIsBootstrapStateResolved(true); }); + createEffect(() => { + if (!isBootstrapPersisted() || !isMaterializationInFlight()) { + return; + } + + let cancelled = false; + let timeoutId: number | undefined; + + const scheduleReload = (): void => { + timeoutId = window.setTimeout(async () => { + if (cancelled) { + return; + } + + // The final bootstrap step only persists relational state. Poll while the + // worker is materializing the POSIX skeleton so the page can transition from + // queued/running to ready/failed without a manual refresh. + await appShellData.reload(); + + if (!cancelled && isBootstrapPersisted() && isMaterializationInFlight()) { + scheduleReload(); + } + }, materializationPollIntervalMs); + }; + + scheduleReload(); + + onCleanup(() => { + cancelled = true; + + if (timeoutId !== undefined) { + window.clearTimeout(timeoutId); + } + }); + }); + const sidebarToggleLabel = (): string => props.sidebarCollapsed ? "Expand left workspace sidebar" : "Collapse left workspace sidebar"; const breadcrumb = (): string => `${appShellData.activeServer().name} / ${appShellData.activeProject().name} / Home`; @@ -240,7 +321,7 @@ export const WorkspaceHome = (props: WorkspaceHomeProps): JSX.Element => { const currentStepState = createMemo(() => stepState[currentStep().id]); const isFirstStep = (): boolean => currentStepIndex() === 0; const isLastStep = (): boolean => currentStepIndex() === bootstrapStepDefinitions.length - 1; - const canDismissWizard = (): boolean => isBootstrapComplete(); + const canDismissWizard = (): boolean => isBootstrapPersisted(); const resetWizardState = (): void => { setInstanceForm({ ...defaultInstanceForm }); @@ -313,12 +394,9 @@ export const WorkspaceHome = (props: WorkspaceHomeProps): JSX.Element => { if (isLastStep()) { await appShellData.reload(); - const installationAccessor = appShellData.installation; - const installation = typeof installationAccessor === "function" ? installationAccessor() : undefined; - const isPersistedBootstrap = installation?.isBootstrapped ?? false; - setIsBootstrapComplete(isPersistedBootstrap); - setIsWizardOpen(!isPersistedBootstrap); + setIsBootstrapComplete(isBootstrapPersisted() && !isMaterializationInFlight()); + setIsWizardOpen(!isBootstrapPersisted()); setIsBootstrapStateResolved(true); return; } @@ -383,20 +461,33 @@ export const WorkspaceHome = (props: WorkspaceHomeProps): JSX.Element => {
-

{isBootstrapComplete() ? appShellData.activeServer().name : bootstrapTargetLabel()}

- -
+

{isBootstrapPersisted() ? appShellData.activeServer().name : bootstrapTargetLabel()}

+ +
+
+ {materializationStatusLabel()} +
+ +

+ {materializationMessage()} +

+
+
+
+ +
-
-
+ {isBootstrapPersisted() ? "Bootstrap saved" : "Open bootstrap wizard"} + +
+
@@ -456,12 +547,14 @@ export const WorkspaceHome = (props: WorkspaceHomeProps): JSX.Element => {
-
- {`Step ${currentStepIndex() + 1} of ${bootstrapStepDefinitions.length}`} -

{currentStep().title}

-
-
{statusLabel(currentStepState())}
-
+
+ {`Step ${currentStepIndex() + 1} of ${bootstrapStepDefinitions.length}`} +

{currentStep().title}

+
+
+ {statusLabel(currentStepState())} +
+