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.
- rpa_bot_sdk_core-1.0.0/PKG-INFO +31 -0
- rpa_bot_sdk_core-1.0.0/README.md +16 -0
- rpa_bot_sdk_core-1.0.0/pyproject.toml +22 -0
- rpa_bot_sdk_core-1.0.0/setup.cfg +4 -0
- rpa_bot_sdk_core-1.0.0/src/rpa_bot_sdk_core/__init__.py +32 -0
- rpa_bot_sdk_core-1.0.0/src/rpa_bot_sdk_core/db_client.py +709 -0
- rpa_bot_sdk_core-1.0.0/src/rpa_bot_sdk_core/init_all_settings.py +19 -0
- rpa_bot_sdk_core-1.0.0/src/rpa_bot_sdk_core/pause_controller.py +61 -0
- rpa_bot_sdk_core-1.0.0/src/rpa_bot_sdk_core/safe_actions.py +65 -0
- rpa_bot_sdk_core-1.0.0/src/rpa_bot_sdk_core/structured_logger.py +130 -0
- rpa_bot_sdk_core-1.0.0/src/rpa_bot_sdk_core/utils.py +15 -0
- rpa_bot_sdk_core-1.0.0/src/rpa_bot_sdk_core.egg-info/PKG-INFO +31 -0
- rpa_bot_sdk_core-1.0.0/src/rpa_bot_sdk_core.egg-info/SOURCES.txt +14 -0
- rpa_bot_sdk_core-1.0.0/src/rpa_bot_sdk_core.egg-info/dependency_links.txt +1 -0
- rpa_bot_sdk_core-1.0.0/src/rpa_bot_sdk_core.egg-info/requires.txt +9 -0
- rpa_bot_sdk_core-1.0.0/src/rpa_bot_sdk_core.egg-info/top_level.txt +1 -0
|
@@ -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,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 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
rpa_bot_sdk_core
|