xmemcli 1.3.0__tar.gz → 1.3.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- xmemcli-1.3.1/.gitignore +1 -0
- {xmemcli-1.3.0 → xmemcli-1.3.1}/PKG-INFO +6 -6
- {xmemcli-1.3.0 → xmemcli-1.3.1}/README.md +5 -5
- xmemcli-1.3.1/pyproject.toml +30 -0
- {xmemcli-1.3.0 → xmemcli-1.3.1}/xmemcli/__init__.py +1 -1
- {xmemcli-1.3.0 → xmemcli-1.3.1}/xmemcli/_instance_schema.py +153 -22
- {xmemcli-1.3.0 → xmemcli-1.3.1}/xmemcli/_version.py +1 -1
- xmemcli-1.3.0/.gitignore +0 -4
- xmemcli-1.3.0/pyproject.toml +0 -22
- xmemcli-1.3.0/uv.lock +0 -8
- {xmemcli-1.3.0 → xmemcli-1.3.1}/xmemcli/_binding.py +0 -0
- {xmemcli-1.3.0 → xmemcli-1.3.1}/xmemcli/_binding_cmd.py +0 -0
- {xmemcli-1.3.0 → xmemcli-1.3.1}/xmemcli/_context_cmd.py +0 -0
- {xmemcli-1.3.0 → xmemcli-1.3.1}/xmemcli/_email_login.py +0 -0
- {xmemcli-1.3.0 → xmemcli-1.3.1}/xmemcli/_envelope.py +0 -0
- {xmemcli-1.3.0 → xmemcli-1.3.1}/xmemcli/_help.py +0 -0
- {xmemcli-1.3.0 → xmemcli-1.3.1}/xmemcli/_login.py +0 -0
- {xmemcli-1.3.0 → xmemcli-1.3.1}/xmemcli/_mcp_bridge.py +0 -0
- {xmemcli-1.3.0 → xmemcli-1.3.1}/xmemcli/_org_cluster.py +0 -0
- {xmemcli-1.3.0 → xmemcli-1.3.1}/xmemcli/_output.py +0 -0
- {xmemcli-1.3.0 → xmemcli-1.3.1}/xmemcli/_pretty.py +0 -0
- {xmemcli-1.3.0 → xmemcli-1.3.1}/xmemcli/_rc.py +0 -0
- {xmemcli-1.3.0 → xmemcli-1.3.1}/xmemcli/_text.py +0 -0
- {xmemcli-1.3.0 → xmemcli-1.3.1}/xmemcli/_trace.py +0 -0
- {xmemcli-1.3.0 → xmemcli-1.3.1}/xmemcli/_xmem_bridge.py +0 -0
- {xmemcli-1.3.0 → xmemcli-1.3.1}/xmemcli/cli.py +0 -0
xmemcli-1.3.1/.gitignore
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
.venv/
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: xmemcli
|
|
3
|
-
Version: 1.3.
|
|
3
|
+
Version: 1.3.1
|
|
4
4
|
Summary: Command-line client for the xmemory HTTP API: PKCE auth, instances, read/write, schema and XMD tools.
|
|
5
5
|
Keywords: api,cli,llm,memory,xmemory
|
|
6
6
|
Requires-Python: <4,>=3.12
|
|
@@ -128,7 +128,7 @@ xmemcli version
|
|
|
128
128
|
xmemcli auth login
|
|
129
129
|
```
|
|
130
130
|
|
|
131
|
-
Version: **1.3.
|
|
131
|
+
Version: **1.3.1**.
|
|
132
132
|
|
|
133
133
|
|
|
134
134
|
---
|
|
@@ -228,7 +228,7 @@ Each login binds its own port and names it in the login URL, so concurrent login
|
|
|
228
228
|
|
|
229
229
|
# `xmemcli` — capabilities reference
|
|
230
230
|
|
|
231
|
-
**Version:** `1.3.
|
|
231
|
+
**Version:** `1.3.1` (see `xmemcli version`)
|
|
232
232
|
|
|
233
233
|
`xmemcli` is the command-line client for the **xmemory HTTP API**. It is **human-approved for key acquisition**: an AI-assisted IDE may run reads, writes, and inventory, and may initiate `auth login`, but a key is only issued after one explicit human approval — a click in the Console UI (PKCE handoff) or an approval of a sign-in email (`--email`). Quota limits change only in the **Console UI**. The full acquisition contract is in the auth-flow reference bundled with this package.
|
|
234
234
|
|
|
@@ -472,9 +472,9 @@ learn; the CLI composes the Console's search clauses itself.
|
|
|
472
472
|
|
|
473
473
|
**Every operation row carries its internal ids.** `operations[]` rows have `operation_type`, `trace_id`, `instance_id`, `timestamp`, `status`, `is_success`, `error_code`, `session_id`, `user_id`, `user_email`, `api_key_id`, `api_key_name`, `api_key_prefix`, `time_taken_ms`, `text_snippet`, `object_names`, `field_names`.
|
|
474
474
|
|
|
475
|
-
> **Three of those never resolve for an API key**, super admin or not, and this is by design rather than a gap. `user_email` is always absent: the accounts `/users` lookup is closed to keys (an agent may learn who *it* is, never enumerate the organisation's users), so rows keep `user_id` and you resolve names yourself if you need them — and a `User:<email>` search clause matches nothing; filter by `user_id`. `api_key_name` / `api_key_prefix` resolve only for the caller's **own** keys, because `GET /api-keys` scopes a key caller to its owner's keys. So a super admin's key does *not* see literally everything its owner's browser sees: it sees the same rows and the same fields, with those three enrichments empty. Everything scope-bearing — which instances, which detail fields, `llm_calls`, xuid substitution — is identical. Detail documents add what the Console's pane shows: `source_id`, `write_mode` / `read_mode`, `scope`, `extracted_objects`, `diff_plan` (sanitised unless super admin), `written_objects`, `migration_id`, `proposal_version`, `schema_version`, `stage`, `triggered_evolution {migration_id, evolution_trace_id, update_trace_id}`, and — **unrestricted callers only** (a super admin, or a browser session on a `KINGDOM_LOCAL` Console) — `llm_calls[]` (`llm_call_id`, `gateway_request_id`, `model_type`, `provider`, …), `full_error_message`, raw token counts, unsanitised xuids, `
|
|
475
|
+
> **Three of those never resolve for an API key**, super admin or not, and this is by design rather than a gap. `user_email` is always absent: the accounts `/users` lookup is closed to keys (an agent may learn who *it* is, never enumerate the organisation's users), so rows keep `user_id` and you resolve names yourself if you need them — and a `User:<email>` search clause matches nothing; filter by `user_id`. `api_key_name` / `api_key_prefix` resolve only for the caller's **own** keys, because `GET /api-keys` scopes a key caller to its owner's keys. So a super admin's key does *not* see literally everything its owner's browser sees: it sees the same rows and the same fields, with those three enrichments empty. Everything scope-bearing — which instances, which detail fields, `llm_calls`, xuid substitution — is identical. Detail documents add what the Console's pane shows: `source_id`, `write_mode` / `read_mode`, `scope`, `extracted_objects`, `diff_plan` (sanitised unless super admin), `written_objects`, `migration_id`, `proposal_version`, `schema_version`, `stage`, `triggered_evolution {migration_id, evolution_trace_id, update_trace_id}`, `sql_queries`, and — **unrestricted callers only** (a super admin, or a browser session on a `KINGDOM_LOCAL` Console) — `llm_calls[]` (`llm_call_id`, `gateway_request_id`, `model_type`, `provider`, …), `full_error_message`, raw token counts, unsanitised xuids, `ddl_queries`, and `schema_generation`.
|
|
476
476
|
|
|
477
|
-
**A scoped key gets `[]` for `
|
|
477
|
+
**A scoped key gets `[]` for `ddl_queries` / `schema_generation`, not the rows.** Those two come from `instance_create_ddl_queries` and `schema_generate_*`, which carry no instance ownership column — `schema_generate_*`'s trace id is client-supplied — so they cannot be scoped in SQL and are served only to callers with no instance restriction at all. This is a deliberate fail-closed choice, not a bug to work around: an empty array means "not available to this credential", never "this operation ran none". `llm_calls[]` behaves the same way and always has. `sql_queries` is not among them: `read_sql_queries` records the owning instance, so every caller who may open a read gets its SQL, attributed to that read even when another instance reused the trace id. **Xuids are hidden for every non-super-admin**, including on writes that carry no identity map — the substitution is done by the Console, server-side, so a caller with no browser sees `#hidden` rather than a raw id.
|
|
478
478
|
|
|
479
479
|
**Who sees what — the Console decides; `auth status` tells you which view to expect.**
|
|
480
480
|
|
|
@@ -630,7 +630,7 @@ Some commands attach a more specific `stage` (`validate`, `usage`, `limit`, `not
|
|
|
630
630
|
|
|
631
631
|
**Envelope errors** (HTTP **200** with non-empty `errors[]`) are not HTTP 401/403; they typically exit **3** with `stage: "request"`. Two exceptions. `QUOTA_EXCEEDED` exits **6** with `stage: "quota"`, the same as it does on a real HTTP 402 — the keepalive flattening does not change the exit code — and this applies to every command that classifies the error, not just `write` and `read`. `WRITE_STILL_PROCESSING` exits **8** with `stage: "processing"` and is not a failure at all; unlike the quota rung this one is **`write`-only**, because its guidance is to poll `write-status`, which is meaningless for any other verb — elsewhere it falls through to **3**/`request`.
|
|
632
632
|
|
|
633
|
-
> **Known bug.** Some subcommands in the `instance
|
|
633
|
+
> **Known bug.** Some subcommands in the `instance` and `schema` groups — `instance get` and `schema get` among them — unwrap the response outside their error handling, so an envelope error never reaches the classifier and escapes as an uncaught exception: a traceback and shell exit **1**, which is not in the table above. Those report **1**, not the **6** this section describes, until that is fixed. It is not whole groups: `instance instructions`, for example, reaches the classifier and exits **6** as documented, and every heartbeat-wrapped command (`instance create`, `xmd generate`, `xmd enhance`, `schema update`, `schema dry-run`) unwraps inside its handler and exits **6**.
|
|
634
634
|
|
|
635
635
|
**Breaking change in 0.0.5:** HTTP **403** previously exited **4** (`auth`); it now exits **3** (`permission`). Scripts that treated exit **4** as “any auth-layer failure” must check `stage` or handle exit **3** for permission denials.
|
|
636
636
|
|
|
@@ -120,7 +120,7 @@ xmemcli version
|
|
|
120
120
|
xmemcli auth login
|
|
121
121
|
```
|
|
122
122
|
|
|
123
|
-
Version: **1.3.
|
|
123
|
+
Version: **1.3.1**.
|
|
124
124
|
|
|
125
125
|
|
|
126
126
|
---
|
|
@@ -220,7 +220,7 @@ Each login binds its own port and names it in the login URL, so concurrent login
|
|
|
220
220
|
|
|
221
221
|
# `xmemcli` — capabilities reference
|
|
222
222
|
|
|
223
|
-
**Version:** `1.3.
|
|
223
|
+
**Version:** `1.3.1` (see `xmemcli version`)
|
|
224
224
|
|
|
225
225
|
`xmemcli` is the command-line client for the **xmemory HTTP API**. It is **human-approved for key acquisition**: an AI-assisted IDE may run reads, writes, and inventory, and may initiate `auth login`, but a key is only issued after one explicit human approval — a click in the Console UI (PKCE handoff) or an approval of a sign-in email (`--email`). Quota limits change only in the **Console UI**. The full acquisition contract is in the auth-flow reference bundled with this package.
|
|
226
226
|
|
|
@@ -464,9 +464,9 @@ learn; the CLI composes the Console's search clauses itself.
|
|
|
464
464
|
|
|
465
465
|
**Every operation row carries its internal ids.** `operations[]` rows have `operation_type`, `trace_id`, `instance_id`, `timestamp`, `status`, `is_success`, `error_code`, `session_id`, `user_id`, `user_email`, `api_key_id`, `api_key_name`, `api_key_prefix`, `time_taken_ms`, `text_snippet`, `object_names`, `field_names`.
|
|
466
466
|
|
|
467
|
-
> **Three of those never resolve for an API key**, super admin or not, and this is by design rather than a gap. `user_email` is always absent: the accounts `/users` lookup is closed to keys (an agent may learn who *it* is, never enumerate the organisation's users), so rows keep `user_id` and you resolve names yourself if you need them — and a `User:<email>` search clause matches nothing; filter by `user_id`. `api_key_name` / `api_key_prefix` resolve only for the caller's **own** keys, because `GET /api-keys` scopes a key caller to its owner's keys. So a super admin's key does *not* see literally everything its owner's browser sees: it sees the same rows and the same fields, with those three enrichments empty. Everything scope-bearing — which instances, which detail fields, `llm_calls`, xuid substitution — is identical. Detail documents add what the Console's pane shows: `source_id`, `write_mode` / `read_mode`, `scope`, `extracted_objects`, `diff_plan` (sanitised unless super admin), `written_objects`, `migration_id`, `proposal_version`, `schema_version`, `stage`, `triggered_evolution {migration_id, evolution_trace_id, update_trace_id}`, and — **unrestricted callers only** (a super admin, or a browser session on a `KINGDOM_LOCAL` Console) — `llm_calls[]` (`llm_call_id`, `gateway_request_id`, `model_type`, `provider`, …), `full_error_message`, raw token counts, unsanitised xuids, `
|
|
467
|
+
> **Three of those never resolve for an API key**, super admin or not, and this is by design rather than a gap. `user_email` is always absent: the accounts `/users` lookup is closed to keys (an agent may learn who *it* is, never enumerate the organisation's users), so rows keep `user_id` and you resolve names yourself if you need them — and a `User:<email>` search clause matches nothing; filter by `user_id`. `api_key_name` / `api_key_prefix` resolve only for the caller's **own** keys, because `GET /api-keys` scopes a key caller to its owner's keys. So a super admin's key does *not* see literally everything its owner's browser sees: it sees the same rows and the same fields, with those three enrichments empty. Everything scope-bearing — which instances, which detail fields, `llm_calls`, xuid substitution — is identical. Detail documents add what the Console's pane shows: `source_id`, `write_mode` / `read_mode`, `scope`, `extracted_objects`, `diff_plan` (sanitised unless super admin), `written_objects`, `migration_id`, `proposal_version`, `schema_version`, `stage`, `triggered_evolution {migration_id, evolution_trace_id, update_trace_id}`, `sql_queries`, and — **unrestricted callers only** (a super admin, or a browser session on a `KINGDOM_LOCAL` Console) — `llm_calls[]` (`llm_call_id`, `gateway_request_id`, `model_type`, `provider`, …), `full_error_message`, raw token counts, unsanitised xuids, `ddl_queries`, and `schema_generation`.
|
|
468
468
|
|
|
469
|
-
**A scoped key gets `[]` for `
|
|
469
|
+
**A scoped key gets `[]` for `ddl_queries` / `schema_generation`, not the rows.** Those two come from `instance_create_ddl_queries` and `schema_generate_*`, which carry no instance ownership column — `schema_generate_*`'s trace id is client-supplied — so they cannot be scoped in SQL and are served only to callers with no instance restriction at all. This is a deliberate fail-closed choice, not a bug to work around: an empty array means "not available to this credential", never "this operation ran none". `llm_calls[]` behaves the same way and always has. `sql_queries` is not among them: `read_sql_queries` records the owning instance, so every caller who may open a read gets its SQL, attributed to that read even when another instance reused the trace id. **Xuids are hidden for every non-super-admin**, including on writes that carry no identity map — the substitution is done by the Console, server-side, so a caller with no browser sees `#hidden` rather than a raw id.
|
|
470
470
|
|
|
471
471
|
**Who sees what — the Console decides; `auth status` tells you which view to expect.**
|
|
472
472
|
|
|
@@ -622,7 +622,7 @@ Some commands attach a more specific `stage` (`validate`, `usage`, `limit`, `not
|
|
|
622
622
|
|
|
623
623
|
**Envelope errors** (HTTP **200** with non-empty `errors[]`) are not HTTP 401/403; they typically exit **3** with `stage: "request"`. Two exceptions. `QUOTA_EXCEEDED` exits **6** with `stage: "quota"`, the same as it does on a real HTTP 402 — the keepalive flattening does not change the exit code — and this applies to every command that classifies the error, not just `write` and `read`. `WRITE_STILL_PROCESSING` exits **8** with `stage: "processing"` and is not a failure at all; unlike the quota rung this one is **`write`-only**, because its guidance is to poll `write-status`, which is meaningless for any other verb — elsewhere it falls through to **3**/`request`.
|
|
624
624
|
|
|
625
|
-
> **Known bug.** Some subcommands in the `instance
|
|
625
|
+
> **Known bug.** Some subcommands in the `instance` and `schema` groups — `instance get` and `schema get` among them — unwrap the response outside their error handling, so an envelope error never reaches the classifier and escapes as an uncaught exception: a traceback and shell exit **1**, which is not in the table above. Those report **1**, not the **6** this section describes, until that is fixed. It is not whole groups: `instance instructions`, for example, reaches the classifier and exits **6** as documented, and every heartbeat-wrapped command (`instance create`, `xmd generate`, `xmd enhance`, `schema update`, `schema dry-run`) unwraps inside its handler and exits **6**.
|
|
626
626
|
|
|
627
627
|
**Breaking change in 0.0.5:** HTTP **403** previously exited **4** (`auth`); it now exits **3** (`permission`). Scripts that treated exit **4** as “any auth-layer failure” must check `stage` or handle exit **3** for permission denials.
|
|
628
628
|
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "xmemcli"
|
|
3
|
+
version = "1.3.1"
|
|
4
|
+
description = "Command-line client for the xmemory HTTP API: PKCE auth, instances, read/write, schema and XMD tools."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.12,<4"
|
|
7
|
+
dependencies = []
|
|
8
|
+
keywords = ["xmemory", "cli", "api", "memory", "llm"]
|
|
9
|
+
|
|
10
|
+
[project.scripts]
|
|
11
|
+
xmemcli = "xmemcli.cli:main"
|
|
12
|
+
|
|
13
|
+
[build-system]
|
|
14
|
+
requires = ["hatchling"]
|
|
15
|
+
build-backend = "hatchling.build"
|
|
16
|
+
|
|
17
|
+
[tool.hatch.build.targets.wheel]
|
|
18
|
+
packages = ["xmemcli"]
|
|
19
|
+
|
|
20
|
+
# The sdist names what it carries rather than what it excludes. Hatchling otherwise ships every
|
|
21
|
+
# file in the staging tree, which is how the smoke run's uv.lock reached PyPI in 1.3.0. The one
|
|
22
|
+
# file this list does not govern is .gitignore: hatchling force-includes the nearest one it finds
|
|
23
|
+
# walking up from the project root until a .git entry, so an sdist always carries one. The
|
|
24
|
+
# staging therefore writes its own, one line, or the archive would carry the monorepo's instead.
|
|
25
|
+
[tool.hatch.build.targets.sdist]
|
|
26
|
+
only-include = ["xmemcli", "README.md", "pyproject.toml"]
|
|
27
|
+
|
|
28
|
+
[tool.pytest.ini_options]
|
|
29
|
+
pythonpath = ["."]
|
|
30
|
+
testpaths = ["tests"]
|
|
@@ -23,6 +23,23 @@ try:
|
|
|
23
23
|
except ImportError:
|
|
24
24
|
_yaml = None
|
|
25
25
|
|
|
26
|
+
# ``generate_schema`` is a long-running LLM call bounded server-side by ``execution.api_timeout``.
|
|
27
|
+
# That key is not one number: the code default and every deployment config say 900.0, while
|
|
28
|
+
# ``config.yml.template`` says 1200.0. This is the largest checked-in bound plus slack for a
|
|
29
|
+
# heartbeat-buffered response to arrive after the server's own deadline fires. Revisit when a
|
|
30
|
+
# deployment sets ``api_timeout`` above 1200.0.
|
|
31
|
+
#
|
|
32
|
+
# It is an idle bound, not a whole-call deadline: ``urlopen`` applies the timeout to each socket
|
|
33
|
+
# operation, and the server streams a heartbeat chunk every ``keepalive_interval_s`` once the
|
|
34
|
+
# grace period passes, so a healthy call never approaches it however long it runs. What it does
|
|
35
|
+
# guard is a proxy that buffers the whitespace chunks — then no bytes arrive until the body does,
|
|
36
|
+
# and a value below the server's deadline would be what aborts the call.
|
|
37
|
+
#
|
|
38
|
+
# Named for the endpoint, not for one command: `xmd generate` and `xmd enhance` both POST to it and
|
|
39
|
+
# are bounded by the same server deadline, so a bound that fitted only one of them would leave the
|
|
40
|
+
# other aborting early against the very same call.
|
|
41
|
+
_GENERATE_SCHEMA_TIMEOUT_S = 1260.0
|
|
42
|
+
|
|
26
43
|
|
|
27
44
|
def _yaml_dump_or_json(value: Any) -> str:
|
|
28
45
|
if _yaml is not None:
|
|
@@ -196,19 +213,90 @@ def _parse_schema_mutation_error_body(body: str) -> tuple[str, str, dict[str, An
|
|
|
196
213
|
return "", body, {}
|
|
197
214
|
|
|
198
215
|
|
|
216
|
+
#: Every ``details`` list a schema-mutation 422 may carry that already names the individual
|
|
217
|
+
#: object/field/relation it is about. ``non_additive_changes``, ``uncovered_non_additive``, and
|
|
218
|
+
#: ``destructive_ops`` are ``NonAdditiveChange`` / ``DestructiveOpSummary`` dumps; ``missing_additive_ops``
|
|
219
|
+
#: and ``uncovered_additive_ops`` are ``MigrationOp`` dumps instead (see ``xmmodel.additive_plan_synthesizer``
|
|
220
|
+
#: and ``api.schema_helpers``).
|
|
221
|
+
_REJECTED_ELEMENT_DETAIL_KEYS = (
|
|
222
|
+
"non_additive_changes",
|
|
223
|
+
"uncovered_non_additive",
|
|
224
|
+
"uncovered_additive_ops",
|
|
225
|
+
"missing_additive_ops",
|
|
226
|
+
"destructive_ops",
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _rejected_elements(details: dict[str, Any]) -> list[dict[str, Any]]:
|
|
231
|
+
"""Collect which object/field/relation each rejected element names, from ``details``.
|
|
232
|
+
|
|
233
|
+
Every list under `_REJECTED_ELEMENT_DETAIL_KEYS` the server may populate already carries
|
|
234
|
+
an identity for the thing it is about (``target``, or an ``object_name``/``field_name``/
|
|
235
|
+
``name`` pair) plus a kind and a reason string; this only keeps the fields already present,
|
|
236
|
+
it never derives or invents one. Returns ``[]`` when ``details`` carries none of them, so a
|
|
237
|
+
rejection with no per-element detail stays without a fabricated ``elements`` list.
|
|
238
|
+
"""
|
|
239
|
+
identity_fields = ("target", "name", "object_name", "field_name", "old_name", "new_name")
|
|
240
|
+
elements: list[dict[str, Any]] = []
|
|
241
|
+
for detail_key in _REJECTED_ELEMENT_DETAIL_KEYS:
|
|
242
|
+
items = details.get(detail_key)
|
|
243
|
+
if not isinstance(items, list):
|
|
244
|
+
continue
|
|
245
|
+
for item in items:
|
|
246
|
+
if not isinstance(item, dict):
|
|
247
|
+
continue
|
|
248
|
+
element: dict[str, Any] = {"detail": detail_key}
|
|
249
|
+
for field in identity_fields:
|
|
250
|
+
if item.get(field) is not None:
|
|
251
|
+
element[field] = item[field]
|
|
252
|
+
kind = item.get("kind") or item.get("op_type")
|
|
253
|
+
if kind is not None:
|
|
254
|
+
element["kind"] = kind
|
|
255
|
+
reason = item.get("message") or item.get("reason")
|
|
256
|
+
if reason is not None:
|
|
257
|
+
element["reason"] = reason
|
|
258
|
+
elements.append(element)
|
|
259
|
+
return elements
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _schema_mutation_error(message: str, *, status: int, stage: str, elements: list[dict[str, Any]]) -> CliOutput:
|
|
263
|
+
"""Build the ``OutputKind.ERROR`` payload for a rejected schema mutation.
|
|
264
|
+
|
|
265
|
+
Same shape as `make_error` plus an ``elements`` key (only when non-empty) naming which
|
|
266
|
+
object/field/relation the server rejected, so a mixed edit does not just say "rejected".
|
|
267
|
+
"""
|
|
268
|
+
payload: dict[str, Any] = {"error": message, "status": status, "stage": stage}
|
|
269
|
+
if elements:
|
|
270
|
+
payload["elements"] = elements
|
|
271
|
+
return CliOutput(OutputKind.ERROR, payload)
|
|
272
|
+
|
|
273
|
+
|
|
199
274
|
def _handle_schema_mutation_error(e: HttpStatusError, *, instance_id: str) -> tuple[CliOutput, int]:
|
|
200
275
|
m = xmem_module()
|
|
201
|
-
if e.status ==
|
|
202
|
-
|
|
276
|
+
if e.status == 404:
|
|
277
|
+
return (
|
|
278
|
+
make_error(f"instance {instance_id} not found", status=e.status, stage="request"),
|
|
279
|
+
m.EXIT_HTTP_ERROR,
|
|
280
|
+
)
|
|
281
|
+
error_type, msg, details = _parse_schema_mutation_error_body(e.body)
|
|
282
|
+
# Every schema-mutation rejection carries the same structured ``SchemaHelperErrorPayload``;
|
|
283
|
+
# only the HTTP status varies with ``error_type`` (422 for validation, 409 for
|
|
284
|
+
# ``destructive_confirmation_required`` and the other preconditions — see
|
|
285
|
+
# ``_SCHEMA_APPLY_ERROR_STATUS`` in api/accounts/instances.py — and 200 when the rejection was
|
|
286
|
+
# streamed past the keepalive grace period). Dispatch on the body, so the stage and the
|
|
287
|
+
# ``elements`` it names survive whatever status carried it.
|
|
288
|
+
if error_type or e.status == 422:
|
|
203
289
|
code = error_type.lower()
|
|
290
|
+
elements = _rejected_elements(details)
|
|
204
291
|
|
|
205
292
|
if code == "non_additive_change_requires_plan":
|
|
206
293
|
return (
|
|
207
|
-
|
|
294
|
+
_schema_mutation_error(
|
|
208
295
|
f"non-additive change requires --migration-plan "
|
|
209
296
|
f"(produce one with `{m.CLI_NAME} xmd enhance --plan-output`)",
|
|
210
297
|
status=e.status,
|
|
211
298
|
stage="plan_required",
|
|
299
|
+
elements=elements,
|
|
212
300
|
),
|
|
213
301
|
m.EXIT_HTTP_ERROR,
|
|
214
302
|
)
|
|
@@ -220,15 +308,16 @@ def _handle_schema_mutation_error(e: HttpStatusError, *, instance_id: str) -> tu
|
|
|
220
308
|
detail_bits.append(f"{key}={len(items)}")
|
|
221
309
|
suffix = f" ({', '.join(detail_bits)})" if detail_bits else ""
|
|
222
310
|
return (
|
|
223
|
-
|
|
311
|
+
_schema_mutation_error(f"{msg}{suffix}", status=e.status, stage="validate", elements=elements),
|
|
224
312
|
m.EXIT_HTTP_ERROR,
|
|
225
313
|
)
|
|
226
314
|
if code == "destructive_confirmation_required" or ("destructive" in code and "confirmation" in code):
|
|
227
315
|
return (
|
|
228
|
-
|
|
316
|
+
_schema_mutation_error(
|
|
229
317
|
"destructive change requires --confirm-destructive",
|
|
230
318
|
status=e.status,
|
|
231
319
|
stage="destructive",
|
|
320
|
+
elements=elements,
|
|
232
321
|
),
|
|
233
322
|
m.EXIT_HTTP_ERROR,
|
|
234
323
|
)
|
|
@@ -237,32 +326,49 @@ def _handle_schema_mutation_error(e: HttpStatusError, *, instance_id: str) -> tu
|
|
|
237
326
|
not error_type and "non_additive_change_requires" in msg.lower()
|
|
238
327
|
):
|
|
239
328
|
return (
|
|
240
|
-
|
|
329
|
+
_schema_mutation_error(
|
|
241
330
|
f"non-additive change requires --migration-plan "
|
|
242
331
|
f"(produce one with `{m.CLI_NAME} xmd enhance --plan-output`)",
|
|
243
332
|
status=e.status,
|
|
244
333
|
stage="plan_required",
|
|
334
|
+
elements=elements,
|
|
245
335
|
),
|
|
246
336
|
m.EXIT_HTTP_ERROR,
|
|
247
337
|
)
|
|
248
338
|
if "destructive" in code or (not error_type and "destructive" in msg.lower()):
|
|
249
339
|
return (
|
|
250
|
-
|
|
340
|
+
_schema_mutation_error(
|
|
251
341
|
"destructive change requires --confirm-destructive",
|
|
252
342
|
status=e.status,
|
|
253
343
|
stage="destructive",
|
|
344
|
+
elements=elements,
|
|
254
345
|
),
|
|
255
346
|
m.EXIT_HTTP_ERROR,
|
|
256
347
|
)
|
|
257
|
-
|
|
258
|
-
if e.status == 404:
|
|
348
|
+
stage = "validate" if e.status == 422 else "request"
|
|
259
349
|
return (
|
|
260
|
-
|
|
350
|
+
_schema_mutation_error(msg, status=e.status, stage=stage, elements=elements),
|
|
261
351
|
m.EXIT_HTTP_ERROR,
|
|
262
352
|
)
|
|
263
353
|
return http_error_output(e, context="schema mutation rejected")
|
|
264
354
|
|
|
265
355
|
|
|
356
|
+
def _streamed_rejection(item: dict[str, Any]) -> Any | None:
|
|
357
|
+
"""The ``ApiError`` a rejection streamed under HTTP 200 stands for, or ``None`` for a result.
|
|
358
|
+
|
|
359
|
+
``_schema_apply_error_response`` answers a rejected mutation with the bare
|
|
360
|
+
``SchemaHelperErrorPayload`` — ``status: "error"``, ``error_type``, ``error_message``,
|
|
361
|
+
``details`` — and its 409/422 reaches the wire only on the fast path. A mutation that outlives
|
|
362
|
+
the server's keepalive grace period comes back as HTTP 200 with that same payload as the body:
|
|
363
|
+
there is no ``errors`` array for ``_unwrap_items`` to reject, so it returns the payload as if it
|
|
364
|
+
were the result. A result is ``status: "ok"`` and never carries ``error_type``.
|
|
365
|
+
"""
|
|
366
|
+
if item.get("status") != "error" or not item.get("error_type"):
|
|
367
|
+
return None
|
|
368
|
+
m = xmem_module()
|
|
369
|
+
return m.ApiError(200, json.dumps(item), str(item.get("error_message") or item["error_type"]))
|
|
370
|
+
|
|
371
|
+
|
|
266
372
|
# The accepted values, duplicated here on purpose: this package is self-contained and
|
|
267
373
|
# published on its own, so it cannot import the server's SetupFormat. A server-side test
|
|
268
374
|
# asserts this tuple equals that enum, so the duplication cannot drift silently.
|
|
@@ -417,8 +523,7 @@ def cmd_instance_instructions(args: argparse.Namespace) -> tuple[CliOutput, int]
|
|
|
417
523
|
if e.status == 409:
|
|
418
524
|
return (
|
|
419
525
|
make_error(
|
|
420
|
-
"the instructions changed while this command was composing its edit; "
|
|
421
|
-
"read them again and reapply",
|
|
526
|
+
"the instructions changed while this command was composing its edit; read them again and reapply",
|
|
422
527
|
status=409,
|
|
423
528
|
stage="request",
|
|
424
529
|
),
|
|
@@ -702,6 +807,10 @@ def cmd_instance_create(args: argparse.Namespace) -> tuple[CliOutput, int]:
|
|
|
702
807
|
api_key,
|
|
703
808
|
timeout_s=180.0,
|
|
704
809
|
)
|
|
810
|
+
# Unwrapped inside the handler: a provision that outlives the server's keepalive grace
|
|
811
|
+
# period comes back as HTTP 200 with the failure in the envelope's `errors` array, which
|
|
812
|
+
# `_unwrap_items` raises as ApiError, and nothing above this command catches one.
|
|
813
|
+
item = m._unwrap_items(envelope)
|
|
705
814
|
except m.ApiError as e:
|
|
706
815
|
if e.status == 403:
|
|
707
816
|
limit = _extract_limit_message(e.body)
|
|
@@ -719,8 +828,7 @@ def cmd_instance_create(args: argparse.Namespace) -> tuple[CliOutput, int]:
|
|
|
719
828
|
# a schema problem the list cannot help with.
|
|
720
829
|
return (
|
|
721
830
|
make_error(
|
|
722
|
-
f"instance create rejected: {e}; "
|
|
723
|
-
f"list valid template ids with `{CLI_NAME} instance templates`",
|
|
831
|
+
f"instance create rejected: {e}; list valid template ids with `{CLI_NAME} instance templates`",
|
|
724
832
|
status=e.status,
|
|
725
833
|
stage="request",
|
|
726
834
|
),
|
|
@@ -728,7 +836,6 @@ def cmd_instance_create(args: argparse.Namespace) -> tuple[CliOutput, int]:
|
|
|
728
836
|
)
|
|
729
837
|
return http_error_output(e, context="instance create rejected")
|
|
730
838
|
|
|
731
|
-
item = m._unwrap_items(envelope)
|
|
732
839
|
curated: dict[str, Any] = {
|
|
733
840
|
"instance_id": item.get("id"),
|
|
734
841
|
"name": item.get("name"),
|
|
@@ -770,11 +877,15 @@ def cmd_xmd_generate(args: argparse.Namespace) -> tuple[CliOutput, int]:
|
|
|
770
877
|
f"{base}/clusters/{cluster_id}/instances/generate_schema",
|
|
771
878
|
payload,
|
|
772
879
|
api_key,
|
|
773
|
-
timeout_s=
|
|
880
|
+
timeout_s=_GENERATE_SCHEMA_TIMEOUT_S,
|
|
774
881
|
)
|
|
882
|
+
# Unwrapped inside the handler, as in ``cmd_xmd_enhance``: a generation that outlives the
|
|
883
|
+
# server's keepalive grace period comes back as HTTP 200 with the failure in the envelope's
|
|
884
|
+
# `errors` array, which `_unwrap_items` raises as ApiError, and nothing above this command
|
|
885
|
+
# catches one.
|
|
886
|
+
item = m._unwrap_items(envelope)
|
|
775
887
|
except m.ApiError as e:
|
|
776
888
|
return http_error_output(e, context="request rejected")
|
|
777
|
-
item = m._unwrap_items(envelope)
|
|
778
889
|
schema = item.get("data_schema") if isinstance(item, dict) else None
|
|
779
890
|
if args.output and schema is not None:
|
|
780
891
|
write_err = _write_text_file(args.output, _yaml_dump_or_json(schema), stage="generate")
|
|
@@ -808,6 +919,9 @@ def cmd_xmd_enhance(args: argparse.Namespace) -> tuple[CliOutput, int]:
|
|
|
808
919
|
return (make_error("instance id required", stage="usage"), m.EXIT_USAGE)
|
|
809
920
|
try:
|
|
810
921
|
envelope = m._get(f"{base}/instances/{instance_id}/schema", api_key)
|
|
922
|
+
# Unwrapped inside the handler: an HTTP 200 whose envelope carries `errors` raises
|
|
923
|
+
# ApiError too, and nothing above this command catches one.
|
|
924
|
+
item = m._unwrap_items(envelope)
|
|
811
925
|
except m.ApiError as e:
|
|
812
926
|
if e.status == 404:
|
|
813
927
|
return (
|
|
@@ -815,7 +929,6 @@ def cmd_xmd_enhance(args: argparse.Namespace) -> tuple[CliOutput, int]:
|
|
|
815
929
|
m.EXIT_HTTP_ERROR,
|
|
816
930
|
)
|
|
817
931
|
return http_error_output(e, context="schema get rejected")
|
|
818
|
-
item = m._unwrap_items(envelope)
|
|
819
932
|
schema = item.get("data_schema") if isinstance(item, dict) else None
|
|
820
933
|
if schema is None:
|
|
821
934
|
return (make_error("instance has no live schema to enhance", stage="usage"), m.EXIT_USAGE)
|
|
@@ -841,11 +954,15 @@ def cmd_xmd_enhance(args: argparse.Namespace) -> tuple[CliOutput, int]:
|
|
|
841
954
|
f"{base}/clusters/{cluster_id}/instances/generate_schema",
|
|
842
955
|
payload,
|
|
843
956
|
api_key,
|
|
844
|
-
timeout_s=
|
|
957
|
+
timeout_s=_GENERATE_SCHEMA_TIMEOUT_S,
|
|
845
958
|
)
|
|
959
|
+
# Unwrapped inside the handler. An enhancement long enough to outlive the server's
|
|
960
|
+
# keepalive grace period comes back as HTTP 200 with the failure in the envelope's
|
|
961
|
+
# `errors` array, which `_unwrap_items` raises as ApiError — the shape a large-schema
|
|
962
|
+
# failure actually takes, and the one this command exists to report.
|
|
963
|
+
item = m._unwrap_items(envelope)
|
|
846
964
|
except m.ApiError as e:
|
|
847
965
|
return http_error_output(e, context="request rejected")
|
|
848
|
-
item = m._unwrap_items(envelope)
|
|
849
966
|
if args.output and isinstance(item, dict) and item.get("data_schema") is not None:
|
|
850
967
|
write_err = _write_text_file(args.output, _yaml_dump_or_json(item["data_schema"]), stage="enhance")
|
|
851
968
|
if write_err is not None:
|
|
@@ -1004,9 +1121,17 @@ def cmd_schema_update(args: argparse.Namespace) -> tuple[CliOutput, int]:
|
|
|
1004
1121
|
api_key,
|
|
1005
1122
|
timeout_s=300.0,
|
|
1006
1123
|
)
|
|
1124
|
+
# Unwrapped inside the handler: a failure raised past the server's keepalive grace period
|
|
1125
|
+
# comes back as HTTP 200 with an `errors` array, which `_unwrap_items` raises as ApiError;
|
|
1126
|
+
# a *rejection* past it comes back as HTTP 200 with the bare rejection payload as the body,
|
|
1127
|
+
# which `_streamed_rejection` recognises. Either way `_handle_schema_mutation_error` turns
|
|
1128
|
+
# it into a JSON error document with a non-zero exit, not a success document or a traceback.
|
|
1129
|
+
item = m._unwrap_items(envelope)
|
|
1130
|
+
rejection = _streamed_rejection(item)
|
|
1131
|
+
if rejection is not None:
|
|
1132
|
+
raise rejection
|
|
1007
1133
|
except m.ApiError as e:
|
|
1008
1134
|
return _handle_schema_mutation_error(e, instance_id=instance_id)
|
|
1009
|
-
item = m._unwrap_items(envelope)
|
|
1010
1135
|
return (CliOutput(OutputKind.SCHEMA_UPDATE, item), m.EXIT_OK)
|
|
1011
1136
|
|
|
1012
1137
|
|
|
@@ -1031,9 +1156,15 @@ def cmd_schema_dry_run(args: argparse.Namespace) -> tuple[CliOutput, int]:
|
|
|
1031
1156
|
api_key,
|
|
1032
1157
|
timeout_s=120.0,
|
|
1033
1158
|
)
|
|
1159
|
+
# Unwrapped inside the handler, same reasoning as `cmd_schema_update`: this endpoint is
|
|
1160
|
+
# heartbeat-wrapped too, so both 200 shapes past the grace period — a raised failure in
|
|
1161
|
+
# `errors`, a rejection payload as the body — must land on `_handle_schema_mutation_error`.
|
|
1162
|
+
item = m._unwrap_items(envelope)
|
|
1163
|
+
rejection = _streamed_rejection(item)
|
|
1164
|
+
if rejection is not None:
|
|
1165
|
+
raise rejection
|
|
1034
1166
|
except m.ApiError as e:
|
|
1035
1167
|
return _handle_schema_mutation_error(e, instance_id=instance_id)
|
|
1036
|
-
item = m._unwrap_items(envelope)
|
|
1037
1168
|
return (CliOutput(OutputKind.SCHEMA_DRY_RUN, item), m.EXIT_OK)
|
|
1038
1169
|
|
|
1039
1170
|
|
|
@@ -4,7 +4,7 @@ import platform
|
|
|
4
4
|
import re
|
|
5
5
|
|
|
6
6
|
CLI_NAME = "xmemcli"
|
|
7
|
-
__version__ = "1.3.
|
|
7
|
+
__version__ = "1.3.1"
|
|
8
8
|
|
|
9
9
|
# The header this CLI sends its identity in, restated rather than imported: the published package is
|
|
10
10
|
# standalone and must not import anything outside this directory. The server's own constant is the other
|
xmemcli-1.3.0/.gitignore
DELETED
xmemcli-1.3.0/pyproject.toml
DELETED
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
[project]
|
|
2
|
-
name = "xmemcli"
|
|
3
|
-
version = "1.3.0"
|
|
4
|
-
description = "Command-line client for the xmemory HTTP API: PKCE auth, instances, read/write, schema and XMD tools."
|
|
5
|
-
readme = "README.md"
|
|
6
|
-
requires-python = ">=3.12,<4"
|
|
7
|
-
dependencies = []
|
|
8
|
-
keywords = ["xmemory", "cli", "api", "memory", "llm"]
|
|
9
|
-
|
|
10
|
-
[project.scripts]
|
|
11
|
-
xmemcli = "xmemcli.cli:main"
|
|
12
|
-
|
|
13
|
-
[build-system]
|
|
14
|
-
requires = ["hatchling"]
|
|
15
|
-
build-backend = "hatchling.build"
|
|
16
|
-
|
|
17
|
-
[tool.hatch.build.targets.wheel]
|
|
18
|
-
packages = ["xmemcli"]
|
|
19
|
-
|
|
20
|
-
[tool.pytest.ini_options]
|
|
21
|
-
pythonpath = ["."]
|
|
22
|
-
testpaths = ["tests"]
|
xmemcli-1.3.0/uv.lock
DELETED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|