patchr 0.1.0__py3-none-any.whl
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.
- apps/__init__.py +2 -0
- apps/api/__init__.py +2 -0
- apps/api/main.py +652 -0
- apps/benchmarks/__init__.py +1 -0
- apps/benchmarks/main.py +20 -0
- apps/sandbox/__init__.py +1 -0
- apps/sandbox/main.py +20 -0
- apps/worker/__init__.py +2 -0
- apps/worker/main.py +15 -0
- apps/worker/verify.py +14 -0
- patchr/__init__.py +12 -0
- patchr/sdk/__init__.py +20 -0
- patchr/sdk/client.py +12 -0
- patchr-0.1.0.dist-info/METADATA +137 -0
- patchr-0.1.0.dist-info/RECORD +116 -0
- patchr-0.1.0.dist-info/WHEEL +5 -0
- patchr-0.1.0.dist-info/entry_points.txt +5 -0
- patchr-0.1.0.dist-info/licenses/LICENSE +17 -0
- patchr-0.1.0.dist-info/top_level.txt +3 -0
- picux/__init__.py +6 -0
- picux/agents/__init__.py +5 -0
- picux/agents/registry.py +204 -0
- picux/api/__init__.py +5 -0
- picux/api/service.py +5075 -0
- picux/audit/__init__.py +31 -0
- picux/audit/activity.py +97 -0
- picux/audit/observability.py +55 -0
- picux/audit/verification/__init__.py +21 -0
- picux/audit/verification/ledger.py +633 -0
- picux/benchmarks/__init__.py +5 -0
- picux/benchmarks/local.py +286 -0
- picux/config.py +140 -0
- picux/contracts/__init__.py +22 -0
- picux/contracts/handshake.py +122 -0
- picux/contracts/integration.py +385 -0
- picux/contracts/openapi.py +187 -0
- picux/contracts/protocol_map.py +152 -0
- picux/contracts/routes.py +980 -0
- picux/contracts/schema_catalog.py +125 -0
- picux/core/__init__.py +17 -0
- picux/core/models.py +148 -0
- picux/core/router.py +131 -0
- picux/core/runtime.py +42 -0
- picux/core/state_machine.py +38 -0
- picux/domains/__init__.py +2 -0
- picux/domains/bridge/HostRun.py +1104 -0
- picux/domains/bridge/__init__.py +6 -0
- picux/domains/bridge/engine.py +345 -0
- picux/domains/hunt/__init__.py +6 -0
- picux/domains/hunt/engine.py +307 -0
- picux/domains/hunt/models.py +88 -0
- picux/domains/pay/__init__.py +16 -0
- picux/domains/pay/adapters.py +607 -0
- picux/domains/pay/engine.py +950 -0
- picux/domains/pay/models.py +95 -0
- picux/domains/proxy/__init__.py +5 -0
- picux/domains/proxy/engine.py +466 -0
- picux/domains/resolve/__init__.py +5 -0
- picux/domains/resolve/engine.py +546 -0
- picux/orchestrator/__init__.py +3 -0
- picux/orchestrator/engine.py +2840 -0
- picux/portals/__init__.py +17 -0
- picux/portals/templates.py +272 -0
- picux/protocols/__init__.py +1 -0
- picux/protocols/a2a/__init__.py +6 -0
- picux/protocols/a2a/client.py +51 -0
- picux/protocols/a2a/envelope.py +132 -0
- picux/protocols/mcp/__init__.py +7 -0
- picux/protocols/mcp/client.py +69 -0
- picux/protocols/mcp/contract.py +67 -0
- picux/protocols/mcp/server.py +76 -0
- picux/sandbox/__init__.py +6 -0
- picux/sandbox/midnight_arbitrage.py +215 -0
- picux/sandbox/models.py +90 -0
- picux/sdk/__init__.py +13 -0
- picux/sdk/client.py +768 -0
- picux/sdk/external.py +245 -0
- picux/security/__init__.py +18 -0
- picux/security/auth.py +86 -0
- picux/security/config_validator.py +58 -0
- picux/security/policy.py +158 -0
- picux/security/secrets.py +144 -0
- picux/signals/__init__.py +1 -0
- picux/signals/community/__init__.py +24 -0
- picux/signals/community/adapters/__init__.py +7 -0
- picux/signals/community/adapters/reddit.py +37 -0
- picux/signals/community/adapters/shopify.py +23 -0
- picux/signals/community/adapters/web.py +23 -0
- picux/signals/community/disambiguation.py +51 -0
- picux/signals/community/intake.py +227 -0
- picux/signals/community/models.py +102 -0
- picux/signals/community/rules.py +91 -0
- picux/signals/community/scoring.py +64 -0
- picux/storage/__init__.py +41 -0
- picux/storage/agents.py +50 -0
- picux/storage/cases.py +440 -0
- picux/storage/channels.py +476 -0
- picux/storage/connectors.py +411 -0
- picux/storage/envelopes.py +137 -0
- picux/storage/escrows.py +168 -0
- picux/storage/events.py +989 -0
- picux/storage/keyspace.py +60 -0
- picux/storage/mandates.py +107 -0
- picux/storage/portals.py +222 -0
- picux/storage/postgres.py +2049 -0
- picux/storage/providers.py +148 -0
- picux/storage/proxy.py +231 -0
- picux/storage/receipts.py +131 -0
- picux/storage/signals.py +147 -0
- picux/storage/tasks.py +179 -0
- picux/tools/__init__.py +11 -0
- picux/tools/shared.py +2048 -0
- picux/verification/__init__.py +5 -0
- picux/verification/rollout.py +183 -0
- picux/workflows/__init__.py +5 -0
- picux/workflows/templates.py +74 -0
|
@@ -0,0 +1,2049 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
from contextlib import contextmanager
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from typing import Any, Iterator
|
|
8
|
+
|
|
9
|
+
from picux.config import PicuxSettings
|
|
10
|
+
from picux.core import Domain, ProtocolTask, ProtocolTaskStatus, ProtocolUser
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger("picux.storage.postgres")
|
|
13
|
+
|
|
14
|
+
try:
|
|
15
|
+
import psycopg
|
|
16
|
+
except Exception: # pragma: no cover - optional dependency
|
|
17
|
+
psycopg = None
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class PicuxPostgresStore:
|
|
21
|
+
"""Optional Postgres persistence for protocol-grade state and audit logs."""
|
|
22
|
+
|
|
23
|
+
def __init__(self, dsn: str = "", *, ensure: bool = True) -> None:
|
|
24
|
+
self.dsn = (dsn or "").strip()
|
|
25
|
+
self.enabled = bool(self.dsn and psycopg is not None)
|
|
26
|
+
if self.enabled and ensure:
|
|
27
|
+
self.ensureSchema()
|
|
28
|
+
|
|
29
|
+
@classmethod
|
|
30
|
+
def fromEnv(cls) -> "PicuxPostgresStore":
|
|
31
|
+
return cls(PicuxSettings.fromEnv().postgresDsn)
|
|
32
|
+
|
|
33
|
+
@contextmanager
|
|
34
|
+
def conn(self) -> Iterator[Any]:
|
|
35
|
+
if not self.enabled:
|
|
36
|
+
raise RuntimeError("postgresDisabled")
|
|
37
|
+
db = psycopg.connect(self.dsn)
|
|
38
|
+
try:
|
|
39
|
+
yield db
|
|
40
|
+
db.commit()
|
|
41
|
+
finally:
|
|
42
|
+
db.close()
|
|
43
|
+
|
|
44
|
+
def ddl(self) -> list[str]:
|
|
45
|
+
return [
|
|
46
|
+
"""
|
|
47
|
+
CREATE TABLE IF NOT EXISTS picux_users (
|
|
48
|
+
user_id TEXT PRIMARY KEY,
|
|
49
|
+
channel TEXT NOT NULL DEFAULT 'api',
|
|
50
|
+
trust INTEGER NOT NULL DEFAULT 1,
|
|
51
|
+
refs JSONB NOT NULL DEFAULT '{}'::jsonb,
|
|
52
|
+
prefs JSONB NOT NULL DEFAULT '{}'::jsonb,
|
|
53
|
+
meta JSONB NOT NULL DEFAULT '{}'::jsonb,
|
|
54
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
55
|
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
56
|
+
)
|
|
57
|
+
""",
|
|
58
|
+
"CREATE INDEX IF NOT EXISTS idx_picux_users_channel ON picux_users (channel)",
|
|
59
|
+
"""
|
|
60
|
+
CREATE TABLE IF NOT EXISTS picux_agents (
|
|
61
|
+
agent_id TEXT PRIMARY KEY,
|
|
62
|
+
name TEXT NOT NULL,
|
|
63
|
+
domains JSONB NOT NULL DEFAULT '[]'::jsonb,
|
|
64
|
+
caps JSONB NOT NULL DEFAULT '[]'::jsonb,
|
|
65
|
+
endpoint TEXT NOT NULL DEFAULT '',
|
|
66
|
+
trust INTEGER NOT NULL DEFAULT 1,
|
|
67
|
+
status TEXT NOT NULL DEFAULT 'active',
|
|
68
|
+
meta JSONB NOT NULL DEFAULT '{}'::jsonb,
|
|
69
|
+
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
|
|
70
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
71
|
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
72
|
+
)
|
|
73
|
+
""",
|
|
74
|
+
"CREATE INDEX IF NOT EXISTS idx_picux_agents_status ON picux_agents (status)",
|
|
75
|
+
"CREATE INDEX IF NOT EXISTS idx_picux_agents_domains ON picux_agents USING GIN (domains)",
|
|
76
|
+
"CREATE INDEX IF NOT EXISTS idx_picux_agents_caps ON picux_agents USING GIN (caps)",
|
|
77
|
+
"""
|
|
78
|
+
CREATE TABLE IF NOT EXISTS picux_a2a_envelopes (
|
|
79
|
+
msg_id TEXT PRIMARY KEY,
|
|
80
|
+
status TEXT NOT NULL DEFAULT 'queued',
|
|
81
|
+
from_agent TEXT NOT NULL DEFAULT '',
|
|
82
|
+
to_agent TEXT NOT NULL DEFAULT '',
|
|
83
|
+
trace_id TEXT NOT NULL DEFAULT '',
|
|
84
|
+
task_id TEXT NOT NULL DEFAULT '',
|
|
85
|
+
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
|
|
86
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
87
|
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
88
|
+
)
|
|
89
|
+
""",
|
|
90
|
+
"CREATE INDEX IF NOT EXISTS idx_picux_a2a_envelopes_to_status ON picux_a2a_envelopes (to_agent, status)",
|
|
91
|
+
"CREATE INDEX IF NOT EXISTS idx_picux_a2a_envelopes_trace ON picux_a2a_envelopes (trace_id)",
|
|
92
|
+
"""
|
|
93
|
+
CREATE TABLE IF NOT EXISTS picux_mandates (
|
|
94
|
+
mandate_id TEXT PRIMARY KEY,
|
|
95
|
+
status TEXT NOT NULL DEFAULT 'pendingVerification',
|
|
96
|
+
issuer_id TEXT NOT NULL DEFAULT '',
|
|
97
|
+
mandate JSONB NOT NULL DEFAULT '{}'::jsonb,
|
|
98
|
+
validation JSONB NOT NULL DEFAULT '{}'::jsonb,
|
|
99
|
+
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
|
|
100
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
101
|
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
102
|
+
)
|
|
103
|
+
""",
|
|
104
|
+
"CREATE INDEX IF NOT EXISTS idx_picux_mandates_status ON picux_mandates (status)",
|
|
105
|
+
"CREATE INDEX IF NOT EXISTS idx_picux_mandates_issuer ON picux_mandates (issuer_id)",
|
|
106
|
+
"""
|
|
107
|
+
CREATE TABLE IF NOT EXISTS picux_tasks (
|
|
108
|
+
task_id TEXT PRIMARY KEY,
|
|
109
|
+
user_id TEXT NOT NULL,
|
|
110
|
+
domain TEXT NOT NULL,
|
|
111
|
+
status TEXT NOT NULL,
|
|
112
|
+
channel TEXT NOT NULL DEFAULT 'api',
|
|
113
|
+
mandate_id TEXT NOT NULL DEFAULT '',
|
|
114
|
+
in_data JSONB NOT NULL DEFAULT '{}'::jsonb,
|
|
115
|
+
out_data JSONB NOT NULL DEFAULT '{}'::jsonb,
|
|
116
|
+
needs_approval BOOLEAN NOT NULL DEFAULT FALSE,
|
|
117
|
+
ext_ref TEXT NOT NULL DEFAULT '',
|
|
118
|
+
error_msg TEXT NOT NULL DEFAULT '',
|
|
119
|
+
meta JSONB NOT NULL DEFAULT '{}'::jsonb,
|
|
120
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
121
|
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
122
|
+
approval_by BIGINT
|
|
123
|
+
)
|
|
124
|
+
""",
|
|
125
|
+
"ALTER TABLE picux_tasks ADD COLUMN IF NOT EXISTS needs_approval BOOLEAN NOT NULL DEFAULT FALSE",
|
|
126
|
+
"ALTER TABLE picux_tasks ADD COLUMN IF NOT EXISTS ext_ref TEXT NOT NULL DEFAULT ''",
|
|
127
|
+
"ALTER TABLE picux_tasks ADD COLUMN IF NOT EXISTS error_msg TEXT NOT NULL DEFAULT ''",
|
|
128
|
+
"ALTER TABLE picux_tasks ADD COLUMN IF NOT EXISTS approval_by BIGINT",
|
|
129
|
+
"CREATE INDEX IF NOT EXISTS idx_picux_tasks_user_created ON picux_tasks (user_id, created_at DESC)",
|
|
130
|
+
"CREATE INDEX IF NOT EXISTS idx_picux_tasks_domain_status ON picux_tasks (domain, status)",
|
|
131
|
+
"""
|
|
132
|
+
CREATE TABLE IF NOT EXISTS picux_activity_logs (
|
|
133
|
+
id BIGSERIAL PRIMARY KEY,
|
|
134
|
+
task_id TEXT NOT NULL,
|
|
135
|
+
user_id TEXT NOT NULL,
|
|
136
|
+
level TEXT NOT NULL,
|
|
137
|
+
action TEXT NOT NULL,
|
|
138
|
+
detail TEXT NOT NULL,
|
|
139
|
+
meta JSONB NOT NULL DEFAULT '{}'::jsonb,
|
|
140
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
141
|
+
)
|
|
142
|
+
""",
|
|
143
|
+
"CREATE INDEX IF NOT EXISTS idx_picux_activity_task_created ON picux_activity_logs (task_id, created_at DESC)",
|
|
144
|
+
"CREATE INDEX IF NOT EXISTS idx_picux_activity_user_created ON picux_activity_logs (user_id, created_at DESC)",
|
|
145
|
+
"""
|
|
146
|
+
CREATE TABLE IF NOT EXISTS picux_activity_events (
|
|
147
|
+
id BIGSERIAL PRIMARY KEY,
|
|
148
|
+
user_id TEXT NOT NULL,
|
|
149
|
+
task_id TEXT NOT NULL DEFAULT '',
|
|
150
|
+
event TEXT NOT NULL,
|
|
151
|
+
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
|
|
152
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
153
|
+
)
|
|
154
|
+
""",
|
|
155
|
+
"CREATE INDEX IF NOT EXISTS idx_picux_events_user_created ON picux_activity_events (user_id, created_at DESC)",
|
|
156
|
+
"""
|
|
157
|
+
CREATE TABLE IF NOT EXISTS picux_event_subscriptions (
|
|
158
|
+
sub_id TEXT PRIMARY KEY,
|
|
159
|
+
client_id TEXT NOT NULL,
|
|
160
|
+
target TEXT NOT NULL DEFAULT '',
|
|
161
|
+
transport TEXT NOT NULL DEFAULT 'poll',
|
|
162
|
+
endpoint TEXT NOT NULL DEFAULT '',
|
|
163
|
+
status TEXT NOT NULL DEFAULT 'active',
|
|
164
|
+
event_types JSONB NOT NULL DEFAULT '[]'::jsonb,
|
|
165
|
+
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
|
|
166
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
167
|
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
168
|
+
)
|
|
169
|
+
""",
|
|
170
|
+
"CREATE INDEX IF NOT EXISTS idx_picux_event_subscriptions_client ON picux_event_subscriptions (client_id)",
|
|
171
|
+
"CREATE INDEX IF NOT EXISTS idx_picux_event_subscriptions_status_transport ON picux_event_subscriptions (status, transport)",
|
|
172
|
+
"""
|
|
173
|
+
CREATE TABLE IF NOT EXISTS picux_integration_events (
|
|
174
|
+
event_id TEXT PRIMARY KEY,
|
|
175
|
+
type TEXT NOT NULL,
|
|
176
|
+
subject TEXT NOT NULL DEFAULT '',
|
|
177
|
+
source TEXT NOT NULL DEFAULT 'picux',
|
|
178
|
+
target TEXT NOT NULL DEFAULT '',
|
|
179
|
+
status TEXT NOT NULL DEFAULT 'queued',
|
|
180
|
+
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
|
|
181
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
182
|
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
183
|
+
ack_at TIMESTAMPTZ
|
|
184
|
+
)
|
|
185
|
+
""",
|
|
186
|
+
"CREATE INDEX IF NOT EXISTS idx_picux_integration_events_type_status ON picux_integration_events (type, status)",
|
|
187
|
+
"CREATE INDEX IF NOT EXISTS idx_picux_integration_events_target_created ON picux_integration_events (target, created_at DESC)",
|
|
188
|
+
"""
|
|
189
|
+
CREATE TABLE IF NOT EXISTS picux_event_deliveries (
|
|
190
|
+
delivery_id TEXT PRIMARY KEY,
|
|
191
|
+
event_id TEXT NOT NULL,
|
|
192
|
+
sub_id TEXT NOT NULL,
|
|
193
|
+
client_id TEXT NOT NULL DEFAULT '',
|
|
194
|
+
transport TEXT NOT NULL DEFAULT 'poll',
|
|
195
|
+
endpoint TEXT NOT NULL DEFAULT '',
|
|
196
|
+
status TEXT NOT NULL DEFAULT 'queued',
|
|
197
|
+
attempt INTEGER NOT NULL DEFAULT 1,
|
|
198
|
+
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
|
|
199
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
200
|
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
201
|
+
next_attempt_at TIMESTAMPTZ,
|
|
202
|
+
claimed_at TIMESTAMPTZ,
|
|
203
|
+
lease_expires_at TIMESTAMPTZ,
|
|
204
|
+
delivered_at TIMESTAMPTZ
|
|
205
|
+
)
|
|
206
|
+
""",
|
|
207
|
+
"ALTER TABLE picux_event_deliveries ADD COLUMN IF NOT EXISTS next_attempt_at TIMESTAMPTZ",
|
|
208
|
+
"ALTER TABLE picux_event_deliveries ADD COLUMN IF NOT EXISTS lease_expires_at TIMESTAMPTZ",
|
|
209
|
+
"CREATE INDEX IF NOT EXISTS idx_picux_event_deliveries_event ON picux_event_deliveries (event_id)",
|
|
210
|
+
"CREATE INDEX IF NOT EXISTS idx_picux_event_deliveries_sub_status ON picux_event_deliveries (sub_id, status)",
|
|
211
|
+
"CREATE INDEX IF NOT EXISTS idx_picux_event_deliveries_client_status ON picux_event_deliveries (client_id, status)",
|
|
212
|
+
"CREATE INDEX IF NOT EXISTS idx_picux_event_deliveries_status_lease ON picux_event_deliveries (status, lease_expires_at)",
|
|
213
|
+
"CREATE INDEX IF NOT EXISTS idx_picux_event_deliveries_status_next_attempt ON picux_event_deliveries (status, next_attempt_at)",
|
|
214
|
+
"""
|
|
215
|
+
CREATE TABLE IF NOT EXISTS picux_bridge_connectors (
|
|
216
|
+
connector_id TEXT PRIMARY KEY,
|
|
217
|
+
status TEXT NOT NULL DEFAULT 'active',
|
|
218
|
+
domain TEXT NOT NULL DEFAULT 'bridge',
|
|
219
|
+
kind TEXT NOT NULL DEFAULT 'api',
|
|
220
|
+
endpoint TEXT NOT NULL DEFAULT '',
|
|
221
|
+
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
|
|
222
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
223
|
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
224
|
+
)
|
|
225
|
+
""",
|
|
226
|
+
"CREATE INDEX IF NOT EXISTS idx_picux_bridge_connectors_status ON picux_bridge_connectors (status)",
|
|
227
|
+
"CREATE INDEX IF NOT EXISTS idx_picux_bridge_connectors_domain_kind ON picux_bridge_connectors (domain, kind)",
|
|
228
|
+
"""
|
|
229
|
+
CREATE TABLE IF NOT EXISTS picux_bridge_connection_sessions (
|
|
230
|
+
session_id TEXT PRIMARY KEY,
|
|
231
|
+
connector_id TEXT NOT NULL DEFAULT '',
|
|
232
|
+
status TEXT NOT NULL DEFAULT 'active',
|
|
233
|
+
subject_id TEXT NOT NULL DEFAULT '',
|
|
234
|
+
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
|
|
235
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
236
|
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
237
|
+
)
|
|
238
|
+
""",
|
|
239
|
+
"CREATE INDEX IF NOT EXISTS idx_picux_bridge_sessions_connector_status ON picux_bridge_connection_sessions (connector_id, status)",
|
|
240
|
+
"""
|
|
241
|
+
CREATE TABLE IF NOT EXISTS picux_channel_threads (
|
|
242
|
+
thread_id TEXT PRIMARY KEY,
|
|
243
|
+
case_id TEXT NOT NULL DEFAULT '',
|
|
244
|
+
conversation_id TEXT NOT NULL DEFAULT '',
|
|
245
|
+
channel TEXT NOT NULL DEFAULT 'api',
|
|
246
|
+
provider TEXT NOT NULL DEFAULT '',
|
|
247
|
+
provider_thread_id TEXT NOT NULL DEFAULT '',
|
|
248
|
+
connector_id TEXT NOT NULL DEFAULT '',
|
|
249
|
+
session_id TEXT NOT NULL DEFAULT '',
|
|
250
|
+
status TEXT NOT NULL DEFAULT 'active',
|
|
251
|
+
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
|
|
252
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
253
|
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
254
|
+
)
|
|
255
|
+
""",
|
|
256
|
+
"CREATE INDEX IF NOT EXISTS idx_picux_channel_threads_case ON picux_channel_threads (case_id)",
|
|
257
|
+
"CREATE INDEX IF NOT EXISTS idx_picux_channel_threads_conversation ON picux_channel_threads (conversation_id)",
|
|
258
|
+
"CREATE INDEX IF NOT EXISTS idx_picux_channel_threads_provider_ref ON picux_channel_threads (provider, provider_thread_id)",
|
|
259
|
+
"""
|
|
260
|
+
CREATE TABLE IF NOT EXISTS picux_touchpoints (
|
|
261
|
+
touchpoint_id TEXT PRIMARY KEY,
|
|
262
|
+
thread_id TEXT NOT NULL DEFAULT '',
|
|
263
|
+
case_id TEXT NOT NULL DEFAULT '',
|
|
264
|
+
conversation_id TEXT NOT NULL DEFAULT '',
|
|
265
|
+
channel TEXT NOT NULL DEFAULT 'api',
|
|
266
|
+
provider TEXT NOT NULL DEFAULT '',
|
|
267
|
+
connector_id TEXT NOT NULL DEFAULT '',
|
|
268
|
+
direction TEXT NOT NULL DEFAULT 'inbound',
|
|
269
|
+
status TEXT NOT NULL DEFAULT 'received',
|
|
270
|
+
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
|
|
271
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
272
|
+
)
|
|
273
|
+
""",
|
|
274
|
+
"CREATE INDEX IF NOT EXISTS idx_picux_touchpoints_thread_created ON picux_touchpoints (thread_id, created_at DESC)",
|
|
275
|
+
"CREATE INDEX IF NOT EXISTS idx_picux_touchpoints_case_created ON picux_touchpoints (case_id, created_at DESC)",
|
|
276
|
+
"CREATE INDEX IF NOT EXISTS idx_picux_touchpoints_conversation_created ON picux_touchpoints (conversation_id, created_at DESC)",
|
|
277
|
+
"""
|
|
278
|
+
CREATE TABLE IF NOT EXISTS picux_provider_actions (
|
|
279
|
+
action_id TEXT PRIMARY KEY,
|
|
280
|
+
case_id TEXT NOT NULL DEFAULT '',
|
|
281
|
+
conversation_id TEXT NOT NULL DEFAULT '',
|
|
282
|
+
thread_id TEXT NOT NULL DEFAULT '',
|
|
283
|
+
connector_id TEXT NOT NULL DEFAULT '',
|
|
284
|
+
session_id TEXT NOT NULL DEFAULT '',
|
|
285
|
+
status TEXT NOT NULL DEFAULT 'queued',
|
|
286
|
+
action TEXT NOT NULL DEFAULT '',
|
|
287
|
+
resource TEXT NOT NULL DEFAULT '',
|
|
288
|
+
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
|
|
289
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
290
|
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
291
|
+
)
|
|
292
|
+
""",
|
|
293
|
+
"CREATE INDEX IF NOT EXISTS idx_picux_provider_actions_connector_status ON picux_provider_actions (connector_id, status)",
|
|
294
|
+
"CREATE INDEX IF NOT EXISTS idx_picux_provider_actions_case ON picux_provider_actions (case_id)",
|
|
295
|
+
"""
|
|
296
|
+
CREATE TABLE IF NOT EXISTS picux_provider_callbacks (
|
|
297
|
+
callback_id TEXT PRIMARY KEY,
|
|
298
|
+
case_id TEXT NOT NULL DEFAULT '',
|
|
299
|
+
conversation_id TEXT NOT NULL DEFAULT '',
|
|
300
|
+
thread_id TEXT NOT NULL DEFAULT '',
|
|
301
|
+
connector_id TEXT NOT NULL DEFAULT '',
|
|
302
|
+
session_id TEXT NOT NULL DEFAULT '',
|
|
303
|
+
provider TEXT NOT NULL DEFAULT '',
|
|
304
|
+
provider_event_id TEXT NOT NULL DEFAULT '',
|
|
305
|
+
status TEXT NOT NULL DEFAULT 'received',
|
|
306
|
+
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
|
|
307
|
+
received_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
308
|
+
)
|
|
309
|
+
""",
|
|
310
|
+
"CREATE INDEX IF NOT EXISTS idx_picux_provider_callbacks_provider_event ON picux_provider_callbacks (provider, provider_event_id)",
|
|
311
|
+
"CREATE INDEX IF NOT EXISTS idx_picux_provider_callbacks_conversation ON picux_provider_callbacks (conversation_id)",
|
|
312
|
+
"""
|
|
313
|
+
CREATE TABLE IF NOT EXISTS picux_case_workspaces (
|
|
314
|
+
case_id TEXT PRIMARY KEY,
|
|
315
|
+
status TEXT NOT NULL DEFAULT 'open',
|
|
316
|
+
owner_id TEXT NOT NULL DEFAULT '',
|
|
317
|
+
conversation_id TEXT NOT NULL DEFAULT '',
|
|
318
|
+
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
|
|
319
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
320
|
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
321
|
+
)
|
|
322
|
+
""",
|
|
323
|
+
"CREATE INDEX IF NOT EXISTS idx_picux_case_workspaces_status ON picux_case_workspaces (status)",
|
|
324
|
+
"CREATE INDEX IF NOT EXISTS idx_picux_case_workspaces_owner ON picux_case_workspaces (owner_id)",
|
|
325
|
+
"CREATE INDEX IF NOT EXISTS idx_picux_case_workspaces_conversation ON picux_case_workspaces (conversation_id)",
|
|
326
|
+
"""
|
|
327
|
+
CREATE TABLE IF NOT EXISTS picux_case_events (
|
|
328
|
+
event_id TEXT PRIMARY KEY,
|
|
329
|
+
case_id TEXT NOT NULL DEFAULT '',
|
|
330
|
+
type TEXT NOT NULL DEFAULT '',
|
|
331
|
+
actor TEXT NOT NULL DEFAULT '',
|
|
332
|
+
status TEXT NOT NULL DEFAULT '',
|
|
333
|
+
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
|
|
334
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
335
|
+
)
|
|
336
|
+
""",
|
|
337
|
+
"CREATE INDEX IF NOT EXISTS idx_picux_case_events_case_created ON picux_case_events (case_id, created_at DESC)",
|
|
338
|
+
"""
|
|
339
|
+
CREATE TABLE IF NOT EXISTS picux_portal_sessions (
|
|
340
|
+
portal_session_id TEXT PRIMARY KEY,
|
|
341
|
+
case_id TEXT NOT NULL DEFAULT '',
|
|
342
|
+
conversation_id TEXT NOT NULL DEFAULT '',
|
|
343
|
+
connector_id TEXT NOT NULL DEFAULT '',
|
|
344
|
+
provider TEXT NOT NULL DEFAULT '',
|
|
345
|
+
status TEXT NOT NULL DEFAULT 'active',
|
|
346
|
+
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
|
|
347
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
348
|
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
349
|
+
)
|
|
350
|
+
""",
|
|
351
|
+
"CREATE INDEX IF NOT EXISTS idx_picux_portal_sessions_case ON picux_portal_sessions (case_id)",
|
|
352
|
+
"CREATE INDEX IF NOT EXISTS idx_picux_portal_sessions_connector ON picux_portal_sessions (connector_id, status)",
|
|
353
|
+
"""
|
|
354
|
+
CREATE TABLE IF NOT EXISTS picux_portal_actions (
|
|
355
|
+
portal_action_id TEXT PRIMARY KEY,
|
|
356
|
+
portal_session_id TEXT NOT NULL DEFAULT '',
|
|
357
|
+
case_id TEXT NOT NULL DEFAULT '',
|
|
358
|
+
conversation_id TEXT NOT NULL DEFAULT '',
|
|
359
|
+
connector_id TEXT NOT NULL DEFAULT '',
|
|
360
|
+
provider TEXT NOT NULL DEFAULT '',
|
|
361
|
+
status TEXT NOT NULL DEFAULT 'queued',
|
|
362
|
+
kind TEXT NOT NULL DEFAULT '',
|
|
363
|
+
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
|
|
364
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
365
|
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
366
|
+
)
|
|
367
|
+
""",
|
|
368
|
+
"CREATE INDEX IF NOT EXISTS idx_picux_portal_actions_session ON picux_portal_actions (portal_session_id)",
|
|
369
|
+
"CREATE INDEX IF NOT EXISTS idx_picux_portal_actions_case_status ON picux_portal_actions (case_id, status)",
|
|
370
|
+
"""
|
|
371
|
+
CREATE TABLE IF NOT EXISTS picux_pov_events (
|
|
372
|
+
event_id TEXT PRIMARY KEY,
|
|
373
|
+
user_id TEXT NOT NULL DEFAULT '',
|
|
374
|
+
task_id TEXT NOT NULL DEFAULT '',
|
|
375
|
+
domain TEXT NOT NULL,
|
|
376
|
+
value_amount NUMERIC(12,2) NOT NULL DEFAULT 0,
|
|
377
|
+
currency TEXT NOT NULL DEFAULT 'USD',
|
|
378
|
+
summary TEXT NOT NULL DEFAULT '',
|
|
379
|
+
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
|
|
380
|
+
occurred_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
381
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
382
|
+
)
|
|
383
|
+
""",
|
|
384
|
+
"CREATE INDEX IF NOT EXISTS idx_picux_pov_user_occurred ON picux_pov_events (user_id, occurred_at DESC)",
|
|
385
|
+
"""
|
|
386
|
+
CREATE TABLE IF NOT EXISTS picux_community_signals (
|
|
387
|
+
signal_id TEXT PRIMARY KEY,
|
|
388
|
+
entity TEXT NOT NULL DEFAULT 'unknown',
|
|
389
|
+
confidence NUMERIC(5,4) NOT NULL DEFAULT 0,
|
|
390
|
+
source_platform TEXT NOT NULL DEFAULT '',
|
|
391
|
+
query TEXT NOT NULL DEFAULT '',
|
|
392
|
+
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
|
|
393
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
394
|
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
395
|
+
)
|
|
396
|
+
""",
|
|
397
|
+
"CREATE INDEX IF NOT EXISTS idx_picux_community_signals_entity ON picux_community_signals (entity)",
|
|
398
|
+
"CREATE INDEX IF NOT EXISTS idx_picux_community_signals_source_query ON picux_community_signals (source_platform, query)",
|
|
399
|
+
"""
|
|
400
|
+
CREATE TABLE IF NOT EXISTS picux_evidence_artifacts (
|
|
401
|
+
artifact_id TEXT PRIMARY KEY,
|
|
402
|
+
kind TEXT NOT NULL DEFAULT '',
|
|
403
|
+
source TEXT NOT NULL DEFAULT '',
|
|
404
|
+
payload_hash TEXT NOT NULL DEFAULT '',
|
|
405
|
+
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
|
|
406
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
407
|
+
)
|
|
408
|
+
""",
|
|
409
|
+
"CREATE INDEX IF NOT EXISTS idx_picux_evidence_kind_source ON picux_evidence_artifacts (kind, source)",
|
|
410
|
+
"CREATE INDEX IF NOT EXISTS idx_picux_evidence_payload_hash ON picux_evidence_artifacts (payload_hash)",
|
|
411
|
+
"""
|
|
412
|
+
CREATE TABLE IF NOT EXISTS picux_escrows (
|
|
413
|
+
escrow_id TEXT PRIMARY KEY,
|
|
414
|
+
status TEXT NOT NULL DEFAULT 'pending',
|
|
415
|
+
mandate_id TEXT NOT NULL DEFAULT '',
|
|
416
|
+
task_id TEXT NOT NULL DEFAULT '',
|
|
417
|
+
amount NUMERIC(12,2) NOT NULL DEFAULT 0,
|
|
418
|
+
currency TEXT NOT NULL DEFAULT 'USD',
|
|
419
|
+
pov_ref TEXT NOT NULL DEFAULT '',
|
|
420
|
+
receipt_id TEXT NOT NULL DEFAULT '',
|
|
421
|
+
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
|
|
422
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
423
|
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
424
|
+
)
|
|
425
|
+
""",
|
|
426
|
+
"CREATE INDEX IF NOT EXISTS idx_picux_escrows_status ON picux_escrows (status)",
|
|
427
|
+
"CREATE INDEX IF NOT EXISTS idx_picux_escrows_mandate_task ON picux_escrows (mandate_id, task_id)",
|
|
428
|
+
"""
|
|
429
|
+
CREATE TABLE IF NOT EXISTS picux_proxy_missions (
|
|
430
|
+
proxy_id TEXT PRIMARY KEY,
|
|
431
|
+
status TEXT NOT NULL DEFAULT 'awaitingProxy',
|
|
432
|
+
kind TEXT NOT NULL DEFAULT 'custom',
|
|
433
|
+
task_id TEXT NOT NULL DEFAULT '',
|
|
434
|
+
user_id TEXT NOT NULL DEFAULT '',
|
|
435
|
+
resolve_id TEXT NOT NULL DEFAULT '',
|
|
436
|
+
refs JSONB NOT NULL DEFAULT '{}'::jsonb,
|
|
437
|
+
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
|
|
438
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
439
|
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
440
|
+
)
|
|
441
|
+
""",
|
|
442
|
+
"CREATE INDEX IF NOT EXISTS idx_picux_proxy_missions_status ON picux_proxy_missions (status)",
|
|
443
|
+
"CREATE INDEX IF NOT EXISTS idx_picux_proxy_missions_task_status ON picux_proxy_missions (task_id, status)",
|
|
444
|
+
"CREATE INDEX IF NOT EXISTS idx_picux_proxy_missions_refs ON picux_proxy_missions USING GIN (refs)",
|
|
445
|
+
"""
|
|
446
|
+
CREATE TABLE IF NOT EXISTS picux_receipts (
|
|
447
|
+
receipt_id TEXT PRIMARY KEY,
|
|
448
|
+
task_id TEXT NOT NULL DEFAULT '',
|
|
449
|
+
mandate_id TEXT NOT NULL DEFAULT '',
|
|
450
|
+
status TEXT NOT NULL,
|
|
451
|
+
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
|
|
452
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
453
|
+
)
|
|
454
|
+
""",
|
|
455
|
+
"""
|
|
456
|
+
CREATE TABLE IF NOT EXISTS picux_schema_migrations (
|
|
457
|
+
version TEXT PRIMARY KEY,
|
|
458
|
+
applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
459
|
+
)
|
|
460
|
+
""",
|
|
461
|
+
]
|
|
462
|
+
|
|
463
|
+
def ensureSchema(self) -> None:
|
|
464
|
+
if not self.enabled:
|
|
465
|
+
return
|
|
466
|
+
with self.conn() as db:
|
|
467
|
+
with db.cursor() as cur:
|
|
468
|
+
for stmt in self.ddl():
|
|
469
|
+
cur.execute(stmt)
|
|
470
|
+
cur.execute(
|
|
471
|
+
"INSERT INTO picux_schema_migrations (version) VALUES (%s) ON CONFLICT (version) DO NOTHING",
|
|
472
|
+
("2026-05-09-001",),
|
|
473
|
+
)
|
|
474
|
+
cur.execute(
|
|
475
|
+
"INSERT INTO picux_schema_migrations (version) VALUES (%s) ON CONFLICT (version) DO NOTHING",
|
|
476
|
+
("2026-05-10-001",),
|
|
477
|
+
)
|
|
478
|
+
cur.execute(
|
|
479
|
+
"INSERT INTO picux_schema_migrations (version) VALUES (%s) ON CONFLICT (version) DO NOTHING",
|
|
480
|
+
("2026-05-10-002",),
|
|
481
|
+
)
|
|
482
|
+
cur.execute(
|
|
483
|
+
"INSERT INTO picux_schema_migrations (version) VALUES (%s) ON CONFLICT (version) DO NOTHING",
|
|
484
|
+
("2026-05-10-003",),
|
|
485
|
+
)
|
|
486
|
+
cur.execute(
|
|
487
|
+
"INSERT INTO picux_schema_migrations (version) VALUES (%s) ON CONFLICT (version) DO NOTHING",
|
|
488
|
+
("2026-05-10-004",),
|
|
489
|
+
)
|
|
490
|
+
cur.execute(
|
|
491
|
+
"INSERT INTO picux_schema_migrations (version) VALUES (%s) ON CONFLICT (version) DO NOTHING",
|
|
492
|
+
("2026-05-10-005",),
|
|
493
|
+
)
|
|
494
|
+
cur.execute(
|
|
495
|
+
"INSERT INTO picux_schema_migrations (version) VALUES (%s) ON CONFLICT (version) DO NOTHING",
|
|
496
|
+
("2026-05-10-006",),
|
|
497
|
+
)
|
|
498
|
+
cur.execute(
|
|
499
|
+
"INSERT INTO picux_schema_migrations (version) VALUES (%s) ON CONFLICT (version) DO NOTHING",
|
|
500
|
+
("2026-05-10-007",),
|
|
501
|
+
)
|
|
502
|
+
cur.execute(
|
|
503
|
+
"INSERT INTO picux_schema_migrations (version) VALUES (%s) ON CONFLICT (version) DO NOTHING",
|
|
504
|
+
("2026-05-10-008",),
|
|
505
|
+
)
|
|
506
|
+
cur.execute(
|
|
507
|
+
"INSERT INTO picux_schema_migrations (version) VALUES (%s) ON CONFLICT (version) DO NOTHING",
|
|
508
|
+
("2026-05-10-009",),
|
|
509
|
+
)
|
|
510
|
+
cur.execute(
|
|
511
|
+
"INSERT INTO picux_schema_migrations (version) VALUES (%s) ON CONFLICT (version) DO NOTHING",
|
|
512
|
+
("2026-05-10-010",),
|
|
513
|
+
)
|
|
514
|
+
cur.execute(
|
|
515
|
+
"INSERT INTO picux_schema_migrations (version) VALUES (%s) ON CONFLICT (version) DO NOTHING",
|
|
516
|
+
("2026-05-10-011",),
|
|
517
|
+
)
|
|
518
|
+
cur.execute(
|
|
519
|
+
"INSERT INTO picux_schema_migrations (version) VALUES (%s) ON CONFLICT (version) DO NOTHING",
|
|
520
|
+
("2026-05-10-012",),
|
|
521
|
+
)
|
|
522
|
+
cur.execute(
|
|
523
|
+
"INSERT INTO picux_schema_migrations (version) VALUES (%s) ON CONFLICT (version) DO NOTHING",
|
|
524
|
+
("2026-05-10-013",),
|
|
525
|
+
)
|
|
526
|
+
cur.execute(
|
|
527
|
+
"INSERT INTO picux_schema_migrations (version) VALUES (%s) ON CONFLICT (version) DO NOTHING",
|
|
528
|
+
("2026-05-14-001",),
|
|
529
|
+
)
|
|
530
|
+
cur.execute(
|
|
531
|
+
"INSERT INTO picux_schema_migrations (version) VALUES (%s) ON CONFLICT (version) DO NOTHING",
|
|
532
|
+
("2026-05-14-002",),
|
|
533
|
+
)
|
|
534
|
+
cur.execute(
|
|
535
|
+
"INSERT INTO picux_schema_migrations (version) VALUES (%s) ON CONFLICT (version) DO NOTHING",
|
|
536
|
+
("2026-05-14-003",),
|
|
537
|
+
)
|
|
538
|
+
|
|
539
|
+
def upsertUser(self, user: ProtocolUser) -> None:
|
|
540
|
+
if not self.enabled:
|
|
541
|
+
return
|
|
542
|
+
sql = (
|
|
543
|
+
"INSERT INTO picux_users (user_id, channel, trust, refs, prefs, meta, created_at, updated_at) "
|
|
544
|
+
"VALUES (%s, %s, %s, %s::jsonb, %s::jsonb, %s::jsonb, TO_TIMESTAMP(%s), TO_TIMESTAMP(%s)) "
|
|
545
|
+
"ON CONFLICT (user_id) DO UPDATE SET "
|
|
546
|
+
"channel = EXCLUDED.channel, trust = EXCLUDED.trust, refs = EXCLUDED.refs, "
|
|
547
|
+
"prefs = EXCLUDED.prefs, meta = EXCLUDED.meta, updated_at = EXCLUDED.updated_at"
|
|
548
|
+
)
|
|
549
|
+
with self.conn() as db:
|
|
550
|
+
with db.cursor() as cur:
|
|
551
|
+
cur.execute(
|
|
552
|
+
sql,
|
|
553
|
+
(
|
|
554
|
+
user.userId,
|
|
555
|
+
user.channel,
|
|
556
|
+
user.trust,
|
|
557
|
+
json.dumps(user.refs, ensure_ascii=True),
|
|
558
|
+
json.dumps(user.prefs, ensure_ascii=True),
|
|
559
|
+
json.dumps(user.meta, ensure_ascii=True),
|
|
560
|
+
int(user.createdAt or 0),
|
|
561
|
+
int(user.updatedAt or 0),
|
|
562
|
+
),
|
|
563
|
+
)
|
|
564
|
+
|
|
565
|
+
def upsertTask(self, task: ProtocolTask) -> None:
|
|
566
|
+
if not self.enabled:
|
|
567
|
+
return
|
|
568
|
+
sql = (
|
|
569
|
+
"INSERT INTO picux_tasks "
|
|
570
|
+
"(task_id, user_id, domain, status, channel, mandate_id, in_data, out_data, needs_approval, "
|
|
571
|
+
"ext_ref, error_msg, meta, created_at, updated_at, approval_by) "
|
|
572
|
+
"VALUES (%s, %s, %s, %s, %s, %s, %s::jsonb, %s::jsonb, %s, %s, %s, %s::jsonb, "
|
|
573
|
+
"TO_TIMESTAMP(%s), TO_TIMESTAMP(%s), %s) "
|
|
574
|
+
"ON CONFLICT (task_id) DO UPDATE SET "
|
|
575
|
+
"user_id = EXCLUDED.user_id, domain = EXCLUDED.domain, status = EXCLUDED.status, "
|
|
576
|
+
"channel = EXCLUDED.channel, mandate_id = EXCLUDED.mandate_id, in_data = EXCLUDED.in_data, "
|
|
577
|
+
"out_data = EXCLUDED.out_data, needs_approval = EXCLUDED.needs_approval, ext_ref = EXCLUDED.ext_ref, "
|
|
578
|
+
"error_msg = EXCLUDED.error_msg, meta = EXCLUDED.meta, updated_at = EXCLUDED.updated_at, "
|
|
579
|
+
"approval_by = EXCLUDED.approval_by"
|
|
580
|
+
)
|
|
581
|
+
with self.conn() as db:
|
|
582
|
+
with db.cursor() as cur:
|
|
583
|
+
cur.execute(
|
|
584
|
+
sql,
|
|
585
|
+
(
|
|
586
|
+
task.taskId,
|
|
587
|
+
task.userId,
|
|
588
|
+
task.domain.value,
|
|
589
|
+
task.status.value,
|
|
590
|
+
task.channel,
|
|
591
|
+
task.mandateId,
|
|
592
|
+
json.dumps(task.inData, ensure_ascii=True),
|
|
593
|
+
json.dumps(task.outData, ensure_ascii=True),
|
|
594
|
+
task.needsApproval,
|
|
595
|
+
task.extRef,
|
|
596
|
+
task.errorMsg,
|
|
597
|
+
json.dumps(task.meta, ensure_ascii=True),
|
|
598
|
+
int(task.createdAt or 0),
|
|
599
|
+
int(task.updatedAt or 0),
|
|
600
|
+
task.approvalBy,
|
|
601
|
+
),
|
|
602
|
+
)
|
|
603
|
+
|
|
604
|
+
def upsertAgent(self, agent: dict[str, Any]) -> None:
|
|
605
|
+
if not self.enabled:
|
|
606
|
+
return
|
|
607
|
+
agentId = str(agent.get("agentId", "") or "")
|
|
608
|
+
if not agentId:
|
|
609
|
+
return
|
|
610
|
+
domains = agent.get("domains", []) if isinstance(agent.get("domains"), list) else []
|
|
611
|
+
caps = agent.get("caps", []) if isinstance(agent.get("caps"), list) else []
|
|
612
|
+
meta = agent.get("meta", {}) if isinstance(agent.get("meta"), dict) else {}
|
|
613
|
+
sql = (
|
|
614
|
+
"INSERT INTO picux_agents "
|
|
615
|
+
"(agent_id, name, domains, caps, endpoint, trust, status, meta, payload, updated_at) "
|
|
616
|
+
"VALUES (%s, %s, %s::jsonb, %s::jsonb, %s, %s, %s, %s::jsonb, %s::jsonb, NOW()) "
|
|
617
|
+
"ON CONFLICT (agent_id) DO UPDATE SET "
|
|
618
|
+
"name = EXCLUDED.name, domains = EXCLUDED.domains, caps = EXCLUDED.caps, "
|
|
619
|
+
"endpoint = EXCLUDED.endpoint, trust = EXCLUDED.trust, status = EXCLUDED.status, "
|
|
620
|
+
"meta = EXCLUDED.meta, payload = EXCLUDED.payload, updated_at = EXCLUDED.updated_at"
|
|
621
|
+
)
|
|
622
|
+
with self.conn() as db:
|
|
623
|
+
with db.cursor() as cur:
|
|
624
|
+
cur.execute(
|
|
625
|
+
sql,
|
|
626
|
+
(
|
|
627
|
+
agentId,
|
|
628
|
+
str(agent.get("name", agentId) or agentId),
|
|
629
|
+
json.dumps(domains, ensure_ascii=True),
|
|
630
|
+
json.dumps(caps, ensure_ascii=True),
|
|
631
|
+
str(agent.get("endpoint", "") or ""),
|
|
632
|
+
int(agent.get("trust", 1) or 1),
|
|
633
|
+
str(agent.get("status", "active") or "active"),
|
|
634
|
+
json.dumps(meta, ensure_ascii=True),
|
|
635
|
+
json.dumps(agent or {}, ensure_ascii=True),
|
|
636
|
+
),
|
|
637
|
+
)
|
|
638
|
+
|
|
639
|
+
def fetchAgent(self, agentId: str) -> dict[str, Any] | None:
|
|
640
|
+
if not self.enabled:
|
|
641
|
+
return None
|
|
642
|
+
with self.conn() as db:
|
|
643
|
+
with db.cursor() as cur:
|
|
644
|
+
cur.execute("SELECT payload::text FROM picux_agents WHERE agent_id = %s", (agentId,))
|
|
645
|
+
row = cur.fetchone()
|
|
646
|
+
if not row:
|
|
647
|
+
return None
|
|
648
|
+
return json.loads(row[0] or "{}")
|
|
649
|
+
|
|
650
|
+
def listAgents(self) -> list[dict[str, Any]]:
|
|
651
|
+
if not self.enabled:
|
|
652
|
+
return []
|
|
653
|
+
with self.conn() as db:
|
|
654
|
+
with db.cursor() as cur:
|
|
655
|
+
cur.execute("SELECT payload::text FROM picux_agents ORDER BY agent_id")
|
|
656
|
+
rows = cur.fetchall()
|
|
657
|
+
return [json.loads(row[0] or "{}") for row in rows]
|
|
658
|
+
|
|
659
|
+
def upsertEnvelope(self, record: dict[str, Any]) -> None:
|
|
660
|
+
if not self.enabled:
|
|
661
|
+
return
|
|
662
|
+
msgId = str(record.get("msgId", "") or "")
|
|
663
|
+
if not msgId:
|
|
664
|
+
return
|
|
665
|
+
sql = (
|
|
666
|
+
"INSERT INTO picux_a2a_envelopes "
|
|
667
|
+
"(msg_id, status, from_agent, to_agent, trace_id, task_id, payload, created_at, updated_at) "
|
|
668
|
+
"VALUES (%s, %s, %s, %s, %s, %s, %s::jsonb, TO_TIMESTAMP(%s), TO_TIMESTAMP(%s)) "
|
|
669
|
+
"ON CONFLICT (msg_id) DO UPDATE SET "
|
|
670
|
+
"status = EXCLUDED.status, from_agent = EXCLUDED.from_agent, to_agent = EXCLUDED.to_agent, "
|
|
671
|
+
"trace_id = EXCLUDED.trace_id, task_id = EXCLUDED.task_id, payload = EXCLUDED.payload, "
|
|
672
|
+
"updated_at = EXCLUDED.updated_at"
|
|
673
|
+
)
|
|
674
|
+
with self.conn() as db:
|
|
675
|
+
with db.cursor() as cur:
|
|
676
|
+
cur.execute(
|
|
677
|
+
sql,
|
|
678
|
+
(
|
|
679
|
+
msgId,
|
|
680
|
+
str(record.get("status", "queued") or "queued"),
|
|
681
|
+
str(record.get("fromAgent", "") or ""),
|
|
682
|
+
str(record.get("toAgent", "") or ""),
|
|
683
|
+
str(record.get("traceId", "") or ""),
|
|
684
|
+
str(record.get("taskId", "") or ""),
|
|
685
|
+
json.dumps(record or {}, ensure_ascii=True),
|
|
686
|
+
int(record.get("createdAt", 0) or 0),
|
|
687
|
+
int(record.get("updatedAt", 0) or 0),
|
|
688
|
+
),
|
|
689
|
+
)
|
|
690
|
+
|
|
691
|
+
def fetchEnvelope(self, msgId: str) -> dict[str, Any] | None:
|
|
692
|
+
if not self.enabled:
|
|
693
|
+
return None
|
|
694
|
+
with self.conn() as db:
|
|
695
|
+
with db.cursor() as cur:
|
|
696
|
+
cur.execute("SELECT payload::text FROM picux_a2a_envelopes WHERE msg_id = %s", (msgId,))
|
|
697
|
+
row = cur.fetchone()
|
|
698
|
+
if not row:
|
|
699
|
+
return None
|
|
700
|
+
return json.loads(row[0] or "{}")
|
|
701
|
+
|
|
702
|
+
def listEnvelopes(self, *, limit: int = 500) -> list[dict[str, Any]]:
|
|
703
|
+
if not self.enabled:
|
|
704
|
+
return []
|
|
705
|
+
with self.conn() as db:
|
|
706
|
+
with db.cursor() as cur:
|
|
707
|
+
cur.execute(
|
|
708
|
+
"SELECT payload::text FROM picux_a2a_envelopes ORDER BY created_at DESC LIMIT %s",
|
|
709
|
+
(max(1, min(int(limit or 500), 500)),),
|
|
710
|
+
)
|
|
711
|
+
rows = cur.fetchall()
|
|
712
|
+
return [json.loads(row[0] or "{}") for row in rows]
|
|
713
|
+
|
|
714
|
+
def upsertMandate(self, record: dict[str, Any]) -> None:
|
|
715
|
+
if not self.enabled:
|
|
716
|
+
return
|
|
717
|
+
mandateId = str(record.get("mandateId", "") or "")
|
|
718
|
+
if not mandateId:
|
|
719
|
+
return
|
|
720
|
+
mandate = record.get("mandate", {}) if isinstance(record.get("mandate"), dict) else {}
|
|
721
|
+
issuer = mandate.get("issuer", {}) if isinstance(mandate.get("issuer"), dict) else {}
|
|
722
|
+
validation = record.get("validation", {}) if isinstance(record.get("validation"), dict) else {}
|
|
723
|
+
sql = (
|
|
724
|
+
"INSERT INTO picux_mandates "
|
|
725
|
+
"(mandate_id, status, issuer_id, mandate, validation, payload, created_at, updated_at) "
|
|
726
|
+
"VALUES (%s, %s, %s, %s::jsonb, %s::jsonb, %s::jsonb, TO_TIMESTAMP(%s), TO_TIMESTAMP(%s)) "
|
|
727
|
+
"ON CONFLICT (mandate_id) DO UPDATE SET "
|
|
728
|
+
"status = EXCLUDED.status, issuer_id = EXCLUDED.issuer_id, mandate = EXCLUDED.mandate, "
|
|
729
|
+
"validation = EXCLUDED.validation, payload = EXCLUDED.payload, updated_at = EXCLUDED.updated_at"
|
|
730
|
+
)
|
|
731
|
+
with self.conn() as db:
|
|
732
|
+
with db.cursor() as cur:
|
|
733
|
+
cur.execute(
|
|
734
|
+
sql,
|
|
735
|
+
(
|
|
736
|
+
mandateId,
|
|
737
|
+
str(record.get("status", "pendingVerification") or "pendingVerification"),
|
|
738
|
+
str(issuer.get("entityId", "") or ""),
|
|
739
|
+
json.dumps(mandate, ensure_ascii=True),
|
|
740
|
+
json.dumps(validation, ensure_ascii=True),
|
|
741
|
+
json.dumps(record or {}, ensure_ascii=True),
|
|
742
|
+
int(record.get("createdAt", 0) or 0),
|
|
743
|
+
int(record.get("updatedAt", 0) or 0),
|
|
744
|
+
),
|
|
745
|
+
)
|
|
746
|
+
|
|
747
|
+
def fetchMandate(self, mandateId: str) -> dict[str, Any] | None:
|
|
748
|
+
if not self.enabled:
|
|
749
|
+
return None
|
|
750
|
+
with self.conn() as db:
|
|
751
|
+
with db.cursor() as cur:
|
|
752
|
+
cur.execute("SELECT payload::text FROM picux_mandates WHERE mandate_id = %s", (mandateId,))
|
|
753
|
+
row = cur.fetchone()
|
|
754
|
+
if not row:
|
|
755
|
+
return None
|
|
756
|
+
return json.loads(row[0] or "{}")
|
|
757
|
+
|
|
758
|
+
def listMandates(self, *, limit: int = 500) -> list[dict[str, Any]]:
|
|
759
|
+
if not self.enabled:
|
|
760
|
+
return []
|
|
761
|
+
with self.conn() as db:
|
|
762
|
+
with db.cursor() as cur:
|
|
763
|
+
cur.execute(
|
|
764
|
+
"SELECT payload::text FROM picux_mandates ORDER BY created_at DESC LIMIT %s",
|
|
765
|
+
(max(1, min(int(limit or 500), 500)),),
|
|
766
|
+
)
|
|
767
|
+
rows = cur.fetchall()
|
|
768
|
+
return [json.loads(row[0] or "{}") for row in rows]
|
|
769
|
+
|
|
770
|
+
def upsertEscrow(self, record: dict[str, Any]) -> None:
|
|
771
|
+
if not self.enabled:
|
|
772
|
+
return
|
|
773
|
+
escrowId = str(record.get("escrowId", "") or "")
|
|
774
|
+
if not escrowId:
|
|
775
|
+
return
|
|
776
|
+
amount = record.get("amount", {}) if isinstance(record.get("amount"), dict) else {}
|
|
777
|
+
sql = (
|
|
778
|
+
"INSERT INTO picux_escrows "
|
|
779
|
+
"(escrow_id, status, mandate_id, task_id, amount, currency, pov_ref, receipt_id, payload, created_at, updated_at) "
|
|
780
|
+
"VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s::jsonb, TO_TIMESTAMP(%s), TO_TIMESTAMP(%s)) "
|
|
781
|
+
"ON CONFLICT (escrow_id) DO UPDATE SET "
|
|
782
|
+
"status = EXCLUDED.status, mandate_id = EXCLUDED.mandate_id, task_id = EXCLUDED.task_id, "
|
|
783
|
+
"amount = EXCLUDED.amount, currency = EXCLUDED.currency, pov_ref = EXCLUDED.pov_ref, "
|
|
784
|
+
"receipt_id = EXCLUDED.receipt_id, payload = EXCLUDED.payload, updated_at = EXCLUDED.updated_at"
|
|
785
|
+
)
|
|
786
|
+
with self.conn() as db:
|
|
787
|
+
with db.cursor() as cur:
|
|
788
|
+
cur.execute(
|
|
789
|
+
sql,
|
|
790
|
+
(
|
|
791
|
+
escrowId,
|
|
792
|
+
str(record.get("status", "pending") or "pending"),
|
|
793
|
+
str(record.get("mandateId", "") or ""),
|
|
794
|
+
str(record.get("taskId", "") or ""),
|
|
795
|
+
self._amount(amount.get("amount", 0.0)),
|
|
796
|
+
str(amount.get("currency", "USD") or "USD"),
|
|
797
|
+
str(record.get("povRef", "") or ""),
|
|
798
|
+
str(record.get("receiptId", "") or ""),
|
|
799
|
+
json.dumps(record or {}, ensure_ascii=True),
|
|
800
|
+
int(record.get("createdAt", 0) or 0),
|
|
801
|
+
int(record.get("updatedAt", 0) or 0),
|
|
802
|
+
),
|
|
803
|
+
)
|
|
804
|
+
|
|
805
|
+
def fetchEscrow(self, escrowId: str) -> dict[str, Any] | None:
|
|
806
|
+
if not self.enabled:
|
|
807
|
+
return None
|
|
808
|
+
with self.conn() as db:
|
|
809
|
+
with db.cursor() as cur:
|
|
810
|
+
cur.execute("SELECT payload::text FROM picux_escrows WHERE escrow_id = %s", (escrowId,))
|
|
811
|
+
row = cur.fetchone()
|
|
812
|
+
if not row:
|
|
813
|
+
return None
|
|
814
|
+
return json.loads(row[0] or "{}")
|
|
815
|
+
|
|
816
|
+
def listEscrows(self, *, limit: int = 500) -> list[dict[str, Any]]:
|
|
817
|
+
if not self.enabled:
|
|
818
|
+
return []
|
|
819
|
+
with self.conn() as db:
|
|
820
|
+
with db.cursor() as cur:
|
|
821
|
+
cur.execute(
|
|
822
|
+
"SELECT payload::text FROM picux_escrows ORDER BY created_at DESC LIMIT %s",
|
|
823
|
+
(max(1, min(int(limit or 500), 500)),),
|
|
824
|
+
)
|
|
825
|
+
rows = cur.fetchall()
|
|
826
|
+
return [json.loads(row[0] or "{}") for row in rows]
|
|
827
|
+
|
|
828
|
+
def upsertProxyMission(self, record: dict[str, Any]) -> None:
|
|
829
|
+
if not self.enabled:
|
|
830
|
+
return
|
|
831
|
+
proxyId = str(record.get("proxyId", "") or "")
|
|
832
|
+
if not proxyId:
|
|
833
|
+
return
|
|
834
|
+
refs = record.get("refs", {}) if isinstance(record.get("refs"), dict) else {}
|
|
835
|
+
sql = (
|
|
836
|
+
"INSERT INTO picux_proxy_missions "
|
|
837
|
+
"(proxy_id, status, kind, task_id, user_id, resolve_id, refs, payload, created_at, updated_at) "
|
|
838
|
+
"VALUES (%s, %s, %s, %s, %s, %s, %s::jsonb, %s::jsonb, TO_TIMESTAMP(%s), TO_TIMESTAMP(%s)) "
|
|
839
|
+
"ON CONFLICT (proxy_id) DO UPDATE SET "
|
|
840
|
+
"status = EXCLUDED.status, kind = EXCLUDED.kind, task_id = EXCLUDED.task_id, "
|
|
841
|
+
"user_id = EXCLUDED.user_id, resolve_id = EXCLUDED.resolve_id, refs = EXCLUDED.refs, "
|
|
842
|
+
"payload = EXCLUDED.payload, updated_at = EXCLUDED.updated_at"
|
|
843
|
+
)
|
|
844
|
+
with self.conn() as db:
|
|
845
|
+
with db.cursor() as cur:
|
|
846
|
+
cur.execute(
|
|
847
|
+
sql,
|
|
848
|
+
(
|
|
849
|
+
proxyId,
|
|
850
|
+
str(record.get("status", "awaitingProxy") or "awaitingProxy"),
|
|
851
|
+
str(record.get("kind", "custom") or "custom"),
|
|
852
|
+
str(record.get("taskId", "") or ""),
|
|
853
|
+
str(record.get("userId", "") or ""),
|
|
854
|
+
str(record.get("resolveId", "") or ""),
|
|
855
|
+
json.dumps(refs, ensure_ascii=True),
|
|
856
|
+
json.dumps(record or {}, ensure_ascii=True),
|
|
857
|
+
int(record.get("createdAt", 0) or 0),
|
|
858
|
+
int(record.get("updatedAt", 0) or 0),
|
|
859
|
+
),
|
|
860
|
+
)
|
|
861
|
+
|
|
862
|
+
def fetchProxyMission(self, proxyId: str) -> dict[str, Any] | None:
|
|
863
|
+
if not self.enabled:
|
|
864
|
+
return None
|
|
865
|
+
with self.conn() as db:
|
|
866
|
+
with db.cursor() as cur:
|
|
867
|
+
cur.execute("SELECT payload::text FROM picux_proxy_missions WHERE proxy_id = %s", (proxyId,))
|
|
868
|
+
row = cur.fetchone()
|
|
869
|
+
if not row:
|
|
870
|
+
return None
|
|
871
|
+
return json.loads(row[0] or "{}")
|
|
872
|
+
|
|
873
|
+
def fetchProxyMissionByRef(self, refName: str, refValue: str) -> dict[str, Any] | None:
|
|
874
|
+
if not self.enabled:
|
|
875
|
+
return None
|
|
876
|
+
refName = str(refName or "")
|
|
877
|
+
refValue = str(refValue or "")
|
|
878
|
+
if refName not in {"requestId", "providerTaskId", "externalRef", "connectorRunId"} or not refValue:
|
|
879
|
+
return None
|
|
880
|
+
with self.conn() as db:
|
|
881
|
+
with db.cursor() as cur:
|
|
882
|
+
cur.execute(
|
|
883
|
+
"SELECT payload::text FROM picux_proxy_missions WHERE refs @> %s::jsonb ORDER BY created_at DESC LIMIT 1",
|
|
884
|
+
(json.dumps({refName: refValue}, ensure_ascii=True),),
|
|
885
|
+
)
|
|
886
|
+
row = cur.fetchone()
|
|
887
|
+
if not row:
|
|
888
|
+
return None
|
|
889
|
+
return json.loads(row[0] or "{}")
|
|
890
|
+
|
|
891
|
+
def listProxyMissions(self, *, limit: int = 500) -> list[dict[str, Any]]:
|
|
892
|
+
if not self.enabled:
|
|
893
|
+
return []
|
|
894
|
+
with self.conn() as db:
|
|
895
|
+
with db.cursor() as cur:
|
|
896
|
+
cur.execute(
|
|
897
|
+
"SELECT payload::text FROM picux_proxy_missions ORDER BY created_at DESC LIMIT %s",
|
|
898
|
+
(max(1, min(int(limit or 500), 500)),),
|
|
899
|
+
)
|
|
900
|
+
rows = cur.fetchall()
|
|
901
|
+
return [json.loads(row[0] or "{}") for row in rows]
|
|
902
|
+
|
|
903
|
+
def fetchTask(self, taskId: str) -> ProtocolTask | None:
|
|
904
|
+
if not self.enabled:
|
|
905
|
+
return None
|
|
906
|
+
sql = (
|
|
907
|
+
"SELECT task_id, user_id, domain, status, channel, mandate_id, "
|
|
908
|
+
"in_data::text, out_data::text, needs_approval, ext_ref, error_msg, meta::text, "
|
|
909
|
+
"EXTRACT(EPOCH FROM created_at)::bigint, EXTRACT(EPOCH FROM updated_at)::bigint, approval_by "
|
|
910
|
+
"FROM picux_tasks WHERE task_id = %s"
|
|
911
|
+
)
|
|
912
|
+
with self.conn() as db:
|
|
913
|
+
with db.cursor() as cur:
|
|
914
|
+
cur.execute(sql, (taskId,))
|
|
915
|
+
row = cur.fetchone()
|
|
916
|
+
if not row:
|
|
917
|
+
return None
|
|
918
|
+
return self._taskFromRow(row)
|
|
919
|
+
|
|
920
|
+
def listTasks(self, *, limit: int = 500) -> list[ProtocolTask]:
|
|
921
|
+
if not self.enabled:
|
|
922
|
+
return []
|
|
923
|
+
sql = (
|
|
924
|
+
"SELECT task_id, user_id, domain, status, channel, mandate_id, "
|
|
925
|
+
"in_data::text, out_data::text, needs_approval, ext_ref, error_msg, meta::text, "
|
|
926
|
+
"EXTRACT(EPOCH FROM created_at)::bigint, EXTRACT(EPOCH FROM updated_at)::bigint, approval_by "
|
|
927
|
+
"FROM picux_tasks ORDER BY created_at DESC LIMIT %s"
|
|
928
|
+
)
|
|
929
|
+
with self.conn() as db:
|
|
930
|
+
with db.cursor() as cur:
|
|
931
|
+
cur.execute(sql, (max(1, min(int(limit or 500), 500)),))
|
|
932
|
+
rows = cur.fetchall()
|
|
933
|
+
tasks: list[ProtocolTask] = []
|
|
934
|
+
for row in rows:
|
|
935
|
+
tasks.append(self._taskFromRow(row))
|
|
936
|
+
return tasks
|
|
937
|
+
|
|
938
|
+
def durableWriteReadCheck(self, task: ProtocolTask) -> dict[str, Any]:
|
|
939
|
+
if not self.enabled:
|
|
940
|
+
return {"ok": False, "skipped": True, "reason": "postgresDisabled"}
|
|
941
|
+
self.upsertTask(task)
|
|
942
|
+
fetched = self.fetchTask(task.taskId)
|
|
943
|
+
return {
|
|
944
|
+
"ok": bool(fetched and fetched.taskId == task.taskId and fetched.domain == task.domain),
|
|
945
|
+
"skipped": False,
|
|
946
|
+
"taskId": task.taskId,
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
def insertActivity(self, *, taskId: str, userId: str, level: str, action: str, detail: str, meta: dict[str, Any]) -> None:
|
|
950
|
+
if not self.enabled:
|
|
951
|
+
return
|
|
952
|
+
sql = (
|
|
953
|
+
"INSERT INTO picux_activity_logs (task_id, user_id, level, action, detail, meta) "
|
|
954
|
+
"VALUES (%s, %s, %s, %s, %s, %s::jsonb)"
|
|
955
|
+
)
|
|
956
|
+
with self.conn() as db:
|
|
957
|
+
with db.cursor() as cur:
|
|
958
|
+
cur.execute(sql, (taskId, userId, level, action, detail, json.dumps(meta or {}, ensure_ascii=True)))
|
|
959
|
+
|
|
960
|
+
def insertEvent(self, *, userId: str, taskId: str, event: str, payload: dict[str, Any]) -> None:
|
|
961
|
+
if not self.enabled:
|
|
962
|
+
return
|
|
963
|
+
sql = "INSERT INTO picux_activity_events (user_id, task_id, event, payload) VALUES (%s, %s, %s, %s::jsonb)"
|
|
964
|
+
with self.conn() as db:
|
|
965
|
+
with db.cursor() as cur:
|
|
966
|
+
cur.execute(sql, (userId, taskId, event, json.dumps(payload or {}, ensure_ascii=True)))
|
|
967
|
+
|
|
968
|
+
def upsertIntegrationEvent(self, record: dict[str, Any]) -> None:
|
|
969
|
+
if not self.enabled:
|
|
970
|
+
return
|
|
971
|
+
eventId = str(record.get("eventId", "") or "")
|
|
972
|
+
if not eventId:
|
|
973
|
+
return
|
|
974
|
+
ackAt = int(record.get("ackAt", 0) or 0)
|
|
975
|
+
sql = (
|
|
976
|
+
"INSERT INTO picux_integration_events "
|
|
977
|
+
"(event_id, type, subject, source, target, status, payload, created_at, updated_at, ack_at) "
|
|
978
|
+
"VALUES (%s, %s, %s, %s, %s, %s, %s::jsonb, TO_TIMESTAMP(%s), TO_TIMESTAMP(%s), "
|
|
979
|
+
"CASE WHEN %s > 0 THEN TO_TIMESTAMP(%s) ELSE NULL END) "
|
|
980
|
+
"ON CONFLICT (event_id) DO UPDATE SET "
|
|
981
|
+
"type = EXCLUDED.type, subject = EXCLUDED.subject, source = EXCLUDED.source, target = EXCLUDED.target, "
|
|
982
|
+
"status = EXCLUDED.status, payload = EXCLUDED.payload, updated_at = EXCLUDED.updated_at, ack_at = EXCLUDED.ack_at"
|
|
983
|
+
)
|
|
984
|
+
with self.conn() as db:
|
|
985
|
+
with db.cursor() as cur:
|
|
986
|
+
cur.execute(
|
|
987
|
+
sql,
|
|
988
|
+
(
|
|
989
|
+
eventId,
|
|
990
|
+
str(record.get("type", "") or ""),
|
|
991
|
+
str(record.get("subject", "") or ""),
|
|
992
|
+
str(record.get("source", "picux") or "picux"),
|
|
993
|
+
str(record.get("target", "") or ""),
|
|
994
|
+
str(record.get("status", "queued") or "queued"),
|
|
995
|
+
json.dumps(record or {}, ensure_ascii=True),
|
|
996
|
+
int(record.get("createdAt", 0) or 0),
|
|
997
|
+
int(record.get("updatedAt", 0) or 0),
|
|
998
|
+
ackAt,
|
|
999
|
+
ackAt,
|
|
1000
|
+
),
|
|
1001
|
+
)
|
|
1002
|
+
|
|
1003
|
+
def fetchIntegrationEvent(self, eventId: str) -> dict[str, Any] | None:
|
|
1004
|
+
if not self.enabled:
|
|
1005
|
+
return None
|
|
1006
|
+
with self.conn() as db:
|
|
1007
|
+
with db.cursor() as cur:
|
|
1008
|
+
cur.execute("SELECT payload::text FROM picux_integration_events WHERE event_id = %s", (eventId,))
|
|
1009
|
+
row = cur.fetchone()
|
|
1010
|
+
if not row:
|
|
1011
|
+
return None
|
|
1012
|
+
return json.loads(row[0] or "{}")
|
|
1013
|
+
|
|
1014
|
+
def listIntegrationEvents(self, *, limit: int = 500) -> list[dict[str, Any]]:
|
|
1015
|
+
if not self.enabled:
|
|
1016
|
+
return []
|
|
1017
|
+
with self.conn() as db:
|
|
1018
|
+
with db.cursor() as cur:
|
|
1019
|
+
cur.execute(
|
|
1020
|
+
"SELECT payload::text FROM picux_integration_events ORDER BY created_at DESC LIMIT %s",
|
|
1021
|
+
(max(1, min(int(limit or 500), 500)),),
|
|
1022
|
+
)
|
|
1023
|
+
rows = cur.fetchall()
|
|
1024
|
+
return [json.loads(row[0] or "{}") for row in rows]
|
|
1025
|
+
|
|
1026
|
+
def upsertEventSubscription(self, record: dict[str, Any]) -> None:
|
|
1027
|
+
if not self.enabled:
|
|
1028
|
+
return
|
|
1029
|
+
subId = str(record.get("subId", "") or "")
|
|
1030
|
+
if not subId:
|
|
1031
|
+
return
|
|
1032
|
+
eventTypes = record.get("eventTypes", []) if isinstance(record.get("eventTypes"), list) else []
|
|
1033
|
+
sql = (
|
|
1034
|
+
"INSERT INTO picux_event_subscriptions "
|
|
1035
|
+
"(sub_id, client_id, target, transport, endpoint, status, event_types, payload, created_at, updated_at) "
|
|
1036
|
+
"VALUES (%s, %s, %s, %s, %s, %s, %s::jsonb, %s::jsonb, TO_TIMESTAMP(%s), TO_TIMESTAMP(%s)) "
|
|
1037
|
+
"ON CONFLICT (sub_id) DO UPDATE SET "
|
|
1038
|
+
"client_id = EXCLUDED.client_id, target = EXCLUDED.target, transport = EXCLUDED.transport, "
|
|
1039
|
+
"endpoint = EXCLUDED.endpoint, status = EXCLUDED.status, event_types = EXCLUDED.event_types, "
|
|
1040
|
+
"payload = EXCLUDED.payload, updated_at = EXCLUDED.updated_at"
|
|
1041
|
+
)
|
|
1042
|
+
with self.conn() as db:
|
|
1043
|
+
with db.cursor() as cur:
|
|
1044
|
+
cur.execute(
|
|
1045
|
+
sql,
|
|
1046
|
+
(
|
|
1047
|
+
subId,
|
|
1048
|
+
str(record.get("clientId", "") or ""),
|
|
1049
|
+
str(record.get("target", "") or ""),
|
|
1050
|
+
str(record.get("transport", "poll") or "poll"),
|
|
1051
|
+
str(record.get("endpoint", "") or ""),
|
|
1052
|
+
str(record.get("status", "active") or "active"),
|
|
1053
|
+
json.dumps(eventTypes, ensure_ascii=True),
|
|
1054
|
+
json.dumps(record or {}, ensure_ascii=True),
|
|
1055
|
+
int(record.get("createdAt", 0) or 0),
|
|
1056
|
+
int(record.get("updatedAt", 0) or 0),
|
|
1057
|
+
),
|
|
1058
|
+
)
|
|
1059
|
+
|
|
1060
|
+
def fetchEventSubscription(self, subId: str) -> dict[str, Any] | None:
|
|
1061
|
+
if not self.enabled:
|
|
1062
|
+
return None
|
|
1063
|
+
with self.conn() as db:
|
|
1064
|
+
with db.cursor() as cur:
|
|
1065
|
+
cur.execute("SELECT payload::text FROM picux_event_subscriptions WHERE sub_id = %s", (subId,))
|
|
1066
|
+
row = cur.fetchone()
|
|
1067
|
+
if not row:
|
|
1068
|
+
return None
|
|
1069
|
+
return json.loads(row[0] or "{}")
|
|
1070
|
+
|
|
1071
|
+
def listEventSubscriptions(self, *, limit: int = 500) -> list[dict[str, Any]]:
|
|
1072
|
+
if not self.enabled:
|
|
1073
|
+
return []
|
|
1074
|
+
with self.conn() as db:
|
|
1075
|
+
with db.cursor() as cur:
|
|
1076
|
+
cur.execute(
|
|
1077
|
+
"SELECT payload::text FROM picux_event_subscriptions ORDER BY created_at DESC LIMIT %s",
|
|
1078
|
+
(max(1, min(int(limit or 500), 500)),),
|
|
1079
|
+
)
|
|
1080
|
+
rows = cur.fetchall()
|
|
1081
|
+
return [json.loads(row[0] or "{}") for row in rows]
|
|
1082
|
+
|
|
1083
|
+
def upsertEventDelivery(self, record: dict[str, Any]) -> None:
|
|
1084
|
+
if not self.enabled:
|
|
1085
|
+
return
|
|
1086
|
+
deliveryId = str(record.get("deliveryId", "") or "")
|
|
1087
|
+
if not deliveryId:
|
|
1088
|
+
return
|
|
1089
|
+
nextAttemptAt = int(record.get("nextAttemptAt", 0) or 0)
|
|
1090
|
+
claimedAt = int(record.get("claimedAt", 0) or 0)
|
|
1091
|
+
leaseExpiresAt = int(record.get("leaseExpiresAt", 0) or 0)
|
|
1092
|
+
deliveredAt = int(record.get("deliveredAt", 0) or 0)
|
|
1093
|
+
sql = (
|
|
1094
|
+
"INSERT INTO picux_event_deliveries "
|
|
1095
|
+
"(delivery_id, event_id, sub_id, client_id, transport, endpoint, status, attempt, payload, "
|
|
1096
|
+
"created_at, updated_at, next_attempt_at, claimed_at, lease_expires_at, delivered_at) "
|
|
1097
|
+
"VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s::jsonb, TO_TIMESTAMP(%s), TO_TIMESTAMP(%s), "
|
|
1098
|
+
"CASE WHEN %s > 0 THEN TO_TIMESTAMP(%s) ELSE NULL END, "
|
|
1099
|
+
"CASE WHEN %s > 0 THEN TO_TIMESTAMP(%s) ELSE NULL END, "
|
|
1100
|
+
"CASE WHEN %s > 0 THEN TO_TIMESTAMP(%s) ELSE NULL END, "
|
|
1101
|
+
"CASE WHEN %s > 0 THEN TO_TIMESTAMP(%s) ELSE NULL END) "
|
|
1102
|
+
"ON CONFLICT (delivery_id) DO UPDATE SET "
|
|
1103
|
+
"event_id = EXCLUDED.event_id, sub_id = EXCLUDED.sub_id, client_id = EXCLUDED.client_id, "
|
|
1104
|
+
"transport = EXCLUDED.transport, endpoint = EXCLUDED.endpoint, status = EXCLUDED.status, "
|
|
1105
|
+
"attempt = EXCLUDED.attempt, payload = EXCLUDED.payload, updated_at = EXCLUDED.updated_at, "
|
|
1106
|
+
"next_attempt_at = EXCLUDED.next_attempt_at, claimed_at = EXCLUDED.claimed_at, "
|
|
1107
|
+
"lease_expires_at = EXCLUDED.lease_expires_at, delivered_at = EXCLUDED.delivered_at"
|
|
1108
|
+
)
|
|
1109
|
+
with self.conn() as db:
|
|
1110
|
+
with db.cursor() as cur:
|
|
1111
|
+
cur.execute(
|
|
1112
|
+
sql,
|
|
1113
|
+
(
|
|
1114
|
+
deliveryId,
|
|
1115
|
+
str(record.get("eventId", "") or ""),
|
|
1116
|
+
str(record.get("subId", "") or ""),
|
|
1117
|
+
str(record.get("clientId", "") or ""),
|
|
1118
|
+
str(record.get("transport", "poll") or "poll"),
|
|
1119
|
+
str(record.get("endpoint", "") or ""),
|
|
1120
|
+
str(record.get("status", "queued") or "queued"),
|
|
1121
|
+
int(record.get("attempt", 1) or 1),
|
|
1122
|
+
json.dumps(record or {}, ensure_ascii=True),
|
|
1123
|
+
int(record.get("createdAt", 0) or 0),
|
|
1124
|
+
int(record.get("updatedAt", 0) or 0),
|
|
1125
|
+
nextAttemptAt,
|
|
1126
|
+
nextAttemptAt,
|
|
1127
|
+
claimedAt,
|
|
1128
|
+
claimedAt,
|
|
1129
|
+
leaseExpiresAt,
|
|
1130
|
+
leaseExpiresAt,
|
|
1131
|
+
deliveredAt,
|
|
1132
|
+
deliveredAt,
|
|
1133
|
+
),
|
|
1134
|
+
)
|
|
1135
|
+
|
|
1136
|
+
def fetchEventDelivery(self, deliveryId: str) -> dict[str, Any] | None:
|
|
1137
|
+
if not self.enabled:
|
|
1138
|
+
return None
|
|
1139
|
+
with self.conn() as db:
|
|
1140
|
+
with db.cursor() as cur:
|
|
1141
|
+
cur.execute("SELECT payload::text FROM picux_event_deliveries WHERE delivery_id = %s", (deliveryId,))
|
|
1142
|
+
row = cur.fetchone()
|
|
1143
|
+
if not row:
|
|
1144
|
+
return None
|
|
1145
|
+
return json.loads(row[0] or "{}")
|
|
1146
|
+
|
|
1147
|
+
def listEventDeliveries(self, *, limit: int = 500) -> list[dict[str, Any]]:
|
|
1148
|
+
if not self.enabled:
|
|
1149
|
+
return []
|
|
1150
|
+
with self.conn() as db:
|
|
1151
|
+
with db.cursor() as cur:
|
|
1152
|
+
cur.execute(
|
|
1153
|
+
"SELECT payload::text FROM picux_event_deliveries ORDER BY created_at DESC LIMIT %s",
|
|
1154
|
+
(max(1, min(int(limit or 500), 500)),),
|
|
1155
|
+
)
|
|
1156
|
+
rows = cur.fetchall()
|
|
1157
|
+
return [json.loads(row[0] or "{}") for row in rows]
|
|
1158
|
+
|
|
1159
|
+
def upsertConnector(self, record: dict[str, Any]) -> None:
|
|
1160
|
+
if not self.enabled:
|
|
1161
|
+
return
|
|
1162
|
+
connectorId = str(record.get("connectorId", "") or "")
|
|
1163
|
+
if not connectorId:
|
|
1164
|
+
return
|
|
1165
|
+
sql = (
|
|
1166
|
+
"INSERT INTO picux_bridge_connectors "
|
|
1167
|
+
"(connector_id, status, domain, kind, endpoint, payload, created_at, updated_at) "
|
|
1168
|
+
"VALUES (%s, %s, %s, %s, %s, %s::jsonb, TO_TIMESTAMP(%s), TO_TIMESTAMP(%s)) "
|
|
1169
|
+
"ON CONFLICT (connector_id) DO UPDATE SET "
|
|
1170
|
+
"status = EXCLUDED.status, domain = EXCLUDED.domain, kind = EXCLUDED.kind, "
|
|
1171
|
+
"endpoint = EXCLUDED.endpoint, payload = EXCLUDED.payload, updated_at = EXCLUDED.updated_at"
|
|
1172
|
+
)
|
|
1173
|
+
with self.conn() as db:
|
|
1174
|
+
with db.cursor() as cur:
|
|
1175
|
+
cur.execute(
|
|
1176
|
+
sql,
|
|
1177
|
+
(
|
|
1178
|
+
connectorId,
|
|
1179
|
+
str(record.get("status", "active") or "active"),
|
|
1180
|
+
str(record.get("domain", "bridge") or "bridge"),
|
|
1181
|
+
str(record.get("kind", "api") or "api"),
|
|
1182
|
+
str(record.get("endpoint", "") or ""),
|
|
1183
|
+
json.dumps(record or {}, ensure_ascii=True),
|
|
1184
|
+
int(record.get("createdAt", 0) or 0),
|
|
1185
|
+
int(record.get("updatedAt", 0) or 0),
|
|
1186
|
+
),
|
|
1187
|
+
)
|
|
1188
|
+
|
|
1189
|
+
def fetchConnector(self, connectorId: str) -> dict[str, Any] | None:
|
|
1190
|
+
return self._fetchJsonPayload("picux_bridge_connectors", "connector_id", connectorId)
|
|
1191
|
+
|
|
1192
|
+
def listConnectors(self, *, limit: int = 500) -> list[dict[str, Any]]:
|
|
1193
|
+
return self._listJsonPayloads("picux_bridge_connectors", "created_at", limit=limit)
|
|
1194
|
+
|
|
1195
|
+
def upsertConnectionSession(self, record: dict[str, Any]) -> None:
|
|
1196
|
+
if not self.enabled:
|
|
1197
|
+
return
|
|
1198
|
+
sessionId = str(record.get("sessionId", "") or "")
|
|
1199
|
+
if not sessionId:
|
|
1200
|
+
return
|
|
1201
|
+
sql = (
|
|
1202
|
+
"INSERT INTO picux_bridge_connection_sessions "
|
|
1203
|
+
"(session_id, connector_id, status, subject_id, payload, created_at, updated_at) "
|
|
1204
|
+
"VALUES (%s, %s, %s, %s, %s::jsonb, TO_TIMESTAMP(%s), TO_TIMESTAMP(%s)) "
|
|
1205
|
+
"ON CONFLICT (session_id) DO UPDATE SET "
|
|
1206
|
+
"connector_id = EXCLUDED.connector_id, status = EXCLUDED.status, subject_id = EXCLUDED.subject_id, "
|
|
1207
|
+
"payload = EXCLUDED.payload, updated_at = EXCLUDED.updated_at"
|
|
1208
|
+
)
|
|
1209
|
+
with self.conn() as db:
|
|
1210
|
+
with db.cursor() as cur:
|
|
1211
|
+
cur.execute(
|
|
1212
|
+
sql,
|
|
1213
|
+
(
|
|
1214
|
+
sessionId,
|
|
1215
|
+
str(record.get("connectorId", "") or ""),
|
|
1216
|
+
str(record.get("status", "active") or "active"),
|
|
1217
|
+
str(record.get("subjectId", "") or ""),
|
|
1218
|
+
json.dumps(record or {}, ensure_ascii=True),
|
|
1219
|
+
int(record.get("createdAt", 0) or 0),
|
|
1220
|
+
int(record.get("updatedAt", 0) or 0),
|
|
1221
|
+
),
|
|
1222
|
+
)
|
|
1223
|
+
|
|
1224
|
+
def fetchConnectionSession(self, sessionId: str) -> dict[str, Any] | None:
|
|
1225
|
+
return self._fetchJsonPayload("picux_bridge_connection_sessions", "session_id", sessionId)
|
|
1226
|
+
|
|
1227
|
+
def listConnectionSessions(self, *, limit: int = 500) -> list[dict[str, Any]]:
|
|
1228
|
+
return self._listJsonPayloads("picux_bridge_connection_sessions", "created_at", limit=limit)
|
|
1229
|
+
|
|
1230
|
+
def upsertChannelThread(self, record: dict[str, Any]) -> None:
|
|
1231
|
+
if not self.enabled:
|
|
1232
|
+
return
|
|
1233
|
+
threadId = str(record.get("threadId", "") or "")
|
|
1234
|
+
if not threadId:
|
|
1235
|
+
return
|
|
1236
|
+
sql = (
|
|
1237
|
+
"INSERT INTO picux_channel_threads "
|
|
1238
|
+
"(thread_id, case_id, conversation_id, channel, provider, provider_thread_id, connector_id, session_id, status, payload, created_at, updated_at) "
|
|
1239
|
+
"VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s::jsonb, TO_TIMESTAMP(%s), TO_TIMESTAMP(%s)) "
|
|
1240
|
+
"ON CONFLICT (thread_id) DO UPDATE SET "
|
|
1241
|
+
"case_id = EXCLUDED.case_id, conversation_id = EXCLUDED.conversation_id, channel = EXCLUDED.channel, "
|
|
1242
|
+
"provider = EXCLUDED.provider, provider_thread_id = EXCLUDED.provider_thread_id, connector_id = EXCLUDED.connector_id, "
|
|
1243
|
+
"session_id = EXCLUDED.session_id, status = EXCLUDED.status, payload = EXCLUDED.payload, updated_at = EXCLUDED.updated_at"
|
|
1244
|
+
)
|
|
1245
|
+
with self.conn() as db:
|
|
1246
|
+
with db.cursor() as cur:
|
|
1247
|
+
cur.execute(
|
|
1248
|
+
sql,
|
|
1249
|
+
(
|
|
1250
|
+
threadId,
|
|
1251
|
+
str(record.get("caseId", "") or ""),
|
|
1252
|
+
str(record.get("conversationId", "") or ""),
|
|
1253
|
+
str(record.get("channel", "api") or "api"),
|
|
1254
|
+
str(record.get("provider", "") or ""),
|
|
1255
|
+
str(record.get("providerThreadId", "") or ""),
|
|
1256
|
+
str(record.get("connectorId", "") or ""),
|
|
1257
|
+
str(record.get("sessionId", "") or ""),
|
|
1258
|
+
str(record.get("status", "active") or "active"),
|
|
1259
|
+
json.dumps(record or {}, ensure_ascii=True),
|
|
1260
|
+
int(record.get("createdAt", 0) or 0),
|
|
1261
|
+
int(record.get("updatedAt", 0) or 0),
|
|
1262
|
+
),
|
|
1263
|
+
)
|
|
1264
|
+
|
|
1265
|
+
def fetchChannelThread(self, threadId: str) -> dict[str, Any] | None:
|
|
1266
|
+
return self._fetchJsonPayload("picux_channel_threads", "thread_id", threadId)
|
|
1267
|
+
|
|
1268
|
+
def findChannelThread(self, refs: dict[str, Any]) -> dict[str, Any] | None:
|
|
1269
|
+
if not self.enabled:
|
|
1270
|
+
return None
|
|
1271
|
+
providerThreadId = str(refs.get("providerThreadId", "") or "")
|
|
1272
|
+
conversationId = str(refs.get("conversationId", "") or "")
|
|
1273
|
+
caseId = str(refs.get("caseId", "") or "")
|
|
1274
|
+
channel = str(refs.get("channel", "") or "")
|
|
1275
|
+
provider = str(refs.get("provider", "") or "")
|
|
1276
|
+
where = []
|
|
1277
|
+
params: list[Any] = []
|
|
1278
|
+
if providerThreadId:
|
|
1279
|
+
where.append("provider_thread_id = %s")
|
|
1280
|
+
params.append(providerThreadId)
|
|
1281
|
+
elif conversationId:
|
|
1282
|
+
where.append("conversation_id = %s")
|
|
1283
|
+
params.append(conversationId)
|
|
1284
|
+
if channel:
|
|
1285
|
+
where.append("channel = %s")
|
|
1286
|
+
params.append(channel)
|
|
1287
|
+
if provider:
|
|
1288
|
+
where.append("provider = %s")
|
|
1289
|
+
params.append(provider)
|
|
1290
|
+
elif caseId and channel:
|
|
1291
|
+
where.extend(["case_id = %s", "channel = %s"])
|
|
1292
|
+
params.extend([caseId, channel])
|
|
1293
|
+
if not where:
|
|
1294
|
+
return None
|
|
1295
|
+
with self.conn() as db:
|
|
1296
|
+
with db.cursor() as cur:
|
|
1297
|
+
cur.execute(
|
|
1298
|
+
f"SELECT payload::text FROM picux_channel_threads WHERE {' AND '.join(where)} ORDER BY updated_at DESC LIMIT 1",
|
|
1299
|
+
tuple(params),
|
|
1300
|
+
)
|
|
1301
|
+
row = cur.fetchone()
|
|
1302
|
+
return json.loads(row[0] or "{}") if row else None
|
|
1303
|
+
|
|
1304
|
+
def listChannelThreads(self, *, limit: int = 500) -> list[dict[str, Any]]:
|
|
1305
|
+
return self._listJsonPayloads("picux_channel_threads", "updated_at", limit=limit)
|
|
1306
|
+
|
|
1307
|
+
def upsertTouchpoint(self, record: dict[str, Any]) -> None:
|
|
1308
|
+
if not self.enabled:
|
|
1309
|
+
return
|
|
1310
|
+
touchpointId = str(record.get("touchpointId", "") or "")
|
|
1311
|
+
if not touchpointId:
|
|
1312
|
+
return
|
|
1313
|
+
sql = (
|
|
1314
|
+
"INSERT INTO picux_touchpoints "
|
|
1315
|
+
"(touchpoint_id, thread_id, case_id, conversation_id, channel, provider, connector_id, direction, status, payload, created_at) "
|
|
1316
|
+
"VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s::jsonb, TO_TIMESTAMP(%s)) "
|
|
1317
|
+
"ON CONFLICT (touchpoint_id) DO UPDATE SET "
|
|
1318
|
+
"thread_id = EXCLUDED.thread_id, case_id = EXCLUDED.case_id, conversation_id = EXCLUDED.conversation_id, "
|
|
1319
|
+
"channel = EXCLUDED.channel, provider = EXCLUDED.provider, connector_id = EXCLUDED.connector_id, "
|
|
1320
|
+
"direction = EXCLUDED.direction, status = EXCLUDED.status, payload = EXCLUDED.payload"
|
|
1321
|
+
)
|
|
1322
|
+
with self.conn() as db:
|
|
1323
|
+
with db.cursor() as cur:
|
|
1324
|
+
cur.execute(
|
|
1325
|
+
sql,
|
|
1326
|
+
(
|
|
1327
|
+
touchpointId,
|
|
1328
|
+
str(record.get("threadId", "") or ""),
|
|
1329
|
+
str(record.get("caseId", "") or ""),
|
|
1330
|
+
str(record.get("conversationId", "") or ""),
|
|
1331
|
+
str(record.get("channel", "api") or "api"),
|
|
1332
|
+
str(record.get("provider", "") or ""),
|
|
1333
|
+
str(record.get("connectorId", "") or ""),
|
|
1334
|
+
str(record.get("direction", "inbound") or "inbound"),
|
|
1335
|
+
str(record.get("status", "received") or "received"),
|
|
1336
|
+
json.dumps(record or {}, ensure_ascii=True),
|
|
1337
|
+
int(record.get("createdAt", 0) or 0),
|
|
1338
|
+
),
|
|
1339
|
+
)
|
|
1340
|
+
|
|
1341
|
+
def fetchTouchpoint(self, touchpointId: str) -> dict[str, Any] | None:
|
|
1342
|
+
return self._fetchJsonPayload("picux_touchpoints", "touchpoint_id", touchpointId)
|
|
1343
|
+
|
|
1344
|
+
def listTouchpoints(self, *, threadId: str = "", caseId: str = "", conversationId: str = "", limit: int = 500) -> list[dict[str, Any]]:
|
|
1345
|
+
if not self.enabled:
|
|
1346
|
+
return []
|
|
1347
|
+
where = []
|
|
1348
|
+
params: list[Any] = []
|
|
1349
|
+
for value, column in ((threadId, "thread_id"), (caseId, "case_id"), (conversationId, "conversation_id")):
|
|
1350
|
+
clean = str(value or "")
|
|
1351
|
+
if clean:
|
|
1352
|
+
where.append(f"{column} = %s")
|
|
1353
|
+
params.append(clean)
|
|
1354
|
+
whereSql = f"WHERE {' AND '.join(where)} " if where else ""
|
|
1355
|
+
with self.conn() as db:
|
|
1356
|
+
with db.cursor() as cur:
|
|
1357
|
+
cur.execute(
|
|
1358
|
+
f"SELECT payload::text FROM picux_touchpoints {whereSql}ORDER BY created_at DESC LIMIT %s",
|
|
1359
|
+
(*params, max(1, min(int(limit or 500), 500))),
|
|
1360
|
+
)
|
|
1361
|
+
rows = cur.fetchall()
|
|
1362
|
+
return [json.loads(row[0] or "{}") for row in rows]
|
|
1363
|
+
|
|
1364
|
+
def upsertProviderAction(self, record: dict[str, Any]) -> None:
|
|
1365
|
+
if not self.enabled:
|
|
1366
|
+
return
|
|
1367
|
+
actionId = str(record.get("actionId", "") or "")
|
|
1368
|
+
if not actionId:
|
|
1369
|
+
return
|
|
1370
|
+
sql = (
|
|
1371
|
+
"INSERT INTO picux_provider_actions "
|
|
1372
|
+
"(action_id, case_id, conversation_id, thread_id, connector_id, session_id, status, action, resource, payload, created_at, updated_at) "
|
|
1373
|
+
"VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s::jsonb, TO_TIMESTAMP(%s), TO_TIMESTAMP(%s)) "
|
|
1374
|
+
"ON CONFLICT (action_id) DO UPDATE SET "
|
|
1375
|
+
"case_id = EXCLUDED.case_id, conversation_id = EXCLUDED.conversation_id, thread_id = EXCLUDED.thread_id, "
|
|
1376
|
+
"connector_id = EXCLUDED.connector_id, session_id = EXCLUDED.session_id, status = EXCLUDED.status, "
|
|
1377
|
+
"action = EXCLUDED.action, resource = EXCLUDED.resource, payload = EXCLUDED.payload, updated_at = EXCLUDED.updated_at"
|
|
1378
|
+
)
|
|
1379
|
+
with self.conn() as db:
|
|
1380
|
+
with db.cursor() as cur:
|
|
1381
|
+
cur.execute(
|
|
1382
|
+
sql,
|
|
1383
|
+
(
|
|
1384
|
+
actionId,
|
|
1385
|
+
str(record.get("caseId", "") or ""),
|
|
1386
|
+
str(record.get("conversationId", "") or ""),
|
|
1387
|
+
str(record.get("threadId", "") or ""),
|
|
1388
|
+
str(record.get("connectorId", "") or ""),
|
|
1389
|
+
str(record.get("sessionId", "") or ""),
|
|
1390
|
+
str(record.get("status", "queued") or "queued"),
|
|
1391
|
+
str(record.get("action", "") or ""),
|
|
1392
|
+
str(record.get("resource", "") or ""),
|
|
1393
|
+
json.dumps(record or {}, ensure_ascii=True),
|
|
1394
|
+
int(record.get("createdAt", 0) or 0),
|
|
1395
|
+
int(record.get("updatedAt", 0) or 0),
|
|
1396
|
+
),
|
|
1397
|
+
)
|
|
1398
|
+
|
|
1399
|
+
def fetchProviderAction(self, actionId: str) -> dict[str, Any] | None:
|
|
1400
|
+
return self._fetchJsonPayload("picux_provider_actions", "action_id", actionId)
|
|
1401
|
+
|
|
1402
|
+
def listProviderActions(self, *, limit: int = 500) -> list[dict[str, Any]]:
|
|
1403
|
+
return self._listJsonPayloads("picux_provider_actions", "updated_at", limit=limit)
|
|
1404
|
+
|
|
1405
|
+
def upsertProviderCallback(self, record: dict[str, Any]) -> None:
|
|
1406
|
+
if not self.enabled:
|
|
1407
|
+
return
|
|
1408
|
+
callbackId = str(record.get("callbackId", "") or "")
|
|
1409
|
+
if not callbackId:
|
|
1410
|
+
return
|
|
1411
|
+
sql = (
|
|
1412
|
+
"INSERT INTO picux_provider_callbacks "
|
|
1413
|
+
"(callback_id, case_id, conversation_id, thread_id, connector_id, session_id, provider, provider_event_id, status, payload, received_at) "
|
|
1414
|
+
"VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s::jsonb, TO_TIMESTAMP(%s)) "
|
|
1415
|
+
"ON CONFLICT (callback_id) DO UPDATE SET "
|
|
1416
|
+
"case_id = EXCLUDED.case_id, conversation_id = EXCLUDED.conversation_id, thread_id = EXCLUDED.thread_id, "
|
|
1417
|
+
"connector_id = EXCLUDED.connector_id, session_id = EXCLUDED.session_id, provider = EXCLUDED.provider, "
|
|
1418
|
+
"provider_event_id = EXCLUDED.provider_event_id, status = EXCLUDED.status, payload = EXCLUDED.payload"
|
|
1419
|
+
)
|
|
1420
|
+
with self.conn() as db:
|
|
1421
|
+
with db.cursor() as cur:
|
|
1422
|
+
cur.execute(
|
|
1423
|
+
sql,
|
|
1424
|
+
(
|
|
1425
|
+
callbackId,
|
|
1426
|
+
str(record.get("caseId", "") or ""),
|
|
1427
|
+
str(record.get("conversationId", "") or ""),
|
|
1428
|
+
str(record.get("threadId", "") or ""),
|
|
1429
|
+
str(record.get("connectorId", "") or ""),
|
|
1430
|
+
str(record.get("sessionId", "") or ""),
|
|
1431
|
+
str(record.get("provider", "") or ""),
|
|
1432
|
+
str(record.get("providerEventId", "") or ""),
|
|
1433
|
+
str(record.get("status", "received") or "received"),
|
|
1434
|
+
json.dumps(record or {}, ensure_ascii=True),
|
|
1435
|
+
int(record.get("receivedAt", 0) or 0),
|
|
1436
|
+
),
|
|
1437
|
+
)
|
|
1438
|
+
|
|
1439
|
+
def listProviderCallbacks(self, *, limit: int = 500) -> list[dict[str, Any]]:
|
|
1440
|
+
return self._listJsonPayloads("picux_provider_callbacks", "received_at", limit=limit)
|
|
1441
|
+
|
|
1442
|
+
def upsertCaseWorkspace(self, record: dict[str, Any]) -> None:
|
|
1443
|
+
if not self.enabled:
|
|
1444
|
+
return
|
|
1445
|
+
caseId = str(record.get("caseId", "") or "")
|
|
1446
|
+
if not caseId:
|
|
1447
|
+
return
|
|
1448
|
+
sql = (
|
|
1449
|
+
"INSERT INTO picux_case_workspaces "
|
|
1450
|
+
"(case_id, status, owner_id, conversation_id, payload, created_at, updated_at) "
|
|
1451
|
+
"VALUES (%s, %s, %s, %s, %s::jsonb, TO_TIMESTAMP(%s), TO_TIMESTAMP(%s)) "
|
|
1452
|
+
"ON CONFLICT (case_id) DO UPDATE SET "
|
|
1453
|
+
"status = EXCLUDED.status, owner_id = EXCLUDED.owner_id, conversation_id = EXCLUDED.conversation_id, "
|
|
1454
|
+
"payload = EXCLUDED.payload, updated_at = EXCLUDED.updated_at"
|
|
1455
|
+
)
|
|
1456
|
+
with self.conn() as db:
|
|
1457
|
+
with db.cursor() as cur:
|
|
1458
|
+
cur.execute(
|
|
1459
|
+
sql,
|
|
1460
|
+
(
|
|
1461
|
+
caseId,
|
|
1462
|
+
str(record.get("status", "open") or "open"),
|
|
1463
|
+
str(record.get("ownerId", "") or ""),
|
|
1464
|
+
str(record.get("conversationId", "") or ""),
|
|
1465
|
+
json.dumps(record or {}, ensure_ascii=True),
|
|
1466
|
+
int(record.get("createdAt", 0) or 0),
|
|
1467
|
+
int(record.get("updatedAt", 0) or 0),
|
|
1468
|
+
),
|
|
1469
|
+
)
|
|
1470
|
+
|
|
1471
|
+
def fetchCaseWorkspace(self, caseId: str) -> dict[str, Any] | None:
|
|
1472
|
+
return self._fetchJsonPayload("picux_case_workspaces", "case_id", caseId)
|
|
1473
|
+
|
|
1474
|
+
def listCaseWorkspaces(self, *, limit: int = 500) -> list[dict[str, Any]]:
|
|
1475
|
+
return self._listJsonPayloads("picux_case_workspaces", "updated_at", limit=limit)
|
|
1476
|
+
|
|
1477
|
+
def upsertCaseEvent(self, record: dict[str, Any]) -> None:
|
|
1478
|
+
if not self.enabled:
|
|
1479
|
+
return
|
|
1480
|
+
eventId = str(record.get("eventId", "") or "")
|
|
1481
|
+
if not eventId:
|
|
1482
|
+
return
|
|
1483
|
+
sql = (
|
|
1484
|
+
"INSERT INTO picux_case_events "
|
|
1485
|
+
"(event_id, case_id, type, actor, status, payload, created_at) "
|
|
1486
|
+
"VALUES (%s, %s, %s, %s, %s, %s::jsonb, TO_TIMESTAMP(%s)) "
|
|
1487
|
+
"ON CONFLICT (event_id) DO UPDATE SET "
|
|
1488
|
+
"case_id = EXCLUDED.case_id, type = EXCLUDED.type, actor = EXCLUDED.actor, "
|
|
1489
|
+
"status = EXCLUDED.status, payload = EXCLUDED.payload"
|
|
1490
|
+
)
|
|
1491
|
+
with self.conn() as db:
|
|
1492
|
+
with db.cursor() as cur:
|
|
1493
|
+
cur.execute(
|
|
1494
|
+
sql,
|
|
1495
|
+
(
|
|
1496
|
+
eventId,
|
|
1497
|
+
str(record.get("caseId", "") or ""),
|
|
1498
|
+
str(record.get("type", "") or ""),
|
|
1499
|
+
str(record.get("actor", "") or ""),
|
|
1500
|
+
str(record.get("status", "") or ""),
|
|
1501
|
+
json.dumps(record or {}, ensure_ascii=True),
|
|
1502
|
+
int(record.get("createdAt", 0) or 0),
|
|
1503
|
+
),
|
|
1504
|
+
)
|
|
1505
|
+
|
|
1506
|
+
def listCaseEvents(self, *, caseId: str = "", limit: int = 500) -> list[dict[str, Any]]:
|
|
1507
|
+
if not self.enabled:
|
|
1508
|
+
return []
|
|
1509
|
+
params: list[Any] = []
|
|
1510
|
+
where = ""
|
|
1511
|
+
if caseId:
|
|
1512
|
+
where = "WHERE case_id = %s "
|
|
1513
|
+
params.append(str(caseId))
|
|
1514
|
+
with self.conn() as db:
|
|
1515
|
+
with db.cursor() as cur:
|
|
1516
|
+
cur.execute(
|
|
1517
|
+
f"SELECT payload::text FROM picux_case_events {where}ORDER BY created_at ASC LIMIT %s",
|
|
1518
|
+
(*params, max(1, min(int(limit or 500), 500))),
|
|
1519
|
+
)
|
|
1520
|
+
rows = cur.fetchall()
|
|
1521
|
+
return [json.loads(row[0] or "{}") for row in rows]
|
|
1522
|
+
|
|
1523
|
+
def upsertPortalSession(self, record: dict[str, Any]) -> None:
|
|
1524
|
+
if not self.enabled:
|
|
1525
|
+
return
|
|
1526
|
+
sessionId = str(record.get("portalSessionId", "") or "")
|
|
1527
|
+
if not sessionId:
|
|
1528
|
+
return
|
|
1529
|
+
sql = (
|
|
1530
|
+
"INSERT INTO picux_portal_sessions "
|
|
1531
|
+
"(portal_session_id, case_id, conversation_id, connector_id, provider, status, payload, created_at, updated_at) "
|
|
1532
|
+
"VALUES (%s, %s, %s, %s, %s, %s, %s::jsonb, TO_TIMESTAMP(%s), TO_TIMESTAMP(%s)) "
|
|
1533
|
+
"ON CONFLICT (portal_session_id) DO UPDATE SET "
|
|
1534
|
+
"case_id = EXCLUDED.case_id, conversation_id = EXCLUDED.conversation_id, connector_id = EXCLUDED.connector_id, "
|
|
1535
|
+
"provider = EXCLUDED.provider, status = EXCLUDED.status, payload = EXCLUDED.payload, updated_at = EXCLUDED.updated_at"
|
|
1536
|
+
)
|
|
1537
|
+
with self.conn() as db:
|
|
1538
|
+
with db.cursor() as cur:
|
|
1539
|
+
cur.execute(
|
|
1540
|
+
sql,
|
|
1541
|
+
(
|
|
1542
|
+
sessionId,
|
|
1543
|
+
str(record.get("caseId", "") or ""),
|
|
1544
|
+
str(record.get("conversationId", "") or ""),
|
|
1545
|
+
str(record.get("connectorId", "") or ""),
|
|
1546
|
+
str(record.get("provider", "") or ""),
|
|
1547
|
+
str(record.get("status", "active") or "active"),
|
|
1548
|
+
json.dumps(record or {}, ensure_ascii=True),
|
|
1549
|
+
int(record.get("createdAt", 0) or 0),
|
|
1550
|
+
int(record.get("updatedAt", 0) or 0),
|
|
1551
|
+
),
|
|
1552
|
+
)
|
|
1553
|
+
|
|
1554
|
+
def fetchPortalSession(self, sessionId: str) -> dict[str, Any] | None:
|
|
1555
|
+
return self._fetchJsonPayload("picux_portal_sessions", "portal_session_id", sessionId)
|
|
1556
|
+
|
|
1557
|
+
def listPortalSessions(self, *, limit: int = 500) -> list[dict[str, Any]]:
|
|
1558
|
+
return self._listJsonPayloads("picux_portal_sessions", "updated_at", limit=limit)
|
|
1559
|
+
|
|
1560
|
+
def upsertPortalAction(self, record: dict[str, Any]) -> None:
|
|
1561
|
+
if not self.enabled:
|
|
1562
|
+
return
|
|
1563
|
+
actionId = str(record.get("portalActionId", "") or "")
|
|
1564
|
+
if not actionId:
|
|
1565
|
+
return
|
|
1566
|
+
sql = (
|
|
1567
|
+
"INSERT INTO picux_portal_actions "
|
|
1568
|
+
"(portal_action_id, portal_session_id, case_id, conversation_id, connector_id, provider, status, kind, payload, created_at, updated_at) "
|
|
1569
|
+
"VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s::jsonb, TO_TIMESTAMP(%s), TO_TIMESTAMP(%s)) "
|
|
1570
|
+
"ON CONFLICT (portal_action_id) DO UPDATE SET "
|
|
1571
|
+
"portal_session_id = EXCLUDED.portal_session_id, case_id = EXCLUDED.case_id, conversation_id = EXCLUDED.conversation_id, "
|
|
1572
|
+
"connector_id = EXCLUDED.connector_id, provider = EXCLUDED.provider, status = EXCLUDED.status, kind = EXCLUDED.kind, "
|
|
1573
|
+
"payload = EXCLUDED.payload, updated_at = EXCLUDED.updated_at"
|
|
1574
|
+
)
|
|
1575
|
+
with self.conn() as db:
|
|
1576
|
+
with db.cursor() as cur:
|
|
1577
|
+
cur.execute(
|
|
1578
|
+
sql,
|
|
1579
|
+
(
|
|
1580
|
+
actionId,
|
|
1581
|
+
str(record.get("portalSessionId", "") or ""),
|
|
1582
|
+
str(record.get("caseId", "") or ""),
|
|
1583
|
+
str(record.get("conversationId", "") or ""),
|
|
1584
|
+
str(record.get("connectorId", "") or ""),
|
|
1585
|
+
str(record.get("provider", "") or ""),
|
|
1586
|
+
str(record.get("status", "queued") or "queued"),
|
|
1587
|
+
str(record.get("kind", "") or ""),
|
|
1588
|
+
json.dumps(record or {}, ensure_ascii=True),
|
|
1589
|
+
int(record.get("createdAt", 0) or 0),
|
|
1590
|
+
int(record.get("updatedAt", 0) or 0),
|
|
1591
|
+
),
|
|
1592
|
+
)
|
|
1593
|
+
|
|
1594
|
+
def fetchPortalAction(self, actionId: str) -> dict[str, Any] | None:
|
|
1595
|
+
return self._fetchJsonPayload("picux_portal_actions", "portal_action_id", actionId)
|
|
1596
|
+
|
|
1597
|
+
def listPortalActions(self, *, limit: int = 500) -> list[dict[str, Any]]:
|
|
1598
|
+
return self._listJsonPayloads("picux_portal_actions", "updated_at", limit=limit)
|
|
1599
|
+
|
|
1600
|
+
def claimEventDelivery(self, filters: dict[str, Any] | None = None) -> dict[str, Any] | None:
|
|
1601
|
+
if not self.enabled:
|
|
1602
|
+
return None
|
|
1603
|
+
filters = filters or {}
|
|
1604
|
+
where = [
|
|
1605
|
+
"((status = %s AND (next_attempt_at IS NULL OR next_attempt_at <= NOW())) "
|
|
1606
|
+
"OR (status = %s AND lease_expires_at IS NOT NULL AND lease_expires_at <= NOW()))"
|
|
1607
|
+
]
|
|
1608
|
+
params: list[Any] = ["queued", "claimed"]
|
|
1609
|
+
for field, column in (
|
|
1610
|
+
("eventId", "event_id"),
|
|
1611
|
+
("subId", "sub_id"),
|
|
1612
|
+
("clientId", "client_id"),
|
|
1613
|
+
("transport", "transport"),
|
|
1614
|
+
):
|
|
1615
|
+
value = str(filters.get(field, "") or "")
|
|
1616
|
+
if value:
|
|
1617
|
+
where.append(f"{column} = %s")
|
|
1618
|
+
params.append(value)
|
|
1619
|
+
whereSql = " AND ".join(where)
|
|
1620
|
+
claimedBy = str(filters.get("claimedBy", filters.get("workerId", "")) or "")
|
|
1621
|
+
now = int(datetime.now(tz=timezone.utc).timestamp())
|
|
1622
|
+
leaseExpiresAt = now + self._leaseSeconds(filters.get("leaseSeconds", 300))
|
|
1623
|
+
explicitLease = int(filters.get("leaseExpiresAt", 0) or 0)
|
|
1624
|
+
if explicitLease:
|
|
1625
|
+
leaseExpiresAt = explicitLease
|
|
1626
|
+
with self.conn() as db:
|
|
1627
|
+
with db.cursor() as cur:
|
|
1628
|
+
cur.execute(
|
|
1629
|
+
f"SELECT payload::text FROM picux_event_deliveries WHERE {whereSql} "
|
|
1630
|
+
"ORDER BY created_at ASC, delivery_id ASC LIMIT 1 FOR UPDATE SKIP LOCKED",
|
|
1631
|
+
tuple(params),
|
|
1632
|
+
)
|
|
1633
|
+
row = cur.fetchone()
|
|
1634
|
+
if not row:
|
|
1635
|
+
return None
|
|
1636
|
+
record = json.loads(row[0] or "{}")
|
|
1637
|
+
record["status"] = "claimed"
|
|
1638
|
+
record["claimedBy"] = claimedBy
|
|
1639
|
+
record["claimedAt"] = now
|
|
1640
|
+
record["leaseExpiresAt"] = leaseExpiresAt
|
|
1641
|
+
record["nextAttemptAt"] = 0
|
|
1642
|
+
record["updatedAt"] = now
|
|
1643
|
+
cur.execute(
|
|
1644
|
+
"UPDATE picux_event_deliveries SET status = %s, payload = %s::jsonb, "
|
|
1645
|
+
"updated_at = TO_TIMESTAMP(%s), claimed_at = TO_TIMESTAMP(%s), "
|
|
1646
|
+
"lease_expires_at = TO_TIMESTAMP(%s), next_attempt_at = NULL WHERE delivery_id = %s",
|
|
1647
|
+
(
|
|
1648
|
+
"claimed",
|
|
1649
|
+
json.dumps(record, ensure_ascii=True),
|
|
1650
|
+
now,
|
|
1651
|
+
now,
|
|
1652
|
+
leaseExpiresAt,
|
|
1653
|
+
str(record.get("deliveryId", "") or ""),
|
|
1654
|
+
),
|
|
1655
|
+
)
|
|
1656
|
+
return record
|
|
1657
|
+
|
|
1658
|
+
def sweepEventDeliveryLeases(self, filters: dict[str, Any] | None = None) -> list[dict[str, Any]]:
|
|
1659
|
+
if not self.enabled:
|
|
1660
|
+
return []
|
|
1661
|
+
filters = filters or {}
|
|
1662
|
+
where = ["status = %s", "lease_expires_at IS NOT NULL", "lease_expires_at <= NOW()"]
|
|
1663
|
+
params: list[Any] = ["claimed"]
|
|
1664
|
+
for field, column in (
|
|
1665
|
+
("eventId", "event_id"),
|
|
1666
|
+
("subId", "sub_id"),
|
|
1667
|
+
("clientId", "client_id"),
|
|
1668
|
+
("transport", "transport"),
|
|
1669
|
+
):
|
|
1670
|
+
value = str(filters.get(field, "") or "")
|
|
1671
|
+
if value:
|
|
1672
|
+
where.append(f"{column} = %s")
|
|
1673
|
+
params.append(value)
|
|
1674
|
+
whereSql = " AND ".join(where)
|
|
1675
|
+
limit = max(1, min(int(filters.get("limit", 100) or 100), 500))
|
|
1676
|
+
lastError = str(filters.get("lastError", "") or "")
|
|
1677
|
+
now = int(datetime.now(tz=timezone.utc).timestamp())
|
|
1678
|
+
swept: list[dict[str, Any]] = []
|
|
1679
|
+
with self.conn() as db:
|
|
1680
|
+
with db.cursor() as cur:
|
|
1681
|
+
cur.execute(
|
|
1682
|
+
f"SELECT payload::text FROM picux_event_deliveries WHERE {whereSql} "
|
|
1683
|
+
"ORDER BY lease_expires_at ASC, delivery_id ASC LIMIT %s FOR UPDATE SKIP LOCKED",
|
|
1684
|
+
tuple(params + [limit]),
|
|
1685
|
+
)
|
|
1686
|
+
rows = cur.fetchall()
|
|
1687
|
+
for row in rows:
|
|
1688
|
+
record = json.loads(row[0] or "{}")
|
|
1689
|
+
record["status"] = "queued"
|
|
1690
|
+
record["claimedBy"] = ""
|
|
1691
|
+
record["claimedAt"] = 0
|
|
1692
|
+
record["leaseExpiresAt"] = 0
|
|
1693
|
+
record["updatedAt"] = now
|
|
1694
|
+
if lastError:
|
|
1695
|
+
record["lastError"] = lastError
|
|
1696
|
+
cur.execute(
|
|
1697
|
+
"UPDATE picux_event_deliveries SET status = %s, payload = %s::jsonb, "
|
|
1698
|
+
"updated_at = TO_TIMESTAMP(%s), claimed_at = NULL, lease_expires_at = NULL "
|
|
1699
|
+
"WHERE delivery_id = %s",
|
|
1700
|
+
(
|
|
1701
|
+
"queued",
|
|
1702
|
+
json.dumps(record, ensure_ascii=True),
|
|
1703
|
+
now,
|
|
1704
|
+
str(record.get("deliveryId", "") or ""),
|
|
1705
|
+
),
|
|
1706
|
+
)
|
|
1707
|
+
swept.append(record)
|
|
1708
|
+
return swept
|
|
1709
|
+
|
|
1710
|
+
def insertEvidence(self, artifact: dict[str, Any]) -> None:
|
|
1711
|
+
if not self.enabled:
|
|
1712
|
+
return
|
|
1713
|
+
artifactId = str(artifact.get("artifactId", "") or "")
|
|
1714
|
+
if not artifactId:
|
|
1715
|
+
return
|
|
1716
|
+
sql = (
|
|
1717
|
+
"INSERT INTO picux_evidence_artifacts (artifact_id, kind, source, payload_hash, payload, created_at) "
|
|
1718
|
+
"VALUES (%s, %s, %s, %s, %s::jsonb, TO_TIMESTAMP(%s)) "
|
|
1719
|
+
"ON CONFLICT (artifact_id) DO UPDATE SET "
|
|
1720
|
+
"kind = EXCLUDED.kind, source = EXCLUDED.source, payload_hash = EXCLUDED.payload_hash, "
|
|
1721
|
+
"payload = EXCLUDED.payload"
|
|
1722
|
+
)
|
|
1723
|
+
with self.conn() as db:
|
|
1724
|
+
with db.cursor() as cur:
|
|
1725
|
+
cur.execute(
|
|
1726
|
+
sql,
|
|
1727
|
+
(
|
|
1728
|
+
artifactId,
|
|
1729
|
+
str(artifact.get("kind", "") or ""),
|
|
1730
|
+
str(artifact.get("source", "") or ""),
|
|
1731
|
+
str(artifact.get("payloadHash", "") or ""),
|
|
1732
|
+
json.dumps(artifact or {}, ensure_ascii=True),
|
|
1733
|
+
self._epoch(artifact.get("createdAt", "")),
|
|
1734
|
+
),
|
|
1735
|
+
)
|
|
1736
|
+
|
|
1737
|
+
def fetchEvidence(self, artifactId: str) -> dict[str, Any] | None:
|
|
1738
|
+
if not self.enabled:
|
|
1739
|
+
return None
|
|
1740
|
+
with self.conn() as db:
|
|
1741
|
+
with db.cursor() as cur:
|
|
1742
|
+
cur.execute("SELECT payload::text FROM picux_evidence_artifacts WHERE artifact_id = %s", (artifactId,))
|
|
1743
|
+
row = cur.fetchone()
|
|
1744
|
+
if not row:
|
|
1745
|
+
return None
|
|
1746
|
+
return json.loads(row[0] or "{}")
|
|
1747
|
+
|
|
1748
|
+
def listEvidence(self, *, limit: int = 500) -> list[dict[str, Any]]:
|
|
1749
|
+
if not self.enabled:
|
|
1750
|
+
return []
|
|
1751
|
+
with self.conn() as db:
|
|
1752
|
+
with db.cursor() as cur:
|
|
1753
|
+
cur.execute(
|
|
1754
|
+
"SELECT payload::text FROM picux_evidence_artifacts ORDER BY created_at DESC LIMIT %s",
|
|
1755
|
+
(max(1, min(int(limit or 500), 500)),),
|
|
1756
|
+
)
|
|
1757
|
+
rows = cur.fetchall()
|
|
1758
|
+
return [json.loads(row[0] or "{}") for row in rows]
|
|
1759
|
+
|
|
1760
|
+
def upsertSignal(self, record: dict[str, Any]) -> None:
|
|
1761
|
+
if not self.enabled:
|
|
1762
|
+
return
|
|
1763
|
+
signalId = str(record.get("signalId", "") or "")
|
|
1764
|
+
if not signalId:
|
|
1765
|
+
return
|
|
1766
|
+
sql = (
|
|
1767
|
+
"INSERT INTO picux_community_signals "
|
|
1768
|
+
"(signal_id, entity, confidence, source_platform, query, payload, created_at, updated_at) "
|
|
1769
|
+
"VALUES (%s, %s, %s, %s, %s, %s::jsonb, TO_TIMESTAMP(%s), TO_TIMESTAMP(%s)) "
|
|
1770
|
+
"ON CONFLICT (signal_id) DO UPDATE SET "
|
|
1771
|
+
"entity = EXCLUDED.entity, confidence = EXCLUDED.confidence, source_platform = EXCLUDED.source_platform, "
|
|
1772
|
+
"query = EXCLUDED.query, payload = EXCLUDED.payload, updated_at = EXCLUDED.updated_at"
|
|
1773
|
+
)
|
|
1774
|
+
with self.conn() as db:
|
|
1775
|
+
with db.cursor() as cur:
|
|
1776
|
+
cur.execute(
|
|
1777
|
+
sql,
|
|
1778
|
+
(
|
|
1779
|
+
signalId,
|
|
1780
|
+
str(record.get("entity", "unknown") or "unknown"),
|
|
1781
|
+
self._confidence(record.get("confidence", 0.0)),
|
|
1782
|
+
str(record.get("sourcePlatform", "") or ""),
|
|
1783
|
+
str(record.get("query", "") or ""),
|
|
1784
|
+
json.dumps(record or {}, ensure_ascii=True),
|
|
1785
|
+
int(record.get("createdAt", 0) or 0),
|
|
1786
|
+
int(record.get("updatedAt", 0) or 0),
|
|
1787
|
+
),
|
|
1788
|
+
)
|
|
1789
|
+
|
|
1790
|
+
def fetchSignal(self, signalId: str) -> dict[str, Any] | None:
|
|
1791
|
+
if not self.enabled:
|
|
1792
|
+
return None
|
|
1793
|
+
with self.conn() as db:
|
|
1794
|
+
with db.cursor() as cur:
|
|
1795
|
+
cur.execute("SELECT payload::text FROM picux_community_signals WHERE signal_id = %s", (signalId,))
|
|
1796
|
+
row = cur.fetchone()
|
|
1797
|
+
if not row:
|
|
1798
|
+
return None
|
|
1799
|
+
return json.loads(row[0] or "{}")
|
|
1800
|
+
|
|
1801
|
+
def listSignals(self, *, limit: int = 500) -> list[dict[str, Any]]:
|
|
1802
|
+
if not self.enabled:
|
|
1803
|
+
return []
|
|
1804
|
+
with self.conn() as db:
|
|
1805
|
+
with db.cursor() as cur:
|
|
1806
|
+
cur.execute(
|
|
1807
|
+
"SELECT payload::text FROM picux_community_signals ORDER BY created_at DESC LIMIT %s",
|
|
1808
|
+
(max(1, min(int(limit or 500), 500)),),
|
|
1809
|
+
)
|
|
1810
|
+
rows = cur.fetchall()
|
|
1811
|
+
return [json.loads(row[0] or "{}") for row in rows]
|
|
1812
|
+
|
|
1813
|
+
def insertReceipt(self, receipt: dict[str, Any]) -> None:
|
|
1814
|
+
if not self.enabled:
|
|
1815
|
+
return
|
|
1816
|
+
receiptId = str(receipt.get("receiptId", "") or "")
|
|
1817
|
+
if not receiptId:
|
|
1818
|
+
return
|
|
1819
|
+
sql = (
|
|
1820
|
+
"INSERT INTO picux_receipts (receipt_id, task_id, mandate_id, status, payload) "
|
|
1821
|
+
"VALUES (%s, %s, %s, %s, %s::jsonb) "
|
|
1822
|
+
"ON CONFLICT (receipt_id) DO UPDATE SET "
|
|
1823
|
+
"task_id = EXCLUDED.task_id, mandate_id = EXCLUDED.mandate_id, "
|
|
1824
|
+
"status = EXCLUDED.status, payload = EXCLUDED.payload"
|
|
1825
|
+
)
|
|
1826
|
+
with self.conn() as db:
|
|
1827
|
+
with db.cursor() as cur:
|
|
1828
|
+
cur.execute(
|
|
1829
|
+
sql,
|
|
1830
|
+
(
|
|
1831
|
+
receiptId,
|
|
1832
|
+
str(receipt.get("taskId", "") or ""),
|
|
1833
|
+
str(receipt.get("mandateId", "") or ""),
|
|
1834
|
+
str(receipt.get("status", "") or ""),
|
|
1835
|
+
json.dumps(receipt or {}, ensure_ascii=True),
|
|
1836
|
+
),
|
|
1837
|
+
)
|
|
1838
|
+
|
|
1839
|
+
def fetchReceipt(self, receiptId: str) -> dict[str, Any] | None:
|
|
1840
|
+
if not self.enabled:
|
|
1841
|
+
return None
|
|
1842
|
+
with self.conn() as db:
|
|
1843
|
+
with db.cursor() as cur:
|
|
1844
|
+
cur.execute("SELECT payload::text FROM picux_receipts WHERE receipt_id = %s", (receiptId,))
|
|
1845
|
+
row = cur.fetchone()
|
|
1846
|
+
if not row:
|
|
1847
|
+
return None
|
|
1848
|
+
return json.loads(row[0] or "{}")
|
|
1849
|
+
|
|
1850
|
+
def listReceipts(self, *, limit: int = 500) -> list[dict[str, Any]]:
|
|
1851
|
+
if not self.enabled:
|
|
1852
|
+
return []
|
|
1853
|
+
with self.conn() as db:
|
|
1854
|
+
with db.cursor() as cur:
|
|
1855
|
+
cur.execute(
|
|
1856
|
+
"SELECT payload::text FROM picux_receipts ORDER BY created_at DESC LIMIT %s",
|
|
1857
|
+
(max(1, min(int(limit or 500), 500)),),
|
|
1858
|
+
)
|
|
1859
|
+
rows = cur.fetchall()
|
|
1860
|
+
return [json.loads(row[0] or "{}") for row in rows]
|
|
1861
|
+
|
|
1862
|
+
def insertPov(self, pov: dict[str, Any]) -> None:
|
|
1863
|
+
if not self.enabled:
|
|
1864
|
+
return
|
|
1865
|
+
povId = str(pov.get("povId", "") or "")
|
|
1866
|
+
if not povId:
|
|
1867
|
+
return
|
|
1868
|
+
value = pov.get("value", {}) if isinstance(pov.get("value"), dict) else {}
|
|
1869
|
+
meta = pov.get("meta", {}) if isinstance(pov.get("meta"), dict) else {}
|
|
1870
|
+
sql = (
|
|
1871
|
+
"INSERT INTO picux_pov_events "
|
|
1872
|
+
"(event_id, user_id, task_id, domain, value_amount, currency, summary, payload) "
|
|
1873
|
+
"VALUES (%s, %s, %s, %s, %s, %s, %s, %s::jsonb) "
|
|
1874
|
+
"ON CONFLICT (event_id) DO UPDATE SET "
|
|
1875
|
+
"task_id = EXCLUDED.task_id, domain = EXCLUDED.domain, value_amount = EXCLUDED.value_amount, "
|
|
1876
|
+
"currency = EXCLUDED.currency, summary = EXCLUDED.summary, payload = EXCLUDED.payload"
|
|
1877
|
+
)
|
|
1878
|
+
with self.conn() as db:
|
|
1879
|
+
with db.cursor() as cur:
|
|
1880
|
+
cur.execute(
|
|
1881
|
+
sql,
|
|
1882
|
+
(
|
|
1883
|
+
povId,
|
|
1884
|
+
str(pov.get("userId", meta.get("userId", "")) or ""),
|
|
1885
|
+
str(pov.get("taskId", "") or ""),
|
|
1886
|
+
str(pov.get("domain", "") or ""),
|
|
1887
|
+
self._amount(value.get("amount", 0.0)),
|
|
1888
|
+
str(value.get("currency", "USD") or "USD"),
|
|
1889
|
+
str(meta.get("summary", "") or ""),
|
|
1890
|
+
json.dumps(pov or {}, ensure_ascii=True),
|
|
1891
|
+
),
|
|
1892
|
+
)
|
|
1893
|
+
|
|
1894
|
+
def fetchPov(self, povId: str) -> dict[str, Any] | None:
|
|
1895
|
+
if not self.enabled:
|
|
1896
|
+
return None
|
|
1897
|
+
with self.conn() as db:
|
|
1898
|
+
with db.cursor() as cur:
|
|
1899
|
+
cur.execute("SELECT payload::text FROM picux_pov_events WHERE event_id = %s", (povId,))
|
|
1900
|
+
row = cur.fetchone()
|
|
1901
|
+
if not row:
|
|
1902
|
+
return None
|
|
1903
|
+
return json.loads(row[0] or "{}")
|
|
1904
|
+
|
|
1905
|
+
def listPov(self, *, limit: int = 500) -> list[dict[str, Any]]:
|
|
1906
|
+
if not self.enabled:
|
|
1907
|
+
return []
|
|
1908
|
+
with self.conn() as db:
|
|
1909
|
+
with db.cursor() as cur:
|
|
1910
|
+
cur.execute(
|
|
1911
|
+
"SELECT payload::text FROM picux_pov_events ORDER BY created_at DESC LIMIT %s",
|
|
1912
|
+
(max(1, min(int(limit or 500), 500)),),
|
|
1913
|
+
)
|
|
1914
|
+
rows = cur.fetchall()
|
|
1915
|
+
return [json.loads(row[0] or "{}") for row in rows]
|
|
1916
|
+
|
|
1917
|
+
def _fetchJsonPayload(self, table: str, column: str, value: str) -> dict[str, Any] | None:
|
|
1918
|
+
if not self.enabled or not value:
|
|
1919
|
+
return None
|
|
1920
|
+
allowedTables = {
|
|
1921
|
+
"picux_bridge_connectors",
|
|
1922
|
+
"picux_bridge_connection_sessions",
|
|
1923
|
+
"picux_channel_threads",
|
|
1924
|
+
"picux_touchpoints",
|
|
1925
|
+
"picux_provider_actions",
|
|
1926
|
+
"picux_case_workspaces",
|
|
1927
|
+
"picux_portal_sessions",
|
|
1928
|
+
"picux_portal_actions",
|
|
1929
|
+
}
|
|
1930
|
+
allowedColumns = {"connector_id", "session_id", "thread_id", "touchpoint_id", "action_id", "case_id", "portal_session_id", "portal_action_id"}
|
|
1931
|
+
if table not in allowedTables or column not in allowedColumns:
|
|
1932
|
+
return None
|
|
1933
|
+
with self.conn() as db:
|
|
1934
|
+
with db.cursor() as cur:
|
|
1935
|
+
cur.execute(f"SELECT payload::text FROM {table} WHERE {column} = %s", (str(value),))
|
|
1936
|
+
row = cur.fetchone()
|
|
1937
|
+
if not row:
|
|
1938
|
+
return None
|
|
1939
|
+
return json.loads(row[0] or "{}")
|
|
1940
|
+
|
|
1941
|
+
def _listJsonPayloads(self, table: str, orderColumn: str, *, limit: int = 500) -> list[dict[str, Any]]:
|
|
1942
|
+
if not self.enabled:
|
|
1943
|
+
return []
|
|
1944
|
+
allowedTables = {
|
|
1945
|
+
"picux_bridge_connectors",
|
|
1946
|
+
"picux_bridge_connection_sessions",
|
|
1947
|
+
"picux_channel_threads",
|
|
1948
|
+
"picux_provider_actions",
|
|
1949
|
+
"picux_provider_callbacks",
|
|
1950
|
+
"picux_case_workspaces",
|
|
1951
|
+
"picux_portal_sessions",
|
|
1952
|
+
"picux_portal_actions",
|
|
1953
|
+
}
|
|
1954
|
+
allowedOrder = {"created_at", "updated_at", "received_at"}
|
|
1955
|
+
if table not in allowedTables or orderColumn not in allowedOrder:
|
|
1956
|
+
return []
|
|
1957
|
+
with self.conn() as db:
|
|
1958
|
+
with db.cursor() as cur:
|
|
1959
|
+
cur.execute(
|
|
1960
|
+
f"SELECT payload::text FROM {table} ORDER BY {orderColumn} DESC LIMIT %s",
|
|
1961
|
+
(max(1, min(int(limit or 500), 500)),),
|
|
1962
|
+
)
|
|
1963
|
+
rows = cur.fetchall()
|
|
1964
|
+
return [json.loads(row[0] or "{}") for row in rows]
|
|
1965
|
+
|
|
1966
|
+
@staticmethod
|
|
1967
|
+
def _amount(value: Any) -> float:
|
|
1968
|
+
try:
|
|
1969
|
+
return float(value or 0.0)
|
|
1970
|
+
except (TypeError, ValueError):
|
|
1971
|
+
return 0.0
|
|
1972
|
+
|
|
1973
|
+
@staticmethod
|
|
1974
|
+
def _confidence(value: Any) -> float:
|
|
1975
|
+
try:
|
|
1976
|
+
parsed = float(value or 0.0)
|
|
1977
|
+
except (TypeError, ValueError):
|
|
1978
|
+
return 0.0
|
|
1979
|
+
return max(0.0, min(parsed, 1.0))
|
|
1980
|
+
|
|
1981
|
+
@staticmethod
|
|
1982
|
+
def _epoch(value: Any) -> int:
|
|
1983
|
+
if isinstance(value, (int, float)):
|
|
1984
|
+
return int(value or 0)
|
|
1985
|
+
raw = str(value or "").strip()
|
|
1986
|
+
if raw.endswith("Z"):
|
|
1987
|
+
raw = raw[:-1] + "+00:00"
|
|
1988
|
+
try:
|
|
1989
|
+
parsed = datetime.fromisoformat(raw)
|
|
1990
|
+
except Exception:
|
|
1991
|
+
return 0
|
|
1992
|
+
if parsed.tzinfo is None:
|
|
1993
|
+
parsed = parsed.replace(tzinfo=timezone.utc)
|
|
1994
|
+
return int(parsed.timestamp())
|
|
1995
|
+
|
|
1996
|
+
@staticmethod
|
|
1997
|
+
def _taskFromRow(row: tuple[Any, ...]) -> ProtocolTask:
|
|
1998
|
+
domain = str(row[2])
|
|
1999
|
+
status = str(row[3])
|
|
2000
|
+
if len(row) >= 15:
|
|
2001
|
+
needsApproval = bool(row[8])
|
|
2002
|
+
extRef = str(row[9] or "")
|
|
2003
|
+
errorMsg = str(row[10] or "")
|
|
2004
|
+
meta = json.loads(row[11] or "{}")
|
|
2005
|
+
createdAt = int(row[12] or 0)
|
|
2006
|
+
updatedAt = int(row[13] or 0)
|
|
2007
|
+
approvalBy = int(row[14]) if row[14] else None
|
|
2008
|
+
else:
|
|
2009
|
+
needsApproval = False
|
|
2010
|
+
extRef = ""
|
|
2011
|
+
errorMsg = ""
|
|
2012
|
+
meta = json.loads(row[8] if isinstance(row[8], str) and row[8] else "{}")
|
|
2013
|
+
createdAt = PicuxPostgresStore._int(row[9])
|
|
2014
|
+
updatedAt = PicuxPostgresStore._int(row[10])
|
|
2015
|
+
approvalBy = None
|
|
2016
|
+
return ProtocolTask(
|
|
2017
|
+
taskId=str(row[0]),
|
|
2018
|
+
userId=str(row[1]),
|
|
2019
|
+
domain=Domain(domain) if domain in Domain._value2member_map_ else Domain.UNKNOWN,
|
|
2020
|
+
status=ProtocolTaskStatus(status)
|
|
2021
|
+
if status in ProtocolTaskStatus._value2member_map_
|
|
2022
|
+
else ProtocolTaskStatus.PENDING,
|
|
2023
|
+
channel=str(row[4]),
|
|
2024
|
+
mandateId=str(row[5]),
|
|
2025
|
+
inData=json.loads(row[6] or "{}"),
|
|
2026
|
+
outData=json.loads(row[7] or "{}"),
|
|
2027
|
+
needsApproval=needsApproval,
|
|
2028
|
+
extRef=extRef,
|
|
2029
|
+
errorMsg=errorMsg,
|
|
2030
|
+
meta=meta,
|
|
2031
|
+
createdAt=createdAt,
|
|
2032
|
+
updatedAt=updatedAt,
|
|
2033
|
+
approvalBy=approvalBy,
|
|
2034
|
+
)
|
|
2035
|
+
|
|
2036
|
+
@staticmethod
|
|
2037
|
+
def _int(value: Any) -> int:
|
|
2038
|
+
try:
|
|
2039
|
+
return int(value or 0)
|
|
2040
|
+
except (TypeError, ValueError):
|
|
2041
|
+
return 0
|
|
2042
|
+
|
|
2043
|
+
@staticmethod
|
|
2044
|
+
def _leaseSeconds(value: Any) -> int:
|
|
2045
|
+
try:
|
|
2046
|
+
parsed = int(value)
|
|
2047
|
+
except (TypeError, ValueError):
|
|
2048
|
+
return 300
|
|
2049
|
+
return max(1, min(parsed, 86400))
|