@senpi/runtime 3.0.9-dev.20260710204324 → 3.0.9-dev.feat-strategy-compos.20260716013009
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.
- package/dist/cli/composer-commands.d.ts +37 -0
- package/dist/cli/composer-commands.d.ts.map +1 -0
- package/dist/cli/composer-commands.js +287 -0
- package/dist/cli/composer-commands.js.map +1 -0
- package/dist/cli/senpi-commands.d.ts.map +1 -1
- package/dist/cli/senpi-commands.js +2 -0
- package/dist/cli/senpi-commands.js.map +1 -1
- package/dist/cli/validate-runtime-config.mjs +124 -0
- package/dist/composer/AUTHORING.md +233 -0
- package/dist/composer/__init__.py +29 -0
- package/dist/composer/authoring/__init__.py +49 -0
- package/dist/composer/authoring/answers.py +226 -0
- package/dist/composer/authoring/archetypes/__init__.py +25 -0
- package/dist/composer/authoring/archetypes/_common.py +218 -0
- package/dist/composer/authoring/archetypes/classifier.py +109 -0
- package/dist/composer/authoring/archetypes/skeleton.py +54 -0
- package/dist/composer/authoring/archetypes/thesis_fund.py +103 -0
- package/dist/composer/authoring/archetypes/trend_momentum.py +104 -0
- package/dist/composer/authoring/builder.py +191 -0
- package/dist/composer/authoring/emit.py +264 -0
- package/dist/composer/authoring/gaps.py +115 -0
- package/dist/composer/authoring/generate.py +131 -0
- package/dist/composer/authoring/presets.py +168 -0
- package/dist/composer/authoring/runtime_emit.py +266 -0
- package/dist/composer/authoring/strategy_manifest.py +94 -0
- package/dist/composer/cli.py +355 -0
- package/dist/composer/compiler/__init__.py +22 -0
- package/dist/composer/compiler/compile.py +550 -0
- package/dist/composer/examples/breakout_swing.graph.yaml +121 -0
- package/dist/composer/examples/conviction_scorer.graph.yaml +216 -0
- package/dist/composer/examples/macro_thesis_basket.graph.yaml +247 -0
- package/dist/composer/examples/pre_listing_classifier.graph.yaml +204 -0
- package/dist/composer/examples/trend_momentum_majors.graph.yaml +133 -0
- package/dist/composer/generated/README.md +22 -0
- package/dist/composer/generated/catalog.md +84 -0
- package/dist/composer/generated/describe.md +703 -0
- package/dist/composer/generated/graph.schema.json +1203 -0
- package/dist/composer/primitives/__init__.py +239 -0
- package/dist/composer/primitives/_candles.py +76 -0
- package/dist/composer/primitives/adapter.py +120 -0
- package/dist/composer/primitives/emitters.py +142 -0
- package/dist/composer/primitives/filters.py +71 -0
- package/dist/composer/primitives/indicators.py +203 -0
- package/dist/composer/primitives/rankers.py +40 -0
- package/dist/composer/primitives/scorers.py +100 -0
- package/dist/composer/primitives/sources.py +405 -0
- package/dist/composer/primitives/stateful.py +52 -0
- package/dist/composer/pure_fn/__init__.py +34 -0
- package/dist/composer/pure_fn/checks.py +39 -0
- package/dist/composer/pure_fn/models.py +177 -0
- package/dist/composer/pure_fn/purity.py +311 -0
- package/dist/composer/pure_fn/runtests.py +155 -0
- package/dist/composer/registry/__init__.py +40 -0
- package/dist/composer/registry/generate.py +422 -0
- package/dist/composer/registry/models.py +331 -0
- package/dist/composer/registry/nodes.py +673 -0
- package/dist/composer/requirements.txt +21 -0
- package/dist/composer/templates/__init__.py +13 -0
- package/dist/composer/templates/scan.py.j2 +31 -0
- package/dist/composer/validator/__init__.py +29 -0
- package/dist/composer/validator/desugar.py +133 -0
- package/dist/composer/validator/errors.py +191 -0
- package/dist/composer/validator/graph_checks.py +348 -0
- package/dist/composer/validator/loader.py +110 -0
- package/dist/composer/validator/schema_checks.py +147 -0
- package/dist/composer/validator/validate.py +86 -0
- package/dist/composer/verbs/__init__.py +33 -0
- package/dist/composer/verbs/check.py +193 -0
- package/dist/composer/verbs/deploy.py +223 -0
- package/dist/composer/verbs/docs.py +30 -0
- package/dist/composer/verbs/runtime_validate.py +262 -0
- package/dist/composer/verbs/smoke.py +115 -0
- package/dist/composer/verbs/status.py +205 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -9
- package/dist/index.js.map +1 -1
- package/dist/runtime-api/config.d.ts +2 -2
- package/dist/runtime-api/config.js +4 -4
- package/dist/runtime-api/config.js.map +1 -1
- package/dist/runtime-api/handlers/ingest-signals.d.ts +3 -5
- package/dist/runtime-api/handlers/ingest-signals.d.ts.map +1 -1
- package/dist/runtime-api/handlers/ingest-signals.js +5 -14
- package/dist/runtime-api/handlers/ingest-signals.js.map +1 -1
- package/dist/runtime-api/intake/intake-mount.d.ts.map +1 -1
- package/dist/runtime-api/intake/intake-mount.js +2 -8
- package/dist/runtime-api/intake/intake-mount.js.map +1 -1
- package/dist/skills-manager/manifest.js +1 -1
- package/dist/skills-manager/manifest.js.map +1 -1
- package/dist/skills-manager/skills-manifest.json +1 -1
- package/package.json +4 -2
- package/dist/runtime-api/intake/ingest-mutex.d.ts +0 -16
- package/dist/runtime-api/intake/ingest-mutex.d.ts.map +0 -1
- package/dist/runtime-api/intake/ingest-mutex.js +0 -18
- package/dist/runtime-api/intake/ingest-mutex.js.map +0 -1
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""validate — the pipeline that runs the stages and collects every error together.
|
|
2
|
+
|
|
3
|
+
Stage order (each stage's findings are added to the same :class:`ValidationResult`;
|
|
4
|
+
stages do not stop at the first error — the whole point is to report every defect at
|
|
5
|
+
once so the agent repairs in one pass):
|
|
6
|
+
|
|
7
|
+
(a) load + anchor/alias/merge rejection -> loader
|
|
8
|
+
(b) `pipeline:` desugaring into nodes+edges -> desugar (before schema)
|
|
9
|
+
(c) JSON Schema validation (registry-generated) -> schema_checks
|
|
10
|
+
(d) graph-semantic checks -> graph_checks
|
|
11
|
+
(e) pure_fn checks (purity + inline tests) -> composer.pure_fn.checks
|
|
12
|
+
|
|
13
|
+
A hard load failure (a) is terminal — there is no document to check further. Everything
|
|
14
|
+
after that accumulates.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
from composer.pure_fn import checks as pure_fn_checks
|
|
22
|
+
|
|
23
|
+
from . import desugar as _desugar
|
|
24
|
+
from . import graph_checks, schema_checks
|
|
25
|
+
from .errors import Location, TeachingError, ValidationResult
|
|
26
|
+
from .loader import AnchorError, YamlLoadError, load_graph
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def validate_text(text: str, source: str = "<text>") -> ValidationResult:
|
|
30
|
+
result = ValidationResult(source=source)
|
|
31
|
+
|
|
32
|
+
# (a) load + anchor rejection
|
|
33
|
+
try:
|
|
34
|
+
data, line_index = load_graph(text)
|
|
35
|
+
except AnchorError as exc:
|
|
36
|
+
result.add(
|
|
37
|
+
TeachingError(
|
|
38
|
+
code="CMP002",
|
|
39
|
+
message=f"YAML {exc.what} is not allowed in a strategy graph.",
|
|
40
|
+
fix_hint="Remove the anchor/alias/merge key and write the value out in full (D6 bans them).",
|
|
41
|
+
location=Location(line=exc.line),
|
|
42
|
+
)
|
|
43
|
+
)
|
|
44
|
+
return result
|
|
45
|
+
except YamlLoadError as exc:
|
|
46
|
+
result.add(
|
|
47
|
+
TeachingError(
|
|
48
|
+
code="CMP001",
|
|
49
|
+
message=f"YAML syntax error: {exc.message}",
|
|
50
|
+
fix_hint="Fix the YAML syntax at the reported line (check indentation and quoting).",
|
|
51
|
+
location=Location(line=exc.line),
|
|
52
|
+
)
|
|
53
|
+
)
|
|
54
|
+
return result
|
|
55
|
+
|
|
56
|
+
if not isinstance(data, dict):
|
|
57
|
+
result.add(
|
|
58
|
+
TeachingError(
|
|
59
|
+
code="CMP003",
|
|
60
|
+
message="the document root is empty or not a mapping.",
|
|
61
|
+
fix_hint="A graph is a mapping with at least a `nodes:` list (or a `pipeline:` shorthand).",
|
|
62
|
+
)
|
|
63
|
+
)
|
|
64
|
+
return result
|
|
65
|
+
|
|
66
|
+
# (b) desugar pipeline -> nodes+edges (before schema)
|
|
67
|
+
if _desugar.has_pipeline(data):
|
|
68
|
+
data, desugar_errors = _desugar.desugar(data)
|
|
69
|
+
result.extend(desugar_errors)
|
|
70
|
+
|
|
71
|
+
# (c) JSON Schema validation
|
|
72
|
+
result.extend(schema_checks.schema_errors(data, line_index))
|
|
73
|
+
|
|
74
|
+
# (d) graph-semantic checks
|
|
75
|
+
result.extend(graph_checks.check(data, line_index))
|
|
76
|
+
|
|
77
|
+
# (e) pure_fn purity + inline fixture tests (D2 kind 2)
|
|
78
|
+
result.extend(pure_fn_checks.check(data, line_index))
|
|
79
|
+
|
|
80
|
+
return result
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def validate_file(path: str | Path) -> ValidationResult:
|
|
84
|
+
p = Path(path)
|
|
85
|
+
text = p.read_text(encoding="utf-8")
|
|
86
|
+
return validate_text(text, source=str(p))
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""verbs — the slice-7 composer verbs (check / deploy / status / catalog / describe).
|
|
2
|
+
|
|
3
|
+
Each verb is a thin, pure-ish Python surface producing a structured result with both a
|
|
4
|
+
human ``render()`` and a ``to_json()`` (the shape the TS rim passes through). The lifecycle
|
|
5
|
+
they implement — ``new → check → deploy``, with ``status`` deriving position and naming the
|
|
6
|
+
next verb — is D7; the TS wrapper is verdict-passthrough only (D8).
|
|
7
|
+
|
|
8
|
+
check — ONE verdict aggregating validate + pure_fn tests + compile + fixture smoke.
|
|
9
|
+
deploy — idempotent, HARD-gated on check; stages the deployable artifact set.
|
|
10
|
+
status — DERIVED lifecycle position + next verb (no journals; the spec file is state).
|
|
11
|
+
docs — catalog index + per-node describe (registry-generated).
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from .check import CheckResult, check_file, run_check
|
|
15
|
+
from .deploy import DeployResult, deploy_file, run_deploy
|
|
16
|
+
from .docs import catalog_text, describe_text
|
|
17
|
+
from .smoke import SmokeError, run_smoke
|
|
18
|
+
from .status import StatusResult, run_status
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"CheckResult",
|
|
22
|
+
"run_check",
|
|
23
|
+
"check_file",
|
|
24
|
+
"DeployResult",
|
|
25
|
+
"run_deploy",
|
|
26
|
+
"deploy_file",
|
|
27
|
+
"StatusResult",
|
|
28
|
+
"run_status",
|
|
29
|
+
"catalog_text",
|
|
30
|
+
"describe_text",
|
|
31
|
+
"SmokeError",
|
|
32
|
+
"run_smoke",
|
|
33
|
+
]
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"""check — ONE verdict aggregating the whole build gate (D7).
|
|
2
|
+
|
|
3
|
+
``composer check <graph.yaml>`` collapses everything that must be true before a graph is
|
|
4
|
+
deployable into a single GREEN/RED verdict, run as ordered stages that short-circuit:
|
|
5
|
+
|
|
6
|
+
validate schema + graph-semantic + pure_fn PURITY checks (the teaching validator).
|
|
7
|
+
pure_fn_tests the pure_fn inline fixture tests (CMP05x), reported as their own stage.
|
|
8
|
+
compile deterministic materialization to scan.py on the external_scanner contract.
|
|
9
|
+
smoke import the emitted module + run one scan() tick over a stub ctx (liveness).
|
|
10
|
+
runtime_validate PROTECTION policy (CMP120) + the REAL Zod+DSL oracle over the emitted
|
|
11
|
+
runtime.yaml (CMP1xx). Protection-by-default is enforced HERE: an unmanaged
|
|
12
|
+
unit with no explicit opt-out is RED. The Zod oracle needs a built dist/; when
|
|
13
|
+
absent (CI without `npm run build`) it is noted SKIPPED, not RED — the policy
|
|
14
|
+
layer still gates. Opt-out (`unmanaged=true`) is GREEN with a loud warning.
|
|
15
|
+
|
|
16
|
+
The validator runs purity AND the inline fixture tests in one pass, so rather than re-run
|
|
17
|
+
them we PARTITION that single run's errors: CMP05x (inline-test) findings become the
|
|
18
|
+
``pure_fn_tests`` stage; everything else is the ``validate`` stage. A stage is SKIPPED once
|
|
19
|
+
an earlier stage is RED (compiling an invalid graph, or smoking a graph that won't compile,
|
|
20
|
+
would only produce noise). The verdict is GREEN iff every stage is GREEN.
|
|
21
|
+
|
|
22
|
+
The result is both human-rendered and ``--json`` (verdict + per-stage status + located
|
|
23
|
+
errors). ``deploy`` consumes this object directly as its hard gate; the compiled source is
|
|
24
|
+
carried on the result so ``deploy`` need not recompile.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
from dataclasses import dataclass, field
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
from typing import Optional
|
|
32
|
+
|
|
33
|
+
from composer.compiler import CompileError, compile_text
|
|
34
|
+
from composer.validator import validate_text
|
|
35
|
+
from composer.validator.errors import TeachingError
|
|
36
|
+
from composer.validator.loader import load_graph
|
|
37
|
+
|
|
38
|
+
from .runtime_validate import check_runtime_unit
|
|
39
|
+
from .smoke import SmokeError, run_smoke
|
|
40
|
+
|
|
41
|
+
# Inline pure_fn fixture-test error codes (partitioned into their own stage).
|
|
42
|
+
_PURE_FN_TEST_CODES = {"CMP050", "CMP051", "CMP052"}
|
|
43
|
+
|
|
44
|
+
GREEN = "GREEN"
|
|
45
|
+
RED = "RED"
|
|
46
|
+
SKIPPED = "SKIPPED"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass
|
|
50
|
+
class Stage:
|
|
51
|
+
name: str
|
|
52
|
+
status: str = SKIPPED
|
|
53
|
+
errors: list[TeachingError] = field(default_factory=list)
|
|
54
|
+
# free-text findings for stages that don't produce TeachingErrors (compile / smoke)
|
|
55
|
+
messages: list[str] = field(default_factory=list)
|
|
56
|
+
detail: Optional[dict] = None
|
|
57
|
+
|
|
58
|
+
def to_json(self) -> dict:
|
|
59
|
+
out: dict = {
|
|
60
|
+
"stage": self.name,
|
|
61
|
+
"status": self.status,
|
|
62
|
+
"errors": [e.to_dict() for e in self.errors],
|
|
63
|
+
}
|
|
64
|
+
if self.messages:
|
|
65
|
+
out["messages"] = list(self.messages)
|
|
66
|
+
if self.detail is not None:
|
|
67
|
+
out["detail"] = self.detail
|
|
68
|
+
return out
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass
|
|
72
|
+
class CheckResult:
|
|
73
|
+
source: str
|
|
74
|
+
stages: list[Stage]
|
|
75
|
+
scan_source: Optional[str] = None # the emitted scan.py when compile succeeded
|
|
76
|
+
runtime_source: Optional[str] = None # the emitted runtime.yaml when runtime_validate ran
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def verdict(self) -> str:
|
|
80
|
+
return GREEN if all(s.status == GREEN for s in self.stages) else RED
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def ok(self) -> bool:
|
|
84
|
+
return self.verdict == GREEN
|
|
85
|
+
|
|
86
|
+
def to_json(self) -> dict:
|
|
87
|
+
errors = [e.to_dict() for s in self.stages for e in s.errors]
|
|
88
|
+
return {
|
|
89
|
+
"verb": "check",
|
|
90
|
+
"graph": self.source,
|
|
91
|
+
"verdict": self.verdict,
|
|
92
|
+
"stages": [s.to_json() for s in self.stages],
|
|
93
|
+
"error_count": len(errors),
|
|
94
|
+
"errors": errors,
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
def render(self) -> str:
|
|
98
|
+
lines = [f"{self.verdict}: {self.source}"]
|
|
99
|
+
for s in self.stages:
|
|
100
|
+
mark = {"GREEN": "ok ", "RED": "FAIL", "SKIPPED": "skip"}[s.status]
|
|
101
|
+
lines.append(f" [{mark}] {s.name}")
|
|
102
|
+
for e in sorted(s.errors, key=lambda e: e.sort_key):
|
|
103
|
+
lines.append(" " + e.render().replace("\n", "\n "))
|
|
104
|
+
for m in s.messages:
|
|
105
|
+
lines.append(f" {m}")
|
|
106
|
+
lines.append("")
|
|
107
|
+
lines.append(
|
|
108
|
+
"check GREEN — ready to deploy." if self.ok
|
|
109
|
+
else "check RED — fix the failing stage(s); deploy is refused until GREEN."
|
|
110
|
+
)
|
|
111
|
+
return "\n".join(lines) + "\n"
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def run_check(source_text: str, source_name: str) -> CheckResult:
|
|
115
|
+
"""Run the whole gate over graph YAML text; return the aggregated verdict."""
|
|
116
|
+
validate_stage = Stage("validate")
|
|
117
|
+
pure_fn_stage = Stage("pure_fn_tests")
|
|
118
|
+
compile_stage = Stage("compile")
|
|
119
|
+
smoke_stage = Stage("smoke")
|
|
120
|
+
runtime_stage = Stage("runtime_validate")
|
|
121
|
+
stages = [validate_stage, pure_fn_stage, compile_stage, smoke_stage, runtime_stage]
|
|
122
|
+
|
|
123
|
+
# --- validate (+ partition the pure_fn inline-test findings into their own stage) ---
|
|
124
|
+
vresult = validate_text(source_text, source=source_name)
|
|
125
|
+
for err in vresult.errors:
|
|
126
|
+
(pure_fn_stage if err.code in _PURE_FN_TEST_CODES else validate_stage).errors.append(err)
|
|
127
|
+
validate_stage.status = RED if validate_stage.errors else GREEN
|
|
128
|
+
pure_fn_stage.status = RED if pure_fn_stage.errors else GREEN
|
|
129
|
+
|
|
130
|
+
if not vresult.ok:
|
|
131
|
+
# compile/smoke stay SKIPPED — nothing valid to materialize.
|
|
132
|
+
return CheckResult(source_name, stages)
|
|
133
|
+
|
|
134
|
+
# --- compile ---
|
|
135
|
+
scan_source: Optional[str] = None
|
|
136
|
+
try:
|
|
137
|
+
scan_source = compile_text(source_text, source_name=source_name)
|
|
138
|
+
compile_stage.status = GREEN
|
|
139
|
+
except CompileError as exc:
|
|
140
|
+
compile_stage.status = RED
|
|
141
|
+
compile_stage.messages.append(str(exc).splitlines()[0])
|
|
142
|
+
return CheckResult(source_name, stages, scan_source=None)
|
|
143
|
+
|
|
144
|
+
# --- smoke (import + one tick over a stub ctx) ---
|
|
145
|
+
try:
|
|
146
|
+
detail = run_smoke(scan_source, scanner_name=source_name)
|
|
147
|
+
smoke_stage.status = GREEN
|
|
148
|
+
smoke_stage.detail = detail
|
|
149
|
+
except SmokeError as exc:
|
|
150
|
+
smoke_stage.status = RED
|
|
151
|
+
smoke_stage.messages.append(str(exc))
|
|
152
|
+
return CheckResult(source_name, stages, scan_source=scan_source)
|
|
153
|
+
|
|
154
|
+
# --- runtime_validate (protection policy + the real Zod+DSL oracle over runtime.yaml) ---
|
|
155
|
+
runtime_source = _run_runtime_validate(source_text, runtime_stage)
|
|
156
|
+
|
|
157
|
+
return CheckResult(source_name, stages, scan_source=scan_source, runtime_source=runtime_source)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _run_runtime_validate(source_text: str, stage: Stage) -> Optional[str]:
|
|
161
|
+
"""Emit runtime.yaml from the graph's metadata, enforce PROTECTION policy (CMP120), and run
|
|
162
|
+
the real Zod+DSL oracle. Records status/errors/messages on ``stage``; returns the emitted
|
|
163
|
+
runtime.yaml text (so deploy can stage it without re-emitting)."""
|
|
164
|
+
from composer.authoring.runtime_emit import answers_from_metadata, render_runtime_yaml
|
|
165
|
+
|
|
166
|
+
try:
|
|
167
|
+
doc, _ = load_graph(source_text)
|
|
168
|
+
answers = answers_from_metadata(doc.get("metadata") or {})
|
|
169
|
+
except Exception as exc: # noqa: BLE001 — a bad metadata block is a graph defect, reported here
|
|
170
|
+
stage.status = RED
|
|
171
|
+
stage.messages.append(f"could not derive runtime answers from metadata: {exc}")
|
|
172
|
+
return None
|
|
173
|
+
|
|
174
|
+
runtime_source = render_runtime_yaml(answers)
|
|
175
|
+
oracle, policy_errors = check_runtime_unit(answers)
|
|
176
|
+
stage.errors.extend(policy_errors)
|
|
177
|
+
stage.errors.extend(oracle.errors)
|
|
178
|
+
|
|
179
|
+
if answers.unmanaged:
|
|
180
|
+
stage.messages.append(
|
|
181
|
+
"WARNING: unmanaged=true — this unit ships with NO exit protection (no stop-loss, "
|
|
182
|
+
"no trailing floor, no time-cut). Positions can run to liquidation. Opt-out accepted."
|
|
183
|
+
)
|
|
184
|
+
if oracle.skipped and oracle.skip_reason:
|
|
185
|
+
stage.messages.append(oracle.skip_reason)
|
|
186
|
+
|
|
187
|
+
stage.status = RED if stage.errors else GREEN
|
|
188
|
+
return runtime_source
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def check_file(path) -> CheckResult:
|
|
192
|
+
p = Path(path)
|
|
193
|
+
return run_check(p.read_text(encoding="utf-8"), p.name)
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
"""deploy — idempotent, HARD-gated on check; stage the COMPLETE standalone unit (slice 9).
|
|
2
|
+
|
|
3
|
+
``composer deploy <graph.yaml> --out <dir>`` is the compile-on-deploy step. It:
|
|
4
|
+
|
|
5
|
+
1. Runs ``composer check`` and REFUSES on RED (verdict ``REFUSED``). There is no
|
|
6
|
+
``--force`` — a graph that isn't GREEN never deploys, by construction (the whole
|
|
7
|
+
trust-by-construction premise: you cannot ship an unverified strategy). Because
|
|
8
|
+
``check`` now includes the ``runtime_validate`` stage, a unit that is not Zod-valid or
|
|
9
|
+
not PROTECTED (CMP120) never deploys either.
|
|
10
|
+
2. Stages a COMPLETE, standalone strategy PACKAGE TREE (design §6) into ``<out>/<strategy>/``:
|
|
11
|
+
strategy.yaml catalog + funding manifest, single instance (§7).
|
|
12
|
+
main/runtime.yaml the complete emitted runtime (strategy/scanners/actions/exit/risk).
|
|
13
|
+
main/scanners/scan.py the compiled module (the scaffold `entrypoint`).
|
|
14
|
+
main/<strategy>.graph.yaml the spec-is-state graph source (D7), under the instance.
|
|
15
|
+
manifest.json content hashes (graph + scan + runtime.yaml), schema version,
|
|
16
|
+
node count, and the zod-oracle verdict for the staged runtime.
|
|
17
|
+
deploy.json the verdict describing exactly what happened.
|
|
18
|
+
3. Is IDEMPOTENT: re-deploying with identical graph/scan/runtime bytes is a no-op with
|
|
19
|
+
verdict ``UNCHANGED`` — the spec file is the identity, matching status's staleness derivation.
|
|
20
|
+
|
|
21
|
+
The staged unit is COMPLETE: it carries its own ``exit`` (DSL protection), its ``OPEN_POSITION``
|
|
22
|
+
entry action, and a funded-wallet manifest — no host runtime.yaml to borrow (the slice-8 wrap
|
|
23
|
+
hack is retired). Ops (``senpi-strategy-ops``) begins where the composer ends: it reads
|
|
24
|
+
``strategy.yaml.instances[]``, mints one wallet per instance, funds by ``funding_share``, and
|
|
25
|
+
renders each ``runtime.yaml`` onto its wallet (``openclaw senpi runtime create``). The composer
|
|
26
|
+
never creates wallets, moves money, or installs (design §7 scope boundary).
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import hashlib
|
|
32
|
+
import json
|
|
33
|
+
from dataclasses import dataclass
|
|
34
|
+
from pathlib import Path
|
|
35
|
+
from typing import Optional
|
|
36
|
+
|
|
37
|
+
from composer.authoring.runtime_emit import answers_from_metadata, render_runtime_yaml
|
|
38
|
+
from composer.authoring.strategy_manifest import render_strategy_yaml
|
|
39
|
+
from composer.registry import SCHEMA_VERSION
|
|
40
|
+
from composer.validator.loader import load_graph
|
|
41
|
+
|
|
42
|
+
from .check import CheckResult, run_check
|
|
43
|
+
from .runtime_validate import validate_runtime_yaml_file
|
|
44
|
+
|
|
45
|
+
DEPLOYED = "DEPLOYED"
|
|
46
|
+
UNCHANGED = "UNCHANGED"
|
|
47
|
+
REFUSED = "REFUSED"
|
|
48
|
+
|
|
49
|
+
# How ops installs the staged unit (documented handoff, not invoked here).
|
|
50
|
+
_OPS_MECHANISM = "senpi-strategy-ops deploy.py create/runtime — one wallet per instance, then `openclaw senpi runtime create`"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _sha256(text: str) -> str:
|
|
54
|
+
return hashlib.sha256(text.encode("utf-8")).hexdigest()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass
|
|
58
|
+
class DeployResult:
|
|
59
|
+
source: str
|
|
60
|
+
verdict: str
|
|
61
|
+
check: CheckResult
|
|
62
|
+
staged_dir: Optional[str] = None
|
|
63
|
+
manifest: Optional[dict] = None
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def ok(self) -> bool:
|
|
67
|
+
return self.verdict in (DEPLOYED, UNCHANGED)
|
|
68
|
+
|
|
69
|
+
def to_json(self) -> dict:
|
|
70
|
+
out: dict = {
|
|
71
|
+
"verb": "deploy",
|
|
72
|
+
"graph": self.source,
|
|
73
|
+
"verdict": self.verdict,
|
|
74
|
+
"check": {
|
|
75
|
+
"verdict": self.check.verdict,
|
|
76
|
+
"error_count": self.check.to_json()["error_count"],
|
|
77
|
+
},
|
|
78
|
+
}
|
|
79
|
+
if self.verdict == REFUSED:
|
|
80
|
+
out["check"]["errors"] = self.check.to_json()["errors"]
|
|
81
|
+
out["message"] = "check RED — deploy refused (no --force; a graph must be GREEN)."
|
|
82
|
+
return out
|
|
83
|
+
man = self.manifest or {}
|
|
84
|
+
out["staged"] = {
|
|
85
|
+
"dir": self.staged_dir,
|
|
86
|
+
"artifacts": sorted(man.get("artifacts", {}).keys()),
|
|
87
|
+
"graph_sha256": man.get("graph_sha256"),
|
|
88
|
+
"scan_sha256": man.get("scan_sha256"),
|
|
89
|
+
"runtime_sha256": man.get("runtime_sha256"),
|
|
90
|
+
"strategy_sha256": man.get("strategy_sha256"),
|
|
91
|
+
"schema_version": man.get("schema_version"),
|
|
92
|
+
"runtime_zod_verdict": man.get("runtime", {}).get("zod_verdict"),
|
|
93
|
+
}
|
|
94
|
+
# The staged unit is COMPLETE and standalone — its own exit + entry action + funding
|
|
95
|
+
# manifest. Ops installs it; there is no host runtime.yaml to borrow (the wrap hack is gone).
|
|
96
|
+
out["install"] = {
|
|
97
|
+
"wired": False,
|
|
98
|
+
"handoff": _OPS_MECHANISM,
|
|
99
|
+
"complete_unit": True,
|
|
100
|
+
"note": (
|
|
101
|
+
"a COMPLETE standalone strategy runtime is staged: main/runtime.yaml carries the "
|
|
102
|
+
"OPEN_POSITION entry action AND the DSL exit (protection-by-default), plus a "
|
|
103
|
+
"position_tracker. Hand the staged dir to senpi-strategy-ops: it reads "
|
|
104
|
+
"strategy.yaml.instances[], mints one funded wallet per instance (funding_share), "
|
|
105
|
+
"and renders each runtime.yaml onto its wallet. The composer never funds or installs."
|
|
106
|
+
),
|
|
107
|
+
}
|
|
108
|
+
out["message"] = (
|
|
109
|
+
f"{self.verdict.lower()}: complete unit staged to {self.staged_dir}"
|
|
110
|
+
if self.verdict == DEPLOYED
|
|
111
|
+
else f"unchanged: staged unit at {self.staged_dir} matches (idempotent no-op)."
|
|
112
|
+
)
|
|
113
|
+
return out
|
|
114
|
+
|
|
115
|
+
def render(self) -> str:
|
|
116
|
+
if self.verdict == REFUSED:
|
|
117
|
+
return (
|
|
118
|
+
f"REFUSED: {self.source} — check is {self.check.verdict} "
|
|
119
|
+
f"({self.check.to_json()['error_count']} error(s)); deploy is hard-gated on a "
|
|
120
|
+
f"GREEN check (there is no --force). Run `composer check {self.source}`.\n"
|
|
121
|
+
)
|
|
122
|
+
j = self.to_json()
|
|
123
|
+
s = j["staged"]
|
|
124
|
+
lines = [
|
|
125
|
+
f"{self.verdict}: {self.source}",
|
|
126
|
+
f" staged dir : {self.staged_dir}",
|
|
127
|
+
f" artifacts : {', '.join(s['artifacts'])}",
|
|
128
|
+
f" graph sha : {(s['graph_sha256'] or '')[:12]}…",
|
|
129
|
+
f" scan sha : {(s['scan_sha256'] or '')[:12]}…",
|
|
130
|
+
f" runtime sha: {(s['runtime_sha256'] or '')[:12]}… (zod: {s['runtime_zod_verdict']})",
|
|
131
|
+
f" install : complete standalone unit — hand off to ops ({_OPS_MECHANISM}).",
|
|
132
|
+
]
|
|
133
|
+
return "\n".join(lines) + "\n"
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def run_deploy(source_text: str, source_name: str, out_dir) -> DeployResult:
|
|
137
|
+
"""Check, then stage the complete standalone unit as a package tree (idempotent). See docstring."""
|
|
138
|
+
check = run_check(source_text, source_name)
|
|
139
|
+
if not check.ok:
|
|
140
|
+
return DeployResult(source_name, REFUSED, check)
|
|
141
|
+
|
|
142
|
+
doc, _line_index = load_graph(source_text)
|
|
143
|
+
metadata = doc.get("metadata") or {}
|
|
144
|
+
strategy = str(metadata.get("strategy") or Path(source_name).stem.replace(".graph", ""))
|
|
145
|
+
archetype = str(metadata.get("archetype") or "unknown")
|
|
146
|
+
|
|
147
|
+
# The runtime.yaml + strategy.yaml derive from the SAME metadata.answers the scan.py did
|
|
148
|
+
# (design §6): recompiling regenerates all three byte-identically.
|
|
149
|
+
answers = answers_from_metadata(metadata)
|
|
150
|
+
scan_source = check.scan_source or ""
|
|
151
|
+
runtime_source = check.runtime_source or render_runtime_yaml(answers)
|
|
152
|
+
strategy_source = render_strategy_yaml(answers)
|
|
153
|
+
|
|
154
|
+
graph_sha = _sha256(source_text)
|
|
155
|
+
scan_sha = _sha256(scan_source)
|
|
156
|
+
runtime_sha = _sha256(runtime_source)
|
|
157
|
+
strategy_sha = _sha256(strategy_source)
|
|
158
|
+
|
|
159
|
+
stage_dir = Path(out_dir) / strategy
|
|
160
|
+
instance_dir = stage_dir / "main"
|
|
161
|
+
scanners_dir = instance_dir / "scanners"
|
|
162
|
+
manifest_path = stage_dir / "manifest.json"
|
|
163
|
+
graph_filename = f"{strategy}.graph.yaml"
|
|
164
|
+
|
|
165
|
+
# Idempotency: the spec file IS the identity (D7). A prior staging whose manifest records
|
|
166
|
+
# the same graph + scan + runtime bytes is an UNCHANGED no-op — don't rewrite anything.
|
|
167
|
+
if manifest_path.is_file():
|
|
168
|
+
try:
|
|
169
|
+
prior = json.loads(manifest_path.read_text(encoding="utf-8"))
|
|
170
|
+
except (OSError, json.JSONDecodeError):
|
|
171
|
+
prior = {}
|
|
172
|
+
if (
|
|
173
|
+
prior.get("graph_sha256") == graph_sha
|
|
174
|
+
and prior.get("scan_sha256") == scan_sha
|
|
175
|
+
and prior.get("runtime_sha256") == runtime_sha
|
|
176
|
+
):
|
|
177
|
+
return DeployResult(source_name, UNCHANGED, check,
|
|
178
|
+
staged_dir=str(stage_dir), manifest=prior)
|
|
179
|
+
|
|
180
|
+
manifest = {
|
|
181
|
+
"strategy": strategy,
|
|
182
|
+
"archetype": archetype,
|
|
183
|
+
"schema_version": SCHEMA_VERSION,
|
|
184
|
+
"node_count": len(doc.get("nodes") or []),
|
|
185
|
+
"graph_sha256": graph_sha,
|
|
186
|
+
"scan_sha256": scan_sha,
|
|
187
|
+
"runtime_sha256": runtime_sha,
|
|
188
|
+
"strategy_sha256": strategy_sha,
|
|
189
|
+
"artifacts": {
|
|
190
|
+
"strategy.yaml": strategy_sha,
|
|
191
|
+
"main/runtime.yaml": runtime_sha,
|
|
192
|
+
"main/scanners/scan.py": scan_sha,
|
|
193
|
+
f"main/{graph_filename}": graph_sha,
|
|
194
|
+
},
|
|
195
|
+
"runtime": {"path": "main/runtime.yaml", "sha256": runtime_sha},
|
|
196
|
+
"generated_by": "composer deploy",
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
# Write the complete package tree.
|
|
200
|
+
scanners_dir.mkdir(parents=True, exist_ok=True)
|
|
201
|
+
(scanners_dir / "scan.py").write_text(scan_source, encoding="utf-8")
|
|
202
|
+
(instance_dir / "runtime.yaml").write_text(runtime_source, encoding="utf-8")
|
|
203
|
+
(instance_dir / graph_filename).write_text(source_text, encoding="utf-8")
|
|
204
|
+
(stage_dir / "strategy.yaml").write_text(strategy_source, encoding="utf-8")
|
|
205
|
+
|
|
206
|
+
# Prove the STAGED runtime.yaml validates against the real oracle; record the verdict.
|
|
207
|
+
ov = validate_runtime_yaml_file(instance_dir / "runtime.yaml")
|
|
208
|
+
manifest["runtime"]["zod_verdict"] = (
|
|
209
|
+
"SKIPPED" if ov.skipped else ("VALID" if ov.ok else "INVALID")
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
manifest_path.write_text(json.dumps(manifest, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
|
213
|
+
|
|
214
|
+
result = DeployResult(source_name, DEPLOYED, check, staged_dir=str(stage_dir), manifest=manifest)
|
|
215
|
+
(stage_dir / "deploy.json").write_text(
|
|
216
|
+
json.dumps(result.to_json(), indent=2, sort_keys=True) + "\n", encoding="utf-8"
|
|
217
|
+
)
|
|
218
|
+
return result
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def deploy_file(path, out_dir) -> DeployResult:
|
|
222
|
+
p = Path(path)
|
|
223
|
+
return run_deploy(p.read_text(encoding="utf-8"), p.name, out_dir)
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""docs — the catalog / describe verbs (the registry-generated progressive-disclosure surface).
|
|
2
|
+
|
|
3
|
+
``composer catalog`` prints the ~1-2k-token index (node names + one-liners + param
|
|
4
|
+
summaries); ``composer describe <node>`` prints one node's full ports/params/example.
|
|
5
|
+
|
|
6
|
+
Both read straight from the committed ``composer/generated/`` artifacts when present (they
|
|
7
|
+
are byte-identical to a fresh render — a golden test enforces that), and fall back to
|
|
8
|
+
rendering from the registry in-memory otherwise, so the verbs work in any checkout.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from composer.registry import generate
|
|
16
|
+
|
|
17
|
+
_GENERATED = Path(__file__).resolve().parents[1] / "generated"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def catalog_text() -> str:
|
|
21
|
+
"""The catalog index (prefer the committed artifact; render from the registry if absent)."""
|
|
22
|
+
path = _GENERATED / "catalog.md"
|
|
23
|
+
if path.is_file():
|
|
24
|
+
return path.read_text(encoding="utf-8")
|
|
25
|
+
return generate.render_catalog()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def describe_text(node: str) -> str:
|
|
29
|
+
"""One node's reference block. Raises KeyError (with a nearest-name hint) if unknown."""
|
|
30
|
+
return generate.render_describe_node(node)
|