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,649 @@
1
+ """042-US2: the systemd user units the engine generates, installs and removes.
2
+
3
+ The design input is not in this repository. Five unit files and a probe script
4
+ were hand-written on the operator's host after a 2026-08-11 outage and live in
5
+ another repo entirely; they work, and every path in them is that one host's.
6
+ This module is those lessons made portable: the containment, the kill
7
+ semantics and the restart bound are carried over verbatim in meaning, and every
8
+ literal path is resolved from the operator's own installation instead
9
+ (SC-006).
10
+
11
+ Four things here are load-bearing, and each was paid for:
12
+
13
+ - **`KillMode=control-group`.** Agents, gate subprocesses and anything they
14
+ spawned live in the unit's cgroup, and stopping the unit must take the whole
15
+ tree. A bare kill of the worker pid is what leaves 66 MiB orphans on PID 1. A
16
+ unit missing this starts, restarts and supervises perfectly while leaking.
17
+ - **The slice.** Orphans reparent to PID 1 but do *not* leave their cgroup, so
18
+ a leak stays inside `MemoryMax` and the kernel reclaims within the slice
19
+ rather than taking the host down. `TasksMax` is the other half: 8,131
20
+ processes is far past anything a healthy floor needs.
21
+ - **The probe is outside the slice.** Every other generated unit goes in it;
22
+ the probe's does not, and that is the whole point — a supervisor inside the
23
+ contained slice is reclaimed alongside the leak it exists to report.
24
+ - **The command line may not contain `python -`.** On 2026-08-12 an agent ran
25
+ `pkill -f "python -"` to clean up its own strays; that matched the worker's
26
+ and the bridge's `uv run python -m factory.worker` command lines and killed
27
+ both. 011/US4's pid namespace fixes the root defect for dispatched agents;
28
+ the spelling still protects the callers it does not cover, and it costs
29
+ nothing.
30
+
31
+ Provenance is recorded at install time — a digest of what was written — so
32
+ `uninstall` *knows* which files are the engine's rather than guessing from
33
+ their names. The operator's own unit of a colliding name may be the one keeping
34
+ their host alive, and it is reported rather than deleted (FR-008).
35
+
36
+ The probe's own unit and timer belong to 042-US4 with the probe itself, and
37
+ could not have shipped ahead of it: a unit whose `ExecStart` names a module
38
+ that does not exist yet exits 1 on `ModuleNotFoundError`, and 1 is inside the
39
+ `SuccessExitStatus=0 1` such a unit declares — the timer would read green while
40
+ supervising nothing.
41
+ """
42
+
43
+ from __future__ import annotations
44
+
45
+ import dataclasses
46
+ import hashlib
47
+ import json
48
+ import subprocess
49
+ import sys
50
+ from pathlib import Path
51
+ from typing import Callable, Mapping, Sequence
52
+
53
+ from factory.cli.errors import OperatorError
54
+ from factory.registry import resolve_state_home
55
+
56
+ SLICE_UNIT = "ergane.slice"
57
+ WORKER_UNIT = "ergane-worker.service"
58
+ BRIDGE_UNIT = "ergane-bridge.service"
59
+ TEMPORAL_UNIT = "ergane-temporal.service"
60
+ PROBE_UNIT = "ergane-probe.service"
61
+ PROBE_TIMER = "ergane-probe.timer"
62
+
63
+ #: One wrapper, shared by every unit, taking the module to run as its argument.
64
+ WRAPPER_NAME = "ergane-run.sh"
65
+
66
+ #: Where the provenance record lives, beside the wrapper rather than beside the
67
+ #: units: a state file under a config directory is what makes `~/.config`
68
+ #: un-copyable between hosts.
69
+ MANIFEST_NAME = "installed.json"
70
+
71
+ #: What an agent's stray-cleanup sweep matched on 2026-08-12.
72
+ PKILL_PATTERN = "python -"
73
+
74
+ #: The units `install` enables. Neither the slice nor the probe *service* is
75
+ #: among them — the slice is pulled in by the `Slice=` lines that reference it
76
+ #: and the probe service by its timer, so enabling either would be declaring a
77
+ #: `WantedBy` that systemd then has to reconcile.
78
+ #: Units enabled for every installation. TEMPORAL_UNIT is added when the
79
+ #: layout's `temporal_mode` is `managed`; it is deliberately not in this tuple
80
+ #: because external mode must not enable a unit that was not generated.
81
+ ENABLE_TARGETS = (WORKER_UNIT, BRIDGE_UNIT, TEMPORAL_UNIT, PROBE_TIMER)
82
+
83
+ _MODULES = {
84
+ WORKER_UNIT: "factory.worker",
85
+ BRIDGE_UNIT: "factory.notify.service",
86
+ PROBE_UNIT: "factory.supervision.probe",
87
+ }
88
+
89
+
90
+ @dataclasses.dataclass(frozen=True)
91
+ class CommandResult:
92
+ """What a command said, in the two terms any caller here needs."""
93
+
94
+ code: int
95
+ out: str = ""
96
+
97
+
98
+ def _run_command(argv: Sequence[str]) -> CommandResult:
99
+ """Run one command. The seam every test in this story closes.
100
+
101
+ Tests pass their own; nothing in the suite may reach the systemd session
102
+ running the factory, because on this host that session *is* the factory.
103
+ """
104
+ finished = subprocess.run( # noqa: S603 - fixed argv, no shell
105
+ list(argv), capture_output=True, text=True, check=False
106
+ )
107
+ return CommandResult(finished.returncode, finished.stdout.strip())
108
+
109
+
110
+ @dataclasses.dataclass(frozen=True)
111
+ class InstallLayout:
112
+ """Every path a generated file may name, and nothing else (SC-006).
113
+
114
+ `roots` is what the scan is against: a generated unit that references a
115
+ path outside these starts, restarts and supervises perfectly on the one
116
+ host where that path happens to exist.
117
+ """
118
+
119
+ install_root: Path
120
+ interpreter: Path
121
+ unit_dir: Path
122
+ generated_dir: Path
123
+ env_command: str | None = None
124
+ memory_high: str = "32G"
125
+ memory_max: str = "48G"
126
+ tasks_max: int = 2000
127
+ restart_window_s: int = 300
128
+ restart_burst: int = 5
129
+ probe_interval: str = "2min"
130
+ temporal_mode: str = "external"
131
+
132
+ @property
133
+ def roots(self) -> tuple[Path, ...]:
134
+ # The Temporal db lives under the state home, one directory above the
135
+ # supervision subdirectory (SC-004). Including that parent in roots lets
136
+ # the path scan treat it as inside the operator's own installation.
137
+ return (
138
+ self.install_root,
139
+ self.unit_dir,
140
+ self.generated_dir,
141
+ self.generated_dir.parent,
142
+ self.interpreter,
143
+ )
144
+
145
+ @property
146
+ def wrapper(self) -> Path:
147
+ return self.generated_dir / WRAPPER_NAME
148
+
149
+ @property
150
+ def temporal_db_path(self) -> Path:
151
+ """Where the managed Temporal server keeps its SQLite history."""
152
+ return self.generated_dir.parent / "temporal" / "dev.db"
153
+
154
+
155
+ def supervision_home() -> Path:
156
+ """Where the engine keeps what supervision generates and remembers.
157
+
158
+ Under 034/us2's `resolve_state_home`, which honors `ERGANE_STATE_HOME` and
159
+ the legacy name ahead of XDG. Deriving a second answer from
160
+ `XDG_STATE_HOME` here is exactly the drift that resolver exists to prevent.
161
+ """
162
+ return resolve_state_home() / "ergane" / "supervision"
163
+
164
+
165
+ def resolve_layout(
166
+ *,
167
+ home: Path | str | None = None,
168
+ install_root: Path | str | None = None,
169
+ interpreter: Path | str | None = None,
170
+ unit_dir: Path | str | None = None,
171
+ generated_dir: Path | str | None = None,
172
+ env_command: str | None = None,
173
+ ) -> InstallLayout:
174
+ """Resolve where this installation actually is.
175
+
176
+ `install_root` defaults to the directory holding the `factory` package,
177
+ which is the repository root in a checkout and `site-packages` in a wheel —
178
+ either way it is *this* installation and never a literal.
179
+ """
180
+ base = Path.home() if home is None else Path(home)
181
+ root = (
182
+ Path(__file__).resolve().parent.parent.parent
183
+ if install_root is None
184
+ else Path(install_root)
185
+ )
186
+ exe = Path(sys.executable if interpreter is None else interpreter)
187
+ return InstallLayout(
188
+ install_root=root,
189
+ interpreter=_python3_spelling(exe),
190
+ unit_dir=base / ".config/systemd/user" if unit_dir is None else Path(unit_dir),
191
+ generated_dir=(
192
+ supervision_home() if generated_dir is None else Path(generated_dir)
193
+ ),
194
+ env_command=env_command,
195
+ )
196
+
197
+
198
+ def _python3_spelling(exe: Path) -> Path:
199
+ """Prefer the `python3` a venv always carries beside its `python`.
200
+
201
+ Not cosmetic: `…/bin/python -m factory.worker` is the command line the
202
+ 2026-08-12 sweep matched, and `…/bin/python3 -m` is the one it did not.
203
+ """
204
+ sibling = exe.with_name("python3")
205
+ if exe.name != "python3" and sibling.exists():
206
+ return sibling
207
+ return exe
208
+
209
+
210
+ def command_line(layout: InstallLayout, module: str) -> str:
211
+ """The argv the kernel will show for a unit running `module`.
212
+
213
+ Distinct from the file's text on purpose: the wrapper quotes the
214
+ interpreter, so a bare `python` reads as `python" -m` on disk and as
215
+ `python -m` in `/proc/<pid>/cmdline`. `pkill -f` reads the second one.
216
+ """
217
+ return f"{layout.interpreter} -m {module}"
218
+
219
+
220
+ @dataclasses.dataclass(frozen=True)
221
+ class GeneratedFile:
222
+ """One file the engine writes, and where it goes."""
223
+
224
+ name: str
225
+ text: str
226
+ directory: Path
227
+ mode: int = 0o644
228
+
229
+ @property
230
+ def path(self) -> Path:
231
+ return self.directory / self.name
232
+
233
+
234
+ def _temporal_managed(layout: InstallLayout) -> bool:
235
+ """Whether this installation generates the managed Temporal server unit.
236
+
237
+ `ergane worker install` is the supervised path and defaults to managed. The
238
+ `ergane install` walkthrough sets `temporal_mode="external"` when the
239
+ operator chose external Temporal, and that layout is what `resolve_layout`
240
+ will produce for the same host.
241
+ """
242
+ return layout.temporal_mode == "managed"
243
+
244
+
245
+ def generated_files(layout: InstallLayout) -> tuple[GeneratedFile, ...]:
246
+ """Every file `install` writes, rendered from `layout` and nothing else."""
247
+ for module in _MODULES.values():
248
+ if PKILL_PATTERN in command_line(layout, module):
249
+ raise OperatorError(
250
+ f"the interpreter at {layout.interpreter} would give this unit a "
251
+ f"command line containing {PKILL_PATTERN!r}, which an agent's "
252
+ "stray-cleanup sweep matched on 2026-08-12, killing the worker "
253
+ "it was running under; point at the venv's python3 instead"
254
+ )
255
+
256
+ units = layout.unit_dir
257
+ generated = layout.generated_dir
258
+ files: list[GeneratedFile] = [
259
+ GeneratedFile(SLICE_UNIT, _slice_text(layout), units),
260
+ GeneratedFile(WORKER_UNIT, _worker_text(layout), units),
261
+ GeneratedFile(BRIDGE_UNIT, _bridge_text(layout), units),
262
+ GeneratedFile(PROBE_UNIT, _probe_text(layout), units),
263
+ GeneratedFile(PROBE_TIMER, _timer_text(layout), units),
264
+ GeneratedFile(WRAPPER_NAME, _wrapper_text(layout), generated, 0o755),
265
+ ]
266
+ if _temporal_managed(layout):
267
+ files.insert(
268
+ 3, GeneratedFile(TEMPORAL_UNIT, _temporal_text(layout), units)
269
+ )
270
+ return tuple(files)
271
+
272
+
273
+ def _slice_text(layout: InstallLayout) -> str:
274
+ return f"""\
275
+ [Unit]
276
+ Description=ergane — memory-contained slice
277
+
278
+ [Slice]
279
+ # Orphaned processes reparent to PID 1 but do NOT leave their cgroup, so a leak
280
+ # stays inside these limits and the kernel reclaims within the slice instead of
281
+ # taking the host down with it. MemoryHigh applies pressure first; MemoryMax is
282
+ # the wall.
283
+ MemoryHigh={layout.memory_high}
284
+ MemoryMax={layout.memory_max}
285
+
286
+ # A runaway spawn is the other half of the same failure.
287
+ TasksMax={layout.tasks_max}
288
+ """
289
+
290
+
291
+ def _service_text(
292
+ layout: InstallLayout,
293
+ *,
294
+ description: str,
295
+ module: str,
296
+ restart: str,
297
+ stop_timeout_s: int,
298
+ ) -> str:
299
+ return f"""\
300
+ [Unit]
301
+ Description={description}
302
+ # Give up rather than flap: a restart loop during a memory storm deepens it.
303
+ StartLimitIntervalSec={layout.restart_window_s}
304
+ StartLimitBurst={layout.restart_burst}
305
+
306
+ [Service]
307
+ Type=simple
308
+ Slice={SLICE_UNIT}
309
+ WorkingDirectory={layout.install_root}
310
+ ExecStart={layout.wrapper} {module}
311
+
312
+ # The point, not a default worth losing: agents, gate subprocesses and anything
313
+ # they spawned are in this unit's cgroup, and stopping the unit must take the
314
+ # whole tree. A bare kill of the main pid is what leaves orphans on PID 1.
315
+ KillMode=control-group
316
+ KillSignal=SIGTERM
317
+ TimeoutStopSec={stop_timeout_s}
318
+
319
+ Restart={restart}
320
+ RestartSec=10
321
+
322
+ [Install]
323
+ WantedBy=default.target
324
+ """
325
+
326
+
327
+ def _worker_text(layout: InstallLayout) -> str:
328
+ return _service_text(
329
+ layout,
330
+ description="ergane — factory worker (workgraph task queue)",
331
+ module=_MODULES[WORKER_UNIT],
332
+ restart="on-failure",
333
+ stop_timeout_s=120,
334
+ )
335
+
336
+
337
+ def _bridge_text(layout: InstallLayout) -> str:
338
+ return _service_text(
339
+ layout,
340
+ # The listening half of the operator channel. Its death is invisible
341
+ # from inside the factory: the sending half is a workflow activity that
342
+ # needs no bridge, so questions keep flowing outward while every inbound
343
+ # answer falls on the floor. This unit is the only watcher it has.
344
+ description="ergane — operator channel bridge (replies to signals)",
345
+ module=_MODULES[BRIDGE_UNIT],
346
+ restart="always",
347
+ stop_timeout_s=30,
348
+ )
349
+
350
+
351
+ def _temporal_text(layout: InstallLayout) -> str:
352
+ """The managed Temporal server unit: persistence, containment, restart bound.
353
+
354
+ The Go dev server is downloaded on demand by the temporalio package and cached
355
+ under the state home. Persistence lives there too: a host reinstall that
356
+ preserves the state directory keeps history, while a fresh install starts
357
+ clean — which is the contract SC-004 defends.
358
+ """
359
+ db_path = layout.temporal_db_path
360
+ # Use a tiny Python shim so the command line does not contain `python -`.
361
+ # The shim starts the local dev server on the bound frontend port and blocks
362
+ # until the process exits; systemd receives the server's stdout and the
363
+ # usual signals.
364
+ return f"""\
365
+ [Unit]
366
+ Description=ergane — managed Temporal server (SQLite persistence)
367
+ # Give up rather than flap: a restart loop during a memory storm deepens it.
368
+ StartLimitIntervalSec={layout.restart_window_s}
369
+ StartLimitBurst={layout.restart_burst}
370
+
371
+ [Service]
372
+ Type=simple
373
+ Slice={SLICE_UNIT}
374
+ WorkingDirectory={layout.install_root}
375
+ ExecStart={layout.wrapper} factory.supervision.temporal_server --db-filename {db_path} --namespace ergane --log-level warn
376
+
377
+ # The point, not a default worth losing: the dev server spawns child processes,
378
+ # and stopping the unit must take the whole tree. A bare kill of the main pid is
379
+ # what leaves orphans on PID 1.
380
+ KillMode=control-group
381
+ KillSignal=SIGTERM
382
+ TimeoutStopSec=60
383
+
384
+ Restart=on-failure
385
+ RestartSec=10
386
+
387
+ [Install]
388
+ WantedBy=default.target
389
+ """
390
+
391
+
392
+ def _probe_text(layout: InstallLayout) -> str:
393
+ return f"""\
394
+ [Unit]
395
+ Description=ergane — stack liveness, memory and orphan-leak probe
396
+
397
+ [Service]
398
+ Type=oneshot
399
+ # Deliberately NOT in {SLICE_UNIT}: the supervisor must outlive what it watches.
400
+ # A probe inside the contained slice is reclaimed alongside the leak it exists
401
+ # to report.
402
+ ExecStart={layout.wrapper} {_MODULES[PROBE_UNIT]}
403
+ # A degraded verdict exits 1 by design; that is a report, not a unit failure.
404
+ # Exit 2 — an alert nobody received — is a failure, and must stay one.
405
+ SuccessExitStatus=0 1
406
+ """
407
+
408
+
409
+ def _timer_text(layout: InstallLayout) -> str:
410
+ return f"""\
411
+ [Unit]
412
+ Description=ergane — run the stack probe on its interval
413
+
414
+ [Timer]
415
+ OnBootSec=90s
416
+ OnUnitActiveSec={layout.probe_interval}
417
+ AccuracySec=15s
418
+ Unit={PROBE_UNIT}
419
+
420
+ [Install]
421
+ WantedBy=timers.target
422
+ """
423
+
424
+
425
+ def _wrapper_text(layout: InstallLayout) -> str:
426
+ """The one indirection, and the two reasons it is not resolved into units.
427
+
428
+ systemd cannot evaluate a command substitution, so an operator's
429
+ environment command can only run from a script; resolving it into
430
+ `Environment=` lines instead would write their credentials to disk in a
431
+ file the journal echoes back. And `PATH` is composed at *start* time rather
432
+ than frozen at install time, because a unit whose `PATH` predates the
433
+ toolchain installed after it is a real defect this repository has already
434
+ paid for (`factory/verify/toolchain.py`).
435
+ """
436
+ environment = (
437
+ f'eval "$({layout.env_command})"\n' if layout.env_command else ""
438
+ )
439
+ return f"""\
440
+ #!/bin/sh
441
+ # Generated by `ergane worker install`. Edit the engine, not this file:
442
+ # uninstall removes it only while it still matches what install wrote.
443
+ set -eu
444
+ cd "{layout.install_root}"
445
+ PATH="$HOME/.local/bin:$PATH"
446
+ export PATH
447
+ {environment}exec "{layout.interpreter}" -m "$1"
448
+ """
449
+
450
+
451
+ @dataclasses.dataclass(frozen=True)
452
+ class InstallReport:
453
+ """What was written, what was left alone, and what came up."""
454
+
455
+ written: tuple[str, ...]
456
+ kept: tuple[str, ...]
457
+ active: tuple[str, ...]
458
+ enabled: tuple[str, ...]
459
+ linger: bool
460
+
461
+ def render(self) -> str:
462
+ lines = [f"wrote {len(self.written)} file(s) to the unit directory"]
463
+ lines += [f" active+enabled: {name}" for name in sorted(self.active)]
464
+ if not self.linger:
465
+ lines.append(" WARN: linger is off; user units stop at logout")
466
+ lines += [f" left alone (not written by ergane): {n}" for n in self.kept]
467
+ return "\n".join(lines)
468
+
469
+
470
+ @dataclasses.dataclass(frozen=True)
471
+ class UninstallReport:
472
+ """What was removed, and what was deliberately not."""
473
+
474
+ removed: tuple[str, ...]
475
+ kept: tuple[str, ...]
476
+
477
+ def render(self) -> str:
478
+ lines = [f"removed {len(self.removed)} file(s)"]
479
+ lines += [f" left in place (not written by ergane): {n}" for n in self.kept]
480
+ return "\n".join(lines)
481
+
482
+
483
+ def install(
484
+ layout: InstallLayout,
485
+ *,
486
+ run: Callable[[Sequence[str]], CommandResult] | None = None,
487
+ ) -> InstallReport:
488
+ """Write the units, enable them, enable linger, and report what came up.
489
+
490
+ A file the engine did not write is never overwritten — that is the half of
491
+ the collision that cannot be undone.
492
+ """
493
+ runner = _run_command if run is None else run
494
+ layout.unit_dir.mkdir(parents=True, exist_ok=True)
495
+ layout.generated_dir.mkdir(parents=True, exist_ok=True)
496
+
497
+ recorded = _read_manifest(layout)
498
+ written: list[str] = []
499
+ kept: list[str] = []
500
+ fresh: dict[str, str] = {}
501
+ for generated in generated_files(layout):
502
+ if _is_someone_elses(generated, recorded):
503
+ kept.append(generated.name)
504
+ continue
505
+ generated.path.write_text(generated.text, encoding="utf-8")
506
+ generated.path.chmod(generated.mode)
507
+ written.append(generated.name)
508
+ fresh[generated.name] = _digest(generated.text)
509
+ _write_manifest(layout, fresh)
510
+
511
+ runner(("systemctl", "--user", "daemon-reload"))
512
+ # Without linger a user unit is stopped when the last session ends, which
513
+ # turns "supervised" into "supervised until the operator closes their laptop".
514
+ linger = runner(("loginctl", "enable-linger")).code == 0
515
+ for name in ENABLE_TARGETS:
516
+ if name in written:
517
+ runner(("systemctl", "--user", "enable", "--now", name))
518
+
519
+ return InstallReport(
520
+ written=tuple(written),
521
+ kept=tuple(kept),
522
+ active=_reading(runner, "is-active"),
523
+ enabled=_reading(runner, "is-enabled"),
524
+ linger=linger,
525
+ )
526
+
527
+
528
+ def uninstall(
529
+ layout: InstallLayout,
530
+ *,
531
+ run: Callable[[Sequence[str]], CommandResult] | None = None,
532
+ open_epics: Callable[[], Sequence[str]] | None = None,
533
+ ) -> UninstallReport:
534
+ """Remove exactly the files install wrote, and report the rest (FR-008).
535
+
536
+ The epic read comes first and touches nothing: a disable issued before the
537
+ refusal is a half-uninstall, which is worse than either outcome.
538
+ """
539
+ epics = tuple((_open_epic_ids if open_epics is None else open_epics)())
540
+ if epics:
541
+ raise OperatorError(
542
+ f"refusing to uninstall while {', '.join(epics)} is in flight: "
543
+ "removing the worker mid-epic strands the attempt it is running; "
544
+ "let it land, or kill it first"
545
+ )
546
+
547
+ runner = _run_command if run is None else run
548
+ recorded = _read_manifest(layout)
549
+ for name in ENABLE_TARGETS:
550
+ if name in recorded:
551
+ # Before deleting: systemd holds the parsed unit in memory, and a
552
+ # file removed out from under a running unit leaves it up and
553
+ # invisible to `disable` until the next reboot.
554
+ runner(("systemctl", "--user", "disable", "--now", name))
555
+
556
+ removed: list[str] = []
557
+ kept: list[str] = []
558
+ for generated in generated_files(layout):
559
+ if not generated.path.exists():
560
+ continue
561
+ if _is_someone_elses(generated, recorded):
562
+ kept.append(generated.name)
563
+ continue
564
+ generated.path.unlink()
565
+ removed.append(generated.name)
566
+ (layout.generated_dir / MANIFEST_NAME).unlink(missing_ok=True)
567
+
568
+ runner(("systemctl", "--user", "daemon-reload"))
569
+ return UninstallReport(tuple(removed), tuple(kept))
570
+
571
+
572
+ def _reading(
573
+ runner: Callable[[Sequence[str]], CommandResult], question: str
574
+ ) -> tuple[str, ...]:
575
+ """Which of the enable targets answer `question` affirmatively.
576
+
577
+ Read back rather than inferred: `enable --now` returning 0 is not the same
578
+ claim as the unit being up, and the difference is what an operator wants
579
+ from an install that says it succeeded.
580
+ """
581
+ return tuple(
582
+ name
583
+ for name in ENABLE_TARGETS
584
+ if runner(("systemctl", "--user", question, name)).code == 0
585
+ )
586
+
587
+
588
+ def _is_someone_elses(generated: GeneratedFile, recorded: Mapping[str, str]) -> bool:
589
+ """Whether this file on disk is one the engine did not write.
590
+
591
+ Provenance is a digest, not a filename: an operator who tuned `MemoryMax`
592
+ by hand has made the file theirs, and the tuning was probably a response to
593
+ something.
594
+ """
595
+ if not generated.path.exists():
596
+ return False
597
+ return recorded.get(generated.name) != _digest(
598
+ generated.path.read_text(encoding="utf-8")
599
+ )
600
+
601
+
602
+ def _digest(text: str) -> str:
603
+ return hashlib.sha256(text.encode("utf-8")).hexdigest()
604
+
605
+
606
+ def _read_manifest(layout: InstallLayout) -> dict[str, str]:
607
+ try:
608
+ document = json.loads(
609
+ (layout.generated_dir / MANIFEST_NAME).read_text(encoding="utf-8")
610
+ )
611
+ return {str(k): str(v) for k, v in document["units"].items()}
612
+ except (OSError, ValueError, KeyError, TypeError, AttributeError):
613
+ return {}
614
+
615
+
616
+ def _write_manifest(layout: InstallLayout, units: Mapping[str, str]) -> None:
617
+ (layout.generated_dir / MANIFEST_NAME).write_text(
618
+ json.dumps({"units": dict(units)}, indent=2, sort_keys=True), encoding="utf-8"
619
+ )
620
+
621
+
622
+ def _open_epic_ids() -> tuple[str, ...]:
623
+ """Which epics are open right now — FR-012's refusal, as a read.
624
+
625
+ Imported inside the function on purpose, and it stays that way when 042-US4
626
+ lands: the probe imports this module for the unit names, and it is the
627
+ process that runs when Temporal is the thing that died. A module-scope
628
+ Temporal import here would put a client on that path for the sake of a
629
+ capacity read the probe never performs. The query is the roadmap's own, so
630
+ a closed epic is narrowed away on the server rather than here.
631
+ """
632
+ import asyncio
633
+
634
+ from factory.activities.roadmap_activities import _OPEN_EPIC_STATUS
635
+ from factory.cli.nouns import _open_client
636
+ from factory.cli.status import EPIC_ID_PREFIX
637
+
638
+ async def listed() -> tuple[str, ...]:
639
+ client = await _open_client()
640
+ found = [
641
+ str(execution.id)
642
+ async for execution in client.list_workflows(
643
+ f'ExecutionStatus = "{_OPEN_EPIC_STATUS}"'
644
+ )
645
+ if str(execution.id).startswith(EPIC_ID_PREFIX)
646
+ ]
647
+ return tuple(sorted(found))
648
+
649
+ return asyncio.run(listed())
File without changes