devops-bot-sdk 1.4.159__tar.gz → 1.6.2__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.
- {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/PKG-INFO +1 -1
- {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/devops_bot_sdk.egg-info/PKG-INFO +1 -1
- {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/devops_bot_sdk.egg-info/SOURCES.txt +2 -0
- {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/sdk/__init__.py +2 -2
- {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/sdk/cli.py +61 -1
- {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/sdk/client.py +78 -10
- devops_bot_sdk-1.6.2/sdk/github_ingest.py +408 -0
- {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/sdk/run_auto.py +41 -0
- {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/sdk/ui/api.py +34 -0
- {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/sdk/ui/server.py +24 -13
- devops_bot_sdk-1.6.2/tests/test_github_ingest.py +396 -0
- {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/README.md +0 -0
- {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/devops_bot_sdk.egg-info/dependency_links.txt +0 -0
- {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/devops_bot_sdk.egg-info/entry_points.txt +0 -0
- {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/devops_bot_sdk.egg-info/requires.txt +0 -0
- {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/devops_bot_sdk.egg-info/top_level.txt +0 -0
- {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/pyproject.toml +0 -0
- {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/sdk/agentd.py +0 -0
- {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/sdk/agents/__init__.py +0 -0
- {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/sdk/agents/activity.py +0 -0
- {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/sdk/agents/builder.py +0 -0
- {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/sdk/agents/chat.py +0 -0
- {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/sdk/agents/connectors.py +0 -0
- {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/sdk/agents/engines/__init__.py +0 -0
- {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/sdk/agents/engines/claude_code.py +0 -0
- {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/sdk/agents/engines/http.py +0 -0
- {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/sdk/agents/engines/mcp.py +0 -0
- {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/sdk/agents/engines/n8n.py +0 -0
- {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/sdk/agents/engines/shell.py +0 -0
- {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/sdk/agents/integrations.py +0 -0
- {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/sdk/agents/inventory.py +0 -0
- {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/sdk/agents/library/__init__.py +0 -0
- {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/sdk/agents/manifest.py +0 -0
- {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/sdk/agents/prompt_studio.py +0 -0
- {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/sdk/agents/prompt_template.py +0 -0
- {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/sdk/agents/prompt_vars.py +0 -0
- {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/sdk/agents/run_log.py +0 -0
- {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/sdk/agents/secrets.py +0 -0
- {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/sdk/agents/ticket_run.py +0 -0
- {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/sdk/agents/usage.py +0 -0
- {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/sdk/agents/varstore.py +0 -0
- {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/sdk/collectors/__init__.py +0 -0
- {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/sdk/collectors/files.py +0 -0
- {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/sdk/collectors/process.py +0 -0
- {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/sdk/collectors/screenshot.py +0 -0
- {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/sdk/config.py +0 -0
- {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/sdk/crucial.py +0 -0
- {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/sdk/design_verify/__init__.py +0 -0
- {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/sdk/design_verify/assets.py +0 -0
- {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/sdk/design_verify/bootstrap.py +0 -0
- {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/sdk/design_verify/browser.py +0 -0
- {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/sdk/design_verify/compare.py +0 -0
- {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/sdk/design_verify/loop.py +0 -0
- {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/sdk/exceptions.py +0 -0
- {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/sdk/git_ops.py +0 -0
- {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/sdk/graphify.py +0 -0
- {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/sdk/hooks/__init__.py +0 -0
- {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/sdk/hooks/crucial_guard.py +0 -0
- {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/sdk/ipc/__init__.py +0 -0
- {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/sdk/ipc/electron_bridge.py +0 -0
- {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/sdk/ipc/handlers.py +0 -0
- {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/sdk/local_exec.py +0 -0
- {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/sdk/models/__init__.py +0 -0
- {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/sdk/models/envelope.py +0 -0
- {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/sdk/models/requests.py +0 -0
- {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/sdk/models/responses.py +0 -0
- {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/sdk/models/snapshots.py +0 -0
- {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/sdk/platform_compat.py +0 -0
- {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/sdk/py.typed +0 -0
- {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/sdk/sse.py +0 -0
- {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/sdk/test.py +0 -0
- {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/sdk/test_pipeline.py +0 -0
- {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/sdk/ui/__init__.py +0 -0
- {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/sdk/ui/dashboard.py +0 -0
- {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/sdk/ui/health.py +0 -0
- {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/sdk/ui/ip_allowlist.py +0 -0
- {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/sdk/ui/jobs.py +0 -0
- {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/sdk/ui/models.py +0 -0
- {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/sdk/ui/runner.py +0 -0
- {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/sdk/ui/security.py +0 -0
- {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/sdk/ui/service.py +0 -0
- {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/sdk/ui/session.py +0 -0
- {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/sdk/ui/setup_manager.py +0 -0
- {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/sdk/ui/static/agents.html +0 -0
- {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/sdk/ui/static/app.js +0 -0
- {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/sdk/ui/static/custom-agents.html +0 -0
- {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/sdk/ui/static/default-agents.html +0 -0
- {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/sdk/ui/static/index.html +0 -0
- {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/sdk/ui/static/login.html +0 -0
- {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/sdk/ui/static/runs.html +0 -0
- {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/sdk/ui/static/setup.html +0 -0
- {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/sdk/ui/static/styles.css +0 -0
- {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/sdk/ui/static/tickets.html +0 -0
- {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/sdk/updater.py +0 -0
- {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/sdk/vps_health.py +0 -0
- {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/setup.cfg +0 -0
- {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/tests/test_agent_activity.py +0 -0
- {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/tests/test_agent_chat_features.py +0 -0
- {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/tests/test_agent_prompt_async.py +0 -0
- {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/tests/test_agent_prompt_models.py +0 -0
- {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/tests/test_agent_run_ticket.py +0 -0
- {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/tests/test_agents_connectors_builder.py +0 -0
- {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/tests/test_git_action.py +0 -0
- {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/tests/test_health.py +0 -0
- {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/tests/test_otp_login.py +0 -0
- {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/tests/test_updater_extras.py +0 -0
- {devops_bot_sdk-1.4.159 → devops_bot_sdk-1.6.2}/tests/test_vps_health.py +0 -0
|
@@ -14,6 +14,7 @@ sdk/config.py
|
|
|
14
14
|
sdk/crucial.py
|
|
15
15
|
sdk/exceptions.py
|
|
16
16
|
sdk/git_ops.py
|
|
17
|
+
sdk/github_ingest.py
|
|
17
18
|
sdk/graphify.py
|
|
18
19
|
sdk/local_exec.py
|
|
19
20
|
sdk/platform_compat.py
|
|
@@ -97,6 +98,7 @@ tests/test_agent_prompt_models.py
|
|
|
97
98
|
tests/test_agent_run_ticket.py
|
|
98
99
|
tests/test_agents_connectors_builder.py
|
|
99
100
|
tests/test_git_action.py
|
|
101
|
+
tests/test_github_ingest.py
|
|
100
102
|
tests/test_health.py
|
|
101
103
|
tests/test_otp_login.py
|
|
102
104
|
tests/test_updater_extras.py
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"""AgentOS Desktop SDK — thin HTTPS/SSE client for the Electron app.
|
|
2
2
|
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.6.2
|
|
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.
|
|
33
|
+
__version__ = "1.6.2" # 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
|
|
@@ -96,12 +96,16 @@ def main() -> None:
|
|
|
96
96
|
_default_agents_command(rest)
|
|
97
97
|
return
|
|
98
98
|
|
|
99
|
+
if noun == "github":
|
|
100
|
+
_github_command(rest)
|
|
101
|
+
return
|
|
102
|
+
|
|
99
103
|
_print_usage()
|
|
100
104
|
sys.exit(1)
|
|
101
105
|
|
|
102
106
|
|
|
103
107
|
def _print_usage() -> None:
|
|
104
|
-
print("usage: agentos <configure|agent|integration|default-agents|agentd> ...")
|
|
108
|
+
print("usage: agentos <configure|agent|integration|default-agents|agentd|github> ...")
|
|
105
109
|
print()
|
|
106
110
|
print(" agentos configure [--rotate]")
|
|
107
111
|
print(" agentos agent <list|run NAME|chat NAME MSG|logs NAME|enable NAME|disable NAME|rm NAME>")
|
|
@@ -109,6 +113,7 @@ def _print_usage() -> None:
|
|
|
109
113
|
print(" agentos integration <add PATH|test NAME|list|rm NAME>")
|
|
110
114
|
print(" agentos default-agents list [--department-id UUID] # read-only; agents are created in the web app")
|
|
111
115
|
print(" agentos agentd <start [--foreground]|status>")
|
|
116
|
+
print(" agentos github <ingest [--days N]|status> # PR ingestion; --days N backfills N days")
|
|
112
117
|
|
|
113
118
|
|
|
114
119
|
# ── agent ────────────────────────────────────────────────────────────────
|
|
@@ -590,5 +595,60 @@ def _agentd_command(args: list[str]) -> None:
|
|
|
590
595
|
sys.exit(1)
|
|
591
596
|
|
|
592
597
|
|
|
598
|
+
# ── github ───────────────────────────────────────────────────────────────
|
|
599
|
+
|
|
600
|
+
def _github_command(args: list[str]) -> None:
|
|
601
|
+
"""`agentos github ingest [--days N]` / `agentos github status`.
|
|
602
|
+
|
|
603
|
+
The daily ingestion normally runs by itself inside `agentos-ui` (see
|
|
604
|
+
sdk.github_ingest). This is the manual handle: `ingest` runs one now,
|
|
605
|
+
`--days N` forces an N-day window — that is the backfill.
|
|
606
|
+
"""
|
|
607
|
+
from sdk import github_ingest as gi
|
|
608
|
+
|
|
609
|
+
verb = args[0] if args else None
|
|
610
|
+
|
|
611
|
+
if verb == "status":
|
|
612
|
+
st = gi.status()
|
|
613
|
+
state = st.get("state") or {}
|
|
614
|
+
print(f"enabled : {st['enabled']}")
|
|
615
|
+
print(f"daily slot : {st['slot_utc']} UTC")
|
|
616
|
+
print(f"catch-up cap : {st['lookback_days']} days")
|
|
617
|
+
print(f"first-run seed : {st['backfill_days']} days"
|
|
618
|
+
f"{'' if st['backfilled'] else ' ← not run yet; the next run backfills'}")
|
|
619
|
+
print(f"last success : {state.get('last_success_at') or '(never)'}")
|
|
620
|
+
print(f"last status : {state.get('last_status') or '(none)'}")
|
|
621
|
+
if state.get("last_job_id"):
|
|
622
|
+
print(f"last job : {state['last_job_id']} {state.get('last_status_url', '')}")
|
|
623
|
+
if state.get("last_error"):
|
|
624
|
+
print(f"last error : {state['last_error']}")
|
|
625
|
+
return
|
|
626
|
+
|
|
627
|
+
if verb == "ingest":
|
|
628
|
+
days = None
|
|
629
|
+
if "--days" in args:
|
|
630
|
+
try:
|
|
631
|
+
days = int(args[args.index("--days") + 1])
|
|
632
|
+
except (IndexError, ValueError):
|
|
633
|
+
print("error: --days needs a whole number of days, e.g. --days 50")
|
|
634
|
+
sys.exit(1)
|
|
635
|
+
result = asyncio.run(gi.run_once(days=days))
|
|
636
|
+
status = result.get("status")
|
|
637
|
+
if status == "queued":
|
|
638
|
+
print(f"queued: PRs since {result['since']} job_id={result.get('job_id') or '(none)'}")
|
|
639
|
+
if result.get("status_url"):
|
|
640
|
+
print(f"poll : {result['status_url']} (on the AI backend)")
|
|
641
|
+
return
|
|
642
|
+
if status == "no_github_link":
|
|
643
|
+
print("nothing to do: this account has no GitHub connected.")
|
|
644
|
+
return
|
|
645
|
+
print(f"failed: {result.get('reason') or 'unknown error'}")
|
|
646
|
+
sys.exit(1)
|
|
647
|
+
|
|
648
|
+
print("usage: agentos github ingest [--days N] # N-day backfill; omit for the normal window")
|
|
649
|
+
print(" agentos github status")
|
|
650
|
+
sys.exit(1)
|
|
651
|
+
|
|
652
|
+
|
|
593
653
|
if __name__ == "__main__":
|
|
594
654
|
main()
|
|
@@ -763,13 +763,18 @@ class BackendClient:
|
|
|
763
763
|
except Exception:
|
|
764
764
|
return "pending"
|
|
765
765
|
|
|
766
|
-
async def
|
|
767
|
-
"""GET /ml-api/github/token → the
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
766
|
+
async def github_token_record(self) -> dict | None:
|
|
767
|
+
"""GET /ml-api/github/token → the caller's WHOLE GitHub link record.
|
|
768
|
+
|
|
769
|
+
Returns the ``data`` object — ``{id, userId, githubId, username,
|
|
770
|
+
accessToken, scope, …}`` — or None when this user has no GitHub link, the
|
|
771
|
+
token is rejected, or the backend is unreachable. Inherently per-user: the
|
|
772
|
+
ML backend resolves WHICH link to return from the ``co_`` token, so there
|
|
773
|
+
is no list-all form.
|
|
774
|
+
|
|
775
|
+
``github_token()`` below is the accessToken-only view of this. The daily
|
|
776
|
+
PR-ingest cron (``sdk.github_ingest``) needs ``username`` too, which is why
|
|
777
|
+
the full record is exposed. Best-effort — never raises.
|
|
773
778
|
"""
|
|
774
779
|
from sdk.config import BE_DEFAULT_URL
|
|
775
780
|
|
|
@@ -785,9 +790,72 @@ class BackendClient:
|
|
|
785
790
|
resp = await client.get(url, headers=headers)
|
|
786
791
|
if resp.status_code >= 400:
|
|
787
792
|
return None
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
793
|
+
return (resp.json() or {}).get("data") or None
|
|
794
|
+
except Exception: # noqa: BLE001
|
|
795
|
+
return None
|
|
796
|
+
|
|
797
|
+
async def github_token(self) -> str | None:
|
|
798
|
+
"""GET /ml-api/github/token → the user's GitHub access token (gho_…).
|
|
799
|
+
|
|
800
|
+
Used by the LOCAL agent to authenticate git/gh for clone/push/PR. The
|
|
801
|
+
endpoint lives on the ML backend (devopsbot-be), authorised by the same
|
|
802
|
+
co_ token. Best-effort: returns None on any failure so a run without a
|
|
803
|
+
connected GitHub account still proceeds (clone/PR steps just won't auth).
|
|
804
|
+
"""
|
|
805
|
+
record = await self.github_token_record()
|
|
806
|
+
return (record or {}).get("accessToken") or None
|
|
807
|
+
|
|
808
|
+
async def ingest_github_prs(
|
|
809
|
+
self,
|
|
810
|
+
*,
|
|
811
|
+
github_username: str,
|
|
812
|
+
github_access_token: str,
|
|
813
|
+
since: str | None = None,
|
|
814
|
+
user_id: str = "",
|
|
815
|
+
) -> dict | None:
|
|
816
|
+
"""POST /api/v1/github/prs — queue PR ingestion for THIS user on the backend.
|
|
817
|
+
|
|
818
|
+
This is the daily GitHub ingestion trigger. It runs on the AI backend
|
|
819
|
+
(``devopsbot-ai.apiswagger.co.uk`` — ``self._url``, the same base every other
|
|
820
|
+
``/api/v1`` call uses), NOT the ML gateway: the backend owns the LLM
|
|
821
|
+
summarise, the downstream daily-summary forward, and the Qdrant upsert.
|
|
822
|
+
The SDK only decides WHEN, because only this machine knows this user's
|
|
823
|
+
schedule and its own last-success watermark.
|
|
824
|
+
|
|
825
|
+
``since`` is an ISO date (``YYYY-MM-DD``) → the backend's
|
|
826
|
+
``GetUserPRsRequest.date_from``. Omitted means "today", which is what a
|
|
827
|
+
never-missed daily run wants; ``sdk.github_ingest`` sends the catch-up
|
|
828
|
+
watermark instead so a machine that was off does not silently lose days.
|
|
829
|
+
|
|
830
|
+
The credentials are sent in the body because that is what the endpoint
|
|
831
|
+
accepts today. The ``Authorization: co_`` header goes with every request
|
|
832
|
+
regardless, so once the backend resolves the GitHub link server-side from
|
|
833
|
+
that token (see docs/GITHUB_SCHEDULING_SDK_MIGRATION.md §3 step 1) it can
|
|
834
|
+
ignore the body fields without breaking this client.
|
|
835
|
+
|
|
836
|
+
Returns the response JSON (``{job_id, …}``) or None on any failure —
|
|
837
|
+
never raises into the cron.
|
|
838
|
+
"""
|
|
839
|
+
payload = [{
|
|
840
|
+
"userId": user_id or self._user_id_from_token(),
|
|
841
|
+
"githubUsername": github_username,
|
|
842
|
+
"githubAccessToken": github_access_token,
|
|
843
|
+
"apiTokenHash": self._token, # downstream daily-summary Authorization
|
|
844
|
+
}]
|
|
845
|
+
if since:
|
|
846
|
+
payload[0]["date_from"] = since
|
|
847
|
+
try:
|
|
848
|
+
async with httpx.AsyncClient(timeout=60.0) as client:
|
|
849
|
+
resp = await client.post(
|
|
850
|
+
self._url("/api/v1/github/prs"), json=payload, headers=self._headers,
|
|
851
|
+
)
|
|
852
|
+
if resp.status_code >= 400:
|
|
853
|
+
logger.warning("client.ingest_github_prs failed status=%s body=%s",
|
|
854
|
+
resp.status_code, resp.text[:200])
|
|
855
|
+
return None
|
|
856
|
+
return resp.json()
|
|
857
|
+
except Exception as exc: # noqa: BLE001
|
|
858
|
+
logger.warning("client.ingest_github_prs error err=%s", exc)
|
|
791
859
|
return None
|
|
792
860
|
|
|
793
861
|
async def move_to_backlog(self, task_id: str) -> bool:
|
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
"""Daily GitHub PR ingestion — the SDK-side scheduler.
|
|
2
|
+
|
|
3
|
+
Replaces the AI backend's Celery-beat entry ``github-prs-daily``, which fired
|
|
4
|
+
once for the whole org and iterated a Postgres roster of every user holding a
|
|
5
|
+
``co_`` token. Here there is no roster: one machine is one user, and this machine
|
|
6
|
+
already holds that user's token — so it schedules only itself. See
|
|
7
|
+
``docs/GITHUB_SCHEDULING_SDK_MIGRATION.md`` in the devops-bot-ai repo (Option A).
|
|
8
|
+
|
|
9
|
+
What moves and what does NOT:
|
|
10
|
+
* MOVED here — the trigger, and only the trigger.
|
|
11
|
+
* STAYS on the backend — fetching the PRs, the LLM summarise, the per-repo
|
|
12
|
+
daily-summary forward, and the Qdrant upsert. This machine never talks to
|
|
13
|
+
Postgres, Qdrant, or Azure, and holds no credential beyond the ``co_`` token
|
|
14
|
+
it was configured with. One VPS cannot write another user's vectors.
|
|
15
|
+
|
|
16
|
+
**Two things run, both on by default, no configuration needed:**
|
|
17
|
+
1. a ONE-TIME 50-day history backfill, fired from ``agentos-auto update`` via
|
|
18
|
+
:func:`run_backfill_once` and guarded by ``BACKFILL_ID`` so it happens at
|
|
19
|
+
most once per machine;
|
|
20
|
+
2. the DAILY loop below, started by the ``agentos-ui`` lifespan alongside the
|
|
21
|
+
2-hourly ``sdk.vps_health`` ping.
|
|
22
|
+
|
|
23
|
+
The daily loop also covers the backfill: if a box never runs ``agentos-auto
|
|
24
|
+
update``, the first tick sees ``backfill_pending`` and seeds the history itself.
|
|
25
|
+
|
|
26
|
+
Schedule semantics — a real daily slot, not a naive interval:
|
|
27
|
+
* the loop ticks every ``_TICK_S`` (cheap: no network unless a run is due),
|
|
28
|
+
* a run is DUE when the UTC clock has passed ``AGENTOS_GITHUB_INGEST_AT``
|
|
29
|
+
(default ``13:30`` UTC = 18:30 Asia/Karachi, the slot the beat entry used) and
|
|
30
|
+
no successful run has been recorded for today's UTC date,
|
|
31
|
+
* so a machine that was asleep at 13:30 catches up the moment it comes back —
|
|
32
|
+
the failure mode that made this the riskiest part of the migration.
|
|
33
|
+
|
|
34
|
+
Catch-up window: the request carries ``since`` = the date of the last SUCCESSFUL
|
|
35
|
+
run (capped at ``_MAX_LOOKBACK_DAYS``), not "today". A box that was off for three
|
|
36
|
+
days asks for three days of PRs on its next run instead of silently losing them.
|
|
37
|
+
The backend's ingestion is expected to be idempotent per (user, repo, date) —
|
|
38
|
+
overlapping windows WILL re-send PRs.
|
|
39
|
+
|
|
40
|
+
State lives in ``~/.agentos/github_ingest.json``: ``last_success_at`` (the
|
|
41
|
+
watermark), plus the last attempt's outcome for ``GET /api/ui/github-ingest``.
|
|
42
|
+
|
|
43
|
+
Config — process env OR ``~/.agentos/.env`` (process env wins), all optional:
|
|
44
|
+
``AGENTOS_GITHUB_INGEST`` ``0``/``false``/``no``/``off`` disables everything.
|
|
45
|
+
``AGENTOS_GITHUB_INGEST_DAILY`` ``0`` turns the recurring daily loop OFF (default ON).
|
|
46
|
+
``AGENTOS_GITHUB_INGEST_BACKFILL_DAYS`` one-time seed size (default 50, max 365).
|
|
47
|
+
``AGENTOS_GITHUB_INGEST_AT`` daily UTC slot, ``HH:MM`` (default ``13:30``).
|
|
48
|
+
``AGENTOS_GITHUB_INGEST_LOOKBACK_DAYS`` catch-up cap (default 7, max 90).
|
|
49
|
+
|
|
50
|
+
Best-effort throughout: a failed run is recorded and retried on the next day's
|
|
51
|
+
slot; nothing here raises into the host process.
|
|
52
|
+
"""
|
|
53
|
+
from __future__ import annotations
|
|
54
|
+
|
|
55
|
+
import asyncio
|
|
56
|
+
import json
|
|
57
|
+
import logging
|
|
58
|
+
import os
|
|
59
|
+
from datetime import date, datetime, timedelta, timezone
|
|
60
|
+
from pathlib import Path
|
|
61
|
+
from typing import TYPE_CHECKING, Any
|
|
62
|
+
|
|
63
|
+
if TYPE_CHECKING: # pragma: no cover — typing only
|
|
64
|
+
from sdk.client import BackendClient
|
|
65
|
+
|
|
66
|
+
logger = logging.getLogger("sdk.github_ingest")
|
|
67
|
+
|
|
68
|
+
STATE_FILE = Path.home() / ".agentos" / "github_ingest.json"
|
|
69
|
+
|
|
70
|
+
DEFAULT_AT = "13:30" # UTC — 18:30 Asia/Karachi (PKT = UTC+5, no DST)
|
|
71
|
+
DEFAULT_LOOKBACK_DAYS = 7
|
|
72
|
+
MAX_LOOKBACK_DAYS = 90
|
|
73
|
+
# First run on a machine (no watermark yet) pulls HISTORY, not just today — so a
|
|
74
|
+
# box that upgrades into this version seeds the platform with real PR history
|
|
75
|
+
# instead of starting empty. This is the auto-trigger: the state file does not
|
|
76
|
+
# exist on any machine yet, so the first tick after the upgrade backfills.
|
|
77
|
+
DEFAULT_BACKFILL_DAYS = 50
|
|
78
|
+
MAX_BACKFILL_DAYS = 365
|
|
79
|
+
# Identifies THIS rollout's one-time history seed. The backfill runs once per
|
|
80
|
+
# machine per id: a box that already has a watermark (it was ingesting before this
|
|
81
|
+
# release) still gets the 50-day seed exactly once, and no future upgrade repeats
|
|
82
|
+
# it. Bump this string ONLY to deliberately re-seed the whole fleet.
|
|
83
|
+
BACKFILL_ID = "2026-08-pr50"
|
|
84
|
+
_TICK_S = 900.0 # 15 min — resolution of the daily slot check
|
|
85
|
+
_FALSEY = {"0", "false", "no", "off"}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
# ── Config ──────────────────────────────────────────────────────────────────
|
|
89
|
+
|
|
90
|
+
def _setting(name: str) -> str:
|
|
91
|
+
"""Read a toggle from the process env OR ``~/.agentos/.env`` (process env wins)."""
|
|
92
|
+
val = (os.environ.get(name) or "").strip()
|
|
93
|
+
if val:
|
|
94
|
+
return val
|
|
95
|
+
try:
|
|
96
|
+
from sdk.config import _dotenv_value
|
|
97
|
+
return (_dotenv_value(name) or "").strip()
|
|
98
|
+
except Exception: # noqa: BLE001 — a config hiccup must never break the cron
|
|
99
|
+
return ""
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def enabled() -> bool:
|
|
103
|
+
"""Master switch — False only when ``AGENTOS_GITHUB_INGEST`` is explicitly falsey.
|
|
104
|
+
|
|
105
|
+
Gates BOTH the one-time backfill and the daily loop.
|
|
106
|
+
"""
|
|
107
|
+
return _setting("AGENTOS_GITHUB_INGEST").lower() not in _FALSEY
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def daily_enabled() -> bool:
|
|
111
|
+
"""Is the RECURRING daily loop switched on? **Default: yes.**
|
|
112
|
+
|
|
113
|
+
The steady state is two schedules running side by side on every box, with no
|
|
114
|
+
configuration required: ``sdk.vps_health`` every 2 hours, and this one daily
|
|
115
|
+
at :func:`slot`. Opt OUT with ``AGENTOS_GITHUB_INGEST_DAILY=0`` (or turn off
|
|
116
|
+
GitHub ingestion entirely with ``AGENTOS_GITHUB_INGEST=0``).
|
|
117
|
+
"""
|
|
118
|
+
return _setting("AGENTOS_GITHUB_INGEST_DAILY").lower() not in _FALSEY
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def slot() -> tuple[int, int]:
|
|
122
|
+
"""The daily UTC slot as ``(hour, minute)``. Bad values fall back to the default."""
|
|
123
|
+
raw = _setting("AGENTOS_GITHUB_INGEST_AT") or DEFAULT_AT
|
|
124
|
+
try:
|
|
125
|
+
hh, _, mm = raw.partition(":")
|
|
126
|
+
hour, minute = int(hh), int(mm or 0)
|
|
127
|
+
if 0 <= hour <= 23 and 0 <= minute <= 59:
|
|
128
|
+
return hour, minute
|
|
129
|
+
except ValueError:
|
|
130
|
+
pass
|
|
131
|
+
logger.warning("github_ingest.bad_slot value=%r — using %s", raw, DEFAULT_AT)
|
|
132
|
+
hh, _, mm = DEFAULT_AT.partition(":")
|
|
133
|
+
return int(hh), int(mm)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def lookback_days() -> int:
|
|
137
|
+
"""Catch-up cap in days — how far back a long-offline machine may ask for."""
|
|
138
|
+
raw = _setting("AGENTOS_GITHUB_INGEST_LOOKBACK_DAYS")
|
|
139
|
+
if not raw:
|
|
140
|
+
return DEFAULT_LOOKBACK_DAYS
|
|
141
|
+
try:
|
|
142
|
+
return max(1, min(MAX_LOOKBACK_DAYS, int(raw)))
|
|
143
|
+
except ValueError:
|
|
144
|
+
logger.warning("github_ingest.bad_lookback value=%r — using %s",
|
|
145
|
+
raw, DEFAULT_LOOKBACK_DAYS)
|
|
146
|
+
return DEFAULT_LOOKBACK_DAYS
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def backfill_days() -> int:
|
|
150
|
+
"""How much history the FIRST run on this machine pulls (default 50 days).
|
|
151
|
+
|
|
152
|
+
Distinct from ``lookback_days()``: that bounds a routine catch-up after
|
|
153
|
+
downtime, this is the one-time seed. Set ``AGENTOS_GITHUB_INGEST_BACKFILL_DAYS=0``
|
|
154
|
+
to skip the backfill and start from today.
|
|
155
|
+
"""
|
|
156
|
+
raw = _setting("AGENTOS_GITHUB_INGEST_BACKFILL_DAYS")
|
|
157
|
+
if not raw:
|
|
158
|
+
return DEFAULT_BACKFILL_DAYS
|
|
159
|
+
try:
|
|
160
|
+
return max(0, min(MAX_BACKFILL_DAYS, int(raw)))
|
|
161
|
+
except ValueError:
|
|
162
|
+
logger.warning("github_ingest.bad_backfill value=%r — using %s",
|
|
163
|
+
raw, DEFAULT_BACKFILL_DAYS)
|
|
164
|
+
return DEFAULT_BACKFILL_DAYS
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
# ── State ───────────────────────────────────────────────────────────────────
|
|
168
|
+
|
|
169
|
+
def load_state() -> dict:
|
|
170
|
+
"""Read the watermark file. Missing / corrupt → empty state (a first run)."""
|
|
171
|
+
try:
|
|
172
|
+
if STATE_FILE.exists():
|
|
173
|
+
data = json.loads(STATE_FILE.read_text(encoding="utf-8"))
|
|
174
|
+
if isinstance(data, dict):
|
|
175
|
+
return data
|
|
176
|
+
except Exception as exc: # noqa: BLE001 — a bad state file must not wedge the cron
|
|
177
|
+
logger.warning("github_ingest.state_unreadable err=%s", exc)
|
|
178
|
+
return {}
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def save_state(state: dict) -> None:
|
|
182
|
+
"""Persist the watermark. Never raises — a lost write just re-runs tomorrow."""
|
|
183
|
+
try:
|
|
184
|
+
STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
185
|
+
STATE_FILE.write_text(json.dumps(state, indent=2), encoding="utf-8")
|
|
186
|
+
except Exception as exc: # noqa: BLE001
|
|
187
|
+
logger.warning("github_ingest.state_write_failed err=%s", exc)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
# ── Scheduling decisions (pure — the interesting logic, kept testable) ──────
|
|
191
|
+
|
|
192
|
+
def backfill_pending(state: dict) -> bool:
|
|
193
|
+
"""True when THIS rollout's one-time history seed has not run on this machine.
|
|
194
|
+
|
|
195
|
+
Keyed on ``BACKFILL_ID``, not on "is the state file empty" — so a machine that
|
|
196
|
+
was already ingesting daily before this release still seeds its history once,
|
|
197
|
+
and a machine that has seeded never does it again.
|
|
198
|
+
"""
|
|
199
|
+
return backfill_days() > 0 and str(state.get("backfill_id") or "") != BACKFILL_ID
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def is_due(now: datetime, state: dict) -> bool:
|
|
203
|
+
"""Should a run happen right now?
|
|
204
|
+
|
|
205
|
+
Two reasons: the one-time backfill for this rollout is still pending (fires
|
|
206
|
+
immediately, without waiting for the daily slot — the history should land as
|
|
207
|
+
part of the update, not up to 24h later), or today's slot has passed with no
|
|
208
|
+
successful run recorded for today (UTC).
|
|
209
|
+
"""
|
|
210
|
+
if backfill_pending(state):
|
|
211
|
+
return True
|
|
212
|
+
hour, minute = slot()
|
|
213
|
+
if (now.hour, now.minute) < (hour, minute):
|
|
214
|
+
return False
|
|
215
|
+
last = str(state.get("last_success_at") or "")
|
|
216
|
+
return last[:10] != now.date().isoformat()
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def since_for(now: datetime, state: dict, days: int | None = None) -> str:
|
|
220
|
+
"""The ``date_from`` to request.
|
|
221
|
+
|
|
222
|
+
Three cases, in order:
|
|
223
|
+
* ``days`` given — an explicit backfill (CLI / API): ``now - days``;
|
|
224
|
+
* this rollout's one-time seed still pending (``backfill_pending``) —
|
|
225
|
+
``now - backfill_days()``, 50 days by default, so upgrading into this
|
|
226
|
+
version seeds history instead of starting blank;
|
|
227
|
+
* otherwise — the last success's date, floored at ``lookback_days()``.
|
|
228
|
+
Re-sending the last successful day is deliberate: a run that succeeded at
|
|
229
|
+
13:30 did not see that day's later PRs. With no watermark and the seed
|
|
230
|
+
disabled (``…BACKFILL_DAYS=0``) it falls back to today.
|
|
231
|
+
"""
|
|
232
|
+
if days is not None:
|
|
233
|
+
return (now - timedelta(days=max(0, days))).date().isoformat()
|
|
234
|
+
|
|
235
|
+
if backfill_pending(state):
|
|
236
|
+
return (now - timedelta(days=backfill_days())).date().isoformat()
|
|
237
|
+
|
|
238
|
+
last = str(state.get("last_success_at") or "")[:10]
|
|
239
|
+
if not last:
|
|
240
|
+
return now.date().isoformat()
|
|
241
|
+
try:
|
|
242
|
+
parsed = date.fromisoformat(last)
|
|
243
|
+
except ValueError:
|
|
244
|
+
return now.date().isoformat()
|
|
245
|
+
floor = (now - timedelta(days=lookback_days())).date()
|
|
246
|
+
return max(parsed, floor).isoformat()
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
# ── One run ─────────────────────────────────────────────────────────────────
|
|
250
|
+
|
|
251
|
+
async def run_once(client: "BackendClient | None" = None, *, now: datetime | None = None,
|
|
252
|
+
days: int | None = None) -> dict:
|
|
253
|
+
"""Resolve this user's GitHub link, POST the ingest request, record the outcome.
|
|
254
|
+
|
|
255
|
+
``days`` forces an explicit window (``now - days`` → today) instead of the
|
|
256
|
+
watermark — that is what ``agentos github backfill --days 50`` and
|
|
257
|
+
``POST /api/ui/github-ingest/run?days=50`` use. It still advances the watermark
|
|
258
|
+
on success, so a manual backfill also satisfies that day's scheduled run.
|
|
259
|
+
|
|
260
|
+
Returns ``{ok, status, since, job_id, reason}``. ``status`` is ``queued``,
|
|
261
|
+
``no_github_link`` (this user never connected GitHub — not a failure), or
|
|
262
|
+
``failed``. Never raises.
|
|
263
|
+
"""
|
|
264
|
+
stamp = (now or datetime.now(timezone.utc))
|
|
265
|
+
state = load_state()
|
|
266
|
+
since = since_for(stamp, state, days)
|
|
267
|
+
out: dict[str, Any] = {"ok": False, "status": "failed", "since": since,
|
|
268
|
+
"job_id": "", "reason": ""}
|
|
269
|
+
try:
|
|
270
|
+
if client is None:
|
|
271
|
+
from sdk.client import BackendClient
|
|
272
|
+
client = BackendClient.from_config()
|
|
273
|
+
|
|
274
|
+
record = await client.github_token_record()
|
|
275
|
+
username = str((record or {}).get("username") or "").strip()
|
|
276
|
+
access_token = str((record or {}).get("accessToken") or "").strip()
|
|
277
|
+
if not username or not access_token:
|
|
278
|
+
# The common case for a user who never connected GitHub. Record it and
|
|
279
|
+
# move on — retrying every tick would spam the gateway for no reason.
|
|
280
|
+
out.update({"ok": True, "status": "no_github_link"})
|
|
281
|
+
logger.info("github_ingest.no_github_link")
|
|
282
|
+
state.update({"last_run_at": stamp.isoformat(), "last_status": "no_github_link",
|
|
283
|
+
"backfill_id": BACKFILL_ID})
|
|
284
|
+
save_state(state)
|
|
285
|
+
return out
|
|
286
|
+
|
|
287
|
+
resp = await client.ingest_github_prs(
|
|
288
|
+
github_username=username, github_access_token=access_token, since=since,
|
|
289
|
+
)
|
|
290
|
+
if resp is None:
|
|
291
|
+
out["reason"] = "backend_rejected_or_unreachable"
|
|
292
|
+
state.update({"last_run_at": stamp.isoformat(), "last_status": "failed",
|
|
293
|
+
"last_error": out["reason"]})
|
|
294
|
+
save_state(state)
|
|
295
|
+
logger.warning("github_ingest.failed since=%s", since)
|
|
296
|
+
return out
|
|
297
|
+
|
|
298
|
+
job_id = str(resp.get("job_id") or resp.get("jobId") or "")
|
|
299
|
+
# The backend returns its own poll path (`/api/v1/github/prs/status/{id}`) —
|
|
300
|
+
# keep it verbatim so an operator can follow a queued job without
|
|
301
|
+
# reconstructing the URL.
|
|
302
|
+
status_url = str(resp.get("status_url") or "")
|
|
303
|
+
out.update({"ok": True, "status": "queued", "job_id": job_id,
|
|
304
|
+
"status_url": status_url})
|
|
305
|
+
# Watermark advances on ACCEPTANCE (202 + job_id), not on completion — the
|
|
306
|
+
# endpoint is fire-and-forget. A job that then fails backend-side is visible
|
|
307
|
+
# in its own job status; re-running the whole window daily would be worse.
|
|
308
|
+
state.update({
|
|
309
|
+
"last_success_at": stamp.isoformat(),
|
|
310
|
+
"last_run_at": stamp.isoformat(),
|
|
311
|
+
"last_status": "queued",
|
|
312
|
+
"last_job_id": job_id,
|
|
313
|
+
"last_status_url": status_url,
|
|
314
|
+
"last_since": since,
|
|
315
|
+
"last_error": "",
|
|
316
|
+
# Marks this rollout's one-time seed as done. Stamped on ANY accepted
|
|
317
|
+
# run — the first accepted run IS the seed (its window was the 50 days),
|
|
318
|
+
# so there is nothing left to catch up.
|
|
319
|
+
"backfill_id": BACKFILL_ID,
|
|
320
|
+
})
|
|
321
|
+
save_state(state)
|
|
322
|
+
logger.info("github_ingest.queued job_id=%s since=%s", job_id, since)
|
|
323
|
+
return out
|
|
324
|
+
except Exception as exc: # noqa: BLE001 — the cron must never take the panel down
|
|
325
|
+
out["reason"] = str(exc)[:200]
|
|
326
|
+
logger.warning("github_ingest.error err=%s", exc)
|
|
327
|
+
return out
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def status() -> dict:
|
|
331
|
+
"""Config + watermark + last outcome — surfaced by ``GET /api/ui/github-ingest``."""
|
|
332
|
+
hour, minute = slot()
|
|
333
|
+
state = load_state()
|
|
334
|
+
return {
|
|
335
|
+
"enabled": enabled(),
|
|
336
|
+
"daily_loop": daily_enabled(),
|
|
337
|
+
"slot_utc": f"{hour:02d}:{minute:02d}",
|
|
338
|
+
"lookback_days": lookback_days(),
|
|
339
|
+
"backfill_days": backfill_days(),
|
|
340
|
+
"backfill_id": BACKFILL_ID,
|
|
341
|
+
"backfill_pending": backfill_pending(state), # True → the next run seeds history
|
|
342
|
+
"backfilled": not backfill_pending(state),
|
|
343
|
+
"state": state,
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
# ── The cron loop ───────────────────────────────────────────────────────────
|
|
348
|
+
|
|
349
|
+
async def run_forever(tick_s: float = _TICK_S) -> None:
|
|
350
|
+
"""Tick until cancelled; run when today's slot has passed and today has no run."""
|
|
351
|
+
hour, minute = slot()
|
|
352
|
+
logger.info("github_ingest.cron_start slot_utc=%02d:%02d tick_s=%s", hour, minute, tick_s)
|
|
353
|
+
try:
|
|
354
|
+
while True:
|
|
355
|
+
try:
|
|
356
|
+
now = datetime.now(timezone.utc)
|
|
357
|
+
if is_due(now, load_state()):
|
|
358
|
+
await run_once(now=now)
|
|
359
|
+
except Exception as exc: # noqa: BLE001 — one bad tick must not end the loop
|
|
360
|
+
logger.warning("github_ingest.tick_failed err=%s", exc)
|
|
361
|
+
await asyncio.sleep(tick_s)
|
|
362
|
+
except asyncio.CancelledError: # pragma: no cover — shutdown path
|
|
363
|
+
logger.info("github_ingest.cron_stop")
|
|
364
|
+
raise
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def start() -> "asyncio.Task | None":
|
|
368
|
+
"""Start the RECURRING daily loop. On by default; returns the task, or None
|
|
369
|
+
when GitHub ingestion (or just the daily loop) has been switched off."""
|
|
370
|
+
if not enabled():
|
|
371
|
+
logger.info("github_ingest.disabled (AGENTOS_GITHUB_INGEST is off)")
|
|
372
|
+
return None
|
|
373
|
+
if not daily_enabled():
|
|
374
|
+
logger.info("github_ingest.daily_loop_off (AGENTOS_GITHUB_INGEST_DAILY is off)")
|
|
375
|
+
return None
|
|
376
|
+
return asyncio.ensure_future(run_forever())
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
# ── The one-time backfill (what `agentos-auto update` fires) ────────────────
|
|
380
|
+
|
|
381
|
+
def run_backfill_once(client: "BackendClient | None" = None) -> dict:
|
|
382
|
+
"""Run THIS rollout's 50-day history backfill — at most once per machine.
|
|
383
|
+
|
|
384
|
+
Called from ``agentos-auto update``. Safe to call on every update: the
|
|
385
|
+
``BACKFILL_ID`` marker in ``~/.agentos/github_ingest.json`` means the second
|
|
386
|
+
and later calls are a no-op, so a user who updates weekly still ingests the
|
|
387
|
+
history exactly once.
|
|
388
|
+
|
|
389
|
+
Synchronous by design — the update command is a plain CLI flow, not an event
|
|
390
|
+
loop. Returns ``{ran, status, since, job_id, status_url, reason}``;
|
|
391
|
+
``ran=False`` with ``status="already_done"`` when the marker is already set,
|
|
392
|
+
or ``"disabled"`` when the feature is switched off. Never raises: a failed
|
|
393
|
+
backfill must not fail the update.
|
|
394
|
+
"""
|
|
395
|
+
if not enabled():
|
|
396
|
+
return {"ran": False, "status": "disabled", "reason": "AGENTOS_GITHUB_INGEST is off"}
|
|
397
|
+
|
|
398
|
+
state = load_state()
|
|
399
|
+
if not backfill_pending(state):
|
|
400
|
+
return {"ran": False, "status": "already_done",
|
|
401
|
+
"since": state.get("last_since", ""), "job_id": state.get("last_job_id", "")}
|
|
402
|
+
|
|
403
|
+
try:
|
|
404
|
+
result = asyncio.run(run_once(client, days=backfill_days()))
|
|
405
|
+
except Exception as exc: # noqa: BLE001 — never fail `agentos-auto update`
|
|
406
|
+
logger.warning("github_ingest.backfill_failed err=%s", exc)
|
|
407
|
+
return {"ran": True, "status": "failed", "reason": str(exc)[:200]}
|
|
408
|
+
return {"ran": True, **result}
|