devops-bot-sdk 1.4.158__tar.gz → 1.4.159__tar.gz

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 (105) hide show
  1. {devops_bot_sdk-1.4.158 → devops_bot_sdk-1.4.159}/PKG-INFO +2 -1
  2. {devops_bot_sdk-1.4.158 → devops_bot_sdk-1.4.159}/devops_bot_sdk.egg-info/PKG-INFO +2 -1
  3. {devops_bot_sdk-1.4.158 → devops_bot_sdk-1.4.159}/devops_bot_sdk.egg-info/SOURCES.txt +5 -1
  4. {devops_bot_sdk-1.4.158 → devops_bot_sdk-1.4.159}/devops_bot_sdk.egg-info/requires.txt +1 -0
  5. {devops_bot_sdk-1.4.158 → devops_bot_sdk-1.4.159}/pyproject.toml +5 -0
  6. {devops_bot_sdk-1.4.158 → devops_bot_sdk-1.4.159}/sdk/__init__.py +1 -1
  7. {devops_bot_sdk-1.4.158 → devops_bot_sdk-1.4.159}/sdk/agentd.py +18 -0
  8. devops_bot_sdk-1.4.159/sdk/agents/activity.py +110 -0
  9. {devops_bot_sdk-1.4.158 → devops_bot_sdk-1.4.159}/sdk/cli.py +12 -1
  10. {devops_bot_sdk-1.4.158 → devops_bot_sdk-1.4.159}/sdk/client.py +29 -0
  11. {devops_bot_sdk-1.4.158 → devops_bot_sdk-1.4.159}/sdk/ui/api.py +41 -22
  12. {devops_bot_sdk-1.4.158 → devops_bot_sdk-1.4.159}/sdk/ui/server.py +34 -1
  13. devops_bot_sdk-1.4.159/sdk/vps_health.py +264 -0
  14. devops_bot_sdk-1.4.159/tests/test_agent_activity.py +93 -0
  15. {devops_bot_sdk-1.4.158 → devops_bot_sdk-1.4.159}/tests/test_otp_login.py +8 -0
  16. devops_bot_sdk-1.4.159/tests/test_vps_health.py +209 -0
  17. {devops_bot_sdk-1.4.158 → devops_bot_sdk-1.4.159}/README.md +0 -0
  18. {devops_bot_sdk-1.4.158 → devops_bot_sdk-1.4.159}/devops_bot_sdk.egg-info/dependency_links.txt +0 -0
  19. {devops_bot_sdk-1.4.158 → devops_bot_sdk-1.4.159}/devops_bot_sdk.egg-info/entry_points.txt +0 -0
  20. {devops_bot_sdk-1.4.158 → devops_bot_sdk-1.4.159}/devops_bot_sdk.egg-info/top_level.txt +0 -0
  21. {devops_bot_sdk-1.4.158 → devops_bot_sdk-1.4.159}/sdk/agents/__init__.py +0 -0
  22. {devops_bot_sdk-1.4.158 → devops_bot_sdk-1.4.159}/sdk/agents/builder.py +0 -0
  23. {devops_bot_sdk-1.4.158 → devops_bot_sdk-1.4.159}/sdk/agents/chat.py +0 -0
  24. {devops_bot_sdk-1.4.158 → devops_bot_sdk-1.4.159}/sdk/agents/connectors.py +0 -0
  25. {devops_bot_sdk-1.4.158 → devops_bot_sdk-1.4.159}/sdk/agents/engines/__init__.py +0 -0
  26. {devops_bot_sdk-1.4.158 → devops_bot_sdk-1.4.159}/sdk/agents/engines/claude_code.py +0 -0
  27. {devops_bot_sdk-1.4.158 → devops_bot_sdk-1.4.159}/sdk/agents/engines/http.py +0 -0
  28. {devops_bot_sdk-1.4.158 → devops_bot_sdk-1.4.159}/sdk/agents/engines/mcp.py +0 -0
  29. {devops_bot_sdk-1.4.158 → devops_bot_sdk-1.4.159}/sdk/agents/engines/n8n.py +0 -0
  30. {devops_bot_sdk-1.4.158 → devops_bot_sdk-1.4.159}/sdk/agents/engines/shell.py +0 -0
  31. {devops_bot_sdk-1.4.158 → devops_bot_sdk-1.4.159}/sdk/agents/integrations.py +0 -0
  32. {devops_bot_sdk-1.4.158 → devops_bot_sdk-1.4.159}/sdk/agents/inventory.py +0 -0
  33. {devops_bot_sdk-1.4.158 → devops_bot_sdk-1.4.159}/sdk/agents/library/__init__.py +0 -0
  34. {devops_bot_sdk-1.4.158 → devops_bot_sdk-1.4.159}/sdk/agents/manifest.py +0 -0
  35. {devops_bot_sdk-1.4.158 → devops_bot_sdk-1.4.159}/sdk/agents/prompt_studio.py +0 -0
  36. {devops_bot_sdk-1.4.158 → devops_bot_sdk-1.4.159}/sdk/agents/prompt_template.py +0 -0
  37. {devops_bot_sdk-1.4.158 → devops_bot_sdk-1.4.159}/sdk/agents/prompt_vars.py +0 -0
  38. {devops_bot_sdk-1.4.158 → devops_bot_sdk-1.4.159}/sdk/agents/run_log.py +0 -0
  39. {devops_bot_sdk-1.4.158 → devops_bot_sdk-1.4.159}/sdk/agents/secrets.py +0 -0
  40. {devops_bot_sdk-1.4.158 → devops_bot_sdk-1.4.159}/sdk/agents/ticket_run.py +0 -0
  41. {devops_bot_sdk-1.4.158 → devops_bot_sdk-1.4.159}/sdk/agents/usage.py +0 -0
  42. {devops_bot_sdk-1.4.158 → devops_bot_sdk-1.4.159}/sdk/agents/varstore.py +0 -0
  43. {devops_bot_sdk-1.4.158 → devops_bot_sdk-1.4.159}/sdk/collectors/__init__.py +0 -0
  44. {devops_bot_sdk-1.4.158 → devops_bot_sdk-1.4.159}/sdk/collectors/files.py +0 -0
  45. {devops_bot_sdk-1.4.158 → devops_bot_sdk-1.4.159}/sdk/collectors/process.py +0 -0
  46. {devops_bot_sdk-1.4.158 → devops_bot_sdk-1.4.159}/sdk/collectors/screenshot.py +0 -0
  47. {devops_bot_sdk-1.4.158 → devops_bot_sdk-1.4.159}/sdk/config.py +0 -0
  48. {devops_bot_sdk-1.4.158 → devops_bot_sdk-1.4.159}/sdk/crucial.py +0 -0
  49. {devops_bot_sdk-1.4.158 → devops_bot_sdk-1.4.159}/sdk/design_verify/__init__.py +0 -0
  50. {devops_bot_sdk-1.4.158 → devops_bot_sdk-1.4.159}/sdk/design_verify/assets.py +0 -0
  51. {devops_bot_sdk-1.4.158 → devops_bot_sdk-1.4.159}/sdk/design_verify/bootstrap.py +0 -0
  52. {devops_bot_sdk-1.4.158 → devops_bot_sdk-1.4.159}/sdk/design_verify/browser.py +0 -0
  53. {devops_bot_sdk-1.4.158 → devops_bot_sdk-1.4.159}/sdk/design_verify/compare.py +0 -0
  54. {devops_bot_sdk-1.4.158 → devops_bot_sdk-1.4.159}/sdk/design_verify/loop.py +0 -0
  55. {devops_bot_sdk-1.4.158 → devops_bot_sdk-1.4.159}/sdk/exceptions.py +0 -0
  56. {devops_bot_sdk-1.4.158 → devops_bot_sdk-1.4.159}/sdk/git_ops.py +0 -0
  57. {devops_bot_sdk-1.4.158 → devops_bot_sdk-1.4.159}/sdk/graphify.py +0 -0
  58. {devops_bot_sdk-1.4.158 → devops_bot_sdk-1.4.159}/sdk/hooks/__init__.py +0 -0
  59. {devops_bot_sdk-1.4.158 → devops_bot_sdk-1.4.159}/sdk/hooks/crucial_guard.py +0 -0
  60. {devops_bot_sdk-1.4.158 → devops_bot_sdk-1.4.159}/sdk/ipc/__init__.py +0 -0
  61. {devops_bot_sdk-1.4.158 → devops_bot_sdk-1.4.159}/sdk/ipc/electron_bridge.py +0 -0
  62. {devops_bot_sdk-1.4.158 → devops_bot_sdk-1.4.159}/sdk/ipc/handlers.py +0 -0
  63. {devops_bot_sdk-1.4.158 → devops_bot_sdk-1.4.159}/sdk/local_exec.py +0 -0
  64. {devops_bot_sdk-1.4.158 → devops_bot_sdk-1.4.159}/sdk/models/__init__.py +0 -0
  65. {devops_bot_sdk-1.4.158 → devops_bot_sdk-1.4.159}/sdk/models/envelope.py +0 -0
  66. {devops_bot_sdk-1.4.158 → devops_bot_sdk-1.4.159}/sdk/models/requests.py +0 -0
  67. {devops_bot_sdk-1.4.158 → devops_bot_sdk-1.4.159}/sdk/models/responses.py +0 -0
  68. {devops_bot_sdk-1.4.158 → devops_bot_sdk-1.4.159}/sdk/models/snapshots.py +0 -0
  69. {devops_bot_sdk-1.4.158 → devops_bot_sdk-1.4.159}/sdk/platform_compat.py +0 -0
  70. {devops_bot_sdk-1.4.158 → devops_bot_sdk-1.4.159}/sdk/py.typed +0 -0
  71. {devops_bot_sdk-1.4.158 → devops_bot_sdk-1.4.159}/sdk/run_auto.py +0 -0
  72. {devops_bot_sdk-1.4.158 → devops_bot_sdk-1.4.159}/sdk/sse.py +0 -0
  73. {devops_bot_sdk-1.4.158 → devops_bot_sdk-1.4.159}/sdk/test.py +0 -0
  74. {devops_bot_sdk-1.4.158 → devops_bot_sdk-1.4.159}/sdk/test_pipeline.py +0 -0
  75. {devops_bot_sdk-1.4.158 → devops_bot_sdk-1.4.159}/sdk/ui/__init__.py +0 -0
  76. {devops_bot_sdk-1.4.158 → devops_bot_sdk-1.4.159}/sdk/ui/dashboard.py +0 -0
  77. {devops_bot_sdk-1.4.158 → devops_bot_sdk-1.4.159}/sdk/ui/health.py +0 -0
  78. {devops_bot_sdk-1.4.158 → devops_bot_sdk-1.4.159}/sdk/ui/ip_allowlist.py +0 -0
  79. {devops_bot_sdk-1.4.158 → devops_bot_sdk-1.4.159}/sdk/ui/jobs.py +0 -0
  80. {devops_bot_sdk-1.4.158 → devops_bot_sdk-1.4.159}/sdk/ui/models.py +0 -0
  81. {devops_bot_sdk-1.4.158 → devops_bot_sdk-1.4.159}/sdk/ui/runner.py +0 -0
  82. {devops_bot_sdk-1.4.158 → devops_bot_sdk-1.4.159}/sdk/ui/security.py +0 -0
  83. {devops_bot_sdk-1.4.158 → devops_bot_sdk-1.4.159}/sdk/ui/service.py +0 -0
  84. {devops_bot_sdk-1.4.158 → devops_bot_sdk-1.4.159}/sdk/ui/session.py +0 -0
  85. {devops_bot_sdk-1.4.158 → devops_bot_sdk-1.4.159}/sdk/ui/setup_manager.py +0 -0
  86. {devops_bot_sdk-1.4.158 → devops_bot_sdk-1.4.159}/sdk/ui/static/agents.html +0 -0
  87. {devops_bot_sdk-1.4.158 → devops_bot_sdk-1.4.159}/sdk/ui/static/app.js +0 -0
  88. {devops_bot_sdk-1.4.158 → devops_bot_sdk-1.4.159}/sdk/ui/static/custom-agents.html +0 -0
  89. {devops_bot_sdk-1.4.158 → devops_bot_sdk-1.4.159}/sdk/ui/static/default-agents.html +0 -0
  90. {devops_bot_sdk-1.4.158 → devops_bot_sdk-1.4.159}/sdk/ui/static/index.html +0 -0
  91. {devops_bot_sdk-1.4.158 → devops_bot_sdk-1.4.159}/sdk/ui/static/login.html +0 -0
  92. {devops_bot_sdk-1.4.158 → devops_bot_sdk-1.4.159}/sdk/ui/static/runs.html +0 -0
  93. {devops_bot_sdk-1.4.158 → devops_bot_sdk-1.4.159}/sdk/ui/static/setup.html +0 -0
  94. {devops_bot_sdk-1.4.158 → devops_bot_sdk-1.4.159}/sdk/ui/static/styles.css +0 -0
  95. {devops_bot_sdk-1.4.158 → devops_bot_sdk-1.4.159}/sdk/ui/static/tickets.html +0 -0
  96. {devops_bot_sdk-1.4.158 → devops_bot_sdk-1.4.159}/sdk/updater.py +0 -0
  97. {devops_bot_sdk-1.4.158 → devops_bot_sdk-1.4.159}/setup.cfg +0 -0
  98. {devops_bot_sdk-1.4.158 → devops_bot_sdk-1.4.159}/tests/test_agent_chat_features.py +0 -0
  99. {devops_bot_sdk-1.4.158 → devops_bot_sdk-1.4.159}/tests/test_agent_prompt_async.py +0 -0
  100. {devops_bot_sdk-1.4.158 → devops_bot_sdk-1.4.159}/tests/test_agent_prompt_models.py +0 -0
  101. {devops_bot_sdk-1.4.158 → devops_bot_sdk-1.4.159}/tests/test_agent_run_ticket.py +0 -0
  102. {devops_bot_sdk-1.4.158 → devops_bot_sdk-1.4.159}/tests/test_agents_connectors_builder.py +0 -0
  103. {devops_bot_sdk-1.4.158 → devops_bot_sdk-1.4.159}/tests/test_git_action.py +0 -0
  104. {devops_bot_sdk-1.4.158 → devops_bot_sdk-1.4.159}/tests/test_health.py +0 -0
  105. {devops_bot_sdk-1.4.158 → devops_bot_sdk-1.4.159}/tests/test_updater_extras.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devops-bot-sdk
3
- Version: 1.4.158
3
+ Version: 1.4.159
4
4
  Summary: DevOps Bot Desktop SDK — thin client for the AgentOS Electron desktop app
5
5
  Author: noumanaziz2128
6
6
  License-Expression: LicenseRef-Proprietary
@@ -20,6 +20,7 @@ Requires-Dist: psutil>=5.9; extra == "process"
20
20
  Provides-Extra: ui
21
21
  Requires-Dist: fastapi>=0.110; extra == "ui"
22
22
  Requires-Dist: uvicorn>=0.29; extra == "ui"
23
+ Requires-Dist: psutil>=5.9; extra == "ui"
23
24
  Provides-Extra: all
24
25
  Requires-Dist: Pillow>=10.0; extra == "all"
25
26
  Requires-Dist: psutil>=5.9; extra == "all"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devops-bot-sdk
3
- Version: 1.4.158
3
+ Version: 1.4.159
4
4
  Summary: DevOps Bot Desktop SDK — thin client for the AgentOS Electron desktop app
5
5
  Author: noumanaziz2128
6
6
  License-Expression: LicenseRef-Proprietary
@@ -20,6 +20,7 @@ Requires-Dist: psutil>=5.9; extra == "process"
20
20
  Provides-Extra: ui
21
21
  Requires-Dist: fastapi>=0.110; extra == "ui"
22
22
  Requires-Dist: uvicorn>=0.29; extra == "ui"
23
+ Requires-Dist: psutil>=5.9; extra == "ui"
23
24
  Provides-Extra: all
24
25
  Requires-Dist: Pillow>=10.0; extra == "all"
25
26
  Requires-Dist: psutil>=5.9; extra == "all"
@@ -23,7 +23,9 @@ sdk/sse.py
23
23
  sdk/test.py
24
24
  sdk/test_pipeline.py
25
25
  sdk/updater.py
26
+ sdk/vps_health.py
26
27
  sdk/agents/__init__.py
28
+ sdk/agents/activity.py
27
29
  sdk/agents/builder.py
28
30
  sdk/agents/chat.py
29
31
  sdk/agents/connectors.py
@@ -88,6 +90,7 @@ sdk/ui/static/runs.html
88
90
  sdk/ui/static/setup.html
89
91
  sdk/ui/static/styles.css
90
92
  sdk/ui/static/tickets.html
93
+ tests/test_agent_activity.py
91
94
  tests/test_agent_chat_features.py
92
95
  tests/test_agent_prompt_async.py
93
96
  tests/test_agent_prompt_models.py
@@ -96,4 +99,5 @@ tests/test_agents_connectors_builder.py
96
99
  tests/test_git_action.py
97
100
  tests/test_health.py
98
101
  tests/test_otp_login.py
99
- tests/test_updater_extras.py
102
+ tests/test_updater_extras.py
103
+ tests/test_vps_health.py
@@ -19,3 +19,4 @@ Pillow>=10.0
19
19
  [ui]
20
20
  fastapi>=0.110
21
21
  uvicorn>=0.29
22
+ psutil>=5.9
@@ -31,6 +31,11 @@ process = [
31
31
  ui = [
32
32
  "fastapi>=0.110", # sdk/ui — control-panel web app
33
33
  "uvicorn>=0.29", # ASGI server for `agentos-ui`
34
+ "psutil>=5.9", # sdk/ui/health.py — CPU/RAM/disk/uptime for GET /api/ui/health
35
+ # AND the 2-hourly allocation PATCH (sdk/vps_health.py). The
36
+ # bootstrap installs `[ui]` only, so without it here every
37
+ # VPS reports "resources.available: False" and the health
38
+ # cron can only send status + IP.
34
39
  ]
35
40
  all = [
36
41
  "Pillow>=10.0",
@@ -30,7 +30,7 @@ Rules:
30
30
  - All data egress through submit_webhook only
31
31
  """
32
32
 
33
- __version__ = "1.4.158" # SINGLE SOURCE OF TRUTH — bump on every change; pyproject,
33
+ __version__ = "1.4.159" # SINGLE SOURCE OF TRUTH — bump on every change; pyproject,
34
34
  __author__ = "AgentOS" # sdk.client.SDK_VERSION and the UI server version all read this.
35
35
 
36
36
  from sdk.client import BackendClient
@@ -85,6 +85,7 @@ from typing import TYPE_CHECKING, Any
85
85
  from apscheduler.schedulers.asyncio import AsyncIOScheduler
86
86
  from apscheduler.triggers.cron import CronTrigger
87
87
 
88
+ from sdk.agents import activity
88
89
  from sdk.client import BackendClient
89
90
  from sdk.models.requests import OrchestrateRequest
90
91
 
@@ -354,6 +355,9 @@ async def _dispatch_agent_run(
354
355
 
355
356
  policy = manifest.approval.policy
356
357
  handled: set[str] = set() # session/task ids already round-tripped by this dispatch
358
+ # How this run was triggered — carried into the `agent_activities` line so the
359
+ # feed distinguishes a cron firing from a ticket trigger from a human "Run".
360
+ _activity_kind = "run-ticket" if auto_approve else ("ticket_label" if task else "scheduled")
357
361
 
358
362
  async def _on_status_update(status: str, state: dict) -> None:
359
363
  if status != "awaiting_approval":
@@ -402,11 +406,21 @@ async def _dispatch_agent_run(
402
406
  async def _tee(ev: dict) -> None:
403
407
  run_log.log_event(_log_path, ev)
404
408
 
409
+ _started = time.monotonic()
405
410
  local_result = await claude_code_engine.run(
406
411
  manifest, skill_text, mcp_config, extra_env=effective_secrets or None,
407
412
  on_event=_tee,
408
413
  )
409
414
  run_log.finish_run(_log_path, local_result)
415
+ # Local ledger + platform `agent_activities` feed. EVERY trigger path
416
+ # reaches this function (cron schedule, ticket label, on-demand
417
+ # run-on-ticket from the UI / CLI / desktop IPC), so a VPS agent run is
418
+ # recorded exactly once regardless of origin — see sdk.agents.activity.
419
+ await activity.record_run(
420
+ manifest.name, _activity_kind, local_result,
421
+ time.monotonic() - _started, client=client,
422
+ detail=f"task {task_id}" if task_id else "",
423
+ )
410
424
  outcome.update({
411
425
  "task_id": task_id,
412
426
  "result": local_result.get("result", ""),
@@ -418,6 +432,10 @@ async def _dispatch_agent_run(
418
432
  manifest.name, task_id, exc,
419
433
  )
420
434
  outcome["error"] = str(exc)
435
+ await activity.record_run(
436
+ manifest.name, _activity_kind, {"ok": False, "error": str(exc)},
437
+ 0.0, client=client, detail=f"task {task_id}" if task_id else "",
438
+ )
421
439
  return
422
440
 
423
441
  # FINALIZE — same field names sdk.local_exec.run_claude_local's result
@@ -0,0 +1,110 @@
1
+ """Agent-activity reporting — the ONE place a custom / department agent run is
2
+ recorded, for both sinks:
3
+
4
+ * the LOCAL usage ledger (``sdk.agents.usage`` → ``~/.agentos/usage.jsonl``),
5
+ which backs ``GET /api/ui/agents/usage`` and, through it, the AI backend
6
+ report's "VPS Agent Logs" section, and
7
+ * the PLATFORM activity feed — ``POST {ML_GATEWAY}/ml-api/agent-activity``
8
+ with ``{activity, agentId}``, which the ML backend writes into the Postgres
9
+ ``agent_activities`` table (docs/DATABASE_SCHEMA.md #3: ``user_id``,
10
+ ``activity``, ``agent_id``) and the AI backend reads back RBAC-scoped for
11
+ reporting + chat.
12
+
13
+ Every VPS-side agent run goes through here no matter how it was triggered — the
14
+ control panel (chat / run / run-on-ticket), an ``agentd`` cron schedule, an
15
+ ``agentd`` ticket-label trigger, the CLI, or the desktop IPC bridge. Before this
16
+ module only the control-panel paths emitted, so scheduled and ticket-triggered
17
+ runs never reached ``agent_activities``.
18
+
19
+ ``agent_activities.agent_id`` is an FK onto ``agents.id``, so only agents that
20
+ exist in the BACKEND emit — ``BackendClient.resolve_agent_id`` returns None for
21
+ a local-only manifest and the emit is skipped (the local ledger still records
22
+ it). Both custom and department-default agents resolve, so both are tracked.
23
+
24
+ Best-effort throughout: nothing in here raises into a run.
25
+ """
26
+ from __future__ import annotations
27
+
28
+ import logging
29
+ from typing import TYPE_CHECKING, Any
30
+
31
+ if TYPE_CHECKING: # pragma: no cover — typing only
32
+ from sdk.client import BackendClient
33
+
34
+ logger = logging.getLogger("sdk.agents.activity")
35
+
36
+ # How a run reached us → the verb that opens the activity line. Keep these short:
37
+ # the feed is rendered as one line per row in reports and in chat answers.
38
+ _VERB = {
39
+ "chat": "Chatted with",
40
+ "run": "Ran",
41
+ "run-ticket": "Ran",
42
+ "scheduled": "Ran (schedule)",
43
+ "ticket_label": "Ran (ticket trigger)",
44
+ "cli": "Ran (cli)",
45
+ }
46
+
47
+
48
+ def describe(name: str, kind: str, result: dict | None, duration_s: float = 0.0,
49
+ detail: str = "") -> str:
50
+ """The human-readable ``activity`` string stored in ``agent_activities``."""
51
+ res = result or {}
52
+ verb = _VERB.get(kind, "Ran")
53
+ status = "ok" if res.get("ok") else "error"
54
+ line = f"{verb} agent '{name}' — {status}"
55
+ if duration_s:
56
+ line += f" ({round(float(duration_s), 1)}s)"
57
+ err = str(res.get("error") or "").strip()
58
+ if status == "error" and err:
59
+ line += f": {err[:200]}"
60
+ if detail:
61
+ line += f" — {detail}"
62
+ return line
63
+
64
+
65
+ async def emit(name: str, kind: str, result: dict | None, duration_s: float = 0.0,
66
+ *, client: "BackendClient | None" = None, detail: str = "") -> bool:
67
+ """Emit ONE ``{activity, agentId}`` event for a finished agent run.
68
+
69
+ Returns True when the webhook accepted it, False otherwise (unknown agent,
70
+ no token, backend down) — never raises.
71
+ """
72
+ try:
73
+ if client is None:
74
+ from sdk.client import BackendClient
75
+ client = BackendClient.from_config()
76
+ agent_id = await client.resolve_agent_id(name)
77
+ if not agent_id: # local-only agent → not tracked
78
+ return False
79
+ return await client.log_agent_activity(
80
+ describe(name, kind, result, duration_s, detail), agent_id,
81
+ )
82
+ except Exception as exc: # noqa: BLE001 — activity emit must never break a run
83
+ logger.debug("activity emit failed agent=%s err=%s", name, exc)
84
+ return False
85
+
86
+
87
+ async def record_run(name: str, kind: str, result: dict | None, duration_s: float = 0.0,
88
+ *, client: "BackendClient | None" = None, detail: str = "") -> None:
89
+ """Record a finished run to BOTH sinks: local ledger, then platform feed.
90
+
91
+ The ledger write happens first and independently of the webhook, so a
92
+ backend outage still leaves per-agent utilisation on the VPS (which is what
93
+ ``/api/ui/agents/usage`` and the report's "VPS Agent Logs" read).
94
+ """
95
+ res: dict[str, Any] = result or {}
96
+ try:
97
+ from sdk.agents import usage as usage_mod
98
+ usage_mod.record(
99
+ name,
100
+ kind=kind,
101
+ duration_s=duration_s,
102
+ input_tokens=res.get("input_tokens", 0) or 0,
103
+ output_tokens=res.get("output_tokens", 0) or 0,
104
+ cost_usd=res.get("cost_usd", 0.0) or 0.0,
105
+ ok=bool(res.get("ok")),
106
+ )
107
+ except Exception as exc: # noqa: BLE001 — ledger write must never break a run
108
+ logger.debug("activity ledger write failed agent=%s err=%s", name, exc)
109
+
110
+ await emit(name, kind, res, duration_s, client=client, detail=detail)
@@ -167,10 +167,21 @@ def _agent_command(args: list[str]) -> None:
167
167
 
168
168
 
169
169
  def _agent_chat(name: str, message: str) -> None:
170
+ import time as _time
171
+
172
+ from sdk.agents import activity
170
173
  from sdk.agents.chat import chat_with_agent
171
174
 
175
+ async def _run() -> dict:
176
+ started = _time.monotonic()
177
+ res = await chat_with_agent(name, message)
178
+ # Same two sinks as the control panel: local usage ledger + the platform
179
+ # `agent_activities` feed. A CLI chat is as much an agent run as a UI one.
180
+ await activity.record_run(name, "chat", res, _time.monotonic() - started)
181
+ return res
182
+
172
183
  try:
173
- result = asyncio.run(chat_with_agent(name, message))
184
+ result = asyncio.run(_run())
174
185
  except LookupError as exc:
175
186
  print(f"error: {exc}")
176
187
  sys.exit(1)
@@ -1093,6 +1093,35 @@ class BackendClient:
1093
1093
  _AGENT_ID_CACHE[nm] = "" # negative-cache: not a backend agent
1094
1094
  return None
1095
1095
 
1096
+ async def report_vps_allocation(self, payload: dict) -> bool:
1097
+ """PATCH ``{ML_GATEWAY_URL}/ml-api/contabo/allocation`` — this machine's health.
1098
+
1099
+ The allocation row is resolved SERVER-side from the ``co_`` token (one row
1100
+ per user — ``contabo_allocation``, docs/DATABASE_SCHEMA.md #18), so the body
1101
+ carries metrics only: ``status``, ``cpuUsage``, ``ramUsage``, ``usedRam``,
1102
+ ``totalRam``, ``diskUsage``, ``totalDisk``, ``bandwidthUsed``, ``ipAddress``,
1103
+ ``uptimeSeconds``, ``lastPingedAt``.
1104
+
1105
+ Driven by the SDK-side health cron (``sdk.vps_health``) — deliberately NOT by
1106
+ the AI backend, so VPS liveness reporting does not depend on that service.
1107
+ Best-effort: never raises; a failed ping is logged and the next tick retries.
1108
+ """
1109
+ from sdk.config import ML_GATEWAY_URL
1110
+
1111
+ url = f"{ML_GATEWAY_URL.rstrip('/')}/ml-api/contabo/allocation"
1112
+ headers = {**self._headers, "accept": "*/*", "developer-name": "null", "product": ""}
1113
+ try:
1114
+ async with httpx.AsyncClient(timeout=20.0) as client:
1115
+ resp = await client.patch(url, json=payload, headers=headers)
1116
+ if resp.status_code >= 400:
1117
+ logger.warning("client.report_vps_allocation failed status=%s body=%s",
1118
+ resp.status_code, resp.text[:200])
1119
+ return False
1120
+ return True
1121
+ except Exception as exc: # noqa: BLE001
1122
+ logger.warning("client.report_vps_allocation error err=%s", exc)
1123
+ return False
1124
+
1096
1125
  async def notify_whatsapp(
1097
1126
  self,
1098
1127
  task_id: str,
@@ -180,6 +180,33 @@ async def health() -> dict:
180
180
  return collect_health()
181
181
 
182
182
 
183
+ @router.get("/vps/allocation", dependencies=[_auth])
184
+ async def vps_allocation_status() -> dict:
185
+ """State of the SDK-side health cron: enabled?, interval, last ping attempt.
186
+
187
+ The cron (``sdk.vps_health``, started by this server's lifespan) PATCHes
188
+ ``/ml-api/contabo/allocation`` every 2h. This is the read-only view of it —
189
+ useful when a ``contabo_allocation`` row looks stale.
190
+ """
191
+ from sdk import vps_health
192
+
193
+ return vps_health.last_report()
194
+
195
+
196
+ @router.post("/vps/allocation/report", dependencies=[_auth])
197
+ async def vps_allocation_report() -> dict:
198
+ """Force ONE health ping now (out of band from the 2h cron) and report the result.
199
+
200
+ Same payload the cron sends — for QA / first-boot verification without waiting
201
+ two hours. Returns ``{ok, payload}``; ``ok=False`` on a rejected or unreachable
202
+ PATCH (the reason is in ``GET /vps/allocation``).
203
+ """
204
+ from sdk import vps_health
205
+
206
+ ok = await vps_health.report_once()
207
+ return {"ok": ok, **vps_health.last_report()}
208
+
209
+
183
210
  # ── IP allowlist management ─────────────────────────────────────────────────
184
211
  # Whitelist a client IP for THIS SDK. A whitelisted IP passes the IP gate for
185
212
  # EVERY path (all /api/ui/* endpoints + the UI + /docs); data endpoints still
@@ -379,23 +406,19 @@ async def logs_stream() -> StreamingResponse:
379
406
  # ── Agent-activity webhook ──────────────────────────────────────────────────
380
407
  # Every custom/default agent run/chat records to the LOCAL usage ledger AND emits
381
408
  # a `{activity, agentId}` event to the ML gateway (POST /ml-api/agent-activity),
382
- # which stores it for RBAC-scoped reporting via the chat endpoint. Only agents that
383
- # exist in the backend (have an agentId) are emitted — local-only agents are skipped.
384
- # Best-effort: never raises into the run.
409
+ # which stores it in Postgres `agent_activities` for RBAC-scoped reporting + chat.
410
+ # Only agents that exist in the backend (have an agentId) are emitted — local-only
411
+ # agents are ledger-only. Best-effort: never raises into the run.
412
+ #
413
+ # The composition + emit itself lives in `sdk.agents.activity` so the agentd
414
+ # trigger paths (cron schedule, ticket label) report identically; this is a thin
415
+ # wrapper kept for the panel's direct chat/run endpoints, which already write the
416
+ # usage ledger themselves.
385
417
 
386
418
  async def _emit_agent_activity(name: str, kind: str, result: dict, duration_s: float = 0.0) -> None:
387
- try:
388
- from sdk.client import BackendClient
389
- client = BackendClient.from_config()
390
- agent_id = await client.resolve_agent_id(name)
391
- if not agent_id: # local-only agent → not tracked
392
- return
393
- verb = "Chatted with" if kind == "chat" else "Ran"
394
- status = "ok" if result.get("ok") else "error"
395
- detail = f" ({round(duration_s, 1)}s)" if duration_s else ""
396
- await client.log_agent_activity(f"{verb} agent '{name}' — {status}{detail}", agent_id)
397
- except Exception: # noqa: BLE001 — activity emit must never break a run
398
- pass
419
+ from sdk.agents import activity
420
+
421
+ await activity.emit(name, kind, result, duration_s)
399
422
 
400
423
 
401
424
  # ── Agents: catalog, creation, chat, default agents ────────────────────────
@@ -863,21 +886,17 @@ async def agent_run_ticket(body: AgentTicketRunRequest) -> dict:
863
886
  department_id = body.department_id
864
887
 
865
888
  async def work() -> dict:
866
- import time as _time
867
-
868
889
  from sdk.agents import ticket_run
869
890
  from sdk.client import BackendClient
870
891
 
871
892
  client = BackendClient.from_config()
872
- started = _time.monotonic()
873
893
  outcome = await ticket_run.run_on_ticket(
874
894
  client, name, ticket_key, scope=scope, department_id=department_id,
875
895
  variables=variables, secrets=secrets,
876
896
  )
877
- await _emit_agent_activity(
878
- name, "run-ticket", outcome if isinstance(outcome, dict) else {},
879
- _time.monotonic() - started,
880
- )
897
+ # No activity/ledger write here: this path runs through
898
+ # `agentd._dispatch_agent_run`, which records the run itself (kind
899
+ # "run-ticket") — emitting again would double-count it.
881
900
  return {"ticket_key": ticket_key, **(outcome if isinstance(outcome, dict) else {})}
882
901
 
883
902
  return {"job_id": jobs.start(work())}
@@ -23,6 +23,9 @@ _STATIC_DIR = Path(__file__).parent / "static"
23
23
 
24
24
  def create_app():
25
25
  """Build the FastAPI app (importable for tests)."""
26
+ import asyncio
27
+ import contextlib
28
+
26
29
  from fastapi import FastAPI, Request
27
30
  from fastapi.responses import FileResponse, RedirectResponse
28
31
  from fastapi.staticfiles import StaticFiles
@@ -31,12 +34,42 @@ def create_app():
31
34
  from sdk.ui.api import router as api_router
32
35
  from sdk.ui.ip_allowlist import IPAllowlistMiddleware
33
36
 
37
+ @contextlib.asynccontextmanager
38
+ async def lifespan(_app):
39
+ """Own the VPS health cron for the life of the server.
40
+
41
+ This process is the one thing that runs 24/7 on a provisioned VPS (the
42
+ bootstrap installs it as a systemd/launchd/scheduled-task service), so the
43
+ 2-hourly ``PATCH /ml-api/contabo/allocation`` ping rides along here rather
44
+ than in ``agentd`` (optional) or the AI backend (would need inbound reach).
45
+ Opt out with ``AGENTOS_VPS_HEALTH=0``. See ``sdk.vps_health``.
46
+ """
47
+ from sdk import vps_health
48
+
49
+ task = None
50
+ try:
51
+ task = vps_health.start()
52
+ except Exception: # noqa: BLE001 — the panel must serve even if the cron won't start
53
+ task = None
54
+ try:
55
+ yield
56
+ finally:
57
+ if task is not None:
58
+ task.cancel()
59
+ # CancelledError is a BaseException — suppress it explicitly, or the
60
+ # cancellation we just requested surfaces as a failed shutdown.
61
+ try:
62
+ await task
63
+ except (asyncio.CancelledError, Exception): # noqa: BLE001
64
+ pass
65
+
34
66
  # Swagger at /docs (ReDoc /redoc, schema /openapi.json). By default the whole
35
67
  # server — docs included — is locked by the IP allowlist below. With
36
68
  # AGENTOS_UI_PUBLIC=1 the IP gate is lifted: /docs is browsable by anyone and
37
69
  # /api/ui/* is guarded solely by the co_ token (paste it in Swagger's Authorize).
38
70
  from sdk import __version__ as _sdk_version
39
- app = FastAPI(title="AgentOS SDK", version=_sdk_version, docs_url="/docs", redoc_url="/redoc")
71
+ app = FastAPI(title="AgentOS SDK", version=_sdk_version, docs_url="/docs",
72
+ redoc_url="/redoc", lifespan=lifespan)
40
73
  # Inbound half of the two-way link: unless AGENTOS_UI_PUBLIC=1, only loopback,
41
74
  # this host's own IPs, and whitelisted IPs (the AI backend) may reach the server
42
75
  # AT ALL. Configure via AGENTOS_UI_ALLOWED_IPS or the config.toml `allowed_ips` key.