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
|
File without changes
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
"""The one alert that cannot be a workflow. 042-US1, FR-001 and FR-002.
|
|
2
|
+
|
|
3
|
+
041 made escalation a Temporal workflow, which is right for every escalation
|
|
4
|
+
except one: the alert that says Temporal is down cannot be hosted by the thing
|
|
5
|
+
it reports dead. So the supervision probe calls the messenger seam **directly**
|
|
6
|
+
— a plain library call, out of band, with a local log as its only record. This
|
|
7
|
+
module is the caller 041 FR-002 exists for, and it runs when Temporal is gone.
|
|
8
|
+
|
|
9
|
+
Four properties carry the weight, and each is asserted rather than intended by
|
|
10
|
+
`tests/test_supervision_alert.py`:
|
|
11
|
+
|
|
12
|
+
- **No Temporal, at all.** Not a client, not an import, not a lazy one inside a
|
|
13
|
+
function body. The test that proves it runs in a subprocess whose import
|
|
14
|
+
system refuses `temporalio` outright, because a test that merely happens not
|
|
15
|
+
to reach Temporal passes identically either way.
|
|
16
|
+
- **Fire and forget.** Nothing is started, nothing requiring an answer is
|
|
17
|
+
written, and no reply is expected: the rendered message carries no actions,
|
|
18
|
+
so there is no button to press, and the outcome carries no handle to wait on.
|
|
19
|
+
*Silence from the operator is not a state this path tracks.* 041's
|
|
20
|
+
record-and-await semantics are the wrong shape here, and reusing them would
|
|
21
|
+
make this a second escalation system rather than the one visible exception.
|
|
22
|
+
- **Nothing raises.** A transport that explodes, one that reports it did not
|
|
23
|
+
send, and a name nothing is registered under are all *data*: a supervisor
|
|
24
|
+
that crashes on a failed send has stopped supervising, and the host it was
|
|
25
|
+
watching is usually the reason the send failed.
|
|
26
|
+
- **Loud when it cannot escalate.** An undelivered alert goes to the log and to
|
|
27
|
+
stderr, and `AlertOutcome.exit_code` is non-zero, because a probe that cannot
|
|
28
|
+
escalate is otherwise indistinguishable from a healthy floor (FR-015). That
|
|
29
|
+
is the exact failure this whole path exists to end.
|
|
30
|
+
|
|
31
|
+
What is deliberately *not* here: measuring anything, deciding whether to send,
|
|
32
|
+
and remembering what was sent last time. This module renders one alert and
|
|
33
|
+
hands it over. Liveness, memory headroom, orphan counts, the edge-trigger table
|
|
34
|
+
and the heartbeat interval are the probe's (US2), which keeps this module
|
|
35
|
+
callable from a process that has no state directory and no configuration.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
from __future__ import annotations
|
|
39
|
+
|
|
40
|
+
import asyncio
|
|
41
|
+
import dataclasses
|
|
42
|
+
import logging
|
|
43
|
+
import sys
|
|
44
|
+
|
|
45
|
+
from factory.notify.adapter import (
|
|
46
|
+
RenderedMessage,
|
|
47
|
+
UnknownAdapterError,
|
|
48
|
+
configured_adapter_name,
|
|
49
|
+
resolve_adapter,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
logger = logging.getLogger(__name__)
|
|
53
|
+
|
|
54
|
+
#: What this path hands the transport in place of a correlation id.
|
|
55
|
+
#:
|
|
56
|
+
#: A constant, and a name rather than an identifier: there is nothing to
|
|
57
|
+
#: correlate. A minted id would imply a row somewhere that a reply could be
|
|
58
|
+
#: matched against, and this path writes none — the transport logs it, and no
|
|
59
|
+
#: press can carry it back because the message has no buttons.
|
|
60
|
+
SUPERVISION_CORRELATION_ID = "supervision"
|
|
61
|
+
|
|
62
|
+
#: The prefix every out-of-band alert reads under, so an operator can tell a
|
|
63
|
+
#: page from the supervisor apart from a page from an epic at a glance.
|
|
64
|
+
ALERT_PREFIX = "ergane supervision"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclasses.dataclass(frozen=True)
|
|
68
|
+
class StackAlert:
|
|
69
|
+
"""One thing the supervisor observed, in the three terms FR-002 requires.
|
|
70
|
+
|
|
71
|
+
`duration_s` arrives as data rather than being measured here: how long a
|
|
72
|
+
condition has held is the probe's reading (it owns the state files US2
|
|
73
|
+
adds), and a clock in this module would be a second answer to a question
|
|
74
|
+
that already has one.
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
service: str
|
|
78
|
+
condition: str
|
|
79
|
+
duration_s: float
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclasses.dataclass(frozen=True)
|
|
83
|
+
class AlertOutcome:
|
|
84
|
+
"""What was said, and whether anybody got it.
|
|
85
|
+
|
|
86
|
+
Three fields and no fourth. A message handle or an escalation id here would
|
|
87
|
+
be the first half of a reply this path then has to wait for, which is the
|
|
88
|
+
property US1-S3 forbids.
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
text: str
|
|
92
|
+
delivered: bool
|
|
93
|
+
failure: str | None = None
|
|
94
|
+
|
|
95
|
+
@property
|
|
96
|
+
def exit_code(self) -> int:
|
|
97
|
+
"""`0` iff the operator was paged — FR-015, in the shape a probe exits.
|
|
98
|
+
|
|
99
|
+
The probe's unit declares `SuccessExitStatus=0 1` in the prior art
|
|
100
|
+
precisely so a degraded verdict stays a report; what must never be
|
|
101
|
+
silent is an alert nobody received.
|
|
102
|
+
"""
|
|
103
|
+
return 0 if self.delivered else 1
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def format_duration(seconds: float) -> str:
|
|
107
|
+
"""How long, in the units an operator reads on a phone at 3am.
|
|
108
|
+
|
|
109
|
+
Two terms at most, largest first: `45s`, `1m 35s`, `2h 3m`. A negative
|
|
110
|
+
reading — a probe subtracting a status file's mtime across a clock
|
|
111
|
+
correction — reads as `0s` rather than backwards, because an alert about
|
|
112
|
+
the supervisor's own arithmetic is not the alert that was sent.
|
|
113
|
+
"""
|
|
114
|
+
total = int(max(0.0, seconds))
|
|
115
|
+
if total >= 3600:
|
|
116
|
+
return f"{total // 3600}h {(total % 3600) // 60}m"
|
|
117
|
+
if total >= 60:
|
|
118
|
+
return f"{total // 60}m {total % 60}s"
|
|
119
|
+
return f"{total}s"
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def render_alert(alert: StackAlert) -> RenderedMessage:
|
|
123
|
+
"""The service, the observed condition and the duration — FR-002.
|
|
124
|
+
|
|
125
|
+
A page that says only "degraded" sends the operator to a terminal to find
|
|
126
|
+
out what this code already knew, and the terminal is on the host that is
|
|
127
|
+
already failing.
|
|
128
|
+
|
|
129
|
+
No actions, deliberately: what a transport turns into buttons is what an
|
|
130
|
+
operator can answer, and this path expects no answer (US1-S3).
|
|
131
|
+
"""
|
|
132
|
+
return RenderedMessage(
|
|
133
|
+
text=(
|
|
134
|
+
f"{ALERT_PREFIX}: {alert.service} — {alert.condition} "
|
|
135
|
+
f"(for {format_duration(alert.duration_s)})"
|
|
136
|
+
)
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
async def deliver_alert(
|
|
141
|
+
alert: StackAlert, *, adapter_name: str | None = None
|
|
142
|
+
) -> AlertOutcome:
|
|
143
|
+
"""Page the operator about `alert` through the configured transport.
|
|
144
|
+
|
|
145
|
+
The awaitable door, for a caller that already has a loop. `send_alert` is
|
|
146
|
+
the one a probe calls.
|
|
147
|
+
|
|
148
|
+
`adapter_name` names the transport; the default reads the same
|
|
149
|
+
configuration every other sender reads, so the supervisor and the factory
|
|
150
|
+
page the same place without a second setting to keep in step.
|
|
151
|
+
"""
|
|
152
|
+
message = render_alert(alert)
|
|
153
|
+
transport = adapter_name or ""
|
|
154
|
+
try:
|
|
155
|
+
transport = transport or configured_adapter_name()
|
|
156
|
+
adapter = resolve_adapter(transport)
|
|
157
|
+
except Exception as exc:
|
|
158
|
+
# Broad on purpose. An unknown transport name, a builder that cannot
|
|
159
|
+
# construct, an unreadable config: the move is identical, and it is
|
|
160
|
+
# never "raise on the host that is already failing".
|
|
161
|
+
return _undelivered(
|
|
162
|
+
message.text,
|
|
163
|
+
f"no transport resolved for {transport or 'the configured transport'}"
|
|
164
|
+
f" ({type(exc).__name__}{_safe_reason(exc)})",
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
try:
|
|
168
|
+
receipt = await adapter.deliver(message, SUPERVISION_CORRELATION_ID)
|
|
169
|
+
except Exception as exc:
|
|
170
|
+
# The class, never the message: an unauthorized Bot API error quotes
|
|
171
|
+
# the bot token back at us, because the token is in the URL it failed
|
|
172
|
+
# on. `TelegramAdapter.deliver` logs the same way for the same reason.
|
|
173
|
+
return _undelivered(
|
|
174
|
+
message.text, f"{transport} raised {type(exc).__name__}"
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
if not receipt.delivered:
|
|
178
|
+
return _undelivered(
|
|
179
|
+
message.text, f"{transport} reported the alert was not delivered"
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
logger.info("supervision alert delivered: %s", message.text)
|
|
183
|
+
return AlertOutcome(text=message.text, delivered=True)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def send_alert(
|
|
187
|
+
alert: StackAlert, *, adapter_name: str | None = None
|
|
188
|
+
) -> AlertOutcome:
|
|
189
|
+
"""`deliver_alert` for a caller with no event loop — the probe's door.
|
|
190
|
+
|
|
191
|
+
A probe is a script run by a timer, and the messenger seam is async, so
|
|
192
|
+
something has to own the loop. `asyncio.run` refuses to be called from
|
|
193
|
+
inside a running one, and refusing is an exception; that is caught here
|
|
194
|
+
like every other failure, because "the supervision alert never raises" is
|
|
195
|
+
a claim about every caller and not only about the transport.
|
|
196
|
+
"""
|
|
197
|
+
pending = deliver_alert(alert, adapter_name=adapter_name)
|
|
198
|
+
try:
|
|
199
|
+
return asyncio.run(pending)
|
|
200
|
+
except Exception as exc:
|
|
201
|
+
# A coroutine `asyncio.run` refused to start is still an unstarted
|
|
202
|
+
# coroutine, and leaving it unclosed warns on collection — noise in
|
|
203
|
+
# the output of the one process whose output is the alert of last
|
|
204
|
+
# resort.
|
|
205
|
+
pending.close()
|
|
206
|
+
return _undelivered(
|
|
207
|
+
render_alert(alert).text,
|
|
208
|
+
f"the alert could not be run ({type(exc).__name__}); a caller with "
|
|
209
|
+
"a running event loop must await deliver_alert",
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _undelivered(text: str, failure: str) -> AlertOutcome:
|
|
214
|
+
"""Record an alert nobody received — twice, and never quietly.
|
|
215
|
+
|
|
216
|
+
Twice because the two readers are different: the log is what an operator
|
|
217
|
+
reading the journal after the fact finds, and stderr is the probe's own
|
|
218
|
+
output, which is the only thing a timer's failure mail carries. Trap 6 of
|
|
219
|
+
the plan is the prior art's line for this — *never fail silently here: a
|
|
220
|
+
probe that cannot escalate is indistinguishable from a healthy floor*.
|
|
221
|
+
"""
|
|
222
|
+
logger.error("supervision alert NOT delivered (%s): %s", failure, text)
|
|
223
|
+
print(f"WARN: cannot escalate ({failure}): {text}", file=sys.stderr, flush=True)
|
|
224
|
+
return AlertOutcome(text=text, delivered=False, failure=failure)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _safe_reason(exc: Exception) -> str:
|
|
228
|
+
"""The part of a resolution failure that is safe to write down.
|
|
229
|
+
|
|
230
|
+
`UnknownAdapterError` names the transport and the registry, which is the
|
|
231
|
+
whole diagnosis and is not a credential — a deployment that asked for one
|
|
232
|
+
messenger and silently got another would page the wrong place and look
|
|
233
|
+
configured, so that name is the one thing worth quoting. Every other
|
|
234
|
+
exception may be quoting whatever it was handed, and only its class
|
|
235
|
+
survives.
|
|
236
|
+
"""
|
|
237
|
+
if isinstance(exc, UnknownAdapterError):
|
|
238
|
+
return f": {exc}"
|
|
239
|
+
return ""
|