witan-code 0.19.0__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.
Files changed (107) hide show
  1. {witan_code-0.19.0 → witan_code-0.20.0}/CHANGELOG.md +48 -0
  2. {witan_code-0.19.0 → witan_code-0.20.0}/PKG-INFO +2 -2
  3. {witan_code-0.19.0 → witan_code-0.20.0}/pyproject.toml +6 -6
  4. {witan_code-0.19.0 → witan_code-0.20.0}/tests/test_branches.py +77 -7
  5. {witan_code-0.19.0 → witan_code-0.20.0}/tests/test_reaper.py +48 -1
  6. {witan_code-0.19.0 → witan_code-0.20.0}/tests/test_store.py +62 -0
  7. {witan_code-0.19.0 → witan_code-0.20.0}/witan_code/graph.py +194 -32
  8. {witan_code-0.19.0 → witan_code-0.20.0}/witan_code/store.py +13 -1
  9. {witan_code-0.19.0 → witan_code-0.20.0}/.gitignore +0 -0
  10. {witan_code-0.19.0 → witan_code-0.20.0}/README.md +0 -0
  11. {witan_code-0.19.0 → witan_code-0.20.0}/config/claude.json +0 -0
  12. {witan_code-0.19.0 → witan_code-0.20.0}/config/copilot.json +0 -0
  13. {witan_code-0.19.0 → witan_code-0.20.0}/config/pi.json +0 -0
  14. {witan_code-0.19.0 → witan_code-0.20.0}/conftest.py +0 -0
  15. {witan_code-0.19.0 → witan_code-0.20.0}/docs/BRANCH_INDEXING.md +0 -0
  16. {witan_code-0.19.0 → witan_code-0.20.0}/docs/CLI_REFERENCE.md +0 -0
  17. {witan_code-0.19.0 → witan_code-0.20.0}/docs/EDGE_PRECISION_TIERS.md +0 -0
  18. {witan_code-0.19.0 → witan_code-0.20.0}/docs/PACKAGE_MAP.md +0 -0
  19. {witan_code-0.19.0 → witan_code-0.20.0}/docs/STAGE2_STITCHING.md +0 -0
  20. {witan_code-0.19.0 → witan_code-0.20.0}/docs/SYMBOL_FORMAT.md +0 -0
  21. {witan_code-0.19.0 → witan_code-0.20.0}/docs/SYMBOL_TABLE.md +0 -0
  22. {witan_code-0.19.0 → witan_code-0.20.0}/docs/USER_GUIDE.md +0 -0
  23. {witan_code-0.19.0 → witan_code-0.20.0}/install.sh +0 -0
  24. {witan_code-0.19.0 → witan_code-0.20.0}/tests/__init__.py +0 -0
  25. {witan_code-0.19.0 → witan_code-0.20.0}/tests/conftest.py +0 -0
  26. {witan_code-0.19.0 → witan_code-0.20.0}/tests/test_batched_deletes.py +0 -0
  27. {witan_code-0.19.0 → witan_code-0.20.0}/tests/test_bridge.py +0 -0
  28. {witan_code-0.19.0 → witan_code-0.20.0}/tests/test_chunked_load.py +0 -0
  29. {witan_code-0.19.0 → witan_code-0.20.0}/tests/test_cli_dispatch.py +0 -0
  30. {witan_code-0.19.0 → witan_code-0.20.0}/tests/test_cli_hooks.py +0 -0
  31. {witan_code-0.19.0 → witan_code-0.20.0}/tests/test_cli_output_format.py +0 -0
  32. {witan_code-0.19.0 → witan_code-0.20.0}/tests/test_cli_selected_target.py +0 -0
  33. {witan_code-0.19.0 → witan_code-0.20.0}/tests/test_config.py +0 -0
  34. {witan_code-0.19.0 → witan_code-0.20.0}/tests/test_config_targets.py +0 -0
  35. {witan_code-0.19.0 → witan_code-0.20.0}/tests/test_context.py +0 -0
  36. {witan_code-0.19.0 → witan_code-0.20.0}/tests/test_crossrepo.py +0 -0
  37. {witan_code-0.19.0 → witan_code-0.20.0}/tests/test_edges.py +0 -0
  38. {witan_code-0.19.0 → witan_code-0.20.0}/tests/test_elicit.py +0 -0
  39. {witan_code-0.19.0 → witan_code-0.20.0}/tests/test_enrichment.py +0 -0
  40. {witan_code-0.19.0 → witan_code-0.20.0}/tests/test_github_app.py +0 -0
  41. {witan_code-0.19.0 → witan_code-0.20.0}/tests/test_graph.py +0 -0
  42. {witan_code-0.19.0 → witan_code-0.20.0}/tests/test_hooks.py +0 -0
  43. {witan_code-0.19.0 → witan_code-0.20.0}/tests/test_identity.py +0 -0
  44. {witan_code-0.19.0 → witan_code-0.20.0}/tests/test_index_failure_diagnostics.py +0 -0
  45. {witan_code-0.19.0 → witan_code-0.20.0}/tests/test_index_reports_its_store.py +0 -0
  46. {witan_code-0.19.0 → witan_code-0.20.0}/tests/test_index_requires_a_repo_key.py +0 -0
  47. {witan_code-0.19.0 → witan_code-0.20.0}/tests/test_indexer.py +0 -0
  48. {witan_code-0.19.0 → witan_code-0.20.0}/tests/test_indexer_scope.py +0 -0
  49. {witan_code-0.19.0 → witan_code-0.20.0}/tests/test_ingest.py +0 -0
  50. {witan_code-0.19.0 → witan_code-0.20.0}/tests/test_languages.py +0 -0
  51. {witan_code-0.19.0 → witan_code-0.20.0}/tests/test_maintenance.py +0 -0
  52. {witan_code-0.19.0 → witan_code-0.20.0}/tests/test_package_map.py +0 -0
  53. {witan_code-0.19.0 → witan_code-0.20.0}/tests/test_rebuild_honours_the_repo_key.py +0 -0
  54. {witan_code-0.19.0 → witan_code-0.20.0}/tests/test_remote_proxy.py +0 -0
  55. {witan_code-0.19.0 → witan_code-0.20.0}/tests/test_remote_store.py +0 -0
  56. {witan_code-0.19.0 → witan_code-0.20.0}/tests/test_repo.py +0 -0
  57. {witan_code-0.19.0 → witan_code-0.20.0}/tests/test_server.py +0 -0
  58. {witan_code-0.19.0 → witan_code-0.20.0}/tests/test_setup.py +0 -0
  59. {witan_code-0.19.0 → witan_code-0.20.0}/tests/test_stitch.py +0 -0
  60. {witan_code-0.19.0 → witan_code-0.20.0}/tests/test_symbol_table.py +0 -0
  61. {witan_code-0.19.0 → witan_code-0.20.0}/tests/test_tasks.py +0 -0
  62. {witan_code-0.19.0 → witan_code-0.20.0}/tests/test_tool_schema.py +0 -0
  63. {witan_code-0.19.0 → witan_code-0.20.0}/tests/test_views.py +0 -0
  64. {witan_code-0.19.0 → witan_code-0.20.0}/tests/test_visualize.py +0 -0
  65. {witan_code-0.19.0 → witan_code-0.20.0}/witan_code/__init__.py +0 -0
  66. {witan_code-0.19.0 → witan_code-0.20.0}/witan_code/__main__.py +0 -0
  67. {witan_code-0.19.0 → witan_code-0.20.0}/witan_code/bridge.py +0 -0
  68. {witan_code-0.19.0 → witan_code-0.20.0}/witan_code/bridge_extractors.py +0 -0
  69. {witan_code-0.19.0 → witan_code-0.20.0}/witan_code/cli.py +0 -0
  70. {witan_code-0.19.0 → witan_code-0.20.0}/witan_code/config.py +0 -0
  71. {witan_code-0.19.0 → witan_code-0.20.0}/witan_code/context.py +0 -0
  72. {witan_code-0.19.0 → witan_code-0.20.0}/witan_code/edges.py +0 -0
  73. {witan_code-0.19.0 → witan_code-0.20.0}/witan_code/elicit.py +0 -0
  74. {witan_code-0.19.0 → witan_code-0.20.0}/witan_code/extensions/pi/codegraph.ts +0 -0
  75. {witan_code-0.19.0 → witan_code-0.20.0}/witan_code/github_app.py +0 -0
  76. {witan_code-0.19.0 → witan_code-0.20.0}/witan_code/hooks.py +0 -0
  77. {witan_code-0.19.0 → witan_code-0.20.0}/witan_code/identity.py +0 -0
  78. {witan_code-0.19.0 → witan_code-0.20.0}/witan_code/indexer.py +0 -0
  79. {witan_code-0.19.0 → witan_code-0.20.0}/witan_code/ingest.py +0 -0
  80. {witan_code-0.19.0 → witan_code-0.20.0}/witan_code/maintenance.py +0 -0
  81. {witan_code-0.19.0 → witan_code-0.20.0}/witan_code/output.py +0 -0
  82. {witan_code-0.19.0 → witan_code-0.20.0}/witan_code/package_map.py +0 -0
  83. {witan_code-0.19.0 → witan_code-0.20.0}/witan_code/queries/bridge.gq +0 -0
  84. {witan_code-0.19.0 → witan_code-0.20.0}/witan_code/queries/code_mutations.gq +0 -0
  85. {witan_code-0.19.0 → witan_code-0.20.0}/witan_code/queries/code_read.gq +0 -0
  86. {witan_code-0.19.0 → witan_code-0.20.0}/witan_code/queries/delete.gq +0 -0
  87. {witan_code-0.19.0 → witan_code-0.20.0}/witan_code/queries_ts/bash.scm +0 -0
  88. {witan_code-0.19.0 → witan_code-0.20.0}/witan_code/queries_ts/hcl.scm +0 -0
  89. {witan_code-0.19.0 → witan_code-0.20.0}/witan_code/queries_ts/python.scm +0 -0
  90. {witan_code-0.19.0 → witan_code-0.20.0}/witan_code/queries_ts/sql.scm +0 -0
  91. {witan_code-0.19.0 → witan_code-0.20.0}/witan_code/queries_ts/typescript.scm +0 -0
  92. {witan_code-0.19.0 → witan_code-0.20.0}/witan_code/queries_ts/yaml.scm +0 -0
  93. {witan_code-0.19.0 → witan_code-0.20.0}/witan_code/reaper.py +0 -0
  94. {witan_code-0.19.0 → witan_code-0.20.0}/witan_code/remote/__init__.py +0 -0
  95. {witan_code-0.19.0 → witan_code-0.20.0}/witan_code/remote/oidc.py +0 -0
  96. {witan_code-0.19.0 → witan_code-0.20.0}/witan_code/remote/proxy.py +0 -0
  97. {witan_code-0.19.0 → witan_code-0.20.0}/witan_code/remote/store.py +0 -0
  98. {witan_code-0.19.0 → witan_code-0.20.0}/witan_code/repo.py +0 -0
  99. {witan_code-0.19.0 → witan_code-0.20.0}/witan_code/schema/bridge-schema.pg +0 -0
  100. {witan_code-0.19.0 → witan_code-0.20.0}/witan_code/schema/code-schema.pg +0 -0
  101. {witan_code-0.19.0 → witan_code-0.20.0}/witan_code/selected_target.py +0 -0
  102. {witan_code-0.19.0 → witan_code-0.20.0}/witan_code/server.py +0 -0
  103. {witan_code-0.19.0 → witan_code-0.20.0}/witan_code/setup.py +0 -0
  104. {witan_code-0.19.0 → witan_code-0.20.0}/witan_code/skills/witan-code/SKILL.md +0 -0
  105. {witan_code-0.19.0 → witan_code-0.20.0}/witan_code/stitch.py +0 -0
  106. {witan_code-0.19.0 → witan_code-0.20.0}/witan_code/views.py +0 -0
  107. {witan_code-0.19.0 → witan_code-0.20.0}/witan_code/visualize.py +0 -0
@@ -8,6 +8,54 @@ 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
+
52
+ ## [0.19.1] - 2026-09-18
53
+
54
+ ### Changed
55
+
56
+ - **Require `witan-core>=0.37`.** This keeps fresh `witan-code` installs on the
57
+ current shared core release alongside `witan-council`.
58
+
11
59
  ## [0.19.0] - 2026-09-16
12
60
 
13
61
  ### Changed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: witan-code
3
- Version: 0.19.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.36
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.19.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"
@@ -137,12 +137,12 @@ dependencies = [
137
137
  # ★ >=0.35 IS AN IMPORT FLOOR too: `store.py` and `ingest.py` import
138
138
  # `witan_core.refusal`, added in witan-core 0.35.0 in the same change.
139
139
  #
140
- # ★ >=0.36 IS A FORMAT FLOOR, and the silent kind: nothing new is imported,
141
- # but 0.36.0 is the release that installs omnigraph 0.11, the only binary
142
- # that parses the keyed `code-schema.pg`, and restores the null fields 0.11
140
+ # ★ >=0.37 KEEPS BOTH SERVERS ON THE CURRENT SHARED CORE RELEASE. It also
141
+ # preserves >=0.36's format floor: omnigraph 0.11 is the only binary that
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.36,<1",
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.19.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
- ("error", "listed"),
199
+ ("exc", "listed"),
198
200
  [
199
- ("omnigraph branch failed (exit 1):\npermission denied", ["main"]),
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
- "omnigraph branch failed (exit 1):\nbranch 'feature_race' already exists",
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, error, listed
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 RuntimeError(error)
240
+ raise exc
221
241
 
222
- monkeypatch.setattr(client, "_run", fail)
242
+ monkeypatch.setattr(client, "statement", fail)
223
243
  with pytest.raises(RuntimeError) as excinfo:
224
244
  client.ensure_branch()
225
- assert str(excinfo.value) == error
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 OmnigraphClient
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
- result = self._run("branch", "list", "--json")
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("branches", parsed) if isinstance(parsed, dict) else parsed
331
+ rows = parsed.get("rows", parsed) if isinstance(parsed, dict) else parsed
220
332
  if not isinstance(rows, list):
221
333
  return []
222
- out: list[str] = []
223
- for row in rows:
224
- name = row.get("name") if isinstance(row, dict) else row
225
- if isinstance(name, str):
226
- out.append(name)
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 or self.branch in self.list_branches():
375
+ if self.branch is None:
236
376
  return
237
- try:
238
- self._run("branch", "create", self.branch, "--from", "main")
239
- except RuntimeError as exc:
240
- # Two concurrent `code_store_open` calls for one view both see it
241
- # missing, and the second create fails. The branch exists either
242
- # way, which is all this method promises. Re-listing confirms that
243
- # rather than trusting the message alone.
244
- if "already exists" not in str(exc) or self.branch not in (
245
- self.list_branches()
246
- ):
247
- raise
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
- self._run("branch", "delete", name, "--yes")
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
- # and `branch` name their branch positionally or as their own flag, so
309
- # injecting this client's would either duplicate the flag or silently
310
- # retarget the call.
311
- if self.branch is None or subcommand in (
312
- "branch",
313
- "commit",
314
- "optimize",
315
- "cleanup",
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 --graph <id>``, the cheapest
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