@senpi/runtime 3.0.9-dev.feat-strategy-compos.20260722213012 → 3.0.9-dev.feat-strategy-compos.20260723050017

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.
@@ -0,0 +1,138 @@
1
+ """_target — the ONE composer ``<target>`` resolver shared by the graph-consuming verbs (K4).
2
+
3
+ Before K4 the verbs disagreed about what a ``<target>`` is: ``status`` accepted a strategy NAME,
4
+ ``check`` / ``deploy`` demanded a full ``graph.yaml`` PATH. A model that typed
5
+ ``composer check my-strat`` (a name) or ``composer deploy ./my-strat/`` (a dir) got a bare
6
+ ``[Errno 2] No such file or directory`` / ``[Errno 21] Is a directory`` with NO fix_hint
7
+ (dev-test #7). This module is the single acceptance surface. A ``<target>`` is ANY of:
8
+
9
+ * an explicit graph ``.yaml`` FILE path (the pre-K4 contract — resolves to itself, unchanged);
10
+ * a strategy DIR — a canonical ``<state>/composer/<name>/`` dir, or a ``composer deploy``
11
+ ``staged/`` tree — holding a ``graph.yaml`` (or ``main/<name>.graph.yaml`` inside a staged tree);
12
+ * a canonical strategy NAME resolved through the state-root layout
13
+ (:func:`composer.verbs._layout.canonical_graph_path`).
14
+
15
+ It resolves to the graph ``.yaml`` :class:`~pathlib.Path` the verb should read, and TEACHES —
16
+ via **CMP256**, the existing "graph file could not be read" class, ENUMERATING the strategies that
17
+ DO exist under the state root — when nothing resolves. It reuses CMP256 rather than minting a
18
+ parallel code: the failure the caller experiences ("I named a graph and it isn't there") is exactly
19
+ CMP256's class; K4 only enriches the fix_hint with what IS available.
20
+
21
+ Back-compat contract: any target that is already an existing FILE resolves to itself, so every
22
+ currently-working ``check``/``deploy`` invocation is byte-identical. The NAME/DIR branches are new
23
+ capability layered on top; a target that looks like a (missing) path or an empty directory falls
24
+ THROUGH to the verb's own ``read_text`` so its pre-K4 CMP256 (missing file / IsADirectory) is
25
+ preserved verbatim — the enumerating CMP256 fires only for a bare NAME that resolves to nothing.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import os
31
+ from pathlib import Path
32
+ from typing import Optional
33
+
34
+ from composer.validator.errors import Location, TeachingError
35
+
36
+ from ._layout import COMPOSER_DIR, GRAPH_FILE, canonical_graph_path
37
+
38
+
39
+ class TargetError(Exception):
40
+ """A composer ``<target>`` could not be resolved to a strategy graph. Carries a
41
+ :class:`TeachingError` (CMP256) the CLI renders exactly like a verb's teaching errors."""
42
+
43
+ def __init__(self, error: TeachingError) -> None:
44
+ super().__init__(error.message)
45
+ self.error = error
46
+
47
+
48
+ def available_strategies(base_state_dir: Optional[str]) -> list[str]:
49
+ """The canonical strategy NAMES that carry a ``graph.yaml`` under ``<state>/composer/`` (sorted).
50
+ Empty when the state root is unknown or has no composer dir — the teaching hint degrades to a
51
+ generic instruction rather than fabricating a list."""
52
+ if not base_state_dir:
53
+ return []
54
+ root = Path(base_state_dir) / COMPOSER_DIR
55
+ if not root.is_dir():
56
+ return []
57
+ out: list[str] = []
58
+ for child in sorted(root.iterdir()):
59
+ if child.is_dir() and (child / GRAPH_FILE).is_file():
60
+ out.append(child.name)
61
+ return out
62
+
63
+
64
+ def available_strategies_hint(base_state_dir: Optional[str]) -> str:
65
+ """A fix_hint fragment naming the strategies that DO exist (or how to find/create one). Shared by
66
+ the graph verbs (check/deploy) and the lifecycle verbs (status/update) so a "not found" always
67
+ points at the real, on-box options rather than repeating the rejected target."""
68
+ names = available_strategies(base_state_dir)
69
+ if names:
70
+ shown = ", ".join(names[:20]) + (", …" if len(names) > 20 else "")
71
+ return f"Known strategies under the state root: {shown}."
72
+ return ("No strategy graphs were found under the state root's `composer/` dir — run "
73
+ "`composer new <id> --archetype …` first, or pass the path to an existing graph .yaml.")
74
+
75
+
76
+ def looks_like_path(target: str) -> bool:
77
+ """True when ``target`` is a PATH attempt (an existing path, a value carrying a path separator,
78
+ or a ``.yaml``/``.yml`` filename) rather than a bare strategy NAME. A path-like miss is left to
79
+ the verb's own ``read_text`` so its pre-K4 CMP256 (missing file / IsADirectory) is preserved."""
80
+ if Path(target).exists():
81
+ return True
82
+ if os.sep in target or (os.altsep and os.altsep in target):
83
+ return True
84
+ return target.endswith((".yaml", ".yml"))
85
+
86
+
87
+ def graph_in_dir(d: Path) -> Optional[Path]:
88
+ """The graph record inside a strategy / staged DIR: the top-level ``graph.yaml`` snapshot
89
+ (canonical dir and deploy staged tree both carry it), else the in-unit ``main/<name>.graph.yaml``
90
+ a staged tree also holds. None when the dir carries no graph record."""
91
+ direct = d / GRAPH_FILE
92
+ if direct.is_file():
93
+ return direct
94
+ main = d / "main"
95
+ if main.is_dir():
96
+ for child in sorted(main.glob("*.graph.yaml")):
97
+ return child
98
+ return None
99
+
100
+
101
+ def resolve_read_path(target: str, base_state_dir: Optional[str]) -> Path:
102
+ """Resolve a ``check`` / ``deploy`` ``<target>`` to the graph ``.yaml`` :class:`~pathlib.Path`
103
+ to read. Accepts a graph file (→ itself), a strategy/staged DIR (→ the graph inside), or a bare
104
+ strategy NAME (→ its canonical ``composer/<name>/graph.yaml``).
105
+
106
+ Returns the path the caller should ``read_text``. For an existing FILE this is the file itself
107
+ (byte-identical to pre-K4). For a path-like MISS (a missing ``.yaml`` path, or a directory with
108
+ NO graph inside) it returns the ORIGINAL path so the caller's own ``read_text`` raises the OSError
109
+ the caller already wraps as CMP256 (preserving the missing-file / IsADirectory teaching). It
110
+ RAISES :class:`TargetError` (CMP256, enumerating the available strategies) only for a bare NAME
111
+ that resolves to nothing — the case that previously produced a bare ``[Errno 2]``."""
112
+ p = Path(target)
113
+ if p.is_file():
114
+ return p
115
+ if p.is_dir():
116
+ found = graph_in_dir(p)
117
+ # a dir WITH a graph → that graph; an empty/graph-less dir falls through unchanged so the
118
+ # caller's read_text raises IsADirectoryError → its own CMP256 (the pre-K4 behaviour).
119
+ return found if found is not None else p
120
+ if looks_like_path(target):
121
+ # a missing FILE path (…/foo.graph.yaml): let the caller's read_text raise → its CMP256.
122
+ return p
123
+ # a bare STRATEGY NAME resolved through the canonical layout (needs the state root).
124
+ if base_state_dir:
125
+ cg = canonical_graph_path(base_state_dir, target)
126
+ if cg.is_file():
127
+ return cg
128
+ raise TargetError(TeachingError(
129
+ "CMP256",
130
+ f"the composer target {target!r} could not be resolved to a strategy graph — it is not an "
131
+ f"existing graph .yaml file, not a strategy directory, and no canonical "
132
+ f"`composer/{target}/graph.yaml` record exists under the state root.",
133
+ "Pass the path to an existing graph .yaml, a strategy directory, or a known strategy name. "
134
+ + available_strategies_hint(base_state_dir)
135
+ + (" (Pass `--state-dir <dir>` or set `SENPI_STATE_DIR` so a bare name can be resolved.)"
136
+ if not base_state_dir else ""),
137
+ Location(),
138
+ ))
@@ -387,7 +387,9 @@ def _resolve_targets(target: str, base_state_dir: str, strategies: list,
387
387
  rec = json.loads(wj_path.read_text(encoding="utf-8"))
388
388
  except (OSError, json.JSONDecodeError):
389
389
  rec = {}
390
- wallet = str(rec.get("wallet") or "")
390
+ # K3 follow-up: tolerant address read — a hand-authored marker may use the MCP key spellings.
391
+ from .fund import _rec_wallet
392
+ wallet = _rec_wallet(rec)
391
393
  rec_sid = rec.get("strategyId")
392
394
  s = next((s for s in strategies
393
395
  if (rec_sid and _strat_id(s) == rec_sid) or wallet_match(_strat_wallet(s), wallet)),
@@ -242,6 +242,33 @@ def _match_strategy(strategies: list, strategy_id: Optional[str], wallet: Option
242
242
  return None
243
243
 
244
244
 
245
+ # Hand-authored / alternate marker key spellings for the wallet ADDRESS (K3). The canonical writer
246
+ # (_write_wallet_json) always emits ``"wallet"``, but a human (or model) authoring a wallet.json
247
+ # marker by hand often copies the field name MCP ``strategy_list`` uses instead —
248
+ # ``strategyWalletAddress`` / ``walletAddress`` / ``address`` (the exact spellings
249
+ # ``close._strat_wallet`` reads). We accept those unambiguous alternates when resolving a marker so
250
+ # the documented adopt/reuse flow survives a hand-authored marker; a backfill then NORMALIZES the
251
+ # file back to the canonical ``"wallet"`` key.
252
+ _WALLET_KEYS = ("wallet", "walletAddress", "strategyWalletAddress", "address")
253
+
254
+
255
+ def _rec_wallet(rec: dict) -> str:
256
+ """The wallet address a wallet.json marker records, tolerating unambiguous hand-authored key
257
+ spellings (K3). Prefers the canonical ``wallet`` key, then the MCP-shaped alternates. The KEY
258
+ match is case-insensitive (a hand-author may write ``Address``); the VALUE is returned as read.
259
+ Empty string when the marker carries no recognizable address key."""
260
+ if not isinstance(rec, dict):
261
+ return ""
262
+ lower = {str(k).lower(): v for k, v in rec.items()}
263
+ for key in _WALLET_KEYS:
264
+ v = rec.get(key)
265
+ if not v:
266
+ v = lower.get(key.lower())
267
+ if v:
268
+ return str(v)
269
+ return ""
270
+
271
+
245
272
  # --------------------------------------------------------------------------- result
246
273
 
247
274
  @dataclass
@@ -563,12 +590,25 @@ def _verify_existing(target: str, strategy: str, budget: float, rec: dict, wj_pa
563
590
  mcp_fn: McpFn, created_at: Optional[str],
564
591
  kind: Optional[str] = None) -> FundResult:
565
592
  """Step (a): a wallet.json already exists — verify it via strategy_list, never re-fund.
566
- ``kind`` (the recorded marker) is PRESERVED across any in-place backfill so a manual wallet
567
- never loses its marker on a re-run."""
593
+
594
+ The marker is resolved by recorded strategyId AND by wallet ADDRESS against the LIVE strategies
595
+ (K3) — the documented adopt/reuse flow, kept alive for a HAND-AUTHORED marker: an alternate
596
+ address key spelling (``walletAddress`` / ``strategyWalletAddress`` / ``address``), an
597
+ address-only marker, or a wrong/stale strategyId sitting next to a live address all still adopt.
598
+ When the match lands by address, the live strategy's own id is written back (a stale recorded id
599
+ is corrected, never propagated), and the file is NORMALIZED in place to the canonical ``wallet``
600
+ key. ``kind`` (the recorded marker), ``created_at`` and ``budget`` are PRESERVED across any
601
+ in-place backfill so a manual wallet never loses its marker on a re-run. CMP231 remains for the
602
+ genuinely-stale case: no live strategy holds this wallet."""
568
603
  rec_kind = kind if kind is not None else wallet_kind(rec)
569
604
  result_kind = rec_kind or KIND_MANAGED
570
- rec_wallet = str(rec.get("wallet") or "")
605
+ # Resolve the recorded address tolerating hand-authored key spellings (K3); keep the raw
606
+ # canonical-key value separately so we know whether the FILE needs normalizing back to "wallet".
607
+ rec_wallet = _rec_wallet(rec)
608
+ rec_wallet_canonical = str(rec.get("wallet") or "")
571
609
  rec_sid = rec.get("strategyId")
610
+ rec_budget = rec.get("budget", budget) # PRESERVE the funded budget across backfills
611
+ rec_created = rec.get("created_at") or created_at
572
612
  try:
573
613
  strategies = _find_list(mcp_fn("strategy_list", status=LIVE_STATUSES), "strategies")
574
614
  mcp_ok = True
@@ -578,39 +618,61 @@ def _verify_existing(target: str, strategy: str, budget: float, rec: dict, wj_pa
578
618
  except McpError:
579
619
  strategies, mcp_ok = [], False
580
620
 
621
+ # Resolve by recorded strategyId FIRST, then fall back to the wallet ADDRESS (this is the adopt/
622
+ # reuse path K3 must keep alive for a hand-authored marker). When the match lands via the
623
+ # address fallback, the LIVE strategy's own id is authoritative — a stale-but-truthy recorded
624
+ # strategyId must be CORRECTED to it, never propagated into the result or the backfilled file.
581
625
  s = _match_strategy(strategies, rec_sid, rec_wallet)
582
626
  status = str(_strat_status(s) or "").upper() if s else ""
627
+ live_sid = _strat_id(s) if s else None
628
+ real_sid = live_sid or rec_sid
583
629
  addr = str(_strat_wallet(s) or "") if s else rec_wallet
630
+ write_addr = addr or rec_wallet
631
+ # The file drifts from live truth when the canonical "wallet" key is empty/stale (a
632
+ # created-but-not-yet-ACTIVE mint, or a hand-authored alternate-key/address-only marker) or when
633
+ # the recorded strategyId is stale. Any drift → backfill in place, normalizing to the "wallet"
634
+ # key and the correct id while PRESERVING kind/created_at/budget (requirement 3: a legacy-read
635
+ # marker is rewritten to the SAME path it was read from — never a new canonical file).
636
+ needs_backfill = bool(write_addr) and (
637
+ write_addr != rec_wallet_canonical or real_sid != rec_sid
638
+ )
584
639
 
585
640
  if status == "ACTIVE":
586
- # Backfill the wallet address if the recorded one was empty (created-but-not-yet-ACTIVE run).
587
- if addr and addr != rec_wallet:
588
- _write_wallet_json(wj_path, strategy=strategy, wallet=addr,
589
- strategy_id=rec_sid or _strat_id(s), budget=budget,
590
- created_at=rec.get("created_at") or created_at, kind=rec_kind)
641
+ if needs_backfill:
642
+ _write_wallet_json(wj_path, strategy=strategy, wallet=write_addr,
643
+ strategy_id=real_sid, budget=rec_budget,
644
+ created_at=rec_created, kind=rec_kind)
591
645
  return FundResult(
592
- target=target, verdict=FUNDED, strategy=strategy, wallet=addr or rec_wallet,
593
- strategy_id=rec_sid or _strat_id(s), budget=rec.get("budget", budget),
646
+ target=target, verdict=FUNDED, strategy=strategy, wallet=write_addr,
647
+ strategy_id=real_sid, budget=rec_budget,
594
648
  kind=result_kind, message="wallet already funded and ACTIVE (no-op).",
595
649
  )
596
650
  if s is not None and status and status not in CLOSED_STATUSES:
651
+ if needs_backfill:
652
+ _write_wallet_json(wj_path, strategy=strategy, wallet=write_addr,
653
+ strategy_id=real_sid, budget=rec_budget,
654
+ created_at=rec_created, kind=rec_kind)
597
655
  return FundResult(
598
- target=target, verdict=PENDING, strategy=strategy, wallet=addr or rec_wallet,
599
- strategy_id=rec_sid or _strat_id(s), budget=rec.get("budget", budget),
656
+ target=target, verdict=PENDING, strategy=strategy, wallet=write_addr,
657
+ strategy_id=real_sid, budget=rec_budget,
600
658
  kind=result_kind, message=f"wallet still initializing (status {status}).",
601
659
  )
602
660
  if not mcp_ok:
603
661
  # Could not reach MCP to confirm — do NOT declare stale; re-run once reachable.
604
662
  return FundResult(
605
663
  target=target, verdict=PENDING, strategy=strategy, wallet=rec_wallet,
606
- strategy_id=rec_sid, budget=rec.get("budget", budget), kind=result_kind,
664
+ strategy_id=rec_sid, budget=rec_budget, kind=result_kind,
607
665
  message="could not reach MCP to verify the recorded wallet — re-run `composer fund`.",
608
666
  )
609
- # Found closed, or not present in the live set at all → the recorded wallet is stale.
667
+ # No LIVE strategy carries this strategyId OR this wallet address → genuinely stale. (fund
668
+ # resolves by wallet address against your active strategies, so "stale" here means no active
669
+ # strategy holds this wallet — not merely that the recorded strategyId moved on.)
610
670
  return _refused(target, strategy, TeachingError(
611
671
  "CMP231", f"the recorded wallet ({rec_wallet or 'strategyId ' + str(rec_sid)}) is "
612
- f"{status or 'no longer in your active strategies'} — wallet.json is stale.",
613
- f"Its strategy was closed or is gone, so it cannot be installed. To fund a FRESH wallet "
672
+ f"{status or 'not held by any of your active strategies'} — wallet.json is stale.",
673
+ f"fund resolves a marker by its recorded strategyId AND by its wallet address against your "
674
+ f"active strategies (`strategy_list`), so 'stale' means NO active strategy holds this wallet "
675
+ f"— its strategy was closed or is gone, and it cannot be installed. To fund a FRESH wallet "
614
676
  f"for {strategy!r}, delete the stale marker `{wj_path}` and re-run `composer fund`. If you "
615
677
  f"believe it is still open, check MCP `strategy_list` (it may be filtered out as terminal).",
616
678
  Location(),
@@ -295,7 +295,7 @@ def _auto_bind_wallet(target: str, out_dir: Optional[str], base_state_dir: str,
295
295
  # Imported lazily to avoid an import cycle (fund/close import install at module load).
296
296
  from ._box import LIVE_STATUSES, McpCredsMissing, McpError
297
297
  from .close import _find_list, _strat_id, _strat_status, _strat_wallet
298
- from .fund import _match_strategy, _read_wallet_json
298
+ from .fund import _match_strategy, _read_wallet_json, _rec_wallet
299
299
 
300
300
  def _cmp206(msg: str, fix: str) -> tuple[str, TeachingError]:
301
301
  return "", TeachingError("CMP206", msg, fix, Location())
@@ -325,7 +325,9 @@ def _auto_bind_wallet(target: str, out_dir: Optional[str], base_state_dir: str,
325
325
  f"writes {canonical_wj}), then re-run `composer install` to auto-bind it — or pass "
326
326
  f"`--wallet 0x…` explicitly.",
327
327
  )
328
- rec_wallet = str(rec.get("wallet") or "")
328
+ # K3 follow-up: read the address with the SAME tolerant reader fund/update use — a
329
+ # hand-authored marker records it under address/walletAddress/strategyWalletAddress.
330
+ rec_wallet = _rec_wallet(rec)
329
331
  rec_sid = rec.get("strategyId")
330
332
  try:
331
333
  strategies = _find_list(mcp_fn("strategy_list", status=LIVE_STATUSES), "strategies")