mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-11-03 22:32:27 +08:00
feat(plugins): consolidate official plugins with independent releases (LANG-180) (#2797)
Co-authored-by: multica-agent <[email protected]>
This commit is contained in:
co-authored by
multica-agent
parent
d099033d79
commit
3a77306c3c
+125
@@ -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
|
||||
}
|
||||
+29
@@ -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()))
|
||||
Reference in New Issue
Block a user