bamboo-coding 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. agent/__init__.py +0 -0
  2. agent/bamboo_git_agent/__init__.py +0 -0
  3. agent/bamboo_git_agent/capabilities/__init__.py +0 -0
  4. agent/bamboo_git_agent/capabilities/repo.py +189 -0
  5. agent/bamboo_git_agent/config.py +76 -0
  6. agent/bamboo_git_agent/controller_client.py +73 -0
  7. agent/bamboo_git_agent/journal.py +209 -0
  8. agent/bamboo_git_agent/main.py +164 -0
  9. agent/bamboo_git_agent/protocol.py +21 -0
  10. agent/bamboo_git_agent/status.py +59 -0
  11. api/__init__.py +2 -0
  12. api/commit.py +82 -0
  13. api/repository.py +592 -0
  14. bamboo_coding/__init__.py +3 -0
  15. bamboo_coding/client/__init__.py +3 -0
  16. bamboo_coding/client/capabilities/__init__.py +1 -0
  17. bamboo_coding/client/capabilities/repo.py +279 -0
  18. bamboo_coding/client/config.py +179 -0
  19. bamboo_coding/client/controller_client.py +112 -0
  20. bamboo_coding/client/journal.py +1 -0
  21. bamboo_coding/client/main.py +425 -0
  22. bamboo_coding/client/status.py +59 -0
  23. bamboo_coding/client/terminal_runtime.py +251 -0
  24. bamboo_coding/server/__init__.py +3 -0
  25. bamboo_coding/server/core/__init__.py +1 -0
  26. bamboo_coding/server/core/config.py +1 -0
  27. bamboo_coding/server/main.py +39 -0
  28. bamboo_coding/shared/__init__.py +39 -0
  29. bamboo_coding/shared/protocol.py +1 -0
  30. bamboo_coding-0.1.0.dist-info/METADATA +284 -0
  31. bamboo_coding-0.1.0.dist-info/RECORD +62 -0
  32. bamboo_coding-0.1.0.dist-info/WHEEL +5 -0
  33. bamboo_coding-0.1.0.dist-info/entry_points.txt +3 -0
  34. bamboo_coding-0.1.0.dist-info/top_level.txt +6 -0
  35. controller/__init__.py +0 -0
  36. controller/app/__init__.py +0 -0
  37. controller/app/api/__init__.py +0 -0
  38. controller/app/api/agents/__init__.py +3 -0
  39. controller/app/api/agents/ws.py +133 -0
  40. controller/app/api/public/__init__.py +11 -0
  41. controller/app/api/public/repos.py +207 -0
  42. controller/app/api/public/terminals.py +64 -0
  43. controller/app/core/__init__.py +0 -0
  44. controller/app/core/config.py +20 -0
  45. controller/app/core/errors.py +14 -0
  46. controller/app/db/__init__.py +0 -0
  47. controller/app/db/models.py +42 -0
  48. controller/app/db/session.py +74 -0
  49. controller/app/main.py +71 -0
  50. controller/app/schemas/__init__.py +23 -0
  51. controller/app/schemas/agent_messages.py +21 -0
  52. controller/app/schemas/public.py +3 -0
  53. controller/app/services/__init__.py +0 -0
  54. controller/app/services/agents.py +108 -0
  55. controller/app/services/registrations.py +17 -0
  56. controller/app/services/repositories.py +85 -0
  57. controller/app/services/router.py +140 -0
  58. controller/app/services/tasks.py +104 -0
  59. controller/app/services/terminals.py +148 -0
  60. git_utils.py +2238 -0
  61. shared/__init__.py +39 -0
  62. shared/protocol.py +253 -0
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ bamboo-coding = bamboo_coding.client.main:main
3
+ bamboo-coding-server = bamboo_coding.server.main:main
@@ -0,0 +1,6 @@
1
+ agent
2
+ api
3
+ bamboo_coding
4
+ controller
5
+ git_utils
6
+ shared
controller/__init__.py ADDED
File without changes
File without changes
File without changes
@@ -0,0 +1,3 @@
1
+ from .ws import router
2
+
3
+ __all__ = ["router"]
@@ -0,0 +1,133 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+
5
+ from fastapi import APIRouter, WebSocket
6
+ from fastapi.websockets import WebSocketDisconnect
7
+
8
+ from controller.app.core.errors import AgentApprovalError
9
+ from shared.protocol import (
10
+ AgentRegisterMessage,
11
+ ClientStatusMessage,
12
+ RegisterRequestMessage,
13
+ TaskEventMessage,
14
+ TaskResultMessage,
15
+ TerminalClosedMessage,
16
+ TerminalHeartbeatMessage,
17
+ TerminalOutputMessage,
18
+ TerminalStatusMessage,
19
+ )
20
+
21
+
22
+ router = APIRouter()
23
+
24
+
25
+ def _extract_token(websocket: WebSocket) -> str:
26
+ auth_header = websocket.headers.get('authorization', '')
27
+ if auth_header.startswith('Bearer '):
28
+ return auth_header[7:]
29
+ return websocket.query_params.get('token', '')
30
+
31
+
32
+ @router.websocket('/ws/agents')
33
+ async def agent_socket(websocket: WebSocket) -> None:
34
+ await websocket.accept()
35
+ send_lock = asyncio.Lock()
36
+
37
+ async def send_json(payload: dict) -> None:
38
+ async with send_lock:
39
+ await websocket.send_json(payload)
40
+
41
+ token = _extract_token(websocket)
42
+ raw = await websocket.receive_json()
43
+ register = AgentRegisterMessage(**raw)
44
+ client_id = register.client_id or register.client_info.hostname
45
+ agents = websocket.app.state.agents
46
+ repos = websocket.app.state.repositories
47
+ router_service = websocket.app.state.router
48
+ terminals = websocket.app.state.terminals
49
+
50
+ if not token:
51
+ existing_agent = agents.get_agent_by_client_id(client_id)
52
+ if existing_agent and existing_agent.get('approval_status') == 'approved' and existing_agent.get('token'):
53
+ await send_json(RegisterRequestMessage(approved=True, client_id=client_id, token=existing_agent['token'], message='approved').model_dump())
54
+ else:
55
+ agents.create_registration_request(client_id)
56
+ await send_json(RegisterRequestMessage(approved=False, client_id=client_id, message='pending approval').model_dump())
57
+ await websocket.close()
58
+ return
59
+
60
+ try:
61
+ agent = agents.register_connection(client_id=client_id, token=token, client_info=register.client_info.model_dump())
62
+ except AgentApprovalError:
63
+ agents.create_registration_request(client_id)
64
+ await send_json(RegisterRequestMessage(approved=False, client_id=client_id, message='pending approval').model_dump())
65
+ await websocket.close()
66
+ return
67
+
68
+ for repo in register.repositories:
69
+ repos.upsert_repository_binding(
70
+ agent_id=agent['agent_id'],
71
+ repo_id=repo.repo_id,
72
+ name=repo.name,
73
+ path=repo.path,
74
+ status='ready',
75
+ )
76
+ router_service.attach_agent(
77
+ agent_id=agent['agent_id'],
78
+ send_json=send_json,
79
+ capabilities=[item.model_dump() for item in register.capabilities],
80
+ repositories=[item.model_dump() for item in register.repositories],
81
+ )
82
+ await send_json(RegisterRequestMessage(approved=True, client_id=client_id, token=token, message='registered').model_dump())
83
+
84
+ try:
85
+ while True:
86
+ payload = await websocket.receive_json()
87
+ message_type = payload.get('type')
88
+ if message_type == 'client_status':
89
+ status = ClientStatusMessage(**payload)
90
+ for repo in status.repositories:
91
+ repos.upsert_repository_binding(
92
+ agent_id=agent['agent_id'],
93
+ repo_id=repo.repo_id,
94
+ name=repo.name,
95
+ path=repo.path,
96
+ status='ready',
97
+ )
98
+ router_service.attach_agent(
99
+ agent_id=agent['agent_id'],
100
+ send_json=send_json,
101
+ capabilities=[item.model_dump() for item in status.capabilities],
102
+ repositories=[item.model_dump() for item in status.repositories],
103
+ )
104
+ elif message_type == 'task_event':
105
+ event = TaskEventMessage(**payload)
106
+ await router_service.handle_event(
107
+ task_id=event.task_id,
108
+ status=event.status,
109
+ message=event.message,
110
+ details=event.details,
111
+ )
112
+ elif message_type == 'task_result':
113
+ result = TaskResultMessage(**payload)
114
+ await router_service.handle_result(
115
+ task_id=result.task_id,
116
+ success=result.success,
117
+ result=result.result,
118
+ error=result.error,
119
+ )
120
+ elif message_type == 'terminal_status':
121
+ await terminals.handle_terminal_status(TerminalStatusMessage(**payload))
122
+ elif message_type == 'terminal_output':
123
+ await terminals.handle_terminal_output(TerminalOutputMessage(**payload))
124
+ elif message_type == 'terminal_heartbeat':
125
+ await terminals.handle_terminal_heartbeat(TerminalHeartbeatMessage(**payload))
126
+ elif message_type == 'terminal_closed':
127
+ await terminals.handle_terminal_closed(TerminalClosedMessage(**payload))
128
+ except WebSocketDisconnect:
129
+ pass
130
+ finally:
131
+ router_service.detach_agent(agent['agent_id'])
132
+ agents.mark_disconnected(agent['agent_id'])
133
+ await terminals.close_agent_sessions(agent['agent_id'], reason='client_disconnected')
@@ -0,0 +1,11 @@
1
+ from fastapi import APIRouter
2
+
3
+ from .repos import router as repos_router
4
+ from .terminals import router as terminals_router
5
+
6
+
7
+ router = APIRouter()
8
+ router.include_router(repos_router)
9
+ router.include_router(terminals_router)
10
+
11
+ __all__ = ["router"]
@@ -0,0 +1,207 @@
1
+ from __future__ import annotations
2
+
3
+ from fastapi import APIRouter, HTTPException, Request
4
+
5
+
6
+ router = APIRouter(prefix="/api")
7
+
8
+
9
+ def _list_public_roots(request: Request) -> list[dict]:
10
+ return [item.model_dump() for item in request.app.state.repositories.list_public_roots()]
11
+
12
+
13
+ async def _dispatch(request: Request, path: str, capability: str, action: str, params: dict, agent_id: str | None = None) -> dict:
14
+ try:
15
+ return await request.app.state.router.dispatch_task(path, capability, action, params, agent_id=agent_id)
16
+ except PermissionError as exc:
17
+ raise HTTPException(status_code=403, detail=str(exc)) from exc
18
+ except RuntimeError as exc:
19
+ raise HTTPException(status_code=503, detail=str(exc)) from exc
20
+ except KeyError as exc:
21
+ raise HTTPException(status_code=404, detail=str(exc)) from exc
22
+
23
+
24
+ @router.get("/agents")
25
+ async def list_agents(request: Request) -> list[dict]:
26
+ agents = request.app.state.agents.list_agents()
27
+ for agent in agents:
28
+ live_agent = request.app.state.router.live_agents.get(agent["agent_id"])
29
+ agent["roots"] = [dict(root) for root in (live_agent.roots if live_agent is not None else [])]
30
+ return agents
31
+
32
+
33
+ @router.post("/agents/{agent_id}/approve")
34
+ async def approve_agent(agent_id: str, request: Request) -> dict:
35
+ import uuid
36
+
37
+ token = uuid.uuid4().hex
38
+ agent = request.app.state.agents.approve_agent(agent_id=agent_id, token=token)
39
+ return {
40
+ "approved": True,
41
+ "agent_id": agent["agent_id"],
42
+ "client_id": agent["client_id"],
43
+ "token": agent["token"],
44
+ }
45
+
46
+
47
+ @router.get("/roots")
48
+ async def list_roots(request: Request) -> list[dict]:
49
+ return _list_public_roots(request)
50
+
51
+
52
+ @router.get("/fs/tree")
53
+ async def get_tree(path: str, request: Request, all: bool = False, agent_id: str | None = None) -> dict:
54
+ return await _dispatch(request, path, "fs.tree", "get", {"all": all}, agent_id=agent_id)
55
+
56
+
57
+ @router.get("/fs/browse")
58
+ async def browse(path: str, request: Request, agent_id: str | None = None) -> dict:
59
+ return await _dispatch(request, path, "fs.browse", "get", {}, agent_id=agent_id)
60
+
61
+
62
+ @router.get("/fs/file")
63
+ async def read_file(path: str, request: Request, agent_id: str | None = None) -> dict:
64
+ return await _dispatch(request, path, "fs.file.read", "get", {}, agent_id=agent_id)
65
+
66
+
67
+ @router.get("/fs/file/raw")
68
+ async def read_file_raw(path: str, request: Request, agent_id: str | None = None) -> dict:
69
+ return await _dispatch(request, path, "fs.file.read_raw", "get", {}, agent_id=agent_id)
70
+
71
+
72
+ @router.put("/fs/file")
73
+ async def write_file(path: str, request: Request, agent_id: str | None = None) -> dict:
74
+ body = await request.json()
75
+ return await _dispatch(request, path, "fs.file.write", "put", {"content": body.get("content", ""), "encoding": body.get("encoding", "utf-8")}, agent_id=agent_id)
76
+
77
+
78
+ @router.post("/fs/folder")
79
+ async def create_folder(path: str, request: Request, agent_id: str | None = None) -> dict:
80
+ try:
81
+ body = await request.json()
82
+ except Exception:
83
+ body = {}
84
+ exist_ok = bool(body.get("exist_ok", True)) if isinstance(body, dict) else True
85
+ return await _dispatch(request, path, "fs.folder.create", "post", {"exist_ok": exist_ok}, agent_id=agent_id)
86
+
87
+
88
+ @router.delete("/fs/path")
89
+ async def delete_path(path: str, request: Request, recursive: bool = False, agent_id: str | None = None) -> dict:
90
+ return await _dispatch(request, path, "fs.path.delete", "delete", {"recursive": recursive}, agent_id=agent_id)
91
+
92
+
93
+ @router.put("/fs/path/rename")
94
+ async def rename_path(path: str, request: Request, agent_id: str | None = None) -> dict:
95
+ body = await request.json()
96
+ if not isinstance(body, dict):
97
+ raise HTTPException(status_code=400, detail="Body must be a JSON object")
98
+ destination = body.get("to") or body.get("new_path")
99
+ if not destination:
100
+ raise HTTPException(status_code=400, detail="Missing 'to' field in body")
101
+ return await _dispatch(request, path, "fs.path.rename", "put", {"to": str(destination)}, agent_id=agent_id)
102
+
103
+
104
+ @router.get("/git/status")
105
+ async def get_status(path: str, request: Request, agent_id: str | None = None) -> dict:
106
+ return await _dispatch(request, path, "git.status", "get", {}, agent_id=agent_id)
107
+
108
+
109
+ @router.get("/git/diff")
110
+ async def get_diff(path: str, request: Request, file: str | None = None, staged: bool = False, context: int = 3, agent_id: str | None = None) -> dict:
111
+ if not file:
112
+ raise HTTPException(status_code=400, detail="Missing file parameter")
113
+ return await _dispatch(request, path, "git.diff", "get", {"file": file, "staged": staged, "context": context}, agent_id=agent_id)
114
+
115
+
116
+ @router.get("/git/commits")
117
+ async def get_commits(path: str, request: Request, limit: int = 50, offset: int = 0, agent_id: str | None = None) -> dict:
118
+ if limit < 1 or limit > 100:
119
+ raise HTTPException(status_code=400, detail="limit must be between 1 and 100")
120
+ if offset < 0:
121
+ raise HTTPException(status_code=400, detail="offset must be non-negative")
122
+ return await _dispatch(request, path, "git.commits", "list", {"limit": limit, "offset": offset}, agent_id=agent_id)
123
+
124
+
125
+ @router.get("/git/commit/{commit_hash}")
126
+ async def get_commit_details(path: str, commit_hash: str, request: Request, agent_id: str | None = None) -> dict:
127
+ return await _dispatch(request, path, "git.commit_details", "get", {"commit_hash": commit_hash}, agent_id=agent_id)
128
+
129
+
130
+ @router.post("/git/stage")
131
+ async def stage(path: str, request: Request, agent_id: str | None = None) -> dict:
132
+ body = await request.json()
133
+ return await _dispatch(request, path, "git.stage", "post", body, agent_id=agent_id)
134
+
135
+
136
+ @router.post("/git/unstage")
137
+ async def unstage(path: str, request: Request, agent_id: str | None = None) -> dict:
138
+ body = await request.json()
139
+ return await _dispatch(request, path, "git.unstage", "post", body, agent_id=agent_id)
140
+
141
+
142
+ @router.get("/git/branches")
143
+ async def list_branches(path: str, request: Request, agent_id: str | None = None) -> dict:
144
+ return await _dispatch(request, path, "git.branches.list", "get", {}, agent_id=agent_id)
145
+
146
+
147
+ @router.post("/git/branches")
148
+ async def create_branch(path: str, request: Request, agent_id: str | None = None) -> dict:
149
+ body = await request.json()
150
+ return await _dispatch(request, path, "git.branches.create", "post", body, agent_id=agent_id)
151
+
152
+
153
+ @router.put("/git/branches/checkout")
154
+ async def checkout_branch(path: str, request: Request, agent_id: str | None = None) -> dict:
155
+ body = await request.json()
156
+ return await _dispatch(request, path, "git.branches.checkout", "put", body, agent_id=agent_id)
157
+
158
+
159
+ @router.delete("/git/branches/{name}")
160
+ async def delete_branch(path: str, name: str, request: Request, force: bool = False, agent_id: str | None = None) -> dict:
161
+ return await _dispatch(request, path, "git.branches.delete", "delete", {"name": name, "force": force}, agent_id=agent_id)
162
+
163
+
164
+ @router.post("/git/branches/merge")
165
+ async def merge_branch(path: str, request: Request, agent_id: str | None = None) -> dict:
166
+ body = await request.json()
167
+ return await _dispatch(request, path, "git.branches.merge", "post", body, agent_id=agent_id)
168
+
169
+
170
+ @router.post("/git/commit")
171
+ async def create_commit(path: str, request: Request, agent_id: str | None = None) -> dict:
172
+ body = await request.json()
173
+ return await _dispatch(request, path, "git.commit.create", "post", body, agent_id=agent_id)
174
+
175
+
176
+ @router.get("/git/remotes")
177
+ async def list_remotes(path: str, request: Request, agent_id: str | None = None) -> dict:
178
+ return await _dispatch(request, path, "git.remotes.list", "get", {}, agent_id=agent_id)
179
+
180
+
181
+ @router.post("/git/remotes")
182
+ async def add_remote(path: str, request: Request, agent_id: str | None = None) -> dict:
183
+ body = await request.json()
184
+ if not isinstance(body, dict) or not body.get("name") or not body.get("url"):
185
+ raise HTTPException(status_code=400, detail="Missing 'name' or 'url'")
186
+ return await _dispatch(request, path, "git.remotes.add", "post", body, agent_id=agent_id)
187
+
188
+
189
+ @router.delete("/git/remotes/{name}")
190
+ async def remove_remote(path: str, name: str, request: Request, agent_id: str | None = None) -> dict:
191
+ return await _dispatch(request, path, "git.remotes.remove", "delete", {"name": name}, agent_id=agent_id)
192
+
193
+
194
+ @router.post("/git/remotes/push")
195
+ async def push_remote(path: str, request: Request, agent_id: str | None = None) -> dict:
196
+ body = await request.json()
197
+ if not isinstance(body, dict) or not body.get("remote"):
198
+ raise HTTPException(status_code=400, detail="Missing 'remote'")
199
+ return await _dispatch(request, path, "git.remotes.push", "post", body, agent_id=agent_id)
200
+
201
+
202
+ @router.post("/git/remotes/pull")
203
+ async def pull_remote(path: str, request: Request, agent_id: str | None = None) -> dict:
204
+ body = await request.json()
205
+ if not isinstance(body, dict) or not body.get("remote"):
206
+ raise HTTPException(status_code=400, detail="Missing 'remote'")
207
+ return await _dispatch(request, path, "git.remotes.pull", "post", body, agent_id=agent_id)
@@ -0,0 +1,64 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ from uuid import uuid4
5
+
6
+ from fastapi import APIRouter, WebSocket
7
+ from fastapi.websockets import WebSocketDisconnect
8
+
9
+ from shared.protocol import TerminalClosedMessage, TerminalPingMessage, TerminalPongMessage, TerminalStatusMessage
10
+
11
+
12
+ router = APIRouter()
13
+
14
+
15
+ def _parse_positive_int(value: str | None, default: int) -> int:
16
+ try:
17
+ parsed = int(value or '')
18
+ except ValueError:
19
+ return default
20
+ return parsed if parsed > 0 else default
21
+
22
+
23
+ @router.websocket('/ws/terminal')
24
+ async def terminal_socket(websocket: WebSocket) -> None:
25
+ await websocket.accept()
26
+
27
+ agent_id = websocket.query_params.get('agent_id') or ''
28
+ path = websocket.query_params.get('path') or ''
29
+ terminal_id = websocket.query_params.get('terminal_id') or str(uuid4())
30
+ cols = _parse_positive_int(websocket.query_params.get('cols'), 120)
31
+ rows = _parse_positive_int(websocket.query_params.get('rows'), 32)
32
+ send_lock = asyncio.Lock()
33
+
34
+ async def send_json(payload: dict) -> None:
35
+ async with send_lock:
36
+ await websocket.send_json(payload)
37
+
38
+ try:
39
+ if not agent_id:
40
+ raise RuntimeError('Missing agent_id query parameter')
41
+ if not path:
42
+ raise RuntimeError('Missing path query parameter')
43
+
44
+ await websocket.app.state.terminals.open_browser_terminal(
45
+ terminal_id=terminal_id,
46
+ agent_id=agent_id,
47
+ path=path,
48
+ cols=cols,
49
+ rows=rows,
50
+ send_json=send_json,
51
+ )
52
+ while True:
53
+ payload = await websocket.receive_json()
54
+ if payload.get('type') == 'terminal_ping':
55
+ TerminalPingMessage(**payload)
56
+ await send_json(TerminalPongMessage(terminal_id=terminal_id).model_dump())
57
+ continue
58
+ await websocket.app.state.terminals.forward_browser_message(terminal_id, payload)
59
+ except WebSocketDisconnect:
60
+ await websocket.app.state.terminals.close_browser_terminal(terminal_id, reason='browser_disconnected')
61
+ except RuntimeError as exc:
62
+ await send_json(TerminalStatusMessage(terminal_id=terminal_id, status='error', message=str(exc)).model_dump())
63
+ await send_json(TerminalClosedMessage(terminal_id=terminal_id, reason=str(exc)).model_dump())
64
+ await websocket.close()
File without changes
@@ -0,0 +1,20 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+ import os
6
+
7
+
8
+ @dataclass(frozen=True)
9
+ class AppConfig:
10
+ database_path: Path
11
+ host: str = "0.0.0.0"
12
+ port: int = 8100
13
+
14
+
15
+ def load_config() -> AppConfig:
16
+ root_dir = Path(__file__).resolve().parents[3]
17
+ database_path = Path(os.getenv("CONTROLLER_DB_PATH", root_dir / ".controller" / "controller.db"))
18
+ host = os.getenv("HOST", "0.0.0.0")
19
+ port = int(os.getenv("PORT", "8100"))
20
+ return AppConfig(database_path=database_path, host=host, port=port)
@@ -0,0 +1,14 @@
1
+ class ControllerError(Exception):
2
+ """Base error for controller services."""
3
+
4
+
5
+ class AgentApprovalError(ControllerError):
6
+ """Raised when an agent is not approved for connection."""
7
+
8
+
9
+ class RepositoryNotFoundError(ControllerError):
10
+ """Raised when a repository binding is missing."""
11
+
12
+
13
+ class TaskNotFoundError(ControllerError):
14
+ """Raised when a task is not present in controller storage."""
File without changes
@@ -0,0 +1,42 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Any
5
+
6
+
7
+ @dataclass(frozen=True)
8
+ class AgentRecord:
9
+ agent_id: str
10
+ client_id: str
11
+ approval_status: str
12
+ status: str
13
+ token: str | None
14
+ client_info: dict[str, Any]
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class RepositoryBindingRecord:
19
+ repo_id: str
20
+ agent_id: str
21
+ name: str
22
+ path: str
23
+ status: str
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class TaskRecord:
28
+ task_id: str
29
+ repo_id: str
30
+ agent_id: str | None
31
+ capability: str
32
+ action: str
33
+ status: str
34
+ params: dict[str, Any]
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class TaskResultRecord:
39
+ task_id: str
40
+ success: bool
41
+ result: dict[str, Any]
42
+ error: str | None
@@ -0,0 +1,74 @@
1
+ from __future__ import annotations
2
+
3
+ from contextlib import contextmanager
4
+ from pathlib import Path
5
+ import sqlite3
6
+
7
+
8
+ class ControllerDatabase:
9
+ def __init__(self, database_path: Path | str):
10
+ self.database_path = Path(database_path)
11
+
12
+ def initialize(self) -> None:
13
+ self.database_path.parent.mkdir(parents=True, exist_ok=True)
14
+ with self.connection() as conn:
15
+ conn.executescript(
16
+ """
17
+ PRAGMA foreign_keys = ON;
18
+ CREATE TABLE IF NOT EXISTS agents (
19
+ agent_id TEXT PRIMARY KEY,
20
+ client_id TEXT NOT NULL UNIQUE,
21
+ token TEXT UNIQUE,
22
+ approval_status TEXT NOT NULL,
23
+ status TEXT NOT NULL,
24
+ client_info_json TEXT NOT NULL DEFAULT '{}',
25
+ created_at TEXT NOT NULL,
26
+ updated_at TEXT NOT NULL,
27
+ last_seen_at TEXT
28
+ );
29
+ CREATE TABLE IF NOT EXISTS repositories (
30
+ repo_id TEXT PRIMARY KEY,
31
+ agent_id TEXT NOT NULL,
32
+ name TEXT NOT NULL,
33
+ path TEXT NOT NULL,
34
+ status TEXT NOT NULL,
35
+ created_at TEXT NOT NULL,
36
+ updated_at TEXT NOT NULL,
37
+ FOREIGN KEY (agent_id) REFERENCES agents(agent_id)
38
+ );
39
+ CREATE TABLE IF NOT EXISTS tasks (
40
+ task_id TEXT PRIMARY KEY,
41
+ repo_id TEXT NOT NULL,
42
+ agent_id TEXT,
43
+ capability TEXT NOT NULL,
44
+ action TEXT NOT NULL,
45
+ params_json TEXT NOT NULL DEFAULT '{}',
46
+ status TEXT NOT NULL,
47
+ created_at TEXT NOT NULL,
48
+ updated_at TEXT NOT NULL,
49
+ terminal_at TEXT,
50
+ result_success INTEGER,
51
+ result_json TEXT,
52
+ error TEXT
53
+ );
54
+ CREATE TABLE IF NOT EXISTS task_events (
55
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
56
+ task_id TEXT NOT NULL,
57
+ status TEXT NOT NULL,
58
+ message TEXT,
59
+ details_json TEXT NOT NULL DEFAULT '{}',
60
+ created_at TEXT NOT NULL,
61
+ FOREIGN KEY (task_id) REFERENCES tasks(task_id)
62
+ );
63
+ """
64
+ )
65
+
66
+ @contextmanager
67
+ def connection(self):
68
+ conn = sqlite3.connect(self.database_path)
69
+ conn.row_factory = sqlite3.Row
70
+ try:
71
+ yield conn
72
+ conn.commit()
73
+ finally:
74
+ conn.close()
controller/app/main.py ADDED
@@ -0,0 +1,71 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ from fastapi import FastAPI, HTTPException, Request
6
+ from fastapi.responses import FileResponse, JSONResponse
7
+ from fastapi.staticfiles import StaticFiles
8
+
9
+ from controller.app.api.agents import router as agent_router
10
+ from controller.app.api.public import router as public_router
11
+ from controller.app.core.config import load_config
12
+ from controller.app.db.session import ControllerDatabase
13
+ from controller.app.services.agents import AgentService
14
+ from controller.app.services.registrations import RegistrationService
15
+ from controller.app.services.repositories import RepositoryService
16
+ from controller.app.services.router import TaskRouter
17
+ from controller.app.services.terminals import TerminalRelayService
18
+ from controller.app.services.tasks import TaskService
19
+
20
+
21
+ def create_app(database_path: Path | str | None = None) -> FastAPI:
22
+ app = FastAPI(title="Bamboo Coding")
23
+ config = load_config()
24
+ static_dir = Path(__file__).resolve().parents[2] / "static"
25
+ index_file = static_dir / "index.html"
26
+ terminal_file = static_dir / "terminal.html"
27
+ resolved_db_path = Path(database_path) if database_path is not None else config.database_path
28
+ database = ControllerDatabase(resolved_db_path)
29
+ database.initialize()
30
+ repositories = RepositoryService(database)
31
+ tasks = TaskService(database)
32
+ agents = AgentService(database)
33
+ router = TaskRouter(repositories=repositories, tasks=tasks)
34
+ terminals = TerminalRelayService(router=router)
35
+
36
+ app.state.config = config
37
+ app.state.database = database
38
+ app.state.repositories = repositories
39
+ app.state.tasks = tasks
40
+ app.state.agents = agents
41
+ app.state.registrations = RegistrationService(agents)
42
+ app.state.router = router
43
+ app.state.terminals = terminals
44
+
45
+ @app.exception_handler(HTTPException)
46
+ async def http_error_handler(_request: Request, exc: HTTPException) -> JSONResponse:
47
+ return JSONResponse(status_code=exc.status_code, content={"error": "request_error", "message": str(exc.detail)})
48
+
49
+ @app.exception_handler(Exception)
50
+ async def unexpected_error_handler(_request: Request, exc: Exception) -> JSONResponse:
51
+ return JSONResponse(status_code=500, content={"error": "internal_error", "message": str(exc)})
52
+
53
+ @app.get("/api/health")
54
+ async def health_check() -> JSONResponse:
55
+ return JSONResponse({"status": "ok", "version": "2.0.0"})
56
+
57
+ @app.get("/")
58
+ async def index() -> FileResponse:
59
+ return FileResponse(index_file)
60
+
61
+ @app.get("/terminal")
62
+ async def terminal_page() -> FileResponse:
63
+ return FileResponse(terminal_file)
64
+
65
+ app.include_router(public_router)
66
+ app.include_router(agent_router)
67
+ app.mount("/static", StaticFiles(directory=str(static_dir)), name="static")
68
+ return app
69
+
70
+
71
+ app = create_app()