witan-code 0.13.5__tar.gz → 0.14.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.13.5 → witan_code-0.14.0}/CHANGELOG.md +39 -0
- {witan_code-0.13.5 → witan_code-0.14.0}/PKG-INFO +2 -2
- {witan_code-0.13.5 → witan_code-0.14.0}/docs/USER_GUIDE.md +7 -0
- {witan_code-0.13.5 → witan_code-0.14.0}/pyproject.toml +36 -7
- witan_code-0.14.0/tests/test_index_failure_diagnostics.py +149 -0
- {witan_code-0.13.5 → witan_code-0.14.0}/witan_code/cli.py +20 -4
- {witan_code-0.13.5 → witan_code-0.14.0}/witan_code/indexer.py +72 -4
- {witan_code-0.13.5 → witan_code-0.14.0}/.gitignore +0 -0
- {witan_code-0.13.5 → witan_code-0.14.0}/README.md +0 -0
- {witan_code-0.13.5 → witan_code-0.14.0}/config/claude.json +0 -0
- {witan_code-0.13.5 → witan_code-0.14.0}/config/copilot.json +0 -0
- {witan_code-0.13.5 → witan_code-0.14.0}/config/pi.json +0 -0
- {witan_code-0.13.5 → witan_code-0.14.0}/docs/BRANCH_INDEXING.md +0 -0
- {witan_code-0.13.5 → witan_code-0.14.0}/docs/CLI_REFERENCE.md +0 -0
- {witan_code-0.13.5 → witan_code-0.14.0}/docs/EDGE_PRECISION_TIERS.md +0 -0
- {witan_code-0.13.5 → witan_code-0.14.0}/docs/PACKAGE_MAP.md +0 -0
- {witan_code-0.13.5 → witan_code-0.14.0}/docs/STAGE2_STITCHING.md +0 -0
- {witan_code-0.13.5 → witan_code-0.14.0}/docs/SYMBOL_FORMAT.md +0 -0
- {witan_code-0.13.5 → witan_code-0.14.0}/docs/SYMBOL_TABLE.md +0 -0
- {witan_code-0.13.5 → witan_code-0.14.0}/install.sh +0 -0
- {witan_code-0.13.5 → witan_code-0.14.0}/tests/__init__.py +0 -0
- {witan_code-0.13.5 → witan_code-0.14.0}/tests/conftest.py +0 -0
- {witan_code-0.13.5 → witan_code-0.14.0}/tests/test_batched_deletes.py +0 -0
- {witan_code-0.13.5 → witan_code-0.14.0}/tests/test_branches.py +0 -0
- {witan_code-0.13.5 → witan_code-0.14.0}/tests/test_bridge.py +0 -0
- {witan_code-0.13.5 → witan_code-0.14.0}/tests/test_chunked_load.py +0 -0
- {witan_code-0.13.5 → witan_code-0.14.0}/tests/test_cli_dispatch.py +0 -0
- {witan_code-0.13.5 → witan_code-0.14.0}/tests/test_cli_hooks.py +0 -0
- {witan_code-0.13.5 → witan_code-0.14.0}/tests/test_cli_output_format.py +0 -0
- {witan_code-0.13.5 → witan_code-0.14.0}/tests/test_config.py +0 -0
- {witan_code-0.13.5 → witan_code-0.14.0}/tests/test_config_targets.py +0 -0
- {witan_code-0.13.5 → witan_code-0.14.0}/tests/test_context.py +0 -0
- {witan_code-0.13.5 → witan_code-0.14.0}/tests/test_crossrepo.py +0 -0
- {witan_code-0.13.5 → witan_code-0.14.0}/tests/test_edges.py +0 -0
- {witan_code-0.13.5 → witan_code-0.14.0}/tests/test_elicit.py +0 -0
- {witan_code-0.13.5 → witan_code-0.14.0}/tests/test_enrichment.py +0 -0
- {witan_code-0.13.5 → witan_code-0.14.0}/tests/test_github_app.py +0 -0
- {witan_code-0.13.5 → witan_code-0.14.0}/tests/test_graph.py +0 -0
- {witan_code-0.13.5 → witan_code-0.14.0}/tests/test_hooks.py +0 -0
- {witan_code-0.13.5 → witan_code-0.14.0}/tests/test_identity.py +0 -0
- {witan_code-0.13.5 → witan_code-0.14.0}/tests/test_indexer.py +0 -0
- {witan_code-0.13.5 → witan_code-0.14.0}/tests/test_indexer_scope.py +0 -0
- {witan_code-0.13.5 → witan_code-0.14.0}/tests/test_ingest.py +0 -0
- {witan_code-0.13.5 → witan_code-0.14.0}/tests/test_languages.py +0 -0
- {witan_code-0.13.5 → witan_code-0.14.0}/tests/test_maintenance.py +0 -0
- {witan_code-0.13.5 → witan_code-0.14.0}/tests/test_package_map.py +0 -0
- {witan_code-0.13.5 → witan_code-0.14.0}/tests/test_reaper.py +0 -0
- {witan_code-0.13.5 → witan_code-0.14.0}/tests/test_remote_proxy.py +0 -0
- {witan_code-0.13.5 → witan_code-0.14.0}/tests/test_remote_store.py +0 -0
- {witan_code-0.13.5 → witan_code-0.14.0}/tests/test_repo.py +0 -0
- {witan_code-0.13.5 → witan_code-0.14.0}/tests/test_server.py +0 -0
- {witan_code-0.13.5 → witan_code-0.14.0}/tests/test_setup.py +0 -0
- {witan_code-0.13.5 → witan_code-0.14.0}/tests/test_stitch.py +0 -0
- {witan_code-0.13.5 → witan_code-0.14.0}/tests/test_store.py +0 -0
- {witan_code-0.13.5 → witan_code-0.14.0}/tests/test_symbol_table.py +0 -0
- {witan_code-0.13.5 → witan_code-0.14.0}/tests/test_tasks.py +0 -0
- {witan_code-0.13.5 → witan_code-0.14.0}/tests/test_tool_schema.py +0 -0
- {witan_code-0.13.5 → witan_code-0.14.0}/tests/test_views.py +0 -0
- {witan_code-0.13.5 → witan_code-0.14.0}/tests/test_visualize.py +0 -0
- {witan_code-0.13.5 → witan_code-0.14.0}/witan_code/__init__.py +0 -0
- {witan_code-0.13.5 → witan_code-0.14.0}/witan_code/__main__.py +0 -0
- {witan_code-0.13.5 → witan_code-0.14.0}/witan_code/bridge.py +0 -0
- {witan_code-0.13.5 → witan_code-0.14.0}/witan_code/bridge_extractors.py +0 -0
- {witan_code-0.13.5 → witan_code-0.14.0}/witan_code/config.py +0 -0
- {witan_code-0.13.5 → witan_code-0.14.0}/witan_code/context.py +0 -0
- {witan_code-0.13.5 → witan_code-0.14.0}/witan_code/edges.py +0 -0
- {witan_code-0.13.5 → witan_code-0.14.0}/witan_code/elicit.py +0 -0
- {witan_code-0.13.5 → witan_code-0.14.0}/witan_code/extensions/pi/codegraph.ts +0 -0
- {witan_code-0.13.5 → witan_code-0.14.0}/witan_code/github_app.py +0 -0
- {witan_code-0.13.5 → witan_code-0.14.0}/witan_code/graph.py +0 -0
- {witan_code-0.13.5 → witan_code-0.14.0}/witan_code/hooks.py +0 -0
- {witan_code-0.13.5 → witan_code-0.14.0}/witan_code/identity.py +0 -0
- {witan_code-0.13.5 → witan_code-0.14.0}/witan_code/ingest.py +0 -0
- {witan_code-0.13.5 → witan_code-0.14.0}/witan_code/maintenance.py +0 -0
- {witan_code-0.13.5 → witan_code-0.14.0}/witan_code/output.py +0 -0
- {witan_code-0.13.5 → witan_code-0.14.0}/witan_code/package_map.py +0 -0
- {witan_code-0.13.5 → witan_code-0.14.0}/witan_code/queries/bridge.gq +0 -0
- {witan_code-0.13.5 → witan_code-0.14.0}/witan_code/queries/code_mutations.gq +0 -0
- {witan_code-0.13.5 → witan_code-0.14.0}/witan_code/queries/code_read.gq +0 -0
- {witan_code-0.13.5 → witan_code-0.14.0}/witan_code/queries/delete.gq +0 -0
- {witan_code-0.13.5 → witan_code-0.14.0}/witan_code/queries_ts/bash.scm +0 -0
- {witan_code-0.13.5 → witan_code-0.14.0}/witan_code/queries_ts/hcl.scm +0 -0
- {witan_code-0.13.5 → witan_code-0.14.0}/witan_code/queries_ts/python.scm +0 -0
- {witan_code-0.13.5 → witan_code-0.14.0}/witan_code/queries_ts/sql.scm +0 -0
- {witan_code-0.13.5 → witan_code-0.14.0}/witan_code/queries_ts/typescript.scm +0 -0
- {witan_code-0.13.5 → witan_code-0.14.0}/witan_code/queries_ts/yaml.scm +0 -0
- {witan_code-0.13.5 → witan_code-0.14.0}/witan_code/reaper.py +0 -0
- {witan_code-0.13.5 → witan_code-0.14.0}/witan_code/remote/__init__.py +0 -0
- {witan_code-0.13.5 → witan_code-0.14.0}/witan_code/remote/oidc.py +0 -0
- {witan_code-0.13.5 → witan_code-0.14.0}/witan_code/remote/proxy.py +0 -0
- {witan_code-0.13.5 → witan_code-0.14.0}/witan_code/remote/store.py +0 -0
- {witan_code-0.13.5 → witan_code-0.14.0}/witan_code/repo.py +0 -0
- {witan_code-0.13.5 → witan_code-0.14.0}/witan_code/schema/bridge-schema.pg +0 -0
- {witan_code-0.13.5 → witan_code-0.14.0}/witan_code/schema/code-schema.pg +0 -0
- {witan_code-0.13.5 → witan_code-0.14.0}/witan_code/server.py +0 -0
- {witan_code-0.13.5 → witan_code-0.14.0}/witan_code/setup.py +0 -0
- {witan_code-0.13.5 → witan_code-0.14.0}/witan_code/skills/witan-code/SKILL.md +0 -0
- {witan_code-0.13.5 → witan_code-0.14.0}/witan_code/stitch.py +0 -0
- {witan_code-0.13.5 → witan_code-0.14.0}/witan_code/store.py +0 -0
- {witan_code-0.13.5 → witan_code-0.14.0}/witan_code/views.py +0 -0
- {witan_code-0.13.5 → witan_code-0.14.0}/witan_code/visualize.py +0 -0
|
@@ -6,6 +6,45 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/) (pre-1.0:
|
|
7
7
|
a MINOR bump may include breaking changes).
|
|
8
8
|
|
|
9
|
+
## [0.14.0] - 2026-08-21
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- **A failed index now reports at least as much as a successful one.** On
|
|
14
|
+
success `witan code index` prints `scanned=… indexed=… skipped=… symbols=…`;
|
|
15
|
+
on failure the exception replaced that line entirely, so the run you most
|
|
16
|
+
need numbers from was the only one that produced none. Attributing the CI
|
|
17
|
+
indexer's `ol-infrastructure` timeout (0.13.3) therefore took five days and
|
|
18
|
+
needed a traceback line number, a source read to find the batch constant, row
|
|
19
|
+
counts from three graphs, an ECR check, and a live timing experiment —
|
|
20
|
+
nearly all of it answerable from numbers the process already had.
|
|
21
|
+
|
|
22
|
+
`index_path` now raises `IndexFailed`, carrying the partial `IndexStats`, the
|
|
23
|
+
write phase that died (the stale-row delete, or either of the two loads), how
|
|
24
|
+
long it ran, and the sizes it was working with — statements composed, chunk
|
|
25
|
+
size in use, records loaded. The CLI prints those stats under `partial …` and
|
|
26
|
+
re-raises, so the exit code and traceback are unchanged. The session hooks
|
|
27
|
+
and the store-missing fallbacks already catch broadly and are unaffected;
|
|
28
|
+
`code_reindex` still propagates, and its caller now gets a message carrying
|
|
29
|
+
the phase and the sizes rather than a bare transport error.
|
|
30
|
+
|
|
31
|
+
Requires `witan-core>=0.31` for the transport and batching halves of the same
|
|
32
|
+
change: a timeout that names its budget, and a chunk failure that names its
|
|
33
|
+
position. (0.31, not 0.30 — agent-kit#280 took 0.30.0 for unrelated work while
|
|
34
|
+
this was open, so the release carrying those halves is 0.31.0.)
|
|
35
|
+
|
|
36
|
+
### Changed
|
|
37
|
+
|
|
38
|
+
- **The CI sweep's last line names the failed repos, not just the count.**
|
|
39
|
+
`indexed 13, failed 1` is the first thing an operator reads and did not say
|
|
40
|
+
which of fourteen repos was a run staler than it should be, so answering that
|
|
41
|
+
meant scrolling back through fourteen repos' output.
|
|
42
|
+
|
|
43
|
+
The exit status stays non-zero for a partial sweep, and the reasoning is now
|
|
44
|
+
recorded next to it: a Job that reports Complete with one repo stale is a Job
|
|
45
|
+
nothing can alert on, and a stale index has no other symptom — which is why
|
|
46
|
+
three consecutive failures from 2026-08-07 went unnoticed for two days.
|
|
47
|
+
|
|
9
48
|
## [0.13.5] - 2026-08-21
|
|
10
49
|
|
|
11
50
|
### Changed
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: witan-code
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.14.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.31
|
|
30
30
|
Provides-Extra: tasks
|
|
31
31
|
Requires-Dist: fastmcp-tasks<5,>=4.0.0b1; extra == 'tasks'
|
|
32
32
|
Description-Content-Type: text/markdown
|
|
@@ -110,6 +110,13 @@ copy the matching snippet from `config/` into your agent's config — see
|
|
|
110
110
|
errors=…`). Re-running `index` is incremental — unchanged files are
|
|
111
111
|
skipped by content hash; use `reindex` to force a full rebuild.
|
|
112
112
|
|
|
113
|
+
A run that fails while writing prints the same counts under `partial …`,
|
|
114
|
+
followed by the phase that failed, how long it ran, and the batch sizes it
|
|
115
|
+
was working with. Those numbers are the diagnosis: they say whether the
|
|
116
|
+
write was large, how far it got, and which of the deletes or the two loads
|
|
117
|
+
died. The exit status is unchanged, so a sweep still counts the repo as
|
|
118
|
+
failed.
|
|
119
|
+
|
|
113
120
|
3. **Ask "where is this defined"** — via the CLI or, more commonly, through
|
|
114
121
|
an agent calling the MCP tool directly:
|
|
115
122
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "witan-code"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.14.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"
|
|
@@ -18,10 +18,18 @@ dependencies = [
|
|
|
18
18
|
# >=0.12: remote/store.py imports RemotePayloadTooLarge and
|
|
19
19
|
# payload_too_large from witan_core.remote.proxy at module scope, and 0.11
|
|
20
20
|
# exports neither. A looser floor lets an external install resolve a
|
|
21
|
-
# witan-core the server cannot even import; that is what this pin is for
|
|
22
|
-
#
|
|
23
|
-
#
|
|
24
|
-
#
|
|
21
|
+
# witan-core the server cannot even import; that is what this pin is for.
|
|
22
|
+
# If you add a witan_core symbol in the same change as its caller, bump this
|
|
23
|
+
# line in that change: the workspace resolves witan-core by path, so no test
|
|
24
|
+
# run in it can catch you.
|
|
25
|
+
#
|
|
26
|
+
# ★ `just check-core-floor` NOW CATCHES THAT, on ordinary PR CI
|
|
27
|
+
# (.github/workflows/core-floor.yml). It installs this package's wheel into
|
|
28
|
+
# a clean venv with witan-core pinned to exactly the floor below and imports
|
|
29
|
+
# every module — the failure mode itself, not a model of it. It escaped
|
|
30
|
+
# three times before that check existed (#198, #200, #201), each time with a
|
|
31
|
+
# green CI; the tally used to live in this comment, which was a comment
|
|
32
|
+
# doing a test's job.
|
|
25
33
|
# (NOT raised for witan-core 0.19/0.20's trace-context work, deliberately.
|
|
26
34
|
# Nothing here imports `trace_context_middleware`, and standalone
|
|
27
35
|
# `witan-code serve` is stdio-only — the ASGI middleware is installed by
|
|
@@ -87,7 +95,28 @@ dependencies = [
|
|
|
87
95
|
# Expect this floor to keep moving while omnigraph 0.10.0 stays on the
|
|
88
96
|
# moving `edge` tag; see
|
|
89
97
|
# packages/witan-core/witan_core/omnigraph_install.py.
|
|
90
|
-
|
|
98
|
+
#
|
|
99
|
+
# ★ >=0.31 IS THE THIRD NON-IMPORT FLOOR, and the narrowest: it buys
|
|
100
|
+
# message content, not correctness. Nothing new is imported and an older
|
|
101
|
+
# witan-core runs fine. But witan-code 0.14.0 is ENTIRELY a diagnostics
|
|
102
|
+
# release, and two of the four questions a failed index has to answer are
|
|
103
|
+
# answered on the witan-core side — which chunk of how many failed
|
|
104
|
+
# (`change_many`), and which timeout budget expired (`omnigraph_http`).
|
|
105
|
+
# Resolved below 0.31 the run still prints its partial summary and names
|
|
106
|
+
# the phase, and the inner error is still the bare "timed out" this
|
|
107
|
+
# release exists to replace. A package whose changelog promises a
|
|
108
|
+
# self-describing failure and delivers half of one is the quiet wrongness
|
|
109
|
+
# the two floors above were raised to avoid.
|
|
110
|
+
#
|
|
111
|
+
# ★ THE NUMBER MOVED 0.30 -> 0.31 ON REBASE, and that is the whole hazard
|
|
112
|
+
# this note exists to flag. agent-kit#280 took 0.30.0 for unrelated work
|
|
113
|
+
# while this branch was open, so the release carrying the diagnostics is
|
|
114
|
+
# 0.31.0 — and a floor left at >=0.30 would resolve #280's release instead
|
|
115
|
+
# and quietly deliver the half-message described above. `just
|
|
116
|
+
# check-core-floor` cannot catch it: nothing new is imported, so the
|
|
117
|
+
# clean-install import check passes at either number. Re-check this line by
|
|
118
|
+
# hand whenever this branch is rebased.
|
|
119
|
+
"witan-core[cli,remote,observability,sentry]>=0.31,<1",
|
|
91
120
|
"fastmcp>=3.4.2,<5",
|
|
92
121
|
"cyclopts>=4,<5",
|
|
93
122
|
# [targets.<name>] override models (config.py) — also transitively pulled
|
|
@@ -171,7 +200,7 @@ testpaths = ["tests"]
|
|
|
171
200
|
packages = ["witan_code"]
|
|
172
201
|
|
|
173
202
|
[tool.bumpversion]
|
|
174
|
-
current_version = "0.
|
|
203
|
+
current_version = "0.14.0"
|
|
175
204
|
allow_dirty = true
|
|
176
205
|
|
|
177
206
|
[[tool.bumpversion.files]]
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""A failed index has to report at least as much as a successful one.
|
|
2
|
+
|
|
3
|
+
The run you most need numbers from was the only one that produced none: on
|
|
4
|
+
success `witan code index` prints `scanned=… indexed=… skipped=… symbols=…`,
|
|
5
|
+
and on failure the exception replaced that line entirely. Attributing the CI
|
|
6
|
+
indexer's `ol-infrastructure` timeout therefore needed a traceback line number,
|
|
7
|
+
a source read to find the batch constant, row counts from three graphs and a
|
|
8
|
+
live timing experiment — nearly all of which the numbers below answer directly.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
import pytest
|
|
16
|
+
|
|
17
|
+
from witan_code import cli as cli_module
|
|
18
|
+
from witan_code import indexer
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _stats(**kwargs) -> indexer.IndexStats:
|
|
22
|
+
return indexer.IndexStats(**kwargs)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# ── the exception carries the sizes ────────────────────────────────
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_a_write_failure_keeps_the_partial_stats():
|
|
29
|
+
stats = _stats(scanned=657, indexed=165, skipped=492, symbols=4102)
|
|
30
|
+
|
|
31
|
+
with pytest.raises(indexer.IndexFailed) as raised:
|
|
32
|
+
with indexer._write_phase("delete of stale rows", stats, statements=330):
|
|
33
|
+
raise RuntimeError("timed out")
|
|
34
|
+
|
|
35
|
+
assert raised.value.stats is stats
|
|
36
|
+
assert raised.value.stats.scanned == 657
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_the_message_names_the_phase_and_what_it_was_working_with():
|
|
40
|
+
with pytest.raises(indexer.IndexFailed) as raised:
|
|
41
|
+
with indexer._write_phase(
|
|
42
|
+
"delete of stale rows", _stats(), statements=330, chunk_size=128
|
|
43
|
+
):
|
|
44
|
+
raise RuntimeError("mutate failed: timed out")
|
|
45
|
+
|
|
46
|
+
message = str(raised.value)
|
|
47
|
+
assert message.startswith("delete of stale rows failed after ")
|
|
48
|
+
assert "statements=330" in message
|
|
49
|
+
assert "chunk_size=128" in message
|
|
50
|
+
assert "mutate failed: timed out" in message
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_the_real_error_stays_reachable():
|
|
54
|
+
"""The wrapper adds sizes; it must not cost anyone the original."""
|
|
55
|
+
cause = RuntimeError("timed out")
|
|
56
|
+
|
|
57
|
+
with pytest.raises(indexer.IndexFailed) as raised:
|
|
58
|
+
with indexer._write_phase("load of nodes and edges", _stats(), records=9000):
|
|
59
|
+
raise cause
|
|
60
|
+
|
|
61
|
+
assert raised.value.__cause__ is cause
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_elapsed_is_measured_not_guessed(monkeypatch):
|
|
65
|
+
"""Elapsed is what says whether a client budget was reached or something
|
|
66
|
+
died early — a server-side cutoff and a client timeout both arrive as a
|
|
67
|
+
failed write, and only the elapsed time separates them."""
|
|
68
|
+
clock = {"t": 10.0}
|
|
69
|
+
monkeypatch.setattr(indexer.time, "monotonic", lambda: clock["t"])
|
|
70
|
+
|
|
71
|
+
with pytest.raises(indexer.IndexFailed) as raised:
|
|
72
|
+
with indexer._write_phase("delete of stale rows", _stats()):
|
|
73
|
+
clock["t"] += 181.3
|
|
74
|
+
raise RuntimeError("timed out")
|
|
75
|
+
|
|
76
|
+
assert raised.value.elapsed == pytest.approx(181.3)
|
|
77
|
+
assert "failed after 181.3s" in str(raised.value)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def test_a_successful_phase_is_transparent():
|
|
81
|
+
with indexer._write_phase("load of nodes and edges", _stats(), records=1):
|
|
82
|
+
pass
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
# ── the CLI prints them ────────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def test_the_cli_prints_the_partial_summary_before_re_raising(monkeypatch, capsys):
|
|
89
|
+
stats = _stats(scanned=657, indexed=165, skipped=492, symbols=4102, purged=3)
|
|
90
|
+
|
|
91
|
+
def failing_index(path, *, force=False, **kwargs):
|
|
92
|
+
raise indexer.IndexFailed(
|
|
93
|
+
"delete of stale rows",
|
|
94
|
+
RuntimeError("timed out after 181.0s"),
|
|
95
|
+
stats=stats,
|
|
96
|
+
elapsed=181.0,
|
|
97
|
+
detail={"statements": 330, "chunk_size": 128},
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
monkeypatch.setattr(indexer, "index_path", failing_index)
|
|
101
|
+
|
|
102
|
+
with pytest.raises(indexer.IndexFailed):
|
|
103
|
+
cli_module.index(Path("."))
|
|
104
|
+
|
|
105
|
+
captured = capsys.readouterr()
|
|
106
|
+
# every number the successful path would have printed
|
|
107
|
+
assert "scanned=657" in captured.out
|
|
108
|
+
assert "indexed=165" in captured.out
|
|
109
|
+
assert "symbols=4102" in captured.out
|
|
110
|
+
assert "purged=3" in captured.out
|
|
111
|
+
# plus what failed, on stderr where the sweep script already redirects
|
|
112
|
+
assert "failed in delete of stale rows" in captured.err
|
|
113
|
+
assert "statements=330" in captured.err
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def test_the_failure_still_propagates(monkeypatch):
|
|
117
|
+
"""Printing the summary must not turn a failed index into a successful one:
|
|
118
|
+
the sweep counts a repo as indexed by exit status alone."""
|
|
119
|
+
|
|
120
|
+
def failing_index(path, *, force=False, **kwargs):
|
|
121
|
+
raise indexer.IndexFailed(
|
|
122
|
+
"load of content hashes",
|
|
123
|
+
RuntimeError("boom"),
|
|
124
|
+
stats=_stats(),
|
|
125
|
+
elapsed=1.0,
|
|
126
|
+
detail={},
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
monkeypatch.setattr(indexer, "index_path", failing_index)
|
|
130
|
+
|
|
131
|
+
with pytest.raises(indexer.IndexFailed):
|
|
132
|
+
cli_module.reindex(Path("."))
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def test_a_failure_that_is_not_a_write_phase_is_left_alone(monkeypatch, capsys):
|
|
136
|
+
"""Only a write failure has partial stats worth printing. Anything else —
|
|
137
|
+
an unreadable config, a refused branch — has none, and inventing a summary
|
|
138
|
+
of zeros would read as "the repo is empty" rather than "it never started".
|
|
139
|
+
"""
|
|
140
|
+
|
|
141
|
+
def failing_index(path, *, force=False, **kwargs):
|
|
142
|
+
raise RuntimeError("witan code: refusing to write to the shared view")
|
|
143
|
+
|
|
144
|
+
monkeypatch.setattr(indexer, "index_path", failing_index)
|
|
145
|
+
|
|
146
|
+
with pytest.raises(RuntimeError, match="refusing to write"):
|
|
147
|
+
cli_module.index(Path("."))
|
|
148
|
+
|
|
149
|
+
assert capsys.readouterr().out == ""
|
|
@@ -15,6 +15,7 @@ unconditionally.
|
|
|
15
15
|
|
|
16
16
|
import asyncio
|
|
17
17
|
import inspect
|
|
18
|
+
import sys
|
|
18
19
|
from pathlib import Path
|
|
19
20
|
from typing import Annotated, Literal
|
|
20
21
|
|
|
@@ -138,15 +139,30 @@ def _render_table(
|
|
|
138
139
|
@app.command
|
|
139
140
|
def index(path: Path = Path(".")) -> None:
|
|
140
141
|
"""Incrementally index PATH (file or directory). Unchanged files are skipped."""
|
|
141
|
-
|
|
142
|
-
_print_summary("index", path, stats)
|
|
142
|
+
_print_summary("index", path, _index(path, force=False))
|
|
143
143
|
|
|
144
144
|
|
|
145
145
|
@app.command
|
|
146
146
|
def reindex(path: Path = Path(".")) -> None:
|
|
147
147
|
"""Force re-index PATH, ignoring content hashes."""
|
|
148
|
-
|
|
149
|
-
|
|
148
|
+
_print_summary("reindex", path, _index(path, force=True))
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _index(path: Path, *, force: bool) -> indexer.IndexStats:
|
|
152
|
+
"""``index_path``, but a failure still prints what the run got through.
|
|
153
|
+
|
|
154
|
+
The parse phase's counts are the same numbers a success prints, and they are
|
|
155
|
+
what says whether a write failure is a big-repo problem or not — so losing
|
|
156
|
+
them to the traceback is losing the diagnosis. Printed here rather than
|
|
157
|
+
swallowed: the exception still propagates, so the exit code and the
|
|
158
|
+
traceback are unchanged.
|
|
159
|
+
"""
|
|
160
|
+
try:
|
|
161
|
+
return indexer.index_path(path, force=force)
|
|
162
|
+
except indexer.IndexFailed as exc:
|
|
163
|
+
_print_summary("partial", path, exc.stats)
|
|
164
|
+
print(f"failed in {exc.phase}: {exc}", file=sys.stderr)
|
|
165
|
+
raise
|
|
150
166
|
|
|
151
167
|
|
|
152
168
|
@app.command
|
|
@@ -9,11 +9,13 @@ then imported modules, then any repo-wide match. It is intentionally syntactic
|
|
|
9
9
|
and will miss dynamic dispatch and produce occasional false links.
|
|
10
10
|
"""
|
|
11
11
|
|
|
12
|
+
import contextlib
|
|
12
13
|
import functools
|
|
13
14
|
import hashlib
|
|
14
15
|
import importlib
|
|
15
16
|
import os
|
|
16
|
-
|
|
17
|
+
import time
|
|
18
|
+
from collections.abc import Callable, Iterator
|
|
17
19
|
from dataclasses import dataclass, field
|
|
18
20
|
from pathlib import Path
|
|
19
21
|
|
|
@@ -295,6 +297,62 @@ class IndexStats:
|
|
|
295
297
|
rather than silent."""
|
|
296
298
|
|
|
297
299
|
|
|
300
|
+
class IndexFailed(RuntimeError):
|
|
301
|
+
"""A write phase of :func:`index_path` failed, carrying what it knows.
|
|
302
|
+
|
|
303
|
+
★ THE FAILING RUN IS THE ONE THAT REPORTED LEAST. A successful index prints
|
|
304
|
+
``scanned=… indexed=… skipped=… symbols=…``; a failing one printed only the
|
|
305
|
+
exception, because the exception replaced the summary — so the run you most
|
|
306
|
+
need data from was the only one that gave you none. That is why the CI
|
|
307
|
+
indexer's ``ol-infrastructure`` timeout sat unexplained for five days
|
|
308
|
+
(tk-the-ci-indexer-s-failure-says-only-timed-out-no--50f61c): attributing it
|
|
309
|
+
needed a traceback line number, a source read to find the constant, row
|
|
310
|
+
counts from three graphs and a live timing experiment, almost all of which
|
|
311
|
+
the numbers below would have answered directly.
|
|
312
|
+
|
|
313
|
+
``stats`` is the partial :class:`IndexStats` as of the failure, ``phase``
|
|
314
|
+
names the write that failed, and ``detail`` carries the sizes that phase was
|
|
315
|
+
working with. ``elapsed`` is the seconds that phase ran before dying, which
|
|
316
|
+
is what says whether a timeout was hit or something failed early.
|
|
317
|
+
"""
|
|
318
|
+
|
|
319
|
+
def __init__(
|
|
320
|
+
self,
|
|
321
|
+
phase: str,
|
|
322
|
+
cause: BaseException,
|
|
323
|
+
*,
|
|
324
|
+
stats: IndexStats,
|
|
325
|
+
elapsed: float,
|
|
326
|
+
detail: dict[str, object],
|
|
327
|
+
) -> None:
|
|
328
|
+
self.phase = phase
|
|
329
|
+
self.stats = stats
|
|
330
|
+
self.elapsed = elapsed
|
|
331
|
+
self.detail = detail
|
|
332
|
+
described = " ".join(f"{k}={v}" for k, v in detail.items())
|
|
333
|
+
super().__init__(
|
|
334
|
+
f"{phase} failed after {elapsed:.1f}s"
|
|
335
|
+
+ (f" ({described})" if described else "")
|
|
336
|
+
+ f": {cause}"
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
@contextlib.contextmanager
|
|
341
|
+
def _write_phase(phase: str, stats: IndexStats, **detail: object) -> Iterator[None]:
|
|
342
|
+
"""Re-raise anything from a write as :class:`IndexFailed` with its sizes."""
|
|
343
|
+
started = time.monotonic()
|
|
344
|
+
try:
|
|
345
|
+
yield
|
|
346
|
+
except Exception as exc:
|
|
347
|
+
raise IndexFailed(
|
|
348
|
+
phase,
|
|
349
|
+
exc,
|
|
350
|
+
stats=stats,
|
|
351
|
+
elapsed=time.monotonic() - started,
|
|
352
|
+
detail=detail,
|
|
353
|
+
) from exc
|
|
354
|
+
|
|
355
|
+
|
|
298
356
|
# ── Public entry points ───────────────────────────────────────────
|
|
299
357
|
|
|
300
358
|
|
|
@@ -432,14 +490,24 @@ def index_path(
|
|
|
432
490
|
delete_steps += _delete_file_steps(file_id)
|
|
433
491
|
stats.purged += 1
|
|
434
492
|
|
|
435
|
-
|
|
493
|
+
with _write_phase(
|
|
494
|
+
"delete of stale rows",
|
|
495
|
+
stats,
|
|
496
|
+
statements=len(delete_steps),
|
|
497
|
+
chunk_size=_DELETE_BATCH_SIZE,
|
|
498
|
+
reindexed_files=len(reindexed_file_ids),
|
|
499
|
+
purged_files=stats.purged,
|
|
500
|
+
):
|
|
501
|
+
client.change_many(delete_steps, chunk_size=_DELETE_BATCH_SIZE)
|
|
436
502
|
# Two loads, not one: the second commits the content hashes the first held
|
|
437
503
|
# back, so a load that dies part-way is re-done rather than skipped forever.
|
|
438
504
|
# See `_defer_content_hashes`.
|
|
439
505
|
deduped = _dedupe(records)
|
|
440
506
|
real_hash_rows = _defer_content_hashes(deduped)
|
|
441
|
-
|
|
442
|
-
|
|
507
|
+
with _write_phase("load of nodes and edges", stats, records=len(deduped)):
|
|
508
|
+
client.load(deduped, mode="merge")
|
|
509
|
+
with _write_phase("load of content hashes", stats, records=len(real_hash_rows)):
|
|
510
|
+
client.load(real_hash_rows, mode="merge")
|
|
443
511
|
|
|
444
512
|
# Cross-repo bridge — a SEPARATE phase after the per-repo store write, so the
|
|
445
513
|
# two stores' write locks never nest. A full-repo index (target is the repo
|
|
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
|