fix(dataset): integrate New RAG with KnowledgeFS (#39621)

This commit is contained in:
Stephen Zhou 2026-07-27 12:52:44 +08:00 committed by GitHub
parent ce0dee9afb
commit 1cde846bcf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
154 changed files with 8666 additions and 9154 deletions

View File

@ -692,8 +692,6 @@ KNOWLEDGE_FS_LEGACY_ACL_FREEZE_READY=false
KNOWLEDGE_FS_LIFECYCLE_POLL_INTERVAL_SECONDS=15
KNOWLEDGE_FS_LIFECYCLE_LEASE_SECONDS=60
KNOWLEDGE_FS_LIFECYCLE_BATCH_SIZE=25
# Legacy rollback-only HMAC; Capability v2 deployments leave this blank.
KNOWLEDGE_FS_JWT_SECRET=
KNOWLEDGE_FS_CAPABILITY_V2_ENABLED=false
KNOWLEDGE_FS_CAPABILITY_V2_SIGNING_KID=
KNOWLEDGE_FS_CAPABILITY_V2_PRIVATE_KEY_PEM=
@ -701,7 +699,6 @@ KNOWLEDGE_FS_CAPABILITY_V2_PREVIOUS_PUBLIC_JWKS=
KNOWLEDGE_FS_CAPABILITY_V2_ISSUER=dify-control-plane
KNOWLEDGE_FS_CAPABILITY_V2_AUDIENCE=knowledge-fs
KNOWLEDGE_FS_CAPABILITY_V2_MAX_TTL_SECONDS=60
KNOWLEDGE_FS_SSE_READ_TIMEOUT_SECONDS=300
KNOWLEDGE_FS_TIMEOUT_SECONDS=10
KNOWLEDGE_FS_JWKS_CACHE_MAX_AGE_SECONDS=300
KNOWLEDGE_FS_PRODUCT_MAX_RESPONSE_BYTES=4194304

View File

@ -12,7 +12,7 @@ class KnowledgeFSConfig(BaseSettings):
KNOWLEDGE_FS_ENABLED: bool = Field(
default=False,
description="Enable the private KnowledgeFS Console bridge.",
description="Enable the KnowledgeFS control-plane product routes.",
)
KNOWLEDGE_FS_LIFECYCLE_WORKER_ENABLED: bool = Field(
default=False,
@ -34,6 +34,10 @@ class KnowledgeFSConfig(BaseSettings):
default=None,
description="Public KnowledgeFS origin returned with direct upload capabilities.",
)
KNOWLEDGE_FS_DIRECT_UPLOAD_READY: bool = Field(
default=False,
description="Confirm that KnowledgeFS direct upload and its browser origin policy are deployed and verified.",
)
KNOWLEDGE_FS_CAPABILITY_V2_ENABLED: bool = Field(
default=False,
description="Prepare resource-scoped Capability v2 issuance; disabled until rollout approval.",

View File

@ -77,3 +77,5 @@ COOKIE_NAME_PASSPORT = "passport"
HEADER_NAME_CSRF_TOKEN = "X-CSRF-Token"
HEADER_NAME_APP_CODE = "X-App-Code"
HEADER_NAME_PASSPORT = "X-App-Passport"
HEADER_NAME_IDEMPOTENCY_KEY = "Idempotency-Key"
HEADER_NAME_REQUEST_ID = "X-Request-ID"

View File

@ -64,6 +64,9 @@ from services.knowledge_fs.product_dto import (
KnowledgeFSBulkDocumentDeletePayload,
KnowledgeFSBulkJobResponse,
KnowledgeFSCapabilityResponse,
KnowledgeFSCrawlPreviewPageListQuery,
KnowledgeFSCrawlPreviewPageListResponse,
KnowledgeFSCrawlPreviewSelectionPayload,
KnowledgeFSCredentialCreatePayload,
KnowledgeFSCredentialCreateResponse,
KnowledgeFSCredentialListResponse,
@ -86,6 +89,7 @@ from services.knowledge_fs.product_dto import (
KnowledgeFSExternalAccessResponse,
KnowledgeFSIdempotencyHeader,
KnowledgeFSJWKSResponse,
KnowledgeFSLogicalDocumentListResponse,
KnowledgeFSLogicalDocumentResponse,
KnowledgeFSMembersReplacePayload,
KnowledgeFSOverviewBaseStatsResponse,
@ -111,6 +115,11 @@ from services.knowledge_fs.product_dto import (
KnowledgeFSSettingsPayload,
KnowledgeFSSettingsResponse,
KnowledgeFSSmallFileUploadResponse,
KnowledgeFSSourceConnectionCreatePayload,
KnowledgeFSSourceConnectionListQuery,
KnowledgeFSSourceConnectionListResponse,
KnowledgeFSSourceConnectionRefreshPayload,
KnowledgeFSSourceConnectionResponse,
KnowledgeFSSourceCrawlResponse,
KnowledgeFSSourceCreatePayload,
KnowledgeFSSourceCredentialTestResponse,
@ -124,8 +133,13 @@ from services.knowledge_fs.product_dto import (
KnowledgeFSSourceListResponse,
KnowledgeFSSourcePagesQuery,
KnowledgeFSSourcePagesResponse,
KnowledgeFSSourceProviderListResponse,
KnowledgeFSSourceResponse,
KnowledgeFSSourceSyncPolicyPayload,
KnowledgeFSSourceSyncPolicyResponse,
KnowledgeFSSourceUpdatePayload,
KnowledgeFSSourceWorkflowCancelPayload,
KnowledgeFSSourceWorkflowResponse,
KnowledgeFSSpaceCreatePayload,
KnowledgeFSSpaceCreateResponse,
KnowledgeFSSpaceDetailResponse,
@ -143,6 +157,7 @@ from services.knowledge_fs.product_remote import (
KnowledgeFSOperationUnavailableError,
KnowledgeFSProductRemoteError,
KnowledgeFSProductRequestRejectedError,
KnowledgeFSProductResourceNotFoundError,
)
from services.knowledge_fs.runtime import KnowledgeFSRuntime, create_knowledge_fs_runtime
from services.knowledge_fs_capability import (
@ -164,6 +179,7 @@ register_schema_models(
KnowledgeFSDocumentMetadataPayload,
KnowledgeFSDocumentReindexPayload,
KnowledgeFSExternalAccessPayload,
KnowledgeFSCrawlPreviewPageListQuery,
KnowledgeFSMembersReplacePayload,
KnowledgeFSQueryCreatePayload,
KnowledgeFSResearchTaskPartialsQuery,
@ -171,6 +187,10 @@ register_schema_models(
KnowledgeFSResearchTaskCreatePayload,
KnowledgeFSSettingsPayload,
KnowledgeFSSourceCreatePayload,
KnowledgeFSSourceConnectionCreatePayload,
KnowledgeFSSourceConnectionListQuery,
KnowledgeFSSourceConnectionRefreshPayload,
KnowledgeFSCrawlPreviewSelectionPayload,
KnowledgeFSSourceDeletePayload,
KnowledgeFSSourceDeleteQuery,
KnowledgeFSSourceFilesQuery,
@ -178,6 +198,8 @@ register_schema_models(
KnowledgeFSSourceImportPagesPayload,
KnowledgeFSSourcePagesQuery,
KnowledgeFSSourceUpdatePayload,
KnowledgeFSSourceSyncPolicyPayload,
KnowledgeFSSourceWorkflowCancelPayload,
KnowledgeFSSpaceCreatePayload,
KnowledgeFSSpaceListQuery,
KnowledgeFSSpaceUpdatePayload,
@ -208,6 +230,7 @@ register_response_schema_models(
KnowledgeFSDurableDeletionAcceptedResponse,
KnowledgeFSExternalAccessResponse,
KnowledgeFSJWKSResponse,
KnowledgeFSLogicalDocumentListResponse,
KnowledgeFSPermissionListResponse,
KnowledgeFSQueryResponse,
KnowledgeFSQueryAdmissionResponse,
@ -218,6 +241,9 @@ register_response_schema_models(
KnowledgeFSResearchTaskListResponse,
KnowledgeFSSettingsResponse,
KnowledgeFSSmallFileUploadResponse,
KnowledgeFSCrawlPreviewPageListResponse,
KnowledgeFSSourceConnectionListResponse,
KnowledgeFSSourceConnectionResponse,
KnowledgeFSSourceListResponse,
KnowledgeFSSourceCrawlResponse,
KnowledgeFSSourceCredentialTestResponse,
@ -225,6 +251,9 @@ register_response_schema_models(
KnowledgeFSSourceImportResponse,
KnowledgeFSSourcePagesResponse,
KnowledgeFSSourceResponse,
KnowledgeFSSourceProviderListResponse,
KnowledgeFSSourceSyncPolicyResponse,
KnowledgeFSSourceWorkflowResponse,
KnowledgeFSSpaceCreateResponse,
KnowledgeFSSpaceDetailResponse,
KnowledgeFSSpaceListResponse,
@ -255,6 +284,8 @@ def _knowledge_fs_errors[**P, R](view: Callable[P, R]) -> Callable[P, R]:
raise KnowledgeFSSpaceNotFoundHTTPError() from exc
except KnowledgeFSOperationUnavailableError as exc:
raise KnowledgeFSOperationUnavailableHTTPError() from exc
except KnowledgeFSProductResourceNotFoundError as exc:
raise NotFound() from exc
except KnowledgeFSProductRemoteError as exc:
raise KnowledgeFSUpstreamUnavailableHTTPError() from exc
except KnowledgeFSOperationRateLimitExceededError as exc:
@ -290,6 +321,14 @@ _SMALL_FILE_UPLOAD_PARAMS = {
"required": True,
}
}
_IDEMPOTENCY_HEADER_PARAMS = {
"Idempotency-Key": {
"description": "Stable key used to make the mutation safe to retry",
"in": "header",
"required": True,
"type": "string",
}
}
_SMALL_FILE_MULTIPART_OVERHEAD_MAX_BYTES = 64 * 1024
_BACKGROUND_TASK_KIND_ADAPTER: TypeAdapter[Literal["document", "document_bulk", "source"]] = TypeAdapter(
Literal["document", "document_bulk", "source"]
@ -822,6 +861,52 @@ class KnowledgeFSSpaceOverviewHealthApi(Resource):
return dump_response(KnowledgeFSOverviewHealthResponse, result)
@console_ns.route("/knowledge-fs/spaces/<string:control_space_id>/logical-documents")
class KnowledgeFSSpaceLogicalDocumentsApi(Resource):
@console_ns.doc(params=query_params_from_model(KnowledgeFSCursorQuery))
@console_ns.response(
HTTPStatus.OK,
"KnowledgeFS logical documents",
console_ns.models[KnowledgeFSLogicalDocumentListResponse.__name__],
)
@setup_required
@login_required
@account_initialization_required
@_knowledge_fs_errors
def get(self, control_space_id: str):
actor_id, tenant_id = _actor()
query = KnowledgeFSCursorQuery.model_validate(request.args.to_dict())
result = _console_services().facade.list_logical_documents(
tenant_id=tenant_id,
account_id=actor_id,
control_space_id=control_space_id,
cursor=query.cursor,
)
return dump_response(KnowledgeFSLogicalDocumentListResponse, result)
@console_ns.route("/knowledge-fs/spaces/<string:control_space_id>/logical-documents/<string:document_id>")
class KnowledgeFSSpaceLogicalDocumentApi(Resource):
@console_ns.response(
HTTPStatus.OK,
"KnowledgeFS logical document",
console_ns.models[KnowledgeFSLogicalDocumentResponse.__name__],
)
@setup_required
@login_required
@account_initialization_required
@_knowledge_fs_errors
def get(self, control_space_id: str, document_id: str):
actor_id, tenant_id = _actor()
result = _console_services().facade.get_logical_document(
tenant_id=tenant_id,
account_id=actor_id,
control_space_id=control_space_id,
document_id=document_id,
)
return dump_response(KnowledgeFSLogicalDocumentResponse, result)
@console_ns.route("/knowledge-fs/spaces/<string:control_space_id>/documents")
class KnowledgeFSSpaceDocumentsApi(Resource):
@console_ns.doc(params=query_params_from_model(KnowledgeFSCursorQuery))
@ -866,6 +951,7 @@ class KnowledgeFSSpaceDocumentsApi(Resource):
@console_ns.route("/knowledge-fs/spaces/<string:control_space_id>/documents/bulk")
class KnowledgeFSSpaceBulkDocumentsApi(Resource):
@console_ns.expect(console_ns.models[KnowledgeFSBulkDocumentDeletePayload.__name__])
@console_ns.doc(params=_IDEMPOTENCY_HEADER_PARAMS)
@console_ns.response(
HTTPStatus.ACCEPTED,
"KnowledgeFS document deletions accepted",
@ -953,6 +1039,7 @@ class KnowledgeFSSpaceDocumentApi(Resource):
return dump_response(KnowledgeFSLogicalDocumentResponse, result)
@console_ns.expect(console_ns.models[KnowledgeFSDocumentDeletePayload.__name__])
@console_ns.doc(params=_IDEMPOTENCY_HEADER_PARAMS)
@console_ns.response(
HTTPStatus.ACCEPTED,
"KnowledgeFS document deletion accepted",
@ -1225,6 +1312,96 @@ class KnowledgeFSSpaceBackgroundTaskRetryApi(Resource):
return dump_response(KnowledgeFSBackgroundTaskResponse, result)
@console_ns.route("/knowledge-fs/spaces/<string:control_space_id>/source-providers")
class KnowledgeFSSourceProvidersApi(Resource):
@console_ns.response(
HTTPStatus.OK,
"KnowledgeFS source providers",
console_ns.models[KnowledgeFSSourceProviderListResponse.__name__],
)
@setup_required
@login_required
@account_initialization_required
@_knowledge_fs_errors
def get(self, control_space_id: str):
actor_id, tenant_id = _actor()
result = _console_services().facade.list_source_providers(
tenant_id=tenant_id,
account_id=actor_id,
control_space_id=control_space_id,
)
return dump_response(KnowledgeFSSourceProviderListResponse, result)
@console_ns.route("/knowledge-fs/spaces/<string:control_space_id>/source-connections")
class KnowledgeFSSourceConnectionsApi(Resource):
@console_ns.doc(params=query_params_from_model(KnowledgeFSSourceConnectionListQuery))
@console_ns.response(
HTTPStatus.OK,
"KnowledgeFS source connections",
console_ns.models[KnowledgeFSSourceConnectionListResponse.__name__],
)
@setup_required
@login_required
@account_initialization_required
@_knowledge_fs_errors
def get(self, control_space_id: str):
actor_id, tenant_id = _actor()
query = KnowledgeFSSourceConnectionListQuery.model_validate(request.args.to_dict())
result = _console_services().facade.list_source_connections(
tenant_id=tenant_id,
account_id=actor_id,
control_space_id=control_space_id,
cursor=query.cursor,
limit=query.limit,
)
return dump_response(KnowledgeFSSourceConnectionListResponse, result)
@console_ns.expect(console_ns.models[KnowledgeFSSourceConnectionCreatePayload.__name__])
@console_ns.response(
HTTPStatus.CREATED,
"KnowledgeFS source connection created",
console_ns.models[KnowledgeFSSourceConnectionResponse.__name__],
)
@setup_required
@login_required
@account_initialization_required
@_knowledge_fs_errors
def post(self, control_space_id: str):
actor_id, tenant_id = _actor()
result = _console_services().facade.create_source_connection(
tenant_id=tenant_id,
account_id=actor_id,
control_space_id=control_space_id,
payload=_payload(KnowledgeFSSourceConnectionCreatePayload),
)
return dump_response(KnowledgeFSSourceConnectionResponse, result), HTTPStatus.CREATED
@console_ns.route("/knowledge-fs/spaces/<string:control_space_id>/source-connections/<string:connection_id>/refresh")
class KnowledgeFSSourceConnectionRefreshApi(Resource):
@console_ns.expect(console_ns.models[KnowledgeFSSourceConnectionRefreshPayload.__name__])
@console_ns.response(
HTTPStatus.OK,
"KnowledgeFS source connection refreshed",
console_ns.models[KnowledgeFSSourceConnectionResponse.__name__],
)
@setup_required
@login_required
@account_initialization_required
@_knowledge_fs_errors
def post(self, control_space_id: str, connection_id: str):
actor_id, tenant_id = _actor()
result = _console_services().facade.refresh_source_connection(
tenant_id=tenant_id,
account_id=actor_id,
control_space_id=control_space_id,
connection_id=connection_id,
payload=_payload(KnowledgeFSSourceConnectionRefreshPayload),
)
return dump_response(KnowledgeFSSourceConnectionResponse, result)
@console_ns.route("/knowledge-fs/spaces/<string:control_space_id>/sources")
class KnowledgeFSSpaceSourcesApi(Resource):
@console_ns.doc(params=query_params_from_model(KnowledgeFSCursorQuery))
@ -1303,7 +1480,7 @@ class KnowledgeFSSpaceSourceApi(Resource):
return dump_response(KnowledgeFSSourceResponse, result)
@console_ns.expect(console_ns.models[KnowledgeFSSourceDeletePayload.__name__])
@console_ns.doc(params=query_params_from_model(KnowledgeFSSourceDeleteQuery))
@console_ns.doc(params=query_params_from_model(KnowledgeFSSourceDeleteQuery) | _IDEMPOTENCY_HEADER_PARAMS)
@console_ns.response(
HTTPStatus.ACCEPTED,
"KnowledgeFS source deletion accepted",
@ -1347,10 +1524,13 @@ class KnowledgeFSSpaceSourceTestApi(Resource):
return dump_response(KnowledgeFSSourceCredentialTestResponse, result)
@console_ns.route("/knowledge-fs/spaces/<string:control_space_id>/sources/<string:source_id>/crawl")
class KnowledgeFSSpaceSourceCrawlApi(Resource):
@console_ns.route("/knowledge-fs/spaces/<string:control_space_id>/sources/<string:source_id>/sync")
class KnowledgeFSSpaceSourceSyncApi(Resource):
@console_ns.doc(params=_IDEMPOTENCY_HEADER_PARAMS)
@console_ns.response(
HTTPStatus.OK, "KnowledgeFS source crawl", console_ns.models[KnowledgeFSSourceCrawlResponse.__name__]
HTTPStatus.ACCEPTED,
"KnowledgeFS source sync accepted",
console_ns.models[KnowledgeFSSourceWorkflowResponse.__name__],
)
@setup_required
@login_required
@ -1358,10 +1538,201 @@ class KnowledgeFSSpaceSourceCrawlApi(Resource):
@_knowledge_fs_errors
def post(self, control_space_id: str, source_id: str):
actor_id, tenant_id = _actor()
result = _console_services().facade.crawl_source(
tenant_id=tenant_id, account_id=actor_id, control_space_id=control_space_id, source_id=source_id
result = _console_services().facade.sync_source(
tenant_id=tenant_id,
account_id=actor_id,
control_space_id=control_space_id,
source_id=source_id,
idempotency_key=_idempotency_key(),
)
return dump_response(KnowledgeFSSourceCrawlResponse, result)
return dump_response(KnowledgeFSSourceWorkflowResponse, result), HTTPStatus.ACCEPTED
@console_ns.route("/knowledge-fs/spaces/<string:control_space_id>/sources/<string:source_id>/crawl-preview")
class KnowledgeFSSpaceSourceCrawlPreviewApi(Resource):
@console_ns.doc(params=_IDEMPOTENCY_HEADER_PARAMS)
@console_ns.response(
HTTPStatus.ACCEPTED,
"KnowledgeFS source crawl preview accepted",
console_ns.models[KnowledgeFSSourceWorkflowResponse.__name__],
)
@setup_required
@login_required
@account_initialization_required
@_knowledge_fs_errors
def post(self, control_space_id: str, source_id: str):
actor_id, tenant_id = _actor()
result = _console_services().facade.preview_source_crawl(
tenant_id=tenant_id,
account_id=actor_id,
control_space_id=control_space_id,
source_id=source_id,
idempotency_key=_idempotency_key(),
)
return dump_response(KnowledgeFSSourceWorkflowResponse, result), HTTPStatus.ACCEPTED
@console_ns.route("/knowledge-fs/spaces/<string:control_space_id>/sources/<string:source_id>/sync-policy")
class KnowledgeFSSpaceSourceSyncPolicyApi(Resource):
@console_ns.response(
HTTPStatus.OK,
"KnowledgeFS source sync policy",
console_ns.models[KnowledgeFSSourceSyncPolicyResponse.__name__],
)
@setup_required
@login_required
@account_initialization_required
@_knowledge_fs_errors
def get(self, control_space_id: str, source_id: str):
actor_id, tenant_id = _actor()
result = _console_services().facade.get_source_sync_policy(
tenant_id=tenant_id,
account_id=actor_id,
control_space_id=control_space_id,
source_id=source_id,
)
return dump_response(KnowledgeFSSourceSyncPolicyResponse, result)
@console_ns.expect(console_ns.models[KnowledgeFSSourceSyncPolicyPayload.__name__])
@console_ns.response(
HTTPStatus.OK,
"KnowledgeFS source sync policy updated",
console_ns.models[KnowledgeFSSourceSyncPolicyResponse.__name__],
)
@setup_required
@login_required
@account_initialization_required
@_knowledge_fs_errors
def put(self, control_space_id: str, source_id: str):
actor_id, tenant_id = _actor()
result = _console_services().facade.update_source_sync_policy(
tenant_id=tenant_id,
account_id=actor_id,
control_space_id=control_space_id,
source_id=source_id,
payload=_payload(KnowledgeFSSourceSyncPolicyPayload),
)
return dump_response(KnowledgeFSSourceSyncPolicyResponse, result)
@console_ns.route("/knowledge-fs/spaces/<string:control_space_id>/source-workflows/<string:run_id>")
class KnowledgeFSSourceWorkflowApi(Resource):
@console_ns.response(
HTTPStatus.OK,
"KnowledgeFS source workflow",
console_ns.models[KnowledgeFSSourceWorkflowResponse.__name__],
)
@setup_required
@login_required
@account_initialization_required
@_knowledge_fs_errors
def get(self, control_space_id: str, run_id: str):
actor_id, tenant_id = _actor()
result = _console_services().facade.get_source_workflow(
tenant_id=tenant_id,
account_id=actor_id,
control_space_id=control_space_id,
run_id=run_id,
)
return dump_response(KnowledgeFSSourceWorkflowResponse, result)
@console_ns.route("/knowledge-fs/spaces/<string:control_space_id>/source-workflows/<string:run_id>/cancel")
class KnowledgeFSSourceWorkflowCancelApi(Resource):
@console_ns.expect(console_ns.models[KnowledgeFSSourceWorkflowCancelPayload.__name__])
@console_ns.response(
HTTPStatus.OK,
"KnowledgeFS source workflow canceled",
console_ns.models[KnowledgeFSSourceWorkflowResponse.__name__],
)
@setup_required
@login_required
@account_initialization_required
@_knowledge_fs_errors
def post(self, control_space_id: str, run_id: str):
actor_id, tenant_id = _actor()
result = _console_services().facade.cancel_source_workflow(
tenant_id=tenant_id,
account_id=actor_id,
control_space_id=control_space_id,
run_id=run_id,
payload=_payload(KnowledgeFSSourceWorkflowCancelPayload),
)
return dump_response(KnowledgeFSSourceWorkflowResponse, result)
@console_ns.route("/knowledge-fs/spaces/<string:control_space_id>/source-workflows/<string:run_id>/retry")
class KnowledgeFSSourceWorkflowRetryApi(Resource):
@console_ns.response(
HTTPStatus.OK,
"KnowledgeFS source workflow retried",
console_ns.models[KnowledgeFSSourceWorkflowResponse.__name__],
)
@setup_required
@login_required
@account_initialization_required
@_knowledge_fs_errors
def post(self, control_space_id: str, run_id: str):
actor_id, tenant_id = _actor()
result = _console_services().facade.retry_source_workflow(
tenant_id=tenant_id,
account_id=actor_id,
control_space_id=control_space_id,
run_id=run_id,
)
return dump_response(KnowledgeFSSourceWorkflowResponse, result)
@console_ns.route("/knowledge-fs/spaces/<string:control_space_id>/source-workflows/<string:run_id>/pages")
class KnowledgeFSSourceWorkflowPagesApi(Resource):
@console_ns.doc(params=query_params_from_model(KnowledgeFSCrawlPreviewPageListQuery))
@console_ns.response(
HTTPStatus.OK,
"KnowledgeFS crawl preview pages",
console_ns.models[KnowledgeFSCrawlPreviewPageListResponse.__name__],
)
@setup_required
@login_required
@account_initialization_required
@_knowledge_fs_errors
def get(self, control_space_id: str, run_id: str):
actor_id, tenant_id = _actor()
query = KnowledgeFSCrawlPreviewPageListQuery.model_validate(request.args.to_dict())
result = _console_services().facade.list_crawl_preview_pages(
tenant_id=tenant_id,
account_id=actor_id,
control_space_id=control_space_id,
run_id=run_id,
cursor=query.cursor,
limit=query.limit,
)
return dump_response(KnowledgeFSCrawlPreviewPageListResponse, result)
@console_ns.route("/knowledge-fs/spaces/<string:control_space_id>/source-workflows/<string:run_id>/selection")
class KnowledgeFSSourceWorkflowSelectionApi(Resource):
@console_ns.expect(console_ns.models[KnowledgeFSCrawlPreviewSelectionPayload.__name__])
@console_ns.doc(params=_IDEMPOTENCY_HEADER_PARAMS)
@console_ns.response(
HTTPStatus.ACCEPTED,
"KnowledgeFS crawl preview selection accepted",
console_ns.models[KnowledgeFSSourceWorkflowResponse.__name__],
)
@setup_required
@login_required
@account_initialization_required
@_knowledge_fs_errors
def post(self, control_space_id: str, run_id: str):
actor_id, tenant_id = _actor()
result = _console_services().facade.select_crawl_preview_pages(
tenant_id=tenant_id,
account_id=actor_id,
control_space_id=control_space_id,
run_id=run_id,
payload=_payload(KnowledgeFSCrawlPreviewSelectionPayload),
idempotency_key=_idempotency_key(),
)
return dump_response(KnowledgeFSSourceWorkflowResponse, result), HTTPStatus.ACCEPTED
@console_ns.route("/knowledge-fs/spaces/<string:control_space_id>/sources/<string:source_id>/pages")
@ -1760,7 +2131,7 @@ class KnowledgeFSSpaceUploadCapabilitiesApi(Resource):
@_knowledge_fs_errors
def post(self, control_space_id: str):
direct_origin = dify_config.KNOWLEDGE_FS_DIRECT_ORIGIN
if direct_origin is None:
if direct_origin is None or not dify_config.KNOWLEDGE_FS_DIRECT_UPLOAD_READY:
raise KnowledgeFSOperationUnavailableError("KnowledgeFS direct upload is not configured")
actor_id, tenant_id = _actor()
payload = _payload(KnowledgeFSUploadCapabilityPayload)

View File

@ -1,5 +1,6 @@
from collections.abc import Callable
from functools import wraps
from uuid import UUID
from flask import current_app, request
from flask_login import user_logged_in
@ -55,14 +56,15 @@ def get_user(tenant_id: str, user_id: str | None) -> EndUser:
# session_id, id is auto-generated) and a fresh EndUser
# was created per call, breaking multi-turn chat
# continuation (see #36736).
user_model = session.scalar(
select(EndUser)
.where(
EndUser.id == user_id,
EndUser.tenant_id == tenant_id,
if _is_uuid(user_id):
user_model = session.scalar(
select(EndUser)
.where(
EndUser.id == user_id,
EndUser.tenant_id == tenant_id,
)
.limit(1)
)
.limit(1)
)
if user_model is None:
user_model = session.scalar(
select(EndUser)
@ -90,6 +92,14 @@ def get_user(tenant_id: str, user_id: str | None) -> EndUser:
return user_model
def _is_uuid(value: str) -> bool:
try:
UUID(value)
except ValueError:
return False
return True
def get_user_tenant[**P, R](view_func: Callable[P, R]) -> Callable[P, R]:
@wraps(view_func)
def decorated_view(*args: P.args, **kwargs: P.kwargs) -> R:

View File

@ -95,6 +95,7 @@ from services.knowledge_fs.product_operations import product_operation_action
from services.knowledge_fs.product_remote import (
KnowledgeFSOperationUnavailableError,
KnowledgeFSProductRemoteError,
KnowledgeFSProductResourceNotFoundError,
)
from services.knowledge_fs.runtime import KnowledgeFSRuntime, create_knowledge_fs_runtime
@ -171,6 +172,8 @@ def _service_api_errors[**P, R](view: Callable[P, R]) -> Callable[P, R]:
raise KnowledgeFSInvalidCredentialHTTPError() from exc
except KnowledgeFSOperationUnavailableError as exc:
raise KnowledgeFSServiceOperationUnavailableHTTPError() from exc
except KnowledgeFSProductResourceNotFoundError as exc:
raise NotFound() from exc
except KnowledgeFSProductRemoteError as exc:
raise KnowledgeFSServiceUpstreamUnavailableHTTPError() from exc
except KnowledgeFSOperationRateLimitExceededError as exc:

View File

@ -1,10 +1,23 @@
"""Register API blueprints with their browser-facing CORS policies."""
from configs import dify_config
from constants import HEADER_NAME_APP_CODE, HEADER_NAME_CSRF_TOKEN, HEADER_NAME_PASSPORT
from constants import (
HEADER_NAME_APP_CODE,
HEADER_NAME_CSRF_TOKEN,
HEADER_NAME_IDEMPOTENCY_KEY,
HEADER_NAME_PASSPORT,
HEADER_NAME_REQUEST_ID,
)
from dify_app import DifyApp
BASE_CORS_HEADERS: tuple[str, ...] = ("Content-Type", HEADER_NAME_APP_CODE, HEADER_NAME_PASSPORT)
SERVICE_API_HEADERS: tuple[str, ...] = (*BASE_CORS_HEADERS, "Authorization")
AUTHENTICATED_HEADERS: tuple[str, ...] = (*SERVICE_API_HEADERS, HEADER_NAME_CSRF_TOKEN)
AUTHENTICATED_HEADERS: tuple[str, ...] = (
*SERVICE_API_HEADERS,
HEADER_NAME_CSRF_TOKEN,
HEADER_NAME_IDEMPOTENCY_KEY,
HEADER_NAME_REQUEST_ID,
)
FILES_HEADERS: tuple[str, ...] = (*BASE_CORS_HEADERS, HEADER_NAME_CSRF_TOKEN)
EMBED_HEADERS: tuple[str, ...] = ("Content-Type", HEADER_NAME_APP_CODE)
EXPOSED_HEADERS: tuple[str, ...] = ("X-Version", "X-Env", "X-Trace-Id")

View File

@ -1,9 +1,9 @@
{
"schemaVersion": 5,
"subtreeTree": "4a5f77139bfa81192ca1151aa2cea8aca8a19501",
"openapiSha256": "0a03c2cdc027c8d8d792da97db65f642c2e28ecfd8c4a9bc08aafb53fc38a64b",
"subtreeTree": "2745077bf08ffb7143abe8bd2e14fa235136d276",
"openapiSha256": "5e6d37b22f3e0441492bd928429899d890dfcda8d4645c671fa4d128faa8947d",
"capabilityV2AuthManifestSha256": "fc0a47e23cce12544882f0298522b4933002e892b84ce1815df7e81d36a7a0c7",
"capabilityV2AuthTestVectorSha256": "ae0de37b1ff05c40f905cf17a7b410d8971acacf64db07d5ee3d6fecfa559ce3",
"productOperationManifestSha256": "8dc392325682a307702bc53420616123ab875e6875d3a473a453dca1ac40c61e",
"productOperationManifestSha256": "7b46eaf900d3db5b518d8ab52e3265b7c7bd0b5fbdb831e5647422c9f8975427",
"productOperationGapManifestSha256": "ccbae37fe658177a77529822211a2cf02e72b5815cbc9b7e7e65e6817ba5e0a9"
}

View File

@ -11,6 +11,8 @@
{"productOperationId":"getOverviewHealth","kfsOperationId":"getKnowledgeSpaceProductHealth","method":"GET","path":"/knowledge-spaces/{id}/overview/health","action":"knowledge_spaces.overview.health.read","resource":"knowledge_space","transport":"json","stream":{"productKind":"json","kfsResponseKind":"buffered"},"limits":{"productMaxRequestBytes":0,"productMaxResponseBytes":262144,"kfsMaxResponseBytes":1048576}},
{"productOperationId":"updateSettings","kfsOperationId":"updateKnowledgeSpaceProductSettings","method":"PATCH","path":"/knowledge-spaces/{id}/product-settings","action":"knowledge_spaces.settings.update","resource":"knowledge_space","transport":"json","stream":{"productKind":"json","kfsResponseKind":"buffered"},"limits":{"productMaxRequestBytes":65536,"productMaxResponseBytes":262144,"kfsMaxResponseBytes":1048576}},
{"productOperationId":"listDocuments","kfsOperationId":"listDocuments","method":"GET","path":"/knowledge-spaces/{id}/documents","action":"documents.list","resource":"knowledge_space","transport":"json","stream":{"productKind":"json","kfsResponseKind":"buffered"},"limits":{"productMaxRequestBytes":16384,"productMaxResponseBytes":2097152,"kfsMaxResponseBytes":1048576}},
{"productOperationId":"listLogicalDocuments","kfsOperationId":"listLogicalDocuments","method":"GET","path":"/knowledge-spaces/{id}/logical-documents","action":"logical_documents.list","resource":"knowledge_space","transport":"json","stream":{"productKind":"json","kfsResponseKind":"buffered"},"limits":{"productMaxRequestBytes":16384,"productMaxResponseBytes":2097152,"kfsMaxResponseBytes":1048576}},
{"productOperationId":"getLogicalDocument","kfsOperationId":"getLogicalDocument","method":"GET","path":"/knowledge-spaces/{id}/logical-documents/{documentId}","action":"logical_documents.read","resource":"document","transport":"json","stream":{"productKind":"json","kfsResponseKind":"buffered"},"limits":{"productMaxRequestBytes":0,"productMaxResponseBytes":524288,"kfsMaxResponseBytes":1048576}},
{"productOperationId":"getDocument","kfsOperationId":"getDocument","method":"GET","path":"/knowledge-spaces/{id}/documents/{documentId}","action":"documents.read","resource":"document","transport":"json","stream":{"productKind":"json","kfsResponseKind":"buffered"},"limits":{"productMaxRequestBytes":0,"productMaxResponseBytes":524288,"kfsMaxResponseBytes":1048576}},
{"productOperationId":"getDocumentOutline","kfsOperationId":"getDocumentOutline","method":"GET","path":"/knowledge-spaces/{id}/documents/{documentId}/outline","action":"documents.outline.read","resource":"document","transport":"json","stream":{"productKind":"json","kfsResponseKind":"buffered"},"limits":{"productMaxRequestBytes":0,"productMaxResponseBytes":4194304,"kfsMaxResponseBytes":1048576}},
{"productOperationId":"listDocumentRevisions","kfsOperationId":"listDocumentRevisions","method":"GET","path":"/knowledge-spaces/{id}/documents/{documentId}/revisions","action":"documents.revisions.list","resource":"document","transport":"json","stream":{"productKind":"json","kfsResponseKind":"buffered"},"limits":{"productMaxRequestBytes":16384,"productMaxResponseBytes":2097152,"kfsMaxResponseBytes":1048576}},
@ -33,6 +35,19 @@
{"productOperationId":"updateSource","kfsOperationId":"updateKnowledgeSpaceSource","method":"PATCH","path":"/knowledge-spaces/{id}/sources/{sourceId}","action":"sources.update","resource":"source","transport":"json","stream":{"productKind":"json","kfsResponseKind":"buffered"},"limits":{"productMaxRequestBytes":262144,"productMaxResponseBytes":524288,"kfsMaxResponseBytes":1048576}},
{"productOperationId":"deleteSource","kfsOperationId":"requestSourceDeletion","method":"DELETE","path":"/knowledge-spaces/{id}/sources/{sourceId}","action":"sources.delete","resource":"source","transport":"json","stream":{"productKind":"json","kfsResponseKind":"buffered"},"limits":{"productMaxRequestBytes":32768,"productMaxResponseBytes":524288,"kfsMaxResponseBytes":1048576}},
{"productOperationId":"testSource","kfsOperationId":"testKnowledgeSpaceSource","method":"POST","path":"/knowledge-spaces/{id}/sources/{sourceId}/test","action":"sources.test","resource":"source","transport":"json","stream":{"productKind":"json","kfsResponseKind":"buffered"},"limits":{"productMaxRequestBytes":0,"productMaxResponseBytes":262144,"kfsMaxResponseBytes":1048576}},
{"productOperationId":"syncSource","kfsOperationId":"createSourceSyncWorkflow","method":"POST","path":"/knowledge-spaces/{id}/sources/{sourceId}/sync","action":"source_workflows.sync.create","resource":"source","transport":"json","stream":{"productKind":"json","kfsResponseKind":"buffered"},"limits":{"productMaxRequestBytes":16384,"productMaxResponseBytes":524288,"kfsMaxResponseBytes":1048576}},
{"productOperationId":"listSourceProviders","kfsOperationId":"listSourceProviders","method":"GET","path":"/source-providers","action":"source_providers.list","resource":"namespace","transport":"json","stream":{"productKind":"json","kfsResponseKind":"buffered"},"limits":{"productMaxRequestBytes":0,"productMaxResponseBytes":524288,"kfsMaxResponseBytes":1048576}},
{"productOperationId":"createSourceConnection","kfsOperationId":"createSourceConnection","method":"POST","path":"/knowledge-spaces/{id}/source-connections","action":"source_connections.create","resource":"knowledge_space","transport":"json","stream":{"productKind":"json","kfsResponseKind":"buffered"},"limits":{"productMaxRequestBytes":262144,"productMaxResponseBytes":524288,"kfsMaxResponseBytes":1048576}},
{"productOperationId":"listSourceConnections","kfsOperationId":"listSourceConnections","method":"GET","path":"/knowledge-spaces/{id}/source-connections","action":"source_connections.list","resource":"knowledge_space","transport":"json","stream":{"productKind":"json","kfsResponseKind":"buffered"},"limits":{"productMaxRequestBytes":16384,"productMaxResponseBytes":1048576,"kfsMaxResponseBytes":1048576}},
{"productOperationId":"refreshSourceConnection","kfsOperationId":"refreshSourceConnection","method":"POST","path":"/knowledge-spaces/{id}/source-connections/{connectionId}/refresh","action":"source_connections.refresh","resource":"knowledge_space","transport":"json","stream":{"productKind":"json","kfsResponseKind":"buffered"},"limits":{"productMaxRequestBytes":32768,"productMaxResponseBytes":524288,"kfsMaxResponseBytes":1048576}},
{"productOperationId":"previewSourceCrawl","kfsOperationId":"createSourceCrawlPreviewWorkflow","method":"POST","path":"/knowledge-spaces/{id}/sources/{sourceId}/crawl-preview","action":"source_workflows.preview.create","resource":"source","transport":"json","stream":{"productKind":"json","kfsResponseKind":"buffered"},"limits":{"productMaxRequestBytes":16384,"productMaxResponseBytes":524288,"kfsMaxResponseBytes":1048576}},
{"productOperationId":"getSourceSyncPolicy","kfsOperationId":"getSourceSyncPolicy","method":"GET","path":"/knowledge-spaces/{id}/sources/{sourceId}/sync-policy","action":"source_sync_policies.read","resource":"source","transport":"json","stream":{"productKind":"json","kfsResponseKind":"buffered"},"limits":{"productMaxRequestBytes":0,"productMaxResponseBytes":262144,"kfsMaxResponseBytes":1048576}},
{"productOperationId":"updateSourceSyncPolicy","kfsOperationId":"putSourceSyncPolicy","method":"PUT","path":"/knowledge-spaces/{id}/sources/{sourceId}/sync-policy","action":"source_sync_policies.update","resource":"source","transport":"json","stream":{"productKind":"json","kfsResponseKind":"buffered"},"limits":{"productMaxRequestBytes":32768,"productMaxResponseBytes":262144,"kfsMaxResponseBytes":1048576}},
{"productOperationId":"getSourceWorkflow","kfsOperationId":"getSourceWorkflow","method":"GET","path":"/knowledge-spaces/{id}/source-workflows/{runId}","action":"source_workflows.read","resource":"job","transport":"json","stream":{"productKind":"json","kfsResponseKind":"buffered"},"limits":{"productMaxRequestBytes":0,"productMaxResponseBytes":524288,"kfsMaxResponseBytes":1048576}},
{"productOperationId":"cancelSourceWorkflow","kfsOperationId":"cancelSourceWorkflow","method":"POST","path":"/knowledge-spaces/{id}/source-workflows/{runId}/cancel","action":"source_workflows.cancel","resource":"job","transport":"json","stream":{"productKind":"json","kfsResponseKind":"buffered"},"limits":{"productMaxRequestBytes":32768,"productMaxResponseBytes":524288,"kfsMaxResponseBytes":1048576}},
{"productOperationId":"retrySourceWorkflow","kfsOperationId":"retrySourceWorkflow","method":"POST","path":"/knowledge-spaces/{id}/source-workflows/{runId}/retry","action":"source_workflows.retry","resource":"job","transport":"json","stream":{"productKind":"json","kfsResponseKind":"buffered"},"limits":{"productMaxRequestBytes":0,"productMaxResponseBytes":524288,"kfsMaxResponseBytes":1048576}},
{"productOperationId":"listCrawlPreviewPages","kfsOperationId":"listCrawlPreviewPages","method":"GET","path":"/knowledge-spaces/{id}/source-workflows/{runId}/pages","action":"source_workflows.pages.list","resource":"job","transport":"json","stream":{"productKind":"json","kfsResponseKind":"buffered"},"limits":{"productMaxRequestBytes":16384,"productMaxResponseBytes":4194304,"kfsMaxResponseBytes":1048576}},
{"productOperationId":"selectCrawlPreviewPages","kfsOperationId":"selectCrawlPreviewPages","method":"POST","path":"/knowledge-spaces/{id}/source-workflows/{runId}/selection","action":"source_workflows.selection.create","resource":"job","transport":"json","stream":{"productKind":"json","kfsResponseKind":"buffered"},"limits":{"productMaxRequestBytes":262144,"productMaxResponseBytes":524288,"kfsMaxResponseBytes":1048576}},
{"productOperationId":"crawlSource","kfsOperationId":"crawlKnowledgeSpaceSource","method":"POST","path":"/knowledge-spaces/{id}/sources/{sourceId}/crawl","action":"sources.crawl","resource":"source","transport":"json","stream":{"productKind":"json","kfsResponseKind":"buffered"},"limits":{"productMaxRequestBytes":0,"productMaxResponseBytes":8388608,"kfsMaxResponseBytes":1048576}},
{"productOperationId":"listSourcePages","kfsOperationId":"listKnowledgeSpaceSourcePages","method":"GET","path":"/knowledge-spaces/{id}/sources/{sourceId}/pages","action":"sources.pages.list","resource":"source","transport":"json","stream":{"productKind":"json","kfsResponseKind":"buffered"},"limits":{"productMaxRequestBytes":16384,"productMaxResponseBytes":4194304,"kfsMaxResponseBytes":1048576}},
{"productOperationId":"importSourcePages","kfsOperationId":"importKnowledgeSpaceSourcePages","method":"POST","path":"/knowledge-spaces/{id}/sources/{sourceId}/import","action":"sources.pages.import","resource":"source","transport":"json","stream":{"productKind":"json","kfsResponseKind":"buffered"},"limits":{"productMaxRequestBytes":1048576,"productMaxResponseBytes":4194304,"kfsMaxResponseBytes":1048576}},

View File

@ -186,6 +186,8 @@ class SystemFeatureModel(FeatureResponseModel):
enable_learn_app: bool = True
enable_step_by_step_tour: bool = False
rbac_enabled: bool = False
knowledge_fs_enabled: bool = False
knowledge_fs_upload_enabled: bool = False
class FeatureService:
@ -289,6 +291,12 @@ class FeatureService:
system_features.enable_learn_app = dify_config.ENABLE_LEARN_APP
system_features.webapp_auth.allow_public_access = dify_config.WEBAPP_PUBLIC_ACCESS_ENABLED
system_features.enable_step_by_step_tour = dify_config.ENABLE_STEP_BY_STEP_TOUR
system_features.knowledge_fs_enabled = dify_config.KNOWLEDGE_FS_ENABLED
system_features.knowledge_fs_upload_enabled = bool(
dify_config.KNOWLEDGE_FS_ENABLED
and dify_config.KNOWLEDGE_FS_DIRECT_ORIGIN
and dify_config.KNOWLEDGE_FS_DIRECT_UPLOAD_READY
)
@classmethod
def _fulfill_trial_models_from_env(cls) -> list[str]:

View File

@ -422,7 +422,9 @@ def _issue_request(
trace_id: str,
) -> CapabilityIssueRequest:
capability_operation = KNOWLEDGE_FS_CAPABILITY_OPERATIONS[capability_operation_id]
if capability_operation.resource_type == "knowledge_space":
if capability_operation.resource_type == "namespace":
resource = CapabilityResource(type="namespace", id=tenant_id)
elif capability_operation.resource_type == "knowledge_space":
resource = CapabilityResource(type="knowledge_space", id=knowledge_space_id)
elif capability_operation.resource_type in {
"document",

View File

@ -18,6 +18,8 @@ from services.knowledge_fs.product_dto import (
KnowledgeFSBulkDeletionAcceptedResponse,
KnowledgeFSBulkDocumentDeletePayload,
KnowledgeFSBulkJobResponse,
KnowledgeFSCrawlPreviewPageListResponse,
KnowledgeFSCrawlPreviewSelectionPayload,
KnowledgeFSDocumentChunkListResponse,
KnowledgeFSDocumentChunkResponse,
KnowledgeFSDocumentCompilationJobResponse,
@ -31,6 +33,7 @@ from services.knowledge_fs.product_dto import (
KnowledgeFSDocumentResponse,
KnowledgeFSDocumentRevisionListResponse,
KnowledgeFSDurableDeletionAcceptedResponse,
KnowledgeFSLogicalDocumentListResponse,
KnowledgeFSLogicalDocumentResponse,
KnowledgeFSOverviewBaseStatsResponse,
KnowledgeFSOverviewHealthResponse,
@ -47,6 +50,10 @@ from services.knowledge_fs.product_dto import (
KnowledgeFSSettingsPayload,
KnowledgeFSSettingsResponse,
KnowledgeFSSmallFileUploadResponse,
KnowledgeFSSourceConnectionCreatePayload,
KnowledgeFSSourceConnectionListResponse,
KnowledgeFSSourceConnectionRefreshPayload,
KnowledgeFSSourceConnectionResponse,
KnowledgeFSSourceCrawlResponse,
KnowledgeFSSourceCreatePayload,
KnowledgeFSSourceCredentialTestResponse,
@ -57,8 +64,13 @@ from services.knowledge_fs.product_dto import (
KnowledgeFSSourceImportResponse,
KnowledgeFSSourceListResponse,
KnowledgeFSSourcePagesResponse,
KnowledgeFSSourceProviderListResponse,
KnowledgeFSSourceResponse,
KnowledgeFSSourceSyncPolicyPayload,
KnowledgeFSSourceSyncPolicyResponse,
KnowledgeFSSourceUpdatePayload,
KnowledgeFSSourceWorkflowCancelPayload,
KnowledgeFSSourceWorkflowResponse,
KnowledgeFSSpaceUpdatePayload,
KnowledgeFSTraceEntryListResponse,
KnowledgeFSTraceListResponse,
@ -194,6 +206,36 @@ class KnowledgeFSDataFacade:
)
return KnowledgeFSDocumentListResponse.model_validate(raw)
def list_logical_documents(
self,
*,
tenant_id: str,
account_id: str,
control_space_id: str,
cursor: str | None,
) -> KnowledgeFSLogicalDocumentListResponse:
raw = self._interactive(
tenant_id=tenant_id,
account_id=account_id,
control_space_id=control_space_id,
operation_id="listLogicalDocuments",
query=(("cursor", cursor),) if cursor else (),
)
return KnowledgeFSLogicalDocumentListResponse.model_validate(raw)
def get_logical_document(
self, *, tenant_id: str, account_id: str, control_space_id: str, document_id: str
) -> KnowledgeFSLogicalDocumentResponse:
raw = self._interactive_child(
tenant_id=tenant_id,
account_id=account_id,
control_space_id=control_space_id,
operation_id="getLogicalDocument",
resource_id=document_id,
path_parameters=(("documentId", document_id),),
)
return KnowledgeFSLogicalDocumentResponse.model_validate(raw)
def create_document(
self,
*,
@ -631,6 +673,235 @@ class KnowledgeFSDataFacade:
)
return KnowledgeFSSourceCredentialTestResponse.model_validate(raw)
def sync_source(
self,
*,
tenant_id: str,
account_id: str,
control_space_id: str,
source_id: str,
idempotency_key: str,
) -> KnowledgeFSSourceWorkflowResponse:
raw = self._interactive_child(
tenant_id=tenant_id,
account_id=account_id,
control_space_id=control_space_id,
operation_id="syncSource",
resource_id=source_id,
path_parameters=(("sourceId", source_id),),
headers=(("Idempotency-Key", idempotency_key),),
)
return KnowledgeFSSourceWorkflowResponse.model_validate(raw)
def list_source_providers(
self, *, tenant_id: str, account_id: str, control_space_id: str
) -> KnowledgeFSSourceProviderListResponse:
raw = self._interactive(
tenant_id=tenant_id,
account_id=account_id,
control_space_id=control_space_id,
operation_id="listSourceProviders",
)
return KnowledgeFSSourceProviderListResponse.model_validate(raw)
def create_source_connection(
self,
*,
tenant_id: str,
account_id: str,
control_space_id: str,
payload: KnowledgeFSSourceConnectionCreatePayload,
) -> KnowledgeFSSourceConnectionResponse:
raw = self._interactive(
tenant_id=tenant_id,
account_id=account_id,
control_space_id=control_space_id,
operation_id="createSourceConnection",
payload=payload,
)
return KnowledgeFSSourceConnectionResponse.model_validate(raw)
def list_source_connections(
self,
*,
tenant_id: str,
account_id: str,
control_space_id: str,
cursor: str | None,
limit: int,
) -> KnowledgeFSSourceConnectionListResponse:
query = (("limit", str(limit)),) + ((("cursor", cursor),) if cursor else ())
raw = self._interactive(
tenant_id=tenant_id,
account_id=account_id,
control_space_id=control_space_id,
operation_id="listSourceConnections",
query=query,
)
return KnowledgeFSSourceConnectionListResponse.model_validate(raw)
def refresh_source_connection(
self,
*,
tenant_id: str,
account_id: str,
control_space_id: str,
connection_id: str,
payload: KnowledgeFSSourceConnectionRefreshPayload,
) -> KnowledgeFSSourceConnectionResponse:
raw = self._interactive(
tenant_id=tenant_id,
account_id=account_id,
control_space_id=control_space_id,
operation_id="refreshSourceConnection",
payload=payload,
path_parameters=(("connectionId", connection_id),),
)
return KnowledgeFSSourceConnectionResponse.model_validate(raw)
def preview_source_crawl(
self,
*,
tenant_id: str,
account_id: str,
control_space_id: str,
source_id: str,
idempotency_key: str,
) -> KnowledgeFSSourceWorkflowResponse:
raw = self._interactive_child(
tenant_id=tenant_id,
account_id=account_id,
control_space_id=control_space_id,
operation_id="previewSourceCrawl",
resource_id=source_id,
path_parameters=(("sourceId", source_id),),
headers=(("Idempotency-Key", idempotency_key),),
)
return KnowledgeFSSourceWorkflowResponse.model_validate(raw)
def get_source_sync_policy(
self, *, tenant_id: str, account_id: str, control_space_id: str, source_id: str
) -> KnowledgeFSSourceSyncPolicyResponse:
raw = self._interactive_child(
tenant_id=tenant_id,
account_id=account_id,
control_space_id=control_space_id,
operation_id="getSourceSyncPolicy",
resource_id=source_id,
path_parameters=(("sourceId", source_id),),
)
return KnowledgeFSSourceSyncPolicyResponse.model_validate(raw)
def update_source_sync_policy(
self,
*,
tenant_id: str,
account_id: str,
control_space_id: str,
source_id: str,
payload: KnowledgeFSSourceSyncPolicyPayload,
) -> KnowledgeFSSourceSyncPolicyResponse:
raw = self._interactive_child(
tenant_id=tenant_id,
account_id=account_id,
control_space_id=control_space_id,
operation_id="updateSourceSyncPolicy",
resource_id=source_id,
path_parameters=(("sourceId", source_id),),
payload=payload,
)
return KnowledgeFSSourceSyncPolicyResponse.model_validate(raw)
def get_source_workflow(
self, *, tenant_id: str, account_id: str, control_space_id: str, run_id: str
) -> KnowledgeFSSourceWorkflowResponse:
raw = self._interactive_child(
tenant_id=tenant_id,
account_id=account_id,
control_space_id=control_space_id,
operation_id="getSourceWorkflow",
resource_id=run_id,
path_parameters=(("runId", run_id),),
)
return KnowledgeFSSourceWorkflowResponse.model_validate(raw)
def cancel_source_workflow(
self,
*,
tenant_id: str,
account_id: str,
control_space_id: str,
run_id: str,
payload: KnowledgeFSSourceWorkflowCancelPayload,
) -> KnowledgeFSSourceWorkflowResponse:
raw = self._interactive_child(
tenant_id=tenant_id,
account_id=account_id,
control_space_id=control_space_id,
operation_id="cancelSourceWorkflow",
resource_id=run_id,
path_parameters=(("runId", run_id),),
payload=payload,
)
return KnowledgeFSSourceWorkflowResponse.model_validate(raw)
def retry_source_workflow(
self, *, tenant_id: str, account_id: str, control_space_id: str, run_id: str
) -> KnowledgeFSSourceWorkflowResponse:
raw = self._interactive_child(
tenant_id=tenant_id,
account_id=account_id,
control_space_id=control_space_id,
operation_id="retrySourceWorkflow",
resource_id=run_id,
path_parameters=(("runId", run_id),),
)
return KnowledgeFSSourceWorkflowResponse.model_validate(raw)
def list_crawl_preview_pages(
self,
*,
tenant_id: str,
account_id: str,
control_space_id: str,
run_id: str,
cursor: str | None,
limit: int,
) -> KnowledgeFSCrawlPreviewPageListResponse:
query = (("limit", str(limit)),) + ((("cursor", cursor),) if cursor else ())
raw = self._interactive_child(
tenant_id=tenant_id,
account_id=account_id,
control_space_id=control_space_id,
operation_id="listCrawlPreviewPages",
resource_id=run_id,
path_parameters=(("runId", run_id),),
query=query,
)
return KnowledgeFSCrawlPreviewPageListResponse.model_validate(raw)
def select_crawl_preview_pages(
self,
*,
tenant_id: str,
account_id: str,
control_space_id: str,
run_id: str,
payload: KnowledgeFSCrawlPreviewSelectionPayload,
idempotency_key: str,
) -> KnowledgeFSSourceWorkflowResponse:
raw = self._interactive_child(
tenant_id=tenant_id,
account_id=account_id,
control_space_id=control_space_id,
operation_id="selectCrawlPreviewPages",
resource_id=run_id,
path_parameters=(("runId", run_id),),
payload=payload,
headers=(("Idempotency-Key", idempotency_key),),
)
return KnowledgeFSSourceWorkflowResponse.model_validate(raw)
def crawl_source(
self, *, tenant_id: str, account_id: str, control_space_id: str, source_id: str
) -> KnowledgeFSSourceCrawlResponse:

View File

@ -354,6 +354,7 @@ class KnowledgeFSOverviewStatsResponse(ResponseModel):
class KnowledgeFSSpaceListItemResponse(ResponseModel):
control_space_id: str
created_at: datetime
state: KnowledgeFSControlSpaceState
visibility: KnowledgeFSControlSpaceVisibility
owner_account_id: str
@ -362,6 +363,7 @@ class KnowledgeFSSpaceListItemResponse(ResponseModel):
permission_keys: list[KnowledgeFSProductPermission]
technical_status: Literal["available", "not_ready", "unavailable"]
technical_summary: KnowledgeFSTechnicalSummary | None = None
updated_at: datetime
class KnowledgeFSSpaceListResponse(ResponseModel):
@ -372,8 +374,7 @@ class KnowledgeFSSpaceListResponse(ResponseModel):
class KnowledgeFSSpaceDetailResponse(KnowledgeFSSpaceListItemResponse):
created_at: datetime
updated_at: datetime
pass
class KnowledgeFSSpaceCreateResponse(ResponseModel):
@ -662,6 +663,11 @@ class KnowledgeFSLogicalDocumentResponse(ResponseModel):
user_metadata: dict[str, object] = Field(validation_alias=AliasChoices("user_metadata", "userMetadata"))
class KnowledgeFSLogicalDocumentListResponse(ResponseModel):
data: list[KnowledgeFSLogicalDocumentResponse] = Field(validation_alias=AliasChoices("data", "items"))
next_cursor: str | None = Field(default=None, validation_alias=AliasChoices("next_cursor", "nextCursor"))
class KnowledgeFSDocumentRevisionListResponse(ResponseModel):
data: list[KnowledgeFSDocumentRevisionResponse] = Field(validation_alias=AliasChoices("data", "items"))
next_cursor: str | None = Field(default=None, validation_alias=AliasChoices("next_cursor", "nextCursor"))
@ -963,6 +969,162 @@ class KnowledgeFSSourceCredentialTestResponse(ResponseModel):
valid: bool
class KnowledgeFSSourceWorkflowResponse(ResponseModel):
canceled_at: datetime | None = Field(default=None, validation_alias=AliasChoices("canceled_at", "canceledAt"))
checkpoint: str
completed_at: datetime | None = Field(default=None, validation_alias=AliasChoices("completed_at", "completedAt"))
created_at: datetime = Field(validation_alias=AliasChoices("created_at", "createdAt"))
cursor: str | None = None
execution_attempts: int = Field(ge=0, validation_alias=AliasChoices("execution_attempts", "executionAttempts"))
id: str
knowledge_space_id: str = Field(validation_alias=AliasChoices("knowledge_space_id", "knowledgeSpaceId"))
kind: str
last_error_code: str | None = Field(default=None, validation_alias=AliasChoices("last_error_code", "lastErrorCode"))
max_execution_attempts: int = Field(
ge=1, validation_alias=AliasChoices("max_execution_attempts", "maxExecutionAttempts")
)
progress_completed: int = Field(ge=0, validation_alias=AliasChoices("progress_completed", "progressCompleted"))
progress_failed: int = Field(ge=0, validation_alias=AliasChoices("progress_failed", "progressFailed"))
progress_skipped: int = Field(ge=0, validation_alias=AliasChoices("progress_skipped", "progressSkipped"))
progress_total: int | None = Field(
default=None, ge=0, validation_alias=AliasChoices("progress_total", "progressTotal")
)
source_id: str | None = Field(default=None, validation_alias=AliasChoices("source_id", "sourceId"))
state: str
updated_at: datetime = Field(validation_alias=AliasChoices("updated_at", "updatedAt"))
class KnowledgeFSSourceProviderFieldResponse(ResponseModel):
description: str | None = None
format: Literal["password", "uri"] | None = None
name: str
required: bool
secret: bool
type: Literal["boolean", "integer", "string"]
class KnowledgeFSSourceProviderResponse(ResponseModel):
auth_kinds: list[Literal["api-key", "endpoint", "oauth2"]] = Field(
validation_alias=AliasChoices("auth_kinds", "authKinds")
)
available: bool
capabilities: list[Literal["website-crawl", "online-document", "online-drive"]]
configuration: list[KnowledgeFSSourceProviderFieldResponse]
display_name: str = Field(validation_alias=AliasChoices("display_name", "displayName"))
id: str
unavailable_reason: str | None = Field(
default=None, validation_alias=AliasChoices("unavailable_reason", "unavailableReason")
)
class KnowledgeFSSourceProviderListResponse(ResponseModel):
data: list[KnowledgeFSSourceProviderResponse] = Field(validation_alias=AliasChoices("data", "items"))
class KnowledgeFSSourceConnectionCreatePayload(BaseModel):
auth_kind: Literal["api-key", "endpoint"] = Field(alias="authKind")
configuration: dict[str, bool | int | str] = Field(default_factory=dict)
credentials: dict[str, object]
name: str = Field(min_length=1, max_length=160)
provider_id: str = Field(min_length=1, max_length=128, alias="providerId")
model_config = ConfigDict(extra="forbid", validate_by_alias=True, validate_by_name=True)
class KnowledgeFSSourceConnectionResponse(ResponseModel):
auth_kind: Literal["api-key", "endpoint", "oauth2"] = Field(validation_alias=AliasChoices("auth_kind", "authKind"))
configuration: dict[str, bool | int | str]
created_at: datetime = Field(validation_alias=AliasChoices("created_at", "createdAt"))
error_code: str | None = Field(default=None, validation_alias=AliasChoices("error_code", "errorCode"))
expires_at: datetime | None = Field(default=None, validation_alias=AliasChoices("expires_at", "expiresAt"))
id: str
knowledge_space_id: str = Field(validation_alias=AliasChoices("knowledge_space_id", "knowledgeSpaceId"))
name: str
provider_id: str = Field(validation_alias=AliasChoices("provider_id", "providerId"))
scopes: list[str]
status: Literal["provisioning", "active", "expired", "error", "revoked"]
updated_at: datetime = Field(validation_alias=AliasChoices("updated_at", "updatedAt"))
version: int = Field(ge=1)
class KnowledgeFSSourceConnectionListQuery(BaseModel):
cursor: str | None = Field(default=None, min_length=1, max_length=4_096)
limit: int = Field(default=50, ge=1, le=200)
model_config = ConfigDict(extra="forbid")
class KnowledgeFSSourceConnectionListResponse(ResponseModel):
data: list[KnowledgeFSSourceConnectionResponse] = Field(validation_alias=AliasChoices("data", "items"))
next_cursor: str | None = Field(default=None, validation_alias=AliasChoices("next_cursor", "nextCursor"))
class KnowledgeFSSourceConnectionRefreshPayload(BaseModel):
expected_version: int = Field(ge=1, alias="expectedVersion")
model_config = ConfigDict(extra="forbid", validate_by_alias=True, validate_by_name=True)
class KnowledgeFSSourceSyncPolicyResponse(ResponseModel):
created_at: datetime = Field(validation_alias=AliasChoices("created_at", "createdAt"))
custom_interval_seconds: int | None = Field(
default=None, validation_alias=AliasChoices("custom_interval_seconds", "customIntervalSeconds")
)
enabled: bool
expected_source_version: int = Field(
ge=1, validation_alias=AliasChoices("expected_source_version", "expectedSourceVersion")
)
id: str
knowledge_space_id: str = Field(validation_alias=AliasChoices("knowledge_space_id", "knowledgeSpaceId"))
mode: Literal["provider", "manual", "interval", "custom"]
next_run_at: datetime | None = Field(default=None, validation_alias=AliasChoices("next_run_at", "nextRunAt"))
revision: int = Field(ge=1)
source_id: str = Field(validation_alias=AliasChoices("source_id", "sourceId"))
updated_at: datetime = Field(validation_alias=AliasChoices("updated_at", "updatedAt"))
class KnowledgeFSSourceSyncPolicyPayload(BaseModel):
custom_interval_seconds: int | None = Field(default=None, ge=3_600, le=2_592_000, alias="customIntervalSeconds")
enabled: bool
expected_revision: int = Field(ge=0, alias="expectedRevision")
expected_source_version: int = Field(ge=1, alias="expectedSourceVersion")
mode: Literal["provider", "manual", "interval", "custom"]
model_config = ConfigDict(extra="forbid", validate_by_alias=True, validate_by_name=True)
class KnowledgeFSSourceWorkflowCancelPayload(BaseModel):
reason: str | None = Field(default=None, max_length=1_000)
model_config = ConfigDict(extra="forbid")
class KnowledgeFSCrawlPreviewPageResponse(ResponseModel):
description: str | None = None
etag: str | None = None
page_id: str = Field(validation_alias=AliasChoices("page_id", "pageId"))
source_url: str = Field(validation_alias=AliasChoices("source_url", "sourceUrl"))
title: str | None = None
class KnowledgeFSCrawlPreviewPageListQuery(BaseModel):
cursor: str | None = Field(default=None, min_length=1, max_length=4_096)
limit: int = Field(default=50, ge=1, le=200)
model_config = ConfigDict(extra="forbid")
class KnowledgeFSCrawlPreviewPageListResponse(ResponseModel):
data: list[KnowledgeFSCrawlPreviewPageResponse] = Field(validation_alias=AliasChoices("data", "items"))
next_cursor: str | None = Field(default=None, validation_alias=AliasChoices("next_cursor", "nextCursor"))
class KnowledgeFSCrawlPreviewSelectionPayload(BaseModel):
page_ids: list[str] = Field(min_length=1, max_length=200, alias="pageIds")
model_config = ConfigDict(extra="forbid", validate_by_alias=True, validate_by_name=True)
class KnowledgeFSCrawledPageResponse(ResponseModel):
content: str
description: str | None = None
@ -1468,6 +1630,9 @@ __all__ = [
"KnowledgeFSBulkDocumentDeletePayload",
"KnowledgeFSBulkJobResponse",
"KnowledgeFSCapabilityResponse",
"KnowledgeFSCrawlPreviewPageListQuery",
"KnowledgeFSCrawlPreviewPageListResponse",
"KnowledgeFSCrawlPreviewSelectionPayload",
"KnowledgeFSCredentialCreatePayload",
"KnowledgeFSCredentialCreateResponse",
"KnowledgeFSCredentialItemResponse",
@ -1492,6 +1657,8 @@ __all__ = [
"KnowledgeFSIdempotencyHeader",
"KnowledgeFSJWKResponse",
"KnowledgeFSJWKSResponse",
"KnowledgeFSLogicalDocumentListResponse",
"KnowledgeFSLogicalDocumentResponse",
"KnowledgeFSMemberBindingPayload",
"KnowledgeFSMembersReplacePayload",
"KnowledgeFSModelIntent",
@ -1532,6 +1699,11 @@ __all__ = [
"KnowledgeFSSettingsPayload",
"KnowledgeFSSettingsResponse",
"KnowledgeFSSmallFileUploadResponse",
"KnowledgeFSSourceConnectionCreatePayload",
"KnowledgeFSSourceConnectionListQuery",
"KnowledgeFSSourceConnectionListResponse",
"KnowledgeFSSourceConnectionRefreshPayload",
"KnowledgeFSSourceConnectionResponse",
"KnowledgeFSSourceCrawlResponse",
"KnowledgeFSSourceCreatePayload",
"KnowledgeFSSourceCredentialTestResponse",
@ -1545,8 +1717,13 @@ __all__ = [
"KnowledgeFSSourceListResponse",
"KnowledgeFSSourcePagesQuery",
"KnowledgeFSSourcePagesResponse",
"KnowledgeFSSourceProviderListResponse",
"KnowledgeFSSourceResponse",
"KnowledgeFSSourceSyncPolicyPayload",
"KnowledgeFSSourceSyncPolicyResponse",
"KnowledgeFSSourceUpdatePayload",
"KnowledgeFSSourceWorkflowCancelPayload",
"KnowledgeFSSourceWorkflowResponse",
"KnowledgeFSSpaceCreatePayload",
"KnowledgeFSSpaceCreateResponse",
"KnowledgeFSSpaceDetailResponse",

View File

@ -233,6 +233,30 @@ KNOWLEDGE_FS_PRODUCT_OPERATIONS: Final[MappingProxyType[str, KnowledgeFSProductO
max_response_bytes=2 * 1024 * 1024,
stream_kind="json",
),
"listLogicalDocuments": _operation(
"GET",
"listLogicalDocuments",
KnowledgeFSProductPermission.READ,
"/knowledge-spaces/{id}/logical-documents",
"json",
resource_resolver="knowledge_space",
billing_cost=2,
max_request_bytes=16 * 1024,
max_response_bytes=2 * 1024 * 1024,
stream_kind="json",
),
"getLogicalDocument": _operation(
"GET",
"getLogicalDocument",
KnowledgeFSProductPermission.READ,
"/knowledge-spaces/{id}/logical-documents/{documentId}",
"json",
resource_resolver="document",
billing_cost=2,
max_request_bytes=0,
max_response_bytes=512 * 1024,
stream_kind="json",
),
"createDocument": _operation(
"POST",
"uploadDocument",
@ -510,6 +534,165 @@ KNOWLEDGE_FS_PRODUCT_OPERATIONS: Final[MappingProxyType[str, KnowledgeFSProductO
max_response_bytes=256 * 1024,
stream_kind="json",
),
"syncSource": _operation(
"POST",
"createSourceSyncWorkflow",
KnowledgeFSProductPermission.DOCUMENT_WRITE,
"/knowledge-spaces/{id}/sources/{sourceId}/sync",
"json",
resource_resolver="source",
billing_cost=8,
max_request_bytes=16 * 1024,
max_response_bytes=512 * 1024,
stream_kind="json",
rate_limit_bucket="import",
),
"listSourceProviders": _operation(
"GET",
"listSourceProviders",
KnowledgeFSProductPermission.READ,
"/source-providers",
"json",
resource_resolver="namespace",
billing_cost=1,
max_request_bytes=0,
max_response_bytes=512 * 1024,
stream_kind="json",
),
"createSourceConnection": _operation(
"POST",
"createSourceConnection",
KnowledgeFSProductPermission.DOCUMENT_WRITE,
"/knowledge-spaces/{id}/source-connections",
"json",
resource_resolver="knowledge_space",
billing_cost=5,
max_request_bytes=256 * 1024,
max_response_bytes=512 * 1024,
stream_kind="json",
),
"listSourceConnections": _operation(
"GET",
"listSourceConnections",
KnowledgeFSProductPermission.READ,
"/knowledge-spaces/{id}/source-connections",
"json",
resource_resolver="knowledge_space",
billing_cost=1,
max_request_bytes=16 * 1024,
max_response_bytes=1024 * 1024,
stream_kind="json",
),
"refreshSourceConnection": _operation(
"POST",
"refreshSourceConnection",
KnowledgeFSProductPermission.DOCUMENT_WRITE,
"/knowledge-spaces/{id}/source-connections/{connectionId}/refresh",
"json",
resource_resolver="knowledge_space",
billing_cost=3,
max_request_bytes=32 * 1024,
max_response_bytes=512 * 1024,
stream_kind="json",
),
"previewSourceCrawl": _operation(
"POST",
"createSourceCrawlPreviewWorkflow",
KnowledgeFSProductPermission.DOCUMENT_WRITE,
"/knowledge-spaces/{id}/sources/{sourceId}/crawl-preview",
"json",
resource_resolver="source",
billing_cost=8,
max_request_bytes=16 * 1024,
max_response_bytes=512 * 1024,
stream_kind="json",
rate_limit_bucket="import",
),
"getSourceSyncPolicy": _operation(
"GET",
"getSourceSyncPolicy",
KnowledgeFSProductPermission.READ,
"/knowledge-spaces/{id}/sources/{sourceId}/sync-policy",
"json",
resource_resolver="source",
billing_cost=1,
max_request_bytes=0,
max_response_bytes=256 * 1024,
stream_kind="json",
),
"updateSourceSyncPolicy": _operation(
"PUT",
"putSourceSyncPolicy",
KnowledgeFSProductPermission.DOCUMENT_WRITE,
"/knowledge-spaces/{id}/sources/{sourceId}/sync-policy",
"json",
resource_resolver="source",
billing_cost=3,
max_request_bytes=32 * 1024,
max_response_bytes=256 * 1024,
stream_kind="json",
),
"getSourceWorkflow": _operation(
"GET",
"getSourceWorkflow",
KnowledgeFSProductPermission.READ,
"/knowledge-spaces/{id}/source-workflows/{runId}",
"json",
resource_resolver="job",
billing_cost=1,
max_request_bytes=0,
max_response_bytes=512 * 1024,
stream_kind="json",
),
"cancelSourceWorkflow": _operation(
"POST",
"cancelSourceWorkflow",
KnowledgeFSProductPermission.DOCUMENT_WRITE,
"/knowledge-spaces/{id}/source-workflows/{runId}/cancel",
"json",
resource_resolver="job",
billing_cost=2,
max_request_bytes=32 * 1024,
max_response_bytes=512 * 1024,
stream_kind="json",
),
"retrySourceWorkflow": _operation(
"POST",
"retrySourceWorkflow",
KnowledgeFSProductPermission.DOCUMENT_WRITE,
"/knowledge-spaces/{id}/source-workflows/{runId}/retry",
"json",
resource_resolver="job",
billing_cost=3,
max_request_bytes=0,
max_response_bytes=512 * 1024,
stream_kind="json",
),
"listCrawlPreviewPages": _operation(
"GET",
"listCrawlPreviewPages",
KnowledgeFSProductPermission.READ,
"/knowledge-spaces/{id}/source-workflows/{runId}/pages",
"json",
resource_resolver="job",
billing_cost=1,
max_request_bytes=16 * 1024,
max_response_bytes=4 * 1024 * 1024,
stream_kind="json",
),
"selectCrawlPreviewPages": _operation(
"POST",
"selectCrawlPreviewPages",
KnowledgeFSProductPermission.DOCUMENT_WRITE,
"/knowledge-spaces/{id}/source-workflows/{runId}/selection",
"json",
resource_resolver="job",
billing_cost=8,
max_request_bytes=256 * 1024,
max_response_bytes=512 * 1024,
stream_kind="json",
rate_limit_bucket="import",
),
"crawlSource": _operation(
"POST",
"crawlKnowledgeSpaceSource",

View File

@ -13,6 +13,10 @@ class KnowledgeFSProductRemoteError(RuntimeError):
"""KnowledgeFS could not provide an authoritative product response."""
class KnowledgeFSProductResourceNotFoundError(KnowledgeFSProductRemoteError):
"""KnowledgeFS authoritatively reported that an authorized child resource is absent."""
class KnowledgeFSOperationUnavailableError(RuntimeError):
"""The Dify/KFS/Capability operation manifests are not yet aligned."""
@ -96,6 +100,7 @@ __all__ = [
"KnowledgeFSProductRemoteError",
"KnowledgeFSProductRemotePort",
"KnowledgeFSProductRequestRejectedError",
"KnowledgeFSProductResourceNotFoundError",
"KnowledgeFSRemoteBinaryRequest",
"KnowledgeFSRemoteJSONRequest",
"UnavailableKnowledgeFSProductRemote",

View File

@ -20,6 +20,7 @@ from services.knowledge_fs.product_remote import (
KnowledgeFSOperationUnavailableError,
KnowledgeFSProductRemoteError,
KnowledgeFSProductRequestRejectedError,
KnowledgeFSProductResourceNotFoundError,
KnowledgeFSRemoteBinaryRequest,
KnowledgeFSRemoteJSONRequest,
)
@ -167,6 +168,8 @@ class HTTPKnowledgeFSProductRemoteClient:
content_type = response.headers.get("content-type", "").partition(";")[0].strip().lower()
if content_type != "application/json" and not content_type.endswith("+json"):
raise KnowledgeFSProductRemoteError("KnowledgeFS returned an unsupported media type")
if response.status_code == HTTPStatus.NOT_FOUND:
raise KnowledgeFSProductResourceNotFoundError("KnowledgeFS resource was not found")
if not HTTPStatus.OK <= response.status_code < HTTPStatus.MULTIPLE_CHOICES:
raise KnowledgeFSProductRemoteError(f"KnowledgeFS returned HTTP {response.status_code}")
try:
@ -234,9 +237,17 @@ class HTTPKnowledgeFSProductRemoteClient:
except (ssrf_proxy.ResponseLimitError, httpx.RequestError, ToolSSRFError) as exc:
raise KnowledgeFSProductRemoteError("KnowledgeFS request failed") from exc
try:
if response.status_code == 409:
raise KnowledgeFSProductRequestRejectedError(status_code=409)
if response.status_code == 413:
raise KnowledgeFSProductRequestRejectedError(status_code=413)
if response.status_code == 422:
raise KnowledgeFSProductRequestRejectedError(status_code=422)
content_type = response.headers.get("content-type", "").partition(";")[0].strip().lower()
if content_type != "application/json" and not content_type.endswith("+json"):
raise KnowledgeFSProductRemoteError("KnowledgeFS returned an unsupported media type")
if response.status_code == HTTPStatus.NOT_FOUND:
raise KnowledgeFSProductResourceNotFoundError("KnowledgeFS resource was not found")
if not HTTPStatus.OK <= response.status_code < HTTPStatus.MULTIPLE_CHOICES:
raise KnowledgeFSProductRemoteError(f"KnowledgeFS returned HTTP {response.status_code}")
try:

View File

@ -212,11 +212,7 @@ class KnowledgeFSProductService:
trace_id=str(uuid.uuid4()),
)
item = _list_item(space, summaries=summaries, permission_keys=authorized.permission_keys)
return KnowledgeFSSpaceDetailResponse(
**item.model_dump(),
created_at=space.created_at,
updated_at=space.updated_at,
)
return KnowledgeFSSpaceDetailResponse(**item.model_dump())
def require_product_routes(self, *, tenant_id: str) -> None:
self._cutover_gate.require_product_routes(tenant_id=tenant_id)
@ -308,6 +304,7 @@ def _list_item(
technical_status = "available"
return KnowledgeFSSpaceListItemResponse(
control_space_id=space.id,
created_at=space.created_at,
state=space.state,
visibility=space.visibility,
owner_account_id=space.owner_account_id,
@ -316,6 +313,7 @@ def _list_item(
permission_keys=list(permission_keys),
technical_status=technical_status,
technical_summary=summary,
updated_at=space.updated_at,
)

View File

@ -388,6 +388,20 @@ KNOWLEDGE_FS_CAPABILITY_OPERATIONS: Final[Mapping[str, KnowledgeFSCapabilityOper
"/knowledge-spaces/{id}/documents",
"knowledge_space",
),
"listLogicalDocuments": KnowledgeFSCapabilityOperation(
"logical_documents.list",
_STANDARD_CALLERS,
"GET",
"/knowledge-spaces/{id}/logical-documents",
"knowledge_space",
),
"getLogicalDocument": KnowledgeFSCapabilityOperation(
"logical_documents.read",
_STANDARD_CALLERS,
"GET",
"/knowledge-spaces/{id}/logical-documents/{documentId}",
"document",
),
"uploadDocument": KnowledgeFSCapabilityOperation(
"documents.create",
_STANDARD_CALLERS,
@ -533,6 +547,97 @@ KNOWLEDGE_FS_CAPABILITY_OPERATIONS: Final[Mapping[str, KnowledgeFSCapabilityOper
"/knowledge-spaces/{id}/sources/{sourceId}/test",
"source",
),
"createSourceSyncWorkflow": KnowledgeFSCapabilityOperation(
"source_workflows.sync.create",
_STANDARD_CALLERS,
"POST",
"/knowledge-spaces/{id}/sources/{sourceId}/sync",
"source",
),
"listSourceProviders": KnowledgeFSCapabilityOperation(
"source_providers.list",
_STANDARD_CALLERS,
"GET",
"/source-providers",
"namespace",
),
"createSourceConnection": KnowledgeFSCapabilityOperation(
"source_connections.create",
_STANDARD_CALLERS,
"POST",
"/knowledge-spaces/{id}/source-connections",
"knowledge_space",
),
"listSourceConnections": KnowledgeFSCapabilityOperation(
"source_connections.list",
_STANDARD_CALLERS,
"GET",
"/knowledge-spaces/{id}/source-connections",
"knowledge_space",
),
"refreshSourceConnection": KnowledgeFSCapabilityOperation(
"source_connections.refresh",
_STANDARD_CALLERS,
"POST",
"/knowledge-spaces/{id}/source-connections/{connectionId}/refresh",
"knowledge_space",
),
"createSourceCrawlPreviewWorkflow": KnowledgeFSCapabilityOperation(
"source_workflows.preview.create",
_STANDARD_CALLERS,
"POST",
"/knowledge-spaces/{id}/sources/{sourceId}/crawl-preview",
"source",
),
"getSourceSyncPolicy": KnowledgeFSCapabilityOperation(
"source_sync_policies.read",
_STANDARD_CALLERS,
"GET",
"/knowledge-spaces/{id}/sources/{sourceId}/sync-policy",
"source",
),
"putSourceSyncPolicy": KnowledgeFSCapabilityOperation(
"source_sync_policies.update",
_STANDARD_CALLERS,
"PUT",
"/knowledge-spaces/{id}/sources/{sourceId}/sync-policy",
"source",
),
"getSourceWorkflow": KnowledgeFSCapabilityOperation(
"source_workflows.read",
_STANDARD_CALLERS,
"GET",
"/knowledge-spaces/{id}/source-workflows/{runId}",
"job",
),
"cancelSourceWorkflow": KnowledgeFSCapabilityOperation(
"source_workflows.cancel",
_STANDARD_CALLERS,
"POST",
"/knowledge-spaces/{id}/source-workflows/{runId}/cancel",
"job",
),
"retrySourceWorkflow": KnowledgeFSCapabilityOperation(
"source_workflows.retry",
_STANDARD_CALLERS,
"POST",
"/knowledge-spaces/{id}/source-workflows/{runId}/retry",
"job",
),
"listCrawlPreviewPages": KnowledgeFSCapabilityOperation(
"source_workflows.pages.list",
_STANDARD_CALLERS,
"GET",
"/knowledge-spaces/{id}/source-workflows/{runId}/pages",
"job",
),
"selectCrawlPreviewPages": KnowledgeFSCapabilityOperation(
"source_workflows.selection.create",
_STANDARD_CALLERS,
"POST",
"/knowledge-spaces/{id}/source-workflows/{runId}/selection",
"job",
),
"crawlKnowledgeSpaceSource": KnowledgeFSCapabilityOperation(
"sources.crawl",
_STANDARD_CALLERS,

View File

@ -17,6 +17,7 @@ _KNOWLEDGE_FS_DOCKER_VARIABLES = (
"KNOWLEDGE_FS_ENABLED",
"KNOWLEDGE_FS_BASE_URL",
"KNOWLEDGE_FS_DIRECT_ORIGIN",
"KNOWLEDGE_FS_DIRECT_UPLOAD_READY",
"KNOWLEDGE_FS_LIFECYCLE_WORKER_ENABLED",
"KNOWLEDGE_FS_INTEGRATED_PROVISION_READY",
"KNOWLEDGE_FS_LEGACY_ACL_FREEZE_READY",
@ -68,6 +69,7 @@ def test_knowledge_fs_lifecycle_worker_is_disabled_by_default() -> None:
assert config.KNOWLEDGE_FS_INTEGRATED_PROVISION_READY is False
assert config.KNOWLEDGE_FS_LEGACY_ACL_FREEZE_READY is False
assert config.KNOWLEDGE_FS_CAPABILITY_V2_ENABLED is False
assert config.KNOWLEDGE_FS_DIRECT_UPLOAD_READY is False
def test_capability_v2_requires_private_signing_configuration_when_enabled() -> None:

View File

@ -25,6 +25,8 @@ from models.base import TypeBase
from models.enums import EndUserType
from models.model import DefaultEndUserSessionID, EndUser
_USER_UUID = "00000000-0000-4000-8000-000000000001"
@pytest.fixture
def sqlite_plugin_engine(
@ -101,14 +103,14 @@ class TestGetUser:
"""Test returning existing user when found by ID"""
_persist_end_user(
sqlite_plugin_engine,
user_id="user123",
user_id=_USER_UUID,
session_id="existing-session",
)
with app.app_context():
result = get_user("tenant123", "user123")
result = get_user("tenant123", _USER_UUID)
assert result.id == "user123"
assert result.id == _USER_UUID
assert result.tenant_id == "tenant123"
def test_should_not_resolve_non_anonymous_users_across_tenants(
@ -158,6 +160,39 @@ class TestGetUser:
users = session.scalars(select(EndUser)).all()
assert [user.id for user in users] == ["persisted-user-id"]
def test_should_skip_uuid_id_lookup_for_text_session_id(
self,
sqlite_plugin_engine: Engine,
app: Flask,
):
"""Service actor names must not be compared with the UUID primary key."""
_persist_end_user(
sqlite_plugin_engine,
user_id="persisted-user-id",
session_id="knowledge-fs",
)
statements: list[str] = []
def _record_statement(
_connection: object,
_cursor: object,
statement: str,
_parameters: object,
_context: object,
_executemany: bool,
) -> None:
statements.append(statement)
event.listen(sqlite_plugin_engine, "before_cursor_execute", _record_statement)
try:
with app.app_context():
result = get_user("tenant123", "knowledge-fs")
finally:
event.remove(sqlite_plugin_engine, "before_cursor_execute", _record_statement)
assert result.id == "persisted-user-id"
assert not any("WHERE end_users.id =" in statement for statement in statements)
def test_should_return_existing_anonymous_user_by_session_id(
self,
sqlite_plugin_engine: Engine,
@ -244,17 +279,17 @@ class TestGetUserTenant:
_persist_tenant(sqlite_plugin_engine)
_persist_end_user(
sqlite_plugin_engine,
user_id="user456",
user_id=_USER_UUID,
session_id="user-session",
)
with app.test_request_context(json={"tenant_id": "tenant123", "user_id": "user456"}):
with app.test_request_context(json={"tenant_id": "tenant123", "user_id": _USER_UUID}):
monkeypatch.setattr(app, "login_manager", MagicMock(), raising=False)
with patch("controllers.inner_api.plugin.wraps.user_logged_in"):
result = protected_view()
assert result["tenant"].id == "tenant123"
assert result["user"].id == "user456"
assert result["user"].id == _USER_UUID
def test_should_raise_error_when_tenant_id_missing(self, app: Flask):
"""Test that Pydantic ValidationError is raised when tenant_id is missing from payload"""

View File

@ -46,7 +46,20 @@ def test_console_and_service_api_routes_are_registered() -> None:
"/knowledge-fs/spaces/<string:control_space_id>/credentials",
"/knowledge-fs/spaces/<string:control_space_id>/settings",
"/knowledge-fs/spaces/<string:control_space_id>/documents",
"/knowledge-fs/spaces/<string:control_space_id>/logical-documents",
"/knowledge-fs/spaces/<string:control_space_id>/logical-documents/<string:document_id>",
"/knowledge-fs/spaces/<string:control_space_id>/sources",
"/knowledge-fs/spaces/<string:control_space_id>/source-connections",
("/knowledge-fs/spaces/<string:control_space_id>/source-connections/<string:connection_id>/refresh"),
"/knowledge-fs/spaces/<string:control_space_id>/sources/<string:source_id>/sync",
"/knowledge-fs/spaces/<string:control_space_id>/sources/<string:source_id>/crawl-preview",
"/knowledge-fs/spaces/<string:control_space_id>/sources/<string:source_id>/sync-policy",
"/knowledge-fs/spaces/<string:control_space_id>/source-workflows/<string:run_id>",
"/knowledge-fs/spaces/<string:control_space_id>/source-workflows/<string:run_id>/cancel",
"/knowledge-fs/spaces/<string:control_space_id>/source-workflows/<string:run_id>/retry",
"/knowledge-fs/spaces/<string:control_space_id>/source-workflows/<string:run_id>/pages",
"/knowledge-fs/spaces/<string:control_space_id>/source-workflows/<string:run_id>/selection",
"/knowledge-fs/spaces/<string:control_space_id>/source-providers",
"/knowledge-fs/spaces/<string:control_space_id>/queries",
"/knowledge-fs/spaces/<string:control_space_id>/research-tasks",
"/knowledge-fs/spaces/<string:control_space_id>/traces",
@ -114,6 +127,18 @@ def test_knowledge_fs_request_and_response_schemas_are_registered() -> None:
"KnowledgeFSStreamCapabilityResponse",
"KnowledgeFSJWKSResponse",
"KnowledgeFSSmallFileUploadResponse",
"KnowledgeFSCrawlPreviewPageListQuery",
"KnowledgeFSCrawlPreviewPageListResponse",
"KnowledgeFSCrawlPreviewSelectionPayload",
"KnowledgeFSSourceConnectionCreatePayload",
"KnowledgeFSSourceConnectionListQuery",
"KnowledgeFSSourceConnectionListResponse",
"KnowledgeFSSourceConnectionRefreshPayload",
"KnowledgeFSSourceProviderListResponse",
"KnowledgeFSSourceSyncPolicyPayload",
"KnowledgeFSSourceSyncPolicyResponse",
"KnowledgeFSSourceWorkflowCancelPayload",
"KnowledgeFSSourceWorkflowResponse",
}.issubset(console_ns.models)
assert {
"KnowledgeFSDocumentCreatePayload",
@ -524,6 +549,7 @@ def test_upload_and_task_stream_capabilities_use_direct_operation_admission(
runtime = SimpleNamespace(direct_operation_admission=DirectAdmission())
monkeypatch.setattr(console_resources.dify_config, "KNOWLEDGE_FS_DIRECT_ORIGIN", "https://kfs.test")
monkeypatch.setattr(console_resources.dify_config, "KNOWLEDGE_FS_DIRECT_UPLOAD_READY", True)
monkeypatch.setattr(console_resources, "_actor", lambda: ("account-1", "tenant-1"))
monkeypatch.setattr(console_resources, "_console_services", lambda: runtime)
app = Flask(__name__)

View File

@ -384,11 +384,11 @@ _CONSOLE_DELEGATION_CASES = (
{"control_space_id": "space-1", "source_id": "source-1"},
),
(
"KnowledgeFSSpaceSourceCrawlApi",
"KnowledgeFSSpaceSourceSyncApi",
"post",
("space-1", "source-1"),
"facade",
"crawl_source",
"sync_source",
{"control_space_id": "space-1", "source_id": "source-1"},
),
(
@ -987,6 +987,7 @@ def test_console_direct_capabilities_bind_the_authorized_resource(monkeypatch: p
]
)
monkeypatch.setattr(console_resources.dify_config, "KNOWLEDGE_FS_DIRECT_ORIGIN", "https://kfs.example/")
monkeypatch.setattr(console_resources.dify_config, "KNOWLEDGE_FS_DIRECT_UPLOAD_READY", True)
monkeypatch.setattr(console_resources, "_actor", lambda: ("account-1", "tenant-1"))
monkeypatch.setattr(
console_resources,
@ -1066,6 +1067,16 @@ def test_direct_routes_fail_before_admission_when_origin_is_unconfigured(
_invoke(resource_module, class_name, "post", "resource-1")
def test_console_upload_capability_fails_before_admission_until_upload_is_verified(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(console_resources.dify_config, "KNOWLEDGE_FS_DIRECT_ORIGIN", "https://kfs.example")
monkeypatch.setattr(console_resources.dify_config, "KNOWLEDGE_FS_DIRECT_UPLOAD_READY", False)
with pytest.raises(KnowledgeFSOperationUnavailableError, match="direct upload"):
_invoke(console_resources, "KnowledgeFSSpaceUploadCapabilitiesApi", "post", "space-1")
def test_console_resource_helpers_validate_feature_payload_headers_and_query_pairs(
monkeypatch: pytest.MonkeyPatch,
) -> None:
@ -1194,7 +1205,10 @@ def test_console_error_adapter_maps_every_domain_boundary_to_the_stable_http_con
from services.knowledge_fs.control_plane_service import KnowledgeFSControlPlaneInvariantError
from services.knowledge_fs.credential_service import KnowledgeFSCredentialPolicyError
from services.knowledge_fs.product_authorization import KnowledgeFSProductNotFoundError
from services.knowledge_fs.product_remote import KnowledgeFSProductRemoteError
from services.knowledge_fs.product_remote import (
KnowledgeFSProductRemoteError,
KnowledgeFSProductResourceNotFoundError,
)
from services.knowledge_fs_capability import KnowledgeFSCapabilityConfigurationError
with pytest.raises(ValidationError) as raised_validation:
@ -1202,6 +1216,7 @@ def test_console_error_adapter_maps_every_domain_boundary_to_the_stable_http_con
validation_error = raised_validation.value
mappings = (
(KnowledgeFSProductNotFoundError("hidden"), KnowledgeFSSpaceNotFoundHTTPError),
(KnowledgeFSProductResourceNotFoundError("missing child"), NotFound),
(KnowledgeFSOperationUnavailableError("manifest mismatch"), KnowledgeFSOperationUnavailableHTTPError),
(KnowledgeFSProductRemoteError("upstream unavailable"), KnowledgeFSUpstreamUnavailableHTTPError),
(KnowledgeFSAppBindingManagementError("invalid binding"), KnowledgeFSInvalidRequestHTTPError),
@ -1223,7 +1238,10 @@ def test_service_error_adapter_maps_every_domain_boundary_to_the_stable_http_con
from pydantic import ValidationError
from services.knowledge_fs.credential_service import KnowledgeFSCredentialValidationError
from services.knowledge_fs.product_remote import KnowledgeFSProductRemoteError
from services.knowledge_fs.product_remote import (
KnowledgeFSProductRemoteError,
KnowledgeFSProductResourceNotFoundError,
)
with pytest.raises(ValidationError) as raised_validation:
KnowledgeFSQueryCreatePayload.model_validate({"query": ""})
@ -1231,6 +1249,7 @@ def test_service_error_adapter_maps_every_domain_boundary_to_the_stable_http_con
mappings = (
(KnowledgeFSCredentialValidationError("revoked"), KnowledgeFSInvalidCredentialHTTPError),
(KnowledgeFSOperationUnavailableError("manifest mismatch"), KnowledgeFSServiceOperationUnavailableHTTPError),
(KnowledgeFSProductResourceNotFoundError("missing child"), NotFound),
(KnowledgeFSProductRemoteError("upstream unavailable"), KnowledgeFSServiceUpstreamUnavailableHTTPError),
(validation_error, KnowledgeFSServiceInvalidRequestHTTPError),
)

View File

@ -0,0 +1,36 @@
"""Regression coverage for authenticated browser CORS headers."""
from flask import Blueprint, Flask
from extensions.ext_blueprints import AUTHENTICATED_HEADERS, _apply_cors_once
def test_authenticated_cors_allows_request_metadata_headers() -> None:
app = Flask(__name__)
blueprint = Blueprint("cors_probe", __name__, url_prefix="/console/api")
@blueprint.post("/probe")
def probe() -> tuple[str, int]:
return "", 204
_apply_cors_once(
blueprint,
resources={r"/*": {"origins": ["http://localhost:3000"]}},
supports_credentials=True,
allow_headers=list(AUTHENTICATED_HEADERS),
methods=["POST", "OPTIONS"],
)
app.register_blueprint(blueprint)
response = app.test_client().options(
"/console/api/probe",
headers={
"Access-Control-Request-Headers": "Idempotency-Key, X-Request-ID",
"Access-Control-Request-Method": "POST",
"Origin": "http://localhost:3000",
},
)
allowed_headers = response.headers.get("Access-Control-Allow-Headers", "").lower()
assert "idempotency-key" in allowed_headers
assert "x-request-id" in allowed_headers

View File

@ -0,0 +1,36 @@
import pytest
from services import feature_service as feature_service_module
from services.feature_service import FeatureService
@pytest.mark.parametrize(
("enabled", "direct_origin", "direct_upload_ready", "upload_enabled"),
[
(True, "https://uploads.knowledge-fs.test", True, True),
(True, "https://uploads.knowledge-fs.test", False, False),
(True, None, True, False),
(False, "https://uploads.knowledge-fs.test", True, False),
],
)
def test_get_system_features_reads_knowledge_fs_availability(
monkeypatch: pytest.MonkeyPatch,
enabled: bool,
direct_origin: str | None,
direct_upload_ready: bool,
upload_enabled: bool,
) -> None:
monkeypatch.setattr(feature_service_module.dify_config, "KNOWLEDGE_FS_ENABLED", enabled)
monkeypatch.setattr(feature_service_module.dify_config, "KNOWLEDGE_FS_DIRECT_ORIGIN", direct_origin)
monkeypatch.setattr(
feature_service_module.dify_config,
"KNOWLEDGE_FS_DIRECT_UPLOAD_READY",
direct_upload_ready,
)
result = FeatureService.get_system_features()
assert result.knowledge_fs_enabled is enabled
assert result.knowledge_fs_upload_enabled is upload_enabled
assert result.model_dump()["knowledge_fs_enabled"] is enabled
assert result.model_dump()["knowledge_fs_upload_enabled"] is upload_enabled

View File

@ -604,6 +604,20 @@ def test_advanced_facade_binds_child_resources_parent_space_and_idempotency() ->
("get_overview_inventory", "KnowledgeFSOverviewInventoryResponse", "getOverviewInventory", {}, None),
("get_overview_health", "KnowledgeFSOverviewHealthResponse", "getOverviewHealth", {}, None),
("list_documents", "KnowledgeFSDocumentListResponse", "listDocuments", {"cursor": "cursor-1"}, None),
(
"list_logical_documents",
"KnowledgeFSLogicalDocumentListResponse",
"listLogicalDocuments",
{"cursor": "cursor-1"},
None,
),
(
"get_logical_document",
"KnowledgeFSLogicalDocumentResponse",
"getLogicalDocument",
{"document_id": "document-1"},
"document-1",
),
("get_document", "KnowledgeFSDocumentResponse", "getDocument", {"document_id": "document-1"}, "document-1"),
(
"get_document_outline",
@ -703,6 +717,97 @@ def test_advanced_facade_binds_child_resources_parent_space_and_idempotency() ->
},
"source-1",
),
(
"sync_source",
"KnowledgeFSSourceWorkflowResponse",
"syncSource",
{"source_id": "source-1", "idempotency_key": "sync-source-once"},
"source-1",
),
(
"list_source_providers",
"KnowledgeFSSourceProviderListResponse",
"listSourceProviders",
{},
None,
),
(
"create_source_connection",
"KnowledgeFSSourceConnectionResponse",
"createSourceConnection",
{"payload": MagicMock()},
None,
),
(
"list_source_connections",
"KnowledgeFSSourceConnectionListResponse",
"listSourceConnections",
{"cursor": "cursor-1", "limit": 25},
None,
),
(
"refresh_source_connection",
"KnowledgeFSSourceConnectionResponse",
"refreshSourceConnection",
{"connection_id": "connection-1", "payload": MagicMock()},
None,
),
(
"preview_source_crawl",
"KnowledgeFSSourceWorkflowResponse",
"previewSourceCrawl",
{"source_id": "source-1", "idempotency_key": "preview-source-once"},
"source-1",
),
(
"get_source_sync_policy",
"KnowledgeFSSourceSyncPolicyResponse",
"getSourceSyncPolicy",
{"source_id": "source-1"},
"source-1",
),
(
"update_source_sync_policy",
"KnowledgeFSSourceSyncPolicyResponse",
"updateSourceSyncPolicy",
{"source_id": "source-1", "payload": MagicMock()},
"source-1",
),
(
"get_source_workflow",
"KnowledgeFSSourceWorkflowResponse",
"getSourceWorkflow",
{"run_id": "run-1"},
"run-1",
),
(
"cancel_source_workflow",
"KnowledgeFSSourceWorkflowResponse",
"cancelSourceWorkflow",
{"run_id": "run-1", "payload": MagicMock()},
"run-1",
),
(
"retry_source_workflow",
"KnowledgeFSSourceWorkflowResponse",
"retrySourceWorkflow",
{"run_id": "run-1"},
"run-1",
),
(
"list_crawl_preview_pages",
"KnowledgeFSCrawlPreviewPageListResponse",
"listCrawlPreviewPages",
{"run_id": "run-1", "cursor": "cursor-1", "limit": 25},
"run-1",
),
(
"select_crawl_preview_pages",
"KnowledgeFSSourceWorkflowResponse",
"selectCrawlPreviewPages",
{"run_id": "run-1", "payload": MagicMock(), "idempotency_key": "selection-once"},
"run-1",
),
(
"crawl_source",
"KnowledgeFSSourceCrawlResponse",

View File

@ -29,11 +29,13 @@ def test_ready_product_operations_exactly_match_capability_method_path_and_actio
"cancelBackgroundTask",
"cancelCompilationJob",
"cancelResearchTask",
"cancelSourceWorkflow",
"completeUploadSession",
"crawlSource",
"createQuery",
"createResearchTask",
"createSource",
"createSourceConnection",
"createUploadSession",
"deleteDocument",
"deleteSource",
@ -42,6 +44,7 @@ def test_ready_product_operations_exactly_match_capability_method_path_and_actio
"getDocument",
"getDocumentChunk",
"getDocumentOutline",
"getLogicalDocument",
"getOverviewHealth",
"getOverviewInventory",
"getOverviewQueryOutcomes",
@ -49,6 +52,8 @@ def test_ready_product_operations_exactly_match_capability_method_path_and_actio
"getResearchTask",
"getSettings",
"getSource",
"getSourceSyncPolicy",
"getSourceWorkflow",
"getSpace",
"getTrace",
"importSourceFiles",
@ -56,26 +61,36 @@ def test_ready_product_operations_exactly_match_capability_method_path_and_actio
"listDocumentChunks",
"listDocumentRevisions",
"listDocuments",
"listLogicalDocuments",
"listBackgroundTasks",
"listCrawlPreviewPages",
"listResearchTaskPartials",
"listResearchTasks",
"listSources",
"listSourceConnections",
"listSourceFiles",
"listSourcePages",
"listSourceProviders",
"listTraceConflicts",
"listTraceEvidence",
"listTraceMissing",
"listTraces",
"planResearchTask",
"presignUploadSessionPart",
"previewSourceCrawl",
"reindexDocuments",
"refreshSourceConnection",
"retryBackgroundTask",
"retryCompilationJob",
"retrySourceWorkflow",
"selectCrawlPreviewPages",
"streamResearchTask",
"syncSource",
"testSource",
"updateDocumentMetadata",
"updateSettings",
"updateSource",
"updateSourceSyncPolicy",
"updateSpace",
"uploadSmallFile",
}

View File

@ -9,6 +9,7 @@ from services.knowledge_fs.product_remote import (
KnowledgeFSOperationUnavailableError,
KnowledgeFSProductRemoteError,
KnowledgeFSProductRequestRejectedError,
KnowledgeFSProductResourceNotFoundError,
KnowledgeFSRemoteBinaryRequest,
KnowledgeFSRemoteJSONRequest,
)
@ -520,17 +521,22 @@ def test_binary_remote_closes_and_maps_all_upstream_response_failures(
@pytest.mark.parametrize(
("status_code", "content_type", "body"),
("status_code", "content_type", "body", "error_type", "expected_status"),
[
(500, "application/json", b"{}"),
(200, "text/plain", b"ok"),
(200, "application/json", b"{"),
(409, "application/json", b"{}", KnowledgeFSProductRequestRejectedError, 409),
(413, "application/json", b"{}", KnowledgeFSProductRequestRejectedError, 413),
(422, "application/json", b"{}", KnowledgeFSProductRequestRejectedError, 422),
(500, "application/json", b"{}", KnowledgeFSProductRemoteError, None),
(200, "text/plain", b"ok", KnowledgeFSProductRemoteError, None),
(200, "application/json", b"{", KnowledgeFSProductRemoteError, None),
],
)
def test_json_remote_closes_and_maps_upstream_response_failures(
status_code: int,
content_type: str,
body: bytes,
error_type: type[Exception],
expected_status: int | None,
monkeypatch: pytest.MonkeyPatch,
) -> None:
response = httpx.Response(status_code, content=body, headers={"Content-Type": content_type})
@ -538,7 +544,28 @@ def test_json_remote_closes_and_maps_upstream_response_failures(
monkeypatch.setattr(ssrf_proxy, "buffer_response", lambda buffered, **_: buffered)
client = HTTPKnowledgeFSProductRemoteClient(base_url="https://knowledge-fs.test", timeout_seconds=3)
with pytest.raises(KnowledgeFSProductRemoteError):
with pytest.raises(error_type) as raised:
client.execute_json(_json_request())
if expected_status is not None:
assert isinstance(raised.value, KnowledgeFSProductRequestRejectedError)
assert raised.value.status_code == expected_status
assert response.is_closed
def test_json_remote_preserves_authoritative_resource_not_found(
monkeypatch: pytest.MonkeyPatch,
) -> None:
response = httpx.Response(
404,
json={"error": "Source sync policy not found"},
headers={"Content-Type": "application/json"},
)
monkeypatch.setattr(ssrf_proxy, "make_request", lambda **_: response)
monkeypatch.setattr(ssrf_proxy, "buffer_response", lambda buffered, **_: buffered)
client = HTTPKnowledgeFSProductRemoteClient(base_url="https://knowledge-fs.test", timeout_seconds=3)
with pytest.raises(KnowledgeFSProductResourceNotFoundError):
client.execute_json(_json_request())
assert response.is_closed

View File

@ -15,13 +15,14 @@ KNOWLEDGE_FS_ENABLED=${KNOWLEDGE_FS_ENABLED:-false}
# Production deployments require HTTPS; plain HTTP is limited to non-production or loopback.
KNOWLEDGE_FS_BASE_URL=
KNOWLEDGE_FS_DIRECT_ORIGIN=
# Set true only after the KnowledgeFS direct-upload service and browser origins below are verified.
KNOWLEDGE_FS_DIRECT_UPLOAD_READY=false
KNOWLEDGE_FS_LIFECYCLE_WORKER_ENABLED=false
KNOWLEDGE_FS_INTEGRATED_PROVISION_READY=false
KNOWLEDGE_FS_LEGACY_ACL_FREEZE_READY=false
KNOWLEDGE_FS_LIFECYCLE_POLL_INTERVAL_SECONDS=15
KNOWLEDGE_FS_LIFECYCLE_LEASE_SECONDS=60
KNOWLEDGE_FS_LIFECYCLE_BATCH_SIZE=25
# Legacy rollback-only HMAC; leave blank when Capability v2 is selected.
KNOWLEDGE_FS_CAPABILITY_V2_ENABLED=false
KNOWLEDGE_FS_CAPABILITY_V2_SIGNING_KID=
KNOWLEDGE_FS_CAPABILITY_V2_PRIVATE_KEY_PEM=

View File

@ -19,6 +19,11 @@ KNOWLEDGE_DOCUMENT_COMPILATION_RUNTIME=on
KNOWLEDGE_FS_CAPABILITY_V2_ENABLED=false
KNOWLEDGE_FS_CAPABILITY_V2_PUBLIC_JWKS=
# Direct upload remains hidden in Dify until this service is enabled, its allowed browser origins
# are verified, and the API service sets KNOWLEDGE_FS_DIRECT_UPLOAD_READY=true.
KNOWLEDGE_DIRECT_UPLOAD_ENABLED=false
KNOWLEDGE_DIRECT_UPLOAD_ALLOWED_ORIGINS=
# Complex-document parser reachable from the default Compose network or an external endpoint.
# Leave both values blank only when PDF/Office parsing is intentionally unavailable.
UNSTRUCTURED_API_URL=

View File

@ -1,8 +1,57 @@
import { createHmac } from "node:crypto";
import { describe, expect, it } from "vitest";
import { createApiAuthVerifier } from "./auth-options";
describe("createApiAuthVerifier", () => {
it.each(["development", "production"])(
"accepts Dify-issued workspace JWTs in %s mode",
async (nodeEnvironment) => {
const secret = "test-secret-with-at-least-32-bytes";
const issuedAt = Math.floor(Date.now() / 1_000);
const token = signJwt(
{
caller_kind: "interactive",
scopes: ["knowledge-spaces:write"],
tenant_id: "tenant-1",
aud: "knowledge-fs",
exp: issuedAt + 60,
iat: issuedAt,
iss: "dify",
sub: "dify-workspace:tenant-1",
},
secret,
);
const verifier = createApiAuthVerifier({
KNOWLEDGE_FS_JWT_SECRET: secret,
NODE_ENV: nodeEnvironment,
});
await expect(verifier?.verify(token)).resolves.toEqual({
callerKind: "interactive",
subject: {
scopes: ["knowledge-spaces:write"],
subjectId: "dify-workspace:tenant-1",
tenantId: "tenant-1",
},
});
},
);
it("keeps explicit local auth available alongside Dify JWT auth", async () => {
const verifier = createApiAuthVerifier({
KNOWLEDGE_DEV_AUTH_TOKEN: "local-secret",
KNOWLEDGE_FS_JWT_SECRET: "test-secret-with-at-least-32-bytes",
NODE_ENV: "development",
});
await expect(verifier?.verify("local-secret")).resolves.toEqual({
scopes: ["knowledge-spaces:*"],
subjectId: "dev-user",
tenantId: "tenant-dev",
});
});
it.each(["development", "test"])(
"accepts the default local dev token in %s mode",
async (nodeEnvironment) => {
@ -56,3 +105,12 @@ describe("createApiAuthVerifier", () => {
},
);
});
function signJwt(payload: Readonly<Record<string, unknown>>, secret: string): string {
const header = Buffer.from(JSON.stringify({ alg: "HS256", typ: "JWT" })).toString("base64url");
const claims = Buffer.from(JSON.stringify(payload)).toString("base64url");
const input = `${header}.${claims}`;
const signature = createHmac("sha256", secret).update(input).digest("base64url");
return `${input}.${signature}`;
}

View File

@ -1,22 +1,35 @@
import { type AuthVerifier, createStaticAuthVerifier } from "@knowledge/api";
import { type AuthVerifier, createJwtAuthVerifier, createStaticAuthVerifier } from "@knowledge/api";
const DEFAULT_LOCAL_AUTH_TOKEN = "dev-token";
const DIFY_JWT_AUDIENCE = "knowledge-fs";
const DIFY_JWT_ISSUER = "dify";
const DIFY_JWT_MAX_TTL_SECONDS = 60;
export interface ApiAuthEnv {
readonly KNOWLEDGE_DEV_AUTH_TOKEN?: string | undefined;
readonly KNOWLEDGE_DEV_SUBJECT_ID?: string | undefined;
readonly KNOWLEDGE_DEV_TENANT_ID?: string | undefined;
readonly KNOWLEDGE_FS_JWT_SECRET?: string | undefined;
readonly NODE_ENV?: string | undefined;
}
export function createApiAuthVerifier(env: ApiAuthEnv = process.env): AuthVerifier | undefined {
const difyJwtSecret = env.KNOWLEDGE_FS_JWT_SECRET?.trim();
const token = getLocalAuthToken(env);
const difyJwtAuth = difyJwtSecret
? createJwtAuthVerifier({
audience: DIFY_JWT_AUDIENCE,
issuer: DIFY_JWT_ISSUER,
maxTtlSeconds: DIFY_JWT_MAX_TTL_SECONDS,
secret: difyJwtSecret,
})
: undefined;
if (!token) {
return undefined;
return difyJwtAuth;
}
return createStaticAuthVerifier({
const localAuth = createStaticAuthVerifier({
subject: {
scopes: ["knowledge-spaces:*"],
subjectId: env.KNOWLEDGE_DEV_SUBJECT_ID?.trim() || "dev-user",
@ -24,6 +37,14 @@ export function createApiAuthVerifier(env: ApiAuthEnv = process.env): AuthVerifi
},
token,
});
if (!difyJwtAuth) {
return localAuth;
}
return {
verify: async (candidate) =>
(await difyJwtAuth.verify(candidate)) ?? localAuth.verify(candidate),
};
}
function getLocalAuthToken(env: ApiAuthEnv): string | undefined {

View File

@ -61,6 +61,48 @@ describe("createDifyDatasourceInvocationClient", () => {
expect(JSON.stringify(getOnlineDocumentPages.mock.calls)).not.toContain("credentials");
});
it("maps product crawl options to the Firecrawl datasource parameters", async () => {
const getWebsiteCrawl = vi.fn(() => chunks({ result: { web_info_list: [] } }));
const adapter = createDifyDatasourceInvocationClient({
client: difyClient({ getWebsiteCrawl }),
});
const source: Source = {
...SOURCE,
metadata: {
credentialId: "dify-credential-1",
crawlOptions: { includeSubpages: false, limit: 1 },
datasource: "crawl",
parameters: { formats: ["markdown"] },
pluginId: "langgenius/firecrawl_datasource",
provider: "firecrawl",
},
type: "web",
uri: "https://example.com",
};
await collect(
adapter.dispatch({
operation: "get_website_crawl",
source,
tenantId: "tenant-1",
}),
);
expect(getWebsiteCrawl).toHaveBeenCalledWith({
credentialId: "dify-credential-1",
datasource: "crawl",
datasourceParameters: {
crawl_subpages: false,
formats: ["markdown"],
limit: 1,
url: "https://example.com",
},
pluginId: "langgenius/firecrawl_datasource",
provider: "firecrawl",
tenantId: "tenant-1",
});
});
it("rejects inline credentials in integrated mode", async () => {
const adapter = createDifyDatasourceInvocationClient({ client: difyClient() });
const source = {

View File

@ -38,7 +38,11 @@ export function createDifyDatasourceInvocationClient(input: {
case "get_website_crawl":
yield* input.client.getWebsiteCrawl({
...common,
datasourceParameters: withCrawlUrl(config.parameters, invocation.source.uri),
datasourceParameters: withCrawlOptions(
config.parameters,
invocation.source,
invocation.source.uri,
),
});
return;
case "get_online_document_pages":
@ -145,6 +149,24 @@ function withCrawlUrl(parameters: Record<string, unknown>, uri: string): Record<
: { ...parameters, url: uri };
}
function withCrawlOptions(
parameters: Record<string, unknown>,
source: Source,
uri: string,
): Record<string, unknown> {
const crawlOptions = plainObject(source.metadata.crawlOptions);
const includeSubpages = crawlOptions.includeSubpages;
const limit = crawlOptions.limit;
return withCrawlUrl(
{
...parameters,
...(typeof includeSubpages === "boolean" ? { crawl_subpages: includeSubpages } : {}),
...(Number.isSafeInteger(limit) && Number(limit) > 0 ? { limit } : {}),
},
uri,
);
}
function decodeNextPageParameters(token: string): Record<string, unknown> {
try {
const value = JSON.parse(Buffer.from(token, "base64url").toString("utf8")) as unknown;

View File

@ -12,6 +12,7 @@ const WEB_SOURCE: WebsiteCrawlInput["source"] = {
id: "00000000-0000-4000-8000-000000000001",
knowledgeSpaceId: "10000000-0000-4000-8000-000000000001",
metadata: {
crawlOptions: { includeSubpages: false, limit: 1 },
datasource: "crawl",
parameters: { limit: 5 },
pluginId: "langgenius/firecrawl_datasource",
@ -62,7 +63,7 @@ describe("createApiWebsiteCrawlConnector", () => {
const result = await connector.crawl({ source: WEB_SOURCE, tenantId: "tenant-1" });
expect(result).toEqual({
completed: 2,
completed: 1,
pages: [
{
content: "# A",
@ -70,10 +71,9 @@ describe("createApiWebsiteCrawlConnector", () => {
sourceUrl: "https://example.com/a",
title: "A",
},
{ content: "# B", sourceUrl: "https://example.com/b" },
],
status: "completed",
total: 2,
total: 1,
});
expect(calls).toHaveLength(1);

View File

@ -20,6 +20,7 @@ export function createApiWebsiteCrawlConnector(input: {
return {
crawl: async ({ signal, source, tenantId, userId }): Promise<WebsiteCrawlResult> => {
const pages = new Map<string, CrawledPage>();
const pageLimit = crawlPageLimit(source.metadata.crawlOptions);
let status: string | undefined;
let total: number | undefined;
let completed: number | undefined;
@ -55,15 +56,25 @@ export function createApiWebsiteCrawlConnector(input: {
}
return {
pages: Array.from(pages.values()),
...(completed === undefined ? {} : { completed }),
pages: Array.from(pages.values()).slice(0, pageLimit),
...(completed === undefined ? {} : { completed: Math.min(completed, pageLimit) }),
...(status === undefined ? {} : { status }),
...(total === undefined ? {} : { total }),
...(total === undefined ? {} : { total: Math.min(total, pageLimit) }),
};
},
};
}
function crawlPageLimit(value: unknown): number {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return Number.POSITIVE_INFINITY;
}
const limit = (value as Readonly<Record<string, unknown>>).limit;
return Number.isSafeInteger(limit) && Number(limit) > 0
? Number(limit)
: Number.POSITIVE_INFINITY;
}
export function createApiWebsiteCrawlOptions(input: {
readonly client: ApiDatasourceInvocationClient;
}): {

View File

@ -60,6 +60,10 @@ describe.each(["postgres", "tidb"] as const)(
expect(calls[0]?.sql).toContain(
dialect === "postgres" ? '"knowledge_space_id" = $2' : "`knowledge_space_id` = ?",
);
if (dialect === "postgres") {
expect(calls[0]?.sql).toContain("$3::uuid IS NOT NULL");
expect(calls[0]?.sql).toContain("$4::uuid IS NOT NULL");
}
expect(calls[0]?.sql).toContain("'knowledge_space'");
expect(calls[0]?.sql).toContain("'source'");
expect(calls[0]?.sql).toContain("'document_asset'");

View File

@ -44,9 +44,13 @@ export function createDatabaseDeletionLifecycleFenceReader(
function tombstoneHierarchySql(database: DatabaseAdapter): string {
const q = (identifier: string) => quoteDatabaseIdentifier(database, identifier);
const p = (position: number) => databasePlaceholder(database, position);
const idParam = (position: number) =>
database.dialect === "postgres" ? `${p(position)}::uuid` : p(position);
const sourceId = idParam(3);
const documentAssetId = idParam(4);
const columns = ["id", "tenant_id", "knowledge_space_id", "target_type", "target_id"];
const selected = columns.map(q).join(", ");
return `SELECT ${selected} FROM (SELECT ${selected}, 0 AS ${q("fence_priority")} FROM ${q("deletion_jobs")} WHERE ${q("tenant_id")} = ${p(1)} AND ${q("knowledge_space_id")} = ${p(2)} AND ${q("active_slot")} = 1 UNION ALL SELECT ${selected}, 1 AS ${q("fence_priority")} FROM ${q(tombstoneTable)} WHERE ${q("tenant_id")} = ${p(1)} AND ${q("knowledge_space_id")} = ${p(2)} AND ((${q("target_type")} = 'knowledge_space' AND ${q("target_id")} = ${p(2)}) OR (${q("target_type")} = 'source' AND ((${p(3)} IS NOT NULL AND ${q("target_id")} = ${p(3)}) OR (${p(4)} IS NOT NULL AND ${q("target_id")} IN (SELECT source_document.${q("source_id")} FROM ${q("document_assets")} source_document WHERE source_document.${q("knowledge_space_id")} = ${p(2)} AND source_document.${q("id")} = ${p(4)} AND source_document.${q("source_id")} IS NOT NULL)))) OR (${p(4)} IS NOT NULL AND ${q("target_type")} = 'document_asset' AND ${q("target_id")} = ${p(4)}) OR (${p(4)} IS NOT NULL AND ${q("target_type")} = 'logical_document' AND ${q("target_id")} IN (SELECT logical_revision.${q("document_id")} FROM ${q("document_revisions")} logical_revision WHERE logical_revision.${q("tenant_id")} = ${p(1)} AND logical_revision.${q("knowledge_space_id")} = ${p(2)} AND logical_revision.${q("document_asset_id")} = ${p(4)}))) AS lifecycle_fence ORDER BY ${q("fence_priority")} ASC, CASE ${q("target_type")} WHEN 'knowledge_space' THEN 0 WHEN 'source' THEN 1 ELSE 2 END ASC LIMIT 1;`;
return `SELECT ${selected} FROM (SELECT ${selected}, 0 AS ${q("fence_priority")} FROM ${q("deletion_jobs")} WHERE ${q("tenant_id")} = ${p(1)} AND ${q("knowledge_space_id")} = ${p(2)} AND ${q("active_slot")} = 1 UNION ALL SELECT ${selected}, 1 AS ${q("fence_priority")} FROM ${q(tombstoneTable)} WHERE ${q("tenant_id")} = ${p(1)} AND ${q("knowledge_space_id")} = ${p(2)} AND ((${q("target_type")} = 'knowledge_space' AND ${q("target_id")} = ${p(2)}) OR (${q("target_type")} = 'source' AND ((${sourceId} IS NOT NULL AND ${q("target_id")} = ${sourceId}) OR (${documentAssetId} IS NOT NULL AND ${q("target_id")} IN (SELECT source_document.${q("source_id")} FROM ${q("document_assets")} source_document WHERE source_document.${q("knowledge_space_id")} = ${p(2)} AND source_document.${q("id")} = ${documentAssetId} AND source_document.${q("source_id")} IS NOT NULL)))) OR (${documentAssetId} IS NOT NULL AND ${q("target_type")} = 'document_asset' AND ${q("target_id")} = ${documentAssetId}) OR (${documentAssetId} IS NOT NULL AND ${q("target_type")} = 'logical_document' AND ${q("target_id")} IN (SELECT logical_revision.${q("document_id")} FROM ${q("document_revisions")} logical_revision WHERE logical_revision.${q("tenant_id")} = ${p(1)} AND logical_revision.${q("knowledge_space_id")} = ${p(2)} AND logical_revision.${q("document_asset_id")} = ${documentAssetId})))) AS lifecycle_fence ORDER BY ${q("fence_priority")} ASC, CASE ${q("target_type")} WHEN 'knowledge_space' THEN 0 WHEN 'source' THEN 1 ELSE 2 END ASC LIMIT 1;`;
}
function mapFence(row: DatabaseRow): ActiveDeletionLifecycleFence {

View File

@ -279,9 +279,14 @@ describe("database durable deletion target capabilities", () => {
signal,
}),
).resolves.toEqual({ complete: true, items: [], scanPhase: "document_objects:6" });
expect(
calls.filter((call) => call.tableName === "document_multimodal_manifests"),
).toHaveLength(3);
const manifestDatabaseCalls = calls.filter(
(call) => call.tableName === "document_multimodal_manifests",
);
expect(manifestDatabaseCalls).toHaveLength(3);
expect(manifestDatabaseCalls[0]?.params).toEqual(["tenant-a", spaceId, targetDocumentId]);
expect(manifestDatabaseCalls[0]?.sql).not.toContain(
dialect === "postgres" ? 'manifest."id" >' : "manifest.`id` >",
);
});
it(`inventories and executes space objects, lifecycle secrets, source secrets, and cache items (${dialect})`, async () => {
@ -371,6 +376,18 @@ describe("database durable deletion target capabilities", () => {
],
scanPhase: "source_secrets",
});
expect(
calls.find((call) => call.operation === "select" && call.tableName === "sources")?.sql,
).toContain(dialect === "postgres" ? `"credential_ref" <> ''` : "`credential_ref` <> ''");
expect(
calls.find(
(call) =>
call.operation === "select" && call.tableName === "source_secret_lifecycle_refs",
)?.params,
).toEqual(["tenant-a", spaceId, null, 2]);
expect(
calls.find((call) => call.operation === "select" && call.tableName === "sources")?.params,
).toEqual(["tenant-a", spaceId, null, 2]);
await capabilities.executeExternalItem({
item: deletionItem("object", { objectKey: firstObjectKey }),
@ -419,6 +436,26 @@ describe("database durable deletion target capabilities", () => {
}
});
it(`uses a nullable UUID cursor for the first document inventory page (${dialect})`, async () => {
const calls: DatabaseExecuteInput[] = [];
const capabilities = capabilitiesFor(dialect, async (input) => {
calls.push(input);
return result([]);
});
await capabilities.inventory({
job: job({ targetType: "source" }),
limit: 2,
signal: new AbortController().signal,
});
const documentCall = calls.find(
(call) => call.operation === "select" && call.tableName === "document_assets",
);
expect(documentCall?.params).toEqual([spaceId, null, targetDocumentId]);
expect(documentCall?.sql).toContain("COALESCE");
});
it(`publishes a target-free, graph-closed head while preserving unrelated Deep members (${dialect})`, async () => {
const calls: DatabaseExecuteInput[] = [];
let targetProbeCount = 0;
@ -861,9 +898,11 @@ describe("database durable deletion target capabilities", () => {
(call) => call.operation === "select" && call.tableName === "knowledge_fs_sessions",
),
).toBe(true);
expect(
calls.some((call) => call.operation === "select" && call.tableName === "golden_questions"),
).toBe(true);
const goldenQuestionSelect = calls.find(
(call) => call.operation === "select" && call.tableName === "golden_questions",
);
expect(goldenQuestionSelect?.params).toEqual([spaceId, 7]);
expect(goldenQuestionSelect?.sql).toContain("1 = 1");
expect(
calls.some(
(call) => call.operation === "select" && call.tableName === "research_task_jobs",
@ -890,6 +929,45 @@ describe("database durable deletion target capabilities", () => {
expect(calls.some((call) => call.tableName === "answer_traces")).toBe(false);
});
it(`preserves the original derived-cleanup error when the transaction is aborted (${dialect})`, async () => {
let fenceChecks = 0;
const execute = async (input: DatabaseExecuteInput): Promise<DatabaseExecuteResult> => {
if (input.operation === "select" && input.tableName === "deletion_jobs") {
fenceChecks += 1;
if (fenceChecks > 1) throw new Error("transaction aborted");
return result([{ id: job().id }]);
}
if (input.operation === "select" && input.tableName === "golden_questions") {
throw new Error("golden question cleanup failed");
}
return result([]);
};
const database = createSchemaDatabaseAdapter({
executor: execute,
kind: dialect,
transaction: async (callback) => callback({ execute }),
});
const capabilities = createDatabaseDurableDeletionTargetCapabilities({
cache: createMemoryCacheAdapter({ maxEntries: 10 }),
database,
objectStorage: createMemoryObjectStorageAdapter({
kind: "memory",
maxObjectBytes: 1_024,
}),
secretStore: { delete: vi.fn(async () => undefined) },
});
await expect(
capabilities.deleteDerivedDataPage({
job: job({ deleteMode: "keep", targetType: "source" }),
limit: 7,
signal: new AbortController().signal,
}),
).rejects.toThrow("golden question cleanup failed");
expect(fenceChecks).toBe(1);
});
it(`source keep still drains live whole-space Research writers (${dialect})`, async () => {
const calls: DatabaseExecuteInput[] = [];
const execute = async (input: DatabaseExecuteInput): Promise<DatabaseExecuteResult> => {
@ -1004,6 +1082,14 @@ describe("database durable deletion target capabilities", () => {
expect(childUpdates[0]?.sql).toContain("deleting_at");
expect(childUpdates[1]?.sql).toContain("deletion_job_id");
expect(childUpdates[1]?.sql).toContain("IS NULL");
const logicalDocumentUpdate = calls.find(
(call) => call.operation === "update" && call.tableName === "logical_documents",
);
expect(logicalDocumentUpdate?.sql).toContain(
dialect === "postgres"
? `"provider_item_digest" = NULL`
: "`provider_item_digest` = NULL",
);
const childResidue = calls.find(
(call) => call.operation === "select" && call.tableName === "document_assets",
);
@ -1116,6 +1202,13 @@ describe("database durable deletion target capabilities", () => {
expect(select?.sql).toContain("knowledge_space_staged_commits");
expect(select?.sql).not.toContain('target_lease."document_asset_id"');
expect(select?.sql).not.toContain("target_lease.`document_asset_id`");
if (dialect === "postgres") {
expect(select?.sql).toContain('target_lease."target_id" = CAST(CAST($2 AS UUID) AS TEXT)');
expect(select?.sql).toContain('target_lease."target_id" = CAST(CAST($3 AS UUID) AS TEXT)');
expect(select?.sql).toContain(
"semantic_document_ref.document_asset_id = CAST(CAST($3 AS UUID) AS TEXT)",
);
}
expect(
calls.find(
(call) => call.operation === "delete" && call.tableName === "knowledge_fs_leases",
@ -1184,6 +1277,7 @@ describe("database durable deletion target capabilities", () => {
["document_multimodal_manifests", [{ id: "018f0d60-7a49-7cc2-9c1b-5b36f18f2d25" }]],
["knowledge_paths", [{ id: "018f0d60-7a49-7cc2-9c1b-5b36f18f2d26" }]],
["knowledge_space_staged_commits", [{ id: "018f0d60-7a49-7cc2-9c1b-5b36f18f2d27" }]],
["parse_artifacts", [{ id: "018f0d60-7a49-7cc2-9c1b-5b36f18f2d28" }]],
]);
const calls: DatabaseExecuteInput[] = [];
const execute = async (input: DatabaseExecuteInput): Promise<DatabaseExecuteResult> => {
@ -1198,7 +1292,7 @@ describe("database durable deletion target capabilities", () => {
};
const capabilities = capabilitiesFor(dialect, execute);
for (let page = 0; page < 7; page += 1) {
for (let page = 0; page < 8; page += 1) {
await expect(
capabilities.deleteDerivedDataPage({
job: job(),
@ -1220,6 +1314,7 @@ describe("database durable deletion target capabilities", () => {
"document_outlines",
"document_multimodal_manifests",
"knowledge_space_staged_commits",
"parse_artifacts",
]);
const pathSelect = calls.find(
(call) => call.operation === "select" && call.tableName === "knowledge_paths",
@ -1229,6 +1324,45 @@ describe("database durable deletion target capabilities", () => {
expect(pathSelect?.sql).toContain("documentAssetIds");
expect(pathSelect?.sql).toContain("sourceSummaryNodeIds");
expect(pathSelect?.sql).toContain("communityId");
const parseArtifactSelect = calls.find(
(call) => call.operation === "select" && call.tableName === "parse_artifacts",
);
expect(parseArtifactSelect?.sql).toContain("document_assets");
expect(parseArtifactSelect?.sql).toContain("knowledge_space_id");
});
it(`scopes the final parse-artifact residue probe to the target knowledge space (${dialect})`, async () => {
const calls: DatabaseExecuteInput[] = [];
const execute = async (input: DatabaseExecuteInput): Promise<DatabaseExecuteResult> => {
calls.push(input);
if (input.operation === "select" && input.tableName === "deletion_jobs") {
return result([{ id: job().id }]);
}
if (input.operation === "select" && input.tableName === "knowledge_space_manifests") {
return result([{ object_key_prefix: `tenant-a/spaces/${spaceId}` }]);
}
return result([]);
};
const targetJob = job();
await expect(
capabilitiesFor(dialect, execute).deletePrimaryData({
job: targetJob,
leaseFence: {
deletionJobId: targetJob.id,
expectedRowVersion: targetJob.rowVersion,
leaseToken: targetJob.leaseToken as string,
},
signal: new AbortController().signal,
transaction: { execute },
}),
).resolves.toEqual({ clean: true });
const parseArtifactProbe = calls.find(
(call) => call.operation === "select" && call.tableName === "parse_artifacts",
);
expect(parseArtifactProbe?.sql).toContain("document_assets");
expect(parseArtifactProbe?.sql).toContain("knowledge_space_id");
});
it(`fails the space primary proof when any cascaded derived row survives (${dialect})`, async () => {

View File

@ -369,6 +369,7 @@ export function createDatabaseDurableDeletionTargetCapabilities({
});
throwIfAborted(signal);
const preservesDocuments = job.targetType === "source" && job.deleteMode === "keep";
let operationFailed = false;
try {
// Command logs, KnowledgeFS session metadata, Golden Question metadata, and Research inputs
// are opaque JSON. They cannot be attributed safely to one document/source, so every target
@ -544,10 +545,13 @@ export function createDatabaseDurableDeletionTargetCapabilities({
}
}
return { complete: true, deleted: 0 };
} catch (error) {
operationFailed = true;
throw error;
} finally {
// A cache/object adapter call can outlive the original lease. Recheck immediately before
// commit so every DB page rolls back when the worker fence expired mid-operation.
await assertJobFence(database, transaction, job);
if (!operationFailed) await assertJobFence(database, transaction, job);
}
});
},
@ -1980,7 +1984,7 @@ async function cleanupLogicalDocumentsForPrimaryDeletion(
maxRows: 0,
operation: "update",
params: [job.tenantId, job.knowledgeSpaceId, job.targetId, job.updatedAt],
sql: `UPDATE ${q("logical_documents")} SET ${q("source_id")} = NULL, ${q("provider_item_id")} = NULL, ${q("system_metadata")} = ${scrubSourceIdentityMetadataSql(database, q("system_metadata"), false)}, ${q("row_version")} = ${q("row_version")} + 1, ${q("updated_at")} = ${p(4)} WHERE ${q("tenant_id")} = ${p(1)} AND ${q("knowledge_space_id")} = ${p(2)} AND ${q("source_id")} = ${p(3)};`,
sql: `UPDATE ${q("logical_documents")} SET ${q("source_id")} = NULL, ${q("provider_item_id")} = NULL, ${q("provider_item_digest")} = NULL, ${q("system_metadata")} = ${scrubSourceIdentityMetadataSql(database, q("system_metadata"), false)}, ${q("row_version")} = ${q("row_version")} + 1, ${q("updated_at")} = ${p(4)} WHERE ${q("tenant_id")} = ${p(1)} AND ${q("knowledge_space_id")} = ${p(2)} AND ${q("source_id")} = ${p(3)};`,
tableName: "logical_documents",
});
return;
@ -2763,7 +2767,8 @@ async function nextTargetDocumentId(
if (job.targetType === "document_asset") return cursor ? undefined : job.targetId;
const q = (value: string) => quoteDatabaseIdentifier(database, value);
const p = (position: number) => databasePlaceholder(database, position);
const params: DatabaseQueryValue[] = [job.knowledgeSpaceId, cursor ?? ""];
const params: DatabaseQueryValue[] = [job.knowledgeSpaceId, cursor ?? null];
const after = nullableUuidCursorExpression(database, p(2));
let target = "";
if (job.targetType === "source") {
params.push(job.targetId);
@ -2776,7 +2781,7 @@ async function nextTargetDocumentId(
maxRows: 1,
operation: "select",
params,
sql: `SELECT ${q("id")} FROM ${q("document_assets")} WHERE ${q("knowledge_space_id")} = ${p(1)} AND ${q("id")} > ${p(2)}${target} ORDER BY ${q("id")} ASC LIMIT 1;`,
sql: `SELECT ${q("id")} FROM ${q("document_assets")} WHERE ${q("knowledge_space_id")} = ${p(1)} AND ${q("id")} > ${after}${target} ORDER BY ${q("id")} ASC LIMIT 1;`,
tableName: "document_assets",
});
return result.rows[0] ? stringColumn(result.rows[0], "id") : undefined;
@ -2801,12 +2806,12 @@ async function documentManifestObjectKeyPage(
const p = (position: number) => databasePlaceholder(database, position);
const activeId = state.manifestActiveId;
const params: DatabaseQueryValue[] = [job.tenantId, job.knowledgeSpaceId, documentId];
let cursorPredicate: string;
let cursorPredicate = "";
if (activeId) {
params.push(activeId);
cursorPredicate = ` AND manifest.${q("id")} = ${p(4)}`;
} else {
params.push(state.manifestAfter ?? "");
} else if (state.manifestAfter) {
params.push(state.manifestAfter);
cursorPredicate = ` AND manifest.${q("id")} > ${p(4)}`;
}
const result = await database.execute({
@ -2912,6 +2917,11 @@ interface SecretInventoryRef {
readonly rowId: string;
}
function nullableUuidCursorExpression(database: DatabaseAdapter, placeholder: string): string {
const value = `COALESCE(${placeholder}, '00000000-0000-0000-0000-000000000000')`;
return database.dialect === "postgres" ? `CAST(${value} AS UUID)` : value;
}
async function lifecycleSecretRefs(
database: DatabaseAdapter,
job: DurableDeletionTargetOperationInput["job"],
@ -2921,7 +2931,8 @@ async function lifecycleSecretRefs(
if (job.targetType === "document_asset" || job.targetType === "logical_document") return [];
const q = (value: string) => quoteDatabaseIdentifier(database, value);
const p = (position: number) => databasePlaceholder(database, position);
const params: DatabaseQueryValue[] = [job.tenantId, job.knowledgeSpaceId, cursor ?? "", limit];
const params: DatabaseQueryValue[] = [job.tenantId, job.knowledgeSpaceId, cursor ?? null, limit];
const after = nullableUuidCursorExpression(database, p(3));
let target = "";
if (job.targetType === "source") {
params.push(job.targetId);
@ -2931,7 +2942,7 @@ async function lifecycleSecretRefs(
maxRows: limit,
operation: "select",
params,
sql: `SELECT ${q("id")}, ${q("source_id")}, ${q("credential_ref")} FROM ${q("source_secret_lifecycle_refs")} WHERE ${q("tenant_id")} = ${p(1)} AND ${q("knowledge_space_id")} = ${p(2)} AND ${q("id")} > ${p(3)} AND ${q("state")} <> 'deleted'${target} ORDER BY ${q("id")} ASC LIMIT ${p(4)};`,
sql: `SELECT ${q("id")}, ${q("source_id")}, ${q("credential_ref")} FROM ${q("source_secret_lifecycle_refs")} WHERE ${q("tenant_id")} = ${p(1)} AND ${q("knowledge_space_id")} = ${p(2)} AND ${q("id")} > ${after} AND ${q("state")} <> 'deleted'${target} ORDER BY ${q("id")} ASC LIMIT ${p(4)};`,
tableName: "source_secret_lifecycle_refs",
});
return result.rows.map((row) => ({
@ -2965,7 +2976,8 @@ async function sourceSecretRefs(
if (job.targetType === "document_asset" || job.targetType === "logical_document") return [];
const q = (value: string) => quoteDatabaseIdentifier(database, value);
const p = (position: number) => databasePlaceholder(database, position);
const params: DatabaseQueryValue[] = [job.tenantId, job.knowledgeSpaceId, cursor ?? "", limit];
const params: DatabaseQueryValue[] = [job.tenantId, job.knowledgeSpaceId, cursor ?? null, limit];
const after = nullableUuidCursorExpression(database, p(3));
let target = "";
if (job.targetType === "source") {
params.push(job.targetId);
@ -2975,7 +2987,7 @@ async function sourceSecretRefs(
maxRows: limit,
operation: "select",
params,
sql: `SELECT s.${q("id")}, s.${q("credential_ref")} FROM ${q("sources")} s WHERE s.${q("knowledge_space_id")} = ${p(2)} AND s.${q("id")} > ${p(3)} AND s.${q("credential_ref")} IS NOT NULL${target} AND EXISTS (SELECT 1 FROM ${q("knowledge_spaces")} ks WHERE ks.${q("tenant_id")} = ${p(1)} AND ks.${q("id")} = ${p(2)}) AND NOT EXISTS (SELECT 1 FROM ${q("source_secret_lifecycle_refs")} lifecycle WHERE lifecycle.${q("tenant_id")} = ${p(1)} AND lifecycle.${q("knowledge_space_id")} = ${p(2)} AND lifecycle.${q("source_id")} = s.${q("id")} AND lifecycle.${q("credential_ref")} = s.${q("credential_ref")}) ORDER BY s.${q("id")} ASC LIMIT ${p(4)};`,
sql: `SELECT s.${q("id")}, s.${q("credential_ref")} FROM ${q("sources")} s WHERE s.${q("knowledge_space_id")} = ${p(2)} AND s.${q("id")} > ${after} AND s.${q("credential_ref")} IS NOT NULL AND s.${q("credential_ref")} <> ''${target} AND EXISTS (SELECT 1 FROM ${q("knowledge_spaces")} ks WHERE ks.${q("tenant_id")} = ${p(1)} AND ks.${q("id")} = ${p(2)}) AND NOT EXISTS (SELECT 1 FROM ${q("source_secret_lifecycle_refs")} lifecycle WHERE lifecycle.${q("tenant_id")} = ${p(1)} AND lifecycle.${q("knowledge_space_id")} = ${p(2)} AND lifecycle.${q("source_id")} = s.${q("id")} AND lifecycle.${q("credential_ref")} = s.${q("credential_ref")}) ORDER BY s.${q("id")} ASC LIMIT ${p(4)};`,
tableName: "sources",
});
return result.rows.map((row) => ({
@ -3128,7 +3140,7 @@ async function deleteGoldenQuestionPage(
const q = (value: string) => quoteDatabaseIdentifier(database, value);
const p = (position: number) => databasePlaceholder(database, position);
return database.transaction(async (transaction) => {
const params = targetDocumentQueryParams(job);
const params = targetGoldenQuestionQueryParams(job);
params.push(limit);
const alias = "target_golden_question";
const rows = await transaction.execute({
@ -3257,6 +3269,15 @@ function goldenMissingEvidencePredicateSql(
return `EXISTS (SELECT 1 FROM JSON_TABLE(${metadata}, '$.evidenceContext.missingEvidence[*]' COLUMNS (evidence_id VARCHAR(255) PATH '$.expectedEvidenceId')) AS golden_missing WHERE ${target})`;
}
function targetGoldenQuestionQueryParams(
job: DurableDeletionTargetOperationInput["job"],
): DatabaseQueryValue[] {
return job.targetType === "knowledge_space" ||
(job.targetType === "source" && job.deleteMode === "keep")
? [job.knowledgeSpaceId]
: targetDocumentQueryParams(job);
}
function targetDocumentQueryParams(
job: DurableDeletionTargetOperationInput["job"],
): DatabaseQueryValue[] {
@ -3354,7 +3375,12 @@ function targetDocumentMembershipAtSql(
const q = (value: string) => quoteDatabaseIdentifier(database, value);
const p = (position: number) => databasePlaceholder(database, position);
if (job.targetType === "document_asset") {
return `${documentIdExpression} = ${p(targetParamPosition)}`;
const targetParameter = textComparison
? database.dialect === "postgres"
? `CAST(CAST(${p(targetParamPosition)} AS UUID) AS TEXT)`
: `CAST(${p(targetParamPosition)} AS CHAR(36))`
: p(targetParamPosition);
return `${documentIdExpression} = ${targetParameter}`;
}
if (job.targetType === "logical_document") {
const selectedRevisionAsset = textComparison
@ -3589,7 +3615,7 @@ async function deleteDocumentDerivedResiduePage(
maxRows: limit,
operation: "select",
params,
sql: `SELECT artifact.${q("id")} FROM ${q("parse_artifacts")} AS artifact WHERE artifact.${q("document_asset_id")} ${documentPredicate} ORDER BY artifact.${q("id")} ASC LIMIT ${p(2)};`,
sql: `SELECT artifact.${q("id")} FROM ${q("parse_artifacts")} AS artifact WHERE artifact.${q("document_asset_id")} ${documentPredicate} AND EXISTS (SELECT 1 FROM ${q("document_assets")} AS target_document WHERE target_document.${q("id")} = artifact.${q("document_asset_id")} AND target_document.${q("knowledge_space_id")} = ${p(1)}) ORDER BY artifact.${q("id")} ASC LIMIT ${p(2)};`,
tableName: "parse_artifacts",
});
const parseArtifactIds = parseArtifacts.rows.map((row) => stringColumn(row, "id"));
@ -3797,7 +3823,7 @@ async function hasTargetGoldenQuestionResidue(
const result = await executor.execute({
maxRows: 1,
operation: "select",
params: targetDocumentQueryParams(job),
params: targetGoldenQuestionQueryParams(job),
sql: `SELECT ${alias}.${q("id")} FROM ${q("golden_questions")} AS ${alias} WHERE ${alias}.${q("knowledge_space_id")} = ${p(1)} AND ${targetGoldenQuestionPredicateSql(database, job, alias)} LIMIT 1;`,
tableName: "golden_questions",
});
@ -4145,7 +4171,7 @@ async function hasTargetDocumentForeignKeyResidue(
table: "artifact_segments",
},
{
sql: `SELECT artifact.${q("id")} FROM ${q("parse_artifacts")} AS artifact WHERE artifact.${q("document_asset_id")} ${documentPredicate} LIMIT 1;`,
sql: `SELECT artifact.${q("id")} FROM ${q("parse_artifacts")} AS artifact WHERE artifact.${q("document_asset_id")} ${documentPredicate} AND EXISTS (SELECT 1 FROM ${q("document_assets")} AS target_document WHERE target_document.${q("id")} = artifact.${q("document_asset_id")} AND target_document.${q("knowledge_space_id")} = ${p(1)}) LIMIT 1;`,
table: "parse_artifacts",
},
] as const;
@ -4373,6 +4399,10 @@ function targetKnowledgeFsLeasePredicateSql(
const q = (value: string) => quoteDatabaseIdentifier(database, value);
const p = (position: number) => databasePlaceholder(database, position);
const field = (column: string) => `${leaseAlias}.${q(column)}`;
const textParam = (position: number) =>
database.dialect === "postgres"
? `CAST(CAST(${p(position)} AS UUID) AS TEXT)`
: `CAST(${p(position)} AS CHAR(36))`;
const scope = `${field("tenant_id")} = ${p(1)} AND ${field("knowledge_space_id")} = ${p(2)}`;
if (job.targetType === "knowledge_space") return scope;
@ -4406,7 +4436,7 @@ function targetKnowledgeFsLeasePredicateSql(
const pathTarget = `${field("target_type")} = 'knowledge-path' AND EXISTS (SELECT 1 FROM ${q("knowledge_paths")} AS target_path WHERE target_path.${q("knowledge_space_id")} = ${p(2)} AND (${field("target_id")} = ${castId("target_path")} OR ${field("target_id")} = target_path.${q("target_id")} OR ${field("virtual_path")} = target_path.${q("virtual_path")}) AND ${targetSemanticPathPredicateSql(database, uuidDocumentPredicate, textDocumentPredicate, 2, "target_path")})`;
const stagedCommitTarget = `${field("target_type")} = 'staged-commit' AND EXISTS (SELECT 1 FROM ${q("knowledge_space_staged_commits")} AS target_commit WHERE target_commit.${q("tenant_id")} = ${p(1)} AND target_commit.${q("knowledge_space_id")} = ${p(2)} AND ${uuidDocumentMembership(`target_commit.${q("document_asset_id")}`)} AND (${field("target_id")} = ${castId("target_commit")} OR ${field("target_id")} = target_commit.${q("raw_object_key")} OR ${field("target_id")} = target_commit.${q("published_object_key")}))`;
return `${scope} AND ((${field("target_type")} = 'knowledge-space' AND ${field("target_id")} = ${p(2)}) OR (${field("target_type")} = 'document-asset' AND ${textDocumentMembership(field("target_id"))}) OR ${documentVirtualPath} OR ${textDocumentMembership(metadataDocumentId)} OR (${parseArtifactTarget}) OR (${projectionTarget}) OR (${pathTarget}) OR (${stagedCommitTarget}))`;
return `${scope} AND ((${field("target_type")} = 'knowledge-space' AND ${field("target_id")} = ${textParam(2)}) OR (${field("target_type")} = 'document-asset' AND ${textDocumentMembership(field("target_id"))}) OR ${documentVirtualPath} OR ${textDocumentMembership(metadataDocumentId)} OR (${parseArtifactTarget}) OR (${projectionTarget}) OR (${pathTarget}) OR (${stagedCommitTarget}))`;
}
async function hasActiveMutationLease(

View File

@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import {
nonnegativeSafeIntegerColumn,
numberColumn,
optionalNumberColumn,
optionalStringColumn,
@ -17,6 +18,8 @@ describe("database-row-utils", () => {
it("reads required and optional number columns", () => {
expect(numberColumn({ count: 3 }, "count")).toBe(3);
expect(nonnegativeSafeIntegerColumn({ count: "3" }, "count")).toBe(3);
expect(nonnegativeSafeIntegerColumn({ count: 3 }, "count")).toBe(3);
expect(optionalNumberColumn({ count: null }, "count")).toBeUndefined();
expect(optionalNumberColumn({ count: undefined }, "count")).toBeUndefined();
expect(optionalNumberColumn({ count: 3 }, "count")).toBe(3);
@ -35,5 +38,11 @@ describe("database-row-utils", () => {
expect(() => optionalNumberColumn({ count: "3" }, "count")).toThrow(
"Database row column count must be a number",
);
expect(() => nonnegativeSafeIntegerColumn({ count: "-1" }, "count")).toThrow(
"Database row column count must be a nonnegative safe integer",
);
expect(() => nonnegativeSafeIntegerColumn({ count: "9007199254740992" }, "count")).toThrow(
"Database row column count must be a nonnegative safe integer",
);
});
});

View File

@ -34,6 +34,17 @@ export function numberColumn(row: DatabaseRow, column: string): number {
return value;
}
export function nonnegativeSafeIntegerColumn(row: DatabaseRow, column: string): number {
const raw = row[column];
const value = typeof raw === "string" && /^\d+$/u.test(raw) ? Number(raw) : raw;
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) {
throw new Error(`Database row column ${column} must be a nonnegative safe integer`);
}
return value;
}
export function optionalNumberColumn(row: DatabaseRow, column: string): number | undefined {
const value = row[column];

View File

@ -266,6 +266,12 @@ describe("Dify Capability v2 request guard", () => {
"sources.crawl",
"source",
],
createSourceSyncWorkflow: [
"POST",
"/knowledge-spaces/{id}/sources/{sourceId}/sync",
"source_workflows.sync.create",
"source",
],
getAnswerTrace: ["GET", "/queries/{traceId}", "queries.read", "query"],
getBulkOperation: ["GET", "/bulk-jobs/{id}", "bulk_jobs.read", "job"],
getDocument: [

View File

@ -521,6 +521,25 @@ export const DIFY_CAPABILITY_V2_OPERATIONS: readonly DifyCapabilityV2Operation[]
resource: { pathParameter: "id" },
resourceType: "knowledge_space",
},
{
action: "logical_documents.list",
allowedCallerKinds: STANDARD_CALLERS,
method: "GET",
operationId: "listLogicalDocuments",
pathTemplate: "/knowledge-spaces/{id}/logical-documents",
resource: { pathParameter: "id" },
resourceType: "knowledge_space",
},
{
action: "logical_documents.read",
allowedCallerKinds: STANDARD_CALLERS,
method: "GET",
operationId: "getLogicalDocument",
parentResource: { pathParameter: "id" },
pathTemplate: "/knowledge-spaces/{id}/logical-documents/{documentId}",
resource: { pathParameter: "documentId" },
resourceType: "document",
},
{
action: "documents.create",
allowedCallerKinds: STANDARD_CALLERS,
@ -746,6 +765,132 @@ export const DIFY_CAPABILITY_V2_OPERATIONS: readonly DifyCapabilityV2Operation[]
resource: { pathParameter: "sourceId" },
resourceType: "source",
},
{
action: "source_workflows.sync.create",
allowedCallerKinds: STANDARD_CALLERS,
method: "POST",
operationId: "createSourceSyncWorkflow",
parentResource: { pathParameter: "id" },
pathTemplate: "/knowledge-spaces/{id}/sources/{sourceId}/sync",
resource: { pathParameter: "sourceId" },
resourceType: "source",
},
{
action: "source_providers.list",
allowedCallerKinds: STANDARD_CALLERS,
method: "GET",
operationId: "listSourceProviders",
pathTemplate: "/source-providers",
resource: { namespace: true },
resourceType: "namespace",
},
{
action: "source_connections.create",
allowedCallerKinds: STANDARD_CALLERS,
method: "POST",
operationId: "createSourceConnection",
pathTemplate: "/knowledge-spaces/{id}/source-connections",
resource: { pathParameter: "id" },
resourceType: "knowledge_space",
},
{
action: "source_connections.list",
allowedCallerKinds: STANDARD_CALLERS,
method: "GET",
operationId: "listSourceConnections",
pathTemplate: "/knowledge-spaces/{id}/source-connections",
resource: { pathParameter: "id" },
resourceType: "knowledge_space",
},
{
action: "source_connections.refresh",
allowedCallerKinds: STANDARD_CALLERS,
method: "POST",
operationId: "refreshSourceConnection",
pathTemplate: "/knowledge-spaces/{id}/source-connections/{connectionId}/refresh",
resource: { pathParameter: "id" },
resourceType: "knowledge_space",
},
{
action: "source_workflows.preview.create",
allowedCallerKinds: STANDARD_CALLERS,
method: "POST",
operationId: "createSourceCrawlPreviewWorkflow",
parentResource: { pathParameter: "id" },
pathTemplate: "/knowledge-spaces/{id}/sources/{sourceId}/crawl-preview",
resource: { pathParameter: "sourceId" },
resourceType: "source",
},
{
action: "source_sync_policies.read",
allowedCallerKinds: STANDARD_CALLERS,
method: "GET",
operationId: "getSourceSyncPolicy",
parentResource: { pathParameter: "id" },
pathTemplate: "/knowledge-spaces/{id}/sources/{sourceId}/sync-policy",
resource: { pathParameter: "sourceId" },
resourceType: "source",
},
{
action: "source_sync_policies.update",
allowedCallerKinds: STANDARD_CALLERS,
method: "PUT",
operationId: "putSourceSyncPolicy",
parentResource: { pathParameter: "id" },
pathTemplate: "/knowledge-spaces/{id}/sources/{sourceId}/sync-policy",
resource: { pathParameter: "sourceId" },
resourceType: "source",
},
{
action: "source_workflows.read",
allowedCallerKinds: STANDARD_CALLERS,
method: "GET",
operationId: "getSourceWorkflow",
parentResource: { pathParameter: "id" },
pathTemplate: "/knowledge-spaces/{id}/source-workflows/{runId}",
resource: { pathParameter: "runId" },
resourceType: "job",
},
{
action: "source_workflows.cancel",
allowedCallerKinds: STANDARD_CALLERS,
method: "POST",
operationId: "cancelSourceWorkflow",
parentResource: { pathParameter: "id" },
pathTemplate: "/knowledge-spaces/{id}/source-workflows/{runId}/cancel",
resource: { pathParameter: "runId" },
resourceType: "job",
},
{
action: "source_workflows.retry",
allowedCallerKinds: STANDARD_CALLERS,
method: "POST",
operationId: "retrySourceWorkflow",
parentResource: { pathParameter: "id" },
pathTemplate: "/knowledge-spaces/{id}/source-workflows/{runId}/retry",
resource: { pathParameter: "runId" },
resourceType: "job",
},
{
action: "source_workflows.pages.list",
allowedCallerKinds: STANDARD_CALLERS,
method: "GET",
operationId: "listCrawlPreviewPages",
parentResource: { pathParameter: "id" },
pathTemplate: "/knowledge-spaces/{id}/source-workflows/{runId}/pages",
resource: { pathParameter: "runId" },
resourceType: "job",
},
{
action: "source_workflows.selection.create",
allowedCallerKinds: STANDARD_CALLERS,
method: "POST",
operationId: "selectCrawlPreviewPages",
parentResource: { pathParameter: "id" },
pathTemplate: "/knowledge-spaces/{id}/source-workflows/{runId}/selection",
resource: { pathParameter: "runId" },
resourceType: "job",
},
{
action: "sources.crawl",
allowedCallerKinds: STANDARD_CALLERS,

View File

@ -1311,8 +1311,10 @@ describe("database document compilation attempt repository", () => {
knowledgeSpaceId,
candidatePublicationId,
candidateFingerprint,
"candidate",
2,
]);
expect(fake.calls[1]?.sql).toContain("'published'");
expect(fake.calls[1]?.sql).toContain("projection_set_publication_heads");
expect(fake.calls[1]?.sql).toContain("FOR UPDATE");
});

View File

@ -3043,7 +3043,7 @@ function productIntentRestoreConflict(): DocumentCompilationAttemptTransitionErr
async function requireDatabaseCandidateBinding(
database: DatabaseAdapter,
transaction: DatabaseExecutor,
attempt: Pick<DocumentCompilationAttempt, "knowledgeSpaceId" | "tenantId">,
attempt: Pick<DocumentCompilationAttempt, "baseHeadRevision" | "knowledgeSpaceId" | "tenantId">,
candidate: { readonly candidateFingerprint: string; readonly candidatePublicationId: string },
): Promise<void> {
const result = await transaction.execute({
@ -3054,7 +3054,7 @@ async function requireDatabaseCandidateBinding(
uuid(attempt.knowledgeSpaceId, "knowledgeSpaceId"),
uuid(candidate.candidatePublicationId, "candidatePublicationId"),
ProjectionSetFingerprintSchema.parse(candidate.candidateFingerprint),
"candidate",
nonnegativeInteger(attempt.baseHeadRevision, "baseHeadRevision"),
],
sql: `SELECT ${quoteDatabaseIdentifier(database, "id")} FROM ${quoteDatabaseIdentifier(
database,
@ -3071,10 +3071,34 @@ async function requireDatabaseCandidateBinding(
)} AND ${quoteDatabaseIdentifier(database, "fingerprint")} = ${databasePlaceholder(
database,
4,
)} AND ${quoteDatabaseIdentifier(database, "status")} = ${databasePlaceholder(
)} AND (${quoteDatabaseIdentifier(database, "status")} = 'candidate' OR (${quoteDatabaseIdentifier(
database,
5,
)} LIMIT 1 FOR UPDATE;`,
"status",
)} = 'published' AND EXISTS (SELECT 1 FROM ${quoteDatabaseIdentifier(
database,
"projection_set_publication_heads",
)} AS publication_head WHERE publication_head.${quoteDatabaseIdentifier(
database,
"tenant_id",
)} = ${quoteDatabaseIdentifier(database, publicationTableName)}.${quoteDatabaseIdentifier(
database,
"tenant_id",
)} AND publication_head.${quoteDatabaseIdentifier(
database,
"knowledge_space_id",
)} = ${quoteDatabaseIdentifier(database, publicationTableName)}.${quoteDatabaseIdentifier(
database,
"knowledge_space_id",
)} AND publication_head.${quoteDatabaseIdentifier(
database,
"publication_id",
)} = ${quoteDatabaseIdentifier(database, publicationTableName)}.${quoteDatabaseIdentifier(
database,
"id",
)} AND publication_head.${quoteDatabaseIdentifier(
database,
"head_revision",
)} = ${databasePlaceholder(database, 5)}))) LIMIT 1 FOR UPDATE;`,
tableName: publicationTableName,
});
if (!result.rows[0]) {

View File

@ -288,6 +288,90 @@ describe("document compilation publication coordinator", () => {
expect(compose).not.toHaveBeenCalled();
});
it("completes a rebuild as a no-op when it resolves to the current published snapshot", async () => {
const publications = createInMemoryProjectionSetPublicationRepository({ maxPublications: 10 });
const execution = fakeExecution(attempt());
const members = createInMemoryProjectionSetPublicationMemberRepository({
attempts: {
get: async (id) => (id === execution.context.attempt.id ? execution.context.attempt : null),
},
maxListLimit: 10,
maxMembers: 10,
publications,
});
const compose = vi.spyOn(members, "composeDocumentCandidate");
const material = fingerprintMaterial();
const fingerprint = await buildProjectionSetFingerprint(material);
await publications.createCandidate({
createdAt: now,
fingerprint,
id: conflictingPublicationId,
knowledgeSpaceId,
metadata: {
[DocumentCompilationCandidateMetadataKey]: {
attemptId: conflictingPublicationId,
},
},
projectionVersion: 3,
tenantId,
});
await publications.publish({
expectedHeadRevision: 0,
fingerprint,
knowledgeSpaceId,
tenantId,
updatedAt: now,
});
const coordinator = createDocumentCompilationPublicationCoordinator({
maxComponents: 100,
members,
publications,
validator: allowingValidator(),
});
await expect(
coordinator.composeCandidate({
candidateId: candidatePublicationId,
componentReceipt: replacementReceipt(),
createdAt: now,
execution: execution.context,
fingerprintMaterial: material,
projectionVersion: 3,
}),
).resolves.toMatchObject({
attempt: {
candidateFingerprint: fingerprint,
candidatePublicationId: conflictingPublicationId,
checkpoint: "projection_built",
},
candidate: { id: conflictingPublicationId, status: "published" },
inheritedMemberCount: 0,
replacedMemberCount: 0,
});
expect(compose).not.toHaveBeenCalled();
await expect(
coordinator.evaluateAndPublishCandidate({
evaluator: {
evaluate: async () => {
throw new Error("an identical published snapshot must not be evaluated again");
},
},
execution: execution.context,
updatedAt: now,
}),
).resolves.toMatchObject({
attempt: { checkpoint: "smoke_eval_passed" },
evaluation: "previously-passed",
publication: { headRevision: 1, published: { id: conflictingPublicationId } },
});
await expect(publications.getPublished({ knowledgeSpaceId, tenantId })).resolves.toMatchObject({
fingerprint,
headRevision: 1,
id: conflictingPublicationId,
});
});
it("requires server-side component validation before candidate creation or member mutation", async () => {
const publications = createInMemoryProjectionSetPublicationRepository({ maxPublications: 10 });
const members = createInMemoryProjectionSetPublicationMemberRepository({

View File

@ -210,7 +210,10 @@ export function createDocumentCompilationPublicationCoordinator({
const initialAttempt = validateAttempt(input.execution.attempt);
const deletionToken = await captureCompilationDeletionFence(deletionFence, initialAttempt);
const assertWritable = () => assertCompilationDeletionFence(deletionFence, deletionToken);
const candidateId = normalizeUuid(input.candidateId);
const proposedCandidateId = normalizeUuid(input.candidateId);
const candidateId = initialAttempt.candidatePublicationId
? normalizeUuid(initialAttempt.candidatePublicationId)
: proposedCandidateId;
const createdAt = DateTimeSchema.parse(input.createdAt);
const projectionVersion = positiveInteger(input.projectionVersion, "projectionVersion");
const fingerprintMaterial = ProjectionSetFingerprintMaterialSchema.parse(
@ -249,20 +252,48 @@ export function createDocumentCompilationPublicationCoordinator({
projectionVersion,
tenantId: initialAttempt.tenantId,
});
assertCandidateIdentity(candidate, {
const reusesCurrentPublication = await isCurrentPublishedSnapshot(publications, candidate, {
attempt: initialAttempt,
candidateId,
fingerprint,
projectionVersion,
});
if (!reusesCurrentPublication) {
assertCandidateIdentity(candidate, {
attempt: initialAttempt,
candidateId: proposedCandidateId,
fingerprint,
projectionVersion,
});
}
let attempt = validateAttempt(input.execution.attempt);
assertSameAttemptScope(attempt, initialAttempt);
if (!attempt.candidatePublicationId) {
await assertWritable();
attempt = await bindCandidate(input.execution, attempt, candidateId, fingerprint);
attempt = await bindCandidate(input.execution, attempt, candidate.id, fingerprint);
} else {
assertAttemptCandidateBinding(attempt, candidateId, fingerprint);
assertAttemptCandidateBinding(attempt, candidate.id, fingerprint);
}
if (reusesCurrentPublication) {
assertExecutionFence(input.execution);
await assertWritable();
attempt = validateAttempt(await input.execution.heartbeat());
assertSameAttemptScope(attempt, initialAttempt);
assertAttemptCandidateBinding(attempt, candidate.id, fingerprint);
if (attempt.checkpoint === "nodes_generated") {
attempt = await input.execution.advance({
candidateFingerprint: fingerprint,
candidatePublicationId: candidate.id,
checkpoint: "projection_built",
});
}
return {
attempt,
candidate,
inheritedMemberCount: 0,
replacedMemberCount: 0,
};
}
assertExecutionFence(input.execution);
@ -345,13 +376,20 @@ export function createDocumentCompilationPublicationCoordinator({
"Document compilation candidate publication was not found",
);
}
assertCandidateIdentity(candidate, {
allowedStatuses: ["candidate", "published"],
const reusesCurrentPublication = await isCurrentPublishedSnapshot(publications, candidate, {
attempt: initialAttempt,
candidateId: candidatePublicationId,
fingerprint: candidateFingerprint,
projectionVersion: candidate.projectionVersion,
});
if (!reusesCurrentPublication) {
assertCandidateIdentity(candidate, {
allowedStatuses: ["candidate", "published"],
attempt: initialAttempt,
candidateId: candidatePublicationId,
fingerprint: candidateFingerprint,
projectionVersion: candidate.projectionVersion,
});
}
try {
if (candidate.status === "published") {
@ -360,7 +398,8 @@ export function createDocumentCompilationPublicationCoordinator({
!published ||
published.id !== candidatePublicationId ||
published.fingerprint !== candidateFingerprint ||
published.headRevision !== initialAttempt.baseHeadRevision + 1
published.headRevision !==
initialAttempt.baseHeadRevision + (reusesCurrentPublication ? 0 : 1)
) {
throw new DocumentCompilationCandidateIdentityConflictError(
"Published document compilation candidate is not the expected publication head",
@ -514,6 +553,33 @@ export function createDocumentCompilationPublicationCoordinator({
};
}
async function isCurrentPublishedSnapshot(
publications: Pick<ProjectionSetPublicationRepository, "getPublished">,
candidate: ProjectionSetPublication,
expected: {
readonly attempt: DocumentCompilationAttempt;
readonly fingerprint: string;
readonly projectionVersion: number;
},
): Promise<boolean> {
if (
candidate.status !== "published" ||
candidate.fingerprint !== expected.fingerprint ||
candidate.projectionVersion !== expected.projectionVersion
) {
return false;
}
const published = await publications.getPublished({
knowledgeSpaceId: expected.attempt.knowledgeSpaceId,
tenantId: expected.attempt.tenantId,
});
return (
published?.id === candidate.id &&
published.fingerprint === expected.fingerprint &&
published.headRevision === expected.attempt.baseHeadRevision
);
}
async function ensureExclusiveCandidate(
publications: Pick<ProjectionSetPublicationRepository, "createCandidate" | "getByFingerprint">,
input: Parameters<ProjectionSetPublicationRepository["createCandidate"]>[0],

View File

@ -92,7 +92,11 @@ import {
LegacySpacePublicationBootstrapSnapshotConflictError,
withKnowledgeSpaceDocumentMutationLease,
} from "./legacy-space-publication-bootstrap";
import type { DocumentRevision, LogicalDocumentRepository } from "./logical-document-repository";
import type {
DocumentRevision,
LogicalDocumentRepository,
LogicalDocumentWithActiveRevision,
} from "./logical-document-repository";
import {
LogicalDocumentConflictError,
LogicalDocumentNotFoundError,
@ -368,7 +372,10 @@ export function registerDocumentWriteHandlers({
const bulkJobId = generateBulkUploadId();
const items = [];
const bulkItems: BulkOperationItem[] = [];
const enqueueAsset = async (asset: DocumentAsset) => {
const enqueueAsset = async (
asset: DocumentAsset,
logicalDocument?: LogicalDocumentWithActiveRevision,
) => {
const compilationJob = await traceAsync(
traces,
traceId,
@ -387,7 +394,7 @@ export function registerDocumentWriteHandlers({
);
bulkItems.push({
compilationJobId: compilationJob.id,
documentId: asset.id,
documentId: logicalDocument?.id ?? asset.id,
requiredPermissionScope: requiredPermissionScopeForAsset(asset),
status: "queued",
});
@ -399,17 +406,34 @@ export function registerDocumentWriteHandlers({
stage: "queued" as const,
},
status: "queued" as const,
statusUrl: createDocumentAssetStatusUrl({ documentAssetId: asset.id, knowledgeSpaceId }),
statusUrl: logicalDocument
? createLogicalDocumentTaskStatusUrl({
documentId: logicalDocument.id,
knowledgeSpaceId,
taskId: compilationJob.id,
})
: createDocumentAssetStatusUrl({ documentAssetId: asset.id, knowledgeSpaceId }),
};
};
for (const documentId of requestedDocumentIds ?? []) {
const asset = await traceAsync(traces, traceId, "ingestion.bulk_reindex_asset_lookup", () =>
assets.get({
id: documentId,
knowledgeSpaceId,
}),
);
const logicalDocument = logicalDocuments
? await logicalDocuments.get({
documentId,
knowledgeSpaceId,
tenantId: subject.tenantId,
})
: null;
const assetId = logicalDocument?.active?.documentAssetId ?? documentId;
const asset =
logicalDocument && !logicalDocument.active
? null
: await traceAsync(traces, traceId, "ingestion.bulk_reindex_asset_lookup", () =>
assets.get({
id: assetId,
knowledgeSpaceId,
}),
);
if (!asset || !candidatePermissionAllowsAsset(asset, candidateGrants)) {
items.push({
@ -423,7 +447,7 @@ export function registerDocumentWriteHandlers({
continue;
}
items.push(await enqueueAsset(asset));
items.push(await enqueueAsset(asset, logicalDocument ?? undefined));
}
for (const asset of selectedAssets?.items ?? []) {
@ -592,7 +616,6 @@ export function registerDocumentWriteHandlers({
let asset: DocumentAsset | undefined;
let logicalRevision: DocumentRevision | undefined;
let compilationJobId: string | undefined;
let pathCreated = false;
try {
await assertWritable();
@ -640,17 +663,6 @@ export function registerDocumentWriteHandlers({
);
asset = createdAsset;
createdAssets.push(createdAsset);
await assertWritable();
await traceAsync(traces, traceId, "ingestion.bulk_document_path_upsert", () =>
knowledgePaths.upsertMany([
buildDocumentKnowledgePath({
asset: createdAsset,
id: generateKnowledgePathId(),
tenantId: subject.tenantId,
}),
]),
);
pathCreated = true;
logicalRevision = (
await traceAsync(traces, traceId, "ingestion.bulk_logical_revision_create", () =>
@ -787,15 +799,6 @@ export function registerDocumentWriteHandlers({
// Once a revision exists, retain its raw asset as a failed, inspectable revision; only
// this item is failed and previously accepted jobs keep their objects and queue state.
if (!logicalRevision) {
if (pathCreated) {
await knowledgePaths
.deleteByDocumentAsset({
documentAssetId: id,
knowledgeSpaceId,
maxPaths: 1,
})
.catch(() => undefined);
}
await scrubStaleDocumentUploadWithRetry(
// The durable stale-write scrubber is intentionally deletion-fence-only. This
// branch has already proved deletion did not win, so compensate the unpublished
@ -1261,16 +1264,18 @@ export function registerDocumentWriteHandlers({
asset = scopedAsset;
metadataAsset = asset;
}
await traceAsync(traces, traceId, "ingestion.document_path_upsert", () =>
knowledgePaths.upsertMany([
buildDocumentKnowledgePath({
asset,
id: generateKnowledgePathId(),
tenantId: subject.tenantId,
}),
]),
);
metadataPathCreated = true;
if (!compilationAuthorization) {
await traceAsync(traces, traceId, "ingestion.document_path_upsert", () =>
knowledgePaths.upsertMany([
buildDocumentKnowledgePath({
asset,
id: generateKnowledgePathId(),
tenantId: subject.tenantId,
}),
]),
);
metadataPathCreated = true;
}
await assertWritable();
await traceAsync(traces, traceId, "ingestion.staged_commit_metadata_prepared", () =>
stagedCommits.transition({

View File

@ -1430,7 +1430,10 @@ describe.each(["postgres", "tidb"] as const)(
it("retries and then completes a fenced external item with redaction", async () => {
const running = runningJobRow({ checkpoint: "deleting_objects" });
const itemId = "del-item-1";
const pending = itemRow({ id: itemId });
const pending = itemRow({
id: itemId,
...(dialect === "postgres" ? { ordinal: "1" } : {}),
});
const retryAt = "2026-07-14T12:01:00.000Z";
const retrying = itemRow({
attempts: 1,
@ -1467,7 +1470,7 @@ describe.each(["postgres", "tidb"] as const)(
now: createdAt,
}),
).resolves.toMatchObject([
{ attempts: 0, id: itemId, objectKey: pending.object_key, status: "pending" },
{ attempts: 0, id: itemId, objectKey: pending.object_key, ordinal: 1, status: "pending" },
]);
claimScript.expectDone();
@ -1515,11 +1518,13 @@ describe.each(["postgres", "tidb"] as const)(
redactedAt: completedAt,
status: "completed",
});
expect(
completeScript.calls.find(
(call) => call.operation === "update" && call.tableName === "deletion_job_items",
)?.sql,
).toContain("redacted_at");
const completeItemSql = completeScript.calls.find(
(call) => call.operation === "update" && call.tableName === "deletion_job_items",
)?.sql;
expect(completeItemSql).toContain("redacted_at");
if (dialect === "postgres") {
expect(completeItemSql).toContain("THEN CAST($3 AS TIMESTAMPTZ)");
}
completeScript.expectDone();
});

View File

@ -14,6 +14,7 @@ import {
resolveCapabilityJobPublicationGrant,
} from "./capability-job-fence";
import {
nonnegativeSafeIntegerColumn,
numberColumn,
optionalNumberColumn,
optionalStringColumn,
@ -1299,11 +1300,13 @@ async function completeDeletionItem(
) {
return null;
}
const completionTimestamp =
database.dialect === "postgres" ? `CAST(${p(database, 3)} AS TIMESTAMPTZ)` : p(database, 3);
const updated = await transaction.execute({
maxRows: 0,
operation: "update",
params: [item.attempts + 1, item.rowVersion + 1, input.now, item.id, job.id, item.rowVersion],
sql: `UPDATE ${q(database, itemTable)} SET ${q(database, "status")} = 'completed', ${q(database, "attempts")} = ${p(database, 1)}, ${q(database, "next_attempt_at")} = NULL, ${q(database, "object_key")} = NULL, ${q(database, "credential_ref")} = NULL, ${q(database, "cache_key")} = NULL, ${q(database, "last_error_code")} = NULL, ${q(database, "last_error_message")} = NULL, ${q(database, "row_version")} = ${p(database, 2)}, ${q(database, "updated_at")} = ${p(database, 3)}, ${q(database, "completed_at")} = ${p(database, 3)}, ${q(database, "redacted_at")} = CASE WHEN ${q(database, "kind")} IN ('object', 'secret_ref', 'cache_key') THEN ${p(database, 3)} ELSE NULL END WHERE ${q(database, "id")} = ${p(database, 4)} AND ${q(database, "deletion_job_id")} = ${p(database, 5)} AND ${q(database, "row_version")} = ${p(database, 6)};`,
sql: `UPDATE ${q(database, itemTable)} SET ${q(database, "status")} = 'completed', ${q(database, "attempts")} = ${p(database, 1)}, ${q(database, "next_attempt_at")} = NULL, ${q(database, "object_key")} = NULL, ${q(database, "credential_ref")} = NULL, ${q(database, "cache_key")} = NULL, ${q(database, "last_error_code")} = NULL, ${q(database, "last_error_message")} = NULL, ${q(database, "row_version")} = ${p(database, 2)}, ${q(database, "updated_at")} = ${completionTimestamp}, ${q(database, "completed_at")} = ${completionTimestamp}, ${q(database, "redacted_at")} = CASE WHEN ${q(database, "kind")} IN ('object', 'secret_ref', 'cache_key') THEN ${completionTimestamp} ELSE NULL END WHERE ${q(database, "id")} = ${p(database, 4)} AND ${q(database, "deletion_job_id")} = ${p(database, 5)} AND ${q(database, "row_version")} = ${p(database, 6)};`,
tableName: itemTable,
});
return updated.rowsAffected === 1
@ -3165,7 +3168,7 @@ function mapItem(row: DatabaseRow): DurableDeletionJobItem {
...(optionalStringColumn(row, "object_key")
? { objectKey: optionalStringColumn(row, "object_key") }
: {}),
ordinal: numberColumn(row, "ordinal"),
ordinal: nonnegativeSafeIntegerColumn(row, "ordinal"),
payloadDigest: stringColumn(row, "payload_digest"),
...(optionalStringColumn(row, "redacted_at")
? { redactedAt: optionalStringColumn(row, "redacted_at") }

View File

@ -3473,6 +3473,89 @@ describe("document write gateway integration", () => {
).toThrow("Bulk document reindex maxBulkReindexDocuments must be at least 1");
});
it("resolves a logical document id to its active asset for reindexing", async () => {
const knowledgeSpaceId = "018f0d60-7a49-7cc2-9c1b-5b36f18f2c42";
const logicalDocumentId = "018f0d60-7a49-7cc2-9c1b-5b36f18f3d01";
const assetId = "018f0d60-7a49-7cc2-9c1b-5b36f18f3a01";
const adapter = createNodePlatformAdapter({ env: {} });
const assets = createInMemoryDocumentAssetRepository({ maxAssets: 10 });
const asset = await assets.create({
filename: "Logical.md",
id: assetId,
knowledgeSpaceId,
mimeType: "text/markdown",
objectKey: "tenant-1/spaces/space/documents/logical.md",
sha256: "a".repeat(64),
sizeBytes: 1,
});
const logicalDocuments = createInMemoryLogicalDocumentRepository({
canReadDocument: ({ candidateGrants }) => candidateGrants.includes("document:read"),
canReadRevision: ({ candidateGrants }) => candidateGrants.includes("document:read"),
generateDocumentId: () => logicalDocumentId,
maxDocuments: 10,
maxRevisionsPerDocument: 2,
});
const candidate = await logicalDocuments.createCandidateRevision({
contentHash: "a".repeat(64),
documentAssetId: asset.id,
documentAssetVersion: asset.version,
knowledgeSpaceId,
mimeType: asset.mimeType,
now: "2026-07-27T00:00:00.000Z",
sizeBytes: asset.sizeBytes,
systemMetadata: {},
tenantId: "tenant-1",
title: asset.filename,
});
await logicalDocuments.activateRevision({
documentId: logicalDocumentId,
expectedActiveRevision: null,
expectedRowVersion: 0,
knowledgeSpaceId,
now: "2026-07-27T00:01:00.000Z",
revision: candidate.revision.revision,
tenantId: "tenant-1",
});
const compilationJobs = createDocumentCompilationJobStateMachine({
generateId: () => "logical-reindex-job-1",
jobs: adapter.jobs,
repository: createInMemoryDocumentCompilationJobRepository({ maxJobs: 10 }),
});
const app = createKnowledgeGateway({
adapter,
auth: createTestAuthVerifier(),
documentAssets: assets,
documentCompilationJobs: compilationJobs,
generateBulkUploadId: () => "logical-reindex-bulk-1",
knowledgeSpaces: createInMemoryKnowledgeSpaceRepository({
generateId: () => knowledgeSpaceId,
maxListLimit: 10,
maxSpaces: 10,
}),
logicalDocuments,
});
await app.request("/knowledge-spaces", {
body: JSON.stringify({ name: "Logical reindex", slug: "logical-reindex" }),
headers: { ...bearer(writeToken), "content-type": "application/json" },
method: "POST",
});
const response = await app.request(
`/knowledge-spaces/${knowledgeSpaceId}/documents/bulk/reindex`,
{
body: JSON.stringify({ documentIds: [logicalDocumentId] }),
headers: { ...bearer(writeToken), "content-type": "application/json" },
method: "POST",
},
);
expect(response.status).toBe(202);
await expect(response.json()).resolves.toMatchObject({
items: [{ asset: { id: assetId }, status: "queued" }],
total: 1,
});
});
it("reports tenant-scoped bulk job progress across queued and completed operations", async () => {
const adapter = createNodePlatformAdapter({ env: {} });
const assets = createInMemoryDocumentAssetRepository({

View File

@ -9874,27 +9874,6 @@ describe("createKnowledgeGateway", () => {
nodes,
}),
).toThrow("Incremental reindexer maxNodes must be at least 1");
await expect(
createIncrementalReindexer({
artifacts,
compute,
denseBuilder: {
build: async () => [],
},
maxNodes: 4,
nodes,
}).reindex({
knowledgeSpaceId,
parseArtifact: ParseArtifactSchema.parse({
...changedArtifact,
artifactHash: "c".repeat(64),
}),
projectionVersion: 2,
}),
).rejects.toThrow(
"Incremental reindexer denseModel is required when denseBuilder is configured",
);
await nodes.deleteByDocumentAsset({ documentAssetId, knowledgeSpaceId, maxNodes: 4 });
const denseBuilds: unknown[] = [];
await expect(

View File

@ -623,7 +623,7 @@ describe("incremental reindexer", () => {
).rejects.toThrow("inconsistent text embedding space");
});
it("validates bounded configuration, dense model requirements, and max node output", async () => {
it("validates bounded configuration, optional dense indexing, and max node output", async () => {
const artifacts = createInMemoryParseArtifactRepository({ maxArtifacts: 4 });
const nodes = createInMemoryKnowledgeNodeRepository({
maxBatchSize: 4,
@ -641,11 +641,17 @@ describe("incremental reindexer", () => {
}),
).toThrow("Incremental reindexer maxNodes must be at least 1");
let denseBuilds = 0;
await expect(
createIncrementalReindexer({
artifacts,
compute,
denseBuilder: { build: async () => [] },
denseBuilder: {
build: async () => {
denseBuilds += 1;
return [];
},
},
maxNodes: 4,
nodes,
}).reindex({
@ -653,9 +659,8 @@ describe("incremental reindexer", () => {
parseArtifact: parseArtifact({ artifactHash: "c".repeat(64) }),
projectionVersion: 1,
}),
).rejects.toThrow(
"Incremental reindexer denseModel is required when denseBuilder is configured",
);
).resolves.toMatchObject({ status: "rebuilt" });
expect(denseBuilds).toBe(0);
await expect(
createIncrementalReindexer({

View File

@ -161,7 +161,7 @@ export function createIncrementalReindexer({
}
: {}),
reindex: async (input) => {
validateIncrementalReindexInput(input, { denseBuilder, visualBuilder });
validateIncrementalReindexInput(input, { visualBuilder });
const parseArtifact = cloneParseArtifact(ParseArtifactSchema.parse(input.parseArtifact));
const publicationGenerationId =
input.publicationGenerationId === undefined
@ -375,10 +375,7 @@ function validateReindexProjectionDimensions(
function validateIncrementalReindexInput(
input: IncrementalReindexInput,
{
denseBuilder,
visualBuilder,
}: Pick<IncrementalReindexerOptions, "denseBuilder" | "visualBuilder">,
{ visualBuilder }: Pick<IncrementalReindexerOptions, "visualBuilder">,
): void {
if (!input.knowledgeSpaceId.trim()) {
throw new Error("Incremental reindexer knowledgeSpaceId is required");
@ -396,10 +393,6 @@ function validateIncrementalReindexInput(
PublicationGenerationIdSchema.parse(input.publicationGenerationId);
}
if (denseBuilder && !input.denseModel?.trim()) {
throw new Error("Incremental reindexer denseModel is required when denseBuilder is configured");
}
if (visualBuilder && !input.visualModel?.trim()) {
throw new Error(
"Incremental reindexer visualModel is required when visualBuilder is configured",

View File

@ -7,6 +7,7 @@ import {
import { CapabilityPublicationFencedError } from "./capability-grant-provenance";
import { resolveCapabilityJobPublicationGrant } from "./capability-job-fence";
import {
nonnegativeSafeIntegerColumn,
numberColumn,
optionalNumberColumn,
optionalStringColumn,
@ -2218,7 +2219,7 @@ function mapRevision(row: DatabaseRow): DocumentRevision {
knowledgeSpaceId: stringColumn(row, "knowledge_space_id"),
mimeType: stringColumn(row, "mime_type"),
revision: numberColumn(row, "revision"),
sizeBytes: numberColumn(row, "size_bytes"),
sizeBytes: nonnegativeSafeIntegerColumn(row, "size_bytes"),
state,
systemMetadata: jsonObjectColumn(row, "system_metadata"),
tenantId: stringColumn(row, "tenant_id"),

View File

@ -161,13 +161,13 @@ describe("flattened PageIndex build repository", () => {
rows: [
{
checksum: manifestParams[10],
actual_node_count: nodeRows.length,
actual_term_count: termRows.length,
actual_node_count: String(nodeRows.length),
actual_term_count: String(termRows.length),
document_asset_id: manifestParams[3],
document_outline_id: manifestParams[4],
document_version: manifestParams[5],
id: manifestParams[0],
invalid_term_count: 0,
invalid_term_count: "0",
knowledge_space_id: manifestParams[1],
node_count: manifestParams[8],
publication_generation_id: manifestParams[2],

View File

@ -15,7 +15,12 @@ import {
} from "@knowledge/core";
import { deterministicChildId } from "./api-shared-utils";
import { numberColumn, optionalStringColumn, stringColumn } from "./database-row-utils";
import {
nonnegativeSafeIntegerColumn,
numberColumn,
optionalStringColumn,
stringColumn,
} from "./database-row-utils";
import { databasePlaceholder, quoteDatabaseIdentifier } from "./database-sql-utils";
import { jsonArrayColumn, jsonObjectColumn } from "./json-utils";
import {
@ -745,9 +750,9 @@ async function readLockedDatabasePageIndex(
) as typeof PageIndexTokenizerVersion,
};
return {
actualNodeCount: numberColumn(manifestRow, "actual_node_count"),
actualTermCount: numberColumn(manifestRow, "actual_term_count"),
invalidTermCount: numberColumn(manifestRow, "invalid_term_count"),
actualNodeCount: nonnegativeSafeIntegerColumn(manifestRow, "actual_node_count"),
actualTermCount: nonnegativeSafeIntegerColumn(manifestRow, "actual_term_count"),
invalidTermCount: nonnegativeSafeIntegerColumn(manifestRow, "invalid_term_count"),
manifest,
};
}

View File

@ -71,6 +71,7 @@ const BulkWorkflowItem = z.object({
export const listSourceProvidersRoute = createRoute({
method: "get",
operationId: "listSourceProviders",
path: "/source-providers",
responses: {
200: {
@ -83,6 +84,7 @@ export const listSourceProvidersRoute = createRoute({
export const createSourceConnectionRoute = createRoute({
method: "post",
operationId: "createSourceConnection",
path: "/knowledge-spaces/{id}/source-connections",
request: {
params: SpaceParams,
@ -189,6 +191,7 @@ export const completeSourceOAuthRoute = createRoute({
export const listSourceConnectionsRoute = createRoute({
method: "get",
operationId: "listSourceConnections",
path: "/knowledge-spaces/{id}/source-connections",
request: {
params: SpaceParams,
@ -231,6 +234,7 @@ export const getSourceConnectionRoute = createRoute({
export const refreshSourceConnectionRoute = createRoute({
method: "post",
operationId: "refreshSourceConnection",
path: "/knowledge-spaces/{id}/source-connections/{connectionId}/refresh",
request: {
params: ConnectionParams,
@ -277,6 +281,7 @@ export const revokeSourceConnectionRoute = createRoute({
export const createSourceSyncWorkflowRoute = createRoute({
method: "post",
operationId: "createSourceSyncWorkflow",
path: "/knowledge-spaces/{id}/sources/{sourceId}/sync",
request: { params: SourceParams, headers: IdempotencyHeader },
responses: {
@ -294,6 +299,7 @@ export const createSourceSyncWorkflowRoute = createRoute({
export const createSourceCrawlPreviewWorkflowRoute = createRoute({
method: "post",
operationId: "createSourceCrawlPreviewWorkflow",
path: "/knowledge-spaces/{id}/sources/{sourceId}/crawl-preview",
request: { params: SourceParams, headers: IdempotencyHeader },
responses: {
@ -374,6 +380,7 @@ export const createSourceImportWorkflowRoute = createRoute({
export const getSourceSyncPolicyRoute = createRoute({
method: "get",
operationId: "getSourceSyncPolicy",
path: "/knowledge-spaces/{id}/sources/{sourceId}/sync-policy",
request: { params: SourceParams },
responses: {
@ -389,6 +396,7 @@ export const getSourceSyncPolicyRoute = createRoute({
export const putSourceSyncPolicyRoute = createRoute({
method: "put",
operationId: "putSourceSyncPolicy",
path: "/knowledge-spaces/{id}/sources/{sourceId}/sync-policy",
request: {
params: SourceParams,
@ -487,6 +495,7 @@ export const listSourceWorkflowsRoute = createRoute({
export const getSourceWorkflowRoute = createRoute({
method: "get",
operationId: "getSourceWorkflow",
path: "/knowledge-spaces/{id}/source-workflows/{runId}",
request: { params: WorkflowParams },
responses: {
@ -532,6 +541,7 @@ export const listSourceBulkWorkflowItemsRoute = createRoute({
export const cancelSourceWorkflowRoute = createRoute({
method: "post",
operationId: "cancelSourceWorkflow",
path: "/knowledge-spaces/{id}/source-workflows/{runId}/cancel",
request: {
params: WorkflowParams,
@ -558,6 +568,7 @@ export const cancelSourceWorkflowRoute = createRoute({
export const retrySourceWorkflowRoute = createRoute({
method: "post",
operationId: "retrySourceWorkflow",
path: "/knowledge-spaces/{id}/source-workflows/{runId}/retry",
request: { params: WorkflowParams },
responses: {
@ -574,6 +585,7 @@ export const retrySourceWorkflowRoute = createRoute({
export const listCrawlPreviewPagesRoute = createRoute({
method: "get",
operationId: "listCrawlPreviewPages",
path: "/knowledge-spaces/{id}/source-workflows/{runId}/pages",
request: {
params: WorkflowParams,
@ -612,6 +624,7 @@ export const listCrawlPreviewPagesRoute = createRoute({
export const selectCrawlPreviewPagesRoute = createRoute({
method: "post",
operationId: "selectCrawlPreviewPages",
path: "/knowledge-spaces/{id}/source-workflows/{runId}/selection",
request: {
params: WorkflowParams,

View File

@ -209,6 +209,127 @@ describe.each(["postgres", "tidb"] as const)(
);
});
it("attributes capability workflow completion to the admitted grant subject", async () => {
const calls: DatabaseExecuteInput[] = [];
const database = orderedMutationDatabase(
dialect,
calls,
runningCapabilitySourceRunRow(),
false,
);
const repository = createDatabaseSourceProductWorkflowRepository({ database });
await expect(
repository.complete({
fence: { leaseToken, rowVersion: 7, runId, workerId: "source-worker" },
now,
}),
).resolves.toMatchObject({ state: "completed" });
const activity = calls.find(
(call) =>
call.tableName === "knowledge_space_activity_events" && call.operation === "insert",
);
expect(activity?.params.slice(3, 5)).toEqual(["member", "editor-a"]);
});
it("attributes capability workflow failure to the admitted grant subject", async () => {
const calls: DatabaseExecuteInput[] = [];
const database = orderedMutationDatabase(
dialect,
calls,
runningCapabilitySourceRunRow(),
false,
);
const repository = createDatabaseSourceProductWorkflowRepository({ database });
await expect(
repository.fail({
errorCode: "SOURCE_IMPORT_FAILED",
errorMessage: "provider unavailable",
fence: { leaseToken, rowVersion: 7, runId, workerId: "source-worker" },
now,
}),
).resolves.toMatchObject({ state: "failed" });
const activity = calls.find(
(call) =>
call.tableName === "knowledge_space_activity_events" && call.operation === "insert",
);
expect(activity?.params.slice(3, 5)).toEqual(["member", "editor-a"]);
});
it("keeps capability workflow update columns aligned with their parameters", async () => {
const calls: DatabaseExecuteInput[] = [];
const database = orderedMutationDatabase(dialect, calls, capabilitySourceRunRow(), false);
const repository = createDatabaseSourceProductWorkflowRepository({ database });
await repository.cancel({
capabilityGrantId,
now,
reason: "stop",
runId,
});
const update = calls.find(
(call) => call.tableName === "source_workflow_runs" && call.operation === "update",
);
expect(update?.sql).toContain("capability_grant_id");
expect(update?.params[11]).toBe(capabilityGrantId);
const placeholders = update?.sql.match(dialect === "postgres" ? /\$\d+/gu : /\?/gu) ?? [];
expect(placeholders).toHaveLength(update?.params.length ?? 0);
});
it("terminalizes a fenced worker failure while durable deletion is active", async () => {
const calls: DatabaseExecuteInput[] = [];
const database = orderedMutationDatabase(dialect, calls, runRow(), false, true);
const repository = createDatabaseSourceProductWorkflowRepository({ database });
await expect(
repository.fail({
errorCode: "SOURCE_IMPORT_PARTIAL_FAILURE",
errorMessage: "Import compensation requested deletion",
fence: { leaseToken, rowVersion: 7, runId, workerId: "source-worker" },
now,
}),
).resolves.toMatchObject({
activeSlot: undefined,
state: "failed",
});
expect(
calls.some(
(call) => call.tableName === "source_workflow_runs" && call.operation === "update",
),
).toBe(true);
});
it("cancels an authorized source workflow while durable deletion is active", async () => {
const calls: DatabaseExecuteInput[] = [];
const database = orderedMutationDatabase(
dialect,
calls,
sourceRunRow("running"),
false,
true,
);
const repository = createDatabaseSourceProductWorkflowRepository({ database });
await expect(
repository.cancel({
accessChannel: "interactive",
now,
permissionSnapshotId,
permissionSnapshotRevision: 1,
reason: "stop",
requestedBySubjectId: "editor-a",
runId,
}),
).resolves.toMatchObject({
activeSlot: undefined,
state: "canceled",
});
});
it("revalidates capability source workflows at restart and terminals revoked work", async () => {
const build = (active: boolean) => {
const calls: DatabaseExecuteInput[] = [];
@ -1863,6 +1984,7 @@ function claimDatabase(
if (input.tableName === "knowledge_space_permission_snapshots") {
return { rows: [permissionRow()], rowsAffected: 1 };
}
if (input.tableName === "capability_grants") return activeCapabilityGrant();
if (isAccessLock(input.tableName)) return oneRow(input.tableName);
if (input.tableName === "sources") return { rows: [sourceRow([])], rowsAffected: 1 };
if (input.tableName === "source_workflow_outbox" && input.operation === "select") {
@ -2138,6 +2260,18 @@ function capabilitySourceRunRow(): DatabaseRow {
};
}
function runningCapabilitySourceRunRow(): DatabaseRow {
return {
...sourceRunRow("running"),
access_channel: null,
capability_grant_id: capabilityGrantId,
permission_snapshot_id: null,
permission_snapshot_revision: null,
requested_by_subject_id: null,
required_permission_scope: null,
};
}
function newBulkRun(): NewSourceWorkflowRun {
return {
accessChannel: "interactive",
@ -2262,14 +2396,19 @@ function orderedMutationDatabase(
calls: DatabaseExecuteInput[],
row: DatabaseRow,
idempotencyMiss: boolean,
activeDeletion = false,
): DatabaseAdapter {
let storedActivity: DatabaseRow | undefined;
return testDatabase(dialect, async (input) => {
calls.push(input);
if (input.tableName === "knowledge_spaces") return activeSpace();
if (input.tableName === "deletion_jobs") return empty();
if (input.tableName === "deletion_jobs") {
return activeDeletion ? oneRow("deletion_jobs") : empty();
}
if (input.tableName === "knowledge_space_permission_snapshots") {
return { rows: [permissionRow()], rowsAffected: 1 };
}
if (input.tableName === "capability_grants") return activeCapabilityGrant();
if (isAccessLock(input.tableName)) return oneRow(input.tableName);
if (input.tableName === "sources") {
return { rows: [sourceRow([])], rowsAffected: 1 };
@ -2286,6 +2425,13 @@ function orderedMutationDatabase(
if (input.tableName === "source_crawl_preview_pages" && input.operation === "select") {
return { rows: [{ page_id: "page-a" }], rowsAffected: 1 };
}
if (input.tableName === "knowledge_space_activity_events") {
if (input.operation === "insert") {
storedActivity = activityRow(input.params);
return { rows: [], rowsAffected: 1 };
}
return storedActivity ? { rows: [storedActivity], rowsAffected: 1 } : empty();
}
return { rows: [], rowsAffected: 1 };
});
}

View File

@ -609,7 +609,7 @@ export function createDatabaseSourceProductWorkflowRepository(input: {
}),
complete: ({ fence, now, state = "completed" }) =>
database.transaction(async (tx) => {
const { run: current } = await requireFenced(database, tx, fence, now);
const { permission, run: current } = await requireFenced(database, tx, fence, now);
if (state === "preview_ready" && current.kind !== "crawl-preview") invalidState();
const terminal = state === "completed" || state === "zero_results";
const next = await writeFenced(database, tx, current, {
@ -631,7 +631,15 @@ export function createDatabaseSourceProductWorkflowRepository(input: {
});
await finishOutbox(database, tx, current.id, "completed", now);
if (terminal && current.kind === "sync" && current.sourceId) {
await appendSourceWorkflowActivity(database, tx, next, "source.synced", "success", now);
await appendSourceWorkflowActivity(
database,
tx,
next,
"source.synced",
"success",
now,
permission?.actorSubjectId,
);
}
return next;
}),
@ -662,7 +670,16 @@ export function createDatabaseSourceProductWorkflowRepository(input: {
}),
fail: ({ errorCode, errorMessage, fence, now }) =>
database.transaction(async (tx) => {
const { run: current } = await requireFenced(database, tx, fence, now, [], true);
const { permission, run: current } = await requireFenced(
database,
tx,
fence,
now,
[],
true,
true,
true,
);
const next = await writeTerminal(database, tx, current, {
errorCode,
errorMessage,
@ -671,7 +688,15 @@ export function createDatabaseSourceProductWorkflowRepository(input: {
});
await finishOutbox(database, tx, current.id, "completed", now);
if (current.sourceId) {
await appendSourceWorkflowActivity(database, tx, next, "source.failed", "failure", now);
await appendSourceWorkflowActivity(
database,
tx,
next,
"source.failed",
"failure",
now,
permission?.actorSubjectId,
);
}
return next;
}),
@ -686,13 +711,22 @@ export function createDatabaseSourceProductWorkflowRepository(input: {
runId,
}) =>
database.transaction(async (tx) => {
const admitted = await getRunForMutationAdmission(database, tx, runId, now, {
accessChannel,
capabilityGrantId,
permissionSnapshotId,
permissionSnapshotRevision,
requestedBySubjectId,
});
const admitted = await getRunForMutationAdmission(
database,
tx,
runId,
now,
{
accessChannel,
capabilityGrantId,
permissionSnapshotId,
permissionSnapshotRevision,
requestedBySubjectId,
},
[],
false,
true,
);
if (!admitted) return null;
const current = admitted.run;
if (["completed", "zero_results", "canceled"].includes(current.state)) return current;
@ -1444,6 +1478,7 @@ async function updateRun(
"progress_completed",
"progress_skipped",
"progress_failed",
"capability_grant_id",
"permission_snapshot_id",
"permission_snapshot_revision",
"requested_by_subject_id",
@ -1465,8 +1500,8 @@ async function updateRun(
"required_permission_scope",
] as const;
const allParams = runParams(next);
// Immutable id/tenant/space occupy 0..2 and created_at is immutable at index 29.
const sourceParams = [...allParams.slice(3, 29), ...allParams.slice(30)];
// Immutable id/tenant/space occupy 0..2 and created_at is immutable at index 30.
const sourceParams = [...allParams.slice(3, 30), ...allParams.slice(31)];
const updateParams = [...sourceParams, next.id, next.rowVersion - 1, ...extraParams];
const idPosition = mutableColumns.length + 1;
const versionPosition = idPosition + 1;
@ -1489,6 +1524,8 @@ async function requireFenced(
now: string,
additionalSourceIds: readonly string[] = [],
allowInvalidPermission = false,
allowDeletionFencedTerminalization = false,
bypassAuthorizationThroughDeletionFence = false,
) {
const admitted = await getRunForMutationAdmission(
database,
@ -1498,6 +1535,8 @@ async function requireFenced(
undefined,
additionalSourceIds,
allowInvalidPermission,
allowDeletionFencedTerminalization,
bypassAuthorizationThroughDeletionFence,
);
const run = admitted?.run;
if (
@ -1526,6 +1565,8 @@ async function getRunForMutationAdmission(
>,
additionalSourceIds: readonly string[] = [],
allowInvalidPermission = false,
allowDeletionFencedTerminalization = false,
bypassAuthorizationThroughDeletionFence = false,
): Promise<{
readonly permission: SourceWorkflowAuthorization | undefined;
readonly run: SourceWorkflowRun;
@ -1533,7 +1574,12 @@ async function getRunForMutationAdmission(
} | null> {
const candidate = await getRun(database, tx, runId, false);
if (!candidate) return null;
if (!(await lockKnowledgeSpaceForDeletionAdmission(database, tx, candidate))) {
const writable = await lockKnowledgeSpaceForDeletionAdmission(database, tx, candidate);
const terminalizingThroughDeletionFence = !writable && allowDeletionFencedTerminalization;
if (
!writable &&
(!terminalizingThroughDeletionFence || !(await knowledgeSpaceExists(database, tx, candidate)))
) {
throw new SourceWorkflowError(
"SOURCE_WORKFLOW_SPACE_NOT_WRITABLE",
"Knowledge space is missing or deletion-fenced",
@ -1543,43 +1589,53 @@ async function getRunForMutationAdmission(
? { ...candidate, ...authorizationOverride }
: candidate;
let permission: SourceWorkflowAuthorization | undefined;
try {
permission = await assertSourceWorkflowPermissionFence(database, tx, authorizationBinding, now);
} catch (error) {
if (
!allowInvalidPermission ||
!(error instanceof SourceWorkflowError) ||
error.code !== "SOURCE_WORKFLOW_PERMISSION_INVALID"
) {
throw error;
}
}
let sourceScopes: ReadonlyMap<string, readonly string[]>;
try {
sourceScopes = await lockSourceWorkflowAdmissions(
database,
tx,
candidate.knowledgeSpaceId,
[candidate.sourceId, ...additionalSourceIds],
permission,
);
} catch (error) {
if (
!allowInvalidPermission ||
!(error instanceof SourceWorkflowError) ||
error.code !== "SOURCE_WORKFLOW_PERMISSION_INVALID"
) {
throw error;
}
if (terminalizingThroughDeletionFence && bypassAuthorizationThroughDeletionFence) {
permission = undefined;
sourceScopes = await lockSourceWorkflowAdmissions(
database,
tx,
candidate.knowledgeSpaceId,
[candidate.sourceId, ...additionalSourceIds],
undefined,
true,
);
sourceScopes = new Map();
} else {
try {
permission = await assertSourceWorkflowPermissionFence(
database,
tx,
authorizationBinding,
now,
);
} catch (error) {
if (
!allowInvalidPermission ||
!(error instanceof SourceWorkflowError) ||
error.code !== "SOURCE_WORKFLOW_PERMISSION_INVALID"
) {
throw error;
}
}
try {
sourceScopes = await lockSourceWorkflowAdmissions(
database,
tx,
candidate.knowledgeSpaceId,
[candidate.sourceId, ...additionalSourceIds],
permission,
);
} catch (error) {
if (
!allowInvalidPermission ||
!(error instanceof SourceWorkflowError) ||
error.code !== "SOURCE_WORKFLOW_PERMISSION_INVALID"
) {
throw error;
}
permission = undefined;
sourceScopes = await lockSourceWorkflowAdmissions(
database,
tx,
candidate.knowledgeSpaceId,
[candidate.sourceId, ...additionalSourceIds],
undefined,
true,
);
}
}
const current = await getRun(database, tx, runId, true);
if (!current) return null;
@ -1600,6 +1656,21 @@ async function getRunForMutationAdmission(
return { permission, run: current, sourceScopes };
}
async function knowledgeSpaceExists(
database: DatabaseAdapter,
tx: DatabaseExecutor,
input: Pick<SourceWorkflowRun, "knowledgeSpaceId" | "tenantId">,
): Promise<boolean> {
const result = await tx.execute({
maxRows: 1,
operation: "select",
params: [input.tenantId, input.knowledgeSpaceId],
sql: `SELECT ${q(database, "id")} FROM ${q(database, "knowledge_spaces")} WHERE ${q(database, "tenant_id")} = ${p(database, 1)} AND ${q(database, "id")} = ${p(database, 2)} LIMIT 1;`,
tableName: "knowledge_spaces",
});
return result.rows.length === 1;
}
async function lockSourceWorkflowAdmissions(
database: DatabaseAdapter,
tx: DatabaseExecutor,
@ -1701,7 +1772,10 @@ async function assertSourceWorkflowPermissionFence(
knowledgeSpaceId: binding.knowledgeSpaceId,
tenantId: binding.tenantId,
});
return { permissionScopes: grant.contentScopeIds };
return {
actorSubjectId: grant.subjectId,
permissionScopes: grant.contentScopeIds,
};
} catch {
throw new SourceWorkflowError(
"SOURCE_WORKFLOW_PERMISSION_INVALID",
@ -1745,10 +1819,15 @@ async function assertSourceWorkflowPermissionFence(
);
}
assertSourceWorkflowScopeAllowed(binding.requiredPermissionScope, permission.permissionScopes);
return permission;
return {
actorSubjectId: permission.subjectId,
permissionScopes: permission.permissionScopes,
};
}
interface SourceWorkflowAuthorization {
/** Subject resolved under the same durable permission or Capability fence as terminalization. */
readonly actorSubjectId: string;
readonly permissionScopes: readonly string[];
}
@ -1927,6 +2006,7 @@ async function appendSourceWorkflowActivity(
action: "source.failed" | "source.synced",
result: "failure" | "success",
now: string,
actorSubjectId?: string,
) {
if (!run.sourceId) return;
const source = await tx.execute({
@ -1945,12 +2025,13 @@ async function appendSourceWorkflowActivity(
const requiredPermissionScope = candidatePermissionScopeSnapshot(
jsonStringArrayColumn(row, "permission_scope"),
);
const memberActorId = actorSubjectId ?? run.requestedBySubjectId;
await appendKnowledgeSpaceActivityWithExecutor({
database,
executor: tx,
input: {
action,
actor: { id: run.requestedBySubjectId, type: "member" },
actor: memberActorId ? { id: memberActorId, type: "member" } : { type: "system" },
details: {
...(run.lastErrorCode ? { reasonCode: run.lastErrorCode } : {}),
count: run.progressCompleted,

View File

@ -174,7 +174,28 @@ describe("source-product workflow staged content store", () => {
describe("source-product workflow provider imports", () => {
it("stages a crawl preview, imports the frozen selection, and drains staged content", async () => {
const source = sourceRecord("crawl-preview-source", { type: "web" });
let source = sourceRecord("crawl-preview-source", {
metadata: { preview: true },
status: "disabled",
type: "web",
});
const sources = {
get: vi.fn(async () => source),
update: vi.fn(
async (input: {
readonly metadata?: Source["metadata"];
readonly status?: Source["status"];
}) => {
source = {
...source,
...(input.metadata ? { metadata: input.metadata } : {}),
...(input.status ? { status: input.status } : {}),
version: source.version + 1,
};
return source;
},
),
};
const pages = [
{
content: "First page body",
@ -219,6 +240,7 @@ describe("source-product workflow provider imports", () => {
maxCleanupBatchesPerRun: 2,
run,
source,
sources: sources as never,
websiteCrawl: { crawl: vi.fn(async () => ({ pages })) },
});
@ -255,6 +277,7 @@ describe("source-product workflow provider imports", () => {
});
expect(fixture.publish).toHaveBeenCalledTimes(2);
expect(deleteRun).toHaveBeenCalledTimes(2);
expect(source).toMatchObject({ metadata: { preview: false }, status: "active", version: 2 });
});
it("imports online-document records with and without optional identity metadata", async () => {

View File

@ -271,6 +271,7 @@ export function createSourceProductWorkflowRuntime(input: {
) {
await cleanupStagedContent(input, execution, maxCleanupBatchesPerRun);
}
await activateImportedPreviewSource(input, execution, source);
await execution.assertActive();
await input.repository.complete({
fence: fence(execution.run()),
@ -597,6 +598,32 @@ async function cleanupStagedContent(
);
}
async function activateImportedPreviewSource(
input: Parameters<typeof createSourceProductWorkflowRuntime>[0],
execution: RuntimeExecution,
source: Source | null,
): Promise<void> {
const run = execution.run();
if (
!source ||
run.kind !== "crawl-preview" ||
selectedPageIds(run).length === 0 ||
source.status !== "disabled" ||
source.metadata.preview !== true
) {
return;
}
await execution.assertActive();
const activated = await input.sources.update({
expectedVersion: source.version,
id: source.id,
knowledgeSpaceId: run.knowledgeSpaceId,
metadata: { ...source.metadata, preview: false },
status: "active",
});
if (!activated) throw runtimeError("SOURCE_NOT_FOUND", "Source no longer exists");
}
async function processCrawlPreview(
input: Parameters<typeof createSourceProductWorkflowRuntime>[0],
execution: RuntimeExecution,

View File

@ -116,6 +116,30 @@ describe("Dify model runtime LLM provider", () => {
});
});
it("keeps content from Dify stream frames with null usage", async () => {
const provider = createDifyModelRuntimeLlmProvider({
...BASE,
client: fakeClient(() => [
{
delta: {
finish_reason: null,
message: { content: "Reply OK." },
usage: null,
},
model: BASE.model,
},
]),
});
const result = await provider.generate({
messages: [{ content: "Reply OK.", role: "user" }],
model: BASE.model,
tenantId: "tenant-abc",
});
expect(result.text).toBe("Reply OK.");
});
it("requires a per-call tenantId and validates constructor options", async () => {
const provider = createDifyModelRuntimeLlmProvider({
...BASE,

View File

@ -745,7 +745,7 @@ const DifyModelRuntimeLlmChunkSchema = z.object({
total_tokens: z.number(),
})
.partial()
.optional(),
.nullish(),
})
.partial()
.optional(),

View File

@ -20,7 +20,46 @@ test("Capability v2 operation export is deterministic and includes internal life
);
const document = JSON.parse(readFileSync(output, "utf8"));
assert.equal(document.schemaVersion, 1);
assert.equal(new Set(document.operations.map((operation) => operation.operationId)).size, 63);
assert.equal(new Set(document.operations.map((operation) => operation.operationId)).size, 78);
assert.deepEqual(
document.operations.find((operation) => operation.operationId === "createSourceSyncWorkflow"),
{
action: "source_workflows.sync.create",
allowedCallerKinds: ["interactive", "service", "agent", "workflow"],
method: "POST",
operationId: "createSourceSyncWorkflow",
parentResourceBinding: { pathParameter: "id" },
path: "/knowledge-spaces/{id}/sources/{sourceId}/sync",
resourceBinding: { pathParameter: "sourceId" },
resourceType: "source",
},
);
assert.deepEqual(
document.operations.find((operation) => operation.operationId === "listSourceProviders"),
{
action: "source_providers.list",
allowedCallerKinds: ["interactive", "service", "agent", "workflow"],
method: "GET",
operationId: "listSourceProviders",
parentResourceBinding: null,
path: "/source-providers",
resourceBinding: { namespace: true },
resourceType: "namespace",
},
);
assert.deepEqual(
document.operations.find((operation) => operation.operationId === "getSourceWorkflow"),
{
action: "source_workflows.read",
allowedCallerKinds: ["interactive", "service", "agent", "workflow"],
method: "GET",
operationId: "getSourceWorkflow",
parentResourceBinding: { pathParameter: "id" },
path: "/knowledge-spaces/{id}/source-workflows/{runId}",
resourceBinding: { pathParameter: "runId" },
resourceType: "job",
},
);
assert.deepEqual(
document.operations.find((operation) => operation.operationId === "cancelBackgroundTask"),
{

View File

@ -16,7 +16,7 @@ function argumentValue(name) {
process.env.NODE_ENV = "test";
const [
{ createNodePlatformAdapter },
{ createKnowledgeGateway },
{ createKnowledgeGateway, registerSourceProductHandlers },
{ createInMemoryCapabilityGrantProvenanceRepository },
] = await Promise.all([
import("../packages/adapters/src/node.ts"),
@ -53,6 +53,20 @@ const app = createKnowledgeGateway({
putSmallFile: unavailableInContractExport,
},
});
const unavailableService = new Proxy(
{},
{
get: () => unavailableInContractExport,
},
);
registerSourceProductHandlers({
app,
authorization: unavailableService,
connections: unavailableService,
providers: unavailableService,
repository: unavailableService,
workflows: unavailableService,
});
const response = await app.request("/openapi.json");
if (!response.ok) {
throw new Error(`OpenAPI export failed with HTTP ${response.status}`);

View File

@ -54,6 +54,23 @@ test("OpenAPI export is hermetic when invoked from a production environment", ()
document.paths["/upload-sessions/{id}/abort"].post.operationId,
"abortUploadSession",
);
assert.equal(
document.paths["/knowledge-spaces/{id}/sources/{sourceId}/sync"].post.operationId,
"createSourceSyncWorkflow",
);
assert.equal(document.paths["/source-providers"].get.operationId, "listSourceProviders");
assert.equal(
document.paths["/knowledge-spaces/{id}/source-connections"].post.operationId,
"createSourceConnection",
);
assert.equal(
document.paths["/knowledge-spaces/{id}/sources/{sourceId}/crawl-preview"].post.operationId,
"createSourceCrawlPreviewWorkflow",
);
assert.equal(
document.paths["/knowledge-spaces/{id}/source-workflows/{runId}/selection"].post.operationId,
"selectCrawlPreviewPages",
);
for (const legacyPath of [
"/knowledge-spaces/{id}/access-policy",
"/knowledge-spaces/{id}/members",

View File

@ -1,7 +1 @@
import { consoleRouterContract as generatedConsoleRouterContract } from './generated/api/console/router.gen'
import { contract as knowledgeFsContract } from './generated/knowledge-fs/orpc.gen'
export const consoleRouterContract = {
...generatedConsoleRouterContract,
knowledgeFs: knowledgeFsContract,
}
export { consoleRouterContract } from './generated/api/console/router.gen'

View File

@ -70,14 +70,52 @@ export type KnowledgeFsAppBindingResponse = {
status: KnowledgeFsAppSpaceJoinStatus
}
export type KnowledgeFsBackgroundTaskListResponse = {
data: Array<KnowledgeFsBackgroundTaskResponse>
next_cursor?: string | null
}
export type KnowledgeFsBackgroundTaskResponse = {
can_cancel: boolean
can_retry: boolean
completed_at?: string | null
created_at: string
document_id?: string | null
document_revision?: number | null
error_code?: string | null
error_message?: string | null
id: string
knowledge_space_id: string
operation:
| 'document_delete'
| 'document_processing'
| 'document_reindex'
| 'document_upload'
| 'source_bulk'
| 'source_crawl_import'
| 'source_crawl_preview'
| 'source_online_document_import'
| 'source_online_drive_import'
| 'source_sync'
progress_completed: number
progress_failed: number
progress_percent: number
progress_total: number
source_id?: string | null
state: 'canceled' | 'completed' | 'failed' | 'queued' | 'running'
task_kind: 'document' | 'document_bulk' | 'source'
updated_at: string
}
export type KnowledgeFsBulkJobResponse = {
canceled_items: number
completed_items: number
created_at: string
failed_item_ids: Array<string>
failed_items: number
id: string
knowledge_space_id: string
status: 'completed' | 'failed' | 'running'
status: 'canceled' | 'completed' | 'failed' | 'running'
total_items: number
type: 'document_delete' | 'document_reindex' | 'document_upload'
updated_at: string
@ -268,6 +306,11 @@ export type KnowledgeFsDocumentCompilationJobResponse = {
version: number
}
export type KnowledgeFsLogicalDocumentListResponse = {
data: Array<KnowledgeFsLogicalDocumentResponse>
next_cursor?: string | null
}
export type KnowledgeFsMembersReplacePayload = {
members: Array<KnowledgeFsMemberBindingPayload>
}
@ -276,6 +319,48 @@ export type KnowledgeFsPermissionListResponse = {
data: Array<KnowledgeFsPermissionResponse>
}
export type KnowledgeFsOverviewHealthResponse = {
components: KnowledgeFsOverviewHealthComponentsResponse
generated_at: string
knowledge_space_id: string
state: 'degraded' | 'healthy' | 'unavailable' | 'unknown'
}
export type KnowledgeFsOverviewInventoryResponse = {
generated_at: string
graph_entities: KnowledgeFsOverviewInventoryDeltaResponse
graph_relations: KnowledgeFsOverviewInventoryDeltaResponse
index_coverage: KnowledgeFsOverviewIndexCoverageResponse
knowledge_space_id: string
source_categories: KnowledgeFsOverviewSourceCategoriesResponse
}
export type KnowledgeFsOverviewQueryOutcomesResponse = {
buckets: Array<KnowledgeFsOverviewQueryOutcomeBucketResponse>
current: KnowledgeFsOverviewQueryOutcomeCountsResponse
generated_at: string
knowledge_space_id: string
previous: KnowledgeFsOverviewQueryOutcomeCountsResponse
previous_since: string
since: string
window: '24h' | '30d' | '7d'
}
export type KnowledgeFsOverviewStatsResponse = {
answer_rate: KnowledgeFsOverviewRateComparisonResponse
documents: number
fresh_source_count: number
freshness_seconds?: number | null
generated_at: string
knowledge_space_id: string
latest_source_sync_at?: string | null
linked_apps: number
queries: KnowledgeFsOverviewCountComparisonResponse
source_count: number
stale_source_count: number
window: '24h' | '30d' | '7d'
}
export type KnowledgeFsQueryCreatePayload = {
activeDocumentIds?: Array<string>
activeEntityIds?: Array<string>
@ -391,6 +476,83 @@ export type KnowledgeFsSettingsPayload = {
retrieval?: KnowledgeFsProductRetrievalProfile | null
}
export type KnowledgeFsSourceConnectionListResponse = {
data: Array<KnowledgeFsSourceConnectionResponse>
next_cursor?: string | null
}
export type KnowledgeFsSourceConnectionCreatePayload = {
authKind: 'api-key' | 'endpoint'
configuration?: {
[key: string]: boolean | number | string
}
credentials: {
[key: string]: unknown
}
name: string
providerId: string
}
export type KnowledgeFsSourceConnectionResponse = {
auth_kind: 'api-key' | 'endpoint' | 'oauth2'
configuration: {
[key: string]: boolean | number | string
}
created_at: string
error_code?: string | null
expires_at?: string | null
id: string
knowledge_space_id: string
name: string
provider_id: string
scopes: Array<string>
status: 'active' | 'error' | 'expired' | 'provisioning' | 'revoked'
updated_at: string
version: number
}
export type KnowledgeFsSourceConnectionRefreshPayload = {
expectedVersion: number
}
export type KnowledgeFsSourceProviderListResponse = {
data: Array<KnowledgeFsSourceProviderResponse>
}
export type KnowledgeFsSourceWorkflowResponse = {
canceled_at?: string | null
checkpoint: string
completed_at?: string | null
created_at: string
cursor?: string | null
execution_attempts: number
id: string
kind: string
knowledge_space_id: string
last_error_code?: string | null
max_execution_attempts: number
progress_completed: number
progress_failed: number
progress_skipped: number
progress_total?: number | null
source_id?: string | null
state: string
updated_at: string
}
export type KnowledgeFsSourceWorkflowCancelPayload = {
reason?: string | null
}
export type KnowledgeFsCrawlPreviewPageListResponse = {
data: Array<KnowledgeFsCrawlPreviewPageResponse>
next_cursor?: string | null
}
export type KnowledgeFsCrawlPreviewSelectionPayload = {
pageIds: Array<string>
}
export type KnowledgeFsSourceListResponse = {
data: Array<KnowledgeFsSourceResponse>
next_cursor?: string | null
@ -442,17 +604,6 @@ export type KnowledgeFsSourceUpdatePayload = {
status?: 'active' | 'disabled' | 'error' | 'syncing' | null
}
export type KnowledgeFsSourceCrawlResponse = {
completed?: number | null
failed?: number | null
imported?: number | null
pages: Array<KnowledgeFsCrawledPageResponse>
replaced?: number | null
skipped?: number | null
status?: string | null
total?: number | null
}
export type KnowledgeFsSourceFilesResponse = {
buckets: Array<KnowledgeFsSourceFileBucketResponse>
}
@ -476,6 +627,28 @@ export type KnowledgeFsSourcePagesResponse = {
workspaces: Array<KnowledgeFsSourceWorkspacePagesResponse>
}
export type KnowledgeFsSourceSyncPolicyResponse = {
created_at: string
custom_interval_seconds?: number | null
enabled: boolean
expected_source_version: number
id: string
knowledge_space_id: string
mode: 'custom' | 'interval' | 'manual' | 'provider'
next_run_at?: string | null
revision: number
source_id: string
updated_at: string
}
export type KnowledgeFsSourceSyncPolicyPayload = {
customIntervalSeconds?: number | null
enabled: boolean
expectedRevision: number
expectedSourceVersion: number
mode: 'custom' | 'interval' | 'manual' | 'provider'
}
export type KnowledgeFsSourceCredentialTestResponse = {
code?: string | null
error?: string | null
@ -552,6 +725,7 @@ export type KnowledgeFsjwkResponse = {
export type KnowledgeFsSpaceListItemResponse = {
control_space_id: string
created_at: string
knowledge_space_id: string | null
owner_account_id: string
permission_keys: Array<KnowledgeFsProductPermission>
@ -559,6 +733,7 @@ export type KnowledgeFsSpaceListItemResponse = {
state: KnowledgeFsControlSpaceState
technical_status: 'available' | 'not_ready' | 'unavailable'
technical_summary?: KnowledgeFsTechnicalSummary | null
updated_at: string
visibility: KnowledgeFsControlSpaceVisibility
}
@ -727,6 +902,62 @@ export type KnowledgeFsPermissionResponse = {
status: string
}
export type KnowledgeFsOverviewHealthComponentsResponse = {
index: KnowledgeFsOverviewHealthComponentResponse
ingestion: KnowledgeFsOverviewHealthComponentResponse
profile_publication: KnowledgeFsOverviewHealthComponentResponse
query_availability: KnowledgeFsOverviewHealthComponentResponse
source_freshness: KnowledgeFsOverviewHealthComponentResponse
worker_readiness: KnowledgeFsOverviewHealthComponentResponse
}
export type KnowledgeFsOverviewInventoryDeltaResponse = {
added_last_7d: number
total: number
}
export type KnowledgeFsOverviewIndexCoverageResponse = {
indexed: number
percentage: number
total: number
}
export type KnowledgeFsOverviewSourceCategoriesResponse = {
crawl: number
online_documents: number
online_drives: number
uploads: number
}
export type KnowledgeFsOverviewQueryOutcomeBucketResponse = {
answered: number
end_at: string
low_confidence: number
no_evidence: number
query_count: number
start_at: string
}
export type KnowledgeFsOverviewQueryOutcomeCountsResponse = {
answer_rate: number
answered: number
low_confidence: number
no_evidence: number
query_count: number
}
export type KnowledgeFsOverviewRateComparisonResponse = {
change_percentage_points: number
previous_value: number
value: number
}
export type KnowledgeFsOverviewCountComparisonResponse = {
change_rate: number | null
previous_value: number
value: number
}
export type KnowledgeFsAdmittedQueryRequest = {
activeDocumentIds?: Array<string>
activeEntityIds?: Array<string>
@ -802,9 +1033,20 @@ export type KnowledgeFsProductRetrievalProfile = {
topK: number
}
export type KnowledgeFsCrawledPageResponse = {
content: string
export type KnowledgeFsSourceProviderResponse = {
auth_kinds: Array<'api-key' | 'endpoint' | 'oauth2'>
available: boolean
capabilities: Array<'online-document' | 'online-drive' | 'website-crawl'>
configuration: Array<KnowledgeFsSourceProviderFieldResponse>
display_name: string
id: string
unavailable_reason?: string | null
}
export type KnowledgeFsCrawlPreviewPageResponse = {
description?: string | null
etag?: string | null
page_id: string
source_url: string
title?: string | null
}
@ -931,6 +1173,11 @@ export type KnowledgeFsDurableDeletionProgressResponse = {
export type KnowledgeFsControlSpacePermissionRole = 'editor' | 'owner' | 'viewer'
export type KnowledgeFsOverviewHealthComponentResponse = {
codes: Array<string>
state: 'degraded' | 'healthy' | 'unavailable' | 'unknown'
}
export type KnowledgeFsProductRerankProfile = {
enabled: boolean
model?: KnowledgeFsProfileModelSelection | null
@ -942,6 +1189,15 @@ export type KnowledgeFsProductScoreThreshold = {
value?: number | null
}
export type KnowledgeFsSourceProviderFieldResponse = {
description?: string | null
format?: 'password' | 'uri' | null
name: string
required: boolean
secret: boolean
type: 'boolean' | 'integer' | 'string'
}
export type KnowledgeFsSourceFileResponse = {
id: string
name: string
@ -1122,6 +1378,62 @@ export type DeleteKnowledgeFsSpacesByControlSpaceIdAppBindingsByCallerKindByAppI
export type DeleteKnowledgeFsSpacesByControlSpaceIdAppBindingsByCallerKindByAppIdResponse =
DeleteKnowledgeFsSpacesByControlSpaceIdAppBindingsByCallerKindByAppIdResponses[keyof DeleteKnowledgeFsSpacesByControlSpaceIdAppBindingsByCallerKindByAppIdResponses]
export type GetKnowledgeFsSpacesByControlSpaceIdBackgroundTasksData = {
body?: never
path: {
control_space_id: string
}
query?: {
cursor?: string
limit?: number
}
url: '/knowledge-fs/spaces/{control_space_id}/background-tasks'
}
export type GetKnowledgeFsSpacesByControlSpaceIdBackgroundTasksResponses = {
200: KnowledgeFsBackgroundTaskListResponse
}
export type GetKnowledgeFsSpacesByControlSpaceIdBackgroundTasksResponse =
GetKnowledgeFsSpacesByControlSpaceIdBackgroundTasksResponses[keyof GetKnowledgeFsSpacesByControlSpaceIdBackgroundTasksResponses]
export type PostKnowledgeFsSpacesByControlSpaceIdBackgroundTasksByTaskKindByTaskIdCancelData = {
body?: never
path: {
control_space_id: string
task_id: string
task_kind: string
}
query?: never
url: '/knowledge-fs/spaces/{control_space_id}/background-tasks/{task_kind}/{task_id}/cancel'
}
export type PostKnowledgeFsSpacesByControlSpaceIdBackgroundTasksByTaskKindByTaskIdCancelResponses =
{
200: KnowledgeFsBackgroundTaskResponse
}
export type PostKnowledgeFsSpacesByControlSpaceIdBackgroundTasksByTaskKindByTaskIdCancelResponse =
PostKnowledgeFsSpacesByControlSpaceIdBackgroundTasksByTaskKindByTaskIdCancelResponses[keyof PostKnowledgeFsSpacesByControlSpaceIdBackgroundTasksByTaskKindByTaskIdCancelResponses]
export type PostKnowledgeFsSpacesByControlSpaceIdBackgroundTasksByTaskKindByTaskIdRetryData = {
body?: never
path: {
control_space_id: string
task_id: string
task_kind: string
}
query?: never
url: '/knowledge-fs/spaces/{control_space_id}/background-tasks/{task_kind}/{task_id}/retry'
}
export type PostKnowledgeFsSpacesByControlSpaceIdBackgroundTasksByTaskKindByTaskIdRetryResponses = {
200: KnowledgeFsBackgroundTaskResponse
}
export type PostKnowledgeFsSpacesByControlSpaceIdBackgroundTasksByTaskKindByTaskIdRetryResponse =
PostKnowledgeFsSpacesByControlSpaceIdBackgroundTasksByTaskKindByTaskIdRetryResponses[keyof PostKnowledgeFsSpacesByControlSpaceIdBackgroundTasksByTaskKindByTaskIdRetryResponses]
export type GetKnowledgeFsSpacesByControlSpaceIdBulkJobsByJobIdData = {
body?: never
path: {
@ -1224,6 +1536,9 @@ export type PostKnowledgeFsSpacesByControlSpaceIdDocumentsResponse =
export type DeleteKnowledgeFsSpacesByControlSpaceIdDocumentsBulkData = {
body: KnowledgeFsBulkDocumentDeletePayload
headers: {
'Idempotency-Key': string
}
path: {
control_space_id: string
}
@ -1256,6 +1571,9 @@ export type PostKnowledgeFsSpacesByControlSpaceIdDocumentsReindexResponse =
export type DeleteKnowledgeFsSpacesByControlSpaceIdDocumentsByDocumentIdData = {
body: KnowledgeFsDocumentDeletePayload
headers: {
'Idempotency-Key': string
}
path: {
control_space_id: string
document_id: string
@ -1468,6 +1786,41 @@ export type PostKnowledgeFsSpacesByControlSpaceIdJobsByJobIdRetryResponses = {
export type PostKnowledgeFsSpacesByControlSpaceIdJobsByJobIdRetryResponse =
PostKnowledgeFsSpacesByControlSpaceIdJobsByJobIdRetryResponses[keyof PostKnowledgeFsSpacesByControlSpaceIdJobsByJobIdRetryResponses]
export type GetKnowledgeFsSpacesByControlSpaceIdLogicalDocumentsData = {
body?: never
path: {
control_space_id: string
}
query?: {
cursor?: string
}
url: '/knowledge-fs/spaces/{control_space_id}/logical-documents'
}
export type GetKnowledgeFsSpacesByControlSpaceIdLogicalDocumentsResponses = {
200: KnowledgeFsLogicalDocumentListResponse
}
export type GetKnowledgeFsSpacesByControlSpaceIdLogicalDocumentsResponse =
GetKnowledgeFsSpacesByControlSpaceIdLogicalDocumentsResponses[keyof GetKnowledgeFsSpacesByControlSpaceIdLogicalDocumentsResponses]
export type GetKnowledgeFsSpacesByControlSpaceIdLogicalDocumentsByDocumentIdData = {
body?: never
path: {
control_space_id: string
document_id: string
}
query?: never
url: '/knowledge-fs/spaces/{control_space_id}/logical-documents/{document_id}'
}
export type GetKnowledgeFsSpacesByControlSpaceIdLogicalDocumentsByDocumentIdResponses = {
200: KnowledgeFsLogicalDocumentResponse
}
export type GetKnowledgeFsSpacesByControlSpaceIdLogicalDocumentsByDocumentIdResponse =
GetKnowledgeFsSpacesByControlSpaceIdLogicalDocumentsByDocumentIdResponses[keyof GetKnowledgeFsSpacesByControlSpaceIdLogicalDocumentsByDocumentIdResponses]
export type PutKnowledgeFsSpacesByControlSpaceIdMembersData = {
body: KnowledgeFsMembersReplacePayload
path: {
@ -1484,6 +1837,74 @@ export type PutKnowledgeFsSpacesByControlSpaceIdMembersResponses = {
export type PutKnowledgeFsSpacesByControlSpaceIdMembersResponse =
PutKnowledgeFsSpacesByControlSpaceIdMembersResponses[keyof PutKnowledgeFsSpacesByControlSpaceIdMembersResponses]
export type GetKnowledgeFsSpacesByControlSpaceIdOverviewHealthData = {
body?: never
path: {
control_space_id: string
}
query?: never
url: '/knowledge-fs/spaces/{control_space_id}/overview/health'
}
export type GetKnowledgeFsSpacesByControlSpaceIdOverviewHealthResponses = {
200: KnowledgeFsOverviewHealthResponse
}
export type GetKnowledgeFsSpacesByControlSpaceIdOverviewHealthResponse =
GetKnowledgeFsSpacesByControlSpaceIdOverviewHealthResponses[keyof GetKnowledgeFsSpacesByControlSpaceIdOverviewHealthResponses]
export type GetKnowledgeFsSpacesByControlSpaceIdOverviewInventoryData = {
body?: never
path: {
control_space_id: string
}
query?: never
url: '/knowledge-fs/spaces/{control_space_id}/overview/inventory'
}
export type GetKnowledgeFsSpacesByControlSpaceIdOverviewInventoryResponses = {
200: KnowledgeFsOverviewInventoryResponse
}
export type GetKnowledgeFsSpacesByControlSpaceIdOverviewInventoryResponse =
GetKnowledgeFsSpacesByControlSpaceIdOverviewInventoryResponses[keyof GetKnowledgeFsSpacesByControlSpaceIdOverviewInventoryResponses]
export type GetKnowledgeFsSpacesByControlSpaceIdOverviewQueryOutcomesData = {
body?: never
path: {
control_space_id: string
}
query?: {
window?: '24h' | '30d' | '7d'
}
url: '/knowledge-fs/spaces/{control_space_id}/overview/query-outcomes'
}
export type GetKnowledgeFsSpacesByControlSpaceIdOverviewQueryOutcomesResponses = {
200: KnowledgeFsOverviewQueryOutcomesResponse
}
export type GetKnowledgeFsSpacesByControlSpaceIdOverviewQueryOutcomesResponse =
GetKnowledgeFsSpacesByControlSpaceIdOverviewQueryOutcomesResponses[keyof GetKnowledgeFsSpacesByControlSpaceIdOverviewQueryOutcomesResponses]
export type GetKnowledgeFsSpacesByControlSpaceIdOverviewStatsData = {
body?: never
path: {
control_space_id: string
}
query?: {
window?: '24h' | '30d' | '7d'
}
url: '/knowledge-fs/spaces/{control_space_id}/overview/stats'
}
export type GetKnowledgeFsSpacesByControlSpaceIdOverviewStatsResponses = {
200: KnowledgeFsOverviewStatsResponse
}
export type GetKnowledgeFsSpacesByControlSpaceIdOverviewStatsResponse =
GetKnowledgeFsSpacesByControlSpaceIdOverviewStatsResponses[keyof GetKnowledgeFsSpacesByControlSpaceIdOverviewStatsResponses]
export type GetKnowledgeFsSpacesByControlSpaceIdPermissionsData = {
body?: never
path: {
@ -1684,6 +2105,165 @@ export type PatchKnowledgeFsSpacesByControlSpaceIdSettingsResponses = {
export type PatchKnowledgeFsSpacesByControlSpaceIdSettingsResponse =
PatchKnowledgeFsSpacesByControlSpaceIdSettingsResponses[keyof PatchKnowledgeFsSpacesByControlSpaceIdSettingsResponses]
export type GetKnowledgeFsSpacesByControlSpaceIdSourceConnectionsData = {
body?: never
path: {
control_space_id: string
}
query?: {
cursor?: string
limit?: number
}
url: '/knowledge-fs/spaces/{control_space_id}/source-connections'
}
export type GetKnowledgeFsSpacesByControlSpaceIdSourceConnectionsResponses = {
200: KnowledgeFsSourceConnectionListResponse
}
export type GetKnowledgeFsSpacesByControlSpaceIdSourceConnectionsResponse =
GetKnowledgeFsSpacesByControlSpaceIdSourceConnectionsResponses[keyof GetKnowledgeFsSpacesByControlSpaceIdSourceConnectionsResponses]
export type PostKnowledgeFsSpacesByControlSpaceIdSourceConnectionsData = {
body: KnowledgeFsSourceConnectionCreatePayload
path: {
control_space_id: string
}
query?: never
url: '/knowledge-fs/spaces/{control_space_id}/source-connections'
}
export type PostKnowledgeFsSpacesByControlSpaceIdSourceConnectionsResponses = {
201: KnowledgeFsSourceConnectionResponse
}
export type PostKnowledgeFsSpacesByControlSpaceIdSourceConnectionsResponse =
PostKnowledgeFsSpacesByControlSpaceIdSourceConnectionsResponses[keyof PostKnowledgeFsSpacesByControlSpaceIdSourceConnectionsResponses]
export type PostKnowledgeFsSpacesByControlSpaceIdSourceConnectionsByConnectionIdRefreshData = {
body: KnowledgeFsSourceConnectionRefreshPayload
path: {
connection_id: string
control_space_id: string
}
query?: never
url: '/knowledge-fs/spaces/{control_space_id}/source-connections/{connection_id}/refresh'
}
export type PostKnowledgeFsSpacesByControlSpaceIdSourceConnectionsByConnectionIdRefreshResponses = {
200: KnowledgeFsSourceConnectionResponse
}
export type PostKnowledgeFsSpacesByControlSpaceIdSourceConnectionsByConnectionIdRefreshResponse =
PostKnowledgeFsSpacesByControlSpaceIdSourceConnectionsByConnectionIdRefreshResponses[keyof PostKnowledgeFsSpacesByControlSpaceIdSourceConnectionsByConnectionIdRefreshResponses]
export type GetKnowledgeFsSpacesByControlSpaceIdSourceProvidersData = {
body?: never
path: {
control_space_id: string
}
query?: never
url: '/knowledge-fs/spaces/{control_space_id}/source-providers'
}
export type GetKnowledgeFsSpacesByControlSpaceIdSourceProvidersResponses = {
200: KnowledgeFsSourceProviderListResponse
}
export type GetKnowledgeFsSpacesByControlSpaceIdSourceProvidersResponse =
GetKnowledgeFsSpacesByControlSpaceIdSourceProvidersResponses[keyof GetKnowledgeFsSpacesByControlSpaceIdSourceProvidersResponses]
export type GetKnowledgeFsSpacesByControlSpaceIdSourceWorkflowsByRunIdData = {
body?: never
path: {
control_space_id: string
run_id: string
}
query?: never
url: '/knowledge-fs/spaces/{control_space_id}/source-workflows/{run_id}'
}
export type GetKnowledgeFsSpacesByControlSpaceIdSourceWorkflowsByRunIdResponses = {
200: KnowledgeFsSourceWorkflowResponse
}
export type GetKnowledgeFsSpacesByControlSpaceIdSourceWorkflowsByRunIdResponse =
GetKnowledgeFsSpacesByControlSpaceIdSourceWorkflowsByRunIdResponses[keyof GetKnowledgeFsSpacesByControlSpaceIdSourceWorkflowsByRunIdResponses]
export type PostKnowledgeFsSpacesByControlSpaceIdSourceWorkflowsByRunIdCancelData = {
body: KnowledgeFsSourceWorkflowCancelPayload
path: {
control_space_id: string
run_id: string
}
query?: never
url: '/knowledge-fs/spaces/{control_space_id}/source-workflows/{run_id}/cancel'
}
export type PostKnowledgeFsSpacesByControlSpaceIdSourceWorkflowsByRunIdCancelResponses = {
200: KnowledgeFsSourceWorkflowResponse
}
export type PostKnowledgeFsSpacesByControlSpaceIdSourceWorkflowsByRunIdCancelResponse =
PostKnowledgeFsSpacesByControlSpaceIdSourceWorkflowsByRunIdCancelResponses[keyof PostKnowledgeFsSpacesByControlSpaceIdSourceWorkflowsByRunIdCancelResponses]
export type GetKnowledgeFsSpacesByControlSpaceIdSourceWorkflowsByRunIdPagesData = {
body?: never
path: {
control_space_id: string
run_id: string
}
query?: {
cursor?: string
limit?: number
}
url: '/knowledge-fs/spaces/{control_space_id}/source-workflows/{run_id}/pages'
}
export type GetKnowledgeFsSpacesByControlSpaceIdSourceWorkflowsByRunIdPagesResponses = {
200: KnowledgeFsCrawlPreviewPageListResponse
}
export type GetKnowledgeFsSpacesByControlSpaceIdSourceWorkflowsByRunIdPagesResponse =
GetKnowledgeFsSpacesByControlSpaceIdSourceWorkflowsByRunIdPagesResponses[keyof GetKnowledgeFsSpacesByControlSpaceIdSourceWorkflowsByRunIdPagesResponses]
export type PostKnowledgeFsSpacesByControlSpaceIdSourceWorkflowsByRunIdRetryData = {
body?: never
path: {
control_space_id: string
run_id: string
}
query?: never
url: '/knowledge-fs/spaces/{control_space_id}/source-workflows/{run_id}/retry'
}
export type PostKnowledgeFsSpacesByControlSpaceIdSourceWorkflowsByRunIdRetryResponses = {
200: KnowledgeFsSourceWorkflowResponse
}
export type PostKnowledgeFsSpacesByControlSpaceIdSourceWorkflowsByRunIdRetryResponse =
PostKnowledgeFsSpacesByControlSpaceIdSourceWorkflowsByRunIdRetryResponses[keyof PostKnowledgeFsSpacesByControlSpaceIdSourceWorkflowsByRunIdRetryResponses]
export type PostKnowledgeFsSpacesByControlSpaceIdSourceWorkflowsByRunIdSelectionData = {
body: KnowledgeFsCrawlPreviewSelectionPayload
headers: {
'Idempotency-Key': string
}
path: {
control_space_id: string
run_id: string
}
query?: never
url: '/knowledge-fs/spaces/{control_space_id}/source-workflows/{run_id}/selection'
}
export type PostKnowledgeFsSpacesByControlSpaceIdSourceWorkflowsByRunIdSelectionResponses = {
202: KnowledgeFsSourceWorkflowResponse
}
export type PostKnowledgeFsSpacesByControlSpaceIdSourceWorkflowsByRunIdSelectionResponse =
PostKnowledgeFsSpacesByControlSpaceIdSourceWorkflowsByRunIdSelectionResponses[keyof PostKnowledgeFsSpacesByControlSpaceIdSourceWorkflowsByRunIdSelectionResponses]
export type GetKnowledgeFsSpacesByControlSpaceIdSourcesData = {
body?: never
path: {
@ -1720,6 +2300,9 @@ export type PostKnowledgeFsSpacesByControlSpaceIdSourcesResponse =
export type DeleteKnowledgeFsSpacesByControlSpaceIdSourcesBySourceIdData = {
body: KnowledgeFsSourceDeletePayload
headers: {
'Idempotency-Key': string
}
path: {
control_space_id: string
source_id: string
@ -1771,22 +2354,25 @@ export type PatchKnowledgeFsSpacesByControlSpaceIdSourcesBySourceIdResponses = {
export type PatchKnowledgeFsSpacesByControlSpaceIdSourcesBySourceIdResponse =
PatchKnowledgeFsSpacesByControlSpaceIdSourcesBySourceIdResponses[keyof PatchKnowledgeFsSpacesByControlSpaceIdSourcesBySourceIdResponses]
export type PostKnowledgeFsSpacesByControlSpaceIdSourcesBySourceIdCrawlData = {
export type PostKnowledgeFsSpacesByControlSpaceIdSourcesBySourceIdCrawlPreviewData = {
body?: never
headers: {
'Idempotency-Key': string
}
path: {
control_space_id: string
source_id: string
}
query?: never
url: '/knowledge-fs/spaces/{control_space_id}/sources/{source_id}/crawl'
url: '/knowledge-fs/spaces/{control_space_id}/sources/{source_id}/crawl-preview'
}
export type PostKnowledgeFsSpacesByControlSpaceIdSourcesBySourceIdCrawlResponses = {
200: KnowledgeFsSourceCrawlResponse
export type PostKnowledgeFsSpacesByControlSpaceIdSourcesBySourceIdCrawlPreviewResponses = {
202: KnowledgeFsSourceWorkflowResponse
}
export type PostKnowledgeFsSpacesByControlSpaceIdSourcesBySourceIdCrawlResponse =
PostKnowledgeFsSpacesByControlSpaceIdSourcesBySourceIdCrawlResponses[keyof PostKnowledgeFsSpacesByControlSpaceIdSourcesBySourceIdCrawlResponses]
export type PostKnowledgeFsSpacesByControlSpaceIdSourcesBySourceIdCrawlPreviewResponse =
PostKnowledgeFsSpacesByControlSpaceIdSourcesBySourceIdCrawlPreviewResponses[keyof PostKnowledgeFsSpacesByControlSpaceIdSourcesBySourceIdCrawlPreviewResponses]
export type GetKnowledgeFsSpacesByControlSpaceIdSourcesBySourceIdFilesData = {
body?: never
@ -1864,6 +2450,60 @@ export type GetKnowledgeFsSpacesByControlSpaceIdSourcesBySourceIdPagesResponses
export type GetKnowledgeFsSpacesByControlSpaceIdSourcesBySourceIdPagesResponse =
GetKnowledgeFsSpacesByControlSpaceIdSourcesBySourceIdPagesResponses[keyof GetKnowledgeFsSpacesByControlSpaceIdSourcesBySourceIdPagesResponses]
export type PostKnowledgeFsSpacesByControlSpaceIdSourcesBySourceIdSyncData = {
body?: never
headers: {
'Idempotency-Key': string
}
path: {
control_space_id: string
source_id: string
}
query?: never
url: '/knowledge-fs/spaces/{control_space_id}/sources/{source_id}/sync'
}
export type PostKnowledgeFsSpacesByControlSpaceIdSourcesBySourceIdSyncResponses = {
202: KnowledgeFsSourceWorkflowResponse
}
export type PostKnowledgeFsSpacesByControlSpaceIdSourcesBySourceIdSyncResponse =
PostKnowledgeFsSpacesByControlSpaceIdSourcesBySourceIdSyncResponses[keyof PostKnowledgeFsSpacesByControlSpaceIdSourcesBySourceIdSyncResponses]
export type GetKnowledgeFsSpacesByControlSpaceIdSourcesBySourceIdSyncPolicyData = {
body?: never
path: {
control_space_id: string
source_id: string
}
query?: never
url: '/knowledge-fs/spaces/{control_space_id}/sources/{source_id}/sync-policy'
}
export type GetKnowledgeFsSpacesByControlSpaceIdSourcesBySourceIdSyncPolicyResponses = {
200: KnowledgeFsSourceSyncPolicyResponse
}
export type GetKnowledgeFsSpacesByControlSpaceIdSourcesBySourceIdSyncPolicyResponse =
GetKnowledgeFsSpacesByControlSpaceIdSourcesBySourceIdSyncPolicyResponses[keyof GetKnowledgeFsSpacesByControlSpaceIdSourcesBySourceIdSyncPolicyResponses]
export type PutKnowledgeFsSpacesByControlSpaceIdSourcesBySourceIdSyncPolicyData = {
body: KnowledgeFsSourceSyncPolicyPayload
path: {
control_space_id: string
source_id: string
}
query?: never
url: '/knowledge-fs/spaces/{control_space_id}/sources/{source_id}/sync-policy'
}
export type PutKnowledgeFsSpacesByControlSpaceIdSourcesBySourceIdSyncPolicyResponses = {
200: KnowledgeFsSourceSyncPolicyResponse
}
export type PutKnowledgeFsSpacesByControlSpaceIdSourcesBySourceIdSyncPolicyResponse =
PutKnowledgeFsSpacesByControlSpaceIdSourcesBySourceIdSyncPolicyResponses[keyof PutKnowledgeFsSpacesByControlSpaceIdSourcesBySourceIdSyncPolicyResponses]
export type PostKnowledgeFsSpacesByControlSpaceIdSourcesBySourceIdTestData = {
body?: never
path: {

View File

@ -2,17 +2,62 @@
import * as z from 'zod'
/**
* KnowledgeFSBackgroundTaskResponse
*/
export const zKnowledgeFsBackgroundTaskResponse = z.object({
can_cancel: z.boolean(),
can_retry: z.boolean(),
completed_at: z.iso.datetime().nullish(),
created_at: z.iso.datetime(),
document_id: z.string().nullish(),
document_revision: z.int().gte(1).nullish(),
error_code: z.string().nullish(),
error_message: z.string().nullish(),
id: z.string(),
knowledge_space_id: z.string(),
operation: z.enum([
'document_delete',
'document_processing',
'document_reindex',
'document_upload',
'source_bulk',
'source_crawl_import',
'source_crawl_preview',
'source_online_document_import',
'source_online_drive_import',
'source_sync',
]),
progress_completed: z.int().gte(0),
progress_failed: z.int().gte(0),
progress_percent: z.int().gte(0).lte(100),
progress_total: z.int().gte(0),
source_id: z.string().nullish(),
state: z.enum(['canceled', 'completed', 'failed', 'queued', 'running']),
task_kind: z.enum(['document', 'document_bulk', 'source']),
updated_at: z.iso.datetime(),
})
/**
* KnowledgeFSBackgroundTaskListResponse
*/
export const zKnowledgeFsBackgroundTaskListResponse = z.object({
data: z.array(zKnowledgeFsBackgroundTaskResponse),
next_cursor: z.string().nullish(),
})
/**
* KnowledgeFSBulkJobResponse
*/
export const zKnowledgeFsBulkJobResponse = z.object({
canceled_items: z.int().gte(0),
completed_items: z.int().gte(0),
created_at: z.iso.datetime(),
failed_item_ids: z.array(z.string()),
failed_items: z.int().gte(0),
id: z.string(),
knowledge_space_id: z.string(),
status: z.enum(['completed', 'failed', 'running']),
status: z.enum(['canceled', 'completed', 'failed', 'running']),
total_items: z.int().gte(0),
type: z.enum(['document_delete', 'document_reindex', 'document_upload']),
updated_at: z.iso.datetime(),
@ -216,6 +261,89 @@ export const zKnowledgeFsResearchTaskPlanPayload = z.object({
topK: z.int().gte(1).lte(50).nullish(),
})
/**
* KnowledgeFSSourceConnectionCreatePayload
*/
export const zKnowledgeFsSourceConnectionCreatePayload = z.object({
authKind: z.enum(['api-key', 'endpoint']),
configuration: z.record(z.string(), z.union([z.boolean(), z.int(), z.string()])).optional(),
credentials: z.record(z.string(), z.unknown()),
name: z.string().min(1).max(160),
providerId: z.string().min(1).max(128),
})
/**
* KnowledgeFSSourceConnectionResponse
*/
export const zKnowledgeFsSourceConnectionResponse = z.object({
auth_kind: z.enum(['api-key', 'endpoint', 'oauth2']),
configuration: z.record(z.string(), z.union([z.boolean(), z.int(), z.string()])),
created_at: z.iso.datetime(),
error_code: z.string().nullish(),
expires_at: z.iso.datetime().nullish(),
id: z.string(),
knowledge_space_id: z.string(),
name: z.string(),
provider_id: z.string(),
scopes: z.array(z.string()),
status: z.enum(['active', 'error', 'expired', 'provisioning', 'revoked']),
updated_at: z.iso.datetime(),
version: z.int().gte(1),
})
/**
* KnowledgeFSSourceConnectionListResponse
*/
export const zKnowledgeFsSourceConnectionListResponse = z.object({
data: z.array(zKnowledgeFsSourceConnectionResponse),
next_cursor: z.string().nullish(),
})
/**
* KnowledgeFSSourceConnectionRefreshPayload
*/
export const zKnowledgeFsSourceConnectionRefreshPayload = z.object({
expectedVersion: z.int().gte(1),
})
/**
* KnowledgeFSSourceWorkflowResponse
*/
export const zKnowledgeFsSourceWorkflowResponse = z.object({
canceled_at: z.iso.datetime().nullish(),
checkpoint: z.string(),
completed_at: z.iso.datetime().nullish(),
created_at: z.iso.datetime(),
cursor: z.string().nullish(),
execution_attempts: z.int().gte(0),
id: z.string(),
kind: z.string(),
knowledge_space_id: z.string(),
last_error_code: z.string().nullish(),
max_execution_attempts: z.int().gte(1),
progress_completed: z.int().gte(0),
progress_failed: z.int().gte(0),
progress_skipped: z.int().gte(0),
progress_total: z.int().gte(0).nullish(),
source_id: z.string().nullish(),
state: z.string(),
updated_at: z.iso.datetime(),
})
/**
* KnowledgeFSSourceWorkflowCancelPayload
*/
export const zKnowledgeFsSourceWorkflowCancelPayload = z.object({
reason: z.string().max(1000).nullish(),
})
/**
* KnowledgeFSCrawlPreviewSelectionPayload
*/
export const zKnowledgeFsCrawlPreviewSelectionPayload = z.object({
pageIds: z.array(z.string()).min(1).max(200),
})
/**
* KnowledgeFSSourceCreatePayload
*/
@ -274,6 +402,34 @@ export const zKnowledgeFsSourceUpdatePayload = z.object({
status: z.enum(['active', 'disabled', 'error', 'syncing']).nullish(),
})
/**
* KnowledgeFSSourceSyncPolicyResponse
*/
export const zKnowledgeFsSourceSyncPolicyResponse = z.object({
created_at: z.iso.datetime(),
custom_interval_seconds: z.int().nullish(),
enabled: z.boolean(),
expected_source_version: z.int().gte(1),
id: z.string(),
knowledge_space_id: z.string(),
mode: z.enum(['custom', 'interval', 'manual', 'provider']),
next_run_at: z.iso.datetime().nullish(),
revision: z.int().gte(1),
source_id: z.string(),
updated_at: z.iso.datetime(),
})
/**
* KnowledgeFSSourceSyncPolicyPayload
*/
export const zKnowledgeFsSourceSyncPolicyPayload = z.object({
customIntervalSeconds: z.int().gte(3600).lte(2592000).nullish(),
enabled: z.boolean(),
expectedRevision: z.int().gte(0),
expectedSourceVersion: z.int().gte(1),
mode: z.enum(['custom', 'interval', 'manual', 'provider']),
})
/**
* KnowledgeFSSourceCredentialTestResponse
*/
@ -447,6 +603,7 @@ export const zKnowledgeFsSpaceDetailResponse = z.object({
*/
export const zKnowledgeFsSpaceListItemResponse = z.object({
control_space_id: z.string(),
created_at: z.iso.datetime(),
knowledge_space_id: z.string().nullable(),
owner_account_id: z.string(),
permission_keys: z.array(zKnowledgeFsProductPermission),
@ -454,6 +611,7 @@ export const zKnowledgeFsSpaceListItemResponse = z.object({
state: zKnowledgeFsControlSpaceState,
technical_status: z.enum(['available', 'not_ready', 'unavailable']),
technical_summary: zKnowledgeFsTechnicalSummary.nullish(),
updated_at: z.iso.datetime(),
visibility: zKnowledgeFsControlSpaceVisibility,
})
@ -603,6 +761,14 @@ export const zKnowledgeFsDocumentRevisionListResponse = z.object({
next_cursor: z.string().nullish(),
})
/**
* KnowledgeFSLogicalDocumentListResponse
*/
export const zKnowledgeFsLogicalDocumentListResponse = z.object({
data: z.array(zKnowledgeFsLogicalDocumentResponse),
next_cursor: z.string().nullish(),
})
/**
* KnowledgeFSDocumentOutlineNodeResponse
*/
@ -642,6 +808,118 @@ export const zKnowledgeFsDocumentOutlineResponse = z.object({
version: z.int().gte(1),
})
/**
* KnowledgeFSOverviewInventoryDeltaResponse
*/
export const zKnowledgeFsOverviewInventoryDeltaResponse = z.object({
added_last_7d: z.int().gte(0),
total: z.int().gte(0),
})
/**
* KnowledgeFSOverviewIndexCoverageResponse
*/
export const zKnowledgeFsOverviewIndexCoverageResponse = z.object({
indexed: z.int().gte(0),
percentage: z.number().gte(0).lte(100),
total: z.int().gte(0),
})
/**
* KnowledgeFSOverviewSourceCategoriesResponse
*/
export const zKnowledgeFsOverviewSourceCategoriesResponse = z.object({
crawl: z.int().gte(0),
online_documents: z.int().gte(0),
online_drives: z.int().gte(0),
uploads: z.int().gte(0),
})
/**
* KnowledgeFSOverviewInventoryResponse
*/
export const zKnowledgeFsOverviewInventoryResponse = z.object({
generated_at: z.iso.datetime(),
graph_entities: zKnowledgeFsOverviewInventoryDeltaResponse,
graph_relations: zKnowledgeFsOverviewInventoryDeltaResponse,
index_coverage: zKnowledgeFsOverviewIndexCoverageResponse,
knowledge_space_id: z.string(),
source_categories: zKnowledgeFsOverviewSourceCategoriesResponse,
})
/**
* KnowledgeFSOverviewQueryOutcomeBucketResponse
*/
export const zKnowledgeFsOverviewQueryOutcomeBucketResponse = z.object({
answered: z.int().gte(0),
end_at: z.iso.datetime(),
low_confidence: z.int().gte(0),
no_evidence: z.int().gte(0),
query_count: z.int().gte(0),
start_at: z.iso.datetime(),
})
/**
* KnowledgeFSOverviewQueryOutcomeCountsResponse
*/
export const zKnowledgeFsOverviewQueryOutcomeCountsResponse = z.object({
answer_rate: z.number().gte(0).lte(1),
answered: z.int().gte(0),
low_confidence: z.int().gte(0),
no_evidence: z.int().gte(0),
query_count: z.int().gte(0),
})
/**
* KnowledgeFSOverviewQueryOutcomesResponse
*/
export const zKnowledgeFsOverviewQueryOutcomesResponse = z.object({
buckets: z.array(zKnowledgeFsOverviewQueryOutcomeBucketResponse),
current: zKnowledgeFsOverviewQueryOutcomeCountsResponse,
generated_at: z.iso.datetime(),
knowledge_space_id: z.string(),
previous: zKnowledgeFsOverviewQueryOutcomeCountsResponse,
previous_since: z.iso.datetime(),
since: z.iso.datetime(),
window: z.enum(['24h', '30d', '7d']),
})
/**
* KnowledgeFSOverviewRateComparisonResponse
*/
export const zKnowledgeFsOverviewRateComparisonResponse = z.object({
change_percentage_points: z.number(),
previous_value: z.number().gte(0).lte(1),
value: z.number().gte(0).lte(1),
})
/**
* KnowledgeFSOverviewCountComparisonResponse
*/
export const zKnowledgeFsOverviewCountComparisonResponse = z.object({
change_rate: z.number().nullable(),
previous_value: z.int().gte(0),
value: z.int().gte(0),
})
/**
* KnowledgeFSOverviewStatsResponse
*/
export const zKnowledgeFsOverviewStatsResponse = z.object({
answer_rate: zKnowledgeFsOverviewRateComparisonResponse,
documents: z.int().gte(0),
fresh_source_count: z.int().gte(0),
freshness_seconds: z.int().gte(0).nullish(),
generated_at: z.iso.datetime(),
knowledge_space_id: z.string(),
latest_source_sync_at: z.iso.datetime().nullish(),
linked_apps: z.int().gte(0),
queries: zKnowledgeFsOverviewCountComparisonResponse,
source_count: z.int().gte(0),
stale_source_count: z.int().gte(0),
window: z.enum(['24h', '30d', '7d']),
})
/**
* KnowledgeFSAdmittedQueryRequest
*/
@ -802,27 +1080,22 @@ export const zKnowledgeFsProfileModelSelection = z.object({
})
/**
* KnowledgeFSCrawledPageResponse
* KnowledgeFSCrawlPreviewPageResponse
*/
export const zKnowledgeFsCrawledPageResponse = z.object({
content: z.string(),
export const zKnowledgeFsCrawlPreviewPageResponse = z.object({
description: z.string().nullish(),
etag: z.string().nullish(),
page_id: z.string(),
source_url: z.string(),
title: z.string().nullish(),
})
/**
* KnowledgeFSSourceCrawlResponse
* KnowledgeFSCrawlPreviewPageListResponse
*/
export const zKnowledgeFsSourceCrawlResponse = z.object({
completed: z.int().gte(0).nullish(),
failed: z.int().gte(0).nullish(),
imported: z.int().gte(0).nullish(),
pages: z.array(zKnowledgeFsCrawledPageResponse),
replaced: z.int().gte(0).nullish(),
skipped: z.int().gte(0).nullish(),
status: z.string().nullish(),
total: z.int().gte(0).nullish(),
export const zKnowledgeFsCrawlPreviewPageListResponse = z.object({
data: z.array(zKnowledgeFsCrawlPreviewPageResponse),
next_cursor: z.string().nullish(),
})
/**
@ -1127,6 +1400,36 @@ export const zKnowledgeFsPermissionListResponse = z.object({
data: z.array(zKnowledgeFsPermissionResponse),
})
/**
* KnowledgeFSOverviewHealthComponentResponse
*/
export const zKnowledgeFsOverviewHealthComponentResponse = z.object({
codes: z.array(z.string()),
state: z.enum(['degraded', 'healthy', 'unavailable', 'unknown']),
})
/**
* KnowledgeFSOverviewHealthComponentsResponse
*/
export const zKnowledgeFsOverviewHealthComponentsResponse = z.object({
index: zKnowledgeFsOverviewHealthComponentResponse,
ingestion: zKnowledgeFsOverviewHealthComponentResponse,
profile_publication: zKnowledgeFsOverviewHealthComponentResponse,
query_availability: zKnowledgeFsOverviewHealthComponentResponse,
source_freshness: zKnowledgeFsOverviewHealthComponentResponse,
worker_readiness: zKnowledgeFsOverviewHealthComponentResponse,
})
/**
* KnowledgeFSOverviewHealthResponse
*/
export const zKnowledgeFsOverviewHealthResponse = z.object({
components: zKnowledgeFsOverviewHealthComponentsResponse,
generated_at: z.iso.datetime(),
knowledge_space_id: z.string(),
state: z.enum(['degraded', 'healthy', 'unavailable', 'unknown']),
})
/**
* KnowledgeFSProductRerankProfile
*/
@ -1191,6 +1494,38 @@ export const zKnowledgeFsSettingsPayload = z.object({
retrieval: zKnowledgeFsProductRetrievalProfile.nullish(),
})
/**
* KnowledgeFSSourceProviderFieldResponse
*/
export const zKnowledgeFsSourceProviderFieldResponse = z.object({
description: z.string().nullish(),
format: z.enum(['password', 'uri']).nullish(),
name: z.string(),
required: z.boolean(),
secret: z.boolean(),
type: z.enum(['boolean', 'integer', 'string']),
})
/**
* KnowledgeFSSourceProviderResponse
*/
export const zKnowledgeFsSourceProviderResponse = z.object({
auth_kinds: z.array(z.enum(['api-key', 'endpoint', 'oauth2'])),
available: z.boolean(),
capabilities: z.array(z.enum(['online-document', 'online-drive', 'website-crawl'])),
configuration: z.array(zKnowledgeFsSourceProviderFieldResponse),
display_name: z.string(),
id: z.string(),
unavailable_reason: z.string().nullish(),
})
/**
* KnowledgeFSSourceProviderListResponse
*/
export const zKnowledgeFsSourceProviderListResponse = z.object({
data: z.array(zKnowledgeFsSourceProviderResponse),
})
/**
* KnowledgeFSSourceFileResponse
*/
@ -1388,6 +1723,47 @@ export const zDeleteKnowledgeFsSpacesByControlSpaceIdAppBindingsByCallerKindByAp
export const zDeleteKnowledgeFsSpacesByControlSpaceIdAppBindingsByCallerKindByAppIdResponse =
z.void()
export const zGetKnowledgeFsSpacesByControlSpaceIdBackgroundTasksPath = z.object({
control_space_id: z.string(),
})
export const zGetKnowledgeFsSpacesByControlSpaceIdBackgroundTasksQuery = z.object({
cursor: z.string().min(1).max(8192).optional(),
limit: z.int().gte(1).lte(100).optional().default(50),
})
/**
* KnowledgeFS background tasks
*/
export const zGetKnowledgeFsSpacesByControlSpaceIdBackgroundTasksResponse =
zKnowledgeFsBackgroundTaskListResponse
export const zPostKnowledgeFsSpacesByControlSpaceIdBackgroundTasksByTaskKindByTaskIdCancelPath =
z.object({
control_space_id: z.string(),
task_id: z.string(),
task_kind: z.string(),
})
/**
* KnowledgeFS background task canceled
*/
export const zPostKnowledgeFsSpacesByControlSpaceIdBackgroundTasksByTaskKindByTaskIdCancelResponse =
zKnowledgeFsBackgroundTaskResponse
export const zPostKnowledgeFsSpacesByControlSpaceIdBackgroundTasksByTaskKindByTaskIdRetryPath =
z.object({
control_space_id: z.string(),
task_id: z.string(),
task_kind: z.string(),
})
/**
* KnowledgeFS background task retried
*/
export const zPostKnowledgeFsSpacesByControlSpaceIdBackgroundTasksByTaskKindByTaskIdRetryResponse =
zKnowledgeFsBackgroundTaskResponse
export const zGetKnowledgeFsSpacesByControlSpaceIdBulkJobsByJobIdPath = z.object({
control_space_id: z.string(),
job_id: z.string(),
@ -1460,6 +1836,10 @@ export const zPostKnowledgeFsSpacesByControlSpaceIdDocumentsResponse = zKnowledg
export const zDeleteKnowledgeFsSpacesByControlSpaceIdDocumentsBulkBody =
zKnowledgeFsBulkDocumentDeletePayload
export const zDeleteKnowledgeFsSpacesByControlSpaceIdDocumentsBulkHeaders = z.object({
'Idempotency-Key': z.string(),
})
export const zDeleteKnowledgeFsSpacesByControlSpaceIdDocumentsBulkPath = z.object({
control_space_id: z.string(),
})
@ -1486,6 +1866,10 @@ export const zPostKnowledgeFsSpacesByControlSpaceIdDocumentsReindexResponse =
export const zDeleteKnowledgeFsSpacesByControlSpaceIdDocumentsByDocumentIdBody =
zKnowledgeFsDocumentDeletePayload
export const zDeleteKnowledgeFsSpacesByControlSpaceIdDocumentsByDocumentIdHeaders = z.object({
'Idempotency-Key': z.string(),
})
export const zDeleteKnowledgeFsSpacesByControlSpaceIdDocumentsByDocumentIdPath = z.object({
control_space_id: z.string(),
document_id: z.string(),
@ -1637,6 +2021,31 @@ export const zPostKnowledgeFsSpacesByControlSpaceIdJobsByJobIdRetryPath = z.obje
export const zPostKnowledgeFsSpacesByControlSpaceIdJobsByJobIdRetryResponse =
zKnowledgeFsDocumentCompilationJobResponse
export const zGetKnowledgeFsSpacesByControlSpaceIdLogicalDocumentsPath = z.object({
control_space_id: z.string(),
})
export const zGetKnowledgeFsSpacesByControlSpaceIdLogicalDocumentsQuery = z.object({
cursor: z.string().min(1).max(1000).optional(),
})
/**
* KnowledgeFS logical documents
*/
export const zGetKnowledgeFsSpacesByControlSpaceIdLogicalDocumentsResponse =
zKnowledgeFsLogicalDocumentListResponse
export const zGetKnowledgeFsSpacesByControlSpaceIdLogicalDocumentsByDocumentIdPath = z.object({
control_space_id: z.string(),
document_id: z.string(),
})
/**
* KnowledgeFS logical document
*/
export const zGetKnowledgeFsSpacesByControlSpaceIdLogicalDocumentsByDocumentIdResponse =
zKnowledgeFsLogicalDocumentResponse
export const zPutKnowledgeFsSpacesByControlSpaceIdMembersBody = zKnowledgeFsMembersReplacePayload
export const zPutKnowledgeFsSpacesByControlSpaceIdMembersPath = z.object({
@ -1649,6 +2058,54 @@ export const zPutKnowledgeFsSpacesByControlSpaceIdMembersPath = z.object({
export const zPutKnowledgeFsSpacesByControlSpaceIdMembersResponse =
zKnowledgeFsPermissionListResponse
export const zGetKnowledgeFsSpacesByControlSpaceIdOverviewHealthPath = z.object({
control_space_id: z.string(),
})
/**
* KnowledgeFS health
*/
export const zGetKnowledgeFsSpacesByControlSpaceIdOverviewHealthResponse =
zKnowledgeFsOverviewHealthResponse
export const zGetKnowledgeFsSpacesByControlSpaceIdOverviewInventoryPath = z.object({
control_space_id: z.string(),
})
/**
* KnowledgeFS inventory
*/
export const zGetKnowledgeFsSpacesByControlSpaceIdOverviewInventoryResponse =
zKnowledgeFsOverviewInventoryResponse
export const zGetKnowledgeFsSpacesByControlSpaceIdOverviewQueryOutcomesPath = z.object({
control_space_id: z.string(),
})
export const zGetKnowledgeFsSpacesByControlSpaceIdOverviewQueryOutcomesQuery = z.object({
window: z.enum(['24h', '30d', '7d']).optional().default('24h'),
})
/**
* KnowledgeFS query outcomes
*/
export const zGetKnowledgeFsSpacesByControlSpaceIdOverviewQueryOutcomesResponse =
zKnowledgeFsOverviewQueryOutcomesResponse
export const zGetKnowledgeFsSpacesByControlSpaceIdOverviewStatsPath = z.object({
control_space_id: z.string(),
})
export const zGetKnowledgeFsSpacesByControlSpaceIdOverviewStatsQuery = z.object({
window: z.enum(['24h', '30d', '7d']).optional().default('24h'),
})
/**
* KnowledgeFS Overview statistics
*/
export const zGetKnowledgeFsSpacesByControlSpaceIdOverviewStatsResponse =
zKnowledgeFsOverviewStatsResponse
export const zGetKnowledgeFsSpacesByControlSpaceIdPermissionsPath = z.object({
control_space_id: z.string(),
})
@ -1791,6 +2248,130 @@ export const zPatchKnowledgeFsSpacesByControlSpaceIdSettingsPath = z.object({
*/
export const zPatchKnowledgeFsSpacesByControlSpaceIdSettingsResponse = zKnowledgeFsSettingsResponse
export const zGetKnowledgeFsSpacesByControlSpaceIdSourceConnectionsPath = z.object({
control_space_id: z.string(),
})
export const zGetKnowledgeFsSpacesByControlSpaceIdSourceConnectionsQuery = z.object({
cursor: z.string().min(1).max(4096).optional(),
limit: z.int().gte(1).lte(200).optional().default(50),
})
/**
* KnowledgeFS source connections
*/
export const zGetKnowledgeFsSpacesByControlSpaceIdSourceConnectionsResponse =
zKnowledgeFsSourceConnectionListResponse
export const zPostKnowledgeFsSpacesByControlSpaceIdSourceConnectionsBody =
zKnowledgeFsSourceConnectionCreatePayload
export const zPostKnowledgeFsSpacesByControlSpaceIdSourceConnectionsPath = z.object({
control_space_id: z.string(),
})
/**
* KnowledgeFS source connection created
*/
export const zPostKnowledgeFsSpacesByControlSpaceIdSourceConnectionsResponse =
zKnowledgeFsSourceConnectionResponse
export const zPostKnowledgeFsSpacesByControlSpaceIdSourceConnectionsByConnectionIdRefreshBody =
zKnowledgeFsSourceConnectionRefreshPayload
export const zPostKnowledgeFsSpacesByControlSpaceIdSourceConnectionsByConnectionIdRefreshPath =
z.object({
connection_id: z.string(),
control_space_id: z.string(),
})
/**
* KnowledgeFS source connection refreshed
*/
export const zPostKnowledgeFsSpacesByControlSpaceIdSourceConnectionsByConnectionIdRefreshResponse =
zKnowledgeFsSourceConnectionResponse
export const zGetKnowledgeFsSpacesByControlSpaceIdSourceProvidersPath = z.object({
control_space_id: z.string(),
})
/**
* KnowledgeFS source providers
*/
export const zGetKnowledgeFsSpacesByControlSpaceIdSourceProvidersResponse =
zKnowledgeFsSourceProviderListResponse
export const zGetKnowledgeFsSpacesByControlSpaceIdSourceWorkflowsByRunIdPath = z.object({
control_space_id: z.string(),
run_id: z.string(),
})
/**
* KnowledgeFS source workflow
*/
export const zGetKnowledgeFsSpacesByControlSpaceIdSourceWorkflowsByRunIdResponse =
zKnowledgeFsSourceWorkflowResponse
export const zPostKnowledgeFsSpacesByControlSpaceIdSourceWorkflowsByRunIdCancelBody =
zKnowledgeFsSourceWorkflowCancelPayload
export const zPostKnowledgeFsSpacesByControlSpaceIdSourceWorkflowsByRunIdCancelPath = z.object({
control_space_id: z.string(),
run_id: z.string(),
})
/**
* KnowledgeFS source workflow canceled
*/
export const zPostKnowledgeFsSpacesByControlSpaceIdSourceWorkflowsByRunIdCancelResponse =
zKnowledgeFsSourceWorkflowResponse
export const zGetKnowledgeFsSpacesByControlSpaceIdSourceWorkflowsByRunIdPagesPath = z.object({
control_space_id: z.string(),
run_id: z.string(),
})
export const zGetKnowledgeFsSpacesByControlSpaceIdSourceWorkflowsByRunIdPagesQuery = z.object({
cursor: z.string().min(1).max(4096).optional(),
limit: z.int().gte(1).lte(200).optional().default(50),
})
/**
* KnowledgeFS crawl preview pages
*/
export const zGetKnowledgeFsSpacesByControlSpaceIdSourceWorkflowsByRunIdPagesResponse =
zKnowledgeFsCrawlPreviewPageListResponse
export const zPostKnowledgeFsSpacesByControlSpaceIdSourceWorkflowsByRunIdRetryPath = z.object({
control_space_id: z.string(),
run_id: z.string(),
})
/**
* KnowledgeFS source workflow retried
*/
export const zPostKnowledgeFsSpacesByControlSpaceIdSourceWorkflowsByRunIdRetryResponse =
zKnowledgeFsSourceWorkflowResponse
export const zPostKnowledgeFsSpacesByControlSpaceIdSourceWorkflowsByRunIdSelectionBody =
zKnowledgeFsCrawlPreviewSelectionPayload
export const zPostKnowledgeFsSpacesByControlSpaceIdSourceWorkflowsByRunIdSelectionHeaders =
z.object({
'Idempotency-Key': z.string(),
})
export const zPostKnowledgeFsSpacesByControlSpaceIdSourceWorkflowsByRunIdSelectionPath = z.object({
control_space_id: z.string(),
run_id: z.string(),
})
/**
* KnowledgeFS crawl preview selection accepted
*/
export const zPostKnowledgeFsSpacesByControlSpaceIdSourceWorkflowsByRunIdSelectionResponse =
zKnowledgeFsSourceWorkflowResponse
export const zGetKnowledgeFsSpacesByControlSpaceIdSourcesPath = z.object({
control_space_id: z.string(),
})
@ -1818,6 +2399,10 @@ export const zPostKnowledgeFsSpacesByControlSpaceIdSourcesResponse = zKnowledgeF
export const zDeleteKnowledgeFsSpacesByControlSpaceIdSourcesBySourceIdBody =
zKnowledgeFsSourceDeletePayload
export const zDeleteKnowledgeFsSpacesByControlSpaceIdSourcesBySourceIdHeaders = z.object({
'Idempotency-Key': z.string(),
})
export const zDeleteKnowledgeFsSpacesByControlSpaceIdSourcesBySourceIdPath = z.object({
control_space_id: z.string(),
source_id: z.string(),
@ -1858,16 +2443,20 @@ export const zPatchKnowledgeFsSpacesByControlSpaceIdSourcesBySourceIdPath = z.ob
export const zPatchKnowledgeFsSpacesByControlSpaceIdSourcesBySourceIdResponse =
zKnowledgeFsSourceResponse
export const zPostKnowledgeFsSpacesByControlSpaceIdSourcesBySourceIdCrawlPath = z.object({
export const zPostKnowledgeFsSpacesByControlSpaceIdSourcesBySourceIdCrawlPreviewHeaders = z.object({
'Idempotency-Key': z.string(),
})
export const zPostKnowledgeFsSpacesByControlSpaceIdSourcesBySourceIdCrawlPreviewPath = z.object({
control_space_id: z.string(),
source_id: z.string(),
})
/**
* KnowledgeFS source crawl
* KnowledgeFS source crawl preview accepted
*/
export const zPostKnowledgeFsSpacesByControlSpaceIdSourcesBySourceIdCrawlResponse =
zKnowledgeFsSourceCrawlResponse
export const zPostKnowledgeFsSpacesByControlSpaceIdSourcesBySourceIdCrawlPreviewResponse =
zKnowledgeFsSourceWorkflowResponse
export const zGetKnowledgeFsSpacesByControlSpaceIdSourcesBySourceIdFilesPath = z.object({
control_space_id: z.string(),
@ -1931,6 +2520,46 @@ export const zGetKnowledgeFsSpacesByControlSpaceIdSourcesBySourceIdPagesQuery =
export const zGetKnowledgeFsSpacesByControlSpaceIdSourcesBySourceIdPagesResponse =
zKnowledgeFsSourcePagesResponse
export const zPostKnowledgeFsSpacesByControlSpaceIdSourcesBySourceIdSyncHeaders = z.object({
'Idempotency-Key': z.string(),
})
export const zPostKnowledgeFsSpacesByControlSpaceIdSourcesBySourceIdSyncPath = z.object({
control_space_id: z.string(),
source_id: z.string(),
})
/**
* KnowledgeFS source sync accepted
*/
export const zPostKnowledgeFsSpacesByControlSpaceIdSourcesBySourceIdSyncResponse =
zKnowledgeFsSourceWorkflowResponse
export const zGetKnowledgeFsSpacesByControlSpaceIdSourcesBySourceIdSyncPolicyPath = z.object({
control_space_id: z.string(),
source_id: z.string(),
})
/**
* KnowledgeFS source sync policy
*/
export const zGetKnowledgeFsSpacesByControlSpaceIdSourcesBySourceIdSyncPolicyResponse =
zKnowledgeFsSourceSyncPolicyResponse
export const zPutKnowledgeFsSpacesByControlSpaceIdSourcesBySourceIdSyncPolicyBody =
zKnowledgeFsSourceSyncPolicyPayload
export const zPutKnowledgeFsSpacesByControlSpaceIdSourcesBySourceIdSyncPolicyPath = z.object({
control_space_id: z.string(),
source_id: z.string(),
})
/**
* KnowledgeFS source sync policy updated
*/
export const zPutKnowledgeFsSpacesByControlSpaceIdSourcesBySourceIdSyncPolicyResponse =
zKnowledgeFsSourceSyncPolicyResponse
export const zPostKnowledgeFsSpacesByControlSpaceIdSourcesBySourceIdTestPath = z.object({
control_space_id: z.string(),
source_id: z.string(),
@ -2029,7 +2658,7 @@ export const zPostKnowledgeFsSpacesByControlSpaceIdUploadCapabilitiesResponse =
export const zPostKnowledgeFsSpacesByControlSpaceIdUploadSessionsByUploadSessionIdSmallFileBody =
z.object({
file: z.custom<Blob | File>(),
file: z.custom<Blob | File>((value) => value instanceof Blob || value instanceof File),
})
export const zPostKnowledgeFsSpacesByControlSpaceIdUploadSessionsByUploadSessionIdSmallFilePath =

View File

@ -23,6 +23,7 @@ export type SystemFeatureModel = {
is_allow_register: boolean
is_email_setup: boolean
knowledge_fs_enabled: boolean
knowledge_fs_upload_enabled: boolean
license: LicenseStatusModel
max_plugin_package_size: number
plugin_installation_permission: PluginInstallationPermissionModel

View File

@ -135,6 +135,7 @@ export const zSystemFeatureModel = z.object({
is_allow_register: z.boolean().default(false),
is_email_setup: z.boolean().default(false),
knowledge_fs_enabled: z.boolean().default(false),
knowledge_fs_upload_enabled: z.boolean().default(false),
license: zLicenseStatusModel.default({ status: 'none' }),
max_plugin_package_size: z.int().default(15728640),
plugin_installation_permission: zPluginInstallationPermissionModel.default({

View File

@ -520,6 +520,7 @@ export type SystemFeatureModel = {
is_allow_register: boolean
is_email_setup: boolean
knowledge_fs_enabled: boolean
knowledge_fs_upload_enabled: boolean
license: LicenseStatusModel
max_plugin_package_size: number
plugin_installation_permission: PluginInstallationPermissionModel

View File

@ -782,6 +782,7 @@ export const zSystemFeatureModel = z.object({
is_allow_register: z.boolean().default(false),
is_email_setup: z.boolean().default(false),
knowledge_fs_enabled: z.boolean().default(false),
knowledge_fs_upload_enabled: z.boolean().default(false),
license: zLicenseStatusModel.default({ status: 'none' }),
max_plugin_package_size: z.int().default(15728640),
plugin_installation_permission: zPluginInstallationPermissionModel.default({

View File

@ -1,12 +0,0 @@
// This file is auto-generated by scripts/generate-knowledge-fs-contract.mjs.
// Do not edit it manually.
export const knowledgeFsSourceOpenapiSha256 =
'f18910e9c45a64f0855e0643a7a626fb2889021b4f943458de86c6bd2469facb'
export const knowledgeFsConsoleDeclarationsSha256 =
'8bd1924747fdd0d478ca085817cbe000eb7e8630b2c6a03f4f13a6a0fac07946'
export const knowledgeFsGeneratedArtifactSha256 = {
'orpc.gen.ts': 'e0d9954f817e97a4e95dd38c4522fb403c8659d741ce485fa671b1b4ce90a540',
'types.gen.ts': 'a558ab80f32a8555bb5b44b7a596ef4a4a7a8cb7904390993aabcc587915f530',
'zod.gen.ts': 'ca698a6fa64a0717e29da5d4678976b55355a2c0fe5ddfa7037325aa79ab4762',
} as const

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,47 +0,0 @@
import { createHash } from 'node:crypto'
import { readFile } from 'node:fs/promises'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
import {
knowledgeFsGeneratedArtifactSha256,
knowledgeFsSourceOpenapiSha256,
} from './generated/knowledge-fs/metadata.gen'
import { getStreamingOperationIds } from './scripts/knowledge-fs-contract-utils.mjs'
const packageRoot = dirname(fileURLToPath(import.meta.url))
describe('KnowledgeFS contract generation', () => {
it.each(['200', '2XX'])('detects an SSE response declared with %s', (status) => {
expect(
getStreamingOperationIds({
paths: {
'/tasks/{id}/events': {
get: {
operationId: 'streamTaskEvents',
responses: {
[status]: {
content: {
'text/event-stream': {},
},
},
},
},
},
},
}),
).toEqual(['streamTaskEvents'])
})
it('matches the pinned source contract and committed generated artifacts', async () => {
const lock = JSON.parse(
await readFile(join(packageRoot, '../../api/knowledge-fs-contract.lock.json'), 'utf8'),
)
expect(knowledgeFsSourceOpenapiSha256).toBe(lock.openapiSha256)
for (const [fileName, expectedSha256] of Object.entries(knowledgeFsGeneratedArtifactSha256)) {
const content = await readFile(join(packageRoot, 'generated/knowledge-fs', fileName))
expect(createHash('sha256').update(content).digest('hex'), fileName).toBe(expectedSha256)
}
})
})

View File

@ -1,61 +0,0 @@
import { $, defineConfig } from '@hey-api/openapi-ts'
const input = process.env.KNOWLEDGE_FS_OPENAPI
const outputPath = process.env.KNOWLEDGE_FS_OUTPUT ?? 'generated/knowledge-fs'
if (!input) throw new Error('KNOWLEDGE_FS_OPENAPI must point to the filtered pinned export')
export default defineConfig({
input,
logs: {
file: false,
},
output: {
clean: true,
entryFile: false,
fileName: {
suffix: '.gen',
},
path: outputPath,
},
parser: {
patch: {
input: (spec) => {
const paths = spec.paths as Record<string, unknown> | undefined
if (!paths) return
for (const [path, pathItem] of Object.entries(paths)) {
delete paths[path]
paths[`/knowledge-fs${path}`] = pathItem
}
},
},
},
plugins: [
{
comments: false,
name: '@hey-api/typescript',
},
{
name: 'zod',
'~resolvers': {
string: (ctx) => {
if (ctx.schema.format === 'binary')
return $(ctx.symbols.z)
.attr('custom')
.call()
.generic($.type.or($.type('Blob'), $.type('File')))
return undefined
},
},
},
{
contracts: {
strategy: 'single',
},
name: 'orpc',
validator: 'zod',
},
],
})

View File

@ -19,16 +19,11 @@
"./enterprise/*": {
"types": "./generated/enterprise/*.ts",
"import": "./generated/enterprise/*.ts"
},
"./knowledge-fs/*": {
"types": "./generated/knowledge-fs/*.ts",
"import": "./generated/knowledge-fs/*.ts"
}
},
"scripts": {
"gen-api-contract": "uv run --project ../../api ../../api/dev/generate_swagger_specs.py --output-dir openapi && uv run --project ../../api ../../api/dev/generate_fastopenapi_specs.py --output-dir openapi && node -e \"fs.rmSync('generated/api', { recursive: true, force: true })\" && openapi-ts -f openapi-ts.api.config.ts && vp fmt generated/api",
"gen-enterprise-contract": "openapi-ts -f openapi-ts.enterprise.config.ts",
"gen-knowledge-fs-contract": "node scripts/generate-knowledge-fs-contract.mjs",
"test": "vp test",
"type-check": "tsc"
},

View File

@ -1,119 +0,0 @@
import { execFileSync } from 'node:child_process'
import { createHash } from 'node:crypto'
import { mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { dirname, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { getStreamingOperationIds } from './knowledge-fs-contract-utils.mjs'
const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const workspaceRoot = resolve(packageRoot, '../..')
const repository = resolve(
process.env.KNOWLEDGE_FS_REPO ?? resolve(workspaceRoot, '../knowledge-fs'),
)
const temporaryDirectory = await mkdtemp(join(tmpdir(), 'dify-knowledge-fs-types-'))
try {
const openapiPath = join(temporaryDirectory, 'knowledge-fs.console.json')
run(
'uv',
[
'run',
'--project',
resolve(workspaceRoot, 'api'),
resolve(workspaceRoot, 'api/dev/generate_knowledge_fs_contract.py'),
'--repository',
repository,
'--check',
'--output-openapi',
openapiPath,
],
workspaceRoot,
)
run('pnpm', ['exec', 'openapi-ts', '-f', 'openapi-ts.knowledge-fs.config.ts'], packageRoot, {
KNOWLEDGE_FS_OPENAPI: openapiPath,
})
await patchStreamingContracts(openapiPath)
run('pnpm', ['exec', 'vp', 'fmt', 'generated/knowledge-fs'], packageRoot)
await writeContractMetadata(openapiPath, await generatedArtifactSha256())
run('pnpm', ['exec', 'vp', 'fmt', 'generated/knowledge-fs/metadata.gen.ts'], packageRoot)
} finally {
await rm(temporaryDirectory, { force: true, recursive: true })
}
async function patchStreamingContracts(openapiPath) {
const document = JSON.parse(await readFile(openapiPath, 'utf8'))
const streamingOperationIds = getStreamingOperationIds(document)
if (streamingOperationIds.length === 0) return
const outputPath = join(packageRoot, 'generated/knowledge-fs/orpc.gen.ts')
let source = await readFile(outputPath, 'utf8')
source = replaceOnce(
source,
"import { oc } from '@orpc/contract'",
"import { eventIterator, oc } from '@orpc/contract'",
)
for (const operationId of streamingOperationIds) {
const responseSchema = `z${capitalize(operationId)}Response`
source = replaceOnce(
source,
`.output(${responseSchema})`,
`.output(eventIterator(${responseSchema}))`,
)
}
await writeFile(outputPath, source)
}
function capitalize(value) {
return value.charAt(0).toUpperCase() + value.slice(1)
}
function replaceOnce(source, target, replacement) {
const firstIndex = source.indexOf(target)
if (firstIndex === -1 || source.indexOf(target, firstIndex + target.length) !== -1)
throw new Error(`Expected exactly one generated occurrence of ${target}`)
return source.slice(0, firstIndex) + replacement + source.slice(firstIndex + target.length)
}
async function generatedArtifactSha256() {
const generatedDirectory = join(packageRoot, 'generated/knowledge-fs')
const fileNames = (await readdir(generatedDirectory))
.filter((fileName) => fileName.endsWith('.gen.ts') && fileName !== 'metadata.gen.ts')
.sort()
return Object.fromEntries(
await Promise.all(
fileNames.map(async (fileName) => [
fileName,
createHash('sha256')
.update(await readFile(join(generatedDirectory, fileName)))
.digest('hex'),
]),
),
)
}
async function writeContractMetadata(openapiPath, artifactSha256) {
const document = JSON.parse(await readFile(openapiPath, 'utf8'))
const source = [
'// This file is auto-generated by scripts/generate-knowledge-fs-contract.mjs.',
'// Do not edit it manually.',
'',
`export const knowledgeFsSourceOpenapiSha256 = ${JSON.stringify(document['x-dify-source-openapi-sha256'])}`,
`export const knowledgeFsConsoleDeclarationsSha256 = ${JSON.stringify(document['x-dify-console-declarations-sha256'])}`,
`export const knowledgeFsGeneratedArtifactSha256 = ${JSON.stringify(artifactSha256, null, 2)} as const`,
'',
].join('\n')
await writeFile(join(packageRoot, 'generated/knowledge-fs/metadata.gen.ts'), source)
}
function run(command, args, cwd, extraEnv = {}) {
execFileSync(command, args, {
cwd,
env: { ...process.env, ...extraEnv },
stdio: 'inherit',
})
}

View File

@ -1,19 +0,0 @@
export function getStreamingOperationIds(document) {
return Object.values(document.paths ?? {})
.flatMap((pathItem) =>
Object.values(pathItem).flatMap((operation) => {
if (typeof operation !== 'object' || operation === null) return []
const isEventStream = Object.entries(operation.responses ?? {}).some(
([status, response]) =>
(status === '2XX' || /^2\d\d$/.test(status)) &&
typeof response === 'object' &&
response !== null &&
'text/event-stream' in (response.content ?? {}),
)
return isEventStream && typeof operation.operationId === 'string'
? [operation.operationId]
: []
}),
)
.sort()
}

View File

@ -17,3 +17,7 @@ export const deploymentEditionAtom = atom((get) => {
export const brandingEnabledAtom = atom((get) => {
return get(systemFeaturesAtom).branding.enabled
})
export const knowledgeFsUploadEnabledAtom = atom((get) => {
return get(systemFeaturesAtom).knowledge_fs_upload_enabled
})

View File

@ -1,7 +1,5 @@
import type {
GetKnowledgeSpacesByIdSourceConnectionsResponse,
GetSourceProvidersResponse,
} from '@dify/contracts/knowledge-fs/types.gen'
import type { DatasourceProviderAuthListResponse } from '@dify/contracts/api/console/auth/types.gen'
import type { SourceConnection, SourceProvider } from '../source-models'
import { act, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { StrictMode } from 'react'
@ -9,14 +7,53 @@ import { render } from '@/test/console/render'
import { AddSourcePage } from '../add-source-page'
import { newKnowledgeSourceDraftStorageKey } from '../routes'
type GetKnowledgeSpacesByIdSourceConnectionsResponse = {
items: SourceConnection[]
nextCursor?: string
}
type GetSourceProvidersResponse = { items: SourceProvider[] }
const routerMock = vi.hoisted(() => ({
push: vi.fn(),
replace: vi.fn(),
}))
const connectFirecrawlButtonName = 'dataset.newKnowledge.connectProvider:{"provider":"Firecrawl"}'
vi.mock('@/next/navigation', () => ({ useRouter: () => routerMock }))
const toastInfoMock = vi.hoisted(() => vi.fn())
const providerApiResponse = vi.hoisted(() => (provider: SourceProvider) => ({
auth_kinds: provider.authKinds,
available: provider.available,
capabilities: provider.capabilities,
configuration: provider.configuration.map((field) => ({
description: field.description ?? null,
format: field.format ?? null,
name: field.name,
required: field.required,
secret: field.secret,
type: field.type,
})),
display_name: provider.displayName,
id: provider.id,
unavailable_reason: provider.unavailableReason ?? null,
}))
const connectionApiResponse = vi.hoisted(() => (connection: SourceConnection) => ({
auth_kind: connection.authKind,
configuration: connection.configuration,
created_at: connection.createdAt,
error_code: connection.errorCode ?? null,
expires_at: connection.expiresAt ?? null,
id: connection.id,
knowledge_space_id: connection.knowledgeSpaceId,
name: connection.name,
provider_id: connection.providerId,
scopes: connection.scopes,
status: connection.status,
updated_at: connection.updatedAt,
version: connection.version,
}))
vi.mock('@langgenius/dify-ui/toast', () => ({
toast: { info: toastInfoMock },
@ -25,12 +62,19 @@ vi.mock('@langgenius/dify-ui/toast', () => ({
type ConnectionsInfiniteData = {
pages: GetKnowledgeSpacesByIdSourceConnectionsResponse[]
}
const connectionInfiniteDataApiResponse = vi.hoisted(() => (data: ConnectionsInfiniteData) => ({
pages: data.pages.map((page) => ({
data: page.items.map(connectionApiResponse),
next_cursor: page.nextCursor ?? null,
})),
}))
type ConnectionsInfiniteOptions = {
enabled?: boolean
getNextPageParam: (
lastPage: GetKnowledgeSpacesByIdSourceConnectionsResponse,
) => string | undefined
getNextPageParam: (lastPage: {
data: ReturnType<typeof connectionApiResponse>[]
next_cursor?: string | null
}) => string | null | undefined
input: (pageParam: string | null) => unknown
initialPageParam: string | null
}
@ -52,6 +96,12 @@ const queryState = vi.hoisted(() => ({
isPending: false,
refetch: vi.fn(),
},
datasourceAuth: {
data: { result: [] } as DatasourceProviderAuthListResponse | undefined,
error: null as unknown,
isPending: false,
refetch: vi.fn(),
},
}))
const clientMock = vi.hoisted(() => ({
@ -64,9 +114,10 @@ const queryClientMock = vi.hoisted(() => ({
}))
const providerQueryOptionsMock = vi.hoisted(() =>
vi.fn((options: { enabled?: boolean }) => ({
vi.fn((options: { enabled?: boolean; select?: (data: unknown) => unknown }) => ({
enabled: options.enabled,
queryKey: ['source-providers'],
select: options.select,
})),
)
const connectionInfiniteOptionsMock = vi.hoisted(() =>
@ -76,6 +127,12 @@ const connectionInfiniteOptionsMock = vi.hoisted(() =>
})),
)
const providerHookOptionsMock = vi.hoisted(() => vi.fn())
const datasourceAuthQueryOptionsMock = vi.hoisted(() =>
vi.fn((options: { enabled?: boolean }) => ({
enabled: options.enabled,
queryKey: ['datasource-auth'],
})),
)
const connectionHookOptionsMock = vi.hoisted(() => vi.fn())
vi.mock('@tanstack/react-query', async (importOriginal) => {
@ -84,11 +141,33 @@ vi.mock('@tanstack/react-query', async (importOriginal) => {
...original,
useInfiniteQuery: (options: unknown) => {
connectionHookOptionsMock(options)
return queryState.connections
return {
...queryState.connections,
data: queryState.connections.data
? connectionInfiniteDataApiResponse(queryState.connections.data)
: undefined,
refetch: async () => {
const result = (await queryState.connections.refetch()) as
| { data?: ConnectionsInfiniteData; error?: unknown }
| undefined
if (!result?.data) return result
return {
...result,
data: connectionInfiniteDataApiResponse(result.data),
}
},
}
},
useQuery: (options: unknown) => {
useQuery: (options: { queryKey?: string[]; select?: (data: unknown) => unknown }) => {
providerHookOptionsMock(options)
return queryState.providers
if (options.queryKey?.[0] === 'datasource-auth') return queryState.datasourceAuth
const raw = queryState.providers.data
? { data: queryState.providers.data.items.map(providerApiResponse) }
: undefined
return {
...queryState.providers,
data: raw && options.select ? options.select(raw) : raw,
}
},
useQueryClient: () => queryClientMock,
}
@ -97,18 +176,49 @@ vi.mock('@tanstack/react-query', async (importOriginal) => {
vi.mock('@/service/client', () => ({
consoleClient: {
knowledgeFs: {
postKnowledgeSpacesByIdSourceConnections: clientMock.createConnection,
postKnowledgeSpacesByIdSourceConnectionsByConnectionIdRefresh: clientMock.refreshConnection,
spaces: {
byControlSpaceId: {
sourceConnections: {
byConnectionId: {
refresh: {
post: async (input: unknown) =>
connectionApiResponse(await clientMock.refreshConnection(input)),
},
},
post: async (input: unknown) =>
connectionApiResponse(await clientMock.createConnection(input)),
},
},
},
},
},
consoleQuery: {
knowledgeFs: {
getSourceProviders: {
queryOptions: providerQueryOptionsMock,
auth: {
plugin: {
datasource: {
defaultList: {
get: {
queryOptions: datasourceAuthQueryOptionsMock,
},
},
},
},
getKnowledgeSpacesByIdSourceConnections: {
infiniteOptions: connectionInfiniteOptionsMock,
key: vi.fn(() => ['source-connections']),
},
knowledgeFs: {
spaces: {
byControlSpaceId: {
sourceConnections: {
get: {
infiniteOptions: connectionInfiniteOptionsMock,
key: vi.fn(() => ['source-connections']),
},
},
sourceProviders: {
get: {
queryOptions: providerQueryOptionsMock,
},
},
},
},
},
},
@ -161,6 +271,69 @@ const firecrawlProvider: GetSourceProvidersResponse['items'][number] = {
id: 'plugin-daemon-website',
}
const difyManagedFirecrawlProvider: GetSourceProvidersResponse['items'][number] = {
authKinds: ['endpoint'],
available: true,
capabilities: ['website-crawl'],
configuration: [
{
name: 'credentialId',
required: true,
secret: false,
type: 'string',
},
{
name: 'pluginId',
required: true,
secret: false,
type: 'string',
},
{
name: 'provider',
required: true,
secret: false,
type: 'string',
},
{
name: 'datasource',
required: true,
secret: false,
type: 'string',
},
{
name: 'providerKind',
required: true,
secret: false,
type: 'string',
},
],
displayName: 'Dify website crawl',
id: 'plugin-daemon-website',
}
const firecrawlDatasourceAuth: DatasourceProviderAuthListResponse['result'][number] = {
author: 'langgenius',
credential_schema: [],
credentials_list: [
{
avatar_url: null,
credential: {},
id: 'firecrawl-credential-1',
is_default: true,
name: 'Default Firecrawl',
type: 'api-key',
},
],
description: { en_US: 'Firecrawl' },
icon: 'icon.svg',
label: { en_US: 'Firecrawl' },
name: 'firecrawl',
oauth_schema: null,
plugin_id: 'langgenius/firecrawl_datasource',
plugin_unique_identifier: 'langgenius/firecrawl_datasource:1.0.0@local',
provider: 'firecrawl',
}
const connection = (
status: 'provisioning' | 'active' | 'expired' | 'error' | 'revoked',
version = 2,
@ -190,9 +363,13 @@ describe('AddSourcePage', () => {
clientMock.refreshConnection.mockReset()
queryState.connections.refetch.mockReset()
queryState.providers.refetch.mockReset()
queryState.datasourceAuth.refetch.mockReset()
queryState.providers.data = { items: [firecrawlProvider] }
queryState.providers.error = null
queryState.providers.isPending = false
queryState.datasourceAuth.data = { result: [] }
queryState.datasourceAuth.error = null
queryState.datasourceAuth.isPending = false
queryState.connections.data = { pages: [{ items: [] }] }
queryState.connections.error = null
queryState.connections.hasNextPage = false
@ -213,18 +390,22 @@ describe('AddSourcePage', () => {
expect(providerQueryOptionsMock).toHaveBeenCalledWith({
context: { silent: true },
enabled: true,
input: {},
input: { params: { control_space_id: 'space-1' } },
retry: false,
select: expect.any(Function),
})
const options = connectionInfiniteOptionsMock.mock.lastCall?.[0]
expect(options).toBeDefined()
if (!options) throw new Error('Expected connection infinite query options')
expect(options.input(null)).toEqual({ params: { id: 'space-1' }, query: { limit: 200 } })
expect(options.input(null)).toEqual({
params: { control_space_id: 'space-1' },
query: { limit: 200 },
})
expect(options.input('next')).toEqual({
params: { id: 'space-1' },
params: { control_space_id: 'space-1' },
query: { cursor: 'next', limit: 200 },
})
expect(options.getNextPageParam({ items: [], nextCursor: 'next' })).toBe('next')
expect(options.getNextPageParam({ data: [], next_cursor: 'next' })).toBe('next')
expect(options.initialPageParam).toBeNull()
expect(screen.getByRole('status')).toBeInTheDocument()
})
@ -534,7 +715,7 @@ describe('AddSourcePage', () => {
)
await user.type(screen.getByLabelText(/Api Key/), 'secret-value')
await user.type(screen.getByLabelText('Endpoint'), 'https://crawl.example.com')
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.connectProvider' }))
await user.click(screen.getByRole('button', { name: connectFirecrawlButtonName }))
await waitFor(() =>
expect(clientMock.createConnection).toHaveBeenCalledWith({
@ -550,7 +731,7 @@ describe('AddSourcePage', () => {
name: 'Firecrawl',
providerId: 'plugin-daemon-website',
},
params: { id: 'space-1' },
params: { control_space_id: 'space-1' },
}),
)
await screen.findByRole('status', { name: 'appApi.loading' })
@ -562,6 +743,64 @@ describe('AddSourcePage', () => {
expect(screen.queryByDisplayValue('secret-value')).not.toBeInTheDocument()
})
it('binds the default Dify Firecrawl credential for the real KnowledgeFS provider', async () => {
const user = userEvent.setup()
queryState.providers.data = { items: [difyManagedFirecrawlProvider] }
queryState.datasourceAuth.data = { result: [firecrawlDatasourceAuth] }
clientMock.createConnection.mockResolvedValue({
...connection('active'),
authKind: 'endpoint',
configuration: {
credentialId: 'firecrawl-credential-1',
datasource: 'crawl',
pluginId: 'langgenius/firecrawl_datasource',
provider: 'firecrawl',
providerKind: 'website',
},
})
render(<AddSourcePage knowledgeSpaceId="space-1" />)
await user.click(screen.getByRole('button', { name: connectFirecrawlButtonName }))
await waitFor(() =>
expect(clientMock.createConnection).toHaveBeenCalledWith({
body: {
authKind: 'endpoint',
configuration: {
credentialId: 'firecrawl-credential-1',
datasource: 'crawl',
pluginId: 'langgenius/firecrawl_datasource',
provider: 'firecrawl',
providerKind: 'website',
},
credentials: {},
name: 'Firecrawl',
providerId: 'plugin-daemon-website',
},
params: { control_space_id: 'space-1' },
}),
)
expect(screen.queryByLabelText(/Api Key/)).not.toBeInTheDocument()
})
it('opens Data Source settings when Dify has no Firecrawl credential', async () => {
const user = userEvent.setup()
queryState.providers.data = { items: [difyManagedFirecrawlProvider] }
render(<AddSourcePage knowledgeSpaceId="space-1" />)
expect(screen.queryByLabelText(/Api Key/)).not.toBeInTheDocument()
expect(
screen.queryByRole('button', { name: connectFirecrawlButtonName }),
).not.toBeInTheDocument()
await user.click(
screen.getByRole('button', { name: 'dataset.newKnowledge.openDataSourceSettings' }),
)
expect(routerMock.replace).toHaveBeenCalledWith('/integrations/data-source')
expect(clientMock.createConnection).not.toHaveBeenCalled()
})
it('releases the parent history guard before the crawl preview owns navigation', async () => {
const user = userEvent.setup()
const historyBack = vi.spyOn(window.history, 'back').mockImplementation(() => undefined)
@ -572,7 +811,7 @@ describe('AddSourcePage', () => {
screen.getByRole('button', { name: /^dataset\.newKnowledge\.configureProvider/ }),
)
await user.type(screen.getByLabelText(/Api Key/), 'secret-value')
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.connectProvider' }))
await user.click(screen.getByRole('button', { name: connectFirecrawlButtonName }))
await waitFor(() => expect(historyBack).toHaveBeenCalledOnce())
expect(screen.queryByText(/dataset\.newKnowledge\.providerConnected/)).not.toBeInTheDocument()
@ -677,7 +916,7 @@ describe('AddSourcePage', () => {
)
await user.type(screen.getByLabelText(/Api Key/), 'do-not-retain')
await user.type(screen.getByLabelText('Endpoint'), 'https://crawl.example.com')
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.connectProvider' }))
await user.click(screen.getByRole('button', { name: connectFirecrawlButtonName }))
expect(await screen.findByText('dataset.newKnowledge.connectionFailed')).toBeInTheDocument()
expect(screen.getByLabelText(/Api Key/)).toHaveValue('')
@ -696,10 +935,9 @@ describe('AddSourcePage', () => {
screen.getByRole('button', { name: /^dataset\.newKnowledge\.configureProvider/ }),
)
await user.type(screen.getByLabelText(/Api Key/), 'secret-value')
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.connectProvider' }))
await user.click(screen.getByRole('button', { name: connectFirecrawlButtonName }))
await waitFor(() => expect(clientMock.createConnection).toHaveBeenCalledOnce())
await screen.findByRole('status', { name: 'appApi.loading' })
act(() => window.dispatchEvent(new PopStateEvent('popstate')))
expect(await screen.findByText(/dataset\.newKnowledge\.providerConnected/)).toBeInTheDocument()
expect(screen.queryByText('dataset.newKnowledge.connectionFailed')).not.toBeInTheDocument()
@ -734,7 +972,7 @@ describe('AddSourcePage', () => {
await user.type(screen.getByLabelText(/Api Key/), 'must-not-be-sent')
await user.click(screen.getByRole('radio', { name: 'dataset.newKnowledge.authKind.endpoint' }))
await user.type(screen.getByLabelText('Endpoint'), 'https://crawl.example.com')
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.connectProvider' }))
await user.click(screen.getByRole('button', { name: connectFirecrawlButtonName }))
await waitFor(() =>
expect(clientMock.createConnection).toHaveBeenCalledWith({
@ -750,7 +988,7 @@ describe('AddSourcePage', () => {
name: 'Firecrawl',
providerId: 'plugin-daemon-website',
},
params: { id: 'space-1' },
params: { control_space_id: 'space-1' },
}),
)
})
@ -792,7 +1030,7 @@ describe('AddSourcePage', () => {
await waitFor(() =>
expect(clientMock.refreshConnection).toHaveBeenCalledWith({
body: { expectedVersion: 2 },
params: { connectionId: 'connection-1', id: 'space-1' },
params: { connection_id: 'connection-1', control_space_id: 'space-1' },
}),
)
expect(queryClientMock.invalidateQueries).toHaveBeenCalled()
@ -830,7 +1068,7 @@ describe('AddSourcePage', () => {
await waitFor(() =>
expect(clientMock.refreshConnection).toHaveBeenLastCalledWith({
body: { expectedVersion: 3 },
params: { connectionId: 'connection-1', id: 'space-1' },
params: { connection_id: 'connection-1', control_space_id: 'space-1' },
}),
)
})
@ -853,7 +1091,7 @@ describe('AddSourcePage', () => {
await waitFor(() =>
expect(clientMock.refreshConnection).toHaveBeenLastCalledWith({
body: { expectedVersion: 3 },
params: { connectionId: 'connection-1', id: 'space-1' },
params: { connection_id: 'connection-1', control_space_id: 'space-1' },
}),
)
})

View File

@ -1,9 +1,9 @@
import type {
GetKnowledgeSpacesByIdSourcesBySourceIdSyncPolicyResponse,
GetKnowledgeSpacesByIdSourceWorkflowsByRunIdPagesResponse,
CrawlPreviewPageList,
Source,
SourceSyncPolicy,
SourceWorkflowRun,
} from '@dify/contracts/knowledge-fs/types.gen'
} from '../source-models'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { act, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
@ -13,19 +13,55 @@ import datasetTranslations from '@/i18n/en-US/dataset.json'
import { render } from '@/test/console/render'
import { CrawlSelectionForm } from '../crawl-selection-form'
type GetKnowledgeSpacesByIdSourcesBySourceIdSyncPolicyResponse = SourceSyncPolicy
type GetKnowledgeSpacesByIdSourceWorkflowsByRunIdPagesResponse = CrawlPreviewPageList
const clientMock = vi.hoisted(() => ({
getPolicy: vi.fn(),
getWorkflow: vi.fn(),
selectPages: vi.fn(),
updatePolicy: vi.fn(),
}))
const policyApiResponse = vi.hoisted(() => (policy: SourceSyncPolicy) => ({
created_at: policy.createdAt,
custom_interval_seconds: policy.customIntervalSeconds ?? null,
enabled: policy.enabled,
expected_source_version: policy.expectedSourceVersion,
id: policy.id,
knowledge_space_id: policy.knowledgeSpaceId,
mode: policy.mode,
next_run_at: policy.nextRunAt ?? null,
revision: policy.revision,
source_id: policy.sourceId,
updated_at: policy.updatedAt,
}))
const workflowApiResponse = vi.hoisted(() => (workflow: SourceWorkflowRun) => ({
canceled_at: workflow.canceledAt ?? null,
checkpoint: workflow.checkpoint,
completed_at: workflow.completedAt ?? null,
created_at: workflow.createdAt,
cursor: workflow.cursor ?? null,
execution_attempts: workflow.executionAttempts,
id: workflow.id,
knowledge_space_id: workflow.knowledgeSpaceId,
kind: workflow.kind,
last_error_code: workflow.lastErrorCode ?? null,
max_execution_attempts: workflow.maxExecutionAttempts,
progress_completed: workflow.progressCompleted,
progress_failed: workflow.progressFailed,
progress_skipped: workflow.progressSkipped,
progress_total: workflow.progressTotal ?? null,
source_id: workflow.sourceId ?? null,
state: workflow.state,
updated_at: workflow.updatedAt,
}))
const routerMock = vi.hoisted(() => ({ push: vi.fn() }))
const queryClientMock = vi.hoisted(() => ({ invalidateQueries: vi.fn() }))
const policyQueryOptionsMock = vi.hoisted(() =>
vi.fn(({ input }) => ({
queryFn: () => clientMock.getPolicy(input),
queryKey: ['sync-policy', input.params.sourceId],
vi.fn(({ input, select }) => ({
queryFn: async () => select(policyApiResponse(await clientMock.getPolicy(input))),
queryKey: ['sync-policy', input.params.source_id],
retry: false,
})),
)
@ -40,27 +76,51 @@ vi.mock('@tanstack/react-query', async (importOriginal) => {
vi.mock('@/service/client', () => ({
consoleClient: {
knowledgeFs: {
getKnowledgeSpacesByIdSourcesBySourceIdSyncPolicy: clientMock.getPolicy,
getKnowledgeSpacesByIdSourceWorkflowsByRunId: clientMock.getWorkflow,
spaces: {
byControlSpaceId: {
sourceWorkflows: {
byRunId: {
get: async (input: unknown) =>
workflowApiResponse(await clientMock.getWorkflow(input)),
selection: {
post: async (input: unknown) =>
workflowApiResponse(await clientMock.selectPages(input)),
},
},
},
sources: {
bySourceId: {
syncPolicy: {
get: async (input: unknown) => policyApiResponse(await clientMock.getPolicy(input)),
put: async (input: unknown) =>
policyApiResponse(await clientMock.updatePolicy(input)),
},
},
get: {
key: vi.fn(() => ['knowledge-sources']),
},
},
},
},
},
},
consoleQuery: {
knowledgeFs: {
getKnowledgeSpacesByIdSources: {
key: vi.fn(() => ['knowledge-sources']),
},
getKnowledgeSpacesByIdSourcesBySourceIdSyncPolicy: {
queryOptions: policyQueryOptionsMock,
},
postKnowledgeSpacesByIdSourceWorkflowsByRunIdSelection: {
mutationOptions: vi.fn(() => ({
mutationFn: (input: unknown) => clientMock.selectPages(input),
})),
},
putKnowledgeSpacesByIdSourcesBySourceIdSyncPolicy: {
mutationOptions: vi.fn(() => ({
mutationFn: (input: unknown) => clientMock.updatePolicy(input),
})),
spaces: {
byControlSpaceId: {
sources: {
bySourceId: {
syncPolicy: {
get: {
queryOptions: policyQueryOptionsMock,
},
},
},
get: {
key: vi.fn(() => ['knowledge-sources']),
},
},
},
},
},
},
@ -346,13 +406,13 @@ describe('CrawlSelectionForm', () => {
expectedSourceVersion: 3,
mode: 'custom',
},
params: { id: 'space-1', sourceId: 'source-1' },
params: { control_space_id: 'space-1', source_id: 'source-1' },
})
expect(clientMock.selectPages).toHaveBeenCalledOnce()
expect(clientMock.selectPages).toHaveBeenCalledWith({
body: { pageIds: ['page-1'] },
headers: { 'Idempotency-Key': expect.any(String) },
params: { id: 'space-1', runId: 'run-1' },
params: { control_space_id: 'space-1', run_id: 'run-1' },
})
selectionRequest.resolve({ ...run, checkpoint: 'import', state: 'queued' })
@ -499,7 +559,7 @@ describe('CrawlSelectionForm', () => {
expectedSourceVersion: 3,
mode,
},
params: { id: 'space-1', sourceId: 'source-1' },
params: { control_space_id: 'space-1', source_id: 'source-1' },
})
expect(clientMock.selectPages).toHaveBeenCalledOnce()
},
@ -587,7 +647,7 @@ describe('CrawlSelectionForm', () => {
expectedSourceVersion: 4,
mode: 'manual',
},
params: { id: 'space-1', sourceId: 'source-1' },
params: { control_space_id: 'space-1', source_id: 'source-1' },
})
expect(clientMock.selectPages).toHaveBeenCalledOnce()
})
@ -628,7 +688,7 @@ describe('CrawlSelectionForm', () => {
expectedSourceVersion: 3,
mode: 'provider',
},
params: { id: 'space-1', sourceId: 'source-1' },
params: { control_space_id: 'space-1', source_id: 'source-1' },
})
expect(clientMock.selectPages).toHaveBeenCalledOnce()
})

View File

@ -7,8 +7,7 @@ import { newKnowledgeSourceDraftStorageKey } from '../routes'
const serviceMock = vi.hoisted(() => ({
create: vi.fn(),
getPolicy: vi.fn(),
patchPolicy: vi.fn(),
getDefaultModel: vi.fn(),
upload: vi.fn(),
uploadBulk: vi.fn(),
listKey: vi.fn(() => ['console', 'knowledgeFs', 'listKnowledgeSpaces']),
@ -28,6 +27,11 @@ const permissionStateMock = vi.hoisted(() => ({
keys: ['dataset.create_and_management', 'dataset.acl.access_config'],
}))
const systemFeaturesStateMock = vi.hoisted(() => ({
atom: Symbol('knowledgeFsUploadEnabledAtom'),
uploadEnabled: true,
}))
vi.mock('@/next/navigation', () => ({
useRouter: () => routerMock,
useSearchParams: () => ({
@ -39,6 +43,10 @@ vi.mock('@/context/permission-state', () => ({
workspacePermissionKeysAtom: permissionStateMock.atom,
}))
vi.mock('@/context/system-features-state', () => ({
knowledgeFsUploadEnabledAtom: systemFeaturesStateMock.atom,
}))
vi.mock('jotai', async (importOriginal) => {
const original = await importOriginal<typeof import('jotai')>()
return {
@ -46,40 +54,62 @@ vi.mock('jotai', async (importOriginal) => {
useAtomValue: (atom: unknown) =>
atom === permissionStateMock.atom
? permissionStateMock.keys
: original.useAtomValue(atom as Parameters<typeof original.useAtomValue>[0]),
: atom === systemFeaturesStateMock.atom
? systemFeaturesStateMock.uploadEnabled
: original.useAtomValue(atom as Parameters<typeof original.useAtomValue>[0]),
}
})
vi.mock('@/service/client', () => ({
consoleClient: {
knowledgeFs: {
createKnowledgeSpace: serviceMock.create,
getKnowledgeSpacesByIdAccessPolicy: serviceMock.getPolicy,
patchKnowledgeSpacesByIdAccessPolicy: serviceMock.patchPolicy,
postKnowledgeSpacesByIdDocuments: serviceMock.upload,
postKnowledgeSpacesByIdDocumentsBulk: serviceMock.uploadBulk,
spaces: {
post: serviceMock.create,
},
},
workspaces: {
current: {
defaultModel: {
get: serviceMock.getDefaultModel,
},
},
},
},
consoleQuery: {
knowledgeFs: {
listKnowledgeSpaces: {
key: serviceMock.listKey,
spaces: {
get: {
key: serviceMock.listKey,
},
},
},
},
}))
const createdKnowledge = {
configurationStatus: 'ready',
createdAt: '2026-07-20T00:00:00Z',
id: 'e735c1dc-d2b8-4dc4-86dc-abaf2fb7d084',
name: 'Product handbook',
revision: 1,
slug: 'product-handbook',
tenantId: 'tenant-1',
updatedAt: '2026-07-20T00:00:00Z',
control_space_id: 'e735c1dc-d2b8-4dc4-86dc-abaf2fb7d084',
operation_id: 'operation-1',
state: 'provisioning' as const,
}
vi.mock('../knowledge-fs-upload', () => ({
uploadKnowledgeFsDocuments: async (
knowledgeSpaceId: string,
uploads: Array<{ file: File; id: string }>,
) => {
const files = uploads.map(({ file }) => file)
if (files.length === 1)
return serviceMock.upload({
body: { file: files[0] },
params: { control_space_id: knowledgeSpaceId },
})
return serviceMock.uploadBulk({
body: { files },
params: { control_space_id: knowledgeSpaceId },
})
},
}))
function renderPage(
queryClient = new QueryClient({ defaultOptions: { mutations: { retry: false } } }),
) {
@ -110,20 +140,20 @@ describe('CreateKnowledgePage', () => {
vi.clearAllMocks()
globalThis.sessionStorage.clear()
serviceMock.create.mockResolvedValue(createdKnowledge)
serviceMock.getPolicy.mockResolvedValue({
id: 'policy-1',
ownerSubjectId: 'user-1',
partialMemberSubjectIds: [],
revision: 4,
visibility: 'only_me',
})
serviceMock.patchPolicy.mockResolvedValue({
id: 'policy-1',
ownerSubjectId: 'user-1',
partialMemberSubjectIds: [],
revision: 5,
visibility: 'all_members',
})
serviceMock.getDefaultModel.mockImplementation(({ query }: { query: { model_type: string } }) =>
Promise.resolve({
data: {
model: query.model_type === 'llm' ? 'echo' : 'embed',
model_type: query.model_type,
provider: {
provider:
query.model_type === 'llm'
? 'kurokobo/fake_models/fake_models'
: 'langgenius/cohere/cohere',
},
},
}),
)
serviceMock.upload.mockResolvedValue({
id: 'document-1',
})
@ -133,6 +163,7 @@ describe('CreateKnowledgePage', () => {
items: [],
})
permissionStateMock.keys = ['dataset.create_and_management', 'dataset.acl.access_config']
systemFeaturesStateMock.uploadEnabled = true
navigationMock.startMode = null
vi.spyOn(globalThis.crypto, 'randomUUID').mockReturnValue(
'a9c36c57-2d84-44d6-a36d-841f0d92a179',
@ -174,12 +205,29 @@ describe('CreateKnowledgePage', () => {
expect(serviceMock.create).toHaveBeenCalledWith({
body: {
description: 'Internal answers',
idempotencyKey: 'a9c36c57-2d84-44d6-a36d-841f0d92a179',
embedding: {
model: 'embed',
plugin_id: 'langgenius/cohere',
provider: 'cohere',
},
idempotency_key: 'a9c36c57-2d84-44d6-a36d-841f0d92a179',
name: 'Product handbook',
retrieval: {
default_mode: 'fast',
reasoning_model: {
model: 'echo',
plugin_id: 'kurokobo/fake_models',
provider: 'fake_models',
},
rerank: { enabled: false },
score_threshold: { enabled: false, stage: 'mode-final' },
top_k: 10,
},
slug: 'product-handbook-a9c36c572d84',
visibility: 'only_me',
},
})
})
expect(serviceMock.getPolicy).not.toHaveBeenCalled()
expect(invalidate).toHaveBeenCalledWith({
queryKey: ['console', 'knowledgeFs', 'listKnowledgeSpaces'],
})
@ -188,7 +236,7 @@ describe('CreateKnowledgePage', () => {
)
})
it('defaults authorized users to the Figma all-members policy and updates its revision', async () => {
it('creates the default all-members visibility atomically', async () => {
const user = userEvent.setup()
renderPage()
await fillRequiredFields(user)
@ -199,13 +247,8 @@ describe('CreateKnowledgePage', () => {
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.createTitle' }))
await waitFor(() => {
expect(serviceMock.patchPolicy).toHaveBeenCalledWith({
body: {
expectedRevision: 4,
partialMemberSubjectIds: [],
visibility: 'all_members',
},
params: { id: createdKnowledge.id },
expect(serviceMock.create).toHaveBeenCalledWith({
body: expect.objectContaining({ visibility: 'all_team_members' }),
})
})
})
@ -226,7 +269,9 @@ describe('CreateKnowledgePage', () => {
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.createTitle' }))
await waitFor(() => expect(serviceMock.create).toHaveBeenCalledOnce())
expect(serviceMock.getPolicy).not.toHaveBeenCalled()
expect(serviceMock.create).toHaveBeenCalledWith({
body: expect.objectContaining({ visibility: 'only_me' }),
})
})
it('prevents duplicate pending submissions', async () => {
@ -263,11 +308,64 @@ describe('CreateKnowledgePage', () => {
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.createTitle' }))
await waitFor(() => expect(serviceMock.create).toHaveBeenCalledTimes(2))
expect(serviceMock.create.mock.calls[0]?.[0].body.idempotencyKey).toBe(
serviceMock.create.mock.calls[1]?.[0].body.idempotencyKey,
expect(serviceMock.create.mock.calls[0]?.[0].body.idempotency_key).toBe(
serviceMock.create.mock.calls[1]?.[0].body.idempotency_key,
)
})
it('unlocks editable fields and rotates the idempotency key after model preflight fails', async () => {
const user = userEvent.setup()
vi.mocked(globalThis.crypto.randomUUID)
.mockReturnValueOnce('11111111-1111-4111-8111-111111111111')
.mockReturnValueOnce('22222222-2222-4222-8222-222222222222')
serviceMock.getDefaultModel.mockImplementation(({ query }: { query: { model_type: string } }) =>
Promise.resolve(
query.model_type === 'llm'
? {
data: {
model: 'echo',
provider: { provider: 'kurokobo/fake_models/fake_models' },
},
}
: { data: null },
),
)
renderPage()
await fillRequiredFields(user)
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.createTitle' }))
expect(await screen.findByRole('alert')).toHaveTextContent('dataset.newKnowledge.createFailed')
const nameInput = screen.getByRole('textbox', { name: 'dataset.newKnowledge.name' })
expect(nameInput).toBeEnabled()
expect(serviceMock.create).not.toHaveBeenCalled()
serviceMock.getDefaultModel.mockImplementation(({ query }: { query: { model_type: string } }) =>
Promise.resolve({
data: {
model: query.model_type === 'llm' ? 'echo' : 'embed',
provider: {
provider:
query.model_type === 'llm'
? 'kurokobo/fake_models/fake_models'
: 'langgenius/cohere/cohere',
},
},
}),
)
await user.clear(nameInput)
await user.type(nameInput, 'Updated handbook')
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.createTitle' }))
await waitFor(() => expect(serviceMock.create).toHaveBeenCalledOnce())
expect(serviceMock.create).toHaveBeenCalledWith({
body: expect.objectContaining({
idempotency_key: '22222222-2222-4222-8222-222222222222',
name: 'Updated handbook',
}),
})
})
it.each([400, 401, 403, 422])(
'unlocks editable fields and rotates the idempotency key after a definitive %s rejection',
async (status) => {
@ -290,11 +388,11 @@ describe('CreateKnowledgePage', () => {
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.createTitle' }))
await waitFor(() => expect(serviceMock.create).toHaveBeenCalledTimes(2))
expect(serviceMock.create.mock.calls[0]?.[0].body.idempotencyKey).toBe(
expect(serviceMock.create.mock.calls[0]?.[0].body.idempotency_key).toBe(
'11111111-1111-4111-8111-111111111111',
)
expect(serviceMock.create.mock.calls[1]?.[0].body).toMatchObject({
idempotencyKey: '22222222-2222-4222-8222-222222222222',
idempotency_key: '22222222-2222-4222-8222-222222222222',
name: 'Updated handbook',
})
},
@ -319,23 +417,30 @@ describe('CreateKnowledgePage', () => {
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.createTitle' }))
await waitFor(() => expect(serviceMock.create).toHaveBeenCalledTimes(2))
expect(serviceMock.create.mock.calls[0]?.[0].body.idempotencyKey).toBe(
serviceMock.create.mock.calls[1]?.[0].body.idempotencyKey,
expect(serviceMock.create.mock.calls[0]?.[0].body.idempotency_key).toBe(
serviceMock.create.mock.calls[1]?.[0].body.idempotency_key,
)
},
)
it('safely resumes the permission step after a partial failure', async () => {
it('safely resumes a downstream upload after the control space is created', async () => {
const user = userEvent.setup()
navigationMock.startMode = 'upload'
const queryClient = new QueryClient({ defaultOptions: { mutations: { retry: false } } })
const invalidate = vi.spyOn(queryClient, 'invalidateQueries')
serviceMock.patchPolicy.mockRejectedValueOnce(new Error('policy update unavailable'))
serviceMock.upload.mockRejectedValueOnce(new Error('upload unavailable'))
renderPage(queryClient)
await user.upload(
screen.getByLabelText('dataset.newKnowledge.uploadFiles', {
selector: 'input[type="file"]',
}),
new File(['content'], 'handbook.md', { type: 'text/markdown' }),
)
await fillRequiredFields(user)
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.createTitle' }))
expect(await screen.findByRole('alert')).toHaveTextContent(
'dataset.newKnowledge.permissionUpdateFailed',
'dataset.newKnowledge.documentUploadFailed',
)
expect(invalidate).toHaveBeenCalledWith({
queryKey: ['console', 'knowledgeFs', 'listKnowledgeSpaces'],
@ -346,44 +451,28 @@ describe('CreateKnowledgePage', () => {
await user.type(nameInput, ' changed')
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.createTitle' }))
await waitFor(() => expect(serviceMock.patchPolicy).toHaveBeenCalledTimes(2))
await waitFor(() => expect(serviceMock.upload).toHaveBeenCalledTimes(2))
expect(serviceMock.create).toHaveBeenCalledOnce()
expect(routerMock.replace).toHaveBeenCalledWith(
'/datasets/new/e735c1dc-d2b8-4dc4-86dc-abaf2fb7d084/sources',
'/datasets/new/e735c1dc-d2b8-4dc4-86dc-abaf2fb7d084/documents',
)
})
it('converges after a permission update succeeds but its response is lost', async () => {
it('converges after an atomic creation response is lost', async () => {
const user = userEvent.setup()
serviceMock.getPolicy
.mockResolvedValueOnce({
id: 'policy-1',
ownerSubjectId: 'user-1',
partialMemberSubjectIds: [],
revision: 4,
visibility: 'only_me',
})
.mockResolvedValueOnce({
id: 'policy-1',
ownerSubjectId: 'user-1',
partialMemberSubjectIds: [],
revision: 5,
visibility: 'all_members',
})
serviceMock.patchPolicy.mockRejectedValueOnce(new Error('response lost'))
serviceMock.create.mockRejectedValueOnce(new Error('response lost'))
renderPage()
await fillRequiredFields(user)
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.createTitle' }))
expect(await screen.findByRole('alert')).toHaveTextContent(
'dataset.newKnowledge.permissionUpdateFailed',
)
expect(await screen.findByRole('alert')).toHaveTextContent('dataset.newKnowledge.createFailed')
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.createTitle' }))
await waitFor(() => expect(routerMock.replace).toHaveBeenCalledOnce())
expect(serviceMock.create).toHaveBeenCalledOnce()
expect(serviceMock.getPolicy).toHaveBeenCalledTimes(2)
expect(serviceMock.patchPolicy).toHaveBeenCalledOnce()
expect(serviceMock.create).toHaveBeenCalledTimes(2)
expect(serviceMock.create.mock.calls[0]?.[0].body.idempotency_key).toBe(
serviceMock.create.mock.calls[1]?.[0].body.idempotency_key,
)
})
it('keeps every start mode interactive without simulating backend success', async () => {
@ -460,6 +549,21 @@ describe('CreateKnowledgePage', () => {
expect(screen.getByRole('button', { name: 'dataset.newKnowledge.createTitle' })).toBeDisabled()
})
it('disables upload before creating a space when direct upload is unavailable', () => {
navigationMock.startMode = 'upload'
systemFeaturesStateMock.uploadEnabled = false
renderPage()
expect(screen.getByRole('radio', { name: 'dataset.newKnowledge.startEmpty' })).toBeChecked()
const uploadFiles = screen.getByRole('radio', { name: 'dataset.newKnowledge.uploadFiles' })
expect(uploadFiles).toBeDisabled()
expect(uploadFiles).toHaveAccessibleDescription(
'dataset.newKnowledge.uploadFilesDescription dataset.cornerLabel.unavailable',
)
expect(serviceMock.create).not.toHaveBeenCalled()
})
it('continues from the upload mode after real creation succeeds', async () => {
const user = userEvent.setup()
navigationMock.startMode = 'upload'
@ -483,7 +587,7 @@ describe('CreateKnowledgePage', () => {
)
expect(serviceMock.upload).toHaveBeenCalledWith({
body: { file: expect.objectContaining({ name: 'handbook.md' }) },
params: { id: createdKnowledge.id },
params: { control_space_id: createdKnowledge.control_space_id },
})
})
@ -533,6 +637,12 @@ describe('CreateKnowledgePage', () => {
const maxPages = screen.getByRole('spinbutton', { name: 'dataset.newKnowledge.maxPages' })
await user.clear(maxPages)
await user.type(maxPages, '25')
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.crawlOptions' }))
expect(
screen.getByText(
'dataset.newKnowledge.includeSubpages: dataset.newKnowledge.booleanFalse · dataset.newKnowledge.maxPages: 25',
),
).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.createTitle' }))
await waitFor(() =>
@ -895,14 +1005,19 @@ describe('CreateKnowledgePage', () => {
it('warns before leaving a partially created knowledge space', async () => {
const user = userEvent.setup()
serviceMock.patchPolicy.mockRejectedValueOnce(new Error('policy update unavailable'))
navigationMock.startMode = 'upload'
serviceMock.upload.mockRejectedValueOnce(new Error('upload unavailable'))
renderPage()
await user.upload(
screen.getByLabelText('dataset.newKnowledge.uploadFiles', {
selector: 'input[type="file"]',
}),
new File(['content'], 'handbook.md', { type: 'text/markdown' }),
)
await fillRequiredFields(user)
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.createTitle' }))
expect(
await screen.findByText('dataset.newKnowledge.permissionUpdateFailed'),
).toBeInTheDocument()
expect(await screen.findByText('dataset.newKnowledge.documentUploadFailed')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'common.operation.cancel' }))

View File

@ -0,0 +1,131 @@
import { createKnowledge, isDefinitiveCreationRejection } from '../create-knowledge-workflow'
const serviceMock = vi.hoisted(() => ({
createSpace: vi.fn(),
getDefaultModel: vi.fn(),
}))
vi.mock('@/service/client', () => ({
consoleClient: {
knowledgeFs: {
spaces: {
post: serviceMock.createSpace,
},
},
workspaces: {
current: {
defaultModel: {
get: serviceMock.getDefaultModel,
},
},
},
},
}))
describe('createKnowledge', () => {
beforeEach(() => {
vi.clearAllMocks()
serviceMock.getDefaultModel.mockImplementation(
({ query }: { query: { model_type: 'llm' | 'text-embedding' } }) =>
Promise.resolve({
data: {
model: query.model_type === 'llm' ? 'reasoning-model' : 'embedding-model',
provider: {
provider:
query.model_type === 'llm'
? 'langgenius/openai/openai'
: 'langgenius/cohere/cohere',
},
},
}),
)
serviceMock.createSpace.mockResolvedValue({
control_space_id: 'control-space-1',
operation_id: 'operation-1',
state: 'provisioning',
})
})
it('creates a control space with the new model intent and visibility', async () => {
const onCreated = vi.fn()
await expect(
createKnowledge({
description: 'Product docs',
idempotencyKey: '11111111-1111-4111-8111-111111111111',
name: 'Dify Product Docs',
onCreated,
visibility: 'all_team_members',
}),
).resolves.toEqual({
control_space_id: 'control-space-1',
operation_id: 'operation-1',
state: 'provisioning',
})
expect(serviceMock.createSpace).toHaveBeenCalledWith({
body: {
description: 'Product docs',
embedding: {
model: 'embedding-model',
plugin_id: 'langgenius/cohere',
provider: 'cohere',
},
idempotency_key: '11111111-1111-4111-8111-111111111111',
name: 'Dify Product Docs',
retrieval: {
default_mode: 'fast',
reasoning_model: {
model: 'reasoning-model',
plugin_id: 'langgenius/openai',
provider: 'openai',
},
rerank: { enabled: false },
score_threshold: { enabled: false, stage: 'mode-final' },
top_k: 10,
},
slug: expect.stringMatching(/^dify-product-docs-[a-z0-9]+$/),
visibility: 'all_team_members',
},
})
expect(onCreated).toHaveBeenCalledWith({
control_space_id: 'control-space-1',
operation_id: 'operation-1',
state: 'provisioning',
})
})
it('requires both default models before creating the control space', async () => {
serviceMock.getDefaultModel.mockImplementation(
({ query }: { query: { model_type: 'llm' | 'text-embedding' } }) =>
Promise.resolve(
query.model_type === 'llm'
? {
data: {
model: 'reasoning-model',
provider: { provider: 'langgenius/openai/openai' },
},
}
: { data: null },
),
)
await expect(
createKnowledge({
description: '',
idempotencyKey: '22222222-2222-4222-8222-222222222222',
name: '知识库',
onCreated: vi.fn(),
visibility: 'only_me',
}),
).rejects.toMatchObject({ name: 'KnowledgeCreationError', stage: 'preflight' })
expect(serviceMock.createSpace).not.toHaveBeenCalled()
})
})
describe('isDefinitiveCreationRejection', () => {
it('only treats client authorization and validation failures as definitive', () => {
expect(isDefinitiveCreationRejection({ status: 422 })).toBe(true)
expect(isDefinitiveCreationRejection({ status: 503 })).toBe(false)
})
})

View File

@ -2,7 +2,7 @@ import type {
DocumentRevisionChunk,
LogicalDocument,
LogicalDocumentRevision,
} from '@dify/contracts/knowledge-fs/types.gen'
} from '../document-models'
import {
buildDocumentChunkTree,
chunkCharacterCount,

View File

@ -1,19 +1,30 @@
import type {
BulkDocumentReindexResult,
DocumentProcessingTask,
DocumentRevisionChunk,
LogicalDocument,
LogicalDocumentRevision,
} from '@dify/contracts/knowledge-fs/types.gen'
} from '../document-models'
import { act, fireEvent, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import copy from 'copy-to-clipboard'
import { renderWithNuqs as render } from '@/test/nuqs-testing'
import { DocumentDetailPage } from '../document-detail-page'
type BulkDocumentReindexResult = {
bulkJobId: string
items: Array<{
asset?: unknown
compilationJob?: unknown
documentId?: string
status: 'not_found' | 'queued'
statusUrl?: string
}>
total: number
}
type InfiniteOptions = {
enabled?: boolean
getNextPageParam: (lastPage: { nextCursor?: string }) => string | undefined
getNextPageParam: (lastPage: { next_cursor?: string | null }) => string | null | undefined
input: (pageParam: string | null) => unknown
initialPageParam: string | null
queryKind: 'chunks' | 'revisions' | 'tasks'
@ -31,18 +42,6 @@ const documentQuery = vi.hoisted(() => ({
refetch: vi.fn(),
}))
const taskSnapshotQuery = vi.hoisted(() => ({
data: undefined as DocumentProcessingTask | undefined,
error: null as unknown,
refetch: vi.fn(),
}))
const submissionTasksQuery = vi.hoisted(() => ({
data: undefined as { items: DocumentProcessingTask[] } | undefined,
error: null as unknown,
refetch: vi.fn(),
}))
const revisionsQuery = vi.hoisted(() => ({
data: undefined as
| { pages: Array<{ items: LogicalDocumentRevision[]; nextCursor?: string }> }
@ -92,10 +91,61 @@ const reindexMutation = vi.hoisted(() => ({ mutateAsync: vi.fn() }))
const queryClient = vi.hoisted(() => ({
invalidateQueries: vi.fn(),
removeQueries: vi.fn(),
setQueryData: vi.fn(),
}))
const toastState = vi.hoisted(() => ({ error: vi.fn(), info: vi.fn(), success: vi.fn() }))
const virtualizerState = vi.hoisted(() => ({ scrollToIndex: vi.fn() }))
const revisionApiResponse = vi.hoisted(
() => (revision: Exclude<LogicalDocumentRevision, null>) => ({
activated_at: revision.activatedAt ?? null,
content_hash: revision.contentHash,
created_at: revision.createdAt,
document_asset_id: revision.documentAssetId,
document_asset_version: revision.documentAssetVersion,
document_id: revision.documentId,
knowledge_space_id: revision.knowledgeSpaceId,
mime_type: revision.mimeType,
revision: revision.revision,
size_bytes: revision.sizeBytes,
state: revision.state,
}),
)
const chunkApiResponse = vi.hoisted(() => (item: DocumentRevisionChunk) => ({
created_at: item.createdAt,
document_id: item.documentId,
document_revision: item.documentRevision,
enabled: item.enabled,
id: item.id,
knowledge_space_id: item.knowledgeSpaceId,
ordinal: item.ordinal,
parent_chunk_id: item.parentChunkId ?? null,
text: item.text,
token_count: item.tokenCount,
user_metadata: item.userMetadata,
}))
const taskApiResponse = vi.hoisted(() => (item: DocumentProcessingTask) => ({
can_cancel: item.canCancel ?? true,
can_retry: item.canRetry ?? item.state === 'failed',
completed_at: item.completedAt ?? null,
created_at: item.createdAt,
document_id: item.documentId,
document_revision: item.documentRevision,
error_code: item.errorCode ?? null,
error_message: item.errorMessage ?? null,
id: item.id,
knowledge_space_id: item.knowledgeSpaceId,
operation: item.operation ?? 'document_processing',
progress_percent: item.progressPercent,
state:
item.state === 'succeeded'
? 'completed'
: item.state === 'dispatch_pending'
? 'queued'
: item.state === 'superseded'
? 'canceled'
: item.state,
task_kind: item.taskKind ?? 'document',
updated_at: item.updatedAt,
}))
const documentOptions = vi.hoisted(() =>
vi.fn((options: object) => ({
...options,
@ -103,23 +153,6 @@ const documentOptions = vi.hoisted(() =>
queryKind: 'document',
})),
)
const taskSnapshotOptions = vi.hoisted(() =>
vi.fn((options: object) => ({ ...options, queryKind: 'task-snapshot' })),
)
const documentSubmissionTasksOptions = vi.hoisted(() =>
vi.fn((options: object) => ({
...options,
queryKey: ['knowledge-fs', 'submission-tasks', 'space-1', 'document-1'],
queryKind: 'submission-tasks',
})),
)
const workspaceSubmissionTasksOptions = vi.hoisted(() =>
vi.fn((options: object) => ({
...options,
queryKey: ['knowledge-fs', 'workspace-submission-tasks', 'space-1'],
queryKind: 'submission-tasks',
})),
)
const revisionsOptions = vi.hoisted(() =>
vi.fn((options: Omit<InfiniteOptions, 'queryKind'>) => ({
...options,
@ -141,13 +174,6 @@ const documentTasksOptions = vi.hoisted(() =>
queryKind: 'tasks',
})),
)
const workspaceTasksOptions = vi.hoisted(() =>
vi.fn((options: Omit<InfiniteOptions, 'queryKind'>) => ({
...options,
queryKey: ['knowledge-fs', 'workspace-tasks', 'space-1'],
queryKind: 'tasks',
})),
)
vi.mock('jotai', async (importOriginal) => {
const original = await importOriginal<typeof import('jotai')>()
@ -197,22 +223,51 @@ vi.mock('@tanstack/react-query', async (importOriginal) => {
return {
...original,
useInfiniteQuery: (options: InfiniteOptions) => {
if (options.queryKind === 'revisions') return revisionsQuery
if (options.queryKind === 'revisions')
return {
...revisionsQuery,
data: revisionsQuery.data
? {
pages: revisionsQuery.data.pages.map((page) => ({
data: page.items.flatMap((revision) =>
revision ? [revisionApiResponse(revision)] : [],
),
next_cursor: page.nextCursor ?? null,
})),
}
: undefined,
}
if (
options.queryKind === 'chunks' ||
('queryKey' in options &&
Array.isArray(options.queryKey) &&
options.queryKey.includes('chunks'))
)
return chunksQuery
return tasksQuery
return {
...chunksQuery,
data: chunksQuery.data
? {
pages: chunksQuery.data.pages.map((page) => ({
data: page.items.map(chunkApiResponse),
next_cursor: page.nextCursor ?? null,
})),
}
: undefined,
}
return {
...tasksQuery,
data: tasksQuery.data
? {
pages: tasksQuery.data.pages.map((page) => ({
data: page.items.map(taskApiResponse),
next_cursor: page.nextCursor ?? null,
})),
}
: undefined,
}
},
useMutation: () => reindexMutation,
useQuery: (options: { queryKind?: string }) => {
if (options.queryKind === 'task-snapshot') return taskSnapshotQuery
if (options.queryKind === 'submission-tasks') return submissionTasksQuery
return documentQuery
},
useQuery: () => documentQuery,
useQueryClient: () => queryClient,
}
})
@ -220,33 +275,46 @@ vi.mock('@tanstack/react-query', async (importOriginal) => {
vi.mock('@/service/client', () => ({
consoleQuery: {
knowledgeFs: {
getKnowledgeSpacesByIdDocumentsByDocumentIdRevisions: {
infiniteOptions: revisionsOptions,
key: () => ['knowledge-fs', 'revisions'],
},
getKnowledgeSpacesByIdDocumentsByDocumentIdRevisionsByRevisionChunks: {
infiniteOptions: chunksOptions,
key: () => ['knowledge-fs', 'chunks'],
},
getKnowledgeSpacesByIdLogicalDocumentsByDocumentId: {
queryOptions: documentOptions,
key: () => ['knowledge-fs', 'document'],
},
getKnowledgeSpacesByIdDocumentsByDocumentIdProcessingTasksByTaskId: {
queryOptions: taskSnapshotOptions,
},
getKnowledgeSpacesByIdDocumentsByDocumentIdProcessingTasks: {
infiniteOptions: documentTasksOptions,
key: () => ['knowledge-fs', 'tasks'],
queryOptions: documentSubmissionTasksOptions,
},
getKnowledgeSpacesByIdProcessingTasks: {
infiniteOptions: workspaceTasksOptions,
key: () => ['knowledge-fs', 'workspace-tasks'],
queryOptions: workspaceSubmissionTasksOptions,
},
postKnowledgeSpacesByIdDocumentsBulkReindex: {
mutationOptions: () => ({}),
spaces: {
byControlSpaceId: {
backgroundTasks: {
get: {
infiniteOptions: documentTasksOptions,
key: () => ['knowledge-fs', 'tasks'],
},
},
documents: {
byDocumentId: {
revisions: {
byRevision: {
chunks: {
get: {
infiniteOptions: chunksOptions,
key: () => ['knowledge-fs', 'chunks'],
},
},
},
get: {
infiniteOptions: revisionsOptions,
key: () => ['knowledge-fs', 'revisions'],
},
},
},
reindex: {
post: {
mutationOptions: () => ({}),
},
},
},
logicalDocuments: {
byDocumentId: {
get: {
queryOptions: documentOptions,
key: () => ['knowledge-fs', 'document'],
},
},
},
},
},
},
},
@ -363,10 +431,6 @@ describe('DocumentDetailPage', () => {
tasksQuery.isFetchNextPageError = false
tasksQuery.isFetchingNextPage = false
tasksQuery.isPending = false
taskSnapshotQuery.data = undefined
taskSnapshotQuery.error = null
submissionTasksQuery.data = undefined
submissionTasksQuery.error = null
permissionState.refresh.mockResolvedValue({
data: { dataset: { default_permission_keys: ['dataset.acl.edit'] } },
error: null,
@ -380,24 +444,28 @@ describe('DocumentDetailPage', () => {
expect(documentOptions).toHaveBeenCalledWith(
expect.objectContaining({
input: { params: { documentId: 'document-1', id: 'space-1' } },
input: {
params: { control_space_id: 'space-1', document_id: 'document-1' },
},
retry: expect.any(Function),
}),
)
expect(infiniteInput(revisionsOptions.mock.lastCall?.[0])(null)).toEqual({
params: { documentId: 'document-1', id: 'space-1' },
query: { limit: 50 },
params: { control_space_id: 'space-1', document_id: 'document-1' },
query: {},
})
expect(infiniteInput(chunksOptions.mock.lastCall?.[0])('next')).toEqual({
params: { documentId: 'document-1', id: 'space-1', revision: 3 },
query: { cursor: 'next', limit: 100 },
params: {
control_space_id: 'space-1',
document_id: 'document-1',
revision: 3,
},
query: { cursor: 'next' },
})
expect(infiniteInput(documentTasksOptions.mock.lastCall?.[0])(null)).toEqual({
params: { documentId: 'document-1', id: 'space-1' },
params: { control_space_id: 'space-1' },
query: { limit: 100 },
})
expect(workspaceTasksOptions).not.toHaveBeenCalled()
expect(workspaceSubmissionTasksOptions).not.toHaveBeenCalled()
})
it('does not construct a chunks request while the document is loading', () => {
@ -602,8 +670,12 @@ describe('DocumentDetailPage', () => {
screen.getByRole('combobox', { name: 'dataset.newKnowledge.documentRevision' }),
).toHaveTextContent('v2')
expect(infiniteInput(chunksOptions.mock.lastCall?.[0])(null)).toEqual({
params: { documentId: 'document-1', id: 'space-1', revision: 2 },
query: { limit: 100 },
params: {
control_space_id: 'space-1',
document_id: 'document-1',
revision: 2,
},
query: {},
})
})
@ -684,19 +756,31 @@ describe('DocumentDetailPage', () => {
).toBeEnabled()
})
it('polls only the discovered active task through the single-task contract', () => {
it('polls active work through the unified background-task contract', () => {
tasksQuery.data = { pages: [{ items: [task({ state: 'running' })] }] }
render(<DocumentDetailPage documentId="document-1" knowledgeSpaceId="space-1" />)
expect(taskSnapshotOptions).toHaveBeenCalledWith(
expect.objectContaining({
enabled: true,
input: {
params: { documentId: 'document-1', id: 'space-1', taskId: 'task-1' },
const taskOptions = documentTasksOptions.mock.lastCall?.[0] as unknown as {
refetchInterval: (query: {
state: {
data?: {
pages: Array<{
data: Array<ReturnType<typeof taskApiResponse>>
next_cursor: string | null
}>
}
}
}) => number | false
}
expect(
taskOptions.refetchInterval({
state: {
data: {
pages: [{ data: [taskApiResponse(task({ state: 'running' }))], next_cursor: null }],
},
},
refetchInterval: expect.any(Function),
}),
)
).toBe(5000)
expect(tasksQuery.refetch).not.toHaveBeenCalled()
})
@ -705,7 +789,7 @@ describe('DocumentDetailPage', () => {
const rendered = render(
<DocumentDetailPage documentId="document-1" knowledgeSpaceId="space-1" />,
)
taskSnapshotQuery.data = task({ state: 'succeeded' })
tasksQuery.data = { pages: [{ items: [task({ state: 'succeeded' })] }] }
rendered.rerender(<DocumentDetailPage documentId="document-1" knowledgeSpaceId="space-1" />)
await waitFor(() => expect(queryClient.invalidateQueries).toHaveBeenCalledTimes(4))
@ -849,7 +933,7 @@ describe('DocumentDetailPage', () => {
expect(reindexMutation.mutateAsync).toHaveBeenCalledWith({
body: { documentIds: ['document-1'] },
params: { id: 'space-1' },
params: { control_space_id: 'space-1' },
})
await waitFor(() => expect(queryClient.invalidateQueries).toHaveBeenCalled())
@ -995,25 +1079,48 @@ describe('DocumentDetailPage', () => {
await user.click(button)
expect(reindexMutation.mutateAsync).toHaveBeenCalledOnce()
const discoveryOptions = documentSubmissionTasksOptions.mock.lastCall?.[0] as unknown as {
const discoveryOptions = documentTasksOptions.mock.lastCall?.[0] as unknown as {
refetchInterval: (query: {
state: { data?: { items: DocumentProcessingTask[] }; error?: unknown }
state: {
data?: {
pages: Array<{
data: Array<ReturnType<typeof taskApiResponse>>
next_cursor: string | null
}>
}
}
}) => number | false
retry: (failureCount: number, error: unknown) => boolean
}
expect(discoveryOptions.refetchInterval({ state: { data: { items: [] } } })).toBe(2000)
expect(
discoveryOptions.refetchInterval({
state: { data: { items: [task({ documentRevision: 5 })] } },
state: {
data: {
pages: [
{
data: [
taskApiResponse(task({ documentRevision: 4, id: 'old-failed', state: 'failed' })),
],
next_cursor: null,
},
],
},
},
}),
).toBe(false)
).toBe(2000)
expect(
discoveryOptions.refetchInterval({
state: { data: { items: [] }, error: { status: 403 } },
state: {
data: {
pages: [
{
data: [taskApiResponse(task({ documentRevision: 5 }))],
next_cursor: null,
},
],
},
},
}),
).toBe(false)
expect(discoveryOptions.retry(0, { status: 403 })).toBe(false)
expect(discoveryOptions.retry(0, { status: 404 })).toBe(false)
).toBe(5000)
})
it('keeps submission protection while a delayed status recheck is unresolved', async () => {
@ -1028,9 +1135,7 @@ describe('DocumentDetailPage', () => {
}),
)
try {
const rendered = render(
<DocumentDetailPage documentId="document-1" knowledgeSpaceId="space-1" />,
)
render(<DocumentDetailPage documentId="document-1" knowledgeSpaceId="space-1" />)
const reindexButton = screen.getByRole('button', {
name: 'dataset.newKnowledge.reindexDocument',
})
@ -1039,21 +1144,10 @@ describe('DocumentDetailPage', () => {
await Promise.resolve()
await Promise.resolve()
})
submissionTasksQuery.error = new Error('submission discovery failed')
rendered.rerender(<DocumentDetailPage documentId="document-1" knowledgeSpaceId="space-1" />)
await act(() => vi.advanceTimersByTimeAsync(30000))
const alert = screen.getByRole('alert')
expect(alert).toHaveTextContent('dataset.newKnowledge.documentReindexConfirmationDelayed')
const timedOutDiscoveryOptions = documentSubmissionTasksOptions.mock
.lastCall?.[0] as unknown as {
refetchInterval: (query: {
state: { data?: { items: DocumentProcessingTask[] }; error?: unknown }
}) => number | false
}
expect(timedOutDiscoveryOptions.refetchInterval({ state: { data: { items: [] } } })).toBe(
false,
)
fireEvent.click(
within(alert).getByRole('button', {
name: 'dataset.newKnowledge.checkReindexStatus',
@ -1061,7 +1155,7 @@ describe('DocumentDetailPage', () => {
)
expect(reindexButton).toHaveAttribute('data-disabled')
expect(reindexMutation.mutateAsync).toHaveBeenCalledOnce()
expect(submissionTasksQuery.refetch).toHaveBeenCalledOnce()
expect(tasksQuery.refetch).toHaveBeenCalledOnce()
await act(async () => {
finishTaskRefresh?.()
@ -1081,14 +1175,30 @@ describe('DocumentDetailPage', () => {
})
expect(reindexMutation.mutateAsync).toHaveBeenCalledTimes(2)
expect(screen.getByRole('heading', { level: 1 })).toHaveFocus()
const discoveryOptions = documentSubmissionTasksOptions.mock.lastCall?.[0] as unknown as {
const discoveryOptions = documentTasksOptions.mock.lastCall?.[0] as unknown as {
refetchInterval: (query: {
state: { data?: { items: DocumentProcessingTask[] } }
state: {
data?: {
pages: Array<{
data: Array<ReturnType<typeof taskApiResponse>>
next_cursor: string | null
}>
}
}
}) => number | false
}
expect(
discoveryOptions.refetchInterval({
state: { data: { items: [task({ documentRevision: 4, state: 'failed' })] } },
state: {
data: {
pages: [
{
data: [taskApiResponse(task({ documentRevision: 4, state: 'failed' }))],
next_cursor: null,
},
],
},
},
}),
).toBe(2000)
} finally {
@ -1134,22 +1244,29 @@ describe('DocumentDetailPage', () => {
await Promise.resolve()
})
const discoveryOptions = documentSubmissionTasksOptions.mock.lastCall?.[0] as unknown as {
refetchInterval: (query: {
state: { data?: { items: DocumentProcessingTask[] }; error?: unknown }
}) => number | false
tasksQuery.data = {
pages: [
{
items: [
task({
documentRevision: 5,
id: 'late-first',
state: 'succeeded',
}),
],
},
],
}
rendered.rerender(<DocumentDetailPage documentId="document-1" knowledgeSpaceId="space-1" />)
expect(
discoveryOptions.refetchInterval({
state: { data: { items: [task({ documentRevision: 5, id: 'late-first' })] } },
}),
).toBe(2000)
screen.getByRole('button', { name: 'dataset.newKnowledge.reindexDocument' }),
).toHaveAttribute('data-disabled')
} finally {
vi.useRealTimers()
}
})
it('stops first-page submission discovery when task history observes the new task', async () => {
it('uses active-task polling after the unified task list observes the new task', async () => {
const user = userEvent.setup()
const rendered = render(
<DocumentDetailPage documentId="document-1" knowledgeSpaceId="space-1" />,
@ -1162,61 +1279,82 @@ describe('DocumentDetailPage', () => {
}
rendered.rerender(<DocumentDetailPage documentId="document-1" knowledgeSpaceId="space-1" />)
expect(documentSubmissionTasksOptions.mock.lastCall?.[0]).toMatchObject({ enabled: false })
const taskOptions = documentTasksOptions.mock.lastCall?.[0] as unknown as {
refetchInterval: (query: {
state: {
data?: {
pages: Array<{
data: Array<ReturnType<typeof taskApiResponse>>
next_cursor: string | null
}>
}
}
}) => number | false
}
expect(
taskOptions.refetchInterval({
state: {
data: {
pages: [
{
data: [taskApiResponse(task({ documentRevision: 4, state: 'running' }))],
next_cursor: null,
},
],
},
},
}),
).toBe(5000)
})
it('stops snapshot polling and distrusts stale active task data after 403 or 404', async () => {
tasksQuery.data = { pages: [{ items: [task({ state: 'running' })] }] }
taskSnapshotQuery.error = { status: 404 }
it('surfaces unified task-list authorization failures and blocks re-indexing', () => {
tasksQuery.data = undefined
tasksQuery.error = { status: 403 }
const rendered = render(
<DocumentDetailPage documentId="document-1" knowledgeSpaceId="space-1" />,
)
expect(screen.queryByRole('status')).toBeNull()
const snapshotOptions = taskSnapshotOptions.mock.lastCall?.[0] as {
refetchInterval: (query: {
state: { data?: DocumentProcessingTask; error?: unknown }
}) => number | false
}
expect(snapshotOptions.refetchInterval({ state: { error: { status: 404 } } })).toBe(false)
await waitFor(() => expect(queryClient.invalidateQueries).toHaveBeenCalled())
queryClient.invalidateQueries.mockClear()
taskSnapshotQuery.error = { status: 403 }
rendered.rerender(<DocumentDetailPage documentId="document-1" knowledgeSpaceId="space-1" />)
expect(screen.getByRole('alert')).toHaveTextContent(
'dataset.newKnowledge.tasksErrorDescription',
)
expect(
screen.getByRole('button', { name: 'dataset.newKnowledge.reindexDocument' }),
).toHaveAttribute('data-disabled')
const taskOptions = documentTasksOptions.mock.lastCall?.[0] as unknown as {
refetchInterval: (query: {
state: {
data?: {
pages: Array<{
data: Array<ReturnType<typeof taskApiResponse>>
next_cursor: string | null
}>
}
error?: unknown
}
}) => number | false
}
expect(taskOptions.refetchInterval({ state: { error: { status: 403 } } })).toBe(false)
tasksQuery.error = { status: 404 }
rendered.rerender(<DocumentDetailPage documentId="document-1" knowledgeSpaceId="space-1" />)
expect(screen.getByRole('alert')).toHaveTextContent(
'dataset.newKnowledge.tasksErrorDescription',
)
})
it('clears both task caches when a submission-discovered task snapshot returns 404', async () => {
submissionTasksQuery.data = { items: [task({ state: 'running' })] }
taskSnapshotQuery.error = { status: 404 }
it('recovers task state directly from the unified task list', () => {
tasksQuery.data = undefined
tasksQuery.error = { status: 404 }
const rendered = render(
<DocumentDetailPage documentId="document-1" knowledgeSpaceId="space-1" />,
)
await waitFor(() => expect(queryClient.invalidateQueries).toHaveBeenCalledTimes(2))
expect(queryClient.invalidateQueries).toHaveBeenCalledWith({
queryKey: ['knowledge-fs', 'tasks', 'space-1', 'document-1'],
})
expect(queryClient.invalidateQueries).toHaveBeenCalledWith({
queryKey: ['knowledge-fs', 'submission-tasks', 'space-1', 'document-1'],
})
expect(queryClient.setQueryData).toHaveBeenCalledTimes(2)
submissionTasksQuery.data = { items: [task({ id: 'missing-task-2', state: 'running' })] }
tasksQuery.error = null
tasksQuery.data = { pages: [{ items: [task({ state: 'running' })] }] }
rendered.rerender(<DocumentDetailPage documentId="document-1" knowledgeSpaceId="space-1" />)
await waitFor(() => expect(queryClient.invalidateQueries).toHaveBeenCalledTimes(4))
expect(queryClient.setQueryData).toHaveBeenCalledTimes(4)
submissionTasksQuery.data = { items: [task({ state: 'running' })] }
rendered.rerender(<DocumentDetailPage documentId="document-1" knowledgeSpaceId="space-1" />)
await waitFor(() => expect(queryClient.setQueryData).toHaveBeenCalledTimes(4))
expect(screen.getByRole('status')).toHaveTextContent(
'dataset.newKnowledge.documentReindexProgress:{"progress":"45"}',
)
})
it('refreshes stale detail and task-list caches for a newer terminal task on revisit', async () => {

View File

@ -1,7 +1,4 @@
import type {
DocumentProcessingTask,
LogicalDocument,
} from '@dify/contracts/knowledge-fs/types.gen'
import type { DocumentProcessingTask, LogicalDocument } from '../document-models'
import {
documentDisplayStatus,
newestTaskByDocument,

View File

@ -1,8 +1,5 @@
import type {
DocumentProcessingTask,
LogicalDocument,
Source,
} from '@dify/contracts/knowledge-fs/types.gen'
import type { DocumentProcessingTask, LogicalDocument } from '../document-models'
import type { Source } from '../source-models'
import { hashKey } from '@tanstack/react-query'
import { act, fireEvent, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
@ -12,7 +9,7 @@ import { TaskEventObserver } from '../task-event-observer'
type InfiniteOptions = {
enabled?: boolean
getNextPageParam: (lastPage: { nextCursor?: string }) => string | undefined
getNextPageParam: (lastPage: { next_cursor?: string | null }) => string | null | undefined
input: (pageParam: string | null) => unknown
initialPageParam: string | null
queryKind: 'documents' | 'sources' | 'tasks'
@ -93,6 +90,12 @@ const queryClient = vi.hoisted(() => ({
}))
const streamProcessingTaskEvents = vi.hoisted(() => vi.fn())
const getTaskSnapshot = vi.hoisted(() => vi.fn())
const taskSnapshotRequestState = vi.hoisted(() => ({ index: 0 }))
const rawQueryDataCache = vi.hoisted(() => ({
documents: new WeakMap<object, object>(),
sources: new WeakMap<object, object>(),
tasks: new WeakMap<object, object>(),
}))
const permissionStateMock = vi.hoisted(() => ({
datasetAtom: Symbol('datasetDefaultPermissionKeysAtom'),
datasetKeys: ['dataset.acl.edit'],
@ -107,12 +110,84 @@ const permissionStateMock = vi.hoisted(() => ({
refreshAfterDenial: vi.fn(),
refreshAfterDenialAtom: Symbol('refreshWorkspacePermissionKeysAfterMutationDenialAtom'),
}))
const systemFeaturesStateMock = vi.hoisted(() => ({
atom: Symbol('knowledgeFsUploadEnabledAtom'),
uploadEnabled: true,
}))
const toastMock = vi.hoisted(() => ({
error: vi.fn(),
info: vi.fn(),
success: vi.fn(),
warning: vi.fn(),
}))
const revisionApiResponse = vi.hoisted(
() => (revision: NonNullable<LogicalDocument['active']>) => ({
activated_at: revision.activatedAt ?? null,
content_hash: revision.contentHash,
created_at: revision.createdAt,
document_asset_id: revision.documentAssetId,
document_asset_version: revision.documentAssetVersion,
document_id: revision.documentId,
knowledge_space_id: revision.knowledgeSpaceId,
mime_type: revision.mimeType,
revision: revision.revision,
size_bytes: revision.sizeBytes,
state: revision.state,
}),
)
const documentApiResponse = vi.hoisted(() => (item: LogicalDocument) => ({
active: item.active ? revisionApiResponse(item.active) : null,
active_revision: item.activeRevision ?? null,
created_at: item.createdAt,
id: item.id,
knowledge_space_id: item.knowledgeSpaceId,
provider_item_id: item.providerItemId ?? null,
row_version: item.rowVersion,
source_id: item.sourceId ?? null,
status: item.status,
title: item.title,
updated_at: item.updatedAt,
user_metadata: item.userMetadata,
}))
const taskApiResponse = vi.hoisted(() => (item: DocumentProcessingTask) => ({
can_cancel: item.canCancel ?? true,
can_retry: item.canRetry ?? item.state === 'failed',
completed_at: item.completedAt ?? null,
created_at: item.createdAt,
document_id: item.documentId,
document_revision: item.documentRevision,
error_code: item.errorCode ?? null,
error_message: item.errorMessage ?? null,
id: item.id,
knowledge_space_id: item.knowledgeSpaceId,
operation: item.operation ?? 'document_processing',
progress_percent: item.progressPercent,
state:
item.state === 'succeeded'
? 'completed'
: item.state === 'dispatch_pending'
? 'queued'
: item.state === 'superseded'
? 'canceled'
: item.state,
task_kind: item.taskKind ?? 'document',
updated_at: item.updatedAt,
}))
const sourceApiResponse = vi.hoisted(() => (item: Source) => ({
connection_id: item.connectionId ?? null,
created_at: item.createdAt,
credential_configured: item.credentialConfigured ?? null,
id: item.id,
knowledge_space_id: item.knowledgeSpaceId,
metadata: item.metadata,
name: item.name,
permission_scope: item.permissionScope ?? [],
status: item.status,
type: item.type,
updated_at: item.updatedAt,
uri: item.uri,
version: item.version ?? 1,
}))
vi.mock('@/context/permission-state', () => ({
datasetDefaultPermissionKeysAtom: permissionStateMock.datasetAtom,
@ -123,6 +198,10 @@ vi.mock('@/context/permission-state', () => ({
workspacePermissionKeysLoadingAtom: permissionStateMock.loadingAtom,
}))
vi.mock('@/context/system-features-state', () => ({
knowledgeFsUploadEnabledAtom: systemFeaturesStateMock.atom,
}))
vi.mock('jotai', async (importOriginal) => {
const original = await importOriginal<typeof import('jotai')>()
return {
@ -132,6 +211,7 @@ vi.mock('jotai', async (importOriginal) => {
if (atom === permissionStateMock.errorAtom) return permissionStateMock.error
if (atom === permissionStateMock.fetchingAtom) return permissionStateMock.fetching
if (atom === permissionStateMock.loadingAtom) return permissionStateMock.loading
if (atom === systemFeaturesStateMock.atom) return systemFeaturesStateMock.uploadEnabled
return original.useAtomValue(atom as Parameters<typeof original.useAtomValue>[0])
},
useSetAtom: (atom: unknown) =>
@ -159,21 +239,100 @@ const sourcesInfiniteOptions = vi.hoisted(() =>
vi.fn((options: Omit<InfiniteOptions, 'queryKind'>) => ({ ...options, queryKind: 'sources' })),
)
function rawDocumentQueryData(data: NonNullable<typeof documentsQuery.data>): {
pages: Array<{ data: Array<ReturnType<typeof documentApiResponse>>; next_cursor: string | null }>
} {
const cached = rawQueryDataCache.documents.get(data)
if (cached) return cached as ReturnType<typeof rawDocumentQueryData>
const mapped = {
pages: data.pages.map((page) => ({
data: page.items.map(documentApiResponse),
next_cursor: page.nextCursor ?? null,
})),
}
rawQueryDataCache.documents.set(data, mapped)
return mapped
}
function rawSourceQueryData(data: NonNullable<typeof sourcesQuery.data>): {
pages: Array<{ data: Array<ReturnType<typeof sourceApiResponse>>; next_cursor: string | null }>
} {
const cached = rawQueryDataCache.sources.get(data)
if (cached) return cached as ReturnType<typeof rawSourceQueryData>
const mapped = {
pages: data.pages.map((page) => ({
data: page.items.map(sourceApiResponse),
next_cursor: page.nextCursor ?? null,
})),
}
rawQueryDataCache.sources.set(data, mapped)
return mapped
}
function rawTaskQueryData(data: NonNullable<typeof tasksQuery.data>): {
pages: Array<{ data: Array<ReturnType<typeof taskApiResponse>>; next_cursor: string | null }>
} {
const cached = rawQueryDataCache.tasks.get(data)
if (cached) return cached as ReturnType<typeof rawTaskQueryData>
const mapped = {
pages: data.pages.map((page) => ({
data: page.items.map(taskApiResponse),
next_cursor: page.nextCursor ?? null,
})),
}
rawQueryDataCache.tasks.set(data, mapped)
return mapped
}
vi.mock('@tanstack/react-query', async (importOriginal) => {
const original = await importOriginal<typeof import('@tanstack/react-query')>()
return {
...original,
useInfiniteQuery: (options: InfiniteOptions) => {
if (options.queryKind === 'documents') return documentsQuery
if (options.queryKind === 'sources') return sourcesQuery
return tasksQuery
if (options.queryKind === 'documents')
return {
...documentsQuery,
data: documentsQuery.data ? rawDocumentQueryData(documentsQuery.data) : undefined,
}
if (options.queryKind === 'sources')
return {
...sourcesQuery,
data: sourcesQuery.data ? rawSourceQueryData(sourcesQuery.data) : undefined,
}
return {
...tasksQuery,
data: tasksQuery.data ? rawTaskQueryData(tasksQuery.data) : undefined,
}
},
useMutation: (options: {
mutationKind: 'bulk-upload' | 'cancel' | 'reindex' | 'retry' | 'upload'
mutationFn?: (input: DocumentProcessingTask) => Promise<DocumentProcessingTask>
mutationKind?: 'bulk-upload' | 'cancel' | 'reindex' | 'retry' | 'upload'
}) => {
if (options.mutationFn)
return {
mutateAsync: options.mutationFn,
}
if (options.mutationKind === 'cancel') return cancelMutation
if (options.mutationKind === 'retry') return retryMutation
if (options.mutationKind === 'reindex') return reindexMutation
if (options.mutationKind === 'reindex')
return {
mutateAsync: async (input: unknown) => {
const result = await reindexMutation.mutateAsync(input)
return {
...result,
items: result.items.map(
(item: {
documentId?: string
document_id?: string
status: 'not_found' | 'queued'
}) => ({
...item,
document_id: item.document_id ?? item.documentId ?? null,
}),
),
}
},
}
if (options.mutationKind === 'bulk-upload') return bulkUploadMutation
return uploadMutation
},
@ -184,43 +343,100 @@ vi.mock('@tanstack/react-query', async (importOriginal) => {
vi.mock('@/service/client', () => ({
consoleClient: {
knowledgeFs: {
getKnowledgeSpacesByIdDocumentsByDocumentIdProcessingTasksByTaskId: getTaskSnapshot,
spaces: {
byControlSpaceId: {
backgroundTasks: {
byTaskKind: {
byTaskId: {
cancel: {
post: async (input: unknown) =>
taskApiResponse(await cancelMutation.mutateAsync(input)),
},
retry: {
post: async (input: unknown) =>
taskApiResponse(await retryMutation.mutateAsync(input)),
},
},
},
get: async (input: unknown, options?: unknown) => {
const allTasks = tasksQuery.data?.pages.flatMap((page) => page.items) ?? []
const requestedTask = allTasks[taskSnapshotRequestState.index % allTasks.length]
taskSnapshotRequestState.index += 1
const snapshot = await getTaskSnapshot(
requestedTask
? {
params: {
documentId: requestedTask.documentId,
id: requestedTask.knowledgeSpaceId,
taskId: requestedTask.id,
},
}
: input,
options,
)
return {
data: snapshot ? [taskApiResponse(snapshot)] : [],
next_cursor: null,
}
},
},
},
},
},
},
consoleQuery: {
knowledgeFs: {
deleteKnowledgeSpacesByIdDocumentsByDocumentIdProcessingTasksByTaskId: {
mutationOptions: () => ({ mutationKind: 'cancel' }),
},
getKnowledgeSpacesByIdLogicalDocuments: {
infiniteOptions: documentsInfiniteOptions,
key: () => ['knowledge-fs', 'documents'],
},
getKnowledgeSpacesByIdProcessingTasks: {
infiniteOptions: tasksInfiniteOptions,
key: () => ['knowledge-fs', 'tasks'],
},
getKnowledgeSpacesByIdSources: {
infiniteOptions: sourcesInfiniteOptions,
key: () => ['knowledge-fs', 'sources'],
},
postKnowledgeSpacesByIdDocuments: {
mutationOptions: () => ({ mutationKind: 'upload' }),
},
postKnowledgeSpacesByIdDocumentsBulk: {
mutationOptions: () => ({ mutationKind: 'bulk-upload' }),
},
postKnowledgeSpacesByIdDocumentsBulkReindex: {
mutationOptions: () => ({ mutationKind: 'reindex' }),
},
postKnowledgeSpacesByIdDocumentsByDocumentIdProcessingTasksByTaskIdRetry: {
mutationOptions: () => ({ mutationKind: 'retry' }),
spaces: {
byControlSpaceId: {
backgroundTasks: {
get: {
infiniteOptions: tasksInfiniteOptions,
key: () => ['knowledge-fs', 'tasks'],
},
},
documents: {
reindex: {
post: {
mutationOptions: () => ({ mutationKind: 'reindex' }),
},
},
},
logicalDocuments: {
get: {
infiniteOptions: documentsInfiniteOptions,
key: () => ['knowledge-fs', 'documents'],
},
},
sources: {
get: {
infiniteOptions: sourcesInfiniteOptions,
key: () => ['knowledge-fs', 'sources'],
},
},
},
},
},
},
}))
vi.mock('../services/processing-task-events', () => ({ streamProcessingTaskEvents }))
vi.mock('../knowledge-fs-upload', () => ({
uploadKnowledgeFsDocuments: async (
knowledgeSpaceId: string,
uploads: Array<{ file: File; id: string }>,
) => {
const files = uploads.map(({ file }) => file)
if (files.length === 1)
return uploadMutation.mutateAsync({
body: { file: files[0] },
params: { control_space_id: knowledgeSpaceId },
})
return bulkUploadMutation.mutateAsync({
body: { files },
params: { control_space_id: knowledgeSpaceId },
})
},
}))
const document = (overrides: Partial<LogicalDocument> = {}): LogicalDocument => ({
active: {
@ -313,7 +529,9 @@ const source = (overrides: Partial<Source> = {}): Source => ({
describe('DocumentsPage', () => {
beforeEach(() => {
vi.clearAllMocks()
systemFeaturesStateMock.uploadEnabled = true
queryCacheListeners.clear()
taskSnapshotRequestState.index = 0
queryClient.cancelQueries.mockResolvedValue(undefined)
queryClient.invalidateQueries.mockResolvedValue(undefined)
documentsQuery.data = { pages: [{ items: [] }] }
@ -424,24 +642,24 @@ describe('DocumentsPage', () => {
const taskOptions = tasksInfiniteOptions.mock.lastCall?.[0]
const sourceOptions = sourcesInfiniteOptions.mock.lastCall?.[0]
expect(documentOptions?.input(null)).toEqual({
params: { id: 'space-1' },
query: { limit: 50 },
params: { control_space_id: 'space-1' },
query: {},
})
expect(documentOptions?.input('next')).toEqual({
params: { id: 'space-1' },
query: { cursor: 'next', limit: 50 },
params: { control_space_id: 'space-1' },
query: { cursor: 'next' },
})
expect(documentOptions?.getNextPageParam({ nextCursor: 'next' })).toBe('next')
expect(documentOptions?.getNextPageParam({ next_cursor: 'next' })).toBe('next')
expect(taskOptions?.input(null)).toEqual({
params: { id: 'space-1' },
params: { control_space_id: 'space-1' },
query: { limit: 100 },
})
expect(taskOptions?.getNextPageParam({ nextCursor: 'next' })).toBe('next')
expect(taskOptions?.getNextPageParam({ next_cursor: 'next' })).toBe('next')
expect(sourceOptions?.input(null)).toEqual({
params: { id: 'space-1' },
query: { limit: 100 },
params: { control_space_id: 'space-1' },
query: {},
})
expect(sourceOptions?.getNextPageParam({ nextCursor: 'next' })).toBe('next')
expect(sourceOptions?.getNextPageParam({ next_cursor: 'next' })).toBe('next')
expect(screen.getByRole('status', { name: 'appApi.loading' })).toBeInTheDocument()
})
@ -588,6 +806,19 @@ describe('DocumentsPage', () => {
expect(dataTransfer.dropEffect).toBe('copy')
})
it('keeps direct-upload actions unavailable until the deployment is verified', () => {
systemFeaturesStateMock.uploadEnabled = false
render(<DocumentsPage knowledgeSpaceId="space-1" />)
expect(screen.queryByLabelText('dataset.newKnowledge.uploadDocuments')).not.toBeInTheDocument()
const addDocument = screen.getByRole('button', {
name: 'dataset.newKnowledge.addDocument',
})
expect(addDocument).toBeDisabled()
expect(addDocument).toHaveAccessibleDescription('dataset.cornerLabel.unavailable')
})
it('removes the empty-state drop affordance when uploads are unavailable', () => {
permissionStateMock.datasetKeys = ['dataset.acl.readonly']
@ -744,7 +975,7 @@ describe('DocumentsPage', () => {
await user.upload(input, new File(['one'], 'one.md', { type: 'text/markdown' }))
expect(uploadMutation.mutateAsync).toHaveBeenCalledWith({
body: { file: expect.any(File) },
params: { id: 'space-1' },
params: { control_space_id: 'space-1' },
})
await user.upload(input, [
@ -753,7 +984,7 @@ describe('DocumentsPage', () => {
])
expect(bulkUploadMutation.mutateAsync).toHaveBeenCalledWith({
body: { files: [expect.any(File), expect.any(File)] },
params: { id: 'space-1' },
params: { control_space_id: 'space-1' },
})
expect(queryClient.invalidateQueries).toHaveBeenCalled()
const documentInvalidation = queryClient.invalidateQueries.mock.calls.find(
@ -763,7 +994,7 @@ describe('DocumentsPage', () => {
documentInvalidation?.predicate({
queryKey: [
['console', 'knowledgeFs', 'getKnowledgeSpacesByIdLogicalDocuments'],
{ input: { params: { id: 'space-1' } }, type: 'infinite' },
{ input: { params: { control_space_id: 'space-1' } }, type: 'infinite' },
],
}),
).toBe(true)
@ -771,7 +1002,7 @@ describe('DocumentsPage', () => {
documentInvalidation?.predicate({
queryKey: [
['console', 'knowledgeFs', 'getKnowledgeSpacesByIdLogicalDocuments'],
{ input: { params: { id: 'space-2' } }, type: 'infinite' },
{ input: { params: { control_space_id: 'space-2' } }, type: 'infinite' },
],
}),
).toBe(false)
@ -791,7 +1022,7 @@ describe('DocumentsPage', () => {
await waitFor(() =>
expect(uploadMutation.mutateAsync).toHaveBeenCalledWith({
body: { file: validFile },
params: { id: 'space-1' },
params: { control_space_id: 'space-1' },
}),
)
expect(bulkUploadMutation.mutateAsync).not.toHaveBeenCalled()
@ -816,68 +1047,28 @@ describe('DocumentsPage', () => {
)
})
it('reports partial and fully excluded bulk uploads from the contract result', async () => {
it('reports local exclusions and direct upload failures', async () => {
const user = userEvent.setup()
bulkUploadMutation.mutateAsync
.mockResolvedValueOnce({
accepted: 1,
bulkJobId: 'upload-partial',
excluded: 1,
items: [
{
filename: 'too-large.pdf',
index: 1,
mimeType: 'application/pdf',
reason: 'file_too_large',
sizeBytes: 10_000,
status: 'excluded',
},
],
total: 2,
})
.mockResolvedValueOnce({
accepted: 0,
bulkJobId: 'upload-rejected',
excluded: 2,
items: [
{
filename: 'one.md',
index: 0,
mimeType: 'text/markdown',
reason: 'quota_exceeded',
sizeBytes: 3,
status: 'excluded',
},
{
filename: 'two.md',
index: 1,
mimeType: 'text/markdown',
reason: 'quota_exceeded',
sizeBytes: 3,
status: 'excluded',
},
],
total: 2,
})
render(<DocumentsPage knowledgeSpaceId="space-1" />)
const input = screen.getByLabelText('dataset.newKnowledge.uploadDocuments')
const oversizedFile = new File(['large'], 'too-large.pdf', { type: 'application/pdf' })
Object.defineProperty(oversizedFile, 'size', { value: 16 * 1024 * 1024 })
await user.upload(input, [
new File(['one'], 'one.md', { type: 'text/markdown' }),
new File(['large'], 'too-large.pdf', { type: 'application/pdf' }),
oversizedFile,
])
expect(toastMock.warning).toHaveBeenCalledWith(
'dataset.newKnowledge.documentUploadPartial:{"accepted":1,"details":"too-large.pdf (dataset.newKnowledge.documentUploadExclusion.fileSize)","excluded":1}',
)
queryClient.invalidateQueries.mockClear()
bulkUploadMutation.mutateAsync.mockRejectedValueOnce(new Error('quota exceeded'))
await user.upload(input, [
new File(['one'], 'one.md', { type: 'text/markdown' }),
new File(['two'], 'two.md', { type: 'text/markdown' }),
])
expect(toastMock.error).toHaveBeenCalledWith(
'dataset.newKnowledge.documentUploadRejected:{"details":"one.md (dataset.newKnowledge.documentUploadExclusion.quota); two.md (dataset.newKnowledge.documentUploadExclusion.quota)"}',
)
expect(toastMock.error).toHaveBeenCalledWith('dataset.newKnowledge.documentUploadFailed')
expect(queryClient.invalidateQueries).not.toHaveBeenCalled()
})
@ -1080,7 +1271,7 @@ describe('DocumentsPage', () => {
taskCancellation?.predicate({
queryKey: [
['console', 'knowledgeFs', 'getKnowledgeSpacesByIdProcessingTasks'],
{ input: { params: { id: 'space-1' } }, type: 'infinite' },
{ input: { params: { control_space_id: 'space-1' } }, type: 'infinite' },
],
}),
).toBe(true)
@ -1088,7 +1279,7 @@ describe('DocumentsPage', () => {
taskCancellation?.predicate({
queryKey: [
['console', 'knowledgeFs', 'getKnowledgeSpacesByIdProcessingTasks'],
{ input: { params: { id: 'space-2' } }, type: 'infinite' },
{ input: { params: { control_space_id: 'space-2' } }, type: 'infinite' },
],
}),
).toBe(false)
@ -1709,7 +1900,7 @@ describe('DocumentsPage', () => {
expect(reindexMutation.mutateAsync).toHaveBeenCalledOnce()
expect(reindexMutation.mutateAsync).toHaveBeenCalledWith({
body: { documentIds: ['one'] },
params: { id: 'space-1' },
params: { control_space_id: 'space-1' },
})
await waitFor(() =>
expect(screen.getByRole('heading', { name: 'dataset.newKnowledge.documents' })).toHaveFocus(),
@ -1941,7 +2132,11 @@ describe('DocumentsPage', () => {
expect(cancelMutation.mutateAsync).toHaveBeenCalledOnce()
expect(cancelMutation.mutateAsync).toHaveBeenCalledWith({
params: { documentId: 'document-1', id: 'space-1', taskId: 'running' },
params: {
control_space_id: 'space-1',
task_id: 'running',
task_kind: 'document',
},
})
await act(async () => resolveCancel?.(task({ id: 'running', state: 'canceled' })))
expect(queryClient.invalidateQueries).toHaveBeenCalled()
@ -3906,7 +4101,20 @@ describe('DocumentsPage', () => {
await act(async () => vi.advanceTimersByTime(5000))
expect(streamProcessingTaskEvents).toHaveBeenCalledTimes(12)
const taskOptions = tasksInfiniteOptions.mock.lastCall?.[0]
expect(taskOptions?.refetchInterval).toBeUndefined()
expect(
taskOptions?.refetchInterval?.({
state: {
data: {
pages: [
{
data: [taskApiResponse(task({ id: 'active-0' }))],
next_cursor: null,
},
],
},
},
}),
).toBe(5000)
expect(
screen.getByRole('button', {
name: 'dataset.newKnowledge.tasksWithAttention:{"count":20}',

View File

@ -0,0 +1,154 @@
import { uploadKnowledgeFsDocuments } from '../knowledge-fs-upload'
const serviceMock = vi.hoisted(() => ({
getSpace: vi.fn(),
issueCapability: vi.fn(),
smallFile: vi.fn(),
}))
vi.mock('@/service/client', () => ({
consoleClient: {
knowledgeFs: {
spaces: {
byControlSpaceId: {
get: serviceMock.getSpace,
uploadCapabilities: {
post: serviceMock.issueCapability,
},
uploadSessions: {
byUploadSessionId: {
smallFile: {
post: serviceMock.smallFile,
},
},
},
},
},
},
},
}))
describe('uploadKnowledgeFsDocuments', () => {
beforeEach(() => {
vi.clearAllMocks()
serviceMock.getSpace.mockResolvedValue({
knowledge_space_id: 'physical-space-1',
state: 'active',
})
serviceMock.issueCapability.mockResolvedValue({
direct_origin: 'https://knowledge-fs.example',
expires_at: '2026-07-27T12:00:00Z',
operation_id: 'createUploadSession',
token: 'capability-token',
})
serviceMock.smallFile.mockResolvedValue({
session: { id: 'session-1', mode: 'small_fallback', status: 'completed' },
})
vi.spyOn(globalThis.crypto.subtle, 'digest').mockResolvedValue(new Uint8Array(32).buffer)
})
afterEach(() => {
vi.restoreAllMocks()
vi.unstubAllGlobals()
})
it('creates a capability-bound session and uses the Dify small-file fallback', async () => {
const request = vi.fn().mockResolvedValue(
new Response(
JSON.stringify({
session: {
id: 'session-1',
mode: 'small_fallback',
status: 'ready',
},
}),
{
status: 201,
headers: { 'content-type': 'application/json' },
},
),
)
vi.stubGlobal('fetch', request)
const file = new File(['hello'], 'hello.txt', { type: 'text/plain' })
await uploadKnowledgeFsDocuments('control-space-1', [{ file, id: 'upload-1' }])
expect(serviceMock.issueCapability).toHaveBeenCalledWith({
body: { operation_id: 'createUploadSession' },
params: { control_space_id: 'control-space-1' },
})
expect(request).toHaveBeenCalledWith(
'https://knowledge-fs.example/knowledge-spaces/physical-space-1/upload-sessions',
expect.objectContaining({
headers: {
Authorization: 'Bearer capability-token',
'Content-Type': 'application/json',
},
method: 'POST',
}),
)
expect(serviceMock.smallFile).toHaveBeenCalledWith({
body: { file },
params: {
control_space_id: 'control-space-1',
upload_session_id: 'session-1',
},
})
})
it('resumes only the failed file after a partial multi-file upload', async () => {
const request = vi.fn(async (_url: string, init?: RequestInit) => {
const body = JSON.parse(String(init?.body)) as { fileName: string }
const sessionId = body.fileName === 'a.txt' ? 'session-a' : 'session-b'
return new Response(
JSON.stringify({
session: {
id: sessionId,
mode: 'small_fallback',
status: 'ready',
},
}),
{
status: 201,
headers: { 'content-type': 'application/json' },
},
)
})
vi.stubGlobal('fetch', request)
serviceMock.smallFile.mockImplementation(
({ params }: { params: { upload_session_id: string } }) => {
if (
params.upload_session_id === 'session-b' &&
serviceMock.smallFile.mock.calls.filter(
([call]) => call.params.upload_session_id === 'session-b',
).length === 1
)
return Promise.reject(new Error('response lost'))
return Promise.resolve({
session: {
id: params.upload_session_id,
mode: 'small_fallback',
status: 'completed',
},
})
},
)
const uploads = [
{ file: new File(['a'], 'a.txt', { type: 'text/plain' }), id: 'upload-a' },
{ file: new File(['b'], 'b.txt', { type: 'text/plain' }), id: 'upload-b' },
]
const progress = new Map()
await expect(uploadKnowledgeFsDocuments('control-space-1', uploads, progress)).rejects.toThrow(
'response lost',
)
await expect(
uploadKnowledgeFsDocuments('control-space-1', uploads, progress),
).resolves.toBeUndefined()
expect(request).toHaveBeenCalledTimes(2)
expect(serviceMock.smallFile.mock.calls.map(([call]) => call.params.upload_session_id)).toEqual(
['session-a', 'session-b', 'session-b'],
)
})
})

View File

@ -6,8 +6,8 @@ import { KnowledgeSpaceShell } from '../knowledge-space-shell'
const queryMock = vi.hoisted(() => ({
data: undefined as
| {
id: string
name: string
control_space_id: string
technical_summary: { name: string }
}
| undefined,
error: null as unknown,
@ -37,8 +37,12 @@ vi.mock('@tanstack/react-query', async (importOriginal) => {
vi.mock('@/service/client', () => ({
consoleQuery: {
knowledgeFs: {
getKnowledgeSpacesById: {
queryOptions: queryOptionsMock,
spaces: {
byControlSpaceId: {
get: {
queryOptions: queryOptionsMock,
},
},
},
},
},
@ -60,12 +64,17 @@ describe('KnowledgeSpaceShell', () => {
render(<KnowledgeSpaceShell knowledgeSpaceId="space-1">content</KnowledgeSpaceShell>)
expect(queryOptionsMock).toHaveBeenCalledWith({ input: { params: { id: 'space-1' } } })
expect(queryOptionsMock).toHaveBeenCalledWith({
input: { params: { control_space_id: 'space-1' } },
})
expect(screen.getByRole('status')).toBeInTheDocument()
})
it('renders a refresh-safe header and route navigation when loaded', () => {
queryMock.data = { id: 'space-1', name: 'Support knowledge' }
queryMock.data = {
control_space_id: 'space-1',
technical_summary: { name: 'Support knowledge' },
}
render(<KnowledgeSpaceShell knowledgeSpaceId="space-1">source content</KnowledgeSpaceShell>)
@ -129,7 +138,10 @@ describe('KnowledgeSpaceShell', () => {
it('marks Documents as the only current detail route', () => {
pathnameMock.value = '/datasets/new/space-1/documents'
queryMock.data = { id: 'space-1', name: 'Support knowledge' }
queryMock.data = {
control_space_id: 'space-1',
technical_summary: { name: 'Support knowledge' },
}
render(<KnowledgeSpaceShell knowledgeSpaceId="space-1">document content</KnowledgeSpaceShell>)

View File

@ -0,0 +1,38 @@
import { render, screen, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { KnowledgeViewSwitcher } from '../components/knowledge-view-switcher'
const guideStorageMock = vi.hoisted(() => ({
dismissed: false,
setDismissed: vi.fn(),
}))
vi.mock('@/features/new-rag/storage', () => ({
useNewKnowledgeGuideDismissedValue: () => guideStorageMock.dismissed,
useSetNewKnowledgeGuideDismissed: () => guideStorageMock.setDismissed,
}))
describe('KnowledgeViewSwitcher', () => {
beforeEach(() => {
vi.clearAllMocks()
guideStorageMock.dismissed = false
})
it('restores focus to the guide trigger when Escape closes the popover', async () => {
const user = userEvent.setup()
render(<KnowledgeViewSwitcher value="new" onChange={vi.fn()} />)
const trigger = screen.getByRole('button', {
name: 'dataset.newKnowledge.guideTitle',
})
const guide = screen.getByRole('dialog', {
name: 'dataset.newKnowledge.guideTitle',
})
within(guide).getByRole('button', { name: 'dataset.newKnowledge.gotIt' }).focus()
await user.keyboard('{Escape}')
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
expect(trigger).toHaveFocus()
})
})

View File

@ -1,17 +1,31 @@
import type { KnowledgeSpaceList } from '@dify/contracts/knowledge-fs/types.gen'
import type { InfiniteData } from '@tanstack/react-query'
import { screen, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { renderWithNuqs } from '@/test/nuqs-testing'
import { NewKnowledgeList } from '../new-knowledge-list'
type KnowledgeSpaceList = {
items: Array<{
createdAt: string
description?: string
iconRef?: string
id: string
name: string
revision: number
slug: string
tenantId: string
updatedAt: string
}>
nextCursor?: string
}
type ListKnowledgeSpacesInfiniteOptions = {
getNextPageParam: (lastPage: KnowledgeSpaceList) => string | undefined
initialPageParam: string | null
getNextPageParam: (lastPage: { has_more: boolean; page: number }) => number | undefined
initialPageParam: number
input: (pageParam: unknown) => {
query: {
cursor?: string
limit: number
page: number
}
}
}
@ -21,6 +35,28 @@ const externalApiPanelMock = vi.hoisted(() => ({
setOpen: vi.fn(),
}))
const toastInfoMock = vi.hoisted(() => vi.fn())
const knowledgeSpaceApiResponse = vi.hoisted(
() => (space: KnowledgeSpaceList['items'][number]) => ({
control_space_id: space.id,
created_at: space.createdAt,
knowledge_space_id: space.id,
owner_account_id: 'account-1',
permission_keys: ['knowledge_space_read'],
resource_version: space.revision,
state: 'active',
technical_status: 'available',
technical_summary: {
description: space.description ?? null,
icon: space.iconRef ?? null,
knowledge_space_id: space.id,
name: space.name,
revision: space.revision,
slug: space.slug,
},
updated_at: space.updatedAt,
visibility: 'only_me',
}),
)
vi.mock('@langgenius/dify-ui/toast', () => ({
toast: { info: toastInfoMock },
@ -69,7 +105,20 @@ vi.mock('@tanstack/react-query', async (importOriginal) => {
const original = await importOriginal<typeof import('@tanstack/react-query')>()
return {
...original,
useInfiniteQuery: () => queryMock,
useInfiniteQuery: () => ({
...queryMock,
data: queryMock.data
? {
...queryMock.data,
pages: queryMock.data.pages.map((page, index) => ({
data: page.items.map(knowledgeSpaceApiResponse),
has_more: Boolean(page.nextCursor),
limit: 30,
page: index + 1,
})),
}
: undefined,
}),
}
})
@ -92,8 +141,10 @@ vi.mock('@/context/permission-state', () => ({
vi.mock('@/service/client', () => ({
consoleQuery: {
knowledgeFs: {
listKnowledgeSpaces: {
infiniteOptions: consoleQueryMock.infiniteOptions,
spaces: {
get: {
infiniteOptions: consoleQueryMock.infiniteOptions,
},
},
},
},
@ -137,13 +188,13 @@ describe('NewKnowledgeList', () => {
const options = consoleQueryMock.infiniteOptions.mock.calls.at(-1)?.[0]
expect(options).toBeDefined()
expect(options?.initialPageParam).toBeNull()
expect(options?.input(null)).toEqual({ query: { limit: 30 } })
expect(options?.input('next-page')).toEqual({
query: { cursor: 'next-page', limit: 30 },
expect(options?.initialPageParam).toBe(1)
expect(options?.input(1)).toEqual({ query: { limit: 30, page: 1 } })
expect(options?.input(2)).toEqual({
query: { limit: 30, page: 2 },
})
expect(options?.getNextPageParam({ items: [], nextCursor: 'next-page' })).toBe('next-page')
expect(options?.getNextPageParam({ items: [] })).toBeUndefined()
expect(options?.getNextPageParam({ has_more: true, page: 1 })).toBe(2)
expect(options?.getNextPageParam({ has_more: false, page: 1 })).toBeUndefined()
})
it('links real knowledge spaces to the new detail shell', () => {

View File

@ -1,68 +1,67 @@
import type { DocumentProcessingTaskEvent } from '@dify/contracts/knowledge-fs/types.gen'
import { withEventMeta } from '@orpc/client'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { streamProcessingTaskEvents } from '../services/processing-task-events'
const { mockStreamEvents } = vi.hoisted(() => ({
mockStreamEvents: vi.fn(),
const { listBackgroundTasks } = vi.hoisted(() => ({
listBackgroundTasks: vi.fn(),
}))
vi.mock('@/service/client', () => ({
consoleClient: {
knowledgeFs: {
getKnowledgeSpacesByIdDocumentsByDocumentIdProcessingTasksByTaskIdEvents: mockStreamEvents,
spaces: {
byControlSpaceId: {
backgroundTasks: {
get: listBackgroundTasks,
},
},
},
},
},
}))
async function* eventIterator(...events: DocumentProcessingTaskEvent[]) {
yield* events
}
const task = (
state: 'completed' | 'failed' | 'queued' | 'running',
overrides: { documentId?: string; id?: string } = {},
) => ({
can_cancel: state === 'queued' || state === 'running',
can_retry: state === 'failed',
completed_at: state === 'completed' ? '2026-07-20T01:03:00Z' : null,
created_at: '2026-07-20T01:00:00Z',
document_id: overrides.documentId ?? 'document/1',
document_revision: 2,
error_code: state === 'failed' ? 'PROCESSING_FAILED' : null,
error_message: null,
id: overrides.id ?? 'task/1',
knowledge_space_id: 'space/1',
operation: 'document_processing',
progress_percent: state === 'completed' ? 100 : 45,
state,
task_kind: 'document',
updated_at: state === 'completed' ? '2026-07-20T01:03:00Z' : '2026-07-20T01:02:03Z',
})
describe('KnowledgeFS processing task events', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.useRealTimers()
})
it('uses the generated streaming client and resumes from the last event id', async () => {
mockStreamEvents.mockResolvedValue(
eventIterator(
withEventMeta(
{
data: {
progressPercent: 45,
stage: 'parsed',
state: 'running',
updatedAt: '2026-07-20T01:02:03Z',
},
event: 'progress',
},
{ id: 'task-1:2026-07-20T01:02:03Z' },
),
withEventMeta(
{
data: { state: 'succeeded' },
event: 'terminal',
},
{ id: 'task-1:terminal' },
),
),
)
it('polls the unified background-task endpoint until the task is terminal', async () => {
vi.useFakeTimers()
listBackgroundTasks
.mockResolvedValueOnce({ data: [task('running')], next_cursor: null })
.mockResolvedValueOnce({ data: [task('completed')], next_cursor: null })
const abortController = new AbortController()
const events = []
for await (const event of streamProcessingTaskEvents({
const events = streamProcessingTaskEvents({
documentId: 'document/1',
knowledgeSpaceId: 'space/1',
lastEventId: 'task-1:previous',
signal: abortController.signal,
taskId: 'task/1',
})) {
events.push(event)
}
})
expect(events).toEqual([
{
await expect(events.next()).resolves.toEqual({
done: false,
value: {
data: {
progressPercent: 45,
stage: 'parsed',
@ -70,46 +69,92 @@ describe('KnowledgeFS processing task events', () => {
updatedAt: '2026-07-20T01:02:03Z',
},
event: 'progress',
id: 'task-1:2026-07-20T01:02:03Z',
id: '2026-07-20T01:02:03Z:running:45',
},
{
data: { state: 'succeeded' },
})
const terminal = events.next()
await vi.advanceTimersByTimeAsync(5000)
await expect(terminal).resolves.toEqual({
done: false,
value: {
data: { errorCode: undefined, state: 'succeeded' },
event: 'terminal',
id: 'task-1:terminal',
id: '2026-07-20T01:03:00Z:succeeded:100',
},
])
expect(mockStreamEvents).toHaveBeenCalledWith(
})
await expect(events.next()).resolves.toEqual({ done: true, value: undefined })
expect(listBackgroundTasks).toHaveBeenNthCalledWith(
1,
{
headers: { 'last-event-id': 'task-1:previous' },
params: {
documentId: 'document/1',
id: 'space/1',
taskId: 'task/1',
},
params: { control_space_id: 'space/1' },
query: { limit: 200 },
},
{
expect.objectContaining({
context: { silent: true },
signal: abortController.signal,
},
)
})
it('rejects events without a resumable event id', async () => {
mockStreamEvents.mockResolvedValue(
eventIterator({
data: { state: 'failed' },
event: 'terminal',
signal: expect.any(AbortSignal),
}),
)
})
await expect(async () => {
for await (const event of streamProcessingTaskEvents({
documentId: 'document-1',
knowledgeSpaceId: 'space-1',
taskId: 'task-1',
})) {
void event
}
}).rejects.toThrow('missing an event id')
it('continues through cursor pages and stops when the requested task is absent', async () => {
listBackgroundTasks
.mockResolvedValueOnce({ data: [], next_cursor: 'next-page' })
.mockResolvedValueOnce({ data: [], next_cursor: null })
const events = streamProcessingTaskEvents({
documentId: 'document-1',
knowledgeSpaceId: 'space-1',
taskId: 'task-1',
})
await expect(events.next()).resolves.toEqual({ done: true, value: undefined })
expect(listBackgroundTasks).toHaveBeenNthCalledWith(
2,
{
params: { control_space_id: 'space-1' },
query: { cursor: 'next-page', limit: 200 },
},
expect.objectContaining({
context: { silent: true },
signal: expect.any(AbortSignal),
}),
)
})
it('shares one paginated snapshot across concurrent task observers', async () => {
listBackgroundTasks
.mockResolvedValueOnce({
data: [task('running', { documentId: 'document/2', id: 'task/2' })],
next_cursor: 'next-page',
})
.mockResolvedValueOnce({
data: [task('running', { documentId: 'document/1', id: 'task/1' })],
next_cursor: null,
})
const firstController = new AbortController()
const secondController = new AbortController()
const firstEvents = streamProcessingTaskEvents({
documentId: 'document/1',
knowledgeSpaceId: 'space/1',
signal: firstController.signal,
taskId: 'task/1',
})
const secondEvents = streamProcessingTaskEvents({
documentId: 'document/2',
knowledgeSpaceId: 'space/1',
signal: secondController.signal,
taskId: 'task/2',
})
const [first, second] = await Promise.all([firstEvents.next(), secondEvents.next()])
expect(first.done).toBe(false)
expect(second.done).toBe(false)
expect(listBackgroundTasks).toHaveBeenCalledTimes(2)
firstController.abort()
secondController.abort()
await firstEvents.return(undefined)
await secondEvents.return(undefined)
})
})

View File

@ -1,4 +1,4 @@
import type { Source } from '@dify/contracts/knowledge-fs/types.gen'
import type { Source } from '../source-models'
import { screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import datasetTranslations from '@/i18n/en-US/dataset.json'
@ -10,6 +10,21 @@ const toastErrorMock = vi.hoisted(() => vi.fn())
const permissionState = vi.hoisted(() => ({
workspacePermissionKeys: ['dataset.acl.edit', 'dataset.external.connect'],
}))
const sourceApiResponse = vi.hoisted(() => (source: Source) => ({
connection_id: source.connectionId ?? null,
created_at: source.createdAt,
credential_configured: source.credentialConfigured ?? null,
id: source.id,
knowledge_space_id: source.knowledgeSpaceId,
metadata: source.metadata,
name: source.name,
permission_scope: source.permissionScope ?? [],
status: source.status,
type: source.type,
updated_at: source.updatedAt,
uri: source.uri,
version: source.version ?? null,
}))
vi.mock('@langgenius/dify-ui/toast', () => ({
toast: { error: toastErrorMock, info: toastInfoMock },
@ -22,12 +37,12 @@ vi.mock('@/context/permission-state', async () => {
})
type SourcesInfiniteOptions = {
getNextPageParam: (lastPage: { nextCursor?: string }) => string | undefined
getNextPageParam: (lastPage: { next_cursor?: string | null }) => string | null | undefined
input: (pageParam: string | null) => unknown
initialPageParam: string | null
refetchInterval: (query: {
state: {
data?: { pages: Array<{ items: Source[] }> }
data?: { pages: Array<{ data: ReturnType<typeof sourceApiResponse>[] }> }
}
}) => false | number
}
@ -55,7 +70,17 @@ vi.mock('@tanstack/react-query', async (importOriginal) => {
const original = await importOriginal<typeof import('@tanstack/react-query')>()
return {
...original,
useInfiniteQuery: () => sourcesQuery,
useInfiniteQuery: () => ({
...sourcesQuery,
data: sourcesQuery.data
? {
pages: sourcesQuery.data.pages.map((page) => ({
data: page.items.map(sourceApiResponse),
next_cursor: page.nextCursor ?? null,
})),
}
: undefined,
}),
useQueryClient: () => ({ invalidateQueries: invalidateQueriesMock }),
}
})
@ -63,16 +88,35 @@ vi.mock('@tanstack/react-query', async (importOriginal) => {
vi.mock('@/service/client', () => ({
consoleClient: {
knowledgeFs: {
deleteKnowledgeSpacesByIdSourcesBySourceId: clientMock.deleteSource,
patchKnowledgeSpacesByIdSourcesBySourceId: clientMock.patchSource,
postKnowledgeSpacesByIdSourcesBySourceIdSync: clientMock.syncSource,
spaces: {
byControlSpaceId: {
sources: {
bySourceId: {
delete: clientMock.deleteSource,
patch: async (input: unknown) =>
sourceApiResponse(await clientMock.patchSource(input)),
sync: { post: clientMock.syncSource },
},
get: {
infiniteOptions: infiniteOptionsMock,
key: vi.fn(() => ['sources']),
},
},
},
},
},
},
consoleQuery: {
knowledgeFs: {
getKnowledgeSpacesByIdSources: {
infiniteOptions: infiniteOptionsMock,
key: vi.fn(() => ['sources']),
spaces: {
byControlSpaceId: {
sources: {
get: {
infiniteOptions: infiniteOptionsMock,
key: vi.fn(() => ['sources']),
},
},
},
},
},
},
@ -116,23 +160,27 @@ describe('SourcesPage', () => {
expect(options).toBeDefined()
if (!options) throw new Error('Expected source infinite query options')
expect(options.input(null)).toEqual({
params: { id: 'space-1' },
params: { control_space_id: 'space-1' },
query: { limit: 50 },
})
expect(options.input('next')).toEqual({
params: { id: 'space-1' },
params: { control_space_id: 'space-1' },
query: { cursor: 'next', limit: 50 },
})
expect(options.getNextPageParam({ nextCursor: 'next' })).toBe('next')
expect(options.getNextPageParam({ next_cursor: 'next' })).toBe('next')
expect(options.initialPageParam).toBeNull()
expect(
options.refetchInterval({
state: { data: { pages: [{ items: [source({ status: 'syncing' })] }] } },
state: {
data: { pages: [{ data: [sourceApiResponse(source({ status: 'syncing' }))] }] },
},
}),
).toBe(3000)
expect(
options.refetchInterval({
state: { data: { pages: [{ items: [source({ status: 'active' })] }] } },
state: {
data: { pages: [{ data: [sourceApiResponse(source({ status: 'active' }))] }] },
},
}),
).toBe(false)
expect(screen.getByRole('status')).toBeInTheDocument()
@ -425,6 +473,13 @@ describe('SourcesPage', () => {
it('syncs a source through the real KnowledgeFS action', async () => {
const user = userEvent.setup()
sourcesQuery.data = { pages: [{ items: [source({})] }] }
let finishRefresh: (() => void) | undefined
invalidateQueriesMock.mockImplementationOnce(
() =>
new Promise<void>((resolve) => {
finishRefresh = resolve
}),
)
render(<SourcesPage knowledgeSpaceId="space-1" />)
await user.click(
@ -438,7 +493,7 @@ describe('SourcesPage', () => {
await waitFor(() =>
expect(clientMock.syncSource).toHaveBeenCalledWith({
headers: { 'Idempotency-Key': expect.any(String) },
params: { id: 'space-1', sourceId: 'source-1' },
params: { control_space_id: 'space-1', source_id: 'source-1' },
}),
)
expect(
@ -446,14 +501,24 @@ describe('SourcesPage', () => {
'dataset.newKnowledge.sourceStatus.syncing',
),
).toBeInTheDocument()
finishRefresh?.()
await waitFor(() =>
expect(
within(screen.getByRole('row', { name: /Product documentation/ })).getByText(
'dataset.newKnowledge.sourceStatus.active',
),
).toBeInTheDocument(),
)
const options = infiniteOptionsMock.mock.lastCall?.[0]
expect(options).toBeDefined()
if (!options) throw new Error('Expected source infinite query options')
expect(
options.refetchInterval({
state: { data: { pages: [{ items: [source({ status: 'active' })] }] } },
state: {
data: { pages: [{ data: [sourceApiResponse(source({ status: 'active' }))] }] },
},
}),
).toBe(3000)
).toBe(false)
expect(invalidateQueriesMock).toHaveBeenCalledWith({ queryKey: ['sources'] })
})
@ -482,7 +547,7 @@ describe('SourcesPage', () => {
await waitFor(() =>
expect(clientMock.patchSource).toHaveBeenCalledWith({
body: { expectedVersion: 3, status: 'disabled' },
params: { id: 'space-1', sourceId: 'active-source' },
params: { control_space_id: 'space-1', source_id: 'active-source' },
}),
)
@ -491,7 +556,7 @@ describe('SourcesPage', () => {
await waitFor(() =>
expect(clientMock.patchSource).toHaveBeenLastCalledWith({
body: { expectedVersion: 3, status: 'active' },
params: { id: 'space-1', sourceId: 'disabled' },
params: { control_space_id: 'space-1', source_id: 'disabled' },
}),
)
})
@ -538,7 +603,7 @@ describe('SourcesPage', () => {
await waitFor(() =>
expect(clientMock.patchSource).toHaveBeenLastCalledWith({
body: { expectedVersion: 4, status: 'active' },
params: { id: 'space-1', sourceId: 'source-1' },
params: { control_space_id: 'space-1', source_id: 'source-1' },
}),
)
})
@ -560,8 +625,8 @@ describe('SourcesPage', () => {
await waitFor(() =>
expect(clientMock.deleteSource).toHaveBeenCalledWith({
body: { expectedRevision: 3 },
headers: { 'idempotency-key': expect.any(String) },
params: { id: 'space-1', sourceId: 'source-1' },
headers: { 'Idempotency-Key': expect.any(String) },
params: { control_space_id: 'space-1', source_id: 'source-1' },
query: { documents: 'keep' },
}),
)
@ -594,6 +659,13 @@ describe('SourcesPage', () => {
it('retries an errored source and shows its queued state', async () => {
const user = userEvent.setup()
sourcesQuery.data = { pages: [{ items: [source({ status: 'error' })] }] }
let finishRefresh: (() => void) | undefined
invalidateQueriesMock.mockImplementationOnce(
() =>
new Promise<void>((resolve) => {
finishRefresh = resolve
}),
)
render(<SourcesPage knowledgeSpaceId="space-1" />)
await user.click(screen.getByRole('button', { name: 'common.operation.retry' }))
@ -604,6 +676,7 @@ describe('SourcesPage', () => {
'dataset.newKnowledge.sourceStatus.syncing',
),
).toBeInTheDocument()
finishRefresh?.()
})
it('supports row selection and a true indeterminate select-all state', async () => {

View File

@ -1,4 +1,4 @@
import type { Source, SourceWorkflowRun } from '@dify/contracts/knowledge-fs/types.gen'
import type { Source, SourceWorkflowRun } from '../source-models'
import { act, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { render } from '@/test/console/render'
@ -13,6 +13,63 @@ const clientMock = vi.hoisted(() => ({
retry: vi.fn(),
startPreview: vi.fn(),
}))
const sourceApiResponse = vi.hoisted(() => (source: Source) => ({
connection_id: source.connectionId ?? null,
created_at: source.createdAt,
credential_configured: source.credentialConfigured ?? null,
id: source.id,
knowledge_space_id: source.knowledgeSpaceId,
metadata: source.metadata,
name: source.name,
permission_scope: source.permissionScope ?? [],
status: source.status,
type: source.type,
updated_at: source.updatedAt,
uri: source.uri,
version: source.version ?? null,
}))
const workflowApiResponse = vi.hoisted(() => (workflow: SourceWorkflowRun) => ({
canceled_at: workflow.canceledAt ?? null,
checkpoint: workflow.checkpoint,
completed_at: workflow.completedAt ?? null,
created_at: workflow.createdAt,
cursor: workflow.cursor ?? null,
execution_attempts: workflow.executionAttempts,
id: workflow.id,
knowledge_space_id: workflow.knowledgeSpaceId,
kind: workflow.kind,
last_error_code: workflow.lastErrorCode ?? null,
max_execution_attempts: workflow.maxExecutionAttempts,
progress_completed: workflow.progressCompleted,
progress_failed: workflow.progressFailed,
progress_skipped: workflow.progressSkipped,
progress_total: workflow.progressTotal ?? null,
source_id: workflow.sourceId ?? null,
state: workflow.state,
updated_at: workflow.updatedAt,
}))
const crawlPreviewPageListApiResponse = vi.hoisted(
() =>
(response: {
items: Array<{
description?: string
etag?: string
pageId: string
sourceUrl: string
title?: string
}>
nextCursor?: string
}) => ({
data: response.items.map((page) => ({
description: page.description ?? null,
etag: page.etag ?? null,
page_id: page.pageId,
source_url: page.sourceUrl,
title: page.title ?? null,
})),
next_cursor: response.nextCursor ?? null,
}),
)
const routerMock = vi.hoisted(() => ({ push: vi.fn() }))
@ -51,13 +108,41 @@ vi.mock('../crawl-selection-form', () => ({
vi.mock('@/service/client', () => ({
consoleClient: {
knowledgeFs: {
getKnowledgeSpacesByIdSources: clientMock.listSources,
getKnowledgeSpacesByIdSourceWorkflowsByRunId: clientMock.getRun,
getKnowledgeSpacesByIdSourceWorkflowsByRunIdPages: clientMock.getPages,
postKnowledgeSpacesByIdSources: clientMock.createSource,
postKnowledgeSpacesByIdSourcesBySourceIdCrawlPreview: clientMock.startPreview,
postKnowledgeSpacesByIdSourceWorkflowsByRunIdCancel: clientMock.cancel,
postKnowledgeSpacesByIdSourceWorkflowsByRunIdRetry: clientMock.retry,
spaces: {
byControlSpaceId: {
sourceWorkflows: {
byRunId: {
cancel: {
post: async (input: unknown) => workflowApiResponse(await clientMock.cancel(input)),
},
get: async (input: unknown) => workflowApiResponse(await clientMock.getRun(input)),
pages: {
get: async (input: unknown) =>
crawlPreviewPageListApiResponse(await clientMock.getPages(input)),
},
retry: {
post: async (input: unknown) => workflowApiResponse(await clientMock.retry(input)),
},
},
},
sources: {
bySourceId: {
crawlPreview: {
post: async (input: unknown) =>
workflowApiResponse(await clientMock.startPreview(input)),
},
},
get: async (input: unknown) => {
const response = await clientMock.listSources(input)
return {
data: response.items.map(sourceApiResponse),
next_cursor: response.nextCursor ?? null,
}
},
post: async (input: unknown) => sourceApiResponse(await clientMock.createSource(input)),
},
},
},
},
},
}))
@ -116,6 +201,7 @@ describe('WebsiteCrawlPreview', () => {
beforeEach(() => {
vi.useRealTimers()
for (const mock of Object.values(clientMock)) mock.mockReset()
clientMock.cancel.mockResolvedValue(run('canceled'))
clientMock.createSource.mockResolvedValue(source())
clientMock.startPreview.mockResolvedValue(run('running'))
clientMock.getRun.mockResolvedValue(
@ -181,11 +267,11 @@ describe('WebsiteCrawlPreview', () => {
type: 'web',
uri: 'https://docs.dify.ai/',
},
params: { id: 'space-1' },
params: { control_space_id: 'space-1' },
})
expect(clientMock.startPreview).toHaveBeenCalledWith({
headers: { 'Idempotency-Key': expect.any(String) },
params: { id: 'space-1', sourceId: 'source-1' },
params: { control_space_id: 'space-1', source_id: 'source-1' },
})
expect(await screen.findByText('Getting started')).toBeInTheDocument()
expect(screen.getByText(/^dataset\.newKnowledge\.pagesCrawled/)).toHaveAttribute(
@ -198,6 +284,31 @@ describe('WebsiteCrawlPreview', () => {
).not.toBeInTheDocument()
})
it('cancels a preview-ready workflow and starts a fresh run when re-crawling', async () => {
clientMock.cancel.mockResolvedValue(run('canceled'))
render(<WebsiteCrawlPreview connection={connection} knowledgeSpaceId="space-1" />)
const user = await fillValidForm()
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.crawlAndPreview' }))
await screen.findByText('Getting started')
const firstIdempotencyKey =
clientMock.startPreview.mock.calls[0]?.[0].headers['Idempotency-Key']
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.reCrawl' }))
await waitFor(() => expect(clientMock.cancel).toHaveBeenCalledOnce())
await waitFor(() => expect(clientMock.startPreview).toHaveBeenCalledTimes(2))
expect(clientMock.cancel).toHaveBeenCalledWith({
body: { reason: 'user_requested' },
params: { control_space_id: 'space-1', run_id: 'run-1' },
})
expect(clientMock.retry).not.toHaveBeenCalled()
expect(clientMock.createSource).toHaveBeenCalledOnce()
expect(clientMock.startPreview.mock.calls[1]?.[0].headers['Idempotency-Key']).not.toBe(
firstIdempotencyKey,
)
})
it('submits the crawl form with Enter and enforces the source name contract limit', async () => {
render(<WebsiteCrawlPreview connection={connection} knowledgeSpaceId="space-1" />)
const user = await fillValidForm()
@ -234,6 +345,12 @@ describe('WebsiteCrawlPreview', () => {
await user.clear(pageLimit)
await user.type(pageLimit, '50')
expect(pageLimit).toHaveValue(50)
await user.click(screen.getByRole('button', { name: /^dataset\.newKnowledge\.crawlOptions/ }))
expect(
screen.getByText(
'dataset.newKnowledge.includeSubpages: dataset.newKnowledge.booleanTrue · dataset.newKnowledge.maxPages: 50',
),
).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.crawlAndPreview' }))
await waitFor(() => expect(clientMock.createSource).toHaveBeenCalledOnce())
@ -285,7 +402,7 @@ describe('WebsiteCrawlPreview', () => {
await waitFor(() => expect(clientMock.cancel).toHaveBeenCalledOnce())
expect(clientMock.cancel).toHaveBeenCalledWith({
body: { reason: 'user_requested' },
params: { id: 'space-1', runId: 'run-1' },
params: { control_space_id: 'space-1', run_id: 'run-1' },
})
await waitFor(() =>
expect(routerMock.push).toHaveBeenCalledWith('/datasets/new/space-1/sources'),
@ -364,10 +481,14 @@ describe('WebsiteCrawlPreview', () => {
expect(clientMock.cancel).not.toHaveBeenCalled()
})
it('cancels a retry that returns after navigation discard was confirmed', async () => {
const retryRequest = deferred<SourceWorkflowRun>()
clientMock.retry.mockReturnValue(retryRequest.promise)
clientMock.cancel.mockResolvedValue(run('canceled'))
it('cancels a fresh re-crawl that returns after navigation discard was confirmed', async () => {
const recrawlRequest = deferred<SourceWorkflowRun>()
clientMock.startPreview
.mockResolvedValueOnce(run('running'))
.mockReturnValueOnce(recrawlRequest.promise)
clientMock.cancel
.mockResolvedValueOnce(run('canceled'))
.mockResolvedValueOnce(run('canceled', { id: 'run-2' }))
render(
<>
<a href="/datasets/new/space-1/documents">Documents navigation</a>
@ -378,28 +499,29 @@ describe('WebsiteCrawlPreview', () => {
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.crawlAndPreview' }))
await screen.findByText('Getting started')
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.reCrawl' }))
await waitFor(() => expect(clientMock.retry).toHaveBeenCalledOnce())
await waitFor(() => expect(clientMock.startPreview).toHaveBeenCalledTimes(2))
await user.click(screen.getByRole('link', { name: 'Documents navigation' }))
await user.click(
screen.getByRole('button', { name: 'dataset.newKnowledge.discardSourceChangesConfirm' }),
)
retryRequest.resolve(run('running'))
recrawlRequest.resolve(run('running', { id: 'run-2' }))
await waitFor(() => expect(clientMock.cancel).toHaveBeenCalledOnce())
await waitFor(() => expect(clientMock.cancel).toHaveBeenCalledTimes(2))
expect(clientMock.retry).not.toHaveBeenCalled()
await waitFor(() =>
expect(routerMock.push).toHaveBeenCalledWith('/datasets/new/space-1/documents'),
)
})
it('reconciles a response-lost retry before leaving the preview', async () => {
const previousRun = run('succeeded', { progressCompleted: 1, progressTotal: 1 })
clientMock.getRun
.mockResolvedValueOnce(previousRun)
.mockResolvedValueOnce(previousRun)
.mockResolvedValueOnce(run('running', { executionAttempts: 2 }))
clientMock.retry.mockRejectedValue(new Error('response lost'))
clientMock.cancel.mockResolvedValue(run('canceled', { executionAttempts: 2 }))
it('reconciles a response-lost fresh re-crawl before leaving the preview', async () => {
clientMock.startPreview
.mockResolvedValueOnce(run('running'))
.mockRejectedValueOnce(new Error('response lost'))
.mockResolvedValueOnce(run('running', { id: 'run-2' }))
clientMock.cancel
.mockResolvedValueOnce(run('canceled'))
.mockResolvedValueOnce(run('canceled', { id: 'run-2' }))
render(
<>
<a href="/datasets/new/space-1/documents">Documents navigation</a>
@ -410,15 +532,18 @@ describe('WebsiteCrawlPreview', () => {
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.crawlAndPreview' }))
await screen.findByText('Getting started')
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.reCrawl' }))
await waitFor(() => expect(clientMock.getRun).toHaveBeenCalledTimes(2))
await waitFor(() => expect(clientMock.startPreview).toHaveBeenCalledTimes(2))
await user.click(screen.getByRole('link', { name: 'Documents navigation' }))
await user.click(
screen.getByRole('button', { name: 'dataset.newKnowledge.discardSourceChangesConfirm' }),
)
await waitFor(() => expect(clientMock.getRun).toHaveBeenCalledTimes(3))
await waitFor(() => expect(clientMock.cancel).toHaveBeenCalledOnce())
await waitFor(() => expect(clientMock.startPreview).toHaveBeenCalledTimes(3))
await waitFor(() => expect(clientMock.cancel).toHaveBeenCalledTimes(2))
expect(clientMock.startPreview.mock.calls[1]?.[0].headers).toEqual(
clientMock.startPreview.mock.calls[2]?.[0].headers,
)
await waitFor(() =>
expect(routerMock.push).toHaveBeenCalledWith('/datasets/new/space-1/documents'),
)
@ -448,37 +573,17 @@ describe('WebsiteCrawlPreview', () => {
)
await waitFor(() => expect(clientMock.cancel).toHaveBeenCalledTimes(2))
expect(clientMock.cancel.mock.calls[0]?.[0].params.runId).toBe('run-1')
expect(clientMock.cancel.mock.calls[1]?.[0].params.runId).toBe('run-1')
expect(clientMock.cancel.mock.calls[0]?.[0].params.run_id).toBe('run-1')
expect(clientMock.cancel.mock.calls[1]?.[0].params.run_id).toBe('run-1')
await waitFor(() =>
expect(routerMock.push).toHaveBeenCalledWith('/datasets/new/space-1/sources'),
)
})
it('restores polling after cancel failure and honors the latest terminal snapshot', async () => {
const retryRequest = deferred<SourceWorkflowRun>()
clientMock.getRun
.mockResolvedValueOnce(run('succeeded', { progressCompleted: 1, progressTotal: 1 }))
.mockResolvedValueOnce(
run('canceled', {
executionAttempts: 2,
progressCompleted: 1,
updatedAt: '2026-07-20T10:02:00Z',
}),
)
clientMock.getPages
.mockResolvedValueOnce({
items: [
{
pageId: 'page-1',
sourceUrl: 'https://docs.dify.ai/getting-started',
title: 'Getting started',
},
],
})
.mockResolvedValueOnce({ items: [] })
clientMock.retry.mockReturnValue(retryRequest.promise)
clientMock.cancel.mockRejectedValue(Object.assign(new Error('conflict'), { status: 409 }))
it('keeps the preview available when re-crawl cancellation fails', async () => {
clientMock.cancel
.mockRejectedValueOnce(Object.assign(new Error('conflict'), { status: 409 }))
.mockResolvedValueOnce(run('canceled'))
render(
<>
<a href="/datasets/new/space-1/documents">Documents navigation</a>
@ -489,28 +594,16 @@ describe('WebsiteCrawlPreview', () => {
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.crawlAndPreview' }))
await screen.findByText('Getting started')
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.reCrawl' }))
await user.click(screen.getByRole('link', { name: 'Documents navigation' }))
await user.click(
screen.getByRole('button', { name: 'dataset.newKnowledge.discardSourceChangesConfirm' }),
)
retryRequest.resolve(
run('running', { executionAttempts: 2, updatedAt: '2026-07-20T10:01:00Z' }),
)
expect(await within(screen.getByRole('alertdialog')).findByRole('alert')).toHaveTextContent(
'dataset.newKnowledge.crawlFailedDescription',
)
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.keepEditing' }))
await waitFor(() => expect(clientMock.getRun).toHaveBeenCalledTimes(2))
expect(await screen.findByText('dataset.newKnowledge.crawlStopped')).toBeInTheDocument()
expect(screen.queryByText('Getting started')).not.toBeInTheDocument()
await waitFor(() => expect(clientMock.cancel).toHaveBeenCalledOnce())
expect(clientMock.startPreview).toHaveBeenCalledOnce()
expect(screen.getByText('Getting started')).toBeInTheDocument()
await user.click(screen.getByRole('link', { name: 'Documents navigation' }))
await user.click(
screen.getByRole('button', { name: 'dataset.newKnowledge.discardSourceChangesConfirm' }),
)
expect(clientMock.cancel).toHaveBeenCalledOnce()
await waitFor(() => expect(clientMock.cancel).toHaveBeenCalledTimes(2))
await waitFor(() =>
expect(routerMock.push).toHaveBeenCalledWith('/datasets/new/space-1/documents'),
)
@ -622,9 +715,9 @@ describe('WebsiteCrawlPreview', () => {
expect(routerMock.push).not.toHaveBeenCalledWith('/datasets/new/space-1/documents')
})
it('shows pending feedback while a completed crawl is being restarted', async () => {
const retryRequest = deferred<SourceWorkflowRun>()
clientMock.retry.mockReturnValue(retryRequest.promise)
it('shows pending feedback while a preview-ready crawl is being restarted', async () => {
const cancelRequest = deferred<SourceWorkflowRun>()
clientMock.cancel.mockReturnValue(cancelRequest.promise)
render(<WebsiteCrawlPreview connection={connection} knowledgeSpaceId="space-1" />)
const user = await fillValidForm()
@ -633,12 +726,9 @@ describe('WebsiteCrawlPreview', () => {
await user.click(reCrawl)
expect(reCrawl).toHaveAttribute('aria-disabled', 'true')
await act(async () =>
retryRequest.resolve(
run('running', { executionAttempts: 2, updatedAt: '2026-07-20T10:01:00Z' }),
),
)
await waitFor(() => expect(clientMock.retry).toHaveBeenCalledOnce())
await act(async () => cancelRequest.resolve(run('canceled')))
await waitFor(() => expect(clientMock.startPreview).toHaveBeenCalledTimes(2))
expect(clientMock.retry).not.toHaveBeenCalled()
})
it('streams page cursors while running and replaces them with the final snapshot', async () => {
@ -681,14 +771,14 @@ describe('WebsiteCrawlPreview', () => {
expect(await screen.findByText('Two')).toBeInTheDocument()
expect(clientMock.getRun).toHaveBeenNthCalledWith(1, {
params: { id: 'space-1', runId: 'run-1' },
params: { control_space_id: 'space-1', run_id: 'run-1' },
})
expect(clientMock.getPages).toHaveBeenNthCalledWith(1, {
params: { id: 'space-1', runId: 'run-1' },
params: { control_space_id: 'space-1', run_id: 'run-1' },
query: { limit: 200 },
})
expect(clientMock.getPages).toHaveBeenNthCalledWith(2, {
params: { id: 'space-1', runId: 'run-1' },
params: { control_space_id: 'space-1', run_id: 'run-1' },
query: { cursor: 'page-2', limit: 200 },
})
expect(
@ -709,11 +799,11 @@ describe('WebsiteCrawlPreview', () => {
expect(screen.queryByText('Old one')).not.toBeInTheDocument()
expect(screen.queryByText('Deleted page')).not.toBeInTheDocument()
expect(clientMock.getPages).toHaveBeenNthCalledWith(3, {
params: { id: 'space-1', runId: 'run-1' },
params: { control_space_id: 'space-1', run_id: 'run-1' },
query: { limit: 200 },
})
expect(clientMock.getPages).toHaveBeenNthCalledWith(4, {
params: { id: 'space-1', runId: 'run-1' },
params: { control_space_id: 'space-1', run_id: 'run-1' },
query: { cursor: 'final-page-2', limit: 200 },
})
expect(
@ -738,7 +828,7 @@ describe('WebsiteCrawlPreview', () => {
await waitFor(() => expect(clientMock.cancel).toHaveBeenCalledOnce())
expect(clientMock.cancel).toHaveBeenCalledWith({
body: { reason: 'user_requested' },
params: { id: 'space-1', runId: 'run-1' },
params: { control_space_id: 'space-1', run_id: 'run-1' },
})
expect(screen.getByText('Getting started')).toBeInTheDocument()
expect(await screen.findByText('dataset.newKnowledge.crawlStopped')).toHaveAttribute(
@ -822,7 +912,7 @@ describe('WebsiteCrawlPreview', () => {
await waitFor(() => expect(clientMock.retry).toHaveBeenCalledOnce())
expect(clientMock.retry).toHaveBeenCalledWith({
params: { id: 'space-1', runId: 'run-1' },
params: { control_space_id: 'space-1', run_id: 'run-1' },
})
expect(clientMock.createSource).toHaveBeenCalledOnce()
expect(clientMock.startPreview).toHaveBeenCalledOnce()
@ -861,7 +951,7 @@ describe('WebsiteCrawlPreview', () => {
.mockResolvedValueOnce(run('running'))
.mockResolvedValueOnce(failedRun)
.mockResolvedValueOnce(
run('succeeded', { progressCompleted: 1, updatedAt: '2026-07-20T10:01:00Z' }),
run('preview_ready', { progressCompleted: 1, updatedAt: '2026-07-20T10:01:00Z' }),
)
clientMock.getPages.mockResolvedValueOnce({ items: [] }).mockResolvedValue({
items: [
@ -873,6 +963,7 @@ describe('WebsiteCrawlPreview', () => {
],
})
clientMock.retry.mockResolvedValue(run('running'))
clientMock.cancel.mockResolvedValue(run('canceled'))
render(<WebsiteCrawlPreview connection={connection} knowledgeSpaceId="space-1" />)
const user = await fillValidForm()
@ -892,7 +983,9 @@ describe('WebsiteCrawlPreview', () => {
await screen.findByText('Getting started')
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.reCrawl' }))
await waitFor(() => expect(clientMock.retry).toHaveBeenCalledTimes(2))
await waitFor(() => expect(clientMock.startPreview).toHaveBeenCalledTimes(2))
expect(clientMock.retry).toHaveBeenCalledOnce()
expect(clientMock.cancel).toHaveBeenCalledOnce()
})
it('reconciles a lost Retry response without sending retry twice', async () => {
@ -987,9 +1080,8 @@ describe('WebsiteCrawlPreview', () => {
})
it('offers an adjust-and-recrawl path after a successful zero-result crawl', async () => {
clientMock.getRun.mockResolvedValue(run('succeeded'))
clientMock.getRun.mockResolvedValue(run('zero_results'))
clientMock.getPages.mockResolvedValue({ items: [] })
clientMock.retry.mockResolvedValue(run('running'))
render(<WebsiteCrawlPreview connection={connection} knowledgeSpaceId="space-1" />)
const user = await fillValidForm()
@ -998,7 +1090,9 @@ describe('WebsiteCrawlPreview', () => {
const noPages = await screen.findByText(/^dataset\.newKnowledge\.noPagesFound:/)
expect(noPages.closest('[role="status"]')).toHaveAttribute('aria-live', 'polite')
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.adjustAndRecrawl' }))
await waitFor(() => expect(clientMock.retry).toHaveBeenCalledOnce())
await waitFor(() => expect(clientMock.startPreview).toHaveBeenCalledTimes(2))
expect(clientMock.retry).not.toHaveBeenCalled()
expect(clientMock.cancel).not.toHaveBeenCalled()
})
it('treats a superseded workflow as terminal and stops polling', async () => {

Some files were not shown because too many files have changed in this diff Show More