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.
Files changed (121) hide show
  1. ergane_cli-0.1.0.dist-info/METADATA +148 -0
  2. ergane_cli-0.1.0.dist-info/RECORD +121 -0
  3. ergane_cli-0.1.0.dist-info/WHEEL +4 -0
  4. ergane_cli-0.1.0.dist-info/entry_points.txt +2 -0
  5. ergane_cli-0.1.0.dist-info/licenses/LICENSE +202 -0
  6. ergane_cli-0.1.0.dist-info/licenses/NOTICE +27 -0
  7. factory/__init__.py +0 -0
  8. factory/activities/__init__.py +0 -0
  9. factory/activities/agent_activities.py +745 -0
  10. factory/activities/merge_activities.py +654 -0
  11. factory/activities/notify_activities.py +1078 -0
  12. factory/activities/roadmap_activities.py +577 -0
  13. factory/activities/usage_activities.py +593 -0
  14. factory/activities/verify_activities.py +617 -0
  15. factory/cli/__init__.py +0 -0
  16. factory/cli/completion.py +150 -0
  17. factory/cli/doctor.py +484 -0
  18. factory/cli/env.py +247 -0
  19. factory/cli/errors.py +77 -0
  20. factory/cli/init.py +1057 -0
  21. factory/cli/install.py +679 -0
  22. factory/cli/main.py +212 -0
  23. factory/cli/nouns/__init__.py +59 -0
  24. factory/cli/nouns/answer.py +134 -0
  25. factory/cli/nouns/build.py +1222 -0
  26. factory/cli/nouns/completion.py +13 -0
  27. factory/cli/nouns/doctor.py +13 -0
  28. factory/cli/nouns/env.py +13 -0
  29. factory/cli/nouns/escalations.py +93 -0
  30. factory/cli/nouns/findings.py +13 -0
  31. factory/cli/nouns/init.py +13 -0
  32. factory/cli/nouns/install.py +99 -0
  33. factory/cli/nouns/repo.py +13 -0
  34. factory/cli/nouns/roadmap.py +13 -0
  35. factory/cli/nouns/spec.py +511 -0
  36. factory/cli/nouns/status.py +18 -0
  37. factory/cli/nouns/usage.py +13 -0
  38. factory/cli/nouns/worker.py +69 -0
  39. factory/cli/repo.py +569 -0
  40. factory/cli/repo_export.py +282 -0
  41. factory/cli/roadmap.py +434 -0
  42. factory/cli/status.py +844 -0
  43. factory/cli/usage.py +129 -0
  44. factory/config.py +262 -0
  45. factory/controlplane/__init__.py +0 -0
  46. factory/controlplane/config.py +863 -0
  47. factory/controlplane/resolve.py +434 -0
  48. factory/controlplane/verify.py +827 -0
  49. factory/discovery/__init__.py +3 -0
  50. factory/discovery/llm_scanner.py +176 -0
  51. factory/doctor/__init__.py +0 -0
  52. factory/doctor/cli.py +392 -0
  53. factory/doctor/models.py +165 -0
  54. factory/doctor/probes.py +522 -0
  55. factory/doctor/scaffold.py +184 -0
  56. factory/doctor/store.py +449 -0
  57. factory/env.py +131 -0
  58. factory/escalation/__init__.py +0 -0
  59. factory/escalation/client.py +168 -0
  60. factory/escalation/question.py +161 -0
  61. factory/escalation/workflow.py +470 -0
  62. factory/locking.py +85 -0
  63. factory/mergequeue/__init__.py +21 -0
  64. factory/mergequeue/classify.py +133 -0
  65. factory/mergequeue/forge.py +401 -0
  66. factory/mergequeue/gh.py +484 -0
  67. factory/mergequeue/github_forge.py +349 -0
  68. factory/mergequeue/merge_queue_ruleset.json +32 -0
  69. factory/mergequeue/messages.py +141 -0
  70. factory/mergequeue/models.py +324 -0
  71. factory/mergequeue/onboard.py +537 -0
  72. factory/mergequeue/wiring.py +466 -0
  73. factory/notify/__init__.py +0 -0
  74. factory/notify/adapter.py +263 -0
  75. factory/notify/messages.py +474 -0
  76. factory/notify/service.py +809 -0
  77. factory/notify/webhook.py +151 -0
  78. factory/personas.yaml +161 -0
  79. factory/registry.py +583 -0
  80. factory/roadmap/__init__.py +8 -0
  81. factory/roadmap/cli.py +125 -0
  82. factory/roadmap/discovery.py +286 -0
  83. factory/roadmap/models.py +583 -0
  84. factory/roadmap/schedule.py +377 -0
  85. factory/roadmap/workflow.py +1364 -0
  86. factory/supervision/__init__.py +0 -0
  87. factory/supervision/alert.py +239 -0
  88. factory/supervision/probe.py +467 -0
  89. factory/supervision/temporal_server.py +121 -0
  90. factory/supervision/units.py +649 -0
  91. factory/usage/__init__.py +0 -0
  92. factory/usage/aggregate.py +113 -0
  93. factory/usage/cli.py +223 -0
  94. factory/usage/ledger.py +295 -0
  95. factory/usage/litellm_client.py +486 -0
  96. factory/usage/models.py +149 -0
  97. factory/verify/__init__.py +0 -0
  98. factory/verify/criteria.py +488 -0
  99. factory/verify/diffbounds.py +159 -0
  100. factory/verify/diffcheck.py +506 -0
  101. factory/verify/factory_yaml.py +842 -0
  102. factory/verify/gates.py +1341 -0
  103. factory/verify/judge.py +775 -0
  104. factory/verify/ladder.py +145 -0
  105. factory/verify/models.py +745 -0
  106. factory/verify/question.py +167 -0
  107. factory/verify/store.py +1366 -0
  108. factory/verify/toolchain.py +370 -0
  109. factory/worker.py +296 -0
  110. factory/workgraph/__init__.py +0 -0
  111. factory/workgraph/adapter.py +1333 -0
  112. factory/workgraph/cli.py +801 -0
  113. factory/workgraph/delta.py +278 -0
  114. factory/workgraph/derive.py +607 -0
  115. factory/workgraph/detector.py +509 -0
  116. factory/workgraph/landed.py +496 -0
  117. factory/workgraph/models.py +494 -0
  118. factory/workgraph/preflight.py +651 -0
  119. factory/workgraph/prompt.py +796 -0
  120. factory/workgraph/workflow.py +2733 -0
  121. factory/workgraph/worktree.py +1322 -0
@@ -0,0 +1,467 @@
1
+ """042-US4: the probe — what it sees, what it says, and what it will not do.
2
+
3
+ It runs from a timer, outside the slice it watches, and it is the process that
4
+ has to work when Temporal is the thing that died. So it imports no client, and
5
+ it raises everything it has to say through US1's out-of-band path.
6
+
7
+ Three properties are the whole design:
8
+
9
+ - **It reports; it does not remediate (FR-014).** The instinct on reading "the
10
+ worker is down" is to restart it. systemd owns restarts, and a restart into
11
+ an already-dying host deepens an OOM storm rather than ending it. Reaping
12
+ orphaned test servers is the sole exception, and only because nothing else
13
+ ever will: a server whose parent died is reparented to PID 1, the fixtures'
14
+ `finally` never runs after a SIGKILL, and it accrues at ~66 MiB a copy until
15
+ the host dies — 2026-08-11, 8,131 copies, 123 GiB, one dead host.
16
+ - **Alerts are edge-triggered, with a heartbeat under them (FR-013).** The
17
+ timer fires every couple of minutes, and a page per firing is a channel the
18
+ operator mutes within a day — worse than none, because it still looks
19
+ supervised. One message per status change, plus a heartbeat on its own much
20
+ longer interval, so silence stays distinguishable from a dead probe.
21
+ - **It is loud when it cannot escalate (FR-015).** A probe whose alert went
22
+ nowhere is indistinguishable from a healthy floor, the exact failure this
23
+ unit exists to end — so it says so on stderr and exits 2, outside the
24
+ `SuccessExitStatus=0 1` its unit declares.
25
+
26
+ One deliberate divergence from the prior art, which pages on every reap: here a
27
+ reap below the configured threshold is a note, and only a count at or above it
28
+ pages (US4-S1). A leak producing two orphans an hour would otherwise page every
29
+ two minutes, which is the muting FR-013 forbids.
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import dataclasses
35
+ import json
36
+ import os
37
+ import signal
38
+ import socket
39
+ import sys
40
+ import time
41
+ from pathlib import Path
42
+ from typing import Callable, Sequence
43
+
44
+ from factory.supervision.alert import AlertOutcome, StackAlert, send_alert
45
+ from factory.supervision.units import (
46
+ BRIDGE_UNIT,
47
+ SLICE_UNIT,
48
+ TEMPORAL_UNIT,
49
+ WORKER_UNIT,
50
+ CommandResult,
51
+ _run_command,
52
+ supervision_home,
53
+ )
54
+
55
+ HEALTHY = "healthy"
56
+ DEGRADED = "degraded"
57
+
58
+ #: What the whole-stack messages name as their subject.
59
+ STACK = "ergane stack"
60
+
61
+ #: `ps` reports `comm` truncated to 15 characters, so the leaked servers appear
62
+ #: as this and never as `temporal-test-server`. Matching the full name matches
63
+ #: nothing, forever, silently — which is a safety net that reports a healthy
64
+ #: floor while the host fills up.
65
+ ORPHAN_COMM_PREFIX = "temporal-test-s"
66
+
67
+ #: The probe's state, in one file: the last status, when it last changed, and
68
+ #: when the last heartbeat went out.
69
+ STATE_FILE = "probe.json"
70
+
71
+
72
+ @dataclasses.dataclass(frozen=True)
73
+ class ProbeConfig:
74
+ """The thresholds, and the units this installation supervises."""
75
+
76
+ units: tuple[str, ...] = (WORKER_UNIT, BRIDGE_UNIT, TEMPORAL_UNIT)
77
+ slice_unit: str = SLICE_UNIT
78
+ dial: tuple[tuple[str, int], ...] = ()
79
+ mem_warn_gib: int = 16
80
+ mem_crit_gib: int = 8
81
+ #: Grace, so a fresh spawn is never raced: reaping a two-second-old server
82
+ #: kills a test that was working.
83
+ orphan_min_age_s: int = 120
84
+ orphan_alert_at: int = 10
85
+ heartbeat_every_s: int = 86_400
86
+
87
+
88
+ @dataclasses.dataclass(frozen=True)
89
+ class Host:
90
+ """Everything the probe reads about the machine, behind one seam."""
91
+
92
+ systemctl: Callable[[Sequence[str]], CommandResult]
93
+ process_table: Callable[[], str]
94
+ meminfo: Callable[[], str]
95
+ uptime_s: Callable[[], float]
96
+ kill: Callable[[int], None]
97
+ dial: Callable[[str, int], bool]
98
+
99
+
100
+ def real_host() -> Host:
101
+ """The production readings. Resolved at call time so a test can refuse them."""
102
+ return Host(
103
+ systemctl=_run_command,
104
+ process_table=_read_process_table,
105
+ meminfo=lambda: Path("/proc/meminfo").read_text(encoding="utf-8"),
106
+ uptime_s=_uptime_s,
107
+ kill=lambda pid: _kill(pid),
108
+ dial=_dial,
109
+ )
110
+
111
+
112
+ @dataclasses.dataclass(frozen=True)
113
+ class ProcessRow:
114
+ """One line of `ps -eo pid=,ppid=,etimes=,comm=`."""
115
+
116
+ pid: int
117
+ ppid: int
118
+ age_s: int
119
+ comm: str
120
+
121
+
122
+ def parse_process_table(text: str) -> tuple[ProcessRow, ...]:
123
+ rows: list[ProcessRow] = []
124
+ for line in text.splitlines():
125
+ parts = line.split()
126
+ if len(parts) < 4 or not parts[0].isdigit():
127
+ continue
128
+ rows.append(
129
+ ProcessRow(int(parts[0]), int(parts[1]), int(parts[2]), parts[3])
130
+ )
131
+ return tuple(rows)
132
+
133
+
134
+ def orphaned_test_servers(
135
+ rows: Sequence[ProcessRow], *, min_age_s: int = 120
136
+ ) -> tuple[ProcessRow, ...]:
137
+ """The three conditions, and each one is why the other two are not enough.
138
+
139
+ The truncated name, because `comm` is 15 characters. `ppid == 1`, because a
140
+ test server with a live parent is a running test rather than a leak. And
141
+ the age, because a fresh spawn is mid-fork and reaping it breaks the test
142
+ that was working. Drop any one and the net becomes a saboteur.
143
+ """
144
+ return tuple(
145
+ row
146
+ for row in rows
147
+ if row.comm.startswith(ORPHAN_COMM_PREFIX)
148
+ and row.ppid == 1
149
+ and row.age_s > min_age_s
150
+ )
151
+
152
+
153
+ def reap_orphans(
154
+ rows: Sequence[ProcessRow], *, kill: Callable[[int], None] | None = None
155
+ ) -> int:
156
+ """Kill each orphan and count what actually died.
157
+
158
+ The one thing this probe does that systemd cannot, and the one place in the
159
+ module that signals anything (FR-014). An orphan that exited between the
160
+ read and the kill is the normal case on a busy host, not an error.
161
+ """
162
+ reaped = 0
163
+ for row in rows:
164
+ try:
165
+ if kill is None:
166
+ _kill(row.pid)
167
+ else:
168
+ kill(row.pid)
169
+ except OSError:
170
+ continue
171
+ reaped += 1
172
+ return reaped
173
+
174
+
175
+ @dataclasses.dataclass(frozen=True)
176
+ class UnitReading:
177
+ """Whether a unit is up, and how long it has been down if it is not."""
178
+
179
+ name: str
180
+ active: bool
181
+ down_for_s: float
182
+
183
+
184
+ def read_unit(name: str, host: Host) -> UnitReading:
185
+ """Ask systemd, and take the outage duration from systemd's own clock.
186
+
187
+ `InactiveEnterTimestampMonotonic` is microseconds since boot, which is why
188
+ the host's uptime is the other half of the subtraction: an outage measured
189
+ against a state file would restart every time the probe's own state was
190
+ lost, and the state file is on the disk of the host that is failing.
191
+ """
192
+ answer = host.systemctl(
193
+ (
194
+ "systemctl",
195
+ "--user",
196
+ "show",
197
+ name,
198
+ "-p",
199
+ "ActiveState",
200
+ "-p",
201
+ "InactiveEnterTimestampMonotonic",
202
+ "--value",
203
+ )
204
+ )
205
+ lines = [line.strip() for line in answer.out.splitlines()]
206
+ active = bool(lines) and lines[0] == "active"
207
+ since_us = int(lines[1]) if len(lines) > 1 and lines[1].isdigit() else 0
208
+ down_for = 0.0
209
+ if not active and since_us:
210
+ down_for = max(0.0, host.uptime_s() - since_us / 1_000_000)
211
+ return UnitReading(name, active, down_for)
212
+
213
+
214
+ @dataclasses.dataclass(frozen=True)
215
+ class Verdict:
216
+ """One pass over the machine: what is wrong, what was noted, who to name."""
217
+
218
+ status: str
219
+ problems: tuple[str, ...]
220
+ notes: tuple[str, ...]
221
+ reaped: int
222
+ subject: str
223
+ subject_duration_s: float
224
+
225
+ @property
226
+ def detail(self) -> str:
227
+ body = "; ".join(self.problems) if self.problems else "all units up"
228
+ if self.notes:
229
+ body = f"{body} ({', '.join(self.notes)})"
230
+ return body
231
+
232
+
233
+ def assess(config: ProbeConfig, host: Host) -> Verdict:
234
+ """Read the machine once and render a verdict.
235
+
236
+ Liveness alone was never the gap: on 2026-08-11 every unit was active while
237
+ the host ran out of memory. Headroom and orphan count are why this checks
238
+ more than `is-active`.
239
+ """
240
+ problems: list[str] = []
241
+ notes: list[str] = []
242
+
243
+ readings = [read_unit(name, host) for name in config.units]
244
+ down = [reading for reading in readings if not reading.active]
245
+ problems += [f"{reading.name} not active" for reading in down]
246
+
247
+ available = _available_gib(host.meminfo())
248
+ if available is not None and available < config.mem_crit_gib:
249
+ problems.append(f"host memory CRITICAL: {available} GiB available")
250
+ elif available is not None and available < config.mem_warn_gib:
251
+ problems.append(f"host memory low: {available} GiB available")
252
+
253
+ for address, port in config.dial:
254
+ if not host.dial(address, port):
255
+ problems.append(f"{address}:{port} unreachable")
256
+
257
+ orphans = orphaned_test_servers(
258
+ parse_process_table(host.process_table()), min_age_s=config.orphan_min_age_s
259
+ )
260
+ reaped = reap_orphans(orphans, kill=host.kill)
261
+ if reaped:
262
+ notes.append(f"reaped {reaped} orphaned test server(s)")
263
+ if len(orphans) >= config.orphan_alert_at:
264
+ problems.append(f"test-server leak: {len(orphans)} orphaned test servers")
265
+
266
+ used = host.systemctl(
267
+ ("systemctl", "--user", "show", config.slice_unit, "-p", "MemoryCurrent", "--value")
268
+ ).out.strip()
269
+ if used.isdigit():
270
+ notes.append(f"{config.slice_unit} {int(used) // 1_073_741_824} GiB")
271
+
272
+ return Verdict(
273
+ status=DEGRADED if problems else HEALTHY,
274
+ problems=tuple(problems),
275
+ notes=tuple(notes),
276
+ reaped=reaped,
277
+ subject=down[0].name if down else STACK,
278
+ subject_duration_s=down[0].down_for_s if down else 0.0,
279
+ )
280
+
281
+
282
+ @dataclasses.dataclass(frozen=True)
283
+ class ProbeState:
284
+ """What the last run left behind, so this one can tell what changed."""
285
+
286
+ status: str | None
287
+ changed_at: float
288
+ heartbeat_at: float
289
+
290
+
291
+ def read_state(state_dir: Path) -> ProbeState:
292
+ """The previous run's state, or none at all.
293
+
294
+ An unreadable file reads as no state rather than raising: a truncated write
295
+ during an OOM is exactly the condition the next run exists to report.
296
+ """
297
+ try:
298
+ document = json.loads((Path(state_dir) / STATE_FILE).read_text(encoding="utf-8"))
299
+ return ProbeState(
300
+ status=document.get("status"),
301
+ changed_at=float(document.get("changed_at", 0.0)),
302
+ heartbeat_at=float(document.get("heartbeat_at", 0.0)),
303
+ )
304
+ except (OSError, ValueError, TypeError, AttributeError):
305
+ return ProbeState(None, 0.0, 0.0)
306
+
307
+
308
+ def write_state(state_dir: Path, state: ProbeState) -> None:
309
+ directory = Path(state_dir)
310
+ directory.mkdir(parents=True, exist_ok=True)
311
+ (directory / STATE_FILE).write_text(
312
+ json.dumps(dataclasses.asdict(state)), encoding="utf-8"
313
+ )
314
+
315
+
316
+ @dataclasses.dataclass(frozen=True)
317
+ class AlertDecision:
318
+ """Whether to page, and whether paging restarts the heartbeat clock."""
319
+
320
+ alert: StackAlert | None
321
+ heartbeat: bool
322
+
323
+
324
+ def decide_alert(
325
+ verdict: Verdict, state: ProbeState, *, now: float, heartbeat_every_s: int
326
+ ) -> AlertDecision:
327
+ """The edge-trigger table (FR-013), and it is deliberately small.
328
+
329
+ A first observation of *healthy* is silent: there is no incident behind it,
330
+ and a fresh install that opens with "RECOVERED" teaches the operator that
331
+ the channel is noisy. A first observation of *degraded* pages, because that
332
+ one is real.
333
+ """
334
+ if verdict.status == DEGRADED and state.status != DEGRADED:
335
+ return AlertDecision(
336
+ StackAlert(verdict.subject, verdict.detail, verdict.subject_duration_s),
337
+ False,
338
+ )
339
+ if verdict.status == HEALTHY and state.status == DEGRADED:
340
+ return AlertDecision(
341
+ StackAlert(
342
+ STACK, f"recovered — {verdict.detail}", max(0.0, now - state.changed_at)
343
+ ),
344
+ False,
345
+ )
346
+ if (
347
+ verdict.status == HEALTHY
348
+ and state.status == HEALTHY
349
+ and now - state.heartbeat_at >= heartbeat_every_s
350
+ ):
351
+ return AlertDecision(
352
+ StackAlert(STACK, f"healthy — {verdict.detail}", now - state.heartbeat_at),
353
+ True,
354
+ )
355
+ return AlertDecision(None, False)
356
+
357
+
358
+ @dataclasses.dataclass(frozen=True)
359
+ class ProbeRun:
360
+ """One firing of the timer, whole."""
361
+
362
+ verdict: Verdict
363
+ alert: StackAlert | None
364
+ outcome: AlertOutcome | None
365
+ exit_code: int
366
+
367
+
368
+ def run_probe(
369
+ config: ProbeConfig,
370
+ host: Host,
371
+ state_dir: Path,
372
+ *,
373
+ now: float | None = None,
374
+ alert: Callable[[StackAlert], AlertOutcome] = send_alert,
375
+ ) -> ProbeRun:
376
+ """Read the machine, decide, page if the edge says so, and record.
377
+
378
+ `alert` defaults to US1's synchronous door — the path that works when
379
+ Temporal is gone — and is injectable only so a test can assert the decision
380
+ without a transport.
381
+ """
382
+ moment = time.time() if now is None else now
383
+ verdict = assess(config, host)
384
+ state = read_state(state_dir)
385
+ decision = decide_alert(
386
+ verdict, state, now=moment, heartbeat_every_s=config.heartbeat_every_s
387
+ )
388
+ outcome = alert(decision.alert) if decision.alert is not None else None
389
+
390
+ became_healthy = verdict.status == HEALTHY and state.status != HEALTHY
391
+ write_state(
392
+ state_dir,
393
+ ProbeState(
394
+ status=verdict.status,
395
+ changed_at=moment if verdict.status != state.status else state.changed_at,
396
+ heartbeat_at=(
397
+ moment if decision.heartbeat or became_healthy else state.heartbeat_at
398
+ ),
399
+ ),
400
+ )
401
+
402
+ print(f"{verdict.status}: {verdict.detail}", file=sys.stdout, flush=True)
403
+ if outcome is not None and not outcome.delivered:
404
+ # The same words US1 logs, on purpose: one phrase an operator greps the
405
+ # journal for, and the timer's failure mail carries this half.
406
+ print(
407
+ f"WARN: cannot escalate ({outcome.failure}): {outcome.text}",
408
+ file=sys.stderr,
409
+ flush=True,
410
+ )
411
+ return ProbeRun(verdict, decision.alert, outcome, 2)
412
+ return ProbeRun(
413
+ verdict, decision.alert, outcome, 0 if verdict.status == HEALTHY else 1
414
+ )
415
+
416
+
417
+ def _available_gib(meminfo: str) -> int | None:
418
+ for line in meminfo.splitlines():
419
+ if line.startswith("MemAvailable:"):
420
+ parts = line.split()
421
+ if len(parts) > 1 and parts[1].isdigit():
422
+ return int(parts[1]) // 1_048_576
423
+ return None
424
+
425
+
426
+ def _read_process_table() -> str:
427
+ return _run_command(("ps", "-eo", "pid=,ppid=,etimes=,comm=")).out
428
+
429
+
430
+ def _uptime_s() -> float:
431
+ return float(Path("/proc/uptime").read_text(encoding="utf-8").split()[0])
432
+
433
+
434
+ def _dial(address: str, port: int) -> bool:
435
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe:
436
+ probe.settimeout(3.0)
437
+ return probe.connect_ex((address, port)) == 0
438
+
439
+
440
+ def _kill(pid: int) -> None:
441
+ os.kill(pid, signal.SIGKILL)
442
+
443
+
444
+ def _default_dial() -> tuple[tuple[str, int], ...]:
445
+ """The declared Temporal address, if one resolves.
446
+
447
+ Imported here rather than at module scope, and swallowed rather than
448
+ raised: a probe that cannot parse a config has stopped supervising, and the
449
+ reachability check is the least of what it reports.
450
+ """
451
+ try:
452
+ from factory.controlplane.resolve import resolve_temporal_target
453
+
454
+ address, _, port = resolve_temporal_target().address.rpartition(":")
455
+ return ((address or "127.0.0.1", int(port)),)
456
+ except Exception:
457
+ return ()
458
+
459
+
460
+ def main(argv: Sequence[str] | None = None) -> int:
461
+ """`python3 -m factory.supervision.probe` — what the timer runs."""
462
+ run = run_probe(ProbeConfig(dial=_default_dial()), real_host(), supervision_home())
463
+ return run.exit_code
464
+
465
+
466
+ if __name__ == "__main__": # pragma: no cover - process entry point
467
+ raise SystemExit(main())
@@ -0,0 +1,121 @@
1
+ """042-US3: managed Temporal dev-server entry point for systemd.
2
+
3
+ The unit runs this module rather than invoking a Temporal CLI binary directly
4
+ because the CLI is not guaranteed to be on `PATH`; the temporalio SDK downloads the
5
+ server binary on first use and caches it. The wrapper (`ergane-run.sh`) sets
6
+ `PATH` and the working directory, then calls the venv's python3 with this module
7
+ as an argument.
8
+
9
+ The command line intentionally avoids `python -`: a 2026-08-12 cleanup sweep ran
10
+ `pkill -f "python -"` and matched the worker's `python -m factory.worker`
11
+ command line. Using the wrapper plus `factory.supervision.temporal_server` keeps
12
+ the visible argv free of that substring.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import argparse
18
+ import asyncio
19
+ import logging
20
+ import socket
21
+ import sys
22
+ from typing import Sequence
23
+
24
+ from temporalio.runtime import Runtime
25
+ from temporalio.testing import WorkflowEnvironment
26
+
27
+ DEFAULT_NAMESPACE = "ergane"
28
+ DEFAULT_LOG_LEVEL = "warn"
29
+
30
+
31
+ async def _run(
32
+ *,
33
+ db_filename: str,
34
+ namespace: str,
35
+ ip: str,
36
+ port: int,
37
+ log_level: str,
38
+ ) -> int:
39
+ """Start the dev server and block until it is shut down."""
40
+ # Ensure a runtime exists before the server starts; the default is created
41
+ # on demand, but referencing it here makes shutdown deterministic.
42
+ _runtime = Runtime.default()
43
+ environment = await WorkflowEnvironment.start_local(
44
+ namespace=namespace,
45
+ ip=ip,
46
+ port=port,
47
+ dev_server_database_filename=db_filename,
48
+ dev_server_log_level=log_level,
49
+ )
50
+ try:
51
+ # Keep the process alive. The server child handles signals; this main
52
+ # thread just waits. WorkflowEnvironment does not expose a wait, so we
53
+ # sleep forever and let systemd's SIGTERM propagate through the wrapper.
54
+ while True:
55
+ await asyncio.sleep(3600)
56
+ finally:
57
+ await environment.shutdown()
58
+ return 0
59
+
60
+
61
+ def _free_port(ip: str) -> int:
62
+ """Bind a free TCP port on `ip` and return it."""
63
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe:
64
+ probe.bind((ip, 0))
65
+ return probe.getsockname()[1]
66
+
67
+
68
+ def main(argv: Sequence[str] | None = None) -> int:
69
+ """`python3 -m factory.supervision.temporal_server` — what the unit runs."""
70
+ parser = argparse.ArgumentParser(
71
+ description="Run the managed Temporal dev server under systemd supervision."
72
+ )
73
+ parser.add_argument(
74
+ "--db-filename",
75
+ required=True,
76
+ help="SQLite database file for persistent workflow history",
77
+ )
78
+ parser.add_argument(
79
+ "--namespace",
80
+ default=DEFAULT_NAMESPACE,
81
+ help=f"Temporal namespace (default: {DEFAULT_NAMESPACE})",
82
+ )
83
+ parser.add_argument(
84
+ "--ip",
85
+ default="127.0.0.1",
86
+ help="IP address to bind the frontend to (default: 127.0.0.1)",
87
+ )
88
+ parser.add_argument(
89
+ "--port",
90
+ type=int,
91
+ default=7233,
92
+ help="Frontend port; 0 means bind a free port (default: 7233)",
93
+ )
94
+ parser.add_argument(
95
+ "--log-level",
96
+ default=DEFAULT_LOG_LEVEL,
97
+ help=f"Dev-server log level (default: {DEFAULT_LOG_LEVEL})",
98
+ )
99
+ args = parser.parse_args(argv)
100
+
101
+ port = _free_port(args.ip) if args.port == 0 else args.port
102
+ logging.basicConfig(level=logging.INFO)
103
+ logging.info(
104
+ "starting managed Temporal dev server on %s:%d (db=%s)",
105
+ args.ip,
106
+ port,
107
+ args.db_filename,
108
+ )
109
+ return asyncio.run(
110
+ _run(
111
+ db_filename=args.db_filename,
112
+ namespace=args.namespace,
113
+ ip=args.ip,
114
+ port=port,
115
+ log_level=args.log_level,
116
+ )
117
+ )
118
+
119
+
120
+ if __name__ == "__main__": # pragma: no cover - process entry point
121
+ raise SystemExit(main())