feat(knowledge-fs): automate greenfield workspace cutover

This commit is contained in:
Jyong 2026-07-28 09:09:03 -04:00
parent d2380dd951
commit ff07021c22
16 changed files with 846 additions and 45 deletions

View File

@ -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())

View File

@ -1,9 +1,9 @@
{
"schemaVersion": 5,
"subtreeTree": "7071b81f867b755a915f464aa9319f06debc647b",
"openapiSha256": "12c5e3674697a70129787efd7d277633a718f6a2d80fcd63a031945c73c54150",
"subtreeTree": "8f43d289905c40bfb41642192115847bacfed0bb",
"openapiSha256": "3aff366ab900fd89166f9996f5b20a9a58bbc9315701f236f8d4dfc0bda55a3e",
"capabilityV2AuthManifestSha256": "fc0a47e23cce12544882f0298522b4933002e892b84ce1815df7e81d36a7a0c7",
"capabilityV2AuthTestVectorSha256": "ae0de37b1ff05c40f905cf17a7b410d8971acacf64db07d5ee3d6fecfa559ce3",
"productOperationManifestSha256": "671a915b9653608a2db79fc090681757643cd8562a8725cfd7951fa594a2b980",
"productOperationManifestSha256": "96639519cb5dc0fe523403a14a86a64be2c3e2f751b475fea1bf57fdc072aff5",
"productOperationGapManifestSha256": "332c80165bd5cf8e79bc511374dde771a8175510404db603a8067f1a22d36df8"
}

View File

@ -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}},

View File

@ -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 |

View File

@ -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()}"

View File

@ -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",
]

View 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"]

View File

@ -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,

View File

@ -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()

View File

@ -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:

View File

@ -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(

View File

@ -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)

View File

@ -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

View File

@ -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` (1100); `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` (1100); `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` (1100, 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`.

View File

@ -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: {

View File

@ -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: {