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,425 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import asyncio
5
+ import json
6
+ import logging
7
+ import os
8
+ import signal
9
+ import sys
10
+ from dataclasses import asdict
11
+ from pathlib import Path
12
+ from typing import Awaitable, Callable
13
+
14
+ from bamboo_coding.client.config import AgentConfig, load_config
15
+ from bamboo_coding.client.controller_client import ControllerConnection
16
+ from bamboo_coding.client.terminal_runtime import ClientTerminalRuntime
17
+ from bamboo_coding.client.journal import RuntimeJournal
18
+ from bamboo_coding.client.status import build_register_message, build_status_message
19
+ from bamboo_coding.shared.protocol import (
20
+ TaskEventMessage,
21
+ TaskMessage,
22
+ TaskResultMessage,
23
+ TerminalCloseMessage,
24
+ TerminalInputMessage,
25
+ TerminalOpenMessage,
26
+ TerminalResizeMessage,
27
+ )
28
+
29
+
30
+ LOGGER = logging.getLogger(__name__)
31
+ INITIAL_RETRY_DELAY = 1
32
+ MAX_RETRY_DELAY = 8
33
+ TaskExecutor = Callable[[TaskMessage], Awaitable[dict]]
34
+
35
+
36
+ class ConfigError(RuntimeError):
37
+ pass
38
+
39
+
40
+ class AgentRuntime:
41
+ def __init__(self, executor: TaskExecutor, config: AgentConfig | None = None):
42
+ self.config = config or load_config()
43
+ self.executor = executor
44
+ self.journal = RuntimeJournal(self.config.journal_path)
45
+ self.connection = ControllerConnection(self.config.controller_url, token=self.config.token)
46
+ self.terminals = ClientTerminalRuntime(
47
+ send_status=self.connection.send_terminal_status,
48
+ send_output=self.connection.send_terminal_output,
49
+ send_closed=self.connection.send_terminal_closed,
50
+ send_heartbeat=self.connection.send_terminal_heartbeat,
51
+ )
52
+ self.last_registration_message = ""
53
+
54
+ def capabilities(self) -> list[dict]:
55
+ return [
56
+ {"capability": "fs.tree", "actions": ["get"]},
57
+ {"capability": "fs.browse", "actions": ["get"]},
58
+ {"capability": "fs.file.read", "actions": ["get"]},
59
+ {"capability": "fs.file.read_raw", "actions": ["get"]},
60
+ {"capability": "fs.file.write", "actions": ["put"]},
61
+ {"capability": "fs.folder.create", "actions": ["post"]},
62
+ {"capability": "fs.path.delete", "actions": ["delete"]},
63
+ {"capability": "fs.path.rename", "actions": ["put"]},
64
+ {"capability": "git.diff", "actions": ["get"]},
65
+ {"capability": "git.status", "actions": ["get"]},
66
+ {"capability": "git.commits", "actions": ["list"]},
67
+ {"capability": "git.commit_details", "actions": ["get"]},
68
+ {"capability": "git.stage", "actions": ["post"]},
69
+ {"capability": "git.unstage", "actions": ["post"]},
70
+ {"capability": "git.branches.list", "actions": ["get"]},
71
+ {"capability": "git.branches.create", "actions": ["post"]},
72
+ {"capability": "git.branches.checkout", "actions": ["put"]},
73
+ {"capability": "git.branches.delete", "actions": ["delete"]},
74
+ {"capability": "git.branches.merge", "actions": ["post"]},
75
+ {"capability": "git.commit.create", "actions": ["post"]},
76
+ {"capability": "git.remotes.list", "actions": ["get"]},
77
+ {"capability": "git.remotes.add", "actions": ["post"]},
78
+ {"capability": "git.remotes.remove", "actions": ["delete"]},
79
+ {"capability": "git.remotes.push", "actions": ["post"]},
80
+ {"capability": "git.remotes.pull", "actions": ["post"]},
81
+ {"capability": "repo.tree", "actions": ["get"]},
82
+ {"capability": "repo.browse", "actions": ["get"]},
83
+ {"capability": "repo.diff", "actions": ["get"]},
84
+ {"capability": "repo.status", "actions": ["get"]},
85
+ {"capability": "repo.commits", "actions": ["list"]},
86
+ {"capability": "repo.commit_details", "actions": ["get"]},
87
+ {"capability": "repo.file.read", "actions": ["get"]},
88
+ {"capability": "repo.file.write", "actions": ["put"]},
89
+ {"capability": "repo.stage", "actions": ["post"]},
90
+ {"capability": "repo.unstage", "actions": ["post"]},
91
+ {"capability": "repo.branches.list", "actions": ["get"]},
92
+ {"capability": "repo.branches.create", "actions": ["post"]},
93
+ {"capability": "repo.branches.checkout", "actions": ["put"]},
94
+ {"capability": "repo.branches.delete", "actions": ["delete"]},
95
+ {"capability": "repo.branches.merge", "actions": ["post"]},
96
+ {"capability": "repo.commit.create", "actions": ["post"]},
97
+ {"capability": "terminal.open", "actions": ["post"]},
98
+ {"capability": "terminal.input", "actions": ["post"]},
99
+ {"capability": "terminal.resize", "actions": ["post"]},
100
+ {"capability": "terminal.close", "actions": ["post"]},
101
+ ]
102
+
103
+ def roots(self) -> list[dict]:
104
+ return [{"path": item.path, "name": item.name} for item in self.config.repositories]
105
+
106
+ def repositories(self) -> list[dict]:
107
+ return [asdict(item) for item in self.config.repositories]
108
+
109
+ def runtime_health(self) -> tuple[int, int, int]:
110
+ active = len(self.journal.list_active_tasks())
111
+ queued = 0
112
+ return active, queued, self.config.max_concurrent_tasks
113
+
114
+ def _persist_token(self, token: str) -> None:
115
+ self.config.token_path.parent.mkdir(parents=True, exist_ok=True)
116
+ self.config.token_path.write_text(json.dumps({"token": token}), encoding="utf-8")
117
+ self.connection.token = token
118
+
119
+ def validate_config(self) -> None:
120
+ errors = validate_runtime_config(self.config)
121
+ if errors:
122
+ raise ConfigError("\n".join(errors))
123
+
124
+ def prepare_storage(self) -> None:
125
+ self.config.token_path.parent.mkdir(parents=True, exist_ok=True)
126
+ self.config.journal_path.parent.mkdir(parents=True, exist_ok=True)
127
+
128
+ async def register_once(self) -> bool:
129
+ await self.connection.connect()
130
+ active, queued, max_concurrent = self.runtime_health()
131
+ register = build_register_message(
132
+ client_id=self.config.client_id,
133
+ hostname=self.config.hostname,
134
+ version=self.config.version,
135
+ platform_name="python",
136
+ capabilities=self.capabilities(),
137
+ roots=self.roots(),
138
+ active_tasks=active,
139
+ queued_tasks=queued,
140
+ max_concurrent_tasks=max_concurrent,
141
+ connected=True,
142
+ )
143
+ registration = await self.connection.send_register(register)
144
+ self.last_registration_message = str(registration.get("message") or "")
145
+ approved = bool(registration.get("approved", False))
146
+ token = registration.get("token")
147
+ if approved and isinstance(token, str) and token:
148
+ self._persist_token(token)
149
+ return approved
150
+
151
+ async def _sleep_before_retry(self, delay: int, reason: str, stop_event: asyncio.Event | None = None) -> bool:
152
+ LOGGER.warning("%s; retrying in %ss", reason, delay)
153
+ if stop_event is None:
154
+ await asyncio.sleep(delay)
155
+ return False
156
+ try:
157
+ await asyncio.wait_for(stop_event.wait(), timeout=delay)
158
+ return True
159
+ except asyncio.TimeoutError:
160
+ return False
161
+
162
+ async def run(self, stop_event: asyncio.Event | None = None) -> None:
163
+ self.validate_config()
164
+ self.prepare_storage()
165
+ retry_delay = INITIAL_RETRY_DELAY
166
+
167
+ while True:
168
+ if stop_event is not None and stop_event.is_set():
169
+ break
170
+ try:
171
+ approved = await self.register_once()
172
+ if not approved:
173
+ await self.connection.close()
174
+ reason = self.last_registration_message or "Client approval is still pending"
175
+ if await self._sleep_before_retry(retry_delay, reason, stop_event):
176
+ break
177
+ retry_delay = min(retry_delay * 2, MAX_RETRY_DELAY)
178
+ continue
179
+ retry_delay = INITIAL_RETRY_DELAY
180
+ await self.connection.listen(self.handle_task, self.handle_terminal_message)
181
+ await self.terminals.close_all(reason="controller_disconnected")
182
+ await self.connection.close()
183
+ if stop_event is not None and stop_event.is_set():
184
+ break
185
+ if await self._sleep_before_retry(retry_delay, "Controller connection closed", stop_event):
186
+ break
187
+ retry_delay = min(retry_delay * 2, MAX_RETRY_DELAY)
188
+ except asyncio.CancelledError:
189
+ await self.terminals.close_all(reason="cancelled")
190
+ await self.connection.close()
191
+ raise
192
+ except ConnectionRefusedError as exc:
193
+ await self.terminals.close_all(reason="connection_failed")
194
+ await self.connection.close()
195
+ if await self._sleep_before_retry(retry_delay, f"Controller connection failed: {exc}", stop_event):
196
+ break
197
+ retry_delay = min(retry_delay * 2, MAX_RETRY_DELAY)
198
+ except Exception as exc:
199
+ await self.terminals.close_all(reason="runtime_error")
200
+ await self.connection.close()
201
+ if await self._sleep_before_retry(retry_delay, f"Client runtime error: {exc}", stop_event):
202
+ break
203
+ retry_delay = min(retry_delay * 2, MAX_RETRY_DELAY)
204
+
205
+ await self.terminals.close_all(reason="stopped")
206
+ await self.connection.close()
207
+
208
+ async def handle_terminal_message(
209
+ self,
210
+ message: TerminalOpenMessage | TerminalInputMessage | TerminalResizeMessage | TerminalCloseMessage,
211
+ ) -> None:
212
+ if isinstance(message, TerminalOpenMessage):
213
+ await self.terminals.open_terminal(message)
214
+ elif isinstance(message, TerminalInputMessage):
215
+ await self.terminals.input_terminal(message)
216
+ elif isinstance(message, TerminalResizeMessage):
217
+ await self.terminals.resize_terminal(message.terminal_id, cols=message.cols, rows=message.rows)
218
+ elif isinstance(message, TerminalCloseMessage):
219
+ await self.terminals.close_terminal(message.terminal_id, reason=message.reason or "controller_closed")
220
+
221
+ async def handle_task(self, task: TaskMessage) -> None:
222
+ self.journal.record_task_received(
223
+ task_id=task.task_id,
224
+ repo_id=task.repo_id,
225
+ capability=task.capability,
226
+ action=task.action,
227
+ params=task.params,
228
+ )
229
+ self.journal.append_event(task_id=task.task_id, event_type="accepted", status="running", payload={"message": "started"})
230
+ await self.connection.send_event(TaskEventMessage(task_id=task.task_id, status="running", message="started"))
231
+ try:
232
+ result = await self.executor(task)
233
+ self.journal.record_terminal_result(task_id=task.task_id, status="succeeded", result=result)
234
+ await self.connection.send_result(TaskResultMessage(task_id=task.task_id, success=True, result=result))
235
+ except Exception as exc:
236
+ self.journal.record_terminal_result(task_id=task.task_id, status="failed", error=str(exc))
237
+ await self.connection.send_result(TaskResultMessage(task_id=task.task_id, success=False, error=str(exc)))
238
+ active, queued, max_concurrent = self.runtime_health()
239
+ await self.connection.send_status(
240
+ build_status_message(
241
+ client_id=self.config.client_id,
242
+ hostname=self.config.hostname,
243
+ version=self.config.version,
244
+ platform_name="python",
245
+ reason="task_update",
246
+ capabilities=self.capabilities(),
247
+ roots=self.roots(),
248
+ active_tasks=active,
249
+ queued_tasks=queued,
250
+ max_concurrent_tasks=max_concurrent,
251
+ connected=True,
252
+ )
253
+ )
254
+
255
+
256
+ def validate_runtime_config(config: AgentConfig) -> list[str]:
257
+ errors: list[str] = []
258
+ if not config.config_path.exists():
259
+ errors.append(f"Config file not found at {config.config_path}. Run `bamboo-coding setup` first.")
260
+ if not config.allowed_roots:
261
+ errors.append(
262
+ f"No allowed roots configured in {config.config_path}. Configure [client].allowed_roots or run `bamboo-coding setup`."
263
+ )
264
+ return errors
265
+
266
+
267
+ def _display_path(path: Path) -> str:
268
+ resolved = path.expanduser().resolve()
269
+ home = Path.home().expanduser().resolve()
270
+ try:
271
+ relative = resolved.relative_to(home)
272
+ except ValueError:
273
+ return str(resolved)
274
+ return f"~/{relative.as_posix()}"
275
+
276
+
277
+ def _toml_string(value: str) -> str:
278
+ return json.dumps(value)
279
+
280
+
281
+ def _render_config_text(
282
+ *,
283
+ controller_url: str,
284
+ client_id: str,
285
+ hostname: str,
286
+ allowed_roots: list[str],
287
+ max_concurrent_tasks: int,
288
+ token_path: Path,
289
+ journal_path: Path,
290
+ ) -> str:
291
+ roots_value = ", ".join(_toml_string(root) for root in allowed_roots)
292
+ return "\n".join(
293
+ [
294
+ "[client]",
295
+ f"controller_url = {_toml_string(controller_url)}",
296
+ f"client_id = {_toml_string(client_id)}",
297
+ f"hostname = {_toml_string(hostname)}",
298
+ f"allowed_roots = [{roots_value}]",
299
+ f"max_concurrent_tasks = {max_concurrent_tasks}",
300
+ "",
301
+ "[storage]",
302
+ f"token_path = {_toml_string(_display_path(token_path))}",
303
+ f"journal_path = {_toml_string(_display_path(journal_path))}",
304
+ "",
305
+ ]
306
+ )
307
+
308
+
309
+ def build_parser() -> argparse.ArgumentParser:
310
+ parser = argparse.ArgumentParser(description="Bamboo Coding client")
311
+ parser.add_argument("--config", help="Path to bamboo-coding.toml")
312
+ subparsers = parser.add_subparsers(dest="command")
313
+
314
+ setup_parser = subparsers.add_parser("setup", help="Create the default Bamboo Coding client config")
315
+ setup_parser.add_argument("--controller-url", help="Controller websocket URL")
316
+ setup_parser.add_argument("--root", action="append", dest="roots", help="Allowed root path; pass more than once for multiple roots")
317
+ setup_parser.add_argument("--client-id", help="Client identifier advertised to the controller")
318
+ setup_parser.add_argument("--hostname", help="Hostname advertised to the controller")
319
+ setup_parser.add_argument("--force", action="store_true", help="Overwrite the config file if it already exists")
320
+
321
+ subparsers.add_parser("run", help="Run the Bamboo Coding client")
322
+ return parser
323
+
324
+
325
+ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
326
+ parser = build_parser()
327
+ args = parser.parse_args(argv)
328
+ if args.command is None:
329
+ args.command = "run"
330
+ return args
331
+
332
+
333
+ def setup_command(args: argparse.Namespace) -> int:
334
+ config = load_config()
335
+ config_path = config.config_path
336
+
337
+ if config_path.exists() and not args.force:
338
+ print(f"Config already exists at {config_path}. Use --force to overwrite it.", file=sys.stderr)
339
+ return 1
340
+
341
+ roots = [str(Path(root).expanduser().resolve()) for root in (args.roots or list(config.allowed_roots) or [str(Path.cwd().resolve())])]
342
+ client_id = args.client_id or config.client_id
343
+ hostname = args.hostname or config.hostname or client_id
344
+ controller_url = args.controller_url or config.controller_url
345
+ config_path.parent.mkdir(parents=True, exist_ok=True)
346
+ config_path.write_text(
347
+ _render_config_text(
348
+ controller_url=controller_url,
349
+ client_id=client_id,
350
+ hostname=hostname,
351
+ allowed_roots=roots,
352
+ max_concurrent_tasks=config.max_concurrent_tasks,
353
+ token_path=config.token_path,
354
+ journal_path=config.journal_path,
355
+ ),
356
+ encoding="utf-8",
357
+ )
358
+ print(f"Wrote config to {config_path}")
359
+ print("Next: bamboo-coding run")
360
+ return 0
361
+
362
+
363
+ async def _run_runtime(runtime: AgentRuntime) -> None:
364
+ stop_event = asyncio.Event()
365
+ loop = asyncio.get_running_loop()
366
+ registered_signals: list[signal.Signals] = []
367
+
368
+ for signum in (signal.SIGINT, signal.SIGTERM):
369
+ try:
370
+ loop.add_signal_handler(signum, stop_event.set)
371
+ registered_signals.append(signum)
372
+ except (NotImplementedError, RuntimeError):
373
+ continue
374
+
375
+ try:
376
+ await runtime.run(stop_event=stop_event)
377
+ finally:
378
+ for signum in registered_signals:
379
+ try:
380
+ loop.remove_signal_handler(signum)
381
+ except RuntimeError:
382
+ continue
383
+
384
+
385
+ def run_command(_args: argparse.Namespace) -> int:
386
+ logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
387
+
388
+ from bamboo_coding.client.capabilities.repo import execute_task
389
+
390
+ config = load_config()
391
+ errors = validate_runtime_config(config)
392
+ if errors:
393
+ for error in errors:
394
+ print(error, file=sys.stderr)
395
+ return 1
396
+
397
+ repositories = {item.repo_id: item.path for item in config.repositories}
398
+
399
+ async def _execute(task: TaskMessage) -> dict:
400
+ return execute_task(task, repositories)
401
+
402
+ runtime = AgentRuntime(_execute, config=config)
403
+ try:
404
+ asyncio.run(_run_runtime(runtime))
405
+ except KeyboardInterrupt:
406
+ LOGGER.info("Client interrupted")
407
+ return 0
408
+
409
+
410
+ def main(argv: list[str] | None = None) -> None:
411
+ args = parse_args(argv)
412
+ if args.config:
413
+ os.environ["BAMBOO_CODING_CONFIG_PATH"] = args.config
414
+
415
+ if args.command == "setup":
416
+ code = setup_command(args)
417
+ else:
418
+ code = run_command(args)
419
+
420
+ if code:
421
+ raise SystemExit(code)
422
+
423
+
424
+ if __name__ == "__main__":
425
+ main()
@@ -0,0 +1,59 @@
1
+ from __future__ import annotations
2
+
3
+ from bamboo_coding.shared.protocol import AgentRegisterMessage, ClientStatusMessage
4
+
5
+
6
+ def _build_common_payload(*, client_id: str, hostname: str, version: str, platform_name: str, capabilities: list[dict], roots: list[dict] | None, repositories: list[dict] | None, active_tasks: int, queued_tasks: int, max_concurrent_tasks: int, connected: bool) -> dict:
7
+ root_payload = roots if roots is not None else (repositories or [])
8
+ return {
9
+ "protocol_version": 2,
10
+ "client_id": client_id,
11
+ "client_info": {
12
+ "hostname": hostname,
13
+ "version": version,
14
+ "platform": platform_name,
15
+ },
16
+ "capabilities": capabilities,
17
+ "roots": root_payload,
18
+ "connectivity": {"controller": {"connected": connected}},
19
+ "runtime_health": {
20
+ "active_tasks": active_tasks,
21
+ "queued_tasks": queued_tasks,
22
+ "max_concurrent_tasks": max_concurrent_tasks,
23
+ },
24
+ }
25
+
26
+
27
+ def build_register_message(*, client_id: str, hostname: str, version: str, platform_name: str, capabilities: list[dict], roots: list[dict] | None = None, repositories: list[dict] | None = None, active_tasks: int, queued_tasks: int, max_concurrent_tasks: int, connected: bool = True) -> AgentRegisterMessage:
28
+ payload = _build_common_payload(
29
+ client_id=client_id,
30
+ hostname=hostname,
31
+ version=version,
32
+ platform_name=platform_name,
33
+ capabilities=capabilities,
34
+ roots=roots,
35
+ repositories=repositories,
36
+ active_tasks=active_tasks,
37
+ queued_tasks=queued_tasks,
38
+ max_concurrent_tasks=max_concurrent_tasks,
39
+ connected=connected,
40
+ )
41
+ return AgentRegisterMessage(**payload)
42
+
43
+
44
+ def build_status_message(*, client_id: str, hostname: str, version: str, platform_name: str, reason: str, capabilities: list[dict], roots: list[dict] | None = None, repositories: list[dict] | None = None, active_tasks: int, queued_tasks: int, max_concurrent_tasks: int, connected: bool) -> ClientStatusMessage:
45
+ payload = _build_common_payload(
46
+ client_id=client_id,
47
+ hostname=hostname,
48
+ version=version,
49
+ platform_name=platform_name,
50
+ capabilities=capabilities,
51
+ roots=roots,
52
+ repositories=repositories,
53
+ active_tasks=active_tasks,
54
+ queued_tasks=queued_tasks,
55
+ max_concurrent_tasks=max_concurrent_tasks,
56
+ connected=connected,
57
+ )
58
+ payload["reason"] = reason
59
+ return ClientStatusMessage(**payload)