devops-bot-sdk 1.4.19__tar.gz → 1.4.20__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 (38) hide show
  1. {devops_bot_sdk-1.4.19 → devops_bot_sdk-1.4.20}/PKG-INFO +1 -1
  2. {devops_bot_sdk-1.4.19 → devops_bot_sdk-1.4.20}/devops_bot_sdk.egg-info/PKG-INFO +1 -1
  3. {devops_bot_sdk-1.4.19 → devops_bot_sdk-1.4.20}/pyproject.toml +1 -1
  4. {devops_bot_sdk-1.4.19 → devops_bot_sdk-1.4.20}/sdk/__init__.py +2 -2
  5. {devops_bot_sdk-1.4.19 → devops_bot_sdk-1.4.20}/sdk/client.py +56 -1
  6. {devops_bot_sdk-1.4.19 → devops_bot_sdk-1.4.20}/sdk/ipc/handlers.py +93 -3
  7. {devops_bot_sdk-1.4.19 → devops_bot_sdk-1.4.20}/sdk/local_exec.py +14 -2
  8. {devops_bot_sdk-1.4.19 → devops_bot_sdk-1.4.20}/README.md +0 -0
  9. {devops_bot_sdk-1.4.19 → devops_bot_sdk-1.4.20}/devops_bot_sdk.egg-info/SOURCES.txt +0 -0
  10. {devops_bot_sdk-1.4.19 → devops_bot_sdk-1.4.20}/devops_bot_sdk.egg-info/dependency_links.txt +0 -0
  11. {devops_bot_sdk-1.4.19 → devops_bot_sdk-1.4.20}/devops_bot_sdk.egg-info/entry_points.txt +0 -0
  12. {devops_bot_sdk-1.4.19 → devops_bot_sdk-1.4.20}/devops_bot_sdk.egg-info/requires.txt +0 -0
  13. {devops_bot_sdk-1.4.19 → devops_bot_sdk-1.4.20}/devops_bot_sdk.egg-info/top_level.txt +0 -0
  14. {devops_bot_sdk-1.4.19 → devops_bot_sdk-1.4.20}/sdk/collectors/__init__.py +0 -0
  15. {devops_bot_sdk-1.4.19 → devops_bot_sdk-1.4.20}/sdk/collectors/files.py +0 -0
  16. {devops_bot_sdk-1.4.19 → devops_bot_sdk-1.4.20}/sdk/collectors/process.py +0 -0
  17. {devops_bot_sdk-1.4.19 → devops_bot_sdk-1.4.20}/sdk/collectors/screenshot.py +0 -0
  18. {devops_bot_sdk-1.4.19 → devops_bot_sdk-1.4.20}/sdk/config.py +0 -0
  19. {devops_bot_sdk-1.4.19 → devops_bot_sdk-1.4.20}/sdk/crucial.py +0 -0
  20. {devops_bot_sdk-1.4.19 → devops_bot_sdk-1.4.20}/sdk/exceptions.py +0 -0
  21. {devops_bot_sdk-1.4.19 → devops_bot_sdk-1.4.20}/sdk/git_ops.py +0 -0
  22. {devops_bot_sdk-1.4.19 → devops_bot_sdk-1.4.20}/sdk/graphify.py +0 -0
  23. {devops_bot_sdk-1.4.19 → devops_bot_sdk-1.4.20}/sdk/hooks/__init__.py +0 -0
  24. {devops_bot_sdk-1.4.19 → devops_bot_sdk-1.4.20}/sdk/hooks/crucial_guard.py +0 -0
  25. {devops_bot_sdk-1.4.19 → devops_bot_sdk-1.4.20}/sdk/ipc/__init__.py +0 -0
  26. {devops_bot_sdk-1.4.19 → devops_bot_sdk-1.4.20}/sdk/ipc/electron_bridge.py +0 -0
  27. {devops_bot_sdk-1.4.19 → devops_bot_sdk-1.4.20}/sdk/models/__init__.py +0 -0
  28. {devops_bot_sdk-1.4.19 → devops_bot_sdk-1.4.20}/sdk/models/envelope.py +0 -0
  29. {devops_bot_sdk-1.4.19 → devops_bot_sdk-1.4.20}/sdk/models/requests.py +0 -0
  30. {devops_bot_sdk-1.4.19 → devops_bot_sdk-1.4.20}/sdk/models/responses.py +0 -0
  31. {devops_bot_sdk-1.4.19 → devops_bot_sdk-1.4.20}/sdk/models/snapshots.py +0 -0
  32. {devops_bot_sdk-1.4.19 → devops_bot_sdk-1.4.20}/sdk/py.typed +0 -0
  33. {devops_bot_sdk-1.4.19 → devops_bot_sdk-1.4.20}/sdk/run_auto.py +0 -0
  34. {devops_bot_sdk-1.4.19 → devops_bot_sdk-1.4.20}/sdk/sse.py +0 -0
  35. {devops_bot_sdk-1.4.19 → devops_bot_sdk-1.4.20}/sdk/test.py +0 -0
  36. {devops_bot_sdk-1.4.19 → devops_bot_sdk-1.4.20}/sdk/test_pipeline.py +0 -0
  37. {devops_bot_sdk-1.4.19 → devops_bot_sdk-1.4.20}/sdk/updater.py +0 -0
  38. {devops_bot_sdk-1.4.19 → devops_bot_sdk-1.4.20}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devops-bot-sdk
3
- Version: 1.4.19
3
+ Version: 1.4.20
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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devops-bot-sdk
3
- Version: 1.4.19
3
+ Version: 1.4.20
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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "devops-bot-sdk"
7
- version = "1.4.19"
7
+ version = "1.4.20"
8
8
  description = "DevOps Bot Desktop SDK — thin client for the AgentOS Electron desktop app"
9
9
  readme = "README.md"
10
10
  license = "LicenseRef-Proprietary"
@@ -1,6 +1,6 @@
1
1
  """AgentOS Desktop SDK — thin HTTPS/SSE client for the Electron app.
2
2
 
3
- Version: 1.4.19
3
+ Version: 1.4.20
4
4
 
5
5
  Public surface:
6
6
  BackendClient.from_config() — create client from ~/.agentos/config.toml
@@ -30,7 +30,7 @@ Rules:
30
30
  - All data egress through submit_webhook only
31
31
  """
32
32
 
33
- __version__ = "1.4.19"
33
+ __version__ = "1.4.20"
34
34
  __author__ = "AgentOS"
35
35
 
36
36
  from sdk.client import BackendClient
@@ -36,7 +36,7 @@ from sdk.sse import _check_status, stream_with_reconnect
36
36
 
37
37
  logger = logging.getLogger(__name__)
38
38
 
39
- SDK_VERSION = "1.4.19"
39
+ SDK_VERSION = "1.4.20"
40
40
  _POLL_INTERVAL = 3.0
41
41
  _POLL_TIMEOUT = 600.0
42
42
  _ORCHESTRATE_TIMEOUT = 2700.0 # 45 min — covers approval wait + VPS execution time
@@ -685,11 +685,66 @@ class BackendClient:
685
685
  logger.warning("client.move_to_backlog failed task=%s status=%s body=%s",
686
686
  task_id, resp.status_code, resp.text[:200])
687
687
  return False
688
+ await self.log_activity(f"Task {task_id}: status → Backlog")
688
689
  return True
689
690
  except Exception as exc: # noqa: BLE001
690
691
  logger.warning("client.move_to_backlog error task=%s err=%s", task_id, exc)
691
692
  return False
692
693
 
694
+ async def mark_in_progress(self, task_id: str) -> bool:
695
+ """PUT {BE}/tasks-ml/{id} status=In Progress (best-effort).
696
+
697
+ Called at the start of processing to move the ticket from To Do to
698
+ In Progress. Returns True on success; logs and returns False otherwise.
699
+ """
700
+ from sdk.config import BE_DEFAULT_URL
701
+
702
+ url = f"{BE_DEFAULT_URL.rstrip('/')}/tasks-ml/{task_id}"
703
+ headers = {
704
+ k: v for k, v in self._headers.items() if k.lower() != "content-type"
705
+ }
706
+ headers.update({"accept": "*/*", "developer-name": "null", "product": ""})
707
+ try:
708
+ async with httpx.AsyncClient(timeout=20.0) as client:
709
+ resp = await client.put(url, files={"status": (None, "In Progress")}, headers=headers)
710
+ if resp.status_code >= 400:
711
+ logger.warning("client.mark_in_progress failed task=%s status=%s body=%s",
712
+ task_id, resp.status_code, resp.text[:200])
713
+ return False
714
+ await self.log_activity(f"Task {task_id}: status → In Progress")
715
+ return True
716
+ except Exception as exc: # noqa: BLE001
717
+ logger.warning("client.mark_in_progress error task=%s err=%s", task_id, exc)
718
+ return False
719
+
720
+ async def log_activity(self, activity: str) -> bool:
721
+ """POST /ml-api/agent-activity — emit an agent activity event (best-effort).
722
+
723
+ Called on status transitions (In Progress, Backlog), every 4 files written
724
+ during local execution, and on errors. Never raises so a log failure can't
725
+ break a run.
726
+ """
727
+ from sdk.config import BE_DEFAULT_URL
728
+
729
+ url = f"{BE_DEFAULT_URL.rstrip('/')}/ml-api/agent-activity"
730
+ headers = {
731
+ **self._headers,
732
+ "accept": "*/*",
733
+ "developer-name": "null",
734
+ "product": "",
735
+ }
736
+ try:
737
+ async with httpx.AsyncClient(timeout=10.0) as client:
738
+ resp = await client.post(url, json={"activity": str(activity)}, headers=headers)
739
+ if resp.status_code >= 400:
740
+ logger.warning("client.log_activity failed status=%s body=%s",
741
+ resp.status_code, resp.text[:200])
742
+ return False
743
+ return True
744
+ except Exception as exc: # noqa: BLE001
745
+ logger.warning("client.log_activity error err=%s", exc)
746
+ return False
747
+
693
748
  async def notify_whatsapp(
694
749
  self,
695
750
  task_id: str,
@@ -438,7 +438,10 @@ async def _code_one_repo(
438
438
  # 0. Make sure the repo is present (clone from repo_url only if missing/empty).
439
439
  await git_ops.ensure_repo(repo_path, repo_url, gh_token)
440
440
 
441
+ _write_count = 0
442
+
441
443
  async def _on_event(event: dict) -> None:
444
+ nonlocal _write_count
442
445
  if event.get("type") == "assistant":
443
446
  for c in event.get("message", {}).get("content", []):
444
447
  if c.get("type") == "tool_use":
@@ -450,6 +453,14 @@ async def _code_one_repo(
450
453
  data={"status": "running", "current_step": label},
451
454
  ).model_dump())
452
455
  if tname in ("Write", "Edit", "MultiEdit"):
456
+ _write_count += 1
457
+ if _write_count % 4 == 0:
458
+ try:
459
+ await client.log_activity(
460
+ f"Task {task_id} [{name}]: {_write_count} files written"
461
+ )
462
+ except Exception: # noqa: BLE001
463
+ pass
453
464
  try:
454
465
  from sdk import graphify
455
466
  graphify.schedule_update(repo_path)
@@ -659,6 +670,11 @@ async def handle_pipeline_approve(
659
670
  return
660
671
 
661
672
  # ── Approved: run the local claude CLI on EACH repo the ticket names ──
673
+ # Mark back to In Progress — covers both direct approval in the auto-loop
674
+ # and a resume that arrives after the ticket sat in Backlog waiting for
675
+ # the human's WhatsApp reply or a usage-limit window reset.
676
+ await client.mark_in_progress(task_id)
677
+
662
678
  # The prompt is built per-repo inside _code_one_repo so its framing matches
663
679
  # whether that target is a git repo (branch + PR) or a plain local folder.
664
680
  agent_mode = bool(pending.get("agent_mode", True))
@@ -765,10 +781,13 @@ async def handle_pipeline_approve(
765
781
  await client.notify_whatsapp(task_id, note, summary="AgentOS local run alert")
766
782
  except Exception: # noqa: BLE001
767
783
  pass
784
+ await client.log_activity(f"Task {task_id}: local execution failed — {err[:300]}")
785
+ await client.move_to_backlog(task_id)
768
786
  await send(Envelope(
769
787
  type=Envelope.TYPE_ERROR, thread_id=thread_id,
770
788
  data={"code": "LOCAL_EXEC_FAILED", "message": err[:300], "task_id": task_id},
771
789
  ).model_dump())
790
+ return
772
791
 
773
792
  # ── Finalize on the server once per task (validation + Jira/cost) ─────
774
793
  await send(Envelope(
@@ -786,6 +805,8 @@ async def handle_pipeline_approve(
786
805
  },
787
806
  )
788
807
  except Exception as exc:
808
+ await client.log_activity(f"Task {task_id}: server finalization failed — {str(exc)[:200]}")
809
+ await client.move_to_backlog(task_id)
789
810
  await send(_error_envelope(thread_id, exc).model_dump())
790
811
  return
791
812
 
@@ -1500,9 +1521,11 @@ def _resolve_local_project_path(task: dict, payload: dict) -> str:
1500
1521
  return str(Path(root).expanduser() / key)
1501
1522
 
1502
1523
 
1503
- # Approval polling cadence for the first-approval gate (agentMode=true). The
1504
- # 20-min timeout matches the backend's own Backlog demotion timer.
1524
+ # Approval polling cadence for the first-approval gate (agentMode=true).
1525
+ # _FIRST_GATE_TIMEOUT_S: ticket moves to Backlog after 15 min with no reply.
1526
+ # _APPROVAL_POLL_TIMEOUT_S: longer timeout kept for mid-run crucial-decision gates.
1505
1527
  _APPROVAL_POLL_INTERVAL_S = 15.0
1528
+ _FIRST_GATE_TIMEOUT_S = 900.0
1506
1529
  _APPROVAL_POLL_TIMEOUT_S = 1200.0
1507
1530
  # Max times a single ticket's session is resumed after a context overflow.
1508
1531
  _MAX_CONTEXT_RESUMES = 3
@@ -1551,9 +1574,10 @@ async def _poll_approval(
1551
1574
 
1552
1575
  Returns 'approved' | 'rejected' | 'stopped' | 'timeout'. The decision is the
1553
1576
  human's WhatsApp reply, recorded backend-side under approval_log:{task_id}.
1577
+ Times out after _FIRST_GATE_TIMEOUT_S (15 min) so the ticket can move to Backlog.
1554
1578
  """
1555
1579
  waited = 0.0
1556
- while waited < _APPROVAL_POLL_TIMEOUT_S:
1580
+ while waited < _FIRST_GATE_TIMEOUT_S:
1557
1581
  if should_stop is not None and should_stop():
1558
1582
  return "stopped"
1559
1583
  status = await client.approval_status(task_id)
@@ -1589,6 +1613,53 @@ async def _poll_fresh_approval(
1589
1613
  return "timeout"
1590
1614
 
1591
1615
 
1616
+ async def _background_approval_watcher(
1617
+ client: BackendClient,
1618
+ session_id: str,
1619
+ task_id: str,
1620
+ jira_key: str,
1621
+ thread_id: str,
1622
+ send: Callable,
1623
+ should_stop: Callable[[], bool] | None,
1624
+ ) -> None:
1625
+ """Poll indefinitely for a late approval on a timed-out gate, then resume.
1626
+
1627
+ Spawned as a fire-and-forget task when the first-gate 15-min timeout fires.
1628
+ When the user approves (via WhatsApp), calls handle_pipeline_approve so the
1629
+ ticket resumes from exactly where it paused. handle_pipeline_approve already
1630
+ calls mark_in_progress, so the Jira status flips In Progress → Backlog →
1631
+ In Progress automatically. Cleans up _PENDING_LOCAL on any terminal outcome.
1632
+ """
1633
+ try:
1634
+ while True:
1635
+ if should_stop is not None and should_stop():
1636
+ return
1637
+ status = await client.approval_status(task_id)
1638
+ if status == "approved":
1639
+ break
1640
+ if status == "rejected":
1641
+ await send(Envelope(
1642
+ type="step_update", thread_id=thread_id,
1643
+ data={"current_step": f"{jira_key}: rejected (late approval) — skipped"},
1644
+ ).model_dump())
1645
+ return
1646
+ await asyncio.sleep(_APPROVAL_POLL_INTERVAL_S)
1647
+ # Approval arrived — move back to In Progress and resume execution.
1648
+ await send(Envelope(
1649
+ type="step_update", thread_id=thread_id,
1650
+ data={"current_step": f"{jira_key}: late approval received — resuming from Backlog"},
1651
+ ).model_dump())
1652
+ await handle_pipeline_approve(
1653
+ client,
1654
+ {"session_id": session_id, "decision": "approved", "thread_id": thread_id},
1655
+ send,
1656
+ )
1657
+ except Exception as exc: # noqa: BLE001
1658
+ logger.warning("background_approval_watcher.failed task=%s err=%s", task_id, exc)
1659
+ finally:
1660
+ _PENDING_LOCAL.pop(session_id, None)
1661
+
1662
+
1592
1663
  async def handle_orchestrate_auto_local_run(
1593
1664
  client: BackendClient, payload: dict, send: Callable,
1594
1665
  should_stop: Callable[[], bool] | None = None,
@@ -1680,6 +1751,9 @@ async def handle_orchestrate_auto_local_run(
1680
1751
  attempt = 0
1681
1752
  while True:
1682
1753
  attempt += 1
1754
+ # Mark In Progress before any work begins so Jira reflects
1755
+ # the real state even if the run fails partway through.
1756
+ await client.mark_in_progress(tid)
1683
1757
  # Phase 1 — server plans + gates. NO local code/bash runs yet.
1684
1758
  await _run_local_pipeline(client, task_payload, send)
1685
1759
  session_id = next(
@@ -1711,6 +1785,20 @@ async def handle_orchestrate_auto_local_run(
1711
1785
  "processed": processed, "task_count": total},
1712
1786
  ).model_dump())
1713
1787
  return
1788
+ if decision == "timeout":
1789
+ # 15 min elapsed — move to Backlog but keep _PENDING_LOCAL
1790
+ # alive, then spawn a background watcher that polls until
1791
+ # the user approves and resumes execution automatically.
1792
+ await client.move_to_backlog(tid)
1793
+ await send(Envelope(
1794
+ type="step_update", thread_id=thread_id,
1795
+ data={"current_step": f"{jira_key}: no approval in 15m — moved to Backlog"
1796
+ " (background watcher will auto-resume on approval)"},
1797
+ ).model_dump())
1798
+ asyncio.ensure_future(_background_approval_watcher(
1799
+ client, session_id, tid, jira_key, thread_id, send, should_stop,
1800
+ ))
1801
+ break
1714
1802
  if decision != "approved":
1715
1803
  _PENDING_LOCAL.pop(session_id, None)
1716
1804
  await send(Envelope(
@@ -1756,6 +1844,8 @@ async def handle_orchestrate_auto_local_run(
1756
1844
  break
1757
1845
  except Exception as exc: # noqa: BLE001 — isolate per-ticket failures
1758
1846
  logger.warning("orchestrate_auto.task_failed", extra={"task_id": tid, "error": str(exc)})
1847
+ await client.log_activity(f"Task {tid}: unexpected error — {str(exc)[:200]}")
1848
+ await client.move_to_backlog(tid)
1759
1849
  await send(Envelope(
1760
1850
  type="step_update", thread_id=thread_id,
1761
1851
  data={"current_step": f"{jira_key or tid}: failed — {str(exc)[:160]} (continuing)"},
@@ -22,6 +22,7 @@ import json
22
22
  import os
23
23
  import re
24
24
  import shutil
25
+ import sys
25
26
  from datetime import datetime
26
27
  from pathlib import Path
27
28
  from typing import Any, Awaitable, Callable
@@ -242,7 +243,6 @@ async def run_claude_local(
242
243
  # destructive tool calls are blocked until a human approves (the guard reads
243
244
  # AGENTOS_APPROVAL_DIR set below). Merges with any global/project hooks.
244
245
  if approval_dir:
245
- import sys
246
246
  from sdk import crucial
247
247
  settings_path = os.path.join(approval_dir, "claude_settings.json")
248
248
  try:
@@ -271,8 +271,20 @@ async def run_claude_local(
271
271
  if approval_dir:
272
272
  env["AGENTOS_APPROVAL_DIR"] = approval_dir
273
273
 
274
+ # On Windows, npm/nvm install the CLI as a `.cmd`/`.bat` batch shim (e.g.
275
+ # claude.CMD). `create_subprocess_exec` calls CreateProcess directly — with NO
276
+ # command processor — and a batch shim launched that way can fail to bootstrap
277
+ # on some setups (notably nvm-for-Windows, where node lives behind a junction):
278
+ # it starts, emits nothing, and exits, surfacing here as "no result event".
279
+ # Route the shim through %COMSPEC% so it runs in a real cmd.exe exactly as an
280
+ # interactive shell would. Native `.exe` installs and POSIX paths spawn directly.
281
+ spawn_cmd = cmd
282
+ if sys.platform == "win32" and cli.lower().endswith((".cmd", ".bat")):
283
+ comspec = os.environ.get("COMSPEC", "cmd.exe")
284
+ spawn_cmd = [comspec, "/c", *cmd]
285
+
274
286
  proc = await asyncio.create_subprocess_exec(
275
- *cmd,
287
+ *spawn_cmd,
276
288
  cwd=str(path),
277
289
  stdin=asyncio.subprocess.PIPE,
278
290
  stdout=asyncio.subprocess.PIPE,