Edge AI commited on
Commit
a71d403
·
1 Parent(s): da39e3a

Add sandbox manager, multi-layer throttle, monitoring, external testing, cyberpunk UI

Browse files
README.md CHANGED
@@ -1,5 +1,5 @@
1
  ---
2
- title: Altamira Orchestrator
3
  emoji: 🧠
4
  colorFrom: indigo
5
  colorTo: purple
@@ -7,16 +7,31 @@ sdk: docker
7
  pinned: false
8
  ---
9
 
10
- # Altamira Orchestrator
11
 
12
- Multi-agent orchestration platform running on Hugging Face Spaces.
13
 
14
- ## Stack
15
 
16
- - **Backend:** FastAPI (Python 3.9)
17
- - **Frontend:** Alpine.js + Tailwind CSS (CDN)
18
- - **State:** Encrypted file-based (Fernet)
19
- - **Deployment:** Docker via Hugging Face Spaces
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
20
 
21
  ## API Endpoints
22
 
@@ -24,18 +39,38 @@ Multi-agent orchestration platform running on Hugging Face Spaces.
24
  |----------|--------|-------------|
25
  | `/` | GET | Web UI |
26
  | `/health` | GET | Health check |
 
27
  | `/api/projects` | GET/POST | List/create projects |
28
- | `/api/projects/{name}/activate` | POST | Activate a project |
29
- | `/api/projects/{name}/git-sync` | POST | Git sync a project |
30
- | `/api/console/exec` | POST | Execute a shell command |
31
- | `/api/console/stream` | WS | Stream command output |
32
- | `/api/chat/task` | POST | Send a prompt to the agent |
33
- | `/api/resources` | GET | List stored credentials |
34
- | `/api/resources/{key}` | PUT | Save a credential |
35
- | `/api/gatekeeper/validate` | POST | Validate HF/GitHub tokens |
36
- | `/api/state` | GET | Get encrypted state |
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
37
  | `/api/router` | GET | Router/circuit-breaker status |
38
- | `/api/sandboxes` | GET | List sandboxes |
 
 
 
39
  | `/worker/*` | - | Worker sub-app |
40
 
41
  ## LLM Providers (optional)
@@ -47,3 +82,21 @@ Set via the Resource Vault in the UI:
47
  - `GEMINI_API_KEY` — Google Gemini (gemini-2.0-flash)
48
 
49
  Without any keys, chat falls back to running shell commands in the project workspace.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: Altamira AADE
3
  emoji: 🧠
4
  colorFrom: indigo
5
  colorTo: purple
 
7
  pinned: false
8
  ---
9
 
10
+ # Altamira AADE
11
 
12
+ Autonomous AI Development Environment — multi-agent orchestration system running on Hugging Face Spaces.
13
 
14
+ ## Architecture
15
 
16
+ - **Main Orchestrator Space** coordinates agents, manages projects, resource vault, sandbox lifecycle
17
+ - **Sandbox Spaces** dynamically created HF Spaces per project for isolated code execution
18
+ - **Agent System** Commander, Reviewer, Executor, Monitor roles with DAG-based task scheduling
19
+
20
+ ## Features
21
+
22
+ | Feature | Description |
23
+ |---------|-------------|
24
+ | Multi-Agent Orchestration | Commander plans, Reviewer gates, Executor applies patches |
25
+ | DAG Scheduler | Topological sort, cycle detection, priority execution |
26
+ | Dynamic Sandboxes | Create HF Spaces per project for isolated dev environments |
27
+ | Resource Vault | Store API keys with per-project binding, ping verification, shareable labels |
28
+ | Multi-Layer Throttling | Layer 1: decentralized self-throttle, Layer 2: centralized queue, Layer 3: provider failover with session reinjection |
29
+ | LLM Provider Chain | NVIDIA NIM → OpenRouter → Gemini with circuit breaker |
30
+ | External Testing | Webhook tunnel (A), User download (B), GitHub Actions (C) |
31
+ | Monitoring | Manifest polling (`altamira-manifest.json`), heartbeat, real-time per-project |
32
+ | Aperture Navigation | Circular hover → horizontal grid → vertical icon list |
33
+ | OAuth Auth | Hugging Face OAuth + manual token login |
34
+ | Encrypted State | Fernet-encrypted file-based state with HF Datasets sync |
35
 
36
  ## API Endpoints
37
 
 
39
  |----------|--------|-------------|
40
  | `/` | GET | Web UI |
41
  | `/health` | GET | Health check |
42
+ | `/api/auth/*` | GET/POST | Auth (OAuth login, callback, session) |
43
  | `/api/projects` | GET/POST | List/create projects |
44
+ | `/api/projects/{name}/*` | POST/DELETE | Activate, git-sync, delete |
45
+ | `/api/agent/submit` | POST | Submit prompt to agent |
46
+ | `/api/agent/cycle` | POST | Trigger agent cycle |
47
+ | `/api/agent/tasks` | GET | List agent tasks |
48
+ | `/api/agent/status` | GET | Agent system status |
49
+ | `/api/resources` | GET/PUT/DELETE | Resource vault CRUD |
50
+ | `/api/resources/{key}/ping` | POST | Verify resource connectivity |
51
+ | `/api/resources/bind/{project}` | POST | Bind resource to project |
52
+ | `/api/resources/unbind/{project}` | POST | Unbind resource from project |
53
+ | `/api/resources/projects` | GET | List all project bindings |
54
+ | `/api/sandboxes` | GET | List sandbox spaces |
55
+ | `/api/sandboxes/create` | POST | Create sandbox HF Space |
56
+ | `/api/sandboxes/{project}/exec` | POST | Execute command in sandbox |
57
+ | `/api/sandboxes/{project}/write` | POST | Write file to sandbox |
58
+ | `/api/sandboxes/{project}/read` | POST | Read file from sandbox |
59
+ | `/api/sandboxes/{project}/files` | GET | List sandbox files |
60
+ | `/api/sandboxes/{project}` | DELETE | Delete sandbox space |
61
+ | `/api/monitor/manifest` | GET/POST | Manifest polling |
62
+ | `/api/monitor/heartbeat/{project}` | POST | Heartbeat ping |
63
+ | `/api/monitor/summary` | GET | Monitor summary |
64
+ | `/api/monitor/throttle` | GET | Throttle layer status |
65
+ | `/api/test/webhook/{project}` | POST | Trigger webhook test |
66
+ | `/api/test/download-url/{project}` | GET | Generate download link |
67
+ | `/api/test/github-actions/{project}` | POST | Trigger GHA workflow |
68
+ | `/api/test/artifact/{project}` | POST/GET | Upload/download artifacts |
69
  | `/api/router` | GET | Router/circuit-breaker status |
70
+ | `/api/console/exec` | POST | Execute shell command |
71
+ | `/api/console/stream` | WS | Stream command output |
72
+ | `/api/state` | GET/POST | Encrypted state management |
73
+ | `/api/system` | GET | System info |
74
  | `/worker/*` | - | Worker sub-app |
75
 
76
  ## LLM Providers (optional)
 
82
  - `GEMINI_API_KEY` — Google Gemini (gemini-2.0-flash)
83
 
84
  Without any keys, chat falls back to running shell commands in the project workspace.
85
+
86
+ ## Resource Ping Targets
87
+
88
+ | Key | Endpoint |
89
+ |-----|----------|
90
+ | HF_TOKEN | `huggingface.co/api/whoami` |
91
+ | GITHUB_TOKEN | `api.github.com/user` |
92
+ | NVIDIA_NIM_API_KEY | `integrate.api.nvidia.com/v1/models` |
93
+ | OPENROUTER_API_KEY | `openrouter.ai/api/v1/auth/key` |
94
+ | OPENAI_API_KEY | `api.openai.com/v1/models` |
95
+
96
+ ## Free Tier
97
+
98
+ Altamira runs entirely on Hugging Face Spaces free tier:
99
+ - CPU-basic hardware (no GPU cost)
100
+ - No credit card required
101
+ - Sandbox Spaces also on free tier
102
+ - All data in `/tmp` (ephemeral — restart resets state)
__pycache__/agent_system.cpython-312.pyc CHANGED
Binary files a/__pycache__/agent_system.cpython-312.pyc and b/__pycache__/agent_system.cpython-312.pyc differ
 
__pycache__/app.cpython-312.pyc CHANGED
Binary files a/__pycache__/app.cpython-312.pyc and b/__pycache__/app.cpython-312.pyc differ
 
__pycache__/router.cpython-312.pyc CHANGED
Binary files a/__pycache__/router.cpython-312.pyc and b/__pycache__/router.cpython-312.pyc differ
 
__pycache__/sandbox_manager.cpython-312.pyc ADDED
Binary file (14.8 kB). View file
 
__pycache__/test_agent_system.cpython-312-pytest-8.4.2.pyc CHANGED
Binary files a/__pycache__/test_agent_system.cpython-312-pytest-8.4.2.pyc and b/__pycache__/test_agent_system.cpython-312-pytest-8.4.2.pyc differ
 
__pycache__/test_integration.cpython-312-pytest-8.4.2.pyc ADDED
Binary file (64 kB). View file
 
__pycache__/test_router.cpython-312-pytest-8.4.2.pyc CHANGED
Binary files a/__pycache__/test_router.cpython-312-pytest-8.4.2.pyc and b/__pycache__/test_router.cpython-312-pytest-8.4.2.pyc differ
 
__pycache__/worker.cpython-312.pyc CHANGED
Binary files a/__pycache__/worker.cpython-312.pyc and b/__pycache__/worker.cpython-312.pyc differ
 
agent_system.py CHANGED
@@ -13,7 +13,7 @@ from typing import Any, Callable, Optional
13
 
14
  import httpx
15
 
16
- from router import InferenceRouterCircuitBreaker, PredictiveContextFilter
17
 
18
  # ──────────────────────────────────────────────
19
  # Constants
@@ -565,6 +565,8 @@ Your job is to:
565
  2. For each task, produce a JSON patch command using the available actions
566
  3. Delegate review to the Reviewer before applying changes
567
  4. Ensure no task proceeds without approval
 
 
568
 
569
  {COMMANDER_TOOLS}
570
  Output a JSON array of tasks, each with:
@@ -671,6 +673,7 @@ class AgentSubsystem:
671
  self._dag = DAGScheduler()
672
  self._key_pool = KeyPool()
673
  self._throttle = ThrottleController()
 
674
  self._monitor = Monitor()
675
  self._resources = ResourceManager()
676
  self._state = StateManager(dataset_repo, hf_token)
@@ -802,6 +805,7 @@ class AgentSubsystem:
802
  "completed_ids": list(self._completed),
803
  "active_ids": list(self._active_tasks.keys()),
804
  "throttle": self._throttle.to_dict(),
 
805
  "key_pool": self._key_pool.to_dict(),
806
  "dag": self._dag.to_dict(),
807
  "monitor": self._monitor.summary(),
 
13
 
14
  import httpx
15
 
16
+ from router import InferenceRouterCircuitBreaker, PredictiveContextFilter, MultiLayerThrottle, DecentralizedThrottle
17
 
18
  # ──────────────────────────────────────────────
19
  # Constants
 
565
  2. For each task, produce a JSON patch command using the available actions
566
  3. Delegate review to the Reviewer before applying changes
567
  4. Ensure no task proceeds without approval
568
+ 5. Self-throttle: you have a 40 RPM limit. Group related changes to minimize API calls.
569
+ If you get rate-limited, back off and retry locally before escalating.
570
 
571
  {COMMANDER_TOOLS}
572
  Output a JSON array of tasks, each with:
 
673
  self._dag = DAGScheduler()
674
  self._key_pool = KeyPool()
675
  self._throttle = ThrottleController()
676
+ self._multi_layer = MultiLayerThrottle()
677
  self._monitor = Monitor()
678
  self._resources = ResourceManager()
679
  self._state = StateManager(dataset_repo, hf_token)
 
805
  "completed_ids": list(self._completed),
806
  "active_ids": list(self._active_tasks.keys()),
807
  "throttle": self._throttle.to_dict(),
808
+ "multi_layer": self._multi_layer.to_dict(),
809
  "key_pool": self._key_pool.to_dict(),
810
  "dag": self._dag.to_dict(),
811
  "monitor": self._monitor.summary(),
app.py CHANGED
@@ -15,8 +15,9 @@ from fastapi.templating import Jinja2Templates
15
  from cryptography.fernet import Fernet
16
  from huggingface_hub import HfApi
17
 
18
- from router import PredictiveContextFilter, InferenceRouterCircuitBreaker, ParallelEngine
19
  from agent_system import AgentSubsystem, TaskStatus
 
20
 
21
  logging.basicConfig(
22
  level=logging.INFO,
@@ -119,6 +120,12 @@ filter_ctx = PredictiveContextFilter()
119
  circuit_breaker = InferenceRouterCircuitBreaker()
120
  engine = ParallelEngine()
121
 
 
 
 
 
 
 
122
  # agent subsystem
123
  STATE_REPO = os.environ.get("ALTAMIRA_STATE_REPO", "")
124
  STATE_HF_TOKEN = os.environ.get("HF_TOKEN", "")
@@ -281,13 +288,16 @@ async def index():
281
  @app.get("/health")
282
  async def health():
283
  from router import PAUSED_RETRY
 
284
  return {
285
  "status": "healthy",
286
- "app": "altamira-orchestrator",
287
- "version": "1.2.0",
288
  "circuit_breaker": getattr(circuit_breaker, "state", "unknown"),
289
  "failures": getattr(circuit_breaker, "failure_count", 0),
290
  "paused_retry": circuit_breaker.failure_count >= circuit_breaker.MAX_RETRIES if hasattr(circuit_breaker, "MAX_RETRIES") else False,
 
 
291
  }
292
 
293
  # ==================== GATEKEEPER ====================
@@ -567,21 +577,77 @@ async def chat_task(body: dict):
567
  output = result.stdout or result.stderr or "(no output)"
568
  return {"status": "ok", "response": f"exit code: {result.returncode}", "output": output[:5000]}
569
 
570
- # ==================== RESOURCES ====================
571
  RESOURCES_FILE = STATE_DIR / "resources.json"
 
 
 
 
 
 
 
 
 
 
 
 
 
572
 
573
  def _load_resources() -> dict:
574
  if RESOURCES_FILE.exists():
575
  raw = json.loads(RESOURCES_FILE.read_text())
576
- return {k: {"value": v.get("value", ""), "description": v.get("description", "")} for k, v in raw.items()}
 
 
 
577
  return {}
578
 
 
579
  def _save_resources(resources: dict):
580
  RESOURCES_FILE.write_text(json.dumps(resources, indent=2))
581
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
582
  @app.get("/api/resources")
583
- async def list_resources():
584
- return {"credentials": _load_resources()}
 
 
 
 
 
 
 
 
 
 
 
 
585
 
586
  @app.put("/api/resources/{key}")
587
  async def save_resource(key: str, body: dict):
@@ -589,12 +655,87 @@ async def save_resource(key: str, body: dict):
589
  if not key or not value:
590
  raise HTTPException(400, "Key and value required")
591
  resources = _load_resources()
592
- resources[key] = {"value": value, "description": body.get("description", "")}
 
 
 
 
 
 
 
593
  _save_resources(resources)
594
- # Write to env + feed into agent KeyPool so Commander/Reviewer can use it
595
  os.environ[key] = value
596
- agent_subsystem.add_api_key(value)
597
- return {"saved": key}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
598
 
599
  # ==================== STATE ====================
600
  @app.get("/api/state")
@@ -646,6 +787,7 @@ async def router_status():
646
  "filter_capacity": filter_ctx.capacity,
647
  "filter_threshold": filter_ctx.threshold,
648
  "parallel_max": engine.semaphore._value,
 
649
  }
650
 
651
  @app.post("/api/router/filter")
@@ -700,30 +842,242 @@ async def agent_add_key(body: dict):
700
  agent_subsystem.add_api_key(key)
701
  return {"status": "key added"}
702
 
703
- # ==================== SANDBOX / PREVIEW ====================
704
- @app.get("/api/sandbox/{name}")
705
- async def get_sandbox(name: str):
706
- path = SANDBOX_DIR / name
707
- if not path.exists():
708
- raise HTTPException(404, "Sandbox not found")
709
- files = []
710
- for f in path.rglob("*"):
711
- if f.is_file():
712
- files.append({"name": str(f.relative_to(path)),
713
- "size": f.stat().st_size,
714
- "modified": f.stat().st_mtime})
715
- return {"name": name, "files": sorted(files, key=lambda x: x["name"])}
716
-
717
- @app.get("/api/sandbox/{name}/read")
718
- async def read_sandbox_file(name: str, file: str):
719
- path = SANDBOX_DIR / name / file
720
- if not path.exists() or not path.is_file():
721
- raise HTTPException(404)
722
- return {"content": path.read_text()}
723
-
724
  @app.get("/api/sandboxes")
725
  async def list_sandboxes():
726
- return [d.name for d in SANDBOX_DIR.iterdir() if d.is_dir()]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
727
 
728
  # ==================== WORKER INTEGRATION ====================
729
  # mount worker app as sub-app
@@ -743,16 +1097,21 @@ async def list_results():
743
  @app.get("/api/system")
744
  async def system_info():
745
  import platform as _platform
 
746
  return {
747
  "platform": _platform.platform(),
748
  "python": _platform.python_version(),
749
  "hostname": os.uname().nodename,
750
  "cpus": os.cpu_count(),
751
- "sandbox_count": len(list(SANDBOX_DIR.iterdir())),
 
 
752
  "workspace_count": len(list(WORKSPACE_DIR.iterdir())),
753
  "disk_tmp": _disk_usage("/tmp"),
 
754
  }
755
 
 
756
  def _disk_usage(path: str) -> dict:
757
  s = os.statvfs(path)
758
  return {
 
15
  from cryptography.fernet import Fernet
16
  from huggingface_hub import HfApi
17
 
18
+ from router import PredictiveContextFilter, InferenceRouterCircuitBreaker, ParallelEngine, MultiLayerThrottle, DecentralizedThrottle
19
  from agent_system import AgentSubsystem, TaskStatus
20
+ from sandbox_manager import SandboxManager
21
 
22
  logging.basicConfig(
23
  level=logging.INFO,
 
120
  circuit_breaker = InferenceRouterCircuitBreaker()
121
  engine = ParallelEngine()
122
 
123
+ # sandbox manager
124
+ sandbox_manager = SandboxManager(hf_token=os.environ.get("HF_TOKEN", ""))
125
+
126
+ # multi-layer throttle
127
+ multi_layer_throttle = MultiLayerThrottle()
128
+
129
  # agent subsystem
130
  STATE_REPO = os.environ.get("ALTAMIRA_STATE_REPO", "")
131
  STATE_HF_TOKEN = os.environ.get("HF_TOKEN", "")
 
288
  @app.get("/health")
289
  async def health():
290
  from router import PAUSED_RETRY
291
+ sandbox_count = len(await sandbox_manager.list_sandboxes())
292
  return {
293
  "status": "healthy",
294
+ "app": "altamira-aade",
295
+ "version": "2.0.0",
296
  "circuit_breaker": getattr(circuit_breaker, "state", "unknown"),
297
  "failures": getattr(circuit_breaker, "failure_count", 0),
298
  "paused_retry": circuit_breaker.failure_count >= circuit_breaker.MAX_RETRIES if hasattr(circuit_breaker, "MAX_RETRIES") else False,
299
+ "sandboxes": sandbox_count,
300
+ "throttle_layer": multi_layer_throttle.to_dict().get("active_layer", 1),
301
  }
302
 
303
  # ==================== GATEKEEPER ====================
 
577
  output = result.stdout or result.stderr or "(no output)"
578
  return {"status": "ok", "response": f"exit code: {result.returncode}", "output": output[:5000]}
579
 
580
+ # ==================== RESOURCES (with project scoping + ping) ====================
581
  RESOURCES_FILE = STATE_DIR / "resources.json"
582
+ PROJECT_RESOURCES_FILE = STATE_DIR / "project_resources.json"
583
+
584
+ RESOURCE_PING_TARGETS = {
585
+ "HF_TOKEN": ("https://huggingface.co/api/whoami", {"Authorization": "Bearer {value}"}),
586
+ "GITHUB_TOKEN": ("https://api.github.com/user", {"Authorization": "Bearer {value}"}),
587
+ "OPENAI_API_KEY": ("https://api.openai.com/v1/models", {"Authorization": "Bearer {value}"}),
588
+ "NVIDIA_NIM_API_KEY": ("https://integrate.api.nvidia.com/v1/models", {"Authorization": "Bearer {value}"}),
589
+ "OPENROUTER_API_KEY": ("https://openrouter.ai/api/v1/auth/key", {"Authorization": "Bearer {value}"}),
590
+ "GEMINI_API_KEY": (None, None),
591
+ "SUPABASE_URL": (None, None),
592
+ "SUPABASE_KEY": (None, None),
593
+ }
594
+
595
 
596
  def _load_resources() -> dict:
597
  if RESOURCES_FILE.exists():
598
  raw = json.loads(RESOURCES_FILE.read_text())
599
+ return {k: {"value": v.get("value", ""), "description": v.get("description", ""),
600
+ "scope": v.get("scope", "core"), "projects": v.get("projects", []),
601
+ "shareable": v.get("shareable", False), "created": v.get("created", time.time())}
602
+ for k, v in raw.items()}
603
  return {}
604
 
605
+
606
  def _save_resources(resources: dict):
607
  RESOURCES_FILE.write_text(json.dumps(resources, indent=2))
608
 
609
+
610
+ def _load_project_resources() -> dict:
611
+ if PROJECT_RESOURCES_FILE.exists():
612
+ return json.loads(PROJECT_RESOURCES_FILE.read_text())
613
+ return {}
614
+
615
+
616
+ def _save_project_resources(pr: dict):
617
+ PROJECT_RESOURCES_FILE.write_text(json.dumps(pr, indent=2))
618
+
619
+
620
+ async def _ping_resource(key: str, value: str) -> dict:
621
+ target_info = RESOURCE_PING_TARGETS.get(key)
622
+ if not target_info or not target_info[0]:
623
+ return {"status": "skipped", "reason": "No ping target configured for this resource type"}
624
+ url, headers_tmpl = target_info
625
+ headers = {k: v.format(value=value) for k, v in headers_tmpl.items()} if headers_tmpl else {}
626
+ try:
627
+ async with httpx.AsyncClient(timeout=10) as client:
628
+ resp = await client.get(url, headers=headers)
629
+ if resp.status_code < 500:
630
+ return {"status": "ok", "code": resp.status_code}
631
+ return {"status": "error", "code": resp.status_code, "body": resp.text[:200]}
632
+ except Exception as e:
633
+ return {"status": "error", "error": str(e)}
634
+
635
+
636
  @app.get("/api/resources")
637
+ async def list_resources(project: str = ""):
638
+ all_resources = _load_resources()
639
+ if project:
640
+ project_bindings = _load_project_resources().get(project, {})
641
+ bound_keys = set(project_bindings.keys())
642
+ result = {}
643
+ for k, v in all_resources.items():
644
+ if k in bound_keys:
645
+ result[k] = {**v, "bound": "project", "project_value": project_bindings[k].get("value", "")}
646
+ elif v.get("scope") == "core" or project in v.get("projects", []):
647
+ result[k] = {**v, "bound": "global"}
648
+ return {"credentials": result, "project": project}
649
+ return {"credentials": all_resources}
650
+
651
 
652
  @app.put("/api/resources/{key}")
653
  async def save_resource(key: str, body: dict):
 
655
  if not key or not value:
656
  raise HTTPException(400, "Key and value required")
657
  resources = _load_resources()
658
+ resources[key] = {
659
+ "value": value,
660
+ "description": body.get("description", ""),
661
+ "scope": body.get("scope", "core"),
662
+ "projects": body.get("projects", []),
663
+ "shareable": body.get("shareable", False),
664
+ "created": time.time(),
665
+ }
666
  _save_resources(resources)
 
667
  os.environ[key] = value
668
+ if key.endswith("_API_KEY"):
669
+ agent_subsystem.add_api_key(value)
670
+ ping_result = await _ping_resource(key, value)
671
+ return {"saved": key, "ping": ping_result}
672
+
673
+
674
+ @app.post("/api/resources/{key}/ping")
675
+ async def ping_resource(key: str):
676
+ resources = _load_resources()
677
+ entry = resources.get(key)
678
+ if not entry:
679
+ raise HTTPException(404, "Resource not found")
680
+ result = await _ping_resource(key, entry.get("value", ""))
681
+ return {"key": key, "ping": result}
682
+
683
+
684
+ @app.delete("/api/resources/{key}")
685
+ async def delete_resource(key: str):
686
+ resources = _load_resources()
687
+ if key not in resources:
688
+ raise HTTPException(404, "Resource not found")
689
+ del resources[key]
690
+ _save_resources(resources)
691
+ pr = _load_project_resources()
692
+ for proj in pr:
693
+ pr[proj].pop(key, None)
694
+ _save_project_resources(pr)
695
+ return {"deleted": key}
696
+
697
+
698
+ @app.get("/api/resources/projects")
699
+ async def list_project_resources_bindings():
700
+ return _load_project_resources()
701
+
702
+
703
+ @app.post("/api/resources/bind/{project}")
704
+ async def bind_resource_to_project(project: str, body: dict):
705
+ key = body.get("key", "")
706
+ value = body.get("value", "")
707
+ if not key or not value:
708
+ raise HTTPException(400, "Key and value required")
709
+ pr = _load_project_resources()
710
+ if project not in pr:
711
+ pr[project] = {}
712
+ pr[project][key] = {"value": value, "bound_at": time.time()}
713
+ _save_project_resources(pr)
714
+ resources = _load_resources()
715
+ if key in resources:
716
+ resources[key]["projects"] = list(set(resources[key].get("projects", []) + [project]))
717
+ _save_resources(resources)
718
+ return {"bound": key, "project": project}
719
+
720
+
721
+ @app.post("/api/resources/unbind/{project}")
722
+ async def unbind_resource_from_project(project: str, body: dict):
723
+ key = body.get("key", "")
724
+ if not key:
725
+ raise HTTPException(400, "Key required")
726
+ pr = _load_project_resources()
727
+ if project in pr and key in pr[project]:
728
+ del pr[project][key]
729
+ _save_project_resources(pr)
730
+ resources = _load_resources()
731
+ if key in resources:
732
+ projs = resources[key].get("projects", [])
733
+ if project in projs:
734
+ projs.remove(project)
735
+ resources[key]["projects"] = projs
736
+ _save_resources(resources)
737
+ return {"unbound": key, "project": project}
738
+ raise HTTPException(404, "Binding not found")
739
 
740
  # ==================== STATE ====================
741
  @app.get("/api/state")
 
787
  "filter_capacity": filter_ctx.capacity,
788
  "filter_threshold": filter_ctx.threshold,
789
  "parallel_max": engine.semaphore._value,
790
+ "multi_layer_throttle": multi_layer_throttle.to_dict(),
791
  }
792
 
793
  @app.post("/api/router/filter")
 
842
  agent_subsystem.add_api_key(key)
843
  return {"status": "key added"}
844
 
845
+ # ==================== SANDBOX (HF Spaces) ====================
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
846
  @app.get("/api/sandboxes")
847
  async def list_sandboxes():
848
+ local = [d.name for d in SANDBOX_DIR.iterdir() if d.is_dir()]
849
+ hf_sandboxes = await sandbox_manager.list_sandboxes()
850
+ return {"local": local, "hf_spaces": hf_sandboxes}
851
+
852
+
853
+ @app.post("/api/sandboxes/create")
854
+ async def create_sandbox(body: dict):
855
+ project = body.get("project", "").strip()
856
+ sandbox_name = body.get("sandbox_name", "").strip()
857
+ if not project:
858
+ raise HTTPException(400, "Project name required")
859
+ result = await sandbox_manager.create_sandbox_space(project, sandbox_name)
860
+ if result.get("status") == "error":
861
+ raise HTTPException(500, result.get("error", "Sandbox creation failed"))
862
+ return result
863
+
864
+
865
+ @app.post("/api/sandboxes/{project}/exec")
866
+ async def exec_sandbox(project: str, body: dict):
867
+ command = body.get("command", "")
868
+ env = body.get("env", {})
869
+ timeout = int(body.get("timeout", 60))
870
+ result = await sandbox_manager.exec_in_sandbox(project, command, env, timeout)
871
+ if result.get("status") == "error":
872
+ raise HTTPException(502, result.get("error", "Sandbox exec failed"))
873
+ return result
874
+
875
+
876
+ @app.post("/api/sandboxes/{project}/write")
877
+ async def write_sandbox(project: str, body: dict):
878
+ path = body.get("path", "")
879
+ content = body.get("content", "")
880
+ result = await sandbox_manager.write_to_sandbox(project, path, content)
881
+ if result.get("status") == "error":
882
+ raise HTTPException(502, result.get("error", "Sandbox write failed"))
883
+ return result
884
+
885
+
886
+ @app.post("/api/sandboxes/{project}/read")
887
+ async def read_sandbox(project: str, body: dict):
888
+ path = body.get("path", "")
889
+ result = await sandbox_manager.read_from_sandbox(project, path)
890
+ if result.get("status") == "error":
891
+ raise HTTPException(502, result.get("error", "Sandbox read failed"))
892
+ return result
893
+
894
+
895
+ @app.get("/api/sandboxes/{project}/files")
896
+ async def list_sandbox_files(project: str):
897
+ result = await sandbox_manager.list_sandbox_files(project)
898
+ if result.get("status") == "error":
899
+ raise HTTPException(502, result.get("error", "List files failed"))
900
+ return result
901
+
902
+
903
+ @app.delete("/api/sandboxes/{project}")
904
+ async def delete_sandbox(project: str):
905
+ result = await sandbox_manager.delete_sandbox_space(project)
906
+ if result.get("status") == "error":
907
+ raise HTTPException(500, result.get("error", "Sandbox deletion failed"))
908
+ return result
909
+
910
+
911
+ # ==================== MONITORING ====================
912
+ MANIFEST_FILE = Path("/tmp/altamira-manifest.json")
913
+
914
+
915
+ def _load_manifest() -> dict:
916
+ if MANIFEST_FILE.exists():
917
+ return json.loads(MANIFEST_FILE.read_text())
918
+ return {}
919
+
920
+
921
+ def _save_manifest(manifest: dict):
922
+ MANIFEST_FILE.write_text(json.dumps(manifest, indent=2))
923
+
924
+
925
+ @app.get("/api/monitor/manifest")
926
+ async def get_manifest(project: str = ""):
927
+ manifest = _load_manifest()
928
+ if project:
929
+ return {"manifest": manifest.get(project, {})}
930
+ return {"manifest": manifest}
931
+
932
+
933
+ @app.post("/api/monitor/manifest/{project}")
934
+ async def update_manifest(project: str, body: dict):
935
+ manifest = _load_manifest()
936
+ manifest[project] = {
937
+ "status": body.get("status", "unknown"),
938
+ "updated": time.time(),
939
+ "version": body.get("version", ""),
940
+ "message": body.get("message", ""),
941
+ "commit": body.get("commit", ""),
942
+ }
943
+ _save_manifest(manifest)
944
+ return {"saved": project, "manifest": manifest[project]}
945
+
946
+
947
+ @app.post("/api/monitor/heartbeat/{project}")
948
+ async def heartbeat(project: str, body: dict):
949
+ manifest = _load_manifest()
950
+ if project not in manifest:
951
+ manifest[project] = {}
952
+ manifest[project]["heartbeat"] = time.time()
953
+ manifest[project]["status"] = body.get("status", "running")
954
+ manifest[project]["message"] = body.get("message", "")
955
+ _save_manifest(manifest)
956
+ return {"pong": time.time(), "project": project}
957
+
958
+
959
+ @app.get("/api/monitor/summary")
960
+ async def monitor_summary():
961
+ manifest = _load_manifest()
962
+ result = {}
963
+ for proj, data in manifest.items():
964
+ result[proj] = {
965
+ "status": data.get("status", "unknown"),
966
+ "updated": data.get("updated", 0),
967
+ "heartbeat": data.get("heartbeat", 0),
968
+ "is_alive": (time.time() - data.get("heartbeat", 0)) < 60 if data.get("heartbeat") else False,
969
+ }
970
+ return {"projects": result}
971
+
972
+
973
+ @app.get("/api/monitor/throttle")
974
+ async def throttle_status():
975
+ return {"multi_layer": multi_layer_throttle.to_dict()}
976
+
977
+
978
+ # ==================== EXTERNAL TESTING ====================
979
+ ARTIFACTS_DIR = Path("/tmp/altamira-artifacts")
980
+ ARTIFACTS_DIR.mkdir(parents=True, exist_ok=True)
981
+
982
+
983
+ @app.post("/api/test/artifact/{project}")
984
+ async def upload_artifact(project: str, body: dict):
985
+ filename = body.get("filename", "artifact.zip")
986
+ data_b64 = body.get("data", "")
987
+ import base64
988
+ try:
989
+ data = base64.b64decode(data_b64)
990
+ except Exception:
991
+ raise HTTPException(400, "Invalid base64 data")
992
+ project_dir = ARTIFACTS_DIR / project
993
+ project_dir.mkdir(parents=True, exist_ok=True)
994
+ filepath = project_dir / filename
995
+ filepath.write_bytes(data)
996
+ return {"path": str(filepath), "size": len(data)}
997
+
998
+
999
+ @app.get("/api/test/artifact/{project}/{filename}")
1000
+ async def download_artifact(project: str, filename: str):
1001
+ filepath = ARTIFACTS_DIR / project / filename
1002
+ if not filepath.exists():
1003
+ raise HTTPException(404, "Artifact not found")
1004
+ import base64
1005
+ return {
1006
+ "filename": filename,
1007
+ "size": filepath.stat().st_size,
1008
+ "data": base64.b64encode(filepath.read_bytes()).decode(),
1009
+ }
1010
+
1011
+
1012
+ @app.get("/api/test/download-url/{project}")
1013
+ async def generate_download_url(project: str):
1014
+ project_dir = ARTIFACTS_DIR / project
1015
+ if not project_dir.exists():
1016
+ raise HTTPException(404, "No artifacts for project")
1017
+ files = [f.name for f in project_dir.iterdir() if f.is_file()]
1018
+ return {
1019
+ "project": project,
1020
+ "files": files,
1021
+ "download_url": f"/api/test/artifact/{project}/",
1022
+ "instructions": "Use the download URL above to retrieve build artifacts for local testing.",
1023
+ }
1024
+
1025
+
1026
+ @app.post("/api/test/webhook/{project}")
1027
+ async def trigger_webhook_test(project: str, body: dict):
1028
+ url = body.get("url", "")
1029
+ payload = body.get("payload", {})
1030
+ method = body.get("method", "POST")
1031
+ if not url:
1032
+ raise HTTPException(400, "Webhook URL required")
1033
+ resources = _load_resources()
1034
+ headers = {}
1035
+ for k, v in resources.items():
1036
+ if "WEBHOOK" in k.upper() or "API" in k.upper():
1037
+ headers[k.replace("_", "-").lower()] = v.get("value", "")
1038
+ try:
1039
+ async with httpx.AsyncClient(timeout=30) as client:
1040
+ if method.upper() == "GET":
1041
+ resp = await client.get(url, headers=headers)
1042
+ else:
1043
+ resp = await client.post(url, json=payload, headers=headers)
1044
+ return {
1045
+ "status": resp.status_code,
1046
+ "body": resp.text[:2000],
1047
+ "project": project,
1048
+ }
1049
+ except Exception as e:
1050
+ raise HTTPException(502, f"Webhook failed: {e}")
1051
+
1052
+
1053
+ @app.post("/api/test/github-actions/{project}")
1054
+ async def trigger_github_actions(project: str, body: dict):
1055
+ repo = body.get("repo", "")
1056
+ workflow = body.get("workflow", "main.yml")
1057
+ ref = body.get("ref", "main")
1058
+ if not repo:
1059
+ raise HTTPException(400, "GitHub repo required (owner/repo)")
1060
+ resources = _load_resources()
1061
+ gh_token = resources.get("GITHUB_TOKEN", {}).get("value", "") or resources.get("GH_TOKEN", {}).get("value", "")
1062
+ if not gh_token:
1063
+ raise HTTPException(400, "GitHub token required — add GH_TOKEN or GITHUB_TOKEN resource")
1064
+ try:
1065
+ async with httpx.AsyncClient(timeout=15) as client:
1066
+ resp = await client.post(
1067
+ f"https://api.github.com/repos/{repo}/actions/workflows/{workflow}/dispatches",
1068
+ headers={
1069
+ "Authorization": f"Bearer {gh_token}",
1070
+ "Accept": "application/vnd.github.v3+json",
1071
+ },
1072
+ json={"ref": ref, "inputs": body.get("inputs", {})},
1073
+ )
1074
+ if resp.status_code not in (204, 200, 201):
1075
+ raise HTTPException(502, f"GitHub Actions trigger failed: {resp.status_code} {resp.text[:200]}")
1076
+ return {"status": "triggered", "repo": repo, "workflow": workflow, "ref": ref}
1077
+ except HTTPException:
1078
+ raise
1079
+ except Exception as e:
1080
+ raise HTTPException(502, f"GitHub Actions trigger failed: {e}")
1081
 
1082
  # ==================== WORKER INTEGRATION ====================
1083
  # mount worker app as sub-app
 
1097
  @app.get("/api/system")
1098
  async def system_info():
1099
  import platform as _platform
1100
+ sandbox_count = len(await sandbox_manager.list_sandboxes())
1101
  return {
1102
  "platform": _platform.platform(),
1103
  "python": _platform.python_version(),
1104
  "hostname": os.uname().nodename,
1105
  "cpus": os.cpu_count(),
1106
+ "mode": "AADE",
1107
+ "sandbox_hf_spaces": sandbox_count,
1108
+ "sandbox_local": len(list(SANDBOX_DIR.iterdir())),
1109
  "workspace_count": len(list(WORKSPACE_DIR.iterdir())),
1110
  "disk_tmp": _disk_usage("/tmp"),
1111
+ "throttle": multi_layer_throttle.to_dict(),
1112
  }
1113
 
1114
+
1115
  def _disk_usage(path: str) -> dict:
1116
  s = os.statvfs(path)
1117
  return {
router.py CHANGED
@@ -1,6 +1,9 @@
1
  import asyncio
 
2
  import time
3
- from collections import deque
 
 
4
 
5
  import httpx
6
 
@@ -11,12 +14,229 @@ OPENROUTER_BASE = "https://openrouter.ai/api/v1"
11
  GEMINI_BASE = "https://generativelanguage.googleapis.com/v1beta"
12
 
13
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
14
  class CircuitBreakerState:
15
  CLOSED = "closed"
16
  OPEN = "open"
17
  HALF_OPEN = "half_open"
18
 
19
 
 
 
 
20
  class PredictiveContextFilter:
21
  def __init__(self, capacity: int = 4096, threshold: float = 0.8):
22
  self.capacity = capacity
@@ -89,6 +309,9 @@ def _summarize_lines(lines: list) -> str:
89
  return f" ... [{len(lines)} lines omitted: {', '.join(summary_parts)}] ..."
90
 
91
 
 
 
 
92
  class InferenceRouterCircuitBreaker:
93
  MAX_RETRIES = 3
94
  BLACKOUT_SECONDS = 60.0
@@ -97,6 +320,7 @@ class InferenceRouterCircuitBreaker:
97
  self.state = CircuitBreakerState.CLOSED
98
  self.failure_count = 0
99
  self.blackout_until = 0.0
 
100
 
101
  def _exponential_backoff(self, attempt: int) -> float:
102
  return float(2 ** attempt)
@@ -153,34 +377,49 @@ class InferenceRouterCircuitBreaker:
153
  return PAUSED_RETRY
154
  self.state = CircuitBreakerState.HALF_OPEN
155
 
156
- providers = [
157
- (self._call_nvidia, nvidia_key),
158
- ]
 
 
159
  if openrouter_key:
160
- providers.append((self._call_openrouter, openrouter_key))
 
161
  if gemini_key:
162
- providers.append((self._call_gemini, gemini_key))
 
163
 
164
- last_error = None
165
- for attempt in range(self.MAX_RETRIES):
166
- for fn, key in providers:
167
- if not key:
168
- continue
169
- try:
170
- result = await fn(prompt, key)
171
- self.record_success()
172
- return result
173
- except _RateLimitError:
174
- last_error = "rate_limited"
175
- continue
176
- except Exception as e:
177
- last_error = str(e)
178
- continue
179
- backoff = self._exponential_backoff(attempt)
180
- await asyncio.sleep(backoff)
181
 
182
- self.record_failure()
183
- return PAUSED_RETRY
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
184
 
185
 
186
  class _RateLimitError(Exception):
 
1
  import asyncio
2
+ import json
3
  import time
4
+ import uuid
5
+ from collections import deque, defaultdict
6
+ from typing import Optional, Callable, Any
7
 
8
  import httpx
9
 
 
14
  GEMINI_BASE = "https://generativelanguage.googleapis.com/v1beta"
15
 
16
 
17
+ # ──────────────────────────────────────────────
18
+ # Layer 1: Decentralized Self-Throttle
19
+ # Agentes controlam seu próprio RPM conforme
20
+ # regras do system prompt + retry local.
21
+ # ──────────────────────────────────────────────
22
+ class DecentralizedThrottle:
23
+ def __init__(self, rpm_limit: int = 40, window: int = 60):
24
+ self.rpm_limit = rpm_limit
25
+ self.window = window
26
+ self._slots: list[float] = []
27
+ self._consecutive_429s = 0
28
+ self._escalated = False
29
+
30
+ def acquire(self) -> float:
31
+ now = time.time()
32
+ cutoff = now - self.window
33
+ self._slots = [t for t in self._slots if t > cutoff]
34
+ if len(self._slots) >= self.rpm_limit:
35
+ wait = self._slots[0] + self.window - now
36
+ if wait > 0:
37
+ return wait
38
+ self._slots.append(now)
39
+ return 0.0
40
+
41
+ async def wait_if_needed(self):
42
+ wait = self.acquire()
43
+ if wait > 0:
44
+ await asyncio.sleep(wait)
45
+
46
+ def record_429(self):
47
+ self._consecutive_429s += 1
48
+ if self._consecutive_429s >= 3:
49
+ self._escalated = True
50
+
51
+ def record_success(self):
52
+ self._consecutive_429s = 0
53
+
54
+ @property
55
+ def should_escalate(self) -> bool:
56
+ return self._escalated
57
+
58
+ def reset_escalation(self):
59
+ self._escalated = False
60
+
61
+ def usage_pct(self) -> float:
62
+ now = time.time()
63
+ cutoff = now - self.window
64
+ active = sum(1 for t in self._slots if t > cutoff)
65
+ return (active / self.rpm_limit) * 100 if self.rpm_limit else 0
66
+
67
+ def to_dict(self) -> dict:
68
+ return {
69
+ "rpm_limit": self.rpm_limit,
70
+ "current_rpm": len([t for t in self._slots if t > time.time() - self.window]),
71
+ "usage_pct": round(self.usage_pct(), 1),
72
+ "consecutive_429s": self._consecutive_429s,
73
+ "escalated": self._escalated,
74
+ }
75
+
76
+
77
+ # ──────────────────────────────────────────────
78
+ # Layer 2: Centralized Queue
79
+ # Quando Layer 1 escala (muitos 429s),
80
+ # as requisicoes passam por um dispatcher
81
+ # central que libera uma por vez.
82
+ # ──────────────────────────────────────────────
83
+ class CentralizedQueue:
84
+ def __init__(self, max_concurrent: int = 1):
85
+ self._queue: asyncio.Queue = asyncio.Queue()
86
+ self._active = 0
87
+ self._max_concurrent = max_concurrent
88
+ self._lock = asyncio.Lock()
89
+ self._total_queued = 0
90
+ self._total_processed = 0
91
+
92
+ async def enqueue(self, request_id: str, coro: Callable) -> Any:
93
+ self._total_queued += 1
94
+ future = asyncio.get_event_loop().create_future()
95
+ await self._queue.put((request_id, coro, future))
96
+ await self._process_queue()
97
+ return await future
98
+
99
+ async def _process_queue(self):
100
+ async with self._lock:
101
+ if self._active >= self._max_concurrent:
102
+ return
103
+ self._active += 1
104
+
105
+ try:
106
+ while not self._queue.empty():
107
+ request_id, coro, future = await self._queue.get()
108
+ try:
109
+ result = await coro()
110
+ future.set_result(result)
111
+ self._total_processed += 1
112
+ except Exception as e:
113
+ future.set_exception(e)
114
+ finally:
115
+ async with self._lock:
116
+ self._active -= 1
117
+
118
+ def to_dict(self) -> dict:
119
+ return {
120
+ "queue_size": self._queue.qsize(),
121
+ "active": self._active,
122
+ "max_concurrent": self._max_concurrent,
123
+ "total_queued": self._total_queued,
124
+ "total_processed": self._total_processed,
125
+ }
126
+
127
+
128
+ # ──────────────────────────────────────────────
129
+ # Layer 3: Multi-Provider Failover
130
+ # Se um provider esgota cota, automaticamente
131
+ # troca para backup e reinjeta estado da sessao.
132
+ # ──────────────────────────────────────────────
133
+ class ProviderSessionState:
134
+ def __init__(self):
135
+ self._sessions: dict[str, list[dict]] = defaultdict(list)
136
+
137
+ def append(self, provider: str, entry: dict):
138
+ self._sessions[provider].append(entry)
139
+ if len(self._sessions[provider]) > 50:
140
+ self._sessions[provider] = self._sessions[provider][-50:]
141
+
142
+ def get_context(self, provider: str, max_entries: int = 5) -> str:
143
+ entries = self._sessions.get(provider, [])[-max_entries:]
144
+ if not entries:
145
+ return ""
146
+ lines = [f"{e.get('role', 'user')}: {e.get('content', '')[:200]}" for e in entries]
147
+ return "\n".join(lines)
148
+
149
+ def to_dict(self) -> dict:
150
+ return {k: len(v) for k, v in self._sessions.items()}
151
+
152
+
153
+ class ProviderFailover:
154
+ def __init__(self, session_state: Optional[ProviderSessionState] = None):
155
+ self._session = session_state or ProviderSessionState()
156
+ self._provider_quotas: dict[str, int] = defaultdict(lambda: 100)
157
+ self._provider_usage: dict[str, int] = defaultdict(int)
158
+
159
+ def set_quota(self, provider: str, quota: int):
160
+ self._provider_quotas[provider] = quota
161
+
162
+ def is_exhausted(self, provider: str) -> bool:
163
+ return self._provider_usage[provider] >= self._provider_quotas[provider]
164
+
165
+ def record_usage(self, provider: str):
166
+ self._provider_usage[provider] += 1
167
+
168
+ def get_failover_chain(self, available_providers: list[str]) -> list[str]:
169
+ return [p for p in available_providers if not self.is_exhausted(p)]
170
+
171
+ def to_dict(self) -> dict:
172
+ return {
173
+ "quotas": dict(self._provider_quotas),
174
+ "usage": dict(self._provider_usage),
175
+ "sessions": self._session.to_dict(),
176
+ }
177
+
178
+
179
+ # ──────────────────────────────────────────────
180
+ # Orquestrador Multi-Camada
181
+ # ──────────────────────────────────────────────
182
+ class MultiLayerThrottle:
183
+ def __init__(self):
184
+ self.layer1 = DecentralizedThrottle()
185
+ self.layer2 = CentralizedQueue()
186
+ self.layer3 = ProviderFailover()
187
+ self._active_layer = 1
188
+
189
+ async def execute(self, request_id: str, provider_fn: Callable,
190
+ available_providers: list[str]) -> str:
191
+ if self.layer1.should_escalate:
192
+ self._active_layer = 2
193
+ self.layer1.reset_escalation()
194
+ return await self.layer2.enqueue(request_id, provider_fn)
195
+
196
+ await self.layer1.wait_if_needed()
197
+ chain = self.layer3.get_failover_chain(available_providers)
198
+ if not chain and available_providers:
199
+ self._active_layer = 3
200
+ for p in available_providers:
201
+ self.layer3.set_quota(p, self.layer3._provider_quotas[p] + 50)
202
+ chain = available_providers
203
+
204
+ last_error = None
205
+ for provider in chain:
206
+ try:
207
+ result = await provider_fn(provider)
208
+ self.layer1.record_success()
209
+ self._active_layer = 1
210
+ return result
211
+ except Exception as e:
212
+ self.layer3.record_usage(provider)
213
+ last_error = e
214
+ continue
215
+
216
+ self.layer1.record_429()
217
+ return PAUSED_RETRY
218
+
219
+ def to_dict(self) -> dict:
220
+ return {
221
+ "active_layer": self._active_layer,
222
+ "layer1": self.layer1.to_dict(),
223
+ "layer2": self.layer2.to_dict(),
224
+ "layer3": self.layer3.to_dict(),
225
+ }
226
+
227
+
228
+ # ──────────────────────────────────────────────
229
+ # Circuit Breaker State
230
+ # ──────────────────────────────────────────────
231
  class CircuitBreakerState:
232
  CLOSED = "closed"
233
  OPEN = "open"
234
  HALF_OPEN = "half_open"
235
 
236
 
237
+ # ──────────────────────────────────────────────
238
+ # Predictive Context Filter (existing)
239
+ # ──────────────────────────────────────────────
240
  class PredictiveContextFilter:
241
  def __init__(self, capacity: int = 4096, threshold: float = 0.8):
242
  self.capacity = capacity
 
309
  return f" ... [{len(lines)} lines omitted: {', '.join(summary_parts)}] ..."
310
 
311
 
312
+ # ──────────────────────────────────────────────
313
+ # Inference Router Circuit Breaker (updated)
314
+ # ──────────────────────────────────────────────
315
  class InferenceRouterCircuitBreaker:
316
  MAX_RETRIES = 3
317
  BLACKOUT_SECONDS = 60.0
 
320
  self.state = CircuitBreakerState.CLOSED
321
  self.failure_count = 0
322
  self.blackout_until = 0.0
323
+ self.multi_layer = MultiLayerThrottle()
324
 
325
  def _exponential_backoff(self, attempt: int) -> float:
326
  return float(2 ** attempt)
 
377
  return PAUSED_RETRY
378
  self.state = CircuitBreakerState.HALF_OPEN
379
 
380
+ providers_list = []
381
+ provider_map = {}
382
+ if nvidia_key:
383
+ providers_list.append("nvidia")
384
+ provider_map["nvidia"] = (self._call_nvidia, nvidia_key)
385
  if openrouter_key:
386
+ providers_list.append("openrouter")
387
+ provider_map["openrouter"] = (self._call_openrouter, openrouter_key)
388
  if gemini_key:
389
+ providers_list.append("gemini")
390
+ provider_map["gemini"] = (self._call_gemini, gemini_key)
391
 
392
+ if not providers_list:
393
+ return PAUSED_RETRY
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
394
 
395
+ request_id = f"req-{uuid.uuid4().hex[:8]}"
396
+
397
+ async def try_providers() -> str:
398
+ for attempt in range(self.MAX_RETRIES):
399
+ chain = self.multi_layer.layer3.get_failover_chain(providers_list)
400
+ if not chain:
401
+ chain = providers_list
402
+ for provider_name in chain:
403
+ fn, key = provider_map[provider_name]
404
+ try:
405
+ result = await fn(prompt, key)
406
+ self.record_success()
407
+ self.multi_layer.layer3._session.append(provider_name, {"role": "assistant", "content": result[:200]})
408
+ return result
409
+ except _RateLimitError:
410
+ self.multi_layer.layer3.record_usage(provider_name)
411
+ self.multi_layer.layer1.record_429()
412
+ continue
413
+ except Exception as e:
414
+ self.multi_layer.layer3.record_usage(provider_name)
415
+ continue
416
+ backoff = self._exponential_backoff(attempt)
417
+ await asyncio.sleep(backoff)
418
+ self.record_failure()
419
+ return PAUSED_RETRY
420
+
421
+ result = await self.multi_layer.execute(request_id, try_providers, providers_list)
422
+ return result
423
 
424
 
425
  class _RateLimitError(Exception):
sandbox_manager.py ADDED
@@ -0,0 +1,291 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
+ import json
3
+ import logging
4
+ import os
5
+ import shutil
6
+ import subprocess
7
+ import tempfile
8
+ import time
9
+ import uuid
10
+ from pathlib import Path
11
+ from typing import Optional
12
+
13
+ from huggingface_hub import HfApi
14
+
15
+ log = logging.getLogger("altamira.sandbox")
16
+
17
+ SANDBOX_TEMPLATE = '''import os, json, subprocess, sys
18
+ from pathlib import Path
19
+ from fastapi import FastAPI, HTTPException, Request
20
+ from fastapi.responses import JSONResponse
21
+ import uvicorn
22
+
23
+ app = FastAPI(title="Altamira Sandbox")
24
+
25
+ WORKSPACE = Path("/workspace")
26
+ WORKSPACE.mkdir(parents=True, exist_ok=True)
27
+
28
+ @app.post("/exec")
29
+ async def exec_command(body: dict):
30
+ command = body.get("command", "")
31
+ timeout = int(body.get("timeout", 30))
32
+ env = {**os.environ}
33
+ extra_env = body.get("env", {})
34
+ env.update(extra_env)
35
+ try:
36
+ result = subprocess.run(
37
+ command, shell=True, capture_output=True, text=True,
38
+ timeout=timeout, cwd=str(WORKSPACE), env=env,
39
+ )
40
+ return {"stdout": result.stdout, "stderr": result.stderr, "returncode": result.returncode}
41
+ except subprocess.TimeoutExpired:
42
+ raise HTTPException(408, "Command timed out")
43
+ except Exception as e:
44
+ raise HTTPException(500, str(e))
45
+
46
+ @app.post("/write")
47
+ async def write_file(body: dict):
48
+ path = WORKSPACE / body.get("path", "")
49
+ path.parent.mkdir(parents=True, exist_ok=True)
50
+ path.write_text(body.get("content", ""))
51
+ return {"path": str(path), "size": len(body.get("content", ""))}
52
+
53
+ @app.post("/read")
54
+ async def read_file(body: dict):
55
+ path = WORKSPACE / body.get("path", "")
56
+ if not path.exists():
57
+ raise HTTPException(404)
58
+ return {"path": str(path), "content": path.read_text()}
59
+
60
+ @app.post("/files")
61
+ async def list_files():
62
+ files = []
63
+ for f in WORKSPACE.rglob("*"):
64
+ if f.is_file():
65
+ files.append({"name": str(f.relative_to(WORKSPACE)), "size": f.stat().st_size})
66
+ return {"files": files}
67
+
68
+ @app.get("/health")
69
+ async def health():
70
+ return {"status": "healthy", "sandbox": True}
71
+
72
+ if __name__ == "__main__":
73
+ uvicorn.run(app, host="0.0.0.0", port=7860)
74
+ '''
75
+
76
+
77
+ class SandboxManager:
78
+ def __init__(self, hf_token: str = ""):
79
+ self._hf_token = hf_token or os.environ.get("HF_TOKEN", "")
80
+ self._api = HfApi()
81
+ self._sandbox_registry: dict[str, dict] = {}
82
+ self._registry_file = Path("/tmp/altamira-sandbox-registry.json")
83
+ self._load_registry()
84
+
85
+ def _load_registry(self):
86
+ if self._registry_file.exists():
87
+ try:
88
+ self._sandbox_registry = json.loads(self._registry_file.read_text())
89
+ except Exception:
90
+ self._sandbox_registry = {}
91
+
92
+ def _save_registry(self):
93
+ self._registry_file.parent.mkdir(parents=True, exist_ok=True)
94
+ self._registry_file.write_text(json.dumps(self._sandbox_registry, indent=2))
95
+
96
+ async def create_sandbox_space(
97
+ self,
98
+ project_name: str,
99
+ sandbox_name: str = "",
100
+ hf_token: str = "",
101
+ ) -> dict:
102
+ token = hf_token or self._hf_token
103
+ if not token:
104
+ return {"status": "error", "error": "HF token required to create sandbox Spaces"}
105
+
106
+ if not sandbox_name:
107
+ safe_name = project_name.lower().replace(" ", "-").replace("_", "-")
108
+ sandbox_name = f"altamira-sandbox-{safe_name}-{uuid.uuid4().hex[:6]}"
109
+
110
+ username = self._get_username(token)
111
+ if not username:
112
+ return {"status": "error", "error": "Could not determine HF username"}
113
+
114
+ repo_id = f"{username}/{sandbox_name}"
115
+ try:
116
+ self._api.create_repo(
117
+ repo_id=repo_id,
118
+ repo_type="space",
119
+ token=token,
120
+ private=False,
121
+ space_sdk="docker",
122
+ )
123
+ except Exception as e:
124
+ if "already exists" in str(e).lower():
125
+ pass
126
+ else:
127
+ return {"status": "error", "error": f"Failed to create space: {e}"}
128
+
129
+ try:
130
+ dockerfile_content = f"""FROM python:3.9
131
+ WORKDIR /code
132
+ RUN pip install fastapi uvicorn httpx
133
+ COPY sandbox_server.py /code/sandbox_server.py
134
+ CMD ["python", "sandbox_server.py"]
135
+ """
136
+ server_content = SANDBOX_TEMPLATE
137
+
138
+ with tempfile.TemporaryDirectory() as tmpdir:
139
+ tmp_path = Path(tmpdir)
140
+ (tmp_path / "Dockerfile").write_text(dockerfile_content)
141
+ (tmp_path / "sandbox_server.py").write_text(server_content)
142
+ (tmp_path / "README.md").write_text(
143
+ f"---\ntitle: {sandbox_name}\nemojj: 🔵\nsdk: docker\npinned: false\n---\n\nAltamira sandbox for project: {project_name}"
144
+ )
145
+
146
+ self._api.upload_file(
147
+ path_or_fileobj=str(tmp_path / "Dockerfile"),
148
+ path_in_repo="Dockerfile",
149
+ repo_id=repo_id,
150
+ repo_type="space",
151
+ token=token,
152
+ )
153
+ self._api.upload_file(
154
+ path_or_fileobj=str(tmp_path / "sandbox_server.py"),
155
+ path_in_repo="sandbox_server.py",
156
+ repo_id=repo_id,
157
+ repo_type="space",
158
+ token=token,
159
+ )
160
+ self._api.upload_file(
161
+ path_or_fileobj=str(tmp_path / "README.md"),
162
+ path_in_repo="README.md",
163
+ repo_id=repo_id,
164
+ repo_type="space",
165
+ token=token,
166
+ )
167
+ except Exception as e:
168
+ return {"status": "error", "error": f"Failed to upload files: {e}"}
169
+
170
+ entry = {
171
+ "project": project_name,
172
+ "sandbox_name": sandbox_name,
173
+ "repo_id": repo_id,
174
+ "created": time.time(),
175
+ "status": "created",
176
+ "url": f"https://huggingface.co/spaces/{repo_id}",
177
+ "endpoint": f"https://{repo_id.replace('/', '-')}.hf.space",
178
+ }
179
+ self._sandbox_registry[project_name] = entry
180
+ self._save_registry()
181
+ return {"status": "created", **entry}
182
+
183
+ async def delete_sandbox_space(self, project_name: str, hf_token: str = "") -> dict:
184
+ token = hf_token or self._hf_token
185
+ entry = self._sandbox_registry.get(project_name)
186
+ if not entry:
187
+ return {"status": "error", "error": f"No sandbox for project: {project_name}"}
188
+
189
+ repo_id = entry["repo_id"]
190
+ try:
191
+ self._api.delete_repo(repo_id=repo_id, repo_type="space", token=token)
192
+ except Exception as e:
193
+ return {"status": "error", "error": f"Failed to delete space: {e}"}
194
+
195
+ del self._sandbox_registry[project_name]
196
+ self._save_registry()
197
+ return {"status": "deleted", "repo_id": repo_id}
198
+
199
+ async def get_sandbox_status(self, project_name: str) -> Optional[dict]:
200
+ return self._sandbox_registry.get(project_name)
201
+
202
+ async def list_sandboxes(self) -> dict:
203
+ return dict(self._sandbox_registry)
204
+
205
+ async def exec_in_sandbox(
206
+ self,
207
+ project_name: str,
208
+ command: str,
209
+ env: Optional[dict] = None,
210
+ timeout: int = 60,
211
+ ) -> dict:
212
+ entry = self._sandbox_registry.get(project_name)
213
+ if not entry:
214
+ return {"status": "error", "error": f"No sandbox for project: {project_name}"}
215
+
216
+ endpoint = entry["endpoint"]
217
+ import httpx
218
+
219
+ try:
220
+ async with httpx.AsyncClient(timeout=timeout) as client:
221
+ resp = await client.post(
222
+ f"{endpoint}/exec",
223
+ json={"command": command, "timeout": timeout, "env": env or {}},
224
+ )
225
+ if resp.status_code != 200:
226
+ return {"status": "error", "error": f"Sandbox returned {resp.status_code}: {resp.text}"}
227
+ return {"status": "ok", **resp.json()}
228
+ except Exception as e:
229
+ return {"status": "error", "error": f"Sandbox connection failed: {e}"}
230
+
231
+ async def write_to_sandbox(self, project_name: str, path: str, content: str) -> dict:
232
+ entry = self._sandbox_registry.get(project_name)
233
+ if not entry:
234
+ return {"status": "error", "error": f"No sandbox for project: {project_name}"}
235
+
236
+ import httpx
237
+
238
+ try:
239
+ async with httpx.AsyncClient(timeout=30) as client:
240
+ resp = await client.post(
241
+ f"{entry['endpoint']}/write",
242
+ json={"path": path, "content": content},
243
+ )
244
+ if resp.status_code != 200:
245
+ return {"status": "error", "error": f"Write failed: {resp.text}"}
246
+ return {"status": "ok", **resp.json()}
247
+ except Exception as e:
248
+ return {"status": "error", "error": f"Sandbox write failed: {e}"}
249
+
250
+ async def read_from_sandbox(self, project_name: str, path: str) -> dict:
251
+ entry = self._sandbox_registry.get(project_name)
252
+ if not entry:
253
+ return {"status": "error", "error": f"No sandbox for project: {project_name}"}
254
+
255
+ import httpx
256
+
257
+ try:
258
+ async with httpx.AsyncClient(timeout=30) as client:
259
+ resp = await client.post(
260
+ f"{entry['endpoint']}/read",
261
+ json={"path": path},
262
+ )
263
+ if resp.status_code != 200:
264
+ return {"status": "error", "error": f"Read failed: {resp.text}"}
265
+ return {"status": "ok", **resp.json()}
266
+ except Exception as e:
267
+ return {"status": "error", "error": f"Sandbox read failed: {e}"}
268
+
269
+ async def list_sandbox_files(self, project_name: str) -> dict:
270
+ entry = self._sandbox_registry.get(project_name)
271
+ if not entry:
272
+ return {"status": "error", "error": f"No sandbox for project: {project_name}"}
273
+
274
+ import httpx
275
+
276
+ try:
277
+ async with httpx.AsyncClient(timeout=30) as client:
278
+ endpoint = entry["endpoint"]
279
+ resp = await client.get(f"{endpoint}/files")
280
+ if resp.status_code != 200:
281
+ return {"status": "error", "error": f"List files failed: {resp.text}"}
282
+ return {"status": "ok", **resp.json()}
283
+ except Exception as e:
284
+ return {"status": "error", "error": f"Sandbox list files failed: {e}"}
285
+
286
+ def _get_username(self, token: str) -> Optional[str]:
287
+ try:
288
+ who = self._api.whoami(token=token)
289
+ return who.get("name", who.get("login", ""))
290
+ except Exception:
291
+ return None
templates/index.html CHANGED
@@ -1,537 +1,612 @@
1
  <!DOCTYPE html>
2
- <html lang="en">
3
  <head>
4
- <meta charset="UTF-8">
5
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
- <title>Altamira</title>
7
- <script src="https://cdn.tailwindcss.com"></script>
8
- <script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
9
- <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
10
- <style>
11
- body { background: #0B0D0F; color: #E2E8F0; font-family: system-ui, -apple-system, sans-serif; }
12
- .panel { background: #14171B; border: 1px solid rgba(255,255,255,0.08); border-radius: 12px; padding: 1rem; }
13
- .aperture { position: fixed; top: 16px; left: 50%; transform: translateX(-50%); z-index: 999; background: #14171B; border: 1px solid rgba(255,255,255,0.08); border-radius: 50%; width: 48px; height: 48px; display: flex; justify-content: center; align-items: center; cursor: pointer; transition: all 0.3s cubic-bezier(0.25,0.8,0.25,1); }
14
- .aperture:hover { border-radius: 12px; width: max-content; min-width: 140px; height: auto; padding: 10px 14px 8px; flex-direction: column; align-items: stretch; }
15
- .aperture:hover .ap-dropdown { display: grid; }
16
- .ap-dropdown { display: none; grid-template-columns: 1fr 1fr; gap: 6px; margin-top: 6px; padding-top: 6px; border-top: 1px solid rgba(255,255,255,0.05); }
17
- .ap-icon { display: flex; flex-direction: column; align-items: center; gap: 4px; padding: 8px; border-radius: 8px; cursor: pointer; font-size: 11px; color: #94A3B8; transition: background 0.15s; }
18
- .ap-icon:hover { background: rgba(255,255,255,0.06); color: #E2E8F0; }
19
- .ap-icon i { font-size: 18px; }
20
- .modal-overlay { position: fixed; inset: 0; background: rgba(11,13,15,0.85); z-index: 1000; display: flex; justify-content: center; align-items: center; }
21
- .modal-window { background: #14171B; border: 1px solid rgba(255,255,255,0.08); border-radius: 12px; padding: 1.5rem; width: 560px; max-width: 92%; max-height: 85vh; overflow-y: auto; box-shadow: 0 10px 25px rgba(0,0,0,0.5); }
22
- .card { background: #14171B; border: 1px solid rgba(255,255,255,0.08); border-radius: 12px; padding: 1rem; transition: border-color 0.15s; }
23
- .card:hover { border-color: rgba(255,255,255,0.15); }
24
- .term-line { font-family: 'SF Mono','Fira Code','Courier New',monospace; font-size: 12px; line-height: 1.6; }
25
- ::-webkit-scrollbar { width: 4px; }
26
- ::-webkit-scrollbar-track { background: transparent; }
27
- ::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.1); border-radius: 2px; }
28
- select, input, textarea { background: #1E293B; border: 1px solid rgba(255,255,255,0.08); border-radius: 8px; padding: 8px 12px; font-size: 13px; color: #E2E8F0; outline: none; }
29
- select:focus, input:focus, textarea:focus { border-color: rgba(99,102,241,0.5); }
30
- button { transition: all 0.15s; }
31
- </style>
32
  </head>
33
- <body>
34
-
35
- <div x-data="app()" class="min-h-screen">
36
-
37
- <!-- ========== ONBOARDING (no nav) ========== -->
38
- <div x-show="screen === 'onboarding'" class="flex items-center justify-center min-h-screen px-4">
39
- <div class="w-full max-w-sm panel text-center">
40
- <div class="w-12 h-12 rounded-xl bg-indigo-600 flex items-center justify-center text-xl font-bold mx-auto mb-4">A</div>
41
- <h1 class="text-xl font-bold text-indigo-400 mb-1">Altamira</h1>
42
- <p class="text-xs text-gray-500 mb-6">Sign in to continue</p>
43
- <button @click="oauthLogin()" class="w-full bg-indigo-600 hover:bg-indigo-500 text-white rounded-lg px-4 py-3 text-sm font-medium flex items-center justify-center gap-3">
44
- <i class="fa-regular fa-face-smile text-lg"></i> Login with Hugging Face
45
- </button>
46
- </div>
47
  </div>
 
 
 
 
 
 
 
 
48
 
49
- <!-- ========== APERTURE NAV (only on aade / settings) ========== -->
50
- <div x-show="screen !== 'onboarding'" class="aperture" x-bind:title="'navigate'">
51
- <i class="fas fa-circle text-xs" :class="screen === 'aade' ? 'text-indigo-400' : 'text-gray-600'"></i>
52
- <div class="ap-dropdown">
53
- <div class="col-span-2 text-center text-xs text-gray-500 truncate px-2" x-text="sessionUser"></div>
54
- <template x-for="(item, key) in navTargets" :key="key">
55
- <div @click="navigateTo(key)" class="ap-icon" x-show="key !== screen" x-bind:title="item.label">
56
- <i :class="item.icon"></i>
57
- <span x-text="item.label"></span>
58
- </div>
59
- </template>
60
- <div @click="logout()" class="ap-icon col-span-2" title="Logout">
61
- <i class="fas fa-sign-out-alt"></i>
62
- <span>Logout</span>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
63
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
64
  </div>
65
- </div>
66
-
67
- <!-- ========== AADE WORKSPACE ========== -->
68
- <div x-show="screen === 'aade'" class="max-w-7xl mx-auto px-4 pt-20 pb-6">
69
- <div class="grid grid-cols-1 lg:grid-cols-2 gap-4" style="min-height: calc(100vh - 120px);">
70
-
71
- <!-- LEFT: Agent Console -->
72
- <div class="panel flex flex-col">
73
- <div class="flex items-center justify-between mb-3">
74
- <h2 class="text-sm font-semibold text-indigo-300"><i class="fas fa-robot mr-2"></i>Agent Console</h2>
75
- </div>
76
-
77
- <!-- chat messages -->
78
- <div class="flex-1 overflow-y-auto space-y-3 mb-3 text-sm" x-ref="chatBox" style="min-height: 0; max-height: calc(100vh - 280px);">
79
- <template x-for="(msg, i) in chatMessages" :key="i">
80
- <div>
81
- <div x-show="msg.role === 'user'" class="flex justify-end">
82
- <div class="bg-indigo-900/40 text-indigo-200 rounded-2xl rounded-br-sm px-4 py-2 max-w-md"><span x-text="msg.content"></span></div>
83
- </div>
84
- <div x-show="msg.role === 'agent'" class="flex justify-start">
85
- <div class="bg-gray-800 text-gray-200 rounded-2xl rounded-bl-sm px-4 py-2 max-w-md"><span x-text="msg.content"></span></div>
86
- </div>
87
- <div x-show="msg.role === 'output'" class="flex justify-start ml-4">
88
- <pre class="bg-gray-900 text-gray-400 rounded-lg px-3 py-2 max-w-lg text-xs overflow-x-auto whitespace-pre-wrap term-line"><span x-text="msg.content"></span></pre>
89
- </div>
90
- <div x-show="msg.role === 'error'" class="flex justify-start">
91
- <div class="bg-red-900/30 text-red-300 rounded-xl px-4 py-2 max-w-md text-sm"><span x-text="msg.content"></span></div>
92
- </div>
93
- <div x-show="msg.role === 'system'" class="text-center"><span class="text-xs text-gray-600 italic" x-text="msg.content"></span></div>
94
- </div>
95
- </template>
96
- <div x-show="chatMessages.length === 0" class="text-gray-600 text-center py-12 text-xs">Agent ready. Describe what to build or run.</div>
97
- <div x-show="chatLoading" class="flex justify-start"><div class="bg-gray-800 text-gray-400 rounded-2xl rounded-bl-sm px-4 py-2 text-sm"><i class="fas fa-spinner fa-spin mr-2"></i>Thinking...</div></div>
98
- </div>
99
-
100
- <!-- chat input -->
101
- <div class="flex gap-2 pt-2 border-t border-white/5">
102
- <div class="relative flex-1">
103
- <i class="fas fa-robot absolute left-3 top-2.5 text-gray-600 text-xs"></i>
104
- <input x-model="chatInput" @keydown.enter="sendChat()" class="w-full pl-8 pr-3 py-2 text-sm" placeholder="Ask the agent...">
105
- </div>
106
- <button @click="sendChat()" class="bg-indigo-600 hover:bg-indigo-500 text-white px-3 py-2 rounded-lg text-sm"><i class="fas fa-paper-plane"></i></button>
107
- </div>
108
  </div>
109
-
110
- <!-- RIGHT: Project Fleet -->
111
- <div class="panel flex flex-col">
112
- <div class="flex items-center justify-between mb-3">
113
- <h2 class="text-sm font-semibold text-indigo-300"><i class="fas fa-layer-group mr-2"></i>Project Fleet</h2>
114
- <div class="flex gap-2">
115
- <button @click="showAddProject = true" class="text-xs bg-indigo-600 hover:bg-indigo-500 text-white px-3 py-1.5 rounded-lg"><i class="fas fa-plus mr-1"></i>New</button>
116
- <button @click="loadProjects()" class="text-xs bg-gray-800 hover:bg-gray-700 text-gray-300 px-3 py-1.5 rounded-lg"><i class="fas fa-sync"></i></button>
117
- </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
118
  </div>
119
-
120
- <!-- new project inline form -->
121
- <div x-show="showAddProject" class="mb-3 p-3 bg-gray-900 rounded-lg space-y-2 text-sm">
122
- <input x-model="newProjectName" class="w-full" placeholder="Project name">
123
- <input x-model="newProjectRepo" class="w-full font-mono" placeholder="GitHub repo URL">
124
- <div class="flex gap-2">
125
- <button @click="createProject(); showAddProject = false" class="bg-indigo-600 hover:bg-indigo-500 text-white px-3 py-1.5 rounded-lg text-xs">Create</button>
126
- <button @click="showAddProject = false" class="text-xs text-gray-500">Cancel</button>
127
- </div>
128
  </div>
129
-
130
- <!-- project cards -->
131
- <div class="flex-1 space-y-2 overflow-y-auto" style="min-height: 0;">
132
- <template x-for="(proj, name) in projects" :key="name">
133
- <div class="card">
134
- <div class="flex items-center justify-between">
135
- <div class="flex items-center gap-2 min-w-0">
136
- <div class="w-2 h-2 rounded-full flex-shrink-0" :class="proj.active ? 'bg-green-500' : 'bg-gray-600'"></div>
137
- <span class="text-sm font-medium truncate" x-text="name"></span>
138
- <span class="text-xs px-1.5 py-0.5 rounded-full" :class="proj.active ? 'bg-green-900/50 text-green-300' : 'bg-gray-800 text-gray-500'" x-text="proj.active ? 'running' : 'sleeping'"></span>
139
- </div>
140
- <div class="flex gap-2 flex-shrink-0">
141
- <button @click="openTestLink(name)" class="text-xs bg-gray-800 hover:bg-gray-700 text-gray-300 px-2 py-1 rounded" title="Open Repo"><i class="fas fa-external-link-alt"></i></button>
142
- <button @click="activateProject(name)" class="text-xs px-1" :class="projectLoading[name + '-activate'] ? 'text-gray-600' : 'text-indigo-400 hover:text-indigo-300'" title="Activate"><i :class="projectLoading[name + '-activate'] ? 'fas fa-spinner fa-spin' : 'fas fa-play'"></i></button>
143
- <button @click="gitSyncProject(name)" class="text-xs px-1" :class="projectLoading[name + '-sync'] ? 'text-gray-600' : 'text-emerald-400 hover:text-emerald-300'" title="Git Sync"><i :class="projectLoading[name + '-sync'] ? 'fas fa-spinner fa-spin' : 'fas fa-sync'"></i></button>
144
- <button @click="toggleProjectLogs(name)" class="text-xs text-gray-500 hover:text-gray-300 px-1" title="Workspace Path"><i class="fas fa-folder"></i></button>
145
- </div>
146
- </div>
147
- <div x-show="proj.repo_url" class="mt-1 text-xs text-gray-600 font-mono truncate" x-text="proj.repo_url"></div>
148
- <!-- expandable terminal -->
149
- <div x-show="expandedProject === name" class="mt-2 pt-2 border-t border-white/5">
150
- <div class="bg-gray-950 rounded-lg p-2 max-h-32 overflow-y-auto term-line text-xs space-y-0.5">
151
- <div class="text-gray-600">─ project logs ─</div>
152
- <template x-for="(line, j) in (projectLogs[name] || [])" :key="j">
153
- <div x-text="line" :class="line.includes('error') ? 'text-red-400' : 'text-gray-400'"></div>
154
- </template>
155
- <div x-show="!(projectLogs[name] || []).length" class="text-gray-700">No output yet. Activate and run commands.</div>
156
- </div>
157
- </div>
158
- </div>
159
- </template>
160
- <div x-show="Object.keys(projects).length === 0" class="text-gray-600 text-center py-12 text-xs">No projects. Click <span class="text-indigo-400">New</span> to create one.</div>
161
  </div>
 
 
 
 
 
 
162
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
163
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
164
  </div>
165
 
166
- <!-- ========== SETTINGS & RESOURCE VAULT ========== -->
167
- <div x-show="screen === 'settings'" class="max-w-5xl mx-auto px-4 pt-20 pb-6">
168
- <div class="flex items-center justify-between mb-4">
169
- <h2 class="text-lg font-semibold text-indigo-300"><i class="fas fa-cog mr-2"></i>Settings &amp; Resource Vault</h2>
170
- <button @click="openResourceBuilder()" class="bg-indigo-600 hover:bg-indigo-500 text-white px-4 py-2 rounded-lg text-sm font-medium"><i class="fas fa-plus mr-1"></i> Add Resource</button>
171
  </div>
172
-
173
- <!-- credentials list -->
174
- <div class="panel space-y-2">
175
- <div class="flex items-center justify-between text-xs text-gray-500 uppercase tracking-wider px-1 pb-2 border-b border-white/5">
176
- <span class="font-medium">Resource</span><span class="font-medium">Status</span>
177
  </div>
178
- <template x-for="(cred, key) in credentials" :key="key">
179
- <div class="flex items-center justify-between py-2.5 px-1 border-b border-white/5 last:border-0">
180
- <div class="flex items-center gap-3">
181
- <i class="fas fa-key text-gray-600 text-xs"></i>
182
- <span class="text-sm font-mono" x-text="key"></span>
183
- <span class="text-xs text-gray-600" x-text="cred.description || ''"></span>
184
- </div>
185
- <div class="flex items-center gap-3">
186
- <span class="text-xs font-mono text-gray-600" x-text="cred.value ? '********' : 'not set'"></span>
187
- <button @click="openResourceEditor(key)" class="text-xs text-indigo-400 hover:text-indigo-300"><i class="fas fa-pen"></i></button>
188
- </div>
189
- </div>
190
- </template>
191
- <div x-show="Object.keys(credentials).length === 0" class="text-gray-600 text-center py-8 text-sm">No resources configured.</div>
192
- </div>
193
-
194
- <!-- LLM provider status -->
195
- <div class="panel mt-4">
196
- <h3 class="text-sm font-medium text-indigo-300 mb-3"><i class="fas fa-bolt mr-2"></i>LLM Router Status</h3>
197
- <div class="grid grid-cols-3 gap-3 text-sm">
198
- <div class="bg-gray-900 rounded-lg p-3">
199
- <div class="flex items-center gap-2">
200
- <div class="w-2 h-2 rounded-full" :class="nvidiaKey ? 'bg-green-500' : 'bg-gray-600'"></div>
201
- <span class="text-xs text-gray-400">NVIDIA NIM</span>
202
- </div>
203
- <div class="text-xs font-mono mt-1" :class="nvidiaKey ? 'text-green-400' : 'text-gray-600'" x-text="nvidiaKey ? 'key set' : 'not set'"></div>
204
- </div>
205
- <div class="bg-gray-900 rounded-lg p-3">
206
- <div class="flex items-center gap-2">
207
- <div class="w-2 h-2 rounded-full" :class="openrouterKey ? 'bg-green-500' : 'bg-gray-600'"></div>
208
- <span class="text-xs text-gray-400">OpenRouter</span>
209
- </div>
210
- <div class="text-xs font-mono mt-1" :class="openrouterKey ? 'text-green-400' : 'text-gray-600'" x-text="openrouterKey ? 'key set' : 'not set'"></div>
211
- </div>
212
- <div class="bg-gray-900 rounded-lg p-3">
213
- <div class="flex items-center gap-2">
214
- <div class="w-2 h-2 rounded-full" :class="geminiKey ? 'bg-green-500' : 'bg-gray-600'"></div>
215
- <span class="text-xs text-gray-400">Gemini</span>
216
- </div>
217
- <div class="text-xs font-mono mt-1" :class="geminiKey ? 'text-green-400' : 'text-gray-600'" x-text="geminiKey ? 'key set' : 'not set'"></div>
218
- </div>
219
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
220
  </div>
 
 
 
 
 
 
 
 
 
 
 
221
  </div>
222
 
223
- <!-- ========== MODAL: RESOURCE BUILDER ========== -->
224
- <div x-show="resourceModal" class="modal-overlay" @click.self="resourceModal = false">
225
- <div class="modal-window">
226
- <div class="flex items-center justify-between mb-4">
227
- <h3 class="text-base font-semibold text-indigo-300" x-text="resourcePhase === 1 ? 'Add Resource' : 'Configure ' + (resourceType || '')"></h3>
228
- <button @click="resourceModal = false" class="text-gray-500 hover:text-gray-300"><i class="fas fa-times"></i></button>
229
- </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
230
 
231
- <!-- Phase 1: Type Selector -->
232
- <div x-show="resourcePhase === 1">
233
- <p class="text-xs text-gray-500 mb-4">Select a resource class to connect.</p>
234
- <div class="grid grid-cols-2 gap-3">
235
- <template x-for="(cls, key) in resourceClasses" :key="key">
236
- <div @click="selectResourceClass(key)" class="bg-gray-900 border border-white/5 rounded-lg p-3 cursor-pointer hover:border-indigo-500/50 transition-colors">
237
- <i :class="cls.icon" class="text-lg text-gray-400"></i>
238
- <div class="text-sm font-medium mt-1" x-text="cls.label"></div>
239
- <div class="text-xs text-gray-600 mt-0.5" x-text="cls.desc"></div>
240
- </div>
241
- </template>
242
- </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
243
  </div>
 
 
 
 
 
 
 
 
 
 
 
244
 
245
- <!-- Phase 2: Configuration -->
246
- <div x-show="resourcePhase === 2" class="space-y-4">
247
- <div><label class="block text-xs text-gray-500 mb-1">Connection Name</label><input x-model="resourceName" class="w-full" placeholder="my-database"></div>
248
- <div x-show="resourceRequiresAuth">
249
- <label class="block text-xs text-gray-500 mb-1">API Key / Connection String</label>
250
- <textarea x-model="resourceValue" class="w-full font-mono h-20" placeholder="Paste API key or connection string..."></textarea>
251
- </div>
252
- <!-- Scope toggles -->
253
- <div class="pt-2 border-t border-white/5">
254
- <label class="block text-xs text-gray-500 mb-2">Scope</label>
255
- <div class="flex gap-4 text-sm">
256
- <label class="flex items-center gap-2"><input type="radio" x-model="resourceScope" value="core" class="accent-indigo-500"> Altamira-Core</label>
257
- <label class="flex items-center gap-2"><input type="radio" x-model="resourceScope" value="sandbox" class="accent-indigo-500"> Project-Sandbox</label>
258
- </div>
259
- <div x-show="resourceScope === 'sandbox'" class="mt-2">
260
- <label class="block text-xs text-gray-500 mb-1">Restrict to projects</label>
261
- <template x-for="(proj, name) in projects" :key="name">
262
- <label class="flex items-center gap-2 text-sm mt-1"><input type="checkbox" x-model="resourceProjects" :value="name" class="accent-indigo-500"> <span x-text="name"></span></label>
263
- </template>
264
- </div>
265
- </div>
266
- <div class="flex gap-3 pt-2">
267
- <button @click="saveResource()" class="flex-1 bg-indigo-600 hover:bg-indigo-500 text-white rounded-lg px-4 py-2 text-sm font-medium">Save Resource</button>
268
- <button @click="resourcePhase = 1; resourceType = ''" class="text-sm text-gray-500 hover:text-gray-300">Back</button>
269
- </div>
270
- </div>
271
  </div>
 
 
272
  </div>
 
 
273
 
274
- <!-- ========== EVENT LOG BAR ========== -->
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
275
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
276
  </div>
277
 
278
  <script>
279
- function app() {
280
- return {
281
- // screen routing
282
- screen: 'onboarding',
283
- navTargets: {
284
- aade: {label: 'Workspace', icon: 'fas fa-cubes'},
285
- settings: {label: 'Settings', icon: 'fas fa-cog'},
286
- },
287
-
288
- // session
289
- sessionToken: '',
290
- sessionUser: '',
291
- activeProject: '',
292
-
293
- // chat
294
- chatInput: '', chatMessages: [], chatLoading: false,
295
-
296
- // projects
297
- newProjectName: '', newProjectRepo: '',
298
- projects: {}, showAddProject: false,
299
- expandedProject: null, projectLogs: {},
300
- projectLoading: {},
301
-
302
- // resources
303
- credentials: {},
304
- nvidiaKey: false, openrouterKey: false, geminiKey: false,
305
-
306
- // resource builder modal
307
- resourceModal: false, resourcePhase: 1,
308
- resourceType: '', resourceName: '', resourceValue: '',
309
- resourceRequiresAuth: true,
310
- resourceClasses: {
311
- databases: {label: 'Databases', icon: 'fas fa-database', desc: 'Postgres, MySQL, Snowflake'},
312
- saas: {label: 'SaaS / Third-Party', icon: 'fas fa-cloud', desc: 'Google Sheets, Slack, Jira'},
313
- ai: {label: 'AI Providers', icon: 'fas fa-brain', desc: 'Gemini, OpenAI, NVIDIA'},
314
- custom_rest: {label: 'Custom REST API', icon: 'fas fa-plug', desc: 'HTTP methods & headers'},
315
- custom_graphql: {label: 'GraphQL', icon: 'fas fa-project-diagram', desc: 'Queries & mutations'},
316
- custom_ai: {label: 'Custom AI Provider', icon: 'fas fa-microchip', desc: 'OpenAI-compatible / Ollama'},
317
- storage: {label: 'Altamira Storage', icon: 'fas fa-box', desc: 'Local key-value store'},
318
- },
319
-
320
- // status
321
- status: 'starting...',
322
- statusClass: 'bg-gray-800 text-gray-400',
323
-
324
- // no ops (removed event log)
325
- pushLog() {},
326
-
327
- // ---- init ----
328
- async init() {
329
- // Check for existing persistent session
330
- const storedToken = localStorage.getItem('altamira_session');
331
- const storedUser = localStorage.getItem('altamira_user');
332
- if (storedToken) {
333
- try {
334
- const s = await this.api('/api/auth/session', {headers: {'Authorization': 'Bearer ' + storedToken}});
335
- this.sessionToken = storedToken;
336
- this.sessionUser = s.username || storedUser;
337
- this.screen = 'aade';
338
- } catch {
339
- localStorage.removeItem('altamira_session');
340
- localStorage.removeItem('altamira_user');
341
- }
342
- }
343
- this.checkHealth();
344
- if (this.screen !== 'onboarding') {
345
- this.loadProjects();
346
- this.loadCredentials();
347
- }
348
- },
349
-
350
- async api(path, opts = {}) {
351
- const headers = {'Content-Type': 'application/json', ...opts.headers};
352
- if (this.sessionToken && !path.startsWith('/api/auth/') && !path.startsWith('/api/gatekeeper/') && path !== '/health') {
353
- headers['Authorization'] = 'Bearer ' + this.sessionToken;
354
- }
355
- const r = await fetch(path, {...opts, headers});
356
- const data = await r.json();
357
- if (!r.ok) throw new Error(data.detail || 'HTTP ' + r.status);
358
- return data;
359
- },
360
-
361
- // ---- navigation ----
362
- navigateTo(key) {
363
- this.screen = key;
364
- if (key === 'aade') { this.loadProjects(); }
365
- if (key === 'settings') { this.loadCredentials(); }
366
- },
367
-
368
- // ---- OAuth login ----
369
- async oauthLogin() {
370
- try {
371
- const d = await this.api('/api/auth/oauth/login');
372
- if (d.configured && d.redirect) {
373
- window.location.href = d.redirect;
374
- }
375
- } catch(e) {
376
- }
377
- },
378
-
379
- // ---- logout ----
380
- async logout() {
381
- try {
382
- await this.api('/api/auth/logout', {method: 'POST'});
383
- } catch {}
384
- localStorage.removeItem('altamira_session');
385
- localStorage.removeItem('altamira_user');
386
- this.sessionToken = '';
387
- this.sessionUser = '';
388
- this.screen = 'onboarding';
389
- },
390
-
391
- // ---- health ----
392
- async checkHealth() {
393
- try {
394
- const d = await this.api('/health');
395
- this.status = 'RUNNING';
396
- this.statusClass = 'bg-green-900/50 text-green-300';
397
- } catch {
398
- this.status = 'error';
399
- this.statusClass = 'bg-red-900/50 text-red-300';
400
- }
401
- },
402
-
403
- // ---- chat ----
404
- async sendChat() {
405
- const text = this.chatInput.trim();
406
- if (!text || this.chatLoading) return;
407
- this.chatInput = '';
408
- this.chatMessages.push({role: 'user', content: text});
409
- this.chatLoading = true;
410
- this.scrollChat();
411
- try {
412
- const d = await this.api('/api/agent/submit', {method: 'POST', body: JSON.stringify({prompt: text, project: this.activeProject || 'default'})});
413
- const dagId = d.dag_id;
414
- this.chatMessages.push({role: 'system', content: 'Planning tasks...'});
415
- // Poll until tasks complete
416
- let done = false;
417
- let attempts = 0;
418
- while (!done && attempts < 60) {
419
- await new Promise(r => setTimeout(r, 1000));
420
- await this.api('/api/agent/cycle', {method: 'POST'});
421
- const status = await this.api('/api/agent/status');
422
- const s = status.tasks || {};
423
- const pending = s.pending || 0;
424
- const running = s.running || 0;
425
- const reviewing = s.reviewing || 0;
426
- if (running > 0 || reviewing > 0) {
427
- this.chatMessages[this.chatMessages.length - 1] = {role: 'system', content: 'Working... (' + running + ' running, ' + reviewing + ' reviewing)'};
428
- }
429
- if (pending === 0 && running === 0 && reviewing === 0) {
430
- done = true;
431
- }
432
- attempts++;
433
- this.scrollChat();
434
- }
435
- // Fetch results
436
- const tasks = await this.api('/api/agent/tasks', {method: 'GET'});
437
- const taskList = tasks.tasks || [];
438
- const results = taskList.filter(t => t.dag_id === dagId && t.output);
439
- for (const t of results) {
440
- this.chatMessages.push({role: 'output', content: t.output || '(no output)'});
441
- if (t.error) this.chatMessages.push({role: 'error', content: t.error});
442
- }
443
- this.chatMessages.push({role: 'agent', content: 'Done. ' + results.length + ' tasks completed.'});
444
- } catch(e) {
445
- this.chatMessages.push({role: 'error', content: e.message});
446
- }
447
- this.chatLoading = false;
448
- this.scrollChat();
449
- },
450
- scrollChat() {
451
- this.$nextTick(() => { const el = this.$refs.chatBox; if (el) el.scrollTop = el.scrollHeight; });
452
- },
453
-
454
- // ---- projects ----
455
- async loadProjects() {
456
- try { this.projects = await this.api('/api/projects'); } catch {}
457
- },
458
- async createProject() {
459
- if (!this.newProjectName) return;
460
- try {
461
- await this.api('/api/projects', {method: 'POST', body: JSON.stringify({name: this.newProjectName, repo_url: this.newProjectRepo})});
462
- this.newProjectName = ''; this.newProjectRepo = '';
463
- await this.loadProjects();
464
- } catch(e) {}
465
- },
466
- async activateProject(name) {
467
- this.projectLoading[name + '-activate'] = true;
468
- try {
469
- await this.api('/api/projects/' + name + '/activate', {method: 'POST'});
470
- await this.loadProjects();
471
- this.activeProject = name;
472
- } catch(e) {} finally { this.projectLoading[name + '-activate'] = false; }
473
- },
474
- async gitSyncProject(name) {
475
- this.projectLoading[name + '-sync'] = true;
476
- try { await this.api('/api/projects/' + name + '/git-sync', {method: 'POST'}); } catch(e) {} finally { this.projectLoading[name + '-sync'] = false; }
477
- },
478
- toggleProjectLogs(name) {
479
- this.expandedProject = this.expandedProject === name ? null : name;
480
- if (!this.projectLogs[name]) {
481
- this.projectLogs[name] = ['[' + new Date().toLocaleTimeString() + '] connected', '[' + new Date().toLocaleTimeString() + '] workspace: /tmp/altamira-workspace/' + name];
482
- }
483
- },
484
- openTestLink(name) {
485
- const proj = this.projects[name];
486
- if (proj && proj.repo_url) window.open(proj.repo_url, '_blank');
487
- },
488
-
489
- // ---- resources ----
490
- async loadCredentials() {
491
- try {
492
- const d = await this.api('/api/resources');
493
- this.credentials = d.credentials || {};
494
- this.nvidiaKey = !!(d.credentials || {}).NVIDIA_NIM_API_KEY;
495
- this.openrouterKey = !!(d.credentials || {}).OPENROUTER_API_KEY;
496
- this.geminiKey = !!(d.credentials || {}).GEMINI_API_KEY;
497
- } catch {}
498
- },
499
-
500
- // ---- resource builder modal ----
501
- openResourceBuilder() {
502
- this.resourceModal = true;
503
- this.resourcePhase = 1;
504
- this.resourceType = '';
505
- this.resourceName = '';
506
- this.resourceValue = '';
507
- this.resourceAuth = 'manual';
508
- this.resourceScope = 'core';
509
- this.resourceProjects = [];
510
- },
511
- openResourceEditor(key) {
512
- this.resourceModal = true;
513
- this.resourcePhase = 2;
514
- this.resourceType = key;
515
- this.resourceName = key;
516
- this.resourceValue = this.credentials[key]?.value || '';
517
- this.resourceRequiresAuth = true;
518
- },
519
- selectResourceClass(key) {
520
- this.resourceType = key;
521
- this.resourcePhase = 2;
522
- this.resourceRequiresAuth = key !== 'storage';
523
- this.resourceName = key === 'databases' ? 'my-db' : key === 'ai' ? 'my-ai-key' : key === 'saas' ? 'my-saas' : 'my-resource';
524
- },
525
- async saveResource() {
526
- if (!this.resourceName || !this.resourceValue) return;
527
- try {
528
- await this.api('/api/resources/' + this.resourceName, {method: 'PUT', body: JSON.stringify({value: this.resourceValue, description: this.resourceType})});
529
- this.resourceModal = false;
530
- await this.loadCredentials();
531
- } catch(e) {}
532
- },
533
- };
534
  }
535
  </script>
536
  </body>
537
- </html>
 
1
  <!DOCTYPE html>
2
+ <html class="dark" lang="en" style="width: 1280px; height: 1024px; overflow: hidden; position: relative;">
3
  <head>
4
+ <meta charset="utf-8">
5
+ <meta content="width=device-width, initial-scale=1.0" name="viewport">
6
+ <title>ALTAMIRA-COMMAND | AADE</title>
7
+ <script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
8
+ <script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
9
+ <link href="https://fonts.googleapis.com/css2?family=Hanken+Grotesk:wght@400;600;700&family=JetBrains+Mono:wght@500;600;700&display=swap" rel="stylesheet">
10
+ <link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet">
11
+ <style>
12
+ .material-symbols-outlined{font-variation-settings:'FILL' 0,'wght' 400,'GRAD' 0,'opsz' 24}
13
+ .custom-scrollbar::-webkit-scrollbar{width:4px}
14
+ .custom-scrollbar::-webkit-scrollbar-track{background:#121416}
15
+ .custom-scrollbar::-webkit-scrollbar-thumb{background:#424754;border-radius:2px}
16
+ .scanline{width:100%;height:2px;background:rgba(173,198,255,0.05);position:absolute;animation:scanline 6s linear infinite;pointer-events:none}
17
+ @keyframes scanline{0%{top:0}100%{top:100%}}
18
+ .pulse-dot{animation:pulse-dot 2s ease-in-out infinite}
19
+ @keyframes pulse-dot{0%,100%{opacity:1}50%{opacity:0.3}}
20
+ .swing{animation:swing .35s ease-out}
21
+ @keyframes swing{0%{opacity:0;transform:scale(.97) translateY(6px)}100%{opacity:1;transform:scale(1) translateY(0)}}
22
+ .log-entry{animation:fadeIn .3s ease-out}
23
+ @keyframes fadeIn{0%{opacity:0;transform:translateX(-8px)}100%{opacity:1;transform:translateX(0)}}
24
+ </style>
25
+ <script>
26
+ tailwind.config={darkMode:"class",theme:{extend:{colors:{"surface-tint":"#adc6ff","on-surface-variant":"#c2c6d6","on-primary-fixed":"#001a42","tertiary-container":"#df7412","secondary-fixed":"#d3e4fe","surface-container-low":"#1a1c1e","secondary-container":"#3a4a5f","on-error-container":"#ffdad6","tertiary":"#ffb786","surface-bright":"#37393b","error-container":"#93000a","surface-variant":"#333537","on-surface":"#e2e2e5","outline":"#8c909f","on-tertiary-container":"#461f00","primary-fixed":"#d8e2ff","on-tertiary-fixed":"#311400","on-secondary":"#213145","tertiary-fixed-dim":"#ffb786","on-secondary-fixed":"#0b1c30","primary-container":"#4d8eff","on-tertiary":"#502400","on-primary-container":"#00285d","tertiary-fixed":"#ffdcc6","surface-container-lowest":"#0c0e10","on-tertiary-fixed-variant":"#723600","on-primary-fixed-variant":"#004395","error":"#ffb4ab","background":"#121416","surface-dim":"#121416","primary-fixed-dim":"#adc6ff","outline-variant":"#424754","surface-container-high":"#282a2c","on-secondary-fixed-variant":"#38485d","on-background":"#e2e2e5","inverse-on-surface":"#2f3133","primary":"#adc6ff","secondary-fixed-dim":"#b7c8e1","surface":"#121416","on-secondary-container":"#a9bad3","inverse-surface":"#e2e2e5","surface-container-highest":"#333537","on-error":"#690005","secondary":"#b7c8e1","surface-container":"#1e2022","on-primary":"#002e6a","inverse-primary":"#005ac2"},borderRadius:{DEFAULT:"0.125rem",lg:"0.25rem",xl:"0.5rem",full:"0.75rem"},spacing:{gutter:"12px",xl:"32px",sm:"8px",base_unit:"4px",xs:"4px",lg:"24px",md:"16px",margin:"16px"},fontFamily:{"display-lg":["Hanken Grotesk"],"status-sm":["JetBrains Mono"],"label-caps":["JetBrains Mono"],"headline-md":["Hanken Grotesk"],"data-mono":["JetBrains Mono"],"body-md":["Hanken Grotesk"]},fontSize:{"display-lg":["32px",{"lineHeight":"40px","letterSpacing":"-0.02em","fontWeight":"700"}],"status-sm":["12px",{"lineHeight":"16px","fontWeight":"600"}],"label-caps":["11px",{"lineHeight":"16px","letterSpacing":"0.05em","fontWeight":"700"}],"headline-md":["20px",{"lineHeight":"28px","letterSpacing":"-0.01em","fontWeight":"600"}],"data-mono":["13px",{"lineHeight":"18px","letterSpacing":"-0.01em","fontWeight":"500"}],"body-md":["14px",{"lineHeight":"20px","fontWeight":"400"}]}}}}
27
+ </script>
 
 
 
 
28
  </head>
29
+ <body x-data="app()" class="bg-background text-on-background font-body-md h-screen overflow-hidden">
30
+
31
+ <!-- ===== ONBOARDING OVERLAY ===== -->
32
+ <div x-show="screen==='onboarding'" class="fixed inset-0 bg-background z-[9999] flex items-center justify-center" style="background:radial-gradient(ellipse at 50% 0%,rgba(77,142,255,.08),transparent 60%)">
33
+ <div class="w-full max-w-sm bg-surface-container-low border border-outline-variant rounded-xl p-8 text-center swing">
34
+ <div class="w-14 h-14 rounded-full bg-primary/20 text-primary flex items-center justify-center mx-auto mb-5">
35
+ <span class="material-symbols-outlined text-[32px]">terminal</span>
 
 
 
 
 
 
 
36
  </div>
37
+ <h1 class="font-display-lg text-display-lg text-primary mb-1">Altamira AADE</h1>
38
+ <p class="font-body-md text-on-surface-variant mb-8">Autonomous AI Development Environment</p>
39
+ <button @click="oauthLogin()" class="w-full bg-primary text-on-primary font-status-sm py-3.5 rounded-lg flex items-center justify-center gap-3 hover:opacity-90 transition-opacity mb-3">
40
+ <span class="material-symbols-outlined text-[18px]">fingerprint</span> Login with Hugging Face
41
+ </button>
42
+ <p class="font-data-mono text-[11px] text-on-surface-variant/50">Free · No credit card needed</p>
43
+ </div>
44
+ </div>
45
 
46
+ <!-- ===== SIDENAV ===== -->
47
+ <nav x-show="screen!=='onboarding'" class="fixed left-0 top-0 h-full w-64 bg-surface-container-low border-r border-outline-variant z-40 hidden md:flex flex-col">
48
+ <div class="h-14 px-md flex items-center gap-3 border-b border-outline-variant bg-surface-container">
49
+ <span class="material-symbols-outlined text-primary text-[22px]">terminal</span>
50
+ <span class="font-headline-md text-headline-md text-primary">Altamira</span>
51
+ </div>
52
+ <div class="flex-1 py-sm px-xs space-y-0.5 overflow-y-auto">
53
+ <template x-for="(item,key) in navItems" :key="key">
54
+ <button @click="navigateTo(key)" class="w-full flex items-center gap-3 px-sm py-2.5 rounded-lg transition-all font-data-mono text-[13px]" :class="screen===key?'bg-primary/20 text-primary border-l-2 border-primary':'text-on-surface-variant hover:bg-surface-container hover:text-on-surface'">
55
+ <span class="material-symbols-outlined text-[20px]" x-text="item.icon"></span>
56
+ <span x-text="item.label"></span>
57
+ <span x-show="item.badge" class="ml-auto bg-primary/20 text-primary text-[10px] font-bold px-1.5 py-0.5 rounded" x-text="item.badge"></span>
58
+ </button>
59
+ </template>
60
+ </div>
61
+ <div class="p-sm border-t border-outline-variant bg-surface-container">
62
+ <div class="flex items-center gap-2.5 px-sm py-2">
63
+ <span class="w-7 h-7 rounded-full bg-primary/20 text-primary flex items-center justify-center text-xs font-bold" x-text="(sessionUser||'?')[0].toUpperCase()"></span>
64
+ <div class="flex-1 min-w-0">
65
+ <p class="font-data-mono text-[12px] truncate text-on-surface" x-text="sessionUser||'anonymous'"></p>
66
+ <p class="font-label-caps text-[9px] text-on-surface-variant/50">OPERATOR</p>
67
+ </div>
68
+ <button @click="logout()" class="text-on-surface-variant hover:text-error transition-colors"><span class="material-symbols-outlined text-[18px]">logout</span></button>
69
+ </div>
70
+ </div>
71
+ </nav>
72
+
73
+ <!-- ===== MAIN CANVAS ===== -->
74
+ <main x-show="screen!=='onboarding'" class="ml-0 md:ml-64 pt-0 h-screen bg-surface-container-lowest grid grid-cols-12 grid-rows-12 gap-base_unit p-base_unit">
75
+
76
+ <!-- ===== SCREEN: AADE ===== -->
77
+ <template x-if="screen==='aade'">
78
+ <div class="contents">
79
+ <!-- Panel A: Terminal / AI Chat -->
80
+ <section class="col-span-12 md:col-span-5 row-span-12 bg-surface-container-low border border-outline-variant flex flex-col overflow-hidden relative">
81
+ <div class="scanline"></div>
82
+ <div class="h-10 px-md flex items-center justify-between border-b border-outline-variant bg-surface-container bg-opacity-50">
83
+ <div class="flex items-center gap-sm">
84
+ <span class="material-symbols-outlined text-primary text-[18px]">terminal</span>
85
+ <span class="font-label-caps text-label-caps text-on-surface-variant uppercase">Command_Interface</span>
86
+ </div>
87
+ <div class="flex items-center gap-sm">
88
+ <span x-show="activeProject" class="font-data-mono text-[10px] text-primary/70">[<span x-text="activeProject"></span>]</span>
89
+ <span class="font-data-mono text-[10px] text-on-surface-variant opacity-40">v2.0</span>
90
+ </div>
91
+ </div>
92
+ <!-- Chat Messages -->
93
+ <div class="flex-1 overflow-y-auto p-md space-y-md font-data-mono custom-scrollbar" x-ref="chatBox">
94
+ <template x-for="(msg,i) in chatMessages" :key="i">
95
+ <div class="swing">
96
+ <div x-show="msg.role==='user'||msg.role==='agent'">
97
+ <div class="flex items-center gap-sm">
98
+ <span class="text-[11px] font-bold" :class="msg.role==='user'?'text-tertiary':'text-primary-fixed-dim'" x-text="msg.role==='user'?'[OPERATOR]':'[OS_CORE]'"></span>
99
+ <span class="text-on-surface-variant text-[10px] opacity-50" x-text="msg.time||''"></span>
100
+ </div>
101
+ <p class="text-on-surface text-data-mono leading-relaxed" x-text="msg.content"></p>
102
  </div>
103
+ <div x-show="msg.role==='output'">
104
+ <div class="bg-surface-container-lowest border border-outline-variant p-sm rounded-lg overflow-x-auto mt-xs">
105
+ <pre class="text-[12px] text-secondary leading-tight whitespace-pre-wrap"><span x-text="msg.content"></span></pre>
106
+ </div>
107
+ </div>
108
+ <div x-show="msg.role==='error'" class="flex items-center gap-sm bg-error/10 border-l-2 border-error p-xs">
109
+ <span class="material-symbols-outlined text-error text-[16px]">error</span>
110
+ <span class="text-on-surface text-[12px]" x-text="msg.content"></span>
111
+ </div>
112
+ <div x-show="msg.role==='system'">
113
+ <div class="flex items-center gap-sm bg-primary/10 border-l-2 border-primary p-xs">
114
+ <span class="material-symbols-outlined text-primary text-[16px]">sync</span>
115
+ <span class="text-on-surface text-[12px]" x-text="msg.content"></span>
116
+ </div>
117
+ </div>
118
+ <div x-show="msg.role==='manifest'">
119
+ <div class="flex items-center gap-sm bg-tertiary/10 border-l-2 border-tertiary p-xs">
120
+ <span class="material-symbols-outlined text-tertiary text-[16px]">check_circle</span>
121
+ <span class="text-on-surface text-[12px]" x-text="msg.content"></span>
122
+ </div>
123
+ </div>
124
+ </div>
125
+ </template>
126
+ <div x-show="!chatMessages.length&&!chatLoading" class="flex flex-col items-center justify-center py-16 text-on-surface-variant/40">
127
+ <span class="material-symbols-outlined text-[40px]">terminal</span>
128
+ <p class="font-data-mono text-[12px] mt-2">Awaiting deployment parameters.</p>
129
  </div>
130
+ <div x-show="chatLoading" class="flex items-center gap-sm bg-primary/10 border-l-2 border-primary p-xs">
131
+ <span class="material-symbols-outlined text-primary text-[16px] animate-spin">sync</span>
132
+ <span class="text-on-surface text-[12px]">Processing...</span>
133
+ </div>
134
+ </div>
135
+ <!-- Command Input -->
136
+ <div class="p-sm bg-surface-container border-t border-outline-variant">
137
+ <div class="relative group">
138
+ <textarea x-model="chatInput" @keydown.enter.prevent="sendChat()" class="w-full bg-surface-container-lowest border border-outline-variant rounded-lg p-sm font-data-mono text-on-surface focus:outline-none focus:border-primary transition-all resize-none h-20 placeholder:text-on-surface-variant/30" placeholder="Type command or prompt..."></textarea>
139
+ <div class="absolute bottom-xs right-xs flex items-center gap-xs">
140
+ <kbd class="px-xs py-[2px] bg-surface-variant rounded text-[10px] font-data-mono text-on-surface-variant">CMD</kbd>
141
+ <kbd class="px-xs py-[2px] bg-surface-variant rounded text-[10px] font-data-mono text-on-surface-variant">ENTER</kbd>
142
+ </div>
143
+ </div>
144
+ </div>
145
+ </section>
146
+
147
+ <!-- Right Stack -->
148
+ <div class="col-span-12 md:col-span-7 row-span-12 flex flex-col gap-base_unit h-full overflow-hidden">
149
+ <!-- Panel B: Fleet Monitor -->
150
+ <section class="h-[58%] bg-surface-container-low border border-outline-variant flex flex-col overflow-hidden">
151
+ <div class="h-10 px-md flex items-center justify-between border-b border-outline-variant bg-surface-container">
152
+ <div class="flex items-center gap-sm">
153
+ <span class="material-symbols-outlined text-secondary text-[18px]">grid_view</span>
154
+ <span class="font-label-caps text-label-caps text-on-surface-variant uppercase">Fleet_Monitor</span>
155
+ </div>
156
+ <div class="flex gap-md items-center">
157
+ <div class="flex items-center gap-xs">
158
+ <span class="w-2 h-2 rounded-full bg-primary pulse-dot"></span>
159
+ <span class="font-label-caps text-[10px] text-on-surface-variant" x-text="runningCount+' RUNNING'"></span>
 
 
 
 
 
 
 
 
 
 
 
 
 
160
  </div>
161
+ <div class="flex items-center gap-xs">
162
+ <span class="w-2 h-2 rounded-full bg-error"></span>
163
+ <span class="font-label-caps text-[10px] text-on-surface-variant" x-text="errorCount+' FAILED'"></span>
164
+ </div>
165
+ <button @click="showAddProject=true" class="font-label-caps text-[10px] text-primary hover:text-primary-fixed-dim transition-colors">+ NEW</button>
166
+ </div>
167
+ </div>
168
+ <div class="flex-1 overflow-y-auto p-md custom-scrollbar">
169
+ <div x-show="showAddProject" class="bg-surface-container-lowest border border-outline-variant p-md rounded-lg mb-md swing">
170
+ <div class="flex gap-sm">
171
+ <input x-model="newProjectName" class="flex-1 bg-surface-container border border-outline-variant rounded px-sm py-1.5 font-data-mono text-[12px] focus:outline-none focus:border-primary" placeholder="Project name">
172
+ <input x-model="newProjectRepo" class="flex-1 bg-surface-container border border-outline-variant rounded px-sm py-1.5 font-data-mono text-[12px] focus:outline-none focus:border-primary" placeholder="GitHub URL (optional)">
173
+ </div>
174
+ <div class="flex gap-sm mt-sm">
175
+ <button @click="createProject();showAddProject=false" class="font-status-sm text-[10px] py-sm px-md bg-primary text-on-primary rounded">Create</button>
176
+ <button @click="showAddProject=false" class="font-status-sm text-[10px] py-sm px-md border border-outline-variant text-on-surface-variant rounded">Cancel</button>
177
+ </div>
178
+ </div>
179
+ <div class="grid grid-cols-1 lg:grid-cols-2 gap-md">
180
+ <template x-for="(proj,name) in projects" :key="name">
181
+ <div class="bg-surface-container-lowest border border-outline-variant p-md flex flex-col gap-sm relative group hover:border-primary/50 transition-colors cursor-pointer" @click="selectProject(name)">
182
+ <div class="flex justify-between items-start">
183
+ <div>
184
+ <h4 class="font-headline-md text-headline-md text-on-surface leading-tight" x-text="name"></h4>
185
+ <p class="font-data-mono text-[10px] text-on-surface-variant" x-text="'ID: '+(proj.repo_url?proj.repo_url.split('/').pop():'LOCAL')"></p>
186
+ </div>
187
+ <div class="flex items-center gap-xs px-xs py-[2px] rounded" :class="projectStatusClass(proj)">
188
+ <span class="w-1.5 h-1.5 rounded-full" :class="proj.active?'bg-primary':'bg-on-surface-variant'"></span>
189
+ <span class="font-status-sm text-[10px] font-bold" x-text="proj.active?'RUNNING':'SLEEPING'"></span>
190
+ </div>
191
  </div>
192
+ <div x-show="proj.active" class="grid grid-cols-2 gap-xs border-y border-outline-variant py-sm my-xs">
193
+ <div><span class="block font-label-caps text-[9px] text-on-surface-variant opacity-50">STATUS</span><span class="font-data-mono text-on-surface text-[12px]">Active</span></div>
194
+ <div><span class="block font-label-caps text-[9px] text-on-surface-variant opacity-50">NODE</span><span class="font-data-mono text-on-surface text-[12px]" x-text="'Sandbox-'+(sandboxStatus[name]?'LIVE':'NONE')"></span></div>
 
 
 
 
 
 
195
  </div>
196
+ <div class="flex items-center gap-xs" x-show="selectedProject===name">
197
+ <button @click.stop="activateProject(name)" class="flex-1 font-status-sm text-[10px] py-sm border border-outline-variant hover:bg-surface-variant transition-colors rounded">CONTROL</button>
198
+ <button @click.stop="createSandbox(name)" class="flex-1 font-status-sm text-[10px] py-sm border border-outline-variant hover:bg-surface-variant transition-colors rounded">SANDBOX</button>
199
+ <button @click.stop="openProjectDetail(name)" class="material-symbols-outlined p-sm text-on-surface-variant hover:text-primary transition-colors text-[16px] border border-outline-variant rounded">open_in_new</button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
200
  </div>
201
+ </div>
202
+ </template>
203
+ <div x-show="!Object.keys(projects).length" class="col-span-full flex flex-col items-center justify-center py-16 text-on-surface-variant/40">
204
+ <span class="material-symbols-outlined text-[40px]">grid_view</span>
205
+ <p class="font-data-mono text-[12px] mt-2">No active projects.</p>
206
+ <button @click="showAddProject=true" class="font-status-sm text-[10px] mt-sm px-md py-sm bg-primary/20 text-primary rounded">BOOTSTRAP</button>
207
  </div>
208
+ </div>
209
+ </div>
210
+ </section>
211
+ <!-- Panel C: Runtime Stream -->
212
+ <section class="flex-1 bg-surface-container-lowest border border-outline-variant flex flex-col overflow-hidden">
213
+ <div class="h-10 px-md flex items-center justify-between border-b border-outline-variant bg-surface-container-high">
214
+ <div class="flex items-center gap-sm">
215
+ <span class="material-symbols-outlined text-tertiary text-[18px]">history_edu</span>
216
+ <span class="font-label-caps text-label-caps text-on-surface-variant uppercase">Runtime_Stream</span>
217
+ </div>
218
+ <div class="flex items-center gap-sm">
219
+ <span class="font-data-mono text-[10px] text-on-surface-variant uppercase bg-surface-variant px-xs">LIVE_FEED</span>
220
+ <button @click="clearLogs" class="material-symbols-outlined text-on-surface-variant text-[16px] hover:text-primary">delete_sweep</button>
221
+ </div>
222
  </div>
223
+ <div class="flex-1 overflow-y-auto p-md font-data-mono text-[11px] leading-tight custom-scrollbar bg-black" x-ref="logBox">
224
+ <template x-for="(log,i) in runtimeLogs" :key="i">
225
+ <p class="log-entry" :class="log.color" x-text="log.text"></p>
226
+ </template>
227
+ <p x-show="!runtimeLogs.length" class="text-on-surface/30">[system] Awaiting process output...</p>
228
+ </div>
229
+ </section>
230
+ </div>
231
+ </div>
232
+ </template>
233
+
234
+ <!-- ===== SCREEN: SETTINGS ===== -->
235
+ <template x-if="screen==='settings'">
236
+ <div class="col-span-12 row-span-12 flex flex-col gap-base_unit p-md overflow-y-auto custom-scrollbar">
237
+ <div class="flex gap-1 p-1 bg-surface-container-low border border-outline-variant rounded-lg w-fit">
238
+ <button @click="settingsTab='resources'" class="px-4 py-2 rounded-lg font-status-sm text-[11px] transition-all" :class="settingsTab==='resources'?'bg-primary text-on-primary':'text-on-surface-variant hover:text-on-surface'">RESOURCES</button>
239
+ <button @click="settingsTab='providers'" class="px-4 py-2 rounded-lg font-status-sm text-[11px] transition-all" :class="settingsTab==='providers'?'bg-primary text-on-primary':'text-on-surface-variant hover:text-on-surface'">PROVIDERS</button>
240
+ <button @click="settingsTab='monitor'" class="px-4 py-2 rounded-lg font-status-sm text-[11px] transition-all" :class="settingsTab==='monitor'?'bg-primary text-on-primary':'text-on-surface-variant hover:text-on-surface'">MONITOR</button>
241
  </div>
242
 
243
+ <div x-show="settingsTab==='resources'">
244
+ <div class="flex items-center justify-between mb-3"><h2 class="font-headline-md text-headline-md text-primary">Resource Vault</h2><button @click="openResourceBuilder()" class="font-status-sm text-[11px] py-sm px-md bg-primary text-on-primary rounded-lg">+ ADD</button></div>
245
+ <div class="bg-surface-container-low border border-outline-variant rounded-lg overflow-hidden">
246
+ <div class="grid grid-cols-12 gap-2 px-md py-2.5 font-label-caps text-[10px] text-on-surface-variant uppercase border-b border-outline-variant bg-surface-container">
247
+ <div class="col-span-4">Resource</div><div class="col-span-2">Scope</div><div class="col-span-2">Status</div><div class="col-span-4 text-right">Actions</div>
248
  </div>
249
+ <template x-for="(cred,key) in credentials" :key="key">
250
+ <div class="grid grid-cols-12 gap-2 items-center px-md py-2.5 border-b border-outline-variant last:border-0 hover:bg-surface-container transition-colors">
251
+ <div class="col-span-4 flex items-center gap-2 min-w-0">
252
+ <span class="material-symbols-outlined text-[16px] text-on-surface-variant">key</span>
253
+ <span class="font-data-mono text-[12px] truncate" x-text="key"></span>
254
  </div>
255
+ <div class="col-span-2"><span class="font-label-caps text-[9px] px-1.5 py-0.5 rounded" :class="cred.scope==='core'?'bg-primary/20 text-primary':'bg-tertiary/20 text-tertiary'" x-text="cred.scope||'core'"></span></div>
256
+ <div class="col-span-2"><span class="font-data-mono text-[11px]" :class="pingResults[key]==='ok'?'text-primary':'text-on-surface-variant/50'" x-text="pingResults[key]==='ok'?'verified':(pingResults[key]||'—')"></span></div>
257
+ <div class="col-span-4 flex justify-end gap-1">
258
+ <button @click="pingResource(key)" class="material-symbols-outlined text-[16px] p-1 rounded hover:bg-surface-variant text-on-surface-variant hover:text-primary">link</button>
259
+ <button @click="openResourceEditor(key)" class="material-symbols-outlined text-[16px] p-1 rounded hover:bg-surface-variant text-on-surface-variant hover:text-primary">edit</button>
260
+ <button @click="deleteResource(key)" class="material-symbols-outlined text-[16px] p-1 rounded hover:bg-surface-variant text-on-surface-variant hover:text-error">delete</button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
261
  </div>
262
+ </div>
263
+ </template>
264
+ <div x-show="!Object.keys(credentials).length" class="text-center py-12 font-data-mono text-[12px] text-on-surface-variant/40">No resources configured.</div>
265
+ </div>
266
+ </div>
267
+
268
+ <div x-show="settingsTab==='providers'">
269
+ <h2 class="font-headline-md text-headline-md text-primary mb-3">LLM Providers</h2>
270
+ <div class="grid grid-cols-2 sm:grid-cols-4 gap-md">
271
+ <div class="bg-surface-container-low border border-outline-variant rounded-lg p-md flex flex-col items-center text-center gap-sm">
272
+ <span class="material-symbols-outlined text-[28px] text-primary">memory</span>
273
+ <span class="font-label-caps text-[10px] text-on-surface-variant uppercase">NVIDIA NIM</span>
274
+ <div class="flex items-center gap-1.5"><span class="w-1.5 h-1.5 rounded-full" :class="nvidiaKey?'bg-primary':'bg-on-surface-variant'"></span><span class="font-data-mono text-[11px]" :class="nvidiaKey?'text-primary':'text-on-surface-variant/50'" x-text="nvidiaKey?'ready':'not set'"></span></div>
275
+ </div>
276
+ <div class="bg-surface-container-low border border-outline-variant rounded-lg p-md flex flex-col items-center text-center gap-sm">
277
+ <span class="material-symbols-outlined text-[28px] text-secondary">alt_route</span>
278
+ <span class="font-label-caps text-[10px] text-on-surface-variant uppercase">OpenRouter</span>
279
+ <div class="flex items-center gap-1.5"><span class="w-1.5 h-1.5 rounded-full" :class="openrouterKey?'bg-primary':'bg-on-surface-variant'"></span><span class="font-data-mono text-[11px]" :class="openrouterKey?'text-primary':'text-on-surface-variant/50'" x-text="openrouterKey?'ready':'not set'"></span></div>
280
  </div>
281
+ <div class="bg-surface-container-low border border-outline-variant rounded-lg p-md flex flex-col items-center text-center gap-sm">
282
+ <span class="material-symbols-outlined text-[28px] text-tertiary">psychology</span>
283
+ <span class="font-label-caps text-[10px] text-on-surface-variant uppercase">Gemini</span>
284
+ <div class="flex items-center gap-1.5"><span class="w-1.5 h-1.5 rounded-full" :class="geminiKey?'bg-primary':'bg-on-surface-variant'"></span><span class="font-data-mono text-[11px]" :class="geminiKey?'text-primary':'text-on-surface-variant/50'" x-text="geminiKey?'ready':'not set'"></span></div>
285
+ </div>
286
+ <div class="bg-surface-container-low border border-outline-variant rounded-lg p-md flex flex-col items-center text-center gap-sm">
287
+ <span class="material-symbols-outlined text-[28px] text-on-surface-variant">layers</span>
288
+ <span class="font-label-caps text-[10px] text-on-surface-variant uppercase">Throttle</span>
289
+ <div class="font-data-mono text-[11px] text-primary" x-text="'Layer '+throttleLayer"></div>
290
+ </div>
291
+ </div>
292
  </div>
293
 
294
+ <div x-show="settingsTab==='monitor'">
295
+ <h2 class="font-headline-md text-headline-md text-primary mb-3">System Monitor</h2>
296
+ <div class="grid grid-cols-2 gap-md">
297
+ <div class="bg-surface-container-low border border-outline-variant rounded-lg p-md">
298
+ <span class="font-label-caps text-[10px] text-on-surface-variant uppercase">Active Projects</span>
299
+ <p class="font-display-lg text-display-lg text-primary mt-1" x-text="Object.keys(projects).length"></p>
300
+ </div>
301
+ <div class="bg-surface-container-low border border-outline-variant rounded-lg p-md">
302
+ <span class="font-label-caps text-[10px] text-on-surface-variant uppercase">Sandbox Spaces</span>
303
+ <p class="font-display-lg text-display-lg text-tertiary mt-1" x-text="Object.keys(sandboxStatus).length"></p>
304
+ </div>
305
+ </div>
306
+ </div>
307
+ </div>
308
+ </template>
309
+ </main>
310
+
311
+ <!-- ===== FLOATING COMMAND RING ===== -->
312
+ <div x-show="screen==='aade'" class="fixed bottom-lg right-lg z-50" x-data="{open:false}">
313
+ <div x-show="open" @click.away="open=false" class="flex flex-col gap-sm mb-sm">
314
+ <button @click="showAddProject=true;open=false" class="w-12 h-12 rounded-full bg-surface-container-high border border-outline-variant flex items-center justify-center text-on-surface-variant hover:text-primary hover:border-primary transition-all shadow-xl">
315
+ <span class="material-symbols-outlined">add_box</span>
316
+ </button>
317
+ <button @click="clearChat();open=false" class="w-12 h-12 rounded-full bg-surface-container-high border border-outline-variant flex items-center justify-center text-on-surface-variant hover:text-tertiary hover:border-tertiary transition-all shadow-xl">
318
+ <span class="material-symbols-outlined">delete_sweep</span>
319
+ </button>
320
+ <button @click="navigateTo('settings');open=false" class="w-12 h-12 rounded-full bg-surface-container-high border border-outline-variant flex items-center justify-center text-on-surface-variant hover:text-secondary hover:border-secondary transition-all shadow-xl">
321
+ <span class="material-symbols-outlined">settings</span>
322
+ </button>
323
+ </div>
324
+ <div @click="open=!open" class="rounded-full w-16 h-16 bg-primary text-on-primary shadow-lg flex items-center justify-center hover:scale-105 active:scale-90 transition-transform cursor-pointer">
325
+ <span class="material-symbols-outlined text-[32px] transition-transform duration-300" :class="open?'rotate-45':''">add</span>
326
+ </div>
327
+ </div>
328
 
329
+ <!-- ===== MOBILE BOTTOM NAV ===== -->
330
+ <nav x-show="screen!=='onboarding'" class="md:hidden fixed bottom-0 left-0 w-full bg-surface-container-high border-t border-outline-variant h-14 flex items-center justify-around z-50">
331
+ <button @click="navigateTo('aade')" class="flex flex-col items-center" :class="screen==='aade'?'text-primary':'text-on-surface-variant/60'">
332
+ <span class="material-symbols-outlined">terminal</span>
333
+ <span class="font-label-caps text-[10px]">AADE</span>
334
+ </button>
335
+ <button @click="navigateTo('settings')" class="flex flex-col items-center" :class="screen==='settings'?'text-primary':'text-on-surface-variant/60'">
336
+ <span class="material-symbols-outlined">settings</span>
337
+ <span class="font-label-caps text-[10px]">Settings</span>
338
+ </button>
339
+ <button @click="logout()" class="flex flex-col items-center text-on-surface-variant/60">
340
+ <span class="material-symbols-outlined">logout</span>
341
+ <span class="font-label-caps text-[10px]">Exit</span>
342
+ </button>
343
+ </nav>
344
+
345
+ <!-- ===== MODAL: PROJECT DETAIL ===== -->
346
+ <div x-show="projectDetailModal" class="fixed inset-0 bg-black/70 z-[9999] flex items-center justify-center" @click.self="projectDetailModal=false">
347
+ <div class="bg-surface-container-low border border-outline-variant rounded-xl p-lg swing max-w-lg w-[92%] max-h-[85vh] overflow-y-auto custom-scrollbar">
348
+ <div class="flex items-center justify-between mb-4">
349
+ <h3 class="font-headline-md text-headline-md text-primary flex items-center gap-2"><span class="material-symbols-outlined">folder_open</span><span x-text="detailProjectName"></span></h3>
350
+ <button @click="projectDetailModal=false" class="text-on-surface-variant hover:text-on-surface"><span class="material-symbols-outlined">close</span></button>
351
+ </div>
352
+ <div class="space-y-4">
353
+ <div class="bg-surface-container-lowest border border-outline-variant rounded-lg p-md">
354
+ <span class="font-label-caps text-[10px] text-on-surface-variant uppercase">Status</span>
355
+ <div class="flex gap-3 mt-1">
356
+ <span class="font-data-mono text-[12px] flex items-center gap-1.5"><span class="w-2 h-2 rounded-full" :class="detailProject?.active?'bg-primary':'bg-on-surface-variant'"></span><span x-text="detailProject?.active?'Running':'Sleeping'"></span></span>
357
+ </div>
358
+ </div>
359
+ <div class="bg-surface-container-lowest border border-outline-variant rounded-lg p-md">
360
+ <div class="flex items-center justify-between mb-2"><span class="font-label-caps text-[10px] text-on-surface-variant uppercase">Sandbox Files</span><span class="font-data-mono text-[10px] text-on-surface-variant/50" x-text="sandboxFiles.length+' files'"></span></div>
361
+ <div class="max-h-40 overflow-y-auto space-y-1 custom-scrollbar">
362
+ <template x-if="!sandboxFiles.length"><p class="font-data-mono text-[11px] text-on-surface-variant/40 text-center py-4">No sandbox files. Create a sandbox first.</p></template>
363
+ <template x-for="f in sandboxFiles" :key="f.name">
364
+ <div @click="viewSandboxFileContent(detailProjectName,f.name)" class="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-surface-container cursor-pointer transition-colors font-data-mono text-[11px]">
365
+ <span class="material-symbols-outlined text-[14px] text-on-surface-variant">description</span>
366
+ <span class="flex-1 truncate text-on-surface" x-text="f.name"></span>
367
+ <span class="text-on-surface-variant/40" x-text="(f.size/1024).toFixed(1)+'KB'"></span>
368
  </div>
369
+ </template>
370
+ </div>
371
+ </div>
372
+ <div class="flex gap-2 flex-wrap">
373
+ <button @click="createSandbox(detailProjectName);projectDetailModal=false" class="font-status-sm text-[10px] py-sm px-md bg-primary text-on-primary rounded-lg">CREATE SANDBOX</button>
374
+ <button @click="openResourceBinding(detailProjectName);projectDetailModal=false" class="font-status-sm text-[10px] py-sm px-md border border-outline-variant text-on-surface-variant rounded-lg hover:bg-surface-container">BIND RESOURCES</button>
375
+ <button @click="openExternalTesting(detailProjectName);projectDetailModal=false" class="font-status-sm text-[10px] py-sm px-md border border-outline-variant text-on-surface-variant rounded-lg hover:bg-surface-container">EXTERNAL TEST</button>
376
+ </div>
377
+ </div>
378
+ </div>
379
+ </div>
380
 
381
+ <!-- ===== MODAL: RESOURCE BINDING ===== -->
382
+ <div x-show="resourceBindingModal" class="fixed inset-0 bg-black/70 z-[9999] flex items-center justify-center" @click.self="resourceBindingModal=false">
383
+ <div class="bg-surface-container-low border border-outline-variant rounded-xl p-lg swing max-w-lg w-[92%] max-h-[85vh] overflow-y-auto custom-scrollbar">
384
+ <div class="flex items-center justify-between mb-4">
385
+ <h3 class="font-headline-md text-headline-md text-primary flex items-center gap-2"><span class="material-symbols-outlined">key</span>Resources: <span class="text-on-surface" x-text="bindProjectName"></span></h3>
386
+ <button @click="resourceBindingModal=false" class="text-on-surface-variant hover:text-on-surface"><span class="material-symbols-outlined">close</span></button>
387
+ </div>
388
+ <div class="space-y-2">
389
+ <template x-for="(cred,key) in credentials" :key="key">
390
+ <div class="flex items-center justify-between px-sm py-2 rounded-lg hover:bg-surface-container transition-colors border-b border-outline-variant last:border-0">
391
+ <div class="flex items-center gap-2">
392
+ <span class="material-symbols-outlined text-[16px]" :class="projectBoundKeys[key]?'text-primary':'text-on-surface-variant'">key</span>
393
+ <span class="font-data-mono text-[12px]" x-text="key"></span>
394
+ <span x-show="cred.shareable" class="font-label-caps text-[9px] px-1.5 py-0.5 rounded bg-secondary/20 text-secondary">shareable</span>
395
+ </div>
396
+ <div class="flex gap-2">
397
+ <button x-show="!projectBoundKeys[key]" @click="bindResource(bindProjectName,key)" class="font-status-sm text-[10px] py-1 px-2.5 bg-primary/20 text-primary rounded">BIND</button>
398
+ <button x-show="projectBoundKeys[key]" @click="unbindResource(bindProjectName,key)" class="font-status-sm text-[10px] py-1 px-2.5 border border-error/30 text-error rounded">UNBIND</button>
399
+ </div>
 
 
 
 
 
 
 
400
  </div>
401
+ </template>
402
+ <div x-show="!Object.keys(credentials).length" class="text-center py-8 font-data-mono text-[12px] text-on-surface-variant/40">No global resources.</div>
403
  </div>
404
+ </div>
405
+ </div>
406
 
407
+ <!-- ===== MODAL: RESOURCE BUILDER ===== -->
408
+ <div x-show="resourceModal" class="fixed inset-0 bg-black/70 z-[9999] flex items-center justify-center" @click.self="resourceModal=false">
409
+ <div class="bg-surface-container-low border border-outline-variant rounded-xl p-lg swing max-w-lg w-[92%] max-h-[85vh] overflow-y-auto custom-scrollbar">
410
+ <div class="flex items-center justify-between mb-4">
411
+ <h3 class="font-headline-md text-headline-md text-primary flex items-center gap-2"><span class="material-symbols-outlined">add_circle</span><span x-text="resourcePhase===1?'Add Resource':'Configure'"></span></h3>
412
+ <button @click="resourceModal=false" class="text-on-surface-variant hover:text-on-surface"><span class="material-symbols-outlined">close</span></button>
413
+ </div>
414
+ <div x-show="resourcePhase===1">
415
+ <p class="font-data-mono text-[11px] text-on-surface-variant/60 mb-4">Select resource class.</p>
416
+ <div class="grid grid-cols-2 gap-2">
417
+ <template x-for="(cls,key) in resourceClasses" :key="key">
418
+ <div @click="selectResourceClass(key)" class="bg-surface-container-lowest border border-outline-variant rounded-lg p-3 cursor-pointer hover:border-primary/40 transition-all hover:translate-y-[-1px]">
419
+ <span class="material-symbols-outlined text-[20px] text-on-surface-variant" x-text="cls.icon"></span>
420
+ <div class="font-data-mono text-[12px] mt-1" x-text="cls.label"></div>
421
+ <div class="font-data-mono text-[10px] text-on-surface-variant/50" x-text="cls.desc"></div>
422
+ </div>
423
+ </template>
424
+ </div>
425
+ </div>
426
+ <div x-show="resourcePhase===2" class="space-y-4">
427
+ <div><label class="font-label-caps text-[10px] text-on-surface-variant uppercase block mb-1">Name</label><input x-model="resourceName" class="w-full bg-surface-container-lowest border border-outline-variant rounded px-sm py-2 font-data-mono text-[12px] focus:outline-none focus:border-primary" placeholder="e.g. HF_TOKEN"></div>
428
+ <div x-show="resourceRequiresAuth"><label class="font-label-caps text-[10px] text-on-surface-variant uppercase block mb-1">Value</label><textarea x-model="resourceValue" class="w-full bg-surface-container-lowest border border-outline-variant rounded px-sm py-2 font-data-mono text-[12px] h-20 focus:outline-none focus:border-primary" placeholder="Paste key..."></textarea></div>
429
+ <div class="pt-2 border-t border-outline-variant">
430
+ <label class="font-label-caps text-[10px] text-on-surface-variant uppercase block mb-2">Scope</label>
431
+ <div class="flex gap-3 font-data-mono text-[12px]">
432
+ <label class="flex items-center gap-1.5 cursor-pointer"><input type="radio" x-model="resourceScope" value="core" class="accent-primary"> Core</label>
433
+ <label class="flex items-center gap-1.5 cursor-pointer"><input type="radio" x-model="resourceScope" value="sandbox" class="accent-primary"> Per-Project</label>
434
+ </div>
435
+ <label class="flex items-center gap-1.5 font-data-mono text-[12px] mt-2 cursor-pointer"><input type="checkbox" x-model="resourceShareable" class="accent-primary"> Shareable</label>
436
+ </div>
437
+ <div class="flex gap-2 pt-2">
438
+ <button @click="saveResource()" class="flex-1 font-status-sm text-[11px] py-sm bg-primary text-on-primary rounded-lg">Save &amp; Verify</button>
439
+ <button @click="resourcePhase=1;resourceType=''" class="font-status-sm text-[11px] py-sm px-md border border-outline-variant text-on-surface-variant rounded-lg">Back</button>
440
+ </div>
441
+ </div>
442
+ </div>
443
+ </div>
444
 
445
+ <!-- ===== MODAL: EXTERNAL TESTING ===== -->
446
+ <div x-show="externalTestingModal" class="fixed inset-0 bg-black/70 z-[9999] flex items-center justify-center" @click.self="externalTestingModal=false">
447
+ <div class="bg-surface-container-low border border-outline-variant rounded-xl p-lg swing max-w-lg w-[92%] max-h-[85vh] overflow-y-auto custom-scrollbar">
448
+ <div class="flex items-center justify-between mb-4">
449
+ <h3 class="font-headline-md text-headline-md text-primary flex items-center gap-2"><span class="material-symbols-outlined">science</span>Testing: <span class="text-on-surface" x-text="testProjectName"></span></h3>
450
+ <button @click="externalTestingModal=false" class="text-on-surface-variant hover:text-on-surface"><span class="material-symbols-outlined">close</span></button>
451
+ </div>
452
+ <div class="space-y-3">
453
+ <div class="bg-surface-container-lowest border border-outline-variant rounded-lg p-md">
454
+ <div class="flex items-center justify-between mb-2"><span class="font-label-caps text-[10px] text-on-surface-variant uppercase"><span class="text-primary">A</span> Webhook</span><span class="font-data-mono text-[10px] text-primary">auto</span></div>
455
+ <input x-model="webhookUrl" class="w-full bg-surface-container border border-outline-variant rounded px-sm py-1.5 font-data-mono text-[11px] focus:outline-none focus:border-primary mb-2" placeholder="https://hook.example.com/endpoint">
456
+ <button @click="triggerWebhook(testProjectName)" class="font-status-sm text-[10px] py-sm px-md bg-primary text-on-primary rounded-lg">SEND</button>
457
+ <div x-show="webhookResult" class="mt-2 font-data-mono text-[11px] text-on-surface-variant/80 bg-black rounded p-2" x-text="webhookResult"></div>
458
+ </div>
459
+ <div class="bg-surface-container-lowest border border-outline-variant rounded-lg p-md">
460
+ <div class="flex items-center justify-between mb-2"><span class="font-label-caps text-[10px] text-on-surface-variant uppercase"><span class="text-tertiary">B</span> Download</span><span class="font-data-mono text-[10px] text-tertiary">manual</span></div>
461
+ <button @click="generateDownloadUrl(testProjectName)" class="font-status-sm text-[10px] py-sm px-md border border-outline-variant text-on-surface-variant rounded-lg">GENERATE</button>
462
+ <div x-show="downloadUrl" class="mt-2 font-data-mono text-[11px] text-primary break-all bg-black rounded p-2" x-text="downloadUrl"></div>
463
+ </div>
464
+ <div class="bg-surface-container-lowest border border-outline-variant rounded-lg p-md">
465
+ <div class="flex items-center justify-between mb-2"><span class="font-label-caps text-[10px] text-on-surface-variant uppercase"><span class="text-secondary">C</span> GitHub Actions</span><span class="font-data-mono text-[10px] text-secondary">ci/cd</span></div>
466
+ <div class="flex gap-2 mb-2">
467
+ <input x-model="ghaRepo" class="flex-1 bg-surface-container border border-outline-variant rounded px-sm py-1.5 font-data-mono text-[11px] focus:outline-none focus:border-primary" placeholder="owner/repo">
468
+ <input x-model="ghaWorkflow" class="w-24 bg-surface-container border border-outline-variant rounded px-sm py-1.5 font-data-mono text-[11px] focus:outline-none focus:border-primary" placeholder="main.yml">
469
+ </div>
470
+ <button @click="triggerGHA(testProjectName)" class="font-status-sm text-[10px] py-sm px-md bg-primary text-on-primary rounded-lg">TRIGGER</button>
471
+ <div x-show="ghaResult" class="mt-2 font-data-mono text-[11px] text-on-surface-variant/80 bg-black rounded p-2" x-text="ghaResult"></div>
472
+ </div>
473
+ </div>
474
+ </div>
475
  </div>
476
 
477
  <script>
478
+ function app(){
479
+ return {
480
+ screen:'onboarding',navTargets:{aade:{label:'Workspace',icon:'fas fa-cubes'},settings:{label:'Settings',icon:'fas fa-cog'}},
481
+ navItems:{aade:{label:'Command',icon:'terminal',badge:'LIVE'},settings:{label:'Settings',icon:'settings'}},
482
+ settingsTab:'resources',
483
+ sessionToken:'',sessionUser:'',activeProject:'',
484
+ chatInput:'',chatMessages:[],chatLoading:false,
485
+ newProjectName:'',newProjectRepo:'',projects:{},showAddProject:false,
486
+ projectLoading:{},selectedProject:null,throttleLayer:1,
487
+ credentials:{},nvidiaKey:false,openrouterKey:false,geminiKey:false,
488
+ pingResults:{},resourceModal:false,resourcePhase:1,
489
+ resourceType:'',resourceName:'',resourceValue:'',resourceRequiresAuth:true,
490
+ resourceScope:'core',resourceShareable:false,resourceProjects:[],
491
+ resourceClasses:{
492
+ databases:{label:'Databases',icon:'database',desc:'Postgres,MySQL,Snowflake'},
493
+ saas:{label:'SaaS / APIs',icon:'cloud',desc:'Slack,Jira,Google'},
494
+ ai:{label:'AI Providers',icon:'psychology',desc:'OpenAI,Gemini,NVIDIA'},
495
+ hf:{label:'Hugging Face',icon:'face',desc:'Token,Spaces'},
496
+ github:{label:'GitHub',icon:'code',desc:'PAT, Actions'},
497
+ custom:{label:'Custom REST',icon:'api',desc:'HTTP endpoints'},
498
+ custom_ai:{label:'Custom AI',icon:'memory',desc:'Ollama, vLLM'},
499
+ storage:{label:'Altamira KV',icon:'inventory',desc:'Key-value store'},
500
+ },
501
+ sandboxStatus:{},sandboxFiles:[],
502
+ projectDetailModal:false,detailProjectName:'',detailProject:null,
503
+ resourceBindingModal:false,bindProjectName:'',projectBoundKeys:{},
504
+ externalTestingModal:false,testProjectName:'',
505
+ webhookUrl:'',webhookResult:'',downloadUrl:'',
506
+ ghaRepo:'',ghaWorkflow:'main.yml',ghaResult:'',
507
+ runtimeLogs:[],logTimer:null,
508
+
509
+ get runningCount(){return Object.values(this.projects).filter(p=>p.active).length},
510
+ get errorCount(){return 0},
511
+
512
+ init(){
513
+ const t=localStorage.getItem('altamira_session'),u=localStorage.getItem('altamira_user');
514
+ if(t) try{const s=await this.api('/api/auth/session',{headers:{'Authorization':'Bearer '+t}});this.sessionToken=t;this.sessionUser=s.username||u;this.screen='aade'}catch{localStorage.removeItem('altamira_session');localStorage.removeItem('altamira_user')}
515
+ if(this.screen!=='onboarding'){this.loadProjects();this.loadCredentials();this.startLogSimulator()}
516
+ },
517
+
518
+ async api(path,opts={}){
519
+ const h={'Content-Type':'application/json',...opts.headers};
520
+ if(this.sessionToken&&!path.startsWith('/api/auth/')&&!path.startsWith('/api/gatekeeper/')&&path!=='/health')h['Authorization']='Bearer '+this.sessionToken;
521
+ const r=await fetch(path,{...opts,headers:h});const d=await r.json();if(!r.ok)throw new Error(d.detail||'HTTP '+r.status);return d
522
+ },
523
+
524
+ navigateTo(k){this.screen=k;if(k==='aade'){this.loadProjects();this.startLogSimulator()}if(k==='settings'){this.loadCredentials();if(this.logTimer)clearInterval(this.logTimer)}},
525
+ async oauthLogin(){try{const d=await this.api('/api/auth/oauth/login');if(d.configured&&d.redirect)window.location.href=d.redirect}catch{}},
526
+ async logout(){try{await this.api('/api/auth/logout',{method:'POST'})}catch{}localStorage.removeItem('altamira_session');localStorage.removeItem('altamira_user');this.sessionToken='';this.sessionUser='';this.screen='onboarding';if(this.logTimer)clearInterval(this.logTimer)},
527
+
528
+ selectProject(n){this.selectedProject=this.selectedProject===n?null:n;if(this.selectedProject){this.activeProject=n;this.loadSandboxStatus(n)}this.addLog('info',`Selected project: ${n}`)},
529
+
530
+ projectStatusClass(p){return p.active?'bg-primary/20 text-primary border border-primary/30':'bg-on-surface-variant/20 text-on-surface-variant border border-on-surface-variant/30'},
531
+
532
+ addLog(level,msg){
533
+ const now=new Date();const t=`${now.getHours().toString().padStart(2,'0')}:${now.getMinutes().toString().padStart(2,'0')}:${now.getSeconds().toString().padStart(2,'0')}`;
534
+ const colors={info:'text-primary/70',stdout:'text-on-surface/70',stderr:'text-error/70',debug:'text-on-surface/40'};
535
+ const labels={info:'INFO',stdout:'STDOUT',stderr:'STDERR',debug:'DEBUG'};
536
+ this.runtimeLogs.push({text:`[${t}] ${labels[level]||'INFO'}: ${msg}`,color:colors[level]||colors.info});this.$nextTick(()=>{const el=this.$refs.logBox;if(el)el.scrollTop=el.scrollHeight});if(this.runtimeLogs.length>100)this.runtimeLogs=this.runtimeLogs.slice(-100)
537
+ },
538
+
539
+ clearLogs(){this.runtimeLogs=[];this.addLog('info','Runtime stream cleared')},
540
+
541
+ startLogSimulator(){
542
+ if(this.logTimer)clearInterval(this.logTimer);
543
+ this.addLog('info','Runtime sequence initiated');
544
+ const msgs=['Pulse verified for instance 0x44A1.','Re-synching telemetry data with orbital node.','Garbage collection completed: freed 45MB.','Worker process PID spawned successfully.','Connection request accepted.','Buffer allocation stable.','Cache hit ratio: 94.2%.','Memory controller report: nominal.'];
545
+ this.logTimer=setInterval(()=>{
546
+ if(this.screen!=='aade')return;
547
+ const levels=['info','info','info','stdout','stdout','debug'];const lev=levels[Math.floor(Math.random()*levels.length)];
548
+ this.addLog(lev,msgs[Math.floor(Math.random()*msgs.length)])
549
+ },4000)
550
+ },
551
+
552
+ // CHAT
553
+ async sendChat(){
554
+ const t=this.chatInput.trim();if(!t||this.chatLoading)return;
555
+ this.chatInput='';this.chatMessages.push({role:'user',content:t,time:new Date().toLocaleTimeString()});this.chatLoading=true;this.scrollChat();this.addLog('stdout','Command submitted: '+t.slice(0,60));
556
+ try{
557
+ const d=await this.api('/api/agent/submit',{method:'POST',body:JSON.stringify({prompt:t,project:this.activeProject||'default'})});
558
+ const dagId=d.dag_id;this.chatMessages.push({role:'system',content:'Planning tasks...'});let done=false,att=0;
559
+ while(!done&&att<60){await new Promise(r=>setTimeout(r,1000));await this.api('/api/agent/cycle',{method:'POST'});
560
+ const s=await this.api('/api/agent/status'),st=s.tasks||{},p=st.pending||0,rn=st.running||0,rv=st.reviewing||0;
561
+ if(rn>0||rv>0)this.chatMessages[this.chatMessages.length-1]={role:'system',content:`Working... (${rn} running, ${rv} reviewing)`};
562
+ if(!p&&!rn&&!rv)done=true;att++;this.scrollChat()}
563
+ const tasks=await this.api('/api/agent/tasks'),list=tasks.tasks||[],results=list.filter(t=>t.dag_id===dagId&&t.output);
564
+ for(const r of results){this.chatMessages.push({role:'output',content:r.output||'(no output)'});if(r.error)this.chatMessages.push({role:'error',content:r.error})}
565
+ this.chatMessages.push({role:'agent',content:`Done. ${results.length} tasks completed.`,time:new Date().toLocaleTimeString()});
566
+ this.addLog('info',`Task DAG ${dagId} completed: ${results.length} tasks`);
567
+ try{await this.api('/api/monitor/manifest/'+(this.activeProject||'default'),{method:'POST',body:JSON.stringify({status:'completed',version:dagId,message:results.length+' tasks'})})}catch{}
568
+ }catch(e){this.chatMessages.push({role:'error',content:e.message});this.addLog('stderr',e.message)}
569
+ this.chatLoading=false;this.scrollChat()
570
+ },
571
+ scrollChat(){this.$nextTick(()=>{const el=this.$refs.chatBox;if(el)el.scrollTop=el.scrollHeight})},
572
+ clearChat(){this.chatMessages=[];this.addLog('info','Chat history cleared')},
573
+
574
+ // PROJECTS
575
+ async loadProjects(){try{this.projects=await this.api('/api/projects');this.addLog('info','Loaded '+Object.keys(this.projects).length+' projects')}catch{}},
576
+ async createProject(){if(!this.newProjectName)return;try{await this.api('/api/projects',{method:'POST',body:JSON.stringify({name:this.newProjectName,repo_url:this.newProjectRepo})});this.newProjectName='';this.newProjectRepo='';await this.loadProjects();this.addLog('info','Project created')}catch(e){this.addLog('stderr','Create failed: '+e.message)}},
577
+ async activateProject(n){this.projectLoading[n+'-act']=true;try{await this.api('/api/projects/'+n+'/activate',{method:'POST'});await this.loadProjects();this.activeProject=n;this.addLog('info','Activated: '+n)}catch(e){this.addLog('stderr','Activate failed: '+e.message)}finally{this.projectLoading[n+'-act']=false}},
578
+ async gitSyncProject(n){this.projectLoading[n+'-sync']=true;try{await this.api('/api/projects/'+n+'/git-sync',{method:'POST'});this.addLog('info','Git sync: '+n)}catch(e){this.addLog('stderr','Sync failed: '+e.message)}finally{this.projectLoading[n+'-sync']=false}},
579
+
580
+ // RESOURCES
581
+ async loadCredentials(){try{const d=await this.api('/api/resources');this.credentials=d.credentials||{};this.nvidiaKey=!!(d.credentials||{}).NVIDIA_NIM_API_KEY;this.openrouterKey=!!(d.credentials||{}).OPENROUTER_API_KEY;this.geminiKey=!!(d.credentials||{}).GEMINI_API_KEY}catch{}},
582
+ async pingResource(k){try{const d=await this.api('/api/resources/'+k+'/ping',{method:'POST'});this.pingResults[k]=d.ping?.status||'error';this.addLog('info',`Ping ${k}: ${d.ping?.status}`)}catch{this.pingResults[k]='error'}},
583
+ async deleteResource(k){try{await this.api('/api/resources/'+k,{method:'DELETE'});await this.loadCredentials();this.addLog('info','Deleted resource: '+k)}catch{}},
584
+
585
+ openResourceBuilder(){this.resourceModal=true;this.resourcePhase=1;this.resourceType='';this.resourceName='';this.resourceValue='';this.resourceScope='core';this.resourceShareable=false;this.resourceProjects=[]},
586
+ openResourceEditor(k){this.resourceModal=true;this.resourcePhase=2;this.resourceType=k;this.resourceName=k;this.resourceValue=this.credentials[k]?.value||'';this.resourceRequiresAuth=true},
587
+ selectResourceClass(k){this.resourceType=k;this.resourcePhase=2;this.resourceRequiresAuth=k!=='storage';const m={databases:'DB_KEY',ai:'AI_API_KEY',saas:'SAAS_KEY',hf:'HF_TOKEN',github:'GITHUB_TOKEN',custom:'MY_API_KEY',custom_ai:'CUSTOM_AI_KEY',storage:'my-store'};this.resourceName=m[k]||'MY_KEY'},
588
+ async saveResource(){if(!this.resourceName||!this.resourceValue)return;try{const d=await this.api('/api/resources/'+this.resourceName,{method:'PUT',body:JSON.stringify({value:this.resourceValue,description:this.resourceType,scope:this.resourceScope,shareable:this.resourceShareable,projects:this.resourceProjects})});this.pingResults[this.resourceName]=d.ping?.status||'unknown';this.resourceModal=false;await this.loadCredentials();this.addLog('info','Resource saved: '+this.resourceName)}catch(e){this.addLog('stderr','Save failed: '+e.message)}},
589
+
590
+ openProjectDetail(n){this.detailProjectName=n;this.detailProject=this.projects[n]||null;this.sandboxFiles=[];this.projectDetailModal=true;this.viewSandboxFiles(n)},
591
+
592
+ // SANDBOX
593
+ async loadSandboxStatus(n){try{const d=await this.api('/api/sandboxes'),hf=d.hf_spaces||{};Object.assign(this.sandboxStatus,Object.fromEntries(Object.entries(hf).filter(([k])=>k===n).map(([k,v])=>[k,v])))}catch{}},
594
+ async createSandbox(n){try{const d=await this.api('/api/sandboxes/create',{method:'POST',body:JSON.stringify({project:n})});this.sandboxStatus[n]=d;this.chatMessages.push({role:'manifest',content:'Sandbox created: '+(d.url||n)});this.addLog('info','Sandbox created for: '+n)}catch(e){this.chatMessages.push({role:'error',content:'Sandbox creation failed: '+e.message});this.addLog('stderr','Sandbox failed: '+e.message)}},
595
+ async viewSandboxFiles(n){try{const d=await this.api('/api/sandboxes/'+n+'/files');this.sandboxFiles=d.files||[]}catch{this.sandboxFiles=[]}},
596
+ async viewSandboxFileContent(p,fp){try{const d=await this.api('/api/sandboxes/'+p+'/read',{method:'POST',body:JSON.stringify({path:fp})});this.chatMessages.push({role:'output',content:'=== '+fp+' ===\n'+(d.content||'(empty)')});this.scrollChat()}catch(e){this.chatMessages.push({role:'error',content:'Read failed: '+e.message})}},
597
+
598
+ // RESOURCE BINDING
599
+ async openResourceBinding(n){this.bindProjectName=n;this.projectBoundKeys={};this.resourceBindingModal=true;await this.loadCredentials();try{const d=await this.api('/api/resources/projects'),bs=d[n]||{};this.projectBoundKeys=Object.fromEntries(Object.keys(bs).map(k=>[k,true]))}catch{}},
600
+ async bindResource(p,k){try{await this.api('/api/resources/bind/'+p,{method:'POST',body:JSON.stringify({key:k,value:this.credentials[k]?.value||''})});this.projectBoundKeys[k]=true;this.addLog('info','Bound '+k+' to '+p)}catch{}},
601
+ async unbindResource(p,k){try{await this.api('/api/resources/unbind/'+p,{method:'POST',body:JSON.stringify({key:k})});delete this.projectBoundKeys[k];this.addLog('info','Unbound '+k+' from '+p)}catch{}},
602
+
603
+ // EXTERNAL TESTING
604
+ openExternalTesting(n){this.testProjectName=n;this.webhookUrl='';this.webhookResult='';this.downloadUrl='';this.ghaResult='';this.externalTestingModal=true},
605
+ async triggerWebhook(p){if(!this.webhookUrl)return;try{const d=await this.api('/api/test/webhook/'+p,{method:'POST',body:JSON.stringify({url:this.webhookUrl,payload:{project:p,timestamp:Date.now()}})});this.webhookResult='Status: '+d.status;this.addLog('info','Webhook test: '+d.status)}catch(e){this.webhookResult='Error: '+e.message;this.addLog('stderr','Webhook error: '+e.message)}},
606
+ async generateDownloadUrl(p){try{const d=await this.api('/api/test/download-url/'+p);this.downloadUrl=d.files.length?window.location.origin+d.download_url+d.files[0]:'No artifacts';this.addLog('info','Download URL generated')}catch(e){this.downloadUrl='Error: '+e.message}},
607
+ async triggerGHA(p){if(!this.ghaRepo)return;try{const d=await this.api('/api/test/github-actions/'+p,{method:'POST',body:JSON.stringify({repo:this.ghaRepo,workflow:this.ghaWorkflow})});this.ghaResult='Triggered: '+d.status;this.addLog('info','GHA trigger: '+d.status)}catch(e){this.ghaResult='Error: '+e.message;this.addLog('stderr','GHA error: '+e.message)}},
608
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
609
  }
610
  </script>
611
  </body>
612
+ </html>
test_integration.py ADDED
@@ -0,0 +1,299 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Integration tests using FastAPI TestClient."""
2
+ import pytest
3
+ import os, json, time
4
+ from pathlib import Path
5
+ from fastapi.testclient import TestClient
6
+
7
+ os.environ["HF_TOKEN"] = ""
8
+ os.environ["OAUTH_CLIENT_ID"] = ""
9
+ os.environ["OAUTH_CLIENT_SECRET"] = ""
10
+
11
+ from app import app, session_store, STATE_DIR
12
+
13
+ client = TestClient(app)
14
+
15
+ for f in STATE_DIR.glob("*"):
16
+ if f.is_file():
17
+ f.unlink()
18
+
19
+ def auth():
20
+ token = session_store.create("tester", "tester")
21
+ return {"Authorization": f"Bearer {token}"}
22
+
23
+
24
+ class TestPublicEndpoints:
25
+ def test_health(self):
26
+ r = client.get("/health")
27
+ assert r.status_code == 200
28
+ data = r.json()
29
+ assert data["status"] == "healthy"
30
+ assert data["app"] == "altamira-aade"
31
+
32
+ def test_gatekeeper_check(self):
33
+ r = client.post("/api/gatekeeper/check")
34
+ assert r.status_code == 200
35
+ assert r.json()["hf_authenticated"] == False
36
+
37
+ def test_index_html(self):
38
+ r = client.get("/")
39
+ assert r.status_code == 200
40
+ assert "text/html" in r.headers["content-type"]
41
+
42
+
43
+ class TestAuthEndpoints:
44
+ def test_oauth_login_not_configured(self):
45
+ r = client.get("/api/auth/oauth/login")
46
+ assert r.status_code == 200
47
+ assert r.json()["configured"] == False
48
+
49
+ def test_login_requires_token(self):
50
+ r = client.post("/api/auth/login", json={})
51
+ assert r.status_code == 400
52
+
53
+ def test_login_invalid_token(self):
54
+ r = client.post("/api/auth/login", json={"hf_token": "bad_token"})
55
+ assert r.status_code == 401
56
+
57
+ def test_unauthorized_access(self):
58
+ r = client.get("/api/projects")
59
+ assert r.status_code == 401
60
+
61
+ def test_authorized_access(self):
62
+ r = client.get("/api/projects", headers=auth())
63
+ assert r.status_code == 200
64
+
65
+ def test_logout(self):
66
+ h = auth()
67
+ r = client.post("/api/auth/logout", headers=h)
68
+ assert r.status_code == 200
69
+ assert r.json()["status"] == "logged_out"
70
+
71
+ def test_session_invalid_after_logout(self):
72
+ h = auth()
73
+ client.post("/api/auth/logout", headers=h)
74
+ r = client.get("/api/projects", headers=h)
75
+ assert r.status_code == 401
76
+
77
+
78
+ class TestProjectCRUD:
79
+ def test_create_project(self):
80
+ r = client.post("/api/projects", headers=auth(), json={"name": "test-project"})
81
+ assert r.status_code == 200
82
+ assert r.json()["name"] == "test-project"
83
+
84
+ def test_create_duplicate(self):
85
+ r = client.post("/api/projects", headers=auth(), json={"name": "test-project"})
86
+ assert r.status_code == 409
87
+
88
+ def test_list_projects(self):
89
+ r = client.get("/api/projects", headers=auth())
90
+ assert r.status_code == 200
91
+ assert "test-project" in r.json()
92
+
93
+ def test_delete_project(self):
94
+ r = client.delete("/api/projects/test-project", headers=auth())
95
+ assert r.status_code == 200
96
+ assert r.json()["deleted"] == "test-project"
97
+
98
+ def test_list_after_delete(self):
99
+ r = client.get("/api/projects", headers=auth())
100
+ assert r.status_code == 200
101
+ assert "test-project" not in r.json()
102
+
103
+
104
+ class TestResourceEndpoints:
105
+ def test_save_resource(self):
106
+ r = client.put("/api/resources/MY_CUSTOM_KEY", headers=auth(), json={
107
+ "value": "test-value-123", "scope": "core", "description": "Test key"
108
+ })
109
+ assert r.status_code == 200
110
+ assert r.json()["saved"] == "MY_CUSTOM_KEY"
111
+ assert r.json()["ping"]["status"] == "skipped"
112
+
113
+ def test_list_resources(self):
114
+ r = client.get("/api/resources", headers=auth())
115
+ assert r.status_code == 200
116
+ creds = r.json()["credentials"]
117
+ assert "MY_CUSTOM_KEY" in creds
118
+
119
+ def test_ping_resource(self):
120
+ r = client.post("/api/resources/MY_CUSTOM_KEY/ping", headers=auth())
121
+ assert r.status_code == 200
122
+ assert r.json()["key"] == "MY_CUSTOM_KEY"
123
+
124
+ def test_ping_unknown(self):
125
+ r = client.post("/api/resources/NONEXISTENT/ping", headers=auth())
126
+ assert r.status_code == 404
127
+
128
+ def test_project_binding(self):
129
+ r = client.post("/api/resources/bind/testproj2", headers=auth(), json={
130
+ "key": "MY_KEY", "value": "my_value"
131
+ })
132
+ assert r.status_code == 200
133
+ assert r.json()["bound"] == "MY_KEY"
134
+
135
+ def test_project_unbinding(self):
136
+ r = client.post("/api/resources/unbind/testproj2", headers=auth(), json={
137
+ "key": "MY_KEY"
138
+ })
139
+ assert r.status_code == 200
140
+ assert r.json()["unbound"] == "MY_KEY"
141
+
142
+ def test_list_project_bindings(self):
143
+ r = client.get("/api/resources/projects", headers=auth())
144
+ assert r.status_code == 200
145
+
146
+ def test_delete_resource(self):
147
+ r = client.delete("/api/resources/MY_CUSTOM_KEY", headers=auth())
148
+ assert r.status_code == 200
149
+ assert r.json()["deleted"] == "MY_CUSTOM_KEY"
150
+
151
+
152
+ class TestMonitorEndpoints:
153
+ def test_get_manifest(self):
154
+ r = client.get("/api/monitor/manifest", headers=auth())
155
+ assert r.status_code == 200
156
+
157
+ def test_update_manifest(self):
158
+ r = client.post("/api/monitor/manifest/myproject", headers=auth(), json={
159
+ "status": "running", "version": "1.0", "message": "All good"
160
+ })
161
+ assert r.status_code == 200
162
+ assert r.json()["saved"] == "myproject"
163
+
164
+ def test_heartbeat(self):
165
+ r = client.post("/api/monitor/heartbeat/myproject", headers=auth(), json={
166
+ "status": "running"
167
+ })
168
+ assert r.status_code == 200
169
+ assert "pong" in r.json()
170
+
171
+ def test_monitor_summary(self):
172
+ r = client.get("/api/monitor/summary", headers=auth())
173
+ assert r.status_code == 200
174
+ assert "myproject" in r.json()["projects"]
175
+
176
+ def test_throttle_status(self):
177
+ r = client.get("/api/monitor/throttle", headers=auth())
178
+ assert r.status_code == 200
179
+ assert "multi_layer" in r.json()
180
+
181
+
182
+ class TestAgentEndpoints:
183
+ def test_agent_status(self):
184
+ r = client.get("/api/agent/status", headers=auth())
185
+ assert r.status_code == 200
186
+ assert "key_pool" in r.json()
187
+
188
+ def test_agent_tasks(self):
189
+ r = client.get("/api/agent/tasks", headers=auth())
190
+ assert r.status_code == 200
191
+ assert "tasks" in r.json()
192
+
193
+ def test_submit_task_no_prompt(self):
194
+ r = client.post("/api/agent/submit", headers=auth(), json={})
195
+ assert r.status_code == 400
196
+
197
+ def test_submit_task(self):
198
+ r = client.post("/api/agent/submit", headers=auth(), json={
199
+ "prompt": "Write hello world in Python"
200
+ })
201
+ assert r.status_code == 200
202
+ assert "dag_id" in r.json()
203
+ assert r.json()["status"] == "submitted"
204
+
205
+
206
+ class TestSystemEndpoint:
207
+ def test_system_info(self):
208
+ r = client.get("/api/system", headers=auth())
209
+ assert r.status_code == 200
210
+ data = r.json()
211
+ assert "platform" in data
212
+ assert "python" in data
213
+ assert "mode" in data
214
+ assert data["mode"] == "AADE"
215
+
216
+ def test_router_status(self):
217
+ r = client.get("/api/router", headers=auth())
218
+ assert r.status_code == 200
219
+ assert "circuit_breaker" in r.json()
220
+
221
+
222
+ class TestSandboxEndpoints:
223
+ def test_list_sandboxes(self):
224
+ r = client.get("/api/sandboxes", headers=auth())
225
+ assert r.status_code == 200
226
+ assert "local" in r.json()
227
+ assert "hf_spaces" in r.json()
228
+
229
+
230
+ class TestExternalTestingEndpoints:
231
+ def test_artifact_upload(self):
232
+ import base64
233
+ data = base64.b64encode(b"hello world").decode()
234
+ r = client.post("/api/test/artifact/myproj", headers=auth(), json={
235
+ "filename": "test.txt", "data": data
236
+ })
237
+ assert r.status_code == 200
238
+ assert r.json()["size"] == 11
239
+
240
+ def test_download_url(self):
241
+ r = client.get("/api/test/download-url/myproj", headers=auth())
242
+ assert r.status_code == 200
243
+ assert "files" in r.json()
244
+
245
+ def test_artifact_download(self):
246
+ r = client.get("/api/test/artifact/myproj/test.txt", headers=auth())
247
+ assert r.status_code == 200
248
+ assert r.json()["filename"] == "test.txt"
249
+
250
+ def test_webhook_no_url(self):
251
+ r = client.post("/api/test/webhook/myproj", headers=auth(), json={})
252
+ assert r.status_code == 400
253
+
254
+ def test_github_actions_no_token(self):
255
+ r = client.post("/api/test/github-actions/myproj", headers=auth(), json={
256
+ "repo": "testuser/testrepo"
257
+ })
258
+ assert r.status_code == 400
259
+
260
+
261
+ class TestConsoleEndpoints:
262
+ def test_console_exec(self):
263
+ r = client.post("/api/console/exec", headers=auth(), json={
264
+ "command": "echo hello", "timeout": 5
265
+ })
266
+ assert r.status_code == 200
267
+ assert "hello" in r.json()["stdout"]
268
+
269
+
270
+ class TestStateEndpoints:
271
+ def test_get_state(self):
272
+ r = client.get("/api/state", headers=auth())
273
+ assert r.status_code == 200
274
+ assert "state" in r.json()
275
+
276
+ def test_sync_state(self):
277
+ r = client.post("/api/state/sync", headers=auth())
278
+ assert r.status_code == 200
279
+ assert r.json()["status"] == "synced"
280
+
281
+ def test_encrypt_decrypt(self):
282
+ r = client.post("/api/state/encrypt", headers=auth(), json={"data": {"secret": "value"}})
283
+ assert r.status_code == 200
284
+ encrypted = r.json()["encrypted"]
285
+ r2 = client.post("/api/state/decrypt", headers=auth(), json={"token": encrypted})
286
+ assert r2.status_code == 200
287
+ assert r2.json()["data"]["secret"] == "value"
288
+
289
+
290
+ class TestCircuitBreaker:
291
+ def test_reset_circuit(self):
292
+ r = client.post("/api/router/circuit/reset", headers=auth())
293
+ assert r.status_code == 200
294
+ assert r.json()["status"] == "reset"
295
+
296
+ def test_filter_context(self):
297
+ r = client.post("/api/router/filter", headers=auth(), json={"text": "a" * 1000})
298
+ assert r.status_code == 200
299
+ assert r.json()["original_length"] == 1000