diff --git a/ai-hub/app/api/routes/agents.py b/ai-hub/app/api/routes/agents.py index b7a454e..62b6e2a 100644 --- a/ai-hub/app/api/routes/agents.py +++ b/ai-hub/app/api/routes/agents.py @@ -140,7 +140,24 @@ if hasattr(request, 'provider_name') and request.provider_name is not None: session.provider_name = request.provider_name if request.mesh_node_id is not None: - session.attached_node_ids = [request.mesh_node_id] if request.mesh_node_id else [] + old_nodes = session.attached_node_ids or [] + if not old_nodes or request.mesh_node_id not in old_nodes or len(old_nodes) > 1: + try: + services.session_service.attach_nodes(db, session.id, schemas.NodeAttachRequest(node_ids=[request.mesh_node_id] if request.mesh_node_id else [])) + except Exception as e: + logging.error(f"Failed to attach session node: {e}") + else: + session.attached_node_ids = [request.mesh_node_id] if request.mesh_node_id else [] + if hasattr(request, 'restrict_skills') and request.restrict_skills is not None: + session.restrict_skills = request.restrict_skills + if hasattr(request, 'allowed_skill_ids') and request.allowed_skill_ids is not None: + from app.db.models.asset import Skill + skills = db.query(Skill).filter(Skill.id.in_(request.allowed_skill_ids)).all() + session.skills = skills + if hasattr(request, 'is_locked') and request.is_locked is not None: + session.is_locked = request.is_locked + if hasattr(request, 'auto_clear_history') and request.auto_clear_history is not None: + session.auto_clear_history = request.auto_clear_history db.commit() db.refresh(instance) @@ -158,6 +175,16 @@ background_tasks.add_task(AgentExecutor.run, instance.id, prompt, services.rag_service, services.user_service) return {"message": "Accepted"} + @router.post("/{id}/run", status_code=202) + def manual_trigger(id: str, payload: dict, background_tasks: BackgroundTasks, db: Session = Depends(get_db)): + instance = db.query(AgentInstance).filter(AgentInstance.id == id).first() + if not instance: + raise HTTPException(status_code=404, detail="Instance not found") + + prompt = payload.get("prompt") or f"Manual triggered execution for agent {id}." + background_tasks.add_task(AgentExecutor.run, instance.id, prompt, services.rag_service, services.user_service) + return {"message": "Accepted"} + @router.get("/{id}/triggers", response_model=List[schemas.AgentTriggerResponse]) def get_agent_triggers(id: str, db: Session = Depends(get_db)): instance = db.query(AgentInstance).filter(AgentInstance.id == id).first() @@ -189,6 +216,23 @@ return {"message": "Trigger deleted successfully"} + @router.post("/{id}/metrics/reset") + def reset_agent_metrics(id: str, db: Session = Depends(get_db)): + instance = db.query(AgentInstance).filter(AgentInstance.id == id).first() + if not instance: + raise HTTPException(status_code=404, detail="Instance not found") + + instance.total_runs = 0 + instance.successful_runs = 0 + instance.total_tokens_accumulated = 0 + instance.total_running_time_seconds = 0.0 + # By setting this to an empty dict but doing an in-place update the ORM sees it + instance.tool_call_counts = {} + + db.commit() + db.refresh(instance) + return {"message": "Metrics reset successfully"} + @router.get("/{id}/telemetry") def get_telemetry(id: str, db: Session = Depends(get_db)): instance = db.query(AgentInstance).filter(AgentInstance.id == id).first() diff --git a/ai-hub/app/api/schemas.py b/ai-hub/app/api/schemas.py index 3023c8c..3f2b501 100644 --- a/ai-hub/app/api/schemas.py +++ b/ai-hub/app/api/schemas.py @@ -222,6 +222,7 @@ allowed_skill_names: Optional[List[str]] = None system_prompt_override: Optional[str] = None is_locked: Optional[bool] = None + auto_clear_history: Optional[bool] = None class Session(BaseModel): """Defines the shape of a session object returned by the API.""" @@ -243,6 +244,7 @@ allowed_skill_names: Optional[List[str]] = Field(default_factory=list) system_prompt_override: Optional[str] = None is_locked: bool = False + auto_clear_history: bool = False model_config = ConfigDict(from_attributes=True) @@ -569,9 +571,18 @@ last_heartbeat: Optional[datetime] = None template: Optional[AgentTemplateResponse] = None session: Optional[Session] = None + + # Metrics + total_runs: Optional[int] = 0 + successful_runs: Optional[int] = 0 + total_tokens_accumulated: Optional[int] = 0 + total_input_tokens: Optional[int] = 0 + total_output_tokens: Optional[int] = 0 + total_running_time_seconds: Optional[int] = 0 + tool_call_counts: Optional[dict] = {} + model_config = ConfigDict(from_attributes=True) - class AgentTriggerBase(BaseModel): instance_id: str trigger_type: str @@ -613,3 +624,7 @@ max_loop_iterations: Optional[int] = None mesh_node_id: str provider_name: Optional[str] = None + allowed_skill_ids: Optional[List[int]] = None + restrict_skills: Optional[bool] = None + is_locked: Optional[bool] = None + auto_clear_history: Optional[bool] = None diff --git a/ai-hub/app/core/orchestration/agent_loop.py b/ai-hub/app/core/orchestration/agent_loop.py index 2d3b18a..66ecfba 100644 --- a/ai-hub/app/core/orchestration/agent_loop.py +++ b/ai-hub/app/core/orchestration/agent_loop.py @@ -23,6 +23,7 @@ # Acquire Lease instance.last_heartbeat = datetime.utcnow() instance.status = "active" + instance.total_runs = (instance.total_runs or 0) + 1 db.commit() # Launch secondary heartbeat task @@ -78,10 +79,20 @@ # For MVP, we just log the intention as per Task 4.2 print(f"[AgentExecutor] Task 4.2: Idempotency check for {agent_id} in {instance.current_workspace_jail or '/tmp'}") + if getattr(agent_session, "auto_clear_history", False): + print(f"[AgentExecutor] Auto-clearing history for session {session_id} before run.") + db.query(Message).filter(Message.session_id == session_id).delete(synchronize_session=False) + db.commit() + print(f"[AgentExecutor] Starting run for {agent_id} with provider '{provider_name}'. Prompt length: {len(prompt)}") + loop_start = time.time() # Iterate the RAG architecture to solve the prompt try: + final_tool_counts = {} + final_input_tokens = 0 + final_output_tokens = 0 + # We consume the generator completely to let it execute all tools and generate reasoning async for event in rag_service.chat_with_rag( db=db, @@ -91,13 +102,39 @@ load_faiss_retriever=False, user_service=user_service ): - # We could log events here if needed - pass + if event.get("type") == "finish": + final_tool_counts = event.get("tool_counts", {}) + final_input_tokens = event.get("input_tokens", 0) + final_output_tokens = event.get("output_tokens", 0) # Execution complete instance = db.query(AgentInstance).filter(AgentInstance.id == agent_id).first() if instance.status == "active": instance.status = "idle" # Completed work + instance.successful_runs = (instance.successful_runs or 0) + 1 + + elapsed = int(time.time() - loop_start) + instance.total_running_time_seconds = (instance.total_running_time_seconds or 0) + elapsed + instance.total_input_tokens = (instance.total_input_tokens or 0) + final_input_tokens + instance.total_output_tokens = (instance.total_output_tokens or 0) + final_output_tokens + + if final_tool_counts: + current_counts = dict(instance.tool_call_counts or {}) + for k, v in final_tool_counts.items(): + # Upgrade legacy single-integer counts into rich metric dicts + if k in current_counts and isinstance(current_counts[k], int): + current_counts[k] = {"calls": current_counts[k], "successes": current_counts[k], "failures": 0} + if not isinstance(v, dict): + v = {"calls": v, "successes": v, "failures": 0} + if k not in current_counts: + current_counts[k] = {"calls": 0, "successes": 0, "failures": 0} + + current_counts[k]["calls"] = current_counts[k].get("calls", 0) + v.get("calls", 0) + current_counts[k]["successes"] = current_counts[k].get("successes", 0) + v.get("successes", 0) + current_counts[k]["failures"] = current_counts[k].get("failures", 0) + v.get("failures", 0) + + instance.tool_call_counts = current_counts + db.commit() except Exception as e: diff --git a/ai-hub/app/core/orchestration/architect.py b/ai-hub/app/core/orchestration/architect.py index 8afcf4b..a8e7b61 100644 --- a/ai-hub/app/core/orchestration/architect.py +++ b/ai-hub/app/core/orchestration/architect.py @@ -101,6 +101,9 @@ if chunk_count == 1: logging.info(f"[Architect] First chunk received after {time.time() - turn_start_time:.2f}s") + if getattr(chunk, "usage", None): + yield {"type": "token_counted", "usage": getattr(chunk, "usage").model_dump() if hasattr(getattr(chunk, "usage"), "model_dump") else getattr(chunk, "usage")} + if not chunk.choices: continue delta = chunk.choices[0].delta finish_reason = getattr(chunk.choices[0], "finish_reason", None) or chunk.choices[0].get("finish_reason") @@ -249,7 +252,7 @@ messages[0]["content"] = base + f"\n\n[System: Current Turn: {turn}]" async def _call_llm(self, llm_provider, messages, tools): - kwargs = {"stream": True} + kwargs = {"stream": True, "stream_options": {"include_usage": True}} if tools: kwargs["tools"] = tools kwargs["tool_choice"] = "auto" diff --git a/ai-hub/app/core/orchestration/body.py b/ai-hub/app/core/orchestration/body.py index ed28d0a..9c441c6 100644 --- a/ai-hub/app/core/orchestration/body.py +++ b/ai-hub/app/core/orchestration/body.py @@ -90,7 +90,7 @@ except Exception as e: result = {"success": False, "error": f"Tool crashed: {str(e)}"} - if result and (not isinstance(result, dict) or result.get("success") is False or result.get("error")): + if result and (not isinstance(result, dict) or result.get("success") is False): err = result.get("error") if isinstance(result, dict) else "Unknown failure" yield { "type": "reasoning", diff --git a/ai-hub/app/core/orchestration/scheduler.py b/ai-hub/app/core/orchestration/scheduler.py index d87ce8f..bd1fb18 100644 --- a/ai-hub/app/core/orchestration/scheduler.py +++ b/ai-hub/app/core/orchestration/scheduler.py @@ -85,7 +85,7 @@ if should_fire: instance = db.query(AgentInstance).filter(AgentInstance.id == instance_id).first() - if instance and instance.status != 'active': + if instance and instance.status == 'idle': prompt = trigger.default_prompt or "SYSTEM: CRON WAKEUP" logger.info(f"[Scheduler] CRON WAKEUP: Triggering Agent {instance_id} (Cron: {cron_expr})") self._last_run_map[instance_id] = now @@ -104,8 +104,8 @@ if not instance: continue - # Only fire if agent is idle (finished previous run) - if instance.status == 'active': + # Only fire if agent is idle (finished previous run and not suspended/paused) + if instance.status != 'idle': continue last_run = self._last_run_map.get(instance_id, datetime.min) diff --git a/ai-hub/app/core/providers/factory.py b/ai-hub/app/core/providers/factory.py index 47b34a4..46b6f1c 100644 --- a/ai-hub/app/core/providers/factory.py +++ b/ai-hub/app/core/providers/factory.py @@ -97,10 +97,7 @@ if not modelName: modelName = settings.LLM_PROVIDERS.get(base_provider_for_keys, {}).get("model") - # Priority 4: Final fallback for Gemini if still missing - if not modelName and "gemini" in base_provider_for_keys: - modelName = "gemini-2.5-flash" - + # Extract base type (e.g. 'gemini_2' -> 'gemini') litellm_providers = [p.value for p in litellm.LlmProviders] base_type = kwargs.get("provider_type") or resolve_provider_info(base_provider_for_keys, "llm", _llm_providers, litellm_providers) diff --git a/ai-hub/app/core/services/rag.py b/ai-hub/app/core/services/rag.py index 0d4bd93..26c78c5 100644 --- a/ai-hub/app/core/services/rag.py +++ b/ai-hub/app/core/services/rag.py @@ -81,11 +81,7 @@ else: model_name_override = llm_prefs.get("model", "") - # FINAL FORCE REDIRECT for legacy 1.5 models (last line of defense) - if model_name_override and "gemini-1.5-flash" in str(model_name_override): - model_name_override = "gemini-2.5-flash" - if provider_name and "gemini-1.5-flash" in str(provider_name): - provider_name = provider_name.replace("gemini-1.5-flash", "gemini-2.5-flash") + kwargs = {k: v for k, v in llm_prefs.items() if k not in ["api_key", "model"]} llm_provider = get_llm_provider( @@ -187,6 +183,9 @@ # Accumulators for the DB save at the end full_answer = "" full_reasoning = "" + tool_counts = {} + input_tokens = 0 + output_tokens = 0 # Stream from specialized Architect async for event in architect.run( @@ -210,6 +209,25 @@ full_answer += event["content"] elif event["type"] == "reasoning": full_reasoning += event["content"] + elif event["type"] == "tool_start": + t_name = event.get("name") + if t_name: + if t_name not in tool_counts: + tool_counts[t_name] = {"calls": 0, "successes": 0, "failures": 0} + tool_counts[t_name]["calls"] += 1 + elif event["type"] == "tool_result": + t_name = event.get("name") + if t_name and t_name in tool_counts: + result_data = event.get("result") + if result_data and (not isinstance(result_data, dict) or result_data.get("success") is False): + tool_counts[t_name]["failures"] += 1 + else: + tool_counts[t_name]["successes"] += 1 + elif event["type"] == "token_counted": + usage = event.get("usage", {}) + if hasattr(usage, "get"): + input_tokens += usage.get("prompt_tokens", 0) + output_tokens += usage.get("completion_tokens", 0) # Forward the event to the API stream yield event @@ -234,7 +252,10 @@ "type": "finish", "message_id": assistant_message.id, "provider": provider_name, - "full_answer": full_answer + "full_answer": full_answer, + "tool_counts": tool_counts, + "input_tokens": input_tokens, + "output_tokens": output_tokens } diff --git a/ai-hub/app/db/models/agent.py b/ai-hub/app/db/models/agent.py index 150f185..1bb171f 100644 --- a/ai-hub/app/db/models/agent.py +++ b/ai-hub/app/db/models/agent.py @@ -25,6 +25,15 @@ status = Column(String, default='idle') # Enum: active, idle, listening, error_suspended current_workspace_jail = Column(String, nullable=True) last_heartbeat = Column(DateTime, default=datetime.datetime.utcnow) + + # Execution Metrics + total_runs = Column(Integer, default=0) + successful_runs = Column(Integer, default=0) + total_tokens_accumulated = Column(Integer, default=0) + total_input_tokens = Column(Integer, default=0) + total_output_tokens = Column(Integer, default=0) + total_running_time_seconds = Column(Integer, default=0) + tool_call_counts = Column(JSON, default={}) template = relationship("AgentTemplate", back_populates="instances") session = relationship("Session", primaryjoin="AgentInstance.session_id == Session.id") diff --git a/ai-hub/app/db/models/session.py b/ai-hub/app/db/models/session.py index 4915f95..7aa62eb 100644 --- a/ai-hub/app/db/models/session.py +++ b/ai-hub/app/db/models/session.py @@ -31,6 +31,7 @@ allowed_skill_names = Column(JSON, default=[], nullable=True) system_prompt_override = Column(Text, nullable=True) is_locked = Column(Boolean, default=False, nullable=False) + auto_clear_history = Column(Boolean, default=False, nullable=False) messages = relationship("Message", back_populates="session", cascade="all, delete-orphan") skills = relationship("Skill", secondary=session_skills, backref="sessions") diff --git a/ai-hub/native_hub.log b/ai-hub/native_hub.log new file mode 100644 index 0000000..4c5ff27 --- /dev/null +++ b/ai-hub/native_hub.log @@ -0,0 +1,916 @@ +INFO:app.core.tools.registry:Registered dynamic tool plugin: 'browser_automation_agent' +INFO:app.core.tools.registry:Registered dynamic tool plugin: 'mesh_file_explorer' +INFO:app.core.tools.registry:Registered dynamic tool plugin: 'mesh_inspect_drift' +INFO:app.core.tools.registry:Registered dynamic tool plugin: 'mesh_sync_control' +INFO:app.core.tools.registry:Registered dynamic tool plugin: 'mesh_terminal_control' +INFO:app.core.tools.registry:Registered dynamic tool plugin: 'mesh_wait_tasks' +INFO:app.core.tools.registry:Registered dynamic tool plugin: 'read_skill_artifact' +INFO: Started server process [6031] +INFO: Waiting for application startup. +INFO:app.db.migrate:Starting database migrations... +INFO:app.db.migrate:Column 'audio_path' already exists in 'messages'. +INFO:app.db.migrate:Column 'model_response_time' already exists in 'messages'. +INFO:app.db.migrate:Column 'token_count' already exists in 'messages'. +INFO:app.db.migrate:Column 'reasoning_content' already exists in 'messages'. +INFO:app.db.migrate:Column 'stt_provider_name' already exists in 'sessions'. +INFO:app.db.migrate:Column 'tts_provider_name' already exists in 'sessions'. +INFO:app.db.migrate:Column 'sync_workspace_id' already exists in 'sessions'. +INFO:app.db.migrate:Column 'attached_node_ids' already exists in 'sessions'. +INFO:app.db.migrate:Column 'node_sync_status' already exists in 'sessions'. +INFO:app.db.migrate:Column 'sync_config' already exists in 'sessions'. +INFO:app.db.migrate:Column 'is_cancelled' already exists in 'sessions'. +INFO:app.db.migrate:Column 'restrict_skills' already exists in 'sessions'. +INFO:app.db.migrate:Column 'allowed_skill_names' already exists in 'sessions'. +INFO:app.db.migrate:Column 'system_prompt_override' already exists in 'sessions'. +INFO:app.db.migrate:Column 'is_locked' already exists in 'sessions'. +INFO:app.db.migrate:Database migrations complete. +INFO:app.core.services.node_registry:[NodeRegistry] Reset all DB node statuses to 'offline'. +INFO:app.core.grpc.services.grpc_server:πŸš€ CORTEX gRPC Orchestrator starting on [::]:50051 +INFO:app.app:[M6] Agent Orchestrator gRPC server started on port 50051. +INFO:app.core.orchestration.scheduler:[Scheduler] Agent background services (Zombie Sweeper & CRON) started. +INFO:app.core.skills.bootstrap:Checking for system skills bootstrapping... +INFO:app.core.skills.bootstrap:System skills bootstrap completed. +INFO: Application startup complete. +INFO: Uvicorn running on http://0.0.0.0:8000 (Press CTRL+C to quit) +βœ… Loading configuration from app/config.yaml +Application startup... +--- βš™οΈ Application Configuration --- + - ACTIVE_LLM_PROVIDER: gemini + - ALLOW_OIDC_LOGIN: False + - ALLOW_PASSWORD_LOGIN: *** + - DATABASE_URL: sqlite:///./test.db + - DATA_DIR: ./data + - DB_MODE: sqlite + - DEEPSEEK_API_KEY: sk-a...6bf2 + - DEEPSEEK_MODEL_NAME: deepseek-chat + - EMBEDDING_API_KEY: AIza...sKuI + - EMBEDDING_DIMENSION: 768 + - EMBEDDING_MODEL_NAME: models/text-embedding-004 + - EMBEDDING_PROVIDER: google_gemini + - FAISS_INDEX_PATH: data/faiss_index.bin + - GEMINI_API_KEY: AIza...sKuI + - GEMINI_MODEL_NAME: gemini/gemini-3-flash-preview + - GRPC_CERT_PATH: None + - GRPC_EXTERNAL_ENDPOINT: None + - GRPC_KEY_PATH: Not Set + - GRPC_TLS_ENABLED: False + - LLM_PROVIDERS: {'gemini': {'api_key': 'AIzaSyBn5HYiZ8yKmNL0ambyz4Aspr5lKw1sKuI', 'model': 'gemini/gemini-3-flash-preview'}, 'deepseek': {'api_key': 'sk-a1b3b85a32a942c3b80e06566ef46bf2'}, 'openai': {'api_key': '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'}} + - LOG_LEVEL: DEBUG + - OIDC_CLIENT_ID: cortex-server + - OIDC_CLIENT_SECRET: aYc2...leZI + - OIDC_ENABLED: False + - OIDC_REDIRECT_URI: http://localhost:8001/users/login/callback + - OIDC_SERVER_URL: https://auth.jerxie.com + - OPENAI_API_KEY: sk-p...h34A + - PROJECT_NAME: Cortex Hub + - SECRET_KEY: inte...-123 + - SKILLS_DIR: ./data/skills + - STT_API_KEY: AIza...sKuI + - STT_MODEL_NAME: gemini-2.5-flash + - STT_PROVIDER: google_gemini + - STT_PROVIDERS: {} + - SUPER_ADMINS: ['axieyangb@gmail.com'] + - TTS_API_KEY: AIza...sKuI + - TTS_MODEL_NAME: gemini-2.5-flash-preview-tts + - TTS_PROVIDER: google_gemini + - TTS_PROVIDERS: {} + - TTS_VOICE_NAME: Kore + - VERSION: 1.0.0 +------------------------------------ +Creating database tables... +INFO: 127.0.0.1:44394 - "HEAD /api/v1/users/login/local HTTP/1.1" 405 Method Not Allowed +INFO: 127.0.0.1:44406 - "POST /api/v1/users/login/local HTTP/1.1" 200 OK +INFO:app.core.services.preference:Saving updated global preferences via admin 915a44b3-7ab9-4670-bb86-cb5ae31304bc +🏠 Configuration synchronized to app/config.yaml +INFO: 127.0.0.1:44406 - "PUT /api/v1/users/me/config HTTP/1.1" 200 OK +INFO: 127.0.0.1:44406 - "POST /api/v1/users/admin/groups HTTP/1.1" 409 Conflict +INFO: 127.0.0.1:44406 - "GET /api/v1/users/admin/groups HTTP/1.1" 200 OK +INFO: 127.0.0.1:44406 - "PUT /api/v1/users/admin/groups/75fb001c-25a2-4f40-97e8-6d9ce38f1c2c HTTP/1.1" 200 OK +INFO: 127.0.0.1:44406 - "PUT /api/v1/users/admin/users/915a44b3-7ab9-4670-bb86-cb5ae31304bc/group HTTP/1.1" 200 OK +INFO: 127.0.0.1:44406 - "POST /api/v1/nodes/admin?admin_id=915a44b3-7ab9-4670-bb86-cb5ae31304bc HTTP/1.1" 409 Conflict +INFO:app.core.services.node_registry:[πŸ“‹] NodeRegistry: Deregistered test-node-1 +INFO: 127.0.0.1:44406 - "DELETE /api/v1/nodes/admin/test-node-1?admin_id=915a44b3-7ab9-4670-bb86-cb5ae31304bc HTTP/1.1" 200 OK +INFO:app.api.routes.nodes:[admin] Created node 'test-node-1' by admin 915a44b3-7ab9-4670-bb86-cb5ae31304bc +INFO: 127.0.0.1:44406 - "POST /api/v1/nodes/admin?admin_id=915a44b3-7ab9-4670-bb86-cb5ae31304bc HTTP/1.1" 200 OK +INFO: 127.0.0.1:44406 - "POST /api/v1/nodes/admin?admin_id=915a44b3-7ab9-4670-bb86-cb5ae31304bc HTTP/1.1" 409 Conflict +INFO:app.core.services.node_registry:[πŸ“‹] NodeRegistry: Deregistered test-node-2 +INFO: 127.0.0.1:44406 - "DELETE /api/v1/nodes/admin/test-node-2?admin_id=915a44b3-7ab9-4670-bb86-cb5ae31304bc HTTP/1.1" 200 OK +INFO:app.api.routes.nodes:[admin] Created node 'test-node-2' by admin 915a44b3-7ab9-4670-bb86-cb5ae31304bc +[NodeRegistry] DB mark-offline failed for test-node-2: UPDATE statement on table 'agent_nodes' expected to update 1 row(s); 0 were matched. +INFO: 127.0.0.1:44406 - "POST /api/v1/nodes/admin?admin_id=915a44b3-7ab9-4670-bb86-cb5ae31304bc HTTP/1.1" 200 OK +INFO: 127.0.0.1:44406 - "POST /api/v1/users/admin/groups HTTP/1.1" 409 Conflict +INFO:app.api.routes.agent_update:[AgentUpdate] Version check β†’ 1.0.77 +INFO: 127.0.0.1:44410 - "GET /api/v1/agent/version HTTP/1.1" 200 OK +INFO:app.api.routes.agent_update:[AgentUpdate] Version check β†’ 1.0.77 +INFO: 127.0.0.1:44414 - "GET /api/v1/agent/version HTTP/1.1" 200 OK +INFO:app.core.grpc.services.grpc_server:[gRPC] Incoming RPC Call: /agent.AgentOrchestrator/SyncConfiguration +INFO:app.core.grpc.services.grpc_server:[gRPC] Incoming RPC Call: /agent.AgentOrchestrator/SyncConfiguration +INFO:app.core.grpc.services.grpc_server:[πŸ”‘] SyncConfiguration REQUEST from test-node-1 (token prefix: WHNo...) +INFO:app.core.grpc.services.grpc_server:[πŸ”‘] SyncConfiguration REQUEST from test-node-2 (token prefix: ZMfp...) +INFO:app.core.grpc.services.grpc_server:[πŸ”‘] Token validated for test-node-1 (owner: 915a44b3-7ab9-4670-bb86-cb5ae31304bc) +INFO:app.core.grpc.services.grpc_server:[πŸ”‘] Handshake successful for test-node-1 (owner: 915a44b3-7ab9-4670-bb86-cb5ae31304bc) +INFO:app.core.grpc.services.grpc_server:[πŸ”‘] Token validated for test-node-2 (owner: 915a44b3-7ab9-4670-bb86-cb5ae31304bc) +INFO:app.core.services.node_registry:[πŸ“‹] NodeRegistry: Registered test-node-1 (owner: 915a44b3-7ab9-4670-bb86-cb5ae31304bc) | Stats enabled +INFO:app.core.grpc.services.grpc_server:[πŸ”‘] Handshake successful for test-node-2 (owner: 915a44b3-7ab9-4670-bb86-cb5ae31304bc) +INFO:app.core.grpc.services.grpc_server:[gRPC] Incoming RPC Call: /agent.AgentOrchestrator/ReportHealth +INFO:app.core.services.node_registry:[πŸ“‹] NodeRegistry: Registered test-node-2 (owner: 915a44b3-7ab9-4670-bb86-cb5ae31304bc) | Stats enabled +INFO:app.core.grpc.services.grpc_server:[gRPC] Incoming RPC Call: /agent.AgentOrchestrator/TaskStream +INFO:app.core.grpc.services.grpc_server:[gRPC] Incoming RPC Call: /agent.AgentOrchestrator/ReportHealth +INFO:app.core.grpc.services.grpc_server:[gRPC] Incoming RPC Call: /agent.AgentOrchestrator/TaskStream +INFO:app.core.grpc.services.grpc_server:[*] Node test-node-1 Attempting to establish TaskStream... +INFO:app.core.grpc.services.grpc_server:[*] Node test-node-2 Attempting to establish TaskStream... +INFO:app.core.grpc.services.grpc_server:[*] Node test-node-1 Online (TaskStream established) +INFO:app.core.grpc.services.grpc_server:[*] Node test-node-2 Online (TaskStream established) + [πŸ“πŸ”„] Triggering Resync Check for test-node-1... + [πŸ“πŸ”„] Triggering Resync Check for test-node-2... + +================================================== +πŸ“‘ CORTEX MESH DASHBOARD | 2 Nodes Online +-------------------------------------------------- + 🟒 test-node-1 | Workers: 0 | Running: 0 tasks + Capabilities: {'has_sudo': 'true', 'has_display': 'false', 'os_release': '6.10.11-linuxkit', 'arch': 'aarch64', 'os': 'linux', 'gpu': 'none', 'local_ip': '172.27.0.2', 'is_root': 'false', 'shell': 'v1'} + 🟒 test-node-2 | Workers: 0 | Running: 0 tasks + Capabilities: {'has_sudo': 'true', 'has_display': 'false', 'os_release': '6.10.11-linuxkit', 'arch': 'aarch64', 'os': 'linux', 'gpu': 'none', 'local_ip': '172.27.0.2', 'is_root': 'false', 'shell': 'v1'} +================================================== + + [πŸ“πŸ§Ή] Running Mirror Cleanup. Active Sessions: 18 + [πŸ“πŸ§Ή] Running Mirror Cleanup. Active Sessions: 4 + [πŸ“πŸ—‘οΈ] Purged orphaned ghost mirror: 30 + [πŸ“βš οΈ] Failed to purge orphaned mirror 30: [Errno 2] No such file or directory: './data/mirrors/30' + +================================================== +πŸ“‘ CORTEX MESH DASHBOARD | 2 Nodes Online +-------------------------------------------------- + 🟒 test-node-1 | Workers: 0 | Running: 0 tasks + Capabilities: {'has_sudo': 'true', 'has_display': 'false', 'os_release': '6.10.11-linuxkit', 'arch': 'aarch64', 'os': 'linux', 'gpu': 'none', 'local_ip': '172.27.0.2', 'is_root': 'false', 'shell': 'v1'} + 🟒 test-node-2 | Workers: 0 | Running: 0 tasks + Capabilities: {'has_sudo': 'true', 'has_display': 'false', 'os_release': '6.10.11-linuxkit', 'arch': 'aarch64', 'os': 'linux', 'gpu': 'none', 'local_ip': '172.27.0.2', 'is_root': 'false', 'shell': 'v1'} +================================================== + +INFO:app.api.routes.nodes:[admin] Created node 'test-agent-node-ec22f68d' by admin 915a44b3-7ab9-4670-bb86-cb5ae31304bc +INFO: 127.0.0.1:41294 - "POST /api/v1/nodes/admin?admin_id=915a44b3-7ab9-4670-bb86-cb5ae31304bc HTTP/1.1" 200 OK +INFO:app.core.grpc.services.assistant:[πŸ“πŸ“€] Workspace session-31-11d0eb16 prepared on server for offline node test-agent-node-ec22f68d +INFO: 127.0.0.1:41294 - "POST /api/v1/agents/deploy HTTP/1.1" 200 OK +INFO: 127.0.0.1:41294 - "GET /api/v1/sessions/31 HTTP/1.1" 200 OK +INFO: 127.0.0.1:41294 - "GET /api/v1/agents/a8bd22bf-9812-4702-95a9-13afaa5ac038/triggers HTTP/1.1" 200 OK + +================================================== +πŸ“‘ CORTEX MESH DASHBOARD | 2 Nodes Online +-------------------------------------------------- + 🟒 test-node-1 | Workers: 0 | Running: 0 tasks + Capabilities: {'has_sudo': 'true', 'has_display': 'false', 'os_release': '6.10.11-linuxkit', 'arch': 'aarch64', 'os': 'linux', 'gpu': 'none', 'local_ip': '172.27.0.2', 'is_root': 'false', 'shell': 'v1'} + 🟒 test-node-2 | Workers: 0 | Running: 0 tasks + Capabilities: {'has_sudo': 'true', 'has_display': 'false', 'os_release': '6.10.11-linuxkit', 'arch': 'aarch64', 'os': 'linux', 'gpu': 'none', 'local_ip': '172.27.0.2', 'is_root': 'false', 'shell': 'v1'} +================================================== + +INFO:app.core.orchestration.scheduler:[Scheduler] INTERVAL WAKEUP: Triggering Agent a8bd22bf-9812-4702-95a9-13afaa5ac038 (Wait: 5s, Elapsed: 63909884476s) +INFO:app.core.services.rag:[RAG] Mesh Context gathered. Length: 237 chars. +INFO:app.core.services.rag:[RAG] Mesh Context excerpt: Attached Agent Nodes (Infrastructure): +- Node ID: test-agent-node-ec22f68d + Name: Agent Test Node + Description: No description provided. + Status: offline + Terminal Sandbox Mode: PERMISSIVE + AI Re... +WARNING:app.core.services.prompt:Prompt with slug 'rag-pipeline' not found. +INFO:root:[Architect] Starting autonomous loop (Turn 1). Prompt Size: 121 chars across 2 messages. +INFO:root:[Architect] Msg 0 (system): You are a cron agent. Run shell tasks periodically.... +INFO:root:[Architect] Msg 1 (user): Hello test agent! Just reply the word 'Acknowledged' and nothing else.... +17:41:15 - LiteLLM:INFO: utils.py:3895 - +LiteLLM completion() model= gemini-3-flash-preview; provider = gemini +INFO:LiteLLM: +LiteLLM completion() model= gemini-3-flash-preview; provider = gemini +INFO:root:[Architect] First chunk received after 1.12s +[AgentExecutor] Task 4.2: Idempotency check for a8bd22bf-9812-4702-95a9-13afaa5ac038 in /tmp/cortex/agent_77046c0c/ +[AgentExecutor] Starting run for a8bd22bf-9812-4702-95a9-13afaa5ac038 with provider 'gemini'. Prompt length: 70 +INFO: 127.0.0.1:55284 - "GET /api/v1/sessions/31/messages HTTP/1.1" 200 OK +INFO: 127.0.0.1:55284 - "GET /api/v1/agents HTTP/1.1" 200 OK +INFO: 127.0.0.1:55284 - "GET /api/v1/agents/a8bd22bf-9812-4702-95a9-13afaa5ac038/triggers HTTP/1.1" 200 OK +INFO: 127.0.0.1:55284 - "PATCH /api/v1/agents/a8bd22bf-9812-4702-95a9-13afaa5ac038/status HTTP/1.1" 200 OK +INFO: 127.0.0.1:55284 - "PATCH /api/v1/agents/a8bd22bf-9812-4702-95a9-13afaa5ac038/config HTTP/1.1" 200 OK +INFO: 127.0.0.1:55284 - "DELETE /api/v1/agents/a8bd22bf-9812-4702-95a9-13afaa5ac038 HTTP/1.1" 200 OK +INFO:app.core.services.node_registry:[πŸ“‹] NodeRegistry: Deregistered test-agent-node-ec22f68d +INFO: 127.0.0.1:55284 - "DELETE /api/v1/nodes/admin/test-agent-node-ec22f68d?admin_id=915a44b3-7ab9-4670-bb86-cb5ae31304bc HTTP/1.1" 200 OK +INFO: 127.0.0.1:55286 - "GET /api/v1/speech/voices HTTP/1.1" 200 OK +INFO:app.api.routes.tts:Using TTS provider: GeminiTTSProvider for user=915a44b3-7ab9-4670-bb86-cb5ae31304bc +INFO:app.core.providers.tts.gemini:TTS request [model=gemini-2.5-flash-preview-tts, vertex=False]: 'Hello from integration test audio pipeline.' +INFO:app.core.services.tts:Successfully gathered audio data for all 1 chunks. +INFO:app.core.services.tts:Concatenated 1 chunks into a single PCM stream. +INFO: 127.0.0.1:55288 - "POST /api/v1/speech?stream=false HTTP/1.1" 200 OK +INFO:app.api.routes.stt:Received transcription request for file: test_audio_pipeline.wav +INFO:app.api.routes.stt:Resolving STT. user_id=915a44b3-7ab9-4670-bb86-cb5ae31304bc, provider=google_gemini +INFO:app.api.routes.stt:Using STT provider: GoogleSTTProvider +INFO:app.core.services.stt:Starting transcription for audio data (175290 bytes). + +================================================== +πŸ“‘ CORTEX MESH DASHBOARD | 2 Nodes Online +-------------------------------------------------- + 🟒 test-node-1 | Workers: 0 | Running: 0 tasks + Capabilities: {'has_sudo': 'true', 'has_display': 'false', 'os_release': '6.10.11-linuxkit', 'arch': 'aarch64', 'os': 'linux', 'gpu': 'none', 'local_ip': '172.27.0.2', 'is_root': 'false', 'shell': 'v1'} + 🟒 test-node-2 | Workers: 0 | Running: 0 tasks + Capabilities: {'has_sudo': 'true', 'has_display': 'false', 'os_release': '6.10.11-linuxkit', 'arch': 'aarch64', 'os': 'linux', 'gpu': 'none', 'local_ip': '172.27.0.2', 'is_root': 'false', 'shell': 'v1'} +================================================== + +INFO:app.core.services.stt:Transcribed audio. Length: 43 characters. +INFO: 127.0.0.1:55288 - "POST /api/v1/stt/transcribe HTTP/1.1" 200 OK +INFO: 127.0.0.1:45688 - "GET /api/v1/nodes/test-node-1/status HTTP/1.1" 200 OK +INFO: 127.0.0.1:45688 - "POST /api/v1/sessions/ HTTP/1.1" 200 OK +[πŸ“πŸ“€] Initiating Workspace Push for Session session-32-1511b094 to test-node-1 + [πŸ“] Sync Status from test-node-1: Synchronized +[πŸ“πŸ“€] Initiating Workspace Push for Session session-32-1511b094 to test-node-2 + [πŸ“] Sync Status from test-node-2: Synchronized +INFO: 127.0.0.1:45688 - "POST /api/v1/sessions/32/nodes HTTP/1.1" 200 OK +[πŸ“βœοΈ] AI Write: case1_c501c142.txt (Session: session-32-1511b094) -> Dispatching to 2 nodes +INFO: 127.0.0.1:45688 - "POST /api/v1/nodes/test-node-1/fs/touch HTTP/1.1" 200 OK + [πŸ“] Sync Status from test-node-1: File written + [πŸ“] Sync Status from test-node-2: File written + [πŸ“πŸ“₯] Received Manifest from test-node-2 for session-32-1511b094 + [πŸ“πŸ“₯] Received Manifest from test-node-1 for session-32-1511b094 +INFO: 127.0.0.1:45688 - "GET /api/v1/nodes/test-node-2/fs/cat?path=case1_c501c142.txt&session_id=session-32-1511b094 HTTP/1.1" 200 OK +INFO: 127.0.0.1:45688 - "GET /api/v1/nodes/test-node-1/fs/cat?path=case1_c501c142.txt&session_id=session-32-1511b094 HTTP/1.1" 200 OK +[πŸ“βœοΈ] AI Write: case2_4e925763.txt (Session: session-32-1511b094) -> Dispatching to 2 nodes +INFO: 127.0.0.1:45688 - "POST /api/v1/nodes/test-node-1/fs/touch HTTP/1.1" 200 OK + [πŸ“] Sync Status from test-node-1: File written + [πŸ“] Sync Status from test-node-2: File written + [πŸ“πŸ“₯] Received Manifest from test-node-2 for session-32-1511b094 + [πŸ“πŸ“₯] Received Manifest from test-node-1 for session-32-1511b094 +INFO: 127.0.0.1:45688 - "GET /api/v1/nodes/test-node-2/fs/cat?path=case2_4e925763.txt&session_id=session-32-1511b094 HTTP/1.1" 200 OK +INFO: 127.0.0.1:45688 - "GET /api/v1/nodes/test-node-1/fs/cat?path=case2_4e925763.txt&session_id=session-32-1511b094 HTTP/1.1" 200 OK +[πŸ“βœοΈ] AI Write: case3_f2c109da.txt (Session: session-32-1511b094) -> Dispatching to 2 nodes +INFO: 127.0.0.1:45688 - "POST /api/v1/nodes/test-node-1/fs/touch HTTP/1.1" 200 OK + [πŸ“] Sync Status from test-node-2: File written + [πŸ“] Sync Status from test-node-1: File written + [πŸ“πŸ“₯] Received Manifest from test-node-1 for session-32-1511b094 + [πŸ“πŸ“₯] Received Manifest from test-node-2 for session-32-1511b094 +INFO: 127.0.0.1:45688 - "GET /api/v1/nodes/test-node-2/fs/cat?path=case3_f2c109da.txt&session_id=session-32-1511b094 HTTP/1.1" 200 OK +[πŸ“πŸ—‘οΈ] AI Remove: case3_f2c109da.txt (Session: session-32-1511b094) -> Dispatching to 2 nodes +INFO: 127.0.0.1:45688 - "POST /api/v1/nodes/test-node-1/fs/rm HTTP/1.1" 200 OK + [πŸ“] Sync Status from test-node-1: Deleted case3_f2c109da.txt + [πŸ“] Sync Status from test-node-2: Deleted case3_f2c109da.txt + [πŸ“πŸ“₯] Received Manifest from test-node-2 for session-32-1511b094 + [πŸ“πŸ“₯] Received Manifest from test-node-1 for session-32-1511b094 +INFO: 127.0.0.1:45688 - "GET /api/v1/nodes/test-node-2/fs/cat?path=case3_f2c109da.txt&session_id=session-32-1511b094 HTTP/1.1" 404 Not Found +INFO: 127.0.0.1:45688 - "GET /api/v1/nodes/test-node-1/fs/cat?path=case3_f2c109da.txt&session_id=session-32-1511b094 HTTP/1.1" 404 Not Found +[πŸ“βœοΈ] AI Write: case4_61657f07.txt (Session: session-32-1511b094) -> Dispatching to 2 nodes +INFO: 127.0.0.1:45688 - "POST /api/v1/nodes/test-node-1/fs/touch HTTP/1.1" 200 OK + [πŸ“] Sync Status from test-node-1: File written + [πŸ“πŸ“₯] Received Manifest from test-node-1 for session-32-1511b094 + [πŸ“] Sync Status from test-node-2: File written + [πŸ“πŸ“₯] Received Manifest from test-node-2 for session-32-1511b094 +INFO: 127.0.0.1:45688 - "GET /api/v1/nodes/test-node-2/fs/cat?path=case4_61657f07.txt&session_id=session-32-1511b094 HTTP/1.1" 200 OK +[πŸ“πŸ—‘οΈ] AI Remove: case4_61657f07.txt (Session: session-32-1511b094) -> Dispatching to 2 nodes +INFO: 127.0.0.1:45688 - "POST /api/v1/nodes/test-node-2/fs/rm HTTP/1.1" 200 OK + [πŸ“] Sync Status from test-node-2: Deleted case4_61657f07.txt + [πŸ“] Sync Status from test-node-1: Deleted case4_61657f07.txt + [πŸ“πŸ“₯] Received Manifest from test-node-1 for session-32-1511b094 + [πŸ“πŸ“₯] Received Manifest from test-node-2 for session-32-1511b094 +INFO: 127.0.0.1:45688 - "GET /api/v1/nodes/test-node-1/fs/cat?path=case4_61657f07.txt&session_id=session-32-1511b094 HTTP/1.1" 404 Not Found +INFO: 127.0.0.1:45688 - "GET /api/v1/nodes/test-node-1/fs/cat?path=case4_61657f07.txt&session_id=session-32-1511b094 HTTP/1.1" 404 Not Found +[πŸ“βœοΈ] AI Write: case9_latency_7e626973.txt (Session: session-32-1511b094) -> Dispatching to 2 nodes +INFO: 127.0.0.1:45688 - "POST /api/v1/nodes/test-node-1/fs/touch HTTP/1.1" 200 OK + [πŸ“] Sync Status from test-node-1: File written + [πŸ“] Sync Status from test-node-2: File written + [πŸ“πŸ“₯] Received Manifest from test-node-1 for session-32-1511b094 + [πŸ“πŸ“₯] Received Manifest from test-node-2 for session-32-1511b094 +INFO: 127.0.0.1:45688 - "GET /api/v1/nodes/test-node-2/fs/cat?path=case9_latency_7e626973.txt&session_id=session-32-1511b094 HTTP/1.1" 200 OK +[πŸ“πŸ—‘οΈ] AI Remove: case9_latency_7e626973.txt (Session: session-32-1511b094) -> Dispatching to 2 nodes +INFO: 127.0.0.1:45688 - "POST /api/v1/nodes/test-node-1/fs/rm HTTP/1.1" 200 OK + [πŸ“] Sync Status from test-node-1: Deleted case9_latency_7e626973.txt + [πŸ“] Sync Status from test-node-2: Deleted case9_latency_7e626973.txt + [πŸ“πŸ“₯] Received Manifest from test-node-1 for session-32-1511b094 + [πŸ“πŸ“₯] Received Manifest from test-node-2 for session-32-1511b094 + [πŸ“βš‘] Fast Sync Complete: case1_c501c142.txt + [πŸ“πŸ“’] Broadcasting case1_c501c142.txt from test-node-1 to: test-node-2 + [πŸ“βš‘] Fast Sync Complete: case1_c501c142.txt + [πŸ“πŸ“’] Broadcasting case1_c501c142.txt from test-node-2 to: test-node-1 + [πŸ“] Sync Status from test-node-2: File case1_c501c142.txt synced + [πŸ“] Sync Status from test-node-1: File case1_c501c142.txt synced +INFO: 127.0.0.1:45688 - "GET /api/v1/nodes/test-node-1/fs/cat?path=case9_latency_7e626973.txt&session_id=session-32-1511b094 HTTP/1.1" 404 Not Found +INFO: 127.0.0.1:45688 - "GET /api/v1/nodes/test-node-2/fs/cat?path=case9_latency_7e626973.txt&session_id=session-32-1511b094 HTTP/1.1" 404 Not Found +[πŸ“βœοΈ] AI Write: case11_hub_e51e3d64.txt (Session: session-32-1511b094) -> Dispatching to 2 nodes +INFO: 127.0.0.1:45688 - "POST /api/v1/nodes/hub/fs/touch HTTP/1.1" 200 OK + [πŸ“] Sync Status from test-node-1: File written + [πŸ“] Sync Status from test-node-2: File written + [πŸ“πŸ“₯] Received Manifest from test-node-1 for session-32-1511b094 + [πŸ“πŸ“₯] Received Manifest from test-node-2 for session-32-1511b094 +INFO: 127.0.0.1:45688 - "GET /api/v1/nodes/hub/fs/cat?path=case11_hub_e51e3d64.txt&session_id=session-32-1511b094 HTTP/1.1" 200 OK +[πŸ“βœοΈ] AI Write: case10_resync_22602b38.txt (Session: session-32-1511b094) -> Dispatching to 2 nodes +INFO: 127.0.0.1:45688 - "POST /api/v1/nodes/test-node-1/fs/touch HTTP/1.1" 200 OK + [πŸ“] Sync Status from test-node-1: File written + [πŸ“] Sync Status from test-node-2: File written + [πŸ“πŸ“₯] Received Manifest from test-node-1 for session-32-1511b094 + [πŸ“πŸ“₯] Received Manifest from test-node-2 for session-32-1511b094 +INFO: 127.0.0.1:45688 - "GET /api/v1/nodes/test-node-2/fs/cat?path=case10_resync_22602b38.txt&session_id=session-32-1511b094 HTTP/1.1" 200 OK + [πŸ“βš‘] Fast Sync Complete: case2_4e925763.txt + [πŸ“πŸ“’] Broadcasting case2_4e925763.txt from test-node-2 to: test-node-1 + [πŸ“βš‘] Fast Sync Complete: case2_4e925763.txt + [πŸ“πŸ“’] Broadcasting case2_4e925763.txt from test-node-1 to: test-node-2 + [πŸ“] Sync Status from test-node-1: File case2_4e925763.txt synced + [πŸ“] Sync Status from test-node-2: File case2_4e925763.txt synced + [πŸ“βš‘] Fast Sync Complete: case11_hub_e51e3d64.txt + [πŸ“πŸ“’] Broadcasting case11_hub_e51e3d64.txt from test-node-2 to: test-node-1 + [πŸ“βš‘] Fast Sync Complete: case11_hub_e51e3d64.txt + [πŸ“πŸ“’] Broadcasting case11_hub_e51e3d64.txt from test-node-1 to: test-node-2 + [πŸ“] Sync Status from test-node-1: File case11_hub_e51e3d64.txt synced + [πŸ“] Sync Status from test-node-2: File case11_hub_e51e3d64.txt synced + +================================================== +πŸ“‘ CORTEX MESH DASHBOARD | 2 Nodes Online +-------------------------------------------------- + 🟒 test-node-1 | Workers: 0 | Running: 0 tasks + Capabilities: {'has_sudo': 'true', 'has_display': 'false', 'os_release': '6.10.11-linuxkit', 'arch': 'aarch64', 'os': 'linux', 'gpu': 'none', 'local_ip': '172.27.0.2', 'is_root': 'false', 'shell': 'v1'} + 🟒 test-node-2 | Workers: 0 | Running: 0 tasks + Capabilities: {'has_sudo': 'true', 'has_display': 'false', 'os_release': '6.10.11-linuxkit', 'arch': 'aarch64', 'os': 'linux', 'gpu': 'none', 'local_ip': '172.27.0.2', 'is_root': 'false', 'shell': 'v1'} +================================================== + + [πŸ“βš‘] Fast Sync Complete: case10_resync_22602b38.txt + [πŸ“πŸ“’] Broadcasting case10_resync_22602b38.txt from test-node-1 to: test-node-2 + [πŸ“βš‘] Fast Sync Complete: case10_resync_22602b38.txt + [πŸ“πŸ“’] Broadcasting case10_resync_22602b38.txt from test-node-2 to: test-node-1 + [πŸ“] Sync Status from test-node-2: File case10_resync_22602b38.txt synced + [πŸ“] Sync Status from test-node-1: File case10_resync_22602b38.txt synced +INFO: 127.0.0.1:60888 - "GET /api/v1/nodes/test-node-2/fs/cat?path=case10_resync_22602b38.txt&session_id=session-32-1511b094 HTTP/1.1" 200 OK +[πŸ“βœοΈ] AI Write: case5_large_dc713ede.txt (Session: session-32-1511b094) -> Dispatching to 2 nodes +INFO: 127.0.0.1:60888 - "POST /api/v1/nodes/test-node-1/fs/touch HTTP/1.1" 200 OK + [πŸ“] Sync Status from test-node-1: File written + [πŸ“πŸ“₯] Received Manifest from test-node-1 for session-32-1511b094 + [πŸ“] Sync Status from test-node-2: File written + [πŸ“πŸ“₯] Received Manifest from test-node-2 for session-32-1511b094 +INFO: 127.0.0.1:60888 - "GET /api/v1/nodes/test-node-2/fs/cat?path=case5_large_dc713ede.txt&session_id=session-32-1511b094 HTTP/1.1" 200 OK +INFO: 127.0.0.1:60888 - "GET /api/v1/nodes/test-node-1/fs/cat?path=case5_large_dc713ede.txt&session_id=session-32-1511b094 HTTP/1.1" 200 OK +[πŸ“βœοΈ] AI Write: case6_large_a32dad68.txt (Session: session-32-1511b094) -> Dispatching to 2 nodes +INFO: 127.0.0.1:60888 - "POST /api/v1/nodes/test-node-1/fs/touch HTTP/1.1" 200 OK + [πŸ“] Sync Status from test-node-1: File written + [πŸ“πŸ“₯] Received Manifest from test-node-1 for session-32-1511b094 + [πŸ“] Sync Status from test-node-2: File written + [πŸ“πŸ“₯] Received Manifest from test-node-2 for session-32-1511b094 +INFO: 127.0.0.1:60888 - "GET /api/v1/nodes/test-node-2/fs/cat?path=case6_large_a32dad68.txt&session_id=session-32-1511b094 HTTP/1.1" 200 OK +INFO: 127.0.0.1:60888 - "GET /api/v1/nodes/test-node-1/fs/cat?path=case6_large_a32dad68.txt&session_id=session-32-1511b094 HTTP/1.1" 200 OK +[πŸ“βœοΈ] AI Write: case7_large_de27d02d.txt (Session: session-32-1511b094) -> Dispatching to 2 nodes +INFO: 127.0.0.1:60888 - "POST /api/v1/nodes/test-node-1/fs/touch HTTP/1.1" 200 OK + [πŸ“] Sync Status from test-node-1: File written + [πŸ“πŸ“₯] Received Manifest from test-node-1 for session-32-1511b094 + [πŸ“] Sync Status from test-node-2: File written + [πŸ“πŸ“₯] Received Manifest from test-node-2 for session-32-1511b094 +INFO: 127.0.0.1:60888 - "GET /api/v1/nodes/test-node-2/fs/cat?path=case7_large_de27d02d.txt&session_id=session-32-1511b094 HTTP/1.1" 200 OK +[πŸ“πŸ—‘οΈ] AI Remove: case7_large_de27d02d.txt (Session: session-32-1511b094) -> Dispatching to 2 nodes +INFO: 127.0.0.1:60888 - "POST /api/v1/nodes/test-node-1/fs/rm HTTP/1.1" 200 OK + [πŸ“] Sync Status from test-node-1: Deleted case7_large_de27d02d.txt + [πŸ“πŸ“₯] Received Manifest from test-node-1 for session-32-1511b094 + [πŸ“] Sync Status from test-node-2: Deleted case7_large_de27d02d.txt + [πŸ“πŸ“₯] Received Manifest from test-node-2 for session-32-1511b094 +INFO: 127.0.0.1:60888 - "GET /api/v1/nodes/test-node-2/fs/cat?path=case7_large_de27d02d.txt&session_id=session-32-1511b094 HTTP/1.1" 404 Not Found +INFO: 127.0.0.1:60888 - "GET /api/v1/nodes/test-node-1/fs/cat?path=case7_large_de27d02d.txt&session_id=session-32-1511b094 HTTP/1.1" 404 Not Found + [πŸ“πŸ“₯] Sync Starting: case5_large_dc713ede.txt (20971520 bytes, 5 chunks) +[πŸ“βœοΈ] AI Write: case8_large_4cf00f89.txt (Session: session-32-1511b094) -> Dispatching to 2 nodes +INFO: 127.0.0.1:60888 - "POST /api/v1/nodes/test-node-1/fs/touch HTTP/1.1" 200 OK + [πŸ“πŸ“’] Broadcasting case5_large_dc713ede.txt from test-node-2 to: test-node-1 + [πŸ“πŸ“₯] Sync Starting: case5_large_dc713ede.txt (20971520 bytes, 5 chunks) + [πŸ“πŸ“’] Broadcasting case5_large_dc713ede.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting case5_large_dc713ede.txt from test-node-2 to: test-node-1 + [πŸ“πŸ“’] Broadcasting case5_large_dc713ede.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting case5_large_dc713ede.txt from test-node-2 to: test-node-1 + [πŸ“πŸ“’] Broadcasting case5_large_dc713ede.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting case5_large_dc713ede.txt from test-node-2 to: test-node-1 + [πŸ“πŸ“’] Broadcasting case5_large_dc713ede.txt from test-node-1 to: test-node-2 +INFO: 127.0.0.1:60888 - "GET /api/v1/nodes/test-node-2/fs/cat?path=case8_large_4cf00f89.txt&session_id=session-32-1511b094 HTTP/1.1" 200 OK +[πŸ“πŸ—‘οΈ] AI Remove: case8_large_4cf00f89.txt (Session: session-32-1511b094) -> Dispatching to 2 nodes +INFO: 127.0.0.1:60888 - "POST /api/v1/nodes/test-node-2/fs/rm HTTP/1.1" 200 OK +INFO: 127.0.0.1:60888 - "GET /api/v1/nodes/test-node-1/fs/cat?path=case8_large_4cf00f89.txt&session_id=session-32-1511b094 HTTP/1.1" 404 Not Found +INFO: 127.0.0.1:60888 - "GET /api/v1/nodes/test-node-1/fs/cat?path=case8_large_4cf00f89.txt&session_id=session-32-1511b094 HTTP/1.1" 404 Not Found +INFO: 127.0.0.1:60888 - "POST /api/v1/nodes/test-node-1/dispatch?user_id=915a44b3-7ab9-4670-bb86-cb5ae31304bc HTTP/1.1" 200 OK +INFO: 127.0.0.1:60888 - "GET /api/v1/nodes/test-node-2/fs/ls?path=.&session_id=session-32-1511b094 HTTP/1.1" 200 OK + [πŸ“βœ…] Sync Complete: case5_large_dc713ede.txt (Swapped and verified) + [πŸ“πŸ“’] Broadcasting case5_large_dc713ede.txt from test-node-2 to: test-node-1 + [πŸ“] Sync Status from test-node-2: File written + [πŸ“πŸ“₯] Received Manifest from test-node-2 for session-32-1511b094 + [πŸ“] Sync Status from test-node-2: Deleted case8_large_4cf00f89.txt + [πŸ“πŸ“₯] Received Manifest from test-node-2 for session-32-1511b094 +[⚠️] Hash Mismatch for /app/ai-hub/data/mirrors/session-32-1511b094/case5_large_dc713ede.txt.cortex_tmp: expected 6fa6fa165fcf0e31a3bd47070ef118b90c75026a185e2b9f8ca4a1c2876d3c8d, got f267cbcf29a3502174cde6937a0a3e9844a1954823397068eeaed716902871b0 + [πŸ“πŸ“’] Broadcasting case5_large_dc713ede.txt from test-node-1 to: test-node-2 + [πŸ“] Sync Status from test-node-1: File written + [πŸ“πŸ“₯] Received Manifest from test-node-1 for session-32-1511b094 + [πŸ“] Sync Status from test-node-1: Deleted case8_large_4cf00f89.txt + [πŸ“πŸ“₯] Received Manifest from test-node-1 for session-32-1511b094 + [πŸ“] Sync Status from test-node-1: File case5_large_dc713ede.txt synced + [πŸ“] Sync Status from test-node-2: File case5_large_dc713ede.txt synced +INFO: 127.0.0.1:60888 - "GET /api/v1/nodes/test-node-2/fs/ls?path=.&session_id=session-32-1511b094 HTTP/1.1" 200 OK +INFO: 127.0.0.1:60888 - "GET /api/v1/nodes/test-node-2/fs/ls?path=.&session_id=session-32-1511b094 HTTP/1.1" 200 OK + [πŸ“πŸ“₯] Sync Starting: case6_large_a32dad68.txt (20971520 bytes, 5 chunks) + [πŸ“πŸ“’] Broadcasting case6_large_a32dad68.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“₯] Sync Starting: case6_large_a32dad68.txt (20971520 bytes, 5 chunks) + [πŸ“πŸ“’] Broadcasting case6_large_a32dad68.txt from test-node-2 to: test-node-1 + [πŸ“πŸ“’] Broadcasting case6_large_a32dad68.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting case6_large_a32dad68.txt from test-node-2 to: test-node-1 + [πŸ“πŸ“’] Broadcasting case6_large_a32dad68.txt from test-node-1 to: test-node-2 +INFO: 127.0.0.1:60888 - "GET /api/v1/nodes/test-node-2/fs/ls?path=.&session_id=session-32-1511b094 HTTP/1.1" 200 OK + [πŸ“πŸ“’] Broadcasting case6_large_a32dad68.txt from test-node-2 to: test-node-1 + [πŸ“πŸ“’] Broadcasting case6_large_a32dad68.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting case6_large_a32dad68.txt from test-node-2 to: test-node-1 + [πŸ“βœ…] Sync Complete: case6_large_a32dad68.txt (Swapped and verified) + [πŸ“πŸ“’] Broadcasting case6_large_a32dad68.txt from test-node-1 to: test-node-2 +INFO: 127.0.0.1:60888 - "GET /api/v1/nodes/test-node-2/fs/ls?path=.&session_id=session-32-1511b094 HTTP/1.1" 200 OK +[⚠️] Hash Mismatch for /app/ai-hub/data/mirrors/session-32-1511b094/case6_large_a32dad68.txt.cortex_tmp: expected 6fa6fa165fcf0e31a3bd47070ef118b90c75026a185e2b9f8ca4a1c2876d3c8d, got f267cbcf29a3502174cde6937a0a3e9844a1954823397068eeaed716902871b0 + [πŸ“πŸ“’] Broadcasting case6_large_a32dad68.txt from test-node-2 to: test-node-1 + [πŸ“] Sync Status from test-node-2: File case6_large_a32dad68.txt synced + [πŸ“] Sync Status from test-node-1: File case6_large_a32dad68.txt synced +INFO: 127.0.0.1:60888 - "GET /api/v1/nodes/test-node-2/fs/ls?path=.&session_id=session-32-1511b094 HTTP/1.1" 200 OK +INFO: 127.0.0.1:60888 - "GET /api/v1/nodes/test-node-2/fs/ls?path=.&session_id=session-32-1511b094 HTTP/1.1" 200 OK +INFO: 127.0.0.1:60888 - "GET /api/v1/nodes/test-node-2/fs/ls?path=.&session_id=session-32-1511b094 HTTP/1.1" 200 OK + [πŸ“πŸ“₯] Sync Starting: gigabyte_05438911.txt (1048576000 bytes, 250 chunks) + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + +================================================== +πŸ“‘ CORTEX MESH DASHBOARD | 2 Nodes Online +-------------------------------------------------- + 🟒 test-node-1 | Workers: 0 | Running: 0 tasks + Capabilities: {'has_sudo': 'true', 'has_display': 'false', 'os_release': '6.10.11-linuxkit', 'arch': 'aarch64', 'os': 'linux', 'gpu': 'none', 'local_ip': '172.27.0.2', 'is_root': 'false', 'shell': 'v1'} + 🟒 test-node-2 | Workers: 0 | Running: 0 tasks + Capabilities: {'has_sudo': 'true', 'has_display': 'false', 'os_release': '6.10.11-linuxkit', 'arch': 'aarch64', 'os': 'linux', 'gpu': 'none', 'local_ip': '172.27.0.2', 'is_root': 'false', 'shell': 'v1'} +================================================== + +17:41:45 - LiteLLM:INFO: utils.py:3895 - +LiteLLM completion() model= gemini-3-flash-preview; provider = gemini +INFO:LiteLLM: +LiteLLM completion() model= gemini-3-flash-preview; provider = gemini + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 +INFO: 127.0.0.1:60888 - "GET /api/v1/nodes/test-node-2/fs/ls?path=.&session_id=session-32-1511b094 HTTP/1.1" 200 OK + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 +INFO: 127.0.0.1:60888 - "GET /api/v1/nodes/test-node-2/fs/ls?path=.&session_id=session-32-1511b094 HTTP/1.1" 200 OK +17:41:46 - LiteLLM:INFO: utils.py:3895 - +LiteLLM completion() model= deepseek-chat; provider = deepseek +INFO:LiteLLM: +LiteLLM completion() model= deepseek-chat; provider = deepseek + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 +INFO: 127.0.0.1:60888 - "GET /api/v1/nodes/test-node-2/fs/ls?path=.&session_id=session-32-1511b094 HTTP/1.1" 200 OK + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 +INFO: 127.0.0.1:60888 - "GET /api/v1/nodes/test-node-2/fs/ls?path=.&session_id=session-32-1511b094 HTTP/1.1" 200 OK + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 +INFO: 127.0.0.1:60888 - "GET /api/v1/nodes/test-node-2/fs/ls?path=.&session_id=session-32-1511b094 HTTP/1.1" 200 OK + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 +INFO: 127.0.0.1:60888 - "GET /api/v1/nodes/test-node-2/fs/ls?path=.&session_id=session-32-1511b094 HTTP/1.1" 200 OK +INFO:app.app:[Health Check] System LLM statuses updated. + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 +INFO: 127.0.0.1:60888 - "GET /api/v1/nodes/test-node-2/fs/ls?path=.&session_id=session-32-1511b094 HTTP/1.1" 200 OK + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 +INFO: 127.0.0.1:60888 - "GET /api/v1/nodes/test-node-2/fs/ls?path=.&session_id=session-32-1511b094 HTTP/1.1" 200 OK + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 +INFO: 127.0.0.1:60888 - "GET /api/v1/nodes/test-node-2/fs/ls?path=.&session_id=session-32-1511b094 HTTP/1.1" 200 OK + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 +INFO: 127.0.0.1:60888 - "GET /api/v1/nodes/test-node-2/fs/ls?path=.&session_id=session-32-1511b094 HTTP/1.1" 200 OK + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 +INFO: 127.0.0.1:60888 - "GET /api/v1/nodes/test-node-2/fs/ls?path=.&session_id=session-32-1511b094 HTTP/1.1" 200 OK + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 +INFO: 127.0.0.1:60888 - "GET /api/v1/nodes/test-node-2/fs/ls?path=.&session_id=session-32-1511b094 HTTP/1.1" 200 OK + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 +INFO: 127.0.0.1:60888 - "GET /api/v1/nodes/test-node-2/fs/ls?path=.&session_id=session-32-1511b094 HTTP/1.1" 200 OK + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 +INFO: 127.0.0.1:60888 - "GET /api/v1/nodes/test-node-2/fs/ls?path=.&session_id=session-32-1511b094 HTTP/1.1" 200 OK + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 +INFO: 127.0.0.1:60888 - "GET /api/v1/nodes/test-node-2/fs/ls?path=.&session_id=session-32-1511b094 HTTP/1.1" 200 OK + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 +INFO: 127.0.0.1:60888 - "GET /api/v1/nodes/test-node-2/fs/ls?path=.&session_id=session-32-1511b094 HTTP/1.1" 200 OK + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 +INFO: 127.0.0.1:60888 - "GET /api/v1/nodes/test-node-2/fs/ls?path=.&session_id=session-32-1511b094 HTTP/1.1" 200 OK + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 +INFO: 127.0.0.1:60888 - "GET /api/v1/nodes/test-node-2/fs/ls?path=.&session_id=session-32-1511b094 HTTP/1.1" 200 OK + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 +INFO: 127.0.0.1:60888 - "GET /api/v1/nodes/test-node-2/fs/ls?path=.&session_id=session-32-1511b094 HTTP/1.1" 200 OK + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + +================================================== +πŸ“‘ CORTEX MESH DASHBOARD | 2 Nodes Online +-------------------------------------------------- + 🟒 test-node-1 | Workers: 0 | Running: 0 tasks + Capabilities: {'has_sudo': 'true', 'has_display': 'false', 'os_release': '6.10.11-linuxkit', 'arch': 'aarch64', 'os': 'linux', 'gpu': 'none', 'local_ip': '172.27.0.2', 'is_root': 'false', 'shell': 'v1'} + 🟒 test-node-2 | Workers: 0 | Running: 0 tasks + Capabilities: {'has_sudo': 'true', 'has_display': 'false', 'os_release': '6.10.11-linuxkit', 'arch': 'aarch64', 'os': 'linux', 'gpu': 'none', 'local_ip': '172.27.0.2', 'is_root': 'false', 'shell': 'v1'} +================================================== + + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 +INFO: 127.0.0.1:60888 - "GET /api/v1/nodes/test-node-2/fs/ls?path=.&session_id=session-32-1511b094 HTTP/1.1" 200 OK + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 +INFO: 127.0.0.1:60888 - "GET /api/v1/nodes/test-node-2/fs/ls?path=.&session_id=session-32-1511b094 HTTP/1.1" 200 OK + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 +INFO: 127.0.0.1:60888 - "GET /api/v1/nodes/test-node-2/fs/ls?path=.&session_id=session-32-1511b094 HTTP/1.1" 200 OK + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 +INFO: 127.0.0.1:60888 - "GET /api/v1/nodes/test-node-2/fs/ls?path=.&session_id=session-32-1511b094 HTTP/1.1" 200 OK + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 +INFO: 127.0.0.1:60888 - "GET /api/v1/nodes/test-node-2/fs/ls?path=.&session_id=session-32-1511b094 HTTP/1.1" 200 OK +INFO: 127.0.0.1:60888 - "GET /api/v1/nodes/test-node-2/fs/ls?path=.&session_id=session-32-1511b094 HTTP/1.1" 200 OK +INFO: 127.0.0.1:60888 - "GET /api/v1/nodes/test-node-2/fs/ls?path=.&session_id=session-32-1511b094 HTTP/1.1" 200 OK +INFO: 127.0.0.1:60888 - "GET /api/v1/nodes/test-node-2/fs/ls?path=.&session_id=session-32-1511b094 HTTP/1.1" 200 OK +INFO: 127.0.0.1:60888 - "GET /api/v1/nodes/test-node-2/fs/ls?path=.&session_id=session-32-1511b094 HTTP/1.1" 200 OK +INFO: 127.0.0.1:60888 - "GET /api/v1/nodes/test-node-2/fs/ls?path=.&session_id=session-32-1511b094 HTTP/1.1" 200 OK +INFO: 127.0.0.1:60888 - "GET /api/v1/nodes/test-node-2/fs/ls?path=.&session_id=session-32-1511b094 HTTP/1.1" 200 OK +INFO: 127.0.0.1:60888 - "GET /api/v1/nodes/test-node-2/fs/ls?path=.&session_id=session-32-1511b094 HTTP/1.1" 200 OK +INFO: 127.0.0.1:60888 - "GET /api/v1/nodes/test-node-2/fs/ls?path=.&session_id=session-32-1511b094 HTTP/1.1" 200 OK +INFO: 127.0.0.1:60888 - "GET /api/v1/nodes/test-node-2/fs/ls?path=.&session_id=session-32-1511b094 HTTP/1.1" 200 OK +INFO: 127.0.0.1:60888 - "GET /api/v1/nodes/test-node-2/fs/ls?path=.&session_id=session-32-1511b094 HTTP/1.1" 200 OK +INFO: 127.0.0.1:60888 - "GET /api/v1/nodes/test-node-2/fs/ls?path=.&session_id=session-32-1511b094 HTTP/1.1" 200 OK +INFO: 127.0.0.1:60888 - "GET /api/v1/nodes/test-node-2/fs/ls?path=.&session_id=session-32-1511b094 HTTP/1.1" 200 OK +INFO: 127.0.0.1:60888 - "GET /api/v1/nodes/test-node-2/fs/ls?path=.&session_id=session-32-1511b094 HTTP/1.1" 200 OK +INFO: 127.0.0.1:60888 - "GET /api/v1/nodes/test-node-2/fs/ls?path=.&session_id=session-32-1511b094 HTTP/1.1" 200 OK + +================================================== +πŸ“‘ CORTEX MESH DASHBOARD | 2 Nodes Online +-------------------------------------------------- + 🟒 test-node-1 | Workers: 0 | Running: 0 tasks + Capabilities: {'has_sudo': 'true', 'has_display': 'false', 'os_release': '6.10.11-linuxkit', 'arch': 'aarch64', 'os': 'linux', 'gpu': 'none', 'local_ip': '172.27.0.2', 'is_root': 'false', 'shell': 'v1'} + 🟒 test-node-2 | Workers: 0 | Running: 0 tasks + Capabilities: {'has_sudo': 'true', 'has_display': 'false', 'os_release': '6.10.11-linuxkit', 'arch': 'aarch64', 'os': 'linux', 'gpu': 'none', 'local_ip': '172.27.0.2', 'is_root': 'false', 'shell': 'v1'} +================================================== + +INFO: 127.0.0.1:60888 - "GET /api/v1/nodes/test-node-2/fs/ls?path=.&session_id=session-32-1511b094 HTTP/1.1" 200 OK +INFO: 127.0.0.1:60888 - "GET /api/v1/nodes/test-node-2/fs/ls?path=.&session_id=session-32-1511b094 HTTP/1.1" 200 OK +INFO: 127.0.0.1:60888 - "GET /api/v1/nodes/test-node-2/fs/ls?path=.&session_id=session-32-1511b094 HTTP/1.1" 200 OK +INFO: 127.0.0.1:60888 - "GET /api/v1/nodes/test-node-2/fs/ls?path=.&session_id=session-32-1511b094 HTTP/1.1" 200 OK +INFO: 127.0.0.1:60888 - "GET /api/v1/nodes/test-node-2/fs/ls?path=.&session_id=session-32-1511b094 HTTP/1.1" 200 OK +INFO: 127.0.0.1:60888 - "GET /api/v1/nodes/test-node-2/fs/ls?path=.&session_id=session-32-1511b094 HTTP/1.1" 200 OK +INFO: 127.0.0.1:60888 - "GET /api/v1/nodes/test-node-2/fs/ls?path=.&session_id=session-32-1511b094 HTTP/1.1" 200 OK +INFO: 127.0.0.1:60888 - "GET /api/v1/nodes/test-node-2/fs/ls?path=.&session_id=session-32-1511b094 HTTP/1.1" 200 OK +INFO: 127.0.0.1:60888 - "GET /api/v1/nodes/test-node-2/fs/ls?path=.&session_id=session-32-1511b094 HTTP/1.1" 200 OK + [πŸ“βœ…] Sync Complete: gigabyte_05438911.txt (Swapped and verified) + [πŸ“πŸ“’] Broadcasting gigabyte_05438911.txt from test-node-1 to: test-node-2 +INFO: 127.0.0.1:60888 - "GET /api/v1/nodes/test-node-2/fs/ls?path=.&session_id=session-32-1511b094 HTTP/1.1" 200 OK +INFO: 127.0.0.1:60888 - "GET /api/v1/nodes/test-node-1/fs/ls?path=.&session_id=session-32-1511b094 HTTP/1.1" 200 OK +[πŸ“πŸ—‘οΈ] AI Remove: gigabyte_05438911.txt (Session: session-32-1511b094) -> Dispatching to 2 nodes +INFO: 127.0.0.1:60888 - "POST /api/v1/nodes/test-node-1/fs/rm HTTP/1.1" 200 OK + [πŸ“] Sync Status from test-node-2: File not found +INFO: 127.0.0.1:60888 - "POST /api/v1/sessions/ HTTP/1.1" 200 OK +[πŸ“πŸ“€] Initiating Workspace Push for Session session-33-6641a37a to test-node-1 +[πŸ“πŸ“€] Initiating Workspace Push for Session session-33-6641a37a to test-node-2 + [πŸ“] Sync Status from test-node-2: Synchronized + [πŸ“] Sync Status from test-node-1: Deleted gigabyte_05438911.txt + [πŸ“πŸ“₯] Received Manifest from test-node-1 for session-32-1511b094 + [πŸ“] Sync Status from test-node-1: Synchronized +INFO: 127.0.0.1:60888 - "POST /api/v1/sessions/33/nodes HTTP/1.1" 200 OK + [πŸ“] Sync Status from test-node-2: File gigabyte_05438911.txt synced +[πŸ“βœοΈ] AI Write: autopurge_7eee7d91.txt (Session: session-33-6641a37a) -> Dispatching to 2 nodes +INFO: 127.0.0.1:60888 - "POST /api/v1/nodes/test-node-1/fs/touch HTTP/1.1" 200 OK + [πŸ“] Sync Status from test-node-2: File written + [πŸ“] Sync Status from test-node-1: File written + [πŸ“πŸ“₯] Received Manifest from test-node-1 for session-33-6641a37a + [πŸ“πŸ“₯] Received Manifest from test-node-2 for session-33-6641a37a +INFO: 127.0.0.1:60888 - "GET /api/v1/nodes/test-node-2/fs/cat?path=autopurge_7eee7d91.txt&session_id=session-33-6641a37a HTTP/1.1" 200 OK + [πŸ“βš‘] Fast Sync Complete: autopurge_7eee7d91.txt + [πŸ“πŸ“’] Broadcasting autopurge_7eee7d91.txt from test-node-2 to: test-node-1 + [πŸ“βš‘] Fast Sync Complete: autopurge_7eee7d91.txt + [πŸ“πŸ“’] Broadcasting autopurge_7eee7d91.txt from test-node-1 to: test-node-2 + [πŸ“] Sync Status from test-node-1: File autopurge_7eee7d91.txt synced + [πŸ“] Sync Status from test-node-2: File autopurge_7eee7d91.txt synced +INFO: 127.0.0.1:60888 - "DELETE /api/v1/sessions/33 HTTP/1.1" 200 OK + +================================================== +πŸ“‘ CORTEX MESH DASHBOARD | 2 Nodes Online +-------------------------------------------------- + 🟒 test-node-1 | Workers: 0 | Running: 0 tasks + Capabilities: {'has_sudo': 'true', 'has_display': 'false', 'os_release': '6.10.11-linuxkit', 'arch': 'aarch64', 'os': 'linux', 'gpu': 'none', 'local_ip': '172.27.0.2', 'is_root': 'false', 'shell': 'v1'} + 🟒 test-node-2 | Workers: 0 | Running: 0 tasks + Capabilities: {'has_sudo': 'true', 'has_display': 'false', 'os_release': '6.10.11-linuxkit', 'arch': 'aarch64', 'os': 'linux', 'gpu': 'none', 'local_ip': '172.27.0.2', 'is_root': 'false', 'shell': 'v1'} +================================================== + +INFO: 127.0.0.1:60888 - "POST /api/v1/nodes/test-node-1/dispatch?user_id=915a44b3-7ab9-4670-bb86-cb5ae31304bc HTTP/1.1" 200 OK +INFO: 127.0.0.1:60888 - "POST /api/v1/nodes/test-node-2/dispatch?user_id=915a44b3-7ab9-4670-bb86-cb5ae31304bc HTTP/1.1" 200 OK +INFO: 127.0.0.1:60888 - "DELETE /api/v1/sessions/32 HTTP/1.1" 200 OK +INFO: 127.0.0.1:35984 - "POST /api/v1/sessions/ HTTP/1.1" 200 OK +INFO: 127.0.0.1:35984 - "POST /api/v1/sessions/34/chat HTTP/1.1" 200 OK +INFO:app.core.services.rag:[RAG] Mesh Context gathered. Length: 0 chars. +WARNING:app.core.services.prompt:Prompt with slug 'rag-pipeline' not found. +INFO:root:[Architect] Starting autonomous loop (Turn 1). Prompt Size: 4133 chars across 2 messages. +INFO:root:[Architect] Msg 0 (system): You are the Cortex AI Assistant, the **Master-Architect** of a decentralized agent mesh. + +## πŸ—οΈ Orchestration Strategy (The Master-Worker Pattern): +- **Action-First**: You are an action-oriented agent. If you possess a tool to perform a task, you MUST invoke it in the current turn. Do not wait for a second confirmation. +- **Large Data Rule**: For files larger than 100KB, DO NOT use `mesh_file_explorer`'s `write` action. Instead, use `mesh_terminal_control` with native commands like `dd`, `head`... +INFO:root:[Architect] Msg 1 (user): What is the capital of France? Please respond with just the city name.... +17:42:17 - LiteLLM:INFO: utils.py:3895 - +LiteLLM completion() model= gemini-3-flash-preview; provider = gemini +INFO:LiteLLM: +LiteLLM completion() model= gemini-3-flash-preview; provider = gemini +INFO:root:[Architect] First chunk received after 0.75s +INFO: 127.0.0.1:35988 - "POST /api/v1/users/login/local HTTP/1.1" 200 OK +INFO: 127.0.0.1:35992 - "POST /api/v1/users/login/local HTTP/1.1" 401 Unauthorized +INFO: 127.0.0.1:35996 - "POST /api/v1/users/login/local HTTP/1.1" 401 Unauthorized +17:42:18 - LiteLLM:INFO: utils.py:3895 - +LiteLLM completion() model= gemini-3-flash-preview; provider = gemini +INFO:LiteLLM: +LiteLLM completion() model= gemini-3-flash-preview; provider = gemini +INFO: 127.0.0.1:36002 - "POST /api/v1/users/me/config/verify_llm HTTP/1.1" 200 OK +17:42:20 - LiteLLM:INFO: utils.py:3895 - +LiteLLM completion() model= gemini-3-flash-preview; provider = gemini +INFO:LiteLLM: +LiteLLM completion() model= gemini-3-flash-preview; provider = gemini +ERROR:app.api.routes.user:LLM Verification failed for gemini (None): Authentication failed for gemini/gemini-3-flash-preview. Check your API key. + +Give Feedback / Get Help: https://github.com/BerriAI/litellm/issues/new +LiteLLM.Info: If you need to debug this error, use `litellm._turn_on_debug()'. + +INFO: 127.0.0.1:36016 - "POST /api/v1/users/me/config/verify_llm HTTP/1.1" 200 OK +INFO:app.core.services.preference:Saving updated global preferences via admin 915a44b3-7ab9-4670-bb86-cb5ae31304bc +🏠 Configuration synchronized to app/config.yaml +INFO: 127.0.0.1:36024 - "PUT /api/v1/users/me/config HTTP/1.1" 200 OK +INFO: 127.0.0.1:36024 - "GET /api/v1/users/me/config HTTP/1.1" 200 OK +17:42:20 - LiteLLM:INFO: utils.py:3895 - +LiteLLM completion() model= gemini-3-flash-preview; provider = gemini +INFO:LiteLLM: +LiteLLM completion() model= gemini-3-flash-preview; provider = gemini +INFO: 127.0.0.1:36032 - "POST /api/v1/users/me/config/verify_llm HTTP/1.1" 200 OK +ERROR:app.api.routes.user:LLM Verification failed for non_existent_provider_xyz (None): LiteLLM Error (non_existent_provider_xyz/unknown_model): litellm.BadRequestError: LLM Provider NOT provided. Pass in the LLM provider you are trying to call. You passed model=non_existent_provider_xyz/unknown_model + Pass model as E.g. For 'Huggingface' inference endpoints pass in `completion(model='huggingface/starcoder',..)` Learn more: https://docs.litellm.ai/docs/providers + +Provider List: https://docs.litellm.ai/docs/providers + + +Provider List: https://docs.litellm.ai/docs/providers + +INFO: 127.0.0.1:36042 - "POST /api/v1/users/me/config/verify_llm HTTP/1.1" 200 OK + +Provider List: https://docs.litellm.ai/docs/providers + + +Provider List: https://docs.litellm.ai/docs/providers + + +Provider List: https://docs.litellm.ai/docs/providers + + +Provider List: https://docs.litellm.ai/docs/providers + + +Provider List: https://docs.litellm.ai/docs/providers + + +Provider List: https://docs.litellm.ai/docs/providers + + +Provider List: https://docs.litellm.ai/docs/providers + + +Provider List: https://docs.litellm.ai/docs/providers + + +Provider List: https://docs.litellm.ai/docs/providers + + +Provider List: https://docs.litellm.ai/docs/providers + + +Provider List: https://docs.litellm.ai/docs/providers + + +Provider List: https://docs.litellm.ai/docs/providers + + +Provider List: https://docs.litellm.ai/docs/providers + + +Provider List: https://docs.litellm.ai/docs/providers + + +Provider List: https://docs.litellm.ai/docs/providers + + +Provider List: https://docs.litellm.ai/docs/providers + + +Provider List: https://docs.litellm.ai/docs/providers + + +Provider List: https://docs.litellm.ai/docs/providers + +INFO: 127.0.0.1:36048 - "GET /api/v1/users/me/config/models?provider_name=gemini HTTP/1.1" 200 OK +WARNING:app.core.grpc.services.grpc_server:Results listener closed for test-node-1: +WARNING:app.core.grpc.services.grpc_server:Results listener closed for test-node-2: +WARNING:app.core.grpc.services.grpc_server:[πŸ“Ά] gRPC Stream TERMINATED for test-node-2. Cleaning up. +INFO:app.core.services.node_registry:[πŸ“‹] NodeRegistry: Deregistered test-node-2 +WARNING:app.core.grpc.services.grpc_server:[πŸ“Ά] gRPC Stream TERMINATED for test-node-1. Cleaning up. +INFO:app.core.services.node_registry:[πŸ“‹] NodeRegistry: Deregistered test-node-1 +INFO: Shutting down +INFO: Waiting for application shutdown. +INFO:app.app:[M6] Stopping gRPC server... +INFO: Application shutdown complete. +INFO: Finished server process [6031] diff --git a/ai-hub/test.db-shm b/ai-hub/test.db-shm new file mode 100644 index 0000000..812cb9e --- /dev/null +++ b/ai-hub/test.db-shm Binary files differ diff --git a/ai-hub/test.db-wal b/ai-hub/test.db-wal new file mode 100644 index 0000000..223af2a --- /dev/null +++ b/ai-hub/test.db-wal Binary files differ diff --git a/ai-hub/test_local.py b/ai-hub/test_local.py new file mode 100644 index 0000000..b00fa69 --- /dev/null +++ b/ai-hub/test_local.py @@ -0,0 +1,943 @@ +""" +File Sync Integration Tests +============================ +Verifies the end-to-end mesh file synchronisation behaviour across nodes and +the Hub server mirror. These tests run against a *live* deployment that has +at least two test nodes connected: + + β€’ test-node-1 (NODE_1 constant) + β€’ test-node-2 (NODE_2 constant) + +The Hub exposes REST endpoints used to: + - read files: GET /api/v1/nodes/{node_id}/fs/cat + - list dirs: GET /api/v1/nodes/{node_id}/fs/ls + - write files: POST /api/v1/nodes/{node_id}/fs/touch + - delete: POST /api/v1/nodes/{node_id}/fs/rm + +A shared swarm-control session is created once per test module so that all +nodes are in the same mesh workspace, and file operations propagate correctly. + +Environment assumptions +----------------------- + BASE_URL http://127.0.0.1:8000 (inside container) or http://192.168.68.113 (from outside) + NODE_1 test-node-1 + NODE_2 test-node-2 + USER_ID SYNC_TEST_USER_ID env var (required for node access checks) + TIMEOUT 10 s for small files, 60 s for 20 MB files +""" + +import os +import time +import uuid +import hashlib +import pytest +import httpx + +# ── Configuration ────────────────────────────────────────────────────────────── +BASE_URL = os.getenv("SYNC_TEST_BASE_URL", "http://127.0.0.1:8002/api/v1") +USER_ID = os.getenv("SYNC_TEST_USER_ID", "c4401d34-8784-4d6e-93a0-c702bd202b66") +NODE_1 = os.getenv("SYNC_TEST_NODE1", "test-node-1") +NODE_2 = os.getenv("SYNC_TEST_NODE2", "test-node-2") + +SMALL_FILE_TIMEOUT = 10 # seconds +LARGE_FILE_TIMEOUT = 60 # seconds (20 MB) +LARGE_FILE_SIZE_MB = 20 +POLL_INTERVAL = 0.5 # seconds + +# Paths β€” relative to BASE_URL +SESSIONS_PATH = "/sessions" +NODES_PATH = "/nodes" + + +# ── Module-level: skip the whole file if nodes are not online ────────────────── +def pytest_configure(config): + config.addinivalue_line( + "markers", + "requires_nodes: mark test as requiring live agent nodes to be connected", + ) + + +pytestmark = pytest.mark.requires_nodes + + +# ── Helpers ───────────────────────────────────────────────────────────────────── + +def _get_user_id() -> str: + return os.getenv("SYNC_TEST_USER_ID", "integration_tester_sync") + +def _headers(): + return {"X-User-ID": _get_user_id()} + + +def _unique(prefix="synctest"): + return f"{prefix}_{uuid.uuid4().hex[:8]}.txt" + + +def _large_content(mb: int = LARGE_FILE_SIZE_MB) -> str: + """Return a UTF-8 string of approximately `mb` megabytes.""" + line = "A" * 1023 + "\n" # 1 KB per line + return line * (mb * 1024) # mb * 1024 lines β‰ˆ mb MB + + +def _poll_until(fn, timeout: float, interval: float = POLL_INTERVAL): + """ + Repeatedly call fn() until it returns a truthy value or timeout expires. + Returns the last return value of fn(). + """ + deadline = time.time() + timeout + last = None + while time.time() < deadline: + try: + last = fn() + if last: + return last + except Exception: + pass + time.sleep(interval) + return last + + +def _cat(client: httpx.Client, node_id: str, path: str, session_id: str) -> str | None: + """Read a file from a node; return its text content or None on error.""" + r = client.get( + f"{NODES_PATH}/{node_id}/fs/cat", + params={"path": path, "session_id": session_id}, + headers=_headers(), + ) + if r.status_code == 200: + return r.json().get("content", "") + return None + + +def _mirror_cat(client: httpx.Client, path: str, session_id: str) -> str | None: + """ + Read a file from the Hub server mirror directly by asking node-1 for it + using the session_id workspace (the Hub mirror is queried when the node + reflects a workspace file). + + For the server-side write tests we can call the same endpoint but the + source is the Hub mirror, not the live node FS. + """ + # The Hub's /cat endpoint fetches from node, then caches in mirror. + # For "server wrote it β†’ does node have it?" we ask the node directly. + return _cat(client, NODE_1, path, session_id) + + +def _touch( + client: httpx.Client, + node_id: str, + path: str, + content: str, + session_id: str, + is_dir: bool = False, +) -> dict: + """Write a file to a node via the REST API.""" + r = client.post( + f"{NODES_PATH}/{node_id}/fs/touch", + json={"path": path, "content": content, "is_dir": is_dir, "session_id": session_id}, + headers=_headers(), + timeout=120.0, + ) + r.raise_for_status() + return r.json() + + +def _rm(client: httpx.Client, node_id: str, path: str, session_id: str) -> dict: + """Delete a file from a node via the REST API.""" + r = client.post( + f"{NODES_PATH}/{node_id}/fs/rm", + json={"path": path, "session_id": session_id}, + headers=_headers(), + timeout=30.0, + ) + r.raise_for_status() + return r.json() + + +def _file_missing(client: httpx.Client, node_id: str, path: str, session_id: str) -> bool: + """Return True if the file does NOT exist on the node.""" + r = client.get( + f"{NODES_PATH}/{node_id}/fs/cat", + params={"path": path, "session_id": session_id}, + headers=_headers(), + ) + return r.status_code != 200 + + +# ── Session fixture ───────────────────────────────────────────────────────────── + +@pytest.fixture(scope="module") +def sync_client(): + """Synchronous httpx client for the whole module (avoids asyncio overhead).""" + with httpx.Client(base_url=BASE_URL, timeout=60.0) as c: + # Quick connectivity + node-online check + try: + r = c.get(f"{NODES_PATH}/{NODE_1}/status", headers=_headers()) + if r.status_code not in (200, 404): + pytest.skip(f"{NODE_1} unreachable β€” hub returned {r.status_code}") + except Exception as exc: + pytest.skip(f"Hub unreachable at {BASE_URL}: {exc}") + yield c + + +@pytest.fixture(scope="module") +def swarm_session(sync_client: httpx.Client) -> str: + """ + Create (or reuse) one swarm_control session that has both test nodes + attached. Returned value is the workspace_id string used by all sync ops. + """ + # Create the session + r = sync_client.post( + f"{SESSIONS_PATH}/", + json={"user_id": _get_user_id(), "provider_name": "gemini", "feature_name": "swarm_control"}, + headers=_headers(), + ) + assert r.status_code == 200, f"Create session failed: {r.text}" + session_id = r.json()["id"] + + # Attach both nodes with source="empty" so they both watch the workspace + r2 = sync_client.post( + f"{SESSIONS_PATH}/{session_id}/nodes", + json={"node_ids": [NODE_1, NODE_2], "config": {"source": "empty"}}, + headers=_headers(), + ) + assert r2.status_code == 200, f"Attach nodes failed: {r2.text}" + workspace_id = r2.json().get("sync_workspace_id") + assert workspace_id, "Expected sync_workspace_id in response" + + # Give nodes a moment to ACK the workspace and start watching + time.sleep(2.0) + + yield workspace_id + + # Teardown: archive the session + sync_client.delete(f"{SESSIONS_PATH}/{session_id}", headers=_headers()) + + +# ══════════════════════════════════════════════════════════════════════════════ +# SMALL FILE TESTS (< 1 chunk) +# ══════════════════════════════════════════════════════════════════════════════ + +class TestSmallFileSync: + """Cases 1–4: single small-file create + delete in both directions.""" + + # ── Case 1: node-1 β†’ node-2 + server ─────────────────────────────────── + def test_case1_write_from_node1_visible_on_node2_and_server( + self, sync_client, swarm_session + ): + """ + Write a file from test-node-1; verify test-node-2 AND the server + mirror receive it within SMALL_FILE_TIMEOUT seconds. + """ + filename = _unique("case1") + content = f"Case 1 payload – node-1 β†’ mesh – {uuid.uuid4()}" + workspace = swarm_session + + print(f"\n[Case 1] Writing {filename!r} to {NODE_1} in workspace {workspace}") + result = _touch(sync_client, NODE_1, filename, content, workspace) + assert result.get("success"), f"Write failed: {result}" + + # Verify on node-2 + node2_content = _poll_until( + lambda: _cat(sync_client, NODE_2, filename, workspace), + timeout=SMALL_FILE_TIMEOUT, + ) + assert node2_content is not None, ( + f"[Case 1] File '{filename}' did NOT appear on {NODE_2} within " + f"{SMALL_FILE_TIMEOUT}s" + ) + assert content in node2_content, ( + f"[Case 1] Content mismatch on {NODE_2}. Got: {node2_content!r}" + ) + print(f"[Case 1] βœ… {NODE_2} received the file.") + + # Verify on Hub server mirror (query node-1 with session scope uses mirror) + server_content = _poll_until( + lambda: _cat(sync_client, NODE_1, filename, workspace), + timeout=SMALL_FILE_TIMEOUT, + ) + assert server_content is not None and content in server_content, ( + f"[Case 1] File '{filename}' not found on Hub mirror." + ) + print(f"[Case 1] βœ… Hub mirror has the file.") + + # ── Case 2: server β†’ node-1 + node-2 ─────────────────────────────────── + def test_case2_write_from_server_visible_on_all_nodes( + self, sync_client, swarm_session + ): + """ + Write a file via the server (the touch endpoint dispatches to all nodes + in the session + writes to Hub mirror). Verify both client nodes and + the mirror reflect it. + """ + filename = _unique("case2") + content = f"Case 2 payload – server β†’ mesh – {uuid.uuid4()}" + workspace = swarm_session + + print(f"\n[Case 2] Writing {filename!r} via server to workspace {workspace}") + # Intentionally write via node-1 (server-dispatched; Hub mirror updated first) + result = _touch(sync_client, NODE_1, filename, content, workspace) + assert result.get("success"), f"Write failed: {result}" + + # Node-2 should receive via broadcast + node2_content = _poll_until( + lambda: _cat(sync_client, NODE_2, filename, workspace), + timeout=SMALL_FILE_TIMEOUT, + ) + assert node2_content is not None and content in node2_content, ( + f"[Case 2] File '{filename}' did NOT appear on {NODE_2}." + ) + print(f"[Case 2] βœ… {NODE_2} received the file.") + + # Node-1 should also have it (it was written directly to it and mirrored) + node1_content = _cat(sync_client, NODE_1, filename, workspace) + assert node1_content is not None and content in node1_content, ( + f"[Case 2] File '{filename}' not found on {NODE_1}." + ) + print(f"[Case 2] βœ… {NODE_1} has the file.") + + # ── Case 3: delete from server β†’ nodes purged ────────────────────────── + def test_case3_delete_from_server_purges_client_nodes( + self, sync_client, swarm_session + ): + """ + Create a file via server, then delete it via the server endpoint. + Verify both client nodes no longer have the file. + """ + filename = _unique("case3") + content = f"Case 3 – to be deleted by server – {uuid.uuid4()}" + workspace = swarm_session + + # Setup: write the file from node-1 (server-side orchestrated) + r = _touch(sync_client, NODE_1, filename, content, workspace) + assert r.get("success"), f"Setup write failed: {r}" + + # Make sure node-2 got it before we delete + got = _poll_until( + lambda: _cat(sync_client, NODE_2, filename, workspace), + timeout=SMALL_FILE_TIMEOUT, + ) + assert got is not None, f"[Case 3] Setup: file not on {NODE_2}." + + print(f"\n[Case 3] Deleting {filename!r} from server (via {NODE_1} endpoint)") + _rm(sync_client, NODE_1, filename, workspace) + + # node-2 should no longer have the file + gone_node2 = _poll_until( + lambda: _file_missing(sync_client, NODE_2, filename, workspace), + timeout=SMALL_FILE_TIMEOUT, + ) + assert gone_node2, ( + f"[Case 3] File '{filename}' still present on {NODE_2} after server delete." + ) + print(f"[Case 3] βœ… {NODE_2} no longer has the file.") + + # node-1 should also have it gone + gone_node1 = _file_missing(sync_client, NODE_1, filename, workspace) + assert gone_node1, ( + f"[Case 3] File '{filename}' still present on {NODE_1} after server delete." + ) + print(f"[Case 3] βœ… {NODE_1} no longer has the file.") + + # ── Case 4: delete from node-2 β†’ server + node-1 purged ─────────────── + def test_case4_delete_from_node2_purges_server_and_node1( + self, sync_client, swarm_session + ): + """ + Create a file, let it propagate, then delete it FROM node-2. + Verify the Hub mirror and node-1 no longer have the file. + """ + filename = _unique("case4") + content = f"Case 4 – to be deleted from node-2 – {uuid.uuid4()}" + workspace = swarm_session + + # Setup: write from node-1 so both nodes and mirror have it + r = _touch(sync_client, NODE_1, filename, content, workspace) + assert r.get("success"), f"Setup write failed: {r}" + + got_node2 = _poll_until( + lambda: _cat(sync_client, NODE_2, filename, workspace), + timeout=SMALL_FILE_TIMEOUT, + ) + assert got_node2 is not None, f"[Case 4] Setup: file did not reach {NODE_2}." + + print(f"\n[Case 4] Deleting {filename!r} from {NODE_2}") + _rm(sync_client, NODE_2, filename, workspace) + + # Hub mirror (observed via node-1's workspace view) should purge + gone_server = _poll_until( + lambda: _file_missing(sync_client, NODE_1, filename, workspace), + timeout=SMALL_FILE_TIMEOUT, + ) + assert gone_server, ( + f"[Case 4] File '{filename}' still present on Hub mirror after node-2 delete." + ) + print(f"[Case 4] βœ… Hub mirror no longer has the file.") + + # node-1 should also be purged + gone_node1 = _file_missing(sync_client, NODE_1, filename, workspace) + assert gone_node1, ( + f"[Case 4] File '{filename}' still present on {NODE_1} after node-2 delete." + ) + print(f"[Case 4] βœ… {NODE_1} no longer has the file.") + + # ── Case 9: cat on deleted file returns quickly, not after timeout ────── + def test_case9_cat_deleted_file_returns_quickly_not_timeout( + self, sync_client, swarm_session + ): + """ + Regression test for the silent-return bug in _push_file (node side) + and the missing mirror short-circuit in cat() (hub side). + + Before the fix, reading a deleted file would stall for the full 15s + journal timeout because the node returned nothing and the hub just sat + waiting. After the fix: + - hub: cat() checks the mirror first; file absent β†’ instant "File not found" + - node: _push_file sends an ERROR SyncStatus immediately when file missing + + This test enforces that a cat call on a deleted file resolves in under + MAX_LATENCY_S seconds on BOTH nodes. + """ + MAX_LATENCY_S = 3.0 # well below the 15s journal timeout + filename = _unique("case9_latency") + content = f"Case 9 β€” delete latency probe β€” {uuid.uuid4()}" + workspace = swarm_session + + # Setup: write the file and wait for full propagation + r = _touch(sync_client, NODE_1, filename, content, workspace) + assert r.get("success"), f"[Case 9] Setup write failed: {r}" + synced = _poll_until( + lambda: _cat(sync_client, NODE_2, filename, workspace), + timeout=SMALL_FILE_TIMEOUT, + ) + assert synced is not None, f"[Case 9] Setup: file did not propagate to {NODE_2}." + + # Delete from server + print(f"\n[Case 9] Deleting {filename!r}, then timing cat() on both nodes") + _rm(sync_client, NODE_1, filename, workspace) + + # Give delete broadcast a moment to reach nodes (but not the full poll timeout) + time.sleep(1.5) + + # Measure cat latency on node-1 (hub mirror path β€” should be instant) + t0 = time.time() + res1 = _cat(sync_client, NODE_1, filename, workspace) + latency_node1 = time.time() - t0 + assert res1 is None, ( + f"[Case 9] {NODE_1} still returned content after delete: {res1!r}" + ) + assert latency_node1 < MAX_LATENCY_S, ( + f"[Case 9] cat() on {NODE_1} took {latency_node1:.1f}s β€” expected < {MAX_LATENCY_S}s. " + f"Hub mirror short-circuit may be broken." + ) + print(f"[Case 9] βœ… {NODE_1} cat returned in {latency_node1:.2f}s (file absent, fast-fail).") + + # Measure cat latency on node-2 (hub mirror path β€” should also be instant) + t0 = time.time() + res2 = _cat(sync_client, NODE_2, filename, workspace) + latency_node2 = time.time() - t0 + assert res2 is None, ( + f"[Case 9] {NODE_2} still returned content after delete: {res2!r}" + ) + assert latency_node2 < MAX_LATENCY_S, ( + f"[Case 9] cat() on {NODE_2} took {latency_node2:.1f}s β€” expected < {MAX_LATENCY_S}s. " + f"Node _push_file may not be sending error status on missing file." + ) + print(f"[Case 9] βœ… {NODE_2} cat returned in {latency_node2:.2f}s (file absent, fast-fail).") + + # ── Case 11: Agent "hub" pseudo-node write visibility ─────────────────── + def test_case11_hub_pseudo_node_write_visibility( + self, sync_client, swarm_session + ): + """ + Regression test for AI agents writing to node='hub'. + When an AI uses mesh_file_explorer with node='hub', it directly modifies + the local mirror without broadcasting to an agent node immediately if it's + just "hub". Wait, if session_id is provided, it DOES broadcast! + Let's ensure that writing to 'hub' with a valid session scope returns success, + is immediately visible in the mirror, and is retrievable via fs/cat! + """ + filename = _unique("case11_hub") + content = f"Case 11 β€” Dummy file from hub β€” {uuid.uuid4()}" + workspace = swarm_session + + print(f"\n[Case 11] Writing {filename!r} to pseudonode 'hub' in workspace {workspace}") + result = _touch(sync_client, "hub", filename, content, workspace) + assert result.get("success"), f"Write to 'hub' failed: {result}" + + # Mirror cat (since we read from node-1, the Hub mirror resolves it instantly if it exists) + # Or better yet, read from "hub" pseudonode! + def _cat_hub(): + r = sync_client.get( + f"{NODES_PATH}/hub/fs/cat", + params={"path": filename, "session_id": workspace}, + headers=_headers(), + ) + if r.status_code == 200: + return r.json().get("content", "") + return None + + # Verify on Hub mirror + hub_content = _poll_until(_cat_hub, timeout=SMALL_FILE_TIMEOUT) + assert hub_content is not None, f"[Case 11] File '{filename}' not found on 'hub' pseudonode." + assert content in hub_content, f"[Case 11] Content mismatch on Hub loopback." + print(f"[Case 11] βœ… 'hub' pseudonode successfully returned the file.") + + + +# ══════════════════════════════════════════════════════════════════════════════ +# NODE RECONNECT / RESYNC TESTS +# ══════════════════════════════════════════════════════════════════════════════ + +# Docker container names for the test nodes on the production server +_NODE_CONTAINER = { + "test-node-1": "cortex-test-1", + "test-node-2": "cortex-test-2", +} + +import subprocess +import os + +def _get_remote_env(): + try: + script_path = os.path.abspath(os.path.join(os.path.dirname(__file__), "../../.agent/utils/env_loader.sh")) + if os.path.exists(script_path): + cmd = f"source {script_path} >/dev/null 2>&1 && echo \"${{REMOTE_PASSWORD}}|${{REMOTE_USER}}|${{REMOTE_HOST}}\"" + res = subprocess.run(["bash", "-c", cmd], capture_output=True, text=True, check=True) + parts = res.stdout.strip().split("|") + if len(parts) == 3 and parts[0]: + return parts[0], parts[1], parts[2] + except Exception: + pass + return os.environ.get("REMOTE_PASSWORD", ""), os.environ.get("REMOTE_USER", "axieyangb"), os.environ.get("REMOTE_HOST", "192.168.68.113") + +_REMOTE_PASSWORD, _REMOTE_USER, _REMOTE_HOST = _get_remote_env() +_SSH_CMD = f"sshpass -p '{_REMOTE_PASSWORD}' ssh -o StrictHostKeyChecking=no {_REMOTE_USER}@{_REMOTE_HOST}" + + +def _restart_test_node(node_id: str): + """ + Restart the named test-node Docker container on the production server. + This wipes /tmp/cortex-sync on the node, simulating a real reboot. + """ + import subprocess + container = _NODE_CONTAINER.get(node_id) + if not container: + pytest.skip(f"No container mapping for {node_id}") + cmd = ( + f"sshpass -p '{_REMOTE_PASSWORD}' ssh -o StrictHostKeyChecking=no {_REMOTE_USER}@{_REMOTE_HOST} " + f"\"echo '{_REMOTE_PASSWORD}' | sudo -S docker restart {container}\"" + ) + result = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=30) + if result.returncode != 0: + pytest.skip(f"Could not restart {container}: {result.stderr}") + + +class TestNodeResync: + """ + Case 10: node reconnect / workspace resync after container restart. + + Real-world scenario: a test node restarts (deploy, crash, reboot) and + /tmp/cortex-sync is wiped. The Hub must re-push the workspace to the + reconnected node via manifest-driven reconciliation. + """ + + # ── Case 10: node-2 restart β†’ hub re-delivers workspace ──────────────── + def test_case10_node_resync_after_restart( + self, sync_client, swarm_session + ): + """ + 1. Write a file to node-1 and confirm node-2 received it. + 2. Restart the node-2 container (wipes /tmp/cortex-sync). + 3. Wait for node-2 to reconnect and receive the manifest from Hub. + 4. Assert that the file re-appears on node-2 within RESYNC_TIMEOUT. + + This guards against regressions in the push_workspace / manifest-driven + reconciliation loop that re-delivers Hub mirror contents to a freshly + reconnected node. + """ + RESYNC_TIMEOUT = 30 # seconds for node to reconnect + resync + RESTART_WAIT = 8 # seconds to allow container to come back up + + filename = _unique("case10_resync") + content = f"Case 10 β€” node resync after restart β€” {uuid.uuid4()}" + workspace = swarm_session + + # Setup: write from node-1, wait for node-2 to receive + r = _touch(sync_client, NODE_1, filename, content, workspace) + assert r.get("success"), f"[Case 10] Setup write failed: {r}" + + synced = _poll_until( + lambda: _cat(sync_client, NODE_2, filename, workspace), + timeout=SMALL_FILE_TIMEOUT, + ) + assert synced is not None, f"[Case 10] Setup: file did not reach {NODE_2} before restart." + print(f"\n[Case 10] File confirmed on {NODE_2}. Restarting container…") + + # Restart node-2 container β€” wipes /tmp/cortex-sync + _restart_test_node(NODE_2) + + # Brief pause to let the container fully stop, then wait for reconnect + time.sleep(RESTART_WAIT) + print(f"[Case 10] Container restarted. Waiting for {NODE_2} to reconnect and resync…") + + # After reconnect, node sends its (now-empty) manifest β†’ Hub sends back + # all missing files. Poll until the file reappears. + resynced = _poll_until( + lambda: _cat(sync_client, NODE_2, filename, workspace), + timeout=RESYNC_TIMEOUT, + ) + assert resynced is not None, ( + f"[Case 10] File '{filename}' did NOT re-appear on {NODE_2} within " + f"{RESYNC_TIMEOUT}s after container restart. " + f"Manifest-driven resync may be broken." + ) + assert content in resynced, ( + f"[Case 10] Content mismatch on {NODE_2} after resync. Got: {resynced!r}" + ) + print(f"[Case 10] βœ… {NODE_2} resynced the file after container restart.") + + +# ══════════════════════════════════════════════════════════════════════════════ +# LARGE FILE TESTS (20 MB, multi-chunk) +# ══════════════════════════════════════════════════════════════════════════════ + +class TestLargeFileSync: + """Cases 5–8: 20 MB file create + delete in both directions.""" + + @pytest.fixture(scope="class", autouse=True) + def _large_content(self): + """Pre-build the 20 MB string once per class to save CPU time.""" + self.__class__._content = _large_content(LARGE_FILE_SIZE_MB) + self.__class__._expected_hash = hashlib.sha256( + self._content.encode() + ).hexdigest() + + # ── Case 5: 20 MB from node-1 β†’ server + node-2 ──────────────────────── + def test_case5_large_file_from_node1_to_server_and_node2( + self, sync_client, swarm_session + ): + """ + Create a 20 MB file from test-node-1. + Both server mirror and test-node-2 should receive it within + LARGE_FILE_TIMEOUT seconds. + """ + filename = _unique("case5_large") + workspace = swarm_session + + print(f"\n[Case 5] Writing {LARGE_FILE_SIZE_MB} MB file {filename!r} from {NODE_1}") + result = _touch(sync_client, NODE_1, filename, self._content, workspace) + assert result.get("success"), f"Write failed: {result}" + + # Verify node-2 received the file + node2_content = _poll_until( + lambda: _cat(sync_client, NODE_2, filename, workspace), + timeout=LARGE_FILE_TIMEOUT, + ) + assert node2_content is not None, ( + f"[Case 5] Large file did NOT appear on {NODE_2} within {LARGE_FILE_TIMEOUT}s." + ) + got_hash = hashlib.sha256(node2_content.encode()).hexdigest() + assert got_hash == self._expected_hash, ( + f"[Case 5] Hash mismatch on {NODE_2}. Expected {self._expected_hash}, got {got_hash}" + ) + print(f"[Case 5] βœ… {NODE_2} received and verified 20 MB large file.") + + # Verify server mirror + mirror_content = _cat(sync_client, NODE_1, filename, workspace) + assert mirror_content is not None, ( + f"[Case 5] Large file not on Hub mirror." + ) + print(f"[Case 5] βœ… Hub mirror has the large file.") + + # ── Case 6: 20 MB from server β†’ node-1 + node-2 ──────────────────────── + def test_case6_large_file_from_server_to_all_nodes( + self, sync_client, swarm_session + ): + """ + Write a 20 MB file via the server (touch endpoint with session scope). + Both client nodes should receive it within LARGE_FILE_TIMEOUT seconds. + """ + filename = _unique("case6_large") + workspace = swarm_session + + print(f"\n[Case 6] Writing {LARGE_FILE_SIZE_MB} MB file {filename!r} via server") + result = _touch(sync_client, NODE_1, filename, self._content, workspace) + assert result.get("success"), f"Write failed: {result}" + + # node-2 receives via mesh broadcast + node2_ok = _poll_until( + lambda: _cat(sync_client, NODE_2, filename, workspace), + timeout=LARGE_FILE_TIMEOUT, + ) + assert node2_ok is not None, ( + f"[Case 6] Large file did NOT appear on {NODE_2} within {LARGE_FILE_TIMEOUT}s." + ) + print(f"[Case 6] βœ… {NODE_2} received the 20 MB file.") + + node1_ok = _cat(sync_client, NODE_1, filename, workspace) + assert node1_ok is not None, f"[Case 6] File not on {NODE_1}." + print(f"[Case 6] βœ… {NODE_1} has the 20 MB file.") + + # ── Case 7: delete large file from server β†’ nodes purged ─────────────── + def test_case7_delete_large_file_from_server_purges_nodes( + self, sync_client, swarm_session + ): + """ + Write and fully sync a large file, then delete via server. + Verify all client nodes are purged. + """ + filename = _unique("case7_large") + workspace = swarm_session + + # Setup + r = _touch(sync_client, NODE_1, filename, self._content, workspace) + assert r.get("success"), f"Setup write failed: {r}" + + synced = _poll_until( + lambda: _cat(sync_client, NODE_2, filename, workspace), + timeout=LARGE_FILE_TIMEOUT, + ) + assert synced is not None, f"[Case 7] Setup: large file did not reach {NODE_2}." + + print(f"\n[Case 7] Deleting large file {filename!r} from server") + _rm(sync_client, NODE_1, filename, workspace) + + gone_node2 = _poll_until( + lambda: _file_missing(sync_client, NODE_2, filename, workspace), + timeout=SMALL_FILE_TIMEOUT, + ) + assert gone_node2, ( + f"[Case 7] Large file still present on {NODE_2} after server delete." + ) + print(f"[Case 7] βœ… {NODE_2} purged the large file.") + + gone_node1 = _file_missing(sync_client, NODE_1, filename, workspace) + assert gone_node1, ( + f"[Case 7] Large file still present on {NODE_1} after server delete." + ) + print(f"[Case 7] βœ… {NODE_1} purged the large file.") + + # ── Case 8: delete large file from node-2 β†’ server + node-1 ─────────── + def test_case8_delete_large_file_from_node2_purges_server_and_node1( + self, sync_client, swarm_session + ): + """ + Write and sync a large file, then delete it FROM node-2. + Verify Hub mirror and node-1 are both purged. + """ + filename = _unique("case8_large") + workspace = swarm_session + + # Setup + r = _touch(sync_client, NODE_1, filename, self._content, workspace) + assert r.get("success"), f"Setup write failed: {r}" + + synced = _poll_until( + lambda: _cat(sync_client, NODE_2, filename, workspace), + timeout=LARGE_FILE_TIMEOUT, + ) + assert synced is not None, f"[Case 8] Setup: large file did not reach {NODE_2}." + + print(f"\n[Case 8] Deleting large file {filename!r} from {NODE_2}") + _rm(sync_client, NODE_2, filename, workspace) + + gone_server = _poll_until( + lambda: _file_missing(sync_client, NODE_1, filename, workspace), + timeout=SMALL_FILE_TIMEOUT, + ) + assert gone_server, ( + f"[Case 8] Large file still on Hub mirror after {NODE_2} delete." + ) + print(f"[Case 8] βœ… Hub mirror purged the large file.") + + gone_node1 = _file_missing(sync_client, NODE_1, filename, workspace) + assert gone_node1, ( + f"[Case 8] Large file still on {NODE_1} after {NODE_2} delete." + ) + print(f"[Case 8] βœ… {NODE_1} purged the large file.") + +# ══════════════════════════════════════════════════════════════════════════════ +# GIGABYTE FILE TEST (1000 MB) +# ══════════════════════════════════════════════════════════════════════════════ + +class TestGigabyteFileSync: + """Tests synchronizing a 1GB file across the mesh via DD CLI tool.""" + + def test_case_1gb_sync_from_client_to_server_and_node( + self, sync_client, swarm_session + ): + """ + Creates a 1 GB file on test-node-1 using the shell command `dd`. + Verifies that it syncs to both the server mirror and test-node-2. + """ + filename = _unique("gigabyte") + workspace = swarm_session + + print(f"\\n[Case 1GB] Disabling memory limit checks and triggering 1GB creation on {NODE_1}...") + + # Create a 1GB file consisting of zeros (highly compressible over the network) on NODE_1 directly. + # This will trigger the Inotify watcher to push chunks back up to the Hub. + # We output to the active session workspace path on the node. + is_native = os.environ.get("SKIP_DOCKER_NODES") == "true" + sync_dir = f"/tmp/cortex-sync-{NODE_1}" if is_native else "/tmp/cortex-sync" + dd_command = f"dd if=/dev/zero of={sync_dir}/{workspace}/{filename} bs=1M count=1000" + + r_disp = sync_client.post( + f"{NODES_PATH}/{NODE_1}/dispatch", + params={"user_id": _get_user_id()}, + json={"command": dd_command}, + headers=_headers(), + timeout=180.0 + ) + assert r_disp.status_code == 200, f"Failed to dispatch 1GB write to {NODE_1}" + + # Give the agent node ample time to write to disk and push chunks over gRPC. + # Wait up to 180 seconds. + def _check_node2_ls(): + r = sync_client.get( + f"{NODES_PATH}/{NODE_2}/fs/ls", + params={"path": ".", "session_id": workspace}, + headers=_headers(), + timeout=30.0 + ) + if r.status_code != 200: + return False + for f in r.json().get("files", []): + # Only return true when size is fully 1 GB (1000 * 1024 * 1024 = 1048576000) + if f.get("name") == filename and f.get("size", 0) >= 1048576000: + return f + return False + + print(f"[Case 1GB] Polling {NODE_2} for the file...") + node2_file = _poll_until(_check_node2_ls, timeout=180) + assert node2_file, f"1GB Large file {filename} did not reach {NODE_2} within 180s in full 1GB size." + print(f"[Case 1GB] βœ… {NODE_2} verified 1GB file sync with correct size.") + + # Verify Server Mirror also saw it and recorded 1GB size + def _check_server_ls(): + r = sync_client.get( + f"{NODES_PATH}/{NODE_1}/fs/ls", + params={"path": ".", "session_id": workspace}, + headers=_headers(), + timeout=30.0 + ) + if r.status_code != 200: + return False + for f in r.json().get("files", []): + if f.get("name") == filename and f.get("size", 0) >= 1048576000: + return f + return False + + server_file = _check_server_ls() + assert server_file, f"1GB Large file {filename} did not appear with 1GB size on Server Mirror." + print(f"[Case 1GB] βœ… Hub mirror successfully verified 1GB file sync with correct size.") + + # Cleanup + _rm(sync_client, NODE_1, filename, workspace) + + +# ══════════════════════════════════════════════════════════════════════════════ +# SESSION AUTO-PURGE TEST +# ══════════════════════════════════════════════════════════════════════════════ + +class TestSessionAutoPurge: + """Verifies that deleting a session purges the physical file system mirrors completely.""" + + def test_session_lifecycle_cleanup(self, sync_client): + """ + Creates a session, touches a file inside it, then deletes the session via API. + Verifies that both the server-side mirror folder and client-side tmp folders + are definitively purged and removed from the physical disk logic. + """ + import subprocess + + print("\n[Case Purge] Starting session cleanup lifecycle test...") + # 1. Create a throwaway session + r = sync_client.post( + f"{SESSIONS_PATH}/", + json={"user_id": _get_user_id(), "provider_name": "gemini", "feature_name": "auto-purge-test"}, + headers=_headers(), + ) + assert r.status_code == 200 + session_id = r.json()["id"] + + # Attach nodes + r2 = sync_client.post( + f"{SESSIONS_PATH}/{session_id}/nodes", + json={"node_ids": [NODE_1, NODE_2], "config": {"source": "empty"}}, + headers=_headers(), + ) + assert r2.status_code == 200 + workspace_id = r2.json().get("sync_workspace_id") + + # Give nodes a moment to ACK the workspace and create folders + time.sleep(2.0) + + # 2. Write a file + filename = _unique("autopurge") + res = _touch(sync_client, NODE_1, filename, "garbage payload", workspace_id) + assert res.get("success"), "Failed to write setup file for auto-purge test" + + # 3. Verify it reached Node 2 (assumes the filesystem structures were physically booted) + node2_ok = _poll_until( + lambda: _cat(sync_client, NODE_2, filename, workspace_id), + timeout=SMALL_FILE_TIMEOUT, + ) + assert node2_ok is not None, "Auto-purge setup file did not sync correctly to node 2" + print("[Case Purge] βœ… Session folders dynamically booted across the mesh") + + # 4. DELETE the Session + # Wait for the watcher to debounce (1s) and push the chunks + print("[Case Purge] Waiting 2 seconds to let the dog flush the chunks...") + time.sleep(2.0) + + print("[Case Purge] Calling API DELETE on the session...") + r_del = sync_client.delete(f"{SESSIONS_PATH}/{session_id}", headers=_headers()) + assert r_del.status_code == 200 + + # Wait a bit for PURGE propagation + print("[Case Purge] Waiting 3 seconds for propagation...") + time.sleep(3.0) + + # 5. Check client-side folders are purged using DISPATCH to run "ls" + is_native = os.environ.get("SKIP_DOCKER_NODES") == "true" + n1_dir = f"/tmp/cortex-sync-{NODE_1}" if is_native else "/tmp/cortex-sync" + # Node 1 + r_d1 = sync_client.post( + f"{NODES_PATH}/{NODE_1}/dispatch", + params={"user_id": _get_user_id()}, + json={"command": f"stat {n1_dir}/{workspace_id}"}, + headers=_headers() + ) + assert "No such file or directory" in r_d1.json().get("stderr", "") or r_d1.json().get("status") != "successful", ( + f"Node 1 failed to purge its physical tmp folder: {r_d1.text}" + ) + + n2_dir = f"/tmp/cortex-sync-{NODE_2}" if is_native else "/tmp/cortex-sync" + # Node 2 + r_d2 = sync_client.post( + f"{NODES_PATH}/{NODE_2}/dispatch", + params={"user_id": _get_user_id()}, + json={"command": f"stat {n2_dir}/{workspace_id}"}, + headers=_headers() + ) + assert "No such file or directory" in r_d2.json().get("stderr", "") or r_d2.json().get("status") != "successful", ( + f"Node 2 failed to purge its physical tmp folder: {r_d2.text}" + ) + print("[Case Purge] βœ… Physical client-side (`/tmp/cortex-sync/...`) folders proactively erased on all nodes") + + # 6. Check server-side folder + if is_native: + mirror_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "ai-hub/data/mirrors", workspace_id) + assert not os.path.exists(mirror_path), f"Server mirror folder still physically exists! stat matched: {mirror_path}" + else: + # (Since the test runner is executed on host but ai_hub is Docker container, we can use docker exec) + cmd = ["docker", "exec", "ai_hub_service", "stat", f"/app/data/mirrors/{workspace_id}"] + # This should fail if it doesn't exist. + res_hub = subprocess.run(cmd, capture_output=True, text=True) + assert res_hub.returncode != 0, f"Server mirror folder still physically exists! stat matched: {res_hub.stdout}" + assert "No such file or directory" in res_hub.stderr, f"Unexpected error during server stat: {res_hub.stderr}" + + print("[Case Purge] βœ… Server-side physical mirror folder proactively erased") + diff --git a/frontend/src/features/agents/components/AgentDrillDown.js b/frontend/src/features/agents/components/AgentDrillDown.js index cc3d827..9ce0d3a 100644 --- a/frontend/src/features/agents/components/AgentDrillDown.js +++ b/frontend/src/features/agents/components/AgentDrillDown.js @@ -1,7 +1,7 @@ import React, { useState, useEffect } from 'react'; import ChatWindow from '../../chat/components/ChatWindow'; import FileSystemNavigator from '../../../shared/components/FileSystemNavigator'; -import { getAgents, getSessionMessages, fetchWithAuth, updateAgentConfig, getUserConfig, clearSessionHistory, getSessionTokenStatus, getAgentTriggers, createAgentTrigger, deleteAgentTrigger, getUserAccessibleNodes } from '../../../services/apiService'; +import { getAgents, getSessionMessages, fetchWithAuth, updateAgentConfig, getUserConfig, clearSessionHistory, getSessionTokenStatus, getAgentTriggers, createAgentTrigger, deleteAgentTrigger, getUserAccessibleNodes, getSkills, resetAgentMetrics } from '../../../services/apiService'; export default function AgentDrillDown({ agentId, onNavigate }) { const [agent, setAgent] = useState(null); @@ -25,6 +25,8 @@ const [creatingTrigger, setCreatingTrigger] = useState(false); const [modalConfig, setModalConfig] = useState(null); const [nodes, setNodes] = useState([]); + const [allSkills, setAllSkills] = useState([]); + const [flippedCards, setFlippedCards] = useState({ runtime: false, tokens: false }); // Helper: Convert cron expression to human-readable text const describeCron = (expr) => { @@ -46,6 +48,15 @@ return expr; }; + const formatTimeLocal = (utcString) => { + if (!utcString) return 'Never'; + const dateStr = utcString.endsWith('Z') || utcString.includes('+') ? utcString : utcString + 'Z'; + return new Date(dateStr).toLocaleString(undefined, { + month: 'short', day: 'numeric', + hour: '2-digit', minute: '2-digit', second: '2-digit' + }); + }; + useEffect(() => { const loadConf = async () => { try { @@ -56,6 +67,10 @@ const nList = await getUserAccessibleNodes(); setNodes(nList); } catch (e) {} + try { + const sList = await getSkills(); + setAllSkills(sList); + } catch (e) {} }; loadConf(); }, []); @@ -74,7 +89,11 @@ system_prompt: found.template?.system_prompt_content || found.template?.system_prompt_path || "", max_loop_iterations: found.template?.max_loop_iterations || 20, mesh_node_id: found.mesh_node_id || "", - provider_name: "" + provider_name: found.session?.provider_name || "", + restrict_skills: found.session?.restrict_skills || false, + allowed_skill_ids: found.session?.skills ? found.session.skills.map(s => s.id) : [], + is_locked: found.session?.is_locked || false, + auto_clear_history: found.session?.auto_clear_history || false }); // Fetch chat history if session exists @@ -96,12 +115,12 @@ const usage = await getSessionTokenStatus(found.session_id); setTokenUsage(usage); } catch(e) {} - - try { - const tList = await getAgentTriggers(agentId); - setTriggers(tList); - } catch(e) {} } + + try { + const tList = await getAgentTriggers(agentId); + setTriggers(tList); + } catch(e) {} } catch (err) { setError(err.message); } finally { @@ -131,6 +150,55 @@ } }; + const handleClearHistory = () => { + if (!agent?.session_id) return; + setModalConfig({ + title: 'Confirm Memory Wipe', + message: "Are you sure you want to clear the agent's memory? This cannot be undone.", + type: 'error', + confirmText: 'Clear Memory', + confirmAction: async () => { + try { + setClearing(true); + if (agent?.session?.is_locked && editConfig?.is_locked === false) { + await updateAgentConfig(agent.id, { + is_locked: false, + mesh_node_id: agent.mesh_node_id || "hub" + }); + } + await clearSessionHistory(agent.session_id); + setChatHistory([]); + fetchData(); + } catch (err) { + setModalConfig({ title: 'Clear Failed', message: err.message, type: 'error' }); + } finally { + setClearing(false); + } + } + }); + }; + + const handleResetMetrics = () => { + if (!agent?.id) return; + setModalConfig({ + title: 'Confirm Reset Metrics', + message: "Are you sure you want to reset all execution metrics for this agent? This cannot be undone.", + type: 'error', + confirmText: 'Reset Metrics', + confirmAction: async () => { + try { + setClearing(true); // Re-use the clearing state to block duplicate clicks + await resetAgentMetrics(agent.id); + fetchData(); + } catch (err) { + setModalConfig({ title: 'Reset Failed', message: err.message, type: 'error' }); + } finally { + setClearing(false); + } + } + }); + }; + const handleSaveConfig = async () => { try { setSaving(true); @@ -143,6 +211,24 @@ if (editConfig.provider_name) { payload.provider_name = editConfig.provider_name; } + if (editConfig.restrict_skills !== undefined) { + payload.restrict_skills = editConfig.restrict_skills; + } + if (editConfig.allowed_skill_ids !== undefined) { + payload.allowed_skill_ids = editConfig.allowed_skill_ids; + } + if (editConfig.is_locked !== undefined) { + payload.is_locked = editConfig.is_locked; + } + if (editConfig.auto_clear_history !== undefined) { + payload.auto_clear_history = editConfig.auto_clear_history; + } + + // Explicitly pause the agent loop during update as requested by the user + try { + await fetchWithAuth(`/agents/${agentId}/status`, { method: "PATCH", body: { status: "idle" } }); + } catch (e) {} + await updateAgentConfig(agentId, payload); fetchData(); setModalConfig({ title: 'Success', message: 'Configuration Saved Successfully!', type: 'success' }); @@ -184,6 +270,19 @@ } }; + const handleFireTrigger = async (triggerPrompt) => { + try { + await fetchWithAuth(`/agents/${agentId}/run`, { + method: 'POST', + body: { prompt: triggerPrompt } + }); + setModalConfig({ title: 'Success', message: 'Agent manual execution started successfully!', type: 'success' }); + fetchData(); + } catch (err) { + setModalConfig({ title: 'Execution Failed', message: err.message, type: 'error' }); + } + }; + if (loading && !agent) return (
@@ -233,6 +332,15 @@
Live Thought Process + {agent?.session_id && ( + + )}
@@ -286,6 +394,12 @@ > Mesh Workspace +
{/* Tab Body */} @@ -323,10 +437,10 @@ -
+
Agent Name - setEditConfig({...editConfig, name: e.target.value})} className="w-full bg-white dark:bg-gray-950 border border-gray-300 dark:border-gray-700 text-sm rounded-md py-2.5 px-3 focus:outline-none focus:ring-1 focus:ring-indigo-500 text-gray-900 dark:text-gray-100 shadow-sm" placeholder="e.g. Documentation Assistant" /> + setEditConfig({...editConfig, name: e.target.value})} className="w-full bg-white dark:bg-gray-950 border border-gray-300 dark:border-gray-700 text-sm rounded-md py-2.5 px-3 focus:outline-none focus:ring-1 focus:ring-indigo-500 text-gray-900 dark:text-gray-100 shadow-sm" placeholder="e.g. Documentation Assistant" disabled={editConfig?.is_locked} />
Active LLM Provider @@ -334,6 +448,7 @@ value={editConfig?.provider_name || userConfig?.effective?.llm?.active_provider || ""} onChange={e => setEditConfig({...editConfig, provider_name: e.target.value})} className="w-full bg-white dark:bg-gray-950 border border-gray-300 dark:border-gray-700 text-sm rounded-md py-2.5 px-3 focus:outline-none focus:ring-1 focus:ring-indigo-500 text-gray-900 dark:text-gray-100 shadow-sm" + disabled={editConfig?.is_locked} > {userConfig?.effective?.llm?.providers && Object.keys(userConfig.effective.llm.providers).map(pid => ( @@ -348,6 +463,7 @@ onChange={(e) => setEditConfig({...editConfig, mesh_node_id: e.target.value})} className="w-full bg-white dark:bg-gray-950 border border-gray-300 dark:border-gray-700 text-sm rounded-md py-2.5 px-3 focus:outline-none focus:ring-1 focus:ring-indigo-500 text-gray-900 dark:text-gray-100 shadow-sm" required + disabled={editConfig?.is_locked} > {nodes.length === 0 && } {nodes.map(n => )} @@ -355,7 +471,113 @@
Max Iterations - setEditConfig({...editConfig, max_loop_iterations: e.target.value})} className="w-full bg-white dark:bg-gray-950 border border-gray-300 dark:border-gray-700 text-sm rounded-md py-2.5 px-3 focus:outline-none focus:ring-1 focus:ring-indigo-500 text-gray-900 dark:text-gray-100 shadow-sm" /> + setEditConfig({...editConfig, max_loop_iterations: e.target.value})} className="w-full bg-white dark:bg-gray-950 border border-gray-300 dark:border-gray-700 text-sm rounded-md py-2.5 px-3 focus:outline-none focus:ring-1 focus:ring-indigo-500 text-gray-900 dark:text-gray-100 shadow-sm" disabled={editConfig?.is_locked} /> +
+
+
+ + {/* Skills and Lock Controls */} +
+
+ Enabled Skills +
+ + +
+ System Core Skills +
+ {allSkills.filter(s => s.is_system).map(skill => ( + + βœ“ {skill.name} + + ))} +
+
+ + {editConfig?.restrict_skills && ( +
+ User Context Skills +
+ {allSkills.filter(s => !s.is_system).map(skill => ( + + ))} + {allSkills.filter(s => !s.is_system).length === 0 && ( + No user skills available + )} +
+
+ )} +
+
+
+ Memory Handling +
+ + +
+ +
@@ -384,12 +606,20 @@ Prompt: "{t.default_prompt.substring(0, 40)}{t.default_prompt.length > 40 ? '...' : ''}" )} - +
+ + +
))} @@ -460,7 +690,7 @@ -
+
Core System Instruction Prompt (Modifiable Live)

Changes to the system prompt will be immediately picked up by the agent loop on its next invocation turn.

@@ -472,6 +702,145 @@
)} + + {activeTab === 'metrics' && ( +
+
+

Execution Metrics

+ +
+ +
+
+ Total Runs + {agent?.total_runs || 0} +
+
+ Last Run + + {formatTimeLocal(agent?.last_heartbeat)} + +
+
+ Success Rate + + {agent?.total_runs ? Math.round(((agent?.successful_runs || 0) / agent.total_runs) * 100) : 0}% + +
+
setFlippedCards(prev => ({...prev, runtime: !prev.runtime}))} + > +
+
+ Total AI Run Time + {agent?.total_running_time_seconds ? agent.total_running_time_seconds.toLocaleString() + 's' : '0s'} +
+
+ Avg Request Time + + {agent?.total_runs ? (agent.total_running_time_seconds / agent.total_runs).toFixed(1) : 0}s + +
+
+
+ +
setFlippedCards(prev => ({...prev, tokens: !prev.tokens}))} + > +
+
+ Total Tokens (In / Out) + + {agent?.total_input_tokens?.toLocaleString() || 0} + / + {agent?.total_output_tokens?.toLocaleString() || 0} + +
+
+ Avg Tokens (In / Out) + + {agent?.total_runs ? Math.round(agent.total_input_tokens / agent.total_runs).toLocaleString() : 0} + / + {agent?.total_runs ? Math.round(agent.total_output_tokens / agent.total_runs).toLocaleString() : 0} + +
+
+
+
+ +
+

Tool Usage breakdown

+
+ + + + + + + + + + + {agent?.tool_call_counts && Object.keys(agent.tool_call_counts).length > 0 ? ( + Object.entries(agent.tool_call_counts).sort((a,b) => { + const bVal = typeof b[1] === 'object' ? b[1].calls : b[1]; + const aVal = typeof a[1] === 'object' ? a[1].calls : a[1]; + return bVal - aVal; + }).map(([tool, data]) => { + const calls = typeof data === 'object' ? data.calls : data; + const successes = typeof data === 'object' ? data.successes : data; + const failures = typeof data === 'object' ? data.failures : 0; + return ( + + + + + + + ); + }) + ) : ( + + + + )} + +
Tool NameCallsSuccessFailed
{tool}{calls}{successes}{failures > 0 ? failures : '-'}
No tool calls recorded yet
+
+
+
+ )} @@ -484,10 +853,21 @@

{modalConfig.title}

{modalConfig.message}

-
- + {modalConfig.confirmAction && ( + + )}
diff --git a/frontend/src/features/agents/components/AgentHarnessPage.js b/frontend/src/features/agents/components/AgentHarnessPage.js index 84812a2..b7091ef 100644 --- a/frontend/src/features/agents/components/AgentHarnessPage.js +++ b/frontend/src/features/agents/components/AgentHarnessPage.js @@ -1,10 +1,19 @@ import React, { useState, useEffect } from 'react'; -import { getAgents, getAgentTelemetry, updateAgentStatus, deployAgent, deleteAgent, getUserConfig, getUserAccessibleNodes } from '../../../services/apiService'; +import { getAgents, getAgentTelemetry, updateAgentStatus, deployAgent, deleteAgent, getUserConfig, getUserAccessibleNodes, getAgentTriggers } from '../../../services/apiService'; import { AreaChart, Area, XAxis, YAxis, Tooltip, ResponsiveContainer } from 'recharts'; // Polling interval in ms const POLLING_INTERVAL = 5000; +const formatTimeLocal = (utcString) => { + if (!utcString) return 'Never'; + const dateStr = utcString.endsWith('Z') || utcString.includes('+') ? utcString : utcString + 'Z'; + return new Date(dateStr).toLocaleString(undefined, { + month: 'short', day: 'numeric', + hour: '2-digit', minute: '2-digit', second: '2-digit' + }); +}; + export default function AgentHarnessPage({ onNavigate }) { const [agents, setAgents] = useState([]); const [loading, setLoading] = useState(true); @@ -364,32 +373,21 @@ // Agent Card Component // ───────────────────────────────────────────────────────────── const AgentCard = ({ agent, onNavigate, onStatusChange }) => { - const [telemetryList, setTelemetryList] = useState([]); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); const [deleteError, setDeleteError] = useState(null); const [isDeleting, setIsDeleting] = useState(false); - + const [triggers, setTriggers] = useState([]); + useEffect(() => { let isMounted = true; - const fetchTelemetry = async () => { + const fetchTriggers = async () => { try { - const t = await getAgentTelemetry(agent.id); - if (isMounted) { - setTelemetryList(prev => { - const next = [...prev, { time: new Date().toLocaleTimeString(), cpu: t.cpu_usage || 0, ram: t.memory_usage || 0 }]; - return next.slice(-20); - }); - } - } catch (err) { - console.error("Failed fetching telemetry for agent " + agent.id, err); - } + const t = await getAgentTriggers(agent.id); + if (isMounted) setTriggers(t); + } catch (err) {} }; - fetchTelemetry(); - const interval = setInterval(fetchTelemetry, 3000); - return () => { - isMounted = false; - clearInterval(interval); - }; + fetchTriggers(); + return () => { isMounted = false; }; }, [agent.id]); const handleAction = async (status) => { @@ -414,14 +412,12 @@ }; const isError = agent.status === 'error_suspended'; - const isPaused = agent.status === 'paused_mid_loop'; + const isPaused = agent.status === 'paused_mid_loop' || agent.status === 'suspended'; const statusTheme = isError ? 'text-rose-400 bg-rose-500/10 border-rose-500/30 ring-rose-500/20' : isPaused ? 'text-amber-400 bg-amber-500/10 border-amber-500/30 ring-amber-500/20' : 'text-emerald-400 bg-emerald-500/10 border-emerald-500/30 ring-emerald-500/20'; - const latestTelemetry = telemetryList.length > 0 ? telemetryList[telemetryList.length - 1] : { cpu: 0, ram: 0 }; - return (
@@ -462,8 +458,8 @@
Execution Stats
Max Iterations: {agent.template?.max_loop_iterations || 20} - Run Iterations: 0 (N/A) - Success Rate: 100% + Run Iterations: {agent?.total_runs || 0} + Success Rate: {agent?.total_runs ? Math.round(((agent?.successful_runs || 0) / agent.total_runs) * 100) : 0}%
@@ -481,36 +477,46 @@ {/* Node Info */} {agent.mesh_node_id && ( -
+
Node: {agent.mesh_node_id}
)} -
-
- CPU - {latestTelemetry.cpu.toFixed(1)}% + {/* Triggers Summary */} +
+ {triggers.length === 0 ? ( + No Triggers + ) : ( + triggers.map(t => ( + + {t.trigger_type === 'cron' && `⏰ ${t.cron_expression}`} + {t.trigger_type === 'interval' && `πŸ”„ ${t.interval_seconds}s`} + {t.trigger_type === 'webhook' && `πŸ”— Webhook`} + {t.trigger_type === 'manual' && `πŸ–οΈ Manual`} + + )) + )} +
+ +
+
+ Runs + {agent?.total_runs || 0}
-
- RAM - {latestTelemetry.ram}MB +
+ Success + {agent?.total_runs ? Math.round(((agent?.successful_runs || 0) / agent.total_runs) * 100) : 0}% +
+
+ Last Run + {formatTimeLocal(agent?.last_heartbeat)}
-
- - - - - - - - - - null} /> - - -
+
@@ -524,28 +530,20 @@
- -
diff --git a/frontend/src/services/api/agentService.js b/frontend/src/services/api/agentService.js index 42ac720..db58304 100644 --- a/frontend/src/services/api/agentService.js +++ b/frontend/src/services/api/agentService.js @@ -1,7 +1,7 @@ import { fetchWithAuth } from './apiClient'; export const getAgents = async () => { - return await fetchWithAuth(`/agents`); + return await fetchWithAuth(`/agents?_t=${Date.now()}`); }; export const getAgentTelemetry = async (id) => { @@ -55,3 +55,9 @@ method: 'DELETE' }); }; + +export const resetAgentMetrics = async (id) => { + return await fetchWithAuth(`/agents/${id}/metrics/reset`, { + method: 'POST' + }); +}; diff --git a/test_run.log b/test_run.log new file mode 100644 index 0000000..57a30ec --- /dev/null +++ b/test_run.log @@ -0,0 +1,31 @@ +========================================== + CORTEX HUB INTEGRATION TESTS SETUP +========================================== +Docker daemon not reachable (likely running in a Dev Container). Switching to Native Python mode... +Starting AI Hub natively in the background... +Waiting for AI Hub to be ready... +Waiting for AI Hub Backend natively... +AI Hub Backend is online. +========================================== + EXECUTING E2E INTEGRATION SUITE +========================================== +/app/run_integration_tests.sh: line 100: /tmp/venv/bin/activate: No such file or directory +No venv found, hoping pytest is in global PATH. +============================= test session starts ============================== +platform linux -- Python 3.11.13, pytest-9.0.2, pluggy-1.6.0 -- /usr/local/bin/python3.11 +cachedir: .pytest_cache +rootdir: / +plugins: mock-3.15.1, anyio-4.12.1, trio-0.8.0, tornasync-0.6.0.post2, asyncio-1.3.0 +asyncio: mode=Mode.STRICT, debug=False, asyncio_default_fixture_loop_scope=None, asyncio_default_test_loop_scope=function +collecting ... ERROR: file or directory not found: ai-hub/integration_tests/ + +collected 0 items + +=============================== warnings summary =============================== +home/vscode/.local/lib/python3.11/site-packages/_pytest/cacheprovider.py:475 + /home/vscode/.local/lib/python3.11/site-packages/_pytest/cacheprovider.py:475: PytestCacheWarning: could not create cache path /.pytest_cache/v/cache/nodeids: [Errno 13] Permission denied: '/pytest-cache-files-z77lkhsd' + config.cache.set("cache/nodeids", sorted(self.cached_nodeids)) + +-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html +============================== 1 warning in 0.00s ============================== +Terminated diff --git a/test_sandbox/test_local.py b/test_sandbox/test_local.py new file mode 100644 index 0000000..b00fa69 --- /dev/null +++ b/test_sandbox/test_local.py @@ -0,0 +1,943 @@ +""" +File Sync Integration Tests +============================ +Verifies the end-to-end mesh file synchronisation behaviour across nodes and +the Hub server mirror. These tests run against a *live* deployment that has +at least two test nodes connected: + + β€’ test-node-1 (NODE_1 constant) + β€’ test-node-2 (NODE_2 constant) + +The Hub exposes REST endpoints used to: + - read files: GET /api/v1/nodes/{node_id}/fs/cat + - list dirs: GET /api/v1/nodes/{node_id}/fs/ls + - write files: POST /api/v1/nodes/{node_id}/fs/touch + - delete: POST /api/v1/nodes/{node_id}/fs/rm + +A shared swarm-control session is created once per test module so that all +nodes are in the same mesh workspace, and file operations propagate correctly. + +Environment assumptions +----------------------- + BASE_URL http://127.0.0.1:8000 (inside container) or http://192.168.68.113 (from outside) + NODE_1 test-node-1 + NODE_2 test-node-2 + USER_ID SYNC_TEST_USER_ID env var (required for node access checks) + TIMEOUT 10 s for small files, 60 s for 20 MB files +""" + +import os +import time +import uuid +import hashlib +import pytest +import httpx + +# ── Configuration ────────────────────────────────────────────────────────────── +BASE_URL = os.getenv("SYNC_TEST_BASE_URL", "http://127.0.0.1:8002/api/v1") +USER_ID = os.getenv("SYNC_TEST_USER_ID", "c4401d34-8784-4d6e-93a0-c702bd202b66") +NODE_1 = os.getenv("SYNC_TEST_NODE1", "test-node-1") +NODE_2 = os.getenv("SYNC_TEST_NODE2", "test-node-2") + +SMALL_FILE_TIMEOUT = 10 # seconds +LARGE_FILE_TIMEOUT = 60 # seconds (20 MB) +LARGE_FILE_SIZE_MB = 20 +POLL_INTERVAL = 0.5 # seconds + +# Paths β€” relative to BASE_URL +SESSIONS_PATH = "/sessions" +NODES_PATH = "/nodes" + + +# ── Module-level: skip the whole file if nodes are not online ────────────────── +def pytest_configure(config): + config.addinivalue_line( + "markers", + "requires_nodes: mark test as requiring live agent nodes to be connected", + ) + + +pytestmark = pytest.mark.requires_nodes + + +# ── Helpers ───────────────────────────────────────────────────────────────────── + +def _get_user_id() -> str: + return os.getenv("SYNC_TEST_USER_ID", "integration_tester_sync") + +def _headers(): + return {"X-User-ID": _get_user_id()} + + +def _unique(prefix="synctest"): + return f"{prefix}_{uuid.uuid4().hex[:8]}.txt" + + +def _large_content(mb: int = LARGE_FILE_SIZE_MB) -> str: + """Return a UTF-8 string of approximately `mb` megabytes.""" + line = "A" * 1023 + "\n" # 1 KB per line + return line * (mb * 1024) # mb * 1024 lines β‰ˆ mb MB + + +def _poll_until(fn, timeout: float, interval: float = POLL_INTERVAL): + """ + Repeatedly call fn() until it returns a truthy value or timeout expires. + Returns the last return value of fn(). + """ + deadline = time.time() + timeout + last = None + while time.time() < deadline: + try: + last = fn() + if last: + return last + except Exception: + pass + time.sleep(interval) + return last + + +def _cat(client: httpx.Client, node_id: str, path: str, session_id: str) -> str | None: + """Read a file from a node; return its text content or None on error.""" + r = client.get( + f"{NODES_PATH}/{node_id}/fs/cat", + params={"path": path, "session_id": session_id}, + headers=_headers(), + ) + if r.status_code == 200: + return r.json().get("content", "") + return None + + +def _mirror_cat(client: httpx.Client, path: str, session_id: str) -> str | None: + """ + Read a file from the Hub server mirror directly by asking node-1 for it + using the session_id workspace (the Hub mirror is queried when the node + reflects a workspace file). + + For the server-side write tests we can call the same endpoint but the + source is the Hub mirror, not the live node FS. + """ + # The Hub's /cat endpoint fetches from node, then caches in mirror. + # For "server wrote it β†’ does node have it?" we ask the node directly. + return _cat(client, NODE_1, path, session_id) + + +def _touch( + client: httpx.Client, + node_id: str, + path: str, + content: str, + session_id: str, + is_dir: bool = False, +) -> dict: + """Write a file to a node via the REST API.""" + r = client.post( + f"{NODES_PATH}/{node_id}/fs/touch", + json={"path": path, "content": content, "is_dir": is_dir, "session_id": session_id}, + headers=_headers(), + timeout=120.0, + ) + r.raise_for_status() + return r.json() + + +def _rm(client: httpx.Client, node_id: str, path: str, session_id: str) -> dict: + """Delete a file from a node via the REST API.""" + r = client.post( + f"{NODES_PATH}/{node_id}/fs/rm", + json={"path": path, "session_id": session_id}, + headers=_headers(), + timeout=30.0, + ) + r.raise_for_status() + return r.json() + + +def _file_missing(client: httpx.Client, node_id: str, path: str, session_id: str) -> bool: + """Return True if the file does NOT exist on the node.""" + r = client.get( + f"{NODES_PATH}/{node_id}/fs/cat", + params={"path": path, "session_id": session_id}, + headers=_headers(), + ) + return r.status_code != 200 + + +# ── Session fixture ───────────────────────────────────────────────────────────── + +@pytest.fixture(scope="module") +def sync_client(): + """Synchronous httpx client for the whole module (avoids asyncio overhead).""" + with httpx.Client(base_url=BASE_URL, timeout=60.0) as c: + # Quick connectivity + node-online check + try: + r = c.get(f"{NODES_PATH}/{NODE_1}/status", headers=_headers()) + if r.status_code not in (200, 404): + pytest.skip(f"{NODE_1} unreachable β€” hub returned {r.status_code}") + except Exception as exc: + pytest.skip(f"Hub unreachable at {BASE_URL}: {exc}") + yield c + + +@pytest.fixture(scope="module") +def swarm_session(sync_client: httpx.Client) -> str: + """ + Create (or reuse) one swarm_control session that has both test nodes + attached. Returned value is the workspace_id string used by all sync ops. + """ + # Create the session + r = sync_client.post( + f"{SESSIONS_PATH}/", + json={"user_id": _get_user_id(), "provider_name": "gemini", "feature_name": "swarm_control"}, + headers=_headers(), + ) + assert r.status_code == 200, f"Create session failed: {r.text}" + session_id = r.json()["id"] + + # Attach both nodes with source="empty" so they both watch the workspace + r2 = sync_client.post( + f"{SESSIONS_PATH}/{session_id}/nodes", + json={"node_ids": [NODE_1, NODE_2], "config": {"source": "empty"}}, + headers=_headers(), + ) + assert r2.status_code == 200, f"Attach nodes failed: {r2.text}" + workspace_id = r2.json().get("sync_workspace_id") + assert workspace_id, "Expected sync_workspace_id in response" + + # Give nodes a moment to ACK the workspace and start watching + time.sleep(2.0) + + yield workspace_id + + # Teardown: archive the session + sync_client.delete(f"{SESSIONS_PATH}/{session_id}", headers=_headers()) + + +# ══════════════════════════════════════════════════════════════════════════════ +# SMALL FILE TESTS (< 1 chunk) +# ══════════════════════════════════════════════════════════════════════════════ + +class TestSmallFileSync: + """Cases 1–4: single small-file create + delete in both directions.""" + + # ── Case 1: node-1 β†’ node-2 + server ─────────────────────────────────── + def test_case1_write_from_node1_visible_on_node2_and_server( + self, sync_client, swarm_session + ): + """ + Write a file from test-node-1; verify test-node-2 AND the server + mirror receive it within SMALL_FILE_TIMEOUT seconds. + """ + filename = _unique("case1") + content = f"Case 1 payload – node-1 β†’ mesh – {uuid.uuid4()}" + workspace = swarm_session + + print(f"\n[Case 1] Writing {filename!r} to {NODE_1} in workspace {workspace}") + result = _touch(sync_client, NODE_1, filename, content, workspace) + assert result.get("success"), f"Write failed: {result}" + + # Verify on node-2 + node2_content = _poll_until( + lambda: _cat(sync_client, NODE_2, filename, workspace), + timeout=SMALL_FILE_TIMEOUT, + ) + assert node2_content is not None, ( + f"[Case 1] File '{filename}' did NOT appear on {NODE_2} within " + f"{SMALL_FILE_TIMEOUT}s" + ) + assert content in node2_content, ( + f"[Case 1] Content mismatch on {NODE_2}. Got: {node2_content!r}" + ) + print(f"[Case 1] βœ… {NODE_2} received the file.") + + # Verify on Hub server mirror (query node-1 with session scope uses mirror) + server_content = _poll_until( + lambda: _cat(sync_client, NODE_1, filename, workspace), + timeout=SMALL_FILE_TIMEOUT, + ) + assert server_content is not None and content in server_content, ( + f"[Case 1] File '{filename}' not found on Hub mirror." + ) + print(f"[Case 1] βœ… Hub mirror has the file.") + + # ── Case 2: server β†’ node-1 + node-2 ─────────────────────────────────── + def test_case2_write_from_server_visible_on_all_nodes( + self, sync_client, swarm_session + ): + """ + Write a file via the server (the touch endpoint dispatches to all nodes + in the session + writes to Hub mirror). Verify both client nodes and + the mirror reflect it. + """ + filename = _unique("case2") + content = f"Case 2 payload – server β†’ mesh – {uuid.uuid4()}" + workspace = swarm_session + + print(f"\n[Case 2] Writing {filename!r} via server to workspace {workspace}") + # Intentionally write via node-1 (server-dispatched; Hub mirror updated first) + result = _touch(sync_client, NODE_1, filename, content, workspace) + assert result.get("success"), f"Write failed: {result}" + + # Node-2 should receive via broadcast + node2_content = _poll_until( + lambda: _cat(sync_client, NODE_2, filename, workspace), + timeout=SMALL_FILE_TIMEOUT, + ) + assert node2_content is not None and content in node2_content, ( + f"[Case 2] File '{filename}' did NOT appear on {NODE_2}." + ) + print(f"[Case 2] βœ… {NODE_2} received the file.") + + # Node-1 should also have it (it was written directly to it and mirrored) + node1_content = _cat(sync_client, NODE_1, filename, workspace) + assert node1_content is not None and content in node1_content, ( + f"[Case 2] File '{filename}' not found on {NODE_1}." + ) + print(f"[Case 2] βœ… {NODE_1} has the file.") + + # ── Case 3: delete from server β†’ nodes purged ────────────────────────── + def test_case3_delete_from_server_purges_client_nodes( + self, sync_client, swarm_session + ): + """ + Create a file via server, then delete it via the server endpoint. + Verify both client nodes no longer have the file. + """ + filename = _unique("case3") + content = f"Case 3 – to be deleted by server – {uuid.uuid4()}" + workspace = swarm_session + + # Setup: write the file from node-1 (server-side orchestrated) + r = _touch(sync_client, NODE_1, filename, content, workspace) + assert r.get("success"), f"Setup write failed: {r}" + + # Make sure node-2 got it before we delete + got = _poll_until( + lambda: _cat(sync_client, NODE_2, filename, workspace), + timeout=SMALL_FILE_TIMEOUT, + ) + assert got is not None, f"[Case 3] Setup: file not on {NODE_2}." + + print(f"\n[Case 3] Deleting {filename!r} from server (via {NODE_1} endpoint)") + _rm(sync_client, NODE_1, filename, workspace) + + # node-2 should no longer have the file + gone_node2 = _poll_until( + lambda: _file_missing(sync_client, NODE_2, filename, workspace), + timeout=SMALL_FILE_TIMEOUT, + ) + assert gone_node2, ( + f"[Case 3] File '{filename}' still present on {NODE_2} after server delete." + ) + print(f"[Case 3] βœ… {NODE_2} no longer has the file.") + + # node-1 should also have it gone + gone_node1 = _file_missing(sync_client, NODE_1, filename, workspace) + assert gone_node1, ( + f"[Case 3] File '{filename}' still present on {NODE_1} after server delete." + ) + print(f"[Case 3] βœ… {NODE_1} no longer has the file.") + + # ── Case 4: delete from node-2 β†’ server + node-1 purged ─────────────── + def test_case4_delete_from_node2_purges_server_and_node1( + self, sync_client, swarm_session + ): + """ + Create a file, let it propagate, then delete it FROM node-2. + Verify the Hub mirror and node-1 no longer have the file. + """ + filename = _unique("case4") + content = f"Case 4 – to be deleted from node-2 – {uuid.uuid4()}" + workspace = swarm_session + + # Setup: write from node-1 so both nodes and mirror have it + r = _touch(sync_client, NODE_1, filename, content, workspace) + assert r.get("success"), f"Setup write failed: {r}" + + got_node2 = _poll_until( + lambda: _cat(sync_client, NODE_2, filename, workspace), + timeout=SMALL_FILE_TIMEOUT, + ) + assert got_node2 is not None, f"[Case 4] Setup: file did not reach {NODE_2}." + + print(f"\n[Case 4] Deleting {filename!r} from {NODE_2}") + _rm(sync_client, NODE_2, filename, workspace) + + # Hub mirror (observed via node-1's workspace view) should purge + gone_server = _poll_until( + lambda: _file_missing(sync_client, NODE_1, filename, workspace), + timeout=SMALL_FILE_TIMEOUT, + ) + assert gone_server, ( + f"[Case 4] File '{filename}' still present on Hub mirror after node-2 delete." + ) + print(f"[Case 4] βœ… Hub mirror no longer has the file.") + + # node-1 should also be purged + gone_node1 = _file_missing(sync_client, NODE_1, filename, workspace) + assert gone_node1, ( + f"[Case 4] File '{filename}' still present on {NODE_1} after node-2 delete." + ) + print(f"[Case 4] βœ… {NODE_1} no longer has the file.") + + # ── Case 9: cat on deleted file returns quickly, not after timeout ────── + def test_case9_cat_deleted_file_returns_quickly_not_timeout( + self, sync_client, swarm_session + ): + """ + Regression test for the silent-return bug in _push_file (node side) + and the missing mirror short-circuit in cat() (hub side). + + Before the fix, reading a deleted file would stall for the full 15s + journal timeout because the node returned nothing and the hub just sat + waiting. After the fix: + - hub: cat() checks the mirror first; file absent β†’ instant "File not found" + - node: _push_file sends an ERROR SyncStatus immediately when file missing + + This test enforces that a cat call on a deleted file resolves in under + MAX_LATENCY_S seconds on BOTH nodes. + """ + MAX_LATENCY_S = 3.0 # well below the 15s journal timeout + filename = _unique("case9_latency") + content = f"Case 9 β€” delete latency probe β€” {uuid.uuid4()}" + workspace = swarm_session + + # Setup: write the file and wait for full propagation + r = _touch(sync_client, NODE_1, filename, content, workspace) + assert r.get("success"), f"[Case 9] Setup write failed: {r}" + synced = _poll_until( + lambda: _cat(sync_client, NODE_2, filename, workspace), + timeout=SMALL_FILE_TIMEOUT, + ) + assert synced is not None, f"[Case 9] Setup: file did not propagate to {NODE_2}." + + # Delete from server + print(f"\n[Case 9] Deleting {filename!r}, then timing cat() on both nodes") + _rm(sync_client, NODE_1, filename, workspace) + + # Give delete broadcast a moment to reach nodes (but not the full poll timeout) + time.sleep(1.5) + + # Measure cat latency on node-1 (hub mirror path β€” should be instant) + t0 = time.time() + res1 = _cat(sync_client, NODE_1, filename, workspace) + latency_node1 = time.time() - t0 + assert res1 is None, ( + f"[Case 9] {NODE_1} still returned content after delete: {res1!r}" + ) + assert latency_node1 < MAX_LATENCY_S, ( + f"[Case 9] cat() on {NODE_1} took {latency_node1:.1f}s β€” expected < {MAX_LATENCY_S}s. " + f"Hub mirror short-circuit may be broken." + ) + print(f"[Case 9] βœ… {NODE_1} cat returned in {latency_node1:.2f}s (file absent, fast-fail).") + + # Measure cat latency on node-2 (hub mirror path β€” should also be instant) + t0 = time.time() + res2 = _cat(sync_client, NODE_2, filename, workspace) + latency_node2 = time.time() - t0 + assert res2 is None, ( + f"[Case 9] {NODE_2} still returned content after delete: {res2!r}" + ) + assert latency_node2 < MAX_LATENCY_S, ( + f"[Case 9] cat() on {NODE_2} took {latency_node2:.1f}s β€” expected < {MAX_LATENCY_S}s. " + f"Node _push_file may not be sending error status on missing file." + ) + print(f"[Case 9] βœ… {NODE_2} cat returned in {latency_node2:.2f}s (file absent, fast-fail).") + + # ── Case 11: Agent "hub" pseudo-node write visibility ─────────────────── + def test_case11_hub_pseudo_node_write_visibility( + self, sync_client, swarm_session + ): + """ + Regression test for AI agents writing to node='hub'. + When an AI uses mesh_file_explorer with node='hub', it directly modifies + the local mirror without broadcasting to an agent node immediately if it's + just "hub". Wait, if session_id is provided, it DOES broadcast! + Let's ensure that writing to 'hub' with a valid session scope returns success, + is immediately visible in the mirror, and is retrievable via fs/cat! + """ + filename = _unique("case11_hub") + content = f"Case 11 β€” Dummy file from hub β€” {uuid.uuid4()}" + workspace = swarm_session + + print(f"\n[Case 11] Writing {filename!r} to pseudonode 'hub' in workspace {workspace}") + result = _touch(sync_client, "hub", filename, content, workspace) + assert result.get("success"), f"Write to 'hub' failed: {result}" + + # Mirror cat (since we read from node-1, the Hub mirror resolves it instantly if it exists) + # Or better yet, read from "hub" pseudonode! + def _cat_hub(): + r = sync_client.get( + f"{NODES_PATH}/hub/fs/cat", + params={"path": filename, "session_id": workspace}, + headers=_headers(), + ) + if r.status_code == 200: + return r.json().get("content", "") + return None + + # Verify on Hub mirror + hub_content = _poll_until(_cat_hub, timeout=SMALL_FILE_TIMEOUT) + assert hub_content is not None, f"[Case 11] File '{filename}' not found on 'hub' pseudonode." + assert content in hub_content, f"[Case 11] Content mismatch on Hub loopback." + print(f"[Case 11] βœ… 'hub' pseudonode successfully returned the file.") + + + +# ══════════════════════════════════════════════════════════════════════════════ +# NODE RECONNECT / RESYNC TESTS +# ══════════════════════════════════════════════════════════════════════════════ + +# Docker container names for the test nodes on the production server +_NODE_CONTAINER = { + "test-node-1": "cortex-test-1", + "test-node-2": "cortex-test-2", +} + +import subprocess +import os + +def _get_remote_env(): + try: + script_path = os.path.abspath(os.path.join(os.path.dirname(__file__), "../../.agent/utils/env_loader.sh")) + if os.path.exists(script_path): + cmd = f"source {script_path} >/dev/null 2>&1 && echo \"${{REMOTE_PASSWORD}}|${{REMOTE_USER}}|${{REMOTE_HOST}}\"" + res = subprocess.run(["bash", "-c", cmd], capture_output=True, text=True, check=True) + parts = res.stdout.strip().split("|") + if len(parts) == 3 and parts[0]: + return parts[0], parts[1], parts[2] + except Exception: + pass + return os.environ.get("REMOTE_PASSWORD", ""), os.environ.get("REMOTE_USER", "axieyangb"), os.environ.get("REMOTE_HOST", "192.168.68.113") + +_REMOTE_PASSWORD, _REMOTE_USER, _REMOTE_HOST = _get_remote_env() +_SSH_CMD = f"sshpass -p '{_REMOTE_PASSWORD}' ssh -o StrictHostKeyChecking=no {_REMOTE_USER}@{_REMOTE_HOST}" + + +def _restart_test_node(node_id: str): + """ + Restart the named test-node Docker container on the production server. + This wipes /tmp/cortex-sync on the node, simulating a real reboot. + """ + import subprocess + container = _NODE_CONTAINER.get(node_id) + if not container: + pytest.skip(f"No container mapping for {node_id}") + cmd = ( + f"sshpass -p '{_REMOTE_PASSWORD}' ssh -o StrictHostKeyChecking=no {_REMOTE_USER}@{_REMOTE_HOST} " + f"\"echo '{_REMOTE_PASSWORD}' | sudo -S docker restart {container}\"" + ) + result = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=30) + if result.returncode != 0: + pytest.skip(f"Could not restart {container}: {result.stderr}") + + +class TestNodeResync: + """ + Case 10: node reconnect / workspace resync after container restart. + + Real-world scenario: a test node restarts (deploy, crash, reboot) and + /tmp/cortex-sync is wiped. The Hub must re-push the workspace to the + reconnected node via manifest-driven reconciliation. + """ + + # ── Case 10: node-2 restart β†’ hub re-delivers workspace ──────────────── + def test_case10_node_resync_after_restart( + self, sync_client, swarm_session + ): + """ + 1. Write a file to node-1 and confirm node-2 received it. + 2. Restart the node-2 container (wipes /tmp/cortex-sync). + 3. Wait for node-2 to reconnect and receive the manifest from Hub. + 4. Assert that the file re-appears on node-2 within RESYNC_TIMEOUT. + + This guards against regressions in the push_workspace / manifest-driven + reconciliation loop that re-delivers Hub mirror contents to a freshly + reconnected node. + """ + RESYNC_TIMEOUT = 30 # seconds for node to reconnect + resync + RESTART_WAIT = 8 # seconds to allow container to come back up + + filename = _unique("case10_resync") + content = f"Case 10 β€” node resync after restart β€” {uuid.uuid4()}" + workspace = swarm_session + + # Setup: write from node-1, wait for node-2 to receive + r = _touch(sync_client, NODE_1, filename, content, workspace) + assert r.get("success"), f"[Case 10] Setup write failed: {r}" + + synced = _poll_until( + lambda: _cat(sync_client, NODE_2, filename, workspace), + timeout=SMALL_FILE_TIMEOUT, + ) + assert synced is not None, f"[Case 10] Setup: file did not reach {NODE_2} before restart." + print(f"\n[Case 10] File confirmed on {NODE_2}. Restarting container…") + + # Restart node-2 container β€” wipes /tmp/cortex-sync + _restart_test_node(NODE_2) + + # Brief pause to let the container fully stop, then wait for reconnect + time.sleep(RESTART_WAIT) + print(f"[Case 10] Container restarted. Waiting for {NODE_2} to reconnect and resync…") + + # After reconnect, node sends its (now-empty) manifest β†’ Hub sends back + # all missing files. Poll until the file reappears. + resynced = _poll_until( + lambda: _cat(sync_client, NODE_2, filename, workspace), + timeout=RESYNC_TIMEOUT, + ) + assert resynced is not None, ( + f"[Case 10] File '{filename}' did NOT re-appear on {NODE_2} within " + f"{RESYNC_TIMEOUT}s after container restart. " + f"Manifest-driven resync may be broken." + ) + assert content in resynced, ( + f"[Case 10] Content mismatch on {NODE_2} after resync. Got: {resynced!r}" + ) + print(f"[Case 10] βœ… {NODE_2} resynced the file after container restart.") + + +# ══════════════════════════════════════════════════════════════════════════════ +# LARGE FILE TESTS (20 MB, multi-chunk) +# ══════════════════════════════════════════════════════════════════════════════ + +class TestLargeFileSync: + """Cases 5–8: 20 MB file create + delete in both directions.""" + + @pytest.fixture(scope="class", autouse=True) + def _large_content(self): + """Pre-build the 20 MB string once per class to save CPU time.""" + self.__class__._content = _large_content(LARGE_FILE_SIZE_MB) + self.__class__._expected_hash = hashlib.sha256( + self._content.encode() + ).hexdigest() + + # ── Case 5: 20 MB from node-1 β†’ server + node-2 ──────────────────────── + def test_case5_large_file_from_node1_to_server_and_node2( + self, sync_client, swarm_session + ): + """ + Create a 20 MB file from test-node-1. + Both server mirror and test-node-2 should receive it within + LARGE_FILE_TIMEOUT seconds. + """ + filename = _unique("case5_large") + workspace = swarm_session + + print(f"\n[Case 5] Writing {LARGE_FILE_SIZE_MB} MB file {filename!r} from {NODE_1}") + result = _touch(sync_client, NODE_1, filename, self._content, workspace) + assert result.get("success"), f"Write failed: {result}" + + # Verify node-2 received the file + node2_content = _poll_until( + lambda: _cat(sync_client, NODE_2, filename, workspace), + timeout=LARGE_FILE_TIMEOUT, + ) + assert node2_content is not None, ( + f"[Case 5] Large file did NOT appear on {NODE_2} within {LARGE_FILE_TIMEOUT}s." + ) + got_hash = hashlib.sha256(node2_content.encode()).hexdigest() + assert got_hash == self._expected_hash, ( + f"[Case 5] Hash mismatch on {NODE_2}. Expected {self._expected_hash}, got {got_hash}" + ) + print(f"[Case 5] βœ… {NODE_2} received and verified 20 MB large file.") + + # Verify server mirror + mirror_content = _cat(sync_client, NODE_1, filename, workspace) + assert mirror_content is not None, ( + f"[Case 5] Large file not on Hub mirror." + ) + print(f"[Case 5] βœ… Hub mirror has the large file.") + + # ── Case 6: 20 MB from server β†’ node-1 + node-2 ──────────────────────── + def test_case6_large_file_from_server_to_all_nodes( + self, sync_client, swarm_session + ): + """ + Write a 20 MB file via the server (touch endpoint with session scope). + Both client nodes should receive it within LARGE_FILE_TIMEOUT seconds. + """ + filename = _unique("case6_large") + workspace = swarm_session + + print(f"\n[Case 6] Writing {LARGE_FILE_SIZE_MB} MB file {filename!r} via server") + result = _touch(sync_client, NODE_1, filename, self._content, workspace) + assert result.get("success"), f"Write failed: {result}" + + # node-2 receives via mesh broadcast + node2_ok = _poll_until( + lambda: _cat(sync_client, NODE_2, filename, workspace), + timeout=LARGE_FILE_TIMEOUT, + ) + assert node2_ok is not None, ( + f"[Case 6] Large file did NOT appear on {NODE_2} within {LARGE_FILE_TIMEOUT}s." + ) + print(f"[Case 6] βœ… {NODE_2} received the 20 MB file.") + + node1_ok = _cat(sync_client, NODE_1, filename, workspace) + assert node1_ok is not None, f"[Case 6] File not on {NODE_1}." + print(f"[Case 6] βœ… {NODE_1} has the 20 MB file.") + + # ── Case 7: delete large file from server β†’ nodes purged ─────────────── + def test_case7_delete_large_file_from_server_purges_nodes( + self, sync_client, swarm_session + ): + """ + Write and fully sync a large file, then delete via server. + Verify all client nodes are purged. + """ + filename = _unique("case7_large") + workspace = swarm_session + + # Setup + r = _touch(sync_client, NODE_1, filename, self._content, workspace) + assert r.get("success"), f"Setup write failed: {r}" + + synced = _poll_until( + lambda: _cat(sync_client, NODE_2, filename, workspace), + timeout=LARGE_FILE_TIMEOUT, + ) + assert synced is not None, f"[Case 7] Setup: large file did not reach {NODE_2}." + + print(f"\n[Case 7] Deleting large file {filename!r} from server") + _rm(sync_client, NODE_1, filename, workspace) + + gone_node2 = _poll_until( + lambda: _file_missing(sync_client, NODE_2, filename, workspace), + timeout=SMALL_FILE_TIMEOUT, + ) + assert gone_node2, ( + f"[Case 7] Large file still present on {NODE_2} after server delete." + ) + print(f"[Case 7] βœ… {NODE_2} purged the large file.") + + gone_node1 = _file_missing(sync_client, NODE_1, filename, workspace) + assert gone_node1, ( + f"[Case 7] Large file still present on {NODE_1} after server delete." + ) + print(f"[Case 7] βœ… {NODE_1} purged the large file.") + + # ── Case 8: delete large file from node-2 β†’ server + node-1 ─────────── + def test_case8_delete_large_file_from_node2_purges_server_and_node1( + self, sync_client, swarm_session + ): + """ + Write and sync a large file, then delete it FROM node-2. + Verify Hub mirror and node-1 are both purged. + """ + filename = _unique("case8_large") + workspace = swarm_session + + # Setup + r = _touch(sync_client, NODE_1, filename, self._content, workspace) + assert r.get("success"), f"Setup write failed: {r}" + + synced = _poll_until( + lambda: _cat(sync_client, NODE_2, filename, workspace), + timeout=LARGE_FILE_TIMEOUT, + ) + assert synced is not None, f"[Case 8] Setup: large file did not reach {NODE_2}." + + print(f"\n[Case 8] Deleting large file {filename!r} from {NODE_2}") + _rm(sync_client, NODE_2, filename, workspace) + + gone_server = _poll_until( + lambda: _file_missing(sync_client, NODE_1, filename, workspace), + timeout=SMALL_FILE_TIMEOUT, + ) + assert gone_server, ( + f"[Case 8] Large file still on Hub mirror after {NODE_2} delete." + ) + print(f"[Case 8] βœ… Hub mirror purged the large file.") + + gone_node1 = _file_missing(sync_client, NODE_1, filename, workspace) + assert gone_node1, ( + f"[Case 8] Large file still on {NODE_1} after {NODE_2} delete." + ) + print(f"[Case 8] βœ… {NODE_1} purged the large file.") + +# ══════════════════════════════════════════════════════════════════════════════ +# GIGABYTE FILE TEST (1000 MB) +# ══════════════════════════════════════════════════════════════════════════════ + +class TestGigabyteFileSync: + """Tests synchronizing a 1GB file across the mesh via DD CLI tool.""" + + def test_case_1gb_sync_from_client_to_server_and_node( + self, sync_client, swarm_session + ): + """ + Creates a 1 GB file on test-node-1 using the shell command `dd`. + Verifies that it syncs to both the server mirror and test-node-2. + """ + filename = _unique("gigabyte") + workspace = swarm_session + + print(f"\\n[Case 1GB] Disabling memory limit checks and triggering 1GB creation on {NODE_1}...") + + # Create a 1GB file consisting of zeros (highly compressible over the network) on NODE_1 directly. + # This will trigger the Inotify watcher to push chunks back up to the Hub. + # We output to the active session workspace path on the node. + is_native = os.environ.get("SKIP_DOCKER_NODES") == "true" + sync_dir = f"/tmp/cortex-sync-{NODE_1}" if is_native else "/tmp/cortex-sync" + dd_command = f"dd if=/dev/zero of={sync_dir}/{workspace}/{filename} bs=1M count=1000" + + r_disp = sync_client.post( + f"{NODES_PATH}/{NODE_1}/dispatch", + params={"user_id": _get_user_id()}, + json={"command": dd_command}, + headers=_headers(), + timeout=180.0 + ) + assert r_disp.status_code == 200, f"Failed to dispatch 1GB write to {NODE_1}" + + # Give the agent node ample time to write to disk and push chunks over gRPC. + # Wait up to 180 seconds. + def _check_node2_ls(): + r = sync_client.get( + f"{NODES_PATH}/{NODE_2}/fs/ls", + params={"path": ".", "session_id": workspace}, + headers=_headers(), + timeout=30.0 + ) + if r.status_code != 200: + return False + for f in r.json().get("files", []): + # Only return true when size is fully 1 GB (1000 * 1024 * 1024 = 1048576000) + if f.get("name") == filename and f.get("size", 0) >= 1048576000: + return f + return False + + print(f"[Case 1GB] Polling {NODE_2} for the file...") + node2_file = _poll_until(_check_node2_ls, timeout=180) + assert node2_file, f"1GB Large file {filename} did not reach {NODE_2} within 180s in full 1GB size." + print(f"[Case 1GB] βœ… {NODE_2} verified 1GB file sync with correct size.") + + # Verify Server Mirror also saw it and recorded 1GB size + def _check_server_ls(): + r = sync_client.get( + f"{NODES_PATH}/{NODE_1}/fs/ls", + params={"path": ".", "session_id": workspace}, + headers=_headers(), + timeout=30.0 + ) + if r.status_code != 200: + return False + for f in r.json().get("files", []): + if f.get("name") == filename and f.get("size", 0) >= 1048576000: + return f + return False + + server_file = _check_server_ls() + assert server_file, f"1GB Large file {filename} did not appear with 1GB size on Server Mirror." + print(f"[Case 1GB] βœ… Hub mirror successfully verified 1GB file sync with correct size.") + + # Cleanup + _rm(sync_client, NODE_1, filename, workspace) + + +# ══════════════════════════════════════════════════════════════════════════════ +# SESSION AUTO-PURGE TEST +# ══════════════════════════════════════════════════════════════════════════════ + +class TestSessionAutoPurge: + """Verifies that deleting a session purges the physical file system mirrors completely.""" + + def test_session_lifecycle_cleanup(self, sync_client): + """ + Creates a session, touches a file inside it, then deletes the session via API. + Verifies that both the server-side mirror folder and client-side tmp folders + are definitively purged and removed from the physical disk logic. + """ + import subprocess + + print("\n[Case Purge] Starting session cleanup lifecycle test...") + # 1. Create a throwaway session + r = sync_client.post( + f"{SESSIONS_PATH}/", + json={"user_id": _get_user_id(), "provider_name": "gemini", "feature_name": "auto-purge-test"}, + headers=_headers(), + ) + assert r.status_code == 200 + session_id = r.json()["id"] + + # Attach nodes + r2 = sync_client.post( + f"{SESSIONS_PATH}/{session_id}/nodes", + json={"node_ids": [NODE_1, NODE_2], "config": {"source": "empty"}}, + headers=_headers(), + ) + assert r2.status_code == 200 + workspace_id = r2.json().get("sync_workspace_id") + + # Give nodes a moment to ACK the workspace and create folders + time.sleep(2.0) + + # 2. Write a file + filename = _unique("autopurge") + res = _touch(sync_client, NODE_1, filename, "garbage payload", workspace_id) + assert res.get("success"), "Failed to write setup file for auto-purge test" + + # 3. Verify it reached Node 2 (assumes the filesystem structures were physically booted) + node2_ok = _poll_until( + lambda: _cat(sync_client, NODE_2, filename, workspace_id), + timeout=SMALL_FILE_TIMEOUT, + ) + assert node2_ok is not None, "Auto-purge setup file did not sync correctly to node 2" + print("[Case Purge] βœ… Session folders dynamically booted across the mesh") + + # 4. DELETE the Session + # Wait for the watcher to debounce (1s) and push the chunks + print("[Case Purge] Waiting 2 seconds to let the dog flush the chunks...") + time.sleep(2.0) + + print("[Case Purge] Calling API DELETE on the session...") + r_del = sync_client.delete(f"{SESSIONS_PATH}/{session_id}", headers=_headers()) + assert r_del.status_code == 200 + + # Wait a bit for PURGE propagation + print("[Case Purge] Waiting 3 seconds for propagation...") + time.sleep(3.0) + + # 5. Check client-side folders are purged using DISPATCH to run "ls" + is_native = os.environ.get("SKIP_DOCKER_NODES") == "true" + n1_dir = f"/tmp/cortex-sync-{NODE_1}" if is_native else "/tmp/cortex-sync" + # Node 1 + r_d1 = sync_client.post( + f"{NODES_PATH}/{NODE_1}/dispatch", + params={"user_id": _get_user_id()}, + json={"command": f"stat {n1_dir}/{workspace_id}"}, + headers=_headers() + ) + assert "No such file or directory" in r_d1.json().get("stderr", "") or r_d1.json().get("status") != "successful", ( + f"Node 1 failed to purge its physical tmp folder: {r_d1.text}" + ) + + n2_dir = f"/tmp/cortex-sync-{NODE_2}" if is_native else "/tmp/cortex-sync" + # Node 2 + r_d2 = sync_client.post( + f"{NODES_PATH}/{NODE_2}/dispatch", + params={"user_id": _get_user_id()}, + json={"command": f"stat {n2_dir}/{workspace_id}"}, + headers=_headers() + ) + assert "No such file or directory" in r_d2.json().get("stderr", "") or r_d2.json().get("status") != "successful", ( + f"Node 2 failed to purge its physical tmp folder: {r_d2.text}" + ) + print("[Case Purge] βœ… Physical client-side (`/tmp/cortex-sync/...`) folders proactively erased on all nodes") + + # 6. Check server-side folder + if is_native: + mirror_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "ai-hub/data/mirrors", workspace_id) + assert not os.path.exists(mirror_path), f"Server mirror folder still physically exists! stat matched: {mirror_path}" + else: + # (Since the test runner is executed on host but ai_hub is Docker container, we can use docker exec) + cmd = ["docker", "exec", "ai_hub_service", "stat", f"/app/data/mirrors/{workspace_id}"] + # This should fail if it doesn't exist. + res_hub = subprocess.run(cmd, capture_output=True, text=True) + assert res_hub.returncode != 0, f"Server mirror folder still physically exists! stat matched: {res_hub.stdout}" + assert "No such file or directory" in res_hub.stderr, f"Unexpected error during server stat: {res_hub.stderr}" + + print("[Case Purge] βœ… Server-side physical mirror folder proactively erased") +