Bound paged retrieval responses
This commit is contained in:
parent
176b2d2784
commit
529accf858
15 changed files with 1567 additions and 30 deletions
|
|
@ -267,6 +267,38 @@ workers, non-reuse, and zero-load status. The Milestone 1 benchmark now measures
|
||||||
not-running, and unavailable visualization status separately with the same zero-work and 50 ms p95
|
not-running, and unavailable visualization status separately with the same zero-work and 50 ms p95
|
||||||
gates as other receipt status operations.
|
gates as other receipt status operations.
|
||||||
|
|
||||||
|
#### Bounded pagination and exact large-result review
|
||||||
|
|
||||||
|
The final Milestone 1 contract audit found that count limits and the global MCP output ceiling were
|
||||||
|
not sufficient. A 1,000-node context response already exceeded the normal 200,000-character tool
|
||||||
|
limit, and one allowed changeset operation can be larger than that limit. Returning
|
||||||
|
`result_too_large` kept transport bounded but stranded useful evidence.
|
||||||
|
|
||||||
|
Version-1 pagination now uses canonical, base64url cursors with a domain-separated SHA-256
|
||||||
|
corruption checksum. Cursors bind the project, adapter, canonical generation, semantic query,
|
||||||
|
collection hash, and position. They are deliberately unkeyed read tokens rather than authorization
|
||||||
|
credentials. Corrupt tokens fail as `invalid_cursor`; changed generations or collections fail as
|
||||||
|
`stale_cursor` with explicit pagination-restart remediation.
|
||||||
|
|
||||||
|
Context transport flattens the compiler's deterministic selected entries followed by all explicit
|
||||||
|
omissions, then partitions each page back into the existing arrays. Both item count and exact
|
||||||
|
compact-JSON response size constrain packing. An individually oversized entry becomes a bounded,
|
||||||
|
hash-identified omission and advances the cursor, avoiding an infinite retry while preserving the
|
||||||
|
fact that evidence was excluded.
|
||||||
|
|
||||||
|
Changeset list, inspection, validation, and diff reads preserve direct full-result defaults while
|
||||||
|
MCP uses bounded pages. Pages retain exact changeset identity and hash. Large operation pages
|
||||||
|
compact content-bearing fields into hashes and character counts. A single oversized structured
|
||||||
|
diff is serialized once as canonical ASCII JSON and returned through hash-bound chunks that
|
||||||
|
reconstruct the exact legacy `operations` and `changes` arrays. This solves transport growth
|
||||||
|
without lowering canonical changeset limits or adding cursor storage.
|
||||||
|
|
||||||
|
The benchmark now validates zero-work and operation-specific counters for every warmup and measured
|
||||||
|
sample, records bounded semantic response summaries, covers filter, backlinks, outgoing and
|
||||||
|
incoming traversal, and measures current/stale/missing/corrupt render receipts plus all
|
||||||
|
visualization lifecycle states. A maintained query-plan test prevents the incoming traversal
|
||||||
|
temporary sort from returning.
|
||||||
|
|
||||||
### Initial design constraints
|
### Initial design constraints
|
||||||
|
|
||||||
- Full rebuild remains the recovery and equivalence oracle.
|
- Full rebuild remains the recovery and equivalence oracle.
|
||||||
|
|
@ -290,5 +322,6 @@ These are notes, not commitments:
|
||||||
risks.
|
risks.
|
||||||
- The stat identity is a cheap publication proof, not a cryptographic integrity scan. Full index
|
- The stat identity is a cheap publication proof, not a cryptographic integrity scan. Full index
|
||||||
validation remains the launch and query oracle.
|
validation remains the launch and query oracle.
|
||||||
- Large context and changeset payloads may need cursor pagination or compact immutable receipts.
|
- Cursor authentication remains deliberately absent. If read cursors ever carry authority rather
|
||||||
The choice should follow actual client workflows rather than generic pagination machinery.
|
than bounded positions, they will need a different versioned security contract and persisted key
|
||||||
|
lifecycle.
|
||||||
|
|
|
||||||
|
|
@ -45,3 +45,12 @@ operations fail if they load a complete project, parse source files, reconstruct
|
||||||
projection, extract adapter sources, build an index, prepare a render, construct rendered output,
|
projection, extract adapter sources, build an index, prepare a render, construct rendered output,
|
||||||
or hash complete rendered output. Its latency ceilings are the Milestone 1 targets, not claims
|
or hash complete rendered output. Its latency ceilings are the Milestone 1 targets, not claims
|
||||||
about all hardware.
|
about all hardware.
|
||||||
|
|
||||||
|
Every warmup and measured invocation is validated. The recorded counter ranges also require one
|
||||||
|
index synchronization for the synchronization operation, no hidden synchronization for reads and
|
||||||
|
status, one index check for each retrieval snapshot, and exactly one manager request for viewer
|
||||||
|
status. The 1,000-node run records bounded semantic summaries for exact errors, search, filtering,
|
||||||
|
backlinks, both traversal directions, paged context, render receipt states, and visualization
|
||||||
|
freshness. The reported p95 uses the nearest-rank method; with ten samples it is the maximum.
|
||||||
|
`process_peak_rss_kib` is the cumulative main-process `RUSAGE_SELF` high-water mark and excludes the
|
||||||
|
detached viewer worker.
|
||||||
|
|
|
||||||
|
|
@ -72,6 +72,10 @@ Milestone 0 preserves:
|
||||||
version 3 adds a source-ordered incoming-edge index for bounded impact traversal.
|
version 3 adds a source-ordered incoming-edge index for bounded impact traversal.
|
||||||
- Index-attestation schema version 1.
|
- Index-attestation schema version 1.
|
||||||
- Incremental extraction-cache schema version 1.
|
- Incremental extraction-cache schema version 1.
|
||||||
|
- Read-pagination schema version 1. Existing tool names and required arguments are unchanged.
|
||||||
|
Context and changeset MCP reads accept optional limits and opaque generation-bound cursors.
|
||||||
|
Direct Python changeset methods and the ordinary CLI context command retain full legacy results
|
||||||
|
when pagination is not requested.
|
||||||
|
|
||||||
Indexes, attestations, extraction caches, previews, and rendered artifacts are disposable. A schema
|
Indexes, attestations, extraction caches, previews, and rendered artifacts are disposable. A schema
|
||||||
change may rebuild them. Canonical project content and stored proposals may not be silently
|
change may rebuild them. Canonical project content and stored proposals may not be silently
|
||||||
|
|
@ -154,7 +158,10 @@ Milestone 0 records rather than redesigns these areas:
|
||||||
- Tree-sitter and the JavaScript and C++ grammars remain mandatory installation dependencies even
|
- Tree-sitter and the JavaScript and C++ grammars remain mandatory installation dependencies even
|
||||||
when their runtime modules are unused.
|
when their runtime modules are unused.
|
||||||
- Several version strings and defaults remain duplicated.
|
- Several version strings and defaults remain duplicated.
|
||||||
- Large changeset results and context responses need compact receipt or pagination contracts.
|
- One individually oversized context entry is represented as explicit bounded omission evidence;
|
||||||
|
callers use targeted retrieval for that node.
|
||||||
|
- One individually oversized changeset diff is transported as reconstructable canonical-JSON
|
||||||
|
chunks. Cursors are corruption-detecting read tokens, not authenticated authorization tokens.
|
||||||
- Manual planning is not separated from rendering.
|
- Manual planning is not separated from rendering.
|
||||||
- There is no portable graph-planning or graph-rendering contract.
|
- There is no portable graph-planning or graph-rendering contract.
|
||||||
- DocForge2 does not self-host its bootstrap documentation.
|
- DocForge2 does not self-host its bootstrap documentation.
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,20 @@ project `max_results` policy. Omitted limits are still capped. Collection respon
|
||||||
they were truncated. Traversal also reports whether truncation came from the result limit or its
|
they were truncated. Traversal also reports whether truncation came from the result limit or its
|
||||||
deterministic candidate-edge work budget; it does not scan or materialize the complete edge table.
|
deterministic candidate-edge work budget; it does not scan or materialize the complete edge table.
|
||||||
|
|
||||||
|
`docforge_get_context` accepts optional `limit` and `cursor` arguments. Its page is one deterministic
|
||||||
|
stream containing selected entries first and explicit omission evidence second. The page receipt
|
||||||
|
reports the returned count, total evidence count, whether another page exists, and an opaque
|
||||||
|
generation-bound cursor. Packing also observes the configured MCP response limit. An individually
|
||||||
|
oversized entry advances as a hash-identified `response size limit` omission so pagination cannot
|
||||||
|
loop; targeted retrieval remains available for that node. The existing three-argument custom
|
||||||
|
context-provider contract is unchanged because pagination is applied after provider selection.
|
||||||
|
|
||||||
|
Version-1 cursors are canonical JSON encoded as base64url with a domain-separated SHA-256
|
||||||
|
corruption checksum. They are opaque and fail closed, but are not authenticated authorization
|
||||||
|
tokens. Cursors bind the project, adapter, source generation, operation parameters, collection
|
||||||
|
hash, and position. A changed generation or collection returns `stale_cursor` with
|
||||||
|
`restart_pagination`; DocForge never silently restarts at page one or combines generations.
|
||||||
|
|
||||||
Adapter-backed servers also validate their process-start implementation fingerprint before every
|
Adapter-backed servers also validate their process-start implementation fingerprint before every
|
||||||
tool. `adapter_restart_required` is stale but not synchronizable. Its remediation is
|
tool. `adapter_restart_required` is stale but not synchronizable. Its remediation is
|
||||||
`restart_project_server`; the current process does not reload project code, update Git staging, or
|
`restart_project_server`; the current process does not reload project code, update Git staging, or
|
||||||
|
|
@ -101,6 +115,15 @@ Changeset listing returns draft and ready work by default. Stale, applied, and a
|
||||||
remain available through an explicit status or history request. Applied and abandoned proposals no
|
remain available through an explicit status or history request. Applied and abandoned proposals no
|
||||||
longer participate in overlap conflict detection.
|
longer participate in overlap conflict detection.
|
||||||
|
|
||||||
|
Changeset list, inspection, validation, and diff reads accept optional `limit` and `cursor`
|
||||||
|
arguments. Direct Python and CLI methods still return their complete legacy result when pagination
|
||||||
|
is not requested. MCP defaults to bounded pages while preserving the exact changeset hash and
|
||||||
|
ordered operation sequence. Pages may contain fewer records than requested to remain inside the
|
||||||
|
response policy. Oversized inspection or validation pages return deterministic operation summaries
|
||||||
|
with hashes and character counts. An individually oversized diff becomes a sequence of
|
||||||
|
`canonical_json_chunk` pages; concatenating the ASCII chunks, decoding the JSON, and verifying its
|
||||||
|
payload hash reconstructs the exact `operations` and `changes` arrays without duplication.
|
||||||
|
|
||||||
Successful mutations return their existing full result while it fits the configured output limit.
|
Successful mutations return their existing full result while it fits the configured output limit.
|
||||||
Before any proposal, preview, or canonical mutation, the server verifies that a minimum exact
|
Before any proposal, preview, or canonical mutation, the server verifies that a minimum exact
|
||||||
success receipt can fit. An impossible receipt fails with `result_too_large`,
|
success receipt can fit. An impossible receipt fails with `result_too_large`,
|
||||||
|
|
|
||||||
|
|
@ -477,7 +477,7 @@ filter [--family X] [--authority X] [--status X] [--tag X] [--limit N]
|
||||||
backlinks NODE_ID [--relation RELATION] [--limit N]
|
backlinks NODE_ID [--relation RELATION] [--limit N]
|
||||||
dependencies NODE_ID [--depth N] [--limit N]
|
dependencies NODE_ID [--depth N] [--limit N]
|
||||||
impact NODE_ID [--depth N] [--limit N]
|
impact NODE_ID [--depth N] [--limit N]
|
||||||
context PROFILE [--budget N]
|
context PROFILE [--budget N] [--limit N] [--cursor OPAQUE]
|
||||||
```
|
```
|
||||||
|
|
||||||
### Render and proposal commands
|
### Render and proposal commands
|
||||||
|
|
@ -641,6 +641,22 @@ an explicit `status="stale"` query for rebase decisions. Applied and abandoned p
|
||||||
terminal history, remain available by status or history request, and no longer block new proposals
|
terminal history, remain available by status or history request, and no longer block new proposals
|
||||||
against the same canonical base.
|
against the same canonical base.
|
||||||
|
|
||||||
|
Context and changeset reads use version-1 continuation receipts when their evidence exceeds one
|
||||||
|
page. Follow `pagination.next_cursor` with the same tool and semantic arguments until
|
||||||
|
`pagination.has_more` is false. Page size may change between calls. Treat the cursor as opaque.
|
||||||
|
It is bound to the project, adapter, source generation, query, exact changeset hash, and collection
|
||||||
|
identity. `stale_cursor` means evidence changed between pages; discard prior pages and restart the
|
||||||
|
read instead of mixing generations.
|
||||||
|
|
||||||
|
`docforge_get_context` paginates one ordered evidence stream: selected entries followed by explicit
|
||||||
|
omissions. An entry too large for one MCP response is represented by a bounded omission carrying
|
||||||
|
its node ID and detail hash, and the cursor advances. `docforge_list_changesets`,
|
||||||
|
`docforge_get_changeset`, `docforge_validate_changeset`, and `docforge_get_changeset_diff` accept
|
||||||
|
the same optional `limit` and `cursor` fields. Small results keep their familiar fields. Large
|
||||||
|
inspection pages may use hash summaries. A large diff may return `result_mode =
|
||||||
|
"canonical_json_chunk"`; concatenate the chunks in order and verify `payload_hash` before decoding
|
||||||
|
the reconstructed `operations` and `changes` object.
|
||||||
|
|
||||||
Canonical application records its terminal receipt immediately after the project-owned serializer
|
Canonical application records its terminal receipt immediately after the project-owned serializer
|
||||||
verifies the new canonical state. A later index or render refresh failure is reported as degraded
|
verifies the new canonical state. A later index or render refresh failure is reported as degraded
|
||||||
derived state with remediation, not as permission to apply the same canonical change again.
|
derived state with remediation, not as permission to apply the same canonical change again.
|
||||||
|
|
|
||||||
|
|
@ -132,6 +132,41 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
"pagination": {
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"schema_version",
|
||||||
|
"kind",
|
||||||
|
"returned_count",
|
||||||
|
"limit",
|
||||||
|
"total_count",
|
||||||
|
"has_more",
|
||||||
|
"next_cursor"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"schema_version": { "const": 1 },
|
||||||
|
"kind": {
|
||||||
|
"enum": [
|
||||||
|
"context.items",
|
||||||
|
"changeset.list",
|
||||||
|
"changeset.inspect",
|
||||||
|
"changeset.validate",
|
||||||
|
"changeset.diff",
|
||||||
|
"changeset.diff-chunks"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"returned_count": { "type": "integer", "minimum": 0 },
|
||||||
|
"limit": { "type": "integer", "minimum": 1 },
|
||||||
|
"total_count": { "type": "integer", "minimum": 0 },
|
||||||
|
"has_more": { "type": "boolean" },
|
||||||
|
"next_cursor": {
|
||||||
|
"type": ["string", "null"],
|
||||||
|
"minLength": 1,
|
||||||
|
"maxLength": 8192
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"oneOf": [
|
"oneOf": [
|
||||||
|
|
@ -147,6 +182,7 @@
|
||||||
"pattern": "^[0-9a-f]{64}$"
|
"pattern": "^[0-9a-f]{64}$"
|
||||||
},
|
},
|
||||||
"adapter": { "type": "string" },
|
"adapter": { "type": "string" },
|
||||||
|
"pagination": { "$ref": "#/$defs/pagination" },
|
||||||
"diagnostics": { "$ref": "#/$defs/diagnostics" }
|
"diagnostics": { "$ref": "#/$defs/diagnostics" }
|
||||||
},
|
},
|
||||||
"additionalProperties": true
|
"additionalProperties": true
|
||||||
|
|
|
||||||
|
|
@ -20,9 +20,12 @@ from .changeset_contract import (
|
||||||
)
|
)
|
||||||
from .errors import DocForgeError
|
from .errors import DocForgeError
|
||||||
from .models import Edge, Node, ProjectService, ProjectSnapshot, ProposalWriter
|
from .models import Edge, Node, ProjectService, ProjectSnapshot, ProposalWriter
|
||||||
|
from .pagination import canonical_hash, decode_cursor, page_limit, page_receipt
|
||||||
from .project import project_root_fingerprint
|
from .project import project_root_fingerprint
|
||||||
from .proposal_projection import ProposalProjector
|
from .proposal_projection import ProposalProjector
|
||||||
|
|
||||||
|
MAX_ABANDON_REASON_CHARS = 2_000
|
||||||
|
|
||||||
|
|
||||||
class ChangesetStore:
|
class ChangesetStore:
|
||||||
"""One project-bound proposal store with an optional immutable writer identity."""
|
"""One project-bound proposal store with an optional immutable writer identity."""
|
||||||
|
|
@ -277,23 +280,37 @@ class ChangesetStore:
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
def validate(self, changeset_id: str) -> dict[str, object]:
|
def validate(
|
||||||
|
self,
|
||||||
|
changeset_id: str,
|
||||||
|
*,
|
||||||
|
limit: int | None = None,
|
||||||
|
cursor: str | None = None,
|
||||||
|
) -> dict[str, object]:
|
||||||
validate_id(changeset_id, "changeset_id")
|
validate_id(changeset_id, "changeset_id")
|
||||||
with self._lock():
|
with self._lock():
|
||||||
snapshot, document, nodes, edges = self._validate_locked(changeset_id)
|
snapshot, document, nodes, edges = self._validate_locked(changeset_id)
|
||||||
return self._result(
|
result = self._result(
|
||||||
snapshot,
|
snapshot,
|
||||||
document,
|
document,
|
||||||
valid=True,
|
valid=True,
|
||||||
projected_node_count=len(nodes),
|
projected_node_count=len(nodes),
|
||||||
projected_edge_count=len(edges),
|
projected_edge_count=len(edges),
|
||||||
)
|
)
|
||||||
|
return self._page_document_result(
|
||||||
|
result,
|
||||||
|
kind="changeset.validate",
|
||||||
|
limit=limit,
|
||||||
|
cursor=cursor,
|
||||||
|
)
|
||||||
|
|
||||||
def list_changesets(
|
def list_changesets(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
include_history: bool = True,
|
include_history: bool = True,
|
||||||
status: str | None = None,
|
status: str | None = None,
|
||||||
|
limit: int | None = None,
|
||||||
|
cursor: str | None = None,
|
||||||
) -> dict[str, object]:
|
) -> dict[str, object]:
|
||||||
with self._lock():
|
with self._lock():
|
||||||
snapshot = self.project.load()
|
snapshot = self.project.load()
|
||||||
|
|
@ -322,14 +339,93 @@ class ChangesetStore:
|
||||||
"operation_count": len(document["operations"]),
|
"operation_count": len(document["operations"]),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
return self._base_result(snapshot, count=len(records), changesets=records)
|
result = self._base_result(snapshot, count=len(records), changesets=records)
|
||||||
|
if limit is None and cursor is None:
|
||||||
|
return result
|
||||||
|
selected_limit = page_limit(
|
||||||
|
limit,
|
||||||
|
default=20,
|
||||||
|
maximum=self.project.descriptor.limits.max_results,
|
||||||
|
)
|
||||||
|
binding = {
|
||||||
|
"project_id": snapshot.descriptor.project_id,
|
||||||
|
"project_root_fingerprint": project_root_fingerprint(snapshot.descriptor.root),
|
||||||
|
"adapter": snapshot.descriptor.adapter,
|
||||||
|
"revision": snapshot.revision,
|
||||||
|
"source_hash": snapshot.source_hash,
|
||||||
|
"include_history": include_history,
|
||||||
|
"status": status,
|
||||||
|
"collection_hash": canonical_hash(records),
|
||||||
|
}
|
||||||
|
position = decode_cursor(
|
||||||
|
cursor,
|
||||||
|
kind="changeset.list",
|
||||||
|
binding=binding,
|
||||||
|
total_count=len(records),
|
||||||
|
)
|
||||||
|
page = records[position : position + selected_limit]
|
||||||
|
while page:
|
||||||
|
candidate = {
|
||||||
|
**result,
|
||||||
|
"count": len(page),
|
||||||
|
"total_count": len(records),
|
||||||
|
"changesets": page,
|
||||||
|
"pagination": page_receipt(
|
||||||
|
kind="changeset.list",
|
||||||
|
binding=binding,
|
||||||
|
position=position,
|
||||||
|
count=len(page),
|
||||||
|
limit=selected_limit,
|
||||||
|
total_count=len(records),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
if self._encoded_length(candidate) <= self._safe_page_chars():
|
||||||
|
return candidate
|
||||||
|
page.pop()
|
||||||
|
if position < len(records):
|
||||||
|
compact_record = self._compact_list_record(records[position])
|
||||||
|
return {
|
||||||
|
**result,
|
||||||
|
"count": 1,
|
||||||
|
"total_count": len(records),
|
||||||
|
"changesets": [compact_record],
|
||||||
|
"result_mode": "changeset_summaries",
|
||||||
|
"pagination": page_receipt(
|
||||||
|
kind="changeset.list",
|
||||||
|
binding=binding,
|
||||||
|
position=position,
|
||||||
|
count=1,
|
||||||
|
limit=selected_limit,
|
||||||
|
total_count=len(records),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
**result,
|
||||||
|
"count": 0,
|
||||||
|
"total_count": len(records),
|
||||||
|
"changesets": [],
|
||||||
|
"pagination": page_receipt(
|
||||||
|
kind="changeset.list",
|
||||||
|
binding=binding,
|
||||||
|
position=position,
|
||||||
|
count=0,
|
||||||
|
limit=selected_limit,
|
||||||
|
total_count=len(records),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
def inspect(self, changeset_id: str) -> dict[str, object]:
|
def inspect(
|
||||||
|
self,
|
||||||
|
changeset_id: str,
|
||||||
|
*,
|
||||||
|
limit: int | None = None,
|
||||||
|
cursor: str | None = None,
|
||||||
|
) -> dict[str, object]:
|
||||||
validate_id(changeset_id, "changeset_id")
|
validate_id(changeset_id, "changeset_id")
|
||||||
with self._lock():
|
with self._lock():
|
||||||
document = self._read(self._path(changeset_id))
|
document = self._read(self._path(changeset_id))
|
||||||
snapshot = self.project.load()
|
snapshot = self.project.load()
|
||||||
return self._result(
|
result = self._result(
|
||||||
snapshot,
|
snapshot,
|
||||||
document,
|
document,
|
||||||
base_state=self._base_state(document, snapshot),
|
base_state=self._base_state(document, snapshot),
|
||||||
|
|
@ -338,6 +434,12 @@ class ChangesetStore:
|
||||||
self._base_state(document, snapshot),
|
self._base_state(document, snapshot),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
return self._page_document_result(
|
||||||
|
result,
|
||||||
|
kind="changeset.inspect",
|
||||||
|
limit=limit,
|
||||||
|
cursor=cursor,
|
||||||
|
)
|
||||||
|
|
||||||
def rebase(
|
def rebase(
|
||||||
self,
|
self,
|
||||||
|
|
@ -398,8 +500,15 @@ class ChangesetStore:
|
||||||
|
|
||||||
validate_id(changeset_id, "changeset_id")
|
validate_id(changeset_id, "changeset_id")
|
||||||
validate_hash(expected_changeset_hash, "expected_changeset_hash")
|
validate_hash(expected_changeset_hash, "expected_changeset_hash")
|
||||||
if not reason.strip():
|
normalized_reason = reason.strip()
|
||||||
|
if not normalized_reason:
|
||||||
raise DocForgeError("invalid_operation", "Abandon reason must be non-empty")
|
raise DocForgeError("invalid_operation", "Abandon reason must be non-empty")
|
||||||
|
if len(normalized_reason) > MAX_ABANDON_REASON_CHARS:
|
||||||
|
raise DocForgeError(
|
||||||
|
"changeset_too_large",
|
||||||
|
"Abandon reason exceeds its character limit",
|
||||||
|
maximum=MAX_ABANDON_REASON_CHARS,
|
||||||
|
)
|
||||||
with self._lock():
|
with self._lock():
|
||||||
document = self._read(self._path(changeset_id))
|
document = self._read(self._path(changeset_id))
|
||||||
actual_hash = document_hash(document)
|
actual_hash = document_hash(document)
|
||||||
|
|
@ -418,7 +527,7 @@ class ChangesetStore:
|
||||||
{
|
{
|
||||||
"status": "abandoned",
|
"status": "abandoned",
|
||||||
"changeset_hash": actual_hash,
|
"changeset_hash": actual_hash,
|
||||||
"reason": reason.strip(),
|
"reason": normalized_reason,
|
||||||
"revision": snapshot.revision,
|
"revision": snapshot.revision,
|
||||||
"source_hash": snapshot.source_hash,
|
"source_hash": snapshot.source_hash,
|
||||||
},
|
},
|
||||||
|
|
@ -430,7 +539,13 @@ class ChangesetStore:
|
||||||
lifecycle=receipt,
|
lifecycle=receipt,
|
||||||
)
|
)
|
||||||
|
|
||||||
def diff(self, changeset_id: str) -> dict[str, object]:
|
def diff(
|
||||||
|
self,
|
||||||
|
changeset_id: str,
|
||||||
|
*,
|
||||||
|
limit: int | None = None,
|
||||||
|
cursor: str | None = None,
|
||||||
|
) -> dict[str, object]:
|
||||||
validate_id(changeset_id, "changeset_id")
|
validate_id(changeset_id, "changeset_id")
|
||||||
with self._lock():
|
with self._lock():
|
||||||
snapshot, document, _, _ = self._validate_locked(changeset_id)
|
snapshot, document, _, _ = self._validate_locked(changeset_id)
|
||||||
|
|
@ -453,7 +568,14 @@ class ChangesetStore:
|
||||||
sorted(before_edges - edges),
|
sorted(before_edges - edges),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return self._result(snapshot, document, valid=True, changes=changes)
|
result = self._result(snapshot, document, valid=True, changes=changes)
|
||||||
|
return self._page_document_result(
|
||||||
|
result,
|
||||||
|
kind="changeset.diff",
|
||||||
|
limit=limit,
|
||||||
|
cursor=cursor,
|
||||||
|
parallel_key="changes",
|
||||||
|
)
|
||||||
|
|
||||||
def projected_snapshot(self, changeset_id: str) -> tuple[ProjectSnapshot, str]:
|
def projected_snapshot(self, changeset_id: str) -> tuple[ProjectSnapshot, str]:
|
||||||
"""Return a validated in-memory proposal projection for derived preview use."""
|
"""Return a validated in-memory proposal projection for derived preview use."""
|
||||||
|
|
@ -790,6 +912,219 @@ class ChangesetStore:
|
||||||
**payload,
|
**payload,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _page_document_result(
|
||||||
|
self,
|
||||||
|
result: dict[str, object],
|
||||||
|
*,
|
||||||
|
kind: str,
|
||||||
|
limit: int | None,
|
||||||
|
cursor: str | None,
|
||||||
|
parallel_key: str | None = None,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
"""Page bulky operation-aligned payloads while preserving direct full defaults."""
|
||||||
|
|
||||||
|
if limit is None and cursor is None:
|
||||||
|
return result
|
||||||
|
selected_limit = page_limit(
|
||||||
|
limit,
|
||||||
|
default=20,
|
||||||
|
maximum=self.project.descriptor.limits.max_results,
|
||||||
|
)
|
||||||
|
operations_value = result.get("operations")
|
||||||
|
if not isinstance(operations_value, list):
|
||||||
|
raise DocForgeError(
|
||||||
|
"invalid_pagination_source",
|
||||||
|
"Changeset result does not contain a deterministic operation list",
|
||||||
|
)
|
||||||
|
operations = cast(list[object], operations_value)
|
||||||
|
parallel: list[object] | None = None
|
||||||
|
if parallel_key is not None:
|
||||||
|
parallel_value = result.get(parallel_key)
|
||||||
|
if not isinstance(parallel_value, list):
|
||||||
|
raise DocForgeError(
|
||||||
|
"invalid_pagination_source",
|
||||||
|
"Changeset result does not contain an aligned detail list",
|
||||||
|
)
|
||||||
|
parallel = cast(list[object], parallel_value)
|
||||||
|
if len(parallel) != len(operations):
|
||||||
|
raise DocForgeError(
|
||||||
|
"invalid_pagination_source",
|
||||||
|
"Changeset detail list is not aligned with its operations",
|
||||||
|
)
|
||||||
|
binding = {
|
||||||
|
"project_id": result["project_id"],
|
||||||
|
"project_root_fingerprint": result["project_root_fingerprint"],
|
||||||
|
"revision": result["revision"],
|
||||||
|
"source_hash": result["source_hash"],
|
||||||
|
"changeset_id": result["changeset_id"],
|
||||||
|
"changeset_hash": result["changeset_hash"],
|
||||||
|
"adapter": result["adapter"],
|
||||||
|
"result_hash": canonical_hash(
|
||||||
|
{
|
||||||
|
"operations": operations,
|
||||||
|
**({parallel_key: parallel} if parallel_key is not None else {}),
|
||||||
|
}
|
||||||
|
),
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
kind == "changeset.diff"
|
||||||
|
and parallel_key is not None
|
||||||
|
and parallel is not None
|
||||||
|
and self._encoded_length(result) > self._safe_page_chars()
|
||||||
|
):
|
||||||
|
return self._page_json_chunks(
|
||||||
|
result,
|
||||||
|
operations=operations,
|
||||||
|
changes=parallel,
|
||||||
|
binding=binding,
|
||||||
|
cursor=cursor,
|
||||||
|
)
|
||||||
|
position = decode_cursor(
|
||||||
|
cursor,
|
||||||
|
kind=kind,
|
||||||
|
binding=binding,
|
||||||
|
total_count=len(operations),
|
||||||
|
)
|
||||||
|
page = operations[position : position + selected_limit]
|
||||||
|
paged = {
|
||||||
|
**result,
|
||||||
|
"operations": page,
|
||||||
|
"returned_operation_count": len(page),
|
||||||
|
"pagination": page_receipt(
|
||||||
|
kind=kind,
|
||||||
|
binding=binding,
|
||||||
|
position=position,
|
||||||
|
count=len(page),
|
||||||
|
limit=selected_limit,
|
||||||
|
total_count=len(operations),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
if parallel_key is not None and parallel is not None:
|
||||||
|
paged[parallel_key] = parallel[position : position + selected_limit]
|
||||||
|
if self._encoded_length(paged) > self._safe_page_chars():
|
||||||
|
paged["operations"] = [
|
||||||
|
self._operation_summary(item)
|
||||||
|
for item in operations[position : position + selected_limit]
|
||||||
|
]
|
||||||
|
paged["result_mode"] = "operation_summaries"
|
||||||
|
paged["detail_tool"] = "docforge_get_changeset_diff"
|
||||||
|
return paged
|
||||||
|
|
||||||
|
def _page_json_chunks(
|
||||||
|
self,
|
||||||
|
result: dict[str, object],
|
||||||
|
*,
|
||||||
|
operations: list[object],
|
||||||
|
changes: list[object],
|
||||||
|
binding: Mapping[str, object],
|
||||||
|
cursor: str | None,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
payload = {"operations": operations, "changes": changes}
|
||||||
|
encoded = json.dumps(
|
||||||
|
payload,
|
||||||
|
sort_keys=True,
|
||||||
|
separators=(",", ":"),
|
||||||
|
ensure_ascii=True,
|
||||||
|
allow_nan=False,
|
||||||
|
)
|
||||||
|
chunk_chars = max(512, min(64_000, self._safe_page_chars() // 2))
|
||||||
|
chunks = [
|
||||||
|
encoded[offset : offset + chunk_chars] for offset in range(0, len(encoded), chunk_chars)
|
||||||
|
] or [""]
|
||||||
|
chunk_binding = {
|
||||||
|
**binding,
|
||||||
|
"payload_hash": canonical_hash(payload),
|
||||||
|
"chunk_chars": chunk_chars,
|
||||||
|
}
|
||||||
|
position = decode_cursor(
|
||||||
|
cursor,
|
||||||
|
kind="changeset.diff-chunks",
|
||||||
|
binding=chunk_binding,
|
||||||
|
total_count=len(chunks),
|
||||||
|
)
|
||||||
|
compact = {
|
||||||
|
key: value for key, value in result.items() if key not in {"operations", "changes"}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
**compact,
|
||||||
|
"result_mode": "canonical_json_chunk",
|
||||||
|
"payload": "changeset_diff",
|
||||||
|
"payload_hash": chunk_binding["payload_hash"],
|
||||||
|
"payload_characters": len(encoded),
|
||||||
|
"chunk": {
|
||||||
|
"index": position,
|
||||||
|
"characters": len(chunks[position]),
|
||||||
|
"content": chunks[position],
|
||||||
|
},
|
||||||
|
"pagination": page_receipt(
|
||||||
|
kind="changeset.diff-chunks",
|
||||||
|
binding=chunk_binding,
|
||||||
|
position=position,
|
||||||
|
count=1,
|
||||||
|
limit=1,
|
||||||
|
total_count=len(chunks),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _operation_summary(operation: object) -> dict[str, object]:
|
||||||
|
if not isinstance(operation, Mapping):
|
||||||
|
raise DocForgeError(
|
||||||
|
"invalid_pagination_source",
|
||||||
|
"Changeset operation is not a deterministic object",
|
||||||
|
)
|
||||||
|
payload = cast(Mapping[str, object], operation)
|
||||||
|
summary = {
|
||||||
|
key: payload.get(key)
|
||||||
|
for key in (
|
||||||
|
"sequence",
|
||||||
|
"operation",
|
||||||
|
"node_id",
|
||||||
|
"expected_content_hash",
|
||||||
|
"target_source",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
for key in ("metadata", "content", "relationship_changes", "rationale"):
|
||||||
|
value = payload.get(key)
|
||||||
|
summary[f"{key}_hash"] = canonical_hash(value)
|
||||||
|
summary[f"{key}_characters"] = len(
|
||||||
|
json.dumps(
|
||||||
|
value,
|
||||||
|
sort_keys=True,
|
||||||
|
separators=(",", ":"),
|
||||||
|
ensure_ascii=True,
|
||||||
|
allow_nan=False,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return summary
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _compact_list_record(record: dict[str, object]) -> dict[str, object]:
|
||||||
|
lifecycle = record.get("lifecycle")
|
||||||
|
if not isinstance(lifecycle, Mapping):
|
||||||
|
return record
|
||||||
|
lifecycle_payload = cast(Mapping[str, object], lifecycle)
|
||||||
|
reason = lifecycle_payload.get("reason")
|
||||||
|
if not isinstance(reason, str):
|
||||||
|
return record
|
||||||
|
return {
|
||||||
|
**record,
|
||||||
|
"lifecycle": {
|
||||||
|
**lifecycle_payload,
|
||||||
|
"reason": {
|
||||||
|
"characters": len(reason),
|
||||||
|
"sha256": canonical_hash(reason),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
def _safe_page_chars(self) -> int:
|
||||||
|
return max(1_024, self.project.descriptor.limits.max_tool_output_chars - 2_048)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _encoded_length(value: Mapping[str, object]) -> int:
|
||||||
|
return len(json.dumps(value, sort_keys=True, separators=(",", ":")))
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _base_result(snapshot: ProjectSnapshot, **payload: object) -> dict[str, object]:
|
def _base_result(snapshot: ProjectSnapshot, **payload: object) -> dict[str, object]:
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,8 @@ def _parser() -> argparse.ArgumentParser:
|
||||||
context = commands.add_parser("context")
|
context = commands.add_parser("context")
|
||||||
context.add_argument("profile")
|
context.add_argument("profile")
|
||||||
context.add_argument("--budget", type=int)
|
context.add_argument("--budget", type=int)
|
||||||
|
context.add_argument("--limit", type=int)
|
||||||
|
context.add_argument("--cursor")
|
||||||
render = commands.add_parser("render")
|
render = commands.add_parser("render")
|
||||||
render.add_argument("view_id")
|
render.add_argument("view_id")
|
||||||
render_status = commands.add_parser("render-status")
|
render_status = commands.add_parser("render-status")
|
||||||
|
|
@ -175,6 +177,15 @@ def _run(arguments: argparse.Namespace) -> dict[str, object]:
|
||||||
limit=arguments.limit,
|
limit=arguments.limit,
|
||||||
)
|
)
|
||||||
if arguments.command == "context":
|
if arguments.command == "context":
|
||||||
|
if arguments.limit is not None or arguments.cursor is not None:
|
||||||
|
from .mcp_server import DocForgeService
|
||||||
|
|
||||||
|
return DocForgeService(project).context(
|
||||||
|
arguments.profile,
|
||||||
|
arguments.budget,
|
||||||
|
limit=arguments.limit,
|
||||||
|
cursor=arguments.cursor,
|
||||||
|
)
|
||||||
return compile_context(index, arguments.profile, arguments.budget)
|
return compile_context(index, arguments.profile, arguments.budget)
|
||||||
if arguments.command == "render":
|
if arguments.command == "render":
|
||||||
return RenderService(project).render(arguments.view_id)
|
return RenderService(project).render(arguments.view_id)
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ from .context import compile_context
|
||||||
from .errors import DocForgeError
|
from .errors import DocForgeError
|
||||||
from .index import ProjectIndex
|
from .index import ProjectIndex
|
||||||
from .models import IncrementalStateProject, ProjectService, RuntimeValidatedProject
|
from .models import IncrementalStateProject, ProjectService, RuntimeValidatedProject
|
||||||
|
from .pagination import canonical_hash, decode_cursor, page_limit, page_receipt
|
||||||
from .project import Project, project_root_fingerprint
|
from .project import Project, project_root_fingerprint
|
||||||
from .rendering import RenderService
|
from .rendering import RenderService
|
||||||
from .telemetry import request, stage
|
from .telemetry import request, stage
|
||||||
|
|
@ -87,6 +88,7 @@ STALE_ERROR_CODES = frozenset(
|
||||||
"content_conflict",
|
"content_conflict",
|
||||||
"source_changed",
|
"source_changed",
|
||||||
"stale_adapter_source",
|
"stale_adapter_source",
|
||||||
|
"stale_cursor",
|
||||||
"stale_index",
|
"stale_index",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
@ -488,6 +490,11 @@ class DocForgeService:
|
||||||
"tool": "docforge_get_changeset",
|
"tool": "docforge_get_changeset",
|
||||||
"arguments": {"changeset_id": "<same>"},
|
"arguments": {"changeset_id": "<same>"},
|
||||||
}
|
}
|
||||||
|
if error.code == "stale_cursor":
|
||||||
|
return {
|
||||||
|
"retryable": True,
|
||||||
|
"action": "restart_pagination",
|
||||||
|
}
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def synchronize(self) -> dict[str, object]:
|
def synchronize(self) -> dict[str, object]:
|
||||||
|
|
@ -707,12 +714,158 @@ class DocForgeService:
|
||||||
operation_name="mcp.render_status",
|
operation_name="mcp.render_status",
|
||||||
)
|
)
|
||||||
|
|
||||||
def context(self, profile: str, budget: int | None = None) -> dict[str, Any]:
|
def context(
|
||||||
|
self,
|
||||||
|
profile: str,
|
||||||
|
budget: int | None = None,
|
||||||
|
*,
|
||||||
|
limit: int | None = None,
|
||||||
|
cursor: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
def operation() -> dict[str, object]:
|
||||||
|
selected_limit = page_limit(
|
||||||
|
limit,
|
||||||
|
default=20,
|
||||||
|
maximum=self.project.descriptor.limits.max_results,
|
||||||
|
)
|
||||||
|
result = self.context_provider(self.index, profile, budget)
|
||||||
|
return self._page_context_result(
|
||||||
|
result,
|
||||||
|
profile=profile,
|
||||||
|
selected_limit=selected_limit,
|
||||||
|
cursor=cursor,
|
||||||
|
)
|
||||||
|
|
||||||
return self.invoke(
|
return self.invoke(
|
||||||
lambda: self.context_provider(self.index, profile, budget),
|
operation,
|
||||||
operation_name="mcp.context",
|
operation_name="mcp.context",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _page_context_result(
|
||||||
|
self,
|
||||||
|
result: dict[str, object],
|
||||||
|
*,
|
||||||
|
profile: str,
|
||||||
|
selected_limit: int,
|
||||||
|
cursor: str | None,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
entries_value = result.get("entries")
|
||||||
|
omissions_value = result.get("omissions")
|
||||||
|
if not isinstance(entries_value, list) or not isinstance(omissions_value, list):
|
||||||
|
raise DocForgeError(
|
||||||
|
"invalid_context_result",
|
||||||
|
"Context provider did not return deterministic entries and omissions",
|
||||||
|
)
|
||||||
|
entries = cast(list[object], entries_value)
|
||||||
|
omissions = cast(list[object], omissions_value)
|
||||||
|
binding = {
|
||||||
|
"project_id": result.get("project_id"),
|
||||||
|
"project_root_fingerprint": result.get("project_root_fingerprint"),
|
||||||
|
"adapter": result.get("adapter"),
|
||||||
|
"revision": result.get("revision"),
|
||||||
|
"source_hash": result.get("source_hash"),
|
||||||
|
"profile": profile,
|
||||||
|
"budget": result.get("budget"),
|
||||||
|
"selection_hash": canonical_hash(
|
||||||
|
{
|
||||||
|
"entries": entries,
|
||||||
|
"omissions": omissions,
|
||||||
|
}
|
||||||
|
),
|
||||||
|
}
|
||||||
|
evidence = [
|
||||||
|
*(("entry", item) for item in entries),
|
||||||
|
*(("omission", item) for item in omissions),
|
||||||
|
]
|
||||||
|
position = decode_cursor(
|
||||||
|
cursor,
|
||||||
|
kind="context.items",
|
||||||
|
binding=binding,
|
||||||
|
total_count=len(evidence),
|
||||||
|
)
|
||||||
|
page_entries: list[object] = []
|
||||||
|
page_omissions: list[object] = []
|
||||||
|
consumed = 0
|
||||||
|
truncation_reason: str | None = None
|
||||||
|
maximum = self.project.descriptor.limits.max_tool_output_chars
|
||||||
|
|
||||||
|
def page_result() -> dict[str, object]:
|
||||||
|
pagination = page_receipt(
|
||||||
|
kind="context.items",
|
||||||
|
binding=binding,
|
||||||
|
position=position,
|
||||||
|
count=consumed,
|
||||||
|
limit=selected_limit,
|
||||||
|
total_count=len(evidence),
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
**result,
|
||||||
|
"entries": page_entries,
|
||||||
|
"omissions": page_omissions,
|
||||||
|
"entry_count": len(page_entries),
|
||||||
|
"omission_count": len(page_omissions),
|
||||||
|
"page_count": consumed,
|
||||||
|
"page_estimated_tokens": sum(
|
||||||
|
cast(int, cast(Mapping[str, object], item).get("estimated_tokens", 0))
|
||||||
|
for item in page_entries
|
||||||
|
if isinstance(item, Mapping)
|
||||||
|
),
|
||||||
|
"summary": {
|
||||||
|
"entry_count": len(entries),
|
||||||
|
"omission_count": len(omissions),
|
||||||
|
"evidence_count": len(evidence),
|
||||||
|
"estimated_tokens": result.get("estimated_tokens"),
|
||||||
|
},
|
||||||
|
"truncation_reason": truncation_reason,
|
||||||
|
"next_cursor": pagination["next_cursor"],
|
||||||
|
"pagination": pagination,
|
||||||
|
}
|
||||||
|
|
||||||
|
for kind, item in evidence[position:]:
|
||||||
|
if consumed >= selected_limit:
|
||||||
|
truncation_reason = "result_limit"
|
||||||
|
break
|
||||||
|
destination = page_entries if kind == "entry" else page_omissions
|
||||||
|
destination.append(item)
|
||||||
|
consumed += 1
|
||||||
|
candidate = page_result()
|
||||||
|
decorated = {
|
||||||
|
**candidate,
|
||||||
|
"server_version": SERVER_VERSION,
|
||||||
|
"content_warning": CONTENT_WARNING,
|
||||||
|
"staleness": "current",
|
||||||
|
}
|
||||||
|
if self._encoded_length(decorated) <= maximum:
|
||||||
|
continue
|
||||||
|
destination.pop()
|
||||||
|
consumed -= 1
|
||||||
|
truncation_reason = "response_limit"
|
||||||
|
if consumed == 0:
|
||||||
|
compact = self._oversized_context_omission(kind, item)
|
||||||
|
page_omissions.append(compact)
|
||||||
|
consumed = 1
|
||||||
|
break
|
||||||
|
if position + consumed < len(evidence) and truncation_reason is None:
|
||||||
|
truncation_reason = "result_limit"
|
||||||
|
return page_result()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _oversized_context_omission(kind: str, item: object) -> dict[str, object]:
|
||||||
|
node_id = "unknown"
|
||||||
|
hash_source = item
|
||||||
|
if isinstance(item, Mapping):
|
||||||
|
item_payload = cast(Mapping[str, object], item)
|
||||||
|
candidate = item_payload.get("node_id")
|
||||||
|
if isinstance(candidate, str) and candidate:
|
||||||
|
node_id = candidate[:256]
|
||||||
|
hash_source = dict(item_payload)
|
||||||
|
return {
|
||||||
|
"node_id": node_id,
|
||||||
|
"reason": "response size limit",
|
||||||
|
"original_evidence": kind,
|
||||||
|
"detail_hash": canonical_hash(hash_source),
|
||||||
|
}
|
||||||
|
|
||||||
def visualize(
|
def visualize(
|
||||||
self,
|
self,
|
||||||
node_id: str | None = None,
|
node_id: str | None = None,
|
||||||
|
|
@ -897,10 +1050,15 @@ def _create_bound_server(service: DocForgeService, *, read_only: bool) -> FastMC
|
||||||
)
|
)
|
||||||
|
|
||||||
@server.tool(name="docforge_get_context")
|
@server.tool(name="docforge_get_context")
|
||||||
def get_context(profile: str, budget: int | None = None) -> dict[str, Any]:
|
def get_context(
|
||||||
|
profile: str,
|
||||||
|
budget: int | None = None,
|
||||||
|
limit: int | None = None,
|
||||||
|
cursor: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
"""Compile bounded cited context from one configured profile with explicit omissions."""
|
"""Compile bounded cited context from one configured profile with explicit omissions."""
|
||||||
|
|
||||||
return service.context(profile, budget)
|
return service.context(profile, budget, limit=limit, cursor=cursor)
|
||||||
|
|
||||||
@server.tool(name="docforge_validate_project")
|
@server.tool(name="docforge_validate_project")
|
||||||
def validate_project() -> dict[str, Any]:
|
def validate_project() -> dict[str, Any]:
|
||||||
|
|
@ -1000,6 +1158,8 @@ def _create_bound_server(service: DocForgeService, *, read_only: bool) -> FastMC
|
||||||
def list_changesets(
|
def list_changesets(
|
||||||
include_history: bool = False,
|
include_history: bool = False,
|
||||||
status: str | None = None,
|
status: str | None = None,
|
||||||
|
limit: int | None = 20,
|
||||||
|
cursor: str | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""List active proposals by default, with optional lifecycle history."""
|
"""List active proposals by default, with optional lifecycle history."""
|
||||||
|
|
||||||
|
|
@ -1007,16 +1167,26 @@ def _create_bound_server(service: DocForgeService, *, read_only: bool) -> FastMC
|
||||||
lambda: service.changesets.list_changesets(
|
lambda: service.changesets.list_changesets(
|
||||||
include_history=include_history,
|
include_history=include_history,
|
||||||
status=status,
|
status=status,
|
||||||
|
limit=20 if limit is None else limit,
|
||||||
|
cursor=cursor,
|
||||||
),
|
),
|
||||||
operation_name="mcp.changeset",
|
operation_name="mcp.changeset",
|
||||||
)
|
)
|
||||||
|
|
||||||
@server.tool(name="docforge_get_changeset")
|
@server.tool(name="docforge_get_changeset")
|
||||||
def get_changeset(changeset_id: str) -> dict[str, Any]:
|
def get_changeset(
|
||||||
|
changeset_id: str,
|
||||||
|
limit: int | None = 20,
|
||||||
|
cursor: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
"""Inspect a stored proposal even when its canonical base has become stale."""
|
"""Inspect a stored proposal even when its canonical base has become stale."""
|
||||||
|
|
||||||
return service.invoke(
|
return service.invoke(
|
||||||
lambda: service.changesets.inspect(changeset_id),
|
lambda: service.changesets.inspect(
|
||||||
|
changeset_id,
|
||||||
|
limit=20 if limit is None else limit,
|
||||||
|
cursor=cursor,
|
||||||
|
),
|
||||||
operation_name="mcp.changeset",
|
operation_name="mcp.changeset",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -1225,20 +1395,36 @@ def _create_bound_server(service: DocForgeService, *, read_only: bool) -> FastMC
|
||||||
)
|
)
|
||||||
|
|
||||||
@server.tool(name="docforge_validate_changeset")
|
@server.tool(name="docforge_validate_changeset")
|
||||||
def validate_changeset(changeset_id: str) -> dict[str, Any]:
|
def validate_changeset(
|
||||||
|
changeset_id: str,
|
||||||
|
limit: int | None = 20,
|
||||||
|
cursor: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
"""Validate a proposal against its exact canonical base and other active proposals."""
|
"""Validate a proposal against its exact canonical base and other active proposals."""
|
||||||
|
|
||||||
return service.invoke(
|
return service.invoke(
|
||||||
lambda: service.changesets.validate(changeset_id),
|
lambda: service.changesets.validate(
|
||||||
|
changeset_id,
|
||||||
|
limit=20 if limit is None else limit,
|
||||||
|
cursor=cursor,
|
||||||
|
),
|
||||||
operation_name="mcp.changeset",
|
operation_name="mcp.changeset",
|
||||||
)
|
)
|
||||||
|
|
||||||
@server.tool(name="docforge_get_changeset_diff")
|
@server.tool(name="docforge_get_changeset_diff")
|
||||||
def get_changeset_diff(changeset_id: str) -> dict[str, Any]:
|
def get_changeset_diff(
|
||||||
|
changeset_id: str,
|
||||||
|
limit: int | None = 20,
|
||||||
|
cursor: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
"""Return a deterministic structured and textual diff without applying the proposal."""
|
"""Return a deterministic structured and textual diff without applying the proposal."""
|
||||||
|
|
||||||
return service.invoke(
|
return service.invoke(
|
||||||
lambda: service.changesets.diff(changeset_id),
|
lambda: service.changesets.diff(
|
||||||
|
changeset_id,
|
||||||
|
limit=20 if limit is None else limit,
|
||||||
|
cursor=cursor,
|
||||||
|
),
|
||||||
operation_name="mcp.changeset",
|
operation_name="mcp.changeset",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
163
src/docforge/pagination.py
Normal file
163
src/docforge/pagination.py
Normal file
|
|
@ -0,0 +1,163 @@
|
||||||
|
"""Deterministic, generation-bound pagination cursors for bounded public results."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import binascii
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import json
|
||||||
|
from collections.abc import Mapping
|
||||||
|
from typing import cast
|
||||||
|
|
||||||
|
from .errors import DocForgeError
|
||||||
|
|
||||||
|
CURSOR_SCHEMA_VERSION = 1
|
||||||
|
MAX_CURSOR_CHARS = 8_192
|
||||||
|
_CURSOR_DOMAIN = b"docforge-page-cursor-v1\0"
|
||||||
|
_CURSOR_KEYS = frozenset({"schema_version", "kind", "binding", "position", "checksum"})
|
||||||
|
|
||||||
|
|
||||||
|
def canonical_hash(value: object) -> str:
|
||||||
|
"""Hash one JSON-compatible value using DocForge's deterministic JSON form."""
|
||||||
|
|
||||||
|
try:
|
||||||
|
encoded = _canonical_bytes(value)
|
||||||
|
except (TypeError, ValueError) as error:
|
||||||
|
raise DocForgeError(
|
||||||
|
"invalid_pagination_source",
|
||||||
|
"Pagination source data is not deterministic JSON",
|
||||||
|
) from error
|
||||||
|
return hashlib.sha256(encoded).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def page_limit(limit: int | None, *, default: int, maximum: int) -> int:
|
||||||
|
"""Validate one additive page size against the project result policy."""
|
||||||
|
|
||||||
|
selected = default if limit is None else limit
|
||||||
|
if type(selected) is not int or selected < 1 or selected > maximum:
|
||||||
|
raise DocForgeError(
|
||||||
|
"invalid_limit",
|
||||||
|
"Page limit is outside the configured result limit",
|
||||||
|
maximum=maximum,
|
||||||
|
)
|
||||||
|
return selected
|
||||||
|
|
||||||
|
|
||||||
|
def encode_cursor(
|
||||||
|
*,
|
||||||
|
kind: str,
|
||||||
|
binding: Mapping[str, object],
|
||||||
|
position: int,
|
||||||
|
) -> str:
|
||||||
|
"""Encode a corruption-detecting cursor bound to an immutable result identity."""
|
||||||
|
|
||||||
|
if not kind or type(position) is not int or position < 0:
|
||||||
|
raise ValueError("Cursor kind and position must be valid")
|
||||||
|
body: dict[str, object] = {
|
||||||
|
"schema_version": CURSOR_SCHEMA_VERSION,
|
||||||
|
"kind": kind,
|
||||||
|
"binding": dict(binding),
|
||||||
|
"position": position,
|
||||||
|
}
|
||||||
|
checksum = hashlib.sha256(_CURSOR_DOMAIN + _canonical_bytes(body)).hexdigest()
|
||||||
|
envelope = {**body, "checksum": checksum}
|
||||||
|
return base64.urlsafe_b64encode(_canonical_bytes(envelope)).decode("ascii").rstrip("=")
|
||||||
|
|
||||||
|
|
||||||
|
def decode_cursor(
|
||||||
|
cursor: str | None,
|
||||||
|
*,
|
||||||
|
kind: str,
|
||||||
|
binding: Mapping[str, object],
|
||||||
|
total_count: int,
|
||||||
|
) -> int:
|
||||||
|
"""Return a validated position, rejecting corrupt, foreign, or stale cursors."""
|
||||||
|
|
||||||
|
if cursor is None:
|
||||||
|
return 0
|
||||||
|
if not cursor or len(cursor) > MAX_CURSOR_CHARS or not cursor.isascii():
|
||||||
|
raise _invalid_cursor()
|
||||||
|
padding = "=" * (-len(cursor) % 4)
|
||||||
|
try:
|
||||||
|
raw = base64.b64decode(
|
||||||
|
(cursor + padding).encode("ascii"),
|
||||||
|
altchars=b"-_",
|
||||||
|
validate=True,
|
||||||
|
)
|
||||||
|
parsed: object = json.loads(raw.decode("utf-8"))
|
||||||
|
except (binascii.Error, UnicodeDecodeError, json.JSONDecodeError):
|
||||||
|
raise _invalid_cursor() from None
|
||||||
|
if base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=") != cursor or not isinstance(
|
||||||
|
parsed, dict
|
||||||
|
):
|
||||||
|
raise _invalid_cursor()
|
||||||
|
payload = cast(dict[str, object], parsed)
|
||||||
|
checksum = payload.get("checksum")
|
||||||
|
position = payload.get("position")
|
||||||
|
stored_binding = payload.get("binding")
|
||||||
|
if (
|
||||||
|
frozenset(payload) != _CURSOR_KEYS
|
||||||
|
or payload.get("schema_version") != CURSOR_SCHEMA_VERSION
|
||||||
|
or payload.get("kind") != kind
|
||||||
|
or not isinstance(stored_binding, dict)
|
||||||
|
or type(position) is not int
|
||||||
|
or position < 0
|
||||||
|
or position >= total_count
|
||||||
|
or not isinstance(checksum, str)
|
||||||
|
or len(checksum) != 64
|
||||||
|
):
|
||||||
|
raise _invalid_cursor()
|
||||||
|
body = {key: payload[key] for key in payload if key != "checksum"}
|
||||||
|
expected = hashlib.sha256(_CURSOR_DOMAIN + _canonical_bytes(body)).hexdigest()
|
||||||
|
if not hmac.compare_digest(checksum, expected):
|
||||||
|
raise _invalid_cursor()
|
||||||
|
if stored_binding != dict(binding):
|
||||||
|
raise DocForgeError(
|
||||||
|
"stale_cursor",
|
||||||
|
"Pagination cursor does not match the current result generation",
|
||||||
|
)
|
||||||
|
return position
|
||||||
|
|
||||||
|
|
||||||
|
def page_receipt(
|
||||||
|
*,
|
||||||
|
kind: str,
|
||||||
|
binding: Mapping[str, object],
|
||||||
|
position: int,
|
||||||
|
count: int,
|
||||||
|
limit: int,
|
||||||
|
total_count: int,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
"""Return one bounded page receipt and the next generation-bound cursor."""
|
||||||
|
|
||||||
|
next_position = position + count
|
||||||
|
has_more = next_position < total_count
|
||||||
|
return {
|
||||||
|
"schema_version": CURSOR_SCHEMA_VERSION,
|
||||||
|
"kind": kind,
|
||||||
|
"returned_count": count,
|
||||||
|
"limit": limit,
|
||||||
|
"total_count": total_count,
|
||||||
|
"has_more": has_more,
|
||||||
|
"next_cursor": (
|
||||||
|
encode_cursor(kind=kind, binding=binding, position=next_position) if has_more else None
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _canonical_bytes(value: object) -> bytes:
|
||||||
|
return json.dumps(
|
||||||
|
value,
|
||||||
|
sort_keys=True,
|
||||||
|
separators=(",", ":"),
|
||||||
|
ensure_ascii=True,
|
||||||
|
allow_nan=False,
|
||||||
|
).encode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def _invalid_cursor() -> DocForgeError:
|
||||||
|
return DocForgeError(
|
||||||
|
"invalid_cursor",
|
||||||
|
"Pagination cursor is malformed or does not match its operation",
|
||||||
|
)
|
||||||
|
|
@ -39,6 +39,20 @@ class DocForgeCliTests(unittest.TestCase):
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
self.assertEqual(7, arguments.limit)
|
self.assertEqual(7, arguments.limit)
|
||||||
|
context = parser.parse_args(
|
||||||
|
[
|
||||||
|
"--project-root",
|
||||||
|
"/tmp/project",
|
||||||
|
"context",
|
||||||
|
"active",
|
||||||
|
"--limit",
|
||||||
|
"7",
|
||||||
|
"--cursor",
|
||||||
|
"opaque",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
self.assertEqual(7, context.limit)
|
||||||
|
self.assertEqual("opaque", context.cursor)
|
||||||
|
|
||||||
def test_reindex_apply_and_visualization_commands_are_self_service(self) -> None:
|
def test_reindex_apply_and_visualization_commands_are_self_service(self) -> None:
|
||||||
with tempfile.TemporaryDirectory() as directory:
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
|
|
||||||
|
|
@ -378,6 +378,27 @@ class DocForgeCoreTests(unittest.TestCase):
|
||||||
operation()
|
operation()
|
||||||
self.assertEqual("invalid_limit", invalid.exception.code)
|
self.assertEqual("invalid_limit", invalid.exception.code)
|
||||||
|
|
||||||
|
def test_incoming_traversal_uses_source_ordered_covering_index(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
root = self.copy_fixture("alpha", Path(directory))
|
||||||
|
index = ProjectIndex(Project.open(root))
|
||||||
|
index.build()
|
||||||
|
|
||||||
|
connection = sqlite3.connect(index.path)
|
||||||
|
try:
|
||||||
|
plan = connection.execute(
|
||||||
|
"EXPLAIN QUERY PLAN "
|
||||||
|
"SELECT source_id, relation, target_id FROM edges "
|
||||||
|
"WHERE target_id = ? "
|
||||||
|
"ORDER BY source_id, relation, target_id LIMIT ?",
|
||||||
|
("guide.foundation", 101),
|
||||||
|
).fetchall()
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
details = " ".join(str(row[3]) for row in plan)
|
||||||
|
self.assertIn("edges_target_source", details)
|
||||||
|
self.assertNotIn("USE TEMP B-TREE", details)
|
||||||
|
|
||||||
def test_query_rechecks_source_identity_before_returning(self) -> None:
|
def test_query_rechecks_source_identity_before_returning(self) -> None:
|
||||||
with tempfile.TemporaryDirectory() as directory:
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
root = self.copy_fixture("alpha", Path(directory))
|
root = self.copy_fixture("alpha", Path(directory))
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ from mcp import ClientSession, StdioServerParameters
|
||||||
from mcp.client.stdio import stdio_client
|
from mcp.client.stdio import stdio_client
|
||||||
from mcp.shared.memory import create_connected_server_and_client_session
|
from mcp.shared.memory import create_connected_server_and_client_session
|
||||||
|
|
||||||
|
from docforge.changesets import ChangesetStore
|
||||||
from docforge.index import ProjectIndex
|
from docforge.index import ProjectIndex
|
||||||
from docforge.mcp_server import (
|
from docforge.mcp_server import (
|
||||||
ALL_TOOLS,
|
ALL_TOOLS,
|
||||||
|
|
@ -80,6 +81,16 @@ class DocForgeMcpTests(unittest.IsolatedAsyncioTestCase):
|
||||||
):
|
):
|
||||||
self.assertIn("limit", tools[name].inputSchema["properties"])
|
self.assertIn("limit", tools[name].inputSchema["properties"])
|
||||||
self.assertNotIn("limit", tools[name].inputSchema.get("required", []))
|
self.assertNotIn("limit", tools[name].inputSchema.get("required", []))
|
||||||
|
for name in (
|
||||||
|
"docforge_get_context",
|
||||||
|
"docforge_list_changesets",
|
||||||
|
"docforge_get_changeset",
|
||||||
|
"docforge_validate_changeset",
|
||||||
|
"docforge_get_changeset_diff",
|
||||||
|
):
|
||||||
|
for field in ("limit", "cursor"):
|
||||||
|
self.assertIn(field, tools[name].inputSchema["properties"])
|
||||||
|
self.assertNotIn(field, tools[name].inputSchema.get("required", []))
|
||||||
self.assertIn(
|
self.assertIn(
|
||||||
"deep",
|
"deep",
|
||||||
tools["docforge_render_status"].inputSchema["properties"],
|
tools["docforge_render_status"].inputSchema["properties"],
|
||||||
|
|
@ -110,6 +121,134 @@ class DocForgeMcpTests(unittest.IsolatedAsyncioTestCase):
|
||||||
self.assertEqual(0, diagnostics["counters"]["project_loads"])
|
self.assertEqual(0, diagnostics["counters"]["project_loads"])
|
||||||
self.assertEqual(0, diagnostics["counters"]["source_files_parsed"])
|
self.assertEqual(0, diagnostics["counters"]["source_files_parsed"])
|
||||||
|
|
||||||
|
async def test_context_pagination_is_complete_and_stale_cursors_fail_closed(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
root = self.copy_fixture("alpha", Path(directory))
|
||||||
|
ProjectIndex(Project.open(root)).build()
|
||||||
|
async with create_connected_server_and_client_session(
|
||||||
|
create_server(root), raise_exceptions=True
|
||||||
|
) as session:
|
||||||
|
first = await session.call_tool(
|
||||||
|
"docforge_get_context",
|
||||||
|
{"profile": "active", "limit": 1},
|
||||||
|
)
|
||||||
|
cursor = first.structuredContent["pagination"]["next_cursor"]
|
||||||
|
pages = [first.structuredContent]
|
||||||
|
while cursor is not None:
|
||||||
|
page = await session.call_tool(
|
||||||
|
"docforge_get_context",
|
||||||
|
{
|
||||||
|
"profile": "active",
|
||||||
|
"limit": 2,
|
||||||
|
"cursor": cursor,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
pages.append(page.structuredContent)
|
||||||
|
cursor = page.structuredContent["pagination"]["next_cursor"]
|
||||||
|
|
||||||
|
stale_cursor = first.structuredContent["pagination"]["next_cursor"]
|
||||||
|
workflow = root / "docs" / "content" / "workflow.md"
|
||||||
|
workflow.write_text(
|
||||||
|
workflow.read_text(encoding="utf-8") + "\nChanged between pages.\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
stale = await session.call_tool(
|
||||||
|
"docforge_get_context",
|
||||||
|
{
|
||||||
|
"profile": "active",
|
||||||
|
"limit": 1,
|
||||||
|
"cursor": stale_cursor,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
evidence = [
|
||||||
|
*(("entry", item["node_id"]) for page in pages for item in page["entries"]),
|
||||||
|
*(("omission", item["node_id"]) for page in pages for item in page["omissions"]),
|
||||||
|
]
|
||||||
|
self.assertEqual(len(evidence), pages[0]["summary"]["evidence_count"])
|
||||||
|
self.assertEqual(len(evidence), len(set(evidence)))
|
||||||
|
self.assertEqual("stale_cursor", stale.structuredContent["error"]["code"])
|
||||||
|
self.assertEqual("stale", stale.structuredContent["staleness"])
|
||||||
|
self.assertEqual(
|
||||||
|
"restart_pagination",
|
||||||
|
stale.structuredContent["error"]["remediation"]["action"],
|
||||||
|
)
|
||||||
|
|
||||||
|
async def test_oversized_changeset_reads_return_exact_pages_and_chunks(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
root = self.copy_fixture("alpha", Path(directory))
|
||||||
|
descriptor = root / ".docforge" / "project.toml"
|
||||||
|
descriptor.write_text(
|
||||||
|
descriptor.read_text(encoding="utf-8").replace(
|
||||||
|
"max_changeset_bytes = 100000",
|
||||||
|
"max_changeset_bytes = 100000\nmax_tool_output_chars = 20000",
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
project = Project.open(root)
|
||||||
|
ProjectIndex(project).build()
|
||||||
|
store = ChangesetStore(project, "alpha-editor")
|
||||||
|
created = store.create("mcp-chunked-diff")
|
||||||
|
foundation = next(
|
||||||
|
node for node in project.load().nodes if node.node_id == "guide.foundation"
|
||||||
|
)
|
||||||
|
proposed = store.propose_update(
|
||||||
|
changeset_id="mcp-chunked-diff",
|
||||||
|
expected_changeset_hash=str(created["changeset_hash"]),
|
||||||
|
node_id=foundation.node_id,
|
||||||
|
expected_content_hash=foundation.content_hash,
|
||||||
|
metadata=None,
|
||||||
|
content="replacement " * 4_000,
|
||||||
|
relationship_changes=[],
|
||||||
|
rationale="Exercise bounded MCP diff reconstruction.",
|
||||||
|
)
|
||||||
|
direct = store.diff("mcp-chunked-diff")
|
||||||
|
|
||||||
|
async with create_connected_server_and_client_session(
|
||||||
|
create_server(root), raise_exceptions=True
|
||||||
|
) as session:
|
||||||
|
inspected = await session.call_tool(
|
||||||
|
"docforge_get_changeset",
|
||||||
|
{"changeset_id": "mcp-chunked-diff"},
|
||||||
|
)
|
||||||
|
validated = await session.call_tool(
|
||||||
|
"docforge_validate_changeset",
|
||||||
|
{"changeset_id": "mcp-chunked-diff"},
|
||||||
|
)
|
||||||
|
cursor: str | None = None
|
||||||
|
chunks: list[str] = []
|
||||||
|
while True:
|
||||||
|
page = await session.call_tool(
|
||||||
|
"docforge_get_changeset_diff",
|
||||||
|
{
|
||||||
|
"changeset_id": "mcp-chunked-diff",
|
||||||
|
"cursor": cursor,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertLessEqual(
|
||||||
|
len(json.dumps(page.structuredContent, separators=(",", ":"))),
|
||||||
|
20_000,
|
||||||
|
)
|
||||||
|
chunks.append(page.structuredContent["chunk"]["content"])
|
||||||
|
cursor = page.structuredContent["pagination"]["next_cursor"]
|
||||||
|
if cursor is None:
|
||||||
|
break
|
||||||
|
|
||||||
|
self.assertEqual("operation_summaries", inspected.structuredContent["result_mode"])
|
||||||
|
self.assertEqual("operation_summaries", validated.structuredContent["result_mode"])
|
||||||
|
self.assertTrue(validated.structuredContent["valid"])
|
||||||
|
self.assertEqual(
|
||||||
|
proposed["changeset_hash"],
|
||||||
|
validated.structuredContent["changeset_hash"],
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
{
|
||||||
|
"changes": direct["changes"],
|
||||||
|
"operations": direct["operations"],
|
||||||
|
},
|
||||||
|
json.loads("".join(chunks)),
|
||||||
|
)
|
||||||
|
|
||||||
async def test_no_ast_binding_preserves_adapter_and_blocks_logic(self) -> None:
|
async def test_no_ast_binding_preserves_adapter_and_blocks_logic(self) -> None:
|
||||||
with tempfile.TemporaryDirectory() as directory:
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
root = self.copy_fixture("alpha", Path(directory))
|
root = self.copy_fixture("alpha", Path(directory))
|
||||||
|
|
|
||||||
338
tests/test_pagination.py
Normal file
338
tests/test_pagination.py
Normal file
|
|
@ -0,0 +1,338 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import shutil
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from jsonschema import Draft202012Validator
|
||||||
|
|
||||||
|
from docforge.changesets import ChangesetStore
|
||||||
|
from docforge.errors import DocForgeError
|
||||||
|
from docforge.mcp_server import DocForgeService
|
||||||
|
from docforge.pagination import decode_cursor, encode_cursor
|
||||||
|
from docforge.project import Project, project_root_fingerprint
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
FIXTURES = ROOT / "tests" / "fixtures"
|
||||||
|
|
||||||
|
|
||||||
|
class PaginationTests(unittest.TestCase):
|
||||||
|
def copy_fixture(self, destination: Path, *, max_tool_chars: int | None = None) -> Path:
|
||||||
|
root = destination / "alpha"
|
||||||
|
shutil.copytree(FIXTURES / "alpha", root)
|
||||||
|
if max_tool_chars is not None:
|
||||||
|
descriptor = root / ".docforge" / "project.toml"
|
||||||
|
text = descriptor.read_text(encoding="utf-8")
|
||||||
|
text = text.replace(
|
||||||
|
"max_changeset_bytes = 100000",
|
||||||
|
(f"max_changeset_bytes = 100000\nmax_tool_output_chars = {max_tool_chars}"),
|
||||||
|
)
|
||||||
|
descriptor.write_text(text, encoding="utf-8")
|
||||||
|
return root
|
||||||
|
|
||||||
|
def test_cursor_is_canonical_corruption_detecting_and_binding_bound(self) -> None:
|
||||||
|
binding = {"project_id": "alpha", "source_hash": "a" * 64}
|
||||||
|
cursor = encode_cursor(kind="context.items", binding=binding, position=2)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
2,
|
||||||
|
decode_cursor(
|
||||||
|
cursor,
|
||||||
|
kind="context.items",
|
||||||
|
binding=binding,
|
||||||
|
total_count=4,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
with self.assertRaises(DocForgeError) as corrupt:
|
||||||
|
decode_cursor(
|
||||||
|
f"{cursor[:-1]}{'A' if cursor[-1] != 'A' else 'B'}",
|
||||||
|
kind="context.items",
|
||||||
|
binding=binding,
|
||||||
|
total_count=4,
|
||||||
|
)
|
||||||
|
self.assertEqual("invalid_cursor", corrupt.exception.code)
|
||||||
|
with self.assertRaises(DocForgeError) as foreign:
|
||||||
|
decode_cursor(
|
||||||
|
cursor,
|
||||||
|
kind="context.items",
|
||||||
|
binding={**binding, "source_hash": "b" * 64},
|
||||||
|
total_count=4,
|
||||||
|
)
|
||||||
|
self.assertEqual("stale_cursor", foreign.exception.code)
|
||||||
|
with self.assertRaises(DocForgeError) as wrong_operation:
|
||||||
|
decode_cursor(
|
||||||
|
cursor,
|
||||||
|
kind="changeset.list",
|
||||||
|
binding=binding,
|
||||||
|
total_count=4,
|
||||||
|
)
|
||||||
|
self.assertEqual("invalid_cursor", wrong_operation.exception.code)
|
||||||
|
|
||||||
|
def test_context_pages_entries_then_omissions_without_loss(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
root = self.copy_fixture(Path(directory))
|
||||||
|
project = Project.open(root)
|
||||||
|
source_hash = "a" * 64
|
||||||
|
|
||||||
|
def context_provider(
|
||||||
|
_index: object, profile: str, budget: int | None
|
||||||
|
) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"project_id": project.descriptor.project_id,
|
||||||
|
"project_root_fingerprint": project_root_fingerprint(root),
|
||||||
|
"adapter": project.descriptor.adapter,
|
||||||
|
"revision": "test-revision",
|
||||||
|
"source_hash": source_hash,
|
||||||
|
"profile": profile,
|
||||||
|
"budget": budget,
|
||||||
|
"estimated_tokens": 3,
|
||||||
|
"entries": [
|
||||||
|
{
|
||||||
|
"node_id": "guide.one",
|
||||||
|
"reason": "required",
|
||||||
|
"estimated_tokens": 1,
|
||||||
|
"text": "one",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"node_id": "guide.two",
|
||||||
|
"reason": "eligible",
|
||||||
|
"estimated_tokens": 2,
|
||||||
|
"text": "two",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"omissions": [
|
||||||
|
{"node_id": "guide.three", "reason": "token budget"},
|
||||||
|
{"node_id": "guide.four", "reason": "token budget"},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
service = DocForgeService(project, context_provider=context_provider)
|
||||||
|
result_validator = Draft202012Validator(
|
||||||
|
json.loads((ROOT / "schemas" / "result.schema.json").read_text(encoding="utf-8"))
|
||||||
|
)
|
||||||
|
cursor: str | None = None
|
||||||
|
evidence: list[tuple[str, str]] = []
|
||||||
|
limits = [1, 2, 1]
|
||||||
|
page_index = 0
|
||||||
|
while True:
|
||||||
|
page = service.context(
|
||||||
|
"active",
|
||||||
|
600,
|
||||||
|
limit=limits[min(page_index, len(limits) - 1)],
|
||||||
|
cursor=cursor,
|
||||||
|
)
|
||||||
|
result_validator.validate(page)
|
||||||
|
evidence.extend(
|
||||||
|
("entry", str(item["node_id"]))
|
||||||
|
for item in page["entries"]
|
||||||
|
if isinstance(item, dict)
|
||||||
|
)
|
||||||
|
evidence.extend(
|
||||||
|
("omission", str(item["node_id"]))
|
||||||
|
for item in page["omissions"]
|
||||||
|
if isinstance(item, dict)
|
||||||
|
)
|
||||||
|
pagination = page["pagination"]
|
||||||
|
self.assertIsInstance(pagination, dict)
|
||||||
|
cursor = pagination["next_cursor"]
|
||||||
|
page_index += 1
|
||||||
|
if cursor is None:
|
||||||
|
break
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
[
|
||||||
|
("entry", "guide.one"),
|
||||||
|
("entry", "guide.two"),
|
||||||
|
("omission", "guide.three"),
|
||||||
|
("omission", "guide.four"),
|
||||||
|
],
|
||||||
|
evidence,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_context_oversized_item_advances_as_a_bounded_omission(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
root = self.copy_fixture(Path(directory), max_tool_chars=4_000)
|
||||||
|
project = Project.open(root)
|
||||||
|
|
||||||
|
def context_provider(
|
||||||
|
_index: object, profile: str, budget: int | None
|
||||||
|
) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"project_id": project.descriptor.project_id,
|
||||||
|
"project_root_fingerprint": project_root_fingerprint(root),
|
||||||
|
"adapter": project.descriptor.adapter,
|
||||||
|
"revision": "test-revision",
|
||||||
|
"source_hash": "a" * 64,
|
||||||
|
"profile": profile,
|
||||||
|
"budget": budget,
|
||||||
|
"estimated_tokens": 10_000,
|
||||||
|
"entries": [
|
||||||
|
{
|
||||||
|
"node_id": "guide.oversized",
|
||||||
|
"reason": "required",
|
||||||
|
"estimated_tokens": 10_000,
|
||||||
|
"text": "x" * 20_000,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"omissions": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
result = DocForgeService(project, context_provider=context_provider).context(
|
||||||
|
"active",
|
||||||
|
600,
|
||||||
|
limit=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual("ok", result["status"])
|
||||||
|
self.assertEqual([], result["entries"])
|
||||||
|
self.assertEqual("response size limit", result["omissions"][0]["reason"])
|
||||||
|
self.assertLess(len(json.dumps(result, separators=(",", ":"))), 4_000)
|
||||||
|
self.assertFalse(result["pagination"]["has_more"])
|
||||||
|
|
||||||
|
def test_page_limits_reject_boolean_zero_and_policy_overflow(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
root = self.copy_fixture(Path(directory))
|
||||||
|
project = Project.open(root)
|
||||||
|
store = ChangesetStore(project, "alpha-editor")
|
||||||
|
store.create("bounded-page")
|
||||||
|
for limit in (True, 0, project.descriptor.limits.max_results + 1):
|
||||||
|
with self.subTest(limit=limit), self.assertRaises(DocForgeError) as invalid:
|
||||||
|
store.list_changesets(limit=limit)
|
||||||
|
self.assertEqual("invalid_limit", invalid.exception.code)
|
||||||
|
|
||||||
|
def test_changeset_pages_preserve_full_direct_defaults_and_exact_order(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
root = self.copy_fixture(Path(directory))
|
||||||
|
project = Project.open(root)
|
||||||
|
store = ChangesetStore(project, "alpha-editor")
|
||||||
|
created = store.create("paged-operations")
|
||||||
|
first = store.propose_update(
|
||||||
|
changeset_id="paged-operations",
|
||||||
|
expected_changeset_hash=str(created["changeset_hash"]),
|
||||||
|
node_id="guide.foundation",
|
||||||
|
expected_content_hash=next(
|
||||||
|
node.content_hash
|
||||||
|
for node in project.load().nodes
|
||||||
|
if node.node_id == "guide.foundation"
|
||||||
|
),
|
||||||
|
metadata={"summary": "First paged update."},
|
||||||
|
content=None,
|
||||||
|
relationship_changes=[],
|
||||||
|
rationale="First page.",
|
||||||
|
)
|
||||||
|
store.propose_update(
|
||||||
|
changeset_id="paged-operations",
|
||||||
|
expected_changeset_hash=str(first["changeset_hash"]),
|
||||||
|
node_id="proof.validation",
|
||||||
|
expected_content_hash=next(
|
||||||
|
node.content_hash
|
||||||
|
for node in project.load().nodes
|
||||||
|
if node.node_id == "proof.validation"
|
||||||
|
),
|
||||||
|
metadata={"summary": "Second paged update."},
|
||||||
|
content=None,
|
||||||
|
relationship_changes=[],
|
||||||
|
rationale="Second page.",
|
||||||
|
)
|
||||||
|
|
||||||
|
full = store.inspect("paged-operations")
|
||||||
|
self.assertNotIn("pagination", full)
|
||||||
|
first_page = store.inspect("paged-operations", limit=1)
|
||||||
|
second_page = store.inspect(
|
||||||
|
"paged-operations",
|
||||||
|
limit=2,
|
||||||
|
cursor=str(first_page["pagination"]["next_cursor"]),
|
||||||
|
)
|
||||||
|
combined = [*first_page["operations"], *second_page["operations"]]
|
||||||
|
self.assertEqual(full["operations"], combined)
|
||||||
|
self.assertEqual(full["changeset_hash"], second_page["changeset_hash"])
|
||||||
|
|
||||||
|
full_diff = store.diff("paged-operations")
|
||||||
|
diff_page = store.diff("paged-operations", limit=1)
|
||||||
|
diff_next = store.diff(
|
||||||
|
"paged-operations",
|
||||||
|
limit=1,
|
||||||
|
cursor=str(diff_page["pagination"]["next_cursor"]),
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
full_diff["changes"],
|
||||||
|
[*diff_page["changes"], *diff_next["changes"]],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_changeset_list_cursor_stales_when_collection_changes(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
root = self.copy_fixture(Path(directory))
|
||||||
|
store = ChangesetStore(Project.open(root), "alpha-editor")
|
||||||
|
store.create("page-a")
|
||||||
|
store.create("page-b")
|
||||||
|
first = store.list_changesets(limit=1)
|
||||||
|
cursor = str(first["pagination"]["next_cursor"])
|
||||||
|
second = store.list_changesets(limit=2, cursor=cursor)
|
||||||
|
self.assertEqual(
|
||||||
|
["page-a", "page-b"],
|
||||||
|
[
|
||||||
|
*(
|
||||||
|
item["changeset_id"]
|
||||||
|
for item in first["changesets"]
|
||||||
|
if isinstance(item, dict)
|
||||||
|
),
|
||||||
|
*(
|
||||||
|
item["changeset_id"]
|
||||||
|
for item in second["changesets"]
|
||||||
|
if isinstance(item, dict)
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
store.create("page-c")
|
||||||
|
|
||||||
|
with self.assertRaises(DocForgeError) as stale:
|
||||||
|
store.list_changesets(limit=1, cursor=cursor)
|
||||||
|
self.assertEqual("stale_cursor", stale.exception.code)
|
||||||
|
|
||||||
|
def test_oversized_diff_is_reconstructable_from_hash_bound_chunks(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
root = self.copy_fixture(Path(directory), max_tool_chars=20_000)
|
||||||
|
project = Project.open(root)
|
||||||
|
store = ChangesetStore(project, "alpha-editor")
|
||||||
|
created = store.create("chunked-diff")
|
||||||
|
store.propose_update(
|
||||||
|
changeset_id="chunked-diff",
|
||||||
|
expected_changeset_hash=str(created["changeset_hash"]),
|
||||||
|
node_id="guide.foundation",
|
||||||
|
expected_content_hash=next(
|
||||||
|
node.content_hash
|
||||||
|
for node in project.load().nodes
|
||||||
|
if node.node_id == "guide.foundation"
|
||||||
|
),
|
||||||
|
metadata=None,
|
||||||
|
content="replacement " * 4_000,
|
||||||
|
relationship_changes=[],
|
||||||
|
rationale="Exercise deterministic chunk transport.",
|
||||||
|
)
|
||||||
|
full = store.diff("chunked-diff")
|
||||||
|
cursor: str | None = None
|
||||||
|
chunks: list[str] = []
|
||||||
|
while True:
|
||||||
|
page = store.diff("chunked-diff", limit=20, cursor=cursor)
|
||||||
|
self.assertEqual("canonical_json_chunk", page["result_mode"])
|
||||||
|
chunks.append(page["chunk"]["content"])
|
||||||
|
cursor = page["pagination"]["next_cursor"]
|
||||||
|
if cursor is None:
|
||||||
|
break
|
||||||
|
reconstructed = json.loads("".join(chunks))
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
{
|
||||||
|
"changes": full["changes"],
|
||||||
|
"operations": full["operations"],
|
||||||
|
},
|
||||||
|
reconstructed,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
|
|
@ -21,7 +21,6 @@ from milestone0_baseline import (
|
||||||
write_synthetic_project,
|
write_synthetic_project,
|
||||||
)
|
)
|
||||||
|
|
||||||
from docforge.context import compile_context
|
|
||||||
from docforge.index import ProjectIndex
|
from docforge.index import ProjectIndex
|
||||||
from docforge.mcp_server import DocForgeService
|
from docforge.mcp_server import DocForgeService
|
||||||
from docforge.project import Project
|
from docforge.project import Project
|
||||||
|
|
@ -80,27 +79,145 @@ def _diagnostics(result: object) -> Mapping[str, object]:
|
||||||
return diagnostics
|
return diagnostics
|
||||||
|
|
||||||
|
|
||||||
|
def _result_summary(result: Mapping[str, object]) -> dict[str, object]:
|
||||||
|
"""Retain bounded semantic evidence without copying primary result payloads."""
|
||||||
|
|
||||||
|
summary: dict[str, object] = {}
|
||||||
|
for key in (
|
||||||
|
"status",
|
||||||
|
"count",
|
||||||
|
"limit",
|
||||||
|
"truncated",
|
||||||
|
"truncation_reason",
|
||||||
|
"candidate_edges_consumed",
|
||||||
|
"candidate_edges_limit",
|
||||||
|
"budget",
|
||||||
|
"estimated_tokens",
|
||||||
|
"state",
|
||||||
|
"verification",
|
||||||
|
"configured",
|
||||||
|
"snapshot_state",
|
||||||
|
"staleness",
|
||||||
|
):
|
||||||
|
if key in result:
|
||||||
|
summary[key] = result[key]
|
||||||
|
error = result.get("error")
|
||||||
|
if isinstance(error, Mapping):
|
||||||
|
error_payload = cast(Mapping[str, object], error)
|
||||||
|
if isinstance(error_payload.get("code"), str):
|
||||||
|
summary["error_code"] = error_payload["code"]
|
||||||
|
synchronization = result.get("synchronization")
|
||||||
|
if isinstance(synchronization, Mapping):
|
||||||
|
synchronization_payload = cast(Mapping[str, object], synchronization)
|
||||||
|
if isinstance(synchronization_payload.get("action"), str):
|
||||||
|
summary["synchronization_action"] = synchronization_payload["action"]
|
||||||
|
freshness = result.get("freshness")
|
||||||
|
if isinstance(freshness, Mapping):
|
||||||
|
freshness_payload = cast(Mapping[str, object], freshness)
|
||||||
|
summary["freshness"] = {
|
||||||
|
key: freshness_payload[key]
|
||||||
|
for key in ("index", "source")
|
||||||
|
if isinstance(freshness_payload.get(key), str)
|
||||||
|
}
|
||||||
|
outputs = result.get("outputs")
|
||||||
|
if isinstance(outputs, list):
|
||||||
|
summarized_outputs: list[dict[str, object]] = []
|
||||||
|
for item in cast(list[object], outputs)[:10]:
|
||||||
|
if not isinstance(item, Mapping):
|
||||||
|
continue
|
||||||
|
item_payload = cast(Mapping[str, object], item)
|
||||||
|
summarized_outputs.append(
|
||||||
|
{
|
||||||
|
key: item_payload[key]
|
||||||
|
for key in ("view_id", "state", "reason")
|
||||||
|
if key in item_payload
|
||||||
|
}
|
||||||
|
)
|
||||||
|
summary["outputs"] = summarized_outputs
|
||||||
|
entries = result.get("entries")
|
||||||
|
if isinstance(entries, list):
|
||||||
|
summary["entry_count"] = len(cast(list[object], entries))
|
||||||
|
omissions = result.get("omissions")
|
||||||
|
if isinstance(omissions, list):
|
||||||
|
summary["omission_count"] = len(cast(list[object], omissions))
|
||||||
|
pagination = result.get("pagination")
|
||||||
|
if isinstance(pagination, Mapping):
|
||||||
|
pagination_payload = cast(Mapping[str, object], pagination)
|
||||||
|
summary["pagination"] = {
|
||||||
|
key: pagination_payload[key]
|
||||||
|
for key in ("kind", "returned_count", "limit", "total_count", "has_more")
|
||||||
|
if key in pagination_payload
|
||||||
|
}
|
||||||
|
return summary
|
||||||
|
|
||||||
|
|
||||||
def _operation(
|
def _operation(
|
||||||
operation: Callable[[], dict[str, object]],
|
operation: Callable[[], dict[str, object]],
|
||||||
*,
|
*,
|
||||||
samples: int,
|
samples: int,
|
||||||
p95_limit_ms: float,
|
p95_limit_ms: float,
|
||||||
expected_status: str = "ok",
|
expected_status: str = "ok",
|
||||||
|
expected_counters: Mapping[str, int],
|
||||||
) -> dict[str, object]:
|
) -> dict[str, object]:
|
||||||
measurement, last = measure_operation(operation, samples=samples)
|
diagnostics_records: list[Mapping[str, object]] = []
|
||||||
|
result_summaries: list[dict[str, object]] = []
|
||||||
|
|
||||||
|
def validated_operation() -> dict[str, object]:
|
||||||
|
result = operation()
|
||||||
|
diagnostics = _diagnostics(result)
|
||||||
|
counters_value = diagnostics["counters"]
|
||||||
|
if not isinstance(counters_value, Mapping):
|
||||||
|
raise RuntimeError("Measured diagnostics did not return counters")
|
||||||
|
counters = cast(Mapping[str, object], counters_value)
|
||||||
|
for counter, expected in expected_counters.items():
|
||||||
|
if counters.get(counter) != expected:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Warm operation expected {counter}={expected}, "
|
||||||
|
f"received {counters.get(counter)!r}"
|
||||||
|
)
|
||||||
|
diagnostics_records.append(diagnostics)
|
||||||
|
result_summaries.append(_result_summary(result))
|
||||||
|
return result
|
||||||
|
|
||||||
|
measurement, last = measure_operation(validated_operation, samples=samples)
|
||||||
if not isinstance(last, Mapping):
|
if not isinstance(last, Mapping):
|
||||||
raise RuntimeError(f"Measured operation did not return status={expected_status}")
|
raise RuntimeError(f"Measured operation did not return status={expected_status}")
|
||||||
last_payload = cast(Mapping[str, object], last)
|
last_payload = cast(Mapping[str, object], last)
|
||||||
if last_payload.get("status") != expected_status:
|
if last_payload.get("status") != expected_status:
|
||||||
raise RuntimeError(f"Measured operation did not return status={expected_status}")
|
raise RuntimeError(f"Measured operation did not return status={expected_status}")
|
||||||
diagnostics = _diagnostics(last_payload)
|
for summary in result_summaries:
|
||||||
|
if summary.get("status") != expected_status:
|
||||||
|
raise RuntimeError(f"Measured operation did not return status={expected_status}")
|
||||||
p95_ms = float(cast(float, measurement["p95_ms"]))
|
p95_ms = float(cast(float, measurement["p95_ms"]))
|
||||||
if p95_ms > p95_limit_ms:
|
if p95_ms > p95_limit_ms:
|
||||||
raise RuntimeError(f"Warm operation p95 {p95_ms:.3f} ms exceeds {p95_limit_ms:.3f} ms")
|
raise RuntimeError(f"Warm operation p95 {p95_ms:.3f} ms exceeds {p95_limit_ms:.3f} ms")
|
||||||
|
counter_names = sorted(
|
||||||
|
{
|
||||||
|
key
|
||||||
|
for diagnostics in diagnostics_records
|
||||||
|
for key in cast(Mapping[str, object], diagnostics["counters"])
|
||||||
|
}
|
||||||
|
)
|
||||||
|
counter_ranges = {
|
||||||
|
counter: {
|
||||||
|
"minimum": min(
|
||||||
|
cast(int, cast(Mapping[str, object], record["counters"])[counter])
|
||||||
|
for record in diagnostics_records
|
||||||
|
),
|
||||||
|
"maximum": max(
|
||||||
|
cast(int, cast(Mapping[str, object], record["counters"])[counter])
|
||||||
|
for record in diagnostics_records
|
||||||
|
),
|
||||||
|
}
|
||||||
|
for counter in counter_names
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
**measurement,
|
**measurement,
|
||||||
"p95_limit_ms": p95_limit_ms,
|
"p95_limit_ms": p95_limit_ms,
|
||||||
"diagnostics": diagnostics,
|
"validated_invocations": len(diagnostics_records),
|
||||||
|
"counter_expectations": dict(sorted(expected_counters.items())),
|
||||||
|
"counter_ranges": counter_ranges,
|
||||||
|
"result_summary": result_summaries[-1],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -110,11 +227,33 @@ def _benchmark(root: Path, node_count: int, samples: int) -> dict[str, object]:
|
||||||
RenderService(project).render("manual")
|
RenderService(project).render("manual")
|
||||||
service = DocForgeService(project, diagnostics=True)
|
service = DocForgeService(project, diagnostics=True)
|
||||||
target = synthetic_node_id(node_count - 1)
|
target = synthetic_node_id(node_count - 1)
|
||||||
|
backlink_target = synthetic_node_id(node_count - 2)
|
||||||
|
first = synthetic_node_id(0)
|
||||||
|
read_counters = {
|
||||||
|
"index_checks": 1,
|
||||||
|
"index_synchronizations": 0,
|
||||||
|
"viewer_manager_requests": 0,
|
||||||
|
}
|
||||||
|
status_counters = {
|
||||||
|
"index_checks": 0,
|
||||||
|
"index_synchronizations": 0,
|
||||||
|
"viewer_manager_requests": 0,
|
||||||
|
}
|
||||||
|
visualization_counters = {
|
||||||
|
"index_checks": 0,
|
||||||
|
"index_synchronizations": 0,
|
||||||
|
"viewer_manager_requests": 1,
|
||||||
|
}
|
||||||
operations = {
|
operations = {
|
||||||
"warm_no_change_synchronize": _operation(
|
"warm_no_change_synchronize": _operation(
|
||||||
service.synchronize,
|
service.synchronize,
|
||||||
samples=samples,
|
samples=samples,
|
||||||
p95_limit_ms=100,
|
p95_limit_ms=100,
|
||||||
|
expected_counters={
|
||||||
|
"index_checks": 1,
|
||||||
|
"index_synchronizations": 1,
|
||||||
|
"viewer_manager_requests": 0,
|
||||||
|
},
|
||||||
),
|
),
|
||||||
"exact_node": _operation(
|
"exact_node": _operation(
|
||||||
lambda: service.invoke(
|
lambda: service.invoke(
|
||||||
|
|
@ -123,6 +262,7 @@ def _benchmark(root: Path, node_count: int, samples: int) -> dict[str, object]:
|
||||||
),
|
),
|
||||||
samples=samples,
|
samples=samples,
|
||||||
p95_limit_ms=50,
|
p95_limit_ms=50,
|
||||||
|
expected_counters=read_counters,
|
||||||
),
|
),
|
||||||
"missing_node_error": _operation(
|
"missing_node_error": _operation(
|
||||||
lambda: service.invoke(
|
lambda: service.invoke(
|
||||||
|
|
@ -132,6 +272,7 @@ def _benchmark(root: Path, node_count: int, samples: int) -> dict[str, object]:
|
||||||
samples=samples,
|
samples=samples,
|
||||||
p95_limit_ms=50,
|
p95_limit_ms=50,
|
||||||
expected_status="error",
|
expected_status="error",
|
||||||
|
expected_counters=read_counters,
|
||||||
),
|
),
|
||||||
"search_limit_20": _operation(
|
"search_limit_20": _operation(
|
||||||
lambda: service.invoke(
|
lambda: service.invoke(
|
||||||
|
|
@ -140,6 +281,25 @@ def _benchmark(root: Path, node_count: int, samples: int) -> dict[str, object]:
|
||||||
),
|
),
|
||||||
samples=samples,
|
samples=samples,
|
||||||
p95_limit_ms=100,
|
p95_limit_ms=100,
|
||||||
|
expected_counters=read_counters,
|
||||||
|
),
|
||||||
|
"filter_limit_20": _operation(
|
||||||
|
lambda: service.invoke(
|
||||||
|
lambda: service.index.filter_nodes(family="guide", limit=20),
|
||||||
|
operation_name="mcp.filter",
|
||||||
|
),
|
||||||
|
samples=samples,
|
||||||
|
p95_limit_ms=100,
|
||||||
|
expected_counters=read_counters,
|
||||||
|
),
|
||||||
|
"backlinks_limit_20": _operation(
|
||||||
|
lambda: service.invoke(
|
||||||
|
lambda: service.index.backlinks(backlink_target, limit=20),
|
||||||
|
operation_name="mcp.backlinks",
|
||||||
|
),
|
||||||
|
samples=samples,
|
||||||
|
p95_limit_ms=100,
|
||||||
|
expected_counters=read_counters,
|
||||||
),
|
),
|
||||||
"dependencies_depth_8": _operation(
|
"dependencies_depth_8": _operation(
|
||||||
lambda: service.invoke(
|
lambda: service.invoke(
|
||||||
|
|
@ -148,21 +308,58 @@ def _benchmark(root: Path, node_count: int, samples: int) -> dict[str, object]:
|
||||||
),
|
),
|
||||||
samples=samples,
|
samples=samples,
|
||||||
p95_limit_ms=100,
|
p95_limit_ms=100,
|
||||||
|
expected_counters=read_counters,
|
||||||
),
|
),
|
||||||
"context_32k": _operation(
|
"impact_depth_8": _operation(
|
||||||
lambda: service.invoke(
|
lambda: service.invoke(
|
||||||
lambda: compile_context(service.index, "active", 32_000),
|
lambda: service.index.impact(first, depth=8, limit=100),
|
||||||
operation_name="mcp.context",
|
operation_name="mcp.impact",
|
||||||
),
|
),
|
||||||
samples=samples,
|
samples=samples,
|
||||||
|
p95_limit_ms=100,
|
||||||
|
expected_counters=read_counters,
|
||||||
|
),
|
||||||
|
"context_32k": _operation(
|
||||||
|
lambda: service.context("active", 32_000, limit=20),
|
||||||
|
samples=samples,
|
||||||
p95_limit_ms=250,
|
p95_limit_ms=250,
|
||||||
|
expected_counters=read_counters,
|
||||||
),
|
),
|
||||||
"render_receipt_status": _operation(
|
"render_receipt_status": _operation(
|
||||||
lambda: service.render_status("manual"),
|
lambda: service.render_status("manual"),
|
||||||
samples=samples,
|
samples=samples,
|
||||||
p95_limit_ms=50,
|
p95_limit_ms=50,
|
||||||
|
expected_counters=status_counters,
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
template = root / "docs" / "templates" / "manual.html"
|
||||||
|
original_template = template.read_bytes()
|
||||||
|
template.write_bytes(original_template + b"\n")
|
||||||
|
operations["render_stale_status"] = _operation(
|
||||||
|
lambda: service.render_status("manual"),
|
||||||
|
samples=samples,
|
||||||
|
p95_limit_ms=50,
|
||||||
|
expected_counters=status_counters,
|
||||||
|
)
|
||||||
|
template.write_bytes(original_template)
|
||||||
|
RenderService(project).render("manual")
|
||||||
|
receipt_path = root / ".docforge" / "cache" / "render-receipts" / "manual.json"
|
||||||
|
receipt_path.unlink()
|
||||||
|
operations["render_missing_receipt_status"] = _operation(
|
||||||
|
lambda: service.render_status("manual"),
|
||||||
|
samples=samples,
|
||||||
|
p95_limit_ms=50,
|
||||||
|
expected_counters=status_counters,
|
||||||
|
)
|
||||||
|
RenderService(project).render("manual")
|
||||||
|
receipt_path.write_text("{", encoding="utf-8")
|
||||||
|
operations["render_corrupt_receipt_status"] = _operation(
|
||||||
|
lambda: service.render_status("manual"),
|
||||||
|
samples=samples,
|
||||||
|
p95_limit_ms=50,
|
||||||
|
expected_counters=status_counters,
|
||||||
|
)
|
||||||
|
RenderService(project).render("manual")
|
||||||
state_path = root / ".docforge" / "benchmark-viewer-manager.json"
|
state_path = root / ".docforge" / "benchmark-viewer-manager.json"
|
||||||
manager = ViewerManager(state_path, check_interval_seconds=0.02)
|
manager = ViewerManager(state_path, check_interval_seconds=0.02)
|
||||||
manager_thread = threading.Thread(target=manager.serve_forever, daemon=True)
|
manager_thread = threading.Thread(target=manager.serve_forever, daemon=True)
|
||||||
|
|
@ -181,6 +378,7 @@ def _benchmark(root: Path, node_count: int, samples: int) -> dict[str, object]:
|
||||||
service.visualization_status,
|
service.visualization_status,
|
||||||
samples=samples,
|
samples=samples,
|
||||||
p95_limit_ms=50,
|
p95_limit_ms=50,
|
||||||
|
expected_counters=visualization_counters,
|
||||||
)
|
)
|
||||||
with service.index.path.open("ab") as stream:
|
with service.index.path.open("ab") as stream:
|
||||||
stream.write(b"\n")
|
stream.write(b"\n")
|
||||||
|
|
@ -188,12 +386,14 @@ def _benchmark(root: Path, node_count: int, samples: int) -> dict[str, object]:
|
||||||
service.visualization_status,
|
service.visualization_status,
|
||||||
samples=samples,
|
samples=samples,
|
||||||
p95_limit_ms=50,
|
p95_limit_ms=50,
|
||||||
|
expected_counters=visualization_counters,
|
||||||
)
|
)
|
||||||
service.stop_visualization()
|
service.stop_visualization()
|
||||||
operations["visualization_not_running_status"] = _operation(
|
operations["visualization_not_running_status"] = _operation(
|
||||||
service.visualization_status,
|
service.visualization_status,
|
||||||
samples=samples,
|
samples=samples,
|
||||||
p95_limit_ms=50,
|
p95_limit_ms=50,
|
||||||
|
expected_counters=visualization_counters,
|
||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
manager.shutdown()
|
manager.shutdown()
|
||||||
|
|
@ -204,6 +404,7 @@ def _benchmark(root: Path, node_count: int, samples: int) -> dict[str, object]:
|
||||||
samples=samples,
|
samples=samples,
|
||||||
p95_limit_ms=50,
|
p95_limit_ms=50,
|
||||||
expected_status="error",
|
expected_status="error",
|
||||||
|
expected_counters=visualization_counters,
|
||||||
)
|
)
|
||||||
return {
|
return {
|
||||||
"fixture": {
|
"fixture": {
|
||||||
|
|
@ -243,8 +444,13 @@ def main() -> int:
|
||||||
"method": {
|
"method": {
|
||||||
"clock": "time.perf_counter_ns",
|
"clock": "time.perf_counter_ns",
|
||||||
"memory": "resource.getrusage(RUSAGE_SELF).ru_maxrss",
|
"memory": "resource.getrusage(RUSAGE_SELF).ru_maxrss",
|
||||||
|
"memory_scope": (
|
||||||
|
"cumulative main-process high-water mark; detached viewer-worker memory excluded"
|
||||||
|
),
|
||||||
"response_size": "UTF-8 bytes of compact sorted JSON",
|
"response_size": "UTF-8 bytes of compact sorted JSON",
|
||||||
"samples": arguments.samples,
|
"samples": arguments.samples,
|
||||||
|
"warmups": 1,
|
||||||
|
"percentile": "nearest-rank",
|
||||||
"zero_work_counters": list(ZERO_WORK_COUNTERS),
|
"zero_work_counters": list(ZERO_WORK_COUNTERS),
|
||||||
},
|
},
|
||||||
**measurement,
|
**measurement,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue