Raise memory discovery limits
This commit is contained in:
@@ -50,6 +50,11 @@ from datetime import datetime, timedelta, timezone
|
|||||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
|
||||||
DISCOVERY_VIEWS = {"paths", "summary", "metadata", "full"}
|
DISCOVERY_VIEWS = {"paths", "summary", "metadata", "full"}
|
||||||
|
DISCOVERY_DEFAULT_LIMIT = 25
|
||||||
|
DISCOVERY_COMPACT_MAX_LIMIT = 10_000
|
||||||
|
DISCOVERY_FULL_MAX_LIMIT = 10_000
|
||||||
|
DISCOVERY_SOURCE_PAGE_LIMIT = 10_000
|
||||||
|
DISCOVERY_CURSOR_GUARD_PAGES = 100
|
||||||
ACTIVE_EXCLUDED_STATUSES = {
|
ACTIVE_EXCLUDED_STATUSES = {
|
||||||
"completed",
|
"completed",
|
||||||
"complete_verified_uncommitted",
|
"complete_verified_uncommitted",
|
||||||
@@ -92,6 +97,10 @@ SUMMARY_METADATA_KEYS = {
|
|||||||
KANBAN_DONE_STATUSES = {"done", "cancelled", "archived"}
|
KANBAN_DONE_STATUSES = {"done", "cancelled", "archived"}
|
||||||
DEFAULT_KANBAN_COLUMNS = ["backlog", "todo", "doing", "review", "done"]
|
DEFAULT_KANBAN_COLUMNS = ["backlog", "todo", "doing", "review", "done"]
|
||||||
|
|
||||||
|
|
||||||
|
def discovery_max_limit(view):
|
||||||
|
return DISCOVERY_FULL_MAX_LIMIT if view == "full" else DISCOVERY_COMPACT_MAX_LIMIT
|
||||||
|
|
||||||
from napseer_spake2 import (
|
from napseer_spake2 import (
|
||||||
relay_aad,
|
relay_aad,
|
||||||
relay_context_hash,
|
relay_context_hash,
|
||||||
@@ -5196,7 +5205,7 @@ def summarize_node(node, view="summary", preview_chars=240):
|
|||||||
|
|
||||||
def discovery_envelope(project_id, items, args, *, view=None, next_cursor=None, has_more=None, diagnostics=None, query_analysis=None, warnings=None):
|
def discovery_envelope(project_id, items, args, *, view=None, next_cursor=None, has_more=None, diagnostics=None, query_analysis=None, warnings=None):
|
||||||
view = view or normalize_discovery_view(args)[0]
|
view = view or normalize_discovery_view(args)[0]
|
||||||
limit = normalize_int(args.get("limit"), 25, 1, 100 if view != "full" else 200)
|
limit = normalize_int(args.get("limit"), DISCOVERY_DEFAULT_LIMIT, 1, discovery_max_limit(view))
|
||||||
preview_chars = normalize_int(args.get("preview_chars"), 240, 80, 1000)
|
preview_chars = normalize_int(args.get("preview_chars"), 240, 80, 1000)
|
||||||
summarized = [summarize_node(node, view=view, preview_chars=preview_chars) for node in items]
|
summarized = [summarize_node(node, view=view, preview_chars=preview_chars) for node in items]
|
||||||
response = {
|
response = {
|
||||||
@@ -5208,7 +5217,7 @@ def discovery_envelope(project_id, items, args, *, view=None, next_cursor=None,
|
|||||||
"has_more": bool(has_more) if has_more is not None else bool(next_cursor),
|
"has_more": bool(has_more) if has_more is not None else bool(next_cursor),
|
||||||
"truncated": False,
|
"truncated": False,
|
||||||
"omitted_fields": [] if view == "full" else ["content_text"],
|
"omitted_fields": [] if view == "full" else ["content_text"],
|
||||||
"budget": {"limit": limit, "max_limit": 100 if view != "full" else 200, "preview_chars": preview_chars},
|
"budget": {"limit": limit, "max_limit": discovery_max_limit(view), "preview_chars": preview_chars},
|
||||||
}
|
}
|
||||||
if as_bool(args.get("verbose"), False):
|
if as_bool(args.get("verbose"), False):
|
||||||
if query_analysis is not None:
|
if query_analysis is not None:
|
||||||
@@ -7327,7 +7336,7 @@ def list_project_nodes(args):
|
|||||||
project_id = resolve_project_id(args)
|
project_id = resolve_project_id(args)
|
||||||
view, view_warnings = normalize_discovery_view(args)
|
view, view_warnings = normalize_discovery_view(args)
|
||||||
filters = status_filter_from_args(args)
|
filters = status_filter_from_args(args)
|
||||||
limit = normalize_int(args.get("limit"), 25, 1, 100 if view != "full" else 200)
|
limit = normalize_int(args.get("limit"), DISCOVERY_DEFAULT_LIMIT, 1, discovery_max_limit(view))
|
||||||
allowed = {"tag", "folder_path", "q", "limit", "cursor", "updated_before", "updated_after", "type", "include_archived", "archived_only"}
|
allowed = {"tag", "folder_path", "q", "limit", "cursor", "updated_before", "updated_after", "type", "include_archived", "archived_only"}
|
||||||
base_query = {key: value for key, value in args.items() if key in allowed and value not in (None, "")}
|
base_query = {key: value for key, value in args.items() if key in allowed and value not in (None, "")}
|
||||||
base_query["limit"] = limit
|
base_query["limit"] = limit
|
||||||
@@ -7394,7 +7403,7 @@ def list_active_plans(args):
|
|||||||
"include_archived": False,
|
"include_archived": False,
|
||||||
"archived_only": False,
|
"archived_only": False,
|
||||||
"view": view,
|
"view": view,
|
||||||
"limit": normalize_int(args.get("limit"), 25, 1, 100),
|
"limit": normalize_int(args.get("limit"), DISCOVERY_DEFAULT_LIMIT, 1, DISCOVERY_COMPACT_MAX_LIMIT),
|
||||||
}
|
}
|
||||||
return list_project_nodes(query)
|
return list_project_nodes(query)
|
||||||
|
|
||||||
@@ -7469,7 +7478,7 @@ def list_kanban_cards(args):
|
|||||||
view, view_warnings = normalize_discovery_view(args)
|
view, view_warnings = normalize_discovery_view(args)
|
||||||
if view == "full":
|
if view == "full":
|
||||||
raise RuntimeError("nap_kanban_list supports paths, summary, or metadata views; use nap_cat for full card bodies")
|
raise RuntimeError("nap_kanban_list supports paths, summary, or metadata views; use nap_cat for full card bodies")
|
||||||
limit = normalize_int(args.get("limit"), 25, 1, 100)
|
limit = normalize_int(args.get("limit"), DISCOVERY_DEFAULT_LIMIT, 1, DISCOVERY_COMPACT_MAX_LIMIT)
|
||||||
preview_chars = normalize_int(args.get("preview_chars"), 240, 80, 1000)
|
preview_chars = normalize_int(args.get("preview_chars"), 240, 80, 1000)
|
||||||
active_only = as_bool(args.get("active_only"), True)
|
active_only = as_bool(args.get("active_only"), True)
|
||||||
column_filter = set(normalize_string_list(args.get("column") or args.get("columns")))
|
column_filter = set(normalize_string_list(args.get("column") or args.get("columns")))
|
||||||
@@ -7483,17 +7492,35 @@ def list_kanban_cards(args):
|
|||||||
if group_by not in {"column", "status", "priority", "owner", "blocked"}:
|
if group_by not in {"column", "status", "priority", "owner", "blocked"}:
|
||||||
raise RuntimeError("group_by must be column, status, priority, owner, or blocked")
|
raise RuntimeError("group_by must be column, status, priority, owner, or blocked")
|
||||||
|
|
||||||
source_args = {
|
source_query = {
|
||||||
"q": "/kanban",
|
"q": "/kanban",
|
||||||
"limit": limit,
|
"limit": DISCOVERY_SOURCE_PAGE_LIMIT,
|
||||||
"view": "full",
|
"view": "full",
|
||||||
"active_only": False,
|
|
||||||
"include_archived": not active_only,
|
"include_archived": not active_only,
|
||||||
"archived_only": False,
|
"archived_only": False,
|
||||||
}
|
}
|
||||||
raw = list_project_nodes(source_args)
|
source_items = []
|
||||||
|
source_truncated = False
|
||||||
|
source_pages = 0
|
||||||
|
seen_cursors = set()
|
||||||
|
while True:
|
||||||
|
suffix = f"?{urllib.parse.urlencode(source_query)}"
|
||||||
|
raw = request_json("GET", f"/v1/projects/{project_id}/nodes{suffix}")
|
||||||
|
page_items = decrypt_nodes_for_return(filter_project_nodes(raw.get("items", [])), project_id=project_id)
|
||||||
|
source_items.extend(page_items)
|
||||||
|
source_pages += 1
|
||||||
|
next_cursor = raw.get("next_cursor")
|
||||||
|
if not next_cursor:
|
||||||
|
source_truncated = bool(raw.get("truncated") or raw.get("has_more"))
|
||||||
|
break
|
||||||
|
if next_cursor in seen_cursors or source_pages >= DISCOVERY_CURSOR_GUARD_PAGES:
|
||||||
|
source_truncated = True
|
||||||
|
break
|
||||||
|
seen_cursors.add(next_cursor)
|
||||||
|
source_query["cursor"] = next_cursor
|
||||||
|
|
||||||
items = []
|
items = []
|
||||||
for node in raw.get("items", []):
|
for node in source_items:
|
||||||
full_path = normalize_node_path(node.get("full_path") or "")
|
full_path = normalize_node_path(node.get("full_path") or "")
|
||||||
if not full_path.startswith("/kanban/"):
|
if not full_path.startswith("/kanban/"):
|
||||||
continue
|
continue
|
||||||
@@ -7524,11 +7551,10 @@ def list_kanban_cards(args):
|
|||||||
enriched["owner"] = owner
|
enriched["owner"] = owner
|
||||||
enriched["blocked"] = blocked
|
enriched["blocked"] = blocked
|
||||||
items.append(enriched)
|
items.append(enriched)
|
||||||
if len(items) >= limit:
|
|
||||||
break
|
|
||||||
|
|
||||||
summarized = [summarize_node(node, view=view, preview_chars=preview_chars) for node in items]
|
returned_items = items[:limit]
|
||||||
for item, node in zip(summarized, items):
|
summarized = [summarize_node(node, view=view, preview_chars=preview_chars) for node in returned_items]
|
||||||
|
for item, node in zip(summarized, returned_items):
|
||||||
if node.get("column") is not None:
|
if node.get("column") is not None:
|
||||||
item["column"] = node["column"]
|
item["column"] = node["column"]
|
||||||
if node.get("priority") is not None:
|
if node.get("priority") is not None:
|
||||||
@@ -7537,7 +7563,7 @@ def list_kanban_cards(args):
|
|||||||
item["owner"] = node["owner"]
|
item["owner"] = node["owner"]
|
||||||
item["blocked"] = bool(node.get("blocked"))
|
item["blocked"] = bool(node.get("blocked"))
|
||||||
groups = {}
|
groups = {}
|
||||||
for item, node in zip(summarized, items):
|
for item, node in zip(summarized, returned_items):
|
||||||
if group_by == "column":
|
if group_by == "column":
|
||||||
key = node.get("column") or "uncategorized"
|
key = node.get("column") or "uncategorized"
|
||||||
elif group_by == "status":
|
elif group_by == "status":
|
||||||
@@ -7559,9 +7585,9 @@ def list_kanban_cards(args):
|
|||||||
"groups": groups,
|
"groups": groups,
|
||||||
"next_cursor": None,
|
"next_cursor": None,
|
||||||
"has_more": False,
|
"has_more": False,
|
||||||
"truncated": bool(raw.get("truncated") or raw.get("has_more") or raw.get("next_cursor")),
|
"truncated": bool(source_truncated or len(items) > limit),
|
||||||
"omitted_fields": ["content_text"],
|
"omitted_fields": ["content_text"],
|
||||||
"budget": {"limit": limit, "max_limit": 100, "preview_chars": preview_chars},
|
"budget": {"limit": limit, "max_limit": DISCOVERY_COMPACT_MAX_LIMIT, "preview_chars": preview_chars},
|
||||||
"filters": {
|
"filters": {
|
||||||
"active_only": active_only,
|
"active_only": active_only,
|
||||||
"column": sorted(column_filter),
|
"column": sorted(column_filter),
|
||||||
@@ -7574,8 +7600,10 @@ def list_kanban_cards(args):
|
|||||||
if as_bool(args.get("verbose"), False):
|
if as_bool(args.get("verbose"), False):
|
||||||
response["diagnostics"] = {
|
response["diagnostics"] = {
|
||||||
"source": "nodes",
|
"source": "nodes",
|
||||||
"source_query": {"q": "/kanban", "view": "full"},
|
"source_query": {"q": "/kanban", "view": "full", "limit": DISCOVERY_SOURCE_PAGE_LIMIT},
|
||||||
"source_item_count": len(raw.get("items", [])),
|
"source_page_count": source_pages,
|
||||||
|
"source_item_count": len(source_items),
|
||||||
|
"filtered_item_count": len(items),
|
||||||
"default_columns": DEFAULT_KANBAN_COLUMNS,
|
"default_columns": DEFAULT_KANBAN_COLUMNS,
|
||||||
}
|
}
|
||||||
if view_warnings:
|
if view_warnings:
|
||||||
@@ -9495,7 +9523,7 @@ def raw_tools():
|
|||||||
"tag": {"type": "string"},
|
"tag": {"type": "string"},
|
||||||
"q": {"type": "string"},
|
"q": {"type": "string"},
|
||||||
"cursor": {"type": "string"},
|
"cursor": {"type": "string"},
|
||||||
"limit": {"type": "integer", "minimum": 1, "maximum": 100},
|
"limit": {"type": "integer", "minimum": 1, "maximum": 10000},
|
||||||
"view": {"type": "string", "enum": ["paths", "summary", "metadata", "full"], "default": "summary"},
|
"view": {"type": "string", "enum": ["paths", "summary", "metadata", "full"], "default": "summary"},
|
||||||
"include_content": {"type": "boolean", "default": False, "description": "Legacy; use view='full' for body text. Ignored when view is set."},
|
"include_content": {"type": "boolean", "default": False, "description": "Legacy; use view='full' for body text. Ignored when view is set."},
|
||||||
"include_archived": {"type": "boolean", "default": False},
|
"include_archived": {"type": "boolean", "default": False},
|
||||||
@@ -9518,7 +9546,7 @@ def raw_tools():
|
|||||||
"folder_path": {"type": "string", "default": "/plans"},
|
"folder_path": {"type": "string", "default": "/plans"},
|
||||||
"status": {"oneOf": [{"type": "string"}, {"type": "array", "items": {"type": "string"}}]},
|
"status": {"oneOf": [{"type": "string"}, {"type": "array", "items": {"type": "string"}}]},
|
||||||
"exclude_status": {"oneOf": [{"type": "string"}, {"type": "array", "items": {"type": "string"}}]},
|
"exclude_status": {"oneOf": [{"type": "string"}, {"type": "array", "items": {"type": "string"}}]},
|
||||||
"limit": {"type": "integer", "minimum": 1, "maximum": 100, "default": 25},
|
"limit": {"type": "integer", "minimum": 1, "maximum": 10000, "default": 25},
|
||||||
"cursor": {"type": "string"},
|
"cursor": {"type": "string"},
|
||||||
"view": {"type": "string", "enum": ["paths", "summary", "metadata"], "default": "summary"},
|
"view": {"type": "string", "enum": ["paths", "summary", "metadata"], "default": "summary"},
|
||||||
"verbose": {"type": "boolean", "default": False},
|
"verbose": {"type": "boolean", "default": False},
|
||||||
@@ -9544,7 +9572,7 @@ def raw_tools():
|
|||||||
"blocking": {"type": "boolean"},
|
"blocking": {"type": "boolean"},
|
||||||
"active_only": {"type": "boolean", "default": True},
|
"active_only": {"type": "boolean", "default": True},
|
||||||
"group_by": {"type": "string", "enum": ["column", "status", "priority", "owner", "blocked"], "default": "column"},
|
"group_by": {"type": "string", "enum": ["column", "status", "priority", "owner", "blocked"], "default": "column"},
|
||||||
"limit": {"type": "integer", "minimum": 1, "maximum": 100, "default": 25},
|
"limit": {"type": "integer", "minimum": 1, "maximum": 10000, "default": 25},
|
||||||
"view": {"type": "string", "enum": ["paths", "summary", "metadata"], "default": "summary"},
|
"view": {"type": "string", "enum": ["paths", "summary", "metadata"], "default": "summary"},
|
||||||
"verbose": {"type": "boolean", "default": False},
|
"verbose": {"type": "boolean", "default": False},
|
||||||
"preview_chars": {"type": "integer", "minimum": 80, "maximum": 1000, "default": 240},
|
"preview_chars": {"type": "integer", "minimum": 80, "maximum": 1000, "default": 240},
|
||||||
@@ -9921,7 +9949,7 @@ def raw_tools():
|
|||||||
"folder_path": {"type": "string"},
|
"folder_path": {"type": "string"},
|
||||||
"q": {"type": "string", "description": "Search phrase. Distinctive nouns, paths, tags, and quoted phrases work best; long sentences are decomposed automatically."},
|
"q": {"type": "string", "description": "Search phrase. Distinctive nouns, paths, tags, and quoted phrases work best; long sentences are decomposed automatically."},
|
||||||
"cursor": {"type": "string"},
|
"cursor": {"type": "string"},
|
||||||
"limit": {"type": "integer", "minimum": 1, "maximum": 100},
|
"limit": {"type": "integer", "minimum": 1, "maximum": 10000},
|
||||||
"view": {"type": "string", "enum": ["paths", "summary", "metadata", "full"], "default": "summary"},
|
"view": {"type": "string", "enum": ["paths", "summary", "metadata", "full"], "default": "summary"},
|
||||||
"include_content": {"type": "boolean", "default": False, "description": "Legacy; use view='full' for body text. Ignored when view is set."},
|
"include_content": {"type": "boolean", "default": False, "description": "Legacy; use view='full' for body text. Ignored when view is set."},
|
||||||
"include_archived": {"type": "boolean", "default": False},
|
"include_archived": {"type": "boolean", "default": False},
|
||||||
|
|||||||
@@ -61,6 +61,25 @@ def test_active_plan_listing(mod):
|
|||||||
assert result["items"][0]["status"] == "in_progress"
|
assert result["items"][0]["status"] == "in_progress"
|
||||||
|
|
||||||
|
|
||||||
|
def test_node_discovery_allows_large_requested_limit(mod):
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def fake_request(method, path, payload=None, **kwargs):
|
||||||
|
calls.append(path)
|
||||||
|
assert method == "GET"
|
||||||
|
assert "limit=250" in path
|
||||||
|
return {"items": [node(f"/plans/item-{index}") for index in range(250)], "next_cursor": None}
|
||||||
|
|
||||||
|
mod.request_json = fake_request
|
||||||
|
result = mod.list_project_nodes({"folder_path": "/plans", "limit": 250})
|
||||||
|
assert result["ok"] is True
|
||||||
|
assert len(result["items"]) == 250
|
||||||
|
assert result["budget"]["limit"] == 250
|
||||||
|
assert result["budget"]["max_limit"] > 200
|
||||||
|
assert "content_text" not in result["items"][0]
|
||||||
|
assert len(calls) == 1
|
||||||
|
|
||||||
|
|
||||||
def test_kanban_list_grouping_and_filters(mod):
|
def test_kanban_list_grouping_and_filters(mod):
|
||||||
doing = node(
|
doing = node(
|
||||||
"/kanban/doing/fix-wrapper",
|
"/kanban/doing/fix-wrapper",
|
||||||
@@ -101,6 +120,43 @@ def test_kanban_list_grouping_and_filters(mod):
|
|||||||
assert list(result["groups"]) == ["doing"]
|
assert list(result["groups"]) == ["doing"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_kanban_list_reads_beyond_first_source_page(mod):
|
||||||
|
first_page = [
|
||||||
|
node(
|
||||||
|
"/kanban/todo/other-card",
|
||||||
|
"todo",
|
||||||
|
metadata={"column": "todo", "owner": "codex", "blocked": False},
|
||||||
|
tags=["kanban"],
|
||||||
|
node_type="kanban_card",
|
||||||
|
)
|
||||||
|
]
|
||||||
|
second_page = [
|
||||||
|
node(
|
||||||
|
"/kanban/doing/blocked-card",
|
||||||
|
"doing",
|
||||||
|
metadata={"column": "doing", "owner": "codex", "blocked": True},
|
||||||
|
tags=["kanban"],
|
||||||
|
node_type="kanban_card",
|
||||||
|
)
|
||||||
|
]
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def fake_request(method, path, payload=None, **kwargs):
|
||||||
|
calls.append(path)
|
||||||
|
assert method == "GET"
|
||||||
|
assert "q=%2Fkanban" in path
|
||||||
|
assert "limit=10000" in path
|
||||||
|
if "cursor=page-2" in path:
|
||||||
|
return {"items": second_page, "next_cursor": None}
|
||||||
|
return {"items": first_page, "next_cursor": "page-2"}
|
||||||
|
|
||||||
|
mod.request_json = fake_request
|
||||||
|
result = mod.list_kanban_cards({"blocked": True, "limit": 10})
|
||||||
|
assert [item["full_path"] for item in result["items"]] == ["/kanban/doing/blocked-card"]
|
||||||
|
assert result["truncated"] is False
|
||||||
|
assert len(calls) == 2
|
||||||
|
|
||||||
|
|
||||||
def test_plan_lifecycle_dry_run_and_write(mod):
|
def test_plan_lifecycle_dry_run_and_write(mod):
|
||||||
plan = node("/plans/example", "in_progress", tags=["plan"])
|
plan = node("/plans/example", "in_progress", tags=["plan"])
|
||||||
outcome = node("/implementation-notes/example", "completed", node_type="implementation-note")
|
outcome = node("/implementation-notes/example", "completed", node_type="implementation-note")
|
||||||
@@ -167,8 +223,12 @@ def run():
|
|||||||
mod = load_module()
|
mod = load_module()
|
||||||
test_active_plan_listing(mod)
|
test_active_plan_listing(mod)
|
||||||
mod = load_module()
|
mod = load_module()
|
||||||
|
test_node_discovery_allows_large_requested_limit(mod)
|
||||||
|
mod = load_module()
|
||||||
test_kanban_list_grouping_and_filters(mod)
|
test_kanban_list_grouping_and_filters(mod)
|
||||||
mod = load_module()
|
mod = load_module()
|
||||||
|
test_kanban_list_reads_beyond_first_source_page(mod)
|
||||||
|
mod = load_module()
|
||||||
test_plan_lifecycle_dry_run_and_write(mod)
|
test_plan_lifecycle_dry_run_and_write(mod)
|
||||||
mod = load_module()
|
mod = load_module()
|
||||||
test_plan_lifecycle_archived_idempotent(mod)
|
test_plan_lifecycle_archived_idempotent(mod)
|
||||||
|
|||||||
Reference in New Issue
Block a user