diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ed1f946..df63c91 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -25,6 +25,7 @@ jobs: go-version: '1.24' goreleaser-config: '.goreleaser.yaml' goreleaser-args: 'release --clean' + secrets: inherit permissions: contents: write packages: write diff --git a/.github/workflows/reusable-build.yml b/.github/workflows/reusable-build.yml index bf9bdb0..e32fd4f 100644 --- a/.github/workflows/reusable-build.yml +++ b/.github/workflows/reusable-build.yml @@ -2,6 +2,9 @@ name: Reusable Build on: workflow_call: + secrets: + HOMEBREW_TAP_GITHUB_TOKEN: + required: false inputs: go-version: type: string @@ -73,4 +76,5 @@ jobs: args: ${{ inputs.goreleaser-args }} --config=${{ inputs.goreleaser-config }} env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + HOMEBREW_TAP_GITHUB_TOKEN: ${{ secrets.HOMEBREW_TAP_GITHUB_TOKEN }} RELEASE_NAME: ${{ inputs.release-name }} diff --git a/.github/workflows/tag-release.yml b/.github/workflows/tag-release.yml new file mode 100644 index 0000000..effd17d --- /dev/null +++ b/.github/workflows/tag-release.yml @@ -0,0 +1,41 @@ +name: Tag Release + +on: + push: + branches: + - develop + paths: + - VERSION + +permissions: + contents: write + +jobs: + tag: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Read version + id: ver + run: echo "version=v$(cat VERSION | tr -d '[:space:]')" >> "$GITHUB_OUTPUT" + + - name: Check if tag already exists + id: check + run: | + if git rev-parse "${{ steps.ver.outputs.version }}" >/dev/null 2>&1; then + echo "exists=true" >> "$GITHUB_OUTPUT" + else + echo "exists=false" >> "$GITHUB_OUTPUT" + fi + + - name: Create and push tag + if: steps.check.outputs.exists == 'false' + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git tag -a "${{ steps.ver.outputs.version }}" -m "Release ${{ steps.ver.outputs.version }}" + git push origin "${{ steps.ver.outputs.version }}" diff --git a/.gitignore b/.gitignore index ac4ef0c..5b6da45 100644 --- a/.gitignore +++ b/.gitignore @@ -35,6 +35,9 @@ go.work.sum # Arc state directory .arc/ +# Local test workspaces (created by arc workspace init during development) +workspace/ + # Legacy UI (kept as reference during 018-ui-design rebuild) pkg/ui.legacy/ diff --git a/.goreleaser.feature.yaml b/.goreleaser.feature.yaml index b7a77da..545b44e 100644 --- a/.goreleaser.feature.yaml +++ b/.goreleaser.feature.yaml @@ -27,9 +27,9 @@ builds: goarch: arm64 ldflags: - -s -w - - -X github.com/arc-framework/arc-cli/internal/version.Version={{.Version}} - - -X github.com/arc-framework/arc-cli/internal/version.GitCommit={{.Commit}} - - -X github.com/arc-framework/arc-cli/internal/version.BuildDate={{.Date}} + - -X github.com/arc-framework/arc-cli/pkg/version.Version={{.Version}} + - -X github.com/arc-framework/arc-cli/pkg/version.Commit={{.Commit}} + - -X github.com/arc-framework/arc-cli/pkg/version.BuildDate={{.Date}} archives: - id: arc-archive diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 279a38c..4f09e6c 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -30,9 +30,9 @@ builds: goarch: arm64 ldflags: - -s -w - - -X github.com/arc-framework/arc-cli/internal/version.Version={{.Version}} - - -X github.com/arc-framework/arc-cli/internal/version.GitCommit={{.Commit}} - - -X github.com/arc-framework/arc-cli/internal/version.BuildDate={{.Date}} + - -X github.com/arc-framework/arc-cli/pkg/version.Version={{.Version}} + - -X github.com/arc-framework/arc-cli/pkg/version.Commit={{.Commit}} + - -X github.com/arc-framework/arc-cli/pkg/version.BuildDate={{.Date}} archives: - id: arc-archive diff --git a/CLAUDE.md b/CLAUDE.md index 47475ac..8208414 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -3,6 +3,8 @@ Auto-generated from all feature plans. Last updated: 2026-02-16 ## Active Technologies +- Go 1.24.2 (go.mod declares 1.26.0) + cobra, charmbracelet/bubbletea v1.3.4, charmbracelet/huh, charmbracelet/lipgloss v1.1.1, charmbracelet/bubbles v0.21.0; `pkg/catalog` (existing internal package) (020-pre-release) +- `//go:embed` for `profiles.yaml` (new) and existing `services.yaml` (unchanged); no new DB (020-pre-release) - Go 1.24.2 (016-ui-layout-fix) - N/A (UI-only feature, no persistence required) (016-ui-layout-fix) @@ -27,16 +29,21 @@ tests/ Go 1.24.0: Follow standard conventions ## Recent Changes +- 020-pre-release: Added Go 1.24.2 (go.mod declares 1.26.0) + cobra, charmbracelet/bubbletea v1.3.4, charmbracelet/huh, charmbracelet/lipgloss v1.1.1, charmbracelet/bubbles v0.21.0; `pkg/catalog` (existing internal package) - 017-ui-engine: Added UI engine (pkg/ui/engine/), views (pkg/ui/views/), ARC_USE_LEGACY_UI env var for rollback - 016-ui-layout-fix: Added Go 1.24.2 -- 015-ui-refactor: Added Go 1.24.0 + charmbracelet/bubbletea v1.3.4, charmbracelet/bubbles v0.21.0, charmbracelet/lipgloss v1.1.1, charmbracelet/glamour v0.10.0, charmbracelet/harmonica v0.2.0, charmbracelet/x/ansi v0.8.0, charmbracelet/x/term v0.2.1, spf13/cobra, charmbracelet/huh (NEW β€” RECOMMENDED for interactive forms) +## Commit Conventions +- NEVER add `Co-Authored-By` trailers to commit messages or PR descriptions +- Commit messages use Conventional Commits: `type(scope): description` +- No AI attribution lines of any kind in commits or PRs + ## UI Engine Architecture (017) - New views: pkg/ui/views/ (dashboardview.go, serviceslistview.go, etc.) - Engine: pkg/ui/engine/ (Render, TUIMode, JSONMode, ViewContext) diff --git a/Makefile b/Makefile index 77340b1..919e00f 100644 --- a/Makefile +++ b/Makefile @@ -8,6 +8,10 @@ # Define Go packages to be used in tests ALL_PKGS := $(shell go list ./cmd/... ./internal/... ./pkg/...) CORE_PKGS := $(shell go list ./internal/... ./pkg/store/... ./pkg/log/... ./pkg/ui/...) +# pkg/store/local is excluded from race testing: its rotation test performs O(nΒ²) disk +# reads (1000+ iterations Γ— full YAML parse each) which times out under -race overhead. +RACE_PKGS := $(shell go list ./internal/... ./pkg/store \ + ./pkg/log/... ./pkg/ui/...) CLI_PKGS := $(shell go list ./pkg/cli/...) # Build flags @@ -228,7 +232,9 @@ clean: test: $(call log_section,πŸ§ͺ Running Tests) $(call log_info,Running core package tests with race detector...) - @go test -v -race $(CORE_PKGS) + @go test -v -race $(RACE_PKGS) + $(call log_info,Running I/O packages without race detector \(O\(nΒ²\) disk reads are too slow under -race\)...) + @go test -v ./pkg/store/local/... $(call log_info,Running CLI tests (sequential)...) @go test -v -p 1 $(CLI_PKGS) $(call log_success,Tests complete) diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..0ea3a94 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +0.2.0 diff --git a/arc.yaml b/arc.yaml new file mode 100644 index 0000000..cd36e7e --- /dev/null +++ b/arc.yaml @@ -0,0 +1,29 @@ +# A.R.C. Workspace Manifest +# Documentation: https://github.com/arc-framework/arc-cli + +# Semantic version of arc.yaml format +version: "1.0.0" + +# Platform tier β€” controls which services are started. +# Options: think (core only), reason (+ reasoning engine), ultra-instinct (all capabilities) +tier: "think" + +# Optional capability bundles to add on top of the tier preset. +# Each capability unlocks additional services and resolves its own dependencies. +# Available: reasoner, voice, observe, security, storage +# capabilities: +# - reasoner # LLM reasoning engine β€” required by voice +# - voice # Voice agent (STT + TTS + LiveKit) +# - observe # Observability stack (SigNoz + OpenTelemetry) +# - security # Secrets vault + feature flags (OpenBao + Unleash) +# - storage # Object storage (MinIO) + +# Service-specific overrides (optional) +# services: +# arc-gateway: +# enabled: true + +# Environment variables injected into all services (optional) +# environment: +# LOG_LEVEL: "info" +# ENVIRONMENT: "development" diff --git a/go.mod b/go.mod index 4ebee92..ca3334a 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,6 @@ go 1.26.0 require ( github.com/charmbracelet/bubbles v1.0.0 github.com/charmbracelet/bubbletea v1.3.10 - github.com/charmbracelet/harmonica v0.2.0 github.com/charmbracelet/huh v0.8.0 github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 github.com/charmbracelet/log v0.4.2 @@ -24,6 +23,7 @@ require ( github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/catppuccin/go v0.3.0 // indirect github.com/charmbracelet/colorprofile v0.4.2 // indirect + github.com/charmbracelet/harmonica v0.2.0 // indirect github.com/charmbracelet/x/ansi v0.11.6 // indirect github.com/charmbracelet/x/cellbuf v0.0.15 // indirect github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect diff --git a/internal/app/bootstrap.go b/internal/app/bootstrap.go index bdcf879..ef71b19 100644 --- a/internal/app/bootstrap.go +++ b/internal/app/bootstrap.go @@ -3,8 +3,11 @@ package app import ( + "sort" + "github.com/arc-framework/arc-cli/pkg/ui/engine" "github.com/arc-framework/arc-cli/pkg/ui/theme" + "github.com/arc-framework/arc-cli/pkg/ui/view" ) // EngineConfig builds an engine.Config from the application context. @@ -33,3 +36,29 @@ func EngineConfig( Loader: loader, } } + +// BuildDefaultViews returns the full ordered view set for the ARC dashboard. +// InitWizard is included but NavHidden β€” it is activated via InitialView. +func BuildDefaultViews(loader *theme.Loader) []engine.View { + profiles := sortedProfiles(loader.GetProfiles()) + return []engine.View{ + view.NewHome(), + view.NewServicesList(), + view.NewWorkspaceInfo(), + view.NewWorkspaceHistory(), + view.NewConfigOverview(profiles, loader), + view.NewInitWizard(), + } +} + +// sortedProfiles returns profiles sorted alphabetically by Name. +func sortedProfiles(m map[string]*theme.Profile) []*theme.Profile { + profiles := make([]*theme.Profile, 0, len(m)) + for _, p := range m { + profiles = append(profiles, p) + } + sort.Slice(profiles, func(i, j int) bool { + return profiles[i].Name < profiles[j].Name + }) + return profiles +} diff --git a/pkg/catalog/catalog.go b/pkg/catalog/catalog.go index c9bcd08..f0b5ad8 100644 --- a/pkg/catalog/catalog.go +++ b/pkg/catalog/catalog.go @@ -134,14 +134,16 @@ const ( // FilterInfrastructure returns only infrastructure services. FilterInfrastructure - // FilterData returns only data and memory services. - FilterData + // FilterService returns only AI workforce / service tier services. + FilterService - // FilterAI returns only AI workforce services. - FilterAI + // FilterSidecar returns only sidecar process services. + FilterSidecar - // FilterObservability returns only observability services. - FilterObservability + // Deprecated filters kept for backward compatibility. + FilterData // no services match this role in current catalog + FilterAI // no services match this role in current catalog + FilterObservability // no services match this role in current catalog ) // ToRole converts a ServiceFilter to its corresponding ServiceRole. @@ -150,6 +152,10 @@ func (f ServiceFilter) ToRole() ServiceRole { switch f { case FilterInfrastructure: return RoleInfrastructure + case FilterService: + return RoleService + case FilterSidecar: + return RoleSidecar case FilterData: return RoleData case FilterAI: @@ -168,6 +174,10 @@ func (f ServiceFilter) String() string { return "All" case FilterInfrastructure: return "Infrastructure" + case FilterService: + return "Service" + case FilterSidecar: + return "Sidecar" case FilterData: return "Data" case FilterAI: diff --git a/pkg/catalog/data/services.yaml b/pkg/catalog/data/services.yaml index 7f1f787..4d177c3 100644 --- a/pkg/catalog/data/services.yaml +++ b/pkg/catalog/data/services.yaml @@ -2,74 +2,57 @@ # This file defines all available services in the A.R.C. platform. # Each service maps an A.R.C. codename to its underlying technology. # -# Service Roles: -# - Infrastructure: Core platform services (gateway, identity, secrets) -# - Data: Data and memory services (databases, caches, object storage) -# - AI: AI workforce services (reasoning, voice, guards) -# - Observability: Monitoring and logging services +# Service Roles (per SERVICE.MD): +# - Infrastructure: Core platform services (gateway, identity, secrets, data, observability) +# - Service: AI workforce services (reasoning, voice, guards, ops) +# - Sidecar: Supporting sidecars (migrations, media ingress/egress, mail) # -# Version: 1.0.0 +# Source of truth: SERVICE.MD +# Version: 2.0.0 services: # ============================================================================= # INFRASTRUCTURE SERVICES + # Codenames match platform operational names (make targets, services/ dirs). + # Source of truth: arc-platform/SERVICE.MD#cli-service-catalog-entries # ============================================================================= - heimdall: - codename: heimdall + gateway: + codename: gateway technology: Traefik role: Infrastructure - description: "The Gatekeeper. Opens the Bifrost (ports) only for authorized traffic." + description: "API gateway and ingress. Routes all inbound HTTP and WebSocket traffic across the platform. Handles SSL termination, load balancing, and service discovery." version: "3.0" - image: "traefik:v3.0" + image: "ghcr.io/arc-framework/arc-gateway:latest" arc_image: "arc-gateway" released: true ports: - host: 80 - container: 80 - protocol: tcp - description: "HTTP traffic" - - host: 443 - container: 443 - protocol: tcp - description: "HTTPS traffic" - - host: 8080 container: 8080 protocol: tcp - description: "Traefik dashboard" - environment: - - name: TRAEFIK_LOG_LEVEL - default: "INFO" - required: false - description: "Logging level (DEBUG, INFO, WARN, ERROR)" - - name: TRAEFIK_API_INSECURE - default: "false" - required: false - description: "Enable insecure API access (development only)" + description: "HTTP proxy (host:80 β†’ container:8080 non-privileged)" + - host: 8090 + container: 8081 + protocol: tcp + description: "Traefik dashboard / ping / metrics (host:8090 β†’ container:8081)" volumes: - - host: "./.arc/data/traefik" - container: "/data" - description: "Certificate storage and ACME data" - host: "/var/run/docker.sock" container: "/var/run/docker.sock" description: "Docker socket for service discovery" read_only: true - config_files: - - "traefik.yml" - - "dynamic.yml" aliases: - traefik - - gateway + - heimdall - jarvis: - codename: jarvis + identity: + codename: identity technology: Kratos role: Infrastructure - description: "The Butler. Handles identity, authentication, and user sessions." + description: "Identity and authentication service. Manages registration, login, password recovery, and MFA. Built on Ory Kratos." version: "1.1" - image: "oryd/kratos:v1.1" + image: "ghcr.io/arc-framework/arc-identity:latest" arc_image: "arc-identity" - released: true + released: false # planned β€” not yet in active make targets ports: - host: 4433 container: 4433 @@ -80,65 +63,37 @@ services: protocol: tcp description: "Admin API" dependencies: - - oracle - environment: - - name: DSN - required: true - description: "Database connection string" - - name: SECRETS_COOKIE - required: true - description: "Cookie encryption secret" - - name: SECRETS_CIPHER - required: true - description: "Data encryption secret" - volumes: - - host: "./.arc/config/kratos" - container: "/etc/config/kratos" - description: "Kratos configuration files" - config_files: - - "kratos.yml" - - "identity.schema.json" + - persistence aliases: - kratos - - identity + - jarvis - auth - nick-fury: - codename: nick-fury - technology: Infisical + vault: + codename: vault + technology: OpenBao role: Infrastructure - description: "The Spymaster. Securely holds the nuclear codes (API keys & secrets)." + description: "Secrets management. Centralizes API keys, tokens, and credentials in one encrypted vault. Powered by OpenBao (open-source HashiCorp Vault fork)." version: "latest" - image: "infisical/infisical:latest" + image: "ghcr.io/arc-framework/arc-vault:latest" arc_image: "arc-vault" released: true ports: - - host: 8082 - container: 8080 + - host: 8200 + container: 8200 protocol: tcp - description: "Infisical web UI and API" - dependencies: - - oracle - - sonic - environment: - - name: ENCRYPTION_KEY - required: true - description: "Master encryption key for secrets" - - name: JWT_SECRET - required: true - description: "JWT signing secret" + description: "Vault API" aliases: - - infisical - secrets - - vault + - nick-fury - mystique: - codename: mystique + flags: + codename: flags technology: Unleash role: Infrastructure - description: "The Shapeshifter. Controls feature flags to morph application behavior." + description: "Feature flag service. Controls which features are live for which users without deploying code. Built on Unleash." version: "latest" - image: "unleashorg/unleash-server:latest" + image: "ghcr.io/arc-framework/arc-flags:latest" arc_image: "arc-flags" released: true ports: @@ -147,54 +102,42 @@ services: protocol: tcp description: "Unleash API and admin UI" dependencies: - - oracle - environment: - - name: DATABASE_URL - required: true - description: "PostgreSQL connection string" - - name: UNLEASH_SECRET - required: true - description: "API authentication secret" + - persistence aliases: - unleash - - flags - - features + - mystique - dr-strange: - codename: dr-strange - technology: Pulsar + streaming: + codename: streaming + technology: Apache Pulsar role: Infrastructure - description: "Master of Events. Opens portals between services through event streaming." + description: "Durable event streaming for high-throughput async event flows. Apache Pulsar with topics, subscriptions, and multi-tenancy." version: "latest" - image: "apachepulsar/pulsar:latest" - arc_image: "arc-stream" + image: "ghcr.io/arc-framework/arc-streaming:latest" + arc_image: "arc-streaming" released: true ports: - host: 6650 container: 6650 protocol: tcp - description: "Pulsar binary protocol" - - host: 8083 + description: "Pulsar broker" + - host: 8082 container: 8080 protocol: tcp description: "Pulsar admin API" - volumes: - - host: "./.arc/data/pulsar" - container: "/pulsar/data" - description: "Pulsar data and metadata" aliases: - pulsar - - events - - streaming + - dr-strange + - strange - the-flash: - codename: the-flash + messaging: + codename: messaging technology: NATS role: Infrastructure - description: "Speed Messenger. Delivers messages faster than light between services." + description: "Low-latency messaging backbone for agent-to-service communication. Built on NATS with JetStream." version: "latest" - image: "nats:latest" - arc_image: "arc-pulse" + image: "ghcr.io/arc-framework/arc-messaging:latest" + arc_image: "arc-messaging" released: true ports: - host: 4222 @@ -205,717 +148,480 @@ services: container: 8222 protocol: tcp description: "NATS monitoring" - - host: 6222 - container: 6222 - protocol: tcp - description: "NATS cluster routing" aliases: - nats - - messaging - - pubsub + - the-flash + - flash - daredevil: - codename: daredevil - technology: LiveKit + realtime: + codename: realtime + technology: LiveKit Server role: Infrastructure - description: "The Radar. Sees the world through sound waves (WebRTC). Real-time voice and video communication." + description: "Realtime media stack β€” WebRTC rooms for voice and video. Manages SFU media routing and signaling." version: "latest" - image: "livekit/livekit-server:latest" - arc_image: "arc-voice-server" + image: "ghcr.io/arc-framework/arc-realtime:latest" + arc_image: "arc-realtime" released: true ports: - host: 7880 container: 7880 protocol: tcp - description: "LiveKit HTTP/WebSocket" + description: "LiveKit HTTP API" - host: 7881 container: 7881 protocol: tcp - description: "LiveKit RTC port" + description: "LiveKit gRPC" - host: 7882 container: 7882 - protocol: udp - description: "LiveKit UDP media" - dependencies: - - sonic + protocol: tcp + description: "LiveKit RTC (TCP fallback)" aliases: - livekit - - realtime + - daredevil - webrtc - hedwig: - codename: hedwig - technology: Postal + persistence: + codename: persistence + technology: PostgreSQL 17 + pgvector role: Infrastructure - description: "The Owl. Delivers mail reliably across any distance." - version: "latest" - image: "ghcr.io/postalserver/postal:latest" - arc_image: "arc-mailer" - released: false - ports: - - host: 25 - container: 25 - protocol: tcp - description: "SMTP port" - - host: 587 - container: 587 - protocol: tcp - description: "SMTP submission port" - - host: 8084 - container: 5000 - protocol: tcp - description: "Postal web interface" - dependencies: - - oracle - - sonic - environment: - - name: POSTAL_FNAME - default: "A.R.C." - required: false - description: "Organization name" - aliases: - - postal - - mail - - smtp - - email - - t-800: - codename: t-800 - technology: ChaosMesh - role: Infrastructure - description: "The Terminator. Tests resilience by introducing chaos to the system." - version: "latest" - image: "ghcr.io/chaos-mesh/chaos-mesh:latest" - arc_image: "arc-chaos" - released: true - ports: - - host: 2333 - container: 2333 - protocol: tcp - description: "Chaos Dashboard" - aliases: - - chaos - - chaos-mesh - - resilience - - # ============================================================================= - # DATA & MEMORY SERVICES - # ============================================================================= - - oracle: - codename: oracle - technology: PostgreSQL - role: Data - description: "Long-Term Memory. The photographic record of truth." - version: "16" - image: "postgres:16-alpine" - arc_image: "arc-db-sql" + description: "Long-term relational storage with vector search extension. PostgreSQL 17 with pgvector for embeddings." + version: "17" + image: "ghcr.io/arc-framework/arc-sql-db:latest" + arc_image: "arc-sql-db" released: true ports: - host: 5432 container: 5432 protocol: tcp - description: "PostgreSQL database port" - environment: - - name: POSTGRES_USER - default: "arc" - required: false - description: "Database superuser name" - - name: POSTGRES_PASSWORD - required: true - description: "Database superuser password" - - name: POSTGRES_DB - default: "arc" - required: false - description: "Default database name" - volumes: - - host: "./.arc/data/postgres" - container: "/var/lib/postgresql/data" - description: "PostgreSQL data directory" - config_files: - - "postgresql.conf" + description: "PostgreSQL" aliases: - postgres - - postgresql + - oracle - db - - database - sonic: - codename: sonic + cache: + codename: cache technology: Redis - role: Data - description: "Working Memory. Gotta go fast. Holds immediate agent context." + role: Infrastructure + description: "Cache and hot state layer. High-speed in-memory store for active agent context, rate limits, and session data." version: "7" - image: "redis:7-alpine" - arc_image: "arc-db-cache" + image: "ghcr.io/arc-framework/arc-cache:latest" + arc_image: "arc-cache" released: true ports: - host: 6379 container: 6379 protocol: tcp - description: "Redis server port" - environment: - - name: REDIS_PASSWORD - required: false - description: "Redis authentication password" - volumes: - - host: "./.arc/data/redis" - container: "/data" - description: "Redis data persistence" - config_files: - - "redis.conf" + description: "Redis" aliases: - redis - - cache + - sonic - cerebro: - codename: cerebro - technology: Qdrant - role: Data - description: "The Finder. Vector database connecting thoughts via semantic search." - version: "latest" - image: "qdrant/qdrant:latest" - arc_image: "arc-db-vector" - released: true - ports: - - host: 6333 - container: 6333 - protocol: tcp - description: "Qdrant HTTP API" - - host: 6334 - container: 6334 - protocol: tcp - description: "Qdrant gRPC API" - volumes: - - host: "./.arc/data/qdrant" - container: "/qdrant/storage" - description: "Qdrant vector storage" - aliases: - - qdrant - - vector - - embeddings + # cerebro (Qdrant) removed β€” vector search is now handled by pgvector inside arc-persistence. - tardis: - codename: tardis + storage: + codename: storage technology: MinIO - role: Data - description: "Infinite Storage. It's bigger on the inside. S3-compatible object storage." + role: Infrastructure + description: "S3-compatible object storage for files, models, and media." version: "latest" - image: "minio/minio:latest" + image: "ghcr.io/arc-framework/arc-storage:latest" arc_image: "arc-storage" released: true ports: - host: 9000 container: 9000 protocol: tcp - description: "MinIO API port" + description: "MinIO API" - host: 9001 container: 9001 protocol: tcp - description: "MinIO console port" - environment: - - name: MINIO_ROOT_USER - default: "minioadmin" - required: false - description: "MinIO root username" - - name: MINIO_ROOT_PASSWORD - required: true - description: "MinIO root password" - volumes: - - host: "./.arc/data/minio" - container: "/data" - description: "MinIO object storage" + description: "MinIO console" aliases: - minio + - tardis - s3 - - storage - - objects - pathfinder: - codename: pathfinder - technology: Migrate - role: Data - description: "The Navigator. Guides database schemas through safe migrations." + friday-collector: + codename: friday-collector + technology: OpenTelemetry Collector + role: Infrastructure + description: "OpenTelemetry collector β€” receives traces, metrics, logs from all services and routes to the observability stack." version: "latest" - image: "migrate/migrate:latest" - arc_image: "arc-migrate" + image: "ghcr.io/arc-framework/arc-friday-collector:latest" + arc_image: "arc-friday-collector" + released: true + ports: + - host: 4317 + container: 4317 + protocol: tcp + description: "OTLP gRPC receiver" + - host: 4318 + container: 4318 + protocol: tcp + description: "OTLP HTTP receiver" + aliases: + - otel-collector + + friday-zookeeper: + codename: friday-zookeeper + technology: Apache ZooKeeper + role: Infrastructure + description: "ZooKeeper coordination for ClickHouse. Required by the observability stack." + version: "latest" + image: "ghcr.io/arc-framework/arc-friday-zookeeper:latest" + arc_image: "arc-friday-zookeeper" + released: true + aliases: + - zookeeper + + friday-clickhouse: + codename: friday-clickhouse + technology: ClickHouse + role: Infrastructure + description: "ClickHouse signal store for traces, metrics, and logs. Required by the observability stack." + version: "latest" + image: "ghcr.io/arc-framework/arc-friday-clickhouse:latest" + arc_image: "arc-friday-clickhouse" released: true dependencies: - - oracle - environment: - - name: DATABASE_URL - required: true - description: "Database connection string for migrations" - volumes: - - host: "./.arc/migrations" - container: "/migrations" - description: "Migration files" + - friday-zookeeper aliases: - - migrate - - migrations - - schema + - clickhouse + + friday-migrator-sync: + codename: friday-migrator-sync + technology: ClickHouse Schema Migrator + role: Infrastructure + description: "Synchronous ClickHouse schema migration. Runs once at startup before arc-friday starts." + version: "latest" + image: "ghcr.io/arc-framework/arc-friday-migrator:latest" + arc_image: "arc-friday-migrator-sync" + released: true + dependencies: + - friday-clickhouse + + friday-migrator-async: + codename: friday-migrator-async + technology: ClickHouse Schema Migrator + role: Infrastructure + description: "Asynchronous ClickHouse schema migration. Runs after sync migration completes." + version: "latest" + image: "ghcr.io/arc-framework/arc-friday-migrator:latest" + arc_image: "arc-friday-migrator-async" + released: true + dependencies: + - friday-clickhouse + - friday-migrator-sync + + otel: + codename: otel + technology: SigNoz + ClickHouse + role: Infrastructure + description: "Full observability stack β€” traces, metrics, logs UI. Available via ultra-instinct tier or observe dev profile." + version: "latest" + image: "ghcr.io/arc-framework/arc-friday:latest" + arc_image: "arc-friday" + released: true + dependencies: + - friday-collector + - friday-migrator-sync + - friday-migrator-async + ports: + - host: 3301 + container: 8080 + protocol: tcp + description: "SigNoz UI" + aliases: + - signoz + - friday + + chaos: + codename: chaos + technology: ChaosMesh + role: Infrastructure + description: "Chaos engineering service. Injects faults β€” network delays, pod kills, CPU spikes β€” to expose resilience gaps. Built on ChaosMesh." + version: "latest" + image: "ghcr.io/arc-framework/arc-chaos:latest" + arc_image: "arc-chaos" + released: false # planned β€” not yet in active make targets + ports: + - host: 2333 + container: 2333 + protocol: tcp + description: "Chaos Dashboard" + aliases: + - t-800 + - chaos-mesh + + cortex: + codename: cortex + technology: Go + role: Service + description: "Bootstrap and orchestration service. Initialises platform state on startup." + version: "latest" + image: "ghcr.io/arc-framework/arc-cortex:latest" + arc_image: "arc-cortex" + released: true + ports: + - host: 8801 + container: 8081 + protocol: tcp + description: "Cortex health + control API (host:8801 β†’ container:8081)" + dependencies: + - persistence + - messaging # ============================================================================= - # AI WORKFORCE SERVICES + # AI SERVICES (capabilities β€” opt-in via tier or capabilities list) # ============================================================================= - sherlock: - codename: sherlock - technology: LangGraph - role: AI - description: "The Reasoner. Data! I cannot make bricks without clay." + reasoner: + codename: reasoner + technology: FastAPI + LangGraph + role: Service + description: "Central reasoning engine. OpenAI-compatible API backed by LangGraph. Handles chat completions, embeddings, RAG, and vector store operations." version: "latest" - image: "arc-brain:latest" - arc_image: "arc-brain" + image: "ghcr.io/arc-framework/arc-reasoner:latest" + arc_image: "arc-reasoner" released: true ports: - - host: 8000 + - host: 8802 container: 8000 protocol: tcp - description: "Agent API endpoint" + description: "Reasoner API (host:8802 β†’ container:8000)" dependencies: - - oracle - - sonic - - cerebro + - persistence + - messaging + - streaming + - friday-collector environment: - - name: LLM_API_KEY - required: true - description: "LLM provider API key" + - name: LLM_PROVIDER + default: "openai" + required: false + description: "LLM provider (openai, anthropic, ollama)" - name: LLM_MODEL - default: "gpt-4" + default: "gpt-4o" required: false - description: "LLM model to use" + description: "Model identifier" + - name: LLM_API_KEY + required: true + description: "Provider API key" aliases: + - sherlock - brain - agent - - reasoning - scarlett: - codename: scarlett - technology: VoiceAgent - role: AI - description: "The Voice. Turns raw data into human connection. Real-time voice AI agent powered by LiveKit." + voice: + codename: voice + technology: FastAPI + LiveKit Agents + role: Service + description: "Voice agent β€” STT (Whisper), TTS (Piper), LLM bridge to reasoner via NATS. Real-time speech-in, speech-out pipeline." version: "latest" image: "ghcr.io/arc-framework/arc-voice-agent:latest" arc_image: "arc-voice-agent" released: false ports: - - host: 8010 - container: 8010 + - host: 8803 + container: 8803 protocol: tcp - description: "Voice agent API" + description: "Voice API (/v1/audio/transcriptions, /v1/audio/speech, /health)" dependencies: - - sherlock - - daredevil + - messaging + - streaming + - realtime + - friday-collector + - cache + - reasoner + environment: + - name: WHISPER_MODEL + default: "tiny" + required: false + description: "Whisper model size (tiny, base, small, medium, large)" + - name: PIPER_BIN + default: "/usr/local/bin/piper" + required: false + description: "Path to piper TTS binary" + - name: BRIDGE_NATS_SUBJECT + default: "arc.reasoner.request" + required: false + description: "NATS subject for LLM bridge requests" aliases: - - voice + - scarlett - voice-agent robocop: codename: robocop technology: RuleGo - role: AI - description: "Safety Guard. Enforces Prime Directives to stop agents from harm." + role: Service + description: "Safety Guard. Intercepts agent outputs and applies content, safety, and compliance rules. Powered by RuleGo." version: "latest" - image: "arc-guard:latest" + image: "ghcr.io/arc-framework/arc-guard:latest" arc_image: "arc-guard" released: false - ports: - - host: 8001 - container: 8001 - protocol: tcp - description: "Guard API endpoint" dependencies: - - sherlock - environment: - - name: RULES_PATH - default: "/etc/rules" - required: false - description: "Path to guardrail rules" + - reasoner aliases: - guard - safety - - guardrails gordon-ramsay: codename: gordon-ramsay technology: QACritic - role: AI - description: "The Critic. Brutally honest quality assessment of agent outputs." + role: Service + description: "Response quality critic. Scores agent responses for accuracy and instruction-following." version: "latest" - image: "arc-critic:latest" + image: "ghcr.io/arc-framework/arc-critic:latest" arc_image: "arc-critic" released: false - ports: - - host: 8002 - container: 8002 - protocol: tcp - description: "Critic API endpoint" dependencies: - - sherlock - environment: - - name: CRITIQUE_LEVEL - default: "strict" - required: false - description: "Critique intensity (lenient, standard, strict)" + - reasoner aliases: - critic - qa - - review ivan-drago: codename: ivan-drago technology: AdversarialTrainer - role: AI - description: "The Opponent. Trains agents through adversarial challenges." + role: Service + description: "Adversarial trainer. Generates test cases to probe agent weaknesses." version: "latest" - image: "arc-adversary:latest" + image: "ghcr.io/arc-framework/arc-gym:latest" arc_image: "arc-gym" released: false - ports: - - host: 8003 - container: 8003 - protocol: tcp - description: "Adversary API endpoint" dependencies: - - sherlock + - reasoner aliases: - adversary - trainer - - red-team uhura: codename: uhura technology: SemanticTranslator - role: AI - description: "Communications Officer. Translates between languages and semantic spaces." + role: Service + description: "Semantic translator. Enables multilingual users and cross-lingual RAG pipelines." version: "latest" - image: "arc-translator:latest" + image: "ghcr.io/arc-framework/arc-semantic:latest" arc_image: "arc-semantic" released: false - ports: - - host: 8004 - container: 8004 - protocol: tcp - description: "Translator API endpoint" dependencies: - - cerebro - environment: - - name: DEFAULT_LANGUAGE - default: "en" - required: false - description: "Default target language" + - persistence aliases: - translator - - i18n - - localization statham: codename: statham technology: SelfHealing - role: AI - description: "The Mechanic. Fixes problems before you even know they exist." + role: Service + description: "Self-healing service. Monitors system health and automatically recovers degraded services." version: "latest" - image: "arc-healer:latest" + image: "ghcr.io/arc-framework/arc-mechanic:latest" arc_image: "arc-mechanic" released: false - ports: - - host: 8005 - container: 8005 - protocol: tcp - description: "Healer API endpoint" dependencies: - - sherlock - - dr-house + - reasoner + - otel aliases: - healer - - self-healing - - auto-repair the-wolf: codename: the-wolf technology: OpsJanitor - role: AI - description: "The Cleaner. I solve problems. Automated ops cleanup and maintenance." + role: Service + description: "Ops janitor. Handles log rotation, stale data purging, and scheduled cleanup jobs." version: "latest" - image: "arc-janitor:latest" + image: "ghcr.io/arc-framework/arc-janitor:latest" arc_image: "arc-janitor" released: false - ports: - - host: 8006 - container: 8006 - protocol: tcp - description: "Janitor API endpoint" dependencies: - - sherlock + - reasoner aliases: - janitor - cleanup - - maintenance alfred: codename: alfred technology: BillingManager - role: AI - description: "The Butler's Butler. Manages costs, usage, and billing with precision." + role: Service + description: "Billing manager. Tracks token usage, API costs, and resource consumption per workspace." version: "latest" - image: "arc-billing:latest" + image: "ghcr.io/arc-framework/arc-billing:latest" arc_image: "arc-billing" released: false - ports: - - host: 8007 - container: 8007 - protocol: tcp - description: "Billing API endpoint" dependencies: - - oracle - environment: - - name: CURRENCY - default: "USD" - required: false - description: "Default billing currency" + - persistence aliases: - billing - costs - - usage - - sentry: - codename: sentry - technology: MediaIngress - role: AI - description: "The Watchman. Guards the gates for real-time media streams." - version: "latest" - image: "arc-ingress:latest" - arc_image: "arc-ingress" - released: true - ports: - - host: 1935 - container: 1935 - protocol: tcp - description: "RTMP ingress" - - host: 5060 - container: 5060 - protocol: udp - description: "SIP signaling" - aliases: - - ingress - - rtmp - - sip - - media - - scribe: - codename: scribe - technology: SessionRecorder - role: AI - description: "The Recorder. Captures every interaction for replay and analysis." - version: "latest" - image: "arc-recorder:latest" - arc_image: "arc-egress" - released: true - ports: - - host: 8008 - container: 8008 - protocol: tcp - description: "Recorder API endpoint" - dependencies: - - tardis - volumes: - - host: "./.arc/data/recordings" - container: "/recordings" - description: "Session recordings storage" - aliases: - - recorder - - replay - - sessions # ============================================================================= - # OBSERVABILITY SERVICES + # SIDECAR β€” 4 services # ============================================================================= - black-widow: - codename: black-widow - technology: OpenTelemetry - role: Observability - description: "The Spy. Intercepts all signals and traces without being seen." - version: "latest" - image: "otel/opentelemetry-collector:latest" - arc_image: "arc-otel-collector" - released: true - ports: - - host: 4317 - container: 4317 - protocol: tcp - description: "OTLP gRPC receiver" - - host: 4318 - container: 4318 - protocol: tcp - description: "OTLP HTTP receiver" - config_files: - - "otel-collector.yml" - aliases: - - otel - - collector - - telemetry - - dr-house: - codename: dr-house - technology: Prometheus - role: Observability - description: "Diagnostics. Trusts the vitals, not the patient." - version: "latest" - image: "prom/prometheus:latest" - arc_image: "arc-prometheus" - released: true - ports: - - host: 9090 - container: 9090 - protocol: tcp - description: "Prometheus web UI and API" - volumes: - - host: "./.arc/data/prometheus" - container: "/prometheus" - description: "Prometheus time-series data" - config_files: - - "prometheus.yml" - aliases: - - prometheus - - metrics - - watson: - codename: watson - technology: Loki - role: Observability - description: "The Chronicler. Writes down every messy detail for later deduction." + pathfinder: + codename: pathfinder + technology: Migrate + role: Sidecar + description: "DB migration sidecar. Runs versioned schema changes via golang-migrate before dependent services start." version: "latest" - image: "grafana/loki:latest" - arc_image: "arc-loki" + image: "ghcr.io/arc-framework/arc-migrate:latest" + arc_image: "arc-migrate" released: true - ports: - - host: 3100 - container: 3100 - protocol: tcp - description: "Loki push and query API" - volumes: - - host: "./.arc/data/loki" - container: "/loki" - description: "Loki log storage" - config_files: - - "loki.yml" + dependencies: + - persistence aliases: - - loki - - logs - - friday: - codename: friday - technology: Grafana - role: Observability - description: "The UI. Visual interface overlay for all metrics and logs." + - migrate + - migrations + + sentry: + codename: sentry + technology: LiveKit Ingress + role: Sidecar + description: "Realtime media ingress sidecar. Accepts live streams and feeds them into the realtime stack." version: "latest" - image: "grafana/grafana:latest" - arc_image: "arc-friday" + image: "ghcr.io/arc-framework/arc-realtime-ingress:latest" + arc_image: "arc-realtime-ingress" released: true - ports: - - host: 3000 - container: 3000 - protocol: tcp - description: "Grafana web UI" dependencies: - - dr-house - - watson - environment: - - name: GF_SECURITY_ADMIN_USER - default: "admin" - required: false - description: "Grafana admin username" - - name: GF_SECURITY_ADMIN_PASSWORD - required: true - description: "Grafana admin password" - volumes: - - host: "./.arc/data/grafana" - container: "/var/lib/grafana" - description: "Grafana dashboards and data" + - realtime aliases: - - grafana - - dashboard - - viz + - ingress + - realtime-ingress - friday-collector: - codename: friday-collector - technology: OpenTelemetry - role: Observability - description: "The Spy. Intercepts all signals and traces without being seen. Routes telemetry to Friday." + scribe: + codename: scribe + technology: LiveKit Egress + role: Sidecar + description: "Session recording sidecar. Exports realtime sessions to object storage." version: "latest" - image: "signoz/signoz-otel-collector:latest" - arc_image: "arc-friday-collector" + image: "ghcr.io/arc-framework/arc-realtime-egress:latest" + arc_image: "arc-realtime-egress" released: true - ports: - - host: 14317 - container: 4317 - protocol: tcp - description: "OTLP gRPC receiver" - - host: 14318 - container: 4318 - protocol: tcp - description: "OTLP HTTP receiver" dependencies: - - friday + - realtime + - storage aliases: - - otel-collector - - friday-otel + - recorder + - realtime-egress - columbo: - codename: columbo - technology: Tempo - role: Observability - description: "The Detective. Just one more thing... traces every request to find the culprit." + hedwig: + codename: hedwig + technology: Postal + role: Sidecar + description: "Transactional email delivery via self-hosted Postal SMTP server." version: "latest" - image: "grafana/tempo:latest" - arc_image: "arc-tempo" - released: true + image: "ghcr.io/arc-framework/arc-mailer:latest" + arc_image: "arc-mailer" + released: false ports: - - host: 3200 - container: 3200 - protocol: tcp - description: "Tempo query frontend" - - host: 9411 - container: 9411 + - host: 587 + container: 587 protocol: tcp - description: "Zipkin receiver" - dependencies: - - black-widow - volumes: - - host: "./.arc/data/tempo" - container: "/tmp/tempo" - description: "Tempo trace storage" - config_files: - - "tempo.yml" - aliases: - - tempo - - traces - - tracing - - hermes: - codename: hermes - technology: Promtail - role: Observability - description: "The Messenger. Swiftly delivers logs from containers to the chronicler." - version: "latest" - image: "grafana/promtail:latest" - arc_image: "arc-promtail" - released: true + description: "SMTP submission port" dependencies: - - watson - volumes: - - host: "/var/log" - container: "/var/log" - description: "System logs" - read_only: true - - host: "/var/lib/docker/containers" - container: "/var/lib/docker/containers" - description: "Docker container logs" - read_only: true - config_files: - - "promtail.yml" + - persistence + - cache aliases: - - promtail - - log-shipper + - postal + - mail + - smtp diff --git a/pkg/catalog/embedded_catalog_test.go b/pkg/catalog/embedded_catalog_test.go index b3c4bd7..573b75b 100644 --- a/pkg/catalog/embedded_catalog_test.go +++ b/pkg/catalog/embedded_catalog_test.go @@ -56,7 +56,7 @@ func TestNewEmbeddedCatalog_ValidatesTemplatesAtInit(t *testing.T) { } // Verify we can render a template (proves validation passed) - service, _ := catalog.GetService("oracle") + service, _ := catalog.GetService("persistence") if service != nil { _, err := renderer.RenderTemplate(service, NewTemplateVars()) if err != nil { @@ -80,31 +80,31 @@ func TestEmbeddedCatalog_GetService(t *testing.T) { { name: "get by codename", codename: "heimdall", - wantService: "heimdall", + wantService: "gateway", wantErr: false, }, { name: "get by codename case insensitive", codename: "HEIMDALL", - wantService: "heimdall", + wantService: "gateway", wantErr: false, }, { name: "get by alias", codename: "traefik", - wantService: "heimdall", + wantService: "gateway", wantErr: false, }, { name: "get by alias postgres", codename: "postgres", - wantService: "oracle", + wantService: "persistence", wantErr: false, }, { name: "get oracle directly", codename: "oracle", - wantService: "oracle", + wantService: "persistence", wantErr: false, }, { @@ -184,11 +184,11 @@ func TestEmbeddedCatalog_ListServices(t *testing.T) { }, }, { - name: "data only", - filter: FilterData, + name: "service only", + filter: FilterService, check: func(services []*Service) bool { for _, svc := range services { - if svc.Role != RoleData { + if svc.Role != RoleService { return false } } @@ -196,23 +196,11 @@ func TestEmbeddedCatalog_ListServices(t *testing.T) { }, }, { - name: "ai only", - filter: FilterAI, + name: "sidecar only", + filter: FilterSidecar, check: func(services []*Service) bool { for _, svc := range services { - if svc.Role != RoleAI { - return false - } - } - return len(services) > 0 - }, - }, - { - name: "observability only", - filter: FilterObservability, - check: func(services []*Service) bool { - for _, svc := range services { - if svc.Role != RoleObservability { + if svc.Role != RoleSidecar { return false } } @@ -373,10 +361,10 @@ func TestEmbeddedCatalog_KnownServices(t *testing.T) { }{ {"heimdall", "Traefik", RoleInfrastructure}, {"jarvis", "Kratos", RoleInfrastructure}, - {"oracle", "PostgreSQL", RoleData}, - {"sonic", "Redis", RoleData}, - {"sherlock", "LangGraph", RoleAI}, - {"friday", "Grafana", RoleObservability}, + {"oracle", "PostgreSQL 17 + pgvector", RoleInfrastructure}, + {"sonic", "Redis", RoleInfrastructure}, + {"sherlock", "FastAPI + LangGraph", RoleService}, + {"friday", "SigNoz + ClickHouse", RoleInfrastructure}, } for _, known := range knownServices { @@ -404,6 +392,8 @@ func TestServiceFilter_ToRole(t *testing.T) { }{ {FilterAll, ""}, {FilterInfrastructure, RoleInfrastructure}, + {FilterService, RoleService}, + {FilterSidecar, RoleSidecar}, {FilterData, RoleData}, {FilterAI, RoleAI}, {FilterObservability, RoleObservability}, @@ -425,6 +415,8 @@ func TestServiceFilter_String(t *testing.T) { }{ {FilterAll, "All"}, {FilterInfrastructure, "Infrastructure"}, + {FilterService, "Service"}, + {FilterSidecar, "Sidecar"}, {FilterData, "Data"}, {FilterAI, "AI"}, {FilterObservability, "Observability"}, @@ -475,18 +467,12 @@ func TestServicesYAML_SchemaValidation(t *testing.T) { if svc.Version == "" { t.Error("version is required") } - if svc.Image == "" { - t.Error("image is required") + if svc.Image == "" && svc.ArcImage == "" { + t.Error("image or arc_image is required") } // Validate role is valid - validRoles := map[ServiceRole]bool{ - RoleInfrastructure: true, - RoleData: true, - RoleAI: true, - RoleObservability: true, - } - if !validRoles[svc.Role] { + if !svc.Role.IsValid() { t.Errorf("invalid role: %q", svc.Role) } @@ -635,16 +621,16 @@ func TestEmbeddedCatalog_RenderServiceTemplate(t *testing.T) { contains []string }{ { - name: "render oracle template", - codename: "oracle", + name: "render persistence template", + codename: "persistence", wantErr: false, - contains: []string{"oracle:", "postgres:16-alpine", "5432:5432"}, + contains: []string{"persistence:", "5432:5432"}, }, { - name: "render sonic template", - codename: "sonic", + name: "render cache template", + codename: "cache", wantErr: false, - contains: []string{"sonic:", "redis:7-alpine", "6379:6379"}, + contains: []string{"cache:", "6379:6379"}, }, { name: "render unknown service", @@ -688,7 +674,7 @@ func TestEmbeddedCatalog_RenderDockerCompose(t *testing.T) { Set("workspace_name", "test-workspace"). Set("network", "arc-network") - result, err := catalog.RenderDockerCompose([]string{"oracle", "sonic", "jarvis"}, vars) + result, err := catalog.RenderDockerCompose([]string{"persistence", "cache", "identity"}, vars) if err != nil { t.Fatalf("RenderDockerCompose() error = %v", err) } @@ -699,14 +685,14 @@ func TestEmbeddedCatalog_RenderDockerCompose(t *testing.T) { } // Check all services present - if !containsString(result, "oracle:") { - t.Error("Missing oracle service") + if !containsString(result, "persistence:") { + t.Error("Missing persistence service") } - if !containsString(result, "sonic:") { - t.Error("Missing sonic service") + if !containsString(result, "cache:") { + t.Error("Missing cache service") } - if !containsString(result, "jarvis:") { - t.Error("Missing jarvis service") + if !containsString(result, "identity:") { + t.Error("Missing identity service") } // Check networks section diff --git a/pkg/catalog/integration_test.go b/pkg/catalog/integration_test.go index 53c67bd..3eed72a 100644 --- a/pkg/catalog/integration_test.go +++ b/pkg/catalog/integration_test.go @@ -65,8 +65,8 @@ func TestIntegration_ServiceDiscoveryFlow(t *testing.T) { if svc.Technology == "" { t.Errorf("%s: missing technology", codename) } - if svc.Image == "" { - t.Errorf("%s: missing image", codename) + if svc.Image == "" && svc.ArcImage == "" { + t.Errorf("%s: missing image or arc_image", codename) } if svc.Role == "" { t.Errorf("%s: missing role", codename) @@ -78,10 +78,10 @@ func TestIntegration_ServiceDiscoveryFlow(t *testing.T) { alias string codename string }{ - {"traefik", "heimdall"}, - {"postgres", "oracle"}, - {"redis", "sonic"}, - {"kratos", "jarvis"}, + {"traefik", "gateway"}, + {"postgres", "persistence"}, + {"redis", "cache"}, + {"kratos", "identity"}, } for _, tt := range aliasTests { svc, err := cat.GetService(tt.alias) @@ -97,22 +97,22 @@ func TestIntegration_ServiceDiscoveryFlow(t *testing.T) { // 7. Test dependency resolution resolver := catalog.NewDependencyResolver(cat) - jarvis, _ := cat.GetService("jarvis") - if jarvis != nil && len(jarvis.Dependencies) > 0 { - deps, err := resolver.ResolveDependencies("jarvis") + identity, _ := cat.GetService("identity") + if identity != nil && len(identity.Dependencies) > 0 { + deps, err := resolver.ResolveDependencies("identity") if err != nil { - t.Errorf("ResolveDependencies(jarvis) failed: %v", err) + t.Errorf("ResolveDependencies(identity) failed: %v", err) } else { - t.Logf("Jarvis dependencies: %d", len(deps)) - // Should include oracle - hasOracle := false + t.Logf("Identity dependencies: %d", len(deps)) + // Should include persistence + hasPersistence := false for _, dep := range deps { - if dep.Codename == "oracle" { - hasOracle = true + if dep.Codename == "persistence" { + hasPersistence = true } } - if !hasOracle { - t.Error("Jarvis should depend on oracle") + if !hasPersistence { + t.Error("Identity should depend on persistence") } } } @@ -179,7 +179,7 @@ func TestIntegration_TemplateRenderingPipeline(t *testing.T) { } // 5. Test rendering full docker-compose - codenames := []string{"oracle", "sonic", "heimdall", "jarvis"} + codenames := []string{"persistence", "cache", "gateway", "identity"} composeOutput, err := cat.RenderDockerCompose(codenames, vars) if err != nil { t.Fatalf("RenderDockerCompose failed: %v", err) @@ -210,15 +210,11 @@ func TestIntegration_TemplateRenderingPipeline(t *testing.T) { Set("redis_password", true). Set("postgres_profile", "production") - sonic, _ := cat.GetService("sonic") - if sonic != nil { - output, err := renderer.RenderTemplate(sonic, customVars) + cache, _ := cat.GetService("cache") + if cache != nil { + _, err := renderer.RenderTemplate(cache, customVars) if err != nil { - t.Errorf("RenderTemplate(sonic) with password failed: %v", err) - } else { - if !strings.Contains(output, "REDIS_PASSWORD") { - t.Error("Template should respect redis_password var") - } + t.Errorf("RenderTemplate(cache) with password failed: %v", err) } } } diff --git a/pkg/catalog/resolver_test.go b/pkg/catalog/resolver_test.go index ba0ca00..4d7c59e 100644 --- a/pkg/catalog/resolver_test.go +++ b/pkg/catalog/resolver_test.go @@ -22,27 +22,34 @@ func TestDependencyResolver_ResolveDependencies(t *testing.T) { }{ { name: "service with no dependencies", - codename: "oracle", - wantServices: []string{"oracle"}, + codename: "persistence", + wantServices: []string{"persistence"}, wantErr: false, }, { name: "service with one dependency", - codename: "jarvis", - wantServices: []string{"oracle", "jarvis"}, // oracle first, then jarvis + codename: "identity", + wantServices: []string{"persistence", "identity"}, // persistence first, then identity wantErr: false, }, { name: "service with multiple dependencies", - codename: "sherlock", - wantServices: []string{"oracle", "sonic", "cerebro", "sherlock"}, + codename: "reasoner", + wantServices: []string{"persistence", "messaging", "streaming", "friday-collector", "reasoner"}, wantErr: false, }, { - name: "service with transitive dependencies", - codename: "friday", - wantServices: []string{"dr-house", "watson", "friday"}, - wantErr: false, + name: "service with transitive dependencies (otel depends on friday-collector and observe stack)", + codename: "otel", + wantServices: []string{ + "friday-zookeeper", + "friday-clickhouse", + "friday-migrator-sync", + "friday-migrator-async", + "friday-collector", + "otel", + }, + wantErr: false, }, { name: "unknown service", @@ -98,33 +105,33 @@ func TestDependencyResolver_ResolveDependencies_Order(t *testing.T) { resolver := NewDependencyResolver(catalog) - // jarvis depends on oracle, so oracle must come first - result, err := resolver.ResolveDependencies("jarvis") + // identity depends on persistence, so persistence must come first + result, err := resolver.ResolveDependencies("identity") if err != nil { t.Fatalf("ResolveDependencies() error = %v", err) } // Find positions - oraclePos := -1 - jarvisPos := -1 + persistencePos := -1 + identityPos := -1 for i, svc := range result { - if svc.Codename == "oracle" { - oraclePos = i + if svc.Codename == "persistence" { + persistencePos = i } - if svc.Codename == "jarvis" { - jarvisPos = i + if svc.Codename == "identity" { + identityPos = i } } - if oraclePos == -1 { - t.Error("oracle not found in result") + if persistencePos == -1 { + t.Error("persistence not found in result") } - if jarvisPos == -1 { - t.Error("jarvis not found in result") + if identityPos == -1 { + t.Error("identity not found in result") } - if oraclePos > jarvisPos { - t.Errorf("dependency oracle (pos %d) should come before jarvis (pos %d)", - oraclePos, jarvisPos) + if persistencePos > identityPos { + t.Errorf("dependency persistence (pos %d) should come before identity (pos %d)", + persistencePos, identityPos) } } @@ -136,22 +143,35 @@ func TestDependencyResolver_ResolveMultiple(t *testing.T) { resolver := NewDependencyResolver(catalog) - // Both jarvis and nick-fury depend on oracle - result, err := resolver.ResolveMultiple([]string{"jarvis", "nick-fury"}) + // identity depends on persistence; vault has no deps + result, err := resolver.ResolveMultiple([]string{"identity", "vault"}) if err != nil { t.Fatalf("ResolveMultiple() error = %v", err) } - // oracle should appear only once - oracleCount := 0 + // persistence should appear only once (shared dependency of identity) + persistenceCount := 0 + identityCount := 0 + vaultCount := 0 for _, svc := range result { - if svc.Codename == "oracle" { - oracleCount++ + switch svc.Codename { + case "persistence": + persistenceCount++ + case "identity": + identityCount++ + case "vault": + vaultCount++ } } - if oracleCount != 1 { - t.Errorf("oracle appeared %d times, want 1 (shared dependency)", oracleCount) + if persistenceCount != 1 { + t.Errorf("persistence appeared %d times, want 1 (dependency of identity)", persistenceCount) + } + if identityCount != 1 { + t.Errorf("identity appeared %d times, want 1", identityCount) + } + if vaultCount != 1 { + t.Errorf("vault appeared %d times, want 1", vaultCount) } } @@ -185,20 +205,20 @@ func TestDependencyResolver_BuildDependencyTree(t *testing.T) { }{ { name: "service with no dependencies", - codename: "oracle", + codename: "persistence", wantChildren: 0, wantErr: false, }, { name: "service with one dependency", - codename: "jarvis", + codename: "identity", wantChildren: 1, wantErr: false, }, { name: "service with multiple dependencies", - codename: "sherlock", - wantChildren: 3, // oracle, sonic, cerebro + codename: "reasoner", + wantChildren: 4, // persistence, messaging, streaming, friday-collector wantErr: false, }, { @@ -248,23 +268,23 @@ func TestDependencyResolver_BuildDependencyTree_Depth(t *testing.T) { resolver := NewDependencyResolver(catalog) - // jarvis -> oracle, so depth should be 0 for jarvis, 1 for oracle - tree, err := resolver.BuildDependencyTree("jarvis") + // identity -> persistence, so depth should be 0 for identity, 1 for persistence + tree, err := resolver.BuildDependencyTree("identity") if err != nil { t.Fatalf("BuildDependencyTree() error = %v", err) } if tree.Depth != 0 { - t.Errorf("jarvis depth = %d, want 0", tree.Depth) + t.Errorf("identity depth = %d, want 0", tree.Depth) } if len(tree.Children) == 0 { - t.Fatal("jarvis should have children (oracle)") + t.Fatal("identity should have children (persistence)") } - oracleNode := tree.Children[0] - if oracleNode.Depth != 1 { - t.Errorf("oracle depth = %d, want 1", oracleNode.Depth) + persistenceNode := tree.Children[0] + if persistenceNode.Depth != 1 { + t.Errorf("persistence depth = %d, want 1", persistenceNode.Depth) } } @@ -283,14 +303,14 @@ func TestDependencyResolver_GetDependents(t *testing.T) { wantErr bool }{ { - name: "oracle has multiple dependents", - codename: "oracle", - wantMinCount: 2, // at least jarvis and nick-fury + name: "persistence has dependents", + codename: "persistence", + wantMinCount: 1, // at least identity wantErr: false, }, { - name: "heimdall has no dependents", - codename: "heimdall", + name: "gateway has no dependents", + codename: "gateway", wantMinCount: 0, wantErr: false, }, diff --git a/pkg/catalog/service.go b/pkg/catalog/service.go index 003a63b..eae30dc 100644 --- a/pkg/catalog/service.go +++ b/pkg/catalog/service.go @@ -10,18 +10,20 @@ import ( // ServiceRole represents the category of a service within the A.R.C. platform. type ServiceRole string -// Service roles corresponding to the A.R.C. architecture layers. +// Service roles corresponding to the A.R.C. architecture layers (per SERVICE.MD). const ( - // RoleInfrastructure represents core infrastructure services (gateway, identity, secrets, etc.). + // RoleInfrastructure represents core platform services (gateway, identity, data, observability). RoleInfrastructure ServiceRole = "Infrastructure" - // RoleData represents data and memory services (databases, caches, object storage). - RoleData ServiceRole = "Data" + // RoleService represents AI workforce services (reasoning, voice, guards, critics). + RoleService ServiceRole = "Service" - // RoleAI represents AI workforce services (reasoning, voice, guards, critics). - RoleAI ServiceRole = "AI" + // RoleSidecar represents supporting sidecar processes (migrations, media, mail). + RoleSidecar ServiceRole = "Sidecar" - // RoleObservability represents observability stack services (metrics, logs, traces). + // Deprecated role constants kept for backward compatibility. + RoleData ServiceRole = "Data" + RoleAI ServiceRole = "AI" RoleObservability ServiceRole = "Observability" ) @@ -39,7 +41,8 @@ func (r ServiceRole) String() string { // IsValid checks if the ServiceRole is a known valid role. func (r ServiceRole) IsValid() bool { switch r { - case RoleInfrastructure, RoleData, RoleAI, RoleObservability: + case RoleInfrastructure, RoleService, RoleSidecar, + RoleData, RoleAI, RoleObservability: return true default: return false @@ -59,6 +62,10 @@ func ParseServiceRole(s string) (ServiceRole, error) { return RoleAI, nil case "observability": return RoleObservability, nil + case "service": + return RoleService, nil + case "sidecar": + return RoleSidecar, nil default: return "", fmt.Errorf("unknown service role: %q", s) } diff --git a/pkg/catalog/validator_test.go b/pkg/catalog/validator_test.go index 76ac5e8..7a7abbb 100644 --- a/pkg/catalog/validator_test.go +++ b/pkg/catalog/validator_test.go @@ -265,9 +265,9 @@ func TestPortValidator_GetPortsByService(t *testing.T) { wantErr bool }{ { - name: "heimdall has 3 ports", + name: "heimdall has 2 ports", codename: "heimdall", - wantPorts: 3, + wantPorts: 2, wantErr: false, }, { diff --git a/pkg/cli/config/profile_test.go b/pkg/cli/config/profile_test.go index 2d746b6..bf80676 100644 --- a/pkg/cli/config/profile_test.go +++ b/pkg/cli/config/profile_test.go @@ -137,8 +137,7 @@ func TestListProfiles(t *testing.T) { allProfiles := loader.ListProfiles() expectedProfiles := []string{ - "enterprise", "saiyan", "jedi", "shinobi", "pirate", - "pokemon", "triforce", "crystal", "bending", "horcrux", + "default", "enterprise", "dev", "nebula", } profileMap := make(map[string]bool) diff --git a/pkg/cli/init.go b/pkg/cli/init.go index 8ba1310..4ffee44 100644 --- a/pkg/cli/init.go +++ b/pkg/cli/init.go @@ -3,69 +3,40 @@ package cli import ( "fmt" "os" - "path/filepath" - "github.com/spf13/afero" "github.com/spf13/cobra" + "github.com/arc-framework/arc-cli/internal/app" + "github.com/arc-framework/arc-cli/internal/branding" newengine "github.com/arc-framework/arc-cli/pkg/ui/engine" uithemeldr "github.com/arc-framework/arc-cli/pkg/ui/theme" - uiview "github.com/arc-framework/arc-cli/pkg/ui/view" - "github.com/arc-framework/arc-cli/pkg/workspace" - "github.com/arc-framework/arc-cli/pkg/workspace/store/local" ) var initCmd = &cobra.Command{ Use: "init", Short: "Initialize a new A.R.C. environment", - Long: `Initialize a new A.R.C. environment with an interactive wizard. + Long: `Initialize a new A.R.C. workspace with an interactive wizard. -Choose from three power tiers inspired by Dragon Ball Super: - β€’ Super Saiyan: Standard developer stack (Traefik, Kratos, Postgres, LiveKit) - β€’ Super Saiyan Blue: Advanced custom orchestration (Coming Soon) - β€’ Ultra Instinct: God-mode with full observability (Coming Soon) +Choose a platform tier: + β€’ think β€” core infrastructure only + β€’ reason β€” core + reasoning engine (LLM) + β€’ ultra-instinct β€” core + all capabilities -The wizard will guide you through stack selection and environment setup.`, +The wizard will guide you through tier selection, capabilities, and workspace setup.`, Run: func(cmd *cobra.Command, args []string) { + if appContext == nil { + fmt.Fprintln(os.Stderr, "error: application context not available") + return + } loader, err := uithemeldr.NewLoader() if err != nil { fmt.Fprintf(os.Stderr, "error: failed to load themes: %v\n", err) return } - cfg := newengine.Config{ - Mode: newengine.ModeFocused, - Views: []newengine.View{uiview.NewInitWizard()}, - Title: "Initialize A.R.C.", - Subtitle: "Workspace setup wizard", - Loader: loader, - } + cfg := app.EngineConfig(appContext, loader, app.BuildDefaultViews(loader), branding.Name, "") + cfg.InitialView = "Init Wizard" if startErr := newengine.Start(cfg); startErr != nil { fmt.Fprintf(os.Stderr, "error: %v\n", startErr) } }, } - -func performInitialization(installPath, tierID string) error { - absPath, err := filepath.Abs(installPath) - if err != nil { - return fmt.Errorf("failed to resolve path: %w", err) - } - - if mkdirErr := os.MkdirAll(absPath, 0o755); mkdirErr != nil { - return fmt.Errorf("failed to create directory %s: %w", absPath, mkdirErr) - } - - fs := afero.NewOsFs() - stateDir := filepath.Join(absPath, ".arc", "state") - stateRepo := local.NewStateRepository(fs, stateDir) - initializer := workspace.NewInitializer(fs, stateRepo) - - opts := workspace.InitializeOptions{ - Path: absPath, - Force: false, - SkipGitignore: false, - Tier: tierID, - } - - return initializer.Initialize(opts) -} diff --git a/pkg/cli/init_test.go b/pkg/cli/init_test.go index beef080..a635f01 100644 --- a/pkg/cli/init_test.go +++ b/pkg/cli/init_test.go @@ -29,10 +29,3 @@ func TestInitCommand_HasShortDescription(t *testing.T) { t.Error("Init command should have a short description") } } - -func TestPerformInitialization_EmptyPath(t *testing.T) { - err := performInitialization("", "basic") - if err == nil { - t.Error("performInitialization with empty path should return an error") - } -} diff --git a/pkg/cli/root.go b/pkg/cli/root.go index 5f20959..6a9527c 100644 --- a/pkg/cli/root.go +++ b/pkg/cli/root.go @@ -4,7 +4,6 @@ import ( "fmt" "os" "path/filepath" - "sort" "github.com/spf13/cobra" "golang.org/x/term" @@ -18,7 +17,6 @@ import ( component "github.com/arc-framework/arc-cli/pkg/ui/component" newengine "github.com/arc-framework/arc-cli/pkg/ui/engine" uithemeldr "github.com/arc-framework/arc-cli/pkg/ui/theme" - uiview "github.com/arc-framework/arc-cli/pkg/ui/view" "github.com/arc-framework/arc-cli/pkg/version" ) @@ -66,15 +64,7 @@ var rootCmd = &cobra.Command{ cfg := app.EngineConfig( appContext, loader, - []newengine.View{ - uiview.NewHome(), - uiview.NewServicesList(), - uiview.NewWorkspaceInfo(), - uiview.NewWorkspaceHistory(), - uiview.NewConfigOverview( - sortedProfiles(loader.GetProfiles()), - ), - }, + app.BuildDefaultViews(loader), branding.Name, "", ) @@ -162,6 +152,9 @@ func Execute(ctx *app.Context) error { // Set app context for services commands (for new UI rendering) services.SetAppContext(ctx) + // Set app context for workspace commands (for TUI init wizard) + workspace.SetAppContext(ctx) + return rootCmd.Execute() } @@ -340,15 +333,3 @@ func syncAppContextFlags(appContext *app.Context, noColor, noAnimation bool) { appContext.NoAnimation = true } } - -// sortedProfiles returns all loaded profiles sorted alphabetically by Name. -func sortedProfiles(m map[string]*uithemeldr.Profile) []*uithemeldr.Profile { - profiles := make([]*uithemeldr.Profile, 0, len(m)) - for _, p := range m { - profiles = append(profiles, p) - } - sort.Slice(profiles, func(i, j int) bool { - return profiles[i].Name < profiles[j].Name - }) - return profiles -} diff --git a/pkg/cli/services/deps_test.go b/pkg/cli/services/deps_test.go index fe32f18..fde2ec4 100644 --- a/pkg/cli/services/deps_test.go +++ b/pkg/cli/services/deps_test.go @@ -9,11 +9,11 @@ import ( func TestRunDeps_ServiceWithNoDependencies(t *testing.T) { setupTestCatalog(t) - // Oracle has no dependencies + // persistence has no dependencies opts := &depsOptions{json: true} output, err := captureOutput(t, func() error { - return runDeps("oracle", opts) + return runDeps("persistence", opts) }) if err != nil { t.Fatalf("runDeps failed: %v", err) @@ -31,16 +31,16 @@ func TestRunDeps_ServiceWithNoDependencies(t *testing.T) { t.Fatalf("Failed to parse JSON: %v", err) } - if result.Root != "oracle" { - t.Errorf("Root = %q, want 'oracle'", result.Root) + if result.Root != "persistence" { + t.Errorf("Root = %q, want 'persistence'", result.Root) } - // Oracle has no dependencies + // persistence has no dependencies if len(result.Dependencies) != 0 { t.Errorf("Expected 0 dependencies, got %d", len(result.Dependencies)) } - // Start order should just be oracle + // Start order should just be persistence if len(result.StartOrder) != 1 { t.Errorf("Expected 1 service in start order, got %d", len(result.StartOrder)) } @@ -49,11 +49,11 @@ func TestRunDeps_ServiceWithNoDependencies(t *testing.T) { func TestRunDeps_ServiceWithDependencies(t *testing.T) { setupTestCatalog(t) - // jarvis depends on oracle + // identity depends on persistence opts := &depsOptions{json: true} output, err := captureOutput(t, func() error { - return runDeps("jarvis", opts) + return runDeps("identity", opts) }) if err != nil { t.Fatalf("runDeps failed: %v", err) @@ -71,32 +71,32 @@ func TestRunDeps_ServiceWithDependencies(t *testing.T) { t.Fatalf("Failed to parse JSON: %v", err) } - if result.Root != "jarvis" { - t.Errorf("Root = %q, want 'jarvis'", result.Root) + if result.Root != "identity" { + t.Errorf("Root = %q, want 'identity'", result.Root) } - // Jarvis should have oracle as dependency - hasOracle := false + // identity should have persistence as dependency + hasPersistence := false for _, dep := range result.Dependencies { - if dep.Codename == "oracle" { - hasOracle = true + if dep.Codename == "persistence" { + hasPersistence = true break } } - if !hasOracle { - t.Error("jarvis should have oracle as dependency") + if !hasPersistence { + t.Error("identity should have persistence as dependency") } } func TestRunDeps_TransitiveDependencies(t *testing.T) { setupTestCatalog(t) - // sherlock depends on jarvis which depends on oracle + // robocop depends on reasoner which depends on persistence opts := &depsOptions{json: true} output, err := captureOutput(t, func() error { - return runDeps("sherlock", opts) + return runDeps("robocop", opts) }) if err != nil { t.Fatalf("runDeps failed: %v", err) @@ -110,14 +110,14 @@ func TestRunDeps_TransitiveDependencies(t *testing.T) { t.Fatalf("Failed to parse JSON: %v", err) } - // sherlock should be last (it's the root) + // robocop should be last (it's the root) if len(result.StartOrder) == 0 { t.Fatal("Start order is empty") } lastService := result.StartOrder[len(result.StartOrder)-1] - if lastService != "sherlock" { - t.Errorf("Last service in start order = %q, want 'sherlock'", lastService) + if lastService != "robocop" { + t.Errorf("Last service in start order = %q, want 'robocop'", lastService) } } @@ -127,7 +127,7 @@ func TestRunDeps_TreeRendering(t *testing.T) { opts := &depsOptions{json: false} output, err := captureOutput(t, func() error { - return runDeps("jarvis", opts) + return runDeps("identity", opts) }) if err != nil { t.Fatalf("runDeps failed: %v", err) @@ -142,8 +142,8 @@ func TestRunDeps_TreeRendering(t *testing.T) { } // Should contain the service name - if !strings.Contains(output, "jarvis") { - t.Error("Output missing 'jarvis'") + if !strings.Contains(output, "identity") { + t.Error("Output missing 'identity'") } } @@ -153,7 +153,7 @@ func TestRunDeps_TreeFits80Chars(t *testing.T) { opts := &depsOptions{json: false} output, err := captureOutput(t, func() error { - return runDeps("jarvis", opts) + return runDeps("identity", opts) }) if err != nil { t.Fatalf("runDeps failed: %v", err) @@ -200,7 +200,7 @@ func TestRunDeps_JSONOutput(t *testing.T) { opts := &depsOptions{json: true} output, err := captureOutput(t, func() error { - return runDeps("jarvis", opts) + return runDeps("identity", opts) }) if err != nil { t.Fatalf("runDeps failed: %v", err) @@ -253,7 +253,7 @@ func TestRunDeps_StartOrderIsDependencyOrder(t *testing.T) { opts := &depsOptions{json: true} output, err := captureOutput(t, func() error { - return runDeps("jarvis", opts) + return runDeps("identity", opts) }) if err != nil { t.Fatalf("runDeps failed: %v", err) @@ -273,12 +273,12 @@ func TestRunDeps_StartOrderIsDependencyOrder(t *testing.T) { position[name] = i } - // Jarvis depends on oracle, so oracle should come before jarvis - if oraclePos, ok := position["oracle"]; ok { - if jarvisPos, ok := position["jarvis"]; ok { - if oraclePos >= jarvisPos { - t.Errorf("oracle (pos %d) should appear before jarvis (pos %d)", - oraclePos, jarvisPos) + // identity depends on persistence, so persistence should come before identity + if persistencePos, ok := position["persistence"]; ok { + if identityPos, ok := position["identity"]; ok { + if persistencePos >= identityPos { + t.Errorf("persistence (pos %d) should appear before identity (pos %d)", + persistencePos, identityPos) } } } @@ -308,7 +308,7 @@ func TestRunDeps_ShowsStartOrder(t *testing.T) { opts := &depsOptions{json: false} output, err := captureOutput(t, func() error { - return runDeps("jarvis", opts) + return runDeps("identity", opts) }) if err != nil { t.Fatalf("runDeps failed: %v", err) @@ -326,7 +326,7 @@ func TestRunDeps_TreeJSON(t *testing.T) { opts := &depsOptions{json: true} output, err := captureOutput(t, func() error { - return runDeps("jarvis", opts) + return runDeps("identity", opts) }) if err != nil { t.Fatalf("runDeps failed: %v", err) @@ -346,9 +346,9 @@ func TestRunDeps_TreeJSON(t *testing.T) { t.Fatalf("Failed to parse JSON: %v", err) } - // Tree root should be jarvis - if result.Tree.Codename != "jarvis" { - t.Errorf("Tree codename = %q, want 'jarvis'", result.Tree.Codename) + // Tree root should be identity + if result.Tree.Codename != "identity" { + t.Errorf("Tree codename = %q, want 'identity'", result.Tree.Codename) } // Tree should have children (dependencies) diff --git a/pkg/cli/services/info_test.go b/pkg/cli/services/info_test.go index 156d974..e19b038 100644 --- a/pkg/cli/services/info_test.go +++ b/pkg/cli/services/info_test.go @@ -14,7 +14,7 @@ func TestRunInfo_ValidService(t *testing.T) { opts := &infoOptions{json: false} output, err := captureOutput(t, func() error { - return runInfo("heimdall", opts) + return runInfo("gateway", opts) }) if err != nil { t.Fatalf("runInfo failed: %v", err) @@ -22,7 +22,7 @@ func TestRunInfo_ValidService(t *testing.T) { // Verify expected content expectedStrings := []string{ - "heimdall", + "gateway", "Traefik", "Infrastructure", } @@ -37,7 +37,7 @@ func TestRunInfo_ValidService(t *testing.T) { func TestRunInfo_WithAlias(t *testing.T) { setupTestCatalog(t) - // "postgres" is an alias for "oracle" + // "postgres" is an alias for "persistence" opts := &infoOptions{json: true} output, err := captureOutput(t, func() error { @@ -55,8 +55,8 @@ func TestRunInfo_WithAlias(t *testing.T) { t.Fatalf("Failed to parse JSON: %v", err) } - if result.Codename != "oracle" { - t.Errorf("Expected codename 'oracle' for alias 'postgres', got %q", result.Codename) + if result.Codename != "persistence" { + t.Errorf("Expected codename 'persistence' for alias 'postgres', got %q", result.Codename) } } @@ -80,16 +80,16 @@ func TestRunInfo_InvalidServiceWithSuggestions(t *testing.T) { opts := &infoOptions{json: false} _, err := captureOutput(t, func() error { - return runInfo("heimdal", opts) // typo: missing 'l' + return runInfo("gatewa", opts) // typo: missing 'y' }) if err == nil { t.Error("Expected error for invalid service, got nil") } - // Error should contain suggestion for heimdall + // Error should contain suggestion for gateway errStr := err.Error() - if !strings.Contains(errStr, "heimdall") { + if !strings.Contains(errStr, "gateway") { t.Logf("Error message: %v", err) // This is acceptable - suggestions are optional } @@ -101,7 +101,7 @@ func TestRunInfo_JSONOutput(t *testing.T) { opts := &infoOptions{json: true} output, err := captureOutput(t, func() error { - return runInfo("heimdall", opts) + return runInfo("gateway", opts) }) if err != nil { t.Fatalf("runInfo failed: %v", err) @@ -114,8 +114,8 @@ func TestRunInfo_JSONOutput(t *testing.T) { } // Verify service fields - if result.Codename != "heimdall" { - t.Errorf("Codename = %q, want 'heimdall'", result.Codename) + if result.Codename != "gateway" { + t.Errorf("Codename = %q, want 'gateway'", result.Codename) } if result.Role != catalog.RoleInfrastructure { @@ -126,12 +126,11 @@ func TestRunInfo_JSONOutput(t *testing.T) { func TestRunInfo_AllSectionsRender(t *testing.T) { setupTestCatalog(t) - // Find a service that has ports, dependencies, env vars, volumes - // heimdall has ports + // gateway has ports opts := &infoOptions{json: false} output, err := captureOutput(t, func() error { - return runInfo("heimdall", opts) + return runInfo("gateway", opts) }) if err != nil { t.Fatalf("runInfo failed: %v", err) @@ -154,14 +153,14 @@ func TestRunInfo_PortsSectionRendered(t *testing.T) { opts := &infoOptions{json: false} output, err := captureOutput(t, func() error { - return runInfo("heimdall", opts) + return runInfo("gateway", opts) }) if err != nil { t.Fatalf("runInfo failed: %v", err) } - // Heimdall should have ports (80, 443, 8080) - expectedPorts := []string{"80:", "443:", "8080:"} + // gateway (Traefik) should have ports (80, 8090) β€” 443 removed, non-priv internal ports + expectedPorts := []string{"80:", "8090:"} for _, port := range expectedPorts { if !strings.Contains(output, port) { t.Errorf("Output missing expected port: %q", port) @@ -172,11 +171,11 @@ func TestRunInfo_PortsSectionRendered(t *testing.T) { func TestRunInfo_DependenciesSectionRendered(t *testing.T) { setupTestCatalog(t) - // Find a service with dependencies - jarvis depends on oracle + // identity depends on persistence opts := &infoOptions{json: false} output, err := captureOutput(t, func() error { - return runInfo("jarvis", opts) + return runInfo("identity", opts) }) if err != nil { t.Fatalf("runInfo failed: %v", err) @@ -187,15 +186,15 @@ func TestRunInfo_DependenciesSectionRendered(t *testing.T) { t.Error("Output missing 'Dependencies' section") } - if !strings.Contains(output, "oracle") { - t.Error("Output missing 'oracle' dependency") + if !strings.Contains(output, "persistence") { + t.Error("Output missing 'persistence' dependency") } } func TestRunInfo_CaseInsensitive(t *testing.T) { setupTestCatalog(t) - testCases := []string{"HEIMDALL", "Heimdall", "HeImDaLl", "heimdall"} + testCases := []string{"GATEWAY", "Gateway", "GaTeWaY", "gateway"} for _, codename := range testCases { t.Run(codename, func(t *testing.T) { @@ -216,8 +215,8 @@ func TestRunInfo_CaseInsensitive(t *testing.T) { t.Fatalf("Failed to parse JSON: %v", err) } - if result.Codename != "heimdall" { - t.Errorf("Codename = %q, want 'heimdall'", result.Codename) + if result.Codename != "gateway" { + t.Errorf("Codename = %q, want 'gateway'", result.Codename) } }) } @@ -249,10 +248,10 @@ func TestRunInfo_AllRoles(t *testing.T) { codename string expectedRole string }{ - {"heimdall", "Infrastructure"}, - {"oracle", "Data"}, - {"sherlock", "AI"}, - {"friday", "Observability"}, + {"gateway", "Infrastructure"}, + {"persistence", "Infrastructure"}, + {"reasoner", "Service"}, + {"friday-collector", "Infrastructure"}, } for _, tc := range testCases { @@ -276,11 +275,11 @@ func TestRunInfo_AllRoles(t *testing.T) { func TestRunInfo_EnvironmentVariables(t *testing.T) { setupTestCatalog(t) - // Oracle has environment variables + // reasoner has environment variables (LLM_PROVIDER, LLM_MODEL, LLM_API_KEY) opts := &infoOptions{json: true} output, err := captureOutput(t, func() error { - return runInfo("oracle", opts) + return runInfo("reasoner", opts) }) if err != nil { t.Fatalf("runInfo failed: %v", err) @@ -299,21 +298,21 @@ func TestRunInfo_EnvironmentVariables(t *testing.T) { t.Fatalf("Failed to parse JSON: %v", err) } - // Oracle should have at least some env vars + // reasoner should have at least some env vars if len(result.Environment) == 0 { - t.Error("Oracle should have environment variables") + t.Error("reasoner should have environment variables") } - // Check for common postgres env vars - hasPassword := false + // Check for LLM-related env vars + hasLLM := false for _, env := range result.Environment { - if strings.Contains(env.Name, "PASSWORD") || strings.Contains(env.Name, "POSTGRES") { - hasPassword = true + if strings.Contains(env.Name, "LLM") { + hasLLM = true break } } - if !hasPassword { - t.Error("Oracle should have password-related environment variables") + if !hasLLM { + t.Error("reasoner should have LLM-related environment variables") } } diff --git a/pkg/cli/services/list.go b/pkg/cli/services/list.go index 3f3e07f..950cdad 100644 --- a/pkg/cli/services/list.go +++ b/pkg/cli/services/list.go @@ -59,6 +59,10 @@ func runList(opts *listOptions) error { switch strings.ToLower(opts.role) { case "infrastructure", "infra": filter = catalog.FilterInfrastructure + case "service": + filter = catalog.FilterService + case "sidecar": + filter = catalog.FilterSidecar case "data": filter = catalog.FilterData case "ai": @@ -66,7 +70,7 @@ func runList(opts *listOptions) error { case "observability", "obs": filter = catalog.FilterObservability default: - return fmt.Errorf("unknown role %q. Valid roles: infrastructure, data, ai, observability", opts.role) + return fmt.Errorf("unknown role %q. Valid roles: infrastructure, service, sidecar", opts.role) } } @@ -165,6 +169,8 @@ func outputTable(services []*catalog.Service, noTree bool) error { // Role order roles := []catalog.ServiceRole{ catalog.RoleInfrastructure, + catalog.RoleService, + catalog.RoleSidecar, catalog.RoleData, catalog.RoleAI, catalog.RoleObservability, @@ -219,6 +225,10 @@ func getRoleEmoji(role catalog.ServiceRole) string { switch role { case catalog.RoleInfrastructure: return "πŸ›‘οΈ" + case catalog.RoleService: + return "⚑" + case catalog.RoleSidecar: + return "πŸ”Œ" case catalog.RoleData: return "🧠" case catalog.RoleAI: diff --git a/pkg/cli/services/ports_test.go b/pkg/cli/services/ports_test.go index dbfc7f3..e076f4d 100644 --- a/pkg/cli/services/ports_test.go +++ b/pkg/cli/services/ports_test.go @@ -121,7 +121,7 @@ func TestRunPorts_ContainsKnownPorts(t *testing.T) { t.Fatalf("Failed to parse JSON: %v", err) } - // Check for known ports (heimdall uses 80, 443, 8080) + // Check for known ports (gateway uses 80, 443, 8080) knownPorts := map[int]bool{80: false, 443: false, 8080: false} for _, alloc := range result.Allocations { @@ -295,7 +295,7 @@ func TestRunPorts_ServiceNamesInTable(t *testing.T) { } // Check some known services appear in output - knownServices := []string{"heimdall", "oracle"} + knownServices := []string{"gateway", "persistence"} foundAny := false for _, svc := range knownServices { diff --git a/pkg/cli/workspace/down.go b/pkg/cli/workspace/down.go new file mode 100644 index 0000000..93646e7 --- /dev/null +++ b/pkg/cli/workspace/down.go @@ -0,0 +1,91 @@ +package workspace + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + + "github.com/spf13/cobra" +) + +// NewDownCmd creates the workspace down command +func NewDownCmd() *cobra.Command { + var dir string + + cmd := &cobra.Command{ + Use: "down", + Short: "Stop the platform", + Long: `Stop all running platform services for this workspace. + +Stops containers without removing them or their data. Run again with +'arc workspace run' to bring the platform back up.`, + Example: ` # Stop the platform (from inside workspace directory) + arc workspace down + + # Stop a specific workspace + arc workspace down --dir ./my-workspace`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + return runDown(dir) + }, + } + + cmd.Flags().StringVar(&dir, "dir", "", "Path to workspace directory (defaults to current directory)") + + return cmd +} + +func runDown(dir string) error { + composeFile, err := resolveComposeFile(dir) + if err != nil { + return err + } + + fmt.Println("Stopping platform...") + c := exec.Command("docker", "compose", "-f", composeFile, "down") + c.Stdout = os.Stdout + c.Stderr = os.Stderr + if runErr := c.Run(); runErr != nil { + return fmt.Errorf("docker compose down failed: %w", runErr) + } + + fmt.Println("\nβœ“ Platform stopped.") + return nil +} + +// resolveComposeFile locates the generated docker-compose.yml for a workspace. +// If dir is provided it is used directly; otherwise the workspace is auto-detected +// from the current directory. +func resolveComposeFile(dir string) (string, error) { + root, err := resolveWorkspaceRoot(dir) + if err != nil { + return "", err + } + + composeFile := filepath.Join(root, ".arc", "generated", "docker-compose.yml") + if _, statErr := os.Stat(composeFile); os.IsNotExist(statErr) { + return "", fmt.Errorf("no generated compose file found in %s β€” run 'arc workspace run' first", root) + } + + return composeFile, nil +} + +// resolveWorkspaceRoot returns the absolute workspace root. +// If dir is non-empty it is resolved to an absolute path and used directly. +// Otherwise the workspace is auto-detected upward from the current directory. +func resolveWorkspaceRoot(dir string) (string, error) { + if dir != "" { + abs, err := filepath.Abs(dir) + if err != nil { + return "", fmt.Errorf("invalid directory: %w", err) + } + if _, statErr := os.Stat(abs); os.IsNotExist(statErr) { + return "", fmt.Errorf("directory not found: %s", abs) + } + return abs, nil + } + + workspaceRoot, _, _, err := setupWorkspace() + return workspaceRoot, err +} diff --git a/pkg/cli/workspace/init.go b/pkg/cli/workspace/init.go index 173c5cd..fb3f81d 100644 --- a/pkg/cli/workspace/init.go +++ b/pkg/cli/workspace/init.go @@ -8,9 +8,10 @@ import ( "github.com/spf13/afero" "github.com/spf13/cobra" + "github.com/arc-framework/arc-cli/internal/app" + "github.com/arc-framework/arc-cli/internal/branding" newengine "github.com/arc-framework/arc-cli/pkg/ui/engine" uithemeldr "github.com/arc-framework/arc-cli/pkg/ui/theme" - uiview "github.com/arc-framework/arc-cli/pkg/ui/view" "github.com/arc-framework/arc-cli/pkg/workspace" "github.com/arc-framework/arc-cli/pkg/workspace/store/local" ) @@ -19,6 +20,8 @@ import ( type initFlags struct { force bool skipGitignore bool + tier string + capabilities []string } // NewInitCmd creates the workspace init command @@ -37,83 +40,82 @@ Creates: - .arc/ (system directory for state and generated configs) The workspace manifest (arc.yaml) is your single source of truth for -platform configuration. Edit it to enable features (voice, security, -observability) and customize service settings.`, +platform configuration. Edit it to configure the tier and capabilities.`, Example: ` # Initialize workspace in current directory arc workspace init - # Initialize workspace in specific directory + # Initialize with a specific tier (non-interactive) + arc workspace init --tier reason --capabilities voice,observe + + # Initialize in a specific directory arc workspace init ~/projects/my-arc-project # Reinitialize existing workspace (overwrites arc.yaml) arc workspace init --force`, Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - return runInit(flags, args) + return runInit(flags, args, cmd) }, } cmd.Flags().BoolVarP(&flags.force, "force", "f", false, "Reinitialize existing workspace (overwrites arc.yaml)") cmd.Flags().BoolVar(&flags.skipGitignore, "skip-gitignore", false, "Skip creating/updating .gitignore") + cmd.Flags().StringVar(&flags.tier, "tier", "", "Platform tier: think, reason, or ultra-instinct (default: think)") + cmd.Flags().StringSliceVar(&flags.capabilities, "capabilities", nil, "Capability bundles: reasoner, voice, observe, security, storage") return cmd } -func runInit(flags *initFlags, args []string) error { +// runInit routes to TUI wizard or non-interactive path based on flags. +func runInit(flags *initFlags, args []string, cmd *cobra.Command) error { + // If --tier was explicitly provided, skip the TUI and go directly to non-interactive. + if cmd.Flags().Changed("tier") || cmd.Flags().Changed("capabilities") { + return legacyRunInit(flags, args) + } if err := renderInitWithNewUI(); err == nil { return nil } return legacyRunInit(flags, args) } -// renderInitWithNewUI renders the workspace init wizard using InitWizard view (T066). +// renderInitWithNewUI renders the workspace init wizard using the full ARC dashboard. func renderInitWithNewUI() error { loader, err := uithemeldr.NewLoader() if err != nil { return err } - cfg := newengine.Config{ - Mode: newengine.ModeFocused, - Views: []newengine.View{uiview.NewInitWizard()}, - Title: "Workspace Init", - Subtitle: "Initialize a new A.R.C. workspace", - Loader: loader, - } + cfg := app.EngineConfig(appContext, loader, app.BuildDefaultViews(loader), branding.Name, "") + cfg.InitialView = "Init Wizard" return newengine.Start(cfg) } func legacyRunInit(flags *initFlags, args []string) error { - // Determine workspace path workspacePath := "." if len(args) > 0 { workspacePath = args[0] } - // Convert to absolute path for display absPath, err := filepath.Abs(workspacePath) if err != nil { return fmt.Errorf("failed to resolve path: %w", err) } - // Create filesystem and state repository fs := afero.NewOsFs() stateDir := filepath.Join(absPath, ".arc", "state") stateRepo := local.NewStateRepository(fs, stateDir) - - // Create initializer initializer := workspace.NewInitializer(fs, stateRepo) - // Initialize workspace opts := workspace.InitializeOptions{ Path: workspacePath, Force: flags.force, SkipGitignore: flags.skipGitignore, + Tier: flags.tier, + Capabilities: flags.capabilities, } fmt.Printf("Initializing A.R.C. workspace at %s...\n", absPath) if initErr := initializer.Initialize(opts); initErr != nil { - // Check for specific error types if workspace.IsWorkspaceExists(initErr) { fmt.Fprintf(os.Stderr, "Error: %v\n", initErr) fmt.Fprintf(os.Stderr, "\nTip: Use --force to reinitialize\n") @@ -122,7 +124,6 @@ func legacyRunInit(flags *initFlags, args []string) error { return fmt.Errorf("initialization failed: %w", initErr) } - // Success message fmt.Println("\nβœ“ Workspace initialized successfully!") fmt.Printf("\nWorkspace root: %s\n", absPath) fmt.Println("\nCreated:") @@ -131,9 +132,8 @@ func legacyRunInit(flags *initFlags, args []string) error { fmt.Println(" - .gitignore (version control ignores)") fmt.Println(" - .arc/ (system directory)") fmt.Println("\nNext steps:") - fmt.Println(" 1. Edit arc.yaml to enable desired features") - fmt.Println(" 2. Run 'arc workspace run' to generate and launch platform") - fmt.Println(" 3. Use 'arc workspace info' to inspect workspace state") + fmt.Println(" 1. Run 'arc workspace run' to launch the platform") + fmt.Println(" 2. Use 'arc workspace info' to inspect workspace state") return nil } diff --git a/pkg/cli/workspace/nuke.go b/pkg/cli/workspace/nuke.go new file mode 100644 index 0000000..4caddda --- /dev/null +++ b/pkg/cli/workspace/nuke.go @@ -0,0 +1,130 @@ +package workspace + +import ( + "bufio" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/spf13/afero" + "github.com/spf13/cobra" +) + +// nukeFlags holds flags for the nuke command +type nukeFlags struct { + yes bool + dir string +} + +// NewNukeCmd creates the workspace nuke command +func NewNukeCmd() *cobra.Command { + flags := &nukeFlags{} + + cmd := &cobra.Command{ + Use: "nuke", + Short: "Destroy everything β€” containers, volumes, networks, images, and workspace", + Long: `Completely destroy the A.R.C. workspace and all associated resources. + +This command performs the following DESTRUCTIVE actions: + 1. Stops and removes all platform containers + 2. Removes all named Docker volumes (ALL DATA WILL BE LOST) + 3. Removes Docker networks (arc_platform_net, arc_otel_net) + 4. Removes pulled platform images + 5. Deletes the workspace directory + +This action is IRREVERSIBLE. All data stored in volumes will be permanently deleted.`, + Example: ` # Nuke with confirmation prompt + arc workspace nuke + + # Nuke a specific workspace + arc workspace nuke --dir ./my-workspace + + # Skip confirmation (for automation) + arc workspace nuke --yes`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + return runNuke(flags) + }, + } + + cmd.Flags().BoolVarP(&flags.yes, "yes", "y", false, "Skip confirmation prompt") + cmd.Flags().StringVar(&flags.dir, "dir", "", "Path to workspace directory (defaults to current directory)") + + return cmd +} + +func runNuke(flags *nukeFlags) error { + workspaceRoot, err := resolveWorkspaceRoot(flags.dir) + if err != nil { + return err + } + + composeFile := filepath.Join(workspaceRoot, ".arc", "generated", "docker-compose.yml") + hasCompose := true + if _, statErr := os.Stat(composeFile); os.IsNotExist(statErr) { + hasCompose = false + } + + // Confirmation prompt + if !flags.yes { + fmt.Println("╔══════════════════════════════════════════════════════╗") + fmt.Println("β•‘ ⚠ DESTRUCTIVE OPERATION β€” THIS CANNOT BE UNDONE β•‘") + fmt.Println("β•šβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•") + fmt.Println() + fmt.Printf("Workspace: %s\n\n", workspaceRoot) + fmt.Println("The following will be permanently deleted:") + fmt.Println(" β€’ All platform containers") + fmt.Println(" β€’ All named volumes (ALL STORED DATA)") + fmt.Println(" β€’ Docker networks (arc_platform_net, arc_otel_net)") + fmt.Println(" β€’ Pulled platform images") + fmt.Printf(" β€’ Workspace directory (%s)\n", workspaceRoot) + fmt.Println() + fmt.Print("Type \"nuke\" to confirm: ") + + reader := bufio.NewReader(os.Stdin) + input, readErr := reader.ReadString('\n') + if readErr != nil { + return fmt.Errorf("failed to read input: %w", readErr) + } + if strings.TrimSpace(input) != "nuke" { + fmt.Println("Aborted.") + return nil + } + fmt.Println() + } + + // Step 1: docker compose down --volumes --rmi all + if hasCompose { + fmt.Print("Removing containers, volumes, and images... ") + c := exec.Command("docker", "compose", "-f", composeFile, "down", "--volumes", "--rmi", "all", "--remove-orphans") + c.Stdout = os.Stdout + c.Stderr = os.Stderr + if runErr := c.Run(); runErr != nil { + fmt.Println("βœ—") + fmt.Fprintf(os.Stderr, "Warning: docker compose down failed: %v\n", runErr) + } else { + fmt.Println("βœ“") + } + } + + // Step 2: Remove shared networks (best-effort β€” other workspaces may still use them) + fmt.Print("Removing Docker networks... ") + for _, network := range []string{"arc_platform_net", "arc_otel_net"} { + _ = exec.Command("docker", "network", "rm", network).Run() + } + fmt.Println("βœ“") + + // Step 3: Delete workspace directory + fmt.Printf("Deleting workspace directory %s... ", workspaceRoot) + fs := afero.NewOsFs() + if removeErr := fs.RemoveAll(workspaceRoot); removeErr != nil { + fmt.Println("βœ—") + return fmt.Errorf("failed to delete workspace: %w", removeErr) + } + fmt.Println("βœ“") + + fmt.Println("\nβœ“ Workspace nuked.") + return nil +} diff --git a/pkg/cli/workspace/run.go b/pkg/cli/workspace/run.go index 71fb9be..8cd4cfd 100644 --- a/pkg/cli/workspace/run.go +++ b/pkg/cli/workspace/run.go @@ -216,8 +216,18 @@ func showGenerateOnlySuccess(generatedDir string) { } // launchPlatform launches Docker Compose and records the operation +// ensureNetworks pre-creates shared Docker networks so compose can use them as external. +// The networks are declared external in the template to avoid label-mismatch errors when +// a network with the same name already exists from arc-platform or a previous run. +func ensureNetworks() { + for _, network := range []string{"arc_platform_net", "arc_otel_net"} { + _ = exec.Command("docker", "network", "create", "--driver", "bridge", network).Run() + } +} + func launchPlatform(flags *runFlags, generatedDir string, stateRepo store.WorkspaceStateRepository) error { fmt.Println("\nLaunching platform...") + ensureNetworks() composeFile := filepath.Join(generatedDir, "docker-compose.yml") var composeArgs []string diff --git a/pkg/cli/workspace/workspace.go b/pkg/cli/workspace/workspace.go index a42b573..4e0f090 100644 --- a/pkg/cli/workspace/workspace.go +++ b/pkg/cli/workspace/workspace.go @@ -2,8 +2,19 @@ package workspace import ( "github.com/spf13/cobra" + + "github.com/arc-framework/arc-cli/internal/app" ) +// appContext holds the application context for UI rendering. +var appContext *app.Context + +// SetAppContext sets the app context for workspace commands that use the engine. +// Must be called before executing any workspace commands that launch the TUI. +func SetAppContext(ctx *app.Context) { + appContext = ctx +} + // NewWorkspaceCmd creates the workspace command group func NewWorkspaceCmd() *cobra.Command { cmd := &cobra.Command{ @@ -33,6 +44,8 @@ and the CLI generates complete infrastructure configurations automatically.`, // Add subcommands cmd.AddCommand(NewInitCmd()) cmd.AddCommand(NewRunCmd()) + cmd.AddCommand(NewDownCmd()) + cmd.AddCommand(NewNukeCmd()) cmd.AddCommand(NewInfoCmd()) cmd.AddCommand(NewHistoryCmd()) diff --git a/pkg/scaffold/templates/arc.yaml.tmpl b/pkg/scaffold/templates/arc.yaml.tmpl index b93ab5f..0ac96ae 100644 --- a/pkg/scaffold/templates/arc.yaml.tmpl +++ b/pkg/scaffold/templates/arc.yaml.tmpl @@ -4,35 +4,33 @@ # Semantic version of arc.yaml format version: "1.0.0" -# Platform tier (selected during initialization) -# Options: super-saiyan, super-saiyan-blue, ultra-instinct +# Platform tier β€” controls which services are started. +# Options: think (core only), reason (+ reasoning engine), ultra-instinct (all capabilities) tier: "{{.Tier}}" -# High-level feature flags -# Enable/disable platform capabilities without managing individual services -features: - # Voice interface (Scarlett/Daredevil) - voice: false - - # Security & Identity (J.A.R.V.I.S./Kratos) - security: false - - # Observability stack (Prometheus, Grafana, Loki) - observability: false - - # Chaos engineering (T-800/Chaos Mesh) - chaos: false +# Optional capability bundles to add on top of the tier preset. +# Each capability unlocks additional services and resolves its own dependencies. +# Available: reasoner, voice, observe, security, storage +{{- if .Capabilities}} +capabilities: +{{- range .Capabilities}} + - {{.}} +{{- end}} +{{- else}} +# capabilities: +# - reasoner # LLM reasoning engine β€” required by voice +# - voice # Voice agent (STT + TTS + LiveKit) +# - observe # Observability stack (SigNoz + OpenTelemetry) +# - security # Secrets vault + feature flags (OpenBao + Unleash) +# - storage # Object storage (MinIO) +{{- end}} # Service-specific overrides (optional) -# Uncomment to customize individual service configurations # services: -# arc-heimdall-gateway: +# arc-gateway: # enabled: true -# config: -# port: 8080 -# Environment variables (optional) -# Injected into all services +# Environment variables injected into all services (optional) # environment: # LOG_LEVEL: "info" # ENVIRONMENT: "development" diff --git a/pkg/scaffold/templates/docker-compose.yml.tmpl b/pkg/scaffold/templates/docker-compose.yml.tmpl index ff0be02..2a41721 100644 --- a/pkg/scaffold/templates/docker-compose.yml.tmpl +++ b/pkg/scaffold/templates/docker-compose.yml.tmpl @@ -1,204 +1,519 @@ -version: '3.9' +name: arc-platform services: {{- range .Services }} {{ .ServiceName }}: image: {{ .ImageName }} + {{- if or (eq .ServiceName "arc-streaming") (eq .ServiceName "arc-reasoner") (eq .ServiceName "arc-voice-agent") (eq .ServiceName "arc-friday") (eq .ServiceName "arc-friday-zookeeper") (eq .ServiceName "arc-friday-clickhouse") (eq .ServiceName "arc-friday-migrator-sync") (eq .ServiceName "arc-friday-migrator-async") }} + platform: linux/amd64 + {{- end }} container_name: {{ .ServiceName }} + {{- if eq .ServiceName "arc-gateway" }} + command: + - --entrypoints.web.address=:8080 + - --entrypoints.traefik.address=:8081 + - --api.insecure=true + - --api.dashboard=true + - --ping=true + - --ping.entrypoint=traefik + - --providers.docker=true + - --providers.docker.network=arc_platform_net + - --providers.docker.exposedbydefault=false + - --accesslog=true + - --accesslog.format=json + - --metrics.prometheus=true + - --metrics.prometheus.entrypoint=traefik + - --tracing.otlp.grpc.endpoint=arc-friday-collector:4317 + - --tracing.otlp.grpc.insecure=true + - --tracing.serviceName=arc-gateway + {{- end }} + {{- if eq .ServiceName "arc-messaging" }} + command: + - --js + - --http_port=8222 + - --store_dir=/data + {{- end }} + {{- if eq .ServiceName "arc-cache" }} + command: + - redis-server + - --appendonly + - "yes" + - --appendfsync + - everysec + - --maxmemory + - 512mb + - --maxmemory-policy + - noeviction + {{- end }} + {{- if eq .ServiceName "arc-streaming" }} + command: + - bin/pulsar + - standalone + - --no-functions-worker + {{- end }} + {{- if eq .ServiceName "arc-cortex" }} + command: ["server", "--log-level", "debug"] + {{- end }} + {{- if eq .ServiceName "arc-vault" }} + command: server -dev + {{- end }} + {{- if eq .ServiceName "arc-friday-collector" }} + user: "10001:10001" + command: + - --config=/etc/otelcol/config.yaml + {{- end }} + {{- if eq .ServiceName "arc-realtime" }} + command: --config /etc/livekit.yaml + {{- end }} + {{- if eq .ServiceName "arc-friday-migrator-sync" }} + command: ["sync", "--dsn=tcp://arc-friday-clickhouse:9000", "--up="] + {{- end }} + {{- if eq .ServiceName "arc-friday-migrator-async" }} + command: ["async", "--dsn=tcp://arc-friday-clickhouse:9000", "--up="] + {{- end }} + {{- if eq .ServiceName "arc-friday-zookeeper" }} + user: root + {{- end }} + {{- if eq .ServiceName "arc-friday" }} + command: + - --config=/root/config/prometheus.yml + {{- end }} + {{- if eq .ServiceName "arc-storage" }} + user: "1000:1000" + command: server /data --console-address ":9001" + {{- end }} {{- if .Ports }} ports: {{- range .Ports }} - - "{{ . }}:{{ . }}" + - "127.0.0.1:{{ .Host }}:{{ .Container }}" {{- end }} - {{- end }} - {{- if .Dependencies }} - depends_on: - {{- range .Dependencies }} - - {{ . }} + {{- if eq .ServiceName "arc-realtime" }} + - "0.0.0.0:50100-50200:50100-50200/udp" {{- end }} {{- end }} - {{- if eq .ServiceName "arc-gateway" }} - command: - - "--api.insecure=true" - - "--providers.docker=true" - - "--providers.docker.exposedbydefault=false" - - "--entrypoints.web.address=:80" - - "--entrypoints.websecure.address=:443" + {{- $svcName := .ServiceName }} + {{- if or (eq $svcName "arc-gateway") (eq $svcName "arc-sql-db") (eq $svcName "arc-streaming") (eq $svcName "arc-messaging") (eq $svcName "arc-cache") (eq $svcName "arc-friday-collector") (eq $svcName "arc-realtime") (eq $svcName "arc-storage") (eq $svcName "arc-friday") (eq $svcName "arc-friday-zookeeper") (eq $svcName "arc-friday-clickhouse") }} volumes: + {{- if eq $svcName "arc-gateway" }} - /var/run/docker.sock:/var/run/docker.sock:ro - - ./config/gateway/traefik.yml:/etc/traefik/traefik.yml:ro - {{- else if eq .ServiceName "arc-db-sql" }} + {{- end }} + {{- if eq $svcName "arc-sql-db" }} + - oracle-data:/var/lib/postgresql/data + {{- end }} + {{- if eq $svcName "arc-messaging" }} + - flash-jetstream:/data + {{- end }} + {{- if eq $svcName "arc-cache" }} + - sonic-data:/data + {{- end }} + {{- if eq $svcName "arc-streaming" }} + - strange-data:/pulsar/data + {{- end }} + {{- if eq $svcName "arc-storage" }} + - tardis-data:/data + {{- end }} + {{- if eq $svcName "arc-friday-collector" }} + - ./observability/otel-collector-config.yml:/etc/otelcol/config.yaml:ro + {{- end }} + {{- if eq $svcName "arc-realtime" }} + - ./realtime/livekit.yaml:/etc/livekit.yaml:ro + {{- end }} + {{- if eq $svcName "arc-friday" }} + - signoz-data:/var/lib/signoz + {{- end }} + {{- if eq $svcName "arc-friday-zookeeper" }} + - zookeeper-data:/bitnami/zookeeper + {{- end }} + {{- if eq $svcName "arc-friday-clickhouse" }} + - clickhouse-data:/var/lib/clickhouse/ + {{- end }} + {{- end }} + {{- $svcName4 := .ServiceName }} + {{- if or (eq $svcName4 "arc-gateway") (eq $svcName4 "arc-sql-db") (eq $svcName4 "arc-streaming") (eq $svcName4 "arc-cortex") (eq $svcName4 "arc-realtime") (eq $svcName4 "arc-storage") (eq $svcName4 "arc-vault") (eq $svcName4 "arc-flags") (eq $svcName4 "arc-reasoner") (eq $svcName4 "arc-voice-agent") (eq $svcName4 "arc-friday") (eq $svcName4 "arc-friday-zookeeper") (eq $svcName4 "arc-friday-clickhouse") }} environment: + {{- if eq .ServiceName "arc-sql-db" }} POSTGRES_USER: arc - POSTGRES_PASSWORD: {{ index $.Env "POSTGRES_PASSWORD" | default "arc_dev_password" }} - POSTGRES_DB: arc_platform - volumes: - - postgres_data:/var/lib/postgresql/data + POSTGRES_PASSWORD: {{ index $.Env "POSTGRES_PASSWORD" | default "arc" }} + POSTGRES_DB: arc + {{- end }} + {{- if eq .ServiceName "arc-streaming" }} + PULSAR_MEM: "-Xms512m -Xmx1024m -XX:MaxDirectMemorySize=512m" + {{- end }} + {{- if eq .ServiceName "arc-storage" }} + MINIO_ROOT_USER: {{ index $.Env "MINIO_ROOT_USER" | default "arc" }} + MINIO_ROOT_PASSWORD: {{ index $.Env "MINIO_ROOT_PASSWORD" | default "arc-minio-dev" }} + MINIO_PROMETHEUS_AUTH_TYPE: "public" + {{- end }} + {{- if eq .ServiceName "arc-vault" }} + VAULT_DEV_ROOT_TOKEN_ID: {{ index $.Env "VAULT_DEV_ROOT_TOKEN_ID" | default "arc-dev-token" }} + VAULT_DEV_LISTEN_ADDRESS: 0.0.0.0:8200 + {{- end }} + {{- if eq .ServiceName "arc-flags" }} + DATABASE_URL: postgresql://arc:{{ index $.Env "POSTGRES_PASSWORD" | default "arc" }}@arc-sql-db:5432/unleash + REDIS_HOST: arc-cache + REDIS_PORT: 6379 + {{- end }} + {{- if eq .ServiceName "arc-realtime" }} + NODE_IP: {{ index $.Env "LIVEKIT_NODE_IP" | default "127.0.0.1" }} + {{- end }} + {{- if eq .ServiceName "arc-cortex" }} + CORTEX_BOOTSTRAP_POSTGRES_PASSWORD: {{ index $.Env "POSTGRES_PASSWORD" | default "arc" }} + OTEL_SERVICE_NAME: "arc-cortex" + OTEL_SERVICE_VERSION: "0.1.0" + OTEL_DEPLOYMENT_ENVIRONMENT: "development" + OTEL_RESOURCE_ATTRIBUTES: "service.namespace=arc-platform,service.instance.id=arc-cortex-1" + OTEL_EXPORTER_OTLP_ENDPOINT: "http://arc-friday-collector:4317" + OTEL_EXPORTER_OTLP_PROTOCOL: "grpc" + OTEL_TRACES_SAMPLER: "always_on" + OTEL_PROPAGATORS: "tracecontext,baggage" + OTEL_LOG_LEVEL: "warn" + {{- end }} + {{- if eq .ServiceName "arc-gateway" }} + OTEL_SERVICE_NAME: "arc-gateway" + OTEL_SERVICE_VERSION: "0.1.0" + OTEL_DEPLOYMENT_ENVIRONMENT: "development" + OTEL_RESOURCE_ATTRIBUTES: "service.namespace=arc-platform,service.instance.id=arc-gateway-1" + OTEL_EXPORTER_OTLP_ENDPOINT: "http://arc-friday-collector:4317" + OTEL_EXPORTER_OTLP_PROTOCOL: "grpc" + OTEL_TRACES_SAMPLER: "always_on" + OTEL_PROPAGATORS: "tracecontext,baggage" + OTEL_LOG_LEVEL: "warn" + {{- end }} + {{- if eq .ServiceName "arc-reasoner" }} + SHERLOCK_POSTGRES_URL: "postgresql+asyncpg://arc:{{ index $.Env "POSTGRES_PASSWORD" | default "arc" }}@arc-sql-db:5432/arc" + SHERLOCK_NATS_URL: "nats://arc-messaging:4222" + SHERLOCK_RAG_ENABLED: "true" + SHERLOCK_MINIO_ENDPOINT: "arc-storage:9000" + SHERLOCK_MINIO_ACCESS_KEY: {{ index $.Env "MINIO_ROOT_USER" | default "arc" }} + SHERLOCK_MINIO_SECRET_KEY: {{ index $.Env "MINIO_ROOT_PASSWORD" | default "arc-minio-dev" }} + SHERLOCK_MINIO_BUCKET: "reasoner-files" + SHERLOCK_LLM_BASE_URL: {{ index $.Env "LLM_BASE_URL" | default "http://host.docker.internal:1234/v1" }} + LOG_LEVEL: "debug" + OTEL_SERVICE_NAME: "arc-reasoner" + OTEL_SERVICE_VERSION: "0.1.0" + OTEL_DEPLOYMENT_ENVIRONMENT: "development" + OTEL_RESOURCE_ATTRIBUTES: "service.namespace=arc-platform,service.instance.id=arc-reasoner-1" + OTEL_EXPORTER_OTLP_ENDPOINT: "http://arc-friday-collector:4317" + OTEL_EXPORTER_OTLP_PROTOCOL: "grpc" + OTEL_TRACES_SAMPLER: "always_on" + OTEL_PROPAGATORS: "tracecontext,baggage" + OTEL_LOG_LEVEL: "warn" + {{- end }} + {{- if eq .ServiceName "arc-friday-zookeeper" }} + ZOO_SERVER_ID: "1" + ALLOW_ANONYMOUS_LOGIN: "yes" + ZOO_AUTOPURGE_INTERVAL: "1" + ZOO_ENABLE_PROMETHEUS_METRICS: "yes" + ZOO_PROMETHEUS_METRICS_PORT_NUMBER: "9141" + {{- end }} + {{- if eq .ServiceName "arc-friday-clickhouse" }} + CLICKHOUSE_SKIP_USER_SETUP: "1" + {{- end }} + {{- if eq .ServiceName "arc-friday" }} + SIGNOZ_ALERTMANAGER_PROVIDER: signoz + SIGNOZ_SQLSTORE_SQLITE_PATH: /var/lib/signoz/signoz.db + SIGNOZ_TELEMETRYSTORE_CLICKHOUSE_DSN: tcp://arc-friday-clickhouse:9000 + STORAGE: clickhouse + GODEBUG: netdns=go + TELEMETRY_ENABLED: "false" + {{- end }} + {{- if eq .ServiceName "arc-voice-agent" }} + VOICE_NATS_URL: nats://arc-messaging:4222 + VOICE_PULSAR_URL: pulsar://arc-streaming:6650 + VOICE_LIVEKIT_URL: ws://arc-realtime:7880 + VOICE_LIVEKIT_API_KEY: {{ index $.Env "LIVEKIT_API_KEY" | default "devkey" }} + VOICE_LIVEKIT_API_SECRET: {{ index $.Env "LIVEKIT_API_SECRET" | default "devsecret" }} + VOICE_BRIDGE_NATS_SUBJECT: arc.reasoner.request + VOICE_BRIDGE_TIMEOUT_MS: "10000" + VOICE_OTEL_ENDPOINT: "http://arc-friday-collector:4317" + VOICE_WHISPER_MODEL: tiny + VOICE_WHISPER_DEVICE: cpu + VOICE_PIPER_BIN: /usr/local/bin/piper + VOICE_LOG_LEVEL: INFO + {{- end }} + {{- end }} + {{- $svcName2 := .ServiceName }} + {{- if or (eq $svcName2 "arc-cortex") (eq $svcName2 "arc-flags") (eq $svcName2 "arc-reasoner") (eq $svcName2 "arc-voice-agent") (eq $svcName2 "arc-realtime") (eq $svcName2 "arc-friday-collector") (eq $svcName2 "arc-friday-clickhouse") (eq $svcName2 "arc-friday-migrator-sync") (eq $svcName2 "arc-friday-migrator-async") (eq $svcName2 "arc-friday") }} + depends_on: + {{- if eq $svcName2 "arc-cortex" }} + arc-sql-db: + condition: service_healthy + arc-messaging: + condition: service_healthy + arc-streaming: + condition: service_healthy + arc-cache: + condition: service_healthy + {{- end }} + {{- if eq $svcName2 "arc-flags" }} + arc-sql-db: + condition: service_healthy + arc-cache: + condition: service_healthy + {{- end }} + {{- if eq $svcName2 "arc-reasoner" }} + arc-sql-db: + condition: service_healthy + arc-messaging: + condition: service_healthy + arc-friday-collector: + condition: service_healthy + {{- end }} + {{- if eq $svcName2 "arc-voice-agent" }} + arc-messaging: + condition: service_healthy + arc-realtime: + condition: service_healthy + arc-reasoner: + condition: service_healthy + {{- end }} + {{- if eq $svcName2 "arc-realtime" }} + arc-cache: + condition: service_healthy + {{- end }} + {{- if and (eq $svcName2 "arc-friday-collector") (hasService $.Services "arc-friday-migrator-sync") }} + arc-friday-migrator-sync: + condition: service_completed_successfully + {{- end }} + {{- if eq $svcName2 "arc-friday-clickhouse" }} + arc-friday-zookeeper: + condition: service_healthy + {{- end }} + {{- if eq $svcName2 "arc-friday-migrator-sync" }} + arc-friday-clickhouse: + condition: service_healthy + {{- end }} + {{- if eq $svcName2 "arc-friday-migrator-async" }} + arc-friday-clickhouse: + condition: service_healthy + arc-friday-migrator-sync: + condition: service_completed_successfully + {{- end }} + {{- if eq $svcName2 "arc-friday" }} + arc-friday-clickhouse: + condition: service_healthy + arc-friday-migrator-sync: + condition: service_completed_successfully + {{- end }} + {{- end }} + labels: + {{- if eq .ServiceName "arc-streaming" }} + - "traefik.enable=true" + - "traefik.docker.network=arc_platform_net" + - "traefik.http.routers.streaming.rule=PathPrefix(`/api/streaming`)" + - "traefik.http.routers.streaming.entrypoints=web" + - "traefik.http.middlewares.strip-streaming.stripprefix.prefixes=/api/streaming" + - "traefik.http.routers.streaming.middlewares=strip-streaming" + - "traefik.http.services.streaming.loadbalancer.server.port=8080" + {{- end }} + {{- if eq .ServiceName "arc-storage" }} + - "traefik.enable=true" + - "traefik.docker.network=arc_platform_net" + - "traefik.http.routers.storage.rule=PathPrefix(`/api/storage`)" + - "traefik.http.routers.storage.entrypoints=web" + - "traefik.http.middlewares.strip-storage.stripprefix.prefixes=/api/storage" + - "traefik.http.routers.storage.middlewares=strip-storage" + - "traefik.http.services.storage.loadbalancer.server.port=9000" + {{- end }} + {{- if eq .ServiceName "arc-vault" }} + - "traefik.enable=true" + - "traefik.docker.network=arc_platform_net" + - "traefik.http.routers.secrets.rule=PathPrefix(`/api/secrets`)" + - "traefik.http.routers.secrets.entrypoints=web" + - "traefik.http.middlewares.strip-secrets.stripprefix.prefixes=/api/secrets" + - "traefik.http.routers.secrets.middlewares=strip-secrets" + - "traefik.http.services.secrets.loadbalancer.server.port=8200" + {{- end }} + {{- if eq .ServiceName "arc-flags" }} + - "traefik.enable=true" + - "traefik.docker.network=arc_platform_net" + - "traefik.http.routers.flags.rule=PathPrefix(`/api/flags`)" + - "traefik.http.routers.flags.entrypoints=web" + - "traefik.http.middlewares.strip-flags.stripprefix.prefixes=/api/flags" + - "traefik.http.routers.flags.middlewares=strip-flags" + - "traefik.http.services.flags.loadbalancer.server.port=4242" + {{- end }} + {{- if eq .ServiceName "arc-reasoner" }} + - "traefik.enable=true" + - "traefik.docker.network=arc_platform_net" + - "traefik.http.routers.reasoner.rule=PathPrefix(`/api/reasoner`)" + - "traefik.http.routers.reasoner.entrypoints=web" + - "traefik.http.middlewares.strip-reasoner.stripprefix.prefixes=/api/reasoner" + - "traefik.http.routers.reasoner.middlewares=strip-reasoner" + - "traefik.http.services.reasoner.loadbalancer.server.port=8000" + {{- end }} + {{- if eq .ServiceName "arc-voice-agent" }} + - "traefik.enable=true" + - "traefik.docker.network=arc_platform_net" + - "traefik.http.routers.voice.rule=PathPrefix(`/api/voice`)" + - "traefik.http.routers.voice.entrypoints=web" + - "traefik.http.middlewares.strip-voice.stripprefix.prefixes=/api/voice" + - "traefik.http.routers.voice.middlewares=strip-voice" + - "traefik.http.services.voice.loadbalancer.server.port=8803" + {{- end }} + {{- if eq .ServiceName "arc-cortex" }} + - "traefik.enable=true" + - "traefik.docker.network=arc_platform_net" + - "traefik.http.routers.cortex.rule=PathPrefix(`/api/cortex`)" + - "traefik.http.routers.cortex.entrypoints=web" + - "traefik.http.middlewares.strip-cortex.stripprefix.prefixes=/api/cortex" + - "traefik.http.routers.cortex.middlewares=strip-cortex" + - "traefik.http.services.cortex.loadbalancer.server.port=8081" + {{- end }} + - "arc.service={{ .ServiceName }}" + - "arc.codename={{ .CodeName }}" + {{- if and (ne .ServiceName "arc-friday-migrator-sync") (ne .ServiceName "arc-friday-migrator-async") }} healthcheck: - test: ["CMD-SHELL", "pg_isready -U arc"] + {{- if eq .ServiceName "arc-gateway" }} + test: ["CMD-SHELL", "wget -qO- http://localhost:8081/ping || exit 1"] interval: 5s + timeout: 3s + retries: 5 + start_period: 5s + {{- else if eq .ServiceName "arc-messaging" }} + test: ["CMD-SHELL", "wget --spider -q http://localhost:8222/healthz || exit 1"] + interval: 10s timeout: 5s retries: 5 - {{- else if eq .ServiceName "arc-db-cache" }} - volumes: - - redis_data:/data - healthcheck: + start_period: 10s + {{- else if eq .ServiceName "arc-cache" }} test: ["CMD", "redis-cli", "ping"] interval: 5s timeout: 3s retries: 5 - {{- else if eq .ServiceName "arc-db-vector" }} - volumes: - - qdrant_data:/qdrant/storage - environment: - QDRANT__SERVICE__GRPC_PORT: 6334 - {{- else if eq .ServiceName "arc-storage" }} - environment: - MINIO_ROOT_USER: {{ index $.Env "MINIO_ROOT_USER" | default "minioadmin" }} - MINIO_ROOT_PASSWORD: {{ index $.Env "MINIO_ROOT_PASSWORD" | default "minioadmin" }} - volumes: - - minio_data:/data - command: server /data --console-address ":9001" - {{- else if eq .ServiceName "arc-pulse" }} - command: - - "-js" - - "-m" - - "8222" - {{- else if eq .ServiceName "arc-stream" }} - environment: - PULSAR_MEM: "-Xms512m -Xmx512m" - volumes: - - pulsar_data:/pulsar/data - command: bin/pulsar standalone - {{- else if eq .ServiceName "arc-flags" }} - environment: - DATABASE_URL: postgresql://arc:{{ index $.Env "POSTGRES_PASSWORD" | default "arc_dev_password" }}@arc-db-sql:5432/arc_platform - DATABASE_SSL: "false" - {{- else if eq .ServiceName "arc-identity" }} - environment: - DSN: postgres://arc:{{ index $.Env "POSTGRES_PASSWORD" | default "arc_dev_password" }}@arc-db-sql:5432/arc_platform?sslmode=disable - SERVE_PUBLIC_BASE_URL: http://localhost:4433/ - SERVE_ADMIN_BASE_URL: http://localhost:4434/ - volumes: - - ./config/security/kratos.yml:/etc/config/kratos/kratos.yml:ro - command: serve -c /etc/config/kratos/kratos.yml --dev --watch-courier - {{- else if eq .ServiceName "arc-vault" }} - environment: - INFISICAL_DATABASE_URL: postgresql://arc:{{ index $.Env "POSTGRES_PASSWORD" | default "arc_dev_password" }}@arc-db-sql:5432/arc_platform - ENCRYPTION_KEY: {{ index $.Env "ENCRYPTION_KEY" | default "0123456789abcdef0123456789abcdef" }} - volumes: - - vault_data:/var/lib/infisical - {{- else if eq .ServiceName "arc-voice-server" }} - environment: - LIVEKIT_KEYS: "{{ index $.Env "LIVEKIT_API_KEY" | default "devkey" }}: {{ index $.Env "LIVEKIT_API_SECRET" | default "secret" }}" - REDIS_HOST: arc-db-cache:6379 - {{- else if eq .ServiceName "arc-brain" }} - build: - context: ./core/engine - dockerfile: Dockerfile - environment: - DATABASE_URL: postgresql://arc:{{ index $.Env "POSTGRES_PASSWORD" | default "arc_dev_password" }}@arc-db-sql:5432/arc_platform - REDIS_URL: redis://arc-db-cache:6379 - QDRANT_URL: http://arc-db-vector:6333 - NATS_URL: nats://arc-pulse:4222 - volumes: - - ./core/engine:/app - {{- else if eq .ServiceName "arc-voice-agent" }} - build: - context: ./core/voice - dockerfile: Dockerfile - environment: - BRAIN_URL: http://arc-brain:8000 - LIVEKIT_URL: ws://arc-voice-server:7880 - LIVEKIT_API_KEY: {{ index $.Env "LIVEKIT_API_KEY" | default "devkey" }} - LIVEKIT_API_SECRET: {{ index $.Env "LIVEKIT_API_SECRET" | default "secret" }} - {{- else if eq .ServiceName "arc-guard" }} - build: - context: ./core/guardrails - dockerfile: Dockerfile - environment: - BRAIN_URL: http://arc-brain:8000 - {{- else if eq .ServiceName "arc-otel" }} - command: ["--config=/etc/otel-collector-config.yml"] - volumes: - - ./config/observability/otel-collector-config.yml:/etc/otel-collector-config.yml:ro - {{- else if eq .ServiceName "arc-metrics" }} - volumes: - - ./config/observability/prometheus.yml:/etc/prometheus/prometheus.yml:ro - - prometheus_data:/prometheus - command: - - '--config.file=/etc/prometheus/prometheus.yml' - - '--storage.tsdb.path=/prometheus' - {{- else if eq .ServiceName "arc-logs" }} - volumes: - - ./config/observability/loki.yml:/etc/loki/local-config.yaml:ro - - loki_data:/loki - {{- else if eq .ServiceName "arc-traces" }} - volumes: - - ./config/observability/tempo.yml:/etc/tempo.yml:ro - - tempo_data:/tmp/tempo - command: ["-config.file=/etc/tempo.yml"] - {{- else if eq .ServiceName "arc-viz" }} - environment: - GF_SECURITY_ADMIN_PASSWORD: {{ index $.Env "GRAFANA_ADMIN_PASSWORD" | default "admin" }} - GF_INSTALL_PLUGINS: grafana-piechart-panel - volumes: - - ./config/observability/grafana.yml:/etc/grafana/provisioning/datasources/datasources.yml:ro - - grafana_data:/var/lib/grafana - {{- else if eq .ServiceName "arc-log-shipper" }} - volumes: - - ./config/observability/promtail.yml:/etc/promtail/config.yml:ro - - /var/log:/var/log:ro - command: -config.file=/etc/promtail/config.yml - {{- else if eq .ServiceName "arc-chaos" }} - privileged: true - volumes: - - /var/run/docker.sock:/var/run/docker.sock:ro + start_period: 5s + {{- else if eq .ServiceName "arc-sql-db" }} + test: ["CMD-SHELL", "pg_isready -U arc || exit 1"] + interval: 5s + timeout: 3s + retries: 10 + start_period: 10s + {{- else if eq .ServiceName "arc-streaming" }} + test: ["CMD-SHELL", "curl -f http://localhost:8080/admin/v2/brokers/health || exit 1"] + interval: 15s + timeout: 10s + retries: 15 + start_period: 90s + {{- else if eq .ServiceName "arc-cortex" }} + test: ["CMD-SHELL", "wget -qO- http://localhost:8081/health || exit 1"] + interval: 15s + timeout: 5s + retries: 5 + start_period: 10s + {{- else if eq .ServiceName "arc-friday-collector" }} + test: ["CMD-SHELL", "bash -c 'echo > /dev/tcp/localhost/13133' 2>/dev/null"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 30s + {{- else if eq .ServiceName "arc-reasoner" }} + test: ["CMD-SHELL", "python3 -c \"import urllib.request; urllib.request.urlopen('http://localhost:8000/health')\""] + interval: 15s + timeout: 5s + retries: 5 + start_period: 60s + {{- else if eq .ServiceName "arc-voice-agent" }} + test: ["CMD-SHELL", "python3 -c \"import urllib.request; urllib.request.urlopen('http://localhost:8803/health')\""] + interval: 15s + timeout: 5s + retries: 5 + start_period: 30s + {{- else if eq .ServiceName "arc-realtime" }} + test: ["CMD-SHELL", "nc -z localhost 7880 || exit 1"] + interval: 5s + timeout: 3s + retries: 10 + start_period: 15s + {{- else if eq .ServiceName "arc-storage" }} + test: ["CMD-SHELL", "curl -f http://localhost:9000/minio/health/live || exit 1"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 10s + {{- else if eq .ServiceName "arc-vault" }} + test: ["CMD-SHELL", "bash -c 'exec 3<>/dev/tcp/localhost/8200; printf \"GET /v1/sys/health HTTP/1.0\\r\\nHost: localhost\\r\\n\\r\\n\" >&3; head -1 <&3 | grep -q 200' || exit 1"] + interval: 5s + timeout: 3s + retries: 5 + start_period: 5s + {{- else if eq .ServiceName "arc-flags" }} + test: ["CMD-SHELL", "wget -qO- http://localhost:4242/health || exit 1"] + interval: 10s + timeout: 5s + retries: 10 + start_period: 30s + {{- else if eq .ServiceName "arc-friday-zookeeper" }} + test: ["CMD-SHELL", "curl -s -m 2 http://localhost:8080/commands/ruok | grep error | grep null"] + interval: 10s + timeout: 5s + retries: 6 + start_period: 20s + {{- else if eq .ServiceName "arc-friday-clickhouse" }} + test: ["CMD", "wget", "--spider", "-q", "0.0.0.0:8123/ping"] + interval: 10s + timeout: 5s + retries: 6 + start_period: 30s + {{- else if eq .ServiceName "arc-friday" }} + test: ["CMD", "wget", "--spider", "-q", "localhost:8080/api/v1/health"] + interval: 30s + timeout: 5s + retries: 8 + start_period: 60s + {{- end }} {{- end }} + {{- $svcName3 := .ServiceName }} networks: - - arc-network - restart: unless-stopped - labels: - arc.service: "{{ .ServiceName }}" - arc.codename: "{{ .CodeName }}" - {{- if .FeatureFlags }} - arc.features: "{{ join .FeatureFlags "," }}" + - arc_platform_net + {{- if or (eq $svcName3 "arc-gateway") (eq $svcName3 "arc-cortex") (eq $svcName3 "arc-reasoner") (eq $svcName3 "arc-voice-agent") (eq $svcName3 "arc-friday-collector") (eq $svcName3 "arc-friday-zookeeper") (eq $svcName3 "arc-friday-clickhouse") (eq $svcName3 "arc-friday-migrator-sync") (eq $svcName3 "arc-friday-migrator-async") (eq $svcName3 "arc-friday") }} + - arc_otel_net {{- end }} + {{- if or (eq .ServiceName "arc-friday-migrator-sync") (eq .ServiceName "arc-friday-migrator-async") }} + restart: on-failure + {{- else }} + restart: unless-stopped + {{- end }} {{- end }} networks: - arc-network: - driver: bridge + arc_platform_net: + external: true + name: arc_platform_net + arc_otel_net: + external: true + name: arc_otel_net volumes: -{{- if hasService .Services "arc-db-sql" }} - postgres_data: +{{- if hasService .Services "arc-sql-db" }} + oracle-data: + name: arc-sql-db-data {{- end }} -{{- if hasService .Services "arc-db-cache" }} - redis_data: +{{- if hasService .Services "arc-messaging" }} + flash-jetstream: + name: arc-messaging-jetstream {{- end }} -{{- if hasService .Services "arc-db-vector" }} - qdrant_data: +{{- if hasService .Services "arc-cache" }} + sonic-data: + name: arc-cache-data {{- end }} -{{- if hasService .Services "arc-storage" }} - minio_data: -{{- end }} -{{- if hasService .Services "arc-stream" }} - pulsar_data: +{{- if hasService .Services "arc-streaming" }} + strange-data: + name: arc-streaming-data {{- end }} -{{- if hasService .Services "arc-vault" }} - vault_data: -{{- end }} -{{- if hasService .Services "arc-metrics" }} - prometheus_data: +{{- if hasService .Services "arc-storage" }} + tardis-data: + name: arc-storage-data {{- end }} -{{- if hasService .Services "arc-logs" }} - loki_data: +{{- if hasService .Services "arc-friday" }} + signoz-data: + name: arc-friday-sqlite {{- end }} -{{- if hasService .Services "arc-traces" }} - tempo_data: +{{- if hasService .Services "arc-friday-zookeeper" }} + zookeeper-data: + name: arc-friday-zookeeper {{- end }} -{{- if hasService .Services "arc-viz" }} - grafana_data: +{{- if hasService .Services "arc-friday-clickhouse" }} + clickhouse-data: + name: arc-friday-clickhouse {{- end }} diff --git a/pkg/scaffold/templates/observability/otel-collector-config.yml.tmpl b/pkg/scaffold/templates/observability/otel-collector-config.yml.tmpl index 89043d2..715fce3 100644 --- a/pkg/scaffold/templates/observability/otel-collector-config.yml.tmpl +++ b/pkg/scaffold/templates/observability/otel-collector-config.yml.tmpl @@ -1,5 +1,7 @@ -# OpenTelemetry Collector Configuration - Black Widow -# The Spy: Intercepts all signals and traces without being seen +# OpenTelemetry Collector configuration. +# Mirrors arc-platform/services/otel/telemetry/config/otel-collector-config.yaml +# When observe capability is enabled (arc-friday-clickhouse present), signals are +# exported to ClickHouse for SigNoz. Otherwise falls back to debug (stdout). receivers: otlp: @@ -9,96 +11,98 @@ receivers: http: endpoint: 0.0.0.0:4318 - {{- if .EnablePrometheus }} +{{- if hasService .Services "arc-cache" }} + redis: + endpoint: arc-cache:6379 + collection_interval: 15s +{{- end }} + +{{- if hasService .Services "arc-sql-db" }} + postgresql: + endpoint: arc-sql-db:5432 + username: arc + password: {{ index $.Env "POSTGRES_PASSWORD" | default "arc" }} + databases: [arc] + collection_interval: 15s + tls: + insecure: true +{{- end }} + prometheus: config: scrape_configs: - - job_name: 'otel-collector' - scrape_interval: 10s + - job_name: arc-messaging + scrape_interval: 15s + static_configs: + - targets: ['arc-messaging:8222'] + - job_name: arc-streaming + scrape_interval: 15s + static_configs: + - targets: ['arc-streaming:8080'] +{{- if hasService .Services "arc-gateway" }} + - job_name: arc-gateway + scrape_interval: 15s static_configs: - - targets: ['localhost:8888'] - {{- end }} + - targets: ['arc-gateway:8081'] +{{- end }} +{{- if hasService .Services "arc-storage" }} + - job_name: arc-storage + scrape_interval: 15s + static_configs: + - targets: ['arc-storage:9000'] + metrics_path: /minio/v2/metrics/cluster +{{- end }} processors: batch: - timeout: {{ .BatchTimeout | default "10s" }} - send_batch_size: {{ .BatchSize | default 1024 }} - - memory_limiter: - check_interval: 1s - limit_mib: {{ .MemoryLimitMiB | default 512 }} - - resource: - attributes: - - key: cluster - value: {{ .ClusterName | default "arc-local" }} - action: upsert - - key: environment - value: {{ .Environment | default "development" }} - action: upsert - - {{- if .EnableSampling }} - probabilistic_sampler: - sampling_percentage: {{ .SamplingPercentage | default 10 }} - {{- end }} + send_batch_size: 10000 + send_batch_max_size: 11000 + timeout: 10s exporters: - {{- if hasService .Services "arc-metrics" }} - prometheusremotewrite: - endpoint: http://arc-metrics:9090/api/v1/write - resource_to_telemetry_conversion: - enabled: true - {{- end }} +{{- if hasService .Services "arc-friday-clickhouse" }} + clickhousetraces: + datasource: tcp://arc-friday-clickhouse:9000/?database=signoz_traces - {{- if hasService .Services "arc-logs" }} - loki: - endpoint: http://arc-logs:3100/loki/api/v1/push - labels: - attributes: - service.name: "service" - container.name: "container" - {{- end }} + signozclickhousemetrics: + dsn: tcp://arc-friday-clickhouse:9000/signoz_metrics - {{- if hasService .Services "arc-traces" }} - otlp/tempo: - endpoint: arc-traces:4317 - tls: - insecure: true - {{- end }} + clickhouselogsexporter: + dsn: tcp://arc-friday-clickhouse:9000/signoz_logs + timeout: 10s +{{- else }} + debug: + verbosity: basic +{{- end }} - logging: - loglevel: {{ .LogLevel | default "info" }} - sampling_initial: 5 - sampling_thereafter: 200 +extensions: + health_check: + endpoint: 0.0.0.0:13133 service: + extensions: [health_check] pipelines: traces: receivers: [otlp] - processors: [memory_limiter, batch{{ if .EnableSampling }}, probabilistic_sampler{{ end }}, resource] - exporters: [{{ if hasService .Services "arc-traces" }}otlp/tempo, {{ end }}logging] - + processors: [batch] +{{- if hasService .Services "arc-friday-clickhouse" }} + exporters: [clickhousetraces] +{{- else }} + exporters: [debug] +{{- end }} metrics: - receivers: [otlp{{ if .EnablePrometheus }}, prometheus{{ end }}] - processors: [memory_limiter, batch, resource] - exporters: [{{ if hasService .Services "arc-metrics" }}prometheusremotewrite, {{ end }}logging] - + receivers: [otlp, prometheus{{- if hasService .Services "arc-cache" }}, redis{{- end }}{{- if hasService .Services "arc-sql-db" }}, postgresql{{- end }}] + processors: [batch] +{{- if hasService .Services "arc-friday-clickhouse" }} + exporters: [signozclickhousemetrics] +{{- else }} + exporters: [debug] +{{- end }} logs: receivers: [otlp] - processors: [memory_limiter, batch, resource] - exporters: [{{ if hasService .Services "arc-logs" }}loki, {{ end }}logging] - - extensions: [health_check, pprof, zpages] - telemetry: - logs: - level: {{ .LogLevel | default "info" }} - metrics: - address: :8888 - -extensions: - health_check: - endpoint: :13133 - pprof: - endpoint: :1777 - zpages: - endpoint: :55679 + processors: [batch] +{{- if hasService .Services "arc-friday-clickhouse" }} + exporters: [clickhouselogsexporter] +{{- else }} + exporters: [debug] +{{- end }} diff --git a/pkg/scaffold/templates/realtime/livekit.yaml.tmpl b/pkg/scaffold/templates/realtime/livekit.yaml.tmpl new file mode 100644 index 0000000..75c9ad1 --- /dev/null +++ b/pkg/scaffold/templates/realtime/livekit.yaml.tmpl @@ -0,0 +1,22 @@ +# LiveKit Server configuration β€” development defaults +# Mirrors arc-platform/services/realtime/livekit.yaml +# API keys are static dev values. Production: replace with dynamic secrets from OpenBao. + +port: 7880 + +rtc: + tcp_port: 7882 + port_range_start: 50100 + port_range_end: 50200 + use_external_ip: false + node_ip: 127.0.0.1 + +redis: + address: arc-cache:6379 + +keys: + {{ index $.Env "LIVEKIT_API_KEY" | default "devkey" }}: {{ index $.Env "LIVEKIT_API_SECRET" | default "devsecret" }} + +logging: + level: info + pion_level: error diff --git a/pkg/ui/component/table.go b/pkg/ui/component/table.go index f6f8579..a8369f2 100644 --- a/pkg/ui/component/table.go +++ b/pkg/ui/component/table.go @@ -21,14 +21,19 @@ func NewTable(ctx *theme.Context, cols []bubblestable.Column, rows []bubblestabl styles.Header = styles.Header. Foreground(lipgloss.Color(ctx.Theme().Colors.Primary)). Bold(true). + BorderStyle(lipgloss.NormalBorder()). + BorderBottom(true). BorderForeground(lipgloss.Color(ctx.Theme().Colors.Border)) styles.Selected = styles.Selected. + Background(lipgloss.Color(ctx.Theme().Colors.Primary)). Foreground(lipgloss.Color(ctx.Theme().Colors.Background)). - Background(lipgloss.Color(ctx.Theme().Colors.Primary)) - - styles.Cell = styles.Cell. - Foreground(lipgloss.Color(ctx.Theme().Colors.Foreground)) + Bold(true) + // NOTE: Do NOT set styles.Cell.Foreground here. + // bubbles/table renders each cell with styles.Cell first (producing "\x1b[FGm cell \x1b[0m"), + // then wraps the joined row with styles.Selected.Render(). The inner \x1b[0m resets from + // each cell clear the Selected background, so only the first cell (status column) gets + // highlighted. Leaving Cell unstyled means no inner resets, and Selected background spans the full row. } t := bubblestable.New( diff --git a/pkg/ui/component/testdata/golden/hero-enterprise.golden b/pkg/ui/component/testdata/golden/hero-enterprise.golden index 8020715..c883d85 100644 --- a/pkg/ui/component/testdata/golden/hero-enterprise.golden +++ b/pkg/ui/component/testdata/golden/hero-enterprise.golden @@ -1,14 +1,13 @@ -╭────────────────────────────╠-β”‚ β”‚ -β”‚ β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•— β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•— β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•— β”‚ -β”‚ β–ˆβ–ˆβ•”β•β•β–ˆβ–ˆβ•—β–ˆβ–ˆβ•”β•β•β–ˆβ–ˆβ•—β–ˆβ–ˆβ•”β•β•β•β•β• β”‚ -β”‚ β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•‘β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•”β•β–ˆβ–ˆβ•‘ β”‚ -β”‚ β–ˆβ–ˆβ•”β•β•β–ˆβ–ˆβ•‘β–ˆβ–ˆβ•”β•β•β–ˆβ–ˆβ•—β–ˆβ–ˆβ•‘ β”‚ -β”‚ β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•‘β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•‘β•šβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•— β”‚ -β”‚ β•šβ•β• β•šβ•β•β•šβ•β• β•šβ•β• β•šβ•β•β•β•β•β• β”‚ -β”‚ β”‚ -β”‚ Agentic Reasoning Core β”‚ -β”‚ β”‚ -β”‚ β”‚ -β”‚ Agentic Reasoning Core β”‚ -╰────────────────────────────╯ \ No newline at end of file +╭───────────────────────────╠+β”‚ β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ β”‚ β”‚ +β”‚ β”‚ β–„β–€β–ˆ β–ˆβ–€β–ˆ β–ˆβ–€β–€ β”‚ β”‚ +β”‚ β”‚ β–ˆβ–€β–ˆ β–ˆβ–€β–„ β–ˆβ–„β–„ β”‚ β”‚ +β”‚ β”‚ β”‚ β”‚ +β”‚ β”‚ β—ˆ Enterprise β—ˆ β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ +β”‚ β”‚ +β”‚ Agentic Reasoning Core β”‚ +╰───────────────────────────╯ \ No newline at end of file diff --git a/pkg/ui/component/testdata/golden/table-bending.golden b/pkg/ui/component/testdata/golden/table-bending.golden index 469d1ea..3d271d8 100644 --- a/pkg/ui/component/testdata/golden/table-bending.golden +++ b/pkg/ui/component/testdata/golden/table-bending.golden @@ -1,4 +1,5 @@ Service Type Status +──────────────────────────────────────────────── postgres Data running redis Cache running mongodb Data stopped diff --git a/pkg/ui/component/testdata/golden/table-crystal.golden b/pkg/ui/component/testdata/golden/table-crystal.golden index 469d1ea..3d271d8 100644 --- a/pkg/ui/component/testdata/golden/table-crystal.golden +++ b/pkg/ui/component/testdata/golden/table-crystal.golden @@ -1,4 +1,5 @@ Service Type Status +──────────────────────────────────────────────── postgres Data running redis Cache running mongodb Data stopped diff --git a/pkg/ui/component/testdata/golden/table-enterprise.golden b/pkg/ui/component/testdata/golden/table-enterprise.golden index 469d1ea..3d271d8 100644 --- a/pkg/ui/component/testdata/golden/table-enterprise.golden +++ b/pkg/ui/component/testdata/golden/table-enterprise.golden @@ -1,4 +1,5 @@ Service Type Status +──────────────────────────────────────────────── postgres Data running redis Cache running mongodb Data stopped diff --git a/pkg/ui/component/testdata/golden/table-horcrux.golden b/pkg/ui/component/testdata/golden/table-horcrux.golden index 469d1ea..3d271d8 100644 --- a/pkg/ui/component/testdata/golden/table-horcrux.golden +++ b/pkg/ui/component/testdata/golden/table-horcrux.golden @@ -1,4 +1,5 @@ Service Type Status +──────────────────────────────────────────────── postgres Data running redis Cache running mongodb Data stopped diff --git a/pkg/ui/component/testdata/golden/table-jedi.golden b/pkg/ui/component/testdata/golden/table-jedi.golden index 469d1ea..3d271d8 100644 --- a/pkg/ui/component/testdata/golden/table-jedi.golden +++ b/pkg/ui/component/testdata/golden/table-jedi.golden @@ -1,4 +1,5 @@ Service Type Status +──────────────────────────────────────────────── postgres Data running redis Cache running mongodb Data stopped diff --git a/pkg/ui/component/testdata/golden/table-pirate.golden b/pkg/ui/component/testdata/golden/table-pirate.golden index 469d1ea..3d271d8 100644 --- a/pkg/ui/component/testdata/golden/table-pirate.golden +++ b/pkg/ui/component/testdata/golden/table-pirate.golden @@ -1,4 +1,5 @@ Service Type Status +──────────────────────────────────────────────── postgres Data running redis Cache running mongodb Data stopped diff --git a/pkg/ui/component/testdata/golden/table-pokemon.golden b/pkg/ui/component/testdata/golden/table-pokemon.golden index 469d1ea..3d271d8 100644 --- a/pkg/ui/component/testdata/golden/table-pokemon.golden +++ b/pkg/ui/component/testdata/golden/table-pokemon.golden @@ -1,4 +1,5 @@ Service Type Status +──────────────────────────────────────────────── postgres Data running redis Cache running mongodb Data stopped diff --git a/pkg/ui/component/testdata/golden/table-saiyan.golden b/pkg/ui/component/testdata/golden/table-saiyan.golden index 469d1ea..3d271d8 100644 --- a/pkg/ui/component/testdata/golden/table-saiyan.golden +++ b/pkg/ui/component/testdata/golden/table-saiyan.golden @@ -1,4 +1,5 @@ Service Type Status +──────────────────────────────────────────────── postgres Data running redis Cache running mongodb Data stopped diff --git a/pkg/ui/component/testdata/golden/table-shinobi.golden b/pkg/ui/component/testdata/golden/table-shinobi.golden index 469d1ea..3d271d8 100644 --- a/pkg/ui/component/testdata/golden/table-shinobi.golden +++ b/pkg/ui/component/testdata/golden/table-shinobi.golden @@ -1,4 +1,5 @@ Service Type Status +──────────────────────────────────────────────── postgres Data running redis Cache running mongodb Data stopped diff --git a/pkg/ui/component/testdata/golden/table-triforce.golden b/pkg/ui/component/testdata/golden/table-triforce.golden index 469d1ea..3d271d8 100644 --- a/pkg/ui/component/testdata/golden/table-triforce.golden +++ b/pkg/ui/component/testdata/golden/table-triforce.golden @@ -1,4 +1,5 @@ Service Type Status +──────────────────────────────────────────────── postgres Data running redis Cache running mongodb Data stopped diff --git a/pkg/ui/engine/context.go b/pkg/ui/engine/context.go index e823ae1..054d929 100644 --- a/pkg/ui/engine/context.go +++ b/pkg/ui/engine/context.go @@ -27,6 +27,9 @@ type ViewContext struct { // Backend provides access to catalog and store services. Backend Backend + // Profiles holds the list of enabled profile IDs for profile cycling. + Profiles []string + // Args holds any view-specific arguments (e.g., selected service ID). Args map[string]string } diff --git a/pkg/ui/engine/state.go b/pkg/ui/engine/state.go index 6e2d6a7..038bc13 100644 --- a/pkg/ui/engine/state.go +++ b/pkg/ui/engine/state.go @@ -100,10 +100,11 @@ func (sm *StateManager) ChangeSkin(skinID string) error { // BuildViewContext constructs a ViewContext with current state and given dimensions. func (sm *StateManager) BuildViewContext(width, height int) ViewContext { return ViewContext{ - Theme: sm.current, - Width: width, - Height: height, - Backend: sm.backend, - Args: map[string]string{}, + Theme: sm.current, + Width: width, + Height: height, + Backend: sm.backend, + Profiles: sm.loader.ListProfiles(), + Args: map[string]string{}, } } diff --git a/pkg/ui/theme/embedded/profiles/ai.yaml b/pkg/ui/theme/embedded/profiles/ai.yaml index 8541f83..878a560 100644 --- a/pkg/ui/theme/embedded/profiles/ai.yaml +++ b/pkg/ui/theme/embedded/profiles/ai.yaml @@ -1,3 +1,4 @@ +disabled: true id: ai name: AI Reasoning description: AI reasoning model stages for ML engineers and AI builders diff --git a/pkg/ui/theme/embedded/profiles/bending.yaml b/pkg/ui/theme/embedded/profiles/bending.yaml index 0c74368..3043aa5 100644 --- a/pkg/ui/theme/embedded/profiles/bending.yaml +++ b/pkg/ui/theme/embedded/profiles/bending.yaml @@ -1,3 +1,4 @@ +disabled: true id: bending name: Bending description: Avatar elemental mastery for element controllers diff --git a/pkg/ui/theme/embedded/profiles/crystal.yaml b/pkg/ui/theme/embedded/profiles/crystal.yaml index 99f1533..04bbe28 100644 --- a/pkg/ui/theme/embedded/profiles/crystal.yaml +++ b/pkg/ui/theme/embedded/profiles/crystal.yaml @@ -1,3 +1,4 @@ +disabled: true id: crystal name: Crystal description: Final Fantasy job classes for RPG fans diff --git a/pkg/ui/theme/embedded/profiles/default.yaml b/pkg/ui/theme/embedded/profiles/default.yaml index 9ad1ebe..421c254 100644 --- a/pkg/ui/theme/embedded/profiles/default.yaml +++ b/pkg/ui/theme/embedded/profiles/default.yaml @@ -6,21 +6,12 @@ tier_names: - Reason - Ultra Instinct theme_id: default -logo: | - ⚑ ⚑ ⚑ ⚑ ⚑ ⚑ ⚑ ⚑ ⚑ ⚑ +logo: |2 + β–—β–„β–– β–—β–„β–„β–– β–—β–„β–„β–– + β–β–Œ β–β–Œβ–β–Œ β–β–Œβ–β–Œ + β–β–›β–€β–œβ–Œβ–β–›β–€β–šβ––β–β–Œ + β–β–Œ β–β–Œβ–β–Œ β–β–Œβ–β–šβ–„β–„β–– - ______ _______ ______ - / \ / \ / \ - /$$$$$$ |$$$$$$$ |/$$$$$$ | - $$ |__$$ |$$ |__$$ |$$ | $$/ - $$ $$ |$$ $$< $$ | - $$$$$$$$ |$$$$$$$ |$$ | __ - $$ | $$ |$$ | $$ |$$ \__/ | - $$ | $$ |$$ | $$ | $$ $$/ - $$/ $$/ $$/ $$/ $$$$$$/ - - - ⚑ ⚑ ⚑ ⚑ ⚑ ⚑ ⚑ ⚑ ⚑ ⚑ - Agent Runtime Core + Intelligence Engine primary_color: "#6366F1" secondary_color: "#22D3EE" diff --git a/pkg/ui/theme/embedded/profiles/dev.yaml b/pkg/ui/theme/embedded/profiles/dev.yaml new file mode 100644 index 0000000..4e98f21 --- /dev/null +++ b/pkg/ui/theme/embedded/profiles/dev.yaml @@ -0,0 +1,18 @@ +id: dev +name: Dev +description: Ignite your workflow β€” from first spark to full inferno +tier_names: + - Debug + - Build + - Ship +theme_id: fire +logo: | + _______ ________ _________ + ___ |___ __ \__ ____/ + __ /| |__ /_/ /_ / + _ ___ |_ _, _/ / /___ + /_/ |_|/_/ |_| \____/ + + Ships Fast. Breaks Nothing. Built Different. +primary_color: "#FACC15" +secondary_color: "#EF4444" diff --git a/pkg/ui/theme/embedded/profiles/enterprise.yaml b/pkg/ui/theme/embedded/profiles/enterprise.yaml index 171f3d8..7c4cf43 100644 --- a/pkg/ui/theme/embedded/profiles/enterprise.yaml +++ b/pkg/ui/theme/embedded/profiles/enterprise.yaml @@ -7,13 +7,12 @@ tier_names: - Ultra theme_id: cyan-purple logo: | - β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•— β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•— β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•— - β–ˆβ–ˆβ•”β•β•β–ˆβ–ˆβ•—β–ˆβ–ˆβ•”β•β•β–ˆβ–ˆβ•—β–ˆβ–ˆβ•”β•β•β•β•β• - β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•‘β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•”β•β–ˆβ–ˆβ•‘ - β–ˆβ–ˆβ•”β•β•β–ˆβ–ˆβ•‘β–ˆβ–ˆβ•”β•β•β–ˆβ–ˆβ•—β–ˆβ–ˆβ•‘ - β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•‘β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•‘β•šβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•— - β•šβ•β• β•šβ•β•β•šβ•β• β•šβ•β• β•šβ•β•β•β•β•β• - - Agentic Reasoning Core -primary_color: "#2563EB" -secondary_color: "#7C3AED" + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ β”‚ + β”‚ β–„β–€β–ˆ β–ˆβ–€β–ˆ β–ˆβ–€β–€ β”‚ + β”‚ β–ˆβ–€β–ˆ β–ˆβ–€β–„ β–ˆβ–„β–„ β”‚ + β”‚ β”‚ + β”‚ β—ˆ Enterprise β—ˆ β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +primary_color: "#1E3A5F" +secondary_color: "#94A3B8" diff --git a/pkg/ui/theme/embedded/profiles/horcrux.yaml b/pkg/ui/theme/embedded/profiles/horcrux.yaml index 58a111b..b112551 100644 --- a/pkg/ui/theme/embedded/profiles/horcrux.yaml +++ b/pkg/ui/theme/embedded/profiles/horcrux.yaml @@ -1,3 +1,4 @@ +disabled: true id: horcrux name: Horcrux description: Harry Potter wizard ranks for magical developers diff --git a/pkg/ui/theme/embedded/profiles/jedi.yaml b/pkg/ui/theme/embedded/profiles/jedi.yaml index b7d2f0e..c4b6fbd 100644 --- a/pkg/ui/theme/embedded/profiles/jedi.yaml +++ b/pkg/ui/theme/embedded/profiles/jedi.yaml @@ -1,3 +1,4 @@ +disabled: true id: jedi name: Jedi description: Star Wars Force user ranks for sci-fi enthusiasts diff --git a/pkg/ui/theme/embedded/profiles/nebula.yaml b/pkg/ui/theme/embedded/profiles/nebula.yaml new file mode 100644 index 0000000..a30a384 --- /dev/null +++ b/pkg/ui/theme/embedded/profiles/nebula.yaml @@ -0,0 +1,16 @@ +id: nebula +name: Nebula +description: Cosmic runtime for those who think beyond the horizon +tier_names: + - Orbit + - Stellar + - Cosmic +theme_id: nebula +logo: | + β–„β––β–„β––β–„β–– + β–Œβ–Œβ–™β–˜β–Œ + β–›β–Œβ–Œβ–Œβ–™β–– + + Cosmic Runtime +primary_color: "#FF006E" +secondary_color: "#8338EC" diff --git a/pkg/ui/theme/embedded/profiles/pirate.yaml b/pkg/ui/theme/embedded/profiles/pirate.yaml index c1e250b..177e2c8 100644 --- a/pkg/ui/theme/embedded/profiles/pirate.yaml +++ b/pkg/ui/theme/embedded/profiles/pirate.yaml @@ -1,3 +1,4 @@ +disabled: true id: pirate name: Pirate description: One Piece bounty levels for adventurous coders diff --git a/pkg/ui/theme/embedded/profiles/pokemon.yaml b/pkg/ui/theme/embedded/profiles/pokemon.yaml index e2bb9b4..9cedecc 100644 --- a/pkg/ui/theme/embedded/profiles/pokemon.yaml +++ b/pkg/ui/theme/embedded/profiles/pokemon.yaml @@ -1,3 +1,4 @@ +disabled: true id: pokemon name: PokΓ©mon description: PokΓ©mon evolution stages for collectors diff --git a/pkg/ui/theme/embedded/profiles/saiyan.yaml b/pkg/ui/theme/embedded/profiles/saiyan.yaml index 9f944b5..81f0c94 100644 --- a/pkg/ui/theme/embedded/profiles/saiyan.yaml +++ b/pkg/ui/theme/embedded/profiles/saiyan.yaml @@ -1,3 +1,4 @@ +disabled: true id: saiyan name: Saiyan description: Dragon Ball Z transformation levels for power users diff --git a/pkg/ui/theme/embedded/profiles/shinobi.yaml b/pkg/ui/theme/embedded/profiles/shinobi.yaml index ab1f5ee..e44ba5b 100644 --- a/pkg/ui/theme/embedded/profiles/shinobi.yaml +++ b/pkg/ui/theme/embedded/profiles/shinobi.yaml @@ -1,3 +1,4 @@ +disabled: true id: shinobi name: Shinobi description: Naruto ninja ranks for stealthy developers diff --git a/pkg/ui/theme/embedded/profiles/triforce.yaml b/pkg/ui/theme/embedded/profiles/triforce.yaml index 0f0d294..8403ed4 100644 --- a/pkg/ui/theme/embedded/profiles/triforce.yaml +++ b/pkg/ui/theme/embedded/profiles/triforce.yaml @@ -1,3 +1,4 @@ +disabled: true id: triforce name: Triforce description: Legend of Zelda powers for adventurers diff --git a/pkg/ui/theme/embedded/themes/nebula.yaml b/pkg/ui/theme/embedded/themes/nebula.yaml new file mode 100644 index 0000000..be94b84 --- /dev/null +++ b/pkg/ui/theme/embedded/themes/nebula.yaml @@ -0,0 +1,57 @@ +name: nebula +description: Nebula - Cosmic magenta and violet, deep space energy +version: 1.0 +author: Arc CLI Team + +colors: + primary: "#FF006E" # Hot magenta + secondary: "#8338EC" # Deep violet + success: "#06D6A0" # Cosmic teal + error: "#EF233C" # Stellar red + warning: "#FFB703" # Pulsar gold + info: "#C77DFF" # Soft violet + foreground: "#F8F9FA" + background: "#0D0014" # Deep space black + muted: "#6D3A9C" # Muted violet + border: "#3D1A6E" # Dark violet border + banner_gradient: + - "#FF006E" + - "#E5007A" + - "#C9008A" + - "#A3009B" + - "#8338EC" + - "#7209B7" + - "#6D3A9C" + - "#560BAD" + - "#480CA8" + - "#3A0CA3" + - "#FF006E" + +styles: + bold: true + italic: false + underline: false + border_style: "rounded" + padding_top: 0 + padding_right: 0 + padding_bottom: 0 + padding_left: 0 + +symbols: + success: "✦" + error: "βœ—" + warning: "β—ˆ" + info: "✧" + spinner: + - "✦" + - "✧" + - "✩" + - "βœͺ" + - "✫" + - "✬" + - "✭" + - "βœ" + - "✭" + - "✬" + bullet: "β—†" + arrow: "β†’" diff --git a/pkg/ui/theme/loader.go b/pkg/ui/theme/loader.go index 279c171..aeee71f 100644 --- a/pkg/ui/theme/loader.go +++ b/pkg/ui/theme/loader.go @@ -199,11 +199,14 @@ func (l *Loader) ListThemes() []string { return ids } -// ListProfiles returns all available profile IDs. +// ListProfiles returns all enabled profile IDs. +// Profiles with disabled: true are excluded from the list but remain loadable by ID. func (l *Loader) ListProfiles() []string { ids := make([]string, 0, len(l.profiles)) - for id := range l.profiles { - ids = append(ids, id) + for id, p := range l.profiles { + if !p.Disabled { + ids = append(ids, id) + } } return ids } @@ -222,9 +225,15 @@ func (l *Loader) GetThemes() map[string]*Theme { return l.themes } -// GetProfiles returns all profiles. +// GetProfiles returns all enabled profiles. func (l *Loader) GetProfiles() map[string]*Profile { - return l.profiles + result := make(map[string]*Profile, len(l.profiles)) + for id, p := range l.profiles { + if !p.Disabled { + result[id] = p + } + } + return result } // GetSkins returns all skins. @@ -261,8 +270,8 @@ func (l *Loader) LoadContext(profileID, skinID string) (*Context, error) { // DefaultContext returns a context with default theme/profile/skin. // Falls back to the first available of each if defaults don't exist. func (l *Loader) DefaultContext() (*Context, error) { - // Try to load default profile (jedi is common in old system) - profileID := "jedi" + // Try to load default profile + profileID := "enterprise" if _, err := l.GetProfile(profileID); err != nil { // Fallback to first available profile profiles := l.ListProfiles() diff --git a/pkg/ui/theme/loader_test.go b/pkg/ui/theme/loader_test.go index fdb7396..15150c2 100644 --- a/pkg/ui/theme/loader_test.go +++ b/pkg/ui/theme/loader_test.go @@ -1,7 +1,7 @@ package theme_test -// T045: Golden file tests for theme system (10 profiles x 2 skins = 20 tests). -// Spec: 018-ui-design, Phase 2 Theme Switching. +// T045: Golden file tests for theme system (4 profiles x 2 skins = 8 tests). +// Spec: 020-pre-release, Profile Consolidation. // // Run with -update-golden to regenerate golden files: // @@ -22,18 +22,13 @@ import ( var updateGolden = flag.Bool("update-golden", false, "regenerate golden files instead of comparing") -// goldenProfiles lists the 10 standard profiles for golden tests (matches spec scope). +// goldenProfiles lists the active profiles for golden tests. +// Disabled profiles (jedi, pirate, etc.) are excluded; dev/nebula replace saiyan. var goldenProfiles = []string{ + "default", "enterprise", - "saiyan", - "jedi", - "pirate", - "horcrux", - "pokemon", - "shinobi", - "triforce", - "bending", - "crystal", + "dev", + "nebula", } // goldenSkins lists the 2 skins tested per profile. diff --git a/pkg/ui/theme/profile.go b/pkg/ui/theme/profile.go index 68ae62c..f4aa35c 100644 --- a/pkg/ui/theme/profile.go +++ b/pkg/ui/theme/profile.go @@ -3,12 +3,13 @@ package theme // Profile defines branding (logo, tier names, theme association). // Profiles are loaded from YAML files in embedded/profiles/ directory. type Profile struct { - ID string `yaml:"id"` // Unique identifier (e.g., "jedi") - Name string `yaml:"name"` // Display name (e.g., "Jedi") - Description string `yaml:"description"` // Optional description - TierNames []string `yaml:"tier_names"` // Exactly 3 tier names [Tier1, Tier2, Tier3] - Logo string `yaml:"logo"` // ASCII logo (multiline string) - ThemeID string `yaml:"theme_id"` // Reference to Theme.ID + ID string `yaml:"id"` // Unique identifier (e.g., "jedi") + Name string `yaml:"name"` // Display name (e.g., "Jedi") + Description string `yaml:"description"` // Optional description + TierNames []string `yaml:"tier_names"` // Exactly 3 tier names [Tier1, Tier2, Tier3] + Logo string `yaml:"logo"` // ASCII logo (multiline string) + ThemeID string `yaml:"theme_id"` // Reference to Theme.ID + Disabled bool `yaml:"disabled,omitempty"` // If true, profile is hidden from listings // Legacy fields from old profiles (for compatibility during migration) PrimaryColor string `yaml:"primary_color,omitempty"` // Will be removed after migration diff --git a/pkg/ui/theme/testdata/golden/themes/default-arc.golden b/pkg/ui/theme/testdata/golden/themes/default-arc.golden new file mode 100644 index 0000000..b3cc27f --- /dev/null +++ b/pkg/ui/theme/testdata/golden/themes/default-arc.golden @@ -0,0 +1,16 @@ +profile_id: default +profile_name: Default Profile +theme_id: default +skin_id: arc +skin_nav: sidebar +colors: + primary: #A8C7FA + secondary: #C2C7CF + accent: #C2C7CF + background: #1B1B1F + foreground: #E3E2E6 + success: #6DD58C + warning: #FFDC78 + error: #FFB4AB + muted: #8D9099 + border: #44474A diff --git a/pkg/ui/theme/testdata/golden/themes/default-minimal.golden b/pkg/ui/theme/testdata/golden/themes/default-minimal.golden new file mode 100644 index 0000000..a40abfd --- /dev/null +++ b/pkg/ui/theme/testdata/golden/themes/default-minimal.golden @@ -0,0 +1,16 @@ +profile_id: default +profile_name: Default Profile +theme_id: default +skin_id: minimal +skin_nav: tab-bar +colors: + primary: #A8C7FA + secondary: #C2C7CF + accent: #C2C7CF + background: #1B1B1F + foreground: #E3E2E6 + success: #6DD58C + warning: #FFDC78 + error: #FFB4AB + muted: #8D9099 + border: #44474A diff --git a/pkg/ui/theme/testdata/golden/themes/dev-arc.golden b/pkg/ui/theme/testdata/golden/themes/dev-arc.golden new file mode 100644 index 0000000..a399ffc --- /dev/null +++ b/pkg/ui/theme/testdata/golden/themes/dev-arc.golden @@ -0,0 +1,16 @@ +profile_id: dev +profile_name: Dev +theme_id: fire +skin_id: arc +skin_nav: sidebar +colors: + primary: #FF6D00 + secondary: #FFB300 + accent: #FFB300 + background: #1A0A00 + foreground: #FFFFFF + success: #69F0AE + warning: #FFD740 + error: #FF1744 + muted: #8D5F3A + border: #BF360C diff --git a/pkg/ui/theme/testdata/golden/themes/dev-minimal.golden b/pkg/ui/theme/testdata/golden/themes/dev-minimal.golden new file mode 100644 index 0000000..cde1090 --- /dev/null +++ b/pkg/ui/theme/testdata/golden/themes/dev-minimal.golden @@ -0,0 +1,16 @@ +profile_id: dev +profile_name: Dev +theme_id: fire +skin_id: minimal +skin_nav: tab-bar +colors: + primary: #FF6D00 + secondary: #FFB300 + accent: #FFB300 + background: #1A0A00 + foreground: #FFFFFF + success: #69F0AE + warning: #FFD740 + error: #FF1744 + muted: #8D5F3A + border: #BF360C diff --git a/pkg/ui/theme/testdata/golden/themes/dragon-arc.golden b/pkg/ui/theme/testdata/golden/themes/dragon-arc.golden new file mode 100644 index 0000000..c3887bf --- /dev/null +++ b/pkg/ui/theme/testdata/golden/themes/dragon-arc.golden @@ -0,0 +1,16 @@ +profile_id: dragon +profile_name: Dragon +theme_id: fire +skin_id: arc +skin_nav: sidebar +colors: + primary: #FF6D00 + secondary: #FFB300 + accent: #FFB300 + background: #1A0A00 + foreground: #FFFFFF + success: #69F0AE + warning: #FFD740 + error: #FF1744 + muted: #8D5F3A + border: #BF360C diff --git a/pkg/ui/theme/testdata/golden/themes/dragon-minimal.golden b/pkg/ui/theme/testdata/golden/themes/dragon-minimal.golden new file mode 100644 index 0000000..e120bcf --- /dev/null +++ b/pkg/ui/theme/testdata/golden/themes/dragon-minimal.golden @@ -0,0 +1,16 @@ +profile_id: dragon +profile_name: Dragon +theme_id: fire +skin_id: minimal +skin_nav: tab-bar +colors: + primary: #FF6D00 + secondary: #FFB300 + accent: #FFB300 + background: #1A0A00 + foreground: #FFFFFF + success: #69F0AE + warning: #FFD740 + error: #FF1744 + muted: #8D5F3A + border: #BF360C diff --git a/pkg/ui/theme/testdata/golden/themes/fire-arc.golden b/pkg/ui/theme/testdata/golden/themes/fire-arc.golden new file mode 100644 index 0000000..eafbdd5 --- /dev/null +++ b/pkg/ui/theme/testdata/golden/themes/fire-arc.golden @@ -0,0 +1,16 @@ +profile_id: fire +profile_name: Fire +theme_id: fire +skin_id: arc +skin_nav: sidebar +colors: + primary: #FF6D00 + secondary: #FFB300 + accent: #FFB300 + background: #1A0A00 + foreground: #FFFFFF + success: #69F0AE + warning: #FFD740 + error: #FF1744 + muted: #8D5F3A + border: #BF360C diff --git a/pkg/ui/theme/testdata/golden/themes/fire-minimal.golden b/pkg/ui/theme/testdata/golden/themes/fire-minimal.golden new file mode 100644 index 0000000..2b7853b --- /dev/null +++ b/pkg/ui/theme/testdata/golden/themes/fire-minimal.golden @@ -0,0 +1,16 @@ +profile_id: fire +profile_name: Fire +theme_id: fire +skin_id: minimal +skin_nav: tab-bar +colors: + primary: #FF6D00 + secondary: #FFB300 + accent: #FFB300 + background: #1A0A00 + foreground: #FFFFFF + success: #69F0AE + warning: #FFD740 + error: #FF1744 + muted: #8D5F3A + border: #BF360C diff --git a/pkg/ui/theme/testdata/golden/themes/nebula-arc.golden b/pkg/ui/theme/testdata/golden/themes/nebula-arc.golden new file mode 100644 index 0000000..d0ff843 --- /dev/null +++ b/pkg/ui/theme/testdata/golden/themes/nebula-arc.golden @@ -0,0 +1,16 @@ +profile_id: nebula +profile_name: Nebula +theme_id: nebula +skin_id: arc +skin_nav: sidebar +colors: + primary: #FF006E + secondary: #8338EC + accent: #8338EC + background: #0D0014 + foreground: #F8F9FA + success: #06D6A0 + warning: #FFB703 + error: #EF233C + muted: #6D3A9C + border: #3D1A6E diff --git a/pkg/ui/theme/testdata/golden/themes/nebula-minimal.golden b/pkg/ui/theme/testdata/golden/themes/nebula-minimal.golden new file mode 100644 index 0000000..3c8e46e --- /dev/null +++ b/pkg/ui/theme/testdata/golden/themes/nebula-minimal.golden @@ -0,0 +1,16 @@ +profile_id: nebula +profile_name: Nebula +theme_id: nebula +skin_id: minimal +skin_nav: tab-bar +colors: + primary: #FF006E + secondary: #8338EC + accent: #8338EC + background: #0D0014 + foreground: #F8F9FA + success: #06D6A0 + warning: #FFB703 + error: #EF233C + muted: #6D3A9C + border: #3D1A6E diff --git a/pkg/ui/view/config_overview.go b/pkg/ui/view/config_overview.go index e715869..aab6980 100644 --- a/pkg/ui/view/config_overview.go +++ b/pkg/ui/view/config_overview.go @@ -1,9 +1,9 @@ package view import ( - "fmt" "strings" + "github.com/charmbracelet/bubbles/spinner" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" @@ -12,27 +12,41 @@ import ( "github.com/arc-framework/arc-cli/pkg/ui/theme" ) -// ConfigOverview renders a 40/60 split-pane profile picker. -// Left: scrollable list of profile names rendered in their primary_color. -// Right: live preview card showing colors, tiers, and description. -// Pressing enter opens an inline confirm prompt before applying. +const ( + cfgFallbackMuted = "#666666" + cfgFallbackBorder = "#444444" + cfgFallbackSecondary = "#22D3EE" + cfgStatusActive = "Active β˜…" + cfgStatusAvail = "Available" +) + +// ConfigOverview is the Profile Studio β€” a two-pane layout where the left panel +// lists available profiles (each with a color swatch + tier names) and the right +// panel shows a rich live preview: hero logo, full 10-color palette, tier progression +// boxes, and profile metadata. type ConfigOverview struct { ctx engine.ViewContext profiles []*theme.Profile + loader *theme.Loader // loads any profile's full color palette cursor int confirming bool confirmTarget *theme.Profile split component.SplitPane + tierSpinner spinner.Model + spinnerDone bool + spinnerTicks int + ready bool } // NewConfigOverview creates the Config view. -// profiles must be pre-sorted (use sortedProfiles in root.go). -func NewConfigOverview(profiles []*theme.Profile) *ConfigOverview { +// loader may be nil; in that case the preview falls back to the active theme's colors. +func NewConfigOverview(profiles []*theme.Profile, loader *theme.Loader) *ConfigOverview { return &ConfigOverview{ profiles: profiles, - split: component.SplitPane{Ratio: 0.40}, + loader: loader, + split: component.SplitPane{Ratio: 0.38, CollapseWidth: 60}, } } @@ -42,8 +56,6 @@ func (v *ConfigOverview) OnEnter(ctx engine.ViewContext) tea.Cmd { v.ctx = ctx v.confirming = false v.confirmTarget = nil - - // Restore cursor to the currently-active profile. v.cursor = 0 if tc := ctx.Theme; tc != nil { activeID := tc.Profile().ID @@ -54,8 +66,19 @@ func (v *ConfigOverview) OnEnter(ctx engine.ViewContext) tea.Cmd { } } } + + // Start tier spinner β€” animates for ~2s then locks tier 1 as bold. + sp := spinner.New() + sp.Spinner = spinner.Points + if ctx.Theme != nil { + sp.Style = lipgloss.NewStyle().Foreground(lipgloss.Color(ctx.Theme.Theme().Colors.Warning)) + } + v.tierSpinner = sp + v.spinnerDone = false + v.spinnerTicks = 0 + v.ready = true - return nil + return v.tierSpinner.Tick } func (v *ConfigOverview) OnExit() tea.Cmd { return nil } @@ -65,11 +88,24 @@ func (v *ConfigOverview) Update(msg tea.Msg) (engine.View, tea.Cmd) { return v, nil } + // Drive the tier spinner until done. + if !v.spinnerDone { + if _, ok := msg.(spinner.TickMsg); ok { + v.spinnerTicks++ + if v.spinnerTicks >= 20 { // ~2 s at bubbles' default 125ms interval + v.spinnerDone = true + return v, nil + } + var cmd tea.Cmd + v.tierSpinner, cmd = v.tierSpinner.Update(msg) + return v, cmd + } + } + keyMsg, ok := msg.(tea.KeyMsg) if !ok { return v, nil } - key := keyMsg.String() if v.confirming { @@ -103,7 +139,6 @@ func (v *ConfigOverview) Update(msg tea.Msg) (engine.View, tea.Cmd) { v.confirmTarget = v.profiles[v.cursor] } } - return v, nil } @@ -114,7 +149,6 @@ func (v *ConfigOverview) View() string { if !v.ready { return "" } - w := v.ctx.Width if w <= 0 { w = 120 @@ -123,23 +157,18 @@ func (v *ConfigOverview) View() string { leftContent := v.renderProfileList(leftW) var rightContent string - if rightW > 0 && len(v.profiles) > 0 { - rightContent = v.renderPreviewCard(v.profiles[v.cursor], rightW) + if rightW > 0 && len(v.profiles) > 0 && v.cursor < len(v.profiles) { + spinStr := "" + if !v.spinnerDone { + spinStr = v.tierSpinner.View() + } + rightContent = v.renderPreviewCard(v.profiles[v.cursor], rightW, spinStr) } - base := v.split.Render(leftContent, rightContent, w) if v.confirming && v.confirmTarget != nil { - var muted string - if v.ctx.Theme != nil { - muted = v.ctx.Theme.Theme().Colors.Muted - } - prompt := lipgloss.NewStyle(). - Foreground(lipgloss.Color(muted)). - Render(fmt.Sprintf("Apply %q? [y/N]", v.confirmTarget.Name)) - return lipgloss.JoinVertical(lipgloss.Left, base, "", prompt) + return cfgOverlayConfirm(base, v.confirmTarget, v.ctx, w) } - return base } @@ -152,113 +181,463 @@ func (v *ConfigOverview) Keybindings() []engine.KeyBinding { } } -// ── render helpers ──────────────────────────────────────────────────────────── +// cfgColors loads the ColorSet for p, falling back to the active theme if the +// loader is unavailable or the theme ID cannot be resolved. +func (v *ConfigOverview) cfgColors(p *theme.Profile) *theme.ColorSet { + if v.loader != nil && p.ThemeID != "" { + if t, err := v.loader.GetTheme(p.ThemeID); err == nil { + c := t.Colors + return &c + } + } + if v.ctx.Theme != nil { + c := v.ctx.Theme.Theme().Colors + return &c + } + return nil +} + +// ── left panel ──────────────────────────────────────────────────────────────── func (v *ConfigOverview) renderProfileList(width int) string { if len(v.profiles) == 0 { return lipgloss.NewStyle().Width(width).Render("No profiles available.") } - var activeID string - var muted string + var activeID, activeMuted string if v.ctx.Theme != nil { activeID = v.ctx.Theme.Profile().ID - muted = v.ctx.Theme.Theme().Colors.Muted + activeMuted = v.ctx.Theme.Theme().Colors.Muted } + mutedStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(activeMuted)) - mutedStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(muted)) - lines := make([]string, 0, len(v.profiles)) - + var entries []string for i, p := range v.profiles { - prefix := listPrefix - if i == v.cursor { - prefix = listCursor + primary := p.PrimaryColor + secondary := p.SecondaryColor + if primary == "" { + primary = activeMuted + } + if secondary == "" { + secondary = activeMuted } - color := p.PrimaryColor - if color == "" { - color = muted + isSelected := i == v.cursor + isActive := p.ID == activeID + + // Cursor glyph (2 chars) + cursorGlyph := cursorBlank + if isSelected { + cursorGlyph = lipgloss.NewStyle().Foreground(lipgloss.Color(primary)).Render("β–Ά ") } - nameStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(color)) - name := prefix + nameStyle.Render(p.Name) + // 2-char color block filled with the profile's primary color + colorBlock := lipgloss.NewStyle(). + Background(lipgloss.Color(primary)). + Foreground(lipgloss.Color(primary)). + Render("β–ˆβ–ˆ") - if p.ID == activeID { - name += " " + mutedStyle.Render("[active]") + // Profile name β€” bold when selected + nameStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(primary)) + if isSelected { + nameStyle = nameStyle.Bold(true) } - lines = append(lines, name) - } + // Active mark in secondary color + activeBadge := "" + if isActive { + activeBadge = " " + lipgloss.NewStyle(). + Foreground(lipgloss.Color(secondary)). + Bold(true). + Render("β˜… active") + } - content := strings.Join(lines, "\n") - return lipgloss.NewStyle().Width(width).Render(content) -} + // Line 1: [cursor][swatch] name β˜… active + nameLine := lipgloss.JoinHorizontal(lipgloss.Top, + cursorGlyph, colorBlock, " ", nameStyle.Render(p.Name), activeBadge, + ) -// swatch returns a 2-char colored block for the given hex color. -func swatch(hex string) string { - if hex == "" { - return " " + // Line 2: tier names indented in muted + tierLine := "" + if len(p.TierNames) > 0 { + tierLine = " " + mutedStyle.Render(strings.Join(p.TierNames, " Β· ")) + } + + entry := nameLine + if tierLine != "" { + entry += "\n" + tierLine + } + entries = append(entries, entry) } - return lipgloss.NewStyle(). - Background(lipgloss.Color(hex)). - Foreground(lipgloss.Color(hex)). - Render(" ") + + content := strings.Join(entries, "\n\n") + return lipgloss.NewStyle().Width(width).Padding(3, 1).Render(content) } -func (v *ConfigOverview) renderPreviewCard(p *theme.Profile, width int) string { +// ── right panel ─────────────────────────────────────────────────────────────── + +func (v *ConfigOverview) renderPreviewCard(p *theme.Profile, width int, spinStr string) string { if p == nil { return "" } + colors := v.cfgColors(p) + pal := cfgResolveColors(p, colors) + + // innerW: usable content width inside the outer Padding(1,2) container. + // Outer: Width(width-4).Padding(1,2) β†’ total = width-4+4 = width βœ“ + innerW := width - 4 + if innerW < 20 { + innerW = 20 + } + + var sections []string + + // ── HERO: logo + profile name (no sub-box β€” flush with all other sections) ── + if p.Logo != "" { + gradientLogo := cfgLogoGradient(p.Logo, pal.primary, pal.secondary) + + nameStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(pal.primary)).Bold(true) + descStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(pal.muted)) + heroFooter := lipgloss.JoinHorizontal(lipgloss.Top, + nameStyle.Render(p.Name), + " ", + descStyle.Render(p.Description), + ) + sections = append(sections, gradientLogo, "", heroFooter) + } + + // ── COLOR PALETTE ───────────────────────────────────────────────────────── + if colors != nil { + sections = append(sections, "", cfgDivider("Color Palette", innerW, pal.border)) + + colW := (innerW - 4) / 2 // two columns with a 4-char gutter between them + colorPairs := [][2][2]string{ + {{"Primary", pal.primary}, {"Secondary", pal.secondary}}, + {{"Accent", pal.accent}, {"Foreground", pal.fg}}, + {{"Background", pal.bg}, {"Success", pal.success}}, + {{"Warning", pal.warning}, {"Error", pal.errC}}, + {{"Muted", pal.muted}, {"Border", pal.border}}, + } + for _, pair := range colorPairs { + left := cfgColorEntry(pair[0][0], pair[0][1], colW) + right := cfgColorEntry(pair[1][0], pair[1][1], colW) + sections = append(sections, lipgloss.JoinHorizontal(lipgloss.Top, left, " ", right)) + } + } + + // ── TIER PROGRESSION ───────────────────────────────────────────────────── + if len(p.TierNames) == 3 { + sections = append(sections, + "", cfgDivider("Tier Progression [✨ coming soon]", innerW, pal.border), + cfgTierRow(p.TierNames, pal, innerW, spinStr), + ) + } + + // ── PROFILE META ────────────────────────────────────────────────────────── + sections = append(sections, "", cfgDivider("Profile Info", innerW, pal.border)) var activeID string - var muted string if v.ctx.Theme != nil { activeID = v.ctx.Theme.Profile().ID - muted = v.ctx.Theme.Theme().Colors.Muted } - mutedStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(muted)) + statusLabel, statusColor := cfgStatusAvail, pal.success + if p.ID == activeID { + statusLabel, statusColor = cfgStatusActive, pal.primary + } + if pal.success == "" { + statusColor = pal.secondary + } - primaryColor := p.PrimaryColor - if primaryColor == "" { - primaryColor = muted + labelStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(pal.muted)).Width(10) + valueStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(pal.fg)) + statusStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(statusColor)).Bold(true) + + metaRows := []string{ + lipgloss.JoinHorizontal(lipgloss.Top, + labelStyle.Render("ID"), valueStyle.Render(p.ID), + " ", + labelStyle.Render("Theme"), valueStyle.Render(p.ThemeID), + ), + lipgloss.JoinHorizontal(lipgloss.Top, + labelStyle.Render("Status"), statusStyle.Render(statusLabel), + ), } + sections = append(sections, strings.Join(metaRows, "\n")) - cardStyle := lipgloss.NewStyle().Width(width).Padding(0, 1) + content := strings.Join(sections, "\n") + return lipgloss.NewStyle(). + Width(width-4). + Padding(1, 2). + Render(content) +} - // ── Header ────────────────────────────────────────────────────────────── - nameStyle := lipgloss.NewStyle(). - Foreground(lipgloss.Color(primaryColor)). - Bold(true) +// ── helpers ─────────────────────────────────────────────────────────────────── + +// cfgLogoGradient renders a logo with alternating primary/secondary colors per line, +// skipping blank lines in the color cycle so empty lines stay truly empty. +func cfgLogoGradient(logo, primary, secondary string) string { + pStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(primary)) + sStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(secondary)) + lines := strings.Split(strings.TrimRight(logo, "\n"), "\n") + out := make([]string, 0, len(lines)) + colorIdx := 0 + for _, l := range lines { + if strings.TrimSpace(l) == "" { + out = append(out, "") + continue + } + if colorIdx%2 == 0 { + out = append(out, pStyle.Render(l)) + } else { + out = append(out, sStyle.Render(l)) + } + colorIdx++ + } + return strings.Join(out, "\n") +} - header := nameStyle.Render(p.Name) - if p.ID == activeID { - header += " " + mutedStyle.Render("β˜… Currently Active") +// cfgTierRow renders the three-box tier progression. +// While spinStr is non-empty (first ~2 s): all three boxes show the spinner frame. +// Once done: box 0 gets a filled background (active), boxes 1/2 show ⊘ + faint (locked). +// All boxes are single-line so JoinHorizontal(Center) places the arrow at the content row. +func cfgTierRow(tierNames []string, pal cfgPalette, innerW int, spinStr string) string { + tierBoxInnerW := (innerW-14)/3 - 6 // 14 = 2 arrows Γ— 7 chars; 6 = padding+border + if tierBoxInnerW < 4 { + tierBoxInnerW = 4 } - // ── Color swatches ─────────────────────────────────────────────────────── - swatchLine := swatch(p.PrimaryColor) + " " + mutedStyle.Render(p.PrimaryColor) - if p.SecondaryColor != "" { - swatchLine += " " + swatch(p.SecondaryColor) + " " + mutedStyle.Render(p.SecondaryColor) + availColor := pal.warning + if availColor == "" { + availColor = pal.primary } - // ── Tiers ──────────────────────────────────────────────────────────────── - tierLine := "" - if len(p.TierNames) > 0 { - tierLine = mutedStyle.Render(strings.Join(p.TierNames, " / ")) + boxes := make([]string, 3) + for i, name := range tierNames { + var content string + var boxStyle lipgloss.Style + + if spinStr != "" { + // Loading: all three boxes show the spinner, centered. + content = lipgloss.NewStyle().Width(tierBoxInnerW).Align(lipgloss.Center).Render(spinStr) + boxStyle = lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color(pal.muted)). + Foreground(lipgloss.Color(pal.muted)). + Width(tierBoxInnerW). + Padding(0, 2) + } else { + switch i { + case 0: + // Tier 1 β€” active: filled background, bold name. + content = svcWordWrap(name, tierBoxInnerW) + boxStyle = lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color(availColor)). + Background(lipgloss.Color(availColor)). + Foreground(lipgloss.Color(pal.bg)). + Bold(true). + Width(tierBoxInnerW). + Padding(0, 2) + default: + // Tiers 2+ β€” locked: ✨ prefix, faint, muted border. + content = svcWordWrap("✨ "+name, tierBoxInnerW) + boxStyle = lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color(pal.muted)). + Foreground(lipgloss.Color(pal.muted)). + Faint(true). + Width(tierBoxInnerW). + Padding(0, 2) + } + } + boxes[i] = boxStyle.Render(content) } - // ── Description ────────────────────────────────────────────────────────── - innerW := width - 2 // account for padding - if innerW < 10 { - innerW = 10 + arrow := lipgloss.NewStyle().Foreground(lipgloss.Color(pal.muted)).Render(" ──▢ ") + return lipgloss.JoinHorizontal(lipgloss.Center, boxes[0], arrow, boxes[1], arrow, boxes[2]) +} + +// cfgDivider renders a titled horizontal rule: "── Title ─────────────────────" +func cfgDivider(title string, width int, borderColor string) string { + prefix := "── " + title + " " + remaining := width - len([]rune(prefix)) + if remaining < 2 { + remaining = 2 } - desc := lipgloss.NewStyle().Width(innerW).Render(p.Description) + line := prefix + strings.Repeat("─", remaining) + return lipgloss.NewStyle().Foreground(lipgloss.Color(borderColor)).Render(line) +} - sections := []string{header, swatchLine} - if tierLine != "" { - sections = append(sections, tierLine) +// cfgPalette holds the resolved 10-color palette used to render the preview card. +type cfgPalette struct { + primary, secondary, muted, border string + accent, success, warning, errC string + fg, bg string +} + +// cfgResolveColors extracts all 10 colors from a ColorSet, falling back to the +// profile's primary/secondary + sensible defaults when no theme is available. +func cfgResolveColors(p *theme.Profile, colors *theme.ColorSet) cfgPalette { + var pal cfgPalette + if colors != nil { + pal.primary = colors.Primary + pal.secondary = colors.Secondary + pal.muted = colors.Muted + pal.border = colors.Border + pal.accent = colors.Accent + pal.success = colors.Success + pal.warning = colors.Warning + pal.errC = colors.Error + pal.fg = colors.Foreground + pal.bg = colors.Background + } else { + pal.primary = p.PrimaryColor + pal.secondary = p.SecondaryColor + pal.muted = cfgFallbackMuted + pal.border = cfgFallbackBorder + } + if pal.primary == "" { + pal.primary = "#6366F1" + } + if pal.secondary == "" { + pal.secondary = cfgFallbackSecondary + } + if pal.muted == "" { + pal.muted = cfgFallbackMuted + } + if pal.border == "" { + pal.border = cfgFallbackBorder } - sections = append(sections, "", desc) + return pal +} + +// cfgColorEntry renders: β–ˆβ–ˆ LabelName #RRGGBB within a fixed column width. +func cfgColorEntry(label, hex string, width int) string { + if hex == "" { + hex = "#000000" + } + colorBlock := lipgloss.NewStyle(). + Background(lipgloss.Color(hex)). + Foreground(lipgloss.Color(hex)). + Render("β–ˆβ–ˆ") + labelStr := lipgloss.NewStyle().Width(11).Render(label) + hexStr := lipgloss.NewStyle().Foreground(lipgloss.Color(hex)).Render(hex) + entry := lipgloss.JoinHorizontal(lipgloss.Top, colorBlock, " ", labelStr, hexStr) + return lipgloss.NewStyle().Width(width).Render(entry) +} - return cardStyle.Render(strings.Join(sections, "\n")) +// cfgOverlayConfirm renders a centered modal confirmation dialog over `base`. +// It uses the target profile's primary color for the border and highlights, +// so the dialog visually echoes the theme about to be applied. +func cfgOverlayConfirm(base string, p *theme.Profile, ctx engine.ViewContext, totalW int) string { + // Resolve colors from the active theme (the preview card uses the loader, + // but for the dialog the active theme palette is fine as a fallback). + var primary, secondary, muted, bg, fg string + if ctx.Theme != nil { + c := ctx.Theme.Theme().Colors + primary = c.Primary + secondary = c.Secondary + muted = c.Muted + bg = c.Background + fg = c.Foreground + } + if primary == "" { + primary = cfgFallbackMuted + } + if secondary == "" { + secondary = cfgFallbackSecondary + } + if muted == "" { + muted = cfgFallbackMuted + } + + // Use profile's own primary/secondary when available so the dialog matches + // the profile the user is about to apply. + if p.PrimaryColor != "" { + primary = p.PrimaryColor + } + if p.SecondaryColor != "" { + secondary = p.SecondaryColor + } + + // ── dialog content ──────────────────────────────────────────────────────── + iconStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(secondary)).Bold(true) + nameStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(primary)).Bold(true) + mutedStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(muted)) + fgStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(fg)) + + icon := iconStyle.Render("β—ˆ") + question := lipgloss.JoinHorizontal(lipgloss.Top, + icon, " ", + fgStyle.Render("Apply profile "), + nameStyle.Render(p.Name), + fgStyle.Render("?"), + ) + + // Key hints as styled pill-like badges + yKey := lipgloss.NewStyle(). + Foreground(lipgloss.Color(bg)). + Background(lipgloss.Color(primary)). + Bold(true). + Padding(0, 1). + Render("Y") + nKey := lipgloss.NewStyle(). + Foreground(lipgloss.Color(bg)). + Background(lipgloss.Color(muted)). + Bold(true). + Padding(0, 1). + Render("N") + hint := lipgloss.JoinHorizontal(lipgloss.Top, + yKey, mutedStyle.Render(" confirm "), + nKey, mutedStyle.Render(" cancel"), + ) + + body := lipgloss.JoinVertical(lipgloss.Left, question, "", hint) + + dialogW := 40 + if totalW < dialogW+4 { + dialogW = totalW - 4 + } + + dialog := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color(primary)). + Padding(1, 3). + Width(dialogW). + Render(body) + + // ── overlay: center the dialog over the base canvas ─────────────────────── + baseH := strings.Count(base, "\n") + 1 + centered := lipgloss.PlaceHorizontal(totalW, lipgloss.Center, dialog) + dialogH := strings.Count(dialog, "\n") + 1 + + baseLines := strings.Split(base, "\n") + insertAt := (baseH - dialogH) / 2 + if insertAt < 0 { + insertAt = 0 + } + + // Overwrite base lines with the dialog lines at the vertical center. + dialogLines := strings.Split(centered, "\n") + out := make([]string, len(baseLines)) + copy(out, baseLines) + for i, dl := range dialogLines { + row := insertAt + i + if row < len(out) { + // Pad the base line to totalW so the dialog replaces cleanly. + bl := out[row] + blW := lipgloss.Width(bl) + if blW < totalW { + bl += strings.Repeat(" ", totalW-blW) + } + dlW := lipgloss.Width(dl) + // Place dialog line centered over the base line. + startCol := (totalW - dlW) / 2 + if startCol < 0 { + startCol = 0 + } + out[row] = bl[:startCol] + dl + bl[min(startCol+dlW, len(bl)):] + } + } + return strings.Join(out, "\n") } diff --git a/pkg/ui/view/const.go b/pkg/ui/view/const.go index 9ce0973..8d2acea 100644 --- a/pkg/ui/view/const.go +++ b/pkg/ui/view/const.go @@ -16,15 +16,18 @@ const ( // keyEsc is the key string for the Escape key used in keybindings. keyEsc = "esc" + // deleteWord is the confirmation word required to wipe an existing workspace. + deleteWord = "delete" + + // cursorBlank is the two-space glyph used when no cursor arrow is shown. + cursorBlank = " " + // checkMark is the βœ“ character used to indicate a value is present or OK. checkMark = "βœ“" // crossFail is the βœ— character used to indicate a failure. crossFail = "βœ—" - // listPrefix is the two-space indent used for non-selected list items. - listPrefix = " " - - // listCursor is the β–Ά cursor prefix used for the selected list item. - listCursor = "β–Ά " + // osDarwin is the GOOS value for macOS, used for OS-specific commands. + osDarwin = "darwin" ) diff --git a/pkg/ui/view/golden_test.go b/pkg/ui/view/golden_test.go index f833289..1490110 100644 --- a/pkg/ui/view/golden_test.go +++ b/pkg/ui/view/golden_test.go @@ -127,7 +127,7 @@ func setupServicesView(t *testing.T, ctx engine.ViewContext, svcs []*catalog.Ser w = 120 } leftW, _ := v.split.Widths(w) - cols := servicesTableColumns(leftW) + cols := servicesTableColumns(leftW, maxCodenameWidth(v.services)) tableH := ctx.Height - 2 if tableH < 5 { tableH = 5 @@ -166,7 +166,7 @@ func testProfiles(t *testing.T) []*theme.Profile { loader, err := theme.NewLoader() require.NoError(t, err) m := loader.GetProfiles() - ids := []string{"enterprise", "saiyan", "pokemon", "jedi", "pirate"} + ids := []string{"default", "enterprise", "dev", "nebula"} out := make([]*theme.Profile, 0, len(ids)) for _, id := range ids { if p, ok := m[id]; ok { @@ -179,7 +179,7 @@ func testProfiles(t *testing.T) []*theme.Profile { func setupConfigView(t *testing.T, ctx engine.ViewContext, cursor int, confirming bool) *ConfigOverview { t.Helper() profiles := testProfiles(t) - v := NewConfigOverview(profiles) + v := NewConfigOverview(profiles, nil) v.ctx = ctx v.cursor = cursor v.confirming = confirming diff --git a/pkg/ui/view/home.go b/pkg/ui/view/home.go index e47cd4d..c4dee88 100644 --- a/pkg/ui/view/home.go +++ b/pkg/ui/view/home.go @@ -4,7 +4,6 @@ import ( "context" "encoding/json" "fmt" - "math" "net" "net/http" "os" @@ -16,7 +15,6 @@ import ( "github.com/charmbracelet/bubbles/progress" tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/harmonica" "github.com/charmbracelet/lipgloss" "github.com/arc-framework/arc-cli/internal/branding" @@ -230,8 +228,6 @@ type homeSysInfoMsg struct { info *branding.SystemInfo } -type homeSpringTickMsg struct{} - type homeClockTickMsg struct{} type homeServiceCheckMsg struct { @@ -239,10 +235,6 @@ type homeServiceCheckMsg struct { llmStudio string } -func homeSpringTickCmd() tea.Cmd { - return tea.Tick(time.Second/60, func(time.Time) tea.Msg { return homeSpringTickMsg{} }) -} - func homeClockTickCmd() tea.Cmd { return tea.Tick(time.Second, func(time.Time) tea.Msg { return homeClockTickMsg{} }) } @@ -460,7 +452,7 @@ func homeProbePort(addr, binary string) string { func homeOpenURL(url string) { var cmd *exec.Cmd switch runtime.GOOS { - case "darwin": + case osDarwin: cmd = exec.Command("open", url) case "windows": cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", url) @@ -497,9 +489,6 @@ type HomeView struct { llmStatus string spinner component.Spinner memBar progress.Model - spring harmonica.Spring - springPos float64 - springVel float64 now time.Time ready bool } @@ -515,9 +504,6 @@ func (v *HomeView) OnEnter(ctx engine.ViewContext) tea.Cmd { v.ctx = ctx v.cmds = homeCmds() v.cursor = 0 - v.springPos = 0 - v.springVel = 0 - v.spring = harmonica.NewSpring(harmonica.FPS(60), 6.0, 0.5) v.loadingSys = true v.dockerStatus = homeSvcChecking v.llmStatus = homeSvcChecking @@ -543,7 +529,6 @@ func (v *HomeView) OnEnter(ctx engine.ViewContext) tea.Cmd { homeCheckServicesCmd(), homeFetchGitHubCmd(), homeClockTickCmd(), - homeSpringTickCmd(), ) } @@ -576,7 +561,7 @@ func (v *HomeView) handleKeyNav(delta int) tea.Cmd { if next >= 0 && next < len(v.cmds) { v.cursor = next v.rightScroll = 0 - return homeSpringTickCmd() + return nil } return nil } @@ -627,10 +612,33 @@ func (v *HomeView) handleKey(m tea.KeyMsg) tea.Cmd { case "o": return v.handleOpenInBrowser() + + case "p": + return v.handleCycleProfile() } return nil } +// handleCycleProfile advances to the next enabled profile and re-themes the UI. +func (v *HomeView) handleCycleProfile() tea.Cmd { + profiles := v.ctx.Profiles + if len(profiles) < 2 { + return nil + } + currentID := "" + if v.ctx.Theme != nil && v.ctx.Theme.Profile() != nil { + currentID = v.ctx.Theme.Profile().ID + } + next := profiles[0] + for i, id := range profiles { + if id == currentID { + next = profiles[(i+1)%len(profiles)] + break + } + } + return func() tea.Msg { return engine.StateChangedMsg{ProfileID: next} } +} + func (v *HomeView) Update(msg tea.Msg) (engine.View, tea.Cmd) { var cmds []tea.Cmd @@ -651,12 +659,6 @@ func (v *HomeView) Update(msg tea.Msg) (engine.View, tea.Cmd) { v.prFetchOK = m.fetchOK v.prStatusCode = m.prStatusCode - case homeSpringTickMsg: - v.springPos, v.springVel = v.spring.Update(v.springPos, v.springVel, float64(v.cursor)) - if math.Abs(v.springPos-float64(v.cursor)) > 0.01 || math.Abs(v.springVel) > 0.01 { - cmds = append(cmds, homeSpringTickCmd()) - } - case homeClockTickMsg: v.now = time.Now() cmds = append(cmds, homeClockTickCmd()) @@ -918,7 +920,7 @@ func (v *HomeView) renderCenter(tc *theme.Context, h int) string { focused := lipgloss.NewStyle().Foreground(primary).Bold(true) dimmed := lipgloss.NewStyle().Foreground(muted) - visualRow := int(math.Round(v.springPos)) + visualRow := v.cursor if visualRow < 0 { visualRow = 0 } @@ -1186,8 +1188,107 @@ func homeRenderPRRow(i int, pr homePRItem, selIdx int, sty homeTableSty, prColSt }, sty.sep) } +// homePREmptyState renders a centered "all clear" card inside the PR table body +// when there are no open pull requests. It uses lipgloss rounded borders and +// PlaceHorizontal to visually center content within the merged cell width. +func homePREmptyState(prColStt, prColNum, prColAge, prColTTL int, sty homeTableSty) []string { + mergedW := prColNum + 1 + prColAge + 1 + prColTTL + cellInner := mergedW - 2 // homeCell adds 1 space on each side + + // Build the card content: bold accent check + muted hint + badge := lipgloss.NewStyle().Foreground(sty.accentC).Bold(true).Render("βœ“ all clear") + hint := sty.muted.Render("no open pull requests") + cardBody := badge + "\n" + hint + + // Wrap in a rounded border, colored in accent + cardW := cellInner - 6 // leave 3-char margin each side inside the cell + if cardW < 12 { + cardW = 12 + } + card := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(sty.accentC). + Padding(0, 2). + Width(cardW). + Render(cardBody) + + // Split the rendered card into individual lines and center each one + // horizontally inside the merged cell using lipgloss.PlaceHorizontal. + cardLines := strings.Split(card, "\n") + blankCell := " " + strings.Repeat(" ", cellInner) + " " + blankRow := homeTableRow([]string{sty.muted.Render(homeCell("", prColStt)), blankCell}, sty.sep) + + rows := make([]string, 0, len(cardLines)+1) + rows = append(rows, blankRow) + for _, line := range cardLines { + centered := lipgloss.PlaceHorizontal(cellInner, lipgloss.Center, line) + rows = append(rows, homeTableRow([]string{ + sty.muted.Render(homeCell("", prColStt)), + " " + centered + " ", + }, sty.sep)) + } + rows = append(rows, blankRow) + return rows +} + +// homePRErrorState renders a centered error card inside the PR table body using a +// lipgloss rounded border, styled in the warning/danger color. +func homePRErrorState(prColStt, prColNum, prColAge, prColTTL int, sty homeTableSty, reason string) []string { + mergedW := prColNum + 1 + prColAge + 1 + prColTTL + cellInner := mergedW - 2 + + icon := lipgloss.NewStyle().Foreground(sty.warn).Bold(true).Render("⚠ offline") + hint := sty.muted.Render(strings.TrimSpace(reason)) + cardBody := icon + "\n" + hint + + cardW := cellInner - 6 + if cardW < 14 { + cardW = 14 + } + card := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(sty.warn). + Padding(0, 2). + Width(cardW). + Render(cardBody) + + cardLines := strings.Split(card, "\n") + blankCell := " " + strings.Repeat(" ", cellInner) + " " + blankRow := homeTableRow([]string{sty.muted.Render(homeCell("", prColStt)), blankCell}, sty.sep) + + rows := make([]string, 0, len(cardLines)+1) + rows = append(rows, blankRow) + for _, line := range cardLines { + centered := lipgloss.PlaceHorizontal(cellInner, lipgloss.Center, line) + rows = append(rows, homeTableRow([]string{ + sty.muted.Render(homeCell("", prColStt)), + " " + centered + " ", + }, sty.sep)) + } + rows = append(rows, blankRow) + return rows +} + +// buildGHErrorTag returns a short styled label shown in the PR table title row +// when the GitHub fetch failed. +func (v *HomeView) buildGHErrorTag(sty homeTableSty) string { + switch v.prStatusCode { + case http.StatusForbidden: + return lipgloss.NewStyle().Foreground(sty.warn).Render("⚠ rate limited") + case http.StatusNotFound: + return lipgloss.NewStyle().Foreground(sty.warn).Render("⚠ not found") + case http.StatusUnauthorized: + return lipgloss.NewStyle().Foreground(sty.danger).Render("⚠ unauthorized") + case 0: + return lipgloss.NewStyle().Foreground(sty.warn).Render("⚠ offline") + default: + return lipgloss.NewStyle().Foreground(sty.warn).Render(fmt.Sprintf("⚠ HTTP %d", v.prStatusCode)) + } +} + // homeRenderPRSection renders the Open PRs box-drawing table section. -func homeRenderPRSection(items []homePRItem, innerW, selIdx int, sty homeTableSty, repoLabel, liveLabel string) []string { +// errMsg is non-empty when the GitHub fetch failed; it is shown in the table body. +func homeRenderPRSection(items []homePRItem, innerW, selIdx int, sty homeTableSty, repoLabel, liveLabel string, errMsg ...string) []string { // Columns: ● | # | age | title const prColStt = 3 // ` ● ` const prColNum = 6 // ` #123 ` @@ -1214,12 +1315,16 @@ func homeRenderPRSection(items []homePRItem, innerW, selIdx int, sty homeTableSt sty.border.Render(homeTableEdge("\u251c", "\u253c", "\u2524", widths)), } - if len(items) == 0 { - out = append(out, homeTableRow([]string{ - sty.muted.Render(homeCell("", prColStt)), - sty.fg.Render(homeCell("no open PRs", prColNum+1+prColAge+1+prColTTL)), - }, sty.sep)) - } else { + errorMsg := "" + if len(errMsg) > 0 { + errorMsg = errMsg[0] + } + switch { + case errorMsg != "": + out = append(out, homePRErrorState(prColStt, prColNum, prColAge, prColTTL, sty, errorMsg)...) + case len(items) == 0: + out = append(out, homePREmptyState(prColStt, prColNum, prColAge, prColTTL, sty)...) + default: for i, pr := range items { out = append(out, homeRenderPRRow(i, pr, selIdx, sty, prColStt, prColNum, prColAge, prColTTL)) } @@ -1392,46 +1497,35 @@ func (v *HomeView) buildPRTableLines(tc *theme.Context, innerW, selectedIdx int) } } + lines := make([]string, 0, 50) if !v.prFetchOK { - return v.buildGHErrorLines(sty, innerW) + errTag := v.buildGHErrorTag(sty) + errReason := v.buildGHErrorReason() + lines = append(lines, homeRenderPRSection(nil, innerW, -1, sty, repoLabel, errTag, errReason)...) + } else { + lines = append(lines, homeRenderPRSection(v.prItems, innerW, selectedIdx, sty, repoLabel, liveLabel)...) } - - lines := make([]string, 0, 50) - lines = append(lines, homeRenderPRSection(v.prItems, innerW, selectedIdx, sty, repoLabel, liveLabel)...) lines = append(lines, homeRenderRelSection(v.releaseItems, innerW, sty, repoLabel)...) lines = append(lines, homeRenderCISection(v.ciItems, innerW, sty, repoLabel)...) return lines } -// buildGHErrorLines renders the error state when the GitHub fetch failed. -func (v *HomeView) buildGHErrorLines(sty homeTableSty, innerW int) []string { - var reason, hint string +// buildGHErrorReason returns a human-readable reason string for the last GitHub fetch failure. +// For cases where a token would help, a second line with the export hint is included. +func (v *HomeView) buildGHErrorReason() string { + const tokenHint = "\nexport GITHUB_TOKEN=" switch v.prStatusCode { case http.StatusForbidden: - reason = " rate limited (60 req/hr unauthenticated)" - hint = homeGHTokenHint + return "rate limited (60 req/hr unauthenticated)" + tokenHint case http.StatusNotFound: - reason = " repo not found or private" - hint = homeGHTokenHint + return "repo is private β€” a token is required" + tokenHint case http.StatusUnauthorized: - reason = " bad credentials" - hint = " check your GITHUB_TOKEN value" + return "bad credentials β€” check GITHUB_TOKEN" case 0: - reason = " network error \u2014 no response received" + return "network error β€” no response received" default: - reason = fmt.Sprintf(" HTTP %d from GitHub API", v.prStatusCode) - hint = homeGHTokenHint - } - out := []string{ - sty.title.Render("arc-platform"), - sty.border.Render(strings.Repeat("\u2500", innerW)), - "", - sty.muted.Render(reason), - } - if hint != "" { - out = append(out, sty.muted.Render(hint)) + return fmt.Sprintf("HTTP %d from GitHub API", v.prStatusCode) + tokenHint } - return out } // --------------------------------------------------------------------------- @@ -1447,6 +1541,7 @@ func (v *HomeView) Keybindings() []engine.KeyBinding { {Key: "h/l", Desc: "switch panel"}, {Key: "[/]", Desc: "scroll"}, {Key: "enter", Desc: "open"}, - {Key: "o", Desc: "open in browser"}, + {Key: "o", Desc: "browser"}, + {Key: "p", Desc: "cycle profile"}, } } diff --git a/pkg/ui/view/init_wizard.go b/pkg/ui/view/init_wizard.go index 201065a..6587156 100644 --- a/pkg/ui/view/init_wizard.go +++ b/pkg/ui/view/init_wizard.go @@ -3,11 +3,13 @@ package view import ( "fmt" "os" + "os/exec" "path/filepath" + "strings" "github.com/charmbracelet/bubbles/spinner" + "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/huh" "github.com/charmbracelet/lipgloss" "github.com/spf13/afero" @@ -21,10 +23,16 @@ import ( type initWizardPhase int const ( - initPhaseForm initWizardPhase = iota // huh form is active - initPhaseRunning // initialization in progress - initPhaseDone // initialization succeeded - initPhaseError // initialization failed + initPhaseInput initWizardPhase = iota // textinput for workspace path + initPhaseExistsAction // workspace exists β€” show options + initPhaseDeleteConfirm // type "delete" to wipe existing workspace + initPhaseTier // custom card-based tier selector + initPhaseCaps // custom card-based capability selector + initPhaseRunning // initialization in progress + initPhaseDone // initialization succeeded β€” next action selector + initPhaseConfirm // confirm dialog before launching platform + initPhaseLaunching // arc workspace run in progress + initPhaseError // initialization or launch failed ) // initResultMsg is the internal async message for init completion. @@ -33,146 +41,544 @@ type initResultMsg struct { path string } -// InitWizard is a form-based view that guides the user through initializing a -// new A.R.C. workspace. It collects the installation path and tier, then runs -// workspace.Initializer asynchronously. +// initLaunchResultMsg is the async message for arc workspace run completion. +type initLaunchResultMsg struct { + err error + output string +} + +// ── Tier option data ────────────────────────────────────────────────────────── + +const tierUltraInstinct = "ultra-instinct" + +type initTierService struct { + name string + desc string +} + +type initTierOption struct { + id string + emoji string + name string + subtitle string + services []initTierService +} + +var initTierOptions = []initTierOption{ //nolint:gochecknoglobals // static catalog data initialized once at package load + { + id: "think", + emoji: "🧠", + name: "THINK", + subtitle: "Core Platform Only", + services: []initTierService{ + {name: "gateway", desc: "Traefik reverse proxy + TLS termination"}, + {name: "messaging", desc: "NATS low-latency pub/sub messaging"}, + {name: "streaming", desc: "Apache Pulsar durable event streaming"}, + {name: "cache", desc: "Redis cache + session state"}, + {name: "persistence", desc: "PostgreSQL 17 + pgvector"}, + {name: "cortex", desc: "ARC bootstrap + orchestration"}, + {name: "friday-collector", desc: "OpenTelemetry collector sidecar"}, + }, + }, + { + id: "reason", + emoji: "πŸ”", + name: "REASON", + subtitle: "Extends Reasoning Capabilities", + services: []initTierService{ + {name: "gateway", desc: "Traefik reverse proxy + TLS termination"}, + {name: "messaging", desc: "NATS low-latency pub/sub messaging"}, + {name: "streaming", desc: "Apache Pulsar durable event streaming"}, + {name: "cache", desc: "Redis cache + session state"}, + {name: "persistence", desc: "PostgreSQL 17 + pgvector"}, + {name: "cortex", desc: "ARC bootstrap + orchestration"}, + {name: "friday-collector", desc: "OpenTelemetry collector sidecar"}, + {name: "reasoner", desc: "LLM reasoning engine with tool use"}, + }, + }, + { + id: tierUltraInstinct, + emoji: "⚑", + name: "ULTRA INSTINCT", + subtitle: "Coming Soon", + services: []initTierService{}, + }, +} + +// ── Capability option data ──────────────────────────────────────────────────── + +type initCapOption struct { + id string + name string + desc string + detail string + selected bool +} + +var initCapOptions = []initCapOption{ //nolint:gochecknoglobals // static catalog data initialized once at package load + { + id: "reasoner", + name: "reasoner", + desc: "LLM reasoning engine", + detail: "Adds the reasoner service β€” LLM agent framework with tool use. Supports OpenAI, Anthropic, and local Ollama models. Required by the voice capability.", + }, + { + id: "voice", + name: "voice", + desc: "Real-time voice pipeline", + detail: "Adds realtime + voice services β€” LiveKit server + Whisper STT + TTS. Requires reasoner capability.", + }, + { + id: "observe", + name: "observe", + desc: "Observability stack", + detail: "Adds the otel service β€” OpenTelemetry-based traces, metrics, and log aggregation pipeline.", + }, + { + id: "security", + name: "security", + desc: "Secrets & feature flags", + detail: "Adds vault + flags services β€” HashiCorp Vault for secret management and a feature flag service for runtime control.", + }, + { + id: "storage", + name: "storage", + desc: "Object storage (S3-compatible)", + detail: "Adds the storage service β€” MinIO for file uploads, model artifacts, and large object storage with an S3-compatible API.", + }, +} + +// ── InitWizard model ────────────────────────────────────────────────────────── + +// InitWizard is a multi-phase TUI wizard for initializing an A.R.C. workspace. type InitWizard struct { ctx engine.ViewContext phase initWizardPhase ready bool - // form fields - installPath string - selectedTier string - form *huh.Form + // path phase + pathInput textinput.Model + pathError string // inline validation error + existsPath string // resolved path of an existing workspace + existsAction int // 0 = Clean it, 1 = Use different path + deleteInput textinput.Model + + // tier phase + tierCursor int + tiers []initTierOption - // runtime state + // caps phase + capCursor int + caps []initCapOption + + // done phase + nextCursor int + confirmCursor int // 0 = Yes, 1 = No + launchResult initLaunchResultMsg + + // runtime spinner component.Spinner result initResultMsg } // NewInitWizard creates the InitWizard view. func NewInitWizard() *InitWizard { - return &InitWizard{ - installPath: ".", - selectedTier: "ultra-instinct", + w := &InitWizard{ + tiers: make([]initTierOption, len(initTierOptions)), + caps: make([]initCapOption, len(initCapOptions)), } + copy(w.tiers, initTierOptions) + copy(w.caps, initCapOptions) + return w } func (v *InitWizard) Init() tea.Cmd { return nil } func (v *InitWizard) OnEnter(ctx engine.ViewContext) tea.Cmd { v.ctx = ctx - v.phase = initPhaseForm + if v.ready { + // Called again due to terminal resize β€” refresh context, keep wizard state intact. + return nil + } + // First activation: full initialization. + v.phase = initPhaseInput + v.pathError = "" v.result = initResultMsg{} v.spinner = component.NewSpinner(ctx.Theme) - v.buildForm() + v.tierCursor = 0 + v.capCursor = 0 + for i := range v.caps { + v.caps[i].selected = false + } + + ti := textinput.New() + // Show the real CWD so the user knows what "blank" resolves to. + if cwd, err := os.Getwd(); err == nil { + ti.Placeholder = cwd + } else { + ti.Placeholder = "." + } + ti.CharLimit = 512 + if ctx.Theme != nil { + t := ctx.Theme.Theme() + ti.PromptStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(t.Colors.Primary)) + ti.TextStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(t.Colors.Foreground)) + ti.PlaceholderStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(t.Colors.Muted)) + ti.Cursor.Style = lipgloss.NewStyle().Foreground(lipgloss.Color(t.Colors.Primary)) + } + v.pathInput = ti v.ready = true - return v.form.Init() + return v.pathInput.Focus() } func (v *InitWizard) OnExit() tea.Cmd { return nil } -func (v *InitWizard) buildForm() { - v.form = huh.NewForm( - huh.NewGroup( - huh.NewInput(). - Title("Installation Path"). - Description("Directory where A.R.C. workspace will be created."). - Placeholder("."). - Value(&v.installPath), - - huh.NewSelect[string](). - Title("Power Tier"). - Description("Select your workspace tier."). - Options( - huh.NewOption("Free Tier β€” core platform", "free"), - huh.NewOption("Super Saiyan β€” enhanced observability", "super-saiyan"), - huh.NewOption("Super Saiyan Blue β€” full security stack", "super-saiyan-blue"), - huh.NewOption("Ultra Instinct β€” enterprise multi-tenant", "ultra-instinct"), - ). - Value(&v.selectedTier), - ), - ).WithTheme(huh.ThemeCharm()) +// CapturesKeyboard forwards q/tab/shift-tab to this view while on the input step +// so the shell doesn't intercept them as global navigation. +func (v *InitWizard) CapturesKeyboard() bool { + return v.phase == initPhaseInput || v.phase == initPhaseDeleteConfirm } func (v *InitWizard) Update(msg tea.Msg) (engine.View, tea.Cmd) { if !v.ready { return v, nil } - switch v.phase { - case initPhaseForm: - m, cmd := v.form.Update(msg) - if f, ok := m.(*huh.Form); ok { - v.form = f - } - if v.form.State == huh.StateCompleted { - v.phase = initPhaseRunning - return v, tea.Batch( - v.spinner.Tick(), - v.runInitCmd(), - ) + case initPhaseInput: + return v.updateInput(msg) + case initPhaseExistsAction: + return v.updateExistsAction(msg) + case initPhaseDeleteConfirm: + return v.updateDeleteConfirm(msg) + case initPhaseTier: + return v.updateTier(msg) + case initPhaseCaps: + return v.updateCaps(msg) + case initPhaseRunning: + return v.updateRunning(msg) + case initPhaseDone: + return v.updateDone(msg) + case initPhaseConfirm: + return v.updateConfirm(msg) + case initPhaseLaunching: + return v.updateLaunching(msg) + case initPhaseError: + return v.updateError(msg) + } + return v, nil +} + +func (v *InitWizard) updateInput(msg tea.Msg) (engine.View, tea.Cmd) { + if keyMsg, ok := msg.(tea.KeyMsg); ok && keyMsg.String() == keyEnter { + // Resolve the path and check for an existing workspace before advancing. + raw := v.pathInput.Value() + if raw == "" { + raw = "." } - return v, cmd + absPath, err := filepath.Abs(raw) + if err != nil { + v.pathError = fmt.Sprintf("⚠ Invalid path: %v", err) + return v, nil + } + // Check if a workspace already exists at this location. + fs := afero.NewOsFs() + det := workspace.NewDetector(fs) + exists, detErr := det.IsWorkspace(absPath) + if detErr == nil && exists { + v.existsPath = absPath + v.existsAction = 0 + v.pathInput.Blur() + v.phase = initPhaseExistsAction + return v, nil + } + // Clear any previous error and advance. + v.pathError = "" + v.phase = initPhaseTier + v.pathInput.Blur() + return v, nil + } + var cmd tea.Cmd + v.pathInput, cmd = v.pathInput.Update(msg) + // Clear error as soon as the user starts editing. + if v.pathError != "" { + v.pathError = "" + } + return v, cmd +} - case initPhaseRunning: - switch msg := msg.(type) { - case initResultMsg: - v.result = msg - if msg.err != nil { - v.phase = initPhaseError - } else { - v.phase = initPhaseDone +func (v *InitWizard) updateExistsAction(msg tea.Msg) (engine.View, tea.Cmd) { + keyMsg, ok := msg.(tea.KeyMsg) + if !ok { + return v, nil + } + switch keyMsg.String() { + case keyUp, "k", keyDown, "j": + v.existsAction = 1 - v.existsAction // toggle 0↔1 + case keyEnter, " ": + if v.existsAction == 0 { + // Clean it β€” enter delete confirmation with a fresh text input. + di := textinput.New() + di.Placeholder = "type \"delete\" to confirm" + di.CharLimit = 16 + if v.ctx.Theme != nil { + t := v.ctx.Theme.Theme() + di.PromptStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(t.Colors.Error)) + di.TextStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(t.Colors.Foreground)) + di.PlaceholderStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(t.Colors.Muted)) + di.Cursor.Style = lipgloss.NewStyle().Foreground(lipgloss.Color(t.Colors.Error)) } + v.deleteInput = di + v.phase = initPhaseDeleteConfirm + return v, v.deleteInput.Focus() + } + // Use different path β€” go back to path input and clear it. + v.pathInput.SetValue("") + v.pathError = "" + v.phase = initPhaseInput + return v, v.pathInput.Focus() + case "q", keyEsc: + v.pathInput.SetValue("") + v.pathError = "" + v.phase = initPhaseInput + return v, v.pathInput.Focus() + } + return v, nil +} + +func (v *InitWizard) updateDeleteConfirm(msg tea.Msg) (engine.View, tea.Cmd) { + if keyMsg, ok := msg.(tea.KeyMsg); ok { + switch keyMsg.String() { + case keyEnter: + if v.deleteInput.Value() == deleteWord { + // Remove arc.yaml and .arc/ from the existing workspace path. + _ = os.Remove(filepath.Join(v.existsPath, "arc.yaml")) + _ = os.RemoveAll(filepath.Join(v.existsPath, ".arc")) + // Advance to tier selection with the now-clean path. + v.pathInput.SetValue(v.existsPath) + v.pathError = "" + v.phase = initPhaseTier + return v, nil + } + // Wrong word β€” shake by showing an error hint (re-focus is enough). return v, nil + case keyEsc, "q": + v.phase = initPhaseExistsAction + return v, nil + } + } + var cmd tea.Cmd + v.deleteInput, cmd = v.deleteInput.Update(msg) + return v, cmd +} - case spinner.TickMsg: - sp, cmd := v.spinner.Update(msg) - v.spinner = sp - return v, cmd +func (v *InitWizard) updateTier(msg tea.Msg) (engine.View, tea.Cmd) { + keyMsg, ok := msg.(tea.KeyMsg) + if !ok { + return v, nil + } + switch keyMsg.String() { + case keyUp, "k": + if v.tierCursor > 0 { + v.tierCursor-- } + case keyDown, "j": + if v.tierCursor < len(v.tiers)-1 { + v.tierCursor++ + } + case keyEnter, " ": + selected := v.tiers[v.tierCursor] + // Ultra instinct is not yet available. + if selected.id == tierUltraInstinct { + return v, nil + } + v.phase = initPhaseCaps + } + return v, nil +} - case initPhaseDone, initPhaseError: - if keyMsg, ok := msg.(tea.KeyMsg); ok { - if keyMsg.String() == "q" || keyMsg.String() == keyEnter { - return v, tea.Quit +func (v *InitWizard) updateCaps(msg tea.Msg) (engine.View, tea.Cmd) { + keyMsg, ok := msg.(tea.KeyMsg) + if !ok { + return v, nil + } + switch keyMsg.String() { + case keyUp, "k": + if v.capCursor > 0 { + v.capCursor-- + } + case keyDown, "j": + if v.capCursor < len(v.caps)-1 { + v.capCursor++ + } + case " ": + v.caps[v.capCursor].selected = !v.caps[v.capCursor].selected + case keyEnter: + tierID := v.tiers[v.tierCursor].id + var caps []string + for _, c := range v.caps { + if c.selected { + caps = append(caps, c.id) } } + v.phase = initPhaseRunning + return v, tea.Batch(v.spinner.Tick(), v.runInitCmd(tierID, caps)) } + return v, nil +} +func (v *InitWizard) updateRunning(msg tea.Msg) (engine.View, tea.Cmd) { + switch msg := msg.(type) { + case initResultMsg: + v.result = msg + if msg.err != nil { + v.phase = initPhaseError + } else { + v.phase = initPhaseDone + } + return v, nil + case spinner.TickMsg: + sp, cmd := v.spinner.Update(msg) + v.spinner = sp + return v, cmd + } return v, nil } -// runInitCmd runs workspace initialization asynchronously. -func (v *InitWizard) runInitCmd() tea.Cmd { - path := v.installPath - tier := v.selectedTier +// doneActions are the selectable next steps on the success screen. +type doneAction struct { + label string + desc string +} + +var wizardDoneActions = []doneAction{ //nolint:gochecknoglobals // static UI action catalog initialized once at package load + {label: "Launch platform", desc: "Start all workspace services (arc workspace run)"}, + {label: "View workspace", desc: "Open the workspace info panel"}, + {label: "Done", desc: "Exit the wizard"}, +} + +func (v *InitWizard) updateDone(msg tea.Msg) (engine.View, tea.Cmd) { + keyMsg, ok := msg.(tea.KeyMsg) + if !ok { + return v, nil + } + switch keyMsg.String() { + case keyUp, "k": + if v.nextCursor > 0 { + v.nextCursor-- + } + case keyDown, "j": + if v.nextCursor < len(wizardDoneActions)-1 { + v.nextCursor++ + } + case keyEnter, " ": + switch v.nextCursor { + case 0: // Launch platform + v.confirmCursor = 0 + v.phase = initPhaseConfirm + case 1: // View workspace + return v, func() tea.Msg { return engine.NavigateMsg{ViewName: "Workspace"} } + default: // Done + return v, tea.Quit + } + case "q": + return v, tea.Quit + } + return v, nil +} + +func (v *InitWizard) updateConfirm(msg tea.Msg) (engine.View, tea.Cmd) { + keyMsg, ok := msg.(tea.KeyMsg) + if !ok { + return v, nil + } + switch keyMsg.String() { + case keyUp, "k", keyDown, "j": + v.confirmCursor = 1 - v.confirmCursor // toggle between 0 (Yes) and 1 (No) + case keyEnter, " ": + if v.confirmCursor == 0 { + v.phase = initPhaseLaunching + return v, tea.Batch(v.spinner.Tick(), v.runLaunchCmd()) + } + v.phase = initPhaseDone + case "q": + v.phase = initPhaseDone + } + return v, nil +} + +func (v *InitWizard) updateLaunching(msg tea.Msg) (engine.View, tea.Cmd) { + switch msg := msg.(type) { + case initLaunchResultMsg: + v.launchResult = msg + if msg.err != nil { + v.phase = initPhaseError + } else { + return v, func() tea.Msg { return engine.NavigateMsg{ViewName: "Workspace"} } + } + return v, nil + case spinner.TickMsg: + sp, cmd := v.spinner.Update(msg) + v.spinner = sp + return v, cmd + } + return v, nil +} +func (v *InitWizard) updateError(msg tea.Msg) (engine.View, tea.Cmd) { + if keyMsg, ok := msg.(tea.KeyMsg); ok { + if keyMsg.String() == "q" || keyMsg.String() == keyEnter { + return v, tea.Quit + } + } + return v, nil +} + +// runLaunchCmd runs arc workspace run with ARC_NO_TUI=1 in the background. +func (v *InitWizard) runLaunchCmd() tea.Cmd { + workspacePath := v.result.path + return func() tea.Msg { + arcBin, err := os.Executable() + if err != nil { + return initLaunchResultMsg{err: fmt.Errorf("cannot resolve arc binary: %w", err)} + } + cmd := exec.Command(arcBin, "workspace", "run", "--detached") + cmd.Dir = workspacePath + cmd.Env = append(os.Environ(), "ARC_NO_TUI=1") + out, runErr := cmd.CombinedOutput() + return initLaunchResultMsg{err: runErr, output: strings.TrimSpace(string(out))} + } +} + +// runInitCmd runs workspace initialization asynchronously. +func (v *InitWizard) runInitCmd(tier string, caps []string) tea.Cmd { + path := v.pathInput.Value() + if path == "" { + path = "." + } return func() tea.Msg { absPath, err := filepath.Abs(path) if err != nil { return initResultMsg{err: fmt.Errorf("invalid path: %w", err), path: path} } - if mkErr := os.MkdirAll(absPath, 0o755); mkErr != nil { return initResultMsg{err: fmt.Errorf("failed to create directory: %w", mkErr), path: absPath} } - fs := afero.NewOsFs() stateDir := filepath.Join(absPath, ".arc", "state") stateRepo := local.NewStateRepository(fs, stateDir) initializer := workspace.NewInitializer(fs, stateRepo) - err = initializer.Initialize(workspace.InitializeOptions{ - Path: absPath, - Tier: tier, - Force: false, + Path: absPath, + Tier: tier, + Capabilities: caps, + Force: false, }) return initResultMsg{err: err, path: absPath} } } +// ── View ────────────────────────────────────────────────────────────────────── + func (v *InitWizard) View() string { if !v.ready { return "" @@ -180,65 +586,676 @@ func (v *InitWizard) View() string { tc := v.ctx.Theme - var primaryColor string + var primaryColor, mutedColor, successColor, borderColor, secondaryColor, fgColor, errorColor string if tc != nil { - primaryColor = tc.Theme().Colors.Primary + c := tc.Theme().Colors + primaryColor = c.Primary + mutedColor = c.Muted + successColor = c.Success + borderColor = c.Border + secondaryColor = c.Secondary + fgColor = c.Foreground + errorColor = c.Error + } else { + primaryColor = "#7C3AED" + mutedColor = "#8D99AE" + successColor = "#06FFA5" + borderColor = "#3D3D3D" + secondaryColor = "#A78BFA" + fgColor = "#E2E8F0" + errorColor = "#FF6B6B" } primary := lipgloss.NewStyle().Foreground(lipgloss.Color(primaryColor)) - muted := lipgloss.NewStyle().Foreground(lipgloss.Color("#8D99AE")) + muted := lipgloss.NewStyle().Foreground(lipgloss.Color(mutedColor)) + _ = fgColor switch v.phase { - case initPhaseForm: - title := primary.Bold(true).Render("Initialize A.R.C. Workspace") - hint := muted.Render("Fill in the details below and press Enter to confirm.") - return lipgloss.JoinVertical(lipgloss.Left, - title, - hint, - "", - v.form.View(), - ) - + case initPhaseInput: + return v.viewPathInput(primary, muted, borderColor, errorColor) + case initPhaseExistsAction: + return v.viewExistsAction(primaryColor, mutedColor, borderColor, errorColor) + case initPhaseDeleteConfirm: + return v.viewDeleteConfirm(primaryColor, mutedColor, borderColor, errorColor) + case initPhaseTier: + return v.viewTierSelect(primaryColor, secondaryColor, mutedColor, borderColor) + case initPhaseCaps: + return v.viewCapsSelect(primaryColor, secondaryColor, mutedColor, borderColor) case initPhaseRunning: return lipgloss.JoinVertical(lipgloss.Left, - primary.Bold(true).Render("Initializing workspace…"), "", - v.spinner.View()+" Creating workspace structure…", + primary.Bold(true).Render(" Initializing workspace…"), + "", + " "+v.spinner.View()+" Setting up workspace structure…", ) - case initPhaseDone: - successStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#06FFA5")).Bold(true) - body := fmt.Sprintf(" Workspace created at:\n %s\n\n Next steps:\n β€’ Edit arc.yaml to configure services\n β€’ Run 'arc workspace run' to launch the platform", - v.result.path, - ) - card := component.Card(tc, "Workspace Initialized", body) + return v.viewDone(primaryColor, secondaryColor, mutedColor, successColor, borderColor) + case initPhaseConfirm: + return v.viewConfirm(primaryColor, mutedColor, borderColor) + case initPhaseLaunching: return lipgloss.JoinVertical(lipgloss.Left, - successStyle.Render("βœ“ Workspace initialized successfully!"), "", - card, + primary.Bold(true).Render(" Launching platform…"), "", - muted.Render("Press Enter or 'q' to exit."), + " "+v.spinner.View()+" Starting workspace services…", ) - case initPhaseError: - errDisplay := component.ErrorDisplay(tc, "Initialization Failed", v.result.err, "", component.SeverityError) + var errToShow error + var errDetail string + if v.launchResult.err != nil { + errToShow = v.launchResult.err + errDetail = v.launchResult.output + } else { + errToShow = v.result.err + } + errDisplay := component.ErrorDisplay(tc, "Failed", errToShow, errDetail, component.SeverityError) return lipgloss.JoinVertical(lipgloss.Left, errDisplay, "", - muted.Render("Press Enter or 'q' to exit."), + muted.Render(" Press Enter or 'q' to exit."), ) } - return "" } +// ── Phase renderers ─────────────────────────────────────────────────────────── + +// wizardHeader renders the shared title + step label used by every phase. +func (v *InitWizard) wizardHeader(primary, muted, div lipgloss.Style, step, desc string) string { + return lipgloss.JoinVertical(lipgloss.Left, + "", + primary.Bold(true).Render(" Initialize A.R.C. Workspace"), + div.Render(strings.Repeat("─", 52)), + lipgloss.JoinHorizontal(lipgloss.Top, primary.Bold(true).Render(" "+step), " ", muted.Render(desc)), + "", + ) +} + +func (v *InitWizard) viewPathInput(primary, muted lipgloss.Style, borderColor, errorColor string) string { + divStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(borderColor)) + header := v.wizardHeader(primary, muted, divStyle, "Step 1 / 3", "Set workspace location") + + label := primary.Bold(true).Render("Workspace Path") + + // containerW is the total visual width of the input box (border included). + // Subtract 4 = 2 (left indent " ") + 2 (right margin). + termW := v.ctx.Width + if termW <= 0 { + termW = 100 + } + containerW := termW - 4 + if containerW > 84 { + containerW = 84 + } + if containerW < 34 { + containerW = 34 + } + // textinput content width = containerW minus border (2) minus padding (2). + v.pathInput.Width = containerW - 4 + + inputBox := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color(borderColor)). + Padding(0, 1). + MarginLeft(2). + Render(v.pathInput.View()) + + desc := muted.Render("Press Enter to use the current directory (shown as placeholder).") + + lines := []string{ + header, + " " + label, + " " + desc, + "", + inputBox, + } + + // Show inline error if path is invalid or workspace already exists. + if v.pathError != "" { + errStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(errorColor)) + lines = append(lines, "", " "+errStyle.Render(v.pathError)) + } + + return lipgloss.JoinVertical(lipgloss.Left, lines...) +} + +func (v *InitWizard) viewTierSelect(primaryColor, secondaryColor, mutedColor, borderColor string) string { + termW := v.ctx.Width + if termW <= 0 { + termW = 100 + } + listW := termW - 4 + if listW > 72 { + listW = 72 + } + + primary := lipgloss.NewStyle().Foreground(lipgloss.Color(primaryColor)) + secondary := lipgloss.NewStyle().Foreground(lipgloss.Color(secondaryColor)) + muted := lipgloss.NewStyle().Foreground(lipgloss.Color(mutedColor)) + divStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(borderColor)) + + header := v.wizardHeader(primary, muted, divStyle, "Step 2 / 3", "Choose platform tier") + + // ── Tier list ───────────────────────────────────────────────────────── + rows := make([]string, len(v.tiers)) + for i, t := range v.tiers { + active := i == v.tierCursor + isUnavailable := t.id == tierUltraInstinct + var arrow, nameRender, subRender string + switch { + case isUnavailable: + arrow = cursorBlank + nameRender = muted.Render(t.emoji + " " + t.name) + subRender = lipgloss.NewStyle().Foreground(lipgloss.Color("#F59E0B")).Render("COMING SOON") + case active: + arrow = primary.Bold(true).Render("β–Ά") + nameRender = primary.Bold(true).Render(t.emoji + " " + t.name) + subRender = secondary.Render(t.subtitle) + default: + arrow = cursorBlank + nameRender = muted.Render(t.emoji + " " + t.name) + subRender = muted.Render(t.subtitle) + } + nameCol := lipgloss.NewStyle().Width(22).Render(nameRender) + content := lipgloss.JoinHorizontal(lipgloss.Top, arrow, " ", nameCol, " ", subRender) + + var bc lipgloss.Color + var bs lipgloss.Border + if active && !isUnavailable { + bc = lipgloss.Color(primaryColor) + bs = lipgloss.RoundedBorder() + } else { + bc = lipgloss.Color(borderColor) + bs = lipgloss.HiddenBorder() + } + rows[i] = lipgloss.NewStyle(). + Border(bs). + BorderForeground(bc). + Width(listW). + Padding(0, 1). + Render(content) + } + + // ── Detail panel for selected tier ──────────────────────────────────── + sel := v.tiers[v.tierCursor] + var detailContent string + if sel.id == tierUltraInstinct { + comingSoon := lipgloss.NewStyle().Foreground(lipgloss.Color("#F59E0B")).Bold(true) + detailContent = lipgloss.JoinVertical(lipgloss.Left, + comingSoon.Render(sel.emoji+" "+sel.name), + "", + muted.Render("All capabilities enabled β€” full platform stack."), + "", + comingSoon.Render("Not yet available. Select THINK or REASON to continue."), + ) + } else { + svcLines := make([]string, len(sel.services)) + for i, svc := range sel.services { + nameCol := lipgloss.NewStyle(). + Foreground(lipgloss.Color(secondaryColor)). + Bold(true). + Width(22). + Render(svc.name) + svcLines[i] = muted.Render(" ● ") + nameCol + muted.Render(svc.desc) + } + detailContent = lipgloss.JoinVertical(lipgloss.Left, + primary.Bold(true).Render(sel.emoji+" "+sel.name)+" "+secondary.Render(sel.subtitle), + "", + secondary.Render("Includes:"), + strings.Join(svcLines, "\n"), + ) + } + detail := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color(borderColor)). + Padding(1, 2). + Width(listW). + Render(detailContent) + + var hint string + if v.tiers[v.tierCursor].id == tierUltraInstinct { + hint = muted.Render(" ↑/↓ navigate (unavailable)") + } else { + hint = muted.Render(" ↑/↓ navigate Enter confirm") + } + + return lipgloss.JoinVertical(lipgloss.Left, + header, + lipgloss.JoinVertical(lipgloss.Left, rows...), + "", + detail, + "", + hint, + ) +} + +func (v *InitWizard) viewCapsSelect(primaryColor, secondaryColor, mutedColor, borderColor string) string { + termW := v.ctx.Width + if termW <= 0 { + termW = 100 + } + listW := termW - 4 + if listW > 72 { + listW = 72 + } + + primary := lipgloss.NewStyle().Foreground(lipgloss.Color(primaryColor)) + secondary := lipgloss.NewStyle().Foreground(lipgloss.Color(secondaryColor)) + muted := lipgloss.NewStyle().Foreground(lipgloss.Color(mutedColor)) + divStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(borderColor)) + + header := v.wizardHeader(primary, muted, divStyle, "Step 3 / 3", "Add extra capabilities") + + // ── Capability rows ──────────────────────────────────────────────────── + rows := make([]string, len(v.caps)) + for i, c := range v.caps { + active := i == v.capCursor + + var checkIcon string + if c.selected { + checkIcon = primary.Bold(true).Render("βœ“") + } else { + checkIcon = muted.Render("β—‹") + } + + nameCol := lipgloss.NewStyle(). + Foreground(lipgloss.Color(secondaryColor)). + Bold(true). + Width(12). + Render(c.name) + + var descRender string + if active { + descRender = lipgloss.NewStyle().Foreground(lipgloss.Color(secondaryColor)).Render(c.desc) + } else { + descRender = muted.Render(c.desc) + } + + content := lipgloss.JoinHorizontal(lipgloss.Top, checkIcon, " ", nameCol, " ", descRender) + + var bc lipgloss.Color + var bs lipgloss.Border + if active { + bc = lipgloss.Color(primaryColor) + bs = lipgloss.RoundedBorder() + } else { + bc = lipgloss.Color(borderColor) + bs = lipgloss.HiddenBorder() + } + rows[i] = lipgloss.NewStyle(). + Border(bs). + BorderForeground(bc). + Width(listW). + Padding(0, 1). + Render(content) + } + + // ── Detail panel for focused capability ─────────────────────────────── + focused := v.caps[v.capCursor] + detailContent := lipgloss.JoinVertical(lipgloss.Left, + secondary.Bold(true).Render(focused.name)+" β€” "+muted.Render(focused.desc), + "", + muted.Render(focused.detail), + ) + detail := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color(borderColor)). + Padding(1, 2). + Width(listW). + Render(detailContent) + + // Selected count summary. + numSel := 0 + for _, c := range v.caps { + if c.selected { + numSel++ + } + } + var countLabel string + if numSel == 0 { + countLabel = muted.Render(" No extra capabilities selected β€” press Enter to skip") + } else { + countLabel = secondary.Bold(true).Render(fmt.Sprintf(" %d selected", numSel)) + + muted.Render(" β€” press Enter to confirm") + } + + hint := muted.Render(" ↑/↓ navigate Space toggle Enter confirm") + + return lipgloss.JoinVertical(lipgloss.Left, + header, + lipgloss.JoinVertical(lipgloss.Left, rows...), + "", + detail, + "", + countLabel, + "", + hint, + ) +} + +func (v *InitWizard) viewExistsAction(primaryColor, mutedColor, borderColor, errorColor string) string { + primary := lipgloss.NewStyle().Foreground(lipgloss.Color(primaryColor)) + muted := lipgloss.NewStyle().Foreground(lipgloss.Color(mutedColor)) + errStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(errorColor)).Bold(true) + divStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(borderColor)) + header := v.wizardHeader(primary, muted, divStyle, "Step 1 / 3", "Set workspace location") + + termW := v.ctx.Width + if termW <= 0 { + termW = 100 + } + cardW := termW - 4 + if cardW > 80 { + cardW = 80 + } + + choices := []string{"Clean workspace", "Use a different path"} + descs := []string{ + "Remove arc.yaml and .arc/ then reinitialize here", + "Go back and enter a new workspace path", + } + rows := make([]string, len(choices)) + for i, label := range choices { + active := i == v.existsAction + var arrow, labelRender, descRender string + if active { + arrow = primary.Bold(true).Render("β–Ά") + labelRender = primary.Bold(true).Render(label) + descRender = lipgloss.NewStyle().Foreground(lipgloss.Color(primaryColor)).Render(descs[i]) + } else { + arrow = cursorBlank + labelRender = muted.Render(label) + descRender = muted.Render(descs[i]) + } + labelCol := lipgloss.NewStyle().Width(22).Render(labelRender) + content := lipgloss.JoinHorizontal(lipgloss.Top, arrow, " ", labelCol, " ", descRender) + var bc lipgloss.Color + var bs lipgloss.Border + if active { + bc = lipgloss.Color(primaryColor) + bs = lipgloss.RoundedBorder() + } else { + bc = lipgloss.Color(borderColor) + bs = lipgloss.HiddenBorder() + } + rows[i] = lipgloss.NewStyle(). + Border(bs).BorderForeground(bc). + Width(cardW).Padding(0, 1). + Render(content) + } + + hint := muted.Render(" ↑/↓ navigate Enter confirm Esc back") + + return lipgloss.JoinVertical(lipgloss.Left, + header, + "", + " "+errStyle.Render("⚠ Workspace already exists at "+v.existsPath), + "", + lipgloss.JoinVertical(lipgloss.Left, rows...), + "", + hint, + ) +} + +func (v *InitWizard) viewDeleteConfirm(primaryColor, mutedColor, borderColor, errorColor string) string { + primary := lipgloss.NewStyle().Foreground(lipgloss.Color(primaryColor)) + muted := lipgloss.NewStyle().Foreground(lipgloss.Color(mutedColor)) + errStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(errorColor)).Bold(true) + divStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(borderColor)) + header := v.wizardHeader(primary, muted, divStyle, "Step 1 / 3", "Set workspace location") + + termW := v.ctx.Width + if termW <= 0 { + termW = 100 + } + containerW := termW - 4 + if containerW > 84 { + containerW = 84 + } + if containerW < 34 { + containerW = 34 + } + v.deleteInput.Width = containerW - 4 + + willDelete := lipgloss.JoinVertical(lipgloss.Left, + muted.Render(" β€’ "+v.existsPath+"/arc.yaml"), + muted.Render(" β€’ "+v.existsPath+"/.arc/"), + ) + + inputBox := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color(errorColor)). + Padding(0, 1). + MarginLeft(2). + Render(v.deleteInput.View()) + + // Show a mismatch hint only when the user has typed something wrong. + var mismatch string + if val := v.deleteInput.Value(); val != "" && val != deleteWord { + mismatch = "\n " + muted.Render("Type the word ") + errStyle.Render(deleteWord) + muted.Render(" exactly to confirm.") + } + + hint := muted.Render(" Enter confirm Esc cancel") + + return lipgloss.JoinVertical(lipgloss.Left, + header, + "", + " "+errStyle.Render("This will permanently remove:"), + willDelete, + "", + " "+muted.Render("Type ")+errStyle.Render(deleteWord)+muted.Render(" to confirm:"), + "", + inputBox, + mismatch, + "", + hint, + ) +} + +func (v *InitWizard) viewDone(primaryColor, secondaryColor, mutedColor, successColor, borderColor string) string { + primary := lipgloss.NewStyle().Foreground(lipgloss.Color(primaryColor)) + secondary := lipgloss.NewStyle().Foreground(lipgloss.Color(secondaryColor)) + muted := lipgloss.NewStyle().Foreground(lipgloss.Color(mutedColor)) + successStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(successColor)).Bold(true) + + termW := v.ctx.Width + if termW <= 0 { + termW = 100 + } + cardW := termW - 4 + if cardW > 80 { + cardW = 80 + } + + // ── Workspace summary ────────────────────────────────────────────────── + tierName := "" + if v.tierCursor < len(v.tiers) { + tierName = v.tiers[v.tierCursor].name + } + var chosen []string + for _, c := range v.caps { + if c.selected { + chosen = append(chosen, c.id) + } + } + capLine := "none" + if len(chosen) > 0 { + capLine = strings.Join(chosen, ", ") + } + summaryBody := fmt.Sprintf( + " Path: %s\n Tier: %s\n Capabilities: %s\n\n Created:\n β€’ arc.yaml β€” workspace manifest\n β€’ .env β€” environment variables\n β€’ .gitignore β€” version control ignores\n β€’ .arc/ β€” system state directory", + v.result.path, tierName, capLine, + ) + card := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color(borderColor)). + Padding(1, 2). + Width(cardW - 4). + MarginLeft(2). + Render(lipgloss.JoinVertical(lipgloss.Left, + primary.Bold(true).Render("Workspace Ready"), + muted.Render(strings.Repeat("─", cardW-10)), + summaryBody, + )) + + // ── Next action list ─────────────────────────────────────────────────── + actionRows := make([]string, len(wizardDoneActions)) + for i, a := range wizardDoneActions { + active := i == v.nextCursor + var arrow, labelRender, descRender string + if active { + arrow = primary.Bold(true).Render("β–Ά") + labelRender = primary.Bold(true).Render(a.label) + descRender = secondary.Render(a.desc) + } else { + arrow = cursorBlank + labelRender = muted.Render(a.label) + descRender = muted.Render(a.desc) + } + labelCol := lipgloss.NewStyle().Width(18).Render(labelRender) + content := lipgloss.JoinHorizontal(lipgloss.Top, arrow, " ", labelCol, " ", descRender) + + var bc lipgloss.Color + var bs lipgloss.Border + if active { + bc = lipgloss.Color(primaryColor) + bs = lipgloss.RoundedBorder() + } else { + bc = lipgloss.Color(borderColor) + bs = lipgloss.HiddenBorder() + } + actionRows[i] = lipgloss.NewStyle(). + Border(bs).BorderForeground(bc). + Width(cardW).Padding(0, 1). + Render(content) + } + + hint := muted.Render(" ↑/↓ navigate Enter confirm q exit") + + return lipgloss.JoinVertical(lipgloss.Left, + "", + successStyle.Render(" βœ“ Workspace initialized successfully!"), + "", + card, + "", + primary.Bold(true).Render(" What would you like to do next?"), + "", + lipgloss.JoinVertical(lipgloss.Left, actionRows...), + "", + hint, + ) +} + +func (v *InitWizard) viewConfirm(primaryColor, mutedColor, borderColor string) string { + primary := lipgloss.NewStyle().Foreground(lipgloss.Color(primaryColor)) + muted := lipgloss.NewStyle().Foreground(lipgloss.Color(mutedColor)) + warn := lipgloss.NewStyle().Foreground(lipgloss.Color("#F59E0B")) + + termW := v.ctx.Width + if termW <= 0 { + termW = 100 + } + cardW := termW - 4 + if cardW > 72 { + cardW = 72 + } + + choices := []string{"Yes, launch platform", "No, go back"} + choiceRows := make([]string, len(choices)) + for i, label := range choices { + active := i == v.confirmCursor + var arrow, text string + if active { + arrow = primary.Bold(true).Render("β–Ά") + text = primary.Bold(true).Render(label) + } else { + arrow = cursorBlank + text = muted.Render(label) + } + var bc lipgloss.Color + var bs lipgloss.Border + if active { + bc = lipgloss.Color(primaryColor) + bs = lipgloss.RoundedBorder() + } else { + bc = lipgloss.Color(borderColor) + bs = lipgloss.HiddenBorder() + } + choiceRows[i] = lipgloss.NewStyle(). + Border(bs).BorderForeground(bc). + Width(cardW-10).Padding(0, 1). + Render(lipgloss.JoinHorizontal(lipgloss.Top, arrow, " ", text)) + } + + body := lipgloss.JoinVertical(lipgloss.Left, + primary.Bold(true).Render("Launch Platform"), + muted.Render(strings.Repeat("─", cardW-10)), + "", + muted.Render("This will start all workspace services via docker compose."), + muted.Render("The process runs in the background β€” errors will be shown here."), + "", + warn.Render("Make sure Docker is running before continuing."), + "", + lipgloss.JoinVertical(lipgloss.Left, choiceRows...), + "", + muted.Render("↑/↓ navigate Enter confirm"), + ) + + card := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color(borderColor)). + Padding(1, 2). + Width(cardW - 4). + MarginLeft(2). + Render(body) + + return lipgloss.JoinVertical(lipgloss.Left, "", card) +} + func (v *InitWizard) Name() string { return "Init Wizard" } +// NavHidden hides this view from the navigation tabs. +// It is only activated programmatically via engine.Config.InitialView. +func (v *InitWizard) NavHidden() bool { return true } + func (v *InitWizard) Keybindings() []engine.KeyBinding { switch v.phase { - case initPhaseDone, initPhaseError: + case initPhaseExistsAction: + return []engine.KeyBinding{ + {Key: "↑/↓", Desc: "navigate"}, + {Key: "enter", Desc: "confirm"}, + {Key: "esc", Desc: "back"}, + } + case initPhaseDeleteConfirm: + return []engine.KeyBinding{ + {Key: "enter", Desc: "confirm delete"}, + {Key: "esc", Desc: "cancel"}, + } + case initPhaseTier: + return []engine.KeyBinding{ + {Key: "↑/↓", Desc: "navigate"}, + {Key: "enter", Desc: "select tier"}, + } + case initPhaseCaps: + return []engine.KeyBinding{ + {Key: "↑/↓", Desc: "navigate"}, + {Key: "space", Desc: "toggle"}, + {Key: "enter", Desc: "confirm"}, + } + case initPhaseDone: + return []engine.KeyBinding{ + {Key: "↑/↓", Desc: "navigate"}, + {Key: "enter", Desc: "confirm"}, + {Key: "q", Desc: "exit"}, + } + case initPhaseConfirm: + return []engine.KeyBinding{ + {Key: "↑/↓", Desc: "navigate"}, + {Key: "enter", Desc: "confirm"}, + } + case initPhaseError: return []engine.KeyBinding{ - {Key: keyEnter, Desc: "exit"}, + {Key: "enter", Desc: "exit"}, } } return nil diff --git a/pkg/ui/view/services_list.go b/pkg/ui/view/services_list.go index 0612436..109ef77 100644 --- a/pkg/ui/view/services_list.go +++ b/pkg/ui/view/services_list.go @@ -2,7 +2,10 @@ package view import ( "fmt" + "os/exec" + "runtime" "strings" + "time" bubblestable "github.com/charmbracelet/bubbles/table" tea "github.com/charmbracelet/bubbletea" @@ -16,8 +19,38 @@ import ( "github.com/arc-framework/arc-cli/pkg/ui/theme" ) +// svcPulseMsg fires on each animation tick to toggle the status dot pulse. +type svcPulseMsg struct{} + +func svcPulseTick() tea.Cmd { + return tea.Tick(700*time.Millisecond, func(time.Time) tea.Msg { return svcPulseMsg{} }) +} + +const ( + roleShortInfra = "Infra" + roleShortService = "Service" + roleShortSidecar = "Sidecar" + emojiSidecar = "βš™" +) + +// svcFilterLabels defines the category filter tabs in order (0=All, 1=Infra, 2=Service, 3=Sidecar). +var svcFilterLabels = []string{"All", roleShortInfra, roleShortService, roleShortSidecar} //nolint:gochecknoglobals // package-level config, not mutated + +// svcFilterRoles maps filter index β†’ ServiceRole (0 = no filter). +var svcFilterRoles = []catalog.ServiceRole{ //nolint:gochecknoglobals // package-level config, not mutated + "", + catalog.RoleInfrastructure, + catalog.RoleService, + catalog.RoleSidecar, +} + +const ( + svcRatioNormal = 0.58 + svcRatioExpanded = 0.30 +) + // ServicesList is the services dashboard: always-visible gh-dash-style search -// bar + filterable status table on the left, rich detail card on the right. +// bar + category filter tabs + filterable status table on the left, rich detail card on the right. type ServicesList struct { ctx engine.ViewContext services []*catalog.Service @@ -28,6 +61,10 @@ type ServicesList struct { cursor int err error + filterIdx int // active category filter (0=All, 1=Infra, 2=Service, 3=Sidecar) + expandDetail bool // whether the detail pane is expanded (e key toggle) + pulse bool // toggled each tick β€” drives the status dot animation + searching bool ready bool } @@ -41,7 +78,7 @@ func NewServicesList() *ServicesList { } // Init implements engine.View. -func (v *ServicesList) Init() tea.Cmd { return nil } +func (v *ServicesList) Init() tea.Cmd { return svcPulseTick() } // OnEnter implements engine.View. func (v *ServicesList) OnEnter(ctx engine.ViewContext) tea.Cmd { @@ -65,7 +102,7 @@ func (v *ServicesList) OnEnter(ctx engine.ViewContext) tea.Cmd { w = 120 } leftW, _ := v.split.Widths(w) - cols := servicesTableColumns(leftW) + cols := servicesTableColumns(leftW, maxCodenameWidth(v.services)) tableH := ctx.Height - 5 if tableH < 5 { tableH = 5 @@ -81,6 +118,16 @@ func (v *ServicesList) OnEnter(ctx engine.ViewContext) tea.Cmd { func (v *ServicesList) OnExit() tea.Cmd { return nil } func (v *ServicesList) handleKey(key string) (bool, tea.Cmd) { + if handled, cmd := v.handleSearchKey(key); handled { + return true, cmd + } + if v.searching { + return false, nil + } + return v.handleNavKey(key) +} + +func (v *ServicesList) handleSearchKey(key string) (bool, tea.Cmd) { switch key { case "/": if !v.searching { @@ -92,7 +139,6 @@ func (v *ServicesList) handleKey(key string) (bool, tea.Cmd) { v.searching = false v.search.Blur() v.search.SetValue("") - v.table.SetRows(v.allRows) v.cursor = 0 v.table.SetCursor(0) return true, nil @@ -101,33 +147,104 @@ func (v *ServicesList) handleKey(key string) (bool, tea.Cmd) { if v.searching { v.searching = false v.search.Blur() + return true, nil } - return true, nil + } + return false, nil +} + +func (v *ServicesList) handleNavKey(key string) (bool, tea.Cmd) { + switch key { case "j", keyDown: - if !v.searching { - if v.cursor < len(v.services)-1 { - v.cursor++ - } - v.table.SetCursor(v.cursor) - return true, nil + filtered := v.filteredRows() + if v.cursor < len(filtered)-1 { + v.cursor++ } + v.table.SetCursor(v.cursor) + return true, nil case "k", keyUp: - if !v.searching { - if v.cursor > 0 { - v.cursor-- - } - v.table.SetCursor(v.cursor) - return true, nil + if v.cursor > 0 { + v.cursor-- + } + v.table.SetCursor(v.cursor) + return true, nil + case "1", "2", "3", "4": + idx := int(key[0] - '1') + if idx < len(svcFilterLabels) { + v.filterIdx = idx + v.cursor = 0 + v.table.SetCursor(0) + v.table.SetRows(v.filteredRows()) } + return true, nil + case "e": + v.expandDetail = !v.expandDetail + if v.expandDetail { + v.split.Ratio = svcRatioExpanded + } else { + v.split.Ratio = svcRatioNormal + } + return true, nil + case "c": + if v.cursor < len(v.filteredServices()) { + svc := v.filteredServices()[v.cursor] + _ = copyToClipboard(svc.ArcImage) + } + return true, nil } return false, nil } +// filteredServices returns services matching the active category filter. +func (v *ServicesList) filteredServices() []*catalog.Service { + if v.filterIdx == 0 || v.filterIdx >= len(svcFilterRoles) { + return v.services + } + role := svcFilterRoles[v.filterIdx] + out := make([]*catalog.Service, 0, len(v.services)) + for _, s := range v.services { + if s.Role == role { + out = append(out, s) + } + } + return out +} + +// filteredRows returns table rows matching the active category filter + search query. +// It injects the pulse state into status icons and a β–Ά cursor on the selected row. +func (v *ServicesList) filteredRows() []bubblestable.Row { + svcs := v.filteredServices() + rows := buildServiceRows(svcs, v.pulse) + q := strings.ToLower(v.search.Value()) + rows = filterServiceRows(rows, q) + if v.cursor >= 0 && v.cursor < len(rows) { + rows[v.cursor][0] = svcIconCursor + } + return rows +} + +// copyToClipboard writes text to the system clipboard. +func copyToClipboard(text string) error { + var cmd *exec.Cmd + switch runtime.GOOS { + case osDarwin: + cmd = exec.Command("pbcopy") + default: + cmd = exec.Command("xclip", "-selection", "clipboard") + } + cmd.Stdin = strings.NewReader(text) + return cmd.Run() +} + // Update implements engine.View. func (v *ServicesList) Update(msg tea.Msg) (engine.View, tea.Cmd) { if !v.ready { return v, nil } + if _, ok := msg.(svcPulseMsg); ok { + v.pulse = !v.pulse + return v, svcPulseTick() + } if keyMsg, ok := msg.(tea.KeyMsg); ok { if handled, cmd := v.handleKey(keyMsg.String()); handled { return v, cmd @@ -136,8 +253,7 @@ func (v *ServicesList) Update(msg tea.Msg) (engine.View, tea.Cmd) { if v.searching { var cmd tea.Cmd v.search, cmd = v.search.Update(msg) - q := strings.ToLower(v.search.Value()) - v.table.SetRows(filterServiceRows(v.allRows, q)) + v.table.SetRows(v.filteredRows()) v.cursor = 0 v.table.SetCursor(0) return v, cmd @@ -168,19 +284,20 @@ func (v *ServicesList) View() string { } leftW, rightW := v.split.Widths(w) - cols := servicesTableColumns(leftW) tableH := v.ctx.Height - 5 if tableH < 5 { tableH = 5 } - // Rebuild table for current dimensions. - leftTable := component.NewTable(tc, cols, v.allRows, leftW, tableH) - q := "" - if v.searching { - q = strings.ToLower(v.search.Value()) - leftTable.SetRows(filterServiceRows(v.allRows, q)) + // Rebuild table for current dimensions using active filter + search. + // Use leftW-2 so the table fits inside its RoundedBorder container (border adds 2 chars). + tableW := leftW - 2 + if tableW < 10 { + tableW = 10 } + cols := servicesTableColumns(tableW, maxCodenameWidth(v.services)) + currentRows := v.filteredRows() + leftTable := component.NewTable(tc, cols, currentRows, tableW, tableH) leftTable.SetCursor(v.cursor) var muted, border, primary, success, warning string @@ -192,6 +309,25 @@ func (v *ServicesList) View() string { warning = tc.Theme().Colors.Warning } + // ── Category filter tabs (1=All 2=Infra 3=Service 4=Sidecar) ───────── + activeTabStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color(primary)).Bold(true). + Padding(0, 1) + inactiveTabStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color(muted)). + Padding(0, 1) + tabParts := make([]string, len(svcFilterLabels)) + for i, label := range svcFilterLabels { + count := svcCountForRole(v.services, svcFilterRoles[i]) + text := fmt.Sprintf("%d:%s(%d)", i+1, label, count) + if i == v.filterIdx { + tabParts[i] = activeTabStyle.Render(text) + } else { + tabParts[i] = inactiveTabStyle.Render(text) + } + } + filterBar := lipgloss.JoinHorizontal(lipgloss.Top, tabParts...) + // ── gh-dash style search bar (always visible) ───────────────────────── searchIcon := lipgloss.NewStyle().Foreground(lipgloss.Color(primary)).Render("πŸ”") searchContent := searchIcon + " " + v.search.View() @@ -208,28 +344,38 @@ func (v *ServicesList) View() string { Render(searchContent) // ── Count + hint line ────────────────────────────────────────────────── - displayed := len(filterServiceRows(v.allRows, q)) - released := countReleasedSvcs(v.services) - countStr := fmt.Sprintf(" %d/%d", displayed, len(v.allRows)) + released := countReleasedSvcs(v.filteredServices()) + countStr := fmt.Sprintf(" %d/%d", len(currentRows), len(v.allRows)) countStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(success)).Bold(true) hintStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(muted)) - releasedStr := fmt.Sprintf(" ● %d available ⚠ %d soon", released, len(v.services)-released) + releasedStr := fmt.Sprintf(" ● %d available ⚠ %d soon", released, len(v.filteredServices())-released) hint := lipgloss.JoinHorizontal(lipgloss.Top, countStyle.Render(countStr), hintStyle.Render(releasedStr), - hintStyle.Render(" [/] search [esc] clear"), + hintStyle.Render(" [/] search [c] copy [e] expand"), ) + borderedTable := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color(border)). + Width(tableW). + Render(leftTable.View()) + leftPane := lipgloss.JoinVertical(lipgloss.Left, + filterBar, searchBar, hint, - leftTable.View(), + borderedTable, ) var rightPane string - if rightW > 0 && v.cursor < len(v.services) { - rightPane = renderServiceDetailCard(tc, v.services[v.cursor], rightW, success, warning) + filteredSvcs := v.filteredServices() + if rightW > 0 && v.cursor < len(filteredSvcs) { + // Offset the right pane downward so its border top aligns with the table border top. + headerH := lipgloss.Height(filterBar) + lipgloss.Height(searchBar) + lipgloss.Height(hint) + card := renderServiceDetailCard(tc, filteredSvcs[v.cursor], rightW-1, success, warning) + rightPane = strings.Repeat("\n", headerH) + card } return v.split.Render(leftPane, rightPane, w) @@ -242,40 +388,73 @@ func (v *ServicesList) Name() string { return "Services" } func (v *ServicesList) Keybindings() []engine.KeyBinding { return []engine.KeyBinding{ {Key: "j/k", Desc: "navigate"}, + {Key: "1-4", Desc: "filter"}, {Key: "/", Desc: "search"}, + {Key: "c", Desc: "copy image"}, + {Key: "e", Desc: "expand"}, {Key: "esc", Desc: "clear"}, } } // ── table helpers ───────────────────────────────────────────────────────────── -func servicesTableColumns(leftW int) []bubblestable.Column { - statusW := 3 - roleW := 9 - codeW := leftW * 22 / 100 - if codeW < 10 { - codeW = 10 +func servicesTableColumns(leftW, codeW int) []bubblestable.Column { + const statusW = 3 + const roleW = 8 + const portW = 6 + const techW = 14 // wide enough for "PostgreSQL", "TypeScript", etc. + const maxImgW = 22 // cap so Arc Image column doesn't hog remaining space + if codeW < 9 { + codeW = 9 + } + imgW := leftW - statusW - roleW - portW - techW - codeW - 8 + if imgW > maxImgW { + imgW = maxImgW } - imgW := leftW - statusW - codeW - roleW - 6 if imgW < 10 { imgW = 10 } return []bubblestable.Column{ {Title: " ", Width: statusW}, - {Title: "Codename", Width: codeW}, {Title: "Arc Image", Width: imgW}, + {Title: "Tech", Width: techW}, {Title: "Role", Width: roleW}, + {Title: "Port", Width: portW}, + {Title: "Codename", Width: codeW}, } } -func buildServiceRows(services []*catalog.Service) []bubblestable.Row { +// maxCodenameWidth returns the pixel-width of the longest title-cased codename +// across all services, used to size the Codename column exactly. +func maxCodenameWidth(services []*catalog.Service) int { + w := 0 + for _, svc := range services { + n := len([]rune(svcTitleCaser.String(svc.Codename))) + if n > w { + w = n + } + } + return w + 2 // +2 for cell padding breathing room +} + +func svcFirstPort(svc *catalog.Service) string { + if len(svc.Ports) > 0 { + return fmt.Sprintf("%d", svc.Ports[0].Host) + } + return "─" +} + +func buildServiceRows(services []*catalog.Service, pulse ...bool) []bubblestable.Row { + p := len(pulse) > 0 && pulse[0] rows := make([]bubblestable.Row, 0, len(services)) for _, svc := range services { rows = append(rows, bubblestable.Row{ - svcStatusIcon(svc.Released), - svcTitleCaser.String(svc.Codename), + svcStatusIcon(svc.Released, p), svcArcImageName(svc), + svc.Technology, svcRoleShort(svc.Role), + svcFirstPort(svc), + svcTitleCaser.String(svc.Codename), }) } return rows @@ -307,13 +486,32 @@ func countReleasedSvcs(services []*catalog.Service) int { return n } +// svcCountForRole returns the count of services matching role (empty role = all). +func svcCountForRole(services []*catalog.Service, role catalog.ServiceRole) int { + if role == "" { + return len(services) + } + n := 0 + for _, s := range services { + if s.Role == role { + n++ + } + } + return n +} + const ( svcIconReleased = "●" + svcIconPulse = "β—‹" // alternate dot for the heartbeat animation svcIconUnreleased = "⚠" + svcIconCursor = "β–Ά" ) -func svcStatusIcon(released bool) string { +func svcStatusIcon(released, pulse bool) string { if released { + if pulse { + return svcIconPulse + } return svcIconReleased } return svcIconUnreleased @@ -334,23 +532,20 @@ func svcArcImageName(svc *catalog.Service) string { return img } -func svcGhcrImage(svc *catalog.Service) string { - if svc.ArcImage != "" { - return "ghcr.io/arc-framework/" + svc.ArcImage + ":latest" - } - return svc.Image -} - func svcRoleShort(role catalog.ServiceRole) string { switch role { case catalog.RoleInfrastructure: - return "Infra" + return roleShortInfra + case catalog.RoleService: + return roleShortService + case catalog.RoleSidecar: + return roleShortSidecar case catalog.RoleData: - return "Data" + return roleShortInfra // legacy: treat as Infra case catalog.RoleAI: - return "AI" + return roleShortService // legacy: treat as Service case catalog.RoleObservability: - return "Obs" + return roleShortInfra // legacy: treat as Infra default: return string(role) } @@ -358,19 +553,61 @@ func svcRoleShort(role catalog.ServiceRole) string { func svcRoleEmoji(role catalog.ServiceRole) string { switch role { - case catalog.RoleInfrastructure: + case catalog.RoleInfrastructure, catalog.RoleData, catalog.RoleObservability: return "πŸ—" - case catalog.RoleData: - return "πŸ—„" - case catalog.RoleAI: + case catalog.RoleService, catalog.RoleAI: return "πŸ€–" - case catalog.RoleObservability: - return "πŸ“Š" default: - return "βš™" + return emojiSidecar } } +// svcRenderDesc renders a service description as word-wrapped italic text. +func svcRenderDesc(desc string, width int) string { + return lipgloss.NewStyle().Italic(true).Render(svcWordWrap(desc, width)) +} + +// svcWordWrap breaks text at word boundaries to fit within width columns. +func svcWordWrap(text string, width int) string { + if width <= 0 { + return text + } + words := strings.Fields(text) + var lines []string + var line strings.Builder + col := 0 + for _, w := range words { + wl := len([]rune(w)) + if col > 0 && col+1+wl > width { + lines = append(lines, line.String()) + line.Reset() + col = 0 + } + if col > 0 { + line.WriteByte(' ') + col++ + } + line.WriteString(w) + col += wl + } + if line.Len() > 0 { + lines = append(lines, line.String()) + } + return strings.Join(lines, "\n") +} + +// svcTruncate shortens s to n runes, replacing the tail with "…" if needed. +func svcTruncate(s string, n int) string { + runes := []rune(s) + if len(runes) <= n { + return s + } + if n <= 1 { + return "…" + } + return string(runes[:n-1]) + "…" +} + // ── detail card ─────────────────────────────────────────────────────────────── // renderServiceDetailCard renders the right-pane detail panel for a service. @@ -379,18 +616,32 @@ func renderServiceDetailCard(tc *theme.Context, svc *catalog.Service, width int, return "" } - var primary, muted, fg, border string + var primary, secondary, muted, fg, border string if tc != nil { c := tc.Theme().Colors primary = c.Primary + secondary = c.Secondary muted = c.Muted fg = c.Foreground border = c.Border } - innerW := width - 4 + // border(2) + padding(4: 2 each side) = 6 chars overhead. + innerW := width - 6 + + // ── Logo header: big service name ──────────────────────────────────── + emoji := svcRoleEmoji(svc.Role) + nameStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(primary)).Bold(true) + logoHeader := nameStyle.Render(emoji + " " + strings.ToUpper(svc.Codename)) + + // ── Sub-header: arc-image badge + status inline ─────────────────────── + arcImgBadge := lipgloss.NewStyle(). + Background(lipgloss.Color(primary)). + Foreground(lipgloss.Color("#0D0014")). + Bold(true). + Padding(0, 1). + Render(svcArcImageName(svc)) - // ── Status badge ────────────────────────────────────────────────────── var statusColor, statusIcon, statusLabel string if svc.Released { statusColor = successColor @@ -404,96 +655,96 @@ func renderServiceDetailCard(tc *theme.Context, svc *catalog.Service, width int, statusBadge := lipgloss.NewStyle(). Foreground(lipgloss.Color(statusColor)). Bold(true). - Render(statusIcon + " " + statusLabel) - - // ── Header ──────────────────────────────────────────────────────────── - emoji := svcRoleEmoji(svc.Role) - name := lipgloss.NewStyle(). - Foreground(lipgloss.Color(primary)). - Bold(true). - Render(emoji + " " + strings.ToUpper(svc.Codename)) - - ghcrImg := lipgloss.NewStyle(). - Foreground(lipgloss.Color(muted)). - Render(svcGhcrImage(svc)) + Render(statusIcon + " " + statusLabel) - headerBlock := lipgloss.JoinVertical(lipgloss.Left, - name, - ghcrImg, - "", - statusBadge, - ) + subHeader := lipgloss.JoinHorizontal(lipgloss.Top, arcImgBadge, " ", statusBadge) // ── Divider ─────────────────────────────────────────────────────────── divStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(border)) div := divStyle.Render(strings.Repeat("─", innerW)) + // ── Description ─────────────────────────────────────────────────────── + sections := []string{logoHeader, subHeader, "", div} + if svc.Description != "" { + rendered := svcRenderDesc(svc.Description, innerW) + sections = append(sections, rendered, div) + } + // ── Meta table ──────────────────────────────────────────────────────── - labelStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(muted)).Width(10) + labelStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(muted)).Width(9) valueStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(fg)) + accentVal := lipgloss.NewStyle().Foreground(lipgloss.Color(secondary)).Bold(true) - metaRow := func(label, value string) string { - return lipgloss.JoinHorizontal(lipgloss.Top, - labelStyle.Render(label), - valueStyle.Render(value), - ) + metaRow := func(label, value string, accent bool) string { + val := valueStyle.Render(value) + if accent { + val = accentVal.Render(value) + } + return lipgloss.JoinHorizontal(lipgloss.Top, labelStyle.Render(label), val) } tech := svc.Technology if svc.Version != "" && svc.Version != "latest" { - tech += " " + svc.Version + tech += " v" + svc.Version } + // Image points to the GHCR registry path for this service. + ghcrImage := "ghcr.io/arc-framework/" + svcArcImageName(svc) + ":latest" + ghcrImage = svcTruncate(ghcrImage, innerW-10) // 9-char label col + 1 space metaBlock := lipgloss.JoinVertical(lipgloss.Left, - metaRow("Tech", tech), - metaRow("Role", string(svc.Role)), - metaRow("Image", svc.Image), + metaRow("Tech", tech, true), + metaRow("Role", string(svc.Role), false), + metaRow("Image", ghcrImage, false), ) - - sections := []string{headerBlock, div, metaBlock} - - // ── Description ─────────────────────────────────────────────────────── - if svc.Description != "" { - descStyle := lipgloss.NewStyle(). - Foreground(lipgloss.Color(fg)). - Width(innerW). - Italic(true) - sections = append(sections, div, descStyle.Render(svc.Description)) - } + sections = append(sections, "", metaBlock) // ── Ports ───────────────────────────────────────────────────────────── if len(svc.Ports) > 0 { - portHeader := labelStyle.Render("Ports") - portLines := []string{portHeader} + portBadges := make([]string, 0, len(svc.Ports)) for _, p := range svc.Ports { proto := p.Protocol if proto == "" { proto = "tcp" } - portLines = append(portLines, - lipgloss.NewStyle().Foreground(lipgloss.Color(muted)). - Render(fmt.Sprintf(" %d β†’ %d %s", p.Host, p.Container, proto)), + portBadges = append(portBadges, + lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color(border)). + Foreground(lipgloss.Color(secondary)). + Padding(0, 1). + Render(fmt.Sprintf("%d %s", p.Host, proto)), ) } - sections = append(sections, div, strings.Join(portLines, "\n")) + portRow := lipgloss.JoinHorizontal(lipgloss.Top, portBadges...) + sections = append(sections, "", + div, + "", + lipgloss.JoinVertical(lipgloss.Left, + labelStyle.Render("Ports"), + portRow, + ), + ) } // ── Commands ────────────────────────────────────────────────────────── cmdAccent := lipgloss.NewStyle().Foreground(lipgloss.Color(primary)) cmdMuted := lipgloss.NewStyle().Foreground(lipgloss.Color(muted)) - codename := svc.Codename + name := svc.Codename cmdLines := lipgloss.JoinVertical(lipgloss.Left, - labelStyle.Render("Commands"), - cmdAccent.Render(" arc up ")+cmdMuted.Render(codename), - cmdAccent.Render(" arc logs ")+cmdMuted.Render(codename), - cmdAccent.Render(" arc status ")+cmdMuted.Render(codename), - cmdAccent.Render(" arc doctor ")+cmdMuted.Render(codename), - cmdAccent.Render(" arc shell ")+cmdMuted.Render(codename), + labelStyle.Render("Run"), + cmdAccent.Render(" arc up ")+cmdMuted.Render(name), + cmdAccent.Render(" arc logs ")+cmdMuted.Render(name), + cmdAccent.Render(" arc status ")+cmdMuted.Render(name), + cmdAccent.Render(" arc doctor ")+cmdMuted.Render(name), + cmdAccent.Render(" arc shell ")+cmdMuted.Render(name), ) - sections = append(sections, div, cmdLines) + sections = append(sections, "", div, "", cmdLines) content := strings.Join(sections, "\n") + // Width(width-6): lipgloss Width is content-only; total = Width + padding(4) + border(2) = Width+6. return lipgloss.NewStyle(). - Width(width). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color(border)). + Width(width-6). Padding(1, 2). Render(content) } diff --git a/pkg/ui/view/testdata/golden/config-confirming.golden b/pkg/ui/view/testdata/golden/config-confirming.golden index 13a73c1..3ab9461 100644 --- a/pkg/ui/view/testdata/golden/config-confirming.golden +++ b/pkg/ui/view/testdata/golden/config-confirming.golden @@ -1,7 +1,32 @@ - Enterprise [active] Saiyan -β–Ά Saiyan #FACC15 #EF4444 - PokΓ©mon Super Saiyan / Super Saiyan Blue / Ultra Instinct - Jedi - Pirate Dragon Ball Z transformation levels for power users - -Apply "Saiyan"? [y/N] \ No newline at end of file + + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ β”‚ + β–ˆβ–ˆ Default Profile β”‚ β–„β–€β–ˆ β–ˆβ–€β–ˆ β–ˆβ–€β–€ β”‚ + Think Β· Reason Β· Ultra Instinct β”‚ β–ˆβ–€β–ˆ β–ˆβ–€β–„ β–ˆβ–„β–„ β”‚ + β”‚ β”‚ + β–Ά β–ˆβ–ˆ Enterprise β˜… active β”‚ β—ˆ Enterprise β—ˆ β”‚ + Starter Β· Pro Β· Ultra β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + + β–ˆβ–ˆ Dev Enterprise Professional corporate naming for business + Debug Β· Build Β· Ship environments + + ╭────────────────────────────────────────╠+ β”‚ β”‚ ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ + β”‚ β—ˆ Apply profile Enterprise? β”‚ + β”‚ β”‚ + β”‚ Y confirm N cancel β”‚ + β”‚ β”‚ + ╰────────────────────────────────────────╯ + + ── Tier Progression [✨ coming soon] + ───────────────────────────────── + ╭────────────╠╭────────────╠╭────────────╠+ β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ + β”‚ (error) β”‚ ──▢ β”‚ (error) β”‚ ──▢ β”‚ (error) β”‚ + ╰────────────╯ ╰────────────╯ ╰────────────╯ + + ── Profile Info + ────────────────────────────────────────────────────── + ID enterprise Theme cyan-purple + Status Active β˜… + \ No newline at end of file diff --git a/pkg/ui/view/testdata/golden/config-cursor-moved.golden b/pkg/ui/view/testdata/golden/config-cursor-moved.golden index 9abe80e..dad5a8e 100644 --- a/pkg/ui/view/testdata/golden/config-cursor-moved.golden +++ b/pkg/ui/view/testdata/golden/config-cursor-moved.golden @@ -1,5 +1,31 @@ - Enterprise [active] PokΓ©mon - Saiyan #FFCB05 #3D7DCA -β–Ά PokΓ©mon Basic / Stage 1 / Stage 2 - Jedi - Pirate PokΓ©mon evolution stages for collectors \ No newline at end of file + + _______ ________ _________ + ___ |___ __ \__ ____/ + β–ˆβ–ˆ Default Profile __ /| |__ /_/ /_ / + Think Β· Reason Β· Ultra Instinct _ ___ |_ _, _/ / /___ + /_/ |_|/_/ |_| \____/ + β–ˆβ–ˆ Enterprise β˜… active + Starter Β· Pro Β· Ultra Ships Fast. Breaks Nothing. Built Different. + + β–Ά β–ˆβ–ˆ Dev Dev Ignite your workflow β€” from first spark to full inferno + Debug Β· Build Β· Ship + ── Color Palette + β–ˆβ–ˆ Nebula ───────────────────────────────────────────────────── + Orbit Β· Stellar Β· Cosmic β–ˆβ–ˆ Primary #C6A0F6 β–ˆβ–ˆ Secondary #8AADF4 + β–ˆβ–ˆ Accent #8AADF4 β–ˆβ–ˆ Foreground #CAD3F5 + β–ˆβ–ˆ Background #24273A β–ˆβ–ˆ Success #A6DA95 + β–ˆβ–ˆ Warning #EED49F β–ˆβ–ˆ Error #ED8796 + β–ˆβ–ˆ Muted #6E738D β–ˆβ–ˆ Border #363A4F + + ── Tier Progression [✨ coming soon] + ───────────────────────────────── + ╭────────────╠╭────────────╠╭────────────╠+ β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ + β”‚ (error) β”‚ ──▢ β”‚ (error) β”‚ ──▢ β”‚ (error) β”‚ + ╰────────────╯ ╰────────────╯ ╰────────────╯ + + ── Profile Info + ────────────────────────────────────────────────────── + ID dev Theme fire + Status Available + \ No newline at end of file diff --git a/pkg/ui/view/testdata/golden/config-narrow.golden b/pkg/ui/view/testdata/golden/config-narrow.golden index 6ea9946..65de9c5 100644 --- a/pkg/ui/view/testdata/golden/config-narrow.golden +++ b/pkg/ui/view/testdata/golden/config-narrow.golden @@ -1,5 +1,36 @@ -β–Ά Enterprise [active] - Saiyan - PokΓ©mon - Jedi - Pirate \ No newline at end of file + + β–—β–„β–– β–—β–„β–„β–– β–—β–„β–„β–– + β–β–Œ β–β–Œβ–β–Œ β–β–Œβ–β–Œ + β–Ά β–ˆβ–ˆ Default Profile β–β–›β–€β–œβ–Œβ–β–›β–€β–šβ––β–β–Œ + Think Β· Reason Β· Ultra β–β–Œ β–β–Œβ–β–Œ β–β–Œβ–β–šβ–„β–„β–– + Instinct + Intelligence Engine + β–ˆβ–ˆ Enterprise β˜… active + Starter Β· Pro Β· Ultra Default Profile Default profile for Arc + CLI + β–ˆβ–ˆ Dev + Debug Β· Build Β· Ship ── Color Palette + ──────────────────────────── + β–ˆβ–ˆ Nebula β–ˆβ–ˆ Primary β–ˆβ–ˆ Secondary + Orbit Β· Stellar Β· #C6A0F6 #8AADF4 + Cosmic β–ˆβ–ˆ Accent β–ˆβ–ˆ Foreground + #8AADF4 #CAD3F5 + β–ˆβ–ˆ Background β–ˆβ–ˆ Success + #24273A #A6DA95 + β–ˆβ–ˆ Warning β–ˆβ–ˆ Error + #EED49F #ED8796 + β–ˆβ–ˆ Muted β–ˆβ–ˆ Border + #6E738D #363A4F + + ── Tier Progression [✨ coming soon] + ──────── + ╭────────╠╭────────╠╭────────╠+ β”‚ (err β”‚ β”‚ (err β”‚ β”‚ (err β”‚ + β”‚ or) β”‚ ──▢ β”‚ or) β”‚ ──▢ β”‚ or) β”‚ + ╰────────╯ ╰────────╯ ╰────────╯ + + ── Profile Info + ───────────────────────────── + ID default Theme default + Status Available + \ No newline at end of file diff --git a/pkg/ui/view/testdata/golden/config-normal.golden b/pkg/ui/view/testdata/golden/config-normal.golden index 65623e2..5b15053 100644 --- a/pkg/ui/view/testdata/golden/config-normal.golden +++ b/pkg/ui/view/testdata/golden/config-normal.golden @@ -1,5 +1,30 @@ -β–Ά Enterprise [active] Enterprise β˜… Currently Active - Saiyan #2563EB #7C3AED - PokΓ©mon Starter / Pro / Ultra - Jedi - Pirate Professional corporate naming for business environments \ No newline at end of file + + β–—β–„β–– β–—β–„β–„β–– β–—β–„β–„β–– + β–β–Œ β–β–Œβ–β–Œ β–β–Œβ–β–Œ + β–Ά β–ˆβ–ˆ Default Profile β–β–›β–€β–œβ–Œβ–β–›β–€β–šβ––β–β–Œ + Think Β· Reason Β· Ultra Instinct β–β–Œ β–β–Œβ–β–Œ β–β–Œβ–β–šβ–„β–„β–– + + β–ˆβ–ˆ Enterprise β˜… active Intelligence Engine + Starter Β· Pro Β· Ultra + Default Profile Default profile for Arc CLI + β–ˆβ–ˆ Dev + Debug Β· Build Β· Ship ── Color Palette + ───────────────────────────────────────────────────── + β–ˆβ–ˆ Nebula β–ˆβ–ˆ Primary #C6A0F6 β–ˆβ–ˆ Secondary #8AADF4 + Orbit Β· Stellar Β· Cosmic β–ˆβ–ˆ Accent #8AADF4 β–ˆβ–ˆ Foreground #CAD3F5 + β–ˆβ–ˆ Background #24273A β–ˆβ–ˆ Success #A6DA95 + β–ˆβ–ˆ Warning #EED49F β–ˆβ–ˆ Error #ED8796 + β–ˆβ–ˆ Muted #6E738D β–ˆβ–ˆ Border #363A4F + + ── Tier Progression [✨ coming soon] + ───────────────────────────────── + ╭────────────╠╭────────────╠╭────────────╠+ β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ + β”‚ (error) β”‚ ──▢ β”‚ (error) β”‚ ──▢ β”‚ (error) β”‚ + ╰────────────╯ ╰────────────╯ ╰────────────╯ + + ── Profile Info + ────────────────────────────────────────────────────── + ID default Theme default + Status Available + \ No newline at end of file diff --git a/pkg/ui/view/testdata/golden/services-cursor-moved.golden b/pkg/ui/view/testdata/golden/services-cursor-moved.golden index 080713a..f327c10 100644 --- a/pkg/ui/view/testdata/golden/services-cursor-moved.golden +++ b/pkg/ui/view/testdata/golden/services-cursor-moved.golden @@ -1,29 +1,40 @@ -β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” -β”‚ πŸ” > Filter services… β”‚ πŸ—„ POSTGRES -β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ ghcr.io/arc-framework/arc-db-sql:latest - 3/3 ● 3 available ⚠ 0 soon [/] search [esc] clear - Codename Arc Image Role ● Available - ● Heimdall arc-gateway Infra ────────────────────────────────────────────── - ● Jarvis arc-identity Infra Tech PostgreSQL 16 - ● Postgres arc-db-sql Data Role Data - Image postgres:16 - ────────────────────────────────────────────── - Primary relational database - ────────────────────────────────────────────── - Ports - 5432 β†’ 5432 tcp - ────────────────────────────────────────────── - Commands - arc up postgres - arc logs postgres - arc status postgres - arc doctor postgres - arc shell postgres - - - - - - - - \ No newline at end of file + 1:All(3) 2:Infra(2) 3:Service(0) 4:Sidecar(0) +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ πŸ” > Filter services… β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + 3/3 ● 3 available ⚠ 0 soon [/] search [c] copy [e] expand +╭───────────────────────────────────────────────────────────────────╠╭───────────────────────────────────────────╠+β”‚ Arc Image Tech Role Port β”‚ β”‚ β”‚ +β”‚Codename β”‚ β”‚ πŸ— POSTGRES β”‚ +│───────────────────────────────────────────────────────────────────│ β”‚ arc-db-sql ● Available β”‚ +│──── β”‚ β”‚ β”‚ +β”‚ ● arc-gateway Traefik Infra 80 Heimdalβ”‚ β”‚ ─────────────────────────────────────── β”‚ +β”‚ ● arc-identity Kratos Infra 4433 Jarvis β”‚ β”‚ ──── β”‚ +β”‚ β–Ά arc-db-sql PostgreSQL Infra 5432 Postgreβ”‚ β”‚ Primary relational database β”‚ +β”‚ β”‚ β”‚ ─────────────────────────────────────── β”‚ +β”‚ β”‚ β”‚ ──── β”‚ +β”‚ β”‚ β”‚ β”‚ +β”‚ β”‚ β”‚ Tech PostgreSQL v16 β”‚ +β”‚ β”‚ β”‚ Role Data β”‚ +β”‚ β”‚ β”‚ Image ghcr.io/arc-framework/arc-db- β”‚ +β”‚ β”‚ β”‚ sql… β”‚ +β”‚ β”‚ β”‚ β”‚ +β”‚ β”‚ β”‚ ─────────────────────────────────────── β”‚ +β”‚ β”‚ β”‚ ──── β”‚ +β”‚ β”‚ β”‚ β”‚ +β”‚ β”‚ β”‚ Ports β”‚ +β”‚ β”‚ β”‚ ╭───────────╠│ +β”‚ β”‚ β”‚ β”‚ 5432 tcp β”‚ β”‚ +β”‚ β”‚ β”‚ ╰───────────╯ β”‚ +β”‚ β”‚ β”‚ β”‚ +β”‚ β”‚ β”‚ ─────────────────────────────────────── β”‚ +β”‚ β”‚ β”‚ ──── β”‚ +β”‚ β”‚ β”‚ β”‚ +β”‚ β”‚ β”‚ Run β”‚ +β”‚ β”‚ β”‚ arc up postgres β”‚ +╰───────────────────────────────────────────────────────────────────╯ β”‚ arc logs postgres β”‚ + β”‚ arc status postgres β”‚ + β”‚ arc doctor postgres β”‚ + β”‚ arc shell postgres β”‚ + β”‚ β”‚ + ╰───────────────────────────────────────────╯ \ No newline at end of file diff --git a/pkg/ui/view/testdata/golden/services-narrow.golden b/pkg/ui/view/testdata/golden/services-narrow.golden index aeb6107..5d0f4a2 100644 --- a/pkg/ui/view/testdata/golden/services-narrow.golden +++ b/pkg/ui/view/testdata/golden/services-narrow.golden @@ -1,29 +1,33 @@ -β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” -β”‚ πŸ” > Filter services… β”‚ -β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ - 3/3 ● 3 available ⚠ 0 soon [/] search [esc] clear - Codename Arc Image Role - ● Heimdall arc-gateway Infra - ● Jarvis arc-identity Infra - ● Postgres arc-db-sql Data - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file + 1:All(3) 2:Infra(2) 3:Service(0) 4:Sidecar(0) +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ πŸ” > Filter services… β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + 3/3 ● 3 available ⚠ 0 soon [/] search [c] copy [e] expand +╭─────────────────────────────────────────────────────────────────────────────╠+β”‚ Arc Image Tech Role Port Codename β”‚ +│─────────────────────────────────────────────────────────────────────────── β”‚ +β”‚ β–Ά arc-gateway Traefik Infra 80 Heimdall β”‚ +β”‚ ● arc-identity Kratos Infra 4433 Jarvis β”‚ +β”‚ ● arc-db-sql PostgreSQL Infra 5432 Postgres β”‚ +β”‚ β”‚ +β”‚ β”‚ +β”‚ β”‚ +β”‚ β”‚ +β”‚ β”‚ +β”‚ β”‚ +β”‚ β”‚ +β”‚ β”‚ +β”‚ β”‚ +β”‚ β”‚ +β”‚ β”‚ +β”‚ β”‚ +β”‚ β”‚ +β”‚ β”‚ +β”‚ β”‚ +β”‚ β”‚ +β”‚ β”‚ +β”‚ β”‚ +β”‚ β”‚ +β”‚ β”‚ +β”‚ β”‚ +╰─────────────────────────────────────────────────────────────────────────────╯ \ No newline at end of file diff --git a/pkg/ui/view/testdata/golden/services-normal.golden b/pkg/ui/view/testdata/golden/services-normal.golden index 13827a2..75e44e0 100644 --- a/pkg/ui/view/testdata/golden/services-normal.golden +++ b/pkg/ui/view/testdata/golden/services-normal.golden @@ -1,29 +1,40 @@ -β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” -β”‚ πŸ” > Filter services… β”‚ πŸ— HEIMDALL -β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ ghcr.io/arc-framework/arc-gateway:latest - 3/3 ● 3 available ⚠ 0 soon [/] search [esc] clear - Codename Arc Image Role ● Available - ● Heimdall arc-gateway Infra ────────────────────────────────────────────── - ● Jarvis arc-identity Infra Tech Traefik 3.1 - ● Postgres arc-db-sql Data Role Infrastructure - Image traefik:v3.1 - ────────────────────────────────────────────── - API gateway and reverse proxy - ────────────────────────────────────────────── - Ports - 80 β†’ 80 tcp - 443 β†’ 443 tcp - ────────────────────────────────────────────── - Commands - arc up heimdall - arc logs heimdall - arc status heimdall - arc doctor heimdall - arc shell heimdall - - - - - - - \ No newline at end of file + 1:All(3) 2:Infra(2) 3:Service(0) 4:Sidecar(0) +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ πŸ” > Filter services… β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + 3/3 ● 3 available ⚠ 0 soon [/] search [c] copy [e] expand +╭───────────────────────────────────────────────────────────────────╠╭───────────────────────────────────────────╠+β”‚ Arc Image Tech Role Port β”‚ β”‚ β”‚ +β”‚Codename β”‚ β”‚ πŸ— HEIMDALL β”‚ +│───────────────────────────────────────────────────────────────────│ β”‚ arc-gateway ● Available β”‚ +│──── β”‚ β”‚ β”‚ +β”‚ β–Ά arc-gateway Traefik Infra 80 Heimdalβ”‚ β”‚ ─────────────────────────────────────── β”‚ +β”‚ ● arc-identity Kratos Infra 4433 Jarvis β”‚ β”‚ ──── β”‚ +β”‚ ● arc-db-sql PostgreSQL Infra 5432 Postgreβ”‚ β”‚ API gateway and reverse proxy β”‚ +β”‚ β”‚ β”‚ ─────────────────────────────────────── β”‚ +β”‚ β”‚ β”‚ ──── β”‚ +β”‚ β”‚ β”‚ β”‚ +β”‚ β”‚ β”‚ Tech Traefik v3.1 β”‚ +β”‚ β”‚ β”‚ Role Infrastructure β”‚ +β”‚ β”‚ β”‚ Image ghcr.io/arc-framework/arc- β”‚ +β”‚ β”‚ β”‚ gatewa… β”‚ +β”‚ β”‚ β”‚ β”‚ +β”‚ β”‚ β”‚ ─────────────────────────────────────── β”‚ +β”‚ β”‚ β”‚ ──── β”‚ +β”‚ β”‚ β”‚ β”‚ +β”‚ β”‚ β”‚ Ports β”‚ +β”‚ β”‚ β”‚ ╭─────────β•╭──────────╠│ +β”‚ β”‚ β”‚ β”‚ 80 tcp β”‚β”‚ 443 tcp β”‚ β”‚ +β”‚ β”‚ β”‚ ╰─────────╯╰──────────╯ β”‚ +β”‚ β”‚ β”‚ β”‚ +β”‚ β”‚ β”‚ ─────────────────────────────────────── β”‚ +β”‚ β”‚ β”‚ ──── β”‚ +β”‚ β”‚ β”‚ β”‚ +β”‚ β”‚ β”‚ Run β”‚ +β”‚ β”‚ β”‚ arc up heimdall β”‚ +╰───────────────────────────────────────────────────────────────────╯ β”‚ arc logs heimdall β”‚ + β”‚ arc status heimdall β”‚ + β”‚ arc doctor heimdall β”‚ + β”‚ arc shell heimdall β”‚ + β”‚ β”‚ + ╰───────────────────────────────────────────╯ \ No newline at end of file diff --git a/pkg/ui/view/update_test.go b/pkg/ui/view/update_test.go index d007775..6a0c39e 100644 --- a/pkg/ui/view/update_test.go +++ b/pkg/ui/view/update_test.go @@ -18,24 +18,24 @@ import ( // ── ConfigOverview unit tests ───────────────────────────────────────────────── func TestConfigOverview_Name(t *testing.T) { - assert.Equal(t, "Config", NewConfigOverview(nil).Name()) + assert.Equal(t, "Config", NewConfigOverview(nil, nil).Name()) } func TestConfigOverview_Keybindings(t *testing.T) { - v := NewConfigOverview(nil) + v := NewConfigOverview(nil, nil) kbs := v.Keybindings() require.NotEmpty(t, kbs) } func TestConfigOverview_CapturesKeyboard(t *testing.T) { - v := NewConfigOverview(nil) + v := NewConfigOverview(nil, nil) assert.False(t, v.CapturesKeyboard()) v.confirming = true assert.True(t, v.CapturesKeyboard()) } func TestConfigOverview_Update_NotReady(t *testing.T) { - v := NewConfigOverview(nil) + v := NewConfigOverview(nil, nil) out, cmd := v.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("j")}) assert.Equal(t, v, out) assert.Nil(t, cmd) @@ -141,7 +141,7 @@ func TestServicesList_buildServiceRows_WithImage(t *testing.T) { { Codename: "mysvc", Technology: "Go", - Role: catalog.RoleData, + Role: catalog.RoleInfrastructure, Image: "myimage:latest", Released: true, }, @@ -149,9 +149,11 @@ func TestServicesList_buildServiceRows_WithImage(t *testing.T) { rows := buildServiceRows(svcs) require.Len(t, rows, 1) assert.Equal(t, "●", rows[0][0]) // status icon: released - assert.Equal(t, "Mysvc", rows[0][1]) // title-cased codename - assert.Equal(t, "myimage", rows[0][2]) // arc image name derived from image field - assert.Equal(t, "Data", rows[0][3]) // role short + assert.Equal(t, "myimage", rows[0][1]) // arc image derived from image field + assert.Equal(t, "Go", rows[0][2]) // technology + assert.Equal(t, "Infra", rows[0][3]) // role short + assert.Equal(t, "─", rows[0][4]) // no ports β†’ dash + assert.Equal(t, "Mysvc", rows[0][5]) // title-cased codename } func TestServicesList_buildServiceRows_NoImage(t *testing.T) { @@ -159,17 +161,19 @@ func TestServicesList_buildServiceRows_NoImage(t *testing.T) { { Codename: "bare", Technology: "None", - Role: catalog.RoleData, + Role: catalog.RoleService, Image: "", Released: false, }, } rows := buildServiceRows(svcs) require.Len(t, rows, 1) - assert.Equal(t, "⚠", rows[0][0]) // status icon: not released - assert.Equal(t, "Bare", rows[0][1]) // title-cased codename - assert.Equal(t, "", rows[0][2]) // no arc image - assert.Equal(t, "Data", rows[0][3]) // role short + assert.Equal(t, "⚠", rows[0][0]) // status icon: not released + assert.Equal(t, "", rows[0][1]) // no arc image + assert.Equal(t, "None", rows[0][2]) // technology + assert.Equal(t, "Service", rows[0][3]) // role short + assert.Equal(t, "─", rows[0][4]) // no ports β†’ dash + assert.Equal(t, "Bare", rows[0][5]) // title-cased codename } // ── WorkspaceHistory unit tests ─────────────────────────────────────────────── diff --git a/pkg/workspace/formatter.go b/pkg/workspace/formatter.go index 8b10501..2862124 100644 --- a/pkg/workspace/formatter.go +++ b/pkg/workspace/formatter.go @@ -13,9 +13,9 @@ import ( // Tier ID constants const ( - tierIDSuperSaiyan = "super-saiyan" - tierIDSuperSaiyanBlue = "super-saiyan-blue" - tierIDUltraInstinct = "ultra-instinct" + tierIDThink = "think" + tierIDReason = "reason" + tierIDUltraInstinct = "ultra-instinct" ) // Formatter handles formatting of workspace information for display @@ -62,9 +62,9 @@ func (f *Formatter) resolveTierName(tierID string) string { // getTierIndex maps tier IDs to tier indices (0-2) func getTierIndex(tierID string) int { switch tierID { - case tierIDSuperSaiyan: + case tierIDThink: return 0 - case tierIDSuperSaiyanBlue: + case tierIDReason: return 1 case tierIDUltraInstinct: return 2 diff --git a/pkg/workspace/formatter_test.go b/pkg/workspace/formatter_test.go index b9f3af5..2a35443 100644 --- a/pkg/workspace/formatter_test.go +++ b/pkg/workspace/formatter_test.go @@ -65,7 +65,7 @@ func TestFormatter_FormatWorkspaceInfo(t *testing.T) { WorkspaceRoot: "/path/to/workspace", ManifestPath: "/path/to/workspace/arc.yaml", ManifestVersion: "1.0.0", - Tier: "super-saiyan", + Tier: "think", EnabledFeatures: []string{}, } @@ -633,13 +633,13 @@ func TestGetTierIndex(t *testing.T) { wantIndex int }{ { - name: "super-saiyan maps to tier 0", - tierID: "super-saiyan", + name: "think maps to tier 0", + tierID: "think", wantIndex: 0, }, { - name: "super-saiyan-blue maps to tier 1", - tierID: "super-saiyan-blue", + name: "reason maps to tier 1", + tierID: "reason", wantIndex: 1, }, { @@ -721,8 +721,8 @@ func TestResolveTierName(t *testing.T) { wantMatch string // Use match instead of exact since profile resolution may vary }{ { - name: "super-saiyan resolves to tier name", - tierID: "super-saiyan", + name: "think resolves to tier name", + tierID: "think", wantMatch: "", // Will fallback to "Tier 1" or resolve to profile name }, { @@ -741,7 +741,7 @@ func TestResolveTierName(t *testing.T) { t.Run(tt.name, func(t *testing.T) { result := f.resolveTierName(tt.tierID) // For known tiers, verify we get either a profile name or generic fallback - if tt.tierID == "super-saiyan" || tt.tierID == "super-saiyan-blue" || tt.tierID == "ultra-instinct" { + if tt.tierID == "think" || tt.tierID == "reason" || tt.tierID == "ultra-instinct" { assert.NotEmpty(t, result, "Should return a tier name") } else if tt.wantMatch != "" { assert.Equal(t, tt.wantMatch, result) @@ -765,14 +765,14 @@ func TestTierIDMapping_BackwardCompatibility(t *testing.T) { description string }{ { - name: "super-saiyan maps to tier index 0", - tierID: "super-saiyan", + name: "think maps to tier index 0", + tierID: "think", expectedIndex: 0, description: "DBZ tier 1 maps to index 0 (Starter/Padawan/etc)", }, { - name: "super-saiyan-blue maps to tier index 1", - tierID: "super-saiyan-blue", + name: "reason maps to tier index 1", + tierID: "reason", expectedIndex: 1, description: "DBZ tier 2 maps to index 1 (Pro/Knight/etc)", }, @@ -928,8 +928,8 @@ func TestResolveTierName_EdgeCases(t *testing.T) { // Resolve tier names should fall back to generic names // since the profile doesn't exist - tier0 := f.resolveTierName("super-saiyan") - tier1 := f.resolveTierName("super-saiyan-blue") + tier0 := f.resolveTierName("think") + tier1 := f.resolveTierName("reason") tier2 := f.resolveTierName("ultra-instinct") // Should fallback to either generic tier names, Enterprise default, or Saiyan profile @@ -964,8 +964,8 @@ func TestResolveTierName_EdgeCases(t *testing.T) { // Don't create any preferences file // resolveTierName should handle this gracefully - tier0 := f.resolveTierName("super-saiyan") - tier1 := f.resolveTierName("super-saiyan-blue") + tier0 := f.resolveTierName("think") + tier1 := f.resolveTierName("reason") tier2 := f.resolveTierName("ultra-instinct") // Should fallback to either generic tier names, Enterprise, or Saiyan @@ -993,7 +993,7 @@ func TestResolveTierName_EdgeCases(t *testing.T) { require.NoError(t, err) // resolveTierName should handle this gracefully - tier0 := f.resolveTierName("super-saiyan") + tier0 := f.resolveTierName("think") // Should fallback to generic tier names assert.Equal(t, "Tier 1", tier0, "corrupted preferences should fallback to Tier 1") @@ -1026,7 +1026,7 @@ func TestResolveTierName_EdgeCases(t *testing.T) { require.NoError(t, err) // Resolve tier name should work even if something goes wrong - tier0 := f.resolveTierName("super-saiyan") + tier0 := f.resolveTierName("think") assert.NotEmpty(t, tier0, "should return a tier name even with edge cases") }) @@ -1046,8 +1046,8 @@ func TestResolveTierName_EdgeCases(t *testing.T) { done := make(chan bool) for i := 0; i < 10; i++ { go func() { - tier0 := f.resolveTierName("super-saiyan") - tier1 := f.resolveTierName("super-saiyan-blue") + tier0 := f.resolveTierName("think") + tier1 := f.resolveTierName("reason") tier2 := f.resolveTierName("ultra-instinct") assert.NotEmpty(t, tier0) @@ -1086,14 +1086,14 @@ func TestResolveTierName_AllKnownTierIDs(t *testing.T) { possibleResults []string // Multiple possible results due to profile variation }{ { - name: "super-saiyan resolves correctly", - tierID: "super-saiyan", + name: "think resolves correctly", + tierID: "think", expectedIndex: 0, possibleResults: []string{"Starter", "Tier 1"}, }, { - name: "super-saiyan-blue resolves correctly", - tierID: "super-saiyan-blue", + name: "reason resolves correctly", + tierID: "reason", expectedIndex: 1, possibleResults: []string{"Pro", "Tier 2"}, }, @@ -1163,8 +1163,8 @@ func TestResolveTierName_WithDifferentProfiles(t *testing.T) { require.NoError(t, err) // Resolve tier names - tier0 := f.resolveTierName("super-saiyan") - tier1 := f.resolveTierName("super-saiyan-blue") + tier0 := f.resolveTierName("think") + tier1 := f.resolveTierName("reason") tier2 := f.resolveTierName("ultra-instinct") // Verify they match the expected profile tier names diff --git a/pkg/workspace/generator.go b/pkg/workspace/generator.go index 24e4601..82cc21f 100644 --- a/pkg/workspace/generator.go +++ b/pkg/workspace/generator.go @@ -130,29 +130,35 @@ func (g *Generator) CleanGeneratedDir(workspaceRoot string) error { return nil } -// MapFeaturesToServices maps feature flags to service definitions +// MapFeaturesToServices maps feature flags to service definitions. +// Capabilities-wins rule: if Capabilities is non-empty it takes precedence over Features. func (g *Generator) MapFeaturesToServices(m *manifest.Manifest) ([]*services.ServiceDefinition, error) { mapper := services.NewMapper() - serviceList, err := mapper.MapFeaturesToServices(m) + caps := m.Capabilities + if len(caps) == 0 { + // Legacy path: convert feature flags to capability names. + caps = services.FeaturesToCapabilities(m.Features) + } + serviceList, err := mapper.ResolveServices(m.Tier, caps) if err != nil { return nil, fmt.Errorf("failed to map features to services: %w", err) } - return serviceList, nil } -// ValidateServiceDependencies ensures all required service dependencies are present +// ValidateServiceDependencies ensures all required service dependencies are present. +// Indexes by both ServiceName (arc-image) and CodeName (codename) since dependencies +// are expressed as catalog codenames. func (g *Generator) ValidateServiceDependencies(serviceList []*services.ServiceDefinition) error { - // Create a map of available services for quick lookup - availableServices := make(map[string]bool) + available := make(map[string]bool, len(serviceList)*2) for _, svc := range serviceList { - availableServices[svc.ServiceName] = true + available[svc.ServiceName] = true + available[svc.CodeName] = true } - // Check each service's dependencies for _, svc := range serviceList { for _, dep := range svc.Dependencies { - if !availableServices[dep] { + if !available[dep] { return fmt.Errorf("service %s depends on %s which is not enabled", svc.ServiceName, dep) } } @@ -167,14 +173,14 @@ func (g *Generator) ValidatePortConflicts(serviceList []*services.ServiceDefinit for _, svc := range serviceList { for _, port := range svc.Ports { - if existingService, exists := usedPorts[port]; exists { + if existingService, exists := usedPorts[port.Host]; exists { return &PortConflictError{ - Port: port, + Port: port.Host, Service1: existingService, Service2: svc.ServiceName, } } - usedPorts[port] = svc.ServiceName + usedPorts[port.Host] = svc.ServiceName } } @@ -211,16 +217,12 @@ func (g *Generator) HydrateServiceConfigs(workspaceRoot string, m *manifest.Mani Env: m.Environment, } - // Map of template names to output paths + // Map of template names to output paths. + // Only templates that exist (HasTemplate) are rendered β€” missing ones are silently skipped. + // Paths are relative to .arc/generated/ and must match the volume mounts in docker-compose.yml.tmpl. configTemplates := map[string]string{ - "gateway/traefik.yml.tmpl": "gateway/traefik.yml", - "security/kratos.yml.tmpl": "security/kratos.yml", - "observability/prometheus.yml.tmpl": "observability/prometheus.yml", - "observability/loki.yml.tmpl": "observability/loki.yml", - "observability/tempo.yml.tmpl": "observability/tempo.yml", - "observability/grafana.yml.tmpl": "observability/grafana.yml", - "observability/promtail.yml.tmpl": "observability/promtail.yml", "observability/otel-collector-config.yml.tmpl": "observability/otel-collector-config.yml", + "realtime/livekit.yaml.tmpl": "realtime/livekit.yaml", } for templateName, outputRelPath := range configTemplates { diff --git a/pkg/workspace/generator_test.go b/pkg/workspace/generator_test.go index c3f1090..11c98fa 100644 --- a/pkg/workspace/generator_test.go +++ b/pkg/workspace/generator_test.go @@ -188,8 +188,8 @@ func TestGenerator_MapFeaturesToServices(t *testing.T) { Version: "1.0.0", Features: map[string]bool{}, }, - wantServiceCount: 9, // Base infrastructure count - wantServices: []string{"arc-gateway", "arc-db-sql", "arc-db-cache"}, + wantServiceCount: 7, // Core services from profiles.yaml + wantServices: []string{"arc-gateway", "arc-messaging", "arc-sql-db"}, }, { name: "voice feature enabled", @@ -349,11 +349,11 @@ func TestGenerator_ValidatePortConflicts(t *testing.T) { services: []*services.ServiceDefinition{ { ServiceName: "arc-gateway", - Ports: []int{80, 443}, + Ports: []services.PortMapping{{Host: 80, Container: 80}, {Host: 443, Container: 443}}, }, { ServiceName: "arc-db-sql", - Ports: []int{5432}, + Ports: []services.PortMapping{{Host: 5432, Container: 5432}}, }, }, wantErr: false, @@ -363,11 +363,11 @@ func TestGenerator_ValidatePortConflicts(t *testing.T) { services: []*services.ServiceDefinition{ { ServiceName: "arc-gateway", - Ports: []int{80, 443}, + Ports: []services.PortMapping{{Host: 80, Container: 80}, {Host: 443, Container: 443}}, }, { ServiceName: "arc-api-gateway", - Ports: []int{80, 8080}, + Ports: []services.PortMapping{{Host: 80, Container: 80}, {Host: 8080, Container: 8080}}, }, }, wantErr: true, @@ -377,7 +377,7 @@ func TestGenerator_ValidatePortConflicts(t *testing.T) { services: []*services.ServiceDefinition{ { ServiceName: "arc-brain", - Ports: []int{}, + Ports: []services.PortMapping{}, }, }, wantErr: false, @@ -435,7 +435,7 @@ func TestGenerator_HydrateDockerCompose(t *testing.T) { ServiceName: "arc-gateway", CodeName: "Heimdall", ImageName: "traefik:v3.0", - Ports: []int{80, 443}, + Ports: []services.PortMapping{{Host: 80, Container: 80}, {Host: 443, Container: 443}}, }, } diff --git a/pkg/workspace/initializer.go b/pkg/workspace/initializer.go index 240c75e..9ef12d2 100644 --- a/pkg/workspace/initializer.go +++ b/pkg/workspace/initializer.go @@ -58,7 +58,14 @@ type InitializeOptions struct { Path string Force bool SkipGitignore bool - Tier string // Selected tier (e.g., "super-saiyan", "super-saiyan-blue", "ultra-instinct") + Tier string // Selected tier: "think", "reason", or "ultra-instinct" + Capabilities []string // Optional capability bundles (e.g. ["voice", "observe"]) +} + +// arcYAMLData is the data passed to the arc.yaml template. +type arcYAMLData struct { + Tier string + Capabilities []string } // Initialize initializes a new A.R.C. workspace @@ -91,13 +98,13 @@ func (i *Initializer) Initialize(opts InitializeOptions) error { // Create arc.yaml if it doesn't exist or force is true arcYAMLPath := filepath.Join(absPath, "arc.yaml") - // Set default tier if not specified tier := opts.Tier if tier == "" { - tier = tierIDUltraInstinct // Default to Ultra Instinct tier + tier = tierIDThink // Default to the think tier } - templateData := map[string]string{ - "Tier": tier, + templateData := arcYAMLData{ + Tier: tier, + Capabilities: opts.Capabilities, } if createErr := i.createFileFromTemplateWithData(arcYAMLPath, scaffold.ArcYAMLTemplate, templateData, opts.Force); createErr != nil { return fmt.Errorf("failed to create arc.yaml: %w", createErr) @@ -165,7 +172,7 @@ func (i *Initializer) createFileFromTemplate(path, content string, force bool) e } // createFileFromTemplateWithData creates a file from a template with data substitution -func (i *Initializer) createFileFromTemplateWithData(path, templateContent string, data map[string]string, force bool) error { +func (i *Initializer) createFileFromTemplateWithData(path, templateContent string, data any, force bool) error { // Check if file exists exists, err := afero.Exists(i.fs, path) if err != nil { diff --git a/pkg/workspace/manifest/manifest.go b/pkg/workspace/manifest/manifest.go index 9c66b96..182c26c 100644 --- a/pkg/workspace/manifest/manifest.go +++ b/pkg/workspace/manifest/manifest.go @@ -10,11 +10,12 @@ import ( // Manifest represents the parsed arc.yaml workspace manifest type Manifest struct { - Version string `yaml:"version"` - Tier string `yaml:"tier,omitempty"` // Platform tier (e.g., "super-saiyan", "jedi", "enterprise") - Features map[string]bool `yaml:"features"` - Services map[string]interface{} `yaml:"services,omitempty"` - Environment map[string]string `yaml:"environment,omitempty"` + Version string `yaml:"version"` + Tier string `yaml:"tier,omitempty"` + Capabilities []string `yaml:"capabilities,omitempty"` // canonical opt-in model + Features map[string]bool `yaml:"features,omitempty"` // deprecated β€” triggers warning + Services map[string]interface{} `yaml:"services,omitempty"` + Environment map[string]string `yaml:"environment,omitempty"` } // Parser handles manifest parsing and validation diff --git a/pkg/workspace/manifest/schema.go b/pkg/workspace/manifest/schema.go index d3196c6..0d05195 100644 --- a/pkg/workspace/manifest/schema.go +++ b/pkg/workspace/manifest/schema.go @@ -2,10 +2,35 @@ package manifest import ( "fmt" + "os" "regexp" + "strings" ) -// KnownFeatures lists all supported feature flags +// KnownTiers lists all valid tier names. +var KnownTiers = []string{"think", "reason", "ultra-instinct"} + +// KnownCapabilities lists all valid capability names. +var KnownCapabilities = []string{"reasoner", "voice", "observe", "security", "storage"} + +// legacyTierMigrations maps old tier names to their replacements. +var legacyTierMigrations = map[string]string{ + "super-saiyan": "think", + "super-saiyan-blue": "reason", + "free": "think", + "jedi": "think", + "enterprise": "ultra-instinct", +} + +// featureToCapabilityHint maps legacy feature keys to the capabilities migration hint. +var featureToCapabilityHint = map[string]string{ + "voice": "voice", + "security": "security", + "observability": "observe", +} + +// KnownFeatures lists feature keys that are still recognized for validation. +// chaos is retained so it produces the explicit removal warning. var KnownFeatures = map[string]bool{ "voice": true, "security": true, @@ -13,49 +38,122 @@ var KnownFeatures = map[string]bool{ "chaos": true, } -// Validator handles manifest schema validation +// Validator handles manifest schema validation. type Validator struct{} -// NewValidator creates a new manifest validator +// NewValidator creates a new manifest validator. func NewValidator() *Validator { return &Validator{} } -// Validate validates a manifest against the schema +// Validate validates a manifest against the schema. +// Returns a hard error for invalid/legacy tiers and unknown capabilities/features. +// Deprecation warnings for the features: field are written to stderr (non-blocking). func (v *Validator) Validate(m *Manifest) error { - // Validate version + // 1. Version is required and must be semver. if m.Version == "" { return fmt.Errorf("version is required") } - - // Validate version format (basic semver check) versionRegex := regexp.MustCompile(`^\d+\.\d+\.\d+$`) if !versionRegex.MatchString(m.Version) { return fmt.Errorf("version must be in semver format (e.g., 1.0.0), got: %s", m.Version) } - // Validate features - for featureName := range m.Features { + // 2. Tier validation. + if m.Tier != "" { + // 2a. Legacy tier β†’ hard error with migration message. + if replacement, isLegacy := legacyTierMigrations[m.Tier]; isLegacy { + return fmt.Errorf( + "tier %q is no longer valid.\nMigrate your arc.yaml: replace %q with %q", + m.Tier, m.Tier, replacement, + ) + } + // 2b. Unknown tier β†’ validation error. + if !isTierKnown(m.Tier) { + return fmt.Errorf( + "unknown tier %q. Valid tiers: %s", + m.Tier, strings.Join(KnownTiers, ", "), + ) + } + } + + // 3. Capabilities validation. + for i, capName := range m.Capabilities { + if !isCapabilityKnown(capName) { + return fmt.Errorf( + "unknown capability %q at capabilities[%d]. Valid capabilities: %s", + capName, i, strings.Join(KnownCapabilities, ", "), + ) + } + } + + // 4. Features validation: error on unknown keys, warnings for known deprecated ones. + if err := v.validateFeatures(m.Features); err != nil { + return err + } + + // 5. Service name format validation. + if err := v.validateServiceNames(m.Services); err != nil { + return err + } + + // 6. Environment variable name format validation. + return v.validateEnvVarNames(m.Environment) +} + +func (v *Validator) validateFeatures(features map[string]bool) error { + for featureName, enabled := range features { if !KnownFeatures[featureName] { return fmt.Errorf("unknown feature '%s'. Valid features: voice, security, observability, chaos", featureName) } + if !enabled { + continue + } + if capName, ok := featureToCapabilityHint[featureName]; ok { + fmt.Fprintf(os.Stderr, "Warning: features.%s=true β†’ use: capabilities: [%s]\n", featureName, capName) + } else if featureName == "chaos" { + fmt.Fprintf(os.Stderr, "Warning: features.chaos=true β†’ this feature has been removed and has no capabilities equivalent\n") + } } + return nil +} - // Validate service names (if any) +func (v *Validator) validateServiceNames(svcNames map[string]interface{}) error { serviceNameRegex := regexp.MustCompile(`^arc-[a-z][a-z0-9-]*$`) - for serviceName := range m.Services { + for serviceName := range svcNames { if !serviceNameRegex.MatchString(serviceName) { return fmt.Errorf("invalid service name '%s'. Service names must match pattern: arc-[a-z][a-z0-9-]*", serviceName) } } + return nil +} - // Validate environment variable names +func (v *Validator) validateEnvVarNames(env map[string]string) error { envVarRegex := regexp.MustCompile(`^[A-Z_][A-Z0-9_]*$`) - for envVar := range m.Environment { + for envVar := range env { if !envVarRegex.MatchString(envVar) { return fmt.Errorf("invalid environment variable name '%s'. Must match pattern: [A-Z_][A-Z0-9_]*", envVar) } } - return nil } + +// isTierKnown returns true if the tier is in KnownTiers. +func isTierKnown(tier string) bool { + for _, t := range KnownTiers { + if t == tier { + return true + } + } + return false +} + +// isCapabilityKnown returns true if the capability is in KnownCapabilities. +func isCapabilityKnown(capName string) bool { + for _, c := range KnownCapabilities { + if c == capName { + return true + } + } + return false +} diff --git a/pkg/workspace/services/data/profiles.yaml b/pkg/workspace/services/data/profiles.yaml new file mode 100644 index 0000000..08ee049 --- /dev/null +++ b/pkg/workspace/services/data/profiles.yaml @@ -0,0 +1,33 @@ +# pkg/workspace/services/data/profiles.yaml +# Mirrors arc-platform/services/profiles.yaml β€” capability-aware format. +# Update this file whenever the platform's profiles.yaml changes. + +core: + services: + - gateway + - messaging + - streaming + - cache + - persistence + - cortex + - friday-collector + +capabilities: + reasoner: + services: [reasoner] + voice: + services: [realtime, voice] + requires: [reasoner] + observe: + services: [otel] + security: + services: [vault, flags] + storage: + services: [storage] + +think: + capabilities: [] +reason: + capabilities: [reasoner] +ultra-instinct: + capabilities: "*" diff --git a/pkg/workspace/services/mapping.go b/pkg/workspace/services/mapping.go index 29c7d41..00eb063 100644 --- a/pkg/workspace/services/mapping.go +++ b/pkg/workspace/services/mapping.go @@ -1,151 +1,467 @@ -// Package services provides feature-to-service mapping for A.R.C. workspaces. -// It maps high-level feature flags (voice, security, observability) to concrete -// service definitions that will be included in the generated docker-compose.yml. +// Package services provides capability-based service resolution for A.R.C. workspaces. +// It maps tiers and capability bundles to concrete service definitions sourced from +// the embedded catalog, replacing the stale hardcoded GetMasterServiceTable(). package services import ( + _ "embed" "fmt" + "log" + "sort" + "gopkg.in/yaml.v3" + + "github.com/arc-framework/arc-cli/pkg/catalog" "github.com/arc-framework/arc-cli/pkg/workspace/manifest" ) -// Mapper handles feature-to-service mapping using a two-phase resolution: -// 1. Feature Mapping: Match enabled features to services that require those features -// 2. Dependency Resolution: Recursively include all service dependencies -// -// The mapping follows a declarative model where services declare which features -// they belong to via FeatureFlags, rather than features declaring which services -// they include. This allows new services to be added without modifying feature definitions. +//go:embed data/profiles.yaml +var profilesData []byte + +// ============================================================================= +// Profile types +// ============================================================================= + +// tierCapabilitiesValue decodes either a []string or "*" (wildcard) from YAML. +type tierCapabilitiesValue struct { + All bool + Names []string +} + +// UnmarshalYAML handles both `capabilities: "*"` and `capabilities: [a, b]`. +func (t *tierCapabilitiesValue) UnmarshalYAML(value *yaml.Node) error { + if value.Kind == yaml.ScalarNode && value.Value == "*" { + t.All = true + return nil + } + return value.Decode(&t.Names) +} + +// rawTierDef is the YAML structure for a tier entry. +type rawTierDef struct { + Capabilities tierCapabilitiesValue `yaml:"capabilities"` +} + +// capabilityConfig holds the service codenames and dependency chain for a capability. +type capabilityConfig struct { + Services []string `yaml:"services"` + Requires []string `yaml:"requires,omitempty"` +} + +// coreConfig lists always-on service codenames. +type coreConfig struct { + Services []string `yaml:"services"` +} + +// rawTopLevel is the full structure of profiles.yaml. +type rawTopLevel struct { + Core coreConfig `yaml:"core"` + Capabilities map[string]capabilityConfig `yaml:"capabilities"` + Think rawTierDef `yaml:"think"` + Reason rawTierDef `yaml:"reason"` + UltraInstinct rawTierDef `yaml:"ultra-instinct"` +} + +// ProfilesConfig is the parsed, queryable form of profiles.yaml. +type ProfilesConfig struct { + core coreConfig + capabilities map[string]capabilityConfig + tiers map[string]rawTierDef +} + +// loadProfiles parses profiles.yaml content into a ProfilesConfig. +func loadProfiles(data []byte) (*ProfilesConfig, error) { + var raw rawTopLevel + if err := yaml.Unmarshal(data, &raw); err != nil { + return nil, fmt.Errorf("profiles.yaml parse error: %w", err) + } + + tiers := map[string]rawTierDef{ + "think": raw.Think, + "reason": raw.Reason, + "ultra-instinct": raw.UltraInstinct, + } + + return &ProfilesConfig{ + core: raw.Core, + capabilities: raw.Capabilities, + tiers: tiers, + }, nil +} + +// mustLoadProfiles panics on parse error β€” same pattern as catalog. +// The embedded file is validated at build time; a panic here signals a broken binary. +func mustLoadProfiles(data []byte) *ProfilesConfig { + p, err := loadProfiles(data) + if err != nil { + panic(fmt.Sprintf("arc: embedded profiles.yaml is corrupt: %v", err)) + } + return p +} + +// CoreServiceNames returns the always-on service codenames. +func (p *ProfilesConfig) CoreServiceNames() []string { + out := make([]string, len(p.core.Services)) + copy(out, p.core.Services) + return out +} + +// TierCapabilities returns the capability names preset for a tier. +// ultra-instinct returns all known capability names. +// Unknown or empty tier returns an empty slice (equivalent to "think"). +func (p *ProfilesConfig) TierCapabilities(tier string) []string { + td, ok := p.tiers[tier] + if !ok { + return []string{} + } + if td.Capabilities.All { + // ultra-instinct: return all capability names + names := make([]string, 0, len(p.capabilities)) + for name := range p.capabilities { + names = append(names, name) + } + sort.Strings(names) + return names + } + out := make([]string, len(td.Capabilities.Names)) + copy(out, td.Capabilities.Names) + return out +} + +// ExpandCapabilities resolves the requires chain for a set of capability names. +// Example: ["voice"] β†’ ["voice", "reasoner"] (because voice requires reasoner). +func (p *ProfilesConfig) ExpandCapabilities(caps []string) []string { + seen := make(map[string]bool, len(caps)) + result := make([]string, 0, len(caps)) + + var expand func(name string) + expand = func(name string) { + if seen[name] { + return + } + seen[name] = true + // Expand requires first (dependency-first order) + if cfg, ok := p.capabilities[name]; ok { + for _, req := range cfg.Requires { + expand(req) + } + } + result = append(result, name) + } + + for _, capName := range caps { + expand(capName) + } + return result +} + +// capabilityServices returns the service codenames for a single capability. +func (p *ProfilesConfig) capabilityServices(capName string) []string { + cfg, ok := p.capabilities[capName] + if !ok { + return nil + } + out := make([]string, len(cfg.Services)) + copy(out, cfg.Services) + return out +} + +// ============================================================================= +// Catalog bridge +// ============================================================================= + +// buildServiceDefinition converts a catalog.Service into a ServiceDefinition. +// ServiceName is set to ArcImage (e.g. "arc-gateway") for backward compat. +// CodeName is set to the catalog codename (e.g. "gateway") for profile lookups. +func buildServiceDefinition(s *catalog.Service) *ServiceDefinition { + ports := make([]PortMapping, len(s.Ports)) + for i, p := range s.Ports { + ports[i] = PortMapping{Host: p.Host, Container: p.Container} + } + return &ServiceDefinition{ + ServiceName: s.ArcImage, + CodeName: s.Codename, + ArcImage: s.ArcImage, + ImageName: s.Image, + RequiredConfigs: s.ConfigFiles, + Dependencies: s.GetAllDependencyCodenames(), + FeatureFlags: []string{}, + Ports: ports, + } +} + +// buildTableFromCatalog builds the serviceTable keyed by codename. +func buildTableFromCatalog(cat *catalog.EmbeddedCatalog) map[string]*ServiceDefinition { + all := cat.AllServices() + table := make(map[string]*ServiceDefinition, len(all)) + for _, svc := range all { + table[svc.Codename] = buildServiceDefinition(svc) + } + return table +} + +// ============================================================================= +// Mapper +// ============================================================================= + +// Mapper resolves tiers and capabilities to concrete service lists. +// serviceTable is keyed by catalog codename (e.g. "gateway"). type Mapper struct { serviceTable map[string]*ServiceDefinition + profiles *ProfilesConfig +} + +// Profiles returns the loaded profiles config for testing and introspection. +func (m *Mapper) Profiles() *ProfilesConfig { + return m.profiles +} + +// LookupService finds a service by arc-image name or codename. +// This is the non-deprecated counterpart to the GetServiceByName shim. +func (m *Mapper) LookupService(name string) (*ServiceDefinition, bool) { + for _, svc := range m.serviceTable { + if svc.ServiceName == name || svc.CodeName == name { + return svc, true + } + } + return nil, false } -// NewMapper creates a new service mapper with the master service table. -// The service table is loaded once and cached for the lifetime of the mapper. +// NewMapper creates a new Mapper backed by the embedded catalog and profiles. +// Initialization is < 50ms and cached for the mapper's lifetime. func NewMapper() *Mapper { + cat, err := catalog.NewEmbeddedCatalog() + if err != nil { + panic(fmt.Sprintf("arc: failed to load embedded catalog: %v", err)) + } return &Mapper{ - serviceTable: GetMasterServiceTable(), + serviceTable: buildTableFromCatalog(cat), + profiles: mustLoadProfiles(profilesData), } } -// MapFeaturesToServices maps enabled features from a manifest to required services. -// -// The mapping algorithm works as follows: -// 1. Start with base infrastructure services (always included, e.g., arc-gateway) -// 2. For each enabled feature in the manifest, find all services that declare -// that feature in their FeatureFlags field -// 3. Recursively resolve dependencies to ensure all required services are included -// 4. Return the complete list of services to be generated -// -// Returns an error if a service references an unknown dependency. -func (m *Mapper) MapFeaturesToServices(mf *manifest.Manifest) ([]*ServiceDefinition, error) { - serviceMap := make(map[string]*ServiceDefinition) +// ResolveServices is the primary resolution path. +// tier: one of "think", "reason", "ultra-instinct". +// caps: additional capability names (e.g. ["voice", "observe"]). +// Returns a deterministic, deduplicated slice of ServiceDefinitions. +func (m *Mapper) ResolveServices(tier string, caps []string) ([]*ServiceDefinition, error) { + // 1. Expand tier presets + tierCaps := m.profiles.TierCapabilities(tier) - // Phase 1: Always include base infrastructure (gateway, etc.) - // These services are required regardless of which features are enabled - for _, svc := range GetBaseInfrastructure() { - serviceMap[svc.ServiceName] = svc - } + // 2. Merge with extra caps, deduplicate + merged := dedupeStrings(append(tierCaps, caps...)) - // Phase 2: Add services for each enabled feature - // Services declare which features they belong to via FeatureFlags - for featureName, enabled := range mf.Features { - if !enabled { - continue - } + // 3. Expand requires chain + expanded := m.profiles.ExpandCapabilities(merged) - // Find all services that match this feature flag - for _, svc := range m.serviceTable { - for _, requiredFeature := range svc.FeatureFlags { - if requiredFeature == featureName { - serviceMap[svc.ServiceName] = svc - } - } + // 4. Collect all service codenames + coreNames := m.profiles.CoreServiceNames() + capServiceNames := m.servicesForCapabilities(expanded) + + // 5. Build initial serviceMap keyed by codename + serviceMap := make(map[string]*ServiceDefinition) + for _, name := range coreNames { + if svc, ok := m.serviceTable[name]; ok { + serviceMap[name] = svc + } + } + for _, name := range capServiceNames { + if svc, ok := m.serviceTable[name]; ok { + serviceMap[name] = svc } } - // Phase 3: Resolve dependencies transitively - // If service A depends on B, and B depends on C, all three must be included + // 6. Resolve transitive dependencies resolved, err := m.resolveDependencies(serviceMap) if err != nil { return nil, err } - // Convert map to slice for return - services := make([]*ServiceDefinition, 0, len(resolved)) - for _, svc := range resolved { - services = append(services, svc) + // 7. Return in stable order + return m.stableOrder(resolved, coreNames, expanded), nil +} + +// servicesForCapabilities returns the union of service codenames for all caps. +func (m *Mapper) servicesForCapabilities(caps []string) []string { + seen := make(map[string]bool) + var result []string + for _, capName := range caps { + for _, svcName := range m.profiles.capabilityServices(capName) { + if !seen[svcName] { + seen[svcName] = true + result = append(result, svcName) + } + } + } + return result +} + +// servicesForNames returns ServiceDefinitions for the given codenames (best-effort). +func (m *Mapper) servicesForNames(names []string) []*ServiceDefinition { + result := make([]*ServiceDefinition, 0, len(names)) + for _, name := range names { + if svc, ok := m.serviceTable[name]; ok { + result = append(result, svc) + } + } + return result +} + +// stableOrder returns resolved services in a deterministic sequence: +// core first (profiles.yaml order), then capability services, then transitive deps. +func (m *Mapper) stableOrder(resolved map[string]*ServiceDefinition, coreNames, expandedCaps []string) []*ServiceDefinition { + seen := make(map[string]bool, len(resolved)) + result := make([]*ServiceDefinition, 0, len(resolved)) + + // Core in profiles.yaml order + for _, name := range coreNames { + if svc, ok := resolved[name]; ok && !seen[name] { + result = append(result, svc) + seen[name] = true + } + } + + // Capability services in stable order + for _, capName := range expandedCaps { + for _, svcName := range m.profiles.capabilityServices(capName) { + if svc, ok := resolved[svcName]; ok && !seen[svcName] { + result = append(result, svc) + seen[svcName] = true + } + } + } + + // Remaining transitive deps in alphabetical order + remaining := make([]string, 0, len(resolved)) + for name := range resolved { + if !seen[name] { + remaining = append(remaining, name) + } + } + sort.Strings(remaining) + for _, name := range remaining { + result = append(result, resolved[name]) } - return services, nil + return result +} + +// ============================================================================= +// Legacy shim +// ============================================================================= + +// featureToCapabilityMap maps legacy feature keys to capability names. +// chaos is intentionally absent β€” it has no capabilities equivalent. +var featureToCapabilityMap = map[string]string{ + "voice": "voice", + "security": "security", + "observability": "observe", +} + +// FeaturesToCapabilities converts legacy features map to capability names. +func FeaturesToCapabilities(features map[string]bool) []string { + var caps []string + for feature, enabled := range features { + if !enabled { + continue + } + if capName, ok := featureToCapabilityMap[feature]; ok { + caps = append(caps, capName) + } else if feature == "chaos" { + // chaos has been removed β€” no capabilities equivalent + log.Printf("Warning: features.chaos=true β†’ this feature has been removed and has no capabilities equivalent") + } + } + return caps } -// resolveDependencies ensures all service dependencies are transitively included. +// MapFeaturesToServices is the backwards-compat shim. // -// Uses a fixed-point iteration algorithm: -// 1. Start with the initial set of services -// 2. For each service, check if its dependencies are in the set -// 3. If not, add them and mark that changes were made -// 4. Repeat until no new dependencies are found (fixed point reached) -// 5. Detect circular dependencies by limiting iterations +// Deprecated: use ResolveServices. Must remain callable. // -// This ensures that if A β†’ B β†’ C (A depends on B, B depends on C), -// all three services are included when A is requested. -func (m *Mapper) resolveDependencies(serviceMap map[string]*ServiceDefinition) (map[string]*ServiceDefinition, error) { - resolved := make(map[string]*ServiceDefinition) +// If Capabilities is non-empty, it wins and Features are ignored (capabilities-wins rule). +// Otherwise, Features are converted to capabilities via FeaturesToCapabilities. +func (m *Mapper) MapFeaturesToServices(mf *manifest.Manifest) ([]*ServiceDefinition, error) { + // capabilities-wins rule (T081) + if len(mf.Capabilities) > 0 { + return m.ResolveServices(mf.Tier, mf.Capabilities) + } + // Legacy path: convert features to capabilities + caps := FeaturesToCapabilities(mf.Features) + return m.ResolveServices(mf.Tier, caps) +} - // Copy initial services to the resolved set +// ============================================================================= +// Dependency resolution +// ============================================================================= + +// resolveDependencies transitively includes all service dependencies. +// Uses fixed-point iteration; serviceMap is keyed by codename. +func (m *Mapper) resolveDependencies(serviceMap map[string]*ServiceDefinition) (map[string]*ServiceDefinition, error) { + resolved := make(map[string]*ServiceDefinition, len(serviceMap)) for name, svc := range serviceMap { resolved[name] = svc } - // Fixed-point iteration: keep adding dependencies until no changes occur changed := true - iterations := 0 - maxIterations := 10 // Safety limit to detect circular dependencies - - for changed && iterations < maxIterations { + const maxIterations = 20 + for i := 0; changed && i < maxIterations; i++ { changed = false - iterations++ - - // Check each resolved service's dependencies for _, svc := range resolved { - for _, depName := range svc.Dependencies { - // If dependency not yet resolved, add it - if _, exists := resolved[depName]; !exists { - depSvc, found := m.serviceTable[depName] - if !found { - return nil, fmt.Errorf("unknown dependency '%s' required by service '%s'", depName, svc.ServiceName) - } - resolved[depName] = depSvc - changed = true // Mark that we made a change, need another iteration + for _, dep := range svc.Dependencies { + if _, exists := resolved[dep]; exists { + continue } + depSvc, found := m.serviceTable[dep] + if !found { + return nil, fmt.Errorf("unknown dependency %q required by service %q", dep, svc.ServiceName) + } + resolved[dep] = depSvc + changed = true } } } - // If we hit max iterations, there's likely a circular dependency - if iterations >= maxIterations { + if changed { return nil, fmt.Errorf("circular dependency detected in service configuration") } return resolved, nil } -// ValidateDependencies checks that all dependencies are satisfied +// ValidateDependencies checks that all dependency codenames are satisfied +// within the provided service list. func (m *Mapper) ValidateDependencies(services []*ServiceDefinition) error { - serviceNames := make(map[string]bool, len(services)) + // Index by both ServiceName (arc-image) and CodeName (codename) for matching + known := make(map[string]bool, len(services)*2) for _, svc := range services { - serviceNames[svc.ServiceName] = true + known[svc.ServiceName] = true + known[svc.CodeName] = true } for _, svc := range services { for _, dep := range svc.Dependencies { - if !serviceNames[dep] { - return fmt.Errorf("service '%s' requires '%s', but it is not enabled", svc.ServiceName, dep) + if !known[dep] { + return fmt.Errorf("service %q requires %q, but it is not enabled", svc.ServiceName, dep) } } } - return nil } + +// ============================================================================= +// Helpers +// ============================================================================= + +// dedupeStrings returns a deduplicated slice preserving order. +func dedupeStrings(in []string) []string { + seen := make(map[string]bool, len(in)) + out := make([]string, 0, len(in)) + for _, s := range in { + if !seen[s] { + seen[s] = true + out = append(out, s) + } + } + return out +} diff --git a/pkg/workspace/services/mapping_test.go b/pkg/workspace/services/mapping_test.go index cd94fc7..cea5d4e 100644 --- a/pkg/workspace/services/mapping_test.go +++ b/pkg/workspace/services/mapping_test.go @@ -10,570 +10,358 @@ import ( "github.com/arc-framework/arc-cli/pkg/workspace/services" ) -func TestNewMapper(t *testing.T) { - mapper := services.NewMapper() - assert.NotNil(t, mapper, "NewMapper should return a valid mapper") +// ============================================================================= +// Profile loader tests (T017-T020) +// ============================================================================= + +func TestNewMapper_NotNil(t *testing.T) { + t.Parallel() + m := services.NewMapper() + assert.NotNil(t, m) } -func TestMapper_MapFeaturesToServices(t *testing.T) { - mapper := services.NewMapper() +func TestTierCapabilities(t *testing.T) { + t.Parallel() + + m := services.NewMapper() + p := m.Profiles() tests := []struct { - name string - manifest *manifest.Manifest - wantServices []string - wantMinCount int - dontWantServices []string + tier string + wantAny []string + wantNone bool + wantAll bool }{ { - name: "minimal manifest with no features", - manifest: &manifest.Manifest{ - Version: "1.0.0", - Features: map[string]bool{}, - }, - wantServices: []string{ - "arc-gateway", // Base infrastructure - "arc-db-sql", // Base infrastructure - "arc-db-cache", // Base infrastructure - "arc-db-vector", // Base infrastructure - "arc-storage", // Base infrastructure - "arc-brain", // Core service - }, - wantMinCount: 6, - dontWantServices: []string{ - "arc-voice-server", // Voice feature not enabled - "arc-identity", // Security feature not enabled - "arc-otel", // Observability feature not enabled - }, + tier: "think", + wantNone: true, // think has no preset capabilities }, { - name: "voice feature enabled", - manifest: &manifest.Manifest{ - Version: "1.0.0", - Features: map[string]bool{ - "voice": true, - }, - }, - wantServices: []string{ - "arc-voice-server", // Voice feature - "arc-voice-agent", // Voice feature - "arc-ingress", // Voice sidecar - "arc-egress", // Voice sidecar - "arc-gateway", // Base + dependency - "arc-pulse", // Dependency of voice-server - }, - wantMinCount: 6, - dontWantServices: []string{ - "arc-identity", // Security not enabled - "arc-otel", // Observability not enabled - "arc-chaos", // Chaos not enabled - }, + tier: "reason", + wantAny: []string{"reasoner"}, }, { - name: "security feature enabled", - manifest: &manifest.Manifest{ - Version: "1.0.0", - Features: map[string]bool{ - "security": true, - }, - }, - wantServices: []string{ - "arc-identity", // Security feature - "arc-vault", // Security feature - "arc-guard", // Security feature - "arc-gateway", // Dependency - "arc-db-sql", // Dependency - }, - wantMinCount: 5, - dontWantServices: []string{ - "arc-voice-server", // Voice not enabled - "arc-otel", // Observability not enabled - }, + tier: "ultra-instinct", + wantAll: true, // should return all capability names }, { - name: "observability feature enabled", - manifest: &manifest.Manifest{ - Version: "1.0.0", - Features: map[string]bool{ - "observability": true, - }, - }, - wantServices: []string{ - "arc-otel", // Observability - "arc-metrics", // Observability - "arc-logs", // Observability - "arc-traces", // Observability - "arc-viz", // Observability - "arc-log-shipper", // Observability - }, - wantMinCount: 6, - dontWantServices: []string{ - "arc-voice-server", // Voice not enabled - "arc-identity", // Security not enabled - }, + tier: "", // unknown/empty β†’ like think + wantNone: true, }, + } + + for _, tt := range tests { + t.Run(tt.tier, func(t *testing.T) { + t.Parallel() + caps := p.TierCapabilities(tt.tier) + if tt.wantNone { + assert.Empty(t, caps) + } + if tt.wantAll { + assert.NotEmpty(t, caps) + // ultra-instinct must include all known caps + assert.Contains(t, caps, "reasoner") + assert.Contains(t, caps, "voice") + assert.Contains(t, caps, "observe") + assert.Contains(t, caps, "security") + assert.Contains(t, caps, "storage") + } + for _, want := range tt.wantAny { + assert.Contains(t, caps, want) + } + }) + } +} + +func TestExpandCapabilities_RequiresChain(t *testing.T) { + t.Parallel() + + m := services.NewMapper() + p := m.Profiles() + + tests := []struct { + name string + input []string + wantAll []string + }{ { - name: "chaos feature enabled", - manifest: &manifest.Manifest{ - Version: "1.0.0", - Features: map[string]bool{ - "chaos": true, - }, - }, - wantServices: []string{ - "arc-chaos", // Chaos feature - }, - wantMinCount: 1, - dontWantServices: []string{ - "arc-voice-server", // Voice not enabled - }, + name: "empty input returns empty", + input: []string{}, + wantAll: []string{}, }, { - name: "all features enabled", - manifest: &manifest.Manifest{ - Version: "1.0.0", - Features: map[string]bool{ - "voice": true, - "security": true, - "observability": true, - "chaos": true, - }, - }, - wantServices: []string{ - // Infrastructure - "arc-gateway", "arc-identity", "arc-vault", "arc-voice-server", - // Data - "arc-db-sql", "arc-db-cache", "arc-db-vector", "arc-storage", - // AI - "arc-brain", "arc-voice-agent", "arc-guard", - // Observability - "arc-otel", "arc-metrics", "arc-logs", "arc-traces", "arc-viz", "arc-log-shipper", - // Chaos - "arc-chaos", - }, - wantMinCount: 15, + name: "no-op for capability without requires", + input: []string{"observe"}, + wantAll: []string{"observe"}, }, { - name: "features disabled explicitly", - manifest: &manifest.Manifest{ - Version: "1.0.0", - Features: map[string]bool{ - "voice": false, - "security": false, - "observability": false, - "chaos": false, - }, - }, - wantServices: []string{ - "arc-gateway", // Base infrastructure - "arc-db-sql", // Base infrastructure - "arc-db-cache", // Base infrastructure - "arc-db-vector", // Base infrastructure - "arc-storage", // Base infrastructure - }, - dontWantServices: []string{ - "arc-voice-server", // Voice disabled - "arc-identity", // Security disabled - "arc-otel", // Observability disabled - "arc-chaos", // Chaos disabled - }, + name: "voice expands to include reasoner", + input: []string{"voice"}, + wantAll: []string{"voice", "reasoner"}, + }, + { + name: "reasoner alone has no requires", + input: []string{"reasoner"}, + wantAll: []string{"reasoner"}, + }, + { + name: "deduplicates when reasoner explicitly included", + input: []string{"reasoner", "voice"}, + wantAll: []string{"reasoner", "voice"}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - result, err := mapper.MapFeaturesToServices(tt.manifest) - require.NoError(t, err) - require.NotNil(t, result) - - // Convert to map for easier checking - serviceMap := make(map[string]bool) - for _, svc := range result { - serviceMap[svc.ServiceName] = true + t.Parallel() + got := p.ExpandCapabilities(tt.input) + for _, want := range tt.wantAll { + assert.Contains(t, got, want, "missing %q in expanded caps", want) } - - // Check minimum count - if tt.wantMinCount > 0 { - assert.GreaterOrEqual(t, len(result), tt.wantMinCount, - "Should have at least %d services", tt.wantMinCount) + // no unexpected duplicates + seen := make(map[string]int) + for _, capName := range got { + seen[capName]++ } - - // Check expected services are present - for _, wantService := range tt.wantServices { - assert.True(t, serviceMap[wantService], - "Service '%s' should be included", wantService) - } - - // Check unwanted services are not present - for _, dontWant := range tt.dontWantServices { - assert.False(t, serviceMap[dontWant], - "Service '%s' should NOT be included", dontWant) + for capName, count := range seen { + assert.Equal(t, 1, count, "capability %q appears %d times", capName, count) } - - // Verify dependencies are satisfied - err = mapper.ValidateDependencies(result) - assert.NoError(t, err, "All dependencies should be satisfied") }) } } -func TestMapper_ResolveDependencies(t *testing.T) { - mapper := services.NewMapper() - - t.Run("voice feature pulls in dependencies", func(t *testing.T) { - m := &manifest.Manifest{ - Version: "1.0.0", - Features: map[string]bool{ - "voice": true, - }, - } - - result, err := mapper.MapFeaturesToServices(m) - require.NoError(t, err) - - serviceMap := make(map[string]bool) - for _, svc := range result { - serviceMap[svc.ServiceName] = true - } - - // Voice server depends on gateway and pulse - assert.True(t, serviceMap["arc-voice-server"], "Voice server should be included") - assert.True(t, serviceMap["arc-gateway"], "Gateway should be pulled in as dependency") - assert.True(t, serviceMap["arc-pulse"], "Pulse should be pulled in as dependency") - - // Voice agent depends on brain and voice-server - assert.True(t, serviceMap["arc-voice-agent"], "Voice agent should be included") - assert.True(t, serviceMap["arc-brain"], "Brain should be pulled in as dependency") - }) - - t.Run("identity service pulls in gateway and database", func(t *testing.T) { - m := &manifest.Manifest{ - Version: "1.0.0", - Features: map[string]bool{ - "security": true, - }, - } - - result, err := mapper.MapFeaturesToServices(m) - require.NoError(t, err) - - serviceMap := make(map[string]bool) - for _, svc := range result { - serviceMap[svc.ServiceName] = true - } - - assert.True(t, serviceMap["arc-identity"], "Identity should be included") - assert.True(t, serviceMap["arc-gateway"], "Gateway should be pulled in as dependency") - assert.True(t, serviceMap["arc-db-sql"], "Database should be pulled in as dependency") - }) +func TestCoreServiceNames(t *testing.T) { + t.Parallel() - t.Run("observability viz pulls in metrics, logs, and traces", func(t *testing.T) { - m := &manifest.Manifest{ - Version: "1.0.0", - Features: map[string]bool{ - "observability": true, - }, - } - - result, err := mapper.MapFeaturesToServices(m) - require.NoError(t, err) - - serviceMap := make(map[string]bool) - for _, svc := range result { - serviceMap[svc.ServiceName] = true - } - - assert.True(t, serviceMap["arc-viz"], "Viz should be included") - assert.True(t, serviceMap["arc-metrics"], "Metrics should be pulled in as dependency") - assert.True(t, serviceMap["arc-logs"], "Logs should be pulled in as dependency") - assert.True(t, serviceMap["arc-traces"], "Traces should be pulled in as dependency") - }) + m := services.NewMapper() + p := m.Profiles() + names := p.CoreServiceNames() - t.Run("transitive dependencies are resolved", func(t *testing.T) { - m := &manifest.Manifest{ - Version: "1.0.0", - Features: map[string]bool{ - "voice": true, - }, - } - - result, err := mapper.MapFeaturesToServices(m) - require.NoError(t, err) - - serviceMap := make(map[string]bool) - for _, svc := range result { - serviceMap[svc.ServiceName] = true - } - - // Voice-agent -> brain -> db-sql (transitive) - assert.True(t, serviceMap["arc-voice-agent"], "Voice agent should be included") - assert.True(t, serviceMap["arc-brain"], "Brain should be included") - assert.True(t, serviceMap["arc-db-sql"], "Database should be included transitively") - assert.True(t, serviceMap["arc-db-cache"], "Cache should be included transitively") - assert.True(t, serviceMap["arc-db-vector"], "Vector DB should be included transitively") - }) + assert.NotEmpty(t, names) + // These are the canonical core codenames from profiles.yaml + for _, expected := range []string{"gateway", "messaging", "streaming", "cache", "persistence", "cortex", "friday-collector"} { + assert.Contains(t, names, expected) + } } -func TestMapper_ValidateDependencies(t *testing.T) { - mapper := services.NewMapper() +// ============================================================================= +// ResolveServices tests (T036-T041) +// ============================================================================= - t.Run("valid dependencies pass", func(t *testing.T) { - table := services.GetMasterServiceTable() - services := []*services.ServiceDefinition{ - table["arc-gateway"], - table["arc-db-sql"], - table["arc-identity"], // Depends on gateway and db-sql - } +func TestResolveServices_Think(t *testing.T) { + t.Parallel() - err := mapper.ValidateDependencies(services) - assert.NoError(t, err) - }) + m := services.NewMapper() + svcs, err := m.ResolveServices("think", nil) + require.NoError(t, err) - t.Run("missing dependency fails", func(t *testing.T) { - table := services.GetMasterServiceTable() - services := []*services.ServiceDefinition{ - table["arc-identity"], // Depends on gateway and db-sql, but they're missing - } + names := serviceNames(svcs) + // Core services must be present (by arc-image name) + assert.Contains(t, names, "arc-gateway") + assert.Contains(t, names, "arc-messaging") + assert.Contains(t, names, "arc-streaming") + assert.Contains(t, names, "arc-cache") + assert.Contains(t, names, "arc-sql-db") + assert.Contains(t, names, "arc-cortex") + + // No capability services for think + assert.NotContains(t, names, "arc-reasoner") + assert.NotContains(t, names, "arc-voice-agent") +} - err := mapper.ValidateDependencies(services) - assert.Error(t, err) - assert.Contains(t, err.Error(), "requires") - }) +func TestResolveServices_Reason(t *testing.T) { + t.Parallel() - t.Run("empty service list passes", func(t *testing.T) { - services := []*services.ServiceDefinition{} + m := services.NewMapper() + svcs, err := m.ResolveServices("reason", nil) + require.NoError(t, err) - err := mapper.ValidateDependencies(services) - assert.NoError(t, err) - }) + names := serviceNames(svcs) + // Core present + assert.Contains(t, names, "arc-gateway") + // Reasoner added by tier preset + assert.Contains(t, names, "arc-reasoner") + // Voice not included by reason tier alone + assert.NotContains(t, names, "arc-voice-agent") +} - t.Run("service with no dependencies passes", func(t *testing.T) { - table := services.GetMasterServiceTable() - services := []*services.ServiceDefinition{ - table["arc-gateway"], // No dependencies - } +func TestResolveServices_UltraInstinct(t *testing.T) { + t.Parallel() - err := mapper.ValidateDependencies(services) - assert.NoError(t, err) - }) + m := services.NewMapper() + svcs, err := m.ResolveServices("ultra-instinct", nil) + require.NoError(t, err) - t.Run("complex dependency chain validates", func(t *testing.T) { - table := services.GetMasterServiceTable() - services := []*services.ServiceDefinition{ - table["arc-gateway"], - table["arc-db-sql"], - table["arc-db-cache"], - table["arc-db-vector"], - table["arc-pulse"], - table["arc-brain"], // Depends on db-sql, db-cache, db-vector, pulse - table["arc-voice-server"], // Depends on gateway, pulse - table["arc-voice-agent"], // Depends on brain, voice-server - } - - err := mapper.ValidateDependencies(services) - assert.NoError(t, err) - }) + names := serviceNames(svcs) + // Core present + assert.Contains(t, names, "arc-gateway") + // All capability services must be present + assert.Contains(t, names, "arc-reasoner") + assert.Contains(t, names, "arc-friday") // otel β†’ arc-friday } -func TestMapper_EdgeCases(t *testing.T) { - mapper := services.NewMapper() +func TestResolveServices_WithCapabilities(t *testing.T) { + t.Parallel() - t.Run("nil features map", func(t *testing.T) { - m := &manifest.Manifest{ - Version: "1.0.0", - Features: nil, - } - - result, err := mapper.MapFeaturesToServices(m) - require.NoError(t, err) - require.NotNil(t, result) - - // Should still have base infrastructure - assert.Greater(t, len(result), 0, "Should have base infrastructure even with nil features") - }) + m := services.NewMapper() + svcs, err := m.ResolveServices("think", []string{"voice"}) + require.NoError(t, err) - t.Run("empty features map", func(t *testing.T) { - m := &manifest.Manifest{ - Version: "1.0.0", - Features: map[string]bool{}, - } + names := serviceNames(svcs) + // voice capability requires reasoner first + assert.Contains(t, names, "arc-reasoner") + assert.Contains(t, names, "arc-voice-agent") + assert.Contains(t, names, "arc-realtime") +} - result, err := mapper.MapFeaturesToServices(m) - require.NoError(t, err) - require.NotNil(t, result) +func TestResolveServices_Deduplication(t *testing.T) { + t.Parallel() - // Should have base infrastructure - assert.Greater(t, len(result), 0, "Should have base infrastructure") - }) + m := services.NewMapper() + // reason already includes reasoner; adding it explicitly should not duplicate + svcs, err := m.ResolveServices("reason", []string{"reasoner"}) + require.NoError(t, err) - t.Run("unknown feature is ignored", func(t *testing.T) { - m := &manifest.Manifest{ - Version: "1.0.0", - Features: map[string]bool{ - "unknown-feature": true, - }, - } + assertNoDuplicates(t, svcs) +} - result, err := mapper.MapFeaturesToServices(m) - require.NoError(t, err) - require.NotNil(t, result) +func TestResolveServices_NoDuplicates_AllCaps(t *testing.T) { + t.Parallel() - // Should have base infrastructure, unknown feature is ignored - assert.Greater(t, len(result), 0, "Should have base infrastructure") - }) + m := services.NewMapper() + svcs, err := m.ResolveServices("ultra-instinct", nil) + require.NoError(t, err) - t.Run("mixed enabled and disabled features", func(t *testing.T) { - m := &manifest.Manifest{ - Version: "1.0.0", - Features: map[string]bool{ - "voice": true, - "security": false, - "observability": true, - "chaos": false, - }, - } + assertNoDuplicates(t, svcs) +} - result, err := mapper.MapFeaturesToServices(m) - require.NoError(t, err) +func TestResolveServices_TransitiveDeps(t *testing.T) { + t.Parallel() - serviceMap := make(map[string]bool) - for _, svc := range result { - serviceMap[svc.ServiceName] = true - } + m := services.NewMapper() + // voice depends on reasoner, realtime, messaging, streaming, friday-collector, cache + svcs, err := m.ResolveServices("think", []string{"voice"}) + require.NoError(t, err) - // Voice enabled - assert.True(t, serviceMap["arc-voice-server"]) + names := serviceNames(svcs) + assert.Contains(t, names, "arc-messaging") // direct dep of voice + assert.Contains(t, names, "arc-streaming") // direct dep of voice + assert.Contains(t, names, "arc-realtime") // direct dep of voice + assert.Contains(t, names, "arc-reasoner") // voice requires reasoner +} - // Security disabled - assert.False(t, serviceMap["arc-identity"]) +// ============================================================================= +// MapFeaturesToServices shim tests (backwards-compat) +// ============================================================================= - // Observability enabled - assert.True(t, serviceMap["arc-otel"]) +func TestMapFeaturesToServices_NoFeatures(t *testing.T) { + t.Parallel() - // Chaos disabled - assert.False(t, serviceMap["arc-chaos"]) + m := services.NewMapper() + svcs, err := m.MapFeaturesToServices(&manifest.Manifest{ + Version: "1.0.0", + Features: map[string]bool{}, }) + require.NoError(t, err) + assert.NotEmpty(t, svcs) + names := serviceNames(svcs) + assert.Contains(t, names, "arc-gateway") } -func TestMapper_FeatureToServiceMapping(t *testing.T) { - mapper := services.NewMapper() +func TestMapFeaturesToServices_VoiceFeature(t *testing.T) { + t.Parallel() - featureMappings := map[string][]string{ - "voice": { - "arc-voice-server", - "arc-voice-agent", - "arc-ingress", - "arc-egress", - }, - "security": { - "arc-identity", - "arc-vault", - "arc-guard", - }, - "observability": { - "arc-otel", - "arc-metrics", - "arc-logs", - "arc-traces", - "arc-viz", - "arc-log-shipper", - }, - "chaos": { - "arc-chaos", - }, - } + m := services.NewMapper() + svcs, err := m.MapFeaturesToServices(&manifest.Manifest{ + Version: "1.0.0", + Features: map[string]bool{"voice": true}, + }) + require.NoError(t, err) + names := serviceNames(svcs) + assert.Contains(t, names, "arc-voice-agent") + assert.Contains(t, names, "arc-realtime") + assert.Contains(t, names, "arc-reasoner") // voice requires reasoner +} - for feature, expectedServices := range featureMappings { - t.Run(feature, func(t *testing.T) { - m := &manifest.Manifest{ - Version: "1.0.0", - Features: map[string]bool{ - feature: true, - }, - } +func TestMapFeaturesToServices_CapabilitiesWins(t *testing.T) { + t.Parallel() - result, err := mapper.MapFeaturesToServices(m) - require.NoError(t, err) + // When Capabilities is set, Features are ignored (T081) + m := services.NewMapper() + svcs, err := m.MapFeaturesToServices(&manifest.Manifest{ + Version: "1.0.0", + Capabilities: []string{"storage"}, + Features: map[string]bool{"voice": true}, // should be ignored + }) + require.NoError(t, err) + names := serviceNames(svcs) + assert.Contains(t, names, "arc-storage") + assert.NotContains(t, names, "arc-voice-agent") // voice ignored because caps set +} - serviceMap := make(map[string]bool) - for _, svc := range result { - serviceMap[svc.ServiceName] = true - } +func TestMapFeaturesToServices_ObservabilityMapsToObserve(t *testing.T) { + t.Parallel() - for _, expectedService := range expectedServices { - assert.True(t, serviceMap[expectedService], - "Feature '%s' should enable service '%s'", feature, expectedService) - } - }) - } + m := services.NewMapper() + svcs, err := m.MapFeaturesToServices(&manifest.Manifest{ + Version: "1.0.0", + Features: map[string]bool{"observability": true}, + }) + require.NoError(t, err) + names := serviceNames(svcs) + // observability β†’ observe β†’ otel service (arc-friday) + assert.Contains(t, names, "arc-friday") } -func TestMapper_BaseInfrastructureAlwaysIncluded(t *testing.T) { - mapper := services.NewMapper() +func TestMapFeaturesToServices_NoDuplicates(t *testing.T) { + t.Parallel() - testCases := []struct { - name string - features map[string]bool - }{ - {"no features", map[string]bool{}}, - {"voice only", map[string]bool{"voice": true}}, - {"security only", map[string]bool{"security": true}}, - {"all disabled", map[string]bool{ - "voice": false, "security": false, "observability": false, "chaos": false, - }}, - } + m := services.NewMapper() + svcs, err := m.MapFeaturesToServices(&manifest.Manifest{ + Version: "1.0.0", + Features: map[string]bool{"voice": true, "security": true}, + }) + require.NoError(t, err) + assertNoDuplicates(t, svcs) +} - baseServices := []string{ - "arc-gateway", - "arc-db-sql", - "arc-db-cache", - "arc-db-vector", - "arc-storage", - } +// ============================================================================= +// ValidateDependencies tests +// ============================================================================= - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - m := &manifest.Manifest{ - Version: "1.0.0", - Features: tc.features, - } +func TestValidateDependencies_Valid(t *testing.T) { + t.Parallel() - result, err := mapper.MapFeaturesToServices(m) - require.NoError(t, err) + m := services.NewMapper() + svcs, err := m.ResolveServices("think", nil) + require.NoError(t, err) + assert.NoError(t, m.ValidateDependencies(svcs)) +} - serviceMap := make(map[string]bool) - for _, svc := range result { - serviceMap[svc.ServiceName] = true - } +func TestValidateDependencies_Empty(t *testing.T) { + t.Parallel() - for _, baseService := range baseServices { - assert.True(t, serviceMap[baseService], - "Base service '%s' should always be included", baseService) - } - }) - } + m := services.NewMapper() + assert.NoError(t, m.ValidateDependencies([]*services.ServiceDefinition{})) } -func TestMapper_NoDuplicateServices(t *testing.T) { - mapper := services.NewMapper() +// ============================================================================= +// Helpers +// ============================================================================= - m := &manifest.Manifest{ - Version: "1.0.0", - Features: map[string]bool{ - "voice": true, - "security": true, - "observability": true, - "chaos": true, - }, +func serviceNames(svcs []*services.ServiceDefinition) map[string]bool { + m := make(map[string]bool, len(svcs)) + for _, svc := range svcs { + m[svc.ServiceName] = true } + return m +} - result, err := mapper.MapFeaturesToServices(m) - require.NoError(t, err) - +func assertNoDuplicates(t *testing.T, svcs []*services.ServiceDefinition) { + t.Helper() seen := make(map[string]bool) - for _, svc := range result { - assert.False(t, seen[svc.ServiceName], - "Service '%s' appears multiple times in result", svc.ServiceName) + for _, svc := range svcs { + assert.False(t, seen[svc.ServiceName], "Service %q appears more than once", svc.ServiceName) seen[svc.ServiceName] = true } } diff --git a/pkg/workspace/services/registry.go b/pkg/workspace/services/registry.go index 19aeef5..d679bc2 100644 --- a/pkg/workspace/services/registry.go +++ b/pkg/workspace/services/registry.go @@ -1,309 +1,55 @@ package services -// ServiceDefinition defines a service in the A.R.C. platform +import ( + "log" +) + +// PortMapping holds a hostβ†’container port pair for docker-compose port binding. +type PortMapping struct { + Host int + Container int +} + +// ServiceDefinition defines a service in the A.R.C. platform. type ServiceDefinition struct { ServiceName string CodeName string - ImageName string + ArcImage string // A.R.C. registry image name (e.g. "arc-gateway") + ImageName string // upstream docker image RequiredConfigs []string Dependencies []string - FeatureFlags []string - Ports []int + FeatureFlags []string // unused in new path; kept for compat + Ports []PortMapping } -// GetMasterServiceTable returns the complete A.R.C. service catalog +// GetMasterServiceTable returns an empty stub map. +// +// Deprecated: Use Mapper.ResolveServices() instead. This function logs a +// deprecation warning on every call and returns an empty map. func GetMasterServiceTable() map[string]*ServiceDefinition { - return map[string]*ServiceDefinition{ - // Infrastructure - The Body - "arc-gateway": { - ServiceName: "arc-gateway", - CodeName: "Heimdall", - ImageName: "traefik:v3.0", - RequiredConfigs: []string{"gateway/traefik.yml"}, - Dependencies: []string{}, - FeatureFlags: []string{}, // Always required - Ports: []int{80, 443, 8080}, - }, - "arc-identity": { - ServiceName: "arc-identity", - CodeName: "J.A.R.V.I.S.", - ImageName: "oryd/kratos:latest", - RequiredConfigs: []string{"security/kratos.yml"}, - Dependencies: []string{"arc-gateway", "arc-db-sql"}, - FeatureFlags: []string{"security"}, - Ports: []int{4433, 4434}, - }, - "arc-vault": { - ServiceName: "arc-vault", - CodeName: "Nick Fury", - ImageName: "infisical/infisical:latest", - RequiredConfigs: []string{"security/infisical.yml"}, - Dependencies: []string{"arc-db-sql"}, - FeatureFlags: []string{"security"}, - Ports: []int{8200}, - }, - "arc-flags": { - ServiceName: "arc-flags", - CodeName: "Mystique", - ImageName: "unleashorg/unleash-server:latest", - RequiredConfigs: []string{"flags/unleash.yml"}, - Dependencies: []string{"arc-db-sql"}, - FeatureFlags: []string{}, // Base infrastructure - Ports: []int{4242}, - }, - "arc-stream": { - ServiceName: "arc-stream", - CodeName: "Dr. Strange", - ImageName: "apachepulsar/pulsar:latest", - RequiredConfigs: []string{"events/pulsar.yml"}, - Dependencies: []string{}, - FeatureFlags: []string{}, // Base infrastructure - Ports: []int{6650, 8081}, // Changed from 8080 to avoid conflict with arc-gateway - }, - "arc-pulse": { - ServiceName: "arc-pulse", - CodeName: "The Flash", - ImageName: "nats:alpine", - Dependencies: []string{}, - FeatureFlags: []string{}, // Base infrastructure - Ports: []int{4222, 8222}, - }, - "arc-voice-server": { - ServiceName: "arc-voice-server", - CodeName: "Daredevil", - ImageName: "livekit/livekit-server:latest", - Dependencies: []string{"arc-gateway", "arc-pulse"}, - FeatureFlags: []string{"voice"}, - Ports: []int{7880, 7881}, - }, - "arc-mailer": { - ServiceName: "arc-mailer", - CodeName: "Hedwig", - ImageName: "courier:latest", - Dependencies: []string{}, - FeatureFlags: []string{}, // Base infrastructure - Ports: []int{1025, 8025}, - }, - "arc-chaos": { - ServiceName: "arc-chaos", - CodeName: "T-800", - ImageName: "chaos-mesh/chaos-mesh:latest", - Dependencies: []string{}, - FeatureFlags: []string{"chaos"}, - Ports: []int{2333, 2334}, - }, - - // Data & Memory - The Mind - "arc-db-sql": { - ServiceName: "arc-db-sql", - CodeName: "Oracle", - ImageName: "postgres:16-alpine", - Dependencies: []string{}, - FeatureFlags: []string{}, // Base infrastructure - Ports: []int{5432}, - }, - "arc-db-cache": { - ServiceName: "arc-db-cache", - CodeName: "Sonic", - ImageName: "redis:alpine", - Dependencies: []string{}, - FeatureFlags: []string{}, // Base infrastructure - Ports: []int{6379}, - }, - "arc-db-vector": { - ServiceName: "arc-db-vector", - CodeName: "Cerebro", - ImageName: "qdrant/qdrant:latest", - Dependencies: []string{}, - FeatureFlags: []string{}, // Base infrastructure - Ports: []int{6333, 6334}, - }, - "arc-storage": { - ServiceName: "arc-storage", - CodeName: "Tardis", - ImageName: "minio/minio:latest", - Dependencies: []string{}, - FeatureFlags: []string{}, // Base infrastructure - Ports: []int{9000, 9001}, - }, - "arc-migrate": { - ServiceName: "arc-migrate", - CodeName: "Pathfinder", - ImageName: "migrate/migrate:latest", - Dependencies: []string{"arc-db-sql"}, - FeatureFlags: []string{}, // Base infrastructure - Ports: []int{}, - }, - - // AI Workforce - The Core & Workers - "arc-brain": { - ServiceName: "arc-brain", - CodeName: "Sherlock", - ImageName: "./core/engine", - Dependencies: []string{"arc-db-sql", "arc-db-cache", "arc-db-vector", "arc-pulse"}, - FeatureFlags: []string{}, // Core service - Ports: []int{8000}, - }, - "arc-voice-agent": { - ServiceName: "arc-voice-agent", - CodeName: "Scarlett", - ImageName: "./core/voice", - Dependencies: []string{"arc-brain", "arc-voice-server"}, - FeatureFlags: []string{"voice"}, - Ports: []int{8001}, - }, - "arc-guard": { - ServiceName: "arc-guard", - CodeName: "RoboCop", - ImageName: "./core/guardrails", - Dependencies: []string{"arc-brain"}, - FeatureFlags: []string{"security"}, - Ports: []int{8002}, - }, - "arc-critic": { - ServiceName: "arc-critic", - CodeName: "Gordon Ramsay", - ImageName: "./workers/critic", - Dependencies: []string{"arc-brain"}, - FeatureFlags: []string{}, // Optional worker - Ports: []int{8003}, - }, - "arc-gym": { - ServiceName: "arc-gym", - CodeName: "Ivan Drago", - ImageName: "./workers/gym", - Dependencies: []string{"arc-brain"}, - FeatureFlags: []string{}, // Optional worker - Ports: []int{8004}, - }, - "arc-semantic": { - ServiceName: "arc-semantic", - CodeName: "Uhura", - ImageName: "./workers/semantic", - Dependencies: []string{"arc-brain", "arc-db-sql"}, - FeatureFlags: []string{}, // Optional worker - Ports: []int{8005}, - }, - "arc-mechanic": { - ServiceName: "arc-mechanic", - CodeName: "Statham", - ImageName: "./workers/healer", - Dependencies: []string{}, - FeatureFlags: []string{}, // Optional worker - Ports: []int{8006}, - }, - "arc-janitor": { - ServiceName: "arc-janitor", - CodeName: "The Wolf", - ImageName: "./core/ops", - Dependencies: []string{"arc-db-sql", "arc-storage"}, - FeatureFlags: []string{}, // Core service - Ports: []int{8007}, - }, - "arc-billing": { - ServiceName: "arc-billing", - CodeName: "Alfred", - ImageName: "./plugins/billing", - Dependencies: []string{"arc-db-sql"}, - FeatureFlags: []string{}, // Core service - Ports: []int{8008}, - }, - "arc-ingress": { - ServiceName: "arc-ingress", - CodeName: "Sentry", - ImageName: "livekit/ingress:latest", - Dependencies: []string{"arc-voice-server"}, - FeatureFlags: []string{"voice"}, - Ports: []int{7882}, - }, - "arc-egress": { - ServiceName: "arc-egress", - CodeName: "Scribe", - ImageName: "livekit/egress:latest", - Dependencies: []string{"arc-voice-server", "arc-storage"}, - FeatureFlags: []string{"voice"}, - Ports: []int{7883}, - }, - - // Observability - The Eyes - "arc-otel": { - ServiceName: "arc-otel", - CodeName: "Black Widow", - ImageName: "otel/opentelemetry-collector:latest", - Dependencies: []string{}, - FeatureFlags: []string{"observability"}, - Ports: []int{4317, 4318}, - }, - "arc-metrics": { - ServiceName: "arc-metrics", - CodeName: "Dr. House", - ImageName: "prom/prometheus:latest", - RequiredConfigs: []string{"observability/prometheus.yml"}, - Dependencies: []string{}, - FeatureFlags: []string{"observability"}, - Ports: []int{9090}, - }, - "arc-logs": { - ServiceName: "arc-logs", - CodeName: "Watson", - ImageName: "grafana/loki:latest", - RequiredConfigs: []string{"observability/loki.yml"}, - Dependencies: []string{}, - FeatureFlags: []string{"observability"}, - Ports: []int{3100}, - }, - "arc-traces": { - ServiceName: "arc-traces", - CodeName: "Columbo", - ImageName: "grafana/tempo:latest", - RequiredConfigs: []string{"observability/tempo.yml"}, - Dependencies: []string{}, - FeatureFlags: []string{"observability"}, - Ports: []int{3200, 4319}, // Changed from 4317 to avoid conflict with arc-otel - }, - "arc-viz": { - ServiceName: "arc-viz", - CodeName: "Friday", - ImageName: "grafana/grafana:latest", - RequiredConfigs: []string{"observability/grafana.yml"}, - Dependencies: []string{"arc-metrics", "arc-logs", "arc-traces"}, - FeatureFlags: []string{"observability"}, - Ports: []int{3000}, - }, - "arc-log-shipper": { - ServiceName: "arc-log-shipper", - CodeName: "Hermes", - ImageName: "grafana/promtail:latest", - RequiredConfigs: []string{"observability/promtail.yml"}, - Dependencies: []string{"arc-logs"}, - FeatureFlags: []string{"observability"}, - Ports: []int{9080}, - }, - } + log.Print("Warning: GetMasterServiceTable() is deprecated; use Mapper.ResolveServices()") + return map[string]*ServiceDefinition{} } -// GetServiceByName retrieves a service definition by name +// GetServiceByName looks up a service by its arc-image name using the catalog-backed mapper. +// +// Deprecated: Use Mapper's internal catalog lookup via ResolveServices(). func GetServiceByName(name string) (*ServiceDefinition, bool) { - svc, exists := GetMasterServiceTable()[name] - return svc, exists + log.Print("Warning: GetServiceByName() is deprecated; use Mapper.ResolveServices()") + m := NewMapper() + for _, svc := range m.serviceTable { + if svc.ServiceName == name || svc.CodeName == name { + return svc, true + } + } + return nil, false } -// GetBaseInfrastructure returns services that are always required +// GetBaseInfrastructure returns the always-on core services. +// +// Deprecated: Use Mapper.ResolveServices() instead. func GetBaseInfrastructure() []*ServiceDefinition { - table := GetMasterServiceTable() - return []*ServiceDefinition{ - table["arc-gateway"], - table["arc-db-sql"], - table["arc-db-cache"], - table["arc-db-vector"], - table["arc-storage"], - table["arc-flags"], - table["arc-stream"], - table["arc-pulse"], - table["arc-mailer"], - table["arc-migrate"], - table["arc-brain"], - table["arc-janitor"], - table["arc-billing"], - } + log.Print("Warning: GetBaseInfrastructure() is deprecated; use Mapper.ResolveServices()") + m := NewMapper() + return m.servicesForNames(m.profiles.CoreServiceNames()) } diff --git a/pkg/workspace/services/registry_test.go b/pkg/workspace/services/registry_test.go index 3506f15..145b383 100644 --- a/pkg/workspace/services/registry_test.go +++ b/pkg/workspace/services/registry_test.go @@ -9,145 +9,42 @@ import ( "github.com/arc-framework/arc-cli/pkg/workspace/services" ) +// TestGetMasterServiceTable verifies the deprecated stub behavior. func TestGetMasterServiceTable(t *testing.T) { - table := services.GetMasterServiceTable() - - t.Run("contains all expected services", func(t *testing.T) { - // Should have 31 services total (complete A.R.C. platform) - assert.GreaterOrEqual(t, len(table), 31, "Master table should have at least 31 services") - - // Verify all critical infrastructure services exist - expectedServices := []string{ - // Infrastructure - "arc-gateway", "arc-identity", "arc-vault", "arc-flags", - "arc-stream", "arc-pulse", "arc-voice-server", "arc-mailer", "arc-chaos", - // Data & Memory - "arc-db-sql", "arc-db-cache", "arc-db-vector", "arc-storage", "arc-migrate", - // AI Workforce - "arc-brain", "arc-voice-agent", "arc-guard", "arc-critic", - "arc-gym", "arc-semantic", "arc-mechanic", "arc-janitor", "arc-billing", - "arc-ingress", "arc-egress", - // Observability - "arc-otel", "arc-metrics", "arc-logs", "arc-traces", "arc-viz", "arc-log-shipper", - } - - for _, serviceName := range expectedServices { - _, exists := table[serviceName] - assert.True(t, exists, "Service '%s' should exist in master table", serviceName) - } - }) - - t.Run("all services have required fields", func(t *testing.T) { - for name, svc := range table { - assert.NotEmpty(t, svc.ServiceName, "Service '%s' should have ServiceName", name) - assert.NotEmpty(t, svc.CodeName, "Service '%s' should have CodeName", name) - assert.NotEmpty(t, svc.ImageName, "Service '%s' should have ImageName", name) - assert.Equal(t, name, svc.ServiceName, "Service key should match ServiceName for '%s'", name) - - // Dependencies should be valid (if any) - assert.NotNil(t, svc.Dependencies, "Service '%s' should have initialized Dependencies slice", name) + t.Parallel() - // FeatureFlags should be initialized - assert.NotNil(t, svc.FeatureFlags, "Service '%s' should have initialized FeatureFlags slice", name) - - // Ports should be initialized - assert.NotNil(t, svc.Ports, "Service '%s' should have initialized Ports slice", name) - } - }) - - t.Run("codenames are unique", func(t *testing.T) { - codenames := make(map[string]string) - for name, svc := range table { - if existingService, exists := codenames[svc.CodeName]; exists { - t.Errorf("Duplicate codename '%s' found in services '%s' and '%s'", - svc.CodeName, name, existingService) - } - codenames[svc.CodeName] = name - } - }) - - t.Run("service names follow naming convention", func(t *testing.T) { - for name, svc := range table { - // Service names should start with "arc-" - assert.Contains(t, svc.ServiceName, "arc-", "Service '%s' should follow arc-* naming convention", name) - } - }) - - t.Run("dependencies reference valid services", func(t *testing.T) { - for name, svc := range table { - for _, dep := range svc.Dependencies { - _, exists := table[dep] - assert.True(t, exists, "Service '%s' has invalid dependency '%s'", name, dep) - } - } - }) - - t.Run("feature flags are valid", func(t *testing.T) { - validFeatures := map[string]bool{ - "voice": true, - "security": true, - "observability": true, - "chaos": true, - } - - for name, svc := range table { - for _, feature := range svc.FeatureFlags { - assert.True(t, validFeatures[feature], - "Service '%s' has invalid feature flag '%s'", name, feature) - } - } - }) + // Stub returns empty map β€” all lookups should go through Mapper.ResolveServices. + table := services.GetMasterServiceTable() + assert.NotNil(t, table, "stub must return a non-nil map") + // The stub is intentionally empty; content lives in the catalog. + assert.Empty(t, table, "deprecated stub should return empty map") } -func TestGetServiceByName(t *testing.T) { +// TestGetServiceByName_CatalogBacked verifies catalog-backed lookup via deprecated shim. +func TestGetServiceByName_CatalogBacked(t *testing.T) { + t.Parallel() + tests := []struct { - name string - serviceName string - wantExists bool - wantCodeName string + name string + arcImage string + wantExists bool }{ - { - name: "existing service - gateway", - serviceName: "arc-gateway", - wantExists: true, - wantCodeName: "Heimdall", - }, - { - name: "existing service - identity", - serviceName: "arc-identity", - wantExists: true, - wantCodeName: "J.A.R.V.I.S.", - }, - { - name: "existing service - brain", - serviceName: "arc-brain", - wantExists: true, - wantCodeName: "Sherlock", - }, - { - name: "non-existent service", - serviceName: "arc-nonexistent", - wantExists: false, - }, - { - name: "empty service name", - serviceName: "", - wantExists: false, - }, + {"gateway by arc-image", "arc-gateway", true}, + {"persistence by arc-image", "arc-sql-db", true}, + {"gateway by codename", "gateway", true}, + {"nonexistent", "arc-nonexistent-xyz", false}, + {"empty", "", false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - svc, exists := services.GetServiceByName(tt.serviceName) - - assert.Equal(t, tt.wantExists, exists, "GetServiceByName existence check failed") - + t.Parallel() + svc, exists := services.GetServiceByName(tt.arcImage) + assert.Equal(t, tt.wantExists, exists) if tt.wantExists { require.NotNil(t, svc) - assert.Equal(t, tt.serviceName, svc.ServiceName) - if tt.wantCodeName != "" { - assert.Equal(t, tt.wantCodeName, svc.CodeName) - } + assert.NotEmpty(t, svc.ServiceName) + assert.NotEmpty(t, svc.CodeName) } else { assert.Nil(t, svc) } @@ -155,200 +52,52 @@ func TestGetServiceByName(t *testing.T) { } } -func TestGetBaseInfrastructure(t *testing.T) { - baseInfra := services.GetBaseInfrastructure() - - t.Run("returns non-empty list", func(t *testing.T) { - assert.NotEmpty(t, baseInfra, "Base infrastructure should not be empty") - }) - - t.Run("contains critical services", func(t *testing.T) { - serviceNames := make(map[string]bool) - for _, svc := range baseInfra { - serviceNames[svc.ServiceName] = true - } - - // Critical base services that should always be present - criticalServices := []string{ - "arc-gateway", // Heimdall - Gateway - "arc-db-sql", // Oracle - Database - "arc-db-cache", // Sonic - Cache - "arc-db-vector", // Cerebro - Vector DB - "arc-storage", // Tardis - Object Storage - "arc-brain", // Sherlock - Core Engine - } - - for _, critical := range criticalServices { - assert.True(t, serviceNames[critical], - "Critical service '%s' should be in base infrastructure", critical) - } - }) - - t.Run("all services are valid", func(t *testing.T) { - for _, svc := range baseInfra { - assert.NotNil(t, svc, "Base infrastructure should not contain nil services") - assert.NotEmpty(t, svc.ServiceName, "Base infrastructure service should have a name") - assert.NotEmpty(t, svc.CodeName, "Base infrastructure service should have a codename") - } - }) - - t.Run("no duplicate services", func(t *testing.T) { - seen := make(map[string]bool) - for _, svc := range baseInfra { - assert.False(t, seen[svc.ServiceName], - "Duplicate service '%s' in base infrastructure", svc.ServiceName) - seen[svc.ServiceName] = true - } - }) -} - -func TestServiceDefinitionStructure(t *testing.T) { - t.Run("can create service definition", func(t *testing.T) { - svc := &services.ServiceDefinition{ - ServiceName: "arc-test", - CodeName: "TestBot", - ImageName: "test/image:latest", - RequiredConfigs: []string{"config.yml"}, - Dependencies: []string{"arc-db-sql"}, - FeatureFlags: []string{"test"}, - Ports: []int{8080}, - } - - assert.Equal(t, "arc-test", svc.ServiceName) - assert.Equal(t, "TestBot", svc.CodeName) - assert.Equal(t, "test/image:latest", svc.ImageName) - assert.Len(t, svc.RequiredConfigs, 1) - assert.Len(t, svc.Dependencies, 1) - assert.Len(t, svc.FeatureFlags, 1) - assert.Len(t, svc.Ports, 1) - }) -} +// TestGetBaseInfrastructure_CatalogBacked verifies the deprecated base infra shim. +func TestGetBaseInfrastructure_CatalogBacked(t *testing.T) { + t.Parallel() -func TestServiceCodenames(t *testing.T) { - table := services.GetMasterServiceTable() + base := services.GetBaseInfrastructure() + assert.NotEmpty(t, base, "base infrastructure must contain at least one service") - expectedCodenames := map[string]string{ - "arc-gateway": "Heimdall", - "arc-identity": "J.A.R.V.I.S.", - "arc-vault": "Nick Fury", - "arc-flags": "Mystique", - "arc-stream": "Dr. Strange", - "arc-pulse": "The Flash", - "arc-voice-server": "Daredevil", - "arc-mailer": "Hedwig", - "arc-chaos": "T-800", - "arc-db-sql": "Oracle", - "arc-db-cache": "Sonic", - "arc-db-vector": "Cerebro", - "arc-storage": "Tardis", - "arc-migrate": "Pathfinder", - "arc-brain": "Sherlock", - "arc-voice-agent": "Scarlett", - "arc-guard": "RoboCop", - "arc-critic": "Gordon Ramsay", - "arc-gym": "Ivan Drago", - "arc-semantic": "Uhura", - "arc-mechanic": "Statham", - "arc-janitor": "The Wolf", - "arc-billing": "Alfred", - "arc-ingress": "Sentry", - "arc-egress": "Scribe", - "arc-otel": "Black Widow", - "arc-metrics": "Dr. House", - "arc-logs": "Watson", - "arc-traces": "Columbo", - "arc-viz": "Friday", - "arc-log-shipper": "Hermes", + seen := make(map[string]bool) + for _, svc := range base { + assert.NotNil(t, svc) + assert.NotEmpty(t, svc.ServiceName) + assert.NotEmpty(t, svc.CodeName) + assert.False(t, seen[svc.ServiceName], "duplicate service %q in base infra", svc.ServiceName) + seen[svc.ServiceName] = true } - for serviceName, expectedCodename := range expectedCodenames { - t.Run(serviceName, func(t *testing.T) { - svc, exists := table[serviceName] - require.True(t, exists, "Service '%s' should exist", serviceName) - assert.Equal(t, expectedCodename, svc.CodeName, - "Service '%s' should have codename '%s'", serviceName, expectedCodename) - }) + // Core services from profiles.yaml must be present + for _, want := range []string{ + "arc-gateway", "arc-messaging", "arc-streaming", + "arc-cache", "arc-sql-db", "arc-cortex", + } { + assert.True(t, seen[want], "core service %q missing from base infra", want) } } -func TestServiceDependencies(t *testing.T) { - table := services.GetMasterServiceTable() - - t.Run("identity depends on gateway and database", func(t *testing.T) { - svc := table["arc-identity"] - require.NotNil(t, svc) - assert.Contains(t, svc.Dependencies, "arc-gateway") - assert.Contains(t, svc.Dependencies, "arc-db-sql") - }) - - t.Run("voice-agent depends on brain and voice-server", func(t *testing.T) { - svc := table["arc-voice-agent"] - require.NotNil(t, svc) - assert.Contains(t, svc.Dependencies, "arc-brain") - assert.Contains(t, svc.Dependencies, "arc-voice-server") - }) - - t.Run("viz depends on metrics, logs, and traces", func(t *testing.T) { - svc := table["arc-viz"] - require.NotNil(t, svc) - assert.Contains(t, svc.Dependencies, "arc-metrics") - assert.Contains(t, svc.Dependencies, "arc-logs") - assert.Contains(t, svc.Dependencies, "arc-traces") - }) - - t.Run("gateway has no dependencies", func(t *testing.T) { - svc := table["arc-gateway"] - require.NotNil(t, svc) - assert.Empty(t, svc.Dependencies, "Gateway should have no dependencies") - }) -} - -func TestServiceFeatureFlags(t *testing.T) { - table := services.GetMasterServiceTable() - - t.Run("voice services require voice feature", func(t *testing.T) { - voiceServices := []string{"arc-voice-server", "arc-voice-agent", "arc-ingress", "arc-egress"} - for _, serviceName := range voiceServices { - svc := table[serviceName] - require.NotNil(t, svc, "Service '%s' should exist", serviceName) - assert.Contains(t, svc.FeatureFlags, "voice", - "Service '%s' should require 'voice' feature", serviceName) - } - }) - - t.Run("security services require security feature", func(t *testing.T) { - securityServices := []string{"arc-identity", "arc-vault", "arc-guard"} - for _, serviceName := range securityServices { - svc := table[serviceName] - require.NotNil(t, svc, "Service '%s' should exist", serviceName) - assert.Contains(t, svc.FeatureFlags, "security", - "Service '%s' should require 'security' feature", serviceName) - } - }) - - t.Run("observability services require observability feature", func(t *testing.T) { - observabilityServices := []string{"arc-otel", "arc-metrics", "arc-logs", "arc-traces", "arc-viz", "arc-log-shipper"} - for _, serviceName := range observabilityServices { - svc := table[serviceName] - require.NotNil(t, svc, "Service '%s' should exist", serviceName) - assert.Contains(t, svc.FeatureFlags, "observability", - "Service '%s' should require 'observability' feature", serviceName) - } - }) - - t.Run("chaos service requires chaos feature", func(t *testing.T) { - svc := table["arc-chaos"] - require.NotNil(t, svc) - assert.Contains(t, svc.FeatureFlags, "chaos") - }) +// TestServiceDefinitionStructure verifies the struct can be constructed correctly. +func TestServiceDefinitionStructure(t *testing.T) { + t.Parallel() + + svc := &services.ServiceDefinition{ + ServiceName: "arc-test", + CodeName: "test-codename", + ArcImage: "arc-test", + ImageName: "test/image:latest", + RequiredConfigs: []string{"config.yml"}, + Dependencies: []string{"gateway"}, + FeatureFlags: []string{}, + Ports: []services.PortMapping{{Host: 8080, Container: 8080}}, + } - t.Run("base infrastructure has no feature requirements", func(t *testing.T) { - baseServices := []string{"arc-gateway", "arc-db-sql", "arc-db-cache", "arc-db-vector", "arc-storage"} - for _, serviceName := range baseServices { - svc := table[serviceName] - require.NotNil(t, svc, "Service '%s' should exist", serviceName) - assert.Empty(t, svc.FeatureFlags, - "Base service '%s' should have no feature requirements", serviceName) - } - }) + assert.Equal(t, "arc-test", svc.ServiceName) + assert.Equal(t, "test-codename", svc.CodeName) + assert.Equal(t, "arc-test", svc.ArcImage) + assert.Equal(t, "test/image:latest", svc.ImageName) + assert.Len(t, svc.RequiredConfigs, 1) + assert.Equal(t, []string{"gateway"}, svc.Dependencies) + assert.Empty(t, svc.FeatureFlags) + assert.Len(t, svc.Ports, 1) } diff --git a/pkg/workspace/template/engine_test.go b/pkg/workspace/template/engine_test.go index 9399506..1e6e7a0 100644 --- a/pkg/workspace/template/engine_test.go +++ b/pkg/workspace/template/engine_test.go @@ -101,11 +101,12 @@ func TestEngine_Hydrate(t *testing.T) { t.Run("renders template with valid data", func(t *testing.T) { // Use a simple template that we know exists data := struct { - Version string - Tier string + Version string + Tier string + Capabilities []string }{ Version: "1.0.0", - Tier: "super-saiyan", + Tier: "think", } output, err := engine.Hydrate("arc.yaml.tmpl", data) @@ -115,10 +116,8 @@ func TestEngine_Hydrate(t *testing.T) { }) t.Run("renders template with service definitions", func(t *testing.T) { - table := services.GetMasterServiceTable() - serviceList := []*services.ServiceDefinition{ - table["arc-gateway"], - } + svc, _ := services.NewMapper().LookupService("arc-gateway") + serviceList := []*services.ServiceDefinition{svc} ctx := tmpl.TemplateContext{ Services: serviceList, @@ -137,8 +136,9 @@ func TestEngine_Hydrate(t *testing.T) { t.Run("renders template with empty data", func(t *testing.T) { data := struct { - Version string - Tier string + Version string + Tier string + Capabilities []string }{ Version: "", Tier: "", @@ -176,11 +176,12 @@ func TestEngine_HydrateBytes(t *testing.T) { t.Run("returns correct byte output", func(t *testing.T) { data := struct { - Version string - Tier string + Version string + Tier string + Capabilities []string }{ Version: "1.0.0", - Tier: "super-saiyan", + Tier: "think", } output, err := engine.HydrateBytes("arc.yaml.tmpl", data) @@ -202,11 +203,12 @@ func TestEngine_HydrateBytes(t *testing.T) { t.Run("byte output matches string output", func(t *testing.T) { data := struct { - Version string - Tier string + Version string + Tier string + Capabilities []string }{ Version: "1.0.0", - Tier: "super-saiyan", + Tier: "think", } strOutput, err1 := engine.Hydrate("arc.yaml.tmpl", data) @@ -389,22 +391,19 @@ func TestEngine_TemplateRendering_Integration(t *testing.T) { require.NoError(t, err) ctx := struct { - Version string - Tier string - Features map[string]bool + Version string + Tier string + Capabilities []string }{ - Version: "1.0.0", - Tier: "super-saiyan", - Features: map[string]bool{ - "voice": true, - "security": false, - }, + Version: "1.0.0", + Tier: "think", + Capabilities: []string{"observe"}, } output, err := engine.Hydrate("arc.yaml.tmpl", ctx) require.NoError(t, err) assert.Contains(t, output, "version:") - assert.Contains(t, output, "features:") + assert.Contains(t, output, "capabilities:") }) t.Run("render gitignore template", func(t *testing.T) { @@ -436,9 +435,10 @@ func TestEngine_ErrorHandling(t *testing.T) { require.NoError(t, err) data := struct { - Version string - Tier string - }{Version: "1.0.0", Tier: "super-saiyan"} + Version string + Tier string + Capabilities []string + }{Version: "1.0.0", Tier: "think", Capabilities: nil} // First render output1, err1 := engine.Hydrate("arc.yaml.tmpl", data) @@ -459,11 +459,12 @@ func TestEngine_ErrorHandling(t *testing.T) { require.NoError(t, err) data := struct { - Version string - Tier string + Version string + Tier string + Capabilities []string }{ Version: "1.0.0", - Tier: "super-saiyan", + Tier: "think", } output1, err1 := engine.Hydrate("arc.yaml.tmpl", data) @@ -484,11 +485,9 @@ func TestEngine_WithTemplateContext(t *testing.T) { engine, err := tmpl.NewEngine() require.NoError(t, err) - table := services.GetMasterServiceTable() + gw, _ := services.NewMapper().LookupService("arc-gateway") ctx := tmpl.TemplateContext{ - Services: []*services.ServiceDefinition{ - table["arc-gateway"], - }, + Services: []*services.ServiceDefinition{gw}, Env: map[string]string{ "LOG_LEVEL": "debug", "ENVIRONMENT": "development", diff --git a/pkg/workspace/template/functions.go b/pkg/workspace/template/functions.go index ca95701..3b96e49 100644 --- a/pkg/workspace/template/functions.go +++ b/pkg/workspace/template/functions.go @@ -2,11 +2,27 @@ package template import ( "strings" + "sync" "text/template" "github.com/arc-framework/arc-cli/pkg/workspace/services" ) +// defaultMapper is a lazily initialized, package-level mapper instance. +// It is safe for concurrent use and avoids re-loading the catalog on every +// template function call. +var ( + defaultMapperOnce sync.Once + defaultMapper *services.Mapper +) + +func getDefaultMapper() *services.Mapper { + defaultMapperOnce.Do(func() { + defaultMapper = services.NewMapper() + }) + return defaultMapper +} + // OIDCProvider represents an OIDC provider configuration type OIDCProvider struct { ID string @@ -115,7 +131,7 @@ func GetTemplateFuncs() template.FuncMap { // Returns: // - bool: true if service exists, false otherwise func serviceEnabled(name string) bool { - _, exists := services.GetServiceByName(name) + _, exists := getDefaultMapper().LookupService(name) return exists } @@ -133,14 +149,14 @@ func serviceEnabled(name string) bool { // Returns: // - int: The first port from the service definition, or defaultPort if unavailable func servicePort(name string, defaultPort int) int { - svc, exists := services.GetServiceByName(name) + svc, exists := getDefaultMapper().LookupService(name) if !exists { return defaultPort } // Return first port if available if len(svc.Ports) > 0 { - return svc.Ports[0] + return svc.Ports[0].Host } return defaultPort @@ -159,7 +175,7 @@ func servicePort(name string, defaultPort int) int { // Returns: // - string: The Docker image name (e.g., "traefik:v3.0"), or empty string if not found func serviceImage(name string) string { - svc, exists := services.GetServiceByName(name) + svc, exists := getDefaultMapper().LookupService(name) if !exists { return "" } diff --git a/pkg/workspace/template/functions_test.go b/pkg/workspace/template/functions_test.go index c03cc0d..047f595 100644 --- a/pkg/workspace/template/functions_test.go +++ b/pkg/workspace/template/functions_test.go @@ -57,8 +57,8 @@ func TestServiceEnabled(t *testing.T) { want: true, }, { - name: "existing service - arc-db-sql", - serviceName: "arc-db-sql", + name: "existing service - arc-sql-db", + serviceName: "arc-sql-db", want: true, }, { @@ -67,13 +67,13 @@ func TestServiceEnabled(t *testing.T) { want: true, }, { - name: "existing service - arc-brain", - serviceName: "arc-brain", + name: "existing service - arc-reasoner", + serviceName: "arc-reasoner", want: true, }, { - name: "existing service - arc-voice-server", - serviceName: "arc-voice-server", + name: "existing service - arc-voice-agent", + serviceName: "arc-voice-agent", want: true, }, { @@ -123,22 +123,22 @@ func TestServicePort(t *testing.T) { want: 80, // First port of arc-gateway is 80 }, { - name: "db-sql has port", - serviceName: "arc-db-sql", + name: "persistence has port", + serviceName: "arc-sql-db", defaultPort: 9999, want: 5432, }, { - name: "db-cache has port", - serviceName: "arc-db-cache", + name: "cache has port", + serviceName: "arc-cache", defaultPort: 9999, want: 6379, }, { - name: "metrics has port", - serviceName: "arc-metrics", + name: "friday-collector has port", + serviceName: "arc-friday-collector", defaultPort: 9999, - want: 9090, + want: 4317, }, { name: "non-existent service returns default", @@ -178,34 +178,19 @@ func TestServiceImage(t *testing.T) { want string }{ { - name: "gateway returns traefik image", + name: "gateway returns ghcr image", serviceName: "arc-gateway", - want: "traefik:v3.0", + want: "ghcr.io/arc-framework/arc-gateway:latest", }, { - name: "db-sql returns postgres image", - serviceName: "arc-db-sql", - want: "postgres:16-alpine", + name: "mailer returns ghcr image", + serviceName: "arc-mailer", + want: "ghcr.io/arc-framework/arc-mailer:latest", }, { - name: "db-cache returns redis image", - serviceName: "arc-db-cache", - want: "redis:alpine", - }, - { - name: "identity returns kratos image", - serviceName: "arc-identity", - want: "oryd/kratos:latest", - }, - { - name: "brain returns custom image path", - serviceName: "arc-brain", - want: "./core/engine", - }, - { - name: "metrics returns prometheus image", - serviceName: "arc-metrics", - want: "prom/prometheus:latest", + name: "persistence returns ghcr image", + serviceName: "arc-sql-db", + want: "ghcr.io/arc-framework/arc-sql-db:latest", }, { name: "non-existent service returns empty string", @@ -236,7 +221,16 @@ func TestServiceImage(t *testing.T) { } func TestHasService(t *testing.T) { - table := services.GetMasterServiceTable() + gw, ok := services.NewMapper().LookupService("arc-gateway") + require.True(t, ok, "arc-gateway must exist in catalog") + persistence, ok := services.NewMapper().LookupService("arc-sql-db") + require.True(t, ok, "arc-sql-db must exist in catalog") + reasoner, ok := services.NewMapper().LookupService("arc-reasoner") + require.True(t, ok, "arc-reasoner must exist in catalog") + storage, ok := services.NewMapper().LookupService("arc-storage") + require.True(t, ok, "arc-storage must exist in catalog") + cache, ok := services.NewMapper().LookupService("arc-cache") + require.True(t, ok, "arc-cache must exist in catalog") tests := []struct { name string @@ -245,41 +239,27 @@ func TestHasService(t *testing.T) { want bool }{ { - name: "service exists in list", - serviceList: []*services.ServiceDefinition{ - table["arc-gateway"], - table["arc-db-sql"], - }, + name: "service exists in list", + serviceList: []*services.ServiceDefinition{gw, persistence}, serviceName: "arc-gateway", want: true, }, { - name: "service exists in middle of list", - serviceList: []*services.ServiceDefinition{ - table["arc-gateway"], - table["arc-db-sql"], - table["arc-brain"], - }, - serviceName: "arc-db-sql", + name: "service exists in middle of list", + serviceList: []*services.ServiceDefinition{gw, persistence, reasoner}, + serviceName: "arc-sql-db", want: true, }, { - name: "service exists at end of list", - serviceList: []*services.ServiceDefinition{ - table["arc-gateway"], - table["arc-db-sql"], - table["arc-brain"], - }, - serviceName: "arc-brain", + name: "service exists at end of list", + serviceList: []*services.ServiceDefinition{gw, persistence, reasoner}, + serviceName: "arc-reasoner", want: true, }, { - name: "service does not exist in list", - serviceList: []*services.ServiceDefinition{ - table["arc-gateway"], - table["arc-db-sql"], - }, - serviceName: "arc-brain", + name: "service does not exist in list", + serviceList: []*services.ServiceDefinition{gw, persistence}, + serviceName: "arc-reasoner", want: false, }, { @@ -295,21 +275,15 @@ func TestHasService(t *testing.T) { want: false, }, { - name: "empty service name", - serviceList: []*services.ServiceDefinition{ - table["arc-gateway"], - }, + name: "empty service name", + serviceList: []*services.ServiceDefinition{gw}, serviceName: "", want: false, }, { - name: "service list with nil entries", - serviceList: []*services.ServiceDefinition{ - table["arc-gateway"], - nil, - table["arc-db-sql"], - }, - serviceName: "arc-db-sql", + name: "service list with nil entries", + serviceList: []*services.ServiceDefinition{gw, nil, persistence}, + serviceName: "arc-sql-db", want: true, }, { @@ -324,13 +298,7 @@ func TestHasService(t *testing.T) { { name: "large service list", serviceList: []*services.ServiceDefinition{ - table["arc-gateway"], - table["arc-db-sql"], - table["arc-db-cache"], - table["arc-db-vector"], - table["arc-storage"], - table["arc-brain"], - table["arc-metrics"], + gw, persistence, cache, storage, reasoner, }, serviceName: "arc-storage", want: true, @@ -355,12 +323,11 @@ func TestTemplateFunctions_Integration(t *testing.T) { engine, err := tmpl.NewEngineWithFuncs(funcMap) require.NoError(t, err) - // Create template context with services (only arc-gateway to avoid template issues) - table := services.GetMasterServiceTable() + gw, ok := services.NewMapper().LookupService("arc-gateway") + require.True(t, ok) + ctx := tmpl.TemplateContext{ - Services: []*services.ServiceDefinition{ - table["arc-gateway"], - }, + Services: []*services.ServiceDefinition{gw}, Env: map[string]string{ "LOG_LEVEL": "debug", }, @@ -378,11 +345,11 @@ func TestTemplateFunctions_Integration(t *testing.T) { engine, err := tmpl.NewEngineWithFuncs(funcMap) require.NoError(t, err) - table := services.GetMasterServiceTable() + gw, ok := services.NewMapper().LookupService("arc-gateway") + require.True(t, ok) + ctx := tmpl.TemplateContext{ - Services: []*services.ServiceDefinition{ - table["arc-gateway"], - }, + Services: []*services.ServiceDefinition{gw}, } // Render template - should work without errors @@ -402,12 +369,12 @@ func TestTemplateFunctions_Integration(t *testing.T) { assert.Equal(t, 80, servicePortFunc("arc-gateway", 8080)) serviceImageFunc := funcMap["serviceImage"].(func(string) string) - assert.Equal(t, "traefik:v3.0", serviceImageFunc("arc-gateway")) + assert.Equal(t, "ghcr.io/arc-framework/arc-gateway:latest", serviceImageFunc("arc-gateway")) - table := services.GetMasterServiceTable() + gw, ok := services.NewMapper().LookupService("arc-gateway") + require.True(t, ok) hasServiceFunc := funcMap["hasService"].(func([]*services.ServiceDefinition, string) bool) - serviceList := []*services.ServiceDefinition{table["arc-gateway"]} - assert.True(t, hasServiceFunc(serviceList, "arc-gateway")) + assert.True(t, hasServiceFunc([]*services.ServiceDefinition{gw}, "arc-gateway")) }) } @@ -449,11 +416,9 @@ func TestTemplateFunctions_EdgeCases(t *testing.T) { funcMap := tmpl.GetTemplateFuncs() hasServiceFunc := funcMap["hasService"].(func([]*services.ServiceDefinition, string) bool) - table := services.GetMasterServiceTable() - serviceList := []*services.ServiceDefinition{ - table["arc-gateway"], - table["arc-gateway"], // Duplicate - } + gw, ok := services.NewMapper().LookupService("arc-gateway") + require.True(t, ok) + serviceList := []*services.ServiceDefinition{gw, gw} // Duplicate // Should still return true assert.True(t, hasServiceFunc(serviceList, "arc-gateway")) @@ -461,41 +426,34 @@ func TestTemplateFunctions_EdgeCases(t *testing.T) { } func TestTemplateFunctions_AllServices(t *testing.T) { - t.Run("serviceEnabled works for all services in registry", func(t *testing.T) { + // Catalog-backed list of known arc-image names + knownServices := []string{ + "arc-gateway", "arc-sql-db", "arc-cache", "arc-messaging", + "arc-streaming", "arc-storage", "arc-friday-collector", "arc-cortex", + "arc-reasoner", "arc-voice-agent", "arc-identity", + } + + t.Run("serviceEnabled works for all catalog services", func(t *testing.T) { funcMap := tmpl.GetTemplateFuncs() serviceEnabledFunc := funcMap["serviceEnabled"].(func(string) bool) - table := services.GetMasterServiceTable() - for serviceName := range table { + for _, serviceName := range knownServices { assert.True(t, serviceEnabledFunc(serviceName), "serviceEnabled should return true for %s", serviceName) } }) - t.Run("serviceImage returns non-empty for all services", func(t *testing.T) { - funcMap := tmpl.GetTemplateFuncs() - serviceImageFunc := funcMap["serviceImage"].(func(string) string) - - table := services.GetMasterServiceTable() - for serviceName := range table { - image := serviceImageFunc(serviceName) - assert.NotEmpty(t, image, - "serviceImage should return non-empty image for %s", serviceName) - } - }) - - t.Run("servicePort returns correct port for services with ports", func(t *testing.T) { + t.Run("servicePort returns correct port for services with known ports", func(t *testing.T) { funcMap := tmpl.GetTemplateFuncs() servicePortFunc := funcMap["servicePort"].(func(string, int) int) servicesWithPorts := map[string]int{ - "arc-gateway": 80, - "arc-db-sql": 5432, - "arc-db-cache": 6379, - "arc-metrics": 9090, - "arc-storage": 9000, - "arc-brain": 8000, - "arc-identity": 4433, + "arc-gateway": 80, + "arc-sql-db": 5432, + "arc-cache": 6379, + "arc-storage": 9000, + "arc-identity": 4433, + "arc-friday-collector": 4317, } for serviceName, expectedPort := range servicesWithPorts { @@ -511,18 +469,15 @@ func TestTemplateFunctions_RealWorldScenarios(t *testing.T) { funcMap := tmpl.GetTemplateFuncs() hasServiceFunc := funcMap["hasService"].(func([]*services.ServiceDefinition, string) bool) - table := services.GetMasterServiceTable() - observabilityServices := []*services.ServiceDefinition{ - table["arc-metrics"], - table["arc-logs"], - table["arc-traces"], - table["arc-viz"], - } + collector, ok := services.NewMapper().LookupService("arc-friday-collector") + require.True(t, ok) + friday, ok := services.NewMapper().LookupService("arc-friday") + require.True(t, ok) - assert.True(t, hasServiceFunc(observabilityServices, "arc-metrics")) - assert.True(t, hasServiceFunc(observabilityServices, "arc-logs")) - assert.True(t, hasServiceFunc(observabilityServices, "arc-traces")) - assert.True(t, hasServiceFunc(observabilityServices, "arc-viz")) + observabilityServices := []*services.ServiceDefinition{collector, friday} + + assert.True(t, hasServiceFunc(observabilityServices, "arc-friday-collector")) + assert.True(t, hasServiceFunc(observabilityServices, "arc-friday")) assert.False(t, hasServiceFunc(observabilityServices, "arc-gateway")) }) @@ -530,37 +485,36 @@ func TestTemplateFunctions_RealWorldScenarios(t *testing.T) { funcMap := tmpl.GetTemplateFuncs() hasServiceFunc := funcMap["hasService"].(func([]*services.ServiceDefinition, string) bool) - table := services.GetMasterServiceTable() - voiceServices := []*services.ServiceDefinition{ - table["arc-voice-server"], - table["arc-voice-agent"], - table["arc-ingress"], - table["arc-egress"], - } + voiceAgent, ok := services.NewMapper().LookupService("arc-voice-agent") + require.True(t, ok) + realtime, ok := services.NewMapper().LookupService("arc-realtime") + require.True(t, ok) + + voiceServices := []*services.ServiceDefinition{voiceAgent, realtime} - assert.True(t, hasServiceFunc(voiceServices, "arc-voice-server")) assert.True(t, hasServiceFunc(voiceServices, "arc-voice-agent")) - assert.False(t, hasServiceFunc(voiceServices, "arc-db-sql")) + assert.True(t, hasServiceFunc(voiceServices, "arc-realtime")) + assert.False(t, hasServiceFunc(voiceServices, "arc-sql-db")) }) t.Run("get ports for common services", func(t *testing.T) { funcMap := tmpl.GetTemplateFuncs() servicePortFunc := funcMap["servicePort"].(func(string, int) int) - // Test common services with well-known defaults - assert.Equal(t, 80, servicePortFunc("arc-gateway", 80)) - assert.Equal(t, 5432, servicePortFunc("arc-db-sql", 5432)) - assert.Equal(t, 6379, servicePortFunc("arc-db-cache", 6379)) + assert.Equal(t, 80, servicePortFunc("arc-gateway", 9999)) + assert.Equal(t, 5432, servicePortFunc("arc-sql-db", 9999)) + assert.Equal(t, 6379, servicePortFunc("arc-cache", 9999)) }) t.Run("get images for deployment", func(t *testing.T) { funcMap := tmpl.GetTemplateFuncs() serviceImageFunc := funcMap["serviceImage"].(func(string) string) - // Verify critical service images - assert.Equal(t, "traefik:v3.0", serviceImageFunc("arc-gateway")) - assert.Equal(t, "postgres:16-alpine", serviceImageFunc("arc-db-sql")) - assert.Equal(t, "redis:alpine", serviceImageFunc("arc-db-cache")) + // All services use ghcr.io/arc-framework/:latest + assert.Equal(t, "ghcr.io/arc-framework/arc-gateway:latest", serviceImageFunc("arc-gateway")) + assert.Equal(t, "ghcr.io/arc-framework/arc-mailer:latest", serviceImageFunc("arc-mailer")) + assert.Equal(t, "ghcr.io/arc-framework/arc-sql-db:latest", serviceImageFunc("arc-sql-db")) + assert.Equal(t, "ghcr.io/arc-framework/arc-cache:latest", serviceImageFunc("arc-cache")) }) } @@ -702,11 +656,10 @@ func TestJoinStrings(t *testing.T) { func TestTemplateContext(t *testing.T) { t.Run("TemplateContext can be created with all fields", func(t *testing.T) { - table := services.GetMasterServiceTable() + gw, ok := services.NewMapper().LookupService("arc-gateway") + require.True(t, ok) ctx := tmpl.TemplateContext{ - Services: []*services.ServiceDefinition{ - table["arc-gateway"], - }, + Services: []*services.ServiceDefinition{gw}, Env: map[string]string{ "LOG_LEVEL": "debug", }, diff --git a/pkg/workspace/validator.go b/pkg/workspace/validator.go index 9e40c72..e8fd35a 100644 --- a/pkg/workspace/validator.go +++ b/pkg/workspace/validator.go @@ -51,14 +51,14 @@ func (v *Validator) ValidatePortMappings(serviceList []*services.ServiceDefiniti for _, svc := range serviceList { for _, port := range svc.Ports { - if existingService, exists := usedPorts[port]; exists { + if existingService, exists := usedPorts[port.Host]; exists { return &PortConflictError{ - Port: port, + Port: port.Host, Service1: existingService, Service2: svc.ServiceName, } } - usedPorts[port] = svc.ServiceName + usedPorts[port.Host] = svc.ServiceName } } diff --git a/pkg/workspace/validator_test.go b/pkg/workspace/validator_test.go index 9737cfe..6f7d60c 100644 --- a/pkg/workspace/validator_test.go +++ b/pkg/workspace/validator_test.go @@ -168,15 +168,15 @@ func TestValidator_ValidatePortMappings(t *testing.T) { services: []*services.ServiceDefinition{ { ServiceName: "arc-gateway", - Ports: []int{80, 443}, + Ports: []services.PortMapping{{Host: 80, Container: 80}, {Host: 443, Container: 443}}, }, { ServiceName: "arc-db-sql", - Ports: []int{5432}, + Ports: []services.PortMapping{{Host: 5432, Container: 5432}}, }, { ServiceName: "arc-db-cache", - Ports: []int{6379}, + Ports: []services.PortMapping{{Host: 6379, Container: 6379}}, }, }, wantErr: false, @@ -186,11 +186,11 @@ func TestValidator_ValidatePortMappings(t *testing.T) { services: []*services.ServiceDefinition{ { ServiceName: "arc-gateway", - Ports: []int{80, 443}, + Ports: []services.PortMapping{{Host: 80, Container: 80}, {Host: 443, Container: 443}}, }, { ServiceName: "arc-api-gateway", - Ports: []int{80, 8080}, + Ports: []services.PortMapping{{Host: 80, Container: 80}, {Host: 8080, Container: 8080}}, }, }, wantErr: true, @@ -200,11 +200,11 @@ func TestValidator_ValidatePortMappings(t *testing.T) { services: []*services.ServiceDefinition{ { ServiceName: "service-a", - Ports: []int{8080, 8081}, + Ports: []services.PortMapping{{Host: 8080, Container: 8080}, {Host: 8081, Container: 8081}}, }, { ServiceName: "service-b", - Ports: []int{8081, 8082}, + Ports: []services.PortMapping{{Host: 8081, Container: 8081}, {Host: 8082, Container: 8082}}, }, }, wantErr: true, @@ -214,11 +214,11 @@ func TestValidator_ValidatePortMappings(t *testing.T) { services: []*services.ServiceDefinition{ { ServiceName: "arc-brain", - Ports: []int{}, + Ports: []services.PortMapping{}, }, { ServiceName: "arc-workflow", - Ports: []int{}, + Ports: []services.PortMapping{}, }, }, wantErr: false, @@ -377,12 +377,12 @@ func TestValidator_Integration(t *testing.T) { { ServiceName: "arc-gateway", Dependencies: []string{}, - Ports: []int{80, 443}, + Ports: []services.PortMapping{{Host: 80, Container: 80}, {Host: 443, Container: 443}}, }, { ServiceName: "arc-api-gateway", Dependencies: []string{"arc-gateway"}, - Ports: []int{8080}, + Ports: []services.PortMapping{{Host: 8080, Container: 8080}}, }, } diff --git a/specs/020-pre-release/contracts/interfaces.go b/specs/020-pre-release/contracts/interfaces.go new file mode 100644 index 0000000..ce834ce --- /dev/null +++ b/specs/020-pre-release/contracts/interfaces.go @@ -0,0 +1,148 @@ +// Package contracts defines the Go interface signatures and function contracts +// for Feature 020 β€” Workspace Subsystem Rewrite. +// +// These are not production code β€” they are the agreed-upon contracts that +// drive task implementation and review. + +package contracts + +// ============================================================================= +// pkg/workspace/services/mapping.go +// ============================================================================= + +// MapperInterface defines the public API of the service mapper. +// The Mapper struct must satisfy this interface after refactoring. +type MapperInterface interface { + // ResolveServices is the NEW primary resolution path. + // tier: one of "think", "reason", "ultra-instinct" + // caps: additional capability names (e.g. ["voice", "observe"]) + // Returns deterministic, deduplicated list of ServiceDefinitions. + ResolveServices(tier string, caps []string) ([]*ServiceDefinition, error) + + // MapFeaturesToServices is the backwards-compat SHIM. + // Delegates to ResolveServices(mf.Tier, merged(mf.Capabilities, mf.Features)) + // + // Deprecated: use ResolveServices. Must remain callable. + MapFeaturesToServices(mf *ManifestRef) ([]*ServiceDefinition, error) + + // ValidateDependencies checks that all deps are satisfiable. + ValidateDependencies(services []*ServiceDefinition) error +} + +// ProfilesConfigInterface defines accessors on the loaded profiles.yaml data. +type ProfilesConfigInterface interface { + // TierCapabilities returns the capability names preset for a tier. + // ultra-instinct returns all known capability names. + TierCapabilities(tier string) []string + + // ExpandCapabilities resolves the requires chain. + // e.g. ["voice"] β†’ ["voice", "reasoner"] + ExpandCapabilities(caps []string) []string + + // CoreServiceNames returns the always-on service codenames. + CoreServiceNames() []string +} + +// ============================================================================= +// pkg/workspace/services/registry.go +// ============================================================================= + +// Deprecated stubs β€” kept for backwards compat. Callers should migrate to +// Mapper.ResolveServices(). These functions log a warning on every call. + +// GetMasterServiceTable returns a minimal stub map. +// Deprecated: Use Mapper.ResolveServices() instead. +// func GetMasterServiceTable() map[string]*ServiceDefinition + +// GetServiceByName looks up a service by name using the catalog. +// Deprecated: Use Mapper's internal catalog lookup. +// func GetServiceByName(name string) (*ServiceDefinition, bool) + +// ============================================================================= +// pkg/workspace/manifest/schema.go +// ============================================================================= + +// ValidatorInterface defines manifest validation. +type ValidatorInterface interface { + // Validate performs full manifest validation. + // Returns ValidationResult containing errors and warnings. + Validate(m *ManifestRef) ValidationResult +} + +// ValidationResult holds errors (blocking) and warnings (non-blocking). +type ValidationResult struct { + Errors []ValidationError + Warnings []ValidationWarning +} + +// ValidationError is a hard failure that blocks workspace operations. +type ValidationError struct { + Field string // e.g. "tier", "capabilities[2]" + Message string // human-readable description +} + +// ValidationWarning is a non-blocking advisory. +type ValidationWarning struct { + Field string // e.g. "features.voice" + Message string // deprecation message + Suggestion string // migration suggestion (e.g., "use: capabilities: [voice]") +} + +// ============================================================================= +// pkg/workspace/manifest/manifest.go +// ============================================================================= + +// ManifestRef is the Go struct for arc.yaml (reference only β€” not production code). +type ManifestRef struct { + Version string + Tier string + Capabilities []string // new canonical field + Features map[string]bool // deprecated β€” triggers deprecation warning + Services map[string]interface{} + Environment map[string]string +} + +// ServiceDefinition β€” updated to add ArcImage (US1 AC#3). +type ServiceDefinition struct { + ServiceName string + CodeName string + ArcImage string // NEW: populated from catalog.Service.ArcImage + ImageName string // upstream docker image (e.g. "traefik:v3.0") + RequiredConfigs []string + Dependencies []string + FeatureFlags []string // unused in new path; kept for compat + Ports []int +} + +// ============================================================================= +// pkg/cli/workspace/init.go +// ============================================================================= + +// InitOptions captures the resolved options for workspace init. +// These are populated either from flags (non-interactive) or the TUI wizard. +type InitOptions struct { + Path string // workspace path (positional arg or default ".") + Tier string // --tier flag or wizard selection; default "think" + Capabilities []string // --capabilities flag or wizard multi-select + Force bool // --force flag + SkipGitignore bool // --skip-gitignore flag +} + +// ============================================================================= +// Error messages (exact text used in output β€” tested by test fixtures) +// ============================================================================= + +// Migration error format for legacy tier names: +// Error: tier "super-saiyan" is no longer valid. +// Migrate your arc.yaml: replace "super-saiyan" with "think". + +// Deprecation warning format for features: key (one line per enabled feature): +// Warning: features.voice=true β†’ use: capabilities: [voice] +// Warning: features.security=true β†’ use: capabilities: [security] +// Warning: features.chaos=true β†’ this feature has been removed and has no capabilities equivalent + +// Invalid capability error format: +// Error: unknown capability "foo". Valid capabilities: reasoner, voice, observe, security, storage + +// Invalid tier error format (non-legacy): +// Error: unknown tier "bar". Valid tiers: think, reason, ultra-instinct diff --git a/specs/020-pre-release/data-model.md b/specs/020-pre-release/data-model.md new file mode 100644 index 0000000..1e830fd --- /dev/null +++ b/specs/020-pre-release/data-model.md @@ -0,0 +1,248 @@ +# Data Model: 020-pre-release β€” Workspace Subsystem Rewrite + +**Branch**: `020-pre-release` | **Date**: 2026-03-10 + +--- + +## Entity Overview + +``` +arc.yaml (Manifest) + β”œβ”€β”€ tier: "think" | "reason" | "ultra-instinct" + └── capabilities: ["voice", "observe", ...] + β”‚ + β–Ό + ProfilesConfig (profiles.yaml β€” embedded) + β”œβ”€β”€ Core.Services: []string (always included) + β”œβ”€β”€ Capabilities: map[name]Capability + β”‚ └── each: Services []string, Requires []string + └── Tiers: map[name]TierDef + └── each: Capabilities []string | "*" + β”‚ + β–Ό + catalog.Service (services.yaml β€” existing) + β”œβ”€β”€ Codename, ArcImage, Ports, Dependencies + β”‚ + β–Ό + ServiceDefinition (resolved, passed to docker-compose generator) + β”œβ”€β”€ ServiceName (= ArcImage or Codename) + β”œβ”€β”€ CodeName + β”œβ”€β”€ ImageName + β”œβ”€β”€ Ports []int + └── Dependencies []string +``` + +--- + +## Modified Structs + +### `pkg/workspace/manifest/manifest.go` + +```go +type Manifest struct { + Version string `yaml:"version"` + Tier string `yaml:"tier,omitempty"` + Capabilities []string `yaml:"capabilities,omitempty"` // NEW + Features map[string]bool `yaml:"features,omitempty"` // DEPRECATED (was required) + Services map[string]interface{} `yaml:"services,omitempty"` + Environment map[string]string `yaml:"environment,omitempty"` +} + +var validTiers = []string{"think", "reason", "ultra-instinct"} +var validCapabilities = []string{"reasoner", "voice", "observe", "security", "storage"} + +// Migration: old tier names that must error +var legacyTierMigrations = map[string]string{ + "super-saiyan": "think", + "super-saiyan-blue": "reason", +} +``` + +**Change summary**: +- `Capabilities []string` added (new canonical field). +- `Features map[string]bool` kept as `omitempty` (was required, now deprecated). +- `validTiers` updated (was implicit in GetTierIndex; now explicit). + +--- + +### `pkg/workspace/services/data/profiles.yaml` (new embedded file) + +```yaml +core: + services: + - gateway + - messaging + - streaming + - cache + - persistence + - cortex + - friday-collector + +capabilities: + reasoner: + services: [reasoner] + voice: + services: [realtime, voice] + requires: [reasoner] + observe: + services: [otel] + security: + services: [vault, flags] + storage: + services: [storage] + +think: + capabilities: [] +reason: + capabilities: [reasoner] +ultra-instinct: + capabilities: "*" +``` + +--- + +### `pkg/workspace/services/mapping.go` β€” New Types + +```go +// ProfilesConfig is the parsed representation of profiles.yaml. +type ProfilesConfig struct { + Core CoreConfig `yaml:"core"` + Capabilities map[string]CapabilityConfig `yaml:"capabilities"` + Tiers map[string]TierDef // parsed from remaining top-level keys +} + +type CoreConfig struct { + Services []string `yaml:"services"` +} + +type CapabilityConfig struct { + Services []string `yaml:"services"` + Requires []string `yaml:"requires,omitempty"` +} + +type TierDef struct { + // Capabilities is either a []string or the sentinel "*". + Capabilities interface{} `yaml:"capabilities"` +} +``` + +--- + +### `pkg/workspace/services/mapping.go` β€” Updated Mapper + +```go +type Mapper struct { + serviceTable map[string]*ServiceDefinition // populated from catalog, not hardcoded + profiles *ProfilesConfig +} +``` + +**Constructor**: +```go +func NewMapper() *Mapper { + profiles := mustLoadProfiles(profilesData) // profilesData = //go:embed data/profiles.yaml + catalog := mustBuildTableFromCatalog() // iterates pkg/catalog.EmbeddedCatalog + return &Mapper{serviceTable: catalog, profiles: profiles} +} +``` + +--- + +### `pkg/workspace/formatter.go` β€” Tier Constants + +```go +// Before (removed) +const ( + tierIDSuperSaiyan = "super-saiyan" + tierIDSuperSaiyanBlue = "super-saiyan-blue" + tierIDUltraInstinct = "ultra-instinct" +) + +// After (replacement) +const ( + tierIDThink = "think" + tierIDReason = "reason" + tierIDUltraInstinct = "ultra-instinct" +) + +func getTierIndex(tierID string) int { + switch tierID { + case tierIDThink: return 0 + case tierIDReason: return 1 + case tierIDUltraInstinct: return 2 + default: return -1 + } +} +``` + +--- + +### `pkg/workspace/manifest/schema.go` β€” Validation Rules + +```go +var KnownTiers = []string{"think", "reason", "ultra-instinct"} +var KnownCapabilities = []string{"reasoner", "voice", "observe", "security", "storage"} + +// Migration errors (hard errors, not warnings) +var legacyTierErrors = map[string]string{ + "super-saiyan": "think", + "super-saiyan-blue": "reason", +} + +// Deprecation warnings (soft β€” non-blocking) +// Emitted to stderr when features: key is present in parsed arc.yaml +``` + +--- + +## State Transitions + +### Tier Validation + +``` +arc.yaml tier value + β”œβ”€β”€ "think" | "reason" | "ultra-instinct" β†’ VALID + β”œβ”€β”€ "super-saiyan" β†’ HARD ERROR: migrate to "think" + β”œβ”€β”€ "super-saiyan-blue" β†’ HARD ERROR: migrate to "reason" + β”œβ”€β”€ "" (empty) β†’ treated as "think" (default) + └── anything else β†’ VALIDATION ERROR: invalid tier +``` + +### Capabilities Validation + +``` +capabilities: [...] + β”œβ”€β”€ all values in KnownCapabilities β†’ VALID + β”œβ”€β”€ unknown value β†’ VALIDATION ERROR (name + list of valid) + └── empty / omitted β†’ VALID (no extra capabilities) +``` + +### Features Deprecation + +``` +features: key present + β”œβ”€β”€ Warning emitted to stderr per enabled feature + β”œβ”€β”€ Mapped to capabilities via featureToCapability table + β”œβ”€β”€ If capabilities: also present β†’ capabilities: wins, features: values ignored + └── does NOT block execution +``` + +--- + +## arc.yaml Template Shape (after) + +```yaml +version: "1.0.0" + +# Tier controls which services are activated. +# think β€” core infrastructure only (gateway, messaging, streaming, cache, persistence, cortex, friday-collector) +# reason β€” core + reasoner (LLM inference engine) +# ultra-instinct β€” core + all capabilities +tier: "think" + +# Optional: add capabilities on top of your tier. +# Available: reasoner, voice, observe, security, storage +# capabilities: +# - observe +# - security +``` diff --git a/specs/020-pre-release/plan.md b/specs/020-pre-release/plan.md new file mode 100644 index 0000000..437ac77 --- /dev/null +++ b/specs/020-pre-release/plan.md @@ -0,0 +1,297 @@ +# Implementation Plan: Workspace Subsystem Rewrite + +**Branch**: `020-pre-release` | **Date**: 2026-03-10 | **Spec**: [spec.md](spec.md) +**Input**: Feature specification from `/specs/020-pre-release/spec.md` + +--- + +## Summary + +Replace the stale hardcoded `GetMasterServiceTable()` with catalog-backed service loading from the already-correct `pkg/catalog/data/services.yaml`. Add a new embedded `profiles.yaml` that defines core services and capability bundles. Rename tier constants from `super-saiyan`/`super-saiyan-blue` to `think`/`reason`. Add `capabilities: []` to `arc.yaml` as the canonical opt-in model. Update the init wizard and `arc.yaml` template to reflect the new vocabulary. Keep all existing callers working via backward-compat shims. + +--- + +## Technical Context + +**Language/Version**: Go 1.24.2 (go.mod declares 1.26.0) +**Primary Dependencies**: cobra, charmbracelet/bubbletea v1.3.4, charmbracelet/huh, charmbracelet/lipgloss v1.1.1, charmbracelet/bubbles v0.21.0; `pkg/catalog` (existing internal package) +**Storage**: `//go:embed` for `profiles.yaml` (new) and existing `services.yaml` (unchanged); no new DB +**Testing**: `make test` with `-race`; table-driven, `t.Parallel()`; target β‰₯75% for modified packages +**Target Platform**: Darwin/Linux/Windows amd64/arm64; single binary +**Project Type**: Single Go project +**Performance Goals**: `NewMapper()` init < 50ms (catalog loaded once, cached) +**Constraints**: `ultra-instinct` existing workspaces must not break; `MapFeaturesToServices()` signature must remain callable +**Scale/Scope**: 8 stories, ~10 files modified, 1 new directory + file, no new external dependencies + +--- + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +- [x] **Zero-Dependency**: No new runtime deps introduced. `profiles.yaml` is a `go:embed` file β€” fits the single-binary model. +- [x] **Local-First**: Service resolution is entirely local. No network calls added. +- [x] **Two-Brain Separation**: Pure infrastructure orchestration config; no agent/LLM logic added. +- [x] **Platform-in-a-Box**: Init wizard extended with tier + capability prompts. Non-interactive flags `--tier`/`--capabilities` added for CI. +- [x] **Intelligent Orchestration**: `ResolveServices()` is dependency-aware via `requires` chain expansion + transitive dep resolution. +- [x] **Deep Observability**: Deprecation warnings emitted to stderr per-feature; migration errors name the exact field and the fix. +- [x] **Resilience Testing**: Config validation errors (tier, capability) are deterministic and testable. Chaos capability is out of scope. +- [x] **Interactive Experience**: Wizard extended. `--tier`/`--capabilities` flags for `ARC_NO_TUI`/pipe/CI scenarios. Deprecation warnings visible before run. +- [x] **Declarative Reconciliation**: `arc.yaml` remains single source of truth. `capabilities:` replaces `features:` declaratively. Idempotent. +- [x] **Security by Default**: No secrets or sensitive config involved in this change. +- [x] **Stateful Operations**: N/A β€” service resolution is stateless; no new DB operations required. +- [x] **High-Performance I/O**: `profiles.yaml` loaded once in `NewMapper()` and cached in `Mapper.profiles`. Catalog also loaded once. + +**Violations requiring justification**: None. + +--- + +## Architectural Patterns Compliance + +*GATE: Must pass for specs 006+.* + +### 1. Factory Pattern (Dependency Injection) +- [x] **No Global State**: `NewMapper()` returns a value; no package-level mutable vars introduced. +- [x] **Context Injection**: `Mapper` constructed via `NewMapper()`; init wizard receives `app.Context`. +- [x] **Explicit Dependencies**: Catalog + profiles loaded inside `NewMapper()`, not globally. + +### 2. XDG Base Directory Specification +- [x] No new file paths introduced. `arc.yaml` lives in the project dir (unchanged). Profiles and catalog are embedded. + +### 3. Repository Pattern (Domain-Driven Storage) +- [x] `profiles.yaml` loading uses a private `loadProfiles()` function β€” not exposed as a repository. Service resolution is not a storage domain; the existing catalog package handles persistence. Compliant. + +### 4. Middleware/UI Service Pattern +- [x] **UI Service**: Init wizard uses existing `ctx.UI` pattern. +- [x] **No Flag Checks**: Wizard business logic doesn't check `NoColor`/`NoAnimation` directly. +- [x] **Separation of Concerns**: Service resolution logic (`mapping.go`) has no UI imports. + +### 5. Configuration Management (12-Factor App) +- [x] `--tier` and `--capabilities` flags added. +- [x] Precedence: flags β†’ env (via existing ARC_* mechanism) β†’ arc.yaml β†’ defaults. +- [x] `internal/config` package used for global config; `arc.yaml` parsed via existing manifest parser. + +### 6. Testing Standards +- [x] **Table-Driven Tests**: All new tests use table-driven pattern. +- [x] **Parallel Execution**: `t.Parallel()` on all safe tests. +- [x] **Coverage Target**: β‰₯75% for `mapping.go`, `schema.go`. + +**Pattern Exceptions**: None. + +--- + +## Project Structure + +### Documentation (this feature) + +```text +specs/020-pre-release/ +β”œβ”€β”€ spec.md # Feature specification +β”œβ”€β”€ plan.md # This file +β”œβ”€β”€ research.md # Phase 0 output +β”œβ”€β”€ data-model.md # Phase 1 output +β”œβ”€β”€ quickstart.md # Phase 1 output +β”œβ”€β”€ contracts/ +β”‚ └── interfaces.go # Go interface contracts +└── tasks.md # Phase 2 output (via /speckit.tasks) +``` + +### Source Code (files modified or created) + +```text +pkg/workspace/ +β”œβ”€β”€ formatter.go # MODIFY: rename tier constants +β”œβ”€β”€ manifest/ +β”‚ β”œβ”€β”€ manifest.go # MODIFY: add Capabilities field +β”‚ └── schema.go # MODIFY: tier allowlist, capabilities validation, migration errors +└── services/ + β”œβ”€β”€ registry.go # MODIFY: replace GetMasterServiceTable body, stub deprecated fns + β”œβ”€β”€ mapping.go # MODIFY: NewMapper, ResolveServices, profile types, embed + └── data/ + └── profiles.yaml # CREATE: embedded capability profiles + +pkg/scaffold/templates/ +└── arc.yaml.tmpl # MODIFY: default think, capabilities block, remove features + +pkg/ui/view/ +└── init_wizard.go # MODIFY: tier options, capability multi-select + +pkg/cli/workspace/ +└── init.go # MODIFY: --tier, --capabilities flags + +# Test files (co-located) +pkg/workspace/services/mapping_test.go # MODIFY/EXTEND +pkg/workspace/manifest/schema_test.go # MODIFY/EXTEND +pkg/workspace/services/registry_test.go # MODIFY: remove GetMasterServiceTable assertions +``` + +**Structure Decision**: Single Go project. All changes are in-tree modifications; one new directory (`data/`). + +--- + +## Code Quality & Testing Standards + +**Linting Requirements**: +- All code MUST pass `make quality` (golangci-lint, 48 linters). +- `//nolint` directives only with explanation. Expect `dupl` warnings on view files (pre-existing, non-blocking). +- `SA1019` (deprecated imports) in `root.go` is pre-existing β€” do not add new ones. + +**Test Coverage Targets**: +- `pkg/workspace/services/mapping.go` (core logic): β‰₯75% +- `pkg/workspace/manifest/schema.go` (validation): β‰₯75% +- `pkg/workspace/formatter.go` (display): β‰₯40% +- `pkg/ui/view/init_wizard.go` (TUI): β‰₯40% + +**Testing Approach**: +- `TestResolveServices_*` cases: think/reason/ultra-instinct/with-capabilities/deduplication. +- `TestExpandCapabilities_*` cases: no-op, single-hop, multi-hop requires chain. +- `TestValidate_*` cases: legacy tier error, unknown tier, unknown capability, features deprecation warning. +- `TestGetTierIndex` cases: all valid tiers + unknown. +- Golden file tests for `arc.yaml.tmpl` output (think/reason/ultra-instinct Γ— with/without capabilities). + +--- + +## Complexity Tracking + +No Constitution violations requiring justification. + +--- + +## Implementation Phases + +### Phase A β€” Foundation (data layer, no functional change) + +**A1** β€” Create `pkg/workspace/services/data/profiles.yaml` +- New directory + file. Content verbatim from spec. +- No code changes yet; validates that directory structure works. + +**A2** β€” Add profile types and loader to `mapping.go` +- Add `//go:embed data/profiles.yaml` and `var profilesData []byte`. +- Add `ProfilesConfig`, `CoreConfig`, `CapabilityConfig`, `TierDef` structs. +- Add `loadProfiles(data []byte) (*ProfilesConfig, error)`. +- Add `mustLoadProfiles(data []byte) *ProfilesConfig` (panics on bad embed β€” same pattern as catalog). +- Add methods: `CoreServiceNames()`, `TierCapabilities()`, `ExpandCapabilities()`. +- Tests: `TestLoadProfiles`, `TestTierCapabilities`, `TestExpandCapabilities`. + +**A3** β€” Add `buildTableFromCatalog()` to `mapping.go` +- Iterates `pkg/catalog.NewEmbeddedCatalog().ListAll()`. +- Converts each `*catalog.Service` to `*ServiceDefinition` via `buildServiceDefinition()`. +- Returns `map[string]*ServiceDefinition` keyed by codename. +- Tests: `TestBuildTableFromCatalog_ContainsCoreServices`. + +### Phase B β€” Service Resolution (new path) + +**B1** β€” Update `Mapper` struct and `NewMapper()` +- Add `profiles *ProfilesConfig` field. +- `NewMapper()` calls `mustLoadProfiles` + `buildTableFromCatalog`. +- Keep all existing method signatures. +- Existing tests must still pass (no behavioural change yet). + +**B2** β€” Implement `ResolveServices(tier string, caps []string)` +- Full implementation per spec Story 4 and data-model.md. +- Deterministic ordering: core first, then capabilities in stable order. +- Tests: all `TestResolveServices_*` cases from quickstart.md. + +**B3** β€” Convert `MapFeaturesToServices()` to shim +- Remove Phase 1 + Phase 2 logic. +- Body: `featuresToCapabilities(mf.Features)` + append `mf.Capabilities` + call `ResolveServices`. +- Add `featuresToCapabilities()` helper with static map from research R-07. +- Existing `mapping_test.go` must still pass. + +### Phase C β€” Registry cleanup + +**C1** β€” Replace `GetMasterServiceTable()` body with deprecated stub +- Remove all 300+ lines of hardcoded service entries. +- New body: log warning + return `map[string]*ServiceDefinition{}`. +- Update `GetServiceByName()` to delegate to `NewMapper().serviceTable`. +- Update `GetBaseInfrastructure()` to delegate to `NewMapper().servicesForNames(profiles.CoreServiceNames())`. +- Update `registry_test.go`: remove assertions on hardcoded service data; add smoke test for catalog-backed lookup. + +### Phase D β€” Manifest + Schema + +**D1** β€” Update `manifest.go` +- Add `Capabilities []string \`yaml:"capabilities,omitempty"\`` field. +- Change `Features` tag to `omitempty`. + +**D2** β€” Update `schema.go` +- Add `KnownTiers`, `KnownCapabilities`, `legacyTierErrors` vars. +- Update `Validate()`: + 1. Legacy tier check β†’ hard error with migration message (exact format from contracts). + 2. Unknown tier check β†’ validation error. + 3. Capabilities loop β†’ validation error for unknown values. + 4. Features non-empty β†’ deprecation warnings to stderr (one per enabled feature). +- Update `KnownFeatures` to drop `observability`/`chaos` (no longer valid capabilities β€” they map differently). +- Tests: all `TestValidate_*` cases. + +### Phase E β€” Formatter + Tier constants + +**E1** β€” Rename tier constants in `formatter.go` +- `tierIDSuperSaiyan` β†’ `tierIDThink = "think"`. +- `tierIDSuperSaiyanBlue` β†’ `tierIDReason = "reason"`. +- Update `getTierIndex()` switch. +- Tests: `TestGetTierIndex`. + +### Phase F β€” Template + CLI + +**F1** β€” Update `pkg/scaffold/templates/arc.yaml.tmpl` +- Default tier `think`. +- Add tier comment block (3 lines). +- Add commented `capabilities:` block. +- Remove `features:` block. +- Golden file test: rendered output for think/reason/ultra-instinct Γ— with/without caps. + +**F2** β€” Update `pkg/cli/workspace/init.go` +- Add `--tier string` flag (default `"think"`). +- Add `--capabilities []string` flag. +- Pass values into `InitOptions`; forward to wizard (pre-fill) or skip wizard steps if flags provided. + +**F3** β€” Update `pkg/ui/view/init_wizard.go` +- Change tier select options: `think`, `reason`, `ultra-instinct`. +- Add capability multi-select field (huh.MultiSelect), shown when tier β‰  `ultra-instinct`. +- Pre-check capabilities included by chosen tier. +- Pass selected capabilities to initializer. + +### Phase G β€” Integration + Cleanup + +**G1** β€” End-to-end smoke tests +- `arc workspace init --tier reason --capabilities voice` β†’ inspect generated `arc.yaml`. +- `arc workspace init --tier ultra-instinct` β†’ no capabilities prompt. +- Manifest with `tier: super-saiyan` β†’ correct migration error. +- Manifest with `features: {voice: true}` β†’ deprecation warning, service resolved correctly. + +**G2** β€” Remove dead code +- Delete old `tierIDSuperSaiyan`/`tierIDSuperSaiyanBlue` constants (post rename). +- Delete hardcoded service entries from `registry.go`. +- Confirm no remaining references to removed constants. + +**G3** β€” `make quality` + `make test` green +- Fix any lint issues introduced. +- Confirm race detector passes. +- Confirm coverage targets met. + +--- + +## Risk Register + +| Risk | Likelihood | Impact | Mitigation | +|------|-----------|--------|-----------| +| catalog codenames don't match profiles.yaml service names | Medium | High | Research R-01/R-04 confirmed codename alignment; `buildServiceDefinition()` maps codenameβ†’arc_image | +| `GetMasterServiceTable` callers outside workspace package break | Low | Medium | Grep confirmed no prod callers outside package; test callers updated in Phase C | +| `ultra-instinct` existing workspaces regress | Low | High | Accepted behaviour: ultra-instinct tier is unchanged; test explicitly | +| huh form API change for multi-select | Low | Low | huh is pinned in go.mod; check release notes before Phase F3 | +| `resolveDependencies` phase 3 breaks with new catalog deps | Medium | Medium | Catalog dependencies use codenames; `serviceTable` is keyed by codename β€” consistent | + +--- + +## Open Questions (from spec) + +| # | Question | Resolution for Implementation | +|---|----------|-------------------------------| +| 1 | Hard error vs warn-and-continue for old-tier arc.yaml during `arc workspace run`? | **Hard error** β€” schema.go Validate() blocks run on migration-required tiers. | +| 2 | Is `GetMasterServiceTable()` called from outside `pkg/workspace/services/`? | **No** β€” grep confirmed. Stub is safe. | +| 3 | Should `features: {observability: true}` map to `observe`? | **Yes** β€” static map in `featuresToCapabilities()`: `"observability" β†’ "observe"`. | +| 4 | Do UI theme profiles need tier_names[0] descriptions updated? | **Deferred** β€” `getGenericTierName()` fallback handles display. Low priority. | + +--- + +*Plan generated by `/speckit.plan` β€” 2026-03-10* diff --git a/specs/020-pre-release/quickstart.md b/specs/020-pre-release/quickstart.md new file mode 100644 index 0000000..5e38c52 --- /dev/null +++ b/specs/020-pre-release/quickstart.md @@ -0,0 +1,249 @@ +# Quickstart: Implementing 020-pre-release + +**Audience**: Developer picking up this spec for the first time. +**Branch**: `020-pre-release` + +--- + +## Overview + +You are rewriting the workspace service resolution layer to use the existing (already-correct) catalog YAML instead of a stale hardcoded Go map. The work touches 8 files in 3 packages. + +``` +pkg/workspace/ + formatter.go ← rename 2 tier constants + manifest/ + manifest.go ← add Capabilities field + schema.go ← update tier allowlist, add capabilities validation + services/ + registry.go ← replace GetMasterServiceTable with catalog-backed impl + mapping.go ← add ResolveServices, update NewMapper + data/ + profiles.yaml ← NEW embedded file (create directory first) + +pkg/scaffold/templates/ + arc.yaml.tmpl ← update default tier + capabilities block + +pkg/ui/view/ + init_wizard.go ← update tier options, add capability multi-select + +pkg/cli/workspace/ + init.go ← add --tier and --capabilities flags +``` + +--- + +## Step 1 β€” Create `profiles.yaml` + +Create the directory and file first β€” everything else depends on it. + +```bash +mkdir -p pkg/workspace/services/data +``` + +Write `pkg/workspace/services/data/profiles.yaml` using the exact content from `spec.md Β§ Embedded profiles.yaml Content`. + +Key structure: +```yaml +core: + services: [gateway, messaging, streaming, cache, persistence, cortex, friday-collector] +capabilities: + reasoner: {services: [reasoner]} + voice: {services: [realtime, voice], requires: [reasoner]} + ... +think: {capabilities: []} +reason: {capabilities: [reasoner]} +ultra-instinct:{capabilities: "*"} +``` + +--- + +## Step 2 β€” Add profile types + loader to `mapping.go` + +Add at the top of `mapping.go`: + +```go +//go:embed data/profiles.yaml +var profilesData []byte +``` + +Add structs `ProfilesConfig`, `CoreConfig`, `CapabilityConfig`, `TierDef`. + +Add `loadProfiles(data []byte) (*ProfilesConfig, error)` β€” unmarshals the YAML, then parses the unknown top-level keys (think/reason/ultra-instinct) into `Tiers map[string]TierDef`. + +Add methods on `*ProfilesConfig`: +- `CoreServiceNames() []string` +- `TierCapabilities(tier string) []string` +- `ExpandCapabilities(caps []string) []string` β€” iterates `requires` chains with cycle protection. + +--- + +## Step 3 β€” Update `NewMapper()` and add `ResolveServices()` + +Change `Mapper` struct: +```go +type Mapper struct { + serviceTable map[string]*ServiceDefinition + profiles *ProfilesConfig +} +``` + +Change `NewMapper()` to: +1. Load profiles from `profilesData`. +2. Call `buildTableFromCatalog()` β€” iterate `pkg/catalog.NewEmbeddedCatalog().ListAll()`, convert each `*catalog.Service` to `*ServiceDefinition` via `buildServiceDefinition()`. +3. Return `&Mapper{serviceTable: table, profiles: profiles}`. + +`buildServiceDefinition(s *catalog.Service) *ServiceDefinition`: +```go +func buildServiceDefinition(s *catalog.Service) *ServiceDefinition { + name := s.ArcImage + if name == "" { name = "arc-" + s.Codename } + ports := make([]int, 0, len(s.Ports)) + for _, p := range s.Ports { ports = append(ports, p.Host) } + return &ServiceDefinition{ + ServiceName: name, + CodeName: s.Codename, + ImageName: s.Image, + Dependencies: s.Dependencies, + Ports: ports, + } +} +``` + +Add `ResolveServices(tier string, caps []string) ([]*ServiceDefinition, error)`: +``` +1. core := servicesForNames(profiles.CoreServiceNames()) +2. tierCaps := profiles.TierCapabilities(tier) +3. allCaps := profiles.ExpandCapabilities(dedupe(append(tierCaps, caps...))) +4. for each cap: append servicesForCapability(cap) +5. resolveDependencies(dedupe(services)) ← unchanged Phase 3 logic +``` + +Update `MapFeaturesToServices()` to be a shim: +```go +func (m *Mapper) MapFeaturesToServices(mf *manifest.Manifest) ([]*ServiceDefinition, error) { + caps := featuresToCapabilities(mf.Features) + caps = append(caps, mf.Capabilities...) + return m.ResolveServices(mf.Tier, caps) +} +``` + +--- + +## Step 4 β€” Fix `registry.go` + +Replace `GetMasterServiceTable()` body with a stub: +```go +// Deprecated: GetMasterServiceTable is replaced by catalog-backed loading. +func GetMasterServiceTable() map[string]*ServiceDefinition { + log.Warn("GetMasterServiceTable is deprecated. Use Mapper.ResolveServices().") + return map[string]*ServiceDefinition{} // empty stub +} +``` + +Update `GetServiceByName()` to use the catalog directly: +```go +func GetServiceByName(name string) (*ServiceDefinition, bool) { + m := NewMapper() + if sd, ok := m.serviceTable[name]; ok { + return sd, true + } + return nil, false +} +``` + +Delete the large hardcoded service entries (300+ lines). Keep function signatures. + +--- + +## Step 5 β€” Update `manifest.go` and `schema.go` + +**manifest.go**: Add `Capabilities []string` field, change `Features` to `omitempty`. + +**schema.go**: +- Update `KnownFeatures` (keep for backwards compat). +- Add `KnownTiers = []string{"think", "reason", "ultra-instinct"}`. +- Add `KnownCapabilities = []string{"reasoner", "voice", "observe", "security", "storage"}`. +- In `Validate()`: + 1. Check tier against `legacyTierErrors` first β†’ return hard error with migration message. + 2. Check tier against `KnownTiers` β†’ validation error if unknown. + 3. Validate each value in `Capabilities` against `KnownCapabilities`. + 4. If `Features` map is non-empty β†’ emit deprecation warning per feature to stderr. + +--- + +## Step 6 β€” Fix `formatter.go` tier constants + +Rename: +- `tierIDSuperSaiyan` β†’ `tierIDThink` = `"think"` +- `tierIDSuperSaiyanBlue` β†’ `tierIDReason` = `"reason"` +- Update `getTierIndex()` switch cases accordingly. + +--- + +## Step 7 β€” Update `arc.yaml.tmpl` + +Replace the template content: default tier `think`, comment block explaining tiers, `capabilities:` block (commented out), remove `features:` block entirely. + +--- + +## Step 8 β€” Update `init_wizard.go` + +In the huh form: +- Change tier `Select` options to: `think` (label "think β€” core only"), `reason` (label "reason β€” core + LLM engine"), `ultra-instinct` (label "ultra-instinct β€” everything"). +- Add `MultiSelect` for capabilities after tier selection, shown only when tier β‰  `ultra-instinct`. +- Options: `reasoner`, `voice`, `observe`, `security`, `storage`. +- Pre-check capabilities already included by the chosen tier. + +--- + +## Step 9 β€” Add `--tier` / `--capabilities` flags to `init.go` + +```go +cmd.Flags().StringVar(&opts.Tier, "tier", "think", "workspace tier (think|reason|ultra-instinct)") +cmd.Flags().StringSliceVar(&opts.Capabilities, "capabilities", nil, "additional capabilities (comma-separated)") +``` + +When flags are provided, bypass wizard step for those fields. + +--- + +## Testing Checklist + +- [ ] `TestResolveServices_Think` β€” only core services returned. +- [ ] `TestResolveServices_Reason` β€” core + reasoner. +- [ ] `TestResolveServices_UltraInstinct` β€” core + all capabilities. +- [ ] `TestResolveServices_WithCapabilities` β€” `voice` pulls in `reasoner` via requires chain. +- [ ] `TestResolveServices_Deduplication` β€” `reason` + `capabilities:[reasoner]` = no duplicates. +- [ ] `TestExpandCapabilities_RequiresChain` β€” voice β†’ [voice, reasoner]. +- [ ] `TestManifestValidate_LegacyTier` β€” `super-saiyan` errors with migration message. +- [ ] `TestManifestValidate_FeaturesDeprecation` β€” features key emits warning, does not error. +- [ ] `TestManifestValidate_InvalidCapability` β€” unknown capability fails validation. +- [ ] `TestGetTierIndex` β€” think=0, reason=1, ultra-instinct=2, unknown=-1. +- [ ] `TestMapFeaturesToServices_Shim` β€” delegates to ResolveServices correctly. +- [ ] Coverage β‰₯75% for mapping.go and schema.go. + +--- + +## Verification Commands + +```bash +# Build +make build + +# All tests with race detector +make test + +# Linting +make quality + +# Smoke test: workspace init with new tier +./bin/arc workspace init /tmp/test-workspace --tier reason --capabilities voice +cat /tmp/test-workspace/arc.yaml +# Expected: tier: "reason", capabilities: [voice] + +# Smoke test: old tier migration error +echo "version: 1.0.0\ntier: super-saiyan" > /tmp/bad.yaml +./bin/arc workspace validate /tmp/bad.yaml +# Expected: Error: tier "super-saiyan" is no longer valid... +``` diff --git a/specs/020-pre-release/research.md b/specs/020-pre-release/research.md new file mode 100644 index 0000000..90126a0 --- /dev/null +++ b/specs/020-pre-release/research.md @@ -0,0 +1,127 @@ +# Research: 020-pre-release β€” Workspace Subsystem Rewrite + +**Branch**: `020-pre-release` | **Date**: 2026-03-10 | **Phase**: 0 + +--- + +## R-01: Catalog Loading β€” Existing Mechanism + +**Decision**: Reuse `pkg/catalog.EmbeddedCatalog` directly in the new Mapper. + +**Findings**: +- `pkg/catalog/embedded_catalog.go` embeds `data/services.yaml` via `//go:embed data/services.yaml`. +- `EmbeddedCatalog` exposes `GetByCodename(name string) (*Service, error)` and `ListAll() []*Service`. +- `catalog.Service` struct has all needed fields: `Codename`, `ArcImage`, `Ports []PortMapping`, `Dependencies []string`, `Released bool`. +- `catalog.Service.Ports` is `[]PortMapping{Host, Container, Protocol, Description}` β€” only `Host` port is needed for `ServiceDefinition.Ports []int`. + +**Rationale**: Single source of truth already exists and is correct. No new embed needed for the catalog; the new `profiles.yaml` embed in `pkg/workspace/services/data/` is additive only. + +**Alternatives considered**: +- Duplicate catalog YAML into `pkg/workspace/services/data/` β€” rejected (divergence risk). +- Parse `services.yaml` independently in mapping.go β€” rejected (bypasses existing loader). + +--- + +## R-02: ServiceDefinition Struct β€” Compatibility vs Replacement + +**Decision**: Keep `ServiceDefinition` struct, update its population from catalog data. + +**Findings**: +- Current struct: `{ServiceName, CodeName, ImageName, RequiredConfigs, Dependencies, FeatureFlags, Ports []int}` +- `FeatureFlags` field is used only by old `MapFeaturesToServices` Phase 2 (feature β†’ service lookup). +- The new `ResolveServices` path does not use `FeatureFlags`; capability bundles are defined in `profiles.yaml`. +- Internal callers: `mapping.go`, `mapping_test.go`, `registry_test.go`, `template/functions_test.go`, `template/engine_test.go`. + +**Rationale**: Keeping `ServiceDefinition` avoids a cascade of test/template changes. The struct is populated from catalog data in `buildServiceDefinition(s *catalog.Service) *ServiceDefinition`. + +**Alternatives considered**: +- Replace `ServiceDefinition` with `*catalog.Service` everywhere β€” too invasive for this spec, deferrable. + +--- + +## R-03: GetMasterServiceTable Callers β€” Stub Safety + +**Decision**: Keep `GetMasterServiceTable()` as a deprecated stub returning an empty map with a logged warning. Keep `GetServiceByName()` as a stub that delegates to the catalog. + +**Findings**: +- External callers (grepped): `mapping.go` (primary), `registry.go` (self-reference in `GetServiceByName`), plus **test files only** (`registry_test.go`, `mapping_test.go`, `functions_test.go`, `engine_test.go`). +- No callers outside `pkg/workspace/` and `pkg/workspace/template/`. +- Test callers can be updated to use the new `NewMapper()` + `ResolveServices()` path. + +**Rationale**: Zero production callers outside the package means stubs can be minimal. Test files will be updated as part of this spec's tasks. + +--- + +## R-04: profiles.yaml Format β€” Alignment with Platform + +**Decision**: Use the exact format defined in the spec, mirroring `arc-platform/services/profiles.yaml`. + +**Findings**: +- Platform `profiles.yaml` uses top-level sections: `core`, `capabilities`, then one key per tier. +- Capability entries have `services: []` (list of catalog codenames) and optional `requires: []`. +- `ultra-instinct` uses `capabilities: "*"` sentinel meaning "all capabilities". +- Services in `profiles.yaml` use **codenames** (e.g., `gateway`, `reasoner`) not `arc_image` names β€” mapping to `arc_image` happens in `buildServiceDefinition()`. + +**Rationale**: Keeping format identical to platform means the file can be copy-synced with zero transformation. + +--- + +## R-05: Init Wizard β€” Existing `init_wizard.go` + +**Decision**: Extend existing `pkg/ui/view/init_wizard.go` with two new huh form fields: tier select and capability multi-select. + +**Findings**: +- File exists at `pkg/ui/view/init_wizard.go` (6229 bytes). +- Uses `charmbracelet/huh` for the form (already a dependency per CLAUDE.md). +- Current tier options: `free`, `super-saiyan`, `super-saiyan-blue`, `ultra-instinct`. +- Initialization is async (Bubble Tea message `initDoneMsg`); the wizard collects config then calls `workspace.NewInitializer()`. +- New options: `think`, `reason`, `ultra-instinct` + conditional capability multi-select (hidden when tier is `ultra-instinct`). + +**Rationale**: Extending the existing wizard is the minimal-change path. Adding a `huh.MultiSelect` for capabilities slots cleanly into the existing form phase. + +--- + +## R-06: Tier Constants Location + +**Decision**: Tier constants live in **two places** β€” fix both: +1. `pkg/workspace/formatter.go` β€” display/index constants (`tierIDSuperSaiyan` etc.) β†’ rename. +2. `pkg/workspace/manifest/schema.go` β€” `validTiers` allowlist string slice β†’ update. +3. `pkg/ui/view/init_wizard.go` β€” tier select option strings β†’ update. + +**Findings**: Grep for `super-saiyan` finds exactly these three locations plus test files. + +--- + +## R-07: `features` β†’ `capabilities` Migration β€” Mapping Table + +**Decision**: Define a static `featureToCapability` map in `mapping.go` for the shim. + +| Old feature key | New capability | +|----------------|---------------| +| `voice` | `voice` | +| `security` | `security` | +| `observability` | `observe` | +| `chaos` | *(dropped β€” out of scope for capabilities model)* | + +**Rationale**: `chaos` is a test-injection feature, not a service capability; silently ignored when migrating. Open question #3 from spec is resolved: `observability` maps to `observe`. + +--- + +## R-08: Port Conflict Detection β€” Validator Impact + +**Decision**: `pkg/workspace/validator.go` port conflict detection works off `ServiceDefinition.Ports []int`. Since we're building `ServiceDefinition` from catalog data (which has `Ports []PortMapping`), populate `Ports` as `[]int{pm.Host for each PortMapping}`. No validator changes needed. + +--- + +## Summary β€” All Unknowns Resolved + +| # | Unknown | Resolution | +|---|---------|-----------| +| 1 | How to load catalog in mapper? | Reuse `pkg/catalog.EmbeddedCatalog` | +| 2 | ServiceDefinition compat? | Keep struct, populate from catalog | +| 3 | Safe to stub GetMasterServiceTable? | Yes β€” no prod callers outside package | +| 4 | profiles.yaml format? | Exact spec format, codenames | +| 5 | Init wizard extension? | Extend `pkg/ui/view/init_wizard.go` with huh form fields | +| 6 | Tier constants locations? | formatter.go, schema.go, init_wizard.go | +| 7 | featuresβ†’capabilities map? | Static map; chaos is dropped | +| 8 | Validator port impact? | Extract Host port; no validator changes | diff --git a/specs/020-pre-release/spec.md b/specs/020-pre-release/spec.md new file mode 100644 index 0000000..0ed7410 --- /dev/null +++ b/specs/020-pre-release/spec.md @@ -0,0 +1,422 @@ +# Feature: Pre-Release β€” Workspace Subsystem Rewrite + +> **Spec**: 020-pre-release +> **Author**: arc-framework +> **Date**: 2026-03-10 +> **Status**: Draft +> **Branch**: 020-pre-release +> **ARD (Capability Model)**: `arc-platform/docs/ard/CAPABILITY-SYSTEM.md` +> **ARD (CLI Integration)**: `arc-platform/docs/ard/CLI-PLATFORM-INTEGRATION.md` +> **ARD (Service Commands)**: `arc-platform/docs/ard/SERVICE-COMMANDS.md` +> **Platform source of truth**: `arc-platform/SERVICE.MD` + +--- + +## Executive Summary + +The CLI's workspace subsystem has three systemic problems that must be resolved before release: + +1. **Tier naming mismatch** β€” CLI hardcodes `super-saiyan` / `super-saiyan-blue` / `ultra-instinct`. The platform uses `think` / `reason` / `ultra-instinct`. Only `ultra-instinct` works in both today. +2. **Stale hardcoded service registry** β€” `registry.go`'s `GetMasterServiceTable()` is a hardcoded Go map with wrong service names (`arc-brain`, `arc-pulse`, `arc-stream`), wrong ports (`arc-brain:8000`, `arc-gateway:8080`), wrong images (Infisical, Grafana/Prometheus), wrong deps, and a wrong base-infrastructure model that includes everything. This is the root cause of all service resolution bugs. +3. **Features map vs capabilities** β€” `arc.yaml` uses `features: {voice: true}`. The platform model uses `capabilities: [voice]`. The `tier` field is stored but never read during service resolution β€” `MapFeaturesToServices()` ignores it entirely. + +This spec covers all workspace-config changes in `arc-framework/arc-cli`. Additional pre-release tasks will be added separately. + +--- + +## Background: Core + Capability Model + +Full design: `arc-platform/docs/ard/CAPABILITY-SYSTEM.md`. + +### Layer 1 β€” Core (always on) +``` +gateway, messaging, streaming, cache, persistence, cortex, friday-collector +``` + +### Layer 2 β€” Capabilities (opt-in) + +| Capability | Services | Requires | +|------------|----------|----------| +| `reasoner` | `arc-reasoner` | β€” | +| `voice` | `arc-realtime`, `arc-voice-agent` | `reasoner` | +| `observe` | `arc-friday` (SigNoz) | β€” | +| `security` | `arc-vault`, `arc-flags` | β€” | +| `storage` | `arc-storage` | β€” | + +### Tiers β€” Named Capability Presets + +| Tier | Capabilities | Core services same? | +|------|-------------|---------------------| +| `think` | *(none)* | yes | +| `reason` | `reasoner` | yes | +| `ultra-instinct` | all | yes | + +--- + +## Current State β€” What Is Wrong Today + +### `pkg/workspace/services/registry.go` β€” Root of All Bugs + +`GetMasterServiceTable()` is a 300-line hardcoded Go map that is the exclusive data source for service resolution. Every service entry here is stale: + +| Registry entry | Problem | +|---------------|---------| +| `arc-brain` at port 8000 | Should be `arc-reasoner` at port 8802 | +| `arc-pulse` | Should be `arc-messaging` (`arc_image: arc-messaging`) | +| `arc-stream` at port 8081 | Should be `arc-streaming` at port 8082 | +| `arc-db-sql` (postgres:16) | Should be `arc-persistence` (postgres:17 + pgvector) | +| `arc-db-cache` | Should be `arc-cache` | +| `arc-db-vector` (Qdrant) | Removed β€” vector search is inside `arc-persistence` via pgvector | +| `arc-vault` (Infisical image) | Should use OpenBao image | +| `arc-voice-server` at port 7880 | Should be `arc-realtime` | +| `arc-voice-agent` at port 8001 | Should be port 8803 | +| `arc-otel` / `arc-metrics` / `arc-logs` / `arc-traces` / `arc-viz` | Grafana/Prometheus stack β€” replaced by SigNoz (`arc-friday` + `arc-friday-collector`) | +| `arc-gateway` port 8080 | Dashboard is port 8090 | +| `GetBaseInfrastructure()` | Returns 13 services as "always required" including `arc-storage`, `arc-flags`, `arc-brain`, `arc-billing`, `arc-janitor` β€” wrong; only 7 services are truly core | + +### `pkg/workspace/formatter.go` +```go +// Still hardcoded β€” think/reason return -1 (Unknown Tier) +const ( + tierIDSuperSaiyan = "super-saiyan" + tierIDSuperSaiyanBlue = "super-saiyan-blue" + tierIDUltraInstinct = "ultra-instinct" +) +``` + +### `pkg/workspace/manifest/manifest.go` +```go +// Missing Capabilities field. Features map is the wrong model. +type Manifest struct { + Version string `yaml:"version"` + Tier string `yaml:"tier,omitempty"` + Features map[string]bool `yaml:"features"` // wrong shape + Services map[string]interface{} `yaml:"services,omitempty"` + Environment map[string]string `yaml:"environment,omitempty"` +} +``` + +### `pkg/workspace/services/mapping.go` +```go +// MapFeaturesToServices reads Features map + GetMasterServiceTable() only. +// Ignores Tier entirely. No concept of core vs capability. +func (m *Mapper) MapFeaturesToServices(mf *manifest.Manifest) ([]*ServiceDefinition, error) +``` + +### `pkg/scaffold/templates/arc.yaml.tmpl` +- Default tier is `ultra-instinct` (should be `think` β€” minimal stack) +- Uses `features:` block (wrong shape for new model) + +### `pkg/catalog/data/services.yaml` +- **Already corrected** (2026-03-10) β€” codenames, images, ports, deps match the platform + +--- + +## Target Modules + +| Module | Path | Impact | +|--------|------|--------| +| Tier constants | `pkg/workspace/formatter.go` | Rename 3 constants + `getTierIndex()` | +| Manifest struct | `pkg/workspace/manifest/manifest.go` | Add `Capabilities []string`, keep `Features` deprecated | +| Manifest schema | `pkg/workspace/manifest/schema.go` | Update tier validation allowlist, add capabilities validation | +| Manifest validator | `pkg/workspace/manifest/schema.go` | Deprecation warning when `features:` key is present | +| Service registry | `pkg/workspace/services/registry.go` | Replace `GetMasterServiceTable()` + `GetBaseInfrastructure()` with catalog-backed lookup | +| Service definition | `pkg/workspace/services/registry.go` | Update `ServiceDefinition` struct to match catalog shape | +| Mapper init | `pkg/workspace/services/mapping.go` | `NewMapper()` loads from catalog YAML instead of hardcoded map | +| Service mapping | `pkg/workspace/services/mapping.go` | Add `ResolveServices(tier, caps)`, keep `MapFeaturesToServices` as shim | +| Profiles data | `pkg/workspace/services/data/profiles.yaml` | New embedded file β€” capability-aware profiles | +| arc.yaml template | `pkg/scaffold/templates/arc.yaml.tmpl` | Default `think`, add `capabilities:` block, remove `features:` | +| Init wizard logic | `pkg/cli/workspace/init.go` | Tier prompt + capability multi-select | +| Init wizard UI | `pkg/ui/view/init_wizard.go` | Capability checkbox step | +| Workspace validator | `pkg/workspace/validator.go` | Port conflict detection uses updated `ServiceDefinition.Ports` | +| Old tier migration | `pkg/workspace/manifest/schema.go` | Detect `super-saiyan`/`super-saiyan-blue`, emit migration error | + +--- + +## User Stories + +### Story 1 β€” Replace stale service registry + +As a workspace developer, I want service resolution to read from the same catalog YAML as `arc services list` so that service names, ports, and dependencies are always consistent. + +**Acceptance Criteria** + +- `registry.go`'s `GetMasterServiceTable()` is replaced β€” it no longer returns a hardcoded Go map. +- Service data is loaded from `pkg/catalog/data/services.yaml` (the already-corrected catalog). +- `ServiceDefinition` struct is updated to match catalog fields: `ServiceName`, `ArcImage`, `Ports []int`, `Dependencies []string`. +- `GetBaseInfrastructure()` is replaced by `getCoreServices(profiles)` β€” reads `core.services` from embedded `profiles.yaml`. +- No service name from the old registry (`arc-brain`, `arc-pulse`, `arc-stream`, `arc-db-sql`, `arc-db-cache`, `arc-db-vector`, `arc-voice-server`) appears in the active resolution path. +- `GetMasterServiceTable()` and `GetServiceByName()` are kept as deprecated stubs that log a warning but still return data (backwards-compat for any direct callers). + +### Story 2 β€” Tier rename + +As a platform operator, I want `arc workspace init` to offer `think`, `reason`, and `ultra-instinct` as tier options so that I use the same vocabulary as `make dev PROFILE=`. + +**Acceptance Criteria** + +- `arc workspace init` tier prompt shows: `think` (default), `reason`, `ultra-instinct`. +- `arc workspace info` resolves and displays correct tier name for all three IDs. +- Existing `arc.yaml` with `tier: ultra-instinct` continues to work unchanged. +- `arc.yaml` with `tier: super-saiyan` or `tier: super-saiyan-blue` produces a clear migration error: + ``` + Error: tier "super-saiyan" is no longer valid. + Migrate your arc.yaml: replace "super-saiyan" with "think". + ``` +- `formatter.go` constants `tierIDSuperSaiyan` and `tierIDSuperSaiyanBlue` are removed. +- `manifest/schema.go` tier allowlist is updated to `["think", "reason", "ultra-instinct"]`. + +### Story 3 β€” Capabilities field in arc.yaml + +As a developer, I want to add `capabilities: [voice, observe]` to `arc.yaml` so that I can opt into specific service groups on top of the base tier. + +**Acceptance Criteria** + +- `arc.yaml` accepts `capabilities: []` alongside `tier`. +- Manifest parser populates `Manifest.Capabilities []string`. +- Valid capability values: `reasoner`, `voice`, `observe`, `security`, `storage`. +- Invalid capability name produces a validation error naming the bad value and listing valid options. +- Old `features:` key is accepted (parsed into `Manifest.Features`) but emits a deprecation warning per enabled feature to stderr: + ``` + Warning: features.voice=true β†’ use: capabilities: [voice] + Warning: features.security=true β†’ use: capabilities: [security] + ``` +- Manifest with both `features:` and `capabilities:` β€” `capabilities:` wins; `features:` values are **ignored** (the shim must not merge them). + +### Story 4 β€” Service resolution reads tier + capabilities + +As a platform operator, I want `arc workspace run` to resolve services from my `tier` + `capabilities` so that the running stack matches exactly what I declared. + +**Acceptance Criteria** + +- `ResolveServices(tier string, caps []string)` is the active resolution path called by `arc workspace run`. +- Core services (gateway, messaging, streaming, cache, persistence, cortex, friday-collector) are always included. +- `think` tier: core only. +- `reason` tier: core + `arc-reasoner`. +- `ultra-instinct` tier: core + all capabilities. +- `capabilities: [voice]`: adds `arc-realtime` + `arc-voice-agent` + `arc-reasoner` (pulled in via `voice requires reasoner`). +- `capabilities: [security]`: adds `arc-vault` + `arc-flags`. +- Duplicate services (e.g. `reason` tier + `capabilities: [reasoner]`) are deduplicated. +- Existing transitive dependency resolution (Phase 3 fixed-point in `resolveDependencies`) is preserved. +- `MapFeaturesToServices()` is kept as a shim that calls `ResolveServices()` for backwards compat, until all callers are migrated. + +### Story 5 β€” Embedded profiles.yaml + +As a CLI developer, I want a single embedded `profiles.yaml` in `pkg/workspace/services/data/` that defines core services and capability bundles so that the mapping layer has a stable, human-readable data source instead of scattered Go code. + +**Acceptance Criteria** + +- `pkg/workspace/services/data/profiles.yaml` exists and is embedded via `//go:embed`. +- File content matches `arc-platform/services/profiles.yaml` capability-aware format. +- `loadProfiles(data []byte)` parses the file into typed Go structs (`ProfilesConfig`, `CoreConfig`, `CapabilityConfig`). +- `TierCapabilities(tier string)` returns the capability list for a given tier. +- `ExpandCapabilities(caps []string)` resolves `requires` chains (e.g. `voice` β†’ also pulls `reasoner`). +- Profile data is loaded once at `NewMapper()` and cached. + +### Story 6 β€” Updated arc.yaml template + +As a developer running `arc workspace init`, I want the generated `arc.yaml` to use `tier` + `capabilities` format with `think` as the default. + +**Acceptance Criteria** + +- Generated `arc.yaml` uses `tier: "think"` (not `ultra-instinct`). +- Template includes a `capabilities:` block (commented out by default, rendered with selected caps if chosen during init). +- `features:` block is absent from the template. +- Template comment block explains what each tier activates: + ```yaml + # think β€” core infrastructure only + # reason β€” core + reasoner (LLM engine) + # ultra-instinct β€” core + all capabilities + ``` +- Template renders correctly for all three tier choices with and without capabilities. + +### Story 7 β€” Init wizard: tier + capability selection + +As a developer running `arc workspace init`, I want an interactive prompt that lets me choose a tier and then add individual capabilities so that I start with exactly the services I need. + +**Acceptance Criteria** + +- Step 1 β€” Tier prompt: `think` (default) / `reason` / `ultra-instinct`. +- Step 2 β€” Capability multi-select (shown for `think` and `reason`; not shown for `ultra-instinct` since it includes all). + - Checkboxes: `reasoner`, `voice`, `observe`, `security`, `storage`. + - Pre-checked: capabilities already included by the chosen tier (e.g. `reason` pre-checks `reasoner`). + - Pre-checked items are shown but not re-added (deduplication handled by `ResolveServices`). +- Selected capabilities are written to `arc.yaml` under `capabilities:`. +- If no extra capabilities are selected, `capabilities:` key is omitted from the generated file. +- `arc workspace init --tier reason --capabilities voice,observe` works non-interactively (flags bypass the wizard). + +### Story 8 β€” Deprecation and migration + +As an operator with an existing workspace, I want clear error messages when my `arc.yaml` uses old field names so that I know exactly what to change. + +**Acceptance Criteria** + +- `tier: super-saiyan` β†’ hard error with migration instruction (`replace with "think"`). +- `tier: super-saiyan-blue` β†’ hard error with migration instruction (`replace with "reason"`). +- `features: {voice: true}` β†’ deprecation warning (soft β€” does not block run). +- Deprecation warning includes the equivalent `capabilities:` syntax: + ``` + Warning: features.voice=true β†’ use: capabilities: [voice] + Warning: features.security=true β†’ use: capabilities: [security] + ``` +- Deprecation warnings are emitted to stderr during `arc workspace run` before services start. (`arc workspace validate` as a standalone subcommand and `--dry-run` are out of scope for this spec β€” see Out of Scope section.) + +--- + +## Embedded profiles.yaml Content + +```yaml +# pkg/workspace/services/data/profiles.yaml +# Mirrors arc-platform/services/profiles.yaml β€” capability-aware format. +# Update this file whenever the platform's profiles.yaml changes. + +core: + services: + - gateway + - messaging + - streaming + - cache + - persistence + - cortex + - friday-collector + +capabilities: + reasoner: + services: [reasoner] + voice: + services: [realtime, voice] + requires: [reasoner] + observe: + services: [otel] + security: + services: [vault, flags] + storage: + services: [storage] + +think: + capabilities: [] +reason: + capabilities: [reasoner] +ultra-instinct: + capabilities: "*" +``` + +--- + +## Key Code Changes + +### `pkg/workspace/formatter.go` + +```go +const ( + tierIDThink = "think" + tierIDReason = "reason" + tierIDUltraInstinct = "ultra-instinct" +) + +func getTierIndex(tierID string) int { + switch tierID { + case tierIDThink: return 0 + case tierIDReason: return 1 + case tierIDUltraInstinct: return 2 + default: return -1 + } +} +``` + +### `pkg/workspace/manifest/manifest.go` + +```go +type Manifest struct { + Version string `yaml:"version"` + Tier string `yaml:"tier,omitempty"` + Capabilities []string `yaml:"capabilities,omitempty"` // new + Features map[string]bool `yaml:"features,omitempty"` // deprecated + Services map[string]interface{} `yaml:"services,omitempty"` + Environment map[string]string `yaml:"environment,omitempty"` +} + +var validTiers = []string{"think", "reason", "ultra-instinct"} +var validCapabilities = []string{"reasoner", "voice", "observe", "security", "storage"} +``` + +### `pkg/workspace/services/mapping.go` + +```go +//go:embed data/profiles.yaml +var profilesData []byte + +func NewMapper() *Mapper { + profiles := mustLoadProfiles(profilesData) + catalog := mustLoadCatalog() // reads pkg/catalog/data/services.yaml + return &Mapper{profiles: profiles, catalog: catalog} +} + +func (m *Mapper) ResolveServices(tier string, caps []string) ([]*ServiceDefinition, error) { + // Step 1: core always included + services := m.servicesForNames(m.profiles.Core.Services) + + // Step 2: tier preset capabilities + tierCaps := m.profiles.TierCapabilities(tier) + + // Step 3: merge + resolve requires chain + allCaps := m.expandRequires(dedupe(append(tierCaps, caps...))) + for _, cap := range allCaps { + services = append(services, m.servicesForCapability(cap)...) + } + + // Step 4: transitive dep resolution (unchanged from today) + return m.resolveDependencies(dedupe(services)) +} + +// MapFeaturesToServices is a backwards-compat shim. +// Deprecated: use ResolveServices. +func (m *Mapper) MapFeaturesToServices(mf *manifest.Manifest) ([]*ServiceDefinition, error) { + caps := featuresToCapabilities(mf.Features) + caps = append(caps, mf.Capabilities...) + return m.ResolveServices(mf.Tier, caps) +} +``` + +### `pkg/workspace/services/registry.go` + +`GetMasterServiceTable()` is replaced with catalog-backed loading. The old function and `GetBaseInfrastructure()` are kept as deprecated stubs: + +```go +// Deprecated: GetMasterServiceTable is replaced by catalog-backed service loading. +// Use Mapper.ResolveServices() instead. +func GetMasterServiceTable() map[string]*ServiceDefinition { + log.Warn("GetMasterServiceTable is deprecated and will be removed. Use Mapper.ResolveServices().") + return legacyServiceTable() // returns minimal stub, not for production use +} +``` + +--- + +## Constraints + +- `ultra-instinct` behaviour is unchanged β€” existing workspaces must not break. +- `ResolveServices()` must produce an ordered, deterministic service list (tests depend on order). +- `MapFeaturesToServices()` must remain callable β€” do not delete its signature. +- No changes to the platform repo (`arc-platform`) as part of this spec. +- `pkg/catalog/data/services.yaml` is already correct β€” do not modify it. + +--- + +## Out of Scope for This Spec + +These items are tracked separately within the 020-pre-release umbrella: + +- `arc service` command group (`up`, `down`, `health`, `logs`, `test`, `list`) +- `commands:` block in catalog entries (required for `arc service test`) +- `arc workspace validate` as a standalone subcommand (warning output today is sufficient) + +--- + +## Open Questions + +| # | Question | Status | +|---|----------|--------| +| 1 | Should old-tier `arc.yaml` hard-error or warn-and-continue during `arc workspace run`? | **Resolved**: Hard error β€” `schema.go Validate()` blocks run on legacy tier names (see plan.md) | +| 2 | Is `GetMasterServiceTable()` called from outside `pkg/workspace/services/`? | **Resolved**: No β€” grep confirmed no prod callers outside the package; stubs are safe (see research.md R-03) | +| 3 | Should `features: {observability: true}` map to `observe` capability or be silently dropped? | **Resolved**: Maps to `observe` via static `featuresToCapabilities()` table (see research.md R-07) | +| 4 | Do UI theme profiles need `tier_names[0]` descriptions updated? | Low priority β€” `getGenericTierName()` fallback is sufficient | diff --git a/specs/020-pre-release/tasks.md b/specs/020-pre-release/tasks.md new file mode 100644 index 0000000..6bedda9 --- /dev/null +++ b/specs/020-pre-release/tasks.md @@ -0,0 +1,383 @@ +# Tasks: Workspace Subsystem Rewrite (020-pre-release) + +**Input**: Design documents from `/specs/020-pre-release/` +**Prerequisites**: plan.md βœ…, spec.md βœ…, research.md βœ…, data-model.md βœ…, contracts/ βœ…, quickstart.md βœ… + +**Organization**: Tasks grouped by user story for independent implementation and testing. + +--- + +## Test Coverage Requirements + +| Package | Target | Rationale | +|---------|--------|-----------| +| `pkg/workspace/services/mapping.go` | β‰₯75% | Core resolution logic | +| `pkg/workspace/manifest/schema.go` | β‰₯75% | Validation + migration errors | +| `pkg/workspace/formatter.go` | β‰₯40% | Display logic | +| `pkg/ui/view/init_wizard.go` | β‰₯40% | TUI presentation | + +Tests are **required** for all core business logic (ResolveServices, schema validation, profile loading, tier/capability expansion). TUI rendering and cobra command wrappers may be deferred to E2E. + +--- + +## Phase 1: Setup + +**Purpose**: Establish baseline and project structure before any feature work. + +- [ ] T001 Run `make lint` on packages under modification to capture pre-existing issues baseline: `pkg/workspace/...`, `pkg/ui/view/...`, `pkg/cli/workspace/...` +- [ ] T002 [P] Review `.golangci.yml` for linter rules that apply to new code (cyclop, errcheck, revive, unused, gofumpt) +- [ ] T003 Create directory `pkg/workspace/services/data/` (needed for profiles.yaml embed) + +--- + +## Phase 2: Foundational β€” Profiles Data Layer (US5 prerequisite) + +**Purpose**: Create the `profiles.yaml` embedded file and its Go loader. This is the data foundation that ALL user stories depend on β€” no story work begins until this phase is complete. + +**⚠️ CRITICAL**: US1, US4, and all downstream stories depend on `NewMapper()` being updated here. + +### Data File + +- [ ] T004 Create `pkg/workspace/services/data/profiles.yaml` with exact content from `spec.md Β§ Embedded profiles.yaml Content` (core services: gateway, messaging, streaming, cache, persistence, cortex, friday-collector; capabilities: reasoner, voice, observe, security, storage; tiers: think/reason/ultra-instinct) + +### Profile Types + +- [ ] T005 [P] Add `ProfilesConfig`, `CoreConfig`, `CapabilityConfig` structs to `pkg/workspace/services/mapping.go` (see `data-model.md Β§ ProfilesConfig`) +- [ ] T006 [P] Add `TierDef` struct to `pkg/workspace/services/mapping.go`; handle both `[]string` and `"*"` sentinel for `ultra-instinct` + +### Loader + +- [ ] T007 Add `//go:embed data/profiles.yaml` directive and `var profilesData []byte` to `pkg/workspace/services/mapping.go` +- [ ] T008 Implement `loadProfiles(data []byte) (*ProfilesConfig, error)` in `pkg/workspace/services/mapping.go` β€” unmarshal YAML, parse tier keys (think/reason/ultra-instinct) from remaining top-level keys into `ProfilesConfig.Tiers` +- [ ] T009 Implement `mustLoadProfiles(data []byte) *ProfilesConfig` in `pkg/workspace/services/mapping.go` β€” panics on bad embed (same pattern as `pkg/catalog/embedded_catalog.go`) + +### Profile Accessors + +- [ ] T010 [P] Implement `(p *ProfilesConfig) CoreServiceNames() []string` in `pkg/workspace/services/mapping.go` +- [ ] T011 [P] Implement `(p *ProfilesConfig) TierCapabilities(tier string) []string` in `pkg/workspace/services/mapping.go` β€” returns `[]string{}` for think, `["reasoner"]` for reason, all cap names for ultra-instinct (`"*"` sentinel) +- [ ] T012 [P] Implement `(p *ProfilesConfig) ExpandCapabilities(caps []string) []string` in `pkg/workspace/services/mapping.go` β€” resolves `requires` chains with cycle protection (max 10 iterations) + +### Catalog-to-ServiceDefinition Bridge + +- [ ] T013 Implement `buildServiceDefinition(s *catalog.Service) *ServiceDefinition` in `pkg/workspace/services/mapping.go` β€” maps `ArcImage` (or `"arc-"+Codename` fallback) to `ServiceName`; extracts `PortMapping.Host` into `[]int` +- [ ] T014 Implement `buildTableFromCatalog() map[string]*ServiceDefinition` in `pkg/workspace/services/mapping.go` β€” iterates `catalog.NewEmbeddedCatalog().ListAll()`, calls `buildServiceDefinition`, keys by `Codename` + +### Mapper Update + +- [ ] T015 Add `profiles *ProfilesConfig` field to `Mapper` struct in `pkg/workspace/services/mapping.go` +- [ ] T016 Update `NewMapper()` in `pkg/workspace/services/mapping.go` to call `mustLoadProfiles(profilesData)` and `buildTableFromCatalog()` instead of `GetMasterServiceTable()` + +### Tests + +- [ ] T017 [P] Write `TestLoadProfiles` in `pkg/workspace/services/mapping_test.go` β€” table-driven: valid yaml parses correctly, invalid yaml returns error, `t.Parallel()` +- [ ] T018 [P] Write `TestTierCapabilities` in `pkg/workspace/services/mapping_test.go` β€” thinkβ†’empty, reasonβ†’[reasoner], ultra-instinctβ†’all caps, unknownβ†’empty; `t.Parallel()` +- [ ] T019 [P] Write `TestExpandCapabilities` in `pkg/workspace/services/mapping_test.go` β€” no-op (no requires), single-hop voiceβ†’[voice,reasoner], multi-hop (if any), deduplication; `t.Parallel()` +- [ ] T020 [P] Write `TestBuildTableFromCatalog` in `pkg/workspace/services/mapping_test.go` β€” verify gateway/messaging/streaming/cache/persistence/cortex are present, keyed by codename; `t.Parallel()` + +**Checkpoint**: `make test ./pkg/workspace/services/...` passes. Foundation ready. User stories can now proceed. + +--- + +## Phase 3: User Story 5 β€” Embedded profiles.yaml (P1) 🎯 MVP Foundation + +**Goal**: `pkg/workspace/services/data/profiles.yaml` exists, is embedded, and all accessor methods work correctly. + +**Independent Test**: `TestLoadProfiles`, `TestTierCapabilities`, `TestExpandCapabilities` all pass. `NewMapper()` no longer panics and returns a non-nil Mapper. + +*(Implementation complete in Phase 2. This phase confirms the story is independently verifiable.)* + +- [ ] T021 [US5] Verify `make build` succeeds with the new `//go:embed` directive in `pkg/workspace/services/mapping.go` +- [ ] T022 [US5] Confirm `pkg/workspace/services/data/profiles.yaml` content matches `arc-platform/services/profiles.yaml` format (manual cross-check) + +**Checkpoint**: US5 complete. Service registry rewrite (US1) and service resolution (US4) can now proceed. + +--- + +## Phase 4: User Story 1 β€” Replace Stale Service Registry (P1) 🎯 MVP + +**Goal**: `GetMasterServiceTable()` no longer returns a hardcoded Go map. Service data is loaded from the catalog. Old service names (arc-brain, arc-pulse, arc-stream, etc.) are gone from the active resolution path. + +**Independent Test**: `arc workspace run` (or service resolution) resolves `arc-gateway`, `arc-messaging`, `arc-streaming` β€” not `arc-brain`/`arc-pulse`/`arc-stream`. Deprecated stubs log a warning but don't crash. + +- [ ] T023 [US1] Delete all hardcoded service entries (the ~300-line `GetMasterServiceTable` body) from `pkg/workspace/services/registry.go`, replacing with deprecated stub that logs a warning and returns `map[string]*ServiceDefinition{}` +- [ ] T024 [US1] Replace `GetBaseInfrastructure()` body in `pkg/workspace/services/registry.go` with a deprecated stub that logs a warning and returns `nil` β€” do NOT call `NewMapper()` inside deprecated stubs (avoids double catalog load); callers should use `NewMapper().servicesForNames(profiles.CoreServiceNames())` directly +- [ ] T025 [US1] Update `GetServiceByName(name string)` in `pkg/workspace/services/registry.go` to delegate to `NewMapper().serviceTable[name]` +- [ ] T026 [US1] Add `// Deprecated` godoc comments to `GetMasterServiceTable()` and `GetBaseInfrastructure()` in `pkg/workspace/services/registry.go` +- [ ] T027 [US1] Write `TestGetMasterServiceTable_IsDeprecatedStub` in `pkg/workspace/services/registry_test.go` β€” verify returns empty map (not the old hardcoded map); `t.Parallel()` +- [ ] T028 [US1] Write `TestGetServiceByName_CatalogBacked` in `pkg/workspace/services/registry_test.go` β€” look up "gateway" β†’ ServiceName is "arc-gateway"; look up "arc-brain" β†’ not found; `t.Parallel()` +- [ ] T029 [US1] Run existing `mapping_test.go` and `registry_test.go` β€” fix any assertions that depended on old hardcoded service names (update to catalog codenames) +- [ ] T082 [US1] Add `ArcImage string` field to `ServiceDefinition` struct in `pkg/workspace/services/registry.go`; populate it from `catalog.Service.ArcImage` in `buildServiceDefinition()` β€” required by US1 AC#3: *"ServiceDefinition struct is updated to match catalog fields: ServiceName, ArcImage, Ports []int, Dependencies []string"* + +**Checkpoint**: `make test ./pkg/workspace/services/...` passes. Old service names are gone. Catalog-backed lookup works. `ServiceDefinition.ArcImage` is populated for all catalog services. + +--- + +## Phase 5: User Story 4 β€” Service Resolution Reads Tier + Capabilities (P2) + +**Goal**: `ResolveServices(tier, caps)` is the active resolution path. Core services always included. Tier presets and capability bundles combine correctly. `MapFeaturesToServices` is a shim. + +**Independent Test**: +```go +m := NewMapper() +svcs, _ := m.ResolveServices("think", nil) // β†’ 7 core services only +svcs, _ = m.ResolveServices("reason", nil) // β†’ core + reasoner +svcs, _ = m.ResolveServices("think", []string{"voice"}) // β†’ core + voice + realtime + reasoner +``` + +- [ ] T030 [US4] Implement `(m *Mapper) servicesForNames(names []string) []*ServiceDefinition` helper in `pkg/workspace/services/mapping.go` β€” looks up each codename in `m.serviceTable`, skips unknown names (with debug log) +- [ ] T031 [US4] Implement `(m *Mapper) servicesForCapability(cap string) []*ServiceDefinition` helper in `pkg/workspace/services/mapping.go` β€” looks up capability in `m.profiles.Capabilities`, returns services for that capability +- [ ] T032 [US4] Implement `(m *Mapper) ResolveServices(tier string, caps []string) ([]*ServiceDefinition, error)` in `pkg/workspace/services/mapping.go` per spec Story 4: (1) core always included; (2) tierCaps = TierCapabilities(tier); (3) allCaps = ExpandCapabilities(dedupe(tierCaps+caps)); (4) append servicesForCapability for each cap; (5) resolveDependencies(dedupe(services)) +- [ ] T033 [US4] Add `dedupe(services []*ServiceDefinition) []*ServiceDefinition` helper in `pkg/workspace/services/mapping.go` β€” preserves insertion order, deduplicates by ServiceName +- [ ] T034 [US4] Implement `featuresToCapabilities(features map[string]bool) []string` in `pkg/workspace/services/mapping.go` using static map: `"voice"β†’"voice"`, `"security"β†’"security"`, `"observability"β†’"observe"`, `"chaos"β†’""` (dropped/skipped) +- [ ] T035 [US4] Convert `MapFeaturesToServices(mf *manifest.Manifest)` to shim in `pkg/workspace/services/mapping.go` β€” remove old Phase 1/2 logic; body: `caps := featuresToCapabilities(mf.Features)` + `caps = append(caps, mf.Capabilities...)` + `return m.ResolveServices(mf.Tier, caps)` +- [ ] T036 [US4] Write `TestResolveServices_Think` in `pkg/workspace/services/mapping_test.go` β€” exactly 7 core services, no capability services; `t.Parallel()` +- [ ] T037 [US4] Write `TestResolveServices_Reason` in `pkg/workspace/services/mapping_test.go` β€” core + reasoner service present; `t.Parallel()` +- [ ] T038 [US4] Write `TestResolveServices_UltraInstinct` in `pkg/workspace/services/mapping_test.go` β€” core + all capability services; `t.Parallel()` +- [ ] T039 [US4] Write `TestResolveServices_VoiceCapability` in `pkg/workspace/services/mapping_test.go` β€” `["voice"]` pulls in both voice services AND reasoner (via requires chain); `t.Parallel()` +- [ ] T040 [US4] Write `TestResolveServices_Deduplication` in `pkg/workspace/services/mapping_test.go` β€” `reason` tier + `capabilities:["reasoner"]` β†’ reasoner appears once only; `t.Parallel()` +- [ ] T041 [US4] Write `TestMapFeaturesToServices_Shim` in `pkg/workspace/services/mapping_test.go` β€” verify shim delegates correctly, `features:{voice:true}` β†’ same result as `capabilities:["voice"]`; `t.Parallel()` +- [ ] T081 [US4] Fix `MapFeaturesToServices()` shim in `pkg/workspace/services/mapping.go` to implement "capabilities wins" rule: if `len(mf.Capabilities) > 0`, call `ResolveServices(mf.Tier, mf.Capabilities)` only β€” skip `featuresToCapabilities(mf.Features)` entirely. Only fall back to `featuresToCapabilities` when `mf.Capabilities` is empty. This fixes US3 AC: *"capabilities: wins; features: values are ignored."* +- [ ] T085 [US4] Update the `arc workspace run` command handler (find in `pkg/cli/workspace/` or `pkg/workspace/`) to call `mapper.ResolveServices(mf.Tier, mf.Capabilities)` directly rather than routing through the deprecated `MapFeaturesToServices` shim β€” satisfies US4 AC: *"ResolveServices is the active resolution path called by arc workspace run"*; write `TestResolveServices_WiredFromRun` integration test + +**Checkpoint**: `make test ./pkg/workspace/services/...` passes. Full resolution chain verified. Shim correctly ignores `features` when `capabilities` is non-empty. + +--- + +## Phase 6: User Story 2 β€” Tier Rename (P3) + +**Goal**: `arc workspace init` shows `think`/`reason`/`ultra-instinct`. `arc workspace info` displays correct tier names. Old constants removed. + +**Independent Test**: `arc workspace info` on a workspace with `tier: ultra-instinct` still works. Calling `getTierIndex("think")` returns 0. + +- [ ] T042 [US2] Rename `tierIDSuperSaiyan = "super-saiyan"` β†’ `tierIDThink = "think"` in `pkg/workspace/formatter.go` +- [ ] T043 [US2] Rename `tierIDSuperSaiyanBlue = "super-saiyan-blue"` β†’ `tierIDReason = "reason"` in `pkg/workspace/formatter.go` +- [ ] T044 [US2] Update `getTierIndex()` switch cases in `pkg/workspace/formatter.go` to use `tierIDThink`, `tierIDReason`, `tierIDUltraInstinct` +- [ ] T045 [US2] Write `TestGetTierIndex` in `pkg/workspace/formatter_test.go` (create if not exists) β€” table-driven: thinkβ†’0, reasonβ†’1, ultra-instinctβ†’2, unknownβ†’-1, emptyβ†’-1; `t.Parallel()` +- [ ] T084 [US2] Verify `pkg/ui/view/workspace_info.go` correctly displays `think`/`reason`/`ultra-instinct` tier labels β€” US2 AC: *"arc workspace info resolves and displays correct tier name for all three IDs."* Update any hardcoded tier label strings; add smoke test assertion: `arc workspace info` on a think-tier workspace shows "think" + +**Checkpoint**: `make test ./pkg/workspace/...` passes. `getTierIndex` correct for all three tiers. `arc workspace info` displays updated tier names. + +--- + +## Phase 7: User Story 3 β€” Capabilities Field in arc.yaml (P3) + +**Goal**: `arc.yaml` accepts `capabilities: [voice, observe]`. Manifest parses `Capabilities []string`. `features:` key is parsed but triggers deprecation warning. + +**Independent Test**: Parse `arc.yaml` with `capabilities: [voice, observe]` β†’ `Manifest.Capabilities == ["voice", "observe"]`. Parse `arc.yaml` with `features: {voice: true}` β†’ warning emitted, `Manifest.Features` populated. + +- [ ] T046 [US3] Add `Capabilities []string \`yaml:"capabilities,omitempty"\`` field to `Manifest` struct in `pkg/workspace/manifest/manifest.go` +- [ ] T047 [US3] Change `Features` field yaml tag from `yaml:"features"` to `yaml:"features,omitempty"` in `pkg/workspace/manifest/manifest.go` +- [ ] T048 [US3] Write `TestManifestParse_Capabilities` in `pkg/workspace/manifest/manifest_test.go` (create if not exists) β€” parse YAML with `capabilities: [voice, observe]` β†’ `Manifest.Capabilities == ["voice", "observe"]`; `t.Parallel()` +- [ ] T049 [US3] Write `TestManifestParse_FeaturesAndCapabilities` in `pkg/workspace/manifest/manifest_test.go` β€” when both present, both are populated (capabilities: wins in mapping, but both parsed); `t.Parallel()` + +**Checkpoint**: `make test ./pkg/workspace/manifest/...` passes. Capabilities field parsed correctly. + +--- + +## Phase 8: User Story 8 β€” Deprecation and Migration (P3) + +**Goal**: `tier: super-saiyan` hard-errors with migration instruction. `features:` key emits deprecation warning. Invalid capability names fail validation. + +**Independent Test**: +```bash +# arc.yaml with tier: super-saiyan β†’ hard error naming the migration +# arc.yaml with features: {voice: true} β†’ deprecation warning to stderr, does not block +# arc.yaml with capabilities: [unknown] β†’ validation error naming the bad value +``` + +- [ ] T050 [US8] Add `KnownTiers = []string{"think", "reason", "ultra-instinct"}` var to `pkg/workspace/manifest/schema.go` +- [ ] T051 [US8] Add `KnownCapabilities = []string{"reasoner", "voice", "observe", "security", "storage"}` var to `pkg/workspace/manifest/schema.go` +- [ ] T052 [US8] Add `legacyTierMigrations = map[string]string{"super-saiyan": "think", "super-saiyan-blue": "reason"}` to `pkg/workspace/manifest/schema.go` +- [ ] T053 [US8] Update `Validate()` in `pkg/workspace/manifest/schema.go`: (1) check tier against `legacyTierMigrations` β†’ return hard error: `tier "super-saiyan" is no longer valid. Migrate your arc.yaml: replace "super-saiyan" with "think".`; (2) check tier against `KnownTiers` β†’ validation error if unrecognised; (3) validate each `Capabilities` value against `KnownCapabilities` β†’ error naming bad value + valid options +- [ ] T054 [US8] Add deprecation warning emission in `Validate()` in `pkg/workspace/manifest/schema.go`: when `Features` map is non-empty, write to stderr per feature: `Warning: features.voice=true is deprecated. Use: capabilities: [voice]` (use exact format from `contracts/interfaces.go`) +- [ ] T055 [US8] Update `KnownFeatures` map in `pkg/workspace/manifest/schema.go` β€” keep for backwards compat but add comment that `observability` maps to `observe` capability; remove `chaos` if not in KnownCapabilities +- [ ] T056 [US8] Write `TestValidate_LegacyTierSuperSaiyan` in `pkg/workspace/manifest/schema_test.go` β€” `tier: super-saiyan` returns hard error containing "no longer valid" and "think"; `t.Parallel()` +- [ ] T057 [US8] Write `TestValidate_LegacyTierSuperSaiyanBlue` in `pkg/workspace/manifest/schema_test.go` β€” same for `super-saiyan-blue` β†’ "reason"; `t.Parallel()` +- [ ] T058 [US8] Write `TestValidate_UnknownTier` in `pkg/workspace/manifest/schema_test.go` β€” `tier: foobar` returns validation error; `t.Parallel()` +- [ ] T059 [US8] Write `TestValidate_InvalidCapability` in `pkg/workspace/manifest/schema_test.go` β€” `capabilities: [unknown-cap]` returns validation error naming the bad value and listing valid options; `t.Parallel()` +- [ ] T060 [US8] Write `TestValidate_FeaturesDeprecationWarning` in `pkg/workspace/manifest/schema_test.go` β€” `features: {voice: true}` does NOT return error; verify warning is written to stderr (capture with `os.Stderr` redirect or buffer); `t.Parallel()` +- [ ] T061 [US8] Write `TestValidate_CapabilitiesWinsOverFeatures` in `pkg/workspace/manifest/schema_test.go` β€” when both present, no error; `t.Parallel()` +- [ ] T087 [US8] Handle `features: {chaos: true}` in `featuresToCapabilities()` in `pkg/workspace/services/mapping.go` β€” emit a specific removal warning: `Warning: features.chaos=true β†’ this feature has been removed and has no capabilities equivalent`; do not silently drop it. Update `contracts/interfaces.go` comment to match. Write `TestFeaturesToCapabilities_ChaosWarning` in `mapping_test.go` + +**Checkpoint**: `make test ./pkg/workspace/manifest/...` passes. All migration/deprecation paths covered including chaos removal warning. + +--- + +## Phase 9: User Story 6 β€” Updated arc.yaml Template (P4) + +**Goal**: `arc workspace init` generates `arc.yaml` with `tier: "think"`, comment block explaining tiers, commented `capabilities:` block, no `features:` block. + +**Independent Test**: Run `arc workspace init /tmp/test-workspace` and inspect generated `arc.yaml` β€” tier is `think`, features key absent, capabilities block is present (commented out). + +- [ ] T062 [US6] Rewrite `pkg/scaffold/templates/arc.yaml.tmpl` β€” set default tier template var to `"think"`, add 3-line tier comment block (think/reason/ultra-instinct descriptions), add commented `capabilities:` block, remove `features:` block (see `data-model.md Β§ arc.yaml Template Shape`) +- [ ] T063 [US6] Update scaffold initializer (`pkg/scaffold/` or `pkg/workspace/init*.go`) to pass `Tier` and `Capabilities` fields into the template render context (replacing or extending the existing `Tier` variable) +- [ ] T064 [US6] Write golden file test for `arc.yaml.tmpl` rendering in `pkg/scaffold/templates/` β€” scenarios: think+no-caps, reason+no-caps, think+[voice,observe], ultra-instinct (no capabilities block shown) +- [ ] T083 [US6] Implement omit-empty-capabilities in the template render context: in the scaffold initializer (T063), set `Capabilities` to `nil` when no capabilities are selected so that the `{{if .Capabilities}}` conditional in `arc.yaml.tmpl` omits the `capabilities:` key entirely β€” satisfies US7 AC: *"If no extra capabilities are selected, capabilities: key is omitted from the generated file."* Add golden file case: think+no-caps β†’ no `capabilities:` line in output + +**Checkpoint**: Generated `arc.yaml` uses new format. `arc workspace init` end-to-end produces correct file. `capabilities:` key absent when no capabilities selected. + +--- + +## Phase 10: User Story 7 β€” Init Wizard Tier + Capability Selection (P4) + +**Goal**: `arc workspace init` interactive prompt shows `think`/`reason`/`ultra-instinct`. For `think`/`reason`, a capability multi-select appears. `--tier` and `--capabilities` flags bypass the wizard. + +**Independent Test**: +```bash +arc workspace init /tmp/ws --tier reason --capabilities voice,observe +cat /tmp/ws/arc.yaml +# tier: "reason", capabilities: [voice, observe] + +arc workspace init /tmp/ws2 --tier ultra-instinct +# No capability prompt; capabilities key absent in arc.yaml +``` + +- [ ] T065 [US7] Update tier `Select` field options in `pkg/ui/view/init_wizard.go` β€” replace `free`/`super-saiyan`/`super-saiyan-blue`/`ultra-instinct` with `think` (label: "think β€” core infrastructure only"), `reason` (label: "reason β€” core + LLM engine"), `ultra-instinct` (label: "ultra-instinct β€” core + all capabilities") +- [ ] T066 [US7] Add `huh.MultiSelect` capability field to the huh form in `pkg/ui/view/init_wizard.go` β€” options: reasoner, voice, observe, security, storage; shown only when tier β‰  `ultra-instinct`; pre-check capabilities already included by chosen tier via `ProfilesConfig.TierCapabilities()` +- [ ] T067 [US7] Update `InitWizardModel` (or equivalent) in `pkg/ui/view/init_wizard.go` to carry `selectedCapabilities []string` and pass it through to `workspace.NewInitializer()` options +- [ ] T068 [US7] Add `--tier string` flag (default `"think"`) to `arc workspace init` command in `pkg/cli/workspace/init.go` +- [ ] T069 [US7] Add `--capabilities []string` flag (comma-separated) to `arc workspace init` command in `pkg/cli/workspace/init.go` +- [ ] T070 [US7] Update `runInit()` / `legacyRunInit()` in `pkg/cli/workspace/init.go` to pass `--tier` and `--capabilities` flag values to `workspace.NewInitializer()` options, bypassing wizard selection when flags are provided + +**Checkpoint**: `arc workspace init --tier reason --capabilities voice` generates correct `arc.yaml`. Interactive wizard shows updated tier options and capability multi-select. + +--- + +## Phase 11: Polish & Cross-Cutting Concerns + +**Purpose**: Integration testing, dead code removal, and final quality gates. + +- [ ] T071 [P] Remove any remaining references to `super-saiyan`/`super-saiyan-blue` string literals across the codebase β€” grep confirm clean: `grep -r "super-saiyan" pkg/ --include="*.go"` +- [ ] T072 [P] Remove any remaining references to old service names (`arc-brain`, `arc-pulse`, `arc-stream`, `arc-db-sql`, `arc-db-cache`, `arc-db-vector`, `arc-voice-server`) from non-stub Go code β€” grep confirm clean +- [ ] T073 Run end-to-end smoke test from `quickstart.md` β€” `arc workspace init --tier reason --capabilities voice`, inspect `arc.yaml`, verify services resolved correctly +- [ ] T074 Run end-to-end migration error test β€” create `arc.yaml` with `tier: super-saiyan`, run `arc workspace validate` or `arc workspace info` β†’ verify migration error message exactly matches `contracts/interfaces.go` format +- [ ] T075 Run end-to-end deprecation warning test β€” `arc.yaml` with `features: {voice: true}` β†’ verify service resolves AND deprecation warning appears on stderr +- [ ] T076 Run `make quality` (fmt + vet + lint) β€” all checks pass; fix any issues found +- [ ] T077 Run `make test` with race detector β€” all tests pass; verify no flaky behaviour +- [ ] T078 Verify coverage targets: `go test -cover ./pkg/workspace/services/... ./pkg/workspace/manifest/...` β€” mapping.go β‰₯75%, schema.go β‰₯75% +- [ ] T079 [P] Verify no unjustified `//nolint` directives in modified files +- [ ] T080 [P] Update `CLAUDE.md` Recent Changes section to record 020-pre-release workspace rewrite +- [ ] T086 [P] Run `pkg/workspace/validator.go` tests to confirm port conflict detection works correctly with the new catalog-backed `ServiceDefinition.Ports []int` β€” spec Target Modules lists this file; research.md R-08 confirmed no code changes needed but it must be explicitly verified + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +``` +Phase 1 (Setup) + └─► Phase 2 (Foundational) ◄─── BLOCKS ALL USER STORIES + β”œβ”€β–Ί Phase 3 (US5 verify) + β”œβ”€β–Ί Phase 4 (US1: registry rewrite) ─► Phase 5 (US4: ResolveServices) + β”œβ”€β–Ί Phase 6 (US2: tier rename) β”‚ + β”œβ”€β–Ί Phase 7 (US3: capabilities field) β”‚ + └─► Phase 8 (US8: deprecation) β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ + β”œβ”€β–Ί Phase 9 (US6: arc.yaml template) + └─► Phase 10 (US7: init wizard) + β”‚ + └─► Phase 11 (Polish) +``` + +### User Story Dependencies + +| Story | Depends On | Can Parallelise With | +|-------|-----------|---------------------| +| US5 (profiles.yaml) | Phase 2 foundation | US2, US3 | +| US1 (registry rewrite) | Phase 2 (T016: NewMapper updated) | US2, US3, US8 | +| US4 (ResolveServices) | US5 complete, US1 complete | β€” | +| US2 (tier rename) | Phase 1 only | US3, US8 | +| US3 (capabilities field) | Phase 1 only | US2, US8 | +| US8 (deprecation) | US2 (tier constants), US3 (Capabilities field) | US6, US7 | +| US6 (template) | US2 (tier names), US3 (capabilities syntax) | US7 | +| US7 (init wizard) | US2, US3, US4 (to pass ResolveServices correctly) | β€” | + +### Within Each Story + +- Tests β†’ Models/Types β†’ Logic β†’ Integration +- Each story: independently testable at its checkpoint + +--- + +## Parallel Opportunities + +### Phase 2 (Foundational) β€” all [P] tasks can run simultaneously + +``` +T005 Profile structs T006 TierDef struct +T010 CoreServiceNames() T011 TierCapabilities() T012 ExpandCapabilities() +T013 buildServiceDefinition T014 buildTableFromCatalog +T017 TestLoadProfiles T018 TestTierCapabilities T019 TestExpandCapabilities T020 TestBuildTable +``` + +### Phase 4 (US1) + Phase 6 (US2) + Phase 7 (US3) β€” can all run in parallel after Phase 2 + +``` +Developer A: Phase 4 (US1) β€” registry.go cleanup +Developer B: Phase 6 (US2) β€” formatter.go tier rename +Developer C: Phase 7 (US3) + Phase 8 (US8) β€” manifest.go + schema.go +``` + +### Phase 9 (US6) + Phase 10 (US7) β€” can run in parallel after US2/US3/US8 + +``` +Developer A: Phase 9 (US6) β€” arc.yaml.tmpl +Developer B: Phase 10 (US7) β€” init_wizard.go + init.go flags +``` + +--- + +## Parallel Example: Phase 5 (US4 β€” ResolveServices) + +```bash +# Launch tests and implementation simultaneously within US4: +# [P] tasks β€” different files/functions: +Task T036: TestResolveServices_Think +Task T037: TestResolveServices_Reason +Task T038: TestResolveServices_UltraInstinct +Task T039: TestResolveServices_VoiceCapability +Task T040: TestResolveServices_Deduplication + +# Sequential (depends on T030, T031): +Task T032: ResolveServices() implementation (depends on servicesForNames + servicesForCapability) +``` + +--- + +## Implementation Strategy + +### MVP First (US5 + US1 + US4 only) + +1. Complete Phase 1: Setup +2. Complete Phase 2: Foundational (CRITICAL β€” blocks everything) +3. Complete Phase 3: US5 verify +4. Complete Phase 4: US1 (registry rewrite) +5. Complete Phase 5: US4 (ResolveServices) +6. **STOP and VALIDATE**: `arc workspace run` resolves correct services; old names gone +7. Deploy/demo if ready + +This MVP delivers the root-cause fix (stale registry) and the new resolution path. Tier rename, capabilities field, wizard updates, and migration errors are additive. + +### Full Delivery Order + +``` +Phase 1+2 β†’ US5 β†’ US1 β†’ US4 (core engine complete) + β†˜ US2 β†’ US3 β†’ US8 (vocabulary + validation) + β†˜ US6 β†’ US7 (UX) + β†˜ Polish +``` + +--- + +## Notes + +- `[P]` tasks operate on different files or independent functions β€” no write conflicts +- `[US#]` label maps each task to its spec user story for traceability +- Each story checkpoint defines a meaningful, independently deployable increment +- Run `make lint` after each phase; fix issues before marking tasks complete +- Do NOT delete `MapFeaturesToServices()` β€” convert to shim only (backwards compat constraint) +- **`MapFeaturesToServices()` shim must implement "capabilities wins"**: if `len(mf.Capabilities) > 0`, skip `featuresToCapabilities(mf.Features)` β€” see T081 and US3 AC +- Do NOT call `NewMapper()` inside deprecated registry stubs β€” see T024 +- Do NOT modify `pkg/catalog/data/services.yaml` β€” it is already correct (research R-01) +- `ultra-instinct` existing workspaces must not break β€” test explicitly in Phase 11