mirror of
https://github.com/langgenius/dify.git
synced 2026-09-05 00:31:19 +08:00
feat(knowledge-fs): automate greenfield workspace cutover
This commit is contained in:
parent
d2380dd951
commit
ff07021c22
@ -37,6 +37,7 @@ from services.knowledge_fs.cutover import (
|
||||
ShadowCompletionInput,
|
||||
WorkspaceInventoryInput,
|
||||
)
|
||||
from services.knowledge_fs.greenfield_initializer import KnowledgeFSWorkspaceGreenfieldInitializer
|
||||
from services.knowledge_fs.orphan_reconciler import KnowledgeFSOrphanReconciler
|
||||
from services.knowledge_fs.remote_registry import get_knowledge_fs_lifecycle_remote
|
||||
|
||||
@ -381,6 +382,15 @@ def status(tenant_id: str) -> None:
|
||||
_echo_json(_operator_call(lambda: _cutover_service().status(tenant_id=tenant_id)))
|
||||
|
||||
|
||||
@knowledge_fs_control_space.command("greenfield-initialize")
|
||||
@click.option("--tenant-id", required=True)
|
||||
def greenfield_initialize(tenant_id: str) -> None:
|
||||
"""Idempotently initialize one Workspace that has no KnowledgeFS state."""
|
||||
|
||||
_operator_call(lambda: _greenfield_initializer().ensure_initialized(tenant_id=tenant_id))
|
||||
_echo_json(_operator_call(lambda: _cutover_service().status(tenant_id=tenant_id)))
|
||||
|
||||
|
||||
@knowledge_fs_control_space.command("cleanup-request")
|
||||
@click.option("--input", "input_path", type=click.Path(path_type=Path, exists=True, dir_okay=False), required=True)
|
||||
@click.option("--apply", is_flag=True, default=False, help="Persist readiness evidence; omitted means dry-run.")
|
||||
@ -593,6 +603,13 @@ def _cutover_service() -> KnowledgeFSWorkspaceCutoverService:
|
||||
)
|
||||
|
||||
|
||||
def _greenfield_initializer() -> KnowledgeFSWorkspaceGreenfieldInitializer:
|
||||
return KnowledgeFSWorkspaceGreenfieldInitializer(
|
||||
session_factory.get_session_maker(),
|
||||
cutover=_cutover_service(),
|
||||
)
|
||||
|
||||
|
||||
def _cleanup_service() -> KnowledgeFSCleanupService:
|
||||
return KnowledgeFSCleanupService(session_factory.get_session_maker())
|
||||
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
{
|
||||
"schemaVersion": 5,
|
||||
"subtreeTree": "7071b81f867b755a915f464aa9319f06debc647b",
|
||||
"openapiSha256": "12c5e3674697a70129787efd7d277633a718f6a2d80fcd63a031945c73c54150",
|
||||
"subtreeTree": "8f43d289905c40bfb41642192115847bacfed0bb",
|
||||
"openapiSha256": "3aff366ab900fd89166f9996f5b20a9a58bbc9315701f236f8d4dfc0bda55a3e",
|
||||
"capabilityV2AuthManifestSha256": "fc0a47e23cce12544882f0298522b4933002e892b84ce1815df7e81d36a7a0c7",
|
||||
"capabilityV2AuthTestVectorSha256": "ae0de37b1ff05c40f905cf17a7b410d8971acacf64db07d5ee3d6fecfa559ce3",
|
||||
"productOperationManifestSha256": "671a915b9653608a2db79fc090681757643cd8562a8725cfd7951fa594a2b980",
|
||||
"productOperationManifestSha256": "96639519cb5dc0fe523403a14a86a64be2c3e2f751b475fea1bf57fdc072aff5",
|
||||
"productOperationGapManifestSha256": "332c80165bd5cf8e79bc511374dde771a8175510404db603a8067f1a22d36df8"
|
||||
}
|
||||
|
||||
@ -10,6 +10,9 @@
|
||||
{"productOperationId":"getOverviewInventory","kfsOperationId":"getKnowledgeSpaceOverviewInventory","method":"GET","path":"/knowledge-spaces/{id}/overview/inventory","action":"knowledge_spaces.overview.inventory.read","resource":"knowledge_space","transport":"json","stream":{"productKind":"json","kfsResponseKind":"buffered"},"limits":{"productMaxRequestBytes":0,"productMaxResponseBytes":262144,"kfsMaxResponseBytes":1048576}},
|
||||
{"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":"updateEmbeddingProfile","kfsOperationId":"updateKnowledgeSpaceEmbeddingProfile","method":"PUT","path":"/knowledge-spaces/{id}/embedding-profile","action":"knowledge_spaces.settings.update","resource":"knowledge_space","transport":"json","stream":{"productKind":"json","kfsResponseKind":"buffered"},"limits":{"productMaxRequestBytes":32768,"productMaxResponseBytes":262144,"kfsMaxResponseBytes":1048576}},
|
||||
{"productOperationId":"updateRetrievalProfile","kfsOperationId":"updateKnowledgeSpaceRetrievalProfile","method":"PUT","path":"/knowledge-spaces/{id}/retrieval-profile","action":"knowledge_spaces.settings.update","resource":"knowledge_space","transport":"json","stream":{"productKind":"json","kfsResponseKind":"buffered"},"limits":{"productMaxRequestBytes":65536,"productMaxResponseBytes":262144,"kfsMaxResponseBytes":1048576}},
|
||||
{"productOperationId":"getProfileMigration","kfsOperationId":"getKnowledgeSpaceProfileMigration","method":"GET","path":"/knowledge-spaces/{id}/profile-migrations/{migrationId}","action":"knowledge_spaces.settings.read","resource":"knowledge_space","transport":"json","stream":{"productKind":"json","kfsResponseKind":"buffered"},"limits":{"productMaxRequestBytes":0,"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}},
|
||||
|
||||
@ -7734,10 +7734,7 @@ Get instruction generation template
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | KnowledgeFS documents | **application/json**: [KnowledgeFSDocumentListResponse](#knowledgefsdocumentlistresponse)<br> |
|
||||
|
||||
### ~~[POST] /knowledge-fs/spaces/{control_space_id}/documents~~
|
||||
|
||||
***DEPRECATED***
|
||||
|
||||
### [POST] /knowledge-fs/spaces/{control_space_id}/documents
|
||||
#### Parameters
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
@ -7748,13 +7745,13 @@ Get instruction generation template
|
||||
|
||||
| Required | Schema |
|
||||
| -------- | ------ |
|
||||
| Yes | **application/json**: [KnowledgeFSDocumentCreatePayload](#knowledgefsdocumentcreatepayload)<br> |
|
||||
| Yes | **multipart/form-data**: { **"file"**: binary }<br> |
|
||||
|
||||
#### Responses
|
||||
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 201 | KnowledgeFS document created | **application/json**: [KnowledgeFSDocumentResponse](#knowledgefsdocumentresponse)<br> |
|
||||
| 202 | KnowledgeFS document accepted for processing | **application/json**: [KnowledgeFSDocumentUploadAcceptedResponse](#knowledgefsdocumentuploadacceptedresponse)<br> |
|
||||
|
||||
### [DELETE] /knowledge-fs/spaces/{control_space_id}/documents/bulk
|
||||
#### Parameters
|
||||
@ -8443,6 +8440,7 @@ Get instruction generation template
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
| ---- | ---------- | ----------- | -------- | ------ |
|
||||
| cursor | query | | No | string |
|
||||
| limit | query | | No | integer, <br>**Default:** 50 |
|
||||
| control_space_id | path | | Yes | string |
|
||||
|
||||
#### Responses
|
||||
@ -8678,6 +8676,27 @@ Get instruction generation template
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | KnowledgeFS source credential test | **application/json**: [KnowledgeFSSourceCredentialTestResponse](#knowledgefssourcecredentialtestresponse)<br> |
|
||||
|
||||
### [POST] /knowledge-fs/spaces/{control_space_id}/sources/{source_id}/workflow-imports
|
||||
#### Parameters
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
| ---- | ---------- | ----------- | -------- | ------ |
|
||||
| Idempotency-Key | header | Stable key used to make the mutation safe to retry | Yes | string |
|
||||
| control_space_id | path | | Yes | string |
|
||||
| source_id | path | | Yes | string |
|
||||
|
||||
#### Request Body
|
||||
|
||||
| Required | Schema |
|
||||
| -------- | ------ |
|
||||
| Yes | **application/json**: [KnowledgeFSSourceWorkflowImportPayload](#knowledgefssourceworkflowimportpayload)<br> |
|
||||
|
||||
#### Responses
|
||||
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 202 | KnowledgeFS durable provider import accepted | **application/json**: [KnowledgeFSSourceWorkflowResponse](#knowledgefssourceworkflowresponse)<br> |
|
||||
|
||||
### [GET] /knowledge-fs/spaces/{control_space_id}/traces
|
||||
#### Parameters
|
||||
|
||||
@ -20412,14 +20431,6 @@ Input field definition for snippet parameters.
|
||||
| updated_at | number | | Yes |
|
||||
| version | integer | | Yes |
|
||||
|
||||
#### KnowledgeFSDocumentCreatePayload
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| idempotency_key | string | | Yes |
|
||||
| name | string | | Yes |
|
||||
| text | string | | Yes |
|
||||
|
||||
#### KnowledgeFSDocumentDeletePayload
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
@ -20543,6 +20554,33 @@ Input field definition for snippet parameters.
|
||||
| size_bytes | integer | | Yes |
|
||||
| state | string, <br>**Available values:** "active", "candidate", "failed", "superseded" | *Enum:* `"active"`, `"candidate"`, `"failed"`, `"superseded"` | Yes |
|
||||
|
||||
#### KnowledgeFSDocumentUploadAcceptedResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| asset | [KnowledgeFSDocumentResponse](#knowledgefsdocumentresponse) | | Yes |
|
||||
| asset_status_url | string | | No |
|
||||
| compilation_job | [KnowledgeFSDocumentUploadCompilationJobResponse](#knowledgefsdocumentuploadcompilationjobresponse) | | Yes |
|
||||
| document_revision | integer | | Yes |
|
||||
| logical_document | [KnowledgeFSDocumentUploadLogicalDocumentResponse](#knowledgefsdocumentuploadlogicaldocumentresponse) | | Yes |
|
||||
| logical_document_id | string | | Yes |
|
||||
| status | string | | No |
|
||||
| status_url | string | | Yes |
|
||||
|
||||
#### KnowledgeFSDocumentUploadCompilationJobResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| id | string | | Yes |
|
||||
| stage | string | | Yes |
|
||||
|
||||
#### KnowledgeFSDocumentUploadLogicalDocumentResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| id | string | | Yes |
|
||||
| revision | integer | | Yes |
|
||||
|
||||
#### KnowledgeFSDurableDeletionAcceptedResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
@ -20676,6 +20714,43 @@ Input field definition for snippet parameters.
|
||||
| plugin_id | string | | Yes |
|
||||
| provider | string | | Yes |
|
||||
|
||||
#### KnowledgeFSOnlineDocumentWorkflowImportItemPayload
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| etag | string | | No |
|
||||
| lastEditedTime | string | | No |
|
||||
| name | string | | No |
|
||||
| pageId | string | | Yes |
|
||||
| providerItemId | string | | Yes |
|
||||
| type | string | | Yes |
|
||||
| workspaceId | string | | Yes |
|
||||
|
||||
#### KnowledgeFSOnlineDocumentWorkflowImportPayload
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| items | [ [KnowledgeFSOnlineDocumentWorkflowImportItemPayload](#knowledgefsonlinedocumentworkflowimportitempayload) ] | | Yes |
|
||||
| kind | string | | Yes |
|
||||
|
||||
#### KnowledgeFSOnlineDriveWorkflowImportItemPayload
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| bucket | string | | No |
|
||||
| etag | string | | No |
|
||||
| id | string | | Yes |
|
||||
| mimeType | string | | No |
|
||||
| name | string | | Yes |
|
||||
| providerItemId | string | | Yes |
|
||||
|
||||
#### KnowledgeFSOnlineDriveWorkflowImportPayload
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| items | [ [KnowledgeFSOnlineDriveWorkflowImportItemPayload](#knowledgefsonlinedriveworkflowimportitempayload) ] | | Yes |
|
||||
| kind | string | | Yes |
|
||||
|
||||
#### KnowledgeFSOverviewCountComparisonResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
@ -21374,6 +21449,12 @@ Input field definition for snippet parameters.
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| reason | string | | No |
|
||||
|
||||
#### KnowledgeFSSourceWorkflowImportPayload
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| KnowledgeFSSourceWorkflowImportPayload | [KnowledgeFSOnlineDocumentWorkflowImportPayload](#knowledgefsonlinedocumentworkflowimportpayload)<br>[KnowledgeFSOnlineDriveWorkflowImportPayload](#knowledgefsonlinedriveworkflowimportpayload) | | |
|
||||
|
||||
#### KnowledgeFSSourceWorkflowResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
|
||||
@ -11,12 +11,13 @@ from __future__ import annotations
|
||||
import hashlib
|
||||
import json
|
||||
from collections.abc import Callable, Iterable
|
||||
from datetime import UTC, datetime
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Literal, NamedTuple, cast
|
||||
from uuid import NAMESPACE_URL, UUID, uuid5
|
||||
|
||||
import sqlalchemy as sa
|
||||
from pydantic import BaseModel, ConfigDict, Field, JsonValue, ValidationError, field_validator, model_validator
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from libs.datetime_utils import naive_utc_now
|
||||
@ -397,6 +398,8 @@ _CUTOVER_QUARANTINE_KINDS = (
|
||||
)
|
||||
_DEFAULT_TRUSTED_SHADOW_PRODUCERS = frozenset({"dify-shadow-authorizer"})
|
||||
_DEFAULT_TRUSTED_SHADOW_OPERATORS = frozenset({"knowledge-fs-cutover"})
|
||||
_GREENFIELD_INITIALIZATION_MAX_ATTEMPTS = 32
|
||||
_GREENFIELD_ROLLBACK_WINDOW = timedelta(days=30)
|
||||
_ZERO_REVISION_WATERMARK: KnowledgeFSCutoverRevisionWatermark = {
|
||||
"membership_epoch": 0,
|
||||
"space_acl_epoch": 0,
|
||||
@ -434,6 +437,114 @@ class KnowledgeFSWorkspaceCutoverService:
|
||||
self._trusted_shadow_operators = trusted_shadow_operators
|
||||
self._remote_factory = remote_factory
|
||||
|
||||
def initialize_greenfield(
|
||||
self,
|
||||
*,
|
||||
tenant_id: str,
|
||||
owner_account_id: str,
|
||||
) -> KnowledgeFSWorkspaceCutoverLedger:
|
||||
"""Idempotently cut over a Workspace that has never owned KnowledgeFS state."""
|
||||
|
||||
try:
|
||||
tenant_uuid = UUID(tenant_id)
|
||||
owner_uuid = UUID(owner_account_id)
|
||||
except ValueError as exc:
|
||||
raise KnowledgeFSCutoverConflictError("Greenfield Workspace and owner identifiers must be UUIDs") from exc
|
||||
if not self._trusted_shadow_producers or not self._trusted_shadow_operators:
|
||||
raise KnowledgeFSCutoverGateBlockedError("Greenfield shadow attestation is not configured")
|
||||
|
||||
inventory = WorkspaceInventoryInput.model_validate(
|
||||
{
|
||||
"tenant_id": str(tenant_uuid),
|
||||
"source_revision_watermark": _ZERO_REVISION_WATERMARK,
|
||||
"task_watermark": 0,
|
||||
"spaces": [],
|
||||
}
|
||||
)
|
||||
for _ in range(_GREENFIELD_INITIALIZATION_MAX_ATTEMPTS):
|
||||
ledger = self._greenfield_initialization_ledger(
|
||||
tenant_id=tenant_id,
|
||||
owner_account_id=owner_account_id,
|
||||
)
|
||||
if ledger is not None and _has_complete_product_cutover(ledger):
|
||||
return ledger
|
||||
try:
|
||||
if ledger is None:
|
||||
self.inventory(inventory, apply=True)
|
||||
elif ledger.phase is KnowledgeFSWorkspaceCutoverPhase.INVENTORY:
|
||||
self.backfill(inventory, apply=True)
|
||||
elif ledger.phase is KnowledgeFSWorkspaceCutoverPhase.BACKFILL:
|
||||
self.begin_shadow(
|
||||
tenant_id=tenant_id,
|
||||
expected_cas_version=ledger.cas_version,
|
||||
started_at=self._clock(),
|
||||
)
|
||||
elif ledger.phase is KnowledgeFSWorkspaceCutoverPhase.SHADOW:
|
||||
if ledger.shadow_completed_at is None:
|
||||
completed_at = _naive_utc(self._clock()).replace(tzinfo=UTC)
|
||||
self.complete_shadow(
|
||||
ShadowCompletionInput.model_validate(
|
||||
{
|
||||
"schema_version": "knowledge-fs-p8-shadow-completion/v1",
|
||||
"tenant_id": tenant_id,
|
||||
"expected_cas_version": ledger.cas_version,
|
||||
"producer": min(self._trusted_shadow_producers),
|
||||
"completed_by_operator": min(self._trusted_shadow_operators),
|
||||
"completed_by_account_id": str(owner_uuid),
|
||||
"completed_at": completed_at,
|
||||
"traffic_zero": True,
|
||||
"traffic_zero_evidence": {
|
||||
"schema_version": "knowledge-fs-greenfield-traffic-zero/v1",
|
||||
"basis": "no-local-control-spaces",
|
||||
},
|
||||
}
|
||||
),
|
||||
apply=True,
|
||||
)
|
||||
elif not ledger.legacy_dependency_ready:
|
||||
self.legacy_dependency_dashboard(
|
||||
tenant_id=tenant_id,
|
||||
dependencies=(),
|
||||
expected_cas_version=ledger.cas_version,
|
||||
checked_at=self._clock(),
|
||||
apply=True,
|
||||
)
|
||||
else:
|
||||
self.freeze(
|
||||
tenant_id=tenant_id,
|
||||
expected_cas_version=ledger.cas_version,
|
||||
freeze_at=self._clock(),
|
||||
)
|
||||
elif ledger.phase is KnowledgeFSWorkspaceCutoverPhase.FROZEN:
|
||||
if not _has_complete_greenfield_final_delta(ledger):
|
||||
self.apply_final_delta(
|
||||
FinalDeltaInput.model_validate(
|
||||
{
|
||||
"tenant_id": tenant_id,
|
||||
"expected_cas_version": ledger.cas_version,
|
||||
"final_revision_watermark": _ZERO_REVISION_WATERMARK,
|
||||
"applied_revision_watermark": _ZERO_REVISION_WATERMARK,
|
||||
"final_task_watermark": 0,
|
||||
"applied_task_watermark": 0,
|
||||
}
|
||||
)
|
||||
)
|
||||
else:
|
||||
cutover_at = _naive_utc(self._clock())
|
||||
self.cutover(
|
||||
tenant_id=tenant_id,
|
||||
expected_cas_version=ledger.cas_version,
|
||||
cutover_at=cutover_at,
|
||||
rollback_cutoff_at=cutover_at + _GREENFIELD_ROLLBACK_WINDOW,
|
||||
)
|
||||
else:
|
||||
raise KnowledgeFSCutoverGateBlockedError(
|
||||
"Workspace is not eligible for automatic greenfield initialization"
|
||||
)
|
||||
except (IntegrityError, KnowledgeFSCutoverConflictError):
|
||||
continue
|
||||
raise KnowledgeFSCutoverConflictError("Greenfield Workspace initialization did not converge")
|
||||
|
||||
def inventory(self, payload: WorkspaceInventoryInput, *, apply: bool) -> CutoverInventoryReport:
|
||||
"""Validate a read-only inventory and optionally create the initial CAS ledger."""
|
||||
|
||||
@ -528,6 +639,58 @@ class KnowledgeFSWorkspaceCutoverService:
|
||||
phase = ledger.phase.value
|
||||
return CutoverBackfillReport(tenant_id, registered, granted, quarantined, open_issues, phase, True)
|
||||
|
||||
def _greenfield_initialization_ledger(
|
||||
self,
|
||||
*,
|
||||
tenant_id: str,
|
||||
owner_account_id: str,
|
||||
) -> KnowledgeFSWorkspaceCutoverLedger | None:
|
||||
with self._session_maker() as session:
|
||||
repository = SQLAlchemyKnowledgeFSCutoverRepository(session)
|
||||
ledger = repository.get_ledger(tenant_id=tenant_id)
|
||||
control_spaces = tuple(
|
||||
session.scalars(
|
||||
sa.select(KnowledgeFSControlSpace)
|
||||
.where(KnowledgeFSControlSpace.tenant_id == tenant_id)
|
||||
.order_by(KnowledgeFSControlSpace.created_at, KnowledgeFSControlSpace.id)
|
||||
).all()
|
||||
)
|
||||
if ledger is None:
|
||||
if control_spaces:
|
||||
raise KnowledgeFSCutoverGateBlockedError(
|
||||
"Workspace with existing KnowledgeFS control state is not greenfield"
|
||||
)
|
||||
return None
|
||||
if not _is_zero_space_cutover_ledger(ledger) or ledger.rolled_back_at is not None:
|
||||
raise KnowledgeFSCutoverGateBlockedError(
|
||||
"Workspace migration state is not eligible for automatic greenfield initialization"
|
||||
)
|
||||
if ledger.legacy_dependency_report not in (None, []):
|
||||
raise KnowledgeFSCutoverGateBlockedError(
|
||||
"Workspace with legacy dependencies is not eligible for automatic greenfield initialization"
|
||||
)
|
||||
if control_spaces:
|
||||
anchor_id = _greenfield_activation_anchor_id(tenant_id)
|
||||
provisioning_key = _greenfield_activation_provisioning_key(tenant_id)
|
||||
if len(control_spaces) != 1:
|
||||
raise KnowledgeFSCutoverGateBlockedError(
|
||||
"Workspace with existing KnowledgeFS control state is not greenfield"
|
||||
)
|
||||
anchor = control_spaces[0]
|
||||
if (
|
||||
anchor.id != anchor_id
|
||||
or anchor.state is not KnowledgeFSControlSpaceState.DELETED
|
||||
or anchor.knowledge_space_id is not None
|
||||
or anchor.provisioning_key != provisioning_key
|
||||
or anchor.owner_account_id != owner_account_id
|
||||
or anchor.lifecycle_operation_id != anchor_id
|
||||
):
|
||||
raise KnowledgeFSCutoverGateBlockedError(
|
||||
"Workspace greenfield activation audit anchor is inconsistent"
|
||||
)
|
||||
session.expunge(ledger)
|
||||
return ledger
|
||||
|
||||
def begin_shadow(
|
||||
self,
|
||||
*,
|
||||
@ -2410,6 +2573,32 @@ def _is_zero_space_cutover_ledger(ledger: KnowledgeFSWorkspaceCutoverLedger) ->
|
||||
)
|
||||
|
||||
|
||||
def _has_complete_greenfield_final_delta(ledger: KnowledgeFSWorkspaceCutoverLedger) -> bool:
|
||||
return (
|
||||
ledger.final_revision_watermark == _ZERO_REVISION_WATERMARK
|
||||
and ledger.applied_revision_watermark == _ZERO_REVISION_WATERMARK
|
||||
and ledger.final_task_watermark == 0
|
||||
and ledger.applied_task_watermark == 0
|
||||
)
|
||||
|
||||
|
||||
def _has_complete_product_cutover(ledger: KnowledgeFSWorkspaceCutoverLedger) -> bool:
|
||||
return bool(
|
||||
ledger.phase
|
||||
in {
|
||||
KnowledgeFSWorkspaceCutoverPhase.CUTOVER,
|
||||
KnowledgeFSWorkspaceCutoverPhase.OBSERVING,
|
||||
KnowledgeFSWorkspaceCutoverPhase.READY_FOR_CLEANUP,
|
||||
}
|
||||
and ledger.cutover_at is not None
|
||||
and ledger.rolled_back_at is None
|
||||
and ledger.product_routes_enabled
|
||||
and ledger.capability_v2_enabled
|
||||
and ledger.integrated_mode_enabled
|
||||
and ledger.legacy_acl_read_only
|
||||
)
|
||||
|
||||
|
||||
def _revision_digest(revision: KnowledgeFSCutoverRevisionWatermark) -> str:
|
||||
canonical = json.dumps(revision, sort_keys=True, separators=(",", ":")).encode()
|
||||
return f"sha256:{hashlib.sha256(canonical).hexdigest()}"
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Protocol
|
||||
|
||||
import sqlalchemy as sa
|
||||
@ -13,6 +14,8 @@ from models.knowledge_fs_cutover import (
|
||||
)
|
||||
from services.knowledge_fs.product_remote import KnowledgeFSOperationUnavailableError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_TRAFFIC_PHASES = frozenset(
|
||||
{
|
||||
KnowledgeFSWorkspaceCutoverPhase.CUTOVER,
|
||||
@ -30,11 +33,23 @@ class KnowledgeFSWorkspaceRuntimeGatePort(Protocol):
|
||||
def require_capability_v2(self, *, tenant_id: str) -> None: ...
|
||||
|
||||
|
||||
class SQLKnowledgeFSWorkspaceRuntimeGate:
|
||||
"""Read the atomic cutover row and reject missing or partial rollout state."""
|
||||
class KnowledgeFSWorkspaceInitializerPort(Protocol):
|
||||
"""Lazily establish a fail-closed greenfield cutover for one Workspace."""
|
||||
|
||||
def __init__(self, session_maker: sessionmaker[Session]) -> None:
|
||||
def ensure_initialized(self, *, tenant_id: str) -> KnowledgeFSWorkspaceCutoverLedger: ...
|
||||
|
||||
|
||||
class SQLKnowledgeFSWorkspaceRuntimeGate:
|
||||
"""Read the atomic cutover row, lazily bootstrap greenfield, and fail closed."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
session_maker: sessionmaker[Session],
|
||||
*,
|
||||
initializer: KnowledgeFSWorkspaceInitializerPort | None = None,
|
||||
) -> None:
|
||||
self._session_maker = session_maker
|
||||
self._initializer = initializer
|
||||
|
||||
def require_product_routes(self, *, tenant_id: str) -> None:
|
||||
self._require_complete_cutover(tenant_id=tenant_id)
|
||||
@ -43,23 +58,47 @@ class SQLKnowledgeFSWorkspaceRuntimeGate:
|
||||
self._require_complete_cutover(tenant_id=tenant_id)
|
||||
|
||||
def _require_complete_cutover(self, *, tenant_id: str) -> None:
|
||||
ledger = self._read_ledger(tenant_id=tenant_id)
|
||||
if not _is_complete_cutover(ledger) and self._initializer is not None:
|
||||
try:
|
||||
self._initializer.ensure_initialized(tenant_id=tenant_id)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"KnowledgeFS automatic Workspace initialization failed",
|
||||
extra={"tenant_id": tenant_id},
|
||||
exc_info=True,
|
||||
)
|
||||
raise KnowledgeFSOperationUnavailableError(
|
||||
"KnowledgeFS Workspace is not cut over for product traffic"
|
||||
) from exc
|
||||
ledger = self._read_ledger(tenant_id=tenant_id)
|
||||
if not _is_complete_cutover(ledger):
|
||||
raise KnowledgeFSOperationUnavailableError("KnowledgeFS Workspace is not cut over for product traffic")
|
||||
|
||||
def _read_ledger(self, *, tenant_id: str) -> KnowledgeFSWorkspaceCutoverLedger | None:
|
||||
with self._session_maker() as session:
|
||||
ledger = session.scalar(
|
||||
return session.scalar(
|
||||
sa.select(KnowledgeFSWorkspaceCutoverLedger).where(
|
||||
KnowledgeFSWorkspaceCutoverLedger.tenant_id == tenant_id
|
||||
)
|
||||
)
|
||||
if (
|
||||
ledger is None
|
||||
or ledger.phase not in _TRAFFIC_PHASES
|
||||
or ledger.cutover_at is None
|
||||
or ledger.rolled_back_at is not None
|
||||
or not ledger.product_routes_enabled
|
||||
or not ledger.capability_v2_enabled
|
||||
or not ledger.integrated_mode_enabled
|
||||
or not ledger.legacy_acl_read_only
|
||||
):
|
||||
raise KnowledgeFSOperationUnavailableError("KnowledgeFS Workspace is not cut over for product traffic")
|
||||
|
||||
|
||||
__all__ = ["KnowledgeFSWorkspaceRuntimeGatePort", "SQLKnowledgeFSWorkspaceRuntimeGate"]
|
||||
def _is_complete_cutover(ledger: KnowledgeFSWorkspaceCutoverLedger | None) -> bool:
|
||||
return bool(
|
||||
ledger is not None
|
||||
and ledger.phase in _TRAFFIC_PHASES
|
||||
and ledger.cutover_at is not None
|
||||
and ledger.rolled_back_at is None
|
||||
and ledger.product_routes_enabled
|
||||
and ledger.capability_v2_enabled
|
||||
and ledger.integrated_mode_enabled
|
||||
and ledger.legacy_acl_read_only
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"KnowledgeFSWorkspaceInitializerPort",
|
||||
"KnowledgeFSWorkspaceRuntimeGatePort",
|
||||
"SQLKnowledgeFSWorkspaceRuntimeGate",
|
||||
]
|
||||
|
||||
58
api/services/knowledge_fs/greenfield_initializer.py
Normal file
58
api/services/knowledge_fs/greenfield_initializer.py
Normal file
@ -0,0 +1,58 @@
|
||||
"""Automatic bootstrap for Workspaces that have never owned KnowledgeFS state."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from models.account import Tenant, TenantAccountJoin, TenantAccountRole, TenantStatus
|
||||
from models.knowledge_fs_cutover import KnowledgeFSWorkspaceCutoverLedger
|
||||
from services.knowledge_fs.cutover import (
|
||||
KnowledgeFSCutoverGateBlockedError,
|
||||
KnowledgeFSWorkspaceCutoverService,
|
||||
)
|
||||
|
||||
|
||||
class KnowledgeFSWorkspaceGreenfieldInitializer:
|
||||
"""Resolve the Workspace owner and delegate the auditable zero-state cutover."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
session_maker: sessionmaker[Session],
|
||||
*,
|
||||
cutover: KnowledgeFSWorkspaceCutoverService,
|
||||
) -> None:
|
||||
self._session_maker = session_maker
|
||||
self._cutover = cutover
|
||||
|
||||
def ensure_initialized(self, *, tenant_id: str) -> KnowledgeFSWorkspaceCutoverLedger:
|
||||
with self._session_maker() as session:
|
||||
tenant = session.scalar(
|
||||
sa.select(Tenant).where(
|
||||
Tenant.id == tenant_id,
|
||||
Tenant.status == TenantStatus.NORMAL,
|
||||
)
|
||||
)
|
||||
if tenant is None:
|
||||
raise KnowledgeFSCutoverGateBlockedError("KnowledgeFS Workspace is not active")
|
||||
owner_account_ids = tuple(
|
||||
session.scalars(
|
||||
sa.select(TenantAccountJoin.account_id)
|
||||
.where(
|
||||
TenantAccountJoin.tenant_id == tenant_id,
|
||||
TenantAccountJoin.role == TenantAccountRole.OWNER,
|
||||
)
|
||||
.order_by(TenantAccountJoin.account_id)
|
||||
).all()
|
||||
)
|
||||
if len(owner_account_ids) != 1:
|
||||
raise KnowledgeFSCutoverGateBlockedError(
|
||||
"Automatic KnowledgeFS initialization requires exactly one Workspace owner"
|
||||
)
|
||||
return self._cutover.initialize_greenfield(
|
||||
tenant_id=tenant_id,
|
||||
owner_account_id=owner_account_ids[0],
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["KnowledgeFSWorkspaceGreenfieldInitializer"]
|
||||
@ -24,8 +24,10 @@ from services.knowledge_fs.control_plane_service import (
|
||||
)
|
||||
from services.knowledge_fs.control_space_commands import KnowledgeFSControlSpaceCommandService
|
||||
from services.knowledge_fs.credential_service import KnowledgeFSCredentialService
|
||||
from services.knowledge_fs.cutover import KnowledgeFSWorkspaceCutoverService
|
||||
from services.knowledge_fs.cutover_runtime_gate import SQLKnowledgeFSWorkspaceRuntimeGate
|
||||
from services.knowledge_fs.data_facade import KnowledgeFSDataFacade
|
||||
from services.knowledge_fs.greenfield_initializer import KnowledgeFSWorkspaceGreenfieldInitializer
|
||||
from services.knowledge_fs.operation_admission import (
|
||||
DifyKnowledgeFSBillingPort,
|
||||
DifyKnowledgeFSWeightedRateLimitPort,
|
||||
@ -38,6 +40,7 @@ from services.knowledge_fs.product_authorization import DifyKnowledgeFSProductRB
|
||||
from services.knowledge_fs.product_remote import KnowledgeFSOperationUnavailableError
|
||||
from services.knowledge_fs.product_remote_http import HTTPKnowledgeFSProductRemoteClient
|
||||
from services.knowledge_fs.product_service import KnowledgeFSProductService
|
||||
from services.knowledge_fs.remote_registry import get_knowledge_fs_lifecycle_remote
|
||||
from services.knowledge_fs.revocation_commands import KnowledgeFSRevocationCommandProducer
|
||||
from services.knowledge_fs_capability import create_configured_knowledge_fs_capability_issuer
|
||||
|
||||
@ -68,7 +71,18 @@ def create_knowledge_fs_runtime(session_maker: sessionmaker[Session]) -> Knowled
|
||||
issuer = create_configured_knowledge_fs_capability_issuer(
|
||||
audit=SQLAlchemyKnowledgeFSCapabilityIssuanceAuditor(session_maker)
|
||||
)
|
||||
cutover_gate = SQLKnowledgeFSWorkspaceRuntimeGate(session_maker)
|
||||
greenfield_cutover = KnowledgeFSWorkspaceCutoverService(
|
||||
session_maker,
|
||||
remote_factory=get_knowledge_fs_lifecycle_remote,
|
||||
)
|
||||
greenfield_initializer = KnowledgeFSWorkspaceGreenfieldInitializer(
|
||||
session_maker,
|
||||
cutover=greenfield_cutover,
|
||||
)
|
||||
cutover_gate = SQLKnowledgeFSWorkspaceRuntimeGate(
|
||||
session_maker,
|
||||
initializer=greenfield_initializer,
|
||||
)
|
||||
batch_capabilities = KnowledgeFSBatchCapabilityBroker(
|
||||
session_maker,
|
||||
cutover_gate=cutover_gate,
|
||||
|
||||
@ -106,6 +106,7 @@ def test_control_space_command_exposes_p8_migration_and_cutover_operations() ->
|
||||
"legacy-check",
|
||||
"freeze",
|
||||
"final-delta",
|
||||
"greenfield-initialize",
|
||||
"cutover",
|
||||
"smoke",
|
||||
"observe",
|
||||
@ -120,6 +121,31 @@ def test_control_space_command_exposes_p8_migration_and_cutover_operations() ->
|
||||
} <= set(knowledge_fs_control_space.commands)
|
||||
|
||||
|
||||
def test_greenfield_initialize_cli_runs_the_automatic_initializer() -> None:
|
||||
with (
|
||||
patch("commands.knowledge_fs._greenfield_initializer") as initializer_factory,
|
||||
patch("commands.knowledge_fs._cutover_service") as cutover_factory,
|
||||
):
|
||||
cutover_factory.return_value.status.return_value = {
|
||||
"tenant_id": "00000000-0000-0000-0000-000000000001",
|
||||
"phase": "cutover",
|
||||
}
|
||||
result = CliRunner().invoke(
|
||||
knowledge_fs_control_space,
|
||||
[
|
||||
"greenfield-initialize",
|
||||
"--tenant-id",
|
||||
"00000000-0000-0000-0000-000000000001",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0
|
||||
initializer_factory.return_value.ensure_initialized.assert_called_once_with(
|
||||
tenant_id="00000000-0000-0000-0000-000000000001"
|
||||
)
|
||||
assert '"phase": "cutover"' in result.output
|
||||
|
||||
|
||||
def test_operator_cutover_service_installs_remote_registry_lazily() -> None:
|
||||
with patch("commands.knowledge_fs.get_knowledge_fs_lifecycle_remote") as remote_factory:
|
||||
service = _cutover_service()
|
||||
|
||||
@ -479,6 +479,82 @@ def test_empty_workspace_uses_a_stable_greenfield_anchor_for_remote_freeze_and_a
|
||||
)
|
||||
|
||||
|
||||
def test_greenfield_initialization_advances_an_empty_workspace_idempotently(
|
||||
cutover_context: tuple[KnowledgeFSWorkspaceCutoverService, sessionmaker[Session]],
|
||||
) -> None:
|
||||
_, session_maker = cutover_context
|
||||
remote = FakeActivationRemote()
|
||||
service = _service_with_remote(session_maker, remote)
|
||||
tenant_id = str(uuid4())
|
||||
owner_account_id = str(uuid4())
|
||||
|
||||
ledger = service.initialize_greenfield(
|
||||
tenant_id=tenant_id,
|
||||
owner_account_id=owner_account_id,
|
||||
)
|
||||
replay = service.initialize_greenfield(
|
||||
tenant_id=tenant_id,
|
||||
owner_account_id=owner_account_id,
|
||||
)
|
||||
|
||||
assert ledger.phase is KnowledgeFSWorkspaceCutoverPhase.CUTOVER
|
||||
assert ledger.product_routes_enabled is True
|
||||
assert ledger.capability_v2_enabled is True
|
||||
assert ledger.integrated_mode_enabled is True
|
||||
assert ledger.legacy_acl_read_only is True
|
||||
assert replay.cas_version == ledger.cas_version
|
||||
assert len(remote.freeze_requests) == 1
|
||||
assert len(remote.requests) == 1
|
||||
with session_maker() as session:
|
||||
audit_anchor = session.scalar(
|
||||
sa.select(KnowledgeFSControlSpace).where(KnowledgeFSControlSpace.tenant_id == tenant_id)
|
||||
)
|
||||
assert audit_anchor is not None
|
||||
assert audit_anchor.owner_account_id == owner_account_id
|
||||
assert audit_anchor.state is KnowledgeFSControlSpaceState.DELETED
|
||||
|
||||
|
||||
def test_greenfield_initialization_resumes_after_a_lost_remote_freeze_ack(
|
||||
cutover_context: tuple[KnowledgeFSWorkspaceCutoverService, sessionmaker[Session]],
|
||||
) -> None:
|
||||
_, session_maker = cutover_context
|
||||
remote = FakeActivationRemote()
|
||||
remote.freeze_fail_after_persist = True
|
||||
service = _service_with_remote(session_maker, remote)
|
||||
tenant_id = str(uuid4())
|
||||
owner_account_id = str(uuid4())
|
||||
|
||||
with pytest.raises(KnowledgeFSCutoverGateBlockedError, match="freeze was not acknowledged"):
|
||||
service.initialize_greenfield(
|
||||
tenant_id=tenant_id,
|
||||
owner_account_id=owner_account_id,
|
||||
)
|
||||
|
||||
ledger = service.initialize_greenfield(
|
||||
tenant_id=tenant_id,
|
||||
owner_account_id=owner_account_id,
|
||||
)
|
||||
|
||||
assert ledger.phase is KnowledgeFSWorkspaceCutoverPhase.CUTOVER
|
||||
assert len(remote.freeze_requests) == 2
|
||||
assert remote.freeze_requests[0] == remote.freeze_requests[1]
|
||||
|
||||
|
||||
def test_greenfield_initialization_rejects_existing_migration_state(
|
||||
cutover_context: tuple[KnowledgeFSWorkspaceCutoverService, sessionmaker[Session]],
|
||||
) -> None:
|
||||
service, _ = cutover_context
|
||||
payload = _inventory()
|
||||
tenant_id = str(payload.tenant_id)
|
||||
service.inventory(payload, apply=True)
|
||||
|
||||
with pytest.raises(KnowledgeFSCutoverGateBlockedError, match="not eligible"):
|
||||
service.initialize_greenfield(
|
||||
tenant_id=tenant_id,
|
||||
owner_account_id=str(uuid4()),
|
||||
)
|
||||
|
||||
|
||||
def test_empty_workspace_freeze_rejects_a_nonempty_remote_namespace(
|
||||
cutover_context: tuple[KnowledgeFSWorkspaceCutoverService, sessionmaker[Session]],
|
||||
) -> None:
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
@ -32,6 +33,37 @@ def test_runtime_gate_fails_closed_without_a_cutover_ledger(sqlite_session: Sess
|
||||
gate.require_capability_v2(tenant_id="tenant-1")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sqlite_session", [(KnowledgeFSWorkspaceCutoverLedger,)], indirect=True)
|
||||
def test_runtime_gate_initializes_a_missing_greenfield_workspace_once(sqlite_session: Session) -> None:
|
||||
session_maker = sessionmaker(bind=sqlite_session.get_bind(), expire_on_commit=False)
|
||||
initializer = MagicMock()
|
||||
|
||||
def initialize(*, tenant_id: str) -> None:
|
||||
with session_maker.begin() as session:
|
||||
session.add(
|
||||
KnowledgeFSWorkspaceCutoverLedger(
|
||||
tenant_id=tenant_id,
|
||||
source_revision_watermark=_WATERMARK,
|
||||
applied_revision_watermark=_WATERMARK,
|
||||
phase=KnowledgeFSWorkspaceCutoverPhase.CUTOVER,
|
||||
freeze_at=datetime(2026, 7, 21, 11, 59, tzinfo=UTC),
|
||||
cutover_at=datetime(2026, 7, 21, 12, 0, tzinfo=UTC),
|
||||
product_routes_enabled=True,
|
||||
capability_v2_enabled=True,
|
||||
integrated_mode_enabled=True,
|
||||
legacy_acl_read_only=True,
|
||||
)
|
||||
)
|
||||
|
||||
initializer.ensure_initialized.side_effect = initialize
|
||||
gate = SQLKnowledgeFSWorkspaceRuntimeGate(session_maker, initializer=initializer)
|
||||
|
||||
gate.require_product_routes(tenant_id="tenant-1")
|
||||
gate.require_capability_v2(tenant_id="tenant-1")
|
||||
|
||||
initializer.ensure_initialized.assert_called_once_with(tenant_id="tenant-1")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sqlite_session", [(KnowledgeFSWorkspaceCutoverLedger,)], indirect=True)
|
||||
def test_runtime_gate_requires_the_complete_atomic_cutover_state(sqlite_session: Session) -> None:
|
||||
ledger = KnowledgeFSWorkspaceCutoverLedger(
|
||||
|
||||
@ -0,0 +1,64 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.engine import Engine
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from models.account import Tenant, TenantAccountJoin, TenantAccountRole
|
||||
from models.base import TypeBase
|
||||
from services.knowledge_fs.cutover import KnowledgeFSCutoverGateBlockedError
|
||||
from services.knowledge_fs.greenfield_initializer import KnowledgeFSWorkspaceGreenfieldInitializer
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def greenfield_session_maker(sqlite_engine: Engine) -> sessionmaker[Session]:
|
||||
tables = [
|
||||
Tenant.metadata.tables[Tenant.__tablename__],
|
||||
TenantAccountJoin.metadata.tables[TenantAccountJoin.__tablename__],
|
||||
]
|
||||
TypeBase.metadata.create_all(sqlite_engine, tables=tables)
|
||||
return sessionmaker(bind=sqlite_engine, expire_on_commit=False)
|
||||
|
||||
|
||||
def test_greenfield_initializer_uses_the_unique_workspace_owner(
|
||||
greenfield_session_maker: sessionmaker[Session],
|
||||
) -> None:
|
||||
tenant = Tenant(name="Greenfield")
|
||||
owner_account_id = str(uuid4())
|
||||
with greenfield_session_maker.begin() as session:
|
||||
session.add(tenant)
|
||||
session.flush()
|
||||
tenant_id = tenant.id
|
||||
session.add(
|
||||
TenantAccountJoin(
|
||||
tenant_id=tenant_id,
|
||||
account_id=owner_account_id,
|
||||
role=TenantAccountRole.OWNER,
|
||||
)
|
||||
)
|
||||
cutover = MagicMock()
|
||||
initializer = KnowledgeFSWorkspaceGreenfieldInitializer(greenfield_session_maker, cutover=cutover)
|
||||
|
||||
initializer.ensure_initialized(tenant_id=tenant_id)
|
||||
|
||||
cutover.initialize_greenfield.assert_called_once_with(
|
||||
tenant_id=tenant_id,
|
||||
owner_account_id=owner_account_id,
|
||||
)
|
||||
|
||||
|
||||
def test_greenfield_initializer_rejects_a_workspace_without_one_owner(
|
||||
greenfield_session_maker: sessionmaker[Session],
|
||||
) -> None:
|
||||
tenant = Tenant(name="No owner")
|
||||
with greenfield_session_maker.begin() as session:
|
||||
session.add(tenant)
|
||||
session.flush()
|
||||
tenant_id = tenant.id
|
||||
initializer = KnowledgeFSWorkspaceGreenfieldInitializer(greenfield_session_maker, cutover=MagicMock())
|
||||
|
||||
with pytest.raises(KnowledgeFSCutoverGateBlockedError, match="exactly one Workspace owner"):
|
||||
initializer.ensure_initialized(tenant_id=tenant_id)
|
||||
@ -40,12 +40,15 @@ def test_runtime_wires_one_shared_authorization_and_remote_graph(monkeypatch: py
|
||||
"KnowledgeFSProductApplicationService",
|
||||
"KnowledgeFSProductService",
|
||||
"KnowledgeFSRevocationCommandProducer",
|
||||
"KnowledgeFSWorkspaceCutoverService",
|
||||
"KnowledgeFSWorkspaceGreenfieldInitializer",
|
||||
"LoggingKnowledgeFSRateLimitAudit",
|
||||
"SQLKnowledgeFSAppCatalog",
|
||||
"SQLKnowledgeFSWorkspaceMemberPort",
|
||||
"SQLKnowledgeFSWorkspaceRuntimeGate",
|
||||
"SQLAlchemyKnowledgeFSCapabilityIssuanceAuditor",
|
||||
"create_configured_knowledge_fs_capability_issuer",
|
||||
"get_knowledge_fs_lifecycle_remote",
|
||||
)
|
||||
factories: dict[str, MagicMock] = {}
|
||||
for name in factory_names:
|
||||
@ -80,6 +83,18 @@ def test_runtime_wires_one_shared_authorization_and_remote_graph(monkeypatch: py
|
||||
factories["KnowledgeFSProductService"].call_args.kwargs["cutover_gate"]
|
||||
is factories["SQLKnowledgeFSWorkspaceRuntimeGate"].return_value
|
||||
)
|
||||
factories["KnowledgeFSWorkspaceCutoverService"].assert_called_once_with(
|
||||
session_maker,
|
||||
remote_factory=factories["get_knowledge_fs_lifecycle_remote"],
|
||||
)
|
||||
factories["KnowledgeFSWorkspaceGreenfieldInitializer"].assert_called_once_with(
|
||||
session_maker,
|
||||
cutover=factories["KnowledgeFSWorkspaceCutoverService"].return_value,
|
||||
)
|
||||
factories["SQLKnowledgeFSWorkspaceRuntimeGate"].assert_called_once_with(
|
||||
session_maker,
|
||||
initializer=factories["KnowledgeFSWorkspaceGreenfieldInitializer"].return_value,
|
||||
)
|
||||
assert (
|
||||
factories["KnowledgeFSProductApplicationService"].call_args.kwargs["rbac"]
|
||||
is factories["DifyKnowledgeFSProductRBACPort"].return_value
|
||||
|
||||
@ -15,20 +15,53 @@ the live spec and this companion document differ.
|
||||
streaming answers/progress are `text/event-stream`; raw multimodal assets are
|
||||
`application/octet-stream`.
|
||||
|
||||
### Dify-integrated request path
|
||||
|
||||
In a Dify deployment, browser clients call the Dify Console API under
|
||||
`/console/api/knowledge-fs/...`; service clients use the narrower Service API under
|
||||
`/v1/knowledge-fs/...`. They do not call this gateway directly or manage model, datasource, or
|
||||
object-storage credentials. Dify performs Workspace/account authorization, resolves the local
|
||||
control-space to a KnowledgeFS space, and issues a short-lived Capability v2 grant for each upstream
|
||||
operation.
|
||||
|
||||
The generated Dify contracts are:
|
||||
|
||||
- [Console KnowledgeFS API](../../api/openapi/markdown/console-openapi.md), including Space,
|
||||
Overview, Document, Source, Background Task, Evidence, and Quality routes.
|
||||
- [Service KnowledgeFS API](../../api/openapi/markdown/service-openapi.md), which intentionally
|
||||
exposes a smaller application-facing surface.
|
||||
|
||||
On first product access, Dify automatically performs the auditable greenfield cutover only when the
|
||||
Workspace has one owner, no local KnowledgeFS control state, and an empty remote namespace.
|
||||
`503 knowledge_fs_operation_unavailable` means the integration is not configured, that strict
|
||||
greenfield initialization could not complete, or the Workspace has migration state that still
|
||||
requires operator-controlled cutover. It is not a signal for the client to bypass Dify and invoke
|
||||
the gateway directly.
|
||||
|
||||
## Authentication & scopes
|
||||
|
||||
- **Bearer token** (JWT) on every guarded route: `Authorization: Bearer <token>`. The tenant is taken
|
||||
from the JWT; a knowledge space is always resolved as `spaces.get({ id, tenantId })`, so one tenant
|
||||
can never read another tenant's space (returns `404`).
|
||||
- **Scopes** (`getRequiredScope`): every `GET` needs `knowledge-spaces:read`; every `POST/PATCH/DELETE`
|
||||
needs `knowledge-spaces:write` — **except** `POST /queries`, `POST /research-tasks/plan`, and
|
||||
`POST /agent-workspace-snapshots/{id}/replay`, which need only `read`. The wildcard scope
|
||||
`knowledge-spaces:*` satisfies both.
|
||||
- **Production auth profile**: guarded routes accept a short-lived, Dify-issued RS256 Capability v2
|
||||
JWT as `Authorization: Bearer <token>`. KnowledgeFS has no independently managed production
|
||||
caller credentials and does not accept the removed legacy tenant JWT/API-key authorization
|
||||
profile.
|
||||
- **Exact binding**: each capability binds the caller kind, Dify Workspace namespace, control-space,
|
||||
action, resource type/id, optional parent, authorization/content-policy revisions, candidate
|
||||
content scopes, and trace/grant identity. The maximum token lifetime is 60 seconds. A valid token
|
||||
for one operation or resource cannot authorize another.
|
||||
- **Route policy labels** (`getRequiredScope`): OpenAPI retains the coarse
|
||||
`knowledge-spaces:read|write` labels for route classification. In production, Dify converts the
|
||||
requested product operation into an exact Capability v2 action/resource grant; clients cannot
|
||||
broaden access by supplying those labels themselves. Most `GET` operations classify as `read` and
|
||||
mutations as `write`, with explicitly read-only POST exceptions such as query planning/replay.
|
||||
- **Tenant isolation**: the Workspace namespace comes from the signed capability. A knowledge space
|
||||
is always resolved with that namespace, so cross-Workspace resource IDs fail closed (normally
|
||||
`404`).
|
||||
- **Auth is path-prefix middleware**, not per-route: it guards `/knowledge-spaces*`, `/queries*`,
|
||||
`/jobs*`, `/research-tasks*`, `/agent-workspace-snapshots*`, `/bulk-jobs*`, `/retention-policy`.
|
||||
**Public (no auth)**: `GET /health` and `GET /openapi.json`.
|
||||
- **Auth failures**: `401` (missing/invalid token), `403` (token lacks the required scope), both
|
||||
`{ error: string }`.
|
||||
Internal lifecycle routes additionally require the exact `internal_worker` capability action.
|
||||
**Public (no auth)**: `GET /health`, `GET /ready`, and `GET /openapi.json`.
|
||||
- **Auth failures**: `401` (missing/invalid token) or `403` (wrong action, caller kind, resource,
|
||||
revision, or lifecycle state), both `{ error: string, code?: string }`.
|
||||
|
||||
## Conventions
|
||||
|
||||
@ -250,6 +283,119 @@ the live spec and this companion document differ.
|
||||
|
||||
---
|
||||
|
||||
## Overview & background tasks
|
||||
|
||||
The Overview endpoints are bounded, read-only product projections. They never expose model
|
||||
credentials, datasource secrets, internal leases, or worker fencing data. In the Dify Console API,
|
||||
the corresponding routes use a local `control_space_id`; Dify resolves it to the upstream
|
||||
KnowledgeFS `id`.
|
||||
|
||||
### `GET /knowledge-spaces/{id}/overview/stats`
|
||||
**Description**: Return current inventory counters plus query/answer totals for the fixed `24h`,
|
||||
`7d`, and `30d` windows.
|
||||
**Auth**: Bearer; scope `knowledge-spaces:read`.
|
||||
**Path params**: `id` (uuid).
|
||||
**Responses**:
|
||||
- `200`: `{ knowledgeSpaceId, generatedAt, current, windows }`. `current` contains
|
||||
`knowledgeCount`, `linkedAppCount`, source totals/freshness, and `latestSourceSyncAt?`; each window
|
||||
contains `{ since, queryCount, answeredQueryCount, answerRate }`.
|
||||
- `404`; `503` Overview backend unavailable; `401`/`403`.
|
||||
|
||||
### `GET /knowledge-spaces/{id}/overview/query-outcomes`
|
||||
**Description**: Return requester-scoped current/previous outcome totals and a bounded time series.
|
||||
**Auth**: Bearer; scope `knowledge-spaces:read`.
|
||||
**Path params**: `id` (uuid). **Query**: `window` (`24h|7d|30d`, optional, default `24h`).
|
||||
**Responses**:
|
||||
- `200`: `{ knowledgeSpaceId, window, since, previousSince, generatedAt, current, previous, buckets }`.
|
||||
Totals contain `queryCount`, `answered`, `lowConfidence`, `noEvidence`, and `answerRate`; buckets
|
||||
additionally contain `startAt` and `endAt`.
|
||||
- `400`; `404`; `503`; `401`/`403`.
|
||||
|
||||
### `GET /knowledge-spaces/{id}/overview/inventory`
|
||||
**Description**: Return source-category, graph, and active-index inventory.
|
||||
**Auth**: Bearer; scope `knowledge-spaces:read`.
|
||||
**Path params**: `id` (uuid).
|
||||
**Responses**:
|
||||
- `200`: `{ knowledgeSpaceId, generatedAt, sourceCategories, graphEntities, graphRelations,
|
||||
indexCoverage }`; index coverage contains `{ indexed, total, percentage }`.
|
||||
- `404`; `503`; `401`/`403`.
|
||||
|
||||
### `GET /knowledge-spaces/{id}/overview/activity`
|
||||
**Description**: Cursor-paginated append-only product activity.
|
||||
**Auth**: Bearer; scope `knowledge-spaces:read`.
|
||||
**Path params**: `id` (uuid).
|
||||
**Query**: `cursor`; `limit` (1–100); `from`/`to` (date-time); optional `action`,
|
||||
`resourceType`, and `result` filters.
|
||||
**Responses**:
|
||||
- `200`: `{ items: [{ id, action, actor, resource, result, details, occurredAt }], nextCursor? }`.
|
||||
- `400`; `404`; `503`; `401`/`403`.
|
||||
|
||||
This upstream route is not currently exposed by the Dify Console controller; the Console Overview
|
||||
uses its existing activity integration until that product contract is migrated.
|
||||
|
||||
### `GET /knowledge-spaces/{id}/overview/attention`
|
||||
**Description**: List rule-backed Needs Attention findings.
|
||||
**Auth**: Bearer; scope `knowledge-spaces:read`.
|
||||
**Path params**: `id` (uuid). **Query**: `limit` (1–100); `includeDismissed` (boolean, default
|
||||
`false`).
|
||||
**Responses**:
|
||||
- `200`: `{ items }`; each item includes `issueKey`, `ruleId`, `severity`, `status`, `title`,
|
||||
`resource`, `action`, `evidence`, `revision`, `updatedAt`, and optional `dismissedUntil`.
|
||||
- `400`; `404`; `503`; `401`/`403`.
|
||||
|
||||
### `PATCH /knowledge-spaces/{id}/overview/attention/{issueKey}`
|
||||
**Description**: Dismiss, resolve, or reactivate one finding with revision CAS.
|
||||
**Auth**: Bearer; scope `knowledge-spaces:write`.
|
||||
**Path params**: `id` (uuid); `issueKey` (string).
|
||||
**Body** (`application/json`, strict): `{ expectedRevision, status:
|
||||
"active"|"dismissed"|"resolved", dismissedUntil? }`.
|
||||
**Responses**: `200` updated finding; `404`; `409` revision conflict; `503`; `401`/`403`.
|
||||
|
||||
The attention list/transition routes are upstream contracts and are not currently proxied by the
|
||||
Dify Console API.
|
||||
|
||||
### `GET /knowledge-spaces/{id}/overview/health`
|
||||
**Description**: Return the stable aggregate product-health state.
|
||||
**Auth**: Bearer; scope `knowledge-spaces:read`.
|
||||
**Path params**: `id` (uuid).
|
||||
**Responses**:
|
||||
- `200`: `{ knowledgeSpaceId, generatedAt, state, components }`, where `state` and each component
|
||||
state are `healthy|degraded|unavailable|unknown`. Components cover index, ingestion, profile
|
||||
publication, query availability, source freshness, and worker readiness.
|
||||
- `404`; `503`; `401`/`403`.
|
||||
|
||||
### `GET /knowledge-spaces/{id}/background-tasks`
|
||||
**Description**: List newest durable document, bulk-document, and source tasks across their native
|
||||
job stores.
|
||||
**Auth**: Bearer; scope `knowledge-spaces:read`.
|
||||
**Path params**: `id` (uuid). **Query**: `cursor` (opaque, optional); `limit` (1–100, default 50).
|
||||
**Responses**:
|
||||
- `200`: `{ items: BackgroundTask[], nextCursor? }`.
|
||||
- `BackgroundTask`: `{ id, knowledgeSpaceId, taskKind, operation, state, canCancel, canRetry,
|
||||
progressCompleted, progressFailed, progressTotal, progressPercent, createdAt, updatedAt,
|
||||
completedAt?, documentId?, documentRevision?, sourceId?, errorCode?, errorMessage? }`.
|
||||
- `taskKind`: `document|document_bulk|source`; `state`:
|
||||
`queued|running|completed|failed|canceled`.
|
||||
- `400`; `404`; `409`; `401`/`403`.
|
||||
|
||||
### `POST /knowledge-spaces/{id}/background-tasks/{taskKind}/{taskId}/cancel`
|
||||
**Description**: Request cancellation through the task's native durable state machine.
|
||||
**Auth**: Bearer; scope `knowledge-spaces:write`.
|
||||
**Path params**: `id` (uuid); `taskKind` (`document|document_bulk|source`); `taskId` (uuid).
|
||||
**Body**: None.
|
||||
**Responses**: `200` updated `BackgroundTask`; `400`; `404`; `409` transition not allowed;
|
||||
`401`/`403`.
|
||||
|
||||
### `POST /knowledge-spaces/{id}/background-tasks/{taskKind}/{taskId}/retry`
|
||||
**Description**: Retry a failed or canceled task through the task's native durable state machine.
|
||||
**Auth**: Bearer; scope `knowledge-spaces:write`.
|
||||
**Path params**: `id` (uuid); `taskKind` (`document|document_bulk|source`); `taskId` (uuid).
|
||||
**Body**: None.
|
||||
**Responses**: `200` updated `BackgroundTask`; `400`; `404`; `409` transition not allowed;
|
||||
`401`/`403`.
|
||||
|
||||
---
|
||||
|
||||
## Query, answers, golden & failed queries
|
||||
|
||||
### `POST /queries`
|
||||
@ -743,11 +889,49 @@ compatibility path does not admit Auto.
|
||||
**Auth**: **None (public)**.
|
||||
**Responses**: `200` `{ ok: boolean, runtime: enum(cloudflare-workers|node-docker), components: Record<string, boolean> }`.
|
||||
|
||||
### `GET /ready`
|
||||
**Description**: Deployment readiness gate. Unlike `/health`, this fails closed until every required
|
||||
runtime dependency and Dify-integrated configuration is ready to receive traffic.
|
||||
**Auth**: **None (public)**.
|
||||
**Responses**:
|
||||
- `200`: `{ ok: true, runtime, components }`.
|
||||
- `503`: `{ ok: false, runtime, components }`.
|
||||
|
||||
### `GET /openapi.json`
|
||||
**Description**: The machine-readable OpenAPI 3 document for the whole API (served via `app.doc`).
|
||||
**Auth**: **None (public)**.
|
||||
**Responses**: `200` OpenAPI JSON document.
|
||||
|
||||
### `POST /internal/dify-integration/freeze`
|
||||
**Description**: Persist or idempotently replay a Dify Workspace maintenance freeze before product
|
||||
traffic activation.
|
||||
**Auth**: Dify-issued Capability v2 token with the exact internal freeze operation and namespace;
|
||||
not callable by browser or Service API clients.
|
||||
**Body** (`application/json`, strict): `{ freezeId, freezeRevision, sourceRevisionDigest,
|
||||
sourceTaskWatermark }`. IDs/digests use `sha256:<64 lowercase hex>` and numeric evidence must be a
|
||||
JavaScript-safe integer.
|
||||
**Responses**:
|
||||
- `200`: freeze evidence plus `{ namespaceId, frozen: true, frozenAt, updatedAt, applied, replayed }`.
|
||||
- `401`; `403` wrong capability; `409` conflicting revision/evidence.
|
||||
|
||||
The namespace/tenant is taken from the signed capability, not from request JSON. Reusing an
|
||||
operation grant is rejected; an exact durable evidence replay uses a newly issued grant.
|
||||
|
||||
### `POST /internal/dify-integration/activate`
|
||||
**Description**: Persist or idempotently replay monotonic Dify Workspace integration activation.
|
||||
**Auth**: Dify-issued Capability v2 token with the exact internal activation operation and
|
||||
namespace; not callable by browser or Service API clients.
|
||||
**Body** (`application/json`, strict): `{ activationId, activationRevision,
|
||||
sourceRevisionDigest }`.
|
||||
**Responses**:
|
||||
- `200`: activation evidence plus `{ namespaceId, active: true, activatedAt, updatedAt, applied,
|
||||
replayed }`.
|
||||
- `401`; `403` wrong capability; `409` non-monotonic or conflicting activation evidence.
|
||||
|
||||
Activation is an acknowledgement to Dify's Workspace cutover coordinator. Dify publishes its local
|
||||
product-route, Capability v2, integrated-mode, and legacy-ACL-read-only switches only after this
|
||||
exact durable acknowledgement is validated.
|
||||
|
||||
### `GET /bulk-jobs/{id}`
|
||||
**Description**: Progress for a bulk document operation job.
|
||||
**Auth**: Bearer; scope `knowledge-spaces:read`.
|
||||
|
||||
@ -54,6 +54,7 @@ export const requestKnowledgeSpaceProfileMigrationRoute = createRoute({
|
||||
|
||||
export const getKnowledgeSpaceProfileMigrationRoute = createRoute({
|
||||
method: "get",
|
||||
operationId: "getKnowledgeSpaceProfileMigration",
|
||||
path: "/knowledge-spaces/{id}/profile-migrations/{migrationId}",
|
||||
request: { params: KnowledgeSpaceProfileMigrationParamsSchema },
|
||||
responses: {
|
||||
|
||||
@ -373,6 +373,7 @@ export const getKnowledgeSpaceManifestRoute = createRoute({
|
||||
|
||||
export const updateKnowledgeSpaceEmbeddingProfileRoute = createRoute({
|
||||
method: "put",
|
||||
operationId: "updateKnowledgeSpaceEmbeddingProfile",
|
||||
path: "/knowledge-spaces/{id}/embedding-profile",
|
||||
request: {
|
||||
body: {
|
||||
@ -436,6 +437,7 @@ export const updateKnowledgeSpaceEmbeddingProfileRoute = createRoute({
|
||||
|
||||
export const updateKnowledgeSpaceRetrievalProfileRoute = createRoute({
|
||||
method: "put",
|
||||
operationId: "updateKnowledgeSpaceRetrievalProfile",
|
||||
path: "/knowledge-spaces/{id}/retrieval-profile",
|
||||
request: {
|
||||
body: {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user