omneval-devloop 0.0.1__py3-none-any.whl

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.
devloop/schedules.py ADDED
@@ -0,0 +1,82 @@
1
+ """Temporal Schedules for the Dev Loop nightly sweep and weekly summary.
2
+
3
+ * Nightly (03:00): start a Dev Loop per enrolled project. The Plan phase no-ops
4
+ cleanly when a project has no open agent-ready issues (issue #20).
5
+ * Weekly (Mon 08:00): start a Summarization workflow per project (issue #24).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+
12
+ from temporalio.client import (
13
+ Client,
14
+ Schedule,
15
+ ScheduleActionStartWorkflow,
16
+ ScheduleAlreadyRunningError,
17
+ ScheduleSpec,
18
+ ScheduleCalendarSpec,
19
+ ScheduleRange,
20
+ )
21
+
22
+ from .projects import ProjectConfig
23
+ from .shared import ORCHESTRATION_QUEUE
24
+
25
+ log = logging.getLogger(__name__)
26
+
27
+
28
+ async def _ensure(client: Client, schedule_id: str, schedule: Schedule) -> None:
29
+ try:
30
+ await client.create_schedule(schedule_id, schedule)
31
+ log.info("created schedule %s", schedule_id)
32
+ except ScheduleAlreadyRunningError:
33
+ log.info("schedule %s already exists", schedule_id)
34
+
35
+
36
+ async def ensure_schedules(client: Client, projects: list[ProjectConfig]) -> None:
37
+ from .dev_loop import DevLoopInput
38
+ from .summarization import SummarizeInput
39
+
40
+ for p in projects:
41
+ await _ensure(
42
+ client,
43
+ f"devloop-nightly-{p.id}",
44
+ Schedule(
45
+ action=ScheduleActionStartWorkflow(
46
+ "DevLoopWorkflow",
47
+ DevLoopInput(project_id=p.id, agent_label=p.agent_label),
48
+ id=f"devloop-nightly-{p.id}",
49
+ task_queue=ORCHESTRATION_QUEUE,
50
+ ),
51
+ spec=ScheduleSpec(
52
+ calendars=[
53
+ ScheduleCalendarSpec(
54
+ hour=[ScheduleRange(3)],
55
+ minute=[ScheduleRange(0)],
56
+ )
57
+ ]
58
+ ),
59
+ ),
60
+ )
61
+ await _ensure(
62
+ client,
63
+ f"summarize-weekly-{p.id}",
64
+ Schedule(
65
+ action=ScheduleActionStartWorkflow(
66
+ "SummarizationWorkflow",
67
+ SummarizeInput(project_id=p.id, trigger="weekly"),
68
+ id=f"summarize-weekly-{p.id}",
69
+ task_queue=ORCHESTRATION_QUEUE,
70
+ ),
71
+ spec=ScheduleSpec(
72
+ calendars=[
73
+ ScheduleCalendarSpec(
74
+ # Monday = 1 in Temporal's day-of-week range
75
+ day_of_week=[ScheduleRange(1)],
76
+ hour=[ScheduleRange(8)],
77
+ minute=[ScheduleRange(0)],
78
+ )
79
+ ]
80
+ ),
81
+ ),
82
+ )
devloop/shared.py ADDED
@@ -0,0 +1,244 @@
1
+ """Sandbox-safe data structures shared between workflows and activities.
2
+
3
+ This module is imported by both Temporal workflow definitions (which run in
4
+ the deterministic sandbox) and activity code, so it must only import from the
5
+ standard library — no I/O, no threading, no network clients.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import os
12
+ from dataclasses import asdict, dataclass, field
13
+ from enum import Enum
14
+
15
+ # Task queues — override via env vars to match helm chart values.
16
+ # MESSAGING_TASK_QUEUE is the queue name for whichever messaging platform bot is
17
+ # deployed (discord-bot, slack-bot, etc.); set it in helm values alongside the
18
+ # bot's own TASK_QUEUE so both sides agree on the queue name.
19
+ ORCHESTRATION_QUEUE = os.getenv("ORCHESTRATION_QUEUE", "devloop-orchestration")
20
+ MESSAGING_QUEUE = os.getenv("MESSAGING_TASK_QUEUE", "discord-bot")
21
+
22
+ # Discord channel logical names (resolved to IDs inside the bot)
23
+ CHANNEL_APPROVALS = "approvals"
24
+ CHANNEL_ALERTS = "alerts"
25
+ CHANNEL_CHANGELOG = "changelog"
26
+
27
+ # Agent Job output ConfigMap contract: the keys the worker and the Agent
28
+ # Execution Job exchange through the Job's output ConfigMap. Defined here so both
29
+ # the devloop-temporal-worker and devloop-agent-base images reference one source.
30
+ KEY_RESULT = "result" # the JSON-encoded AgentJobResult payload
31
+ KEY_HUMAN_ANSWER = "human_answer" # a human's mid-run reply patched back in
32
+
33
+
34
+ class Phase(str, Enum):
35
+ PLAN = "plan"
36
+ EXECUTE = "execute"
37
+ REVIEW = "review"
38
+ MERGE = "merge"
39
+ DIAGNOSIS = "diagnosis"
40
+ REMEDIATION = "remediation"
41
+ SUMMARIZE = "summarize"
42
+
43
+
44
+ class JobStatus(str, Enum):
45
+ COMPLETE = "complete"
46
+ FAILED = "failed"
47
+ AWAITING_HUMAN = "awaiting_human"
48
+
49
+
50
+ # ---------------------------------------------------------------------------
51
+ # Discord activity I/O (mirror of images/discord-bot/activities.py dataclasses)
52
+ # ---------------------------------------------------------------------------
53
+
54
+
55
+ @dataclass
56
+ class SendMessageInput:
57
+ workflow_id: str
58
+ message: str
59
+ channel: str = CHANNEL_APPROVALS
60
+ thread_name: str = ""
61
+
62
+
63
+ @dataclass
64
+ class SendMessageOutput:
65
+ thread_id: str
66
+
67
+
68
+ @dataclass
69
+ class SendNotificationInput:
70
+ workflow_id: str
71
+ message: str
72
+
73
+
74
+ @dataclass
75
+ class ArchiveThreadInput:
76
+ workflow_id: str
77
+
78
+
79
+ # ---------------------------------------------------------------------------
80
+ # GitHub activity I/O
81
+ # ---------------------------------------------------------------------------
82
+
83
+
84
+ @dataclass
85
+ class InlineComment:
86
+ file: str
87
+ line: int
88
+ body: str
89
+
90
+
91
+ @dataclass
92
+ class PostCommentsInput:
93
+ """Reviewer findings posted to a PR: a PR-level ``summary`` plus optional
94
+ line-anchored ``inline_comments``. Built by the workflow from the review
95
+ Agent Execution Job's ``review`` payload, consumed by the ``post_pr_comments``
96
+ activity."""
97
+
98
+ project_id: str
99
+ pr_number: int
100
+ summary: str
101
+ inline_comments: list[InlineComment] = field(default_factory=list)
102
+
103
+
104
+ # ---------------------------------------------------------------------------
105
+ # Execution model
106
+ # ---------------------------------------------------------------------------
107
+
108
+
109
+ @dataclass
110
+ class TaskSpec:
111
+ """The instruction payload handed to an Agent Execution Job.
112
+
113
+ Serialized into the Job's ``TASK_SPEC`` env var by the worker and rebuilt by
114
+ the agent entrypoint — both via the methods below, so the field set has one
115
+ owner."""
116
+
117
+ phase: str
118
+ project_id: str
119
+ issue_number: int = 0
120
+ title: str = ""
121
+ body: str = ""
122
+ branch: str = ""
123
+ instructions: str = ""
124
+ # phase-specific extras (review rubric, merge branch list, alert payload …)
125
+ extra: dict = field(default_factory=dict)
126
+
127
+ def to_env_value(self) -> str:
128
+ """Render the ``TASK_SPEC`` env value the Agent Execution Job reads."""
129
+ return json.dumps(asdict(self))
130
+
131
+ @classmethod
132
+ def from_env(cls, raw: str) -> "TaskSpec":
133
+ """Rebuild a TaskSpec from the ``TASK_SPEC`` env value (agent side)."""
134
+ d = json.loads(raw or "{}")
135
+ return cls(
136
+ phase=d.get("phase", "execute"),
137
+ project_id=d.get("project_id", ""),
138
+ issue_number=int(d.get("issue_number", 0) or 0),
139
+ title=d.get("title", ""),
140
+ body=d.get("body", ""),
141
+ branch=d.get("branch", ""),
142
+ instructions=d.get("instructions", ""),
143
+ extra=d.get("extra", {}) or {},
144
+ )
145
+
146
+
147
+ @dataclass
148
+ class AgentJobResult:
149
+ """The result an Agent Execution Job writes to its output ConfigMap.
150
+
151
+ The agent serializes one of these with :meth:`to_payload`; the worker rebuilds
152
+ it with :meth:`from_payload`. ``job_name`` is assigned by the reader (the
153
+ worker knows which Job it polled) and is not part of the wire payload."""
154
+
155
+ status: str = JobStatus.FAILED.value
156
+ job_name: str = ""
157
+ issue_number: int = 0
158
+ branch: str = ""
159
+ pr_url: str = ""
160
+ # number of commits the agent produced (execute/review phases)
161
+ commits: int = 0
162
+ tests_passed: bool = False
163
+ # mid-run question (status == awaiting_human)
164
+ question: str = ""
165
+ # plan phase output (codebase-grounded plan from the planner Agent Job)
166
+ plan: dict | None = None
167
+ # review phase output
168
+ review: dict | None = None
169
+ # diagnosis phase output
170
+ diagnosis: dict | None = None
171
+ # merge / summarize output
172
+ summary: str = ""
173
+ merged_issues: list[int] = field(default_factory=list)
174
+ merge_commit: str = ""
175
+ error: str = ""
176
+
177
+ def to_payload(self) -> dict:
178
+ """Render the dict the agent stores under ``KEY_RESULT`` (drops the
179
+ reader-assigned ``job_name``)."""
180
+ d = asdict(self)
181
+ d.pop("job_name", None)
182
+ return d
183
+
184
+ @classmethod
185
+ def from_payload(cls, payload: dict, job_name: str) -> "AgentJobResult":
186
+ """Rebuild an AgentJobResult from a Job's output payload (worker side)."""
187
+ return cls(
188
+ status=payload.get("status", JobStatus.FAILED.value),
189
+ job_name=job_name,
190
+ issue_number=int(payload.get("issue_number", 0) or 0),
191
+ branch=payload.get("branch", ""),
192
+ pr_url=payload.get("pr_url", ""),
193
+ commits=int(payload.get("commits", 0) or 0),
194
+ tests_passed=bool(payload.get("tests_passed", False)),
195
+ question=payload.get("question", ""),
196
+ plan=payload.get("plan"),
197
+ review=payload.get("review"),
198
+ diagnosis=payload.get("diagnosis"),
199
+ summary=payload.get("summary", ""),
200
+ merged_issues=list(payload.get("merged_issues", []) or []),
201
+ merge_commit=payload.get("merge_commit", ""),
202
+ error=payload.get("error", ""),
203
+ )
204
+
205
+
206
+ @dataclass
207
+ class DispatchInput:
208
+ project_id: str
209
+ issue_number: int
210
+ task_spec: TaskSpec
211
+ # test override: poll interval / job ttl (seconds)
212
+ poll_interval_seconds: float = 5.0
213
+ retention_seconds: float = 300.0
214
+ # For jobs not backed by a registry project (e.g. custom consumer workflows):
215
+ # override the image / omneval ingest secret / repo without a registry entry.
216
+ image_override: str = ""
217
+ omneval_secret_override: str = ""
218
+ github_url_override: str = ""
219
+ # GitHub token Secret name; empty means the job needs no GitHub access
220
+ github_token_secret_override: str = ""
221
+ # ServiceAccount the Job pod runs as; empty falls back to the default SA
222
+ service_account_override: str = ""
223
+
224
+
225
+ @dataclass
226
+ class OpenAgentPRsInput:
227
+ """Input for the activity that lists issue numbers with an open agent PR."""
228
+
229
+ project_id: str
230
+
231
+
232
+ @dataclass
233
+ class AnswerInput:
234
+ job_name: str
235
+ answer: str
236
+
237
+
238
+ @dataclass
239
+ class AwaitInput:
240
+ """Resume polling a parked Job. Only the job name and poll cadence are needed
241
+ — the poll reads neither project nor task spec."""
242
+
243
+ job_name: str
244
+ poll_interval_seconds: float = 5.0
@@ -0,0 +1,69 @@
1
+ """Summarization workflow (issue #24).
2
+
3
+ Runs after a successful Merge (as a Dev Loop child workflow) and on a weekly
4
+ Temporal Schedule. Reads the changes since the last summarized commit, asks the
5
+ LLM for a plain-English digest, and posts it to ``#changelog``.
6
+
7
+ Sandbox-safe: only stdlib + shared imports here. The I/O (GitHub compare, LLM
8
+ call, dedup state) lives in ``summarize_activities`` and is referenced by name.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from dataclasses import dataclass, field
14
+ from datetime import timedelta
15
+
16
+ from temporalio import workflow
17
+ from temporalio.common import RetryPolicy
18
+
19
+ from .shared import CHANNEL_CHANGELOG, MESSAGING_QUEUE, SendMessageInput
20
+
21
+ _RETRY = RetryPolicy(maximum_attempts=3)
22
+
23
+
24
+ @dataclass
25
+ class SummarizeInput:
26
+ project_id: str
27
+ trigger: str = "post-merge" # post-merge | weekly
28
+ head_sha: str = ""
29
+ closed_issues: list[int] = field(default_factory=list)
30
+
31
+
32
+ @dataclass
33
+ class SummarizeResult:
34
+ skipped: bool = False
35
+ summary: str = ""
36
+ head_sha: str = ""
37
+
38
+
39
+ @workflow.defn
40
+ class SummarizationWorkflow:
41
+ @workflow.run
42
+ async def run(self, inp: SummarizeInput) -> SummarizeResult:
43
+ result: SummarizeResult = await workflow.execute_activity(
44
+ "summarize_changes",
45
+ inp,
46
+ result_type=SummarizeResult,
47
+ start_to_close_timeout=timedelta(minutes=10),
48
+ retry_policy=_RETRY,
49
+ )
50
+ if result.skipped:
51
+ workflow.logger.info(
52
+ "summary skipped (no new changes) for %s", inp.project_id
53
+ )
54
+ return result
55
+
56
+ title = f"{inp.project_id} — {inp.trigger} digest"
57
+ await workflow.execute_activity(
58
+ "send_message",
59
+ SendMessageInput(
60
+ workflow_id=workflow.info().workflow_id,
61
+ message=result.summary,
62
+ channel=CHANNEL_CHANGELOG,
63
+ thread_name=title,
64
+ ),
65
+ task_queue=MESSAGING_QUEUE,
66
+ start_to_close_timeout=timedelta(seconds=60),
67
+ retry_policy=_RETRY,
68
+ )
69
+ return result
@@ -0,0 +1,130 @@
1
+ """I/O activities for the Summarization workflow (issue #24).
2
+
3
+ * dedup state (last-summarized commit SHA per project) is kept in a ConfigMap.
4
+ * the change set is read from the GitHub compare API (no clone needed).
5
+ * the digest is produced by a single-turn LLM call against the homelab model.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import logging
12
+ import os
13
+
14
+ from temporalio import activity
15
+
16
+ from . import cluster
17
+ from .github_ops import _client # reuse the authed httpx client
18
+ from .projects import get_project, parse_github_repo
19
+ from .summarization import SummarizeInput, SummarizeResult
20
+
21
+ log = logging.getLogger(__name__)
22
+
23
+ STATE_CONFIGMAP = os.getenv("SUMMARY_STATE_CONFIGMAP", "dev-loop-summary-state")
24
+ OPENAI_BASE_URL = os.getenv("AGENT_OPENAI_BASE_URL", "http://192.168.68.104/v1")
25
+ SUMMARY_MODEL = os.getenv("SUMMARY_MODEL", "qwen3-27b")
26
+
27
+
28
+ # --------------------------------------------------------------------------- #
29
+ # Pure helpers
30
+ # --------------------------------------------------------------------------- #
31
+ def should_summarize(last_sha: str, head_sha: str, closed_issues: list[int]) -> bool:
32
+ """Skip only when nothing new has landed since the last summary."""
33
+ if closed_issues:
34
+ return True
35
+ if not head_sha:
36
+ return False
37
+ return head_sha != last_sha
38
+
39
+
40
+ def build_prompt(commits: list[str], issues: list[dict]) -> str:
41
+ commit_block = "\n".join(f"- {c}" for c in commits) or "- (no new commits)"
42
+ issue_block = (
43
+ "\n".join(f"- #{i['number']} {i['title']}" for i in issues) or "- (none)"
44
+ )
45
+ return (
46
+ "You are writing a changelog entry for a homelab Kubernetes repo. "
47
+ "Given the commit messages and resolved issues below, write a short "
48
+ "plain-English paragraph explaining WHAT changed and WHY, followed by a "
49
+ "bullet list of the resolved issues by title. Do NOT include raw diff "
50
+ "lines or git hashes.\n\n"
51
+ f"Commit messages:\n{commit_block}\n\nResolved issues:\n{issue_block}\n"
52
+ )
53
+
54
+
55
+ # --------------------------------------------------------------------------- #
56
+ # Dedup state — last-summarized SHA per project, kept in a ConfigMap
57
+ # --------------------------------------------------------------------------- #
58
+ def get_last_sha(project_id: str) -> str:
59
+ data = cluster.read_configmap_data(STATE_CONFIGMAP) or {}
60
+ return json.loads(data.get("last-sha", "{}")).get(project_id, "")
61
+
62
+
63
+ def set_last_sha(project_id: str, sha: str) -> None:
64
+ data = cluster.read_configmap_data(STATE_CONFIGMAP) or {}
65
+ mapping = json.loads(data.get("last-sha", "{}"))
66
+ mapping[project_id] = sha
67
+ cluster.patch_configmap_data(STATE_CONFIGMAP, {"last-sha": json.dumps(mapping)})
68
+
69
+
70
+ # --------------------------------------------------------------------------- #
71
+ # GitHub + LLM
72
+ # --------------------------------------------------------------------------- #
73
+ def _fetch_changes(
74
+ repo: str, base: str, head: str, closed: list[int]
75
+ ) -> tuple[list[str], list[dict], str]:
76
+ commits: list[str] = []
77
+ issues: list[dict] = []
78
+ resolved_head = head
79
+ with _client() as c:
80
+ if not resolved_head:
81
+ r = c.get(f"/repos/{repo}/commits", params={"per_page": 1})
82
+ r.raise_for_status()
83
+ resolved_head = r.json()[0]["sha"]
84
+ if base and base != resolved_head:
85
+ r = c.get(f"/repos/{repo}/compare/{base}...{resolved_head}")
86
+ if r.status_code == 200:
87
+ commits = [
88
+ cm["commit"]["message"].splitlines()[0]
89
+ for cm in r.json().get("commits", [])
90
+ ]
91
+ for n in closed:
92
+ r = c.get(f"/repos/{repo}/issues/{n}")
93
+ if r.status_code == 200:
94
+ j = r.json()
95
+ issues.append({"number": j["number"], "title": j["title"]})
96
+ return commits, issues, resolved_head
97
+
98
+
99
+ def _llm_summary(prompt: str) -> str:
100
+ import httpx
101
+
102
+ resp = httpx.post(
103
+ f"{OPENAI_BASE_URL}/chat/completions",
104
+ json={
105
+ "model": SUMMARY_MODEL,
106
+ "messages": [{"role": "user", "content": prompt}],
107
+ "temperature": 0.3,
108
+ },
109
+ timeout=120.0,
110
+ )
111
+ resp.raise_for_status()
112
+ return resp.json()["choices"][0]["message"]["content"].strip()
113
+
114
+
115
+ @activity.defn
116
+ async def summarize_changes(inp: SummarizeInput) -> SummarizeResult:
117
+ cfg = get_project(inp.project_id)
118
+ repo = parse_github_repo(cfg.github_url)
119
+ last_sha = get_last_sha(inp.project_id)
120
+
121
+ commits, issues, head = _fetch_changes(
122
+ repo, last_sha, inp.head_sha, inp.closed_issues
123
+ )
124
+
125
+ if not should_summarize(last_sha, head, inp.closed_issues):
126
+ return SummarizeResult(skipped=True, head_sha=head)
127
+
128
+ summary = _llm_summary(build_prompt(commits, issues))
129
+ set_last_sha(inp.project_id, head)
130
+ return SummarizeResult(skipped=False, summary=summary, head_sha=head)
devloop/webhook.py ADDED
@@ -0,0 +1,105 @@
1
+ """Webhook receiver for the Orchestration Worker (issues #20, #25, #31).
2
+
3
+ A FastAPI app served alongside the Temporal worker:
4
+
5
+ * ``POST /webhook/github`` — GitHub ``issues`` events; an ``agent-ready`` label
6
+ on an issue starts a Dev Loop workflow for the matching enrolled project.
7
+ HMAC-SHA256 signature verification is enforced when ``GITHUB_WEBHOOK_SECRET``
8
+ is set (GitHub sends the ``X-Hub-Signature-256`` header).
9
+ * ``POST /alertmanager/webhook`` — AlertManager alerts; each starts an Alert
10
+ Response workflow.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import hashlib
16
+ import hmac
17
+ import json
18
+ import logging
19
+ import os
20
+
21
+ from fastapi import FastAPI, Request, Response
22
+ from temporalio.client import Client
23
+ from temporalio.common import WorkflowIDConflictPolicy
24
+
25
+ from .projects import ProjectConfig, parse_github_repo
26
+ from .shared import ORCHESTRATION_QUEUE
27
+
28
+ log = logging.getLogger(__name__)
29
+
30
+ # Module-level constant so tests can monkeypatch ``webhook.GITHUB_WEBHOOK_SECRET``.
31
+ GITHUB_WEBHOOK_SECRET: str = os.environ.get("GITHUB_WEBHOOK_SECRET", "")
32
+
33
+
34
+ def _verify_github_signature(body: bytes, signature: str) -> bool:
35
+ """Return True iff the ``X-Hub-Signature-256`` value matches the body HMAC.
36
+
37
+ The HMAC is computed over the exact raw request bytes — not re-serialised
38
+ JSON — using ``GITHUB_WEBHOOK_SECRET`` as the key. Comparison is done with
39
+ ``hmac.compare_digest`` to resist timing attacks.
40
+ """
41
+ expected = hmac.new(
42
+ GITHUB_WEBHOOK_SECRET.encode(), body, hashlib.sha256
43
+ ).hexdigest()
44
+ # GitHub sends "sha256=<hex>" — strip the prefix before comparing.
45
+ received = signature.removeprefix("sha256=")
46
+ return hmac.compare_digest(expected, received)
47
+
48
+
49
+ def create_app(client: Client, projects: list[ProjectConfig]) -> FastAPI:
50
+ app = FastAPI(title="orchestration-worker-webhooks")
51
+ by_repo = {parse_github_repo(p.github_url): p for p in projects}
52
+
53
+ @app.get("/healthz")
54
+ async def healthz():
55
+ return {"ok": True}
56
+
57
+ @app.post("/webhook/github")
58
+ async def github_webhook(request: Request):
59
+ # Read raw bytes first so HMAC is computed over the exact wire body.
60
+ body = await request.body()
61
+
62
+ if GITHUB_WEBHOOK_SECRET:
63
+ sig = request.headers.get("X-Hub-Signature-256", "")
64
+ if not sig or not _verify_github_signature(body, sig):
65
+ log.warning("GitHub webhook: invalid or missing signature")
66
+ return Response(
67
+ content='{"detail":"invalid signature"}',
68
+ status_code=401,
69
+ media_type="application/json",
70
+ )
71
+
72
+ payload = json.loads(body)
73
+ event = request.headers.get("X-GitHub-Event", "")
74
+ if event != "issues" or payload.get("action") != "labeled":
75
+ return {"ignored": f"event={event} action={payload.get('action')}"}
76
+
77
+ label = (payload.get("label") or {}).get("name", "")
78
+ repo = (payload.get("repository") or {}).get("full_name", "")
79
+ project = by_repo.get(repo)
80
+ if project is None or label != project.agent_label:
81
+ return {"ignored": f"repo={repo} label={label}"}
82
+
83
+ issue_number = (payload.get("issue") or {}).get("number")
84
+ wf_id = f"devloop-{project.id}"
85
+ await client.start_workflow(
86
+ "DevLoopWorkflow",
87
+ _dev_loop_input(project.id, project.agent_label),
88
+ id=wf_id,
89
+ task_queue=ORCHESTRATION_QUEUE,
90
+ id_conflict_policy=WorkflowIDConflictPolicy.USE_EXISTING,
91
+ )
92
+ log.info(
93
+ "triggered Dev Loop %s for %s (issue #%s)", wf_id, project.id, issue_number
94
+ )
95
+ return {"workflow_id": wf_id, "project": project.id, "issue": issue_number}
96
+
97
+ return app
98
+
99
+
100
+ # Inputs are built lazily to avoid importing the workflow modules (and their
101
+ # passthrough deps) at module import time in the webhook process path.
102
+ def _dev_loop_input(project_id: str, agent_label: str):
103
+ from .dev_loop import DevLoopInput
104
+
105
+ return DevLoopInput(project_id=project_id, agent_label=agent_label)