vinyasar 0.2.2__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.
vinyasar/__init__.py ADDED
@@ -0,0 +1,21 @@
1
+ """Vinyasar: bounded self-healing supervision for Yasarda."""
2
+
3
+ __version__ = "0.2.2"
4
+
5
+ from .config import Profile, generate_profile, load_profile
6
+ from .path_diagnostics import doctor_paths
7
+ from .controller import Controller
8
+ from .models import Effect, Issue, RunRecord
9
+ from .yasarda_adapter import YasardaTarget
10
+
11
+ __all__ = [
12
+ "Controller",
13
+ "Effect",
14
+ "Issue",
15
+ "Profile",
16
+ "RunRecord",
17
+ "YasardaTarget",
18
+ "doctor_paths",
19
+ "generate_profile",
20
+ "load_profile",
21
+ ]
vinyasar/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
vinyasar/cli.py ADDED
@@ -0,0 +1,181 @@
1
+ """Command-line interface for profile generation and one-shot reconciliation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+ import uuid
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ from yasarda.errors import YasardaError
13
+
14
+ from . import __version__
15
+ from .config import (
16
+ generate_profile,
17
+ load_profile,
18
+ profile_from_document,
19
+ run_schema_document,
20
+ schema_document,
21
+ )
22
+ from .controller import Controller
23
+ from .paths import new_profile_destination
24
+ from .path_diagnostics import doctor_paths
25
+ from .errors import VinyasarError
26
+ from .util import write_new
27
+
28
+
29
+ def emit(value: Any) -> None:
30
+ print(json.dumps(value, indent=2, ensure_ascii=False))
31
+
32
+
33
+ def build_parser() -> argparse.ArgumentParser:
34
+ parser = argparse.ArgumentParser(
35
+ prog="vinyasar",
36
+ description="Policy-driven, evidence-bound self-healing supervision for Yasarda.",
37
+ )
38
+ parser.add_argument(
39
+ "--version", action="version", version=f"vinyasar {__version__}"
40
+ )
41
+ commands = parser.add_subparsers(dest="command", required=True)
42
+ schema = commands.add_parser("schema", help="print a packaged JSON Schema")
43
+ schema.add_argument(
44
+ "name", nargs="?", choices=("profile", "run"), default="profile"
45
+ )
46
+
47
+ init = commands.add_parser(
48
+ "init", help="generate a profile from a read-only setup inspection"
49
+ )
50
+ init.add_argument("--repo", required=True)
51
+ init.add_argument("--out", required=True)
52
+ init.add_argument("--name")
53
+ init.add_argument("--state-dir")
54
+ init.add_argument("--reports-dir")
55
+ init.add_argument("--yasarda-state-dir")
56
+ init.add_argument(
57
+ "--schedule", help="scheduler-owned expression recorded in the profile"
58
+ )
59
+
60
+ validate = commands.add_parser("validate", help="validate and summarize a profile")
61
+ validate.add_argument("config")
62
+
63
+ doctor = commands.add_parser("doctor", help="read-only configuration diagnostics")
64
+ diagnoses = doctor.add_subparsers(dest="diagnostic", required=True)
65
+ paths = diagnoses.add_parser("paths", help="show supplied/canonical paths without writing")
66
+ paths.add_argument("config")
67
+
68
+ for name in ("inspect", "run"):
69
+ run = commands.add_parser(name)
70
+ run.add_argument("config")
71
+ run.add_argument("--trigger", default="manual")
72
+ run.add_argument("--trigger-id")
73
+ run.add_argument("--approve", action="append", default=[])
74
+ adopt = commands.add_parser("adopt-ledger", help="host-only conservative import of a legacy retry ledger")
75
+ adopt.add_argument("config")
76
+ adopt.add_argument("--legacy-revision", required=True)
77
+ adopt.add_argument("--yes", action="store_true", required=True)
78
+ return parser
79
+
80
+
81
+ def _authority(profile: Any) -> list[dict[str, Any]]:
82
+ return [
83
+ {
84
+ "issue_codes": sorted(rule.issue_codes),
85
+ "effect": rule.effect.value,
86
+ "capabilities": sorted(rule.capabilities),
87
+ "playbooks": sorted(rule.playbooks),
88
+ }
89
+ for rule in profile.rules
90
+ ]
91
+
92
+
93
+ def _summary(profile: Any) -> dict[str, Any]:
94
+ return {
95
+ "valid": True,
96
+ "name": profile.name,
97
+ "revision": profile.revision,
98
+ "repository": str(profile.repository),
99
+ "state_directory": str(profile.state_directory),
100
+ "report_directory": str(profile.reports.directory),
101
+ "triggers": [
102
+ {
103
+ "type": item.type,
104
+ **({"expression": item.expression} if item.expression else {}),
105
+ }
106
+ for item in profile.triggers
107
+ ],
108
+ "authority": _authority(profile),
109
+ "default_effect": profile.default_effect.value,
110
+ }
111
+
112
+
113
+ def main(argv: list[str] | None = None) -> int:
114
+ args = build_parser().parse_args(argv)
115
+ try:
116
+ if args.command == "schema":
117
+ emit(run_schema_document() if args.name == "run" else schema_document())
118
+ return 0
119
+ if args.command == "init":
120
+ output = new_profile_destination(args.out)
121
+ if not output.parent.is_dir():
122
+ raise ValueError("profile output directory must already exist")
123
+ document = generate_profile(
124
+ args.repo,
125
+ name=args.name,
126
+ state_directory=args.state_dir,
127
+ report_directory=args.reports_dir,
128
+ yasarda_state_directory=args.yasarda_state_dir,
129
+ schedule=args.schedule,
130
+ )
131
+ profile = profile_from_document(document, base_directory=output.parent)
132
+ payload = (
133
+ json.dumps(profile.document, indent=2, ensure_ascii=False) + "\n"
134
+ ).encode("utf-8")
135
+ write_new(output, payload)
136
+ emit({"created": str(output), **_summary(profile)})
137
+ return 0
138
+ if args.command == "doctor":
139
+ result = doctor_paths(args.config)
140
+ emit(result)
141
+ return 0 if result["ok"] else 2
142
+ profile = load_profile(args.config)
143
+ if args.command == "validate":
144
+ emit(_summary(profile))
145
+ return 0
146
+ if args.command == "adopt-ledger":
147
+ controller = Controller(profile)
148
+ with controller.runs.lock(timeout=30):
149
+ emit(controller.state.adopt_legacy(args.legacy_revision))
150
+ return 0
151
+ trigger_id = args.trigger_id or str(uuid.uuid4())
152
+ record = Controller(profile).run(
153
+ trigger_type=args.trigger,
154
+ trigger_id=trigger_id,
155
+ approvals=args.approve,
156
+ dry_run=args.command == "inspect",
157
+ )
158
+ emit(record.to_dict())
159
+ if record.outcome in {"healthy", "repaired", "dry_run"}:
160
+ return 0
161
+ if record.outcome == "approval_required":
162
+ return 3
163
+ return 2
164
+ except (VinyasarError, YasardaError) as exc:
165
+ print(json.dumps({"error": exc.to_dict()}, ensure_ascii=False), file=sys.stderr)
166
+ return 2
167
+ except (OSError, ValueError) as exc:
168
+ print(
169
+ json.dumps(
170
+ {"error": {"code": "invalid_request", "message": str(exc)}},
171
+ ensure_ascii=False,
172
+ ),
173
+ file=sys.stderr,
174
+ )
175
+ return 2
176
+ except KeyboardInterrupt:
177
+ return 130
178
+
179
+
180
+ if __name__ == "__main__":
181
+ raise SystemExit(main())
vinyasar/config.py ADDED
@@ -0,0 +1,356 @@
1
+ """Strict profile loading and environment-derived profile generation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import re
8
+ from dataclasses import dataclass
9
+ from importlib.resources import files
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ from jsonschema import Draft202012Validator
14
+
15
+ from .errors import ConfigurationError
16
+ from .models import Effect
17
+ from .paths import canonical_directory, pinned_path, read_profile_bytes
18
+ from .util import digest, path_is_within, strict_json_loads
19
+
20
+ API_VERSION = "vinyasar.yasarda.dev/v1alpha1"
21
+ PROFILE_KIND = "VinyasarProfile"
22
+ MAX_PROFILE_BYTES = 1024 * 1024
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class TriggerConfig:
27
+ type: str
28
+ expression: str | None = None
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class PolicyRule:
33
+ issue_codes: frozenset[str]
34
+ effect: Effect
35
+ capabilities: frozenset[str]
36
+ playbooks: frozenset[str]
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class Limits:
41
+ maximum_attempts: int
42
+ cooldown_seconds: int
43
+ maximum_actions: int
44
+ maximum_files: int
45
+ maximum_runtime_seconds: int
46
+ maximum_file_bytes: int = 8 * 1024 * 1024
47
+ maximum_total_bytes: int = 24 * 1024 * 1024
48
+
49
+
50
+ @dataclass(frozen=True)
51
+ class ReportConfig:
52
+ directory: Path
53
+ formats: tuple[str, ...]
54
+ retention_days: int
55
+ redact_source_content: bool
56
+
57
+
58
+ @dataclass(frozen=True)
59
+ class Profile:
60
+ name: str
61
+ description: str
62
+ repository: Path
63
+ yasarda_state_directory: Path
64
+ state_directory: Path
65
+ triggers: tuple[TriggerConfig, ...]
66
+ default_effect: Effect
67
+ rules: tuple[PolicyRule, ...]
68
+ limits: Limits
69
+ reports: ReportConfig
70
+ revision: str
71
+ document: dict[str, Any]
72
+
73
+ def supports_trigger(self, trigger_type: str) -> bool:
74
+ return any(trigger.type == trigger_type for trigger in self.triggers)
75
+
76
+
77
+ def schema_document() -> dict[str, Any]:
78
+ """Return the packaged Vinyasar profile schema."""
79
+ return strict_json_loads(
80
+ files("vinyasar").joinpath("profile.schema.json").read_text("utf-8")
81
+ )
82
+
83
+
84
+ def run_schema_document() -> dict[str, Any]:
85
+ """Return the packaged canonical run-record schema."""
86
+ return strict_json_loads(
87
+ files("vinyasar").joinpath("run.schema.json").read_text("utf-8")
88
+ )
89
+
90
+
91
+ def _load_document(path: Path) -> dict[str, Any]:
92
+ try:
93
+ payload = read_profile_bytes(path, MAX_PROFILE_BYTES)
94
+ except OSError as exc:
95
+ raise ConfigurationError(f"cannot read profile: {exc}") from exc
96
+ if len(payload) > MAX_PROFILE_BYTES:
97
+ raise ConfigurationError("profile exceeds the 1 MiB limit")
98
+ try:
99
+ text = payload.decode("utf-8")
100
+ except UnicodeDecodeError as exc:
101
+ raise ConfigurationError("profile must be UTF-8") from exc
102
+ if path.suffix.casefold() in {".yaml", ".yml"}:
103
+ try:
104
+ import yaml # type: ignore[import-not-found]
105
+ except ImportError as exc:
106
+ raise ConfigurationError(
107
+ "YAML support requires the vinyasar[yaml] extra"
108
+ ) from exc
109
+
110
+ class UniqueKeyLoader(yaml.SafeLoader):
111
+ pass
112
+
113
+ def construct_mapping(
114
+ loader: Any, node: Any, deep: bool = False
115
+ ) -> dict[Any, Any]:
116
+ loader.flatten_mapping(node)
117
+ result: dict[Any, Any] = {}
118
+ for key_node, value_node in node.value:
119
+ key = loader.construct_object(key_node, deep=deep)
120
+ if key in result:
121
+ raise ConfigurationError(f"duplicate YAML key: {key}")
122
+ result[key] = loader.construct_object(value_node, deep=deep)
123
+ return result
124
+
125
+ UniqueKeyLoader.add_constructor(
126
+ yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG,
127
+ construct_mapping,
128
+ )
129
+ try:
130
+ value = yaml.load(text, Loader=UniqueKeyLoader)
131
+ except ConfigurationError:
132
+ raise
133
+ except yaml.YAMLError as exc:
134
+ raise ConfigurationError(f"invalid YAML: {exc}") from exc
135
+ else:
136
+ value = strict_json_loads(text)
137
+ if not isinstance(value, dict):
138
+ raise ConfigurationError("profile root must be an object")
139
+ return value
140
+
141
+
142
+ def _validate_schema(document: dict[str, Any]) -> None:
143
+ validator = Draft202012Validator(schema_document())
144
+ errors = sorted(
145
+ validator.iter_errors(document), key=lambda item: list(item.absolute_path)
146
+ )
147
+ if errors:
148
+ first = errors[0]
149
+ location = "/".join(str(part) for part in first.absolute_path) or "<root>"
150
+ raise ConfigurationError(
151
+ f"profile schema violation at {location}: {first.message}",
152
+ details={"violations": len(errors)},
153
+ )
154
+
155
+
156
+ def _absolute(value: str, base: Path, *, strict: bool = False) -> Path:
157
+ return pinned_path(value, base=base, must_exist=strict)
158
+
159
+
160
+ def profile_from_document(document: dict[str, Any], *, base_directory: Path) -> Profile:
161
+ """Validate and normalize an already parsed profile document."""
162
+ _validate_schema(document)
163
+ spec = document["spec"]
164
+ target = spec["target"]
165
+ repository = _absolute(target["repository"], base_directory, strict=True)
166
+ if not repository.is_dir():
167
+ raise ConfigurationError(f"target repository is not a directory: {repository}")
168
+ yasarda_state_path = _absolute(target["yasarda_state_directory"], base_directory)
169
+ state_directory = _absolute(spec["state_directory"], base_directory)
170
+ report_directory = _absolute(spec["reports"]["directory"], base_directory)
171
+ for label, path in (
172
+ ("state_directory", state_directory),
173
+ ("reports.directory", report_directory),
174
+ ("target.yasarda_state_directory", yasarda_state_path),
175
+ ):
176
+ if path_is_within(path, repository):
177
+ raise ConfigurationError(f"{label} must be outside the target repository")
178
+
179
+ rules: list[PolicyRule] = []
180
+ claimed_codes: set[str] = set()
181
+ for raw in spec["policy"]["rules"]:
182
+ codes = frozenset(raw["issue_codes"])
183
+ duplicate = claimed_codes & codes
184
+ if duplicate:
185
+ raise ConfigurationError(
186
+ f"issue code appears in more than one policy rule: {min(duplicate)}"
187
+ )
188
+ claimed_codes.update(codes)
189
+ effect = Effect(raw["effect"])
190
+ capabilities = frozenset(raw["capabilities"])
191
+ playbooks = frozenset(raw["playbooks"])
192
+ if effect in {Effect.AUTO, Effect.APPROVAL} and (
193
+ not capabilities or not playbooks
194
+ ):
195
+ raise ConfigurationError(
196
+ f"{effect.value} rules require explicit capabilities and playbooks"
197
+ )
198
+ if effect in {Effect.OBSERVE, Effect.ESCALATE} and (capabilities or playbooks):
199
+ raise ConfigurationError(
200
+ f"{effect.value} rules cannot grant capabilities or playbooks"
201
+ )
202
+ rules.append(PolicyRule(codes, effect, capabilities, playbooks))
203
+
204
+ triggers = tuple(
205
+ TriggerConfig(item["type"], item.get("expression"))
206
+ for item in spec["automation"]["triggers"]
207
+ )
208
+ trigger_keys = [(item.type, item.expression) for item in triggers]
209
+ if len(trigger_keys) != len(set(trigger_keys)):
210
+ raise ConfigurationError("automation triggers must be unique")
211
+
212
+ normalized = json.loads(json.dumps(document))
213
+ normalized["spec"]["target"]["repository"] = str(repository)
214
+ normalized["spec"]["target"]["yasarda_state_directory"] = str(yasarda_state_path)
215
+ normalized["spec"]["state_directory"] = str(state_directory)
216
+ normalized["spec"]["reports"]["directory"] = str(report_directory)
217
+ limits = spec["limits"]
218
+ report = spec["reports"]
219
+ return Profile(
220
+ name=document["metadata"]["name"],
221
+ description=document["metadata"].get("description", ""),
222
+ repository=repository,
223
+ yasarda_state_directory=yasarda_state_path,
224
+ state_directory=state_directory,
225
+ triggers=triggers,
226
+ default_effect=Effect(spec["policy"]["default_effect"]),
227
+ rules=tuple(rules),
228
+ limits=Limits(
229
+ maximum_attempts=limits["maximum_attempts"],
230
+ cooldown_seconds=limits["cooldown_seconds"],
231
+ maximum_actions=limits["maximum_actions"],
232
+ maximum_files=limits["maximum_files"],
233
+ maximum_runtime_seconds=limits["maximum_runtime_seconds"],
234
+ maximum_file_bytes=limits.get("maximum_file_bytes", 8 * 1024 * 1024),
235
+ maximum_total_bytes=limits.get("maximum_total_bytes", 24 * 1024 * 1024),
236
+ ),
237
+ reports=ReportConfig(
238
+ directory=report_directory,
239
+ formats=tuple(report["formats"]),
240
+ retention_days=report["retention_days"],
241
+ redact_source_content=report["redact_source_content"],
242
+ ),
243
+ revision=digest(normalized),
244
+ document=normalized,
245
+ )
246
+
247
+
248
+ def load_profile(path: Path | str) -> Profile:
249
+ """Load, validate, normalize, and revision-pin a JSON or YAML profile."""
250
+ source = pinned_path(path, must_exist=True)
251
+ return profile_from_document(_load_document(source), base_directory=source.parent)
252
+
253
+
254
+ def _profile_name(repository: Path) -> str:
255
+ value = re.sub(r"[^a-z0-9._-]+", "-", repository.name.casefold()).strip("-._")
256
+ return (value or "repository")[:64].rstrip("-._")
257
+
258
+
259
+ def generate_profile(
260
+ repository: Path | str,
261
+ *,
262
+ name: str | None = None,
263
+ state_directory: Path | str | None = None,
264
+ report_directory: Path | str | None = None,
265
+ yasarda_state_directory: Path | str | None = None,
266
+ schedule: str | None = None,
267
+ ) -> dict[str, Any]:
268
+ """Resolve host setup paths and generate a profile without creating state.
269
+
270
+ Existing profiles are never rewritten; load_profile remains link-strict.
271
+ """
272
+ from .yasarda_adapter import probe_setup
273
+
274
+ root = canonical_directory(repository, must_exist=True)
275
+ if yasarda_state_directory is not None:
276
+ yasarda_state = canonical_directory(yasarda_state_directory)
277
+ elif os.environ.get("YASARDA_STATE_DIR"):
278
+ yasarda_state = canonical_directory(os.environ["YASARDA_STATE_DIR"])
279
+ else:
280
+ yasarda_state = canonical_directory(Path.home() / ".local" / "state" / "yasarda")
281
+ profile_name = name or _profile_name(root)
282
+ base_state = canonical_directory(state_directory if state_directory is not None
283
+ else Path.home() / ".local" / "state" / "vinyasar" / profile_name)
284
+ storage_issues = probe_setup(root, yasarda_state, base_state)
285
+ reports = (
286
+ canonical_directory(report_directory)
287
+ if report_directory is not None
288
+ else base_state / "reports"
289
+ )
290
+ triggers: list[dict[str, str]] = [
291
+ {"type": "manual"},
292
+ {"type": "on_startup"},
293
+ {"type": "after_failed_execution"},
294
+ ]
295
+ if schedule:
296
+ triggers.append({"type": "scheduled", "expression": schedule})
297
+ recovery_effect = (
298
+ "auto" if os.name == "posix" and not storage_issues else "escalate"
299
+ )
300
+ recovery_capabilities = ["yasarda.recover"] if recovery_effect == "auto" else []
301
+ recovery_playbooks = (
302
+ ["recover_interrupted_transaction"] if recovery_effect == "auto" else []
303
+ )
304
+ target: dict[str, str] = {
305
+ "repository": str(root),
306
+ "yasarda_state_directory": str(yasarda_state),
307
+ }
308
+ return {
309
+ "api_version": API_VERSION,
310
+ "kind": PROFILE_KIND,
311
+ "metadata": {
312
+ "name": profile_name,
313
+ "description": "Generated by Vinyasar from a read-only target inspection.",
314
+ },
315
+ "spec": {
316
+ "target": target,
317
+ "automation": {"triggers": triggers},
318
+ "policy": {
319
+ "default_effect": "escalate",
320
+ "rules": [
321
+ {
322
+ "issue_codes": ["INTERRUPTED_OPERATION"],
323
+ "effect": recovery_effect,
324
+ "capabilities": recovery_capabilities,
325
+ "playbooks": recovery_playbooks,
326
+ },
327
+ {
328
+ "issue_codes": ["DIRTY_WORKTREE"],
329
+ "effect": "observe",
330
+ "capabilities": [],
331
+ "playbooks": [],
332
+ },
333
+ {
334
+ "issue_codes": ["UNSUPPORTED_STORAGE", "INSPECTION_FAILED"],
335
+ "effect": "escalate",
336
+ "capabilities": [],
337
+ "playbooks": [],
338
+ },
339
+ ],
340
+ },
341
+ "limits": {
342
+ "maximum_attempts": 2,
343
+ "cooldown_seconds": 900,
344
+ "maximum_actions": 10,
345
+ "maximum_files": 100,
346
+ "maximum_runtime_seconds": 120,
347
+ },
348
+ "state_directory": str(base_state),
349
+ "reports": {
350
+ "directory": str(reports),
351
+ "formats": ["json", "markdown"],
352
+ "retention_days": 30,
353
+ "redact_source_content": True,
354
+ },
355
+ },
356
+ }