@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.
- package/README.md +10 -9
- package/dist/actions/action-runtime-module.d.ts.map +1 -1
- package/dist/actions/action-runtime-module.js +1 -1
- package/dist/actions/action-runtime-module.js.map +1 -1
- package/dist/actions/decision-engine.d.ts.map +1 -1
- package/dist/actions/decision-engine.js +3 -0
- package/dist/actions/decision-engine.js.map +1 -1
- package/dist/actions/open-position/open-position.action.d.ts.map +1 -1
- package/dist/actions/open-position/open-position.action.js +54 -14
- package/dist/actions/open-position/open-position.action.js.map +1 -1
- package/dist/actions/types.d.ts +10 -2
- package/dist/actions/types.d.ts.map +1 -1
- package/dist/actions/types.js +8 -0
- package/dist/actions/types.js.map +1 -1
- package/dist/cli/guide-commands.d.ts.map +1 -1
- package/dist/cli/guide-commands.js +80 -74
- package/dist/cli/guide-commands.js.map +1 -1
- package/dist/cli/senpi-commands.d.ts.map +1 -1
- package/dist/cli/senpi-commands.js +0 -108
- package/dist/cli/senpi-commands.js.map +1 -1
- package/dist/constants/dsl/index.d.ts +2 -0
- package/dist/constants/dsl/index.d.ts.map +1 -1
- package/dist/constants/dsl/index.js +2 -0
- package/dist/constants/dsl/index.js.map +1 -1
- package/dist/dsl/config/parse-tiers.d.ts.map +1 -1
- package/dist/dsl/config/parse-tiers.js +6 -2
- package/dist/dsl/config/parse-tiers.js.map +1 -1
- package/dist/dsl/engine-client/config-mapping.d.ts +7 -0
- package/dist/dsl/engine-client/config-mapping.d.ts.map +1 -1
- package/dist/dsl/engine-client/config-mapping.js +16 -2
- package/dist/dsl/engine-client/config-mapping.js.map +1 -1
- package/dist/dsl/events/handlers.d.ts +18 -2
- package/dist/dsl/events/handlers.d.ts.map +1 -1
- package/dist/dsl/events/handlers.js +302 -213
- package/dist/dsl/events/handlers.js.map +1 -1
- package/dist/dsl/monitor/create-monitor.d.ts +15 -0
- package/dist/dsl/monitor/create-monitor.d.ts.map +1 -1
- package/dist/dsl/monitor/create-monitor.js +46 -9
- package/dist/dsl/monitor/create-monitor.js.map +1 -1
- package/dist/dsl/monitor/process-one-position.d.ts.map +1 -1
- package/dist/dsl/monitor/process-one-position.js +138 -77
- package/dist/dsl/monitor/process-one-position.js.map +1 -1
- package/dist/dsl/plugin/index.d.ts +16 -1
- package/dist/dsl/plugin/index.d.ts.map +1 -1
- package/dist/dsl/plugin/index.js +35 -4
- package/dist/dsl/plugin/index.js.map +1 -1
- package/dist/dsl/plugin/options.d.ts +18 -0
- package/dist/dsl/plugin/options.d.ts.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/runtime/create-scanner-module.d.ts +4 -3
- package/dist/runtime/create-scanner-module.d.ts.map +1 -1
- package/dist/runtime/create-scanner-module.js +14 -5
- package/dist/runtime/create-scanner-module.js.map +1 -1
- package/dist/runtime/create-scanner-providers-for-run.d.ts.map +1 -1
- package/dist/runtime/create-scanner-providers-for-run.js +12 -1
- package/dist/runtime/create-scanner-providers-for-run.js.map +1 -1
- package/dist/runtime/load-runtime-config.js +1 -1
- package/dist/runtime/load-runtime-config.js.map +1 -1
- package/dist/runtime/map-strategies-registration.js +7 -8
- package/dist/runtime/map-strategies-registration.js.map +1 -1
- package/dist/runtime/map-strategies.d.ts.map +1 -1
- package/dist/runtime/map-strategies.js +12 -3
- package/dist/runtime/map-strategies.js.map +1 -1
- package/dist/runtime/reconcile-decide.d.ts +73 -0
- package/dist/runtime/reconcile-decide.d.ts.map +1 -0
- package/dist/runtime/reconcile-decide.js +186 -0
- package/dist/runtime/reconcile-decide.js.map +1 -0
- package/dist/runtime/reconcile-run.d.ts +62 -0
- package/dist/runtime/reconcile-run.d.ts.map +1 -0
- package/dist/runtime/reconcile-run.js +173 -0
- package/dist/runtime/reconcile-run.js.map +1 -0
- package/dist/runtime/reconcile-state.d.ts +44 -0
- package/dist/runtime/reconcile-state.d.ts.map +1 -1
- package/dist/runtime/reconcile-state.js +80 -44
- package/dist/runtime/reconcile-state.js.map +1 -1
- package/dist/runtime/risk-guard.d.ts.map +1 -1
- package/dist/runtime/risk-guard.js +17 -12
- package/dist/runtime/risk-guard.js.map +1 -1
- package/dist/runtime/runtime-schema.d.ts +2912 -1052
- package/dist/runtime/runtime-schema.d.ts.map +1 -1
- package/dist/runtime/runtime-schema.js +136 -42
- package/dist/runtime/runtime-schema.js.map +1 -1
- package/dist/runtime/runtime.d.ts +26 -0
- package/dist/runtime/runtime.d.ts.map +1 -1
- package/dist/runtime/runtime.js +254 -8
- package/dist/runtime/runtime.js.map +1 -1
- package/dist/runtime/scanner-registry.d.ts +1 -1
- package/dist/runtime/scanner-registry.d.ts.map +1 -1
- package/dist/runtime/scanner-registry.js +3 -12
- package/dist/runtime/scanner-registry.js.map +1 -1
- package/dist/runtime/supervisor/long-running-supervisor.d.ts.map +1 -1
- package/dist/runtime/supervisor/long-running-supervisor.js +1 -1
- package/dist/runtime/supervisor/long-running-supervisor.js.map +1 -1
- package/dist/runtime/supervisor/orphan-sweep.js +1 -1
- package/dist/runtime/supervisor/orphan-sweep.js.map +1 -1
- package/dist/runtime/supervisor/supervisor-utils.d.ts +1 -1
- package/dist/runtime/supervisor/supervisor-utils.d.ts.map +1 -1
- package/dist/runtime/supervisor/supervisor-utils.js +1 -1
- package/dist/runtime/supervisor/supervisor-utils.js.map +1 -1
- package/dist/runtime-api/intake/acceptance-record.d.ts +10 -0
- package/dist/runtime-api/intake/acceptance-record.d.ts.map +1 -1
- package/dist/runtime-api/intake/acceptance-record.js +28 -0
- package/dist/runtime-api/intake/acceptance-record.js.map +1 -1
- package/dist/runtime-api/intake/intake-mount.d.ts.map +1 -1
- package/dist/runtime-api/intake/intake-mount.js +28 -36
- package/dist/runtime-api/intake/intake-mount.js.map +1 -1
- package/dist/runtime-api/intake/process-signal-post.d.ts +20 -1
- package/dist/runtime-api/intake/process-signal-post.d.ts.map +1 -1
- package/dist/runtime-api/intake/process-signal-post.js +57 -6
- package/dist/runtime-api/intake/process-signal-post.js.map +1 -1
- package/dist/runtime-api/intake/scanner-launcher.d.ts +18 -4
- package/dist/runtime-api/intake/scanner-launcher.d.ts.map +1 -1
- package/dist/runtime-api/intake/scanner-launcher.js +24 -3
- package/dist/runtime-api/intake/scanner-launcher.js.map +1 -1
- package/dist/runtime-api/intake/types.d.ts +16 -0
- package/dist/runtime-api/intake/types.d.ts.map +1 -1
- package/dist/runtime-api/routes/signals.schema.d.ts +4 -0
- package/dist/runtime-api/routes/signals.schema.d.ts.map +1 -1
- package/dist/runtime-api/routes/signals.schema.js +12 -0
- package/dist/runtime-api/routes/signals.schema.js.map +1 -1
- package/dist/scanners/external/scaffold/__main__.py +65 -24
- package/dist/scanners/external/scaffold/ctx.py +78 -0
- package/dist/scanners/external/scaffold/delivery.py +56 -22
- package/dist/scanners/external/scaffold/entrypoint_loader.py +42 -48
- package/dist/scanners/external/scaffold/journal.py +13 -2
- package/dist/scanners/external/scaffold/requirements.txt +11 -0
- package/dist/scanners/external/scaffold/scaffold.py +159 -128
- package/dist/scanners/external/scaffold/senpi_mcp.py +250 -0
- package/dist/scanners/external/scaffold/signal_envelope.py +177 -34
- package/dist/scanners/external/scaffold/state_manager.py +175 -0
- package/dist/scanners/external-scanner-receiver.d.ts +6 -0
- package/dist/scanners/external-scanner-receiver.d.ts.map +1 -1
- package/dist/scanners/external-scanner-receiver.js +14 -1
- package/dist/scanners/external-scanner-receiver.js.map +1 -1
- package/dist/scripts/run-dsl-standalone-live.d.ts +1 -1
- package/dist/scripts/run-dsl-standalone-live.js +5 -3
- package/dist/scripts/run-dsl-standalone-live.js.map +1 -1
- package/dist/senpi/client.d.ts +37 -4
- package/dist/senpi/client.d.ts.map +1 -1
- package/dist/senpi/client.js +101 -24
- package/dist/senpi/client.js.map +1 -1
- package/dist/state/state-manager.d.ts +17 -0
- package/dist/state/state-manager.d.ts.map +1 -1
- package/dist/state/state-manager.js +50 -4
- package/dist/state/state-manager.js.map +1 -1
- package/dist/strategy/strategy-state.d.ts +9 -1
- package/dist/strategy/strategy-state.d.ts.map +1 -1
- package/dist/strategy/strategy-state.js +62 -9
- package/dist/strategy/strategy-state.js.map +1 -1
- package/dist/tracking/constants.d.ts.map +1 -1
- package/dist/tracking/constants.js +2 -0
- package/dist/tracking/constants.js.map +1 -1
- package/dist/types/dsl/index.d.ts +1 -1
- package/dist/types/dsl/index.d.ts.map +1 -1
- package/dist/types/scanner.d.ts +7 -0
- package/dist/types/scanner.d.ts.map +1 -1
- package/dist/types/strategy.d.ts +3 -1
- package/dist/types/strategy.d.ts.map +1 -1
- package/dist/utils/keyed-mutex.d.ts +37 -0
- package/dist/utils/keyed-mutex.d.ts.map +1 -0
- package/dist/utils/keyed-mutex.js +77 -0
- package/dist/utils/keyed-mutex.js.map +1 -0
- package/examples/strategies/README.md +17 -12
- package/examples/strategies/iguana/README.md +135 -0
- package/examples/strategies/iguana/recipe.yaml +137 -0
- package/examples/strategies/iguana/scanners/scan.py +243 -0
- package/examples/strategies/iguana/scanners/scoring.py +152 -0
- package/examples/strategies/raptor/README.md +156 -0
- package/examples/strategies/raptor/recipe.yaml +154 -0
- package/examples/strategies/raptor/scanners/scan.py +349 -0
- package/examples/strategies/raptor/scanners/scoring.py +452 -0
- package/examples/strategies/spider/README.md +89 -0
- package/examples/strategies/spider/scalp/recipe.yaml +129 -0
- package/examples/strategies/spider/scalp/scanners/scan.py +219 -0
- package/examples/strategies/spider/scalp/scanners/scoring.py +179 -0
- package/examples/strategies/spider/swing/recipe.yaml +137 -0
- package/examples/strategies/spider/swing/scanners/scan.py +334 -0
- package/examples/strategies/spider/swing/scanners/scoring.py +184 -0
- package/examples/strategies/turbine/README.md +166 -0
- package/examples/strategies/turbine/recipe-runners.yaml +123 -0
- package/examples/strategies/turbine/recipe-volume.yaml +129 -0
- package/examples/strategies/turbine/scanners/scan.py +283 -0
- package/examples/strategies/turbine/scanners/scoring.py +190 -0
- package/package.json +1 -1
- package/dist/scanners/external/liquidation-watchdog/README.md +0 -107
- package/dist/scanners/external/liquidation-watchdog/liquidation-watchdog.yaml +0 -48
- package/dist/scanners/external/liquidation-watchdog/producer.mjs +0 -336
- package/dist/scanners/external/momentum/README.md +0 -148
- package/dist/scanners/external/momentum/momentum.yaml +0 -116
- package/dist/scanners/external/momentum/producer.mjs +0 -251
- package/dist/scanners/external/shared/ingest.mjs +0 -112
- package/examples/README.md +0 -89
- package/examples/scanners-consumer-quickstart.mjs +0 -134
- package/examples/scanners-emerging-movers-v4.mjs +0 -287
- package/examples/scanners-liquidation-watchdog.mjs +0 -341
- package/examples/scanners-live-mcp.mjs +0 -208
- package/examples/scanners-multi-strategy.mjs +0 -165
- package/examples/scanners-position-tracker-e2e.mjs +0 -225
- package/examples/scanners-scheduled-events.mjs +0 -96
- package/examples/scanners-senpi-provider-client.mjs +0 -182
- package/examples/scanners-single-strategy.mjs +0 -198
- package/examples/strategies/dsl-showcase.yaml +0 -68
- package/examples/strategies/dsl.yaml +0 -51
- package/examples/strategies/fox-minimal-loosened.yaml +0 -240
- package/examples/strategies/fox-minimal.yaml +0 -242
- package/examples/strategies/minimal.yaml +0 -46
- package/examples/strategies/momentum/how-it-works.md +0 -48
- package/examples/strategies/momentum/momentum-guarded.yaml +0 -161
- package/examples/strategies/risk-showcase.yaml +0 -93
- package/examples/strategies/viper.yaml +0 -67
- package/examples/strategies/wolf.yaml +0 -72
- package/examples/yamls/sample-with-dsl-and-emerging-movers.yaml +0 -123
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
"""`ctx.senpi_mcp` — the MCP client capability (runtime v2.0.0, Block B5).
|
|
2
|
+
|
|
3
|
+
The author's `scan(inputs, ctx)` is SYNC. `ctx.senpi_mcp.call_tool(name, args)`
|
|
4
|
+
is therefore a SYNC surface: the author never sees `await`. Internally it bridges
|
|
5
|
+
the OFFICIAL MCP Python SDK (`mcp`), which is async-only:
|
|
6
|
+
|
|
7
|
+
from mcp import ClientSession
|
|
8
|
+
from mcp.client.streamable_http import streamablehttp_client
|
|
9
|
+
|
|
10
|
+
async with streamablehttp_client(url, headers=...) as (read, write, _):
|
|
11
|
+
async with ClientSession(read, write) as session:
|
|
12
|
+
await session.initialize()
|
|
13
|
+
result = await session.call_tool(name, arguments=args)
|
|
14
|
+
|
|
15
|
+
DECISION (locked, execution-plan B5): use the SDK the STANDARD way — a SINGLE
|
|
16
|
+
PERSISTENT `ClientSession` created ONCE (and `initialize()`d once), reused across
|
|
17
|
+
every call. NO extra guards: no custom timeout, no reconnect/retry wrapper, no
|
|
18
|
+
pooling toggle, no fresh-connection-per-call. The async context managers must
|
|
19
|
+
STAY OPEN for the client's lifetime, so we drive them on a dedicated event loop
|
|
20
|
+
running in a background thread; `call_tool` submits work onto that loop and blocks
|
|
21
|
+
for the result (the sync↔async bridge).
|
|
22
|
+
|
|
23
|
+
Response normalization MATCHES producer.mjs `createSenpiToolClient`:
|
|
24
|
+
1. isError truthy -> RAISE with the error text (never a silent None).
|
|
25
|
+
2. structuredContent present -> return it.
|
|
26
|
+
3. else first text content -> json.loads it (fallback to the raw text).
|
|
27
|
+
4. else -> return content as-is.
|
|
28
|
+
|
|
29
|
+
The `mcp` package is LAZY-IMPORTED on first connect (NOT at module top), so
|
|
30
|
+
`import scaffold.senpi_mcp` works even where `mcp` is not installed. Missing creds
|
|
31
|
+
are LOUD AT FIRST USE: `call_tool` raises, construction never does.
|
|
32
|
+
|
|
33
|
+
TEARDOWN (intentional, one-process-per-scanner model): there is NO explicit
|
|
34
|
+
close()/shutdown for the persistent session. The background loop runs on a DAEMON
|
|
35
|
+
thread, so it dies with the process. The scaffold runs exactly ONE client per
|
|
36
|
+
process for that process's whole life — the supervisor restarts by spawning a
|
|
37
|
+
FRESH child process (not by re-running run_scaffold in-place), so a single client
|
|
38
|
+
is never replaced in-process and there is no loop/session leak across restart.
|
|
39
|
+
On graceful stop (SIGTERM/SIGINT) run_scaffold exits its loop and the process
|
|
40
|
+
returns; on a hard kill the OS reclaims the thread. We deliberately do NOT drive
|
|
41
|
+
an AsyncExitStack.aclose() from a shutdown hook: it buys nothing over the dropped
|
|
42
|
+
TCP connection (streamable-http is request/response, no protocol goodbye) and a
|
|
43
|
+
cross-thread aclose() on a wedged transport could itself hang a path that today
|
|
44
|
+
cannot hang.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
from __future__ import annotations
|
|
48
|
+
|
|
49
|
+
import asyncio
|
|
50
|
+
import json
|
|
51
|
+
import threading
|
|
52
|
+
from typing import Any, Iterable, Optional
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# --------------------------------------------------------------------------
|
|
56
|
+
# Producer MCP write boundary — read-only by default.
|
|
57
|
+
# --------------------------------------------------------------------------
|
|
58
|
+
#
|
|
59
|
+
# A supervised producer's scan(inputs, ctx) runs in-process with the strategy's
|
|
60
|
+
# full-capability SENPI_API_KEY. Without a boundary it could call ANY MCP tool —
|
|
61
|
+
# including money/state mutations — bypassing every runtime guard (risk-guard,
|
|
62
|
+
# DSL, slots, dedup). MUTATION_TOOLS enumerates those money/state-mutating tools;
|
|
63
|
+
# call_tool BLOCKS them by default unless the EXACT tool name is on the client's
|
|
64
|
+
# write_allowlist (least privilege — one permitted tool does not unlock others).
|
|
65
|
+
#
|
|
66
|
+
# Read tools (discovery_*, leaderboard_*, market_*, strategy_get*, strategy_list,
|
|
67
|
+
# execution_get*, account_get*, user_get*, arena_*, audit_*, get_*,
|
|
68
|
+
# list_senpi_guides, read_senpi_guide, ratchet_stop_get/list/events) are NOT in
|
|
69
|
+
# this set and therefore always pass.
|
|
70
|
+
MUTATION_TOOLS: frozenset[str] = frozenset(
|
|
71
|
+
{
|
|
72
|
+
"create_position",
|
|
73
|
+
"close_position",
|
|
74
|
+
"edit_position",
|
|
75
|
+
"cancel_order",
|
|
76
|
+
"send_usdc",
|
|
77
|
+
"transfer_spot_to_perps",
|
|
78
|
+
"strategy_create",
|
|
79
|
+
"strategy_create_custom_strategy",
|
|
80
|
+
"strategy_close",
|
|
81
|
+
"strategy_close_positions",
|
|
82
|
+
"strategy_update",
|
|
83
|
+
"strategy_pause",
|
|
84
|
+
"strategy_top_up",
|
|
85
|
+
"strategy_withdraw_funds",
|
|
86
|
+
"strategy_bridge_funds_from_hyperliquid_to_evm",
|
|
87
|
+
"ratchet_stop_add",
|
|
88
|
+
"ratchet_stop_edit",
|
|
89
|
+
"ratchet_stop_delete",
|
|
90
|
+
"user_claim_referral_rewards",
|
|
91
|
+
}
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
# THE allowlist a scaffold maintainer edits to permit a mutation tool for
|
|
95
|
+
# producers. EMPTY BY DEFAULT — every MUTATION_TOOLS entry is blocked. The ONLY
|
|
96
|
+
# way to permit a mutation is to add its exact tool name to this frozenset
|
|
97
|
+
# (e.g. {"create_position"}). This is scaffold-maintainer-owned source: it is
|
|
98
|
+
# not operator/env-configurable and is not reachable by authored producer code.
|
|
99
|
+
PRODUCER_WRITE_ALLOWLIST: frozenset[str] = frozenset()
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class SenpiMcpClient:
|
|
103
|
+
"""Sync MCP client over the official async SDK with one persistent session.
|
|
104
|
+
|
|
105
|
+
Construction is cheap and never touches the network or imports `mcp`. The
|
|
106
|
+
first `call_tool` lazily imports the SDK, spins up a private event loop on a
|
|
107
|
+
background thread, opens the transport + ClientSession ONCE, and initializes
|
|
108
|
+
it ONCE. Every subsequent call reuses that same session.
|
|
109
|
+
"""
|
|
110
|
+
|
|
111
|
+
def __init__(
|
|
112
|
+
self,
|
|
113
|
+
api_key: Optional[str] = None,
|
|
114
|
+
mcp_url: Optional[str] = None,
|
|
115
|
+
write_allowlist: Iterable[str] = (),
|
|
116
|
+
) -> None:
|
|
117
|
+
self._api_key = api_key
|
|
118
|
+
self._mcp_url = mcp_url
|
|
119
|
+
# Mutation tools the operator has explicitly permitted. EMPTY by default
|
|
120
|
+
# → every MUTATION_TOOLS entry is blocked (read-only producer boundary).
|
|
121
|
+
self._write_allowlist: frozenset[str] = frozenset(write_allowlist)
|
|
122
|
+
|
|
123
|
+
# Persistent bridge state — created lazily on first call_tool.
|
|
124
|
+
self._loop: Optional[asyncio.AbstractEventLoop] = None
|
|
125
|
+
self._thread: Optional[threading.Thread] = None
|
|
126
|
+
self._session: Any = None
|
|
127
|
+
self._stack: Any = None
|
|
128
|
+
self._connected = False
|
|
129
|
+
self._connect_lock = threading.Lock()
|
|
130
|
+
|
|
131
|
+
# -- public sync surface ----------------------------------------------
|
|
132
|
+
|
|
133
|
+
def call_tool(self, name: str, args: Optional[dict] = None) -> Any:
|
|
134
|
+
"""Invoke an MCP tool synchronously; return the unwrapped result.
|
|
135
|
+
|
|
136
|
+
Raises (loud, never a silent None) when the tool is a money/state
|
|
137
|
+
mutation not on the write allowlist, when creds are missing, or when the
|
|
138
|
+
tool reports `isError`.
|
|
139
|
+
"""
|
|
140
|
+
# Read-only producer boundary, enforced PRE-CONNECT (no transport opened
|
|
141
|
+
# for a blocked write). A mutation tool passes only if its exact name is
|
|
142
|
+
# on the write allowlist (least privilege — one tool ≠ all).
|
|
143
|
+
if name in MUTATION_TOOLS and name not in self._write_allowlist:
|
|
144
|
+
raise PermissionError(
|
|
145
|
+
f'senpi_mcp: tool "{name}" is a money/state-mutating MCP tool and '
|
|
146
|
+
"is blocked — producers are READ-ONLY by default. The ONLY way to "
|
|
147
|
+
f'permit it is to add "{name}" to the PRODUCER_WRITE_ALLOWLIST '
|
|
148
|
+
"frozenset in src/scanners/external/scaffold/senpi_mcp.py "
|
|
149
|
+
"(scaffold-maintainer-owned source; not operator/env-configurable; "
|
|
150
|
+
"not reachable by authored producer code)."
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
self._ensure_connected()
|
|
154
|
+
result = self._run(self._session.call_tool(name, arguments=args))
|
|
155
|
+
return self._unwrap(name, result)
|
|
156
|
+
|
|
157
|
+
# -- persistent session lifecycle -------------------------------------
|
|
158
|
+
|
|
159
|
+
def _ensure_connected(self) -> None:
|
|
160
|
+
if self._connected:
|
|
161
|
+
return
|
|
162
|
+
with self._connect_lock:
|
|
163
|
+
if self._connected:
|
|
164
|
+
return
|
|
165
|
+
|
|
166
|
+
# Loud at first use: creds are required to reach the MCP server.
|
|
167
|
+
if not self._api_key:
|
|
168
|
+
raise RuntimeError(
|
|
169
|
+
"senpi_mcp: SENPI_API_KEY is not set; cannot call MCP tools "
|
|
170
|
+
"(creds are resolved at launch from SENPI_API_KEY/SENPI_MCP_URL)."
|
|
171
|
+
)
|
|
172
|
+
if not self._mcp_url:
|
|
173
|
+
raise RuntimeError(
|
|
174
|
+
"senpi_mcp: SENPI_MCP_URL is not set; cannot call MCP tools "
|
|
175
|
+
"(creds are resolved at launch from SENPI_API_KEY/SENPI_MCP_URL)."
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
# Dedicated event loop on a background thread. The async context
|
|
179
|
+
# managers (transport + session) must stay OPEN for the client's
|
|
180
|
+
# lifetime, so we cannot use asyncio.run() per call — that would
|
|
181
|
+
# tear the session down between calls.
|
|
182
|
+
self._loop = asyncio.new_event_loop()
|
|
183
|
+
self._thread = threading.Thread(
|
|
184
|
+
target=self._loop.run_forever,
|
|
185
|
+
name="senpi-mcp-loop",
|
|
186
|
+
daemon=True,
|
|
187
|
+
)
|
|
188
|
+
self._thread.start()
|
|
189
|
+
|
|
190
|
+
self._run(self._connect())
|
|
191
|
+
self._connected = True
|
|
192
|
+
|
|
193
|
+
async def _connect(self) -> None:
|
|
194
|
+
"""Open the transport + ClientSession ONCE and initialize ONCE.
|
|
195
|
+
|
|
196
|
+
Lazy-import the SDK here so `import scaffold.senpi_mcp` succeeds in envs
|
|
197
|
+
without `mcp` installed (tests inject a fake `mcp` into sys.modules).
|
|
198
|
+
"""
|
|
199
|
+
from mcp import ClientSession
|
|
200
|
+
from mcp.client.streamable_http import streamablehttp_client
|
|
201
|
+
|
|
202
|
+
# Keep the async context managers alive by holding their __aenter__
|
|
203
|
+
# values on self and only __aexit__-ing them at close. We use
|
|
204
|
+
# AsyncExitStack so both nest correctly and unwind in order.
|
|
205
|
+
from contextlib import AsyncExitStack
|
|
206
|
+
|
|
207
|
+
self._stack = AsyncExitStack()
|
|
208
|
+
headers = {"Authorization": f"Bearer {self._api_key}"}
|
|
209
|
+
read, write, _ = await self._stack.enter_async_context(
|
|
210
|
+
streamablehttp_client(self._mcp_url, headers=headers)
|
|
211
|
+
)
|
|
212
|
+
session = await self._stack.enter_async_context(ClientSession(read, write))
|
|
213
|
+
await session.initialize()
|
|
214
|
+
self._session = session
|
|
215
|
+
|
|
216
|
+
def _run(self, coro: Any) -> Any:
|
|
217
|
+
"""Drive a coroutine to completion on the persistent loop, blocking."""
|
|
218
|
+
future = asyncio.run_coroutine_threadsafe(coro, self._loop)
|
|
219
|
+
return future.result()
|
|
220
|
+
|
|
221
|
+
# -- response normalization (matches producer.mjs createSenpiToolClient) --
|
|
222
|
+
|
|
223
|
+
@staticmethod
|
|
224
|
+
def _unwrap(name: str, result: Any) -> Any:
|
|
225
|
+
if getattr(result, "isError", False):
|
|
226
|
+
content = getattr(result, "content", None)
|
|
227
|
+
if (
|
|
228
|
+
isinstance(content, list)
|
|
229
|
+
and len(content) > 0
|
|
230
|
+
and getattr(content[0], "type", None) == "text"
|
|
231
|
+
):
|
|
232
|
+
error_text = content[0].text
|
|
233
|
+
else:
|
|
234
|
+
error_text = json.dumps(content, default=str)
|
|
235
|
+
raise RuntimeError(f'MCP tool "{name}" returned error: {error_text}')
|
|
236
|
+
|
|
237
|
+
structured = getattr(result, "structuredContent", None)
|
|
238
|
+
if structured is not None:
|
|
239
|
+
return structured
|
|
240
|
+
|
|
241
|
+
content = getattr(result, "content", None)
|
|
242
|
+
if isinstance(content, list) and len(content) > 0:
|
|
243
|
+
first = content[0]
|
|
244
|
+
if getattr(first, "type", None) == "text":
|
|
245
|
+
try:
|
|
246
|
+
return json.loads(first.text)
|
|
247
|
+
except (ValueError, TypeError):
|
|
248
|
+
return first.text
|
|
249
|
+
|
|
250
|
+
return content
|
|
@@ -1,75 +1,218 @@
|
|
|
1
|
-
"""Pure candidate -> wire-signal mapping.
|
|
1
|
+
"""Pure candidate -> wire-signal mapping (v2: lenient + loud).
|
|
2
|
+
|
|
3
|
+
candidate_to_signal(
|
|
4
|
+
candidate,
|
|
5
|
+
produced_at_ms,
|
|
6
|
+
*,
|
|
7
|
+
signal_id=None, # stable production id (journal eid); minted if absent
|
|
8
|
+
signal_data_schema, # dict {key: {type, required?}} | None
|
|
9
|
+
default_signal_validity_seconds, # int REQUIRED fallback TTL (seconds)
|
|
10
|
+
on_reject=None, # callback(reason_dict) on reject -> /errors
|
|
11
|
+
) -> dict | None
|
|
12
|
+
|
|
13
|
+
Returns the wire dict on success:
|
|
14
|
+
{signal_id, asset, direction, produced_at, valid_until, data}
|
|
15
|
+
plus signal_type only when the author set a non-empty string, and the canonical
|
|
16
|
+
sizing fields `marginUsd` (USD margin, not a percent) and/or `leverage` at the
|
|
17
|
+
TOP LEVEL (not inside `data`) when the candidate sets them — the runtime reads
|
|
18
|
+
signal.marginUsd / signal.leverage directly. A present-but-non-positive-number
|
|
19
|
+
marginUsd/leverage is a LOUD reject.
|
|
20
|
+
|
|
21
|
+
Returns None (never raises for a bad CANDIDATE) when the candidate is invalid,
|
|
22
|
+
and — when on_reject is supplied — calls on_reject(reason) so the rejection is
|
|
23
|
+
LOUD (logged AND posted to /errors), never a silent drop.
|
|
24
|
+
|
|
25
|
+
v2 contract (design §4):
|
|
26
|
+
- direction is NORMALIZED case: "long"/"LONG"/"Long" -> "LONG"; "short"/.. -> "SHORT".
|
|
27
|
+
Surrounding whitespace is stripped. "buy"/"sell"/""/non-string -> REJECT.
|
|
28
|
+
- asset + direction are MANDATORY (non-empty string asset).
|
|
29
|
+
- `data` is VALIDATED against signal_data_schema (when a dict is supplied):
|
|
30
|
+
missing-required / wrong-type / extra (unknown) keys all REJECT. A None schema
|
|
31
|
+
means "no validation" (passthrough — used when no schema is threaded in).
|
|
32
|
+
- valid_until = produced_at_ms + (valid_for_seconds else
|
|
33
|
+
default_signal_validity_seconds) * 1000. The v1 produced_at + 2*interval default
|
|
34
|
+
is GONE; there is no interval_ms arg. A non-positive / non-int valid_for_seconds
|
|
35
|
+
is REJECTED. A MISSING default_signal_validity_seconds (None) when it is NEEDED
|
|
36
|
+
fails LOUD (raises) — no magic fallback.
|
|
37
|
+
- author absolute `valid_until` is DROPPED (never honored, never leaks).
|
|
38
|
+
- The keys on the wire are {signal_id, asset, direction, produced_at,
|
|
39
|
+
valid_until, data} (+ optional signal_type, marginUsd, leverage). `marginUsd`/
|
|
40
|
+
`leverage` are the canonical top-level sizing fields (validated positive numbers
|
|
41
|
+
when present; `marginUsd` is a USD value, not a percent). `signal_id` is the
|
|
42
|
+
scaffold-minted
|
|
43
|
+
stable production id (the journal eid), threaded in by the delivery sink — it is
|
|
44
|
+
the intake dedup key. An author-supplied `signal_id` in the candidate dict is
|
|
45
|
+
IGNORED: the scaffold owns the dedup key, an author cannot spoof it. Apart from
|
|
46
|
+
signal_id/signal_type, author bookkeeping never leaks.
|
|
47
|
+
"""
|
|
2
48
|
|
|
3
|
-
|
|
49
|
+
from __future__ import annotations
|
|
4
50
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
plus signal_type only when the author set a non-empty string.
|
|
51
|
+
import uuid
|
|
52
|
+
from typing import Any, Optional
|
|
8
53
|
|
|
9
|
-
Returns None (never raises) when the candidate is invalid:
|
|
10
|
-
- non-mapping candidate
|
|
11
|
-
- missing / blank / non-string asset
|
|
12
|
-
- missing / non-LONG/SHORT direction (no case coercion)
|
|
13
54
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
55
|
+
# JSON type name -> Python type(s) for schema validation. `bool` is excluded from
|
|
56
|
+
# "number" because a bool is an int subclass in Python but is not a numeric value.
|
|
57
|
+
def _is_number(value: Any) -> bool:
|
|
58
|
+
return isinstance(value, (int, float)) and not isinstance(value, bool)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _is_string(value: Any) -> bool:
|
|
62
|
+
return isinstance(value, str)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _is_boolean(value: Any) -> bool:
|
|
66
|
+
return isinstance(value, bool)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _is_object(value: Any) -> bool:
|
|
70
|
+
return isinstance(value, dict)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _is_array(value: Any) -> bool:
|
|
74
|
+
return isinstance(value, list)
|
|
21
75
|
|
|
22
|
-
from __future__ import annotations
|
|
23
76
|
|
|
24
|
-
|
|
77
|
+
_TYPE_CHECKS = {
|
|
78
|
+
"number": _is_number,
|
|
79
|
+
"string": _is_string,
|
|
80
|
+
"boolean": _is_boolean,
|
|
81
|
+
"object": _is_object,
|
|
82
|
+
"array": _is_array,
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _validate_data_against_schema(data: dict, schema: dict) -> Optional[str]:
|
|
87
|
+
"""Validate `data` against `schema`. Returns a reason string on violation,
|
|
88
|
+
or None when valid. additionalProperties:false — unknown keys reject."""
|
|
89
|
+
allowed = set(schema.keys())
|
|
90
|
+
|
|
91
|
+
# Extra (unknown) keys reject.
|
|
92
|
+
for key in data:
|
|
93
|
+
if key not in allowed:
|
|
94
|
+
return f"data has unknown key {key!r} (not in signal_data_schema)"
|
|
95
|
+
|
|
96
|
+
# Missing-required and wrong-type.
|
|
97
|
+
for key, spec in schema.items():
|
|
98
|
+
spec = spec if isinstance(spec, dict) else {}
|
|
99
|
+
required = spec.get("required", True) # required unless explicitly False
|
|
100
|
+
if key not in data:
|
|
101
|
+
if required:
|
|
102
|
+
return f"data missing required key {key!r}"
|
|
103
|
+
continue
|
|
104
|
+
declared_type = spec.get("type")
|
|
105
|
+
check = _TYPE_CHECKS.get(declared_type)
|
|
106
|
+
if check is not None and not check(data[key]):
|
|
107
|
+
return (
|
|
108
|
+
f"data key {key!r} has wrong type "
|
|
109
|
+
f"(expected {declared_type}, got {type(data[key]).__name__})"
|
|
110
|
+
)
|
|
111
|
+
return None
|
|
25
112
|
|
|
26
113
|
|
|
27
114
|
def candidate_to_signal(
|
|
28
115
|
candidate: Any,
|
|
29
116
|
produced_at_ms: int,
|
|
30
|
-
|
|
117
|
+
*,
|
|
118
|
+
signal_id: Optional[str] = None,
|
|
119
|
+
signal_data_schema: Optional[dict],
|
|
120
|
+
default_signal_validity_seconds: Optional[int],
|
|
121
|
+
on_reject=None,
|
|
31
122
|
) -> dict | None:
|
|
32
|
-
"""Wrap a scan() candidate into a wire signal dict.
|
|
123
|
+
"""Wrap a scan() candidate into a wire signal dict (v2).
|
|
33
124
|
|
|
34
|
-
Returns the signal dict on success, None when the candidate is invalid
|
|
35
|
-
|
|
125
|
+
Returns the signal dict on success, None when the candidate is invalid (and
|
|
126
|
+
calls on_reject(reason) so the rejection is loud). Raises only when the
|
|
127
|
+
OPERATOR config is wrong (missing default_signal_validity_seconds when needed).
|
|
36
128
|
"""
|
|
129
|
+
|
|
130
|
+
def _reject(reason: str) -> None:
|
|
131
|
+
if on_reject is not None:
|
|
132
|
+
on_reject({"reason": reason, "candidate": repr(candidate)})
|
|
133
|
+
|
|
37
134
|
if not isinstance(candidate, dict):
|
|
135
|
+
_reject(f"candidate is not a mapping (got {type(candidate).__name__})")
|
|
38
136
|
return None
|
|
39
137
|
|
|
138
|
+
# -- asset: mandatory, non-empty string --------------------------------
|
|
40
139
|
asset = candidate.get("asset")
|
|
41
140
|
if not isinstance(asset, str) or not asset.strip():
|
|
141
|
+
_reject(f"asset missing/blank/non-string (got {asset!r})")
|
|
42
142
|
return None
|
|
43
143
|
|
|
144
|
+
# -- direction: mandatory string, case-normalized to LONG / SHORT ------
|
|
44
145
|
direction = candidate.get("direction")
|
|
45
|
-
if direction
|
|
146
|
+
if not isinstance(direction, str) or isinstance(direction, bool):
|
|
147
|
+
_reject(f"direction is not a string (got {direction!r})")
|
|
148
|
+
return None
|
|
149
|
+
normalized = direction.strip().upper()
|
|
150
|
+
if normalized not in ("LONG", "SHORT"):
|
|
151
|
+
_reject(f"direction must be long/short (got {direction!r})")
|
|
46
152
|
return None
|
|
47
153
|
|
|
48
|
-
# valid_until
|
|
49
|
-
#
|
|
50
|
-
if "
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
reason="valid_until_not_numeric",
|
|
56
|
-
candidate=repr(candidate))
|
|
154
|
+
# -- valid_until from valid_for_seconds else the default ---------------
|
|
155
|
+
# (compute BEFORE schema so a missing operator default still fails loud.)
|
|
156
|
+
if "valid_for_seconds" in candidate:
|
|
157
|
+
vfs = candidate["valid_for_seconds"]
|
|
158
|
+
# int only (bool excluded), strictly positive.
|
|
159
|
+
if not isinstance(vfs, int) or isinstance(vfs, bool) or vfs <= 0:
|
|
160
|
+
_reject(f"valid_for_seconds must be a positive int (got {vfs!r})")
|
|
57
161
|
return None
|
|
162
|
+
validity_seconds = vfs
|
|
58
163
|
else:
|
|
59
|
-
|
|
164
|
+
if default_signal_validity_seconds is None:
|
|
165
|
+
# OPERATOR error, not a candidate bug — fail LOUD (no magic fallback).
|
|
166
|
+
raise ValueError(
|
|
167
|
+
"default_signal_validity_seconds is required when a candidate has "
|
|
168
|
+
"no valid_for_seconds; got None (no magic fallback in v2)."
|
|
169
|
+
)
|
|
170
|
+
validity_seconds = default_signal_validity_seconds
|
|
171
|
+
|
|
172
|
+
valid_until = produced_at_ms + validity_seconds * 1000
|
|
60
173
|
|
|
174
|
+
# -- data: validate against schema (when supplied) ---------------------
|
|
61
175
|
data = candidate.get("data", {})
|
|
62
176
|
if not isinstance(data, dict):
|
|
63
177
|
data = {}
|
|
178
|
+
if signal_data_schema is not None:
|
|
179
|
+
violation = _validate_data_against_schema(data, signal_data_schema)
|
|
180
|
+
if violation is not None:
|
|
181
|
+
_reject(violation)
|
|
182
|
+
return None
|
|
183
|
+
|
|
184
|
+
# -- canonical sizing: marginUsd + leverage ride at the TOP LEVEL of the wire
|
|
185
|
+
# signal, NOT inside `data`. `marginUsd` is the USD margin (not a percent);
|
|
186
|
+
# the runtime consumes signal.marginUsd / signal.leverage directly (data is
|
|
187
|
+
# freeform context only), so per-signal sizing/leverage from the producer is
|
|
188
|
+
# honored instead of silently dropped. Present-but-invalid is a LOUD reject —
|
|
189
|
+
# sizing must never fall back silently.
|
|
190
|
+
sizing: dict = {}
|
|
191
|
+
for field in ("marginUsd", "leverage"):
|
|
192
|
+
if candidate.get(field) is None:
|
|
193
|
+
continue
|
|
194
|
+
value = candidate[field]
|
|
195
|
+
if not _is_number(value) or value <= 0:
|
|
196
|
+
_reject(f"{field} must be a positive number when set (got {value!r})")
|
|
197
|
+
return None
|
|
198
|
+
sizing[field] = value
|
|
64
199
|
|
|
65
200
|
sig: dict = {
|
|
201
|
+
# scaffold-minted stable production id (journal eid); the author cannot
|
|
202
|
+
# override it via the candidate dict — the kwarg is authoritative. The
|
|
203
|
+
# delivery sink threads the eid in; when no id is supplied a stable one is
|
|
204
|
+
# minted so the wire ALWAYS carries a signal_id (required end-to-end).
|
|
205
|
+
"signal_id": signal_id if (isinstance(signal_id, str) and signal_id) else uuid.uuid4().hex,
|
|
66
206
|
"asset": asset,
|
|
67
|
-
"direction":
|
|
207
|
+
"direction": normalized,
|
|
68
208
|
"produced_at": produced_at_ms,
|
|
69
209
|
"valid_until": valid_until,
|
|
70
210
|
"data": data,
|
|
71
211
|
}
|
|
72
212
|
|
|
213
|
+
# Lift the validated sizing fields onto the wire signal at the top level.
|
|
214
|
+
sig.update(sizing)
|
|
215
|
+
|
|
73
216
|
signal_type = candidate.get("signal_type")
|
|
74
217
|
if signal_type and isinstance(signal_type, str):
|
|
75
218
|
sig["signal_type"] = signal_type
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""Disk-backed, bounded, transactional state series for the scaffold core.
|
|
2
|
+
|
|
3
|
+
`StateManager` is the `ctx.state` capability the supervised external scanner
|
|
4
|
+
author sees: a list-like series of dict records, persisted between ticks and
|
|
5
|
+
bounded by `state_history_max_count`.
|
|
6
|
+
|
|
7
|
+
Shape (per scanner-and-ctx-design.md §2.3):
|
|
8
|
+
|
|
9
|
+
StateManager(state_path, state_history_max_count)
|
|
10
|
+
|
|
11
|
+
Author surface:
|
|
12
|
+
append(record) record MUST be a dict; a reserved `recorded_at` (float
|
|
13
|
+
SECONDS, time.time() magnitude — never ms) is injected.
|
|
14
|
+
scaffold wins on a `recorded_at` collision. returns None.
|
|
15
|
+
RAISES when state_history_max_count is 0/unset — history
|
|
16
|
+
disabled is a hard error, never a silent no-op.
|
|
17
|
+
recent(n) last n entries, chronological (oldest->newest); n<=0 -> [].
|
|
18
|
+
last() most recent entry, or None if empty.
|
|
19
|
+
__len__ / __getitem__ (index + slice) / __iter__ (oldest -> newest).
|
|
20
|
+
|
|
21
|
+
Internals: backed by collections.deque(maxlen=state_history_max_count).
|
|
22
|
+
Loading an on-disk series longer than the bound keeps the NEWEST entries
|
|
23
|
+
(deque eviction drops the oldest).
|
|
24
|
+
|
|
25
|
+
Transactional (one commit per tick):
|
|
26
|
+
commit() persists the working buffer via the atomic write_state.
|
|
27
|
+
JSON-serializability is enforced LOUD here — a failed persist
|
|
28
|
+
RAISES and does NOT advance the committed snapshot, leaving the
|
|
29
|
+
prior on-disk state intact.
|
|
30
|
+
rollback() discards mid-tick mutations: the working buffer is restored to
|
|
31
|
+
the last committed snapshot.
|
|
32
|
+
read-your-writes within a tick: appended records are visible via
|
|
33
|
+
len/recent/last/iter/getitem BEFORE commit.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
from __future__ import annotations
|
|
37
|
+
|
|
38
|
+
import copy
|
|
39
|
+
import time
|
|
40
|
+
from collections import deque
|
|
41
|
+
|
|
42
|
+
from .scaffold_state import read_state, write_state
|
|
43
|
+
|
|
44
|
+
# Reserved, scaffold-owned key injected into every record. Float seconds.
|
|
45
|
+
RECORDED_AT_KEY = "recorded_at"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class StateManager:
|
|
49
|
+
"""A disk-backed, bounded, transactional series of dict records."""
|
|
50
|
+
|
|
51
|
+
def __init__(self, state_path: str, state_history_max_count):
|
|
52
|
+
self._state_path = state_path
|
|
53
|
+
# 0 / None / falsy => history disabled. Kept as-is so append can raise.
|
|
54
|
+
self._max_count = state_history_max_count
|
|
55
|
+
|
|
56
|
+
# Load any persisted series. read_state never raises: missing/corrupt
|
|
57
|
+
# -> None. A non-list payload is treated as "no usable history".
|
|
58
|
+
# state_path None => no disk persistence (commit advances the in-memory
|
|
59
|
+
# snapshot but never writes). read_state needs a real path.
|
|
60
|
+
loaded = read_state(state_path) if state_path is not None else None
|
|
61
|
+
if not isinstance(loaded, list):
|
|
62
|
+
loaded = []
|
|
63
|
+
|
|
64
|
+
# The committed snapshot is the last durably-persisted series; the
|
|
65
|
+
# working buffer is what the current tick reads/mutates. Both are
|
|
66
|
+
# bounded the same way (deque(maxlen=...)); loading a longer-than-bound
|
|
67
|
+
# series keeps the NEWEST entries (deque evicts from the left).
|
|
68
|
+
self._committed = self._new_deque(loaded)
|
|
69
|
+
self._working = self._snapshot(self._committed)
|
|
70
|
+
|
|
71
|
+
# -- internals ---------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
def _new_deque(self, initial=()):
|
|
74
|
+
"""A deque bounded by max_count. When history is disabled (max_count is
|
|
75
|
+
0/None) the bound is 0 — nothing can ever be buffered."""
|
|
76
|
+
maxlen = self._max_count if self._max_count else 0
|
|
77
|
+
return deque(initial, maxlen=maxlen)
|
|
78
|
+
|
|
79
|
+
def _snapshot(self, source):
|
|
80
|
+
"""An independent bounded deque whose records share NO mutable state
|
|
81
|
+
with `source`. Committed and working buffers must never alias the same
|
|
82
|
+
dict objects — otherwise a mid-tick mutation of a record (an author
|
|
83
|
+
scribbling on a dict handed back by last()/recent()/getitem) would
|
|
84
|
+
survive a rollback and corrupt the very snapshot the rollback restores.
|
|
85
|
+
Deep-copy at every snapshot boundary (load / commit / rollback)."""
|
|
86
|
+
return self._new_deque(copy.deepcopy(entry) for entry in source)
|
|
87
|
+
|
|
88
|
+
# -- author surface ----------------------------------------------------
|
|
89
|
+
|
|
90
|
+
def append(self, record) -> None:
|
|
91
|
+
"""Append a dict record to the working buffer.
|
|
92
|
+
|
|
93
|
+
Injects the reserved `recorded_at` (float seconds, scaffold wins on
|
|
94
|
+
collision). Raises TypeError if record is not a dict. Raises if history
|
|
95
|
+
is disabled (state_history_max_count 0/unset) — never a silent no-op.
|
|
96
|
+
"""
|
|
97
|
+
if not self._max_count:
|
|
98
|
+
raise ValueError(
|
|
99
|
+
"state is disabled: state_history_max_count is 0 or unset "
|
|
100
|
+
"(set it > 0 to use ctx.state.append)"
|
|
101
|
+
)
|
|
102
|
+
# bool is an int subclass but never a dict; explicit dict check rejects
|
|
103
|
+
# lists/tuples/sets/strings/numbers/None/objects/bool alike.
|
|
104
|
+
if not isinstance(record, dict):
|
|
105
|
+
raise TypeError(
|
|
106
|
+
f"state record must be a dict, got {type(record).__name__}"
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
# Copy so the author's dict is never mutated, then stamp recorded_at.
|
|
110
|
+
# scaffold wins on collision: assign AFTER copying author keys.
|
|
111
|
+
entry = dict(record)
|
|
112
|
+
entry[RECORDED_AT_KEY] = time.time()
|
|
113
|
+
self._working.append(entry)
|
|
114
|
+
return None
|
|
115
|
+
|
|
116
|
+
def recent(self, n) -> list:
|
|
117
|
+
"""The last n entries, chronological (oldest->newest).
|
|
118
|
+
|
|
119
|
+
n <= 0 returns []; n greater than the buffer returns the whole buffer.
|
|
120
|
+
Negative n is NOT a from-the-end slice — it is meaningless for "last n"
|
|
121
|
+
and yields [].
|
|
122
|
+
"""
|
|
123
|
+
if n <= 0:
|
|
124
|
+
return []
|
|
125
|
+
items = list(self._working)
|
|
126
|
+
return items[-n:]
|
|
127
|
+
|
|
128
|
+
def last(self):
|
|
129
|
+
"""The most recent entry, or None if empty."""
|
|
130
|
+
if not self._working:
|
|
131
|
+
return None
|
|
132
|
+
return self._working[-1]
|
|
133
|
+
|
|
134
|
+
# -- sequence protocol -------------------------------------------------
|
|
135
|
+
|
|
136
|
+
def __len__(self) -> int:
|
|
137
|
+
return len(self._working)
|
|
138
|
+
|
|
139
|
+
def __getitem__(self, key):
|
|
140
|
+
# deque supports integer indexing (raising IndexError out of range) but
|
|
141
|
+
# not slicing; materialise a list so slices work with Python semantics
|
|
142
|
+
# (clamping, steps, negative bounds) and index errors still propagate.
|
|
143
|
+
if isinstance(key, slice):
|
|
144
|
+
return list(self._working)[key]
|
|
145
|
+
return self._working[key]
|
|
146
|
+
|
|
147
|
+
def __iter__(self):
|
|
148
|
+
return iter(self._working)
|
|
149
|
+
|
|
150
|
+
# -- transaction -------------------------------------------------------
|
|
151
|
+
|
|
152
|
+
def commit(self) -> None:
|
|
153
|
+
"""Persist the working buffer atomically via write_state.
|
|
154
|
+
|
|
155
|
+
JSON-serializability is enforced LOUD: write_state returns False on a
|
|
156
|
+
non-serializable value (or IO failure) without touching the existing
|
|
157
|
+
file. We raise on that False so a failed persist never silently
|
|
158
|
+
advances the committed snapshot — the prior on-disk state stays intact.
|
|
159
|
+
"""
|
|
160
|
+
payload = list(self._working)
|
|
161
|
+
# No disk path => commit advances the in-memory snapshot only.
|
|
162
|
+
ok = True if self._state_path is None else write_state(self._state_path, payload)
|
|
163
|
+
if not ok:
|
|
164
|
+
raise RuntimeError(
|
|
165
|
+
f"failed to persist state to {self._state_path} "
|
|
166
|
+
"(non-serializable value or IO error); state not advanced"
|
|
167
|
+
)
|
|
168
|
+
# Durable: advance the committed snapshot to match what is on disk.
|
|
169
|
+
# Deep-copy so the committed snapshot does not alias working records.
|
|
170
|
+
self._committed = self._snapshot(self._working)
|
|
171
|
+
|
|
172
|
+
def rollback(self) -> None:
|
|
173
|
+
"""Discard mid-tick mutations: restore the working buffer to the last
|
|
174
|
+
committed snapshot. Never touches disk."""
|
|
175
|
+
self._working = self._snapshot(self._committed)
|