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 +21 -0
- vinyasar/__main__.py +3 -0
- vinyasar/cli.py +181 -0
- vinyasar/config.py +356 -0
- vinyasar/controller.py +287 -0
- vinyasar/diagnostics.py +121 -0
- vinyasar/errors.py +43 -0
- vinyasar/events.py +39 -0
- vinyasar/mcp_server.py +386 -0
- vinyasar/models.py +220 -0
- vinyasar/path_diagnostics.py +107 -0
- vinyasar/paths.py +135 -0
- vinyasar/playbooks.py +65 -0
- vinyasar/policy.py +77 -0
- vinyasar/profile.schema.json +289 -0
- vinyasar/reporting.py +166 -0
- vinyasar/run.schema.json +403 -0
- vinyasar/run.v1.schema.json +381 -0
- vinyasar/state.py +202 -0
- vinyasar/storage.py +189 -0
- vinyasar/util.py +86 -0
- vinyasar/yasarda_adapter.py +176 -0
- vinyasar-0.2.2.dist-info/METADATA +114 -0
- vinyasar-0.2.2.dist-info/RECORD +27 -0
- vinyasar-0.2.2.dist-info/WHEEL +5 -0
- vinyasar-0.2.2.dist-info/entry_points.txt +3 -0
- vinyasar-0.2.2.dist-info/top_level.txt +1 -0
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
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
|
+
}
|