wexa 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.
- wexa-0.1.0.dist-info/METADATA +857 -0
- wexa-0.1.0.dist-info/RECORD +5 -0
- wexa-0.1.0.dist-info/WHEEL +5 -0
- wexa-0.1.0.dist-info/top_level.txt +1 -0
- wexa.py +480 -0
|
@@ -0,0 +1,857 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: wexa
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python client for Wexa Fabric
|
|
5
|
+
License: Proprietary
|
|
6
|
+
Project-URL: Homepage, https://fabric.wexa.ai
|
|
7
|
+
Requires-Python: >=3.9
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
|
|
10
|
+
# wexa
|
|
11
|
+
|
|
12
|
+
Python client for the Wexa Fabric api-gateway. One module, stdlib only, no
|
|
13
|
+
dependencies to conflict with yours.
|
|
14
|
+
|
|
15
|
+
Every call goes through the gateway's ten-stage governed lifecycle, so the same
|
|
16
|
+
things happen to a graph query and to an agent run: the credential is checked,
|
|
17
|
+
the grant is checked, quota is counted, arguments are validated, policy runs,
|
|
18
|
+
a human approves if the data is sensitive, then it executes and is recorded.
|
|
19
|
+
When something refuses, the error names the stage that refused it —
|
|
20
|
+
`S2:resolve-scope`, `S5:policy`, `S8:execute` — so you know whether to fix your
|
|
21
|
+
token, your payload, or your retry loop.
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
pip install wexa # once published
|
|
25
|
+
pip install -e . # from this repo today
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Requires Python 3.9+.
|
|
29
|
+
|
|
30
|
+
---
|
|
31
|
+
|
|
32
|
+
## 60-second quickstart
|
|
33
|
+
|
|
34
|
+
**1. Get a key.** In the console: **Simple mode → Generate API key**. Keys are
|
|
35
|
+
project-scoped and shown once.
|
|
36
|
+
|
|
37
|
+
**2. Put it in the environment.**
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
export WEXA_WORKSPACE=https://fabric.wexa.ai
|
|
41
|
+
export WEXA_API_KEY=fab_sk_...
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
**3. Make a call.**
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
from wexa import Fabric
|
|
48
|
+
|
|
49
|
+
fabric = Fabric() # reads WEXA_WORKSPACE / WEXA_API_KEY
|
|
50
|
+
print(fabric.whoami())
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
```
|
|
54
|
+
{'user_id': 'usr_311', 'role': 'DEVELOPER', 'org_id': 'org_7f2a',
|
|
55
|
+
'dept_id': '', 'project_id': 'prj_1c94',
|
|
56
|
+
'grants': ['fabric:query.read', 'fabric:docs.read']}
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
That is the whole setup. The client discovers its endpoints from
|
|
60
|
+
`/v1/connection-info`, so the workspace URL is the only address you supply. You
|
|
61
|
+
can pass credentials inline instead, which is handy in a notebook:
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
fabric = Fabric(workspace="https://fabric.wexa.ai", api_key="fab_sk_...")
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
**A client is bound to one project.** Scope is fixed into the key when it is
|
|
68
|
+
minted and never widens. To work across projects, make one client per key.
|
|
69
|
+
|
|
70
|
+
### What to do next
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
WEXA_WORKSPACE=... WEXA_API_KEY=... python3 examples/status.py
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
`status.py` prints your role, which tools your grants actually cover, how close
|
|
77
|
+
you are to the rate limit, and any approvals waiting on you. It is the fastest
|
|
78
|
+
answer to "why did that call fail".
|
|
79
|
+
|
|
80
|
+
---
|
|
81
|
+
|
|
82
|
+
## Authentication: what exactly do you send?
|
|
83
|
+
|
|
84
|
+
One HTTP header, on every call:
|
|
85
|
+
|
|
86
|
+
```
|
|
87
|
+
Authorization: Bearer <your credential>
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
That is all the client ever sends. `Fabric(api_key=...)` puts whatever string
|
|
91
|
+
you give it behind `Bearer `, so the parameter name is narrower than the truth —
|
|
92
|
+
**any bearer credential the gateway accepts works there.** The gateway names its
|
|
93
|
+
two options itself, at the unauthenticated `/v1/connection-info` endpoint:
|
|
94
|
+
|
|
95
|
+
```json
|
|
96
|
+
"auth": "OAuth 2.1 (PKCE) or Bearer API key (fab_sk_…)"
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
| Credential | Looks like | Where it comes from | Use it for |
|
|
100
|
+
|---|---|---|---|
|
|
101
|
+
| **API key** | `fab_sk_…` | Console → Simple mode → Generate API key | scripts, backends, CI — anything long-running |
|
|
102
|
+
| **Access token** | a JWT, three dot-separated blobs | the OAuth 2.1 (PKCE) flow, or your identity provider | apps acting on behalf of a signed-in person |
|
|
103
|
+
|
|
104
|
+
Both go in the same header and behave identically once issued. Start with an API
|
|
105
|
+
key; it needs no flow.
|
|
106
|
+
|
|
107
|
+
### A credential is a key card, not a password
|
|
108
|
+
|
|
109
|
+
It does not just prove who you are. It carries **where you may go** and **what you
|
|
110
|
+
may do**, stamped in at the moment it was minted:
|
|
111
|
+
|
|
112
|
+
- **Scope** — one org, one department, one project. Printed on the card.
|
|
113
|
+
- **Grants** — the specific doors it opens, like `fabric:query.read`.
|
|
114
|
+
|
|
115
|
+
Two consequences that surprise people:
|
|
116
|
+
|
|
117
|
+
1. **Scope never widens.** A client is bound to one project for its whole life.
|
|
118
|
+
To reach a second project, mint a second credential and build a second
|
|
119
|
+
client. There is no "switch project" call, by design.
|
|
120
|
+
2. **Being authenticated is not being authorized.** A perfectly valid credential
|
|
121
|
+
still gets refused if it lacks the grant for the tool you called.
|
|
122
|
+
|
|
123
|
+
### The failure you will actually hit
|
|
124
|
+
|
|
125
|
+
Missing grants are the most common auth error, and the message says exactly
|
|
126
|
+
what is missing:
|
|
127
|
+
|
|
128
|
+
```
|
|
129
|
+
ForbiddenError: S2:resolve-scope — token missing required grant "fabric:ontology.write"
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
`S2` is the scope stage, so you know the credential was accepted (S1 passed) and
|
|
133
|
+
the *permission* is what's wrong. Fix the key, not the payload.
|
|
134
|
+
|
|
135
|
+
### Which grant does each call need?
|
|
136
|
+
|
|
137
|
+
Six grants cover every tool in this client:
|
|
138
|
+
|
|
139
|
+
| Grant | Opens |
|
|
140
|
+
|---|---|
|
|
141
|
+
| `fabric:query.read` | `query_context`, `search_code`, `fetch_code`, `connector_read` |
|
|
142
|
+
| `fabric:ontology.write` | `save_context`, `create_ontology` |
|
|
143
|
+
| `fabric:docs.read` | `docs` |
|
|
144
|
+
| `fabric:orchestrate.read` | `list_skills`, `get_process_flow`, `get_execution`, `knowledge_base_retrieve` |
|
|
145
|
+
| `fabric:orchestrate.write` | `create_process_flow`, `update_process_flow`, `create_agent` |
|
|
146
|
+
| `fabric:agent.run` | `run_agent`, `run_process_flow` |
|
|
147
|
+
|
|
148
|
+
Reads and writes are separate grants, and *running* something is separate from
|
|
149
|
+
*authoring* it — a credential can be allowed to run a process flow without being
|
|
150
|
+
allowed to change it.
|
|
151
|
+
|
|
152
|
+
Three other grants exist (`fabric:catalog.read`, `fabric:catalog.write`,
|
|
153
|
+
`fabric:codesync.write`) but belong to endpoint families this client does not
|
|
154
|
+
wrap. You will not need them here.
|
|
155
|
+
|
|
156
|
+
To see your own, ask:
|
|
157
|
+
|
|
158
|
+
```python
|
|
159
|
+
print(fabric.whoami()["grants"])
|
|
160
|
+
# ['fabric:query.read', 'fabric:ontology.write']
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
---
|
|
164
|
+
|
|
165
|
+
## Your first real call
|
|
166
|
+
|
|
167
|
+
Reads go through `query_context`, which runs read-only Cypher. One rule: the
|
|
168
|
+
query must constrain itself to your project, or the gateway's Cypher guard
|
|
169
|
+
rejects it before it reaches the graph.
|
|
170
|
+
|
|
171
|
+
```python
|
|
172
|
+
rows = fabric.query_context(
|
|
173
|
+
query="MATCH (c:Customer) WHERE c.project_id = $project_id RETURN c LIMIT 50",
|
|
174
|
+
)
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
`$project_id` is bound for you from the token. Do **not** pass `project_id`
|
|
178
|
+
yourself — see [Arguments the server owns](#arguments-the-server-owns).
|
|
179
|
+
|
|
180
|
+
Needs `fabric:query.read`.
|
|
181
|
+
|
|
182
|
+
---
|
|
183
|
+
|
|
184
|
+
## What goes in a payload?
|
|
185
|
+
|
|
186
|
+
Every call is `fabric.<tool>(**keyword_arguments)`. There is no envelope to build
|
|
187
|
+
and no request object to construct — the keywords *are* the payload.
|
|
188
|
+
|
|
189
|
+
```python
|
|
190
|
+
fabric.query_context(query="MATCH (n) WHERE n.project_id = $project_id RETURN n")
|
|
191
|
+
# └──────────── this is the whole payload ────────────┘
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
### You supply the intent; the server supplies the identity
|
|
195
|
+
|
|
196
|
+
Some arguments are **server-owned**. They describe *who is calling*, and the
|
|
197
|
+
gateway fills them in from your credential. Sending them yourself is refused —
|
|
198
|
+
not ignored — because a payload that could name its own caller is a payload that
|
|
199
|
+
could impersonate one.
|
|
200
|
+
|
|
201
|
+
| You always send | The server always fills in |
|
|
202
|
+
|---|---|
|
|
203
|
+
| `query`, `nodes`, `relationships`, `goal`, `topic` … | `project_id`, `organization_id`, `executed_by` |
|
|
204
|
+
|
|
205
|
+
```python
|
|
206
|
+
# refused before it leaves your process
|
|
207
|
+
fabric.query_context(query="…", project_id="prj_other")
|
|
208
|
+
|
|
209
|
+
# correct — reference it as a bound parameter instead
|
|
210
|
+
fabric.query_context(query="MATCH (n) WHERE n.project_id = $project_id RETURN n")
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
`$project_id` is already bound for you. Think of it as a blank the server fills
|
|
214
|
+
in after you hand the form over. The complete list, and why the client refuses
|
|
215
|
+
these rather than letting the server quietly overwrite them, is in
|
|
216
|
+
[Arguments the server owns](#arguments-the-server-owns).
|
|
217
|
+
|
|
218
|
+
### Nodes: `key` names the identity field, it is not the identity value
|
|
219
|
+
|
|
220
|
+
This is the one shape worth reading twice, because `key` sounds like it holds an
|
|
221
|
+
id and it does not — it holds the *name of the property* that holds the id.
|
|
222
|
+
|
|
223
|
+
```python
|
|
224
|
+
{"label": "Refund", "key": "id", "properties": {"id": "rfnd_1001", "amount": 4200}}
|
|
225
|
+
# │ └── the identity value
|
|
226
|
+
# └── the property to identify by
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
Read it as: *"identify this Refund by its `id` property."* That is what makes
|
|
230
|
+
writes idempotent — send the same node twice and Fabric updates it rather than
|
|
231
|
+
creating a duplicate, because it knows which field to match on. Point `key` at a
|
|
232
|
+
property that is genuinely unique, and never at something like `amount`.
|
|
233
|
+
|
|
234
|
+
### Relationships: endpoints are objects, not strings
|
|
235
|
+
|
|
236
|
+
A relationship has to say which node it means, and a bare `"rfnd_1001"` is
|
|
237
|
+
ambiguous — two labels could each have a node with that id. So each endpoint
|
|
238
|
+
repeats the address in full: `{label, key, value}`.
|
|
239
|
+
|
|
240
|
+
```python
|
|
241
|
+
{"type": "ISSUED_TO",
|
|
242
|
+
"from": {"label": "Refund", "key": "id", "value": "rfnd_1001"},
|
|
243
|
+
"to": {"label": "Customer", "key": "id", "value": "cust_8841"}}
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
Note `value` here, versus `properties` on a node: a node *carries* its data, an
|
|
247
|
+
endpoint only *points at* it.
|
|
248
|
+
|
|
249
|
+
### When a payload is wrong, S4 tells you
|
|
250
|
+
|
|
251
|
+
Validation is stage 4, so a malformed payload never reaches the graph:
|
|
252
|
+
|
|
253
|
+
```
|
|
254
|
+
ValidationError: S4:validate-input … cannot unmarshal string into Go struct
|
|
255
|
+
field Rel.relationships.from of type contextsvc.Ref
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
`Ref` is that `{label, key, value}` object. This exact error means an endpoint
|
|
259
|
+
was passed as a string.
|
|
260
|
+
|
|
261
|
+
---
|
|
262
|
+
|
|
263
|
+
## Common tasks
|
|
264
|
+
|
|
265
|
+
Every sample below is one continuous scenario: a refund issued to a customer.
|
|
266
|
+
Copy any of them as-is.
|
|
267
|
+
|
|
268
|
+
### Write to the context graph
|
|
269
|
+
|
|
270
|
+
```python
|
|
271
|
+
fabric.save_context(
|
|
272
|
+
nodes=[
|
|
273
|
+
{"label": "Refund", "key": "id",
|
|
274
|
+
"properties": {"id": "rfnd_1001", "amount": 4200, "currency": "usd"}},
|
|
275
|
+
],
|
|
276
|
+
)
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
A node is `{"label", "key", "properties"}`, where `key` names the property that
|
|
280
|
+
is the node's identity — `key: "id"` means `properties.id` is the identity.
|
|
281
|
+
|
|
282
|
+
Relationship endpoints are **objects, not strings**. This is the single most
|
|
283
|
+
common payload error:
|
|
284
|
+
|
|
285
|
+
```python
|
|
286
|
+
fabric.save_context(
|
|
287
|
+
nodes=[
|
|
288
|
+
{"label": "Refund", "key": "id", "properties": {"id": "rfnd_1001"}},
|
|
289
|
+
{"label": "Customer", "key": "id", "properties": {"id": "cust_8841"}},
|
|
290
|
+
],
|
|
291
|
+
relationships=[
|
|
292
|
+
{"type": "ISSUED_TO",
|
|
293
|
+
"from": {"label": "Refund", "key": "id", "value": "rfnd_1001"},
|
|
294
|
+
"to": {"label": "Customer", "key": "id", "value": "cust_8841"}},
|
|
295
|
+
],
|
|
296
|
+
)
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
Passing `"from": "rfnd_1001"` gets you:
|
|
300
|
+
|
|
301
|
+
```
|
|
302
|
+
ValidationError: S4:validate-input ... cannot unmarshal string into Go struct
|
|
303
|
+
field Rel.relationships.from of type contextsvc.Ref
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
`save_context` needs a project in **Simple/auto** mode, which is the default. On
|
|
307
|
+
an Advanced/manual project it fails validation outright and `create_ontology` is
|
|
308
|
+
the ingestion path instead — see [Project mode](#project-mode-decides-your-ingestion-path).
|
|
309
|
+
|
|
310
|
+
Needs `fabric:ontology.write`.
|
|
311
|
+
|
|
312
|
+
### Run an agent or a process flow
|
|
313
|
+
|
|
314
|
+
These start real work and return without waiting for it.
|
|
315
|
+
|
|
316
|
+
```python
|
|
317
|
+
run = fabric.run_agent(agentflow_id="af_1", goal="summarize last week's refunds")
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
```python
|
|
321
|
+
flow = fabric.create_process_flow(name="nightly-reconcile")
|
|
322
|
+
run = fabric.run_process_flow(
|
|
323
|
+
process_flow_id=flow["process_flow_id"], goal="reconcile refunds",
|
|
324
|
+
)
|
|
325
|
+
status = fabric.get_execution(execution_id=...) # id from run, see below
|
|
326
|
+
```
|
|
327
|
+
|
|
328
|
+
`create_process_flow` returns `{"process_flow_id": ..., "flow": {...}}`.
|
|
329
|
+
`run_process_flow` passes the data-service response through unchanged, so read
|
|
330
|
+
the execution id off `run` rather than assuming a field name.
|
|
331
|
+
|
|
332
|
+
`run_agent` and `run_process_flow` both need `fabric:agent.run` — including
|
|
333
|
+
`run_process_flow`, which does *not* accept `fabric:orchestrate.write` in its
|
|
334
|
+
place.
|
|
335
|
+
|
|
336
|
+
### Search code
|
|
337
|
+
|
|
338
|
+
```python
|
|
339
|
+
hits = fabric.search_code(query="def issue_refund", limit=20)
|
|
340
|
+
|
|
341
|
+
# fetch_code needs either a line span or a row_pk from a search hit
|
|
342
|
+
body = fabric.fetch_code(path="billing/refunds.py", start_line=1, end_line=80)
|
|
343
|
+
```
|
|
344
|
+
|
|
345
|
+
`path` alone fails validation: without `row_pk` you must give positive
|
|
346
|
+
`start_line` and `end_line` with `end_line >= start_line`. `search_code` also
|
|
347
|
+
takes `path_prefix` and `lang` filters.
|
|
348
|
+
|
|
349
|
+
These two return their body unwrapped, so `result.lifecycle_id` is `None` for
|
|
350
|
+
them and only for them. Needs `fabric:query.read`.
|
|
351
|
+
|
|
352
|
+
### Check what you can do, and where you stand on quota
|
|
353
|
+
|
|
354
|
+
```python
|
|
355
|
+
gov = fabric.docs(topic="governance")
|
|
356
|
+
gov["mode"] # "auto" | "manual"
|
|
357
|
+
gov["grants"]
|
|
358
|
+
gov["quota"] # project_window_used / _max, org_window_used / _max
|
|
359
|
+
gov["rules"] # the live governance rules, in words
|
|
360
|
+
```
|
|
361
|
+
|
|
362
|
+
`docs` is the only place the gateway exposes live quota. Reading it up front
|
|
363
|
+
lets you pace writes against the real cap instead of discovering it as a
|
|
364
|
+
`QuotaExceeded`. Needs `fabric:docs.read`.
|
|
365
|
+
|
|
366
|
+
---
|
|
367
|
+
|
|
368
|
+
## Approvals
|
|
369
|
+
|
|
370
|
+
This is the part of the SDK worth reading twice.
|
|
371
|
+
|
|
372
|
+
Some calls do not return a result. They return a pending approval, because a
|
|
373
|
+
human has to look at them first. The SDK raises `ApprovalRequired` carrying
|
|
374
|
+
everything you need to finish the call later.
|
|
375
|
+
|
|
376
|
+
### What gates
|
|
377
|
+
|
|
378
|
+
Gating is **driven by the data you touch, not by the size or kind of the
|
|
379
|
+
operation**. There is no amount threshold. A call gates when:
|
|
380
|
+
|
|
381
|
+
- any graph label or argument it touches contains `pii`, `sensitive`,
|
|
382
|
+
`confidential` or `personal` — substring match, case-insensitive; or
|
|
383
|
+
- the asset it touches is classified restricted in OpenMetadata.
|
|
384
|
+
|
|
385
|
+
So `save_context` with a node label of `Refund` runs straight through, and the
|
|
386
|
+
same call with a label of `CustomerPersonalData` comes back as an approval. On
|
|
387
|
+
an auto-mode project this gates **everyone, admins included**.
|
|
388
|
+
|
|
389
|
+
The second gate site is narrow: a non-admin `create_ontology(mode="commit")`,
|
|
390
|
+
and only on an Advanced/manual project.
|
|
391
|
+
|
|
392
|
+
Writes are not gated for being writes. Reads are not exempt for being reads —
|
|
393
|
+
a `query_context` whose Cypher mentions a `Personal` label gates too.
|
|
394
|
+
|
|
395
|
+
Because the trigger is the data, **handle `ApprovalRequired` on every governed
|
|
396
|
+
call**, not on the one step you expect to gate. Classification is populated at
|
|
397
|
+
runtime, so a call that sailed through yesterday can start gating today with no
|
|
398
|
+
change on your side.
|
|
399
|
+
|
|
400
|
+
### The complete round trip
|
|
401
|
+
|
|
402
|
+
Three actors, and they are genuinely three: **your code** requests, **an admin**
|
|
403
|
+
decides, **your code** resumes. The credential that requested is the only one
|
|
404
|
+
that can resume.
|
|
405
|
+
|
|
406
|
+
```python
|
|
407
|
+
from wexa import Fabric, ApprovalRequired
|
|
408
|
+
|
|
409
|
+
fabric = Fabric()
|
|
410
|
+
|
|
411
|
+
# 1. The call gates. HTTP 202, no result — an ApprovalRequired instead.
|
|
412
|
+
try:
|
|
413
|
+
fabric.save_context(nodes=[
|
|
414
|
+
{"label": "CustomerPersonalData", "key": "id",
|
|
415
|
+
"properties": {"id": "cust_8841", "email": "ada@example.com"}},
|
|
416
|
+
])
|
|
417
|
+
except ApprovalRequired as e:
|
|
418
|
+
print(e.approval_id) # apr_000012
|
|
419
|
+
print(e.resume_token) # rtok_000012_17... single use, keep it safe
|
|
420
|
+
print(e.lifecycle_id) # the lifecycle that parked
|
|
421
|
+
token = e.resume_token
|
|
422
|
+
```
|
|
423
|
+
|
|
424
|
+
```python
|
|
425
|
+
# 2. A human decides. The deciding credential must hold an admin role —
|
|
426
|
+
# OWNER / ORG_ADMIN / PROJECT_ADMIN. Usually it happens in the console.
|
|
427
|
+
admin = Fabric(api_key="fab_sk_admin_...")
|
|
428
|
+
[a for a in admin.approvals("pending")]
|
|
429
|
+
# [{'id': 'apr_000012', 'tool': 'save-context', 'what': '...',
|
|
430
|
+
# 'requested_by': 'usr_311', 'status': 'pending', ...}]
|
|
431
|
+
admin.approve("apr_000012")
|
|
432
|
+
```
|
|
433
|
+
|
|
434
|
+
A non-admin credential calling `approve()` gets
|
|
435
|
+
`ForbiddenError: approver must be an admin role` (HTTP 403). The role
|
|
436
|
+
check is the whole check — the gateway records `decided_by` but does not compare
|
|
437
|
+
it to `requested_by`, so an admin approving their own request is not blocked by
|
|
438
|
+
the API. If your policy needs that, enforce it on your side.
|
|
439
|
+
|
|
440
|
+
```python
|
|
441
|
+
# 3. Your code resumes. Send ONLY the token — the engine substitutes the
|
|
442
|
+
# approved arguments, so anything else you pass is ignored.
|
|
443
|
+
result = fabric.save_context(resume_token=token)
|
|
444
|
+
print(result) # the write landed
|
|
445
|
+
print(result.lifecycle_id)
|
|
446
|
+
```
|
|
447
|
+
|
|
448
|
+
The resume must come from the **same user and project** that requested it. An
|
|
449
|
+
admin cannot resume on your behalf, and the approved arguments win — you cannot
|
|
450
|
+
amend a call at approval time.
|
|
451
|
+
|
|
452
|
+
Approvals expire. The default TTL is 24 hours; after that the request goes to
|
|
453
|
+
`expired` and the token is dead.
|
|
454
|
+
|
|
455
|
+
### Blocking instead of handling it yourself
|
|
456
|
+
|
|
457
|
+
If your process can afford to sit and wait, opt in and the client polls for you:
|
|
458
|
+
|
|
459
|
+
```python
|
|
460
|
+
result = fabric.save_context(
|
|
461
|
+
nodes=[{"label": "CustomerPersonalData", "key": "id", "properties": {...}}],
|
|
462
|
+
wait_for_approval=True,
|
|
463
|
+
approval_timeout=600, # seconds; raises ApprovalError past this
|
|
464
|
+
poll=5, # seconds between checks
|
|
465
|
+
)
|
|
466
|
+
```
|
|
467
|
+
|
|
468
|
+
This does the whole round trip: gate, poll `approvals()`, resume with the token.
|
|
469
|
+
A rejection or expiry raises `ApprovalError`.
|
|
470
|
+
|
|
471
|
+
### Resuming from a durable pipeline
|
|
472
|
+
|
|
473
|
+
`wait_for_approval=True` blocks in-process, so a crash or a deploy while a human
|
|
474
|
+
is deliberating loses an approval that was actually granted. If that matters,
|
|
475
|
+
checkpoint the token before you block, and resume later — possibly from another
|
|
476
|
+
process:
|
|
477
|
+
|
|
478
|
+
```python
|
|
479
|
+
try:
|
|
480
|
+
fabric.save_context(nodes=[...])
|
|
481
|
+
except ApprovalRequired as e:
|
|
482
|
+
checkpoint(e.approval_id, e.resume_token) # durable, before you wait
|
|
483
|
+
|
|
484
|
+
# ... minutes or hours later, a different process ...
|
|
485
|
+
fabric.save_context(resume_token=load_token(), retry=True)
|
|
486
|
+
```
|
|
487
|
+
|
|
488
|
+
Passing `resume_token=` takes exactly the same code path as
|
|
489
|
+
`wait_for_approval`, so everything below behaves identically either way.
|
|
490
|
+
|
|
491
|
+
### A failed resume is not always safe to retry
|
|
492
|
+
|
|
493
|
+
The token is single-use, and the gateway redeems it **partway** through the
|
|
494
|
+
lifecycle:
|
|
495
|
+
|
|
496
|
+
```
|
|
497
|
+
S2 scope → S3 quota → REDEEM TOKEN → S4 validate → S5 policy → S8 execute
|
|
498
|
+
```
|
|
499
|
+
|
|
500
|
+
A refusal before redemption leaves the token intact. A refusal after redemption
|
|
501
|
+
has burned it: the write did not land, and re-running needs a *new* human
|
|
502
|
+
approval. Every exception carries `resume_spent` to tell the two apart.
|
|
503
|
+
|
|
504
|
+
```python
|
|
505
|
+
from wexa import WexaError
|
|
506
|
+
|
|
507
|
+
try:
|
|
508
|
+
fabric.save_context(resume_token=token, retry=True)
|
|
509
|
+
except WexaError as e:
|
|
510
|
+
if e.resume_spent:
|
|
511
|
+
alert_human(e.lifecycle_id) # approval consumed, write did NOT land
|
|
512
|
+
else:
|
|
513
|
+
pass # nothing consumed, safe to re-run as-is
|
|
514
|
+
```
|
|
515
|
+
|
|
516
|
+
| Refusal on resume | Stage | `resume_spent` | What to do |
|
|
517
|
+
|---|---|---|---|
|
|
518
|
+
| `QuotaExceeded` (429) | S3, before redemption | `False` | Re-send the same token |
|
|
519
|
+
| `ValidationError` (400/422) | S4, after redemption | `True` | Needs a new approval |
|
|
520
|
+
| `PolicyDenied` (403) | S5, after redemption | `True` | Needs a new approval |
|
|
521
|
+
| `UpstreamError` (502) | S8, after redemption | `True` | Needs a new approval |
|
|
522
|
+
|
|
523
|
+
`retry=True` covers the resume, but only for `QuotaExceeded` — the one stage
|
|
524
|
+
that refuses before redemption. Retrying anything later would re-send a spent
|
|
525
|
+
token and earn a misleading `ApprovalError: resume rejected: approval not
|
|
526
|
+
found`, burying the real error. Replaying a token you already spent gets you
|
|
527
|
+
that same message.
|
|
528
|
+
|
|
529
|
+
---
|
|
530
|
+
|
|
531
|
+
## Reference
|
|
532
|
+
|
|
533
|
+
### Methods
|
|
534
|
+
|
|
535
|
+
| Area | Methods | Grant |
|
|
536
|
+
|---|---|---|
|
|
537
|
+
| Graph read | `query_context` | `fabric:query.read` |
|
|
538
|
+
| Graph write | `save_context`, `create_ontology` | `fabric:ontology.write` |
|
|
539
|
+
| Code | `search_code`, `fetch_code` | `fabric:query.read` |
|
|
540
|
+
| Docs | `docs` | `fabric:docs.read` |
|
|
541
|
+
| Connectors | `connector_read` | `fabric:query.read` |
|
|
542
|
+
| Execution | `run_agent`, `run_process_flow` | `fabric:agent.run` |
|
|
543
|
+
| Orchestrate read | `get_process_flow`, `get_execution`, `list_skills`, `knowledge_base_retrieve` | `fabric:orchestrate.read` |
|
|
544
|
+
| Orchestrate write | `create_process_flow`, `update_process_flow` | `fabric:orchestrate.write` |
|
|
545
|
+
| Governance | `whoami`, `approvals`, `approve`, `reject`, `lifecycle` | — |
|
|
546
|
+
|
|
547
|
+
Two more grants exist for planes this client does not wrap:
|
|
548
|
+
`fabric:catalog.read` / `fabric:catalog.write` (catalog endpoints) and
|
|
549
|
+
`fabric:codesync.write` (the `/v1/codesync/*` ingest plane).
|
|
550
|
+
|
|
551
|
+
Grants live in the token's `scope` claim. Missing one fails at S2 with the exact
|
|
552
|
+
string you need:
|
|
553
|
+
|
|
554
|
+
```
|
|
555
|
+
ForbiddenError: token missing required grant "fabric:ontology.write"
|
|
556
|
+
```
|
|
557
|
+
|
|
558
|
+
`fabric.docs(topic="governance")["grants"]` lists what you actually hold.
|
|
559
|
+
|
|
560
|
+
`call()` reaches any tool by name and accepts the gateway's own hyphenated
|
|
561
|
+
spelling, so these three are the same call:
|
|
562
|
+
|
|
563
|
+
```python
|
|
564
|
+
fabric.run_process_flow(process_flow_id="pf_1", goal="reconcile")
|
|
565
|
+
fabric.call("run_process_flow", process_flow_id="pf_1", goal="reconcile")
|
|
566
|
+
fabric.call("run-process-flow", process_flow_id="pf_1", goal="reconcile")
|
|
567
|
+
```
|
|
568
|
+
|
|
569
|
+
### Errors
|
|
570
|
+
|
|
571
|
+
Every failure is typed by the stage that refused it and carries `trace_id`, and
|
|
572
|
+
usually `lifecycle_id`. Quote both in a support request.
|
|
573
|
+
|
|
574
|
+
`e.stage` is set only when the gateway returned an `S…` code — `S2:resolve-scope`
|
|
575
|
+
through `S8:execute`. Refusals that happen outside the lifecycle (`401`, the
|
|
576
|
+
`forbidden` 403 from `approve()`, `404`, `409`, `unconfigured`) carry
|
|
577
|
+
`stage=None`, and a 401 predates the lifecycle entirely so there is no
|
|
578
|
+
`lifecycle_id` to quote either. The stage column below is the conceptual stage,
|
|
579
|
+
not a promise about `e.stage`.
|
|
580
|
+
|
|
581
|
+
```python
|
|
582
|
+
from wexa import PolicyDenied, ForbiddenError, ValidationError, QuotaExceeded
|
|
583
|
+
|
|
584
|
+
try:
|
|
585
|
+
fabric.run_agent(agentflow_id="af_1", goal="summarize last week")
|
|
586
|
+
except PolicyDenied as e: # S5 — a policy rule refused it
|
|
587
|
+
print(e.stage, e.lifecycle_id)
|
|
588
|
+
except ForbiddenError: # S2 — the token lacks the grant
|
|
589
|
+
...
|
|
590
|
+
except ValidationError: # S4/S7 — the request is malformed
|
|
591
|
+
...
|
|
592
|
+
except QuotaExceeded: # S3 — over the window
|
|
593
|
+
...
|
|
594
|
+
```
|
|
595
|
+
|
|
596
|
+
| Exception | HTTP | Stage | Means | Do |
|
|
597
|
+
|---|---|---|---|---|
|
|
598
|
+
| `AuthError` | 401 | S1 | `invalid_token`, `unauthorized` — key wrong, revoked or expired | Refresh the key, then one retry |
|
|
599
|
+
| `ForbiddenError` | 403 | S2 | Token lacks the grant, or scope is invalid | Mint a key with the named grant. Terminal |
|
|
600
|
+
| `PolicyDenied` | 403 | S5 | A policy rule refused the call outright | Terminal. Change what you're asking for |
|
|
601
|
+
| `ValidationError` | 400/422 | S4, S7 | Malformed body, bad Cypher, failed dry-run | Fix the payload. Terminal |
|
|
602
|
+
| `ApprovalRequired` | 202 | S6 | A human must decide first | See [Approvals](#approvals) |
|
|
603
|
+
| `ApprovalError` | 403 | S6 | Resume token bad, expired, rejected or already spent | Needs a new approval |
|
|
604
|
+
| `QuotaExceeded` | 429 | S3 | Over 120/min project or 600/min org | Back off and retry |
|
|
605
|
+
| `NotFound` | 404 | — | No such approval, flow or execution in your org | Terminal |
|
|
606
|
+
| `ConflictError` | 409 | — | Approval not pending, agent not ready | Re-read state, then decide |
|
|
607
|
+
| `UpstreamError` | 502 | S8 | A downstream service refused or was unreachable | Retry with backoff |
|
|
608
|
+
| `TimeoutError_` | 504 | S8 | Upstream deadline exceeded | Retry with backoff |
|
|
609
|
+
| `ConfigurationError` | 502/503 | — | `unconfigured`, `unavailable` — a deployment problem, not load | Retrying will not help. Tell an operator |
|
|
610
|
+
|
|
611
|
+
Only `UpstreamError` (so `TimeoutError_` too) and `QuotaExceeded` are retried by
|
|
612
|
+
`retry=True`. Everything else refuses identically forever.
|
|
613
|
+
|
|
614
|
+
Three inheritance traps, because `except` order decides what you catch:
|
|
615
|
+
|
|
616
|
+
- `PolicyDenied` is a `ForbiddenError`. Catch `PolicyDenied` first or a policy
|
|
617
|
+
denial looks like a missing grant.
|
|
618
|
+
- `TimeoutError_` is an `UpstreamError`, and it is *not* Python's builtin
|
|
619
|
+
`TimeoutError` — hence the underscore.
|
|
620
|
+
- `ApprovalRequired` is a `WexaError`. In a shared helper, catch it **before**
|
|
621
|
+
`except WexaError` or pending approvals get logged as failures.
|
|
622
|
+
|
|
623
|
+
`WexaError` only covers responses the gateway actually sent. An unreachable
|
|
624
|
+
workspace, DNS that does not resolve or a TLS failure never reaches the gateway
|
|
625
|
+
and surfaces as urllib's own `OSError` (`URLError` is a subclass). A top-level
|
|
626
|
+
handler needs both:
|
|
627
|
+
|
|
628
|
+
```python
|
|
629
|
+
try:
|
|
630
|
+
fabric.query_context(query=...)
|
|
631
|
+
except WexaError: # the gateway refused
|
|
632
|
+
...
|
|
633
|
+
except OSError: # never got there
|
|
634
|
+
...
|
|
635
|
+
```
|
|
636
|
+
|
|
637
|
+
### Results
|
|
638
|
+
|
|
639
|
+
A **tool call** returns a `dict` subclass with the correlation ids attached
|
|
640
|
+
rather than nested, so you can treat it as the payload and still trace it. The
|
|
641
|
+
governance methods (`whoami`, `approvals`, `approve`, `reject`, `lifecycle`)
|
|
642
|
+
return plain dicts and lists — no `.lifecycle_id` on those.
|
|
643
|
+
|
|
644
|
+
```python
|
|
645
|
+
r = fabric.docs(topic="governance")
|
|
646
|
+
r["mode"]
|
|
647
|
+
r.lifecycle_id # None for search_code and fetch_code only
|
|
648
|
+
r.trace_id
|
|
649
|
+
```
|
|
650
|
+
|
|
651
|
+
The client sends a W3C `traceparent` on every request and reads `X-Trace-Id`
|
|
652
|
+
back, so correlation works even where telemetry is switched off.
|
|
653
|
+
|
|
654
|
+
`fabric.lifecycle(lifecycle_id)` returns the full per-stage record for a call:
|
|
655
|
+
what each of the ten stages saw and decided.
|
|
656
|
+
|
|
657
|
+
### Retries
|
|
658
|
+
|
|
659
|
+
**Nothing retries unless you ask.** The gateway has no idempotency key, so a
|
|
660
|
+
retried `run_agent` starts a second real run.
|
|
661
|
+
|
|
662
|
+
```python
|
|
663
|
+
fabric.query_context(query=..., retry=True) # safe: it's a read
|
|
664
|
+
```
|
|
665
|
+
|
|
666
|
+
`retry=True` uses full jitter, capped at the 60-second quota window, because
|
|
667
|
+
tool routes send no `Retry-After` and no `X-RateLimit-*` headers and the limiter
|
|
668
|
+
is per-pod — the client cannot know the real ceiling. Attempt count comes from
|
|
669
|
+
the constructor.
|
|
670
|
+
|
|
671
|
+
### Constructor
|
|
672
|
+
|
|
673
|
+
```python
|
|
674
|
+
Fabric(
|
|
675
|
+
workspace=None, # or WEXA_WORKSPACE
|
|
676
|
+
api_key=None, # or WEXA_API_KEY. Bearer JWT or API key
|
|
677
|
+
timeout=70, # seconds. Governed tool routes cap near 60s upstream
|
|
678
|
+
retries=3, # attempts when retry=True; ignored otherwise
|
|
679
|
+
)
|
|
680
|
+
```
|
|
681
|
+
|
|
682
|
+
Missing workspace or key raises `ConfigurationError` immediately, before any
|
|
683
|
+
network call.
|
|
684
|
+
|
|
685
|
+
### The ten stages
|
|
686
|
+
|
|
687
|
+
Useful when you are reading an error, not before.
|
|
688
|
+
|
|
689
|
+
| Stage | Name | Refuses with |
|
|
690
|
+
|---|---|---|
|
|
691
|
+
| S1 | authenticate | `AuthError` |
|
|
692
|
+
| S2 | resolve-scope | `ForbiddenError` |
|
|
693
|
+
| S3 | rate-quota | `QuotaExceeded` |
|
|
694
|
+
| S4 | validate-input | `ValidationError` |
|
|
695
|
+
| S5 | policy | `PolicyDenied` |
|
|
696
|
+
| S6 | approval | `ApprovalRequired`, `ApprovalError` |
|
|
697
|
+
| S7 | dry-run | `ValidationError` |
|
|
698
|
+
| S8 | execute | `UpstreamError`, `TimeoutError_` |
|
|
699
|
+
| S9 | post-process | — |
|
|
700
|
+
| S10 | record | — |
|
|
701
|
+
|
|
702
|
+
Two things about S7 that surprise people. It runs **after** approval, not
|
|
703
|
+
before — so a dry-run failure on a gated call has already spent the token. And
|
|
704
|
+
it produces a **one-line human-readable summary string**, not a structured
|
|
705
|
+
before/after diff. There is no client-side dry-run switch; the stage is
|
|
706
|
+
server-driven, and its output shows up in the lifecycle record.
|
|
707
|
+
|
|
708
|
+
Quota (S3) is a fixed 60-second window: 120 requests per minute per project,
|
|
709
|
+
600 per minute per org.
|
|
710
|
+
|
|
711
|
+
---
|
|
712
|
+
|
|
713
|
+
## Edge cases
|
|
714
|
+
|
|
715
|
+
### Arguments the server owns
|
|
716
|
+
|
|
717
|
+
The gateway binds these from your token scope, so the client refuses them before
|
|
718
|
+
building the request:
|
|
719
|
+
|
|
720
|
+
```
|
|
721
|
+
project_id projectID organization_id executed_by
|
|
722
|
+
```
|
|
723
|
+
|
|
724
|
+
`query_context` rejects a mismatching `project_id` outright; the others were
|
|
725
|
+
silently overwritten, which is worse — your value accepted and ignored. Both
|
|
726
|
+
become one clear client-side `ValidationError` instead.
|
|
727
|
+
|
|
728
|
+
### Unknown argument keys are dropped silently
|
|
729
|
+
|
|
730
|
+
A misspelled key is a 200 that wrote nothing, not an error. The two shapes worth
|
|
731
|
+
double-checking:
|
|
732
|
+
|
|
733
|
+
```python
|
|
734
|
+
# create_ontology — domain is required; it's nodes/relationships, not entities
|
|
735
|
+
fabric.create_ontology(
|
|
736
|
+
domain="risk",
|
|
737
|
+
nodes=[{"name": "Vendor", "label": "Vendor", "primaryKey": "vendor_id"}],
|
|
738
|
+
relationships=[{"name": "uses", "fromNode": "Vendor", "toNode": "Service"}],
|
|
739
|
+
)
|
|
740
|
+
|
|
741
|
+
# save_context — relationship endpoints are {label, key, value} objects
|
|
742
|
+
fabric.save_context(
|
|
743
|
+
nodes=[{"label": "Vendor", "key": "vendor_id", "properties": {"vendor_id": "v1"}}],
|
|
744
|
+
relationships=[{"type": "USES",
|
|
745
|
+
"from": {"label": "Vendor", "key": "vendor_id", "value": "v1"},
|
|
746
|
+
"to": {"label": "Service", "key": "svc_id", "value": "s1"}}],
|
|
747
|
+
)
|
|
748
|
+
```
|
|
749
|
+
|
|
750
|
+
### Project mode decides your ingestion path
|
|
751
|
+
|
|
752
|
+
A project is in Simple/auto or Advanced/manual mode, and the mode — not your
|
|
753
|
+
arguments — decides how ingestion works. The two halves are mutually exclusive:
|
|
754
|
+
|
|
755
|
+
| | Simple / auto | Advanced / manual |
|
|
756
|
+
|---|---|---|
|
|
757
|
+
| `save_context` | works | fails validation outright |
|
|
758
|
+
| `create_ontology(mode="commit")` | applies, never gated | gated for a non-admin |
|
|
759
|
+
| PII/sensitive gate | gates **everyone**, admins included | admins bypass, others gated |
|
|
760
|
+
|
|
761
|
+
**Check the mode before you write anything.** A pipeline that commits an
|
|
762
|
+
ontology under human approval and then calls `save_context` will spend a real
|
|
763
|
+
person's approval on a run that cannot finish.
|
|
764
|
+
|
|
765
|
+
Note the diagonal: on the only mode where `save_context` works, the ontology
|
|
766
|
+
gate cannot fire — so the data-classification rule is the *sole* gate in play,
|
|
767
|
+
and it attaches to calls by their data, not by their name. That is the concrete
|
|
768
|
+
reason to route every governed call through one shared helper that handles
|
|
769
|
+
`ApprovalRequired`.
|
|
770
|
+
|
|
771
|
+
```python
|
|
772
|
+
fabric.docs(topic="governance")["mode"] # "auto" | "manual"
|
|
773
|
+
```
|
|
774
|
+
|
|
775
|
+
### Why isn't my call gating?
|
|
776
|
+
|
|
777
|
+
On a bare dev stack, usually because no gate is armed. The classification rule
|
|
778
|
+
needs OpenMetadata **configured** — `OM_SYNC_ENABLED=true` plus a host and a
|
|
779
|
+
project id. The token is not checked when arming, and the host need not be
|
|
780
|
+
reachable: the bridge is installed before any sync runs, and the label check is
|
|
781
|
+
a plain substring test that needs no synced data, so a dead OM host still gates.
|
|
782
|
+
With no OM configured at all, the rule is inert. Combined with the ontology gate
|
|
783
|
+
being short-circuited in auto mode, that leaves no gate able to fire and
|
|
784
|
+
`ApprovalRequired` unreachable.
|
|
785
|
+
|
|
786
|
+
To arm it against a host that does not exist:
|
|
787
|
+
|
|
788
|
+
```
|
|
789
|
+
OM_SYNC_ENABLED=true OM_HOST=http://127.0.0.1:1 OM_SYNC_PROJECT_ID=…
|
|
790
|
+
```
|
|
791
|
+
|
|
792
|
+
That is a deployment state, not a bug in your code, and no code change on your
|
|
793
|
+
side will alter it.
|
|
794
|
+
|
|
795
|
+
### Not covered yet
|
|
796
|
+
|
|
797
|
+
- **OAuth 2.1.** API keys and Bearer JWTs only for now.
|
|
798
|
+
- **`create-agent`.** The handler exists in the gateway but is not registered,
|
|
799
|
+
so it is reachable over neither REST nor MCP. Build agents into a process flow
|
|
800
|
+
manifest instead.
|
|
801
|
+
- **Chat completions.** `/v1/agents/{id}/chat/completions` is OpenAI-shaped —
|
|
802
|
+
point the `openai` package at your API base. It has its own per-key rate limit
|
|
803
|
+
that does send `Retry-After`, does not support streaming, and can block for
|
|
804
|
+
several minutes.
|
|
805
|
+
- Catalog and code-sync endpoints, an async client, pagination helpers.
|
|
806
|
+
|
|
807
|
+
---
|
|
808
|
+
|
|
809
|
+
## What has been verified against a running gateway
|
|
810
|
+
|
|
811
|
+
Confirmed live — SDK method → route → auth → lifecycle → handler. Exercised on
|
|
812
|
+
the tools named below, **not** on all fifteen: eight of them
|
|
813
|
+
(`list_skills`, the four `*_process_flow` methods, `get_execution`,
|
|
814
|
+
`knowledge_base_retrieve`, `connector_read`) have no REST route on `dev` yet, and
|
|
815
|
+
REST is this client's only transport — so they cannot have been confirmed
|
|
816
|
+
through it. They are routed by the `pr/gateway-orchestrate-rest` change; until
|
|
817
|
+
that lands, treat them as unproven.
|
|
818
|
+
|
|
819
|
+
- `whoami` and `docs` return real scope, grant, mode and quota payloads.
|
|
820
|
+
- The full approval round trip. A `save_context` with an ordinary node label
|
|
821
|
+
runs straight through; one with a `CustomerPersonalData` label returns 202
|
|
822
|
+
with `{approval_id, resume_token, lifecycle_id}`; the request appears in
|
|
823
|
+
`approvals()`; `approve()` succeeds from an admin credential; the resume with
|
|
824
|
+
only the token lands the write.
|
|
825
|
+
- Approving is gated on **role, not on being a different person**: a non-admin
|
|
826
|
+
credential gets `forbidden / "approver must be an admin role"`. This is not
|
|
827
|
+
separation of duties — an admin approving her own request was verified to
|
|
828
|
+
succeed (`requested_by == decided_by`, status `approved`), because
|
|
829
|
+
`approval.decide()` records the decider and never compares it to the
|
|
830
|
+
requester. Enforce requester ≠ approver in your own code if you need it.
|
|
831
|
+
- Resume tokens are single-use. A resume that fails after redemption reports
|
|
832
|
+
`resume_spent = True`, and replaying that token yields
|
|
833
|
+
`ApprovalError: resume rejected: approval not found`.
|
|
834
|
+
- Typed error mapping, including `ValidationError` carrying a real
|
|
835
|
+
`lifecycle_id`, and the S2 missing-grant message.
|
|
836
|
+
|
|
837
|
+
Still source-derived only:
|
|
838
|
+
|
|
839
|
+
- `create_ontology(mode="commit")` gating for a non-admin on a manual project.
|
|
840
|
+
- The 429 boundary in practice. The limits themselves are configuration:
|
|
841
|
+
120/min project, 600/min org, fixed 60s window.
|
|
842
|
+
- The response bodies of `run_process_flow` and `get_execution`. The argument
|
|
843
|
+
shapes above come from the handlers' input schemas, not from an observed round
|
|
844
|
+
trip.
|
|
845
|
+
|
|
846
|
+
---
|
|
847
|
+
|
|
848
|
+
## Development
|
|
849
|
+
|
|
850
|
+
```bash
|
|
851
|
+
python3 wexa.py # offline self-check of the approval flow
|
|
852
|
+
WEXA_WORKSPACE=... WEXA_API_KEY=... python3 examples/status.py
|
|
853
|
+
```
|
|
854
|
+
|
|
855
|
+
`wexa.py`'s `demo()` covers the branches that actually have logic: the 202 →
|
|
856
|
+
approve → resume round trip, the resume retry boundary, tool routing, and the
|
|
857
|
+
server-bound argument guard.
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
wexa.py,sha256=n-08ylbO7ubFnRGTqH1AeVHWgmb33sSgV5SvXOho6Tk,17953
|
|
2
|
+
wexa-0.1.0.dist-info/METADATA,sha256=F3NZDpz1pls6OFT_KJpkvhnAMWPCska1-hVVgf4vH9g,32473
|
|
3
|
+
wexa-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
4
|
+
wexa-0.1.0.dist-info/top_level.txt,sha256=8gXuUbFLusL0kx7BNIGyy6EntG0dmE0pI36Ju-3Ooz0,5
|
|
5
|
+
wexa-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
wexa
|
wexa.py
ADDED
|
@@ -0,0 +1,480 @@
|
|
|
1
|
+
"""Python client for the Wexa Fabric api-gateway.
|
|
2
|
+
|
|
3
|
+
from wexa import Fabric
|
|
4
|
+
|
|
5
|
+
fabric = Fabric(workspace="http://localhost:7004", api_key="fab_sk_...")
|
|
6
|
+
rows = fabric.query_context(query="MATCH (n) RETURN n", limit=10)
|
|
7
|
+
|
|
8
|
+
Stdlib only — no dependency conflicts in the caller's environment.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import random
|
|
16
|
+
import time
|
|
17
|
+
import urllib.error
|
|
18
|
+
import urllib.request
|
|
19
|
+
import uuid
|
|
20
|
+
|
|
21
|
+
__version__ = "0.1.0"
|
|
22
|
+
|
|
23
|
+
TOOLS = {
|
|
24
|
+
"query_context": "query-context",
|
|
25
|
+
"save_context": "save-context",
|
|
26
|
+
"create_ontology": "ontology",
|
|
27
|
+
"run_agent": "run-agent",
|
|
28
|
+
"search_code": "search-code",
|
|
29
|
+
"fetch_code": "fetch-code",
|
|
30
|
+
"docs": "docs",
|
|
31
|
+
"create_process_flow": "create-process-flow",
|
|
32
|
+
"run_process_flow": "run-process-flow",
|
|
33
|
+
"update_process_flow": "update-process-flow",
|
|
34
|
+
"get_process_flow": "get-process-flow",
|
|
35
|
+
"get_execution": "get-execution",
|
|
36
|
+
"list_skills": "list-skills",
|
|
37
|
+
"knowledge_base_retrieve": "knowledge-base-retrieve",
|
|
38
|
+
"connector_read": "connector-read",
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
SERVER_BOUND = frozenset(
|
|
42
|
+
{"project_id", "projectID", "organization_id", "executed_by"}
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
TIMEOUT_TOOL = 70
|
|
46
|
+
|
|
47
|
+
QUOTA_WINDOW = 60
|
|
48
|
+
|
|
49
|
+
class WexaError(Exception):
|
|
50
|
+
"""Base. Carries whatever correlation ids the gateway returned."""
|
|
51
|
+
|
|
52
|
+
def __init__(self, message, *, stage=None, status=None, lifecycle_id=None,
|
|
53
|
+
trace_id=None, raw=None):
|
|
54
|
+
self.stage = stage
|
|
55
|
+
self.status = status
|
|
56
|
+
self.lifecycle_id = lifecycle_id
|
|
57
|
+
self.trace_id = trace_id
|
|
58
|
+
self.raw = raw
|
|
59
|
+
self.resume_spent = False
|
|
60
|
+
ids = " ".join(f"{k}={v}" for k, v in
|
|
61
|
+
(("lifecycle_id", lifecycle_id), ("trace_id", trace_id)) if v)
|
|
62
|
+
super().__init__(f"{message} [{ids}]" if ids else message)
|
|
63
|
+
|
|
64
|
+
class AuthError(WexaError):
|
|
65
|
+
"""401 — invalid_token, unauthorized."""
|
|
66
|
+
|
|
67
|
+
class ForbiddenError(WexaError):
|
|
68
|
+
"""403 — forbidden, S2:resolve-scope."""
|
|
69
|
+
|
|
70
|
+
class PolicyDenied(ForbiddenError):
|
|
71
|
+
"""403 S5:policy — a policy rule refused the call."""
|
|
72
|
+
|
|
73
|
+
class ValidationError(WexaError):
|
|
74
|
+
"""400/422 — S4:validate-input, S7:dry-run, invalid_request."""
|
|
75
|
+
|
|
76
|
+
class ApprovalRequired(WexaError):
|
|
77
|
+
"""202 — a human must approve before this call proceeds."""
|
|
78
|
+
|
|
79
|
+
def __init__(self, approval, tool):
|
|
80
|
+
self.approval_id = approval.get("approval_id")
|
|
81
|
+
self.resume_token = approval.get("resume_token")
|
|
82
|
+
self.tool = tool
|
|
83
|
+
super().__init__(
|
|
84
|
+
approval.get("message", "approval required"),
|
|
85
|
+
status=202,
|
|
86
|
+
lifecycle_id=approval.get("lifecycle_id"),
|
|
87
|
+
raw=approval,
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
class ApprovalError(WexaError):
|
|
91
|
+
"""403 S6:approval — resume token bad, expired, or already spent."""
|
|
92
|
+
|
|
93
|
+
class QuotaExceeded(WexaError):
|
|
94
|
+
"""429 S3:rate-quota."""
|
|
95
|
+
|
|
96
|
+
class NotFound(WexaError):
|
|
97
|
+
"""404."""
|
|
98
|
+
|
|
99
|
+
class ConflictError(WexaError):
|
|
100
|
+
"""409 — approval not pending, agent not ready."""
|
|
101
|
+
|
|
102
|
+
class UpstreamError(WexaError):
|
|
103
|
+
"""502 — S8:execute, *_unreachable."""
|
|
104
|
+
|
|
105
|
+
class TimeoutError_(UpstreamError):
|
|
106
|
+
"""504 — upstream deadline exceeded."""
|
|
107
|
+
|
|
108
|
+
class ConfigurationError(WexaError):
|
|
109
|
+
"""502/503 — unconfigured, unavailable. Deployment problem, not load."""
|
|
110
|
+
|
|
111
|
+
_ERRORS = {
|
|
112
|
+
"invalid_token": AuthError,
|
|
113
|
+
"unauthorized": AuthError,
|
|
114
|
+
"forbidden": ForbiddenError,
|
|
115
|
+
"S2:resolve-scope": ForbiddenError,
|
|
116
|
+
"S5:policy": PolicyDenied,
|
|
117
|
+
"S6:approval": ApprovalError,
|
|
118
|
+
"S3:rate-quota": QuotaExceeded,
|
|
119
|
+
"S4:validate-input": ValidationError,
|
|
120
|
+
"S7:dry-run": ValidationError,
|
|
121
|
+
"invalid_request": ValidationError,
|
|
122
|
+
"invalid_json": ValidationError,
|
|
123
|
+
"not_found": NotFound,
|
|
124
|
+
"conflict": ConflictError,
|
|
125
|
+
"S8:execute": UpstreamError,
|
|
126
|
+
"harness_unreachable": UpstreamError,
|
|
127
|
+
"data_service_unreachable": UpstreamError,
|
|
128
|
+
"upstream_unreachable": UpstreamError,
|
|
129
|
+
"identity_unavailable": UpstreamError,
|
|
130
|
+
"revoke_failed": UpstreamError,
|
|
131
|
+
"unconfigured": ConfigurationError,
|
|
132
|
+
"unavailable": ConfigurationError,
|
|
133
|
+
"oauth_unconfigured": ConfigurationError,
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
_STATUS = {
|
|
137
|
+
400: ValidationError,
|
|
138
|
+
401: AuthError,
|
|
139
|
+
403: ForbiddenError,
|
|
140
|
+
404: NotFound,
|
|
141
|
+
409: ConflictError,
|
|
142
|
+
422: ValidationError,
|
|
143
|
+
429: QuotaExceeded,
|
|
144
|
+
504: TimeoutError_,
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
RETRYABLE = (UpstreamError, QuotaExceeded)
|
|
148
|
+
|
|
149
|
+
RESUME_RETRYABLE = (QuotaExceeded,)
|
|
150
|
+
|
|
151
|
+
class Fabric:
|
|
152
|
+
"""Client for one project. Scope is pinned into the credential at mint time
|
|
153
|
+
and never widens, so a client cannot switch projects — make another."""
|
|
154
|
+
|
|
155
|
+
def __init__(self, workspace=None, api_key=None, timeout=TIMEOUT_TOOL,
|
|
156
|
+
retries=3):
|
|
157
|
+
workspace = (workspace or os.environ.get("WEXA_WORKSPACE", "")).rstrip("/")
|
|
158
|
+
api_key = api_key or os.environ.get("WEXA_API_KEY", "")
|
|
159
|
+
if not workspace or not api_key:
|
|
160
|
+
raise ConfigurationError("workspace and api_key are required "
|
|
161
|
+
"(or WEXA_WORKSPACE / WEXA_API_KEY)")
|
|
162
|
+
self.workspace = workspace
|
|
163
|
+
self.api_key = api_key
|
|
164
|
+
self.timeout = timeout
|
|
165
|
+
self.retries = retries
|
|
166
|
+
info = self._request("GET", f"{workspace}/v1/connection-info", auth=False)[1]
|
|
167
|
+
self.base = info["api"]["base_url"].rstrip("/")
|
|
168
|
+
self.issuer = info.get("issuer", workspace)
|
|
169
|
+
|
|
170
|
+
def _request(self, method, url, body=None, auth=True, timeout=None):
|
|
171
|
+
"""Returns (status, decoded_body). Raises only on transport failure."""
|
|
172
|
+
data = json.dumps(body).encode() if body is not None else None
|
|
173
|
+
req = urllib.request.Request(url, data=data, method=method)
|
|
174
|
+
req.add_header("Accept", "application/json")
|
|
175
|
+
req.add_header("User-Agent", f"wexa-python/{__version__}")
|
|
176
|
+
if data:
|
|
177
|
+
req.add_header("Content-Type", "application/json")
|
|
178
|
+
if auth:
|
|
179
|
+
req.add_header("Authorization", f"Bearer {self.api_key}")
|
|
180
|
+
req.add_header("traceparent",
|
|
181
|
+
f"00-{uuid.uuid4().hex}-{uuid.uuid4().hex[:16]}-01")
|
|
182
|
+
try:
|
|
183
|
+
with urllib.request.urlopen(req, timeout=timeout or self.timeout) as res:
|
|
184
|
+
return res.status, self._decode(res.read()), self._lower(res.headers)
|
|
185
|
+
except urllib.error.HTTPError as e:
|
|
186
|
+
return e.code, self._decode(e.read()), self._lower(e.headers)
|
|
187
|
+
|
|
188
|
+
@staticmethod
|
|
189
|
+
def _lower(headers):
|
|
190
|
+
return {k.lower(): v for k, v in headers.items()}
|
|
191
|
+
|
|
192
|
+
@staticmethod
|
|
193
|
+
def _decode(raw):
|
|
194
|
+
if not raw:
|
|
195
|
+
return {}
|
|
196
|
+
try:
|
|
197
|
+
return json.loads(raw)
|
|
198
|
+
except ValueError:
|
|
199
|
+
return {"error": "invalid_response", "error_description": raw[:400].decode(
|
|
200
|
+
"utf-8", "replace")}
|
|
201
|
+
|
|
202
|
+
def _raise(self, status, body, headers):
|
|
203
|
+
code = body.get("error", "unknown")
|
|
204
|
+
cls = _ERRORS.get(code) or _STATUS.get(status) or (
|
|
205
|
+
UpstreamError if status >= 500 else WexaError)
|
|
206
|
+
raise cls(
|
|
207
|
+
body.get("error_description") or code,
|
|
208
|
+
stage=code if code.startswith("S") else None,
|
|
209
|
+
status=status,
|
|
210
|
+
lifecycle_id=body.get("lifecycle_id"),
|
|
211
|
+
trace_id=headers.get("x-trace-id"),
|
|
212
|
+
raw=body,
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
def _call_once(self, path, args, timeout=None):
|
|
216
|
+
status, body, headers = self._request(
|
|
217
|
+
"POST", f"{self.base}/{path}", body=args, timeout=timeout)
|
|
218
|
+
if status == 200:
|
|
219
|
+
return _Result(body.get("result", body), body.get("lifecycle_id"),
|
|
220
|
+
headers.get("x-trace-id"))
|
|
221
|
+
if status == 202:
|
|
222
|
+
raise ApprovalRequired(body.get("approval", {}), path)
|
|
223
|
+
self._raise(status, body, headers)
|
|
224
|
+
|
|
225
|
+
def call(self, tool, *, retry=False, wait_for_approval=False,
|
|
226
|
+
approval_timeout=600, poll=5, **args):
|
|
227
|
+
"""Invoke a tool. `retry` defaults off: the gateway has no idempotency
|
|
228
|
+
key, so a retried run_agent starts a second real run."""
|
|
229
|
+
path = TOOLS.get(tool.replace("-", "_"), tool)
|
|
230
|
+
bad = SERVER_BOUND & args.keys()
|
|
231
|
+
if bad:
|
|
232
|
+
raise ValidationError(
|
|
233
|
+
f"{sorted(bad)} are bound server-side from your token scope; "
|
|
234
|
+
"sending them is rejected")
|
|
235
|
+
attempts = self.retries if retry else 1
|
|
236
|
+
token = args.pop("resume_token", None)
|
|
237
|
+
attempt = 0
|
|
238
|
+
while True:
|
|
239
|
+
try:
|
|
240
|
+
return self._call_once(
|
|
241
|
+
path, {"resume_token": token} if token else args)
|
|
242
|
+
except ApprovalRequired as pending:
|
|
243
|
+
if not wait_for_approval or token:
|
|
244
|
+
raise
|
|
245
|
+
self._await_approval(pending.approval_id, approval_timeout, poll)
|
|
246
|
+
token = pending.resume_token
|
|
247
|
+
continue
|
|
248
|
+
except WexaError as e:
|
|
249
|
+
if token:
|
|
250
|
+
e.resume_spent = not isinstance(e, RESUME_RETRYABLE)
|
|
251
|
+
if e.resume_spent:
|
|
252
|
+
raise
|
|
253
|
+
elif not isinstance(e, RETRYABLE):
|
|
254
|
+
raise
|
|
255
|
+
attempt += 1
|
|
256
|
+
if attempt >= attempts:
|
|
257
|
+
raise
|
|
258
|
+
time.sleep(self._backoff(attempt - 1))
|
|
259
|
+
|
|
260
|
+
def _backoff(self, attempt):
|
|
261
|
+
return random.uniform(0, min(2 ** attempt, QUOTA_WINDOW))
|
|
262
|
+
|
|
263
|
+
def _await_approval(self, approval_id, timeout, poll):
|
|
264
|
+
deadline = time.monotonic() + timeout
|
|
265
|
+
while time.monotonic() < deadline:
|
|
266
|
+
for a in self.approvals() or []:
|
|
267
|
+
if a.get("id") == approval_id:
|
|
268
|
+
if a.get("status") == "approved":
|
|
269
|
+
return
|
|
270
|
+
if a.get("status") in ("rejected", "expired"):
|
|
271
|
+
raise ApprovalError(f"approval {a['status']}",
|
|
272
|
+
status=403, raw=a)
|
|
273
|
+
time.sleep(poll)
|
|
274
|
+
raise ApprovalError(f"approval {approval_id} not decided within {timeout}s")
|
|
275
|
+
|
|
276
|
+
def whoami(self):
|
|
277
|
+
return self._get("/whoami")
|
|
278
|
+
|
|
279
|
+
def approvals(self, status=None):
|
|
280
|
+
path = f"/approvals?status={status}" if status else "/approvals"
|
|
281
|
+
return self._get(path).get("approvals") or []
|
|
282
|
+
|
|
283
|
+
def approve(self, approval_id):
|
|
284
|
+
return self._post(f"/approvals/{approval_id}/approve")
|
|
285
|
+
|
|
286
|
+
def reject(self, approval_id):
|
|
287
|
+
return self._post(f"/approvals/{approval_id}/reject")
|
|
288
|
+
|
|
289
|
+
def lifecycle(self, lifecycle_id):
|
|
290
|
+
return self._get(f"/lifecycles/{lifecycle_id}")
|
|
291
|
+
|
|
292
|
+
def _get(self, path):
|
|
293
|
+
status, body, headers = self._request("GET", f"{self.base}{path}")
|
|
294
|
+
if status != 200:
|
|
295
|
+
self._raise(status, body, headers)
|
|
296
|
+
return body
|
|
297
|
+
|
|
298
|
+
def _post(self, path, body=None):
|
|
299
|
+
status, b, headers = self._request("POST", f"{self.base}{path}", body=body or {})
|
|
300
|
+
if status not in (200, 201):
|
|
301
|
+
self._raise(status, b, headers)
|
|
302
|
+
return b
|
|
303
|
+
|
|
304
|
+
class _Result(dict):
|
|
305
|
+
"""The tool's result, with the correlation ids attached rather than nested."""
|
|
306
|
+
|
|
307
|
+
def __init__(self, payload, lifecycle_id, trace_id):
|
|
308
|
+
super().__init__(payload if isinstance(payload, dict) else {"value": payload})
|
|
309
|
+
self.lifecycle_id = lifecycle_id
|
|
310
|
+
self.trace_id = trace_id
|
|
311
|
+
|
|
312
|
+
def _bind(name):
|
|
313
|
+
def method(self, **kw):
|
|
314
|
+
return self.call(name, **kw)
|
|
315
|
+
method.__name__ = name
|
|
316
|
+
return method
|
|
317
|
+
|
|
318
|
+
for _name in TOOLS:
|
|
319
|
+
setattr(Fabric, _name, _bind(_name))
|
|
320
|
+
|
|
321
|
+
def demo():
|
|
322
|
+
"""Offline check of the branch that actually has logic: 202 -> approve ->
|
|
323
|
+
resume. Everything else is one HTTP call and a dict lookup."""
|
|
324
|
+
calls = []
|
|
325
|
+
|
|
326
|
+
class FakeFabric(Fabric):
|
|
327
|
+
def __init__(self):
|
|
328
|
+
self.base, self.retries, self.timeout = "", 1, 1
|
|
329
|
+
self.api_key, self.workspace = "fab_sk_x", ""
|
|
330
|
+
self._approved = False
|
|
331
|
+
|
|
332
|
+
def _call_once(self, path, args):
|
|
333
|
+
calls.append(args)
|
|
334
|
+
if "resume_token" not in args:
|
|
335
|
+
raise ApprovalRequired(
|
|
336
|
+
{"approval_id": "apr_1", "resume_token": "rtok_1",
|
|
337
|
+
"message": "needs approval", "lifecycle_id": "lc_1"}, path)
|
|
338
|
+
return _Result({"ok": True}, "lc_1", None)
|
|
339
|
+
|
|
340
|
+
def approvals(self, status=None):
|
|
341
|
+
self._approved = True
|
|
342
|
+
return [{"id": "apr_1", "status": "approved" if self._approved else "pending"}]
|
|
343
|
+
|
|
344
|
+
f = FakeFabric()
|
|
345
|
+
|
|
346
|
+
try:
|
|
347
|
+
f.call("save_context", nodes=[])
|
|
348
|
+
raise AssertionError("should have raised")
|
|
349
|
+
except ApprovalRequired as e:
|
|
350
|
+
assert e.approval_id == "apr_1" and e.resume_token == "rtok_1"
|
|
351
|
+
assert e.lifecycle_id == "lc_1"
|
|
352
|
+
|
|
353
|
+
calls.clear()
|
|
354
|
+
out = f.call("save_context", nodes=[{"label": "X"}], wait_for_approval=True, poll=0)
|
|
355
|
+
assert out["ok"] is True
|
|
356
|
+
assert calls[-1] == {"resume_token": "rtok_1"}, calls[-1]
|
|
357
|
+
|
|
358
|
+
try:
|
|
359
|
+
f.call("query_context", query="MATCH (n) RETURN n", project_id="p1")
|
|
360
|
+
raise AssertionError("should have raised")
|
|
361
|
+
except ValidationError as e:
|
|
362
|
+
assert "project_id" in str(e)
|
|
363
|
+
|
|
364
|
+
f2 = FakeFabric()
|
|
365
|
+
for status, want in [(404, NotFound), (403, ForbiddenError), (429, QuotaExceeded),
|
|
366
|
+
(502, UpstreamError), (504, TimeoutError_)]:
|
|
367
|
+
body = Fabric._decode(b"404 page not found")
|
|
368
|
+
assert body["error"] == "invalid_response", body
|
|
369
|
+
try:
|
|
370
|
+
f2._raise(status, body, {"x-trace-id": "tr_1"})
|
|
371
|
+
raise AssertionError("should have raised")
|
|
372
|
+
except WexaError as e:
|
|
373
|
+
assert type(e) is want, (status, type(e))
|
|
374
|
+
assert e.trace_id == "tr_1", e.trace_id
|
|
375
|
+
assert not isinstance(NotFound("x"), RETRYABLE), "a typo'd tool must not retry"
|
|
376
|
+
assert Fabric._lower({"X-Trace-Id": "t"}) == {"x-trace-id": "t"}
|
|
377
|
+
|
|
378
|
+
sent = []
|
|
379
|
+
|
|
380
|
+
class RoutingFabric(Fabric):
|
|
381
|
+
def __init__(self):
|
|
382
|
+
self.base, self.retries, self.timeout = "https://gw/v1", 1, 1
|
|
383
|
+
self.api_key, self.workspace = "fab_sk_x", ""
|
|
384
|
+
|
|
385
|
+
def _request(self, method, url, body=None, auth=True, timeout=None):
|
|
386
|
+
sent.append((method, url, body))
|
|
387
|
+
return 200, {"execution": {"status": "completed"}}, {}
|
|
388
|
+
|
|
389
|
+
g = RoutingFabric()
|
|
390
|
+
|
|
391
|
+
out = g.run_process_flow(process_flow_id="pf_1", goal="reconcile invoices")
|
|
392
|
+
assert sent[-1] == ("POST", "https://gw/v1/run-process-flow",
|
|
393
|
+
{"process_flow_id": "pf_1",
|
|
394
|
+
"goal": "reconcile invoices"}), sent[-1]
|
|
395
|
+
assert out["execution"]["status"] == "completed"
|
|
396
|
+
|
|
397
|
+
g.get_execution(execution_id="ex_1")
|
|
398
|
+
assert sent[-1][1] == "https://gw/v1/get-execution", sent[-1]
|
|
399
|
+
|
|
400
|
+
g.call("create-ontology", domain="risk")
|
|
401
|
+
assert sent[-1][1] == "https://gw/v1/ontology", sent[-1]
|
|
402
|
+
|
|
403
|
+
try:
|
|
404
|
+
g.create_process_flow(name="nightly", project_id="p1")
|
|
405
|
+
raise AssertionError("should have raised")
|
|
406
|
+
except ValidationError as e:
|
|
407
|
+
assert "project_id" in str(e)
|
|
408
|
+
|
|
409
|
+
assert all(callable(getattr(Fabric, t, None)) for t in TOOLS)
|
|
410
|
+
|
|
411
|
+
class ScriptedFabric(Fabric):
|
|
412
|
+
def __init__(self, responses):
|
|
413
|
+
self.base, self.retries, self.timeout = "https://gw/v1", 3, 1
|
|
414
|
+
self.api_key, self.workspace = "fab_sk_x", ""
|
|
415
|
+
self.responses, self.sent = list(responses), []
|
|
416
|
+
|
|
417
|
+
def _request(self, method, url, body=None, auth=True, timeout=None):
|
|
418
|
+
self.sent.append((method, url, body))
|
|
419
|
+
return self.responses.pop(0)
|
|
420
|
+
|
|
421
|
+
gate = (202, {"approval": {"approval_id": "a1", "resume_token": "t1",
|
|
422
|
+
"lifecycle_id": "lc"}}, {})
|
|
423
|
+
listed = (200, {"approvals": [{"id": "a1", "status": "approved"}]}, {})
|
|
424
|
+
real_sleep, time.sleep = time.sleep, lambda _s: None
|
|
425
|
+
try:
|
|
426
|
+
f2 = ScriptedFabric([
|
|
427
|
+
gate, listed,
|
|
428
|
+
(429, {"error": "S3:rate-quota", "error_description": "slow down"}, {}),
|
|
429
|
+
(200, {"lifecycle_id": "lc", "result": {"saved": 1}}, {}),
|
|
430
|
+
])
|
|
431
|
+
out = f2.call("save_context", nodes=[{"a": 1}], retry=True,
|
|
432
|
+
wait_for_approval=True, poll=0)
|
|
433
|
+
assert out["saved"] == 1, out
|
|
434
|
+
resumes = [b for _m, u, b in f2.sent if u.endswith("/save-context")]
|
|
435
|
+
assert resumes[1:] == [{"resume_token": "t1"},
|
|
436
|
+
{"resume_token": "t1"}], resumes
|
|
437
|
+
|
|
438
|
+
f3 = ScriptedFabric([
|
|
439
|
+
gate, listed,
|
|
440
|
+
(502, {"error": "S8:execute", "error_description": "harness down"}, {}),
|
|
441
|
+
])
|
|
442
|
+
try:
|
|
443
|
+
f3.call("save_context", nodes=[{"a": 1}], retry=True,
|
|
444
|
+
wait_for_approval=True, poll=0)
|
|
445
|
+
raise AssertionError("should have raised")
|
|
446
|
+
except UpstreamError as e:
|
|
447
|
+
assert e.resume_spent is True
|
|
448
|
+
assert len([1 for _m, u, _b in f3.sent
|
|
449
|
+
if u.endswith("/save-context")]) == 2, f3.sent
|
|
450
|
+
|
|
451
|
+
f4 = ScriptedFabric([
|
|
452
|
+
(502, {"error": "S8:execute", "error_description": "down"}, {}),
|
|
453
|
+
(200, {"lifecycle_id": "lc", "result": {"rows": []}}, {}),
|
|
454
|
+
])
|
|
455
|
+
assert f4.call("query_context", query="MATCH (n) RETURN n",
|
|
456
|
+
retry=True)["rows"] == []
|
|
457
|
+
|
|
458
|
+
quota = (429, {"error": "S3:rate-quota", "error_description": "slow"}, {})
|
|
459
|
+
f5 = ScriptedFabric([quota, quota,
|
|
460
|
+
(200, {"lifecycle_id": "lc", "result": {"saved": 1}}, {})])
|
|
461
|
+
assert f5.call("save_context", resume_token="t1", retry=True)["saved"] == 1
|
|
462
|
+
assert [b for _m, _u, b in f5.sent] == [{"resume_token": "t1"}] * 3, f5.sent
|
|
463
|
+
|
|
464
|
+
f6 = ScriptedFabric([
|
|
465
|
+
(502, {"error": "S8:execute", "error_description": "harness down"}, {}),
|
|
466
|
+
])
|
|
467
|
+
try:
|
|
468
|
+
f6.call("save_context", resume_token="t1", retry=True)
|
|
469
|
+
raise AssertionError("should have raised")
|
|
470
|
+
except UpstreamError as e:
|
|
471
|
+
assert e.resume_spent is True
|
|
472
|
+
assert len(f6.sent) == 1, f6.sent
|
|
473
|
+
finally:
|
|
474
|
+
time.sleep = real_sleep
|
|
475
|
+
|
|
476
|
+
print("ok — approval round trip, resume args, server-bound guard, "
|
|
477
|
+
"tool routing, resume retry boundary")
|
|
478
|
+
|
|
479
|
+
if __name__ == "__main__":
|
|
480
|
+
demo()
|