ergane-cli 0.1.0__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.
- ergane_cli-0.1.0.dist-info/METADATA +148 -0
- ergane_cli-0.1.0.dist-info/RECORD +121 -0
- ergane_cli-0.1.0.dist-info/WHEEL +4 -0
- ergane_cli-0.1.0.dist-info/entry_points.txt +2 -0
- ergane_cli-0.1.0.dist-info/licenses/LICENSE +202 -0
- ergane_cli-0.1.0.dist-info/licenses/NOTICE +27 -0
- factory/__init__.py +0 -0
- factory/activities/__init__.py +0 -0
- factory/activities/agent_activities.py +745 -0
- factory/activities/merge_activities.py +654 -0
- factory/activities/notify_activities.py +1078 -0
- factory/activities/roadmap_activities.py +577 -0
- factory/activities/usage_activities.py +593 -0
- factory/activities/verify_activities.py +617 -0
- factory/cli/__init__.py +0 -0
- factory/cli/completion.py +150 -0
- factory/cli/doctor.py +484 -0
- factory/cli/env.py +247 -0
- factory/cli/errors.py +77 -0
- factory/cli/init.py +1057 -0
- factory/cli/install.py +679 -0
- factory/cli/main.py +212 -0
- factory/cli/nouns/__init__.py +59 -0
- factory/cli/nouns/answer.py +134 -0
- factory/cli/nouns/build.py +1222 -0
- factory/cli/nouns/completion.py +13 -0
- factory/cli/nouns/doctor.py +13 -0
- factory/cli/nouns/env.py +13 -0
- factory/cli/nouns/escalations.py +93 -0
- factory/cli/nouns/findings.py +13 -0
- factory/cli/nouns/init.py +13 -0
- factory/cli/nouns/install.py +99 -0
- factory/cli/nouns/repo.py +13 -0
- factory/cli/nouns/roadmap.py +13 -0
- factory/cli/nouns/spec.py +511 -0
- factory/cli/nouns/status.py +18 -0
- factory/cli/nouns/usage.py +13 -0
- factory/cli/nouns/worker.py +69 -0
- factory/cli/repo.py +569 -0
- factory/cli/repo_export.py +282 -0
- factory/cli/roadmap.py +434 -0
- factory/cli/status.py +844 -0
- factory/cli/usage.py +129 -0
- factory/config.py +262 -0
- factory/controlplane/__init__.py +0 -0
- factory/controlplane/config.py +863 -0
- factory/controlplane/resolve.py +434 -0
- factory/controlplane/verify.py +827 -0
- factory/discovery/__init__.py +3 -0
- factory/discovery/llm_scanner.py +176 -0
- factory/doctor/__init__.py +0 -0
- factory/doctor/cli.py +392 -0
- factory/doctor/models.py +165 -0
- factory/doctor/probes.py +522 -0
- factory/doctor/scaffold.py +184 -0
- factory/doctor/store.py +449 -0
- factory/env.py +131 -0
- factory/escalation/__init__.py +0 -0
- factory/escalation/client.py +168 -0
- factory/escalation/question.py +161 -0
- factory/escalation/workflow.py +470 -0
- factory/locking.py +85 -0
- factory/mergequeue/__init__.py +21 -0
- factory/mergequeue/classify.py +133 -0
- factory/mergequeue/forge.py +401 -0
- factory/mergequeue/gh.py +484 -0
- factory/mergequeue/github_forge.py +349 -0
- factory/mergequeue/merge_queue_ruleset.json +32 -0
- factory/mergequeue/messages.py +141 -0
- factory/mergequeue/models.py +324 -0
- factory/mergequeue/onboard.py +537 -0
- factory/mergequeue/wiring.py +466 -0
- factory/notify/__init__.py +0 -0
- factory/notify/adapter.py +263 -0
- factory/notify/messages.py +474 -0
- factory/notify/service.py +809 -0
- factory/notify/webhook.py +151 -0
- factory/personas.yaml +161 -0
- factory/registry.py +583 -0
- factory/roadmap/__init__.py +8 -0
- factory/roadmap/cli.py +125 -0
- factory/roadmap/discovery.py +286 -0
- factory/roadmap/models.py +583 -0
- factory/roadmap/schedule.py +377 -0
- factory/roadmap/workflow.py +1364 -0
- factory/supervision/__init__.py +0 -0
- factory/supervision/alert.py +239 -0
- factory/supervision/probe.py +467 -0
- factory/supervision/temporal_server.py +121 -0
- factory/supervision/units.py +649 -0
- factory/usage/__init__.py +0 -0
- factory/usage/aggregate.py +113 -0
- factory/usage/cli.py +223 -0
- factory/usage/ledger.py +295 -0
- factory/usage/litellm_client.py +486 -0
- factory/usage/models.py +149 -0
- factory/verify/__init__.py +0 -0
- factory/verify/criteria.py +488 -0
- factory/verify/diffbounds.py +159 -0
- factory/verify/diffcheck.py +506 -0
- factory/verify/factory_yaml.py +842 -0
- factory/verify/gates.py +1341 -0
- factory/verify/judge.py +775 -0
- factory/verify/ladder.py +145 -0
- factory/verify/models.py +745 -0
- factory/verify/question.py +167 -0
- factory/verify/store.py +1366 -0
- factory/verify/toolchain.py +370 -0
- factory/worker.py +296 -0
- factory/workgraph/__init__.py +0 -0
- factory/workgraph/adapter.py +1333 -0
- factory/workgraph/cli.py +801 -0
- factory/workgraph/delta.py +278 -0
- factory/workgraph/derive.py +607 -0
- factory/workgraph/detector.py +509 -0
- factory/workgraph/landed.py +496 -0
- factory/workgraph/models.py +494 -0
- factory/workgraph/preflight.py +651 -0
- factory/workgraph/prompt.py +796 -0
- factory/workgraph/workflow.py +2733 -0
- factory/workgraph/worktree.py +1322 -0
|
@@ -0,0 +1,577 @@
|
|
|
1
|
+
"""The roadmap's pre-dispatch surface: clone, derive, preflight, onboarding, capacity.
|
|
2
|
+
|
|
3
|
+
US2's `RoadmapWorkflow` runs four checks before starting each dispatchable
|
|
4
|
+
spec's child `EpicWorkflow`, and one read to know how many epics are already
|
|
5
|
+
in flight. Each is an activity because workflow code touches nothing
|
|
6
|
+
(constitution IV): the clone is a git operation, derivation reads a file, the
|
|
7
|
+
preflight dials the proxy, onboarding reads the target repo, and the capacity
|
|
8
|
+
read lists open workflows through the Temporal client. None of it belongs in
|
|
9
|
+
workflow code, and none of it carries a credential across the boundary.
|
|
10
|
+
|
|
11
|
+
The shape mirrors `factory/activities/merge_activities.py`: a pure library
|
|
12
|
+
function does the work, a thin `@activity.defn` wrapper builds the side-effect
|
|
13
|
+
through an injectable seam, and tests script the seam. The seams are:
|
|
14
|
+
|
|
15
|
+
- `_clone_runner` — production refreshes the target clone to its default
|
|
16
|
+
branch (FR-006: "fresh clone at the current default branch"); tests hand
|
|
17
|
+
back a scripted `CloneResult` so the clone never touches a real repo.
|
|
18
|
+
- `_registry` — production reads `personas.yaml`; tests hand a fixed
|
|
19
|
+
registry so the preflight checks deterministic personas (the same split the
|
|
20
|
+
CLI's preflight draws, one host reading its own file).
|
|
21
|
+
- `_preflight_client` — production dials the proxy from the environment; the
|
|
22
|
+
activity passes it to `check_aliases`, the shared core.
|
|
23
|
+
- `_open_epics_provider` — production lists open `epic-*` workflows through
|
|
24
|
+
`activity.client()`; tests hand a scripted set, because the time-skipping
|
|
25
|
+
test server does not answer the list filter the production query uses and
|
|
26
|
+
the capacity read is a single per-pass call (not an interval poll) either
|
|
27
|
+
way (FR-004).
|
|
28
|
+
|
|
29
|
+
Onboarding reuses 003's `validate_target_repo` activity as it stands — the
|
|
30
|
+
roadmap inherits N onboarding checks for N epics and does not optimize (plan
|
|
31
|
+
§ US2). A failing onboarding check parks the spec with its finding verbatim
|
|
32
|
+
(FR-006); the child epic re-runs onboarding at its own start, the same
|
|
33
|
+
belt-and-suspenders the epic already had.
|
|
34
|
+
|
|
35
|
+
FR-009 (the closed credential discipline 001 established, extended one level
|
|
36
|
+
up): the master key lives only in this process's environment and is read
|
|
37
|
+
inside the preflight client seam. No key value reaches any activity *input*
|
|
38
|
+
— `PreflightInput` carries the graph and a proxy *url* (not a credential),
|
|
39
|
+
`CloneInput` carries a path, `CountOpenInput` is empty. The sweep (T012)
|
|
40
|
+
asserts each surface.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
from __future__ import annotations
|
|
44
|
+
|
|
45
|
+
from dataclasses import dataclass
|
|
46
|
+
from pathlib import Path
|
|
47
|
+
from typing import Any, Awaitable, Callable, Mapping
|
|
48
|
+
|
|
49
|
+
from temporalio import activity
|
|
50
|
+
|
|
51
|
+
from factory.config import Persona, load_personas
|
|
52
|
+
from factory.mergequeue.models import TargetRepoProfile
|
|
53
|
+
from factory.usage.litellm_client import LiteLLMClient
|
|
54
|
+
from factory.workgraph.derive import DerivationError
|
|
55
|
+
from factory.workgraph.models import WorkGraph
|
|
56
|
+
from factory.workgraph.preflight import (
|
|
57
|
+
PreflightFinding,
|
|
58
|
+
check_aliases,
|
|
59
|
+
prompt_assembly_preflight,
|
|
60
|
+
)
|
|
61
|
+
from factory.workgraph.workflow import JUDGE_PERSONA
|
|
62
|
+
from factory.workgraph.worktree import landing_branch
|
|
63
|
+
from factory.verify.factory_yaml import load_loop_config
|
|
64
|
+
from factory.verify.models import VerificationConfig
|
|
65
|
+
|
|
66
|
+
from factory.activities.merge_activities import (
|
|
67
|
+
ValidateTargetRepoInput,
|
|
68
|
+
validate_target_repo,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
# --- clone: refresh the target clone to its current default branch ------------
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass(frozen=True)
|
|
76
|
+
class CloneInput:
|
|
77
|
+
"""Where to refresh, and which spec the clone is for (FR-006).
|
|
78
|
+
|
|
79
|
+
`target_repo` is the worker-host path to the target clone; `spec_dir` is
|
|
80
|
+
the spec the clone is being prepared for, carried only so a finding can
|
|
81
|
+
name which spec's pre-dispatch failed. No credential crosses here.
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
target_repo: str
|
|
85
|
+
spec_dir: str
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclass(frozen=True)
|
|
89
|
+
class CloneResult:
|
|
90
|
+
"""The clone's identity at refresh time: path, default branch, head ref.
|
|
91
|
+
|
|
92
|
+
`head_ref` is the commit the clone stood at after the refresh — the
|
|
93
|
+
branch point the epic's nodes will pin against (FR-006: derive from the
|
|
94
|
+
current default branch, not a stale one).
|
|
95
|
+
"""
|
|
96
|
+
|
|
97
|
+
path: str
|
|
98
|
+
default_branch: str
|
|
99
|
+
head_ref: str
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _refresh_to_default(target_repo: str) -> CloneResult:
|
|
103
|
+
"""Refresh the target clone to its default branch and report where it stands.
|
|
104
|
+
|
|
105
|
+
A fetch + hard reset to `origin/<default>` so the epic derives from the
|
|
106
|
+
trunk's current head, whatever other landings moved it since the last
|
|
107
|
+
epic. Reuses the workgraph's git helpers (the same `_git` discipline the
|
|
108
|
+
node worktrees already follow) so the clone never sees a factory credential
|
|
109
|
+
(constitution V).
|
|
110
|
+
"""
|
|
111
|
+
from factory.workgraph.worktree import _default_branch, _git, _head
|
|
112
|
+
from pathlib import Path
|
|
113
|
+
|
|
114
|
+
repo = Path(target_repo)
|
|
115
|
+
default = _default_branch(repo)
|
|
116
|
+
_git(repo, "fetch", "--quiet", "origin")
|
|
117
|
+
_git(repo, "checkout", "--quiet", default)
|
|
118
|
+
_git(repo, "reset", "--quiet", "--hard", f"origin/{default}")
|
|
119
|
+
return CloneResult(path=str(repo), default_branch=default, head_ref=_head(repo))
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
#: The clone seam — production refreshes a real clone; tests hand back a
|
|
123
|
+
#: scripted result so the activity never spawns git.
|
|
124
|
+
_clone_runner: Callable[[str], CloneResult] = _refresh_to_default
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@activity.defn
|
|
128
|
+
async def clone_target(request: CloneInput) -> CloneResult:
|
|
129
|
+
"""Refresh the target clone to its default branch before dispatch (FR-006).
|
|
130
|
+
|
|
131
|
+
The thin wrapper: the clone runs through `_clone_runner`, which production
|
|
132
|
+
implements with git and tests replace with a scripted result. Never raises
|
|
133
|
+
on a refused clone — that is a pre-dispatch refusal the workflow parks —
|
|
134
|
+
but a git error propagates as an activity failure the workflow catches and
|
|
135
|
+
parks verbatim (FR-006).
|
|
136
|
+
"""
|
|
137
|
+
return _clone_runner(request.target_repo)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
# --- derive: the pure deriver behind a thin activity --------------------------
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
@dataclass(frozen=True)
|
|
144
|
+
class DeriveInput:
|
|
145
|
+
"""One spec's text and the identity fields the deriver cannot infer.
|
|
146
|
+
|
|
147
|
+
The deriver is pure and is handed text (FR-011), so the epic id, feature,
|
|
148
|
+
specs root and target repo — the four identity fields — are the caller's.
|
|
149
|
+
No credential; the spec text is read by the workflow's `read_roadmap`
|
|
150
|
+
surface, not carried here.
|
|
151
|
+
"""
|
|
152
|
+
|
|
153
|
+
spec_text: str
|
|
154
|
+
epic_id: str
|
|
155
|
+
feature: str
|
|
156
|
+
specs_root: str
|
|
157
|
+
target_repo: str
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
@activity.defn
|
|
161
|
+
async def derive_spec(request: DeriveInput) -> WorkGraph:
|
|
162
|
+
"""Compile one spec into the delta graph the child epic runs (FR-010).
|
|
163
|
+
|
|
164
|
+
A thin activity around the pure `derive_delta`: the workflow cannot read
|
|
165
|
+
files or shell git, so derivation — which a spec that does not compile refuses
|
|
166
|
+
— runs as an activity whose `DerivationError` the workflow catches and parks
|
|
167
|
+
verbatim (FR-006). The delta baseline is read from the refreshed target repo
|
|
168
|
+
by `landed_facts` inside the activity; the workflow receives only the result.
|
|
169
|
+
Returns the compiled `WorkGraph` on success.
|
|
170
|
+
|
|
171
|
+
A `DerivationError` is a *deterministic* refusal (a spec that does not
|
|
172
|
+
compile the same way every time), so it is re-raised as a non-retryable
|
|
173
|
+
`ApplicationError` — the workflow parks it on the first attempt rather than
|
|
174
|
+
retrying a refusal that cannot change (and the activity's retry policy
|
|
175
|
+
would otherwise burn three attempts before the workflow ever saw it). The
|
|
176
|
+
workflow reads the original message off the `ApplicationError` verbatim.
|
|
177
|
+
"""
|
|
178
|
+
from pathlib import Path
|
|
179
|
+
|
|
180
|
+
from factory.workgraph.delta import derive_delta
|
|
181
|
+
from factory.workgraph.landed import landed_facts
|
|
182
|
+
|
|
183
|
+
runner = _derive_runner
|
|
184
|
+
if runner is not None:
|
|
185
|
+
return runner(request)
|
|
186
|
+
|
|
187
|
+
try:
|
|
188
|
+
facts = landed_facts(
|
|
189
|
+
request.target_repo,
|
|
190
|
+
request.epic_id,
|
|
191
|
+
default_branch=landing_branch(Path(request.target_repo)),
|
|
192
|
+
)
|
|
193
|
+
baseline = {
|
|
194
|
+
story_key: {
|
|
195
|
+
"commit": fact.commit,
|
|
196
|
+
"fingerprint": _fingerprint_for_spec(
|
|
197
|
+
request.target_repo, request.epic_id, fact.commit, story_key
|
|
198
|
+
),
|
|
199
|
+
}
|
|
200
|
+
for story_key, fact in facts.items()
|
|
201
|
+
}
|
|
202
|
+
delta = derive_delta(
|
|
203
|
+
request.spec_text,
|
|
204
|
+
baseline=baseline,
|
|
205
|
+
epic_id=request.epic_id,
|
|
206
|
+
feature=request.feature,
|
|
207
|
+
specs_root=request.specs_root,
|
|
208
|
+
target_repo=request.target_repo,
|
|
209
|
+
)
|
|
210
|
+
return delta.graph
|
|
211
|
+
except DerivationError as exc:
|
|
212
|
+
from temporalio.exceptions import ApplicationError
|
|
213
|
+
|
|
214
|
+
raise ApplicationError(str(exc), non_retryable=True, type="DerivationError")
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
#: Test seam for derive_spec: production is `None` so the real delta path runs;
|
|
218
|
+
#: scheduler tests set this to a scripted runner so they can drive the graph that
|
|
219
|
+
#: reaches the child without a real target clone.
|
|
220
|
+
_derive_runner: Callable[[DeriveInput], WorkGraph] | None = None
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _default_branch(repo: Path) -> str:
|
|
224
|
+
"""The repo's default branch, or "main" as a safe fallback."""
|
|
225
|
+
from factory.workgraph.worktree import _default_branch as worktree_default_branch
|
|
226
|
+
|
|
227
|
+
try:
|
|
228
|
+
return worktree_default_branch(repo)
|
|
229
|
+
except Exception:
|
|
230
|
+
return "main"
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _fingerprint_for_spec(repo: str | Path, spec_dir: str, rev: str, story_key: str):
|
|
234
|
+
"""Pinned fingerprint of one story at a landing commit."""
|
|
235
|
+
from factory.workgraph.landed import fingerprint
|
|
236
|
+
|
|
237
|
+
return fingerprint(repo, rev, spec_dir, story_key)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
# --- drift: read-only fingerprint comparison for render -----------------------
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
@dataclass(frozen=True)
|
|
244
|
+
class DriftInput:
|
|
245
|
+
"""Inputs for drift detection: which spec and which repo to compare against."""
|
|
246
|
+
|
|
247
|
+
target_repo: str
|
|
248
|
+
spec_dir: str
|
|
249
|
+
spec_text: str
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
#: The drift seam — production computes from git; tests script a boolean so the
|
|
253
|
+
#: scheduler tests do not need a real target clone at `TARGET_REPO`.
|
|
254
|
+
_drift_runner: Callable[[DriftInput], bool] | None = None
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
@activity.defn
|
|
258
|
+
async def drift_for_spec(request: DriftInput) -> bool:
|
|
259
|
+
"""Return True when the spec's fingerprints differ from their landed baseline.
|
|
260
|
+
|
|
261
|
+
Read-only and repo-authoritative: computes per-story landed facts from the
|
|
262
|
+
refreshed target repo, pins each fact's fingerprint at its landing commit,
|
|
263
|
+
and compares it to the current spec text. A spec whose frontmatter is not
|
|
264
|
+
`landed` is never drifted. The workflow uses this via `compute_readiness`
|
|
265
|
+
so the render can show `amended` without dispatching (FR-009).
|
|
266
|
+
"""
|
|
267
|
+
runner = _drift_runner
|
|
268
|
+
if runner is not None:
|
|
269
|
+
return runner(request)
|
|
270
|
+
|
|
271
|
+
from factory.workgraph.delta import fingerprint_for
|
|
272
|
+
from factory.workgraph.landed import landed_facts
|
|
273
|
+
|
|
274
|
+
repo_path = Path(request.target_repo)
|
|
275
|
+
default = landing_branch(repo_path)
|
|
276
|
+
facts = landed_facts(request.target_repo, request.spec_dir, default_branch=default)
|
|
277
|
+
if not facts:
|
|
278
|
+
return False
|
|
279
|
+
|
|
280
|
+
current_by_key = {
|
|
281
|
+
story_key: fingerprint_for(request.spec_text, story_key)
|
|
282
|
+
for story_key in facts
|
|
283
|
+
}
|
|
284
|
+
for story_key, fact in facts.items():
|
|
285
|
+
pinned = _fingerprint_for_spec(
|
|
286
|
+
request.target_repo, request.spec_dir, fact.commit, story_key
|
|
287
|
+
)
|
|
288
|
+
# Missing baselines are skipped, not errors (US1 FR-001/FR-002).
|
|
289
|
+
if pinned.digest is None:
|
|
290
|
+
continue
|
|
291
|
+
current = current_by_key.get(story_key)
|
|
292
|
+
if current is None or pinned.digest != current.digest:
|
|
293
|
+
return True
|
|
294
|
+
return False
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
# --- preflight: the shared alias checks behind the proxy seam -----------------
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
@dataclass(frozen=True)
|
|
301
|
+
class PreflightInput:
|
|
302
|
+
"""The graph to preflight and the proxy url its key is honored at (FR-006).
|
|
303
|
+
|
|
304
|
+
`proxy_url` is a url, never a credential — the master key is read from the
|
|
305
|
+
environment inside the preflight client seam (FR-009). `spec_dir` names
|
|
306
|
+
which spec this preflight is for, so a parked finding can say so.
|
|
307
|
+
|
|
308
|
+
044 US2 adds `specs_root`, and it is deliberately a *root* rather than the
|
|
309
|
+
three documents' text: `specs_root/spec_dir` is where the dispatch path's
|
|
310
|
+
own read (`load_prompt_sources`) will look, so the activity opens the same
|
|
311
|
+
files at the last moment before dispatch (044 FR-007). Carrying the texts
|
|
312
|
+
instead would mean checking bytes the workflow read on some earlier pass,
|
|
313
|
+
which is the hole this check exists to close. It carries a default only so
|
|
314
|
+
an activity task scheduled by a pre-044 worker deserializes into this shape
|
|
315
|
+
instead of failing the converter; every live caller passes it, and a task
|
|
316
|
+
that somehow arrives without one reads its trio relative to the worker's
|
|
317
|
+
working directory and parks naming the path it could not open — legible, and
|
|
318
|
+
never a silent pass.
|
|
319
|
+
"""
|
|
320
|
+
|
|
321
|
+
graph: WorkGraph
|
|
322
|
+
proxy_url: str
|
|
323
|
+
spec_dir: str
|
|
324
|
+
specs_root: str = ""
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def _preflight_registry() -> dict[str, Persona]:
|
|
328
|
+
"""The worker host's view of the registry, read from its `personas.yaml`.
|
|
329
|
+
|
|
330
|
+
The same split the CLI's preflight draws: the worker reads its own file,
|
|
331
|
+
so the preflight states what *this* registry resolves — never claims the
|
|
332
|
+
CLI's resolution was validated (US2 Edge Cases).
|
|
333
|
+
"""
|
|
334
|
+
return load_personas()
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _preflight_client(proxy_url: str) -> LiteLLMClient:
|
|
338
|
+
"""The activity's one route to the proxy, credentials from the environment.
|
|
339
|
+
|
|
340
|
+
A seam, not a factory: production builds a `LiteLLMClient` against the
|
|
341
|
+
worker host's environment (the master key lives there and nowhere else);
|
|
342
|
+
tests replace it to inject a fake transport, the same way the usage
|
|
343
|
+
activities' `open_client` is replaced.
|
|
344
|
+
"""
|
|
345
|
+
from factory.usage.litellm_client import LiteLLMClient
|
|
346
|
+
|
|
347
|
+
return LiteLLMClient(base_url=proxy_url, master_key=_master_key_from_env())
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _master_key_from_env() -> str:
|
|
351
|
+
"""The proxy master key, read from the worker environment (FR-009).
|
|
352
|
+
|
|
353
|
+
Lives only in this process's environment; never enters an activity input,
|
|
354
|
+
a finding, or a status payload. Read here — inside the seam — so the
|
|
355
|
+
workflow never sees it.
|
|
356
|
+
|
|
357
|
+
*Which* variable holds it is the control plane's answer, not this module's
|
|
358
|
+
(048 FR-006): `LITELLM_MASTER_KEY` when the host exports it, otherwise the
|
|
359
|
+
variable the operator declared at install. Routed through the one resolver
|
|
360
|
+
so the roadmap and a hand-started epic cannot disagree about which host
|
|
361
|
+
they are on. Only the credential is resolved here — the seam above is
|
|
362
|
+
handed its `proxy_url` as an argument.
|
|
363
|
+
"""
|
|
364
|
+
from factory.controlplane.resolve import resolve_master_key_env
|
|
365
|
+
|
|
366
|
+
return resolve_master_key_env().read()
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
@activity.defn
|
|
370
|
+
async def preflight_spec(request: PreflightInput) -> list[PreflightFinding]:
|
|
371
|
+
"""Run the shared preflight before dispatch: prompts, then the proxy (FR-006).
|
|
372
|
+
|
|
373
|
+
The pure checks live in `factory/workgraph/preflight.py` and are shared
|
|
374
|
+
with the CLI's `ergane build start`, so the two surfaces cannot drift. The
|
|
375
|
+
activity owns the client (master key from the environment) and the
|
|
376
|
+
registry (the worker host's `personas.yaml`); the preflight module owns
|
|
377
|
+
the alias math and the wording. Returns `[]` when every check passes.
|
|
378
|
+
|
|
379
|
+
Prompt assembly runs **here, in the activity** (044 US2, FR-003). It is a
|
|
380
|
+
read of three files, and workflow code cannot read files — putting it in
|
|
381
|
+
`RoadmapWorkflow._dispatch` would make the roadmap non-deterministic, which
|
|
382
|
+
is the defect class that killed the schedule on 2026-08-13. The workflow
|
|
383
|
+
hands over the root it already holds; the activity opens the trio.
|
|
384
|
+
|
|
385
|
+
Assembly goes first, and both checks run. First because it is the cheaper
|
|
386
|
+
and more certain fact — no service is consulted, so it cannot be wrong about
|
|
387
|
+
a proxy that is merely slow — and the workflow parks on the first finding,
|
|
388
|
+
which makes it the one the operator is shown. Both, rather than returning
|
|
389
|
+
early, because an author fixing one refusal per run is the failure mode the
|
|
390
|
+
collected-findings discipline exists to avoid: a spec with a broken
|
|
391
|
+
`tasks.md` *and* an unserved alias needs one edit pass, not two roadmap
|
|
392
|
+
passes.
|
|
393
|
+
"""
|
|
394
|
+
findings = prompt_assembly_preflight(
|
|
395
|
+
request.graph, Path(request.specs_root) / request.spec_dir
|
|
396
|
+
)
|
|
397
|
+
findings += await check_aliases(
|
|
398
|
+
request.graph, _preflight_registry(), _preflight_client(request.proxy_url)
|
|
399
|
+
)
|
|
400
|
+
return findings
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
# --- onboarding: reuse 003's activity as it stands ----------------------------
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
@dataclass(frozen=True)
|
|
407
|
+
class OnboardInput:
|
|
408
|
+
"""The target repo to validate before dispatch (FR-006, reusing 003).
|
|
409
|
+
|
|
410
|
+
`spec_dir` names which spec this onboarding is for, so a parked finding
|
|
411
|
+
can say so; the onboarding check itself is the repo's, not the spec's.
|
|
412
|
+
"""
|
|
413
|
+
|
|
414
|
+
target_repo: str
|
|
415
|
+
spec_dir: str
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
async def _onboard_profile(target_repo: str) -> TargetRepoProfile:
|
|
419
|
+
"""Production onboarding: 003's `validate_target_repo` activity as it stands.
|
|
420
|
+
|
|
421
|
+
The child epic re-runs onboarding at its own start, so this is the roadmap's
|
|
422
|
+
pre-dispatch refusal gate — a repo that fails onboarding parks every spec
|
|
423
|
+
that would dispatch against it, with the finding verbatim, instead of
|
|
424
|
+
starting an epic that immediately fails (FR-006). Onboarding re-validates
|
|
425
|
+
per epic by design, uncached — inherit, do not optimize (plan § US2).
|
|
426
|
+
"""
|
|
427
|
+
return await validate_target_repo(
|
|
428
|
+
ValidateTargetRepoInput(target_repo=target_repo)
|
|
429
|
+
)
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
#: The onboarding seam — production reuses 003's activity; tests replace it with
|
|
433
|
+
#: a scripted `TargetRepoProfile` so the scheduler tests are not also onboarding
|
|
434
|
+
#: tests (003 owns onboarding's own coverage).
|
|
435
|
+
_onboard: Callable[[str], "Awaitable[TargetRepoProfile]"] = _onboard_profile
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
@activity.defn
|
|
439
|
+
async def onboard_target(request: OnboardInput) -> TargetRepoProfile:
|
|
440
|
+
"""Re-validate the target repo before each dispatch (FR-006, reusing 003).
|
|
441
|
+
|
|
442
|
+
Thin wrapper over the `_onboard` seam: production calls 003's
|
|
443
|
+
`validate_target_repo`; tests script a profile. `spec_dir` names which
|
|
444
|
+
spec this onboarding is for, so a parked finding can say so; the check
|
|
445
|
+
itself is the repo's, not the spec's.
|
|
446
|
+
"""
|
|
447
|
+
return await _onboard(request.target_repo)
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
# --- capacity: count the open epics the roadmap dispatches around -------------
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
@dataclass(frozen=True)
|
|
454
|
+
class CountOpenInput:
|
|
455
|
+
"""No arguments — the capacity read is against the worker's own client.
|
|
456
|
+
|
|
457
|
+
Empty on purpose: the open-epic count is a property of the Temporal
|
|
458
|
+
namespace the worker polls, not of any roadmap input. Carrying nothing
|
|
459
|
+
is what keeps a credential out of it (FR-009).
|
|
460
|
+
"""
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
@dataclass(frozen=True)
|
|
464
|
+
class CountOpenResult:
|
|
465
|
+
"""The open `epic-*` workflow ids at pass start.
|
|
466
|
+
|
|
467
|
+
Includes epics the roadmap started *and* operator-started epics mid-flight
|
|
468
|
+
when the roadmap booted — the roadmap dispatches around both, so the bound
|
|
469
|
+
is honest about everything consuming a slot (plan § US2, the double-
|
|
470
|
+
dispatch-after-restart guard).
|
|
471
|
+
"""
|
|
472
|
+
|
|
473
|
+
open_ids: tuple[str, ...]
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
async def _list_open_epics() -> set[str]:
|
|
477
|
+
"""Production capacity read: open `epic-*` workflows through the client.
|
|
478
|
+
|
|
479
|
+
A single read at the start of a scheduling pass, never an interval poll
|
|
480
|
+
(FR-004): the workflow wakes on child completion events and calls this once
|
|
481
|
+
per pass to know how many slots are free. `RUNNING` is the open state;
|
|
482
|
+
`CONTINUED_AS_NEW` is a closed run (the new run carries a fresh id), so it
|
|
483
|
+
does not count.
|
|
484
|
+
"""
|
|
485
|
+
client = activity.client()
|
|
486
|
+
open_ids: set[str] = set()
|
|
487
|
+
# The list filter is the production query shape; the time-skipping test
|
|
488
|
+
# server does not answer it, which is exactly why the seam below lets a
|
|
489
|
+
# test script the count instead. The SDK's WorkflowExecutionStatus enum
|
|
490
|
+
# does not round-trip to the grammar's expected spelling by any of .name,
|
|
491
|
+
# .value, or str(), and .name.title() silently breaks on multi-word
|
|
492
|
+
# members, so the literal is pinned here and guarded by tests/test_live_capacity.py.
|
|
493
|
+
async for execution in client.list_workflows(
|
|
494
|
+
f'ExecutionStatus = "{_OPEN_EPIC_STATUS}"'
|
|
495
|
+
):
|
|
496
|
+
if execution.id.startswith("epic-"):
|
|
497
|
+
open_ids.add(execution.id)
|
|
498
|
+
return open_ids
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
#: The visibility status the production capacity read treats as "open".
|
|
502
|
+
#: Temporal's visibility grammar wants the title-case enum name, not the
|
|
503
|
+
#: uppercase .name of the SDK enum; see the comment in _list_open_epics.
|
|
504
|
+
_OPEN_EPIC_STATUS = "Running"
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
#: The capacity seam — production lists open workflows through the client;
|
|
508
|
+
#: tests hand a scripted set so the count is deterministic without a real
|
|
509
|
+
#: Temporal list round trip.
|
|
510
|
+
_open_epics_provider: Callable[[], Awaitable[set[str]]] = _list_open_epics
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
@activity.defn
|
|
514
|
+
async def count_open_epics(request: CountOpenInput) -> CountOpenResult:
|
|
515
|
+
"""How many `epic-*` workflows are open right now (FR-005 capacity accounting).
|
|
516
|
+
|
|
517
|
+
The roadmap's own in-flight children are open `epic-*` workflows, so this
|
|
518
|
+
count is the total slots in use — roadmap's plus operator-started — and the
|
|
519
|
+
roadmap dispatches around it (FR-005). Called once per scheduling pass, not
|
|
520
|
+
on an interval (FR-004).
|
|
521
|
+
"""
|
|
522
|
+
open_ids = await _open_epics_provider()
|
|
523
|
+
return CountOpenResult(open_ids=tuple(sorted(open_ids)))
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
# --- loop config: dispatch-time ladder + order read ---------------------------
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
@dataclass(frozen=True)
|
|
530
|
+
class ReadLoopConfigInput:
|
|
531
|
+
"""The operator clone whose manifest decides this spec's loop (023 FR-002).
|
|
532
|
+
|
|
533
|
+
The target repo is a worker-host path; the workflow never touches the
|
|
534
|
+
filesystem, so this read runs as an activity and is pinned before the
|
|
535
|
+
child starts. A manifest that does not parse is a non-retryable refusal:
|
|
536
|
+
it will not become valid by being read again a moment later.
|
|
537
|
+
"""
|
|
538
|
+
|
|
539
|
+
target_repo: str
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
@dataclass(frozen=True)
|
|
543
|
+
class ReadLoopConfigResult:
|
|
544
|
+
"""The two loop facts that ride `EpicInput` into the child epic."""
|
|
545
|
+
|
|
546
|
+
config: VerificationConfig
|
|
547
|
+
verify_order: tuple[str, ...]
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
#: Test seam for `read_loop_config`: production reads the clone's manifest;
|
|
551
|
+
#: scheduler tests script a fixed result so the manifest file is not the subject.
|
|
552
|
+
_read_loop_config_runner: Callable[[str], ReadLoopConfigResult] | None = None
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
@activity.defn
|
|
556
|
+
async def read_loop_config(request: ReadLoopConfigInput) -> ReadLoopConfigResult:
|
|
557
|
+
"""Read the committed loop config from the operator clone (023 US2).
|
|
558
|
+
|
|
559
|
+
The read is pinned at dispatch time so the child epic's ladder and step
|
|
560
|
+
order are properties of the commit the roadmap saw, not of any worktree
|
|
561
|
+
the child may later write (trap 2, FR-002). `FactoryConfigError` is raised
|
|
562
|
+
as a non-retryable `ApplicationError` so the workflow parks the spec with
|
|
563
|
+
the rule named.
|
|
564
|
+
"""
|
|
565
|
+
runner = _read_loop_config_runner
|
|
566
|
+
if runner is not None:
|
|
567
|
+
return runner(request.target_repo)
|
|
568
|
+
|
|
569
|
+
from factory.verify.factory_yaml import FactoryConfigError
|
|
570
|
+
|
|
571
|
+
try:
|
|
572
|
+
config, verify_order = load_loop_config(request.target_repo)
|
|
573
|
+
except FactoryConfigError as exc:
|
|
574
|
+
from temporalio.exceptions import ApplicationError
|
|
575
|
+
|
|
576
|
+
raise ApplicationError(str(exc), non_retryable=True, type="FactoryConfigError") from None
|
|
577
|
+
return ReadLoopConfigResult(config=config, verify_order=verify_order)
|