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
factory/registry.py
ADDED
|
@@ -0,0 +1,583 @@
|
|
|
1
|
+
"""The engine's registry of managed repositories (034 US2, FR-006 … FR-008).
|
|
2
|
+
|
|
3
|
+
A committed `ergane.yaml` is what makes a repository managed. That fact lives
|
|
4
|
+
in the repo and travels with it — but committed facts cannot be *enumerated*
|
|
5
|
+
without an index of where the repos are, and every fleet-level surface (the
|
|
6
|
+
scheduler, `repo list`, per-repo ledger and findings dimensions) needs to
|
|
7
|
+
enumerate. This module is that index, and nothing more:
|
|
8
|
+
|
|
9
|
+
- It lives under the engine's state home, outside every repository, so joining
|
|
10
|
+
a repo never changes what the repo *is*.
|
|
11
|
+
- It is a **cache**. `rebuild()` re-derives it from the committed manifests it
|
|
12
|
+
is pointed at, prunes entries whose repos are gone, and leaves live entries
|
|
13
|
+
alone. Losing the file is an inconvenience, not amnesia — which is the whole
|
|
14
|
+
defence against a derived index quietly becoming a second authority.
|
|
15
|
+
- The one thing it holds that no repo holds is the **slug**: the token woven
|
|
16
|
+
into workflow IDs, ledger rows, findings and the memory-bank identity. Slugs
|
|
17
|
+
are unique across the registry (FR-007) because two repos sharing one would
|
|
18
|
+
alias each other's workflows. A rebuild seeded from a bare path therefore
|
|
19
|
+
proposes the directory name, which is what the interview defaults to; a repo
|
|
20
|
+
that declared something else is re-declared by re-running `ergane init`.
|
|
21
|
+
|
|
22
|
+
**Locking (FR-008).** Every mutation runs inside `factory.locking`'s exclusive
|
|
23
|
+
lock — the same lock `ergane install` takes over the control-plane config, for
|
|
24
|
+
the same reason and by the same mechanism, rather than a second convention one
|
|
25
|
+
epic later. Two properties of that helper matter here and are easy to lose if
|
|
26
|
+
this is ever rewritten:
|
|
27
|
+
|
|
28
|
+
1. The lock is a *sibling* `repos.json.lock`, not the registry file itself. So
|
|
29
|
+
the write below can replace `repos.json` atomically while the lock is held —
|
|
30
|
+
locking the target directly would leave the holder guarding an inode nobody
|
|
31
|
+
reads any more the instant the rename lands.
|
|
32
|
+
2. `flock` is released by the kernel when the holder dies, so a killed `ergane
|
|
33
|
+
init` never leaves a stale lock for an operator to clear by hand.
|
|
34
|
+
|
|
35
|
+
Writes go through a temporary file and `os.replace`, so a reader either sees the
|
|
36
|
+
whole previous registry or the whole next one. The rendering is canonical
|
|
37
|
+
(sorted keys, two-space indent), which is what lets SC-005 compare bytes.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
from __future__ import annotations
|
|
41
|
+
|
|
42
|
+
import dataclasses
|
|
43
|
+
import json
|
|
44
|
+
import os
|
|
45
|
+
import re
|
|
46
|
+
import unicodedata
|
|
47
|
+
from pathlib import Path
|
|
48
|
+
from typing import Any, Iterable, Mapping
|
|
49
|
+
|
|
50
|
+
from factory.controlplane.config import (
|
|
51
|
+
ControlPlaneConfigError,
|
|
52
|
+
load_controlplane_config,
|
|
53
|
+
)
|
|
54
|
+
from factory.env import (
|
|
55
|
+
ERGANE_STATE_HOME_ENV,
|
|
56
|
+
FACTORY_STATE_HOME_ENV,
|
|
57
|
+
resolve_env_path,
|
|
58
|
+
)
|
|
59
|
+
from factory.locking import exclusive_lock
|
|
60
|
+
from factory.verify.factory_yaml import (
|
|
61
|
+
MANIFEST_NAME,
|
|
62
|
+
FactoryConfigError,
|
|
63
|
+
load_factory_config,
|
|
64
|
+
resolve_manifest_path,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
#: Where the registry sits under the state home.
|
|
68
|
+
DEFAULT_REGISTRY_REL = Path("ergane") / "repos.json"
|
|
69
|
+
|
|
70
|
+
#: The registry document's schema version.
|
|
71
|
+
REGISTRY_VERSION = 1
|
|
72
|
+
|
|
73
|
+
#: Manifest statuses `repo list` renders (FR-011). Drift is reported, never
|
|
74
|
+
#: hidden by dropping the entry.
|
|
75
|
+
MANIFEST_VALID = "valid"
|
|
76
|
+
MANIFEST_INVALID = "invalid"
|
|
77
|
+
MANIFEST_MISSING = "missing"
|
|
78
|
+
|
|
79
|
+
#: How long a mutation waits for another writer before refusing.
|
|
80
|
+
DEFAULT_LOCK_TIMEOUT_S = 30.0
|
|
81
|
+
|
|
82
|
+
#: The memory bank identity is derived from the slug, never declared separately:
|
|
83
|
+
#: two names for one repo is how a bank and a ledger start disagreeing.
|
|
84
|
+
MEMORY_BANK_PREFIX = "ergane-"
|
|
85
|
+
|
|
86
|
+
#: What a slug may be. It ends up in workflow IDs and search attributes, so it
|
|
87
|
+
#: is deliberately narrow: lowercase, starting alphanumeric, no spaces.
|
|
88
|
+
_SLUG_RE = re.compile(r"^[a-z0-9][a-z0-9._-]*$")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
# ---------------------------------------------------------------------------
|
|
92
|
+
# Refusals
|
|
93
|
+
# ---------------------------------------------------------------------------
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class RegistryError(Exception):
|
|
97
|
+
"""Something an operator can fix, raised by the registry rather than the CLI.
|
|
98
|
+
|
|
99
|
+
The registry is a library: the CLI boundary turns these into `OperatorError`
|
|
100
|
+
so that `ergane init` and `ergane repo …` render one refusal grammar.
|
|
101
|
+
"""
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class SlugCollision(RegistryError):
|
|
105
|
+
"""Two repos claimed one slug; the holder is named so the loser can move."""
|
|
106
|
+
|
|
107
|
+
def __init__(self, slug: str, holder: Path, *, claimant: Path | None = None) -> None:
|
|
108
|
+
self.slug = slug
|
|
109
|
+
self.holder = Path(holder)
|
|
110
|
+
self.claimant = Path(claimant) if claimant is not None else None
|
|
111
|
+
message = (
|
|
112
|
+
f"slug '{slug}' is already registered to {self.holder}; "
|
|
113
|
+
"slugs are unique because they name the repo in every workflow ID, "
|
|
114
|
+
"so declare a different slug for this repository"
|
|
115
|
+
)
|
|
116
|
+
super().__init__(message)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class RegistryCorrupt(RegistryError):
|
|
120
|
+
"""The cache cannot be read, and rebuilding it is the safe answer."""
|
|
121
|
+
|
|
122
|
+
def __init__(self, path: Path, problem: str) -> None:
|
|
123
|
+
self.path = Path(path)
|
|
124
|
+
self.problem = problem
|
|
125
|
+
super().__init__(
|
|
126
|
+
f"{self.path} is not a readable registry ({problem}); it is a cache — "
|
|
127
|
+
"re-derive it with `ergane repo rebuild <repo path> …`"
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class NotAManagedRepo(RegistryError):
|
|
132
|
+
"""A path offered to `rebuild` carries no committed manifest."""
|
|
133
|
+
|
|
134
|
+
def __init__(self, path: Path) -> None:
|
|
135
|
+
self.path = Path(path)
|
|
136
|
+
super().__init__(
|
|
137
|
+
f"{self.path} declares no {MANIFEST_NAME}; the committed manifest is "
|
|
138
|
+
"what makes a repository managed — run `ergane init` there first"
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class InvalidSlug(RegistryError):
|
|
143
|
+
"""A slug that cannot be woven into a workflow ID, and what to use instead."""
|
|
144
|
+
|
|
145
|
+
def __init__(self, slug: str) -> None:
|
|
146
|
+
self.slug = slug
|
|
147
|
+
self.proposal = normalize_slug(slug)
|
|
148
|
+
super().__init__(
|
|
149
|
+
f"slug {slug!r} is not usable as a namespace token; "
|
|
150
|
+
f"use {self.proposal!r} (lowercase, no spaces, starting alphanumeric)"
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
# ---------------------------------------------------------------------------
|
|
155
|
+
# Where the registry lives
|
|
156
|
+
# ---------------------------------------------------------------------------
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def resolve_state_home() -> Path:
|
|
160
|
+
"""Return the engine's state home: env override, then XDG, then `~`.
|
|
161
|
+
|
|
162
|
+
Follows the 040 rename convention through `resolve_env_path` — the modern
|
|
163
|
+
`ERGANE_STATE_HOME` wins, the legacy `FACTORY_STATE_HOME` is honored with one
|
|
164
|
+
deprecation per process — rather than reading `os.environ` directly, so this
|
|
165
|
+
resolver and 033's config resolver cannot drift apart (034 plan, trap 4).
|
|
166
|
+
"""
|
|
167
|
+
return resolve_env_path(
|
|
168
|
+
ERGANE_STATE_HOME_ENV,
|
|
169
|
+
FACTORY_STATE_HOME_ENV,
|
|
170
|
+
_xdg_state_home(),
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _xdg_state_home() -> Path:
|
|
175
|
+
xdg = os.environ.get("XDG_STATE_HOME")
|
|
176
|
+
if xdg:
|
|
177
|
+
return Path(xdg)
|
|
178
|
+
return Path.home() / ".local" / "state"
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def resolve_registry_path() -> Path:
|
|
182
|
+
"""Return the registry file's path under the state home."""
|
|
183
|
+
return resolve_state_home() / DEFAULT_REGISTRY_REL
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
# ---------------------------------------------------------------------------
|
|
187
|
+
# Slugs
|
|
188
|
+
# ---------------------------------------------------------------------------
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def is_valid_slug(slug: str) -> bool:
|
|
192
|
+
"""True when `slug` is usable as a namespace token."""
|
|
193
|
+
return bool(_SLUG_RE.match(slug))
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def normalize_slug(text: str) -> str:
|
|
197
|
+
"""Propose a valid slug for `text` — a directory name, usually.
|
|
198
|
+
|
|
199
|
+
Proposed, never applied silently: D-009 governs the interview, so the
|
|
200
|
+
operator confirms this the same way they confirm every other declaration.
|
|
201
|
+
"""
|
|
202
|
+
folded = unicodedata.normalize("NFKD", text)
|
|
203
|
+
ascii_only = folded.encode("ascii", "ignore").decode("ascii").lower()
|
|
204
|
+
cleaned = re.sub(r"[^a-z0-9._-]+", "-", ascii_only)
|
|
205
|
+
cleaned = re.sub(r"-{2,}", "-", cleaned).strip("-._")
|
|
206
|
+
return cleaned or "repo"
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def derive_memory_bank(slug: str) -> str:
|
|
210
|
+
"""The repo's memory-bank identity, derived from its slug (FR-006, AS4)."""
|
|
211
|
+
return f"{MEMORY_BANK_PREFIX}{slug}"
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def declared_memory_backend() -> str | None:
|
|
215
|
+
"""The control plane's memory backend, or None when none is declared.
|
|
216
|
+
|
|
217
|
+
Read through 033's typed control-plane config rather than a second TOML
|
|
218
|
+
reader. A host with no control plane installed — or one whose config the
|
|
219
|
+
parser refuses — simply declares no backend here; registering a repo must
|
|
220
|
+
not depend on the engine being fully provisioned (FR-017's spirit).
|
|
221
|
+
"""
|
|
222
|
+
try:
|
|
223
|
+
config = load_controlplane_config()
|
|
224
|
+
except ControlPlaneConfigError:
|
|
225
|
+
return None
|
|
226
|
+
backend = config.memory.backend
|
|
227
|
+
return None if backend == "none" else backend
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def derive_scopes(slug: str) -> dict[str, str]:
|
|
231
|
+
"""Scopes recorded on a registry entry, all derived from the slug."""
|
|
232
|
+
backend = declared_memory_backend()
|
|
233
|
+
if backend is None:
|
|
234
|
+
return {}
|
|
235
|
+
return {"memory_backend": backend, "memory_bank": derive_memory_bank(slug)}
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
# ---------------------------------------------------------------------------
|
|
239
|
+
# The entries
|
|
240
|
+
# ---------------------------------------------------------------------------
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
@dataclasses.dataclass(frozen=True)
|
|
244
|
+
class RegistryEntry:
|
|
245
|
+
"""One managed repo: its slug, where it is, and what was derived from both."""
|
|
246
|
+
|
|
247
|
+
slug: str
|
|
248
|
+
path: Path
|
|
249
|
+
manifest: Path
|
|
250
|
+
scopes: Mapping[str, str] = dataclasses.field(default_factory=dict)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
@dataclasses.dataclass(frozen=True)
|
|
254
|
+
class Registry:
|
|
255
|
+
"""A snapshot of the cache, read at one moment."""
|
|
256
|
+
|
|
257
|
+
path: Path
|
|
258
|
+
entries: tuple[RegistryEntry, ...]
|
|
259
|
+
|
|
260
|
+
def get(self, slug: str) -> RegistryEntry | None:
|
|
261
|
+
for entry in self.entries:
|
|
262
|
+
if entry.slug == slug:
|
|
263
|
+
return entry
|
|
264
|
+
return None
|
|
265
|
+
|
|
266
|
+
def for_path(self, repo_path: str | Path) -> RegistryEntry | None:
|
|
267
|
+
target = Path(repo_path).resolve()
|
|
268
|
+
for entry in self.entries:
|
|
269
|
+
if entry.path == target:
|
|
270
|
+
return entry
|
|
271
|
+
return None
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
@dataclasses.dataclass(frozen=True)
|
|
275
|
+
class Registration:
|
|
276
|
+
"""What `register` did, so the caller can report it honestly."""
|
|
277
|
+
|
|
278
|
+
entry: RegistryEntry
|
|
279
|
+
changed: bool
|
|
280
|
+
previous_slug: str | None = None
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
@dataclasses.dataclass(frozen=True)
|
|
284
|
+
class RebuildResult:
|
|
285
|
+
"""What `rebuild` did: what it took in, what it dropped, what remains."""
|
|
286
|
+
|
|
287
|
+
adopted: tuple[RegistryEntry, ...]
|
|
288
|
+
pruned: tuple[tuple[str, Path], ...]
|
|
289
|
+
kept: tuple[RegistryEntry, ...]
|
|
290
|
+
recovered: bool = False
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
# ---------------------------------------------------------------------------
|
|
294
|
+
# Reading and writing the document
|
|
295
|
+
# ---------------------------------------------------------------------------
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _blank_document() -> dict[str, Any]:
|
|
299
|
+
return {"version": REGISTRY_VERSION, "repos": {}}
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _read_document(path: Path) -> dict[str, Any]:
|
|
303
|
+
"""Return the registry document at `path`, or a blank one when absent."""
|
|
304
|
+
try:
|
|
305
|
+
raw = path.read_text(encoding="utf-8")
|
|
306
|
+
except FileNotFoundError:
|
|
307
|
+
return _blank_document()
|
|
308
|
+
except OSError as error:
|
|
309
|
+
raise RegistryCorrupt(path, error.strerror or str(error)) from None
|
|
310
|
+
except UnicodeDecodeError as error:
|
|
311
|
+
raise RegistryCorrupt(path, f"is not valid UTF-8 ({error.reason})") from None
|
|
312
|
+
|
|
313
|
+
if not raw.strip():
|
|
314
|
+
return _blank_document()
|
|
315
|
+
|
|
316
|
+
try:
|
|
317
|
+
document = json.loads(raw)
|
|
318
|
+
except json.JSONDecodeError as error:
|
|
319
|
+
raise RegistryCorrupt(path, f"is not parseable JSON: {error}") from None
|
|
320
|
+
|
|
321
|
+
if not isinstance(document, dict) or not isinstance(document.get("repos"), dict):
|
|
322
|
+
raise RegistryCorrupt(path, "does not hold a `repos` mapping")
|
|
323
|
+
return document
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _entries_of(document: Mapping[str, Any], path: Path) -> tuple[RegistryEntry, ...]:
|
|
327
|
+
entries: list[RegistryEntry] = []
|
|
328
|
+
for slug in sorted(document["repos"]):
|
|
329
|
+
record = document["repos"][slug]
|
|
330
|
+
if not isinstance(record, Mapping) or "path" not in record:
|
|
331
|
+
raise RegistryCorrupt(path, f"entry {slug!r} declares no `path`")
|
|
332
|
+
repo_path = Path(str(record["path"]))
|
|
333
|
+
manifest = Path(str(record.get("manifest") or (repo_path / MANIFEST_NAME)))
|
|
334
|
+
scopes = record.get("scopes") or {}
|
|
335
|
+
if not isinstance(scopes, Mapping):
|
|
336
|
+
raise RegistryCorrupt(path, f"entry {slug!r} has non-mapping `scopes`")
|
|
337
|
+
entries.append(
|
|
338
|
+
RegistryEntry(
|
|
339
|
+
slug=slug,
|
|
340
|
+
path=repo_path,
|
|
341
|
+
manifest=manifest,
|
|
342
|
+
scopes={str(key): str(value) for key, value in scopes.items()},
|
|
343
|
+
)
|
|
344
|
+
)
|
|
345
|
+
return tuple(entries)
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def render_registry(document: Mapping[str, Any]) -> str:
|
|
349
|
+
"""Render the document canonically, so two equal registries are equal bytes."""
|
|
350
|
+
return json.dumps(document, indent=2, sort_keys=True) + "\n"
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def _write_document(path: Path, document: Mapping[str, Any]) -> None:
|
|
354
|
+
"""Replace the registry atomically; a reader never sees a half-written file."""
|
|
355
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
356
|
+
temporary = path.with_name(path.name + ".tmp")
|
|
357
|
+
temporary.write_text(render_registry(document), encoding="utf-8")
|
|
358
|
+
temporary.chmod(0o600)
|
|
359
|
+
os.replace(temporary, path)
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def _record_for(repo: Path, scopes: Mapping[str, str]) -> dict[str, Any]:
|
|
363
|
+
manifest, _ = resolve_manifest_path(repo)
|
|
364
|
+
record: dict[str, Any] = {"path": str(repo), "manifest": str(manifest)}
|
|
365
|
+
if scopes:
|
|
366
|
+
record["scopes"] = dict(scopes)
|
|
367
|
+
return record
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
# ---------------------------------------------------------------------------
|
|
371
|
+
# The operations
|
|
372
|
+
# ---------------------------------------------------------------------------
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def load_registry(path: str | Path | None = None) -> Registry:
|
|
376
|
+
"""Read the registry. An absent file is an empty registry, not an error."""
|
|
377
|
+
registry_path = Path(path) if path is not None else resolve_registry_path()
|
|
378
|
+
document = _read_document(registry_path)
|
|
379
|
+
return Registry(path=registry_path, entries=_entries_of(document, registry_path))
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def manifest_status(entry: RegistryEntry) -> str:
|
|
383
|
+
"""Judge the entry's manifest *now*, against the repo rather than the cache.
|
|
384
|
+
|
|
385
|
+
The committed manifest is the authority, so the status is resolved live: a
|
|
386
|
+
repo that has since renamed `factory.yaml` to `ergane.yaml` reads valid, and
|
|
387
|
+
one whose manifest was deleted reads `missing` rather than vanishing from the
|
|
388
|
+
listing (AS5).
|
|
389
|
+
"""
|
|
390
|
+
manifest, _ = resolve_manifest_path(entry.path)
|
|
391
|
+
if not manifest.is_file():
|
|
392
|
+
return MANIFEST_MISSING
|
|
393
|
+
try:
|
|
394
|
+
load_factory_config(manifest)
|
|
395
|
+
except FactoryConfigError:
|
|
396
|
+
return MANIFEST_INVALID
|
|
397
|
+
return MANIFEST_VALID
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def register(
|
|
401
|
+
slug: str,
|
|
402
|
+
repo_path: str | Path,
|
|
403
|
+
*,
|
|
404
|
+
path: str | Path | None = None,
|
|
405
|
+
timeout_s: float = DEFAULT_LOCK_TIMEOUT_S,
|
|
406
|
+
scopes: Mapping[str, str] | None = None,
|
|
407
|
+
) -> Registration:
|
|
408
|
+
"""Record `repo_path` under `slug`, refusing a collision (FR-007, FR-008).
|
|
409
|
+
|
|
410
|
+
Idempotent for the repo that already holds the slug: re-running `ergane init`
|
|
411
|
+
with unchanged answers leaves the file byte-identical (SC-003). A repo
|
|
412
|
+
re-declared under a *new* slug moves — one repo has one slug, or the two
|
|
413
|
+
entries alias each other in every ID the slug appears in.
|
|
414
|
+
"""
|
|
415
|
+
registry_path = Path(path) if path is not None else resolve_registry_path()
|
|
416
|
+
repo = Path(repo_path).resolve()
|
|
417
|
+
|
|
418
|
+
if not is_valid_slug(slug):
|
|
419
|
+
raise InvalidSlug(slug)
|
|
420
|
+
|
|
421
|
+
entry_scopes = dict(scopes) if scopes is not None else derive_scopes(slug)
|
|
422
|
+
record = _record_for(repo, entry_scopes)
|
|
423
|
+
|
|
424
|
+
with exclusive_lock(registry_path, timeout_s=timeout_s):
|
|
425
|
+
document = _read_document(registry_path)
|
|
426
|
+
repos = document["repos"]
|
|
427
|
+
|
|
428
|
+
holder = repos.get(slug)
|
|
429
|
+
if holder is not None and Path(str(holder["path"])) != repo:
|
|
430
|
+
raise SlugCollision(slug, Path(str(holder["path"])), claimant=repo)
|
|
431
|
+
|
|
432
|
+
previous_slug: str | None = None
|
|
433
|
+
for other_slug, other in list(repos.items()):
|
|
434
|
+
if other_slug != slug and Path(str(other["path"])) == repo:
|
|
435
|
+
previous_slug = other_slug
|
|
436
|
+
del repos[other_slug]
|
|
437
|
+
|
|
438
|
+
changed = repos.get(slug) != record or previous_slug is not None
|
|
439
|
+
repos[slug] = record
|
|
440
|
+
if changed or not registry_path.exists():
|
|
441
|
+
_write_document(registry_path, document)
|
|
442
|
+
|
|
443
|
+
entries = _entries_of(document, registry_path)
|
|
444
|
+
return Registration(
|
|
445
|
+
entry=next(entry for entry in entries if entry.slug == slug),
|
|
446
|
+
changed=changed,
|
|
447
|
+
previous_slug=previous_slug,
|
|
448
|
+
)
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def forget(
|
|
452
|
+
slug: str,
|
|
453
|
+
*,
|
|
454
|
+
path: str | Path | None = None,
|
|
455
|
+
timeout_s: float = DEFAULT_LOCK_TIMEOUT_S,
|
|
456
|
+
) -> RegistryEntry | None:
|
|
457
|
+
"""Remove one entry, returning it, or `None` when no entry held that slug.
|
|
458
|
+
|
|
459
|
+
Only the entry: the manifest, the gitignore line and `.ergane/` belong to
|
|
460
|
+
the repository, and removing them is the operator's own git work (FR-011).
|
|
461
|
+
Held under the same exclusive lock every other mutation takes, so a forget
|
|
462
|
+
racing an init cannot interleave with it.
|
|
463
|
+
"""
|
|
464
|
+
registry_path = Path(path) if path is not None else resolve_registry_path()
|
|
465
|
+
with exclusive_lock(registry_path, timeout_s=timeout_s):
|
|
466
|
+
document = _read_document(registry_path)
|
|
467
|
+
repos = document["repos"]
|
|
468
|
+
if slug not in repos:
|
|
469
|
+
return None
|
|
470
|
+
entries = _entries_of(document, registry_path)
|
|
471
|
+
removed = None
|
|
472
|
+
for entry in entries:
|
|
473
|
+
if entry.slug == slug:
|
|
474
|
+
removed = entry
|
|
475
|
+
del repos[slug]
|
|
476
|
+
_write_document(registry_path, document)
|
|
477
|
+
return removed
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def rebuild(
|
|
481
|
+
seed_paths: Iterable[str | Path] = (),
|
|
482
|
+
*,
|
|
483
|
+
path: str | Path | None = None,
|
|
484
|
+
timeout_s: float = DEFAULT_LOCK_TIMEOUT_S,
|
|
485
|
+
) -> RebuildResult:
|
|
486
|
+
"""Re-derive the cache: adopt the seeds, prune the dead, touch nothing live.
|
|
487
|
+
|
|
488
|
+
Rebuilding must always be the safe answer (FR-006), which has two
|
|
489
|
+
consequences worth stating. A seed that carries no committed manifest is
|
|
490
|
+
refused *before* the lock is taken, so a refusal changes nothing. And a
|
|
491
|
+
registry that cannot be parsed at all is rebuilt from the seeds rather than
|
|
492
|
+
refused — refusing there would leave the operator with a corrupt cache and
|
|
493
|
+
no verb that could repair it.
|
|
494
|
+
"""
|
|
495
|
+
registry_path = Path(path) if path is not None else resolve_registry_path()
|
|
496
|
+
|
|
497
|
+
seeds: list[Path] = []
|
|
498
|
+
for seed in seed_paths:
|
|
499
|
+
resolved = Path(seed).resolve()
|
|
500
|
+
manifest, _ = resolve_manifest_path(resolved)
|
|
501
|
+
if not manifest.is_file():
|
|
502
|
+
raise NotAManagedRepo(resolved)
|
|
503
|
+
seeds.append(resolved)
|
|
504
|
+
|
|
505
|
+
with exclusive_lock(registry_path, timeout_s=timeout_s):
|
|
506
|
+
recovered = False
|
|
507
|
+
try:
|
|
508
|
+
document = _read_document(registry_path)
|
|
509
|
+
_entries_of(document, registry_path)
|
|
510
|
+
except RegistryCorrupt:
|
|
511
|
+
if not seeds:
|
|
512
|
+
raise
|
|
513
|
+
document = _blank_document()
|
|
514
|
+
recovered = True
|
|
515
|
+
|
|
516
|
+
repos = document["repos"]
|
|
517
|
+
known_paths = {Path(str(record["path"])): slug for slug, record in repos.items()}
|
|
518
|
+
|
|
519
|
+
adopted: list[str] = []
|
|
520
|
+
for seed in seeds:
|
|
521
|
+
if seed in known_paths:
|
|
522
|
+
# Already known: it is a live entry, and live entries are never
|
|
523
|
+
# touched — including their declared slug, which the repo itself
|
|
524
|
+
# does not carry.
|
|
525
|
+
continue
|
|
526
|
+
slug = normalize_slug(seed.name)
|
|
527
|
+
holder = repos.get(slug)
|
|
528
|
+
if holder is not None:
|
|
529
|
+
raise SlugCollision(slug, Path(str(holder["path"])), claimant=seed)
|
|
530
|
+
repos[slug] = _record_for(seed, derive_scopes(slug))
|
|
531
|
+
known_paths[seed] = slug
|
|
532
|
+
adopted.append(slug)
|
|
533
|
+
|
|
534
|
+
pruned: list[tuple[str, Path]] = []
|
|
535
|
+
for slug in sorted(repos):
|
|
536
|
+
repo = Path(str(repos[slug]["path"]))
|
|
537
|
+
if not repo.is_dir():
|
|
538
|
+
pruned.append((slug, repo))
|
|
539
|
+
for slug, _repo in pruned:
|
|
540
|
+
del repos[slug]
|
|
541
|
+
|
|
542
|
+
if adopted or pruned or recovered or not registry_path.exists():
|
|
543
|
+
_write_document(registry_path, document)
|
|
544
|
+
entries = _entries_of(document, registry_path)
|
|
545
|
+
|
|
546
|
+
return RebuildResult(
|
|
547
|
+
adopted=tuple(entry for entry in entries if entry.slug in set(adopted)),
|
|
548
|
+
pruned=tuple(pruned),
|
|
549
|
+
kept=entries,
|
|
550
|
+
recovered=recovered,
|
|
551
|
+
)
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
__all__ = [
|
|
555
|
+
"DEFAULT_LOCK_TIMEOUT_S",
|
|
556
|
+
"ERGANE_STATE_HOME_ENV",
|
|
557
|
+
"FACTORY_STATE_HOME_ENV",
|
|
558
|
+
"MANIFEST_INVALID",
|
|
559
|
+
"MANIFEST_MISSING",
|
|
560
|
+
"MANIFEST_VALID",
|
|
561
|
+
"InvalidSlug",
|
|
562
|
+
"NotAManagedRepo",
|
|
563
|
+
"RebuildResult",
|
|
564
|
+
"Registration",
|
|
565
|
+
"Registry",
|
|
566
|
+
"RegistryCorrupt",
|
|
567
|
+
"RegistryEntry",
|
|
568
|
+
"RegistryError",
|
|
569
|
+
"SlugCollision",
|
|
570
|
+
"declared_memory_backend",
|
|
571
|
+
"derive_memory_bank",
|
|
572
|
+
"derive_scopes",
|
|
573
|
+
"forget",
|
|
574
|
+
"is_valid_slug",
|
|
575
|
+
"load_registry",
|
|
576
|
+
"manifest_status",
|
|
577
|
+
"normalize_slug",
|
|
578
|
+
"rebuild",
|
|
579
|
+
"register",
|
|
580
|
+
"render_registry",
|
|
581
|
+
"resolve_registry_path",
|
|
582
|
+
"resolve_state_home",
|
|
583
|
+
]
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""The roadmap: spec frontmatter intent, computed readiness, and the render.
|
|
2
|
+
|
|
3
|
+
This package is the factory's second workflow surface's data layer (US1): a
|
|
4
|
+
pure reader turns a `specs/` corpus into a graph of `SpecEntry`s — each with a
|
|
5
|
+
declared intent state and `depends_on_landed` edges — and readiness computation
|
|
6
|
+
distinguishes attested from observed satisfaction. The workflow and CLI live
|
|
7
|
+
in sibling modules; the grammar and the graph are here.
|
|
8
|
+
"""
|