witan-code 0.19.1__tar.gz → 0.20.0__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.
- {witan_code-0.19.1 → witan_code-0.20.0}/CHANGELOG.md +41 -0
- {witan_code-0.19.1 → witan_code-0.20.0}/PKG-INFO +2 -2
- {witan_code-0.19.1 → witan_code-0.20.0}/pyproject.toml +3 -3
- {witan_code-0.19.1 → witan_code-0.20.0}/tests/test_branches.py +77 -7
- {witan_code-0.19.1 → witan_code-0.20.0}/tests/test_reaper.py +48 -1
- {witan_code-0.19.1 → witan_code-0.20.0}/tests/test_store.py +62 -0
- {witan_code-0.19.1 → witan_code-0.20.0}/witan_code/graph.py +194 -32
- {witan_code-0.19.1 → witan_code-0.20.0}/witan_code/store.py +13 -1
- {witan_code-0.19.1 → witan_code-0.20.0}/.gitignore +0 -0
- {witan_code-0.19.1 → witan_code-0.20.0}/README.md +0 -0
- {witan_code-0.19.1 → witan_code-0.20.0}/config/claude.json +0 -0
- {witan_code-0.19.1 → witan_code-0.20.0}/config/copilot.json +0 -0
- {witan_code-0.19.1 → witan_code-0.20.0}/config/pi.json +0 -0
- {witan_code-0.19.1 → witan_code-0.20.0}/conftest.py +0 -0
- {witan_code-0.19.1 → witan_code-0.20.0}/docs/BRANCH_INDEXING.md +0 -0
- {witan_code-0.19.1 → witan_code-0.20.0}/docs/CLI_REFERENCE.md +0 -0
- {witan_code-0.19.1 → witan_code-0.20.0}/docs/EDGE_PRECISION_TIERS.md +0 -0
- {witan_code-0.19.1 → witan_code-0.20.0}/docs/PACKAGE_MAP.md +0 -0
- {witan_code-0.19.1 → witan_code-0.20.0}/docs/STAGE2_STITCHING.md +0 -0
- {witan_code-0.19.1 → witan_code-0.20.0}/docs/SYMBOL_FORMAT.md +0 -0
- {witan_code-0.19.1 → witan_code-0.20.0}/docs/SYMBOL_TABLE.md +0 -0
- {witan_code-0.19.1 → witan_code-0.20.0}/docs/USER_GUIDE.md +0 -0
- {witan_code-0.19.1 → witan_code-0.20.0}/install.sh +0 -0
- {witan_code-0.19.1 → witan_code-0.20.0}/tests/__init__.py +0 -0
- {witan_code-0.19.1 → witan_code-0.20.0}/tests/conftest.py +0 -0
- {witan_code-0.19.1 → witan_code-0.20.0}/tests/test_batched_deletes.py +0 -0
- {witan_code-0.19.1 → witan_code-0.20.0}/tests/test_bridge.py +0 -0
- {witan_code-0.19.1 → witan_code-0.20.0}/tests/test_chunked_load.py +0 -0
- {witan_code-0.19.1 → witan_code-0.20.0}/tests/test_cli_dispatch.py +0 -0
- {witan_code-0.19.1 → witan_code-0.20.0}/tests/test_cli_hooks.py +0 -0
- {witan_code-0.19.1 → witan_code-0.20.0}/tests/test_cli_output_format.py +0 -0
- {witan_code-0.19.1 → witan_code-0.20.0}/tests/test_cli_selected_target.py +0 -0
- {witan_code-0.19.1 → witan_code-0.20.0}/tests/test_config.py +0 -0
- {witan_code-0.19.1 → witan_code-0.20.0}/tests/test_config_targets.py +0 -0
- {witan_code-0.19.1 → witan_code-0.20.0}/tests/test_context.py +0 -0
- {witan_code-0.19.1 → witan_code-0.20.0}/tests/test_crossrepo.py +0 -0
- {witan_code-0.19.1 → witan_code-0.20.0}/tests/test_edges.py +0 -0
- {witan_code-0.19.1 → witan_code-0.20.0}/tests/test_elicit.py +0 -0
- {witan_code-0.19.1 → witan_code-0.20.0}/tests/test_enrichment.py +0 -0
- {witan_code-0.19.1 → witan_code-0.20.0}/tests/test_github_app.py +0 -0
- {witan_code-0.19.1 → witan_code-0.20.0}/tests/test_graph.py +0 -0
- {witan_code-0.19.1 → witan_code-0.20.0}/tests/test_hooks.py +0 -0
- {witan_code-0.19.1 → witan_code-0.20.0}/tests/test_identity.py +0 -0
- {witan_code-0.19.1 → witan_code-0.20.0}/tests/test_index_failure_diagnostics.py +0 -0
- {witan_code-0.19.1 → witan_code-0.20.0}/tests/test_index_reports_its_store.py +0 -0
- {witan_code-0.19.1 → witan_code-0.20.0}/tests/test_index_requires_a_repo_key.py +0 -0
- {witan_code-0.19.1 → witan_code-0.20.0}/tests/test_indexer.py +0 -0
- {witan_code-0.19.1 → witan_code-0.20.0}/tests/test_indexer_scope.py +0 -0
- {witan_code-0.19.1 → witan_code-0.20.0}/tests/test_ingest.py +0 -0
- {witan_code-0.19.1 → witan_code-0.20.0}/tests/test_languages.py +0 -0
- {witan_code-0.19.1 → witan_code-0.20.0}/tests/test_maintenance.py +0 -0
- {witan_code-0.19.1 → witan_code-0.20.0}/tests/test_package_map.py +0 -0
- {witan_code-0.19.1 → witan_code-0.20.0}/tests/test_rebuild_honours_the_repo_key.py +0 -0
- {witan_code-0.19.1 → witan_code-0.20.0}/tests/test_remote_proxy.py +0 -0
- {witan_code-0.19.1 → witan_code-0.20.0}/tests/test_remote_store.py +0 -0
- {witan_code-0.19.1 → witan_code-0.20.0}/tests/test_repo.py +0 -0
- {witan_code-0.19.1 → witan_code-0.20.0}/tests/test_server.py +0 -0
- {witan_code-0.19.1 → witan_code-0.20.0}/tests/test_setup.py +0 -0
- {witan_code-0.19.1 → witan_code-0.20.0}/tests/test_stitch.py +0 -0
- {witan_code-0.19.1 → witan_code-0.20.0}/tests/test_symbol_table.py +0 -0
- {witan_code-0.19.1 → witan_code-0.20.0}/tests/test_tasks.py +0 -0
- {witan_code-0.19.1 → witan_code-0.20.0}/tests/test_tool_schema.py +0 -0
- {witan_code-0.19.1 → witan_code-0.20.0}/tests/test_views.py +0 -0
- {witan_code-0.19.1 → witan_code-0.20.0}/tests/test_visualize.py +0 -0
- {witan_code-0.19.1 → witan_code-0.20.0}/witan_code/__init__.py +0 -0
- {witan_code-0.19.1 → witan_code-0.20.0}/witan_code/__main__.py +0 -0
- {witan_code-0.19.1 → witan_code-0.20.0}/witan_code/bridge.py +0 -0
- {witan_code-0.19.1 → witan_code-0.20.0}/witan_code/bridge_extractors.py +0 -0
- {witan_code-0.19.1 → witan_code-0.20.0}/witan_code/cli.py +0 -0
- {witan_code-0.19.1 → witan_code-0.20.0}/witan_code/config.py +0 -0
- {witan_code-0.19.1 → witan_code-0.20.0}/witan_code/context.py +0 -0
- {witan_code-0.19.1 → witan_code-0.20.0}/witan_code/edges.py +0 -0
- {witan_code-0.19.1 → witan_code-0.20.0}/witan_code/elicit.py +0 -0
- {witan_code-0.19.1 → witan_code-0.20.0}/witan_code/extensions/pi/codegraph.ts +0 -0
- {witan_code-0.19.1 → witan_code-0.20.0}/witan_code/github_app.py +0 -0
- {witan_code-0.19.1 → witan_code-0.20.0}/witan_code/hooks.py +0 -0
- {witan_code-0.19.1 → witan_code-0.20.0}/witan_code/identity.py +0 -0
- {witan_code-0.19.1 → witan_code-0.20.0}/witan_code/indexer.py +0 -0
- {witan_code-0.19.1 → witan_code-0.20.0}/witan_code/ingest.py +0 -0
- {witan_code-0.19.1 → witan_code-0.20.0}/witan_code/maintenance.py +0 -0
- {witan_code-0.19.1 → witan_code-0.20.0}/witan_code/output.py +0 -0
- {witan_code-0.19.1 → witan_code-0.20.0}/witan_code/package_map.py +0 -0
- {witan_code-0.19.1 → witan_code-0.20.0}/witan_code/queries/bridge.gq +0 -0
- {witan_code-0.19.1 → witan_code-0.20.0}/witan_code/queries/code_mutations.gq +0 -0
- {witan_code-0.19.1 → witan_code-0.20.0}/witan_code/queries/code_read.gq +0 -0
- {witan_code-0.19.1 → witan_code-0.20.0}/witan_code/queries/delete.gq +0 -0
- {witan_code-0.19.1 → witan_code-0.20.0}/witan_code/queries_ts/bash.scm +0 -0
- {witan_code-0.19.1 → witan_code-0.20.0}/witan_code/queries_ts/hcl.scm +0 -0
- {witan_code-0.19.1 → witan_code-0.20.0}/witan_code/queries_ts/python.scm +0 -0
- {witan_code-0.19.1 → witan_code-0.20.0}/witan_code/queries_ts/sql.scm +0 -0
- {witan_code-0.19.1 → witan_code-0.20.0}/witan_code/queries_ts/typescript.scm +0 -0
- {witan_code-0.19.1 → witan_code-0.20.0}/witan_code/queries_ts/yaml.scm +0 -0
- {witan_code-0.19.1 → witan_code-0.20.0}/witan_code/reaper.py +0 -0
- {witan_code-0.19.1 → witan_code-0.20.0}/witan_code/remote/__init__.py +0 -0
- {witan_code-0.19.1 → witan_code-0.20.0}/witan_code/remote/oidc.py +0 -0
- {witan_code-0.19.1 → witan_code-0.20.0}/witan_code/remote/proxy.py +0 -0
- {witan_code-0.19.1 → witan_code-0.20.0}/witan_code/remote/store.py +0 -0
- {witan_code-0.19.1 → witan_code-0.20.0}/witan_code/repo.py +0 -0
- {witan_code-0.19.1 → witan_code-0.20.0}/witan_code/schema/bridge-schema.pg +0 -0
- {witan_code-0.19.1 → witan_code-0.20.0}/witan_code/schema/code-schema.pg +0 -0
- {witan_code-0.19.1 → witan_code-0.20.0}/witan_code/selected_target.py +0 -0
- {witan_code-0.19.1 → witan_code-0.20.0}/witan_code/server.py +0 -0
- {witan_code-0.19.1 → witan_code-0.20.0}/witan_code/setup.py +0 -0
- {witan_code-0.19.1 → witan_code-0.20.0}/witan_code/skills/witan-code/SKILL.md +0 -0
- {witan_code-0.19.1 → witan_code-0.20.0}/witan_code/stitch.py +0 -0
- {witan_code-0.19.1 → witan_code-0.20.0}/witan_code/views.py +0 -0
- {witan_code-0.19.1 → witan_code-0.20.0}/witan_code/visualize.py +0 -0
|
@@ -8,6 +8,47 @@ a MINOR bump may include breaking changes).
|
|
|
8
8
|
|
|
9
9
|
## [Unreleased]
|
|
10
10
|
|
|
11
|
+
## [0.20.0] - 2026-09-21
|
|
12
|
+
|
|
13
|
+
### Changed
|
|
14
|
+
|
|
15
|
+
- **Branch work goes through omnigraph 0.11 GQ statements, not the branch
|
|
16
|
+
CLI.** `list_branches`, `ensure_branch` and `delete_branch` now issue
|
|
17
|
+
`branch list` / `branch create` / `branch delete` on the same query and
|
|
18
|
+
mutate routes as every other read and write, so they share its transport,
|
|
19
|
+
retry policy and error classification. Against the cluster that removes a
|
|
20
|
+
subprocess per call, including on a branched client, which the named-query
|
|
21
|
+
path still cannot route over HTTP. Cedar still scopes these as branch
|
|
22
|
+
actions rather than as the route's `change`, so no actor gains authority it
|
|
23
|
+
did not have (verified against a 0.11.0 server running the code-graph
|
|
24
|
+
bundle).
|
|
25
|
+
|
|
26
|
+
Three behaviours of 0.11 this depends on, all measured against the 0.11.0
|
|
27
|
+
binary and server:
|
|
28
|
+
|
|
29
|
+
- A branch name must be QUOTED in the statement; an unquoted `act-x/v` is a
|
|
30
|
+
parse error. A `"` inside the quotes, on the other hand, PARSES fine (GQ
|
|
31
|
+
honours `\"`) and is then refused by the storage layer, so the guard is
|
|
32
|
+
against a statement that means something other than what was asked, not
|
|
33
|
+
against a syntax error. Names are checked against omnigraph's own rule,
|
|
34
|
+
which is Unicode `Alphabetic` or numeric plus `.`, `-`, `_` per
|
|
35
|
+
`/`-separated segment. That admits `café`, `act-x/дом`, and the combining
|
|
36
|
+
vowel signs of Indic, Hebrew and Arabic scripts (`कि`, `אָ`) that Python's
|
|
37
|
+
`\w` and `str.isalnum` both reject. A narrower charset would have made the
|
|
38
|
+
reaper refuse to delete views omnigraph created quite happily.
|
|
39
|
+
- A duplicate `branch create` answers HTTP 409, which `classify_status`
|
|
40
|
+
reads as retryable by status. So does the transient write-authority
|
|
41
|
+
precondition, and the two are indistinguishable. `ensure_branch` surfaces
|
|
42
|
+
conflicts and does its own re-list-and-retry, which resolves the first
|
|
43
|
+
without giving up the retry the second needs. That retry carries its own
|
|
44
|
+
backoff, because the engine's does not run under `surface_conflict`.
|
|
45
|
+
- `branch list` returns a `rows` envelope of `{"name": ...}`, not the old
|
|
46
|
+
`{"branches": [...]}`.
|
|
47
|
+
|
|
48
|
+
- **Require `witan-core>=0.38`**, for `OmnigraphClient.statement`. The failure
|
|
49
|
+
without it is a runtime `AttributeError` on the first `list_branches()`, not
|
|
50
|
+
an import error, so `just check-core-floor` cannot catch it.
|
|
51
|
+
|
|
11
52
|
## [0.19.1] - 2026-09-18
|
|
12
53
|
|
|
13
54
|
### Changed
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: witan-code
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.20.0
|
|
4
4
|
Summary: witan-code — tree-sitter code graph + cross-repo bridge (mounts under `witan code`)
|
|
5
5
|
Project-URL: Homepage, https://github.com/mitodl/agent-kit/tree/main/mcp/servers/witan-code
|
|
6
6
|
Project-URL: Repository, https://github.com/mitodl/agent-kit
|
|
@@ -26,7 +26,7 @@ Requires-Dist: tree-sitter-sql<0.4,>=0.3
|
|
|
26
26
|
Requires-Dist: tree-sitter-typescript<0.24,>=0.23
|
|
27
27
|
Requires-Dist: tree-sitter-yaml<0.8,>=0.7
|
|
28
28
|
Requires-Dist: tree-sitter<0.26,>=0.25
|
|
29
|
-
Requires-Dist: witan-core[cli,observability,remote,sentry]<1,>=0.
|
|
29
|
+
Requires-Dist: witan-core[cli,observability,remote,sentry]<1,>=0.38
|
|
30
30
|
Provides-Extra: tasks
|
|
31
31
|
Requires-Dist: fastmcp-tasks<5,>=4; extra == 'tasks'
|
|
32
32
|
Description-Content-Type: text/markdown
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "witan-code"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.20.0"
|
|
4
4
|
description = "witan-code — tree-sitter code graph + cross-repo bridge (mounts under `witan code`)"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = "BSD-3-Clause"
|
|
@@ -142,7 +142,7 @@ dependencies = [
|
|
|
142
142
|
# parses the keyed `code-schema.pg`, and it restores the null fields 0.11
|
|
143
143
|
# drops from query rows. An older witan-core imports fine and then fails
|
|
144
144
|
# on the first schema init.
|
|
145
|
-
"witan-core[cli,remote,observability,sentry]>=0.
|
|
145
|
+
"witan-core[cli,remote,observability,sentry]>=0.38,<1",
|
|
146
146
|
"fastmcp>=4,<5",
|
|
147
147
|
"cyclopts>=4,<5",
|
|
148
148
|
# [targets.<name>] override models (config.py) — also transitively pulled
|
|
@@ -225,7 +225,7 @@ testpaths = ["tests"]
|
|
|
225
225
|
packages = ["witan_code"]
|
|
226
226
|
|
|
227
227
|
[tool.bumpversion]
|
|
228
|
-
current_version = "0.
|
|
228
|
+
current_version = "0.20.0"
|
|
229
229
|
allow_dirty = true
|
|
230
230
|
|
|
231
231
|
[[tool.bumpversion.files]]
|
|
@@ -6,6 +6,8 @@ import subprocess
|
|
|
6
6
|
|
|
7
7
|
import pytest
|
|
8
8
|
|
|
9
|
+
from witan_core.omnigraph import OmnigraphConflict
|
|
10
|
+
|
|
9
11
|
from witan_code import repo as repo_module
|
|
10
12
|
|
|
11
13
|
from .conftest import SAMPLE, requires_stack
|
|
@@ -194,18 +196,36 @@ def test_a_view_opened_by_two_callers_at_once_is_not_an_error(tmp_path, monkeypa
|
|
|
194
196
|
|
|
195
197
|
@requires_stack
|
|
196
198
|
@pytest.mark.parametrize(
|
|
197
|
-
("
|
|
199
|
+
("exc", "listed"),
|
|
198
200
|
[
|
|
199
|
-
(
|
|
201
|
+
(
|
|
202
|
+
RuntimeError("omnigraph mutate failed (exit 1):\npermission denied"),
|
|
203
|
+
["main"],
|
|
204
|
+
),
|
|
200
205
|
# The message alone is not trusted: the branch has to actually be there.
|
|
201
206
|
(
|
|
202
|
-
|
|
207
|
+
RuntimeError(
|
|
208
|
+
"omnigraph mutate failed (exit 1):\n"
|
|
209
|
+
"branch 'feature_race' already exists"
|
|
210
|
+
),
|
|
211
|
+
["main"],
|
|
212
|
+
),
|
|
213
|
+
# What the cluster raises for the same race. 0.11 answers a duplicate
|
|
214
|
+
# create with HTTP 409, `classify_status` reads a 409 as retryable, and
|
|
215
|
+
# `ensure_branch`'s `surface_conflict=True` is what turns that into this
|
|
216
|
+
# rather than a retry budget spent re-racing. `OmnigraphConflict` is a
|
|
217
|
+
# `RuntimeError`, so the recovery clause sees both shapes the same way —
|
|
218
|
+
# and still re-lists before deciding.
|
|
219
|
+
(
|
|
220
|
+
OmnigraphConflict(
|
|
221
|
+
"branch 'feature_race' already exists (HTTP 409, conflict)"
|
|
222
|
+
),
|
|
203
223
|
["main"],
|
|
204
224
|
),
|
|
205
225
|
],
|
|
206
226
|
)
|
|
207
227
|
def test_a_create_failure_that_is_not_the_race_still_raises(
|
|
208
|
-
tmp_path, monkeypatch,
|
|
228
|
+
tmp_path, monkeypatch, exc, listed
|
|
209
229
|
):
|
|
210
230
|
from witan_code import config as cfg_module
|
|
211
231
|
from witan_code.graph import OmnigraphClient
|
|
@@ -217,12 +237,62 @@ def test_a_create_failure_that_is_not_the_race_still_raises(
|
|
|
217
237
|
monkeypatch.setattr(client, "list_branches", lambda: listed)
|
|
218
238
|
|
|
219
239
|
def fail(*_args, **_kwargs):
|
|
220
|
-
raise
|
|
240
|
+
raise exc
|
|
221
241
|
|
|
222
|
-
monkeypatch.setattr(client, "
|
|
242
|
+
monkeypatch.setattr(client, "statement", fail)
|
|
223
243
|
with pytest.raises(RuntimeError) as excinfo:
|
|
224
244
|
client.ensure_branch()
|
|
225
|
-
assert
|
|
245
|
+
assert excinfo.value is exc
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
@requires_stack
|
|
249
|
+
def test_a_conflicting_create_is_retried_a_bounded_number_of_spaced_times(
|
|
250
|
+
tmp_path, monkeypatch
|
|
251
|
+
):
|
|
252
|
+
"""The transient half of the 409, which the already-exists tests cannot see.
|
|
253
|
+
|
|
254
|
+
0.11 answers the write-authority precondition with a 409 as well, and
|
|
255
|
+
`classify_status` keys on the status, so `ensure_branch` cannot tell it
|
|
256
|
+
from a duplicate create. It retries for that one, and the retry has to be
|
|
257
|
+
both BOUNDED (a permanently conflicted graph must fail, not hang) and
|
|
258
|
+
SPACED (`_retry_loop` raises on the first retryable under
|
|
259
|
+
`surface_conflict`, before its own `time.sleep`, so three attempts inside
|
|
260
|
+
one listing round trip would be no wait at all for a writer mid-commit).
|
|
261
|
+
|
|
262
|
+
Neither property is visible in the other tests: they stub `list_branches`
|
|
263
|
+
to a fixed value and never count.
|
|
264
|
+
"""
|
|
265
|
+
from witan_code import config as cfg_module
|
|
266
|
+
from witan_code import graph as graph_module
|
|
267
|
+
from witan_code.graph import OmnigraphClient
|
|
268
|
+
|
|
269
|
+
monkeypatch.setenv("WITAN_CODE_DIR", str(tmp_path / "code"))
|
|
270
|
+
client = OmnigraphClient(
|
|
271
|
+
str(tmp_path / "store"), cfg_module.load().queries_dir, branch="feature_race"
|
|
272
|
+
)
|
|
273
|
+
monkeypatch.setattr(client, "list_branches", lambda: ["main"])
|
|
274
|
+
slept: list[float] = []
|
|
275
|
+
monkeypatch.setattr(graph_module.time, "sleep", slept.append)
|
|
276
|
+
|
|
277
|
+
attempts = []
|
|
278
|
+
|
|
279
|
+
def always_conflicts(*_args, **_kwargs):
|
|
280
|
+
attempts.append(None)
|
|
281
|
+
raise OmnigraphConflict(
|
|
282
|
+
"write authority 'graph_head:main' changed during preparation "
|
|
283
|
+
"(HTTP 409, conflict)"
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
monkeypatch.setattr(client, "statement", always_conflicts)
|
|
287
|
+
|
|
288
|
+
with pytest.raises(OmnigraphConflict):
|
|
289
|
+
client.ensure_branch()
|
|
290
|
+
|
|
291
|
+
assert len(attempts) == graph_module._ENSURE_BRANCH_ATTEMPTS
|
|
292
|
+
assert slept == [
|
|
293
|
+
graph_module._ENSURE_BRANCH_BACKOFF_SECONDS * n
|
|
294
|
+
for n in range(1, graph_module._ENSURE_BRANCH_ATTEMPTS)
|
|
295
|
+
], "retries must be spaced, not merely counted"
|
|
226
296
|
|
|
227
297
|
|
|
228
298
|
@requires_stack
|
|
@@ -17,7 +17,11 @@ import pytest
|
|
|
17
17
|
|
|
18
18
|
from witan_code import config as cfg_module
|
|
19
19
|
from witan_code import reaper as reaper_module
|
|
20
|
-
from witan_code.graph import
|
|
20
|
+
from witan_code.graph import (
|
|
21
|
+
OmnigraphClient,
|
|
22
|
+
UnquotableBranchName,
|
|
23
|
+
_gq_branch_name,
|
|
24
|
+
)
|
|
21
25
|
from witan_code.reaper import ViewAge, reap, select_stale
|
|
22
26
|
|
|
23
27
|
from .conftest import requires_omnigraph
|
|
@@ -207,6 +211,49 @@ def test_one_undeletable_view_does_not_strand_the_sweep():
|
|
|
207
211
|
assert report.failed == [("act-alice/ancient", "branch is locked")]
|
|
208
212
|
|
|
209
213
|
|
|
214
|
+
def test_a_name_the_client_cannot_quote_is_recorded_rather_than_raised():
|
|
215
|
+
"""The same rule, for the one failure this client can produce BEFORE it
|
|
216
|
+
reaches the engine. `branch list` returns whatever any client ever created,
|
|
217
|
+
so a name outside `_QUOTABLE_BRANCH` is reachable here and nowhere else.
|
|
218
|
+
|
|
219
|
+
It is pinned because the exception TYPE is load-bearing and invisible: the
|
|
220
|
+
handler above catches `RuntimeError`, `select_stale` sorts oldest-first,
|
|
221
|
+
and a sibling type would therefore abort the sweep at the oldest poison
|
|
222
|
+
name, stranding every newer stale view behind it and returning no report at
|
|
223
|
+
all.
|
|
224
|
+
"""
|
|
225
|
+
|
|
226
|
+
class _WithAnUnquotableName(_FakeClient):
|
|
227
|
+
def list_branches(self):
|
|
228
|
+
return ['act-alice/we"ird', "act-bob/older"]
|
|
229
|
+
|
|
230
|
+
def branch_last_write(self, name):
|
|
231
|
+
# Oldest first out of `select_stale`, so the bad one is reached
|
|
232
|
+
# before the good one, which is the case that used to strand.
|
|
233
|
+
return NOW - (500 * DAY if '"' in name else 400 * DAY)
|
|
234
|
+
|
|
235
|
+
def delete_branch(self, name):
|
|
236
|
+
_gq_branch_name(name) # what the real client does first
|
|
237
|
+
super().delete_branch(name)
|
|
238
|
+
|
|
239
|
+
report = reap(
|
|
240
|
+
_WithAnUnquotableName(is_remote=False),
|
|
241
|
+
graph="local",
|
|
242
|
+
now=NOW,
|
|
243
|
+
max_idle=14,
|
|
244
|
+
apply=True,
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
assert report.deleted == ["act-bob/older"]
|
|
248
|
+
assert [view for view, _ in report.failed] == ['act-alice/we"ird']
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def test_the_unquotable_name_error_is_a_runtime_error():
|
|
252
|
+
"""What the test above rests on, stated directly so a change to the class
|
|
253
|
+
fails here rather than as a stranded sweep."""
|
|
254
|
+
assert issubclass(UnquotableBranchName, RuntimeError)
|
|
255
|
+
|
|
256
|
+
|
|
210
257
|
def test_a_disabled_window_ages_nothing_rather_than_ageing_and_discarding():
|
|
211
258
|
"""`select_stale` would return nothing anyway — but only after a
|
|
212
259
|
`commit list` subprocess per view. On a shared graph carrying every
|
|
@@ -5,6 +5,7 @@ reads and per-entry Path construction. That makes their edge cases (an empty
|
|
|
5
5
|
store, an unreadable one, a dangling symlink) worth pinning explicitly.
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
|
+
import json
|
|
8
9
|
import os
|
|
9
10
|
import threading
|
|
10
11
|
import time
|
|
@@ -613,6 +614,67 @@ def test_the_local_fallback_hint_names_the_setting_actually_routing(monkeypatch)
|
|
|
613
614
|
assert "code_server" not in str(exc.value)
|
|
614
615
|
|
|
615
616
|
|
|
617
|
+
class _NotFoundConnection:
|
|
618
|
+
"""An omnigraph-server that serves no graph, answering as 0.11.0 does.
|
|
619
|
+
|
|
620
|
+
Body and status measured against a real `omnigraph-server --cluster`
|
|
621
|
+
(0.11.0, 2026-09-21): `POST /graphs/nosuch/query` with a `branch list`
|
|
622
|
+
statement answers `404 {"error": "graph 'nosuch' not found", "code":
|
|
623
|
+
"not_found"}`.
|
|
624
|
+
"""
|
|
625
|
+
|
|
626
|
+
def __init__(self, host, port=None, timeout=None, context=None):
|
|
627
|
+
self.host = host
|
|
628
|
+
|
|
629
|
+
def connect(self):
|
|
630
|
+
pass
|
|
631
|
+
|
|
632
|
+
def request(self, method, path, body=None, headers=None):
|
|
633
|
+
pass
|
|
634
|
+
|
|
635
|
+
def getresponse(self):
|
|
636
|
+
class _Response:
|
|
637
|
+
status = 404
|
|
638
|
+
will_close = False
|
|
639
|
+
|
|
640
|
+
def read(self):
|
|
641
|
+
return json.dumps(
|
|
642
|
+
{"error": "graph 'nosuch' not found", "code": "not_found"}
|
|
643
|
+
).encode()
|
|
644
|
+
|
|
645
|
+
def getheader(self, _name, default=None):
|
|
646
|
+
return default
|
|
647
|
+
|
|
648
|
+
return _Response()
|
|
649
|
+
|
|
650
|
+
def close(self):
|
|
651
|
+
pass
|
|
652
|
+
|
|
653
|
+
|
|
654
|
+
def test_a_missing_cluster_graph_is_still_recognised_over_http(monkeypatch):
|
|
655
|
+
"""★ THE WORDING `_NOT_FOUND_RE` KEYS ON NOW ARRIVES FROM THE SERVER.
|
|
656
|
+
|
|
657
|
+
`list_branches` was unconditionally a subprocess, so `_NOT_FOUND_RE` was
|
|
658
|
+
tuned against what the CLI printed (see the comment above it). It is a
|
|
659
|
+
`branch list` statement now, so a remote client takes the pooled transport
|
|
660
|
+
and this discrimination runs on the HTTP body instead. If the server ever
|
|
661
|
+
stops quoting the id, every missing graph files as `ClusterUnreachable`
|
|
662
|
+
rather than `ClusterGraphMissing`, which is the exact confusion those two
|
|
663
|
+
types exist to prevent — and nothing else in the suite would notice,
|
|
664
|
+
because every other probe test fakes the client.
|
|
665
|
+
"""
|
|
666
|
+
monkeypatch.setattr(http_module.http.client, "HTTPSConnection", _NotFoundConnection)
|
|
667
|
+
monkeypatch.setattr(http_module.http.client, "HTTPConnection", _NotFoundConnection)
|
|
668
|
+
monkeypatch.setenv("WITAN_CODE_SERVER", "https://omnigraph.test")
|
|
669
|
+
from witan_code import config as cfg_module
|
|
670
|
+
|
|
671
|
+
cfg = cfg_module.load()
|
|
672
|
+
ref = store_module.StoreRef(uri="https://omnigraph.test", graph_id="nosuch")
|
|
673
|
+
|
|
674
|
+
with pytest.raises(store_module.ClusterGraphMissing):
|
|
675
|
+
store_module.probe_cluster_graph(ref, "code-nosuch", cfg)
|
|
676
|
+
|
|
677
|
+
|
|
616
678
|
def test_a_failed_listing_is_not_cached(monkeypatch):
|
|
617
679
|
"""A transient outage must not pin an error for the whole TTL — the next
|
|
618
680
|
call has to be able to succeed."""
|
|
@@ -9,10 +9,12 @@ bulk ``load`` used to write thousands of symbol/edge records in one call.
|
|
|
9
9
|
from __future__ import annotations
|
|
10
10
|
|
|
11
11
|
import json
|
|
12
|
+
import time
|
|
13
|
+
import unicodedata
|
|
12
14
|
from pathlib import Path
|
|
13
15
|
|
|
14
16
|
from witan_core.omnigraph import OmnigraphClient as _BaseOmnigraphClient
|
|
15
|
-
from witan_core.omnigraph import _is_storage_version_mismatch
|
|
17
|
+
from witan_core.omnigraph import OmnigraphConflict, _is_storage_version_mismatch
|
|
16
18
|
|
|
17
19
|
from witan_core import chunking
|
|
18
20
|
from . import config as cfg_module
|
|
@@ -22,6 +24,7 @@ from . import views
|
|
|
22
24
|
__all__ = [
|
|
23
25
|
"OmnigraphClient",
|
|
24
26
|
"SharedGraphWriteRefused",
|
|
27
|
+
"UnquotableBranchName",
|
|
25
28
|
"check_writable",
|
|
26
29
|
"is_stale_schema",
|
|
27
30
|
"owns_view",
|
|
@@ -38,6 +41,100 @@ class SharedGraphWriteRefused(RuntimeError):
|
|
|
38
41
|
"""A writer tried to write a view of a shared graph it does not own."""
|
|
39
42
|
|
|
40
43
|
|
|
44
|
+
class UnquotableBranchName(RuntimeError):
|
|
45
|
+
"""A branch name that cannot be written into a GQ branch statement.
|
|
46
|
+
|
|
47
|
+
A ``RuntimeError`` on purpose: the names that reach :func:`_gq_branch_name`
|
|
48
|
+
from outside this package come from ``branch list``, so the reaper meets
|
|
49
|
+
them one at a time and must record the bad one in ``report.failed`` and
|
|
50
|
+
carry on. Its handler catches ``RuntimeError``, and a sibling type would
|
|
51
|
+
abort the whole sweep at the oldest poison name, stranding every newer
|
|
52
|
+
stale view behind it.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
#: Punctuation a branch name may carry: what omnigraph allows inside a segment,
|
|
57
|
+
#: plus the ``/`` that separates them.
|
|
58
|
+
_BRANCH_PUNCTUATION = frozenset("._-/")
|
|
59
|
+
|
|
60
|
+
#: Unicode categories that are alphabetic to the ENGINE but not to Python.
|
|
61
|
+
#: omnigraph validates a segment as "alphanumeric, '.', '-', '_'", where
|
|
62
|
+
#: "alphanumeric" is Rust's ``char::is_alphanumeric``, whose alphabetic half is
|
|
63
|
+
#: the Unicode ``Alphabetic`` property. That property includes
|
|
64
|
+
#: ``Other_Alphabetic`` — the combining vowel signs of Indic, Hebrew and Arabic
|
|
65
|
+
#: scripts — which ``str.isalnum`` and ``\w`` both exclude. Measured against
|
|
66
|
+
#: 0.11.0: ``branch create "कि"`` (U+0915 Lo, U+093F Mc) succeeds.
|
|
67
|
+
_BRANCH_COMBINING_CATEGORIES = frozenset({"Mn", "Mc"})
|
|
68
|
+
|
|
69
|
+
#: How many times :meth:`OmnigraphClient.ensure_branch` re-lists and retries
|
|
70
|
+
#: after a 409, and how long it spaces them. Small on purpose: the
|
|
71
|
+
#: duplicate-create case resolves on the SECOND listing, and the transient
|
|
72
|
+
#: precondition it also covers is a writer mid-commit rather than a queue to
|
|
73
|
+
#: wait out. The sleep is not optional though — see ``ensure_branch``, the
|
|
74
|
+
#: engine's own backoff does not run under ``surface_conflict``.
|
|
75
|
+
_ENSURE_BRANCH_ATTEMPTS = 3
|
|
76
|
+
_ENSURE_BRANCH_BACKOFF_SECONDS = 0.05
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _is_quotable_branch(name: str) -> bool:
|
|
80
|
+
"""Whether every character of ``name`` is one omnigraph accepts.
|
|
81
|
+
|
|
82
|
+
Deliberately the ENGINE'S rule rather than witan-code's own sanitizers.
|
|
83
|
+
Every name this package builds is inside it either way, so the only names
|
|
84
|
+
this decides are the ones ``branch list`` returns, which are whatever any
|
|
85
|
+
client ever created. A guard NARROWER than the engine refuses those, which
|
|
86
|
+
is not a theoretical loss: it is the reaper declining to delete a view
|
|
87
|
+
omnigraph made quite happily, and ``config.sanitize_slug`` only collapses
|
|
88
|
+
``[/:]+``, so a bridge view for a repo with such a name is one witan-code
|
|
89
|
+
builds itself.
|
|
90
|
+
|
|
91
|
+
Not a regex, because the character class cannot be written as one without
|
|
92
|
+
either ``regex``'s ``\\p{Alphabetic}`` (not a dependency) or a hand-rolled
|
|
93
|
+
range table that would rot against each Unicode revision.
|
|
94
|
+
|
|
95
|
+
Still narrower than the engine in one harmless direction: ``..`` passes
|
|
96
|
+
here and omnigraph refuses it (``Branch name cannot contain '..'``), which
|
|
97
|
+
only changes which layer reports it.
|
|
98
|
+
"""
|
|
99
|
+
return bool(name) and all(
|
|
100
|
+
char.isalnum()
|
|
101
|
+
or char in _BRANCH_PUNCTUATION
|
|
102
|
+
or unicodedata.category(char) in _BRANCH_COMBINING_CATEGORIES
|
|
103
|
+
for char in name
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _gq_branch_name(name: str) -> str:
|
|
108
|
+
"""``name`` as a GQ string literal, or raise :class:`UnquotableBranchName`.
|
|
109
|
+
|
|
110
|
+
A branch statement needs its name QUOTED. Verified against the 0.11.0
|
|
111
|
+
binary: ``branch create act-x/feat_y from main`` is a ``parse error``, and
|
|
112
|
+
``branch create "act-x/feat_y" from main`` succeeds.
|
|
113
|
+
|
|
114
|
+
GQ *does* honour ``\\"`` inside the quotes, so the danger is not that a
|
|
115
|
+
``"`` fails to parse. It is that it parses fine and the statement then
|
|
116
|
+
means something other than what the caller asked. What refuses
|
|
117
|
+
``"we\\"ird"`` is the storage layer, one level down and after the parse:
|
|
118
|
+
``storage: Ref is invalid: Branch segment 'we"ird.<ulid>' contains invalid
|
|
119
|
+
characters``. So :func:`_is_quotable_branch`, which admits no ``"`` and no
|
|
120
|
+
``\\``, is what keeps a name from reaching that point at all. Nor can
|
|
121
|
+
anything it admits smuggle in a second statement: every character it
|
|
122
|
+
accepts is alphanumeric, a combining mark, or one of ``._-/``, and none of
|
|
123
|
+
those is a quote, a backslash, a separator or whitespace.
|
|
124
|
+
|
|
125
|
+
Unreachable for a name this package built. The ones that do not come from
|
|
126
|
+
here are whatever ``branch list`` returns, which is whatever any client
|
|
127
|
+
ever created on the store.
|
|
128
|
+
"""
|
|
129
|
+
if not _is_quotable_branch(name):
|
|
130
|
+
raise UnquotableBranchName(
|
|
131
|
+
f"branch name {name!r} cannot be written as an omnigraph GQ "
|
|
132
|
+
"statement: it has characters outside the alphanumerics, combining "
|
|
133
|
+
"marks and `._-/` that omnigraph allows in a branch name."
|
|
134
|
+
)
|
|
135
|
+
return f'"{name}"'
|
|
136
|
+
|
|
137
|
+
|
|
41
138
|
def owns_view(
|
|
42
139
|
*,
|
|
43
140
|
is_remote: bool,
|
|
@@ -210,44 +307,110 @@ class OmnigraphClient(_BaseOmnigraphClient):
|
|
|
210
307
|
# ── Branch operations ─────────────────────────────────────────
|
|
211
308
|
|
|
212
309
|
def list_branches(self) -> list[str]:
|
|
213
|
-
"""Names of all branches on this store (includes ``main``).
|
|
214
|
-
|
|
310
|
+
"""Names of all branches on this store (includes ``main``).
|
|
311
|
+
|
|
312
|
+
0.11's ``branch list`` GQ statement on the canonical read route
|
|
313
|
+
(upstream RFC 0055), not an ``omnigraph branch list`` subprocess, so
|
|
314
|
+
branch reads share the transport, retry policy and error
|
|
315
|
+
classification of every other read — see
|
|
316
|
+
:meth:`~witan_core.omnigraph.OmnigraphClient.statement`. Against the
|
|
317
|
+
cluster that is an HTTP POST rather than a process, including on a
|
|
318
|
+
branched client, which the named-query path still cannot use.
|
|
319
|
+
|
|
320
|
+
One row per branch, ``{"name": ...}``, under the same ``rows``
|
|
321
|
+
envelope a named read returns — verified identical on both transports
|
|
322
|
+
against the 0.11.0 binary and the 0.11.0 server.
|
|
323
|
+
"""
|
|
324
|
+
result = self.statement(
|
|
325
|
+
"query", "branch list", "--format", "json", label="branch list"
|
|
326
|
+
)
|
|
215
327
|
try:
|
|
216
328
|
parsed = json.loads(result)
|
|
217
329
|
except json.JSONDecodeError:
|
|
218
330
|
return []
|
|
219
|
-
rows = parsed.get("
|
|
331
|
+
rows = parsed.get("rows", parsed) if isinstance(parsed, dict) else parsed
|
|
220
332
|
if not isinstance(rows, list):
|
|
221
333
|
return []
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
if isinstance(name, str)
|
|
226
|
-
|
|
227
|
-
return out
|
|
334
|
+
return [
|
|
335
|
+
row["name"]
|
|
336
|
+
for row in rows
|
|
337
|
+
if isinstance(row, dict) and isinstance(row.get("name"), str)
|
|
338
|
+
]
|
|
228
339
|
|
|
229
340
|
def ensure_branch(self) -> None:
|
|
230
341
|
"""Create ``self.branch`` from main if it doesn't exist yet.
|
|
231
342
|
|
|
232
343
|
Needed before the first *read* on a new branch — reads never fork
|
|
233
344
|
(only ``load --from`` does), so a read against a missing branch errors.
|
|
345
|
+
|
|
346
|
+
★ TWO DIFFERENT 409s ARRIVE HERE AND ONLY ONE IS THIS METHOD'S RACE.
|
|
347
|
+
0.11 answers a duplicate create with ``branch 'x' already exists``, and
|
|
348
|
+
it answers a lost write-authority precondition (``write authority
|
|
349
|
+
'graph_head:main' changed during preparation``) with a 409 too.
|
|
350
|
+
``classify_status`` keys on the status, so it cannot tell them apart,
|
|
351
|
+
and reads both as RETRYABLE.
|
|
352
|
+
|
|
353
|
+
Neither default is right on its own. Retrying silently would spend the
|
|
354
|
+
whole budget, backoff sleeps included, re-racing a branch that is
|
|
355
|
+
already there; ``surface_conflict=True`` alone would turn the genuinely
|
|
356
|
+
transient precondition into a hard failure of ``code_store_open``,
|
|
357
|
+
where the old CLI path rode it out. So conflicts are surfaced AND the
|
|
358
|
+
retry is put back here, around a fresh listing, which is what makes the
|
|
359
|
+
duplicate case resolve as "it exists, we are done" instead of as
|
|
360
|
+
another doomed attempt.
|
|
361
|
+
|
|
362
|
+
★ INCLUDING THE BACKOFF, which does not survive ``surface_conflict``.
|
|
363
|
+
``_retry_loop`` raises on the FIRST retryable classification when it is
|
|
364
|
+
set, before it increments the attempt counter and before its
|
|
365
|
+
``time.sleep``, so the engine's schedule never runs. Left to the
|
|
366
|
+
listing round trip alone the three attempts here would fall inside
|
|
367
|
+
~10ms, which is no wait at all for a writer that is mid-commit: the
|
|
368
|
+
transient case would fail about as reliably as it did before the retry
|
|
369
|
+
was restored. The sleep below is what makes these attempts SPACED
|
|
370
|
+
rather than merely counted.
|
|
371
|
+
|
|
372
|
+
The CLI path produces neither 409: its already-exists is prose matching
|
|
373
|
+
no marker, classifies FATAL, and lands in the ``RuntimeError`` clause.
|
|
234
374
|
"""
|
|
235
|
-
if self.branch is None
|
|
375
|
+
if self.branch is None:
|
|
236
376
|
return
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
self.
|
|
246
|
-
|
|
247
|
-
|
|
377
|
+
create = f"branch create {_gq_branch_name(self.branch)} from main"
|
|
378
|
+
conflict: RuntimeError | None = None
|
|
379
|
+
for attempt in range(_ENSURE_BRANCH_ATTEMPTS):
|
|
380
|
+
if attempt:
|
|
381
|
+
time.sleep(_ENSURE_BRANCH_BACKOFF_SECONDS * attempt)
|
|
382
|
+
if self.branch in self.list_branches():
|
|
383
|
+
return
|
|
384
|
+
try:
|
|
385
|
+
self.statement(
|
|
386
|
+
"mutate", create, label="branch create", surface_conflict=True
|
|
387
|
+
)
|
|
388
|
+
return
|
|
389
|
+
except OmnigraphConflict as exc:
|
|
390
|
+
conflict = exc
|
|
391
|
+
except RuntimeError as exc:
|
|
392
|
+
# The CLI's own already-exists. Two concurrent
|
|
393
|
+
# `code_store_open` calls for one view both saw it missing and
|
|
394
|
+
# both tried to create it. Anything else is a real failure.
|
|
395
|
+
if "already exists" not in str(exc):
|
|
396
|
+
raise
|
|
397
|
+
conflict = exc
|
|
398
|
+
# The message alone is never trusted: the branch has to actually be
|
|
399
|
+
# there, which is all this method promises. `conflict` cannot be None:
|
|
400
|
+
# every iteration either returns, re-raises, or assigns it.
|
|
401
|
+
if self.branch not in self.list_branches():
|
|
402
|
+
raise conflict
|
|
248
403
|
|
|
249
404
|
def delete_branch(self, name: str) -> None:
|
|
250
|
-
|
|
405
|
+
# `--yes` is for the CLI path only, where a destructive write against a
|
|
406
|
+
# non-local scope refuses without it; the HTTP route has no prompt to
|
|
407
|
+
# skip and ignores flags entirely.
|
|
408
|
+
self.statement(
|
|
409
|
+
"mutate",
|
|
410
|
+
f"branch delete {_gq_branch_name(name)}",
|
|
411
|
+
"--yes",
|
|
412
|
+
label="branch delete",
|
|
413
|
+
)
|
|
251
414
|
|
|
252
415
|
def branch_last_write(self, name: str) -> float | None:
|
|
253
416
|
"""When ``name`` was last written, as epoch seconds, or ``None``.
|
|
@@ -305,15 +468,14 @@ class OmnigraphClient(_BaseOmnigraphClient):
|
|
|
305
468
|
def _extra_args(self, subcommand: str) -> list[str]:
|
|
306
469
|
# optimize/cleanup compact the whole store (every branch), not a single
|
|
307
470
|
# one, so they never take --branch even on a branched client. `commit`
|
|
308
|
-
#
|
|
309
|
-
#
|
|
310
|
-
#
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
):
|
|
471
|
+
# names its branch as its own flag, so injecting this client's would
|
|
472
|
+
# either duplicate it or silently retarget the call.
|
|
473
|
+
#
|
|
474
|
+
# `branch` is NOT in this list any more because nothing runs it: branch
|
|
475
|
+
# work goes through `statement`, which bypasses `_extra_args` entirely
|
|
476
|
+
# (a GQ branch statement takes no --branch). Re-adding the subcommand
|
|
477
|
+
# here would be dead code, not a safety net.
|
|
478
|
+
if self.branch is None or subcommand in ("commit", "optimize", "cleanup"):
|
|
317
479
|
return []
|
|
318
480
|
if subcommand == "load":
|
|
319
481
|
return ["--branch", self.branch, "--from", "main"]
|
|
@@ -562,7 +562,7 @@ def probe_cluster_graph(ref: StoreRef, what: str, cfg: cfg_module.Config) -> Sto
|
|
|
562
562
|
records into one error per subprocess and reports success having written
|
|
563
563
|
nothing.
|
|
564
564
|
|
|
565
|
-
Asks a GRAPH-scoped question (``branch list
|
|
565
|
+
Asks a GRAPH-scoped question (the ``branch list`` statement, the cheapest
|
|
566
566
|
one that has to reach the store to be answered) rather than the
|
|
567
567
|
server-scoped ``graphs list``. That is not a detail: ``graphs list`` is a
|
|
568
568
|
management-surface action that no ordinary client can use against the
|
|
@@ -572,6 +572,18 @@ def probe_cluster_graph(ref: StoreRef, what: str, cfg: cfg_module.Config) -> Sto
|
|
|
572
572
|
``read`` on that graph, which every actor the v1 Cedar bundle grants — CI,
|
|
573
573
|
users, service and admin alike — already holds.
|
|
574
574
|
|
|
575
|
+
★ AND IT STILL NEEDS ONLY ``read`` NOW THAT IT IS A GQ STATEMENT ON
|
|
576
|
+
``/query`` RATHER THAN A CLI SUBCOMMAND, which is not obvious: the WRITE
|
|
577
|
+
statements on ``/mutate`` are authorized as their own branch actions
|
|
578
|
+
(``branch delete`` checks ``branch_delete``, not the route's ``change``),
|
|
579
|
+
so a reader could reasonably expect ``branch list`` to check a matching
|
|
580
|
+
``branch_list``. It does not. Measured against a 0.11.0 server running
|
|
581
|
+
this bundle: an actor in no group gets ``policy denied action 'read'`` for
|
|
582
|
+
``branch list``, the identical wording an ordinary named read gets, while
|
|
583
|
+
the same actor's ``branch create`` is denied as ``branch_create``. Had it
|
|
584
|
+
gone the other way, every read-only actor would be failing this write-path
|
|
585
|
+
preflight.
|
|
586
|
+
|
|
575
587
|
On the DIRECT path it also inherits
|
|
576
588
|
:class:`~witan_core.omnigraph.OmnigraphClient`'s connect-retry budget, so a
|
|
577
589
|
server mid-restart is ridden out rather than counted as an absent graph.
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|