@senpi/runtime 3.0.0-dev.runtime-phase-2.20260623210537 → 3.0.0-dev.runtime-phase-2.20260625112547

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.
Files changed (214) hide show
  1. package/README.md +10 -9
  2. package/dist/actions/action-runtime-module.d.ts.map +1 -1
  3. package/dist/actions/action-runtime-module.js +1 -1
  4. package/dist/actions/action-runtime-module.js.map +1 -1
  5. package/dist/actions/decision-engine.d.ts.map +1 -1
  6. package/dist/actions/decision-engine.js +3 -0
  7. package/dist/actions/decision-engine.js.map +1 -1
  8. package/dist/actions/open-position/open-position.action.d.ts.map +1 -1
  9. package/dist/actions/open-position/open-position.action.js +54 -14
  10. package/dist/actions/open-position/open-position.action.js.map +1 -1
  11. package/dist/actions/types.d.ts +10 -2
  12. package/dist/actions/types.d.ts.map +1 -1
  13. package/dist/actions/types.js +8 -0
  14. package/dist/actions/types.js.map +1 -1
  15. package/dist/cli/guide-commands.d.ts.map +1 -1
  16. package/dist/cli/guide-commands.js +80 -74
  17. package/dist/cli/guide-commands.js.map +1 -1
  18. package/dist/cli/senpi-commands.d.ts.map +1 -1
  19. package/dist/cli/senpi-commands.js +0 -108
  20. package/dist/cli/senpi-commands.js.map +1 -1
  21. package/dist/constants/dsl/index.d.ts +2 -0
  22. package/dist/constants/dsl/index.d.ts.map +1 -1
  23. package/dist/constants/dsl/index.js +2 -0
  24. package/dist/constants/dsl/index.js.map +1 -1
  25. package/dist/dsl/config/parse-tiers.d.ts.map +1 -1
  26. package/dist/dsl/config/parse-tiers.js +6 -2
  27. package/dist/dsl/config/parse-tiers.js.map +1 -1
  28. package/dist/dsl/engine-client/config-mapping.d.ts +7 -0
  29. package/dist/dsl/engine-client/config-mapping.d.ts.map +1 -1
  30. package/dist/dsl/engine-client/config-mapping.js +16 -2
  31. package/dist/dsl/engine-client/config-mapping.js.map +1 -1
  32. package/dist/dsl/events/handlers.d.ts +18 -2
  33. package/dist/dsl/events/handlers.d.ts.map +1 -1
  34. package/dist/dsl/events/handlers.js +302 -213
  35. package/dist/dsl/events/handlers.js.map +1 -1
  36. package/dist/dsl/monitor/create-monitor.d.ts +15 -0
  37. package/dist/dsl/monitor/create-monitor.d.ts.map +1 -1
  38. package/dist/dsl/monitor/create-monitor.js +46 -9
  39. package/dist/dsl/monitor/create-monitor.js.map +1 -1
  40. package/dist/dsl/monitor/process-one-position.d.ts.map +1 -1
  41. package/dist/dsl/monitor/process-one-position.js +138 -77
  42. package/dist/dsl/monitor/process-one-position.js.map +1 -1
  43. package/dist/dsl/plugin/index.d.ts +16 -1
  44. package/dist/dsl/plugin/index.d.ts.map +1 -1
  45. package/dist/dsl/plugin/index.js +35 -4
  46. package/dist/dsl/plugin/index.js.map +1 -1
  47. package/dist/dsl/plugin/options.d.ts +18 -0
  48. package/dist/dsl/plugin/options.d.ts.map +1 -1
  49. package/dist/index.d.ts.map +1 -1
  50. package/dist/index.js +4 -0
  51. package/dist/index.js.map +1 -1
  52. package/dist/runtime/create-scanner-module.d.ts +4 -3
  53. package/dist/runtime/create-scanner-module.d.ts.map +1 -1
  54. package/dist/runtime/create-scanner-module.js +14 -5
  55. package/dist/runtime/create-scanner-module.js.map +1 -1
  56. package/dist/runtime/create-scanner-providers-for-run.d.ts.map +1 -1
  57. package/dist/runtime/create-scanner-providers-for-run.js +12 -1
  58. package/dist/runtime/create-scanner-providers-for-run.js.map +1 -1
  59. package/dist/runtime/load-runtime-config.js +1 -1
  60. package/dist/runtime/load-runtime-config.js.map +1 -1
  61. package/dist/runtime/map-strategies-registration.js +7 -8
  62. package/dist/runtime/map-strategies-registration.js.map +1 -1
  63. package/dist/runtime/map-strategies.d.ts.map +1 -1
  64. package/dist/runtime/map-strategies.js +12 -3
  65. package/dist/runtime/map-strategies.js.map +1 -1
  66. package/dist/runtime/reconcile-decide.d.ts +73 -0
  67. package/dist/runtime/reconcile-decide.d.ts.map +1 -0
  68. package/dist/runtime/reconcile-decide.js +186 -0
  69. package/dist/runtime/reconcile-decide.js.map +1 -0
  70. package/dist/runtime/reconcile-run.d.ts +62 -0
  71. package/dist/runtime/reconcile-run.d.ts.map +1 -0
  72. package/dist/runtime/reconcile-run.js +173 -0
  73. package/dist/runtime/reconcile-run.js.map +1 -0
  74. package/dist/runtime/reconcile-state.d.ts +44 -0
  75. package/dist/runtime/reconcile-state.d.ts.map +1 -1
  76. package/dist/runtime/reconcile-state.js +80 -44
  77. package/dist/runtime/reconcile-state.js.map +1 -1
  78. package/dist/runtime/risk-guard.d.ts.map +1 -1
  79. package/dist/runtime/risk-guard.js +17 -12
  80. package/dist/runtime/risk-guard.js.map +1 -1
  81. package/dist/runtime/runtime-schema.d.ts +2912 -1052
  82. package/dist/runtime/runtime-schema.d.ts.map +1 -1
  83. package/dist/runtime/runtime-schema.js +136 -42
  84. package/dist/runtime/runtime-schema.js.map +1 -1
  85. package/dist/runtime/runtime.d.ts +26 -0
  86. package/dist/runtime/runtime.d.ts.map +1 -1
  87. package/dist/runtime/runtime.js +254 -8
  88. package/dist/runtime/runtime.js.map +1 -1
  89. package/dist/runtime/scanner-registry.d.ts +1 -1
  90. package/dist/runtime/scanner-registry.d.ts.map +1 -1
  91. package/dist/runtime/scanner-registry.js +3 -12
  92. package/dist/runtime/scanner-registry.js.map +1 -1
  93. package/dist/runtime/supervisor/long-running-supervisor.d.ts.map +1 -1
  94. package/dist/runtime/supervisor/long-running-supervisor.js +1 -1
  95. package/dist/runtime/supervisor/long-running-supervisor.js.map +1 -1
  96. package/dist/runtime/supervisor/orphan-sweep.js +1 -1
  97. package/dist/runtime/supervisor/orphan-sweep.js.map +1 -1
  98. package/dist/runtime/supervisor/supervisor-utils.d.ts +1 -1
  99. package/dist/runtime/supervisor/supervisor-utils.d.ts.map +1 -1
  100. package/dist/runtime/supervisor/supervisor-utils.js +1 -1
  101. package/dist/runtime/supervisor/supervisor-utils.js.map +1 -1
  102. package/dist/runtime-api/intake/acceptance-record.d.ts +10 -0
  103. package/dist/runtime-api/intake/acceptance-record.d.ts.map +1 -1
  104. package/dist/runtime-api/intake/acceptance-record.js +28 -0
  105. package/dist/runtime-api/intake/acceptance-record.js.map +1 -1
  106. package/dist/runtime-api/intake/intake-mount.d.ts.map +1 -1
  107. package/dist/runtime-api/intake/intake-mount.js +28 -36
  108. package/dist/runtime-api/intake/intake-mount.js.map +1 -1
  109. package/dist/runtime-api/intake/process-signal-post.d.ts +20 -1
  110. package/dist/runtime-api/intake/process-signal-post.d.ts.map +1 -1
  111. package/dist/runtime-api/intake/process-signal-post.js +57 -6
  112. package/dist/runtime-api/intake/process-signal-post.js.map +1 -1
  113. package/dist/runtime-api/intake/scanner-launcher.d.ts +18 -4
  114. package/dist/runtime-api/intake/scanner-launcher.d.ts.map +1 -1
  115. package/dist/runtime-api/intake/scanner-launcher.js +24 -3
  116. package/dist/runtime-api/intake/scanner-launcher.js.map +1 -1
  117. package/dist/runtime-api/intake/types.d.ts +16 -0
  118. package/dist/runtime-api/intake/types.d.ts.map +1 -1
  119. package/dist/runtime-api/routes/signals.schema.d.ts +4 -0
  120. package/dist/runtime-api/routes/signals.schema.d.ts.map +1 -1
  121. package/dist/runtime-api/routes/signals.schema.js +12 -0
  122. package/dist/runtime-api/routes/signals.schema.js.map +1 -1
  123. package/dist/scanners/external/scaffold/__main__.py +65 -24
  124. package/dist/scanners/external/scaffold/ctx.py +78 -0
  125. package/dist/scanners/external/scaffold/delivery.py +56 -22
  126. package/dist/scanners/external/scaffold/entrypoint_loader.py +42 -48
  127. package/dist/scanners/external/scaffold/journal.py +13 -2
  128. package/dist/scanners/external/scaffold/requirements.txt +11 -0
  129. package/dist/scanners/external/scaffold/scaffold.py +159 -128
  130. package/dist/scanners/external/scaffold/senpi_mcp.py +250 -0
  131. package/dist/scanners/external/scaffold/signal_envelope.py +177 -34
  132. package/dist/scanners/external/scaffold/state_manager.py +175 -0
  133. package/dist/scanners/external-scanner-receiver.d.ts +6 -0
  134. package/dist/scanners/external-scanner-receiver.d.ts.map +1 -1
  135. package/dist/scanners/external-scanner-receiver.js +14 -1
  136. package/dist/scanners/external-scanner-receiver.js.map +1 -1
  137. package/dist/scripts/run-dsl-standalone-live.d.ts +1 -1
  138. package/dist/scripts/run-dsl-standalone-live.js +5 -3
  139. package/dist/scripts/run-dsl-standalone-live.js.map +1 -1
  140. package/dist/senpi/client.d.ts +37 -4
  141. package/dist/senpi/client.d.ts.map +1 -1
  142. package/dist/senpi/client.js +101 -24
  143. package/dist/senpi/client.js.map +1 -1
  144. package/dist/state/state-manager.d.ts +17 -0
  145. package/dist/state/state-manager.d.ts.map +1 -1
  146. package/dist/state/state-manager.js +50 -4
  147. package/dist/state/state-manager.js.map +1 -1
  148. package/dist/strategy/strategy-state.d.ts +9 -1
  149. package/dist/strategy/strategy-state.d.ts.map +1 -1
  150. package/dist/strategy/strategy-state.js +62 -9
  151. package/dist/strategy/strategy-state.js.map +1 -1
  152. package/dist/tracking/constants.d.ts.map +1 -1
  153. package/dist/tracking/constants.js +2 -0
  154. package/dist/tracking/constants.js.map +1 -1
  155. package/dist/types/dsl/index.d.ts +1 -1
  156. package/dist/types/dsl/index.d.ts.map +1 -1
  157. package/dist/types/scanner.d.ts +7 -0
  158. package/dist/types/scanner.d.ts.map +1 -1
  159. package/dist/types/strategy.d.ts +3 -1
  160. package/dist/types/strategy.d.ts.map +1 -1
  161. package/dist/utils/keyed-mutex.d.ts +37 -0
  162. package/dist/utils/keyed-mutex.d.ts.map +1 -0
  163. package/dist/utils/keyed-mutex.js +77 -0
  164. package/dist/utils/keyed-mutex.js.map +1 -0
  165. package/examples/strategies/README.md +17 -12
  166. package/examples/strategies/iguana/README.md +135 -0
  167. package/examples/strategies/iguana/recipe.yaml +137 -0
  168. package/examples/strategies/iguana/scanners/scan.py +243 -0
  169. package/examples/strategies/iguana/scanners/scoring.py +152 -0
  170. package/examples/strategies/raptor/README.md +156 -0
  171. package/examples/strategies/raptor/recipe.yaml +154 -0
  172. package/examples/strategies/raptor/scanners/scan.py +349 -0
  173. package/examples/strategies/raptor/scanners/scoring.py +452 -0
  174. package/examples/strategies/spider/README.md +89 -0
  175. package/examples/strategies/spider/scalp/recipe.yaml +129 -0
  176. package/examples/strategies/spider/scalp/scanners/scan.py +219 -0
  177. package/examples/strategies/spider/scalp/scanners/scoring.py +179 -0
  178. package/examples/strategies/spider/swing/recipe.yaml +137 -0
  179. package/examples/strategies/spider/swing/scanners/scan.py +334 -0
  180. package/examples/strategies/spider/swing/scanners/scoring.py +184 -0
  181. package/examples/strategies/turbine/README.md +166 -0
  182. package/examples/strategies/turbine/recipe-runners.yaml +123 -0
  183. package/examples/strategies/turbine/recipe-volume.yaml +129 -0
  184. package/examples/strategies/turbine/scanners/scan.py +283 -0
  185. package/examples/strategies/turbine/scanners/scoring.py +190 -0
  186. package/package.json +1 -1
  187. package/dist/scanners/external/liquidation-watchdog/README.md +0 -107
  188. package/dist/scanners/external/liquidation-watchdog/liquidation-watchdog.yaml +0 -48
  189. package/dist/scanners/external/liquidation-watchdog/producer.mjs +0 -336
  190. package/dist/scanners/external/momentum/README.md +0 -148
  191. package/dist/scanners/external/momentum/momentum.yaml +0 -116
  192. package/dist/scanners/external/momentum/producer.mjs +0 -251
  193. package/dist/scanners/external/shared/ingest.mjs +0 -112
  194. package/examples/README.md +0 -89
  195. package/examples/scanners-consumer-quickstart.mjs +0 -134
  196. package/examples/scanners-emerging-movers-v4.mjs +0 -287
  197. package/examples/scanners-liquidation-watchdog.mjs +0 -341
  198. package/examples/scanners-live-mcp.mjs +0 -208
  199. package/examples/scanners-multi-strategy.mjs +0 -165
  200. package/examples/scanners-position-tracker-e2e.mjs +0 -225
  201. package/examples/scanners-scheduled-events.mjs +0 -96
  202. package/examples/scanners-senpi-provider-client.mjs +0 -182
  203. package/examples/scanners-single-strategy.mjs +0 -198
  204. package/examples/strategies/dsl-showcase.yaml +0 -68
  205. package/examples/strategies/dsl.yaml +0 -51
  206. package/examples/strategies/fox-minimal-loosened.yaml +0 -240
  207. package/examples/strategies/fox-minimal.yaml +0 -242
  208. package/examples/strategies/minimal.yaml +0 -46
  209. package/examples/strategies/momentum/how-it-works.md +0 -48
  210. package/examples/strategies/momentum/momentum-guarded.yaml +0 -161
  211. package/examples/strategies/risk-showcase.yaml +0 -93
  212. package/examples/strategies/viper.yaml +0 -67
  213. package/examples/strategies/wolf.yaml +0 -72
  214. package/examples/yamls/sample-with-dsl-and-emerging-movers.yaml +0 -123
@@ -20,7 +20,7 @@ class ScanEntrypointError(Exception):
20
20
 
21
21
 
22
22
  class ScanSignatureError(Exception):
23
- """scan's call shape is incompatible with scaffold(params, state[, client])."""
23
+ """scan's call shape is incompatible with scan(inputs, ctx)."""
24
24
 
25
25
 
26
26
  def add_path_to_sys_path(path: str) -> None:
@@ -32,16 +32,22 @@ def add_path_to_sys_path(path: str) -> None:
32
32
  def import_entrypoint(path: str, entrypoint: str):
33
33
  """Put `path` on sys.path, then import `entrypoint` by module name.
34
34
 
35
+ The recipe expresses `entrypoint` as a FILENAME (e.g. `scan.py`, the shape the
36
+ B1 launcher writes verbatim into the launch config and the §4a entrypoint FS
37
+ check validates). importlib needs a MODULE name, so a trailing `.py` is
38
+ stripped here — the only place the filename<->module-name bridge lives.
39
+
35
40
  Raises ScanEntrypointError on any import/syntax/missing-sibling failure,
36
41
  chaining the underlying exception.
37
42
  """
38
43
  add_path_to_sys_path(path)
44
+ module_name = entrypoint[:-3] if entrypoint.endswith(".py") else entrypoint
39
45
  try:
40
46
  # Force re-import when the module has been previously imported so that
41
47
  # tests can write fresh modules to tmp_path without cache collisions.
42
- if entrypoint in sys.modules:
43
- del sys.modules[entrypoint]
44
- return importlib.import_module(entrypoint)
48
+ if module_name in sys.modules:
49
+ del sys.modules[module_name]
50
+ return importlib.import_module(module_name)
45
51
  except Exception as exc:
46
52
  raise ScanEntrypointError(
47
53
  f"Failed to import entrypoint '{entrypoint}' from '{path}': {exc}"
@@ -67,19 +73,25 @@ def locate_scan(module) -> Callable:
67
73
 
68
74
 
69
75
  def verify_scan_signature(scan: Callable) -> None:
70
- """Verify scan's call shape is compatible with scan(params, state[, client]).
76
+ """Verify scan's call shape is compatible with the v2 contract scan(inputs, ctx).
77
+
78
+ v2 calls scan with EXACTLY two positionals (inputs, ctx). A signature must be
79
+ callable that way.
71
80
 
72
81
  Accepted shapes:
73
- scan(params, state) — 2 required
74
- scan(params, state, client) — 3 required
75
- scan(params, state, client=None) — 2 required, 1 optional
76
- scan(params, state, *args) — variadic absorbs client
82
+ scan(inputs, ctx) — 2 required (the canonical shape)
83
+ scan(inputs, ctx, extra=None) — 2 required + defaulted extra
84
+ scan(inputs, ctx, *args) — variadic absorbs nothing extra here
77
85
  scan(*args, **kwargs) — fully variadic, accepts anything
78
86
 
79
87
  Rejected shapes:
80
88
  scan() — 0 required
81
- scan(params) — 1 required
82
- scan(a, b, c, d) — >=4 fixed required
89
+ scan(inputs) — 1 required
90
+ scan(params, state, client) — 3 required (the OLD v1 client shape)
91
+ scan(a, b, c, d) — >=4 required
92
+
93
+ The contract is now EXACTLY two positionals: a 3rd REQUIRED positional cannot
94
+ be satisfied by scan(inputs, ctx) and is rejected.
83
95
 
84
96
  Raises ScanSignatureError naming the found arity on mismatch.
85
97
  """
@@ -94,57 +106,39 @@ def verify_scan_signature(scan: Callable) -> None:
94
106
  has_var_positional = any(
95
107
  p.kind == inspect.Parameter.VAR_POSITIONAL for p in params
96
108
  )
97
- has_var_keyword = any(
98
- p.kind == inspect.Parameter.VAR_KEYWORD for p in params
99
- )
100
-
101
- # A *args/**kwargs-only signature accepts any positional call.
102
- if has_var_positional or has_var_keyword:
103
- # Check that it actually accepts at least 2 positional args (i.e., no
104
- # required positional params ahead of *args that block the call).
105
- required_before_var = [
106
- p for p in params
107
- if p.kind in (
108
- inspect.Parameter.POSITIONAL_ONLY,
109
- inspect.Parameter.POSITIONAL_OR_KEYWORD,
110
- )
111
- and p.default is inspect.Parameter.empty
112
- ]
113
- # If there are >=4 required positionals even WITH *args, reject.
114
- if len(required_before_var) >= 4:
115
- raise ScanSignatureError(
116
- f"scan requires {len(required_before_var)} fixed positional params; "
117
- f"expected 2 or 3 (found arity: {len(required_before_var)})."
118
- )
119
- # Otherwise, variadic accepts our call.
120
- return
121
109
 
122
- # Count fixed positional params (POSITIONAL_ONLY or POSITIONAL_OR_KEYWORD).
123
- fixed_positional = [
110
+ # Required (non-defaulted) fixed positional params.
111
+ required_fixed = [
124
112
  p for p in params
125
113
  if p.kind in (
126
114
  inspect.Parameter.POSITIONAL_ONLY,
127
115
  inspect.Parameter.POSITIONAL_OR_KEYWORD,
128
116
  )
117
+ and p.default is inspect.Parameter.empty
129
118
  ]
130
- required_fixed = [
131
- p for p in fixed_positional
132
- if p.default is inspect.Parameter.empty
133
- ]
134
-
135
119
  n_required = len(required_fixed)
136
- n_fixed = len(fixed_positional)
120
+
121
+ # A *args (or *args/**kwargs) signature accepts the 2-positional call as long
122
+ # as it has no MORE than 2 required fixed positionals ahead of *args.
123
+ if has_var_positional:
124
+ if n_required > 2:
125
+ raise ScanSignatureError(
126
+ f"scan requires {n_required} fixed positional params; the v2 "
127
+ f"contract calls scan(inputs, ctx) — exactly 2. Found arity: {n_required}."
128
+ )
129
+ return
137
130
 
138
131
  if n_required < 2:
139
132
  raise ScanSignatureError(
140
- f"scan has {n_required} required positional param(s); "
141
- f"expected at least 2 (params, state). Found arity: {n_required}."
133
+ f"scan has {n_required} required positional param(s); the v2 contract "
134
+ f"requires exactly 2 (inputs, ctx). Found arity: {n_required}."
142
135
  )
143
136
 
144
- if n_required >= 4:
137
+ if n_required > 2:
145
138
  raise ScanSignatureError(
146
- f"scan has {n_required} required positional params; "
147
- f"expected at most 3 (params, state, client). Found arity: {n_required}."
139
+ f"scan has {n_required} required positional params; the v2 contract "
140
+ f"calls scan(inputs, ctx) — exactly 2. A 3rd required positional "
141
+ f"(the old v1 client shape) is no longer accepted. Found arity: {n_required}."
148
142
  )
149
143
 
150
144
 
@@ -90,8 +90,19 @@ class Journal:
90
90
  # ------------------------------------------------------------------
91
91
 
92
92
  def record_produced(self, signal: dict, *, now_ms: int) -> str:
93
- """Write a 'produced' line and enqueue the signal. Returns entry id."""
94
- entry_id = uuid.uuid4().hex
93
+ """Write a 'produced' line and enqueue the signal. Returns entry id.
94
+
95
+ The entry id IS the wire `signal_id`: the journal stamps `signal_id` onto
96
+ the persisted/queued signal so the POSTed signal carries the dedup key, and
97
+ a replay re-uses the SAME id (no fresh id minted on restart). If the signal
98
+ already carries a `signal_id` (the delivery sink threaded the eid in via the
99
+ envelope), that id is re-used as the entry id; otherwise one is minted.
100
+ """
101
+ existing = signal.get("signal_id")
102
+ entry_id = existing if isinstance(existing, str) and existing else uuid.uuid4().hex
103
+ # Stamp the id onto the wire signal so the persisted produced line and the
104
+ # POSTed body both carry signal_id == entry id.
105
+ signal["signal_id"] = entry_id
95
106
  asset = signal.get("asset", "")
96
107
  valid_until = signal.get("valid_until", 0)
97
108
 
@@ -0,0 +1,11 @@
1
+ # Runtime dependencies for the scaffold harness (src/scanners/external/scaffold/).
2
+ #
3
+ # The scaffold is shipped to the scanner box and run via `python3 -m scaffold`.
4
+ # `mcp` (the official Model Context Protocol Python SDK) backs ctx.senpi_mcp:
5
+ # `streamablehttp_client` + a single persistent `ClientSession` (see senpi_mcp.py).
6
+ # It is LAZY-IMPORTED at first use, so the package still imports where `mcp` is
7
+ # absent (e.g. unit tests inject a fake SDK) — install this on the box that runs
8
+ # scanners that call ctx.senpi_mcp.
9
+ #
10
+ # `mcp[cli]` is NOT needed — only the client transport + ClientSession surface.
11
+ mcp>=1.9.0
@@ -1,11 +1,14 @@
1
- """Scaffold core loop.
1
+ """Scaffold core loop (v2).
2
2
 
3
3
  run_scaffold(...) loads the author's entrypoint, finds and validates scan(),
4
- then drives the interval loop: time-box per tick, graceful stop on SIGTERM/SIGINT,
5
- parent-death detection via ppid, state in/out, candidates to sink.
6
-
7
- `path` is a scaffold concern only — it is put on sys.path before import and
8
- never passed to scan(). scan() is called as scan(params, state, client=None).
4
+ builds the ScanContext, then drives the interval loop: time-box per tick, graceful
5
+ stop on SIGTERM/SIGINT, parent-death detection via ppid, transactional state via
6
+ ctx.state, candidates to sink.
7
+
8
+ `path` is a scaffold concern only — it is put on sys.path before import and never
9
+ passed to scan(). scan() is called as scan(inputs, ctx) — 2-arg, sync, returning a
10
+ plain list[dict]. State mutates ONLY via ctx.state (commit on a clean tick, rollback
11
+ on error/timeout/malformed/failed-persist — transactional discard wins on failure).
9
12
  """
10
13
 
11
14
  import inspect
@@ -14,6 +17,7 @@ import time
14
17
  import threading
15
18
  from typing import Any, Callable, Optional
16
19
 
20
+ from .ctx import ScanContext
17
21
  from .entrypoint_loader import (
18
22
  ScanEntrypointError,
19
23
  ScanSignatureError,
@@ -29,40 +33,44 @@ from .loop_primitives import (
29
33
  _interruptible_sleep,
30
34
  log_event,
31
35
  )
32
- from .scaffold_state import read_state, write_state
36
+ from .state_manager import StateManager
33
37
 
34
38
 
35
- def _call_scan(scan: Callable, params: dict, state: Any, client: Any) -> tuple:
36
- """Call scan with the right arity.
39
+ def _build_senpi_mcp() -> Any:
40
+ """Construct the MCP client when creds are present; else return None.
37
41
 
38
- Tries scan(params, state, client) first; if that raises TypeError because
39
- scan only accepts 2 positional args, falls back to scan(params, state).
40
- This lets 2-arg and 3-arg (and variadic) scan functions all work uniformly.
42
+ Creds come from the launch env (SENPI_API_KEY / SENPI_MCP_URL), injected by
43
+ the launcher regardless of how the operator supplied them. With neither set,
44
+ return None so the no-creds path does not crash — the client is loud at FIRST
45
+ USE (see scaffold.senpi_mcp). Reference the class through the module attribute
46
+ so a test stub patched onto scaffold.senpi_mcp is honored.
41
47
  """
42
- sig = None
43
- try:
44
- sig = inspect.signature(scan)
45
- except (ValueError, TypeError):
46
- pass
47
-
48
- if sig is not None:
49
- params_list = list(sig.parameters.values())
50
- has_var_positional = any(
51
- p.kind == inspect.Parameter.VAR_POSITIONAL for p in params_list
52
- )
53
- fixed_positional = [
54
- p for p in params_list
55
- if p.kind in (
56
- inspect.Parameter.POSITIONAL_ONLY,
57
- inspect.Parameter.POSITIONAL_OR_KEYWORD,
58
- )
59
- ]
60
- # If only 2 fixed positional params (no *args), call 2-arg form.
61
- if not has_var_positional and len(fixed_positional) == 2:
62
- return scan(params, state)
48
+ api_key = os.environ.get("SENPI_API_KEY") or None
49
+ mcp_url = os.environ.get("SENPI_MCP_URL") or None
50
+ if not api_key and not mcp_url:
51
+ return None
52
+
53
+ from . import senpi_mcp as senpi_mcp_mod
54
+
55
+ # Read-only-by-default producer MCP boundary: the set of MUTATION tools
56
+ # producers are permitted to call is owned SOLELY by the scaffold-source
57
+ # PRODUCER_WRITE_ALLOWLIST constant (EMPTY by default → all mutations
58
+ # blocked). It is edited by the scaffold maintainer in senpi_mcp.py — it is
59
+ # NOT operator/env-configurable and NOT reachable by authored producer code.
60
+ return senpi_mcp_mod.SenpiMcpClient(
61
+ api_key=api_key,
62
+ mcp_url=mcp_url,
63
+ write_allowlist=senpi_mcp_mod.PRODUCER_WRITE_ALLOWLIST,
64
+ )
63
65
 
64
- # Default: 3-arg call (variadic or 3-param).
65
- return scan(params, state, client)
66
+
67
+ def _call_scan(scan: Callable, inputs: dict, ctx: Any):
68
+ """Call scan(inputs, ctx) — the v2 2-arg contract.
69
+
70
+ There is no `client` and no 3-arg path. scan returns a plain list[dict];
71
+ validation of that return happens in the tick loop.
72
+ """
73
+ return scan(inputs, ctx)
66
74
 
67
75
 
68
76
  def run_scaffold(
@@ -71,13 +79,17 @@ def run_scaffold(
71
79
  *,
72
80
  interval: float,
73
81
  timeout_seconds: Optional[float] = None,
74
- params: Optional[dict] = None,
82
+ inputs: Optional[dict] = None,
75
83
  state_path: Optional[str] = None,
84
+ scanner_name: str,
85
+ wallet: str,
86
+ default_signal_validity_seconds: int,
87
+ state_history_max_count: int = 0,
76
88
  sink: Optional[Callable] = None,
77
89
  max_ticks: Optional[int] = None,
78
90
  install_signal_handlers: bool = True,
79
91
  ) -> int:
80
- """Load the author's entrypoint and run its scan() on an interval loop.
92
+ """Load the author's entrypoint and run its scan() on an interval loop (v2).
81
93
 
82
94
  Args:
83
95
  path: Directory added to sys.path before import. Never passed to scan().
@@ -86,9 +98,15 @@ def run_scaffold(
86
98
  timeout_seconds:
87
99
  Per-tick wall-clock budget. Defaults to `interval`. Must be > 0.
88
100
  Enforced via SIGALRM on platforms that support it.
89
- params: Tunables dict passed as scan()'s first argument. Default {}.
90
- state_path: Path to the JSON state file. Reads on start, written after
91
- each clean tick. None means no persistence.
101
+ inputs: Tunables dict passed as scan()'s first argument. Default {}.
102
+ state_path: Path to the JSON state file. None means no persistence.
103
+ scanner_name: This scanner's recipe name (read-only ctx metadata).
104
+ wallet: Strategy wallet (read-only ctx metadata).
105
+ default_signal_validity_seconds:
106
+ Fallback signal TTL (seconds); threaded to the envelope path.
107
+ state_history_max_count:
108
+ Bound on the persisted state series. 0/unset = history disabled
109
+ (ctx.state.append raises).
92
110
  sink: Callable(candidates) invoked after each clean tick. Default no-op.
93
111
  max_ticks: Stop after this many ticks (for tests). None = unbounded.
94
112
  install_signal_handlers:
@@ -112,14 +130,41 @@ def run_scaffold(
112
130
  if timeout_seconds <= 0:
113
131
  raise ValueError(f"timeout_seconds must be > 0; got {timeout_seconds!r}")
114
132
 
115
- if params is None:
116
- params = {}
133
+ if inputs is None:
134
+ inputs = {}
117
135
 
118
136
  # Import and locate scan — any failure raises before tick 1.
119
137
  module = import_entrypoint(path, entrypoint)
120
138
  scan = locate_scan(module)
121
139
  verify_scan_signature(scan)
122
140
 
141
+ # ---------- ctx + state setup ----------
142
+
143
+ # The StateManager (B2) is the persisted series. It reads any prior state from
144
+ # disk on construction. state_history_max_count 0/unset => history disabled
145
+ # (append raises). It is passed into ctx AS-IS (never wrapped).
146
+ state = StateManager(state_path, state_history_max_count) if state_path is not None \
147
+ else StateManager(None, state_history_max_count)
148
+
149
+ # ctx.interval_seconds is the integer-seconds cadence (design §2.1). The loop's
150
+ # `interval` may be a sub-second float in tests; expose the int seconds when it
151
+ # is whole, else the float as-is.
152
+ interval_seconds = int(interval) if float(interval).is_integer() else interval
153
+
154
+ # B5: fill ctx.senpi_mcp when MCP creds are present (resolved at launch from
155
+ # SENPI_API_KEY / SENPI_MCP_URL). With no creds the slot stays None and launch
156
+ # must NOT crash — the client is loud at FIRST USE, not at construction. Import
157
+ # is deferred so a stub patched onto scaffold.senpi_mcp in tests is honored.
158
+ senpi_mcp = _build_senpi_mcp()
159
+
160
+ ctx = ScanContext(
161
+ state=state,
162
+ wallet=wallet,
163
+ scanner_name=scanner_name,
164
+ interval_seconds=interval_seconds,
165
+ senpi_mcp=senpi_mcp,
166
+ )
167
+
123
168
  # ---------- Lifecycle setup ----------
124
169
 
125
170
  stop_event = threading.Event()
@@ -128,24 +173,17 @@ def run_scaffold(
128
173
 
129
174
  start_ppid = os.getppid()
130
175
 
131
- # Read initial state — corrupt/missing -> None (logged inside read_state).
132
- state = read_state(state_path) if state_path is not None else None
133
-
134
176
  log_event(
135
177
  "scaffold_started",
136
178
  path=path,
137
179
  entrypoint=entrypoint,
138
180
  interval=interval,
139
181
  timeout_seconds=timeout_seconds,
182
+ scanner_name=scanner_name,
140
183
  max_ticks=max_ticks,
141
184
  )
142
185
 
143
- client = None # placeholder; will be the real data handle in a later unit
144
186
  tick_count = 0
145
- # True once a state write has failed and we have escalated to /errors;
146
- # cleared when a write next succeeds. Throttles the escalation to one
147
- # post per failure run (a persistent failure would otherwise post every tick).
148
- state_persist_degraded = False
149
187
 
150
188
  try:
151
189
  while not stop_event.is_set():
@@ -159,40 +197,28 @@ def run_scaffold(
159
197
  tick_started_at = time.time()
160
198
  tick_status = "ok"
161
199
  tick_error = None
162
- candidates = []
163
- new_state = state # default: keep current state on error
200
+ candidates: list = []
164
201
 
165
202
  armed = False
166
203
  try:
167
204
  armed = _arm_tick_alarm(timeout_seconds)
168
205
  try:
169
- result = _call_scan(scan, params, state, client)
206
+ result = _call_scan(scan, inputs, ctx)
170
207
 
171
- # Validate the return value: must be a 2-tuple.
172
- if (
173
- not isinstance(result, tuple)
174
- or len(result) != 2
175
- ):
208
+ # v2 return contract: a PLAIN list[dict]. Anything else is
209
+ # malformed (a tuple — the old v1 shape — included).
210
+ if not isinstance(result, list):
176
211
  tick_status = "malformed"
177
212
  log_event(
178
213
  "scaffold_tick_malformed",
179
214
  tick=tick_count,
180
- reason=f"scan() returned {type(result).__name__!r}, expected (candidates, new_state) tuple",
215
+ reason=(
216
+ f"scan() returned {type(result).__name__!r}; expected a "
217
+ f"plain list[dict] (the v1 (candidates, state) tuple is gone)"
218
+ ),
181
219
  )
182
220
  else:
183
- raw_candidates, returned_state = result
184
- # Validate that candidates is iterable.
185
- try:
186
- candidates = list(raw_candidates)
187
- except TypeError:
188
- tick_status = "malformed"
189
- log_event(
190
- "scaffold_tick_malformed",
191
- tick=tick_count,
192
- reason=f"candidates is not iterable (got {type(raw_candidates).__name__!r})",
193
- )
194
- else:
195
- new_state = returned_state
221
+ candidates = result
196
222
 
197
223
  except _TickTimeout as exc:
198
224
  tick_status = "timeout"
@@ -223,48 +249,40 @@ def run_scaffold(
223
249
 
224
250
  duration_ms = int((time.time() - tick_started_at) * 1000)
225
251
 
226
- # --- Clean tick: persist (best-effort), advance state, deliver ---
252
+ # --- Clean tick: commit ctx.state, deliver candidates ---
227
253
  if tick_status == "ok":
228
- # Disk persistence is best-effort: advance the in-memory state
229
- # regardless of the write result, so the next tick never
230
- # recomputes from stale state (which could re-deliver the same
231
- # signal). But a persist failure is almost always persistent
232
- # (unserialisable state, read-only dir, full disk), and the
233
- # un-persisted ticks are LOST on the next restart — silently
234
- # degrading any scanner that decides on accumulated history.
235
- # So escalate it like a tick error: post to /errors once on the
236
- # first failure (throttled while it keeps failing) and clear
237
- # when a write next succeeds.
238
- if state_path is not None:
239
- if write_state(state_path, new_state):
240
- if state_persist_degraded:
241
- state_persist_degraded = False
242
- log_event("scaffold_state_persist_recovered", tick=tick_count)
243
- else:
244
- log_event("scaffold_state_persist_failed", tick=tick_count)
245
- if not state_persist_degraded:
246
- state_persist_degraded = True
247
- if sink is not None and hasattr(sink, "post_error"):
248
- try:
249
- sink.post_error({
250
- "type": "state_persist_failed",
251
- "tick": tick_count,
252
- "message": (
253
- "scanner state could not be persisted; "
254
- "accumulated history will be lost on restart"
255
- ),
256
- })
257
- except Exception as perr: # noqa: BLE001
258
- log_event(
259
- "scaffold_post_error_failed",
260
- tick=tick_count,
261
- error_type=type(perr).__name__,
262
- error=str(perr),
263
- )
264
- state = new_state
265
-
266
- # Hand candidates to the delivery sink.
267
- if sink is not None:
254
+ # Transactional commit. A failed persist RAISES (StateManager
255
+ # enforces it) and does NOT advance the committed snapshot — so
256
+ # the next tick recomputes from the un-advanced state. On a
257
+ # commit failure we roll back and treat the tick as failed
258
+ # (discard wins on failed-persist; no advance-on-persist-failure).
259
+ committed = True
260
+ try:
261
+ ctx.state.commit()
262
+ except Exception as commit_exc: # noqa: BLE001
263
+ committed = False
264
+ ctx.state.rollback()
265
+ log_event(
266
+ "scaffold_state_persist_failed",
267
+ tick=tick_count,
268
+ error_type=type(commit_exc).__name__,
269
+ error=str(commit_exc),
270
+ )
271
+ if sink is not None and hasattr(sink, "post_error"):
272
+ _post_error_safe(
273
+ sink,
274
+ {
275
+ "type": "state_persist_failed",
276
+ "tick": tick_count,
277
+ "message": (
278
+ "scanner state could not be persisted; "
279
+ "tick discarded, state not advanced"
280
+ ),
281
+ },
282
+ tick_count,
283
+ )
284
+
285
+ if committed and sink is not None:
268
286
  try:
269
287
  sink(candidates)
270
288
  except Exception as sink_exc: # noqa: BLE001
@@ -275,18 +293,14 @@ def run_scaffold(
275
293
  error=str(sink_exc),
276
294
  )
277
295
  else:
278
- # Non-ok tick (error / timeout / malformed): report it to the
279
- # runtime's errors endpoint via the sink, when supported.
296
+ # Non-ok tick (error / timeout / malformed): roll back the
297
+ # working buffer (discard the tick's mutations — state not
298
+ # advanced) and report to /errors via the sink, when supported.
299
+ ctx.state.rollback()
280
300
  if sink is not None and hasattr(sink, "post_error"):
281
- try:
282
- sink.post_error(tick_error or {"type": tick_status})
283
- except Exception as perr: # noqa: BLE001
284
- log_event(
285
- "scaffold_post_error_failed",
286
- tick=tick_count,
287
- error_type=type(perr).__name__,
288
- error=str(perr),
289
- )
301
+ _post_error_safe(
302
+ sink, tick_error or {"type": tick_status}, tick_count
303
+ )
290
304
 
291
305
  log_event(
292
306
  "scaffold_tick_finished",
@@ -297,11 +311,15 @@ def run_scaffold(
297
311
  )
298
312
 
299
313
  except _TickTimeout as late_exc:
300
- # A SIGALRM queued by the OS was delivered after the inner
301
- # try/except exited (e.g. during write_state, sink, or log_event).
302
- # Absorb it: the tick already did its work; disarm just in case,
303
- # log the late arrival, and let the loop continue.
314
+ # A SIGALRM queued by the OS, delivered after the inner try/except
315
+ # exited (e.g. during commit, sink, or log_event). Absorb it: the
316
+ # tick already did its work; disarm, roll back any uncommitted
317
+ # mutations, log the late arrival, and let the loop continue.
304
318
  _disarm_tick_alarm()
319
+ try:
320
+ ctx.state.rollback()
321
+ except Exception: # noqa: BLE001
322
+ pass
305
323
  log_event(
306
324
  "scaffold_tick_timeout_late",
307
325
  tick=tick_count,
@@ -328,3 +346,16 @@ def run_scaffold(
328
346
  log_event("scaffold_stopping", tick_count=tick_count)
329
347
 
330
348
  return tick_count
349
+
350
+
351
+ def _post_error_safe(sink: Any, error: dict, tick_count: int) -> None:
352
+ """Call sink.post_error(error), swallowing/logging any failure."""
353
+ try:
354
+ sink.post_error(error)
355
+ except Exception as perr: # noqa: BLE001
356
+ log_event(
357
+ "scaffold_post_error_failed",
358
+ tick=tick_count,
359
+ error_type=type(perr).__name__,
360
+ error=str(perr),
361
+ )