devops-bot-sdk 1.4.37__tar.gz → 1.4.39__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.37 → devops_bot_sdk-1.4.39}/PKG-INFO +1 -1
  2. {devops_bot_sdk-1.4.37 → devops_bot_sdk-1.4.39}/devops_bot_sdk.egg-info/PKG-INFO +1 -1
  3. {devops_bot_sdk-1.4.37 → devops_bot_sdk-1.4.39}/pyproject.toml +1 -1
  4. {devops_bot_sdk-1.4.37 → devops_bot_sdk-1.4.39}/sdk/__init__.py +2 -2
  5. {devops_bot_sdk-1.4.37 → devops_bot_sdk-1.4.39}/sdk/client.py +66 -1
  6. {devops_bot_sdk-1.4.37 → devops_bot_sdk-1.4.39}/sdk/ipc/handlers.py +211 -0
  7. {devops_bot_sdk-1.4.37 → devops_bot_sdk-1.4.39}/README.md +0 -0
  8. {devops_bot_sdk-1.4.37 → devops_bot_sdk-1.4.39}/devops_bot_sdk.egg-info/SOURCES.txt +0 -0
  9. {devops_bot_sdk-1.4.37 → devops_bot_sdk-1.4.39}/devops_bot_sdk.egg-info/dependency_links.txt +0 -0
  10. {devops_bot_sdk-1.4.37 → devops_bot_sdk-1.4.39}/devops_bot_sdk.egg-info/entry_points.txt +0 -0
  11. {devops_bot_sdk-1.4.37 → devops_bot_sdk-1.4.39}/devops_bot_sdk.egg-info/requires.txt +0 -0
  12. {devops_bot_sdk-1.4.37 → devops_bot_sdk-1.4.39}/devops_bot_sdk.egg-info/top_level.txt +0 -0
  13. {devops_bot_sdk-1.4.37 → devops_bot_sdk-1.4.39}/sdk/collectors/__init__.py +0 -0
  14. {devops_bot_sdk-1.4.37 → devops_bot_sdk-1.4.39}/sdk/collectors/files.py +0 -0
  15. {devops_bot_sdk-1.4.37 → devops_bot_sdk-1.4.39}/sdk/collectors/process.py +0 -0
  16. {devops_bot_sdk-1.4.37 → devops_bot_sdk-1.4.39}/sdk/collectors/screenshot.py +0 -0
  17. {devops_bot_sdk-1.4.37 → devops_bot_sdk-1.4.39}/sdk/config.py +0 -0
  18. {devops_bot_sdk-1.4.37 → devops_bot_sdk-1.4.39}/sdk/crucial.py +0 -0
  19. {devops_bot_sdk-1.4.37 → devops_bot_sdk-1.4.39}/sdk/exceptions.py +0 -0
  20. {devops_bot_sdk-1.4.37 → devops_bot_sdk-1.4.39}/sdk/git_ops.py +0 -0
  21. {devops_bot_sdk-1.4.37 → devops_bot_sdk-1.4.39}/sdk/graphify.py +0 -0
  22. {devops_bot_sdk-1.4.37 → devops_bot_sdk-1.4.39}/sdk/hooks/__init__.py +0 -0
  23. {devops_bot_sdk-1.4.37 → devops_bot_sdk-1.4.39}/sdk/hooks/crucial_guard.py +0 -0
  24. {devops_bot_sdk-1.4.37 → devops_bot_sdk-1.4.39}/sdk/ipc/__init__.py +0 -0
  25. {devops_bot_sdk-1.4.37 → devops_bot_sdk-1.4.39}/sdk/ipc/electron_bridge.py +0 -0
  26. {devops_bot_sdk-1.4.37 → devops_bot_sdk-1.4.39}/sdk/local_exec.py +0 -0
  27. {devops_bot_sdk-1.4.37 → devops_bot_sdk-1.4.39}/sdk/models/__init__.py +0 -0
  28. {devops_bot_sdk-1.4.37 → devops_bot_sdk-1.4.39}/sdk/models/envelope.py +0 -0
  29. {devops_bot_sdk-1.4.37 → devops_bot_sdk-1.4.39}/sdk/models/requests.py +0 -0
  30. {devops_bot_sdk-1.4.37 → devops_bot_sdk-1.4.39}/sdk/models/responses.py +0 -0
  31. {devops_bot_sdk-1.4.37 → devops_bot_sdk-1.4.39}/sdk/models/snapshots.py +0 -0
  32. {devops_bot_sdk-1.4.37 → devops_bot_sdk-1.4.39}/sdk/py.typed +0 -0
  33. {devops_bot_sdk-1.4.37 → devops_bot_sdk-1.4.39}/sdk/run_auto.py +0 -0
  34. {devops_bot_sdk-1.4.37 → devops_bot_sdk-1.4.39}/sdk/sse.py +0 -0
  35. {devops_bot_sdk-1.4.37 → devops_bot_sdk-1.4.39}/sdk/test.py +0 -0
  36. {devops_bot_sdk-1.4.37 → devops_bot_sdk-1.4.39}/sdk/test_pipeline.py +0 -0
  37. {devops_bot_sdk-1.4.37 → devops_bot_sdk-1.4.39}/sdk/updater.py +0 -0
  38. {devops_bot_sdk-1.4.37 → devops_bot_sdk-1.4.39}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devops-bot-sdk
3
- Version: 1.4.37
3
+ Version: 1.4.39
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.37
3
+ Version: 1.4.39
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.37"
7
+ version = "1.4.39"
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.37
3
+ Version: 1.4.39
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.37"
33
+ __version__ = "1.4.39"
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.37"
61
+ SDK_VERSION = "1.4.39"
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
@@ -803,6 +803,71 @@ class BackendClient:
803
803
  logger.warning("client.mark_in_progress error task=%s err=%s", task_id, exc)
804
804
  return False
805
805
 
806
+ async def update_task_ml(
807
+ self, task_id: str, fields: dict, model_used: list[str] | None = None,
808
+ ) -> bool:
809
+ """PUT {BE}/tasks-ml/{id} with arbitrary multipart form fields (best-effort).
810
+
811
+ Generic form-update used e.g. to mark an Epic Done while attaching the
812
+ generated detailed description + AI plan. `model_used` is sent as repeated
813
+ `modelUsed` fields. None-valued fields are dropped. Returns True on 2xx.
814
+ """
815
+ from sdk.config import BE_DEFAULT_URL
816
+
817
+ url = f"{BE_DEFAULT_URL.rstrip('/')}/tasks-ml/{task_id}"
818
+ headers = {k: v for k, v in self._headers.items() if k.lower() != "content-type"}
819
+ headers.update({"accept": "*/*", "developer-name": "null", "product": ""})
820
+ # httpx multipart wants a list of (name, (filename, value)) tuples so a
821
+ # field like `modelUsed` can repeat.
822
+ files = [(k, (None, str(v))) for k, v in fields.items() if v is not None]
823
+ for m in (model_used or []):
824
+ files.append(("modelUsed", (None, str(m))))
825
+ try:
826
+ async with httpx.AsyncClient(timeout=30.0) as client:
827
+ resp = await client.put(url, files=files, headers=headers)
828
+ if resp.status_code >= 400:
829
+ logger.warning("client.update_task_ml failed task=%s status=%s body=%s",
830
+ task_id, resp.status_code, resp.text[:200])
831
+ return False
832
+ return True
833
+ except Exception as exc: # noqa: BLE001
834
+ logger.warning("client.update_task_ml error task=%s err=%s", task_id, exc)
835
+ return False
836
+
837
+ async def create_task_ml(
838
+ self, fields: dict, model_used: list[str] | None = None,
839
+ ) -> str | None:
840
+ """POST {BE}/tasks-ml — create a task via multipart form; return its id.
841
+
842
+ Required fields per the API: projectKey, type, summary (description
843
+ optional but recommended). Used to spin a To Do child Task off a decomposed
844
+ Epic. Returns the new task id on success, else None (best-effort).
845
+ """
846
+ from sdk.config import BE_DEFAULT_URL
847
+
848
+ url = f"{BE_DEFAULT_URL.rstrip('/')}/tasks-ml"
849
+ headers = {k: v for k, v in self._headers.items() if k.lower() != "content-type"}
850
+ headers.update({"accept": "*/*", "developer-name": "null", "product": ""})
851
+ files = [(k, (None, str(v))) for k, v in fields.items() if v is not None]
852
+ for m in (model_used or []):
853
+ files.append(("modelUsed", (None, str(m))))
854
+ try:
855
+ async with httpx.AsyncClient(timeout=30.0) as client:
856
+ resp = await client.post(url, files=files, headers=headers)
857
+ if resp.status_code >= 400:
858
+ logger.warning("client.create_task_ml failed status=%s body=%s",
859
+ resp.status_code, resp.text[:300])
860
+ return None
861
+ body = resp.json() or {}
862
+ rec = body.get("data") or body
863
+ # The created record's id may sit at data.id or data.record.id.
864
+ if isinstance(rec, dict):
865
+ return rec.get("id") or ((rec.get("record") or {}).get("id"))
866
+ return None
867
+ except Exception as exc: # noqa: BLE001
868
+ logger.warning("client.create_task_ml error err=%s", exc)
869
+ return None
870
+
806
871
  async def log_activity(self, activity: str) -> bool:
807
872
  """POST /ml-api/agent-activity — emit an agent activity event (best-effort).
808
873
 
@@ -351,6 +351,196 @@ def _parse_scope(recon_text: str | None) -> str:
351
351
  return "in"
352
352
 
353
353
 
354
+ # ── Epic decomposition ────────────────────────────────────────────────────
355
+ # An EPIC is not coded directly. It is analysed (grounded in the codebase when a
356
+ # project path exists) into a detailed user-story description + an implementation
357
+ # plan; the Epic is then marked Done with that content and one To Do child Task is
358
+ # created per generated story (inheriting the Epic's project/assignee/priority/…).
359
+ _MAX_EPIC_STORIES = int(os.getenv("AGENTOS_MAX_EPIC_STORIES", "") or 8)
360
+ # Label recorded in the ticket's modelUsed on Epic completion.
361
+ _EPIC_MODELS = [m.strip() for m in (os.getenv("AGENTOS_EPIC_MODELS", "") or "claude-code").split(",") if m.strip()]
362
+
363
+
364
+ def _build_epic_prompt(jira_key: str, summary: str, description: str | None,
365
+ human_instructions: str | None) -> str:
366
+ """Read-only prompt that turns an Epic into a decomposition JSON."""
367
+ parts: list[str] = [
368
+ "You are decomposing a JIRA EPIC into implementable work. This is ANALYSIS "
369
+ "ONLY — do NOT create, edit, or delete any file.",
370
+ f"=== EPIC {jira_key} ===\nSummary: {summary}",
371
+ ]
372
+ if description and description.strip():
373
+ parts.append("Description:\n" + _clip_text(description.strip()))
374
+ if human_instructions and human_instructions.strip():
375
+ parts.append(
376
+ "Operator instructions (authoritative — carry any project location / "
377
+ "constraints into each story's humanInstructions):\n"
378
+ + human_instructions.strip()
379
+ )
380
+ parts.append(
381
+ "First understand the existing codebase in this directory (read CLAUDE.md, "
382
+ "use graphify / Read / Glob / Grep) so the stories fit the REAL architecture. "
383
+ "Then produce the decomposition.\n\n"
384
+ "Output ONLY a single fenced ```json block and nothing after it, with shape:\n"
385
+ "{\n"
386
+ ' "epic_description": "A detailed description for the epic: a full user-story '
387
+ "write-up covering goal, the user stories, acceptance criteria and scope.\",\n"
388
+ ' "plan": "An ordered, numbered high-level implementation plan.",\n'
389
+ ' "stories": [\n'
390
+ ' {\n'
391
+ ' "summary": "Short imperative story title (a JIRA task summary)",\n'
392
+ ' "description": "Detailed description: what to build + acceptance criteria.",\n'
393
+ ' "humanInstructions": "Operator instructions to implement THIS story. MUST '
394
+ "repeat the same project location/path and constraints from the epic so the "
395
+ 'coding run works in the right place.",\n'
396
+ ' "points": 5\n'
397
+ " }\n"
398
+ " ]\n"
399
+ "}\n"
400
+ "Create as many stories as the epic genuinely needs (do not pad or truncate "
401
+ "artificially); each story must be independently implementable."
402
+ )
403
+ return "\n\n".join(parts)
404
+
405
+
406
+ def _extract_json_block(text: str | None) -> dict | None:
407
+ """Parse the decomposition JSON from a claude response (fenced block preferred)."""
408
+ if not text:
409
+ return None
410
+ import json as _json
411
+ candidates = re.findall(r"```(?:json)?\s*(\{.*?\})\s*```", text, re.S | re.I)
412
+ if not candidates:
413
+ start, end = text.find("{"), text.rfind("}")
414
+ if start != -1 and end > start:
415
+ candidates = [text[start:end + 1]]
416
+ for c in reversed(candidates):
417
+ try:
418
+ obj = _json.loads(c)
419
+ if isinstance(obj, dict):
420
+ return obj
421
+ except Exception: # noqa: BLE001
422
+ continue
423
+ return None
424
+
425
+
426
+ def _epic_inherited_fields(task: dict) -> dict:
427
+ """Parent-Epic fields a child Task should inherit (drops empties)."""
428
+ def _date(v: object) -> str | None:
429
+ s = str(v or "")
430
+ return s[:10] if s else None # ISO datetime → YYYY-MM-DD
431
+
432
+ fields = {
433
+ "projectKey": task.get("projectKey"),
434
+ "priority": task.get("priority"),
435
+ "assigneeEmail": (task.get("assignee") or {}).get("email"),
436
+ "teamId": task.get("teamId"),
437
+ "startDate": _date(task.get("startDate")),
438
+ "dueDate": _date(task.get("dueDate")),
439
+ "slaTarget": task.get("slaTarget"),
440
+ }
441
+ sprint = task.get("sprint") or {}
442
+ if sprint.get("jiraSprintId"):
443
+ fields["jiraSprintId"] = sprint["jiraSprintId"]
444
+ return {k: v for k, v in fields.items() if v}
445
+
446
+
447
+ async def _handle_epic(
448
+ client: BackendClient, task: dict, payload: dict, send: Callable, thread_id: str,
449
+ ) -> None:
450
+ """Decompose an Epic: generate user stor(y|ies), mark the Epic Done with the
451
+ detailed description + plan, then create a To Do child Task per story.
452
+
453
+ Child tasks are created in "To Do"; a later `agentos-auto` run picks them up and
454
+ implements them through the normal grounded pipeline. Best-effort throughout —
455
+ a failure returns the Epic to Backlog for human review rather than raising.
456
+ """
457
+ from sdk import local_exec, graphify
458
+
459
+ tid = str(task.get("id", ""))
460
+ jira_key = task.get("jiraIssueKey") or task.get("jiraKey", "") or "EPIC"
461
+ summary = task.get("summary", "") or ""
462
+ description = task.get("description")
463
+ human = task.get("humanInstructions")
464
+ project_path = _resolve_local_project_path(task, payload)
465
+
466
+ await client.mark_in_progress(tid)
467
+ await send(Envelope(
468
+ type="step_update", thread_id=thread_id,
469
+ data={"jira_task_id": tid, "task_id": tid,
470
+ "current_step": f"Epic {jira_key}: analysing & generating user stories"},
471
+ ).model_dump())
472
+
473
+ # 1. Generate the decomposition, grounded in the codebase where one exists.
474
+ try:
475
+ await graphify.prepare(project_path)
476
+ except Exception: # noqa: BLE001
477
+ pass
478
+ gen = await local_exec.run_claude_local(
479
+ _build_epic_prompt(jira_key, summary, description, human),
480
+ project_path, allowed_tools=_RECON_ALLOWED_TOOLS, # read-only analysis
481
+ github_token=await client.github_token(),
482
+ )
483
+ doc = _extract_json_block(gen.get("result")) if gen.get("ok") else None
484
+ stories = (doc or {}).get("stories") or []
485
+ if not doc or not stories:
486
+ reason = (gen.get("error") or "could not generate a valid decomposition")[:200]
487
+ await client.log_activity(f"Epic {jira_key}: decomposition failed — {reason}")
488
+ await client.move_to_backlog(tid)
489
+ await send(Envelope(
490
+ type=Envelope.TYPE_ERROR, thread_id=thread_id,
491
+ data={"code": "EPIC_DECOMPOSE_FAILED", "task_id": tid, "message": reason},
492
+ ).model_dump())
493
+ return
494
+
495
+ # 2. Mark the Epic Done with the detailed description + plan.
496
+ epic_desc = (doc.get("epic_description") or description or summary or "")[:20000]
497
+ plan = (doc.get("plan") or "")[:8000]
498
+ await client.update_task_ml(
499
+ tid, {"status": "Done", "description": epic_desc, "initialAiPlan": plan},
500
+ model_used=_EPIC_MODELS,
501
+ )
502
+ await send(Envelope(
503
+ type="step_update", thread_id=thread_id,
504
+ data={"jira_task_id": tid, "current_step": f"Epic {jira_key}: marked Done — "
505
+ f"creating {min(len(stories), _MAX_EPIC_STORIES)} child task(s)"},
506
+ ).model_dump())
507
+
508
+ # 3. Create a To Do child Task per story, inheriting the Epic's fields.
509
+ inherited = _epic_inherited_fields(task)
510
+ created: list[str] = []
511
+ for st in stories[:_MAX_EPIC_STORIES]:
512
+ if not isinstance(st, dict) or not (st.get("summary") or "").strip():
513
+ continue
514
+ fields = {
515
+ **inherited,
516
+ "type": "Task",
517
+ "status": "To Do",
518
+ "summary": str(st.get("summary", "")).strip()[:250],
519
+ "description": str(st.get("description", "") or "")[:20000],
520
+ "humanInstructions": str(st.get("humanInstructions") or human or "")[:20000] or None,
521
+ "parentTaskId": tid,
522
+ }
523
+ if st.get("points"):
524
+ fields["points"] = st["points"]
525
+ new_id = await client.create_task_ml(fields, model_used=_EPIC_MODELS)
526
+ if new_id:
527
+ created.append(new_id)
528
+ await send(Envelope(
529
+ type="step_update", thread_id=thread_id,
530
+ data={"current_step": f" ↳ created To Do task: {fields['summary']}"},
531
+ ).model_dump())
532
+
533
+ await client.log_activity(
534
+ f"Epic {jira_key}: Done — created {len(created)}/{len(stories)} child To Do task(s)"
535
+ )
536
+ await send(Envelope(
537
+ type="step_update", thread_id=thread_id,
538
+ data={"jira_task_id": tid,
539
+ "current_step": f"Epic {jira_key} complete — {len(created)} child task(s) queued "
540
+ "(run again to implement them)"},
541
+ ).model_dump())
542
+
543
+
354
544
  async def _run_local_pipeline(
355
545
  client: BackendClient, payload: dict, send: Callable
356
546
  ) -> None:
@@ -2189,6 +2379,27 @@ async def handle_orchestrate_auto_local_run(
2189
2379
  "project_path": project_path, "jira_task_id": tid, "agent_mode": agent_mode},
2190
2380
  ).model_dump())
2191
2381
 
2382
+ # EPIC → decompose (generate user stories, mark Done, create To Do
2383
+ # child tasks) instead of coding it directly. Isolated like every other
2384
+ # task: a failure here must not abort the remaining queue.
2385
+ if str(task.get("type") or "").strip().lower() == "epic":
2386
+ try:
2387
+ await _handle_epic(client, task, payload, send, thread_id)
2388
+ except Exception as exc: # noqa: BLE001
2389
+ logger.warning("auto_local.epic_failed task=%s err=%s", tid, exc)
2390
+ try:
2391
+ await client.log_activity(f"Task {tid}: epic decomposition errored — {str(exc)[:200]}")
2392
+ await client.move_to_backlog(tid)
2393
+ except Exception: # noqa: BLE001
2394
+ pass
2395
+ await send(Envelope(
2396
+ type=Envelope.TYPE_ERROR, thread_id=thread_id,
2397
+ data={"code": "EPIC_DECOMPOSE_FAILED", "task_id": tid,
2398
+ "message": str(exc)[:200]},
2399
+ ).model_dump())
2400
+ processed += 1
2401
+ continue
2402
+
2192
2403
  task_payload = {
2193
2404
  "thread_id": thread_id,
2194
2405
  "jira_task_id": tid,