cortexdb-cli 0.7.1__py3-none-any.whl → 0.8.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.
- cortexdb_cli/__init__.py +1 -1
- cortexdb_cli/commands/admin.py +1 -1
- cortexdb_cli/commands/beliefs.py +65 -8
- cortexdb_cli/commands/changes.py +212 -35
- cortexdb_cli/commands/conflicts.py +6 -0
- cortexdb_cli/commands/corrections.py +514 -0
- cortexdb_cli/commands/derivation.py +1667 -0
- cortexdb_cli/commands/derivation_jobs.py +377 -0
- cortexdb_cli/commands/episodes.py +315 -11
- cortexdb_cli/commands/experience.py +96 -14
- cortexdb_cli/commands/facts.py +39 -7
- cortexdb_cli/commands/import_cmd.py +5 -1
- cortexdb_cli/commands/publish.py +1 -1
- cortexdb_cli/commands/review.py +238 -0
- cortexdb_cli/commands/rollups.py +1 -1
- cortexdb_cli/common.py +118 -3
- cortexdb_cli/errors.py +498 -5
- cortexdb_cli/harness_packs_data.py +6 -6
- cortexdb_cli/main.py +14 -1
- cortexdb_cli/output.py +107 -5
- {cortexdb_cli-0.7.1.dist-info → cortexdb_cli-0.8.0.dist-info}/METADATA +213 -6
- {cortexdb_cli-0.7.1.dist-info → cortexdb_cli-0.8.0.dist-info}/RECORD +24 -20
- {cortexdb_cli-0.7.1.dist-info → cortexdb_cli-0.8.0.dist-info}/WHEEL +0 -0
- {cortexdb_cli-0.7.1.dist-info → cortexdb_cli-0.8.0.dist-info}/entry_points.txt +0 -0
cortexdb_cli/__init__.py
CHANGED
cortexdb_cli/commands/admin.py
CHANGED
|
@@ -95,7 +95,7 @@ def admin_layers(ctx: click.Context) -> None:
|
|
|
95
95
|
|
|
96
96
|
# -- retention (unified WAL) -------------------------------------------------
|
|
97
97
|
#
|
|
98
|
-
# The operator surface over the
|
|
98
|
+
# The operator surface over the retention plane. Every write here is
|
|
99
99
|
# irreversible in effect - an applied sweep removes expired captured events,
|
|
100
100
|
# everything about them under an `erase` rule, their raw data under a
|
|
101
101
|
# `keep_learned` one - so reporting is the default on both mutating commands
|
cortexdb_cli/commands/beliefs.py
CHANGED
|
@@ -5,12 +5,20 @@ from __future__ import annotations
|
|
|
5
5
|
import click
|
|
6
6
|
|
|
7
7
|
from cortexdb_cli.client import get_client
|
|
8
|
-
from cortexdb_cli.common import
|
|
8
|
+
from cortexdb_cli.common import (
|
|
9
|
+
api_options,
|
|
10
|
+
at_least_option,
|
|
11
|
+
derived_by_option,
|
|
12
|
+
include_option,
|
|
13
|
+
include_param,
|
|
14
|
+
)
|
|
9
15
|
from cortexdb_cli.errors import handle_errors
|
|
10
16
|
from cortexdb_cli.output import (
|
|
11
17
|
console,
|
|
18
|
+
derivation_summary,
|
|
12
19
|
escape,
|
|
13
20
|
is_json_mode,
|
|
21
|
+
note_unreported_derivation,
|
|
14
22
|
print_json,
|
|
15
23
|
spinner,
|
|
16
24
|
)
|
|
@@ -40,15 +48,30 @@ def _scope_or_die(cfg: dict, scope: str | None) -> str:
|
|
|
40
48
|
@beliefs_group.command("list")
|
|
41
49
|
@click.option("--scope", "-S", help="Scope path (defaults to your default scope)")
|
|
42
50
|
@click.option("--limit", "-l", default=50, show_default=True)
|
|
51
|
+
@include_option
|
|
43
52
|
@at_least_option
|
|
44
53
|
@api_options
|
|
45
54
|
@click.pass_context
|
|
46
55
|
@handle_errors
|
|
47
|
-
def beliefs_list(
|
|
48
|
-
|
|
56
|
+
def beliefs_list(
|
|
57
|
+
ctx: click.Context,
|
|
58
|
+
scope: str | None,
|
|
59
|
+
limit: int,
|
|
60
|
+
include: tuple[str, ...],
|
|
61
|
+
at_least: str | None,
|
|
62
|
+
) -> None:
|
|
63
|
+
"""List active beliefs in a scope.
|
|
64
|
+
|
|
65
|
+
With --include derivation each belief also says how it was made,
|
|
66
|
+
computed from its supporting facts (the latest one's; unsynced when any
|
|
67
|
+
of them is). A belief with no fact support, a declared one, has no stamp.
|
|
68
|
+
"""
|
|
49
69
|
cfg = ctx.obj
|
|
50
70
|
s = _scope_or_die(cfg, scope)
|
|
51
71
|
params: dict[str, str | int] = {"scope": s, "limit": limit}
|
|
72
|
+
included = include_param(include)
|
|
73
|
+
if included:
|
|
74
|
+
params["include"] = included
|
|
52
75
|
if at_least:
|
|
53
76
|
params["at_least"] = at_least
|
|
54
77
|
with get_client(cfg) as client:
|
|
@@ -79,19 +102,26 @@ def beliefs_list(ctx: click.Context, scope: str | None, limit: int, at_least: st
|
|
|
79
102
|
table.add_column("Object", style="bold")
|
|
80
103
|
table.add_column("Conf", justify="right", width=6)
|
|
81
104
|
table.add_column("Support", justify="right", width=8)
|
|
105
|
+
if included:
|
|
106
|
+
table.add_column("Why", style="dim", max_width=48)
|
|
82
107
|
for b in items:
|
|
83
108
|
# v1 beliefs carry their triple under `claim` ({subject, predicate,
|
|
84
109
|
# object}, each term {type, value|id}); the columns used to read
|
|
85
110
|
# top-level `predicate`/`object`, which no belief has.
|
|
86
111
|
claim = b.get("claim") if isinstance(b.get("claim"), dict) else b
|
|
87
|
-
|
|
112
|
+
row = [
|
|
88
113
|
escape(str(b.get("belief_id") or b.get("id", ""))[:16]),
|
|
89
114
|
escape(_term(claim.get("predicate"))),
|
|
90
115
|
escape(_term(claim.get("object"))),
|
|
91
116
|
f"{(b.get('confidence') or 0):.0%}",
|
|
92
117
|
str(b.get("support_count") or len(b.get("supports") or [])),
|
|
93
|
-
|
|
118
|
+
]
|
|
119
|
+
if included:
|
|
120
|
+
row.append(escape(derivation_summary(b.get("derivation"))))
|
|
121
|
+
table.add_row(*row)
|
|
94
122
|
console.print(table)
|
|
123
|
+
if included:
|
|
124
|
+
note_unreported_derivation(items)
|
|
95
125
|
if isinstance(data, dict) and data.get("rollups"):
|
|
96
126
|
console.print(
|
|
97
127
|
f"[dim]{len(data['rollups'])} rollup(s) on this page; "
|
|
@@ -108,19 +138,30 @@ def beliefs_list(ctx: click.Context, scope: str | None, limit: int, at_least: st
|
|
|
108
138
|
help="Return the belief only if it lies at or under this scope (?scope=; else 404). It "
|
|
109
139
|
"routes the read on a cluster, and a break-glass read of a private region must name it.",
|
|
110
140
|
)
|
|
141
|
+
@include_option
|
|
111
142
|
@api_options
|
|
112
143
|
@click.pass_context
|
|
113
144
|
@handle_errors
|
|
114
|
-
def beliefs_why(
|
|
145
|
+
def beliefs_why(
|
|
146
|
+
ctx: click.Context, belief_id: str, scope: str | None, include: tuple[str, ...]
|
|
147
|
+
) -> None:
|
|
115
148
|
"""Show the facts + events that ground a belief.
|
|
116
149
|
|
|
150
|
+
\b
|
|
117
151
|
cortexdb beliefs why belief_01HX...
|
|
118
152
|
cortexdb beliefs why belief_01HX... --scope org:acme/team:eng
|
|
153
|
+
cortexdb beliefs why belief_01HX... --include derivation
|
|
154
|
+
|
|
155
|
+
With --include derivation the belief also carries its derivation, and
|
|
156
|
+
the terminal says it on one line under the answer.
|
|
119
157
|
"""
|
|
120
158
|
cfg = ctx.obj
|
|
121
159
|
params = {"belief_id": belief_id}
|
|
122
160
|
if scope:
|
|
123
161
|
params["scope"] = scope
|
|
162
|
+
included = include_param(include)
|
|
163
|
+
if included:
|
|
164
|
+
params["include"] = included
|
|
124
165
|
with get_client(cfg) as client:
|
|
125
166
|
if not is_json_mode(cfg):
|
|
126
167
|
with spinner("Tracing provenance..."):
|
|
@@ -128,7 +169,13 @@ def beliefs_why(ctx: click.Context, belief_id: str, scope: str | None) -> None:
|
|
|
128
169
|
else:
|
|
129
170
|
resp = client.get("/v1/beliefs/why", params=params)
|
|
130
171
|
resp.raise_for_status()
|
|
131
|
-
|
|
172
|
+
data = resp.json()
|
|
173
|
+
print_json(data)
|
|
174
|
+
if included and not is_json_mode(cfg):
|
|
175
|
+
belief = data.get("belief") if isinstance(data, dict) else None
|
|
176
|
+
if isinstance(belief, dict):
|
|
177
|
+
console.print(f"[dim]why: {escape(derivation_summary(belief.get('derivation')))}[/dim]")
|
|
178
|
+
note_unreported_derivation([belief])
|
|
132
179
|
|
|
133
180
|
|
|
134
181
|
@beliefs_group.command("create")
|
|
@@ -165,6 +212,10 @@ def beliefs_why(ctx: click.Context, belief_id: str, scope: str | None) -> None:
|
|
|
165
212
|
"key is 409 IDEMPOTENCY_CONFLICT).",
|
|
166
213
|
)
|
|
167
214
|
@click.option("--scope", "-S", help="Scope path (defaults to your default scope)")
|
|
215
|
+
@derived_by_option(
|
|
216
|
+
"A declared belief carries it in its record (below apply format 10 the server answers 503 "
|
|
217
|
+
"FORMAT_UPGRADE_PENDING), and include=derivation reads it back."
|
|
218
|
+
)
|
|
168
219
|
@api_options
|
|
169
220
|
@click.pass_context
|
|
170
221
|
@handle_errors
|
|
@@ -179,16 +230,20 @@ def beliefs_create(
|
|
|
179
230
|
provenance: tuple[str, ...],
|
|
180
231
|
idempotency_key: str | None,
|
|
181
232
|
scope: str | None,
|
|
233
|
+
derived_by: dict[str, str] | None,
|
|
182
234
|
) -> None:
|
|
183
235
|
"""Declare a belief directly (POST /v1/beliefs).
|
|
184
236
|
|
|
185
237
|
\b
|
|
186
238
|
cortexdb beliefs create "Acme is on the enterprise plan" \\
|
|
187
239
|
--confidence 0.9 --support evt_01HX... --subject Acme --predicate plan
|
|
240
|
+
cortexdb beliefs create "Bob reads at level B2" --confidence 0.8 \\
|
|
241
|
+
--subject Bob --predicate level --derived-by acme-level@1.1.0
|
|
188
242
|
|
|
189
243
|
Declaring a newer belief over the same (subject, predicate) closes the
|
|
190
244
|
previous one. Pass --idempotency-key to retry safely after an outcome
|
|
191
|
-
you did not learn (WAL_OUTCOME_UNKNOWN, a lost connection)
|
|
245
|
+
you did not learn (WAL_OUTCOME_UNKNOWN, a lost connection); the same key
|
|
246
|
+
with another --derived-by stamp, or none, is 409 IDEMPOTENCY_CONFLICT.
|
|
192
247
|
"""
|
|
193
248
|
cfg = ctx.obj
|
|
194
249
|
s = _scope_or_die(cfg, scope)
|
|
@@ -210,6 +265,8 @@ def beliefs_create(
|
|
|
210
265
|
if not idempotency_key.strip() or len(idempotency_key.encode("utf-8")) > 256:
|
|
211
266
|
raise click.UsageError("--idempotency-key is 1 to 256 bytes, not blank")
|
|
212
267
|
body["idempotency_key"] = idempotency_key
|
|
268
|
+
if derived_by:
|
|
269
|
+
body["derived_by"] = derived_by
|
|
213
270
|
with get_client(cfg) as client:
|
|
214
271
|
resp = client.post("/v1/beliefs", json=body)
|
|
215
272
|
resp.raise_for_status()
|
cortexdb_cli/commands/changes.py
CHANGED
|
@@ -19,12 +19,21 @@ applies a structure other than the one previewed (409 PREVIEW_CHANGED).
|
|
|
19
19
|
- a list of operations `{"path", "op": "put" | "delete", "record"?}`;
|
|
20
20
|
- `{"records": [...]}` (one part), or `{"parts": [{"part", "records"}]}`.
|
|
21
21
|
|
|
22
|
+
A derivation policy (`derivation_policy_v1`, CortexDB 0.15.0) is a record of
|
|
23
|
+
kind `derive` at `<scope>/attrs:derive`. An entry `{"scope": SCOPE,
|
|
24
|
+
"derive": {...}}` is the put of SCOPE's policy, `{"path":
|
|
25
|
+
"<scope>/attrs:derive", "op": "put", "record": {"tree": {"kind": "derive"},
|
|
26
|
+
"derive": {...}}}`; a record that carries `derive` and no `tree` is given
|
|
27
|
+
`{"tree": {"kind": "derive"}}`. A change set that writes a derive record
|
|
28
|
+
writes nothing else (`cortexdb derivation stage` stages one for you).
|
|
29
|
+
|
|
22
30
|
`--delete PATH` adds a delete of that path to the part. `--scope` is the
|
|
23
31
|
tenant root and defaults to the root of your default scope.
|
|
24
32
|
"""
|
|
25
33
|
|
|
26
34
|
from __future__ import annotations
|
|
27
35
|
|
|
36
|
+
import json
|
|
28
37
|
import time
|
|
29
38
|
from typing import Any
|
|
30
39
|
|
|
@@ -33,7 +42,7 @@ import click
|
|
|
33
42
|
from cortexdb_cli.client import get_client, path_segment
|
|
34
43
|
from cortexdb_cli.common import api_options, body_of, emit, read_json_file, root_or_die, send
|
|
35
44
|
from cortexdb_cli.errors import handle_errors
|
|
36
|
-
from cortexdb_cli.output import console, err_console, escape, is_json_mode, print_json
|
|
45
|
+
from cortexdb_cli.output import console, err_console, escape, is_json_mode, print_json, warning_text
|
|
37
46
|
|
|
38
47
|
#: The exit status of a change-set command whose --wait ran out while the
|
|
39
48
|
#: server still answered 202 (EX_TEMPFAIL): nothing failed, the evaluation
|
|
@@ -49,7 +58,9 @@ def changes_group() -> None:
|
|
|
49
58
|
"""Access change sets - stage, preview, activate, discard."""
|
|
50
59
|
|
|
51
60
|
|
|
52
|
-
def
|
|
61
|
+
def wait_option(fn):
|
|
62
|
+
"""`--wait SECONDS`: how long a command sends a request again while the
|
|
63
|
+
server answers an evaluation's 202."""
|
|
53
64
|
return click.option(
|
|
54
65
|
"--wait", "wait_s", type=click.FloatRange(min=0), default=DEFAULT_WAIT_S, show_default=True,
|
|
55
66
|
help="Seconds to keep sending the request while the server answers 202 (its evaluation runs "
|
|
@@ -130,15 +141,51 @@ def _records_options(fn):
|
|
|
130
141
|
return fn
|
|
131
142
|
|
|
132
143
|
|
|
144
|
+
#: The leaf of a derivation policy's record path (`<scope>/attrs:derive`).
|
|
145
|
+
DERIVE_LEAF = "attrs:derive"
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def derive_path(scope: str) -> str:
|
|
149
|
+
"""The path of SCOPE's derivation policy record."""
|
|
150
|
+
return f"{scope}/{DERIVE_LEAF}"
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def derive_record(body: Any) -> dict[str, Any]:
|
|
154
|
+
"""The registry record that carries a derive body."""
|
|
155
|
+
return {"tree": {"kind": "derive"}, "derive": body}
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def with_derive_kind(record: Any) -> Any:
|
|
159
|
+
"""`record`, with `{"tree": {"kind": "derive"}}` when it carries a derive
|
|
160
|
+
body and no `tree` (a derive record is of kind derive, and the server
|
|
161
|
+
refuses one without it)."""
|
|
162
|
+
if isinstance(record, dict) and "derive" in record and "tree" not in record:
|
|
163
|
+
return {"tree": {"kind": "derive"}, **record}
|
|
164
|
+
return record
|
|
165
|
+
|
|
166
|
+
|
|
133
167
|
def _operation(entry: Any) -> dict[str, Any]:
|
|
168
|
+
if isinstance(entry, dict) and "derive" in entry and "path" not in entry and "op" not in entry:
|
|
169
|
+
# A derivation policy entry: {"scope": the governed scope, "derive": body}.
|
|
170
|
+
scope = entry.get("scope")
|
|
171
|
+
if not isinstance(scope, str) or not scope:
|
|
172
|
+
raise click.UsageError(
|
|
173
|
+
"a derivation policy entry in --file names the scope it governs: "
|
|
174
|
+
'{"scope": SCOPE, "derive": {...}}'
|
|
175
|
+
)
|
|
176
|
+
record = {key: value for key, value in entry.items() if key != "scope"}
|
|
177
|
+
return {"path": derive_path(scope), "op": "put", "record": with_derive_kind(record)}
|
|
134
178
|
if not isinstance(entry, dict) or not entry.get("path"):
|
|
135
|
-
raise click.UsageError(
|
|
179
|
+
raise click.UsageError(
|
|
180
|
+
"each record or operation in --file needs a `path` (a derivation policy entry: "
|
|
181
|
+
"`scope` and `derive`)"
|
|
182
|
+
)
|
|
136
183
|
if "op" in entry:
|
|
137
184
|
op = {"path": entry["path"], "op": entry["op"]}
|
|
138
185
|
if "record" in entry:
|
|
139
|
-
op["record"] = entry["record"]
|
|
186
|
+
op["record"] = with_derive_kind(entry["record"])
|
|
140
187
|
return op
|
|
141
|
-
return {"path": entry["path"], "op": "put", "record": entry}
|
|
188
|
+
return {"path": entry["path"], "op": "put", "record": with_derive_kind(entry)}
|
|
142
189
|
|
|
143
190
|
|
|
144
191
|
def build_parts(doc: Any, part: int | None, deletes: tuple[str, ...], default_part: int | None) -> list[dict[str, Any]]:
|
|
@@ -170,7 +217,8 @@ def build_parts(doc: Any, part: int | None, deletes: tuple[str, ...], default_pa
|
|
|
170
217
|
return [{"part": number, "records": records}]
|
|
171
218
|
|
|
172
219
|
|
|
173
|
-
def
|
|
220
|
+
def render_state(data: dict[str, Any]) -> None:
|
|
221
|
+
"""A change set's status (a staging's, an activation's or `get`'s answer)."""
|
|
174
222
|
state = str(data.get("state", ""))
|
|
175
223
|
style = {"activated": "success", "discarded": "dim", "scheduled": "info"}.get(state, "warning")
|
|
176
224
|
parts = data.get("parts")
|
|
@@ -187,11 +235,27 @@ def _render_state(data: dict[str, Any]) -> None:
|
|
|
187
235
|
if isinstance(refusal, dict) and refusal:
|
|
188
236
|
console.print(f"[error] refused: {escape(str(refusal.get('code')))}[/error] "
|
|
189
237
|
f"[dim]{escape(', '.join(refusal.get('paths') or []))}[/dim]")
|
|
238
|
+
_render_warnings(data)
|
|
190
239
|
if data.get("consistency_token"):
|
|
191
240
|
console.print(f"[dim] consistency token: {escape(str(data['consistency_token']))}[/dim]")
|
|
192
241
|
|
|
193
242
|
|
|
194
|
-
def
|
|
243
|
+
def _render_warnings(data: dict[str, Any]) -> None:
|
|
244
|
+
"""A staging answer's or a preview page's `warnings` (since 0.15.0):
|
|
245
|
+
each companion put that keeps a stored `enrich` of on or off,
|
|
246
|
+
`{reason: "enrich_kept", path, kept}`, the first 256; absent when none.
|
|
247
|
+
Never a refusal: the change set stages and activates as it is."""
|
|
248
|
+
warnings = data.get("warnings") or []
|
|
249
|
+
for warning in warnings:
|
|
250
|
+
console.print(f"[warning] {escape(warning_text(warning))}[/warning]")
|
|
251
|
+
if any(isinstance(w, dict) and w.get("reason") == "enrich_kept" for w in warnings):
|
|
252
|
+
console.print("[dim] (a companion put that does not name enrich keeps the stored value; "
|
|
253
|
+
"only enrich: inherit clears it)[/dim]")
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def render_preview(data: dict[str, Any]) -> None:
|
|
257
|
+
"""A preview page: its access diff, its hash and, for a derivation change
|
|
258
|
+
set, its `derivation` section."""
|
|
195
259
|
summary = data.get("summary") or {}
|
|
196
260
|
kind = "widens" if data.get("widens") else ("narrows" if data.get("narrows") else "changes no access")
|
|
197
261
|
console.print(f"[header]Preview[/header] {escape(str(data.get('id', '')))}: {kind}")
|
|
@@ -240,6 +304,8 @@ def _render_preview(data: dict[str, Any]) -> None:
|
|
|
240
304
|
if data.get("stale_paths"):
|
|
241
305
|
console.print(f"[warning] changed since staging: {escape(', '.join(data['stale_paths']))}[/warning]")
|
|
242
306
|
_render_in_band(data)
|
|
307
|
+
_render_derivation(data)
|
|
308
|
+
_render_warnings(data)
|
|
243
309
|
if data.get("has_more"):
|
|
244
310
|
console.print(f"[dim] more on the next page - rerun with --cursor {escape(str(data.get('next_cursor')))}"
|
|
245
311
|
"[/dim]")
|
|
@@ -279,6 +345,90 @@ def _render_in_band(data: dict[str, Any]) -> None:
|
|
|
279
345
|
console.print("[dim] (the first 256 in-band records)[/dim]")
|
|
280
346
|
|
|
281
347
|
|
|
348
|
+
def value_text(value: Any) -> str:
|
|
349
|
+
"""A rule's value as text: a list comma-joined, `unset` for null."""
|
|
350
|
+
if value is None:
|
|
351
|
+
return "unset"
|
|
352
|
+
if isinstance(value, bool):
|
|
353
|
+
return "true" if value else "false"
|
|
354
|
+
if isinstance(value, list):
|
|
355
|
+
return ", ".join(value_text(item) for item in value) or "none"
|
|
356
|
+
if isinstance(value, dict):
|
|
357
|
+
if set(value) == {"label"}:
|
|
358
|
+
return f"label {value['label']}"
|
|
359
|
+
return json.dumps(value, sort_keys=True, default=str)
|
|
360
|
+
return str(value)
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def rule_change_text(change: dict[str, Any]) -> str:
|
|
364
|
+
"""One rule a derivation change set moves: a list rule's `added` and
|
|
365
|
+
`removed` values, a scalar rule's `from` and `to`."""
|
|
366
|
+
rule = str(change.get("rule", ""))
|
|
367
|
+
if "added" in change or "removed" in change:
|
|
368
|
+
moved = []
|
|
369
|
+
if change.get("added"):
|
|
370
|
+
moved.append("+ " + value_text(change["added"]))
|
|
371
|
+
if change.get("removed"):
|
|
372
|
+
moved.append("- " + value_text(change["removed"]))
|
|
373
|
+
return f"{rule}: {'; '.join(moved) or 'unchanged'}"
|
|
374
|
+
return f"{rule}: {value_text(change.get('from'))} -> {value_text(change.get('to'))}"
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def text_summary(text: dict[str, Any]) -> str:
|
|
378
|
+
"""A policy text a derivation change set names, as its preview lists it
|
|
379
|
+
(`sources` is a count there) or as `derivation texts` lists it (a list
|
|
380
|
+
of event ids)."""
|
|
381
|
+
if text.get("missing"):
|
|
382
|
+
return f"{text.get('id')}: missing (erased since it was named; resolution skips it)"
|
|
383
|
+
bits = [str(text.get("role") or "text")]
|
|
384
|
+
sources = text.get("sources")
|
|
385
|
+
count = len(sources) if isinstance(sources, list) else sources
|
|
386
|
+
if text.get("synthetic") is True:
|
|
387
|
+
bits.append("synthetic")
|
|
388
|
+
elif isinstance(count, int) and count:
|
|
389
|
+
bits.append(f"cites {count} event(s)")
|
|
390
|
+
if text.get("bytes") is not None:
|
|
391
|
+
bits.append(f"{text['bytes']} bytes")
|
|
392
|
+
return f"{text.get('id')}: {', '.join(bits)}"
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def _render_derivation(data: dict[str, Any]) -> None:
|
|
396
|
+
"""A derivation change set's `derivation` section (`derivation_policy_v1`,
|
|
397
|
+
CortexDB 0.15.0): each derive record's own hash before and after, each
|
|
398
|
+
governed scope's effective policy hash before and after with the rules
|
|
399
|
+
that move, whether every record only tightens, the texts the records name
|
|
400
|
+
and the section's warnings. The approval binds it beside the structural
|
|
401
|
+
hash. An access change set's page has none."""
|
|
402
|
+
section = data.get("derivation")
|
|
403
|
+
if not isinstance(section, dict):
|
|
404
|
+
return
|
|
405
|
+
narrowing = ("narrowing: every record only tightens" if section.get("narrowing")
|
|
406
|
+
else "not narrowing: a record loosens or steers")
|
|
407
|
+
console.print(f"[header]Derivation[/header] {narrowing}")
|
|
408
|
+
for record in section.get("records") or []:
|
|
409
|
+
before = record.get("before") or "no record"
|
|
410
|
+
after = record.get("after_hash") or "deleted"
|
|
411
|
+
console.print(f" record {escape(str(record.get('path', '')))}: {escape(str(before))} -> "
|
|
412
|
+
f"{escape(str(after))}")
|
|
413
|
+
for effective in section.get("effective") or []:
|
|
414
|
+
console.print(f" effective at {escape(str(effective.get('scope', '')))}: "
|
|
415
|
+
f"{escape(str(effective.get('before')))} -> {escape(str(effective.get('after')))}")
|
|
416
|
+
changes = effective.get("changes") or []
|
|
417
|
+
for change in changes:
|
|
418
|
+
console.print(f" {escape(rule_change_text(change))}")
|
|
419
|
+
if not changes:
|
|
420
|
+
console.print("[dim] no rule moves[/dim]")
|
|
421
|
+
for text in section.get("texts") or []:
|
|
422
|
+
style = "warning" if text.get("missing") else "dim"
|
|
423
|
+
console.print(f"[{style}] text {escape(text_summary(text))}[/{style}]")
|
|
424
|
+
for warning in section.get("warnings") or []:
|
|
425
|
+
if isinstance(warning, dict) and warning.get("reason") == "text_missing":
|
|
426
|
+
console.print(f"[warning] warning: text {escape(str(warning.get('id')))} is gone: the policy "
|
|
427
|
+
"keeps its id and resolution skips it[/warning]")
|
|
428
|
+
else:
|
|
429
|
+
console.print(f"[warning] warning: {escape(warning_text(warning))}[/warning]")
|
|
430
|
+
|
|
431
|
+
|
|
282
432
|
def _id_path(change_id: str, suffix: str = "") -> str:
|
|
283
433
|
return f"/v1/access/changes/{path_segment(change_id)}{suffix}"
|
|
284
434
|
|
|
@@ -292,6 +442,40 @@ def _params(proposer: str | None, scope: str | None = None) -> dict[str, str] |
|
|
|
292
442
|
return params or None
|
|
293
443
|
|
|
294
444
|
|
|
445
|
+
#: What a command whose staging's --wait ran out says to do next.
|
|
446
|
+
STAGE_AGAIN = "Send the same `cortexdb changes stage` again for its status, or `cortexdb changes get` it."
|
|
447
|
+
#: ... and one whose activation's --wait ran out.
|
|
448
|
+
ACTIVATE_AGAIN = ("Its activation goes on in the background: `cortexdb changes get` shows its state, and "
|
|
449
|
+
"the same `cortexdb changes activate --preview-hash` again answers its status.")
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def stage_change_set(cfg: dict[str, Any], client: Any, body: dict[str, Any],
|
|
453
|
+
wait_s: float = DEFAULT_WAIT_S, again: str = STAGE_AGAIN) -> dict[str, Any]:
|
|
454
|
+
"""POST /v1/access/changes with `body`, sent again while the server
|
|
455
|
+
answers an evaluation's 202; exits 75 when --wait runs out first."""
|
|
456
|
+
resp, waited = send_until_evaluated(cfg, "Staging...",
|
|
457
|
+
lambda: client.post("/v1/access/changes", json=body), wait_s)
|
|
458
|
+
if waited is not None:
|
|
459
|
+
still_evaluating(cfg, str(body.get("id", "")), "staging", waited, again)
|
|
460
|
+
return body_of(resp)
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def activate_change_set(cfg: dict[str, Any], client: Any, change_id: str, root: str, preview_hash: str,
|
|
464
|
+
proposer: str | None = None, wait_s: float = DEFAULT_WAIT_S,
|
|
465
|
+
again: str = ACTIVATE_AGAIN) -> dict[str, Any]:
|
|
466
|
+
"""POST .../activate with the approved preview's hash, sent again while
|
|
467
|
+
the server answers an evaluation's 202 with no body (a dated change
|
|
468
|
+
set's 202 carries its status, and is its answer); exits 75 when --wait
|
|
469
|
+
runs out first."""
|
|
470
|
+
body = {"scope": root, "preview_hash": preview_hash}
|
|
471
|
+
resp, waited = send_until_evaluated(
|
|
472
|
+
cfg, "Activating...",
|
|
473
|
+
lambda: client.post(_id_path(change_id, "/activate"), json=body, params=_params(proposer)), wait_s)
|
|
474
|
+
if waited is not None:
|
|
475
|
+
still_evaluating(cfg, change_id, "activation", waited, again)
|
|
476
|
+
return body_of(resp)
|
|
477
|
+
|
|
478
|
+
|
|
295
479
|
@changes_group.command("stage")
|
|
296
480
|
@click.argument("change_id", metavar="ID")
|
|
297
481
|
@_records_options
|
|
@@ -304,7 +488,7 @@ def _params(proposer: str | None, scope: str | None = None) -> dict[str, str] |
|
|
|
304
488
|
@click.option("--convert", is_flag=True, help="A version-1 tree conversion: its preview must change no access.")
|
|
305
489
|
@click.option("--resync", is_flag=True, help="A source's resync change set (served during a restore).")
|
|
306
490
|
@click.option("--resync-last", is_flag=True, help="The last resync change set of the restore.")
|
|
307
|
-
@
|
|
491
|
+
@wait_option
|
|
308
492
|
@api_options
|
|
309
493
|
@click.pass_context
|
|
310
494
|
@handle_errors
|
|
@@ -345,12 +529,8 @@ def changes_stage(ctx, change_id, file, part, deletes, scope, parts_total, activ
|
|
|
345
529
|
if resync or resync_last:
|
|
346
530
|
body["resync"] = {"last": bool(resync_last)}
|
|
347
531
|
with get_client(cfg) as client:
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
if waited is not None:
|
|
351
|
-
still_evaluating(cfg, change_id, "staging", waited,
|
|
352
|
-
"Send the same `cortexdb changes stage` again for its status, or `cortexdb changes get` it.")
|
|
353
|
-
emit(cfg, body_of(resp), _render_state)
|
|
532
|
+
data = stage_change_set(cfg, client, body, wait_s)
|
|
533
|
+
emit(cfg, data, render_state)
|
|
354
534
|
|
|
355
535
|
|
|
356
536
|
@changes_group.command("parts")
|
|
@@ -358,7 +538,7 @@ def changes_stage(ctx, change_id, file, part, deletes, scope, parts_total, activ
|
|
|
358
538
|
@_records_options
|
|
359
539
|
@_scope_option
|
|
360
540
|
@_proposer_option
|
|
361
|
-
@
|
|
541
|
+
@wait_option
|
|
362
542
|
@api_options
|
|
363
543
|
@click.pass_context
|
|
364
544
|
@handle_errors
|
|
@@ -378,11 +558,12 @@ def changes_parts(ctx, change_id, file, part, deletes, scope, proposer, wait_s):
|
|
|
378
558
|
if waited is not None:
|
|
379
559
|
still_evaluating(cfg, change_id, "staging of its parts", waited,
|
|
380
560
|
"Send the same `cortexdb changes parts` again for its status, or `cortexdb changes get` it.")
|
|
381
|
-
emit(cfg, body_of(resp),
|
|
561
|
+
emit(cfg, body_of(resp), render_state)
|
|
382
562
|
|
|
383
563
|
|
|
384
564
|
def preview_change_set(cfg, client, change_id, root, proposer=None, people=False, limit=None,
|
|
385
|
-
cursor=None, wait_s=DEFAULT_WAIT_S
|
|
565
|
+
cursor=None, wait_s=DEFAULT_WAIT_S,
|
|
566
|
+
again="Run the same `cortexdb changes preview` again to collect it."):
|
|
386
567
|
"""POST .../preview, sent again while the server answers 202
|
|
387
568
|
`{state: "previewing"}`; exits 75 when --wait runs out first."""
|
|
388
569
|
body: dict[str, Any] = {"scope": root}
|
|
@@ -396,8 +577,7 @@ def preview_change_set(cfg, client, change_id, root, proposer=None, people=False
|
|
|
396
577
|
cfg, "Previewing...",
|
|
397
578
|
lambda: client.post(_id_path(change_id, "/preview"), json=body, params=_params(proposer)), wait_s)
|
|
398
579
|
if waited is not None:
|
|
399
|
-
still_evaluating(cfg, change_id, "preview", waited,
|
|
400
|
-
"Run the same `cortexdb changes preview` again to collect it.")
|
|
580
|
+
still_evaluating(cfg, change_id, "preview", waited, again)
|
|
401
581
|
return body_of(resp)
|
|
402
582
|
|
|
403
583
|
|
|
@@ -408,7 +588,7 @@ def preview_change_set(cfg, client, change_id, root, proposer=None, people=False
|
|
|
408
588
|
@click.option("--people", is_flag=True, help="List each person who gains or loses access, per unit.")
|
|
409
589
|
@click.option("--limit", type=click.IntRange(min=1), default=None, help="Units per page.")
|
|
410
590
|
@click.option("--cursor", default=None, help="The next_cursor of a previous page.")
|
|
411
|
-
@
|
|
591
|
+
@wait_option
|
|
412
592
|
@api_options
|
|
413
593
|
@click.pass_context
|
|
414
594
|
@handle_errors
|
|
@@ -418,13 +598,17 @@ def changes_preview(ctx, change_id, scope, proposer, people, limit, cursor, wait
|
|
|
418
598
|
A page holds at most 1 MiB: a unit that does not fit continues on the
|
|
419
599
|
next page (--cursor), and its counts on this page are marked +. While
|
|
420
600
|
the evaluation runs past the server's wait it answers 202
|
|
421
|
-
{state: previewing}; the CLI asks again until it answers (--wait).
|
|
601
|
+
{state: previewing}; the CLI asks again until it answers (--wait). A
|
|
602
|
+
derivation change set's page also has its derivation section: each
|
|
603
|
+
derive record's hash before and after, each governed scope's effective
|
|
604
|
+
policy hash before and after with the rules that move, and the texts
|
|
605
|
+
the records name.
|
|
422
606
|
"""
|
|
423
607
|
cfg = ctx.obj
|
|
424
608
|
with get_client(cfg) as client:
|
|
425
609
|
data = preview_change_set(cfg, client, change_id, root_or_die(cfg, scope), proposer,
|
|
426
610
|
people, limit, cursor, wait_s)
|
|
427
|
-
emit(cfg, data,
|
|
611
|
+
emit(cfg, data, render_preview)
|
|
428
612
|
|
|
429
613
|
|
|
430
614
|
@changes_group.command("activate")
|
|
@@ -434,7 +618,7 @@ def changes_preview(ctx, change_id, scope, proposer, people, limit, cursor, wait
|
|
|
434
618
|
@click.option("--preview-hash", metavar="HASH",
|
|
435
619
|
help="The approved preview's hash (sha256:...). Without it the CLI previews first.")
|
|
436
620
|
@click.option("--yes", "-y", is_flag=True, help="Skip the confirmation after the preview.")
|
|
437
|
-
@
|
|
621
|
+
@wait_option
|
|
438
622
|
@api_options
|
|
439
623
|
@click.pass_context
|
|
440
624
|
@handle_errors
|
|
@@ -465,21 +649,14 @@ def changes_activate(ctx, change_id, scope, proposer, preview_hash, yes, wait_s)
|
|
|
465
649
|
if not preview_hash:
|
|
466
650
|
raise click.UsageError("the preview returned no preview_hash; pass --preview-hash")
|
|
467
651
|
if not is_json_mode(cfg):
|
|
468
|
-
|
|
652
|
+
render_preview(preview)
|
|
469
653
|
if preview.get("in_band") and not cfg.get("break_glass"):
|
|
470
654
|
console.print("[warning]No --break-glass is set: under deny this activation will be "
|
|
471
655
|
"refused (403 BREAK_GLASS_REQUIRED) and nothing activated.[/warning]")
|
|
472
656
|
if not yes:
|
|
473
657
|
click.confirm(f"Activate change set {change_id}?", abort=True)
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
cfg, "Activating...",
|
|
477
|
-
lambda: client.post(_id_path(change_id, "/activate"), json=body, params=_params(proposer)), wait_s)
|
|
478
|
-
if waited is not None:
|
|
479
|
-
still_evaluating(cfg, change_id, "activation", waited,
|
|
480
|
-
"Its activation goes on in the background: `cortexdb changes get` shows its state, and "
|
|
481
|
-
"the same `cortexdb changes activate --preview-hash` again answers its status.")
|
|
482
|
-
emit(cfg, body_of(resp), _render_state)
|
|
658
|
+
data = activate_change_set(cfg, client, change_id, root, preview_hash, proposer, wait_s)
|
|
659
|
+
emit(cfg, data, render_state)
|
|
483
660
|
|
|
484
661
|
|
|
485
662
|
@changes_group.command("get")
|
|
@@ -496,7 +673,7 @@ def changes_get(ctx, change_id, scope, proposer):
|
|
|
496
673
|
data = body_of(send(cfg, "Loading...",
|
|
497
674
|
lambda: client.get(_id_path(change_id),
|
|
498
675
|
params=_params(proposer, root_or_die(cfg, scope)))))
|
|
499
|
-
emit(cfg, data,
|
|
676
|
+
emit(cfg, data, render_state)
|
|
500
677
|
|
|
501
678
|
|
|
502
679
|
@changes_group.command("discard")
|
|
@@ -516,4 +693,4 @@ def changes_discard(ctx, change_id, scope, proposer, yes):
|
|
|
516
693
|
with get_client(cfg) as client:
|
|
517
694
|
data = body_of(send(cfg, "Discarding...",
|
|
518
695
|
lambda: client.delete(_id_path(change_id), params=_params(proposer, root))))
|
|
519
|
-
emit(cfg, data,
|
|
696
|
+
emit(cfg, data, render_state)
|
|
@@ -319,3 +319,9 @@ def conflicts_resolve(
|
|
|
319
319
|
records = data.get("records") or []
|
|
320
320
|
if records:
|
|
321
321
|
console.print(_records_table(records))
|
|
322
|
+
# corrections_v1 (CortexDB 0.15.0): on a shard armed for format 11 a
|
|
323
|
+
# resolution is proposed as corrections, replicated to every node.
|
|
324
|
+
correction_ids = data.get("correction_ids") or []
|
|
325
|
+
if correction_ids:
|
|
326
|
+
console.print(f"[dim]Proposed as correction(s) {escape(', '.join(map(str, correction_ids)))}: "
|
|
327
|
+
"`cortexdb corrections show SCOPE ID` shows one.[/dim]")
|