yaml-flow 5.4.2 → 7.0.0
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/board-live-cards-cli.js +6 -6
- package/browser/asset-integrity.json +10 -0
- package/browser/board-livecards-client.js +2 -0
- package/browser/board-livecards-client.js.map +1 -0
- package/browser/board-livecards-localstorage.js +10 -0
- package/browser/board-livecards-localstorage.js.map +1 -0
- package/browser/board-livegraph-engine.js +2 -1676
- package/browser/board-livegraph-engine.js.map +1 -1
- package/browser/card-compute.js +28 -28
- package/browser/compute-jsonata.js +5 -0
- package/browser/compute-jsonata.js.map +1 -0
- package/browser/live-cards.js +561 -129
- package/browser/live-cards.schema.json +418 -132
- package/card-store.js +37 -0
- package/dist/batch/index.cjs +1 -108
- package/dist/batch/index.cjs.map +1 -1
- package/dist/batch/index.js +1 -106
- package/dist/batch/index.js.map +1 -1
- package/dist/board-live-cards-lib-Bg6EvCo5.d.cts +136 -0
- package/dist/board-live-cards-lib-jM2uYG1v.d.ts +136 -0
- package/dist/board-live-cards-public-CW5074xr.d.cts +318 -0
- package/dist/board-live-cards-public-hnZo0mAf.d.ts +318 -0
- package/dist/board-livegraph-runtime/index.cjs +2 -1671
- package/dist/board-livegraph-runtime/index.cjs.map +1 -1
- package/dist/board-livegraph-runtime/index.d.cts +12 -11
- package/dist/board-livegraph-runtime/index.d.ts +12 -11
- package/dist/board-livegraph-runtime/index.js +2 -1662
- package/dist/board-livegraph-runtime/index.js.map +1 -1
- package/dist/board-livegraph-runtime/jsonata-sync.cjs +7623 -0
- package/dist/card-compute/index.cjs +9 -7159
- package/dist/card-compute/index.cjs.map +1 -1
- package/dist/card-compute/index.d.cts +27 -1
- package/dist/card-compute/index.d.ts +27 -1
- package/dist/card-compute/index.js +9 -7145
- package/dist/card-compute/index.js.map +1 -1
- package/dist/card-compute/jsonata-sync.cjs +7623 -0
- package/dist/cli/browser-api/board-live-cards-browser-adapter.cjs +3 -0
- package/dist/cli/browser-api/board-live-cards-browser-adapter.cjs.map +1 -0
- package/dist/cli/browser-api/board-live-cards-browser-adapter.d.cts +37 -0
- package/dist/cli/browser-api/board-live-cards-browser-adapter.d.ts +37 -0
- package/dist/cli/browser-api/board-live-cards-browser-adapter.js +3 -0
- package/dist/cli/browser-api/board-live-cards-browser-adapter.js.map +1 -0
- package/dist/cli/browser-api/card-store-browser-api.cjs +2 -0
- package/dist/cli/browser-api/card-store-browser-api.cjs.map +1 -0
- package/dist/cli/browser-api/card-store-browser-api.d.cts +26 -0
- package/dist/cli/browser-api/card-store-browser-api.d.ts +26 -0
- package/dist/cli/browser-api/card-store-browser-api.js +2 -0
- package/dist/cli/browser-api/card-store-browser-api.js.map +1 -0
- package/dist/cli/browser-api/jsonata-sync.cjs +7623 -0
- package/dist/cli/node/artifacts-store-cli.cjs +11 -0
- package/dist/cli/node/artifacts-store-cli.cjs.map +1 -0
- package/dist/cli/node/artifacts-store-cli.d.cts +8 -0
- package/dist/cli/node/artifacts-store-cli.d.ts +8 -0
- package/dist/cli/node/artifacts-store-cli.js +11 -0
- package/dist/cli/node/artifacts-store-cli.js.map +1 -0
- package/dist/cli/node/board-live-cards-cli.cjs +15 -0
- package/dist/cli/node/board-live-cards-cli.cjs.map +1 -0
- package/dist/cli/node/board-live-cards-cli.d.cts +20 -0
- package/dist/cli/node/board-live-cards-cli.d.ts +20 -0
- package/dist/cli/node/board-live-cards-cli.js +15 -0
- package/dist/cli/node/board-live-cards-cli.js.map +1 -0
- package/dist/cli/node/card-store-cli.cjs +8 -0
- package/dist/cli/node/card-store-cli.cjs.map +1 -0
- package/dist/cli/node/card-store-cli.d.cts +15 -0
- package/dist/cli/node/card-store-cli.d.ts +15 -0
- package/dist/cli/node/card-store-cli.js +8 -0
- package/dist/cli/node/card-store-cli.js.map +1 -0
- package/dist/cli/node/execution-adapter.cjs +3 -0
- package/dist/cli/node/execution-adapter.cjs.map +1 -0
- package/dist/cli/node/execution-adapter.d.cts +174 -0
- package/dist/cli/node/execution-adapter.d.ts +174 -0
- package/dist/cli/node/execution-adapter.js +3 -0
- package/dist/cli/node/execution-adapter.js.map +1 -0
- package/dist/cli/node/fs-board-adapter.cjs +14 -0
- package/dist/cli/node/fs-board-adapter.cjs.map +1 -0
- package/dist/cli/node/fs-board-adapter.d.cts +204 -0
- package/dist/cli/node/fs-board-adapter.d.ts +204 -0
- package/dist/cli/node/fs-board-adapter.js +14 -0
- package/dist/cli/node/fs-board-adapter.js.map +1 -0
- package/dist/cli/node/jsonata-sync.cjs +7623 -0
- package/dist/cli/node/source-cli-task-executor.cjs +11 -0
- package/dist/cli/node/source-cli-task-executor.cjs.map +1 -0
- package/dist/cli/node/source-cli-task-executor.d.cts +1 -0
- package/dist/cli/node/source-cli-task-executor.d.ts +1 -0
- package/dist/cli/node/source-cli-task-executor.js +11 -0
- package/dist/cli/node/source-cli-task-executor.js.map +1 -0
- package/dist/config/index.cjs +1 -79
- package/dist/config/index.cjs.map +1 -1
- package/dist/config/index.js +1 -76
- package/dist/config/index.js.map +1 -1
- package/dist/continuous-event-graph/index.cjs +2 -2129
- package/dist/continuous-event-graph/index.cjs.map +1 -1
- package/dist/continuous-event-graph/index.d.cts +81 -5
- package/dist/continuous-event-graph/index.d.ts +81 -5
- package/dist/continuous-event-graph/index.js +2 -2088
- package/dist/continuous-event-graph/index.js.map +1 -1
- package/dist/continuous-event-graph/jsonata-sync.cjs +7623 -0
- package/dist/event-graph/index.cjs +22 -8292
- package/dist/event-graph/index.cjs.map +1 -1
- package/dist/event-graph/index.js +22 -8237
- package/dist/event-graph/index.js.map +1 -1
- package/dist/execution-refs.cjs +3 -0
- package/dist/execution-refs.cjs.map +1 -0
- package/dist/execution-refs.d.cts +260 -0
- package/dist/execution-refs.d.ts +260 -0
- package/dist/execution-refs.js +3 -0
- package/dist/execution-refs.js.map +1 -0
- package/dist/index.cjs +29 -13221
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +2 -4
- package/dist/index.d.ts +2 -4
- package/dist/index.js +29 -13112
- package/dist/index.js.map +1 -1
- package/dist/inference/index.cjs +5 -617
- package/dist/inference/index.cjs.map +1 -1
- package/dist/inference/index.js +5 -610
- package/dist/inference/index.js.map +1 -1
- package/dist/jsonata-sync.cjs +7623 -0
- package/dist/{live-cards-bridge-x5XREkXm.d.cts → live-cards-bridge-BXbVTsna.d.cts} +27 -4
- package/dist/{live-cards-bridge-EQjytzI_.d.ts → live-cards-bridge-Ds28XR15.d.ts} +27 -4
- package/dist/server-runtime/index.cjs +9 -0
- package/dist/server-runtime/index.cjs.map +1 -0
- package/dist/server-runtime/index.d.cts +31 -0
- package/dist/server-runtime/index.d.ts +31 -0
- package/dist/server-runtime/index.js +9 -0
- package/dist/server-runtime/index.js.map +1 -0
- package/dist/server-runtime/jsonata-sync.cjs +7623 -0
- package/dist/step-machine/index.cjs +11 -7129
- package/dist/step-machine/index.cjs.map +1 -1
- package/dist/step-machine/index.js +11 -7113
- package/dist/step-machine/index.js.map +1 -1
- package/dist/step-machine-public/index.cjs +2 -0
- package/dist/step-machine-public/index.cjs.map +1 -0
- package/dist/step-machine-public/index.d.cts +159 -0
- package/dist/step-machine-public/index.d.ts +159 -0
- package/dist/step-machine-public/index.js +2 -0
- package/dist/step-machine-public/index.js.map +1 -0
- package/dist/step-machine-public/jsonata-sync.cjs +7623 -0
- package/dist/storage-refs.cjs +10 -0
- package/dist/storage-refs.cjs.map +1 -0
- package/dist/storage-refs.d.cts +93 -0
- package/dist/storage-refs.d.ts +93 -0
- package/dist/storage-refs.js +10 -0
- package/dist/storage-refs.js.map +1 -0
- package/dist/stores/file.cjs +1 -114
- package/dist/stores/file.cjs.map +1 -1
- package/dist/stores/file.js +1 -112
- package/dist/stores/file.js.map +1 -1
- package/dist/stores/index.cjs +1 -231
- package/dist/stores/index.cjs.map +1 -1
- package/dist/stores/index.js +1 -227
- package/dist/stores/index.js.map +1 -1
- package/dist/stores/localStorage.cjs +1 -76
- package/dist/stores/localStorage.cjs.map +1 -1
- package/dist/stores/localStorage.js +1 -74
- package/dist/stores/localStorage.js.map +1 -1
- package/dist/stores/memory.cjs +1 -47
- package/dist/stores/memory.cjs.map +1 -1
- package/dist/stores/memory.js +1 -45
- package/dist/stores/memory.js.map +1 -1
- package/dist/types-B1ZRa4aI.d.ts +147 -0
- package/dist/types-BxEFcVK9.d.cts +147 -0
- package/examples/browser/boards/portfolio-tracker/portfolio-t4.js +291 -0
- package/examples/browser/boards/portfolio-tracker/portfolio-tracker-fetch-prices.js +218 -0
- package/examples/browser/boards/portfolio-tracker/portfolio-tracker-fetch-prices.py +201 -0
- package/examples/browser/boards/portfolio-tracker/portfolio-tracker-http-test.js +357 -0
- package/examples/browser/boards/portfolio-tracker/portfolio-tracker-inference-adapter.js +25 -16
- package/examples/browser/boards/portfolio-tracker/portfolio-tracker-public.js +552 -0
- package/examples/browser/boards/portfolio-tracker/portfolio-tracker-server.js +300 -0
- package/examples/browser/boards/portfolio-tracker/portfolio-tracker-server.py +617 -0
- package/examples/browser/boards/portfolio-tracker/portfolio-tracker-sse-worker.js +48 -0
- package/examples/browser/boards/portfolio-tracker/portfolio-tracker.py +366 -0
- package/examples/cli/step-machine-cli/portfolio-tracker/--base-ref/.runtime-out +1 -0
- package/examples/cli/step-machine-cli/portfolio-tracker/--base-ref/board-graph.json +32 -0
- package/examples/cli/step-machine-cli/portfolio-tracker/handlers/_board-cli.js +70 -3
- package/examples/cli/step-machine-cli/portfolio-tracker/handlers/add-cards-cli.js +16 -11
- package/examples/cli/step-machine-cli/portfolio-tracker/handlers/init-board-cli.js +9 -8
- package/examples/cli/step-machine-cli/portfolio-tracker/handlers/poll-status-cli.js +49 -0
- package/examples/cli/step-machine-cli/portfolio-tracker/handlers/reset-board-dir-cli.js +2 -6
- package/examples/cli/step-machine-cli/portfolio-tracker/handlers/retrigger-cli.js +4 -8
- package/examples/cli/step-machine-cli/portfolio-tracker/handlers/status-cli.js +3 -7
- package/examples/cli/step-machine-cli/portfolio-tracker/handlers/update-holdings-cli.js +9 -8
- package/examples/cli/step-machine-cli/portfolio-tracker/handlers/wait-completed-cli.js +12 -17
- package/examples/cli/step-machine-cli/portfolio-tracker/handlers/write-prices-cli.js +2 -6
- package/examples/cli/step-machine-cli/portfolio-tracker/handlers-py/_board_pycli.py +107 -0
- package/examples/cli/step-machine-cli/portfolio-tracker/handlers-py/add-cards.py +51 -0
- package/examples/cli/step-machine-cli/portfolio-tracker/handlers-py/init-board.py +45 -0
- package/examples/cli/step-machine-cli/portfolio-tracker/handlers-py/poll-status.py +71 -0
- package/examples/cli/step-machine-cli/portfolio-tracker/handlers-py/reset-board-dir.py +36 -0
- package/examples/cli/step-machine-cli/portfolio-tracker/inline-python-demo.flow.yaml +26 -0
- package/examples/cli/step-machine-cli/portfolio-tracker/inline-python-handlers.py +39 -0
- package/examples/cli/step-machine-cli/portfolio-tracker/portfolio-tracker-pycli.flow.yaml +80 -0
- package/examples/cli/step-machine-cli/portfolio-tracker/portfolio-tracker.flow.yaml +36 -187
- package/examples/cli/step-machine-cli/portfolio-tracker/portfolio-tracker.input.json +40 -34
- package/examples/cli/step-machine-cli/portfolio-tracker/run-inline-python-demo-pycli.py +43 -0
- package/examples/cli/step-machine-cli/portfolio-tracker/run-portfolio-tracker-pycli.py +77 -0
- package/examples/cli/step-machine-cli/portfolio-tracker/run-portfolio-tracker.bat +1 -2
- package/examples/cli/step-machine-demo/jsonata-init-board-cli.js +8 -13
- package/examples/cli/step-machine-demo/jsonata-init-board.flow.yaml +33 -9
- package/examples/cli/step-machine-demo/one-step-cli-only.flow.yaml +3 -1
- package/examples/cli/step-machine-demo/step2-double-cli.js +6 -12
- package/examples/cli/step-machine-demo/two-step-math.flow.yaml +66 -4
- package/examples/cli/step-machine-demo/two-step-mixed.flow.yaml +13 -5
- package/examples/example-board/agent-instructions.md +11 -5
- package/examples/example-board/cards/_index.json +47 -0
- package/examples/example-board/cards/card-market-prices.json +33 -9
- package/examples/example-board/cards/card-my-identity.json +30 -6
- package/examples/example-board/cards/card-portfolio-action.json +24 -6
- package/examples/example-board/cards/card-portfolio-intelligence.json +97 -0
- package/examples/example-board/cards/card-portfolio-risks.json +24 -6
- package/examples/example-board/cards/card-portfolio-value.json +38 -10
- package/examples/example-board/cards/card-portfolio.json +57 -13
- package/examples/example-board/cards/card-rebalance-impact.json +22 -6
- package/examples/example-board/cards/card-rebalance-sim.json +66 -15
- package/examples/example-board/demo-chat-handler.js +14 -4
- package/examples/example-board/demo-server-config.json +1 -0
- package/examples/example-board/demo-server.js +366 -68
- package/examples/example-board/demo-shell-localstorage.html +774 -0
- package/examples/example-board/demo-shell-with-server.html +20 -37
- package/examples/example-board/demo-shell.html +5 -4
- package/examples/example-board/demo-task-executor.js +273 -275
- package/examples/index.html +0 -14
- package/examples/step-machine-cli/portfolio-tracker/handlers/_board-cli.js +0 -1
- package/examples/step-machine-cli/portfolio-tracker/run-portfolio-tracker.bat +1 -2
- package/package.json +46 -8
- package/schema/live-cards.schema.json +418 -132
- package/step-machine-cli.js +43 -310
- package/board-livecards-server-runtime.js +0 -1574
- package/browser/board-livecards-runtime-client.js +0 -263
- package/dist/cli/board-live-cards-cli.cjs +0 -10650
- package/dist/cli/board-live-cards-cli.cjs.map +0 -1
- package/dist/cli/board-live-cards-cli.d.cts +0 -179
- package/dist/cli/board-live-cards-cli.d.ts +0 -179
- package/dist/cli/board-live-cards-cli.js +0 -10598
- package/dist/cli/board-live-cards-cli.js.map +0 -1
- package/dist/journal-9HEgs7dU.d.ts +0 -28
- package/dist/journal-B-JCfQnh.d.cts +0 -28
- package/dist/schedule-Cszq9LYY.d.ts +0 -21
- package/dist/schedule-qWNL0RQh.d.cts +0 -21
- package/examples/browser/boards/portfolio-tracker/cards/holdings-table.json +0 -22
- package/examples/browser/boards/portfolio-tracker/cards/portfolio-form.json +0 -16
- package/examples/browser/boards/portfolio-tracker/cards/portfolio-risk-assessment.json +0 -28
- package/examples/browser/boards/portfolio-tracker/cards/portfolio-value.json +0 -15
- package/examples/browser/boards/portfolio-tracker/cards/price-fetch.json +0 -15
- package/examples/browser/boards/portfolio-tracker/cards/rebalancing-strategy.json +0 -28
- package/examples/browser/boards/portfolio-tracker/fetch-prices.js +0 -43
- package/examples/browser/boards/portfolio-tracker/portfolio-tracker-task-executor.cjs +0 -96
- package/examples/browser/boards/portfolio-tracker/portfolio-tracker.bat +0 -7
- package/examples/browser/boards/portfolio-tracker/portfolio-tracker.js +0 -351
- package/examples/cli/step-machine-demo/two-step-math-handlers.js +0 -32
- package/examples/cli/step-machine-demo/two-step-mixed-handlers.js +0 -24
- package/examples/example-board/demo-shell-browser.html +0 -674
|
@@ -0,0 +1,617 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""portfolio-tracker-server.py
|
|
3
|
+
|
|
4
|
+
Python port of portfolio-tracker-server.js.
|
|
5
|
+
|
|
6
|
+
Minimal single-board HTTP server for the portfolio-tracker example.
|
|
7
|
+
Uses create_single_board_server_runtime from py-server-runtime.
|
|
8
|
+
|
|
9
|
+
Cards are seeded inline on first start (if the card store is empty).
|
|
10
|
+
Task executor: portfolio-tracker-fetch-prices.py (mock-quotes source kind).
|
|
11
|
+
|
|
12
|
+
Notification flow: a background polling thread (2-second interval) calls
|
|
13
|
+
board.process_accumulated_events() using the server adapter, which has its
|
|
14
|
+
publish_board_change_notifications hook wired (by the runtime) to broadcast
|
|
15
|
+
SSE frames to connected clients. This bridges pycli task-executor callbacks
|
|
16
|
+
(which run in subprocesses with a no-op publish) to SSE clients.
|
|
17
|
+
|
|
18
|
+
Usage:
|
|
19
|
+
python portfolio-tracker-server.py [--port 7801] [--reset]
|
|
20
|
+
|
|
21
|
+
Endpoints (all under /api/board):
|
|
22
|
+
GET /api/board/init-board
|
|
23
|
+
GET /api/board/sse
|
|
24
|
+
GET /api/board/board-status
|
|
25
|
+
PATCH /api/board/cards/:id
|
|
26
|
+
POST /api/board/cards/:id/actions
|
|
27
|
+
"""
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import argparse
|
|
31
|
+
import http.server
|
|
32
|
+
import json
|
|
33
|
+
import os
|
|
34
|
+
import select
|
|
35
|
+
import shutil
|
|
36
|
+
import sys
|
|
37
|
+
import tempfile
|
|
38
|
+
import threading
|
|
39
|
+
import time
|
|
40
|
+
import uuid
|
|
41
|
+
from pathlib import Path
|
|
42
|
+
from typing import Any, Dict, List, Optional
|
|
43
|
+
from urllib.parse import urlparse, parse_qs, unquote
|
|
44
|
+
|
|
45
|
+
# ── Path resolution ────────────────────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
_HERE = os.path.dirname(os.path.abspath(__file__))
|
|
48
|
+
_REPO_ROOT = os.path.normpath(os.path.join(_HERE, '..', '..', '..', '..'))
|
|
49
|
+
_PYCLI_ROOT = os.path.join(_REPO_ROOT, 'pycli')
|
|
50
|
+
if _PYCLI_ROOT not in sys.path:
|
|
51
|
+
sys.path.insert(0, _PYCLI_ROOT)
|
|
52
|
+
|
|
53
|
+
_PY_SERVER_RUNTIME_DIR = os.path.join(_PYCLI_ROOT, 'py-server-runtime')
|
|
54
|
+
if _PY_SERVER_RUNTIME_DIR not in sys.path:
|
|
55
|
+
sys.path.insert(0, _PY_SERVER_RUNTIME_DIR)
|
|
56
|
+
|
|
57
|
+
from index import create_single_board_server_runtime # noqa: E402
|
|
58
|
+
|
|
59
|
+
_PYCLI_SUB = os.path.join(_PYCLI_ROOT, 'sub')
|
|
60
|
+
if _PYCLI_SUB not in sys.path:
|
|
61
|
+
sys.path.insert(0, _PYCLI_SUB)
|
|
62
|
+
|
|
63
|
+
from board_live_cards_adapters import ( # noqa: E402
|
|
64
|
+
ExecutionRef,
|
|
65
|
+
FsKvStorage,
|
|
66
|
+
FsBlobStorage,
|
|
67
|
+
FsJournalStorageAdapter,
|
|
68
|
+
FileAtomicRelayLock,
|
|
69
|
+
compute_stable_json_hash,
|
|
70
|
+
dispatch_execution as _dispatch_execution_impl,
|
|
71
|
+
)
|
|
72
|
+
from pylib.cli.storage_interface import parse_ref, serialize_ref # noqa: E402
|
|
73
|
+
from pylib.cli.board_live_cards_public import create_board_live_cards_public # noqa: E402
|
|
74
|
+
|
|
75
|
+
# ── CLI args ───────────────────────────────────────────────────────────────────
|
|
76
|
+
|
|
77
|
+
_parser = argparse.ArgumentParser()
|
|
78
|
+
_parser.add_argument('--port', type=int, default=7801)
|
|
79
|
+
_parser.add_argument('--reset', action='store_true')
|
|
80
|
+
_args = _parser.parse_args()
|
|
81
|
+
|
|
82
|
+
PORT = _args.port
|
|
83
|
+
RESET = _args.reset
|
|
84
|
+
|
|
85
|
+
# ── Paths ──────────────────────────────────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
SETUP_DIR = os.path.join(tempfile.gettempdir(), 'portfolio-tracker-py-server')
|
|
88
|
+
RUNTIME_DIR = os.path.join(SETUP_DIR, 'runtime')
|
|
89
|
+
CARDS_DIR = os.path.join(SETUP_DIR, 'cards')
|
|
90
|
+
OUTPUTS_DIR = os.path.join(SETUP_DIR, 'outputs')
|
|
91
|
+
FETCH_PRICES_PY = os.path.join(_HERE, 'portfolio-tracker-fetch-prices.py')
|
|
92
|
+
|
|
93
|
+
if RESET and os.path.exists(SETUP_DIR):
|
|
94
|
+
shutil.rmtree(SETUP_DIR, ignore_errors=True)
|
|
95
|
+
print(f'[portfolio-tracker-server.py] reset: wiped {SETUP_DIR}')
|
|
96
|
+
|
|
97
|
+
for _d in [RUNTIME_DIR, CARDS_DIR, OUTPUTS_DIR]:
|
|
98
|
+
os.makedirs(_d, exist_ok=True)
|
|
99
|
+
|
|
100
|
+
# ── Inline card definitions ────────────────────────────────────────────────────
|
|
101
|
+
|
|
102
|
+
INLINE_CARDS: List[Dict[str, Any]] = [
|
|
103
|
+
{
|
|
104
|
+
'id': 'portfolio-form',
|
|
105
|
+
'meta': {'title': 'Portfolio Holdings Form'},
|
|
106
|
+
'provides': [{'bindTo': 'holdings', 'ref': 'card_data.holdings'}],
|
|
107
|
+
'card_data': {'holdings': [{'symbol': 'AAPL', 'qty': 50}, {'symbol': 'MSFT', 'qty': 30}]},
|
|
108
|
+
'view': {
|
|
109
|
+
'elements': [
|
|
110
|
+
{'kind': 'table', 'label': 'Holdings',
|
|
111
|
+
'data': {'bind': 'card_data.holdings', 'columns': ['symbol', 'qty']}},
|
|
112
|
+
],
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
'id': 'price-fetch',
|
|
117
|
+
'meta': {'title': 'Fetch Market Prices'},
|
|
118
|
+
'requires': ['holdings'],
|
|
119
|
+
'provides': [{'bindTo': 'prices', 'ref': 'fetched_sources.prices'}],
|
|
120
|
+
'card_data': {},
|
|
121
|
+
'source_defs': [{
|
|
122
|
+
'kind': 'mock-quotes',
|
|
123
|
+
'bindTo': 'prices',
|
|
124
|
+
'outputFile': 'prices.json',
|
|
125
|
+
'projections': {'tickers': '$append([], requires.holdings.symbol)'},
|
|
126
|
+
}],
|
|
127
|
+
'view': {
|
|
128
|
+
'elements': [
|
|
129
|
+
{'kind': 'table', 'label': 'Market Prices', 'data': {'bind': 'fetched_sources.prices'}},
|
|
130
|
+
],
|
|
131
|
+
},
|
|
132
|
+
},
|
|
133
|
+
{
|
|
134
|
+
'id': 'holdings-table',
|
|
135
|
+
'meta': {'title': 'Holdings Table'},
|
|
136
|
+
'requires': ['holdings', 'prices'],
|
|
137
|
+
'provides': [{'bindTo': 'table', 'ref': 'computed_values.table'}],
|
|
138
|
+
'card_data': {},
|
|
139
|
+
'compute': [{
|
|
140
|
+
'bindTo': 'table',
|
|
141
|
+
'expr': ('{ "rows": $map(requires.holdings, function($h) {'
|
|
142
|
+
' { "symbol": $h.symbol, "qty": $h.qty,'
|
|
143
|
+
' "price": $lookup(requires.prices, $h.symbol),'
|
|
144
|
+
' "value": $h.qty * $lookup(requires.prices, $h.symbol) } }) }'),
|
|
145
|
+
}],
|
|
146
|
+
'view': {
|
|
147
|
+
'elements': [
|
|
148
|
+
{'kind': 'table', 'label': 'Portfolio Positions',
|
|
149
|
+
'data': {'bind': 'computed_values.table.rows', 'columns': ['symbol', 'qty', 'price', 'value']}},
|
|
150
|
+
],
|
|
151
|
+
},
|
|
152
|
+
},
|
|
153
|
+
{
|
|
154
|
+
'id': 'portfolio-value',
|
|
155
|
+
'meta': {'title': 'Portfolio Total Value'},
|
|
156
|
+
'requires': ['table'],
|
|
157
|
+
'provides': [{'bindTo': 'totalValue', 'ref': 'computed_values.totalValue'}],
|
|
158
|
+
'card_data': {},
|
|
159
|
+
'compute': [{'bindTo': 'totalValue', 'expr': '$sum(requires.table.rows.value)'}],
|
|
160
|
+
'view': {
|
|
161
|
+
'elements': [
|
|
162
|
+
{'kind': 'metric', 'label': 'Total Portfolio Value',
|
|
163
|
+
'data': {'bind': 'computed_values.totalValue'}},
|
|
164
|
+
],
|
|
165
|
+
},
|
|
166
|
+
},
|
|
167
|
+
]
|
|
168
|
+
|
|
169
|
+
# ── FS adapter helpers ─────────────────────────────────────────────────────────
|
|
170
|
+
|
|
171
|
+
def _make_kv(root: str):
|
|
172
|
+
kv = FsKvStorage(root)
|
|
173
|
+
class _KV:
|
|
174
|
+
def read(self, key): return kv.read(key)
|
|
175
|
+
def write(self, key, value): kv.write(key, value)
|
|
176
|
+
def delete(self, key): kv.delete(key)
|
|
177
|
+
def list_keys(self, prefix=None): return kv.list_keys(prefix)
|
|
178
|
+
return _KV()
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _make_blob(root: str):
|
|
182
|
+
blob = FsBlobStorage(root)
|
|
183
|
+
class _Blob:
|
|
184
|
+
def read(self, key): return blob.read(key)
|
|
185
|
+
def write(self, key, content): blob.write(key, content)
|
|
186
|
+
def exists(self, key): return blob.exists(key)
|
|
187
|
+
def remove(self, key): blob.remove(key)
|
|
188
|
+
def list_keys(self, prefix: str = '') -> List[str]:
|
|
189
|
+
root_path = Path(root)
|
|
190
|
+
if not root_path.is_dir():
|
|
191
|
+
return []
|
|
192
|
+
results = []
|
|
193
|
+
for p in root_path.rglob('*'):
|
|
194
|
+
if p.is_file():
|
|
195
|
+
rel = p.relative_to(root_path).as_posix()
|
|
196
|
+
if not prefix or rel.startswith(prefix):
|
|
197
|
+
results.append(rel)
|
|
198
|
+
return sorted(results)
|
|
199
|
+
return _Blob()
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _make_journal(scope: str):
|
|
203
|
+
j = FsJournalStorageAdapter(scope)
|
|
204
|
+
class _Journal:
|
|
205
|
+
def read_all_entries(self): return j.read_all_entries()
|
|
206
|
+
def append_entry(self, entry): j.append_entry(entry)
|
|
207
|
+
def generate_id(self): return j.generate_id()
|
|
208
|
+
return _Journal()
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _make_lock(lock_path: str):
|
|
212
|
+
lk = FileAtomicRelayLock(lock_path)
|
|
213
|
+
class _Lock:
|
|
214
|
+
def try_acquire(self): return lk.try_acquire()
|
|
215
|
+
return _Lock()
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def create_fs_board_platform_adapter(base_ref: Dict[str, str]):
|
|
219
|
+
"""FS-backed board platform adapter for the server context."""
|
|
220
|
+
scope = base_ref['value']
|
|
221
|
+
|
|
222
|
+
class _Adapter:
|
|
223
|
+
def kv_storage(self, namespace: str):
|
|
224
|
+
root = os.path.join(scope, f'.{namespace}') if namespace else scope
|
|
225
|
+
return _make_kv(root)
|
|
226
|
+
|
|
227
|
+
def kv_storage_for_ref(self, ref: str):
|
|
228
|
+
return _make_kv(parse_ref(ref)['value'])
|
|
229
|
+
|
|
230
|
+
def blob_storage(self, namespace: str):
|
|
231
|
+
root = os.path.join(scope, namespace) if namespace else scope
|
|
232
|
+
return _make_blob(root)
|
|
233
|
+
|
|
234
|
+
def journal_adapter(self):
|
|
235
|
+
return _make_journal(scope)
|
|
236
|
+
|
|
237
|
+
@property
|
|
238
|
+
def lock(self):
|
|
239
|
+
return _make_lock(os.path.join(scope, '.board.lock'))
|
|
240
|
+
|
|
241
|
+
@property
|
|
242
|
+
def self_ref(self) -> Dict[str, Any]:
|
|
243
|
+
return {
|
|
244
|
+
'meta': 'board-live-cards',
|
|
245
|
+
'howToRun': 'local-python',
|
|
246
|
+
'whatToRun': serialize_ref({'kind': 'fs-path', 'value': os.path.join(_PYCLI_ROOT, 'main', 'board_live_cards_pycli.py')}),
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
def dispatch_execution(self, ref: Dict[str, Any], args: Dict[str, Any]) -> Dict[str, Any]:
|
|
250
|
+
"""Dispatch a source-fetch task executor subprocess."""
|
|
251
|
+
exec_ref = ExecutionRef(
|
|
252
|
+
meta=ref.get('meta'),
|
|
253
|
+
howToRun=ref.get('howToRun', ''),
|
|
254
|
+
whatToRun=ref.get('whatToRun', ''),
|
|
255
|
+
)
|
|
256
|
+
label = (args.get('source_def') or {}).get('bindTo') or uuid.uuid4().hex[:8]
|
|
257
|
+
tmp_dir = os.path.join(scope, '.tmp')
|
|
258
|
+
os.makedirs(tmp_dir, exist_ok=True)
|
|
259
|
+
in_file = os.path.join(tmp_dir, f'exec-in-{label}.json')
|
|
260
|
+
out_file = os.path.join(tmp_dir, f'exec-out-{label}.json')
|
|
261
|
+
err_file = os.path.join(tmp_dir, f'exec-err-{label}.txt')
|
|
262
|
+
with open(in_file, 'w', encoding='utf-8') as f:
|
|
263
|
+
json.dump(args, f, indent=2)
|
|
264
|
+
return _dispatch_execution_impl(exec_ref, {
|
|
265
|
+
'subcommand': 'run-source-fetch',
|
|
266
|
+
'inRef': serialize_ref({'kind': 'fs-path', 'value': in_file}),
|
|
267
|
+
'outRef': serialize_ref({'kind': 'fs-path', 'value': out_file}),
|
|
268
|
+
'errRef': serialize_ref({'kind': 'fs-path', 'value': err_file}),
|
|
269
|
+
}, cwd=scope, detached=True)
|
|
270
|
+
|
|
271
|
+
def resolve_blob(self, ref: Dict[str, str]) -> str:
|
|
272
|
+
if ref.get('kind') == 'fs-path':
|
|
273
|
+
with open(ref['value'], 'r', encoding='utf-8') as f:
|
|
274
|
+
return f.read()
|
|
275
|
+
raise ValueError(f'resolveBlob: unsupported kind: {ref.get("kind")}')
|
|
276
|
+
|
|
277
|
+
def hash_fn(self, value: Any) -> str:
|
|
278
|
+
return compute_stable_json_hash(value)
|
|
279
|
+
|
|
280
|
+
def gen_id(self) -> str:
|
|
281
|
+
return uuid.uuid4().hex[:32]
|
|
282
|
+
|
|
283
|
+
def request_process_accumulated(self) -> None:
|
|
284
|
+
# No-op: the polling thread below drives drain in the server process
|
|
285
|
+
# every 2 seconds using this same adapter (with the runtime-hooked publish).
|
|
286
|
+
pass
|
|
287
|
+
|
|
288
|
+
def publish_board_change_notifications(self, notifications) -> None:
|
|
289
|
+
pass # Overridden by the runtime to broadcast to SSE clients
|
|
290
|
+
|
|
291
|
+
return _Adapter()
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def create_invocation_adapter():
|
|
295
|
+
"""Generic invocation adapter (for chat handlers etc — not used by portfolio-tracker)."""
|
|
296
|
+
class _Adapter:
|
|
297
|
+
def invoke(self, ref: Dict[str, Any], args: Dict[str, Any]) -> Dict[str, Any]:
|
|
298
|
+
how = ref.get('howToRun', '')
|
|
299
|
+
what = str(ref.get('whatToRun') or '')
|
|
300
|
+
if what.startswith('b64:'):
|
|
301
|
+
try:
|
|
302
|
+
parsed = parse_ref(what)
|
|
303
|
+
script_path = parsed.get('value') if parsed.get('kind') == 'fs-path' else ''
|
|
304
|
+
except Exception:
|
|
305
|
+
script_path = ''
|
|
306
|
+
else:
|
|
307
|
+
script_path = what
|
|
308
|
+
if not script_path:
|
|
309
|
+
return {'dispatched': False, 'error': f'no script path: {what}'}
|
|
310
|
+
if how == 'local-python':
|
|
311
|
+
interpreter = sys.executable
|
|
312
|
+
elif how == 'local-node':
|
|
313
|
+
interpreter = shutil.which('node') or 'node'
|
|
314
|
+
else:
|
|
315
|
+
return {'dispatched': False, 'error': f'unsupported howToRun: {how}'}
|
|
316
|
+
import base64
|
|
317
|
+
import subprocess
|
|
318
|
+
extra = base64.b64encode(json.dumps(args).encode('utf-8')).decode('ascii')
|
|
319
|
+
cmd = [interpreter, script_path,
|
|
320
|
+
'--boardId', str(args.get('boardId') or ''),
|
|
321
|
+
'--cardId', str(args.get('cardId') or ''),
|
|
322
|
+
'--extraEncJson', extra]
|
|
323
|
+
try:
|
|
324
|
+
if sys.platform == 'win32':
|
|
325
|
+
subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
|
326
|
+
creationflags=0x08000000)
|
|
327
|
+
else:
|
|
328
|
+
subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
|
329
|
+
start_new_session=True)
|
|
330
|
+
return {'dispatched': True}
|
|
331
|
+
except Exception as e:
|
|
332
|
+
return {'dispatched': False, 'error': str(e)}
|
|
333
|
+
return _Adapter()
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
# ── Board adapter & runtime ────────────────────────────────────────────────────
|
|
337
|
+
|
|
338
|
+
base_ref = parse_ref(serialize_ref({'kind': 'fs-path', 'value': RUNTIME_DIR}))
|
|
339
|
+
board_adapter = create_fs_board_platform_adapter(base_ref)
|
|
340
|
+
card_store_ref = serialize_ref({'kind': 'fs-path', 'value': os.path.join(CARDS_DIR, 'cards')})
|
|
341
|
+
outputs_store_ref = serialize_ref({'kind': 'fs-path', 'value': os.path.join(OUTPUTS_DIR, '.outputs')})
|
|
342
|
+
task_executor_ref = {
|
|
343
|
+
'howToRun': 'local-python',
|
|
344
|
+
'whatToRun': serialize_ref({'kind': 'fs-path', 'value': FETCH_PRICES_PY}),
|
|
345
|
+
'meta': 'task-executor',
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
_logger = type('L', (), {
|
|
349
|
+
'info': staticmethod(lambda msg, *a: print(f'[portfolio-tracker-server.py] {msg}', *a)),
|
|
350
|
+
'warn': staticmethod(lambda msg, *a: print(f'[portfolio-tracker-server.py][WARN] {msg}', *a)),
|
|
351
|
+
'error': staticmethod(lambda msg, *a: print(f'[portfolio-tracker-server.py][ERROR] {msg}', *a)),
|
|
352
|
+
})()
|
|
353
|
+
|
|
354
|
+
runtime = create_single_board_server_runtime({
|
|
355
|
+
'api_base_path': '/api/board',
|
|
356
|
+
'board_id': 'portfolio-tracker',
|
|
357
|
+
'boards': [{
|
|
358
|
+
'label': 'portfolio-tracker',
|
|
359
|
+
'board_adapter': board_adapter,
|
|
360
|
+
'base_ref': base_ref,
|
|
361
|
+
'card_store_ref': card_store_ref,
|
|
362
|
+
'outputs_store_ref': outputs_store_ref,
|
|
363
|
+
'task_executor_ref': task_executor_ref,
|
|
364
|
+
}],
|
|
365
|
+
'invocation_adapter': create_invocation_adapter(),
|
|
366
|
+
'logger': _logger,
|
|
367
|
+
'server_url': f'http://127.0.0.1:{PORT}',
|
|
368
|
+
})
|
|
369
|
+
|
|
370
|
+
# ── Card store seeding ─────────────────────────────────────────────────────────
|
|
371
|
+
|
|
372
|
+
_existing = runtime.card_store.get({})
|
|
373
|
+
_is_empty = _existing.get('status') != 'success' or not _existing.get('data', {}).get('cards')
|
|
374
|
+
if _is_empty:
|
|
375
|
+
runtime.card_store.set({'body': INLINE_CARDS})
|
|
376
|
+
print(f'[portfolio-tracker-server.py] seeded {len(INLINE_CARDS)} cards')
|
|
377
|
+
else:
|
|
378
|
+
print(f'[portfolio-tracker-server.py] card store already populated '
|
|
379
|
+
f'({len(_existing["data"]["cards"])} cards)')
|
|
380
|
+
|
|
381
|
+
# ── Polling drain thread ───────────────────────────────────────────────────────
|
|
382
|
+
#
|
|
383
|
+
# board_adapter.publish_board_change_notifications is now the runtime's SSE broadcast
|
|
384
|
+
# hook (set during create_single_board_server_runtime above).
|
|
385
|
+
#
|
|
386
|
+
# When portfolio-tracker-fetch-prices.py (the task executor) completes, it calls
|
|
387
|
+
# back via pycli (board_live_cards_pycli.py source-data-fetched). That subprocess
|
|
388
|
+
# uses NativeBoardPlatformAdapter whose publish_board_change_notifications is a no-op.
|
|
389
|
+
# The polling thread below creates a fresh board with the server's board_adapter and
|
|
390
|
+
# calls process_accumulated_events(), which reads the FS journal written by the pycli
|
|
391
|
+
# subprocess and broadcasts the results to SSE clients within ~2 seconds.
|
|
392
|
+
|
|
393
|
+
def _poll_drain() -> None:
|
|
394
|
+
while True:
|
|
395
|
+
time.sleep(2)
|
|
396
|
+
try:
|
|
397
|
+
poll_board = create_board_live_cards_public(base_ref, board_adapter)
|
|
398
|
+
poll_board.process_accumulated_events({})
|
|
399
|
+
publish = getattr(board_adapter, 'publish_board_change_notifications', None)
|
|
400
|
+
if callable(publish):
|
|
401
|
+
notifications: List[Dict[str, Any]] = []
|
|
402
|
+
status_result = poll_board.status({})
|
|
403
|
+
if status_result.get('status') == 'success' and status_result.get('data') is not None:
|
|
404
|
+
notifications.append({'kind': 'status', 'status': status_result['data']})
|
|
405
|
+
data_result = poll_board.get_all_outputs_data_objects({})
|
|
406
|
+
if data_result.get('status') == 'success' and isinstance(data_result.get('data'), dict):
|
|
407
|
+
for token, payload in data_result['data'].items():
|
|
408
|
+
if token:
|
|
409
|
+
notifications.append({'kind': 'data_object', 'key': token, 'payload': payload})
|
|
410
|
+
cv_result = poll_board.get_all_outputs_computed_values({})
|
|
411
|
+
if cv_result.get('status') == 'success' and isinstance(cv_result.get('data'), dict):
|
|
412
|
+
for card_id, values in cv_result['data'].items():
|
|
413
|
+
if card_id:
|
|
414
|
+
notifications.append({'kind': 'computed_values', 'cardId': card_id, 'values': values})
|
|
415
|
+
if notifications:
|
|
416
|
+
publish(notifications)
|
|
417
|
+
except Exception:
|
|
418
|
+
pass
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
_poll_thread = threading.Thread(target=_poll_drain, daemon=True, name='poll-drain')
|
|
422
|
+
_poll_thread.start()
|
|
423
|
+
|
|
424
|
+
# ── HTTP server ────────────────────────────────────────────────────────────────
|
|
425
|
+
|
|
426
|
+
CORS_HEADERS = {
|
|
427
|
+
'Access-Control-Allow-Origin': '*',
|
|
428
|
+
'Access-Control-Allow-Headers': 'content-type,x-file-name',
|
|
429
|
+
'Access-Control-Allow-Methods': 'GET,POST,PATCH,OPTIONS',
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
class ParsedUrl:
|
|
434
|
+
def __init__(self, url_str: str):
|
|
435
|
+
parsed = urlparse(url_str)
|
|
436
|
+
self.path = parsed.path
|
|
437
|
+
qs = parse_qs(parsed.query, keep_blank_values=True)
|
|
438
|
+
self.query_params = {k: v[0] for k, v in qs.items()}
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
class RequestAdapter:
|
|
442
|
+
def __init__(self, handler: http.server.BaseHTTPRequestHandler, body: bytes):
|
|
443
|
+
self._handler = handler
|
|
444
|
+
self._body = body
|
|
445
|
+
|
|
446
|
+
@property
|
|
447
|
+
def method(self) -> str:
|
|
448
|
+
return self._handler.command
|
|
449
|
+
|
|
450
|
+
@property
|
|
451
|
+
def path(self) -> str:
|
|
452
|
+
return urlparse(self._handler.path).path
|
|
453
|
+
|
|
454
|
+
@property
|
|
455
|
+
def headers(self) -> Dict[str, str]:
|
|
456
|
+
return {k.lower(): v for k, v in self._handler.headers.items()}
|
|
457
|
+
|
|
458
|
+
@property
|
|
459
|
+
def query_params(self) -> Dict[str, str]:
|
|
460
|
+
qs = parse_qs(urlparse(self._handler.path).query, keep_blank_values=True)
|
|
461
|
+
return {k: v[0] for k, v in qs.items()}
|
|
462
|
+
|
|
463
|
+
def read_body(self) -> bytes:
|
|
464
|
+
return self._body
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
class ResponseAdapter:
|
|
468
|
+
"""Adapts http.server to the py-server-runtime response protocol.
|
|
469
|
+
|
|
470
|
+
For SSE connections the caller must invoke wait_for_close() after
|
|
471
|
+
handle_runtime_api returns so that the thread stays alive (and the
|
|
472
|
+
TCP connection stays open) until the client disconnects.
|
|
473
|
+
Writes from other threads (notification broadcasts) are protected by
|
|
474
|
+
a per-connection lock.
|
|
475
|
+
"""
|
|
476
|
+
|
|
477
|
+
def __init__(self, handler: http.server.BaseHTTPRequestHandler):
|
|
478
|
+
self._handler = handler
|
|
479
|
+
self._headers_sent = False
|
|
480
|
+
self._status = 200
|
|
481
|
+
self._headers: Dict[str, str] = {}
|
|
482
|
+
self._is_sse = False
|
|
483
|
+
self._write_lock = threading.Lock()
|
|
484
|
+
|
|
485
|
+
def write_head(self, status_code: int, headers: Optional[Dict[str, str]] = None) -> None:
|
|
486
|
+
self._status = status_code
|
|
487
|
+
if headers:
|
|
488
|
+
self._headers.update(headers)
|
|
489
|
+
ct = (headers or {}).get('Content-Type', '')
|
|
490
|
+
if ct.startswith('text/event-stream'):
|
|
491
|
+
self._is_sse = True
|
|
492
|
+
|
|
493
|
+
def _send_headers_locked(self) -> None:
|
|
494
|
+
if self._headers_sent:
|
|
495
|
+
return
|
|
496
|
+
self._headers_sent = True
|
|
497
|
+
self._handler.send_response(self._status)
|
|
498
|
+
for k, v in self._headers.items():
|
|
499
|
+
self._handler.send_header(k, str(v))
|
|
500
|
+
self._handler.end_headers()
|
|
501
|
+
|
|
502
|
+
def write(self, data) -> None:
|
|
503
|
+
with self._write_lock:
|
|
504
|
+
self._send_headers_locked()
|
|
505
|
+
raw = data.encode('utf-8') if isinstance(data, str) else data
|
|
506
|
+
try:
|
|
507
|
+
self._handler.wfile.write(raw)
|
|
508
|
+
self._handler.wfile.flush()
|
|
509
|
+
except Exception:
|
|
510
|
+
pass # Client disconnected; broadcast will remove us from sse_clients
|
|
511
|
+
|
|
512
|
+
def end(self, data=None) -> None:
|
|
513
|
+
with self._write_lock:
|
|
514
|
+
self._send_headers_locked()
|
|
515
|
+
if data:
|
|
516
|
+
raw = data.encode('utf-8') if isinstance(data, str) else data
|
|
517
|
+
try:
|
|
518
|
+
self._handler.wfile.write(raw)
|
|
519
|
+
self._handler.wfile.flush()
|
|
520
|
+
except Exception:
|
|
521
|
+
pass
|
|
522
|
+
|
|
523
|
+
def wait_for_close(self) -> None:
|
|
524
|
+
"""Block the SSE-connection thread until the client disconnects."""
|
|
525
|
+
conn = self._handler.connection
|
|
526
|
+
try:
|
|
527
|
+
while True:
|
|
528
|
+
try:
|
|
529
|
+
r, _, e = select.select([conn], [], [conn], 1.0)
|
|
530
|
+
except Exception:
|
|
531
|
+
break
|
|
532
|
+
if e:
|
|
533
|
+
break
|
|
534
|
+
if r:
|
|
535
|
+
try:
|
|
536
|
+
data = conn.recv(16)
|
|
537
|
+
if not data:
|
|
538
|
+
break
|
|
539
|
+
except Exception:
|
|
540
|
+
break
|
|
541
|
+
except Exception:
|
|
542
|
+
pass
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
class _ThreadedServer(http.server.ThreadingHTTPServer):
|
|
546
|
+
daemon_threads = True
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
class _Handler(http.server.BaseHTTPRequestHandler):
|
|
550
|
+
def log_message(self, fmt, *args): # type: ignore[override]
|
|
551
|
+
pass # suppress per-request access log
|
|
552
|
+
|
|
553
|
+
def do_OPTIONS(self) -> None:
|
|
554
|
+
self.send_response(204)
|
|
555
|
+
for k, v in CORS_HEADERS.items():
|
|
556
|
+
self.send_header(k, v)
|
|
557
|
+
self.end_headers()
|
|
558
|
+
|
|
559
|
+
def do_GET(self) -> None:
|
|
560
|
+
self._handle()
|
|
561
|
+
|
|
562
|
+
def do_POST(self) -> None:
|
|
563
|
+
self._handle()
|
|
564
|
+
|
|
565
|
+
def do_PATCH(self) -> None:
|
|
566
|
+
self._handle()
|
|
567
|
+
|
|
568
|
+
def _handle(self) -> None:
|
|
569
|
+
content_length = int(self.headers.get('content-length') or 0)
|
|
570
|
+
body = self.rfile.read(content_length) if content_length > 0 else b''
|
|
571
|
+
|
|
572
|
+
req = RequestAdapter(self, body)
|
|
573
|
+
res = ResponseAdapter(self)
|
|
574
|
+
parsed = ParsedUrl(self.path)
|
|
575
|
+
|
|
576
|
+
try:
|
|
577
|
+
handled = runtime.handle_runtime_api(req, res, parsed)
|
|
578
|
+
except Exception as exc:
|
|
579
|
+
self.send_response(500)
|
|
580
|
+
self.send_header('Content-Type', 'application/json')
|
|
581
|
+
self.end_headers()
|
|
582
|
+
self.wfile.write(json.dumps({'error': str(exc)}).encode('utf-8'))
|
|
583
|
+
return
|
|
584
|
+
|
|
585
|
+
if not handled:
|
|
586
|
+
self.send_response(404)
|
|
587
|
+
self.send_header('Content-Type', 'application/json')
|
|
588
|
+
self.end_headers()
|
|
589
|
+
self.wfile.write(b'{"error":"Not found"}')
|
|
590
|
+
return
|
|
591
|
+
|
|
592
|
+
if res._is_sse:
|
|
593
|
+
# Keep the thread alive (and the TCP connection open) until the client
|
|
594
|
+
# disconnects. Other threads write SSE frames via res.write() under the
|
|
595
|
+
# per-connection lock above.
|
|
596
|
+
res.wait_for_close()
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
def main() -> None:
|
|
600
|
+
server = _ThreadedServer(('127.0.0.1', PORT), _Handler)
|
|
601
|
+
print(f'[portfolio-tracker-server.py] listening on http://127.0.0.1:{PORT}')
|
|
602
|
+
print(f'[portfolio-tracker-server.py] runtime dir: {RUNTIME_DIR}')
|
|
603
|
+
print('[portfolio-tracker-server.py] endpoints:')
|
|
604
|
+
print(' GET /api/board/init-board')
|
|
605
|
+
print(' GET /api/board/sse')
|
|
606
|
+
print(' GET /api/board/board-status')
|
|
607
|
+
print(' PATCH /api/board/cards/:id')
|
|
608
|
+
print(' POST /api/board/cards/:id/actions')
|
|
609
|
+
try:
|
|
610
|
+
server.serve_forever()
|
|
611
|
+
except KeyboardInterrupt:
|
|
612
|
+
print('\n[portfolio-tracker-server.py] shutting down')
|
|
613
|
+
server.shutdown()
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
if __name__ == '__main__':
|
|
617
|
+
main()
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* portfolio-tracker-sse-worker.js
|
|
3
|
+
*
|
|
4
|
+
* SSE consumer worker thread.
|
|
5
|
+
*
|
|
6
|
+
* Spawned by portfolio-tracker-http-test.js via worker_threads.
|
|
7
|
+
* Receives { sseUrl } in workerData, opens the SSE stream, and forwards
|
|
8
|
+
* every parsed frame to the main thread via parentPort.postMessage.
|
|
9
|
+
*
|
|
10
|
+
* Messages posted to main thread:
|
|
11
|
+
* { type: 'frame', payload: <parsed SSE frame object> }
|
|
12
|
+
* { type: 'error', message: <string> }
|
|
13
|
+
* { type: 'closed' }
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { parentPort, workerData } from 'node:worker_threads';
|
|
17
|
+
import http from 'node:http';
|
|
18
|
+
|
|
19
|
+
const { sseUrl } = workerData;
|
|
20
|
+
|
|
21
|
+
const req = http.get(sseUrl, (res) => {
|
|
22
|
+
let buf = '';
|
|
23
|
+
res.setEncoding('utf-8');
|
|
24
|
+
res.on('data', (chunk) => {
|
|
25
|
+
buf += chunk;
|
|
26
|
+
while (true) {
|
|
27
|
+
const idx = buf.indexOf('\n\n');
|
|
28
|
+
if (idx === -1) break;
|
|
29
|
+
const block = buf.slice(0, idx);
|
|
30
|
+
buf = buf.slice(idx + 2);
|
|
31
|
+
let data = '';
|
|
32
|
+
for (const line of block.split('\n')) {
|
|
33
|
+
if (line.startsWith('data: ')) data = line.slice(6);
|
|
34
|
+
}
|
|
35
|
+
if (!data) continue;
|
|
36
|
+
try {
|
|
37
|
+
parentPort.postMessage({ type: 'frame', payload: JSON.parse(data) });
|
|
38
|
+
} catch { /* ignore malformed */ }
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
res.on('end', () => parentPort.postMessage({ type: 'closed' }));
|
|
42
|
+
res.on('error', (err) => parentPort.postMessage({ type: 'error', message: err.message }));
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
req.on('error', (err) => parentPort.postMessage({ type: 'error', message: err.message }));
|
|
46
|
+
|
|
47
|
+
// Worker stays alive as long as the SSE connection is open.
|
|
48
|
+
// Main thread calls sseWorker.terminate() to clean up.
|