feat(plugins): consolidate official plugins with independent releases (LANG-180) (#2797)

Co-authored-by: multica-agent <[email protected]>
This commit is contained in:
RockChinQ
2026-10-28 17:32:24 +08:00
committed by GitHub
co-authored by multica-agent
parent d099033d79
commit 3a77306c3c
1144 changed files with 142049 additions and 130 deletions
@@ -0,0 +1,125 @@
{
"plugin": "LangRAG",
"sdk": "0.7.4",
"worker_pid": 764391,
"same_worker_two_workspace_slots": true,
"slot_configs_distinct": true,
"successful_representative_invocation": true,
"host_actions": [
[
"A",
"embed"
],
[
"A",
"upsert"
],
[
"A",
"keys"
],
[
"A",
"set"
],
[
"A",
"keys"
],
[
"A",
"get"
],
[
"A",
"set"
],
[
"B",
"embed"
],
[
"B",
"upsert"
],
[
"B",
"keys"
],
[
"B",
"set"
],
[
"B",
"keys"
],
[
"B",
"get"
],
[
"B",
"set"
],
[
"A",
"embed"
],
[
"A",
"search"
],
[
"A",
"keys"
],
[
"A",
"get"
],
[
"A",
"set"
],
[
"B",
"embed"
],
[
"B",
"search"
],
[
"B",
"keys"
],
[
"B",
"get"
],
[
"B",
"set"
],
[
"A",
"keys"
],
[
"A",
"get"
],
[
"B",
"keys"
],
[
"B",
"get"
]
],
"scope": "real SDK child RPC, local Host fixture; no production/upstream/sandbox proof",
"object_identity_assertion": "asserted inside child; parent checks child stderr before success",
"manifest_override_for_probe": false
}
@@ -0,0 +1,29 @@
{
"plugin": "LongTermMemory",
"sdk": "0.7.4",
"worker_pid": 764459,
"same_worker_two_workspace_slots": true,
"slot_configs_distinct": true,
"successful_representative_invocation": true,
"host_actions": [
[
"A",
"get"
],
[
"A",
"keys"
],
[
"B",
"get"
],
[
"B",
"keys"
]
],
"scope": "real SDK child RPC, local Host fixture; no production/upstream/sandbox proof",
"object_identity_assertion": "asserted inside child; parent checks child stderr before success",
"manifest_override_for_probe": true
}
@@ -0,0 +1,353 @@
"""Exact artifact, real b5 discovery/controller/proxies and WebSocket RPC.
Two native InstallationBinding connections; fake Host storage/vector/model and
fake local HTTP provider. No Cloud, real provider, nsjail or certificate claims.
"""
import asyncio
import base64
import hashlib
import importlib.metadata
import json
import logging
import os
from pathlib import Path
import sys
from contextlib import asynccontextmanager
import websockets
from aiohttp import web
from langbot_plugin.utils.discover.engine import ComponentDiscoveryEngine
from langbot_plugin.cli.utils.page_components import discover_plugin_components
from langbot_plugin.cli.run.controller import PluginRuntimeController
from langbot_plugin.cli.run.handler import PluginRuntimeHandler
from langbot_plugin.runtime.io.handler import Handler, ActionResponse
from langbot_plugin.runtime.io.connections.ws import WebSocketConnection
from langbot_plugin.entities.io.actions.enums import RuntimeToPluginAction as R, PluginToRuntimeAction as P
from langbot_plugin.entities.io.context import InstallationBinding
from langbot_plugin.entities.io.errors import ActionCallError
CODE, OUT, NAME, DIGEST = Path(sys.argv[1]), Path(sys.argv[2]), sys.argv[3], sys.argv[4]
os.chdir(CODE)
sys.path.insert(0, str(CODE))
def _metadata_matches(metadata, filters):
"""Evaluate the Host's metadata filters (equality plus $and/$or terms)."""
if not filters:
return True
if not isinstance(filters, dict):
raise AssertionError(f'Unsupported vector filter: {filters!r}')
for key, expected in filters.items():
if key == '$and' and not all(_metadata_matches(metadata, term) for term in expected):
return False
if key == '$or' and not any(_metadata_matches(metadata, term) for term in expected):
return False
if key in ('$and', '$or'):
continue
if str(metadata.get(key, '')) != str(expected):
return False
return True
async def main():
assert importlib.metadata.version('langbot-plugin') == '0.7.4'
before = {str(p.relative_to(CODE)): hashlib.sha256(p.read_bytes()).hexdigest() for p in CODE.rglob('*') if p.is_file()}
discovery = ComponentDiscoveryEngine()
manifest = discovery.load_component_manifest('manifest.yaml', no_save=True)
components = discover_plugin_components(manifest, discovery)
kinds = sorted(c.kind for c in components)
if NAME == 'LongTermMemory':
# LongTermMemory ships a KnowledgeEngine, an EventListener, four Tools,
# a Command and a Page, so the exact kind count is not a fixed list. The
# kinds are read from the manifest inside the exact archive instead.
declared_kinds = sorted(manifest.manifest['spec']['components'])
assert sorted(set(kinds)) == declared_kinds, (kinds, declared_kinds)
else:
assert kinds == (['KnowledgeEngine', 'Page'] if NAME == 'LangRAG' else ['KnowledgeEngine'])
# Every verified candidate is a stateless shared-placement archive, so the
# manifest inside the built artifact has to declare exactly that. The
# declaration is not a certificate: this only checks it survived packaging.
assert manifest.execution.shared_runtime == 'shared-runtime-v1'
assert manifest.execution.component_model == 'stateless-v1'
stores, vectors, calls = {}, {}, []
searches = []
# Filled only by the LongTermMemory branch, so the archived report records
# the memory markers and retrieval scopes its assertions actually checked.
ltm_evidence = {}
http_calls = []
async def provider(request):
body = await request.read()
marker = request.headers['Authorization'].split()[-1]
http_calls.append((request.method, request.path, marker))
await asyncio.sleep(.002)
if request.path.endswith('/retrieve'):
return web.json_response({'records': [{'segment': {'id': 'seg', 'content': marker}, 'score': .9}]})
if request.path.endswith('/searchTest'):
return web.json_response({'data': [{'id': 'seg', 'q': marker, 'score': .9}]})
if request.path.endswith('/retrieval'):
return web.json_response({'code': 0, 'data': {'chunks': [{'id': 'seg', 'content': marker, 'similarity': .9}]}})
if request.path.endswith('/create-by-file'):
assert b'fixture file' in body
return web.json_response({'document': {'id': 'remote-doc'}})
if request.path.endswith('/localFile'):
assert b'fixture file' in body
return web.json_response({'code': 200, 'data': {'collectionId': 'remote-doc'}})
if request.method == 'POST' and request.path.endswith('/documents'):
assert b'fixture file' in body
return web.json_response({'code': 0, 'data': [{'id': 'remote-doc'}]})
if request.method == 'GET':
return web.json_response({'code': 0, 'data': []})
return web.json_response({'code': 200 if NAME == 'FastGPTConnector' else 0})
app = web.Application()
app.router.add_route('*', '/{path:.*}', provider)
runner = web.AppRunner(app)
await runner.setup()
site = web.TCPSite(runner, '127.0.0.1', 0)
await site.start()
url = f'http://127.0.0.1:{site._server.sockets[0].getsockname()[1]}'
def binding(label):
return InstallationBinding(instance_uuid='fixture-instance', workspace_uuid='workspace-'+label,
placement_generation=1, installation_uuid='installation-'+label, runtime_revision=1, artifact_digest=DIGEST)
a, b = binding('A'), binding('B')
@asynccontextmanager
async def installation(scope):
label = scope.workspace_uuid
store = stores.setdefault(label, {})
rows = vectors.setdefault(label, {})
controller = PluginRuntimeController(manifest, components, False, '')
ready = asyncio.get_running_loop().create_future()
async def connected(ws):
os.environ['LANGBOT_PLUGIN_FILE_STORAGE_DIR'] = str(OUT / 'rpc-tmp' / NAME / label / 'worker')
handler = PluginRuntimeHandler(WebSocketConnection(ws), controller.initialize)
controller.handler = handler
handler.plugin_container = controller.plugin_container
# Stateless shared placement: the runtime drives the plugin through
# per-slot lifecycle callbacks, not one bound plugin instance.
handler._slot_initialize_callback = controller.initialize_slot
handler._slot_detach_callback = controller.detach_slot
handler._slot_cancel_callback = controller.invalidate_slot
ready.set_result(handler)
await handler.run()
async with websockets.serve(connected, '127.0.0.1', 0) as server:
async with websockets.connect(f'ws://127.0.0.1:{server.sockets[0].getsockname()[1]}') as ws:
host = Handler(WebSocketConnection(ws), file_storage_dir=OUT / 'rpc-tmp' / NAME / label / 'host')
def check(action, data):
assert host.current_action_context == scope, 'SDK proxy lost its immutable binding'
calls.append((label, action))
@host.action(P.GET_PLUGIN_STORAGE_KEYS)
async def keys(data):
check('keys', data)
return ActionResponse.success({'keys': list(store)})
@host.action(P.GET_PLUGIN_STORAGE)
async def get(data):
check('get', data)
# A key that was never written reads back as an empty object,
# matching the Host storage fixture the shared-placement probe
# already drives LongTermMemory with.
if data['key'] not in store:
store[data['key']] = base64.b64encode(b'{}').decode()
return ActionResponse.success({'value_base64': store[data['key']]})
@host.action(P.SET_PLUGIN_STORAGE)
async def set_value(data):
check('set', data)
await asyncio.sleep(.002)
store[data['key']] = data['value_base64']
return ActionResponse.success({})
@host.action(P.INVOKE_EMBEDDING)
async def embed(data):
check('embedding', data)
return ActionResponse.success({'vectors': [[1.0] for _ in data['texts']]})
@host.action(P.VECTOR_UPSERT)
async def upsert(data):
check('upsert', data)
for id_, meta in zip(data['ids'], data['metadata']):
rows[id_] = {'id': id_, 'distance': .1, 'metadata': meta}
return ActionResponse.success({})
@host.action(P.VECTOR_SEARCH)
async def search(data):
check('search', data)
searches.append((label, data.get('filters')))
return ActionResponse.success({'results': list(rows.values())[:data['top_k']]})
@host.action(P.VECTOR_LIST)
async def list_vectors(data):
check('vector_list', data)
hits = [row for row in rows.values() if _metadata_matches(row['metadata'], data.get('filters'))]
offset, limit = data.get('offset', 0), data.get('limit', 20)
return ActionResponse.success({'items': hits[offset:offset + limit], 'total': len(hits)})
@host.action(P.VECTOR_DELETE)
async def delete(data):
check('delete', data)
n = len(rows)
rows.clear()
return ActionResponse.success({'count': n})
@host.action(P.GET_KNOWLEDEGE_FILE_STREAM)
async def file_stream(data):
check('file', data)
key = await host.send_file(b'fixture file', 'txt', action_context=scope)
return ActionResponse.success({'file_key': key})
task = asyncio.create_task(host.run())
handler = await ready
async def call(action, data, context=scope):
return await host.call_action(action, data, action_context=context, timeout=8)
try:
settings = {'plugin_settings': {'enabled': True, 'priority': 0, 'plugin_config': {'marker': label}}}
await call(R.ATTACH_PLUGIN_SLOT, settings)
slot = await call(R.GET_PLUGIN_SLOT_CONTAINER, {})
assert slot['plugin_config'] == {'marker': label}
assert controller.plugin_container_for_slot(scope.installation_uuid).binding == scope
yield call, controller
finally:
await host.close()
await handler.close()
task.cancel()
await asyncio.gather(task, return_exceptions=True)
await controller.cleanup_instances()
def config(marker):
return {'api_base_url': url, 'api_key': marker, 'dify_apikey': marker,
'dataset_id': marker, 'dataset_ids': marker, 'embedding_model_uuid': 'fixture-embedding'}
async def ingest(call, marker):
ctx = {'knowledge_base_id': 'same-kb', 'creation_settings': config(marker),
'file_object': {'metadata': {'filename': 'doc.txt', 'document_id': 'same-doc',
'knowledge_base_id': 'same-kb', 'file_size': 12, 'mime_type': 'text/plain'}, 'storage_path': 'fixture-file'}}
if NAME == 'LangRAG':
ctx['parsed_content'] = {'text': 'private-' + marker}
result = await call(R.INGEST_DOCUMENT, {'context': ctx})
assert result['status'] == ('completed' if NAME == 'LangRAG' else 'processing'), result
async def retrieve(call, marker):
result = await call(R.RETRIEVE_KNOWLEDGE, {'retriever_name': '', 'retrieval_context': {
'query': 'fixture query', 'knowledge_base_id': 'same-kb', 'creation_settings': config(marker), 'retrieval_settings': {'top_k': 2}}})
assert result['total_found'] == 1, result
assert marker in result['results'][0]['content'][0]['text'], result
return result
ltm_kb, ltm_doc = 'ltm-kb', 'ltm-doc'
ltm_creation = {'embedding_model_uuid': 'fixture-embedding', 'isolation': 'session'}
page_id = next((c.metadata.name for c in components if c.kind == 'Page'), None)
async def page(call, endpoint, method='GET', body=None):
return await call(R.PAGE_API, {'page_id': page_id, 'endpoint': endpoint, 'method': method,
'body': {} if body is None else body})
def ltm_session(marker):
return 'person_fixture-' + marker
async def ltm_user_key(call, marker):
resp = await page(call, '/derive-scope', 'POST', {'bot_uuid': 'fixture-bot',
'session_name': ltm_session(marker), 'isolation': 'session'})
assert not resp.get('error'), resp
return resp['data']['user_key']
async def ltm_ingest(call, marker, user_key):
payload = json.dumps([{'content': 'ltm-' + marker + ' remembered fact', 'tags': ['fixture'],
'importance': 3, 'user_key': user_key}])
ctx = {'knowledge_base_id': ltm_kb, 'creation_settings': ltm_creation,
'parsed_content': {'text': payload},
'file_object': {'metadata': {'filename': 'memories.json', 'document_id': ltm_doc,
'knowledge_base_id': ltm_kb, 'file_size': len(payload), 'mime_type': 'application/json'},
'storage_path': 'fixture-file'}}
result = await call(R.INGEST_DOCUMENT, {'context': ctx})
assert result['status'] == 'completed', result
assert result['chunks_created'] == 1, result
async def ltm_episodes(call, user_key):
resp = await page(call, '/episodes/list', 'POST', {'collection_id': ltm_kb, 'user_key': user_key})
assert not resp.get('error'), resp
return resp['data']
async def ltm_summary(call):
resp = await page(call, '/summary')
assert not resp.get('error'), resp
return resp['data']
async def ltm_retrieve(call, marker):
result = await call(R.RETRIEVE_KNOWLEDGE, {'retriever_name': '', 'retrieval_context': {
'query': 'remembered fact', 'knowledge_base_id': ltm_kb, 'creation_settings': ltm_creation,
'retrieval_settings': {'top_k': 2, 'session_name': ltm_session(marker), 'bot_uuid': 'fixture-bot'}}})
assert result['total_found'] == 1, result
text = result['results'][0]['content'][0]['text']
assert '<mem>ltm-' + marker + ' remembered fact</mem>' in text, text
try:
async with installation(a) as (ca, cta), installation(b) as (cb, ctb):
if NAME == 'LongTermMemory':
capabilities = await ca(R.GET_RAG_CAPABILITIES, {})
assert capabilities['capabilities'] == ['doc_ingestion'], capabilities
for call in (ca, cb):
created = await call(R.ON_KB_CREATE, {'kb_id': ltm_kb, 'config': dict(ltm_creation)})
assert created['success'], created
user_keys = {}
for call, marker in ((ca, 'A'), (cb, 'B')):
user_keys[marker] = await ltm_user_key(call, marker)
await ltm_ingest(call, marker, user_keys[marker])
for call, marker, other in ((ca, 'A', 'B'), (cb, 'B', 'A')):
mine = await ltm_episodes(call, user_keys[marker])
assert mine['total'] == 1 and 'ltm-' + marker in mine['episodes'][0]['content'], mine
assert (await ltm_episodes(call, user_keys[other]))['total'] == 0, other
summary = await ltm_summary(call)
assert summary['kb_count'] == 1, summary
assert summary['kb_entries'][0]['id'] == ltm_kb, summary
assert summary['profile_key_count'] == 0, summary
await asyncio.gather(*(ltm_retrieve(ca if i % 2 == 0 else cb, 'A' if i % 2 == 0 else 'B') for i in range(12)))
# Writes and reads went to this installation's own Host storage:
# each binding holds exactly its own episode, and the retrieval
# scope filter was derived from that binding's session.
assert [row['metadata']['user_key'] for row in vectors['workspace-A'].values()] == [user_keys['A']]
assert [row['metadata']['user_key'] for row in vectors['workspace-B'].values()] == [user_keys['B']]
for label, marker in (('workspace-A', 'A'), ('workspace-B', 'B')):
scopes = [f for l, f in searches if l == label]
assert scopes and all(f == {'user_key': user_keys[marker]} for f in scopes), scopes
ltm_evidence['memory_markers'] = sorted('ltm-' + marker for marker in user_keys)
ltm_evidence['retrieval_scope_filters'] = {
label: sorted({json.dumps(f) for l, f in searches if l == label})
for label in ('workspace-A', 'workspace-B')}
else:
await asyncio.gather(ingest(ca, 'A'), ingest(cb, 'B'))
await asyncio.gather(*(retrieve(ca if i % 2 == 0 else cb, 'A' if i % 2 == 0 else 'B') for i in range(12)))
try:
await ca(R.ON_KB_DELETE, {'kb_id': 'same-kb'}, context=b)
except ActionCallError:
pass
else:
raise AssertionError('Cross-Workspace action was accepted')
assert cta.plugin_container.plugin_instance is not ctb.plugin_container.plugin_instance
if NAME == 'LangRAG':
sa, sb = await page(ca, '/snapshot'), await page(cb, '/snapshot')
assert sa['data']['counters']['ingest.total'] == sb['data']['counters']['ingest.total'] == 1
await page(ca, '/clear', 'POST')
assert (await page(ca, '/snapshot'))['data']['counters'] == {}
assert (await page(cb, '/snapshot'))['data']['counters']['ingest.total'] == 1
# Fresh real controller objects recover each installation's durable state.
async with installation(a) as (ca, _), installation(b) as (cb, _):
if NAME == 'LongTermMemory':
for call, marker in ((ca, 'A'), (cb, 'B')):
assert (await ltm_summary(call))['kb_count'] == 1, marker
assert (await ltm_episodes(call, user_keys[marker]))['total'] == 1, marker
deleted = await ca(R.DELETE_DOCUMENT, {'kb_id': ltm_kb, 'document_id': ltm_doc})
assert deleted['success'], deleted
assert (await ltm_episodes(ca, user_keys['A']))['total'] == 0
assert (await ltm_episodes(cb, user_keys['B']))['total'] == 1
assert (await ca(R.ON_KB_DELETE, {'kb_id': ltm_kb}))['success']
assert (await ltm_summary(ca))['kb_count'] == 0
assert (await ltm_summary(cb))['kb_count'] == 1
await ltm_retrieve(cb, 'B')
else:
if NAME == 'LangRAG':
assert (await page(ca, '/snapshot'))['data']['counters'] == {}
assert (await page(cb, '/snapshot'))['data']['counters']['ingest.total'] == 1
result = await ca(R.DELETE_DOCUMENT, {'kb_id': 'same-kb', 'document_id': 'same-doc'})
assert result['success']
if NAME != 'LangRAG':
assert http_calls[-1][2] == 'A'
await ca(R.ON_KB_DELETE, {'kb_id': 'same-kb'})
assert not (await ca(R.DELETE_DOCUMENT, {'kb_id': 'same-kb', 'document_id': 'same-doc'}))['success']
await retrieve(cb, 'B')
after = {str(p.relative_to(CODE)): hashlib.sha256(p.read_bytes()).hexdigest() for p in CODE.rglob('*') if p.is_file()}
assert before == after, 'Runtime wrote into shared artifact tree'
report = {'name': NAME, 'sdk': '0.7.4', 'artifact_sha256': DIGEST, 'components': kinds,
'native_installation_bindings': 2, 'concurrent_retrievals': 12,
'cross_workspace_denied': True, 'restart_state_recovered': True,
'artifact_unchanged': True, 'host_calls': len(calls), 'http_calls': len(http_calls),
'scope': 'Real SDK loopback RPC; fake Host/provider; no production or nsjail execution',
**ltm_evidence}
(OUT / (NAME + '-rpc-results.json')).write_text(json.dumps(report, indent=2))
print(json.dumps(report, indent=2))
finally:
await runner.cleanup()
if __name__ == '__main__':
logging.basicConfig(level=logging.CRITICAL)
asyncio.run(asyncio.wait_for(main(), 80))
@@ -0,0 +1,283 @@
"""SDK 0.7.4 real child, WebSocket RPC, two slots and one component graph.
LangRAG/LongTermMemory: local Host vector/storage fixture only; no Cloud/sandbox
claim. Connectors: local Host storage/file fixture plus a loopback HTTP provider
fixture; no upstream/Cloud/sandbox claim.
"""
import asyncio
import base64
import hashlib
import json
import os
from pathlib import Path
import sys
from aiohttp import web
import websockets
from langbot_plugin.utils.discover.engine import ComponentDiscoveryEngine
from langbot_plugin.cli.utils.page_components import discover_plugin_components
from langbot_plugin.cli.run.controller import PluginRuntimeController
from langbot_plugin.cli.run.handler import PluginRuntimeHandler
from langbot_plugin.runtime.io.handler import Handler, ActionResponse
from langbot_plugin.runtime.io.connections.ws import WebSocketConnection
from langbot_plugin.entities.io.actions.enums import RuntimeToPluginAction as R, PluginToRuntimeAction as P
from langbot_plugin.entities.io.context import InstallationBinding
CODE = Path(sys.argv[1]).resolve()
os.chdir(CODE)
sys.path.insert(0, str(CODE))
CONNECTORS = ('DifyDatasetsConnector', 'FastGPTConnector', 'RAGFlowConnector')
def binding(label):
return InstallationBinding(instance_uuid='fixture-instance', workspace_uuid='workspace-'+label,
placement_generation=1, installation_uuid='installation-'+label,
runtime_revision=1, artifact_digest='a'*64)
async def child(url):
discovery = ComponentDiscoveryEngine()
manifest = discovery.load_component_manifest('manifest.yaml', no_save=True)
# Probe shared eligibility without changing the dedicated release manifest.
if CODE.name == 'LongTermMemory':
manifest.execution.shared_runtime = 'shared-runtime-v1'
manifest.execution.component_model = 'stateless-v1'
components = discover_plugin_components(manifest, discovery)
controller = PluginRuntimeController(manifest, components, False, '')
original_attach = controller.initialize_slot
identities = []
async def attach(binding, settings):
slot = await original_attach(binding, settings)
identity = (id(slot.plugin_container.plugin_instance),
tuple(id(c.component_instance) for c in slot.plugin_container.components))
identities.append(identity)
if len(identities) == 2:
assert identities[0] == identities[1], 'Slots have different object graphs'
print('SAME_OBJECT_GRAPH=true', file=sys.stderr, flush=True)
return slot
async with websockets.connect(url) as ws:
handler = PluginRuntimeHandler(WebSocketConnection(ws), controller.initialize)
handler.plugin_container = controller.plugin_container
handler._slot_initialize_callback = attach
handler._slot_detach_callback = controller.detach_slot
handler._slot_cancel_callback = controller.invalidate_slot
controller.handler = handler
await handler.run()
async def parent():
stores = {label: {} for label in 'AB'}
vectors = {label: {} for label in 'AB'}
actions = []
provider_calls = []
async def provider(request):
"""Loopback stand-in for Dify/FastGPT/RAGFlow; echoes the caller's own credentials."""
body = await request.read()
auth = request.headers.get('Authorization', '')
marker = auth.removeprefix('Bearer ')
provider_calls.append((request.method, request.path_qs, auth, body))
if request.method == 'DELETE':
# FastGPT acknowledges with code 200; Dify accepts any 2xx; RAGFlow reads code 0.
return web.json_response({'code': 200 if request.path.endswith('/collection/delete') else 0})
if request.path.endswith('/retrieve'):
return web.json_response({'records': [{'segment': {'id': 'seg', 'content': auth}, 'score': .8}]})
if request.path.endswith('/searchTest'):
return web.json_response({'code': 200, 'data': [{'id': 'seg', 'q': auth, 'a': 'answer', 'score': .8}]})
if request.path.endswith('/retrieval'):
return web.json_response({'code': 0, 'data': {'chunks': [{'id': 'seg', 'content': auth, 'similarity': .8}]}})
if request.path.endswith('/create-by-file'):
return web.json_response({'document': {'id': 'remote-doc-' + marker}})
if request.path.endswith('/localFile'):
return web.json_response({'code': 200, 'data': {'collectionId': 'remote-doc-' + marker,
'results': {'insertLen': 2}}})
if request.path.endswith(('/chunks', '/run_graphrag', '/run_raptor')):
return web.json_response({'code': 0, 'data': {}})
if request.method == 'POST' and request.path.endswith('/documents'):
return web.json_response({'code': 0, 'data': [{'id': 'remote-doc-' + marker}]})
# Dataset validation/listing.
return web.json_response({'code': 0, 'data': []})
# The loopback provider fixture is only needed by the connector branch.
provider_runner = None
base_url = ''
if CODE.name in CONNECTORS:
app = web.Application()
app.router.add_route('*', '/{path:.*}', provider)
provider_runner = web.AppRunner(app)
await provider_runner.setup()
site = web.TCPSite(provider_runner, '127.0.0.1', 0)
await site.start()
base_url = f"http://127.0.0.1:{site._server.sockets[0].getsockname()[1]}"
connected = asyncio.get_running_loop().create_future()
async def accept(ws):
handler = Handler(WebSocketConnection(ws), file_storage_dir=Path('/tmp/ke-shared-probe-host'))
def label():
ctx = handler.current_action_context
assert ctx in (binding('A'), binding('B')), ctx
return ctx.workspace_uuid[-1]
@handler.action(P.GET_PLUGIN_STORAGE_KEYS)
async def keys(data):
l = label(); actions.append((l, 'keys'))
return ActionResponse.success({'keys': list(stores[l])})
@handler.action(P.GET_PLUGIN_STORAGE)
async def get(data):
l = label(); actions.append((l, 'get'))
if data['key'] not in stores[l]:
stores[l][data['key']] = base64.b64encode(b'{}').decode()
return ActionResponse.success({'value_base64': stores[l][data['key']]})
@handler.action(P.SET_PLUGIN_STORAGE)
async def set_value(data):
l = label(); actions.append((l, 'set'))
stores[l][data['key']] = data['value_base64']
return ActionResponse.success({})
@handler.action(P.GET_KNOWLEDEGE_FILE_STREAM)
async def file_stream(data):
# Per-binding fixture file: a binding must never read its sibling's bytes.
l = label(); actions.append((l, 'file'))
assert data['storage_path'] == 'fixture-file', data
key = await host.send_file(b'private-' + l.encode(), '')
return ActionResponse.success({'file_key': key})
@handler.action(P.INVOKE_EMBEDDING)
async def embed(data):
l = label(); actions.append((l, 'embed'))
return ActionResponse.success({'vectors': [[1.0] for _ in data['texts']]})
@handler.action(P.VECTOR_UPSERT)
async def upsert(data):
l = label(); actions.append((l, 'upsert'))
for id_, meta in zip(data['ids'], data['metadata']):
vectors[l][id_] = {'id': id_, 'distance': .1, 'metadata': meta}
return ActionResponse.success({})
@handler.action(P.VECTOR_SEARCH)
async def search(data):
l = label(); actions.append((l, 'search'))
return ActionResponse.success({'results': list(vectors[l].values())[:data['top_k']]})
@handler.action(P.VECTOR_DELETE)
async def delete(data):
l = label(); actions.append((l, 'delete'))
n = len(vectors[l]); vectors[l].clear()
return ActionResponse.success({'count': n})
connected.set_result(handler)
await handler.run()
async with websockets.serve(accept, '127.0.0.1', 0) as server:
url = f'ws://127.0.0.1:{server.sockets[0].getsockname()[1]}'
proc = await asyncio.create_subprocess_exec(sys.executable, __file__, str(CODE), '--child', url,
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
env={**os.environ, 'LANGBOT_PLUGIN_FILE_STORAGE_DIR': '/tmp/ke-shared-probe-child-files'})
try:
host = await asyncio.wait_for(connected, 15)
async def call(label, action, data):
return await host.call_action(action, data, action_context=binding(label), timeout=20)
def settings(label):
if CODE.name in CONNECTORS:
# Connectors declare no plugin config; the per-installation
# settings still differ between the two bindings.
return {'enabled': True, 'priority': 0 if label == 'A' else 1, 'plugin_config': {}}
return {'enabled': True, 'priority': 0, 'plugin_config': {
'max_profile_traits': 1 if label == 'A' else 4,
'max_profile_preferences': 1 if label == 'A' else 4}}
for label in 'AB':
await call(label, R.ATTACH_PLUGIN_SLOT, {'plugin_settings': settings(label)})
a = await call('A', R.GET_PLUGIN_SLOT_CONTAINER, {})
b = await call('B', R.GET_PLUGIN_SLOT_CONTAINER, {})
if CODE.name in CONNECTORS:
assert a['priority'] != b['priority'], (a['priority'], b['priority'])
else:
assert a['plugin_config'] != b['plugin_config']
if CODE.name == 'LangRAG':
for label in 'AB':
result = await call(label, R.INGEST_DOCUMENT, {'context': {
'knowledge_base_id': 'same-kb', 'creation_settings': {'embedding_model_uuid': 'fixture-embedding'},
'file_object': {'metadata': {'filename': 'doc.txt', 'document_id': 'same-doc',
'knowledge_base_id': 'same-kb', 'file_size': 12, 'mime_type': 'text/plain'}, 'storage_path': 'fixture-file'},
'parsed_content': {'text': 'private-'+label}}})
assert result['status'] == 'completed', result
for label in 'AB':
result = await call(label, R.RETRIEVE_KNOWLEDGE, {'retriever_name': '', 'retrieval_context': {
'query': 'fixture query', 'knowledge_base_id': 'same-kb',
'creation_settings': {'embedding_model_uuid': 'fixture-embedding'},
'retrieval_settings': {'top_k': 2}}})
assert result['total_found'] == 1, result
assert 'private-'+label in str(result), result
for label in 'AB':
page = await call(label, R.PAGE_API, {'page_id': 'observability', 'endpoint': '/snapshot',
'method': 'GET', 'body': {}})
assert page['data']['counters']['ingest.total'] == 1, page
elif CODE.name in CONNECTORS:
def connector_config(label):
return {'api_base_url': base_url, 'api_key': label,
'dify_apikey': label, 'dataset_id': label, 'dataset_ids': label}
for label in 'AB':
# Host notification for the same knowledge-base ID on each binding.
await call(label, R.ON_KB_CREATE, {'kb_id': 'same-kb', 'config': connector_config(label)})
for label in 'AB':
result = await call(label, R.INGEST_DOCUMENT, {'context': {
'knowledge_base_id': 'same-kb', 'creation_settings': connector_config(label),
'file_object': {'metadata': {'filename': 'doc.txt', 'document_id': 'same-doc',
'knowledge_base_id': 'same-kb', 'file_size': 12, 'mime_type': 'text/plain'},
'storage_path': 'fixture-file'}}})
# The provider acknowledged a per-binding upstream document.
assert result['status'] == 'processing', result
assert result['document_id'] == 'remote-doc-'+label, result
for label in 'AB':
result = await call(label, R.RETRIEVE_KNOWLEDGE, {'retriever_name': '', 'retrieval_context': {
'query': 'fixture query', 'knowledge_base_id': 'same-kb',
'creation_settings': connector_config(label), 'retrieval_settings': {'top_k': 2}}})
assert result['total_found'] == 1, result
other = 'B' if label == 'A' else 'A'
assert 'Bearer '+label in str(result), result
assert 'Bearer '+other not in str(result), result
# Same Host storage key per binding, distinct durable per-binding value.
for label in 'AB':
other = 'B' if label == 'A' else 'A'
key = 'ke.config.v1.' + hashlib.sha256(b'same-kb').hexdigest()
assert json.loads(base64.b64decode(stores[label][key]))['api_key'] == label
assert all('"'+other+'"' not in base64.b64decode(value).decode()
for value in stores[label].values()), stores[label]
for label in 'AB':
# Delete resolves the stored per-binding credentials, not the sibling's.
result = await call(label, R.DELETE_DOCUMENT, {'kb_id': 'same-kb', 'document_id': 'same-doc'})
assert result['success'] is True, result
for label in 'AB':
other = 'B' if label == 'A' else 'A'
own = [c for c in provider_calls if c[2] == 'Bearer '+label]
assert own, 'no provider call carried binding '+label+' credentials'
assert any(c[0] == 'DELETE' for c in own), own
assert 'remote-doc-'+label in str(own) and 'remote-doc-'+other not in str(own), own
assert 'private-'+label in str([c[3] for c in own])
assert 'private-'+other not in str([c[3] for c in own]), own
# Each binding's ingest read its own Host file stream.
assert [l for l, action in actions if action == 'file'] == ['A', 'B'], actions
else:
for label in 'AB':
page = await call(label, R.PAGE_API, {'page_id': 'memory_console', 'endpoint': '/summary',
'method': 'GET', 'body': {}})
assert page.get('data') and page['data']['plugin_config']['max_profile_traits'] == (1 if label == 'A' else 4), page
summary = {'plugin': CODE.name, 'sdk': '0.7.4', 'worker_pid': proc.pid,
'same_worker_two_workspace_slots': True, 'slot_settings_distinct': True,
'successful_representative_invocation': True,
'host_actions': actions, 'scope': 'real SDK child RPC, local Host fixture; no production/upstream/sandbox proof'}
if CODE.name not in CONNECTORS:
# Connectors declare no plugin config; their slots differ in
# per-installation settings only.
summary['slot_configs_distinct'] = True
print(json.dumps(summary))
finally:
proc.terminate()
try:
stdout, stderr = await asyncio.wait_for(proc.communicate(), 5)
except asyncio.TimeoutError:
proc.kill(); stdout, stderr = await proc.communicate()
if provider_runner is not None:
await provider_runner.cleanup()
# A terminated worker is SIGTERM on POSIX and exit code 1 on Windows.
terminated = (0, -15) if os.name != 'nt' else (0, 1)
if proc.returncode not in terminated:
print('child stderr:', stderr.decode()[-3500:], file=sys.stderr)
assert b'SAME_OBJECT_GRAPH=true' in stderr, stderr.decode()[-3500:]
assert proc.returncode in terminated, proc.returncode
if __name__ == '__main__':
if len(sys.argv) > 2 and sys.argv[2] == '--child':
asyncio.run(child(sys.argv[3]))
else:
asyncio.run(asyncio.wait_for(parent(), 60))
@@ -0,0 +1,144 @@
"""Dify deletion safety: fail-closed fence reads, dispatch fencing, target binding.
Host storage and the HTTP transport are explicit local fixtures; no live service
is contacted. The plugin is loaded through the shared certification loader so the
bundled ``components`` package is exercised as shipped.
"""
import asyncio
import httpx
import pytest
from test_shared_state import StorageFixture, bind, configuration, load_plugin
def make_engine(storage):
pc, ec = load_plugin('DifyDatasetsConnector')
engine = ec()
engine.plugin = bind(pc, storage)
return engine
def patch_http(monkeypatch, handler):
calls = []
async def service(request):
calls.append(request)
result = handler(request)
if asyncio.iscoroutine(result):
result = await result
return result
client = httpx.AsyncClient
monkeypatch.setattr(
httpx, 'AsyncClient',
lambda **kw: client(transport=httpx.MockTransport(service), **kw),
)
return calls
async def seed(engine, mapping_target='https://fixture.invalid', dataset_id='A'):
await engine._save_config('kb', configuration('A'))
await engine._save_document('kb', 'doc', {
'upstream_id': 'upstream-A', 'dataset_id': dataset_id, 'status': 'created',
'api_base_url': mapping_target,
})
@pytest.mark.asyncio
async def test_fence_read_failure_refuses_without_remote_call_and_recovers(monkeypatch):
calls = patch_http(monkeypatch, lambda request: httpx.Response(204))
store = StorageFixture()
engine = make_engine(store)
await seed(engine)
from components import shared_state
binding = engine._state.binding(engine.plugin)
store.fail = True
with pytest.raises(shared_state.FenceStateUnavailableError, match='fence state'):
await engine.delete_document('kb', 'doc')
# Unknown fence state fails closed before any provider request.
assert calls == []
# The failure is not cached as a loaded binding, so the read is retried.
assert binding not in engine._state._fence_loaded
store.fail = False
assert await engine.delete_document('kb', 'doc') is True
assert [request.method for request in calls] == ['DELETE']
class BlockingDelete:
def __init__(self):
self.entered = asyncio.Event()
self.gate = asyncio.Event()
async def __call__(self, request):
self.entered.set()
await self.gate.wait()
return httpx.Response(204)
@pytest.mark.asyncio
async def test_cancelled_delete_dispatch_keeps_fence_and_mapping(monkeypatch):
transport = BlockingDelete()
calls = patch_http(monkeypatch, transport)
store = StorageFixture()
engine = make_engine(store)
await seed(engine)
binding = engine._state.binding(engine.plugin)
task = asyncio.create_task(engine.delete_document('kb', 'doc'))
await asyncio.wait_for(transport.entered.wait(), 1.0)
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
assert len(calls) == 1
# Dispatched with an unknown outcome: the fence is kept.
assert engine._state.is_fenced(binding, 'kb')
# The remote delete was never acknowledged, so the mapping stays actionable.
assert (await engine._load_document('kb', 'doc'))['status'] == 'created'
restarted = make_engine(store)
with pytest.raises(RuntimeError, match='fenced'):
await restarted.delete_document('kb', 'doc')
@pytest.mark.asyncio
async def test_deterministic_delete_rejection_leaves_no_fence(monkeypatch):
calls = patch_http(monkeypatch, lambda request: httpx.Response(404, text='missing'))
store = StorageFixture()
engine = make_engine(store)
await seed(engine)
binding = engine._state.binding(engine.plugin)
assert await engine.delete_document('kb', 'doc') is False
assert len(calls) == 1
assert not engine._state.is_fenced(binding, 'kb')
# A restarted worker still dispatches (rather than being fenced).
restarted = make_engine(store)
assert await restarted.delete_document('kb', 'doc') is False
@pytest.mark.asyncio
async def test_delete_rejects_changed_upstream_before_any_remote_call(monkeypatch):
calls = patch_http(monkeypatch, lambda request: httpx.Response(204))
store = StorageFixture()
engine = make_engine(store)
await seed(engine, mapping_target='https://old.invalid')
# Current configuration points at a different upstream instance.
with pytest.raises(RuntimeError, match='Upstream target changed'):
await engine.delete_document('kb', 'doc')
assert calls == []
# Legacy mappings lacking a bound target are equally rejected, before dispatch.
legacy_store = StorageFixture()
legacy = make_engine(legacy_store)
await legacy._save_config('kb', configuration('A'))
await legacy._save_document('kb', 'doc', {
'upstream_id': 'upstream-A', 'dataset_id': 'A', 'status': 'created',
})
with pytest.raises(RuntimeError, match='Upstream target changed'):
await legacy.delete_document('kb', 'doc')
assert calls == []
@@ -0,0 +1,582 @@
"""FastGPT deletion safety: dispatched-but-unknown deletes, binding, and uploads.
Local HTTP fixture; real SDK objects via ``test_shared_state``'s loader.
"""
import asyncio
import json
import httpx
import pytest
from test_shared_state import (
StorageFixture,
bind,
configuration,
ingest_context,
load_plugin,
)
from langbot_plugin.api.entities.builtin.rag import DocumentStatus
def bind_mock_http(monkeypatch, handler):
client = httpx.AsyncClient
monkeypatch.setattr(
httpx, 'AsyncClient',
lambda **kw: client(transport=httpx.MockTransport(handler), **kw),
)
def upload_engine(pc, ec, store):
"""An engine whose Host resolves storage and file streaming to local fixtures."""
engine = ec()
plugin = bind(pc, store)
async def read_file(path):
return b'fixture file'
plugin.get_knowledge_file_stream = read_file
engine.plugin = plugin
return engine
@pytest.mark.asyncio
async def test_fastgpt_cancelled_delete_keeps_fence_and_mapping(monkeypatch):
pc, ec = load_plugin('FastGPTConnector')
dispatched = asyncio.Event()
release = asyncio.Event()
async def service(request):
if request.method == 'DELETE':
dispatched.set()
await release.wait()
return httpx.Response(200, json={'code': 200, 'data': None})
bind_mock_http(monkeypatch, service)
store = StorageFixture()
engine = ec()
engine.plugin = bind(pc, store)
cfg = configuration('A')
await engine.on_knowledge_base_create('kb', cfg)
await engine._save_document('kb', 'host-doc', {
'upstream_id': 'upstream-doc', 'dataset_id': 'A', 'status': 'created',
'api_base_url': cfg['api_base_url']})
task = asyncio.create_task(engine.delete_document('kb', 'host-doc'))
await dispatched.wait()
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
release.set()
# The delete was already dispatched, so its outcome is unknown: the mapping
# must not be closed out as deleted.
assert (await engine._load_document('kb', 'host-doc'))['status'] == 'created'
# The knowledge base stays fenced, also for a worker that restarts.
with pytest.raises(RuntimeError, match='fenced'):
await engine.delete_document('kb', 'host-doc')
restarted = ec()
restarted.plugin = bind(pc, store)
with pytest.raises(RuntimeError, match='fenced'):
await restarted.delete_document('kb', 'host-doc')
@pytest.mark.asyncio
@pytest.mark.parametrize('recorded_target', [None, 'https://other.invalid'])
async def test_fastgpt_delete_refuses_mapping_from_another_upstream(
monkeypatch, recorded_target
):
pc, ec = load_plugin('FastGPTConnector')
calls = []
async def service(request):
calls.append(request.method)
return httpx.Response(200, json={'code': 200, 'data': None})
bind_mock_http(monkeypatch, service)
store = StorageFixture()
engine = ec()
engine.plugin = bind(pc, store)
cfg = configuration('A')
await engine.on_knowledge_base_create('kb', cfg)
mapping = {'upstream_id': 'upstream-doc', 'dataset_id': 'A', 'status': 'created'}
if recorded_target is not None:
mapping['api_base_url'] = recorded_target
await engine._save_document('kb', 'host-doc', mapping)
# A mapping that is not bound to the configured upstream target (including a
# mapping recorded before targets were bound) is refused before any request.
with pytest.raises(RuntimeError, match='Upstream target changed'):
await engine.delete_document('kb', 'host-doc')
assert calls == []
@pytest.mark.asyncio
async def test_fastgpt_deterministic_upload_rejection_leaves_no_fence(monkeypatch):
pc, ec = load_plugin('FastGPTConnector')
async def service(request):
return httpx.Response(400, json={'message': 'unsupported file'})
bind_mock_http(monkeypatch, service)
store = StorageFixture()
engine = upload_engine(pc, ec, store)
result = await engine.ingest(ingest_context(configuration('A')))
assert result.status == DocumentStatus.FAILED
# The provider answered, so the pre-dispatch upload intent is cleared again:
# a restarted worker accepts mutations for this knowledge base.
restarted = upload_engine(pc, ec, store)
await restarted.on_knowledge_base_create('kb', configuration('A'))
@pytest.mark.asyncio
async def test_fastgpt_upload_binds_mapping_to_normalized_upstream_target(monkeypatch):
pc, ec = load_plugin('FastGPTConnector')
async def service(request):
if request.method == 'POST':
return httpx.Response(200, json={
'code': 200, 'data': {'collectionId': 'upstream-doc', 'results': {}},
})
return httpx.Response(200, json={'code': 200, 'data': None})
bind_mock_http(monkeypatch, service)
store = StorageFixture()
engine = upload_engine(pc, ec, store)
cfg = configuration('A')
# Casing and a trailing slash are config spellings, not a different upstream.
upload = await engine.ingest(ingest_context({
**cfg, 'api_base_url': cfg['api_base_url'].upper() + '/',
}))
assert upload.status == DocumentStatus.PROCESSING
mapping = await engine._load_document('kb', 'local-doc')
assert mapping['api_base_url'] == cfg['api_base_url']
# The mapping names the upstream that owns the collection, so a delete
# against that same upstream still succeeds.
assert await engine.delete_document('kb', 'local-doc') is True
def fence_key_fault(shared_state, message):
def fault(key):
return RuntimeError(message) if key.startswith(shared_state.FENCE_KEY_PREFIX) else None
return fault
@pytest.mark.asyncio
async def test_fastgpt_unpersistable_fence_aborts_the_upload_before_the_request(monkeypatch):
pc, ec = load_plugin('FastGPTConnector')
from components import shared_state
calls = []
async def service(request):
calls.append(request)
return httpx.Response(200, json={
'code': 200, 'data': {'collectionId': 'upstream-doc', 'results': {}},
})
bind_mock_http(monkeypatch, service)
store = StorageFixture()
store.write_fault = fence_key_fault(shared_state, 'fixture fence write rejected')
engine = upload_engine(pc, ec, store)
# The pre-written protection cannot be persisted, so the upload must not be
# dispatched at all.
with pytest.raises(shared_state.FencePersistError, match='Could not persist fence'):
await engine.ingest(ingest_context(configuration('A')))
assert calls == []
# A mutation that was never dispatched leaves no fence behind...
assert not engine._state.is_fenced(engine._state.binding(engine.plugin), 'kb')
assert [key for key in store.data if key.startswith(shared_state.FENCE_KEY_PREFIX)] == []
# ...only the durable pre-dispatch intent it had already recorded.
assert (await engine._load_document('kb', 'local-doc'))['status'] == 'pending'
@pytest.mark.asyncio
async def test_fastgpt_unpersistable_fence_aborts_the_delete_before_the_request(monkeypatch):
pc, ec = load_plugin('FastGPTConnector')
from components import shared_state
calls = []
async def service(request):
calls.append(request)
return httpx.Response(200, json={'code': 200, 'data': None})
bind_mock_http(monkeypatch, service)
store = StorageFixture()
engine = ec()
engine.plugin = bind(pc, store)
cfg = configuration('A')
await engine.on_knowledge_base_create('kb', cfg)
await engine._save_document('kb', 'host-doc', {
'upstream_id': 'upstream-doc', 'dataset_id': 'A', 'status': 'created',
'api_base_url': cfg['api_base_url']})
store.write_fault = fence_key_fault(shared_state, 'fixture fence write rejected')
with pytest.raises(shared_state.FencePersistError, match='Could not persist fence'):
await engine.delete_document('kb', 'host-doc')
assert calls == []
assert not engine._state.is_fenced(engine._state.binding(engine.plugin), 'kb')
assert [key for key in store.data if key.startswith(shared_state.FENCE_KEY_PREFIX)] == []
# The delete was never dispatched, so its mapping is still the recorded one.
assert (await engine._load_document('kb', 'host-doc'))['status'] == 'created'
@pytest.mark.asyncio
async def test_fastgpt_cancellation_after_ack_before_the_mapping_keeps_the_fence(monkeypatch):
pc, ec = load_plugin('FastGPTConnector')
calls = []
async def service(request):
calls.append(request)
return httpx.Response(200, json={'code': 200, 'data': None})
bind_mock_http(monkeypatch, service)
store = StorageFixture()
engine = ec()
engine.plugin = bind(pc, store)
cfg = configuration('A')
await engine.on_knowledge_base_create('kb', cfg)
await engine._save_document('kb', 'host-doc', {
'upstream_id': 'upstream-doc', 'dataset_id': 'A', 'status': 'created',
'api_base_url': cfg['api_base_url']})
# The documented window: the provider has acknowledged the delete, the Host
# record of it has not been written yet.
entered = asyncio.Event()
release = asyncio.Event()
record = engine._save_document
async def gated_record(kb_id, host_document_id, value):
if value.get('status') == 'deleted':
entered.set()
await release.wait()
return await record(kb_id, host_document_id, value)
monkeypatch.setattr(engine, '_save_document', gated_record)
task = asyncio.create_task(engine.delete_document('kb', 'host-doc'))
await asyncio.wait_for(entered.wait(), 5)
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
release.set()
# The delete was acknowledged upstream but its Host record does not exist, so
# the protection must not have been released: a retry would replay the delete.
assert (await engine._load_document('kb', 'host-doc'))['status'] == 'created'
assert engine._state.is_fenced(engine._state.binding(engine.plugin), 'kb')
with pytest.raises(RuntimeError, match='fenced'):
await engine.delete_document('kb', 'host-doc')
assert [request.method for request in calls] == ['DELETE']
@pytest.mark.asyncio
async def test_fastgpt_later_success_never_clears_an_unresolved_fence():
pc, ec = load_plugin('FastGPTConnector')
from components import shared_state
store = StorageFixture()
engine = ec()
engine.plugin = bind(pc, store)
plugin = engine.plugin
await engine._state.fence(plugin, 'kb', 'earlier delete outcome unknown')
ran = []
async def later_mutation():
ran.append(1)
return 'dispatched'
# The later mutation is refused instead of running and releasing the earlier
# operation's fence as if its own success had resolved it.
with pytest.raises(shared_state.FencedKnowledgeBaseError, match='unresolved'):
await engine._state.dispatched(plugin, 'kb', later_mutation, 'later mutation')
assert ran == []
assert engine._state.is_fenced(engine._state.binding(plugin), 'kb')
assert [key for key in store.data if key.startswith(shared_state.FENCE_KEY_PREFIX)]
@pytest.mark.asyncio
@pytest.mark.parametrize('status', [500, 400])
async def test_fastgpt_delete_outcome_is_unknown_exactly_on_a_server_error(monkeypatch, status):
pc, ec = load_plugin('FastGPTConnector')
from components import shared_state
calls = []
async def service(request):
calls.append(request.method)
return httpx.Response(status, json={'message': 'upstream failed'})
bind_mock_http(monkeypatch, service)
store = StorageFixture()
engine = ec()
engine.plugin = bind(pc, store)
cfg = configuration('A')
await engine.on_knowledge_base_create('kb', cfg)
await engine._save_document('kb', 'host-doc', {
'upstream_id': 'upstream-doc', 'dataset_id': 'A', 'status': 'created',
'api_base_url': cfg['api_base_url']})
if status >= 500:
# The provider or a gateway failed after receiving the delete, so whether
# it was applied is unknown: the fence stays and the caller is told
# instead of being handed a resolved rejection.
with pytest.raises(shared_state.AmbiguousMutationError):
await engine.delete_document('kb', 'host-doc')
assert engine._state.is_fenced(engine._state.binding(engine.plugin), 'kb')
# The fence is durable, not just in this process.
assert json.loads(store.data[shared_state._kb_fence_key('kb')]) is not None
# Nothing was recorded as deleted, and the next mutation for this
# knowledge base is refused, also for a worker that restarts.
assert (await engine._load_document('kb', 'host-doc'))['status'] == 'created'
with pytest.raises(RuntimeError, match='fenced'):
await engine.delete_document('kb', 'host-doc')
restarted = ec()
restarted.plugin = bind(pc, store)
with pytest.raises(RuntimeError, match='fenced'):
await restarted.delete_document('kb', 'host-doc')
else:
# The provider answered with a rejection, so the delete did not happen.
assert await engine.delete_document('kb', 'host-doc') is False
assert not engine._state.is_fenced(engine._state.binding(engine.plugin), 'kb')
assert json.loads(store.data[shared_state._kb_fence_key('kb')]) is None
restarted = ec()
restarted.plugin = bind(pc, store)
assert await restarted.delete_document('kb', 'host-doc') is False
@pytest.mark.asyncio
@pytest.mark.parametrize('status', [500, 400])
async def test_fastgpt_upload_outcome_is_unknown_exactly_on_a_server_error(monkeypatch, status):
pc, ec = load_plugin('FastGPTConnector')
from components import shared_state
async def service(request):
return httpx.Response(status, json={'message': 'upstream failed'})
bind_mock_http(monkeypatch, service)
store = StorageFixture()
engine = upload_engine(pc, ec, store)
cfg = configuration('A')
if status >= 500:
# A dispatched upload whose answer is a server error has an unknown
# outcome, so it is neither reported as a plain failure nor unfenced.
with pytest.raises(shared_state.AmbiguousMutationError):
await engine.ingest(ingest_context(cfg))
assert engine._state.is_fenced(engine._state.binding(engine.plugin), 'kb')
assert json.loads(store.data[shared_state._kb_fence_key('kb')]) is not None
assert (await engine._load_document('kb', 'local-doc'))['status'] == 'pending'
with pytest.raises(RuntimeError, match='fenced'):
await engine.on_knowledge_base_create('kb', cfg)
restarted = upload_engine(pc, ec, store)
with pytest.raises(RuntimeError, match='fenced'):
await restarted.on_knowledge_base_create('kb', cfg)
else:
# The provider answered with a rejection, so the upload is a failure with
# a resolved outcome and the knowledge base stays usable.
result = await engine.ingest(ingest_context(cfg))
assert result.status == DocumentStatus.FAILED
assert not engine._state.is_fenced(engine._state.binding(engine.plugin), 'kb')
await engine.on_knowledge_base_create('kb', cfg)
@pytest.mark.asyncio
async def test_fastgpt_concurrent_first_load_cannot_resurrect_a_cleared_fence(monkeypatch):
pc, ec = load_plugin('FastGPTConnector')
from components import shared_state
store = StorageFixture()
seed = ec()
seed.plugin = bind(pc, store)
# An earlier ambiguous mutation left this knowledge base durably fenced.
await seed._state.fence(seed.plugin, 'cleared', 'earlier delete outcome unknown')
# The restarted worker's first fence load blocks on that record, so the
# delete of the fenced knowledge base is issued while the read is in flight.
store.gate_read(shared_state._kb_fence_key('cleared'))
engine = ec()
engine.plugin = bind(pc, store)
cfg = configuration('A')
loading = asyncio.create_task(engine.on_knowledge_base_create('fresh', cfg))
await asyncio.wait_for(store.gate_entered.wait(), 5)
clearing = asyncio.create_task(engine.on_knowledge_base_delete('cleared'))
for _ in range(200):
await asyncio.sleep(0)
# Both first calls share one Host read instead of one read per knowledge base.
assert store.keys_reads == 1
store.gate_release.set()
await loading
await clearing
assert store.keys_reads == 1
# The cleared knowledge base is usable: the snapshot that was read before
# the clear was not installed in its place.
assert not engine._state.is_fenced(engine._state.binding(engine.plugin), 'cleared')
await engine.on_knowledge_base_create('cleared', cfg)
@pytest.mark.asyncio
async def test_fastgpt_load_discards_a_snapshot_read_before_a_sibling_clear():
pc, ec = load_plugin('FastGPTConnector')
from components import shared_state
store = StorageFixture()
seed = ec()
seed.plugin = bind(pc, store)
await seed._state.fence(seed.plugin, 'kb', 'earlier delete outcome unknown')
store.gate_read(shared_state._kb_fence_key('kb'))
engine = ec()
engine.plugin = bind(pc, store)
cfg = configuration('A')
loading = asyncio.create_task(engine.on_knowledge_base_create('kb', cfg))
await asyncio.wait_for(store.gate_entered.wait(), 5)
# The operator path out of a fence lands while the load's Host read is in
# flight and already holds the fence record in hand.
await engine._state.clear_fence(engine.plugin, 'kb')
store.gate_release.set()
await loading
# The stale snapshot was discarded and re-read instead of installed: the
# cleared knowledge base neither fails this call nor stays fenced.
assert not engine._state.is_fenced(engine._state.binding(engine.plugin), 'kb')
assert store.keys_reads == 2
await engine.on_knowledge_base_create('kb', configuration('B'))
def collection_context(config, collection_id):
"""An ingestion context whose collection id is not the knowledge-base id."""
context = ingest_context(config)
context.collection_id = collection_id
return context
def accepting_upload():
async def service(request):
if request.method == 'POST':
return httpx.Response(200, json={
'code': 200, 'data': {'collectionId': 'upstream-1', 'results': {'insertLen': 3}},
})
return httpx.Response(200, json={'code': 200, 'data': None})
return service
@pytest.mark.asyncio
async def test_fastgpt_collection_and_knowledge_base_ids_share_one_identity(monkeypatch):
pc, ec = load_plugin('FastGPTConnector')
from components import shared_state
calls = []
async def service(request):
calls.append(request.method)
return await accepting_upload()(request)
bind_mock_http(monkeypatch, service)
store = StorageFixture()
engine = upload_engine(pc, ec, store)
plugin = engine.plugin
binding = engine._state.binding(plugin)
cfg = configuration('A')
# A fence taken for the knowledge base the Host names must refuse an
# ingestion whose context carries a different collection id: the two ids are
# one knowledge base, not two independently protected scopes.
await engine._state.fence(plugin, 'kb', 'earlier upload outcome unknown')
context = collection_context(cfg, 'collection-1')
with pytest.raises(shared_state.FencedKnowledgeBaseError):
await engine.ingest(context)
assert calls == []
await engine._state.clear_fence(plugin, 'kb')
entered, release = asyncio.Event(), asyncio.Event()
async def read_file(path):
entered.set()
await release.wait()
return b'fixture file'
engine.plugin.get_knowledge_file_stream = read_file
upload = asyncio.create_task(engine.ingest(context))
await asyncio.wait_for(entered.wait(), 5)
# In flight, the mutation holds the knowledge base's lock, not the
# collection's.
assert (binding, 'kb') in engine._state._locks
assert (binding, 'collection-1') not in engine._state._locks
release.set()
assert (await upload).status == DocumentStatus.PROCESSING
# One identity for storage too: the mapping is written under the key the
# deletion that names the knowledge base resolves.
assert (await engine._load_document('kb', 'local-doc'))['upstream_id'] == 'upstream-1'
assert await engine.delete_document('kb', 'local-doc') is True
assert calls == ['POST', 'DELETE']
assert (await engine._load_document('kb', 'local-doc'))['status'] == 'deleted'
assert not engine._state.is_fenced(binding, 'kb')
@pytest.mark.asyncio
async def test_fastgpt_delete_under_another_knowledge_base_never_adopts_its_mapping(monkeypatch):
pc, ec = load_plugin('FastGPTConnector')
calls = []
async def service(request):
calls.append(request.method)
return httpx.Response(200, json={'code': 200, 'data': None})
bind_mock_http(monkeypatch, service)
store = StorageFixture()
engine = ec()
engine.plugin = bind(pc, store)
cfg = configuration('A')
# Knowledge base A's mapping and configuration name the upstream document;
# knowledge base B has neither.
await engine._save_config('A', cfg)
await engine._save_document('A', 'shared-doc', {
'upstream_id': 'upstream-doc', 'dataset_id': 'A', 'status': 'created',
'api_base_url': cfg['api_base_url']})
# Deleting under B must not resolve, copy or tombstone A's record: the
# operation is refused with nothing dispatched and nothing written under B.
assert await engine.delete_document('B', 'shared-doc') is False
assert calls == []
assert engine._document_key('B', 'shared-doc') not in store.data
assert engine._key('B') not in store.data
assert (await engine._load_document('A', 'shared-doc'))['status'] == 'created'
# A's own delete still reaches the provider: B never consumed its record.
assert await engine.delete_document('A', 'shared-doc') is True
assert calls == ['DELETE']
@pytest.mark.asyncio
async def test_fastgpt_pending_record_of_another_knowledge_base_does_not_block_ingest(monkeypatch):
pc, ec = load_plugin('FastGPTConnector')
calls = []
async def service(request):
calls.append(request.method)
return await accepting_upload()(request)
bind_mock_http(monkeypatch, service)
store = StorageFixture()
engine = upload_engine(pc, ec, store)
# A pending record under knowledge base A is A's upload intent, not B's.
await engine._save_document('A', 'local-doc', {
'upstream_id': '', 'dataset_id': 'A',
'api_base_url': 'https://fixture.invalid', 'status': 'pending'})
before = store.data[engine._document_key('A', 'local-doc')]
context = ingest_context(configuration('B'))
context.knowledge_base_id = 'B'
result = await engine.ingest(context)
assert result.status == DocumentStatus.PROCESSING
# B recorded only its own mapping, and A's pending record is untouched: B
# neither treated A's intent as its own nor wrote under A.
assert (await engine._load_document('B', 'local-doc'))['status'] == 'created'
assert (await engine._load_document('A', 'local-doc'))['status'] == 'pending'
assert store.data[engine._document_key('A', 'local-doc')] == before
assert calls == ['POST']
@@ -0,0 +1,33 @@
import pytest
import httpx
from test_shared_state import load_plugin, StorageFixture, bind, configuration, ingest_context, bind_mock_http
@pytest.mark.asyncio
async def test_uploaded_collection_mapping_failure_is_ambiguous(monkeypatch):
pc, ec = load_plugin('FastGPTConnector')
engine = ec()
engine.plugin = bind(pc, StorageFixture())
async def stream(path):
return b'acceptance document'
engine.plugin.get_knowledge_file_stream = stream
original = engine._save_document
async def save(kb, document, value):
if value.get('status') == 'created':
raise RuntimeError('Host mapping commit unavailable')
return await original(kb, document, value)
monkeypatch.setattr(engine, '_save_document', save)
async def upstream(request):
return httpx.Response(200, json={'code': 200, 'data': {'collectionId': 'real-response-id', 'results': {'insertLen': 1}}})
bind_mock_http(monkeypatch, upstream)
from components.shared_state import AmbiguousMutationError
with pytest.raises(AmbiguousMutationError):
await engine.ingest(ingest_context(configuration('A')))
assert engine._state.is_fenced(engine._state.binding(engine.plugin), 'kb')
@@ -0,0 +1,44 @@
"""Regression against FastGPT v4.9.3 observed wire contracts; local HTTP fixture."""
import pytest
from aiohttp import web
from langbot_plugin.api.entities.builtin.rag import RetrievalContext
from test_shared_state import load_plugin, bind, StorageFixture, configuration
@pytest.mark.asyncio
@pytest.mark.parametrize('scores, expected', [([{'type':'embedding','value':0.72,'index':0}],0.72),([{'type':'embedding','value':0.72},{'type':'rerank','value':0.91}],0.91),([],0.0)])
async def test_fastgpt_nested_search_list_and_typed_scores(scores, expected):
pc,ec=load_plugin('FastGPTConnector')
async def search(request):
return web.json_response({'code':200,'data':{'list':[{'id':'seg','q':'real-format','a':'answer','score':scores,'collectionId':'doc'}]}})
app=web.Application();app.router.add_post('/api/core/dataset/searchTest',search)
runner=web.AppRunner(app);await runner.setup();site=web.TCPSite(runner,'127.0.0.1',0);await site.start()
cfg={**configuration('A'),'api_base_url':f'http://127.0.0.1:{site._server.sockets[0].getsockname()[1]}'}
engine=ec();engine.plugin=bind(pc,StorageFixture())
try:
result=await engine.retrieve(RetrievalContext(query='q',knowledge_base_id='kb',creation_settings=cfg))
assert result.total_found==1
assert result.results[0].content[0].text=='real-format\nanswer'
assert result.results[0].score==expected
assert result.results[0].distance==pytest.approx(1-expected)
finally:await runner.cleanup()
@pytest.mark.asyncio
async def test_fastgpt_delete_uses_delete_query_id_and_saved_credentials():
pc,ec=load_plugin('FastGPTConnector');seen=[]
async def delete(request):
seen.append((request.method,dict(request.query),request.headers.get('Authorization')))
if request.method!='DELETE' or request.query.get('id')!='upstream-doc':
return web.json_response({'code':500,'message':'missingParams'})
return web.json_response({'code':200,'data':None})
app=web.Application();app.router.add_route('*','/api/core/dataset/collection/delete',delete)
runner=web.AppRunner(app);await runner.setup();site=web.TCPSite(runner,'127.0.0.1',0);await site.start()
cfg={**configuration('A'),'api_base_url':f'http://127.0.0.1:{site._server.sockets[0].getsockname()[1]}'}
store=StorageFixture();first=ec();first.plugin=bind(pc,store)
try:
await first.on_knowledge_base_create('kb',cfg)
restarted=ec();restarted.plugin=bind(pc,store)
assert await restarted.delete_document('kb','upstream-doc') is False
await restarted._save_document('kb','host-doc', {'upstream_id':'upstream-doc', 'dataset_id':'A', 'status':'created', 'api_base_url':cfg['api_base_url']})
assert await restarted.delete_document('kb','host-doc')
assert seen==[('DELETE',{'id':'upstream-doc'},'Bearer A')]
finally:await runner.cleanup()
@@ -0,0 +1,101 @@
"""Real HTTPX requests against a FAKE loopback provider (not provider E2E)."""
import asyncio
import json
from types import SimpleNamespace
import pytest
from aiohttp import web
from langbot_plugin.api.entities.builtin.rag import (
RetrievalContext, IngestionContext, FileObject, FileMetadata, DocumentStatus,
)
from test_shared_state import load_plugin, bind, StorageFixture, CONNECTORS, configuration
@pytest.mark.asyncio
@pytest.mark.parametrize('name', CONNECTORS)
async def test_fake_http_ingest_retrieve_restart_delete_contract(name):
pc, ec = load_plugin(name)
received = []
async def fake_service(request):
body = await request.read()
auth = request.headers.get('Authorization')
received.append((request.method, request.path, auth, body))
await asyncio.sleep(.002)
if request.path.endswith('/retrieve'):
return web.json_response({'records': [{'segment': {'id': 'seg', 'content': auth}, 'score': .8}]})
if request.path.endswith('/searchTest'):
return web.json_response({'data': [{'id': 'seg', 'q': auth, 'a': 'answer', 'score': .8}]})
if request.path.endswith('/retrieval'):
return web.json_response({'code': 0, 'data': {'chunks': [{'id': 'seg', 'content': auth, 'similarity': .8}]}})
if request.path.endswith('/create-by-file'):
assert b'fixture file' in body
return web.json_response({'document': {'id': 'remote-doc'}})
if request.path.endswith('/localFile'):
assert b'fixture file' in body
return web.json_response({'code': 200, 'data': {'collectionId': 'remote-doc', 'results': {'insertLen': 2}}})
if request.method == 'POST' and request.path.endswith('/documents'):
assert b'fixture file' in body
return web.json_response({'code': 0, 'data': [{'id': 'remote-doc'}]})
if request.method == 'GET':
return web.json_response({'code': 0, 'data': []})
return web.json_response({'code': 200 if name == 'FastGPTConnector' else 0, 'data': {}})
app = web.Application()
app.router.add_route('*', '/{path:.*}', fake_service)
runner = web.AppRunner(app)
await runner.setup()
site = web.TCPSite(runner, '127.0.0.1', 0)
await site.start()
port = site._server.sockets[0].getsockname()[1]
store = StorageFixture()
plugin = bind(pc, store)
async def read_file(path):
assert path == 'fixture-file'
return b'fixture file'
plugin.get_knowledge_file_stream = read_file
engine = ec()
engine.plugin = plugin
cfg = {**configuration('A'), 'api_base_url': f'http://127.0.0.1:{port}', 'auto_graphrag': True, 'auto_raptor': True}
try:
ctx = IngestionContext(file_object=FileObject(metadata=FileMetadata(filename='doc.txt', file_size=12, mime_type='text/plain', document_id='local-doc', knowledge_base_id='kb'), storage_path='fixture-file'), knowledge_base_id='kb', creation_settings=cfg)
result = await engine.ingest(ctx)
assert result.status == DocumentStatus.PROCESSING
assert result.document_id == 'remote-doc'
if name == 'RAGFlowConnector':
assert any(x[1].endswith('/chunks') for x in received)
assert any(x[1].endswith('/run_graphrag') for x in received)
assert any(x[1].endswith('/run_raptor') for x in received)
async def retrieve(i):
config = {**cfg, 'api_key': f'T{i}', 'dify_apikey': f'T{i}'}
return await engine.retrieve(RetrievalContext(query=f'query{i}', knowledge_base_id='kb', creation_settings=config, retrieval_settings={'top_k': 2}))
outputs = await asyncio.gather(*(retrieve(i) for i in range(8)))
for i, output in enumerate(outputs):
assert output.total_found == 1
assert f'Bearer T{i}' in output.results[0].content[0].text
assert output.results[0].score == .8
restarted = ec()
restarted.plugin = bind(pc, store)
assert await restarted.delete_document('kb', 'remote-doc')
assert received[-1][2] == 'Bearer A'
await restarted.on_knowledge_base_delete('kb')
assert not await restarted.delete_document('kb', 'remote-doc')
finally:
await runner.cleanup()
@pytest.mark.asyncio
@pytest.mark.parametrize('name', CONNECTORS)
async def test_http_has_total_deadline_not_only_inactivity_timeout(name, monkeypatch):
pc, ec = load_plugin(name)
from components import shared_state
assert hasattr(shared_state, 'HTTP_TOTAL_TIMEOUT'), 'Require a total operation deadline'
monkeypatch.setattr(shared_state, 'HTTP_TOTAL_TIMEOUT', .02)
import httpx
async def stalls(request):
await asyncio.sleep(10)
client = httpx.AsyncClient
monkeypatch.setattr(httpx, 'AsyncClient', lambda **kw: client(transport=httpx.MockTransport(stalls), **kw))
engine = ec()
engine.plugin = bind(pc, StorageFixture())
output = await asyncio.wait_for(engine.retrieve(RetrievalContext(query='q', knowledge_base_id='kb', creation_settings=configuration('A'))), .5)
assert output.total_found == 0
@@ -0,0 +1,206 @@
"""Real LangRAG + SDK dispatch/proxy/WebSocket; explicitly simulated Host.
Run separately from LangRAG/tests, which installs SDK stubs. No Core DB, live
vector provider or production deployment is exercised here. The row fixture
models Core's explicit-True gate and a failed Host commit, not its ORM.
"""
import asyncio
from pathlib import Path
from types import SimpleNamespace
import pytest
import pytest_asyncio
import websockets
from langbot_plugin.cli.run.controller import PluginRuntimeController
from langbot_plugin.cli.run.handler import PluginRuntimeHandler
from langbot_plugin.cli.utils.page_components import discover_plugin_components
from langbot_plugin.entities.io.actions.enums import (
PluginToRuntimeAction as P,
RuntimeToPluginAction as R,
)
from langbot_plugin.entities.io.context import InstallationBinding
from langbot_plugin.entities.io.errors import ActionCallError
from langbot_plugin.runtime.io.connections.ws import WebSocketConnection
from langbot_plugin.runtime.io.handler import ActionResponse, Handler
from langbot_plugin.utils.discover.engine import ComponentDiscoveryEngine
from test_shared_state import load_plugin
@pytest_asyncio.fixture
async def boundary(tmp_path, monkeypatch):
# Clear prior candidate modules, then let the genuine SDK discover/instantiate.
load_plugin('LangRAG')
code = Path(__file__).resolve().parents[1] / 'LangRAG'
monkeypatch.chdir(code)
monkeypatch.syspath_prepend(str(code))
monkeypatch.setenv('LANGBOT_PLUGIN_FILE_STORAGE_DIR', str(tmp_path / 'worker'))
discovery = ComponentDiscoveryEngine()
manifest = discovery.load_component_manifest('manifest.yaml', no_save=True)
controller = PluginRuntimeController(
manifest, discover_plugin_components(manifest, discovery), False, ''
)
scope = InstallationBinding(
instance_uuid='fixture-instance', workspace_uuid='fixture-workspace',
placement_generation=1, installation_uuid='fixture-installation',
runtime_revision=1, artifact_digest='0' * 64,
)
state = SimpleNamespace(rows={}, store={}, calls=[], response=None)
ready = asyncio.get_running_loop().create_future()
async def connected(ws):
worker = PluginRuntimeHandler(WebSocketConnection(ws), controller.initialize)
controller.handler = worker
worker.plugin_container = controller.plugin_container
ready.set_result(worker)
await worker.run()
async with websockets.serve(connected, '127.0.0.1', 0) as server:
async with websockets.connect(
f'ws://127.0.0.1:{server.sockets[0].getsockname()[1]}'
) as ws:
host = Handler(WebSocketConnection(ws), file_storage_dir=tmp_path / 'host')
@host.action(P.GET_PLUGIN_STORAGE_KEYS)
async def keys(data):
return ActionResponse.success({'keys': list(state.store)})
@host.action(P.GET_PLUGIN_STORAGE)
async def get(data):
return ActionResponse.success({'value_base64': state.store[data['key']]})
@host.action(P.SET_PLUGIN_STORAGE)
async def put(data):
state.store[data['key']] = data['value_base64']
return ActionResponse.success({})
@host.action(P.GET_KNOWLEDEGE_FILE_STREAM)
async def file_stream(data):
# Empty external text currently falls back to the internal parser.
assert data['storage_path'] == 'fixture-unused'
key = await host.send_file(b'', 'txt', action_context=scope)
return ActionResponse.success({'file_key': key})
@host.action(P.VECTOR_DELETE)
async def delete(data):
assert host.current_action_context == scope
state.calls.append(data)
if state.response is not None:
return state.response
# Authoritative, file/collection-scoped removal; counts real fixture rows.
targets = [key for key, file_id in state.rows.items()
if key[0] == data['collection_id'] and file_id in data['file_ids']]
for key in targets:
del state.rows[key]
return ActionResponse.success({'count': len(targets)})
task = asyncio.create_task(host.run())
worker = await ready
async def call(action, data):
return await host.call_action(action, data, action_context=scope, timeout=5)
state.call = call
state.controller = controller
try:
await call(R.INITIALIZE_PLUGIN, {'plugin_settings': {
'enabled': True, 'priority': 0, 'plugin_config': {},
}})
yield state
finally:
await host.close()
await worker.close()
task.cancel()
await asyncio.gather(task, return_exceptions=True)
await controller.cleanup_instances()
class HostRowFixture:
"""Core-compatible confirmation gate; simulated Host transaction outcome."""
present = True
async def delete(self, boundary, *, fail_commit=False):
result = await boundary.call(R.DELETE_DOCUMENT, {'kb_id': 'kb', 'document_id': 'doc'})
if result['success'] is not True:
raise RuntimeError('engine did not confirm deletion')
if fail_commit:
raise RuntimeError('fixture Host commit failed')
self.present = False
@pytest.mark.asyncio
async def test_empty_parsed_ingest_then_zero_vector_delete_confirms_host_removal(boundary):
result = await boundary.call(R.INGEST_DOCUMENT, {'context': {
'knowledge_base_id': 'kb', 'creation_settings': {},
'file_object': {'metadata': {'filename': 'empty.txt', 'file_size': 0,
'mime_type': 'text/plain', 'document_id': 'doc', 'knowledge_base_id': 'kb'},
'storage_path': 'fixture-unused'},
'parsed_content': {'text': ''},
}})
assert result['status'] == 'completed'
assert result['document_id'] == 'doc'
assert result['chunks_created'] == 0
assert not boundary.rows
row = HostRowFixture()
await row.delete(boundary)
assert not row.present
event = (await boundary.controller.plugin_container.plugin_instance.telemetry.snapshot())['recent']['delete'][0]
assert event['status'] == 'completed'
assert event['deleted'] is True
assert event['vectors_deleted'] == 0
assert boundary.calls == [{'collection_id': 'kb', 'file_ids': ['doc'], 'filters': None}]
@pytest.mark.asyncio
async def test_retry_after_vector_delete_and_failed_host_commit_is_idempotent(boundary):
boundary.rows.update({('kb', 'chunk'): 'doc', ('kb', 'other'): 'other-doc',
('other-kb', 'chunk'): 'doc'})
row = HostRowFixture()
with pytest.raises(RuntimeError, match='fixture Host commit failed'):
await row.delete(boundary, fail_commit=True)
assert row.present
assert boundary.rows == {('kb', 'other'): 'other-doc', ('other-kb', 'chunk'): 'doc'}
await row.delete(boundary)
assert not row.present
# Another authoritative no-op delete must also confirm through SDK serialization.
assert await boundary.call(R.DELETE_DOCUMENT, {'kb_id': 'kb', 'document_id': 'doc'}) == {'success': True}
assert len(boundary.calls) == 3
@pytest.mark.asyncio
@pytest.mark.parametrize('count', [0, 1, 3])
async def test_nonnegative_integer_acknowledgement_confirms(boundary, count):
boundary.response = ActionResponse.success({'count': count})
assert await boundary.call(R.DELETE_DOCUMENT, {'kb_id': 'kb', 'document_id': 'doc'}) == {'success': True}
@pytest.mark.asyncio
@pytest.mark.parametrize('count', [-1, None, False, True, 0.0, 1.5, '0', '1', [], {}])
async def test_malformed_count_never_confirms_and_fences_mutations(boundary, count):
boundary.response = ActionResponse.success({'count': count})
row = HostRowFixture()
with pytest.raises(ActionCallError, match='nonnegative integer'):
await row.delete(boundary)
assert row.present
boundary.response = None
with pytest.raises(ActionCallError, match='fenced'):
await row.delete(boundary)
assert len(boundary.calls) == 1
@pytest.mark.asyncio
@pytest.mark.parametrize('response, message', [
(ActionResponse.success({}), 'count'),
(ActionResponse.error('fixture VectorStoreError'), 'fixture VectorStoreError'),
(ActionResponse.error('fixture timeout; unknown remote outcome'), 'fixture timeout'),
])
async def test_unconfirmed_host_result_propagates_and_preserves_row(boundary, response, message):
boundary.response = response
row = HostRowFixture()
with pytest.raises(ActionCallError, match=message):
await row.delete(boundary)
assert row.present
boundary.response = None
with pytest.raises(ActionCallError, match='fenced'):
await row.delete(boundary)
assert len(boundary.calls) == 1
@@ -0,0 +1,70 @@
"""LangRAG mutation ordering against a detached, simulated Host vector commit."""
import asyncio
import pytest
from langbot_plugin.api.entities.builtin.rag import IngestionContext, FileMetadata, FileObject, ParseResult
from test_shared_state import load_plugin, bind, StorageFixture
def context():
return IngestionContext(knowledge_base_id='kb', file_object=FileObject(
metadata=FileMetadata(filename='file.txt', mime_type='text/plain', file_size=4, document_id='doc', knowledge_base_id='kb'),
storage_path='unused'), parsed_content=ParseResult(text='test content'),
creation_settings={'embedding_model_uuid': 'fixture-model'})
@pytest.mark.asyncio
async def test_cancelled_ingest_cannot_commit_after_delete():
pc, ec = load_plugin('LangRAG')
plugin = bind(pc, StorageFixture())
await plugin.initialize()
entered, release = asyncio.Event(), asyncio.Event()
rows = []
async def embed(*args):
return [[1.0]]
async def upsert(**kwargs):
async def commit():
entered.set()
await release.wait()
rows.extend(kwargs['ids'])
await asyncio.shield(asyncio.create_task(commit()))
async def delete(**kwargs):
count = len(rows)
rows.clear()
return count
plugin.invoke_embedding, plugin.vector_upsert, plugin.vector_delete = embed, upsert, delete
engine = ec()
engine.plugin = plugin
task = asyncio.create_task(engine.ingest(context()))
await entered.wait()
task.cancel()
await asyncio.sleep(0)
task.cancel()
deleted = asyncio.create_task(engine.delete_document('kb', 'doc'))
try:
await asyncio.sleep(.01)
with pytest.raises(RuntimeError, match='fenced'):
await deleted
finally:
release.set()
await asyncio.gather(task, deleted, return_exceptions=True)
# A detached Host may still commit; fenced state cannot claim safe deletion.
with pytest.raises(RuntimeError, match='fenced'):
await engine.delete_document('kb', 'doc')
@pytest.mark.asyncio
async def test_ambiguous_vector_failure_fences_future_mutations():
pc, ec = load_plugin('LangRAG')
plugin = bind(pc, StorageFixture())
await plugin.initialize()
async def embed(*args):
return [[1.0]]
async def upsert(**kwargs):
raise TimeoutError('fixture unknown remote commit outcome')
plugin.invoke_embedding, plugin.vector_upsert = embed, upsert
engine = ec()
engine.plugin = plugin
result = await engine.ingest(context())
assert result.status.value == 'failed'
with pytest.raises(RuntimeError, match='fenced'):
await engine.ingest(context())
@@ -0,0 +1,107 @@
"""Regression fixtures are local; no external model/parser/provider requests."""
import asyncio
import threading
import pytest
from test_shared_state import load_plugin, bind, StorageFixture, CONNECTORS
@pytest.mark.asyncio
async def test_offload_slot_is_held_through_repeated_cancellation():
pc, _ = load_plugin('LangRAG')
plugin = bind(pc, StorageFixture())
assert hasattr(plugin, 'offload'), 'CPU parsing/chunking needs installation bounded offload'
entered = threading.Event()
release = threading.Event()
count = 0
lock = threading.Lock()
def job():
nonlocal count
with lock:
count += 1
if count == 2:
entered.set()
release.wait(2)
return 'done'
tasks = [asyncio.create_task(plugin.offload.run(job)) for _ in range(5)]
try:
for _ in range(100):
if entered.is_set():
break
await asyncio.sleep(.005)
assert entered.is_set()
tasks[0].cancel()
await asyncio.sleep(.01)
tasks[0].cancel()
await asyncio.sleep(.01)
assert count == 2, 'Cancelled waiters must not release running thread slots'
release.set()
results = await asyncio.gather(*tasks, return_exceptions=True)
assert isinstance(results[0], asyncio.CancelledError)
assert results[1:] == ['done'] * 4
finally:
release.set()
await asyncio.gather(*tasks, return_exceptions=True)
@pytest.mark.asyncio
async def test_parser_rejects_oversized_input_before_decode():
load_plugin('LangRAG')
from components.knowledge_engine.parser import FileParser
with pytest.raises(ValueError, match='limit'):
await FileParser().parse(b'x' * (16 * 1024 * 1024 + 1), 'doc.txt')
@pytest.mark.asyncio
@pytest.mark.parametrize('name', CONNECTORS)
async def test_connector_storage_failure_is_not_missing_and_refuses_mutations(name):
pc, ec = load_plugin(name)
store = StorageFixture()
engine = ec()
engine.plugin = bind(pc, store)
store.fail = True
# Unreadable fence state is "unknown", never "unfenced": the mutation is
# refused with the Host failure instead of being dispatched.
with pytest.raises(RuntimeError, match='offline'):
await engine.delete_document('kb', 'doc')
with pytest.raises(RuntimeError, match='offline'):
await engine.on_knowledge_base_create('kb', {})
assert not store.data
# The unreadable state is not cached either: once Host storage answers, the
# same mutation is retried and proceeds rather than staying refused.
store.fail = False
await engine.on_knowledge_base_create('kb', {})
assert store.data
@pytest.mark.asyncio
async def test_telemetry_cancellation_clear_and_failure_fencing():
pc, _ = load_plugin('LangRAG')
store = StorageFixture()
plugin = bind(pc, store)
await plugin.initialize()
store.release.clear()
task = asyncio.create_task(plugin.telemetry.record_delete(collection_id='kb', document_id='old', status='completed', duration_ms=1))
await store.started.wait()
task.cancel()
clear = asyncio.create_task(plugin.telemetry.clear())
await asyncio.sleep(.01)
with pytest.raises(RuntimeError, match='fenced'):
await clear
store.release.set()
await asyncio.gather(task, return_exceptions=True)
other = bind(pc, store)
await other.initialize()
# A fresh reader may see the detached commit, but a failed clear cannot
# acknowledge deletion or introduce a later event.
assert all(event['document_id'] != 'new' for event in (await other.telemetry.snapshot())['recent']['delete'])
store.fail = True
with pytest.raises(RuntimeError, match='fenced'):
await plugin.telemetry.record_delete(collection_id='kb', document_id='new', status='completed', duration_ms=1)
with pytest.raises(RuntimeError, match='fenced'):
await plugin.telemetry.snapshot()
store.fail = False
with pytest.raises(RuntimeError, match='fenced'):
await plugin.telemetry.clear()
assert all(event['document_id'] != 'new' for event in (await other.telemetry.snapshot())['recent']['delete'])
@@ -0,0 +1,149 @@
"""B1 admission regressions: real SDK/strategies, explicit local Host fixtures.
No live model, vector service, production Host, or RPC transport is exercised.
Large chunk settings keep the real chunkers deterministic and inexpensive.
"""
import sys
from unittest.mock import AsyncMock, Mock
import pytest
from langbot_plugin.api.entities.builtin.provider.message import Message
from langbot_plugin.api.entities.builtin.rag import ParseResult, TextSection
from test_langrag_mutations import context
from test_shared_state import StorageFixture, bind, load_plugin
LIMIT = 4 * 1024 * 1024
STRATEGIES = ['chunk', 'parent_child', 'qa']
def sized_text(size, character):
width = len(character.encode('utf-8'))
text = character * (size // width) + 'x' * (size % width)
assert len(text.encode('utf-8')) == size
return text
async def engine_fixture(monkeypatch, strategy):
pc, ec = load_plugin('LangRAG')
plugin = bind(pc, StorageFixture())
await plugin.initialize()
plugin.invoke_embedding = AsyncMock(side_effect=lambda model, texts: [[1.0] for _ in texts])
plugin.vector_upsert = AsyncMock()
plugin.invoke_llm = AsyncMock(return_value=Message(
role='assistant', content='[{"q": "fixture question", "a": "fixture answer"}]'))
# Spy on the actual offloader; accepted requests still run real chunkers.
plugin.offload.run = AsyncMock(wraps=plugin.offload.run)
plugin.get_knowledge_file_stream = AsyncMock(return_value=b'fixture file')
engine = ec()
engine.plugin = plugin
module = sys.modules[ec.__module__]
select_strategy = Mock(wraps=module.get_strategy)
monkeypatch.setattr(module, 'get_strategy', select_strategy)
ctx = context()
ctx.creation_settings.update({
'index_type': strategy,
'chunk_size': LIMIT + 16,
'parent_chunk_size': LIMIT + 16,
'child_chunk_size': LIMIT + 16,
'qa_llm_model_uuid': 'fixture-llm',
})
return engine, ctx, select_strategy, module
def assert_rejected(result, plugin, select_strategy):
assert result.status.value == 'failed'
assert '4 MiB' in result.error_message
assert 'UTF-8' in result.error_message
assert result.chunks_created == 0
select_strategy.assert_not_called()
plugin.offload.run.assert_not_called()
plugin.invoke_embedding.assert_not_called()
plugin.vector_upsert.assert_not_called()
plugin.invoke_llm.assert_not_called()
def assert_accepted(result, plugin, select_strategy, strategy, expected_texts):
assert result.status.value == 'completed', result.error_message
assert result.chunks_created == len(expected_texts)
select_strategy.assert_called_once_with(strategy)
assert plugin.offload.run.await_count > 0
plugin.invoke_embedding.assert_awaited_once()
plugin.vector_upsert.assert_awaited_once()
stored = plugin.vector_upsert.call_args.kwargs
assert len(stored['ids']) == len(expected_texts)
if strategy == 'qa':
assert plugin.invoke_llm.await_count == len(expected_texts)
assert [m['source_chunk'] for m in stored['metadata']] == expected_texts
else:
plugin.invoke_llm.assert_not_called()
assert stored['documents'] == expected_texts
@pytest.mark.asyncio
@pytest.mark.parametrize('strategy', STRATEGIES)
@pytest.mark.parametrize('character', ['x', '界'], ids=['ascii', 'utf8'])
@pytest.mark.parametrize('shape', ['flat', 'single_section', 'aggregate_sections'])
@pytest.mark.parametrize('delta', [-1, 0, 1], ids=['below', 'boundary', 'over'])
async def test_parsed_utf8_budget(monkeypatch, strategy, character, shape, delta):
engine, ctx, select_strategy, _ = await engine_fixture(monkeypatch, strategy)
payload = sized_text(LIMIT + delta, character)
if shape == 'flat':
ctx.parsed_content = ParseResult(text=payload)
expected = [payload]
else:
expected = ([payload] if shape == 'single_section' else
[payload[:len(payload) // 2], payload[len(payload) // 2:]])
if shape == 'aggregate_sections':
assert all(len(part.encode('utf-8')) < LIMIT for part in expected)
ctx.parsed_content = ParseResult(
text='summary', sections=[TextSection(content=part) for part in expected])
result = await engine.ingest(ctx)
if delta > 0:
assert_rejected(result, engine.plugin, select_strategy)
else:
assert_accepted(result, engine.plugin, select_strategy, strategy, expected)
engine.plugin.get_knowledge_file_stream.assert_not_called()
@pytest.mark.asyncio
@pytest.mark.parametrize('strategy', STRATEGIES)
@pytest.mark.parametrize('delta', [0, 1], ids=['boundary', 'over'])
async def test_flat_budget_still_applies_with_sections(monkeypatch, strategy, delta):
engine, ctx, select_strategy, _ = await engine_fixture(monkeypatch, strategy)
# Flat text and section content are alternative representations, not additive.
# Still bound the flat text even when the strategy consumes small sections.
ctx.parsed_content = ParseResult(
text=sized_text(LIMIT + delta, '界'),
sections=[TextSection(content='small section')])
result = await engine.ingest(ctx)
if delta > 0:
assert_rejected(result, engine.plugin, select_strategy)
else:
assert_accepted(result, engine.plugin, select_strategy, strategy, ['small section'])
@pytest.mark.asyncio
@pytest.mark.parametrize('strategy', STRATEGIES)
@pytest.mark.parametrize('delta', [-1, 0, 1], ids=['below', 'boundary', 'over'])
async def test_internal_parser_output_uses_same_utf8_budget(monkeypatch, strategy, delta):
engine, ctx, select_strategy, module = await engine_fixture(monkeypatch, strategy)
payload = sized_text(LIMIT + delta, '界')
ctx.parsed_content = None
# Explicit parser-output fixture: parsing itself necessarily precedes admission.
parse = AsyncMock(return_value=payload)
monkeypatch.setattr(module.FileParser, 'parse', parse)
result = await engine.ingest(ctx)
parse.assert_awaited_once_with(b'fixture file', 'file.txt')
engine.plugin.get_knowledge_file_stream.assert_awaited_once_with('unused')
if delta > 0:
assert_rejected(result, engine.plugin, select_strategy)
else:
assert_accepted(result, engine.plugin, select_strategy, strategy, [payload])
@@ -0,0 +1,578 @@
"""RAGFlow connector: fail-closed fences and upstream-bound document mappings.
Host storage and the provider are local fixtures; no live deployment is
contacted. The behaviour asserted here is scoped to RAGFlow, so it lives in its
own file rather than in the shared per-connector fixtures.
"""
import asyncio
import json
import httpx
import pytest
from test_shared_state import (
StorageFixture,
bind,
configuration,
ingest_context,
load_plugin,
)
from langbot_plugin.api.entities.builtin.rag import DocumentStatus
FENCE_KEY_PREFIX = 'ke.fence.v1.'
def fence_records(store):
"""Persisted fence markers that still require reconciliation."""
return [json.loads(value) for key, value in store.data.items()
if key.startswith(FENCE_KEY_PREFIX) and json.loads(value) is not None]
def provider(monkeypatch, handler):
"""Route the connector's HTTP through a recording mock transport."""
calls = []
async def service(request):
calls.append(request)
response = handler(request)
if asyncio.iscoroutine(response):
response = await response
return response
client = httpx.AsyncClient
monkeypatch.setattr(
httpx, 'AsyncClient',
lambda **kw: client(transport=httpx.MockTransport(service), **kw),
)
return calls
def acknowledge(request):
if request.method == 'POST' and request.url.path.endswith('/documents'):
return httpx.Response(200, json={'code': 0, 'data': [{'id': 'upstream-1'}]})
if request.method == 'DELETE':
return httpx.Response(200, json={'code': 0})
return httpx.Response(200, json={'code': 0, 'data': []})
def engine_with(monkeypatch, store, handler):
calls = provider(monkeypatch, handler)
pc, ec = load_plugin('RAGFlowConnector')
engine = ec()
plugin = bind(pc, store)
async def read_file(path):
return b'fixture file'
plugin.get_knowledge_file_stream = read_file
engine.plugin = plugin
return engine, calls
def created_mapping(upstream_target, dataset_id='A'):
return {'upstream_id': 'upstream-1', 'dataset_id': dataset_id,
'api_base_url': upstream_target, 'status': 'created'}
@pytest.mark.asyncio
async def test_unreadable_fence_state_refuses_without_dispatch_and_recovers(monkeypatch):
store = StorageFixture()
engine, calls = engine_with(monkeypatch, store, acknowledge)
await engine._save_config('kb', configuration('A'))
await engine._save_document('kb', 'doc', created_mapping('https://fixture.invalid'))
store.fail = True
# An unreadable fence record is not the same as "no fences": the mutation is
# refused before anything is dispatched.
with pytest.raises(RuntimeError, match='Could not read persisted fences'):
await engine.delete_document('kb', 'doc')
assert calls == []
store.fail = False
# The failed read was not cached, so the next attempt re-reads and proceeds.
assert await engine.delete_document('kb', 'doc') is True
assert [request.method for request in calls] == ['DELETE']
@pytest.mark.asyncio
async def test_cancelled_upload_dispatch_fences_and_keeps_the_pending_mapping(monkeypatch):
dispatched = asyncio.Event()
async def handler(request):
if request.method == 'POST' and request.url.path.endswith('/documents'):
dispatched.set()
await asyncio.sleep(30)
return acknowledge(request)
store = StorageFixture()
engine, calls = engine_with(monkeypatch, store, handler)
task = asyncio.create_task(engine.ingest(ingest_context(configuration('A'))))
await asyncio.wait_for(dispatched.wait(), 5)
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
# The upload may have committed upstream, so the knowledge base stays fenced
# until an operator reconciles it, in process and in durable storage.
assert engine._state.is_fenced(engine._state.binding(engine.plugin), 'kb')
assert [record['kb_id'] for record in fence_records(store)] == ['kb']
assert [request.method for request in calls] == ['POST']
mapping = await engine._load_document('kb', 'local-doc')
assert mapping['status'] == 'pending', 'a cancelled upload must not advance the mapping'
assert mapping['upstream_id'] == ''
@pytest.mark.asyncio
async def test_cancelled_delete_dispatch_fences_and_keeps_the_created_mapping(monkeypatch):
dispatched = asyncio.Event()
async def handler(request):
if request.method == 'DELETE':
dispatched.set()
await asyncio.sleep(30)
return acknowledge(request)
store = StorageFixture()
engine, calls = engine_with(monkeypatch, store, handler)
await engine._save_config('kb', configuration('A'))
await engine._save_document('kb', 'doc', created_mapping('https://fixture.invalid'))
task = asyncio.create_task(engine.delete_document('kb', 'doc'))
await asyncio.wait_for(dispatched.wait(), 5)
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
assert engine._state.is_fenced(engine._state.binding(engine.plugin), 'kb')
assert [record['kb_id'] for record in fence_records(store)] == ['kb']
assert [request.method for request in calls] == ['DELETE']
mapping = await engine._load_document('kb', 'doc')
assert mapping['status'] == 'created', 'an unacknowledged delete must not tombstone the mapping'
@pytest.mark.asyncio
async def test_upload_rejection_leaves_no_fence(monkeypatch):
async def handler(request):
if request.method == 'POST' and request.url.path.endswith('/documents'):
return httpx.Response(400, text='rejected')
return acknowledge(request)
store = StorageFixture()
engine, calls = engine_with(monkeypatch, store, handler)
result = await engine.ingest(ingest_context(configuration('A')))
assert result.status == DocumentStatus.FAILED
# The provider refused the request, which is an unambiguous answer: the
# pre-dispatch intent was cleared again and the knowledge base is usable.
assert not engine._state.is_fenced(engine._state.binding(engine.plugin), 'kb')
assert fence_records(store) == []
await engine.on_knowledge_base_create('kb', configuration('A'))
@pytest.mark.asyncio
async def test_mapping_of_another_upstream_is_refused_before_dispatch(monkeypatch):
store = StorageFixture()
engine, calls = engine_with(monkeypatch, store, acknowledge)
await engine._save_config('kb', configuration('A'))
await engine._save_document('kb', 'doc', created_mapping('https://other.invalid'))
with pytest.raises(RuntimeError, match='Upstream target changed'):
await engine.delete_document('kb', 'doc')
assert calls == []
@pytest.mark.asyncio
async def test_mapping_without_a_recorded_upstream_is_refused_before_dispatch(monkeypatch):
store = StorageFixture()
engine, calls = engine_with(monkeypatch, store, acknowledge)
await engine._save_config('kb', configuration('A'))
# Historical mapping, written before the upstream target was recorded.
await engine._save_document('kb', 'doc', {'upstream_id': 'upstream-1', 'dataset_id': 'A',
'status': 'created'})
with pytest.raises(RuntimeError, match='Upstream target changed'):
await engine.delete_document('kb', 'doc')
assert calls == []
@pytest.mark.asyncio
async def test_equivalent_configuration_of_the_recorded_upstream_is_accepted(monkeypatch):
store = StorageFixture()
engine, calls = engine_with(monkeypatch, store, acknowledge)
config = configuration('A')
result = await engine.ingest(ingest_context(config))
assert result.status == DocumentStatus.PROCESSING
mapping = await engine._load_document('kb', 'local-doc')
assert mapping['api_base_url'] == 'https://fixture.invalid', 'the canonical target is recorded'
# Casing, a default port and a trailing slash never distinguish deployments.
await engine._save_config('kb', {**config, 'api_base_url': 'HTTPS://FIXTURE.INVALID:443/'})
assert await engine.delete_document('kb', 'local-doc') is True
assert [request.method for request in calls] == ['POST', 'POST', 'DELETE']
def fence_key_fault(shared_state, message):
def fault(key):
return RuntimeError(message) if key.startswith(shared_state.FENCE_KEY_PREFIX) else None
return fault
@pytest.mark.asyncio
async def test_unpersistable_fence_aborts_the_upload_before_the_request(monkeypatch):
store = StorageFixture()
engine, calls = engine_with(monkeypatch, store, acknowledge)
from components import shared_state
store.write_fault = fence_key_fault(shared_state, 'fixture fence write rejected')
# The pre-written protection cannot be persisted, so the upload must not be
# dispatched at all.
with pytest.raises(shared_state.FencePersistError, match='Could not persist fence'):
await engine.ingest(ingest_context(configuration('A')))
assert calls == []
# A mutation that was never dispatched leaves no fence behind...
assert not engine._state.is_fenced(engine._state.binding(engine.plugin), 'kb')
assert fence_records(store) == []
# ...only the durable pre-dispatch intent it had already recorded.
assert (await engine._load_document('kb', 'local-doc'))['status'] == 'pending'
@pytest.mark.asyncio
async def test_unpersistable_fence_aborts_the_delete_before_the_request(monkeypatch):
store = StorageFixture()
engine, calls = engine_with(monkeypatch, store, acknowledge)
from components import shared_state
await engine._save_config('kb', configuration('A'))
await engine._save_document('kb', 'doc', created_mapping('https://fixture.invalid'))
store.write_fault = fence_key_fault(shared_state, 'fixture fence write rejected')
with pytest.raises(shared_state.FencePersistError, match='Could not persist fence'):
await engine.delete_document('kb', 'doc')
assert calls == []
assert not engine._state.is_fenced(engine._state.binding(engine.plugin), 'kb')
assert fence_records(store) == []
# The delete was never dispatched, so its mapping is still the recorded one.
assert (await engine._load_document('kb', 'doc'))['status'] == 'created'
@pytest.mark.asyncio
async def test_upload_response_that_fails_validation_keeps_the_fence(monkeypatch):
async def handler(request):
if request.method == 'POST' and request.url.path.endswith('/documents'):
# The provider answered 200 with a body the connector cannot decode:
# the upload may have committed, so the outcome is unresolved.
return httpx.Response(200, text='not json')
return acknowledge(request)
store = StorageFixture()
engine, calls = engine_with(monkeypatch, store, handler)
result = await engine.ingest(ingest_context(configuration('A')))
assert result.status == DocumentStatus.FAILED
# The whole mutation — response validation included — is inside the fence, so
# the unparsable answer keeps the knowledge base fenced.
assert engine._state.is_fenced(engine._state.binding(engine.plugin), 'kb')
assert [record['kb_id'] for record in fence_records(store)] == ['kb']
assert [request.method for request in calls] == ['POST']
# The next mutation for this knowledge base is refused rather than dispatched
# over the unresolved upload.
with pytest.raises(RuntimeError, match='fenced'):
await engine.delete_document('kb', 'local-doc')
@pytest.mark.asyncio
async def test_unresolved_graphrag_keeps_the_fence_and_blocks_raptor(monkeypatch):
async def handler(request):
if request.url.path.endswith('/run_graphrag'):
raise httpx.ReadTimeout('fixture graphrag timeout')
return acknowledge(request)
store = StorageFixture()
engine, calls = engine_with(monkeypatch, store, handler)
config = {**configuration('A'), 'auto_graphrag': True, 'auto_raptor': True}
result = await engine.ingest(ingest_context(config))
assert result.status == DocumentStatus.FAILED
# The unknown GraphRAG outcome is unresolved: the chain stops there instead of
# running RAPTOR, whose success would have cleared the shared fence.
paths = [request.url.path for request in calls]
assert paths == [
'/api/v1/datasets/A/documents',
'/api/v1/datasets/A/chunks',
'/api/v1/datasets/A/run_graphrag',
], 'the chain stops at the unresolved GraphRAG trigger'
assert not any(path.endswith('/run_raptor') for path in paths)
assert engine._state.is_fenced(engine._state.binding(engine.plugin), 'kb')
assert [record['kb_id'] for record in fence_records(store)] == ['kb']
with pytest.raises(RuntimeError, match='fenced'):
await engine.on_knowledge_base_create('kb', configuration('A'))
@pytest.mark.asyncio
async def test_delete_cancellation_after_ack_before_the_tombstone_keeps_the_fence(monkeypatch):
store = StorageFixture()
engine, calls = engine_with(monkeypatch, store, acknowledge)
await engine._save_config('kb', configuration('A'))
await engine._save_document('kb', 'doc', created_mapping('https://fixture.invalid'))
# The documented window: the provider has acknowledged the delete, the Host
# tombstone has not been written yet.
entered = asyncio.Event()
release = asyncio.Event()
record = engine._save_document
async def gated_record(kb_id, host_document_id, value):
if value.get('status') == 'deleted':
entered.set()
await release.wait()
return await record(kb_id, host_document_id, value)
monkeypatch.setattr(engine, '_save_document', gated_record)
task = asyncio.create_task(engine.delete_document('kb', 'doc'))
await asyncio.wait_for(entered.wait(), 5)
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
release.set()
# The delete was acknowledged upstream but its Host record does not exist, so
# the protection must not have been released: a retry would replay the delete.
assert (await engine._load_document('kb', 'doc'))['status'] == 'created'
assert engine._state.is_fenced(engine._state.binding(engine.plugin), 'kb')
assert [record['kb_id'] for record in fence_records(store)] == ['kb']
with pytest.raises(RuntimeError, match='fenced'):
await engine.delete_document('kb', 'doc')
assert [request.method for request in calls] == ['DELETE']
@pytest.mark.asyncio
async def test_later_success_never_clears_an_unresolved_fence(monkeypatch):
store = StorageFixture()
engine, calls = engine_with(monkeypatch, store, acknowledge)
from components import shared_state
plugin = engine.plugin
await engine._state.fence(plugin, 'kb', 'earlier upload outcome unknown')
ran = []
async def later_mutation():
ran.append(1)
return 'dispatched'
# The later mutation is refused instead of running and releasing the earlier
# operation's fence as if its own success had resolved it.
with pytest.raises(shared_state.FencedKnowledgeBaseError, match='unresolved'):
await engine._state.dispatched(plugin, 'kb', later_mutation, 'later mutation')
assert ran == []
assert engine._state.is_fenced(engine._state.binding(plugin), 'kb')
assert [record['kb_id'] for record in fence_records(store)] == ['kb']
def collection_context(config, collection_id):
"""An ingestion context whose collection id is not the knowledge-base id."""
context = ingest_context(config)
context.collection_id = collection_id
return context
@pytest.mark.asyncio
async def test_differing_collection_and_knowledge_base_ids_share_one_identity(monkeypatch):
store = StorageFixture()
engine, calls = engine_with(monkeypatch, store, acknowledge)
from components import shared_state
plugin = engine.plugin
binding = engine._state.binding(plugin)
# The fence is taken for the knowledge base the Host names; an ingestion
# context that carries a different collection id must observe the same one.
await engine._state.fence(plugin, 'kb', 'earlier upload outcome unknown')
context = collection_context(configuration('A'), 'collection-1')
with pytest.raises(shared_state.FencedKnowledgeBaseError):
await engine.ingest(context)
assert calls == [], 'a fenced knowledge base dispatches nothing'
await engine._state.clear_fence(plugin, 'kb')
result = await engine.ingest(context)
assert result.status == DocumentStatus.PROCESSING
# One identity for the mapping too: it is written under the knowledge-base
# key, so the deletion that names the knowledge base finds it.
assert (await engine._load_document('kb', 'local-doc'))['upstream_id'] == 'upstream-1'
assert await engine.delete_document('kb', 'local-doc') is True
assert [request.method for request in calls] == ['POST', 'POST', 'DELETE']
assert (await engine._load_document('kb', 'local-doc'))['status'] == 'deleted'
assert not engine._state.is_fenced(binding, 'kb')
@pytest.mark.asyncio
async def test_delete_under_another_knowledge_base_never_adopts_its_mapping(monkeypatch):
store = StorageFixture()
engine, calls = engine_with(monkeypatch, store, acknowledge)
# Knowledge base A's mapping and configuration name the upstream document;
# knowledge base B has neither.
cfg = configuration('A')
await engine._save_config('A', cfg)
await engine._save_document('A', 'shared-doc', created_mapping('https://fixture.invalid'))
# Deleting under B must not resolve, copy or tombstone A's record: the
# operation is refused with nothing dispatched and nothing written under B.
assert await engine.delete_document('B', 'shared-doc') is False
assert calls == []
assert engine._document_key('B', 'shared-doc') not in store.data
assert engine._key('B') not in store.data
assert (await engine._load_document('A', 'shared-doc'))['status'] == 'created'
# A's own delete still reaches the provider: B never consumed its record.
assert await engine.delete_document('A', 'shared-doc') is True
assert [request.method for request in calls] == ['DELETE']
@pytest.mark.asyncio
async def test_pending_record_of_another_knowledge_base_does_not_block_ingest(monkeypatch):
store = StorageFixture()
engine, calls = engine_with(monkeypatch, store, acknowledge)
# A pending record under knowledge base A is A's upload intent, not B's.
await engine._save_document('A', 'local-doc',
{'upstream_id': '', 'dataset_id': 'A',
'api_base_url': 'https://fixture.invalid', 'status': 'pending'})
before = store.data[engine._document_key('A', 'local-doc')]
context = ingest_context(configuration('B'))
context.knowledge_base_id = 'B'
result = await engine.ingest(context)
assert result.status == DocumentStatus.PROCESSING
# B recorded only its own mapping, and A's pending record is untouched: B
# neither treated A's intent as its own nor wrote under A.
assert (await engine._load_document('B', 'local-doc'))['status'] == 'created'
assert (await engine._load_document('A', 'local-doc'))['status'] == 'pending'
assert store.data[engine._document_key('A', 'local-doc')] == before
assert [request.method for request in calls] == ['POST', 'POST']
INGESTION_STEPS = [
pytest.param('/documents', {}, id='upload'),
pytest.param('/chunks', {}, id='parse'),
pytest.param('/run_graphrag', {'auto_graphrag': True}, id='graphrag'),
pytest.param('/run_raptor', {'auto_raptor': True}, id='raptor'),
]
@pytest.mark.asyncio
@pytest.mark.parametrize('status, fenced', [(503, True), (400, False)])
@pytest.mark.parametrize('suffix, extra', INGESTION_STEPS)
async def test_ingestion_failure_classification_on_every_step(
monkeypatch, suffix, extra, status, fenced):
async def handler(request):
if request.url.path.endswith(suffix):
return httpx.Response(status, text='fixture answer')
return acknowledge(request)
store = StorageFixture()
engine, calls = engine_with(monkeypatch, store, handler)
result = await engine.ingest(ingest_context({**configuration('A'), **extra}))
assert result.status == DocumentStatus.FAILED
# A gateway failure is not proof that the request was not applied, so the
# knowledge base stays fenced; a rejection is a resolved answer.
assert engine._state.is_fenced(engine._state.binding(engine.plugin), 'kb') is fenced
assert bool(fence_records(store)) is fenced
if fenced:
# The next mutation is refused instead of being dispatched over the
# unresolved one.
with pytest.raises(RuntimeError, match='fenced'):
await engine.delete_document('kb', 'local-doc')
else:
# The rejection was resolved, so the knowledge base is usable again.
await engine.delete_document('kb', 'local-doc')
@pytest.mark.asyncio
@pytest.mark.parametrize('status, fenced', [(503, True), (400, False)])
async def test_delete_failure_classification(monkeypatch, status, fenced):
async def handler(request):
if request.method == 'DELETE':
return httpx.Response(status, text='fixture answer')
return acknowledge(request)
store = StorageFixture()
engine, calls = engine_with(monkeypatch, store, handler)
await engine._save_config('kb', configuration('A'))
await engine._save_document('kb', 'doc', created_mapping('https://fixture.invalid'))
assert await engine.delete_document('kb', 'doc') is False
assert engine._state.is_fenced(engine._state.binding(engine.plugin), 'kb') is fenced
assert bool(fence_records(store)) is fenced
assert (await engine._load_document('kb', 'doc'))['status'] == 'created'
if fenced:
with pytest.raises(RuntimeError, match='fenced'):
await engine.delete_document('kb', 'doc')
assert [request.method for request in calls] == ['DELETE']
else:
# The rejection released the fence, so the delete may be retried.
assert await engine.delete_document('kb', 'doc') is False
@pytest.mark.asyncio
async def test_fence_snapshot_read_before_a_clear_is_discarded(monkeypatch):
store = StorageFixture()
engine, calls = engine_with(monkeypatch, store, acknowledge)
from components import shared_state
state, plugin = engine._state, engine.plugin
binding = state.binding(plugin)
await state.fence(plugin, 'kb', 'earlier upload outcome unknown')
# The fence is read once for the installation, and the read is answered with
# the marker that existed before the clear below lands.
store.gate_read(shared_state._kb_fence_key('kb'))
load = asyncio.create_task(state._load_fences(plugin, binding))
await asyncio.wait_for(store.gate_entered.wait(), 5)
await state.clear_fence(plugin, 'kb')
assert not state.is_fenced(binding, 'kb')
store.gate_release.set()
await load
# The snapshot taken before the clear is discarded instead of re-fencing the
# knowledge base the operator just cleared.
assert not state.is_fenced(binding, 'kb')
async def operation():
return 'ran'
assert await state.run(plugin, 'kb', operation) == 'ran'
@pytest.mark.asyncio
async def test_fence_state_is_read_once_per_installation(monkeypatch):
store = StorageFixture()
engine, calls = engine_with(monkeypatch, store, acknowledge)
from components import shared_state
state, plugin = engine._state, engine.plugin
# A persisted fence for another knowledge base of the same installation gives
# the first load a marker to read, and so a window inside that read.
await state.fence(plugin, 'other', 'earlier upload outcome unknown')
store.gate_read(shared_state._kb_fence_key('other'))
before = store.keys_reads
async def operation():
return 'ran'
first = asyncio.create_task(state.run(plugin, 'kb', operation))
await asyncio.wait_for(store.gate_entered.wait(), 5)
second = asyncio.create_task(state.run(plugin, 'kb-2', operation))
await asyncio.sleep(.05)
store.gate_release.set()
assert await asyncio.gather(first, second) == ['ran', 'ran']
# Both knowledge bases share one installation: the persisted fence state was
# read once for the pair, not once per knowledge base.
assert store.keys_reads - before == 1
@@ -0,0 +1,417 @@
"""Real SDK objects; storage/HTTP are explicit local fixtures, not live services."""
import asyncio
import importlib.util
import sys
from pathlib import Path
import httpx
import pytest
from langbot_plugin.api.entities.builtin.rag import (
DocumentStatus,
FileMetadata,
FileObject,
IngestionContext,
)
ROOT = Path(__file__).resolve().parents[1]
CONNECTORS = ['DifyDatasetsConnector', 'FastGPTConnector', 'RAGFlowConnector']
def load_plugin(name):
# Runtime uses one process per installation. Clear package names for this test loader.
for key in list(sys.modules):
if key == 'components' or key.startswith('components.'):
del sys.modules[key]
sys.path.insert(0, str(ROOT / name))
try:
spec = importlib.util.spec_from_file_location('candidate_main', ROOT / name / 'main.py')
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
from importlib import import_module
engine = import_module('components.knowledge_engine.' + ('langrag' if name == 'LangRAG' else 'engine'))
return getattr(mod, name), getattr(engine, name)
finally:
sys.path.pop(0)
class StorageFixture:
"""Explicit fixture for Host's installation-bound storage, not production storage."""
def __init__(self):
self.data = {}
self.fail = False
# Optional ``key -> exception`` fault injection for one durable write.
self.write_fault = None
self.started = asyncio.Event()
self.release = asyncio.Event()
self.release.set()
# Host-read instrumentation: how many key listings were served, and
# one-shot gates that suspend the next read of one key so a test can land
# a concurrent write inside that reader's window.
self.keys_reads = 0
self.gates = {}
self.gate_entered = asyncio.Event()
self.gate_release = asyncio.Event()
def gate_read(self, key):
"""Suspend the next ``get_plugin_storage(key)`` until ``gate_release``.
The gated reader captures the value it was answered with before it
blocks, so the caller can change that record while the read is in flight
exactly like a response computed before a concurrent write. Only the
first read of ``key`` is gated; later readers pass through.
"""
self.gate_entered = asyncio.Event()
self.gate_release = asyncio.Event()
self.gates[key] = self.gate_entered
async def get_plugin_storage_keys(self):
if self.fail:
raise RuntimeError('fixture storage offline')
self.keys_reads += 1
return list(self.data)
async def get_plugin_storage(self, key):
if self.fail:
raise RuntimeError('fixture storage offline')
value = self.data[key]
entered = self.gates.pop(key, None)
if entered is not None:
entered.set()
await self.gate_release.wait()
return value
async def set_plugin_storage(self, key, value):
# A detached Host commit survives cancellation of the caller, like real RPC.
async def commit():
self.started.set()
await self.release.wait()
if self.write_fault is not None:
fault = self.write_fault(key)
if fault is not None:
raise fault
if self.fail:
raise RuntimeError('fixture ambiguous commit')
self.data[key] = value
await asyncio.shield(asyncio.create_task(commit()))
def bind(plugin_cls, storage):
plugin = plugin_cls()
for name in ['get_plugin_storage_keys', 'get_plugin_storage', 'set_plugin_storage']:
setattr(plugin, name, getattr(storage, name))
return plugin
def configuration(marker):
return {'api_base_url': 'https://fixture.invalid', 'api_key': marker,
'dify_apikey': marker, 'dataset_id': marker, 'dataset_ids': marker}
@pytest.mark.asyncio
@pytest.mark.parametrize('name', CONNECTORS)
async def test_connector_restart_isolation_and_delete(name, monkeypatch):
pc, ec = load_plugin(name)
calls = []
async def service(request):
calls.append(request)
if request.method == 'GET':
return httpx.Response(200, json={'code': 0, 'data': []})
return httpx.Response(200, json={'code': 200 if name == 'FastGPTConnector' else 0})
client = httpx.AsyncClient
monkeypatch.setattr(httpx, 'AsyncClient', lambda **kw: client(transport=httpx.MockTransport(service), **kw))
a, b = StorageFixture(), StorageFixture()
ea, eb = ec(), ec()
ea.plugin, eb.plugin = bind(pc, a), bind(pc, b)
config = configuration('A')
await ea.on_knowledge_base_create('same-kb', config)
config['api_key'] = 'MUTATED'
await eb.on_knowledge_base_create('same-kb', configuration('B'))
restarted = ec()
restarted.plugin = bind(pc, a)
# No acknowledged ingestion mapping exists; guessing a provider ID is unsafe.
assert await restarted.delete_document('same-kb', 'doc') is False
assert await eb.delete_document('same-kb', 'doc') is False
assert not any(request.method == 'DELETE' for request in calls)
# Mappings record the upstream target they were created on; a delete only
# replays against that same target.
await restarted._save_document('same-kb', 'doc', {'upstream_id': 'upstream-A', 'dataset_id': 'A', 'status': 'created', 'api_base_url': 'https://fixture.invalid'})
await eb._save_document('same-kb', 'doc', {'upstream_id': 'upstream-B', 'dataset_id': 'B', 'status': 'created', 'api_base_url': 'https://fixture.invalid'})
assert await restarted.delete_document('same-kb', 'doc') is True
assert calls[-1].headers['authorization'] == 'Bearer A'
assert await eb.delete_document('same-kb', 'doc') is True
assert calls[-1].headers['authorization'] == 'Bearer B'
await restarted.on_knowledge_base_delete('same-kb')
assert await restarted.delete_document('same-kb', 'doc') is False
assert await eb.delete_document('same-kb', 'doc') is False
@pytest.mark.asyncio
@pytest.mark.parametrize('name', CONNECTORS)
async def test_cancelled_config_commit_cannot_resurrect_deleted_kb(name):
pc, ec = load_plugin(name)
store = StorageFixture()
engine = ec()
engine.plugin = bind(pc, store)
store.release.clear()
# Empty credentials avoid the optional RAGFlow validation request.
write = asyncio.create_task(engine.on_knowledge_base_create('kb', {'marker': 'A'}))
try:
await asyncio.wait_for(store.started.wait(), .3)
write.cancel()
await asyncio.sleep(0)
write.cancel()
delete = asyncio.create_task(engine.on_knowledge_base_delete('kb'))
await asyncio.sleep(.01)
# The dispatched Host commit already holds the lock and is settled before
# release, so the delete is ordered behind it rather than fenced: a
# settled commit is not an ambiguous mutation. Ordinary cancellation of
# the caller therefore does not fence the knowledge base.
assert not delete.done()
store.release.set()
with pytest.raises(asyncio.CancelledError):
await write
await delete
finally:
store.release.set()
await asyncio.gather(write, return_exceptions=True)
# The tombstone is the last acknowledged commit: the cancelled create cannot
# resurrect the deleted knowledge base, and the survivor is not fenced.
restarted = ec()
restarted.plugin = bind(pc, store)
assert await restarted._load_config('kb') is None
assert await restarted.delete_document('kb', 'doc') is False
await restarted.on_knowledge_base_create('kb', {'marker': 'B'})
assert (await restarted._load_config('kb'))['marker'] == 'B'
@pytest.mark.asyncio
async def test_langrag_telemetry_is_per_plugin_and_survives_restart():
pc, _ = load_plugin('LangRAG')
sa, sb = StorageFixture(), StorageFixture()
a, b = bind(pc, sa), bind(pc, sb)
await a.initialize()
await b.initialize()
assert hasattr(a, 'telemetry'), 'Telemetry must belong to the installation plugin'
await a.telemetry.record_delete(collection_id='same', document_id='private-A', status='completed', duration_ms=1)
assert (await b.telemetry.snapshot())['recent']['delete'] == []
metrics = await b.telemetry.prometheus()
warning_line = next(line for line in metrics.splitlines() if line.startswith('langrag_alerts_active{') and 'severity="warning"' in line)
assert warning_line.endswith(' 0'), 'SDK persistence must not report JSONL-disabled warning'
a2 = bind(pc, sa)
await a2.initialize()
assert (await a2.telemetry.snapshot())['recent']['delete'][0]['document_id'] == 'private-A'
await a2.telemetry.clear()
a3 = bind(pc, sa)
await a3.initialize()
assert (await a3.telemetry.snapshot())['recent']['delete'] == []
@pytest.mark.asyncio
async def test_text_parser_is_offloop(monkeypatch):
load_plugin('LangRAG')
from components.knowledge_engine.parser import FileParser
import threading
parser = FileParser()
main_thread = threading.get_ident()
monkeypatch.setattr(parser, '_decode_text', lambda data: str(threading.get_ident()))
for filename in ['doc.txt', 'doc.unknown']:
assert await parser.parse(b'x', filename) != str(main_thread)
def ingest_context(config):
return IngestionContext(
file_object=FileObject(
metadata=FileMetadata(filename='doc.txt', file_size=12, mime_type='text/plain',
document_id='local-doc', knowledge_base_id='kb'),
storage_path='fixture-file'),
knowledge_base_id='kb', creation_settings=config,
)
def bind_mock_http(monkeypatch, handler):
client = httpx.AsyncClient
monkeypatch.setattr(
httpx, 'AsyncClient',
lambda **kw: client(transport=httpx.MockTransport(handler), **kw),
)
@pytest.mark.asyncio
@pytest.mark.parametrize('name', CONNECTORS)
async def test_deterministic_validation_error_does_not_fence(name):
pc, ec = load_plugin(name)
store = StorageFixture()
engine = ec()
engine.plugin = bind(pc, store)
# Raised before any Host or provider call: deterministic, must not fence.
with pytest.raises(ValueError, match='64 KiB'):
await engine.on_knowledge_base_create('kb', {'blob': 'x' * 70000})
await engine.on_knowledge_base_create('kb', {'marker': 'ok'})
restarted = ec()
restarted.plugin = bind(pc, store)
assert (await restarted._load_config('kb'))['marker'] == 'ok'
class GatedStorageFixture(StorageFixture):
"""Suspends the first key listing so cancellation lands before any dispatch."""
def __init__(self):
super().__init__()
self.keys_entered = asyncio.Event()
self.keys_gate = asyncio.Event()
async def get_plugin_storage_keys(self):
self.keys_entered.set()
await self.keys_gate.wait()
return await super().get_plugin_storage_keys()
@pytest.mark.asyncio
@pytest.mark.parametrize('name', CONNECTORS)
async def test_cancellation_before_dispatch_does_not_fence(name):
pc, ec = load_plugin(name)
store = GatedStorageFixture()
engine = ec()
engine.plugin = bind(pc, store)
task = asyncio.create_task(engine.on_knowledge_base_create('kb', {'marker': 'A'}))
await store.keys_entered.wait()
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
store.keys_gate.set()
# No mutation was dispatched, so the knowledge base stays usable.
await engine.on_knowledge_base_create('kb', {'marker': 'B'})
assert (await engine._load_config('kb'))['marker'] == 'B'
@pytest.mark.asyncio
@pytest.mark.parametrize('name', CONNECTORS)
async def test_ambiguous_provider_response_fences_only_that_knowledge_base(name, monkeypatch):
pc, ec = load_plugin(name)
async def service(request):
# Provider reports success but omits the upstream id: dispatched, unknown.
if request.url.path.endswith('/create-by-file'):
return httpx.Response(200, json={'document': {}})
if request.url.path.endswith('/localFile'):
return httpx.Response(200, json={'code': 200, 'data': {'collectionId': ''}})
return httpx.Response(200, json={'code': 0, 'data': []})
bind_mock_http(monkeypatch, service)
store = StorageFixture()
engine = ec()
plugin = bind(pc, store)
async def read_file(path):
return b'fixture file'
plugin.get_knowledge_file_stream = read_file
engine.plugin = plugin
cfg = configuration('A')
with pytest.raises(RuntimeError, match='reconciliation'):
await engine.ingest(ingest_context(cfg))
# Only the failing knowledge base is fenced; its sibling still works.
with pytest.raises(RuntimeError, match='fenced'):
await engine.on_knowledge_base_create('kb', cfg)
await engine.on_knowledge_base_create('other-kb', cfg)
# The fence is persisted, so a restarted worker still refuses that KB only.
restarted = ec()
restarted.plugin = bind(pc, store)
with pytest.raises(RuntimeError, match='fenced'):
await restarted.on_knowledge_base_create('kb', cfg)
assert (await restarted._load_config('other-kb')) is not None
# Deleting the knowledge base is the operator path out of the fence.
await restarted.on_knowledge_base_delete('kb')
await restarted.on_knowledge_base_create('kb', cfg)
@pytest.mark.asyncio
@pytest.mark.parametrize('name', CONNECTORS)
async def test_ambiguous_transport_failure_is_reported_and_fenced(name, monkeypatch):
pc, ec = load_plugin(name)
async def service(request):
raise httpx.ReadTimeout('fixture timeout')
bind_mock_http(monkeypatch, service)
store = StorageFixture()
engine = ec()
plugin = bind(pc, store)
async def read_file(path):
return b'fixture file'
plugin.get_knowledge_file_stream = read_file
engine.plugin = plugin
cfg = configuration('A')
result = await engine.ingest(ingest_context(cfg))
assert result.status == DocumentStatus.FAILED
# The caller still gets a FAILED result, but the dispatched upload is unknown.
with pytest.raises(RuntimeError, match='fenced'):
await engine.on_knowledge_base_create('kb', cfg)
@pytest.mark.asyncio
@pytest.mark.parametrize('name', CONNECTORS + ['LangRAG'])
async def test_installation_revocation_releases_process_local_state(name):
pc, ec = load_plugin(name)
plugin = pc()
engine = ec()
engine.plugin = plugin
await engine.initialize()
binding = ('workspace', 'installation')
lock = asyncio.Lock()
engine._state._locks[(binding, 'kb')] = lock
engine._state._fenced_keys(binding).add('ke.fence.v1.deadbeef')
engine._state._fence_loaded.add(binding)
if hasattr(engine._state, '_fence_load_locks'):
# The single-flight load lock and the fence generation belong to the
# binding too, so a revoked installation must not keep a stale pair.
engine._state._fence_load_locks[binding] = asyncio.Lock()
engine._state._fence_generation[binding] = 3
await plugin.on_installation_revoked(binding)
assert (binding, 'kb') not in engine._state._locks
assert binding not in engine._state._fenced
assert binding not in engine._state._fence_loaded
if hasattr(engine._state, '_fence_load_locks'):
assert binding not in engine._state._fence_load_locks
assert binding not in engine._state._fence_generation
@pytest.mark.asyncio
async def test_langrag_fence_is_scoped_to_one_collection_and_survives_restart():
"""One knowledge base's ambiguous mutation must not block its siblings."""
_, ec = load_plugin('LangRAG')
engine = ec()
plugin = StorageFixture()
engine.plugin = plugin
await engine.initialize()
binding = engine._state.binding(plugin)
async def boom():
raise RuntimeError('host offline after dispatch')
async def ok():
return 7
with pytest.raises(RuntimeError, match='host offline'):
await engine._state.mutate(plugin, 'kb-a', boom, 'Host vector_upsert outcome unknown')
# Only the failing knowledge base is fenced.
assert engine._state.is_fenced(binding, 'kb-a')
assert not engine._state.is_fenced(binding, 'kb-b')
with pytest.raises(RuntimeError, match='fenced'):
await engine._state.run(plugin, 'kb-a', ok)
assert await engine._state.mutate(plugin, 'kb-b', ok, 'unrelated') == 7
# A restarted worker still refuses the fenced knowledge base.
restarted = ec()
restarted.plugin = plugin
with pytest.raises(RuntimeError, match='fenced'):
await restarted._state.run(plugin, 'kb-a', ok)
assert not restarted._state.is_fenced(restarted._state.binding(plugin), 'kb-b')
# Deleting the knowledge base clears the fence.
await restarted._state.clear_fence(plugin, 'kb-a')
assert await restarted._state.run(plugin, 'kb-a', ok) == 7
@@ -0,0 +1,100 @@
"""Build exact unsigned archives and exercise genuine SDK 0.7.4 admission.
Uses SDK ArtifactStore, DependencyEnvironmentStore.prepare and its real direct
pip installer. This is NOT nsjail/cgroup certification or a production install.
"""
import argparse
import asyncio
import base64
import csv
import hashlib
import importlib.metadata
import io
import json
import os
from pathlib import Path
import subprocess
import sys
import zipfile
from langbot_plugin.runtime.plugin.artifact import PluginArtifactStore
from langbot_plugin.runtime.plugin.dependency_environment import PluginDependencyEnvironmentStore
from langbot_plugin.runtime.plugin.worker_launcher import PluginWorkerLauncher
from langbot_plugin.entities.io.context import PluginWorkerPolicy
NAMES = ['DifyDatasetsConnector', 'FastGPTConnector', 'LangRAG', 'RAGFlowConnector', 'LongTermMemory']
ROOT = Path(__file__).resolve().parents[1]
async def verify(out):
assert importlib.metadata.version('langbot-plugin') == '0.7.4'
out.mkdir(parents=True, exist_ok=True)
dist = importlib.metadata.distribution('langbot-plugin')
verified = []
for name, encoded, size in csv.reader(io.StringIO(dist.read_text('RECORD'))):
if name.startswith('langbot_plugin/') and name.endswith('.py'):
digest = hashlib.sha256(Path(dist.locate_file(name)).read_bytes()).digest()
assert encoded == 'sha256=' + base64.urlsafe_b64encode(digest).decode().rstrip('=')
verified.append(name)
(out / 'sdk-environment.json').write_text(json.dumps({
'sdk': dist.version, 'python': sys.version, 'sdk_record_verified_files': len(verified),
'source': 'installed PyPI distribution, not local SDK source',
'runtime_distributions': sorted(f'{d.metadata["Name"]}=={d.version}' for d in importlib.metadata.distributions()),
}, indent=2))
artifacts = PluginArtifactStore(out / 'runtime')
deps = PluginDependencyEnvironmentStore(out / 'runtime')
launcher = PluginWorkerLauncher()
launcher.configure(PluginWorkerPolicy(max_cpus=1, max_memory_mb=512, max_pids=128, max_open_files=256, max_file_size_mb=512, require_hard_limits=False), 'oss_dev')
inventory = []
for name in NAMES:
dest = out / 'packages' / name
dest.mkdir(parents=True, exist_ok=True)
result = subprocess.run([str(Path(sys.executable).parent / 'lbp'), 'build', '-o', str(dest)],
cwd=ROOT / name, capture_output=True, text=True, timeout=60)
(out / (name + '-build.log')).write_text(result.stdout + result.stderr)
assert result.returncode == 0, result.stdout + result.stderr
packages = list(dest.glob('*.lbpkg'))
assert len(packages) == 1
package = packages[0]
data = package.read_bytes()
digest = hashlib.sha256(data).hexdigest()
with zipfile.ZipFile(package) as z:
assert not z.comment, 'Candidates must remain unsigned'
entries = [{'path': p, 'sha256': hashlib.sha256(z.read(p)).hexdigest(), 'bytes': len(z.read(p))}
for p in sorted(z.namelist()) if not p.endswith('/')]
artifact = artifacts.install_package(data, digest)
assert artifacts.require_verified(digest) == artifact
env = await launcher.prepare_dependency_environment(deps, artifact)
assert deps.get_ready(env.digest) == env
assert await launcher.prepare_dependency_environment(deps, artifact) == env
assert not list(env.site_packages_path.glob('langbot_plugin*')), 'SDK must not be shadow installed'
row = {'name': name, 'author': artifact.plugin_author, 'version': artifact.plugin_version,
'package': str(package), 'sha256': digest, 'bytes': len(data), 'files': entries,
'code_path': str(artifact.code_path), 'dependency_path': str(env.site_packages_path),
'environment_digest': env.digest, 'requirements_digest': env.requirements_digest,
'dependency_prepare_passed': True, 'dependency_cache_readback_passed': True,
'sdk_shadow_installed': False, 'unsigned': True,
'resolved_dependency_distributions': sorted(
f'{d.metadata["Name"]}=={d.version}' for d in
importlib.metadata.distributions(path=[str(env.site_packages_path)]))}
inventory.append(row)
(out / 'candidate-inventory.json').write_text(json.dumps(inventory, indent=2))
print(name, artifact.plugin_version, digest, flush=True)
child_env = {**os.environ, 'PYTHONDONTWRITEBYTECODE': '1',
'PYTHONPATH': os.pathsep.join([str(dist.locate_file('')), str(env.site_packages_path)])}
result = subprocess.run([sys.executable, str(Path(__file__).with_name('sdk_rpc_probe.py')),
str(artifact.code_path), str(out), name, digest],
cwd=artifact.code_path, env=child_env, text=True, capture_output=True, timeout=100)
(out / (name + '-rpc.log')).write_text(result.stdout + result.stderr)
assert result.returncode == 0, result.stdout + result.stderr
saved = json.loads((out / 'candidate-inventory.json').read_text())
assert len(saved) == len({r['name'] for r in saved}) == 5
assert {r['name'] for r in saved} == set(NAMES)
print('VERIFIED 5 exact unsigned candidates, dependencies, discovery and RPC isolation')
if __name__ == '__main__':
parser = argparse.ArgumentParser()
parser.add_argument('--evidence', required=True, type=Path)
args = parser.parse_args()
asyncio.run(verify(args.evidence.resolve()))