feat(telemetry): a usage event records which project the reader was in (#4196, #3735)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 13s
CI & Build / TypeScript typecheck (push) Successful in 54s
CI & Build / integration (push) Successful in 1m5s
CI & Build / Python tests (push) Failing after 1m15s
CI & Build / Build & push image (push) Skipped
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 13s
CI & Build / TypeScript typecheck (push) Successful in 54s
CI & Build / integration (push) Successful in 1m5s
CI & Build / Python tests (push) Failing after 1m15s
CI & Build / Build & push image (push) Skipped
`RetrievalLog` has carried `project_id` since it existed, so "this record was SURFACED on project B" was always answerable. `note_usage_events` had none, so "this record was OPENED on project B" was not — and the two cannot be joined to recover it, because there is deliberately no session identity server-side. NoteUsageEvent's own docstring rules that out. That gap sat exactly on the question milestone 385 exists to answer. A lesson's whole claim is that it reaches a session on a project it was not written on, and step 8's acceptance is "retrieved on a different project AND opened". Each half was answerable; the conjunction was not. WHICH project, because the name is ambiguous and the wrong reading makes the column useless: it is the project the READER was in, never the one the record belongs to. The record's own project is already on the note; copying it here would answer a question nobody asked while looking like it answered this one. The surfacing half is free — every arm already holds the scope it just searched, so auto_inject, lesson_slot, the write-path arms and enter_project now record it. process_skill_sync does not and should not: it installs every Process the operator can reach, which is not a project-scoped question, so a project there would be a fiction. The pull half needs the caller, since a getter knows only what it was handed. The five single-record getters take `project_id: int = 0` and pass it through, following the convention `search` and `create_*` already set. Null stays an ordinary answer meaning "not reported" — a pull with no project is still a pull and still counts toward dead weight; it simply cannot speak to transfer. The four REST detail views report none for now: a human opening a record in a browser is a different event from an agent recalling one, and #2245 left that asymmetry deliberately undecided. Guarded the way #2245 and #2476 taught: by source inspection, because a parameter that was never threaded through changes no return value and shows up only as a column that is mysteriously always null. Three guards — the signature, the pass-through, and the arms — plus the can-fail test rule 167 asks for. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01821k5B3Ysecp9fNYs92Kuy
This commit is contained in:
@@ -201,3 +201,110 @@ def test_every_named_loader_still_exists():
|
||||
f"renamed or moved. Update the list, or the pull check silently stops "
|
||||
f"covering whatever used them."
|
||||
)
|
||||
|
||||
|
||||
# ── the reading project travels with the event (#4196, #3735) ────────────────
|
||||
#
|
||||
# The same sibling-drift shape as above, one dimension over. `RetrievalLog` has
|
||||
# always carried `project_id`, so "surfaced on project B" was answerable; the
|
||||
# usage table had none, so "opened on project B" was not, and the two cannot be
|
||||
# joined (no session identity server-side — NoteUsageEvent's own docstring).
|
||||
# That gap sat exactly on milestone 385's acceptance: a lesson's claim is that
|
||||
# it reaches a session on a project it was NOT written on.
|
||||
#
|
||||
# A getter that takes the project and forgets to pass it fails the same way a
|
||||
# missing record_pulled did: silently, changing no return value, and showing up
|
||||
# only as a column that is mysteriously always null.
|
||||
|
||||
|
||||
def _pulling_getters():
|
||||
"""(module, name, source) for every getter that records a pull."""
|
||||
for module, name, body in _getters():
|
||||
if "record_pulled" in body:
|
||||
yield module, name, body
|
||||
|
||||
|
||||
def test_every_getter_that_pulls_takes_the_reading_project():
|
||||
"""Asserted on structure (rule 167): a behavioural test cannot see a
|
||||
parameter that was never threaded through."""
|
||||
missing = [
|
||||
f"{module}.{name}"
|
||||
for module, name, body in _pulling_getters()
|
||||
if "project_id: int = 0" not in body.split("\n")[0]
|
||||
]
|
||||
assert not missing, (
|
||||
f"these getters record a pull but cannot say where the reader was: "
|
||||
f"{missing}. Add `project_id: int = 0` to the signature — see "
|
||||
f"mcp/tools/notes.py:get_note."
|
||||
)
|
||||
|
||||
|
||||
def test_every_getter_that_pulls_passes_the_project_through():
|
||||
"""Taking the argument and dropping it is worse than not taking it: the
|
||||
signature advertises a dimension the column never receives."""
|
||||
checked, missing = [], []
|
||||
for module, name, body in _pulling_getters():
|
||||
checked.append(f"{module}.{name}")
|
||||
if "project_id=project_id" not in body:
|
||||
missing.append(f"{module}.{name}")
|
||||
assert checked, "found no pulling getters; the detection must have moved"
|
||||
assert not missing, (
|
||||
f"these getters accept a project and do not hand it to record_pulled: "
|
||||
f"{missing}."
|
||||
)
|
||||
|
||||
|
||||
# The arms are the other half: unlike a getter, a surfacing arm ALWAYS knows
|
||||
# the project — it is the scope it just searched — so there is no excuse for a
|
||||
# null there, and `lesson_slot` is the one milestone 385 is measured through.
|
||||
#
|
||||
# `process_skill_sync` is exempt and named here rather than pattern-matched: it
|
||||
# installs every Process the operator can reach, which is not a project-scoped
|
||||
# question, so a project on that row would be a fiction.
|
||||
SURFACING_ARMS_WITHOUT_A_PROJECT = {"process_skill_sync"}
|
||||
|
||||
|
||||
def test_every_project_scoped_surfacing_arm_records_its_project():
|
||||
import ast as _ast
|
||||
|
||||
src = (_SRC / "services" / "plugin_context.py").read_text()
|
||||
missing = []
|
||||
for node in _ast.walk(_ast.parse(src)):
|
||||
if not (isinstance(node, _ast.Call)
|
||||
and getattr(node.func, "id", "") == "record_surfaced"):
|
||||
continue
|
||||
kwargs = {k.arg: k.value for k in node.keywords}
|
||||
source = kwargs.get("source")
|
||||
named = (source.value
|
||||
if isinstance(source, _ast.Constant) else "<computed>")
|
||||
if named in SURFACING_ARMS_WITHOUT_A_PROJECT:
|
||||
continue
|
||||
if "project_id" not in kwargs:
|
||||
missing.append(f"{named} (line {node.lineno})")
|
||||
assert not missing, (
|
||||
f"these surfacing arms know the project they searched and do not "
|
||||
f"record it: {missing}. Pass project_id=project_id, or add the arm to "
|
||||
f"SURFACING_ARMS_WITHOUT_A_PROJECT with the reason it has none."
|
||||
)
|
||||
|
||||
|
||||
def test_the_reading_project_guards_can_fail():
|
||||
"""Rule 167: shown turning red once, so a guard that has quietly stopped
|
||||
matching anything is distinguishable from one that passes."""
|
||||
assert list(_pulling_getters()), "the pulling-getter detection matches nothing"
|
||||
fake = "async def get_thing(thing_id: int) -> dict:\n record_pulled(x)"
|
||||
assert "project_id: int = 0" not in fake.split("\n")[0]
|
||||
assert "project_id=project_id" not in fake
|
||||
|
||||
|
||||
def test_zero_and_none_both_mean_no_project_reported():
|
||||
"""0 is how the MCP tools spell "no project" (int parameter, int default);
|
||||
the column is nullable. A row claiming project #0 would be a fiction."""
|
||||
from scribe.services.note_usage import _project_or_none
|
||||
|
||||
assert _project_or_none(0) is None
|
||||
assert _project_or_none(None) is None
|
||||
assert _project_or_none("") is None
|
||||
assert _project_or_none("nonsense") is None
|
||||
assert _project_or_none(2) == 2
|
||||
assert _project_or_none("2") == 2
|
||||
|
||||
Reference in New Issue
Block a user