Skip to content

Commit fc5e6c7

Browse files
committed
위젯 값 변경 → dependents-only 리액티브 재실행 경로
WS setUiValue {blockId, elementId, value, blocks} 메시지를 추가해, 위젯 값이 바뀌면 워커 store를 갱신(session.setUiValue → engine → worker command)한 뒤 그 변수를 쓰는 다운스트림 셀만 재실행한다 (includeSource=False, 위젯 정의 셀은 제외해 값 리셋 방지). kernelWebSocket의 reactive 송신 로직을 _runReactiveAndSend로 추출해 executeReactive와 공유. 테스트: setUiValue 50 → consumer 셀이 100으로 갱신, executionOrder가 위젯 셀(w)을 제외하고 [c]만. 세션 레벨 + WS 라운드트립 양쪽 검증. 프론트 송신은 다음 커밋(H2c).
1 parent d7b9ae4 commit fc5e6c7

7 files changed

Lines changed: 135 additions & 10 deletions

File tree

src/codaro/api/kernelWebSocket.py

Lines changed: 41 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
WsGetVariablesMessage,
1515
WsInterruptMessage,
1616
WsResetMessage,
17+
WsSetUiValueMessage,
1718
WsStatusMessage,
1819
)
1920
from ..kernel.uiEventFlow import resetKernelUiCallbacks
@@ -89,6 +90,8 @@ async def handleKernelWsMessage(websocket: WebSocket, session: Any, message: Any
8990
logger.debug("kernel-variables %s", formatLogFields(transport="ws", sessionId=session.sessionId))
9091
elif isinstance(message, WsExecuteReactiveMessage):
9192
await handleReactiveMessage(websocket, session, message, logger)
93+
elif isinstance(message, WsSetUiValueMessage):
94+
await handleSetUiValueMessage(websocket, session, message, logger)
9295
elif isinstance(message, WsResetMessage):
9396
session.reset()
9497
resetKernelUiCallbacks()
@@ -106,6 +109,8 @@ def validateKernelWsMessage(message: dict[str, Any]) -> Any:
106109
return WsGetVariablesMessage.model_validate(message)
107110
if messageType == "executeReactive":
108111
return WsExecuteReactiveMessage.model_validate(message)
112+
if messageType == "setUiValue":
113+
return WsSetUiValueMessage.model_validate(message)
109114
if messageType == "reset":
110115
return WsResetMessage.model_validate(message)
111116
raise ValueError(f"Unsupported websocket message type: {messageType or 'unknown'}.")
@@ -152,28 +157,55 @@ async def handleReactiveMessage(
152157
message: WsExecuteReactiveMessage,
153158
logger: Any,
154159
) -> None:
155-
requestId = message.requestId
156-
changedBlockId = message.blockId
157160
blocks = [block.model_dump() for block in message.blocks]
161+
await _runReactiveAndSend(
162+
websocket, session, message.requestId, message.blockId, blocks, logger,
163+
includeSource=True, kind="reactive",
164+
)
165+
166+
167+
async def handleSetUiValueMessage(
168+
websocket: WebSocket,
169+
session: Any,
170+
message: WsSetUiValueMessage,
171+
logger: Any,
172+
) -> None:
173+
# 위젯 값 store 갱신 → 그 변수를 쓰는 다운스트림만 재실행(위젯 셀 자신은 제외).
174+
session.setUiValue(message.elementId, message.value)
175+
blocks = [block.model_dump() for block in message.blocks]
176+
await _runReactiveAndSend(
177+
websocket, session, message.requestId, message.blockId, blocks, logger,
178+
includeSource=False, kind="setUiValue",
179+
)
180+
158181

182+
async def _runReactiveAndSend(
183+
websocket: WebSocket,
184+
session: Any,
185+
requestId: str,
186+
changedBlockId: str,
187+
blocks: list[dict[str, Any]],
188+
logger: Any,
189+
*,
190+
includeSource: bool,
191+
kind: str,
192+
) -> None:
159193
if not await _safeSendJson(websocket, WsStatusMessage(type="status", engineStatus="busy").model_dump()):
160194
return
161195
reactiveEvents: list[dict[str, Any]] = []
162196

163197
async def eventHandler(event: Any) -> None:
164-
reactiveEvents.append(
165-
{
166-
"blockId": event.blockId,
167-
"eventType": event.eventType,
168-
}
169-
)
198+
reactiveEvents.append({"blockId": event.blockId, "eventType": event.eventType})
170199
await sendExecutionEvent(websocket, requestId, event)
171200

172-
payload = await executeKernelReactive(session, blocks, changedBlockId, eventHandler=eventHandler)
201+
payload = await executeKernelReactive(
202+
session, blocks, changedBlockId, eventHandler=eventHandler, includeSource=includeSource
203+
)
173204
logger.debug(
174205
"kernel-reactive %s",
175206
formatLogFields(
176207
transport="ws",
208+
kind=kind,
177209
sessionId=session.sessionId,
178210
requestId=requestId,
179211
changedBlockId=changedBlockId,

src/codaro/kernel/protocol.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,15 @@ class WsExecuteReactiveMessage(BaseModel):
111111
blocks: list[ReactiveBlockPayload]
112112

113113

114+
class WsSetUiValueMessage(BaseModel):
115+
type: Literal["setUiValue"] = "setUiValue"
116+
requestId: str
117+
blockId: str
118+
elementId: str
119+
value: Any = None
120+
blocks: list[ReactiveBlockPayload]
121+
122+
114123
class WsResetMessage(BaseModel):
115124
type: Literal["reset"] = "reset"
116125

src/codaro/kernel/session.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,9 @@ async def engineEventHandler(event: RuntimeExecutionEvent) -> None:
6565
status=result.status,
6666
)
6767

68+
def setUiValue(self, elementId: str, value: object) -> None:
69+
self._engine.setUiValue(elementId, value)
70+
6871
def removeCellDefinitions(self, blockId: str) -> None:
6972
self._engine.removeBlockDefinitions(blockId)
7073

src/codaro/runtime/localEngine.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -283,6 +283,15 @@ def removeBlockDefinitions(self, blockId: str) -> None:
283283
self._replaceWorker()
284284
self._cellDefinitions.pop(blockId, None)
285285

286+
def setUiValue(self, elementId: str, value: Any) -> None:
287+
"""리액티브 위젯 값을 워커 store에 갱신한다(registry/변수 불변, 값만)."""
288+
if not self._hasLiveWorker():
289+
return
290+
try:
291+
self._sendCommand({"action": "setUiValue", "elementId": elementId, "value": value})
292+
except (BrokenPipeError, EOFError, OSError):
293+
self._replaceWorker()
294+
286295
def reset(self, *, preserveDefinitions: bool = False) -> None:
287296
if self._hasLiveWorker():
288297
action = "resetVariables" if preserveDefinitions else "reset"

src/codaro/runtime/localWorker.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
from ..document.analysis import analyzeCode
2727
from ..errorGuard import safeRepr
2828
from ..outputDescriptor import isDescriptorPayload
29-
from ..uiValue import beginBlock, resetStore
29+
from ..uiValue import beginBlock, resetStore, setStoredValue
3030

3131

3232
def runLocalWorker(
@@ -145,6 +145,11 @@ def emitEvent(eventType: str, payload: Any = None) -> None:
145145
)
146146
continue
147147

148+
if action == "setUiValue":
149+
setStoredValue(command["elementId"], command.get("value"))
150+
_workerSend(connection, {"ok": True})
151+
continue
152+
148153
_workerSend(connection, {"error": f"Unsupported worker action: {action}"})
149154
except (BrokenPipeError, EOFError, OSError):
150155
_uninstallInterruptTrace(interruptFlag)

tests/testServerApi.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -891,6 +891,52 @@ def testKernelWebSocketReactiveAndReset() -> None:
891891
assert websocket.receive_json() == {"type": "status", "engineStatus": "ready"}
892892

893893

894+
def testKernelWebSocketSetUiValueRerunsDependentsOnly() -> None:
895+
client = TestClient(createServerApp())
896+
sessionId = client.post("/api/kernel/create", json={}).json()["sessionId"]
897+
widgetBlock = {
898+
"id": "w",
899+
"type": "code",
900+
"content": "from codaro.outputDescriptor import ui\nslider = ui.slider(0, 100)",
901+
}
902+
consumerBlock = {"id": "c", "type": "code", "content": "doubled = slider.value * 2\ndoubled"}
903+
904+
def drainUntilReady(websocket) -> list[dict]:
905+
messages: list[dict] = []
906+
while True:
907+
message = websocket.receive_json()
908+
messages.append(message)
909+
if message.get("type") == "status" and message.get("engineStatus") == "ready":
910+
return messages
911+
912+
with client.websocket_connect(f"/ws/kernel/{sessionId}") as websocket:
913+
websocket.receive_json() # initial ready
914+
for requestId, block in (("rw", widgetBlock), ("rc", consumerBlock)):
915+
websocket.send_json({"type": "execute", "requestId": requestId, "blockId": block["id"], "code": block["content"]})
916+
consumerMessages = drainUntilReady(websocket)
917+
firstResult = next(m for m in consumerMessages if m["type"] == "result")
918+
assert "0" in str(firstResult["data"]) # slider.value=0 → doubled=0
919+
920+
websocket.send_json({
921+
"type": "setUiValue",
922+
"requestId": "rs",
923+
"blockId": "w",
924+
"elementId": "w#0",
925+
"value": 50,
926+
"blocks": [widgetBlock, consumerBlock],
927+
})
928+
messages: list[dict] = []
929+
while True:
930+
message = websocket.receive_json()
931+
messages.append(message)
932+
if message.get("type") == "reactiveComplete":
933+
break
934+
complete = messages[-1]
935+
assert complete["executionOrder"] == ["c"] # 위젯 셀 w는 재실행 제외
936+
results = [m for m in messages if m["type"] == "result"]
937+
assert any("100" in str(result["data"]) for result in results) # 50 * 2
938+
939+
894940
def testKernelWebSocketRejectsInvalidPayload() -> None:
895941
client = TestClient(createServerApp())
896942
sessionId = client.post("/api/kernel/create", json={}).json()["sessionId"]

tests/testUiValueBinding.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
import asyncio
55

6+
from codaro.kernel.reactive import executeReactive
67
from codaro.kernel.session import KernelSession
78
from codaro.uiValue import (
89
UiValue,
@@ -81,3 +82,23 @@ def testSessionSliderRendersAndValueReadable() -> None:
8182
consumer = _run(session.execute("print(slider.value)", blockId="b2"))
8283
assert "0" in consumer.stdout
8384
session.dispose()
85+
86+
87+
def testSetUiValueRerunsDependentsOnly() -> None:
88+
session = KernelSession()
89+
blocks = [
90+
{"id": "w", "type": "code", "content": "from codaro.outputDescriptor import ui\nslider = ui.slider(0, 100)\nslider"},
91+
{"id": "c", "type": "code", "content": "doubled = slider.value * 2\nprint(doubled)"},
92+
]
93+
_run(session.execute(blocks[0]["content"], blockId="w"))
94+
initial = _run(session.execute(blocks[1]["content"], blockId="c"))
95+
assert "0" in initial.stdout # slider.value=0 → doubled=0
96+
97+
# 위젯 값 변경 → store 갱신, dependents만 재실행(위젯 셀 w 제외).
98+
session.setUiValue("w#0", 50)
99+
results, order = _run(executeReactive(session, blocks, "w", includeSource=False))
100+
assert "w" not in order # 위젯 정의 셀은 재실행 안 함(값 리셋 방지)
101+
assert "c" in order
102+
consumer = next(result for result in results if result.blockId == "c")
103+
assert "100" in consumer.stdout # 50 * 2
104+
session.dispose()

0 commit comments

Comments
 (0)