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
agent/__init__.py ADDED
File without changes
File without changes
File without changes
@@ -0,0 +1,189 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import Callable
5
+
6
+ import git_utils
7
+ from shared.protocol import TaskMessage
8
+
9
+
10
+ def _allowed_roots(repositories: dict[str, str] | list[str] | tuple[str, ...]) -> list[str]:
11
+ if isinstance(repositories, dict):
12
+ values = list(repositories.values()) or list(repositories.keys())
13
+ else:
14
+ values = list(repositories)
15
+ return [str(Path(item).expanduser().resolve()) for item in values]
16
+
17
+
18
+ def _resolve_allowed_path(task: TaskMessage, repositories: dict[str, str] | list[str] | tuple[str, ...]) -> str:
19
+ target = str(Path(task.path).expanduser().resolve())
20
+ for root in _allowed_roots(repositories):
21
+ try:
22
+ Path(target).relative_to(root)
23
+ return target
24
+ except ValueError:
25
+ continue
26
+ raise PermissionError(f"Path outside allowed roots: {task.path}")
27
+
28
+
29
+ def _resolve_repo_root(task: TaskMessage, repositories: dict[str, str] | list[str] | tuple[str, ...]) -> str:
30
+ allowed_path = _resolve_allowed_path(task, repositories)
31
+ return git_utils.resolve_git_repository_path(allowed_path)
32
+
33
+
34
+ def execute_task(task: TaskMessage, repositories: dict[str, str] | list[str] | tuple[str, ...]) -> dict:
35
+ handler = _HANDLERS.get((task.capability, task.action))
36
+ if handler is None:
37
+ raise KeyError(f"Unsupported task: {task.capability}/{task.action}")
38
+ git_utils.clear_cache()
39
+ return handler(task, repositories)
40
+
41
+
42
+ def _fs_tree(task: TaskMessage, repositories) -> dict:
43
+ path = _resolve_allowed_path(task, repositories)
44
+ include_nested = bool(task.params.get("all", False))
45
+ if git_utils.is_git_repository_path(path):
46
+ repo_path = git_utils.resolve_git_repository_path(path)
47
+ return git_utils.get_file_tree(repo_path, changed_only=not include_nested)
48
+ return git_utils.get_workspace_tree(path, include_nested=include_nested)
49
+
50
+
51
+ def _fs_browse(task: TaskMessage, repositories) -> dict:
52
+ path = _resolve_allowed_path(task, repositories)
53
+ return git_utils.browse_workspace(path)
54
+
55
+
56
+ def _fs_read(task: TaskMessage, repositories) -> dict:
57
+ path = _resolve_allowed_path(task, repositories)
58
+ return git_utils.read_workspace_file(path)
59
+
60
+
61
+ def _fs_write(task: TaskMessage, repositories) -> dict:
62
+ path = _resolve_allowed_path(task, repositories)
63
+ return git_utils.write_workspace_file(path, task.params["content"], task.params.get("encoding", "utf-8"))
64
+
65
+
66
+ def _tree(task: TaskMessage, repositories) -> dict:
67
+ show_all = bool(task.params.get("all", False))
68
+ return git_utils.get_file_tree(_resolve_repo_root(task, repositories), changed_only=not show_all)
69
+
70
+
71
+ def _browse(task: TaskMessage, repositories) -> dict:
72
+ return git_utils.browse_directory(_resolve_repo_root(task, repositories), task.params.get("path", "/"))
73
+
74
+
75
+ def _diff(task: TaskMessage, repositories) -> dict:
76
+ return git_utils.get_file_diff(
77
+ _resolve_repo_root(task, repositories),
78
+ task.params["file"],
79
+ bool(task.params.get("staged", False)),
80
+ int(task.params.get("context", 3)),
81
+ )
82
+
83
+
84
+ def _status(task: TaskMessage, repositories) -> dict:
85
+ return git_utils.get_repo_status(_resolve_repo_root(task, repositories))
86
+
87
+
88
+ def _commits(task: TaskMessage, repositories) -> dict:
89
+ limit = int(task.params.get("limit", 50))
90
+ offset = int(task.params.get("offset", 0))
91
+ repo_path = _resolve_repo_root(task, repositories)
92
+ return {
93
+ "commits": git_utils.get_commit_history(repo_path, limit=limit, offset=offset),
94
+ "limit": limit,
95
+ "offset": offset,
96
+ }
97
+
98
+
99
+ def _commit_details(task: TaskMessage, repositories) -> dict:
100
+ return git_utils.get_commit_details(_resolve_repo_root(task, repositories), task.params["commit_hash"])
101
+
102
+
103
+ def _read_file(task: TaskMessage, repositories) -> dict:
104
+ return git_utils.read_file_content(_resolve_repo_root(task, repositories), task.params["file_path"])
105
+
106
+
107
+ def _write_file(task: TaskMessage, repositories) -> dict:
108
+ return git_utils.write_file_content(
109
+ _resolve_repo_root(task, repositories),
110
+ task.params["file_path"],
111
+ task.params["content"],
112
+ task.params.get("encoding", "utf-8"),
113
+ )
114
+
115
+
116
+ def _stage(task: TaskMessage, repositories) -> dict:
117
+ repo_path = _resolve_repo_root(task, repositories)
118
+ if task.params.get("stage_all"):
119
+ return git_utils.stage_all_files(repo_path)
120
+ return git_utils.stage_file(repo_path, task.params["file_path"])
121
+
122
+
123
+ def _unstage(task: TaskMessage, repositories) -> dict:
124
+ return git_utils.unstage_file(_resolve_repo_root(task, repositories), task.params["file_path"])
125
+
126
+
127
+ def _list_branches(task: TaskMessage, repositories) -> dict:
128
+ return git_utils.list_branches(_resolve_repo_root(task, repositories))
129
+
130
+
131
+ def _create_branch(task: TaskMessage, repositories) -> dict:
132
+ return git_utils.create_branch(_resolve_repo_root(task, repositories), task.params["name"], task.params.get("start_point"))
133
+
134
+
135
+ def _checkout_branch(task: TaskMessage, repositories) -> dict:
136
+ return git_utils.switch_branch(_resolve_repo_root(task, repositories), task.params["name"])
137
+
138
+
139
+ def _delete_branch(task: TaskMessage, repositories) -> dict:
140
+ return git_utils.delete_branch(_resolve_repo_root(task, repositories), task.params["name"], bool(task.params.get("force", False)))
141
+
142
+
143
+ def _merge_branch(task: TaskMessage, repositories) -> dict:
144
+ return git_utils.merge_branch(_resolve_repo_root(task, repositories), task.params["source"], task.params.get("message"))
145
+
146
+
147
+ def _create_commit(task: TaskMessage, repositories) -> dict:
148
+ return git_utils.create_commit(
149
+ _resolve_repo_root(task, repositories),
150
+ task.params["message"],
151
+ task.params.get("author_name"),
152
+ task.params.get("author_email"),
153
+ )
154
+
155
+
156
+ _HANDLERS: dict[tuple[str, str], Callable[[TaskMessage, dict[str, str] | list[str] | tuple[str, ...]], dict]] = {
157
+ ("fs.tree", "get"): _fs_tree,
158
+ ("fs.browse", "get"): _fs_browse,
159
+ ("fs.file.read", "get"): _fs_read,
160
+ ("fs.file.write", "put"): _fs_write,
161
+ ("repo.tree", "get"): _tree,
162
+ ("repo.browse", "get"): _browse,
163
+ ("repo.diff", "get"): _diff,
164
+ ("repo.status", "get"): _status,
165
+ ("repo.commits", "list"): _commits,
166
+ ("repo.commit_details", "get"): _commit_details,
167
+ ("repo.file.read", "get"): _read_file,
168
+ ("repo.file.write", "put"): _write_file,
169
+ ("repo.stage", "post"): _stage,
170
+ ("repo.unstage", "post"): _unstage,
171
+ ("repo.branches.list", "get"): _list_branches,
172
+ ("repo.branches.create", "post"): _create_branch,
173
+ ("repo.branches.checkout", "put"): _checkout_branch,
174
+ ("repo.branches.delete", "delete"): _delete_branch,
175
+ ("repo.branches.merge", "post"): _merge_branch,
176
+ ("repo.commit.create", "post"): _create_commit,
177
+ ("git.status", "get"): _status,
178
+ ("git.diff", "get"): _diff,
179
+ ("git.commits", "list"): _commits,
180
+ ("git.commit_details", "get"): _commit_details,
181
+ ("git.stage", "post"): _stage,
182
+ ("git.unstage", "post"): _unstage,
183
+ ("git.branches.list", "get"): _list_branches,
184
+ ("git.branches.create", "post"): _create_branch,
185
+ ("git.branches.checkout", "put"): _checkout_branch,
186
+ ("git.branches.delete", "delete"): _delete_branch,
187
+ ("git.branches.merge", "post"): _merge_branch,
188
+ ("git.commit.create", "post"): _create_commit,
189
+ }
@@ -0,0 +1,76 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from pathlib import Path
5
+ import json
6
+ import os
7
+ import platform
8
+
9
+
10
+ @dataclass(frozen=True)
11
+ class RepositoryConfig:
12
+ repo_id: str
13
+ name: str
14
+ path: str
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class AgentConfig:
19
+ controller_url: str
20
+ client_id: str
21
+ hostname: str
22
+ version: str
23
+ token: str
24
+ token_path: Path
25
+ journal_path: Path
26
+ max_concurrent_tasks: int = 1
27
+ allowed_roots: tuple[str, ...] = field(default_factory=tuple)
28
+ repositories: tuple[RepositoryConfig, ...] = field(default_factory=tuple)
29
+
30
+
31
+ def _normalize_root(path_value: str) -> str:
32
+ return str(Path(path_value).expanduser().resolve())
33
+
34
+
35
+ def _load_allowed_roots() -> tuple[str, ...]:
36
+ raw_value = os.getenv("AGENT_ALLOWED_ROOTS") or os.getenv("AGENT_ROOT") or os.getenv("AGENT_REPO_PATH") or str(Path.cwd())
37
+ roots = tuple(_normalize_root(item) for item in raw_value.split(os.pathsep) if item.strip())
38
+ return roots or (_normalize_root(str(Path.cwd())),)
39
+
40
+
41
+ def _load_stored_token(token_path: Path) -> str:
42
+ if not token_path.exists():
43
+ return ""
44
+ try:
45
+ payload = json.loads(token_path.read_text(encoding="utf-8"))
46
+ except (OSError, json.JSONDecodeError):
47
+ return ""
48
+ token = payload.get("token")
49
+ return token if isinstance(token, str) else ""
50
+
51
+
52
+ def load_config() -> AgentConfig:
53
+ base_dir = Path(os.getenv("AGENT_HOME", Path.cwd() / ".agent"))
54
+ token_path = Path(os.getenv("AGENT_TOKEN_PATH", base_dir / "token.json"))
55
+ allowed_roots = _load_allowed_roots()
56
+ repositories = tuple(
57
+ RepositoryConfig(
58
+ repo_id=root,
59
+ name=Path(root).name or root,
60
+ path=root,
61
+ )
62
+ for root in allowed_roots
63
+ )
64
+ token = os.getenv("AGENT_TOKEN") or _load_stored_token(token_path)
65
+ return AgentConfig(
66
+ controller_url=os.getenv("CONTROLLER_URL", "ws://127.0.0.1:8100/ws/agents"),
67
+ client_id=os.getenv("AGENT_CLIENT_ID", platform.node() or "git-agent"),
68
+ hostname=os.getenv("AGENT_HOSTNAME", platform.node() or "git-agent"),
69
+ version=os.getenv("AGENT_VERSION", "0.1.0"),
70
+ token=token,
71
+ token_path=token_path,
72
+ journal_path=Path(os.getenv("AGENT_JOURNAL_PATH", base_dir / "journal.db")),
73
+ max_concurrent_tasks=int(os.getenv("AGENT_MAX_CONCURRENT_TASKS", "1")),
74
+ allowed_roots=allowed_roots,
75
+ repositories=repositories,
76
+ )
@@ -0,0 +1,73 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from typing import Awaitable, Callable
5
+
6
+ from websockets import connect
7
+ from websockets.exceptions import ConnectionClosed
8
+
9
+ from shared.protocol import (
10
+ AgentRegisterMessage,
11
+ ClientStatusMessage,
12
+ TaskEventMessage,
13
+ TaskMessage,
14
+ TaskQueryResultMessage,
15
+ TaskResultMessage,
16
+ )
17
+
18
+
19
+ class ControllerConnection:
20
+ def __init__(self, controller_url: str, token: str = ""):
21
+ self.controller_url = controller_url
22
+ self.token = token
23
+ self.websocket = None
24
+
25
+ async def connect(self):
26
+ headers = {}
27
+ if self.token:
28
+ headers["Authorization"] = f"Bearer {self.token}"
29
+ self.websocket = await connect(self.controller_url, extra_headers=headers or None, ping_interval=None, ping_timeout=None)
30
+ return self.websocket
31
+
32
+ async def send_register(self, message: AgentRegisterMessage) -> dict:
33
+ await self._send(message.model_dump())
34
+ return await self._receive()
35
+
36
+ async def send_status(self, message: ClientStatusMessage) -> None:
37
+ await self._send(message.model_dump())
38
+
39
+ async def send_event(self, message: TaskEventMessage) -> None:
40
+ await self._send(message.model_dump())
41
+
42
+ async def send_result(self, message: TaskResultMessage) -> None:
43
+ await self._send(message.model_dump())
44
+
45
+ async def send_query_result(self, message: TaskQueryResultMessage) -> None:
46
+ await self._send(message.model_dump())
47
+
48
+ async def listen(self, on_task: Callable[[TaskMessage], Awaitable[None]]) -> None:
49
+ if self.websocket is None:
50
+ raise RuntimeError("connection not established")
51
+ try:
52
+ async for raw in self.websocket:
53
+ data = json.loads(raw)
54
+ if data.get("type") == "task":
55
+ await on_task(TaskMessage(**data))
56
+ except ConnectionClosed:
57
+ return
58
+
59
+ async def close(self) -> None:
60
+ if self.websocket is not None:
61
+ await self.websocket.close()
62
+ self.websocket = None
63
+
64
+ async def _send(self, payload: dict) -> None:
65
+ if self.websocket is None:
66
+ raise RuntimeError("connection not established")
67
+ await self.websocket.send(json.dumps(payload))
68
+
69
+ async def _receive(self) -> dict:
70
+ if self.websocket is None:
71
+ raise RuntimeError("connection not established")
72
+ raw = await self.websocket.recv()
73
+ return json.loads(raw)
@@ -0,0 +1,209 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import sqlite3
5
+ import time
6
+ from pathlib import Path
7
+
8
+
9
+ ACTIVE_STATUSES = {"received", "queued", "accepted", "running"}
10
+ TERMINAL_STATUSES = {"succeeded", "failed", "cancelled", "timeout"}
11
+
12
+
13
+ def _json_dumps(value) -> str | None:
14
+ if value is None:
15
+ return None
16
+ return json.dumps(value, sort_keys=True)
17
+
18
+
19
+ def _json_loads(value):
20
+ if not value:
21
+ return None
22
+ return json.loads(value)
23
+
24
+
25
+ class RuntimeJournal:
26
+ def __init__(self, path: str | Path):
27
+ self.path = Path(path)
28
+ self.path.parent.mkdir(parents=True, exist_ok=True)
29
+ self._ensure_schema()
30
+
31
+ def _connect(self) -> sqlite3.Connection:
32
+ conn = sqlite3.connect(self.path)
33
+ conn.row_factory = sqlite3.Row
34
+ conn.execute("PRAGMA foreign_keys = ON")
35
+ return conn
36
+
37
+ def _ensure_schema(self) -> None:
38
+ with self._connect() as conn:
39
+ conn.executescript(
40
+ """
41
+ CREATE TABLE IF NOT EXISTS tasks (
42
+ task_id TEXT PRIMARY KEY,
43
+ repo_id TEXT NOT NULL,
44
+ capability TEXT NOT NULL,
45
+ action TEXT NOT NULL,
46
+ status TEXT NOT NULL,
47
+ params_json TEXT NOT NULL DEFAULT '{}',
48
+ result_json TEXT,
49
+ error TEXT,
50
+ created_at REAL NOT NULL,
51
+ updated_at REAL NOT NULL,
52
+ completed_at REAL,
53
+ last_seq INTEGER NOT NULL DEFAULT 0
54
+ );
55
+
56
+ CREATE TABLE IF NOT EXISTS task_events (
57
+ task_id TEXT NOT NULL,
58
+ seq INTEGER NOT NULL,
59
+ event_type TEXT NOT NULL,
60
+ status TEXT NOT NULL,
61
+ payload_json TEXT NOT NULL DEFAULT '{}',
62
+ created_at REAL NOT NULL,
63
+ PRIMARY KEY (task_id, seq),
64
+ FOREIGN KEY (task_id) REFERENCES tasks(task_id) ON DELETE CASCADE
65
+ );
66
+ """
67
+ )
68
+
69
+ def record_task_received(self, *, task_id: str, repo_id: str, capability: str, action: str, params: dict) -> dict:
70
+ now = time.time()
71
+ with self._connect() as conn:
72
+ conn.execute(
73
+ """
74
+ INSERT OR IGNORE INTO tasks (
75
+ task_id, repo_id, capability, action, status, params_json, created_at, updated_at
76
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
77
+ """,
78
+ (task_id, repo_id, capability, action, "received", _json_dumps(params), now, now),
79
+ )
80
+ row = conn.execute("SELECT * FROM tasks WHERE task_id = ?", (task_id,)).fetchone()
81
+ return self._row_to_task(row)
82
+
83
+ def append_event(self, *, task_id: str, event_type: str, status: str, payload: dict | None = None) -> dict:
84
+ payload = payload or {}
85
+ now = time.time()
86
+ with self._connect() as conn:
87
+ row = conn.execute("SELECT * FROM tasks WHERE task_id = ?", (task_id,)).fetchone()
88
+ if row is None:
89
+ raise KeyError(f"unknown task_id: {task_id}")
90
+ seq = int(row["last_seq"]) + 1
91
+ completed_at = row["completed_at"]
92
+ if status in TERMINAL_STATUSES and completed_at is None:
93
+ completed_at = now
94
+ conn.execute(
95
+ "INSERT INTO task_events (task_id, seq, event_type, status, payload_json, created_at) VALUES (?, ?, ?, ?, ?, ?)",
96
+ (task_id, seq, event_type, status, _json_dumps(payload), now),
97
+ )
98
+ conn.execute(
99
+ "UPDATE tasks SET status = ?, updated_at = ?, completed_at = ?, last_seq = ? WHERE task_id = ?",
100
+ (status, now, completed_at, seq, task_id),
101
+ )
102
+ return {
103
+ "task_id": task_id,
104
+ "seq": seq,
105
+ "event_type": event_type,
106
+ "status": status,
107
+ "payload": payload,
108
+ "created_at": now,
109
+ }
110
+
111
+ def record_terminal_result(self, *, task_id: str, status: str, result: dict | None = None, error: str | None = None) -> dict:
112
+ if status not in TERMINAL_STATUSES:
113
+ raise ValueError(f"terminal status required, got {status}")
114
+ payload = {"result": result or {}}
115
+ if error is not None:
116
+ payload["error"] = error
117
+ event = self.append_event(task_id=task_id, event_type="result", status=status, payload=payload)
118
+ now = time.time()
119
+ with self._connect() as conn:
120
+ conn.execute(
121
+ "UPDATE tasks SET result_json = ?, error = ?, updated_at = ?, completed_at = COALESCE(completed_at, ?) WHERE task_id = ?",
122
+ (_json_dumps(result or {}), error, now, now, task_id),
123
+ )
124
+ row = conn.execute("SELECT * FROM tasks WHERE task_id = ?", (task_id,)).fetchone()
125
+ summary = self._row_to_task(row)
126
+ return {
127
+ "task_id": task_id,
128
+ "status": summary["status"],
129
+ "seq": event["seq"],
130
+ "result": summary["result"],
131
+ "error": summary["error"],
132
+ }
133
+
134
+ def get_task(self, task_id: str) -> dict | None:
135
+ with self._connect() as conn:
136
+ row = conn.execute("SELECT * FROM tasks WHERE task_id = ?", (task_id,)).fetchone()
137
+ return self._row_to_task(row)
138
+
139
+ def get_events(self, task_id: str, *, after_seq: int = 0) -> list[dict]:
140
+ with self._connect() as conn:
141
+ rows = conn.execute(
142
+ "SELECT task_id, seq, event_type, status, payload_json, created_at FROM task_events WHERE task_id = ? AND seq > ? ORDER BY seq ASC",
143
+ (task_id, after_seq),
144
+ ).fetchall()
145
+ return [self._row_to_event(row) for row in rows]
146
+
147
+ def list_active_tasks(self) -> list[dict]:
148
+ placeholders = ",".join("?" for _ in ACTIVE_STATUSES)
149
+ with self._connect() as conn:
150
+ rows = conn.execute(
151
+ f"SELECT * FROM tasks WHERE status IN ({placeholders}) ORDER BY created_at ASC",
152
+ tuple(sorted(ACTIVE_STATUSES)),
153
+ ).fetchall()
154
+ return [self._row_to_task(row) for row in rows]
155
+
156
+ def build_replay_payload(self, cursor_map: dict[str, int] | None) -> dict:
157
+ cursor_map = cursor_map or {}
158
+ active_tasks = self.list_active_tasks()
159
+ events = {}
160
+ terminal_results = []
161
+ for task_id, cursor in cursor_map.items():
162
+ summary = self.get_task(task_id)
163
+ if summary is None:
164
+ continue
165
+ replay_events = self.get_events(task_id, after_seq=int(cursor or 0))
166
+ if replay_events:
167
+ events[task_id] = replay_events
168
+ if summary["status"] in TERMINAL_STATUSES and summary["last_seq"] > int(cursor or 0):
169
+ terminal_results.append(
170
+ {
171
+ "task_id": task_id,
172
+ "repo_id": summary["repo_id"],
173
+ "capability": summary["capability"],
174
+ "action": summary["action"],
175
+ "status": summary["status"],
176
+ "seq": summary["last_seq"],
177
+ "result": summary["result"],
178
+ "error": summary["error"],
179
+ }
180
+ )
181
+ return {"active_tasks": active_tasks, "events": events, "terminal_results": terminal_results}
182
+
183
+ def _row_to_task(self, row: sqlite3.Row | None) -> dict | None:
184
+ if row is None:
185
+ return None
186
+ return {
187
+ "task_id": row["task_id"],
188
+ "repo_id": row["repo_id"],
189
+ "capability": row["capability"],
190
+ "action": row["action"],
191
+ "status": row["status"],
192
+ "params": _json_loads(row["params_json"]) or {},
193
+ "result": _json_loads(row["result_json"]) or {},
194
+ "error": row["error"],
195
+ "created_at": row["created_at"],
196
+ "updated_at": row["updated_at"],
197
+ "completed_at": row["completed_at"],
198
+ "last_seq": row["last_seq"],
199
+ }
200
+
201
+ def _row_to_event(self, row: sqlite3.Row) -> dict:
202
+ return {
203
+ "task_id": row["task_id"],
204
+ "seq": row["seq"],
205
+ "event_type": row["event_type"],
206
+ "status": row["status"],
207
+ "payload": _json_loads(row["payload_json"]) or {},
208
+ "created_at": row["created_at"],
209
+ }