Compare commits

...

7 Commits

Author SHA1 Message Date
MangoPig 32acf6dc17 Merge branch 'Refactor/Usability' 2026-06-28 04:08:58 +01:00
MangoPig 6b87e8a1fe Refactor: improve bootstrap usability 2026-06-28 04:08:31 +01:00
MangoPig 3247f28c87 Merge branch 'Fix/Bootstrap' 2026-06-26 23:31:10 +01:00
MangoPig 891e8b83ed Fix: harden bootstrap worker flow 2026-06-26 23:30:50 +01:00
MangoPig ae1f347549 Merge branch 'Refactor/Code-Quality' 2026-06-26 18:33:12 +01:00
MangoPig 7e62ff6d9a Refactor: improve code quality and worker flow 2026-06-26 18:32:49 +01:00
MangoPig adcc9afe05 Merge branch 'Refactor/Folder-Layout' 2026-06-25 21:53:48 +01:00
20 changed files with 2992 additions and 941 deletions
+25 -3
View File
@@ -1,10 +1,16 @@
package main package main
import ( import (
"context"
"encoding/json"
"log" "log"
"os/signal"
"syscall"
"time"
"moku-backend/internal/bootstrap" "moku-backend/internal/bootstrap"
"moku-backend/internal/process" "moku-backend/internal/jobs"
"moku-backend/internal/worker"
) )
func main() { 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) app.Logger.Error("worker stopped", "error", err)
log.Fatal(err) log.Fatal(err)
} }
@@ -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;
@@ -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
}
@@ -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
}
@@ -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
}
+113 -221
View File
@@ -17,7 +17,6 @@ import (
"github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5"
"moku-backend/internal/database" "moku-backend/internal/database"
"moku-backend/internal/posixproj"
) )
const ( const (
@@ -48,6 +47,7 @@ const (
var ( var (
ErrInstallationNotConfigured = errors.New("bootstrap installation step has not been completed") ErrInstallationNotConfigured = errors.New("bootstrap installation step has not been completed")
ErrAdminNotConfigured = errors.New("bootstrap admin 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") ErrProjectNotFound = errors.New("project not found")
ErrProjectFolderNotFound = errors.New("project folder not found") ErrProjectFolderNotFound = errors.New("project folder not found")
ErrInvalidProjectFolderMove = errors.New("invalid project folder move") ErrInvalidProjectFolderMove = errors.New("invalid project folder move")
@@ -90,6 +90,8 @@ type InstallationRecord struct {
Protocol string `json:"protocol"` Protocol string `json:"protocol"`
Host string `json:"host"` Host string `json:"host"`
IsBootstrapped bool `json:"isBootstrapped"` IsBootstrapped bool `json:"isBootstrapped"`
MaterializationStatus string `json:"materializationStatus"`
MaterializationError *string `json:"materializationError,omitempty"`
} }
type AdminRecord struct { type AdminRecord struct {
@@ -195,27 +197,27 @@ type ProjectHierarchyFolderRecord struct {
type CreateProjectFolderInput struct { type CreateProjectFolderInput struct {
ProjectID string ProjectID string
ParentFolderID string ParentFolderPath string
Name string Name string
} }
type DeleteProjectFolderInput struct { type DeleteProjectFolderInput struct {
ProjectID string ProjectID string
FolderID string FolderPath string
} }
type RenameProjectFolderInput struct { type RenameProjectFolderInput struct {
ProjectID string ProjectID string
FolderID string FolderPath string
Name string Name string
} }
type MoveProjectFolderInput struct { type MoveProjectFolderInput struct {
ProjectID string ProjectID string
FolderID string FolderPath string
FolderNodeID string FolderStableID string
ParentFolderID string ParentFolderPath string
ParentNodeID string ParentStableID string
TargetIndex int TargetIndex int
} }
@@ -227,14 +229,14 @@ type CreateProjectFolderResult struct {
type DeleteProjectFolderResult struct { type DeleteProjectFolderResult struct {
ProjectID string `json:"projectId"` ProjectID string `json:"projectId"`
DeletedFolderID string `json:"deletedFolderId"` DeletedFolderStableID string `json:"deletedFolderId"`
DeletedFolderPath string `json:"deletedFolderPath"` DeletedFolderPath string `json:"deletedFolderPath"`
Folders []ProjectHierarchyFolderRecord `json:"folders"` Folders []ProjectHierarchyFolderRecord `json:"folders"`
} }
type RenameProjectFolderResult struct { type RenameProjectFolderResult struct {
ProjectID string `json:"projectId"` ProjectID string `json:"projectId"`
PreviousFolderID string `json:"previousFolderId"` PreviousFolderStableID string `json:"previousFolderId"`
PreviousFolderPath string `json:"previousFolderPath"` PreviousFolderPath string `json:"previousFolderPath"`
RenamedFolder ProjectHierarchyFolderRecord `json:"renamedFolder"` RenamedFolder ProjectHierarchyFolderRecord `json:"renamedFolder"`
Folders []ProjectHierarchyFolderRecord `json:"folders"` Folders []ProjectHierarchyFolderRecord `json:"folders"`
@@ -242,7 +244,7 @@ type RenameProjectFolderResult struct {
type MoveProjectFolderResult struct { type MoveProjectFolderResult struct {
ProjectID string `json:"projectId"` ProjectID string `json:"projectId"`
PreviousFolderID string `json:"previousFolderId"` PreviousFolderStableID string `json:"previousFolderId"`
PreviousFolderPath string `json:"previousFolderPath"` PreviousFolderPath string `json:"previousFolderPath"`
MovedFolder ProjectHierarchyFolderRecord `json:"movedFolder"` MovedFolder ProjectHierarchyFolderRecord `json:"movedFolder"`
Folders []ProjectHierarchyFolderRecord `json:"folders"` Folders []ProjectHierarchyFolderRecord `json:"folders"`
@@ -276,7 +278,16 @@ func (service *Service) SaveInstance(ctx context.Context, input SaveInstanceInpu
protocol = EXCLUDED.protocol, protocol = EXCLUDED.protocol,
host = EXCLUDED.host, host = EXCLUDED.host,
updated_at = NOW() 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) `, input.Access, input.Protocol, input.Host)
return scanInstallationRecord(row) return scanInstallationRecord(row)
@@ -298,7 +309,16 @@ func (service *Service) SaveMode(ctx context.Context, input SaveModeInput) (Inst
name = EXCLUDED.name, name = EXCLUDED.name,
mode = EXCLUDED.mode, mode = EXCLUDED.mode,
updated_at = NOW() 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) `, input.Mode, input.Name, defaultInstallationHost)
return scanInstallationRecord(row) return scanInstallationRecord(row)
@@ -360,6 +380,11 @@ func (service *Service) SaveAdmin(ctx context.Context, input SaveAdminInput) (Ad
return record, nil 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) { func (service *Service) SaveStructure(ctx context.Context, input SaveStructureInput) (StructureRecord, error) {
tx, err := service.db.Pool.BeginTx(ctx, pgx.TxOptions{}) tx, err := service.db.Pool.BeginTx(ctx, pgx.TxOptions{})
if err != nil { if err != nil {
@@ -369,27 +394,19 @@ func (service *Service) SaveStructure(ctx context.Context, input SaveStructureIn
_ = tx.Rollback(ctx) _ = tx.Rollback(ctx)
}() }()
installation, err := loadInstallation(ctx, tx) prerequisites, err := service.loadBootstrapStructurePrerequisites(ctx, tx)
if err != nil { 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 return StructureRecord{}, err
} }
organizationName := strings.TrimSpace(input.OrganizationName) organizationName := strings.TrimSpace(input.OrganizationName)
if 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, ` organization, err := upsertNamedRecord(ctx, tx, `
@@ -398,7 +415,7 @@ func (service *Service) SaveStructure(ctx context.Context, input SaveStructureIn
ON CONFLICT (slug) DO UPDATE ON CONFLICT (slug) DO UPDATE
SET name = EXCLUDED.name, created_by_user_id = EXCLUDED.created_by_user_id, updated_at = NOW() SET name = EXCLUDED.name, created_by_user_id = EXCLUDED.created_by_user_id, updated_at = NOW()
RETURNING id::text, name, slug; RETURNING id::text, name, slug;
`, organizationName, primaryOrganizationSlug, admin.ID) `, organizationName, primaryOrganizationSlug, prerequisites.admin.ID)
if err != nil { if err != nil {
return StructureRecord{}, err return StructureRecord{}, err
} }
@@ -408,7 +425,7 @@ func (service *Service) SaveStructure(ctx context.Context, input SaveStructureIn
VALUES ($1::uuid, $2::uuid, 'owner'::membership_role) VALUES ($1::uuid, $2::uuid, 'owner'::membership_role)
ON CONFLICT (organization_id, user_id) DO UPDATE ON CONFLICT (organization_id, user_id) DO UPDATE
SET role = EXCLUDED.role; SET role = EXCLUDED.role;
`, organization.ID, admin.ID); err != nil { `, organization.ID, prerequisites.admin.ID); err != nil {
return StructureRecord{}, err return StructureRecord{}, err
} }
@@ -418,7 +435,7 @@ func (service *Service) SaveStructure(ctx context.Context, input SaveStructureIn
ON CONFLICT (organization_id, slug) DO UPDATE ON CONFLICT (organization_id, slug) DO UPDATE
SET name = EXCLUDED.name, created_by_user_id = EXCLUDED.created_by_user_id, updated_at = NOW() SET name = EXCLUDED.name, created_by_user_id = EXCLUDED.created_by_user_id, updated_at = NOW()
RETURNING id::text, name, slug; RETURNING id::text, name, slug;
`, organization.ID, input.DepartmentName, primaryDepartmentSlug, admin.ID) `, organization.ID, input.DepartmentName, primaryDepartmentSlug, prerequisites.admin.ID)
if err != nil { if err != nil {
return StructureRecord{}, err return StructureRecord{}, err
} }
@@ -429,7 +446,7 @@ func (service *Service) SaveStructure(ctx context.Context, input SaveStructureIn
ON CONFLICT (organization_id, slug) DO UPDATE 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() 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; 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 { if err != nil {
return StructureRecord{}, err return StructureRecord{}, err
} }
@@ -439,7 +456,7 @@ func (service *Service) SaveStructure(ctx context.Context, input SaveStructureIn
VALUES ($1::uuid, $2::uuid, 'owner'::membership_role) VALUES ($1::uuid, $2::uuid, 'owner'::membership_role)
ON CONFLICT (team_id, user_id) DO UPDATE ON CONFLICT (team_id, user_id) DO UPDATE
SET role = EXCLUDED.role; SET role = EXCLUDED.role;
`, team.ID, admin.ID); err != nil { `, team.ID, prerequisites.admin.ID); err != nil {
return StructureRecord{}, err return StructureRecord{}, err
} }
@@ -449,7 +466,7 @@ func (service *Service) SaveStructure(ctx context.Context, input SaveStructureIn
ON CONFLICT (organization_id, slug) DO UPDATE 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() 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; 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 { if err != nil {
return StructureRecord{}, err return StructureRecord{}, err
} }
@@ -459,27 +476,27 @@ func (service *Service) SaveStructure(ctx context.Context, input SaveStructureIn
VALUES ($1::uuid, $2::uuid, 'owner'::membership_role) VALUES ($1::uuid, $2::uuid, 'owner'::membership_role)
ON CONFLICT (project_id, user_id) DO UPDATE ON CONFLICT (project_id, user_id) DO UPDATE
SET role = EXCLUDED.role; SET role = EXCLUDED.role;
`, project.ID, admin.ID); err != nil { `, project.ID, prerequisites.admin.ID); err != nil {
return StructureRecord{}, err 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 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 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 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 return StructureRecord{}, err
} }
installation, err = updateBootstrappedInstallation(ctx, tx) installation, err := updateBootstrappedInstallation(ctx, tx)
if err != nil { if err != nil {
return StructureRecord{}, err return StructureRecord{}, err
} }
@@ -488,21 +505,17 @@ func (service *Service) SaveStructure(ctx context.Context, input SaveStructureIn
return StructureRecord{}, err 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 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{ return StructureRecord{
Installation: installation, Installation: installation,
Organization: organization, Organization: organization,
Department: department, Department: department,
Team: team, Team: team,
Project: project, Project: project,
Admin: admin, Admin: prerequisites.admin,
}, nil }, nil
} }
@@ -527,6 +540,7 @@ func (service *Service) ResetDevelopmentState(ctx context.Context) error {
user_homes, user_homes,
users, users,
organizations, organizations,
background_jobs,
installations installations
RESTART IDENTITY; RESTART IDENTITY;
`); err != nil { `); err != nil {
@@ -536,24 +550,6 @@ func (service *Service) ResetDevelopmentState(ctx context.Context) error {
return tx.Commit(ctx) 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) { func (service *Service) GetAdmin(ctx context.Context) (*AdminRecord, error) {
var record AdminRecord var record AdminRecord
err := service.db.Pool.QueryRow(ctx, ` err := service.db.Pool.QueryRow(ctx, `
@@ -620,86 +616,6 @@ func (service *Service) GetStructure(ctx context.Context) (BootstrapStructureSta
Workspaces: workspaces, Workspaces: workspaces,
}, nil }, 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) { func (service *Service) loadPrimaryOrganization(ctx context.Context) (*OrganizationRecord, error) {
var record OrganizationRecord var record OrganizationRecord
err := service.db.Pool.QueryRow(ctx, ` err := service.db.Pool.QueryRow(ctx, `
@@ -945,6 +861,9 @@ func (service *Service) getProjectHierarchyFoldersByRootPath(
return nil, err 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) folders := buildProjectHierarchyFolderTree(folderRows, rootParentPath)
folderOrder := service.readProjectFolderOrder(project.Slug, rootParentPath) folderOrder := service.readProjectFolderOrder(project.Slug, rootParentPath)
@@ -987,8 +906,14 @@ func (service *Service) createProjectHierarchyFolder(
ctx context.Context, ctx context.Context,
input CreateProjectFolderInput, input CreateProjectFolderInput,
rootPath func(projectSlug string) string, 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) { ) (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)) project, err := service.loadProjectByID(ctx, strings.TrimSpace(input.ProjectID))
if err != nil { if err != nil {
return CreateProjectFolderResult{}, err return CreateProjectFolderResult{}, err
@@ -1000,22 +925,22 @@ func (service *Service) createProjectHierarchyFolder(
} }
parentOrderID := "" parentOrderID := ""
trimmedParentFolderID := strings.TrimSpace(input.ParentFolderID) trimmedParentFolderPath := strings.TrimSpace(input.ParentFolderPath)
if trimmedParentFolderID != "" { if trimmedParentFolderPath != "" {
parentFolder, found := findProjectHierarchyFolderByPath(currentFolders, trimmedParentFolderID) parentFolder, found := findProjectHierarchyFolderByPath(currentFolders, trimmedParentFolderPath)
if !found { if !found {
return CreateProjectFolderResult{}, ErrProjectFolderNotFound return CreateProjectFolderResult{}, ErrProjectFolderNotFound
} }
parentOrderID = parentFolder.ID 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 { if err != nil {
return CreateProjectFolderResult{}, err return CreateProjectFolderResult{}, err
} }
if err := posixproj.NewProjector(service.db, service.posixRoot).Rebuild(ctx); err != nil { if err := service.rebuildProjection(ctx); err != nil {
return CreateProjectFolderResult{}, fmt.Errorf("rebuild POSIX projection: %w", err) return CreateProjectFolderResult{}, err
} }
folders, err := service.getProjectHierarchyFoldersByRootPath(ctx, project.ID, rootPath) folders, err := service.getProjectHierarchyFoldersByRootPath(ctx, project.ID, rootPath)
@@ -1056,7 +981,7 @@ func (service *Service) deleteProjectHierarchyFolder(
ctx context.Context, ctx context.Context,
input DeleteProjectFolderInput, input DeleteProjectFolderInput,
rootPath func(projectSlug string) string, rootPath func(projectSlug string) string,
deleteOnDisk func(projectSlug, folderID string) (string, error), deleteOnDisk func(projectSlug, folderPath string) (string, error),
) (DeleteProjectFolderResult, error) { ) (DeleteProjectFolderResult, error) {
project, err := service.loadProjectByID(ctx, strings.TrimSpace(input.ProjectID)) project, err := service.loadProjectByID(ctx, strings.TrimSpace(input.ProjectID))
if err != nil { if err != nil {
@@ -1068,18 +993,18 @@ func (service *Service) deleteProjectHierarchyFolder(
return DeleteProjectFolderResult{}, err return DeleteProjectFolderResult{}, err
} }
deletedFolder, found := findProjectHierarchyFolderByPath(currentFolders, strings.TrimSpace(input.FolderID)) deletedFolder, found := findProjectHierarchyFolderByPath(currentFolders, strings.TrimSpace(input.FolderPath))
if !found { if !found {
return DeleteProjectFolderResult{}, ErrProjectFolderNotFound return DeleteProjectFolderResult{}, ErrProjectFolderNotFound
} }
deletedFolderID, err := deleteOnDisk(project.Slug, input.FolderID) deletedFolderPath, err := deleteOnDisk(project.Slug, input.FolderPath)
if err != nil { if err != nil {
return DeleteProjectFolderResult{}, err return DeleteProjectFolderResult{}, err
} }
if err := posixproj.NewProjector(service.db, service.posixRoot).Rebuild(ctx); err != nil { if err := service.rebuildProjection(ctx); err != nil {
return DeleteProjectFolderResult{}, fmt.Errorf("rebuild POSIX projection: %w", err) return DeleteProjectFolderResult{}, err
} }
folders, err := service.getProjectHierarchyFoldersByRootPath(ctx, project.ID, rootPath) folders, err := service.getProjectHierarchyFoldersByRootPath(ctx, project.ID, rootPath)
@@ -1087,7 +1012,7 @@ func (service *Service) deleteProjectHierarchyFolder(
return DeleteProjectFolderResult{}, err 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") return DeleteProjectFolderResult{}, fmt.Errorf("deleted project folder still present in projection")
} }
@@ -1104,8 +1029,8 @@ func (service *Service) deleteProjectHierarchyFolder(
return DeleteProjectFolderResult{ return DeleteProjectFolderResult{
ProjectID: project.ID, ProjectID: project.ID,
DeletedFolderID: deletedFolder.ID, DeletedFolderStableID: deletedFolder.ID,
DeletedFolderPath: deletedFolderID, DeletedFolderPath: deletedFolderPath,
Folders: folders, Folders: folders,
}, nil }, nil
} }
@@ -1114,20 +1039,20 @@ func (service *Service) renameProjectHierarchyFolder(
ctx context.Context, ctx context.Context,
input RenameProjectFolderInput, input RenameProjectFolderInput,
rootPath func(projectSlug string) string, 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) { ) (RenameProjectFolderResult, error) {
project, err := service.loadProjectByID(ctx, strings.TrimSpace(input.ProjectID)) project, err := service.loadProjectByID(ctx, strings.TrimSpace(input.ProjectID))
if err != nil { if err != nil {
return RenameProjectFolderResult{}, err 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 { if err != nil {
return RenameProjectFolderResult{}, err return RenameProjectFolderResult{}, err
} }
if err := posixproj.NewProjector(service.db, service.posixRoot).Rebuild(ctx); err != nil { if err := service.rebuildProjection(ctx); err != nil {
return RenameProjectFolderResult{}, fmt.Errorf("rebuild POSIX projection: %w", err) return RenameProjectFolderResult{}, err
} }
folders, err := service.getProjectHierarchyFoldersByRootPath(ctx, project.ID, rootPath) folders, err := service.getProjectHierarchyFoldersByRootPath(ctx, project.ID, rootPath)
@@ -1135,19 +1060,19 @@ func (service *Service) renameProjectHierarchyFolder(
return RenameProjectFolderResult{}, err return RenameProjectFolderResult{}, err
} }
renamedFolder, found := findProjectHierarchyFolderByPath(folders, renamedFolderID) renamedFolder, found := findProjectHierarchyFolderByPath(folders, renamedFolderPath)
if !found { if !found {
return RenameProjectFolderResult{}, fmt.Errorf("renamed project folder missing from projection") 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{}, fmt.Errorf("previous project folder path still present in projection")
} }
return RenameProjectFolderResult{ return RenameProjectFolderResult{
ProjectID: project.ID, ProjectID: project.ID,
PreviousFolderID: renamedFolder.ID, PreviousFolderStableID: renamedFolder.ID,
PreviousFolderPath: previousFolderID, PreviousFolderPath: previousFolderPath,
RenamedFolder: renamedFolder, RenamedFolder: renamedFolder,
Folders: folders, Folders: folders,
}, nil }, nil
@@ -1157,7 +1082,7 @@ func (service *Service) moveProjectHierarchyFolder(
ctx context.Context, ctx context.Context,
input MoveProjectFolderInput, input MoveProjectFolderInput,
rootPath func(projectSlug string) string, 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) { ) (MoveProjectFolderResult, error) {
project, err := service.loadProjectByID(ctx, strings.TrimSpace(input.ProjectID)) project, err := service.loadProjectByID(ctx, strings.TrimSpace(input.ProjectID))
if err != nil { if err != nil {
@@ -1169,40 +1094,40 @@ func (service *Service) moveProjectHierarchyFolder(
return MoveProjectFolderResult{}, err return MoveProjectFolderResult{}, err
} }
currentFolder, found := findProjectHierarchyFolderByPath(currentFolders, strings.TrimSpace(input.FolderID)) currentFolder, found := findProjectHierarchyFolderByPath(currentFolders, strings.TrimSpace(input.FolderPath))
if !found { if !found {
return MoveProjectFolderResult{}, ErrProjectFolderNotFound return MoveProjectFolderResult{}, ErrProjectFolderNotFound
} }
movedFolderOrderID := currentFolder.ID movedFolderStableID := currentFolder.ID
providedFolderNodeID := strings.TrimSpace(input.FolderNodeID) providedFolderStableID := strings.TrimSpace(input.FolderStableID)
if providedFolderNodeID != "" && providedFolderNodeID != movedFolderOrderID { if providedFolderStableID != "" && providedFolderStableID != movedFolderStableID {
return MoveProjectFolderResult{}, ErrInvalidProjectFolderMove return MoveProjectFolderResult{}, ErrInvalidProjectFolderMove
} }
parentOrderID := "" parentOrderID := ""
trimmedParentFolderID := strings.TrimSpace(input.ParentFolderID) trimmedParentFolderPath := strings.TrimSpace(input.ParentFolderPath)
providedParentNodeID := strings.TrimSpace(input.ParentNodeID) providedParentStableID := strings.TrimSpace(input.ParentStableID)
if trimmedParentFolderID != "" { if trimmedParentFolderPath != "" {
parentFolder, found := findProjectHierarchyFolderByPath(currentFolders, trimmedParentFolderID) parentFolder, found := findProjectHierarchyFolderByPath(currentFolders, trimmedParentFolderPath)
if !found { if !found {
return MoveProjectFolderResult{}, ErrProjectFolderNotFound return MoveProjectFolderResult{}, ErrProjectFolderNotFound
} }
parentOrderID = parentFolder.ID parentOrderID = parentFolder.ID
if providedParentNodeID != "" && providedParentNodeID != parentOrderID { if providedParentStableID != "" && providedParentStableID != parentOrderID {
return MoveProjectFolderResult{}, ErrInvalidProjectFolderMove return MoveProjectFolderResult{}, ErrInvalidProjectFolderMove
} }
} else if providedParentNodeID != "" { } else if providedParentStableID != "" {
return MoveProjectFolderResult{}, ErrInvalidProjectFolderMove 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 { if err != nil {
return MoveProjectFolderResult{}, err return MoveProjectFolderResult{}, err
} }
if err := posixproj.NewProjector(service.db, service.posixRoot).Rebuild(ctx); err != nil { if err := service.rebuildProjection(ctx); err != nil {
return MoveProjectFolderResult{}, fmt.Errorf("rebuild POSIX projection: %w", err) return MoveProjectFolderResult{}, err
} }
folders, err := service.getProjectHierarchyFoldersByRootPath(ctx, project.ID, rootPath) folders, err := service.getProjectHierarchyFoldersByRootPath(ctx, project.ID, rootPath)
@@ -1210,20 +1135,20 @@ func (service *Service) moveProjectHierarchyFolder(
return MoveProjectFolderResult{}, err return MoveProjectFolderResult{}, err
} }
movedFolder, found := findProjectHierarchyFolderByPath(folders, movedFolderID) movedFolder, found := findProjectHierarchyFolderByPath(folders, movedFolderPath)
if !found { if !found {
return MoveProjectFolderResult{}, fmt.Errorf("moved project folder missing from projection") return MoveProjectFolderResult{}, fmt.Errorf("moved project folder missing from projection")
} }
if previousFolderID != movedFolderID { if previousFolderPath != movedFolderPath {
if _, found := findProjectHierarchyFolderByPath(folders, previousFolderID); found { if _, found := findProjectHierarchyFolderByPath(folders, previousFolderPath); found {
return MoveProjectFolderResult{}, fmt.Errorf("previous project folder path still present in projection") return MoveProjectFolderResult{}, fmt.Errorf("previous project folder path still present in projection")
} }
} }
folderOrder := service.readProjectFolderOrder(project.Slug, rootPath(project.Slug)) folderOrder := service.readProjectFolderOrder(project.Slug, rootPath(project.Slug))
seedFolderOrderParent(folderOrder, currentFolders, parentOrderID) seedFolderOrderParent(folderOrder, currentFolders, parentOrderID)
removeFolderOrderReference(folderOrder, movedFolderOrderID) removeFolderOrderReference(folderOrder, movedFolderStableID)
removeFolderOrderReference(folderOrder, movedFolder.ID) removeFolderOrderReference(folderOrder, movedFolder.ID)
insertFolderOrder(folderOrder, parentOrderID, movedFolder.ID, input.TargetIndex) insertFolderOrder(folderOrder, parentOrderID, movedFolder.ID, input.TargetIndex)
if err := service.writeProjectFolderOrder(project.Slug, rootPath(project.Slug), folderOrder); err != nil { if err := service.writeProjectFolderOrder(project.Slug, rootPath(project.Slug), folderOrder); err != nil {
@@ -1235,15 +1160,15 @@ func (service *Service) moveProjectHierarchyFolder(
return MoveProjectFolderResult{}, err return MoveProjectFolderResult{}, err
} }
movedFolder, found = findProjectHierarchyFolderByPath(folders, movedFolderID) movedFolder, found = findProjectHierarchyFolderByPath(folders, movedFolderPath)
if !found { if !found {
return MoveProjectFolderResult{}, fmt.Errorf("moved project folder missing from ordered projection") return MoveProjectFolderResult{}, fmt.Errorf("moved project folder missing from ordered projection")
} }
return MoveProjectFolderResult{ return MoveProjectFolderResult{
ProjectID: project.ID, ProjectID: project.ID,
PreviousFolderID: movedFolder.ID, PreviousFolderStableID: movedFolder.ID,
PreviousFolderPath: previousFolderID, PreviousFolderPath: previousFolderPath,
MovedFolder: movedFolder, MovedFolder: movedFolder,
Folders: folders, Folders: folders,
}, nil }, nil
@@ -1273,39 +1198,6 @@ func (service *Service) listWorkspaces(ctx context.Context) ([]WorkspaceRecord,
return records, rows.Err() 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) { func upsertNamedRecord(ctx context.Context, tx pgx.Tx, query string, args ...any) (namedRecord, error) {
var record namedRecord var record namedRecord
if err := tx.QueryRow(ctx, query, args...).Scan(&record.ID, &record.Name, &record.Slug); err != nil { if err := tx.QueryRow(ctx, query, args...).Scan(&record.ID, &record.Name, &record.Slug); err != nil {
@@ -9,6 +9,66 @@ import (
"testing" "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) { func TestEnsureBootstrapPOSIXSkeletonInitializesEmptyRoot(t *testing.T) {
rootPath := filepath.Join(t.TempDir(), "POSIX") rootPath := filepath.Join(t.TempDir(), "POSIX")
t.Setenv("POSIX_ROOT", rootPath) t.Setenv("POSIX_ROOT", rootPath)
+42 -40
View File
@@ -14,23 +14,25 @@ import (
type createProjectFolderRequest struct { type createProjectFolderRequest struct {
Name string `json:"name"` Name string `json:"name"`
ParentFolderID string `json:"parentFolderId"` ParentFolderPath string `json:"parentFolderId"`
} }
type renameProjectFolderRequest struct { type renameProjectFolderRequest struct {
FolderID string `json:"folderId"` FolderPath string `json:"folderId"`
Name string `json:"name"` Name string `json:"name"`
} }
type deleteProjectFolderRequest struct { 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 { type moveProjectFolderRequest struct {
FolderID string `json:"folderId"` FolderPath string `json:"folderId"`
FolderNodeID string `json:"folderNodeId"` FolderStableID string `json:"folderNodeId"`
ParentFolderID string `json:"parentFolderId"` ParentFolderPath string `json:"parentFolderId"`
ParentNodeID string `json:"parentNodeId"` ParentStableID string `json:"parentNodeId"`
TargetIndex int `json:"targetIndex"` TargetIndex int `json:"targetIndex"`
} }
@@ -71,7 +73,7 @@ func (routes apiRoutes) handleCreateProjectFolder(w http.ResponseWriter, r *http
} }
payload.Name = strings.TrimSpace(payload.Name) payload.Name = strings.TrimSpace(payload.Name)
payload.ParentFolderID = strings.TrimSpace(payload.ParentFolderID) payload.ParentFolderPath = strings.TrimSpace(payload.ParentFolderPath)
if payload.Name == "" { if payload.Name == "" {
WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Folder name is required.") WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Folder name is required.")
return return
@@ -79,7 +81,7 @@ func (routes apiRoutes) handleCreateProjectFolder(w http.ResponseWriter, r *http
result, err := routes.bootstrapService().CreateProjectFolder(r.Context(), bootstrapservice.CreateProjectFolderInput{ result, err := routes.bootstrapService().CreateProjectFolder(r.Context(), bootstrapservice.CreateProjectFolderInput{
ProjectID: projectID, ProjectID: projectID,
ParentFolderID: payload.ParentFolderID, ParentFolderPath: payload.ParentFolderPath,
Name: payload.Name, Name: payload.Name,
}) })
if err != nil { if err != nil {
@@ -104,14 +106,14 @@ func (routes apiRoutes) handleDeleteProjectFolder(w http.ResponseWriter, r *http
} }
payload := decodeDeleteProjectFolderRequest(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.") WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Folder ID is required.")
return return
} }
result, err := routes.bootstrapService().DeleteProjectFolder(r.Context(), bootstrapservice.DeleteProjectFolderInput{ result, err := routes.bootstrapService().DeleteProjectFolder(r.Context(), bootstrapservice.DeleteProjectFolderInput{
ProjectID: projectID, ProjectID: projectID,
FolderID: payload.FolderID, FolderPath: payload.FolderPath,
}) })
if err != nil { if err != nil {
routes.writeProjectFolderError(w, r, err, "delete") routes.writeProjectFolderError(w, r, err, "delete")
@@ -139,9 +141,9 @@ func (routes apiRoutes) handleRenameProjectFolder(w http.ResponseWriter, r *http
return return
} }
payload.FolderID = strings.TrimSpace(payload.FolderID) payload.FolderPath = strings.TrimSpace(payload.FolderPath)
payload.Name = strings.TrimSpace(payload.Name) 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.") WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Folder ID is required.")
return return
} }
@@ -152,7 +154,7 @@ func (routes apiRoutes) handleRenameProjectFolder(w http.ResponseWriter, r *http
result, err := routes.bootstrapService().RenameProjectFolder(r.Context(), bootstrapservice.RenameProjectFolderInput{ result, err := routes.bootstrapService().RenameProjectFolder(r.Context(), bootstrapservice.RenameProjectFolderInput{
ProjectID: projectID, ProjectID: projectID,
FolderID: payload.FolderID, FolderPath: payload.FolderPath,
Name: payload.Name, Name: payload.Name,
}) })
if err != nil { if err != nil {
@@ -181,21 +183,21 @@ func (routes apiRoutes) handleMoveProjectFolder(w http.ResponseWriter, r *http.R
return return
} }
payload.FolderID = strings.TrimSpace(payload.FolderID) payload.FolderPath = strings.TrimSpace(payload.FolderPath)
payload.FolderNodeID = strings.TrimSpace(payload.FolderNodeID) payload.FolderStableID = strings.TrimSpace(payload.FolderStableID)
payload.ParentFolderID = strings.TrimSpace(payload.ParentFolderID) payload.ParentFolderPath = strings.TrimSpace(payload.ParentFolderPath)
payload.ParentNodeID = strings.TrimSpace(payload.ParentNodeID) payload.ParentStableID = strings.TrimSpace(payload.ParentStableID)
if payload.FolderID == "" { if payload.FolderPath == "" {
WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Folder ID is required.") WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Folder ID is required.")
return return
} }
result, err := routes.bootstrapService().MoveProjectFolder(r.Context(), bootstrapservice.MoveProjectFolderInput{ result, err := routes.bootstrapService().MoveProjectFolder(r.Context(), bootstrapservice.MoveProjectFolderInput{
ProjectID: projectID, ProjectID: projectID,
FolderID: payload.FolderID, FolderPath: payload.FolderPath,
FolderNodeID: payload.FolderNodeID, FolderStableID: payload.FolderStableID,
ParentFolderID: payload.ParentFolderID, ParentFolderPath: payload.ParentFolderPath,
ParentNodeID: payload.ParentNodeID, ParentStableID: payload.ParentStableID,
TargetIndex: payload.TargetIndex, TargetIndex: payload.TargetIndex,
}) })
if err != nil { if err != nil {
@@ -249,7 +251,7 @@ func (routes apiRoutes) handleCreateProjectTreeFolder(w http.ResponseWriter, r *
} }
payload.Name = strings.TrimSpace(payload.Name) payload.Name = strings.TrimSpace(payload.Name)
payload.ParentFolderID = strings.TrimSpace(payload.ParentFolderID) payload.ParentFolderPath = strings.TrimSpace(payload.ParentFolderPath)
if payload.Name == "" { if payload.Name == "" {
WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Folder name is required.") WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Folder name is required.")
return return
@@ -257,7 +259,7 @@ func (routes apiRoutes) handleCreateProjectTreeFolder(w http.ResponseWriter, r *
result, err := routes.bootstrapService().CreateProjectTreeFolder(r.Context(), bootstrapservice.CreateProjectFolderInput{ result, err := routes.bootstrapService().CreateProjectTreeFolder(r.Context(), bootstrapservice.CreateProjectFolderInput{
ProjectID: projectID, ProjectID: projectID,
ParentFolderID: payload.ParentFolderID, ParentFolderPath: payload.ParentFolderPath,
Name: payload.Name, Name: payload.Name,
}) })
if err != nil { if err != nil {
@@ -282,14 +284,14 @@ func (routes apiRoutes) handleDeleteProjectTreeFolder(w http.ResponseWriter, r *
} }
payload := decodeDeleteProjectFolderRequest(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.") WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Folder ID is required.")
return return
} }
result, err := routes.bootstrapService().DeleteProjectTreeFolder(r.Context(), bootstrapservice.DeleteProjectFolderInput{ result, err := routes.bootstrapService().DeleteProjectTreeFolder(r.Context(), bootstrapservice.DeleteProjectFolderInput{
ProjectID: projectID, ProjectID: projectID,
FolderID: payload.FolderID, FolderPath: payload.FolderPath,
}) })
if err != nil { if err != nil {
routes.writeProjectFolderError(w, r, err, "delete") routes.writeProjectFolderError(w, r, err, "delete")
@@ -317,9 +319,9 @@ func (routes apiRoutes) handleRenameProjectTreeFolder(w http.ResponseWriter, r *
return return
} }
payload.FolderID = strings.TrimSpace(payload.FolderID) payload.FolderPath = strings.TrimSpace(payload.FolderPath)
payload.Name = strings.TrimSpace(payload.Name) 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.") WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Folder ID is required.")
return return
} }
@@ -330,7 +332,7 @@ func (routes apiRoutes) handleRenameProjectTreeFolder(w http.ResponseWriter, r *
result, err := routes.bootstrapService().RenameProjectTreeFolder(r.Context(), bootstrapservice.RenameProjectFolderInput{ result, err := routes.bootstrapService().RenameProjectTreeFolder(r.Context(), bootstrapservice.RenameProjectFolderInput{
ProjectID: projectID, ProjectID: projectID,
FolderID: payload.FolderID, FolderPath: payload.FolderPath,
Name: payload.Name, Name: payload.Name,
}) })
if err != nil { if err != nil {
@@ -359,21 +361,21 @@ func (routes apiRoutes) handleMoveProjectTreeFolder(w http.ResponseWriter, r *ht
return return
} }
payload.FolderID = strings.TrimSpace(payload.FolderID) payload.FolderPath = strings.TrimSpace(payload.FolderPath)
payload.FolderNodeID = strings.TrimSpace(payload.FolderNodeID) payload.FolderStableID = strings.TrimSpace(payload.FolderStableID)
payload.ParentFolderID = strings.TrimSpace(payload.ParentFolderID) payload.ParentFolderPath = strings.TrimSpace(payload.ParentFolderPath)
payload.ParentNodeID = strings.TrimSpace(payload.ParentNodeID) payload.ParentStableID = strings.TrimSpace(payload.ParentStableID)
if payload.FolderID == "" { if payload.FolderPath == "" {
WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Folder ID is required.") WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Folder ID is required.")
return return
} }
result, err := routes.bootstrapService().MoveProjectTreeFolder(r.Context(), bootstrapservice.MoveProjectFolderInput{ result, err := routes.bootstrapService().MoveProjectTreeFolder(r.Context(), bootstrapservice.MoveProjectFolderInput{
ProjectID: projectID, ProjectID: projectID,
FolderID: payload.FolderID, FolderPath: payload.FolderPath,
FolderNodeID: payload.FolderNodeID, FolderStableID: payload.FolderStableID,
ParentFolderID: payload.ParentFolderID, ParentFolderPath: payload.ParentFolderPath,
ParentNodeID: payload.ParentNodeID, ParentStableID: payload.ParentStableID,
TargetIndex: payload.TargetIndex, TargetIndex: payload.TargetIndex,
}) })
if err != nil { if err != nil {
@@ -432,7 +434,7 @@ func decodeMoveProjectFolderRequest(w http.ResponseWriter, r *http.Request) (mov
func decodeDeleteProjectFolderRequest(r *http.Request) deleteProjectFolderRequest { func decodeDeleteProjectFolderRequest(r *http.Request) deleteProjectFolderRequest {
return deleteProjectFolderRequest{ return deleteProjectFolderRequest{
FolderID: strings.TrimSpace(r.URL.Query().Get("folderId")), FolderPath: strings.TrimSpace(r.URL.Query().Get("folderId")),
} }
} }
+196
View File
@@ -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
}
+124
View File
@@ -0,0 +1,124 @@
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
}
runner.logger.Error("worker claim failed", "error", err)
if err := waitForNextPoll(ctx, runner.pollInterval); err != nil {
return nil
}
continue
}
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
}
}
+209
View File
@@ -0,0 +1,209 @@
package worker
import (
"context"
"errors"
"io"
"log/slog"
"strings"
"sync"
"testing"
"time"
"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])
}
}
func TestRunnerRetriesClaimErrors(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
store := &fakeJobStore{
claimErrors: []error{errors.New("relation \"background_jobs\" does not exist")},
job: &jobs.Job{
ID: "job-4",
Kind: jobs.KindBootstrapStructureMaterialize,
},
cancel: cancel,
}
runner := NewRunner(store, slog.New(slog.NewTextHandler(io.Discard, nil)), time.Millisecond)
handlerCalled := false
runner.Register(jobs.KindBootstrapStructureMaterialize, func(ctx context.Context, job jobs.Job) error {
handlerCalled = true
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 after claim retry")
}
if store.claimAttempts < 2 {
t.Fatalf("expected at least two claim attempts, got %d", store.claimAttempts)
}
if len(store.succeeded) != 1 || store.succeeded[0] != "job-4" {
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)
}
}
type fakeJobStore struct {
mu sync.Mutex
job *jobs.Job
claimed bool
claimErrors []error
claimAttempts int
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()
store.claimAttempts++
if len(store.claimErrors) > 0 {
err := store.claimErrors[0]
store.claimErrors = store.claimErrors[1:]
return nil, err
}
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
}
@@ -103,6 +103,9 @@ const buildProjectTree = (
items: readonly ProjectItem[], items: readonly ProjectItem[],
folders: readonly PersistedProjectFolderRecord[] = [], folders: readonly PersistedProjectFolderRecord[] = [],
): ProjectTreeNode[] => [ ): 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) => ({ ...items.map((item) => ({
kind: "project" as const, kind: "project" as const,
item, item,
@@ -110,6 +113,11 @@ const buildProjectTree = (
...buildPersistedFolderNodes(folders), ...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[] => const readPersistedFolders = (body: ProjectFoldersResponse): PersistedProjectFolderRecord[] =>
Array.isArray(body.data?.folders) ? body.data.folders : []; Array.isArray(body.data?.folders) ? body.data.folders : [];
@@ -596,10 +604,11 @@ export const ProjectSelector = (props: ProjectSelectorProps): JSX.Element => {
? persistedParentLocation.node.children ? persistedParentLocation.node.children
: [] : []
: previewNodes; : 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 const targetIndex = previewLocation
? previewSiblings ? countProjectFolderSiblingsBeforeIndex(previewSiblings, previewLocation.index)
.slice(0, previewLocation.index)
.filter((node) => node.kind === "folder").length
: 0; : 0;
if ( if (
@@ -795,12 +804,12 @@ export const ProjectSelector = (props: ProjectSelectorProps): JSX.Element => {
const movePersistedFolder = async ( const movePersistedFolder = async (
folderPath: string, folderPath: string,
parentFolderPath: string | null, parentFolderPath: string | null,
folderNodeId: string, folderStableId: string,
parentNodeId: string | null, parentStableId: string | null,
targetIndex: number, targetIndex: number,
): Promise<void> => { ): Promise<void> => {
const projectId = selectedProject().id; const projectId = selectedProject().id;
if (!folderPath || !folderNodeId || !isUuidString(projectId)) { if (!folderPath || !folderStableId || !isUuidString(projectId)) {
return; return;
} }
@@ -813,9 +822,9 @@ export const ProjectSelector = (props: ProjectSelectorProps): JSX.Element => {
}, },
body: JSON.stringify({ body: JSON.stringify({
folderId: folderPath, folderId: folderPath,
folderNodeId, folderNodeId: folderStableId,
parentFolderId: parentFolderPath, parentFolderId: parentFolderPath,
parentNodeId, parentNodeId: parentStableId,
targetIndex, targetIndex,
}), }),
}); });
@@ -93,6 +93,11 @@ const buildPersistedWorkspaceFolderNodes = (
const readPersistedWorkspaceFolders = (body: WorkspaceFoldersResponse): PersistedWorkspaceFolderRecord[] => const readPersistedWorkspaceFolders = (body: WorkspaceFoldersResponse): PersistedWorkspaceFolderRecord[] =>
Array.isArray(body.data?.folders) ? body.data.folders : []; 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<WorkspaceTreeNode> = { const workspaceTreeAdapter: NavTreeAdapter<WorkspaceTreeNode> = {
getNodeId: getWorkspaceTreeNodeId, getNodeId: getWorkspaceTreeNodeId,
isBranchNode: (node) => node.kind === "folder", isBranchNode: (node) => node.kind === "folder",
@@ -548,10 +553,11 @@ export const WorkspaceSidebar = (props: WorkspaceSidebarProps): JSX.Element => {
? persistedParentLocation.node.children ?? [] ? persistedParentLocation.node.children ?? []
: [] : []
: previewNodes; : 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 const targetIndex = previewLocation
? previewSiblings ? countWorkspaceFolderSiblingsBeforeIndex(previewSiblings, previewLocation.index)
.slice(0, previewLocation.index)
.filter((node) => node.kind === "folder").length
: 0; : 0;
if ( if (
@@ -716,12 +722,12 @@ export const WorkspaceSidebar = (props: WorkspaceSidebarProps): JSX.Element => {
const movePersistedFolder = async ( const movePersistedFolder = async (
folderPath: string, folderPath: string,
parentFolderPath: string | null, parentFolderPath: string | null,
folderNodeId: string, folderStableId: string,
parentNodeId: string | null, parentStableId: string | null,
targetIndex: number, targetIndex: number,
): Promise<void> => { ): Promise<void> => {
const projectId = activeProject()?.id ?? ""; const projectId = activeProject()?.id ?? "";
if (!folderPath || !folderNodeId || !projectId || !isUuidString(projectId)) { if (!folderPath || !folderStableId || !projectId || !isUuidString(projectId)) {
return; return;
} }
@@ -734,9 +740,9 @@ export const WorkspaceSidebar = (props: WorkspaceSidebarProps): JSX.Element => {
}, },
body: JSON.stringify({ body: JSON.stringify({
folderId: folderPath, folderId: folderPath,
folderNodeId, folderNodeId: folderStableId,
parentFolderId: parentFolderPath, parentFolderId: parentFolderPath,
parentNodeId, parentNodeId: parentStableId,
targetIndex, targetIndex,
}), }),
}); });
@@ -40,6 +40,8 @@ type AppShellInstallation = {
protocol: string; protocol: string;
host: string; host: string;
isBootstrapped: boolean; isBootstrapped: boolean;
materializationStatus: "not_started" | "pending" | "running" | "succeeded" | "failed" | string;
materializationError?: string;
}; };
type AppShellAdmin = { type AppShellAdmin = {
@@ -101,8 +103,29 @@ type AppShellPayload = {
workspaces: AppShellWorkspace[]; 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 => ({ const normalizeAppShellPayload = (payload: AppShellPayload | null | undefined): AppShellPayload => ({
installation: payload?.installation, installation: normalizeInstallation(payload?.installation),
admin: payload?.admin, admin: payload?.admin,
organizations: Array.isArray(payload?.organizations) ? payload.organizations : [], organizations: Array.isArray(payload?.organizations) ? payload.organizations : [],
departments: Array.isArray(payload?.departments) ? payload.departments : [], departments: Array.isArray(payload?.departments) ? payload.departments : [],
@@ -559,8 +559,6 @@ export const getWorkspaceContextMenuSections = (
id: "organize", id: "organize",
label: undefined, label: undefined,
items: [ items: [
{ id: "duplicate-folder", label: "Duplicate", shortcut: { modifiers: ["meta"], key: "d" } },
{ id: "move-folder", label: "Move", shortcut: { modifiers: ["meta"], key: "m" } },
{ id: "delete-folder", label: "Delete", shortcut: { modifiers: ["meta"], key: "delete" }, tone: "danger" }, { id: "delete-folder", label: "Delete", shortcut: { modifiers: ["meta"], key: "delete" }, tone: "danger" },
], ],
}, },
@@ -582,8 +580,6 @@ export const getWorkspaceContextMenuSections = (
id: "organize", id: "organize",
label: undefined, label: undefined,
items: [ items: [
{ id: `duplicate-${actionPrefix}`, label: "Duplicate", shortcut: { modifiers: ["meta"], key: "d" } },
{ id: `move-${actionPrefix}`, label: "Move", shortcut: { modifiers: ["meta"], key: "m" } },
{ id: `delete-${actionPrefix}`, label: "Delete", shortcut: { modifiers: ["meta"], key: "delete" }, tone: "danger" }, { id: `delete-${actionPrefix}`, label: "Delete", shortcut: { modifiers: ["meta"], key: "delete" }, tone: "danger" },
], ],
}, },
@@ -0,0 +1,173 @@
// Path: Frontend/src/components/workspace-home/WorkspaceHome/WorkspaceHome.data.ts
export type BootstrapStepKey = "persona" | "instance" | "mode" | "admin" | "structure";
export type BootstrapStepDefinition = {
id: BootstrapStepKey;
title: string;
buttonLabel: string;
};
export type InstanceForm = {
protocol: "http" | "https";
access: "local" | "remote";
host: string;
};
export type ModeForm = {
mode: "personal" | "organizational";
name: string;
};
export type AdminForm = {
displayName: string;
email: string;
password: string;
};
export type StructureForm = {
departmentName: string;
teamName: string;
projectName: string;
};
export type BootstrapPersona = "personal" | "enthusiast" | "team" | "organization";
export type BootstrapPersonaDefinition = {
id: BootstrapPersona;
title: string;
isAvailable: boolean;
bestFor: string;
bullets: readonly string[];
defaults: {
protocol: InstanceForm["protocol"];
access: InstanceForm["access"];
host: string;
mode: ModeForm["mode"];
namePlaceholder: string;
departmentName: string;
teamName: string;
projectName: string;
};
};
export const bootstrapStepDefinitions: readonly BootstrapStepDefinition[] = [
{ id: "persona", title: "What are you setting up your server for?", buttonLabel: "Continue" },
{ id: "instance", title: "Connection details", buttonLabel: "Save and continue" },
{ id: "mode", title: "Server identity", buttonLabel: "Save and continue" },
{ id: "admin", title: "Admin account", buttonLabel: "Save and continue" },
{ id: "structure", title: "Initial structure", buttonLabel: "Submit" },
];
export const defaultInstanceForm: InstanceForm = {
protocol: "http",
access: "local",
host: "localhost",
};
export const defaultModeForm: ModeForm = {
mode: "personal",
name: "",
};
export const defaultAdminForm: AdminForm = {
displayName: "Admin",
email: "admin@example.com",
password: "",
};
export const personalStructureDefaults = {
departmentName: "Default",
teamName: "Personal",
};
export const organizationalStructureDefaults = {
departmentName: "Department",
teamName: "Team",
};
export const defaultStructureForm: StructureForm = {
...personalStructureDefaults,
projectName: "Project",
};
export const bootstrapPersonaDefinitions: readonly BootstrapPersonaDefinition[] = [
{
id: "personal",
title: "Personal",
isAvailable: true,
bestFor: "Best for low maintenance, personal use",
bullets: ["Preconfigured for personal use", "Low setup time", "Easy to manage"],
defaults: {
protocol: "http",
access: "local",
host: "localhost",
mode: "personal",
namePlaceholder: "Personal Server",
departmentName: "Default",
teamName: "Personal",
projectName: "Project",
},
},
{
id: "enthusiast",
title: "Self Hosted Enthusiast",
isAvailable: true,
bestFor: "Best for people who want to customize their server",
bullets: ["Networking knowledge", "Comfortable with tinkering", "Willing to troubleshoot issues"],
defaults: {
protocol: "https",
access: "remote",
host: "moku.local",
mode: "personal",
namePlaceholder: "Personal Server",
departmentName: "Default",
teamName: "Personal",
projectName: "Project",
},
},
{
id: "team",
title: "Team",
isAvailable: true,
bestFor: "Best for low maintenance but for small team",
bullets: ["Built-in collaboration with low setup time", "Keeps the shared structure simple", "Good for a small product, design, or delivery team"],
defaults: {
protocol: "http",
access: "local",
host: "localhost",
mode: "organizational",
namePlaceholder: "Team Server",
departmentName: "Default",
teamName: "Core Team",
projectName: "Project",
},
},
{
id: "organization",
title: "Organization",
isAvailable: true,
bestFor: "Best for multiple teams and shared ownership",
bullets: ["SME to Organization", "Fine grained access control", "Better fit for teams with multiple departments"],
defaults: {
protocol: "https",
access: "remote",
host: "workspace.example.com",
mode: "organizational",
namePlaceholder: "Organization server name",
departmentName: "Operations",
teamName: "Platform Team",
projectName: "Moku",
},
},
];
export const workspaceHomeFieldTooltips = {
protocol: "Usually people use http for a local-only setup and https when the server will be reached over a domain or reverse proxy.",
access: "Usually people use local when Moku is only reached on the same machine or LAN, and remote when they plan to reach it from another network or public domain.",
host: "Examples people usually set here are localhost, moku.local, or a real domain like workspace.example.com depending on how they plan to reach the server.",
serverName: "This is the friendly name people usually give the server itself, for example Personal Server, Ronald's Server, Homelab, Studio, or Workspace.",
department: "Departments are the highest-level grouping for work. People usually use names like Default, Operations, Product, Design, or Engineering.",
team: "Teams sit inside a department. Common examples are Personal, Platform Team, Core Team, Delivery, or Design Systems.",
project: "Projects are the workspace or initiative people work inside. Common examples are Project, Shared Workspace, Moku, Client Portal, or Website Redesign.",
} as const;
@@ -0,0 +1,542 @@
// Path: Frontend/src/components/workspace-home/WorkspaceHome/WorkspaceHome.hook.ts
import { createEffect, createMemo, createSignal, onCleanup } from "solid-js";
import { createStore } from "solid-js/store";
import { resolveAPIBase } from "../../../lib/api";
import {
bootstrapPersonaDefinitions,
bootstrapStepDefinitions,
defaultAdminForm,
defaultInstanceForm,
defaultModeForm,
defaultStructureForm,
organizationalStructureDefaults,
personalStructureDefaults,
type AdminForm,
type BootstrapPersona,
type BootstrapPersonaDefinition,
type BootstrapStepDefinition,
type BootstrapStepKey,
type InstanceForm,
type ModeForm,
type StructureForm,
} from "./WorkspaceHome.data";
type AppShellBootstrapAdapter = {
installation: () => { isBootstrapped?: boolean; materializationStatus?: string; materializationError?: string } | undefined;
status: () => string;
reload: () => Promise<void>;
};
export type BootstrapSubmissionState = {
status: "idle" | "submitting" | "success" | "error";
error: string;
};
export type MaterializationState = "not_started" | "pending" | "running" | "succeeded" | "failed";
export type FieldTooltipState = {
text: string;
left: number;
top: number;
placement: "top" | "bottom";
};
const initialSubmissionState = (): BootstrapSubmissionState => ({
status: "idle",
error: "",
});
const materializationPollIntervalMs = 2000;
const readResponseBody = async (response: Response): Promise<unknown> => {
const raw = await response.text();
if (!raw.trim()) {
return null;
}
try {
return JSON.parse(raw);
} catch {
return raw;
}
};
const readResponseError = (step: BootstrapStepKey, data: unknown): string => {
const fallback = `Bootstrap ${step} request failed.`;
if (typeof data === "string") {
const message = data.trim();
return message || fallback;
}
if (!data || typeof data !== "object") {
return fallback;
}
const record = data as {
error?: string;
message?: string;
requestId?: string;
};
const message = typeof record.message === "string" ? record.message.trim() : "";
const errorCode = typeof record.error === "string" ? record.error.trim() : "";
const requestId = typeof record.requestId === "string" ? record.requestId.trim() : "";
if (!message && !errorCode && !requestId) {
return fallback;
}
const details: string[] = [];
if (errorCode) {
details.push(`code: ${errorCode}`);
}
if (requestId) {
details.push(`request: ${requestId}`);
}
if (message && details.length > 0) {
return `${message} (${details.join(", ")})`;
}
if (message) {
return message;
}
return `${fallback} (${details.join(", ")})`;
};
export const useWorkspaceHomeWizard = (appShellData: AppShellBootstrapAdapter) => {
const [instanceForm, setInstanceForm] = createStore<InstanceForm>({ ...defaultInstanceForm });
const [modeForm, setModeForm] = createStore<ModeForm>({ ...defaultModeForm });
const [adminForm, setAdminForm] = createStore<AdminForm>({ ...defaultAdminForm });
const [structureForm, setStructureForm] = createStore<StructureForm>({ ...defaultStructureForm });
const [selectedPersona, setSelectedPersona] = createSignal<BootstrapPersona>("enthusiast");
const [hasChosenPersona, setHasChosenPersona] = createSignal(false);
const [stepState, setStepState] = createStore<Record<BootstrapStepKey, BootstrapSubmissionState>>({
persona: initialSubmissionState(),
instance: initialSubmissionState(),
mode: initialSubmissionState(),
admin: initialSubmissionState(),
structure: initialSubmissionState(),
});
const [isBootstrapStateResolved, setIsBootstrapStateResolved] = createSignal(false);
const [isBootstrapComplete, setIsBootstrapComplete] = createSignal(false);
const [isWizardOpen, setIsWizardOpen] = createSignal(false);
const [isFinishingBootstrapFlow, setIsFinishingBootstrapFlow] = createSignal(false);
const [currentStepIndex, setCurrentStepIndex] = createSignal(0);
const [fieldTooltip, setFieldTooltip] = createSignal<FieldTooltipState | null>(null);
const installation = createMemo(() => appShellData.installation());
const materializationState = createMemo<MaterializationState>(() => {
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 showBootstrapFinishingState = createMemo(() => isFinishingBootstrapFlow() && (isMaterializationInFlight() || hasMaterializationFailed()));
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 "";
});
const personaDefinition = createMemo<BootstrapPersonaDefinition>(() => bootstrapPersonaDefinitions.find((persona) => persona.id === selectedPersona()) ?? bootstrapPersonaDefinitions[0]!);
const selectedPersonaIsAvailable = createMemo(() => personaDefinition().isAvailable);
const usesCondensedBootstrapFlow = createMemo(() => selectedPersona() === "personal" || selectedPersona() === "team");
const activeBootstrapSteps = createMemo<readonly BootstrapStepDefinition[]>(() => {
if (usesCondensedBootstrapFlow()) {
return [bootstrapStepDefinitions[0]!, bootstrapStepDefinitions[2]!, bootstrapStepDefinitions[3]!];
}
return bootstrapStepDefinitions;
});
const activeWizardSteps = createMemo(() => activeBootstrapSteps().filter((step) => step.id !== "persona"));
createEffect(() => {
const defaults = personaDefinition().defaults;
setInstanceForm({
protocol: defaults.protocol,
access: defaults.access,
host: defaults.host,
});
setModeForm("mode", defaults.mode);
setStructureForm({
departmentName: defaults.departmentName,
teamName: defaults.teamName,
projectName: defaults.projectName,
});
});
createEffect(() => {
if (modeForm.mode === "personal") {
setStructureForm("departmentName", personalStructureDefaults.departmentName);
setStructureForm("teamName", personalStructureDefaults.teamName);
return;
}
if (structureForm.departmentName === personalStructureDefaults.departmentName) {
setStructureForm("departmentName", organizationalStructureDefaults.departmentName);
}
if (structureForm.teamName === personalStructureDefaults.teamName) {
setStructureForm("teamName", organizationalStructureDefaults.teamName);
}
});
const resetWizardState = (): void => {
setSelectedPersona("enthusiast");
setHasChosenPersona(false);
setInstanceForm({ ...defaultInstanceForm });
setModeForm({ ...defaultModeForm });
setAdminForm({ ...defaultAdminForm });
setStructureForm({ ...defaultStructureForm });
setStepState({
persona: initialSubmissionState(),
instance: initialSubmissionState(),
mode: initialSubmissionState(),
admin: initialSubmissionState(),
structure: initialSubmissionState(),
});
setCurrentStepIndex(0);
setIsFinishingBootstrapFlow(false);
};
createEffect(() => {
const shellStatus = appShellData.status();
if (shellStatus === "idle" || shellStatus === "loading") {
return;
}
if (shellStatus !== "success") {
return;
}
if (!isBootstrapPersisted()) {
setIsFinishingBootstrapFlow(false);
resetWizardState();
}
setIsBootstrapComplete(isBootstrapPersisted() && !isMaterializationInFlight());
setIsWizardOpen(!isBootstrapPersisted() || showBootstrapFinishingState());
setIsBootstrapStateResolved(true);
});
createEffect(() => {
if (!isFinishingBootstrapFlow()) {
return;
}
if (isMaterializationInFlight() || hasMaterializationFailed()) {
return;
}
setIsFinishingBootstrapFlow(false);
setIsWizardOpen(false);
});
createEffect(() => {
if (!isBootstrapPersisted() || !isMaterializationInFlight()) {
return;
}
let cancelled = false;
let timeoutId: number | undefined;
const scheduleReload = (): void => {
timeoutId = window.setTimeout(async () => {
if (cancelled) {
return;
}
await appShellData.reload();
if (!cancelled && isBootstrapPersisted() && isMaterializationInFlight()) {
scheduleReload();
}
}, materializationPollIntervalMs);
};
scheduleReload();
onCleanup(() => {
cancelled = true;
if (timeoutId !== undefined) {
window.clearTimeout(timeoutId);
}
});
});
const apiBase = (): string => resolveAPIBase();
const bootstrapNamePlaceholder = (): string => personaDefinition().defaults.namePlaceholder;
const bootstrapStepCount = createMemo(() => activeWizardSteps().length);
const currentStep = createMemo<BootstrapStepDefinition>(() => activeBootstrapSteps()[currentStepIndex()] ?? activeBootstrapSteps()[0] ?? bootstrapStepDefinitions[0]!);
const currentWizardStepIndex = createMemo(() => {
const visibleIndex = activeWizardSteps().findIndex((step) => step.id === currentStep().id);
return visibleIndex >= 0 ? visibleIndex : 0;
});
const wizardProgressPercent = createMemo(() => {
const totalSteps = bootstrapStepCount();
const activeIndex = Math.max(currentWizardStepIndex(), 0);
if (totalSteps <= 1) {
return 100;
}
return (activeIndex / (totalSteps - 1)) * 100;
});
const wizardProgressFillWidth = createMemo(() => {
if (currentStepIndex() <= 0) {
return `${wizardProgressPercent()}%`;
}
return `calc(${wizardProgressPercent()}% + ((var(--control-size-md) - var(--space-2)) / 2))`;
});
const currentStepState = createMemo<BootstrapSubmissionState>(() => stepState[currentStep().id]);
const isFirstStep = (): boolean => currentStepIndex() === 0;
const isLastStep = (): boolean => currentStepIndex() === activeBootstrapSteps().length - 1;
const canDismissWizard = (): boolean => isBootstrapPersisted() && !isMaterializationInFlight();
createEffect(() => {
setCurrentStepIndex((index) => Math.min(index, activeBootstrapSteps().length - 1));
});
const submitStep = async (step: BootstrapStepKey, payload: unknown): Promise<boolean> => {
setStepState(step, { status: "submitting", error: "" });
try {
const response = await fetch(`${apiBase()}/bootstrap/steps/${step}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify(payload),
});
const data = await readResponseBody(response);
if (!response.ok) {
throw new Error(readResponseError(step, data));
}
setStepState(step, {
status: "success",
error: "",
});
return true;
} catch (error) {
setStepState(step, {
status: "error",
error: error instanceof Error ? error.message : `Bootstrap ${step} request failed.`,
});
return false;
}
};
const payloadForStep = (step: BootstrapStepKey): unknown => {
switch (step) {
case "instance":
return instanceForm;
case "mode":
return modeForm;
case "admin":
return adminForm;
case "structure":
return structureForm;
}
};
const applyPersonaSelection = (persona: BootstrapPersona): void => {
const definition = bootstrapPersonaDefinitions.find((candidate) => candidate.id === persona);
if (!definition?.isAvailable) {
return;
}
setSelectedPersona(persona);
setHasChosenPersona(true);
setStepState("persona", {
status: "success",
error: "",
});
setCurrentStepIndex((index) => Math.min(index + 1, activeBootstrapSteps().length - 1));
};
const statusLabel = (state: BootstrapSubmissionState): string => {
switch (state.status) {
case "submitting":
return "Sending";
case "error":
return "Request failed";
default:
return "";
}
};
const submitCurrentStep = async (): Promise<void> => {
const step = currentStep().id;
if (step === "persona") {
applyPersonaSelection(selectedPersona());
return;
}
if (step === "mode" && usesCondensedBootstrapFlow() && stepState.instance.status !== "success") {
const didPersistInstanceDefaults = await submitStep("instance", instanceForm);
if (!didPersistInstanceDefaults) {
return;
}
}
const didSucceed = await submitStep(step, payloadForStep(step));
if (!didSucceed) {
return;
}
if (step === "admin" && usesCondensedBootstrapFlow()) {
const didPersistStructureDefaults = await submitStep("structure", structureForm);
if (!didPersistStructureDefaults) {
return;
}
}
if (isLastStep()) {
await appShellData.reload();
const shouldShowFinishingState = isBootstrapPersisted() && (isMaterializationInFlight() || hasMaterializationFailed());
setIsFinishingBootstrapFlow(shouldShowFinishingState);
setIsBootstrapComplete(isBootstrapPersisted() && !isMaterializationInFlight());
setIsWizardOpen(!isBootstrapPersisted() || shouldShowFinishingState);
setIsBootstrapStateResolved(true);
return;
}
setCurrentStepIndex((index) => Math.min(index + 1, activeBootstrapSteps().length - 1));
};
const showFieldTooltip = (target: HTMLElement, text: string): void => {
const rect = target.getBoundingClientRect();
const placement = rect.top > 96 ? "top" : "bottom";
const viewportPadding = 20;
const left = Math.min(Math.max(rect.left + rect.width / 2, viewportPadding), window.innerWidth - viewportPadding);
const top = placement === "top" ? rect.top - 10 : rect.bottom + 10;
setFieldTooltip({ text, left, top, placement });
};
const hideFieldTooltip = (): void => {
setFieldTooltip(null);
};
const stepStatusLabel = (step: BootstrapStepDefinition): string => {
const state = stepState[step.id];
if (state.status === "success") {
return "Done";
}
if (state.status === "error") {
return "Needs retry";
}
return "";
};
return {
instanceForm,
setInstanceForm,
modeForm,
setModeForm,
adminForm,
setAdminForm,
structureForm,
setStructureForm,
selectedPersona,
setSelectedPersona,
hasChosenPersona,
stepState,
isBootstrapStateResolved,
isBootstrapComplete,
isWizardOpen,
setIsWizardOpen,
isFinishingBootstrapFlow,
setIsFinishingBootstrapFlow,
fieldTooltip,
materializationState,
isMaterializationInFlight,
hasMaterializationFailed,
showBootstrapFinishingState,
materializationStatusLabel,
materializationMessage,
personaDefinition,
selectedPersonaIsAvailable,
usesCondensedBootstrapFlow,
activeWizardSteps,
bootstrapNamePlaceholder,
bootstrapStepCount,
currentStep,
currentWizardStepIndex,
wizardProgressFillWidth,
currentStepState,
isFirstStep,
canDismissWizard,
resetWizardState,
handleCurrentStepSubmit: (event: SubmitEvent & { currentTarget: HTMLFormElement; target: Element }): void => {
event.preventDefault();
void submitCurrentStep();
},
applyPersonaSelection,
statusLabel,
showFieldTooltip,
hideFieldTooltip,
stepStatusLabel,
navigateBack: (): void => {
setCurrentStepIndex((index) => Math.max(index - 1, 0));
},
navigateToVisibleStep: (index: number): void => {
setCurrentStepIndex(index + 1);
},
};
};
@@ -1,3 +1,5 @@
/* Path: Frontend/src/components/workspace-home/WorkspaceHome/WorkspaceHome.module.scss */
.viewport, .viewport,
.wizardLayer { .wizardLayer {
--workspace-content-max-width: var(--content-width-wide); --workspace-content-max-width: var(--content-width-wide);
@@ -92,6 +94,21 @@
flex-wrap: wrap; 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 { .title {
@include text-display; @include text-display;
font-family: var(--font-family-display); font-family: var(--font-family-display);
@@ -194,6 +211,19 @@
background: color-mix(in srgb, var(--color-success-surface, var(--color-surface-secondary)) 80%, transparent); 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"] { .statusBadge[data-status="error"] {
color: var(--color-danger-text, var(--color-text)); color: var(--color-danger-text, var(--color-text));
border-color: color-mix(in srgb, var(--color-danger-border, var(--color-border)) 68%, transparent); border-color: color-mix(in srgb, var(--color-danger-border, var(--color-border)) 68%, transparent);
@@ -240,11 +270,249 @@
color: var(--color-text-muted); color: var(--color-text-muted);
} }
.fieldLabelRow {
display: inline-flex;
align-items: center;
gap: 0.35rem;
}
.fieldInfoButton {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
border: 0;
background: transparent;
color: var(--color-text-muted);
cursor: help;
outline: none;
}
.fieldInfoButton:hover,
.fieldInfoButton:focus-visible {
color: var(--color-text);
}
.fieldTooltip {
position: fixed;
z-index: calc(var(--z-modal, 1000) + 4);
pointer-events: none;
transform: translateX(-50%);
max-width: min(18rem, calc(100vw - 2rem));
}
.fieldTooltip[data-placement="top"] {
transform: translate(-50%, -100%);
}
.fieldTooltip[data-placement="bottom"] {
transform: translate(-50%, 0);
}
.fieldTooltipBubble {
position: relative;
padding: 0.45rem 0.6rem;
border: 1px solid color-mix(in srgb, var(--color-border-strong) 48%, transparent);
border-radius: var(--radius-md);
background: color-mix(in srgb, var(--color-surface-elevated, var(--color-surface)) 96%, black 4%);
box-shadow: var(--shadow-soft);
color: var(--color-text);
white-space: normal;
text-align: left;
line-height: 1.35;
}
.fieldTooltipBubble::after {
content: "";
position: absolute;
left: 50%;
width: 0.55rem;
height: 0.55rem;
background: color-mix(in srgb, var(--color-surface-elevated, var(--color-surface)) 96%, black 4%);
transform: translateX(-50%) rotate(45deg);
}
.fieldTooltip[data-placement="top"] .fieldTooltipBubble::after {
top: calc(100% - 0.3rem);
border-right: 1px solid color-mix(in srgb, var(--color-border-strong) 48%, transparent);
border-bottom: 1px solid color-mix(in srgb, var(--color-border-strong) 48%, transparent);
}
.fieldTooltip[data-placement="bottom"] .fieldTooltipBubble::after {
bottom: calc(100% - 0.3rem);
border-top: 1px solid color-mix(in srgb, var(--color-border-strong) 48%, transparent);
border-left: 1px solid color-mix(in srgb, var(--color-border-strong) 48%, transparent);
}
.fieldHelp { .fieldHelp {
@include text-caption; @include text-caption;
color: var(--color-text-muted); color: var(--color-text-muted);
} }
.personaIntro {
display: grid;
gap: var(--space-2);
}
.personaGrid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-3);
}
.personaCard {
appearance: none;
position: relative;
display: grid;
padding: var(--space-3);
border: 1px solid color-mix(in srgb, var(--color-border) 88%, transparent);
border-radius: var(--radius-xl);
background: color-mix(in srgb, var(--color-surface-secondary) 84%, transparent);
text-align: left;
overflow: hidden;
transition:
transform 180ms var(--easing-standard),
border-color 160ms var(--easing-standard),
background 160ms var(--easing-standard),
box-shadow 160ms var(--easing-standard);
}
.personaCard:hover,
.personaCard:focus-visible,
.personaCard[data-selected="true"] {
transform: translateY(-1px);
border-color: color-mix(in srgb, var(--bootstrap-accent) 32%, var(--color-border));
background: color-mix(in srgb, var(--bootstrap-accent) 7%, var(--color-surface));
box-shadow: var(--shadow-soft);
}
.personaCard:focus-visible {
outline: none;
box-shadow:
var(--shadow-soft),
0 0 0 3px color-mix(in srgb, var(--bootstrap-accent) 16%, transparent);
}
.personaCard[data-available="false"] {
opacity: 0.9;
}
.personaCard[data-available="false"]:hover,
.personaCard[data-available="false"]:focus-visible,
.personaCard[data-available="false"][data-selected="true"] {
transform: none;
border-color: color-mix(in srgb, var(--color-border) 88%, transparent);
background: color-mix(in srgb, var(--color-surface-secondary) 84%, transparent);
box-shadow: none;
}
.personaCardMedia {
position: relative;
display: flex;
align-items: center;
justify-content: center;
justify-self: center;
align-self: center;
width: min(100%, 16rem);
aspect-ratio: 1 / 1;
border: 1px dashed color-mix(in srgb, var(--color-border-strong) 40%, transparent);
border-radius: calc(var(--radius-xl) - var(--space-1));
background: linear-gradient(
135deg,
color-mix(in srgb, var(--color-surface-elevated) 92%, transparent),
color-mix(in srgb, var(--color-surface-secondary) 88%, transparent)
);
transition:
filter 180ms var(--easing-standard),
opacity 180ms var(--easing-standard),
transform 180ms var(--easing-standard);
}
.personaCardBody {
position: absolute;
inset: 0;
display: grid;
align-content: space-between;
gap: var(--space-3);
padding: var(--space-3);
pointer-events: none;
z-index: 1;
}
.personaCardTitle {
@include text-title;
margin: 0;
max-width: min(100%, 14rem);
padding: 0.35rem 0.65rem;
border-radius: var(--radius-md);
background: color-mix(in srgb, var(--color-surface) 84%, transparent);
backdrop-filter: blur(10px);
color: var(--color-text);
}
.personaCardDetails {
display: grid;
gap: var(--space-2);
max-height: 0;
opacity: 0;
overflow: hidden;
align-self: end;
padding: var(--space-3);
border-radius: var(--radius-lg);
background: linear-gradient(
180deg,
color-mix(in srgb, var(--color-surface) 18%, transparent),
color-mix(in srgb, var(--color-surface) 92%, transparent)
);
backdrop-filter: blur(12px);
transition:
max-height 180ms var(--easing-standard),
opacity 160ms var(--easing-standard);
}
.personaCard:hover .personaCardMedia,
.personaCard:focus-visible .personaCardMedia,
.personaCard[data-selected="true"] .personaCardMedia {
filter: brightness(0.72);
opacity: 0.92;
transform: scale(0.985);
}
.personaCard:hover .personaCardDetails,
.personaCard:focus-visible .personaCardDetails,
.personaCard[data-selected="true"] .personaCardDetails {
max-height: 12rem;
opacity: 1;
}
.personaCard[data-available="false"]:hover .personaCardMedia,
.personaCard[data-available="false"]:focus-visible .personaCardMedia,
.personaCard[data-available="false"][data-selected="true"] .personaCardMedia {
filter: brightness(0.82);
opacity: 0.96;
transform: none;
}
.personaBestFor,
.personaBulletList {
margin: 0;
color: var(--color-text);
}
.personaBulletList {
padding-left: 1rem;
display: grid;
gap: 0.25rem;
}
.personaAvailability {
@include text-caption;
margin: 0;
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.06em;
}
.field input, .field input,
.field select { .field select {
min-height: var(--control-size-md); min-height: var(--control-size-md);
@@ -342,7 +610,7 @@
.primaryButton:hover, .primaryButton:hover,
.secondaryButton:hover, .secondaryButton:hover,
.wizardCloseButton:hover, .wizardCloseButton:hover,
.wizardStepButton:hover { .wizardProgressStep:hover {
transform: translateY(-1px); transform: translateY(-1px);
} }
@@ -417,72 +685,93 @@
.wizardBody { .wizardBody {
display: grid; display: grid;
grid-template-columns: minmax(17rem, 20rem) minmax(0, 1fr);
gap: var(--space-4); gap: var(--space-4);
min-height: 0; min-height: 0;
} }
.wizardSidebar { .wizardProgress {
position: relative;
display: grid; display: grid;
gap: var(--space-4);
align-content: start;
}
.wizardSidebarSection {
gap: var(--space-3); gap: var(--space-3);
} }
.wizardSteps { .wizardProgressTrack {
display: grid; position: absolute;
gap: var(--space-2); left: calc((var(--control-size-md) - var(--space-2)) / 2);
right: calc((var(--control-size-md) - var(--space-2)) / 2);
top: calc((var(--control-size-md) - var(--space-2)) / 2);
height: 2px;
background: color-mix(in srgb, var(--color-border) 72%, transparent);
transform: translateY(-50%);
pointer-events: none;
} }
.wizardStepButton { .wizardProgressFill {
width: 100%; height: 100%;
display: grid; border-radius: 999px;
grid-template-columns: auto minmax(0, 1fr); background: color-mix(in srgb, var(--bootstrap-accent) 72%, white 8%);
transition: width 220ms var(--easing-standard);
}
.wizardProgressSteps {
display: flex;
align-items: center; align-items: center;
text-align: left; justify-content: space-between;
padding: var(--space-2) var(--space-3); gap: 0;
border: 1px solid color-mix(in srgb, var(--color-border) 88%, transparent);
background: color-mix(in srgb, var(--color-surface-secondary) 84%, transparent);
} }
.wizardStepButton[data-active="true"] { .wizardProgressStep {
border-color: color-mix(in srgb, var(--bootstrap-accent) 42%, transparent); position: relative;
background: color-mix(in srgb, var(--bootstrap-accent) 10%, var(--color-surface)); z-index: 1;
flex: 0 0 auto;
display: inline-flex;
justify-content: center;
justify-items: center;
text-align: center;
padding: 0;
border: 0;
background: transparent;
} }
.wizardStepButton:disabled { .wizardProgressStep:disabled {
opacity: 0.56;
cursor: not-allowed; cursor: not-allowed;
transform: none; transform: none;
} }
.wizardStepIndex { .wizardProgressIndex {
width: calc(var(--control-size-md) - var(--space-2)); width: calc(var(--control-size-md) - var(--space-2));
height: calc(var(--control-size-md) - var(--space-2)); height: calc(var(--control-size-md) - var(--space-2));
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
border-radius: var(--radius-pill); border-radius: 999px;
background: color-mix(in srgb, var(--color-surface) 80%, transparent); border: 1px solid color-mix(in srgb, var(--color-border) 88%, transparent);
background: color-mix(in srgb, var(--color-surface) 92%, transparent);
color: var(--color-text-muted);
transition:
border-color 160ms var(--easing-standard),
background 160ms var(--easing-standard),
color 160ms var(--easing-standard),
box-shadow 160ms var(--easing-standard),
transform 180ms var(--easing-standard);
}
.wizardProgressStep[data-active="true"] .wizardProgressIndex,
.wizardProgressStep[data-complete="true"] .wizardProgressIndex {
border-color: color-mix(in srgb, var(--bootstrap-accent) 42%, transparent);
background: color-mix(in srgb, var(--bootstrap-accent) 12%, var(--color-surface));
color: var(--color-text); color: var(--color-text);
} }
.wizardStepCopy { .wizardProgressStep[data-active="true"] .wizardProgressIndex {
min-width: 0; box-shadow: 0 0 0 3px color-mix(in srgb, var(--bootstrap-accent) 14%, transparent);
display: grid;
gap: 0.125rem;
} }
.wizardStepCopy strong { .wizardProgressStep:not(:disabled):hover .wizardProgressIndex,
@include text-label; .wizardProgressStep:not(:disabled):focus-visible .wizardProgressIndex {
} transform: translateY(-1px);
border-color: color-mix(in srgb, var(--bootstrap-accent) 36%, var(--color-border));
.wizardStepCopy small { color: var(--color-text);
@include text-caption;
color: var(--color-text-muted);
} }
.wizardStepPanel { .wizardStepPanel {
@@ -490,7 +779,116 @@
gap: var(--space-3); gap: var(--space-3);
} }
.wizardFinishPanel {
display: grid;
gap: var(--space-4);
justify-items: stretch;
padding: var(--space-2) 0 0;
min-height: min(14rem, 32dvh);
align-content: center;
}
.wizardFinishShell {
display: grid;
gap: var(--space-4);
width: min(100%, 40rem);
padding: 0;
border: 0;
border-radius: 0;
background: transparent;
box-shadow: none;
}
.wizardFinishStatusRow {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: var(--space-3);
align-items: start;
}
.wizardFinishIndicator {
width: 2.5rem;
height: 2.5rem;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 999px;
border: 1px solid color-mix(in srgb, var(--bootstrap-accent) 18%, transparent);
background: color-mix(in srgb, var(--bootstrap-accent) 8%, transparent);
}
.wizardFinishIndicator[data-status="failed"] {
border-color: color-mix(in srgb, var(--color-danger-border, var(--color-border)) 44%, transparent);
background: color-mix(in srgb, var(--color-danger-surface, var(--color-surface-secondary)) 36%, transparent);
}
.wizardFinishSpinner {
width: 1.25rem;
height: 1.25rem;
border-radius: 999px;
border: 2px solid color-mix(in srgb, var(--bootstrap-accent) 18%, transparent);
border-top-color: var(--bootstrap-accent);
animation: wizardFinishSpin 900ms linear infinite;
}
.wizardFinishIndicator[data-status="failed"] .wizardFinishSpinner {
border: 2px solid color-mix(in srgb, var(--color-danger-border, var(--color-border)) 22%, transparent);
border-top-color: var(--color-danger-text, var(--color-text));
animation: none;
transform: rotate(45deg);
border-radius: var(--radius-sm);
width: 1rem;
height: 1rem;
}
.wizardFinishCopy {
display: grid;
gap: var(--space-1);
min-width: 0;
}
.wizardFinishTitle {
@include text-title;
margin: 0;
}
.wizardFinishDescription,
.wizardFinishMessage,
.wizardFinishHint {
margin: 0;
color: var(--color-text-muted);
}
.wizardFinishMessage[data-status="failed"] {
color: var(--color-danger-text, var(--color-text));
}
.wizardFinishHint {
@include text-caption;
}
.wizardFinishActions {
display: flex;
gap: var(--space-3);
flex-wrap: wrap;
padding-top: var(--space-1);
}
@keyframes wizardFinishSpin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@include respond-down(tablet) { @include respond-down(tablet) {
.personaGrid {
grid-template-columns: 1fr;
}
.summaryGrid, .summaryGrid,
.wizardBody { .wizardBody {
grid-template-columns: 1fr; grid-template-columns: 1fr;
@@ -536,21 +934,20 @@
} }
.wizardHeader, .wizardHeader,
.wizardBody, .wizardBody {
.wizardSidebar {
gap: var(--space-3); gap: var(--space-3);
} }
.wizardSteps { .wizardProgressSteps {
grid-auto-flow: column; justify-content: flex-start;
grid-auto-columns: minmax(10rem, 1fr); gap: var(--space-8);
overflow-x: auto; overflow-x: auto;
padding-bottom: var(--space-1); padding-bottom: var(--space-1);
scrollbar-width: thin; scrollbar-width: thin;
} }
.wizardStepButton { .wizardProgressStep {
min-width: 10rem; min-width: calc(var(--control-size-md) - var(--space-2));
} }
.wizardFormActions { .wizardFormActions {
@@ -0,0 +1,285 @@
import { For, Show, type JSX } from "solid-js";
import { CircleHelp } from "../../../lib/icons";
import {
organizationalStructureDefaults,
workspaceHomeFieldTooltips,
type AdminForm,
type BootstrapPersona,
type BootstrapPersonaDefinition,
type BootstrapStepDefinition,
type BootstrapStepKey,
type InstanceForm,
type ModeForm,
type StructureForm,
} from "./WorkspaceHome.data";
import styles from "./WorkspaceHome.module.scss";
type BootstrapSubmissionState = {
status: "idle" | "submitting" | "success" | "error";
error: string;
};
type MaterializationState = "not_started" | "pending" | "running" | "succeeded" | "failed";
type TooltipHandlers = {
onShowTooltip: (target: HTMLElement, text: string) => void;
onHideTooltip: () => void;
};
type FieldLabelWithTooltipProps = TooltipHandlers & {
label: string;
tooltip?: string;
};
export const FieldLabelWithTooltip = (props: FieldLabelWithTooltipProps): JSX.Element => (
<span class={styles.fieldLabelRow}>
<span class={styles.fieldLabel}>{props.label}</span>
<Show when={props.tooltip}>
<button
type="button"
class={styles.fieldInfoButton}
aria-label={`${props.label} help: ${props.tooltip}`}
onMouseEnter={(event): void => props.onShowTooltip(event.currentTarget, props.tooltip!)}
onMouseLeave={props.onHideTooltip}
onFocus={(event): void => props.onShowTooltip(event.currentTarget, props.tooltip!)}
onBlur={props.onHideTooltip}
>
<CircleHelp size={14} strokeWidth={2} />
</button>
</Show>
</span>
);
type BootstrapFinishingStateProps = {
materializationState: MaterializationState;
statusLabel: string;
message: string;
isInFlight: boolean;
hasFailed: boolean;
onClose: () => void;
};
export const BootstrapFinishingState = (props: BootstrapFinishingStateProps): JSX.Element => (
<div class={styles.wizardFinishPanel} data-slot="bootstrap-wizard-finishing-state">
<div class={styles.wizardFinishShell}>
<div class={styles.wizardFinishStatusRow}>
<div class={styles.wizardFinishIndicator} data-status={props.materializationState} aria-hidden="true">
<div class={styles.wizardFinishSpinner} />
</div>
<div class={styles.wizardFinishCopy}>
<span class={styles.wizardStepEyebrow}>Bootstrap status</span>
<h3 class={styles.wizardFinishTitle}>Finishing setup</h3>
<p class={styles.wizardFinishDescription}>
We saved your initial bootstrap. The server is finishing the last background setup steps now.
</p>
</div>
</div>
<div class={styles.statusBadge} data-status={props.materializationState}>{props.statusLabel}</div>
<Show when={props.message}>
<p class={styles.wizardFinishMessage} data-status={props.materializationState}>{props.message}</p>
</Show>
<Show when={props.isInFlight}>
<p class={styles.wizardFinishHint}>This window will close automatically when setup is complete.</p>
</Show>
</div>
<Show when={props.hasFailed}>
<div class={styles.wizardFinishActions}>
<button type="button" class={styles.secondaryButton} onClick={props.onClose}>Close</button>
</div>
</Show>
</div>
);
type BootstrapWizardProgressProps = {
steps: readonly BootstrapStepDefinition[];
currentStepId: BootstrapStepKey;
currentWizardStepIndex: number;
stepState: Record<BootstrapStepKey, BootstrapSubmissionState>;
bootstrapStepCount: number;
wizardProgressFillWidth: string;
stepStatusLabel: (step: BootstrapStepDefinition) => string;
onSelectStep: (index: number) => void;
};
export const BootstrapWizardProgress = (props: BootstrapWizardProgressProps): JSX.Element => (
<div class={styles.wizardProgress} data-slot="bootstrap-wizard-progress">
<div class={styles.wizardProgressTrack} aria-hidden="true">
<div class={styles.wizardProgressFill} style={{ width: props.wizardProgressFillWidth }} />
</div>
<nav class={styles.wizardProgressSteps} aria-label="Bootstrap steps" style={{ "--wizard-progress-step-count": props.bootstrapStepCount }}>
<For each={props.steps}>
{(step, index): JSX.Element => (
<button
type="button"
class={styles.wizardProgressStep}
data-active={step.id === props.currentStepId ? "true" : "false"}
data-complete={props.stepState[step.id].status === "success" ? "true" : "false"}
disabled={index() > props.currentWizardStepIndex}
onClick={(): void => {
if (index() <= props.currentWizardStepIndex) {
props.onSelectStep(index());
}
}}
aria-label={`Step ${index() + 1}${props.stepStatusLabel(step) ? `, ${props.stepStatusLabel(step)}` : ""}`}
>
<span class={styles.wizardProgressIndex}>{index() + 1}</span>
</button>
)}
</For>
</nav>
</div>
);
type BootstrapPersonaStepProps = {
personas: readonly BootstrapPersonaDefinition[];
hasChosenPersona: boolean;
selectedPersona: BootstrapPersona;
selectedPersonaIsAvailable: boolean;
onSelectPersona: (persona: BootstrapPersona) => void;
};
export const BootstrapPersonaStep = (props: BootstrapPersonaStepProps): JSX.Element => (
<>
<div class={styles.personaGrid}>
<For each={props.personas}>
{(persona): JSX.Element => (
<button
type="button"
class={styles.personaCard}
data-selected={props.hasChosenPersona && persona.id === props.selectedPersona ? "true" : "false"}
data-available={persona.isAvailable ? "true" : "false"}
aria-pressed={props.hasChosenPersona && persona.id === props.selectedPersona}
onClick={(): void => props.onSelectPersona(persona.id)}
>
<div class={styles.personaCardMedia} aria-hidden="true" />
<div class={styles.personaCardBody}>
<h4 class={styles.personaCardTitle}>{persona.title}</h4>
<div class={styles.personaCardDetails}>
<p class={styles.personaBestFor}>{persona.bestFor}</p>
<ul class={styles.personaBulletList}>
<For each={persona.bullets}>{(bullet): JSX.Element => <li>{bullet}</li>}</For>
</ul>
<Show when={!persona.isAvailable}><p class={styles.personaAvailability}>Coming later</p></Show>
</div>
</div>
</button>
)}
</For>
</div>
<Show when={!props.selectedPersonaIsAvailable}>
<p class={styles.fieldHelp}>Only <strong>Self Hosted Enthusiast</strong> is wired up right now. The other setup paths will come next.</p>
</Show>
</>
);
type BootstrapInstanceStepProps = TooltipHandlers & {
instanceForm: InstanceForm;
onProtocolChange: (value: InstanceForm["protocol"]) => void;
onAccessChange: (value: InstanceForm["access"]) => void;
onHostChange: (value: string) => void;
};
export const BootstrapInstanceStep = (props: BootstrapInstanceStepProps): JSX.Element => (
<>
<label class={styles.field}>
<FieldLabelWithTooltip label="Protocol" tooltip={workspaceHomeFieldTooltips.protocol} onShowTooltip={props.onShowTooltip} onHideTooltip={props.onHideTooltip} />
<select value={props.instanceForm.protocol} onInput={(event): void => props.onProtocolChange(event.currentTarget.value as InstanceForm["protocol"])}>
<option value="http">http</option>
<option value="https">https</option>
</select>
</label>
<label class={styles.field}>
<FieldLabelWithTooltip label="Access" tooltip={workspaceHomeFieldTooltips.access} onShowTooltip={props.onShowTooltip} onHideTooltip={props.onHideTooltip} />
<select value={props.instanceForm.access} onInput={(event): void => props.onAccessChange(event.currentTarget.value as InstanceForm["access"])}>
<option value="local">local</option>
<option value="remote">remote</option>
</select>
</label>
<label class={styles.field}>
<FieldLabelWithTooltip label="Host" tooltip={workspaceHomeFieldTooltips.host} onShowTooltip={props.onShowTooltip} onHideTooltip={props.onHideTooltip} />
<input type="text" value={props.instanceForm.host} onInput={(event): void => props.onHostChange(event.currentTarget.value)} placeholder="localhost or app.example.com" />
</label>
</>
);
type BootstrapModeStepProps = TooltipHandlers & {
modeForm: ModeForm;
structureForm: StructureForm;
usesCondensedBootstrapFlow: boolean;
selectedPersona: BootstrapPersona;
namePlaceholder: string;
onNameChange: (value: string) => void;
onProjectNameChange: (value: string) => void;
onTeamNameChange: (value: string) => void;
};
export const BootstrapModeStep = (props: BootstrapModeStepProps): JSX.Element => (
<>
<label class={styles.field}>
<FieldLabelWithTooltip label="Server name" tooltip={workspaceHomeFieldTooltips.serverName} onShowTooltip={props.onShowTooltip} onHideTooltip={props.onHideTooltip} />
<input type="text" value={props.modeForm.name} required onInput={(event): void => props.onNameChange(event.currentTarget.value)} placeholder={props.namePlaceholder} />
</label>
<Show when={props.usesCondensedBootstrapFlow}>
<label class={styles.field}>
<FieldLabelWithTooltip label="Default Project" tooltip={workspaceHomeFieldTooltips.project} onShowTooltip={props.onShowTooltip} onHideTooltip={props.onHideTooltip} />
<input type="text" value={props.structureForm.projectName} onInput={(event): void => props.onProjectNameChange(event.currentTarget.value)} placeholder="Project" />
</label>
</Show>
<Show when={props.selectedPersona === "team"}>
<label class={styles.field}>
<FieldLabelWithTooltip label="Team name" tooltip={workspaceHomeFieldTooltips.team} onShowTooltip={props.onShowTooltip} onHideTooltip={props.onHideTooltip} />
<input type="text" value={props.structureForm.teamName} onInput={(event): void => props.onTeamNameChange(event.currentTarget.value)} placeholder="Core Team" />
</label>
</Show>
</>
);
type BootstrapAdminStepProps = {
adminForm: AdminForm;
onDisplayNameChange: (value: string) => void;
onEmailChange: (value: string) => void;
onPasswordChange: (value: string) => void;
};
export const BootstrapAdminStep = (props: BootstrapAdminStepProps): JSX.Element => (
<>
<label class={styles.field}>
<span class={styles.fieldLabel}>Display name</span>
<input type="text" value={props.adminForm.displayName} onInput={(event): void => props.onDisplayNameChange(event.currentTarget.value)} placeholder="Admin" />
</label>
<label class={styles.field}>
<span class={styles.fieldLabel}>Email</span>
<input type="email" value={props.adminForm.email} onInput={(event): void => props.onEmailChange(event.currentTarget.value)} placeholder="admin@example.com" />
</label>
<label class={styles.field}>
<span class={styles.fieldLabel}>Password</span>
<input type="password" value={props.adminForm.password} onInput={(event): void => props.onPasswordChange(event.currentTarget.value)} placeholder="Create a strong password" />
<small class={styles.fieldHelp}>Use at least 12 characters with uppercase, lowercase, numbers, and symbols.</small>
</label>
</>
);
type BootstrapStructureStepProps = TooltipHandlers & {
mode: ModeForm["mode"];
structureForm: StructureForm;
onDepartmentNameChange: (value: string) => void;
onTeamNameChange: (value: string) => void;
onProjectNameChange: (value: string) => void;
};
export const BootstrapStructureStep = (props: BootstrapStructureStepProps): JSX.Element => (
<>
<label class={styles.field}>
<FieldLabelWithTooltip label="Department" tooltip={workspaceHomeFieldTooltips.department} onShowTooltip={props.onShowTooltip} onHideTooltip={props.onHideTooltip} />
<input type="text" value={props.structureForm.departmentName} disabled={props.mode === "personal"} onInput={(event): void => props.onDepartmentNameChange(event.currentTarget.value)} placeholder={organizationalStructureDefaults.departmentName} />
</label>
<label class={styles.field}>
<FieldLabelWithTooltip label="Team" tooltip={workspaceHomeFieldTooltips.team} onShowTooltip={props.onShowTooltip} onHideTooltip={props.onHideTooltip} />
<input type="text" value={props.structureForm.teamName} disabled={props.mode === "personal"} onInput={(event): void => props.onTeamNameChange(event.currentTarget.value)} placeholder={organizationalStructureDefaults.teamName} />
</label>
<label class={styles.field}>
<FieldLabelWithTooltip label="Project" tooltip={workspaceHomeFieldTooltips.project} onShowTooltip={props.onShowTooltip} onHideTooltip={props.onHideTooltip} />
<input type="text" value={props.structureForm.projectName} onInput={(event): void => props.onProjectNameChange(event.currentTarget.value)} placeholder="Moku" />
</label>
</>
);
@@ -1,168 +1,13 @@
// Path: Frontend/src/components/workspace-home/WorkspaceHome/WorkspaceHome.tsx // Path: Frontend/src/components/workspace-home/WorkspaceHome/WorkspaceHome.tsx
import { For, Show, createEffect, createMemo, createSignal, type JSX } from "solid-js"; import { Show, createMemo, type JSX } from "solid-js";
import { Portal } from "solid-js/web"; import { Portal } from "solid-js/web";
import { createStore } from "solid-js/store";
import { resolveAPIBase } from "../../../lib/api";
import { ChevronLeft, ChevronRight } from "../../../lib/icons"; import { ChevronLeft, ChevronRight } from "../../../lib/icons";
import { useAppShellData } from "../../shell/data/app-shell.context"; import { useAppShellData } from "../../shell/data/app-shell.context";
import { bootstrapPersonaDefinitions } from "./WorkspaceHome.data";
import { useWorkspaceHomeWizard } from "./WorkspaceHome.hook";
import styles from "./WorkspaceHome.module.scss"; import styles from "./WorkspaceHome.module.scss";
import { BootstrapAdminStep, BootstrapFinishingState, BootstrapInstanceStep, BootstrapModeStep, BootstrapPersonaStep, BootstrapStructureStep, BootstrapWizardProgress } from "./WorkspaceHome.parts";
type BootstrapStepKey = "instance" | "mode" | "admin" | "structure";
type BootstrapStepDefinition = {
id: BootstrapStepKey;
title: string;
buttonLabel: string;
};
type BootstrapSubmissionState = {
status: "idle" | "submitting" | "success" | "error";
error: string;
};
type InstanceForm = {
protocol: "http" | "https";
access: "local" | "remote";
host: string;
};
type ModeForm = {
mode: "personal" | "organizational";
name: string;
};
type AdminForm = {
displayName: string;
email: string;
password: string;
};
type StructureForm = {
departmentName: string;
teamName: string;
projectName: string;
};
const bootstrapStepDefinitions: readonly BootstrapStepDefinition[] = [
{
id: "instance",
title: "Instance shape",
buttonLabel: "Save and continue",
},
{
id: "mode",
title: "Server mode",
buttonLabel: "Save and continue",
},
{
id: "admin",
title: "Admin account",
buttonLabel: "Save and continue",
},
{
id: "structure",
title: "Initial structure",
buttonLabel: "Submit",
},
];
const defaultInstanceForm: InstanceForm = {
protocol: "http",
access: "local",
host: "localhost",
};
const defaultModeForm: ModeForm = {
mode: "personal",
name: "",
};
const defaultAdminForm: AdminForm = {
displayName: "Admin",
email: "admin@example.com",
password: "",
};
const personalStructureDefaults = {
departmentName: "Default",
teamName: "Personal",
};
const organizationalStructureDefaults = {
departmentName: "Department",
teamName: "Team",
};
const defaultStructureForm: StructureForm = {
...personalStructureDefaults,
projectName: "Project",
};
const initialSubmissionState = (): BootstrapSubmissionState => ({
status: "idle",
error: "",
});
const readResponseBody = async (response: Response): Promise<unknown> => {
const raw = await response.text();
if (!raw.trim()) {
return null;
}
try {
return JSON.parse(raw);
} catch {
return raw;
}
};
const readResponseError = (step: BootstrapStepKey, data: unknown): string => {
const fallback = `Bootstrap ${step} request failed.`;
if (typeof data === "string") {
const message = data.trim();
return message || fallback;
}
if (!data || typeof data !== "object") {
return fallback;
}
const record = data as {
error?: string;
message?: string;
requestId?: string;
};
const message = typeof record.message === "string" ? record.message.trim() : "";
const errorCode = typeof record.error === "string" ? record.error.trim() : "";
const requestId = typeof record.requestId === "string" ? record.requestId.trim() : "";
if (!message && !errorCode && !requestId) {
return fallback;
}
const details: string[] = [];
if (errorCode) {
details.push(`code: ${errorCode}`);
}
if (requestId) {
details.push(`request: ${requestId}`);
}
if (message && details.length > 0) {
return `${message} (${details.join(", ")})`;
}
if (message) {
return message;
}
return `${fallback} (${details.join(", ")})`;
};
type WorkspaceHomeProps = { type WorkspaceHomeProps = {
sidebarCollapsed: boolean; sidebarCollapsed: boolean;
@@ -171,206 +16,61 @@ type WorkspaceHomeProps = {
export const WorkspaceHome = (props: WorkspaceHomeProps): JSX.Element => { export const WorkspaceHome = (props: WorkspaceHomeProps): JSX.Element => {
const appShellData = useAppShellData(); const appShellData = useAppShellData();
const [instanceForm, setInstanceForm] = createStore<InstanceForm>({ ...defaultInstanceForm }); const {
const [modeForm, setModeForm] = createStore<ModeForm>({ ...defaultModeForm }); instanceForm,
const [adminForm, setAdminForm] = createStore<AdminForm>({ ...defaultAdminForm }); setInstanceForm,
const [structureForm, setStructureForm] = createStore<StructureForm>({ ...defaultStructureForm }); modeForm,
const [stepState, setStepState] = createStore<Record<BootstrapStepKey, BootstrapSubmissionState>>({ setModeForm,
instance: initialSubmissionState(), adminForm,
mode: initialSubmissionState(), setAdminForm,
admin: initialSubmissionState(), structureForm,
structure: initialSubmissionState(), setStructureForm,
}); selectedPersona,
const [isBootstrapStateResolved, setIsBootstrapStateResolved] = createSignal(false); hasChosenPersona,
const [isBootstrapComplete, setIsBootstrapComplete] = createSignal(false); stepState,
const [isWizardOpen, setIsWizardOpen] = createSignal(false); isBootstrapStateResolved,
const [currentStepIndex, setCurrentStepIndex] = createSignal(0); isWizardOpen,
setIsWizardOpen,
setIsFinishingBootstrapFlow,
fieldTooltip,
materializationState,
isMaterializationInFlight,
hasMaterializationFailed,
showBootstrapFinishingState,
materializationStatusLabel,
materializationMessage,
personaDefinition,
selectedPersonaIsAvailable,
usesCondensedBootstrapFlow,
activeWizardSteps,
bootstrapNamePlaceholder,
bootstrapStepCount,
currentStep,
currentWizardStepIndex,
wizardProgressFillWidth,
currentStepState,
isFirstStep,
canDismissWizard,
handleCurrentStepSubmit,
applyPersonaSelection,
statusLabel,
showFieldTooltip,
hideFieldTooltip,
stepStatusLabel,
navigateBack,
navigateToVisibleStep,
} = useWorkspaceHomeWizard(appShellData);
const isBootstrapPersisted = createMemo(() => appShellData.installation()?.isBootstrapped ?? false);
createEffect(() => { const sidebarToggleLabel = (): string => (props.sidebarCollapsed ? "Expand left workspace sidebar" : "Collapse left workspace sidebar");
if (modeForm.mode === "personal") {
setStructureForm("departmentName", personalStructureDefaults.departmentName);
setStructureForm("teamName", personalStructureDefaults.teamName);
return;
}
if (structureForm.departmentName === personalStructureDefaults.departmentName) {
setStructureForm("departmentName", organizationalStructureDefaults.departmentName);
}
if (structureForm.teamName === personalStructureDefaults.teamName) {
setStructureForm("teamName", organizationalStructureDefaults.teamName);
}
});
createEffect(() => {
const shellStatus = appShellData.status();
if (shellStatus === "idle" || shellStatus === "loading") {
return;
}
if (shellStatus !== "success") {
return;
}
const installationAccessor = appShellData.installation;
const installation = typeof installationAccessor === "function" ? installationAccessor() : undefined;
const isPersistedBootstrap = installation?.isBootstrapped ?? false;
if (!isPersistedBootstrap) {
resetWizardState();
}
setIsBootstrapComplete(isPersistedBootstrap);
setIsWizardOpen(!isPersistedBootstrap);
setIsBootstrapStateResolved(true);
});
const sidebarToggleLabel = (): string =>
props.sidebarCollapsed ? "Expand left workspace sidebar" : "Collapse left workspace sidebar";
const breadcrumb = (): string => `${appShellData.activeServer().name} / ${appShellData.activeProject().name} / Home`; const breadcrumb = (): string => `${appShellData.activeServer().name} / ${appShellData.activeProject().name} / Home`;
const apiBase = (): string => resolveAPIBase();
const bootstrapTargetLabel = (): string =>
modeForm.mode === "personal" ? "Personal server" : "Organization server";
const bootstrapNamePlaceholder = (): string =>
modeForm.mode === "personal" ? "Personal server name" : "Organization server name";
const currentStep = createMemo<BootstrapStepDefinition>(
() => bootstrapStepDefinitions[currentStepIndex()] ?? bootstrapStepDefinitions[0]!,
);
const currentStepState = createMemo<BootstrapSubmissionState>(() => stepState[currentStep().id]);
const isFirstStep = (): boolean => currentStepIndex() === 0;
const isLastStep = (): boolean => currentStepIndex() === bootstrapStepDefinitions.length - 1;
const canDismissWizard = (): boolean => isBootstrapComplete();
const resetWizardState = (): void => {
setInstanceForm({ ...defaultInstanceForm });
setModeForm({ ...defaultModeForm });
setAdminForm({ ...defaultAdminForm });
setStructureForm({ ...defaultStructureForm });
setStepState({
instance: initialSubmissionState(),
mode: initialSubmissionState(),
admin: initialSubmissionState(),
structure: initialSubmissionState(),
});
setCurrentStepIndex(0);
};
const submitStep = async (step: BootstrapStepKey, payload: unknown): Promise<boolean> => {
setStepState(step, { status: "submitting", error: "" });
try {
const response = await fetch(`${apiBase()}/bootstrap/steps/${step}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify(payload),
});
const data = await readResponseBody(response);
if (!response.ok) {
throw new Error(readResponseError(step, data));
}
setStepState(step, {
status: "success",
error: "",
});
return true;
} catch (error) {
setStepState(step, {
status: "error",
error: error instanceof Error ? error.message : `Bootstrap ${step} request failed.`,
});
return false;
}
};
const payloadForStep = (step: BootstrapStepKey): unknown => {
switch (step) {
case "instance":
return instanceForm;
case "mode":
return modeForm;
case "admin":
return adminForm;
case "structure":
return structureForm;
}
};
const submitCurrentStep = async (): Promise<void> => {
const step = currentStep().id;
const didSucceed = await submitStep(step, payloadForStep(step));
if (!didSucceed) {
return;
}
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);
setIsBootstrapStateResolved(true);
return;
}
setCurrentStepIndex((index) => Math.min(index + 1, bootstrapStepDefinitions.length - 1));
};
const handleCurrentStepSubmit: JSX.EventHandler<HTMLFormElement, SubmitEvent> = (event): void => {
event.preventDefault();
void submitCurrentStep();
};
const statusLabel = (state: BootstrapSubmissionState): string => {
switch (state.status) {
case "submitting":
return "Sending";
case "success":
return "Saved";
case "error":
return "Request failed";
default:
return "Ready";
}
};
const stepStatusLabel = (step: BootstrapStepDefinition): string => {
const state = stepState[step.id];
if (state.status === "success") {
return "Done";
}
if (state.status === "error") {
return "Needs retry";
}
return "";
};
return ( return (
<> <>
<main class={styles.viewport} data-ui="workspace-home"> <main class={styles.viewport} data-ui="workspace-home">
<div class={styles.workspaceTopBar} data-slot="workspace-home-top-bar"> <div class={styles.workspaceTopBar} data-slot="workspace-home-top-bar">
<div class={styles.workspaceTopBarStart} data-slot="workspace-home-top-bar-start"> <div class={styles.workspaceTopBarStart} data-slot="workspace-home-top-bar-start">
<button <button type="button" class={styles.workspaceCollapseButton} aria-label={sidebarToggleLabel()} title={sidebarToggleLabel()} data-slot="workspace-home-sidebar-toggle" onClick={props.onToggleSidebarCollapse}>
type="button"
class={styles.workspaceCollapseButton}
aria-label={sidebarToggleLabel()}
title={sidebarToggleLabel()}
data-slot="workspace-home-sidebar-toggle"
onClick={props.onToggleSidebarCollapse}
>
{props.sidebarCollapsed ? <ChevronRight size={16} strokeWidth={2} /> : <ChevronLeft size={16} strokeWidth={2} />} {props.sidebarCollapsed ? <ChevronRight size={16} strokeWidth={2} /> : <ChevronLeft size={16} strokeWidth={2} />}
</button> </button>
</div> </div>
@@ -383,8 +83,8 @@ export const WorkspaceHome = (props: WorkspaceHomeProps): JSX.Element => {
</div> </div>
<section class={styles.hero} data-slot="workspace-home-hero"> <section class={styles.hero} data-slot="workspace-home-hero">
<h1 class={styles.title}>{isBootstrapComplete() ? appShellData.activeServer().name : bootstrapTargetLabel()}</h1> <h1 class={styles.title}>{isBootstrapPersisted() ? appShellData.activeServer().name : "Server"}</h1>
<Show when={isBootstrapStateResolved() && !isBootstrapComplete()}> <Show when={isBootstrapStateResolved() && !isBootstrapPersisted()}>
<div class={styles.heroActions}> <div class={styles.heroActions}>
<button <button
type="button" type="button"
@@ -409,7 +109,7 @@ export const WorkspaceHome = (props: WorkspaceHomeProps): JSX.Element => {
<header class={styles.wizardHeader} data-slot="bootstrap-wizard-header"> <header class={styles.wizardHeader} data-slot="bootstrap-wizard-header">
<div class={styles.wizardHeaderCopy}> <div class={styles.wizardHeaderCopy}>
<h2 id="bootstrap-wizard-title" class={styles.wizardTitle}> <h2 id="bootstrap-wizard-title" class={styles.wizardTitle}>
Bootstrap {bootstrapTargetLabel()} Bootstrap Server
</h2> </h2>
</div> </div>
<Show when={canDismissWizard()}> <Show when={canDismissWizard()}>
@@ -425,205 +125,144 @@ export const WorkspaceHome = (props: WorkspaceHomeProps): JSX.Element => {
</Show> </Show>
</header> </header>
<div class={styles.wizardBody}> <Show
<aside class={styles.wizardSidebar} data-slot="bootstrap-wizard-sidebar"> when={!showBootstrapFinishingState()}
<nav class={styles.wizardSteps} aria-label="Bootstrap steps"> fallback={
<For each={bootstrapStepDefinitions}> <BootstrapFinishingState
{(step, index): JSX.Element => ( materializationState={materializationState()}
<button statusLabel={materializationStatusLabel()}
type="button" message={materializationMessage()}
class={styles.wizardStepButton} isInFlight={isMaterializationInFlight()}
data-active={step.id === currentStep().id ? "true" : "false"} hasFailed={hasMaterializationFailed()}
disabled={index() > currentStepIndex()} onClose={(): void => {
onClick={(): void => { setIsFinishingBootstrapFlow(false);
if (index() <= currentStepIndex()) { setIsWizardOpen(false);
setCurrentStepIndex(index());
}
}} }}
/>
}
> >
<span class={styles.wizardStepIndex}>{index() + 1}</span> <div class={styles.wizardBody}>
<span class={styles.wizardStepCopy}> <Show when={currentStep().id !== "persona"}>
<strong>{step.title}</strong> <BootstrapWizardProgress
<Show when={stepStatusLabel(step)}> steps={activeWizardSteps()}
<small>{stepStatusLabel(step)}</small> currentStepId={currentStep().id}
currentWizardStepIndex={currentWizardStepIndex()}
stepState={stepState}
bootstrapStepCount={bootstrapStepCount()}
wizardProgressFillWidth={wizardProgressFillWidth()}
stepStatusLabel={stepStatusLabel}
onSelectStep={navigateToVisibleStep}
/>
</Show> </Show>
</span>
</button>
)}
</For>
</nav>
</aside>
<div class={styles.wizardStepPanel} data-slot="bootstrap-wizard-step-panel"> <div class={styles.wizardStepPanel} data-slot="bootstrap-wizard-step-panel">
<Show when={currentStep().id !== "persona" || statusLabel(currentStepState())}>
<div class={styles.sectionHeader}> <div class={styles.sectionHeader}>
<Show when={currentStep().id !== "persona"}>
<div> <div>
<span class={styles.wizardStepEyebrow}>{`Step ${currentStepIndex() + 1} of ${bootstrapStepDefinitions.length}`}</span> <span class={styles.wizardStepEyebrow}>{`Step ${currentWizardStepIndex() + 1} of ${bootstrapStepCount()}`}</span>
<h3 class={styles.sectionTitle}>{currentStep().title}</h3>
</div> </div>
<div class={styles.statusBadge} data-status={currentStepState().status}>{statusLabel(currentStepState())}</div> </Show>
<Show when={statusLabel(currentStepState())}>
<div class={styles.statusBadge} data-status={currentStepState().status}>
{statusLabel(currentStepState())}
</div> </div>
</Show>
</div>
</Show>
<form class={styles.form} onSubmit={handleCurrentStepSubmit}> <form class={styles.form} onSubmit={handleCurrentStepSubmit}>
<Show when={currentStep().id === "instance"}> <Show when={currentStep().id === "persona"}>
<> <BootstrapPersonaStep
<label class={styles.field}> personas={bootstrapPersonaDefinitions}
<span class={styles.fieldLabel}>Protocol</span> hasChosenPersona={hasChosenPersona()}
<select selectedPersona={selectedPersona()}
value={instanceForm.protocol} selectedPersonaIsAvailable={selectedPersonaIsAvailable()}
onInput={(event): void => onSelectPersona={applyPersonaSelection}
setInstanceForm("protocol", event.currentTarget.value as InstanceForm["protocol"]) />
} </Show>
>
<option value="http">http</option> <Show when={currentStep().id === "instance"}>
<option value="https">https</option> <BootstrapInstanceStep
</select> instanceForm={instanceForm}
</label> onProtocolChange={(value): void => setInstanceForm("protocol", value)}
<label class={styles.field}> onAccessChange={(value): void => setInstanceForm("access", value)}
<span class={styles.fieldLabel}>Access</span> onHostChange={(value): void => setInstanceForm("host", value)}
<select onShowTooltip={showFieldTooltip}
value={instanceForm.access} onHideTooltip={hideFieldTooltip}
onInput={(event): void =>
setInstanceForm("access", event.currentTarget.value as InstanceForm["access"])
}
>
<option value="local">local</option>
<option value="remote">remote</option>
</select>
</label>
<label class={styles.field}>
<span class={styles.fieldLabel}>Host</span>
<input
type="text"
value={instanceForm.host}
onInput={(event): void => setInstanceForm("host", event.currentTarget.value)}
placeholder="localhost or app.example.com"
/> />
</label>
</>
</Show> </Show>
<Show when={currentStep().id === "mode"}> <Show when={currentStep().id === "mode"}>
<> <BootstrapModeStep
<label class={styles.field}> modeForm={modeForm}
<span class={styles.fieldLabel}>Mode</span> structureForm={structureForm}
<select usesCondensedBootstrapFlow={usesCondensedBootstrapFlow()}
value={modeForm.mode} selectedPersona={selectedPersona()}
onInput={(event): void => setModeForm("mode", event.currentTarget.value as ModeForm["mode"])} namePlaceholder={bootstrapNamePlaceholder()}
> onNameChange={(value): void => setModeForm("name", value)}
<option value="personal">personal</option> onProjectNameChange={(value): void => setStructureForm("projectName", value)}
<option value="organizational">organizational</option> onTeamNameChange={(value): void => setStructureForm("teamName", value)}
</select> onShowTooltip={showFieldTooltip}
</label> onHideTooltip={hideFieldTooltip}
<label class={styles.field}>
<span class={styles.fieldLabel}>Server name</span>
<input
type="text"
value={modeForm.name}
required
onInput={(event): void => setModeForm("name", event.currentTarget.value)}
placeholder={bootstrapNamePlaceholder()}
/> />
</label>
</>
</Show> </Show>
<Show when={currentStep().id === "admin"}> <Show when={currentStep().id === "admin"}>
<> <BootstrapAdminStep
<label class={styles.field}> adminForm={adminForm}
<span class={styles.fieldLabel}>Display name</span> onDisplayNameChange={(value): void => setAdminForm("displayName", value)}
<input onEmailChange={(value): void => setAdminForm("email", value)}
type="text" onPasswordChange={(value): void => setAdminForm("password", value)}
value={adminForm.displayName}
onInput={(event): void => setAdminForm("displayName", event.currentTarget.value)}
placeholder="Admin"
/> />
</label>
<label class={styles.field}>
<span class={styles.fieldLabel}>Email</span>
<input
type="email"
value={adminForm.email}
onInput={(event): void => setAdminForm("email", event.currentTarget.value)}
placeholder="admin@example.com"
/>
</label>
<label class={styles.field}>
<span class={styles.fieldLabel}>Password</span>
<input
type="password"
value={adminForm.password}
onInput={(event): void => setAdminForm("password", event.currentTarget.value)}
placeholder="Create a strong password"
/>
<small class={styles.fieldHelp}>
Use at least 12 characters with uppercase, lowercase, numbers, and symbols.
</small>
</label>
</>
</Show> </Show>
<Show when={currentStep().id === "structure"}> <Show when={currentStep().id === "structure"}>
<> <BootstrapStructureStep
<label class={styles.field}> mode={modeForm.mode}
<span class={styles.fieldLabel}>Department</span> structureForm={structureForm}
<input onDepartmentNameChange={(value): void => setStructureForm("departmentName", value)}
type="text" onTeamNameChange={(value): void => setStructureForm("teamName", value)}
value={structureForm.departmentName} onProjectNameChange={(value): void => setStructureForm("projectName", value)}
disabled={modeForm.mode === "personal"} onShowTooltip={showFieldTooltip}
onInput={(event): void => setStructureForm("departmentName", event.currentTarget.value)} onHideTooltip={hideFieldTooltip}
placeholder={organizationalStructureDefaults.departmentName}
/> />
</label>
<label class={styles.field}>
<span class={styles.fieldLabel}>Team</span>
<input
type="text"
value={structureForm.teamName}
disabled={modeForm.mode === "personal"}
onInput={(event): void => setStructureForm("teamName", event.currentTarget.value)}
placeholder={organizationalStructureDefaults.teamName}
/>
</label>
<label class={styles.field}>
<span class={styles.fieldLabel}>Project</span>
<input
type="text"
value={structureForm.projectName}
onInput={(event): void => setStructureForm("projectName", event.currentTarget.value)}
placeholder="Moku"
/>
</label>
</>
</Show> </Show>
<Show when={currentStep().id !== "persona"}>
<div class={styles.wizardFormActions}> <div class={styles.wizardFormActions}>
<button <button type="button" class={styles.secondaryButton} disabled={isFirstStep()} onClick={navigateBack}>
type="button"
class={styles.secondaryButton}
disabled={isFirstStep()}
onClick={(): void => {
setCurrentStepIndex((index) => Math.max(index - 1, 0));
}}
>
Back Back
</button> </button>
<button <button type="submit" class={styles.primaryButton} disabled={currentStepState().status === "submitting"}>
type="submit"
class={styles.primaryButton}
disabled={currentStepState().status === "submitting"}
>
{currentStep().buttonLabel} {currentStep().buttonLabel}
</button> </button>
</div> </div>
</Show>
</form> </form>
<Show when={currentStepState().error}> <Show when={currentStepState().error}>
<p class={styles.errorText}>{currentStepState().error}</p> <p class={styles.errorText}>{currentStepState().error}</p>
</Show> </Show>
</div> </div>
</div> </div>
</Show>
</section> </section>
<Show when={fieldTooltip()}>
{(tooltip): JSX.Element => (
<div
class={styles.fieldTooltip}
data-placement={tooltip().placement}
style={{
left: `${tooltip().left}px`,
top: `${tooltip().top}px`,
}}
>
<div class={styles.fieldTooltipBubble}>{tooltip().text}</div>
</div>
)}
</Show>
</div> </div>
</Portal> </Portal>
</Show> </Show>