hebbrix 2.3.1__tar.gz → 2.3.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {hebbrix-2.3.1 → hebbrix-2.3.2}/CHANGELOG.md +10 -0
- {hebbrix-2.3.1/hebbrix.egg-info → hebbrix-2.3.2}/PKG-INFO +35 -2
- {hebbrix-2.3.1 → hebbrix-2.3.2}/README.md +12 -0
- {hebbrix-2.3.1 → hebbrix-2.3.2}/hebbrix/__init__.py +1 -1
- {hebbrix-2.3.1 → hebbrix-2.3.2}/hebbrix/client.py +1 -1
- {hebbrix-2.3.1 → hebbrix-2.3.2}/hebbrix/resources.py +132 -15
- {hebbrix-2.3.1 → hebbrix-2.3.2}/hebbrix/sync_client.py +196 -1
- {hebbrix-2.3.1 → hebbrix-2.3.2/hebbrix.egg-info}/PKG-INFO +35 -2
- {hebbrix-2.3.1 → hebbrix-2.3.2}/hebbrix.egg-info/SOURCES.txt +1 -0
- {hebbrix-2.3.1 → hebbrix-2.3.2}/pyproject.toml +3 -4
- {hebbrix-2.3.1 → hebbrix-2.3.2}/tests/test_memories_resource.py +69 -0
- {hebbrix-2.3.1 → hebbrix-2.3.2}/tests/test_openapi_parity.py +29 -41
- hebbrix-2.3.2/tests/test_procedural_resource.py +121 -0
- {hebbrix-2.3.1 → hebbrix-2.3.2}/LICENSE +0 -0
- {hebbrix-2.3.1 → hebbrix-2.3.2}/MANIFEST.in +0 -0
- {hebbrix-2.3.1 → hebbrix-2.3.2}/hebbrix/chat.py +0 -0
- {hebbrix-2.3.1 → hebbrix-2.3.2}/hebbrix/exceptions.py +0 -0
- {hebbrix-2.3.1 → hebbrix-2.3.2}/hebbrix/models.py +0 -0
- {hebbrix-2.3.1 → hebbrix-2.3.2}/hebbrix.egg-info/dependency_links.txt +0 -0
- {hebbrix-2.3.1 → hebbrix-2.3.2}/hebbrix.egg-info/requires.txt +0 -0
- {hebbrix-2.3.1 → hebbrix-2.3.2}/hebbrix.egg-info/top_level.txt +0 -0
- {hebbrix-2.3.1 → hebbrix-2.3.2}/setup.cfg +0 -0
- {hebbrix-2.3.1 → hebbrix-2.3.2}/tests/test_sync_client.py +0 -0
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 2.3.2 — 2026-08-27
|
|
4
|
+
|
|
5
|
+
- Align procedure create/list/get/update/execute/delete with the canonical
|
|
6
|
+
`/v1/procedures` REST and OpenAPI contract, including idempotent `204`
|
|
7
|
+
deletion.
|
|
8
|
+
- Add explicit synchronous/asynchronous batch readiness receipts to the async
|
|
9
|
+
and synchronous clients.
|
|
10
|
+
- Add batch wait helpers that poll every accepted memory, propagate terminal
|
|
11
|
+
failures, respect deadlines, and support asynchronous cancellation.
|
|
12
|
+
|
|
3
13
|
## 2.3.1 — 2026-08-26
|
|
4
14
|
|
|
5
15
|
- Make `wait_for_index=True` poll the authoritative memory or job readiness
|
|
@@ -1,10 +1,31 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: hebbrix
|
|
3
|
-
Version: 2.3.
|
|
3
|
+
Version: 2.3.2
|
|
4
4
|
Summary: Advanced Memory API for AI Agents with Reinforcement Learning - 3-line integration
|
|
5
5
|
Author-email: Hebbrix Team <support@hebbrix.com>
|
|
6
6
|
Maintainer-email: Hebbrix Team <support@hebbrix.com>
|
|
7
|
-
License
|
|
7
|
+
License: MIT License
|
|
8
|
+
|
|
9
|
+
Copyright (c) 2025 Hebbrix
|
|
10
|
+
|
|
11
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
12
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
13
|
+
in the Software without restriction, including without limitation the rights
|
|
14
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
15
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
16
|
+
furnished to do so, subject to the following conditions:
|
|
17
|
+
|
|
18
|
+
The above copyright notice and this permission notice shall be included in all
|
|
19
|
+
copies or substantial portions of the Software.
|
|
20
|
+
|
|
21
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
22
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
23
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
24
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
25
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
26
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
27
|
+
SOFTWARE.
|
|
28
|
+
|
|
8
29
|
Project-URL: Homepage, https://hebbrix.com
|
|
9
30
|
Project-URL: Documentation, https://docs.hebbrix.com
|
|
10
31
|
Project-URL: Repository, https://github.com/hebbrix/hebbrix-python
|
|
@@ -96,6 +117,18 @@ async def main():
|
|
|
96
117
|
# It returns only when searchable=true, raises on terminal failure, and
|
|
97
118
|
# raises TimeoutError if the caller's readiness deadline expires.
|
|
98
119
|
|
|
120
|
+
# Batch contract: a successful synchronous return means every accepted item
|
|
121
|
+
# is searchable. A bounded server timeout raises explicitly and is safe to
|
|
122
|
+
# retry with the same idempotency key; it is never a successful 202.
|
|
123
|
+
batch = await client.memories.create_batch(
|
|
124
|
+
[{"content": "First fact"}, {"content": "Second fact"}],
|
|
125
|
+
collection_id=collection["id"],
|
|
126
|
+
wait_for_index=True,
|
|
127
|
+
idempotency_key="import-42",
|
|
128
|
+
)
|
|
129
|
+
# For wait_for_index=False receipts:
|
|
130
|
+
# batch = await client.memories.wait_batch_until_searchable(batch)
|
|
131
|
+
|
|
99
132
|
# Search memories
|
|
100
133
|
results = await client.search(
|
|
101
134
|
query="What programming language does user like?",
|
|
@@ -55,6 +55,18 @@ async def main():
|
|
|
55
55
|
# It returns only when searchable=true, raises on terminal failure, and
|
|
56
56
|
# raises TimeoutError if the caller's readiness deadline expires.
|
|
57
57
|
|
|
58
|
+
# Batch contract: a successful synchronous return means every accepted item
|
|
59
|
+
# is searchable. A bounded server timeout raises explicitly and is safe to
|
|
60
|
+
# retry with the same idempotency key; it is never a successful 202.
|
|
61
|
+
batch = await client.memories.create_batch(
|
|
62
|
+
[{"content": "First fact"}, {"content": "Second fact"}],
|
|
63
|
+
collection_id=collection["id"],
|
|
64
|
+
wait_for_index=True,
|
|
65
|
+
idempotency_key="import-42",
|
|
66
|
+
)
|
|
67
|
+
# For wait_for_index=False receipts:
|
|
68
|
+
# batch = await client.memories.wait_batch_until_searchable(batch)
|
|
69
|
+
|
|
58
70
|
# Search memories
|
|
59
71
|
results = await client.search(
|
|
60
72
|
query="What programming language does user like?",
|
|
@@ -435,6 +435,106 @@ class MemoriesResource(BaseResource):
|
|
|
435
435
|
)
|
|
436
436
|
return receipt
|
|
437
437
|
|
|
438
|
+
async def create_batch(
|
|
439
|
+
self,
|
|
440
|
+
memories: List[Dict[str, Any]],
|
|
441
|
+
*,
|
|
442
|
+
collection_id: Optional[str] = None,
|
|
443
|
+
user_id: Optional[str] = None,
|
|
444
|
+
agent_id: Optional[str] = None,
|
|
445
|
+
run_id: Optional[str] = None,
|
|
446
|
+
app_id: Optional[str] = None,
|
|
447
|
+
namespace: Optional[str] = None,
|
|
448
|
+
wait_for_index: bool = False,
|
|
449
|
+
idempotency_key: Optional[str] = None,
|
|
450
|
+
) -> Dict[str, Any]:
|
|
451
|
+
"""Create a durable batch with explicit synchronous/async readiness.
|
|
452
|
+
|
|
453
|
+
When ``wait_for_index`` is true, a successful return is guaranteed to
|
|
454
|
+
have ``processing_status=completed`` and ``searchable=true``. A bounded
|
|
455
|
+
server timeout raises an HTTP error and may be retried with the same
|
|
456
|
+
idempotency key; it is never returned as a successful processing state.
|
|
457
|
+
"""
|
|
458
|
+
|
|
459
|
+
if not 1 <= len(memories) <= 100:
|
|
460
|
+
raise ValueError("memories must contain between 1 and 100 items")
|
|
461
|
+
if any(not str(item.get("content") or "").strip() for item in memories):
|
|
462
|
+
raise ValueError("every batch memory must contain non-empty content")
|
|
463
|
+
payload = {
|
|
464
|
+
"memories": memories,
|
|
465
|
+
"wait_for_index": wait_for_index,
|
|
466
|
+
}
|
|
467
|
+
optional = {
|
|
468
|
+
"collection_id": collection_id,
|
|
469
|
+
"user_id": user_id,
|
|
470
|
+
"agent_id": agent_id,
|
|
471
|
+
"run_id": run_id,
|
|
472
|
+
"app_id": app_id,
|
|
473
|
+
"namespace": namespace,
|
|
474
|
+
}
|
|
475
|
+
payload.update(
|
|
476
|
+
{key: value for key, value in optional.items() if value is not None}
|
|
477
|
+
)
|
|
478
|
+
kwargs: Dict[str, Any] = {"json": payload}
|
|
479
|
+
if idempotency_key:
|
|
480
|
+
kwargs["headers"] = {"Idempotency-Key": idempotency_key}
|
|
481
|
+
receipt = await self.client.post("/v1/memories/batch", **kwargs)
|
|
482
|
+
if wait_for_index and not (
|
|
483
|
+
receipt.get("searchable") is True
|
|
484
|
+
and str(receipt.get("processing_status") or "").casefold() == "completed"
|
|
485
|
+
):
|
|
486
|
+
raise RuntimeError(
|
|
487
|
+
"wait_for_index batch response was not fully searchable; retry "
|
|
488
|
+
"with the same Idempotency-Key"
|
|
489
|
+
)
|
|
490
|
+
return receipt
|
|
491
|
+
|
|
492
|
+
async def wait_batch_until_searchable(
|
|
493
|
+
self,
|
|
494
|
+
receipt: Dict[str, Any],
|
|
495
|
+
*,
|
|
496
|
+
timeout: float = 60.0,
|
|
497
|
+
poll_interval: float = 0.5,
|
|
498
|
+
) -> Dict[str, Any]:
|
|
499
|
+
"""Poll every item in an asynchronous batch receipt to one terminal state.
|
|
500
|
+
|
|
501
|
+
Cancellation uses normal asyncio task cancellation. A failed/cancelled
|
|
502
|
+
item or a local deadline raises explicitly; a successful return means
|
|
503
|
+
every batch item reported ``searchable=true``.
|
|
504
|
+
"""
|
|
505
|
+
|
|
506
|
+
memory_ids = list(dict.fromkeys(map(str, receipt.get("memory_ids") or [])))
|
|
507
|
+
if not memory_ids:
|
|
508
|
+
raise ValueError("batch receipt does not contain memory_ids")
|
|
509
|
+
deadline = time.monotonic() + max(0.0, timeout)
|
|
510
|
+
while True:
|
|
511
|
+
rows = await asyncio.gather(
|
|
512
|
+
*(self.get(memory_id) for memory_id in memory_ids)
|
|
513
|
+
)
|
|
514
|
+
for row in rows:
|
|
515
|
+
state = str(row.get("processing_status") or "").casefold()
|
|
516
|
+
if state in {"failed", "cancelled", "canceled"}:
|
|
517
|
+
raise RuntimeError(
|
|
518
|
+
f"memory {row.get('id')} indexing reached terminal state {state}"
|
|
519
|
+
)
|
|
520
|
+
if all(row.get("searchable") is True for row in rows):
|
|
521
|
+
return {
|
|
522
|
+
**receipt,
|
|
523
|
+
"processing_status": "completed",
|
|
524
|
+
"searchable": True,
|
|
525
|
+
"results": [
|
|
526
|
+
{
|
|
527
|
+
"id": memory_id,
|
|
528
|
+
"memory_id": memory_id,
|
|
529
|
+
"processing_status": "completed",
|
|
530
|
+
}
|
|
531
|
+
for memory_id in memory_ids
|
|
532
|
+
],
|
|
533
|
+
}
|
|
534
|
+
if time.monotonic() >= deadline:
|
|
535
|
+
raise TimeoutError(f"batch was not searchable within {timeout}s")
|
|
536
|
+
await asyncio.sleep(max(0.05, poll_interval))
|
|
537
|
+
|
|
438
538
|
async def wait_until_searchable(
|
|
439
539
|
self,
|
|
440
540
|
memory_id: str,
|
|
@@ -1187,6 +1287,15 @@ class RLResource(BaseResource):
|
|
|
1187
1287
|
class ProceduralResource(BaseResource):
|
|
1188
1288
|
"""Procedural memory endpoints."""
|
|
1189
1289
|
|
|
1290
|
+
@staticmethod
|
|
1291
|
+
def _unwrap_procedure(response: Dict[str, Any]) -> Dict[str, Any]:
|
|
1292
|
+
procedure = response.get("procedure")
|
|
1293
|
+
if isinstance(procedure, dict):
|
|
1294
|
+
return procedure
|
|
1295
|
+
if response.get("procedure_id") and not response.get("id"):
|
|
1296
|
+
return {**response, "id": response["procedure_id"]}
|
|
1297
|
+
return response
|
|
1298
|
+
|
|
1190
1299
|
async def create(
|
|
1191
1300
|
self,
|
|
1192
1301
|
name: str,
|
|
@@ -1212,18 +1321,19 @@ class ProceduralResource(BaseResource):
|
|
|
1212
1321
|
Returns:
|
|
1213
1322
|
Created procedure
|
|
1214
1323
|
"""
|
|
1215
|
-
|
|
1216
|
-
"/
|
|
1324
|
+
response = await self.client.post(
|
|
1325
|
+
"/v1/procedures",
|
|
1217
1326
|
json={
|
|
1218
1327
|
"name": name,
|
|
1219
1328
|
"description": description,
|
|
1220
|
-
"
|
|
1221
|
-
"
|
|
1329
|
+
"condition": {"expression": trigger_condition},
|
|
1330
|
+
"action": {"steps": action_sequence},
|
|
1222
1331
|
"collection_id": collection_id,
|
|
1223
1332
|
"category": category,
|
|
1224
|
-
"
|
|
1333
|
+
"parameters": metadata or {},
|
|
1225
1334
|
},
|
|
1226
1335
|
)
|
|
1336
|
+
return self._unwrap_procedure(response)
|
|
1227
1337
|
|
|
1228
1338
|
async def list(
|
|
1229
1339
|
self,
|
|
@@ -1250,7 +1360,10 @@ class ProceduralResource(BaseResource):
|
|
|
1250
1360
|
if category:
|
|
1251
1361
|
params["category"] = category
|
|
1252
1362
|
|
|
1253
|
-
|
|
1363
|
+
response = await self.client.get("/v1/procedures", params=params)
|
|
1364
|
+
return (
|
|
1365
|
+
response if isinstance(response, list) else response.get("procedures", [])
|
|
1366
|
+
)
|
|
1254
1367
|
|
|
1255
1368
|
async def get(self, procedure_id: str) -> Dict[str, Any]:
|
|
1256
1369
|
"""
|
|
@@ -1262,7 +1375,8 @@ class ProceduralResource(BaseResource):
|
|
|
1262
1375
|
Returns:
|
|
1263
1376
|
Procedure data
|
|
1264
1377
|
"""
|
|
1265
|
-
|
|
1378
|
+
response = await self.client.get(f"/v1/procedures/{procedure_id}")
|
|
1379
|
+
return self._unwrap_procedure(response)
|
|
1266
1380
|
|
|
1267
1381
|
async def execute(
|
|
1268
1382
|
self,
|
|
@@ -1279,10 +1393,12 @@ class ProceduralResource(BaseResource):
|
|
|
1279
1393
|
Returns:
|
|
1280
1394
|
Execution result
|
|
1281
1395
|
"""
|
|
1282
|
-
|
|
1283
|
-
f"/
|
|
1284
|
-
json={"
|
|
1396
|
+
response = await self.client.post(
|
|
1397
|
+
f"/v1/procedures/{procedure_id}/execute",
|
|
1398
|
+
json={"input_state": context or {}},
|
|
1285
1399
|
)
|
|
1400
|
+
result = response.get("execution_result")
|
|
1401
|
+
return result if isinstance(result, dict) else response
|
|
1286
1402
|
|
|
1287
1403
|
async def update(
|
|
1288
1404
|
self,
|
|
@@ -1313,13 +1429,14 @@ class ProceduralResource(BaseResource):
|
|
|
1313
1429
|
if description is not None:
|
|
1314
1430
|
data["description"] = description
|
|
1315
1431
|
if trigger_condition is not None:
|
|
1316
|
-
data["
|
|
1432
|
+
data["condition"] = {"expression": trigger_condition}
|
|
1317
1433
|
if action_sequence is not None:
|
|
1318
|
-
data["
|
|
1434
|
+
data["action"] = {"steps": action_sequence}
|
|
1319
1435
|
if metadata is not None:
|
|
1320
|
-
data["
|
|
1436
|
+
data["parameters"] = metadata
|
|
1321
1437
|
|
|
1322
|
-
|
|
1438
|
+
response = await self.client.patch(f"/v1/procedures/{procedure_id}", json=data)
|
|
1439
|
+
return self._unwrap_procedure(response)
|
|
1323
1440
|
|
|
1324
1441
|
async def delete(self, procedure_id: str) -> None:
|
|
1325
1442
|
"""
|
|
@@ -1328,7 +1445,7 @@ class ProceduralResource(BaseResource):
|
|
|
1328
1445
|
Args:
|
|
1329
1446
|
procedure_id: Procedure ID
|
|
1330
1447
|
"""
|
|
1331
|
-
await self.client.delete(f"/
|
|
1448
|
+
await self.client.delete(f"/v1/procedures/{procedure_id}")
|
|
1332
1449
|
|
|
1333
1450
|
|
|
1334
1451
|
class TemporalResource(BaseResource):
|
|
@@ -101,6 +101,91 @@ class SyncMemoriesResource:
|
|
|
101
101
|
)
|
|
102
102
|
return receipt
|
|
103
103
|
|
|
104
|
+
def create_batch(
|
|
105
|
+
self,
|
|
106
|
+
memories: List[Dict[str, Any]],
|
|
107
|
+
*,
|
|
108
|
+
collection_id: Optional[str] = None,
|
|
109
|
+
user_id: Optional[str] = None,
|
|
110
|
+
agent_id: Optional[str] = None,
|
|
111
|
+
run_id: Optional[str] = None,
|
|
112
|
+
app_id: Optional[str] = None,
|
|
113
|
+
namespace: Optional[str] = None,
|
|
114
|
+
wait_for_index: bool = False,
|
|
115
|
+
idempotency_key: Optional[str] = None,
|
|
116
|
+
) -> Dict[str, Any]:
|
|
117
|
+
if not 1 <= len(memories) <= 100:
|
|
118
|
+
raise ValueError("memories must contain between 1 and 100 items")
|
|
119
|
+
if any(not str(item.get("content") or "").strip() for item in memories):
|
|
120
|
+
raise ValueError("every batch memory must contain non-empty content")
|
|
121
|
+
payload: Dict[str, Any] = {
|
|
122
|
+
"memories": memories,
|
|
123
|
+
"wait_for_index": wait_for_index,
|
|
124
|
+
}
|
|
125
|
+
optional = {
|
|
126
|
+
"collection_id": collection_id,
|
|
127
|
+
"user_id": user_id,
|
|
128
|
+
"agent_id": agent_id,
|
|
129
|
+
"run_id": run_id,
|
|
130
|
+
"app_id": app_id,
|
|
131
|
+
"namespace": namespace,
|
|
132
|
+
}
|
|
133
|
+
payload.update(
|
|
134
|
+
{key: value for key, value in optional.items() if value is not None}
|
|
135
|
+
)
|
|
136
|
+
kwargs: Dict[str, Any] = {"json": payload}
|
|
137
|
+
if idempotency_key:
|
|
138
|
+
kwargs["headers"] = {"Idempotency-Key": idempotency_key}
|
|
139
|
+
receipt = self.client.post("/v1/memories/batch", **kwargs)
|
|
140
|
+
if wait_for_index and not (
|
|
141
|
+
receipt.get("searchable") is True
|
|
142
|
+
and str(receipt.get("processing_status") or "").casefold() == "completed"
|
|
143
|
+
):
|
|
144
|
+
raise RuntimeError(
|
|
145
|
+
"wait_for_index batch response was not fully searchable; retry "
|
|
146
|
+
"with the same Idempotency-Key"
|
|
147
|
+
)
|
|
148
|
+
return receipt
|
|
149
|
+
|
|
150
|
+
def wait_batch_until_searchable(
|
|
151
|
+
self,
|
|
152
|
+
receipt: Dict[str, Any],
|
|
153
|
+
*,
|
|
154
|
+
timeout: float = 60.0,
|
|
155
|
+
poll_interval: float = 0.5,
|
|
156
|
+
) -> Dict[str, Any]:
|
|
157
|
+
"""Poll every item in an asynchronous batch receipt until searchable."""
|
|
158
|
+
|
|
159
|
+
memory_ids = list(dict.fromkeys(map(str, receipt.get("memory_ids") or [])))
|
|
160
|
+
if not memory_ids:
|
|
161
|
+
raise ValueError("batch receipt does not contain memory_ids")
|
|
162
|
+
deadline = time.monotonic() + max(0.0, timeout)
|
|
163
|
+
while True:
|
|
164
|
+
rows = [self.get(memory_id) for memory_id in memory_ids]
|
|
165
|
+
for row in rows:
|
|
166
|
+
state = str(row.get("processing_status") or "").casefold()
|
|
167
|
+
if state in {"failed", "cancelled", "canceled"}:
|
|
168
|
+
raise RuntimeError(
|
|
169
|
+
f"memory {row.get('id')} indexing reached terminal state {state}"
|
|
170
|
+
)
|
|
171
|
+
if all(row.get("searchable") is True for row in rows):
|
|
172
|
+
return {
|
|
173
|
+
**receipt,
|
|
174
|
+
"processing_status": "completed",
|
|
175
|
+
"searchable": True,
|
|
176
|
+
"results": [
|
|
177
|
+
{
|
|
178
|
+
"id": memory_id,
|
|
179
|
+
"memory_id": memory_id,
|
|
180
|
+
"processing_status": "completed",
|
|
181
|
+
}
|
|
182
|
+
for memory_id in memory_ids
|
|
183
|
+
],
|
|
184
|
+
}
|
|
185
|
+
if time.monotonic() >= deadline:
|
|
186
|
+
raise TimeoutError(f"batch was not searchable within {timeout}s")
|
|
187
|
+
time.sleep(max(0.05, poll_interval))
|
|
188
|
+
|
|
104
189
|
def wait_until_searchable(
|
|
105
190
|
self,
|
|
106
191
|
memory_id: str,
|
|
@@ -310,6 +395,115 @@ class SyncCorrectionsResource:
|
|
|
310
395
|
return self.client.delete(f"/v1/corrections/{correction_id}")
|
|
311
396
|
|
|
312
397
|
|
|
398
|
+
class SyncProceduralResource:
|
|
399
|
+
"""Blocking procedure lifecycle mapped to the canonical REST contract."""
|
|
400
|
+
|
|
401
|
+
def __init__(self, client: "SyncMemoryClient"):
|
|
402
|
+
self.client = client
|
|
403
|
+
|
|
404
|
+
@staticmethod
|
|
405
|
+
def _unwrap(response: Dict[str, Any]) -> Dict[str, Any]:
|
|
406
|
+
procedure = response.get("procedure")
|
|
407
|
+
if isinstance(procedure, dict):
|
|
408
|
+
return procedure
|
|
409
|
+
if response.get("procedure_id") and not response.get("id"):
|
|
410
|
+
return {**response, "id": response["procedure_id"]}
|
|
411
|
+
return response
|
|
412
|
+
|
|
413
|
+
def create(
|
|
414
|
+
self,
|
|
415
|
+
*,
|
|
416
|
+
name: str,
|
|
417
|
+
description: str,
|
|
418
|
+
trigger_condition: str,
|
|
419
|
+
action_sequence: List[str],
|
|
420
|
+
collection_id: Optional[str] = None,
|
|
421
|
+
category: Optional[str] = None,
|
|
422
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
423
|
+
) -> Dict[str, Any]:
|
|
424
|
+
response = self.client.post(
|
|
425
|
+
"/v1/procedures",
|
|
426
|
+
json={
|
|
427
|
+
"name": name,
|
|
428
|
+
"description": description,
|
|
429
|
+
"condition": {"expression": trigger_condition},
|
|
430
|
+
"action": {"steps": action_sequence},
|
|
431
|
+
"collection_id": collection_id,
|
|
432
|
+
"category": category,
|
|
433
|
+
"parameters": metadata or {},
|
|
434
|
+
},
|
|
435
|
+
)
|
|
436
|
+
return self._unwrap(response)
|
|
437
|
+
|
|
438
|
+
def list(
|
|
439
|
+
self,
|
|
440
|
+
*,
|
|
441
|
+
collection_id: Optional[str] = None,
|
|
442
|
+
category: Optional[str] = None,
|
|
443
|
+
skip: int = 0,
|
|
444
|
+
limit: int = 100,
|
|
445
|
+
) -> List[Dict[str, Any]]:
|
|
446
|
+
response = self.client.get(
|
|
447
|
+
"/v1/procedures",
|
|
448
|
+
params={
|
|
449
|
+
key: value
|
|
450
|
+
for key, value in {
|
|
451
|
+
"collection_id": collection_id,
|
|
452
|
+
"category": category,
|
|
453
|
+
"skip": skip,
|
|
454
|
+
"limit": limit,
|
|
455
|
+
}.items()
|
|
456
|
+
if value is not None
|
|
457
|
+
},
|
|
458
|
+
)
|
|
459
|
+
return (
|
|
460
|
+
response if isinstance(response, list) else response.get("procedures", [])
|
|
461
|
+
)
|
|
462
|
+
|
|
463
|
+
def get(self, procedure_id: str) -> Dict[str, Any]:
|
|
464
|
+
return self._unwrap(self.client.get(f"/v1/procedures/{procedure_id}"))
|
|
465
|
+
|
|
466
|
+
def update(
|
|
467
|
+
self,
|
|
468
|
+
procedure_id: str,
|
|
469
|
+
*,
|
|
470
|
+
name: Optional[str] = None,
|
|
471
|
+
description: Optional[str] = None,
|
|
472
|
+
trigger_condition: Optional[str] = None,
|
|
473
|
+
action_sequence: Optional[List[str]] = None,
|
|
474
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
475
|
+
) -> Dict[str, Any]:
|
|
476
|
+
payload: Dict[str, Any] = {}
|
|
477
|
+
if name is not None:
|
|
478
|
+
payload["name"] = name
|
|
479
|
+
if description is not None:
|
|
480
|
+
payload["description"] = description
|
|
481
|
+
if trigger_condition is not None:
|
|
482
|
+
payload["condition"] = {"expression": trigger_condition}
|
|
483
|
+
if action_sequence is not None:
|
|
484
|
+
payload["action"] = {"steps": action_sequence}
|
|
485
|
+
if metadata is not None:
|
|
486
|
+
payload["parameters"] = metadata
|
|
487
|
+
return self._unwrap(
|
|
488
|
+
self.client.patch(f"/v1/procedures/{procedure_id}", json=payload)
|
|
489
|
+
)
|
|
490
|
+
|
|
491
|
+
def execute(
|
|
492
|
+
self,
|
|
493
|
+
procedure_id: str,
|
|
494
|
+
context: Optional[Dict[str, Any]] = None,
|
|
495
|
+
) -> Dict[str, Any]:
|
|
496
|
+
response = self.client.post(
|
|
497
|
+
f"/v1/procedures/{procedure_id}/execute",
|
|
498
|
+
json={"input_state": context or {}},
|
|
499
|
+
)
|
|
500
|
+
result = response.get("execution_result")
|
|
501
|
+
return result if isinstance(result, dict) else response
|
|
502
|
+
|
|
503
|
+
def delete(self, procedure_id: str) -> None:
|
|
504
|
+
self.client.delete(f"/v1/procedures/{procedure_id}")
|
|
505
|
+
|
|
506
|
+
|
|
313
507
|
class SyncSearchResource:
|
|
314
508
|
def __init__(self, client: "SyncMemoryClient"):
|
|
315
509
|
self.client = client
|
|
@@ -544,7 +738,7 @@ class SyncMemoryClient:
|
|
|
544
738
|
self.source = source or os.getenv("HEBBRIX_SOURCE")
|
|
545
739
|
headers = {
|
|
546
740
|
"Content-Type": "application/json",
|
|
547
|
-
"User-Agent": "hebbrix-python/2.3.
|
|
741
|
+
"User-Agent": "hebbrix-python/2.3.2",
|
|
548
742
|
}
|
|
549
743
|
if api_key:
|
|
550
744
|
headers["Authorization"] = f"Bearer {api_key}"
|
|
@@ -559,6 +753,7 @@ class SyncMemoryClient:
|
|
|
559
753
|
self.memories = SyncMemoriesResource(self)
|
|
560
754
|
self.memory_jobs = SyncMemoryJobsResource(self)
|
|
561
755
|
self.corrections = SyncCorrectionsResource(self)
|
|
756
|
+
self.procedural = SyncProceduralResource(self)
|
|
562
757
|
self.search_resource = SyncSearchResource(self)
|
|
563
758
|
self.proofloop = SyncProofLoopResource(self)
|
|
564
759
|
|
|
@@ -1,10 +1,31 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: hebbrix
|
|
3
|
-
Version: 2.3.
|
|
3
|
+
Version: 2.3.2
|
|
4
4
|
Summary: Advanced Memory API for AI Agents with Reinforcement Learning - 3-line integration
|
|
5
5
|
Author-email: Hebbrix Team <support@hebbrix.com>
|
|
6
6
|
Maintainer-email: Hebbrix Team <support@hebbrix.com>
|
|
7
|
-
License
|
|
7
|
+
License: MIT License
|
|
8
|
+
|
|
9
|
+
Copyright (c) 2025 Hebbrix
|
|
10
|
+
|
|
11
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
12
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
13
|
+
in the Software without restriction, including without limitation the rights
|
|
14
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
15
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
16
|
+
furnished to do so, subject to the following conditions:
|
|
17
|
+
|
|
18
|
+
The above copyright notice and this permission notice shall be included in all
|
|
19
|
+
copies or substantial portions of the Software.
|
|
20
|
+
|
|
21
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
22
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
23
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
24
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
25
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
26
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
27
|
+
SOFTWARE.
|
|
28
|
+
|
|
8
29
|
Project-URL: Homepage, https://hebbrix.com
|
|
9
30
|
Project-URL: Documentation, https://docs.hebbrix.com
|
|
10
31
|
Project-URL: Repository, https://github.com/hebbrix/hebbrix-python
|
|
@@ -96,6 +117,18 @@ async def main():
|
|
|
96
117
|
# It returns only when searchable=true, raises on terminal failure, and
|
|
97
118
|
# raises TimeoutError if the caller's readiness deadline expires.
|
|
98
119
|
|
|
120
|
+
# Batch contract: a successful synchronous return means every accepted item
|
|
121
|
+
# is searchable. A bounded server timeout raises explicitly and is safe to
|
|
122
|
+
# retry with the same idempotency key; it is never a successful 202.
|
|
123
|
+
batch = await client.memories.create_batch(
|
|
124
|
+
[{"content": "First fact"}, {"content": "Second fact"}],
|
|
125
|
+
collection_id=collection["id"],
|
|
126
|
+
wait_for_index=True,
|
|
127
|
+
idempotency_key="import-42",
|
|
128
|
+
)
|
|
129
|
+
# For wait_for_index=False receipts:
|
|
130
|
+
# batch = await client.memories.wait_batch_until_searchable(batch)
|
|
131
|
+
|
|
99
132
|
# Search memories
|
|
100
133
|
results = await client.search(
|
|
101
134
|
query="What programming language does user like?",
|
|
@@ -1,15 +1,14 @@
|
|
|
1
1
|
[build-system]
|
|
2
|
-
requires = ["setuptools>=
|
|
2
|
+
requires = ["setuptools>=68.0", "wheel"]
|
|
3
3
|
build-backend = "setuptools.build_meta"
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "hebbrix"
|
|
7
|
-
version = "2.3.
|
|
7
|
+
version = "2.3.2"
|
|
8
8
|
description = "Advanced Memory API for AI Agents with Reinforcement Learning - 3-line integration"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.8"
|
|
11
|
-
license = "
|
|
12
|
-
license-files = ["LICENSE"]
|
|
11
|
+
license = {file = "LICENSE"}
|
|
13
12
|
authors = [
|
|
14
13
|
{name = "Hebbrix Team", email = "support@hebbrix.com"}
|
|
15
14
|
]
|
|
@@ -30,6 +30,10 @@ class RecordingClient:
|
|
|
30
30
|
self.calls.append((path, kwargs))
|
|
31
31
|
return {"id": "memory-1"}
|
|
32
32
|
|
|
33
|
+
async def delete(self, path, **kwargs):
|
|
34
|
+
self.calls.append((path, kwargs))
|
|
35
|
+
return {}
|
|
36
|
+
|
|
33
37
|
|
|
34
38
|
@pytest.mark.asyncio
|
|
35
39
|
async def test_create_forwards_every_supported_scope_and_source_field():
|
|
@@ -102,6 +106,71 @@ async def test_create_sends_idempotency_as_transport_header_not_payload():
|
|
|
102
106
|
assert "idempotency_key" not in request["json"]
|
|
103
107
|
|
|
104
108
|
|
|
109
|
+
@pytest.mark.asyncio
|
|
110
|
+
async def test_batch_create_has_one_truthful_readiness_contract():
|
|
111
|
+
client = RecordingClient()
|
|
112
|
+
|
|
113
|
+
async def post(path, **kwargs):
|
|
114
|
+
client.calls.append((path, kwargs))
|
|
115
|
+
return {
|
|
116
|
+
"created": 1,
|
|
117
|
+
"failed": 0,
|
|
118
|
+
"memory_ids": ["memory-1"],
|
|
119
|
+
"results": [{"id": "memory-1", "processing_status": "completed"}],
|
|
120
|
+
"processing_status": "completed",
|
|
121
|
+
"searchable": True,
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
client.post = post
|
|
125
|
+
receipt = await MemoriesResource(client).create_batch(
|
|
126
|
+
[{"content": "A batch fact"}],
|
|
127
|
+
collection_id="collection-1",
|
|
128
|
+
wait_for_index=True,
|
|
129
|
+
idempotency_key="batch-retry-1",
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
path, request = client.calls[0]
|
|
133
|
+
assert path == "/v1/memories/batch"
|
|
134
|
+
assert receipt["searchable"] is True
|
|
135
|
+
assert request["json"]["wait_for_index"] is True
|
|
136
|
+
assert request["headers"] == {"Idempotency-Key": "batch-retry-1"}
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@pytest.mark.asyncio
|
|
140
|
+
async def test_async_batch_polling_requires_every_item_to_be_searchable(monkeypatch):
|
|
141
|
+
client = RecordingClient()
|
|
142
|
+
calls = {"memory-1": 0, "memory-2": 0}
|
|
143
|
+
|
|
144
|
+
async def get(path, **_kwargs):
|
|
145
|
+
memory_id = path.rsplit("/", 1)[-1]
|
|
146
|
+
calls[memory_id] += 1
|
|
147
|
+
ready = calls[memory_id] >= 2
|
|
148
|
+
return {
|
|
149
|
+
"id": memory_id,
|
|
150
|
+
"processing_status": "completed" if ready else "processing",
|
|
151
|
+
"searchable": ready,
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
client.get = get
|
|
155
|
+
monkeypatch.setattr("hebbrix.resources.asyncio.sleep", AsyncMock())
|
|
156
|
+
receipt = await MemoriesResource(client).wait_batch_until_searchable(
|
|
157
|
+
{
|
|
158
|
+
"memory_ids": ["memory-1", "memory-2"],
|
|
159
|
+
"processing_status": "processing",
|
|
160
|
+
"searchable": False,
|
|
161
|
+
},
|
|
162
|
+
timeout=2,
|
|
163
|
+
poll_interval=0.05,
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
assert receipt["processing_status"] == "completed"
|
|
167
|
+
assert receipt["searchable"] is True
|
|
168
|
+
assert {row["memory_id"] for row in receipt["results"]} == {
|
|
169
|
+
"memory-1",
|
|
170
|
+
"memory-2",
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
|
|
105
174
|
@pytest.mark.asyncio
|
|
106
175
|
async def test_create_rejects_empty_content_before_network_io():
|
|
107
176
|
client = RecordingClient()
|
|
@@ -1,13 +1,12 @@
|
|
|
1
1
|
"""Release guard: GA SDK methods must remain mapped to exported OpenAPI operations."""
|
|
2
2
|
|
|
3
3
|
import inspect
|
|
4
|
-
import json
|
|
5
|
-
from pathlib import Path
|
|
6
4
|
|
|
7
5
|
from hebbrix.resources import (
|
|
8
6
|
CorrectionsResource,
|
|
9
7
|
MemoriesResource,
|
|
10
8
|
MemoryJobsResource,
|
|
9
|
+
ProceduralResource,
|
|
11
10
|
ProofLoopResource,
|
|
12
11
|
SearchResource,
|
|
13
12
|
)
|
|
@@ -15,50 +14,22 @@ from hebbrix.sync_client import (
|
|
|
15
14
|
SyncCorrectionsResource,
|
|
16
15
|
SyncMemoriesResource,
|
|
17
16
|
SyncMemoryJobsResource,
|
|
17
|
+
SyncProceduralResource,
|
|
18
18
|
SyncProofLoopResource,
|
|
19
19
|
SyncSearchResource,
|
|
20
20
|
)
|
|
21
21
|
|
|
22
|
-
|
|
23
|
-
OPENAPI = json.loads(
|
|
24
|
-
(Path(__file__).resolve().parents[3] / "docs/api/openapi.json").read_text()
|
|
25
|
-
)
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
GA_OPERATIONS = {
|
|
29
|
-
("/v1/memories", "post"),
|
|
30
|
-
("/v1/memories", "get"),
|
|
31
|
-
("/v1/memory-jobs/{job_id}", "get"),
|
|
32
|
-
("/v1/corrections", "post"),
|
|
33
|
-
("/v1/corrections/relevant", "get"),
|
|
34
|
-
("/v1/corrections/{correction_id}", "get"),
|
|
35
|
-
("/v1/corrections/{correction_id}", "delete"),
|
|
36
|
-
("/v1/search", "post"),
|
|
37
|
-
("/v1/search/reason", "post"),
|
|
38
|
-
("/v1/learning/decisions", "post"),
|
|
39
|
-
("/v1/learning/decisions/{decision_id}", "get"),
|
|
40
|
-
("/v1/learning/decisions/{decision_id}/outcomes", "post"),
|
|
41
|
-
("/v1/learning/decisions/{decision_id}/proof", "get"),
|
|
42
|
-
("/v1/learning/metrics", "post"),
|
|
43
|
-
("/v1/learning/metrics", "get"),
|
|
44
|
-
("/v1/learning/policies/{policy_key}/insights", "get"),
|
|
45
|
-
("/v1/learning/policies/{policy_key}/evaluate", "post"),
|
|
46
|
-
("/v1/learning/proof-key", "get"),
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
def test_ga_operation_manifest_exists_in_exported_openapi():
|
|
51
|
-
missing = sorted(
|
|
52
|
-
(path, method)
|
|
53
|
-
for path, method in GA_OPERATIONS
|
|
54
|
-
if path not in OPENAPI["paths"] or method not in OPENAPI["paths"][path]
|
|
55
|
-
)
|
|
56
|
-
assert missing == []
|
|
57
|
-
|
|
58
|
-
|
|
59
22
|
def test_async_and_sync_resources_cover_the_ga_lifecycle():
|
|
60
23
|
required = {
|
|
61
|
-
MemoriesResource: {
|
|
24
|
+
MemoriesResource: {
|
|
25
|
+
"create",
|
|
26
|
+
"create_batch",
|
|
27
|
+
"wait_batch_until_searchable",
|
|
28
|
+
"list_page",
|
|
29
|
+
"get",
|
|
30
|
+
"update",
|
|
31
|
+
"delete",
|
|
32
|
+
},
|
|
62
33
|
MemoryJobsResource: {"get", "wait"},
|
|
63
34
|
CorrectionsResource: {"create", "relevant", "get", "delete"},
|
|
64
35
|
SearchResource: {"search_with_proof", "reason"},
|
|
@@ -73,7 +44,16 @@ def test_async_and_sync_resources_cover_the_ga_lifecycle():
|
|
|
73
44
|
"proof",
|
|
74
45
|
"public_key",
|
|
75
46
|
},
|
|
76
|
-
|
|
47
|
+
ProceduralResource: {"create", "list", "get", "update", "execute", "delete"},
|
|
48
|
+
SyncMemoriesResource: {
|
|
49
|
+
"create",
|
|
50
|
+
"create_batch",
|
|
51
|
+
"wait_batch_until_searchable",
|
|
52
|
+
"list_page",
|
|
53
|
+
"get",
|
|
54
|
+
"update",
|
|
55
|
+
"delete",
|
|
56
|
+
},
|
|
77
57
|
SyncMemoryJobsResource: {"get", "wait"},
|
|
78
58
|
SyncCorrectionsResource: {"create", "relevant", "get", "delete"},
|
|
79
59
|
SyncSearchResource: {"search_with_proof", "reason"},
|
|
@@ -88,6 +68,14 @@ def test_async_and_sync_resources_cover_the_ga_lifecycle():
|
|
|
88
68
|
"proof",
|
|
89
69
|
"public_key",
|
|
90
70
|
},
|
|
71
|
+
SyncProceduralResource: {
|
|
72
|
+
"create",
|
|
73
|
+
"list",
|
|
74
|
+
"get",
|
|
75
|
+
"update",
|
|
76
|
+
"execute",
|
|
77
|
+
"delete",
|
|
78
|
+
},
|
|
91
79
|
}
|
|
92
80
|
missing = {
|
|
93
81
|
resource.__name__: sorted(
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
from hebbrix.resources import ProceduralResource
|
|
3
|
+
from hebbrix.sync_client import SyncProceduralResource
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class RecordingClient:
|
|
7
|
+
def __init__(self):
|
|
8
|
+
self.calls = []
|
|
9
|
+
|
|
10
|
+
async def post(self, path, **kwargs):
|
|
11
|
+
self.calls.append(("POST", path, kwargs))
|
|
12
|
+
if path == "/v1/procedures":
|
|
13
|
+
return {"status": "success", "procedure_id": "procedure-1"}
|
|
14
|
+
if path.endswith("/execute"):
|
|
15
|
+
return {"execution_result": {"output": "recovered"}}
|
|
16
|
+
return {"status": "success"}
|
|
17
|
+
|
|
18
|
+
async def get(self, path, **kwargs):
|
|
19
|
+
self.calls.append(("GET", path, kwargs))
|
|
20
|
+
return {"procedures": []}
|
|
21
|
+
|
|
22
|
+
async def patch(self, path, **kwargs):
|
|
23
|
+
self.calls.append(("PATCH", path, kwargs))
|
|
24
|
+
return {
|
|
25
|
+
"status": "success",
|
|
26
|
+
"procedure": {"id": "procedure-1", "name": "updated"},
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async def delete(self, path, **kwargs):
|
|
30
|
+
self.calls.append(("DELETE", path, kwargs))
|
|
31
|
+
return {}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@pytest.mark.asyncio
|
|
35
|
+
async def test_procedure_lifecycle_matches_canonical_rest_contract():
|
|
36
|
+
client = RecordingClient()
|
|
37
|
+
resource = ProceduralResource(client)
|
|
38
|
+
|
|
39
|
+
created = await resource.create(
|
|
40
|
+
name="restart proxy",
|
|
41
|
+
description="Recover the query proxy",
|
|
42
|
+
trigger_condition="proxy unavailable",
|
|
43
|
+
action_sequence=["recycle proxy once"],
|
|
44
|
+
metadata={"owner": "on-call"},
|
|
45
|
+
)
|
|
46
|
+
assert await resource.list() == []
|
|
47
|
+
executed = await resource.execute("procedure-1", {"incident": "INC-1"})
|
|
48
|
+
updated = await resource.update(
|
|
49
|
+
"procedure-1",
|
|
50
|
+
trigger_condition="proxy unhealthy",
|
|
51
|
+
action_sequence=["page on-call", "recycle proxy once"],
|
|
52
|
+
)
|
|
53
|
+
await resource.delete("procedure-1")
|
|
54
|
+
assert created["id"] == "procedure-1"
|
|
55
|
+
assert executed == {"output": "recovered"}
|
|
56
|
+
assert updated == {"id": "procedure-1", "name": "updated"}
|
|
57
|
+
|
|
58
|
+
assert [(method, path) for method, path, _ in client.calls] == [
|
|
59
|
+
("POST", "/v1/procedures"),
|
|
60
|
+
("GET", "/v1/procedures"),
|
|
61
|
+
("POST", "/v1/procedures/procedure-1/execute"),
|
|
62
|
+
("PATCH", "/v1/procedures/procedure-1"),
|
|
63
|
+
("DELETE", "/v1/procedures/procedure-1"),
|
|
64
|
+
]
|
|
65
|
+
create = client.calls[0][2]["json"]
|
|
66
|
+
assert create["condition"] == {"expression": "proxy unavailable"}
|
|
67
|
+
assert create["action"] == {"steps": ["recycle proxy once"]}
|
|
68
|
+
assert create["parameters"] == {"owner": "on-call"}
|
|
69
|
+
assert client.calls[2][2]["json"] == {"input_state": {"incident": "INC-1"}}
|
|
70
|
+
update = client.calls[3][2]["json"]
|
|
71
|
+
assert update["condition"] == {"expression": "proxy unhealthy"}
|
|
72
|
+
assert update["action"] == {"steps": ["page on-call", "recycle proxy once"]}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class SyncRecordingClient:
|
|
76
|
+
def __init__(self):
|
|
77
|
+
self.calls = []
|
|
78
|
+
|
|
79
|
+
def post(self, path, **kwargs):
|
|
80
|
+
self.calls.append(("POST", path, kwargs))
|
|
81
|
+
if path == "/v1/procedures":
|
|
82
|
+
return {"procedure_id": "procedure-1"}
|
|
83
|
+
if path.endswith("/execute"):
|
|
84
|
+
return {"execution_result": {"output": "recovered"}}
|
|
85
|
+
return {}
|
|
86
|
+
|
|
87
|
+
def get(self, path, **kwargs):
|
|
88
|
+
self.calls.append(("GET", path, kwargs))
|
|
89
|
+
return {"procedures": []}
|
|
90
|
+
|
|
91
|
+
def patch(self, path, **kwargs):
|
|
92
|
+
self.calls.append(("PATCH", path, kwargs))
|
|
93
|
+
return {"procedure": {"id": "procedure-1", "name": "updated"}}
|
|
94
|
+
|
|
95
|
+
def delete(self, path, **kwargs):
|
|
96
|
+
self.calls.append(("DELETE", path, kwargs))
|
|
97
|
+
return None
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def test_sync_procedure_lifecycle_matches_canonical_rest_contract():
|
|
101
|
+
client = SyncRecordingClient()
|
|
102
|
+
resource = SyncProceduralResource(client)
|
|
103
|
+
|
|
104
|
+
created = resource.create(
|
|
105
|
+
name="restart proxy",
|
|
106
|
+
description="Recover the query proxy",
|
|
107
|
+
trigger_condition="proxy unavailable",
|
|
108
|
+
action_sequence=["recycle proxy once"],
|
|
109
|
+
)
|
|
110
|
+
assert resource.list() == []
|
|
111
|
+
assert resource.execute("procedure-1") == {"output": "recovered"}
|
|
112
|
+
assert resource.update("procedure-1", name="updated")["name"] == "updated"
|
|
113
|
+
assert resource.delete("procedure-1") is None
|
|
114
|
+
assert created["id"] == "procedure-1"
|
|
115
|
+
assert [(method, path) for method, path, _ in client.calls] == [
|
|
116
|
+
("POST", "/v1/procedures"),
|
|
117
|
+
("GET", "/v1/procedures"),
|
|
118
|
+
("POST", "/v1/procedures/procedure-1/execute"),
|
|
119
|
+
("PATCH", "/v1/procedures/procedure-1"),
|
|
120
|
+
("DELETE", "/v1/procedures/procedure-1"),
|
|
121
|
+
]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|