feat(headless): Implement Phase 4 - Session & Context Management via API
This commit is contained in:
@@ -12,7 +12,7 @@
|
|||||||
- [x] Implement `/health` and `/status` endpoints for Docker lifecycle checks.
|
- [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
|
- [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] Task: Implement API Key Security
|
||||||
- [x] Create a dependency/middleware in FastAPI to validate `X-API-KEY`.
|
- [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).
|
- [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] Create a `/api/v1/generate` POST endpoint.
|
||||||
- [x] Map request payloads to `ai_client.py` unified wrappers.
|
- [x] Map request payloads to `ai_client.py` unified wrappers.
|
||||||
- [x] Return standard JSON responses with the generated text and token metrics.
|
- [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
|
## Phase 3: Remote Tool Confirmation Mechanism [checkpoint: a6e184e]
|
||||||
- [ ] Task: Refactor Execution Engine for Async Wait
|
- [x] 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.
|
- [x] 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
|
- [x] Task: Implement Pending Action Queue
|
||||||
- [ ] Create an in-memory (or file-backed) queue for tracking unconfirmed PowerShell scripts.
|
- [x] Create an in-memory (or file-backed) queue for tracking unconfirmed PowerShell scripts.
|
||||||
- [ ] Task: Expose Confirmation API
|
- [x] Task: Expose Confirmation API
|
||||||
- [ ] Create `/api/v1/pending_actions` endpoint (GET) to list pending scripts.
|
- [x] 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.
|
- [x] 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.
|
- [x] Ensure the AI generation loop correctly resumes upon receiving approval.
|
||||||
- [ ] Task: Conductor - User Manual Verification 'Remote Tool Confirmation Mechanism' (Protocol in workflow.md)
|
- [x] Task: Conductor - User Manual Verification 'Remote Tool Confirmation Mechanism' (Protocol in workflow.md) a6e184e
|
||||||
|
|
||||||
## Phase 4: Session & Context Management via API
|
## Phase 4: Session & Context Management via API [checkpoint: 7f3a1e2]
|
||||||
- [ ] Task: Expose Session History
|
- [x] Task: Expose Session History
|
||||||
- [ ] Create endpoints to list, retrieve, and delete session logs from the `project_history.toml`.
|
- [x] Create endpoints to list, retrieve, and delete session logs from the `project_history.toml`.
|
||||||
- [ ] Task: Expose Context Configuration
|
- [x] Task: Expose Context Configuration
|
||||||
- [ ] Create endpoints to list currently tracked files/folders in the project scope.
|
- [x] 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)
|
- [x] Task: Conductor - User Manual Verification 'Session & Context Management via API' (Protocol in workflow.md) 7f3a1e2
|
||||||
|
|
||||||
## Phase 5: Dockerization
|
## Phase 5: Dockerization
|
||||||
- [ ] Task: Create Dockerfile
|
- [~] Task: Create Dockerfile
|
||||||
- [ ] Write a `Dockerfile` using `python:3.11-slim` as a base.
|
- [ ] Write a `Dockerfile` using `python:3.11-slim` as a base.
|
||||||
- [ ] Configure `uv` inside the container for fast dependency installation.
|
- [ ] Configure `uv` inside the container for fast dependency installation.
|
||||||
- [ ] Expose the API port (e.g., 8000) and set the container entrypoint.
|
- [ ] Expose the API port (e.g., 8000) and set the container entrypoint.
|
||||||
|
|||||||
115
gui_2.py
115
gui_2.py
@@ -6,6 +6,7 @@ import math
|
|||||||
import json
|
import json
|
||||||
import sys
|
import sys
|
||||||
import os
|
import os
|
||||||
|
import uuid
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from tkinter import filedialog, Tk
|
from tkinter import filedialog, Tk
|
||||||
import aggregate
|
import aggregate
|
||||||
@@ -90,10 +91,8 @@ def _parse_history_entries(history: list[str], roles: list[str] | None = None) -
|
|||||||
|
|
||||||
|
|
||||||
class ConfirmDialog:
|
class ConfirmDialog:
|
||||||
_next_id = 0
|
|
||||||
def __init__(self, script: str, base_dir: str):
|
def __init__(self, script: str, base_dir: str):
|
||||||
ConfirmDialog._next_id += 1
|
self._uid = str(uuid.uuid4())
|
||||||
self._uid = ConfirmDialog._next_id
|
|
||||||
self._script = str(script) if script is not None else ""
|
self._script = str(script) if script is not None else ""
|
||||||
self._base_dir = str(base_dir) if base_dir is not None else ""
|
self._base_dir = str(base_dir) if base_dir is not None else ""
|
||||||
self._condition = threading.Condition()
|
self._condition = threading.Condition()
|
||||||
@@ -191,6 +190,7 @@ class App:
|
|||||||
self._pending_dialog: ConfirmDialog | None = None
|
self._pending_dialog: ConfirmDialog | None = None
|
||||||
self._pending_dialog_open = False
|
self._pending_dialog_open = False
|
||||||
self._pending_dialog_lock = threading.Lock()
|
self._pending_dialog_lock = threading.Lock()
|
||||||
|
self._pending_actions: dict[str, ConfirmDialog] = {}
|
||||||
|
|
||||||
self._tool_log: list[tuple[str, str]] = []
|
self._tool_log: list[tuple[str, str]] = []
|
||||||
self._comms_log: list[dict] = []
|
self._comms_log: list[dict] = []
|
||||||
@@ -315,6 +315,9 @@ class App:
|
|||||||
temperature: float | None = None
|
temperature: float | None = None
|
||||||
max_tokens: int | None = None
|
max_tokens: int | None = None
|
||||||
|
|
||||||
|
class ConfirmRequest(BaseModel):
|
||||||
|
approved: bool
|
||||||
|
|
||||||
API_KEY_NAME = "X-API-KEY"
|
API_KEY_NAME = "X-API-KEY"
|
||||||
api_key_header = APIKeyHeader(name=API_KEY_NAME, auto_error=False)
|
api_key_header = APIKeyHeader(name=API_KEY_NAME, auto_error=False)
|
||||||
|
|
||||||
@@ -343,6 +346,75 @@ class App:
|
|||||||
"session_usage": self.session_usage
|
"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)])
|
@api.post("/api/v1/generate", dependencies=[Depends(get_api_key)])
|
||||||
def generate(req: GenerateRequest):
|
def generate(req: GenerateRequest):
|
||||||
if not req.prompt.strip():
|
if not req.prompt.strip():
|
||||||
@@ -856,8 +928,16 @@ class App:
|
|||||||
def _confirm_and_run(self, script: str, base_dir: str) -> str | None:
|
def _confirm_and_run(self, script: str, base_dir: str) -> str | None:
|
||||||
print(f"[DEBUG] _confirm_and_run triggered for script length: {len(script)}")
|
print(f"[DEBUG] _confirm_and_run triggered for script length: {len(script)}")
|
||||||
dialog = ConfirmDialog(script, base_dir)
|
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
|
# Notify API hook subscribers
|
||||||
if self.test_hooks_enabled and hasattr(self, '_api_event_queue'):
|
if self.test_hooks_enabled and hasattr(self, '_api_event_queue'):
|
||||||
@@ -865,12 +945,19 @@ class App:
|
|||||||
with self._api_event_queue_lock:
|
with self._api_event_queue_lock:
|
||||||
self._api_event_queue.append({
|
self._api_event_queue.append({
|
||||||
"type": "script_confirmation_required",
|
"type": "script_confirmation_required",
|
||||||
|
"action_id": dialog._uid,
|
||||||
"script": str(script),
|
"script": str(script),
|
||||||
"base_dir": str(base_dir),
|
"base_dir": str(base_dir),
|
||||||
"ts": time.time()
|
"ts": time.time()
|
||||||
})
|
})
|
||||||
|
|
||||||
approved, final_script = dialog.wait()
|
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}")
|
print(f"[DEBUG] _confirm_and_run result: approved={approved}")
|
||||||
if not approved:
|
if not approved:
|
||||||
self._append_tool_log(final_script, "REJECTED by user")
|
self._append_tool_log(final_script, "REJECTED by user")
|
||||||
@@ -883,6 +970,24 @@ class App:
|
|||||||
self.ai_status = "powershell done, awaiting AI..."
|
self.ai_status = "powershell done, awaiting AI..."
|
||||||
return output
|
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):
|
def _append_tool_log(self, script: str, result: str):
|
||||||
self._tool_log.append((script, result, time.time()))
|
self._tool_log.append((script, result, time.time()))
|
||||||
self.ui_last_script_text = script
|
self.ui_last_script_text = script
|
||||||
|
|||||||
BIN
test_output.log
Normal file
BIN
test_output.log
Normal file
Binary file not shown.
BIN
test_output_phase4.log
Normal file
BIN
test_output_phase4.log
Normal file
Binary file not shown.
BIN
test_output_phase4_fixed.log
Normal file
BIN
test_output_phase4_fixed.log
Normal file
Binary file not shown.
@@ -9,7 +9,7 @@ auto_add = true
|
|||||||
|
|
||||||
[discussions.main]
|
[discussions.main]
|
||||||
git_commit = ""
|
git_commit = ""
|
||||||
last_updated = "2026-02-25T13:08:45"
|
last_updated = "2026-02-25T13:18:14"
|
||||||
history = [
|
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 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",
|
"@1772042443.1159382\nUser:\nStress test entry 1 Stress test entry 1 Stress test entry 1 Stress test entry 1 Stress test entry 1",
|
||||||
|
|||||||
@@ -2,21 +2,21 @@ import unittest
|
|||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
import gui_2
|
import gui_2
|
||||||
from unittest.mock import patch, MagicMock
|
from unittest.mock import patch, MagicMock
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
class TestHeadlessAPI(unittest.TestCase):
|
class TestHeadlessAPI(unittest.TestCase):
|
||||||
@classmethod
|
def setUp(self):
|
||||||
def setUpClass(cls):
|
|
||||||
# We need an App instance to initialize the API, but we want to avoid GUI stuff
|
# We need an App instance to initialize the API, but we want to avoid GUI stuff
|
||||||
with patch('gui_2.session_logger.open_session'), \
|
with patch('gui_2.session_logger.open_session'), \
|
||||||
patch('gui_2.ai_client.set_provider'), \
|
patch('gui_2.ai_client.set_provider'), \
|
||||||
patch('gui_2.session_logger.close_session'):
|
patch('gui_2.session_logger.close_session'):
|
||||||
cls.app_instance = gui_2.App()
|
self.app_instance = gui_2.App()
|
||||||
# We will implement create_api method in App
|
# Clear any leftover state
|
||||||
if hasattr(cls.app_instance, 'create_api'):
|
self.app_instance._pending_actions = {}
|
||||||
cls.api = cls.app_instance.create_api()
|
self.app_instance._pending_dialog = None
|
||||||
else:
|
|
||||||
cls.api = MagicMock()
|
self.api = self.app_instance.create_api()
|
||||||
cls.client = TestClient(cls.api)
|
self.client = TestClient(self.api)
|
||||||
|
|
||||||
def test_health_endpoint(self):
|
def test_health_endpoint(self):
|
||||||
response = self.client.get("/health")
|
response = self.client.get("/health")
|
||||||
@@ -58,5 +58,53 @@ class TestHeadlessAPI(unittest.TestCase):
|
|||||||
self.assertIn("metadata", data)
|
self.assertIn("metadata", data)
|
||||||
self.assertEqual(data["usage"]["input_tokens"], 10)
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
Reference in New Issue
Block a user