rpa-bot-sdk-core 1.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,31 @@
1
+ Metadata-Version: 2.4
2
+ Name: rpa-bot-sdk-core
3
+ Version: 1.0.0
4
+ Summary: Orchestrator bot-side core: server client, pause/stop, logging, config loading. Shared by rpa-bot-sdk-performer and rpa-bot-sdk-dispatcher.
5
+ Author: Kasun Perera
6
+ Requires-Python: >=3.9
7
+ Description-Content-Type: text/markdown
8
+ Requires-Dist: pandas
9
+ Requires-Dist: openpyxl
10
+ Provides-Extra: sqlcipher
11
+ Requires-Dist: sqlcipher3-binary; extra == "sqlcipher"
12
+ Requires-Dist: keyring; extra == "sqlcipher"
13
+ Provides-Extra: screenshot
14
+ Requires-Dist: pyautogui; extra == "screenshot"
15
+
16
+ # rpa-bot-sdk-core
17
+
18
+ The Orchestrator bot-side code shared by every role: `DbClient` (server auth,
19
+ queue, assets, storage, human-in-the-loop over HTTP, or a local SQLite queue
20
+ in StandaloneMode), `PauseController`/`checkpoint`, structured logging,
21
+ `init_all_settings()` (Config.xlsx loader), `take_screenshot()`, and the
22
+ `safe_*` UI-action wrappers.
23
+
24
+ Neither a Performer nor a Dispatcher project installs this directly — it's
25
+ the shared dependency of [`rpa-bot-sdk-performer`](../performer/README.md)
26
+ and [`rpa-bot-sdk-dispatcher`](../dispatcher/README.md), pulled in
27
+ automatically by whichever of those two a project actually needs.
28
+
29
+ Bump this package's version specifically when the server contract itself
30
+ changes (a `db_client.py` field/action). A change confined to one role's own
31
+ orchestration (e.g. `QueueManager`) belongs in that role's package instead.
@@ -0,0 +1,16 @@
1
+ # rpa-bot-sdk-core
2
+
3
+ The Orchestrator bot-side code shared by every role: `DbClient` (server auth,
4
+ queue, assets, storage, human-in-the-loop over HTTP, or a local SQLite queue
5
+ in StandaloneMode), `PauseController`/`checkpoint`, structured logging,
6
+ `init_all_settings()` (Config.xlsx loader), `take_screenshot()`, and the
7
+ `safe_*` UI-action wrappers.
8
+
9
+ Neither a Performer nor a Dispatcher project installs this directly — it's
10
+ the shared dependency of [`rpa-bot-sdk-performer`](../performer/README.md)
11
+ and [`rpa-bot-sdk-dispatcher`](../dispatcher/README.md), pulled in
12
+ automatically by whichever of those two a project actually needs.
13
+
14
+ Bump this package's version specifically when the server contract itself
15
+ changes (a `db_client.py` field/action). A change confined to one role's own
16
+ orchestration (e.g. `QueueManager`) belongs in that role's package instead.
@@ -0,0 +1,22 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "rpa-bot-sdk-core"
7
+ version = "1.0.0"
8
+ description = "Orchestrator bot-side core: server client, pause/stop, logging, config loading. Shared by rpa-bot-sdk-performer and rpa-bot-sdk-dispatcher."
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ authors = [{ name = "Kasun Perera" }]
12
+ dependencies = [
13
+ "pandas", # init_all_settings() reads Config.xlsx at import time
14
+ "openpyxl", # pandas' xlsx engine
15
+ ]
16
+
17
+ [project.optional-dependencies]
18
+ sqlcipher = ["sqlcipher3-binary", "keyring"] # StandaloneMode + SQLiteDatabaseType=SQLCipher only
19
+ screenshot = ["pyautogui"] # take_screenshot() only
20
+
21
+ [tool.setuptools.packages.find]
22
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,32 @@
1
+ """rpa_bot_sdk_core — the part of the Orchestrator's bot-side protocol layer
2
+ that BOTH a Performer and a Dispatcher project need: the server client, the
3
+ pause/stop controller, logging, Config.xlsx loading, and safe UI-action
4
+ wrappers. Nothing here knows which role is using it.
5
+
6
+ Split out from rpa_bot_sdk-performer / rpa_bot_sdk-dispatcher on purpose: a
7
+ Dispatcher machine installing rpa-bot-sdk-dispatcher pulls this in as its one
8
+ shared dependency, but never pulls in Performer-only code (queue_manager,
9
+ transaction states, ...), and vice versa. Bump this package's version when
10
+ the server contract itself changes (db_client.py); bump the role packages
11
+ when only their own orchestration changes.
12
+ """
13
+
14
+ __version__ = "1.0.0"
15
+
16
+ from .db_client import DbClient, standalone_enabled, standalone_queue_args
17
+ from .pause_controller import PauseController, checkpoint, get_pause_controller
18
+ from .structured_logger import setup_logging, attach_proxy_handler
19
+ from .init_all_settings import init_all_settings
20
+ from .utils import take_screenshot
21
+ from .safe_actions import (
22
+ safe_sleep, safe_click, safe_type_text, safe_fill_text, safe_call, safe_iter,
23
+ )
24
+
25
+ __all__ = [
26
+ "__version__",
27
+ "DbClient", "standalone_enabled", "standalone_queue_args",
28
+ "PauseController", "checkpoint", "get_pause_controller",
29
+ "setup_logging", "attach_proxy_handler",
30
+ "init_all_settings", "take_screenshot",
31
+ "safe_sleep", "safe_click", "safe_type_text", "safe_fill_text", "safe_call", "safe_iter",
32
+ ]
@@ -0,0 +1,709 @@
1
+ import json
2
+ import logging
3
+ import getpass
4
+ from contextlib import contextmanager
5
+ import socket as _hostsock
6
+ import uuid
7
+ import urllib.request
8
+ import urllib.error
9
+
10
+
11
+ def _machine_identity():
12
+ # hostname\\username — uniquely identifies this bot's Windows profile,
13
+ # computed automatically (no environment variable needed).
14
+ try:
15
+ return _hostsock.gethostname() + chr(92) + getpass.getuser()
16
+ except Exception:
17
+ try:
18
+ return _hostsock.gethostname()
19
+ except Exception:
20
+ return "Unknown"
21
+
22
+ logger = logging.getLogger("DbClient")
23
+
24
+
25
+ class _LocalQueue:
26
+ """A local stand-in for the Orchestrator queue, used only in StandaloneMode.
27
+
28
+ The SQLite templates keep BUSINESS data in a local SQLite file but the QUEUE
29
+ itself still lives in the Orchestrator. With no server there is therefore
30
+ nothing to dispatch into and nothing to pick up, so standalone needs a real
31
+ local queue or the Performer would drain zero items and exit reporting success.
32
+
33
+ This class implements the same verbs the proxy does, against a
34
+ '_standalone_queue' table inside the project's own SQLite database, and mirrors
35
+ the server's semantics deliberately:
36
+ * duplicate unique_id per (process, queue) is rejected
37
+ * items are picked oldest-New-first and claimed as InProgress
38
+ * InProgress items older than an hour revert to New (crash recovery)
39
+ Keeping it in the SAME database file means a SQLCipher project encrypts the
40
+ queue with the same key as its business data, and the client is handed one file.
41
+ """
42
+
43
+ _DDL = (
44
+ "CREATE TABLE IF NOT EXISTS _standalone_queue ("
45
+ " id INTEGER PRIMARY KEY AUTOINCREMENT,"
46
+ " unique_id TEXT, reference TEXT, json_data TEXT, json_data_secure TEXT,"
47
+ " status TEXT NOT NULL DEFAULT 'New', retry_count INTEGER NOT NULL DEFAULT 0,"
48
+ " exception_type TEXT, exception TEXT,"
49
+ " process_name TEXT, queue_name TEXT, client_name TEXT, user_name TEXT,"
50
+ " bot_id TEXT, machine_name TEXT,"
51
+ " created_at TEXT, start_time TEXT, end_time TEXT)",
52
+ "CREATE INDEX IF NOT EXISTS _sq_pick "
53
+ "ON _standalone_queue (status, process_name, queue_name, created_at)",
54
+ "CREATE UNIQUE INDEX IF NOT EXISTS _sq_unique "
55
+ "ON _standalone_queue (process_name, queue_name, unique_id)",
56
+ )
57
+ # Columns added after the first release. Applied with ALTER TABLE so a queue file
58
+ # created by an earlier bot keeps working instead of failing on an unknown column.
59
+ _MIGRATIONS = ("ALTER TABLE _standalone_queue ADD COLUMN json_data_secure TEXT",)
60
+
61
+ def __init__(self, db_path, password=None):
62
+ if not db_path:
63
+ raise RuntimeError(
64
+ "StandaloneMode needs a local queue database. Set "
65
+ "'SQLiteDatabasePath' in Config.xlsx.")
66
+ self._db_path = db_path
67
+ self._password = password or ""
68
+ import os as _os
69
+ _d = _os.path.dirname(_os.path.abspath(db_path))
70
+ if _d and not _os.path.isdir(_d):
71
+ _os.makedirs(_d, exist_ok=True)
72
+ with self._conn() as conn:
73
+ for stmt in self._DDL:
74
+ conn.execute(stmt)
75
+ for stmt in self._MIGRATIONS:
76
+ try:
77
+ conn.execute(stmt)
78
+ except Exception:
79
+ pass # already present
80
+ logger.info(f"Standalone queue ready: {db_path} (table _standalone_queue)")
81
+
82
+ @contextmanager
83
+ def _conn(self):
84
+ """One connection per operation, always closed.
85
+
86
+ Note `with sqlite3.connect(...)` commits but does NOT close, so a plain
87
+ `with` here would leak a handle for every queue operation in the run.
88
+ """
89
+ if self._password:
90
+ from sqlcipher3 import dbapi2 as _sq
91
+ else:
92
+ import sqlite3 as _sq
93
+ # isolation_level=None -> autocommit; the pick path opens its own
94
+ # BEGIN IMMEDIATE explicitly.
95
+ conn = _sq.connect(self._db_path, timeout=30, isolation_level=None)
96
+ try:
97
+ conn.row_factory = _sq.Row
98
+ if self._password:
99
+ # Doubling the quote is the only escape SQLite string literals have;
100
+ # PRAGMA key cannot be parameterised.
101
+ conn.execute("PRAGMA key='" + self._password.replace("'", "''") + "'")
102
+ yield conn
103
+ finally:
104
+ conn.close()
105
+
106
+ @staticmethod
107
+ def _now():
108
+ from datetime import datetime as _dt, timezone as _tz
109
+ return _dt.now(_tz.utc).strftime("%Y-%m-%d %H:%M:%S")
110
+
111
+ def handle(self, action, payload):
112
+ fn = getattr(self, "_do_" + action, None)
113
+ if fn is None:
114
+ return {"status": "ok", "standalone": True}
115
+ try:
116
+ return fn(payload or {})
117
+ except Exception as e:
118
+ logger.error(f"Standalone queue error on {action}: {e}")
119
+ return {"status": "error", "message": str(e)}
120
+
121
+ def _do_add_queue_item(self, p):
122
+ import json as _json
123
+ raw = p.get("json_data", p.get("excel_file_path", ""))
124
+ if isinstance(raw, dict):
125
+ jd = raw
126
+ elif isinstance(raw, str) and raw.strip().startswith("{"):
127
+ try:
128
+ jd = _json.loads(raw)
129
+ except Exception:
130
+ jd = {"Excel File Path": raw}
131
+ else:
132
+ jd = {"Excel File Path": raw}
133
+ proc = p.get("process_name", "")
134
+ qname = (str(p.get("queue_name") or "").strip() or "Main")
135
+ uid = p.get("unique_id")
136
+ with self._conn() as conn:
137
+ if uid:
138
+ cur = conn.execute(
139
+ "SELECT id FROM _standalone_queue "
140
+ "WHERE process_name=? AND queue_name=? AND unique_id=?",
141
+ (proc, qname, uid))
142
+ if cur.fetchone():
143
+ msg = (f"Duplicate unique_id '{uid}' in queue '{qname}' "
144
+ f"for process '{proc}'.")
145
+ logger.warning(f"Standalone queue insert rejected: {msg}")
146
+ return {"status": "error", "message": msg}
147
+ # json_data_secure is the Fernet ciphertext a Secure Cloud bot produced
148
+ # with its phi.key. Store it verbatim and never look inside — dropping it
149
+ # here would silently lose every encrypted field on the way back out.
150
+ secure_blob = p.get("json_data_secure")
151
+ if secure_blob is not None and not isinstance(secure_blob, str):
152
+ secure_blob = _json.dumps(secure_blob)
153
+ conn.execute(
154
+ "INSERT INTO _standalone_queue (unique_id, reference, json_data,"
155
+ " json_data_secure, status,"
156
+ " retry_count, exception_type, exception, process_name, queue_name,"
157
+ " client_name, user_name, bot_id, machine_name, created_at)"
158
+ " VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
159
+ (uid, p.get("reference"), _json.dumps(jd), secure_blob,
160
+ p.get("status", "New"),
161
+ int(p.get("retry_count") or 0), p.get("exception_type"),
162
+ p.get("exception"), proc, qname, p.get("client_name", ""),
163
+ p.get("user_name", ""), str(p.get("bot_id") or ""),
164
+ p.get("machine_name", ""), self._now()))
165
+ return {"status": "ok"}
166
+
167
+ def _do_get_next_transaction(self, p):
168
+ proc = p.get("process_name", "")
169
+ qname = (str(p.get("queue_name") or "").strip() or "Main")
170
+ ref = (p.get("reference_filter") or "").strip()
171
+ with self._conn() as conn:
172
+ # Same crash-recovery rule the server applies: a run that died mid-item
173
+ # would otherwise strand it as InProgress forever.
174
+ from datetime import datetime as _dt, timezone as _tz, timedelta as _td
175
+ cutoff = (_dt.now(_tz.utc) - _td(hours=1)).strftime("%Y-%m-%d %H:%M:%S")
176
+ conn.execute(
177
+ "UPDATE _standalone_queue SET status='New', start_time=NULL,"
178
+ " exception=NULL, exception_type=NULL"
179
+ " WHERE status='InProgress' AND start_time IS NOT NULL AND start_time < ?",
180
+ (cutoff,))
181
+ sql = ("SELECT id, unique_id, json_data, json_data_secure, retry_count,"
182
+ " reference FROM _standalone_queue WHERE status='New' AND queue_name=?")
183
+ args = [qname]
184
+ if proc:
185
+ sql += " AND process_name=?"; args.append(proc)
186
+ if ref:
187
+ sql += " AND reference LIKE ?"; args.append(ref + "%")
188
+ sql += " ORDER BY created_at ASC, id ASC LIMIT 1"
189
+ # BEGIN IMMEDIATE takes the write lock up front, so two bots sharing the
190
+ # file cannot both claim the same row between the SELECT and the UPDATE.
191
+ conn.execute("BEGIN IMMEDIATE")
192
+ try:
193
+ row = conn.execute(sql, args).fetchone()
194
+ if not row:
195
+ conn.execute("COMMIT")
196
+ return {"status": "ok", "data": None}
197
+ conn.execute(
198
+ "UPDATE _standalone_queue SET status='InProgress', start_time=?,"
199
+ " bot_id=?, machine_name=?, exception=NULL, exception_type=NULL,"
200
+ " end_time=NULL WHERE id=?",
201
+ (self._now(), str(p.get("bot_id") or ""),
202
+ p.get("machine_name", ""), row["id"]))
203
+ conn.execute("COMMIT")
204
+ except Exception:
205
+ conn.execute("ROLLBACK")
206
+ raise
207
+ logger.debug(f"Standalone transaction locked -> {row['unique_id']}")
208
+ return {"status": "ok", "data": dict(row)}
209
+
210
+ def _do_update_transaction_status(self, p):
211
+ sets, args = ["status=?"], [p.get("status")]
212
+ if "exception_type" in p: sets.append("exception_type=?"); args.append(p.get("exception_type"))
213
+ if "error" in p: sets.append("exception=?"); args.append(p.get("error"))
214
+ if "retry_count" in p: sets.append("retry_count=?"); args.append(int(p.get("retry_count") or 0))
215
+ sets.append("end_time=?"); args.append(p.get("end_time") or self._now())
216
+ args.append(p.get("id"))
217
+ with self._conn() as conn:
218
+ conn.execute("UPDATE _standalone_queue SET " + ", ".join(sets) + " WHERE id=?", args)
219
+ return {"status": "ok"}
220
+
221
+ def _do_update_queue_data(self, p):
222
+ import json as _json
223
+ jd = p.get("json_data", {})
224
+ if not isinstance(jd, str):
225
+ jd = _json.dumps(jd)
226
+ sets, args = ["json_data=?"], [jd]
227
+ # Only rewrite the ciphertext when this update actually carries one. A blind
228
+ # write would blank the encrypted half whenever a bot updates plaintext only.
229
+ if p.get("json_data_secure") is not None:
230
+ blob = p["json_data_secure"]
231
+ sets.append("json_data_secure=?")
232
+ args.append(blob if isinstance(blob, str) else _json.dumps(blob))
233
+ args.append(p.get("id"))
234
+ with self._conn() as conn:
235
+ conn.execute("UPDATE _standalone_queue SET " + ", ".join(sets) + " WHERE id=?", args)
236
+ return {"status": "ok"}
237
+
238
+ def _do_get_queue_count(self, p):
239
+ qname = (str(p.get("queue_name") or "").strip() or "Main")
240
+ proc = p.get("process_name", "")
241
+ sql = "SELECT COUNT(*) FROM _standalone_queue WHERE status='New' AND queue_name=?"
242
+ args = [qname]
243
+ if proc:
244
+ sql += " AND process_name=?"; args.append(proc)
245
+ with self._conn() as conn:
246
+ n = conn.execute(sql, args).fetchone()[0]
247
+ return {"status": "ok", "count": n}
248
+
249
+
250
+ def standalone_enabled(config: dict = None) -> bool:
251
+ """True when Config.xlsx says StandaloneMode -> run with no Orchestrator at all.
252
+
253
+ Config.xlsx is deliberately the ONLY source. An environment variable would be a
254
+ second, easier way to switch central logging off, and an operator setting one to
255
+ make connection errors go away would silently leave a PHI bot unaudited. Turning
256
+ monitoring off has to be a change someone makes to the project's own config.
257
+ """
258
+ val = str((config or {}).get("StandaloneMode", "") or "").strip().lower()
259
+ return val in ("1", "true", "yes", "y", "on")
260
+
261
+
262
+ def standalone_queue_args(config: dict = None) -> dict:
263
+ """DbClient kwargs for the local queue, read from Config.xlsx.
264
+
265
+ Path resolution, in order:
266
+ StandaloneQueuePath - explicit, wins if set
267
+ SQLiteDatabasePath - a SQLite project keeps queue and business data in ONE
268
+ file, so SQLCipher encrypts both with the same key and
269
+ the client is handed a single file
270
+ data/StandaloneQueue.sqlite - Excel and Secure Cloud projects, which have no
271
+ database of their own
272
+
273
+ A SQLCipher project takes the key from Windows Credential Manager exactly as its
274
+ business tables do, so the queue is encrypted with the same key instead of
275
+ sitting in the clear beside the data it points at.
276
+ """
277
+ cfg = config or {}
278
+ path = str(cfg.get("StandaloneQueuePath", "") or "").strip()
279
+ if not path:
280
+ path = str(cfg.get("SQLiteDatabasePath", "") or "").strip()
281
+ if not path:
282
+ path = "data/StandaloneQueue.sqlite"
283
+ pw = None
284
+ if str(cfg.get("SQLiteDatabaseType", "") or "").strip().lower() == "sqlcipher":
285
+ cred = str(cfg.get("SQLiteCredentialName", "") or "").strip()
286
+ if not cred:
287
+ raise RuntimeError("SQLiteCredentialName is required for SQLCipher mode.")
288
+ import keyring
289
+ pw = keyring.get_password("Windows", cred)
290
+ if not pw:
291
+ raise RuntimeError("No password found in Windows Credential Manager for "
292
+ "SQLiteCredentialName: " + cred)
293
+ return {"queue_db_path": path, "queue_db_password": pw}
294
+
295
+
296
+ class DbClient:
297
+ def __init__(self, base_url="http://127.0.0.1", api_key=None, standalone=False,
298
+ queue_db_path=None, queue_db_password=None):
299
+ """
300
+ base_url: The ALB URL or Server IP (e.g., https://rpa-orchestrator-prod-alb...)
301
+ api_key: The enterprise unique machine token
302
+ standalone: run with NO Orchestrator at all (Config.xlsx StandaloneMode=true).
303
+ queue_db_path / queue_db_password: standalone only — the local SQLite file
304
+ that holds the queue (Config.xlsx SQLiteDatabasePath, and the SQLCipher
305
+ key when SQLiteDatabaseType is SQLCipher).
306
+
307
+ Standalone mode is for bots handed to a client who does not take the
308
+ Orchestrator service. Every server call becomes a local no-op, so the bot
309
+ runs entirely on its own local queue and writes only to its local log file.
310
+ What is given up, deliberately:
311
+ * no remote Pause / Stop / Cancel
312
+ * no central run history, logs or dashboards
313
+ * no alerting on failure
314
+ It must be switched on EXPLICITLY — an unreachable Orchestrator is still a
315
+ hard error, so a connected bot can never fall back to running unmonitored.
316
+ """
317
+ self.base_url = base_url.rstrip('/')
318
+ self.api_key = api_key
319
+ self.standalone = bool(standalone)
320
+ # Built eagerly so a misconfigured standalone bot fails at startup rather
321
+ # than at the first queue write, halfway through a run.
322
+ self._local = _LocalQueue(queue_db_path, queue_db_password) if standalone else None
323
+ self.run_token: str = "" # set by register_run_token()
324
+
325
+ # ── core command ──────────────────────────────────────────────────────
326
+ def send_command(self, action: str, payload: dict = None) -> dict:
327
+ if self.standalone:
328
+ # Queue verbs are served from the local SQLite queue; run-tracking verbs
329
+ # (register/close token, get_job_command, write_run_log) have no local
330
+ # equivalent and fall through to a harmless ok.
331
+ return self._local.handle(action, payload)
332
+ url = f"{self.base_url}/api/bot/command"
333
+ payload = payload or {}
334
+ if self.api_key and "api_key" not in payload:
335
+ payload["api_key"] = self.api_key
336
+ data = json.dumps({"action": action, "payload": payload}).encode("utf-8")
337
+
338
+ try:
339
+ req = urllib.request.Request(url, data=data, headers={'Content-Type': 'application/json'})
340
+ with urllib.request.urlopen(req, timeout=15) as response:
341
+ res = json.loads(response.read().decode("utf-8"))
342
+
343
+ # Auto re-register if worker didn't know the token
344
+ if res.get("status") == "error" and "unregistered run_token" in str(res.get("message", "")):
345
+ if action != "register_run_token" and hasattr(self, "_last_proc_name"):
346
+ machine = _machine_identity()
347
+ reg_payload = {
348
+ "api_key": self.api_key,
349
+ "token": getattr(self, "run_token", ""),
350
+ "machine_name": machine,
351
+ "process_name": self._last_proc_name,
352
+ "process_type": self._last_proc_type,
353
+ "user_name": getattr(self, "_last_user_name", ""),
354
+ "sub_process_name": getattr(self, "_last_sub_proc", ""),
355
+ # Must carry the folder too. This re-registration runs
356
+ # when the load balancer sends us to a node that never
357
+ # saw the token; dropping FolderPath here would re-bind
358
+ # the run to whichever same-named project that node
359
+ # resolves first, mid-run, once enforcement is on.
360
+ "folder_path": getattr(self, "_last_folder", "")
361
+ }
362
+ reg_data = json.dumps({"action": "register_run_token", "payload": reg_payload}).encode("utf-8")
363
+ reg_req = urllib.request.Request(url, data=reg_data, headers={'Content-Type': 'application/json'})
364
+
365
+ # Loop to try to get past load balancer sending us to different workers
366
+ for _ in range(5):
367
+ try:
368
+ urllib.request.urlopen(reg_req, timeout=15)
369
+ except Exception:
370
+ pass
371
+
372
+ try:
373
+ with urllib.request.urlopen(req, timeout=15) as retry_resp:
374
+ r_json = json.loads(retry_resp.read().decode("utf-8"))
375
+ if not (r_json.get("status") == "error" and "unregistered run_token" in str(r_json.get("message", ""))):
376
+ return r_json
377
+ except Exception:
378
+ pass
379
+ return res
380
+ except urllib.error.URLError as e:
381
+ logger.error(f"HTTP Connection Error to {self.base_url}: {e}")
382
+ return {"status": "error", "message": f"Connection refused: {e}"}
383
+ except Exception as e:
384
+ logger.error(f"DbClient error: {e}")
385
+ return {"status": "error", "message": str(e)}
386
+
387
+ def register_run_token(self, process_name: str,
388
+ process_type: str,
389
+ machine_name: str = "",
390
+ user_name: str = "",
391
+ client_name: str = "",
392
+ sub_process_name: str = "",
393
+ folder_path: str = "") -> str:
394
+ """
395
+ Generate a unique run token, register it with the proxy so it
396
+ creates logs/{token}.log, and store it for subsequent calls.
397
+ Returns the token string.
398
+
399
+ folder_path is Config.xlsx 'FolderPath' — where this project sits in the
400
+ Orchestrator, e.g. 'MKV/Claim Status'. ProcessName alone is not unique
401
+ (Test and Prod share one, and two folders may each hold a 'Dispatcher'),
402
+ so without it the server picks whichever row it finds first. Blank is
403
+ accepted until the Orchestrator has 'Require every bot to name its
404
+ folder' switched on.
405
+ """
406
+ import socket as _socket
407
+ import uuid
408
+
409
+ self._last_proc_name = process_name
410
+ self._last_proc_type = process_type
411
+ self._last_user_name = user_name
412
+ self._last_sub_proc = sub_process_name
413
+ self._last_folder = folder_path
414
+
415
+ if not hasattr(self, "run_token") or not self.run_token:
416
+ self.run_token = uuid.uuid4().hex[:8]
417
+
418
+ if self.standalone:
419
+ # No server to register with — keep the token so local log lines still
420
+ # carry a run id, and carry on.
421
+ logger.info(f"Standalone mode: run {self.run_token[:8]}... "
422
+ f"| {process_name} [{process_type}] (no Orchestrator)")
423
+ return self.run_token
424
+
425
+ token = self.run_token
426
+ if not machine_name:
427
+ machine_name = _machine_identity()
428
+ res = self.send_command("register_run_token", {
429
+ "token": token,
430
+ "process_name": process_name,
431
+ "process_type": process_type,
432
+ "machine_name": machine_name,
433
+ "user_name": user_name,
434
+ "sub_process_name": sub_process_name,
435
+ "folder_path": folder_path,
436
+ })
437
+ # client_name is accepted above and deliberately NOT sent. The server
438
+ # filters on it when present, and a Dispatcher never calls get_next, so
439
+ # a project whose Config.xlsx carries a stale ClientName would start
440
+ # failing at registration the day it was added. FolderPath already
441
+ # identifies the project; adding a second new filter in the same release
442
+ # would make a rejection ambiguous about which of the two was wrong.
443
+ if res.get("status") == "ok":
444
+ self.run_token = token
445
+ logger.info(f"Run token registered: {token[:8]}... "
446
+ f"| {process_name} [{process_type}] @ {machine_name}")
447
+ else:
448
+ msg = res.get("message", "Unknown error")
449
+ logger.error(f"register_run_token REJECTED: {msg}")
450
+ raise RuntimeError(
451
+ f"Proxy rejected token registration: {msg}. "
452
+ f"Fix Config.xlsx and restart the bot.")
453
+ return token
454
+
455
+
456
+ def close_run_token(self, summary: dict = None) -> None:
457
+ """Signal the proxy that this run has finished."""
458
+ if not self.run_token:
459
+ return
460
+ self.send_command("close_run_token", {
461
+ "token": self.run_token,
462
+ "summary": summary or {},
463
+ })
464
+ logger.info(f"Run token closed: {self.run_token[:8]}...")
465
+ self.run_token = ""
466
+
467
+ def get_job_command(self) -> str:
468
+ """Poll the orchestrator for pending commands (PAUSE, STOP)."""
469
+ if self.standalone:
470
+ return None # nobody can steer a standalone run
471
+ if not self.run_token: return None
472
+ res = self.send_command("get_job_command", {"token": self.run_token})
473
+ if res.get("status") == "ok":
474
+ return res.get("command")
475
+ return None
476
+
477
+ def get_queue_count(self, process_name: str, process_type: str = "", queue_name: str = "Main") -> str:
478
+ """Poll the orchestrator for pending queue count."""
479
+ res = self.send_command("get_queue_count", {
480
+ "process_name": process_name,
481
+ "process_type": process_type,
482
+ "queue_name": queue_name
483
+ })
484
+ if res.get("status") == "ok":
485
+ return str(res.get("count", "Unknown"))
486
+ return "Unknown"
487
+
488
+ def get_asset(self, name: str, process_name: str = "", default=None):
489
+ """Read one centrally-managed asset (config value or credential).
490
+
491
+ Assets live in the Orchestrator, so a portal password changes in ONE place
492
+ instead of being edited by hand in Config.xlsx on every bot machine, and
493
+ the change is audited.
494
+
495
+ Falls back to `default` when the Orchestrator has no such asset or is
496
+ unreachable, so a project can move across one value at a time rather than
497
+ needing every asset migrated before the bot will run.
498
+ """
499
+ if self.standalone:
500
+ return default
501
+ res = self.send_command("get_asset", {
502
+ "name": name,
503
+ "process_name": process_name or getattr(self, "_last_proc_name", ""),
504
+ "machine_name": _machine_identity().split(chr(92))[0],
505
+ })
506
+ if res.get("status") == "ok":
507
+ return res.get("value")
508
+ logger.debug(f"Asset '{name}' not served: {res.get('message')}")
509
+ return default
510
+
511
+ def get_assets(self, process_name: str = "") -> dict:
512
+ """Every asset of this project in one round trip. Returns {} on failure,
513
+ so callers can merge over their Config.xlsx values without branching."""
514
+ if self.standalone:
515
+ return {}
516
+ res = self.send_command("get_assets", {
517
+ "process_name": process_name or getattr(self, "_last_proc_name", ""),
518
+ "machine_name": _machine_identity().split(chr(92))[0],
519
+ })
520
+ if res.get("status") == "ok":
521
+ return res.get("assets") or {}
522
+ logger.debug(f"Assets not served: {res.get('message')}")
523
+ return {}
524
+
525
+ def put_file(self, key: str, data, process_name: str = "",
526
+ bucket: str = "default", content_type: str = None,
527
+ is_encrypted: bool = False) -> dict:
528
+ """Upload a file to the Orchestrator so it is attached to this project.
529
+
530
+ `data` may be bytes or a path to a local file. The key is a label such as
531
+ "invoices/2026-08/INV-1002.pdf" — it is stored as a name, never used as a
532
+ path on the server, so any shape is safe.
533
+
534
+ PHI: encrypt the bytes with this project's phi.key BEFORE calling, and
535
+ pass is_encrypted=True. The server stores what it is given and cannot
536
+ read an encrypted payload — uploading PHI in the clear would put it
537
+ somewhere the zero-knowledge model does not protect.
538
+ """
539
+ import base64 as _b64
540
+ if self.standalone:
541
+ return {"status": "error", "message": "Standalone mode: no Orchestrator storage"}
542
+ if isinstance(data, str):
543
+ with open(data, "rb") as _fh:
544
+ raw = _fh.read()
545
+ else:
546
+ raw = bytes(data)
547
+ return self.send_command("put_object", {
548
+ "process_name": process_name or getattr(self, "_last_proc_name", ""),
549
+ "machine_name": _machine_identity().split(chr(92))[0],
550
+ "key": key, "bucket": bucket, "content_type": content_type,
551
+ "is_encrypted": bool(is_encrypted),
552
+ "run_token": self.run_token,
553
+ "data_b64": _b64.b64encode(raw).decode("ascii"),
554
+ })
555
+
556
+ def get_file(self, key: str, process_name: str = "", bucket: str = "default",
557
+ save_to: str = None):
558
+ """Download a stored file. Returns bytes, or writes to save_to and
559
+ returns the path. Returns None when the file is not there."""
560
+ import base64 as _b64
561
+ if self.standalone:
562
+ return None
563
+ res = self.send_command("get_object", {
564
+ "process_name": process_name or getattr(self, "_last_proc_name", ""),
565
+ "machine_name": _machine_identity().split(chr(92))[0],
566
+ "key": key, "bucket": bucket,
567
+ })
568
+ if res.get("status") != "ok":
569
+ logger.debug(f"File '{key}' not served: {res.get('message')}")
570
+ return None
571
+ raw = _b64.b64decode(res.get("data_b64") or "")
572
+ if save_to:
573
+ with open(save_to, "wb") as _fh:
574
+ _fh.write(raw)
575
+ return save_to
576
+ return raw
577
+
578
+ def list_files(self, process_name: str = "", bucket: str = None) -> list:
579
+ """Files stored for this project. Returns [] on failure."""
580
+ if self.standalone:
581
+ return []
582
+ res = self.send_command("list_objects", {
583
+ "process_name": process_name or getattr(self, "_last_proc_name", ""),
584
+ "machine_name": _machine_identity().split(chr(92))[0],
585
+ "bucket": bucket,
586
+ })
587
+ return res.get("objects") or [] if res.get("status") == "ok" else []
588
+
589
+ def ask_human(self, title: str, prompt: str = "", fields: list = None,
590
+ context: dict = None, secure_context: dict = None,
591
+ process_name: str = "", queue_item_id=None, priority: int = 50,
592
+ expires_in_hours: int = None) -> int:
593
+ """Ask a person something this run cannot decide. Returns an action id.
594
+
595
+ `context` is shown to the reviewer as-is, so keep PHI out of it.
596
+ `secure_context` is for anything with patient data: on a Secure Cloud bot
597
+ it is encrypted with this project's phi.key before it leaves the machine,
598
+ and only the reviewer's browser can read it. The plain DbClient has no
599
+ key, so it REFUSES secure_context rather than sending PHI in the clear.
600
+
601
+ `fields` describes what to ask, e.g.
602
+ [{"name": "decision", "type": "choice",
603
+ "options": ["Approve", "Deny", "Send back"], "label": "What should we do?"}]
604
+
605
+ Don't block a machine waiting on this. Raise the action, then postpone
606
+ the transaction (`postpone_until`) and let the item come round again.
607
+ """
608
+ if self.standalone:
609
+ return 0
610
+ if secure_context and getattr(self, "_phi", None) is None:
611
+ # A plain DbClient has no phi.key, so there is nothing to encrypt with.
612
+ return self._refuse_phi("ask_human")
613
+ payload = {
614
+ "process_name": process_name or getattr(self, "_last_proc_name", ""),
615
+ "machine_name": _machine_identity().split(chr(92))[0],
616
+ "title": title, "prompt": prompt, "form_schema": fields or [],
617
+ "form_data": context or {}, "priority": priority,
618
+ "run_token": self.run_token, "queue_item_id": queue_item_id,
619
+ }
620
+ if secure_context:
621
+ # Carried only as far as _SecureDbClient.send_command, which encrypts
622
+ # it and removes it from the payload. It never reaches the network.
623
+ payload["secure_context"] = secure_context
624
+ if expires_in_hours:
625
+ from datetime import datetime as _dt, timezone as _tz, timedelta as _td
626
+ payload["expires_at"] = (
627
+ _dt.now(_tz.utc) + _td(hours=int(expires_in_hours))).isoformat()
628
+ res = self.send_command("create_action", payload)
629
+ if res.get("status") != "ok":
630
+ logger.error(f"Could not raise action '{title}': {res.get('message')}")
631
+ return 0
632
+ logger.info(f"Action #{res['action_id']} raised for review: {title}")
633
+ return res["action_id"]
634
+
635
+ def _refuse_phi(self, where: str) -> int:
636
+ # Reached only on a non-Secure project, which has no phi.key. Sending the
637
+ # data anyway would put PHI somewhere the zero-knowledge model does not
638
+ # cover, so refuse instead — a failed run is recoverable, a disclosure is not.
639
+ logger.critical(
640
+ f"{where}: secure_context needs a Secure Cloud project with phi.key. "
641
+ "Refusing to send patient data unencrypted.")
642
+ return 0
643
+
644
+ def check_action(self, action_id: int, process_name: str = "") -> dict:
645
+ """Has it been answered? Returns {"status", "outcome", "result"}.
646
+
647
+ status is Pending / Completed / Cancelled / Expired. Treat anything but
648
+ Completed as "no decision" — an expired action means nobody was
649
+ available, which the run has to handle itself.
650
+ """
651
+ if self.standalone or not action_id:
652
+ return {"status": "Unavailable", "outcome": None, "result": None}
653
+ res = self.send_command("get_action", {
654
+ "process_name": process_name or getattr(self, "_last_proc_name", ""),
655
+ "machine_name": _machine_identity().split(chr(92))[0],
656
+ "action_id": action_id,
657
+ })
658
+ if res.get("status") != "ok":
659
+ return {"status": "Unavailable", "outcome": None, "result": None,
660
+ "message": res.get("message")}
661
+ return {"status": res.get("action_status"), "outcome": res.get("outcome"),
662
+ "result": res.get("result"), "result_secure": res.get("result_secure"),
663
+ "completed_at": res.get("completed_at")}
664
+
665
+ def wait_for_action(self, action_id: int, timeout_minutes: int = 30,
666
+ poll_seconds: int = 20, process_name: str = "") -> dict:
667
+ """Block until answered or the timeout passes.
668
+
669
+ Only for a run that genuinely cannot proceed. It holds the machine for
670
+ the whole wait, so for anything a person may take hours over, raise the
671
+ action and postpone the transaction instead.
672
+ """
673
+ import time as _time
674
+ # Nothing to wait for: standalone has no Orchestrator, and action_id 0 is
675
+ # what ask_human returns when it could not raise the question. Without
676
+ # this the loop below sees "Unavailable" every poll, which is not a
677
+ # decision, and holds the machine for the whole timeout — thirty minutes
678
+ # by default — before returning the same nothing it had at the start.
679
+ if self.standalone or not action_id:
680
+ return {"status": "Unavailable", "outcome": None, "result": None}
681
+ deadline = _time.time() + max(1, int(timeout_minutes)) * 60
682
+ while _time.time() < deadline:
683
+ res = self.check_action(action_id, process_name)
684
+ if res.get("status") not in ("Pending", "Unavailable"):
685
+ return res
686
+ _time.sleep(max(5, int(poll_seconds)))
687
+ logger.warning(f"Action #{action_id} not answered within {timeout_minutes} min")
688
+ return {"status": "Pending", "outcome": None, "result": None}
689
+
690
+ def cancel_action(self, action_id: int, process_name: str = "") -> bool:
691
+ """Withdraw a question this run no longer needs answered."""
692
+ if self.standalone or not action_id:
693
+ return False
694
+ res = self.send_command("cancel_action", {
695
+ "process_name": process_name or getattr(self, "_last_proc_name", ""),
696
+ "machine_name": _machine_identity().split(chr(92))[0],
697
+ "action_id": action_id,
698
+ })
699
+ return res.get("status") == "ok"
700
+
701
+ def write_log(self, level: str, message: str) -> None:
702
+ """Push a log message to the token log file via the proxy."""
703
+ if not self.run_token:
704
+ return
705
+ self.send_command("write_run_log", {
706
+ "token": self.run_token,
707
+ "level": level.upper(),
708
+ "message": message,
709
+ })
@@ -0,0 +1,19 @@
1
+ import pandas as pd
2
+ import math
3
+
4
+
5
+ def init_all_settings(config_path: str = "data/Config.xlsx") -> dict:
6
+ """Load Settings and Constants sheets from Config.xlsx into a dict."""
7
+ config = {}
8
+ xls = pd.ExcelFile(config_path)
9
+ for sheet in xls.sheet_names:
10
+ df = pd.read_excel(xls, sheet_name=sheet)
11
+ if "Name" in df.columns and "Value" in df.columns:
12
+ for _, row in df.iterrows():
13
+ name = row["Name"]
14
+ value = row["Value"]
15
+ if isinstance(name, str) and name.strip():
16
+ if isinstance(value, float) and math.isnan(value):
17
+ value = None
18
+ config[name.strip()] = value
19
+ return config
@@ -0,0 +1,61 @@
1
+ import logging
2
+ import threading
3
+ import time
4
+ from typing import Any
5
+
6
+ logger = logging.getLogger(__name__)
7
+
8
+
9
+ class PauseController:
10
+ # Cooperative pause/stop controller shared across the bot run.
11
+ # It pauses safely at framework checkpoints and safe action wrappers.
12
+
13
+ def __init__(self, poll_seconds: float = 0.25):
14
+ self._pause_event = threading.Event()
15
+ self._stop_event = threading.Event()
16
+ self._poll_seconds = poll_seconds
17
+ self._pause_logged = False
18
+
19
+ def request_pause(self) -> None:
20
+ self._pause_event.set()
21
+
22
+ def request_resume(self) -> None:
23
+ self._pause_event.clear()
24
+ self._pause_logged = False
25
+
26
+ def request_stop(self) -> None:
27
+ self._stop_event.set()
28
+
29
+ def is_paused(self) -> bool:
30
+ return self._pause_event.is_set()
31
+
32
+ def is_stop_requested(self) -> bool:
33
+ return self._stop_event.is_set()
34
+
35
+ def checkpoint(self, label: str = "") -> None:
36
+ if self._stop_event.is_set():
37
+ raise KeyboardInterrupt(f"STOP requested at {label}".strip())
38
+
39
+ while self._pause_event.is_set():
40
+ if not self._pause_logged:
41
+ logger.warning(f"Bot paused at checkpoint: {label}")
42
+ self._pause_logged = True
43
+ time.sleep(self._poll_seconds)
44
+ if self._stop_event.is_set():
45
+ raise KeyboardInterrupt(f"STOP requested while paused at {label}".strip())
46
+
47
+ self._pause_logged = False
48
+
49
+
50
+ def get_pause_controller(config_or_controller: Any):
51
+ if isinstance(config_or_controller, PauseController):
52
+ return config_or_controller
53
+ if isinstance(config_or_controller, dict):
54
+ return config_or_controller.get("_pause_controller")
55
+ return None
56
+
57
+
58
+ def checkpoint(config_or_controller: Any, label: str = "") -> None:
59
+ controller = get_pause_controller(config_or_controller)
60
+ if controller:
61
+ controller.checkpoint(label)
@@ -0,0 +1,65 @@
1
+ import time
2
+ from typing import Any, Callable
3
+ from .pause_controller import checkpoint
4
+
5
+
6
+ def safe_sleep(config: dict, seconds: float, label: str = "sleep", interval: float = 0.25) -> None:
7
+ # Pause-aware replacement for time.sleep().
8
+ end_at = time.time() + float(seconds)
9
+ while time.time() < end_at:
10
+ checkpoint(config, label)
11
+ time.sleep(min(interval, max(0, end_at - time.time())))
12
+ checkpoint(config, label)
13
+
14
+
15
+ def safe_click(config: dict, target: Any, label: str = "click", *args, **kwargs):
16
+ # Pause-aware click wrapper for Selenium elements or Playwright locators.
17
+ checkpoint(config, f"before {label}")
18
+ result = target.click(*args, **kwargs)
19
+ checkpoint(config, f"after {label}")
20
+ return result
21
+
22
+
23
+ def safe_type_text(config: dict, target: Any, text: str, label: str = "type", delay: float = 0.03) -> None:
24
+ # Pause-aware character-by-character typing.
25
+ # Supports Selenium WebElement.send_keys() and Playwright Locator.type().
26
+ checkpoint(config, f"before {label}")
27
+ for ch in str(text):
28
+ checkpoint(config, label)
29
+ if hasattr(target, "type"):
30
+ target.type(ch, delay=0)
31
+ elif hasattr(target, "send_keys"):
32
+ target.send_keys(ch)
33
+ elif callable(target):
34
+ target(ch)
35
+ else:
36
+ raise TypeError("safe_type_text target must support type(), send_keys(), or be callable")
37
+ if delay:
38
+ safe_sleep(config, delay, label=label, interval=min(delay, 0.25))
39
+ checkpoint(config, f"after {label}")
40
+
41
+
42
+ def safe_fill_text(config: dict, target: Any, text: str, label: str = "fill", delay: float = 0.03) -> None:
43
+ # Clear/fill field safely, then type with pause checkpoints.
44
+ checkpoint(config, f"before {label}")
45
+ if hasattr(target, "fill"):
46
+ target.fill("")
47
+ elif hasattr(target, "clear"):
48
+ target.clear()
49
+ safe_type_text(config, target, text, label=label, delay=delay)
50
+ checkpoint(config, f"after {label}")
51
+
52
+
53
+ def safe_call(config: dict, fn: Callable, label: str = "call", *args, **kwargs):
54
+ # Pause-aware wrapper for any short atomic action.
55
+ checkpoint(config, f"before {label}")
56
+ result = fn(*args, **kwargs)
57
+ checkpoint(config, f"after {label}")
58
+ return result
59
+
60
+
61
+ def safe_iter(config: dict, iterable, label: str = "loop"):
62
+ # Yield items from an iterable with pause checkpoints before each item.
63
+ for item in iterable:
64
+ checkpoint(config, label)
65
+ yield item
@@ -0,0 +1,130 @@
1
+ import logging
2
+ import json
3
+ import os
4
+ import queue
5
+ import threading
6
+ from datetime import datetime
7
+
8
+ _COLORS = {
9
+ "DEBUG": "\033[36m",
10
+ "INFO": "\033[32m",
11
+ "WARNING": "\033[33m",
12
+ "ERROR": "\033[31m",
13
+ "CRITICAL": "\033[35m",
14
+ }
15
+ _RESET = "\033[0m"
16
+
17
+
18
+ class _StructuredFormatter(logging.Formatter):
19
+ def __init__(self, run_id: str, use_json: bool = False, colorize: bool = True):
20
+ super().__init__()
21
+ self.run_id = run_id
22
+ self.use_json = use_json
23
+ self.colorize = colorize
24
+
25
+ def format(self, record: logging.LogRecord) -> str:
26
+ base = {
27
+ "ts": datetime.utcnow().isoformat() + "Z",
28
+ "level": record.levelname,
29
+ "run_id": self.run_id,
30
+ "logger": record.name,
31
+ "msg": record.getMessage(),
32
+ }
33
+ if hasattr(record, "txn_id"):
34
+ base["txn_id"] = record.txn_id
35
+ if hasattr(record, "state"):
36
+ base["state"] = record.state
37
+ if record.exc_info:
38
+ base["exc"] = self.formatException(record.exc_info)
39
+ if self.use_json:
40
+ return json.dumps(base, ensure_ascii=False)
41
+ color = _COLORS.get(record.levelname, "") if self.colorize else ""
42
+ ts = base["ts"][11:23]
43
+ prefix = f"[{ts}][{record.levelname:8}][{self.run_id}]"
44
+ return f"{color}{prefix} {record.getMessage()}{_RESET}"
45
+
46
+ class ProxyLogHandler(logging.Handler):
47
+ """Forwards Python log records to the Orchestrator proxy via DbClient.write_log()."""
48
+
49
+ def __init__(self, db_client, level=logging.INFO):
50
+ super().__init__(level)
51
+ self._client = db_client
52
+ self._queue = queue.Queue()
53
+ self._stop = threading.Event()
54
+ self._thread = threading.Thread(target=self._worker, daemon=True, name="ProxyLogWorker")
55
+ self._thread.start()
56
+
57
+ def emit(self, record: logging.LogRecord) -> None:
58
+ try:
59
+ self._queue.put_nowait(record)
60
+ except Exception:
61
+ pass
62
+
63
+ def _worker(self) -> None:
64
+ while True:
65
+ try:
66
+ record = self._queue.get(timeout=0.5)
67
+ except queue.Empty:
68
+ if self._stop.is_set():
69
+ break
70
+ continue
71
+ try:
72
+ msg = self.format(record)
73
+ res = self._client.send_command("write_run_log", {
74
+ "token": self._client.run_token,
75
+ "level": record.levelname.upper(),
76
+ "message": msg,
77
+ })
78
+ if res and res.get("status") == "error":
79
+ print(f"[PROXY LOG ERROR] Server rejected log: {res.get('message')}")
80
+ except Exception as e:
81
+ print(f"[PROXY LOG ERROR] Failed to send log: {e}")
82
+ finally:
83
+ self._queue.task_done()
84
+
85
+ def flush(self) -> None:
86
+ self._queue.join()
87
+
88
+ def close(self) -> None:
89
+ self._stop.set()
90
+ self._queue.join()
91
+ self._thread.join(timeout=5)
92
+ super().close()
93
+
94
+
95
+ def attach_proxy_handler(db_client, log_level: str = "INFO", run_id: str = "") -> None:
96
+ """Attach a ProxyLogHandler to the root logger so all framework
97
+ log messages are forwarded to the orchestrator's token log file."""
98
+ level = getattr(logging, log_level.upper(), logging.INFO)
99
+ root = logging.getLogger()
100
+ for h in root.handlers[:]:
101
+ if isinstance(h, ProxyLogHandler):
102
+ h.close()
103
+ root.removeHandler(h)
104
+ # Standalone: there is no orchestrator to forward to. Skip the handler
105
+ # entirely rather than run its background thread for a no-op send.
106
+ if getattr(db_client, "standalone", False):
107
+ return
108
+ handler = ProxyLogHandler(db_client, level=level)
109
+
110
+ # Use the same formatter as the file/console so the dashboard looks identical
111
+ handler.setFormatter(_StructuredFormatter(run_id, use_json=False, colorize=False))
112
+
113
+ root.addHandler(handler)
114
+
115
+
116
+
117
+ def setup_logging(log_file: str, run_id: str, log_level: str = "INFO") -> None:
118
+ level = getattr(logging, log_level.upper(), logging.INFO)
119
+ root = logging.getLogger()
120
+ root.setLevel(level)
121
+ root.handlers.clear()
122
+ log_dir = os.path.dirname(log_file)
123
+ if log_dir:
124
+ os.makedirs(log_dir, exist_ok=True)
125
+ fh = logging.FileHandler(log_file, encoding="utf-8")
126
+ fh.setFormatter(_StructuredFormatter(run_id, use_json=True, colorize=False))
127
+ root.addHandler(fh)
128
+ ch = logging.StreamHandler()
129
+ ch.setFormatter(_StructuredFormatter(run_id, use_json=False, colorize=True))
130
+ root.addHandler(ch)
@@ -0,0 +1,15 @@
1
+ import logging, os
2
+ logger = logging.getLogger(__name__)
3
+ def take_screenshot(screenshot_dir: str = "logs", prefix: str = "error") -> str:
4
+ try:
5
+ import pyautogui
6
+ from datetime import datetime
7
+ os.makedirs(screenshot_dir, exist_ok=True)
8
+ ts = datetime.now().strftime("%Y%m%d_%H%M%S")
9
+ dest = os.path.join(screenshot_dir, f"{prefix}_{ts}.png")
10
+ pyautogui.screenshot(dest)
11
+ logger.info(f"Screenshot: {dest}")
12
+ return dest
13
+ except Exception as e:
14
+ logger.error(f"Screenshot failed: {e}")
15
+ return "screenshot_failed"
@@ -0,0 +1,31 @@
1
+ Metadata-Version: 2.4
2
+ Name: rpa-bot-sdk-core
3
+ Version: 1.0.0
4
+ Summary: Orchestrator bot-side core: server client, pause/stop, logging, config loading. Shared by rpa-bot-sdk-performer and rpa-bot-sdk-dispatcher.
5
+ Author: Kasun Perera
6
+ Requires-Python: >=3.9
7
+ Description-Content-Type: text/markdown
8
+ Requires-Dist: pandas
9
+ Requires-Dist: openpyxl
10
+ Provides-Extra: sqlcipher
11
+ Requires-Dist: sqlcipher3-binary; extra == "sqlcipher"
12
+ Requires-Dist: keyring; extra == "sqlcipher"
13
+ Provides-Extra: screenshot
14
+ Requires-Dist: pyautogui; extra == "screenshot"
15
+
16
+ # rpa-bot-sdk-core
17
+
18
+ The Orchestrator bot-side code shared by every role: `DbClient` (server auth,
19
+ queue, assets, storage, human-in-the-loop over HTTP, or a local SQLite queue
20
+ in StandaloneMode), `PauseController`/`checkpoint`, structured logging,
21
+ `init_all_settings()` (Config.xlsx loader), `take_screenshot()`, and the
22
+ `safe_*` UI-action wrappers.
23
+
24
+ Neither a Performer nor a Dispatcher project installs this directly — it's
25
+ the shared dependency of [`rpa-bot-sdk-performer`](../performer/README.md)
26
+ and [`rpa-bot-sdk-dispatcher`](../dispatcher/README.md), pulled in
27
+ automatically by whichever of those two a project actually needs.
28
+
29
+ Bump this package's version specifically when the server contract itself
30
+ changes (a `db_client.py` field/action). A change confined to one role's own
31
+ orchestration (e.g. `QueueManager`) belongs in that role's package instead.
@@ -0,0 +1,14 @@
1
+ README.md
2
+ pyproject.toml
3
+ src/rpa_bot_sdk_core/__init__.py
4
+ src/rpa_bot_sdk_core/db_client.py
5
+ src/rpa_bot_sdk_core/init_all_settings.py
6
+ src/rpa_bot_sdk_core/pause_controller.py
7
+ src/rpa_bot_sdk_core/safe_actions.py
8
+ src/rpa_bot_sdk_core/structured_logger.py
9
+ src/rpa_bot_sdk_core/utils.py
10
+ src/rpa_bot_sdk_core.egg-info/PKG-INFO
11
+ src/rpa_bot_sdk_core.egg-info/SOURCES.txt
12
+ src/rpa_bot_sdk_core.egg-info/dependency_links.txt
13
+ src/rpa_bot_sdk_core.egg-info/requires.txt
14
+ src/rpa_bot_sdk_core.egg-info/top_level.txt
@@ -0,0 +1,9 @@
1
+ pandas
2
+ openpyxl
3
+
4
+ [screenshot]
5
+ pyautogui
6
+
7
+ [sqlcipher]
8
+ sqlcipher3-binary
9
+ keyring
@@ -0,0 +1 @@
1
+ rpa_bot_sdk_core