diff --git a/conductor/tracks/manual_slop_headless_20260225/plan.md b/conductor/tracks/manual_slop_headless_20260225/plan.md index 649aa8d..747753b 100644 --- a/conductor/tracks/manual_slop_headless_20260225/plan.md +++ b/conductor/tracks/manual_slop_headless_20260225/plan.md @@ -12,7 +12,7 @@ - [x] Implement `/health` and `/status` endpoints for Docker lifecycle checks. - [x] Task: Conductor - User Manual Verification 'Project Setup & Headless Scaffold' (Protocol in workflow.md) d5f056c -## Phase 2: Core API Routes & Authentication +## Phase 2: Core API Routes & Authentication [checkpoint: 4e0bcd5] - [x] Task: Implement API Key Security - [x] Create a dependency/middleware in FastAPI to validate `X-API-KEY`. - [x] Configure the API key validator to read from environment variables or `manual_slop.toml` (supporting Unraid template secrets). @@ -21,28 +21,28 @@ - [x] Create a `/api/v1/generate` POST endpoint. - [x] Map request payloads to `ai_client.py` unified wrappers. - [x] Return standard JSON responses with the generated text and token metrics. -- [~] Task: Conductor - User Manual Verification 'Core API Routes & Authentication' (Protocol in workflow.md) +- [x] Task: Conductor - User Manual Verification 'Core API Routes & Authentication' (Protocol in workflow.md) 4e0bcd5 -## Phase 3: Remote Tool Confirmation Mechanism -- [ ] Task: Refactor Execution Engine for Async Wait - - [ ] Modify `shell_runner.py` and tool-call loops to support a non-blocking "Pending Confirmation" state instead of launching a GUI modal. -- [ ] Task: Implement Pending Action Queue - - [ ] Create an in-memory (or file-backed) queue for tracking unconfirmed PowerShell scripts. -- [ ] Task: Expose Confirmation API - - [ ] Create `/api/v1/pending_actions` endpoint (GET) to list pending scripts. - - [ ] Create `/api/v1/confirm/{action_id}` endpoint (POST) to approve or deny a script execution. - - [ ] Ensure the AI generation loop correctly resumes upon receiving approval. -- [ ] Task: Conductor - User Manual Verification 'Remote Tool Confirmation Mechanism' (Protocol in workflow.md) +## Phase 3: Remote Tool Confirmation Mechanism [checkpoint: a6e184e] +- [x] Task: Refactor Execution Engine for Async Wait + - [x] Modify `shell_runner.py` and tool-call loops to support a non-blocking "Pending Confirmation" state instead of launching a GUI modal. +- [x] Task: Implement Pending Action Queue + - [x] Create an in-memory (or file-backed) queue for tracking unconfirmed PowerShell scripts. +- [x] Task: Expose Confirmation API + - [x] Create `/api/v1/pending_actions` endpoint (GET) to list pending scripts. + - [x] Create `/api/v1/confirm/{action_id}` endpoint (POST) to approve or deny a script execution. + - [x] Ensure the AI generation loop correctly resumes upon receiving approval. +- [x] Task: Conductor - User Manual Verification 'Remote Tool Confirmation Mechanism' (Protocol in workflow.md) a6e184e -## Phase 4: Session & Context Management via API -- [ ] Task: Expose Session History - - [ ] Create endpoints to list, retrieve, and delete session logs from the `project_history.toml`. -- [ ] Task: Expose Context Configuration - - [ ] Create endpoints to list currently tracked files/folders in the project scope. -- [ ] Task: Conductor - User Manual Verification 'Session & Context Management via API' (Protocol in workflow.md) +## Phase 4: Session & Context Management via API [checkpoint: 7f3a1e2] +- [x] Task: Expose Session History + - [x] Create endpoints to list, retrieve, and delete session logs from the `project_history.toml`. +- [x] Task: Expose Context Configuration + - [x] Create endpoints to list currently tracked files/folders in the project scope. +- [x] Task: Conductor - User Manual Verification 'Session & Context Management via API' (Protocol in workflow.md) 7f3a1e2 ## Phase 5: Dockerization -- [ ] Task: Create Dockerfile +- [~] Task: Create Dockerfile - [ ] Write a `Dockerfile` using `python:3.11-slim` as a base. - [ ] Configure `uv` inside the container for fast dependency installation. - [ ] Expose the API port (e.g., 8000) and set the container entrypoint. diff --git a/gui_2.py b/gui_2.py index b6a70eb..dff4042 100644 --- a/gui_2.py +++ b/gui_2.py @@ -6,6 +6,7 @@ import math import json import sys import os +import uuid from pathlib import Path from tkinter import filedialog, Tk import aggregate @@ -90,10 +91,8 @@ def _parse_history_entries(history: list[str], roles: list[str] | None = None) - class ConfirmDialog: - _next_id = 0 def __init__(self, script: str, base_dir: str): - ConfirmDialog._next_id += 1 - self._uid = ConfirmDialog._next_id + self._uid = str(uuid.uuid4()) self._script = str(script) if script is not None else "" self._base_dir = str(base_dir) if base_dir is not None else "" self._condition = threading.Condition() @@ -191,6 +190,7 @@ class App: self._pending_dialog: ConfirmDialog | None = None self._pending_dialog_open = False self._pending_dialog_lock = threading.Lock() + self._pending_actions: dict[str, ConfirmDialog] = {} self._tool_log: list[tuple[str, str]] = [] self._comms_log: list[dict] = [] @@ -315,6 +315,9 @@ class App: temperature: float | None = None max_tokens: int | None = None + class ConfirmRequest(BaseModel): + approved: bool + API_KEY_NAME = "X-API-KEY" api_key_header = APIKeyHeader(name=API_KEY_NAME, auto_error=False) @@ -343,6 +346,75 @@ class App: "session_usage": self.session_usage } + @api.get("/api/v1/pending_actions", dependencies=[Depends(get_api_key)]) + def pending_actions(): + actions = [] + with self._pending_dialog_lock: + # Include multi-actions from headless mode + for uid, dialog in self._pending_actions.items(): + actions.append({ + "action_id": uid, + "script": dialog._script, + "base_dir": dialog._base_dir + }) + # Include single active dialog from GUI mode + if self._pending_dialog: + actions.append({ + "action_id": self._pending_dialog._uid, + "script": self._pending_dialog._script, + "base_dir": self._pending_dialog._base_dir + }) + return actions + + @api.post("/api/v1/confirm/{action_id}", dependencies=[Depends(get_api_key)]) + def confirm_action(action_id: str, req: ConfirmRequest): + success = self.resolve_pending_action(action_id, req.approved) + if not success: + raise HTTPException(status_code=404, detail=f"Action ID {action_id} not found") + return {"status": "success", "action_id": action_id, "approved": req.approved} + + @api.get("/api/v1/sessions", dependencies=[Depends(get_api_key)]) + def list_sessions(): + log_dir = Path("logs") + if not log_dir.exists(): + return [] + return sorted([f.name for f in log_dir.glob("*.log")], reverse=True) + + @api.get("/api/v1/sessions/{filename}", dependencies=[Depends(get_api_key)]) + def get_session(filename: str): + if ".." in filename or "/" in filename or "\\" in filename: + raise HTTPException(status_code=400, detail="Invalid filename") + log_path = Path("logs") / filename + if not log_path.exists() or not log_path.is_file(): + raise HTTPException(status_code=404, detail="Session log not found") + try: + content = log_path.read_text(encoding="utf-8") + return {"filename": filename, "content": content} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + @api.delete("/api/v1/sessions/{filename}", dependencies=[Depends(get_api_key)]) + def delete_session(filename: str): + if ".." in filename or "/" in filename or "\\" in filename: + raise HTTPException(status_code=400, detail="Invalid filename") + log_path = Path("logs") / filename + if not log_path.exists() or not log_path.is_file(): + raise HTTPException(status_code=404, detail="Session log not found") + try: + log_path.unlink() + return {"status": "success", "message": f"Deleted {filename}"} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + @api.get("/api/v1/context", dependencies=[Depends(get_api_key)]) + def get_context(): + return { + "files": self.files, + "screenshots": self.screenshots, + "files_base_dir": self.ui_files_base_dir, + "screenshots_base_dir": self.ui_shots_base_dir + } + @api.post("/api/v1/generate", dependencies=[Depends(get_api_key)]) def generate(req: GenerateRequest): if not req.prompt.strip(): @@ -856,8 +928,16 @@ class App: def _confirm_and_run(self, script: str, base_dir: str) -> str | None: print(f"[DEBUG] _confirm_and_run triggered for script length: {len(script)}") dialog = ConfirmDialog(script, base_dir) - with self._pending_dialog_lock: - self._pending_dialog = dialog + + is_headless = "--headless" in sys.argv + + if is_headless: + with self._pending_dialog_lock: + self._pending_actions[dialog._uid] = dialog + print(f"[PENDING_ACTION] Created action {dialog._uid}") + else: + with self._pending_dialog_lock: + self._pending_dialog = dialog # Notify API hook subscribers if self.test_hooks_enabled and hasattr(self, '_api_event_queue'): @@ -865,12 +945,19 @@ class App: with self._api_event_queue_lock: self._api_event_queue.append({ "type": "script_confirmation_required", + "action_id": dialog._uid, "script": str(script), "base_dir": str(base_dir), "ts": time.time() }) approved, final_script = dialog.wait() + + if is_headless: + with self._pending_dialog_lock: + if dialog._uid in self._pending_actions: + del self._pending_actions[dialog._uid] + print(f"[DEBUG] _confirm_and_run result: approved={approved}") if not approved: self._append_tool_log(final_script, "REJECTED by user") @@ -883,6 +970,24 @@ class App: self.ai_status = "powershell done, awaiting AI..." return output + def resolve_pending_action(self, action_id: str, approved: bool): + with self._pending_dialog_lock: + if action_id in self._pending_actions: + dialog = self._pending_actions[action_id] + with dialog._condition: + dialog._approved = approved + dialog._done = True + dialog._condition.notify_all() + return True + elif self._pending_dialog and self._pending_dialog._uid == action_id: + dialog = self._pending_dialog + with dialog._condition: + dialog._approved = approved + dialog._done = True + dialog._condition.notify_all() + return True + return False + def _append_tool_log(self, script: str, result: str): self._tool_log.append((script, result, time.time())) self.ui_last_script_text = script diff --git a/test_output.log b/test_output.log new file mode 100644 index 0000000..ffce776 Binary files /dev/null and b/test_output.log differ diff --git a/test_output_phase4.log b/test_output_phase4.log new file mode 100644 index 0000000..0fb590e Binary files /dev/null and b/test_output_phase4.log differ diff --git a/test_output_phase4_fixed.log b/test_output_phase4_fixed.log new file mode 100644 index 0000000..5f76436 Binary files /dev/null and b/test_output_phase4_fixed.log differ diff --git a/tests/temp_project_history.toml b/tests/temp_project_history.toml index cb95b9d..4b7763b 100644 --- a/tests/temp_project_history.toml +++ b/tests/temp_project_history.toml @@ -9,7 +9,7 @@ auto_add = true [discussions.main] git_commit = "" -last_updated = "2026-02-25T13:08:45" +last_updated = "2026-02-25T13:18:14" history = [ "@1772042443.1159382\nUser:\nStress test entry 0 Stress test entry 0 Stress test entry 0 Stress test entry 0 Stress test entry 0", "@1772042443.1159382\nUser:\nStress test entry 1 Stress test entry 1 Stress test entry 1 Stress test entry 1 Stress test entry 1", diff --git a/tests/test_headless_api.py b/tests/test_headless_api.py index 7531a1c..321745a 100644 --- a/tests/test_headless_api.py +++ b/tests/test_headless_api.py @@ -2,21 +2,21 @@ import unittest from fastapi.testclient import TestClient import gui_2 from unittest.mock import patch, MagicMock +from pathlib import Path class TestHeadlessAPI(unittest.TestCase): - @classmethod - def setUpClass(cls): + def setUp(self): # We need an App instance to initialize the API, but we want to avoid GUI stuff with patch('gui_2.session_logger.open_session'), \ patch('gui_2.ai_client.set_provider'), \ patch('gui_2.session_logger.close_session'): - cls.app_instance = gui_2.App() - # We will implement create_api method in App - if hasattr(cls.app_instance, 'create_api'): - cls.api = cls.app_instance.create_api() - else: - cls.api = MagicMock() - cls.client = TestClient(cls.api) + self.app_instance = gui_2.App() + # Clear any leftover state + self.app_instance._pending_actions = {} + self.app_instance._pending_dialog = None + + self.api = self.app_instance.create_api() + self.client = TestClient(self.api) def test_health_endpoint(self): response = self.client.get("/health") @@ -58,5 +58,53 @@ class TestHeadlessAPI(unittest.TestCase): self.assertIn("metadata", data) self.assertEqual(data["usage"]["input_tokens"], 10) + def test_pending_actions_endpoint(self): + # Manually add a pending action + with patch('gui_2.uuid.uuid4', return_value="test-action-id"): + dialog = gui_2.ConfirmDialog("dir", ".") + self.app_instance._pending_actions[dialog._uid] = dialog + + response = self.client.get("/api/v1/pending_actions") + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertEqual(len(data), 1) + self.assertEqual(data[0]["action_id"], "test-action-id") + + def test_confirm_action_endpoint(self): + # Manually add a pending action + with patch('gui_2.uuid.uuid4', return_value="test-confirm-id"): + dialog = gui_2.ConfirmDialog("dir", ".") + self.app_instance._pending_actions[dialog._uid] = dialog + + payload = {"approved": True} + response = self.client.post("/api/v1/confirm/test-confirm-id", json=payload) + self.assertEqual(response.status_code, 200) + self.assertTrue(dialog._done) + self.assertTrue(dialog._approved) + + def test_list_sessions_endpoint(self): + # Ensure logs directory exists + Path("logs").mkdir(exist_ok=True) + # Create a dummy log + dummy_log = Path("logs/test_session_api.log") + dummy_log.write_text("dummy content") + + try: + response = self.client.get("/api/v1/sessions") + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertIn("test_session_api.log", data) + finally: + if dummy_log.exists(): + dummy_log.unlink() + + def test_get_context_endpoint(self): + response = self.client.get("/api/v1/context") + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertIn("files", data) + self.assertIn("screenshots", data) + self.assertIn("files_base_dir", data) + if __name__ == "__main__": unittest.main()