-
Notifications
You must be signed in to change notification settings - Fork 2
feat: add scheduled sessions, export, workflow, and repo management tools #42
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -622,6 +622,163 @@ async def update_session( | |
| except ValueError as e: | ||
| return {"updated": False, "message": f"Failed to update session: {str(e)}"} | ||
|
|
||
| # ── Scheduled Sessions ────────────────────────────────────────────── | ||
|
|
||
| async def list_scheduled_sessions(self, project: str) -> dict[str, Any]: | ||
| """List all scheduled sessions.""" | ||
| self._validate_input(project, "project") | ||
| response = await self._request("GET", "/v1/scheduled-sessions", project) | ||
| items = response.get("items", []) | ||
| return {"scheduled_sessions": items, "total": len(items)} | ||
|
|
||
| async def get_scheduled_session(self, project: str, name: str) -> dict[str, Any]: | ||
| """Get a specific scheduled session by name.""" | ||
| self._validate_input(project, "project") | ||
| self._validate_input(name, "name") | ||
| return await self._request("GET", f"/v1/scheduled-sessions/{name}", project) | ||
|
|
||
| async def create_scheduled_session( | ||
| self, | ||
| project: str, | ||
| schedule: str, | ||
| session_template: dict[str, Any], | ||
| display_name: str | None = None, | ||
| suspend: bool = False, | ||
| dry_run: bool = False, | ||
| ) -> dict[str, Any]: | ||
| """Create a scheduled session backed by a Kubernetes CronJob.""" | ||
| self._validate_input(project, "project") | ||
|
|
||
| payload: dict[str, Any] = { | ||
| "schedule": schedule, | ||
| "sessionTemplate": session_template, | ||
| "suspend": suspend, | ||
| } | ||
| if display_name: | ||
| payload["displayName"] = display_name | ||
|
|
||
| if dry_run: | ||
| return { | ||
| "dry_run": True, | ||
| "success": True, | ||
| "message": f"Would create scheduled session with schedule '{schedule}'", | ||
| "manifest": payload, | ||
| "project": project, | ||
| } | ||
|
|
||
| try: | ||
| result = await self._request("POST", "/v1/scheduled-sessions", project, json_data=payload) | ||
| name = result.get("name", "unknown") | ||
| return { | ||
| "created": True, | ||
| "name": name, | ||
| "project": project, | ||
| "message": f"Scheduled session '{name}' created with schedule '{schedule}'", | ||
| } | ||
| except (ValueError, TimeoutError) as e: | ||
| return {"created": False, "message": str(e)} | ||
|
|
||
| async def update_scheduled_session( | ||
| self, | ||
| project: str, | ||
| name: str, | ||
| schedule: str | None = None, | ||
| display_name: str | None = None, | ||
| session_template: dict[str, Any] | None = None, | ||
| suspend: bool | None = None, | ||
| dry_run: bool = False, | ||
| ) -> dict[str, Any]: | ||
| """Update a scheduled session (partial update).""" | ||
| self._validate_input(project, "project") | ||
| self._validate_input(name, "name") | ||
|
|
||
| payload: dict[str, Any] = {} | ||
| if schedule is not None: | ||
| payload["schedule"] = schedule | ||
| if display_name is not None: | ||
| payload["displayName"] = display_name | ||
| if session_template is not None: | ||
| payload["sessionTemplate"] = session_template | ||
| if suspend is not None: | ||
| payload["suspend"] = suspend | ||
|
|
||
| if not payload: | ||
| raise ValueError("No fields to update. Provide schedule, display_name, session_template, or suspend.") | ||
|
|
||
| if dry_run: | ||
| return { | ||
| "dry_run": True, | ||
| "success": True, | ||
| "message": f"Would update scheduled session '{name}'", | ||
| "patch": payload, | ||
| } | ||
|
|
||
| try: | ||
| await self._request("PUT", f"/v1/scheduled-sessions/{name}", project, json_data=payload) | ||
| return {"updated": True, "message": f"Successfully updated scheduled session '{name}'"} | ||
| except ValueError as e: | ||
| return {"updated": False, "message": f"Failed to update: {str(e)}"} | ||
|
|
||
| async def delete_scheduled_session(self, project: str, name: str, dry_run: bool = False) -> dict[str, Any]: | ||
| """Delete a scheduled session.""" | ||
| self._validate_input(project, "project") | ||
| self._validate_input(name, "name") | ||
|
|
||
| if dry_run: | ||
| try: | ||
| data = await self._request("GET", f"/v1/scheduled-sessions/{name}", project) | ||
| return { | ||
| "dry_run": True, | ||
| "success": True, | ||
| "message": f"Would delete scheduled session '{name}'", | ||
| "session_info": { | ||
| "name": data.get("name"), | ||
| "schedule": data.get("schedule"), | ||
| "suspend": data.get("suspend"), | ||
| }, | ||
| } | ||
| except ValueError: | ||
| return {"dry_run": True, "success": False, "message": f"Scheduled session '{name}' not found"} | ||
|
|
||
| try: | ||
| await self._request("DELETE", f"/v1/scheduled-sessions/{name}", project) | ||
| return {"deleted": True, "message": f"Successfully deleted scheduled session '{name}'"} | ||
| except ValueError as e: | ||
| return {"deleted": False, "message": f"Failed to delete: {str(e)}"} | ||
|
|
||
| async def suspend_scheduled_session(self, project: str, name: str) -> dict[str, Any]: | ||
| """Suspend (pause) a scheduled session.""" | ||
| self._validate_input(project, "project") | ||
| self._validate_input(name, "name") | ||
|
|
||
| await self._request("POST", f"/v1/scheduled-sessions/{name}/suspend", project) | ||
| return {"suspended": True, "message": f"Scheduled session '{name}' suspended"} | ||
|
|
||
| async def resume_scheduled_session(self, project: str, name: str) -> dict[str, Any]: | ||
| """Resume a suspended scheduled session.""" | ||
| self._validate_input(project, "project") | ||
| self._validate_input(name, "name") | ||
|
|
||
| await self._request("POST", f"/v1/scheduled-sessions/{name}/resume", project) | ||
| return {"resumed": True, "message": f"Scheduled session '{name}' resumed"} | ||
|
|
||
| async def trigger_scheduled_session(self, project: str, name: str) -> dict[str, Any]: | ||
| """Manually trigger a scheduled session to run immediately.""" | ||
| self._validate_input(project, "project") | ||
| self._validate_input(name, "name") | ||
|
|
||
| await self._request("POST", f"/v1/scheduled-sessions/{name}/trigger", project) | ||
| return {"triggered": True, "message": f"Scheduled session '{name}' triggered"} | ||
|
|
||
| async def list_scheduled_session_runs(self, project: str, name: str) -> dict[str, Any]: | ||
| """List past runs (AgenticSessions) created by a scheduled session.""" | ||
| self._validate_input(project, "project") | ||
| self._validate_input(name, "name") | ||
|
|
||
| response = await self._request("GET", f"/v1/scheduled-sessions/{name}/runs", project) | ||
| items = response.get("items", []) | ||
| return {"runs": items, "total": len(items), "scheduled_session": name} | ||
|
|
||
| # ── Observability ──────────────────────────────────────────────────── | ||
|
|
||
| async def get_session_logs( | ||
|
|
@@ -676,6 +833,14 @@ async def get_session_metrics(self, project: str, session: str) -> dict[str, Any | |
| result["session"] = session | ||
| return result | ||
|
|
||
| async def export_session(self, project: str, session: str) -> dict[str, Any]: | ||
| """Export session chat as markdown.""" | ||
| self._validate_input(project, "project") | ||
| self._validate_input(session, "session") | ||
|
|
||
| text = await self._request_text("GET", f"/v1/sessions/{session}/export", project) | ||
| return {"export": text, "session": session} | ||
|
Comment on lines
+836
to
+842
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: # First, let's look at the _request_text() method implementation
cd src/mcp_acp && grep -n "_request_text" client.py | head -20Repository: ambient-code/mcp Length of output: 297 🏁 Script executed: # Search for _request_text method definition
rg -n "def _request_text" src/mcp_acp/client.py -A 15Repository: ambient-code/mcp Length of output: 661 🏁 Script executed: # Check for any Accept header specifications in the codebase
rg -n "Accept" src/mcp_acp/ -B 2 -A 2Repository: ambient-code/mcp Length of output: 602 🏁 Script executed: # Look for export endpoint tests or documentation
fd -type f \( -name "*.py" -o -name "*.md" \) | xargs rg -l "export.*session\|export_session" 2>/dev/nullRepository: ambient-code/mcp Length of output: 232 🏁 Script executed: # Search for API specification files
fd -type f \( -name "*.json" -o -name "*.yaml" -o -name "*.yml" \) | head -20Repository: ambient-code/mcp Length of output: 230 🏁 Script executed: # Get the complete _request_text() method
sed -n '205,240p' src/mcp_acp/client.pyRepository: ambient-code/mcp Length of output: 1418 🏁 Script executed: # Look for test files related to export
fd -name "*test*.py" -o -name "test_*.py" | xargs rg -l "export_session\|export" 2>/dev/nullRepository: ambient-code/mcp Length of output: 292 🏁 Script executed: # Search for API specs or documentation
find . -name "*.md" -o -name "*.txt" -o -name "README*" | xargs rg -l "export\|markdown" 2>/dev/null | head -10Repository: ambient-code/mcp Length of output: 42 🏁 Script executed: # Check if there are any comments about markdown in the export context
rg -n "markdown\|export" src/mcp_acp/client.py -i -B 2 -A 2Repository: ambient-code/mcp Length of output: 42 🏁 Script executed: # Check if there are other methods that override Accept header
rg -n "Accept.*:" src/mcp_acp/ -B 3 -A 1Repository: ambient-code/mcp Length of output: 668 🏁 Script executed: # Look for backend or spec files
find . -type f -name "*.py" | xargs rg -l "export.*markdown\|markdown.*export" 2>/dev/nullRepository: ambient-code/mcp Length of output: 44 🏁 Script executed: # Check all usages of Accept header in the entire codebase
rg -r "Accept" . --type py 2>/dev/null | head -30Repository: ambient-code/mcp Length of output: 4475 🏁 Script executed: # Look for the export endpoint handler or documentation in comments
rg -n "export" src/mcp_acp/client.py -B 3 -A 3Repository: ambient-code/mcp Length of output: 618 🏁 Script executed: # Check if there's a different method that accepts custom headers
rg -n "def _request" src/mcp_acp/client.py -A 8Repository: ambient-code/mcp Length of output: 674 🏁 Script executed: # Look for any markdown-related Accept headers or content types
rg -i "markdown\|text/markdown\|application/vnd" src/mcp_acp/Repository: ambient-code/mcp Length of output: 42 🏁 Script executed: # Check if there are integration tests or docs that specify the expected format
find . -type f \( -name "*.md" -o -name "*.txt" \) -exec grep -l "export\|session" {} \;Repository: ambient-code/mcp Length of output: 175 🏁 Script executed: # Check API_REFERENCE.md for export endpoint specification
grep -n -i "export\|/v1/sessions" ./API_REFERENCE.md -A 5 -B 2Repository: ambient-code/mcp Length of output: 3533 🏁 Script executed: # Check README for export session information
grep -n -i "export\|markdown" ./README.md -A 3 -B 2Repository: ambient-code/mcp Length of output: 1178 🏁 Script executed: # Check CLAUDE.md for any context about export format
grep -n -i "export\|markdown" ./CLAUDE.md -A 3 -B 2Repository: ambient-code/mcp Length of output: 155 🏁 Script executed: # Look at the complete _request method to understand the pattern
sed -n '142,203p' src/mcp_acp/client.pyRepository: ambient-code/mcp Length of output: 2454 Ensure export endpoint explicitly requests markdown format. The
Verify against the backend export endpoint spec to confirm expected content type or query parameter convention. |
||
|
|
||
| # ── Labels ─────────────────────────────────────────────────────────── | ||
|
|
||
| async def label_session(self, project: str, session: str, labels: dict[str, str]) -> dict[str, Any]: | ||
|
|
@@ -874,6 +1039,53 @@ async def bulk_restart_sessions_by_label( | |
| """Restart sessions matching label selectors (max 3 matches).""" | ||
| return await self._run_bulk_by_label(project, labels, self.restart_session, "restart", "restarted", dry_run) | ||
|
|
||
| # ── Workflow Management ───────────────────────────────────────────── | ||
|
|
||
| async def set_workflow(self, project: str, session: str, workflow: str) -> dict[str, Any]: | ||
| """Set the active workflow on a session.""" | ||
| self._validate_input(project, "project") | ||
| self._validate_input(session, "session") | ||
|
|
||
| return await self._request( | ||
| "POST", f"/v1/sessions/{session}/workflow", project, json_data={"workflow": workflow} | ||
| ) | ||
|
|
||
| async def get_workflow_metadata(self, project: str, session: str) -> dict[str, Any]: | ||
| """Get workflow metadata for a session.""" | ||
| self._validate_input(project, "project") | ||
| self._validate_input(session, "session") | ||
|
|
||
| result = await self._request("GET", f"/v1/sessions/{session}/workflow/metadata", project) | ||
| result["session"] = session | ||
| return result | ||
|
|
||
| # ── Repo Management ───────────────────────────────────────────────── | ||
|
|
||
| async def add_repo(self, project: str, session: str, repo_url: str) -> dict[str, Any]: | ||
| """Add a repository to a running session.""" | ||
| self._validate_input(project, "project") | ||
| self._validate_input(session, "session") | ||
|
|
||
| return await self._request("POST", f"/v1/sessions/{session}/repos", project, json_data={"url": repo_url}) | ||
|
|
||
| async def remove_repo(self, project: str, session: str, repo_name: str) -> dict[str, Any]: | ||
| """Remove a repository from a session.""" | ||
| self._validate_input(project, "project") | ||
| self._validate_input(session, "session") | ||
| self._validate_input(repo_name, "repo_name") | ||
|
|
||
| await self._request("DELETE", f"/v1/sessions/{session}/repos/{repo_name}", project) | ||
| return {"removed": True, "message": f"Repo '{repo_name}' removed from session '{session}'"} | ||
|
Comment on lines
+1064
to
+1078
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Line 1075 reuses the DNS-1123 validator, but repository names derived from URLs are not guaranteed to fit that shape. Proposed fix+from urllib.parse import quote
+
async def remove_repo(self, project: str, session: str, repo_name: str) -> dict[str, Any]:
"""Remove a repository from a session."""
self._validate_input(project, "project")
self._validate_input(session, "session")
- self._validate_input(repo_name, "repo_name")
+ if not repo_name:
+ raise ValueError("repo_name must not be empty")
- await self._request("DELETE", f"/v1/sessions/{session}/repos/{repo_name}", project)
+ encoded_repo_name = quote(repo_name, safe="")
+ await self._request("DELETE", f"/v1/sessions/{session}/repos/{encoded_repo_name}", project)
return {"removed": True, "message": f"Repo '{repo_name}' removed from session '{session}'"}🤖 Prompt for AI Agents |
||
|
|
||
| async def get_repos_status(self, project: str, session: str) -> dict[str, Any]: | ||
| """Get repository clone status for a session.""" | ||
| self._validate_input(project, "project") | ||
| self._validate_input(session, "session") | ||
|
|
||
| result = await self._request("GET", f"/v1/sessions/{session}/repos/status", project) | ||
| result["session"] = session | ||
| return result | ||
|
|
||
| # ── Cluster & auth ─────────────────────────────────────────────────── | ||
|
|
||
| def list_clusters(self) -> dict[str, Any]: | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
Repository: ambient-code/mcp
Length of output: 1599
🏁 Script executed:
Repository: ambient-code/mcp
Length of output: 6199
🏁 Script executed:
Repository: ambient-code/mcp
Length of output: 4342
🏁 Script executed:
Repository: ambient-code/mcp
Length of output: 44
🏁 Script executed:
Repository: ambient-code/mcp
Length of output: 462
🏁 Script executed:
Repository: ambient-code/mcp
Length of output: 953
🏁 Script executed:
Repository: ambient-code/mcp
Length of output: 596
🏁 Script executed:
Repository: ambient-code/mcp
Length of output: 3718
🏁 Script executed:
Repository: ambient-code/mcp
Length of output: 93
🏁 Script executed:
Repository: ambient-code/mcp
Length of output: 42
Use
PATCHinstead ofPUTfor this partial update.The method docstring states "partial update" and builds a sparse payload, but line 717 sends it with
PUT. This creates a semantic mismatch:PUTtypically means full replacement (omitted fields could be cleared), whilePATCHmeans partial update. The codebase already usesPATCHfor other partial updates on sessions. Either switch toPATCHor fetch and resend the full resource document. Additionally, the existing test does not validate the HTTP method, so add an assertion to prevent regression.🤖 Prompt for AI Agents