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.
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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()