devops-bot-sdk 1.4.90__tar.gz → 1.4.94__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.90 → devops_bot_sdk-1.4.94}/PKG-INFO +1 -1
  2. {devops_bot_sdk-1.4.90 → devops_bot_sdk-1.4.94}/devops_bot_sdk.egg-info/PKG-INFO +1 -1
  3. {devops_bot_sdk-1.4.90 → devops_bot_sdk-1.4.94}/pyproject.toml +1 -1
  4. {devops_bot_sdk-1.4.90 → devops_bot_sdk-1.4.94}/sdk/__init__.py +2 -2
  5. {devops_bot_sdk-1.4.90 → devops_bot_sdk-1.4.94}/sdk/client.py +1 -1
  6. {devops_bot_sdk-1.4.90 → devops_bot_sdk-1.4.94}/sdk/git_ops.py +40 -12
  7. {devops_bot_sdk-1.4.90 → devops_bot_sdk-1.4.94}/sdk/ipc/handlers.py +49 -2
  8. {devops_bot_sdk-1.4.90 → devops_bot_sdk-1.4.94}/sdk/local_exec.py +15 -0
  9. {devops_bot_sdk-1.4.90 → devops_bot_sdk-1.4.94}/README.md +0 -0
  10. {devops_bot_sdk-1.4.90 → devops_bot_sdk-1.4.94}/devops_bot_sdk.egg-info/SOURCES.txt +0 -0
  11. {devops_bot_sdk-1.4.90 → devops_bot_sdk-1.4.94}/devops_bot_sdk.egg-info/dependency_links.txt +0 -0
  12. {devops_bot_sdk-1.4.90 → devops_bot_sdk-1.4.94}/devops_bot_sdk.egg-info/entry_points.txt +0 -0
  13. {devops_bot_sdk-1.4.90 → devops_bot_sdk-1.4.94}/devops_bot_sdk.egg-info/requires.txt +0 -0
  14. {devops_bot_sdk-1.4.90 → devops_bot_sdk-1.4.94}/devops_bot_sdk.egg-info/top_level.txt +0 -0
  15. {devops_bot_sdk-1.4.90 → devops_bot_sdk-1.4.94}/sdk/collectors/__init__.py +0 -0
  16. {devops_bot_sdk-1.4.90 → devops_bot_sdk-1.4.94}/sdk/collectors/files.py +0 -0
  17. {devops_bot_sdk-1.4.90 → devops_bot_sdk-1.4.94}/sdk/collectors/process.py +0 -0
  18. {devops_bot_sdk-1.4.90 → devops_bot_sdk-1.4.94}/sdk/collectors/screenshot.py +0 -0
  19. {devops_bot_sdk-1.4.90 → devops_bot_sdk-1.4.94}/sdk/config.py +0 -0
  20. {devops_bot_sdk-1.4.90 → devops_bot_sdk-1.4.94}/sdk/crucial.py +0 -0
  21. {devops_bot_sdk-1.4.90 → devops_bot_sdk-1.4.94}/sdk/exceptions.py +0 -0
  22. {devops_bot_sdk-1.4.90 → devops_bot_sdk-1.4.94}/sdk/graphify.py +0 -0
  23. {devops_bot_sdk-1.4.90 → devops_bot_sdk-1.4.94}/sdk/hooks/__init__.py +0 -0
  24. {devops_bot_sdk-1.4.90 → devops_bot_sdk-1.4.94}/sdk/hooks/crucial_guard.py +0 -0
  25. {devops_bot_sdk-1.4.90 → devops_bot_sdk-1.4.94}/sdk/ipc/__init__.py +0 -0
  26. {devops_bot_sdk-1.4.90 → devops_bot_sdk-1.4.94}/sdk/ipc/electron_bridge.py +0 -0
  27. {devops_bot_sdk-1.4.90 → devops_bot_sdk-1.4.94}/sdk/models/__init__.py +0 -0
  28. {devops_bot_sdk-1.4.90 → devops_bot_sdk-1.4.94}/sdk/models/envelope.py +0 -0
  29. {devops_bot_sdk-1.4.90 → devops_bot_sdk-1.4.94}/sdk/models/requests.py +0 -0
  30. {devops_bot_sdk-1.4.90 → devops_bot_sdk-1.4.94}/sdk/models/responses.py +0 -0
  31. {devops_bot_sdk-1.4.90 → devops_bot_sdk-1.4.94}/sdk/models/snapshots.py +0 -0
  32. {devops_bot_sdk-1.4.90 → devops_bot_sdk-1.4.94}/sdk/py.typed +0 -0
  33. {devops_bot_sdk-1.4.90 → devops_bot_sdk-1.4.94}/sdk/run_auto.py +0 -0
  34. {devops_bot_sdk-1.4.90 → devops_bot_sdk-1.4.94}/sdk/sse.py +0 -0
  35. {devops_bot_sdk-1.4.90 → devops_bot_sdk-1.4.94}/sdk/test.py +0 -0
  36. {devops_bot_sdk-1.4.90 → devops_bot_sdk-1.4.94}/sdk/test_pipeline.py +0 -0
  37. {devops_bot_sdk-1.4.90 → devops_bot_sdk-1.4.94}/sdk/updater.py +0 -0
  38. {devops_bot_sdk-1.4.90 → devops_bot_sdk-1.4.94}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devops-bot-sdk
3
- Version: 1.4.90
3
+ Version: 1.4.94
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.90
3
+ Version: 1.4.94
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.90"
7
+ version = "1.4.94"
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.90
3
+ Version: 1.4.94
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.90"
33
+ __version__ = "1.4.94"
34
34
  __author__ = "AgentOS"
35
35
 
36
36
  from sdk.client import BackendClient
@@ -58,7 +58,7 @@ def _notify_status(task_id: str, status: str) -> None:
58
58
  pass
59
59
 
60
60
 
61
- SDK_VERSION = "1.4.90"
61
+ SDK_VERSION = "1.4.94"
62
62
  _POLL_INTERVAL = 3.0
63
63
  _POLL_TIMEOUT = 600.0
64
64
  _ORCHESTRATE_TIMEOUT = 2700.0 # 45 min — covers approval wait + VPS execution time
@@ -72,6 +72,19 @@ async def _is_git_repo(path: str, env: dict) -> bool:
72
72
  return rc == 0 and out == "true"
73
73
 
74
74
 
75
+ # A branch named exactly like a Jira ticket id (e.g. "LDA-6", "OCR-103"). The
76
+ # "name the branch exactly the ticket id" convention produces these, so a
77
+ # leftover checkout from a PREVIOUS ticket's run must be treated as a stale
78
+ # feature branch — never as a base to cut the next ticket's branch from.
79
+ _TICKET_BRANCH_RE = re.compile(r"^[A-Za-z][A-Za-z0-9]{1,9}-\d+$")
80
+
81
+
82
+ def _is_work_branch(name: str) -> bool:
83
+ """True when `name` is one of OUR generated work branches (feature/* or a
84
+ bare ticket id) — i.e. never a legitimate base."""
85
+ return name.startswith("feature/") or bool(_TICKET_BRANCH_RE.match(name))
86
+
87
+
75
88
  async def detect_default_branch(path: str, env: dict) -> str:
76
89
  """Repo default branch — the PR base.
77
90
 
@@ -100,9 +113,10 @@ async def detect_default_branch(path: str, env: dict) -> str:
100
113
  )
101
114
  if rc == 0:
102
115
  return cand
103
- # 4. Last resort: the current branch, but never a feature branch.
116
+ # 4. Last resort: the current branch, but never one of our work branches
117
+ # (feature/* or a bare ticket id like "LDA-6").
104
118
  rc, out, _ = await _run(["git", "rev-parse", "--abbrev-ref", "HEAD"], path, env)
105
- if rc == 0 and out and not out.startswith("feature/"):
119
+ if rc == 0 and out and not _is_work_branch(out):
106
120
  return out
107
121
  return "main"
108
122
 
@@ -167,9 +181,10 @@ async def start_branch(
167
181
  - `base` omitted → the repo's CURRENT checked-out branch (the branch the
168
182
  user is actually working on); the feature branch is cut from local HEAD
169
183
  so the user's working state is preserved. If HEAD is detached or already
170
- one of OUR `feature/*` branches (a stale checkout from a prior re-run in
171
- a shared working tree), fall back to `detect_default_branch` so we never
172
- chain a feature branch onto another feature branch.
184
+ one of OUR work branches `feature/*` or a bare ticket-id branch like
185
+ "LDA-6" (a stale checkout from a prior ticket's run in a shared working
186
+ tree) fall back to `detect_default_branch` so we never chain one
187
+ ticket's branch onto another's.
173
188
 
174
189
  Returns {"branch", "base"} or None when the path isn't a git repo (e.g. a
175
190
  fresh scratch dir) or the checkout failed. The returned `base` is the branch
@@ -210,7 +225,7 @@ async def start_branch(
210
225
  # Default: base = the CURRENT checked-out branch (the user's working branch).
211
226
  rc, cur, _ = await _run(["git", "rev-parse", "--abbrev-ref", "HEAD"], path, env)
212
227
  cur = cur.strip() if rc == 0 else ""
213
- if cur and cur != "HEAD" and not cur.startswith("feature/"):
228
+ if cur and cur != "HEAD" and not _is_work_branch(cur):
214
229
  # Cut from local HEAD — preserve the user's working state, do not pull.
215
230
  rc, _, _ = await _run(["git", "checkout", "-B", branch], path, env)
216
231
  if rc != 0:
@@ -218,7 +233,8 @@ async def start_branch(
218
233
  logger.info("git_ops.branch | %s ← current branch %s", branch, cur)
219
234
  return {"branch": branch, "base": cur}
220
235
 
221
- # HEAD detached or a stale feature/* checkout fall back to repo default.
236
+ # HEAD detached or a stale work-branch checkout (feature/* or a previous
237
+ # ticket's id-named branch) → fall back to the repo default.
222
238
  resolved = await detect_default_branch(path, env)
223
239
  if resolved == branch:
224
240
  resolved = "main"
@@ -300,7 +316,10 @@ async def finish_pr(
300
316
  `gh auth login`). SSH alone covers git transport, not the PR.
301
317
 
302
318
  Every failure path is logged so a missing PR is diagnosable instead of silent.
303
- Returns {"pushed": bool, "pr_url": str | None, "pr_error": str | None}.
319
+ Returns {"pushed": bool, "pr_url": str | None, "pr_error": str | None,
320
+ "committed": bool, "pr_unavailable": bool} — `pr_unavailable` is True when
321
+ the box simply has no way to open a PR (no token AND no `gh`), which callers
322
+ treat as a warning rather than a failed PR attempt.
304
323
  """
305
324
  path = str(Path(project_path).expanduser())
306
325
  env = _git_env(github_token)
@@ -353,18 +372,27 @@ async def finish_pr(
353
372
  pr_error = f"gh pr create failed: {gh_err[:200]}"
354
373
  logger.warning("git_ops.gh_pr_failed branch=%s error=%s", branch, gh_err[:300])
355
374
 
375
+ pr_unavailable = False
356
376
  if not pr_url and pr_error is None:
357
377
  # Pushed, but no PR and no specific error captured above.
358
378
  if github_token and owner_repo:
359
379
  pr_error = "no PR created — REST API returned no URL"
360
380
  elif not github_token and not shutil.which("gh"):
361
- pr_error = ("no PR created no token and `gh` not installed; SSH "
362
- "transport can't open a PR. Install gh + `gh auth login`, "
363
- "or provide a GitHub token.")
381
+ # This box CANNOT open a PR at all (no token, no gh) SSH covers
382
+ # git transport only. Not a failed attempt: on SSH-only setups the
383
+ # PR is opened externally (platform/human), so flag it as
384
+ # `pr_unavailable` so the caller treats it as a warning, not a
385
+ # compliance failure.
386
+ pr_unavailable = True
387
+ pr_error = ("PR not opened from this machine — no GitHub token and "
388
+ "`gh` not installed (SSH covers push only). The PR must "
389
+ "be opened externally, or install gh + `gh auth login` / "
390
+ "provide a token.")
364
391
  elif not shutil.which("gh"):
365
392
  pr_error = "no PR created — missing GitHub token or unrecognized origin remote"
366
393
  else:
367
394
  pr_error = "no PR created"
368
395
  logger.warning("git_ops.no_pr branch=%s reason=%s", branch, pr_error)
369
396
 
370
- return {"pushed": pushed, "pr_url": pr_url, "pr_error": pr_error, "committed": committed}
397
+ return {"pushed": pushed, "pr_url": pr_url, "pr_error": pr_error,
398
+ "committed": committed, "pr_unavailable": pr_unavailable}
@@ -416,7 +416,10 @@ async def _run_with_heartbeat(coro, *, send: Callable, thread_id: str, label: st
416
416
  beat.cancel()
417
417
  try:
418
418
  await beat
419
- except Exception: # noqa: BLE001
419
+ except (asyncio.CancelledError, Exception): # noqa: BLE001
420
+ # CancelledError is a BaseException: raised here when the beat task
421
+ # was cancelled before it ever ran (coro finished immediately) — it
422
+ # must not propagate and sink the caller's result.
420
423
  pass
421
424
 
422
425
 
@@ -441,6 +444,7 @@ async def _run_generation(
441
444
  send=send, thread_id=thread_id, label=label, name=name,
442
445
  )
443
446
  combined = res.get("result") or ""
447
+ longest = res.get("longest_text") or ""
444
448
  sid = res.get("session_id")
445
449
  chunks = 0
446
450
  while res.get("output_cap_exceeded") and sid and chunks < _MAX_OUTPUT_CAP_RESUMES:
@@ -463,9 +467,24 @@ async def _run_generation(
463
467
  send=send, thread_id=thread_id, label=f"{label} (cont.)", name=name,
464
468
  )
465
469
  combined += res.get("result") or ""
470
+ if len(res.get("longest_text") or "") > len(longest):
471
+ longest = res.get("longest_text") or ""
466
472
  sid = res.get("session_id") or sid
467
473
  out = dict(res)
468
474
  out["result"] = combined # the full, reassembled output
475
+ # Salvage a lost artifact: the CLI's `result` is only the LAST assistant
476
+ # message. If the model wrote the artifact and was then woken once more by a
477
+ # stray late event (e.g. a leftover background timer) for a short closing
478
+ # remark ("nothing further is pending"), `combined` is that remark and the
479
+ # real output is an EARLIER, much longer message. When an earlier message
480
+ # dwarfs the final one, it is the artifact — use it.
481
+ if (not out.get("limit_exceeded") and len(longest) > 1000
482
+ and len(longest) > 2 * len(combined.strip())):
483
+ logger.warning(
484
+ "generation.salvaged_longest_message final_len=%s longest_len=%s",
485
+ len(combined.strip()), len(longest),
486
+ )
487
+ out["result"] = longest.strip()
469
488
  out["session_id"] = sid
470
489
  return out
471
490
 
@@ -1382,6 +1401,13 @@ async def _handle_story(
1382
1401
  # Use the output regardless of an is_error flag (a stray read-only tool denial
1383
1402
  # shouldn't discard a PRD the model actually produced); only a usage limit is fatal.
1384
1403
  prd = "" if gen.get("limit_exceeded") else (gen.get("result") or "").strip()
1404
+ if prd and len(prd) < 400:
1405
+ # A real PRD (numbered sections per _PRD_FORMAT) is never this short —
1406
+ # this is a stub (e.g. a closing remark captured as the run's result).
1407
+ # Treat it as a failed generation rather than filing garbage in the ticket.
1408
+ if not gen.get("error"):
1409
+ gen["error"] = f"PRD too short ({len(prd)} chars) — not a document"
1410
+ prd = ""
1385
1411
  if prd:
1386
1412
  # PRD lives alongside the user story in the (now Task) description.
1387
1413
  new_description = f"{user_story}\n\n---\n\n{_PRD_MARKER}\n\n{prd}"[:20000]
@@ -2078,8 +2104,19 @@ async def _code_one_repo(
2078
2104
  issues.append("in scope but produced 0 committed changes")
2079
2105
  elif not pr.get("pushed"):
2080
2106
  issues.append("branch was not pushed")
2081
- elif not pr.get("pr_url"):
2107
+ elif not pr.get("pr_url") and not pr.get("pr_unavailable"):
2082
2108
  issues.append(f"no PR opened ({(pr or {}).get('pr_error') or 'unknown'})")
2109
+ elif pr.get("pr_unavailable"):
2110
+ # Committed + pushed, but this box has NO way to open a PR (no
2111
+ # token, no gh — SSH transport only). The PR is opened
2112
+ # externally on such setups, so this is a WARNING, not a
2113
+ # compliance failure: the ticket still completes (Done).
2114
+ await send(Envelope(
2115
+ type="step_update", thread_id=thread_id,
2116
+ data={"status": "running",
2117
+ "current_step": f"[{name}] Pushed OK — PR to be opened "
2118
+ "externally (no token/gh on this machine)"},
2119
+ ).model_dump())
2083
2120
  else:
2084
2121
  # Plain working folder (documentation / analyst deliverables, non-git):
2085
2122
  # no branch/PR to verify, but it must still have PRODUCED something.
@@ -2878,6 +2915,7 @@ import re as _re # noqa: E402
2878
2915
  _BASE_NOISE = {
2879
2916
  "the", "a", "an", "this", "that", "new", "feature", "features",
2880
2917
  "your", "same", "current", "default", "its", "please", "create",
2918
+ "origin", "latest", "upstream", "branch",
2881
2919
  }
2882
2920
  _BASE_DIRECTIVE_RES = [
2883
2921
  # "branch from/off <base>" — most explicit, tried first. Greedy so a full
@@ -2885,6 +2923,15 @@ _BASE_DIRECTIVE_RES = [
2885
2923
  _re.compile(r"branch(?:es|ed|ing)?\s+(?:from|off(?:\s+of)?)\s+(?:the\s+)?[\"'`]?([A-Za-z0-9._\-/]+)[\"'`]?(?:\s+branch)?\b", _re.I),
2886
2924
  # "base branch: <base>" / "base is <base>"
2887
2925
  _re.compile(r"base(?:\s+branch)?\s*(?:[:=]|\bis\b|\bshould\s+be\b)\s*[\"'`]?([A-Za-z0-9._\-/]+)[\"'`]?\b", _re.I),
2926
+ # Literal git-workflow steps: "git checkout <base>" (NOT -b/-B — that's the
2927
+ # NEW branch) and "git pull origin <base>". This is how the standard
2928
+ # Human Instructions template names the base ("git checkout main && git
2929
+ # pull origin main").
2930
+ _re.compile(r"git\s+checkout\s+(?!-)[\"'`]?([A-Za-z0-9._\-/]+)[\"'`]?", _re.I),
2931
+ _re.compile(r"git\s+pull\s+(?:origin\s+)?[\"'`]?([A-Za-z0-9._\-/]+)[\"'`]?", _re.I),
2932
+ # "open a PR into <base>" / "merge to <base>" / "PR Requirements (target: <base>"
2933
+ _re.compile(r"(?:PR|pull\s+request|merge[sd]?)\s+(?:in)?to\s+(?:the\s+)?[\"'`]?([A-Za-z0-9._\-/]+)[\"'`]?", _re.I),
2934
+ _re.compile(r"target\s*:\s*[\"'`]?([A-Za-z][A-Za-z0-9._\-/]*)[\"'`]?", _re.I),
2888
2935
  # "from the <base> branch"
2889
2936
  _re.compile(r"from\s+(?:the\s+)?[\"'`]?([A-Za-z0-9._\-/]+)[\"'`]?\s+branch\b", _re.I),
2890
2937
  # loose "<base> branch" (e.g. "main branch") — last, most permissive.
@@ -386,6 +386,12 @@ async def run_claude_local(
386
386
 
387
387
  result_event: dict | None = None
388
388
  files_changed: list[str] = []
389
+ # Longest single assistant TEXT message seen in the stream. The CLI's final
390
+ # `result` field is only the LAST assistant message — if the model outputs a
391
+ # large artifact (PRD/decomposition) and is then woken once more by a stray
392
+ # late event (e.g. a leftover background timer) for a short closing remark,
393
+ # the artifact would be lost. Callers use this to salvage it.
394
+ longest_text = {"text": ""}
389
395
  session_id: str | None = resume_session_id
390
396
  import time as _time
391
397
  started = _time.monotonic()
@@ -452,6 +458,14 @@ async def run_claude_local(
452
458
  if event.get("session_id"):
453
459
  session_id = str(event["session_id"])
454
460
 
461
+ # Track the longest assistant text message (see `longest_text` above).
462
+ if event.get("type") == "assistant":
463
+ for c in event.get("message", {}).get("content", []) or []:
464
+ if isinstance(c, dict) and c.get("type") == "text":
465
+ txt = str(c.get("text") or "")
466
+ if len(txt) > len(longest_text["text"]):
467
+ longest_text["text"] = txt
468
+
455
469
  # Track files the agent created/edited for the result summary.
456
470
  if event.get("type") == "user":
457
471
  tr = event.get("tool_use_result")
@@ -526,6 +540,7 @@ async def run_claude_local(
526
540
  return _finish({
527
541
  "ok": (not is_error) and (not limit_hit),
528
542
  "result": result_text,
543
+ "longest_text": longest_text["text"],
529
544
  "input_tokens": in_tok,
530
545
  "output_tokens": out_tok,
531
546
  "total_tokens": in_tok + out_tok,