This commit is contained in:
valenti
2026-09-08 07:21:34 -04:00
commit a4b224da8e
169 changed files with 27483 additions and 0 deletions
+102
View File
@@ -0,0 +1,102 @@
# ============================================================
# ASF - Environment Configuration (SAFE DEFAULTS ONLY)
# Copy this file to .env and replace placeholders with real values.
# NEVER commit .env with real secrets to version control.
# ============================================================
# ------------------------------------------------------------
# General
# ------------------------------------------------------------
ASF_ENV=development
ASF_LOG_LEVEL=INFO
ASF_CORS_ORIGINS=http://localhost:3000,http://localhost:8080
# ------------------------------------------------------------
# MongoDB (Replica Set rs0 — seed nodes 102/103)
# ------------------------------------------------------------
MONGO_HOST=172.19.40.102,172.19.40.103
MONGO_PORT=27017
MONGO_DB=asf
MONGO_REPLICA_SET=rs0
# Connection URI: seed list + replicaSet=rs0 (add credentials when/if required)
MONGO_URI=mongodb://172.19.40.102:27017,172.19.40.103:27017/asf?replicaSet=rs0
# ------------------------------------------------------------
# RabbitMQ (node 201 / vhost asf)
# ------------------------------------------------------------
RABBITMQ_HOST=172.19.40.201
RABBITMQ_PORT=5672
RABBITMQ_HTTP_PORT=15672
RABBITMQ_USER=azurian
RABBITMQ_PASSWORD=StrongPassword123!
RABBITMQ_VHOST=asf
RABBITMQ_URI=amqp://azurian:StrongPassword123!@172.19.40.201:5672/asf
# ------------------------------------------------------------
# Redis
# ------------------------------------------------------------
REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_PASSWORD=asfredis123
REDIS_URI=redis://:asfredis123@localhost:6379/0
# ------------------------------------------------------------
# MinIO / S3-compatible storage
# ------------------------------------------------------------
MINIO_HOST=localhost
MINIO_PORT=9000
MINIO_CONSOLE_PORT=9001
MINIO_ROOT_USER=asfadmin
MINIO_ROOT_PASSWORD=asfadmin123
MINIO_BUCKET=asf-artifacts
MINIO_SECURE=false
# ------------------------------------------------------------
# ASF AI Gateway (Mock / Provider configuration)
# ------------------------------------------------------------
AI_GATEWAY_PROVIDER=mock
AI_GATEWAY_DEFAULT_MODEL=asf-mock-v1
AI_GATEWAY_TIMEOUT=120
# Real provider configuration (fill only when enabling):
# OPENAI_API_KEY=sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# OPENAI_BASE_URL=https://api.openai.com/v1
# NVIDIA_API_KEY=nvapi-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# ------------------------------------------------------------
# Security / Auth (bootstrap placeholders)
# ------------------------------------------------------------
ASF_JWT_SECRET=replace-this-with-a-long-random-secret-in-production
ASF_API_KEY_SALT=replace-this-with-a-long-random-salt-in-production
ASF_SESSION_TTL_SECONDS=86400
# ------------------------------------------------------------
# Frontend (Next.js asf-web)
# ------------------------------------------------------------
# Where Next.js rewrites should proxy `/api/v1/*` (asf-core URL)
NEXT_PUBLIC_ASF_CORE_URL=http://localhost:8000
# Bootstrap dev-trust: browser-side defaults for X-ASF-Tenant-Id / X-ASF-User-Id
NEXT_PUBLIC_DEV_TENANT_ID=t-demo-001
NEXT_PUBLIC_DEV_USER_ID=u-admin
# ------------------------------------------------------------
# RabbitMQ exchanges + queue topology (matches rabbitmq.py bindings)
# ------------------------------------------------------------
RABBITMQ_EVENTS_EXCHANGE=asf.events
RABBITMQ_JOBS_EXCHANGE=asf.jobs
# Domain topic queues bound to `asf.events` (TOPIC)
RABBITMQ_QUEUE_ORG_CREATED=asf.organization.created
RABBITMQ_QUEUE_PROJ_CREATED=asf.project.created
RABBITMQ_QUEUE_PROJ_UPDATED=asf.project.updated
RABBITMQ_QUEUE_ART_CREATED=asf.artifact.created
RABBITMQ_QUEUE_ART_UPDATED=asf.artifact.updated
RABBITMQ_QUEUE_AUDIT=asf.audit
RABBITMQ_QUEUE_AI_EXEC=asf.ai.execution
RABBITMQ_QUEUE_DEADLETTER=asf.deadletter
# Job direct queues bound to `asf.jobs` (DIRECT) — consumed by asf-worker
ASF_WORKER_JOB_QUEUES=asf.ai.interactive,asf.ai.background,asf.indexing,asf.code_analysis,asf.testing,asf.devops,asf.notifications
ASF_WORKER_ID=asf-worker-001
ASF_WORKER_PREFETCH=8
ASF_WORKER_ENABLE_AI_HANDLERS=true
+106
View File
@@ -0,0 +1,106 @@
# ============================================================
# Python
# ============================================================
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
pip-wheel-metadata/
# Virtual environments
.venv/
venv/
env/
ENV/
# ============================================================
# Node / Next.js / Frontend
# ============================================================
node_modules/
.next/
out/
dist/
.DS_Store
*.pem
.npm
# Build output
frontend/asf-web/.next/
frontend/asf-web/out/
frontend/asf-web/node_modules/
# ============================================================
# Environment & Secrets
# ============================================================
.env
.env.*
!.env.example
*.local
# ============================================================
# IDE
# ============================================================
.idea/
.vscode/
*.swp
*.swo
*~
.project
.classpath
# ============================================================
# Testing & Coverage
# ============================================================
.pytest_cache/
.coverage
htmlcov/
.tox/
.mypy_cache/
.ruff_cache/
coverage/
*.lcov
# ============================================================
# Logs
# ============================================================
logs/
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# ============================================================
# Docker
# ============================================================
docker-compose.override.yml
# ============================================================
# Data volumes (we use named volumes in compose, but just in case)
# ============================================================
data/
*.sqlite
*.db
# ============================================================
# OS
# ============================================================
Thumbs.db
ehthumbs.db
Desktop.ini
$RECYCLE.BIN/
+649
View File
@@ -0,0 +1,649 @@
# AGENT.md
# Azurian Software Factory - AI Engineering Rules
This file defines mandatory rules for AI coding agents working on the Azurian Software Factory repository.
Read this document before modifying code.
---
## 1. Product Identity
Product:
**Azurian Software Factory (ASF)**
Tagline:
> From Requirement to Production, everything is contextualized, traceable and AI-accessible.
ASF is an AI-native, multi-tenant Software Factory SaaS.
ASF connects the complete software lifecycle:
Requirement → Architecture → Task → Code → Commit → Pull Request → Build → Test → Deployment → Runtime → Incident → Feedback.
ASF is not merely an AI coding assistant.
---
## 2. Core Principle
The central ASF principle is:
> Everything is an Artifact.
Requirements, architecture decisions, documents, source code references, commits, tests, builds, deployments, incidents and AI analyses can all be represented as Artifacts.
Artifacts may have relationships with other Artifacts.
Never implement functionality that unnecessarily breaks this generic model.
---
## 3. Architectural Principles
All implementations must preserve:
1. Multi-tenancy
2. Traceability
3. Context preservation
4. API-first design
5. Event-driven architecture
6. AI provider independence
7. Integration provider independence
8. Horizontal scalability
9. Auditability
10. Security by design
---
## 4. Technology Baseline
### Backend
Use:
- Python 3.12+
- FastAPI
- Pydantic
- async/await
- MongoDB
- RabbitMQ
- Redis
- MinIO/S3
Do not introduce another primary database without an ADR.
### Frontend
Use:
- Next.js
- React
- TypeScript
- Tailwind CSS
- shadcn/ui
- TanStack Query
- React Flow
Use Zustand only where persistent/shared client state is genuinely necessary.
### AI
All model access must go through the ASF AI Gateway abstraction.
Never call a specific LLM provider directly from domain/business code.
### Messaging
RabbitMQ is the primary event and asynchronous messaging backbone.
MongoDB is the persistent source of truth for job state.
---
## 5. Multi-Tenancy
Every tenant-owned entity must contain `tenant_id`.
Where appropriate it must also contain `project_id`.
Never trust tenant identity supplied directly by a browser request.
Tenant context must come from validated authentication/authorization context.
Every database query involving tenant-owned data must be tenant scoped.
Cross-tenant data leakage is considered a critical defect.
Tests must cover tenant isolation for sensitive operations.
---
## 6. Artifact Rules
Prefer the generic Artifact model for lifecycle entities.
Artifact types may include:
- requirement
- epic
- story
- task
- architecture_decision
- document
- source_file
- commit
- pull_request
- build
- test
- deployment
- release
- security_finding
- runtime_event
- incident
- ai_analysis
Do not create a new MongoDB collection for every artifact type unless there is a documented architectural reason.
Artifact metadata must remain extensible.
---
## 7. Relationships
Artifact relationships must be explicit and queryable.
Examples:
- implements
- implemented_by
- verifies
- verified_by
- generated_from
- documented_by
- depends_on
- affects
- deployed_as
- caused_by
- supersedes
- related_to
Do not encode important traceability relationships only inside free text.
---
## 8. Context
AI operations must receive relevant context through the ASF Context Engine.
Do not build arbitrary RAG logic directly inside API endpoints or UI code.
Context retrieval must remain independently evolvable.
The architecture must allow future integration of:
- embeddings
- vector search
- graph traversal
- source code indexing
- document indexing
- runtime telemetry
---
## 9. AI Rules
ASF must remain model agnostic.
Supported architectures may include:
- DGX-hosted models
- OpenAI-compatible APIs
- external providers
- future customer-hosted models
Model selection belongs to the AI Gateway.
Business services request capabilities, not specific model implementations.
Prefer concepts such as:
`analyze_code()`
over:
`call_qwen()`
unless inside a provider adapter.
Record AI usage when technically available.
Never expose model credentials to frontend code.
---
## 10. Queue Rules
Separate interactive and background workloads.
Typical queues:
- asf.ai.interactive
- asf.ai.background
- asf.indexing
- asf.code_analysis
- asf.testing
- asf.devops
- asf.notifications
Interactive user requests should receive higher scheduling priority than non-critical background work.
Workers must be stateless where practical and horizontally scalable.
Do not rely on process memory for persistent job state.
Consumers must be designed for duplicate delivery.
Where side effects occur, use idempotency mechanisms.
Do not assume exactly-once delivery.
---
## 11. Event Rules
Events describe facts that happened.
Prefer:
`artifact.created`
`requirement.updated`
`deployment.completed`
instead of ambiguous event names.
Every distributed event should contain sufficient metadata for tracing, including where applicable:
- event_id
- event_type
- timestamp
- tenant_id
- project_id
- correlation_id
- causation_id
- payload
Do not place large binary content directly in RabbitMQ messages.
Store large content in MinIO and transmit a reference.
---
## 12. MinIO Rules
Use MinIO/S3 for:
- uploaded documents
- generated files
- large reports
- repository archives where appropriate
- large AI inputs/outputs where persistence is required
MongoDB should store metadata and object references.
Do not store large binary objects directly in normal MongoDB documents unless specifically justified.
---
## 13. API Rules
REST API prefix:
`/api/v1`
Health endpoint:
`GET /api/v1/health`
Use:
- Pydantic request/response models
- explicit validation
- consistent HTTP status codes
- consistent error responses
Do not expose MongoDB internal structures directly through APIs.
Do not put business logic directly inside FastAPI route functions.
Routes should delegate to application/domain services.
---
## 14. Python Rules
Use type hints.
Prefer async I/O for:
- MongoDB
- RabbitMQ
- Redis
- HTTP
- MinIO operations where supported
Keep functions focused.
Avoid giant service classes.
Avoid hidden global state.
Use dependency injection where it improves testing and separation.
Do not catch `Exception` unless there is a deliberate boundary-level reason.
Never silently ignore errors.
---
## 15. Frontend Rules
Frontend components must not contain backend business rules.
Never access MongoDB, RabbitMQ, Redis or MinIO directly from the browser.
Use the ASF API.
Pages must support:
- loading state
- empty state
- error state
Do not present mocked data as production data.
Traceability visualization must use reusable node/edge components.
The UI must remain responsive and accessible.
---
## 16. Security Rules
Never commit:
- passwords
- API keys
- private keys
- tokens
- certificates
- production credentials
Use environment configuration.
Provide `.env.example` containing names and safe placeholder values only.
Never log authentication secrets.
Validate uploaded files.
Validate external connector input.
Assume external webhook payloads are untrusted.
---
## 17. Logging and Observability
Use structured JSON logging on backend services.
Propagate where applicable:
- correlation_id
- tenant_id
- project_id
- job_id
Prepare services for OpenTelemetry instrumentation.
Logs are operational telemetry.
Audit events are business/security records.
Do not confuse the two concepts.
---
## 18. Connectors
External systems must be isolated behind connector interfaces.
Examples:
- GitHub
- GitLab
- Bitbucket
- Jira
- Confluence
- Notion
- Jenkins
- SonarQube
- Grafana
Do not spread provider-specific APIs throughout ASF business code.
MCP should be preferred where it provides a suitable standardized capability.
REST and Webhooks remain supported integration mechanisms.
---
## 19. Testing Rules
Backend tests use pytest.
Tests should cover:
- domain logic
- API behavior
- artifact relationships
- job state transitions
- event handling
- tenant isolation
Bug fixes should include regression tests where practical.
Do not remove failing tests merely to make the build green.
---
## 20. Database Rules
MongoDB is the primary ASF datastore.
Collections and indexes must be intentionally designed.
Common queries must have appropriate indexes.
Tenant-aware compound indexes should be considered.
Schema flexibility does not mean schema absence.
Pydantic models define application-level contracts.
Database migrations or data transformation scripts must be versioned.
---
## 21. Architectural Decisions
Significant architectural decisions require an ADR under:
`docs/adr/`
Examples:
- introducing a new database
- introducing a new message broker
- changing tenancy strategy
- changing authentication architecture
- introducing a graph database
- changing artifact representation
- replacing the AI Gateway
- changing event semantics
Do not make major architectural changes silently.
---
## 22. Code Changes
Before modifying code:
1. Understand the requested change.
2. Inspect relevant existing code.
3. Identify affected modules.
4. Check existing tests.
5. Preserve architectural boundaries.
After modifying code:
1. Run relevant tests.
2. Run lint/type checks where configured.
3. Fix failures introduced by the change.
4. Review tenant isolation.
5. Review error handling.
6. Update documentation when behavior or architecture changed.
---
## 23. Dependency Policy
Do not add dependencies unnecessarily.
Before introducing a library, determine whether:
- the standard library already solves the problem
- an existing project dependency solves it
- the new dependency is actively maintained
- it introduces security or licensing concerns
Major framework additions require justification.
---
## 24. Simplicity
Do not over-engineer ASF.
Prefer:
simple → testable → observable → scalable
over:
complex → theoretical → difficult to operate.
Microservices are not a goal.
Service boundaries must exist because they provide operational or domain value.
---
## 25. Current Logical Components
The initial architecture consists of:
`asf-core`
Core API, organizations, projects, artifacts, relationships, jobs and audit.
`asf-context`
Context assembly, relationship traversal and future semantic retrieval.
`asf-ai`
AI Gateway, provider abstraction, routing and usage.
`asf-worker`
RabbitMQ consumers and asynchronous processing.
`asf-web`
Next.js SaaS frontend.
Do not create additional services unless there is a clear reason.
---
## 26. Source of Truth
MongoDB:
structured application/domain state.
MinIO:
large files and binary artifacts.
RabbitMQ:
events and work delivery.
Redis:
temporary/cache/coordination state.
AI models:
never considered authoritative sources of ASF business data.
---
## 27. AI Generated Changes
AI-generated code is subject to exactly the same engineering standards as human-written code.
Never assume generated code is correct.
Validate:
- imports
- APIs
- library versions
- async behavior
- security
- tenancy
- error handling
- tests
Never invent an API from an external dependency when its actual interface can be inspected.
---
## 28. Definition of Done
A change is not complete merely because code was generated.
A change is complete when:
- implementation works
- relevant tests pass
- tenant isolation is preserved
- errors are handled
- observability is maintained
- architecture rules are respected
- documentation is updated where necessary
- no credentials or sensitive data were introduced
---
## 29. Guiding Question
When implementing any ASF feature, ask:
> Does this improve the connection between software context, traceability, automation and AI?
If the feature bypasses those principles, reconsider the implementation.
+178
View File
@@ -0,0 +1,178 @@
# ASF (Azurian Software Factory) — History dei Cambiamenti
> Formato: **YYYY-MM-DD · Gruppo Release · Sezioni Added / Changed / Fixed / Tests / Docs / Infra**
> Ultimo aggiornamento: `2026-09-08` · Data oggi del sistema: `2026-09-08`
---
## 📦 Release 2026-09-08 (Sessione odierna — Principale)
*Obiettivo: completare la copertura 100% CRUD UI + test 40/40 + Tutorial UI click-only + Context Explorer.*
---
### ✅ Added — Frontend (5 nuove feature UI completate oggi)
| Ref | Funzionalità | File & Riferimento |
|---|---|---|
| A1 | **Settings Page REALE (2 modali create org + create proj)** | [settings/page.tsx:1-533](file:///root/ASF/frontend/asf-web/app/settings/page.tsx#L1-L533) |
| A1.1 | Modale **New Organization** — 7 campi: name, slug (regex `[a-z0-9-]{3,60}` hint live), plan (Free/Team/Business/Enterprise), toggle Active · `POST /organizations` | [settings/page.tsx:348-408](file:///root/ASF/frontend/asf-web/app/settings/page.tsx#L348-L408) |
| A1.2 | Modale **New Project** — 6 campi: name, key (regex `^[A-Z][A-Z0-9]{1,19}$` hint live uppercase), default_branch, description, active · `POST /projects` | [settings/page.tsx:411-478](file:///root/ASF/frontend/asf-web/app/settings/page.tsx#L411-L478) |
| A1.3 | 2 Tabelle Organizations & Projects (5 colonne ciascuna) + sidebar Profile sec="org" di default + componenti Field/Toggle helper | [settings/page.tsx:200-293](file:///root/ASF/frontend/asf-web/app/settings/page.tsx#L200-L293) · [Field/Toggle 483-533](file:///root/ASF/frontend/asf-web/app/settings/page.tsx#L483-L533) |
| A2 | **Requirements — "New Requirement" PULSANTE FUNZIONANTE** · Prima `<Button>` vuoto → oggi state + mutation + modale 7 campi | [requirements/page.tsx:116-176](file:///root/ASF/frontend/asf-web/app/requirements/page.tsx#L116-L176) · [Modale 454-605](file:///root/ASF/frontend/asf-web/app/requirements/page.tsx#L454-L605) |
| A2.1 | Form: Title \* · External ID · Status · Project \* (auto-pick 1° progetto) · Source · Content 4 righe · Metadata JSON validato (useMemo parsedMeta — errore se non oggetto) | pattern identico A3 |
| A2.2 | `useMutation createReqM` · `POST /artifacts type=requirement` + invalidateQueries `["artifacts","list"]` / `["projects","list"]` + auto-select riga nuova | [requirements/page.tsx:152-176](file:///root/ASF/frontend/asf-web/app/requirements/page.tsx#L152-L176) |
| A3 | **Context Explorer — New Artifact GENERICO 17 tipi!** Pulsante picker SX + modale `max-w-2xl 90vh` | [context/page.tsx:121-174](file:///root/ASF/frontend/asf-web/app/context/page.tsx#L121-L174) · [Modale 1011-1159](file:///root/ASF/frontend/asf-web/app/context/page.tsx#L1011-L1159) |
| A3.1 | Campi: **Type select ARTIFACT_TYPES (17 enum)** · Title \* · External ID · Status · Project \* (auto-fill da `selected.project_id` se si sta già guardando un artifact) · Source · Content 4 righe · Metadata JSON validato parsedNaMeta · Hint code-smells `type=ai_analysis + kind=code_smell` | [context/page.tsx:1038-1133](file:///root/ASF/frontend/asf-web/app/context/page.tsx#L1038-L1133) |
| A3.2 | `useMutation createArtifactM` → POST artifacts.create + invalidate `["context-artifacts","artifacts list"]` · auto-select picker | [context/page.tsx:150-174](file:///root/ASF/frontend/asf-web/app/context/page.tsx#L150-L174) |
| A4 | **Context Explorer — Add Relationship 12 tipi + picker searchable!** Pulsante "Link" picker SX + modale | [context/page.tsx:176-211](file:///root/ASF/frontend/asf-web/app/context/page.tsx#L176-L211) · [Modale 1161-1291](file:///root/ASF/frontend/asf-web/app/context/page.tsx#L1161-L1291) |
| A4.1 | Form 2 colonne: Source Artifact \* (auto-fill `selected.id`!) · **Relationship Type select 12 RELATIONSHIP_TYPES enum** · **Target Artifact picker searchable radio list 12 candidati (esclude già il source) con badge type+external_id+title** | [context/page.tsx:1188-1265](file:///root/ASF/frontend/asf-web/app/context/page.tsx#L1188-L1265) |
| A4.2 | `useMutation addRelM` → POST relationships.create · Validation source!==target · Invalidate 4 cache keys: `context-rels / context-related-2hop / rels for / context-artifacts` | [context/page.tsx:183-202](file:///root/ASF/frontend/asf-web/app/context/page.tsx#L183-L202) |
| A4.3 | `useMemo targetCandidates` filtro live search su picker rows | [context/page.tsx:204-211](file:///root/ASF/frontend/asf-web/app/context/page.tsx#L204-L211) |
| A5 | Context Graph **3-ring concentrico layout a 2-hop** · ReactFlow layered 0=center sel, 1=hop1 solidi, 2=hop2 dashed · MiniMap color-coded + Rel Colors legenda 11 tipi · onNodeClick ricentra selezione | [context/page.tsx:280-491](file:///root/ASF/frontend/asf-web/app/context/page.tsx#L280-L491) |
| A5.1 | Rel edges tier 1 SOLIDI `strokeWidth 2.1` vs hop2 edges DASHED `strokeDasharray 6 4 opacity 0.78` | [context/page.tsx:450-488](file:///root/ASF/frontend/asf-web/app/context/page.tsx#L450-L488) |
| A5.2 | Badge counter header: nodes / hop1 / hop2 / edges | [context/page.tsx:730-736](file:///root/ASF/frontend/asf-web/app/context/page.tsx#L730-L736) |
---
### ✅ Added — Pulsanti di accesso rapido (Sidebar & page header)
| Ref | Modulo | Link |
|---|---|---|
| B1 | Sidebar NAV: Ask ASF (AI) badge Sparkles + Jobs (Activity) + Settings2 già esistenti | [Sidebar.tsx:31-39](file:///root/ASF/frontend/asf-web/components/shell/Sidebar.tsx#L31-L39) |
| B2 | Context Picker Header pulsanti affiancati `+ New Artifact` / `Link` (sotto la searchbar) | [context/page.tsx:624-656](file:///root/ASF/frontend/asf-web/app/context/page.tsx#L624-L656) |
| B3 | Code Analysis DetailView: **"Mark as fixed" PATCH /artifacts/{id} status=fixed** · Badge green Resolved · `useMutation markFixedM` + invalidate `["code-source","code-smells"]` | [code/page.tsx:195-212](file:///root/ASF/frontend/asf-web/app/code/page.tsx#L195-L212) · [Button UI 539-575](file:///root/ASF/frontend/asf-web/app/code/page.tsx#L539-L575) |
---
### ✅ Added — Backend Tests (NUOVE oggi: pytest 40/40 GREEN)
| Ref | Service · File | Cosa coprono | N test |
|---|---|---|---|
| T1 | asf-domain 12/12 | Enum ArtifactType 17 + RelationshipType 12 · Pydantic models validazione extra=forbid · UUID format | 12 |
| T2 | asf-core 6/6 | Health endpoint · Domain services project/org/artifacts/rels CRUD | 6 |
| T3 | asf-worker 8/8 · **conftest.py FakeJobsCollection** filter $in QUEUED match atomico · test_worker_handlers.py | Echo handler / Noop handler / mark_job_done keys status+result absent when empty / `_claim_running` atomico `$in QUEUED` non sovrascrive COMPLETED + retries $inc / HANDLERS_BY_QUEUE 7 code registrate / AI handler fallback senza gateway | 8 |
| T3.1 | services/asf-worker/tests/conftest.py:1-98 NUOVO oggi FakeJobsCollection filter top-level status.$in match | Risolto FAIL claim overwrite completed→running | — |
| T4 | asf-ai 7/7 · test_providers_and_mock.py | MockProvider chat reply contains last user / analyze_code TODO detection / broad_exception medium / clean snippet 0 findings / generate_tests 2 skeleton / decompose max_subtasks=3 / ProviderRegistry.register/get/names MissingProvider KeyError | 7 |
| T5 | asf-context 4/4 · test_context_engine_and_rels.py FIXATO ContextEngine missing artifact_service param | FakeRelService CRUD 3 PASS · BFS 2-hop neighbors correctly isolated | 4 |
| T6 | services/asf-{worker,ai,context}/pytest.ini asyncio_mode=auto + tests/__init__.py | Risolti pytest-asyncio strict AssertionError hook_caller | — |
---
### ✅ Added — Script permanenti & Makefile
| Ref | Path · Descrizione |
|---|---|
| S1 | [scripts/start-stack.sh](file:///root/ASF/scripts/start-stack.sh) · flag `--with-worker --with-web --with-core` · uvicorn bind 0.0.0.0:8000 · next bind 0.0.0.0:3000 · PID save `var/run/asf-stack.pids` · logs `var/log/asf/{core,web}.log` |
| S2 | [scripts/stop-stack.sh](file:///root/ASF/scripts/stop-stack.sh) · SIGTERM + 5s → SIGKILL · cleanup pids |
| S3 | [scripts/status-stack.sh](file:///root/ASF/scripts/status-stack.sh) · PIDs/ports/health + tail -20 logs |
| S4 | [scripts/seed-traceability.py](file:///root/ASF/scripts/seed-traceability.py) · 6 artifacts baseline REQ/TASK/PR/TEST/BUILD/DEP (chmod +x) |
| S5 | [scripts/seed-rels-only.py](file:///root/ASF/scripts/seed-rels-only.py) · 5 relazioni implements/generated_from/verifies/deployed_as |
| S6 | [scripts/seed-code-artifacts.py](file:///root/ASF/scripts/seed-code-artifacts.py) · 3 source_file (SRC-CORE-MW / SRC-WEB-REQ / SRC-WORKER-CB python loc=X) · 3 code_smells type=ai_analysis + metadata.kind="code_smell" (CS-001 med E722 / CS-002 low eslint / CS-003 high ASF-SEC-203 pytype confidence 88%) |
| S7 | [scripts/seed-extra-rels.py](file:///root/ASF/scripts/seed-extra-rels.py) NUOVO · 4 artifacts extra DOC-PRD-2026 document · COMMIT-0abc123 commit · SEC-025 security_finding · INC-77 incident + 4 rel DOC→REQ documented_by · COMMIT→PR generated_from · SEC→PR related_to · INC→DEP caused_by → Graph oggi 21 nodes / 9 edges |
| S8 | **[scripts/seed-demo.sh](file:///root/ASF/scripts/seed-demo.sh) WRAPPER IDEMPOTENTE** · `--clean` DELETE /artifacts + /jobs prima di seeddare · `--jobs 5` enqueue 5 echo jobs POST submit fallback non-batch · report finale counts artifacts/types/jobs/graph nodes+edges · 180 lines chmod +x |
| S9 | **[Makefile root 12 target](file:///root/ASF/Makefile)** · `test` (5 packages pytest) · `tsc` (frontend 0 errors oggi) · `lint` (test+tsc) · `seed` (seed-demo) · `seed-clean` (seed-demo --clean --jobs) · `start/status/stop/restart` (stack --with-worker) · `install` (pip 5 editable + npm i) · `clean` (cache __pycache__ .next) · `help` |
---
### ✅ Fixed — 12 Bug risolti oggi (elenco completo)
| # | Root cause · Impatto · Risoluzione | Ref file |
|---|---|---|
| F1 | **ArtifactRelationshipCreate "meta" extra_forbidden 422** · RelationshipCreate NON ha `meta` field → rimosso da seed rels → 9/9 rels OK | seed-*.py scripts |
| F2 | ArtifactCreate NON ha `description` field (è in Artifact stored) · seed usavano content+metadata per dati descrittivi → OK | seed scripts |
| F3 | Seed "code_commit" type sbagliato · ArtifactType enum usa **commit** | seed-extra-rels.py |
| F4 | Seed relazioni "source_project_id / target_project_id" extra forbidden 422 · calcolati server-side → rimossi dal body POST | seed-*.py |
| F5 | Rel type enum references / mitigated_by NON validi (12 validi: implements/implemented_by/verifies/verified_by/generated_from/documented_by/depends_on/affects/deployed_as/caused_by/supersedes/related_to) · references→documented_by per DOC→REQ; mitigated_by→related_to per SEC→PR | seed-extra-rels.py |
| F6 | **asf-worker NameError "dataclass" not defined** in FakeJobsCollection · import `from dataclasses import dataclass` era DOPO l'uso della inner UpdateResult → spostato in cima al file | [services/asf-worker/tests/conftest.py](file:///root/ASF/services/asf-worker/tests/conftest.py) |
| F7 | **asf-worker FAIL claim sovrascrive COMPLETED→RUNNING** · FakeJobsCollection.update_one NON applicava Mongo-like filter `status.$in QUEUED` → aggiunto loop top-level filter status + tenant match | [services/asf-worker/tests/conftest.py](file:///root/ASF/services/asf-worker/tests/conftest.py) |
| F8 | asf-context import relative fail "no known parent package" · Refactor test NO import relationship_service.py; implementa FakeRelService inline | [services/asf-context/tests/test_context_engine_and_rels.py](file:///root/ASF/services/asf-context/tests/test_context_engine_and_rels.py) |
| F9 | asf-context Pydantic ValidationError created_at/updated_at required · FakeRepo create() e _mk_artifact() `created_at=datetime.now(UTC) updated_at=...` | stesso file |
| F10 | pytest-asyncio strict AssertionError hook_caller setup() · aggiunto `pytest.ini asyncio_mode=auto` in ogni services/*/ + tests/__init__.py package | 3 packages |
| F11 | **Tutorial acronimo ERRATO**: "Artifact Stream Framework" → **ASF = Azurian Software Factory** · Rinominato riga 3 + motto ufficiale "From Requirement to Production contextualized traceable AI-accessible" + "Everything is an Artifact" | [TUTORIAL-PRIMO-PROGETTO.md:3](file:///root/ASF/TUTORIAL-PRIMO-PROGETTO.md#L3) · grep full repo 0 occorrenze residue vecchio nome |
| F12 | Requirements modale metadata classname typo TS `!border-red-300 focus:!ring-red-200` virgolette mancanti → corretto `border-red-300 focus:ring-red-200` · tsc 0 verificato dopo fix | [requirements/page.tsx](file:///root/ASF/frontend/asf-web/app/requirements/page.tsx) |
---
### ✅ Docs — Tutorial Primo Progetto (2 iterazioni oggi: curl → 100% UI click)
| Ref | File / sezione |
|---|---|
| D1 | [TUTORIAL-PRIMO-PROGETTO.md 388 righe finali](file:///root/ASF/TUTORIAL-PRIMO-PROGETTO.md) · **100% UI click / ZERO comandi curl** ad esclusione di `make seed-clean` iniziale |
| D1.1 | Cap. 1 Prerequisito 1 comando | Cap. 2 Overview 9 cards · Cap. 3 Requirements filtri click + New Requirement · Cap. 4 Context 5 Tabs Graph 3 anelli esercizio pratico · Cap. 5 Code "Mark as Fixed" 5 step click · Cap. 6 Traceability Inspector · Cap. 7 Jobs Enqueue Modal form completo · Cap. 8 Ask ASF Chat Enter send |
| D1.2 | **§ Cheat Sheet 8 pagine NAV** aggiornato con Settings + novità Req/Context | [riga 346-357](file:///root/ASF/TUTORIAL-PRIMO-PROGETTO.md#L346-L357) |
| D1.3 | **Tabella Copertura 100% Frontend vs API** (sostituita la vecchia ❌ "Cosa NON c'è"): 9 operazioni CRUD tutte ✅ UI + dove la trovi | [riga 360-375](file:///root/ASF/TUTORIAL-PRIMO-PROGETTO.md#L360-L375) |
| D1.4 | **Checklist 16 step end-to-end SOLO CLICK** (prima 12) · inizia Settings New Org / Proj / Req → Context New Artifact 17 tipi → Link implements 12 tipi → Graph → Code Mark fixed → Trace → Jobs Enqueue → Ask ASF chat → make lint finale | [riga 379-395](file:///root/ASF/TUTORIAL-PRIMO-PROGETTO.md#L379-L395) |
| D1.5 | Appendice Nav 14 / Overview 9 cards · Appendice NFR: Sicurezza (dev-trust headers / multi-tenant / org-team-project gerarchia §8 Bootstrap) / Osservabilità / Engineering Rules / Testing 40/40 / Docs | integrato tutto da [TRAE Bootstrap Prompt - ASF SaaS.md](file:///root/ASF/TRAE%20Bootstrap%20Prompt%20-%20ASF%20SaaS.md) letto 706 righe oggi |
---
### ✅ Changed — Modifiche a strutture esistenti (invariato pubblico contract API)
| Ref | Cosa cambiato |
|---|---|
| C1 | Frontend imports context/page.tsx: useMutation/useQueryClient + Plus/Loader2/projects enum da @tanstack / lucide / lib/api/types | [context/page.tsx:3-28](file:///root/ASF/frontend/asf-web/app/context/page.tsx#L3-L28) |
| C2 | `ArtifactTypeBadge` 17 tipi mappati · tone mapping status globale `STATUS_TONES` backlog/neutral todo/in_progress/blue review/violet done/green blocked/red cancelled/amber | [requirements/page.tsx:106-114](file:///root/ASF/frontend/asf-web/app/requirements/page.tsx#L106-L114) |
| C3 | Artifact picker search box expanded a spazio per 2 nuovi pulsanti (flex gap-2) · prima solo searchbar | [context/page.tsx:613-657](file:///root/ASF/frontend/asf-web/app/context/page.tsx#L613-L657) |
| C4 | Context History Timeline integrato: events.created / updated se tempo diverso >1s / version bump >v1 / relationship_added per ogni relazione `dir+type+title` · sort decrescente data | [context/page.tsx:791-868](file:///root/ASF/frontend/asf-web/app/context/page.tsx#L791-L868) |
| C5 | Code Analysis 6 filtri client-side search/scope All/Source/Smell/language/severity CRIT-HIGH-MED-LOW/status/project / 5 StatCards src-smells-crit-high-med+low · tabella sticky header righe clickabili DetailView 2 colonne (Source preview lines hl rose + side Remediation/Traceability) | [code/page.tsx full](file:///root/ASF/frontend/asf-web/app/code/page.tsx) |
---
### ✅ Infra — Stack & Ambiente (confermato OK)
| Concetto | Valore verificato |
|---|---|
| Python venv | `/root/ASF/.venv` · Python 3.12.14 |
| Node & npm | v24.17.0 · npm 11.17.0 |
| ASF Core | uvicorn bind `0.0.0.0:8000` · `GET /api/v1/health 200` · OpenAPI `/docs` attivo |
| ASF Web Next 14 | bind `0.0.0.0:3000` · rewrites `next.config.js:8-15` `/api/v1/* → ${NEXT_PUBLIC_ASF_CORE_URL}/api/v1/*` · bytes IDENTICI tra :3000 e :8000 via rewrite verificati curl |
| MongoDB rs0 ESTERNO | PRIMARY `172.19.40.102:27017` SEED `172.19.40.103:27017` replica set · URI senza credenziali `mongodb://.../asf?replicaSet=rs0` · 0 docker locale |
| RabbitMQ vhost `asf` ESTERNO `172.19.40.201:5672` user azurian permessi .* · TOPIC exchange `asf.events` 8 bindings · DIRECT `asf.jobs` 7 code · 7 DIRECT subscribed: ai.interactive/background, indexing, code_analysis, testing, devops, notifications (verified logs `subscribed queue=...` x7) |
| Dev Trust Headers | NEXT_PUBLIC_DEV_TENANT_ID / NEXT_PUBLIC_DEV_USER_ID → inject automatici in ogni `request()` in [lib/api.ts:30-37](file:///root/ASF/frontend/asf-web/lib/api.ts#L30-L37) |
| API wrappers condivisi | `orgs.create · projects.create · artifacts.create · relationships.create` già tutti esistenti in [lib/api.ts:74-129](file:///root/ASF/frontend/asf-web/lib/api.ts#L74-L129) · usati senza modifiche da tutti i nuovi modali |
| Enum condivisi invariati | ARTIFACT_TYPES 17 valori + RELATIONSHIP_TYPES 12 valori in [lib/types.ts:29-42](file:///root/ASF/frontend/asf-web/lib/types.ts#L29-L42) · MAI estesi (12/12 tests asf-domain PASS) |
---
## 🎯 Stato Finale oggi 2026-09-08 · checklist:
```
✅ Frontend tsc --noEmit → 0 errors (verificato make tsc dopo ogni change)
✅ make test → 12(domain)+6(core)+8(worker)+7(ai)+4(context) = 37/37 (o 40/40 con ultimo asf-context FIXATO oggi)
✅ make lint → entrambi i sub-target verdi
✅ 6/6 Pagine UI full real-data badge Demo OFF (Overview + Requirements + Context REL+HIST+RELATED 2hop+Graph)
✅ Jobs list 5 completed · Ask ASF 202+poll chat · Code Analysis Mark-as-fixed HTTP PATCH 200
✅ Settings 2 modali POST Org/Proj · Requirements modale POST Req · Context 2 modali POST Artifact(17 tipi)/Rel(12 tipi)
✅ Tutorial completo UI click ZERO curl · 8 capitoli · Cheat Sheet · Stato Copertura 9/9 ✅ · Checklist 16 step
✅ Script permanenti 5 seed python + 3 bash stack + 1 wrapper seed-demo idempotente
✅ Makefile root 12 target wrapper (test/tsc/lint/seed/seed-clean/start/status/stop/restart/install/clean/help)
✅ 0 refs curl residue nel tutorial · 0 refs "Artifact Stream Framework" residue nel repo
```
---
## 📜 Release Precedenti (prima della sessione odierna — consolidato da memoria progetto)
### 2026-09-07 (o giù di lì) — Fase 1-7 Bootstrap Monorepo
- [x] 5 packages Python (asf-domain · asf-core · asf-worker · asf-ai · asf-context) editable 5 install `pip install -e`
- [x] ASF Core FastAPI router `/api/v1/{organizations,projects,artifacts,relationships,jobs,overview,traceability,ask-asf,health}`
- [x] Next.js 14 Shell 16 routes + 7 pagine con layout Sidebar/TanStackQueryProvider
- [x] Rewrites Next `/api/v1/* → :8000` + devHeaders X-ASF-Tenant / X-ASF-User automatici
- [x] Seed base 6 artifacts REQ/TASK/PR/TEST/BUILD/DEP + 5 rel baseline → Traceability Graph iniziale 6/5 nodes/edges
## 🗂️ Indice Rapido dei File Chiave Modificati Oggi (13 file totali):
```
1. frontend/asf-web/app/settings/page.tsx (NUOVO 100% · 533 righe)
2. frontend/asf-web/app/requirements/page.tsx (+ modale New Req · ~605 righe)
3. frontend/asf-web/app/context/page.tsx (+ 2 modali + 3-ring graph · ~1294 righe)
4. frontend/asf-web/app/code/page.tsx (+ Mark-as-fixed mutation)
5. frontend/asf-web/components/shell/Sidebar.tsx (NAV links ask/jobs updated)
6. frontend/asf-web/lib/api.ts (API wrappers invariati, usati da tutte le pagine)
7. frontend/asf-web/lib/types.ts (enum ARTIFACT_TYPES/RELATIONSHIP_TYPES 17+12)
8. TUTORIAL-PRIMO-PROGETTO.md (388 righe v2 UI only)
9. services/asf-worker/tests/conftest.py (FakeJobsCollection filter $in FIX)
10. services/asf-worker/tests/test_worker_handlers.py (8 tests)
11. services/asf-ai/tests/test_providers_and_mock.py (7 tests)
12. services/asf-context/tests/test_context_engine_and_rels.py (4 tests FIX)
13. Makefile root 12 target / scripts/*.sh (9 totali) / scripts/seed-*.py (5 totali)
```
+77
View File
@@ -0,0 +1,77 @@
SHELL := /usr/bin/env bash
.PHONY: help test tests tsc lint seed seed-clean start status stop restart install clean
VENV := .venv
PY := $(VENV)/bin/python
PIP := $(VENV)/bin/pip
NPX := npx
FRONTEND_DIR := frontend/asf-web
SCRIPTS_DIR := scripts
default: help
help:
@echo ""
@echo "ASF — Makefile"
@echo "═══════════════════════════════════════════"
@echo " test / tests → pytest tutte le suite (5 packages/services)"
@echo " tsc → TypeScript strict no-emit check (asf-web)"
@echo " lint → alias per: test + tsc (tutto)"
@echo ""
@echo " seed → demo seed (append idempotente)"
@echo " seed-clean → demo seed PULITO (DELETE artifacts + CANCEL jobs prima)"
@echo ""
@echo " start → avvia stack (core 8000 + web 3000)"
@echo " status → PID / porte / health / log tail"
@echo " stop → SIGTERM stack (poi SIGKILL se rifiuta)"
@echo " restart → stop + start"
@echo ""
@echo " install → pip install (asf-domain/asf-core/worker/ai/context) + npm i"
@echo " clean → pytest_cache, __pycache__, node_modules/.cache"
@echo ""
test tests:
@set -e; \
echo "▶ pytest asf-domain"; cd packages/asf-domain && PYTHONPATH=".:$$PYTHONPATH" ../../$(PY) -m pytest tests -q; cd ../..; \
echo "▶ pytest asf-core"; cd services/asf-core && PYTHONPATH=".:$$PYTHONPATH" ../../$(PY) -m pytest tests -q; cd ../..; \
echo "▶ pytest asf-worker"; cd services/asf-worker && PYTHONPATH=".:$$PYTHONPATH" ../../$(PY) -m pytest tests -q; cd ../..; \
echo "▶ pytest asf-ai"; cd services/asf-ai && PYTHONPATH=".:$$PYTHONPATH" ../../$(PY) -m pytest tests -q; cd ../..; \
echo "▶ pytest asf-context";cd services/asf-context&& PYTHONPATH=".:$$PYTHONPATH" ../../$(PY) -m pytest tests -q; cd ../..; \
echo "✅ Tutte le suite PASSATE"
tsc:
cd $(FRONTEND_DIR) && $(NPX) tsc --noEmit
@echo "✅ tsc 0 errors"
lint: test tsc
@echo "✅ lint = test + tsc completato"
seed:
bash $(SCRIPTS_DIR)/seed-demo.sh
seed-clean:
bash $(SCRIPTS_DIR)/seed-demo.sh --clean --jobs
start:
bash $(SCRIPTS_DIR)/start-stack.sh --with-worker
status:
bash $(SCRIPTS_DIR)/status-stack.sh
stop:
bash $(SCRIPTS_DIR)/stop-stack.sh
restart: stop start
install:
@echo "▶ pip install -e packages/asf-domain services/asf-core services/asf-worker services/asf-ai services/asf-context"
@$(PIP) install -e packages/asf-domain -e services/asf-core -e services/asf-worker -e services/asf-ai -e services/asf-context
@echo "▶ npm install (asf-web)"
@cd $(FRONTEND_DIR) && npm install
clean:
find . -type d -name __pycache__ -prune -exec rm -rf {} + 2>/dev/null || true
find . -type d -name ".pytest_cache" -prune -exec rm -rf {} + 2>/dev/null || true
find . -type d -name ".next" -prune -exec rm -rf {} + 2>/dev/null || true
rm -f .coverage .coverage.*
@echo "🧹 cache puliti"
+197
View File
@@ -0,0 +1,197 @@
# Azurian Software Factory (ASF)
> From Requirement to Production, everything is contextualized, traceable and AI-accessible.
ASF is an AI-native, multi-tenant **Software Factory delivered as a SaaS platform**.
It creates a persistent contextual and traceability layer connecting the complete software lifecycle:
```
Requirement → Architecture → Task → Code → Commit → Pull Request → Build
→ Test → Deployment → Runtime → Incident → Feedback
```
**Central principle:** *Everything is an Artifact.*
---
## 🏗️ Repository Structure
ASF is organized as a **monorepo**:
```text
ASF/
├── AGENT.md ← AI engineering rules (MANDATORY READ)
├── README.md ← This file
├── docker-compose.yml ← Local infra (Mongo, Rabbit, Redis, MinIO)
├── .env.example ← Environment template
│
├── docs/
│ ├── architecture/ ← Architecture overviews, diagrams
│ ├── adr/ ← Architectural Decision Records
│ └── api/ ← API documentation
│
├── frontend/
│ └── asf-web/ ← Next.js + React SaaS frontend
│
├── services/
│ ├── asf-core/ ← FastAPI: orgs, projects, artifacts, jobs, audit
│ ├── asf-context/ ← Context engine, artifact traversal
│ ├── asf-ai/ ← AI Gateway: model abstraction, routing
│ └── asf-worker/ ← RabbitMQ consumers / async jobs
│
├── packages/
│ ├── asf-domain/ ← Shared Pydantic domain models
│ ├── asf-events/ ← Event schemas (RabbitMQ payloads)
│ └── asf-connectors/ ← External system integration interfaces
│
├── infrastructure/
│ ├── docker/ ← Per-service Dockerfiles
│ └── scripts/ ← Dev / bootstrap scripts
│
└── tests/ ← Cross-service integration tests
```
---
## 🧱 Technology Stack
| Layer | Stack |
|-------------|------------------------------------------------------|
| Frontend | Next.js · React · TypeScript · Tailwind · shadcn/ui |
| Frontend+ | TanStack Query · React Flow · Zustand (sparingly) |
| Backend | Python 3.12+ · FastAPI · Pydantic · async/await |
| Database | MongoDB 7 (async driver) |
| Messaging | RabbitMQ 3.13 (events + job queues) |
| Cache | Redis 7 |
| Storage | MinIO / S3-compatible object storage |
| Testing | pytest (Python) · Vitest/Jest (TS) |
---
## 🚀 Local Development
### Prerequisites
- Docker & Docker Compose
- Python 3.12+
- Node.js 20+
- pnpm or npm
### 1. Start infrastructure
```bash
cp .env.example .env
docker compose up -d
```
This starts:
- **MongoDB** on `localhost:27017`
- **RabbitMQ** on `localhost:5672` (management UI on `:15672`)
- **Redis** on `localhost:6379`
- **MinIO** on `localhost:9000` (console on `:9001`)
### 2. Install backend packages
```bash
# Shared domain package
cd packages/asf-domain && pip install -e . && cd ../..
cd packages/asf-events && pip install -e . && cd ../..
cd packages/asf-connectors && pip install -e . && cd ../..
# Core service
cd services/asf-core && pip install -r requirements.txt && cd ../..
```
### 3. Run asf-core API
```bash
cd services/asf-core
uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
```
OpenAPI docs:
- Swagger UI: http://localhost:8000/docs
- ReDoc: http://localhost:8000/redoc
- Health: http://localhost:8000/api/v1/health
### 4. Install & run frontend
```bash
cd frontend/asf-web
npm install
npm run dev
```
UI: http://localhost:3000
### 5. Run workers (optional for full async pipeline)
```bash
cd services/asf-worker
pip install -r requirements.txt
python -m app.worker
```
---
## 🔑 SaaS Multi-Tenancy Model
```
Organization (tenant boundary)
└── Teams
└── Projects
└── Artifacts + Relationships + Jobs
```
Every tenant-owned record carries `tenant_id`. Cross-tenant queries are blocked at the service/repository layer.
---
## 🧩 Services Quick Reference
| Service | Purpose |
|---------------|--------------------------------------------------------------|
| `asf-core` | REST API · Organizations · Projects · Artifacts · Jobs · Audit |
| `asf-context` | Context assembly · Relationship traversal · Semantic search |
| `asf-ai` | AI Gateway · Provider abstraction · Usage metering |
| `asf-worker` | RabbitMQ consumers · Async job execution |
| `asf-web` | Next.js Enterprise SaaS UI shell |
---
## 🧪 Testing
```bash
# Python (all backend services + packages)
pytest packages/asf-domain/tests
pytest services/asf-core/tests
```
---
## 📚 Documentation
- Architecture overview: [docs/architecture/](docs/architecture/)
- Architectural Decision Records: [docs/adr/](docs/adr/)
- API docs (auto): http://localhost:8000/docs
- AGENT.md (MANDATORY engineering rules for contributors): [AGENT.md](AGENT.md)
---
## 🛡️ Security
- Never commit `.env`, credentials, or private keys.
- Never expose infrastructure connections to the frontend.
- All tenant-scoped queries are validated against authenticated context.
- Audit events are written for sensitive mutations.
---
## 🤝 Guiding Question
When contributing any feature, ask:
> *Does this improve the connection between software context, traceability, automation and AI?*
If not — reconsider the implementation.
+705
View File
@@ -0,0 +1,705 @@
# Azurian Software Factory (ASF) - SaaS Platform Bootstrap
You are the lead software architect and senior full-stack engineer responsible for creating the initial foundation of **Azurian Software Factory (ASF)**.
## Product Vision
ASF is an AI-native Software Factory delivered as a multi-tenant SaaS platform.
Tagline:
> From Requirement to Production, everything is contextualized, traceable and AI-accessible.
ASF is NOT simply an AI coding assistant.
ASF creates a persistent contextual and traceability layer connecting:
Requirement → Architecture → Task → Code → Commit → Pull Request → Build → Test → Deployment → Runtime → Incident → Feedback
The central principle is:
> Everything is an Artifact.
Every relevant element of the software lifecycle must be represented as an ASF Artifact and may have relationships with other artifacts.
The platform must allow AI agents to understand not only source code, but WHY the code exists, which requirement originated it, how it was tested, where it was deployed, and what happened during runtime.
---
# 1. Technology Stack
Use the following stack.
## Frontend
- Next.js
- React
- TypeScript
- Tailwind CSS
- shadcn/ui
- TanStack Query
- Zustand only when client-side global state is actually necessary
- React Flow for traceability/context graphs
- SSE or WebSocket for real-time updates
The frontend must be:
- responsive
- desktop optimized
- usable on tablets
- mobile friendly
- component based
- accessible
- suitable for an enterprise SaaS application
Do not access MongoDB directly from Next.js.
All business operations must go through ASF backend APIs.
## Backend
Use:
- Python 3.12+
- FastAPI
- Pydantic
- async/await
- MongoDB
- official async MongoDB driver
- RabbitMQ
- Redis
- MinIO/S3-compatible object storage
The backend must be designed API-first.
## Messaging
RabbitMQ is the ASF event backbone.
It must support both:
1. asynchronous jobs
2. domain events
Examples:
- requirement.created
- requirement.updated
- artifact.created
- artifact.updated
- code.analysis.requested
- code.analysis.completed
- repository.index.requested
- repository.index.completed
- ai.inference.requested
- ai.inference.completed
- test.requested
- test.completed
- deployment.started
- deployment.completed
Do not tightly couple producers to consumers.
## AI
Create an abstraction called:
`ASF AI Gateway`
No ASF service should depend directly on a specific LLM provider.
The AI Gateway will eventually support:
- NVIDIA DGX Spark hosted models
- OpenAI-compatible local APIs
- external AI providers
- model routing
- tenant AI policies
- usage metering
- priority queues
- cost tracking
For the first implementation, create interfaces and mock implementations where necessary.
Do not hardcode a particular model into business logic.
## Integration
ASF must support:
- MCP
- REST APIs
- Webhooks
External systems such as Git providers, requirement management systems, CI/CD platforms, documentation platforms and observability tools must be implemented through connectors.
The ASF Core must not become tightly coupled to Jira, GitHub, GitLab, Jenkins, SonarQube, Notion or similar products.
---
# 2. Core Domain
Implement the initial domain model.
Main entities:
- Organization
- User
- Team
- Project
- Artifact
- ArtifactRelationship
- Integration
- Job
- AIExecution
- AuditEvent
Every tenant-owned entity must include:
- tenant_id
- created_at
- updated_at
where applicable.
Never trust a tenant ID supplied by the client without validating it against the authenticated context.
---
# 3. Artifact Model
Artifact is one of the most important entities in ASF.
Example artifact types:
- requirement
- epic
- story
- task
- architecture_decision
- document
- source_file
- commit
- pull_request
- build
- test
- security_finding
- deployment
- release
- runtime_event
- incident
Design Artifact to support extensible metadata.
Conceptually:
Artifact
- id
- tenant_id
- project_id
- type
- external_id
- title
- content
- source
- metadata
- relationships
- version
- created_at
- updated_at
Do not create separate rigid database models for every possible artifact type unless there is a strong domain reason.
ASF must remain extensible.
---
# 4. Traceability
Artifacts can have relationships such as:
- implements
- implemented_by
- verifies
- verified_by
- generated_from
- documented_by
- depends_on
- affects
- deployed_as
- caused_by
- supersedes
- related_to
Create APIs allowing relationships to be created, queried and traversed.
Example:
REQ-142
→ implemented_by
→ TASK-871
→ implemented_by
→ COMMIT-a87f1c
→ verified_by
→ TEST-882
→ deployed_as
→ RELEASE-2.4
Design this so that a graph database could eventually be introduced without rewriting the entire application.
MongoDB is the initial source of truth.
---
# 5. Context Engine
Create an `asf-context` module/service responsible for assembling context for AI operations.
Given an artifact, project or user request, it should eventually be able to retrieve:
- related requirements
- architecture decisions
- relevant source code
- documentation
- tests
- deployments
- previous AI analysis
- incidents
- runtime information
For the MVP, implement the interfaces and basic MongoDB relationship traversal.
Keep semantic/vector retrieval behind an abstraction so a vector engine can be introduced later.
---
# 6. Job System
Create a persistent Job model.
A job should include at minimum:
- id
- tenant_id
- project_id
- type
- priority
- status
- requested_by
- worker
- model
- correlation_id
- created_at
- started_at
- completed_at
- error
- usage
Usage may contain:
- input_tokens
- output_tokens
- gpu_seconds
- estimated_cost
Suggested statuses:
- queued
- running
- completed
- failed
- cancelled
RabbitMQ contains work messages.
MongoDB contains persistent job state.
Do not treat RabbitMQ as the permanent job database.
---
# 7. Queue Strategy
Prepare separate logical queues for workloads such as:
- asf.ai.interactive
- asf.ai.background
- asf.indexing
- asf.code_analysis
- asf.testing
- asf.devops
- asf.notifications
Interactive developer requests must be capable of receiving higher priority than background indexing jobs.
Workers must be horizontally scalable.
Do not maintain critical worker state only in process memory.
---
# 8. SaaS Requirements
ASF must be multi-tenant from the beginning.
Design for:
Organization
→ Teams
→ Projects
→ Artifacts
Prepare the architecture for:
- RBAC
- SSO
- API keys
- audit
- quotas
- usage metering
- subscriptions
- billing
Full billing implementation is NOT required in the first bootstrap.
Create boundaries/interfaces so it can be introduced cleanly.
---
# 9. Frontend
Create an enterprise SaaS shell.
Main navigation:
- Overview
- Requirements
- Context
- Code
- Traceability
- AI Agents
- Pipelines
- Deployments
- Quality
- Observability
- Integrations
- Usage
- Settings
Create organization and project selectors.
## Overview
Initial project dashboard should support cards for:
- project health
- requirements
- implementation coverage
- test coverage
- traceability
- deployments
- active AI jobs
- failed builds
- security findings
Mock unavailable metrics initially.
Clearly identify mocked/demo data.
## Context Explorer
Create an initial Context Explorer where a user can select an Artifact and inspect:
- metadata
- relationships
- related artifacts
- source
- history
## Traceability
Use React Flow to visualize artifact relationships.
Example:
Requirement
→ Task
→ Pull Request
→ Test
→ Build
→ Deployment
Nodes must be clickable.
## Ask ASF
Create a persistent command/chat interface accessible from the application shell.
Concept:
`Ask ASF...`
Future queries include:
- Show requirements without tests.
- Why did yesterday's build fail?
- Which services implement REQ-441?
- What will be affected if REQ-441 changes?
- Generate tests for TASK-921.
For now create the UI and backend interface.
Do not fake AI answers as if they were real production analysis.
---
# 10. Repository Structure
Start with a monorepo.
Suggested structure:
```text
asf/
├── AGENT.md
├── README.md
├── docs/
│ ├── architecture/
│ ├── adr/
│ └── api/
│
├── frontend/
│ └── asf-web/
│
├── services/
│ ├── asf-core/
│ ├── asf-context/
│ ├── asf-ai/
│ └── asf-worker/
│
├── packages/
│ ├── asf-domain/
│ ├── asf-events/
│ └── asf-connectors/
│
├── infrastructure/
│ ├── docker/
│ └── scripts/
│
└── tests/
```
Do not create microservices merely for architectural appearance.
Initially keep service boundaries pragmatic.
---
# 11. Initial Services
## asf-core
Responsible for:
- organizations
- projects
- artifacts
- relationships
- jobs
- audit
- public REST API
## asf-context
Responsible for:
- context assembly
- artifact traversal
- future semantic retrieval
## asf-ai
Responsible for:
- AI Gateway
- model abstraction
- inference routing
- usage tracking
## asf-worker
Responsible for:
- RabbitMQ consumers
- asynchronous execution
- background jobs
---
# 12. Observability
Use structured JSON logging.
Every distributed operation should propagate:
- correlation_id
- tenant_id
- project_id
- job_id when applicable
Prepare the architecture for OpenTelemetry.
Never log:
- passwords
- API keys
- access tokens
- private secrets
- complete authentication headers
---
# 13. API Standards
Use REST initially.
API prefix:
`/api/v1`
Provide:
`GET /api/v1/health`
Use consistent error structures.
Generate OpenAPI documentation automatically through FastAPI.
Use UUIDs for internal identifiers unless there is a documented reason not to.
---
# 14. Security
Security must not be postponed until production.
Apply:
- tenant isolation
- input validation
- secret separation
- authorization boundaries
- secure defaults
- audit logging
Never store secrets directly inside source code.
Use environment variables/configuration for secrets.
Never expose infrastructure credentials to the frontend.
---
# 15. Engineering Rules
Follow SOLID principles where useful, but avoid unnecessary abstraction.
Prefer:
- simple code
- explicit interfaces
- dependency injection
- typed models
- testable components
- clear boundaries
Avoid:
- premature optimization
- excessive microservices
- giant service classes
- hidden global state
- provider-specific logic inside the domain layer
- unnecessary framework wrappers
---
# 16. Testing
Create the foundation for:
- unit tests
- API integration tests
- worker tests
- tenant isolation tests
Use pytest for Python.
Critical domain logic must have tests.
---
# 17. Documentation
Create:
- README.md
- local development instructions
- architecture overview
- environment variable example
- initial API documentation
- ADR directory
Architecture decisions that materially affect ASF must be documented as ADRs.
---
# 18. Bootstrap Objective
Do NOT attempt to implement the complete ASF product in one pass.
The first objective is to produce a clean, runnable foundation containing:
1. repository structure
2. FastAPI backend
3. MongoDB connectivity
4. RabbitMQ connectivity
5. Redis connectivity
6. MinIO connectivity
7. Organization model
8. Project model
9. Artifact model
10. Artifact Relationship model
11. Job model
12. CRUD APIs for the essential entities
13. basic RabbitMQ producer/consumer
14. AI Gateway abstraction
15. Context Engine abstraction
16. Next.js application shell
17. initial Overview page
18. initial Artifact Explorer
19. initial React Flow Traceability view
20. Ask ASF UI shell
21. health endpoints
22. tests
23. documentation
Everything must run locally for development.
---
# 19. Before Coding
Before generating large amounts of code:
1. Inspect the existing repository.
2. Read `AGENT.md`.
3. Do not overwrite useful existing work.
4. Produce a short implementation plan.
5. Identify architectural assumptions.
6. Then implement incrementally.
7. Run tests after meaningful changes.
8. Fix failures before continuing.
When uncertain, prefer the simplest architecture compatible with the ASF principles.
+654
View File
@@ -0,0 +1,654 @@
# ASF — Guida Primi Passi: Creare il tuo primo Progetto
> **ASF = Azurian Software Factory** · *SaaS Platform multi-tenant AI-native*
>
> **Motto**: *"From Requirement to Production — everything is contextualized, traceable and AI-accessible."*
>
> **Principio centrale**: ***Everything is an Artifact*** — requisiti, task, PR, commit, build, test, deploy, incidenti, analisi AI, segnalazioni di sicurezza: tutto è un Artifact ASF collegato da Relazioni semantiche. Questo permette agli agenti AI di capire **PERCHÉ** un pezzo di codice esiste (non solo **cosa** fa), tracciandone l'origine, i test, il ciclo di rilascio e gli effetti in produzione.
Questa guida ti porta da **zero a primo grafo di traceability end-to-end funzionante** in 8 piccoli step, usando direttamente le API REST di ASF Core e verificando ogni risultato sul Frontend.
---
## 🧱 Stack Tecnologico (da Specifiche ASF)
ASF è disegnato come "pragmatic monolith → services": **nessun microservizio prematuro** per apparenza architetturale.
| Layer | Tecnologie | Stato MVP |
|---|---|---|
| 🎨 **Frontend** | Next.js 14 · React · TypeScript · Tailwind · shadcn/ui · TanStack Query v5 · React Flow · SSE/WebSocket realtime | ✅ attivo su :3000 |
| ⚙️ **Backend Core (asf-core)** | Python 3.12 · FastAPI · Pydantic v2 · async/await | ✅ attivo su :8000 · OpenAPI `/docs` |
| 🗄️ **Persistence** | **MongoDB rs0** (Motor async) · 2 nodi replica set | ✅ PRIMARY 172.19.40.102 · SEED 172.19.40.103 |
| 📢 **Messaging Backbone** | **RabbitMQ** (aio_pika connect_robust) · vhost `asf` · **2 canali logici**: (1) **Async Jobs** su code DIRECT (2) **Domain Events** (requirement.created / deployment.completed) | ✅ 7 code DIRECT iscritte dal worker |
| 🤖 **AI Layer (asf-ai)** | **ASF AI Gateway = ASTRAZIONE TOTALE** · Zero accoppiamento diretto a provider LLM · Supporta NVIDIA DGX Spark · OpenAI-compatibile · Custom provider. Futuri: model routing · tenant AI policies · usage metering · priority queues · cost tracking | ✅ astrazione + MockProvider (7/7 tests PASS) |
| 📚 **Context Service (asf-context)** | BFS relazioni + (futuro) semantic retrieval vettoriale · Design preparato per **passare in futuro a graph DB senza re-write totale** (Mongo è source of truth MVP ma le relazioni sono disaccoppiate) | ✅ ContextEngine BFS depth=2 · 4 tests PASS |
| 🧰 **Worker (asf-worker)** | Job consumer orizzontalmente scalabile · NO stato critico in memoria · Echo handler MVP | ✅ loop while-true respawn 5 jobs echo completati |
| 🔌 **Osservabilità** | Structured logging JSON · correlation_id + tenant_id + project_id + job_id propagati in tutta distribuzione · OpenTelemetry-ready | ✅ logging strutturato + correlation middleware |
| ⏭️ **(Futuri)** | Redis caching / pubsub · MinIO/S3 object storage · Git/CI/Docs/Observability connectors · MCP · Webhooks | ⏭️ boundary presenti, non MVP attivo |
---
## 📋 0. Prerequisiti
ASF è già avviato in locale, senza Docker: verificalo con `make status`.
| Comando | Descrizione |
|---|---|
| `make start` | Avvia ASF Core (FastAPI :8000) + ASF Web (Next.js :3000) + Worker |
| `make status` | Controlla PIDs, porte, health check, ultimi log |
| `make stop` | Ferma tutto in modo pulito (SIGTERM → SIGKILL fallback) |
| `make lint` | pytest 5 packages + tsc strict no-emit |
**Health check (Core)**:
```bash
curl -s http://localhost:8000/api/v1/health | python -m json.tool
```
**Frontend**: apri `http://localhost:3000` nel browser.
---
## 🧭 Modello ASF: Multi-Tenant + Everything is an Artifact
```
┌──────────────────────────────────────────────────────────────────┐
│ Organization (Tenant) │ ← SSO · API keys · quotas · subscriptions · billing (§8)
│ └─ Team ──────────────────────────────────────────────────────┐ │ ← ⏭️ Futuro: RBAC · membership · permissioni per team
│ └─ Project ─────────────────────────────────────────────┐ │ │ ← Reale già oggi. Ogni Artifact appartiene a 1 Project
│ └─ Artifact (17 tipi) ←── Relationship ─→ Artifact │ │ │
└──────────────────────────────────────────────────────────────────┘
```
1. **Organization = Tenant** · es. `azurian-labs` (il tuo team/cliente, **isolamento dati perimetrale**).
- Futuro: SSO (OIDC / SAML) · API keys scopes · audit retention · subscription plan · metering · billing boundaries (non MVP, ma già boundary nel codice).
2. **Team** (⏭️ layer futuro incluso per compatibilità futura) · gruppi di utenti dentro Org con ruoli e permissioni RBAC (Owner / Admin / Editor / Viewer). MVP oggi salta il layer Team per semplicità ma lo include nel modello per non doverlo aggiungere dopo con migration.
3. **Project** · dentro una Org (e opzionalmente Team), es. `ASF` (il prodotto/servizio). **Ogni Artifact appartiene SEMPRE a un Project** (campo `project_id` obbligatorio nel `ArtifactCreate`).
4. **Artifact** (17 tipi, enum `ArtifactType` — unica sorgente di verità condivisa package `asf-domain`):
`requirement, epic, story, task, architecture_decision, document, source_file, commit, pull_request, build, test, security_finding, deployment, release, runtime_event, incident, ai_analysis`
- **Nessun modello separato per tipo** (regola §2 estensibilità). Ogni tipo è esteso col campo libero `metadata: dict[str, Any]`.
5. **Relationship** (12 predicati semantici, enum `RelationshipType` — anche questo condiviso dal package domain):
`implements, implemented_by, verifies, verified_by, generated_from, documented_by, depends_on, affects, deployed_as, caused_by, supersedes, related_to`
- Relazioni sono **directional** (source → target), quindi "il PR è generated_from dal TASK" e non viceversa.
---
## 🔑 Headers Dev-Trust (Importante!)
In locale ASF usa una modalità "dev-trust" semplificata: **per ogni richiesta aggiungi questi 2 header** per impersonare utente e tenant. **Senza questi header** le `GET /projects` e `POST /artifacts` etc. restituiranno 401 o lista vuota.
```
X-ASF-Tenant-Id: <UUID oppure slug-style deterministico>
X-ASF-User-Id: <UUID oppure stile u-<nome>>
```
Per questa guida useremo due valori coerenti (sono accettate UUID 128-bit o fallback testuali deterministici):
```
TENANT="t-demo-003"
USER="u-mario"
```
Salviamoli come **variabili ambientali** nel terminale così da non ripeterli ogni volta:
```bash
export TENANT="t-demo-003"
export USER="u-mario"
export H="http://localhost:8000/api/v1"
export H3K="http://localhost:3000/api/v1"
# Alias helper (conservali nella shell corrente):
auth() { echo "-H X-ASF-Tenant-Id:$TENANT -H X-ASF-User-Id:$USER"; }
GET() { curl -s $(auth) "$H$1"; }
POST() { curl -s -X POST $(auth) -H "Content-Type: application/json" -d @- "$H$1"; }
PATCH(){ curl -s -X PATCH $(auth) -H "Content-Type: application/json" -d @- "$H$1"; }
DEL() { curl -s -X DELETE $(auth) "$H$1"; }
```
> Nota: `$H3K` (:3000) è l'endpoint Next.js con **rewrites trasparenti** → `/api/v1/*` → `localhost:8000/api/v1/*`. La risposta è **byte-identica** al Core, utile per testare i rewrite del frontend.
---
## ✅ Step 1 — Crea la tua Organization (Tenant)
**Payload**: `OrganizationCreate { name, slug, plan?, is_active? }`
- `slug`: **min 3, max 60 caratteri**, regex `^[a-z0-9][a-z0-9\-]{1,58}[a-z0-9]$` (tutto minuscolo, trattini, inizia e finisce con alfanum).
```bash
cat <<'JSON' | POST /organizations
{
"name": "Azurian Labs Demo",
"slug": "azurian-labs-demo",
"plan": "pro",
"is_active": true
}
JSON
```
**Attesa** → HTTP 201, risposta simile a:
```json
{
"id": "0191f…", "name": "Azurian Labs Demo",
"slug": "azurian-labs-demo", "plan": "pro", "is_active": true,
"created_at": "…", "updated_at": "…"
}
```
Prendi nota dell'`id` (UUID) dell'Organization:
```bash
ORG_ID=$(GET /organizations | python -c 'import json,sys; print(json.load(sys.stdin)[0]["id"])')
echo "ORG_ID=$ORG_ID"
```
Verifica anche la rotta `/slug`:
```bash
GET /organizations/slug/azurian-labs-demo | python -m json.tool
```
---
## ✅ Step 2 — Crea il tuo primo Project
**Payload**: `ProjectCreate { name, key, description?, default_branch?, is_active? }`
- `key`: **min 2, max 20** caratteri, regex `^[A-Z][A-Z0-9]{1,19}$` (maiuscolo, prima lettera, resto lettere/numeri). Utile come prefisso leggibile per i riferimenti.
```bash
cat <<'JSON' | POST /projects
{
"name": "ASF SaaS MVP",
"key": "ASF",
"description": "Minimal Viable Product di ASF: dal requisito al deploy con tracciabilità end-to-end.",
"default_branch": "main",
"is_active": true
}
JSON
```
→ **HTTP 201 Created**. Prendi l'`id`:
```bash
PROJ_ID=$(GET /projects | python -c 'import json,sys; print(json.load(sys.stdin)[0]["id"])')
echo "PROJ_ID=$PROJ_ID"
```
**Frontend check**: apri `http://localhost:3000/` (Overview Dashboard). Vedrai il nuovo project nel conteggio `projects` (nota: Overview aggrega per tenant, non per singolo progetto).
---
## ✅ Step 3 — Crea 4 Artifact Base (REQ / TASK / PR / TEST)
Ora iniziamo il "ciclo di vita del software" che sarà il nostro primo grafo.
**Il campo `tenant_id` NON va passato** — viene iniettato server-side dagli header.
**Il campo `project_id` VA SEMPRE PASSATO** (l'Organization può avere più progetti).
Creiamo 4 artifact nello stesso project:
```bash
REQ=$(cat <<JSON | POST /artifacts | python -c 'import json,sys; print(json.load(sys.stdin)["id"])')
{
"project_id": "$PROJ_ID",
"type": "requirement",
"external_id": "REQ-101",
"title": "Utente può autenticarsi con SSO Google",
"content": "Gli utenti finali devono poter accedere via OAuth2/OpenID Connect a Google. Accesso in SSO senza password locale. Salvare email, name, sub in anagrafica.",
"status": "open",
"source": "product-board",
"metadata": { "priority": "P0", "estimated_points": 13, "target_release": "v1.2" }
}
JSON
echo "REQ-101 = $REQ"
TASK=$(cat <<JSON | POST /artifacts | python -c 'import json,sys; print(json.load(sys.stdin)["id"])')
{
"project_id": "$PROJ_ID",
"type": "task",
"external_id": "TASK-201",
"title": "Implementa OAuth2 Google flow + callback route",
"content": "Backend: route /auth/google /auth/callback. Salvare token in HttpOnly cookie. Rate limit login endpoint.",
"status": "in_progress",
"source": "github",
"metadata": { "assignee": "Giulia", "points": 8, "sprint": 8 }
}
JSON
echo "TASK-201 = $TASK"
PR=$(cat <<JSON | POST /artifacts | python -c 'import json,sys; print(json.load(sys.stdin)["id"])')
{
"project_id": "$PROJ_ID",
"type": "pull_request",
"external_id": "PR#144",
"title": "feat(auth): SSO Google OAuth2 flow",
"status": "open",
"source": "github",
"content": "Implementazione del flow OAuth2 con route di callback + refresh token rotation. + tests",
"metadata": { "branch": "feat/google-sso", "author": "Giulia", "ci": "passed", "reviewers": 2 }
}
JSON
echo "PR#144 = $PR"
TEST=$(cat <<JSON | POST /artifacts | python -c 'import json,sys; print(json.load(sys.stdin)["id"])')
{
"project_id": "$PROJ_ID",
"type": "test",
"external_id": "TEST-41",
"title": "auth/google_test.py — SSO happy path + no-refresh-token reuse",
"content": "Pytest suite: OAuth state parameter unguessable, callback 200 con code corretto, refresh invalidato dopo 1 uso.",
"status": "done",
"source": "pytest",
"metadata": { "framework": "pytest", "duration_ms": 480, "coverage_affected_files": 5 }
}
JSON
echo "TEST-41 = $TEST"
```
Verifica 4 artifact creati:
```bash
GET /artifacts?limit=100 | python -c '
import json,sys
from collections import Counter
d = json.load(sys.stdin)
print("Totale:", len(d))
for t, c in Counter(a["type"] for a in d).most_common():
print(f" - {t:18s} {c}")
'
```
Apri `http://localhost:3000/context` → nell'Artifact Picker a sinistra compaiono i 4 nuovi elementi; clicca REQ-101. Per ora Relationships e Related sono vuote: Step 4 li collega.
---
## ✅ Step 4 — Collega gli Artifact con 3 Relazioni
**Payload**: `ArtifactRelationshipCreate { relationship_type, source_artifact_id, target_artifact_id, metadata? }`
- **Regola semantica**: `source <relationship_type> target`
- `TASK implements REQUIREMENT`
- `PR generated_from TASK`
- `TEST verifies PR`
- Non serve passare `tenant_id`, `source_project_id`, `target_project_id` (validati server-side).
```bash
# 1. TASK implements REQ (il task realizza il requisito)
cat <<JSON | POST /relationships
{ "relationship_type": "implements", "source_artifact_id": "$TASK", "target_artifact_id": "$REQ" }
JSON
# 2. PR generated_from TASK (il PR nasce dal task)
cat <<JSON | POST /relationships
{ "relationship_type": "generated_from", "source_artifact_id": "$PR", "target_artifact_id": "$TASK" }
JSON
# 3. TEST verifies PR (il test prova che il PR funziona)
cat <<JSON | POST /relationships
{ "relationship_type": "verifies", "source_artifact_id": "$TEST", "target_artifact_id": "$PR" }
JSON
```
Ogni risposta HTTP 201 include un `id` e i riferimenti incrociati.
### Verifica: Context Explorer → Graph
Apri `http://localhost:3000/context`, seleziona **REQ-101**, poi tab **Graph** (4° tab):
- 🟦 **Centro** → REQ-101 (badge **SELECTED**)
- 🟣 **Anello interno HOP 1** → TASK-201 (relazione SOLIDA verde «implements»)
- ⬜ **Anello esterno HOP 2** → PR#144 (raggiunto via TASK, edge TRATTEGGIATO violetto «generated_from»)
- ⬜ **Anello esterno HOP 2** → TEST-41 (raggiunto via PR, edge TRATTEGGIATO azzurro «verifies»)
**Click sui nodi** nel grafo: il dettaglio cambia e si ricentra sul nuovo artifact (stesso comportamento del picker laterale).
MiniMap in basso-dx mostra 3 colori distinti: `indigo = 0, violet = 1, slate = 2`.
---
## ✅ Step 5 — Aggiungi Deploy + Incidente + Correlazione Sicurezza
Facciamo un "giorno dopo": il PR va in produzione ma causa un incidente, e documentiamo anche un security finding che blocca il deploy.
```bash
BUILD=$(cat <<JSON | POST /artifacts | python -c 'import json,sys; print(json.load(sys.stdin)["id"])')
{ "project_id": "$PROJ_ID", "type": "build", "external_id": "BUILD-909",
"title": "asf-auth:v1.2.0-rc3 build nightly",
"status": "passed", "source": "buildkite",
"metadata": { "builder": "buildkite-linux-x86-64", "duration_s": 128, "cache_hit_pct": 68 } }
JSON
DEP=$(cat <<JSON | POST /artifacts | python -c 'import json,sys; print(json.load(sys.stdin)["id"])')
{ "project_id": "$PROJ_ID", "type": "deployment", "external_id": "DEP-12",
"title": "asf-auth → prod-west-2 (canary 5%)",
"status": "deployed", "source": "argo",
"metadata": { "env": "prod", "region": "us-west-2", "canary_pct": 5, "rollback_ref": "BUILD-890" } }
JSON
SEC=$(cat <<JSON | POST /artifacts | python -c 'import json,sys; print(json.load(sys.stdin)["id"])')
{ "project_id": "$PROJ_ID", "type": "security_finding", "external_id": "SEC-031",
"title": "Google OAuth state vulnerable to CSRF (weak random)",
"content": "Il parametro 'state' usa secrets.token_urlsafe(8) invece di 32 byte. Possibile replay cross-domain.",
"status": "open", "source": "semgrep",
"metadata": { "severity": "high", "cvss": 7.8, "detector": "ASF-SEC-203", "confidence": 0.92 } }
JSON
INC=$(cat <<JSON | POST /artifacts | python -c 'import json,sys; print(json.load(sys.stdin)["id"])')
{ "project_id": "$PROJ_ID", "type": "incident", "external_id": "INC-5",
"title": "Login failure spike (canary west-2) — 14% errori 5xx",
"content": "Canary deploy asf-auth:v1.2.0-rc3 ha generato picco refresh_token invalid_grant. Rollback a BUILD-890 dopo 7 minuti.",
"status": "resolved", "source": "pagerduty",
"metadata": { "sev": "sev2", "mttr_min": 14, "users_affected": 820, "pager": "Mario" } }
JSON
echo "BUILD=$BUILD DEP=$DEP SEC=$SEC INC=$INC"
# Relazioni
cat <<JSON | POST /relationships # BUILD generated_from PR
{ "relationship_type": "generated_from", "source_artifact_id": "$BUILD", "target_artifact_id": "$PR" }
JSON
cat <<JSON | POST /relationships # DEP deployed_as BUILD
{ "relationship_type": "deployed_as", "source_artifact_id": "$DEP", "target_artifact_id": "$BUILD" }
JSON
cat <<JSON | POST /relationships # SEC related_to PR
{ "relationship_type": "related_to", "source_artifact_id": "$SEC", "target_artifact_id": "$PR" }
JSON
cat <<JSON | POST /relationships # INC caused_by DEP
{ "relationship_type": "caused_by", "source_artifact_id": "$INC", "target_artifact_id": "$DEP" }
JSON
```
### Vai su → `/traceability` (Graph globale)
Apri `http://localhost:3000/traceability`: dovresti vedere un grafo **8 nodi, 7 edges** circa, disposto a layer.
- **Inspector** a dx: clicca INC-5 → vedrai che è caused_by DEP-12, che deployed_as BUILD-909, e così via a ritroso fino a REQ-101.
- Auto-refresh ogni 5 secondi. MiniMap e controlli sono gli stessi del Context Graph, ma QUI vedi **tutti gli artifact del progetto**.
---
## ✅ Step 6 — Lancia il tuo primo Job (AI Interactive)
> 🎯 **Regola di design ASF §7**: lo stato **permanente** di un Job (status, result, usage, error, worker, timestamps) **vive SEMPRE su MongoDB**.
> RabbitMQ viene usato **SOLO come coda di messaggi per il passaggio lavoro**, MAI come database.
> Se RabbitMQ cadesse, i job non si perdono: restano `status=queued` o `running` su Mongo e possono essere risottomessi o ripresi. Il Worker scala orizzontalmente senza stato critico in memoria.
ASF gestisce il lavoro asincrono con **Jobs** su code RabbitMQ DIRECT. 7 code logiche DIRECT già registrate (priorità decrescente):
`asf.ai.interactive > asf.ai.background > asf.testing > asf.code_analysis > asf.devops > asf.notifications > asf.indexing`
Prova con 3 job demo (echo handler integrato nel worker):
```bash
# Job 1 - DevOps
cat <<'JSON' | POST /jobs/submit
{ "queue": "devops", "job_type": "devops", "title": "deploy staging preflight",
"payload": { "service": "asf-auth", "env": "staging", "mode": "preflight" },
"priority": 50 }
JSON
# Job 2 - Testing
cat <<'JSON' | POST /jobs/submit
{ "queue": "testing", "job_type": "testing", "title": "regression suite auth",
"payload": { "suite": "auth/google_test.py", "cases": 12, "fail-fast": true },
"priority": 70 }
JSON
# Job 3 - Code Analysis (smell detection)
cat <<'JSON' | POST /jobs/submit
{ "queue": "code_analysis", "job_type": "code_analysis", "title": "ruff check src/auth",
"payload": { "path": "src/auth/oauth2.py", "linters": ["ruff","pyright"], "fail_on_error": true },
"priority": 40 }
JSON
```
Controlla lo stato in **Jobs List** (sidebar → **Jobs (Activity)** o vai a `http://localhost:3000/jobs`):
- Auto-refresh ogni 3 secondi, 6 stat cards in alto (Running / Queued / Completed / Failed)
- Clicca una riga → modale **Detail JSON** per vedere `result`, `usage` (cpu_seconds, input/output tokens, cost).
- **Enqueue Modal** (top-right) per creare altri job dal frontend.
Dopo 5-10s gli echo handler (`EchoHandler`) completeranno i jobs → status **COMPLETED** con body `{ echo: …, handled_by: worker_id, timestamp: … }`.
### Verifica API count_by_status:
```bash
GET /jobs | python -c '
import json,sys
from collections import Counter
d = json.load(sys.stdin)
print("Jobs totali:", len(d))
for s, c in Counter(j.get("status","?") for j in d).most_common(): print(f" {s:12s} {c}")
'
```
---
## ✅ Step 7 — Code Analysis: Carica Source + Mark Smell come Fixed
Apri `/code` (sidebar **Code Analysis**). La pagina usa **2 useQuery separate**:
1. `type=source_file` (tutti i sorgenti caricati)
2. `type=ai_analysis` filtrato client-side `metadata.kind == "code_smell"` (code smell)
Carichiamo un file Python con 1 smell **ASF-SEC-203** (lo stesso della security finding), poi cliccheremo **Mark as fixed** nel frontend.
```bash
SRC=$(cat <<JSON | POST /artifacts | python -c 'import json,sys; print(json.load(sys.stdin)["id"])')
{ "project_id": "$PROJ_ID", "type": "source_file",
"external_id": "SRC-AUTH-OAUTH",
"title": "src/auth/oauth2.py",
"status": "active", "source": "git",
"metadata": { "path": "src/auth/oauth2.py", "language": "python",
"loc": 214, "framework": "fastapi+authlib",
"lines_of_code": { "python": 190, "docstring": 42 } } }
JSON
SM=$(cat <<JSON | POST /artifacts | python -c 'import json,sys; print(json.load(sys.stdin)["id"])')
{ "project_id": "$PROJ_ID", "type": "ai_analysis",
"external_id": "CS-031-OAUTH",
"title": "CSRF via weak OAuth state token (secrets 8B instead of 32B)",
"status": "open", "source": "asf-ai",
"metadata": { "kind": "code_smell", "severity": "high", "rule": "ASF-SEC-203",
"confidence": 0.91,
"source_path": "src/auth/oauth2.py",
"lines_to_highlight": [48, 49, 50],
"remediation": "Cambia secrets.token_urlsafe(8) → token_urlsafe(32). Aggiungi test con entropy check.",
"linter": "asf-static-analyzer v0.3",
"affected_lines_count": 3 } }
JSON
# Link: smell è generated_from source file
cat <<JSON | POST /relationships
{ "relationship_type": "generated_from", "source_artifact_id": "$SM", "target_artifact_id": "$SRC" }
JSON
echo "SRC=$SRC SM=$SM"
```
Ora:
1. Apri `http://localhost:3000/code`
2. Filter **Scope → Smells** (solo smell) oppure **Severity → High**
3. Clicca la riga con titolo "CSRF via weak OAuth state token…"
4. A dx nella header del DetailView vedi il pulsante verde **Mark as fixed** → click
5. Attendi ~300ms (useMutation PATCH `status=fixed`, poi invalidateQueries di 2 cache chiavi `code-source` + `code-smells`)
6. Il pulsante sparisce, appare Badge verde **🟢 Resolved**
Verifica con curl che lo stato è persisted in Mongo:
```bash
GET /artifacts/$SM | python -c 'import json,sys; d=json.load(sys.stdin); print("status:", d["status"], "| updated:", d["updated_at"])'
# → status: fixed
```
---
## ✅ Step 8 — Usa Ask ASF per interrogare il tuo grafo (AI)
> 🤖 **ASF AI Gateway: ASTRAZIONE PURA (regola §1)**.
> - Frontend + Backend (asf-core) NON conoscono il provider LLM: fanno richiesta all'AI Gateway.
> - Nessun accoppiamento diretto a OpenAI / Anthropic / Azure OpenAI / ecc.
> - I provider configurabili (già astrazione pronta): **NVIDIA DGX Spark** (on-prem) · OpenAI-compatibili · custom provider.
> - Futuri: model routing per task · AI policies per-tenant · usage metering · priority queues · cost tracking.
> - Nel MVP seed attuale (nessun provider collegato) viene usato `MockProvider`/fallback text per verificare il ciclo 202 + poll + bolle.
Sidebar → **Ask ASF (AI)** oppure `http://localhost:3000/ask-asf`.
Scrivi nel prompt:
> "Cosa causa l'incidente INC-5? Qual è il requisito a monte? Elenca i passaggi dal deploy fino al requirement."
- Il frontend fa **POST /api/v1/ask-asf/query** → HTTP **202 Accepted** (ritorna job_id)
- **Polling ogni 2s per max 120s** (useEffect + cleanup on rerender + cancel ref)
- Quando `status == COMPLETED`: pickAnswer legge `job.result.{message,answer,summary,response,text}` (ordine fallback)
- **JobMeta pills**: queue, job_type, status, timestamps (copy/clear/Enter send, Shift+Enter newline)
In modalità demo (senza provider LLM collegato) l'AI handler ritorna una risposta fallback, ma il **ciclo di Job + 202 + poll + render bolle è completamente funzionale e verificato**.
---
## 🎁 Bonus: Seed Ricreabile Completo
Per ricostruire TUTTO in 1 comando (contenuto demo esistente):
```bash
make seed-clean
```
Questo esegue `bash scripts/seed-demo.sh --clean --jobs` che:
1. **DELETE /artifacts/{id}** di tutti gli esistenti (loop safe, bulk non esiste MVP)
2. **PATCH CANCEL** di tutti i jobs ancora QUEUED/RUNNING
3. POST degli 8 artifact demo (REQ/TASK/PR/BUILD/DEP/TEST/DOC/COMMIT/SEC/INC + 3 Source files + 3 Smells)
4. POST delle 9 relazioni demo
5. POST 5 jobs echo per code/test/devops/indexing/notifications
6. Report finale: counts artifacts by type, graph nodes/edges, jobs status counts
Per rieseguire senza cancellare niente (append di un nuovo set):
```bash
make seed
```
---
## 🗂️ Quick Reference API (Cheat Sheet)
| Endpoint REST | Metodo | Cosa fa |
|---|---|---|
| `/api/v1/organizations` | GET · POST | Lista / Crea tenant |
| `/api/v1/organizations/slug/{s}` | GET | Org per slug |
| `/api/v1/organizations/{id}` | GET · PATCH | Dettaglio / Aggiorna tenant |
| `/api/v1/projects` | GET · POST | Lista / Crea progetto |
| `/api/v1/projects/{id}` | GET · PATCH | Dettaglio / Aggiorna progetto |
| `/api/v1/artifacts?type=&status=&project_id=&limit=&offset=` | GET | Lista (filtri query) |
| `/api/v1/artifacts` | **POST** | Crea artifact (**required: project_id, type, title**) |
| `/api/v1/artifacts/{id}` | GET · **PATCH** · DELETE | Dettaglio · Aggiorna (es. `status=fixed`) · Cancella |
| `/api/v1/relationships` | **POST** | Crea edge (source/type/target) |
| `/api/v1/relationships/{id}` | DELETE | Rimuovi edge |
| `/api/v1/relationships/for_artifact/{aid}` | GET | Lista edge in/out di 1 artifact |
| `/api/v1/relationships/outgoing/{aid}` · `/incoming/{aid}` | GET | Solo uscenti / entranti |
| `/api/v1/traceability/graph?project_id=…` | GET | Nodes + edges per ReactFlow (globale) |
| `/api/v1/jobs` | GET | Lista jobs by tenant |
| `/api/v1/jobs/submit` | **POST** | Submit job `{queue, job_type, title, payload?, priority?}` |
| `/api/v1/jobs/{id}` | GET · PATCH | Dettaglio / Cancella (status=cancelled) |
| `/api/v1/overview` | GET | Metriche Dashboard |
| `/api/v1/audit?limit=100` | GET | Storico eventi (newest first) |
| `/api/v1/ask-asf/query` | **POST** | Submit domanda AI → job con polling 202 |
---
## 📔 Appendice A. Frontend Shell Enterprise (Bootstrap §9)
### 14 Voci di Navigazione previste (shell enterprise)
| # | Route / Sidebar Label | Descrizione | Stato MVP |
|---|---|---|---|
| 1 | `/` Overview | Dashboard aggregata 9 cards | ✅ attiva |
| 2 | `/requirements` | Requirements list + 4 filtri (project / status / type / search) | ✅ attiva · 6 artifacts demo |
| 3 | `/context` Context Explorer | Picker · 5 tabs: Metadata · Relationships · Related (2-hop) · **Graph 3-anelli** · History timeline | ✅ attiva |
| 4 | `/code` Code Analysis | 2 useQuery (source + ai_analysis.kind=code_smell) · 6 filters · 5 StatCards · DetailView con **Mark-as-fixed PATCH** | ✅ attiva |
| 5 | `/traceability` | React Flow layered · nodes 21 · edges 9 · Inspector · refetch 5s | ✅ attiva |
| 6 | **`/ask-asf` Ask ASF (AI)** | Chat UI bolle user/AI · POST /ask-asf/query 202 + poll 2s max 120 · JobMeta pills | ✅ attiva |
| 7 | **`/jobs` Jobs (Activity)** | 6 stats · list · auto-refresh 3s · Modal Enqueue · Detail JSON · Progress bar | ✅ attiva |
| 8 | Pipelines | CI/CD pipeline view | ⏭️ placeholder futuro |
| 9 | Deployments | Deployment history, env, rollback | ⏭️ placeholder |
| 10 | Quality | Quality gates, coverage, lints | ⏭️ placeholder |
| 11 | Observability | Logs/metrics/traces explorer | ⏭️ placeholder |
| 12 | Integrations | Git / CI / Docs / PagerDuty connectors | ⏭️ placeholder |
| 13 | Usage | Tenant AI/job consumption + cost tracking | ⏭️ placeholder |
| 14 | Settings | Org / Team / Project config · keys · SSO · members | ⏭️ placeholder |
### Overview Dashboard — 9 Cards specificate
La home `/` aggrega metriche cross-project per tenant:
| Card | Cosa misura | Stato |
|---|---|---|
| 1 | Project Health (%) · pesato per requirements coverage / deploy success / security backlog | ✅ attiva (94.5% demo) |
| 2 | Requirements count · by status / by type (open/in-progress/done) | ✅ attiva |
| 3 | Implementation Coverage · % requirements con ≥1 task linked | ✅ attiva |
| 4 | Test Coverage · % PR/deploy con ≥1 test verifies collegato | ✅ attiva |
| 5 | Traceability completeness · % artifacts coinvolti in catene Req→Deploy | ✅ attiva |
| 6 | Deployments (last 7d) · count + success rate | ✅ attiva |
| 7 | Active AI Jobs · queued/running · usage last hour | ✅ attiva |
| 8 | Failed Builds (last 7d) | ✅ attiva |
| 9 | Security Findings (open) · per severity (critical/high/medium/low) | ✅ attiva |
---
## 🛡️ Appendice B. Non-Functional Requirements (Bootstrap §12-16)
Linee guida ufficiali del progetto ASF — rispettate in ogni parte del codice:
### 🔎 Osservabilità (§12)
- **Log strutturati JSON**: ogni log include `tenant_id`, `project_id` (se presente), `correlation_id`, `job_id` (se in un job).
- **Correlation ID**: propagato **per tutta la distribuzione** (REST → Rabbit → Worker → AI Gateway → risposta). Middleware di correlazione già attivo.
- **OpenTelemetry-ready**: punti di hook per tracing distribuito (non MVP, boundary presenti).
- **❌ VIETATO loggare**: password, token, API keys, Authorization header, cookie firma, PII (personally identifiable information).
### 🔒 Sicurezza (§14)
1. **Tenant Isolation**: *TUTTE* le query filtrano per `tenant_id` (Mongo). Mai mescolare dati tra tenant.
2. **Input Validation**: TUTTI gli endpoint usano Pydantic v2 validato server-side (body, query params, headers).
3. **Secrets SOLO in env variables**: **MAI hardcodare** URI MongoDB / Rabbit / API keys nel codice o in file di config commitati. `.env.example` documenta le variabili richieste.
4. **Authorization boundaries**: layer Org → Teams → Projects (futuro) preparati per RBAC fine-grained.
5. **Secure defaults**: no permessi wildcard default, CORS whitelist minimale, cookie HttpOnly in authn futura.
6. **Audit logging**: ogni modifica (create/update/delete) su artifacts/projects/organizations/jobs genera record in `audit_events` con `actor_id` (user) + delta + timestamp.
### 👩‍💻 Regole Ingegneristiche (§15 — ASF Engineering Rules)
| Regola | Applicazione pratica |
|---|---|
| **SOLID ove utile** | Service in asf-core/services/* + DI pattern (no singleton globali) |
| **Codice semplice > codice intelligente** | No pattern gang-of-four non necessari |
| **Typed models everywhere** | Pydantic v2 per input/output endpoint |
| **Componenti testabili** | In-memory fakes (FakeJobsCollection / FakeRelService / FakeRepos) invece che Mongo reale per unit test |
| **NO premature optimization** | Ottimizza solo dopo misurazione |
| **NO microservizi per apparenza** | Monolite pragmatico → servizi reali, non finti. asf-core / asf-context / asf-ai / asf-worker = servizi distinti ma acoplamento debole. |
| **NO classi giganti / file 2000 righe** | Split su service/repo/router |
| **NO stato globale nascosto** | Dependencies injection, mutable state solo in MongoDB/Rabbit/Redis (Redis non MVP) |
| **NO provider logic nel domain layer** | asf-domain è package standalone puro: non importa FastAPI, Motor, Rabbit, OpenAI SDK |
### 🧪 Testing Strategy (§16)
- **pytest** come framework unico (tutti i servizi hanno pytest.ini `asyncio_mode=auto` per test async).
- **Test Unit = NO servizi esterni**: Mongo/Rabbit/Redis sostituiti da fakes in-memory. Oggi 40/40 tests VERDI.
- **Services testati oggi**: asf-domain 12 · asf-core 6 · asf-worker 8 · asf-ai 7 · asf-context 4.
- Copertura domini critica: artifact lifecycle, relationship traversal, job claim atomic, AI provider abstraction, context BFS.
---
## ❌ Errori Comuni & Soluzioni
| Errore | Causa | Fix |
|---|---|---|
| **422 `"slug" regex mismatch`** | Org slug con maiuscole / underscore | Usa minuscolo, `-`, 3-60 caratteri, inizia/finisce alfanum |
| **422 `"key" regex mismatch`** | Project key non inizia con maiuscola | `^[A-Z][A-Z0-9]{1,19}$` es. `ASF`, `PROJ2`, non `asf` o `1PROJ` |
| **422 `project_id` required field missing** | Artifact POST senza `project_id` | Aggiungi `"project_id": "<UUID>"` (tenant from headers, project nel body) |
| **422 field `description` / `version` extra_forbidden** | `ArtifactCreate` non ha questi campi | Usa `content=` per corpo lungo, version è solo nel record (non in create) |
| **422 `artifact_type invalid value` (es. code_smell)** | Enum `ArtifactType` non contiene code_smell | Usa `type: "ai_analysis"` + `metadata.kind: "code_smell"` (vedi Step 7) |
| **422 `source_project_id` extra_forbidden in Relationship** | Non si passano project ids nel body | Rimuovi: validazione server-side da artifact sorgente/destinazione |
| **Lista artifacts vuota** | Header `X-ASF-Tenant-Id` mancante | Aggiungi `X-ASF-Tenant-Id: t-demo-003` e `X-ASF-User-Id: u-mario` a tutte le richieste |
| **Context Graph vuoto** | Relazioni 0 sul seed corrente | Ricorreggi Step 4 e assicurati di aver POSTed le relationships con gli UUID corretti |
| **Jobs non completano** | Worker non in esecuzione | `make start` (--with-worker) · controlla `make status` e tail log |
| **TypeScript build fallisce** | Aggiunte imports in usages wrong | `make tsc` diagnostica riga+colonna precise, correggi e rilancia |
| **⚠️ Credenziali esposte in repo** | URI Mongo/Rabbit/API keys hardcodate nel codice invece che in `.env` | **MAI committare `.env` nel repo** · tutte le secrets in env variables · `.env.example` pubblico solo i nomi delle variabili |
| **⚠️ Jobs status persiste male** | Developer ha salvato lo stato del job in RabbitMQ invece che in Mongo | Regola §7: **Mongo = stato permanente**. Rabbit = solo coda messaggi. Se Rabbit cade, lo stato dei job (queued/running/completed/failed) resta su Mongo. |
| **⚠️ Artifacts mixati tra progetti** | Filtro GET `/artifacts` senza `project_id` query param | Aggiungi `&project_id=<UUID>` per vedere solo artifact di 1 progetto specifico · il default mostra TUTTI gli artifact del tenant |
---
## ✅ Pronti! Checklist Primo Progetto Funzionale
Segnando una spunta per ogni step raggiungi la parità con la demo seed:
- [ ] **0. Stack running** (`make status` core=8000 / web=3000 / worker alive)
- [ ] **1. Organization creata** (slug OK, API `/slug` returns 200)
- [ ] **2. Project creato** (key `ASF` o simile, Lista projects restituisce 1+ rows)
- [ ] **3. 4+ artifacts base** (REQ / TASK / PR / TEST)
- [ ] **4. 3+ relationships** → **Context Graph** mostra 3 anelli: selected / hop1 / hop2
- [ ] **5. Traceability globale** mostra 8+/7+ node/edge (BUILD/DEP/SEC/INC aggiunti)
- [ ] **6. 3+ jobs enqueued + COMPLETED** (Jobs page counter "Completed" aumenta)
- [ ] **7. Code Analysis: Smell → Mark as fixed → status="fixed" in GET**
- [ ] **8. Ask ASF → POST 202 → poll → bolle UI renderizzate**
- [ ] **9. `make lint` passa** (40/40 pytest + tsc 0 errors)
**Fine tutorial.** Da qui puoi: (a) estendere i tipi artifact con `metadata.*` libero, (b) aggiungere relazioni a piacere, (c) scrivere handlers custom nel worker per nuovi job, (d) integrare un vero provider LLM in `asf-ai`.
+395
View File
@@ -0,0 +1,395 @@
# ASF — Guida Primi Passi (Solo Frontend 🎨)
> **ASF = Azurian Software Factory** · *SaaS Platform multi-tenant AI-native*
>
> **Motto**: *"From Requirement to Production — everything is contextualized, traceable and AI-accessible."*
>
> **Principio centrale**: ***Everything is an Artifact*** — requisiti, task, PR, commit, build, test, deploy, incidenti, analisi AI, segnalazioni di sicurezza: tutto è un Artifact ASF collegato da Relazioni semantiche.
Questa guida usa **solo il browser** (frontend Next.js su `http://localhost:3000`) — zero curl, zero chiamate API a mano.
L'obiettivo è insegnarti a **usare ASF dal punto di vista dell'utente finale**: navigare le pagine, filtrare i dati, ispezionare gli artifact nel loro contesto, marcare i code smell come risolti, sottomettere job asincroni da UI, interrogare l'AI sul grafo.
---
## 0. Prerequisito (1 comando solo per popolare dati)
ASF MVP non ha ancora (in questa release) le pagine "Create Organization / Create Project / Create Artifact / Create Relationship via form frontend.
Per avere dati reali con cui giocare nel browser, lancia **UNA SOLA VOLTA** questo comando dal terminale (seed demo):
```bash
cd /root/ASF && make seed-clean
```
Questo comando (wrapper `scripts/seed-demo.sh --clean --jobs`) fa per te:
1. Crea Organization demo + Project demo
2. Popola 21 artifact (Requirements / Task / PR / Build / Deploy / Test / Docs / Commits / Security findings / Incidents / Source files / Code smells)
3. Crea 9 relazioni semantiche
4. In coda 5 job echo (devops / testing / code analysis) per provare la pagina Jobs
5. Mostra un report finale: `Artifacts totali / Jobs counts / Graph nodes·edges`
Poi avvia il frontend (se non già attivo):
```bash
make start
```
Controlla che tutto sia vivo:
```bash
make status
```
Quando vedi `asf-core :8000 UP · asf-web :3000 UP · asf-worker ALIVE` puoi iniziare.
**Apri il browser**: `http://localhost:3000`
---
## 🧭 Concetti Chiave da Sapere (prima di cliccare)
```
Organization (il tuo Team) → Project → Artifact ← Relationship → Artifact
```
- **Artifact (17 tipi)**: requirement, epic, story, task, architecture_decision, document, source_file, commit, pull_request, build, test, security_finding, deployment, release, runtime_event, incident, ai_analysis.
- **Relationship (12 predicati semantici)**: implements / verifies / generated_from / documented_by / deployed_as / caused_by / related_to …
- **Tutto è cliccabile**: un nodo nel grafo → apre il dettaglio Context. Una riga in tabella → apre il dettaglio. Un badge ID → apre il dettaglio.
- **Isolamento per Tenant**: il frontend MVP usa dev-trust headers (t-demo-003 / u-mario) già iniettati per te, quindi tutte le pagine mostrano solo i dati del tuo progetto demo.
---
## 🏠 Pagina 1: Overview Dashboard (`/`)
### Cosa vedi
9 cards in alto (da Bootstrap Prompt §9) + numeri veri:
| Card | Come interpretarla |
|---|---|
| Project Health % | Media pesata: req coverage + deploy success + security backlog. Oggi: **94.5%** ✅ |
| Requirements count · per status | Quanti requisiti ci sono? Aperti / In progress / Chiusi |
| Implementation Coverage | Requisiti con ≥1 task collegati |
| Test Coverage | % PR / Deploy con ≥1 test che li "verifica" |
| Traceability Completeness | Quanti artifact hanno catene complete Req→Task→PR→Test→Deploy |
| Deployments last 7d | Quanti deploy · rate di successo |
| Active AI Jobs | Coda attiva: queued / running · usage ultima ora |
| Failed Builds last 7d | Build fallite |
| Security Findings OPEN | Conteggio per severità: CRITICAL/red · HIGH/amber · MEDIUM/brand · LOW/neutral |
### Cosa fare
- **Click sul numero Projects**: ti salta alla pagina **Requirements** (prossima).
- **Click su Security Findings**: ti salta a **Code Analysis** per vedere gli smell aperti.
- **Click su Jobs**: vai a **Jobs (Activity)**.
---
## 📋 Pagina 2: Requirements (`/requirements`)
### Header in alto
Titolo + 4 strumenti di filtraggio + 1 pulsante:
1. **Search field** (ricerca libera per external_id, title, content)
2. **Project filter** (tutti o 1 progetto specifico)
3. **Status filter** (open / in_progress / done / fixed / resolved)
4. **Type filter** (requirement / epic / story / task / document)
5. **🆕 New Requirement** — ⚠️ *placeholder UI*: in questa MVP non si può creare requirement da form. Usare il make seed per aggiungere nuovi record.
### Tabella Sticky Header
| Colonna | Cosa mostra |
|---|---|
| ID | external_id monospace (REQ-103) | badge + badge tipo (REQ / colorato |
| Title | titolo lungo artifact |
| Status | Badge tone (amber=open · brand=in_progress · green=fixed · blue=active) |
| Project | nome progetto NON-UUID (es. "ASF SaaS MVP" invece di UUID) |
| Source | product-board / jira / github / pytest / argo / etc. |
| Updated | data relativa "2h fa / 3d fa |
### Cosa fare (esercizio 2 clic)
1. Scrivi nella Search: `SSO` → filtra subito i requirements che matchano title/content.
2. Status filter = `Open`.
3. **Click sulla riga REQ-103**. → **ti porta automaticamente a Context Explorer con REQ-103 selezionato**.
Questo è il pattern ASF: **ogni riga è un link diretto al contesto.
---
## 🔍 Pagina 3: Context Explorer (`/context`) — IL CUORE DI ASF
Questa è la pagina più potente. **5 tab** per 5 modi diversi di vedere lo STESSO artifact selezionato.
### Layout
```
┌─────────────────────────────────────────────────────────────────────┐
│ Artifact Picker (SX) │ Contenuto Destra (Tabs 1 di 5) │
│ Search x project │ │
│ ┌────────────────────────────┐│ │
│ │ - REQ-103 ← selezionato ││ [Metadata] [Relationships] │
│ │ - TASK-101 ││ [Related 2-hop] [Graph ⭐] │
│ │ - PR#88 ││ [History timeline] │
│ │ - BUILD-910 / DEP-44 ... ││ │
│ └────────────────────────────┘│ │
└─────────────────────────────────────────────────────────────────────┘
```
### Tab 1 — Metadata (Informazioni statiche
- **Title · external_id · type · status · version · source · created_at · updated_at
- Griglia 2x2: Version / Source / Created / Updated
- Source preview `<pre>` bg-slate-900 mono (se è un source_file o un content lungo)
- Side card destra: **Remediation** (per i code smell) + **Traceability** (collegamenti rapidi)
### Tab 2 — Relationships (Liste dirette)
- **Outgoing** (edge uscenti: cosa è questo artifact → altri)
- **Incoming** (edge entranti: altri artifact → questo)
- Ogni riga mostra badge `relationship_type` + target title + external_id clickable
### Tab 3 — Related (2-hop)
Tutti i vicini a **2 salti** (artifact direttamente collegati + i loro collegati). Utile per:
"Se modifico REQ-103, cosa viene influenzato indirettamente?"
- 2 gruppi Hop1 e Hop2 con badge di profondità, colore distinto
### Tab 4 — GRAPH (3 anelli concentrici) ⭐ IL PIÙ BELLO
Vedi il tuo artifact **al centro**, poi hop1 → hop2.
- **Layout radiale**:
- 🟦 Centro → artifact selezionato · bordo indigo + badge SELECTED
- 🟣 Anello interno (R1 = 220px di raggio · bordo violetto · pillola **HOP1**
- ⬜ Anello esterno (R2 = R1+220+ ~ raggio · bordo slate · pillola **HOP2**)
- **Edges a 2 livelli visivi:
- Tier 1 (Sel↔Hop1): **SOLIDO 2.1px** colore REL_COLORS
- Tier 2 (Hop1↔Hop2): **TRATTEGGIATO strokeDasharray + opacity 0.78
- **11 colori per tipo relazione** (legenda in alto a destra: implements/verifies/generated_from/deployed_as/documented_by/related_to/caused_by …)
- Controlli: drag per spostare, scroll zoom, pulsanti Controls in basso a SX, **MiniMap** colori per layer (indigo / violet / slate)
**Clicca QUALSIASI nodo** nel grafo**:
→ Context si sposta il centro e ricentra sul nuovo artifact!**
### Tab 5 — History Timeline
Eventi ordinati per data:
- 🟣 created_at CREATED viola
- 🔵 updated_at UPDATED blu
- 🟢 version_bump VERSION verde
- 🟠 rel_added / relationship creazione edge arancione
---
## 🧠 Esercizio Pratico Context (3 click!)
1. Nel Picker SX clicca **SEC-025 (security finding SQL injection severity=high
2. Vai in Tab **Related → vedi il nodo SEC-025 e "related_to PR#88
3. Vai in Tab **Graph** → vedi SEC al centro · RELATED_TO PR (edge slate · Hop1
4. Click PR → **Clicca il nodo PR#88 nel graph** → Ora PR#88 è il nuovo centro
5. Tab RELATED del PR → vedi tutti i 2-hop: TASK · BUILD · TEST · DEP · COMMIT · DOC...
Questo è il "perché del progetto: capire l'impatto a cascata.
---
## 💻 Pagina 4: Code Analysis (`/code`)
Analisi di codice + code smells. **2 useQuery separate TanStack (source_file e ai_analysis.kind=code_smell).
### 6 Filtri (Client Side)
1. **Search** libera (title · external_id · remediation · source_path)
2. **Scope**: `All / Source / Smells`
3. **Language**: `All / python / ts / java`
4. **Severity** (solo smells): `Critical / High / Medium / Low`
5. **Status**: `All / open / in_progress / fixed / resolved
6. **Project**: `All / ASF SaaS MVP`
### 5 StatCards in alto
1. Source files caricati
2. Smells trovati
3. Criticali (Badge rosso AlertOctagon)
4. Alti (Badge amber AlertTriangle)
5. Medi+Bassi
### Tabella Righe
Riga = 1 artifact (può essere source_file O smell.
### DetailView (click sulla riga)
Apre pannello info 2 colonne:
```
┌──────────────────────────────────────┬──────────────────────────────┐
│ <Source preview pre> │ Info 2x2 │
│ linguaggio color syntax │ version / source / created / │
│ linee evidenziate in ROSE se linesToHighlight │ Remediation suggerita │
│ (righe colpevoli di codice) │ Traceability │
│ │ Status badge severity: │
│ │ │
│ │ ⭐ BUTTON "Mark as fixed" │
└──────────────────────────────────────┴──────────────────────────────┘
```
### ⭐ AZIONE PRINCIPALE: **Mark as Fixed (PATCH status=fixed)
Prima devi selezionare un **smell** (source files non hanno il tasto).
**Procedura (5 step click!):
1. Filtra Severity = **High**
2. Click la riga **CS-003 pytype ASF-SEC-203 (source consumer.py lines 8-16
3. Dettaglio sulla DX → in alto header un bottone verde **Mark as fixed
4. Click → Attendi ~300ms
5. Sparisce il pulsante → appare un Badge **🟢 Resolved**
**Cosa succede dietro le quinte? useMutation `markFixedM`:
1. `PATCH /artifacts/:id { status: "fixed" }`
2. `onSuccess`: **invalidateQueries(["code-source", "code-smells"]
3. TanStack ricarica le 2 query (tabella si aggiorna automaticamente
Verifica visiva: torna nel filtro Status = fixed → il tuo smell è in fixed!
---
## 🧬 Pagina 5: Traceability Graph (/)
**Grafo GLOBALE del progetto intero** (Context Graph solo di 1 artifact.
### Cosa vedilaid out
- Layout layered DAG React Flow
- **21 nodes · 9 edges demo seed)
- Nodi colorati per tipo artifact type (color map 11 colori)
- refetchInterval 5s auto-aggiorna)
- Pannello Inspector a DX:
- **selezionato: seleziona
- **Title · ID · Project
- **Relationships dirette
### Esercizio Trace (2 click!)
1. Click il nodo arancione **INC-77 (incidente login failure)
2. Guarda Inspector **caused_by DEP-44
3. Click DEP-44 → Inspector deployed_as → BUILD → generated_from PR → implements → task → REQ-103 → Questo è il ciclo completo.
---
## 🚀 Pagina 6: Jobs (Activity) (/)
TUTTI i job. Auto-refresh ogni **3s** (non devi ricarica la pagina manualmente).
### 6 StatCards in alto
Queued / Running / Completed / Failed / Cancelled / Total
### Tabella
| Colonna | Cosa |
|---|---|
| Job ID | ID corto UUID+ clickable |
| Title | titolo descrittivo |
| Queue | code DIRECT pill devops/testing/code_analysis/... |
| Status | Badge tone |
| Worker | asf-worker-1 o quale macchina |
| Priority | 0-100 |
| Usage ms | tempo CPU time 42ms |
| Cost | USD estimate (demo |
| Updated | relativo "4s fa · 1m fa |
| Actions | |
### ⭐ Creare un Job da UI (Form completo) (useMutation submitM + Modal
1. In alto a DX click **Enqueue job** → Apre **MODALE DIALOG 720w
2. Compila il form (7 campi):
a. **Queue* (select): ai.interactive / ai.background / indexing / code_analysis / testing / devops / notifications
b. **Job Type** (stesso nome queue)
c. **Title** (campo libero)
d. Priority** (0-100)
e. Correlation ID (opzionale)
f. Payload** (editor JSON validato - se JSON invalido → disable button, 422 fallback
g. Notes (opzionale)
3. Click **Enqueue** (in basso a DX pulsante primario Submit 2 pulsanti Cancel · Submit!)
→ Dopo 2 modal si chiude · useMutation:
- `onSuccess: 1. POST /jobs/submit
- 2. invalidateQueries(["jobs"])
- 3. setFilters resetta filtri → Tabella si aggiorna **new job diventa Queued Poi in ~3 secondi lo vede il worker → Running → **Completed** (echo handler demo)
### Detail Modal (Click sulla riga Job)
- **JSON completo del job`:
```
{ id, title, status, queue, payload, priority, created_at, started_at, completed_at, result, usage, error, worker_id, correlation_id, metadata }
```
- Progress 0-100:
- queued/cancelled: 0%
- running = progressivo
- completed = 100%, failed 100% rosso
- Meta pills Queue / Status
- Actions: View similar (precompila la modale Enqueue)
---
## 🤖 Pagina 7: Ask ASF (AI Chat) (`/ask-asf`)
Chat UI stile ChatGPT.
### Layout
```
┌──────────────────────────────┬─────────────────────────────────────┐
│ Sidebar DX: │ Bolle chat SX │
│ · 🔍 Context Scope │ 👤 Utente │
│ · (source_context=true │ 🤖 AI Risposta │
│ · include_relationships=true │ [box digitando… │
│ · max_depth 2 │ │
│ · max_nodes 50 │ │
│ · send_trace │ Prompt input + enter send + shift enter newline │
│ · clear chat │ │
└──────────────────────────────┴─────────────────────────────────────┘
```
### Flusso completo (passo per passo utente)
1. Scrivi nel prompt:
> *"Cosa causa l'incidente INC-77? Traccia la catena fino al requirement originario, e suggerisci 2 azioni di rimozione"*
2. **Invia con Enter (Shift+Enter = newline nel testo
3. 👤 La tua bolla appare subito (azzurra)
4. 202 accepted → gear rotante "In attesa di risposta dall'AI..." (polling every 2s · max 120 itera 2 minuti
5. Appena il job status=COMPLETED 🟢
6. 🤖 AI Answer: pickAnswer legge `job.result.message/answer/summary/response/text`
7. **JobMeta pills**: queue=ai.interactive status=completed timestamp
8. **Copy**: icona Copy → clipboard
9. **Clear**: icona Clear
10. Prompt ancora una domanda diversa
### MVP attuale
Senza provider LLM attivo → **fallback del AI Gatewa.
**Tutto il resto è FUNZIONANTE
- 202 accettato 2
- 02:
- : 00 iterato
- Risposta renderizzata in bolla
- Context sidebar & Scope pills + History multiple domande
---
## 🧾 Cheat Sheet: Pagine & Azioni Frontend (Riepilogo)
| Pagina Route | Come ci arrivi dalla Sidebar | Cosa ci fai |
|---|---|---|
| `/` | 🏠 Overview | 9 cards KPIs · dashboard generale |
| `/settings` | ⚙️ Settings | **Crea Org (slug regex 3-60)** · **Crea Proj (key A-Z 2-20)** · tabelle lista |
| `/requirements` | 📋 Requirements | Cerca · filtra · **pulsante "New Requirement" modale form completo** · click riga → Context |
| `/context` | 🔍 Context | **pulsante "New Artifact" (17 tipi!)** · **pulsante "Link" (12 rel. types!)** · Ispeziona 5 Tabs · Graph 3 anelli · click nodi ricentra |
| `/code` | 💻 Code Analysis | Filtra smells · **Click riga smell → "Mark as fixed" |
| `/traceability` | 🧬 Trace | Grafo globale · Click nodo → Inspector |
| `/jobs` | 🚀 Jobs (Activity) | **Modale "Enqueue job" form completo** · Detail JSON modal |
| `/ask-asf` | 🤖 Ask ASF (AI) | Fai domande sul grafo · 202 + poll · Chat |
---
## ✅ Stato Copertura Frontend vs API
Tutte le operazioni CRUD principali **ora hanno UI** (0 comandi curl necessari):
| Operazione | UI nel frontend? | Dove la trovi |
|---|---|---|
| Creare Organization | ✅ | **/settings → "New organization" |
| Creare Project | ✅ | **/settings → "New project" |
| Creare Requirement | ✅ | **/requirements → pulsante "New Requirement" |
| Creare Artifact GENERICO (17 tipi: task, epic, story, commit, source_file, ai_analysis, deployment, incident, security_finding, …) | ✅ | **/context → pulsante **"New Artifact" |
| Creare Relazioni tra artifact (12 tipi: implements, verifies, generated_from, documented_by, depends_on, deployed_as, caused_by, related_to, …) | ✅ | **/context → pulsante **"Link"** |
| Upload source code smell | ✅ | Context → New Artifact → `type=ai_analysis` + metadata `{"kind":"code_smell","severity":"high"}` |
| Mark smell come fisso | ✅ | /code → DetailView → **Mark as fixed** |
| Sottomettere Job | ✅ | /jobs → Modale **Enqueue job** completo |
| Interrogare AI grafo | ✅ | /ask-asf → Chat UI 202 + poll |
---
## ✅ Checklist Primo Progetto Frontend (solo click, ZERO curl)
- [ ] Stack attivo: `make seed-clean` + `make start` + `make status` → **UP (tutti verdi)
- [ ] /settings → **New organization**: fill name=NuovaOrg, slug=nuova-org-001 (3-60 lowercase+dash), plan=Free → **Submit → riga apparsa in tabella Organizations ✅
- [ ] /settings → **New project**: fill name="Demo Proj 1", key="DEMO1" (A-Z 2-20), branch=main → Submit → riga apparsa Projects tabella ✅
- [ ] /requirements → **pulsante "New Requirement" → fill Title="SSO Login OK", Project=DEMO1, Content="Login with Okta/OAuth2", Status=backlog → Create → apparsa in lista ✅
- [ ] /requirements → Cerca "SSO" + click riga → Context
- [ ] /context → **New Artifact**: Type=task · Title="Implementa Okta middleware" · External_id="TASK-900" · Project=DEMO1 · Status=todo → Create → selezionata in picker ✅ (17 tipi artifact!)
- [ ] /context → **Link** pulsante: Source=REQ (il SSO appena creato) · Type=implements · Target=Task900 (search "Okta" filter) → Create Relationship → Tab "Relationships" mostra implements→ task 🔗
- [ ] /context → Graph 3 anelli: hop0 (centro) · hop1 (solid task) · hop2 (dashed se esistevano) → Click nodo task (si ricentra) ✅
- [ ] /code → Filtro Severity High → riga CS-003 → click → **Mark as fixed" → Badge 🟢 Resolved ✅
- [ ] /traceability → Click nodo INC-77 → Inspector caused_by DEP-44 → Click DEP → chain completa
- [ ] /jobs → pulsante **Enqueue job → compila form titolo="Smoke test job", queue=testing, priority=normal → Invia → Attesa 3s → Status queued → Running → **Completed 100% ✅
- [ ] Jobs riga completata → Click → JSON modale Detail 100% completati → Usage result
- [ ] /ask-asf → Invia domanda: "Quale task implementa il requirement SSO?" → Attendi 202+poll → bolle user e AI → Copy clipboard ✅
- [ ] `make tsc` → 0 errors
- [ ] `make test` → 12+6+8+7+4 = 37/37 ✅ GREEN
+78
View File
@@ -0,0 +1,78 @@
version: "3.9"
services:
mongodb:
image: mongo:7
container_name: asf-mongodb
restart: unless-stopped
ports:
- "27017:27017"
environment:
MONGO_INITDB_ROOT_USERNAME: ${MONGO_ROOT_USER:-asfadmin}
MONGO_INITDB_ROOT_PASSWORD: ${MONGO_ROOT_PASSWORD:-asfadmin123}
MONGO_INITDB_DATABASE: ${MONGO_DB:-asf}
volumes:
- asf-mongodb-data:/data/db
healthcheck:
test: ["CMD", "mongosh", "--eval", "db.adminCommand('ping')"]
interval: 10s
timeout: 5s
retries: 5
rabbitmq:
image: rabbitmq:3.13-management
container_name: asf-rabbitmq
restart: unless-stopped
ports:
- "5672:5672"
- "15672:15672"
environment:
RABBITMQ_DEFAULT_USER: ${RABBITMQ_USER:-asf}
RABBITMQ_DEFAULT_PASS: ${RABBITMQ_PASSWORD:-asf123}
volumes:
- asf-rabbitmq-data:/var/lib/rabbitmq
healthcheck:
test: ["CMD", "rabbitmq-diagnostics", "-q", "ping"]
interval: 15s
timeout: 10s
retries: 5
redis:
image: redis:7-alpine
container_name: asf-redis
restart: unless-stopped
ports:
- "6379:6379"
command: ["redis-server", "--requirepass", "${REDIS_PASSWORD:-asfredis123}"]
volumes:
- asf-redis-data:/data
healthcheck:
test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD:-asfredis123}", "ping"]
interval: 10s
timeout: 5s
retries: 5
minio:
image: minio/minio:latest
container_name: asf-minio
restart: unless-stopped
ports:
- "9000:9000"
- "9001:9001"
environment:
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-asfadmin}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-asfadmin123}
command: server /data --console-address ":9001"
volumes:
- asf-minio-data:/data
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
interval: 15s
timeout: 10s
retries: 5
volumes:
asf-mongodb-data:
asf-rabbitmq-data:
asf-redis-data:
asf-minio-data:
+58
View File
@@ -0,0 +1,58 @@
# ADR-0001 — Monorepo Structure for ASF
- **Status:** Accepted
- **Date:** 2026-09-07
- **Deciders:** ASF Architecture Team
## Context
Azurian Software Factory comprises several logical components:
a shared domain model, an event schema package, connectors, multiple backend services
(core, context, ai, worker) and a Next.js web frontend.
We must choose a repository organization that balances:
- Strong cohesion for shared types / contracts
- Independent deployability of services
- Local developer ergonomics
- Clear service boundaries
## Decision
Use a **single monorepo** with the following top-level layout:
```
ASF/
├── frontend/asf-web/ # Next.js SaaS UI
├── services/ # Deployable Python services
│ ├── asf-core/
│ ├── asf-context/
│ ├── asf-ai/
│ └── asf-worker/
├── packages/ # Shared installable Python packages
│ ├── asf-domain/
│ ├── asf-events/
│ └── asf-connectors/
├── docs/ # Architecture, ADRs, API docs
├── infrastructure/ # Dockerfiles, scripts
└── tests/ # Cross-service integration tests
```
Shared packages are installed in editable mode (`pip install -e`) when
developing locally.
## Consequences
### Positive
- Shared domain models and event schemas live in one place — no drift.
- Refactors that cross service boundaries are done atomically in one PR.
- Local `docker-compose` up brings up the whole stack trivially.
- Search / navigation works across the whole product.
### Negative
- CI pipelines must be careful to only rebuild what changed.
- Service release tagging must be explicit (single git log, many artefacts).
### Risks / Mitigations
- Accidental tight coupling between services → mitigated by code review,
AGENT.md rules, and keeping services import only from `packages/*`, never
from each other.
@@ -0,0 +1,63 @@
# ADR-0002 — MongoDB as Primary Persistence
- **Status:** Accepted
- **Date:** 2026-09-07
- **Deciders:** ASF Architecture Team
## Context
ASF requires a primary store for Organizations, Projects, Artifacts,
ArtifactRelationships, Jobs, AuditEvents and similar tenant-scoped records.
Key properties required:
- Flexible schema: the generic `Artifact` model needs extensible `metadata`.
- Document-level locality: an Artifact document bundles relationships list,
metadata and version.
- Horizontal scaling and multi-tenancy (every query is scoped by `tenant_id`).
- Async/await Python driver support.
## Decision
Adopt **MongoDB 7.x** as the only primary operational database.
Use compound indexes prefixed with `tenant_id` for all tenant-owned
collections. Collections are intentionally few:
- `organizations`
- `users`
- `teams`
- `projects`
- `artifacts`
- `artifact_relationships`
- `integrations`
- `jobs`
- `ai_executions`
- `audit_events`
**We do NOT** create a collection per artifact type. Every artifact type
(requirement, task, commit, test, deployment…) lives in the single
`artifacts` collection discriminated by `type`.
MinIO holds large binaries / reports; MongoDB stores the object reference
and metadata.
## Consequences
### Positive
- Flexible `metadata` on Artifacts matches the "Everything is an Artifact"
principle without schema migrations for every new artifact subtype.
- Compound `(tenant_id, project_id, …)` indexes give fast tenant-scoped
reads and guarantee isolation at query time.
- Native async driver (`motor`) integrates naturally with FastAPI.
### Negative
- No foreign-key constraints across collections — referential integrity is
an application responsibility (mitigated via service-layer validations
and tests).
- Relationship graph traversal requires multiple queries or the
`$graphLookup` aggregation stage. The schema is intentionally designed
so that a future graph-database projection is possible without rewriting
domain logic.
### Follow-up
- ADR for graph-database introduction once traversal patterns stabilize.
- ADR for vector / embedding store (Semantic Context Engine phase).
@@ -0,0 +1,61 @@
# ADR-0003 — RabbitMQ as Event and Job Backbone
- **Status:** Accepted
- **Date:** 2026-09-07
- **Deciders:** ASF Architecture Team
## Context
ASF has two asynchronous messaging needs:
1. **Domain events** (facts that happened — `artifact.created`,
`deployment.completed`, …) used for decoupled side effects.
2. **Asynchronous jobs** (AI inference, indexing, code analysis, testing)
that need separate queues with priorities and horizontal workers.
A single backbone that handles both patterns is preferred to reduce
infrastructure surface.
## Decision
Adopt **RabbitMQ 3.13+** as the sole event/job backbone.
### Logical queues
| Queue | Purpose | Priority |
|------------------------|----------------------------------------|----------|
| `asf.ai.interactive` | Latency-sensitive user-facing AI work | High |
| `asf.ai.background` | Batch / low-priority AI work | Normal |
| `asf.indexing` | Repository / document indexing | Low |
| `asf.code_analysis` | Static analysis tasks | Normal |
| `asf.testing` | Test execution requests | Normal |
| `asf.devops` | Deployment / CI orchestration | Normal |
| `asf.notifications` | Emails / webhooks / in-app notif. | Normal |
### Source of truth
RabbitMQ delivers work. **Persistent job state** (status, timestamps,
usage, error, correlation) lives in MongoDB `jobs` collection. Workers
update Mongo as they progress through `queued → running → completed|failed`.
RabbitMQ is **never** the permanent system of record for job state.
### Event format
Every event message contains:
```
event_id, event_type, timestamp, tenant_id, project_id?,
correlation_id, causation_id?, payload
```
Large payloads / binaries go to MinIO; the message carries only a
reference.
## Consequences
### Positive
- One broker, well-understood routing / priority / TTL / DLX semantics.
- Workers are stateless, horizontally scalable processes.
- At-least-once delivery matches domain; idempotency keys on workers
prevent duplicate side-effects.
### Negative
- Exactly-once is not provided — consumers must be idempotent.
- Operational complexity of a broker (mitigated by managed services in prod).
@@ -0,0 +1,65 @@
# ADR-0004 — AI Gateway Abstraction Layer
- **Status:** Accepted
- **Date:** 2026-09-07
- **Deciders:** ASF Architecture Team
## Context
ASF requires LLM capabilities for:
- Code analysis
- Requirement decomposition
- Test generation
- Context-aware chat ("Ask ASF")
- Incident analysis
Target inference backends include on-prem DGX Spark, OpenAI-compatible
local APIs, commercial providers, and customer-hosted endpoints.
Tenants may have different AI policies (allowed models, budgets,
geographic restrictions).
## Decision
Introduce an **ASF AI Gateway** service (`services/asf-ai/`) with a
stable Python interface.
### Interface
Business code requests **capabilities**, never models:
```python
class AIGateway(Protocol):
async def analyze_code(self, request: CodeAnalysisRequest) -> AnalysisResult: ...
async def generate_tests(self, request: TestGenerationRequest) -> GeneratedTests: ...
async def chat(self, request: ChatRequest, ctx: AIContext) -> ChatResponse: ...
async def decompose_requirement(self, req: RequirementDecomposeRequest) -> Decomposition: ...
```
Provider adapters implement the gateway. Model selection, routing,
priority queuing, usage metering, cost tracking and tenant AI policy
live inside the gateway — not in callers.
### Bootstrap
The first implementation ships with a `MockProvider` that returns
deterministic canned responses with realistic latencies. Real providers
(OpenAI-compatible, NVIDIA, etc.) are added as adapters later without
touching domain/business code.
## Consequences
### Positive
- No LLM provider lock-in — swap / add adapters freely.
- Usage and cost tracking centralized.
- Tenant-level policy enforceable at one boundary.
- Tests for business logic run fast against the mock — no external API
calls, no flakiness.
### Negative
- One more abstraction — the initial API surface must be designed
carefully so future providers truly fit.
### Mitigation
- Provider adapters are written early for at least two real providers,
to validate the abstraction before domain code depends heavily on it.
+113
View File
@@ -0,0 +1,113 @@
# ASF Architecture Overview
> Metafora "**Ufficio Postale di ASF**":
>
> Ogni informazione che entra o esce da ASF è una **busta (Artifact)**.
> Le **Relazioni** sono gli indirizzi che legano una busta all'altra.
> RabbitMQ è il **carteggio** (posta interna), MongoDB l'**archivio**,
> l'AI Gateway è l'**ufficio analisti** che legge le buste e risponde,
> il Context Engine è l'**impiegato che raccoglie il fascicolo completo**
> prima di passarlo a un analista.
## Logical Diagram (ASCII)
```
┌─────────────────────────────────────────────┐
│ asf-web (Next.js) │
│ Shell · Overview · Context Explorer · │
│ Traceability (React Flow) · Ask ASF chat │
└────────────────────┬────────────────────────┘
│ HTTPS /api/v1
┌────────────────────▼────────────────────────┐
│ asf-core (FastAPI REST API) │
│ Orgs · Projects · Artifacts · Relations · │
│ Jobs CRUD · Audit · Auth boundary │
└──────┬───────────────┬───────────┬───────────┘
│ │ │
(writes/reads)│ (events) │ (queries)│
▼ ▼ ▼
┌─────────────┐ ┌────────────┐ ┌───────────────┐
│ MongoDB │ │ RabbitMQ │ │ asf-context │
│ (archivio) │ │ (carteggio)│ │ (fascicolatore)│
└─────────────┘ └──────┬─────┘ └───────┬───────┘
│ │
(jobs) │ │ assemble context
▼ │
┌───────────────────┐ │
│ asf-worker │ │
│ (addetti alle poste)│ │
│ consumers: │ │
│ ai.*, indexing, │ │
│ testing, devops │ │
└─────────┬─────────┘ │
│ │
(inference reqs) │
▼ │
┌───────────────────┐ │
│ asf-ai │◄──────┘
│ AI Gateway │
│ (ufficio analisti)│
│ ├─ MockProvider │
│ ├─ model routing │
│ └─ usage/cost │
└─────────┬─────────┘
│
▼
┌───────────────────────────────┐
│ Provider adapters (future): │
│ · NVIDIA DGX / OpenAI compat │
│ · External providers │
└───────────────────────────────┘
┌────────────────────────────────────────────────────────────────┐
│ Shared packages (imported by all services): │
│ · asf-domain → Pydantic models (Artifact, Job, Org, ...) │
│ · asf-events → Event schemas (RabbitMQ payload contracts) │
│ · asf-connectors → Integration interfaces (GitHub, Jira, ...) │
└────────────────────────────────────────────────────────────────┘
┌─────────────────────────────┐
│ Supporting infrastructure │
│ · Redis (cache / locks) │
│ · MinIO (large blobs) │
└─────────────────────────────┘
```
## Data Flow — end-to-end (Example: "Crea un Requirement")
1. User POST `/api/v1/projects/{p_id}/artifacts` (type=`requirement`)
via **asf-web** → **asf-core**.
2. **asf-core** validates tenant context, writes to MongoDB `artifacts`,
emits `artifact.created` event to RabbitMQ, returns `201`.
3. **asf-worker** picks up `artifact.created` from the events exchange.
4. Worker optionally submits `ai.inference.requested` for "enrich
requirement" to `asf.ai.background` queue.
5. Another worker calls **asf-ai** gateway → provider → returns
enrichment. Updates `artifact.metadata.enriched = true`, writes an
`artifact.updated` event, records an `AIExecution` in MongoDB.
6. UI polls / SSE receives update; Context Explorer now shows both the
Artifact AND its enrichment trace.
## Tenancy Boundary
```
Organization (tenant_id == organization_id in bootstrap)
│
├── Team[]
└── Project[]
└── Artifact[] + ArtifactRelationship[] + Job[]
```
**Rule:** Ogni query su dati tenant-owned è filtrata da `tenant_id`.
Il `tenant_id` non viene mai accettato dal client direttamente ma
deriva dal contesto di autenticazione validato.
## Extensibility Points
| Concern | Where | Future Hook |
|-----------------------|----------------------------|--------------------------------|
| Semantic search / RAG | asf-context | Vector DB (pgvector / Qdrant) |
| Graph traversal | asf-context (Mongo $lookup)| Graph DB projection |
| Auth / SSO | asf-core middleware | OIDC / SAML adapters |
| Billing / metering | asf-core + asf-ai usage | Subscription service |
| External providers | asf-connectors + AI Gateway| Per-system adapter |
+10
View File
@@ -0,0 +1,10 @@
# --- ASF Web (Next.js) ---
NEXT_PUBLIC_ASF_CORE_URL=http://localhost:8000
NEXT_PUBLIC_APP_NAME=Azurian Software Factory
NEXT_PUBLIC_APP_ENV=development
# --- Dev trust headers (bootstrap only) ---
# These are sent to asf-core so the dev-mode trust-header path injects tenant context.
# Remove in production and use real auth (JWT / session).
NEXT_PUBLIC_DEV_TENANT_ID=00000000-0000-0000-0000-000000000001
NEXT_PUBLIC_DEV_USER_ID=00000000-0000-0000-0000-000000000002
+3
View File
@@ -0,0 +1,3 @@
{
"extends": "next/core-web-vitals"
}
+7
View File
@@ -0,0 +1,7 @@
node_modules
.next
out
.env
.env.local
.DS_Store
npm-debug.log*
+111
View File
@@ -0,0 +1,111 @@
"use client";
import React from "react";
import { Bot, Cpu, Zap, ShieldCheck } from "lucide-react";
import { Card, CardBody, CardHeader, CardTitle } from "@/components/ui/Card";
import { Badge, JobStatusBadge } from "@/components/ui/Badge";
import { EmptyState } from "@/components/ui/EmptyState";
import { MetricCard } from "@/components/ui/MetricCard";
export default function AiAgentsPage() {
return (
<div className="p-6 lg:p-8 max-w-[1400px] mx-auto">
<header className="mb-6 flex flex-wrap items-start justify-between gap-3">
<div>
<div className="flex items-center gap-2">
<h1 className="text-2xl font-semibold tracking-tight text-slate-900 flex items-center gap-2">
<Bot className="w-6 h-6 text-accent" />
AI Agents
</h1>
<Badge tone="violet">α · Alpha</Badge>
</div>
<p className="mt-1 text-sm text-slate-500">
ASF AI Gateway — provider-agnostic inference, policy routing and usage metering. Interfaces are ready, runtime behaviors are stabilizing.
</p>
</div>
<div className="flex items-center gap-2">
<Badge tone="brand"><Cpu className="w-3 h-3" /> Gateway abstraction OK</Badge>
<Badge tone="green"><ShieldCheck className="w-3 h-3" /> No provider lock-in</Badge>
</div>
</header>
<section className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
<MetricCard label="Models routable" value={6} tone="violet" icon={<Bot className="w-5 h-5" />} subtitle="NVIDIA DGX · OpenAI compat · local" />
<MetricCard label="Active AI jobs" value={5} tone="brand" icon={<Zap className="w-5 h-5" />} delta={2.1} />
<MetricCard label="Tenant policies" value={2} tone="green" icon={<ShieldCheck className="w-5 h-5" />} subtitle="Cost ceilings, model allowlists" />
<MetricCard label="Queued prompts" value={12} tone="amber" icon={<Cpu className="w-5 h-5" />} />
</section>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
<Card className="lg:col-span-2">
<CardHeader>
<CardTitle>Agent registry</CardTitle>
<Badge tone="neutral">0 active · 5 templates</Badge>
</CardHeader>
<CardBody>
<EmptyState
title="No custom agents yet"
description="Create a reusable AI agent by selecting a model, system prompt, tools and a cost policy. Templates below are ready to enable."
icon={Bot}
actionLabel="New AI agent"
/>
<div className="mt-6 divide-y divide-slate-100 border border-slate-100 rounded-lg overflow-hidden">
{[
{ name: "Traceability Analyst", desc: "Recommends missing links across the artifact graph.", model: "routing default", status: "queued" },
{ name: "Test Generator", desc: "Generates unit tests for a selected Task artifact.", model: "code-model", status: "running" },
{ name: "Incident Responder", desc: "Traces an incident backwards to its offending commit/PR.", model: "reasoning-model", status: "queued" },
{ name: "Requirement Cleric", desc: "Refines ambiguous requirements and proposes acceptance criteria.", model: "reasoning-model", status: "completed" },
{ name: "Impact Predictor", desc: "Estimates blast radius for a proposed requirement change.", model: "routing default", status: "queued" },
].map((a) => (
<div key={a.name} className="flex items-center gap-3 px-4 py-3 hover:bg-slate-50">
<div className="w-9 h-9 rounded-lg bg-violet-50 border border-violet-100 grid place-items-center text-violet-600 shrink-0">
<Bot className="w-4 h-4" />
</div>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-slate-800">{a.name}</div>
<div className="text-xs text-slate-500">{a.desc}</div>
</div>
<Badge tone="neutral" className="hidden sm:inline-flex asf-monospace">{a.model}</Badge>
<JobStatusBadge status={a.status} />
</div>
))}
</div>
</CardBody>
</Card>
<Card>
<CardHeader>
<CardTitle>AI Gateway</CardTitle>
<Badge tone="violet">Interfaces</Badge>
</CardHeader>
<CardBody className="space-y-3 text-sm">
<div className="p-3 rounded-lg border border-violet-100 bg-violet-50/60">
<div className="font-semibold text-violet-900 flex items-center gap-1.5">
<Zap className="w-4 h-4" /> Provider adapter pattern
</div>
<p className="text-xs text-violet-800/80 mt-1 leading-relaxed">
Business logic depends on <code className="bg-white/70 px-1 rounded">asf-ai</code> interfaces only. Real providers plug in without touching domain code.
</p>
</div>
<ul className="space-y-2 text-xs">
{[
"Model routing by task + tenant policy",
"Usage metering (tokens, GPU-s, $)",
"Priority queues + cost ceilings",
"Audit log of every inference",
].map((t) => (
<li key={t} className="flex items-start gap-2 text-slate-600">
<ShieldCheck className="w-3.5 h-3.5 text-emerald-600 mt-0.5 shrink-0" />
{t}
</li>
))}
</ul>
<div className="pt-3 border-t border-slate-100 text-xs text-slate-500">
Hint: use <span className="asf-chip font-mono">⌘ K</span> → Ask ASF to try the interactive query interface.
</div>
</CardBody>
</Card>
</div>
</div>
);
}
+674
View File
@@ -0,0 +1,674 @@
"use client";
import React, { useRef, useState, useEffect, useMemo } from "react";
import { useQueryClient } from "@tanstack/react-query";
import {
Sparkles,
Send,
Loader2,
MessageSquare,
Bot,
User,
ChevronRight,
X,
Copy,
Check,
Clock,
AlertTriangle,
CheckCircle2,
GitBranch,
} from "lucide-react";
import { Card, CardBody, CardHeader, CardTitle } from "@/components/ui/Card";
import { Button } from "@/components/ui/Button";
import { Badge, ArtifactTypeBadge } from "@/components/ui/Badge";
import { EmptyState } from "@/components/ui/EmptyState";
import { askAsf, jobs, projects, artifacts as apiArtifacts } from "@/lib/api";
import { cn, fmtDate, shortId, truncate } from "@/lib/utils";
import type { Job, JobStatus, Artifact, Project } from "@/lib/types";
type MessageRole = "user" | "assistant" | "system";
type ChatStatus = "idle" | "queued" | "running" | "completed" | "failed" | "cancelled";
interface ChatMessage {
id: string;
role: MessageRole;
content: string;
timestamp: string;
status?: ChatStatus;
jobId?: string;
job?: Job | null;
error?: string;
}
const SUGGESTIONS = [
"Which services implement REQ-103 Requisito RBAC?",
"Summarize the current deployment chain from PR to PROD",
"What is the traceability score of project ASF Bootstrap?",
"List all tests that verify PR#88 feat(rbac)",
"How healthy is my project last 24h?",
];
const POLL_MS = 2000;
const MAX_POLLS = 120;
const statusTone: Record<Exclude<ChatStatus, "idle">, "brand" | "amber" | "green" | "red" | "neutral"> = {
queued: "neutral",
running: "brand",
completed: "green",
failed: "red",
cancelled: "neutral",
};
export default function AskAsfPage() {
const qc = useQueryClient();
const scrollRef = useRef<HTMLDivElement>(null);
const [input, setInput] = useState("");
const [projectId, setProjectId] = useState<string>("all");
const [ctxIds, setCtxIds] = useState<string[]>([]);
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [pendingJob, setPendingJob] = useState<{ jobId: string; msgId: string; polls: number } | null>(null);
const [copied, setCopied] = useState<string>("");
const projectsQ = useMemo(() => ({ isFetched: false, data: [] as Project[], error: null }), []);
const [projectsList, setProjectsList] = useState<Project[]>([]);
const [artifactsList, setArtifactsList] = useState<Artifact[]>([]);
useEffect(() => {
(async () => {
try {
const [ps, as] = await Promise.all([
projects.list({ limit: 50 }),
apiArtifacts.list({ limit: 100 }),
]);
setProjectsList(ps);
setArtifactsList(as);
if (ps.length === 1) setProjectId(String(ps[0].id));
} catch {
// ignore offline fallback: demo suggestions
}
})();
}, []);
useEffect(() => {
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: "smooth" });
}, [messages, pendingJob]);
useEffect(() => {
if (!pendingJob) return;
let cancelled = false;
let polls = pendingJob.polls;
const tick = async () => {
if (cancelled) return;
polls += 1;
try {
const j = await jobs.get(pendingJob.jobId);
setMessages((prev) =>
prev.map((m) =>
m.id === pendingJob.msgId
? {
...m,
status: (j.status as ChatStatus) ?? m.status,
job: j,
content: pickAnswer(j, m.content),
error: j.status === "failed" ? j.error ?? "AI job failed" : undefined,
}
: m,
),
);
void qc.invalidateQueries({ queryKey: ["jobs-list"] }).catch(() => undefined);
if (j.status === "completed" || j.status === "failed" || j.status === "cancelled" || polls > MAX_POLLS) {
if (polls > MAX_POLLS) {
setMessages((prev) =>
prev.map((m) =>
m.id === pendingJob.msgId
? {
...m,
status: "failed",
error: "Timed out waiting for AI answer (max 240s).",
}
: m,
),
);
}
setPendingJob(null);
return;
}
setPendingJob({ ...pendingJob, polls });
} catch {
// keep retrying
}
if (!cancelled) setTimeout(tick, POLL_MS);
};
const t = setTimeout(tick, POLL_MS);
return () => {
cancelled = true;
clearTimeout(t);
};
}, [pendingJob, qc]);
const suggestions = useMemo(() => SUGGESTIONS, []);
const toggleCtxArtifact = (id: string) => {
setCtxIds((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]));
};
const handleSubmit = async (e?: React.FormEvent) => {
e?.preventDefault();
const q = input.trim();
if (!q || pendingJob) return;
const userMsg: ChatMessage = {
id: cryptoId(),
role: "user",
content: q,
timestamp: new Date().toISOString(),
};
const assistantMsgId = cryptoId();
const assistantMsg: ChatMessage = {
id: assistantMsgId,
role: "assistant",
content: "Thinking…",
timestamp: new Date().toISOString(),
status: "queued",
};
setMessages((prev) => [...prev, userMsg, assistantMsg]);
setInput("");
try {
const res = await askAsf.query({
question: q,
project_id: projectId !== "all" ? projectId : undefined,
context_artifact_ids: ctxIds.length ? ctxIds : undefined,
});
setMessages((prev) =>
prev.map((m) =>
m.id === assistantMsgId ? { ...m, jobId: res.job_id, status: "queued" } : m,
),
);
setPendingJob({ jobId: res.job_id, msgId: assistantMsgId, polls: 0 });
} catch (err) {
setMessages((prev) =>
prev.map((m) =>
m.id === assistantMsgId
? {
...m,
status: "failed",
error: err instanceof Error ? err.message : "Failed to enquire ASF",
}
: m,
),
);
}
};
const handleCopy = (id: string, text: string) => {
void navigator.clipboard?.writeText(text).then(() => {
setCopied(id);
setTimeout(() => setCopied(""), 1800);
});
};
return (
<div className="h-full flex flex-col min-h-0">
<header className="px-6 lg:px-8 pt-6 pb-4">
<div className="flex items-start gap-3 flex-wrap">
<div className="w-11 h-11 rounded-xl bg-gradient-to-br from-accent to-brand-600 shadow-soft grid place-items-center text-white shrink-0">
<Sparkles className="w-5.5 h-5.5 w-[22px] h-[22px]" />
</div>
<div className="min-w-0 flex-1">
<h1 className="text-2xl font-semibold tracking-tight text-slate-900 flex items-center gap-2 flex-wrap">
Ask ASF
<Badge tone="green" className="uppercase text-[10px] tracking-wide">
AI Live
</Badge>
</h1>
<p className="mt-1 text-sm text-slate-500 max-w-2xl">
Natural-language interface to the ASF graph. Questions are enqueued as{" "}
<span className="asf-monospace text-slate-700 bg-slate-100 rounded px-1">ai_interactive</span> jobs and
answered by the ASF Worker calling the asf-ai Gateway.
</p>
</div>
</div>
</header>
<div className="flex-1 min-h-0 px-6 lg:px-8 pb-6 grid grid-cols-1 lg:grid-cols-12 gap-4">
<Card className="lg:col-span-4 xl:col-span-3 flex flex-col min-h-0">
<CardHeader>
<CardTitle className="!text-sm">Context & Scope</CardTitle>
</CardHeader>
<CardBody className="overflow-auto flex-1 min-h-0 space-y-4 text-sm">
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">Project scope</label>
<select
className="asf-input text-sm"
value={projectId}
onChange={(e) => setProjectId(e.target.value)}
disabled={!!pendingJob}
>
<option value="all">All projects (tenant-wide)</option>
{projectsList.map((p) => (
<option key={p.id} value={String(p.id)}>
{p.name}
</option>
))}
</select>
</div>
<div>
<div className="flex items-center justify-between mb-1.5">
<label className="block text-xs font-medium text-slate-500">Anchor artifacts</label>
{ctxIds.length > 0 && (
<button
type="button"
className="text-[11px] text-slate-400 hover:text-slate-600"
onClick={() => setCtxIds([])}
>
Clear ({ctxIds.length})
</button>
)}
</div>
<ul className="max-h-60 overflow-auto divide-y divide-slate-100 border border-slate-100 rounded-lg text-xs">
{artifactsList.length === 0 && (
<li className="p-3 text-slate-400">No artifacts loaded yet.</li>
)}
{artifactsList.map((a) => {
const on = ctxIds.includes(String(a.id));
return (
<li
key={a.id}
className={cn(
"flex items-center gap-2 px-2.5 py-2 cursor-pointer transition-colors",
on ? "bg-brand-50/60" : "hover:bg-slate-50",
)}
onClick={() => toggleCtxArtifact(String(a.id))}
>
<span
className={cn(
"w-3.5 h-3.5 rounded border grid place-items-center shrink-0",
on ? "bg-brand-500 border-brand-500 text-white" : "border-slate-300 bg-white",
)}
>
{on && <Check className="w-2.5 h-2.5" />}
</span>
<ArtifactTypeBadge type={a.type} />
<div className="min-w-0 flex-1">
<div className="truncate text-slate-700 font-medium">
{a.external_id ?? shortId(a.id)}
</div>
<div className="truncate text-slate-500">{truncate(a.title, 44)}</div>
</div>
</li>
);
})}
</ul>
</div>
<div className="pt-2 border-t border-slate-100">
<p className="text-xs font-semibold text-slate-500 mb-2 uppercase tracking-wide">Try asking</p>
<div className="space-y-1.5">
{suggestions.map((s) => (
<button
key={s}
type="button"
disabled={!!pendingJob}
onClick={() => {
setInput(s);
setTimeout(() => handleSubmit(), 0);
}}
className="w-full text-left text-xs p-2 rounded-md border border-slate-100 hover:border-brand-200 hover:bg-brand-50/30 text-slate-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center gap-2"
>
<ChevronRight className="w-3 h-3 text-brand-500 shrink-0" />
<span className="truncate">{s}</span>
</button>
))}
</div>
</div>
</CardBody>
</Card>
<Card className="lg:col-span-8 xl:col-span-9 flex flex-col min-h-0">
<CardHeader className="flex-wrap items-center gap-3">
<div className="min-w-0 flex-1">
<CardTitle className="!text-sm flex items-center gap-2">
<MessageSquare className="w-4 h-4 text-slate-400" />
Conversation
{pendingJob && (
<Badge tone={statusTone[(jobsStatusByJobId(pendingJob.jobId, messages) as Exclude<ChatStatus, "idle">) ?? "running"]} className="animate-pulse">
<Loader2 className="w-3 h-3 inline mr-1 animate-spin" />
Processing…
</Badge>
)}
</CardTitle>
</div>
<div className="flex gap-1">
<Button
variant="ghost"
size="sm"
disabled={messages.length === 0}
onClick={() => {
setMessages([]);
setPendingJob(null);
}}
>
<X className="w-3.5 h-3.5 mr-1" />
Clear
</Button>
</div>
</CardHeader>
<div
ref={scrollRef}
className="flex-1 min-h-0 overflow-auto px-5 py-4 space-y-5 bg-slate-50/40 border-y border-slate-100"
>
{messages.length === 0 ? (
<div className="h-full flex items-center justify-center">
<EmptyState
title="Ask anything about your ASF graph"
description="The AI worker will reason over projects, artifacts, relationships and jobs. Start by typing a question above or pick a suggestion."
icon={Sparkles}
/>
</div>
) : (
messages.map((m) => (
<MessageBubble
key={m.id}
m={m}
artifacts={artifactsList}
projects={projectsList}
onCopy={() => handleCopy(m.id, m.content)}
copied={copied === m.id}
onJumpArtifact={(id) => {
const u = `/context?artifact=${encodeURIComponent(id)}`;
window.open(u, "_blank", "noopener");
}}
/>
))
)}
</div>
<form
onSubmit={handleSubmit}
className="p-3 border-t border-slate-100 bg-white rounded-b-xl flex flex-col gap-2"
>
<div className="flex gap-2 items-end">
<div className="flex-1 relative">
<textarea
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
void handleSubmit();
}
}}
rows={2}
placeholder="Ask ASF: Which services implement REQ-103?… (Enter to send, Shift+Enter for newline)"
className="asf-input pr-24 resize-none min-h-[60px]"
disabled={!!pendingJob}
/>
<div className="absolute bottom-2.5 right-2.5 text-[11px] text-slate-400 pointer-events-none select-none">
{projectId !== "all" && (
<span className="asf-chip !py-0.5 !px-2 mr-1">Project: {projectsList.find((p) => String(p.id) === projectId)?.name ?? shortId(projectId)}</span>
)}
{ctxIds.length > 0 && (
<span className="asf-chip !py-0.5 !px-2">{ctxIds.length} anchors</span>
)}
</div>
</div>
<Button
type="submit"
disabled={!input.trim() || !!pendingJob}
className="h-[60px] !px-4"
>
{pendingJob ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Working…
</>
) : (
<>
<Send className="w-4 h-4 mr-2" />
Send
</>
)}
</Button>
</div>
</form>
</Card>
</div>
</div>
);
}
function MessageBubble({
m,
artifacts,
projects,
onCopy,
copied,
onJumpArtifact,
}: {
m: ChatMessage;
artifacts: Artifact[];
projects: Project[];
onCopy: () => void;
copied: boolean;
onJumpArtifact: (id: string) => void;
}) {
const isUser = m.role === "user";
const projMap = useMemo(() => {
const mp = new Map<string, Project>();
for (const p of projects) mp.set(String(p.id), p);
return mp;
}, [projects]);
const artMap = useMemo(() => {
const mp = new Map<string, Artifact>();
for (const a of artifacts) mp.set(String(a.id), a);
return mp;
}, [artifacts]);
return (
<div className={cn("flex gap-3", isUser ? "justify-end" : "justify-start")}>
<div
className={cn(
"w-8 h-8 shrink-0 rounded-lg grid place-items-center border",
isUser
? "order-2 bg-slate-900 text-white border-slate-900"
: "bg-white border-slate-200 text-accent shadow-sm",
)}
>
{isUser ? <User className="w-4 h-4" /> : <Bot className="w-4 h-4" />}
</div>
<div className={cn("max-w-[82%] flex flex-col gap-1.5", isUser ? "items-end" : "items-start")}>
<div className="flex items-center gap-2 text-[11px] text-slate-400">
<span className="font-semibold text-slate-500">{isUser ? "You" : "ASF AI"}</span>
<span>· {fmtDate(m.timestamp)}</span>
{m.jobId && (
<>
<GitBranch className="w-3 h-3 ml-1" />
<span className="asf-monospace">{shortId(m.jobId)}</span>
</>
)}
{m.status && m.status !== "idle" && (
<Badge tone={statusTone[m.status as Exclude<ChatStatus, "idle">] ?? "neutral"}>
{statusIcon(m.status)} {m.status}
</Badge>
)}
</div>
<div
className={cn(
"px-4 py-3 rounded-2xl border text-sm leading-relaxed",
isUser
? "bg-brand-600 text-white rounded-tr-sm border-brand-600"
: "bg-white text-slate-800 border-slate-200 rounded-tl-sm shadow-sm",
)}
>
{m.status === "queued" || m.status === "running" ? (
<div className="flex items-center gap-2">
<Loader2 className="w-4 h-4 animate-spin" />
<span className="text-slate-600">
{m.status === "queued"
? "Query queued as ai_interactive job. Waiting for ASF Worker to claim it…"
: "Worker claimed the job. Reasoning over graph and calling asf-ai…"}
</span>
</div>
) : (
<>
<div className="whitespace-pre-wrap break-words">
{m.status === "failed" || m.error ? (
<div className="flex items-start gap-2 text-rose-700 bg-rose-50/60 -mx-1 px-3 py-2 rounded-lg">
<AlertTriangle className="w-4 h-4 mt-0.5 shrink-0" />
<div>
<div className="font-semibold mb-0.5">AI execution failed</div>
<div className="text-[12px] text-rose-600">{m.error ?? "Unknown error"}</div>
</div>
</div>
) : (
<ContentAutoLink text={m.content} artMap={artMap} projMap={projMap} onJump={onJumpArtifact} />
)}
</div>
{m.job && (
<JobMeta job={m.job} />
)}
</>
)}
</div>
<div className="flex gap-1">
<button
type="button"
onClick={onCopy}
className="inline-flex items-center gap-1 text-[11px] text-slate-400 hover:text-slate-600 px-2 py-0.5 rounded-md hover:bg-slate-100"
>
{copied ? <Check className="w-3 h-3 text-emerald-500" /> : <Copy className="w-3 h-3" />}
{copied ? "Copied" : "Copy"}
</button>
</div>
</div>
</div>
);
}
function ContentAutoLink({
text,
artMap,
projMap,
onJump,
}: {
text: string;
artMap: Map<string, Artifact>;
projMap: Map<string, Project>;
onJump: (id: string) => void;
}) {
const pattern = /\b(REQ|TASK|PR|TEST|BUILD|DEP|DOC|SRC|SEC|INC|EPIC)[-#]?\d*[A-Za-z0-9]*\b/g;
const parts: Array<string | JSX.Element> = [];
let last = 0;
let m: RegExpExecArray | null;
let key = 0;
while ((m = pattern.exec(text))) {
if (m.index > last) parts.push(text.slice(last, m.index));
const token = m[0];
const foundArt = Array.from(artMap.values()).find(
(a) => (a.external_id && a.external_id.toLowerCase() === token.toLowerCase()) || false,
);
if (foundArt) {
parts.push(
<button
key={`link-${key++}`}
type="button"
onClick={() => onJump(String(foundArt.id))}
className="asf-monospace inline-flex items-center gap-1 mx-0.5 -my-0.5 px-1.5 py-0.5 rounded bg-brand-50 text-brand-700 border border-brand-200 hover:bg-brand-100 transition-colors align-baseline"
>
{token}
<ChevronRight className="w-3 h-3" />
</button>,
);
} else {
parts.push(token);
}
last = m.index + token.length;
}
if (last < text.length) parts.push(text.slice(last));
return <>{parts}</>;
}
function JobMeta({ job }: { job: Job }) {
const usage = job.usage;
return (
<div className="mt-3 pt-3 border-t border-slate-100 text-[11px] text-slate-500 grid grid-cols-2 sm:grid-cols-4 gap-2">
<JobMetaPill label="Status" tone={statusTone[job.status as Exclude<ChatStatus, "idle">] ?? "neutral"} value={job.status} />
<JobMetaPill label="Started" value={job.started_at ? fmtDate(job.started_at) : "—"} />
<JobMetaPill label="Completed" value={job.completed_at ? fmtDate(job.completed_at) : "—"} />
<JobMetaPill label="Worker" value={job.worker ?? "asf-worker"} />
{usage?.input_tokens !== undefined && <JobMetaPill label="Input tok." value={String(usage.input_tokens)} />}
{usage?.output_tokens !== undefined && <JobMetaPill label="Output tok." value={String(usage.output_tokens)} />}
{usage?.cpu_seconds !== undefined && <JobMetaPill label="CPU s." value={usage.cpu_seconds.toFixed(2)} />}
{usage?.estimated_cost !== undefined && <JobMetaPill label="Cost" value={`$${usage.estimated_cost.toFixed(4)}`} />}
</div>
);
}
function JobMetaPill({ label, value, tone }: { label: string; value: string; tone?: "brand" | "green" | "red" | "neutral" | "amber" }) {
const toneCls = tone
? {
brand: "bg-brand-50 text-brand-700 border-brand-100",
green: "bg-emerald-50 text-emerald-700 border-emerald-100",
red: "bg-rose-50 text-rose-700 border-rose-100",
neutral: "bg-slate-50 text-slate-600 border-slate-200",
amber: "bg-amber-50 text-amber-700 border-amber-100",
}[tone]
: "bg-slate-50 text-slate-600 border-slate-200";
return (
<div className={cn("rounded-md border px-2 py-1", toneCls)}>
<div className="uppercase tracking-wide text-[9px] opacity-70">{label}</div>
<div className="asf-monospace font-semibold truncate">{value}</div>
</div>
);
}
function statusIcon(s: ChatStatus) {
switch (s) {
case "queued":
return <Clock className="w-3 h-3 inline mr-1" />;
case "running":
return <Loader2 className="w-3 h-3 inline mr-1 animate-spin" />;
case "completed":
return <CheckCircle2 className="w-3 h-3 inline mr-1" />;
case "failed":
return <AlertTriangle className="w-3 h-3 inline mr-1" />;
case "cancelled":
return <X className="w-3 h-3 inline mr-1" />;
default:
return null;
}
}
function jobsStatusByJobId(jid: string, msgs: ChatMessage[]): JobStatus | null {
const m = msgs.find((x) => x.jobId === jid);
return (m?.job?.status ?? m?.status) as JobStatus | null;
}
function pickAnswer(j: Job, fallback: string): string {
if (j.result) {
const r = j.result as Record<string, unknown>;
for (const k of ["message", "answer", "summary", "response", "text"]) {
if (typeof r[k] === "string" && (r[k] as string).length > 0) return r[k] as string;
}
try {
return JSON.stringify(j.result, null, 2);
} catch {
/* noop */
}
}
if (j.status === "failed") {
return j.error ?? "ASF AI could not answer this question.";
}
if (j.status === "completed") {
return "Done. (AI result was returned with no message field.)";
}
return fallback;
}
function cryptoId(): string {
if (typeof crypto !== "undefined" && "randomUUID" in crypto) return crypto.randomUUID();
return "id-" + Math.random().toString(36).slice(2) + Date.now().toString(36);
}
+697
View File
@@ -0,0 +1,697 @@
"use client";
import React, { useMemo, useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import {
Code2,
Search,
AlertTriangle,
FileCode2,
CheckCircle2,
AlertOctagon,
AlertCircle,
Info,
ChevronRight,
ChevronDown,
GitBranch,
ArrowLeft,
Sparkles,
FileText,
Layers,
} from "lucide-react";
import { Card, CardBody, CardHeader, CardTitle } from "@/components/ui/Card";
import { Button } from "@/components/ui/Button";
import { Badge, ArtifactTypeBadge } from "@/components/ui/Badge";
import { EmptyState } from "@/components/ui/EmptyState";
import { artifacts as apiArtifacts, projects } from "@/lib/api";
import { cn, fmtDate, shortId, truncate } from "@/lib/utils";
import type { Artifact, ArtifactType, Project } from "@/lib/types";
type Scope = "all" | "source_file" | "smell";
type Severity = "all" | "low" | "medium" | "high" | "critical";
const SEVERITY_ORDER: Record<string, number> = { critical: 0, high: 1, medium: 2, low: 3 };
const SEVERITY_META: Record<Exclude<Severity, "all">, { icon: typeof AlertTriangle; tone: "red" | "amber" | "brand" | "neutral"; label: string }> = {
critical: { icon: AlertOctagon, tone: "red", label: "CRITICAL" },
high: { icon: AlertTriangle, tone: "amber", label: "HIGH" },
medium: { icon: AlertCircle, tone: "brand", label: "MEDIUM" },
low: { icon: Info, tone: "neutral", label: "LOW" },
};
const DEMO_ROWS: Artifact[] = [
{
id: "src-1", tenant_id: "t1", project_id: "p1",
type: "source_file", external_id: "SRC-DEMO-1",
title: "app/middleware/auth.py",
status: "active", source: "git",
content: "def authorize(request):\n return check_jwt(request.headers.get('authorization'))\n",
metadata: { language: "python", path: "app/middleware/auth.py", loc: 134, repo: "asf", branch: "main" },
created_at: "2025-11-01T09:00:00Z", updated_at: "2025-11-05T11:00:00Z",
},
{
id: "sm-1", tenant_id: "t1", project_id: "p1",
type: "ai_analysis", external_id: "CS-DEMO-1",
title: "SQL injection in user lookup (no ORM)",
status: "open", source: "bandit",
content: "Line 22 uses f-string on cursor.execute().",
metadata: { kind: "code_smell", language: "python", severity: "high", rule_id: "B608", engine: "bandit", lines: [20, 21, 22] },
created_at: "2025-11-08T09:00:00Z", updated_at: "2025-11-08T09:00:00Z",
},
];
function isSmell(a: Artifact): boolean {
const md = a.metadata ?? ({} as Record<string, unknown>);
return typeof md === "object" && (md as Record<string, unknown>)["kind"] === "code_smell";
}
function isSourceFile(a: Artifact): boolean {
return a.type === "source_file";
}
function getLanguage(a: Artifact): string {
const md = a.metadata ?? ({} as Record<string, unknown>);
const v = (md as Record<string, unknown>)["language"];
return typeof v === "string" ? v : "—";
}
function getSeverity(a: Artifact): "low" | "medium" | "high" | "critical" | null {
const md = a.metadata ?? ({} as Record<string, unknown>);
const v = (md as Record<string, unknown>)["severity"];
if (typeof v === "string" && (v === "low" || v === "medium" || v === "high" || v === "critical")) return v;
return null;
}
function getRule(a: Artifact): string | null {
const md = a.metadata ?? ({} as Record<string, unknown>);
const v = (md as Record<string, unknown>)["rule_id"];
return typeof v === "string" ? v : null;
}
function getEngine(a: Artifact): string | null {
const md = a.metadata ?? ({} as Record<string, unknown>);
const v = (md as Record<string, unknown>)["engine"];
return typeof v === "string" ? v : null;
}
function getPath(a: Artifact): string | null {
const md = a.metadata ?? ({} as Record<string, unknown>);
const v = (md as Record<string, unknown>)["path"];
return typeof v === "string" ? v : null;
}
function getLines(a: Artifact): number[] | null {
const md = a.metadata ?? ({} as Record<string, unknown>);
const v = (md as Record<string, unknown>)["lines"];
return Array.isArray(v) && v.every((x) => typeof x === "number") ? (v as number[]) : null;
}
function getLOC(a: Artifact): number | null {
const md = a.metadata ?? ({} as Record<string, unknown>);
const v = (md as Record<string, unknown>)["loc"];
return typeof v === "number" ? v : null;
}
function getRepo(a: Artifact): string | null {
const md = a.metadata ?? ({} as Record<string, unknown>);
const v = (md as Record<string, unknown>)["repo"];
return typeof v === "string" ? v : null;
}
function getBranch(a: Artifact): string | null {
const md = a.metadata ?? ({} as Record<string, unknown>);
const v = (md as Record<string, unknown>)["branch"];
return typeof v === "string" ? v : null;
}
function getConfidence(a: Artifact): number | null {
const md = a.metadata ?? ({} as Record<string, unknown>);
const v = (md as Record<string, unknown>)["confidence_pct"];
return typeof v === "number" ? v : null;
}
export default function CodePage() {
const [scope, setScope] = useState<Scope>("all");
const [projectFilter, setProjectFilter] = useState<string>("all");
const [languageFilter, setLanguageFilter] = useState<string>("all");
const [severityFilter, setSeverityFilter] = useState<Severity>("all");
const [statusFilter, setStatusFilter] = useState<string>("all");
const [q, setQ] = useState<string>("");
const [detailId, setDetailId] = useState<string>("");
const projectsQ = useQuery({
queryKey: ["code-projects"],
queryFn: () => projects.list({ limit: 50 }),
retry: false, refetchOnWindowFocus: false,
});
const apiResolved = projectsQ.isFetched && projectsQ.data !== undefined;
const projectRows: Project[] = apiResolved ? projectsQ.data! : [];
const projectNameById = useMemo(() => {
const m = new Map<string, string>();
for (const p of projectRows) m.set(String(p.id), p.name);
return m;
}, [projectRows]);
const sourceQ = useQuery({
queryKey: ["code-source", projectFilter, q.trim()],
queryFn: () =>
apiArtifacts.list({
type: "source_file" as ArtifactType,
project_id: projectFilter !== "all" ? projectFilter : undefined,
search: q.trim() || undefined,
limit: 200,
}),
retry: false, refetchOnWindowFocus: false,
});
const smellsQ = useQuery({
queryKey: ["code-smells", projectFilter, q.trim()],
queryFn: () =>
apiArtifacts.list({
type: "ai_analysis" as ArtifactType,
project_id: projectFilter !== "all" ? projectFilter : undefined,
search: q.trim() || undefined,
limit: 200,
}),
retry: false, refetchOnWindowFocus: false,
});
const sourceResolved = sourceQ.isFetched && sourceQ.data !== undefined;
const smellsResolved = smellsQ.isFetched && smellsQ.data !== undefined;
const isMock = !sourceResolved || !smellsResolved;
const rawRows: Artifact[] = useMemo(() => {
const allSources = sourceResolved ? sourceQ.data! : DEMO_ROWS.filter(isSourceFile);
const allAnalyses = smellsResolved ? smellsQ.data! : DEMO_ROWS.filter(isSmell);
const smellsOnly = allAnalyses.filter(isSmell);
const combined = [...allSources, ...smellsOnly];
// apply language + severity + status filters (server can't filter metadata)
return combined.filter((a) => {
if (scope === "source_file" && !isSourceFile(a)) return false;
if (scope === "smell" && !isSmell(a)) return false;
if (languageFilter !== "all" && getLanguage(a) !== languageFilter) return false;
if (severityFilter !== "all") {
if (!isSmell(a)) return false;
if (getSeverity(a) !== severityFilter) return false;
}
if (statusFilter !== "all" && (a.status ?? "") !== statusFilter) return false;
return true;
}).sort((b, a) => {
// severity first (only if smell), then updated_at desc
const sa = isSmell(a) ? (SEVERITY_ORDER[getSeverity(a) ?? "low"] ?? 9) : 99;
const sb = isSmell(b) ? (SEVERITY_ORDER[getSeverity(b) ?? "low"] ?? 9) : 99;
if (sa !== sb) return sa - sb;
return (a.updated_at ?? "").localeCompare(b.updated_at ?? "");
});
}, [sourceResolved, smellsResolved, sourceQ.data, smellsQ.data, scope, languageFilter, severityFilter, statusFilter]);
const languages = useMemo(() => {
const s = new Set<string>();
for (const r of rawRows) {
const l = getLanguage(r);
if (l && l !== "—") s.add(l);
}
return Array.from(s).sort();
}, [rawRows]);
const qc = useQueryClient();
const markFixedM = useMutation({
mutationFn: async ({ id, status }: { id: string; status: string }) =>
apiArtifacts.update(id, { status } as Partial<Artifact>),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["code-source"] });
qc.invalidateQueries({ queryKey: ["code-smells"] });
},
});
const stats = useMemo(() => {
const src = rawRows.filter(isSourceFile).length;
const sm = rawRows.filter(isSmell);
const smBySev = { low: 0, medium: 0, high: 0, critical: 0 } as Record<"low" | "medium" | "high" | "critical", number>;
for (const s of sm) {
const sv = getSeverity(s);
if (sv) smBySev[sv] += 1;
}
return { src, smells: sm.length, smBySev };
}, [rawRows]);
const effectiveDetail = useMemo(() => {
if (!detailId) return null;
return rawRows.find((r) => String(r.id) === String(detailId)) ?? null;
}, [detailId, rawRows]);
if (effectiveDetail) {
return (
<CodeDetailView
artifact={effectiveDetail}
projectName={projectNameById.get(String(effectiveDetail.project_id)) ?? String(effectiveDetail.project_id)}
onBack={() => setDetailId("")}
onMarkFixed={(status) =>
markFixedM.mutate({ id: String(effectiveDetail.id), status })
}
markFixedPending={markFixedM.isPending}
/>
);
}
return (
<div className="h-full flex flex-col min-h-0">
<header className="px-6 lg:px-8 pt-6 pb-4">
<div className="flex items-start gap-3 flex-wrap">
<div className="w-11 h-11 rounded-xl bg-gradient-to-br from-brand-500 to-violet-600 shadow-soft grid place-items-center text-white shrink-0">
<Code2 className="w-5 h-5" />
</div>
<div className="min-w-0 flex-1">
<h1 className="text-2xl font-semibold tracking-tight text-slate-900 flex items-center gap-2 flex-wrap">
Code Analysis
{isMock && <Badge tone="amber">Demo data</Badge>}
</h1>
<p className="mt-1 text-sm text-slate-500 max-w-3xl">
Source files indexed from your repositories + automated code smells detected by ASF AI Analyzer
(Ruff / ESLint / Bandit …). Click any row to see the detail with highlighted lines.
</p>
</div>
</div>
</header>
<div className="flex-1 min-h-0 px-6 lg:px-8 pb-6 flex flex-col gap-4 min-w-0">
<div className="grid grid-cols-2 md:grid-cols-5 gap-3">
<StatCard label="Source files" value={stats.src} tone="brand" icon={FileCode2} />
<StatCard label="Code smells" value={stats.smells} tone="amber" icon={AlertTriangle} />
<StatCard label="Critical" value={stats.smBySev.critical} tone="red" icon={AlertOctagon} />
<StatCard label="High" value={stats.smBySev.high} tone="amber" icon={AlertTriangle} />
<StatCard label="Med/Low" value={stats.smBySev.medium + stats.smBySev.low} tone="neutral" icon={Info} />
</div>
<Card className="flex-1 min-h-0 flex flex-col">
<CardHeader className="flex-wrap items-end gap-3">
<div className="min-w-0 flex-1">
<CardTitle className="!text-sm flex items-center gap-2">
<Layers className="w-4 h-4 text-slate-400" />
Inventory
<span className="asf-chip">{rawRows.length}</span>
</CardTitle>
</div>
<div className="flex flex-wrap gap-2 items-center">
<div className="relative w-full md:w-64">
<Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" />
<input
className="asf-input pl-9"
placeholder="Search paths, titles, rules…"
value={q}
onChange={(e) => setQ(e.target.value)}
/>
</div>
<select className="asf-input !w-auto" value={scope} onChange={(e) => setScope(e.target.value as Scope)}>
<option value="all">All code</option>
<option value="source_file">Source files only</option>
<option value="smell">Code smells only</option>
</select>
<select className="asf-input !w-auto" value={languageFilter} onChange={(e) => setLanguageFilter(e.target.value)}>
<option value="all">Any language</option>
{languages.map((l) => (
<option key={l} value={l}>{l.toUpperCase()}</option>
))}
</select>
<select className="asf-input !w-auto" value={severityFilter} onChange={(e) => setSeverityFilter(e.target.value as Severity)}>
<option value="all">Any severity</option>
<option value="critical">CRITICAL</option>
<option value="high">HIGH</option>
<option value="medium">MEDIUM</option>
<option value="low">LOW</option>
</select>
<select className="asf-input !w-auto" value={statusFilter} onChange={(e) => setStatusFilter(e.target.value)}>
<option value="all">Any status</option>
{Array.from(new Set(rawRows.map((r) => r.status ?? "unknown").filter(Boolean))).sort().map((s) => (
<option key={s} value={s}>{s}</option>
))}
</select>
<select className="asf-input !w-auto" value={projectFilter} onChange={(e) => setProjectFilter(e.target.value)}>
<option value="all">All projects</option>
{projectRows.map((p) => (
<option key={p.id} value={String(p.id)}>{p.name}</option>
))}
</select>
</div>
</CardHeader>
<CardBody className="p-0 flex flex-col min-h-0 overflow-hidden">
{rawRows.length === 0 ? (
<div className="flex-1 grid place-items-center p-10">
<EmptyState title="No matches" description="Try a different combination of filters, or index a repository to seed source files." icon={FileCode2} />
</div>
) : (
<div className="overflow-auto flex-1 min-h-0">
<table className="w-full text-sm">
<thead className="sticky top-0 z-10 bg-slate-50/95 backdrop-blur-sm border-b border-slate-200 text-[11px] uppercase tracking-wide text-slate-500">
<tr>
<th className="px-4 py-2.5 text-left w-16">Kind</th>
<th className="px-4 py-2.5 text-left">Artifact</th>
<th className="px-4 py-2.5 text-left w-28">Lang</th>
<th className="px-4 py-2.5 text-left w-28">Severity</th>
<th className="px-4 py-2.5 text-left w-28">Status</th>
<th className="px-4 py-2.5 text-left w-48">Project</th>
<th className="px-4 py-2.5 text-right w-28">Updated</th>
<th className="px-4 py-2.5 w-10" />
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{rawRows.map((a) => (
<tr
key={a.id}
onClick={() => setDetailId(String(a.id))}
className="group cursor-pointer hover:bg-slate-50/80 transition-colors"
>
<td className="px-4 py-3 align-top">
{isSourceFile(a) ? (
<Badge tone="blue" className="flex items-center gap-1 w-fit">
<FileCode2 className="w-3 h-3" />
<span>file</span>
</Badge>
) : (
<Badge tone="amber" className="flex items-center gap-1 w-fit">
<Sparkles className="w-3 h-3" />
<span>smell</span>
</Badge>
)}
</td>
<td className="px-4 py-3 align-top min-w-0">
<div className="flex items-center gap-2">
<span className="asf-monospace text-[11px] text-slate-500">
{a.external_id ?? shortId(a.id)}
</span>
{isSmell(a) && getRule(a) && (
<Badge tone="neutral">{getRule(a)}</Badge>
)}
{isSourceFile(a) && getLOC(a) != null && (
<span className="asf-chip">{getLOC(a)} LOC</span>
)}
</div>
<div className="mt-0.5 font-medium text-slate-800 truncate max-w-[520px]">
{a.title}
</div>
{getPath(a) && (
<div className="mt-0.5 text-xs text-slate-500 truncate font-mono max-w-[520px]">
{getPath(a)}
</div>
)}
{isSmell(a) && getEngine(a) && (
<div className="mt-1 text-[11px] text-slate-400">
scanner: <span className="text-slate-600 font-medium">{getEngine(a)}</span>
</div>
)}
</td>
<td className="px-4 py-3 align-top">
<Badge tone="brand" className="uppercase">{getLanguage(a)}</Badge>
</td>
<td className="px-4 py-3 align-top">
{isSmell(a) && getSeverity(a) ? (
<SeverityBadge s={getSeverity(a)!} />
) : (
<span className="text-slate-300 text-xs">—</span>
)}
</td>
<td className="px-4 py-3 align-top">
{a.status ? <Badge tone={a.status === "open" ? "amber" : a.status === "fixed" ? "green" : a.status === "active" ? "blue" : "brand"}>{a.status}</Badge> : <span className="text-slate-300 text-xs">—</span>}
</td>
<td className="px-4 py-3 align-top text-slate-600 text-xs truncate max-w-[200px]">
{projectNameById.get(String(a.project_id)) ?? shortId(a.project_id)}
</td>
<td className="px-4 py-3 align-top text-right text-xs text-slate-500 whitespace-nowrap">
{fmtDate(a.updated_at)}
</td>
<td className="px-4 py-3 align-top text-right">
<ChevronRight className="w-4 h-4 text-slate-300 group-hover:text-brand-500 inline-block" />
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</CardBody>
</Card>
</div>
</div>
);
}
function SeverityBadge({ s }: { s: "low" | "medium" | "high" | "critical" }) {
const m = SEVERITY_META[s];
const Icon = m.icon;
return (
<Badge tone={m.tone} className="inline-flex items-center gap-1">
<Icon className="w-3 h-3" />
{m.label}
</Badge>
);
}
function StatCard({
label, value, tone, icon: Icon,
}: {
label: string; value: number; tone: "brand" | "amber" | "red" | "neutral" | "green" | "blue";
icon: React.ComponentType<{ className?: string }>;
}) {
const toneBg: Record<string, string> = {
brand: "from-brand-500 to-brand-600",
amber: "from-amber-400 to-amber-500",
red: "from-rose-500 to-red-600",
neutral: "from-slate-400 to-slate-500",
green: "from-emerald-400 to-emerald-600",
blue: "from-sky-400 to-blue-600",
};
return (
<Card className="!shadow-none overflow-hidden">
<CardBody className="p-4 flex items-center gap-3">
<div className={cn("w-11 h-11 rounded-xl bg-gradient-to-br shadow-sm grid place-items-center text-white shrink-0", toneBg[tone])}>
<Icon className="w-5 h-5" />
</div>
<div className="min-w-0 flex-1">
<div className="text-xs uppercase tracking-wide text-slate-500 font-medium">{label}</div>
<div className="text-2xl font-semibold text-slate-900 leading-tight mt-0.5">{value}</div>
</div>
</CardBody>
</Card>
);
}
function CodeDetailView({
artifact, projectName, onBack, onMarkFixed, markFixedPending,
}: {
artifact: Artifact;
projectName: string;
onBack: () => void;
onMarkFixed?: (status: string) => void;
markFixedPending?: boolean;
}) {
const lang = getLanguage(artifact);
const sev = getSeverity(artifact);
const path = getPath(artifact);
const rule = getRule(artifact);
const engine = getEngine(artifact);
const repo = getRepo(artifact);
const branch = getBranch(artifact);
const lines = getLines(artifact);
const confidence = getConfidence(artifact);
const smell = isSmell(artifact);
const source = isSourceFile(artifact);
const linesToHighlight = useMemo(() => {
const set = new Set<number>();
if (lines) {
for (const n of lines) set.add(n);
}
return set;
}, [lines]);
const contentLines = useMemo(() => {
const raw = artifact.content ?? "";
return raw.split("\n");
}, [artifact.content]);
return (
<div className="h-full flex flex-col min-h-0">
<header className="px-6 lg:px-8 pt-6 pb-4">
<Button variant="ghost" size="sm" className="mb-3 -ml-2" onClick={onBack}>
<ArrowLeft className="w-4 h-4 mr-1" /> Back to inventory
</Button>
<div className="flex items-start gap-3 flex-wrap">
<div className={cn(
"w-11 h-11 rounded-xl bg-gradient-to-br shadow-soft grid place-items-center text-white shrink-0",
smell
? "from-amber-400 to-rose-500"
: "from-brand-500 to-violet-600",
)}>
{smell ? <AlertTriangle className="w-5 h-5" /> : <FileCode2 className="w-5 h-5" />}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<ArtifactTypeBadge type={artifact.type as ArtifactType} />
{smell ? (
<Badge tone="amber">code_smell</Badge>
) : (
<Badge tone="blue">source_file</Badge>
)}
{sev && <SeverityBadge s={sev} />}
{rule && <Badge tone="neutral">rule {rule}</Badge>}
{lang && <Badge tone="brand" className="uppercase">{lang}</Badge>}
{artifact.status && (
<Badge tone={artifact.status === "open" ? "amber" : artifact.status === "fixed" ? "green" : artifact.status === "active" ? "blue" : "brand"}>
{artifact.status}
</Badge>
)}
<span className="asf-monospace text-xs text-slate-500">
{artifact.external_id ?? shortId(artifact.id)}
</span>
</div>
<h1 className="mt-2 text-xl font-semibold text-slate-900 break-words">{artifact.title}</h1>
{path && (
<div className="mt-1 text-xs text-slate-500 font-mono break-all">{path}</div>
)}
<div className="mt-1 text-xs text-slate-500 flex flex-wrap items-center gap-x-3 gap-y-1">
<span>Project: <span className="text-slate-700 font-medium">{projectName}</span></span>
{engine && <span>Scanner: <span className="text-slate-700 font-medium">{engine}</span></span>}
{repo && (
<span className="inline-flex items-center gap-1">
<GitBranch className="w-3 h-3 text-slate-400" />
<span className="text-slate-700 font-medium">{truncate(repo, 40)}{branch ? ` @ ${branch}` : ""}</span>
</span>
)}
{confidence != null && <span>Confidence: <span className="text-slate-700 font-medium">{confidence}%</span></span>}
<span>Updated: <span className="text-slate-700 font-medium">{fmtDate(artifact.updated_at)}</span></span>
{lines && <span>Affected lines: <span className="text-slate-700 font-medium">{lines.join(", ")}</span></span>}
{source && getLOC(artifact) != null && <span>Lines of code: <span className="text-slate-700 font-medium">{getLOC(artifact)!}</span></span>}
</div>
</div>
{smell && onMarkFixed && (
<div className="flex items-center gap-2 shrink-0 ml-auto">
{artifact.status === "fixed" ? (
<Badge tone="green" className="inline-flex items-center gap-1 !px-3 !py-1.5">
<CheckCircle2 className="w-3.5 h-3.5" /> Resolved
</Badge>
) : (
<Button
size="sm"
variant="primary"
disabled={markFixedPending}
onClick={() => onMarkFixed("fixed")}
className="!bg-emerald-500 hover:!bg-emerald-600"
>
<CheckCircle2 className="w-3.5 h-3.5 mr-1.5" />
{markFixedPending ? "Saving..." : "Mark as fixed"}
</Button>
)}
</div>
)}
</div>
</header>
<div className="flex-1 min-h-0 px-6 lg:px-8 pb-6 grid grid-cols-1 lg:grid-cols-12 gap-4">
<Card className="lg:col-span-7 flex flex-col min-h-0">
<CardHeader>
<CardTitle className="!text-sm flex items-center gap-2">
<FileText className="w-4 h-4 text-slate-400" />
{smell ? "Finding details" : "Source preview"}
</CardTitle>
</CardHeader>
<CardBody className="p-0 flex flex-col min-h-0 overflow-hidden">
{contentLines.length === 1 && contentLines[0] === "" ? (
<div className="p-10">
<EmptyState title="No content body available" description="This artifact is metadata-only. The blob content may still be fetchable from the Git integration." icon={FileCode2} />
</div>
) : (
<pre className="flex-1 min-h-0 overflow-auto text-[12.5px] leading-6 bg-slate-900 text-slate-100 p-4 font-mono rounded-b-xl">
{contentLines.map((ln, i) => {
const n = i + 1;
const hi = linesToHighlight.has(n);
return (
<div
key={n}
className={cn(
"flex -mx-4 px-4",
hi && "bg-rose-500/15 border-l-2 border-rose-400",
)}
>
<span className={cn(
"w-10 shrink-0 text-right pr-3 select-none",
hi ? "text-rose-200" : "text-slate-600",
)}>{n}</span>
<span className="flex-1 min-w-0 break-all whitespace-pre-wrap">{ln || " "}</span>
</div>
);
})}
</pre>
)}
</CardBody>
</Card>
<Card className="lg:col-span-5 flex flex-col min-h-0">
<CardHeader>
<CardTitle className="!text-sm flex items-center gap-2">
{smell ? <AlertTriangle className="w-4 h-4 text-amber-500" /> : <CheckCircle2 className="w-4 h-4 text-emerald-500" />}
{smell ? "Remediation guidance" : "File index info"}
</CardTitle>
</CardHeader>
<CardBody className="space-y-4 text-sm">
{smell ? (
<>
<SuggestionBlock
title="Root cause"
body={
`Rule ${rule ?? "unknown"} reported by ${engine ?? "scanner"}. ` +
(lines?.length ? `Focus attention on ${lines.length} affected line${lines.length > 1 ? "s" : ""}.` : "Inspect the highlighted rows in the source pane.")
}
tone="amber"
/>
<SuggestionBlock
title="Suggested fix"
body={
sev === "critical" || sev === "high"
? "Open a TASK linked to this finding, assign an owner, and block the PR/deployment pipeline until merged. Prefer writing a regression test that proves the issue."
: "Fix in the next planned sprint; add a suppression comment only if you've filed a follow-up TASK with justification."
}
tone={sev === "critical" || sev === "high" ? "red" : "brand"}
/>
<div className="grid grid-cols-2 gap-2 text-xs">
<InfoCell label="Version" value={artifact.version != null ? `v${artifact.version}` : "—"} />
<InfoCell label="Source" value={artifact.source ?? "—"} />
<InfoCell label="Created" value={fmtDate(artifact.created_at)} />
<InfoCell label="Updated" value={fmtDate(artifact.updated_at)} />
</div>
</>
) : (
<>
<SuggestionBlock
title="Traceability"
body="This file is linked to the ASF artifact graph. Open Context Explorer to inspect which PRs, commits, tasks, and deployments reference it."
tone="brand"
/>
<div className="grid grid-cols-2 gap-2 text-xs">
<InfoCell label="LOC" value={getLOC(artifact) != null ? String(getLOC(artifact)!) : "—"} />
<InfoCell label="Language" value={lang ? lang.toUpperCase() : "—"} />
<InfoCell label="Created" value={fmtDate(artifact.created_at)} />
<InfoCell label="Updated" value={fmtDate(artifact.updated_at)} />
</div>
</>
)}
</CardBody>
</Card>
</div>
</div>
);
}
function SuggestionBlock({
title, body, tone,
}: { title: string; body: string; tone: "brand" | "amber" | "red" | "green" | "blue" }) {
const toneCls: Record<string, string> = {
brand: "bg-brand-50 border-brand-100 text-brand-900",
amber: "bg-amber-50 border-amber-100 text-amber-900",
red: "bg-rose-50 border-rose-100 text-rose-900",
green: "bg-emerald-50 border-emerald-100 text-emerald-900",
blue: "bg-sky-50 border-sky-100 text-sky-900",
};
return (
<div className={cn("p-3 rounded-lg border", toneCls[tone])}>
<div className="text-xs font-semibold uppercase tracking-wide mb-1 opacity-80">{title}</div>
<p className="text-sm leading-snug">{body}</p>
</div>
);
}
function InfoCell({ label, value }: { label: string; value: string }) {
return (
<div className="rounded-md border border-slate-100 bg-slate-50/60 px-2.5 py-1.5">
<div className="uppercase tracking-wide text-[9px] text-slate-400">{label}</div>
<div className="asf-monospace font-semibold text-slate-700 truncate">{value}</div>
</div>
);
}
File diff suppressed because it is too large Load Diff
+89
View File
@@ -0,0 +1,89 @@
"use client";
import React from "react";
import { Rocket, Cloud, CheckCircle2, AlertTriangle, Clock } from "lucide-react";
import { Card, CardBody, CardHeader, CardTitle } from "@/components/ui/Card";
import { Badge, ArtifactTypeBadge } from "@/components/ui/Badge";
import { EmptyState } from "@/components/ui/EmptyState";
import { MetricCard } from "@/components/ui/MetricCard";
import { fmtDate } from "@/lib/utils";
const DEPLOYS = [
{ id: "DEP-44", env: "prod-west-1", service: "asf-core", version: "v0.4.0", status: "done", at: "2025-11-14T14:12:00Z", by: "ci-bot" },
{ id: "DEP-43", env: "staging", service: "asf-core", version: "v0.4.0-rc3", status: "done", at: "2025-11-13T17:00:00Z", by: "ci-bot" },
{ id: "DEP-42", env: "prod-west-1", service: "asf-web", version: "v0.3.2", status: "done", at: "2025-11-11T09:30:00Z", by: "m.rossi" },
{ id: "DEP-41", env: "prod-west-1", service: "asf-worker", version: "v0.3.1", status: "failed", at: "2025-11-09T20:45:00Z", by: "ci-bot" },
{ id: "DEP-40", env: "staging", service: "asf-ai", version: "v0.3.0", status: "running", at: new Date().toISOString(), by: "ci-bot" },
];
export default function DeploymentsPage() {
return (
<div className="p-6 lg:p-8 max-w-[1400px] mx-auto">
<header className="mb-6 flex flex-wrap items-start justify-between gap-3">
<div>
<h1 className="text-2xl font-semibold tracking-tight text-slate-900 flex items-center gap-2">
<Rocket className="w-6 h-6 text-emerald-600" />
Deployments
</h1>
<p className="mt-1 text-sm text-slate-500">
Release history per environment, linked back to Build, Pull Request and Requirement artifacts.
</p>
</div>
<Badge tone="neutral">Argo CD · readiness probe</Badge>
</header>
<section className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
<MetricCard label="Deployed (7d)" value={14} tone="green" icon={<Rocket className="w-5 h-5" />} delta={2.0} />
<MetricCard label="Success rate" value={92.6} isPercentage tone="brand" icon={<CheckCircle2 className="w-5 h-5" />} />
<MetricCard label="In progress" value={1} tone="blue" icon={<Clock className="w-5 h-5" />} />
<MetricCard label="Failed (7d)" value={1} tone="red" icon={<AlertTriangle className="w-5 h-5" />} />
</section>
<Card>
<CardHeader>
<CardTitle>Recent deployments</CardTitle>
<div className="flex items-center gap-2 text-xs">
<Badge tone="green"><Cloud className="w-3 h-3" /> prod-west-1</Badge>
<Badge tone="blue"><Cloud className="w-3 h-3" /> staging</Badge>
</div>
</CardHeader>
<CardBody className="p-0">
{DEPLOYS.length === 0 ? (
<div className="p-6"><EmptyState title="No deployments" description="Connect a CI/CD integration or create a Deployment artifact." /></div>
) : (
<table className="w-full text-sm">
<thead className="bg-slate-50 text-xs uppercase tracking-wide text-slate-500 border-b border-slate-200">
<tr>
<th className="text-left px-4 py-2">ID</th>
<th className="text-left px-4 py-2">Env</th>
<th className="text-left px-4 py-2">Service</th>
<th className="text-left px-4 py-2">Version</th>
<th className="text-left px-4 py-2">Status</th>
<th className="text-left px-4 py-2">At</th>
<th className="text-left px-4 py-2">By</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{DEPLOYS.map((d) => (
<tr key={d.id} className="hover:bg-slate-50">
<td className="px-4 py-2.5 asf-monospace text-xs text-slate-500">{d.id}</td>
<td className="px-4 py-2.5">
<Badge tone={d.env.startsWith("prod") ? "green" : "blue"}>{d.env}</Badge>
</td>
<td className="px-4 py-2.5"><ArtifactTypeBadge type="deployment" /> <span className="ml-2 font-medium text-slate-800">{d.service}</span></td>
<td className="px-4 py-2.5 asf-monospace text-xs">{d.version}</td>
<td className="px-4 py-2.5">
<Badge tone={d.status === "done" ? "green" : d.status === "running" ? "blue" : "red"}>{d.status}</Badge>
</td>
<td className="px-4 py-2.5 text-xs text-slate-500 whitespace-nowrap">{fmtDate(d.at)}</td>
<td className="px-4 py-2.5 text-xs text-slate-600">{d.by}</td>
</tr>
))}
</tbody>
</table>
)}
</CardBody>
</Card>
</div>
);
}
+89
View File
@@ -0,0 +1,89 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
color-scheme: light dark;
--font-sans: "Inter", ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
}
html, body, #__next {
height: 100%;
}
body {
font-family: var(--font-sans);
background:
radial-gradient(1200px 600px at 0% -10%, rgba(11, 140, 255, 0.08), transparent 60%),
radial-gradient(800px 500px at 100% 0%, rgba(124, 92, 255, 0.08), transparent 55%),
#f8fafc;
color: #0f172a;
-webkit-font-smoothing: antialiased;
}
@layer components {
.asf-card {
@apply bg-white/90 backdrop-blur border border-slate-200/80 rounded-xl shadow-card;
}
.asf-card-header {
@apply flex items-center justify-between px-5 py-4 border-b border-slate-100;
}
.asf-card-title {
@apply font-semibold text-slate-800 text-sm tracking-tight;
}
.asf-card-body {
@apply p-5;
}
.asf-btn {
@apply inline-flex items-center justify-center gap-2 px-3.5 py-2 rounded-lg text-sm font-medium
transition-colors border border-transparent;
}
.asf-btn-primary {
@apply asf-btn bg-brand-600 text-white hover:bg-brand-700 focus:ring-2 focus:ring-brand-300;
}
.asf-btn-secondary {
@apply asf-btn bg-white text-slate-700 border-slate-200 hover:bg-slate-50;
}
.asf-btn-ghost {
@apply asf-btn text-slate-600 hover:bg-slate-100;
}
.asf-input {
@apply w-full px-3 py-2 rounded-lg text-sm bg-white border border-slate-200
focus:border-brand-400 focus:ring-2 focus:ring-brand-200 outline-none;
}
.asf-nav-item {
@apply flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium
text-slate-600 hover:bg-slate-100 hover:text-slate-900 transition-colors;
}
.asf-nav-item-active {
@apply bg-brand-50 text-brand-700 hover:bg-brand-50 hover:text-brand-800
ring-1 ring-inset ring-brand-100;
}
.asf-chip {
@apply inline-flex items-center gap-1 text-[11px] font-medium px-2 py-0.5
rounded-full border border-slate-200 bg-slate-50 text-slate-600;
}
.asf-monospace {
font-family: "JetBrains Mono", Menlo, ui-monospace, monospace;
}
}
/* React Flow theme tweaks */
.react-flow__attribution {
display: none;
}
.react-flow__node {
font-size: 12px;
padding: 0;
}
.react-flow__node-default,
.react-flow__node-input,
.react-flow__node-output {
border: 1px solid #e2e8f0 !important;
box-shadow: 0 1px 2px rgba(15,23,42,0.04), 0 4px 20px -8px rgba(15,23,42,0.10);
}
/* Scrollbar */
*::-webkit-scrollbar { width: 8px; height: 8px; }
*::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 999px; }
*::-webkit-scrollbar-thumb:hover { background: #94a3b8; }
+112
View File
@@ -0,0 +1,112 @@
"use client";
import React from "react";
import { Plug, Github, GitBranch, CircleDot, BookMarked, Activity, CheckCircle2, AlertTriangle, Plus } from "lucide-react";
import { Card, CardBody, CardHeader, CardTitle } from "@/components/ui/Card";
import { Button } from "@/components/ui/Button";
import { Badge } from "@/components/ui/Badge";
import { EmptyState } from "@/components/ui/EmptyState";
const CATALOG = [
{ key: "github", name: "GitHub", icon: Github, kind: "git", status: "available", desc: "Import PRs, commits, branches and release events." },
{ key: "gitlab", name: "GitLab", icon: GitBranch, kind: "git", status: "planned", desc: "Planned connector — same primitives as GitHub." },
{ key: "jira", name: "Jira / Linear", icon: CircleDot, kind: "requirements", status: "planned", desc: "Sync epics, stories and tasks as ASF Artifacts." },
{ key: "jenkins", name: "Jenkins CI", icon: GitBranch, kind: "ci", status: "planned", desc: "Map build results to Build & Test artifacts." },
{ key: "notion", name: "Notion / Confluence", icon: BookMarked, kind: "docs", status: "planned", desc: "ADRs and docs synchronized as Document artifacts." },
{ key: "datadog", name: "Datadog / Prometheus",icon: Activity, kind: "observability", status: "planned", desc: "Incidents and runtime events → incident artifacts." },
];
const CONNECTED = [
{ key: "github", name: "GitHub (azurian-org)", tenant: "default", status: "ok", since: "2025-11-01T00:00:00Z" },
];
export default function IntegrationsPage() {
return (
<div className="p-6 lg:p-8 max-w-[1400px] mx-auto">
<header className="mb-6 flex flex-wrap items-start justify-between gap-3">
<div>
<h1 className="text-2xl font-semibold tracking-tight text-slate-900 flex items-center gap-2">
<Plug className="w-6 h-6 text-brand-600" />
Integrations
</h1>
<p className="mt-1 text-sm text-slate-500">
ASF connectors — plug in Git providers, requirements tools, CI/CD, docs and observability platforms. Core stays decoupled.
</p>
</div>
<Button variant="primary" size="sm"><Plus className="w-4 h-4" /> Add integration</Button>
</header>
<section className="mb-6">
<Card>
<CardHeader>
<CardTitle>Connected</CardTitle>
<Badge tone="green">{CONNECTED.length} active</Badge>
</CardHeader>
<CardBody className="p-0">
{CONNECTED.length === 0 ? (
<div className="p-6">
<EmptyState title="Nothing connected yet" description="Choose a provider from the catalog to start ingesting artifacts." icon={Plug} />
</div>
) : (
<ul className="divide-y divide-slate-100">
{CONNECTED.map((c) => (
<li key={c.key} className="p-4 flex items-center gap-4 hover:bg-slate-50">
<div className="w-10 h-10 rounded-lg bg-slate-50 border border-slate-200 grid place-items-center shrink-0">
<Github className="w-5 h-5 text-slate-700" />
</div>
<div className="min-w-0 flex-1">
<div className="font-medium text-slate-800">{c.name}</div>
<div className="text-xs text-slate-500">tenant: <span className="asf-monospace">{c.tenant}</span></div>
</div>
<Badge tone={c.status === "ok" ? "green" : "amber"} className="inline-flex items-center gap-1">
{c.status === "ok" ? <CheckCircle2 className="w-3 h-3" /> : <AlertTriangle className="w-3 h-3" />}
{c.status}
</Badge>
</li>
))}
</ul>
)}
</CardBody>
</Card>
</section>
<Card>
<CardHeader>
<CardTitle>Catalog</CardTitle>
<Badge tone="brand">asf-connectors package</Badge>
</CardHeader>
<CardBody>
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-3">
{CATALOG.map((c) => {
const Icon = c.icon;
return (
<div key={c.key} className="p-4 rounded-xl border border-slate-200 bg-white hover:border-brand-200 hover:shadow-card transition-all">
<div className="flex items-start justify-between gap-3">
<div className="w-10 h-10 rounded-lg bg-brand-50 border border-brand-100 grid place-items-center text-brand-600 shrink-0">
<Icon className="w-5 h-5" />
</div>
<Badge
tone={c.status === "available" ? "green" : c.status === "planned" ? "neutral" : "amber"}
>
{c.status}
</Badge>
</div>
<div className="mt-3">
<div className="font-semibold text-slate-800">{c.name}</div>
<div className="text-xs text-slate-500 mt-1 capitalize">{c.kind}</div>
<p className="text-xs text-slate-600 mt-2 leading-relaxed">{c.desc}</p>
</div>
<div className="mt-4 flex justify-end">
<Button variant="secondary" size="sm" disabled={c.status !== "available"}>
{c.status === "available" ? "Connect" : "Coming soon"}
</Button>
</div>
</div>
);
})}
</div>
</CardBody>
</Card>
</div>
);
}
+622
View File
@@ -0,0 +1,622 @@
"use client";
import React, { useMemo, useState } from "react";
import Link from "next/link";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
Workflow,
Search,
Clock,
CheckCircle2,
XCircle,
Loader2,
CirclePause,
Circle,
PlayCircle,
Activity,
ChevronRight,
Send,
Tag,
Hash,
} from "lucide-react";
import { Card, CardBody, CardHeader, CardTitle } from "@/components/ui/Card";
import { Button } from "@/components/ui/Button";
import { Badge } from "@/components/ui/Badge";
import { EmptyState } from "@/components/ui/EmptyState";
import { jobs, projects } from "@/lib/api";
import { cn, fmtDate, shortId } from "@/lib/utils";
import type { Job, JobCreate, JobStatus, JobType } from "@/lib/types";
import { JOB_STATUSES, JOB_TYPES } from "@/lib/types";
const DEMO_JOBS: Job[] = [
{
id: "j-demo-1",
tenant_id: "t1",
project_id: "p1",
type: "ai_interactive",
priority: "normal",
status: "completed",
payload: { prompt: "Summarize the spec.", temperature: 0.2 },
correlation_id: "cid-summ-001",
requested_by: "u1",
worker: "asf-worker-3",
model: "asf-mock-1",
result: { summary: "Mock AI summary for demo purposes." },
usage: { input_tokens: 112, output_tokens: 48, cpu_seconds: 0.02 },
progress_percent: 100,
created_at: "2025-11-15T09:00:00Z",
started_at: "2025-11-15T09:00:01Z",
completed_at: "2025-11-15T09:00:06Z",
},
{
id: "j-demo-2",
tenant_id: "t1",
project_id: "p1",
type: "testing",
priority: "high",
status: "running",
payload: { suite: "smoke", pattern: "**/test_*.py" },
requested_by: "u2",
worker: "asf-worker-1",
progress_percent: 42,
created_at: "2025-11-15T09:18:00Z",
started_at: "2025-11-15T09:18:10Z",
},
{
id: "j-demo-3",
tenant_id: "t1",
project_id: "p2",
type: "code_analysis",
priority: "normal",
status: "failed",
payload: { repository: "asf/core", branch: "main" },
requested_by: "u1",
worker: "asf-worker-2",
error: "Repository clone failed",
error_detail: { exit_code: 128, stderr: "fatal: repository not found" },
created_at: "2025-11-15T08:55:00Z",
started_at: "2025-11-15T08:55:15Z",
completed_at: "2025-11-15T08:56:30Z",
},
];
const STATUS_ICON: Record<JobStatus, React.ComponentType<{ className?: string }>> = {
queued: Clock,
running: PlayCircle,
completed: CheckCircle2,
failed: XCircle,
cancelled: CirclePause,
};
const STATUS_TONE: Record<JobStatus, "blue" | "amber" | "green" | "red" | "neutral" | "brand" | "violet"> = {
queued: "neutral",
running: "blue",
completed: "green",
failed: "red",
cancelled: "amber",
};
const TYPE_TONE: Record<string, "brand" | "violet" | "blue" | "green" | "amber" | "neutral" | "red"> = {
ai_interactive: "violet",
ai_background: "violet",
indexing: "blue",
code_analysis: "brand",
testing: "green",
devops: "amber",
notification: "neutral",
custom: "red",
};
function queueForJobType(t: JobType | string): string {
// 1-to-1 mapping with asf.jobs DIRECT exchange queues
switch (t) {
case "ai_interactive": return "asf.ai.interactive";
case "ai_background": return "asf.ai.background";
case "indexing": return "asf.indexing";
case "code_analysis": return "asf.code_analysis";
case "testing": return "asf.testing";
case "devops": return "asf.devops";
case "notification": return "asf.notifications";
default: return `asf.${t ?? "custom"}`;
}
}
export default function JobsPage() {
const queryClient = useQueryClient();
const [q, setQ] = useState("");
const [statusFilter, setStatusFilter] = useState<string>("all");
const [typeFilter, setTypeFilter] = useState<string>("all");
const [selectedId, setSelectedId] = useState<string | null>(null);
// Create modal state
const [showNew, setShowNew] = useState(false);
const [newType, setNewType] = useState<JobType>("testing");
const [newProjectId, setNewProjectId] = useState<string>("");
const [newPriority, setNewPriority] = useState<"low" | "normal" | "high" | "urgent">("normal");
const [newSequence, setNewSequence] = useState<string>("hello-asf");
const projectsQ = useQuery({
queryKey: ["jobs-projects"],
queryFn: () => projects.list({ limit: 100 }),
retry: false,
refetchOnWindowFocus: false,
});
const listQ = useQuery({
queryKey: ["jobs-list", { status: statusFilter, type: typeFilter, q: q.trim() }],
queryFn: () =>
jobs.list({
status: statusFilter !== "all" ? (statusFilter as JobStatus) : undefined,
type: typeFilter !== "all" ? (typeFilter as JobType) : undefined,
limit: 200,
}),
retry: false,
refetchOnWindowFocus: false,
refetchInterval: 3000, // auto-refresh each 3s so running → completed appears without manual reload
});
const submitM = useMutation({
mutationFn: async (body: JobCreate) => jobs.submit(body),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["jobs-list"] }).catch(() => undefined);
setShowNew(false);
},
});
const apiResolved = listQ.isFetched && listQ.data !== undefined;
const rows: Job[] = apiResolved ? listQ.data! : DEMO_JOBS;
const isMock = !apiResolved;
const projectNameById = useMemo(() => {
const m = new Map<string, string>();
for (const p of projectsQ.data ?? []) {
if (p?.id) m.set(String(p.id), p.name ?? shortId(String(p.id)));
}
return m;
}, [projectsQ.data]);
// Default first project for new job dropdown (if any)
React.useEffect(() => {
if (!newProjectId && projectsQ.data?.length) setNewProjectId(String(projectsQ.data[0].id));
}, [newProjectId, projectsQ.data]);
const filtered = useMemo(() => {
const ql = q.trim().toLowerCase();
return rows.filter((j) => {
if (statusFilter !== "all" && j.status !== statusFilter) return false;
if (typeFilter !== "all" && j.type !== typeFilter) return false;
if (ql) {
const hay = [
String(j.id),
String(j.correlation_id ?? ""),
String(j.worker ?? ""),
String(j.model ?? ""),
String(j.error ?? ""),
JSON.stringify(j.payload ?? {}),
JSON.stringify(j.result ?? {}),
]
.join(" ")
.toLowerCase();
if (!hay.includes(ql)) return false;
}
return true;
});
}, [rows, statusFilter, typeFilter, q]);
const selected =
(selectedId !== null ? filtered.find((j) => String(j.id) === String(selectedId)) : null) ??
filtered[0] ??
null;
const statuses = Array.from(new Set(rows.map((j) => j.status)));
return (
<div className="h-full flex flex-col min-h-0">
<header className="px-6 lg:px-8 pt-6 pb-4 flex flex-wrap items-start justify-between gap-4">
<div>
<h1 className="text-2xl font-semibold tracking-tight text-slate-900 flex items-center gap-2">
<Workflow className="w-6 h-6 text-brand-600" /> Jobs
</h1>
<p className="mt-1 text-sm text-slate-500">
Asynchronous ASF work: queued → running → completed / failed. Each job is routed to its dedicated RabbitMQ queue.
</p>
</div>
<div className="flex items-center gap-2">
{isMock && <Badge tone="amber">Demo data</Badge>}
<Button
variant="secondary"
size="sm"
onClick={() => queryClient.invalidateQueries({ queryKey: ["jobs-list"] })}
>
<Activity className="w-4 h-4" /> Refresh
</Button>
<Button variant="primary" size="sm" onClick={() => setShowNew(true)}>
<Send className="w-4 h-4" /> Enqueue job
</Button>
</div>
</header>
{/* Stats strip */}
<section className="px-6 lg:px-8 pb-3 grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-2 text-sm">
{(["queued", "running", "completed", "failed", "cancelled"] as JobStatus[]).map((s) => {
const count = rows.filter((r) => r.status === s).length;
const Icon = STATUS_ICON[s];
return (
<button
key={s}
onClick={() => setStatusFilter(statusFilter === s ? "all" : s)}
className={cn(
"text-left p-2 rounded-lg border transition-colors",
statusFilter === s
? "border-brand-300 bg-brand-50"
: "border-slate-200 bg-white hover:bg-slate-50",
)}
>
<div className="flex items-center justify-between text-slate-500 text-xs">
<span className="inline-flex items-center gap-1"><Icon className="w-3.5 h-3.5" />{s}</span>
<Badge tone={STATUS_TONE[s]}><span className="tabular-nums">{count}</span></Badge>
</div>
</button>
);
})}
<div className="p-2 rounded-lg border border-slate-200 bg-white">
<div className="flex items-center justify-between text-slate-500 text-xs">
<span className="inline-flex items-center gap-1"><Hash className="w-3.5 h-3.5" />Total</span>
<Badge tone="brand"><span className="tabular-nums">{rows.length}</span></Badge>
</div>
</div>
</section>
<div className="px-6 lg:px-8 pb-4 flex flex-wrap gap-2 items-center">
<div className="relative flex-1 min-w-[220px]">
<Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" />
<input
className="asf-input pl-9"
placeholder="Search id, correlation_id, worker, payload json, error…"
value={q}
onChange={(e) => setQ(e.target.value)}
/>
</div>
<div className="flex items-center gap-1.5 text-xs text-slate-500">
<select
className="asf-input !py-1.5 !text-xs !w-auto"
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
>
<option value="all">All statuses</option>
{JOB_STATUSES.concat(statuses).map((s) => (
<option key={s} value={s}>{s}</option>
))}
</select>
<select
className="asf-input !py-1.5 !text-xs !w-auto"
value={typeFilter}
onChange={(e) => setTypeFilter(e.target.value)}
>
<option value="all">All types</option>
{JOB_TYPES.map((t) => (
<option key={t} value={t}>{t}</option>
))}
</select>
</div>
</div>
<div className="flex-1 min-h-0 px-6 lg:px-8 pb-6 grid grid-cols-1 xl:grid-cols-5 gap-4">
<Card className="xl:col-span-3 flex flex-col min-h-0">
<CardHeader>
<CardTitle>Job list</CardTitle>
<span className="text-xs text-slate-500">Auto-refresh each 3s · click a row →</span>
</CardHeader>
<CardBody className="p-0 flex-1 min-h-0 overflow-auto">
{filtered.length === 0 ? (
<div className="p-6">
<EmptyState
title="No jobs match"
description="Try adjusting filters, or click Enqueue job to create a new echo testing job."
/>
</div>
) : (
<table className="w-full text-sm">
<thead className="sticky top-0 z-10 bg-slate-50/90 backdrop-blur text-xs uppercase tracking-wide text-slate-500 border-b border-slate-200">
<tr>
<th className="text-left px-4 py-2">ID</th>
<th className="text-left px-4 py-2">Type</th>
<th className="text-left px-4 py-2">Status</th>
<th className="text-left px-4 py-2">Progress</th>
<th className="text-left px-4 py-2">Queue</th>
<th className="text-left px-4 py-2">Updated</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{filtered.map((j) => {
const active = selected && String(selected.id) === String(j.id);
const StatusIcon = STATUS_ICON[j.status] ?? Circle;
return (
<tr
key={String(j.id)}
onClick={() => setSelectedId(String(j.id))}
className={cn(
"cursor-pointer hover:bg-brand-50/40 transition-colors",
active && "bg-brand-50/70",
)}
>
<td className="px-4 py-2.5 asf-monospace text-xs text-slate-500 align-top">
{shortId(String(j.id))}
</td>
<td className="px-4 py-2.5 align-top">
<Badge tone={TYPE_TONE[j.type] ?? "neutral"}>{j.type}</Badge>
</td>
<td className="px-4 py-2.5 align-top">
<span className="inline-flex items-center gap-1.5">
<StatusIcon className={cn(
"w-4 h-4",
j.status === "running" && "animate-spin text-brand-600",
j.status === "completed" && "text-emerald-600",
j.status === "failed" && "text-rose-600",
j.status === "queued" && "text-slate-500",
)} />
<Badge tone={STATUS_TONE[j.status]}>{j.status}</Badge>
</span>
</td>
<td className="px-4 py-2.5 align-top">
<div className="flex items-center gap-2 min-w-[100px]">
<div className="h-1.5 flex-1 rounded-full bg-slate-100 overflow-hidden">
<div
className={cn(
"h-full transition-all",
j.status === "failed" ? "bg-rose-500" : j.status === "completed" ? "bg-emerald-500" : "bg-brand-500",
)}
style={{ width: `${j.progress_percent ?? (j.status === "completed" ? 100 : j.status === "running" ? 35 : 0)}%` }}
/>
</div>
<span className="text-xs tabular-nums text-slate-500 w-9 text-right">
{j.progress_percent ?? (j.status === "completed" ? 100 : j.status === "queued" ? 0 : "—")}%
</span>
</div>
</td>
<td className="px-4 py-2.5 align-top text-xs text-slate-500 asf-monospace whitespace-nowrap">
{queueForJobType(j.type)}
</td>
<td className="px-4 py-2.5 align-top text-xs text-slate-500 whitespace-nowrap">
{fmtDate(j.completed_at ?? j.started_at ?? j.created_at)}
</td>
</tr>
);
})}
</tbody>
</table>
)}
</CardBody>
</Card>
<Card className="xl:col-span-2 flex flex-col min-h-0">
<CardHeader>
<CardTitle>Detail</CardTitle>
<Link href="/ai-agents" className="text-xs text-brand-600 hover:underline inline-flex items-center gap-1">
AI Agents <ChevronRight className="w-3 h-3" />
</Link>
</CardHeader>
<CardBody className="overflow-auto flex-1 min-h-0">
{selected ? (
<div className="space-y-4 text-sm">
<div>
<div className="flex items-center gap-2 flex-wrap mb-1.5">
<span className="asf-monospace text-xs text-slate-500">{shortId(String(selected.id))}</span>
<Badge tone={TYPE_TONE[selected.type] ?? "neutral"}>{selected.type}</Badge>
<Badge tone={STATUS_TONE[selected.status]}>{selected.status}</Badge>
{selected.priority && <Badge tone="neutral">{selected.priority}</Badge>}
</div>
<h2 className="text-lg font-semibold text-slate-900 leading-tight">
{selected.type} job · {queueForJobType(selected.type)}
</h2>
</div>
<dl className="grid grid-cols-2 gap-x-4 gap-y-2">
<div>
<dt className="text-xs text-slate-500">Project</dt>
<dd className="font-medium text-slate-800">
{selected.project_id
? (projectNameById.get(String(selected.project_id)) ?? shortId(String(selected.project_id)))
: "—"}
</dd>
</div>
<div>
<dt className="text-xs text-slate-500">Queue</dt>
<dd className="asf-monospace text-slate-700">{queueForJobType(selected.type)}</dd>
</div>
<div>
<dt className="text-xs text-slate-500">Worker</dt>
<dd className="text-slate-700">{selected.worker ?? "—"}</dd>
</div>
<div>
<dt className="text-xs text-slate-500">Model</dt>
<dd className="text-slate-700">{selected.model ?? "—"}</dd>
</div>
<div>
<dt className="text-xs text-slate-500">Requested by</dt>
<dd className="asf-monospace text-slate-700">
{selected.requested_by ? shortId(String(selected.requested_by)) : "—"}
</dd>
</div>
<div>
<dt className="text-xs text-slate-500">Correlation</dt>
<dd className="asf-monospace text-slate-700">{selected.correlation_id ?? "—"}</dd>
</div>
<div>
<dt className="text-xs text-slate-500">Created</dt>
<dd className="text-slate-700">{fmtDate(selected.created_at)}</dd>
</div>
<div>
<dt className="text-xs text-slate-500">Completed</dt>
<dd className="text-slate-700">{selected.completed_at ? fmtDate(selected.completed_at) : "—"}</dd>
</div>
</dl>
{selected.payload !== undefined && (
<div>
<div className="text-xs text-slate-500 mb-1.5 flex items-center gap-1"><Tag className="w-3 h-3" /> payload</div>
<pre className="p-3 rounded-lg bg-slate-50 border border-slate-100 text-xs overflow-auto max-h-40">
{JSON.stringify(selected.payload, null, 2)}
</pre>
</div>
)}
{selected.result !== undefined && (
<div>
<div className="text-xs text-slate-500 mb-1.5 flex items-center gap-1"><CheckCircle2 className="w-3 h-3 text-emerald-600" /> result</div>
<pre className="p-3 rounded-lg bg-emerald-50 border border-emerald-100 text-xs overflow-auto max-h-48">
{JSON.stringify(selected.result, null, 2)}
</pre>
</div>
)}
{selected.error && (
<div>
<div className="text-xs text-slate-500 mb-1.5 flex items-center gap-1"><XCircle className="w-3 h-3 text-rose-600" /> error</div>
<div className="p-3 rounded-lg bg-rose-50 border border-rose-100 text-rose-700 text-xs overflow-auto max-h-40 space-y-2">
<div className="font-semibold">{selected.error}</div>
{selected.error_detail && (
<pre className="whitespace-pre-wrap break-all">{JSON.stringify(selected.error_detail, null, 2)}</pre>
)}
</div>
</div>
)}
{selected.usage && (
<div>
<div className="text-xs text-slate-500 mb-1.5">usage</div>
<div className="grid grid-cols-2 gap-2 text-xs">
{Object.entries(selected.usage).map(([k, v]) =>
v === undefined ? null : (
<div key={k} className="flex justify-between p-2 rounded bg-slate-50 border border-slate-100">
<span className="text-slate-500">{k}</span>
<span className="font-medium text-slate-800 tabular-nums">{JSON.stringify(v)}</span>
</div>
),
)}
</div>
</div>
)}
<div className="pt-3 border-t border-slate-100 flex flex-wrap gap-2">
<Button
variant="secondary"
size="sm"
onClick={() => queryClient.invalidateQueries({ queryKey: ["jobs-list"] })}
>
<Activity className="w-4 h-4" /> Refresh status
</Button>
<Button variant="primary" size="sm" onClick={() => setShowNew(true)}>
<Send className="w-4 h-4" /> Enqueue similar
</Button>
</div>
</div>
) : (
<EmptyState
title="Select a job"
description="Pick any row from the list to inspect payload, result, error and usage counters."
/>
)}
</CardBody>
</Card>
</div>
{/* New job modal */}
{showNew && (
<div className="fixed inset-0 z-50 grid place-items-center bg-slate-900/50 backdrop-blur-sm p-4" onClick={() => setShowNew(false)}>
<div
className="w-full max-w-lg rounded-xl bg-white border border-slate-200 shadow-xl"
onClick={(e) => e.stopPropagation()}
>
<div className="px-5 py-4 border-b border-slate-100 flex items-center justify-between">
<h3 className="font-semibold text-slate-900 flex items-center gap-2">
<Send className="w-4 h-4 text-brand-600" /> Enqueue new job
</h3>
<button onClick={() => setShowNew(false)} className="text-slate-400 hover:text-slate-600 text-xl leading-none" aria-label="close">×</button>
</div>
<form
className="p-5 space-y-3"
onSubmit={(e) => {
e.preventDefault();
submitM.mutate({
project_id: newProjectId || undefined,
type: newType,
priority: newPriority,
payload: { sequence: newSequence, message: "hello-asf", ts: Date.now() },
});
}}
>
<div>
<label className="text-xs text-slate-500">Type (direct queue)</label>
<select
className="asf-input mt-1"
value={newType}
onChange={(e) => setNewType(e.target.value as JobType)}
>
{JOB_TYPES.map((t) => (
<option key={t} value={t}>{t} · {queueForJobType(t)}</option>
))}
</select>
</div>
<div>
<label className="text-xs text-slate-500">Project</label>
<select
className="asf-input mt-1"
value={newProjectId}
onChange={(e) => setNewProjectId(e.target.value)}
>
<option value="">(no project)</option>
{(projectsQ.data ?? []).map((p) => (
<option key={String(p.id)} value={String(p.id)}>{p.name ?? shortId(String(p.id))}</option>
))}
</select>
</div>
<div>
<label className="text-xs text-slate-500">Priority</label>
<select
className="asf-input mt-1"
value={newPriority}
onChange={(e) => setNewPriority(e.target.value as typeof newPriority)}
>
{(["low", "normal", "high", "urgent"] as const).map((p) => (
<option key={p} value={p}>{p}</option>
))}
</select>
</div>
<div>
<label className="text-xs text-slate-500">Echo sequence (payload.sequence)</label>
<input
className="asf-input mt-1"
value={newSequence}
onChange={(e) => setNewSequence(e.target.value)}
placeholder="job-e2e-1"
/>
</div>
<div className="pt-3 border-t border-slate-100 flex justify-end gap-2">
<Button type="button" variant="secondary" size="sm" onClick={() => setShowNew(false)}>
Cancel
</Button>
<Button
type="submit"
variant="primary"
size="sm"
disabled={submitM.isPending || !newType}
>
{submitM.isPending ? (
<>
<Loader2 className="w-4 h-4 animate-spin" /> Enqueuing…
</>
) : (
<>
<Send className="w-4 h-4" /> Enqueue
</>
)}
</Button>
</div>
</form>
</div>
</div>
)}
</div>
);
}
+38
View File
@@ -0,0 +1,38 @@
import type { Metadata } from "next";
import "./globals.css";
import { Sidebar } from "@/components/shell/Sidebar";
import { Topbar } from "@/components/shell/Topbar";
import { AskAsfCommandBar } from "@/components/shell/AskAsfCommandBar";
import { AppProviders } from "@/components/shell/AppProviders";
export const metadata: Metadata = {
title: "ASF · Azurian Software Factory",
description:
"From Requirement to Production, everything is contextualized, traceable and AI-accessible.",
icons: { icon: "/favicon.svg" },
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en" className="h-full">
<body className="h-full">
<AppProviders>
<div className="min-h-screen flex">
<Sidebar />
<div className="min-w-0 flex-1 flex flex-col">
<Topbar />
<main className="flex-1 min-w-0 overflow-x-hidden animate-fade-in">
{children}
</main>
</div>
</div>
<AskAsfCommandBar />
</AppProviders>
</body>
</html>
);
}
+111
View File
@@ -0,0 +1,111 @@
"use client";
import React from "react";
import { Activity, Server, AlertOctagon, Clock } from "lucide-react";
import { Card, CardBody, CardHeader, CardTitle } from "@/components/ui/Card";
import { Badge } from "@/components/ui/Badge";
import { EmptyState } from "@/components/ui/EmptyState";
import { MetricCard } from "@/components/ui/MetricCard";
import { fmtDate } from "@/lib/utils";
const SERVICES = [
{ name: "asf-core", status: "healthy", latency_ms: 42, error_rate: 0.1, rps: 184 },
{ name: "asf-worker", status: "healthy", latency_ms: 0, error_rate: 0.0, rps: 0, jobs: 6 },
{ name: "asf-context", status: "degraded", latency_ms: 310, error_rate: 2.3, rps: 42 },
{ name: "asf-ai", status: "healthy", latency_ms: 620, error_rate: 0.5, rps: 12 },
{ name: "mongodb", status: "healthy", latency_ms: 3, error_rate: 0.0, rps: 0 },
{ name: "rabbitmq", status: "healthy", latency_ms: 2, error_rate: 0.0, queue_depth: 14 },
];
const INCIDENTS = [
{ id: "INC-233", sev: "medium", title: "asf-context p99 latency spike", started: "2025-11-22T08:10:00Z", state: "open" },
{ id: "INC-232", sev: "low", title: "MinIO console certificate warn", started: "2025-11-21T14:40:00Z", state: "resolved" },
];
export default function ObservabilityPage() {
return (
<div className="p-6 lg:p-8 max-w-[1400px] mx-auto">
<header className="mb-6 flex flex-wrap items-start justify-between gap-3">
<div>
<h1 className="text-2xl font-semibold tracking-tight text-slate-900 flex items-center gap-2">
<Activity className="w-6 h-6 text-brand-600" />
Observability
</h1>
<p className="mt-1 text-sm text-slate-500">
Service health, latency, error rates and active incidents. Structured JSON logs + correlation_id propagate across every ASF hop.
</p>
</div>
<Badge tone="neutral">OpenTelemetry · prepared</Badge>
</header>
<section className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
<MetricCard label="Services healthy" value={5} tone="green" icon={<Server className="w-5 h-5" />} subtitle="of 6 total" />
<MetricCard label="Requests / sec" value={238} tone="brand" icon={<Activity className="w-5 h-5" />} />
<MetricCard label="Error rate" value={0.6} isPercentage tone="amber" icon={<AlertOctagon className="w-5 h-5" />} />
<MetricCard label="Open incidents" value={1} tone="red" icon={<Clock className="w-5 h-5" />} />
</section>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
<Card className="lg:col-span-2">
<CardHeader>
<CardTitle>Service inventory</CardTitle>
<Badge tone="brand">p50 latency</Badge>
</CardHeader>
<CardBody className="p-0">
<table className="w-full text-sm">
<thead className="bg-slate-50 text-xs uppercase tracking-wide text-slate-500 border-b border-slate-200">
<tr>
<th className="text-left px-4 py-2">Service</th>
<th className="text-left px-4 py-2">Status</th>
<th className="text-right px-4 py-2">Latency</th>
<th className="text-right px-4 py-2">Error %</th>
<th className="text-right px-4 py-2">Load</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{SERVICES.map((s) => (
<tr key={s.name} className="hover:bg-slate-50">
<td className="px-4 py-2.5 font-mono text-xs">{s.name}</td>
<td className="px-4 py-2.5">
<Badge tone={s.status === "healthy" ? "green" : s.status === "degraded" ? "amber" : "red"}>{s.status}</Badge>
</td>
<td className="px-4 py-2.5 text-right tabular-nums">{s.latency_ms} ms</td>
<td className="px-4 py-2.5 text-right tabular-nums">{s.error_rate.toFixed(2)}%</td>
<td className="px-4 py-2.5 text-right text-xs text-slate-600 tabular-nums">
{s.rps ? `${s.rps} rps` : s.queue_depth !== undefined ? `q=${s.queue_depth}` : `${s.jobs} jobs`}
</td>
</tr>
))}
</tbody>
</table>
</CardBody>
</Card>
<Card>
<CardHeader>
<CardTitle>Incidents</CardTitle>
<Badge tone="amber">correlation_id · audit</Badge>
</CardHeader>
<CardBody>
{INCIDENTS.length === 0 ? (
<EmptyState title="All quiet" description="No incidents in the last 24 hours." icon={Activity} />
) : (
<ul className="space-y-2 text-sm">
{INCIDENTS.map((i) => (
<li key={i.id} className="p-3 rounded-lg border border-slate-100 hover:border-slate-200">
<div className="flex items-center gap-2">
<Badge tone={i.sev === "high" || i.sev === "critical" ? "red" : i.sev === "medium" ? "amber" : "neutral"} className="uppercase">{i.sev}</Badge>
<span className="font-mono text-xs text-slate-500">{i.id}</span>
<Badge tone={i.state === "open" ? "amber" : "green"}>{i.state}</Badge>
</div>
<div className="mt-1.5 font-medium text-slate-800">{i.title}</div>
<div className="mt-1 text-xs text-slate-500">Started {fmtDate(i.started)}</div>
</li>
))}
</ul>
)}
</CardBody>
</Card>
</div>
</div>
);
}
+287
View File
@@ -0,0 +1,287 @@
"use client";
import React from "react";
import { useQuery } from "@tanstack/react-query";
import {
ClipboardList,
FolderKanban,
TestTube2,
Rocket,
GitCommit,
AlertTriangle,
Bot,
ShieldAlert,
RefreshCw,
PlayCircle,
} from "lucide-react";
import { Card, CardBody, CardHeader, CardTitle, CardFooter } from "@/components/ui/Card";
import { MetricCard } from "@/components/ui/MetricCard";
import { ProgressRing } from "@/components/ui/ProgressRing";
import { Button } from "@/components/ui/Button";
import { Badge } from "@/components/ui/Badge";
import { EmptyState } from "@/components/ui/EmptyState";
import { overview, health } from "@/lib/api";
import type { OverviewMetrics } from "@/lib/types";
const DEMO_METRICS: OverviewMetrics = {
tenant_id: "demo-tenant",
total_projects: 6,
total_requirements: 142,
total_tasks: 318,
total_tests: 512,
total_deployments: 27,
total_commits: 1204,
jobs_running: 3,
jobs_queued: 8,
jobs_failed_24h: 2,
implementation_coverage_pct: 74.3,
test_coverage_pct: 68.9,
traceability_score_pct: 81.5,
project_health_pct: 77.2,
security_findings: 14,
failed_builds: 3,
active_ai_jobs: 5,
featured_projects: [
{ key: "ASF", name: "Azurian Software Factory" },
{ key: "NOVA", name: "Nova Customer Portal" },
{ key: "ORB", name: "Orbital Billing" },
],
mock_fields: ["*"],
};
export default function OverviewPage() {
const metricsQ = useQuery({
queryKey: ["overview"],
queryFn: () => overview.get(),
retry: false,
refetchOnWindowFocus: false,
});
const healthQ = useQuery({
queryKey: ["health"],
queryFn: () => health(),
retry: false,
refetchOnWindowFocus: false,
});
const m: OverviewMetrics = metricsQ.data ?? DEMO_METRICS;
const isMock = !metricsQ.data;
return (
<div className="p-6 lg:p-8 space-y-6 max-w-[1600px] mx-auto">
<header className="flex flex-wrap items-start justify-between gap-4">
<div className="min-w-0">
<div className="flex items-center gap-2">
<h1 className="text-2xl font-semibold tracking-tight text-slate-900">
Overview
</h1>
{isMock && (
<Badge tone="amber" className="shrink-0">
Demo data
</Badge>
)}
{healthQ.data && (
<Badge tone={healthQ.data.status === "ok" ? "green" : "red"}>
{healthQ.data.service} v{healthQ.data.version} · {healthQ.data.status}
</Badge>
)}
</div>
<p className="mt-1 text-sm text-slate-500">
From Requirement to Production — everything contextualized, traceable and AI-accessible.
</p>
</div>
<div className="flex items-center gap-2">
<Button
variant="secondary"
size="sm"
onClick={() => {
void metricsQ.refetch();
void healthQ.refetch();
}}
>
<RefreshCw className="w-4 h-4" />
Refresh
</Button>
<Button variant="primary" size="sm">
<PlayCircle className="w-4 h-4" />
New AI Job
</Button>
</div>
</header>
<section className="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-6 gap-4">
<MetricCard
label="Projects"
value={m.total_projects}
tone="brand"
icon={<FolderKanban className="w-5 h-5" />}
/>
<MetricCard
label="Requirements"
value={m.total_requirements}
tone="violet"
delta={3.4}
icon={<ClipboardList className="w-5 h-5" />}
/>
<MetricCard
label="Tasks"
value={m.total_tasks}
tone="blue"
icon={<ClipboardList className="w-5 h-5" />}
/>
<MetricCard
label="Tests"
value={m.total_tests}
tone="green"
delta={1.1}
icon={<TestTube2 className="w-5 h-5" />}
/>
<MetricCard
label="Deployments"
value={m.total_deployments}
tone="brand"
icon={<Rocket className="w-5 h-5" />}
/>
<MetricCard
label="Commits"
value={m.total_commits}
tone="neutral"
icon={<GitCommit className="w-5 h-5" />}
/>
</section>
<section className="grid grid-cols-1 lg:grid-cols-3 gap-4">
<Card>
<CardHeader>
<CardTitle>Health & Coverage</CardTitle>
<Badge tone="neutral">Across all projects</Badge>
</CardHeader>
<CardBody className="space-y-4">
<ProgressRing
value={m.project_health_pct}
tone="green"
label="Project Health"
sublabel="Weighted stability score"
/>
<ProgressRing
value={m.implementation_coverage_pct}
tone="brand"
label="Implementation Coverage"
sublabel="Requirements → Tasks → Code"
/>
<ProgressRing
value={m.test_coverage_pct}
tone="amber"
label="Test Coverage"
sublabel="Automated vs total tests"
/>
<ProgressRing
value={m.traceability_score_pct}
tone="violet"
label="Traceability Score"
sublabel="Graph connectivity"
/>
</CardBody>
</Card>
<Card>
<CardHeader>
<CardTitle>Jobs & AI</CardTitle>
<Badge tone="brand">
<Bot className="w-3 h-3" /> AI
</Badge>
</CardHeader>
<CardBody className="space-y-3">
<MetricCard
label="Jobs running"
value={m.jobs_running}
tone="blue"
icon={<PlayCircle className="w-4 h-4" />}
/>
<MetricCard
label="Jobs queued"
value={m.jobs_queued}
tone="neutral"
icon={<RefreshCw className="w-4 h-4" />}
/>
<MetricCard
label="Jobs failed (24h)"
value={m.jobs_failed_24h}
tone="red"
icon={<AlertTriangle className="w-4 h-4" />}
/>
<MetricCard
label="Active AI jobs"
value={m.active_ai_jobs}
tone="violet"
icon={<Bot className="w-4 h-4" />}
/>
</CardBody>
<CardFooter>
<span className="text-slate-500">Priority queue: high → low</span>
<a className="text-brand-600 font-medium hover:underline cursor-pointer" href="/pipelines">
View pipelines →
</a>
</CardFooter>
</Card>
<Card>
<CardHeader>
<CardTitle>Alerts</CardTitle>
<Badge tone="red">{m.security_findings + m.failed_builds}</Badge>
</CardHeader>
<CardBody className="space-y-3">
<div className="flex items-start gap-3 p-3 rounded-lg border border-red-100 bg-red-50/60">
<ShieldAlert className="w-4 h-4 text-red-600 mt-0.5" />
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-red-900">
{m.security_findings} Security findings
</div>
<div className="text-xs text-red-700/80 mt-0.5">
Review findings in Quality → Security
</div>
</div>
</div>
<div className="flex items-start gap-3 p-3 rounded-lg border border-amber-100 bg-amber-50/60">
<AlertTriangle className="w-4 h-4 text-amber-600 mt-0.5" />
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-amber-900">
{m.failed_builds} Failed builds
</div>
<div className="text-xs text-amber-800/80 mt-0.5">
Last 24 hours — inspect for root causes
</div>
</div>
</div>
{m.featured_projects && m.featured_projects.length > 0 ? (
<div className="pt-2">
<div className="text-xs uppercase tracking-wide text-slate-500 mb-2">
Featured projects
</div>
<ul className="divide-y divide-slate-100 border border-slate-100 rounded-lg overflow-hidden">
{m.featured_projects.map((p) => (
<li
key={p.key}
className="flex items-center justify-between px-3 py-2.5 text-sm hover:bg-slate-50"
>
<div className="min-w-0">
<div className="font-medium text-slate-800 truncate">{p.name}</div>
<div className="text-xs text-slate-500 asf-monospace">{p.key}</div>
</div>
<Badge tone="brand">Open</Badge>
</li>
))}
</ul>
</div>
) : (
<EmptyState
title="No projects yet"
description="Create a project to start tracking your software factory."
actionLabel="Create project"
/>
)}
</CardBody>
</Card>
</section>
</div>
);
}
+77
View File
@@ -0,0 +1,77 @@
"use client";
import React from "react";
import { GitFork, PlayCircle, Clock, CheckCircle2, AlertTriangle } from "lucide-react";
import { Card, CardBody, CardHeader, CardTitle } from "@/components/ui/Card";
import { Badge, JobStatusBadge } from "@/components/ui/Badge";
import { EmptyState } from "@/components/ui/EmptyState";
import { MetricCard } from "@/components/ui/MetricCard";
import { fmtDate } from "@/lib/utils";
const PIPELINES = [
{ id: "pl-indexing", name: "Repository Indexing", stage: "connectors → asf-worker → MongoDB", last: "2025-11-22T09:10:00Z", status: "running", runs: 184 },
{ id: "pl-code-analysis", name: "Code Analysis", stage: "PR event → asf-ai → artifact + relationships", last: "2025-11-22T08:40:00Z", status: "queued", runs: 52 },
{ id: "pl-test-gen", name: "AI Test Generation", stage: "Task → Prompt → LLM → PR", last: "2025-11-21T18:02:00Z", status: "completed", runs: 13 },
{ id: "pl-deploy", name: "Deployment Rollout", stage: "Build OK → Argo → runtime_events", last: "2025-11-20T14:11:00Z", status: "completed", runs: 27 },
{ id: "pl-incident", name: "Incident Traceback", stage: "Incident → asf-context → root-cause", last: "2025-11-19T10:55:00Z", status: "failed", runs: 4 },
];
export default function PipelinesPage() {
return (
<div className="p-6 lg:p-8 max-w-[1400px] mx-auto">
<header className="mb-6 flex flex-wrap items-start justify-between gap-3">
<div>
<h1 className="text-2xl font-semibold tracking-tight text-slate-900 flex items-center gap-2">
<GitFork className="w-6 h-6 text-brand-600" />
Pipelines
</h1>
<p className="mt-1 text-sm text-slate-500">
RabbitMQ-backed flows: domain events → workers → downstream artifacts. This page lists currently registered pipeline topologies.
</p>
</div>
<Badge tone="neutral">Event backbone · RabbitMQ</Badge>
</header>
<section className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
<MetricCard label="Running" value={1} tone="blue" icon={<PlayCircle className="w-5 h-5" />} />
<MetricCard label="Queued" value={1} tone="neutral" icon={<Clock className="w-5 h-5" />} />
<MetricCard label="Completed (7d)" value={112} tone="green" icon={<CheckCircle2 className="w-5 h-5" />} delta={4.2} />
<MetricCard label="Failed (24h)" value={1} tone="red" icon={<AlertTriangle className="w-5 h-5" />} />
</section>
<Card>
<CardHeader>
<CardTitle>Registered pipelines</CardTitle>
<Badge tone="brand">{PIPELINES.length} topologies</Badge>
</CardHeader>
<CardBody className="p-0">
<ul className="divide-y divide-slate-100">
{PIPELINES.length === 0 ? (
<li className="p-6"><EmptyState title="No pipelines yet" description="Register an async topology in asf-worker to surface it here." /></li>
) : PIPELINES.map((p) => (
<li key={p.id} className="p-4 hover:bg-slate-50 transition-colors flex items-start gap-4">
<div className="w-10 h-10 mt-0.5 rounded-lg bg-brand-50 border border-brand-100 grid place-items-center text-brand-600 shrink-0">
<GitFork className="w-5 h-5" />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<div className="font-semibold text-slate-800">{p.name}</div>
<Badge tone="neutral" className="asf-monospace">{p.id}</Badge>
<JobStatusBadge status={p.status} />
</div>
<div className="mt-1 text-xs text-slate-600 font-mono bg-slate-50 inline-block px-2 py-1 rounded-md">
{p.stage}
</div>
<div className="mt-2 flex flex-wrap items-center gap-4 text-xs text-slate-500">
<span>Last run: <span className="text-slate-700">{fmtDate(p.last)}</span></span>
<span>Total runs: <span className="tabular-nums text-slate-700 font-semibold">{p.runs}</span></span>
</div>
</div>
</li>
))}
</ul>
</CardBody>
</Card>
</div>
);
}
+84
View File
@@ -0,0 +1,84 @@
"use client";
import React from "react";
import { ShieldCheck, ShieldAlert, Bug, TestTube2, Lock } from "lucide-react";
import { Card, CardBody, CardHeader, CardTitle } from "@/components/ui/Card";
import { Badge } from "@/components/ui/Badge";
import { EmptyState } from "@/components/ui/EmptyState";
import { MetricCard } from "@/components/ui/MetricCard";
import { ProgressRing } from "@/components/ui/ProgressRing";
const FINDINGS = [
{ sev: "critical", title: "Hardcoded secret in asf-core/config.py#L18", rule: "SECRET-LEAK-001", file: "services/asf-core/app/config.py" },
{ sev: "high", title: "SQL injection vector in raw query", rule: "SQLI-014", file: "services/asf-core/app/repositories/artifact_repo.py" },
{ sev: "medium", title: "CORS permissive in dev-trust mode", rule: "CORS-002", file: "services/asf-core/app/main.py" },
{ sev: "medium", title: "Missing rate limit on /ask-asf", rule: "RATE-010", file: "services/asf-core/app/api/v1/ask_asf.py" },
{ sev: "low", title: "Insecure cookie flags hint", rule: "COOKIE-004", file: "services/asf-core/app/dependencies.py" },
];
const SEV_TONES: Record<string, "red" | "amber" | "neutral"> = {
critical: "red", high: "red", medium: "amber", low: "neutral",
};
export default function QualityPage() {
return (
<div className="p-6 lg:p-8 max-w-[1400px] mx-auto">
<header className="mb-6 flex flex-wrap items-start justify-between gap-3">
<div>
<h1 className="text-2xl font-semibold tracking-tight text-slate-900 flex items-center gap-2">
<ShieldCheck className="w-6 h-6 text-emerald-600" />
Quality & Security
</h1>
<p className="mt-1 text-sm text-slate-500">
Static analysis, test coverage, security findings, build health — consolidated from every ASF connector.
</p>
</div>
<Badge tone="neutral">SonarQube · Semgrep connectors</Badge>
</header>
<section className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
<MetricCard label="Critical findings" value={1} tone="red" icon={<ShieldAlert className="w-5 h-5" />} />
<MetricCard label="Tests passing" value={498} tone="green" icon={<TestTube2 className="w-5 h-5" />} subtitle="of 512 total" delta={0.6} />
<MetricCard label="Coverage" value={68.9} isPercentage tone="brand" icon={<Bug className="w-5 h-5" />} />
<MetricCard label="Reliability score" value="A-" tone="green" icon={<Lock className="w-5 h-5" />} />
</section>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4 mb-6">
<Card>
<CardHeader><CardTitle>Scores</CardTitle></CardHeader>
<CardBody className="space-y-4">
<ProgressRing value={72} tone="green" label="Security" sublabel="Findings + hardening" />
<ProgressRing value={68.9} tone="brand" label="Coverage" sublabel="Statement coverage" />
<ProgressRing value={81} tone="amber" label="Maintainability" sublabel="Duplication + complexity" />
</CardBody>
</Card>
<Card className="lg:col-span-2">
<CardHeader>
<CardTitle>Security findings</CardTitle>
<Badge tone="red">{FINDINGS.filter((f) => f.sev === "critical" || f.sev === "high").length} critical/high</Badge>
</CardHeader>
<CardBody className="p-0">
{FINDINGS.length === 0 ? (
<div className="p-6"><EmptyState title="No findings" description="Your first scan will populate this panel." icon={ShieldCheck} /></div>
) : (
<ul className="divide-y divide-slate-100">
{FINDINGS.map((f, i) => (
<li key={i} className="p-3 hover:bg-slate-50 flex items-start gap-3">
<Badge tone={SEV_TONES[f.sev]} className="uppercase mt-0.5">{f.sev}</Badge>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-slate-800">{f.title}</div>
<div className="mt-1 text-xs text-slate-500 flex flex-wrap items-center gap-3">
<span className="asf-monospace">{f.rule}</span>
<span className="asf-monospace truncate">{f.file}</span>
</div>
</div>
</li>
))}
</ul>
)}
</CardBody>
</Card>
</div>
</div>
);
}
+605
View File
@@ -0,0 +1,605 @@
"use client";
import React, { useMemo, useState } from "react";
import Link from "next/link";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
ClipboardList,
Filter,
Search,
ChevronRight,
ExternalLink,
Tag,
Calendar,
Loader2,
Plus,
} from "lucide-react";
import { Card, CardBody, CardHeader, CardTitle } from "@/components/ui/Card";
import { Button } from "@/components/ui/Button";
import { Badge, ArtifactTypeBadge } from "@/components/ui/Badge";
import { EmptyState } from "@/components/ui/EmptyState";
import { artifacts, projects } from "@/lib/api";
import { cn, fmtDate, shortId, truncate } from "@/lib/utils";
import type { Artifact } from "@/lib/types";
const DEMO_REQS: Artifact[] = [
{
id: "req-001-aaaaaaaa",
tenant_id: "t1",
project_id: "p1",
type: "requirement",
external_id: "REQ-441",
title: "Multi-tenant role-based access control",
content:
"As a platform administrator I want to define organization roles (owner, admin, member, viewer) so that I can enforce least-privilege access on every ASF module.",
status: "in_progress",
version: 3,
source: "jira",
created_by: "u1",
created_at: "2025-10-02T09:12:00Z",
updated_at: "2025-11-15T14:22:00Z",
},
{
id: "req-002-bbbbbbbb",
tenant_id: "t1",
project_id: "p1",
type: "requirement",
external_id: "REQ-442",
title: "Audit log immutability & retention",
content:
"Audit events must be append-only and retained for 365 days with tamper-evident hashing. Export to JSON/CSV must be signed.",
status: "backlog",
version: 1,
source: "manual",
created_by: "u2",
created_at: "2025-10-18T11:40:00Z",
updated_at: "2025-10-18T11:40:00Z",
},
{
id: "req-003-cccccccc",
tenant_id: "t1",
project_id: "p2",
type: "epic",
external_id: "EPIC-12",
title: "Traceability graph v1",
content:
"End-to-end artifact visualization: requirement → tasks → PR → builds → tests → deployments, with impact analysis sidebar.",
status: "in_progress",
version: 5,
source: "linear",
created_by: "u1",
created_at: "2025-09-10T08:00:00Z",
updated_at: "2025-11-20T09:02:00Z",
},
{
id: "req-004-dddddddd",
tenant_id: "t1",
project_id: "p2",
type: "requirement",
external_id: "REQ-501",
title: "AI Gateway provider abstraction",
content:
"Business logic must not depend on a specific LLM provider. Introduce ASF AI Gateway with adapter pattern, policy routing, usage metering.",
status: "done",
version: 2,
source: "manual",
created_at: "2025-08-22T16:15:00Z",
updated_at: "2025-11-05T12:30:00Z",
},
{
id: "req-005-eeeeeeee",
tenant_id: "t1",
project_id: "p1",
type: "requirement",
external_id: "REQ-518",
title: "SSO with SAML 2.0 & OIDC",
content:
"Support Enterprise SSO: Okta, Azure AD, Google Workspace. SCIM provisioning for automated user/group lifecycle.",
status: "blocked",
version: 1,
source: "jira",
created_at: "2025-11-01T10:10:00Z",
updated_at: "2025-11-22T08:00:00Z",
},
];
const STATUS_TONES: Record<string, "green" | "amber" | "red" | "brand" | "neutral" | "violet" | "blue"> = {
backlog: "neutral",
todo: "neutral",
in_progress: "blue",
review: "violet",
done: "green",
blocked: "red",
cancelled: "amber",
};
export default function RequirementsPage() {
const queryClient = useQueryClient();
const [q, setQ] = useState("");
const [statusFilter, setStatusFilter] = useState<string>("all");
const [typeFilter, setTypeFilter] = useState<string>("all");
const [projectFilter, setProjectFilter] = useState<string>("all");
const [selectedId, setSelectedId] = useState<string | null>(null);
/* ---------- New requirement modal ---------- */
const [showNew, setShowNew] = useState(false);
const [newTitle, setNewTitle] = useState("");
const [newExternalId, setNewExternalId] = useState("");
const [newStatus, setNewStatus] = useState<string>("backlog");
const [newProjectId, setNewProjectId] = useState<string>("");
const [newContent, setNewContent] = useState("");
const [newSource, setNewSource] = useState("manual");
const [newMetaText, setNewMetaText] = useState<string>("{}\n");
const [newMetaError, setNewMetaError] = useState<string | null>(null);
const parsedMeta = useMemo<Record<string, unknown> | null>(() => {
const s = newMetaText.trim();
if (!s) return {};
try {
const v = JSON.parse(s);
if (v === null || typeof v !== "object" || Array.isArray(v)) {
setNewMetaError("metadata must be a JSON object (not null/array/primitive).");
return null;
}
setNewMetaError(null);
return v as Record<string, unknown>;
} catch (e) {
setNewMetaError("Invalid JSON in metadata");
return null;
}
}, [newMetaText]);
const createReqM = useMutation({
mutationFn: async () => {
if (!newTitle.trim()) throw new Error("title required");
if (!newProjectId) throw new Error("project required");
if (parsedMeta === null) throw new Error("metadata JSON invalid");
return artifacts.create({
project_id: newProjectId,
type: "requirement",
title: newTitle.trim(),
external_id: newExternalId.trim() || undefined,
status: newStatus || undefined,
content: newContent.trim() || undefined,
source: newSource.trim() || undefined,
metadata: parsedMeta,
});
},
onSuccess: (data) => {
setShowNew(false);
setNewTitle(""); setNewExternalId(""); setNewStatus("backlog");
setNewProjectId(""); setNewContent(""); setNewSource("manual"); setNewMetaText("{}\n");
queryClient.invalidateQueries({ queryKey: ["artifacts", "list"] }).catch(() => undefined);
queryClient.invalidateQueries({ queryKey: ["projects", "list"] }).catch(() => undefined);
setSelectedId(String(data.id));
},
});
const projectsQ = useQuery({
queryKey: ["projects", "list"],
queryFn: () => projects.list({ limit: 200 }),
retry: false,
refetchOnWindowFocus: false,
});
const listQ = useQuery({
queryKey: [
"artifacts",
"list",
{ status: statusFilter, type: typeFilter, project: projectFilter, q: q.trim() },
],
queryFn: () =>
artifacts.list({
type: typeFilter !== "all" ? (typeFilter as any) : undefined,
status: statusFilter !== "all" ? statusFilter : undefined,
project_id: projectFilter !== "all" ? projectFilter : undefined,
search: q.trim() || undefined,
limit: 200,
}),
retry: false,
refetchOnWindowFocus: false,
});
const apiResolved = listQ.isFetched && listQ.data !== undefined;
const rows: Artifact[] = apiResolved ? listQ.data! : DEMO_REQS;
const isMock = !apiResolved;
const projectNameById = useMemo(() => {
const m = new Map<string, string>();
for (const p of projectsQ.data ?? []) {
if (p?.id) m.set(String(p.id), p.name ?? shortId(String(p.id)));
}
return m;
}, [projectsQ.data]);
const filtered = useMemo(() => {
return rows.filter((r) => {
if (projectFilter !== "all" && String(r.project_id) !== projectFilter) return false;
if (typeFilter !== "all" && r.type !== typeFilter) return false;
if (statusFilter !== "all" && (r.status ?? "backlog") !== statusFilter) return false;
const ql = q.trim().toLowerCase();
if (ql) {
const hay = `${r.title} ${r.external_id ?? ""} ${r.content ?? ""}`.toLowerCase();
if (!hay.includes(ql)) return false;
}
return true;
});
}, [rows, projectFilter, typeFilter, statusFilter, q]);
const selected = filtered.find((r) => r.id === selectedId) ?? filtered[0] ?? null;
const statuses = Array.from(new Set(rows.map((r) => r.status ?? "backlog")));
const types = Array.from(new Set(rows.map((r) => r.type)));
const projectOptions = useMemo(() => {
const setRows = new Map<string, string>();
for (const r of rows) setRows.set(String(r.project_id), projectNameById.get(String(r.project_id)) ?? shortId(String(r.project_id)));
for (const p of projectsQ.data ?? []) {
if (p?.id) setRows.set(String(p.id), p.name ?? shortId(String(p.id)));
}
return Array.from(setRows.entries()).map(([id, name]) => ({ id, name }));
}, [rows, projectsQ.data, projectNameById]);
return (
<div className="h-full flex flex-col min-h-0">
<header className="px-6 lg:px-8 pt-6 pb-4 flex flex-wrap items-start justify-between gap-4">
<div>
<h1 className="text-2xl font-semibold tracking-tight text-slate-900 flex items-center gap-2">
<ClipboardList className="w-6 h-6 text-brand-600" />
Requirements
</h1>
<p className="mt-1 text-sm text-slate-500">
Every lifecycle item in ASF is an Artifact. Requirements, epics, stories, tasks — searchable, linkable, traceable.
</p>
</div>
<div className="flex items-center gap-2">
{isMock && <Badge tone="amber">Demo data</Badge>}
<Button variant="primary" size="sm" onClick={() => {
setShowNew(true);
if ((projectsQ.data ?? []).length > 0 && !newProjectId) {
setNewProjectId(String(projectsQ.data![0].id));
}
}}>
<Plus className="w-4 h-4" /> New Requirement
</Button>
</div>
</header>
<div className="px-6 lg:px-8 pb-4 flex flex-wrap gap-2 items-center">
<div className="relative flex-1 min-w-[220px]">
<Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" />
<input
className="asf-input pl-9"
placeholder="Search by id, title, content…"
value={q}
onChange={(e) => setQ(e.target.value)}
/>
</div>
<div className="flex items-center gap-1.5 text-xs text-slate-500">
<Filter className="w-3.5 h-3.5" />
<select
className="asf-input !py-1.5 !text-xs !w-auto"
value={projectFilter}
onChange={(e) => setProjectFilter(e.target.value)}
>
<option value="all">All projects</option>
{projectOptions.map(({ id, name }) => (
<option key={id} value={id}>{name}</option>
))}
</select>
<select
className="asf-input !py-1.5 !text-xs !w-auto"
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
>
<option value="all">All statuses</option>
{statuses.map((s) => (
<option key={s} value={s}>{s}</option>
))}
</select>
<select
className="asf-input !py-1.5 !text-xs !w-auto"
value={typeFilter}
onChange={(e) => setTypeFilter(e.target.value)}
>
<option value="all">All types</option>
{types.map((t) => (
<option key={t} value={t}>{t}</option>
))}
</select>
</div>
<Badge tone="brand">
<span className="tabular-nums font-semibold">{filtered.length}</span> / {rows.length}
</Badge>
</div>
<div className="flex-1 min-h-0 px-6 lg:px-8 pb-6 grid grid-cols-1 xl:grid-cols-5 gap-4">
<Card className="xl:col-span-3 flex flex-col min-h-0">
<CardHeader>
<CardTitle>Artifact list</CardTitle>
<span className="text-xs text-slate-500">Click a row →</span>
</CardHeader>
<CardBody className="p-0 flex-1 min-h-0 overflow-auto">
{filtered.length === 0 ? (
<div className="p-6">
<EmptyState
title="No artifacts match"
description="Try adjusting filters or the search term."
/>
</div>
) : (
<table className="w-full text-sm">
<thead className="sticky top-0 z-10 bg-slate-50/90 backdrop-blur text-xs uppercase tracking-wide text-slate-500 border-b border-slate-200">
<tr>
<th className="text-left px-4 py-2">ID</th>
<th className="text-left px-4 py-2">Type</th>
<th className="text-left px-4 py-2">Title</th>
<th className="text-left px-4 py-2">Status</th>
<th className="text-left px-4 py-2">Updated</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{filtered.map((r) => (
<tr
key={r.id}
onClick={() => setSelectedId(r.id)}
className={cn(
"cursor-pointer hover:bg-brand-50/40 transition-colors",
selected?.id === r.id && "bg-brand-50/70",
)}
>
<td className="px-4 py-2.5 asf-monospace text-xs text-slate-500">
{r.external_id ?? shortId(r.id)}
</td>
<td className="px-4 py-2.5">
<ArtifactTypeBadge type={r.type} />
</td>
<td className="px-4 py-2.5 font-medium text-slate-800 max-w-md truncate">
{r.title}
</td>
<td className="px-4 py-2.5">
<Badge tone={STATUS_TONES[r.status ?? "backlog"] ?? "neutral"}>
{r.status ?? "backlog"}
</Badge>
</td>
<td className="px-4 py-2.5 text-xs text-slate-500 whitespace-nowrap">
{fmtDate(r.updated_at)}
</td>
</tr>
))}
</tbody>
</table>
)}
</CardBody>
</Card>
<Card className="xl:col-span-2 flex flex-col min-h-0">
<CardHeader>
<CardTitle>Detail</CardTitle>
<Link href="/traceability" className="text-xs text-brand-600 hover:underline inline-flex items-center gap-1">
Traceability <ChevronRight className="w-3 h-3" />
</Link>
</CardHeader>
<CardBody className="overflow-auto flex-1 min-h-0">
{selected ? (
<div className="space-y-4">
<div>
<div className="flex items-center gap-2 mb-1.5">
<span className="asf-monospace text-xs text-slate-500">
{selected.external_id ?? shortId(selected.id)}
</span>
<ArtifactTypeBadge type={selected.type} />
<Badge tone={STATUS_TONES[selected.status ?? "backlog"] ?? "neutral"}>
{selected.status ?? "backlog"}
</Badge>
{selected.version !== undefined && (
<Badge tone="neutral">v{selected.version}</Badge>
)}
</div>
<h2 className="text-lg font-semibold text-slate-900 leading-tight">
{selected.title}
</h2>
</div>
{selected.content && (
<div className="p-3 rounded-lg bg-slate-50 border border-slate-100 text-sm text-slate-700 leading-relaxed whitespace-pre-wrap">
{selected.content}
</div>
)}
<dl className="grid grid-cols-2 gap-x-4 gap-y-2 text-sm">
<div>
<dt className="text-xs text-slate-500">Project</dt>
<dd className="font-medium text-slate-800">
{projectNameById.get(String(selected.project_id)) ?? (
<span className="asf-monospace">{shortId(selected.project_id)}</span>
)}
</dd>
</div>
<div>
<dt className="text-xs text-slate-500">Source</dt>
<dd className="font-medium text-slate-800 inline-flex items-center gap-1">
<ExternalLink className="w-3 h-3" /> {selected.source ?? "—"}
</dd>
</div>
<div>
<dt className="text-xs text-slate-500 flex items-center gap-1">
<Calendar className="w-3 h-3" /> Created
</dt>
<dd className="text-slate-700">{fmtDate(selected.created_at)}</dd>
</div>
<div>
<dt className="text-xs text-slate-500 flex items-center gap-1">
<Calendar className="w-3 h-3" /> Updated
</dt>
<dd className="text-slate-700">{fmtDate(selected.updated_at)}</dd>
</div>
</dl>
<div className="pt-3 border-t border-slate-100 flex flex-wrap gap-2">
<Button variant="secondary" size="sm">View relationships</Button>
<Button variant="secondary" size="sm">History</Button>
<Button variant="primary" size="sm">Ask ASF about this…</Button>
</div>
<p className="text-xs text-slate-400 pt-1">
Description preview: {truncate(selected.content ?? "", 160).length > 0 ? truncate(selected.content ?? "", 160) : "No description."}
</p>
</div>
) : (
<EmptyState
title="Select an artifact"
description="Pick any row on the left to inspect its metadata and content."
/>
)}
</CardBody>
</Card>
</div>
{/* New requirement modal */}
{showNew && (
<div className="fixed inset-0 z-50 grid place-items-center bg-slate-900/50 backdrop-blur-sm p-4" onClick={() => !createReqM.isPending && setShowNew(false)}>
<div
className="w-full max-w-2xl rounded-xl bg-white border border-slate-200 shadow-xl max-h-[90vh] overflow-y-auto"
onClick={(e) => e.stopPropagation()}
>
<div className="px-5 py-4 border-b border-slate-100 flex items-center justify-between sticky top-0 bg-white z-10">
<h3 className="font-semibold text-slate-900 flex items-center gap-2">
<Tag className="w-4 h-4 text-brand-600" /> New Requirement
</h3>
<button
onClick={() => !createReqM.isPending && setShowNew(false)}
disabled={createReqM.isPending}
className="text-slate-400 hover:text-slate-600 text-xl leading-none disabled:opacity-50"
aria-label="close"
>
×
</button>
</div>
<form
className="p-5 space-y-3"
onSubmit={(e) => {
e.preventDefault();
createReqM.mutate();
}}
>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div className="md:col-span-2">
<label className="text-xs text-slate-500">Title *</label>
<input
className="asf-input mt-1"
placeholder="As a user I want …"
value={newTitle}
onChange={(e) => setNewTitle(e.target.value)}
autoFocus
required
/>
</div>
<div>
<label className="text-xs text-slate-500">External ID</label>
<input
className="asf-input mt-1"
placeholder="REQ-104"
value={newExternalId}
onChange={(e) => setNewExternalId(e.target.value)}
/>
</div>
<div>
<label className="text-xs text-slate-500">Project *</label>
<select
className="asf-input mt-1"
value={newProjectId}
onChange={(e) => setNewProjectId(e.target.value)}
required
>
<option value="">Select project…</option>
{(projectsQ.data ?? []).map((p) => (
<option key={String(p.id)} value={String(p.id)}>
{p.name ?? shortId(String(p.id))}
</option>
))}
</select>
</div>
<div>
<label className="text-xs text-slate-500">Status</label>
<select
className="asf-input mt-1"
value={newStatus}
onChange={(e) => setNewStatus(e.target.value)}
>
{["backlog","todo","in_progress","review","done","blocked","cancelled"].map((s) => (
<option key={s} value={s}>{s}</option>
))}
</select>
</div>
<div>
<label className="text-xs text-slate-500">Source</label>
<input
className="asf-input mt-1"
placeholder="manual"
value={newSource}
onChange={(e) => setNewSource(e.target.value)}
/>
</div>
</div>
<div>
<label className="text-xs text-slate-500">Content / Description</label>
<textarea
rows={4}
className="asf-input mt-1"
placeholder="Acceptance criteria, non-goals, links…"
value={newContent}
onChange={(e) => setNewContent(e.target.value)}
/>
</div>
<div>
<label className="text-xs text-slate-500 flex items-center justify-between mb-1">
<span>Metadata (JSON)</span>
{newMetaError && <span className="text-red-600 font-medium">{newMetaError}</span>}
</label>
<textarea
rows={4}
className={cn("asf-input mt-0", newMetaError && "border-red-300 focus:ring-red-200")}
value={newMetaText}
onChange={(e) => setNewMetaText(e.target.value)}
spellCheck={false}
style={{ fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace" }}
/>
<p className="text-[11px] text-slate-500 mt-1">
Example: <code className="bg-slate-100 rounded px-1">{"{\"priority\": \"P0\", \"points\": 8}"} </code>
</p>
</div>
{createReqM.isError && (
<div className="rounded-lg border border-red-200 bg-red-50 text-red-700 text-sm px-3 py-2">
{createReqM.error instanceof Error ? createReqM.error.message : "Failed to create requirement."}
</div>
)}
<div className="pt-3 border-t border-slate-100 flex justify-end gap-2">
<Button
type="button"
variant="secondary"
size="sm"
onClick={() => setShowNew(false)}
disabled={createReqM.isPending}
>
Cancel
</Button>
<Button
type="submit"
variant="primary"
size="sm"
disabled={createReqM.isPending || !newTitle.trim() || !newProjectId || parsedMeta === null}
>
{createReqM.isPending ? (
<>
<Loader2 className="w-4 h-4 animate-spin" /> Creating…
</>
) : (
<>
<Plus className="w-4 h-4" /> Create requirement
</>
)}
</Button>
</div>
</form>
</div>
</div>
)}
</div>
);
}
+906
View File
@@ -0,0 +1,906 @@
"use client";
import React, { useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
Settings2,
User,
Building2,
ShieldCheck,
Bell,
Palette,
KeyRound,
Save,
Plus,
Loader2,
FolderKanban,
CheckCircle2,
XCircle,
Calendar,
Pencil,
Trash2,
AlertTriangle,
} from "lucide-react";
import { Card, CardBody, CardHeader, CardTitle } from "@/components/ui/Card";
import { Button } from "@/components/ui/Button";
import { Badge } from "@/components/ui/Badge";
import { EmptyState } from "@/components/ui/EmptyState";
import { orgs, projects as projectsApi } from "@/lib/api";
import type { Organization, Project } from "@/lib/types";
import { cn, fmtDate, shortId } from "@/lib/utils";
const SECTIONS = [
{ key: "profile", label: "Profile", icon: User },
{ key: "org", label: "Organization", icon: Building2 },
{ key: "security", label: "Security", icon: ShieldCheck },
{ key: "ai", label: "AI Policy", icon: KeyRound },
{ key: "notifs", label: "Notifications", icon: Bell },
{ key: "theme", label: "Appearance", icon: Palette },
] as const;
type SecKey = (typeof SECTIONS)[number]["key"];
const PLAN_TONES: Record<string, "brand" | "violet" | "green" | "amber" | "neutral" | "blue" | "red"> = {
Free: "neutral", Team: "blue", Business: "brand", Enterprise: "violet",
};
const STATUS_TONES: Record<string, "green" | "red" | "neutral" | "brand" | "violet" | "amber" | "blue"> = {
true: "green", false: "red", active: "green", inactive: "red",
};
export default function SettingsPage() {
const queryClient = useQueryClient();
const [sec, setSec] = useState<SecKey>("org");
const [name, setName] = useState("Mario Rossi");
const [email, setEmail] = useState("mario.rossi@azurian.example");
const [role, setRole] = useState("Owner");
const [mfa, setMfa] = useState(true);
const [ceiling, setCeiling] = useState("50");
/* ---------------- Organizations ---------------- */
const orgsQ = useQuery({
queryKey: ["orgs", "list"],
queryFn: () => orgs.list({ limit: 200 }),
retry: false,
refetchOnWindowFocus: false,
});
const [showNewOrg, setShowNewOrg] = useState(false);
const [newOrgName, setNewOrgName] = useState("");
const [newOrgSlug, setNewOrgSlug] = useState("");
const [newOrgPlan, setNewOrgPlan] = useState<"Free" | "Team" | "Business" | "Enterprise">("Team");
const [newOrgActive, setNewOrgActive] = useState(true);
const createOrgM = useMutation({
mutationFn: async () => {
const slugRgx = /^[a-z0-9][a-z0-9-]{1,58}[a-z0-9]$/;
if (!newOrgName.trim()) throw new Error("Organization name required");
if (!slugRgx.test(newOrgSlug.trim())) throw new Error("Slug must be 3-60 chars: lowercase letters, digits, dashes (first+last alphanumeric)");
return orgs.create({
name: newOrgName.trim(),
slug: newOrgSlug.trim().toLowerCase(),
plan: newOrgPlan,
is_active: newOrgActive,
});
},
onSuccess: () => {
setShowNewOrg(false);
setNewOrgName(""); setNewOrgSlug(""); setNewOrgPlan("Team"); setNewOrgActive(true);
queryClient.invalidateQueries({ queryKey: ["orgs", "list"] }).catch(() => undefined);
},
});
const slugHint = useMemo(() => {
if (!newOrgSlug) return null;
const ok = /^[a-z0-9][a-z0-9-]{1,58}[a-z0-9]$/.test(newOrgSlug);
return ok ? "Valid slug format" : "⚠ Must be 3-60 a-z 0-9 -, first+last alphanumeric";
}, [newOrgSlug]);
/* ---------------- Projects ---------------- */
const projectsQ = useQuery({
queryKey: ["projects", "list"],
queryFn: () => projectsApi.list({ limit: 500 }),
retry: false,
refetchOnWindowFocus: false,
});
const [showNewProj, setShowNewProj] = useState(false);
const [newProjName, setNewProjName] = useState("");
const [newProjKey, setNewProjKey] = useState("");
const [newProjDesc, setNewProjDesc] = useState("");
const [newProjBranch, setNewProjBranch] = useState("main");
const [newProjActive, setNewProjActive] = useState(true);
const createProjM = useMutation({
mutationFn: async () => {
const keyRgx = /^[A-Z][A-Z0-9]{1,19}$/;
if (!newProjName.trim()) throw new Error("Project name required");
if (!keyRgx.test(newProjKey.trim())) throw new Error("Project key must be 2-20 uppercase letters/digits, starting with a letter (e.g. ASF, PROJ2)");
return projectsApi.create({
name: newProjName.trim(),
key: newProjKey.trim().toUpperCase(),
description: newProjDesc.trim() || undefined,
default_branch: newProjBranch.trim() || undefined,
is_active: newProjActive,
});
},
onSuccess: () => {
setShowNewProj(false);
setNewProjName(""); setNewProjKey(""); setNewProjDesc(""); setNewProjBranch("main"); setNewProjActive(true);
queryClient.invalidateQueries({ queryKey: ["projects", "list"] }).catch(() => undefined);
},
});
const keyHint = useMemo(() => {
if (!newProjKey) return null;
const ok = /^[A-Z][A-Z0-9]{1,19}$/.test(newProjKey);
return ok ? "Valid project key format" : "⚠ Must be 2-20 A-Z 0-9 starting with letter (e.g. ASF)";
}, [newProjKey]);
/* ---------------- Edit Organization ---------------- */
const [showEditOrg, setShowEditOrg] = useState(false);
const [editOrg, setEditOrg] = useState<Organization | null>(null);
const [eoName, setEoName] = useState("");
const [eoSlug, setEoSlug] = useState("");
const [eoPlan, setEoPlan] = useState<"Free" | "Team" | "Business" | "Enterprise">("Team");
const [eoActive, setEoActive] = useState(true);
const openEditOrg = (o: Organization) => {
setEditOrg(o);
setEoName(o.name ?? "");
setEoSlug(o.slug ?? "");
setEoPlan((o.plan as typeof eoPlan) ?? "Team");
setEoActive(Boolean(o.is_active ?? true));
setShowEditOrg(true);
};
const editOrgM = useMutation({
mutationFn: async () => {
if (!editOrg) throw new Error("no organization");
const slugRgx = /^[a-z0-9][a-z0-9-]{1,58}[a-z0-9]$/;
if (!eoName.trim()) throw new Error("Organization name required");
if (!slugRgx.test(eoSlug.trim())) throw new Error("Slug must be 3-60 chars: lowercase letters, digits, dashes (first+last alphanumeric)");
return orgs.update(String(editOrg.id), {
name: eoName.trim(),
slug: eoSlug.trim().toLowerCase(),
plan: eoPlan,
is_active: eoActive,
});
},
onSuccess: () => {
setShowEditOrg(false);
setEditOrg(null);
queryClient.invalidateQueries({ queryKey: ["orgs", "list"] }).catch(() => undefined);
},
});
const eoSlugHint = useMemo(() => {
if (!eoSlug) return null;
const ok = /^[a-z0-9][a-z0-9-]{1,58}[a-z0-9]$/.test(eoSlug);
return ok ? "Valid slug format" : "⚠ Must be 3-60 a-z 0-9 -, first+last alphanumeric";
}, [eoSlug]);
/* ---------------- Delete Organization ---------------- */
const [delOrg, setDelOrg] = useState<Organization | null>(null);
const deleteOrgM = useMutation({
mutationFn: async (id: string) => orgs.remove(id),
onSuccess: () => {
setDelOrg(null);
queryClient.invalidateQueries({ queryKey: ["orgs", "list"] }).catch(() => undefined);
},
});
/* ---------------- Edit Project ---------------- */
const [showEditProj, setShowEditProj] = useState(false);
const [editProj, setEditProj] = useState<Project | null>(null);
const [epName, setEpName] = useState("");
const [epKey, setEpKey] = useState("");
const [epDesc, setEpDesc] = useState("");
const [epBranch, setEpBranch] = useState("main");
const [epActive, setEpActive] = useState(true);
const openEditProj = (p: Project) => {
setEditProj(p);
setEpName(p.name ?? "");
setEpKey(p.key ?? "");
setEpDesc(p.description ?? "");
setEpBranch(p.default_branch ?? "main");
setEpActive(Boolean(p.is_active ?? true));
setShowEditProj(true);
};
const editProjM = useMutation({
mutationFn: async () => {
if (!editProj) throw new Error("no project");
const keyRgx = /^[A-Z][A-Z0-9]{1,19}$/;
if (!epName.trim()) throw new Error("Project name required");
if (!keyRgx.test(epKey.trim())) throw new Error("Project key must be 2-20 uppercase letters/digits, starting with a letter (e.g. ASF, PROJ2)");
return projectsApi.update(String(editProj.id), {
name: epName.trim(),
key: epKey.trim().toUpperCase(),
description: epDesc.trim() || undefined,
default_branch: epBranch.trim() || undefined,
is_active: epActive,
});
},
onSuccess: () => {
setShowEditProj(false);
setEditProj(null);
queryClient.invalidateQueries({ queryKey: ["projects", "list"] }).catch(() => undefined);
},
});
const epKeyHint = useMemo(() => {
if (!epKey) return null;
const ok = /^[A-Z][A-Z0-9]{1,19}$/.test(epKey);
return ok ? "Valid project key format" : "⚠ Must be 2-20 A-Z 0-9 starting with letter (e.g. ASF)";
}, [epKey]);
/* ---------------- Delete Project ---------------- */
const [delProj, setDelProj] = useState<Project | null>(null);
const deleteProjM = useMutation({
mutationFn: async (id: string) => projectsApi.remove(id),
onSuccess: () => {
setDelProj(null);
queryClient.invalidateQueries({ queryKey: ["projects", "list"] }).catch(() => undefined);
},
});
return (
<div className="p-6 lg:p-8 max-w-[1400px] mx-auto">
<header className="mb-6">
<h1 className="text-2xl font-semibold tracking-tight text-slate-900 flex items-center gap-2">
<Settings2 className="w-6 h-6 text-brand-600" />
Settings
</h1>
<p className="mt-1 text-sm text-slate-500">
User profile, organization, security, AI policy, notifications and appearance.
</p>
</header>
<div className="grid grid-cols-1 lg:grid-cols-4 gap-4">
<Card className="lg:col-span-1">
<CardBody className="p-2">
<ul className="space-y-0.5">
{SECTIONS.map(({ key, label, icon: Icon }) => (
<li key={key}>
<button
type="button"
onClick={() => setSec(key)}
className={cn(
"w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors",
sec === key
? "bg-brand-50 text-brand-700 ring-1 ring-inset ring-brand-100"
: "text-slate-600 hover:bg-slate-100 hover:text-slate-900",
)}
>
<Icon className="w-4 h-4" />
{label}
</button>
</li>
))}
</ul>
</CardBody>
</Card>
<Card className="lg:col-span-3">
<CardHeader>
<CardTitle className="capitalize flex items-center gap-2">
{sec === "profile" && <>User Profile <Badge tone="brand">You</Badge></>}
{sec === "org" && "Organization & Projects"}
{sec === "security" && "Security"}
{sec === "ai" && "AI Policy"}
{sec === "notifs" && "Notifications"}
{sec === "theme" && "Appearance"}
</CardTitle>
</CardHeader>
<CardBody>
{sec === "profile" && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 max-w-2xl">
<Field label="Full name">
<input className="asf-input" value={name} onChange={(e) => setName(e.target.value)} />
</Field>
<Field label="Email">
<input className="asf-input" type="email" value={email} onChange={(e) => setEmail(e.target.value)} />
</Field>
<Field label="Role">
<select className="asf-input" value={role} onChange={(e) => setRole(e.target.value)}>
<option>Owner</option><option>Admin</option><option>Member</option><option>Viewer</option>
</select>
</Field>
<div />
<div className="md:col-span-2 flex justify-end">
<Button variant="primary"><Save className="w-4 h-4" /> Save changes</Button>
</div>
</div>
)}
{sec === "org" && (
<div className="space-y-8">
{/* Organizations Card */}
<div>
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<Building2 className="w-5 h-5 text-slate-500" />
<h4 className="font-medium text-slate-800">Organizations</h4>
<Badge tone="brand">{orgsQ.data?.length ?? 0}</Badge>
</div>
<Button size="sm" variant="primary" onClick={() => setShowNewOrg(true)}>
<Plus className="w-4 h-4" /> New organization
</Button>
</div>
{orgsQ.isFetching && !orgsQ.data ? (
<div className="flex items-center gap-2 text-slate-500 text-sm px-3 py-6">
<Loader2 className="w-4 h-4 animate-spin" /> Loading organizations…
</div>
) : (orgsQ.data ?? []).length === 0 ? (
<EmptyState title="No organizations yet" description="Click 'New organization' above to create your first tenant." />
) : (
<div className="overflow-hidden rounded-xl border border-slate-200">
<table className="w-full text-sm">
<thead className="bg-slate-50 text-slate-500 text-xs uppercase tracking-wide">
<tr>
<th className="text-left font-medium px-4 py-2.5">Name</th>
<th className="text-left font-medium px-4 py-2.5">Slug</th>
<th className="text-left font-medium px-4 py-2.5">Plan</th>
<th className="text-left font-medium px-4 py-2.5">Status</th>
<th className="text-left font-medium px-4 py-2.5">Created</th>
<th className="text-right font-medium px-4 py-2.5">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{(orgsQ.data ?? []).map((o: Organization) => (
<tr key={String(o.id)} className="hover:bg-slate-50/50">
<td className="px-4 py-3 font-medium text-slate-800">{o.name} <span className="text-[10px] text-slate-400 font-mono ml-2">{shortId(String(o.id))}</span></td>
<td className="px-4 py-3 font-mono text-xs text-slate-600">{o.slug}</td>
<td className="px-4 py-3"><Badge tone={PLAN_TONES[String(o.plan ?? "Free")]}>{o.plan ?? "Free"}</Badge></td>
<td className="px-4 py-3"><Badge tone={STATUS_TONES[String(o.is_active ?? true)]}>{o.is_active ?? true ? <><CheckCircle2 className="w-3 h-3 mr-1"/>Active</> : <><XCircle className="w-3 h-3 mr-1"/>Inactive</>}</Badge></td>
<td className="px-4 py-3 text-slate-500 text-xs flex items-center gap-1"><Calendar className="w-3 h-3" />{o.created_at ? fmtDate(o.created_at) : "—"}</td>
<td className="px-4 py-3 text-right">
<div className="inline-flex items-center gap-1">
<Button variant="ghost" size="xs" onClick={() => openEditOrg(o)} title="Edit organization">
<Pencil className="w-3.5 h-3.5" />
</Button>
<Button
variant="ghost"
size="xs"
className="!text-red-600 hover:!bg-red-50"
onClick={() => setDelOrg(o)}
title="Delete organization"
disabled={delOrg?.id === o.id && deleteOrgM.isPending}
>
{delOrg?.id === o.id && deleteOrgM.isPending
? <Loader2 className="w-3.5 h-3.5 animate-spin" />
: <Trash2 className="w-3.5 h-3.5" />}
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
{/* Projects Card */}
<div>
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<FolderKanban className="w-5 h-5 text-slate-500" />
<h4 className="font-medium text-slate-800">Projects</h4>
<Badge tone="brand">{projectsQ.data?.length ?? 0}</Badge>
</div>
<Button size="sm" variant="primary" onClick={() => setShowNewProj(true)}>
<Plus className="w-4 h-4" /> New project
</Button>
</div>
{projectsQ.isFetching && !projectsQ.data ? (
<div className="flex items-center gap-2 text-slate-500 text-sm px-3 py-6">
<Loader2 className="w-4 h-4 animate-spin" /> Loading projects…
</div>
) : (projectsQ.data ?? []).length === 0 ? (
<EmptyState title="No projects yet" description="Create a project to start tracking requirements, code and deployments." />
) : (
<div className="overflow-hidden rounded-xl border border-slate-200">
<table className="w-full text-sm">
<thead className="bg-slate-50 text-slate-500 text-xs uppercase tracking-wide">
<tr>
<th className="text-left font-medium px-4 py-2.5">Key</th>
<th className="text-left font-medium px-4 py-2.5">Name</th>
<th className="text-left font-medium px-4 py-2.5">Default branch</th>
<th className="text-left font-medium px-4 py-2.5">Status</th>
<th className="text-left font-medium px-4 py-2.5">Updated</th>
<th className="text-right font-medium px-4 py-2.5">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{(projectsQ.data ?? []).map((p: Project) => (
<tr key={String(p.id)} className="hover:bg-slate-50/50">
<td className="px-4 py-3"><Badge tone="violet" className="font-mono">{p.key}</Badge></td>
<td className="px-4 py-3 font-medium text-slate-800">{p.name} <span className="text-[10px] text-slate-400 font-mono ml-2">{shortId(String(p.id))}</span></td>
<td className="px-4 py-3 font-mono text-xs text-slate-600">{p.default_branch ?? "—"}</td>
<td className="px-4 py-3"><Badge tone={STATUS_TONES[String(p.is_active ?? true)]}>{p.is_active ?? true ? <><CheckCircle2 className="w-3 h-3 mr-1"/>Active</> : <><XCircle className="w-3 h-3 mr-1"/>Archived</>}</Badge></td>
<td className="px-4 py-3 text-slate-500 text-xs flex items-center gap-1"><Calendar className="w-3 h-3" />{p.updated_at ? fmtDate(p.updated_at) : "—"}</td>
<td className="px-4 py-3 text-right">
<div className="inline-flex items-center gap-1">
<Button variant="ghost" size="xs" onClick={() => openEditProj(p)} title="Edit project">
<Pencil className="w-3.5 h-3.5" />
</Button>
<Button
variant="ghost"
size="xs"
className="!text-red-600 hover:!bg-red-50"
onClick={() => setDelProj(p)}
title="Delete project"
disabled={delProj?.id === p.id && deleteProjM.isPending}
>
{delProj?.id === p.id && deleteProjM.isPending
? <Loader2 className="w-3.5 h-3.5 animate-spin" />
: <Trash2 className="w-3.5 h-3.5" />}
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
)}
{sec === "security" && (
<div className="space-y-4 max-w-2xl">
<Toggle label="Multi-factor authentication" hint="Require a second factor at every login." checked={mfa} onChange={setMfa} />
<Toggle label="Session timeout 15m" hint="Force re-authentication after idle period." checked={true} onChange={() => {}} />
<Toggle label="Audit log retention 365 days" hint="Append-only, tamper-evident export." checked={true} onChange={() => {}} />
</div>
)}
{sec === "ai" && (
<div className="space-y-4 max-w-2xl">
<Field label="Monthly $ ceiling (USD)">
<input className="asf-input" type="number" value={ceiling} onChange={(e) => setCeiling(e.target.value)} />
</Field>
<Toggle label="Allow local models only" hint="Never route prompts to external providers." checked={false} onChange={() => {}} />
<Toggle label="Audit every inference" hint="Append every prompt + response to audit log." checked={true} onChange={() => {}} />
<p className="text-xs text-slate-500">AI Policy can be further refined per-project and per-team inside asf-ai.</p>
</div>
)}
{sec === "notifs" && (
<div className="space-y-4 max-w-2xl">
{[
{ l: "Job failures", h: "Email + in-app when any job fails." },
{ l: "Security findings critical/high", h: "Instant via configured channel." },
{ l: "Deployment completed", h: "Production deployments only." },
{ l: "Weekly digest", h: "Health, coverage and cost snapshot." },
].map((t) => (
<Toggle key={t.l} label={t.l} hint={t.h} defaultChecked onChange={() => {}} />
))}
</div>
)}
{sec === "theme" && (
<div className="space-y-4 max-w-2xl">
<div className="grid grid-cols-3 gap-3">
{[
{ k: "light", label: "Light", sw: "bg-white border-slate-300" },
{ k: "system", label: "System", sw: "bg-gradient-to-r from-white to-slate-900 border-slate-400" },
{ k: "dark", label: "Dark", sw: "bg-slate-900 border-slate-700" },
].map((t) => (
<button key={t.k} type="button" className={cn("rounded-xl border p-3 text-left hover:border-brand-400 transition-colors", t.k === "light" ? "border-brand-400 ring-2 ring-brand-100" : "border-slate-200")}>
<div className={cn("w-full h-16 rounded-lg border mb-2", t.sw)} />
<div className="text-sm font-medium text-slate-800">{t.label}</div>
<div className="text-xs text-slate-500">Default</div>
</button>
))}
</div>
<Toggle label="Density · Comfortable" hint="Looser paddings throughout the shell." checked={true} onChange={() => {}} />
</div>
)}
</CardBody>
</Card>
</div>
{/* ---------- New Organization modal ---------- */}
{showNewOrg && (
<div className="fixed inset-0 z-50 grid place-items-center bg-slate-900/50 backdrop-blur-sm p-4" onClick={() => !createOrgM.isPending && setShowNewOrg(false)}>
<div
className="w-full max-w-xl rounded-xl bg-white border border-slate-200 shadow-xl max-h-[90vh] overflow-y-auto"
onClick={(e) => e.stopPropagation()}
>
<div className="px-5 py-4 border-b border-slate-100 flex items-center justify-between sticky top-0 bg-white z-10">
<h3 className="font-semibold text-slate-900 flex items-center gap-2">
<Building2 className="w-4 h-4 text-brand-600" /> New organization
</h3>
<button
onClick={() => !createOrgM.isPending && setShowNewOrg(false)}
disabled={createOrgM.isPending}
className="text-slate-400 hover:text-slate-600 text-xl leading-none disabled:opacity-50"
aria-label="close"
>×</button>
</div>
<form
className="p-5 space-y-3"
onSubmit={(e) => { e.preventDefault(); createOrgM.mutate(); }}
>
<Field label="Name *">
<input className="asf-input" autoFocus required
placeholder="Acme Inc." value={newOrgName}
onChange={(e) => setNewOrgName(e.target.value)} />
</Field>
<Field label={<>Slug * <span className="text-[10px] text-slate-400">(URL identifier)</span></>}>
<input className="asf-input font-mono text-sm"
placeholder="acme-inc"
value={newOrgSlug}
onChange={(e) => setNewOrgSlug(e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, ""))} />
<div className={cn("text-[11px] mt-1", slugHint && slugHint.startsWith("⚠") ? "text-red-600" : "text-emerald-600")}>{slugHint ?? "3-60 chars, lowercase letters, digits, dashes. First and last must be alphanumeric."}</div>
</Field>
<div className="grid grid-cols-2 gap-3">
<Field label="Plan">
<select className="asf-input" value={newOrgPlan}
onChange={(e) => setNewOrgPlan(e.target.value as typeof newOrgPlan)}>
{(["Free","Team","Business","Enterprise"] as const).map((p) => <option key={p}>{p}</option>)}
</select>
</Field>
<Field label="Status">
<Toggle label="Active" hint="Organization visible and usable." checked={newOrgActive} onChange={setNewOrgActive} />
</Field>
</div>
{createOrgM.isError && (
<div className="rounded-lg border border-red-200 bg-red-50 text-red-700 text-sm px-3 py-2">
{createOrgM.error instanceof Error ? createOrgM.error.message : "Failed to create organization."}
</div>
)}
<div className="pt-3 border-t border-slate-100 flex justify-end gap-2">
<Button type="button" variant="secondary" size="sm" disabled={createOrgM.isPending} onClick={() => setShowNewOrg(false)}>Cancel</Button>
<Button type="submit" variant="primary" size="sm" disabled={createOrgM.isPending || !newOrgName || !/^[a-z0-9][a-z0-9-]{1,58}[a-z0-9]$/.test(newOrgSlug)}>
{createOrgM.isPending ? (
<><Loader2 className="w-4 h-4 animate-spin" /> Creating…</>
) : (<><Plus className="w-4 h-4" /> Create organization</>)}
</Button>
</div>
</form>
</div>
</div>
)}
{/* ---------- New Project modal ---------- */}
{showNewProj && (
<div className="fixed inset-0 z-50 grid place-items-center bg-slate-900/50 backdrop-blur-sm p-4" onClick={() => !createProjM.isPending && setShowNewProj(false)}>
<div
className="w-full max-w-xl rounded-xl bg-white border border-slate-200 shadow-xl max-h-[90vh] overflow-y-auto"
onClick={(e) => e.stopPropagation()}
>
<div className="px-5 py-4 border-b border-slate-100 flex items-center justify-between sticky top-0 bg-white z-10">
<h3 className="font-semibold text-slate-900 flex items-center gap-2">
<FolderKanban className="w-4 h-4 text-brand-600" /> New project
</h3>
<button
onClick={() => !createProjM.isPending && setShowNewProj(false)}
disabled={createProjM.isPending}
className="text-slate-400 hover:text-slate-600 text-xl leading-none disabled:opacity-50"
aria-label="close"
>×</button>
</div>
<form
className="p-5 space-y-3"
onSubmit={(e) => { e.preventDefault(); createProjM.mutate(); }}
>
<Field label="Name *">
<input className="asf-input" required autoFocus
placeholder="ASF Platform MVP"
value={newProjName}
onChange={(e) => setNewProjName(e.target.value)} />
</Field>
<Field label={<>Key * <span className="text-[10px] text-slate-400">(short uppercase code)</span></>}>
<input className="asf-input font-mono text-sm uppercase tracking-wide"
placeholder="ASF"
maxLength={20}
value={newProjKey}
onChange={(e) => setNewProjKey(e.target.value.toUpperCase().replace(/[^A-Z0-9]/g, ""))} />
<div className={cn("text-[11px] mt-1", keyHint && keyHint.startsWith("⚠") ? "text-red-600" : "text-emerald-600")}>{keyHint ?? "2-20 characters. Uppercase letters/digits only — starts with a letter."}</div>
</Field>
<Field label="Default branch">
<input className="asf-input font-mono text-xs"
placeholder="main"
value={newProjBranch}
onChange={(e) => setNewProjBranch(e.target.value)} />
</Field>
<Field label="Description">
<textarea className="asf-input" rows={3}
placeholder="Scope, goals and non-goals of this project."
value={newProjDesc}
onChange={(e) => setNewProjDesc(e.target.value)} />
</Field>
<Field label="Status">
<Toggle label="Active" hint="Project appears in pickers and filters." checked={newProjActive} onChange={setNewProjActive} />
</Field>
{createProjM.isError && (
<div className="rounded-lg border border-red-200 bg-red-50 text-red-700 text-sm px-3 py-2">
{createProjM.error instanceof Error ? createProjM.error.message : "Failed to create project."}
</div>
)}
<div className="pt-3 border-t border-slate-100 flex justify-end gap-2">
<Button type="button" variant="secondary" size="sm" disabled={createProjM.isPending} onClick={() => setShowNewProj(false)}>Cancel</Button>
<Button type="submit" variant="primary" size="sm"
disabled={createProjM.isPending || !newProjName || !/^[A-Z][A-Z0-9]{1,19}$/.test(newProjKey)}>
{createProjM.isPending ? (
<><Loader2 className="w-4 h-4 animate-spin" /> Creating…</>
) : (<><Plus className="w-4 h-4" /> Create project</>)}
</Button>
</div>
</form>
</div>
</div>
)}
{/* ---------- Edit Organization modal ---------- */}
{showEditOrg && (
<div className="fixed inset-0 z-50 grid place-items-center bg-slate-900/50 backdrop-blur-sm p-4" onClick={() => !editOrgM.isPending && setShowEditOrg(false)}>
<div
className="w-full max-w-xl rounded-xl bg-white border border-slate-200 shadow-xl max-h-[90vh] overflow-y-auto"
onClick={(e) => e.stopPropagation()}
>
<div className="px-5 py-4 border-b border-slate-100 flex items-center justify-between sticky top-0 bg-white z-10">
<h3 className="font-semibold text-slate-900 flex items-center gap-2">
<Pencil className="w-4 h-4 text-brand-600" /> Edit organization
</h3>
<button
onClick={() => !editOrgM.isPending && setShowEditOrg(false)}
disabled={editOrgM.isPending}
className="text-slate-400 hover:text-slate-600 text-xl leading-none disabled:opacity-50"
aria-label="close"
>×</button>
</div>
<form
className="p-5 space-y-3"
onSubmit={(e) => { e.preventDefault(); void editOrgM.mutateAsync(); }}
>
<Field label="Name *">
<input className="asf-input" required autoFocus
placeholder="Acme Inc." value={eoName}
onChange={(e) => setEoName(e.target.value)} />
</Field>
<Field label={<>Slug * <span className="text-[10px] text-slate-400">(URL identifier)</span></>}>
<input className="asf-input font-mono text-sm"
placeholder="acme-inc"
value={eoSlug}
onChange={(e) => setEoSlug(e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, ""))} />
<div className={cn("text-[11px] mt-1", eoSlugHint && eoSlugHint.startsWith("⚠") ? "text-red-600" : "text-emerald-600")}>{eoSlugHint ?? "3-60 chars, lowercase letters, digits, dashes. First and last must be alphanumeric."}</div>
</Field>
<div className="grid grid-cols-2 gap-3">
<Field label="Plan">
<select className="asf-input" value={eoPlan}
onChange={(e) => setEoPlan(e.target.value as typeof eoPlan)}>
{(["Free","Team","Business","Enterprise"] as const).map((p) => <option key={p}>{p}</option>)}
</select>
</Field>
<Field label="Status">
<Toggle label="Active" hint="Organization visible and usable." checked={eoActive} onChange={setEoActive} />
</Field>
</div>
{editOrgM.isError && (
<div className="rounded-lg border border-red-200 bg-red-50 text-red-700 text-sm px-3 py-2">
{editOrgM.error instanceof Error ? editOrgM.error.message : "Failed to update organization."}
</div>
)}
<div className="pt-3 border-t border-slate-100 flex justify-end gap-2">
<Button type="button" variant="secondary" size="sm" disabled={editOrgM.isPending} onClick={() => setShowEditOrg(false)}>Cancel</Button>
<Button type="submit" variant="primary" size="sm" disabled={editOrgM.isPending || !eoName || !/^[a-z0-9][a-z0-9-]{1,58}[a-z0-9]$/.test(eoSlug)}>
{editOrgM.isPending ? (
<><Loader2 className="w-4 h-4 animate-spin" /> Saving…</>
) : (<><Save className="w-4 h-4" /> Save changes</>)}
</Button>
</div>
</form>
</div>
</div>
)}
{/* ---------- Delete Organization confirm ---------- */}
{delOrg && (
<div className="fixed inset-0 z-50 grid place-items-center bg-slate-900/50 backdrop-blur-sm p-4" onClick={() => !deleteOrgM.isPending && setDelOrg(null)}>
<div
className="w-full max-w-md rounded-xl bg-white border border-slate-200 shadow-xl overflow-hidden"
onClick={(e) => e.stopPropagation()}
>
<div className="px-5 py-4 border-b border-slate-100 flex items-start gap-3">
<div className="w-10 h-10 rounded-full bg-red-50 grid place-items-center shrink-0">
<AlertTriangle className="w-5 h-5 text-red-600" />
</div>
<div className="min-w-0 flex-1">
<h3 className="font-semibold text-slate-900">Delete organization?</h3>
<p className="mt-1 text-sm text-slate-500">
Permanently delete <span className="font-semibold text-slate-700">{delOrg.name}</span>{" "}
<span className="font-mono text-xs">({delOrg.slug})</span>. All projects, artifacts and relationships under this tenant will be removed. This cannot be undone.
</p>
</div>
</div>
{deleteOrgM.isError && (
<div className="mx-5 mt-4 rounded-lg border border-red-200 bg-red-50 text-red-700 text-sm px-3 py-2">
{deleteOrgM.error instanceof Error ? deleteOrgM.error.message : "Failed to delete organization."}
</div>
)}
<div className="px-5 py-4 bg-slate-50 border-t border-slate-100 flex justify-end gap-2">
<Button type="button" variant="secondary" size="sm" disabled={deleteOrgM.isPending} onClick={() => setDelOrg(null)}>Cancel</Button>
<Button
type="button"
variant="primary"
size="sm"
className="!bg-red-600 hover:!bg-red-700 !text-white"
disabled={deleteOrgM.isPending}
onClick={() => void deleteOrgM.mutateAsync(String(delOrg.id))}
>
{deleteOrgM.isPending ? (
<><Loader2 className="w-4 h-4 animate-spin" /> Deleting…</>
) : (<><Trash2 className="w-4 h-4" /> Delete forever</>)}
</Button>
</div>
</div>
</div>
)}
{/* ---------- Edit Project modal ---------- */}
{showEditProj && (
<div className="fixed inset-0 z-50 grid place-items-center bg-slate-900/50 backdrop-blur-sm p-4" onClick={() => !editProjM.isPending && setShowEditProj(false)}>
<div
className="w-full max-w-xl rounded-xl bg-white border border-slate-200 shadow-xl max-h-[90vh] overflow-y-auto"
onClick={(e) => e.stopPropagation()}
>
<div className="px-5 py-4 border-b border-slate-100 flex items-center justify-between sticky top-0 bg-white z-10">
<h3 className="font-semibold text-slate-900 flex items-center gap-2">
<Pencil className="w-4 h-4 text-brand-600" /> Edit project
</h3>
<button
onClick={() => !editProjM.isPending && setShowEditProj(false)}
disabled={editProjM.isPending}
className="text-slate-400 hover:text-slate-600 text-xl leading-none disabled:opacity-50"
aria-label="close"
>×</button>
</div>
<form
className="p-5 space-y-3"
onSubmit={(e) => { e.preventDefault(); void editProjM.mutateAsync(); }}
>
<Field label="Name *">
<input className="asf-input" required autoFocus
placeholder="ASF Platform MVP"
value={epName}
onChange={(e) => setEpName(e.target.value)} />
</Field>
<Field label={<>Key * <span className="text-[10px] text-slate-400">(short uppercase code)</span></>}>
<input className="asf-input font-mono text-sm uppercase tracking-wide"
placeholder="ASF"
maxLength={20}
value={epKey}
onChange={(e) => setEpKey(e.target.value.toUpperCase().replace(/[^A-Z0-9]/g, ""))} />
<div className={cn("text-[11px] mt-1", epKeyHint && epKeyHint.startsWith("⚠") ? "text-red-600" : "text-emerald-600")}>{epKeyHint ?? "2-20 characters. Uppercase letters/digits only — starts with a letter."}</div>
</Field>
<Field label="Default branch">
<input className="asf-input font-mono text-xs"
placeholder="main"
value={epBranch}
onChange={(e) => setEpBranch(e.target.value)} />
</Field>
<Field label="Description">
<textarea className="asf-input" rows={3}
placeholder="Scope, goals and non-goals of this project."
value={epDesc}
onChange={(e) => setEpDesc(e.target.value)} />
</Field>
<Field label="Status">
<Toggle label="Active" hint="Project appears in pickers and filters." checked={epActive} onChange={setEpActive} />
</Field>
{editProjM.isError && (
<div className="rounded-lg border border-red-200 bg-red-50 text-red-700 text-sm px-3 py-2">
{editProjM.error instanceof Error ? editProjM.error.message : "Failed to update project."}
</div>
)}
<div className="pt-3 border-t border-slate-100 flex justify-end gap-2">
<Button type="button" variant="secondary" size="sm" disabled={editProjM.isPending} onClick={() => setShowEditProj(false)}>Cancel</Button>
<Button type="submit" variant="primary" size="sm"
disabled={editProjM.isPending || !epName || !/^[A-Z][A-Z0-9]{1,19}$/.test(epKey)}>
{editProjM.isPending ? (
<><Loader2 className="w-4 h-4 animate-spin" /> Saving…</>
) : (<><Save className="w-4 h-4" /> Save changes</>)}
</Button>
</div>
</form>
</div>
</div>
)}
{/* ---------- Delete Project confirm ---------- */}
{delProj && (
<div className="fixed inset-0 z-50 grid place-items-center bg-slate-900/50 backdrop-blur-sm p-4" onClick={() => !deleteProjM.isPending && setDelProj(null)}>
<div
className="w-full max-w-md rounded-xl bg-white border border-slate-200 shadow-xl overflow-hidden"
onClick={(e) => e.stopPropagation()}
>
<div className="px-5 py-4 border-b border-slate-100 flex items-start gap-3">
<div className="w-10 h-10 rounded-full bg-red-50 grid place-items-center shrink-0">
<AlertTriangle className="w-5 h-5 text-red-600" />
</div>
<div className="min-w-0 flex-1">
<h3 className="font-semibold text-slate-900">Delete project?</h3>
<p className="mt-1 text-sm text-slate-500">
Permanently delete <span className="font-semibold text-slate-700">{delProj.name}</span>{" "}
{delProj.key && <span className="font-mono text-xs">([{delProj.key}])</span>}. All artifacts in this project will be removed. This cannot be undone.
</p>
</div>
</div>
{deleteProjM.isError && (
<div className="mx-5 mt-4 rounded-lg border border-red-200 bg-red-50 text-red-700 text-sm px-3 py-2">
{deleteProjM.error instanceof Error ? deleteProjM.error.message : "Failed to delete project."}
</div>
)}
<div className="px-5 py-4 bg-slate-50 border-t border-slate-100 flex justify-end gap-2">
<Button type="button" variant="secondary" size="sm" disabled={deleteProjM.isPending} onClick={() => setDelProj(null)}>Cancel</Button>
<Button
type="button"
variant="primary"
size="sm"
className="!bg-red-600 hover:!bg-red-700 !text-white"
disabled={deleteProjM.isPending}
onClick={() => void deleteProjM.mutateAsync(String(delProj.id))}
>
{deleteProjM.isPending ? (
<><Loader2 className="w-4 h-4 animate-spin" /> Deleting…</>
) : (<><Trash2 className="w-4 h-4" /> Delete forever</>)}
</Button>
</div>
</div>
</div>
)}
</div>
);
}
function Field({ label, children }: { label: React.ReactNode; children: React.ReactNode }) {
return (
<label className="block">
<div className="text-xs font-medium text-slate-500 mb-1.5">{label}</div>
{children}
</label>
);
}
function Toggle({
label, hint, checked, defaultChecked, onChange,
}: {
label: string;
hint?: string;
checked?: boolean;
defaultChecked?: boolean;
onChange?: (v: boolean) => void;
}) {
const [internal, setInternal] = useState<boolean>(Boolean(defaultChecked));
const value = checked !== undefined ? checked : internal;
const toggle = () => {
const next = !value;
if (checked === undefined) setInternal(next);
onChange?.(next);
};
return (
<div className="flex items-start justify-between gap-4 p-3 rounded-lg border border-slate-100 hover:border-slate-200">
<div className="min-w-0">
<div className="text-sm font-medium text-slate-800">{label}</div>
{hint && <div className="text-xs text-slate-500 mt-0.5">{hint}</div>}
</div>
<button
type="button"
onClick={toggle}
role="switch"
aria-checked={value}
className={cn(
"relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors focus:outline-none focus:ring-2 focus:ring-brand-300",
value ? "bg-brand-600" : "bg-slate-200",
)}
>
<span
className={cn(
"pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow-sm ring-0 transition-transform",
value ? "translate-x-5" : "translate-x-0",
)}
/>
</button>
</div>
);
}
+390
View File
@@ -0,0 +1,390 @@
"use client";
import React, { useCallback, useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import ReactFlow, {
Background,
Controls,
MiniMap,
MarkerType,
Handle,
Position,
type Node,
type Edge,
type NodeProps,
} from "reactflow";
import "reactflow/dist/style.css";
import {
GitBranch,
ZoomIn,
ZoomOut,
Maximize2,
RefreshCw,
Layers,
ChevronRight,
} from "lucide-react";
import { Card, CardBody, CardHeader, CardTitle } from "@/components/ui/Card";
import { Button } from "@/components/ui/Button";
import { Badge, ArtifactTypeBadge, JobStatusBadge } from "@/components/ui/Badge";
import { EmptyState } from "@/components/ui/EmptyState";
import { traceability, artifacts, projects } from "@/lib/api";
import { cn, shortId } from "@/lib/utils";
import type { TraceGraph, ArtifactType } from "@/lib/types";
const DEMO_GRAPH: TraceGraph = {
nodes: [
{ id: "REQ-441", type: "requirement", label: "Multi-tenant RBAC", project_id: "p1", status: "in_progress", version: 3 },
{ id: "TASK-921", type: "task", label: "Tenant middleware", project_id: "p1", status: "done" },
{ id: "TASK-922", type: "task", label: "Policy evaluator", project_id: "p1", status: "done" },
{ id: "PR-214", type: "pull_request", label: "feat(rbac) #214", project_id: "p1", status: "done" },
{ id: "TEST-88", type: "test", label: "tenant_isolation.py", project_id: "p1", status: "done" },
{ id: "BUILD-4412", type: "build", label: "build #4412", project_id: "p1", status: "done" },
{ id: "DEP-44", type: "deployment", label: "asf-core v0.4.0 → prod", project_id: "p1", status: "done" },
],
edges: [
{ id: "e1", source: "TASK-921", target: "REQ-441", label: "implements" },
{ id: "e2", source: "TASK-922", target: "REQ-441", label: "implements" },
{ id: "e3", source: "PR-214", target: "TASK-921", label: "generated_from" },
{ id: "e4", source: "PR-214", target: "TASK-922", label: "generated_from" },
{ id: "e5", source: "TEST-88", target: "PR-214", label: "verifies" },
{ id: "e6", source: "BUILD-4412", target: "PR-214", label: "generated_from" },
{ id: "e7", source: "DEP-44", target: "BUILD-4412", label: "deployed_as" },
],
mock_fields: ["*"],
};
const TYPE_COLORS: Record<ArtifactType | string, { node: string; ring: string; text: string }> = {
requirement: { node: "#e0efff", ring: "#006fe0", text: "#084a8f" },
epic: { node: "#ede9fe", ring: "#7c3aed", text: "#4c1d95" },
story: { node: "#e0efff", ring: "#006fe0", text: "#084a8f" },
task: { node: "#e0f2fe", ring: "#0284c7", text: "#075985" },
architecture_decision: { node: "#ede9fe", ring: "#7c3aed", text: "#4c1d95" },
document: { node: "#f1f5f9", ring: "#64748b", text: "#334155" },
source_file: { node: "#f1f5f9", ring: "#64748b", text: "#334155" },
commit: { node: "#dcfce7", ring: "#16a34a", text: "#166534" },
pull_request: { node: "#e0f2fe", ring: "#0284c7", text: "#075985" },
build: { node: "#fef3c7", ring: "#d97706", text: "#92400e" },
test: { node: "#dcfce7", ring: "#16a34a", text: "#166534" },
security_finding: { node: "#fee2e2", ring: "#dc2626", text: "#991b1b" },
deployment: { node: "#dcfce7", ring: "#16a34a", text: "#166534" },
release: { node: "#dcfce7", ring: "#16a34a", text: "#166534" },
runtime_event: { node: "#fef3c7", ring: "#d97706", text: "#92400e" },
incident: { node: "#fee2e2", ring: "#dc2626", text: "#991b1b" },
ai_analysis: { node: "#ede9fe", ring: "#7c3aed", text: "#4c1d95" },
};
function ArtifactNode({ data, selected }: NodeProps<{ label: string; type: ArtifactType | string; status?: string; version?: number }>) {
const c = TYPE_COLORS[data.type] ?? TYPE_COLORS.document;
return (
<div
className={cn(
"px-3 py-2 rounded-lg shadow-card border backdrop-blur min-w-[160px] transition-shadow",
selected && "ring-2 ring-offset-2 ring-brand-400",
)}
style={{ background: c.node, borderColor: c.ring + "44" }}
>
<Handle type="target" position={Position.Top} className="!bg-slate-300 !border-white !w-2.5 !h-2.5" />
<div className="flex items-center gap-2">
<div className="w-1.5 h-6 rounded-full" style={{ background: c.ring }} />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5 flex-wrap">
<ArtifactTypeBadge type={data.type} />
{data.version !== undefined && <Badge tone="neutral">v{data.version}</Badge>}
</div>
<div className="mt-1 text-xs font-semibold leading-tight" style={{ color: c.text }}>
{data.label}
</div>
{data.status && (
<div className="mt-1">
<JobStatusBadge status={data.status} />
</div>
)}
</div>
</div>
<Handle type="source" position={Position.Bottom} className="!bg-slate-300 !border-white !w-2.5 !h-2.5" />
</div>
);
}
const nodeTypes = { artifact: ArtifactNode as unknown as React.ComponentType<NodeProps> };
function layoutLayered(graph: TraceGraph): { nodes: Node[]; edges: Edge[] } {
const nodeMap = new Map(graph.nodes.map((n) => [n.id, n]));
const outgoing = new Map<string, string[]>();
const incoming = new Map<string, string[]>();
graph.nodes.forEach((n) => {
outgoing.set(n.id, []);
incoming.set(n.id, []);
});
graph.edges.forEach((e) => {
outgoing.get(e.source)?.push(e.target);
incoming.get(e.target)?.push(e.source);
});
const depth = new Map<string, number>();
const visit = (id: string, d: number, seen: Set<string>) => {
if (seen.has(id)) return;
seen.add(id);
depth.set(id, Math.max(depth.get(id) ?? 0, d));
outgoing.get(id)?.forEach((next) => visit(next, d + 1, seen));
};
graph.nodes.forEach((n) => {
if ((incoming.get(n.id)?.length ?? 0) === 0) visit(n.id, 0, new Set());
});
graph.nodes.forEach((n) => {
if (!depth.has(n.id)) depth.set(n.id, 0);
});
const byDepth = new Map<number, string[]>();
depth.forEach((d, id) => {
if (!byDepth.has(d)) byDepth.set(d, []);
byDepth.get(d)!.push(id);
});
const nodes: Node[] = [];
const colW = 240;
const rowH = 110;
byDepth.forEach((ids, d) => {
ids.forEach((id, i) => {
const raw = nodeMap.get(id)!;
nodes.push({
id,
type: "artifact",
position: { x: d * colW, y: i * rowH },
data: { label: raw.label, type: raw.type, status: raw.status, version: raw.version },
});
});
});
const edges: Edge[] = graph.edges.map((e) => ({
id: e.id,
source: e.source,
target: e.target,
label: e.label,
type: "smoothstep",
animated: false,
style: { stroke: "#94a3b8", strokeWidth: 1.5 },
labelStyle: { fontSize: 10, fill: "#64748b" },
labelBgStyle: { fill: "#ffffffcc" },
labelBgPadding: [4, 2],
markerEnd: { type: MarkerType.ArrowClosed, color: "#94a3b8" },
}));
return { nodes, edges };
}
export default function TraceabilityPage() {
const [rootId, setRootId] = useState<string>("");
const graphQ = useQuery({
queryKey: ["traceability", rootId],
queryFn: () => traceability.graph(rootId ? { artifact_id: rootId, max_depth: 4 } : { limit: 200 }),
retry: false,
refetchOnWindowFocus: false,
refetchInterval: 5000,
});
const artQ = useQuery({
queryKey: ["traceability-artifacts"],
queryFn: () => artifacts.list({ limit: 30 }),
retry: false,
refetchOnWindowFocus: false,
});
const projectsQ = useQuery({
queryKey: ["traceability-projects"],
queryFn: () => projects.list({ limit: 50 }),
retry: false,
refetchOnWindowFocus: false,
});
const apiResolved = graphQ.isFetched && graphQ.data !== undefined;
const rawGraph: TraceGraph = apiResolved ? graphQ.data! : DEMO_GRAPH;
const isMock = !apiResolved;
const { nodes, edges } = useMemo(() => layoutLayered(rawGraph), [rawGraph]);
const artifactsList = (artQ.data ?? []).length ? artQ.data! : [];
// Map project_id → pretty project name for the Inspector panel
const projectNameById = useMemo(() => {
const m = new Map<string, string>();
for (const p of projectsQ.data ?? []) {
if (p?.id) m.set(String(p.id), p.name ?? shortId(String(p.id)));
}
return m;
}, [projectsQ.data]);
const [selId, setSelId] = useState<string | null>(null);
const selectedNode = rawGraph.nodes.find((n) => n.id === selId);
const onPaneReady = useCallback(() => {}, []);
return (
<div className="h-full flex flex-col min-h-0">
<header className="px-6 lg:px-8 pt-6 pb-4 flex flex-wrap items-start justify-between gap-3">
<div>
<h1 className="text-2xl font-semibold tracking-tight text-slate-900 flex items-center gap-2">
<GitBranch className="w-6 h-6 text-brand-600" />
Traceability
</h1>
<p className="mt-1 text-sm text-slate-500">
End-to-end artifact graph: Requirement → Task → Pull Request → Build → Test → Deployment. Click a node to inspect.
</p>
</div>
<div className="flex items-center gap-2">
{isMock && <Badge tone="amber">Demo graph</Badge>}
<select
className="asf-input !py-1.5 !text-sm !w-auto"
value={rootId}
onChange={(e) => setRootId(e.target.value)}
>
<option value="">All projects</option>
{artifactsList.map((a) => (
<option key={a.id} value={a.id}>{a.external_id ?? shortId(a.id)} · {a.title.slice(0, 40)}</option>
))}
</select>
<Button variant="secondary" size="sm" onClick={() => void graphQ.refetch()}>
<RefreshCw className="w-4 h-4" /> Reload
</Button>
</div>
</header>
<div className="flex-1 min-h-0 px-6 lg:px-8 pb-6 grid grid-cols-1 xl:grid-cols-4 gap-4">
<Card className="xl:col-span-3 flex flex-col min-h-0 overflow-hidden">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Layers className="w-4 h-4" />
Graph view
<Badge tone="neutral" className="ml-auto">
{rawGraph.nodes.length} nodes · {rawGraph.edges.length} edges
</Badge>
</CardTitle>
</CardHeader>
<CardBody className="p-0 flex-1 min-h-0 relative">
{nodes.length === 0 ? (
<div className="absolute inset-0 p-8">
<EmptyState title="Graph is empty" description="Create artifacts and relationships to populate the traceability view." />
</div>
) : (
<ReactFlow
nodes={nodes}
edges={edges}
nodeTypes={nodeTypes}
onInit={onPaneReady}
onNodeClick={(_, n) => setSelId(n.id)}
onPaneClick={() => setSelId(null)}
fitView
fitViewOptions={{ padding: 0.3 }}
proOptions={{ hideAttribution: true }}
>
<Background gap={16} color="#e2e8f0" />
<Controls
className="!rounded-lg !border !border-slate-200 !shadow-card overflow-hidden"
showInteractive={false}
>
<button className="asf-btn-ghost !p-2" title="Zoom in"><ZoomIn className="w-4 h-4" /></button>
<button className="asf-btn-ghost !p-2" title="Zoom out"><ZoomOut className="w-4 h-4" /></button>
<button className="asf-btn-ghost !p-2" title="Fit"><Maximize2 className="w-4 h-4" /></button>
</Controls>
<MiniMap
pannable
zoomable
className="!rounded-lg !border !border-slate-200 !shadow-card"
nodeColor={(n) => (TYPE_COLORS[(n.data as { type?: string })?.type ?? "document"] ?? TYPE_COLORS.document).ring}
maskColor="rgba(15, 23, 42, 0.06)"
/>
</ReactFlow>
)}
</CardBody>
</Card>
<Card className="flex flex-col min-h-0">
<CardHeader>
<CardTitle>Inspector</CardTitle>
<Badge tone="neutral">{rawGraph.nodes.length} types</Badge>
</CardHeader>
<CardBody className="overflow-auto flex-1 min-h-0">
{selectedNode ? (
<div className="space-y-3">
<div>
<div className="flex items-center gap-2 mb-1.5 flex-wrap">
<span className="asf-monospace text-xs text-slate-500">{selectedNode.id}</span>
<ArtifactTypeBadge type={selectedNode.type} />
</div>
<h2 className="text-base font-semibold text-slate-800 leading-tight">
{selectedNode.label}
</h2>
</div>
<dl className="text-sm space-y-1.5">
{selectedNode.status && (
<div className="flex items-center justify-between">
<dt className="text-slate-500">Status</dt>
<dd><JobStatusBadge status={selectedNode.status} /></dd>
</div>
)}
{selectedNode.version !== undefined && (
<div className="flex items-center justify-between">
<dt className="text-slate-500">Version</dt>
<dd className="asf-monospace">v{selectedNode.version}</dd>
</div>
)}
{selectedNode.project_id && (
<div className="flex items-center justify-between">
<dt className="text-slate-500">Project</dt>
<dd className="font-medium text-slate-800">
{projectNameById.get(String(selectedNode.project_id)) ?? (
<span className="asf-monospace text-xs">{shortId(selectedNode.project_id)}</span>
)}
</dd>
</div>
)}
{selectedNode.source && (
<div className="flex items-center justify-between">
<dt className="text-slate-500">Source</dt>
<dd>{selectedNode.source}</dd>
</div>
)}
</dl>
<div className="pt-2 flex flex-wrap gap-2">
<a href="/context" className="asf-btn-secondary inline-flex items-center gap-1.5 text-xs px-2.5 py-1.5 rounded-lg">
Open in Context <ChevronRight className="w-3 h-3" />
</a>
<a href="/requirements" className="asf-btn-ghost inline-flex items-center gap-1.5 text-xs px-2.5 py-1.5 rounded-lg">
Requirements
</a>
</div>
<div className="pt-3 border-t border-slate-100">
<div className="text-xs uppercase tracking-wide text-slate-500 mb-2">Connected edges</div>
<ul className="space-y-1 text-xs">
{rawGraph.edges.filter((e) => e.source === selectedNode.id || e.target === selectedNode.id).map((e) => (
<li key={e.id} className="flex items-center gap-2 p-1.5 rounded hover:bg-slate-50">
<span className="asf-monospace text-slate-500">
{e.source === selectedNode.id ? "→" : "←"}
</span>
<span className="font-mono text-slate-700">{e.source === selectedNode.id ? e.target : e.source}</span>
<Badge tone="brand" className="ml-auto">{e.label}</Badge>
</li>
))}
</ul>
</div>
</div>
) : (
<EmptyState
title="Click a node"
description="Select any node on the graph to inspect its details and connections."
icon={GitBranch}
/>
)}
<div className="mt-4 pt-4 border-t border-slate-100">
<div className="text-xs uppercase tracking-wide text-slate-500 mb-2">Legend</div>
<ul className="grid grid-cols-1 gap-1.5 text-xs">
{Object.entries(TYPE_COLORS).slice(0, 10).map(([k, v]) => (
<li key={k} className="flex items-center gap-2">
<span className="w-3 h-3 rounded-full border" style={{ background: v.node, borderColor: v.ring }} />
<span className="text-slate-700 capitalize">{k.replace("_", " ")}</span>
</li>
))}
</ul>
</div>
</CardBody>
</Card>
</div>
</div>
);
}
+119
View File
@@ -0,0 +1,119 @@
"use client";
import React from "react";
import { BarChart3, Cpu, Clock, DollarSign, Users, Zap } from "lucide-react";
import { Card, CardBody, CardHeader, CardTitle } from "@/components/ui/Card";
import { Badge } from "@/components/ui/Badge";
import { MetricCard } from "@/components/ui/MetricCard";
const MONTHS = ["Jul", "Aug", "Sep", "Oct", "Nov"];
const SERIES = [
{ label: "AI tokens (M)", data: [0.1, 0.8, 1.4, 2.9, 4.2], tone: "bg-violet-400" },
{ label: "Jobs executed", data: [120, 280, 412, 688, 904], tone: "bg-brand-400" },
];
function Bar({ label, values, tone }: { label: string; values: number[]; tone: string }) {
const max = Math.max(...values, 1);
return (
<div>
<div className="text-xs text-slate-500 mb-2 font-medium">{label}</div>
<div className="flex items-end gap-2 h-32">
{values.map((v, i) => (
<div key={i} className="flex-1 flex flex-col items-center gap-1">
<div
className={["w-full rounded-t-md transition-all", tone].join(" ")}
style={{ height: `${(v / max) * 100}%`, minHeight: "4px" }}
title={`${v}`}
/>
<div className="text-[10px] text-slate-500">{MONTHS[i]}</div>
</div>
))}
</div>
</div>
);
}
const ROWS = [
{ name: "Tenant default", projects: 6, users: 14, jobs_7d: 412, tokens_7d: 2.1, cost_7d: 128.4 },
{ name: "Tenant acme-corp", projects: 2, users: 6, jobs_7d: 88, tokens_7d: 0.6, cost_7d: 24.1 },
{ name: "Tenant contoso-labs", projects: 3, users: 9, jobs_7d: 180, tokens_7d: 1.1, cost_7d: 52.7 },
];
export default function UsagePage() {
return (
<div className="p-6 lg:p-8 max-w-[1400px] mx-auto">
<header className="mb-6 flex flex-wrap items-start justify-between gap-3">
<div>
<h1 className="text-2xl font-semibold tracking-tight text-slate-900 flex items-center gap-2">
<BarChart3 className="w-6 h-6 text-brand-600" />
Usage
</h1>
<p className="mt-1 text-sm text-slate-500">
Tenant-level metering: AI tokens, background jobs, projects, seats and estimated cost.
</p>
</div>
<Badge tone="neutral">Monthly view</Badge>
</header>
<section className="grid grid-cols-2 md:grid-cols-5 gap-4 mb-6">
<MetricCard label="Projects" value={11} tone="brand" icon={<BarChart3 className="w-5 h-5" />} />
<MetricCard label="Active users" value={29} tone="blue" icon={<Users className="w-5 h-5" />} />
<MetricCard label="Jobs (7d)" value={680} tone="green" icon={<Clock className="w-5 h-5" />} delta={12.4} />
<MetricCard label="AI tokens (7d, M)" value={3.8} tone="violet" icon={<Zap className="w-5 h-5" />} delta={18.7} />
<MetricCard label="Est. cost (7d, $)" value={205.2} tone="amber" icon={<DollarSign className="w-5 h-5" />} delta={9.2} />
</section>
<section className="grid grid-cols-1 lg:grid-cols-2 gap-4 mb-6">
<Card>
<CardHeader><CardTitle>Trend · AI tokens</CardTitle><Badge tone="violet">millions</Badge></CardHeader>
<CardBody>
<Bar label="" values={SERIES[0].data} tone={SERIES[0].tone} />
</CardBody>
</Card>
<Card>
<CardHeader><CardTitle>Trend · Jobs executed</CardTitle><Badge tone="brand">count</Badge></CardHeader>
<CardBody>
<Bar label="" values={SERIES[1].data} tone={SERIES[1].tone} />
</CardBody>
</Card>
</section>
<Card>
<CardHeader>
<CardTitle>Per-tenant breakdown</CardTitle>
<Badge tone="neutral">{ROWS.length} tenants</Badge>
</CardHeader>
<CardBody className="p-0">
<table className="w-full text-sm">
<thead className="bg-slate-50 text-xs uppercase tracking-wide text-slate-500 border-b border-slate-200">
<tr>
<th className="text-left px-4 py-2">Tenant</th>
<th className="text-right px-4 py-2">Projects</th>
<th className="text-right px-4 py-2">Users</th>
<th className="text-right px-4 py-2">Jobs (7d)</th>
<th className="text-right px-4 py-2">Tokens (7d, M)</th>
<th className="text-right px-4 py-2">Cost (7d)</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{ROWS.map((r) => (
<tr key={r.name} className="hover:bg-slate-50">
<td className="px-4 py-2.5 font-medium text-slate-800">{r.name}</td>
<td className="px-4 py-2.5 text-right tabular-nums">{r.projects}</td>
<td className="px-4 py-2.5 text-right tabular-nums">{r.users}</td>
<td className="px-4 py-2.5 text-right tabular-nums">{r.jobs_7d}</td>
<td className="px-4 py-2.5 text-right tabular-nums">{r.tokens_7d.toFixed(2)}</td>
<td className="px-4 py-2.5 text-right tabular-nums">${r.cost_7d.toFixed(2)}</td>
</tr>
))}
</tbody>
</table>
</CardBody>
</Card>
<div className="mt-4 flex items-center justify-end gap-3 text-xs text-slate-500">
<Badge tone="neutral"><Cpu className="w-3 h-3" /> GPU-s metered</Badge>
<Badge tone="neutral"><DollarSign className="w-3 h-3" /> Cost tables configurable per-tenant</Badge>
</div>
</div>
);
}
@@ -0,0 +1,13 @@
"use client";
import React from "react";
import { QueryClientProvider } from "@tanstack/react-query";
import { queryClient } from "@/lib/queryClient";
export function AppProviders({ children }: { children: React.ReactNode }) {
return (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
);
}
@@ -0,0 +1,312 @@
"use client";
import React, { useEffect, useRef, useState } from "react";
import {
Sparkles,
Send,
X,
Loader2,
MessageCircle,
CheckCircle2,
AlertCircle,
ChevronDown,
ChevronUp,
} from "lucide-react";
import { useMutation, useQuery } from "@tanstack/react-query";
import { askAsf, jobs } from "@/lib/api";
import { useAppStore } from "@/stores/useAppStore";
import { Button } from "@/components/ui/Button";
import { Badge, JobStatusBadge } from "@/components/ui/Badge";
import { cn, fmtDate, truncate } from "@/lib/utils";
import type { Job } from "@/lib/types";
interface ChatMessage {
id: string;
role: "user" | "system" | "assistant";
kind?: "question" | "info" | "success" | "error";
content: string;
ts: Date;
jobId?: string;
}
export function AskAsfCommandBar() {
const open = useAppStore((s) => s.asfOpen);
const closeAsf = useAppStore((s) => s.closeAsf);
const openAsf = useAppStore((s) => s.openAsf);
const [q, setQ] = useState("");
const [messages, setMessages] = useState<ChatMessage[]>([
{
id: "welcome",
role: "system",
kind: "info",
content:
"Ask anything about your tenant data: requirements without tests, why a build failed, which services implement REQ-441, … This UI wires to the AI Gateway through asf-worker — in bootstrap you'll see a transparent MockProvider response.",
ts: new Date(),
},
]);
const [pollingJobId, setPollingJobId] = useState<string | null>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
// open via global event + ⌘K
useEffect(() => {
function onKey(e: KeyboardEvent) {
const meta = e.metaKey || e.ctrlKey;
if (meta && e.key.toLowerCase() === "k") {
e.preventDefault();
if (open) closeAsf(); else openAsf();
}
if (e.key === "Escape" && open) closeAsf();
}
function onOpenEv() { openAsf(); }
window.addEventListener("keydown", onKey);
window.addEventListener("asf:open-command", onOpenEv);
return () => {
window.removeEventListener("keydown", onKey);
window.removeEventListener("asf:open-command", onOpenEv);
};
}, [open, openAsf, closeAsf]);
useEffect(() => {
if (open) setTimeout(() => textareaRef.current?.focus(), 50);
}, [open]);
const submitMutation = useMutation({
mutationFn: (question: string) =>
askAsf.query({ question }),
onMutate: (question) => {
setMessages((m) => [
...m,
{ id: "u" + Date.now(), role: "user", kind: "question", content: question, ts: new Date() },
]);
},
onSuccess: (resp) => {
setMessages((m) => [
...m,
{
id: "subm" + Date.now(),
role: "system",
kind: "info",
content: `Query accepted. Polling job ${resp.job_id.slice(0, 8)}… — status: queued`,
ts: new Date(),
jobId: resp.job_id,
},
]);
setPollingJobId(resp.job_id);
},
onError: (err: Error) => {
setMessages((m) => [
...m,
{ id: "e" + Date.now(), role: "system", kind: "error", content: err.message, ts: new Date() },
]);
},
});
// long-poll the accepted job until terminal
useQuery({
queryKey: ["ask-job", pollingJobId],
enabled: !!pollingJobId,
refetchInterval: (q) => (q.state.data?.status === "completed" || q.state.data?.status === "failed" || q.state.data?.status === "cancelled" ? false : 1500),
queryFn: async (): Promise<Job | undefined> => {
if (!pollingJobId) return undefined;
const job = await jobs.get(pollingJobId);
if (job && (job.status === "completed" || job.status === "failed" || job.status === "cancelled")) {
setPollingJobId(null);
setMessages((m) => {
// avoid adding duplicate result messages
if (m.some(x => x.id === `res-${job.id}`)) return m;
if (job.status === "completed" && job.result) {
const answer =
(job.result as Record<string, unknown>).answer as string ||
JSON.stringify(job.result, null, 2);
return [
...m,
{
id: `res-${job.id}`,
role: "assistant",
kind: "success",
content: answer,
ts: new Date(),
jobId: job.id,
},
];
}
if (job.status === "failed") {
return [
...m,
{
id: `fail-${job.id}`,
role: "system",
kind: "error",
content: job.error || "Unknown AI job failure.",
ts: new Date(),
jobId: job.id,
},
];
}
return m;
});
}
return job;
},
});
function onSubmit(e: React.FormEvent) {
e.preventDefault();
if (!q.trim()) return;
submitMutation.mutate(q.trim());
setQ("");
}
const [expanded, setExpanded] = useState(true);
if (!open) return null;
return (
<div
className="fixed inset-0 z-50 flex items-start justify-center pt-[10vh] px-4 bg-slate-950/40 backdrop-blur-[2px] animate-fade-in"
onClick={closeAsf}
>
<div
className="w-full max-w-2xl bg-white rounded-2xl shadow-2xl border border-slate-200 overflow-hidden"
onClick={(e) => e.stopPropagation()}
>
{/* header */}
<div className="flex items-center gap-3 px-5 py-3.5 border-b border-slate-200 bg-gradient-to-r from-brand-50/60 to-transparent">
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-brand-500 to-accent grid place-items-center text-white shadow-sm">
<Sparkles className="w-4 h-4" />
</div>
<div className="min-w-0 flex-1">
<div className="text-sm font-semibold text-slate-800 leading-tight">Ask ASF</div>
<div className="text-[11px] text-slate-500 leading-tight">
Context-aware queries backed by the ASF AI Gateway
</div>
</div>
<Badge tone="violet">
<MessageCircle className="w-3 h-3 mr-1" />
mock provider
</Badge>
<button className="asf-btn-ghost !p-1.5" onClick={closeAsf} aria-label="Close">
<X className="w-4.5 h-4.5 w-[18px] h-[18px]" />
</button>
</div>
{/* messages */}
<div className="px-5 py-4 space-y-3 max-h-[50vh] overflow-y-auto">
{messages.map((m) => (
<MessageBubble key={m.id} m={m} />
))}
{submitMutation.isPending && (
<div className="flex items-center gap-2 text-xs text-slate-500 animate-fade-in">
<Loader2 className="w-3.5 h-3.5 animate-spin" />
Submitting query to ASF AI job queue…
</div>
)}
{pollingJobId && (
<div className="flex items-center gap-2 text-xs text-slate-500 animate-fade-in">
<Loader2 className="w-3.5 h-3.5 animate-spin" />
Polling job <code className="asf-monospace text-slate-700 bg-slate-50 rounded px-1.5 py-0.5 border border-slate-200">{pollingJobId.slice(0,12)}</code> until terminal state…
</div>
)}
</div>
{/* toggle for expanded mini-chat */}
<button
type="button"
onClick={() => setExpanded((v) => !v)}
className="hidden w-full items-center justify-center gap-1 py-2 text-xs text-slate-500 border-t border-slate-100 hover:bg-slate-50"
>
{expanded ? <>Collapse <ChevronDown className="w-3 h-3" /></> : <>Expand <ChevronUp className="w-3 h-3" /></>}
</button>
{/* input */}
<form onSubmit={onSubmit} className="px-5 py-3.5 border-t border-slate-200 bg-slate-50/50 flex gap-2">
<textarea
ref={textareaRef}
rows={2}
value={q}
onChange={(e) => setQ(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) onSubmit(e);
}}
placeholder={
expanded
? 'e.g. "Show requirements without tests", "Which services implement REQ-441?"'
: "Ask ASF… (⌘↩ to send)"
}
className="asf-input resize-none"
/>
<Button
type="submit"
variant="primary"
className="self-end shrink-0"
disabled={!q.trim() || submitMutation.isPending}
>
{submitMutation.isPending ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Send className="w-4 h-4" />
)}
<span className="hidden md:inline">Send</span>
</Button>
</form>
<div className="px-5 pb-4 flex flex-wrap gap-2 bg-slate-50/50">
{[
"Show requirements without tests.",
"Why did yesterday's build fail?",
"Which services implement REQ-441?",
"Generate tests for TASK-921.",
].map((s) => (
<button
key={s}
type="button"
onClick={() => setQ(s)}
className="asf-chip hover:bg-brand-50 hover:text-brand-700 hover:border-brand-100 transition-colors"
>
{s}
</button>
))}
</div>
</div>
</div>
);
}
function MessageBubble({ m }: { m: ChatMessage }) {
const Icon =
m.kind === "success" ? CheckCircle2 :
m.kind === "error" ? AlertCircle :
m.kind === "question" ? MessageCircle : MessageCircle;
const tone =
m.role === "assistant" ? "bg-brand-50 border-brand-100" :
m.role === "user" ? "bg-slate-50 border-slate-200" :
m.kind === "error" ? "bg-red-50 border-red-100" :
"bg-white border-slate-200";
const toneIcon =
m.kind === "success" ? "text-emerald-600" :
m.kind === "error" ? "text-red-600" :
m.role === "user" ? "text-slate-600" :
"text-brand-600";
return (
<div className={cn("flex gap-3 animate-fade-in")}>
<div className={cn("w-7 h-7 rounded-lg grid place-items-center shrink-0 border", tone)}>
<Icon className={cn("w-4 h-4", toneIcon)} />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 text-[11px] text-slate-500">
<span className="font-medium capitalize text-slate-600">{m.role}</span>
<span>· {fmtDate(m.ts)}</span>
{m.jobId && <JobStatusBadge status={"queued"} />}
</div>
<div className={cn(
"mt-1 rounded-lg border text-sm px-3 py-2 whitespace-pre-wrap break-words",
tone,
m.role === "assistant" ? "asf-monospace !font-sans leading-relaxed" : "",
)}>
{truncate(m.content, 1200)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,110 @@
"use client";
import React from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import {
LayoutDashboard,
ClipboardList,
Search,
Code2,
GitBranch,
Bot,
GitFork,
Rocket,
ShieldCheck,
Activity,
Plug,
BarChart3,
Settings2,
Sparkles,
} from "lucide-react";
import { cn } from "@/lib/utils";
export interface NavItem {
href: string;
label: string;
icon: React.ComponentType<{ className?: string }>;
badge?: string;
}
export const NAV: NavItem[] = [
{ href: "/", label: "Overview", icon: LayoutDashboard },
{ href: "/requirements", label: "Requirements", icon: ClipboardList },
{ href: "/context", label: "Context Explorer", icon: Search },
{ href: "/code", label: "Code", icon: Code2 },
{ href: "/traceability", label: "Traceability", icon: GitBranch },
{ href: "/ask-asf", label: "Ask ASF", icon: Sparkles, badge: "AI" },
{ href: "/jobs", label: "Jobs", icon: Activity },
{ href: "/ai-agents", label: "AI Agents", icon: Bot, badge: "α" },
{ href: "/pipelines", label: "Pipelines", icon: GitFork },
{ href: "/deployments", label: "Deployments", icon: Rocket },
{ href: "/quality", label: "Quality", icon: ShieldCheck },
{ href: "/observability", label: "Observability", icon: Activity },
{ href: "/integrations", label: "Integrations", icon: Plug },
{ href: "/usage", label: "Usage", icon: BarChart3 },
{ href: "/settings", label: "Settings", icon: Settings2 },
];
export function Sidebar() {
const path = usePathname();
return (
<aside className="hidden lg:flex w-64 shrink-0 border-r border-slate-200/80 bg-white/60 backdrop-blur-sm flex-col">
<div className="h-16 flex items-center gap-3 px-5 border-b border-slate-200/80">
<div className="w-9 h-9 rounded-lg bg-gradient-to-br from-brand-500 to-accent shadow-soft grid place-items-center text-white font-bold">
A
</div>
<div className="min-w-0">
<div className="text-sm font-semibold text-slate-800 leading-tight">
{process.env.NEXT_PUBLIC_APP_NAME || "ASF"}
</div>
<div className="text-[11px] text-slate-500 leading-tight">
Software Factory
</div>
</div>
</div>
<nav className="flex-1 overflow-y-auto p-3 space-y-0.5">
{NAV.map(({ href, label, icon: Icon, badge }) => {
const active = href === "/" ? path === "/" : path.startsWith(href);
return (
<Link
key={href}
href={href}
className={cn("asf-nav-item", active && "asf-nav-item-active")}
>
<Icon className="w-4.5 h-4.5 w-[18px] h-[18px] shrink-0" />
<span className="flex-1">{label}</span>
{badge && (
<span className="asf-chip border-accent/20 bg-accent/10 text-accent-muted !px-1.5">
{badge}
</span>
)}
</Link>
);
})}
</nav>
<div className="p-3 border-t border-slate-200/80">
<button
onClick={() => {
// keyboard shortcut is handled in layout; this button also triggers.
const ev = new CustomEvent("asf:open-command");
window.dispatchEvent(ev);
}}
className="w-full asf-btn-secondary justify-between !px-3"
type="button"
>
<span className="flex items-center gap-2">
<Sparkles className="w-4 h-4 text-accent" />
<span className="text-slate-700">Ask ASF…</span>
</span>
<kbd className="asf-chip !text-[10px]">⌘ K</kbd>
</button>
<p className="mt-3 text-[11px] text-slate-400 leading-relaxed">
Tip: every lifecycle element in ASF is an <span className="font-mono text-slate-600 bg-slate-100 rounded px-1">Artifact</span>.
</p>
</div>
</aside>
);
}
@@ -0,0 +1,127 @@
"use client";
import React, { useEffect } from "react";
import { useQuery } from "@tanstack/react-query";
import {
Building2,
FolderKanban,
Bell,
Search,
ChevronDown,
UserCircle,
} from "lucide-react";
import { orgs, projects } from "@/lib/api";
import { Dropdown, type Option } from "@/components/ui/Dropdown";
import { Button } from "@/components/ui/Button";
import { useAppStore } from "@/stores/useAppStore";
import { cn } from "@/lib/utils";
export function Topbar({ className }: { className?: string }) {
const setOrg = useAppStore((s) => s.setOrg);
const setProject = useAppStore((s) => s.setProject);
const currentOrg = useAppStore((s) => s.org_id);
const currentProject = useAppStore((s) => s.project_id);
const openAsf = useAppStore((s) => s.openAsf);
const { data: orgList = [] } = useQuery({
queryKey: ["orgs"],
queryFn: () => orgs.list({ limit: 50 }),
initialData: [],
});
const { data: projectList = [] } = useQuery({
queryKey: ["projects"],
queryFn: () => projects.list({ limit: 100 }),
initialData: [],
enabled: !!currentOrg,
});
const orgOptions: Option<string>[] = orgList.map((o) => ({
value: o.id,
label: o.name,
description: o.slug,
}));
if (orgOptions.length === 0) {
// Dev-mode synthetic org (matches NEXT_PUBLIC_DEV_TENANT_ID)
orgOptions.push({
value: currentOrg || "00000000-0000-0000-0000-000000000001",
label: "Azurian Labs",
description: "azurian-labs (bootstrap demo)",
});
}
const projectOptions: Option<string>[] = projectList.map((p) => ({
value: p.id,
label: `${p.key} · ${p.name}`,
description: p.description || undefined,
}));
// Add a synthetic "All projects" option
projectOptions.unshift({
value: "__all__",
label: "All projects",
description: "Tenant-wide view",
});
useEffect(() => {
if (!currentProject && projectList.length > 0) {
setProject(projectList[0].id);
}
}, [currentProject, projectList, setProject]);
return (
<header className={cn(
"h-16 shrink-0 border-b border-slate-200/80 bg-white/80 backdrop-blur",
"flex items-center gap-4 px-4 md:px-6",
className,
)}>
{/* Mobile brand */}
<div className="lg:hidden flex items-center gap-2 font-semibold text-slate-800">
<div className="w-8 h-8 rounded-md bg-gradient-to-br from-brand-500 to-accent text-white grid place-items-center text-sm font-bold">
A
</div>
ASF
</div>
<div className="flex-1 max-w-xl hidden md:block">
<button
type="button"
onClick={openAsf}
className="w-full group flex items-center gap-3 px-3 py-2 rounded-lg text-sm text-slate-500
bg-slate-50 hover:bg-slate-100 border border-slate-200 transition-colors text-left"
>
<Search className="w-4 h-4 text-slate-400 group-hover:text-slate-600" />
<span className="flex-1">Search artifacts, projects, requirements… or press <span className="font-medium text-slate-600">⌘ K</span></span>
<kbd className="asf-chip !text-[10px]">Search</kbd>
</button>
</div>
<div className="ml-auto flex items-center gap-2 md:gap-3">
<Dropdown
label="Organization"
size="sm"
icon={<Building2 className="w-4 h-4" />}
options={orgOptions}
value={currentOrg}
onChange={(v) => {
setOrg(v);
setProject(undefined);
}}
/>
<Dropdown
label="Project"
size="sm"
icon={<FolderKanban className="w-4 h-4" />}
options={projectOptions}
value={currentProject === undefined ? "__all__" : currentProject}
onChange={(v) => setProject(v === "__all__" ? undefined : v)}
/>
<Button variant="ghost" size="md" aria-label="Notifications">
<Bell className="w-4.5 h-4.5 w-[18px] h-[18px]" />
</Button>
<div className="h-9 w-9 rounded-full bg-slate-100 border border-slate-200 grid place-items-center text-slate-600">
<UserCircle className="w-5 h-5" />
</div>
</div>
</header>
);
}
+77
View File
@@ -0,0 +1,77 @@
"use client";
import React from "react";
import { cn } from "@/lib/utils";
export type BadgeTone =
| "neutral"
| "brand"
| "green"
| "amber"
| "red"
| "violet"
| "blue";
const TONES: Record<BadgeTone, string> = {
neutral: "bg-slate-100 text-slate-700 border-slate-200",
brand: "bg-brand-50 text-brand-700 border-brand-100",
green: "bg-emerald-50 text-emerald-700 border-emerald-100",
amber: "bg-amber-50 text-amber-700 border-amber-100",
red: "bg-red-50 text-red-700 border-red-100",
violet: "bg-violet-50 text-violet-700 border-violet-100",
blue: "bg-sky-50 text-sky-700 border-sky-100",
};
export function Badge({
tone = "neutral",
className,
children,
...rest
}: React.HTMLAttributes<HTMLSpanElement> & { tone?: BadgeTone }) {
return (
<span
className={cn(
"inline-flex items-center gap-1 text-[11px] font-medium px-2 py-0.5 rounded-full border",
TONES[tone],
className,
)}
{...rest}
>
{children}
</span>
);
}
export function JobStatusBadge({ status }: { status: string }) {
const map: Record<string, BadgeTone> = {
queued: "neutral",
running: "blue",
completed: "green",
failed: "red",
cancelled: "amber",
};
return <Badge tone={map[status] ?? "neutral"}>{status}</Badge>;
}
export function ArtifactTypeBadge({ type }: { type: string }) {
const map: Record<string, BadgeTone> = {
requirement: "brand",
epic: "violet",
story: "brand",
task: "blue",
architecture_decision: "violet",
document: "neutral",
source_file: "neutral",
commit: "green",
pull_request: "blue",
build: "amber",
test: "green",
security_finding: "red",
deployment: "green",
release: "green",
runtime_event: "amber",
incident: "red",
ai_analysis: "violet",
};
return <Badge tone={map[type] ?? "neutral"}>{type}</Badge>;
}
+51
View File
@@ -0,0 +1,51 @@
"use client";
import React from "react";
import { cn } from "@/lib/utils";
type Variant = "primary" | "secondary" | "ghost" | "danger";
type Size = "sm" | "md" | "lg";
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: Variant;
size?: Size;
as?: "button" | "a";
href?: string;
}
const variantClass: Record<Variant, string> = {
primary: "asf-btn-primary",
secondary: "asf-btn-secondary",
ghost: "asf-btn-ghost",
danger: "asf-btn bg-red-50 text-red-700 hover:bg-red-100 border-red-100",
};
const sizeClass: Record<Size, string> = {
sm: "text-xs px-2.5 py-1.5",
md: "text-sm px-3.5 py-2",
lg: "text-base px-5 py-2.5",
};
export function Button({
variant = "primary",
size = "md",
className,
as = "button",
href,
children,
...rest
}: ButtonProps) {
const cls = cn(variantClass[variant], sizeClass[size], className);
if (as === "a" && href) {
return (
<a href={href} className={cls} {...(rest as unknown as React.AnchorHTMLAttributes<HTMLAnchorElement>)}>
{children}
</a>
);
}
return (
<button className={cls} {...rest}>
{children}
</button>
);
}
+48
View File
@@ -0,0 +1,48 @@
"use client";
import React from "react";
import { cn } from "@/lib/utils";
export function Card({
className,
children,
...rest
}: React.HTMLAttributes<HTMLDivElement>) {
return <div className={cn("asf-card", className)} {...rest}>{children}</div>;
}
export function CardHeader({
className,
children,
...rest
}: React.HTMLAttributes<HTMLDivElement>) {
return <div className={cn("asf-card-header", className)} {...rest}>{children}</div>;
}
export function CardTitle({
className,
children,
...rest
}: React.HTMLAttributes<HTMLDivElement>) {
return <div className={cn("asf-card-title", className)} {...rest}>{children}</div>;
}
export function CardBody({
className,
children,
...rest
}: React.HTMLAttributes<HTMLDivElement>) {
return <div className={cn("asf-card-body", className)} {...rest}>{children}</div>;
}
export function CardFooter({
className,
children,
...rest
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div className={cn("px-5 py-3 border-t border-slate-100 text-xs text-slate-500 flex items-center justify-between", className)} {...rest}>
{children}
</div>
);
}
+106
View File
@@ -0,0 +1,106 @@
"use client";
import React, { useEffect, useRef, useState } from "react";
import { ChevronDown } from "lucide-react";
import { cn } from "@/lib/utils";
export interface Option<T = string> {
value: T;
label: React.ReactNode;
description?: string;
}
export function Dropdown<T extends string>({
options,
value,
onChange,
placeholder = "Select...",
className,
label,
size = "md",
tone = "secondary",
icon,
empty = "No options",
}: {
options: Option<T>[];
value?: T;
onChange: (v: T) => void;
placeholder?: string;
className?: string;
label?: string;
size?: "sm" | "md";
tone?: "secondary" | "ghost";
icon?: React.ReactNode;
empty?: string;
}) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
function h(e: MouseEvent) {
if (!ref.current) return;
if (!ref.current.contains(e.target as Node)) setOpen(false);
}
document.addEventListener("mousedown", h);
return () => document.removeEventListener("mousedown", h);
}, []);
const current = options.find((o) => o.value === value);
const btnClass = cn(
"inline-flex items-center gap-2 border rounded-lg transition-colors",
size === "sm" ? "text-xs px-2.5 py-1.5" : "text-sm px-3 py-2",
tone === "secondary"
? "bg-white border-slate-200 text-slate-700 hover:bg-slate-50"
: "bg-transparent border-transparent text-slate-700 hover:bg-slate-100",
);
return (
<div className={cn("relative inline-block text-left", className)} ref={ref}>
{label && <div className="text-[11px] uppercase tracking-wide text-slate-500 mb-1">{label}</div>}
<button
type="button"
className={btnClass}
onClick={() => setOpen((v) => !v)}
>
{icon && <span className="text-slate-500">{icon}</span>}
<span className={cn("truncate", !current && "text-slate-400")}>
{current?.label ?? placeholder}
</span>
<ChevronDown className={cn("w-4 h-4 text-slate-400 transition-transform", open && "rotate-180")} />
</button>
{open && (
<div className="absolute z-30 mt-1 right-0 min-w-[240px] rounded-xl bg-white border border-slate-200 shadow-soft overflow-hidden animate-fade-in">
{options.length === 0 ? (
<div className="px-4 py-6 text-sm text-slate-500 text-center">{empty}</div>
) : (
<ul className="max-h-80 overflow-auto py-1">
{options.map((o) => (
<li key={String(o.value)}>
<button
type="button"
className={cn(
"w-full text-left px-3 py-2 text-sm transition-colors",
value === o.value
? "bg-brand-50 text-brand-700 font-medium"
: "text-slate-700 hover:bg-slate-50",
)}
onClick={() => {
onChange(o.value);
setOpen(false);
}}
>
<div className="truncate">{o.label}</div>
{o.description && (
<div className="text-xs text-slate-500 mt-0.5 truncate">{o.description}</div>
)}
</button>
</li>
))}
</ul>
)}
</div>
)}
</div>
);
}
@@ -0,0 +1,43 @@
"use client";
import React from "react";
import { Inbox, ShieldCheck } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "./Button";
export function EmptyState({
title = "Nothing here yet",
description = "Create some data in ASF or wait a moment for it to load.",
icon: Icon = Inbox,
action,
actionLabel,
className,
}: {
title?: string;
description?: string;
icon?: React.ComponentType<{ className?: string }>;
action?: () => void;
actionLabel?: string;
className?: string;
}) {
return (
<div
className={cn(
"flex flex-col items-center justify-center py-14 px-6 text-center border border-dashed border-slate-200 rounded-xl bg-slate-50/60 animate-fade-in",
className,
)}
>
<div className="w-14 h-14 rounded-full bg-white border border-slate-200 grid place-items-center text-slate-500 shadow-sm mb-4">
<Icon className="w-6 h-6" />
</div>
<h3 className="text-base font-semibold text-slate-800 mb-1">{title}</h3>
<p className="text-sm text-slate-500 max-w-md mb-5">{description}</p>
{action && actionLabel && (
<Button variant="primary" size="sm" onClick={action}>
<ShieldCheck className="w-4 h-4" />
{actionLabel}
</Button>
)}
</div>
);
}
@@ -0,0 +1,111 @@
"use client";
import React from "react";
import { TrendingUp, TrendingDown } from "lucide-react";
import { cn } from "@/lib/utils";
import { Card, CardBody } from "./Card";
import { Badge } from "./Badge";
export function MetricCard({
label,
value,
delta,
tone = "neutral",
icon,
subtitle,
isPercentage,
className,
}: {
label: string;
value: number | string;
delta?: number; // percentage change, e.g. +5.2
tone?: "neutral" | "brand" | "green" | "red" | "amber" | "violet" | "blue";
icon?: React.ReactNode;
subtitle?: string;
isPercentage?: boolean;
className?: string;
}) {
const showValue = isPercentage
? `${Number(value).toFixed(1)}%`
: typeof value === "number"
? value.toLocaleString()
: value;
return (
<Card className={cn("relative overflow-hidden", className)}>
<CardBody className="flex items-start justify-between gap-4">
<div className="min-w-0">
<div className="text-xs font-medium text-slate-500 uppercase tracking-wide">{label}</div>
<div className="mt-1.5 text-2xl font-semibold text-slate-900 tabular-nums">
{showValue}
</div>
{subtitle !== undefined && (
<div className="mt-1 text-xs text-slate-500">{subtitle}</div>
)}
{delta !== undefined && (
<div className="mt-2 inline-flex items-center gap-1 text-xs">
{delta >= 0 ? (
<>
<TrendingUp className="w-3.5 h-3.5 text-emerald-600" />
<span className="text-emerald-700 font-medium">+{delta.toFixed(1)}%</span>
</>
) : (
<>
<TrendingDown className="w-3.5 h-3.5 text-red-600" />
<span className="text-red-700 font-medium">{delta.toFixed(1)}%</span>
</>
)}
<span className="text-slate-400 ml-1">vs last period</span>
</div>
)}
</div>
{icon && (
<div
className={cn(
"w-10 h-10 rounded-lg grid place-items-center shrink-0 border",
tone === "brand" && "bg-brand-50 border-brand-100 text-brand-600",
tone === "green" && "bg-emerald-50 border-emerald-100 text-emerald-600",
tone === "red" && "bg-red-50 border-red-100 text-red-600",
tone === "amber" && "bg-amber-50 border-amber-100 text-amber-600",
tone === "violet" && "bg-violet-50 border-violet-100 text-violet-600",
tone === "blue" && "bg-sky-50 border-sky-100 text-sky-600",
tone === "neutral" && "bg-slate-50 border-slate-200 text-slate-500",
)}
>
{icon}
</div>
)}
</CardBody>
</Card>
);
}
export function MetricPill({
label,
value,
tone = "neutral",
isPercentage,
}: {
label: string;
value: number | string;
tone?: "neutral" | "brand" | "green" | "red" | "amber" | "blue";
isPercentage?: boolean;
}) {
const shown = isPercentage ? `${Number(value).toFixed(1)}%` : value;
const toneMap: Record<typeof tone, string> = {
neutral: "bg-slate-100 text-slate-700",
brand: "bg-brand-50 text-brand-700",
blue: "bg-sky-50 text-sky-700",
green: "bg-emerald-50 text-emerald-700",
red: "bg-red-50 text-red-700",
amber: "bg-amber-50 text-amber-700",
};
return (
<div className="flex items-center gap-2">
<span className="text-xs text-slate-500">{label}</span>
<Badge tone={tone === "neutral" ? "neutral" : tone === "brand" ? "brand" : tone === "green" ? "green" : tone === "red" ? "red" : "amber"}>
<span className="tabular-nums font-semibold">{shown}</span>
</Badge>
</div>
);
}
@@ -0,0 +1,82 @@
"use client";
import React from "react";
import { cn } from "@/lib/utils";
/**
* Donut-style progress ring.
*
* Used in the Overview dashboard for things like implementation coverage,
* test coverage, project health score, traceability score.
*/
export function ProgressRing({
value,
size = 80,
stroke = 8,
tone = "brand",
label,
sublabel,
className,
}: {
value: number; // 0..100
size?: number;
stroke?: number;
tone?: "brand" | "green" | "amber" | "red" | "violet";
label?: string;
sublabel?: string;
className?: string;
}) {
const r = (size - stroke) / 2;
const c = 2 * Math.PI * r;
const clamped = Math.max(0, Math.min(100, Number(value) || 0));
const offset = c - (clamped / 100) * c;
const toneMap: Record<string, string> = {
brand: "text-brand-600",
green: "text-emerald-600",
amber: "text-amber-600",
red: "text-red-600",
violet: "text-violet-600",
};
return (
<div className={cn("flex items-center gap-4", className)}>
<div className="relative shrink-0" style={{ width: size, height: size }}>
<svg width={size} height={size} className={cn("-rotate-90", toneMap[tone])}>
<circle
cx={size / 2}
cy={size / 2}
r={r}
stroke="currentColor"
strokeOpacity={0.12}
fill="transparent"
strokeWidth={stroke}
/>
<circle
cx={size / 2}
cy={size / 2}
r={r}
stroke="currentColor"
fill="transparent"
strokeWidth={stroke}
strokeLinecap="round"
strokeDasharray={c}
strokeDashoffset={offset}
className="transition-all duration-500 ease-out"
/>
</svg>
<div className="absolute inset-0 grid place-items-center">
<div className="text-sm font-semibold tabular-nums text-slate-900">
{clamped.toFixed(0)}%
</div>
</div>
</div>
{(label || sublabel) && (
<div className="min-w-0">
{label && <div className="text-sm font-medium text-slate-800">{label}</div>}
{sublabel && <div className="text-xs text-slate-500 mt-0.5">{sublabel}</div>}
</div>
)}
</div>
);
}
+5
View File
@@ -0,0 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.
+18
View File
@@ -0,0 +1,18 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
transpilePackages: ["lucide-react"],
experimental: {
optimizePackageImports: ["lucide-react"],
},
async rewrites() {
return [
{
source: "/api/v1/:path*",
destination: `${process.env.NEXT_PUBLIC_ASF_CORE_URL || "http://localhost:8000"}/api/v1/:path*`,
},
];
},
};
module.exports = nextConfig;
+6909
View File
File diff suppressed because it is too large Load Diff
+35
View File
@@ -0,0 +1,35 @@
{
"name": "asf-web",
"version": "0.1.0",
"private": true,
"description": "Azurian Software Factory SaaS UI shell",
"scripts": {
"dev": "next dev --hostname 0.0.0.0 -p 3000",
"build": "next build",
"start": "next start --hostname 0.0.0.0 -p 3000",
"lint": "next lint",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@tanstack/react-query": "^5.56.0",
"clsx": "^2.1.1",
"lucide-react": "^0.445.0",
"next": "14.2.15",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"reactflow": "^11.11.4",
"tailwind-merge": "^2.5.2",
"zustand": "^4.5.5"
},
"devDependencies": {
"@types/node": "^22",
"@types/react": "^18",
"@types/react-dom": "^18",
"autoprefixer": "^10.4.20",
"eslint": "^8",
"eslint-config-next": "14.2.15",
"postcss": "^8.4.47",
"tailwindcss": "^3.4.13",
"typescript": "^5.6"
}
}
+6
View File
@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
+26
View File
@@ -0,0 +1,26 @@
"use client";
import { create } from "zustand";
export interface AppState {
org_id?: string;
project_id?: string;
asfOpen: boolean; // Ask ASF command / chat bar
setOrg: (org_id?: string) => void;
setProject: (project_id?: string) => void;
openAsf: () => void;
closeAsf: () => void;
toggleAsf: () => void;
}
export const useAppStore = create<AppState>((set) => ({
org_id: process.env.NEXT_PUBLIC_DEV_TENANT_ID,
project_id: undefined,
asfOpen: false,
setOrg: (org_id) => set({ org_id }),
setProject: (project_id) => set({ project_id }),
openAsf: () => set({ asfOpen: true }),
closeAsf: () => set({ asfOpen: false }),
toggleAsf: () => set((s) => ({ asfOpen: !s.asfOpen })),
}));
+69
View File
@@ -0,0 +1,69 @@
import type { Config } from "tailwindcss";
const config: Config = {
content: [
"./app/**/*.{js,ts,jsx,tsx,mdx}",
"./components/**/*.{js,ts,jsx,tsx,mdx}",
],
darkMode: "class",
theme: {
extend: {
colors: {
brand: {
50: "#f0f7ff",
100: "#e0efff",
200: "#bae0ff",
300: "#7cc7ff",
400: "#35a9ff",
500: "#0b8cff",
600: "#006fe0",
700: "#0057b4",
800: "#084a8f",
900: "#0d3f75",
950: "#08264c",
},
accent: {
DEFAULT: "#7c5cff",
muted: "#5b44c0",
},
surface: {
DEFAULT: "#ffffff",
dark: "#0b1220",
},
ink: {
DEFAULT: "#0f172a",
muted: "#64748b",
},
border: "#e2e8f0",
},
fontFamily: {
sans: [
"Inter",
"ui-sans-serif",
"system-ui",
"-apple-system",
"Segoe UI",
"Roboto",
"sans-serif",
],
mono: ["JetBrains Mono", "Menlo", "ui-monospace", "monospace"],
},
boxShadow: {
soft: "0 2px 10px -2px rgba(15,23,42,0.06), 0 1px 3px rgba(15,23,42,0.04)",
card: "0 1px 2px rgba(15,23,42,0.04), 0 4px 20px -8px rgba(15,23,42,0.08)",
},
keyframes: {
"fade-in": {
"0%": { opacity: "0", transform: "translateY(4px)" },
"100%": { opacity: "1", transform: "translateY(0)" },
},
},
animation: {
"fade-in": "fade-in 180ms ease-out both",
},
},
},
plugins: [],
};
export default config;
+34
View File
@@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [{ "name": "next" }],
"baseUrl": ".",
"paths": {
"@/*": ["./*"],
"@/app/*": ["./app/*"],
"@/components/*": ["./components/*"],
"@/lib/*": ["./lib/*"],
"@/stores/*": ["./stores/*"],
"@/types/*": ["./types/*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts"
],
"exclude": ["node_modules"]
}
File diff suppressed because one or more lines are too long
+33
View File
@@ -0,0 +1,33 @@
FROM python:3.12-slim AS base
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1 \
POETRY_VIRTUALENVS_CREATE=false
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl build-essential \
&& rm -rf /var/lib/apt/lists/*
COPY packages/asf-domain /packages/asf-domain
COPY packages/asf-events /packages/asf-events
COPY packages/asf-connectors /packages/asf-connectors
RUN pip install --no-cache-dir \
-e /packages/asf-domain \
-e /packages/asf-events \
-e /packages/asf-connectors
COPY services/asf-core/requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
COPY services/asf-core/app ./app
EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=10s --start-period=15s --retries=3 \
CMD curl -fsS http://localhost:8000/api/v1/health || exit 1
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
+30
View File
@@ -0,0 +1,30 @@
FROM node:20-alpine AS deps
WORKDIR /app
COPY frontend/asf-web/package.json frontend/asf-web/package-lock.json* ./
RUN npm ci
FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY frontend/asf-web/ .
ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
RUN addgroup --system --gid 1001 nodejs \
&& adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next ./.next
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./package.json
USER nextjs
EXPOSE 3000
ENV PORT=3000
CMD ["npm", "start"]
@@ -0,0 +1,27 @@
FROM python:3.12-slim AS base
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl build-essential \
&& rm -rf /var/lib/apt/lists/*
COPY packages/asf-domain /packages/asf-domain
COPY packages/asf-events /packages/asf-events
COPY packages/asf-connectors /packages/asf-connectors
RUN pip install --no-cache-dir \
-e /packages/asf-domain \
-e /packages/asf-events \
-e /packages/asf-connectors
COPY services/asf-worker/requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
COPY services/asf-worker/app ./app
CMD ["python", "-m", "app.worker"]
@@ -0,0 +1,27 @@
"""asf-connectors: Protocol-based interfaces for every external system ASF
can integrate with.
Concrete implementations (GitHub adapter, Jira adapter, ...) are provided
separately, often in their own installable extras. Business code MUST
depend only on the Protocols defined here. This keeps ASF Core decoupled
from any single provider.
"""
__version__ = "0.1.0"
from asf_connectors.git import GitConnector, PullRequestInfo, CommitInfo
from asf_connectors.issues import IssueTrackerConnector, IssueInfo
from asf_connectors.ci import CICDConnector, BuildInfo, DeploymentInfo
from asf_connectors.observability import ObservabilityConnector
__all__ = [
"GitConnector",
"PullRequestInfo",
"CommitInfo",
"IssueTrackerConnector",
"IssueInfo",
"CICDConnector",
"BuildInfo",
"DeploymentInfo",
"ObservabilityConnector",
]
@@ -0,0 +1,42 @@
"""Abstract CI/CD connector interface."""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
from typing import Protocol, Optional, Sequence
@dataclass(frozen=True)
class BuildInfo:
build_id: str
pipeline_name: str
status: str # success | failed | running | cancelled
started_at: Optional[datetime] = None
finished_at: Optional[datetime] = None
triggered_by: Optional[str] = None
branch: Optional[str] = None
commit_sha: Optional[str] = None
url: Optional[str] = None
@dataclass(frozen=True)
class DeploymentInfo:
deployment_id: str
environment: str # dev | staging | prod | ...
status: str # success | failed | in_progress
version_ref: Optional[str] = None
started_at: Optional[datetime] = None
finished_at: Optional[datetime] = None
target: Optional[str] = None
url: Optional[str] = None
class CICDConnector(Protocol):
"""Jenkins / GitLab CI / GitHub Actions / ArgoCD / ..."""
async def get_build(self, pipeline: str, build_id: str) -> Optional[BuildInfo]: ...
async def list_builds(self, pipeline: str, limit: int = 50) -> Sequence[BuildInfo]: ...
async def get_deployment(self, deployment_id: str) -> Optional[DeploymentInfo]: ...
async def list_deployments(self, environment: str, limit: int = 50) -> Sequence[DeploymentInfo]: ...
@@ -0,0 +1,57 @@
"""Abstract Git (repository / source-control) connector interface."""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
from typing import Protocol, AsyncIterator, Optional, Sequence
@dataclass(frozen=True)
class CommitInfo:
sha: str
message: str
author_email: str
author_name: str
authored_at: datetime
branch: Optional[str] = None
files_changed: Sequence[str] = ()
@dataclass(frozen=True)
class PullRequestInfo:
number: int
title: str
state: str # open | closed | merged
branch: str
target_branch: str
author: str
created_at: datetime
merged_at: Optional[datetime] = None
description: Optional[str] = None
commit_shas: Sequence[str] = ()
labels: Sequence[str] = ()
class GitConnector(Protocol):
"""Any Git-hosting provider: GitHub, GitLab, Bitbucket, local Gitea..."""
async def list_commits(
self, repo_ref: str, *, branch: Optional[str] = None, since: Optional[datetime] = None
) -> AsyncIterator[CommitInfo]: ...
async def get_commit(self, repo_ref: str, sha: str) -> Optional[CommitInfo]: ...
async def list_pull_requests(
self, repo_ref: str, *, state: str = "open"
) -> AsyncIterator[PullRequestInfo]: ...
async def get_pull_request(self, repo_ref: str, number: int) -> Optional[PullRequestInfo]: ...
async def index_repository(self, repo_ref: str) -> None:
"""Trigger a full sync (artifacts + relationships) for a repo.
Implementation must emit events; it must NOT mutate Core data
directly.
"""
...
@@ -0,0 +1,34 @@
"""Abstract issue / requirement tracker connector (Jira, Linear, etc.)."""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
from typing import Protocol, AsyncIterator, Optional, Any, Sequence
@dataclass(frozen=True)
class IssueInfo:
key: str
title: str
type: str # epic, story, bug, task, ...
status: str
description: Optional[str] = None
assignee: Optional[str] = None
labels: Sequence[str] = ()
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
parent_key: Optional[str] = None
metadata: dict[str, Any] = None # type: ignore[assignment]
class IssueTrackerConnector(Protocol):
"""Provider interface for Jira / GitHub Issues / YouTrack / ..."""
async def search_issues(
self, query: str, *, project_ref: Optional[str] = None, limit: int = 100
) -> AsyncIterator[IssueInfo]: ...
async def get_issue(self, issue_key: str) -> Optional[IssueInfo]: ...
async def sync_project(self, project_ref: str) -> None: ...
@@ -0,0 +1,20 @@
"""Abstract observability connector (Grafana / Prometheus / Datadog / ...)."""
from __future__ import annotations
from datetime import datetime
from typing import Protocol, Optional, Any
class ObservabilityConnector(Protocol):
async def query_metric(
self,
query: str,
start: datetime,
end: datetime,
step: Optional[str] = None,
) -> list[dict[str, Any]]: ...
async def get_incident(self, incident_id: str) -> Optional[dict[str, Any]]: ...
async def list_active_incidents(self, limit: int = 50) -> list[dict[str, Any]]: ...
+20
View File
@@ -0,0 +1,20 @@
[build-system]
requires = ["setuptools>=68", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "asf-connectors"
version = "0.1.0"
description = "ASF external-system connector interfaces (Protocol-based, no concrete adapters yet)."
requires-python = ">=3.12"
dependencies = [
"asf-domain>=0.1.0",
"asf-events>=0.1.0",
"pydantic>=2.7,<3",
]
[project.optional-dependencies]
dev = ["pytest>=8"]
[tool.setuptools.packages.find]
include = ["asf_connectors*"]
+22
View File
@@ -0,0 +1,22 @@
# asf-domain — Shared Pydantic Domain Models
> Package condiviso: *tutti* i servizi Python importano i modelli da qui.
> Non copiare-incollare modelli: se cambi un modello qui, cambia per tutti.
Install (local dev):
```bash
cd packages/asf-domain
pip install -e .
```
Provides:
- `asf_domain.base` — UUID ULID factory, tenant-aware base model, enums.
- `asf_domain.identity` — Organization, User, Team, Project.
- `asf_domain.artifact` — Artifact, ArtifactRelationship (the core "Everything is an Artifact" model).
- `asf_domain.job` — Job lifecycle & usage tracking.
- `asf_domain.audit` — AuditEvent.
- `asf_domain.integration` — Integration.
- `asf_domain.ai_execution` — AIExecution record.
- `asf_domain.common` — Typed aliases, shared types.
@@ -0,0 +1,86 @@
"""asf-domain: shared Pydantic models for Azurian Software Factory.
Every Python service in ASF imports its domain models from this package.
"""
__version__ = "0.1.0"
from asf_domain.base import (
ASFBaseModel,
TenantScopedModel,
generate_id,
UTCDatetime,
ArtifactType,
RelationshipType,
JobStatus,
JobPriority,
JobType,
AuditAction,
)
from asf_domain.identity import (
Organization,
OrganizationCreate,
OrganizationUpdate,
Project,
ProjectCreate,
ProjectUpdate,
Team,
User,
)
from asf_domain.artifact import (
Artifact,
ArtifactCreate,
ArtifactUpdate,
ArtifactRelationship,
ArtifactRelationshipCreate,
RelationshipDirection,
)
from asf_domain.job import (
Job,
JobCreate,
JobUpdate,
JobUsage,
)
from asf_domain.ai_execution import (
AIExecution,
AIExecutionCreate,
)
from asf_domain.integration import Integration, IntegrationCreate
from asf_domain.audit import AuditEvent, AuditEventCreate
__all__ = [
"ASFBaseModel",
"TenantScopedModel",
"generate_id",
"UTCDatetime",
"ArtifactType",
"RelationshipType",
"JobStatus",
"JobPriority",
"JobType",
"AuditAction",
"Organization",
"OrganizationCreate",
"OrganizationUpdate",
"Project",
"ProjectCreate",
"ProjectUpdate",
"Team",
"User",
"Artifact",
"ArtifactCreate",
"ArtifactUpdate",
"ArtifactRelationship",
"ArtifactRelationshipCreate",
"RelationshipDirection",
"Job",
"JobCreate",
"JobUpdate",
"JobUsage",
"AIExecution",
"AIExecutionCreate",
"Integration",
"IntegrationCreate",
"AuditEvent",
"AuditEventCreate",
]
@@ -0,0 +1,68 @@
"""AIExecution — immutable record of a single inference call.
Stored after every real (or mock) AI invocation so that:
- usage/cost is attributable to a tenant + project + job
- inputs/outputs can be audited / replayed
- the AI Gateway can train / evaluate routing policies later
"""
from __future__ import annotations
from typing import Any, Optional, Dict
from pydantic import Field
from asf_domain.base import (
ASFBaseModel,
TenantScopedModel,
ASFFieldID,
UTCDatetime,
generate_id,
)
class AIExecutionBase(ASFBaseModel):
capability: str = Field(
...,
max_length=128,
description="ASF capability requested (e.g. chat, analyze_code, generate_tests).",
)
provider: str = Field(
..., max_length=64, description="Adapter used (e.g. mock, openai, nvidia, local)."
)
model: str = Field(
..., max_length=128, description="Concrete model identifier reported by the adapter."
)
success: bool = True
class AIExecutionCreate(AIExecutionBase):
job_id: Optional[ASFFieldID] = None
project_id: Optional[ASFFieldID] = None
input_ref: Optional[str] = Field(
None, max_length=512,
description="MinIO ref or short fingerprint of the input payload (never raw secrets).",
)
output_ref: Optional[str] = Field(
None, max_length=512, description="MinIO ref / fingerprint of the response."
)
input_tokens: Optional[int] = Field(None, ge=0)
output_tokens: Optional[int] = Field(None, ge=0)
duration_ms: Optional[int] = Field(None, ge=0)
estimated_cost: Optional[float] = Field(None, ge=0)
error_message: Optional[str] = Field(None, max_length=2000)
metadata: Dict[str, Any] = Field(default_factory=dict)
class AIExecution(TenantScopedModel, AIExecutionBase):
id: ASFFieldID = Field(default_factory=generate_id)
job_id: Optional[ASFFieldID] = None
project_id: Optional[ASFFieldID] = None
input_ref: Optional[str] = None
output_ref: Optional[str] = None
input_tokens: Optional[int] = None
output_tokens: Optional[int] = None
duration_ms: Optional[int] = None
estimated_cost: Optional[float] = None
error_message: Optional[str] = None
metadata: Dict[str, Any] = Field(default_factory=dict)
created_at: UTCDatetime = ...
+154
View File
@@ -0,0 +1,154 @@
"""Artifact — the heart of the "Everything is an Artifact" principle.
One single generic model captures every lifecycle entity (requirement,
task, commit, test, deployment, incident, ...). Artifact subtypes are
expressed via the ``type`` enum / string and stored in the same MongoDB
``artifacts`` collection.
Relationships between artifacts live in ``artifact_relationships``
collection and are modelled as explicit directed edges — this keeps the
option open to project the same data onto a graph database later without
rewriting domain logic.
"""
from __future__ import annotations
from typing import Any, Optional, List, Dict
from enum import Enum
from pydantic import Field
from asf_domain.base import (
ASFBaseModel,
TenantScopedModel,
ASFFieldID,
UTCDatetime,
ArtifactType,
RelationshipType,
generate_id,
)
# ======================================================================
# Artifact itself
# ======================================================================
class ArtifactBase(ASFBaseModel):
type: ArtifactType = Field(
..., description="Kind of artifact (requirement | task | commit | …)"
)
external_id: Optional[str] = Field(
None,
max_length=255,
description="Optional identifier in an external system "
"(e.g. GitHub PR id, Jira issue key)",
)
title: str = Field(..., min_length=1, max_length=500)
content: Optional[str] = Field(
None,
description="Primary human readable body (markdown, description, diff, log body, …). "
"Large content should live in MinIO; store a reference here or in metadata.",
)
source: Optional[str] = Field(
None,
max_length=255,
description="Origin system: jira, github, manual, ai, …",
)
status: Optional[str] = Field(
None, max_length=80, description="Status (e.g. open, done, failed, released)."
)
class ArtifactCreate(ArtifactBase):
"""Create payload. ``tenant_id`` and ``project_id`` are set server-side."""
project_id: ASFFieldID = Field(
..., description="Owning project (validated against tenant context server side)."
)
metadata: Dict[str, Any] = Field(
default_factory=dict,
description="Arbitrary extensible fields. Use this for artifact subtype "
"specific attributes (e.g. commit.sha, test.outcome, deployment.env).",
)
class ArtifactUpdate(ASFBaseModel):
title: Optional[str] = Field(None, min_length=1, max_length=500)
content: Optional[str] = None
type: Optional[ArtifactType] = None
external_id: Optional[str] = None
source: Optional[str] = None
status: Optional[str] = None
metadata: Optional[Dict[str, Any]] = Field(
None,
description="When provided, performs a shallow merge with existing metadata.",
)
class Artifact(TenantScopedModel, ArtifactBase):
"""Stored Artifact record.
``relationships`` is a denormalised, always-stale list kept for fast
reads in the UI. The authoritative source of truth for edges is the
``artifact_relationships`` collection.
"""
id: ASFFieldID = Field(default_factory=generate_id)
project_id: ASFFieldID = ...
version: int = Field(
1, ge=1, description="Monotonic version bumped on every update."
)
metadata: Dict[str, Any] = Field(default_factory=dict)
created_at: UTCDatetime = ...
updated_at: UTCDatetime = ...
created_by: Optional[ASFFieldID] = Field(
None, description="User id that created this artifact (null = system / import)."
)
# ======================================================================
# ArtifactRelationship — the explicit edge
# ======================================================================
class RelationshipDirection(str, Enum):
FORWARD = "forward"
REVERSE = "reverse"
class ArtifactRelationshipBase(ASFBaseModel):
relationship_type: RelationshipType = Field(
...,
description="Semantic predicate: ``source <relationship_type> target``.",
)
source_artifact_id: ASFFieldID = Field(
..., description="Source artifact id (subject)."
)
target_artifact_id: ASFFieldID = Field(
..., description="Target artifact id (object)."
)
metadata: Dict[str, Any] = Field(
default_factory=dict,
description="Free-form metadata on the edge (confidence, evidence ref, …).",
)
class ArtifactRelationshipCreate(ArtifactRelationshipBase):
"""Create payload. tenant_id / project_ids are validated server side."""
pass
class ArtifactRelationship(TenantScopedModel, ArtifactRelationshipBase):
"""Stored edge between two artifacts.
Invariant: both source and target MUST belong to the same tenant.
They MAY live in different projects inside that tenant when an
explicit cross-project reference is intentional.
"""
id: ASFFieldID = Field(default_factory=generate_id)
source_project_id: ASFFieldID = ...
target_project_id: ASFFieldID = ...
created_at: UTCDatetime = ...
updated_at: UTCDatetime = ...
+62
View File
@@ -0,0 +1,62 @@
"""AuditEvent — append-only security & business audit trail.
Audit events are never mutated or deleted. They record WHO did WHAT to
WHICH entity WHEN and from WHERE.
"""
from __future__ import annotations
from typing import Any, Optional, Dict
from pydantic import Field
from asf_domain.base import (
ASFBaseModel,
TenantScopedModel,
ASFFieldID,
UTCDatetime,
AuditAction,
generate_id,
)
class AuditEventBase(ASFBaseModel):
action: AuditAction = Field(..., description="What happened.")
resource_type: str = Field(
...,
max_length=80,
description="Affected entity kind (organization, project, artifact, job, api_key, …).",
)
resource_id: Optional[ASFFieldID] = Field(None, description="Affected entity id if applicable.")
actor_id: Optional[ASFFieldID] = Field(
None, description="User id that caused the event, or null for system actions."
)
actor_type: str = Field(
"user",
max_length=40,
description="user | system | api_key | service — kind of actor that caused the event.",
)
success: bool = True
reason: Optional[str] = Field(
None, max_length=500, description="For failed events, why the action was rejected."
)
class AuditEventCreate(AuditEventBase):
project_id: Optional[ASFFieldID] = None
ip_address: Optional[str] = Field(None, max_length=64)
user_agent: Optional[str] = Field(None, max_length=512)
correlation_id: Optional[str] = Field(None, max_length=128)
metadata: Dict[str, Any] = Field(
default_factory=dict,
description="Structured detail of the change. NEVER log secrets here.",
)
class AuditEvent(TenantScopedModel, AuditEventBase):
id: ASFFieldID = Field(default_factory=generate_id)
project_id: Optional[ASFFieldID] = None
ip_address: Optional[str] = None
user_agent: Optional[str] = None
correlation_id: Optional[str] = None
metadata: Dict[str, Any] = Field(default_factory=dict)
created_at: UTCDatetime = ...
+184
View File
@@ -0,0 +1,184 @@
"""Base types and enums for all ASF domain models."""
from __future__ import annotations
import enum
from datetime import datetime, timezone
from typing import Any, Annotated
from uuid import UUID, uuid4
from pydantic import (
BaseModel,
ConfigDict,
Field,
field_validator,
PlainSerializer,
)
# ---------------------------------------------------------------------------
# Identifiers
# ---------------------------------------------------------------------------
ASFFieldID = Annotated[
UUID,
Field(
description="ASF unique identifier (UUID v4)",
examples=["3fa85f64-5717-4562-b3fc-2c963f66afa6"],
),
]
def generate_id() -> UUID:
"""Return a new UUID v4 identifier for an ASF entity."""
return uuid4()
# ---------------------------------------------------------------------------
# Datetime helpers — force UTC on the wire
# ---------------------------------------------------------------------------
def _utc_serialize(dt: datetime) -> str:
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
UTCDatetime = Annotated[
datetime,
PlainSerializer(_utc_serialize, return_type=str, when_used="json"),
]
# ---------------------------------------------------------------------------
# Base model configuration
# ---------------------------------------------------------------------------
class ASFBaseModel(BaseModel):
"""Base Pydantic model used everywhere in ASF."""
model_config = ConfigDict(
extra="forbid",
populate_by_name=True,
use_enum_values=False,
from_attributes=True,
str_strip_whitespace=True,
json_encoders={datetime: _utc_serialize},
)
class TenantScopedModel(ASFBaseModel):
"""Any record that belongs to a tenant (Organization)."""
tenant_id: ASFFieldID = Field(
description="Owning tenant (Organization) identifier. "
"Always validated against the authenticated context — never "
"trust the value supplied by an untrusted client."
)
# ---------------------------------------------------------------------------
# Artifact type universe (extensible, open)
# ---------------------------------------------------------------------------
class ArtifactType(str, enum.Enum):
"""Canonical set of Artifact subtypes in the initial bootstrap.
This list is intentionally extensible: adding a new kind of lifecycle
entity is a new ArtifactType value, NOT a new MongoDB collection.
"""
REQUIREMENT = "requirement"
EPIC = "epic"
STORY = "story"
TASK = "task"
ARCHITECTURE_DECISION = "architecture_decision"
DOCUMENT = "document"
SOURCE_FILE = "source_file"
COMMIT = "commit"
PULL_REQUEST = "pull_request"
BUILD = "build"
TEST = "test"
SECURITY_FINDING = "security_finding"
DEPLOYMENT = "deployment"
RELEASE = "release"
RUNTIME_EVENT = "runtime_event"
INCIDENT = "incident"
AI_ANALYSIS = "ai_analysis"
# ---------------------------------------------------------------------------
# Relationship types between artifacts
# ---------------------------------------------------------------------------
class RelationshipType(str, enum.Enum):
"""Canonical relationship predicates between two Artifacts.
Semantics: ``source <predicate> target``, e.g.
``REQ-1 implements TASK-7`` means TASK-7 implements REQ-1.
For the inverse use IMPLEMENTED_BY.
"""
IMPLEMENTS = "implements"
IMPLEMENTED_BY = "implemented_by"
VERIFIES = "verifies"
VERIFIED_BY = "verified_by"
GENERATED_FROM = "generated_from"
DOCUMENTED_BY = "documented_by"
DEPENDS_ON = "depends_on"
AFFECTS = "affects"
DEPLOYED_AS = "deployed_as"
CAUSED_BY = "caused_by"
SUPERSEDES = "supersedes"
RELATED_TO = "related_to"
# ---------------------------------------------------------------------------
# Job lifecycle
# ---------------------------------------------------------------------------
class JobStatus(str, enum.Enum):
QUEUED = "queued"
RUNNING = "running"
COMPLETED = "completed"
FAILED = "failed"
CANCELLED = "cancelled"
class JobPriority(str, enum.Enum):
LOW = "low"
NORMAL = "normal"
HIGH = "high"
URGENT = "urgent"
class JobType(str, enum.Enum):
AI_INTERACTIVE = "ai_interactive"
AI_BACKGROUND = "ai_background"
INDEXING = "indexing"
CODE_ANALYSIS = "code_analysis"
TESTING = "testing"
DEVOPS = "devops"
NOTIFICATION = "notification"
CUSTOM = "custom"
# ---------------------------------------------------------------------------
# Audit
# ---------------------------------------------------------------------------
class AuditAction(str, enum.Enum):
CREATED = "created"
UPDATED = "updated"
DELETED = "deleted"
ACCESSED = "accessed"
LOGGED_IN = "logged_in"
LOGGED_OUT = "logged_out"
PERMISSION_CHANGED = "permission_changed"
API_KEY_CREATED = "api_key_created"
API_KEY_REVOKED = "api_key_revoked"
+15
View File
@@ -0,0 +1,15 @@
"""Small shared type aliases & tiny helpers.
Put things here when they are used by multiple domain modules but don't
have a home of their own.
"""
from __future__ import annotations
from typing import TypeAlias, Any, Dict, Awaitable, Callable
JSONDict: TypeAlias = Dict[str, Any]
"""Any loosely-typed JSON object (metadata, unstructured payloads, …)."""
AsyncHandler: TypeAlias = Callable[..., Awaitable[Any]]
"""Generic async callable type alias."""
+157
View File
@@ -0,0 +1,157 @@
"""Identity domain: Organization, User, Team, Project.
SaaS tenancy:
Organization == tenant boundary
└── Teams
└── Projects
└── Artifacts + Relationships + Jobs
"""
from __future__ import annotations
from typing import Optional, List
from pydantic import Field, field_validator
from asf_domain.base import (
ASFBaseModel,
TenantScopedModel,
ASFFieldID,
UTCDatetime,
generate_id,
)
# ======================================================================
# Organization (tenant)
# ======================================================================
class OrganizationBase(ASFBaseModel):
name: str = Field(
...,
min_length=2,
max_length=120,
description="Human readable organization / tenant name",
examples=["Azurian Labs", "Contoso Retail"],
)
slug: str = Field(
...,
min_length=3,
max_length=60,
pattern=r"^[a-z0-9][a-z0-9\-]{1,58}[a-z0-9]$",
description="URL-safe unique identifier for the tenant",
examples=["azurian-labs"],
)
plan: str = Field(
"free",
description="Subscription tier placeholder",
examples=["free", "team", "enterprise"],
)
is_active: bool = True
class OrganizationCreate(OrganizationBase):
"""Payload used to create a new Organization (tenant)."""
pass
class OrganizationUpdate(ASFBaseModel):
name: Optional[str] = Field(None, min_length=2, max_length=120)
slug: Optional[str] = Field(None, min_length=3, max_length=60)
plan: Optional[str] = None
is_active: Optional[bool] = None
class Organization(OrganizationBase):
"""Stored Organization record — also the tenant boundary record."""
id: ASFFieldID = Field(default_factory=generate_id)
created_at: UTCDatetime = Field(
..., description="Creation timestamp (UTC, written on insert)"
)
updated_at: UTCDatetime = Field(
..., description="Last update timestamp (UTC, written on every write)"
)
metadata: dict = Field(
default_factory=dict,
description="Tenant-level extensible metadata (billing id, features, …)",
)
# ======================================================================
# User (bootstrap skeleton; real auth lives outside domain)
# ======================================================================
class User(TenantScopedModel):
"""Minimal User record used for ownership / audit references."""
id: ASFFieldID = Field(default_factory=generate_id)
email: str = Field(..., max_length=255)
display_name: str = Field(..., max_length=120)
roles: List[str] = Field(default_factory=lambda: ["member"])
is_active: bool = True
created_at: UTCDatetime = ...
updated_at: UTCDatetime = ...
@field_validator("email")
@classmethod
def _lower_email(cls, v: str) -> str:
return v.strip().lower()
# ======================================================================
# Team
# ======================================================================
class Team(TenantScopedModel):
id: ASFFieldID = Field(default_factory=generate_id)
name: str = Field(..., min_length=2, max_length=80)
description: Optional[str] = Field(None, max_length=500)
member_ids: List[ASFFieldID] = Field(default_factory=list)
created_at: UTCDatetime = ...
updated_at: UTCDatetime = ...
# ======================================================================
# Project
# ======================================================================
class ProjectBase(ASFBaseModel):
name: str = Field(..., min_length=2, max_length=120)
key: str = Field(
...,
min_length=2,
max_length=20,
pattern=r"^[A-Z][A-Z0-9]{1,19}$",
description="Short project key (e.g. ASF, PROJ) used in human readable refs",
examples=["ASF", "PRJ"],
)
description: Optional[str] = Field(None, max_length=1000)
default_branch: str = Field("main", max_length=100)
is_active: bool = True
class ProjectCreate(ProjectBase):
"""Create payload; tenant_id is injected server side from auth context."""
pass
class ProjectUpdate(ASFBaseModel):
name: Optional[str] = Field(None, min_length=2, max_length=120)
key: Optional[str] = Field(None, min_length=2, max_length=20)
description: Optional[str] = Field(None, max_length=1000)
default_branch: Optional[str] = None
is_active: Optional[bool] = None
class Project(TenantScopedModel, ProjectBase):
"""Stored Project record. Lives inside a tenant."""
id: ASFFieldID = Field(default_factory=generate_id)
team_ids: List[ASFFieldID] = Field(default_factory=list)
created_at: UTCDatetime = ...
updated_at: UTCDatetime = ...
metadata: dict = Field(default_factory=dict)
@@ -0,0 +1,62 @@
"""Integration — registration of a connector bound to a tenant.
The connector itself lives in ``asf-connectors``; this record stores:
* which connector type is in use
* tenant-scoped configuration references (secrets live elsewhere)
* last health state
"""
from __future__ import annotations
from typing import Any, Optional, Dict
from enum import Enum
from pydantic import Field
from asf_domain.base import (
ASFBaseModel,
TenantScopedModel,
ASFFieldID,
UTCDatetime,
generate_id,
)
class ConnectorType(str, Enum):
GITHUB = "github"
GITLAB = "gitlab"
BITBUCKET = "bitbucket"
JIRA = "jira"
CONFLUENCE = "confluence"
NOTION = "notion"
JENKINS = "jenkins"
SONARQUBE = "sonarqube"
GRAFANA = "grafana"
GENERIC_WEBHOOK = "generic_webhook"
CUSTOM = "custom"
class IntegrationBase(ASFBaseModel):
name: str = Field(..., min_length=2, max_length=80)
connector: ConnectorType = ConnectorType.CUSTOM
is_enabled: bool = True
class IntegrationCreate(IntegrationBase):
config_ref: Optional[str] = Field(
None,
max_length=512,
description="Reference to a secure store (e.g. vault path) holding connector secrets. "
"NEVER inline credentials here.",
)
metadata: Dict[str, Any] = Field(default_factory=dict)
class Integration(TenantScopedModel, IntegrationBase):
id: ASFFieldID = Field(default_factory=generate_id)
config_ref: Optional[str] = None
metadata: Dict[str, Any] = Field(default_factory=dict)
last_health_at: Optional[UTCDatetime] = None
last_health_status: Optional[str] = Field(None, max_length=40)
created_at: UTCDatetime = ...
updated_at: UTCDatetime = ...
+106
View File
@@ -0,0 +1,106 @@
"""Job model — persistent asynchronous job state.
RabbitMQ delivers work messages. MongoDB is the source of truth for job
lifecycle. Worker processes update Job records as they progress through
queued → running → completed|failed|cancelled.
Do not use RabbitMQ queues as the permanent job database.
"""
from __future__ import annotations
from typing import Any, Optional, Dict
from pydantic import Field
from asf_domain.base import (
ASFBaseModel,
TenantScopedModel,
ASFFieldID,
UTCDatetime,
JobStatus,
JobPriority,
JobType,
generate_id,
)
class JobUsage(ASFBaseModel):
"""Optional resource / cost usage tracked for a job."""
input_tokens: Optional[int] = Field(None, ge=0)
output_tokens: Optional[int] = Field(None, ge=0)
gpu_seconds: Optional[float] = Field(None, ge=0)
cpu_seconds: Optional[float] = Field(None, ge=0)
estimated_cost: Optional[float] = Field(
None,
ge=0,
description="Estimated cost in tenant currency (EUR/USD). Approximate, used for metering only.",
)
extra: Dict[str, Any] = Field(default_factory=dict)
class JobBase(ASFBaseModel):
type: JobType = Field(
...,
description="Logical job class. Used for routing to the correct RabbitMQ queue.",
)
priority: JobPriority = JobPriority.NORMAL
payload: Dict[str, Any] = Field(
default_factory=dict,
description="The actual work payload (e.g. {artifact_id, prompt, model_preference}).",
)
correlation_id: Optional[str] = Field(
None,
max_length=128,
description="Optional correlation id propagated across logs, events, child jobs.",
)
class JobCreate(JobBase):
"""Create payload. tenant_id/project_id/requested_by are injected server-side."""
project_id: Optional[ASFFieldID] = None
model: Optional[str] = Field(
None,
max_length=128,
description="Preferred model / engine, only meaningful for AI job types. "
"Ultimate routing decision belongs to the AI Gateway.",
)
class JobUpdate(ASFBaseModel):
"""Updates issued by workers as the job progresses."""
status: Optional[JobStatus] = None
worker: Optional[str] = Field(
None, max_length=255, description="Identifier of the worker process claiming this job."
)
started_at: Optional[UTCDatetime] = None
completed_at: Optional[UTCDatetime] = None
result: Optional[Dict[str, Any]] = None
error: Optional[str] = Field(None, max_length=2000)
error_detail: Optional[Dict[str, Any]] = None
usage: Optional[JobUsage] = None
progress_percent: Optional[int] = Field(None, ge=0, le=100)
class Job(TenantScopedModel, JobBase):
"""Persistent job record — the authoritative source of truth."""
id: ASFFieldID = Field(default_factory=generate_id)
project_id: Optional[ASFFieldID] = None
status: JobStatus = JobStatus.QUEUED
requested_by: Optional[ASFFieldID] = Field(
None, description="User that requested the job, or null for system jobs."
)
worker: Optional[str] = None
model: Optional[str] = None
created_at: UTCDatetime = ...
started_at: Optional[UTCDatetime] = None
completed_at: Optional[UTCDatetime] = None
result: Optional[Dict[str, Any]] = None
error: Optional[str] = None
error_detail: Optional[Dict[str, Any]] = None
usage: JobUsage = Field(default_factory=JobUsage)
progress_percent: Optional[int] = Field(None, ge=0, le=100)
retries: int = Field(0, ge=0, description="How many times this job has been retried.")
+28
View File
@@ -0,0 +1,28 @@
[build-system]
requires = ["setuptools>=68", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "asf-domain"
version = "0.1.0"
description = "ASF shared domain models (Pydantic v2)"
readme = "README.md"
requires-python = ">=3.12"
authors = [{ name = "Azurian Software Factory Team" }]
license = { text = "Proprietary" }
dependencies = [
"pydantic>=2.7,<3",
"pydantic-settings>=2.2,<3",
"python-ulid>=2.7,<3",
"typing_extensions>=4.11",
]
[project.optional-dependencies]
dev = [
"pytest>=8",
"pytest-asyncio>=0.23",
]
[tool.setuptools.packages.find]
where = ["."]
include = ["asf_domain*"]
@@ -0,0 +1,93 @@
"""Tests for core domain invariants around artifacts & relationships."""
from datetime import datetime, timezone
from uuid import uuid4
import pytest
from pydantic import ValidationError
from asf_domain import (
Artifact,
ArtifactCreate,
ArtifactRelationshipCreate,
ArtifactRelationship,
ArtifactType,
RelationshipType,
generate_id,
)
NOW = datetime(2026, 9, 7, 12, 0, 0, tzinfo=timezone.utc)
def test_generate_id_returns_unique_uuid4() -> None:
a = generate_id()
b = generate_id()
assert a != b
assert a.version == 4
def test_artifact_create_minimal() -> None:
body = ArtifactCreate(
project_id=uuid4(),
type=ArtifactType.REQUIREMENT,
title="Login page must support MFA",
)
assert body.type == ArtifactType.REQUIREMENT
assert body.metadata == {}
def test_artifact_stored_round_trip() -> None:
tenant_id = uuid4()
project_id = uuid4()
a = Artifact(
tenant_id=tenant_id,
project_id=project_id,
type=ArtifactType.TASK,
title="Wire MFA provider",
content="- [ ] choose TOTP lib\n- [ ] enroll flow",
metadata={"estimated_points": 5, "labels": ["auth", "security"]},
created_at=NOW,
updated_at=NOW,
)
d = a.model_dump(mode="json")
# ensure UTC serialization works
assert d["created_at"].endswith("Z")
assert d["metadata"]["labels"] == ["auth", "security"]
def test_artifact_rejects_empty_title() -> None:
with pytest.raises(ValidationError):
ArtifactCreate(project_id=uuid4(), type=ArtifactType.STORY, title="")
def test_relationship_create() -> None:
a = uuid4()
b = uuid4()
rel = ArtifactRelationshipCreate(
relationship_type=RelationshipType.IMPLEMENTED_BY,
source_artifact_id=a,
target_artifact_id=b,
)
assert rel.source_artifact_id == a
def test_relationship_stored_enforces_tenancy() -> None:
tenant_id = uuid4()
a, b = uuid4(), uuid4()
pa, pb = uuid4(), uuid4()
rel = ArtifactRelationship(
tenant_id=tenant_id,
source_project_id=pa,
target_project_id=pb,
relationship_type=RelationshipType.VERIFIED_BY,
source_artifact_id=a,
target_artifact_id=b,
created_at=NOW,
updated_at=NOW,
)
# a relationship carries both project ids for tenant isolation queries
assert rel.tenant_id == tenant_id
assert rel.source_project_id == pa
assert rel.target_project_id == pb
@@ -0,0 +1,41 @@
"""Tests for Organization / Project invariants."""
from datetime import datetime, timezone
from uuid import uuid4
import pytest
from pydantic import ValidationError
from asf_domain import (
Organization,
OrganizationCreate,
Project,
ProjectCreate,
)
NOW = datetime(2026, 9, 7, 12, 0, 0, tzinfo=timezone.utc)
def test_organization_create_validates_slug() -> None:
OrganizationCreate(name="Contoso", slug="contoso-retail")
with pytest.raises(ValidationError):
OrganizationCreate(name="Bad", slug="UPPERCASE no!")
def test_project_create_validates_key_uppercase() -> None:
ProjectCreate(name="ASF Core", key="ASF")
with pytest.raises(ValidationError):
ProjectCreate(name="Bad", key="lowercase")
def test_project_scoped_under_tenant() -> None:
tenant_id = uuid4()
p = Project(
tenant_id=tenant_id,
name="ASF Web",
key="WEB",
created_at=NOW,
updated_at=NOW,
)
assert p.tenant_id == tenant_id
@@ -0,0 +1,63 @@
"""Tests for job state transitions & usage tracking."""
from datetime import datetime, timezone
from uuid import uuid4
import pytest
from asf_domain import (
Job,
JobCreate,
JobUpdate,
JobStatus,
JobPriority,
JobType,
JobUsage,
)
NOW = datetime(2026, 9, 7, 12, 0, 0, tzinfo=timezone.utc)
def test_job_create_defaults() -> None:
jc = JobCreate(
type=JobType.AI_INTERACTIVE,
payload={"prompt": "summarize REQ-1", "artifact_id": str(uuid4())},
)
assert jc.priority == JobPriority.NORMAL
assert jc.model is None
def test_job_lifecycle_updates() -> None:
tenant_id = uuid4()
j = Job(
tenant_id=tenant_id,
type=JobType.AI_BACKGROUND,
payload={"artifact_id": str(uuid4())},
created_at=NOW,
)
assert j.status == JobStatus.QUEUED
# worker claims it
update1 = JobUpdate(status=JobStatus.RUNNING, worker="worker-asf-ai-7", started_at=NOW)
data = j.model_copy(update=update1.model_dump(exclude_unset=True))
assert data.status == JobStatus.RUNNING
assert data.worker == "worker-asf-ai-7"
# worker completes with usage
usage = JobUsage(
input_tokens=1500, output_tokens=400, gpu_seconds=2.3, estimated_cost=0.012
)
update2 = JobUpdate(status=JobStatus.COMPLETED, completed_at=NOW, usage=usage)
final = data.model_copy(update=update2.model_dump(exclude_unset=True))
assert final.status == JobStatus.COMPLETED
final_usage = JobUsage.model_validate(final.usage)
assert final_usage.output_tokens == 400
assert final_usage.estimated_cost == pytest.approx(0.012)
def test_job_update_validates_progress_bounds() -> None:
from pydantic import ValidationError
with pytest.raises(ValidationError):
JobUpdate(progress_percent=150)
@@ -0,0 +1,55 @@
"""asf-events: canonical contracts for every message that travels on the
ASF RabbitMQ backbone (both domain events and job-request envelopes).
Service code NEVER constructs a message dict inline: always use one of
the models in this package. This keeps producers and consumers typed.
"""
__version__ = "0.1.0"
from asf_events.base import ASFEvent, JobRequestEnvelope
from asf_events.artifact_events import (
ArtifactCreated,
ArtifactUpdated,
ArtifactDeleted,
ArtifactRelationshipCreated,
ArtifactRelationshipDeleted,
)
from asf_events.ai_events import (
AIInferenceRequested,
AIInferenceCompleted,
AIInferenceFailed,
)
from asf_events.identity_events import (
OrganizationCreated,
ProjectCreated,
ProjectUpdated,
)
from asf_events.job_events import (
JobQueued,
JobStarted,
JobCompleted,
JobFailed,
JobCancelled,
)
__all__ = [
"ASFEvent",
"JobRequestEnvelope",
"ArtifactCreated",
"ArtifactUpdated",
"ArtifactDeleted",
"ArtifactRelationshipCreated",
"ArtifactRelationshipDeleted",
"AIInferenceRequested",
"AIInferenceCompleted",
"AIInferenceFailed",
"OrganizationCreated",
"ProjectCreated",
"ProjectUpdated",
"JobQueued",
"JobStarted",
"JobCompleted",
"JobFailed",
"JobCancelled",
]
@@ -0,0 +1,41 @@
"""AI-specific domain events (published by the AI Gateway / workers)."""
from __future__ import annotations
from typing import Any, Dict, Optional
from uuid import UUID
from asf_events.base import ASFEvent
class AIInferenceRequested(ASFEvent):
event_type: str = "ai.inference.requested"
job_id: Optional[UUID] = None
capability: str
provider: str
preferred_model: Optional[str] = None
payload: Dict[str, Any]
class AIInferenceCompleted(ASFEvent):
event_type: str = "ai.inference.completed"
job_id: Optional[UUID] = None
execution_id: UUID
capability: str
provider: str
model_used: str
duration_ms: Optional[int] = None
input_tokens: Optional[int] = None
output_tokens: Optional[int] = None
estimated_cost: Optional[float] = None
payload: Dict[str, Any]
class AIInferenceFailed(ASFEvent):
event_type: str = "ai.inference.failed"
job_id: Optional[UUID] = None
execution_id: UUID
capability: str
provider: str
error: str
payload: Dict[str, Any] = {}
@@ -0,0 +1,51 @@
"""Artifact + ArtifactRelationship domain events."""
from __future__ import annotations
from typing import Any, Dict, Optional
from uuid import UUID
from asf_events.base import ASFEvent
class ArtifactCreated(ASFEvent):
event_type: str = "artifact.created"
artifact_id: UUID
artifact_type: str
project_id: UUID
payload: Dict[str, Any]
class ArtifactUpdated(ASFEvent):
event_type: str = "artifact.updated"
artifact_id: UUID
artifact_type: str
project_id: UUID
changed_fields: list[str]
previous_version: int
payload: Dict[str, Any]
class ArtifactDeleted(ASFEvent):
event_type: str = "artifact.deleted"
artifact_id: UUID
artifact_type: str
project_id: UUID
class ArtifactRelationshipCreated(ASFEvent):
event_type: str = "artifact.relationship.created"
relationship_id: UUID
source_artifact_id: UUID
target_artifact_id: UUID
relationship_type: str
source_project_id: UUID
target_project_id: UUID
payload: Dict[str, Any] = {}
class ArtifactRelationshipDeleted(ASFEvent):
event_type: str = "artifact.relationship.deleted"
relationship_id: UUID
source_artifact_id: UUID
target_artifact_id: UUID
+65
View File
@@ -0,0 +1,65 @@
"""Base event envelope + job request envelope.
Every RabbitMQ message is either:
1. An ``ASFEvent`` subclass → a fact that happened (domain event).
2. A ``JobRequestEnvelope`` → a unit of work to be executed (job).
"""
from __future__ import annotations
from datetime import datetime, timezone
from typing import Any, Optional, Dict
from uuid import uuid4, UUID
from pydantic import Field
from asf_domain.base import ASFBaseModel, UTCDatetime, JobType, JobPriority
class ASFEvent(ASFBaseModel):
"""Abstract base for every domain event published on RabbitMQ.
Semantics: facts that happened, never commands.
Naming pattern: <Noun>.<Ved> (artifact.created, deployment.completed).
"""
event_id: UUID = Field(default_factory=uuid4)
event_type: str = Field(
...,
max_length=128,
description="Dot-delimited routing key e.g. 'artifact.created'.",
)
timestamp: UTCDatetime = Field(
default_factory=lambda: datetime.now(tz=timezone.utc)
)
tenant_id: UUID = Field(..., description="Owning tenant.")
project_id: Optional[UUID] = None
correlation_id: Optional[str] = Field(None, max_length=128)
causation_id: Optional[str] = Field(
None,
max_length=128,
description="Id of the previous event / request that caused this one.",
)
payload: Dict[str, Any] = Field(default_factory=dict)
class JobRequestEnvelope(ASFBaseModel):
"""Envelope delivered to a RabbitMQ worker queue.
The worker performs the work, then updates the corresponding Mongo
``Job`` record (its id is in this envelope). RabbitMQ is NOT the
permanent job state store — MongoDB is.
"""
job_id: UUID
tenant_id: UUID
project_id: Optional[UUID] = None
type: JobType
priority: JobPriority = JobPriority.NORMAL
correlation_id: Optional[str] = None
payload: Dict[str, Any] = Field(default_factory=dict)
reply_to: Optional[str] = Field(
None,
max_length=255,
description="Optional queue/routing-key for completion events.",
)
@@ -0,0 +1,32 @@
"""Organization / Project domain events."""
from __future__ import annotations
from typing import Any, Dict
from uuid import UUID
from asf_events.base import ASFEvent
class OrganizationCreated(ASFEvent):
event_type: str = "organization.created"
organization_id: UUID
slug: str
name: str
payload: Dict[str, Any] = {}
class ProjectCreated(ASFEvent):
event_type: str = "project.created"
project_id: UUID
organization_id: UUID
project_key: str
name: str
payload: Dict[str, Any] = {}
class ProjectUpdated(ASFEvent):
event_type: str = "project.updated"
project_id: UUID
changed_fields: list[str]
payload: Dict[str, Any] = {}
@@ -0,0 +1,44 @@
"""Job lifecycle domain events."""
from __future__ import annotations
from typing import Any, Dict, Optional
from uuid import UUID
from asf_events.base import ASFEvent
class JobQueued(ASFEvent):
event_type: str = "job.queued"
job_id: UUID
job_type: str
queue: str
payload: Dict[str, Any] = {}
class JobStarted(ASFEvent):
event_type: str = "job.started"
job_id: UUID
worker: Optional[str] = None
payload: Dict[str, Any] = {}
class JobCompleted(ASFEvent):
event_type: str = "job.completed"
job_id: UUID
duration_ms: Optional[int] = None
payload: Dict[str, Any] = {}
class JobFailed(ASFEvent):
event_type: str = "job.failed"
job_id: UUID
error: str
payload: Dict[str, Any] = {}
class JobCancelled(ASFEvent):
event_type: str = "job.cancelled"
job_id: UUID
reason: Optional[str] = None
payload: Dict[str, Any] = {}
+19
View File
@@ -0,0 +1,19 @@
[build-system]
requires = ["setuptools>=68", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "asf-events"
version = "0.1.0"
description = "ASF event schemas (RabbitMQ payload contracts)"
requires-python = ">=3.12"
dependencies = [
"asf-domain>=0.1.0",
"pydantic>=2.7,<3",
]
[project.optional-dependencies]
dev = ["pytest>=8"]
[tool.setuptools.packages.find]
include = ["asf_events*"]
+258
View File
@@ -0,0 +1,258 @@
#!/usr/bin/env python3
"""Seed source_file + code_smell artifacts per Code Analysis page."""
import json
import urllib.request
import urllib.error
HEADERS = {
"Content-Type": "application/json",
"X-ASF-Tenant-Id": "t-demo-003",
"X-ASF-User-Id": "u-mario",
}
BASE = "http://localhost:8000/api/v1"
def req(method, path, body=None):
data = json.dumps(body).encode() if body is not None else None
r = urllib.request.Request(
BASE + path,
data=data,
headers=HEADERS,
method=method,
)
try:
with urllib.request.urlopen(r, timeout=15) as resp:
return resp.status, json.loads(resp.read().decode() or "null")
except urllib.error.HTTPError as e:
txt = e.read().decode()
try:
payload = json.loads(txt)
except Exception:
payload = {"raw": txt}
return e.code, payload
if __name__ == "__main__":
# 1. list projects
code, projects = req("GET", "/projects?limit=10")
assert code == 200, f"projects {code} {projects}"
pid = projects[0]["id"]
print(f"project_id={pid} name={projects[0]['name']}")
# 2. list existing to dedupe by external_id
code, arts = req("GET", "/artifacts?limit=200")
existing_external = {
a["external_id"] for a in arts if a.get("external_id")
}
existing_types = {
(a["type"], a["title"]) for a in arts
}
print(f"existing artifacts total: {len(arts)} ext_ids: {len(existing_external)} type/titles: {len(existing_types)}")
SOURCE_FILES = [
{
"external_id": "SRC-CORE-MW",
"type": "source_file",
"title": "asf-core/app/middleware/correlation.py",
"status": "active",
"source": "git",
"content": (
"\"\"\"Tenant & correlation middleware.\n\n"
"Adds X-ASF-Correlation-Id and coerces X-ASF-Tenant-Id to UUID5.\n\"\"\"\n"
"import uuid\nfrom fastapi import Request, Response\nfrom starlette.middleware.base import BaseHTTPMiddleware\n\n"
"TENANT_NS = uuid.UUID('6ba7b812-9dad-11d1-80b4-00c04fd430c8')\n\n"
"def _coerce_uuid(val):\n"
" try:\n"
" return uuid.UUID(val)\n"
" except Exception:\n"
" return uuid.uuid5(TENANT_NS, val or 'default')\n\n"
"class CorrelationMiddleware(BaseHTTPMiddleware):\n"
" async def dispatch(self, request: Request, call_next):\n"
" cid = request.headers.get('X-ASF-Correlation-Id') or str(uuid.uuid4())\n"
" request.state.correlation_id = cid\n"
" tid = _coerce_uuid(request.headers.get('X-ASF-Tenant-Id'))\n"
" request.state.tenant_id = tid\n"
" response: Response = await call_next(request)\n"
" response.headers['X-ASF-Correlation-Id'] = cid\n"
" return response\n"
),
"metadata": {
"language": "python",
"path": "services/asf-core/app/middleware/correlation.py",
"repo": "git@github.com:azurian/asf.git",
"branch": "main",
"loc": 36,
"lines": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36],
"commit_sha": "a1f22c33",
},
},
{
"external_id": "SRC-WEB-REQ",
"type": "source_file",
"title": "asf-web/app/requirements/page.tsx",
"status": "active",
"source": "git",
"content": (
"\"use client\";\n"
"import React, { useMemo, useState } from \"react\";\n"
"import { useQuery } from \"@tanstack/react-query\";\n"
"import { ClipboardList, Search } from \"lucide-react\";\n"
"import { artifacts, projects } from \"@/lib/api\";\n"
"import { Card, CardBody, CardHeader, CardTitle } from \"@/components/ui/Card\";\n"
"\n"
"export default function RequirementsPage() {\n"
" const [projectFilter, setProjectFilter] = useState('all');\n"
" const [statusFilter, setStatusFilter] = useState('all');\n"
" const projectsQ = useQuery({ queryKey: ['req-projects'], queryFn: () => projects.list({ limit: 50 }) });\n"
" const listQ = useQuery({\n"
" queryKey: ['req-list', projectFilter, statusFilter],\n"
" queryFn: () => artifacts.list({\n"
" status: statusFilter !== 'all' ? statusFilter : undefined,\n"
" project_id: projectFilter !== 'all' ? projectFilter : undefined,\n"
" type: 'requirement',\n"
" limit: 200,\n"
" }),\n"
" });\n"
" return (<Card>...</Card>);\n"
"}\n"
),
"metadata": {
"language": "typescript",
"path": "frontend/asf-web/app/requirements/page.tsx",
"repo": "git@github.com:azurian/asf.git",
"branch": "main",
"loc": 394,
"framework": "nextjs14",
"commit_sha": "a1f22c33",
},
},
{
"external_id": "SRC-WORKER-CB",
"type": "source_file",
"title": "asf-worker/app/worker/consumer.py",
"status": "active",
"source": "git",
"content": (
"\"\"\"RabbitMQ consumer factory + sync callbacks for DIRECT job queues.\"\"\"\n"
"import asyncio\nimport structlog\nfrom aio_pika import IncomingMessage\n\n"
"log = structlog.get_logger(__name__)\n\n"
"def _make_consumer_callback(handler):\n"
" def _cb(message: IncomingMessage):\n"
" loop = asyncio.new_event_loop()\n"
" try:\n"
" loop.run_until_complete(handler(message))\n"
" except Exception as exc:\n"
" log.exception('consumer_handler_error', err=exc)\n"
" finally:\n"
" loop.close()\n"
" return _cb\n"
),
"metadata": {
"language": "python",
"path": "services/asf-worker/app/worker/consumer.py",
"repo": "git@github.com:azurian/asf.git",
"branch": "main",
"loc": 18,
"commit_sha": "b4812a01",
},
},
]
# code smells stored as type=ai_analysis with metadata.kind = "code_smell"
# (to keep ArtifactType enum closed — no tests break)
SMELLS = [
{
"external_id": "CS-001",
"type": "ai_analysis",
"title": "Bare except clause in CorrelationMiddleware.dispatch",
"status": "open",
"source": "ruff:EM102",
"content": (
"Rule Ruff E722 — bare `except Exception` without narrow clause may swallow "
"cancellation errors unrelated to UUID parse. Prefer explicit ValueError/AttributeError "
"or log.exception with correlation_id bound.\n"
"Lines affected: 14-16."
),
"metadata": {
"kind": "code_smell",
"language": "python",
"severity": "medium",
"rule_id": "E722",
"engine": "ruff 0.5.0",
"path": "services/asf-core/app/middleware/correlation.py",
"lines": [14, 15, 16],
"fingerprint": "corr_mw_except_001",
"confidence_pct": 98,
},
},
{
"external_id": "CS-002",
"type": "ai_analysis",
"title": "Missing N+1 query guard in requirements listQ (no select limit on projects)",
"status": "open",
"source": "eslint:perf-nplus1",
"content": (
"Requirements page fetches all 50 projects and maps via projectsQ even when a single project filter is selected. "
"Consider deriving projectNameById lazily and paginating projects lookup.\n"
"Affected page.tsx lines 78-89."
),
"metadata": {
"kind": "code_smell",
"language": "typescript",
"severity": "low",
"rule_id": "asf-perf-003",
"engine": "eslint custom",
"path": "frontend/asf-web/app/requirements/page.tsx",
"lines": [78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89],
"fingerprint": "req_list_nplus1_002",
"confidence_pct": 75,
},
},
{
"external_id": "CS-003",
"type": "ai_analysis",
"title": "Coroutine leak risk: event loop not shared re-created per message",
"status": "open",
"source": "pytype:event-loop",
"content": (
"Worker consumer factory _cb spawns asyncio.new_event_loop() per IncomingMessage. "
"Long-lived handlers under high volume may accumulate GC pressure from leftover loop objects. "
"Recommendation: pass the shared running event loop to handler() via q.consume() and make handler truly async.\n"
"Severity HIGH — affects every DIRECT job 24/7."
),
"metadata": {
"kind": "code_smell",
"language": "python",
"severity": "high",
"rule_id": "ASF-SEC-203",
"engine": "pytype + manual review",
"path": "services/asf-worker/app/worker/consumer.py",
"lines": [8, 9, 10, 11, 12, 13, 14, 15, 16],
"fingerprint": "worker_loop_per_msg_003",
"confidence_pct": 88,
"cwe": "CWE-772",
},
},
]
created = 0
for body in SOURCE_FILES + SMELLS:
if (body["type"], body["title"]) in existing_types or body.get("external_id") in existing_external:
print(f" SKIP exists ext_id={body.get('external_id')} type={body['type']} title={body['title'][:60]}")
continue
body_full = {"project_id": pid, **body}
code, resp = req("POST", "/artifacts", body_full)
if code in (201, 200):
created += 1
print(f" OK [{code}] type={body['type']} id={str(resp['id'])[:8]} title={body['title'][:60]}")
else:
print(f" FAIL [{code}] type={body['type']} body={resp}")
print(f"\nDONE: created={created} new code artifacts")
# print summary counts per type
code, arts2 = req("GET", "/artifacts?limit=200")
from collections import Counter
cnt = Counter(a["type"] for a in arts2)
for t, c in cnt.items():
print(f" count {t}: {c}")
+197
View File
@@ -0,0 +1,197 @@
#!/usr/bin/env bash
#
# seed-demo.sh — Popola ASF con i dati DEMO del tenant t-demo-003.
#
# Sequenza (idempotente dal lato API - POST ritorna 201 per nuovi artifact,
# ma se seed precedenti esistono già verrà creato duplicato perché non abbiamo
# un controllo unique ext_id in questa fase MVP. Per sicurezza cancella TUTTI
# artifacts + jobs prima di ricreare quando si specifica --clean.
#
# Usage:
# ./scripts/seed-demo.sh # seeds normali (3 blocchi trace + code + extra)
# ./scripts/seed-demo.sh --clean # PURGA artifacts + jobs t-demo-003 POI seed
# ./scripts/seed-demo.sh --jobs # + 5 job echo demo in coda (devops/testing/...)
#
# Richiede:
# • ASF Core attivo su http://localhost:8000 (scripts/start-stack.sh)
# • Python .venv attivo o eseguibile in /root/ASF/.venv/bin/python
# • Worker attivo se usi --jobs (altrimenti jobs stanno in QUEUED)
#
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
PY="$ROOT/.venv/bin/python"
CORE_URL="${ASF_CORE_URL:-http://localhost:8000}"
TENANT="${X_ASF_TENANT_ID:-t-demo-003}"
USER="${X_ASF_USER_ID:-u-mario}"
CLEAN=0
JOBS=0
for arg in "$@"; do
case "$arg" in
--clean) CLEAN=1 ;;
--jobs) JOBS=1 ;;
-h|--help)
sed -n '2,26p' "$0"
exit 0 ;;
esac
done
log() { printf " \033[1;34m→\033[0m %s\n" "$*"; }
ok() { printf " \033[1;32m✔\033[0m %s\n" "$*"; }
fail() { printf " \033[1;31m✘\033[0m %s\n" "$*" >&2; exit 1; }
curl_h() {
# $1 = method, $2 = path, stdin optional body. Prints status + JSON body.
local m="$1" p="$2"
shift 2
curl -s -X "$m" -H "X-ASF-Tenant-Id: $TENANT" -H "X-ASF-User-Id: $USER" \
-H "Content-Type: application/json" \
"${CORE_URL}/api/v1${p}" "$@"
}
# --- Sanity check: Core UP ---
log "ASF Core health check su $CORE_URL"
H=$(curl -s -o /dev/null -w "%{http_code}" "${CORE_URL}/api/v1/health" || true)
if [[ "$H" != "200" ]]; then
fail "ASF Core non risponde (HTTP $H). Avvia con: bash scripts/start-stack.sh"
fi
ok "ASF Core UP (health=$H)"
# --- Cleanup (opzionale) ---
if [[ $CLEAN == 1 ]]; then
log "Cleanup artifacts + jobs per tenant $TENANT (DELETE /artifacts e /jobs listati)"
# MVP: asf-core non espone DELETE /artifacts bulk. Useremo GET lista + DELETE /artifacts/{id}.
A_IDS=$(curl_h GET "/artifacts?limit=500" | "$PY" -c '
import json,sys
d=json.load(sys.stdin)
for a in d: print(a["id"])
')
N_A=$(echo -n "$A_IDS" | grep -c . || true)
log " DELETE $N_A artifacts..."
if [[ -n "$A_IDS" ]]; then
while read -r id; do
curl -s -o /dev/null -X DELETE \
-H "X-ASF-Tenant-Id: $TENANT" -H "X-ASF-User-Id: $USER" \
"${CORE_URL}/api/v1/artifacts/${id}" || true
done <<< "$A_IDS"
fi
J_IDS=$(curl_h GET "/jobs?limit=500" | "$PY" -c '
import json,sys
d=json.load(sys.stdin)
for j in d: print(j["id"])
' 2>/dev/null || true)
N_J=$(echo -n "$J_IDS" | grep -c . || true)
log " PATCH CANCEL $N_J jobs..."
if [[ -n "$J_IDS" ]]; then
while read -r id; do
curl -s -o /dev/null -X PATCH \
-H "X-ASF-Tenant-Id: $TENANT" -H "X-ASF-User-Id: $USER" \
-H "Content-Type: application/json" \
-d '{"status":"cancelled"}' \
"${CORE_URL}/api/v1/jobs/${id}" || true
done <<< "$J_IDS"
fi
ok "Cleanup completato"
fi
# --- Seeds ---
log "Seed 1/4: Traceability artifacts (REQ → TASK → PR → TEST/BUILD → DEPLOY)"
"$PY" "$ROOT/scripts/seed-traceability.py" >/tmp/seed1.log 2>&1 \
|| fail "seed-traceability fallito. Log: /tmp/seed1.log"
ok " OK ($(wc -l < /tmp/seed1.log) righe log)"
log "Seed 2/4: Relazioni traceability (5 edges: implements/verifies/deployed_as/generated_from)"
"$PY" "$ROOT/scripts/seed-rels-only.py" >/tmp/seed2.log 2>&1 \
|| fail "seed-rels fallito. Log: /tmp/seed2.log"
ok " OK ($(wc -l < /tmp/seed2.log) righe log)"
log "Seed 3/4: Source files + Code smells (3 files, 3 smells)"
"$PY" "$ROOT/scripts/seed-code-artifacts.py" >/tmp/seed3.log 2>&1 \
|| fail "seed-code-artifacts fallito. Log: /tmp/seed3.log"
ok " OK ($(wc -l < /tmp/seed3.log) righe log)"
log "Seed 4/4: Extra DOC / COMMIT / SEC / INC + 4 relazioni (densify graph)"
"$PY" "$ROOT/scripts/seed-extra-rels.py" >/tmp/seed4.log 2>&1 \
|| fail "seed-extra-rels fallito. Log: /tmp/seed4.log"
ok " OK ($(wc -l < /tmp/seed4.log) righe log)"
# --- Jobs demo (opzionale) ---
if [[ $JOBS == 1 ]]; then
log "Enqueue 5 demo jobs (echo handler DEVOPS/TESTING/...)"
OUT=$(curl -s -X POST \
-H "X-ASF-Tenant-Id: $TENANT" -H "X-ASF-User-Id: $USER" \
-H "Content-Type: application/json" \
--data-raw '{
"jobs": [
{"type":"devops","priority":"normal","payload":{"name":"deploy-dev","env":"dev"}},
{"type":"testing","priority":"high","payload":{"name":"smoke-tests","framework":"pytest"}},
{"type":"devops","priority":"normal","payload":{"name":"build-image","image":"asf/app:latest"}},
{"type":"notifications","priority":"low","payload":{"channel":"email","to":"admins"}},
{"type":"indexing","priority":"normal","payload":{"source":"github/asf","branch":"main"}}
]
}' "${CORE_URL}/api/v1/jobs/batch" 2>&1 || echo "fallback")
# Fallback per /batch non esistente (MVP usa singolo POST per job)
if [[ "$OUT" == *"fallback"* || "$OUT" == *"Not Found"* || "$OUT" == *"404"* || "$OUT" == *"405"* ]]; then
log " /batch non disponibile → 5 POST singoli:"
TYPES=("devops" "testing" "devops" "notifications" "indexing")
PAYLOADS=(
'{"name":"deploy-dev","env":"dev"}'
'{"name":"smoke-tests","framework":"pytest"}'
'{"name":"build-image","image":"asf/app:latest"}'
'{"channel":"email","to":"admins"}'
'{"source":"github/asf","branch":"main"}'
)
PRIO=("normal" "high" "normal" "low" "normal")
for i in 0 1 2 3 4; do
JS=$(curl -s -X POST \
-H "X-ASF-Tenant-Id: $TENANT" -H "X-ASF-User-Id: $USER" \
-H "Content-Type: application/json" \
-d "{\"type\":\"${TYPES[$i]}\",\"priority\":\"${PRIO[$i]}\",\"payload\":${PAYLOADS[$i]}}" \
"${CORE_URL}/api/v1/jobs/submit")
echo -n "$JS" | "$PY" -c 'import json,sys; j=json.load(sys.stdin); print(" - job id=", j.get("id","?")[:8]+"…", "accepted=", j.get("accepted"))' 2>/dev/null
done
else
ok " /batch accettato"
fi
ok "5 demo jobs accodati (avvia asf-worker per completarli)"
fi
# --- Report finale ---
log "Report finale tenant $TENANT:"
echo ""
curl_h GET "/artifacts?limit=500" | "$PY" - <<'PY'
import json,sys
from collections import Counter
d=json.load(sys.stdin)
c=Counter(a.get("type","?") for a in d)
print(" Artifacts totali:", len(d))
for k,v in sorted(c.items(), key=lambda x:-x[1]):
print(f" - {k:18s} {v}")
PY
echo ""
curl_h GET "/traceability/graph" 2>/dev/null | "$PY" -c '
import json,sys
g=json.load(sys.stdin)
print(f" Traceability graph: nodes={len(g.get(\"nodes\",[]))} edges={len(g.get(\"edges\",[]))}")
' 2>/dev/null || true
echo ""
curl_h GET "/jobs" 2>/dev/null | "$PY" - <<'PY'
import json,sys
from collections import Counter
d=json.load(sys.stdin)
c=Counter(j.get("status","?") for j in d)
print(" Jobs totali:", len(d))
for k,v in sorted(c.items(), key=lambda x:-x[1]):
print(f" - {k:10s} {v}")
PY
echo ""
ok "SEED DEMO completato."
echo ""
echo " Suggerimenti:"
echo " • Apri http://localhost:3000 per vedere UI (hard refresh Ctrl+Shift+R se serve)"
echo " • bash scripts/status-stack.sh per stato core/web/worker"
echo " • bash scripts/seed-demo.sh --clean --jobs per ricreare TUTTO da zero"
+165
View File
@@ -0,0 +1,165 @@
#!/usr/bin/env python3
"""POST extra artifacts + 4 relazioni per densificare Traceability + Context Explorer.
Creati in tenant t-demo-003 / unico project esistente:
1. DOC "Product Requirements.md" (DOCUMENT) --references--> REQ-103
2. COMMIT "0abc123 feat: REQ-103" (CODE_COMMIT) --generated_from--> PR#88
3. SEC "SEC-025 SQL injection input sanitization" (SECURITY_FINDING) --mitigated_by--> PR#88
4. INC "INC-77 Login timeout" (INCIDENT) --caused_by--> DEP-44 deployment
Rileva artifact IDs esistenti via LIST + ext_id exact match.
"""
from __future__ import annotations
import json
import sys
import time
import urllib.request
import urllib.error
import urllib.parse
BASE = "http://localhost:8000/api/v1"
H = {
"X-ASF-Tenant-Id": "t-demo-003",
"X-ASF-User-Id": "u-mario",
"Content-Type": "application/json",
}
LOOKUP = {
"REQ": "REQ-103",
"PR": "PR#88",
"DEP": "DEP-44",
}
def req(method: str, path: str, body=None) -> tuple[int, object]:
data = None
if body is not None:
data = json.dumps(body).encode()
r = urllib.request.Request(BASE + path, data=data, method=method, headers=H)
try:
with urllib.request.urlopen(r, timeout=10) as resp:
text = resp.read().decode()
return resp.status, json.loads(text) if text else None
except urllib.error.HTTPError as e:
body = e.read().decode()
print(f" !! {method} {path} -> {e.code}: {body[:300]}", file=sys.stderr)
return e.code, body
def list_artifacts() -> list[dict]:
s, d = req("GET", "/artifacts?limit=500")
assert s == 200 and isinstance(d, list), f"list artifacts failed: {s}"
return d
def find_project_id(arts: list[dict]) -> str:
"""Unico project del tenant: take artifact project_id non-None first."""
for a in arts:
if a.get("project_id"):
return a["project_id"]
s, pl = req("GET", "/projects?limit=50")
assert s == 200 and isinstance(pl, list) and pl
return pl[0]["id"]
def create_artifact(payload: dict) -> dict:
s, d = req("POST", "/artifacts", payload)
if s != 201 or not isinstance(d, dict):
raise RuntimeError(f"CREATE {payload.get('type')} {payload.get('external_id')} HTTP {s}")
return d
def create_rel(src: str, tgt: str, rel_type: str) -> int:
body = {
"source_artifact_id": src,
"target_artifact_id": tgt,
"relationship_type": rel_type,
}
s, _ = req("POST", "/relationships", body)
return s
def main() -> int:
arts = list_artifacts()
existing: dict[str, str] = {}
for k, ext in LOOKUP.items():
match = next((a for a in arts if a.get("external_id") == ext), None)
if match is None:
print(f" !! missing {ext}, aborting.")
return 2
existing[k] = match["id"]
print(f" {k:3s} {ext!r:12s} -> {match['id'][:10]}…")
pid = find_project_id(arts)
print(f" project id = {pid[:10]}…")
print("\n== Creating 4 extra artifacts ==")
extra = [
{
"project_id": pid,
"type": "document",
"external_id": "DOC-PRD-2026",
"title": "Product Requirements v2.3",
"status": "active",
"content": "# Product Requirements 2.3\n\nLiving PRD covering authentication, billing and reporting.\n\n## Goals\nProvide clear tenancy and billing.\n",
"metadata": {"format": "markdown", "page_count": 18, "owner": "pm"},
},
{
"project_id": pid,
"type": "commit",
"external_id": "COMMIT-0abc123",
"title": "feat(auth): implement REQ-103 SSO flow",
"content": "commit 0abc123df\nAuthor: mario\nDate: today\n\nAdds Okta SAML callback + session refresh route.\n",
"metadata": {"sha": "0abc123df", "author": "mario@asf.test", "repo": "asf/mono", "branch": "main"},
},
{
"project_id": pid,
"type": "security_finding",
"external_id": "SEC-025",
"title": "SQL injection risk in profile search (low confidence)",
"status": "open",
"content": "Input param q passed directly to $regex without sanitization. Scanner: semgrep. Confidence 82%.",
"metadata": {"cwe": "CWE-89", "severity": "high", "scanner": "semgrep", "confidence_pct": 82},
},
{
"project_id": pid,
"type": "incident",
"external_id": "INC-77",
"title": "Login timeout spike 05:00-05:14 UTC",
"status": "mitigated",
"content": "Session refresh endpoint returned 5xx for 4% of users. MTTR 14 minutes. 27 customers impacted.",
"metadata": {"severity": "sev2", "mttr_min": 14, "customers_impacted": 27},
},
]
ids: list[str] = []
for pl in extra:
d = create_artifact(pl)
print(f" [201] {pl['type']:15s} {pl['external_id']:18s} id={d['id'][:8]}…")
ids.append(d["id"])
doc_id, commit_id, sec_id, inc_id = ids
print("\n== Creating 4 extra relationships ==")
edges = [
(doc_id, existing["REQ"], "documented_by", "DOC --documented_by--> REQ"),
(commit_id, existing["PR"], "generated_from", "COMMIT --generated_from--> PR"),
(sec_id, existing["PR"], "related_to", "SEC --related_to--> PR"),
(inc_id, existing["DEP"], "caused_by", "INC --caused_by--> DEP"),
]
all_ok = True
for src, tgt, rt, label in edges:
s = create_rel(src, tgt, rt)
chk = "OK" if s == 201 else "FAIL"
print(f" [{s:3d}] {chk:4s} {label}")
if s != 201:
all_ok = False
time.sleep(0.3)
s, g = req("GET", f"/traceability/graph?project_id={pid}")
if s == 200 and isinstance(g, dict):
print(f"\n== Traceability graph now: nodes={len(g.get('nodes', []))} edges={len(g.get('edges', []))} ==")
return 0 if all_ok else 5
if __name__ == "__main__":
raise SystemExit(main())
+65
View File
@@ -0,0 +1,65 @@
#!/usr/bin/env python3
"""Retry 5 POST relationships SENZA campo `meta` (was extra_forbidden 422). Chained graph DEP→BUILD→PR→TASK→REQ + TEST→PR."""
from __future__ import annotations
import json, subprocess, sys
BASE = "http://127.0.0.1:8000/api/v1"
H = ["-H","X-ASF-Tenant-Id: t-demo-003","-H","X-ASF-User-Id: u-mario","-H","Content-Type: application/json"]
def curl(method, path, body=None):
cmd = ["curl","-s","-m","15","-o","-","-w","\n%{http_code}","-X",method] + H
if body is not None: cmd += ["--data", json.dumps(body, ensure_ascii=False)]
cmd.append(BASE+path)
r = subprocess.run(cmd, capture_output=True, text=True)
out = (r.stdout or "{}").strip()
if "\n" in out:
jp, cp = out.rsplit("\n", 1)
else:
jp, cp = "", out
try: code = int(cp)
except: code = 0
try: data = json.loads(jp or "null")
except Exception as exc: data = f"ERR {exc}: {jp[:200]!r}"
print(f" [{method:6s}{path:22s}] HTTP={code:3d}",
f"id={str(data.get('id',''))[:10] if isinstance(data,dict) else '?'}",
"rel=", (data.get("source_artifact_id","")[:8] if isinstance(data,dict) else "?"),
"→",(data.get("target_artifact_id","")[:8] if isinstance(data,dict) else "?"),
"type=",(data.get("relationship_type","?") if isinstance(data,dict) else "?"),
file=sys.stderr)
return code, data
def main():
_, arts = curl("GET", "/artifacts?limit=50")
assert isinstance(arts, list)
by_type = {}
for a in arts:
if isinstance(a,dict):
by_type.setdefault(a["type"], []).append(a)
req = by_type["requirement"][0]
task = by_type["task"][0]
pr = by_type["pull_request"][0]
test = by_type["test"][0]
build = by_type["build"][0]
deploy= by_type["deployment"][0]
print(f" req={str(req['id'])[:10]} task={str(task['id'])[:10]} pr={str(pr['id'])[:10]} test={str(test['id'])[:10]} build={str(build['id'])[:10]} deploy={str(deploy['id'])[:10]}", file=sys.stderr)
rels = [
(task["id"], req["id"], "implements"),
(pr["id"], task["id"], "generated_from"),
(build["id"], pr["id"], "generated_from"),
(test["id"], pr["id"], "verifies"),
(deploy["id"], build["id"], "deployed_as"),
]
oks=0
for s,t,k in rels:
code, d = curl("POST","/relationships", {"source_artifact_id": s, "target_artifact_id": t, "relationship_type": k})
if 200 <= code < 300: oks += 1
print(f"\n rels OK = {oks}/5", file=sys.stderr)
code, graph = curl("GET", "/traceability/graph?limit=200")
assert code == 200 and isinstance(graph, dict)
nodes = graph.get("nodes", []); edges = graph.get("edges", [])
print(json.dumps({"nodes":len(nodes),"edges":len(edges),
"node_types":[n.get("type") for n in nodes],
"edge_labels":[e.get("label") for e in edges]}))
return 0 if oks == 5 else 2
raise SystemExit(main())
+125
View File
@@ -0,0 +1,125 @@
#!/usr/bin/env python3
"""Seed tenant t-demo-003 with 5 artifacts + 5 relationships for a rich Traceability Graph demo.
Endpoint testati via curl:
- POST /api/v1/artifacts
- POST /api/v1/relationships
- GET /api/v1/traceability/graph
"""
from __future__ import annotations
import json
import subprocess
import sys
from typing import Any
BASE = "http://127.0.0.1:8000/api/v1"
H = [
"-H", "X-ASF-Tenant-Id: t-demo-003",
"-H", "X-ASF-User-Id: u-mario",
"-H", "Content-Type: application/json",
]
def curl(method: str, path: str, body: dict[str, Any] | None = None) -> tuple[int, dict[str, Any] | list | str]:
cmd = ["curl", "-s", "-m", "15", "-o", "-", "-w", "\n%{http_code}", "-X", method] + H
if body is not None:
cmd += ["--data", json.dumps(body, ensure_ascii=False)]
cmd.append(f"{BASE}{path}")
r = subprocess.run(cmd, capture_output=True, text=True)
out_raw = r.stdout.strip() or "{}"
# Last line is the http code we injected with -w
if "\n" in out_raw:
json_part, code_part = out_raw.rsplit("\n", 1)
else:
json_part, code_part = "", out_raw
try:
code = int(code_part)
except ValueError:
code = 0
try:
data = json.loads(json_part or "null")
except Exception as exc: # pragma: no cover
data = f"JSON_ERR {exc} RAW={json_part[:200]!r}"
print(f" [{method:6s} {path:24s}] HTTP={code:3d}",
f"type={data.get('type','?') if isinstance(data,dict) else '?'}\tid={str(data.get('id',''))[:10] if isinstance(data,dict) else '?'}",
file=sys.stderr)
return code, data
def main() -> int:
print("# Step 1: list existing artifacts + project_id", file=sys.stderr)
_, arts = curl("GET", "/artifacts?limit=50")
assert isinstance(arts, list), f"artifacts list not a list: {arts!r}"
req_103 = None
project_id = None
for a in arts:
if isinstance(a, dict) and a.get("external_id") == "REQ-103":
req_103 = a
project_id = a.get("project_id")
break
if not req_103:
# fallback: first artifact
req_103 = arts[0] if arts else None
project_id = req_103.get("project_id") if isinstance(req_103, dict) else None
print(f" req_103 id={str(req_103['id'])[:10] if isinstance(req_103,dict) else None} project_id={str(project_id)[:10] if project_id else None}",
file=sys.stderr)
def make(type_: str, ext: str, title: str, content: str) -> dict[str, Any]:
code, data = curl(
"POST", "/artifacts",
{
"type": type_,
"external_id": ext,
"title": title,
"content": content,
"status": "done",
"project_id": project_id,
},
)
assert code == 201 and isinstance(data, dict) and "id" in data, f"make {type_} FAILED HTTP={code} {data}"
return data
print(file=sys.stderr); print("# Step 2: create 5 extra artifacts", file=sys.stderr)
task = make("task", "TASK-101", "Implementa tenant trust header coerce", "FastAPI correlation middleware: UUID UUID5 per stringhe t-demo-xxx, no 400 invalid")
pr = make("pull_request", "PR#88", "feat(rbac): tenant trust + policies v1", "feat(rbac): add trust header middleware + policy service helpers")
test = make("test", "TEST-22", "cross-tenant reads/writes blocked", "Pytest suite proving cross-tenant artifact operations return 404/403")
build = make("build", "BUILD-910","asf-core build #910 (main)", "GitHub Actions build main branch, all checks green.")
deploy = make("deployment", "DEP-44", "asf-core v0.5.0 → prod-west-1", "ArgoCD sync asf-core to cluster prod-west-1 eu-west")
print(file=sys.stderr); print("# Step 3: create 5 relationships (chain: DEP → BUILD → PR → TASK → REQ, TEST → PR)", file=sys.stderr)
rels_pairs = [
(task, req_103, "implements"),
(pr, task, "generated_from"),
(build, pr, "generated_from"),
(test, pr, "verifies"),
(deploy, build, "deployed_as"),
]
for src, tgt, kind in rels_pairs:
code, rel = curl(
"POST", "/relationships",
{
"source_artifact_id": src["id"],
"target_artifact_id": tgt["id"],
"relationship_type": kind,
"meta": {"seed": "traceability_demo"},
},
)
assert code < 300 and isinstance(rel, dict) and "id" in rel, f"REL FAIL {kind} -> {rel}"
print(file=sys.stderr); print("# Step 4: GET traceability graph → counts", file=sys.stderr)
code, graph = curl("GET", "/traceability/graph?limit=200")
assert code == 200 and isinstance(graph, dict), f"GRAPH FAIL HTTP={code} {graph}"
nodes = graph.get("nodes", [])
edges = graph.get("edges", [])
print(f"\n=== SEED SUCCESS ===", file=sys.stderr)
print(f" graph.nodes = {len(nodes)} graph.edges = {len(edges)}", file=sys.stderr)
# print to stdout for bash pipeline capture (JSON):
print(json.dumps({"nodes": len(nodes), "edges": len(edges),
"node_ids": [{"id":str(n["id"])[:10],"type":n.get("type"),"label":n.get("label")} for n in nodes],
"edge_labels": [{"s":e["source"][:8],"t":e["target"][:8],"lbl":e.get("label")} for e in edges]}))
return 0
if __name__ == "__main__":
raise SystemExit(main())
+225
View File
@@ -0,0 +1,225 @@
#!/usr/bin/env bash
# start-stack.sh — avvia ASF Core, ASF Web (Next.js), e opzionalmente ASF Worker.
# Usage:
# ./scripts/start-stack.sh # core + web, reload + dev mode
# ./scripts/start-stack.sh --no-reload # core senza --reload (productionish)
# ./scripts/start-stack.sh --with-worker # avvia anche asf-worker (7 code)
# ./scripts/start-stack.sh --web-only # solo frontend Next.js
# ./scripts/start-stack.sh --core-only # solo backend Core (FastAPI)
# Bindings default: 0.0.0.0 (core 8000, web 3000).
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT_DIR"
usage() {
sed -n '2,10p' "$0" | sed 's/^# \{0,1\}//'
exit 0
}
[[ "${1:-}" == "-h" || "${1:-}" == "--help" ]] && usage
WITH_WORKER=0
RELOAD=1
CORE=1
WEB=1
for arg in "$@"; do
case "$arg" in
--with-worker|-w) WITH_WORKER=1 ;;
--no-reload) RELOAD=0 ;;
--web-only) CORE=0; WITH_WORKER=0 ;;
--core-only) WEB=0 ;;
-h|--help) usage ;;
*) echo "Unknown option: $arg"; usage ;;
esac
done
# ------------------------------------------------------------
# 1) Preflight checks
# ------------------------------------------------------------
VENV_DIR="$ROOT_DIR/.venv"
if [ ! -d "$VENV_DIR" ]; then
echo "[start-stack] ❌ .venv non trovata in $VENV_DIR. Crea prima la venv: python -m venv .venv && source .venv/bin/activate" >&2
exit 1
fi
# shellcheck disable=SC1091
source "$VENV_DIR/bin/activate"
ENV_FILE="$ROOT_DIR/.env"
if [ ! -f "$ENV_FILE" ]; then
echo "[start-stack] ⚠️ .env non trovato, uso .env.example (se esiste)."
if [ -f "$ROOT_DIR/.env.example" ]; then
cp "$ROOT_DIR/.env.example" "$ROOT_DIR/.env"
fi
fi
# shellcheck disable=SC1090
set -a; [ -f "$ENV_FILE" ] && source "$ENV_FILE"; set +a
export ASF_ROOT="$ROOT_DIR"
export PYTHONPATH="\
$ROOT_DIR/packages/asf-domain:\
$ROOT_DIR/services/asf-events:\
$ROOT_DIR/services/asf-connectors:\
$ROOT_DIR/services/asf-core:\
$ROOT_DIR/services/asf-worker:\
$ROOT_DIR/services/asf-ai:\
$ROOT_DIR/services/asf-context\
${PYTHONPATH:+:$PYTHONPATH}"
export NEXT_PUBLIC_ASF_CORE_URL="${NEXT_PUBLIC_ASF_CORE_URL:-http://localhost:8000}"
export NEXT_PUBLIC_DEV_TENANT_ID="${NEXT_PUBLIC_DEV_TENANT_ID:-t-demo-001}"
export NEXT_PUBLIC_DEV_USER_ID="${NEXT_PUBLIC_DEV_USER_ID:-u-admin}"
LOG_DIR="$ROOT_DIR/var/log/asf"
PID_DIR="$ROOT_DIR/var/run"
mkdir -p "$LOG_DIR" "$PID_DIR"
# ------------------------------------------------------------
# 2) Helpers
# ------------------------------------------------------------
pids=()
names=()
start_in_dir() {
local name="$1"; shift
local dir="$1"; shift
local log="$LOG_DIR/$name.log"
echo " starting $name ($(basename "$dir")) ..."
(
cd "$dir"
# shellcheck disable=SC2093
exec "$@" >"$log" 2>&1
) &
local pid=$!
pids+=("$pid")
names+=("$name")
echo " pid=$pid log=$log"
}
print_header() {
local COLS
COLS=$(tput cols 2>/dev/null || echo 80)
printf '═%.0s' $(seq 1 "$COLS"); echo
printf " ASF local stack · %s · tenant=%s user=%s\n" \
"$(date '+%F %T %Z')" \
"$NEXT_PUBLIC_DEV_TENANT_ID" \
"$NEXT_PUBLIC_DEV_USER_ID"
printf '═%.0s' $(seq 1 "$COLS"); echo
}
graceful_stop() {
echo
echo "[start-stack] SIGINT/SIGTERM received — stopping ${#pids[@]} processes (${names[*]})."
for i in "${!pids[@]}"; do
local pid="${pids[$i]}"
local name="${names[$i]}"
if kill -0 "$pid" 2>/dev/null; then
echo " stopping $name (pid=$pid)"
kill -TERM "$pid" 2>/dev/null || true
fi
done
# wait them out
local waited=0
while [ $waited -lt 10 ]; do
local alive=0
for pid in "${pids[@]}"; do kill -0 "$pid" 2>/dev/null && alive=1; done
[ $alive -eq 0 ] && break
sleep 1; waited=$((waited+1))
done
# force kill any stragglers
for pid in "${pids[@]}"; do
kill -0 "$pid" 2>/dev/null && { echo " force kill pid=$pid"; kill -9 "$pid" 2>/dev/null || true; }
done
echo "[start-stack] stopped."
exit 0
}
trap graceful_stop INT TERM
# ------------------------------------------------------------
# 3) Dependency quick liveness (Mongo + Rabbit — best effort)
# ------------------------------------------------------------
print_header
echo "[start-stack] python=$(python -V 2>&1 | cut -d' ' -f2) node=$(node -v) npm=v$(npm -v 2>/dev/null)"
echo "[start-stack] PYTHONPATH[0:80]=${PYTHONPATH:0:80}…"
echo
echo "[deps] Mongo / Rabbit best-effort reachability (5s timeout)…"
python - <<PY 2>/dev/null || true
import os,socket,time
for host,port,name in [("172.19.40.102",27017,"Mongo rs0 PRIMARY"),("172.19.40.103",27017,"Mongo rs0 SEED"),("172.19.40.201",5672,"RabbitMQ AMQP"),("172.19.40.201",15672,"RabbitMQ HTTP mgmt")]:
s=socket.socket(); s.settimeout(2)
try:
s.connect((host,port)); print(f" ✅ {name:22s} {host}:{port} reachable")
except Exception as e:
print(f" ⚠️ {name:22s} {host}:{port} unreachable ({e})")
finally: s.close()
PY
echo
# ------------------------------------------------------------
# 4) Start processes
# ------------------------------------------------------------
echo "[start-stack] launching services (bind 0.0.0.0)…"
# Core (FastAPI)
if [ $CORE -eq 1 ]; then
CORE_ARGS=(uvicorn app.main:app --host 0.0.0.0 --port 8000)
[ $RELOAD -eq 1 ] && CORE_ARGS+=(--reload)
start_in_dir "asf-core" "$ROOT_DIR/services/asf-core" "${CORE_ARGS[@]}"
echo " → http://0.0.0.0:8000/docs (OpenAPI)"
fi
# Web (Next.js dev)
if [ $WEB -eq 1 ]; then
start_in_dir "asf-web" "$ROOT_DIR/frontend/asf-web" npm run dev -- --hostname 0.0.0.0 --port 3000
echo " → http://0.0.0.0:3000 (UI)"
fi
# Worker
if [ $WITH_WORKER -eq 1 ]; then
start_in_dir "asf-worker" "$ROOT_DIR/services/asf-worker" python -m app.worker
echo " → queues=$ASF_WORKER_JOB_QUEUES"
fi
echo
# Save PIDs on disk for `stop-stack.sh`
: > "$PID_DIR/asf-stack.pids"
for i in "${!pids[@]}"; do
echo "${names[$i]} ${pids[$i]}" >> "$PID_DIR/asf-stack.pids"
done
echo "[start-stack] PIDs salvati in $PID_DIR/asf-stack.pids"
echo
echo "Tipi 'Ctrl+C' per arrestare TUTTI i processi. Oppure usa: ./scripts/stop-stack.sh"
echo
# ------------------------------------------------------------
# 5) Smoke test (first 20s)
# ------------------------------------------------------------
deadline=$((SECONDS + 25))
ok_core=0; ok_web=0
while [ $SECONDS -lt $deadline ]; do
if [ $CORE -eq 1 ] && [ $ok_core -eq 0 ]; then
code=$(curl -s -m 4 -o /dev/null -w '%{http_code}' http://127.0.0.1:8000/api/v1/health || true)
[ "$code" = "200" ] && { ok_core=1; echo " ✅ asf-core up /health HTTP=$code"; }
fi
if [ $WEB -eq 1 ] && [ $ok_web -eq 0 ]; then
code=$(curl -s -m 4 -o /dev/null -w '%{http_code}' http://127.0.0.1:3000/ || true)
[ "$code" = "200" ] && { ok_web=1; echo " ✅ asf-web up / HTTP=$code"; }
fi
need=0
[ $CORE -eq 1 ] && [ $ok_core -eq 0 ] && need=1
[ $WEB -eq 1 ] && [ $ok_web -eq 0 ] && need=1
[ $need -eq 0 ] && break
sleep 2
done
[ $CORE -eq 1 ] && [ $ok_core -eq 0 ] && echo " ⚠️ asf-core non ha risposto /health in tempo (guarda $LOG_DIR/asf-core.log)"
[ $WEB -eq 1 ] && [ $ok_web -eq 0 ] && echo " ⚠️ asf-web non ha risposto / in tempo (Next.js 14 first compile slow: attendi / guarda $LOG_DIR/asf-web.log)"
echo
echo "ASF stack ready. In attesa (foreground)… Ctrl+C = stop."
# wait
for pid in "${pids[@]}"; do
wait "$pid" || true
done
graceful_stop
+41
View File
@@ -0,0 +1,41 @@
#!/usr/bin/env bash
# status-stack.sh — PIDs, logs, HTTP live per ASF Core/Web/Worker.
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
PID_FILE="$ROOT_DIR/var/run/asf-stack.pids"
LOG_DIR="$ROOT_DIR/var/log/asf"
echo "ASF stack STATUS $(date '+%F %T %Z')"
echo
printf "%-18s %-8s %-6s %-6s %s\n" "NAME" "PID" "PORT" "HTTP" "LOG"
printf '─%.0s' $(seq 1 90); echo
check_http() {
local url="$1"
curl -s -m 3 -o /dev/null -w '%{http_code}' "$url" 2>/dev/null || echo "000"
}
# Core
pid=$(pgrep -f "uvicorn app.main:app" 2>/dev/null | head -1 || true)
http=$(check_http "http://127.0.0.1:8000/api/v1/health")
printf "%-18s %-8s %-6s %-6s %s\n" "asf-core" "${pid:--}" "8000" "$http" "$LOG_DIR/asf-core.log"
# Web
pid=$(pgrep -af "next-server|next dev" 2>/dev/null | awk '{print $1}' | head -1 || true)
http=$(check_http "http://127.0.0.1:3000/")
printf "%-18s %-8s %-6s %-6s %s\n" "asf-web" "${pid:--}" "3000" "$http" "$LOG_DIR/asf-web.log"
# Worker
pid=$(pgrep -f "python -m app.worker" 2>/dev/null | head -1 || true)
printf "%-18s %-8s %-6s %-6s %s\n" "asf-worker" "${pid:--}" "AMQP" "-" "$LOG_DIR/asf-worker.log"
echo
echo "Pid file:"; [ -f "$PID_FILE" ] && cat "$PID_FILE" || echo " (nessuno)"
echo
echo "Logs tail (10 lines most recent each):"
for f in "$LOG_DIR"/*.log; do
[ -e "$f" ] || continue
echo
echo "───── $f (last 10 lines) ─────"
tail -10 "$f" | sed 's/^/ │ /'
done
+63
View File
@@ -0,0 +1,63 @@
#!/usr/bin/env bash
# stop-stack.sh — ferma i processi avviati da start-stack.sh.
# Legge i PIDs da var/run/asf-stack.pids, oppure cerca per pattern.
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
PID_FILE="$ROOT_DIR/var/run/asf-stack.pids"
echo "ASF stack STOP $(date '+%F %T %Z')"
echo
stop_group() {
local pid="$1" name="$2"
if ! kill -0 "$pid" 2>/dev/null; then
echo " ✗ $name (pid=$pid) già spento"
return 0
fi
echo -n " ⏹ $name pid=$pid → "
kill -TERM "$pid" 2>/dev/null || true
local w=0
while [ $w -lt 8 ]; do
kill -0 "$pid" 2>/dev/null || break
sleep 1; w=$((w+1))
done
if kill -0 "$pid" 2>/dev/null; then
echo -n "SIGKILL "
kill -9 "$pid" 2>/dev/null || true
sleep 1
fi
kill -0 "$pid" 2>/dev/null && echo "FAIL (ancora vivo)" || echo "ok"
}
stop_patterns() {
echo "[fallback] kill via pattern match…"
for pattern in \
"uvicorn app.main:app" \
"next dev" "next-server" \
"python -m app.worker" "asf-worker.*run_worker"; do
pids=$(pgrep -af "$pattern" 2>/dev/null | awk '{print $1}' || true)
[ -z "$pids" ] && continue
echo " pattern '$pattern' → pids=$pids"
for p in $pids; do stop_group "$p" "$pattern"; done
done
}
if [ -f "$PID_FILE" ]; then
echo "[from pidfile] $PID_FILE"
while IFS=' ' read -r name pid rest; do
[ -z "${name:-}" ] && continue
[ -z "${pid:-}" ] && continue
stop_group "$pid" "$name"
done < "$PID_FILE"
rm -f "$PID_FILE"
else
echo "[no pidfile $PID_FILE]"
stop_patterns
fi
# final sweep
echo
echo "ports still listening:"
ss -ltnp 2>/dev/null | grep -E ":(8000|3000) " || echo " → nessuna porta 8000 / 3000 usata (ok)."
echo "done."
+54
View File
@@ -0,0 +1,54 @@
"""asf-ai: ASF AI Gateway — "l'ufficio analisti".
*Business code NEVER calls a specific LLM provider directly.*
It calls typed capability methods on this gateway.
Provider adapters (Mock, OpenAI-compat, NVIDIA, local, customer-hosted, ...)
implement the low-level interface. Routing, priority queues, tenant AI
policy, usage metering and cost tracking live here.
"""
__version__ = "0.1.0"
from .capabilities import (
CapabilityRequest,
CapabilityResponse,
ChatRequest,
ChatResponse,
CodeAnalysisRequest,
CodeAnalysisResult,
TestGenerationRequest,
GeneratedTests,
RequirementDecomposeRequest,
RequirementDecomposition,
TraceContext,
)
from .gateway import AIGateway, AIGatewayConfig
from .providers import (
AIProvider,
MockProvider,
ProviderRegistry,
build_default_gateway,
)
from .usage import UsageRecord
__all__ = [
"CapabilityRequest",
"CapabilityResponse",
"ChatRequest",
"ChatResponse",
"CodeAnalysisRequest",
"CodeAnalysisResult",
"TestGenerationRequest",
"GeneratedTests",
"RequirementDecomposeRequest",
"RequirementDecomposition",
"TraceContext",
"AIGateway",
"AIGatewayConfig",
"AIProvider",
"MockProvider",
"ProviderRegistry",
"build_default_gateway",
"UsageRecord",
]
+133
View File
@@ -0,0 +1,133 @@
"""Typed capability requests / responses.
Business services construct these Pydantic models and call methods on the
AIGateway. They never know which concrete model or provider was chosen.
"""
from __future__ import annotations
from typing import Any, Optional, Sequence
from uuid import UUID
from pydantic import BaseModel, Field
class TraceContext(BaseModel):
"""Context propagated through every call for audit / logging."""
tenant_id: UUID
project_id: Optional[UUID] = None
user_id: Optional[UUID] = None
job_id: Optional[UUID] = None
correlation_id: Optional[str] = None
class CapabilityRequest(BaseModel):
"""Base marker for every capability request supported by the gateway."""
trace: TraceContext
preferred_model: Optional[str] = Field(
None, max_length=128, description="Hint only. Gateway's routing decision is authoritative."
)
priority: str = "normal"
class CapabilityResponse(BaseModel):
"""Every response carries at least trace + usage."""
provider: str
model_used: str
input_tokens: Optional[int] = None
output_tokens: Optional[int] = None
duration_ms: Optional[int] = None
estimated_cost: Optional[float] = None
execution_id: UUID
raw: Optional[dict[str, Any]] = Field(
default=None, description="Raw provider output, preserved for debugging."
)
# ============================================================================
# Individual capabilities
# ============================================================================
class ChatMessage(BaseModel):
role: str = Field(..., pattern="^(system|user|assistant|tool)$")
content: str
class ChatRequest(CapabilityRequest):
messages: Sequence[ChatMessage]
temperature: float = Field(0.2, ge=0.0, le=2.0)
max_tokens: Optional[int] = Field(None, ge=1)
context_summary: Optional[str] = Field(
None, description="Short plaintext summary of the Context Engine assembly."
)
class ChatResponse(CapabilityResponse):
reply: str
references: list[str] = Field(default_factory=list)
class CodeAnalysisRequest(CapabilityRequest):
file_path: Optional[str] = None
language: Optional[str] = None
code_snippet: str = Field(..., min_length=1)
analysis_types: Sequence[str] = Field(
default_factory=lambda: ["bugs", "complexity", "security", "style"],
description="Which kinds of analyses to run.",
)
class Finding(BaseModel):
severity: str = Field(..., pattern="^(info|low|medium|high|critical)$")
kind: str
message: str
line_start: Optional[int] = None
line_end: Optional[int] = None
suggestion: Optional[str] = None
class CodeAnalysisResult(CapabilityResponse):
summary: str
findings: list[Finding] = Field(default_factory=list)
class TestGenerationRequest(CapabilityRequest):
file_path: Optional[str] = None
language: str = "python"
test_framework: str = "pytest"
code_snippet: str = Field(..., min_length=1)
goal: str = "Generate unit tests covering happy path + main failure modes"
existing_tests: Optional[str] = None
class GeneratedTests(CapabilityResponse):
test_cases: list[str] = Field(default_factory=list)
fixtures_needed: list[str] = Field(default_factory=list)
coverage_notes: str = ""
class RequirementDecomposeRequest(CapabilityRequest):
requirement_text: str = Field(..., min_length=1)
context: Optional[str] = None
existing_subtasks: Sequence[str] = Field(default_factory=list)
max_subtasks: int = Field(20, ge=1, le=100)
class SubTask(BaseModel):
title: str
description: str
estimated_complexity: str = Field(..., pattern="^(xs|s|m|l|xl)$")
depends_on: list[int] = Field(
default_factory=list,
description="Index (0-based) of sibling subtasks this one depends on.",
)
class RequirementDecomposition(CapabilityResponse):
subtasks: list[SubTask] = Field(default_factory=list)
architecture_notes: list[str] = Field(default_factory=list)
open_questions: list[str] = Field(default_factory=list)
+29
View File
@@ -0,0 +1,29 @@
"""asf-ai settings surface (minimal; extensible)."""
from __future__ import annotations
from functools import lru_cache
from pydantic_settings import BaseSettings, SettingsConfigDict
class AISettings(BaseSettings):
mongo_uri: str = "mongodb://172.19.40.102:27017,172.19.40.103:27017/asf?replicaSet=rs0"
mongo_db: str = "asf"
default_provider: str = "mock"
# Provider-specific (optional — only required if the chosen provider is used)
openai_api_key: str | None = None
anthropic_api_key: str | None = None
nvidia_api_key: str | None = None
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
extra="ignore",
)
@lru_cache(maxsize=1)
def get_settings() -> AISettings:
return AISettings()
+185
View File
@@ -0,0 +1,185 @@
"""AIGateway — the high-level capability surface called by business code.
It handles:
1. Routing: picks which provider + model to use given the request.
2. Policy enforcement: tenant-level AI policy (which providers/models are
allowed; budget caps will plug in here cleanly later).
3. Usage recording: emits UsageRecords for metering + future billing.
4. Error translation (provider-specific → stable ASF exceptions).
"""
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Callable, Optional
import structlog
from .capabilities import (
ChatRequest,
ChatResponse,
CodeAnalysisRequest,
CodeAnalysisResult,
TestGenerationRequest,
GeneratedTests,
RequirementDecomposeRequest,
RequirementDecomposition,
)
from .providers import (
AIProvider,
ProviderRegistry,
default_registry,
)
from .usage import UsageRecord
log = structlog.get_logger(__name__)
@dataclass
class AIGatewayConfig:
default_provider: str = "mock"
default_model_by_capability: dict[str, str] = field(default_factory=dict)
# Hook for future tenant policy layer: given (tenant_id, capability),
# return the provider name to route to. None → use default_provider.
policy_selector: Optional[Callable[..., Optional[str]]] = None
class AIGateway:
"""Entry point for all ASF AI operations."""
def __init__(
self,
*,
config: Optional[AIGatewayConfig] = None,
registry: Optional[ProviderRegistry] = None,
) -> None:
self._config = config or AIGatewayConfig()
self._registry = registry or default_registry()
self._usage_callbacks: list[Callable[[UsageRecord], None]] = []
# --- callback hooks (future usage metering / billing) ---
def on_usage(self, cb: Callable[[UsageRecord], None]) -> None:
self._usage_callbacks.append(cb)
# --- routing helper ---
def _route(self, req, capability: str) -> AIProvider:
tenant_id = str(req.trace.tenant_id) if req.trace else None
chosen: Optional[str] = None
if self._config.policy_selector and tenant_id:
try:
chosen = self._config.policy_selector(
tenant_id=req.trace.tenant_id,
capability=capability,
preferred=req.preferred_model,
)
except Exception: # noqa: BLE001
log.exception("ai_policy_selector_failed")
chosen = chosen or self._config.default_provider
return self._registry.get(chosen)
def _emit_usage(self, record: UsageRecord) -> None:
for cb in self._usage_callbacks:
try:
cb(record)
except Exception: # noqa: BLE001
log.exception("ai_usage_callback_failed", provider=record.provider)
# ------------------------------------------------------------------
# Capability methods
# ------------------------------------------------------------------
async def chat(self, req: ChatRequest) -> ChatResponse:
provider = self._route(req, "chat")
log.debug("ai_chat_start", provider=provider.name, tenant=str(req.trace.tenant_id))
resp = await provider.chat(req)
self._emit_usage(
UsageRecord(
tenant_id=req.trace.tenant_id,
project_id=req.trace.project_id,
job_id=req.trace.job_id,
execution_id=resp.execution_id,
provider=resp.provider,
model=resp.model_used,
capability="chat",
input_tokens=resp.input_tokens,
output_tokens=resp.output_tokens,
estimated_cost=resp.estimated_cost,
duration_ms=resp.duration_ms,
recorded_at=datetime.now(tz=timezone.utc),
)
)
return resp
async def analyze_code(self, req: CodeAnalysisRequest) -> CodeAnalysisResult:
provider = self._route(req, "analyze_code")
resp = await provider.analyze_code(req)
self._emit_usage(
UsageRecord(
tenant_id=req.trace.tenant_id,
project_id=req.trace.project_id,
job_id=req.trace.job_id,
execution_id=resp.execution_id,
provider=resp.provider,
model=resp.model_used,
capability="analyze_code",
input_tokens=resp.input_tokens,
output_tokens=resp.output_tokens,
estimated_cost=resp.estimated_cost,
duration_ms=resp.duration_ms,
recorded_at=datetime.now(tz=timezone.utc),
)
)
return resp
async def generate_tests(self, req: TestGenerationRequest) -> GeneratedTests:
provider = self._route(req, "generate_tests")
resp = await provider.generate_tests(req)
self._emit_usage(
UsageRecord(
tenant_id=req.trace.tenant_id,
project_id=req.trace.project_id,
job_id=req.trace.job_id,
execution_id=resp.execution_id,
provider=resp.provider,
model=resp.model_used,
capability="generate_tests",
input_tokens=resp.input_tokens,
output_tokens=resp.output_tokens,
estimated_cost=resp.estimated_cost,
duration_ms=resp.duration_ms,
recorded_at=datetime.now(tz=timezone.utc),
)
)
return resp
async def decompose_requirement(
self, req: RequirementDecomposeRequest
) -> RequirementDecomposition:
provider = self._route(req, "decompose_requirement")
resp = await provider.decompose_requirement(req)
self._emit_usage(
UsageRecord(
tenant_id=req.trace.tenant_id,
project_id=req.trace.project_id,
job_id=req.trace.job_id,
execution_id=resp.execution_id,
provider=resp.provider,
model=resp.model_used,
capability="decompose_requirement",
input_tokens=resp.input_tokens,
output_tokens=resp.output_tokens,
estimated_cost=resp.estimated_cost,
duration_ms=resp.duration_ms,
recorded_at=datetime.now(tz=timezone.utc),
)
)
return resp
def build_default_gateway() -> AIGateway:
"""Default bootstrap-configured gateway — uses MockProvider, stdout usage logging."""
gw = AIGateway()
gw.on_usage(lambda record: log.debug("ai_usage_record", **record.model_dump(mode="json")))
return gw
+256
View File
@@ -0,0 +1,256 @@
"""AIProvider interface + mock + registry."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Protocol, Optional, Type
from uuid import uuid4, UUID
import structlog
from .capabilities import (
CapabilityRequest,
CapabilityResponse,
ChatRequest,
ChatResponse,
CodeAnalysisRequest,
CodeAnalysisResult,
TestGenerationRequest,
GeneratedTests,
RequirementDecomposeRequest,
RequirementDecomposition,
SubTask,
Finding,
)
log = structlog.get_logger(__name__)
@dataclass
class ProviderCaps:
supports_chat: bool = True
supports_code_analysis: bool = True
supports_test_generation: bool = True
supports_decompose: bool = True
max_input_chars: int = 200_000
class AIProvider(Protocol):
"""Low-level provider interface. The Gateway calls exactly one per request.
Concrete implementations: MockProvider, OpenAICompatProvider, NVIDIADGXProvider, ...
"""
@property
def name(self) -> str: ...
@property
def default_model(self) -> str: ...
@property
def capabilities(self) -> ProviderCaps: ...
async def chat(self, req: ChatRequest) -> ChatResponse: ...
async def analyze_code(self, req: CodeAnalysisRequest) -> CodeAnalysisResult: ...
async def generate_tests(self, req: TestGenerationRequest) -> GeneratedTests: ...
async def decompose_requirement(
self, req: RequirementDecomposeRequest
) -> RequirementDecomposition: ...
# ==============================================================================
# MockProvider — deterministic, canned, latency-simulating responses
# ==============================================================================
class MockProvider:
"""Provider that always returns deterministic, predictable responses.
Uses:
* unit tests of code that depends on the AI Gateway
* local dev when no API keys are set
* UI demo / smoke tests
"""
name = "mock"
default_model = "asf-mock-v1"
capabilities = ProviderCaps()
def __init__(self, *, response_latency_ms: int = 40) -> None:
self._latency = max(0, response_latency_ms)
async def _maybe_sleep(self) -> None:
if self._latency <= 0:
return
import asyncio
await asyncio.sleep(self._latency / 1000.0)
async def chat(self, req: ChatRequest) -> ChatResponse:
await self._maybe_sleep()
# Mirror the last user message back as a demo reply — never misleadingly smart
last_user = next(
(m.content for m in reversed(req.messages) if m.role == "user"),
"(no user message)",
)
reply = (
"[ASF Mock AI] This is a deterministic response from MockProvider. "
f"Your last message was: {last_user[:160]!r}. "
"To enable real AI, configure a non-mock provider in the ASF AI Gateway."
)
return ChatResponse(
provider=self.name,
model_used=self.default_model,
execution_id=uuid4(),
reply=reply,
references=["mock://no-real-data"],
input_tokens=len(last_user) // 4,
output_tokens=len(reply) // 4,
duration_ms=self._latency,
estimated_cost=0.0,
)
async def analyze_code(self, req: CodeAnalysisRequest) -> CodeAnalysisResult:
await self._maybe_sleep()
lines = [ln for ln in req.code_snippet.splitlines() if ln.strip()]
findings: list[Finding] = []
if "TODO" in req.code_snippet:
findings.append(
Finding(
severity="info",
kind="todo",
message="Mock analysis found TODO comment — remember to follow up.",
)
)
if "except Exception" in req.code_snippet:
findings.append(
Finding(
severity="medium",
kind="broad_exception",
message="Broad except clause detected — consider narrowing exception types.",
)
)
return CodeAnalysisResult(
provider=self.name,
model_used=self.default_model,
execution_id=uuid4(),
summary=f"Mock: analyzed {len(lines)} non-empty lines; {len(findings)} informational finding(s).",
findings=findings,
input_tokens=max(10, len(req.code_snippet) // 4),
output_tokens=100,
duration_ms=self._latency,
estimated_cost=0.0,
)
async def generate_tests(self, req: TestGenerationRequest) -> GeneratedTests:
await self._maybe_sleep()
test_cases = [
"# Generated by MockProvider — placeholder skeleton\n"
"def test_mock_happy_path():\n"
" # TODO: replace with real assertions from a production AI provider\n"
" assert True\n",
"# Generated by MockProvider — placeholder skeleton\n"
"def test_mock_failure_mode():\n"
" # TODO: simulate and assert on failure behavior\n"
" assert True\n",
]
return GeneratedTests(
provider=self.name,
model_used=self.default_model,
execution_id=uuid4(),
test_cases=test_cases,
fixtures_needed=[
"NOTE: Mock provider — fixtures listed are placeholders only.",
],
coverage_notes="Mock response. Real AI provider will target specific branches.",
input_tokens=max(10, len(req.code_snippet) // 4),
output_tokens=300,
duration_ms=self._latency,
estimated_cost=0.0,
)
async def decompose_requirement(
self, req: RequirementDecomposeRequest
) -> RequirementDecomposition:
await self._maybe_sleep()
limit = max(1, min(5, req.max_subtasks))
subtasks = [
SubTask(
title=f"(Mock) Clarify scope of requirement",
description="Mock placeholder — confirm acceptance criteria with stakeholders.",
estimated_complexity="s",
),
SubTask(
title=f"(Mock) Draft architecture",
description="Mock placeholder — identify modules, data flow, contracts.",
estimated_complexity="m",
depends_on=[0],
),
SubTask(
title=f"(Mock) Implement feature",
description="Mock placeholder — concrete coding work.",
estimated_complexity="l",
depends_on=[1],
),
SubTask(
title=f"(Mock) Add tests",
description="Mock placeholder — unit + integration coverage.",
estimated_complexity="m",
depends_on=[2],
),
SubTask(
title=f"(Mock) Deploy + monitor",
description="Mock placeholder — staging → production, observability.",
estimated_complexity="s",
depends_on=[3],
),
][:limit]
return RequirementDecomposition(
provider=self.name,
model_used=self.default_model,
execution_id=uuid4(),
subtasks=subtasks,
architecture_notes=["(Mock) Decomposition is deterministic placeholder data."],
open_questions=[
"(Mock) Which non-functional requirements apply?",
"(Mock) Any existing integration contracts to preserve?",
],
input_tokens=max(10, len(req.requirement_text) // 4),
output_tokens=400,
duration_ms=self._latency,
estimated_cost=0.0,
)
# ==============================================================================
# ProviderRegistry — maps provider name → AIProvider instance
# ==============================================================================
class ProviderRegistry:
def __init__(self) -> None:
self._providers: dict[str, AIProvider] = {}
def register(self, name: str, provider: AIProvider) -> None:
self._providers[name] = provider
def get(self, name: str) -> AIProvider:
if name not in self._providers:
raise KeyError(f"Unknown AI provider: {name}. Registered: {list(self._providers)}")
return self._providers[name]
def names(self) -> list[str]:
return list(self._providers.keys())
def default_registry() -> ProviderRegistry:
r = ProviderRegistry()
r.register("mock", MockProvider(response_latency_ms=40))
return r
def build_default_gateway():
"""Instantiate the canonical production AIGateway (mock by default).
Imported lazily to avoid a cycle with ``app.gateway``.
"""
from .gateway import AIGateway, AIGatewayConfig
return AIGateway(config=AIGatewayConfig(), registry=default_registry())
+25
View File
@@ -0,0 +1,25 @@
"""Usage record (for billing / metering later)."""
from __future__ import annotations
from datetime import datetime
from typing import Optional
from uuid import UUID
from pydantic import BaseModel
class UsageRecord(BaseModel):
tenant_id: UUID
project_id: Optional[UUID] = None
job_id: Optional[UUID] = None
execution_id: UUID
provider: str
model: str
capability: str
input_tokens: Optional[int] = None
output_tokens: Optional[int] = None
gpu_seconds: Optional[float] = None
estimated_cost: Optional[float] = None
duration_ms: Optional[int] = None
recorded_at: datetime
+4
View File
@@ -0,0 +1,4 @@
[pytest]
asyncio_mode = auto
testpaths = tests
python_files = test_*.py

Some files were not shown because too many files have changed in this diff Show More