From 84a7351f0ebbd3a040f46fdd53fdd3951c9d49ad Mon Sep 17 00:00:00 2001 From: Chojan Shang Date: Thu, 27 Aug 2026 15:20:07 +0800 Subject: [PATCH 01/15] feat(scope): add durable scope organization --- .../builtin/persistence/tables.py | 84 +++++- .../builtin/runtime/application.py | 3 + .../builtin/runtime/composition.py | 9 +- .../builtin/runtime/relational.py | 2 + src/powercontext/builtin/scope/__init__.py | 46 +++ src/powercontext/builtin/scope/application.py | 210 +++++++++++++ src/powercontext/builtin/scope/errors.py | 51 ++++ src/powercontext/builtin/scope/models.py | 218 ++++++++++++++ src/powercontext/builtin/scope/repository.py | 280 ++++++++++++++++++ src/powercontext/limits.py | 8 + tests/builtin/test_scope_application.py | 169 +++++++++++ 11 files changed, 1077 insertions(+), 3 deletions(-) create mode 100644 src/powercontext/builtin/scope/__init__.py create mode 100644 src/powercontext/builtin/scope/application.py create mode 100644 src/powercontext/builtin/scope/errors.py create mode 100644 src/powercontext/builtin/scope/models.py create mode 100644 src/powercontext/builtin/scope/repository.py create mode 100644 tests/builtin/test_scope_application.py diff --git a/src/powercontext/builtin/persistence/tables.py b/src/powercontext/builtin/persistence/tables.py index 99e1013a6..aad6b20a3 100644 --- a/src/powercontext/builtin/persistence/tables.py +++ b/src/powercontext/builtin/persistence/tables.py @@ -39,7 +39,14 @@ MAX_EXTERNAL_SKILL_HOST_ID_LENGTH, MAX_EXTERNAL_SKILL_LOCATOR_LENGTH, MAX_EXTERNAL_SKILL_NAME_LENGTH, + MAX_SCOPE_BINDING_EXTERNAL_ID_LENGTH, + MAX_SCOPE_BINDING_INTEGRATION_LENGTH, + MAX_SCOPE_BINDING_KIND_LENGTH, + MAX_SCOPE_EXTERNAL_REFERENCE_KIND_LENGTH, MAX_SCOPE_ID_LENGTH, + MAX_SCOPE_IDEMPOTENCY_KEY_LENGTH, + MAX_SCOPE_SUMMARY_LENGTH, + MAX_SCOPE_TITLE_LENGTH, MAX_SOURCE_ID_LENGTH, MAX_SOURCE_TYPE_LENGTH, ) @@ -75,6 +82,81 @@ def _entry_text_type(): return Text().with_variant(MEDIUMTEXT(), "mysql") +SCOPES_TABLE = Table( + "pc_scopes", + SHARED_METADATA, + Column("scope_id", identity_string(MAX_SCOPE_ID_LENGTH), primary_key=True), + Column("title", String(MAX_SCOPE_TITLE_LENGTH), nullable=False), + Column("summary", String(MAX_SCOPE_SUMMARY_LENGTH), nullable=False), + Column("parent_scope_id", identity_string(MAX_SCOPE_ID_LENGTH)), + Column("version", Integer, nullable=False), + ForeignKeyConstraint( + ("parent_scope_id",), + ("pc_scopes.scope_id",), + ondelete="RESTRICT", + ), + CheckConstraint("version > 0", name="ck_pc_scopes_version_positive"), +) + +SCOPE_CONTEXT_REFERENCES_TABLE = Table( + "pc_scope_context_references", + SHARED_METADATA, + Column("scope_id", identity_string(MAX_SCOPE_ID_LENGTH), primary_key=True), + Column("referenced_scope_id", identity_string(MAX_SCOPE_ID_LENGTH), primary_key=True), + ForeignKeyConstraint(("scope_id",), ("pc_scopes.scope_id",), ondelete="CASCADE"), + ForeignKeyConstraint(("referenced_scope_id",), ("pc_scopes.scope_id",), ondelete="RESTRICT"), + CheckConstraint("scope_id <> referenced_scope_id", name="ck_pc_scope_context_references_not_self"), +) + +SCOPE_EXTERNAL_REFERENCES_TABLE = Table( + "pc_scope_external_references", + SHARED_METADATA, + Column("scope_id", identity_string(MAX_SCOPE_ID_LENGTH), primary_key=True), + Column("ordinal", Integer, primary_key=True), + Column("kind", identity_string(MAX_SCOPE_EXTERNAL_REFERENCE_KIND_LENGTH), nullable=False), + Column("value", String(MAX_SCOPE_SUMMARY_LENGTH), nullable=False), + ForeignKeyConstraint(("scope_id",), ("pc_scopes.scope_id",), ondelete="CASCADE"), + UniqueConstraint("scope_id", "kind", "value", name="uq_pc_scope_external_references_value"), + CheckConstraint("ordinal >= 0", name="ck_pc_scope_external_references_ordinal_nonnegative"), +) + +SCOPE_CREATION_REQUESTS_TABLE = Table( + "pc_scope_creation_requests", + SHARED_METADATA, + Column("idempotency_key", identity_string(MAX_SCOPE_IDEMPOTENCY_KEY_LENGTH), primary_key=True), + Column("request_digest", identity_string(64), nullable=False), + Column("scope_id", identity_string(MAX_SCOPE_ID_LENGTH), nullable=False), + ForeignKeyConstraint(("scope_id",), ("pc_scopes.scope_id",), ondelete="RESTRICT"), +) + +SCOPE_SETTINGS_TABLE = Table( + "pc_scope_settings", + SHARED_METADATA, + Column("name", identity_string(64), primary_key=True), + Column("scope_id", identity_string(MAX_SCOPE_ID_LENGTH), nullable=False), + ForeignKeyConstraint(("scope_id",), ("pc_scopes.scope_id",), ondelete="RESTRICT"), +) + +SCOPE_BINDINGS_TABLE = Table( + "pc_scope_bindings", + SHARED_METADATA, + Column("integration", identity_string(MAX_SCOPE_BINDING_INTEGRATION_LENGTH), primary_key=True), + Column("kind", identity_string(MAX_SCOPE_BINDING_KIND_LENGTH), primary_key=True), + Column("external_id", identity_string(MAX_SCOPE_BINDING_EXTERNAL_ID_LENGTH), primary_key=True), + Column("scope_id", identity_string(MAX_SCOPE_ID_LENGTH), nullable=False), + ForeignKeyConstraint(("scope_id",), ("pc_scopes.scope_id",), ondelete="RESTRICT"), +) + +SCOPE_TABLES = ( + SCOPES_TABLE, + SCOPE_CONTEXT_REFERENCES_TABLE, + SCOPE_EXTERNAL_REFERENCES_TABLE, + SCOPE_CREATION_REQUESTS_TABLE, + SCOPE_SETTINGS_TABLE, + SCOPE_BINDINGS_TABLE, +) + + SOURCES_TABLE = Table( "pc_sources", SHARED_METADATA, @@ -452,4 +534,4 @@ def _entry_text_type(): STATISTICS_TABLES = (MODEL_USAGE_DAILY_TABLE, RECALL_TOKEN_DAILY_TABLE) -BUILTIN_TABLES = SHARED_TABLES + MEMORY_TABLES + STATISTICS_TABLES +BUILTIN_TABLES = SCOPE_TABLES + SHARED_TABLES + MEMORY_TABLES + STATISTICS_TABLES diff --git a/src/powercontext/builtin/runtime/application.py b/src/powercontext/builtin/runtime/application.py index 94eb7e8ee..e35866308 100644 --- a/src/powercontext/builtin/runtime/application.py +++ b/src/powercontext/builtin/runtime/application.py @@ -133,6 +133,7 @@ RuntimeReadinessChecks, ) from powercontext.builtin.runtime.statistics import RelationalScopedStatistics +from powercontext.builtin.scope import ScopeApplication from powercontext.builtin.sources import ( ContentCapture, ContentSource, @@ -1191,6 +1192,7 @@ def __init__( external_skill_importer: ExternalSkillImporter | None = None, statistics_service: StatisticsServiceFactory | None = None, recall_token_estimator: RecallTokenEstimator | None = None, + scope_application: ScopeApplication | None = None, readiness: RuntimeReadinessChecks | None = None, clock: Clock | None = None, tracing: RuntimeTracing | None = None, @@ -1209,6 +1211,7 @@ def __init__( self._external_skill_importer = external_skill_importer self._statistics_service = statistics_service self._recall_token_estimator = recall_token_estimator + self.scopes = scope_application self._readiness = RuntimeReadinessChecks() if readiness is None else readiness self._clock = _utc_now if clock is None else clock self._tracing = tracing diff --git a/src/powercontext/builtin/runtime/composition.py b/src/powercontext/builtin/runtime/composition.py index 7c623b068..d4365256a 100644 --- a/src/powercontext/builtin/runtime/composition.py +++ b/src/powercontext/builtin/runtime/composition.py @@ -279,6 +279,7 @@ async def open_builtin_runtime( external_skill_importer=contexts.import_external_skill if contexts.external_skill_registry else None, statistics_service=contexts.statistics, recall_token_estimator=contexts.estimate_recall_tokens, + scope_application=contexts.scopes, readiness=RuntimeReadinessChecks(readiness_probes), tracing=tracing, ) @@ -338,7 +339,7 @@ async def open_builtin_contexts( async with profile.database.transaction() as connection: await index.initialize(connection) await experience_index.initialize(connection) - yield RelationalContexts( + contexts = RelationalContexts( database=profile.database, index=index, experience_index=experience_index, @@ -353,6 +354,8 @@ async def open_builtin_contexts( memory_reranker=memory_reranker, memory_rerank_candidate_limit=config.runtime.memory_rerank_candidate_limit, ) + await contexts.scopes.bootstrap_default() + yield contexts return experience_index = OceanBaseExperienceFTSIndex() indexes = [OceanBaseMemoryFTSIndex()] @@ -370,7 +373,7 @@ async def open_builtin_contexts( async with profile.database.transaction() as connection: await index.initialize(connection) await experience_index.initialize(connection) - yield RelationalContexts( + contexts = RelationalContexts( database=profile.database, index=index, experience_index=experience_index, @@ -385,6 +388,8 @@ async def open_builtin_contexts( memory_reranker=memory_reranker, memory_rerank_candidate_limit=config.runtime.memory_rerank_candidate_limit, ) + await contexts.scopes.bootstrap_default() + yield contexts async def _generation_pipelines( diff --git a/src/powercontext/builtin/runtime/relational.py b/src/powercontext/builtin/runtime/relational.py index 534a828f5..cf6011afb 100644 --- a/src/powercontext/builtin/runtime/relational.py +++ b/src/powercontext/builtin/runtime/relational.py @@ -89,6 +89,7 @@ from powercontext.builtin.runtime.protocols import BuiltinTriggers from powercontext.builtin.runtime.recall import RelationalRecallTokenEstimator from powercontext.builtin.runtime.statistics import RelationalScopedStatistics +from powercontext.builtin.scope import ScopeApplication from powercontext.builtin.sources import ( CONTENT_SOURCE_ADAPTER, EXTERNAL_SKILL_SNAPSHOT_SOURCE_ADAPTER, @@ -305,6 +306,7 @@ def __init__( memory_artifact_id: str = "memory", ) -> None: self.database = database + self.scopes = ScopeApplication(database) self.index = NoMemoryIndex() if index is None else index self.experience_index = NoExperienceIndex() if experience_index is None else experience_index self.repositories = _Repositories( diff --git a/src/powercontext/builtin/scope/__init__.py b/src/powercontext/builtin/scope/__init__.py new file mode 100644 index 000000000..0f83e4b93 --- /dev/null +++ b/src/powercontext/builtin/scope/__init__.py @@ -0,0 +1,46 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 + +"""Durable Scope organization and binding.""" + +from powercontext.builtin.scope.application import ScopeApplication, generate_scope_id +from powercontext.builtin.scope.errors import ( + ScopeBindingNotFoundError, + ScopeError, + ScopeIdempotencyConflictError, + ScopeNotFoundError, + ScopeRelationshipError, + ScopeVersionConflictError, +) +from powercontext.builtin.scope.models import ( + ScopeBinding, + ScopeBindingKey, + ScopeDescriptor, + ScopeDraft, + ScopeExternalReference, + ScopeMutation, + ScopeSelection, +) + +__all__ = [ + "ScopeApplication", + "ScopeBinding", + "ScopeBindingKey", + "ScopeBindingNotFoundError", + "ScopeDescriptor", + "ScopeDraft", + "ScopeError", + "ScopeExternalReference", + "ScopeIdempotencyConflictError", + "ScopeMutation", + "ScopeNotFoundError", + "ScopeRelationshipError", + "ScopeSelection", + "ScopeVersionConflictError", + "generate_scope_id", +] diff --git a/src/powercontext/builtin/scope/application.py b/src/powercontext/builtin/scope/application.py new file mode 100644 index 000000000..b13bc64a3 --- /dev/null +++ b/src/powercontext/builtin/scope/application.py @@ -0,0 +1,210 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 + +"""Application service for Scope ownership, organization, and binding.""" + +from __future__ import annotations + +import hashlib +import secrets +from collections.abc import Callable, Sequence + +from powercontext.builtin.persistence.database import AsyncDatabase +from powercontext.builtin.scope.errors import ( + ScopeBindingNotFoundError, + ScopeIdempotencyConflictError, + ScopeNotFoundError, + ScopeRelationshipError, + ScopeVersionConflictError, +) +from powercontext.builtin.scope.models import ( + ScopeBinding, + ScopeBindingKey, + ScopeDescriptor, + ScopeDraft, + ScopeMutation, + ScopeSelection, +) +from powercontext.builtin.scope.repository import ScopeRepository + +ScopeIdFactory = Callable[[], str] +_CROCKFORD = "0123456789abcdefghjkmnpqrstvwxyz" +_DEFAULT_IDEMPOTENCY_KEY = "powercontext.default-scope.v1" + + +class ScopeApplication: + def __init__( + self, + database: AsyncDatabase, + *, + repository: ScopeRepository | None = None, + id_factory: ScopeIdFactory | None = None, + ) -> None: + self._database = database + self._repository = ScopeRepository() if repository is None else repository + self._id_factory = generate_scope_id if id_factory is None else id_factory + + async def bootstrap_default(self) -> ScopeDescriptor: + existing = await self.default_scope() + if existing is not None: + return existing + created = await self.create( + ScopeDraft( + title="Default", + summary="Default context", + idempotency_key=_DEFAULT_IDEMPOTENCY_KEY, + ) + ) + await self.set_default(created.scope_id) + return created + + async def create(self, draft: ScopeDraft, /) -> ScopeDescriptor: + digest = _draft_digest(draft) + async with self._database.transaction() as connection: + existing = await self._repository.creation(connection, draft.idempotency_key) + if existing is not None: + existing_digest, scope_id = existing + if existing_digest != digest: + raise ScopeIdempotencyConflictError(draft.idempotency_key) + return await self._required(connection, scope_id) + await self._validate_relationships( + connection, + scope_id=None, + parent_scope_id=draft.parent_scope_id, + context_references=draft.context_references, + ) + return await self._repository.add(connection, self._id_factory(), draft, digest) + + async def get(self, scope_id: str, /) -> ScopeDescriptor: + async with self._database.transaction() as connection: + return await self._required(connection, scope_id) + + async def list(self) -> tuple[ScopeDescriptor, ...]: + async with self._database.transaction() as connection: + return await self._repository.list(connection) + + async def update(self, scope_id: str, mutation: ScopeMutation, /) -> ScopeDescriptor: + async with self._database.transaction() as connection: + current = await self._required(connection, scope_id) + if current.version != mutation.expected_version: + raise ScopeVersionConflictError(scope_id, mutation.expected_version, current.version) + await self._validate_relationships( + connection, + scope_id=scope_id, + parent_scope_id=mutation.parent_scope_id, + context_references=mutation.context_references, + ) + if not await self._repository.replace(connection, scope_id, mutation): + refreshed = await self._required(connection, scope_id) + raise ScopeVersionConflictError(scope_id, mutation.expected_version, refreshed.version) + return await self._required(connection, scope_id) + + async def default_scope(self) -> ScopeDescriptor | None: + async with self._database.transaction() as connection: + scope_id = await self._repository.default_scope_id(connection) + return None if scope_id is None else await self._required(connection, scope_id) + + async def set_default(self, scope_id: str, /) -> ScopeDescriptor: + async with self._database.transaction() as connection: + scope = await self._required(connection, scope_id) + await self._repository.set_default(connection, scope_id) + return scope + + async def bind(self, key: ScopeBindingKey, scope_id: str, /) -> ScopeBinding: + async with self._database.transaction() as connection: + await self._required(connection, scope_id) + return await self._repository.set_binding(connection, key, scope_id) + + async def binding(self, key: ScopeBindingKey, /) -> ScopeBinding | None: + async with self._database.transaction() as connection: + return await self._repository.binding(connection, key) + + async def clear_binding(self, key: ScopeBindingKey, /) -> bool: + async with self._database.transaction() as connection: + return await self._repository.clear_binding(connection, key) + + async def resolve_binding( + self, + *, + explicit_scope_id: str | None = None, + binding_keys: Sequence[ScopeBindingKey] = (), + ) -> ScopeDescriptor: + async with self._database.transaction() as connection: + if explicit_scope_id is not None: + return await self._required(connection, explicit_scope_id) + for key in binding_keys: + binding = await self._repository.binding(connection, key) + if binding is not None: + return await self._required(connection, binding.scope_id) + default_scope_id = await self._repository.default_scope_id(connection) + if default_scope_id is None: + raise ScopeBindingNotFoundError + return await self._required(connection, default_scope_id) + + async def resolve_selection(self, selection: ScopeSelection, /) -> tuple[ScopeDescriptor, ...]: + async with self._database.transaction() as connection: + if selection.mode == "all": + return await self._repository.list(connection) + if selection.mode == "exact": + resolved = [await self._required(connection, scope_id) for scope_id in selection.scope_ids] + return tuple(sorted(resolved, key=lambda scope: scope.scope_id)) + root_scope_id = selection.root_scope_id + if root_scope_id is None: + raise AssertionError + root = await self._required(connection, root_scope_id) + resolved = [root] + pending = [root.scope_id] + while pending: + parent_scope_id = pending.pop(0) + for child_scope_id in await self._repository.children(connection, parent_scope_id): + resolved.append(await self._required(connection, child_scope_id)) + pending.append(child_scope_id) + return tuple(resolved) + + async def _required(self, connection, scope_id: str) -> ScopeDescriptor: + scope = await self._repository.get(connection, scope_id) + if scope is None: + raise ScopeNotFoundError(scope_id) + return scope + + async def _validate_relationships( + self, + connection, + *, + scope_id: str | None, + parent_scope_id: str | None, + context_references: tuple[str, ...], + ) -> None: + if scope_id is not None and parent_scope_id == scope_id: + raise ScopeRelationshipError("Parent", "a Scope cannot parent itself") + if parent_scope_id is not None: + parent = await self._required(connection, parent_scope_id) + while parent.parent_scope_id is not None: + if parent.parent_scope_id == scope_id: + raise ScopeRelationshipError("Parent", "relationships must be acyclic") + parent = await self._required(connection, parent.parent_scope_id) + for referenced_scope_id in context_references: + if referenced_scope_id == scope_id: + raise ScopeRelationshipError( # noqa: TRY003 + "Context Reference", + "a Scope cannot reference itself", + ) + await self._required(connection, referenced_scope_id) + + +def generate_scope_id() -> str: + """Generate the RFC-defined 128-bit opaque Scope identity.""" + + value = int.from_bytes(secrets.token_bytes(16), "big") + encoded = "".join(_CROCKFORD[(value >> shift) & 31] for shift in range(125, -1, -5)) + return f"scp_{encoded}" + + +def _draft_digest(draft: ScopeDraft) -> str: + payload = draft.model_dump_json(exclude={"idempotency_key"}) + return hashlib.sha256(payload.encode()).hexdigest() diff --git a/src/powercontext/builtin/scope/errors.py b/src/powercontext/builtin/scope/errors.py new file mode 100644 index 000000000..b3c488328 --- /dev/null +++ b/src/powercontext/builtin/scope/errors.py @@ -0,0 +1,51 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 + +"""Stable failures for Scope organization and binding.""" + +from __future__ import annotations + +from powercontext.errors import PowerContextError + + +class ScopeError(PowerContextError): + """Base failure for Scope operations.""" + + +class ScopeNotFoundError(ScopeError, LookupError): + def __init__(self, scope_id: str) -> None: + self.scope_id = scope_id + super().__init__("scope was not found") + + +class ScopeVersionConflictError(ScopeError, RuntimeError): + def __init__(self, scope_id: str, expected: int, actual: int) -> None: + self.scope_id = scope_id + self.expected = expected + self.actual = actual + super().__init__("scope metadata changed since it was read") + + +class ScopeIdempotencyConflictError(ScopeError, RuntimeError): + def __init__(self, idempotency_key: str) -> None: + self.idempotency_key = idempotency_key + super().__init__("scope creation key was reused with different parameters") + + +class ScopeRelationshipError(ScopeError, ValueError): + def __init__(self, relationship: str, issue: str) -> None: + self.relationship = relationship + self.issue = issue + super().__init__(f"invalid Scope {relationship}: {issue}") + + +class ScopeBindingNotFoundError(ScopeError, LookupError): + """Raised when no explicit, durable, or default binding can be resolved.""" + + def __init__(self) -> None: + super().__init__("no Scope binding is available") diff --git a/src/powercontext/builtin/scope/models.py b/src/powercontext/builtin/scope/models.py new file mode 100644 index 000000000..4f11ad940 --- /dev/null +++ b/src/powercontext/builtin/scope/models.py @@ -0,0 +1,218 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 + +"""Domain values for Scope organization, observation, and external binding.""" + +from __future__ import annotations + +from typing import Literal + +from pydantic import BaseModel, ConfigDict, Field, StrictInt, field_validator, model_validator + +from powercontext.builtin.sources import validate_scope_id +from powercontext.limits import ( + MAX_SCOPE_BINDING_EXTERNAL_ID_LENGTH, + MAX_SCOPE_BINDING_INTEGRATION_LENGTH, + MAX_SCOPE_BINDING_KIND_LENGTH, + MAX_SCOPE_EXTERNAL_REFERENCE_KIND_LENGTH, + MAX_SCOPE_EXTERNAL_REFERENCE_VALUE_LENGTH, + MAX_SCOPE_IDEMPOTENCY_KEY_LENGTH, + MAX_SCOPE_SUMMARY_LENGTH, + MAX_SCOPE_TITLE_LENGTH, +) + + +class _ScopeValue(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + + +class ScopeExternalReference(_ScopeValue): + kind: str + value: str + + @field_validator("kind") + @classmethod + def validate_kind(cls, value: str) -> str: + return _required_text("kind", value, MAX_SCOPE_EXTERNAL_REFERENCE_KIND_LENGTH) + + @field_validator("value") + @classmethod + def validate_value(cls, value: str) -> str: + return _required_text("value", value, MAX_SCOPE_EXTERNAL_REFERENCE_VALUE_LENGTH) + + +class ScopeDraft(_ScopeValue): + title: str + summary: str + parent_scope_id: str | None = None + context_references: tuple[str, ...] = () + external_references: tuple[ScopeExternalReference, ...] = () + idempotency_key: str + + @field_validator("title") + @classmethod + def validate_title(cls, value: str) -> str: + return _required_text("title", value, MAX_SCOPE_TITLE_LENGTH) + + @field_validator("summary") + @classmethod + def validate_summary(cls, value: str) -> str: + return _required_text("summary", value, MAX_SCOPE_SUMMARY_LENGTH) + + @field_validator("parent_scope_id") + @classmethod + def validate_parent(cls, value: str | None) -> str | None: + return None if value is None else validate_scope_id(value) + + @field_validator("context_references") + @classmethod + def validate_context_references(cls, values: tuple[str, ...]) -> tuple[str, ...]: + normalized = tuple(validate_scope_id(value) for value in values) + if len(set(normalized)) != len(normalized): + raise ValueError("Context References must be unique") # noqa: TRY003 + return normalized + + @field_validator("external_references") + @classmethod + def validate_external_references( + cls, + values: tuple[ScopeExternalReference, ...], + ) -> tuple[ScopeExternalReference, ...]: + if len(set(values)) != len(values): + raise ValueError("external references must be unique") # noqa: TRY003 + return values + + @field_validator("idempotency_key") + @classmethod + def validate_idempotency_key(cls, value: str) -> str: + return _required_text("idempotency_key", value, MAX_SCOPE_IDEMPOTENCY_KEY_LENGTH) + + +class ScopeMutation(_ScopeValue): + expected_version: StrictInt = Field(ge=1) + title: str + summary: str + parent_scope_id: str | None = None + context_references: tuple[str, ...] = () + external_references: tuple[ScopeExternalReference, ...] = () + + @field_validator("title") + @classmethod + def validate_title(cls, value: str) -> str: + return _required_text("title", value, MAX_SCOPE_TITLE_LENGTH) + + @field_validator("summary") + @classmethod + def validate_summary(cls, value: str) -> str: + return _required_text("summary", value, MAX_SCOPE_SUMMARY_LENGTH) + + @field_validator("parent_scope_id") + @classmethod + def validate_parent(cls, value: str | None) -> str | None: + return None if value is None else validate_scope_id(value) + + @field_validator("context_references") + @classmethod + def validate_context_references(cls, values: tuple[str, ...]) -> tuple[str, ...]: + normalized = tuple(validate_scope_id(value) for value in values) + if len(set(normalized)) != len(normalized): + raise ValueError("Context References must be unique") # noqa: TRY003 + return normalized + + @field_validator("external_references") + @classmethod + def validate_external_references( + cls, + values: tuple[ScopeExternalReference, ...], + ) -> tuple[ScopeExternalReference, ...]: + if len(set(values)) != len(values): + raise ValueError("external references must be unique") # noqa: TRY003 + return values + + +class ScopeDescriptor(_ScopeValue): + scope_id: str + title: str + summary: str + parent_scope_id: str | None = None + context_references: tuple[str, ...] = () + external_references: tuple[ScopeExternalReference, ...] = () + version: StrictInt = Field(ge=1) + + @field_validator("scope_id") + @classmethod + def validate_scope_id(cls, value: str) -> str: + return validate_scope_id(value) + + +class ScopeBindingKey(_ScopeValue): + integration: str + kind: str + external_id: str + + @field_validator("integration") + @classmethod + def validate_integration(cls, value: str) -> str: + return _required_text("integration", value, MAX_SCOPE_BINDING_INTEGRATION_LENGTH) + + @field_validator("kind") + @classmethod + def validate_kind(cls, value: str) -> str: + return _required_text("kind", value, MAX_SCOPE_BINDING_KIND_LENGTH) + + @field_validator("external_id") + @classmethod + def validate_external_id(cls, value: str) -> str: + return _required_text("external_id", value, MAX_SCOPE_BINDING_EXTERNAL_ID_LENGTH) + + +class ScopeBinding(_ScopeValue): + key: ScopeBindingKey + scope_id: str + + @field_validator("scope_id") + @classmethod + def validate_scope_id(cls, value: str) -> str: + return validate_scope_id(value) + + +class ScopeSelection(_ScopeValue): + mode: Literal["all", "exact", "subtree"] + scope_ids: tuple[str, ...] = () + root_scope_id: str | None = None + + @field_validator("scope_ids") + @classmethod + def validate_scope_ids(cls, values: tuple[str, ...]) -> tuple[str, ...]: + normalized = tuple(validate_scope_id(value) for value in values) + if len(set(normalized)) != len(normalized): + raise ValueError("exact Scope selection must be unique") # noqa: TRY003 + return normalized + + @field_validator("root_scope_id") + @classmethod + def validate_root_scope_id(cls, value: str | None) -> str | None: + return None if value is None else validate_scope_id(value) + + @model_validator(mode="after") + def validate_shape(self) -> ScopeSelection: + if self.mode == "all" and (self.scope_ids or self.root_scope_id is not None): + raise ValueError("all selection accepts no Scope arguments") # noqa: TRY003 + if self.mode == "exact" and (not self.scope_ids or self.root_scope_id is not None): + raise ValueError("exact selection requires scope_ids only") # noqa: TRY003 + if self.mode == "subtree" and (self.root_scope_id is None or self.scope_ids): + raise ValueError("subtree selection requires root_scope_id only") # noqa: TRY003 + return self + + +def _required_text(field: str, value: str, maximum: int) -> str: + if not value.strip() or value != value.strip(): + raise ValueError(f"{field} must be non-empty without surrounding whitespace") # noqa: TRY003 + if len(value) > maximum: + raise ValueError(f"{field} must not exceed {maximum} characters") # noqa: TRY003 + return value diff --git a/src/powercontext/builtin/scope/repository.py b/src/powercontext/builtin/scope/repository.py new file mode 100644 index 000000000..326ba03b2 --- /dev/null +++ b/src/powercontext/builtin/scope/repository.py @@ -0,0 +1,280 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 + +"""Relational persistence for durable Scope organization.""" + +from __future__ import annotations + +from sqlalchemy import delete, insert, select, update +from sqlalchemy.ext.asyncio import AsyncConnection + +from powercontext.builtin.persistence.tables import ( + SCOPE_BINDINGS_TABLE, + SCOPE_CONTEXT_REFERENCES_TABLE, + SCOPE_CREATION_REQUESTS_TABLE, + SCOPE_EXTERNAL_REFERENCES_TABLE, + SCOPE_SETTINGS_TABLE, + SCOPES_TABLE, +) +from powercontext.builtin.scope.models import ( + ScopeBinding, + ScopeBindingKey, + ScopeDescriptor, + ScopeDraft, + ScopeExternalReference, + ScopeMutation, +) + +_DEFAULT_SETTING = "default" + + +class ScopeRepository: + async def get(self, connection: AsyncConnection, scope_id: str, /) -> ScopeDescriptor | None: + row = ( + (await connection.execute(select(SCOPES_TABLE).where(SCOPES_TABLE.c.scope_id == scope_id))) + .mappings() + .one_or_none() + ) + if row is None: + return None + context_references = tuple( + str(value) + for value in ( + await connection.execute( + select(SCOPE_CONTEXT_REFERENCES_TABLE.c.referenced_scope_id) + .where(SCOPE_CONTEXT_REFERENCES_TABLE.c.scope_id == scope_id) + .order_by(SCOPE_CONTEXT_REFERENCES_TABLE.c.referenced_scope_id) + ) + ).scalars() + ) + external_references = tuple( + ScopeExternalReference(kind=str(value.kind), value=str(value.value)) + for value in ( + await connection.execute( + select( + SCOPE_EXTERNAL_REFERENCES_TABLE.c.kind, + SCOPE_EXTERNAL_REFERENCES_TABLE.c.value, + ) + .where(SCOPE_EXTERNAL_REFERENCES_TABLE.c.scope_id == scope_id) + .order_by(SCOPE_EXTERNAL_REFERENCES_TABLE.c.ordinal) + ) + ) + ) + return ScopeDescriptor( + scope_id=str(row["scope_id"]), + title=str(row["title"]), + summary=str(row["summary"]), + parent_scope_id=None if row["parent_scope_id"] is None else str(row["parent_scope_id"]), + context_references=context_references, + external_references=external_references, + version=int(row["version"]), + ) + + async def list(self, connection: AsyncConnection, /) -> tuple[ScopeDescriptor, ...]: + scope_ids = tuple( + str(value) + for value in ( + await connection.execute(select(SCOPES_TABLE.c.scope_id).order_by(SCOPES_TABLE.c.scope_id)) + ).scalars() + ) + scopes: list[ScopeDescriptor] = [] + for scope_id in scope_ids: + scope = await self.get(connection, scope_id) + if scope is None: + raise AssertionError + scopes.append(scope) + return tuple(scopes) + + async def children(self, connection: AsyncConnection, parent_scope_id: str, /) -> tuple[str, ...]: + return tuple( + str(value) + for value in ( + await connection.execute( + select(SCOPES_TABLE.c.scope_id) + .where(SCOPES_TABLE.c.parent_scope_id == parent_scope_id) + .order_by(SCOPES_TABLE.c.scope_id) + ) + ).scalars() + ) + + async def creation(self, connection: AsyncConnection, idempotency_key: str, /) -> tuple[str, str] | None: + row = ( + await connection.execute( + select( + SCOPE_CREATION_REQUESTS_TABLE.c.request_digest, + SCOPE_CREATION_REQUESTS_TABLE.c.scope_id, + ).where(SCOPE_CREATION_REQUESTS_TABLE.c.idempotency_key == idempotency_key) + ) + ).one_or_none() + return None if row is None else (str(row.request_digest), str(row.scope_id)) + + async def add( + self, + connection: AsyncConnection, + scope_id: str, + draft: ScopeDraft, + request_digest: str, + /, + ) -> ScopeDescriptor: + await connection.execute( + insert(SCOPES_TABLE).values( + scope_id=scope_id, + title=draft.title, + summary=draft.summary, + parent_scope_id=draft.parent_scope_id, + version=1, + ) + ) + await self._replace_relationships( + connection, + scope_id, + draft.context_references, + draft.external_references, + ) + await connection.execute( + insert(SCOPE_CREATION_REQUESTS_TABLE).values( + idempotency_key=draft.idempotency_key, + request_digest=request_digest, + scope_id=scope_id, + ) + ) + created = await self.get(connection, scope_id) + if created is None: + raise AssertionError + return created + + async def replace( + self, + connection: AsyncConnection, + scope_id: str, + mutation: ScopeMutation, + /, + ) -> bool: + result = await connection.execute( + update(SCOPES_TABLE) + .where( + SCOPES_TABLE.c.scope_id == scope_id, + SCOPES_TABLE.c.version == mutation.expected_version, + ) + .values( + title=mutation.title, + summary=mutation.summary, + parent_scope_id=mutation.parent_scope_id, + version=mutation.expected_version + 1, + ) + ) + if result.rowcount != 1: + return False + await self._replace_relationships( + connection, + scope_id, + mutation.context_references, + mutation.external_references, + ) + return True + + async def default_scope_id(self, connection: AsyncConnection, /) -> str | None: + value = ( + await connection.execute( + select(SCOPE_SETTINGS_TABLE.c.scope_id).where(SCOPE_SETTINGS_TABLE.c.name == _DEFAULT_SETTING) + ) + ).scalar_one_or_none() + return None if value is None else str(value) + + async def set_default(self, connection: AsyncConnection, scope_id: str, /) -> None: + result = await connection.execute( + update(SCOPE_SETTINGS_TABLE) + .where(SCOPE_SETTINGS_TABLE.c.name == _DEFAULT_SETTING) + .values(scope_id=scope_id) + ) + if result.rowcount == 0: + await connection.execute(insert(SCOPE_SETTINGS_TABLE).values(name=_DEFAULT_SETTING, scope_id=scope_id)) + + async def binding(self, connection: AsyncConnection, key: ScopeBindingKey, /) -> ScopeBinding | None: + value = ( + await connection.execute( + select(SCOPE_BINDINGS_TABLE.c.scope_id).where( + SCOPE_BINDINGS_TABLE.c.integration == key.integration, + SCOPE_BINDINGS_TABLE.c.kind == key.kind, + SCOPE_BINDINGS_TABLE.c.external_id == key.external_id, + ) + ) + ).scalar_one_or_none() + return None if value is None else ScopeBinding(key=key, scope_id=str(value)) + + async def set_binding( + self, + connection: AsyncConnection, + key: ScopeBindingKey, + scope_id: str, + /, + ) -> ScopeBinding: + result = await connection.execute( + update(SCOPE_BINDINGS_TABLE) + .where( + SCOPE_BINDINGS_TABLE.c.integration == key.integration, + SCOPE_BINDINGS_TABLE.c.kind == key.kind, + SCOPE_BINDINGS_TABLE.c.external_id == key.external_id, + ) + .values(scope_id=scope_id) + ) + if result.rowcount == 0: + await connection.execute( + insert(SCOPE_BINDINGS_TABLE).values( + integration=key.integration, + kind=key.kind, + external_id=key.external_id, + scope_id=scope_id, + ) + ) + return ScopeBinding(key=key, scope_id=scope_id) + + async def clear_binding(self, connection: AsyncConnection, key: ScopeBindingKey, /) -> bool: + result = await connection.execute( + delete(SCOPE_BINDINGS_TABLE).where( + SCOPE_BINDINGS_TABLE.c.integration == key.integration, + SCOPE_BINDINGS_TABLE.c.kind == key.kind, + SCOPE_BINDINGS_TABLE.c.external_id == key.external_id, + ) + ) + return result.rowcount == 1 + + async def _replace_relationships( + self, + connection: AsyncConnection, + scope_id: str, + context_references: tuple[str, ...], + external_references: tuple[ScopeExternalReference, ...], + ) -> None: + await connection.execute( + delete(SCOPE_CONTEXT_REFERENCES_TABLE).where(SCOPE_CONTEXT_REFERENCES_TABLE.c.scope_id == scope_id) + ) + if context_references: + await connection.execute( + insert(SCOPE_CONTEXT_REFERENCES_TABLE), + [ + {"scope_id": scope_id, "referenced_scope_id": referenced_scope_id} + for referenced_scope_id in context_references + ], + ) + await connection.execute( + delete(SCOPE_EXTERNAL_REFERENCES_TABLE).where(SCOPE_EXTERNAL_REFERENCES_TABLE.c.scope_id == scope_id) + ) + if external_references: + await connection.execute( + insert(SCOPE_EXTERNAL_REFERENCES_TABLE), + [ + { + "scope_id": scope_id, + "ordinal": ordinal, + "kind": reference.kind, + "value": reference.value, + } + for ordinal, reference in enumerate(external_references) + ], + ) diff --git a/src/powercontext/limits.py b/src/powercontext/limits.py index f71ed0fe2..5550c3507 100644 --- a/src/powercontext/limits.py +++ b/src/powercontext/limits.py @@ -15,6 +15,14 @@ """Shared identity limits that remain safe for utf8mb4 relational indexes.""" MAX_SCOPE_ID_LENGTH = 256 +MAX_SCOPE_TITLE_LENGTH = 256 +MAX_SCOPE_SUMMARY_LENGTH = 2_000 +MAX_SCOPE_EXTERNAL_REFERENCE_KIND_LENGTH = 128 +MAX_SCOPE_EXTERNAL_REFERENCE_VALUE_LENGTH = 2_000 +MAX_SCOPE_IDEMPOTENCY_KEY_LENGTH = 256 +MAX_SCOPE_BINDING_INTEGRATION_LENGTH = 128 +MAX_SCOPE_BINDING_KIND_LENGTH = 64 +MAX_SCOPE_BINDING_EXTERNAL_ID_LENGTH = 256 MAX_SOURCE_ID_LENGTH = 256 MAX_SOURCE_TYPE_LENGTH = 128 MAX_ARTIFACT_FAMILY_LENGTH = 128 diff --git a/tests/builtin/test_scope_application.py b/tests/builtin/test_scope_application.py new file mode 100644 index 000000000..99b10d78e --- /dev/null +++ b/tests/builtin/test_scope_application.py @@ -0,0 +1,169 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 + +from __future__ import annotations + +import asyncio +import re + +import pytest + +from powercontext.builtin.persistence.sqlite import SQLiteConfig, SQLiteProfile +from powercontext.builtin.persistence.tables import BUILTIN_TABLES +from powercontext.builtin.scope import ( + ScopeApplication, + ScopeBindingKey, + ScopeDraft, + ScopeIdempotencyConflictError, + ScopeMutation, + ScopeRelationshipError, + ScopeSelection, + ScopeVersionConflictError, +) + + +def test_scope_bootstrap_creates_one_ordinary_default() -> None: + async def scenario() -> None: + async with SQLiteProfile.open(SQLiteConfig(), tables=BUILTIN_TABLES) as profile: + scopes = ScopeApplication(profile.database) + + first = await scopes.bootstrap_default() + second = await scopes.bootstrap_default() + + assert first == second + assert re.fullmatch(r"scp_[0-7][0-9a-hjkmnp-tv-z]{25}", first.scope_id) + assert first.parent_scope_id is None + assert first.context_references == () + assert await scopes.list() == (first,) + + asyncio.run(scenario()) + + +def test_scope_creation_is_idempotent_but_not_ambiguous() -> None: + async def scenario() -> None: + async with SQLiteProfile.open(SQLiteConfig(), tables=BUILTIN_TABLES) as profile: + scopes = ScopeApplication(profile.database) + draft = ScopeDraft(title="Feature", summary="Implement search", idempotency_key="feature-search") + + assert await scopes.create(draft) == await scopes.create(draft) + with pytest.raises(ScopeIdempotencyConflictError): + await scopes.create( + ScopeDraft(title="Feature", summary="Different work", idempotency_key="feature-search") + ) + + asyncio.run(scenario()) + + +def test_parent_organizes_without_sharing_and_cannot_form_a_cycle() -> None: + async def scenario() -> None: + async with SQLiteProfile.open(SQLiteConfig(), tables=BUILTIN_TABLES) as profile: + scopes = ScopeApplication(profile.database) + root = await scopes.create(ScopeDraft(title="Root", summary="Root result", idempotency_key="root")) + child = await scopes.create( + ScopeDraft( + title="Child", + summary="Independent result", + parent_scope_id=root.scope_id, + idempotency_key="child", + ) + ) + + assert child.context_references == () + with pytest.raises(ScopeRelationshipError, match="acyclic"): + await scopes.update( + root.scope_id, + ScopeMutation( + expected_version=root.version, + title=root.title, + summary=root.summary, + parent_scope_id=child.scope_id, + ), + ) + + asyncio.run(scenario()) + + +def test_context_references_remain_direct_and_metadata_updates_use_cas() -> None: + async def scenario() -> None: + async with SQLiteProfile.open(SQLiteConfig(), tables=BUILTIN_TABLES) as profile: + scopes = ScopeApplication(profile.database) + shared = await scopes.create(ScopeDraft(title="Shared", summary="Shared facts", idempotency_key="shared")) + middle = await scopes.create( + ScopeDraft( + title="Middle", + summary="Direct reader", + context_references=(shared.scope_id,), + idempotency_key="middle", + ) + ) + current = await scopes.create( + ScopeDraft( + title="Current", + summary="Reads only Middle", + context_references=(middle.scope_id,), + idempotency_key="current", + ) + ) + + assert current.context_references == (middle.scope_id,) + updated = await scopes.update( + current.scope_id, + ScopeMutation( + expected_version=current.version, + title="Current result", + summary=current.summary, + context_references=current.context_references, + ), + ) + assert updated.version == 2 + with pytest.raises(ScopeVersionConflictError): + await scopes.update( + current.scope_id, + ScopeMutation( + expected_version=current.version, + title=current.title, + summary=current.summary, + ), + ) + + asyncio.run(scenario()) + + +def test_binding_precedence_and_observation_selection_are_independent() -> None: + async def scenario() -> None: + async with SQLiteProfile.open(SQLiteConfig(), tables=BUILTIN_TABLES) as profile: + scopes = ScopeApplication(profile.database) + default = await scopes.bootstrap_default() + root = await scopes.create(ScopeDraft(title="Root", summary="Root result", idempotency_key="root")) + child = await scopes.create( + ScopeDraft( + title="Child", + summary="Child result", + parent_scope_id=root.scope_id, + idempotency_key="child", + ) + ) + session = ScopeBindingKey(integration="codex", kind="session", external_id="session-1") + workspace = ScopeBindingKey(integration="codex", kind="workspace", external_id="workspace-1") + await scopes.bind(session, child.scope_id) + await scopes.bind(workspace, root.scope_id) + + assert (await scopes.resolve_binding(binding_keys=(session, workspace))).scope_id == child.scope_id + assert (await scopes.resolve_binding(binding_keys=(workspace,))).scope_id == root.scope_id + assert (await scopes.resolve_binding()).scope_id == default.scope_id + assert tuple( + scope.scope_id + for scope in await scopes.resolve_selection(ScopeSelection(mode="subtree", root_scope_id=root.scope_id)) + ) == (root.scope_id, child.scope_id) + assert {scope.scope_id for scope in await scopes.resolve_selection(ScopeSelection(mode="all"))} == { + default.scope_id, + root.scope_id, + child.scope_id, + } + + asyncio.run(scenario()) From 3a4c196fde6313a0b7af00751069fe97dac3df72 Mon Sep 17 00:00:00 2001 From: Chojan Shang Date: Thu, 27 Aug 2026 15:28:45 +0800 Subject: [PATCH 02/15] feat(api): expose scope and binding capabilities --- .../powercontext/src/operations.generated.ts | 10 + .../powercontext/src/operations.generated.ts | 10 + .../powercontext/src/operations.generated.ts | 10 + openapi/powercontext.yaml | 478 ++++++++++++++++++ src/powercontext/http/__init__.py | 32 ++ src/powercontext/http/_generated/models.py | 137 +++++ .../http/_generated/operations.py | 189 +++++++ src/powercontext/http/_generated/schema.py | 384 ++++++++++++++ src/powercontext/server/app.py | 235 ++++++++- tests/test_scope_api.py | 108 ++++ 10 files changed, 1590 insertions(+), 3 deletions(-) create mode 100644 tests/test_scope_api.py diff --git a/integrations/dsh/plugins/powercontext/src/operations.generated.ts b/integrations/dsh/plugins/powercontext/src/operations.generated.ts index 092c7108a..0e6ab4383 100644 --- a/integrations/dsh/plugins/powercontext/src/operations.generated.ts +++ b/integrations/dsh/plugins/powercontext/src/operations.generated.ts @@ -20,6 +20,16 @@ export const OPERATIONS = { get_liveness: { method: 'GET', path: '/health/live', location: null, scope: false }, get_readiness: { method: 'GET', path: '/health/ready', location: null, scope: false }, get_capabilities: { method: 'GET', path: '/v1/capabilities', location: null, scope: false }, + list_scopes: { method: 'GET', path: '/v1/scopes', location: null, scope: false }, + create_scope: { method: 'POST', path: '/v1/scopes', location: "body", scope: false }, + get_scope: { method: 'POST', path: '/v1/scopes/get', location: "body", scope: true }, + update_scope: { method: 'POST', path: '/v1/scopes/update', location: "body", scope: true }, + get_default_scope: { method: 'GET', path: '/v1/scopes/default', location: null, scope: false }, + set_default_scope: { method: 'PUT', path: '/v1/scopes/default', location: "body", scope: true }, + resolve_scope_selection: { method: 'POST', path: '/v1/scopes/selection/resolve', location: "body", scope: false }, + resolve_scope_binding: { method: 'POST', path: '/v1/scope-bindings/resolve', location: "body", scope: false }, + set_scope_binding: { method: 'PUT', path: '/v1/scope-bindings', location: "body", scope: true }, + clear_scope_binding: { method: 'POST', path: '/v1/scope-bindings/clear', location: "body", scope: false }, capture_content_source: { method: 'POST', path: '/v1/sources/content', location: "body", scope: true }, prepare_context: { method: 'POST', path: '/v1/context/prepare', location: "body", scope: true }, create_work_contract: { method: 'POST', path: '/v1/work/contracts/create', location: "body", scope: true }, diff --git a/integrations/opencode/plugins/powercontext/src/operations.generated.ts b/integrations/opencode/plugins/powercontext/src/operations.generated.ts index 092c7108a..0e6ab4383 100644 --- a/integrations/opencode/plugins/powercontext/src/operations.generated.ts +++ b/integrations/opencode/plugins/powercontext/src/operations.generated.ts @@ -20,6 +20,16 @@ export const OPERATIONS = { get_liveness: { method: 'GET', path: '/health/live', location: null, scope: false }, get_readiness: { method: 'GET', path: '/health/ready', location: null, scope: false }, get_capabilities: { method: 'GET', path: '/v1/capabilities', location: null, scope: false }, + list_scopes: { method: 'GET', path: '/v1/scopes', location: null, scope: false }, + create_scope: { method: 'POST', path: '/v1/scopes', location: "body", scope: false }, + get_scope: { method: 'POST', path: '/v1/scopes/get', location: "body", scope: true }, + update_scope: { method: 'POST', path: '/v1/scopes/update', location: "body", scope: true }, + get_default_scope: { method: 'GET', path: '/v1/scopes/default', location: null, scope: false }, + set_default_scope: { method: 'PUT', path: '/v1/scopes/default', location: "body", scope: true }, + resolve_scope_selection: { method: 'POST', path: '/v1/scopes/selection/resolve', location: "body", scope: false }, + resolve_scope_binding: { method: 'POST', path: '/v1/scope-bindings/resolve', location: "body", scope: false }, + set_scope_binding: { method: 'PUT', path: '/v1/scope-bindings', location: "body", scope: true }, + clear_scope_binding: { method: 'POST', path: '/v1/scope-bindings/clear', location: "body", scope: false }, capture_content_source: { method: 'POST', path: '/v1/sources/content', location: "body", scope: true }, prepare_context: { method: 'POST', path: '/v1/context/prepare', location: "body", scope: true }, create_work_contract: { method: 'POST', path: '/v1/work/contracts/create', location: "body", scope: true }, diff --git a/integrations/pi/plugins/powercontext/src/operations.generated.ts b/integrations/pi/plugins/powercontext/src/operations.generated.ts index 092c7108a..0e6ab4383 100644 --- a/integrations/pi/plugins/powercontext/src/operations.generated.ts +++ b/integrations/pi/plugins/powercontext/src/operations.generated.ts @@ -20,6 +20,16 @@ export const OPERATIONS = { get_liveness: { method: 'GET', path: '/health/live', location: null, scope: false }, get_readiness: { method: 'GET', path: '/health/ready', location: null, scope: false }, get_capabilities: { method: 'GET', path: '/v1/capabilities', location: null, scope: false }, + list_scopes: { method: 'GET', path: '/v1/scopes', location: null, scope: false }, + create_scope: { method: 'POST', path: '/v1/scopes', location: "body", scope: false }, + get_scope: { method: 'POST', path: '/v1/scopes/get', location: "body", scope: true }, + update_scope: { method: 'POST', path: '/v1/scopes/update', location: "body", scope: true }, + get_default_scope: { method: 'GET', path: '/v1/scopes/default', location: null, scope: false }, + set_default_scope: { method: 'PUT', path: '/v1/scopes/default', location: "body", scope: true }, + resolve_scope_selection: { method: 'POST', path: '/v1/scopes/selection/resolve', location: "body", scope: false }, + resolve_scope_binding: { method: 'POST', path: '/v1/scope-bindings/resolve', location: "body", scope: false }, + set_scope_binding: { method: 'PUT', path: '/v1/scope-bindings', location: "body", scope: true }, + clear_scope_binding: { method: 'POST', path: '/v1/scope-bindings/clear', location: "body", scope: false }, capture_content_source: { method: 'POST', path: '/v1/sources/content', location: "body", scope: true }, prepare_context: { method: 'POST', path: '/v1/context/prepare', location: "body", scope: true }, create_work_contract: { method: 'POST', path: '/v1/work/contracts/create', location: "body", scope: true }, diff --git a/openapi/powercontext.yaml b/openapi/powercontext.yaml index 2c8681f99..4641765ee 100644 --- a/openapi/powercontext.yaml +++ b/openapi/powercontext.yaml @@ -79,6 +79,226 @@ paths: $ref: "#/components/schemas/Capabilities" "401": $ref: "#/components/responses/Unauthorized" + /v1/scopes: + get: + tags: [scopes] + summary: List observable Scopes + operationId: list_scopes + responses: + "200": + description: Durable Scope metadata in deterministic identity order. + content: + application/json: + schema: + $ref: "#/components/schemas/ScopePage" + "401": + $ref: "#/components/responses/Unauthorized" + "503": + $ref: "#/components/responses/Unavailable" + post: + tags: [scopes] + summary: Create an independent Scope boundary + operationId: create_scope + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/CreateScopeRequest" + responses: + "201": + description: The durable Scope descriptor. + content: + application/json: + schema: + $ref: "#/components/schemas/ScopeDescriptor" + "404": + $ref: "#/components/responses/NotFound" + "409": + $ref: "#/components/responses/Conflict" + "401": + $ref: "#/components/responses/Unauthorized" + "422": + $ref: "#/components/responses/InvalidRequest" + /v1/scopes/get: + post: + tags: [scopes] + summary: Get one Scope descriptor + operationId: get_scope + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/GetScopeRequest" + responses: + "200": + description: The exact Scope descriptor. + content: + application/json: + schema: + $ref: "#/components/schemas/ScopeDescriptor" + "404": + $ref: "#/components/responses/NotFound" + "401": + $ref: "#/components/responses/Unauthorized" + /v1/scopes/update: + post: + tags: [scopes] + summary: Replace mutable Scope metadata and relationships + operationId: update_scope + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/UpdateScopeRequest" + responses: + "200": + description: The updated Scope descriptor. + content: + application/json: + schema: + $ref: "#/components/schemas/ScopeDescriptor" + "404": + $ref: "#/components/responses/NotFound" + "409": + $ref: "#/components/responses/Conflict" + "401": + $ref: "#/components/responses/Unauthorized" + "422": + $ref: "#/components/responses/InvalidRequest" + /v1/scopes/default: + get: + tags: [scopes] + summary: Get the default Scope binding target + operationId: get_default_scope + responses: + "200": + description: The ordinary Scope selected by the host default pointer. + content: + application/json: + schema: + $ref: "#/components/schemas/ScopeDescriptor" + "404": + $ref: "#/components/responses/NotFound" + "401": + $ref: "#/components/responses/Unauthorized" + put: + tags: [scopes] + summary: Change the default Scope binding target + operationId: set_default_scope + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/SetDefaultScopeRequest" + responses: + "200": + description: The selected ordinary Scope. + content: + application/json: + schema: + $ref: "#/components/schemas/ScopeDescriptor" + "404": + $ref: "#/components/responses/NotFound" + "401": + $ref: "#/components/responses/Unauthorized" + /v1/scopes/selection/resolve: + post: + tags: [scopes] + summary: Resolve an observation selection to a frozen Scope set + operationId: resolve_scope_selection + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/ResolveScopeSelectionRequest" + responses: + "200": + description: The selected Scope descriptors in deterministic order. + content: + application/json: + schema: + $ref: "#/components/schemas/ScopePage" + "404": + $ref: "#/components/responses/NotFound" + "401": + $ref: "#/components/responses/Unauthorized" + "422": + $ref: "#/components/responses/InvalidRequest" + /v1/scope-bindings/resolve: + post: + tags: [scope-bindings] + summary: Resolve an explicit durable or default Scope binding + operationId: resolve_scope_binding + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/ResolveScopeBindingRequest" + responses: + "200": + description: The resolved Scope descriptor. + content: + application/json: + schema: + $ref: "#/components/schemas/ScopeDescriptor" + "404": + $ref: "#/components/responses/NotFound" + "401": + $ref: "#/components/responses/Unauthorized" + "422": + $ref: "#/components/responses/InvalidRequest" + /v1/scope-bindings: + put: + tags: [scope-bindings] + summary: Persist an external identity to Scope binding + operationId: set_scope_binding + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/SetScopeBindingRequest" + responses: + "200": + description: The durable external binding. + content: + application/json: + schema: + $ref: "#/components/schemas/ScopeBinding" + "404": + $ref: "#/components/responses/NotFound" + "401": + $ref: "#/components/responses/Unauthorized" + "422": + $ref: "#/components/responses/InvalidRequest" + /v1/scope-bindings/clear: + post: + tags: [scope-bindings] + summary: Remove one durable external Scope binding + operationId: clear_scope_binding + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/ClearScopeBindingRequest" + responses: + "200": + description: Whether a durable binding was removed. + content: + application/json: + schema: + $ref: "#/components/schemas/ClearScopeBindingResponse" + "401": + $ref: "#/components/responses/Unauthorized" + "422": + $ref: "#/components/responses/InvalidRequest" /v1/sources/content: post: tags: [sources] @@ -1817,6 +2037,264 @@ components: revision: type: integer minimum: 1 + ScopeExternalReference: + type: object + additionalProperties: false + required: [kind, value] + properties: + kind: + type: string + minLength: 1 + maxLength: 128 + pattern: '.*\S.*' + value: + type: string + minLength: 1 + maxLength: 2000 + pattern: '.*\S.*' + ScopeDescriptor: + type: object + additionalProperties: false + required: [scope_id, title, summary, context_references, external_references, version] + properties: + scope_id: + type: string + minLength: 1 + maxLength: 256 + pattern: '.*\S.*' + title: + type: string + minLength: 1 + maxLength: 256 + pattern: '.*\S.*' + summary: + type: string + minLength: 1 + maxLength: 2000 + pattern: '.*\S.*' + parent_scope_id: + type: string + minLength: 1 + maxLength: 256 + pattern: '.*\S.*' + nullable: true + context_references: + type: array + uniqueItems: true + items: + type: string + minLength: 1 + maxLength: 256 + pattern: '.*\S.*' + external_references: + type: array + uniqueItems: true + items: + $ref: "#/components/schemas/ScopeExternalReference" + version: + type: integer + minimum: 1 + ScopePage: + type: object + additionalProperties: false + required: [items] + properties: + items: + type: array + items: + $ref: "#/components/schemas/ScopeDescriptor" + CreateScopeRequest: + type: object + additionalProperties: false + required: [title, summary, idempotency_key] + properties: + title: + type: string + minLength: 1 + maxLength: 256 + pattern: '.*\S.*' + summary: + type: string + minLength: 1 + maxLength: 2000 + pattern: '.*\S.*' + parent_scope_id: + type: string + minLength: 1 + maxLength: 256 + pattern: '.*\S.*' + nullable: true + context_references: + type: array + uniqueItems: true + items: + type: string + minLength: 1 + maxLength: 256 + pattern: '.*\S.*' + default: [] + external_references: + type: array + uniqueItems: true + items: + $ref: "#/components/schemas/ScopeExternalReference" + default: [] + idempotency_key: + type: string + minLength: 1 + maxLength: 256 + pattern: '.*\S.*' + GetScopeRequest: + type: object + additionalProperties: false + required: [scope_id] + properties: + scope_id: + type: string + minLength: 1 + maxLength: 256 + pattern: '.*\S.*' + UpdateScopeRequest: + type: object + additionalProperties: false + required: [scope_id, expected_version, title, summary] + properties: + scope_id: + type: string + minLength: 1 + maxLength: 256 + pattern: '.*\S.*' + expected_version: + type: integer + minimum: 1 + title: + type: string + minLength: 1 + maxLength: 256 + pattern: '.*\S.*' + summary: + type: string + minLength: 1 + maxLength: 2000 + pattern: '.*\S.*' + parent_scope_id: + type: string + minLength: 1 + maxLength: 256 + pattern: '.*\S.*' + nullable: true + context_references: + type: array + uniqueItems: true + items: + type: string + minLength: 1 + maxLength: 256 + pattern: '.*\S.*' + default: [] + external_references: + type: array + uniqueItems: true + items: + $ref: "#/components/schemas/ScopeExternalReference" + default: [] + SetDefaultScopeRequest: + $ref: "#/components/schemas/GetScopeRequest" + ScopeSelectionMode: + type: string + enum: [all, exact, subtree] + ScopeSelection: + type: object + additionalProperties: false + required: [mode] + properties: + mode: + $ref: "#/components/schemas/ScopeSelectionMode" + scope_ids: + type: array + uniqueItems: true + items: + type: string + minLength: 1 + maxLength: 256 + pattern: '.*\S.*' + default: [] + root_scope_id: + type: string + minLength: 1 + maxLength: 256 + pattern: '.*\S.*' + nullable: true + ResolveScopeSelectionRequest: + type: object + additionalProperties: false + required: [selection] + properties: + selection: + $ref: "#/components/schemas/ScopeSelection" + ScopeBindingKey: + type: object + additionalProperties: false + required: [integration, kind, external_id] + properties: + integration: + type: string + minLength: 1 + maxLength: 128 + pattern: '.*\S.*' + kind: + type: string + minLength: 1 + maxLength: 64 + pattern: '.*\S.*' + external_id: + type: string + minLength: 1 + maxLength: 256 + pattern: '.*\S.*' + ScopeBinding: + type: object + additionalProperties: false + required: [key, scope_id] + properties: + key: + $ref: "#/components/schemas/ScopeBindingKey" + scope_id: + type: string + minLength: 1 + maxLength: 256 + pattern: '.*\S.*' + SetScopeBindingRequest: + $ref: "#/components/schemas/ScopeBinding" + ClearScopeBindingRequest: + type: object + additionalProperties: false + required: [key] + properties: + key: + $ref: "#/components/schemas/ScopeBindingKey" + ClearScopeBindingResponse: + type: object + additionalProperties: false + required: [cleared] + properties: + cleared: + type: boolean + ResolveScopeBindingRequest: + type: object + additionalProperties: false + properties: + explicit_scope_id: + type: string + minLength: 1 + maxLength: 256 + pattern: '.*\S.*' + nullable: true + binding_keys: + type: array + items: + $ref: "#/components/schemas/ScopeBindingKey" + default: [] ArtifactCandidate: type: object additionalProperties: false diff --git a/src/powercontext/http/__init__.py b/src/powercontext/http/__init__.py index 56c05bf89..d66aa9db9 100644 --- a/src/powercontext/http/__init__.py +++ b/src/powercontext/http/__init__.py @@ -31,10 +31,13 @@ CaptureContentSourceRequest, CaptureContentSourceResponse, CaptureStatus, + ClearScopeBindingRequest, + ClearScopeBindingResponse, CommitHandoffRequest, CommittedHandoff, ContinueHandoffRequest, CreateHandoffReportProjectRequest, + CreateScopeRequest, CreateWorkContractRequest, CurrentWorkHandoff, DetachHandoffReportWorkspaceRequest, @@ -64,6 +67,7 @@ GetHandoffReportRequest, GetHandoffReportWorkspaceRequest, GetMemoryEntryRequest, + GetScopeRequest, GetSkillRequest, GetStatsRequest, HandoffAcknowledgement, @@ -158,15 +162,26 @@ ReportTimeBasis, ResolvedUsagePeriod, ResolveExternalSkillRequest, + ResolveScopeBindingRequest, + ResolveScopeSelectionRequest, RetireMemoryEntryRequest, ReviseArtifactCandidateRequest, ReviseMemoryEntryRequest, ScanExternalSkillsRequest, ScanExternalSkillsResponse, + ScopeBinding, + ScopeBindingKey, + ScopeDescriptor, ScopedStats, + ScopeExternalReference, + ScopePage, + ScopeSelection, + ScopeSelectionMode, SearchMemoryHit, SearchMemoryRequest, SearchMemoryResponse, + SetDefaultScopeRequest, + SetScopeBindingRequest, SkillArtifact, SkillGenerationOrigin, SkillProposal, @@ -182,6 +197,7 @@ TokenEstimatorProfile, UpdateHandoffReportProjectRequest, UpdateHandoffReportWorkstreamRequest, + UpdateScopeRequest, UsageStatistics, WorkClaim, WorkClaimBasis, @@ -210,10 +226,13 @@ "CaptureContentSourceRequest", "CaptureContentSourceResponse", "CaptureStatus", + "ClearScopeBindingRequest", + "ClearScopeBindingResponse", "CommitHandoffRequest", "CommittedHandoff", "ContinueHandoffRequest", "CreateHandoffReportProjectRequest", + "CreateScopeRequest", "CreateWorkContractRequest", "CurrentWorkHandoff", "DetachHandoffReportWorkspaceRequest", @@ -243,6 +262,7 @@ "GetHandoffReportRequest", "GetHandoffReportWorkspaceRequest", "GetMemoryEntryRequest", + "GetScopeRequest", "GetSkillRequest", "GetStatsRequest", "HandoffAcknowledgement", @@ -336,16 +356,27 @@ "ReportLocale", "ReportTimeBasis", "ResolveExternalSkillRequest", + "ResolveScopeBindingRequest", + "ResolveScopeSelectionRequest", "ResolvedUsagePeriod", "RetireMemoryEntryRequest", "ReviseArtifactCandidateRequest", "ReviseMemoryEntryRequest", "ScanExternalSkillsRequest", "ScanExternalSkillsResponse", + "ScopeBinding", + "ScopeBindingKey", + "ScopeDescriptor", + "ScopeExternalReference", + "ScopePage", + "ScopeSelection", + "ScopeSelectionMode", "ScopedStats", "SearchMemoryHit", "SearchMemoryRequest", "SearchMemoryResponse", + "SetDefaultScopeRequest", + "SetScopeBindingRequest", "SkillArtifact", "SkillGenerationOrigin", "SkillProposal", @@ -361,6 +392,7 @@ "TokenEstimatorProfile", "UpdateHandoffReportProjectRequest", "UpdateHandoffReportWorkstreamRequest", + "UpdateScopeRequest", "UsageStatistics", "WorkClaim", "WorkClaimBasis", diff --git a/src/powercontext/http/_generated/models.py b/src/powercontext/http/_generated/models.py index ad2a598c1..a4328b164 100644 --- a/src/powercontext/http/_generated/models.py +++ b/src/powercontext/http/_generated/models.py @@ -30,6 +30,143 @@ class ArtifactReference(BaseModel): revision: Annotated[StrictInt, Field(ge=1)] +class ScopeExternalReference(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + kind: Annotated[StrictStr, Field(max_length=128, min_length=1, pattern=".*\\S.*")] + value: Annotated[StrictStr, Field(max_length=2000, min_length=1, pattern=".*\\S.*")] + + +class ContextReference(RootModel[StrictStr]): + root: Annotated[StrictStr, Field(max_length=256, min_length=1, pattern=".*\\S.*")] + + +class ScopeDescriptor(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + scope_id: Annotated[StrictStr, Field(max_length=256, min_length=1, pattern=".*\\S.*")] + title: Annotated[StrictStr, Field(max_length=256, min_length=1, pattern=".*\\S.*")] + summary: Annotated[StrictStr, Field(max_length=2000, min_length=1, pattern=".*\\S.*")] + parent_scope_id: Annotated[StrictStr | None, Field(max_length=256, min_length=1, pattern=".*\\S.*")] = None + context_references: list[ContextReference] + external_references: list[ScopeExternalReference] + version: Annotated[StrictInt, Field(ge=1)] + + +class ScopePage(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + items: list[ScopeDescriptor] + + +class CreateScopeRequest(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + title: Annotated[StrictStr, Field(max_length=256, min_length=1, pattern=".*\\S.*")] + summary: Annotated[StrictStr, Field(max_length=2000, min_length=1, pattern=".*\\S.*")] + parent_scope_id: Annotated[StrictStr | None, Field(max_length=256, min_length=1, pattern=".*\\S.*")] = None + context_references: Annotated[list[ContextReference], Field(validate_default=True)] = [] + external_references: Annotated[list[ScopeExternalReference], Field(validate_default=True)] = [] + idempotency_key: Annotated[StrictStr, Field(max_length=256, min_length=1, pattern=".*\\S.*")] + + +class GetScopeRequest(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + scope_id: Annotated[StrictStr, Field(max_length=256, min_length=1, pattern=".*\\S.*")] + + +class UpdateScopeRequest(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + scope_id: Annotated[StrictStr, Field(max_length=256, min_length=1, pattern=".*\\S.*")] + expected_version: Annotated[StrictInt, Field(ge=1)] + title: Annotated[StrictStr, Field(max_length=256, min_length=1, pattern=".*\\S.*")] + summary: Annotated[StrictStr, Field(max_length=2000, min_length=1, pattern=".*\\S.*")] + parent_scope_id: Annotated[StrictStr | None, Field(max_length=256, min_length=1, pattern=".*\\S.*")] = None + context_references: Annotated[list[ContextReference], Field(validate_default=True)] = [] + external_references: Annotated[list[ScopeExternalReference], Field(validate_default=True)] = [] + + +class SetDefaultScopeRequest(RootModel[GetScopeRequest]): + root: GetScopeRequest + + +class ScopeSelectionMode(StrEnum): + ALL = "all" + EXACT = "exact" + SUBTREE = "subtree" + + +class ScopeId(RootModel[StrictStr]): + root: Annotated[StrictStr, Field(max_length=256, min_length=1, pattern=".*\\S.*")] + + +class ScopeSelection(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + mode: ScopeSelectionMode + scope_ids: Annotated[list[ScopeId], Field(validate_default=True)] = [] + root_scope_id: Annotated[StrictStr | None, Field(max_length=256, min_length=1, pattern=".*\\S.*")] = None + + +class ResolveScopeSelectionRequest(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + selection: ScopeSelection + + +class ScopeBindingKey(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + integration: Annotated[StrictStr, Field(max_length=128, min_length=1, pattern=".*\\S.*")] + kind: Annotated[StrictStr, Field(max_length=64, min_length=1, pattern=".*\\S.*")] + external_id: Annotated[StrictStr, Field(max_length=256, min_length=1, pattern=".*\\S.*")] + + +class ScopeBinding(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + key: ScopeBindingKey + scope_id: Annotated[StrictStr, Field(max_length=256, min_length=1, pattern=".*\\S.*")] + + +class SetScopeBindingRequest(RootModel[ScopeBinding]): + root: ScopeBinding + + +class ClearScopeBindingRequest(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + key: ScopeBindingKey + + +class ClearScopeBindingResponse(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + cleared: StrictBool + + +class ResolveScopeBindingRequest(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + explicit_scope_id: Annotated[StrictStr | None, Field(max_length=256, min_length=1, pattern=".*\\S.*")] = None + binding_keys: Annotated[list[ScopeBindingKey], Field(validate_default=True)] = [] + + class ApproveArtifactCandidateRequest(BaseModel): model_config = ConfigDict( extra="forbid", diff --git a/src/powercontext/http/_generated/operations.py b/src/powercontext/http/_generated/operations.py index d344d87bd..0c59cf69e 100644 --- a/src/powercontext/http/_generated/operations.py +++ b/src/powercontext/http/_generated/operations.py @@ -16,10 +16,13 @@ Capabilities, CaptureContentSourceRequest, CaptureContentSourceResponse, + ClearScopeBindingRequest, + ClearScopeBindingResponse, CommitHandoffRequest, CommittedHandoff, ContinueHandoffRequest, CreateHandoffReportProjectRequest, + CreateScopeRequest, CreateWorkContractRequest, DetachHandoffReportWorkspaceRequest, ExperienceArtifact, @@ -36,6 +39,7 @@ GetHandoffReportRequest, GetHandoffReportWorkspaceRequest, GetMemoryEntryRequest, + GetScopeRequest, GetSkillRequest, GetStatsRequest, HandoffAcknowledgement, @@ -80,18 +84,26 @@ RejectArtifactCandidateRequest, RememberMemoryRequest, ResolveExternalSkillRequest, + ResolveScopeBindingRequest, + ResolveScopeSelectionRequest, RetireMemoryEntryRequest, ReviseArtifactCandidateRequest, ReviseMemoryEntryRequest, ScanExternalSkillsRequest, ScanExternalSkillsResponse, + ScopeBinding, + ScopeDescriptor, ScopedStats, + ScopePage, SearchMemoryRequest, SearchMemoryResponse, + SetDefaultScopeRequest, + SetScopeBindingRequest, SkillArtifact, StoredHandoffReportActivity, UpdateHandoffReportProjectRequest, UpdateHandoffReportWorkstreamRequest, + UpdateScopeRequest, WorkSourceReceipt, WorkstreamDescriptor, WorkstreamPage, @@ -178,6 +190,183 @@ class Operation(BaseModel, Generic[RequestT, ResponseT]): }, ) +LIST_SCOPES = Operation[None, ScopePage]( + method="GET", + path="/v1/scopes", + operation_id="list_scopes", + request_type=None, + request_location=None, + response_type=ScopePage, + success_status=200, + summary="List observable Scopes", + tags=("scopes",), + responses={ + 200: {"description": "Durable Scope metadata in deterministic identity order."}, + 401: {"$ref": "#/components/responses/Unauthorized"}, + 503: {"$ref": "#/components/responses/Unavailable"}, + }, +) + +CREATE_SCOPE = Operation[CreateScopeRequest, ScopeDescriptor]( + method="POST", + path="/v1/scopes", + operation_id="create_scope", + request_type=CreateScopeRequest, + request_location="body", + response_type=ScopeDescriptor, + success_status=201, + summary="Create an independent Scope boundary", + tags=("scopes",), + responses={ + 201: {"description": "The durable Scope descriptor."}, + 404: {"$ref": "#/components/responses/NotFound"}, + 409: {"$ref": "#/components/responses/Conflict"}, + 401: {"$ref": "#/components/responses/Unauthorized"}, + 422: {"$ref": "#/components/responses/InvalidRequest"}, + }, +) + +GET_SCOPE = Operation[GetScopeRequest, ScopeDescriptor]( + method="POST", + path="/v1/scopes/get", + operation_id="get_scope", + request_type=GetScopeRequest, + request_location="body", + response_type=ScopeDescriptor, + success_status=200, + summary="Get one Scope descriptor", + tags=("scopes",), + responses={ + 200: {"description": "The exact Scope descriptor."}, + 404: {"$ref": "#/components/responses/NotFound"}, + 401: {"$ref": "#/components/responses/Unauthorized"}, + }, +) + +UPDATE_SCOPE = Operation[UpdateScopeRequest, ScopeDescriptor]( + method="POST", + path="/v1/scopes/update", + operation_id="update_scope", + request_type=UpdateScopeRequest, + request_location="body", + response_type=ScopeDescriptor, + success_status=200, + summary="Replace mutable Scope metadata and relationships", + tags=("scopes",), + responses={ + 200: {"description": "The updated Scope descriptor."}, + 404: {"$ref": "#/components/responses/NotFound"}, + 409: {"$ref": "#/components/responses/Conflict"}, + 401: {"$ref": "#/components/responses/Unauthorized"}, + 422: {"$ref": "#/components/responses/InvalidRequest"}, + }, +) + +GET_DEFAULT_SCOPE = Operation[None, ScopeDescriptor]( + method="GET", + path="/v1/scopes/default", + operation_id="get_default_scope", + request_type=None, + request_location=None, + response_type=ScopeDescriptor, + success_status=200, + summary="Get the default Scope binding target", + tags=("scopes",), + responses={ + 200: {"description": "The ordinary Scope selected by the host default pointer."}, + 404: {"$ref": "#/components/responses/NotFound"}, + 401: {"$ref": "#/components/responses/Unauthorized"}, + }, +) + +SET_DEFAULT_SCOPE = Operation[SetDefaultScopeRequest, ScopeDescriptor]( + method="PUT", + path="/v1/scopes/default", + operation_id="set_default_scope", + request_type=SetDefaultScopeRequest, + request_location="body", + response_type=ScopeDescriptor, + success_status=200, + summary="Change the default Scope binding target", + tags=("scopes",), + responses={ + 200: {"description": "The selected ordinary Scope."}, + 404: {"$ref": "#/components/responses/NotFound"}, + 401: {"$ref": "#/components/responses/Unauthorized"}, + }, +) + +RESOLVE_SCOPE_SELECTION = Operation[ResolveScopeSelectionRequest, ScopePage]( + method="POST", + path="/v1/scopes/selection/resolve", + operation_id="resolve_scope_selection", + request_type=ResolveScopeSelectionRequest, + request_location="body", + response_type=ScopePage, + success_status=200, + summary="Resolve an observation selection to a frozen Scope set", + tags=("scopes",), + responses={ + 200: {"description": "The selected Scope descriptors in deterministic order."}, + 404: {"$ref": "#/components/responses/NotFound"}, + 401: {"$ref": "#/components/responses/Unauthorized"}, + 422: {"$ref": "#/components/responses/InvalidRequest"}, + }, +) + +RESOLVE_SCOPE_BINDING = Operation[ResolveScopeBindingRequest, ScopeDescriptor]( + method="POST", + path="/v1/scope-bindings/resolve", + operation_id="resolve_scope_binding", + request_type=ResolveScopeBindingRequest, + request_location="body", + response_type=ScopeDescriptor, + success_status=200, + summary="Resolve an explicit durable or default Scope binding", + tags=("scope-bindings",), + responses={ + 200: {"description": "The resolved Scope descriptor."}, + 404: {"$ref": "#/components/responses/NotFound"}, + 401: {"$ref": "#/components/responses/Unauthorized"}, + 422: {"$ref": "#/components/responses/InvalidRequest"}, + }, +) + +SET_SCOPE_BINDING = Operation[SetScopeBindingRequest, ScopeBinding]( + method="PUT", + path="/v1/scope-bindings", + operation_id="set_scope_binding", + request_type=SetScopeBindingRequest, + request_location="body", + response_type=ScopeBinding, + success_status=200, + summary="Persist an external identity to Scope binding", + tags=("scope-bindings",), + responses={ + 200: {"description": "The durable external binding."}, + 404: {"$ref": "#/components/responses/NotFound"}, + 401: {"$ref": "#/components/responses/Unauthorized"}, + 422: {"$ref": "#/components/responses/InvalidRequest"}, + }, +) + +CLEAR_SCOPE_BINDING = Operation[ClearScopeBindingRequest, ClearScopeBindingResponse]( + method="POST", + path="/v1/scope-bindings/clear", + operation_id="clear_scope_binding", + request_type=ClearScopeBindingRequest, + request_location="body", + response_type=ClearScopeBindingResponse, + success_status=200, + summary="Remove one durable external Scope binding", + tags=("scope-bindings",), + responses={ + 200: {"description": "Whether a durable binding was removed."}, + 401: {"$ref": "#/components/responses/Unauthorized"}, + 422: {"$ref": "#/components/responses/InvalidRequest"}, + }, +) + CAPTURE_CONTENT_SOURCE = Operation[CaptureContentSourceRequest, CaptureContentSourceResponse]( method="POST", path="/v1/sources/content", diff --git a/src/powercontext/http/_generated/schema.py b/src/powercontext/http/_generated/schema.py index 6be425400..c8daf648f 100644 --- a/src/powercontext/http/_generated/schema.py +++ b/src/powercontext/http/_generated/schema.py @@ -60,6 +60,203 @@ }, } }, + "/v1/scopes": { + "get": { + "tags": ["scopes"], + "summary": "List observable Scopes", + "operationId": "list_scopes", + "responses": { + "200": { + "description": "Durable Scope metadata in deterministic identity order.", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ScopePage"}}}, + }, + "401": {"$ref": "#/components/responses/Unauthorized"}, + "503": {"$ref": "#/components/responses/Unavailable"}, + }, + }, + "post": { + "tags": ["scopes"], + "summary": "Create an independent Scope boundary", + "operationId": "create_scope", + "requestBody": { + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/CreateScopeRequest"}}}, + "required": True, + }, + "responses": { + "201": { + "description": "The durable Scope descriptor.", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ScopeDescriptor"}}}, + }, + "404": {"$ref": "#/components/responses/NotFound"}, + "409": {"$ref": "#/components/responses/Conflict"}, + "401": {"$ref": "#/components/responses/Unauthorized"}, + "422": {"$ref": "#/components/responses/InvalidRequest"}, + }, + }, + }, + "/v1/scopes/get": { + "post": { + "tags": ["scopes"], + "summary": "Get one Scope descriptor", + "operationId": "get_scope", + "requestBody": { + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/GetScopeRequest"}}}, + "required": True, + }, + "responses": { + "200": { + "description": "The exact Scope descriptor.", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ScopeDescriptor"}}}, + }, + "404": {"$ref": "#/components/responses/NotFound"}, + "401": {"$ref": "#/components/responses/Unauthorized"}, + }, + } + }, + "/v1/scopes/update": { + "post": { + "tags": ["scopes"], + "summary": "Replace mutable Scope metadata and relationships", + "operationId": "update_scope", + "requestBody": { + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/UpdateScopeRequest"}}}, + "required": True, + }, + "responses": { + "200": { + "description": "The updated Scope descriptor.", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ScopeDescriptor"}}}, + }, + "404": {"$ref": "#/components/responses/NotFound"}, + "409": {"$ref": "#/components/responses/Conflict"}, + "401": {"$ref": "#/components/responses/Unauthorized"}, + "422": {"$ref": "#/components/responses/InvalidRequest"}, + }, + } + }, + "/v1/scopes/default": { + "get": { + "tags": ["scopes"], + "summary": "Get the default Scope binding target", + "operationId": "get_default_scope", + "responses": { + "200": { + "description": "The ordinary Scope selected by the host default pointer.", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ScopeDescriptor"}}}, + }, + "404": {"$ref": "#/components/responses/NotFound"}, + "401": {"$ref": "#/components/responses/Unauthorized"}, + }, + }, + "put": { + "tags": ["scopes"], + "summary": "Change the default Scope binding target", + "operationId": "set_default_scope", + "requestBody": { + "content": { + "application/json": {"schema": {"$ref": "#/components/schemas/SetDefaultScopeRequest"}} + }, + "required": True, + }, + "responses": { + "200": { + "description": "The selected ordinary Scope.", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ScopeDescriptor"}}}, + }, + "404": {"$ref": "#/components/responses/NotFound"}, + "401": {"$ref": "#/components/responses/Unauthorized"}, + }, + }, + }, + "/v1/scopes/selection/resolve": { + "post": { + "tags": ["scopes"], + "summary": "Resolve an observation selection to a frozen Scope set", + "operationId": "resolve_scope_selection", + "requestBody": { + "content": { + "application/json": {"schema": {"$ref": "#/components/schemas/ResolveScopeSelectionRequest"}} + }, + "required": True, + }, + "responses": { + "200": { + "description": "The selected Scope descriptors in deterministic order.", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ScopePage"}}}, + }, + "404": {"$ref": "#/components/responses/NotFound"}, + "401": {"$ref": "#/components/responses/Unauthorized"}, + "422": {"$ref": "#/components/responses/InvalidRequest"}, + }, + } + }, + "/v1/scope-bindings/resolve": { + "post": { + "tags": ["scope-bindings"], + "summary": "Resolve an explicit durable or default Scope binding", + "operationId": "resolve_scope_binding", + "requestBody": { + "content": { + "application/json": {"schema": {"$ref": "#/components/schemas/ResolveScopeBindingRequest"}} + }, + "required": True, + }, + "responses": { + "200": { + "description": "The resolved Scope descriptor.", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ScopeDescriptor"}}}, + }, + "404": {"$ref": "#/components/responses/NotFound"}, + "401": {"$ref": "#/components/responses/Unauthorized"}, + "422": {"$ref": "#/components/responses/InvalidRequest"}, + }, + } + }, + "/v1/scope-bindings": { + "put": { + "tags": ["scope-bindings"], + "summary": "Persist an external identity to Scope binding", + "operationId": "set_scope_binding", + "requestBody": { + "content": { + "application/json": {"schema": {"$ref": "#/components/schemas/SetScopeBindingRequest"}} + }, + "required": True, + }, + "responses": { + "200": { + "description": "The durable external binding.", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ScopeBinding"}}}, + }, + "404": {"$ref": "#/components/responses/NotFound"}, + "401": {"$ref": "#/components/responses/Unauthorized"}, + "422": {"$ref": "#/components/responses/InvalidRequest"}, + }, + } + }, + "/v1/scope-bindings/clear": { + "post": { + "tags": ["scope-bindings"], + "summary": "Remove one durable external Scope binding", + "operationId": "clear_scope_binding", + "requestBody": { + "content": { + "application/json": {"schema": {"$ref": "#/components/schemas/ClearScopeBindingRequest"}} + }, + "required": True, + }, + "responses": { + "200": { + "description": "Whether a durable binding was removed.", + "content": { + "application/json": {"schema": {"$ref": "#/components/schemas/ClearScopeBindingResponse"}} + }, + }, + "401": {"$ref": "#/components/responses/Unauthorized"}, + "422": {"$ref": "#/components/responses/InvalidRequest"}, + }, + } + }, "/v1/sources/content": { "post": { "tags": ["sources"], @@ -1535,6 +1732,193 @@ "type": "object", "required": ["family", "artifact_id", "revision"], }, + "ScopeExternalReference": { + "properties": { + "kind": {"type": "string", "maxLength": 128, "minLength": 1, "pattern": ".*\\S.*"}, + "value": {"type": "string", "maxLength": 2000, "minLength": 1, "pattern": ".*\\S.*"}, + }, + "additionalProperties": False, + "type": "object", + "required": ["kind", "value"], + }, + "ScopeDescriptor": { + "properties": { + "scope_id": {"type": "string", "maxLength": 256, "minLength": 1, "pattern": ".*\\S.*"}, + "title": {"type": "string", "maxLength": 256, "minLength": 1, "pattern": ".*\\S.*"}, + "summary": {"type": "string", "maxLength": 2000, "minLength": 1, "pattern": ".*\\S.*"}, + "parent_scope_id": { + "type": "string", + "maxLength": 256, + "minLength": 1, + "pattern": ".*\\S.*", + "nullable": True, + }, + "context_references": { + "items": {"type": "string", "maxLength": 256, "minLength": 1, "pattern": ".*\\S.*"}, + "type": "array", + "uniqueItems": True, + }, + "external_references": { + "items": {"$ref": "#/components/schemas/ScopeExternalReference"}, + "type": "array", + "uniqueItems": True, + }, + "version": {"type": "integer", "minimum": 1.0}, + }, + "additionalProperties": False, + "type": "object", + "required": ["scope_id", "title", "summary", "context_references", "external_references", "version"], + }, + "ScopePage": { + "properties": {"items": {"items": {"$ref": "#/components/schemas/ScopeDescriptor"}, "type": "array"}}, + "additionalProperties": False, + "type": "object", + "required": ["items"], + }, + "CreateScopeRequest": { + "properties": { + "title": {"type": "string", "maxLength": 256, "minLength": 1, "pattern": ".*\\S.*"}, + "summary": {"type": "string", "maxLength": 2000, "minLength": 1, "pattern": ".*\\S.*"}, + "parent_scope_id": { + "type": "string", + "maxLength": 256, + "minLength": 1, + "pattern": ".*\\S.*", + "nullable": True, + }, + "context_references": { + "items": {"type": "string", "maxLength": 256, "minLength": 1, "pattern": ".*\\S.*"}, + "type": "array", + "uniqueItems": True, + "default": [], + }, + "external_references": { + "items": {"$ref": "#/components/schemas/ScopeExternalReference"}, + "type": "array", + "uniqueItems": True, + "default": [], + }, + "idempotency_key": {"type": "string", "maxLength": 256, "minLength": 1, "pattern": ".*\\S.*"}, + }, + "additionalProperties": False, + "type": "object", + "required": ["title", "summary", "idempotency_key"], + }, + "GetScopeRequest": { + "properties": {"scope_id": {"type": "string", "maxLength": 256, "minLength": 1, "pattern": ".*\\S.*"}}, + "additionalProperties": False, + "type": "object", + "required": ["scope_id"], + }, + "UpdateScopeRequest": { + "properties": { + "scope_id": {"type": "string", "maxLength": 256, "minLength": 1, "pattern": ".*\\S.*"}, + "expected_version": {"type": "integer", "minimum": 1.0}, + "title": {"type": "string", "maxLength": 256, "minLength": 1, "pattern": ".*\\S.*"}, + "summary": {"type": "string", "maxLength": 2000, "minLength": 1, "pattern": ".*\\S.*"}, + "parent_scope_id": { + "type": "string", + "maxLength": 256, + "minLength": 1, + "pattern": ".*\\S.*", + "nullable": True, + }, + "context_references": { + "items": {"type": "string", "maxLength": 256, "minLength": 1, "pattern": ".*\\S.*"}, + "type": "array", + "uniqueItems": True, + "default": [], + }, + "external_references": { + "items": {"$ref": "#/components/schemas/ScopeExternalReference"}, + "type": "array", + "uniqueItems": True, + "default": [], + }, + }, + "additionalProperties": False, + "type": "object", + "required": ["scope_id", "expected_version", "title", "summary"], + }, + "SetDefaultScopeRequest": {"$ref": "#/components/schemas/GetScopeRequest"}, + "ScopeSelectionMode": {"type": "string", "enum": ["all", "exact", "subtree"]}, + "ScopeSelection": { + "properties": { + "mode": {"$ref": "#/components/schemas/ScopeSelectionMode"}, + "scope_ids": { + "items": {"type": "string", "maxLength": 256, "minLength": 1, "pattern": ".*\\S.*"}, + "type": "array", + "uniqueItems": True, + "default": [], + }, + "root_scope_id": { + "type": "string", + "maxLength": 256, + "minLength": 1, + "pattern": ".*\\S.*", + "nullable": True, + }, + }, + "additionalProperties": False, + "type": "object", + "required": ["mode"], + }, + "ResolveScopeSelectionRequest": { + "properties": {"selection": {"$ref": "#/components/schemas/ScopeSelection"}}, + "additionalProperties": False, + "type": "object", + "required": ["selection"], + }, + "ScopeBindingKey": { + "properties": { + "integration": {"type": "string", "maxLength": 128, "minLength": 1, "pattern": ".*\\S.*"}, + "kind": {"type": "string", "maxLength": 64, "minLength": 1, "pattern": ".*\\S.*"}, + "external_id": {"type": "string", "maxLength": 256, "minLength": 1, "pattern": ".*\\S.*"}, + }, + "additionalProperties": False, + "type": "object", + "required": ["integration", "kind", "external_id"], + }, + "ScopeBinding": { + "properties": { + "key": {"$ref": "#/components/schemas/ScopeBindingKey"}, + "scope_id": {"type": "string", "maxLength": 256, "minLength": 1, "pattern": ".*\\S.*"}, + }, + "additionalProperties": False, + "type": "object", + "required": ["key", "scope_id"], + }, + "SetScopeBindingRequest": {"$ref": "#/components/schemas/ScopeBinding"}, + "ClearScopeBindingRequest": { + "properties": {"key": {"$ref": "#/components/schemas/ScopeBindingKey"}}, + "additionalProperties": False, + "type": "object", + "required": ["key"], + }, + "ClearScopeBindingResponse": { + "properties": {"cleared": {"type": "boolean"}}, + "additionalProperties": False, + "type": "object", + "required": ["cleared"], + }, + "ResolveScopeBindingRequest": { + "properties": { + "explicit_scope_id": { + "type": "string", + "maxLength": 256, + "minLength": 1, + "pattern": ".*\\S.*", + "nullable": True, + }, + "binding_keys": { + "items": {"$ref": "#/components/schemas/ScopeBindingKey"}, + "type": "array", + "default": [], + }, + }, + "additionalProperties": False, + "type": "object", + }, "ArtifactCandidate": { "properties": { "candidate_id": {"type": "string", "maxLength": 128, "minLength": 1, "pattern": "^[\\x21-\\x7E]+$"}, diff --git a/src/powercontext/server/app.py b/src/powercontext/server/app.py index 8cfd96edd..d552dd628 100644 --- a/src/powercontext/server/app.py +++ b/src/powercontext/server/app.py @@ -32,6 +32,7 @@ from fastapi.exceptions import RequestValidationError from fastapi.responses import JSONResponse from opentelemetry.trace import SpanKind +from pydantic import ValidationError as PydanticValidationError from starlette.middleware import Middleware from starlette.middleware.base import RequestResponseEndpoint from starlette.types import Lifespan @@ -192,6 +193,28 @@ from powercontext.builtin.runtime import ( StatisticsPeriod as RuntimeStatisticsPeriod, ) +from powercontext.builtin.scope import ( + ScopeApplication, + ScopeBindingNotFoundError, + ScopeDraft, + ScopeIdempotencyConflictError, + ScopeMutation, + ScopeNotFoundError, + ScopeRelationshipError, + ScopeVersionConflictError, +) +from powercontext.builtin.scope import ( + ScopeBindingKey as DomainScopeBindingKey, +) +from powercontext.builtin.scope import ( + ScopeDescriptor as DomainScopeDescriptor, +) +from powercontext.builtin.scope import ( + ScopeExternalReference as DomainScopeExternalReference, +) +from powercontext.builtin.scope import ( + ScopeSelection as DomainScopeSelection, +) from powercontext.builtin.work import ( AcknowledgeHandoff as RuntimeAcknowledgeHandoff, ) @@ -223,10 +246,13 @@ Capabilities, CaptureContentSourceRequest, CaptureContentSourceResponse, + ClearScopeBindingRequest, + ClearScopeBindingResponse, CommitHandoffRequest, CommittedHandoff, ContinueHandoffRequest, CreateHandoffReportProjectRequest, + CreateScopeRequest, CreateWorkContractRequest, DetachHandoffReportWorkspaceRequest, ErrorDetail, @@ -245,6 +271,7 @@ GetHandoffReportRequest, GetHandoffReportWorkspaceRequest, GetMemoryEntryRequest, + GetScopeRequest, GetSkillRequest, GetStatsRequest, HandoffAcknowledgement, @@ -289,18 +316,27 @@ RejectArtifactCandidateRequest, RememberMemoryRequest, ResolveExternalSkillRequest, + ResolveScopeBindingRequest, + ResolveScopeSelectionRequest, RetireMemoryEntryRequest, ReviseArtifactCandidateRequest, ReviseMemoryEntryRequest, ScanExternalSkillsRequest, ScanExternalSkillsResponse, + ScopeBinding, + ScopeBindingKey, + ScopeDescriptor, ScopedStats, + ScopePage, SearchMemoryRequest, SearchMemoryResponse, + SetDefaultScopeRequest, + SetScopeBindingRequest, SkillArtifact, StoredHandoffReportActivity, UpdateHandoffReportProjectRequest, UpdateHandoffReportWorkstreamRequest, + UpdateScopeRequest, WorkSourceReceipt, WorkstreamDescriptor, WorkstreamPage, @@ -326,9 +362,11 @@ APPROVE_ARTIFACT_CANDIDATE, ATTACH_HANDOFF_REPORT_WORKSPACE, CAPTURE_CONTENT_SOURCE, + CLEAR_SCOPE_BINDING, COMMIT_HANDOFF, CONTINUE_HANDOFF, CREATE_HANDOFF_REPORT_PROJECT, + CREATE_SCOPE, CREATE_WORK_CONTRACT, DETACH_HANDOFF_REPORT_WORKSPACE, FINALIZE_HANDOFF, @@ -337,6 +375,7 @@ GENERATE_SKILL, GET_ARTIFACT_CANDIDATE, GET_CAPABILITIES, + GET_DEFAULT_SCOPE, GET_EXPERIENCE, GET_HANDOFF_REPORT, GET_HANDOFF_REPORT_PROJECT, @@ -344,6 +383,7 @@ GET_LIVENESS, GET_MEMORY_ENTRY, GET_READINESS, + GET_SCOPE, GET_SKILL, GET_STATS, HANDOFF_CURRENT_WORK, @@ -356,6 +396,7 @@ LIST_HANDOFF_REPORT_WORKSTREAMS, LIST_MEMORY_CHANGES, LIST_MEMORY_ENTRIES, + LIST_SCOPES, OPENAPI_VERSION, PREPARE_CONTEXT, PREPARE_HANDOFF, @@ -368,13 +409,18 @@ REJECT_ARTIFACT_CANDIDATE, REMEMBER_MEMORY, RESOLVE_EXTERNAL_SKILL, + RESOLVE_SCOPE_BINDING, + RESOLVE_SCOPE_SELECTION, RETIRE_MEMORY_ENTRY, REVISE_ARTIFACT_CANDIDATE, REVISE_MEMORY_ENTRY, SCAN_EXTERNAL_SKILLS, SEARCH_MEMORY, + SET_DEFAULT_SCOPE, + SET_SCOPE_BINDING, UPDATE_HANDOFF_REPORT_PROJECT, UPDATE_HANDOFF_REPORT_WORKSTREAM, + UPDATE_SCOPE, Operation, ) from powercontext.http._generated.schema import OPENAPI_SCHEMA @@ -537,6 +583,7 @@ def for_scope(self, scope_id: str, /) -> _ScopedStatisticsApplication: ... class ServerApplication(Protocol): + scopes: ScopeApplication | None sources: _SourceApplication context: _ContextApplication experience: _ExperienceApplication @@ -606,7 +653,11 @@ async def attach_request_id(request: Request, call_next: RequestResponseEndpoint return response @app.exception_handler(RequestValidationError) - async def invalid_request(request: Request, error: RequestValidationError) -> JSONResponse: + @app.exception_handler(PydanticValidationError) + async def invalid_request( + request: Request, + error: RequestValidationError | PydanticValidationError, + ) -> JSONResponse: return _error_response( status.HTTP_422_UNPROCESSABLE_CONTENT, code="invalid_request", @@ -635,6 +686,16 @@ async def unexpected_error(request: Request, error: Exception) -> JSONResponse: _add_route(app, GET_LIVENESS, get_liveness) _add_route(app, GET_READINESS, get_readiness) _add_route(app, GET_CAPABILITIES, get_capabilities) + _add_route(app, LIST_SCOPES, list_scopes) + _add_route(app, CREATE_SCOPE, create_scope) + _add_route(app, GET_SCOPE, get_scope) + _add_route(app, UPDATE_SCOPE, update_scope) + _add_route(app, GET_DEFAULT_SCOPE, get_default_scope) + _add_route(app, SET_DEFAULT_SCOPE, set_default_scope) + _add_route(app, RESOLVE_SCOPE_SELECTION, resolve_scope_selection) + _add_route(app, RESOLVE_SCOPE_BINDING, resolve_scope_binding) + _add_route(app, SET_SCOPE_BINDING, set_scope_binding) + _add_route(app, CLEAR_SCOPE_BINDING, clear_scope_binding) _add_route(app, GET_STATS, get_stats) if handoff_report_enabled: _add_route(app, CREATE_HANDOFF_REPORT_PROJECT, create_handoff_report_project) @@ -723,6 +784,117 @@ async def get_capabilities(request: Request) -> Capabilities: return request.app.state.capabilities +async def list_scopes( + scopes: Annotated[ScopeApplication, Depends(_require_scope_application)], +) -> ScopePage: + return ScopePage(items=[_scope_descriptor_response(scope) for scope in await scopes.list()]) + + +async def create_scope( + request: CreateScopeRequest, + scopes: Annotated[ScopeApplication, Depends(_require_scope_application)], +) -> ScopeDescriptor: + created = await scopes.create( + ScopeDraft( + title=request.title, + summary=request.summary, + parent_scope_id=request.parent_scope_id, + context_references=tuple(reference.root for reference in request.context_references), + external_references=tuple( + DomainScopeExternalReference(kind=reference.kind, value=reference.value) + for reference in request.external_references + ), + idempotency_key=request.idempotency_key, + ) + ) + return _scope_descriptor_response(created) + + +async def get_scope( + request: GetScopeRequest, + scopes: Annotated[ScopeApplication, Depends(_require_scope_application)], +) -> ScopeDescriptor: + return _scope_descriptor_response(await scopes.get(request.scope_id)) + + +async def update_scope( + request: UpdateScopeRequest, + scopes: Annotated[ScopeApplication, Depends(_require_scope_application)], +) -> ScopeDescriptor: + updated = await scopes.update( + request.scope_id, + ScopeMutation( + expected_version=request.expected_version, + title=request.title, + summary=request.summary, + parent_scope_id=request.parent_scope_id, + context_references=tuple(reference.root for reference in request.context_references), + external_references=tuple( + DomainScopeExternalReference(kind=reference.kind, value=reference.value) + for reference in request.external_references + ), + ), + ) + return _scope_descriptor_response(updated) + + +async def get_default_scope( + scopes: Annotated[ScopeApplication, Depends(_require_scope_application)], +) -> ScopeDescriptor: + current = await scopes.default_scope() + if current is None: + raise ScopeBindingNotFoundError + return _scope_descriptor_response(current) + + +async def set_default_scope( + request: SetDefaultScopeRequest, + scopes: Annotated[ScopeApplication, Depends(_require_scope_application)], +) -> ScopeDescriptor: + return _scope_descriptor_response(await scopes.set_default(request.root.scope_id)) + + +async def resolve_scope_selection( + request: ResolveScopeSelectionRequest, + scopes: Annotated[ScopeApplication, Depends(_require_scope_application)], +) -> ScopePage: + selection = DomainScopeSelection( + mode=request.selection.mode.value, + scope_ids=tuple(scope_id.root for scope_id in request.selection.scope_ids), + root_scope_id=request.selection.root_scope_id, + ) + return ScopePage(items=[_scope_descriptor_response(scope) for scope in await scopes.resolve_selection(selection)]) + + +async def resolve_scope_binding( + request: ResolveScopeBindingRequest, + scopes: Annotated[ScopeApplication, Depends(_require_scope_application)], +) -> ScopeDescriptor: + resolved = await scopes.resolve_binding( + explicit_scope_id=request.explicit_scope_id, + binding_keys=tuple(_domain_binding_key(key) for key in request.binding_keys), + ) + return _scope_descriptor_response(resolved) + + +async def set_scope_binding( + request: SetScopeBindingRequest, + scopes: Annotated[ScopeApplication, Depends(_require_scope_application)], +) -> ScopeBinding: + binding = await scopes.bind(_domain_binding_key(request.root.key), request.root.scope_id) + return ScopeBinding( + key=_transport_binding_key(binding.key), + scope_id=binding.scope_id, + ) + + +async def clear_scope_binding( + request: ClearScopeBindingRequest, + scopes: Annotated[ScopeApplication, Depends(_require_scope_application)], +) -> ClearScopeBindingResponse: + return ClearScopeBindingResponse(cleared=await scopes.clear_binding(_domain_binding_key(request.key))) + + async def get_stats( request: Annotated[GetStatsRequest, Query()], response: Response, @@ -1327,6 +1499,13 @@ def _require_application(request: Request) -> ServerApplication: return application +def _require_scope_application(request: Request) -> ScopeApplication: + application = _require_application(request) + if application.scopes is None: + raise _RuntimeNotReadyError + return application.scopes + + def _require_handoff_report_application(request: Request) -> HandoffReportApplication: application = _require_application(request) if application.handoff_report is None: @@ -1342,6 +1521,26 @@ def _workstream_descriptor_response(value: DomainWorkstreamDescriptor) -> Workst return WorkstreamDescriptor.model_validate(value.model_dump(mode="json", by_alias=True)) +def _scope_descriptor_response(value: DomainScopeDescriptor) -> ScopeDescriptor: + return ScopeDescriptor.model_validate(value.model_dump(mode="json")) + + +def _domain_binding_key(value: ScopeBindingKey) -> DomainScopeBindingKey: + return DomainScopeBindingKey( + integration=value.integration, + kind=value.kind, + external_id=value.external_id, + ) + + +def _transport_binding_key(value: DomainScopeBindingKey) -> ScopeBindingKey: + return ScopeBindingKey( + integration=value.integration, + kind=value.kind, + external_id=value.external_id, + ) + + def _add_route( app: FastAPI, operation: Operation[_RequestT, _ResponseT], @@ -1481,11 +1680,11 @@ def _error_response( return JSONResponse(status_code=response_status, content=error.model_dump(mode="json")) -def _validation_error_details(error: RequestValidationError) -> list[Any]: +def _validation_error_details(error: RequestValidationError | PydanticValidationError) -> list[Any]: details: list[Any] = [] for item in error.errors(): if isinstance(item, dict): - details.append({key: value for key, value in item.items() if key != "input"}) + details.append({key: value for key, value in item.items() if key not in {"ctx", "input", "url"}}) else: details.append(item) return details @@ -1517,6 +1716,9 @@ def _map_error(error: Exception) -> tuple[int, str, str, dict[str, Any] | None]: "Artifact generation is not configured.", {"family": error.family}, ) + scope_error = _map_scope_error(error) + if scope_error is not None: + return scope_error candidate_error = _map_candidate_error(error) if candidate_error is not None: return candidate_error @@ -1529,6 +1731,33 @@ def _map_error(error: Exception) -> tuple[int, str, str, dict[str, Any] | None]: return _map_domain_error(error) +def _map_scope_error(error: Exception) -> tuple[int, str, str, dict[str, Any] | None] | None: + if isinstance(error, (ScopeNotFoundError, ScopeBindingNotFoundError)): + return status.HTTP_404_NOT_FOUND, "scope_not_found", "The requested Scope was not found.", None + if isinstance(error, ScopeVersionConflictError): + return ( + status.HTTP_409_CONFLICT, + "scope_version_conflict", + "The Scope metadata version is stale.", + {"expected_version": error.expected, "current_version": error.actual}, + ) + if isinstance(error, ScopeIdempotencyConflictError): + return ( + status.HTTP_409_CONFLICT, + "scope_idempotency_conflict", + "The Scope creation key identifies different parameters.", + None, + ) + if isinstance(error, ScopeRelationshipError): + return ( + status.HTTP_422_UNPROCESSABLE_CONTENT, + "invalid_scope_relationship", + "The Scope relationship is invalid.", + {"relationship": error.relationship, "issue": error.issue}, + ) + return None + + def _map_candidate_error(error: Exception) -> tuple[int, str, str, dict[str, Any] | None] | None: if isinstance(error, CandidateNotFoundError): return status.HTTP_404_NOT_FOUND, "candidate_not_found", "The requested Candidate was not found.", None diff --git a/tests/test_scope_api.py b/tests/test_scope_api.py new file mode 100644 index 000000000..be69e1368 --- /dev/null +++ b/tests/test_scope_api.py @@ -0,0 +1,108 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 + +from __future__ import annotations + +from fastapi.testclient import TestClient + +from powercontext.builtin.persistence.sqlite import SQLiteConfig +from powercontext.server.factory import create_server_app +from powercontext.server.settings import McpConfig, ServerSettings + + +def test_scope_http_flow_resolves_default_durable_and_observation_ranges(tmp_path) -> None: + app = create_server_app( + settings=ServerSettings( + database=SQLiteConfig(url=f"sqlite+aiosqlite:///{tmp_path / 'runtime.db'}"), + mcp=McpConfig(enabled=False), + ) + ) + + with TestClient(app) as client: + default = client.get("/v1/scopes/default") + assert default.status_code == 200 + default_scope_id = default.json()["scope_id"] + + root = client.post( + "/v1/scopes", + json={"title": "Feature", "summary": "Feature result", "idempotency_key": "feature"}, + ) + assert root.status_code == 201 + root_scope_id = root.json()["scope_id"] + child = client.post( + "/v1/scopes", + json={ + "title": "Validation", + "summary": "Independent validation", + "parent_scope_id": root_scope_id, + "idempotency_key": "validation", + }, + ) + assert child.status_code == 201 + child_scope_id = child.json()["scope_id"] + + binding_key = {"integration": "codex", "kind": "session", "external_id": "session-1"} + assert ( + client.put( + "/v1/scope-bindings", + json={"key": binding_key, "scope_id": child_scope_id}, + ).status_code + == 200 + ) + resolved = client.post( + "/v1/scope-bindings/resolve", + json={"binding_keys": [binding_key]}, + ) + assert resolved.status_code == 200 + assert resolved.json()["scope_id"] == child_scope_id + + subtree = client.post( + "/v1/scopes/selection/resolve", + json={"selection": {"mode": "subtree", "root_scope_id": root_scope_id}}, + ) + assert [scope["scope_id"] for scope in subtree.json()["items"]] == [root_scope_id, child_scope_id] + all_scopes = client.post( + "/v1/scopes/selection/resolve", + json={"selection": {"mode": "all"}}, + ) + assert {scope["scope_id"] for scope in all_scopes.json()["items"]} == { + default_scope_id, + root_scope_id, + child_scope_id, + } + + +def test_scope_http_flow_rejects_stale_metadata_and_invalid_selection(tmp_path) -> None: + app = create_server_app( + settings=ServerSettings( + database=SQLiteConfig(url=f"sqlite+aiosqlite:///{tmp_path / 'runtime.db'}"), + mcp=McpConfig(enabled=False), + ) + ) + + with TestClient(app) as client: + created = client.post( + "/v1/scopes", + json={"title": "Work", "summary": "Initial", "idempotency_key": "work"}, + ).json() + request = { + "scope_id": created["scope_id"], + "expected_version": created["version"], + "title": "Work", + "summary": "Updated", + } + assert client.post("/v1/scopes/update", json=request).status_code == 200 + stale = client.post("/v1/scopes/update", json=request) + assert stale.status_code == 409 + assert stale.json()["error"]["code"] == "scope_version_conflict" + + invalid = client.post( + "/v1/scopes/selection/resolve", + json={"selection": {"mode": "exact", "scope_ids": []}}, + ) + assert invalid.status_code == 422 From 0a072272042208d1f1ad67eb4dcee963601ce7ea Mon Sep 17 00:00:00 2001 From: Chojan Shang Date: Thu, 27 Aug 2026 15:35:52 +0800 Subject: [PATCH 03/15] feat(codex): bind sessions to scopes --- .../codex/plugins/powercontext/README.md | 16 +- .../plugins/powercontext/hooks/bind_tools.py | 121 ++++++++++ .../plugins/powercontext/hooks/hooks.json | 24 ++ .../plugins/powercontext/hooks/recall.py | 10 +- .../powercontext/hooks/session_binding.py | 48 ++++ .../powercontext/scripts/project_scope.py | 224 ------------------ .../powercontext/scripts/scope_binding.py | 199 ++++++++++++++++ .../skills/project-context/SKILL.md | 61 ++--- src/powercontext/server/mcp.py | 12 + tests/codex_plugin/conftest.py | 12 +- tests/codex_plugin/test_contract.py | 103 ++++---- tests/codex_plugin/test_recall.py | 12 +- tests/codex_plugin/test_scope_binding.py | 137 +++++++++++ tests/test_mcp.py | 7 +- 14 files changed, 650 insertions(+), 336 deletions(-) create mode 100644 integrations/codex/plugins/powercontext/hooks/bind_tools.py create mode 100644 integrations/codex/plugins/powercontext/hooks/session_binding.py delete mode 100644 integrations/codex/plugins/powercontext/scripts/project_scope.py create mode 100644 integrations/codex/plugins/powercontext/scripts/scope_binding.py create mode 100644 tests/codex_plugin/test_scope_binding.py diff --git a/integrations/codex/plugins/powercontext/README.md b/integrations/codex/plugins/powercontext/README.md index 38899de80..e54579cc7 100644 --- a/integrations/codex/plugins/powercontext/README.md +++ b/integrations/codex/plugins/powercontext/README.md @@ -51,13 +51,15 @@ The hook runtime is declared by the plugin's `pyproject.toml` and launched with `uv`; this keeps its `pydantic-settings` dependency isolated and reproducible. The hook uses a small synchronous standard-library HTTP adapter because Codex executes it as a short-lived process. It does not expose that adapter as an SDK. -The `project-context` Skill reuses the installed hook virtual environment when -deriving project scope, so a read-only Codex turn does not need to mutate the -`uv` cache. - -Set `POWERCONTEXT_CODEX_SCOPE_ID` to override automatic project scoping. By -default, the scope comes from the normalized Git remote, or from the project -path when no supported remote is available. +`SessionStart` fixes a durable binding from an explicit plugin Scope, an +existing Session binding, a workspace binding preference, or the Server's +default Scope. `UserPromptSubmit` uses that binding for recall and capture. +`PreToolUse` injects the same binding into PowerContext data-plane tools, so an +Agent-supplied `scope_id` cannot redirect a write. Repository and directory +identities are binding lookup inputs only; they never generate a Scope ID. + +Set `POWERCONTEXT_CODEX_SCOPE_ID` only when the host must explicitly bind every +request to one known Scope. `.mcp.json` is the single Server endpoint configuration consumed by Codex and the hook: the hook validates its PowerContext MCP URL and derives the HTTP API base by removing the final `/mcp` path segment. Change that file before diff --git a/integrations/codex/plugins/powercontext/hooks/bind_tools.py b/integrations/codex/plugins/powercontext/hooks/bind_tools.py new file mode 100644 index 000000000..f71d85148 --- /dev/null +++ b/integrations/codex/plugins/powercontext/hooks/bind_tools.py @@ -0,0 +1,121 @@ +#!/usr/bin/env python3 +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 + +"""Bind PowerContext MCP data-plane calls to the current Codex Session.""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path +from time import monotonic +from typing import Any, cast + +_PLUGIN_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(_PLUGIN_ROOT)) + +from scripts.scope_binding import ScopeBindingError, resolve_scope_id, session_binding_key # noqa: E402 +from settings import CodexPluginSettings # noqa: E402 + +_PREFIX = "mcp__powercontext__" +_CONTROL_OPERATIONS = frozenset({"set_scope_binding", "clear_scope_binding"}) +_SCOPE_BOUND_OPERATIONS = frozenset({ + "acknowledge_handoff", + "activate_handoff", + "approve_artifact_candidate", + "capture_content_source", + "commit_handoff", + "continue_handoff", + "create_work_contract", + "finalize_handoff", + "get_artifact_candidate", + "get_memory_entry", + "handoff_current_work", + "list_artifact_candidates", + "list_memory_entries", + "record_task_outcome", + "reject_artifact_candidate", + "remember_memory", + "retire_memory_entry", + "revise_artifact_candidate", + "revise_memory_entry", + "search_memory", +}) + + +def main(settings: CodexPluginSettings | None = None) -> int: + try: + payload = cast(dict[str, Any], json.load(sys.stdin)) + tool_name = payload.get("tool_name") + tool_input = payload.get("tool_input") + session_id = payload.get("session_id") + cwd = payload.get("cwd") + if ( + not isinstance(tool_name, str) + or not tool_name.startswith(_PREFIX) + or not isinstance(tool_input, dict) + or not isinstance(session_id, str) + or not isinstance(cwd, str) + ): + return 0 + operation = tool_name.removeprefix(_PREFIX) + if operation in _CONTROL_OPERATIONS: + updated = dict(tool_input) + updated["key"] = session_binding_key(session_id) + _allow(updated) + return 0 + if operation not in _SCOPE_BOUND_OPERATIONS: + return 0 + settings = CodexPluginSettings() if settings is None else settings + scope_id = resolve_scope_id( + cwd, + session_id=session_id, + settings=settings, + deadline=monotonic() + settings.http_budget_seconds, + ) + updated = dict(tool_input) + updated["scope_id"] = scope_id + _allow(updated) + except (ScopeBindingError, ValueError, OSError, json.JSONDecodeError): + _deny() + return 0 + + +def _allow(updated_input: dict[str, object]) -> None: + json.dump( + { + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": "allow", + "updatedInput": updated_input, + } + }, + sys.stdout, + separators=(",", ":"), + ) + sys.stdout.write("\n") + + +def _deny() -> None: + json.dump( + { + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": "deny", + "permissionDecisionReason": "PowerContext could not resolve the current Scope binding.", + } + }, + sys.stdout, + separators=(",", ":"), + ) + sys.stdout.write("\n") + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/integrations/codex/plugins/powercontext/hooks/hooks.json b/integrations/codex/plugins/powercontext/hooks/hooks.json index fba24ad40..bd55336dc 100644 --- a/integrations/codex/plugins/powercontext/hooks/hooks.json +++ b/integrations/codex/plugins/powercontext/hooks/hooks.json @@ -1,6 +1,30 @@ { "description": "Recall relevant memory and capture the current Codex prompt as a Source.", "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "uv run --frozen --quiet --project \"${PLUGIN_ROOT}\" python \"${PLUGIN_ROOT}/hooks/session_binding.py\"", + "timeout": 10, + "statusMessage": "Binding PowerContext" + } + ] + } + ], + "PreToolUse": [ + { + "matcher": "mcp__powercontext__.*", + "hooks": [ + { + "type": "command", + "command": "uv run --frozen --quiet --project \"${PLUGIN_ROOT}\" python \"${PLUGIN_ROOT}/hooks/bind_tools.py\"", + "timeout": 10 + } + ] + } + ], "UserPromptSubmit": [ { "hooks": [ diff --git a/integrations/codex/plugins/powercontext/hooks/recall.py b/integrations/codex/plugins/powercontext/hooks/recall.py index c56ac9398..57e499cad 100644 --- a/integrations/codex/plugins/powercontext/hooks/recall.py +++ b/integrations/codex/plugins/powercontext/hooks/recall.py @@ -36,7 +36,7 @@ sys.path.insert(0, str(_PLUGIN_ROOT)) from hooks import prepared_context as _prepared_context # noqa: E402 -from scripts.project_scope import resolve_scope_id # noqa: E402 +from scripts.scope_binding import resolve_scope_id # noqa: E402 from settings import CodexPluginSettings # noqa: E402 _MAX_CONTEXT_BYTES = _prepared_context.MAX_CONTEXT_BYTES @@ -110,7 +110,13 @@ def main(settings: CodexPluginSettings | None = None) -> int: if not isinstance(prompt, str) or not prompt.strip() or not isinstance(cwd, str): _emit_context_event("skipped") return 0 - scope_id = resolve_scope_id(cwd, configured_scope_id=settings.scope_id) + session_id = _payload_identifier(payload, "session_id", "conversation_id", "thread_id") + scope_id = resolve_scope_id( + cwd, + session_id=session_id, + settings=settings, + deadline=http_deadline, + ) context = _recall_context(prompt, scope_id, settings=settings, deadline=http_deadline) if settings.capture_prompts and len(prompt) <= _MAX_SOURCE_LENGTH: with suppress(Exception): diff --git a/integrations/codex/plugins/powercontext/hooks/session_binding.py b/integrations/codex/plugins/powercontext/hooks/session_binding.py new file mode 100644 index 000000000..22f3e7c33 --- /dev/null +++ b/integrations/codex/plugins/powercontext/hooks/session_binding.py @@ -0,0 +1,48 @@ +#!/usr/bin/env python3 +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 + +"""Fix one Codex Session binding without blocking Session startup.""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path +from time import monotonic +from typing import Any, cast + +_PLUGIN_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(_PLUGIN_ROOT)) + +from scripts.scope_binding import ScopeBindingError, resolve_scope_id # noqa: E402 +from settings import CodexPluginSettings # noqa: E402 + + +def main(settings: CodexPluginSettings | None = None) -> int: + try: + payload = cast(dict[str, Any], json.load(sys.stdin)) + session_id = payload.get("session_id") + cwd = payload.get("cwd") + if not isinstance(session_id, str) or not isinstance(cwd, str): + return 0 + settings = CodexPluginSettings() if settings is None else settings + resolve_scope_id( + cwd, + session_id=session_id, + settings=settings, + deadline=monotonic() + settings.http_budget_seconds, + persist_session=True, + ) + except (ScopeBindingError, ValueError, OSError, json.JSONDecodeError): + return 0 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/integrations/codex/plugins/powercontext/scripts/project_scope.py b/integrations/codex/plugins/powercontext/scripts/project_scope.py deleted file mode 100644 index 0de5e9e44..000000000 --- a/integrations/codex/plugins/powercontext/scripts/project_scope.py +++ /dev/null @@ -1,224 +0,0 @@ -#!/usr/bin/env python3 -# Copyright (c) 2026 OceanBase. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Derive a stable PowerContext scope for one project directory.""" - -from __future__ import annotations - -import argparse -import hashlib -import json -import os -import re -import subprocess -import sys -from collections.abc import Sequence -from contextlib import suppress -from pathlib import Path -from shutil import which -from urllib.parse import urlsplit - -_PLUGIN_ROOT = Path(__file__).resolve().parents[1] -sys.path.insert(0, str(_PLUGIN_ROOT)) - -from settings import CodexPluginSettings # noqa: E402 - -_MAX_SCOPE_LENGTH = 256 -_SCP_REMOTE = re.compile(r"^(?:[^@/\s]+@)?(?P[^:/\s]+):(?P.+)$") -_WORKSPACE_STATE_SCHEMA = "powercontext.codex-workspace.v1" -_WORKSPACE_STATE_DIRECTORY = "powercontext" -_WORKSPACE_STATE_FILE = "codex-workspace.json" - - -def resolve_scope_id(cwd: str, *, configured_scope_id: str | None = None) -> str: - """Return the configured, workspace-bound, or derived scope for one checkout.""" - - if configured_scope_id: - return _bounded_explicit(configured_scope_id) - bound_scope_id = read_bound_scope_id(cwd) - if bound_scope_id is not None: - return bound_scope_id - return derive_scope_id(cwd) - - -def derive_scope_id(cwd: str, *, configured_scope_id: str | None = None) -> str: - """Return an explicit, remote-derived, or path-derived project scope.""" - - if configured_scope_id: - return _bounded_explicit(configured_scope_id) - root_value = _git_value(cwd, "rev-parse", "--show-toplevel") - project_root = Path(root_value or cwd).resolve(strict=False) - remote = _git_value(str(project_root), "config", "--get", "remote.origin.url") - normalized_remote = normalize_git_remote(remote) if remote else None - if normalized_remote: - return _bounded("git", normalized_remote) - return f"local:{hashlib.sha256(os.fsencode(project_root)).hexdigest()}" - - -def bind_workstream_scope(cwd: str, scope_id: str, /) -> str: - """Persist one Workstream scope in Git-private state for later Codex sessions.""" - - normalized_scope_id = _bounded_explicit(scope_id.strip()) - if not normalized_scope_id: - raise ValueError("Workstream scope must be non-empty") # noqa: TRY003 - state_path = _workspace_state_path(cwd) - if state_path is None: - raise ValueError("Workstream scope binding requires a Git workspace") # noqa: TRY003 - _write_workspace_state(state_path, normalized_scope_id) - return normalized_scope_id - - -def read_bound_scope_id(cwd: str, /) -> str | None: - """Read a valid Workstream scope binding without trusting arbitrary state fields.""" - - state_path = _workspace_state_path(cwd) - if state_path is None: - return None - try: - payload = json.loads(state_path.read_text(encoding="utf-8")) - except (OSError, UnicodeError, json.JSONDecodeError): - return None - if not isinstance(payload, dict) or payload.get("schema") != _WORKSPACE_STATE_SCHEMA: - return None - scope_id = payload.get("scope_id") - if ( - not isinstance(scope_id, str) - or not scope_id - or scope_id != scope_id.strip() - or len(scope_id) > _MAX_SCOPE_LENGTH - ): - return None - return scope_id - - -def clear_workstream_scope(cwd: str, /) -> bool: - """Remove only the Git-private Codex Workstream binding file.""" - - state_path = _workspace_state_path(cwd) - if state_path is None: - return False - try: - state_path.unlink() - except FileNotFoundError: - return False - return True - - -def normalize_git_remote(remote: str) -> str | None: - """Normalize common network remotes without retaining credentials.""" - - value = remote.strip() - if not value: - return None - scp_match = _SCP_REMOTE.fullmatch(value) - if scp_match and "://" not in value: - host = scp_match.group("host").lower() - path = _normalize_path(scp_match.group("path")) - return f"{host}/{path}" if path else None - parsed = urlsplit(value) - if parsed.scheme not in {"http", "https", "ssh", "git"} or parsed.hostname is None: - return None - host = parsed.hostname.lower() - if parsed.port is not None: - host = f"{host}:{parsed.port}" - path = _normalize_path(parsed.path) - return f"{host}/{path}" if path else None - - -def _normalize_path(path: str) -> str: - normalized = "/".join(part for part in path.replace("\\", "/").split("/") if part) - if normalized.endswith(".git"): - normalized = normalized[:-4] - return normalized.rstrip("/") - - -def _bounded(prefix: str, value: str) -> str: - candidate = f"{prefix}:{value}" - if len(candidate) <= _MAX_SCOPE_LENGTH: - return candidate - return f"{prefix}:sha256:{hashlib.sha256(value.encode()).hexdigest()}" - - -def _bounded_explicit(value: str) -> str: - if len(value) <= _MAX_SCOPE_LENGTH: - return value - return f"sha256:{hashlib.sha256(value.encode()).hexdigest()}" - - -def _workspace_state_path(cwd: str, /) -> Path | None: - git_directory = _git_value(cwd, "rev-parse", "--absolute-git-dir") - if git_directory is None: - return None - return Path(git_directory) / _WORKSPACE_STATE_DIRECTORY / _WORKSPACE_STATE_FILE - - -def _write_workspace_state(state_path: Path, scope_id: str, /) -> None: - state_path.parent.mkdir(mode=0o700, parents=True, exist_ok=True) - temporary_path = state_path.with_name(f".{state_path.name}.{os.getpid()}.tmp") - encoded = ( - json.dumps({"schema": _WORKSPACE_STATE_SCHEMA, "scope_id": scope_id}, separators=(",", ":")) + "\n" - ).encode() - descriptor: int | None = None - try: - descriptor = os.open(temporary_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600) - os.write(descriptor, encoded) - os.fsync(descriptor) - os.close(descriptor) - descriptor = None - os.replace(temporary_path, state_path) - finally: - if descriptor is not None: - os.close(descriptor) - with suppress(FileNotFoundError): - temporary_path.unlink() - - -def _git_value(cwd: str, *arguments: str) -> str | None: - executable = which("git") - if executable is None: - return None - try: - completed = subprocess.run( # noqa: S603 - git executable and arguments are integration-owned. - [executable, *arguments], - cwd=cwd, - check=True, - capture_output=True, - text=True, - timeout=2, - ) - except (OSError, subprocess.SubprocessError): - return None - return completed.stdout.strip() or None - - -def main(argv: Sequence[str] | None = None) -> int: - parser = argparse.ArgumentParser() - parser.add_argument("--cwd", default=os.getcwd()) - action = parser.add_mutually_exclusive_group() - action.add_argument("--bind-workstream", metavar="SCOPE_ID") - action.add_argument("--clear-workstream", action="store_true") - arguments = parser.parse_args(argv) - if arguments.bind_workstream is not None: - print(bind_workstream_scope(arguments.cwd, arguments.bind_workstream)) - return 0 - if arguments.clear_workstream: - clear_workstream_scope(arguments.cwd) - settings = CodexPluginSettings() - print(resolve_scope_id(arguments.cwd, configured_scope_id=settings.scope_id)) - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/integrations/codex/plugins/powercontext/scripts/scope_binding.py b/integrations/codex/plugins/powercontext/scripts/scope_binding.py new file mode 100644 index 000000000..46a5554b0 --- /dev/null +++ b/integrations/codex/plugins/powercontext/scripts/scope_binding.py @@ -0,0 +1,199 @@ +#!/usr/bin/env python3 +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 + +"""Resolve Codex external identities through the PowerContext Scope service.""" + +from __future__ import annotations + +import json +import os +import subprocess +import sys +from collections.abc import Mapping +from hashlib import sha256 +from pathlib import Path +from shutil import which +from time import monotonic +from typing import Any, Protocol +from urllib.error import HTTPError +from urllib.request import HTTPRedirectHandler, Request, build_opener + +from typing_extensions import override + +_PLUGIN_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(_PLUGIN_ROOT)) + +from settings import CodexPluginSettings # noqa: E402 + +_MAX_RESPONSE_BYTES = 1_048_576 +_READ_CHUNK_BYTES = 65_536 +_REQUEST_HEADERS = { + "Accept": "application/json", + "Content-Type": "application/json", + "User-Agent": "powercontext-codex-plugin/0.3.0", +} + + +class ScopeBindingError(RuntimeError): + """Raised when the integration cannot establish one current Scope.""" + + +class _Response(Protocol): + fp: object + status: int + + def __enter__(self) -> _Response: ... + + def __exit__(self, *args: object) -> object: ... + + def read(self, amount: int = -1) -> bytes: ... + + +class _RejectRedirects(HTTPRedirectHandler): + @override + def redirect_request( + self, + req: Request, + fp: object, + code: int, + msg: str, + headers: object, + newurl: str, + ) -> Request | None: + return None + + +_URL_OPENER = build_opener(_RejectRedirects) + + +def resolve_scope_id( + cwd: str, + *, + session_id: str | None, + settings: CodexPluginSettings, + deadline: float, + persist_session: bool = False, +) -> str: + """Resolve explicit, session, workspace, then default binding in that order.""" + + keys = binding_keys(cwd, session_id=session_id) + response = _post_json( + "/v1/scope-bindings/resolve", + { + "explicit_scope_id": settings.scope_id, + "binding_keys": keys, + }, + settings=settings, + deadline=deadline, + ) + scope_id = response.get("scope_id") + if not isinstance(scope_id, str) or not scope_id.strip() or scope_id != scope_id.strip(): + raise ScopeBindingError + if persist_session and session_id is not None and settings.scope_id is None: + _post_json( + "/v1/scope-bindings", + { + "key": session_binding_key(session_id), + "scope_id": scope_id, + }, + settings=settings, + deadline=deadline, + method="PUT", + ) + return scope_id + + +def binding_keys(cwd: str, *, session_id: str | None) -> list[dict[str, str]]: + keys: list[dict[str, str]] = [] + if session_id is not None: + keys.append(session_binding_key(session_id)) + keys.append(workspace_binding_key(cwd)) + return keys + + +def session_binding_key(session_id: str) -> dict[str, str]: + value = session_id.strip() + if not value or len(value) > 256: + raise ScopeBindingError + return {"integration": "codex", "kind": "session", "external_id": value} + + +def workspace_binding_key(cwd: str) -> dict[str, str]: + root_value = _git_value(cwd, "rev-parse", "--show-toplevel") + root = Path(root_value or cwd).resolve(strict=False) + external_id = sha256(os.fsencode(root)).hexdigest() + return {"integration": "codex", "kind": "workspace", "external_id": external_id} + + +def _post_json( + path: str, + payload: Mapping[str, object], + *, + settings: CodexPluginSettings, + deadline: float, + method: str = "POST", +) -> Mapping[str, object]: + remaining = deadline - monotonic() + if remaining <= 0: + raise ScopeBindingError + headers = dict(_REQUEST_HEADERS) + if settings.authorization is not None: + headers["Authorization"] = settings.authorization.get_secret_value() + request = Request( # noqa: S310 - settings validates the configured transport. + f"{settings.server_url}{path}", + data=json.dumps(payload, separators=(",", ":")).encode(), + headers=headers, + method=method, + ) + try: + with _URL_OPENER.open( + request, + timeout=min(settings.request_timeout_seconds, remaining), + ) as response: + if response.status < 200 or response.status >= 300: + raise ScopeBindingError + raw = _read_bounded(response) + except (HTTPError, OSError, TimeoutError) as error: + raise ScopeBindingError from error + try: + value: Any = json.loads(raw) + except (UnicodeDecodeError, json.JSONDecodeError) as error: + raise ScopeBindingError from error + if not isinstance(value, dict): + raise ScopeBindingError + return value + + +def _read_bounded(response: _Response) -> bytes: + chunks: list[bytes] = [] + size = 0 + while chunk := response.read(_READ_CHUNK_BYTES): + size += len(chunk) + if size > _MAX_RESPONSE_BYTES: + raise ScopeBindingError + chunks.append(chunk) + return b"".join(chunks) + + +def _git_value(cwd: str, *arguments: str) -> str | None: + executable = which("git") + if executable is None: + return None + try: + completed = subprocess.run( # noqa: S603 - executable and arguments are integration-owned. + [executable, *arguments], + cwd=cwd, + check=True, + capture_output=True, + text=True, + timeout=2, + ) + except (OSError, subprocess.SubprocessError): + return None + return completed.stdout.strip() or None diff --git a/integrations/codex/plugins/powercontext/skills/project-context/SKILL.md b/integrations/codex/plugins/powercontext/skills/project-context/SKILL.md index 0ba151417..a7af92f9b 100644 --- a/integrations/codex/plugins/powercontext/skills/project-context/SKILL.md +++ b/integrations/codex/plugins/powercontext/skills/project-context/SKILL.md @@ -13,42 +13,19 @@ The Server's Source window Trigger and candidate pipeline decide whether that evidence should produce or update Memory. Do not call `remember_memory` merely to duplicate the current prompt. -## Resolve scope - -Before the first memory tool call, run: - -```bash -"$PLUGIN_ROOT/.venv/bin/python" "$PLUGIN_ROOT/scripts/project_scope.py" --cwd "$PWD" -``` - -Reuse that exact `scope_id` for the task. - -The resolver first honors an explicit plugin scope, then a Git-private Workstream -binding, and finally the normalized remote or project path. When the user -explicitly asks to bind the current checkout to a known Handoff Report -Workstream, run: - -```bash -"$PLUGIN_ROOT/.venv/bin/python" "$PLUGIN_ROOT/scripts/project_scope.py" \ - --cwd "$PWD" --bind-workstream "WORKSTREAM_SCOPE_ID" -``` - -Then run the normal resolver command again and verify the same scope. The -binding is stored below the checkout's Git directory and is not committed. -Never infer one Workstream when multiple candidates remain consequential. - -Before a durable one-turn Handoff or a `latest` Continue without an exact -Workstream, call `select_handoff_workstream` when that MCP tool is available. -With multiple candidates, Codex presents the tool's MCP elicitation as a native -picker; one candidate is selected automatically. On `selected`, bind the -returned `scope_id` with `--bind-workstream`, run the normal resolver again, -and require the resolved scope to match before any Handoff write. On -`needs_selection`, present the returned choices and call the tool again with -the user's exact `project_id` and `work_id`; never choose a fallback candidate -silently. On `cancelled` or `declined`, stop the Handoff flow. If the tool is -unavailable or returns `empty`, preserve the existing resolver behavior. The -picker is read-only and selecting work does not itself prepare or commit a -Handoff. +## Scope binding + +The integration binds the current Codex Session before recall, capture, and +PowerContext MCP calls. Do not derive a Scope from the repository, directory, +branch, Agent, or prompt, and do not override the integration binding in an +ordinary data-plane call. + +When the user explicitly asks to start an independent result, use +`create_scope` with a concise title, summary, stable idempotency key, and only +the Parent, Context References, or external references the user established. +Then use `set_scope_binding`; the integration replaces its binding key with the +current Codex Session identity. Reuse an existing Scope instead when the work +does not need independent isolation, continuation, delivery, or observation. ## Read @@ -83,22 +60,20 @@ or draft a Handoff does not authorize any write. When the one-turn flow applies: -1. Select the Workstream when the picker is available, then resolve and verify - the exact scope using the commands above. -2. Inspect the current conversation and repository before writing. At minimum, +1. Inspect the current conversation and repository before writing. At minimum, ground the active objective, current branch and worktree state, changed files, relevant recent commits, checks already run, blockers, omissions, and the next executable action. Do not read or include secret values. -3. Build a concise current-work record from observed facts. Use `declared` for +2. Build a concise current-work record from observed facts. Use `declared` for claims without an exact same-scope PowerContext citation; never invent `verified` evidence. Choose `continuable`, `blocked`, or `complete` from the observed state rather than defaulting silently. -4. Call `handoff_current_work` once with a unique `source_id`. This persists the +3. Call `handoff_current_work` once with a unique `source_id`. This persists the inspected boundary and returns a `PreparedWorkHandoff` containing `boundary` and `handoff`. -5. Pass the returned `handoff` member unchanged as the `handoff` argument to +4. Pass the returned `handoff` member unchanged as the `handoff` argument to `commit_handoff` in the same turn. -6. Report success only after commit returns an exact Handoff Revision. Summarize +5. Report success only after commit returns an exact Handoff Revision. Summarize the objective, disposition, next action, omissions, scope, and exact Revision so the user can immediately transfer it. diff --git a/src/powercontext/server/mcp.py b/src/powercontext/server/mcp.py index e76b1f740..2890026ed 100644 --- a/src/powercontext/server/mcp.py +++ b/src/powercontext/server/mcp.py @@ -36,20 +36,24 @@ ACTIVATE_HANDOFF, APPROVE_ARTIFACT_CANDIDATE, CAPTURE_CONTENT_SOURCE, + CLEAR_SCOPE_BINDING, COMMIT_HANDOFF, CONTINUE_HANDOFF, + CREATE_SCOPE, CREATE_WORK_CONTRACT, FINALIZE_HANDOFF, GET_ARTIFACT_CANDIDATE, GET_HANDOFF_REPORT, GET_HANDOFF_REPORT_WORKSPACE, GET_MEMORY_ENTRY, + GET_SCOPE, HANDOFF_CURRENT_WORK, LIST_ARTIFACT_CANDIDATES, LIST_HANDOFF_REPORT_KNOWN_SCOPES, LIST_HANDOFF_REPORT_PROJECTS, LIST_HANDOFF_REPORT_WORKSTREAMS, LIST_MEMORY_ENTRIES, + LIST_SCOPES, RECORD_TASK_OUTCOME, REJECT_ARTIFACT_CANDIDATE, REMEMBER_MEMORY, @@ -57,6 +61,7 @@ REVISE_ARTIFACT_CANDIDATE, REVISE_MEMORY_ENTRY, SEARCH_MEMORY, + SET_SCOPE_BINDING, ) from powercontext.server.access import McpAccessLogMiddleware from powercontext.server.app import REQUEST_ID_HEADER @@ -95,6 +100,11 @@ APPROVE_ARTIFACT_CANDIDATE.operation_id, REJECT_ARTIFACT_CANDIDATE.operation_id, REVISE_ARTIFACT_CANDIDATE.operation_id, + CREATE_SCOPE.operation_id, + LIST_SCOPES.operation_id, + GET_SCOPE.operation_id, + SET_SCOPE_BINDING.operation_id, + CLEAR_SCOPE_BINDING.operation_id, }) _MCP_READ_ONLY_OPERATION_IDS = frozenset({ CONTINUE_HANDOFF.operation_id, @@ -106,6 +116,8 @@ GET_HANDOFF_REPORT_WORKSPACE.operation_id, LIST_ARTIFACT_CANDIDATES.operation_id, GET_ARTIFACT_CANDIDATE.operation_id, + LIST_SCOPES.operation_id, + GET_SCOPE.operation_id, }) diff --git a/tests/codex_plugin/conftest.py b/tests/codex_plugin/conftest.py index 21ecb9360..785989b15 100644 --- a/tests/codex_plugin/conftest.py +++ b/tests/codex_plugin/conftest.py @@ -35,7 +35,7 @@ def _load_module(name: str, path: Path) -> ModuleType: @pytest.fixture def scope_module() -> ModuleType: - return _load_module("powercontext_codex_scope", PLUGIN_ROOT / "scripts" / "project_scope.py") + return _load_module("powercontext_codex_scope", PLUGIN_ROOT / "scripts" / "scope_binding.py") @pytest.fixture @@ -46,3 +46,13 @@ def recall_module() -> ModuleType: @pytest.fixture def settings_module() -> ModuleType: return _load_module("powercontext_codex_settings", PLUGIN_ROOT / "settings.py") + + +@pytest.fixture +def bind_tools_module() -> ModuleType: + return _load_module("powercontext_codex_bind_tools", PLUGIN_ROOT / "hooks" / "bind_tools.py") + + +@pytest.fixture +def session_binding_module() -> ModuleType: + return _load_module("powercontext_codex_session_binding", PLUGIN_ROOT / "hooks" / "session_binding.py") diff --git a/tests/codex_plugin/test_contract.py b/tests/codex_plugin/test_contract.py index 211248fb8..1fe3b9b21 100644 --- a/tests/codex_plugin/test_contract.py +++ b/tests/codex_plugin/test_contract.py @@ -15,9 +15,9 @@ from __future__ import annotations import json -import os from pathlib import Path from types import ModuleType +from typing import Any, cast import pytest from pydantic import ValidationError @@ -25,67 +25,56 @@ PLUGIN_ROOT = Path(__file__).resolve().parents[2] / "integrations" / "codex" / "plugins" / "powercontext" -@pytest.mark.parametrize( - ("remote", "expected"), - [ - ("https://github.com/OceanBase/powercontext.git", "github.com/OceanBase/powercontext"), - ("ssh://git@github.com/OceanBase/powercontext.git", "github.com/OceanBase/powercontext"), - ("git@github.com:OceanBase/powercontext.git", "github.com/OceanBase/powercontext"), - ], -) -def test_scope_normalizes_network_git_remotes( - scope_module: ModuleType, - remote: str, - expected: str, -) -> None: - assert scope_module.normalize_git_remote(remote) == expected - - -def test_scope_override_wins(scope_module: ModuleType, tmp_path: Path) -> None: - assert ( - scope_module.derive_scope_id( - str(tmp_path), - configured_scope_id="project:explicit", - ) - == "project:explicit" - ) - - -def test_scope_binding_is_persisted_in_git_private_state( +def test_scope_binding_keys_separate_session_and_workspace_identity( scope_module: ModuleType, monkeypatch: pytest.MonkeyPatch, tmp_path: Path, ) -> None: - git_directory = tmp_path / ".git" - git_directory.mkdir() - monkeypatch.setattr( - scope_module, - "_git_value", - lambda _cwd, *arguments: str(git_directory) if arguments == ("rev-parse", "--absolute-git-dir") else None, - ) + monkeypatch.setattr(scope_module, "_git_value", lambda *_args: str(tmp_path)) - assert scope_module.bind_workstream_scope(str(tmp_path), "handoff-ui-review") == "handoff-ui-review" - assert scope_module.read_bound_scope_id(str(tmp_path)) == "handoff-ui-review" - assert scope_module.resolve_scope_id(str(tmp_path)) == "handoff-ui-review" - assert scope_module.resolve_scope_id(str(tmp_path), configured_scope_id="scope:override") == "scope:override" - state_path = git_directory / "powercontext" / "codex-workspace.json" - if os.name != "nt": - assert state_path.stat().st_mode & 0o777 == 0o600 + keys = scope_module.binding_keys(str(tmp_path), session_id="session-1") - assert scope_module.clear_workstream_scope(str(tmp_path)) is True - assert scope_module.read_bound_scope_id(str(tmp_path)) is None - assert scope_module.clear_workstream_scope(str(tmp_path)) is False + assert keys[0] == {"integration": "codex", "kind": "session", "external_id": "session-1"} + assert keys[1]["integration"] == "codex" + assert keys[1]["kind"] == "workspace" + assert len(keys[1]["external_id"]) == 64 + assert not keys[1]["external_id"].startswith("scp_") -def test_scope_binding_requires_a_git_workspace( +def test_scope_resolver_uses_server_binding_and_fixes_new_session( scope_module: ModuleType, monkeypatch: pytest.MonkeyPatch, tmp_path: Path, ) -> None: + requests: list[tuple[str, dict[str, object], str]] = [] + + def post(path, payload, *, settings, deadline, method="POST"): + requests.append((path, payload, method)) + return {"scope_id": "scp_00000000000000000000000000"} + + monkeypatch.setattr(scope_module, "_post_json", post) monkeypatch.setattr(scope_module, "_git_value", lambda *_args: None) - with pytest.raises(ValueError, match="requires a Git workspace"): - scope_module.bind_workstream_scope(str(tmp_path), "handoff-ui-review") + resolved = scope_module.resolve_scope_id( + str(tmp_path), + session_id="session-1", + settings=scope_module.CodexPluginSettings(), + deadline=float("inf"), + persist_session=True, + ) + + assert resolved == "scp_00000000000000000000000000" + assert requests[0][0] == "/v1/scope-bindings/resolve" + binding_keys = cast(list[dict[str, Any]], requests[0][1]["binding_keys"]) + assert [key["kind"] for key in binding_keys] == ["session", "workspace"] + assert requests[1] == ( + "/v1/scope-bindings", + { + "key": {"integration": "codex", "kind": "session", "external_id": "session-1"}, + "scope_id": resolved, + }, + "PUT", + ) def test_codex_settings_precedence_and_validation( @@ -181,16 +170,17 @@ def test_project_context_skill_uses_the_high_level_work_continuity_loop() -> Non content = (PLUGIN_ROOT / "skills" / "project-context" / "SKILL.md").read_text(encoding="utf-8") assert 'description: Create and commit a current-work Handoff when the user says "交接"' in content - assert '"$PLUGIN_ROOT/.venv/bin/python" "$PLUGIN_ROOT/scripts/project_scope.py"' in content - assert "uv run --frozen" not in content + assert "Do not derive a Scope from the repository" in content + assert "create_scope" in content + assert "set_scope_binding" in content + assert "project_scope.py" not in content + assert "select_handoff_workstream" not in content assert "create_work_contract" in content - assert "select_handoff_workstream" in content assert "handoff_current_work" in content assert "acknowledge_handoff" in content assert "record_task_outcome" in content assert "Complete a one-turn durable Handoff" in content assert "do not ask for a second confirmation" in content - assert "native\npicker" in content assert "Pass the returned `handoff` member unchanged" in content assert "no durable Handoff milestone was committed" in content assert "canonical temporary carrier" in content @@ -199,6 +189,15 @@ def test_project_context_skill_uses_the_high_level_work_continuity_loop() -> Non assert "Do not treat every session stop as task completion" in content +def test_codex_hooks_fix_session_and_data_plane_bindings() -> None: + configuration = json.loads((PLUGIN_ROOT / "hooks" / "hooks.json").read_text()) + + assert "session_binding.py" in configuration["hooks"]["SessionStart"][0]["hooks"][0]["command"] + pre_tool_use = configuration["hooks"]["PreToolUse"][0] + assert pre_tool_use["matcher"] == "mcp__powercontext__.*" + assert "bind_tools.py" in pre_tool_use["hooks"][0]["command"] + + def test_powercontext_plugin_advertises_the_one_turn_handoff() -> None: manifest = json.loads((PLUGIN_ROOT / ".codex-plugin" / "plugin.json").read_text()) prompts = manifest["interface"]["defaultPrompt"] diff --git a/tests/codex_plugin/test_recall.py b/tests/codex_plugin/test_recall.py index 50e1002ee..ad3095e25 100644 --- a/tests/codex_plugin/test_recall.py +++ b/tests/codex_plugin/test_recall.py @@ -71,7 +71,7 @@ def test_recall_emits_bounded_untrusted_context( monkeypatch.setattr( recall_module, "resolve_scope_id", - lambda _cwd, *, configured_scope_id: "project:test", + lambda _cwd, **_kwargs: "project:test", ) captured: list[tuple[str, str]] = [] monkeypatch.setattr( @@ -116,7 +116,7 @@ def test_recall_reads_utf8_stdin_on_windows_encodings( monkeypatch.setattr( recall_module, "resolve_scope_id", - lambda _cwd, *, configured_scope_id: "project:test", + lambda _cwd, **_kwargs: "project:test", ) monkeypatch.setattr( recall_module, @@ -154,7 +154,7 @@ def test_recall_failure_is_non_blocking( monkeypatch.setattr( recall_module, "resolve_scope_id", - lambda _cwd, *, configured_scope_id: "project:test", + lambda _cwd, **_kwargs: "project:test", ) monkeypatch.setattr( sys, @@ -228,7 +228,7 @@ def test_recall_records_exact_injected_context_only_when_eval_trace_is_enabled( monkeypatch.setattr( recall_module, "resolve_scope_id", - lambda _cwd, *, configured_scope_id: "eval:run-1:on", + lambda _cwd, **_kwargs: "eval:run-1:on", ) monkeypatch.setattr(recall_module, "_capture_prompt", lambda *_args, **_kwargs: {"position": 1}) monkeypatch.setattr( @@ -281,7 +281,7 @@ def test_recall_does_not_write_an_evaluation_trace_by_default( monkeypatch.setattr( recall_module, "resolve_scope_id", - lambda _cwd, *, configured_scope_id: "project:test", + lambda _cwd, **_kwargs: "project:test", ) monkeypatch.setattr(recall_module, "_capture_prompt", lambda *_args, **_kwargs: {"position": 1}) monkeypatch.setattr( @@ -353,7 +353,7 @@ def test_hook_accepts_codex_event_name_variants( monkeypatch.setattr( recall_module, "resolve_scope_id", - lambda _cwd, *, configured_scope_id: "project:test", + lambda _cwd, **_kwargs: "project:test", ) monkeypatch.setattr( sys, diff --git a/tests/codex_plugin/test_scope_binding.py b/tests/codex_plugin/test_scope_binding.py new file mode 100644 index 000000000..b75eaff1b --- /dev/null +++ b/tests/codex_plugin/test_scope_binding.py @@ -0,0 +1,137 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 + +from __future__ import annotations + +import io +import json +import sys +from types import ModuleType + +import pytest + + +def test_pre_tool_hook_overwrites_agent_scope_with_session_binding( + bind_tools_module: ModuleType, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr( + bind_tools_module, + "resolve_scope_id", + lambda _cwd, *, session_id, **_kwargs: f"scope-for-{session_id}", + ) + monkeypatch.setattr( + sys, + "stdin", + io.StringIO( + json.dumps({ + "hook_event_name": "PreToolUse", + "session_id": "session-a", + "cwd": "/workspace", + "tool_name": "mcp__powercontext__remember_memory", + "tool_input": {"scope_id": "agent-selected", "kind": "decision", "text": "Use Scope binding."}, + }) + ), + ) + output = io.StringIO() + monkeypatch.setattr(sys, "stdout", output) + + assert bind_tools_module.main() == 0 + result = json.loads(output.getvalue())["hookSpecificOutput"] + assert result["permissionDecision"] == "allow" + assert result["updatedInput"]["scope_id"] == "scope-for-session-a" + assert result["updatedInput"]["text"] == "Use Scope binding." + + +def test_pre_tool_hook_fixes_control_binding_to_current_session( + bind_tools_module: ModuleType, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr( + sys, + "stdin", + io.StringIO( + json.dumps({ + "hook_event_name": "PreToolUse", + "session_id": "session-a", + "cwd": "/workspace", + "tool_name": "mcp__powercontext__set_scope_binding", + "tool_input": { + "key": {"integration": "other", "kind": "session", "external_id": "session-b"}, + "scope_id": "target-scope", + }, + }) + ), + ) + output = io.StringIO() + monkeypatch.setattr(sys, "stdout", output) + + assert bind_tools_module.main() == 0 + updated = json.loads(output.getvalue())["hookSpecificOutput"]["updatedInput"] + assert updated == { + "key": {"integration": "codex", "kind": "session", "external_id": "session-a"}, + "scope_id": "target-scope", + } + + +def test_pre_tool_hook_denies_data_plane_when_binding_is_unavailable( + bind_tools_module: ModuleType, + monkeypatch: pytest.MonkeyPatch, +) -> None: + def fail(*_args, **_kwargs): + raise bind_tools_module.ScopeBindingError + + monkeypatch.setattr(bind_tools_module, "resolve_scope_id", fail) + monkeypatch.setattr( + sys, + "stdin", + io.StringIO( + json.dumps({ + "hook_event_name": "PreToolUse", + "session_id": "session-a", + "cwd": "/workspace", + "tool_name": "mcp__powercontext__search_memory", + "tool_input": {"query": "current state"}, + }) + ), + ) + output = io.StringIO() + monkeypatch.setattr(sys, "stdout", output) + + assert bind_tools_module.main() == 0 + result = json.loads(output.getvalue())["hookSpecificOutput"] + assert result["permissionDecision"] == "deny" + + +def test_session_start_fixes_default_or_workspace_resolution( + session_binding_module: ModuleType, + monkeypatch: pytest.MonkeyPatch, +) -> None: + calls: list[dict[str, object]] = [] + + def resolve(cwd, **kwargs): + calls.append({"cwd": cwd, **kwargs}) + return "scope-a" + + monkeypatch.setattr(session_binding_module, "resolve_scope_id", resolve) + monkeypatch.setattr( + sys, + "stdin", + io.StringIO( + json.dumps({ + "hook_event_name": "SessionStart", + "session_id": "session-a", + "cwd": "/workspace", + }) + ), + ) + + assert session_binding_module.main() == 0 + assert calls[0]["cwd"] == "/workspace" + assert calls[0]["session_id"] == "session-a" + assert calls[0]["persist_session"] is True diff --git a/tests/test_mcp.py b/tests/test_mcp.py index e58a40e40..0b35be3ce 100644 --- a/tests/test_mcp.py +++ b/tests/test_mcp.py @@ -109,7 +109,7 @@ def run_async(operation: Callable[[], Coroutine[Any, Any, ResultT]]) -> ResultT: return asyncio.run(operation()) -def test_mcp_exposes_only_the_agent_facing_server_operations() -> None: +def test_mcp_exposes_only_data_plane_and_integration_control_operations() -> None: async def inspect_components() -> tuple[list[str], int, int]: async with Client(create_mcp_server(create_app())) as client: tools = await client.list_tools() @@ -124,15 +124,19 @@ async def inspect_components() -> tuple[list[str], int, int]: "acknowledge_handoff", "approve_artifact_candidate", "capture_content_source", + "clear_scope_binding", "commit_handoff", "continue_handoff", + "create_scope", "create_work_contract", "finalize_handoff", "get_artifact_candidate", "get_memory_entry", + "get_scope", "handoff_current_work", "list_artifact_candidates", "list_memory_entries", + "list_scopes", "reject_artifact_candidate", "record_task_outcome", "remember_memory", @@ -140,6 +144,7 @@ async def inspect_components() -> tuple[list[str], int, int]: "revise_artifact_candidate", "revise_memory_entry", "search_memory", + "set_scope_binding", } assert resource_count == 0 assert prompt_count == 0 From 09302097e7464efd5228d984870a36194644d63f Mon Sep 17 00:00:00 2001 From: Chojan Shang Date: Thu, 27 Aug 2026 15:44:33 +0800 Subject: [PATCH 04/15] feat(context): read direct scope references --- src/powercontext/__init__.py | 2 + src/powercontext/artifacts/__init__.py | 3 +- src/powercontext/artifacts/models.py | 20 ++- .../builtin/runtime/application.py | 151 ++++++++++++------ .../builtin/runtime/prepared_context.py | 110 +++++++++++-- src/powercontext/builtin/runtime/recall.py | 121 +++++++++----- .../builtin/runtime/relational.py | 9 +- .../builtin/runtime/test_prepared_context.py | 39 ++++- tests/e2e/test_builtin_runtime.py | 75 +++++++++ tests/test_artifacts.py | 13 ++ 10 files changed, 434 insertions(+), 109 deletions(-) diff --git a/src/powercontext/__init__.py b/src/powercontext/__init__.py index 9ae96033b..c22b37893 100644 --- a/src/powercontext/__init__.py +++ b/src/powercontext/__init__.py @@ -16,6 +16,7 @@ from powercontext.artifacts import ( Artifact, + ArtifactAddress, ArtifactCatalog, ArtifactDraft, ArtifactLineage, @@ -52,6 +53,7 @@ __all__ = [ "Artifact", + "ArtifactAddress", "ArtifactCatalog", "ArtifactDraft", "ArtifactError", diff --git a/src/powercontext/artifacts/__init__.py b/src/powercontext/artifacts/__init__.py index a68c0c2b9..fff5989b0 100644 --- a/src/powercontext/artifacts/__init__.py +++ b/src/powercontext/artifacts/__init__.py @@ -14,11 +14,12 @@ """Immutable artifacts and their read-only catalog contract.""" -from powercontext.artifacts.models import Artifact, ArtifactDraft, ArtifactLineage, ArtifactRef +from powercontext.artifacts.models import Artifact, ArtifactAddress, ArtifactDraft, ArtifactLineage, ArtifactRef from powercontext.artifacts.protocols import ArtifactCatalog, ArtifactStore __all__ = [ "Artifact", + "ArtifactAddress", "ArtifactCatalog", "ArtifactDraft", "ArtifactLineage", diff --git a/src/powercontext/artifacts/models.py b/src/powercontext/artifacts/models.py index 3451feb94..adc214ab9 100644 --- a/src/powercontext/artifacts/models.py +++ b/src/powercontext/artifacts/models.py @@ -21,7 +21,7 @@ from pydantic import BaseModel, Field, StrictInt, field_validator, model_validator from powercontext.errors import InvalidArtifactReferenceError -from powercontext.limits import MAX_ARTIFACT_FAMILY_LENGTH, MAX_ARTIFACT_ID_LENGTH +from powercontext.limits import MAX_ARTIFACT_FAMILY_LENGTH, MAX_ARTIFACT_ID_LENGTH, MAX_SCOPE_ID_LENGTH from powercontext.sources.models import SourceRef ContentT = TypeVar("ContentT", covariant=True) @@ -44,6 +44,24 @@ def validate_identity(cls, value: str, info) -> str: return value +class ArtifactAddress(BaseModel): + """A complete address for one exact Artifact revision across Scope boundaries.""" + + scope_id: str + artifact: ArtifactRef + + @field_validator("scope_id") + @classmethod + def validate_scope_id(cls, value: str) -> str: + _validate_reference_part("scope_id", value) + if len(value) > MAX_SCOPE_ID_LENGTH: + raise InvalidArtifactReferenceError( + "scope_id", + f"must not exceed {MAX_SCOPE_ID_LENGTH} characters", + ) + return value + + class ArtifactLineage(BaseModel): """The direct evidence used to produce one artifact revision.""" diff --git a/src/powercontext/builtin/runtime/application.py b/src/powercontext/builtin/runtime/application.py index e35866308..c5112c8f9 100644 --- a/src/powercontext/builtin/runtime/application.py +++ b/src/powercontext/builtin/runtime/application.py @@ -119,7 +119,12 @@ SkillCandidate, SourceReceipt, ) -from powercontext.builtin.runtime.prepared_context import PreparedContextBuild, PreparedContextBuilder +from powercontext.builtin.runtime.prepared_context import ( + PreparedContextBuild, + PreparedContextBuilder, + PreparedExperienceCandidates, + PreparedMemoryCandidates, +) from powercontext.builtin.runtime.protocols import ( BuiltinTriggers, PowerContextProvider, @@ -133,7 +138,7 @@ RuntimeReadinessChecks, ) from powercontext.builtin.runtime.statistics import RelationalScopedStatistics -from powercontext.builtin.scope import ScopeApplication +from powercontext.builtin.scope import ScopeApplication, ScopeNotFoundError from powercontext.builtin.sources import ( ContentCapture, ContentSource, @@ -337,26 +342,103 @@ async def prepare(self, request: PrepareContextRequest, /) -> PreparedContext: async def _prepare(self, request: PrepareContextRequest, /) -> PreparedContext: builder = PreparedContextBuilder() + scope_ids = [self.scope_id] + if self._runtime.scopes is not None: + try: + scope = await self._runtime.scopes.get(self.scope_id) + except ScopeNotFoundError: + pass + else: + scope_ids.extend(scope.context_references) + + memory_candidates: list[PreparedMemoryCandidates] = [] + experience_candidates: list[PreparedExperienceCandidates] = [] + remaining_memory = builder.memory_candidate_limit + remaining_experience = builder.experience_candidate_limit + for scope_id in scope_ids: + memory, experiences = await self._recall_scope( + scope_id, + request, + memory_limit=remaining_memory, + experience_limit=remaining_experience, + ) + memory_candidates.append(memory) + experience_candidates.append(experiences) + remaining_memory -= len(memory.hits) + remaining_experience -= len(experiences.hits) + + with self._runtime._stage( + "context.build", + attributes={ + "powercontext.context.build.scope_count": len(scope_ids), + "powercontext.context.build.memory_candidate_count": sum( + len(candidates.hits) for candidates in memory_candidates + ), + "powercontext.context.build.experience_candidate_count": sum( + len(candidates.hits) for candidates in experience_candidates + ), + }, + ) as span: + build = builder.build_scopes_result( + request=request, + current_scope_id=self.scope_id, + memory_candidates=memory_candidates, + experience_candidates=experience_candidates, + ) + if span is not None: + span.set_attributes({ + "powercontext.context.build.selected_count": len(build.origins), + "powercontext.context.build.status": build.context.status, + "powercontext.context.build.content_bytes": build.context.content_bytes, + }) + if self._runtime._recall_token_estimator is not None: + try: + measurement = await self._runtime._recall_token_estimator(self.scope_id, build) + except Exception as error: + log_safely( + logger, + logging.ERROR, + "Recall token estimation failed", + exc_info=error, + extra={ + "event": "statistics.recall_tokens.estimation_failed", + "outcome": "failure", + "unit": "statistics", + }, + ) + else: + if measurement is not None: + await self._runtime.statistics.for_scope(self.scope_id).record_recall(measurement) + return build.context + + async def _recall_scope( + self, + scope_id: str, + request: PrepareContextRequest, + *, + memory_limit: int, + experience_limit: int, + ) -> tuple[PreparedMemoryCandidates, PreparedExperienceCandidates]: async with ( - self._runtime._context(self.scope_id, embedding_purpose=ModelUsagePurpose.MEMORY_RECALL) as context, - self._runtime._locked(self.scope_id), + self._runtime._context(scope_id, embedding_purpose=ModelUsagePurpose.MEMORY_RECALL) as context, + self._runtime._locked(scope_id), ): with self._runtime._stage( _MEMORY_SEARCH_STAGE, attributes={ _MEMORY_SEARCH_REQUESTED_MODE: "auto", - _MEMORY_SEARCH_LIMIT: builder.memory_candidate_limit, + _MEMORY_SEARCH_LIMIT: memory_limit, }, ) as span: service = context.artifacts.memory current = await _head_or_none(service, context.artifacts.memory_artifact_id) memory_hits = () search_mode: str | None = None - if current is not None: + if current is not None and memory_limit > 0: result = await service.search( request.query, memories=(current,), - limit=builder.memory_candidate_limit, + limit=memory_limit, mode="auto", ) memory_hits = result.hits @@ -375,59 +457,28 @@ async def _prepare(self, request: PrepareContextRequest, /) -> PreparedContext: "experience.search", attributes={ "powercontext.experience.search.configured": experience_recall is not None, - "powercontext.experience.search.limit": builder.experience_candidate_limit, + "powercontext.experience.search.limit": experience_limit, }, ) as span: experience_hits = ( () - if experience_recall is None + if experience_recall is None or experience_limit == 0 else await experience_recall( - self.scope_id, + scope_id, request.query, - builder.experience_candidate_limit, + experience_limit, ) ) if span is not None: span.set_attributes({"powercontext.experience.search.result_count": len(experience_hits)}) - - with self._runtime._stage( - "context.build", - attributes={ - "powercontext.context.build.memory_candidate_count": len(memory_hits), - "powercontext.context.build.experience_candidate_count": len(experience_hits), - }, - ) as span: - build = builder.build_result( - request=request, - memory_ref=None if current is None else current.as_ref(), - hits=memory_hits, - experience_hits=experience_hits, - ) - if span is not None: - span.set_attributes({ - "powercontext.context.build.selected_count": len(build.origins), - "powercontext.context.build.status": build.context.status, - "powercontext.context.build.content_bytes": build.context.content_bytes, - }) - if self._runtime._recall_token_estimator is not None: - try: - measurement = await self._runtime._recall_token_estimator(self.scope_id, build) - except Exception as error: - log_safely( - logger, - logging.ERROR, - "Recall token estimation failed", - exc_info=error, - extra={ - "event": "statistics.recall_tokens.estimation_failed", - "outcome": "failure", - "unit": "statistics", - }, - ) - else: - if measurement is not None: - await self._runtime.statistics.for_scope(self.scope_id).record_recall(measurement) - return build.context + return ( + PreparedMemoryCandidates( + scope_id=scope_id, + memory_ref=None if current is None else current.as_ref(), + hits=memory_hits, + ), + PreparedExperienceCandidates(scope_id=scope_id, hits=experience_hits), + ) class ContextApplication: diff --git a/src/powercontext/builtin/runtime/prepared_context.py b/src/powercontext/builtin/runtime/prepared_context.py index a1c3d8189..218dbc2cd 100644 --- a/src/powercontext/builtin/runtime/prepared_context.py +++ b/src/powercontext/builtin/runtime/prepared_context.py @@ -20,7 +20,7 @@ from collections.abc import Sequence from dataclasses import dataclass -from powercontext.artifacts import ArtifactRef +from powercontext.artifacts import ArtifactAddress, ArtifactRef from powercontext.builtin.artifacts.experience import Experience, ExperienceSearchHit, render_experience from powercontext.builtin.artifacts.memory.models import MemoryCitation, MemoryHit from powercontext.builtin.runtime.errors import PreparedContextInvariantError @@ -39,7 +39,7 @@ @dataclass(frozen=True) class _PreparedContextEntry: - origin: MemoryCitation | ArtifactRef + origin: PreparedContextOrigin kind: str citation: dict[str, object] content: str @@ -51,7 +51,36 @@ class PreparedContextBuild: """Final public context and the exact origins selected to produce it.""" context: PreparedContext - origins: tuple[MemoryCitation | ArtifactRef, ...] + origins: tuple[PreparedContextOrigin, ...] + + +@dataclass(frozen=True) +class MemoryEntryAddress: + """Identify one exact Memory entry version across Scope boundaries.""" + + memory: ArtifactAddress + entry_id: str + entry_version_id: str + + +PreparedContextOrigin = MemoryCitation | ArtifactRef | MemoryEntryAddress | ArtifactAddress + + +@dataclass(frozen=True) +class PreparedMemoryCandidates: + """Memory candidates read from one Scope.""" + + scope_id: str + memory_ref: ArtifactRef | None = None + hits: tuple[MemoryHit, ...] = () + + +@dataclass(frozen=True) +class PreparedExperienceCandidates: + """Experience candidates read from one Scope.""" + + scope_id: str + hits: tuple[ExperienceSearchHit, ...] = () class PreparedContextBuilder: @@ -90,15 +119,43 @@ def build_result( hits: Sequence[MemoryHit] = (), experience_hits: Sequence[ExperienceSearchHit] = (), ) -> PreparedContextBuild: - if len(hits) > self.memory_candidate_limit: + return self.build_scopes_result( + request=request, + current_scope_id=None, + memory_candidates=(PreparedMemoryCandidates(scope_id="", memory_ref=memory_ref, hits=tuple(hits)),), + experience_candidates=(PreparedExperienceCandidates(scope_id="", hits=tuple(experience_hits)),), + ) + + def build_scopes_result( + self, + *, + request: PrepareContextRequest, + current_scope_id: str | None, + memory_candidates: Sequence[PreparedMemoryCandidates] = (), + experience_candidates: Sequence[PreparedExperienceCandidates] = (), + ) -> PreparedContextBuild: + if sum(len(candidates.hits) for candidates in memory_candidates) > self.memory_candidate_limit: raise PreparedContextInvariantError("memory-candidate-limit") - if len(experience_hits) > self.experience_candidate_limit: + if sum(len(candidates.hits) for candidates in experience_candidates) > self.experience_candidate_limit: raise PreparedContextInvariantError("experience-candidate-limit") - if hits and memory_ref is None: - raise PreparedContextInvariantError("memory-ref-missing") - memory_entries = self._memory_entries(memory_ref, hits) - experience_entries = self._experience_entries(experience_hits) + memory_entries = tuple( + entry + for candidates in memory_candidates + for entry in self._memory_entries( + candidates.memory_ref, + candidates.hits, + scope_id=None if candidates.scope_id == current_scope_id else candidates.scope_id or None, + ) + ) + experience_entries = tuple( + entry + for candidates in experience_candidates + for entry in self._experience_entries( + candidates.hits, + scope_id=None if candidates.scope_id == current_scope_id else candidates.scope_id or None, + ) + ) entries = self._fit_entries(request, memory_entries, experience_entries) if not entries: @@ -116,7 +173,11 @@ def _memory_entries( self, memory_ref: ArtifactRef | None, hits: Sequence[MemoryHit], + *, + scope_id: str | None = None, ) -> tuple[_PreparedContextEntry, ...]: + if hits and memory_ref is None: + raise PreparedContextInvariantError("memory-ref-missing") memory_entries: list[_PreparedContextEntry] = [] seen: set[tuple[str, str]] = set() for hit in hits: @@ -136,11 +197,25 @@ def _memory_entries( entry_id=hit.entry_id, entry_version_id=hit.entry_version_id, ) + origin: PreparedContextOrigin = citation + rendered_citation = citation.model_dump(mode="json") + if scope_id is not None: + memory = ArtifactAddress(scope_id=scope_id, artifact=hit.memory_ref) + origin = MemoryEntryAddress( + memory=memory, + entry_id=hit.entry_id, + entry_version_id=hit.entry_version_id, + ) + rendered_citation = { + "memory": memory.model_dump(mode="json"), + "entry_id": hit.entry_id, + "entry_version_id": hit.entry_version_id, + } memory_entries.append( _PreparedContextEntry( - origin=citation, + origin=origin, kind="memory", - citation=citation.model_dump(mode="json"), + citation=rendered_citation, content=hit.text, truncated=False, ) @@ -150,6 +225,8 @@ def _memory_entries( def _experience_entries( self, hits: Sequence[ExperienceSearchHit], + *, + scope_id: str | None = None, ) -> tuple[_PreparedContextEntry, ...]: experience_entries: list[_PreparedContextEntry] = [] seen_experiences: set[tuple[str, int]] = set() @@ -162,11 +239,16 @@ def _experience_entries( seen_experiences.add(identity) if len(experience_entries) >= self.experience_entry_limit: break + origin: PreparedContextOrigin = hit.artifact_ref + rendered_citation: dict[str, object] = {"artifact_ref": hit.artifact_ref.model_dump(mode="json")} + if scope_id is not None: + origin = ArtifactAddress(scope_id=scope_id, artifact=hit.artifact_ref) + rendered_citation = {"artifact": origin.model_dump(mode="json")} experience_entries.append( _PreparedContextEntry( - origin=hit.artifact_ref, + origin=origin, kind="experience", - citation={"artifact_ref": hit.artifact_ref.model_dump(mode="json")}, + citation=rendered_citation, content=render_experience(hit.content), truncated=False, ) @@ -199,7 +281,7 @@ def _fit_entry( self, entries: Sequence[_PreparedContextEntry], *, - origin: MemoryCitation | ArtifactRef, + origin: PreparedContextOrigin, kind: str, citation: dict[str, object], text: str, diff --git a/src/powercontext/builtin/runtime/recall.py b/src/powercontext/builtin/runtime/recall.py index b5b2d195c..470bcc1e5 100644 --- a/src/powercontext/builtin/runtime/recall.py +++ b/src/powercontext/builtin/runtime/recall.py @@ -20,13 +20,17 @@ from sqlalchemy.ext.asyncio import AsyncConnection -from powercontext.artifacts import ArtifactRef +from powercontext.artifacts import ArtifactAddress, ArtifactRef from powercontext.builtin.artifacts.memory import MemoryCitation, MemoryService from powercontext.builtin.inference import TokenEstimator from powercontext.builtin.persistence.artifacts import ArtifactRepository from powercontext.builtin.persistence.database import AsyncDatabase from powercontext.builtin.persistence.sources import SourceRepository -from powercontext.builtin.runtime.prepared_context import PreparedContextBuild +from powercontext.builtin.runtime.prepared_context import ( + MemoryEntryAddress, + PreparedContextBuild, + PreparedContextOrigin, +) from powercontext.builtin.sources import ContentSource, ExternalSkillSnapshotSource from powercontext.builtin.statistics import RecallTokenMeasurement from powercontext.sources import Source, SourceRef @@ -39,6 +43,66 @@ def __init__(self, source: Source) -> None: super().__init__(f"no recall token projection is registered for {type(source).__name__}") +SourceAddress = tuple[str, str, str] + + +class _RecallOriginResolver: + def __init__( + self, + *, + connection: AsyncConnection, + current_scope_id: str, + artifacts: ArtifactRepository, + memory_service: Callable[[str, AsyncConnection], MemoryService], + ) -> None: + self._connection = connection + self._current_scope_id = current_scope_id + self._artifacts = artifacts + self._memory_service = memory_service + self._artifact_sources: dict[tuple[str, str, str, int], frozenset[SourceAddress]] = {} + self._resolving_artifacts: set[tuple[str, str, str, int]] = set() + + async def resolve(self, origin: PreparedContextOrigin, /) -> set[SourceAddress]: + if isinstance(origin, MemoryCitation): + return await self._memory(self._current_scope_id, origin) + if isinstance(origin, MemoryEntryAddress): + return await self._memory( + origin.memory.scope_id, + MemoryCitation( + memory_ref=origin.memory.artifact, + entry_id=origin.entry_id, + entry_version_id=origin.entry_version_id, + ), + ) + if isinstance(origin, ArtifactAddress): + return set(await self._artifact(origin.scope_id, origin.artifact)) + return set(await self._artifact(self._current_scope_id, origin)) + + async def _memory(self, scope_id: str, citation: MemoryCitation) -> set[SourceAddress]: + memory = self._memory_service(scope_id, self._connection) + entry = await memory.validate_citation(citation) + sources = _source_identities(scope_id, entry.sources) + for artifact_ref in entry.artifacts: + sources.update(await self._artifact(scope_id, artifact_ref)) + return sources + + async def _artifact(self, scope_id: str, artifact_ref: ArtifactRef) -> frozenset[SourceAddress]: + identity = _artifact_identity(scope_id, artifact_ref) + if identity in self._artifact_sources: + return self._artifact_sources[identity] + if identity in self._resolving_artifacts: + return frozenset() + self._resolving_artifacts.add(identity) + artifact = await self._artifacts.get(self._connection, scope_id, artifact_ref) + resolved = _source_identities(scope_id, artifact.lineage.sources) + for parent in artifact.lineage.artifacts: + resolved.update(await self._artifact(scope_id, parent)) + self._resolving_artifacts.remove(identity) + result = frozenset(resolved) + self._artifact_sources[identity] = result + return result + + class RelationalRecallTokenEstimator: """Resolve exact recall lineage and estimate its token reduction.""" @@ -49,7 +113,7 @@ def __init__( scope_id: str, sources: SourceRepository, artifacts: ArtifactRepository, - memory_service: Callable[[AsyncConnection], MemoryService], + memory_service: Callable[[str, AsyncConnection], MemoryService], estimator: TokenEstimator, ) -> None: self._database = database @@ -60,38 +124,18 @@ def __init__( self._estimator = estimator async def estimate(self, build: PreparedContextBuild, /) -> RecallTokenMeasurement: - source_refs: set[tuple[str, str]] = set() + source_refs: set[tuple[str, str, str]] = set() comparable = build.context.status == "ready" and bool(build.origins) async with self._database.transaction() as connection: - memory = self._memory_service(connection) - artifact_sources: dict[tuple[str, str, int], frozenset[tuple[str, str]]] = {} - resolving_artifacts: set[tuple[str, str, int]] = set() - - async def resolve_artifact(artifact_ref: ArtifactRef) -> frozenset[tuple[str, str]]: - identity = _artifact_identity(artifact_ref) - if identity in artifact_sources: - return artifact_sources[identity] - if identity in resolving_artifacts: - return frozenset() - resolving_artifacts.add(identity) - artifact = await self._artifacts.get(connection, self._scope_id, artifact_ref) - resolved = _source_identities(artifact.lineage.sources) - for parent in artifact.lineage.artifacts: - resolved.update(await resolve_artifact(parent)) - resolving_artifacts.remove(identity) - result = frozenset(resolved) - artifact_sources[identity] = result - return result - + resolver = _RecallOriginResolver( + connection=connection, + current_scope_id=self._scope_id, + artifacts=self._artifacts, + memory_service=self._memory_service, + ) for origin in build.origins: - if isinstance(origin, MemoryCitation): - entry = await memory.validate_citation(origin) - origin_sources = _source_identities(entry.sources) - for artifact_ref in entry.artifacts: - origin_sources.update(await resolve_artifact(artifact_ref)) - else: - origin_sources = set(await resolve_artifact(origin)) + origin_sources = await resolver.resolve(origin) comparable = comparable and bool(origin_sources) source_refs.update(origin_sources) @@ -105,10 +149,13 @@ async def resolve_artifact(artifact_ref: ArtifactRef) -> frozenset[tuple[str, st ) texts = [] - for source_type, source_id in sorted(source_refs, key=lambda ref: (ref[0].encode(), ref[1].encode())): + for scope_id, source_type, source_id in sorted( + source_refs, + key=lambda ref: (ref[0].encode(), ref[1].encode(), ref[2].encode()), + ): stored = await self._sources.get( connection, - self._scope_id, + scope_id, SourceRef(source_type=source_type, source_id=source_id), ) texts.append(_source_text(stored.value)) @@ -123,12 +170,12 @@ async def resolve_artifact(artifact_ref: ArtifactRef) -> frozenset[tuple[str, st ) -def _source_identities(sources: tuple[SourceRef, ...], /) -> set[tuple[str, str]]: - return {(source.source_type, source.source_id) for source in sources} +def _source_identities(scope_id: str, sources: tuple[SourceRef, ...], /) -> set[SourceAddress]: + return {(scope_id, source.source_type, source.source_id) for source in sources} -def _artifact_identity(artifact: ArtifactRef, /) -> tuple[str, str, int]: - return artifact.family, artifact.artifact_id, artifact.revision +def _artifact_identity(scope_id: str, artifact: ArtifactRef, /) -> tuple[str, str, str, int]: + return scope_id, artifact.family, artifact.artifact_id, artifact.revision def _source_text(source: Source, /) -> str: diff --git a/src/powercontext/builtin/runtime/relational.py b/src/powercontext/builtin/runtime/relational.py index cf6011afb..98a247a2e 100644 --- a/src/powercontext/builtin/runtime/relational.py +++ b/src/powercontext/builtin/runtime/relational.py @@ -18,7 +18,7 @@ import asyncio from collections.abc import Callable -from dataclasses import dataclass +from dataclasses import dataclass, replace from typing import Any, cast from uuid import uuid4 @@ -268,9 +268,10 @@ def recall_tokens(self) -> RelationalRecallTokenEstimator | None: if self.token_estimator is None: return None - def memory_service(connection: AsyncConnection) -> MemoryService: - _, source_catalog = self.sources(connection) - return self.memory(source_catalog, connection) + def memory_service(scope_id: str, connection: AsyncConnection) -> MemoryService: + services = self if scope_id == self.scope_id else replace(self, scope_id=scope_id) + _, source_catalog = services.sources(connection) + return services.memory(source_catalog, connection) return RelationalRecallTokenEstimator( database=self.database, diff --git a/tests/builtin/runtime/test_prepared_context.py b/tests/builtin/runtime/test_prepared_context.py index 44fad2374..25f242dd9 100644 --- a/tests/builtin/runtime/test_prepared_context.py +++ b/tests/builtin/runtime/test_prepared_context.py @@ -25,13 +25,20 @@ from powercontext.builtin.artifacts.memory import MemoryHit from powercontext.builtin.runtime import PrepareContextRequest from powercontext.builtin.runtime.errors import PreparedContextInvariantError -from powercontext.builtin.runtime.prepared_context import PreparedContextBuilder +from powercontext.builtin.runtime.prepared_context import ( + PreparedContextBuilder, + PreparedExperienceCandidates, + PreparedMemoryCandidates, +) MEMORY_REF = ArtifactRef(family="memory", artifact_id="memory", revision=3) -class _PreparedCitation(TypedDict): +class _PreparedCitation(TypedDict, total=False): entry_id: str + memory_ref: object + memory: object + artifact: object class _PreparedItem(TypedDict): @@ -173,6 +180,34 @@ def test_builder_prepares_experience_without_a_memory_head_and_keeps_v1_envelope assert item["content"].endswith("Lesson: Regenerate and inspect the client before contract tests.") +def test_builder_qualifies_only_cross_scope_citations() -> None: + builder = PreparedContextBuilder() + prepared = builder.build_scopes_result( + request=PrepareContextRequest(query="shared evidence"), + current_scope_id="current", + memory_candidates=( + PreparedMemoryCandidates(scope_id="current", memory_ref=MEMORY_REF, hits=(_hit("local", "Local"),)), + PreparedMemoryCandidates(scope_id="shared", memory_ref=MEMORY_REF, hits=(_hit("shared", "Shared"),)), + ), + experience_candidates=(PreparedExperienceCandidates(scope_id="shared", hits=(_experience_hit(),)),), + ).context + + local, experience, shared = _items(prepared.content) + assert local["citation"]["memory_ref"] == MEMORY_REF.model_dump(mode="json") + assert shared["citation"]["memory"] == { + "scope_id": "shared", + "artifact": MEMORY_REF.model_dump(mode="json"), + } + assert experience["citation"]["artifact"] == { + "scope_id": "shared", + "artifact": { + "family": "experience", + "artifact_id": "experience-1", + "revision": 1, + }, + } + + def test_builder_keeps_memory_primary_and_bounds_experience_share() -> None: experiences = tuple(_experience_hit(f"experience-{index}") for index in range(1, 5)) prepared = PreparedContextBuilder().build( diff --git a/tests/e2e/test_builtin_runtime.py b/tests/e2e/test_builtin_runtime.py index b18f47584..8591c3098 100644 --- a/tests/e2e/test_builtin_runtime.py +++ b/tests/e2e/test_builtin_runtime.py @@ -28,6 +28,7 @@ SearchMemoryRequest, open_builtin_runtime, ) +from powercontext.builtin.scope import ScopeDraft, ScopeMutation from powercontext.builtin.sources import ContentSource @@ -92,6 +93,80 @@ async def scenario() -> None: asyncio.run(scenario()) +def test_prepare_context_reads_only_direct_context_references() -> None: + async def scenario() -> None: + async with open_builtin_runtime(BuiltinConfig(database=SQLiteConfig())) as runtime: + assert runtime.scopes is not None + shared = await runtime.scopes.create( + ScopeDraft(title="Shared", summary="Reusable evidence", idempotency_key="shared") + ) + middle = await runtime.scopes.create( + ScopeDraft( + title="Middle", + summary="Reads shared evidence", + context_references=(shared.scope_id,), + idempotency_key="middle", + ) + ) + reader = await runtime.scopes.create( + ScopeDraft( + title="Reader", + summary="Reads middle only", + context_references=(middle.scope_id,), + idempotency_key="reader", + ) + ) + child = await runtime.scopes.create( + ScopeDraft( + title="Child", + summary="Organized under shared", + parent_scope_id=shared.scope_id, + idempotency_key="child", + ) + ) + await runtime.memory.for_scope(shared.scope_id).remember( + RememberMemoryRequest(entries=(MemoryEntryInput(kind="fact", text="Shared direct context evidence."),)) + ) + + direct = await runtime.context.for_scope(middle.scope_id).prepare( + PrepareContextRequest(query="direct context evidence") + ) + transitive = await runtime.context.for_scope(reader.scope_id).prepare( + PrepareContextRequest(query="direct context evidence") + ) + reverse = await runtime.context.for_scope(shared.scope_id).prepare( + PrepareContextRequest(query="unrelated reverse evidence") + ) + parent_only = await runtime.context.for_scope(child.scope_id).prepare( + PrepareContextRequest(query="direct context evidence") + ) + + assert direct.status == "ready" + assert direct.content is not None + item = json.loads(direct.content.splitlines()[-2])["items"][0] + assert item["citation"]["memory"]["scope_id"] == shared.scope_id + assert transitive.status == "empty" + assert reverse.status == "empty" + assert parent_only.status == "empty" + + updated = await runtime.scopes.update( + reader.scope_id, + ScopeMutation( + expected_version=reader.version, + title=reader.title, + summary=reader.summary, + context_references=(shared.scope_id,), + ), + ) + assert updated.context_references == (shared.scope_id,) + now_direct = await runtime.context.for_scope(reader.scope_id).prepare( + PrepareContextRequest(query="direct context evidence") + ) + assert now_direct.status == "ready" + + asyncio.run(scenario()) + + class _ConcurrentReranker: policy_id = "test.concurrent-rerank.v1" diff --git a/tests/test_artifacts.py b/tests/test_artifacts.py index 7d008aafa..90f70956a 100644 --- a/tests/test_artifacts.py +++ b/tests/test_artifacts.py @@ -23,6 +23,7 @@ from powercontext import ArtifactFamilyMismatchError from powercontext.artifacts import ( Artifact, + ArtifactAddress, ArtifactCatalog, ArtifactDraft, ArtifactLineage, @@ -64,6 +65,18 @@ def test_artifact_is_a_fixed_family_snapshot_with_direct_lineage() -> None: assert artifact.lineage == ArtifactLineage(sources=(source,), artifacts=(dependency,)) +def test_artifact_address_adds_scope_only_at_cross_scope_boundaries() -> None: + artifact = ArtifactRef(family="memory", artifact_id="memory", revision=3) + + address = ArtifactAddress(scope_id="scope-a", artifact=artifact) + + assert address.artifact is artifact + assert address.model_dump(mode="json") == { + "scope_id": "scope-a", + "artifact": {"family": "memory", "artifact_id": "memory", "revision": 3}, + } + + @pytest.mark.parametrize( ("family", "artifact_id", "revision", "field"), [ From bc028a2b90e9161945a054d98c18ff87c77869a1 Mon Sep 17 00:00:00 2001 From: Chojan Shang Date: Thu, 27 Aug 2026 15:54:55 +0800 Subject: [PATCH 05/15] feat(artifact): publish exact revisions across scopes --- .../plugins/powercontext/hooks/bind_tools.py | 4 + .../skills/project-context/SKILL.md | 9 + .../powercontext/src/operations.generated.ts | 1 + .../powercontext/src/operations.generated.ts | 1 + .../powercontext/src/operations.generated.ts | 1 + openapi/powercontext.yaml | 67 ++++++ .../builtin/persistence/artifacts.py | 31 +++ .../builtin/persistence/tables.py | 37 +++ src/powercontext/builtin/publication.py | 214 ++++++++++++++++++ .../builtin/runtime/application.py | 3 + .../builtin/runtime/composition.py | 1 + .../builtin/runtime/relational.py | 6 + src/powercontext/client/client.py | 8 + src/powercontext/http/__init__.py | 6 + src/powercontext/http/_generated/models.py | 26 +++ .../http/_generated/operations.py | 21 ++ src/powercontext/http/_generated/schema.py | 54 +++++ src/powercontext/server/app.py | 47 +++- src/powercontext/server/mcp.py | 2 + tests/builtin/test_publication.py | 88 +++++++ tests/codex_plugin/test_scope_binding.py | 34 +++ tests/test_mcp.py | 1 + tests/test_scope_api.py | 38 ++++ 23 files changed, 699 insertions(+), 1 deletion(-) create mode 100644 src/powercontext/builtin/publication.py create mode 100644 tests/builtin/test_publication.py diff --git a/integrations/codex/plugins/powercontext/hooks/bind_tools.py b/integrations/codex/plugins/powercontext/hooks/bind_tools.py index f71d85148..4776d4ce5 100644 --- a/integrations/codex/plugins/powercontext/hooks/bind_tools.py +++ b/integrations/codex/plugins/powercontext/hooks/bind_tools.py @@ -25,6 +25,7 @@ _PREFIX = "mcp__powercontext__" _CONTROL_OPERATIONS = frozenset({"set_scope_binding", "clear_scope_binding"}) +_HOST_OPERATIONS = frozenset({"create_scope", "get_scope", "list_scopes", "publish_artifact"}) _SCOPE_BOUND_OPERATIONS = frozenset({ "acknowledge_handoff", "activate_handoff", @@ -70,6 +71,9 @@ def main(settings: CodexPluginSettings | None = None) -> int: updated["key"] = session_binding_key(session_id) _allow(updated) return 0 + if operation in _HOST_OPERATIONS: + _allow(dict(tool_input)) + return 0 if operation not in _SCOPE_BOUND_OPERATIONS: return 0 settings = CodexPluginSettings() if settings is None else settings diff --git a/integrations/codex/plugins/powercontext/skills/project-context/SKILL.md b/integrations/codex/plugins/powercontext/skills/project-context/SKILL.md index a7af92f9b..4f1bae955 100644 --- a/integrations/codex/plugins/powercontext/skills/project-context/SKILL.md +++ b/integrations/codex/plugins/powercontext/skills/project-context/SKILL.md @@ -27,6 +27,15 @@ Then use `set_scope_binding`; the integration replaces its binding key with the current Codex Session identity. Reuse an existing Scope instead when the work does not need independent isolation, continuation, delivery, or observation. +## Deliver selected material + +Use `publish_artifact` only when the user has selected an exact Artifact +revision for delivery into another Scope. Supply the complete source address, +the target Scope, and a stable idempotency key. Publication creates an +independent target Artifact and does not move Sources, other revisions, or +other state from the source Scope. Never publish personal information, +debugging fragments, rejected results, or an inferred `latest` revision. + ## Read - Use `search_memory` with a focused query, `mode: "auto"`, and no more than diff --git a/integrations/dsh/plugins/powercontext/src/operations.generated.ts b/integrations/dsh/plugins/powercontext/src/operations.generated.ts index 0e6ab4383..4a87fc46b 100644 --- a/integrations/dsh/plugins/powercontext/src/operations.generated.ts +++ b/integrations/dsh/plugins/powercontext/src/operations.generated.ts @@ -22,6 +22,7 @@ export const OPERATIONS = { get_capabilities: { method: 'GET', path: '/v1/capabilities', location: null, scope: false }, list_scopes: { method: 'GET', path: '/v1/scopes', location: null, scope: false }, create_scope: { method: 'POST', path: '/v1/scopes', location: "body", scope: false }, + publish_artifact: { method: 'POST', path: '/v1/artifact-publications', location: "body", scope: false }, get_scope: { method: 'POST', path: '/v1/scopes/get', location: "body", scope: true }, update_scope: { method: 'POST', path: '/v1/scopes/update', location: "body", scope: true }, get_default_scope: { method: 'GET', path: '/v1/scopes/default', location: null, scope: false }, diff --git a/integrations/opencode/plugins/powercontext/src/operations.generated.ts b/integrations/opencode/plugins/powercontext/src/operations.generated.ts index 0e6ab4383..4a87fc46b 100644 --- a/integrations/opencode/plugins/powercontext/src/operations.generated.ts +++ b/integrations/opencode/plugins/powercontext/src/operations.generated.ts @@ -22,6 +22,7 @@ export const OPERATIONS = { get_capabilities: { method: 'GET', path: '/v1/capabilities', location: null, scope: false }, list_scopes: { method: 'GET', path: '/v1/scopes', location: null, scope: false }, create_scope: { method: 'POST', path: '/v1/scopes', location: "body", scope: false }, + publish_artifact: { method: 'POST', path: '/v1/artifact-publications', location: "body", scope: false }, get_scope: { method: 'POST', path: '/v1/scopes/get', location: "body", scope: true }, update_scope: { method: 'POST', path: '/v1/scopes/update', location: "body", scope: true }, get_default_scope: { method: 'GET', path: '/v1/scopes/default', location: null, scope: false }, diff --git a/integrations/pi/plugins/powercontext/src/operations.generated.ts b/integrations/pi/plugins/powercontext/src/operations.generated.ts index 0e6ab4383..4a87fc46b 100644 --- a/integrations/pi/plugins/powercontext/src/operations.generated.ts +++ b/integrations/pi/plugins/powercontext/src/operations.generated.ts @@ -22,6 +22,7 @@ export const OPERATIONS = { get_capabilities: { method: 'GET', path: '/v1/capabilities', location: null, scope: false }, list_scopes: { method: 'GET', path: '/v1/scopes', location: null, scope: false }, create_scope: { method: 'POST', path: '/v1/scopes', location: "body", scope: false }, + publish_artifact: { method: 'POST', path: '/v1/artifact-publications', location: "body", scope: false }, get_scope: { method: 'POST', path: '/v1/scopes/get', location: "body", scope: true }, update_scope: { method: 'POST', path: '/v1/scopes/update', location: "body", scope: true }, get_default_scope: { method: 'GET', path: '/v1/scopes/default', location: null, scope: false }, diff --git a/openapi/powercontext.yaml b/openapi/powercontext.yaml index 4641765ee..a170b79bd 100644 --- a/openapi/powercontext.yaml +++ b/openapi/powercontext.yaml @@ -120,6 +120,32 @@ paths: $ref: "#/components/responses/Unauthorized" "422": $ref: "#/components/responses/InvalidRequest" + /v1/artifact-publications: + post: + tags: [scopes] + summary: Publish one exact Artifact revision into another Scope + operationId: publish_artifact + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/PublishArtifactRequest" + responses: + "201": + description: Independent target Artifact and its exact source provenance. + content: + application/json: + schema: + $ref: "#/components/schemas/ArtifactPublication" + "404": + $ref: "#/components/responses/NotFound" + "409": + $ref: "#/components/responses/Conflict" + "401": + $ref: "#/components/responses/Unauthorized" + "422": + $ref: "#/components/responses/InvalidRequest" /v1/scopes/get: post: tags: [scopes] @@ -2037,6 +2063,47 @@ components: revision: type: integer minimum: 1 + ArtifactAddress: + type: object + additionalProperties: false + required: [scope_id, artifact] + properties: + scope_id: + type: string + minLength: 1 + maxLength: 256 + pattern: '.*\S.*' + artifact: + $ref: "#/components/schemas/ArtifactReference" + PublishArtifactRequest: + type: object + additionalProperties: false + required: [source, target_scope_id, idempotency_key] + properties: + source: + $ref: "#/components/schemas/ArtifactAddress" + target_scope_id: + type: string + minLength: 1 + maxLength: 256 + pattern: '.*\S.*' + idempotency_key: + type: string + minLength: 1 + maxLength: 256 + pattern: '.*\S.*' + ArtifactPublication: + type: object + additionalProperties: false + required: [source, target, content_digest] + properties: + source: + $ref: "#/components/schemas/ArtifactAddress" + target: + $ref: "#/components/schemas/ArtifactAddress" + content_digest: + type: string + pattern: '^[0-9a-f]{64}$' ScopeExternalReference: type: object additionalProperties: false diff --git a/src/powercontext/builtin/persistence/artifacts.py b/src/powercontext/builtin/persistence/artifacts.py index 0e65140df..5693630a9 100644 --- a/src/powercontext/builtin/persistence/artifacts.py +++ b/src/powercontext/builtin/persistence/artifacts.py @@ -240,6 +240,37 @@ async def revisions( revisions.append(await self._decode_row(connection, row)) return tuple(revisions) + async def copy_exact( + self, + connection: AsyncConnection, + target_scope_id: str, + target_artifact_id: str, + source: Artifact[Any], + /, + ) -> Artifact[Any]: + """Create an independent target lifecycle from one exact source revision.""" + + _require_scope(target_scope_id) + artifact_type = self._artifact_type(source.family) + ref = ArtifactRef(family=source.family, artifact_id=target_artifact_id, revision=1) + copied = await self._insert_revision( + connection, + target_scope_id, + artifact_type, + ref, + source.content, + ArtifactLineage(), + ) + await connection.execute( + insert(ARTIFACT_HEADS_TABLE).values( + scope_id=target_scope_id, + family=ref.family, + artifact_id=ref.artifact_id, + revision=ref.revision, + ) + ) + return copied + async def _insert_revision( self, connection: AsyncConnection, diff --git a/src/powercontext/builtin/persistence/tables.py b/src/powercontext/builtin/persistence/tables.py index aad6b20a3..fc59eed03 100644 --- a/src/powercontext/builtin/persistence/tables.py +++ b/src/powercontext/builtin/persistence/tables.py @@ -268,6 +268,42 @@ def _entry_text_type(): ), ) +ARTIFACT_PUBLICATIONS_TABLE = Table( + "pc_artifact_publications", + SHARED_METADATA, + Column("target_scope_id", identity_string(MAX_SCOPE_ID_LENGTH), primary_key=True), + Column("target_family", identity_string(MAX_ARTIFACT_FAMILY_LENGTH), primary_key=True), + Column("target_artifact_id", identity_string(MAX_ARTIFACT_ID_LENGTH), primary_key=True), + Column("target_revision", Integer, primary_key=True), + Column("source_scope_id", identity_string(MAX_SCOPE_ID_LENGTH), nullable=False), + Column("source_family", identity_string(MAX_ARTIFACT_FAMILY_LENGTH), nullable=False), + Column("source_artifact_id", identity_string(MAX_ARTIFACT_ID_LENGTH), nullable=False), + Column("source_revision", Integer, nullable=False), + Column("content_digest", identity_string(64), nullable=False), + Column("idempotency_key", identity_string(MAX_SCOPE_IDEMPOTENCY_KEY_LENGTH), nullable=False), + ForeignKeyConstraint( + ("target_scope_id", "target_family", "target_artifact_id", "target_revision"), + ( + "pc_artifacts.scope_id", + "pc_artifacts.family", + "pc_artifacts.artifact_id", + "pc_artifacts.revision", + ), + ondelete="RESTRICT", + ), + ForeignKeyConstraint( + ("source_scope_id", "source_family", "source_artifact_id", "source_revision"), + ( + "pc_artifacts.scope_id", + "pc_artifacts.family", + "pc_artifacts.artifact_id", + "pc_artifacts.revision", + ), + ondelete="RESTRICT", + ), + UniqueConstraint("target_scope_id", "idempotency_key", name="uq_pc_artifact_publications_request"), +) + ARTIFACT_CANDIDATE_VERSIONS_TABLE = Table( "pc_artifact_candidate_versions", SHARED_METADATA, @@ -436,6 +472,7 @@ def _entry_text_type(): ARTIFACT_HEADS_TABLE, ARTIFACT_LINEAGE_SOURCES_TABLE, ARTIFACT_LINEAGE_ARTIFACTS_TABLE, + ARTIFACT_PUBLICATIONS_TABLE, ARTIFACT_CANDIDATE_VERSIONS_TABLE, ARTIFACT_CANDIDATE_HEADS_TABLE, SOURCE_CURSORS_TABLE, diff --git a/src/powercontext/builtin/publication.py b/src/powercontext/builtin/publication.py new file mode 100644 index 000000000..984a15ba4 --- /dev/null +++ b/src/powercontext/builtin/publication.py @@ -0,0 +1,214 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Exact Artifact delivery across Scope ownership boundaries.""" + +from __future__ import annotations + +import hashlib +import secrets +from collections.abc import Callable, Mapping +from typing import Any + +from pydantic import BaseModel, ConfigDict, field_validator, model_validator +from sqlalchemy import insert, select +from sqlalchemy.ext.asyncio import AsyncConnection + +from powercontext.artifacts import ArtifactAddress, ArtifactRef +from powercontext.builtin.persistence.artifacts import ArtifactRepository +from powercontext.builtin.persistence.codec import dump_model +from powercontext.builtin.persistence.database import AsyncDatabase +from powercontext.builtin.persistence.tables import ARTIFACT_PUBLICATIONS_TABLE +from powercontext.builtin.scope import ScopeApplication +from powercontext.limits import MAX_SCOPE_IDEMPOTENCY_KEY_LENGTH + +PublicationIdFactory = Callable[[], str] +_CROCKFORD = "0123456789abcdefghjkmnpqrstvwxyz" + + +class ArtifactPublicationRequest(BaseModel): + """Select one exact source revision for delivery into a target Scope.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + + source: ArtifactAddress + target_scope_id: str + idempotency_key: str + + @field_validator("target_scope_id", "idempotency_key") + @classmethod + def validate_text(cls, value: str, info) -> str: + if not value.strip() or value != value.strip(): + raise ValueError(f"{info.field_name} must be non-empty and trimmed") # noqa: TRY003 + if info.field_name == "idempotency_key" and len(value) > MAX_SCOPE_IDEMPOTENCY_KEY_LENGTH: + raise ValueError( # noqa: TRY003 + f"idempotency_key must not exceed {MAX_SCOPE_IDEMPOTENCY_KEY_LENGTH} characters" + ) + return value + + @model_validator(mode="after") + def require_scope_boundary(self) -> ArtifactPublicationRequest: + if self.source.scope_id == self.target_scope_id: + raise ValueError("publication requires different source and target Scopes") # noqa: TRY003 + return self + + +class ArtifactPublication(BaseModel): + """The immutable source and target addresses created by one publication.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + + source: ArtifactAddress + target: ArtifactAddress + content_digest: str + + +class ArtifactPublicationConflictError(RuntimeError): + """Report reuse of an idempotency key for a different publication.""" + + +class ArtifactPublicationApplication: + def __init__( + self, + database: AsyncDatabase, + artifacts: ArtifactRepository, + scopes: ScopeApplication, + *, + id_factory: PublicationIdFactory | None = None, + ) -> None: + self._database = database + self._artifacts = artifacts + self._scopes = scopes + self._id_factory = generate_publication_artifact_id if id_factory is None else id_factory + + async def publish(self, request: ArtifactPublicationRequest, /) -> ArtifactPublication: + await self._scopes.get(request.source.scope_id) + await self._scopes.get(request.target_scope_id) + async with self._database.transaction() as connection: + existing = await self._find_request(connection, request.target_scope_id, request.idempotency_key) + if existing is not None: + if existing.source != request.source: + raise ArtifactPublicationConflictError(request.idempotency_key) + return existing + + source = await self._artifacts.get(connection, request.source.scope_id, request.source.artifact) + target_artifact_id = self._id_factory() + target = await self._artifacts.copy_exact( + connection, + request.target_scope_id, + target_artifact_id, + source, + ) + publication = ArtifactPublication( + source=request.source, + target=ArtifactAddress(scope_id=request.target_scope_id, artifact=target.as_ref()), + content_digest=hashlib.sha256( + dump_model(source.content, kind="artifact", name=source.family) + ).hexdigest(), + ) + await connection.execute( + insert(ARTIFACT_PUBLICATIONS_TABLE).values( + **_publication_row(publication), + idempotency_key=request.idempotency_key, + ) + ) + return publication + + async def get(self, target: ArtifactAddress, /) -> ArtifactPublication | None: + async with self._database.transaction() as connection: + row = ( + ( + await connection.execute( + select(ARTIFACT_PUBLICATIONS_TABLE).where( + ARTIFACT_PUBLICATIONS_TABLE.c.target_scope_id == target.scope_id, + ARTIFACT_PUBLICATIONS_TABLE.c.target_family == target.artifact.family, + ARTIFACT_PUBLICATIONS_TABLE.c.target_artifact_id == target.artifact.artifact_id, + ARTIFACT_PUBLICATIONS_TABLE.c.target_revision == target.artifact.revision, + ) + ) + ) + .mappings() + .one_or_none() + ) + return None if row is None else _decode_publication(row) + + async def _find_request( + self, + connection: AsyncConnection, + target_scope_id: str, + idempotency_key: str, + ) -> ArtifactPublication | None: + row = ( + ( + await connection.execute( + select(ARTIFACT_PUBLICATIONS_TABLE).where( + ARTIFACT_PUBLICATIONS_TABLE.c.target_scope_id == target_scope_id, + ARTIFACT_PUBLICATIONS_TABLE.c.idempotency_key == idempotency_key, + ) + ) + ) + .mappings() + .one_or_none() + ) + return None if row is None else _decode_publication(row) + + +def generate_publication_artifact_id() -> str: + value = int.from_bytes(secrets.token_bytes(16), "big") + encoded = "".join(_CROCKFORD[(value >> shift) & 31] for shift in range(125, -1, -5)) + return f"pub_{encoded}" + + +def _publication_row(publication: ArtifactPublication) -> dict[str, object]: + return { + "target_scope_id": publication.target.scope_id, + "target_family": publication.target.artifact.family, + "target_artifact_id": publication.target.artifact.artifact_id, + "target_revision": publication.target.artifact.revision, + "source_scope_id": publication.source.scope_id, + "source_family": publication.source.artifact.family, + "source_artifact_id": publication.source.artifact.artifact_id, + "source_revision": publication.source.artifact.revision, + "content_digest": publication.content_digest, + } + + +def _decode_publication(row: Mapping[Any, Any]) -> ArtifactPublication: + return ArtifactPublication( + source=ArtifactAddress( + scope_id=str(row["source_scope_id"]), + artifact=ArtifactRef( + family=str(row["source_family"]), + artifact_id=str(row["source_artifact_id"]), + revision=int(row["source_revision"]), + ), + ), + target=ArtifactAddress( + scope_id=str(row["target_scope_id"]), + artifact=ArtifactRef( + family=str(row["target_family"]), + artifact_id=str(row["target_artifact_id"]), + revision=int(row["target_revision"]), + ), + ), + content_digest=str(row["content_digest"]), + ) + + +__all__ = [ + "ArtifactPublication", + "ArtifactPublicationApplication", + "ArtifactPublicationConflictError", + "ArtifactPublicationRequest", +] diff --git a/src/powercontext/builtin/runtime/application.py b/src/powercontext/builtin/runtime/application.py index c5112c8f9..dd2e24af1 100644 --- a/src/powercontext/builtin/runtime/application.py +++ b/src/powercontext/builtin/runtime/application.py @@ -71,6 +71,7 @@ from powercontext.builtin.context import BuiltinArtifacts, BuiltinSources from powercontext.builtin.inference.models import InferenceUsage from powercontext.builtin.inference.usage import bind_usage_reporter +from powercontext.builtin.publication import ArtifactPublicationApplication from powercontext.builtin.review.generation import GeneratedCandidateResult, ReviewedGenerationService from powercontext.builtin.review.service import ReviewService from powercontext.builtin.runtime._scope_cache import ( @@ -1243,6 +1244,7 @@ def __init__( external_skill_importer: ExternalSkillImporter | None = None, statistics_service: StatisticsServiceFactory | None = None, recall_token_estimator: RecallTokenEstimator | None = None, + publication_application: ArtifactPublicationApplication | None = None, scope_application: ScopeApplication | None = None, readiness: RuntimeReadinessChecks | None = None, clock: Clock | None = None, @@ -1262,6 +1264,7 @@ def __init__( self._external_skill_importer = external_skill_importer self._statistics_service = statistics_service self._recall_token_estimator = recall_token_estimator + self.publications = publication_application self.scopes = scope_application self._readiness = RuntimeReadinessChecks() if readiness is None else readiness self._clock = _utc_now if clock is None else clock diff --git a/src/powercontext/builtin/runtime/composition.py b/src/powercontext/builtin/runtime/composition.py index d4365256a..59f9dd132 100644 --- a/src/powercontext/builtin/runtime/composition.py +++ b/src/powercontext/builtin/runtime/composition.py @@ -279,6 +279,7 @@ async def open_builtin_runtime( external_skill_importer=contexts.import_external_skill if contexts.external_skill_registry else None, statistics_service=contexts.statistics, recall_token_estimator=contexts.estimate_recall_tokens, + publication_application=contexts.publications, scope_application=contexts.scopes, readiness=RuntimeReadinessChecks(readiness_probes), tracing=tracing, diff --git a/src/powercontext/builtin/runtime/relational.py b/src/powercontext/builtin/runtime/relational.py index 98a247a2e..859981121 100644 --- a/src/powercontext/builtin/runtime/relational.py +++ b/src/powercontext/builtin/runtime/relational.py @@ -77,6 +77,7 @@ from powercontext.builtin.persistence.sources import SourceRepository, StoredSource from powercontext.builtin.persistence.statistics import StatisticsRepository from powercontext.builtin.persistence.tables import ARTIFACT_HEADS_TABLE, SOURCE_JOURNAL_HEADS_TABLE +from powercontext.builtin.publication import ArtifactPublicationApplication from powercontext.builtin.review.generation import ( GeneratedCandidateResult, GenerationCapabilityUnavailableError, @@ -321,6 +322,11 @@ def __init__( external_skills=ExternalSkillRepository(), statistics=StatisticsRepository(), ) + self.publications = ArtifactPublicationApplication( + database, + self.repositories.artifacts, + self.scopes, + ) self._candidate_pipeline = candidate_pipeline self.memory_extraction = candidate_pipeline is not None self._experience_pipeline = experience_pipeline diff --git a/src/powercontext/client/client.py b/src/powercontext/client/client.py index 2c1618f2f..5ab056e08 100644 --- a/src/powercontext/client/client.py +++ b/src/powercontext/client/client.py @@ -31,6 +31,7 @@ ApproveArtifactCandidateRequest, ArtifactCandidate, ArtifactCandidatePage, + ArtifactPublication, AttachHandoffReportWorkspaceRequest, Capabilities, CaptureContentSourceRequest, @@ -91,6 +92,7 @@ ProjectPage, ProposeExperienceRequest, ProposeSkillRequest, + PublishArtifactRequest, PurgeHandoffReportActivitiesRequest, PurgeHandoffReportActivitiesResponse, ReadinessResponse, @@ -156,6 +158,7 @@ PREPARE_HANDOFF, PROPOSE_EXPERIENCE, PROPOSE_SKILL, + PUBLISH_ARTIFACT, PURGE_HANDOFF_REPORT_ACTIVITIES, RECORD_HANDOFF_REPORT_ACTIVITY, RECORD_TASK_OUTCOME, @@ -246,6 +249,11 @@ async def get_capabilities(self) -> Capabilities: return await self._request(GET_CAPABILITIES) + async def publish_artifact(self, request: PublishArtifactRequest) -> ArtifactPublication: + """Deliver one exact Artifact revision into another Scope.""" + + return await self._request(PUBLISH_ARTIFACT, request) + async def get_stats(self, request: GetStatsRequest) -> ScopedStats: """Read current inventory and bounded usage for one scope.""" diff --git a/src/powercontext/http/__init__.py b/src/powercontext/http/__init__.py index d66aa9db9..cf16d200d 100644 --- a/src/powercontext/http/__init__.py +++ b/src/powercontext/http/__init__.py @@ -18,9 +18,11 @@ AcknowledgeHandoffRequest, ActivateHandoffRequest, ApproveArtifactCandidateRequest, + ArtifactAddress, ArtifactCandidate, ArtifactCandidatePage, ArtifactInventoryStatistics, + ArtifactPublication, ArtifactReference, AttachHandoffReportWorkspaceRequest, CandidateFamily, @@ -143,6 +145,7 @@ ProjectPage, ProposeExperienceRequest, ProposeSkillRequest, + PublishArtifactRequest, PurgeHandoffReportActivitiesRequest, PurgeHandoffReportActivitiesResponse, ReadinessResponse, @@ -213,9 +216,11 @@ "AcknowledgeHandoffRequest", "ActivateHandoffRequest", "ApproveArtifactCandidateRequest", + "ArtifactAddress", "ArtifactCandidate", "ArtifactCandidatePage", "ArtifactInventoryStatistics", + "ArtifactPublication", "ArtifactReference", "AttachHandoffReportWorkspaceRequest", "CandidateFamily", @@ -338,6 +343,7 @@ "ProjectPage", "ProposeExperienceRequest", "ProposeSkillRequest", + "PublishArtifactRequest", "PurgeHandoffReportActivitiesRequest", "PurgeHandoffReportActivitiesResponse", "ReadinessResponse", diff --git a/src/powercontext/http/_generated/models.py b/src/powercontext/http/_generated/models.py index a4328b164..04f4401d8 100644 --- a/src/powercontext/http/_generated/models.py +++ b/src/powercontext/http/_generated/models.py @@ -30,6 +30,32 @@ class ArtifactReference(BaseModel): revision: Annotated[StrictInt, Field(ge=1)] +class ArtifactAddress(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + scope_id: Annotated[StrictStr, Field(max_length=256, min_length=1, pattern=".*\\S.*")] + artifact: ArtifactReference + + +class PublishArtifactRequest(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + source: ArtifactAddress + target_scope_id: Annotated[StrictStr, Field(max_length=256, min_length=1, pattern=".*\\S.*")] + idempotency_key: Annotated[StrictStr, Field(max_length=256, min_length=1, pattern=".*\\S.*")] + + +class ArtifactPublication(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + source: ArtifactAddress + target: ArtifactAddress + content_digest: Annotated[StrictStr, Field(pattern="^[0-9a-f]{64}$")] + + class ScopeExternalReference(BaseModel): model_config = ConfigDict( extra="forbid", diff --git a/src/powercontext/http/_generated/operations.py b/src/powercontext/http/_generated/operations.py index 0c59cf69e..f48ff8e2a 100644 --- a/src/powercontext/http/_generated/operations.py +++ b/src/powercontext/http/_generated/operations.py @@ -12,6 +12,7 @@ ApproveArtifactCandidateRequest, ArtifactCandidate, ArtifactCandidatePage, + ArtifactPublication, AttachHandoffReportWorkspaceRequest, Capabilities, CaptureContentSourceRequest, @@ -75,6 +76,7 @@ ProjectPage, ProposeExperienceRequest, ProposeSkillRequest, + PublishArtifactRequest, PurgeHandoffReportActivitiesRequest, PurgeHandoffReportActivitiesResponse, ReadinessResponse, @@ -226,6 +228,25 @@ class Operation(BaseModel, Generic[RequestT, ResponseT]): }, ) +PUBLISH_ARTIFACT = Operation[PublishArtifactRequest, ArtifactPublication]( + method="POST", + path="/v1/artifact-publications", + operation_id="publish_artifact", + request_type=PublishArtifactRequest, + request_location="body", + response_type=ArtifactPublication, + success_status=201, + summary="Publish one exact Artifact revision into another Scope", + tags=("scopes",), + responses={ + 201: {"description": "Independent target Artifact and its exact source provenance."}, + 404: {"$ref": "#/components/responses/NotFound"}, + 409: {"$ref": "#/components/responses/Conflict"}, + 401: {"$ref": "#/components/responses/Unauthorized"}, + 422: {"$ref": "#/components/responses/InvalidRequest"}, + }, +) + GET_SCOPE = Operation[GetScopeRequest, ScopeDescriptor]( method="POST", path="/v1/scopes/get", diff --git a/src/powercontext/http/_generated/schema.py b/src/powercontext/http/_generated/schema.py index c8daf648f..3a1f4ed87 100644 --- a/src/powercontext/http/_generated/schema.py +++ b/src/powercontext/http/_generated/schema.py @@ -94,6 +94,31 @@ }, }, }, + "/v1/artifact-publications": { + "post": { + "tags": ["scopes"], + "summary": "Publish one exact Artifact revision into another Scope", + "operationId": "publish_artifact", + "requestBody": { + "content": { + "application/json": {"schema": {"$ref": "#/components/schemas/PublishArtifactRequest"}} + }, + "required": True, + }, + "responses": { + "201": { + "description": "Independent target Artifact and its exact source provenance.", + "content": { + "application/json": {"schema": {"$ref": "#/components/schemas/ArtifactPublication"}} + }, + }, + "404": {"$ref": "#/components/responses/NotFound"}, + "409": {"$ref": "#/components/responses/Conflict"}, + "401": {"$ref": "#/components/responses/Unauthorized"}, + "422": {"$ref": "#/components/responses/InvalidRequest"}, + }, + } + }, "/v1/scopes/get": { "post": { "tags": ["scopes"], @@ -1732,6 +1757,35 @@ "type": "object", "required": ["family", "artifact_id", "revision"], }, + "ArtifactAddress": { + "properties": { + "scope_id": {"type": "string", "maxLength": 256, "minLength": 1, "pattern": ".*\\S.*"}, + "artifact": {"$ref": "#/components/schemas/ArtifactReference"}, + }, + "additionalProperties": False, + "type": "object", + "required": ["scope_id", "artifact"], + }, + "PublishArtifactRequest": { + "properties": { + "source": {"$ref": "#/components/schemas/ArtifactAddress"}, + "target_scope_id": {"type": "string", "maxLength": 256, "minLength": 1, "pattern": ".*\\S.*"}, + "idempotency_key": {"type": "string", "maxLength": 256, "minLength": 1, "pattern": ".*\\S.*"}, + }, + "additionalProperties": False, + "type": "object", + "required": ["source", "target_scope_id", "idempotency_key"], + }, + "ArtifactPublication": { + "properties": { + "source": {"$ref": "#/components/schemas/ArtifactAddress"}, + "target": {"$ref": "#/components/schemas/ArtifactAddress"}, + "content_digest": {"type": "string", "pattern": "^[0-9a-f]{64}$"}, + }, + "additionalProperties": False, + "type": "object", + "required": ["source", "target", "content_digest"], + }, "ScopeExternalReference": { "properties": { "kind": {"type": "string", "maxLength": 128, "minLength": 1, "pattern": ".*\\S.*"}, diff --git a/src/powercontext/server/app.py b/src/powercontext/server/app.py index d552dd628..b29e12509 100644 --- a/src/powercontext/server/app.py +++ b/src/powercontext/server/app.py @@ -38,7 +38,7 @@ from starlette.types import Lifespan from powercontext._logging import log_safely -from powercontext.artifacts import ArtifactRef +from powercontext.artifacts import ArtifactAddress, ArtifactRef from powercontext.builtin.artifacts.experience import Experience from powercontext.builtin.artifacts.handoff import ( HandoffEvidenceUnavailableError, @@ -97,6 +97,13 @@ InvalidActivityRepositoryArgumentError, ) from powercontext.builtin.inference.errors import InferenceTimeoutError, InferenceUnavailableError +from powercontext.builtin.publication import ( + ArtifactPublicationApplication, + ArtifactPublicationConflictError, +) +from powercontext.builtin.publication import ( + ArtifactPublicationRequest as DomainArtifactPublicationRequest, +) from powercontext.builtin.review import ( ArtifactTargetConflictError, CandidateConflictError, @@ -306,6 +313,7 @@ ProjectPage, ProposeExperienceRequest, ProposeSkillRequest, + PublishArtifactRequest, PurgeHandoffReportActivitiesRequest, PurgeHandoffReportActivitiesResponse, ReadinessResponse, @@ -341,6 +349,9 @@ WorkstreamDescriptor, WorkstreamPage, ) +from powercontext.http import ( + ArtifactPublication as TransportArtifactPublication, +) from powercontext.http import ( HandoffActivation as TransportHandoffActivation, ) @@ -402,6 +413,7 @@ PREPARE_HANDOFF, PROPOSE_EXPERIENCE, PROPOSE_SKILL, + PUBLISH_ARTIFACT, PURGE_HANDOFF_REPORT_ACTIVITIES, RECORD_HANDOFF_REPORT_ACTIVITY, RECORD_TASK_OUTCOME, @@ -584,6 +596,7 @@ def for_scope(self, scope_id: str, /) -> _ScopedStatisticsApplication: ... class ServerApplication(Protocol): scopes: ScopeApplication | None + publications: ArtifactPublicationApplication | None sources: _SourceApplication context: _ContextApplication experience: _ExperienceApplication @@ -696,6 +709,7 @@ async def unexpected_error(request: Request, error: Exception) -> JSONResponse: _add_route(app, RESOLVE_SCOPE_BINDING, resolve_scope_binding) _add_route(app, SET_SCOPE_BINDING, set_scope_binding) _add_route(app, CLEAR_SCOPE_BINDING, clear_scope_binding) + _add_route(app, PUBLISH_ARTIFACT, publish_artifact) _add_route(app, GET_STATS, get_stats) if handoff_report_enabled: _add_route(app, CREATE_HANDOFF_REPORT_PROJECT, create_handoff_report_project) @@ -895,6 +909,23 @@ async def clear_scope_binding( return ClearScopeBindingResponse(cleared=await scopes.clear_binding(_domain_binding_key(request.key))) +async def publish_artifact( + request: PublishArtifactRequest, + publications: Annotated[ArtifactPublicationApplication, Depends(_require_publication_application)], +) -> TransportArtifactPublication: + result = await publications.publish( + DomainArtifactPublicationRequest( + source=ArtifactAddress( + scope_id=request.source.scope_id, + artifact=ArtifactRef.model_validate(request.source.artifact.model_dump(mode="json")), + ), + target_scope_id=request.target_scope_id, + idempotency_key=request.idempotency_key, + ) + ) + return TransportArtifactPublication.model_validate(result.model_dump(mode="json")) + + async def get_stats( request: Annotated[GetStatsRequest, Query()], response: Response, @@ -1506,6 +1537,13 @@ def _require_scope_application(request: Request) -> ScopeApplication: return application.scopes +def _require_publication_application(request: Request) -> ArtifactPublicationApplication: + application = _require_application(request) + if application.publications is None: + raise _RuntimeNotReadyError + return application.publications + + def _require_handoff_report_application(request: Request) -> HandoffReportApplication: application = _require_application(request) if application.handoff_report is None: @@ -1732,6 +1770,13 @@ def _map_error(error: Exception) -> tuple[int, str, str, dict[str, Any] | None]: def _map_scope_error(error: Exception) -> tuple[int, str, str, dict[str, Any] | None] | None: + if isinstance(error, ArtifactPublicationConflictError): + return ( + status.HTTP_409_CONFLICT, + "artifact_publication_conflict", + "The publication key identifies a different source Artifact.", + None, + ) if isinstance(error, (ScopeNotFoundError, ScopeBindingNotFoundError)): return status.HTTP_404_NOT_FOUND, "scope_not_found", "The requested Scope was not found.", None if isinstance(error, ScopeVersionConflictError): diff --git a/src/powercontext/server/mcp.py b/src/powercontext/server/mcp.py index 2890026ed..b18dfaae4 100644 --- a/src/powercontext/server/mcp.py +++ b/src/powercontext/server/mcp.py @@ -54,6 +54,7 @@ LIST_HANDOFF_REPORT_WORKSTREAMS, LIST_MEMORY_ENTRIES, LIST_SCOPES, + PUBLISH_ARTIFACT, RECORD_TASK_OUTCOME, REJECT_ARTIFACT_CANDIDATE, REMEMBER_MEMORY, @@ -105,6 +106,7 @@ GET_SCOPE.operation_id, SET_SCOPE_BINDING.operation_id, CLEAR_SCOPE_BINDING.operation_id, + PUBLISH_ARTIFACT.operation_id, }) _MCP_READ_ONLY_OPERATION_IDS = frozenset({ CONTINUE_HANDOFF.operation_id, diff --git a/tests/builtin/test_publication.py b/tests/builtin/test_publication.py new file mode 100644 index 000000000..2508c34d2 --- /dev/null +++ b/tests/builtin/test_publication.py @@ -0,0 +1,88 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import asyncio + +import pytest + +from powercontext.artifacts import ArtifactAddress +from powercontext.builtin.artifacts.memory import MemoryEntryInput +from powercontext.builtin.persistence.sqlite import SQLiteConfig +from powercontext.builtin.publication import ( + ArtifactPublicationConflictError, + ArtifactPublicationRequest, +) +from powercontext.builtin.runtime import BuiltinConfig, open_builtin_contexts +from powercontext.builtin.scope import ScopeDraft + + +def test_publication_copies_only_one_exact_revision_with_resolvable_provenance() -> None: + async def scenario() -> None: + async with open_builtin_contexts(BuiltinConfig(database=SQLiteConfig())) as contexts: + source_scope = await contexts.scopes.create( + ScopeDraft(title="Source", summary="Private working state", idempotency_key="source") + ) + target_scope = await contexts.scopes.create( + ScopeDraft(title="Target", summary="Accepted result", idempotency_key="target") + ) + source_context = await contexts.get(source_scope.scope_id) + first = await source_context.artifacts.memory.remember( + memory=None, entries=(MemoryEntryInput(kind="decision", text="Publish this exact decision."),) + ) + assert first is not None + request = ArtifactPublicationRequest( + source=ArtifactAddress(scope_id=source_scope.scope_id, artifact=first.as_ref()), + target_scope_id=target_scope.scope_id, + idempotency_key="accepted-decision", + ) + + publication = await contexts.publications.publish(request) + repeated = await contexts.publications.publish(request) + await source_context.artifacts.memory.remember( + memory=first, + entries=(MemoryEntryInput(kind="fact", text="Later private material."),), + ) + + assert repeated == publication + assert publication.source == request.source + assert publication.target.scope_id == target_scope.scope_id + assert publication.target.artifact.family == "memory" + assert publication.target.artifact.artifact_id.startswith("pub_") + assert len(publication.content_digest) == 64 + assert await contexts.publications.get(publication.target) == publication + async with contexts.database.transaction() as connection: + copied = await contexts.repositories.artifacts.get( + connection, + publication.target.scope_id, + publication.target.artifact, + ) + assert copied.content == first.content + assert copied.lineage.sources == () + assert copied.lineage.artifacts == () + + with pytest.raises(ArtifactPublicationConflictError): + await contexts.publications.publish( + request.model_copy( + update={ + "source": ArtifactAddress( + scope_id=source_scope.scope_id, + artifact=first.model_copy(update={"revision": 2}).as_ref(), + ) + } + ) + ) + + asyncio.run(scenario()) diff --git a/tests/codex_plugin/test_scope_binding.py b/tests/codex_plugin/test_scope_binding.py index b75eaff1b..3c7c909ac 100644 --- a/tests/codex_plugin/test_scope_binding.py +++ b/tests/codex_plugin/test_scope_binding.py @@ -79,6 +79,40 @@ def test_pre_tool_hook_fixes_control_binding_to_current_session( } +def test_pre_tool_hook_preserves_explicit_publication_boundaries( + bind_tools_module: ModuleType, + monkeypatch: pytest.MonkeyPatch, +) -> None: + tool_input = { + "source": { + "scope_id": "source-scope", + "artifact": {"family": "handoff", "artifact_id": "handoff", "revision": 3}, + }, + "target_scope_id": "target-scope", + "idempotency_key": "handoff-3", + } + monkeypatch.setattr( + sys, + "stdin", + io.StringIO( + json.dumps({ + "hook_event_name": "PreToolUse", + "session_id": "session-a", + "cwd": "/workspace", + "tool_name": "mcp__powercontext__publish_artifact", + "tool_input": tool_input, + }) + ), + ) + output = io.StringIO() + monkeypatch.setattr(sys, "stdout", output) + + assert bind_tools_module.main() == 0 + result = json.loads(output.getvalue())["hookSpecificOutput"] + assert result["permissionDecision"] == "allow" + assert result["updatedInput"] == tool_input + + def test_pre_tool_hook_denies_data_plane_when_binding_is_unavailable( bind_tools_module: ModuleType, monkeypatch: pytest.MonkeyPatch, diff --git a/tests/test_mcp.py b/tests/test_mcp.py index 0b35be3ce..f54806049 100644 --- a/tests/test_mcp.py +++ b/tests/test_mcp.py @@ -137,6 +137,7 @@ async def inspect_components() -> tuple[list[str], int, int]: "list_artifact_candidates", "list_memory_entries", "list_scopes", + "publish_artifact", "reject_artifact_candidate", "record_task_outcome", "remember_memory", diff --git a/tests/test_scope_api.py b/tests/test_scope_api.py index be69e1368..4d4a91c0c 100644 --- a/tests/test_scope_api.py +++ b/tests/test_scope_api.py @@ -106,3 +106,41 @@ def test_scope_http_flow_rejects_stale_metadata_and_invalid_selection(tmp_path) json={"selection": {"mode": "exact", "scope_ids": []}}, ) assert invalid.status_code == 422 + + +def test_scope_http_flow_publishes_one_exact_artifact(tmp_path) -> None: + app = create_server_app( + settings=ServerSettings( + database=SQLiteConfig(url=f"sqlite+aiosqlite:///{tmp_path / 'runtime.db'}"), + mcp=McpConfig(enabled=False), + ) + ) + + with TestClient(app) as client: + source_scope_id = client.post( + "/v1/scopes", + json={"title": "Source", "summary": "Working state", "idempotency_key": "source"}, + ).json()["scope_id"] + target_scope_id = client.post( + "/v1/scopes", + json={"title": "Target", "summary": "Accepted state", "idempotency_key": "target"}, + ).json()["scope_id"] + memory = client.post( + "/v1/memory/remember", + json={"scope_id": source_scope_id, "kind": "decision", "text": "Publish the accepted decision."}, + ).json()["memory"] + request = { + "source": {"scope_id": source_scope_id, "artifact": memory}, + "target_scope_id": target_scope_id, + "idempotency_key": "accepted-decision", + } + + created = client.post("/v1/artifact-publications", json=request) + repeated = client.post("/v1/artifact-publications", json=request) + + assert created.status_code == 201 + assert repeated.status_code == 201 + assert created.json() == repeated.json() + assert created.json()["source"] == request["source"] + assert created.json()["target"]["scope_id"] == target_scope_id + assert created.json()["target"]["artifact"]["revision"] == 1 From efedeeaec972063f89b068619e8f60787d80299e Mon Sep 17 00:00:00 2001 From: Chojan Shang Date: Thu, 27 Aug 2026 16:07:44 +0800 Subject: [PATCH 06/15] feat(stats): aggregate scope selections --- .../powercontext/src/operations.generated.ts | 2 +- .../powercontext/src/operations.generated.ts | 2 +- .../powercontext/src/operations.generated.ts | 2 +- openapi/powercontext.yaml | 46 ++-- .../builtin/runtime/application.py | 24 +- .../builtin/runtime/statistics.py | 4 +- .../builtin/statistics/aggregation.py | 250 ++++++++++++++++++ src/powercontext/builtin/statistics/models.py | 6 +- src/powercontext/client/cli.py | 22 +- src/powercontext/http/__init__.py | 2 + src/powercontext/http/_generated/models.py | 5 +- .../http/_generated/operations.py | 8 +- src/powercontext/http/_generated/schema.py | 36 ++- src/powercontext/server/app.py | 30 ++- src/powercontext/server/factory.py | 1 - src/powercontext/server/settings.py | 24 -- src/powercontext/server/static/dashboard.js | 73 +++-- src/powercontext/server/web.py | 45 ++-- tests/builtin/runtime/test_statistics.py | 45 ++++ tests/e2e/test_statistics_flow.py | 30 ++- tests/test_api_contract.py | 17 +- tests/test_dashboard.py | 60 +++-- tests/test_js_operations.py | 2 +- tests/test_server.py | 1 - 24 files changed, 556 insertions(+), 181 deletions(-) create mode 100644 src/powercontext/builtin/statistics/aggregation.py diff --git a/integrations/dsh/plugins/powercontext/src/operations.generated.ts b/integrations/dsh/plugins/powercontext/src/operations.generated.ts index 4a87fc46b..ab65d69f0 100644 --- a/integrations/dsh/plugins/powercontext/src/operations.generated.ts +++ b/integrations/dsh/plugins/powercontext/src/operations.generated.ts @@ -65,7 +65,7 @@ export const OPERATIONS = { approve_artifact_candidate: { method: 'POST', path: '/v1/artifact-candidates/approve', location: "body", scope: true }, reject_artifact_candidate: { method: 'POST', path: '/v1/artifact-candidates/reject', location: "body", scope: true }, revise_artifact_candidate: { method: 'POST', path: '/v1/artifact-candidates/revise', location: "body", scope: true }, - get_stats: { method: 'GET', path: '/v1/stats', location: "query", scope: true }, + get_stats: { method: 'POST', path: '/v1/stats', location: "body", scope: false }, create_handoff_report_project: { method: 'POST', path: '/v1/handoff-reports/projects/create', location: "body", scope: false }, list_handoff_report_projects: { method: 'POST', path: '/v1/handoff-reports/projects/list', location: "body", scope: false }, list_handoff_report_known_scopes: { method: 'POST', path: '/v1/handoff-reports/scopes/list-known', location: "body", scope: false }, diff --git a/integrations/opencode/plugins/powercontext/src/operations.generated.ts b/integrations/opencode/plugins/powercontext/src/operations.generated.ts index 4a87fc46b..ab65d69f0 100644 --- a/integrations/opencode/plugins/powercontext/src/operations.generated.ts +++ b/integrations/opencode/plugins/powercontext/src/operations.generated.ts @@ -65,7 +65,7 @@ export const OPERATIONS = { approve_artifact_candidate: { method: 'POST', path: '/v1/artifact-candidates/approve', location: "body", scope: true }, reject_artifact_candidate: { method: 'POST', path: '/v1/artifact-candidates/reject', location: "body", scope: true }, revise_artifact_candidate: { method: 'POST', path: '/v1/artifact-candidates/revise', location: "body", scope: true }, - get_stats: { method: 'GET', path: '/v1/stats', location: "query", scope: true }, + get_stats: { method: 'POST', path: '/v1/stats', location: "body", scope: false }, create_handoff_report_project: { method: 'POST', path: '/v1/handoff-reports/projects/create', location: "body", scope: false }, list_handoff_report_projects: { method: 'POST', path: '/v1/handoff-reports/projects/list', location: "body", scope: false }, list_handoff_report_known_scopes: { method: 'POST', path: '/v1/handoff-reports/scopes/list-known', location: "body", scope: false }, diff --git a/integrations/pi/plugins/powercontext/src/operations.generated.ts b/integrations/pi/plugins/powercontext/src/operations.generated.ts index 4a87fc46b..ab65d69f0 100644 --- a/integrations/pi/plugins/powercontext/src/operations.generated.ts +++ b/integrations/pi/plugins/powercontext/src/operations.generated.ts @@ -65,7 +65,7 @@ export const OPERATIONS = { approve_artifact_candidate: { method: 'POST', path: '/v1/artifact-candidates/approve', location: "body", scope: true }, reject_artifact_candidate: { method: 'POST', path: '/v1/artifact-candidates/reject', location: "body", scope: true }, revise_artifact_candidate: { method: 'POST', path: '/v1/artifact-candidates/revise', location: "body", scope: true }, - get_stats: { method: 'GET', path: '/v1/stats', location: "query", scope: true }, + get_stats: { method: 'POST', path: '/v1/stats', location: "body", scope: false }, create_handoff_report_project: { method: 'POST', path: '/v1/handoff-reports/projects/create', location: "body", scope: false }, list_handoff_report_projects: { method: 'POST', path: '/v1/handoff-reports/projects/list', location: "body", scope: false }, list_handoff_report_known_scopes: { method: 'POST', path: '/v1/handoff-reports/scopes/list-known', location: "body", scope: false }, diff --git a/openapi/powercontext.yaml b/openapi/powercontext.yaml index a170b79bd..e69aff948 100644 --- a/openapi/powercontext.yaml +++ b/openapi/powercontext.yaml @@ -1424,27 +1424,19 @@ paths: "500": $ref: "#/components/responses/InternalError" /v1/stats: - get: + post: tags: [stats] - summary: Get scoped product statistics + summary: Aggregate product statistics over a Scope selection operationId: get_stats - parameters: - - name: scope_id - in: query - required: true - schema: - type: string - minLength: 1 - maxLength: 256 - pattern: '.*\S.*' - - name: period - in: query - required: false - schema: - $ref: "#/components/schemas/StatsPeriod" + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/GetStatsRequest" responses: "200": - description: Current inventory, model usage, and recall token estimates for the scope. + description: Current inventory, model usage, and recall token estimates for the frozen Scope set. headers: X-PowerContext-Request-ID: $ref: "#/components/headers/RequestId" @@ -2797,10 +2789,15 @@ components: ScopedStats: type: object additionalProperties: false - required: [scope_id, as_of, inventory, usage, recall] + required: [selection, scope_ids, as_of, inventory, usage, recall] properties: - scope_id: - type: string + selection: + $ref: "#/components/schemas/ScopeSelection" + scope_ids: + type: array + uniqueItems: true + items: + type: string as_of: type: string format: date-time @@ -2813,13 +2810,10 @@ components: GetStatsRequest: type: object additionalProperties: false - required: [scope_id] + required: [selection] properties: - scope_id: - type: string - minLength: 1 - maxLength: 256 - pattern: '.*\S.*' + selection: + $ref: "#/components/schemas/ScopeSelection" period: $ref: "#/components/schemas/StatsPeriod" default: 30d diff --git a/src/powercontext/builtin/runtime/application.py b/src/powercontext/builtin/runtime/application.py index dd2e24af1..437d5f8d7 100644 --- a/src/powercontext/builtin/runtime/application.py +++ b/src/powercontext/builtin/runtime/application.py @@ -139,7 +139,7 @@ RuntimeReadinessChecks, ) from powercontext.builtin.runtime.statistics import RelationalScopedStatistics -from powercontext.builtin.scope import ScopeApplication, ScopeNotFoundError +from powercontext.builtin.scope import ScopeApplication, ScopeNotFoundError, ScopeSelection from powercontext.builtin.sources import ( ContentCapture, ContentSource, @@ -154,6 +154,7 @@ Statistics, StatisticsPeriod, ) +from powercontext.builtin.statistics.aggregation import aggregate_statistics from powercontext.builtin.work import ( HANDOFF_BOUNDARY_SOURCE_KIND, HANDOFF_RECEIPT_SOURCE_KIND, @@ -329,6 +330,27 @@ def __init__(self, runtime: BuiltinRuntime) -> None: def for_scope(self, scope_id: str, /) -> ScopedStatisticsApplication: return ScopedStatisticsApplication(self._runtime, scope_id) + async def overview( + self, + selection: ScopeSelection, + *, + period: StatisticsPeriod = StatisticsPeriod.THIRTY_DAYS, + ) -> Statistics: + if self._runtime.scopes is None: + raise _RuntimeStateError("statistics") + async with self._runtime._operation(): + resolved = await self._runtime.scopes.resolve_selection(selection) + captured_at = self._runtime._clock() + snapshots = tuple([ + await self._runtime._statistics(scope.scope_id).overview(period, captured_at) for scope in resolved + ]) + return aggregate_statistics( + selection, + tuple(scope.scope_id for scope in resolved), + snapshots, + captured_at, + ) + class ScopedContextApplication: """Prepare final context for one scope using Runtime-owned source policy.""" diff --git a/src/powercontext/builtin/runtime/statistics.py b/src/powercontext/builtin/runtime/statistics.py index 0fe8d5dbf..e640aaa82 100644 --- a/src/powercontext/builtin/runtime/statistics.py +++ b/src/powercontext/builtin/runtime/statistics.py @@ -31,6 +31,7 @@ StoredModelUsage, StoredRecallTokenUsage, ) +from powercontext.builtin.scope import ScopeSelection from powercontext.builtin.statistics import ( ArtifactInventoryStatistics, CandidateFamilyCount, @@ -124,7 +125,8 @@ async def overview(self, period: StatisticsPeriod, as_of: datetime, /) -> Statis artifacts = tuple(FamilyCount(family=family, total=total) for family, total in stored_inventory.artifacts) candidates = _candidate_inventory(stored_inventory.candidates) return Statistics( - scope_id=self._scope_id, + selection=ScopeSelection(mode="exact", scope_ids=(self._scope_id,)), + scope_ids=(self._scope_id,), as_of=captured_at, inventory=InventoryStatistics( sources=SourceInventoryStatistics( diff --git a/src/powercontext/builtin/statistics/aggregation.py b/src/powercontext/builtin/statistics/aggregation.py new file mode 100644 index 000000000..d6d47f3ac --- /dev/null +++ b/src/powercontext/builtin/statistics/aggregation.py @@ -0,0 +1,250 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Additive Statistics projection over a frozen Scope selection.""" + +from __future__ import annotations + +from collections import defaultdict +from collections.abc import Callable, Iterable +from datetime import datetime + +from powercontext.builtin.scope import ScopeSelection +from powercontext.builtin.statistics.models import ( + ArtifactInventoryStatistics, + CandidateFamilyCount, + CandidateInventoryStatistics, + FamilyCount, + InventoryStatistics, + MemoryEntryInventoryStatistics, + MemoryInventoryStatistics, + MemoryKindCount, + ModelUsageDay, + ModelUsagePurpose, + ModelUsagePurposeBreakdown, + ModelUsageStatistics, + ModelUsageValue, + RecallTokenDay, + RecallTokenStatistics, + RecallTokenValue, + SourceInventoryStatistics, + Statistics, + UsageStatistics, +) + + +def aggregate_statistics( + selection: ScopeSelection, + scope_ids: tuple[str, ...], + snapshots: tuple[Statistics, ...], + as_of: datetime, +) -> Statistics: + """Aggregate values that are already bounded to the same reporting period.""" + + if len(scope_ids) != len(snapshots): + raise ValueError("every selected Scope must have one Statistics snapshot") # noqa: TRY003 + if not snapshots: + raise ValueError("Statistics selection must resolve at least one Scope") # noqa: TRY003 + period = snapshots[0].usage.period + if any(snapshot.usage.period != period or snapshot.recall.period != period for snapshot in snapshots): + raise ValueError("Statistics snapshots must use the same period") # noqa: TRY003 + + return Statistics( + selection=selection, + scope_ids=scope_ids, + as_of=as_of, + inventory=_inventory(snapshots), + usage=_usage(snapshots), + recall=_recall(snapshots), + ) + + +def _inventory(snapshots: tuple[Statistics, ...]) -> InventoryStatistics: + family_counts: dict[str, int] = defaultdict(int) + candidate_counts: dict[str, list[int]] = defaultdict(lambda: [0, 0, 0]) + kind_counts: dict[str, list[int]] = defaultdict(lambda: [0, 0]) + for snapshot in snapshots: + for item in snapshot.inventory.artifacts.by_family: + family_counts[item.family] += item.total + for item in snapshot.inventory.candidates.by_family: + values = candidate_counts[item.family] + values[0] += item.pending + values[1] += item.approved + values[2] += item.rejected + for item in snapshot.inventory.memory.entries.by_kind: + values = kind_counts[item.kind] + values[0] += item.active + values[1] += item.inactive + candidates = tuple( + CandidateFamilyCount( + family=family, + total=sum(values), + pending=values[0], + approved=values[1], + rejected=values[2], + ) + for family, values in sorted(candidate_counts.items()) + ) + kinds = tuple( + MemoryKindCount(kind=kind, total=sum(values), active=values[0], inactive=values[1]) + for kind, values in sorted(kind_counts.items()) + ) + return InventoryStatistics( + sources=SourceInventoryStatistics( + total=sum(snapshot.inventory.sources.total for snapshot in snapshots), + memory_processed=sum(snapshot.inventory.sources.memory_processed for snapshot in snapshots), + memory_pending=sum(snapshot.inventory.sources.memory_pending for snapshot in snapshots), + ), + artifacts=ArtifactInventoryStatistics( + total=sum(family_counts.values()), + by_family=tuple(FamilyCount(family=family, total=total) for family, total in sorted(family_counts.items())), + ), + candidates=CandidateInventoryStatistics( + total=sum(item.total for item in candidates), + pending=sum(item.pending for item in candidates), + approved=sum(item.approved for item in candidates), + rejected=sum(item.rejected for item in candidates), + by_family=candidates, + ), + memory=MemoryInventoryStatistics( + entries=MemoryEntryInventoryStatistics( + total=sum(item.total for item in kinds), + active=sum(item.active for item in kinds), + inactive=sum(item.inactive for item in kinds), + by_kind=kinds, + ) + ), + ) + + +def _usage(snapshots: tuple[Statistics, ...]) -> UsageStatistics: + period = snapshots[0].usage.period + purposes = tuple( + purpose + for purpose in ModelUsagePurpose + if any(any(item.purpose is purpose for item in snapshot.usage.by_purpose) for snapshot in snapshots) + ) + return UsageStatistics( + period=period, + totals=_model_usage(snapshot.usage.totals for snapshot in snapshots), + by_purpose=tuple( + ModelUsagePurposeBreakdown( + purpose=purpose, + generation=_usage_value( + _purpose(snapshot.usage.by_purpose, purpose).generation for snapshot in snapshots + ), + embedding=_usage_value( + _purpose(snapshot.usage.by_purpose, purpose).embedding for snapshot in snapshots + ), + ) + for purpose in purposes + ), + daily=tuple( + ModelUsageDay( + date=day.date, + generation=_usage_value(snapshot.usage.daily[index].generation for snapshot in snapshots), + embedding=_usage_value(snapshot.usage.daily[index].embedding for snapshot in snapshots), + by_purpose=tuple( + ModelUsagePurposeBreakdown( + purpose=purpose, + generation=_usage_value( + _purpose(snapshot.usage.daily[index].by_purpose, purpose).generation + for snapshot in snapshots + ), + embedding=_usage_value( + _purpose(snapshot.usage.daily[index].by_purpose, purpose).embedding + for snapshot in snapshots + ), + ) + for purpose in ModelUsagePurpose + if any( + any(item.purpose is purpose for item in snapshot.usage.daily[index].by_purpose) + for snapshot in snapshots + ) + ), + ) + for index, day in enumerate(snapshots[0].usage.daily) + ), + ) + + +def _recall(snapshots: tuple[Statistics, ...]) -> RecallTokenStatistics: + first = snapshots[0].recall + if any(snapshot.recall.estimator != first.estimator for snapshot in snapshots): + raise ValueError("Statistics snapshots must use the same recall estimator") # noqa: TRY003 + return RecallTokenStatistics( + period=first.period, + estimator=first.estimator, + totals=_recall_value(snapshot.recall.totals for snapshot in snapshots), + daily=tuple( + RecallTokenDay( + date=day.date, + **_recall_value(snapshot.recall.daily[index] for snapshot in snapshots).model_dump(), + ) + for index, day in enumerate(first.daily) + ), + ) + + +def _model_usage(values: Iterable[ModelUsageStatistics]) -> ModelUsageStatistics: + items = tuple(values) + return ModelUsageStatistics( + generation=_usage_value(item.generation for item in items), + embedding=_usage_value(item.embedding for item in items), + ) + + +def _usage_value(values: Iterable[ModelUsageValue]) -> ModelUsageValue: + items = tuple(values) + return ModelUsageValue( + requests=sum(item.requests for item in items), + input_tokens=_optional_sum(items, lambda item: item.input_tokens), + output_tokens=_optional_sum(items, lambda item: item.output_tokens), + ) + + +def _optional_sum(values: tuple[ModelUsageValue, ...], getter: Callable[[ModelUsageValue], int | None]) -> int | None: + selected = tuple(getter(value) for value in values) + return None if any(value is None for value in selected) else sum(value for value in selected if value is not None) + + +def _purpose( + values: tuple[ModelUsagePurposeBreakdown, ...], + purpose: ModelUsagePurpose, +) -> ModelUsagePurposeBreakdown: + return next( + (value for value in values if value.purpose is purpose), + ModelUsagePurposeBreakdown( + purpose=purpose, + generation=ModelUsageValue(requests=0, input_tokens=0, output_tokens=0), + embedding=ModelUsageValue(requests=0, input_tokens=0, output_tokens=0), + ), + ) + + +def _recall_value(values: Iterable[RecallTokenValue]) -> RecallTokenValue: + items = tuple(values) + baseline = sum(item.baseline_tokens for item in items) + recalled = sum(item.recalled_tokens for item in items) + return RecallTokenValue( + preparations=sum(item.preparations for item in items), + ready_preparations=sum(item.ready_preparations for item in items), + comparable_preparations=sum(item.comparable_preparations for item in items), + baseline_tokens=baseline, + recalled_tokens=recalled, + token_reduction=baseline - recalled, + ) + + +__all__ = ["aggregate_statistics"] diff --git a/src/powercontext/builtin/statistics/models.py b/src/powercontext/builtin/statistics/models.py index 428c457a0..45bfcc154 100644 --- a/src/powercontext/builtin/statistics/models.py +++ b/src/powercontext/builtin/statistics/models.py @@ -23,6 +23,7 @@ from pydantic import BaseModel, Field, model_validator from powercontext.builtin.inference import TokenEstimatorProfile +from powercontext.builtin.scope import ScopeSelection class StatisticsPeriod(StrEnum): @@ -229,9 +230,10 @@ class RecallTokenStatistics(BaseModel): class Statistics(BaseModel): - """Current inventory and bounded model usage for one scope.""" + """Current inventory and bounded model usage for one frozen Scope selection.""" - scope_id: str + selection: ScopeSelection + scope_ids: tuple[str, ...] as_of: datetime inventory: InventoryStatistics usage: UsageStatistics diff --git a/src/powercontext/client/cli.py b/src/powercontext/client/cli.py index 811f4e25b..784a0c87b 100644 --- a/src/powercontext/client/cli.py +++ b/src/powercontext/client/cli.py @@ -61,6 +61,9 @@ ScanExternalSkillsRequest, ScanExternalSkillsResponse, ScopedStats, + ScopeId, + ScopeSelection, + ScopeSelectionMode, SkillArtifact, SkillGenerationOrigin, SkillProposal, @@ -157,12 +160,21 @@ def capabilities(context: typer.Context) -> None: def stats( context: typer.Context, - scope_id: Annotated[str, typer.Option(help="Application scope to inspect.")], + scope_id: Annotated[list[str] | None, typer.Option(help="Exact application scope to include.")] = None, + root_scope_id: Annotated[str | None, typer.Option(help="Organization subtree root to include.")] = None, period: Annotated[StatsPeriod, typer.Option(help="Bounded UTC statistics period.")] = StatsPeriod.FIELD_30D, ) -> None: - """Show current inventory and bounded usage for one scope.""" - - request = GetStatsRequest(scope_id=scope_id, period=period) + """Show current inventory and bounded usage for a Scope selection.""" + + if scope_id and root_scope_id is not None: + raise typer.BadParameter("--scope-id and --root-scope-id are mutually exclusive") # noqa: TRY003 + if root_scope_id is not None: + selection = ScopeSelection(mode=ScopeSelectionMode.SUBTREE, root_scope_id=root_scope_id) + elif scope_id: + selection = ScopeSelection(mode=ScopeSelectionMode.EXACT, scope_ids=[ScopeId(value) for value in scope_id]) + else: + selection = ScopeSelection(mode=ScopeSelectionMode.ALL) + request = GetStatsRequest(selection=selection, period=period) asyncio.run(_execute(context, lambda client: client.get_stats(request))) @@ -762,7 +774,7 @@ def _print_human_response(response: _ClientResponse) -> None: def _print_stats(response: ScopedStats) -> None: inventory = response.inventory - typer.echo(f"Scope: {response.scope_id}") + typer.echo(f"Selection: {response.selection.mode.value} ({len(response.scope_ids)} Scopes)") typer.echo(f"As of: {response.as_of.isoformat()}") typer.echo( "Sources: " diff --git a/src/powercontext/http/__init__.py b/src/powercontext/http/__init__.py index cf16d200d..6459ef7e2 100644 --- a/src/powercontext/http/__init__.py +++ b/src/powercontext/http/__init__.py @@ -177,6 +177,7 @@ ScopeDescriptor, ScopedStats, ScopeExternalReference, + ScopeId, ScopePage, ScopeSelection, ScopeSelectionMode, @@ -374,6 +375,7 @@ "ScopeBindingKey", "ScopeDescriptor", "ScopeExternalReference", + "ScopeId", "ScopePage", "ScopeSelection", "ScopeSelectionMode", diff --git a/src/powercontext/http/_generated/models.py b/src/powercontext/http/_generated/models.py index 04f4401d8..04048b8ef 100644 --- a/src/powercontext/http/_generated/models.py +++ b/src/powercontext/http/_generated/models.py @@ -1155,7 +1155,8 @@ class ScopedStats(BaseModel): model_config = ConfigDict( extra="forbid", ) - scope_id: StrictStr + selection: ScopeSelection + scope_ids: list[StrictStr] as_of: AwareDatetime inventory: InventoryStatistics usage: UsageStatistics @@ -1166,7 +1167,7 @@ class GetStatsRequest(BaseModel): model_config = ConfigDict( extra="forbid", ) - scope_id: Annotated[StrictStr, Field(max_length=256, min_length=1, pattern=".*\\S.*")] + selection: ScopeSelection period: StatsPeriod = StatsPeriod.FIELD_30D diff --git a/src/powercontext/http/_generated/operations.py b/src/powercontext/http/_generated/operations.py index f48ff8e2a..4f0129d0e 100644 --- a/src/powercontext/http/_generated/operations.py +++ b/src/powercontext/http/_generated/operations.py @@ -1178,18 +1178,18 @@ class Operation(BaseModel, Generic[RequestT, ResponseT]): ) GET_STATS = Operation[GetStatsRequest, ScopedStats]( - method="GET", + method="POST", path="/v1/stats", operation_id="get_stats", request_type=GetStatsRequest, - request_location="query", + request_location="body", response_type=ScopedStats, success_status=200, - summary="Get scoped product statistics", + summary="Aggregate product statistics over a Scope selection", tags=("stats",), responses={ 200: { - "description": "Current inventory, model usage, and recall token estimates for the scope.", + "description": "Current inventory, model usage, and recall token estimates for the frozen Scope set.", "headers": { "X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}, "Cache-Control": { diff --git a/src/powercontext/http/_generated/schema.py b/src/powercontext/http/_generated/schema.py index 3a1f4ed87..7fa5fd1ee 100644 --- a/src/powercontext/http/_generated/schema.py +++ b/src/powercontext/http/_generated/schema.py @@ -1251,27 +1251,20 @@ } }, "/v1/stats": { - "get": { + "post": { "tags": ["stats"], - "summary": "Get scoped product statistics", + "summary": "Aggregate product statistics over a Scope selection", "operationId": "get_stats", - "parameters": [ - { - "name": "scope_id", - "in": "query", - "required": True, - "schema": {"type": "string", "minLength": 1, "maxLength": 256, "pattern": ".*\\S.*"}, - }, - { - "name": "period", - "in": "query", - "required": False, - "schema": {"$ref": "#/components/schemas/StatsPeriod"}, - }, - ], + "requestBody": { + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/GetStatsRequest"}}}, + "required": True, + }, "responses": { "200": { - "description": "Current inventory, model usage, and recall token estimates for the scope.", + "description": "Current inventory, model " + "usage, and recall token " + "estimates for the frozen " + "Scope set.", "headers": { "X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}, "Cache-Control": { @@ -2343,7 +2336,8 @@ }, "ScopedStats": { "properties": { - "scope_id": {"type": "string"}, + "selection": {"$ref": "#/components/schemas/ScopeSelection"}, + "scope_ids": {"items": {"type": "string"}, "type": "array", "uniqueItems": True}, "as_of": {"type": "string", "format": "date-time"}, "inventory": {"$ref": "#/components/schemas/InventoryStatistics"}, "usage": {"$ref": "#/components/schemas/UsageStatistics"}, @@ -2351,16 +2345,16 @@ }, "additionalProperties": False, "type": "object", - "required": ["scope_id", "as_of", "inventory", "usage", "recall"], + "required": ["selection", "scope_ids", "as_of", "inventory", "usage", "recall"], }, "GetStatsRequest": { "properties": { - "scope_id": {"type": "string", "maxLength": 256, "minLength": 1, "pattern": ".*\\S.*"}, + "selection": {"$ref": "#/components/schemas/ScopeSelection"}, "period": {"$ref": "#/components/schemas/StatsPeriod", "default": "30d"}, }, "additionalProperties": False, "type": "object", - "required": ["scope_id"], + "required": ["selection"], }, "WorkClaimBasis": {"type": "string", "enum": ["declared", "verified"]}, "WorkClaim": { diff --git a/src/powercontext/server/app.py b/src/powercontext/server/app.py index b29e12509..f125fc81f 100644 --- a/src/powercontext/server/app.py +++ b/src/powercontext/server/app.py @@ -28,7 +28,7 @@ from typing import TYPE_CHECKING, Annotated, Any, Protocol, TypeVar, cast from uuid import uuid4 -from fastapi import Depends, FastAPI, Query, Request, Response, status +from fastapi import Depends, FastAPI, Request, Response, status from fastapi.exceptions import RequestValidationError from fastapi.responses import JSONResponse from opentelemetry.trace import SpanKind @@ -336,6 +336,7 @@ ScopeDescriptor, ScopedStats, ScopePage, + ScopeSelection, SearchMemoryRequest, SearchMemoryResponse, SetDefaultScopeRequest, @@ -593,6 +594,13 @@ async def overview(self, *, period: RuntimeStatisticsPeriod) -> RuntimeStatistic class _StatisticsApplication(Protocol): def for_scope(self, scope_id: str, /) -> _ScopedStatisticsApplication: ... + async def overview( + self, + selection: DomainScopeSelection, + *, + period: RuntimeStatisticsPeriod, + ) -> RuntimeStatistics: ... + class ServerApplication(Protocol): scopes: ScopeApplication | None @@ -872,11 +880,7 @@ async def resolve_scope_selection( request: ResolveScopeSelectionRequest, scopes: Annotated[ScopeApplication, Depends(_require_scope_application)], ) -> ScopePage: - selection = DomainScopeSelection( - mode=request.selection.mode.value, - scope_ids=tuple(scope_id.root for scope_id in request.selection.scope_ids), - root_scope_id=request.selection.root_scope_id, - ) + selection = _domain_scope_selection(request.selection) return ScopePage(items=[_scope_descriptor_response(scope) for scope in await scopes.resolve_selection(selection)]) @@ -927,13 +931,13 @@ async def publish_artifact( async def get_stats( - request: Annotated[GetStatsRequest, Query()], + request: GetStatsRequest, response: Response, application: Annotated[ServerApplication, Depends(_require_application)], ) -> ScopedStats: response.headers["Cache-Control"] = "no-store" - result = await application.statistics.for_scope(request.scope_id).overview( - period=RuntimeStatisticsPeriod(request.period.value) + result = await application.statistics.overview( + _domain_scope_selection(request.selection), period=RuntimeStatisticsPeriod(request.period.value) ) return mapping.statistics_response(result) @@ -1571,6 +1575,14 @@ def _domain_binding_key(value: ScopeBindingKey) -> DomainScopeBindingKey: ) +def _domain_scope_selection(value: ScopeSelection) -> DomainScopeSelection: + return DomainScopeSelection( + mode=value.mode.value, + scope_ids=tuple(scope_id.root for scope_id in value.scope_ids), + root_scope_id=value.root_scope_id, + ) + + def _transport_binding_key(value: DomainScopeBindingKey) -> ScopeBindingKey: return ScopeBindingKey( integration=value.integration, diff --git a/src/powercontext/server/factory.py b/src/powercontext/server/factory.py index c3b77933b..058f2b539 100644 --- a/src/powercontext/server/factory.py +++ b/src/powercontext/server/factory.py @@ -183,7 +183,6 @@ def _mount_optional_web_ui(app: FastAPI, settings: ServerSettings) -> None: try: mount_web_ui( app, - scopes={scope.scope_id: scope.display_name for scope in settings.dashboard.scopes}, dashboard_enabled=settings.dashboard.enabled, handoff_report_enabled=settings.handoff_report.enabled, authentication_required=settings.auth.enabled, diff --git a/src/powercontext/server/settings.py b/src/powercontext/server/settings.py index 060730d89..bc744db81 100644 --- a/src/powercontext/server/settings.py +++ b/src/powercontext/server/settings.py @@ -108,33 +108,10 @@ def require_token_when_enabled(self) -> BearerAuthConfig: return self -class DashboardScopeConfig(BaseModel): - """One scope exposed by the personal Dashboard.""" - - scope_id: str = Field(min_length=1, max_length=255) - display_name: str = Field(min_length=1, max_length=80) - - @field_validator("scope_id", "display_name") - @classmethod - def strip_non_empty_text(cls, value: str) -> str: - stripped = value.strip() - if not stripped: - raise ValueError("Dashboard scope values must not be empty") # noqa: TRY003 - return stripped - - class DashboardConfig(BaseModel): """Personal Dashboard served by the local Server.""" enabled: bool = True - scopes: list[DashboardScopeConfig] = Field(default_factory=list, max_length=100) - - @model_validator(mode="after") - def validate_scopes(self) -> DashboardConfig: - scope_ids = [scope.scope_id for scope in self.scopes] - if len(scope_ids) != len(set(scope_ids)): - raise ValueError("Dashboard scope IDs must be unique") # noqa: TRY003 - return self class ServerLoggingConfig(BaseModel): @@ -222,7 +199,6 @@ def reject_unauthenticated_non_loopback_bind(self) -> ServerSettings: __all__ = [ "BearerAuthConfig", "DashboardConfig", - "DashboardScopeConfig", "HandoffReportConfig", "HttpConfig", "McpConfig", diff --git a/src/powercontext/server/static/dashboard.js b/src/powercontext/server/static/dashboard.js index b4e9f34e9..93232d626 100644 --- a/src/powercontext/server/static/dashboard.js +++ b/src/powercontext/server/static/dashboard.js @@ -40,6 +40,9 @@ const translations = { tokenLabel: "Server token", continue: "Continue", selectScope: "Scope", + allScopes: "All", + subtreeView: "{title} and descendants", + exactFocus: "Focus: {title}", period30: "Last 30 days", estimatedReduction: "Estimated token reduction", sources: "Sources", @@ -82,7 +85,7 @@ const translations = { requestFailed: "The Dashboard request failed with HTTP {status}.", serverUnavailable: "The Server is unavailable.", retry: "Retry", - noScopes: "No Dashboard scopes are configured.", + noScopes: "No Scopes are available.", scopeUnavailable: "The selected scope is not available.", scopeOverview: "Scope overview" }, @@ -101,6 +104,9 @@ const translations = { tokenLabel: "服务器访问令牌", continue: "继续", selectScope: "作用域", + allScopes: "全部", + subtreeView: "{title}及其下级", + exactFocus: "聚焦:{title}", period30: "过去 30 天", estimatedReduction: "预估令牌减少量", sources: "数据源", @@ -143,7 +149,7 @@ const translations = { requestFailed: "仪表盘请求失败(HTTP {status})。", serverUnavailable: "服务器无法访问。", retry: "重试", - noScopes: "未配置仪表盘作用域。", + noScopes: "当前没有可用作用域。", scopeUnavailable: "选中的作用域不可用。", scopeOverview: "作用域概览" } @@ -230,11 +236,10 @@ async function authenticate(token, scopeId = "") { showPageStatus("noScopes", {}, true); return; } - const selectedScopeId = currentScopes.some((scope) => scope.scope_id === scopeId) - ? scopeId - : currentScopes[0].scope_id; - currentScopeId = selectedScopeId; - await loadStatistics(token, selectedScopeId, request); + const choices = selectionChoices(currentScopes); + const selectedKey = choices.some((choice) => choice.key === scopeId) ? scopeId : "all"; + currentScopeId = selectedKey; + await loadStatistics(token, selectedKey, request); } catch (error) { if (request.isCurrent()) { showPageStatus("serverUnavailable", {}, true); @@ -256,10 +261,16 @@ async function loadStatistics(token, scopeId, request = null) { currentScopeId = scopeId; scopeSelect.disabled = true; try { - const url = new URL("/v1/stats", window.location.origin); - url.searchParams.set("scope_id", scopeId); - url.searchParams.set("period", "30d"); - const response = await fetchWithBearer(url, token); + const choice = selectionChoices(currentScopes).find((item) => item.key === scopeId); + if (!choice) { + showPageStatus("scopeUnavailable", {}, true); + return; + } + const response = await fetchWithBearer("/v1/stats", token, { + method: "POST", + headers: {"Content-Type": "application/json"}, + body: JSON.stringify({selection: choice.selection, period: "30d"}) + }); if (!activeRequest.isCurrent()) { return; } @@ -276,12 +287,7 @@ async function loadStatistics(token, scopeId, request = null) { if (!activeRequest.isCurrent()) { return; } - const selectedScope = currentScopes.find((scope) => scope.scope_id === statistics.scope_id); - if (!selectedScope) { - showPageStatus("scopeUnavailable", {}, true); - return; - } - renderDashboard({scopes: currentScopes, selectedScope, statistics}); + renderDashboard({scopes: currentScopes, choice, statistics}); } catch (error) { if (activeRequest.isCurrent()) { showPageStatus("serverUnavailable", {}, true); @@ -349,8 +355,8 @@ function renderDashboard(view) { dashboard.hidden = false; signOut.hidden = !authenticationRequired; - renderScopes(view.scopes, statistics.scope_id); - setText("dashboard-name", view.selectedScope.display_name); + renderScopes(view.scopes, view.choice.key); + setText("dashboard-name", view.choice.label); setText("as-of", translate("updated", {value: formatDateTime(statistics.as_of)})); setText("sources", formatNumber(inventory.sources.total)); setText("memory-entries", formatNumber(inventory.memory.entries.total)); @@ -368,17 +374,36 @@ function renderDashboard(view) { renderTrend(recall.daily); } -function renderScopes(scopes, selectedScopeId) { +function renderScopes(scopes, selectedKey) { scopeSelect.replaceChildren(); - for (const scope of scopes) { + for (const choice of selectionChoices(scopes)) { const option = document.createElement("option"); - option.value = scope.scope_id; - option.textContent = `${scope.display_name} (${scope.scope_id})`; - option.selected = scope.scope_id === selectedScopeId; + option.value = choice.key; + option.textContent = choice.label; + option.selected = choice.key === selectedKey; scopeSelect.appendChild(option); } } +function selectionChoices(scopes) { + const choices = [{key: "all", label: translate("allScopes"), selection: {mode: "all"}}]; + for (const scope of scopes.filter((item) => item.parent_scope_id === null)) { + choices.push({ + key: `subtree:${scope.scope_id}`, + label: translate("subtreeView", {title: scope.display_name}), + selection: {mode: "subtree", root_scope_id: scope.scope_id} + }); + } + for (const scope of scopes) { + choices.push({ + key: `exact:${scope.scope_id}`, + label: translate("exactFocus", {title: scope.display_name}), + selection: {mode: "exact", scope_ids: [scope.scope_id]} + }); + } + return choices; +} + function renderArtifactFamilies(inventory) { const rows = document.getElementById("family-rows"); rows.replaceChildren(); diff --git a/src/powercontext/server/web.py b/src/powercontext/server/web.py index 023b2a4b0..842fd7e12 100644 --- a/src/powercontext/server/web.py +++ b/src/powercontext/server/web.py @@ -17,7 +17,6 @@ from __future__ import annotations import asyncio -from collections.abc import Mapping from functools import cache from typing import Literal @@ -38,6 +37,7 @@ ) from powercontext.builtin.review import CandidateStatus from powercontext.builtin.runtime import GetArtifactCandidateRequest, GetSkillRequest, ListExternalSkillsRequest +from powercontext.builtin.scope import ScopeNotFoundError from powercontext.http import ErrorDetail, ErrorResponse from powercontext.limits import MAX_ARTIFACT_ID_LENGTH @@ -51,12 +51,14 @@ class DashboardScope(BaseModel): - """One Server scope exposed by the personal Dashboard.""" + """One durable Scope exposed by the personal Dashboard.""" model_config = ConfigDict(extra="forbid") scope_id: str display_name: str + summary: str + parent_scope_id: str | None = None class DashboardSkillProjectionRequest(BaseModel): @@ -108,8 +110,7 @@ class DashboardSkillProjection(BaseModel): class _DashboardSkillProjectionRoutes: - def __init__(self, scope_ids: frozenset[str], targets: tuple[AgentSkillTarget, ...]) -> None: - self._scope_ids = scope_ids + def __init__(self, targets: tuple[AgentSkillTarget, ...]) -> None: self._targets = targets async def inspect( @@ -117,7 +118,7 @@ async def inspect( request: DashboardSkillProjectionRequest, http_request: Request, ) -> DashboardSkillProjection | JSONResponse: - resolved = await _dashboard_managed_skill(http_request, request, self._scope_ids) + resolved = await _dashboard_managed_skill(http_request, request) if isinstance(resolved, JSONResponse): return resolved application, skill = resolved @@ -128,7 +129,7 @@ async def publish( request: DashboardSkillPublishRequest, http_request: Request, ) -> DashboardSkillProjection | JSONResponse: - resolved = await _dashboard_managed_skill(http_request, request, self._scope_ids) + resolved = await _dashboard_managed_skill(http_request, request) if isinstance(resolved, JSONResponse): return resolved application, skill = resolved @@ -167,7 +168,6 @@ async def publish( def mount_web_ui( app: FastAPI, *, - scopes: Mapping[str, str], dashboard_enabled: bool = False, handoff_report_enabled: bool = False, authentication_required: bool = False, @@ -175,10 +175,8 @@ def mount_web_ui( ) -> None: """Mount Server-owned pages, static assets, and UI support endpoints.""" - dashboard_scopes = tuple(DashboardScope(scope_id=scope_id, display_name=name) for scope_id, name in scopes.items()) - dashboard_scope_ids = frozenset(scopes) publish_targets = tuple(target for target in agent_skill_targets if target.allow_managed_publish) - skill_projection_routes = _DashboardSkillProjectionRoutes(dashboard_scope_ids, publish_targets) + skill_projection_routes = _DashboardSkillProjectionRoutes(publish_targets) templates = _templates() if dashboard_enabled: templates.env.get_template("pages/dashboard.html") @@ -254,9 +252,9 @@ async def handoff_report_page(request: Request) -> Response: headers=_PAGE_HEADERS, ) - async def list_dashboard_scopes(response: Response) -> tuple[DashboardScope, ...]: + async def list_dashboard_scopes(request: Request, response: Response) -> tuple[DashboardScope, ...]: response.headers["Cache-Control"] = "no-store" - return dashboard_scopes + return await _dashboard_scopes(request.app.state.application) if dashboard_enabled: router.add_api_route( @@ -330,13 +328,16 @@ def _templates() -> Jinja2Templates: async def _dashboard_managed_skill( request: Request, selection: DashboardSkillProjectionRequest, - dashboard_scope_ids: frozenset[str], ): - if selection.scope_id not in dashboard_scope_ids: - return _web_error(404, "dashboard_scope_not_found", "The Dashboard scope was not found.") application = request.app.state.application if application is None: return _web_error(503, "runtime_not_ready", "The Runtime is not ready.") + if application.scopes is None: + return _web_error(503, "runtime_not_ready", "The Runtime is not ready.") + try: + await application.scopes.get(selection.scope_id) + except ScopeNotFoundError: + return _web_error(404, "dashboard_scope_not_found", "The Dashboard scope was not found.") candidate = await application.review.for_scope(selection.scope_id).get( GetArtifactCandidateRequest(candidate_id=selection.candidate_id) ) @@ -354,6 +355,20 @@ async def _dashboard_managed_skill( return application, skill +async def _dashboard_scopes(application) -> tuple[DashboardScope, ...]: + if application is None or application.scopes is None: + return () + return tuple( + DashboardScope( + scope_id=scope.scope_id, + display_name=scope.title, + summary=scope.summary, + parent_scope_id=scope.parent_scope_id, + ) + for scope in await application.scopes.list() + ) + + async def _skill_projection_response( application, scope_id: str, diff --git a/tests/builtin/runtime/test_statistics.py b/tests/builtin/runtime/test_statistics.py index a75721225..efbaea0a2 100644 --- a/tests/builtin/runtime/test_statistics.py +++ b/tests/builtin/runtime/test_statistics.py @@ -30,6 +30,7 @@ StatisticsPeriod, open_builtin_runtime, ) +from powercontext.builtin.scope import ScopeDraft, ScopeSelection from powercontext.builtin.sources import ContentSource @@ -149,3 +150,47 @@ async def scenario() -> None: assert result.recall.totals.baseline_tokens == estimator.estimate("a") + estimator.estimate("b") == 2 asyncio.run(scenario()) + + +def test_statistics_uses_the_same_all_exact_and_subtree_selection() -> None: + async def scenario() -> None: + async with open_builtin_runtime(BuiltinConfig(database=SQLiteConfig())) as runtime: + assert runtime.scopes is not None + root = await runtime.scopes.create(ScopeDraft(title="Root", summary="Root result", idempotency_key="root")) + child = await runtime.scopes.create( + ScopeDraft( + title="Child", + summary="Child result", + parent_scope_id=root.scope_id, + idempotency_key="child", + ) + ) + other = await runtime.scopes.create( + ScopeDraft(title="Other", summary="Other result", idempotency_key="other") + ) + for scope_id in (root.scope_id, child.scope_id, other.scope_id): + await runtime.memory.for_scope(scope_id).remember( + RememberMemoryRequest(entries=(MemoryEntryInput(kind="fact", text=f"Fact for {scope_id}."),)) + ) + + all_statistics = await runtime.statistics.overview( + ScopeSelection(mode="all"), + period=StatisticsPeriod.TODAY, + ) + subtree = await runtime.statistics.overview( + ScopeSelection(mode="subtree", root_scope_id=root.scope_id), + period=StatisticsPeriod.TODAY, + ) + exact = await runtime.statistics.overview( + ScopeSelection(mode="exact", scope_ids=(child.scope_id,)), + period=StatisticsPeriod.TODAY, + ) + + assert set(all_statistics.scope_ids) >= {root.scope_id, child.scope_id, other.scope_id} + assert all_statistics.inventory.memory.entries.total == 3 + assert subtree.scope_ids == (root.scope_id, child.scope_id) + assert subtree.inventory.memory.entries.total == 2 + assert exact.scope_ids == (child.scope_id,) + assert exact.inventory.memory.entries.total == 1 + + asyncio.run(scenario()) diff --git a/tests/e2e/test_statistics_flow.py b/tests/e2e/test_statistics_flow.py index d3b516549..c8c8d891e 100644 --- a/tests/e2e/test_statistics_flow.py +++ b/tests/e2e/test_statistics_flow.py @@ -98,6 +98,13 @@ def _client(app) -> tuple[httpx.AsyncClient, PowerContextClient]: ) +def _stats_request(scope_id: str, period: StatsPeriod) -> GetStatsRequest: + return GetStatsRequest.model_validate({ + "selection": {"mode": "exact", "scope_ids": [scope_id]}, + "period": period, + }) + + @pytest.mark.parametrize("database_kind", ["sqlite", "oceanbase"]) def test_statistics_survive_the_authenticated_http_business_flow_and_restart( database_kind: str, @@ -125,9 +132,17 @@ def test_statistics_survive_the_authenticated_http_business_flow_and_restart( first_app = create_server_app(settings=settings) async def scenario() -> None: + nonlocal scope_id async with first_app.router.lifespan_context(first_app): transport, client = _client(first_app) async with transport: + created_scope = await transport.post( + "/v1/scopes", + json={"title": "Statistics", "summary": "Statistics flow", "idempotency_key": scope_id}, + headers={"Authorization": f"Bearer {_AUTH_TOKEN}"}, + ) + assert created_scope.status_code == 201 + scope_id = created_scope.json()["scope_id"] source = await client.capture_content_source( CaptureContentSourceRequest( scope_id=scope_id, @@ -201,12 +216,15 @@ async def scenario() -> None: empty = await client.prepare_context( PrepareContextRequest(scope_id=scope_id, query="unrelated-zebra-phrase") ) - first = await client.get_stats(GetStatsRequest(scope_id=scope_id, period=StatsPeriod.TODAY)) + first = await client.get_stats(_stats_request(scope_id, StatsPeriod.TODAY)) - unauthorized = await transport.get("/v1/stats", params={"scope_id": scope_id}) - raw = await transport.get( + unauthorized = await transport.post( + "/v1/stats", + json={"selection": {"mode": "exact", "scope_ids": [scope_id]}}, + ) + raw = await transport.post( "/v1/stats", - params={"scope_id": scope_id, "period": "today"}, + json={"selection": {"mode": "exact", "scope_ids": [scope_id]}, "period": "today"}, headers={"Authorization": f"Bearer {_AUTH_TOKEN}"}, ) @@ -233,11 +251,11 @@ async def scenario() -> None: async with second_app.router.lifespan_context(second_app): transport, client = _client(second_app) async with transport: - restored = await client.get_stats(GetStatsRequest(scope_id=scope_id, period=StatsPeriod.FIELD_7D)) + restored = await client.get_stats(_stats_request(scope_id, StatsPeriod.FIELD_7D)) prepared_again = await client.prepare_context( PrepareContextRequest(scope_id=scope_id, query="statistics contract") ) - updated = await client.get_stats(GetStatsRequest(scope_id=scope_id, period=StatsPeriod.FIELD_7D)) + updated = await client.get_stats(_stats_request(scope_id, StatsPeriod.FIELD_7D)) assert restored.inventory == first.inventory assert restored.usage.totals == first.usage.totals diff --git a/tests/test_api_contract.py b/tests/test_api_contract.py index 537e827ef..3c68d1efb 100644 --- a/tests/test_api_contract.py +++ b/tests/test_api_contract.py @@ -163,14 +163,14 @@ def test_capture_operation_declares_its_typed_accepted_exchange() -> None: assert CAPTURE_CONTENT_SOURCE.success_status == 202 -def test_stats_operation_exposes_dashboard_ready_scoped_values() -> None: - assert GET_STATS.method == "GET" +def test_stats_operation_exposes_dashboard_ready_selection_values() -> None: + assert GET_STATS.method == "POST" assert GET_STATS.path == "/v1/stats" assert GET_STATS.request_type is GetStatsRequest - assert GET_STATS.request_location == "query" + assert GET_STATS.request_location == "body" assert GET_STATS.response_type is ScopedStats assert GET_STATS.success_status == 200 - assert GetStatsRequest(scope_id="project").period is StatsPeriod.FIELD_30D + assert GetStatsRequest.model_validate({"selection": {"mode": "all"}}).period is StatsPeriod.FIELD_30D contract = yaml.safe_load(CONTRACT_PATH.read_text()) schemas = contract["components"]["schemas"] @@ -179,10 +179,11 @@ def test_stats_operation_exposes_dashboard_ready_scoped_values() -> None: usage_value = schemas["ModelUsageValue"] recall = schemas["RecallTokenStatistics"] - operation = contract["paths"]["/v1/stats"]["get"] - assert "requestBody" not in operation - assert [parameter["name"] for parameter in operation["parameters"]] == ["scope_id", "period"] - assert set(stats["properties"]) == {"scope_id", "as_of", "inventory", "usage", "recall"} + operation = contract["paths"]["/v1/stats"]["post"] + assert operation["requestBody"]["content"]["application/json"]["schema"] == { + "$ref": "#/components/schemas/GetStatsRequest" + } + assert set(stats["properties"]) == {"selection", "scope_ids", "as_of", "inventory", "usage", "recall"} assert usage["properties"]["by_purpose"]["maxItems"] == 16 assert usage["properties"]["daily"]["maxItems"] == 30 assert usage_value["properties"]["input_tokens"]["nullable"] is True diff --git a/tests/test_dashboard.py b/tests/test_dashboard.py index f6de65ff2..793691e81 100644 --- a/tests/test_dashboard.py +++ b/tests/test_dashboard.py @@ -27,7 +27,6 @@ from powercontext.server.settings import ( BearerAuthConfig, DashboardConfig, - DashboardScopeConfig, McpConfig, ServerSettings, ) @@ -56,7 +55,6 @@ def test_dashboard_is_enabled_by_default_without_authentication_or_scopes(tmp_pa scopes = client.get("/dashboard/scopes") assert settings.dashboard.enabled is True - assert settings.dashboard.scopes == [] assert home.status_code == 200 assert skills.status_code == 200 assert review.status_code == 200 @@ -65,7 +63,9 @@ def test_dashboard_is_enabled_by_default_without_authentication_or_scopes(tmp_pa assert 'data-server-session="active"' in home.text assert 'data-server-auth-required="false"' in home.text assert scopes.status_code == 200 - assert scopes.json() == [] + assert scopes.json()[0]["display_name"] == "Default" + assert scopes.json()[0]["summary"] == "Default context" + assert scopes.json()[0]["parent_scope_id"] is None def test_dashboard_can_be_disabled_explicitly(tmp_path) -> None: @@ -118,19 +118,23 @@ def test_dashboard_is_the_authenticated_server_ui_entry(tmp_path) -> None: enabled=True, token=SecretStr("dashboard-secret"), ), - dashboard=DashboardConfig( - enabled=True, - scopes=[ - DashboardScopeConfig(scope_id="person:psiace", display_name="PsiACE"), - DashboardScopeConfig(scope_id="project:powercontext", display_name="PowerContext"), - ], - ), + dashboard=DashboardConfig(enabled=True), database=SQLiteConfig(url=f"sqlite+aiosqlite:///{tmp_path / 'dashboard.db'}"), mcp=McpConfig(enabled=False), ) ) with TestClient(app) as client: + first_scope = client.post( + "/v1/scopes", + headers=_AUTH_HEADERS, + json={"title": "PsiACE", "summary": "Personal context", "idempotency_key": "psiace"}, + ).json() + second_scope = client.post( + "/v1/scopes", + headers=_AUTH_HEADERS, + json={"title": "PowerContext", "summary": "Repository context", "idempotency_key": "powercontext"}, + ).json() home = client.get("/") skills = client.get("/skills") review = client.get("/reviews") @@ -188,10 +192,10 @@ def test_dashboard_is_the_authenticated_server_ui_entry(tmp_path) -> None: assert 'id="review-revision-title"' in review.text assert 'id="review-publish-dialog"' in review.text assert "review.js?v=agent-targets-v1" in review.text - assert scopes.json() == [ - {"scope_id": "person:psiace", "display_name": "PsiACE"}, - {"scope_id": "project:powercontext", "display_name": "PowerContext"}, - ] + returned = {item["scope_id"]: item for item in scopes.json()} + assert returned[first_scope["scope_id"]]["display_name"] == "PsiACE" + assert returned[first_scope["scope_id"]]["summary"] == "Personal context" + assert returned[second_scope["scope_id"]]["display_name"] == "PowerContext" def test_review_publishes_an_approved_managed_skill_into_configured_agent_targets(tmp_path) -> None: @@ -199,10 +203,7 @@ def test_review_publishes_an_approved_managed_skill_into_configured_agent_target claude_skill_root = tmp_path / "repository" / ".claude" / "skills" settings = ServerSettings( auth=BearerAuthConfig(enabled=True, token=SecretStr("dashboard-secret")), - dashboard=DashboardConfig( - enabled=True, - scopes=[DashboardScopeConfig(scope_id="project:powercontext", display_name="PowerContext")], - ), + dashboard=DashboardConfig(enabled=True), database=SQLiteConfig(url=f"sqlite+aiosqlite:///{tmp_path / 'managed-skill-publish.db'}"), external_skills=ExternalSkillsConfig( host_id="dashboard-test", @@ -228,11 +229,16 @@ def test_review_publishes_an_approved_managed_skill_into_configured_agent_target app = create_server_app(settings=settings) with TestClient(app) as client: + scope_id = client.post( + "/v1/scopes", + headers=_AUTH_HEADERS, + json={"title": "PowerContext", "summary": "Repository context", "idempotency_key": "powercontext"}, + ).json()["scope_id"] source = client.post( "/v1/sources/content", headers=_AUTH_HEADERS, json={ - "scope_id": "project:powercontext", + "scope_id": scope_id, "source_id": "managed-skill-evidence", "content": "The contract workflow was reviewed and its validation passed.", }, @@ -241,7 +247,7 @@ def test_review_publishes_an_approved_managed_skill_into_configured_agent_target "/v1/skill/propose", headers=_AUTH_HEADERS, json={ - "scope_id": "project:powercontext", + "scope_id": scope_id, "proposal": { "name": "review-contract-change", "description": "Use when changing the reviewed public contract.", @@ -256,13 +262,13 @@ def test_review_publishes_an_approved_managed_skill_into_configured_agent_target "/v1/artifact-candidates/approve", headers=_AUTH_HEADERS, json={ - "scope_id": "project:powercontext", + "scope_id": scope_id, "candidate_id": candidate["candidate_id"], "expected_version": candidate["version"], }, ).json() selection = { - "scope_id": "project:powercontext", + "scope_id": scope_id, "candidate_id": approved["candidate_id"], "artifact": approved["result_artifact"], } @@ -293,13 +299,13 @@ def test_review_publishes_an_approved_managed_skill_into_configured_agent_target registered = client.post( "/v1/external-skills/list", headers=_AUTH_HEADERS, - json={"scope_id": "project:powercontext", "include_unavailable": False}, + json={"scope_id": scope_id, "include_unavailable": False}, ) revision_source = client.post( "/v1/sources/content", headers=_AUTH_HEADERS, json={ - "scope_id": "project:powercontext", + "scope_id": scope_id, "source_id": "managed-skill-revision-evidence", "content": "The packaged contract must also be verified after regeneration.", }, @@ -308,7 +314,7 @@ def test_review_publishes_an_approved_managed_skill_into_configured_agent_target "/v1/skill/propose", headers=_AUTH_HEADERS, json={ - "scope_id": "project:powercontext", + "scope_id": scope_id, "proposal": { "name": "review-contract-change", "description": "Use when changing the reviewed public contract.", @@ -325,13 +331,13 @@ def test_review_publishes_an_approved_managed_skill_into_configured_agent_target "/v1/artifact-candidates/approve", headers=_AUTH_HEADERS, json={ - "scope_id": "project:powercontext", + "scope_id": scope_id, "candidate_id": revision_candidate["candidate_id"], "expected_version": revision_candidate["version"], }, ).json() revision_selection = { - "scope_id": "project:powercontext", + "scope_id": scope_id, "candidate_id": revision_approved["candidate_id"], "artifact": revision_approved["result_artifact"], } diff --git a/tests/test_js_operations.py b/tests/test_js_operations.py index 3ff76d830..26bfca21b 100644 --- a/tests/test_js_operations.py +++ b/tests/test_js_operations.py @@ -58,7 +58,7 @@ def test_js_operations_record_method_path_location_and_scope() -> None: "location": None, "scope": False, } - assert by_id["get_stats"]["location"] == "query" + assert by_id["get_stats"]["location"] == "body" assert by_id["remember_memory"]["location"] == "body" assert by_id["remember_memory"]["scope"] is True diff --git a/tests/test_server.py b/tests/test_server.py index 7cb939bea..8ed184264 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -134,7 +134,6 @@ def test_settings_load_server_environment(monkeypatch) -> None: assert settings.mcp.enabled is False assert settings.mcp.path == "/context" assert settings.dashboard.enabled is True - assert settings.dashboard.scopes == [] assert settings.external_skills.host_id == "workstation-1" assert settings.external_skills.targets[0].target_id == "codex-project" assert settings.external_skills.targets[0].path.as_posix() == "/srv/project/.agents/skills" From a14df2dc3598965f68ad91b9abe11fab0b71561c Mon Sep 17 00:00:00 2001 From: Chojan Shang Date: Thu, 27 Aug 2026 16:53:01 +0800 Subject: [PATCH 07/15] fix(scope): bound external reference index keys --- src/powercontext/builtin/persistence/tables.py | 3 ++- src/powercontext/builtin/scope/repository.py | 3 +++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/powercontext/builtin/persistence/tables.py b/src/powercontext/builtin/persistence/tables.py index fc59eed03..7055e74fd 100644 --- a/src/powercontext/builtin/persistence/tables.py +++ b/src/powercontext/builtin/persistence/tables.py @@ -115,8 +115,9 @@ def _entry_text_type(): Column("ordinal", Integer, primary_key=True), Column("kind", identity_string(MAX_SCOPE_EXTERNAL_REFERENCE_KIND_LENGTH), nullable=False), Column("value", String(MAX_SCOPE_SUMMARY_LENGTH), nullable=False), + Column("value_digest", identity_string(64), nullable=False), ForeignKeyConstraint(("scope_id",), ("pc_scopes.scope_id",), ondelete="CASCADE"), - UniqueConstraint("scope_id", "kind", "value", name="uq_pc_scope_external_references_value"), + UniqueConstraint("scope_id", "kind", "value_digest", name="uq_pc_scope_external_references_value"), CheckConstraint("ordinal >= 0", name="ck_pc_scope_external_references_ordinal_nonnegative"), ) diff --git a/src/powercontext/builtin/scope/repository.py b/src/powercontext/builtin/scope/repository.py index 326ba03b2..f3d704a6d 100644 --- a/src/powercontext/builtin/scope/repository.py +++ b/src/powercontext/builtin/scope/repository.py @@ -10,6 +10,8 @@ from __future__ import annotations +from hashlib import sha256 + from sqlalchemy import delete, insert, select, update from sqlalchemy.ext.asyncio import AsyncConnection @@ -274,6 +276,7 @@ async def _replace_relationships( "ordinal": ordinal, "kind": reference.kind, "value": reference.value, + "value_digest": sha256(reference.value.encode()).hexdigest(), } for ordinal, reference in enumerate(external_references) ], From 76817f96f8418f4212738e54376ce8222b578e8a Mon Sep 17 00:00:00 2001 From: Chojan Shang Date: Thu, 27 Aug 2026 16:53:17 +0800 Subject: [PATCH 08/15] feat(report): project handoffs by scope selection --- .../skills/project-context/SKILL.md | 15 +- .../powercontext/src/operations.generated.ts | 16 +- .../powercontext/src/operations.generated.ts | 16 +- .../skills/project-context/SKILL.md | 15 +- openapi/powercontext.yaml | 1959 ++++---------- .../builtin/handoff_report/__init__.py | 158 +- .../builtin/handoff_report/adapters.py | 54 +- .../builtin/handoff_report/application.py | 443 +--- .../builtin/handoff_report/canonical.py | 105 +- .../builtin/handoff_report/catalog.py | 223 -- .../builtin/handoff_report/catalog_store.py | 751 ------ .../builtin/handoff_report/errors.py | 179 +- .../builtin/handoff_report/models.py | 483 ---- .../builtin/handoff_report/protocols.py | 47 +- .../builtin/handoff_report/rendering.py | 467 +--- .../builtin/handoff_report/report.py | 393 +-- .../builtin/handoff_report/repository.py | 149 -- .../builtin/handoff_report/selection.py | 91 - .../builtin/handoff_report/service.py | 322 --- .../builtin/handoff_report/sqlite.py | 454 ---- .../builtin/handoff_report/workspace.py | 101 - .../builtin/handoff_report/workspace_store.py | 322 --- .../builtin/runtime/composition.py | 12 +- .../builtin/statistics/aggregation.py | 2 +- src/powercontext/builtin/statistics/models.py | 2 +- src/powercontext/client/client.py | 193 +- src/powercontext/http/__init__.py | 118 +- src/powercontext/http/_generated/models.py | 407 +-- .../http/_generated/operations.py | 335 --- src/powercontext/http/_generated/schema.py | 844 +----- src/powercontext/server/app.py | 327 +-- src/powercontext/server/handoff_picker.py | 505 ---- src/powercontext/server/mcp.py | 15 +- src/powercontext/server/static/dashboard.js | 26 +- .../server/static/handoff-report.js | 2263 ++--------------- .../server/static/scope-selection.js | 30 + .../templates/pages/handoff_report.html | 507 +--- src/powercontext/server/web.py | 17 +- tests/claude_code_plugin/test_contract.py | 4 +- tests/e2e/test_mcp_transport.py | 9 +- tests/e2e/test_runtime_server.py | 33 +- tests/test_cli.py | 8 +- tests/test_client.py | 15 +- tests/test_dashboard.py | 120 +- tests/test_dashboard_locale.py | 14 +- tests/test_handoff_report.py | 151 ++ tests/test_handoff_report_canonical.py | 86 - tests/test_handoff_report_catalog.py | 248 -- tests/test_handoff_report_http.py | 289 --- tests/test_handoff_report_models.py | 199 -- tests/test_handoff_report_repository.py | 280 -- tests/test_handoff_report_selection.py | 208 -- tests/test_handoff_report_service.py | 488 ---- tests/test_handoff_report_workspace.py | 173 -- tests/test_mcp.py | 240 +- tests/test_server.py | 23 +- 56 files changed, 1292 insertions(+), 13662 deletions(-) delete mode 100644 src/powercontext/builtin/handoff_report/catalog.py delete mode 100644 src/powercontext/builtin/handoff_report/catalog_store.py delete mode 100644 src/powercontext/builtin/handoff_report/models.py delete mode 100644 src/powercontext/builtin/handoff_report/repository.py delete mode 100644 src/powercontext/builtin/handoff_report/selection.py delete mode 100644 src/powercontext/builtin/handoff_report/service.py delete mode 100644 src/powercontext/builtin/handoff_report/sqlite.py delete mode 100644 src/powercontext/builtin/handoff_report/workspace.py delete mode 100644 src/powercontext/builtin/handoff_report/workspace_store.py delete mode 100644 src/powercontext/server/handoff_picker.py create mode 100644 src/powercontext/server/static/scope-selection.js create mode 100644 tests/test_handoff_report.py delete mode 100644 tests/test_handoff_report_canonical.py delete mode 100644 tests/test_handoff_report_catalog.py delete mode 100644 tests/test_handoff_report_http.py delete mode 100644 tests/test_handoff_report_models.py delete mode 100644 tests/test_handoff_report_repository.py delete mode 100644 tests/test_handoff_report_selection.py delete mode 100644 tests/test_handoff_report_service.py delete mode 100644 tests/test_handoff_report_workspace.py diff --git a/integrations/claude-code/plugins/powercontext/skills/project-context/SKILL.md b/integrations/claude-code/plugins/powercontext/skills/project-context/SKILL.md index 7ce8a6341..b2c5e802c 100644 --- a/integrations/claude-code/plugins/powercontext/skills/project-context/SKILL.md +++ b/integrations/claude-code/plugins/powercontext/skills/project-context/SKILL.md @@ -37,17 +37,10 @@ Then run the normal resolver command again and verify the same scope. The binding is stored below the checkout's Git directory and is not committed. Never infer one Workstream when multiple candidates remain consequential. -Before a durable one-turn Handoff or a `latest` Continue without an exact -Workstream, call `select_handoff_workstream` when that MCP tool is available. -Clients with MCP elicitation can present a native picker; otherwise the tool -returns structured choices. On `selected`, bind the returned `scope_id` with -`--bind-workstream`, run the normal resolver again, and require the resolved -scope to match before any Handoff write. On `needs_selection`, present the -returned choices and call the tool again with the user's exact `project_id` and -`work_id`; never choose a fallback candidate silently. On `cancelled` or -`declined`, stop the Handoff flow. If the tool is unavailable or returns -`empty`, preserve the existing resolver behavior. The picker is read-only and -selecting work does not itself prepare or commit a Handoff. +Before a durable one-turn Handoff or a `latest` Continue, resolve the intended +Scope explicitly. If the current binding is not the intended boundary, ask the +user or host for the exact Scope ID, bind it, and verify the resolver result +before any Handoff write. Never infer a Scope from a report view. ## Read diff --git a/integrations/opencode/plugins/powercontext/src/operations.generated.ts b/integrations/opencode/plugins/powercontext/src/operations.generated.ts index ab65d69f0..f6f47b093 100644 --- a/integrations/opencode/plugins/powercontext/src/operations.generated.ts +++ b/integrations/opencode/plugins/powercontext/src/operations.generated.ts @@ -66,21 +66,7 @@ export const OPERATIONS = { reject_artifact_candidate: { method: 'POST', path: '/v1/artifact-candidates/reject', location: "body", scope: true }, revise_artifact_candidate: { method: 'POST', path: '/v1/artifact-candidates/revise', location: "body", scope: true }, get_stats: { method: 'POST', path: '/v1/stats', location: "body", scope: false }, - create_handoff_report_project: { method: 'POST', path: '/v1/handoff-reports/projects/create', location: "body", scope: false }, - list_handoff_report_projects: { method: 'POST', path: '/v1/handoff-reports/projects/list', location: "body", scope: false }, - list_handoff_report_known_scopes: { method: 'POST', path: '/v1/handoff-reports/scopes/list-known', location: "body", scope: false }, - get_handoff_report_project: { method: 'POST', path: '/v1/handoff-reports/projects/get', location: "body", scope: false }, - update_handoff_report_project: { method: 'POST', path: '/v1/handoff-reports/projects/update', location: "body", scope: false }, - register_handoff_report_workstream: { method: 'POST', path: '/v1/handoff-reports/workstreams/register', location: "body", scope: true }, - list_handoff_report_workstreams: { method: 'POST', path: '/v1/handoff-reports/workstreams/list', location: "body", scope: false }, - update_handoff_report_workstream: { method: 'POST', path: '/v1/handoff-reports/workstreams/update', location: "body", scope: false }, - get_handoff_report: { method: 'POST', path: '/v1/handoff-reports/get', location: "body", scope: true }, - record_handoff_report_activity: { method: 'POST', path: '/v1/handoff-reports/activities/record', location: "body", scope: true }, - list_handoff_report_activities: { method: 'POST', path: '/v1/handoff-reports/activities/list', location: "body", scope: false }, - purge_handoff_report_activities: { method: 'POST', path: '/v1/handoff-reports/activities/purge', location: "body", scope: false }, - get_handoff_report_workspace: { method: 'POST', path: '/v1/handoff-reports/workspace-bindings/get', location: "body", scope: false }, - attach_handoff_report_workspace: { method: 'POST', path: '/v1/handoff-reports/workspace-bindings/attach', location: "body", scope: false }, - detach_handoff_report_workspace: { method: 'POST', path: '/v1/handoff-reports/workspace-bindings/detach', location: "body", scope: false }, + get_handoff_report: { method: 'POST', path: '/v1/handoff-reports/get', location: "body", scope: false }, } as const export type OperationId = keyof typeof OPERATIONS diff --git a/integrations/pi/plugins/powercontext/src/operations.generated.ts b/integrations/pi/plugins/powercontext/src/operations.generated.ts index ab65d69f0..f6f47b093 100644 --- a/integrations/pi/plugins/powercontext/src/operations.generated.ts +++ b/integrations/pi/plugins/powercontext/src/operations.generated.ts @@ -66,21 +66,7 @@ export const OPERATIONS = { reject_artifact_candidate: { method: 'POST', path: '/v1/artifact-candidates/reject', location: "body", scope: true }, revise_artifact_candidate: { method: 'POST', path: '/v1/artifact-candidates/revise', location: "body", scope: true }, get_stats: { method: 'POST', path: '/v1/stats', location: "body", scope: false }, - create_handoff_report_project: { method: 'POST', path: '/v1/handoff-reports/projects/create', location: "body", scope: false }, - list_handoff_report_projects: { method: 'POST', path: '/v1/handoff-reports/projects/list', location: "body", scope: false }, - list_handoff_report_known_scopes: { method: 'POST', path: '/v1/handoff-reports/scopes/list-known', location: "body", scope: false }, - get_handoff_report_project: { method: 'POST', path: '/v1/handoff-reports/projects/get', location: "body", scope: false }, - update_handoff_report_project: { method: 'POST', path: '/v1/handoff-reports/projects/update', location: "body", scope: false }, - register_handoff_report_workstream: { method: 'POST', path: '/v1/handoff-reports/workstreams/register', location: "body", scope: true }, - list_handoff_report_workstreams: { method: 'POST', path: '/v1/handoff-reports/workstreams/list', location: "body", scope: false }, - update_handoff_report_workstream: { method: 'POST', path: '/v1/handoff-reports/workstreams/update', location: "body", scope: false }, - get_handoff_report: { method: 'POST', path: '/v1/handoff-reports/get', location: "body", scope: true }, - record_handoff_report_activity: { method: 'POST', path: '/v1/handoff-reports/activities/record', location: "body", scope: true }, - list_handoff_report_activities: { method: 'POST', path: '/v1/handoff-reports/activities/list', location: "body", scope: false }, - purge_handoff_report_activities: { method: 'POST', path: '/v1/handoff-reports/activities/purge', location: "body", scope: false }, - get_handoff_report_workspace: { method: 'POST', path: '/v1/handoff-reports/workspace-bindings/get', location: "body", scope: false }, - attach_handoff_report_workspace: { method: 'POST', path: '/v1/handoff-reports/workspace-bindings/attach', location: "body", scope: false }, - detach_handoff_report_workspace: { method: 'POST', path: '/v1/handoff-reports/workspace-bindings/detach', location: "body", scope: false }, + get_handoff_report: { method: 'POST', path: '/v1/handoff-reports/get', location: "body", scope: false }, } as const export type OperationId = keyof typeof OPERATIONS diff --git a/integrations/workbuddy/plugins/powercontext/skills/project-context/SKILL.md b/integrations/workbuddy/plugins/powercontext/skills/project-context/SKILL.md index ee12870fe..1be81445e 100644 --- a/integrations/workbuddy/plugins/powercontext/skills/project-context/SKILL.md +++ b/integrations/workbuddy/plugins/powercontext/skills/project-context/SKILL.md @@ -44,17 +44,10 @@ Then run the normal resolver command again and verify the same scope. The binding is stored below the checkout's Git directory and is not committed. Never infer one Workstream when multiple candidates remain consequential. -Before a durable one-turn Handoff or a `latest` Continue without an exact -Workstream, call `select_handoff_workstream` when that MCP tool is available. -Clients with MCP elicitation can present a native picker; otherwise the tool -returns structured choices. On `selected`, bind the returned `scope_id` with -`--bind-workstream`, run the normal resolver again, and require the resolved -scope to match before any Handoff write. On `needs_selection`, present the -returned choices and call the tool again with the user's exact `project_id` and -`work_id`; never choose a fallback candidate silently. On `cancelled` or -`declined`, stop the Handoff flow. If the tool is unavailable or returns -`empty`, preserve the existing resolver behavior. The picker is read-only and -selecting work does not itself prepare or commit a Handoff. +Before a durable one-turn Handoff or a `latest` Continue, resolve the intended +Scope explicitly. If the current binding is not the intended boundary, ask the +user or host for the exact Scope ID, bind it, and verify the resolver result +before any Handoff write. Never infer a Scope from a report view. ## Read diff --git a/openapi/powercontext.yaml b/openapi/powercontext.yaml index e69aff948..25c653946 100644 --- a/openapi/powercontext.yaml +++ b/openapi/powercontext.yaml @@ -1457,240 +1457,6 @@ paths: $ref: "#/components/responses/Unavailable" "500": $ref: "#/components/responses/InternalError" - /v1/handoff-reports/projects/create: - post: - tags: [handoff-reports] - summary: Create a Handoff Report Project - operationId: create_handoff_report_project - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/CreateHandoffReportProjectRequest" - responses: - "201": - description: The created Report Project. - headers: - X-PowerContext-Request-ID: - $ref: "#/components/headers/RequestId" - content: - application/json: - schema: - $ref: "#/components/schemas/ProjectDescriptor" - "409": - $ref: "#/components/responses/Conflict" - "401": - $ref: "#/components/responses/Unauthorized" - "422": - $ref: "#/components/responses/InvalidRequest" - "500": - $ref: "#/components/responses/InternalError" - /v1/handoff-reports/projects/list: - post: - tags: [handoff-reports] - summary: List Handoff Report Projects - operationId: list_handoff_report_projects - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/ListHandoffReportProjectsRequest" - responses: - "200": - description: A cursor-paginated page of Report Projects. - headers: - X-PowerContext-Request-ID: - $ref: "#/components/headers/RequestId" - content: - application/json: - schema: - $ref: "#/components/schemas/ProjectPage" - "401": - $ref: "#/components/responses/Unauthorized" - "422": - $ref: "#/components/responses/InvalidRequest" - "500": - $ref: "#/components/responses/InternalError" - /v1/handoff-reports/scopes/list-known: - post: - tags: [handoff-reports] - summary: List scopes that contain a committed Handoff - operationId: list_handoff_report_known_scopes - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/ListHandoffReportKnownScopesRequest" - responses: - "200": - description: A cursor-paginated page of scopes that can be rendered as Handoff Reports. - headers: - X-PowerContext-Request-ID: - $ref: "#/components/headers/RequestId" - content: - application/json: - schema: - $ref: "#/components/schemas/KnownHandoffScopePage" - "401": - $ref: "#/components/responses/Unauthorized" - "422": - $ref: "#/components/responses/InvalidRequest" - "500": - $ref: "#/components/responses/InternalError" - /v1/handoff-reports/projects/get: - post: - tags: [handoff-reports] - summary: Get a Handoff Report Project - operationId: get_handoff_report_project - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/GetHandoffReportProjectRequest" - responses: - "200": - description: The exact current Report Project descriptor. - headers: - X-PowerContext-Request-ID: - $ref: "#/components/headers/RequestId" - content: - application/json: - schema: - $ref: "#/components/schemas/ProjectDescriptor" - "404": - $ref: "#/components/responses/NotFound" - "401": - $ref: "#/components/responses/Unauthorized" - "422": - $ref: "#/components/responses/InvalidRequest" - "500": - $ref: "#/components/responses/InternalError" - /v1/handoff-reports/projects/update: - post: - tags: [handoff-reports] - summary: Update a Handoff Report Project - operationId: update_handoff_report_project - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/UpdateHandoffReportProjectRequest" - responses: - "200": - description: The updated Report Project descriptor. - headers: - X-PowerContext-Request-ID: - $ref: "#/components/headers/RequestId" - content: - application/json: - schema: - $ref: "#/components/schemas/ProjectDescriptor" - "404": - $ref: "#/components/responses/NotFound" - "409": - $ref: "#/components/responses/Conflict" - "401": - $ref: "#/components/responses/Unauthorized" - "422": - $ref: "#/components/responses/InvalidRequest" - "500": - $ref: "#/components/responses/InternalError" - /v1/handoff-reports/workstreams/register: - post: - tags: [handoff-reports] - summary: Register a Handoff Report Workstream - operationId: register_handoff_report_workstream - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/RegisterHandoffReportWorkstreamRequest" - responses: - "201": - description: The registered Report Workstream. - headers: - X-PowerContext-Request-ID: - $ref: "#/components/headers/RequestId" - content: - application/json: - schema: - $ref: "#/components/schemas/WorkstreamDescriptor" - "404": - $ref: "#/components/responses/NotFound" - "409": - $ref: "#/components/responses/Conflict" - "401": - $ref: "#/components/responses/Unauthorized" - "422": - $ref: "#/components/responses/InvalidRequest" - "500": - $ref: "#/components/responses/InternalError" - /v1/handoff-reports/workstreams/list: - post: - tags: [handoff-reports] - summary: List Handoff Report Workstreams - operationId: list_handoff_report_workstreams - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/ListHandoffReportWorkstreamsRequest" - responses: - "200": - description: A cursor-paginated page of Report Workstreams. - headers: - X-PowerContext-Request-ID: - $ref: "#/components/headers/RequestId" - content: - application/json: - schema: - $ref: "#/components/schemas/WorkstreamPage" - "404": - $ref: "#/components/responses/NotFound" - "401": - $ref: "#/components/responses/Unauthorized" - "422": - $ref: "#/components/responses/InvalidRequest" - "500": - $ref: "#/components/responses/InternalError" - /v1/handoff-reports/workstreams/update: - post: - tags: [handoff-reports] - summary: Update a Handoff Report Workstream - operationId: update_handoff_report_workstream - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/UpdateHandoffReportWorkstreamRequest" - responses: - "200": - description: The updated Report Workstream descriptor. - headers: - X-PowerContext-Request-ID: - $ref: "#/components/headers/RequestId" - content: - application/json: - schema: - $ref: "#/components/schemas/WorkstreamDescriptor" - "404": - $ref: "#/components/responses/NotFound" - "409": - $ref: "#/components/responses/Conflict" - "401": - $ref: "#/components/responses/Unauthorized" - "422": - $ref: "#/components/responses/InvalidRequest" - "500": - $ref: "#/components/responses/InternalError" /v1/handoff-reports/get: post: tags: [handoff-reports] @@ -1746,186 +1512,6 @@ paths: $ref: "#/components/responses/Unavailable" "500": $ref: "#/components/responses/InternalError" - /v1/handoff-reports/activities/record: - post: - tags: [handoff-reports] - summary: Record a Handoff Report Activity - operationId: record_handoff_report_activity - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/RecordHandoffReportActivityRequest" - responses: - "201": - description: The idempotently recorded Report Activity. - headers: - X-PowerContext-Request-ID: - $ref: "#/components/headers/RequestId" - content: - application/json: - schema: - $ref: "#/components/schemas/StoredHandoffReportActivity" - "404": - $ref: "#/components/responses/NotFound" - "409": - $ref: "#/components/responses/Conflict" - "401": - $ref: "#/components/responses/Unauthorized" - "422": - $ref: "#/components/responses/InvalidRequest" - "500": - $ref: "#/components/responses/InternalError" - /v1/handoff-reports/activities/list: - post: - tags: [handoff-reports] - summary: List Handoff Report Activities - operationId: list_handoff_report_activities - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/ListHandoffReportActivitiesRequest" - responses: - "200": - description: A frozen cursor page of Report Activities. - headers: - X-PowerContext-Request-ID: - $ref: "#/components/headers/RequestId" - content: - application/json: - schema: - $ref: "#/components/schemas/HandoffReportActivityPage" - "404": - $ref: "#/components/responses/NotFound" - "401": - $ref: "#/components/responses/Unauthorized" - "422": - $ref: "#/components/responses/InvalidRequest" - "500": - $ref: "#/components/responses/InternalError" - /v1/handoff-reports/activities/purge: - post: - tags: [handoff-reports] - summary: Purge Handoff Report Activities - operationId: purge_handoff_report_activities - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/PurgeHandoffReportActivitiesRequest" - responses: - "200": - description: The number of deleted Report-owned Activity rows. - headers: - X-PowerContext-Request-ID: - $ref: "#/components/headers/RequestId" - content: - application/json: - schema: - $ref: "#/components/schemas/PurgeHandoffReportActivitiesResponse" - "404": - $ref: "#/components/responses/NotFound" - "401": - $ref: "#/components/responses/Unauthorized" - "422": - $ref: "#/components/responses/InvalidRequest" - "500": - $ref: "#/components/responses/InternalError" - /v1/handoff-reports/workspace-bindings/get: - post: - tags: [handoff-reports] - summary: Get a Handoff Report Workspace Binding - operationId: get_handoff_report_workspace - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/GetHandoffReportWorkspaceRequest" - responses: - "200": - description: The confirmed Workspace binding. - headers: - X-PowerContext-Request-ID: - $ref: "#/components/headers/RequestId" - content: - application/json: - schema: - $ref: "#/components/schemas/HandoffReportWorkspaceBinding" - "404": - $ref: "#/components/responses/NotFound" - "401": - $ref: "#/components/responses/Unauthorized" - "422": - $ref: "#/components/responses/InvalidRequest" - "500": - $ref: "#/components/responses/InternalError" - /v1/handoff-reports/workspace-bindings/attach: - post: - tags: [handoff-reports] - summary: Attach a Handoff Report Workspace Binding - operationId: attach_handoff_report_workspace - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/AttachHandoffReportWorkspaceRequest" - responses: - "200": - description: The confirmed Workspace binding. - headers: - X-PowerContext-Request-ID: - $ref: "#/components/headers/RequestId" - content: - application/json: - schema: - $ref: "#/components/schemas/HandoffReportWorkspaceBinding" - "404": - $ref: "#/components/responses/NotFound" - "409": - $ref: "#/components/responses/Conflict" - "401": - $ref: "#/components/responses/Unauthorized" - "422": - $ref: "#/components/responses/InvalidRequest" - "500": - $ref: "#/components/responses/InternalError" - /v1/handoff-reports/workspace-bindings/detach: - post: - tags: [handoff-reports] - summary: Detach a Handoff Report Workspace Binding - operationId: detach_handoff_report_workspace - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/DetachHandoffReportWorkspaceRequest" - responses: - "200": - description: The detached Workspace binding record. - headers: - X-PowerContext-Request-ID: - $ref: "#/components/headers/RequestId" - content: - application/json: - schema: - $ref: "#/components/schemas/HandoffReportWorkspaceBinding" - "404": - $ref: "#/components/responses/NotFound" - "409": - $ref: "#/components/responses/Conflict" - "401": - $ref: "#/components/responses/Unauthorized" - "422": - $ref: "#/components/responses/InvalidRequest" - "500": - $ref: "#/components/responses/InternalError" components: securitySchemes: BearerAuth: @@ -3205,1315 +2791,648 @@ components: CommitHandoffRequest: type: object additionalProperties: false - required: [scope_id, handoff] - properties: - scope_id: - type: string - minLength: 1 - maxLength: 256 - pattern: '.*\S.*' - handoff: - $ref: "#/components/schemas/PreparedHandoff" - CommittedHandoff: - type: object - additionalProperties: false - required: [reference, content, source_refs, artifact_refs] - properties: - reference: - $ref: "#/components/schemas/ArtifactReference" - content: - $ref: "#/components/schemas/HandoffContent" - source_refs: - type: array - items: - $ref: "#/components/schemas/SourceReference" - artifact_refs: - type: array - items: - $ref: "#/components/schemas/ArtifactReference" - ContinueHandoffRequest: - type: object - additionalProperties: false - required: [scope_id, selection] - properties: - scope_id: - type: string - minLength: 1 - maxLength: 256 - pattern: '.*\S.*' - selection: - $ref: "#/components/schemas/HandoffSelection" - prepared: - $ref: "#/components/schemas/PreparedHandoff" - nullable: true - revision: - $ref: "#/components/schemas/ArtifactReference" - nullable: true - FinalizeHandoffRequest: - type: object - additionalProperties: false - required: [scope_id, draft] - properties: - scope_id: - type: string - minLength: 1 - maxLength: 256 - pattern: '.*\S.*' - draft: - $ref: "#/components/schemas/HandoffDraft" - HandoffArtifactCitation: - type: object - additionalProperties: false - required: [kind, artifact_ref] - properties: - kind: - type: string - enum: [artifact] - artifact_ref: - $ref: "#/components/schemas/ArtifactReference" - HandoffActivation: - type: object - additionalProperties: false - required: [status, boundary_source, previous_position, current_position, draft] - properties: - status: - $ref: "#/components/schemas/HandoffActivationStatus" - boundary_source: - $ref: "#/components/schemas/SourceReference" - previous_position: - type: integer - minimum: 0 - current_position: - type: integer - minimum: 0 - draft: - $ref: "#/components/schemas/HandoffDraft" - nullable: true - HandoffCitation: - oneOf: - - $ref: "#/components/schemas/HandoffSourceCitation" - - $ref: "#/components/schemas/HandoffArtifactCitation" - - $ref: "#/components/schemas/HandoffMemoryCitation" - discriminator: - propertyName: kind - mapping: - source: "#/components/schemas/HandoffSourceCitation" - artifact: "#/components/schemas/HandoffArtifactCitation" - memory: "#/components/schemas/HandoffMemoryCitation" - HandoffContent: - type: object - additionalProperties: false - required: [schema, objective, state, disposition, next_action, omissions] - properties: - schema: - $ref: "#/components/schemas/HandoffSchema" - objective: - type: string - minLength: 1 - maxLength: 8192 - pattern: '.*\S.*' - state: - type: array - minItems: 1 - maxItems: 64 - items: - $ref: "#/components/schemas/HandoffStatement" - disposition: - $ref: "#/components/schemas/HandoffDisposition" - next_action: - $ref: "#/components/schemas/HandoffStatement" - nullable: true - omissions: - type: array - maxItems: 64 - items: - $ref: "#/components/schemas/HandoffOmission" - HandoffDraft: - type: object - additionalProperties: false - required: [objective, state, disposition, next_action, omissions] - properties: - objective: - type: string - minLength: 1 - maxLength: 8192 - pattern: '.*\S.*' - state: - type: array - minItems: 1 - maxItems: 64 - items: - $ref: "#/components/schemas/HandoffStatement" - disposition: - $ref: "#/components/schemas/HandoffDisposition" - next_action: - $ref: "#/components/schemas/HandoffStatement" - nullable: true - omissions: - type: array - maxItems: 64 - items: - $ref: "#/components/schemas/HandoffOmission" - HandoffEvidenceCheck: - type: object - additionalProperties: false - required: [claim, state_index, status, unavailable_evidence] - properties: - claim: - $ref: "#/components/schemas/HandoffClaim" - state_index: - type: integer - minimum: 0 - nullable: true - status: - $ref: "#/components/schemas/HandoffEvidenceStatus" - unavailable_evidence: - type: array - maxItems: 32 - items: - $ref: "#/components/schemas/HandoffCitation" - HandoffMemoryCitation: - type: object - additionalProperties: false - required: [kind, memory_citation] - properties: - kind: - type: string - enum: [memory] - memory_citation: - $ref: "#/components/schemas/MemoryCitation" - HandoffOmission: - type: object - additionalProperties: false - required: [text, citation] - properties: - text: - type: string - minLength: 1 - maxLength: 8192 - pattern: '.*\S.*' - citation: - $ref: "#/components/schemas/HandoffCitation" - nullable: true - HandoffResolution: - type: object - additionalProperties: false - required: - [trust, status, scope_id, content, selection, selected_revision, current_revision, evidence_checks] - properties: - trust: - type: string - enum: [untrusted_history] - status: - $ref: "#/components/schemas/HandoffResolutionStatus" - scope_id: - type: string - content: - $ref: "#/components/schemas/HandoffContent" - nullable: true - selection: - $ref: "#/components/schemas/HandoffSelection" - nullable: true - selected_revision: - $ref: "#/components/schemas/ArtifactReference" - nullable: true - current_revision: - $ref: "#/components/schemas/ArtifactReference" - nullable: true - evidence_checks: - type: array - maxItems: 65 - items: - $ref: "#/components/schemas/HandoffEvidenceCheck" - HandoffSourceCitation: - type: object - additionalProperties: false - required: [kind, source_ref] - properties: - kind: - type: string - enum: [source] - source_ref: - $ref: "#/components/schemas/SourceReference" - HandoffStatement: - type: object - additionalProperties: false - required: [text, citations] - properties: - text: - type: string - minLength: 1 - maxLength: 8192 - pattern: '.*\S.*' - citations: - type: array - minItems: 1 - maxItems: 32 - items: - $ref: "#/components/schemas/HandoffCitation" - PrepareHandoffRequest: - type: object - additionalProperties: false - required: [scope_id, objective, evidence] - properties: - scope_id: - type: string - minLength: 1 - maxLength: 256 - pattern: '.*\S.*' - objective: - type: string - minLength: 1 - maxLength: 8192 - pattern: '.*\S.*' - evidence: - type: array - minItems: 1 - maxItems: 32 - items: - $ref: "#/components/schemas/HandoffCitation" - max_bytes: - type: integer - minimum: 512 - maximum: 32768 - default: 8000 - PreparedHandoff: - type: object - additionalProperties: false - required: [schema, scope_id, base, content] - properties: - schema: - $ref: "#/components/schemas/PreparedHandoffSchema" - scope_id: - type: string - base: - $ref: "#/components/schemas/ArtifactReference" - nullable: true - content: - $ref: "#/components/schemas/HandoffContent" - PreparedContext: - type: object - additionalProperties: false - required: [schema, status, content, content_bytes] - properties: - schema: - $ref: "#/components/schemas/PreparedContextSchema" - status: - $ref: "#/components/schemas/PreparedContextStatus" - content: - type: string - nullable: true - content_bytes: - type: integer - minimum: 0 - EntryChange: - type: object - additionalProperties: false - required: [op, entry_id, from_entry_version_id, to_entry_version_id, reason] - properties: - op: - $ref: "#/components/schemas/EntryChangeOperation" - entry_id: - type: string - minLength: 1 - maxLength: 128 - pattern: '^[\x21-\x7E]+$' - from_entry_version_id: - type: string - minLength: 1 - maxLength: 128 - pattern: '^[\x21-\x7E]+$' - nullable: true - to_entry_version_id: - type: string - minLength: 1 - maxLength: 128 - pattern: '^[\x21-\x7E]+$' - nullable: true - reason: - type: string - nullable: true - ExperienceArtifact: - type: object - additionalProperties: false - required: [artifact, content, source_refs, artifact_refs] - properties: - artifact: - $ref: "#/components/schemas/ArtifactReference" - content: - $ref: "#/components/schemas/ExperienceProposal" - source_refs: - type: array - items: - $ref: "#/components/schemas/SourceReference" - artifact_refs: - type: array - items: - $ref: "#/components/schemas/ArtifactReference" - ExperienceProposal: - type: object - additionalProperties: false - required: [situation, action, outcome, lesson] - properties: - situation: - type: string - minLength: 1 - maxLength: 8000 - pattern: '.*\S.*' - action: - type: string - minLength: 1 - maxLength: 8000 - pattern: '.*\S.*' - outcome: - type: string - minLength: 1 - maxLength: 8000 - pattern: '.*\S.*' - lesson: - type: string - minLength: 1 - maxLength: 8000 - pattern: '.*\S.*' - SkillArtifact: - type: object - additionalProperties: false - required: [artifact, content, source_refs, artifact_refs] - properties: - artifact: - $ref: "#/components/schemas/ArtifactReference" - content: - $ref: "#/components/schemas/SkillProposal" - source_refs: - type: array - items: - $ref: "#/components/schemas/SourceReference" - artifact_refs: - type: array - items: - $ref: "#/components/schemas/ArtifactReference" - SkillProposal: - type: object - additionalProperties: false - required: [name, description, instructions, validation] - properties: - name: - type: string - minLength: 1 - maxLength: 128 - pattern: '^\S(?:.*\S)?$' - description: - type: string - minLength: 1 - maxLength: 2000 - pattern: '^\S(?:.*\S)?$' - instructions: - type: string - minLength: 1 - maxLength: 32000 - pattern: '.*\S.*' - validation: - type: array - minItems: 1 - maxItems: 32 - items: - $ref: "#/components/schemas/SkillValidationItem" - SkillValidationItem: - type: string - minLength: 1 - maxLength: 2000 - pattern: '^\S(?:.*\S)?$' - ExternalSkillRegistration: - type: object - additionalProperties: false - required: - - external_skill_id - - provider - - agent_kind - - host_id - - installation_scope - - locator - - fingerprint - - name - - description - properties: - external_skill_id: - type: string - minLength: 1 - maxLength: 128 - pattern: '^[\x21-\x7E]+$' - provider: - type: string - enum: [codex, claude_code] - agent_kind: - type: string - enum: [codex, claude_code] - host_id: - type: string - minLength: 1 - maxLength: 128 - pattern: '^\S(?:.*\S)?$' - installation_scope: - $ref: "#/components/schemas/ExternalSkillInstallationScope" - locator: - type: string - minLength: 1 - maxLength: 2000 - pattern: '^\S(?:.*\S)?$' - description: Host-local locator; not a cross-Agent or cross-host contract. - fingerprint: - type: string - pattern: '^[0-9a-f]{64}$' - name: - type: string - minLength: 1 - maxLength: 128 - pattern: '^\S(?:.*\S)?$' - description: - type: string - minLength: 1 - maxLength: 2000 - pattern: '^\S(?:.*\S)?$' - ExternalSkillResolution: - type: object - additionalProperties: false - required: [registration, status, entrypoint] - properties: - registration: - $ref: "#/components/schemas/ExternalSkillRegistration" - status: - $ref: "#/components/schemas/ExternalSkillResolutionStatus" - entrypoint: - type: string - nullable: true - description: Host-local SKILL.md path; present only when the exact fingerprint is available. - ScanExternalSkillsResponse: - type: object - additionalProperties: false - required: [registrations, skipped] - properties: - registrations: - type: array - items: - $ref: "#/components/schemas/ExternalSkillRegistration" - skipped: - type: integer - minimum: 0 - ListExternalSkillsResponse: - type: object - additionalProperties: false - required: [skills] - properties: - skills: - type: array - items: - $ref: "#/components/schemas/ExternalSkillResolution" - ErrorDetail: - type: object - additionalProperties: false - required: [code, message, details] - properties: - code: - type: string - message: - type: string - details: - type: object - additionalProperties: true - nullable: true - ErrorResponse: - type: object - additionalProperties: false - required: [error] - properties: - error: - $ref: "#/components/schemas/ErrorDetail" - FlushMemoryRequest: - type: object - additionalProperties: false - required: [scope_id] - properties: - scope_id: - type: string - minLength: 1 - maxLength: 256 - pattern: '.*\S.*' - FlushMemoryResponse: - type: object - additionalProperties: false - required: [status, previous_cursor, current_cursor, high_watermark, processed_source_count] - properties: - status: - $ref: "#/components/schemas/FlushStatus" - previous_cursor: - type: integer - minimum: 0 - current_cursor: - type: integer - minimum: 0 - high_watermark: - type: integer - minimum: 0 - processed_source_count: - type: integer - minimum: 0 - memory: - $ref: "#/components/schemas/ArtifactReference" - nullable: true - GetMemoryEntryRequest: - type: object - additionalProperties: false - required: [scope_id, citation] - properties: - scope_id: - type: string - minLength: 1 - maxLength: 256 - pattern: '.*\S.*' - citation: - $ref: "#/components/schemas/MemoryCitation" - GetArtifactCandidateRequest: - type: object - additionalProperties: false - required: [scope_id, candidate_id] - properties: - scope_id: - type: string - minLength: 1 - maxLength: 256 - pattern: '.*\S.*' - candidate_id: - type: string - minLength: 1 - maxLength: 128 - pattern: '^[\x21-\x7E]+$' - GetExperienceRequest: - type: object - additionalProperties: false - required: [scope_id, artifact] + required: [scope_id, handoff] properties: scope_id: type: string minLength: 1 maxLength: 256 pattern: '.*\S.*' - artifact: + handoff: + $ref: "#/components/schemas/PreparedHandoff" + CommittedHandoff: + type: object + additionalProperties: false + required: [reference, content, source_refs, artifact_refs] + properties: + reference: $ref: "#/components/schemas/ArtifactReference" - GetSkillRequest: + content: + $ref: "#/components/schemas/HandoffContent" + source_refs: + type: array + items: + $ref: "#/components/schemas/SourceReference" + artifact_refs: + type: array + items: + $ref: "#/components/schemas/ArtifactReference" + ContinueHandoffRequest: type: object additionalProperties: false - required: [scope_id, artifact] + required: [scope_id, selection] properties: scope_id: type: string minLength: 1 maxLength: 256 pattern: '.*\S.*' - artifact: + selection: + $ref: "#/components/schemas/HandoffSelection" + prepared: + $ref: "#/components/schemas/PreparedHandoff" + nullable: true + revision: $ref: "#/components/schemas/ArtifactReference" - CreateHandoffReportProjectRequest: + nullable: true + FinalizeHandoffRequest: type: object additionalProperties: false - required: [project_key, title] + required: [scope_id, draft] properties: - project_key: - type: string - minLength: 1 - maxLength: 64 - title: - type: string - minLength: 1 - maxLength: 256 - description: - type: string - maxLength: 2000 - nullable: true - default_locale: - $ref: "#/components/schemas/ReportLocale" - default: zh-CN - timezone: + scope_id: type: string minLength: 1 maxLength: 256 - default: UTC - ListHandoffReportProjectsRequest: - type: object - additionalProperties: false - properties: - cursor: - type: string - nullable: true - limit: - type: integer - minimum: 1 - maximum: 100 - default: 50 - include_archived: - type: boolean - default: false - GetHandoffReportProjectRequest: + pattern: '.*\S.*' + draft: + $ref: "#/components/schemas/HandoffDraft" + HandoffArtifactCitation: type: object additionalProperties: false - required: [project_id] + required: [kind, artifact_ref] properties: - project_id: + kind: type: string - minLength: 1 - maxLength: 256 - UpdateHandoffReportProjectRequest: + enum: [artifact] + artifact_ref: + $ref: "#/components/schemas/ArtifactReference" + HandoffActivation: type: object additionalProperties: false - required: [project, expected_version] + required: [status, boundary_source, previous_position, current_position, draft] properties: - project: - $ref: "#/components/schemas/ProjectDescriptor" - expected_version: + status: + $ref: "#/components/schemas/HandoffActivationStatus" + boundary_source: + $ref: "#/components/schemas/SourceReference" + previous_position: type: integer - minimum: 1 - RegisterHandoffReportWorkstreamRequest: + minimum: 0 + current_position: + type: integer + minimum: 0 + draft: + $ref: "#/components/schemas/HandoffDraft" + nullable: true + HandoffCitation: + oneOf: + - $ref: "#/components/schemas/HandoffSourceCitation" + - $ref: "#/components/schemas/HandoffArtifactCitation" + - $ref: "#/components/schemas/HandoffMemoryCitation" + discriminator: + propertyName: kind + mapping: + source: "#/components/schemas/HandoffSourceCitation" + artifact: "#/components/schemas/HandoffArtifactCitation" + memory: "#/components/schemas/HandoffMemoryCitation" + HandoffContent: type: object additionalProperties: false - required: [project_id, scope_id, title, kind] + required: [schema, objective, state, disposition, next_action, omissions] properties: - project_id: - type: string - minLength: 1 - maxLength: 256 - scope_id: - type: string - minLength: 1 - maxLength: 256 - key: - type: string - minLength: 1 - maxLength: 64 - nullable: true - title: + schema: + $ref: "#/components/schemas/HandoffSchema" + objective: type: string minLength: 1 - maxLength: 256 - kind: - $ref: "#/components/schemas/WorkstreamKind" - catalog_state: - $ref: "#/components/schemas/ReportCatalogState" - default: included - external_refs: + maxLength: 8192 + pattern: '.*\S.*' + state: type: array - maxItems: 32 + minItems: 1 + maxItems: 64 items: - $ref: "#/components/schemas/HandoffReportExternalReference" - default: [] - labels: + $ref: "#/components/schemas/HandoffStatement" + disposition: + $ref: "#/components/schemas/HandoffDisposition" + next_action: + $ref: "#/components/schemas/HandoffStatement" + nullable: true + omissions: type: array - maxItems: 32 + maxItems: 64 items: - type: string - minLength: 1 - maxLength: 128 - default: [] - ListHandoffReportWorkstreamsRequest: + $ref: "#/components/schemas/HandoffOmission" + HandoffDraft: type: object additionalProperties: false - required: [project_id] + required: [objective, state, disposition, next_action, omissions] properties: - project_id: + objective: type: string minLength: 1 - maxLength: 256 - cursor: - type: string + maxLength: 8192 + pattern: '.*\S.*' + state: + type: array + minItems: 1 + maxItems: 64 + items: + $ref: "#/components/schemas/HandoffStatement" + disposition: + $ref: "#/components/schemas/HandoffDisposition" + next_action: + $ref: "#/components/schemas/HandoffStatement" nullable: true - limit: - type: integer - minimum: 1 - maximum: 100 - default: 50 - include_archived: - type: boolean - default: false - UpdateHandoffReportWorkstreamRequest: + omissions: + type: array + maxItems: 64 + items: + $ref: "#/components/schemas/HandoffOmission" + HandoffEvidenceCheck: type: object additionalProperties: false - required: [workstream, expected_version] + required: [claim, state_index, status, unavailable_evidence] properties: - workstream: - $ref: "#/components/schemas/WorkstreamDescriptor" - expected_version: + claim: + $ref: "#/components/schemas/HandoffClaim" + state_index: type: integer - minimum: 1 - GetHandoffReportRequest: + minimum: 0 + nullable: true + status: + $ref: "#/components/schemas/HandoffEvidenceStatus" + unavailable_evidence: + type: array + maxItems: 32 + items: + $ref: "#/components/schemas/HandoffCitation" + HandoffMemoryCitation: type: object additionalProperties: false - required: [scope_id] + required: [kind, memory_citation] properties: - scope_id: + kind: type: string - minLength: 1 - maxLength: 256 - project_id: + enum: [memory] + memory_citation: + $ref: "#/components/schemas/MemoryCitation" + HandoffOmission: + type: object + additionalProperties: false + required: [text, citation] + properties: + text: type: string minLength: 1 - maxLength: 256 + maxLength: 8192 + pattern: '.*\S.*' + citation: + $ref: "#/components/schemas/HandoffCitation" nullable: true - deprecated: true - description: Retained for wire compatibility and ignored when generating a scope report. - locale: - $ref: "#/components/schemas/ReportLocale" + HandoffResolution: + type: object + additionalProperties: false + required: + [trust, status, scope_id, content, selection, selected_revision, current_revision, evidence_checks] + properties: + trust: + type: string + enum: [untrusted_history] + status: + $ref: "#/components/schemas/HandoffResolutionStatus" + scope_id: + type: string + content: + $ref: "#/components/schemas/HandoffContent" nullable: true - include_evidence_checks: - type: boolean - default: true - format: - $ref: "#/components/schemas/ReportFormat" - default: markdown - include_archived: - type: boolean - default: false - download: - type: boolean - default: false - period: - $ref: "#/components/schemas/HandoffReportPeriodRequest" + selection: + $ref: "#/components/schemas/HandoffSelection" + nullable: true + selected_revision: + $ref: "#/components/schemas/ArtifactReference" nullable: true - ListHandoffReportKnownScopesRequest: + current_revision: + $ref: "#/components/schemas/ArtifactReference" + nullable: true + evidence_checks: + type: array + maxItems: 65 + items: + $ref: "#/components/schemas/HandoffEvidenceCheck" + HandoffSourceCitation: + type: object + additionalProperties: false + required: [kind, source_ref] + properties: + kind: + type: string + enum: [source] + source_ref: + $ref: "#/components/schemas/SourceReference" + HandoffStatement: type: object additionalProperties: false + required: [text, citations] properties: - cursor: + text: type: string - nullable: true - limit: - type: integer - minimum: 1 - maximum: 100 - default: 50 - KnownHandoffScope: + minLength: 1 + maxLength: 8192 + pattern: '.*\S.*' + citations: + type: array + minItems: 1 + maxItems: 32 + items: + $ref: "#/components/schemas/HandoffCitation" + PrepareHandoffRequest: type: object additionalProperties: false - required: [scope_id] + required: [scope_id, objective, evidence] properties: scope_id: type: string minLength: 1 maxLength: 256 - KnownHandoffScopePage: - type: object - additionalProperties: false - required: [items] - properties: - items: + pattern: '.*\S.*' + objective: + type: string + minLength: 1 + maxLength: 8192 + pattern: '.*\S.*' + evidence: type: array + minItems: 1 + maxItems: 32 items: - $ref: "#/components/schemas/KnownHandoffScope" - next_cursor: - type: string - nullable: true - HandoffReportPeriodRequest: + $ref: "#/components/schemas/HandoffCitation" + max_bytes: + type: integer + minimum: 512 + maximum: 32768 + default: 8000 + PreparedHandoff: type: object additionalProperties: false - required: [start, end] + required: [schema, scope_id, base, content] properties: - start: - type: string - format: date-time - end: - type: string - format: date-time - timezone: + schema: + $ref: "#/components/schemas/PreparedHandoffSchema" + scope_id: type: string - minLength: 1 - maxLength: 256 + base: + $ref: "#/components/schemas/ArtifactReference" nullable: true - compare_to_previous_period: - type: boolean - default: false - HandoffReportResponse: + content: + $ref: "#/components/schemas/HandoffContent" + PreparedContext: type: object additionalProperties: false - required: [format, report, markdown, selection_digest, report_digest] + required: [schema, status, content, content_bytes] properties: - format: - $ref: "#/components/schemas/ReportFormat" - report: - type: object - additionalProperties: true - nullable: true - markdown: + schema: + $ref: "#/components/schemas/PreparedContextSchema" + status: + $ref: "#/components/schemas/PreparedContextStatus" + content: type: string nullable: true - selection_digest: - type: string - pattern: '^sha256:[0-9a-f]{64}$' - report_digest: - type: string - pattern: '^sha256:[0-9a-f]{64}$' - ReportActivitySource: - type: string - enum: [handoff_observation, git_commit, git_worktree, coding_session, other] - ReportTimeBasis: - type: string - enum: [source_reported, host_observed, first_seen, current_only, unknown] - HandoffReportActivityAgent: + content_bytes: + type: integer + minimum: 0 + EntryChange: type: object additionalProperties: false + required: [op, entry_id, from_entry_version_id, to_entry_version_id, reason] properties: - provider: + op: + $ref: "#/components/schemas/EntryChangeOperation" + entry_id: type: string minLength: 1 - maxLength: 64 - nullable: true - label: + maxLength: 128 + pattern: '^[\x21-\x7E]+$' + from_entry_version_id: type: string minLength: 1 maxLength: 128 + pattern: '^[\x21-\x7E]+$' nullable: true - HandoffReportActivityVcsContext: - type: object - additionalProperties: false - properties: - branch: + to_entry_version_id: type: string minLength: 1 - maxLength: 256 + maxLength: 128 + pattern: '^[\x21-\x7E]+$' nullable: true - head_revision: + reason: type: string - minLength: 1 - maxLength: 256 nullable: true - RecordHandoffReportActivityRequest: + ExperienceArtifact: + type: object + additionalProperties: false + required: [artifact, content, source_refs, artifact_refs] + properties: + artifact: + $ref: "#/components/schemas/ArtifactReference" + content: + $ref: "#/components/schemas/ExperienceProposal" + source_refs: + type: array + items: + $ref: "#/components/schemas/SourceReference" + artifact_refs: + type: array + items: + $ref: "#/components/schemas/ArtifactReference" + ExperienceProposal: type: object additionalProperties: false - required: [project_id, source, source_event_id, time_basis] + required: [situation, action, outcome, lesson] properties: - project_id: + situation: type: string minLength: 1 - maxLength: 256 - scope_id: + maxLength: 8000 + pattern: '.*\S.*' + action: type: string minLength: 1 - maxLength: 256 - nullable: true - source: - $ref: "#/components/schemas/ReportActivitySource" - source_event_id: + maxLength: 8000 + pattern: '.*\S.*' + outcome: type: string minLength: 1 - maxLength: 256 - source_ref: - $ref: "#/components/schemas/HandoffReportExternalReference" - nullable: true - occurred_at: + maxLength: 8000 + pattern: '.*\S.*' + lesson: type: string - format: date-time - nullable: true - time_basis: - $ref: "#/components/schemas/ReportTimeBasis" - title: + minLength: 1 + maxLength: 8000 + pattern: '.*\S.*' + SkillArtifact: + type: object + additionalProperties: false + required: [artifact, content, source_refs, artifact_refs] + properties: + artifact: + $ref: "#/components/schemas/ArtifactReference" + content: + $ref: "#/components/schemas/SkillProposal" + source_refs: + type: array + items: + $ref: "#/components/schemas/SourceReference" + artifact_refs: + type: array + items: + $ref: "#/components/schemas/ArtifactReference" + SkillProposal: + type: object + additionalProperties: false + required: [name, description, instructions, validation] + properties: + name: type: string minLength: 1 - maxLength: 256 - nullable: true - summary: + maxLength: 128 + pattern: '^\S(?:.*\S)?$' + description: type: string minLength: 1 maxLength: 2000 - nullable: true - agent: - $ref: "#/components/schemas/HandoffReportActivityAgent" - nullable: true - session_id: + pattern: '^\S(?:.*\S)?$' + instructions: type: string minLength: 1 - maxLength: 256 - nullable: true - vcs_context: - $ref: "#/components/schemas/HandoffReportActivityVcsContext" - nullable: true - evidence_refs: + maxLength: 32000 + pattern: '.*\S.*' + validation: type: array + minItems: 1 maxItems: 32 items: - $ref: "#/components/schemas/HandoffReportExternalReference" - default: [] - HandoffReportActivity: + $ref: "#/components/schemas/SkillValidationItem" + SkillValidationItem: + type: string + minLength: 1 + maxLength: 2000 + pattern: '^\S(?:.*\S)?$' + ExternalSkillRegistration: type: object additionalProperties: false - required: [schema, event_id, project_id, scope_id, source, source_event_id, source_ref, occurred_at, observed_at, time_basis, title, summary, agent, session_id, vcs_context, evidence_refs, trust] + required: + - external_skill_id + - provider + - agent_kind + - host_id + - installation_scope + - locator + - fingerprint + - name + - description properties: - schema: - type: string - enum: [powercontext.handoff-report-activity.v1] - event_id: - type: string - minLength: 1 - maxLength: 256 - project_id: - type: string - minLength: 1 - maxLength: 256 - scope_id: - type: string - minLength: 1 - maxLength: 256 - nullable: true - source: - $ref: "#/components/schemas/ReportActivitySource" - source_event_id: + external_skill_id: type: string minLength: 1 - maxLength: 256 - source_ref: - $ref: "#/components/schemas/HandoffReportExternalReference" - nullable: true - occurred_at: + maxLength: 128 + pattern: '^[\x21-\x7E]+$' + provider: type: string - format: date-time - nullable: true - observed_at: + enum: [codex, claude_code] + agent_kind: type: string - format: date-time - time_basis: - $ref: "#/components/schemas/ReportTimeBasis" - title: + enum: [codex, claude_code] + host_id: type: string minLength: 1 - maxLength: 256 - nullable: true - summary: + maxLength: 128 + pattern: '^\S(?:.*\S)?$' + installation_scope: + $ref: "#/components/schemas/ExternalSkillInstallationScope" + locator: type: string minLength: 1 maxLength: 2000 - nullable: true - agent: - $ref: "#/components/schemas/HandoffReportActivityAgent" - nullable: true - session_id: + pattern: '^\S(?:.*\S)?$' + description: Host-local locator; not a cross-Agent or cross-host contract. + fingerprint: + type: string + pattern: '^[0-9a-f]{64}$' + name: type: string minLength: 1 - maxLength: 256 - nullable: true - vcs_context: - $ref: "#/components/schemas/HandoffReportActivityVcsContext" - nullable: true - evidence_refs: - type: array - maxItems: 32 - items: - $ref: "#/components/schemas/HandoffReportExternalReference" - trust: + maxLength: 128 + pattern: '^\S(?:.*\S)?$' + description: type: string - enum: [untrusted_observation] - StoredHandoffReportActivity: + minLength: 1 + maxLength: 2000 + pattern: '^\S(?:.*\S)?$' + ExternalSkillResolution: type: object additionalProperties: false - required: [cursor, event] + required: [registration, status, entrypoint] properties: - cursor: - type: integer - minimum: 1 - event: - $ref: "#/components/schemas/HandoffReportActivity" - ListHandoffReportActivitiesRequest: + registration: + $ref: "#/components/schemas/ExternalSkillRegistration" + status: + $ref: "#/components/schemas/ExternalSkillResolutionStatus" + entrypoint: + type: string + nullable: true + description: Host-local SKILL.md path; present only when the exact fingerprint is available. + ScanExternalSkillsResponse: type: object additionalProperties: false - required: [project_id] + required: [registrations, skipped] properties: - project_id: - type: string - minLength: 1 - maxLength: 256 - period_start: - type: string - format: date-time - nullable: true - period_end: - type: string - format: date-time - nullable: true - sources: + registrations: type: array - maxItems: 5 items: - $ref: "#/components/schemas/ReportActivitySource" - nullable: true - after_cursor: - type: integer - minimum: 0 - default: 0 - through_cursor: + $ref: "#/components/schemas/ExternalSkillRegistration" + skipped: type: integer minimum: 0 - nullable: true - limit: - type: integer - minimum: 1 - maximum: 100 - default: 50 - HandoffReportActivityPage: + ListExternalSkillsResponse: type: object additionalProperties: false - required: [items, next_cursor, high_watermark] + required: [skills] properties: - items: + skills: type: array - maxItems: 100 items: - $ref: "#/components/schemas/HandoffReportActivity" - next_cursor: - type: integer - minimum: 1 - nullable: true - high_watermark: - type: integer - minimum: 0 - PurgeHandoffReportActivitiesRequest: + $ref: "#/components/schemas/ExternalSkillResolution" + ErrorDetail: type: object additionalProperties: false - required: [project_id, observed_before] + required: [code, message, details] properties: - project_id: + code: type: string - minLength: 1 - maxLength: 256 - observed_before: + message: type: string - format: date-time - PurgeHandoffReportActivitiesResponse: + details: + type: object + additionalProperties: true + nullable: true + ErrorResponse: type: object additionalProperties: false - required: [deleted_count] + required: [error] properties: - deleted_count: - type: integer - minimum: 0 - HandoffReportRepositoryRef: + error: + $ref: "#/components/schemas/ErrorDetail" + FlushMemoryRequest: type: object additionalProperties: false - required: [provider, repository_id, normalized_remote, subpath] + required: [scope_id] properties: - provider: - type: string - enum: [github, gitlab, local, other] - repository_id: + scope_id: type: string minLength: 1 maxLength: 256 - nullable: true - normalized_remote: - type: string - minLength: 1 - maxLength: 2048 - nullable: true - subpath: - type: string - minLength: 1 - maxLength: 1024 - nullable: true - HandoffReportWorkspaceBinding: + pattern: '.*\S.*' + FlushMemoryResponse: type: object additionalProperties: false - required: [schema, workspace_instance_id, project_id, repository_ref, state, confirmed_at, version] + required: [status, previous_cursor, current_cursor, high_watermark, processed_source_count] properties: - schema: - type: string - enum: [powercontext.workspace-binding.v1] - workspace_instance_id: - type: string - minLength: 1 - maxLength: 256 - project_id: - type: string - minLength: 1 - maxLength: 256 - repository_ref: - $ref: "#/components/schemas/HandoffReportRepositoryRef" - state: - type: string - enum: [confirmed, detached] - confirmed_at: - type: string - format: date-time - version: + status: + $ref: "#/components/schemas/FlushStatus" + previous_cursor: type: integer - minimum: 1 - GetHandoffReportWorkspaceRequest: + minimum: 0 + current_cursor: + type: integer + minimum: 0 + high_watermark: + type: integer + minimum: 0 + processed_source_count: + type: integer + minimum: 0 + memory: + $ref: "#/components/schemas/ArtifactReference" + nullable: true + GetMemoryEntryRequest: type: object additionalProperties: false - required: [workspace_instance_id] + required: [scope_id, citation] properties: - workspace_instance_id: + scope_id: type: string minLength: 1 maxLength: 256 - AttachHandoffReportWorkspaceRequest: + pattern: '.*\S.*' + citation: + $ref: "#/components/schemas/MemoryCitation" + GetArtifactCandidateRequest: type: object additionalProperties: false - required: [workspace_instance_id, project_id, repository_ref, expected_version] + required: [scope_id, candidate_id] properties: - workspace_instance_id: + scope_id: type: string minLength: 1 maxLength: 256 - project_id: + pattern: '.*\S.*' + candidate_id: type: string minLength: 1 - maxLength: 256 - repository_ref: - $ref: "#/components/schemas/HandoffReportRepositoryRef" - expected_version: - type: integer - minimum: 1 - nullable: true - DetachHandoffReportWorkspaceRequest: + maxLength: 128 + pattern: '^[\x21-\x7E]+$' + GetExperienceRequest: type: object additionalProperties: false - required: [workspace_instance_id, expected_version] + required: [scope_id, artifact] properties: - workspace_instance_id: + scope_id: type: string minLength: 1 maxLength: 256 - expected_version: - type: integer - minimum: 1 - ProjectDescriptor: + pattern: '.*\S.*' + artifact: + $ref: "#/components/schemas/ArtifactReference" + GetSkillRequest: type: object additionalProperties: false - required: [schema, project_id, project_key, title, description, default_locale, timezone, catalog_state, version] + required: [scope_id, artifact] properties: - schema: - type: string - enum: [powercontext.project.v1] - project_id: - type: string - minLength: 1 - maxLength: 256 - project_key: - type: string - minLength: 1 - maxLength: 64 - title: - type: string - minLength: 1 - maxLength: 256 - description: - type: string - maxLength: 2000 - nullable: true - default_locale: - $ref: "#/components/schemas/ReportLocale" - timezone: + scope_id: type: string minLength: 1 maxLength: 256 - catalog_state: - $ref: "#/components/schemas/ReportCatalogState" - version: - type: integer - minimum: 1 - ProjectPage: + pattern: '.*\S.*' + artifact: + $ref: "#/components/schemas/ArtifactReference" + GetHandoffReportRequest: type: object additionalProperties: false - required: [items, next_cursor] + required: [selection] properties: - items: - type: array - maxItems: 100 - items: - $ref: "#/components/schemas/ProjectDescriptor" - next_cursor: - type: string - nullable: true - WorkstreamDescriptor: + selection: + $ref: "#/components/schemas/ScopeSelection" + format: + $ref: "#/components/schemas/ReportFormat" + default: json + download: + type: boolean + default: false + HandoffReportResponse: type: object additionalProperties: false - required: [schema, scope_id, project_id, key, title, kind, catalog_state, external_refs, labels, version] + required: [format, report, markdown, selection_digest, report_digest] properties: - schema: - type: string - enum: [powercontext.workstream.v1] - scope_id: - type: string - minLength: 1 - maxLength: 256 - project_id: - type: string - minLength: 1 - maxLength: 256 - key: - type: string - maxLength: 64 + format: + $ref: "#/components/schemas/ReportFormat" + report: + type: object + additionalProperties: true nullable: true - title: - type: string - minLength: 1 - maxLength: 256 - kind: - $ref: "#/components/schemas/WorkstreamKind" - catalog_state: - $ref: "#/components/schemas/ReportCatalogState" - external_refs: - type: array - maxItems: 32 - items: - $ref: "#/components/schemas/HandoffReportExternalReference" - labels: - type: array - maxItems: 32 - items: - type: string - minLength: 1 - maxLength: 128 - version: - type: integer - minimum: 1 - WorkstreamPage: - type: object - additionalProperties: false - required: [items, next_cursor] - properties: - items: - type: array - maxItems: 100 - items: - $ref: "#/components/schemas/WorkstreamDescriptor" - next_cursor: + markdown: type: string nullable: true - HandoffReportExternalReference: - type: object - additionalProperties: false - required: [kind, provider, external_id, url] - properties: - kind: - type: string - enum: [issue, task, pull_request, branch, feature, release, program, other] - provider: - type: string - minLength: 1 - maxLength: 64 - external_id: + selection_digest: type: string - minLength: 1 - maxLength: 256 - url: + pattern: '^sha256:[0-9a-f]{64}$' + report_digest: type: string - maxLength: 2048 - nullable: true - ReportLocale: - type: string - enum: [zh-CN, en] + pattern: '^sha256:[0-9a-f]{64}$' ReportFormat: type: string enum: [json, markdown] - ReportCatalogState: - type: string - enum: [included, archived] - WorkstreamKind: - type: string - enum: [feature, bug, refactor, operations, research, other] HealthResponse: type: object additionalProperties: false diff --git a/src/powercontext/builtin/handoff_report/__init__.py b/src/powercontext/builtin/handoff_report/__init__.py index 16af8671b..08a66b926 100644 --- a/src/powercontext/builtin/handoff_report/__init__.py +++ b/src/powercontext/builtin/handoff_report/__init__.py @@ -5,21 +5,11 @@ # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -"""Optional, read-only Handoff Report domain values.""" +"""Scope-based Handoff Report projection.""" -from powercontext.builtin.handoff_report.adapters import RuntimeHandoffReadAdapter, RuntimeWorkContinuityReadAdapter -from powercontext.builtin.handoff_report.application import ( - HandoffReportApplication, - ReportActivityPage, - ReportPeriodInput, -) +from powercontext.builtin.handoff_report.adapters import RuntimeHandoffReadAdapter +from powercontext.builtin.handoff_report.application import HandoffReportApplication from powercontext.builtin.handoff_report.canonical import ( ReportCanonicalizationError, canonical_json_bytes, @@ -28,164 +18,34 @@ selection_digest, selection_envelope, ) -from powercontext.builtin.handoff_report.catalog import HandoffReportCatalog, ProjectIdFactory -from powercontext.builtin.handoff_report.catalog_store import ( - DEFAULT_CATALOG_PAGE_SIZE, - HANDOFF_REPORT_CATALOG_TABLES, - MAX_CATALOG_PAGE_SIZE, - CatalogPage, - ReportCatalogRepository, -) from powercontext.builtin.handoff_report.errors import ( - HandoffReportBusyError, - HandoffReportCatalogArgumentError, HandoffReportError, - HandoffReportEvidenceCheckUnavailableError, HandoffReportInconsistentError, HandoffReportTooLargeError, - InvalidStoredCatalogError, - ProjectConflictError, - ProjectNotFoundError, - ScopeAlreadyGroupedError, - WorkspaceBindingConflictError, - WorkspaceBindingNotFoundError, - WorkstreamConflictError, - WorkstreamNotFoundError, -) -from powercontext.builtin.handoff_report.models import ( - ActivityAgent, - ActivityVcsContext, - CatalogState, - ExternalReference, - ExternalReferenceKind, - GeneratedSummaryTrust, - HandoffReportTrust, - ProjectDescriptor, - ReportActivityEvent, - ReportActivitySource, - ReportActivityTrust, - ReportLocale, - ReportSelectionConsistency, - ReportSelectionEntry, - ReportSelectionStatus, - ReportTimeBasis, - RepositoryProvider, - RepositoryRef, - WorkspaceBinding, - WorkspaceBindingState, - WorkstreamDescriptor, - WorkstreamKind, - activity_sort_key, - normalize_repository_ref, - normalized_sort_text, - selection_sort_key, - workstream_sort_key, ) from powercontext.builtin.handoff_report.rendering import render_markdown from powercontext.builtin.handoff_report.report import ( HandoffReport, - HandoffRevisionSummary, - ReportActivityCoverageStatus, - ReportActivityStatus, - ReportCoverage, - ReportEvidenceChecks, - ReportFormat, - ReportHandoffActivityRelation, - ReportKind, - ReportPeriodComparison, - ReportReportingStatus, - ReportSummary, - ReportWorkStatus, - WorkstreamReport, -) -from powercontext.builtin.handoff_report.selection import select_optimistic_stable_handoffs -from powercontext.builtin.handoff_report.service import HandoffReportService -from powercontext.builtin.handoff_report.workspace import WorkspaceBindingService -from powercontext.builtin.handoff_report.workspace_store import ( - HANDOFF_REPORT_WORKSPACE_BINDINGS_TABLE, - HANDOFF_REPORT_WORKSPACE_TABLES, - WorkspaceBindingRepository, + HandoffReportStatus, + HandoffReportSummary, + ScopeHandoffReport, ) __all__ = [ - "DEFAULT_CATALOG_PAGE_SIZE", - "HANDOFF_REPORT_CATALOG_TABLES", - "HANDOFF_REPORT_WORKSPACE_BINDINGS_TABLE", - "HANDOFF_REPORT_WORKSPACE_TABLES", - "MAX_CATALOG_PAGE_SIZE", - "ActivityAgent", - "ActivityVcsContext", - "CatalogPage", - "CatalogState", - "ExternalReference", - "ExternalReferenceKind", - "GeneratedSummaryTrust", "HandoffReport", "HandoffReportApplication", - "HandoffReportBusyError", - "HandoffReportCatalog", - "HandoffReportCatalogArgumentError", "HandoffReportError", - "HandoffReportEvidenceCheckUnavailableError", "HandoffReportInconsistentError", - "HandoffReportService", + "HandoffReportStatus", + "HandoffReportSummary", "HandoffReportTooLargeError", - "HandoffReportTrust", - "HandoffRevisionSummary", - "InvalidStoredCatalogError", - "ProjectConflictError", - "ProjectDescriptor", - "ProjectIdFactory", - "ProjectNotFoundError", - "ReportActivityCoverageStatus", - "ReportActivityEvent", - "ReportActivityPage", - "ReportActivitySource", - "ReportActivityStatus", - "ReportActivityTrust", "ReportCanonicalizationError", - "ReportCatalogRepository", - "ReportCoverage", - "ReportEvidenceChecks", - "ReportFormat", - "ReportHandoffActivityRelation", - "ReportKind", - "ReportLocale", - "ReportPeriodComparison", - "ReportPeriodInput", - "ReportReportingStatus", - "ReportSelectionConsistency", - "ReportSelectionEntry", - "ReportSelectionStatus", - "ReportSummary", - "ReportTimeBasis", - "ReportWorkStatus", - "RepositoryProvider", - "RepositoryRef", "RuntimeHandoffReadAdapter", - "RuntimeWorkContinuityReadAdapter", - "ScopeAlreadyGroupedError", - "WorkspaceBinding", - "WorkspaceBindingConflictError", - "WorkspaceBindingNotFoundError", - "WorkspaceBindingRepository", - "WorkspaceBindingService", - "WorkspaceBindingState", - "WorkstreamConflictError", - "WorkstreamDescriptor", - "WorkstreamKind", - "WorkstreamNotFoundError", - "WorkstreamReport", - "activity_sort_key", + "ScopeHandoffReport", "canonical_json_bytes", "finalize_digests", - "normalize_repository_ref", - "normalized_sort_text", "render_markdown", "report_digest", - "select_optimistic_stable_handoffs", "selection_digest", "selection_envelope", - "selection_sort_key", - "workstream_sort_key", ] diff --git a/src/powercontext/builtin/handoff_report/adapters.py b/src/powercontext/builtin/handoff_report/adapters.py index d43175cd8..e4d22393b 100644 --- a/src/powercontext/builtin/handoff_report/adapters.py +++ b/src/powercontext/builtin/handoff_report/adapters.py @@ -5,26 +5,15 @@ # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -"""Adapters from existing Runtime behavior into Handoff Report read ports.""" +"""Runtime adapter for read-only Handoff report projection.""" from __future__ import annotations from typing import Protocol from powercontext.artifacts import ArtifactRef -from powercontext.builtin.artifacts.handoff import ( - Handoff, - HandoffEvidenceCheck, -) -from powercontext.builtin.handoff_report.errors import HandoffReportEvidenceCheckUnavailableError -from powercontext.builtin.work import WorkContinuity +from powercontext.builtin.artifacts.handoff import Handoff class _ScopedHandoffReader(Protocol): @@ -32,24 +21,12 @@ async def latest(self) -> Handoff | None: ... async def revision(self, reference: ArtifactRef, /) -> Handoff: ... - async def revisions(self) -> tuple[Handoff, ...]: ... - class _HandoffApplicationReader(Protocol): def for_scope(self, scope_id: str, /) -> _ScopedHandoffReader: ... -class _ScopedWorkReader(Protocol): - async def continuity(self, selected_handoff: ArtifactRef | None = None) -> WorkContinuity: ... - - -class _WorkApplicationReader(Protocol): - def for_scope(self, scope_id: str, /) -> _ScopedWorkReader: ... - - class RuntimeHandoffReadAdapter: - """Use the existing public Runtime Handoff application as a read-only source.""" - def __init__(self, application: _HandoffApplicationReader, /) -> None: self._application = application @@ -59,30 +36,5 @@ async def latest(self, scope_id: str, /) -> Handoff | None: async def get(self, scope_id: str, reference: ArtifactRef, /) -> Handoff: return await self._application.for_scope(scope_id).revision(reference) - async def revisions(self, scope_id: str, /) -> tuple[Handoff, ...]: - return await self._application.for_scope(scope_id).revisions() - - async def check_evidence( - self, - scope_id: str, - reference: ArtifactRef, - /, - ) -> tuple[HandoffEvidenceCheck, ...]: - del scope_id, reference - # The current Runtime exposes evidence checks through Continue only. - # Report must not enter that control flow, so it degrades explicitly - # until an independent read-only capability exists. - raise HandoffReportEvidenceCheckUnavailableError - - -class RuntimeWorkContinuityReadAdapter: - """Project Work continuity through the Runtime's high-level read application.""" - - def __init__(self, application: _WorkApplicationReader, /) -> None: - self._application = application - - async def get(self, scope_id: str, reference: ArtifactRef | None, /) -> WorkContinuity: - return await self._application.for_scope(scope_id).continuity(reference) - -__all__ = ["RuntimeHandoffReadAdapter", "RuntimeWorkContinuityReadAdapter"] +__all__ = ["RuntimeHandoffReadAdapter"] diff --git a/src/powercontext/builtin/handoff_report/application.py b/src/powercontext/builtin/handoff_report/application.py index 0135c4afb..2f5ad4039 100644 --- a/src/powercontext/builtin/handoff_report/application.py +++ b/src/powercontext/builtin/handoff_report/application.py @@ -12,412 +12,71 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Runtime-facing application service for Handoff Report operations.""" +"""Read-only Handoff reports over a resolved Scope selection.""" from __future__ import annotations -import json -from bisect import bisect_right -from collections.abc import Awaitable, Callable -from dataclasses import dataclass -from datetime import UTC, datetime, timedelta -from typing import cast -from zoneinfo import ZoneInfo, ZoneInfoNotFoundError +from datetime import UTC, datetime -from pydantic import JsonValue - -from powercontext.builtin.handoff_report.catalog import HandoffReportCatalog -from powercontext.builtin.handoff_report.catalog_store import ( - DEFAULT_CATALOG_PAGE_SIZE, - CatalogPage, -) -from powercontext.builtin.handoff_report.errors import HandoffReportCatalogArgumentError -from powercontext.builtin.handoff_report.models import ( - CatalogState, - ExternalReference, - ProjectDescriptor, - ReportActivityEvent, - ReportLocale, - RepositoryRef, - WorkspaceBinding, - WorkstreamDescriptor, - WorkstreamKind, -) -from powercontext.builtin.handoff_report.protocols import HandoffReadAdapter, WorkContinuityReadAdapter -from powercontext.builtin.handoff_report.report import HandoffReport, ReportFormat -from powercontext.builtin.handoff_report.repository import ActivityEventRepository, StoredActivityEvent -from powercontext.builtin.handoff_report.service import HandoffReportService -from powercontext.builtin.handoff_report.sqlite import SQLiteActivityEventRepository -from powercontext.builtin.handoff_report.workspace import WorkspaceBindingService -from powercontext.builtin.persistence.database import AsyncDatabase -from powercontext.builtin.sources import validate_scope_id - - -@dataclass(frozen=True, slots=True) -class ReportActivityPage: - """One cursor page plus the frozen current Project high watermark.""" - - items: tuple[ReportActivityEvent, ...] - next_cursor: int | None - high_watermark: int - - -@dataclass(frozen=True, slots=True) -class ReportPeriodInput: - """Explicit half-open period requested by a Report consumer.""" - - start: datetime - end: datetime - timezone: str | None = None - compare_to_previous_period: bool = False - - -@dataclass(frozen=True, slots=True) -class KnownScopePage: - """One cursor page of scopes that contain a committed Handoff.""" - - items: tuple[str, ...] - next_cursor: str | None +from powercontext.artifacts import ArtifactAddress +from powercontext.builtin.handoff_report.canonical import finalize_digests +from powercontext.builtin.handoff_report.errors import HandoffReportInconsistentError +from powercontext.builtin.handoff_report.protocols import HandoffReadAdapter, ScopeSelectionResolver +from powercontext.builtin.handoff_report.report import HandoffReport, HandoffReportSummary, ScopeHandoffReport +from powercontext.builtin.scope.models import ScopeSelection class HandoffReportApplication: - """Coordinate Report-owned persistence with the existing Handoff read port.""" - - def __init__( - self, - database: AsyncDatabase, - handoffs: HandoffReadAdapter, - /, - *, - activities: ActivityEventRepository | None = None, - workspace_bindings: WorkspaceBindingService | None = None, - continuity: WorkContinuityReadAdapter | None = None, - scope_ids: Callable[[], Awaitable[tuple[str, ...]]] | None = None, - ) -> None: - self._database = database - self._catalog = HandoffReportCatalog() - self._activities = SQLiteActivityEventRepository() if activities is None else activities - self._workspace_bindings = WorkspaceBindingService() if workspace_bindings is None else workspace_bindings - self._reports = HandoffReportService(handoffs, continuity=continuity) - self._scope_ids = scope_ids - - async def list_known_scopes( - self, - *, - cursor: str | None = None, - limit: int = DEFAULT_CATALOG_PAGE_SIZE, - ) -> KnownScopePage: - """List exact scope identities backed by a committed Handoff.""" - - if limit < 1 or limit > 100: - raise HandoffReportCatalogArgumentError("limit", "must be between 1 and 100") - if cursor is not None and (not cursor.strip() or cursor != cursor.strip()): - raise HandoffReportCatalogArgumentError("cursor", "must be non-empty trimmed text") - scopes = () if self._scope_ids is None else tuple(sorted(set(await self._scope_ids()))) - start = 0 if cursor is None else bisect_right(scopes, cursor) - items = scopes[start : start + limit] - next_cursor = items[-1] if start + len(items) < len(scopes) else None - return KnownScopePage(items=items, next_cursor=next_cursor) - - async def create_project( - self, - *, - project_key: str, - title: str, - description: str | None = None, - default_locale: ReportLocale = "zh-CN", - timezone: str = "UTC", - effective_at: datetime | None = None, - ) -> ProjectDescriptor: - async with self._database.transaction() as connection: - return await self._catalog.create_project( - connection, - project_key=project_key, - title=title, - description=description, - default_locale=default_locale, - timezone=timezone, - effective_at=effective_at, - ) - - async def get_project(self, project_id: str, /) -> ProjectDescriptor: - async with self._database.transaction() as connection: - return await self._catalog.get_project(connection, project_id) - - async def update_project( - self, - descriptor: ProjectDescriptor, - expected_version: int, - /, - *, - effective_at: datetime | None = None, - ) -> ProjectDescriptor: - async with self._database.transaction() as connection: - return await self._catalog.update_project( - connection, - descriptor, - expected_version, - effective_at=effective_at, - ) - - async def list_projects( - self, - *, - cursor: str | None = None, - limit: int = DEFAULT_CATALOG_PAGE_SIZE, - include_archived: bool = False, - ) -> CatalogPage[ProjectDescriptor]: - async with self._database.transaction() as connection: - return await self._catalog.list_projects( - connection, - cursor=cursor, - limit=limit, - include_archived=include_archived, - ) - - async def register_workstream( - self, - *, - project_id: str, - scope_id: str, - title: str, - kind: WorkstreamKind, - key: str | None = None, - catalog_state: CatalogState = "included", - external_refs: tuple[ExternalReference, ...] = (), - labels: tuple[str, ...] = (), - effective_at: datetime | None = None, - ) -> WorkstreamDescriptor: - async with self._database.transaction() as connection: - return await self._catalog.register_workstream( - connection, - project_id=project_id, - scope_id=scope_id, - title=title, - kind=kind, - key=key, - catalog_state=catalog_state, - external_refs=external_refs, - labels=labels, - effective_at=effective_at, - ) - - async def list_workstreams( - self, - project_id: str, - /, - *, - cursor: str | None = None, - limit: int = DEFAULT_CATALOG_PAGE_SIZE, - include_archived: bool = False, - ) -> CatalogPage[WorkstreamDescriptor]: - async with self._database.transaction() as connection: - return await self._catalog.list_workstreams( - connection, - project_id, - cursor=cursor, - limit=limit, - include_archived=include_archived, - ) - - async def update_workstream( - self, - descriptor: WorkstreamDescriptor, - expected_version: int, - /, - *, - effective_at: datetime | None = None, - ) -> WorkstreamDescriptor: - async with self._database.transaction() as connection: - return await self._catalog.update_workstream( - connection, - descriptor, - expected_version, - effective_at=effective_at, - ) - - async def record_activity(self, event: ReportActivityEvent, /) -> StoredActivityEvent: - """Record an explicit observation without entering the Handoff write path.""" - - async with self._database.transaction() as connection: - await self._catalog.get_project(connection, event.project_id) - return await self._activities.record(connection, event) - - async def list_activities( - self, - project_id: str, - /, - *, - period_start: datetime | None = None, - period_end: datetime | None = None, - sources: tuple[str, ...] | None = None, - after_cursor: int = 0, - through_cursor: int | None = None, - limit: int = DEFAULT_CATALOG_PAGE_SIZE, - ) -> ReportActivityPage: - """Read a stable cursor page from the Report-owned Activity Store.""" - - async with self._database.transaction() as connection: - await self._catalog.get_project(connection, project_id) - high_watermark = await self._activities.high_watermark(connection, project_id) - frozen_cursor = high_watermark if through_cursor is None else through_cursor - stored = await self._activities.list( - connection, - project_id, - period_start=period_start, - period_end=period_end, - sources=sources, - after_cursor=after_cursor, - through_cursor=frozen_cursor, - limit=limit + 1, - ) - has_more = len(stored) > limit - selected = stored[:limit] - return ReportActivityPage( - items=tuple(_activity_event(item) for item in selected), - next_cursor=selected[-1].cursor if has_more and selected else None, - high_watermark=high_watermark, - ) - - async def purge_activities(self, project_id: str, observed_before: datetime, /) -> int: - """Purge only Report-owned Activity rows for one Project.""" - - async with self._database.transaction() as connection: - await self._catalog.get_project(connection, project_id) - return await self._activities.purge(connection, project_id, observed_before) + """Build exact Handoff state without introducing another organization model.""" - async def get_workspace_binding(self, workspace_instance_id: str, /) -> WorkspaceBinding: - async with self._database.transaction() as connection: - return await self._workspace_bindings.get(connection, workspace_instance_id) - - async def attach_workspace_binding( - self, - *, - workspace_instance_id: str, - project_id: str, - repository_ref: RepositoryRef, - expected_version: int | None, - ) -> WorkspaceBinding: - async with self._database.transaction() as connection: - return await self._workspace_bindings.attach( - connection, - workspace_instance_id=workspace_instance_id, - project_id=project_id, - repository_ref=repository_ref, - expected_version=expected_version, - ) - - async def detach_workspace_binding( - self, - workspace_instance_id: str, - expected_version: int, - /, - ) -> WorkspaceBinding: - async with self._database.transaction() as connection: - return await self._workspace_bindings.detach(connection, workspace_instance_id, expected_version) + def __init__(self, scopes: ScopeSelectionResolver, handoffs: HandoffReadAdapter, /) -> None: + self._scopes = scopes + self._handoffs = handoffs async def get_report( self, - scope_id: str, + selection: ScopeSelection, /, *, - locale: ReportLocale | None = None, - include_evidence_checks: bool = True, - report_format: ReportFormat = "markdown", - include_archived: bool = False, - normalized_filters: dict[str, JsonValue] | None = None, - period: ReportPeriodInput | None = None, + generated_at: datetime | None = None, ) -> HandoffReport: - del include_archived - project = _scope_report_project(scope_id) - workstreams = (_scope_report_workstream(scope_id),) - period_values = _normalize_period(project, period) - normalized_period = _normalized_period(period, period_values) - return await self._reports.generate( - project, - workstreams, - locale=locale, - include_evidence_checks=include_evidence_checks, - activities=(), - activity_cursor=0, - activity_coverage="not_configured", - report_format=report_format, - report_kind="handoff" if period is None else "periodic", - normalized_filters={} if normalized_filters is None else normalized_filters, - normalized_period=normalized_period, - period_comparison=None, - ) - - -def _scope_report_project(scope_id: str) -> ProjectDescriptor: - scope = validate_scope_id(scope_id) - return ProjectDescriptor( - project_id="unused", - project_key="unused", - title=scope, - default_locale="zh-CN", - timezone="UTC", - version=1, - ) - - -def _scope_report_workstream(scope_id: str) -> WorkstreamDescriptor: - scope = validate_scope_id(scope_id) - return WorkstreamDescriptor( - scope_id=scope, - project_id="unused", - title=scope, - kind="other", - version=1, - ) - - -def _normalized_period( - period: ReportPeriodInput | None, - values: tuple[datetime, datetime, str] | None, -) -> dict[str, JsonValue] | None: - if values is None: - return None - requested = cast(ReportPeriodInput, period) - start, end, timezone = values - return { - "start": _utc_text(start), - "end": _utc_text(end), - "timezone": timezone, - "compare_to_previous_period": requested.compare_to_previous_period, - } - - -def _activity_event(value: StoredActivityEvent) -> ReportActivityEvent: - return ReportActivityEvent.model_validate_json(json.dumps(value.payload)) - - -def _normalize_period( - project: ProjectDescriptor, - period: ReportPeriodInput | None, -) -> tuple[datetime, datetime, str] | None: - if period is None: - return None - if period.start.tzinfo is None or period.start.utcoffset() is None: - raise HandoffReportCatalogArgumentError("period.start", "must include a UTC offset") - if period.end.tzinfo is None or period.end.utcoffset() is None: - raise HandoffReportCatalogArgumentError("period.end", "must include a UTC offset") - start = period.start.astimezone(UTC) - end = period.end.astimezone(UTC) - if start >= end: - raise HandoffReportCatalogArgumentError("period", "start must precede end") - if end - start > timedelta(days=366): - raise HandoffReportCatalogArgumentError("period", "must not exceed 366 days") - timezone = project.timezone if period.timezone is None else period.timezone - try: - ZoneInfo(timezone) - except ZoneInfoNotFoundError as error: - raise HandoffReportCatalogArgumentError("period.timezone", "must be a recognized IANA timezone") from error - return start, end, timezone - + """Resolve one common selection and freeze each Scope at an exact Handoff revision.""" + + scopes = await self._scopes.resolve_selection(selection) + projected: list[ScopeHandoffReport] = [] + for scope in scopes: + latest = await self._handoffs.latest(scope.scope_id) + if latest is None: + projected.append(ScopeHandoffReport(scope=scope, status="no_handoff")) + continue + + reference = latest.as_ref() + frozen = await self._handoffs.get(scope.scope_id, reference) + if frozen.as_ref() != reference: + raise HandoffReportInconsistentError(scope.scope_id) + projected.append( + ScopeHandoffReport( + scope=scope, + status=frozen.content.disposition, + handoff=ArtifactAddress(scope_id=scope.scope_id, artifact=reference), + content=frozen.content, + ) + ) -def _utc_text(value: datetime) -> str: - return value.astimezone(UTC).isoformat(timespec="microseconds").replace("+00:00", "Z") + entries = tuple(projected) + report = HandoffReport( + selection=selection, + scope_ids=tuple(scope.scope_id for scope in scopes), + generated_at=datetime.now(UTC) if generated_at is None else generated_at, + summary=HandoffReportSummary( + continuable_count=sum(entry.status == "continuable" for entry in entries), + blocked_count=sum(entry.status == "blocked" for entry in entries), + complete_count=sum(entry.status == "complete" for entry in entries), + no_handoff_count=sum(entry.status == "no_handoff" for entry in entries), + ), + scopes=entries, + ) + return finalize_digests(report) -__all__ = ["HandoffReportApplication", "KnownScopePage", "ReportActivityPage", "ReportPeriodInput"] +__all__ = ["HandoffReportApplication"] diff --git a/src/powercontext/builtin/handoff_report/canonical.py b/src/powercontext/builtin/handoff_report/canonical.py index 65e8a084d..831151734 100644 --- a/src/powercontext/builtin/handoff_report/canonical.py +++ b/src/powercontext/builtin/handoff_report/canonical.py @@ -5,100 +5,55 @@ # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -"""Canonical JSON and digest helpers for Handoff Reports.""" +"""Canonical digests for Handoff Report snapshots.""" + +# ruff: noqa: TRY003 from __future__ import annotations from collections.abc import Mapping, Sequence from datetime import UTC, datetime from hashlib import sha256 -from typing import TYPE_CHECKING, Any, cast +from typing import Any, cast from unicodedata import normalize import rfc8785 from pydantic import BaseModel, JsonValue -if TYPE_CHECKING: - from powercontext.builtin.handoff_report.report import HandoffReport +from powercontext.builtin.handoff_report.report import HandoffReport class ReportCanonicalizationError(ValueError): - """Raised when a report digest input cannot be represented canonically.""" - - def __init__(self, code: str, detail: object | None = None) -> None: - messages = { - "unknown-event": f"activity selection references unknown event {detail!r}", - "timestamp": "digest timestamps must be timezone-aware", - "float": "digest inputs must not contain floating-point values", - "key-type": "digest object keys must be strings", - "key-collision": "digest object keys collide after NFC normalization", - "unsupported-type": f"digest input contains unsupported value type {detail}", - } - super().__init__(messages[code]) + pass def canonical_json_bytes(value: object, /) -> bytes: - """Return RFC 8785 JSON bytes after applying the report NFC rules.""" - return rfc8785.dumps(cast(Any, _normalize_json(value))) def selection_envelope(report: HandoffReport, /) -> dict[str, object]: - """Build the locale-independent exact selection envelope for one report.""" - - events = {event.event_id: event for item in report.workstreams for event in item.activities} - events.update({event.event_id: event for event in report.unassigned_activity}) - activity_selection = [] - for event_id in report.activity_selection: - event = events.get(event_id) - if event is None: - raise ReportCanonicalizationError("unknown-event", event_id) - activity_selection.append({ - "event_id": event.event_id, - "source": event.source, - "source_event_id": event.source_event_id, - "occurred_at": event.occurred_at, - "observed_at": event.observed_at, - "time_basis": event.time_basis, - }) + """Describe the exact resolved observation independently of rendering time.""" + return { - "schema": "powercontext.handoff-report-selection.v1", - "project_id": report.project.project_id, - "project_revision": report.project_revision, - "normalized_filters": report.normalized_filters, - "normalized_period": report.normalized_period, - "selection_consistency": report.selection_consistency, - "activity_cursor": report.activity_cursor, - "baseline_selection": report.baseline_selection, - "end_selection": report.end_selection, - "activity_selection": activity_selection, + "schema": "powercontext.handoff-report-selection.v2", + "selection": report.selection, + "scope_ids": report.scope_ids, + "handoffs": tuple(entry.handoff for entry in report.scopes), } def selection_digest(report: HandoffReport, /) -> str: - """Hash the exact selection independently of locale and renderer.""" - return _digest(selection_envelope(report)) def report_digest(report: HandoffReport, /) -> str: - """Hash the complete report payload, excluding its own digest field.""" - payload = report.model_dump(mode="python", by_alias=True, exclude_none=False) payload.pop("report_digest", None) return _digest(payload) def finalize_digests(report: HandoffReport, /) -> HandoffReport: - """Return a report with selection and output-specific digests populated.""" - selected = report.model_copy(update={"selection_digest": selection_digest(report)}) return selected.model_copy(update={"report_digest": report_digest(selected)}) @@ -107,40 +62,32 @@ def _digest(value: object) -> str: return f"sha256:{sha256(canonical_json_bytes(value)).hexdigest()}" -def _normalize_json(value: object) -> JsonValue: +def _normalize_json(value: object) -> JsonValue: # noqa: C901 if isinstance(value, BaseModel): return _normalize_json(value.model_dump(mode="python", by_alias=True, exclude_none=False)) if isinstance(value, datetime): if value.tzinfo is None or value.utcoffset() is None: - raise ReportCanonicalizationError("timestamp") + raise ReportCanonicalizationError("digest timestamps must be timezone-aware") return value.astimezone(UTC).isoformat(timespec="microseconds").replace("+00:00", "Z") if isinstance(value, str): return normalize("NFC", value) if value is None or isinstance(value, (bool, int)): return value if isinstance(value, float): - raise ReportCanonicalizationError("float") + raise ReportCanonicalizationError("digest inputs must not contain floating-point values") if isinstance(value, Mapping): - return _normalize_mapping(cast(Mapping[object, object], value)) + normalized: dict[str, JsonValue] = {} + for key, item in cast(Mapping[object, object], value).items(): + if not isinstance(key, str): + raise ReportCanonicalizationError("digest object keys must be strings") + normalized_key = normalize("NFC", key) + if normalized_key in normalized: + raise ReportCanonicalizationError("digest object keys collide after normalization") + normalized[normalized_key] = _normalize_json(item) + return normalized if isinstance(value, Sequence) and not isinstance(value, (bytes, bytearray, memoryview)): - return _normalize_sequence(value) - raise ReportCanonicalizationError("unsupported-type", type(value).__name__) - - -def _normalize_mapping(value: Mapping[object, object]) -> dict[str, JsonValue]: - normalized: dict[str, JsonValue] = {} - for key, item in value.items(): - if not isinstance(key, str): - raise ReportCanonicalizationError("key-type") - normalized_key = normalize("NFC", key) - if normalized_key in normalized: - raise ReportCanonicalizationError("key-collision") - normalized[normalized_key] = _normalize_json(item) - return normalized - - -def _normalize_sequence(value: Sequence[object]) -> list[JsonValue]: - return [_normalize_json(item) for item in value] + return [_normalize_json(item) for item in value] + raise ReportCanonicalizationError(f"unsupported digest value: {type(value).__name__}") __all__ = [ diff --git a/src/powercontext/builtin/handoff_report/catalog.py b/src/powercontext/builtin/handoff_report/catalog.py deleted file mode 100644 index 09bce97f9..000000000 --- a/src/powercontext/builtin/handoff_report/catalog.py +++ /dev/null @@ -1,223 +0,0 @@ -# Copyright (c) 2026 OceanBase. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Application service for the Report-owned Project catalog.""" - -from __future__ import annotations - -from collections.abc import Callable -from datetime import datetime -from uuid import uuid4 - -from sqlalchemy.ext.asyncio import AsyncConnection - -from powercontext.builtin.handoff_report.catalog_store import ( - DEFAULT_CATALOG_PAGE_SIZE, - CatalogPage, - ReportCatalogRepository, -) -from powercontext.builtin.handoff_report.models import ( - CatalogState, - ExternalReference, - ProjectDescriptor, - ReportLocale, - WorkstreamDescriptor, - WorkstreamKind, -) - -ProjectIdFactory = Callable[[], str] - - -class HandoffReportCatalog: - """Coordinate server-generated Project identity with catalog persistence. - - The service only mutates Report-owned catalog tables. It never creates a - Core scope, writes Handoff data, or infers membership from repository - signals. - """ - - def __init__( - self, - repository: ReportCatalogRepository | None = None, - *, - project_id_factory: ProjectIdFactory | None = None, - ) -> None: - self._repository = ReportCatalogRepository() if repository is None else repository - self._project_id_factory = _new_project_id if project_id_factory is None else project_id_factory - - async def create_project( - self, - connection: AsyncConnection, - *, - project_key: str, - title: str, - description: str | None = None, - default_locale: ReportLocale = "zh-CN", - timezone: str = "UTC", - effective_at: datetime | None = None, - ) -> ProjectDescriptor: - descriptor = ProjectDescriptor( - project_id=self._project_id_factory(), - project_key=project_key, - title=title, - description=description, - default_locale=default_locale, - timezone=timezone, - version=1, - ) - return await self._repository.create_project(connection, descriptor, effective_at=effective_at) - - async def get_project(self, connection: AsyncConnection, project_id: str, /) -> ProjectDescriptor: - return await self._repository.get_project(connection, project_id) - - async def list_projects( - self, - connection: AsyncConnection, - /, - *, - cursor: str | None = None, - limit: int = DEFAULT_CATALOG_PAGE_SIZE, - include_archived: bool = False, - ) -> CatalogPage[ProjectDescriptor]: - return await self._repository.list_projects( - connection, - cursor=cursor, - limit=limit, - include_archived=include_archived, - ) - - async def update_project( - self, - connection: AsyncConnection, - descriptor: ProjectDescriptor, - expected_version: int, - *, - effective_at: datetime | None = None, - ) -> ProjectDescriptor: - return await self._repository.update_project( - connection, - descriptor, - expected_version, - effective_at=effective_at, - ) - - async def project_revision( - self, - connection: AsyncConnection, - project_id: str, - version: int, - /, - ) -> ProjectDescriptor: - return await self._repository.project_revision(connection, project_id, version) - - async def project_at( - self, - connection: AsyncConnection, - project_id: str, - effective_at: datetime, - /, - ) -> ProjectDescriptor | None: - return await self._repository.project_at(connection, project_id, effective_at) - - async def register_workstream( - self, - connection: AsyncConnection, - *, - project_id: str, - scope_id: str, - title: str, - kind: WorkstreamKind, - key: str | None = None, - catalog_state: CatalogState = "included", - external_refs: tuple[ExternalReference, ...] = (), - labels: tuple[str, ...] = (), - effective_at: datetime | None = None, - ) -> WorkstreamDescriptor: - descriptor = WorkstreamDescriptor( - scope_id=scope_id, - project_id=project_id, - key=key, - title=title, - kind=kind, - catalog_state=catalog_state, - external_refs=external_refs, - labels=labels, - version=1, - ) - return await self._repository.create_workstream(connection, descriptor, effective_at=effective_at) - - async def get_workstream(self, connection: AsyncConnection, scope_id: str, /) -> WorkstreamDescriptor: - return await self._repository.get_workstream(connection, scope_id) - - async def list_workstreams( - self, - connection: AsyncConnection, - project_id: str, - /, - *, - cursor: str | None = None, - limit: int = DEFAULT_CATALOG_PAGE_SIZE, - include_archived: bool = False, - ) -> CatalogPage[WorkstreamDescriptor]: - return await self._repository.list_workstreams( - connection, - project_id, - cursor=cursor, - limit=limit, - include_archived=include_archived, - ) - - async def update_workstream( - self, - connection: AsyncConnection, - descriptor: WorkstreamDescriptor, - expected_version: int, - *, - effective_at: datetime | None = None, - ) -> WorkstreamDescriptor: - return await self._repository.update_workstream( - connection, - descriptor, - expected_version, - effective_at=effective_at, - ) - - async def workstream_revision( - self, - connection: AsyncConnection, - scope_id: str, - version: int, - /, - ) -> WorkstreamDescriptor: - return await self._repository.workstream_revision(connection, scope_id, version) - - async def workstream_at( - self, - connection: AsyncConnection, - scope_id: str, - effective_at: datetime, - /, - ) -> WorkstreamDescriptor | None: - return await self._repository.workstream_at(connection, scope_id, effective_at) - - async def project_for_scope(self, connection: AsyncConnection, scope_id: str, /) -> ProjectDescriptor: - workstream = await self._repository.get_workstream(connection, scope_id) - return await self._repository.get_project(connection, workstream.project_id) - - -def _new_project_id() -> str: - return f"prj_{uuid4().hex}" - - -__all__ = ["HandoffReportCatalog", "ProjectIdFactory"] diff --git a/src/powercontext/builtin/handoff_report/catalog_store.py b/src/powercontext/builtin/handoff_report/catalog_store.py deleted file mode 100644 index dda79e185..000000000 --- a/src/powercontext/builtin/handoff_report/catalog_store.py +++ /dev/null @@ -1,751 +0,0 @@ -# Copyright (c) 2026 OceanBase. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Report-owned Project and Workstream catalog persistence. - -The tables in this module deliberately keep ``scope_id`` opaque. They are -application-layer metadata and do not create foreign keys into Core Handoff, -Artifact, Source, Memory, or Context tables. -""" - -from __future__ import annotations - -import json -from collections.abc import Mapping -from datetime import UTC, datetime -from typing import Any, Generic, TypeVar - -from pydantic import ValidationError -from sqlalchemy import ( - CheckConstraint, - Column, - Index, - Integer, - MetaData, - Table, - Text, - UniqueConstraint, - insert, - select, - update, -) -from sqlalchemy.exc import IntegrityError -from sqlalchemy.ext.asyncio import AsyncConnection -from typing_extensions import override - -from powercontext.builtin.handoff_report.errors import ( - HandoffReportCatalogArgumentError, - InvalidStoredCatalogError, - ProjectConflictError, - ProjectNotFoundError, - ScopeAlreadyGroupedError, - WorkstreamConflictError, - WorkstreamNotFoundError, -) -from powercontext.builtin.handoff_report.models import ( - MAX_PROJECT_KEY_LENGTH, - MAX_REPORT_ID_LENGTH, - MAX_WORKSTREAM_KEY_LENGTH, - ProjectDescriptor, - WorkstreamDescriptor, -) -from powercontext.builtin.persistence.tables import identity_string -from powercontext.limits import MAX_SCOPE_ID_LENGTH - -HANDOFF_REPORT_CATALOG_METADATA = MetaData() - -HANDOFF_REPORT_PROJECTS_TABLE = Table( - "pc_handoff_report_projects", - HANDOFF_REPORT_CATALOG_METADATA, - Column("project_id", identity_string(MAX_REPORT_ID_LENGTH), primary_key=True), - Column("project_key", identity_string(MAX_PROJECT_KEY_LENGTH), nullable=False, unique=True), - Column("version", Integer, nullable=False), - Column("catalog_state", identity_string(16), nullable=False), - Column("payload", Text, nullable=False), - CheckConstraint("version > 0", name="ck_pc_handoff_report_projects_version_positive"), -) - -HANDOFF_REPORT_PROJECT_REVISIONS_TABLE = Table( - "pc_handoff_report_project_revisions", - HANDOFF_REPORT_CATALOG_METADATA, - Column("project_id", identity_string(MAX_REPORT_ID_LENGTH), primary_key=True), - Column("version", Integer, primary_key=True), - Column("effective_at", identity_string(32), nullable=False), - Column("payload", Text, nullable=False), - CheckConstraint("version > 0", name="ck_pc_handoff_report_project_revisions_version_positive"), -) -Index( - "ix_pc_handoff_report_project_revisions_effective_at", - HANDOFF_REPORT_PROJECT_REVISIONS_TABLE.c.project_id, - HANDOFF_REPORT_PROJECT_REVISIONS_TABLE.c.effective_at, - HANDOFF_REPORT_PROJECT_REVISIONS_TABLE.c.version, -) - -HANDOFF_REPORT_WORKSTREAMS_TABLE = Table( - "pc_handoff_report_workstreams", - HANDOFF_REPORT_CATALOG_METADATA, - Column("scope_id", identity_string(MAX_SCOPE_ID_LENGTH), primary_key=True), - Column("project_id", identity_string(MAX_REPORT_ID_LENGTH), nullable=False), - Column("workstream_key", identity_string(MAX_WORKSTREAM_KEY_LENGTH)), - Column("version", Integer, nullable=False), - Column("catalog_state", identity_string(16), nullable=False), - Column("payload", Text, nullable=False), - UniqueConstraint( - "project_id", - "workstream_key", - name="uq_pc_handoff_report_workstreams_project_key", - ), - CheckConstraint("version > 0", name="ck_pc_handoff_report_workstreams_version_positive"), -) -Index( - "ix_pc_handoff_report_workstreams_project_scope", - HANDOFF_REPORT_WORKSTREAMS_TABLE.c.project_id, - HANDOFF_REPORT_WORKSTREAMS_TABLE.c.scope_id, -) - -HANDOFF_REPORT_WORKSTREAM_REVISIONS_TABLE = Table( - "pc_handoff_report_workstream_revisions", - HANDOFF_REPORT_CATALOG_METADATA, - Column("scope_id", identity_string(MAX_SCOPE_ID_LENGTH), primary_key=True), - Column("version", Integer, primary_key=True), - Column("project_id", identity_string(MAX_REPORT_ID_LENGTH), nullable=False), - Column("effective_at", identity_string(32), nullable=False), - Column("payload", Text, nullable=False), - CheckConstraint("version > 0", name="ck_pc_handoff_report_workstream_revisions_version_positive"), -) -Index( - "ix_pc_handoff_report_workstream_revisions_effective_at", - HANDOFF_REPORT_WORKSTREAM_REVISIONS_TABLE.c.scope_id, - HANDOFF_REPORT_WORKSTREAM_REVISIONS_TABLE.c.effective_at, - HANDOFF_REPORT_WORKSTREAM_REVISIONS_TABLE.c.version, -) - -HANDOFF_REPORT_CATALOG_TABLES = ( - HANDOFF_REPORT_PROJECTS_TABLE, - HANDOFF_REPORT_PROJECT_REVISIONS_TABLE, - HANDOFF_REPORT_WORKSTREAMS_TABLE, - HANDOFF_REPORT_WORKSTREAM_REVISIONS_TABLE, -) - -DEFAULT_CATALOG_PAGE_SIZE = 50 -MAX_CATALOG_PAGE_SIZE = 100 -CatalogItemT = TypeVar("CatalogItemT") - - -class CatalogPage(Generic[CatalogItemT]): - """A stable identity-cursor page returned by the Report catalog.""" - - __slots__ = ("items", "next_cursor") - - def __init__(self, items: tuple[CatalogItemT, ...], next_cursor: str | None) -> None: - self.items = items - self.next_cursor = next_cursor - - @override - def __repr__(self) -> str: - return f"CatalogPage(items={self.items!r}, next_cursor={self.next_cursor!r})" - - @override - def __eq__(self, other: object) -> bool: - return isinstance(other, CatalogPage) and self.items == other.items and self.next_cursor == other.next_cursor - - -class ReportCatalogRepository: - """Persist mutable catalog heads and immutable descriptor revisions.""" - - async def create_project( - self, - connection: AsyncConnection, - descriptor: ProjectDescriptor, - /, - *, - effective_at: datetime | None = None, - ) -> ProjectDescriptor: - _validate_project_descriptor(descriptor) - if descriptor.version != 1: - raise HandoffReportCatalogArgumentError("version", "a new Project must start at version 1") - if await self._find_project(connection, descriptor.project_id) is not None: - raise ProjectConflictError(descriptor.project_id, None, descriptor.version) - key_owner = await self._find_project_by_key(connection, descriptor.project_key) - if key_owner is not None: - raise ProjectConflictError( - descriptor.project_id, - None, - int(key_owner["version"]), - detail=f"Project key {descriptor.project_key!r} is already in use", - ) - - effective_text = _effective_at_text(effective_at) - try: - await connection.execute( - insert(HANDOFF_REPORT_PROJECTS_TABLE).values( - project_id=descriptor.project_id, - project_key=descriptor.project_key, - version=descriptor.version, - catalog_state=descriptor.catalog_state, - payload=_dump_descriptor(descriptor), - ) - ) - await connection.execute( - insert(HANDOFF_REPORT_PROJECT_REVISIONS_TABLE).values( - project_id=descriptor.project_id, - version=descriptor.version, - effective_at=effective_text, - payload=_dump_descriptor(descriptor), - ) - ) - except IntegrityError as error: - raise ProjectConflictError( - descriptor.project_id, - None, - None, - detail=f"Project key {descriptor.project_key!r} conflicts with the current catalog", - ) from error - return descriptor - - async def get_project(self, connection: AsyncConnection, project_id: str, /) -> ProjectDescriptor: - project_id = _identifier("project_id", project_id, MAX_REPORT_ID_LENGTH) - row = await self._find_project(connection, project_id) - if row is None: - raise ProjectNotFoundError(project_id) - return _decode_project(row) - - async def list_projects( - self, - connection: AsyncConnection, - /, - *, - cursor: str | None = None, - limit: int = DEFAULT_CATALOG_PAGE_SIZE, - include_archived: bool = False, - ) -> CatalogPage[ProjectDescriptor]: - cursor = None if cursor is None else _identifier("cursor", cursor, MAX_REPORT_ID_LENGTH) - _page_limit(limit) - statement = ( - select(HANDOFF_REPORT_PROJECTS_TABLE).order_by(HANDOFF_REPORT_PROJECTS_TABLE.c.project_id).limit(limit + 1) - ) - if cursor is not None: - statement = statement.where(HANDOFF_REPORT_PROJECTS_TABLE.c.project_id > cursor) - if not include_archived: - statement = statement.where(HANDOFF_REPORT_PROJECTS_TABLE.c.catalog_state == "included") - rows = list((await connection.execute(statement)).mappings()) - has_more = len(rows) > limit - selected = rows[:limit] - items = tuple(_decode_project(row) for row in selected) - return CatalogPage(items, items[-1].project_id if has_more and items else None) - - async def update_project( - self, - connection: AsyncConnection, - descriptor: ProjectDescriptor, - expected_version: int, - /, - *, - effective_at: datetime | None = None, - ) -> ProjectDescriptor: - _validate_project_descriptor(descriptor) - _version("expected_version", expected_version) - if descriptor.version != expected_version + 1: - raise HandoffReportCatalogArgumentError( - "version", - "updated Project version must equal expected_version + 1", - ) - current = await self._find_project(connection, descriptor.project_id) - if current is None: - raise ProjectNotFoundError(descriptor.project_id) - current_version = int(current["version"]) - if current_version != expected_version: - raise ProjectConflictError(descriptor.project_id, expected_version, current_version) - key_owner = await self._find_project_by_key(connection, descriptor.project_key) - if key_owner is not None and str(key_owner["project_id"]) != descriptor.project_id: - raise ProjectConflictError( - descriptor.project_id, - expected_version, - current_version, - detail=f"Project key {descriptor.project_key!r} is already in use", - ) - - effective_text = _effective_at_text(effective_at) - try: - result = await connection.execute( - update(HANDOFF_REPORT_PROJECTS_TABLE) - .where( - HANDOFF_REPORT_PROJECTS_TABLE.c.project_id == descriptor.project_id, - HANDOFF_REPORT_PROJECTS_TABLE.c.version == expected_version, - ) - .values( - project_key=descriptor.project_key, - version=descriptor.version, - catalog_state=descriptor.catalog_state, - payload=_dump_descriptor(descriptor), - ) - ) - if result.rowcount != 1: - current = await self._find_project(connection, descriptor.project_id) - if current is None: - raise ProjectNotFoundError(descriptor.project_id) - raise ProjectConflictError( - descriptor.project_id, - expected_version, - int(current["version"]), - ) - await connection.execute( - insert(HANDOFF_REPORT_PROJECT_REVISIONS_TABLE).values( - project_id=descriptor.project_id, - version=descriptor.version, - effective_at=effective_text, - payload=_dump_descriptor(descriptor), - ) - ) - except IntegrityError as error: - raise ProjectConflictError( - descriptor.project_id, - expected_version, - current_version, - detail=f"Project key {descriptor.project_key!r} conflicts with the current catalog", - ) from error - return descriptor - - async def project_revision( - self, - connection: AsyncConnection, - project_id: str, - version: int, - /, - ) -> ProjectDescriptor: - project_id = _identifier("project_id", project_id, MAX_REPORT_ID_LENGTH) - _version("version", version) - row = ( - ( - await connection.execute( - select(HANDOFF_REPORT_PROJECT_REVISIONS_TABLE).where( - HANDOFF_REPORT_PROJECT_REVISIONS_TABLE.c.project_id == project_id, - HANDOFF_REPORT_PROJECT_REVISIONS_TABLE.c.version == version, - ) - ) - ) - .mappings() - .one_or_none() - ) - if row is None: - raise ProjectNotFoundError(project_id) - return _decode_project(row) - - async def project_at( - self, - connection: AsyncConnection, - project_id: str, - effective_at: datetime, - /, - ) -> ProjectDescriptor | None: - project_id = _identifier("project_id", project_id, MAX_REPORT_ID_LENGTH) - boundary = _effective_at_text(effective_at) - row = ( - ( - await connection.execute( - select(HANDOFF_REPORT_PROJECT_REVISIONS_TABLE) - .where( - HANDOFF_REPORT_PROJECT_REVISIONS_TABLE.c.project_id == project_id, - HANDOFF_REPORT_PROJECT_REVISIONS_TABLE.c.effective_at <= boundary, - ) - .order_by( - HANDOFF_REPORT_PROJECT_REVISIONS_TABLE.c.effective_at.desc(), - HANDOFF_REPORT_PROJECT_REVISIONS_TABLE.c.version.desc(), - ) - .limit(1) - ) - ) - .mappings() - .one_or_none() - ) - return None if row is None else _decode_project(row) - - async def create_workstream( - self, - connection: AsyncConnection, - descriptor: WorkstreamDescriptor, - /, - *, - effective_at: datetime | None = None, - ) -> WorkstreamDescriptor: - _validate_workstream_descriptor(descriptor) - if descriptor.version != 1: - raise HandoffReportCatalogArgumentError("version", "a new Workstream must start at version 1") - await self.get_project(connection, descriptor.project_id) - existing = await self._find_workstream(connection, descriptor.scope_id) - if existing is not None: - existing_project = str(existing["project_id"]) - if existing_project != descriptor.project_id: - raise ScopeAlreadyGroupedError(descriptor.scope_id, existing_project) - raise WorkstreamConflictError( - descriptor.scope_id, - None, - int(existing["version"]), - detail=f"scope {descriptor.scope_id!r} is already registered", - ) - key_owner = await self._find_workstream_by_key(connection, descriptor.project_id, descriptor.key) - if key_owner is not None: - raise WorkstreamConflictError( - descriptor.scope_id, - None, - int(key_owner["version"]), - detail=f"Workstream key {descriptor.key!r} is already in use", - ) - - effective_text = _effective_at_text(effective_at) - try: - await connection.execute( - insert(HANDOFF_REPORT_WORKSTREAMS_TABLE).values( - scope_id=descriptor.scope_id, - project_id=descriptor.project_id, - workstream_key=descriptor.key, - version=descriptor.version, - catalog_state=descriptor.catalog_state, - payload=_dump_descriptor(descriptor), - ) - ) - await connection.execute( - insert(HANDOFF_REPORT_WORKSTREAM_REVISIONS_TABLE).values( - scope_id=descriptor.scope_id, - version=descriptor.version, - project_id=descriptor.project_id, - effective_at=effective_text, - payload=_dump_descriptor(descriptor), - ) - ) - except IntegrityError as error: - raise WorkstreamConflictError( - descriptor.scope_id, - None, - None, - detail=f"Workstream key {descriptor.key!r} conflicts with the current catalog", - ) from error - return descriptor - - async def get_workstream(self, connection: AsyncConnection, scope_id: str, /) -> WorkstreamDescriptor: - scope_id = _identifier("scope_id", scope_id, MAX_SCOPE_ID_LENGTH) - row = await self._find_workstream(connection, scope_id) - if row is None: - raise WorkstreamNotFoundError(scope_id) - return _decode_workstream(row) - - async def list_workstreams( - self, - connection: AsyncConnection, - project_id: str, - /, - *, - cursor: str | None = None, - limit: int = DEFAULT_CATALOG_PAGE_SIZE, - include_archived: bool = False, - ) -> CatalogPage[WorkstreamDescriptor]: - project_id = _identifier("project_id", project_id, MAX_REPORT_ID_LENGTH) - cursor = None if cursor is None else _identifier("cursor", cursor, MAX_SCOPE_ID_LENGTH) - _page_limit(limit) - statement = ( - select(HANDOFF_REPORT_WORKSTREAMS_TABLE) - .where(HANDOFF_REPORT_WORKSTREAMS_TABLE.c.project_id == project_id) - .order_by(HANDOFF_REPORT_WORKSTREAMS_TABLE.c.scope_id) - .limit(limit + 1) - ) - if cursor is not None: - statement = statement.where(HANDOFF_REPORT_WORKSTREAMS_TABLE.c.scope_id > cursor) - if not include_archived: - statement = statement.where(HANDOFF_REPORT_WORKSTREAMS_TABLE.c.catalog_state == "included") - rows = list((await connection.execute(statement)).mappings()) - has_more = len(rows) > limit - selected = rows[:limit] - items = tuple(_decode_workstream(row) for row in selected) - return CatalogPage(items, items[-1].scope_id if has_more and items else None) - - async def update_workstream( - self, - connection: AsyncConnection, - descriptor: WorkstreamDescriptor, - expected_version: int, - /, - *, - effective_at: datetime | None = None, - ) -> WorkstreamDescriptor: - _validate_workstream_descriptor(descriptor) - _version("expected_version", expected_version) - if descriptor.version != expected_version + 1: - raise HandoffReportCatalogArgumentError( - "version", - "updated Workstream version must equal expected_version + 1", - ) - current = await self._find_workstream(connection, descriptor.scope_id) - if current is None: - raise WorkstreamNotFoundError(descriptor.scope_id) - current_project = str(current["project_id"]) - current_version = int(current["version"]) - if current_project != descriptor.project_id: - raise HandoffReportCatalogArgumentError( - "project_id", - "Workstream membership cannot move between Projects", - ) - if current_version != expected_version: - raise WorkstreamConflictError(descriptor.scope_id, expected_version, current_version) - key_owner = await self._find_workstream_by_key(connection, descriptor.project_id, descriptor.key) - if key_owner is not None and str(key_owner["scope_id"]) != descriptor.scope_id: - raise WorkstreamConflictError( - descriptor.scope_id, - expected_version, - current_version, - detail=f"Workstream key {descriptor.key!r} is already in use", - ) - - effective_text = _effective_at_text(effective_at) - try: - result = await connection.execute( - update(HANDOFF_REPORT_WORKSTREAMS_TABLE) - .where( - HANDOFF_REPORT_WORKSTREAMS_TABLE.c.scope_id == descriptor.scope_id, - HANDOFF_REPORT_WORKSTREAMS_TABLE.c.version == expected_version, - ) - .values( - workstream_key=descriptor.key, - version=descriptor.version, - catalog_state=descriptor.catalog_state, - payload=_dump_descriptor(descriptor), - ) - ) - if result.rowcount != 1: - current = await self._find_workstream(connection, descriptor.scope_id) - if current is None: - raise WorkstreamNotFoundError(descriptor.scope_id) - raise WorkstreamConflictError( - descriptor.scope_id, - expected_version, - int(current["version"]), - ) - await connection.execute( - insert(HANDOFF_REPORT_WORKSTREAM_REVISIONS_TABLE).values( - scope_id=descriptor.scope_id, - version=descriptor.version, - project_id=descriptor.project_id, - effective_at=effective_text, - payload=_dump_descriptor(descriptor), - ) - ) - except IntegrityError as error: - raise WorkstreamConflictError( - descriptor.scope_id, - expected_version, - current_version, - detail=f"Workstream key {descriptor.key!r} conflicts with the current catalog", - ) from error - return descriptor - - async def workstream_revision( - self, - connection: AsyncConnection, - scope_id: str, - version: int, - /, - ) -> WorkstreamDescriptor: - scope_id = _identifier("scope_id", scope_id, MAX_SCOPE_ID_LENGTH) - _version("version", version) - row = ( - ( - await connection.execute( - select(HANDOFF_REPORT_WORKSTREAM_REVISIONS_TABLE).where( - HANDOFF_REPORT_WORKSTREAM_REVISIONS_TABLE.c.scope_id == scope_id, - HANDOFF_REPORT_WORKSTREAM_REVISIONS_TABLE.c.version == version, - ) - ) - ) - .mappings() - .one_or_none() - ) - if row is None: - raise WorkstreamNotFoundError(scope_id) - return _decode_workstream(row) - - async def workstream_at( - self, - connection: AsyncConnection, - scope_id: str, - effective_at: datetime, - /, - ) -> WorkstreamDescriptor | None: - scope_id = _identifier("scope_id", scope_id, MAX_SCOPE_ID_LENGTH) - boundary = _effective_at_text(effective_at) - row = ( - ( - await connection.execute( - select(HANDOFF_REPORT_WORKSTREAM_REVISIONS_TABLE) - .where( - HANDOFF_REPORT_WORKSTREAM_REVISIONS_TABLE.c.scope_id == scope_id, - HANDOFF_REPORT_WORKSTREAM_REVISIONS_TABLE.c.effective_at <= boundary, - ) - .order_by( - HANDOFF_REPORT_WORKSTREAM_REVISIONS_TABLE.c.effective_at.desc(), - HANDOFF_REPORT_WORKSTREAM_REVISIONS_TABLE.c.version.desc(), - ) - .limit(1) - ) - ) - .mappings() - .one_or_none() - ) - return None if row is None else _decode_workstream(row) - - async def _find_project(self, connection: AsyncConnection, project_id: str) -> Mapping[Any, Any] | None: - return ( - ( - await connection.execute( - select(HANDOFF_REPORT_PROJECTS_TABLE).where( - HANDOFF_REPORT_PROJECTS_TABLE.c.project_id == project_id - ) - ) - ) - .mappings() - .one_or_none() - ) - - async def _find_project_by_key(self, connection: AsyncConnection, project_key: str) -> Mapping[Any, Any] | None: - return ( - ( - await connection.execute( - select(HANDOFF_REPORT_PROJECTS_TABLE).where( - HANDOFF_REPORT_PROJECTS_TABLE.c.project_key == project_key - ) - ) - ) - .mappings() - .one_or_none() - ) - - async def _find_workstream(self, connection: AsyncConnection, scope_id: str) -> Mapping[Any, Any] | None: - return ( - ( - await connection.execute( - select(HANDOFF_REPORT_WORKSTREAMS_TABLE).where( - HANDOFF_REPORT_WORKSTREAMS_TABLE.c.scope_id == scope_id - ) - ) - ) - .mappings() - .one_or_none() - ) - - async def _find_workstream_by_key( - self, - connection: AsyncConnection, - project_id: str, - key: str | None, - ) -> Mapping[Any, Any] | None: - if key is None: - return None - return ( - ( - await connection.execute( - select(HANDOFF_REPORT_WORKSTREAMS_TABLE).where( - HANDOFF_REPORT_WORKSTREAMS_TABLE.c.project_id == project_id, - HANDOFF_REPORT_WORKSTREAMS_TABLE.c.workstream_key == key, - ) - ) - ) - .mappings() - .one_or_none() - ) - - -def _validate_project_descriptor(value: ProjectDescriptor) -> None: - if not isinstance(value, ProjectDescriptor): - raise HandoffReportCatalogArgumentError("descriptor", "must be a ProjectDescriptor") - - -def _validate_workstream_descriptor(value: WorkstreamDescriptor) -> None: - if not isinstance(value, WorkstreamDescriptor): - raise HandoffReportCatalogArgumentError("descriptor", "must be a WorkstreamDescriptor") - - -def _dump_descriptor(value: ProjectDescriptor | WorkstreamDescriptor) -> str: - return json.dumps( - value.model_dump(mode="json", by_alias=True), - ensure_ascii=False, - sort_keys=True, - separators=(",", ":"), - allow_nan=False, - ) - - -def _decode_project(row: Mapping[Any, Any]) -> ProjectDescriptor: - try: - value = ProjectDescriptor.model_validate_json(str(row["payload"])) - except ValidationError as error: - raise InvalidStoredCatalogError("Project descriptor", "does not match its schema") from error # noqa: TRY003 - if value.project_id != str(row["project_id"]) or value.version != int(row["version"]): - raise InvalidStoredCatalogError( # noqa: TRY003 - "Project descriptor", - "identity does not match indexed columns", - ) - return value - - -def _decode_workstream(row: Mapping[Any, Any]) -> WorkstreamDescriptor: - try: - value = WorkstreamDescriptor.model_validate_json(str(row["payload"])) - except ValidationError as error: - raise InvalidStoredCatalogError("Workstream descriptor", "does not match its schema") from error # noqa: TRY003 - if value.scope_id != str(row["scope_id"]) or value.version != int(row["version"]): - raise InvalidStoredCatalogError( # noqa: TRY003 - "Workstream descriptor", - "identity does not match indexed columns", - ) - return value - - -def _identifier(field: str, value: object, maximum: int) -> str: - if not isinstance(value, str) or not value or value != value.strip(): - raise HandoffReportCatalogArgumentError(field, "must be a non-empty trimmed string") - if len(value) > maximum: - raise HandoffReportCatalogArgumentError(field, f"must not exceed {maximum} characters") - return value - - -def _version(field: str, value: object) -> None: - if not isinstance(value, int) or isinstance(value, bool) or value < 1: - raise HandoffReportCatalogArgumentError(field, "must be a positive integer") - - -def _page_limit(value: object) -> None: - if not isinstance(value, int) or isinstance(value, bool) or not 1 <= value <= MAX_CATALOG_PAGE_SIZE: - raise HandoffReportCatalogArgumentError( - "limit", - f"must be between 1 and {MAX_CATALOG_PAGE_SIZE}", - ) - - -def _effective_at_text(value: datetime | None) -> str: - current = datetime.now(UTC) if value is None else value - if current.tzinfo is None or current.utcoffset() is None: - raise HandoffReportCatalogArgumentError("effective_at", "must include a UTC offset") - return current.astimezone(UTC).isoformat(timespec="microseconds").replace("+00:00", "Z") - - -__all__ = [ - "DEFAULT_CATALOG_PAGE_SIZE", - "HANDOFF_REPORT_CATALOG_METADATA", - "HANDOFF_REPORT_CATALOG_TABLES", - "HANDOFF_REPORT_PROJECTS_TABLE", - "HANDOFF_REPORT_PROJECT_REVISIONS_TABLE", - "HANDOFF_REPORT_WORKSTREAMS_TABLE", - "HANDOFF_REPORT_WORKSTREAM_REVISIONS_TABLE", - "MAX_CATALOG_PAGE_SIZE", - "CatalogPage", - "ReportCatalogRepository", -] diff --git a/src/powercontext/builtin/handoff_report/errors.py b/src/powercontext/builtin/handoff_report/errors.py index 778dfa23d..faaa2f7af 100644 --- a/src/powercontext/builtin/handoff_report/errors.py +++ b/src/powercontext/builtin/handoff_report/errors.py @@ -5,192 +5,27 @@ # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Typed failures owned by the optional Handoff Report feature.""" -from __future__ import annotations +"""Failures specific to Handoff Report projection.""" from powercontext.errors import PowerContextError class HandoffReportError(PowerContextError): - """Base class for failures isolated to Handoff Report operations.""" - - -class HandoffReportBusyError(HandoffReportError): - """Raised when repeated head reads cannot form an optimistic-stable selection.""" - - def __init__(self, attempts: int) -> None: - self.attempts = attempts - super().__init__(f"Handoff heads remained unstable after {attempts} attempts") + pass class HandoffReportInconsistentError(HandoffReportError): - """Raised when an adapter cannot return the exact Handoff frozen in selection.""" - def __init__(self, scope_id: str) -> None: self.scope_id = scope_id - super().__init__(f"the frozen Handoff selection became inconsistent for scope {scope_id!r}") - - -class HandoffReportEvidenceCheckUnavailableError(HandoffReportError): - """Raised when a read adapter has no independent evidence-check capability.""" + super().__init__(f"the exact Handoff could not be read for Scope {scope_id!r}") class HandoffReportTooLargeError(HandoffReportError): - """Raised when an untruncated report exceeds a deterministic resource limit.""" - - def __init__( - self, - *, - selected_workstreams: int, - selected_activities: int, - estimated_bytes: int | None = None, - ) -> None: - self.selected_workstreams = selected_workstreams - self.selected_activities = selected_activities + def __init__(self, *, selected_scopes: int, estimated_bytes: int | None = None) -> None: + self.selected_scopes = selected_scopes self.estimated_bytes = estimated_bytes - super().__init__("the Handoff Report exceeds the configured projection limit") - - -class HandoffReportCatalogArgumentError(HandoffReportError, ValueError): - """Raised when a catalog operation receives an invalid control value.""" - - def __init__(self, field: str, detail: str) -> None: - self.field = field - self.detail = detail - super().__init__(f"invalid Handoff Report catalog argument {field}: {detail}") - - -class InvalidStoredCatalogError(HandoffReportError): - """Raised when a persisted catalog descriptor is malformed or inconsistent.""" - - def __init__(self, kind: str, detail: str) -> None: - self.kind = kind - self.detail = detail - super().__init__(f"invalid stored Handoff Report {kind}: {detail}") - - -class ProjectNotFoundError(HandoffReportError, LookupError): - """Raised when a Report Project is absent.""" - - code = "project_not_found" - - def __init__(self, project_id: str) -> None: - self.project_id = project_id - super().__init__(f"Report Project {project_id!r} was not found") - - -class WorkstreamNotFoundError(HandoffReportError, LookupError): - """Raised when a Report Workstream is absent.""" - - code = "scope_not_grouped" - - def __init__(self, scope_id: str) -> None: - self.scope_id = scope_id - super().__init__(f"Report Workstream {scope_id!r} was not found") - - -class ProjectConflictError(HandoffReportError, ValueError): - """Raised when Project CAS or uniqueness validation fails.""" - - code = "project_conflict" - - def __init__( - self, - project_id: str, - expected_version: int | None, - current_version: int | None, - *, - detail: str = "Project version or key conflicts with the current catalog", - ) -> None: - self.project_id = project_id - self.expected_version = expected_version - self.current_version = current_version - self.detail = detail - super().__init__(detail) - - -class WorkstreamConflictError(HandoffReportError, ValueError): - """Raised when Workstream CAS or uniqueness validation fails.""" - - code = "workstream_conflict" - - def __init__( - self, - scope_id: str, - expected_version: int | None, - current_version: int | None, - *, - detail: str = "Workstream version or key conflicts with the current catalog", - ) -> None: - self.scope_id = scope_id - self.expected_version = expected_version - self.current_version = current_version - self.detail = detail - super().__init__(detail) - - -class ScopeAlreadyGroupedError(HandoffReportError, ValueError): - """Raised when a scope is already a member of another Project.""" - - code = "scope_already_grouped" - - def __init__(self, scope_id: str, project_id: str) -> None: - self.scope_id = scope_id - self.project_id = project_id - super().__init__(f"scope {scope_id!r} already belongs to Project {project_id!r}") - - -class WorkspaceBindingNotFoundError(HandoffReportError, LookupError): - """Raised when a workspace has no confirmed Project binding.""" - - code = "workspace_not_bound" - - def __init__(self, workspace_instance_id: str) -> None: - self.workspace_instance_id = workspace_instance_id - super().__init__(f"workspace {workspace_instance_id!r} has no confirmed Report binding") - - -class WorkspaceBindingConflictError(HandoffReportError, ValueError): - """Raised when workspace binding CAS or single-binding rules fail.""" - - code = "workspace_binding_conflict" - - def __init__( - self, - workspace_instance_id: str, - expected_version: int | None, - current_version: int | None, - *, - detail: str = "workspace binding version conflicts with the current catalog", - ) -> None: - self.workspace_instance_id = workspace_instance_id - self.expected_version = expected_version - self.current_version = current_version - self.detail = detail - super().__init__(detail) + super().__init__("the Handoff Report exceeds the response limit") -__all__ = [ - "HandoffReportBusyError", - "HandoffReportCatalogArgumentError", - "HandoffReportError", - "HandoffReportEvidenceCheckUnavailableError", - "HandoffReportInconsistentError", - "HandoffReportTooLargeError", - "InvalidStoredCatalogError", - "ProjectConflictError", - "ProjectNotFoundError", - "ScopeAlreadyGroupedError", - "WorkspaceBindingConflictError", - "WorkspaceBindingNotFoundError", - "WorkstreamConflictError", - "WorkstreamNotFoundError", -] +__all__ = ["HandoffReportError", "HandoffReportInconsistentError", "HandoffReportTooLargeError"] diff --git a/src/powercontext/builtin/handoff_report/models.py b/src/powercontext/builtin/handoff_report/models.py deleted file mode 100644 index 5348d2e29..000000000 --- a/src/powercontext/builtin/handoff_report/models.py +++ /dev/null @@ -1,483 +0,0 @@ -# Copyright (c) 2026 OceanBase. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Immutable domain values owned by the optional Handoff Report feature.""" - -from __future__ import annotations - -import posixpath -from datetime import UTC, datetime, timedelta -from typing import Annotated, Literal, TypeAlias -from unicodedata import normalize -from urllib.parse import urlsplit, urlunsplit -from zoneinfo import ZoneInfo, ZoneInfoNotFoundError - -from pydantic import BaseModel, ConfigDict, Field, StrictInt, field_validator, model_validator - -from powercontext.artifacts import ArtifactRef -from powercontext.limits import MAX_SCOPE_ID_LENGTH - -MAX_REPORT_ID_LENGTH = 256 -MAX_PROJECT_KEY_LENGTH = 64 -MAX_WORKSTREAM_KEY_LENGTH = 64 -MAX_REPORT_TITLE_LENGTH = 256 -MAX_REPORT_DESCRIPTION_LENGTH = 2_000 -MAX_REPORT_LABEL_LENGTH = 128 -MAX_REPORT_PROVIDER_LENGTH = 64 -MAX_REPORT_AGENT_LABEL_LENGTH = 128 -MAX_REPORT_SOURCE_SUMMARY_LENGTH = 2_000 -MAX_REPORT_EXTERNAL_ID_LENGTH = 256 -MAX_REPORT_URL_LENGTH = 2_048 -MAX_REPORT_REPOSITORY_ID_LENGTH = 256 -MAX_REPORT_NORMALIZED_REMOTE_LENGTH = 2_048 -MAX_REPORT_SUBPATH_LENGTH = 1_024 -MAX_WORKSPACE_INSTANCE_ID_LENGTH = 256 -MAX_REPORT_EXTERNAL_REFS = 32 -MAX_REPORT_LABELS = 32 -MAX_REPORT_EVIDENCE_REFS = 32 - -ReportLocale: TypeAlias = Literal["zh-CN", "en"] -CatalogState: TypeAlias = Literal["included", "archived"] -WorkstreamKind: TypeAlias = Literal["feature", "bug", "refactor", "operations", "research", "other"] -ExternalReferenceKind: TypeAlias = Literal[ - "issue", - "task", - "pull_request", - "branch", - "feature", - "release", - "program", - "other", -] -ReportActivitySource: TypeAlias = Literal[ - "handoff_observation", - "git_commit", - "git_worktree", - "coding_session", - "other", -] -ReportTimeBasis: TypeAlias = Literal[ - "source_reported", - "host_observed", - "first_seen", - "current_only", - "unknown", -] -ReportSelectionConsistency: TypeAlias = Literal["exact_input", "optimistic_stable"] -ReportSelectionStatus: TypeAlias = Literal["selected", "no_handoff"] -ReportActivityTrust: TypeAlias = Literal["untrusted_observation"] -HandoffReportTrust: TypeAlias = Literal["untrusted_history"] -GeneratedSummaryTrust: TypeAlias = Literal["generated_untrusted"] -RepositoryProvider: TypeAlias = Literal["github", "gitlab", "local", "other"] -WorkspaceBindingState: TypeAlias = Literal["confirmed", "detached"] - - -class _ReportValue(BaseModel): - model_config = ConfigDict(extra="forbid", frozen=True, strict=True) - - -class ExternalReference(_ReportValue): - """A navigation or filtering reference that is not identity or evidence by itself.""" - - kind: ExternalReferenceKind - provider: Annotated[str, Field(max_length=MAX_REPORT_PROVIDER_LENGTH)] - external_id: Annotated[str, Field(max_length=MAX_REPORT_EXTERNAL_ID_LENGTH)] - url: Annotated[str, Field(max_length=MAX_REPORT_URL_LENGTH)] | None = None - - @field_validator("provider", "external_id", "url") - @classmethod - def require_trimmed_text(cls, value: str | None, info) -> str | None: - return _require_optional_text(info.field_name, value) - - -class RepositoryRef(_ReportValue): - """Credential-free repository identity hints attached to a workspace.""" - - provider: RepositoryProvider - repository_id: Annotated[str, Field(max_length=MAX_REPORT_REPOSITORY_ID_LENGTH)] | None = None - normalized_remote: Annotated[str, Field(max_length=MAX_REPORT_NORMALIZED_REMOTE_LENGTH)] | None = None - subpath: Annotated[str, Field(max_length=MAX_REPORT_SUBPATH_LENGTH)] | None = None - - @field_validator("repository_id", "normalized_remote", "subpath") - @classmethod - def require_trimmed_text(cls, value: str | None, info) -> str | None: - return _require_optional_text(info.field_name, value) - - @model_validator(mode="after") - def require_repository_signal(self) -> RepositoryRef: - if self.repository_id is None and self.normalized_remote is None and self.subpath is None: - raise ValueError("repository_ref must contain a repository id, remote, or subpath") # noqa: TRY003 - if self.normalized_remote is not None and any(marker in self.normalized_remote for marker in ("@", "?", "#")): - raise ValueError("normalized_remote must not contain credentials or query fragments") # noqa: TRY003 - return self - - -class WorkspaceBinding(_ReportValue): - """One CAS-versioned binding between a local checkout and a Report Project.""" - - schema_version: Literal["powercontext.workspace-binding.v1"] = Field( - default="powercontext.workspace-binding.v1", - alias="schema", - ) - workspace_instance_id: Annotated[str, Field(max_length=MAX_WORKSPACE_INSTANCE_ID_LENGTH)] - project_id: Annotated[str, Field(max_length=MAX_REPORT_ID_LENGTH)] - repository_ref: RepositoryRef - state: WorkspaceBindingState = "confirmed" - confirmed_at: datetime - version: StrictInt = Field(ge=1) - - @field_validator("workspace_instance_id", "project_id") - @classmethod - def require_trimmed_text(cls, value: str, info) -> str: - return _require_text(info.field_name, value) - - @field_validator("confirmed_at") - @classmethod - def normalize_confirmed_at(cls, value: datetime) -> datetime: - if value.tzinfo is None or value.utcoffset() is None: - raise ValueError("confirmed_at must include a UTC offset") # noqa: TRY003 - return value.astimezone(UTC) - - -class ProjectDescriptor(_ReportValue): - """One versioned Report-owned Project catalog snapshot.""" - - schema_version: Literal["powercontext.project.v1"] = Field( - default="powercontext.project.v1", - alias="schema", - ) - project_id: Annotated[str, Field(max_length=MAX_REPORT_ID_LENGTH)] - project_key: Annotated[str, Field(max_length=MAX_PROJECT_KEY_LENGTH)] - title: Annotated[str, Field(max_length=MAX_REPORT_TITLE_LENGTH)] - description: Annotated[str, Field(max_length=MAX_REPORT_DESCRIPTION_LENGTH)] | None = None - default_locale: ReportLocale = "zh-CN" - timezone: Annotated[str, Field(max_length=MAX_REPORT_ID_LENGTH)] - catalog_state: CatalogState = "included" - version: StrictInt = Field(ge=1) - - @field_validator("project_id", "project_key", "title", "description") - @classmethod - def require_trimmed_text(cls, value: str | None, info) -> str | None: - return _require_optional_text(info.field_name, value) - - @field_validator("timezone") - @classmethod - def require_iana_timezone(cls, value: str) -> str: - _require_text("timezone", value) - try: - ZoneInfo(value) - except ZoneInfoNotFoundError as error: - raise ValueError("timezone must be a recognized IANA timezone") from error # noqa: TRY003 - return value - - -class WorkstreamDescriptor(_ReportValue): - """One versioned Report-owned descriptor for an existing Handoff scope.""" - - schema_version: Literal["powercontext.workstream.v1"] = Field( - default="powercontext.workstream.v1", - alias="schema", - ) - scope_id: Annotated[str, Field(max_length=MAX_SCOPE_ID_LENGTH)] - project_id: Annotated[str, Field(max_length=MAX_REPORT_ID_LENGTH)] - key: Annotated[str, Field(max_length=MAX_WORKSTREAM_KEY_LENGTH)] | None = None - title: Annotated[str, Field(max_length=MAX_REPORT_TITLE_LENGTH)] - kind: WorkstreamKind - catalog_state: CatalogState = "included" - external_refs: Annotated[tuple[ExternalReference, ...], Field(max_length=MAX_REPORT_EXTERNAL_REFS)] = () - labels: Annotated[tuple[str, ...], Field(max_length=MAX_REPORT_LABELS)] = () - version: StrictInt = Field(ge=1) - - @field_validator("scope_id", "project_id", "key", "title") - @classmethod - def require_trimmed_text(cls, value: str | None, info) -> str | None: - return _require_optional_text(info.field_name, value) - - @field_validator("labels") - @classmethod - def require_valid_labels(cls, values: tuple[str, ...]) -> tuple[str, ...]: - for value in values: - _require_text("label", value) - if len(value) > MAX_REPORT_LABEL_LENGTH: - raise ValueError(f"label must not exceed {MAX_REPORT_LABEL_LENGTH} characters") # noqa: TRY003 - if len(set(values)) != len(values): - raise ValueError("Workstream labels must be unique") # noqa: TRY003 - return values - - @model_validator(mode="after") - def require_unique_external_refs(self) -> WorkstreamDescriptor: - if len(set(self.external_refs)) != len(self.external_refs): - raise ValueError("Workstream external references must be unique") # noqa: TRY003 - return self - - -class ActivityAgent(_ReportValue): - """Untrusted Agent attribution reported by an activity source.""" - - provider: Annotated[str, Field(max_length=MAX_REPORT_PROVIDER_LENGTH)] | None = None - label: Annotated[str, Field(max_length=MAX_REPORT_AGENT_LABEL_LENGTH)] | None = None - - @field_validator("provider", "label") - @classmethod - def require_trimmed_text(cls, value: str | None, info) -> str | None: - return _require_optional_text(info.field_name, value) - - @model_validator(mode="after") - def require_attribution(self) -> ActivityAgent: - if self.provider is None and self.label is None: - raise ValueError("Activity Agent must contain a provider or label") # noqa: TRY003 - return self - - -class ActivityVcsContext(_ReportValue): - """Untrusted VCS display context observed for an activity.""" - - branch: Annotated[str, Field(max_length=MAX_REPORT_TITLE_LENGTH)] | None = None - head_revision: Annotated[str, Field(max_length=MAX_REPORT_ID_LENGTH)] | None = None - - @field_validator("branch", "head_revision") - @classmethod - def require_trimmed_text(cls, value: str | None, info) -> str | None: - return _require_optional_text(info.field_name, value) - - @model_validator(mode="after") - def require_context(self) -> ActivityVcsContext: - if self.branch is None and self.head_revision is None: - raise ValueError("Activity VCS context must contain a branch or head revision") # noqa: TRY003 - return self - - -class ReportActivityEvent(_ReportValue): - """One idempotent, untrusted observation in the independent Report activity store.""" - - schema_version: Literal["powercontext.handoff-report-activity.v1"] = Field( - default="powercontext.handoff-report-activity.v1", - alias="schema", - ) - event_id: Annotated[str, Field(max_length=MAX_REPORT_ID_LENGTH)] - project_id: Annotated[str, Field(max_length=MAX_REPORT_ID_LENGTH)] - scope_id: Annotated[str, Field(max_length=MAX_SCOPE_ID_LENGTH)] | None = None - source: ReportActivitySource - source_event_id: Annotated[str, Field(max_length=MAX_REPORT_ID_LENGTH)] - source_ref: ExternalReference | None = None - occurred_at: datetime | None = None - observed_at: datetime - time_basis: ReportTimeBasis - title: Annotated[str, Field(max_length=MAX_REPORT_TITLE_LENGTH)] | None = None - summary: Annotated[str, Field(max_length=MAX_REPORT_SOURCE_SUMMARY_LENGTH)] | None = None - agent: ActivityAgent | None = None - session_id: Annotated[str, Field(max_length=MAX_REPORT_ID_LENGTH)] | None = None - vcs_context: ActivityVcsContext | None = None - evidence_refs: Annotated[tuple[ExternalReference, ...], Field(max_length=MAX_REPORT_EVIDENCE_REFS)] = () - trust: ReportActivityTrust = "untrusted_observation" - - @field_validator("event_id", "project_id", "scope_id", "source_event_id", "title", "summary", "session_id") - @classmethod - def require_trimmed_text(cls, value: str | None, info) -> str | None: - return _require_optional_text(info.field_name, value) - - @field_validator("occurred_at", "observed_at") - @classmethod - def require_aware_timestamp(cls, value: datetime | None, info) -> datetime | None: - if value is not None and (value.tzinfo is None or value.utcoffset() is None): - raise ValueError(f"{info.field_name} must include a UTC offset") # noqa: TRY003 - return value - - @field_validator("observed_at") - @classmethod - def require_utc_observation(cls, value: datetime) -> datetime: - if value.utcoffset() != timedelta(0): - raise ValueError("observed_at must be UTC") # noqa: TRY003 - return value - - @model_validator(mode="after") - def validate_time_semantics_and_evidence(self) -> ReportActivityEvent: - if self.time_basis == "source_reported": - if self.occurred_at is None: - raise ValueError("source-reported activity must contain occurred_at") # noqa: TRY003 - elif self.occurred_at is not None: - raise ValueError("occurred_at is only valid for source-reported activity") # noqa: TRY003 - if len(set(self.evidence_refs)) != len(self.evidence_refs): - raise ValueError("Activity evidence references must be unique") # noqa: TRY003 - return self - - def effective_period_time(self) -> datetime | None: - """Return the reportable event time, without inventing time for current or unknown activity.""" - - if self.time_basis == "source_reported": - return self.occurred_at - if self.time_basis in {"host_observed", "first_seen"}: - return self.observed_at - return None - - -class ReportSelectionEntry(_ReportValue): - """Freeze one Workstream descriptor revision and exact Handoff selection.""" - - scope_id: Annotated[str, Field(max_length=MAX_SCOPE_ID_LENGTH)] - workstream_revision: StrictInt = Field(ge=1) - status: ReportSelectionStatus - handoff_ref: ArtifactRef | None = None - - @field_validator("scope_id") - @classmethod - def require_scope_id(cls, value: str) -> str: - return _require_text("scope_id", value) - - @model_validator(mode="after") - def validate_selection(self) -> ReportSelectionEntry: - if self.status == "selected": - if self.handoff_ref is None: - raise ValueError("selected Report entry must contain an exact Handoff reference") # noqa: TRY003 - if self.handoff_ref.family != "handoff": - raise ValueError("selected Report entry must reference the Handoff family") # noqa: TRY003 - elif self.handoff_ref is not None: - raise ValueError("no-handoff Report entry cannot contain a Handoff reference") # noqa: TRY003 - return self - - -def normalized_sort_text(value: str) -> str: - """Normalize user-visible text for deterministic, locale-independent ordering.""" - - return normalize("NFC", value).casefold() - - -def workstream_sort_key(workstream: WorkstreamDescriptor) -> tuple[str, str]: - """Sort descriptors by normalized title and stable scope identity.""" - - return normalized_sort_text(workstream.title), workstream.scope_id - - -def activity_sort_key(event: ReportActivityEvent) -> tuple[bool, datetime, datetime, str]: - """Sort reportable activity first and unknown/current activity deterministically last.""" - - effective_time = event.effective_period_time() - return effective_time is None, effective_time or event.observed_at, event.observed_at, event.event_id - - -def selection_sort_key(entry: ReportSelectionEntry) -> str: - """Sort exact selections by canonical Workstream identity.""" - - return entry.scope_id - - -def normalize_repository_ref(value: RepositoryRef) -> RepositoryRef: - """Apply the versioned, credential-free normalization used for binding keys.""" - - if not isinstance(value, RepositoryRef): - raise TypeError("repository_ref must be a RepositoryRef") # noqa: TRY003 - remote = _normalize_repository_remote(value.normalized_remote) - subpath = _normalize_repository_subpath(value.subpath) - - return RepositoryRef( - provider=value.provider, - repository_id=value.repository_id, - normalized_remote=remote, - subpath=subpath, - ) - - -def _normalize_repository_remote(value: str | None) -> str | None: - if value is None or "://" not in value: - return None if value is None else value.rstrip("/") - parsed = urlsplit(value) - if parsed.username is not None or parsed.password is not None or parsed.query or parsed.fragment: - raise ValueError("normalized_remote must not contain credentials or query fragments") # noqa: TRY003 - if parsed.hostname is None: - raise ValueError("normalized_remote must contain a host") # noqa: TRY003 - try: - port = parsed.port - except ValueError as error: - raise ValueError("normalized_remote must contain a valid port") from error # noqa: TRY003 - host = parsed.hostname.lower() - netloc = host if port is None else f"{host}:{port}" - path = "/" + "/".join(part for part in parsed.path.split("/") if part not in {"", "."}) - return urlunsplit((parsed.scheme.lower(), netloc, path or "/", "", "")) - - -def _normalize_repository_subpath(value: str | None) -> str | None: - if value is None: - return None - candidate = value.replace("\\", "/") - if any(part == ".." for part in candidate.split("/")): - raise ValueError("repository subpath must not contain parent traversal") # noqa: TRY003 - normalized = posixpath.normpath(candidate) - if normalized in {"", "."}: - return "." - if normalized.startswith("/"): - raise ValueError("repository subpath must be relative") # noqa: TRY003 - return normalized - - -def _require_text(field_name: str, value: str) -> str: - if not value.strip(): - raise ValueError(f"{field_name} must contain non-whitespace text") # noqa: TRY003 - if value != value.strip(): - raise ValueError(f"{field_name} must not contain leading or trailing whitespace") # noqa: TRY003 - return value - - -def _require_optional_text(field_name: str, value: str | None) -> str | None: - if value is not None: - _require_text(field_name, value) - return value - - -__all__ = [ - "MAX_PROJECT_KEY_LENGTH", - "MAX_REPORT_AGENT_LABEL_LENGTH", - "MAX_REPORT_DESCRIPTION_LENGTH", - "MAX_REPORT_EVIDENCE_REFS", - "MAX_REPORT_EXTERNAL_ID_LENGTH", - "MAX_REPORT_EXTERNAL_REFS", - "MAX_REPORT_ID_LENGTH", - "MAX_REPORT_LABELS", - "MAX_REPORT_LABEL_LENGTH", - "MAX_REPORT_NORMALIZED_REMOTE_LENGTH", - "MAX_REPORT_PROVIDER_LENGTH", - "MAX_REPORT_REPOSITORY_ID_LENGTH", - "MAX_REPORT_SOURCE_SUMMARY_LENGTH", - "MAX_REPORT_SUBPATH_LENGTH", - "MAX_REPORT_TITLE_LENGTH", - "MAX_REPORT_URL_LENGTH", - "MAX_WORKSPACE_INSTANCE_ID_LENGTH", - "MAX_WORKSTREAM_KEY_LENGTH", - "ActivityAgent", - "ActivityVcsContext", - "CatalogState", - "ExternalReference", - "ExternalReferenceKind", - "GeneratedSummaryTrust", - "HandoffReportTrust", - "ProjectDescriptor", - "ReportActivityEvent", - "ReportActivitySource", - "ReportActivityTrust", - "ReportLocale", - "ReportSelectionConsistency", - "ReportSelectionEntry", - "ReportSelectionStatus", - "ReportTimeBasis", - "RepositoryProvider", - "RepositoryRef", - "WorkspaceBinding", - "WorkspaceBindingState", - "WorkstreamDescriptor", - "WorkstreamKind", - "activity_sort_key", - "normalize_repository_ref", - "normalized_sort_text", - "selection_sort_key", - "workstream_sort_key", -] diff --git a/src/powercontext/builtin/handoff_report/protocols.py b/src/powercontext/builtin/handoff_report/protocols.py index 5d77cae1b..c087524d5 100644 --- a/src/powercontext/builtin/handoff_report/protocols.py +++ b/src/powercontext/builtin/handoff_report/protocols.py @@ -5,57 +5,26 @@ # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -"""Read-only ports consumed by the optional Handoff Report feature.""" +"""Read ports used by Handoff Report projection.""" from __future__ import annotations from typing import Protocol from powercontext.artifacts import ArtifactRef -from powercontext.builtin.artifacts.handoff import Handoff, HandoffEvidenceCheck -from powercontext.builtin.work import WorkContinuity +from powercontext.builtin.artifacts.handoff import Handoff +from powercontext.builtin.scope.models import ScopeDescriptor, ScopeSelection class HandoffReadAdapter(Protocol): - """Read committed Handoffs without extending their persistence protocol.""" - - async def latest(self, scope_id: str, /) -> Handoff | None: - """Return one scope's current committed Handoff, if it exists.""" - - ... - - async def get(self, scope_id: str, reference: ArtifactRef, /) -> Handoff: - """Return the exact committed Handoff addressed by ``reference``.""" - - ... - - async def revisions(self, scope_id: str, /) -> tuple[Handoff, ...]: - """Return one scope's committed Handoffs in ascending Revision order.""" - - ... - - async def check_evidence( - self, - scope_id: str, - reference: ArtifactRef, - /, - ) -> tuple[HandoffEvidenceCheck, ...]: - """Recheck evidence readability for one exact committed Handoff.""" - - ... + async def latest(self, scope_id: str, /) -> Handoff | None: ... + async def get(self, scope_id: str, reference: ArtifactRef, /) -> Handoff: ... -class WorkContinuityReadAdapter(Protocol): - """Read the high-level Work loop projection for one scope.""" - async def get(self, scope_id: str, reference: ArtifactRef | None, /) -> WorkContinuity: ... +class ScopeSelectionResolver(Protocol): + async def resolve_selection(self, selection: ScopeSelection, /) -> tuple[ScopeDescriptor, ...]: ... -__all__ = ["HandoffReadAdapter", "WorkContinuityReadAdapter"] +__all__ = ["HandoffReadAdapter", "ScopeSelectionResolver"] diff --git a/src/powercontext/builtin/handoff_report/rendering.py b/src/powercontext/builtin/handoff_report/rendering.py index 00d2d1da4..fbd305e6e 100644 --- a/src/powercontext/builtin/handoff_report/rendering.py +++ b/src/powercontext/builtin/handoff_report/rendering.py @@ -5,450 +5,59 @@ # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -"""Deterministic Markdown rendering for canonical Handoff Reports.""" +"""Markdown rendering for Scope-based Handoff Reports.""" from __future__ import annotations -import html -import json -import re -from datetime import datetime -from unicodedata import category - -from powercontext.builtin.handoff_report.canonical import finalize_digests -from powercontext.builtin.handoff_report.models import ExternalReference, ReportActivityEvent -from powercontext.builtin.handoff_report.report import HandoffReport, WorkstreamReport - -_LABELS = { - "zh-CN": { - "title": "PowerContext 项目交接报告", - "overview": "项目概览", - "blockers": "阻塞事项", - "workstreams": "Workstream 状态", - "details": "Workstream 详情", - "objective": "目标", - "progress": "当前进度", - "next": "下一步", - "omissions": "缺失信息", - "activities": "观察到的 Activity", - "unassigned_activities": "未分配 Activity", - "event": "事件", - "schema": "Schema", - "event_id": "事件 ID", - "project_id": "Project ID", - "source": "来源", - "source_event_id": "来源事件 ID", - "scope": "Scope", - "time_basis": "时间依据", - "occurred_at": "发生时间", - "observed_at": "观察时间", - "event_title": "标题", - "event_summary": "摘要", - "source_ref": "来源引用", - "agent": "Agent", - "session": "Session", - "vcs": "VCS 上下文", - "evidence": "证据引用", - "evidence_checks": "Evidence 检查", - "revision_history": "Handoff Revision 历史", - "revision_history_summary": "共 {total} 个 Revision,显示最近 {shown} 个。", # noqa: RUF001 - "revision_state_count": "状态条目", - "revision_omission_count": "缺失条目", - "continuity": "连续性时间线", - "transfer_state": "交接状态", - "outcome_state": "结果状态", - "journal_order_notice": "按 Source journal 的稳定位置排序;位置表示先后顺序,不代表时间戳。", # noqa: RUF001 - "invalid_work_records": "无法读取的 Work 记录", - "metadata": "报告元数据", - "selection_digest": "Selection Digest", - "report_digest": "Report Digest", - "report_kind": "报告类型", - "period": "报告周期", - "period_comparison": "与前一周期对比", - "current_activity_count": "本周期 Activity 数", - "previous_activity_count": "前一周期 Activity 数", - "activity_delta": "Activity 变化", - "handoff_boundary_coverage": "Handoff 时间边界覆盖", - "format": "格式", - "trust": "信任标记", - "none": "无", - "activity_notice": "Activity Adapter 未配置;此处不能解释为没有活动。", # noqa: RUF001 - }, - "en": { - "title": "PowerContext Project Handoff Report", - "overview": "Project Overview", - "blockers": "Blockers", - "workstreams": "Workstream Status", - "details": "Workstream Details", - "objective": "Objective", - "progress": "Current Progress", - "next": "Next Action", - "omissions": "Omissions", - "activities": "Observed Activity", - "unassigned_activities": "Unassigned Activity", - "event": "Event", - "schema": "Schema", - "event_id": "Event ID", - "project_id": "Project ID", - "source": "Source", - "source_event_id": "Source Event ID", - "scope": "Scope", - "time_basis": "Time Basis", - "occurred_at": "Occurred At", - "observed_at": "Observed At", - "event_title": "Title", - "event_summary": "Summary", - "source_ref": "Source Reference", - "agent": "Agent", - "session": "Session", - "vcs": "VCS Context", - "evidence": "Evidence References", - "evidence_checks": "Evidence Checks", - "revision_history": "Handoff Revision History", - "revision_history_summary": "{total} Revisions total. Showing the latest {shown}.", - "revision_state_count": "State Items", - "revision_omission_count": "Omissions", - "continuity": "Continuity Timeline", - "transfer_state": "Transfer State", - "outcome_state": "Outcome State", - "journal_order_notice": "Ordered by stable Source journal position; positions show sequence, not timestamps.", - "invalid_work_records": "Unreadable Work Records", - "metadata": "Report Metadata", - "selection_digest": "Selection Digest", - "report_digest": "Report Digest", - "report_kind": "Report Kind", - "period": "Report Period", - "period_comparison": "Previous Period Comparison", - "current_activity_count": "Current Activity Count", - "previous_activity_count": "Previous Activity Count", - "activity_delta": "Activity Delta", - "handoff_boundary_coverage": "Handoff Boundary Coverage", - "format": "Format", - "trust": "Trust", - "none": "None", - "activity_notice": "Activity adapters are not configured; this does not mean that no activity occurred.", - }, -} +from powercontext.builtin.handoff_report.report import HandoffReport, ScopeHandoffReport def render_markdown(report: HandoffReport, /) -> str: - """Render one stable human projection without invoking a model or parsing Markdown input.""" - - projection = finalize_digests(report.model_copy(update={"format": "markdown", "renderer_version": "markdown-v1"})) - labels = _LABELS[projection.locale] - lines = _front_matter_lines(projection) - overview_identity = ( - f"Scope: {_code_span(projection.workstreams[0].workstream.scope_id)}" - if _is_scope_report(projection) - else f"Project: {_text(projection.project.title)}" - ) - lines.extend([ - "---", - "", - f"# {labels['title']}", + lines = [ + "# Handoff Report", "", - f"## {labels['overview']}", + f"Selection: `{report.selection.mode}`", + f"Scopes: {len(report.scopes)}", "", - f"- {overview_identity}", - f"- Workstreams: {projection.coverage.selected_workstreams}", - f"- Missing Handoff: {projection.coverage.missing_handoff_workstreams}", - f"- Continuable: {projection.summary.continuable_count}", - f"- Blocked: {projection.summary.blocked_count}", - f"- Complete: {projection.summary.complete_count}", - f"- No Handoff: {projection.summary.no_handoff_count}", - ]) - if projection.coverage.activity_coverage == "not_configured": - lines.extend((f"- {_text(labels['activity_notice'])}", "")) - else: - lines.append("") - if projection.normalized_period is not None: - lines.extend(( - f"## {labels['period']}", - "", - f"- Start: {_code_span(str(projection.normalized_period['start']))}", - f"- End: {_code_span(str(projection.normalized_period['end']))}", - f"- Timezone: {_code_span(str(projection.normalized_period['timezone']))}", - "", - )) - if projection.period_comparison is not None: - comparison = projection.period_comparison - lines.extend(( - f"## {labels['period_comparison']}", - "", - f"- {labels['current_activity_count']}: {comparison.current_activity_count}", - f"- {labels['previous_activity_count']}: {comparison.previous_activity_count}", - f"- {labels['activity_delta']}: {comparison.activity_delta:+d}", - f"- {labels['handoff_boundary_coverage']}: {_code_span(comparison.handoff_boundary_coverage)}", - "", - )) - lines.extend((f"## {labels['blockers']}", "")) - blockers = tuple(item for item in projection.workstreams if item.work_status == "blocked") - if blockers: - lines.extend( - f"- {_text(item.workstream.title)} ({_code_span(item.workstream.scope_id)}): " - f"{_code_span(item.reporting_status)}" - for item in blockers - ) - else: - lines.extend((labels["none"], "")) - lines.extend((f"## {labels['workstreams']}", "")) - lines.extend( - f"- {_text(item.workstream.title)} ({_code_span(item.workstream.scope_id)}): " - f"{_code_span(item.work_status)} / {_code_span(item.reporting_status)}" - for item in projection.workstreams - ) - lines.extend(("", f"## {labels['details']}", "")) - for item in projection.workstreams: - lines.extend(_render_workstream(item, labels)) - lines.extend((f"## {labels['unassigned_activities']}", "")) - if projection.unassigned_activity: - for event in projection.unassigned_activity: - lines.extend(_render_activity(event, labels)) - else: - lines.extend((labels["none"], "")) - lines.extend((f"## {labels['metadata']}", "")) - lines.extend(( - f"- {labels['selection_digest']}: {_code_span(projection.selection_digest or labels['none'])}", - f"- {labels['report_digest']}: {_code_span(projection.report_digest or labels['none'])}", - f"- {labels['report_kind']}: {_code_span(projection.report_kind)}", - f"- {labels['format']}: {_code_span(projection.format)}", - f"- {labels['trust']}: {_code_span(projection.trust)}", - )) - return "\n".join(lines).rstrip() + "\n" - - -def _render_workstream(item: WorkstreamReport, labels: dict[str, str]) -> list[str]: - lines = [f"### {_text(item.workstream.title)} ({_code_span(item.workstream.scope_id)})", ""] - if item.content is None: - lines.extend((f"#### {labels['objective']}", "", labels["none"], "")) - else: - lines.extend((f"#### {labels['objective']}", "", _text(item.content.objective), "")) - lines.extend((f"#### {labels['progress']}", "")) - lines.extend(f"- {_text(statement.text)}" for statement in item.content.state) - lines.extend(("", f"#### {labels['next']}", "")) - lines.append(labels["none"] if item.content.next_action is None else _text(item.content.next_action.text)) - lines.extend(("", f"#### {labels['omissions']}", "")) - if item.content.omissions: - lines.extend(f"- {_text(omission.text)}" for omission in item.content.omissions) - else: - lines.append(labels["none"]) - lines.append("") - lines.extend((f"#### {labels['evidence_checks']}", "")) - if item.evidence_checks == "not_checked": - evidence_state = "not_checked" - if item.evidence_unavailable: - evidence_state = "not_checked (adapter unavailable)" - lines.extend((_code_span(evidence_state), "")) - elif item.evidence_checks: - lines.extend(f"- {_code_span(check.claim)}: {_code_span(check.status)}" for check in item.evidence_checks) - lines.append("") - lines.extend(_render_revision_history(item, labels)) - continuity = item.continuity - lines.extend((f"#### {labels['continuity']}", "")) - lines.extend(( - f"- {labels['transfer_state']}: {_code_span(continuity.coverage.transfer_state)}", - f"- {labels['outcome_state']}: {_code_span(continuity.coverage.outcome_state)}", - f"- {labels['invalid_work_records']}: {continuity.invalid_record_count}", - f"- {_text(labels['journal_order_notice'])}", - )) - if continuity.events: - for event in continuity.events: - detail = event.summary or event.actor or labels["none"] - lines.append( - f"- {_code_span(f'#{event.position}')} {_code_span(event.kind)} / " - f"{_code_span(event.status)}: {_text(detail)}" - ) - else: - lines.append(labels["none"]) - lines.append("") - lines.extend((f"#### {labels['activities']}", "")) - if item.activities: - for event in item.activities: - lines.extend(_render_activity(event, labels)) - else: - lines.extend((labels["none"], "")) - return lines - - -def _render_revision_history(item: WorkstreamReport, labels: dict[str, str]) -> list[str]: - lines = [f"#### {labels['revision_history']}", ""] - if item.handoff_history: - lines.extend(( - _text( - labels["revision_history_summary"].format( - total=item.handoff_revision_count, - shown=len(item.handoff_history), - ) - ), - "", - )) - for revision in reversed(item.handoff_history): - reference = revision.reference - lines.append( - f"- {_code_span(f'@{reference.revision}')} {_code_span(revision.disposition)}: " - f"{_text(revision.objective_excerpt)}" - ) - lines.append( - f" - {labels['revision_state_count']}: {revision.state_count}; " - f"{labels['revision_omission_count']}: {revision.omission_count}" - ) - if revision.next_action_excerpt is not None: - lines.append(f" - {labels['next']}: {_text(revision.next_action_excerpt)}") - else: - lines.append(labels["none"]) - lines.append("") - return lines - - -def _render_activity(event: ReportActivityEvent, labels: dict[str, str]) -> list[str]: - lines = [f"- **{labels['event']}** {_code_span(event.event_id)}"] - lines.extend(( - f" - {labels['schema']}: {_code_span(event.schema_version)}", - f" - {labels['event_id']}: {_code_span(event.event_id)}", - f" - {labels['project_id']}: {_code_span(event.project_id)}", - f" - {labels['source']}: {_code_span(event.source)}", - f" - {labels['source_event_id']}: {_code_span(event.source_event_id)}", - f" - {labels['scope']}: {_optional_code(event.scope_id, labels)}", - f" - {labels['time_basis']}: {_code_span(event.time_basis)}", - f" - {labels['occurred_at']}: {_optional_timestamp(event.occurred_at, labels)}", - f" - {labels['observed_at']}: {_code_span(event.observed_at.isoformat())}", - f" - {labels['event_title']}: {_optional_text(event.title, labels)}", - f" - {labels['event_summary']}: {_optional_text(event.summary, labels)}", - f" - {labels['source_ref']}: {_optional_reference(event.source_ref, labels)}", - f" - {labels['agent']}: {_agent(event, labels)}", - f" - {labels['session']}: {_optional_code(event.session_id, labels)}", - f" - {labels['vcs']}: {_vcs(event, labels)}", - f" - {labels['trust']}: {_code_span(event.trust)}", - f" - {labels['evidence']}:", - )) - if event.evidence_refs: - lines.extend(f" - {_reference(reference)}" for reference in event.evidence_refs) - else: - lines.append(f" - {labels['none']}") - lines.append("") - return lines - - -def _front_matter_lines(report: HandoffReport) -> list[str]: - lines = [ - "---", - "schema: powercontext.handoff-report.v1", - f"locale: {report.locale}", - "format: markdown", + "| Scope | Parent | Status | Handoff |", + "| --- | --- | --- | --- |", ] - if _is_scope_report(report): - lines.append(f"scope_id: {_yaml_string(report.workstreams[0].workstream.scope_id)}") - else: - lines.extend(( - f"project_id: {_yaml_string(report.project.project_id)}", - f"project_key: {_yaml_string(report.project.project_key)}", - f"project_version: {report.project.version}", - )) - lines.extend(( - f"report_kind: {report.report_kind}", - f"selection_digest: {_yaml_string(report.selection_digest or '')}", - f"report_digest: {_yaml_string(report.report_digest or '')}", - f"generated_at: {_yaml_string(report.generated_at.isoformat())}", - f"trust: {report.trust}", - f"selection_consistency: {report.selection_consistency}", - f"activity_cursor: {report.activity_cursor}", - )) - if report.end_selection: - lines.append("end_selection:") - for entry in report.end_selection: - lines.extend(( - f" - scope_id: {_yaml_string(entry.scope_id)}", - f" workstream_revision: {entry.workstream_revision}", - f" status: {entry.status}", - )) - if entry.handoff_ref is None: - lines.append(" handoff_ref: null") - else: - lines.extend(( - " handoff_ref:", - f" family: {_yaml_string(entry.handoff_ref.family)}", - f" artifact_id: {_yaml_string(entry.handoff_ref.artifact_id)}", - f" revision: {entry.handoff_ref.revision}", - )) - else: - lines.append("end_selection: []") - if report.activity_selection: - lines.append("activity_selection:") - lines.extend(f" - {_yaml_string(event_id)}" for event_id in report.activity_selection) - else: - lines.append("activity_selection: []") + for entry in report.scopes: + parent = entry.scope.parent_scope_id or "—" + reference = "—" if entry.handoff is None else _format_address(entry) + lines.append(f"| {_cell(entry.scope.title)} | `{_cell(parent)}` | {entry.status} | {reference} |") + + for entry in report.scopes: + lines.extend(_scope_section(entry)) + lines.extend(["", f"Selection digest: `{report.selection_digest}`", f"Report digest: `{report.report_digest}`", ""]) + return "\n".join(lines) + + +def _scope_section(entry: ScopeHandoffReport) -> list[str]: + lines = ["", f"## {entry.scope.title}", "", entry.scope.summary] + if entry.content is None: + return [*lines, "", "No committed Handoff."] + lines.extend(["", f"Status: **{entry.status}**", "", f"Objective: {entry.content.objective}", "", "Current state:"]) + lines.extend(f"- {statement.text}" for statement in entry.content.state) + if entry.content.next_action is not None: + lines.extend(["", f"Next action: {entry.content.next_action.text}"]) + if entry.content.omissions: + lines.extend(["", "Known omissions:"]) + lines.extend(f"- {omission.text}" for omission in entry.content.omissions) return lines -def _is_scope_report(report: HandoffReport) -> bool: - return report.project.project_id == "unused" and len(report.workstreams) == 1 - - -def _agent(event: ReportActivityEvent, labels: dict[str, str]) -> str: - if event.agent is None: - return labels["none"] - values = tuple(value for value in (event.agent.provider, event.agent.label) if value is not None) - return " / ".join(_code_span(value) for value in values) - - -def _vcs(event: ReportActivityEvent, labels: dict[str, str]) -> str: - if event.vcs_context is None: - return labels["none"] - values = tuple(value for value in (event.vcs_context.branch, event.vcs_context.head_revision) if value is not None) - return " / ".join(_code_span(value) for value in values) - - -def _optional_reference(reference: ExternalReference | None, labels: dict[str, str]) -> str: - return labels["none"] if reference is None else _reference(reference) - - -def _reference(reference: ExternalReference) -> str: - values = [_code_span(reference.kind), _code_span(reference.provider), _code_span(reference.external_id)] - if reference.url is not None: - values.append(_code_span(reference.url)) - return " / ".join(values) - - -def _optional_text(value: str | None, labels: dict[str, str]) -> str: - return labels["none"] if value is None else _text(value) - - -def _optional_code(value: str | None, labels: dict[str, str]) -> str: - return labels["none"] if value is None else _code_span(value) - - -def _optional_timestamp(value: datetime | None, labels: dict[str, str]) -> str: - return labels["none"] if value is None else _code_span(value.isoformat()) - - -def _collapse_lines(value: str) -> str: - flattened = " ".join(value.splitlines()) - return "".join(" " if category(character) == "Cc" else character for character in flattened) - - -def _text(value: str) -> str: - escaped_html = html.escape(_collapse_lines(value), quote=True) - return re.sub(r"([\\`*_{}\[\]()#+\-.!|>~])", r"\\\1", escaped_html) - - -def _code_span(value: str) -> str: - escaped_html = html.escape(_collapse_lines(value), quote=True) - runs = tuple(len(match.group(0)) for match in re.finditer(r"`+", escaped_html)) - delimiter = "`" * (max(runs, default=0) + 1) - if runs: - return f"{delimiter} {escaped_html} {delimiter}" - return f"{delimiter}{escaped_html}{delimiter}" +def _format_address(entry: ScopeHandoffReport) -> str: + handoff = entry.handoff + if handoff is None: + return "—" + artifact = handoff.artifact + return f"`{handoff.scope_id}/{artifact.family}/{artifact.artifact_id}@{artifact.revision}`" -def _yaml_string(value: str) -> str: - return json.dumps(value, ensure_ascii=False) +def _cell(value: str) -> str: + return value.replace("|", "\\|").replace("\n", " ") __all__ = ["render_markdown"] diff --git a/src/powercontext/builtin/handoff_report/report.py b/src/powercontext/builtin/handoff_report/report.py index 7e0d6d662..d43597a97 100644 --- a/src/powercontext/builtin/handoff_report/report.py +++ b/src/powercontext/builtin/handoff_report/report.py @@ -12,371 +12,86 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Canonical output values for the optional Handoff Report feature.""" +"""Canonical Handoff Report values.""" from __future__ import annotations -from datetime import UTC, datetime -from typing import Annotated, Literal, TypeAlias +from datetime import datetime +from typing import Literal -from pydantic import BaseModel, ConfigDict, Field, JsonValue, StrictInt, field_validator, model_validator +from pydantic import BaseModel, ConfigDict, Field, StrictInt, model_validator -from powercontext.artifacts import ArtifactRef -from powercontext.builtin.artifacts.handoff import HandoffContent, HandoffDisposition, HandoffEvidenceCheck -from powercontext.builtin.handoff_report.models import ( - HandoffReportTrust, - ProjectDescriptor, - ReportActivityEvent, - ReportLocale, - ReportSelectionConsistency, - ReportSelectionEntry, - WorkstreamDescriptor, -) -from powercontext.builtin.work import WorkContinuity +from powercontext.artifacts import ArtifactAddress +from powercontext.builtin.artifacts.handoff import HandoffContent +from powercontext.builtin.scope.models import ScopeDescriptor, ScopeSelection -ReportEvidenceChecks: TypeAlias = tuple[HandoffEvidenceCheck, ...] | Literal["not_checked"] -ReportActivityCoverageStatus: TypeAlias = Literal["not_configured", "captured", "unavailable"] -ReportFormat: TypeAlias = Literal["json", "markdown"] -ReportKind: TypeAlias = Literal["handoff", "periodic"] -ReportWorkStatus: TypeAlias = Literal["continuable", "blocked", "complete", "no_handoff"] -ReportActivityStatus: TypeAlias = Literal[ - "no_observed_activity", - "activity_after_handoff", - "activity_without_handoff", - "current_only", - "unknown", -] -ReportReportingStatus: TypeAlias = Literal[ - "reported", - "reported_with_omissions", - "evidence_unavailable", - "no_handoff", -] -ReportHandoffActivityRelation: TypeAlias = Literal[ - "activity_after_handoff", - "no_observed_activity_after_handoff", - "unknown", -] -ReportHandoffBoundaryCoverage: TypeAlias = Literal["unavailable"] -MAX_REPORT_WORKSTREAMS = 100 -MAX_REPORT_ACTIVITIES = 5_000 -MAX_REPORT_HANDOFF_HISTORY = 20 -MAX_REPORT_HANDOFF_HISTORY_EXCERPT_LENGTH = 240 +HandoffReportStatus = Literal["continuable", "blocked", "complete", "no_handoff"] -class _ReportOutputValue(BaseModel): - model_config = ConfigDict(extra="forbid", frozen=True, strict=True) +class _ReportValue(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) -class ReportCoverage(_ReportOutputValue): - """Counts and explicit adapter coverage for one frozen report.""" +class ScopeHandoffReport(_ReportValue): + """The exact latest Handoff projected for one selected Scope.""" - total_included_workstreams: StrictInt = Field(ge=0) - catalog_matched_workstreams: StrictInt = Field(default=0, ge=0) - selected_workstreams: StrictInt = Field(ge=0) - missing_handoff_workstreams: StrictInt = Field(ge=0) - reported_with_omissions: StrictInt = Field(ge=0) - unchecked_evidence_workstreams: StrictInt = Field(default=0, ge=0) - unavailable_evidence_workstreams: StrictInt = Field(ge=0) - activity_without_handoff_workstreams: StrictInt = Field(ge=0) - activity_after_handoff_workstreams: StrictInt = Field(default=0, ge=0) - unknown_time_events: StrictInt = Field(default=0, ge=0) - unassigned_activity_count: StrictInt = Field(ge=0) - unassigned_activity_events: StrictInt = Field(default=0, ge=0) - activity_coverage: ReportActivityCoverageStatus + scope: ScopeDescriptor + status: HandoffReportStatus + handoff: ArtifactAddress | None = None + content: HandoffContent | None = None + @model_validator(mode="after") + def validate_state(self) -> ScopeHandoffReport: + if self.status == "no_handoff": + if self.handoff is not None or self.content is not None: + raise ValueError("no_handoff cannot contain Handoff data") # noqa: TRY003 + return self + if self.handoff is None or self.content is None: + raise ValueError("reported Scope must contain an exact Handoff") # noqa: TRY003 + if self.handoff.scope_id != self.scope.scope_id: + raise ValueError("Handoff address must belong to the reported Scope") # noqa: TRY003 + if self.status != self.content.disposition: + raise ValueError("report status must match Handoff disposition") # noqa: TRY003 + return self -class ReportSummary(_ReportOutputValue): - """Deterministic work-status counts derived from exact Handoff content.""" +class HandoffReportSummary(_ReportValue): continuable_count: StrictInt = Field(ge=0) blocked_count: StrictInt = Field(ge=0) complete_count: StrictInt = Field(ge=0) no_handoff_count: StrictInt = Field(ge=0) -class ReportPeriodComparison(_ReportOutputValue): - """Truthful Activity comparison when Handoff boundary time is unavailable.""" - - previous_start: datetime - previous_end: datetime - current_activity_count: StrictInt = Field(ge=0) - previous_activity_count: StrictInt = Field(ge=0) - activity_delta: StrictInt - handoff_boundary_coverage: ReportHandoffBoundaryCoverage = "unavailable" - - @field_validator("previous_start", "previous_end") - @classmethod - def require_aware_boundary(cls, value: datetime) -> datetime: - if value.tzinfo is None or value.utcoffset() is None: - raise ValueError("period comparison boundaries must be timezone-aware") # noqa: TRY003 - return value.astimezone(UTC) - - @model_validator(mode="after") - def validate_comparison(self) -> ReportPeriodComparison: - if self.previous_start >= self.previous_end: - raise ValueError("previous period start must precede its end") # noqa: TRY003 - if self.activity_delta != self.current_activity_count - self.previous_activity_count: - raise ValueError("activity_delta must match current minus previous Activity count") # noqa: TRY003 - return self - - -class HandoffRevisionSummary(_ReportOutputValue): - """Bounded display metadata for one committed Handoff Revision.""" - - reference: ArtifactRef - objective_excerpt: Annotated[str, Field(max_length=MAX_REPORT_HANDOFF_HISTORY_EXCERPT_LENGTH)] - disposition: HandoffDisposition - next_action_excerpt: Annotated[str | None, Field(max_length=MAX_REPORT_HANDOFF_HISTORY_EXCERPT_LENGTH)] = None - state_count: StrictInt = Field(ge=1) - omission_count: StrictInt = Field(ge=0) - - -class WorkstreamReport(_ReportOutputValue): - """One Workstream projected from an exact Handoff selection.""" - - workstream: WorkstreamDescriptor - continuity: WorkContinuity - handoff_ref: ArtifactRef | None - content: HandoffContent | None - handoff_revision_count: StrictInt = Field(default=0, ge=0) - handoff_history_truncated: bool = False - handoff_history: Annotated[tuple[HandoffRevisionSummary, ...], Field(max_length=MAX_REPORT_HANDOFF_HISTORY)] = () - evidence_checks: ReportEvidenceChecks = "not_checked" - evidence_unavailable: bool = False - activities: Annotated[tuple[ReportActivityEvent, ...], Field(max_length=MAX_REPORT_ACTIVITIES)] = () - work_status: ReportWorkStatus - reporting_status: ReportReportingStatus - activity_status: ReportActivityStatus - handoff_activity_relation: ReportHandoffActivityRelation | None = None - observed_activity_count: StrictInt = Field(default=0, ge=0) - - @model_validator(mode="after") - def validate_handoff_projection(self) -> WorkstreamReport: - if self.continuity.scope_id != self.workstream.scope_id: - raise ValueError("continuity scope must match its Workstream") # noqa: TRY003 - if self.handoff_ref is None: - _validate_no_handoff(self) - else: - _validate_selected_handoff(self) - if self.observed_activity_count != len(self.activities): - raise ValueError("observed_activity_count must match the Workstream activity count") # noqa: TRY003 - return self - - -def _validate_no_handoff(report: WorkstreamReport) -> None: - if report.content is not None: - raise ValueError("a Workstream without Handoff cannot contain Handoff content") # noqa: TRY003 - if report.evidence_checks != "not_checked": - raise ValueError("a Workstream without Handoff cannot contain evidence checks") # noqa: TRY003 - if report.evidence_unavailable: - raise ValueError("a Workstream without Handoff cannot have unavailable evidence checks") # noqa: TRY003 - if report.work_status != "no_handoff": - raise ValueError("a Workstream without Handoff must have no_handoff work status") # noqa: TRY003 - if report.reporting_status != "no_handoff": - raise ValueError("a Workstream without Handoff must report missing Handoff state") # noqa: TRY003 - if report.handoff_revision_count != 0 or report.handoff_history or report.handoff_history_truncated: - raise ValueError("a Workstream without Handoff cannot contain Handoff Revision history") # noqa: TRY003 +class HandoffReport(_ReportValue): + """A read-only projection over all, exact, or subtree Scope selection.""" - -def _validate_selected_handoff(report: WorkstreamReport) -> None: - if report.content is None: - raise ValueError("an exact Handoff selection must contain Handoff content") # noqa: TRY003 - if report.evidence_unavailable and report.evidence_checks != "not_checked": - raise ValueError("an unavailable evidence check must remain not_checked") # noqa: TRY003 - if report.work_status != report.content.disposition: - raise ValueError("work status must match the exact Handoff disposition") # noqa: TRY003 - if report.reporting_status == "no_handoff": - raise ValueError("an exact Handoff selection cannot report missing Handoff state") # noqa: TRY003 - _validate_handoff_history(report) - - -def _validate_handoff_history(report: WorkstreamReport) -> None: - handoff_ref = report.handoff_ref - if handoff_ref is None: - raise ValueError("Handoff Revision history requires an exact selected Handoff") # noqa: TRY003 - if not report.handoff_history: - raise ValueError("an exact Handoff selection must contain Handoff Revision history") # noqa: TRY003 - if report.handoff_history[-1].reference != handoff_ref: - raise ValueError("Handoff Revision history must end at the exact selected Handoff") # noqa: TRY003 - if report.handoff_revision_count < len(report.handoff_history): - raise ValueError("Handoff Revision count cannot be smaller than its projected history") # noqa: TRY003 - if report.handoff_history_truncated != (report.handoff_revision_count > len(report.handoff_history)): - raise ValueError("Handoff Revision truncation must match its projected history") # noqa: TRY003 - references = tuple(item.reference for item in report.handoff_history) - if any( - reference.family != handoff_ref.family or reference.artifact_id != handoff_ref.artifact_id - for reference in references - ): - raise ValueError("Handoff Revision history must belong to the selected Artifact lifecycle") # noqa: TRY003 - if tuple(reference.revision for reference in references) != tuple( - sorted({reference.revision for reference in references}) - ): - raise ValueError("Handoff Revision history must be unique and ascending") # noqa: TRY003 - - -class HandoffReport(_ReportOutputValue): - """Language-neutral canonical report used by renderers and Agents.""" - - schema_version: Literal["powercontext.handoff-report.v1"] = Field( - default="powercontext.handoff-report.v1", + schema_version: Literal["powercontext.handoff-report.v2"] = Field( + default="powercontext.handoff-report.v2", alias="schema", ) - trust: HandoffReportTrust = "untrusted_history" - locale: ReportLocale - format: ReportFormat = "json" - report_kind: ReportKind = "handoff" - renderer_version: str = "canonical-v1" + selection: ScopeSelection + scope_ids: tuple[str, ...] generated_at: datetime - selection_consistency: ReportSelectionConsistency - project: ProjectDescriptor - project_revision: StrictInt = Field(default=1, ge=1) - normalized_filters: dict[str, JsonValue] = Field(default_factory=dict) - normalized_period: dict[str, JsonValue] | None = None - period_comparison: ReportPeriodComparison | None = None - baseline_selection: Annotated[tuple[ReportSelectionEntry, ...], Field(max_length=MAX_REPORT_WORKSTREAMS)] | None = ( - None - ) - end_selection: Annotated[tuple[ReportSelectionEntry, ...], Field(max_length=MAX_REPORT_WORKSTREAMS)] - activity_cursor: StrictInt = Field(ge=0) - activity_selection: Annotated[tuple[str, ...], Field(max_length=MAX_REPORT_ACTIVITIES)] = () - selection_digest: str | None = Field(default=None, pattern=r"sha256:[0-9a-f]{64}") - report_digest: str | None = Field(default=None, pattern=r"sha256:[0-9a-f]{64}") - coverage: ReportCoverage - summary: ReportSummary - unassigned_activity: Annotated[tuple[ReportActivityEvent, ...], Field(max_length=MAX_REPORT_ACTIVITIES)] = () - workstreams: Annotated[tuple[WorkstreamReport, ...], Field(max_length=MAX_REPORT_WORKSTREAMS)] - - @field_validator("generated_at") - @classmethod - def require_utc_generated_at(cls, value: datetime) -> datetime: - if value.tzinfo is None or value.utcoffset() is None: - raise ValueError("generated_at must be timezone-aware") # noqa: TRY003 - return value.astimezone(UTC) + summary: HandoffReportSummary + scopes: tuple[ScopeHandoffReport, ...] + selection_digest: str | None = None + report_digest: str | None = None @model_validator(mode="after") - def validate_selection_projection(self) -> HandoffReport: - _validate_scope_projection(self) - if self.project_revision != self.project.version: - raise ValueError("project_revision must match the projected Project descriptor") # noqa: TRY003 - if self.coverage.selected_workstreams != len(self.workstreams): - raise ValueError("selected_workstreams must match Workstream report count") # noqa: TRY003 - _validate_activity_projection(self) - _validate_coverage_projection(self) - _validate_summary_projection(self) - if self.report_kind == "periodic" and self.normalized_period is None: - raise ValueError("a periodic report must contain a normalized period") # noqa: TRY003 - if self.report_kind == "handoff" and (self.normalized_period is not None or self.period_comparison is not None): - raise ValueError("a point-in-time Handoff report cannot contain period values") # noqa: TRY003 - if self.period_comparison is not None and self.report_kind != "periodic": - raise ValueError("period comparison is only valid for a periodic report") # noqa: TRY003 + def validate_projection(self) -> HandoffReport: + if self.generated_at.tzinfo is None or self.generated_at.utcoffset() is None: + raise ValueError("generated_at must be timezone-aware") # noqa: TRY003 + if self.scope_ids != tuple(entry.scope.scope_id for entry in self.scopes): + raise ValueError("scope_ids must match report entries") # noqa: TRY003 + expected = { + "continuable_count": sum(entry.status == "continuable" for entry in self.scopes), + "blocked_count": sum(entry.status == "blocked" for entry in self.scopes), + "complete_count": sum(entry.status == "complete" for entry in self.scopes), + "no_handoff_count": sum(entry.status == "no_handoff" for entry in self.scopes), + } + if self.summary.model_dump() != expected: + raise ValueError("summary must match report entries") # noqa: TRY003 return self -def _validate_scope_projection(report: HandoffReport) -> None: - selection_scopes = tuple(entry.scope_id for entry in report.end_selection) - report_scopes = tuple(item.workstream.scope_id for item in report.workstreams) - if len(set(selection_scopes)) != len(selection_scopes): - raise ValueError("Handoff Report selection scopes must be unique") # noqa: TRY003 - if len(set(report_scopes)) != len(report_scopes): - raise ValueError("Handoff Report Workstream scopes must be unique") # noqa: TRY003 - if selection_scopes != report_scopes: - raise ValueError("Workstream reports must exactly match selection scope order") # noqa: TRY003 - for entry, item in zip(report.end_selection, report.workstreams, strict=True): - if item.workstream.project_id != report.project.project_id: - raise ValueError("every Workstream report must belong to the Report Project") # noqa: TRY003 - if entry.workstream_revision != item.workstream.version: - raise ValueError("selection Workstream revision must match the projected descriptor") # noqa: TRY003 - if entry.handoff_ref != item.handoff_ref: - raise ValueError("selection Handoff reference must match the Workstream report") # noqa: TRY003 - - -def _validate_activity_projection(report: HandoffReport) -> None: - known_scopes = {item.workstream.scope_id for item in report.workstreams} - assigned_activity: list[ReportActivityEvent] = [] - for item in report.workstreams: - for event in item.activities: - if event.project_id != report.project.project_id: - raise ValueError("every assigned Activity Event must belong to the Report Project") # noqa: TRY003 - if event.scope_id != item.workstream.scope_id: - raise ValueError("assigned Activity Event scope must match its Workstream report") # noqa: TRY003 - assigned_activity.append(event) - for event in report.unassigned_activity: - if event.project_id != report.project.project_id: - raise ValueError("every unassigned Activity Event must belong to the Report Project") # noqa: TRY003 - if event.scope_id in known_scopes: - raise ValueError("Activity Event for a selected scope cannot be unassigned") # noqa: TRY003 - activity_ids = tuple(event.event_id for event in (*assigned_activity, *report.unassigned_activity)) - if len(set(activity_ids)) != len(activity_ids): - raise ValueError("Activity Event ids must be unique within a Handoff Report") # noqa: TRY003 - if report.activity_selection != activity_ids: - raise ValueError("activity_selection must match projected Activity Event order") # noqa: TRY003 - - -def _validate_coverage_projection(report: HandoffReport) -> None: - if report.coverage.total_included_workstreams < len(report.workstreams): - raise ValueError("total_included_workstreams cannot be smaller than the selected report") # noqa: TRY003 - if report.coverage.catalog_matched_workstreams > report.coverage.total_included_workstreams: - raise ValueError("catalog_matched_workstreams cannot exceed total_included_workstreams") # noqa: TRY003 - all_events = tuple(event for item in report.workstreams for event in item.activities) + report.unassigned_activity - expected = { - "missing_handoff_workstreams": sum(item.handoff_ref is None for item in report.workstreams), - "reported_with_omissions": sum( - item.reporting_status == "reported_with_omissions" for item in report.workstreams - ), - "unchecked_evidence_workstreams": sum( - item.handoff_ref is not None and item.evidence_checks == "not_checked" for item in report.workstreams - ), - "unavailable_evidence_workstreams": sum( - item.evidence_unavailable - or ( - item.evidence_checks != "not_checked" - and any(check.status == "unavailable" for check in item.evidence_checks) - ) - for item in report.workstreams - ), - "activity_without_handoff_workstreams": sum( - item.activity_status == "activity_without_handoff" for item in report.workstreams - ), - "activity_after_handoff_workstreams": sum( - item.handoff_activity_relation == "activity_after_handoff" for item in report.workstreams - ), - "unknown_time_events": sum(event.effective_period_time() is None for event in all_events), - "unassigned_activity_count": len(report.unassigned_activity), - "unassigned_activity_events": len(report.unassigned_activity), - } - for field, value in expected.items(): - if getattr(report.coverage, field) != value: - raise ValueError(f"{field} must match the canonical report projection") # noqa: TRY003 - - -def _validate_summary_projection(report: HandoffReport) -> None: - expected = { - "continuable_count": sum(item.work_status == "continuable" for item in report.workstreams), - "blocked_count": sum(item.work_status == "blocked" for item in report.workstreams), - "complete_count": sum(item.work_status == "complete" for item in report.workstreams), - "no_handoff_count": sum(item.work_status == "no_handoff" for item in report.workstreams), - } - for field, value in expected.items(): - if getattr(report.summary, field) != value: - raise ValueError(f"{field} must match the canonical report projection") # noqa: TRY003 - - -__all__ = [ - "MAX_REPORT_ACTIVITIES", - "MAX_REPORT_WORKSTREAMS", - "HandoffReport", - "ReportActivityCoverageStatus", - "ReportActivityStatus", - "ReportCoverage", - "ReportEvidenceChecks", - "ReportFormat", - "ReportHandoffActivityRelation", - "ReportHandoffBoundaryCoverage", - "ReportKind", - "ReportPeriodComparison", - "ReportReportingStatus", - "ReportSummary", - "ReportWorkStatus", - "WorkstreamReport", -] +__all__ = ["HandoffReport", "HandoffReportStatus", "HandoffReportSummary", "ScopeHandoffReport"] diff --git a/src/powercontext/builtin/handoff_report/repository.py b/src/powercontext/builtin/handoff_report/repository.py deleted file mode 100644 index f4eb7a3d7..000000000 --- a/src/powercontext/builtin/handoff_report/repository.py +++ /dev/null @@ -1,149 +0,0 @@ -# Copyright (c) 2026 OceanBase. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Repository boundary for Report-owned activity observations. - -The boundary accepts structural event values and validates their complete payload -against the canonical domain model at the persistence edge. -""" - -from __future__ import annotations - -from collections.abc import Iterable, Mapping -from dataclasses import dataclass -from datetime import datetime -from typing import Literal, Protocol, TypeAlias, runtime_checkable - -from sqlalchemy.ext.asyncio import AsyncConnection - -from powercontext.builtin.handoff_report.errors import HandoffReportError -from powercontext.builtin.handoff_report.models import ReportTimeBasis - -ActivityTimeBasis: TypeAlias = ReportTimeBasis - - -@runtime_checkable -class ActivityEventLike(Protocol): - """Structural input required by an activity repository.""" - - @property - def event_id(self) -> str: ... - - @property - def project_id(self) -> str: ... - - @property - def scope_id(self) -> str | None: ... - - @property - def source(self) -> str: ... - - @property - def source_event_id(self) -> str: ... - - @property - def occurred_at(self) -> datetime | None: ... - - @property - def observed_at(self) -> datetime: ... - - @property - def time_basis(self) -> ActivityTimeBasis: ... - - @property - def trust(self) -> str: ... - - def model_dump(self, *, mode: Literal["json"], by_alias: Literal[True]) -> dict[str, object]: - """Return the complete canonical event payload.""" - - -@dataclass(frozen=True, slots=True) -class StoredActivityEvent: - """One stored observation plus its stable per-Project cursor.""" - - cursor: int - event_id: str - project_id: str - scope_id: str | None - source: str - source_event_id: str - occurred_at: datetime | None - observed_at: datetime - time_basis: ActivityTimeBasis - payload: Mapping[str, object] - - -class ActivityEventConflictError(HandoffReportError, ValueError): - """An idempotency key was reused for a different canonical event.""" - - def __init__(self, source: str, source_event_id: str) -> None: - super().__init__(f"activity event conflict for ({source!r}, {source_event_id!r})") - self.source = source - self.source_event_id = source_event_id - - -class InvalidActivityRepositoryArgumentError(HandoffReportError, ValueError): - """A repository argument is structurally invalid.""" - - def __init__(self, field: str, reason: str) -> None: - super().__init__(f"invalid {field}: {reason}") - - -class InvalidActivityEventError(HandoffReportError, ValueError): - """An activity does not satisfy the persistence boundary.""" - - def __init__(self, field: str, reason: str) -> None: - super().__init__(f"invalid activity event {field}: {reason}") - - -class ActivityEventSerializationError(HandoffReportError, TypeError): - """An activity cannot be converted to its canonical JSON payload.""" - - def __init__(self, operation: str, reason: str) -> None: - super().__init__(f"activity event serialization failed during {operation}: {reason}") - - -class StoredActivityEventError(HandoffReportError, RuntimeError): - """Persisted activity data violates the store schema.""" - - def __init__(self, field: str, reason: str) -> None: - super().__init__(f"invalid stored activity event {field}: {reason}") - - -class ActivityEventRepository(Protocol): - """Persistence operations needed by Activity capture and report assembly.""" - - async def record(self, connection: AsyncConnection, event: ActivityEventLike, /) -> StoredActivityEvent: - """Record an event or return an identical existing capture.""" - - async def list( - self, - connection: AsyncConnection, - project_id: str, - /, - *, - period_start: datetime | None = None, - period_end: datetime | None = None, - sources: Iterable[str] | None = None, - after_cursor: int = 0, - through_cursor: int | None = None, - limit: int | None = 50, - ) -> tuple[StoredActivityEvent, ...]: - """List a stable cursor-ordered Project page.""" - - async def high_watermark(self, connection: AsyncConnection, project_id: str, /) -> int: - """Return the latest allocated cursor without regressing after retention purge.""" - - async def purge(self, connection: AsyncConnection, project_id: str, observed_before: datetime, /) -> int: - """Delete expired Report-owned events and return the deleted row count.""" diff --git a/src/powercontext/builtin/handoff_report/selection.py b/src/powercontext/builtin/handoff_report/selection.py deleted file mode 100644 index 8766ef58f..000000000 --- a/src/powercontext/builtin/handoff_report/selection.py +++ /dev/null @@ -1,91 +0,0 @@ -# Copyright (c) 2026 OceanBase. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Optimistic, read-only selection of exact Handoff heads.""" - -from __future__ import annotations - -from collections.abc import Sequence -from itertools import pairwise - -from powercontext.artifacts import ArtifactRef -from powercontext.builtin.handoff_report.errors import HandoffReportBusyError -from powercontext.builtin.handoff_report.models import ( - ReportSelectionEntry, - WorkstreamDescriptor, -) -from powercontext.builtin.handoff_report.protocols import HandoffReadAdapter - -DEFAULT_HANDOFF_SELECTION_ATTEMPTS = 3 -MAX_HANDOFF_SELECTION_ATTEMPTS = 5 - - -async def select_optimistic_stable_handoffs( - adapter: HandoffReadAdapter, - workstreams: Sequence[WorkstreamDescriptor], - /, - *, - attempts: int = DEFAULT_HANDOFF_SELECTION_ATTEMPTS, -) -> tuple[ReportSelectionEntry, ...]: - """Freeze exact heads after two equal vectors, retrying bounded instability.""" - - _validate_attempts(attempts) - ordered = _ordered_workstreams(workstreams) - for _ in range(attempts): - first = await _read_head_vector(adapter, ordered) - second = await _read_head_vector(adapter, ordered) - if first == second: - return tuple( - ReportSelectionEntry( - scope_id=workstream.scope_id, - workstream_revision=workstream.version, - status="no_handoff" if reference is None else "selected", - handoff_ref=reference, - ) - for workstream, reference in zip(ordered, second, strict=True) - ) - raise HandoffReportBusyError(attempts) - - -async def _read_head_vector( - adapter: HandoffReadAdapter, - workstreams: tuple[WorkstreamDescriptor, ...], -) -> tuple[ArtifactRef | None, ...]: - values: list[ArtifactRef | None] = [] - for workstream in workstreams: - handoff = await adapter.latest(workstream.scope_id) - values.append(None if handoff is None else handoff.as_ref()) - return tuple(values) - - -def _ordered_workstreams(values: Sequence[WorkstreamDescriptor]) -> tuple[WorkstreamDescriptor, ...]: - ordered = tuple(sorted(values, key=lambda value: value.scope_id)) - for previous, current in pairwise(ordered): - if previous.scope_id == current.scope_id: - raise ValueError(f"duplicate Workstream scope_id: {current.scope_id}") # noqa: TRY003 - return ordered - - -def _validate_attempts(value: int) -> None: - if not isinstance(value, int) or isinstance(value, bool) or not 1 <= value <= MAX_HANDOFF_SELECTION_ATTEMPTS: - raise ValueError( # noqa: TRY003 - f"attempts must be between 1 and {MAX_HANDOFF_SELECTION_ATTEMPTS}" - ) - - -__all__ = [ - "DEFAULT_HANDOFF_SELECTION_ATTEMPTS", - "MAX_HANDOFF_SELECTION_ATTEMPTS", - "select_optimistic_stable_handoffs", -] diff --git a/src/powercontext/builtin/handoff_report/service.py b/src/powercontext/builtin/handoff_report/service.py deleted file mode 100644 index 978252dc9..000000000 --- a/src/powercontext/builtin/handoff_report/service.py +++ /dev/null @@ -1,322 +0,0 @@ -# Copyright (c) 2026 OceanBase. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Read-only assembly of canonical Handoff Reports.""" - -from __future__ import annotations - -from collections.abc import Sequence -from datetime import UTC, datetime - -from pydantic import JsonValue - -from powercontext.artifacts import ArtifactRef -from powercontext.builtin.artifacts.handoff import Handoff -from powercontext.builtin.handoff_report.canonical import finalize_digests -from powercontext.builtin.handoff_report.errors import ( - HandoffReportEvidenceCheckUnavailableError, - HandoffReportInconsistentError, -) -from powercontext.builtin.handoff_report.models import ( - ProjectDescriptor, - ReportActivityEvent, - ReportLocale, - WorkstreamDescriptor, - activity_sort_key, -) -from powercontext.builtin.handoff_report.protocols import HandoffReadAdapter, WorkContinuityReadAdapter -from powercontext.builtin.handoff_report.report import ( - MAX_REPORT_ACTIVITIES, - MAX_REPORT_HANDOFF_HISTORY, - MAX_REPORT_HANDOFF_HISTORY_EXCERPT_LENGTH, - MAX_REPORT_WORKSTREAMS, - HandoffReport, - HandoffRevisionSummary, - ReportActivityCoverageStatus, - ReportActivityStatus, - ReportCoverage, - ReportEvidenceChecks, - ReportFormat, - ReportKind, - ReportPeriodComparison, - ReportReportingStatus, - ReportSummary, - WorkstreamReport, -) -from powercontext.builtin.handoff_report.selection import select_optimistic_stable_handoffs -from powercontext.builtin.work import WorkContinuity - - -class HandoffReportService: - """Assemble reports without entering Handoff prepare, commit, or Continue control flow.""" - - def __init__( - self, - handoffs: HandoffReadAdapter, - /, - continuity: WorkContinuityReadAdapter | None = None, - ) -> None: - self._handoffs = handoffs - self._continuity = continuity - - async def generate( - self, - project: ProjectDescriptor, - workstreams: Sequence[WorkstreamDescriptor], - /, - *, - locale: ReportLocale | None = None, - include_evidence_checks: bool = True, - activities: Sequence[ReportActivityEvent] = (), - activity_cursor: int = 0, - activity_coverage: ReportActivityCoverageStatus = "not_configured", - generated_at: datetime | None = None, - selection_attempts: int = 3, - report_format: ReportFormat = "json", - report_kind: ReportKind = "handoff", - normalized_filters: dict[str, JsonValue] | None = None, - normalized_period: dict[str, JsonValue] | None = None, - period_comparison: ReportPeriodComparison | None = None, - ) -> HandoffReport: - """Freeze exact heads and project only exact Handoffs plus explicit Activity Events.""" - - ordered_workstreams = _validate_inputs(project, workstreams, activities, activity_cursor) - selection = await select_optimistic_stable_handoffs( - self._handoffs, - ordered_workstreams, - attempts=selection_attempts, - ) - activities_by_scope, unassigned = _group_activities(activities, ordered_workstreams) - projected: list[WorkstreamReport] = [] - for descriptor, entry in zip(ordered_workstreams, selection, strict=True): - scoped_activity = activities_by_scope.get(descriptor.scope_id, ()) - continuity = ( - WorkContinuity(scope_id=descriptor.scope_id) - if self._continuity is None - else await self._continuity.get(descriptor.scope_id, entry.handoff_ref) - ) - if entry.handoff_ref is None: - projected.append( - WorkstreamReport( - workstream=descriptor, - continuity=continuity, - handoff_ref=None, - content=None, - activities=scoped_activity, - work_status="no_handoff", - reporting_status="no_handoff", - activity_status=("activity_without_handoff" if scoped_activity else "no_observed_activity"), - handoff_activity_relation=None, - observed_activity_count=len(scoped_activity), - ) - ) - continue - - handoff = await self._handoffs.get(descriptor.scope_id, entry.handoff_ref) - if handoff.as_ref() != entry.handoff_ref: - raise HandoffReportInconsistentError(descriptor.scope_id) - revision_count, revision_history = await self._revision_history( - descriptor.scope_id, - entry.handoff_ref, - ) - checks: ReportEvidenceChecks = "not_checked" - evidence_unavailable = False - if include_evidence_checks: - try: - checks = await self._handoffs.check_evidence(descriptor.scope_id, entry.handoff_ref) - except HandoffReportEvidenceCheckUnavailableError: - evidence_unavailable = True - projected.append( - WorkstreamReport( - workstream=descriptor, - continuity=continuity, - handoff_ref=entry.handoff_ref, - content=handoff.content, - handoff_revision_count=revision_count, - handoff_history_truncated=revision_count > len(revision_history), - handoff_history=revision_history, - evidence_checks=checks, - evidence_unavailable=evidence_unavailable, - activities=scoped_activity, - work_status=handoff.content.disposition, - reporting_status=_reporting_status(handoff.content.omissions, checks, evidence_unavailable), - activity_status=_activity_status(scoped_activity), - handoff_activity_relation=(None if not scoped_activity else "unknown"), - observed_activity_count=len(scoped_activity), - ) - ) - - reports = tuple(projected) - activity_selection = tuple(event.event_id for report in reports for event in report.activities) + tuple( - event.event_id for event in unassigned - ) - report = HandoffReport( - locale=project.default_locale if locale is None else locale, - format=report_format, - report_kind=report_kind, - renderer_version="canonical-v1" if report_format == "json" else "markdown-v1", - generated_at=datetime.now(UTC) if generated_at is None else generated_at, - selection_consistency="optimistic_stable", - project=project, - project_revision=project.version, - normalized_filters={} if normalized_filters is None else normalized_filters, - normalized_period=normalized_period, - period_comparison=period_comparison, - end_selection=selection, - activity_cursor=activity_cursor, - activity_selection=activity_selection, - coverage=ReportCoverage( - total_included_workstreams=len(ordered_workstreams), - catalog_matched_workstreams=len(ordered_workstreams), - selected_workstreams=len(reports), - missing_handoff_workstreams=sum(item.handoff_ref is None for item in reports), - reported_with_omissions=sum(item.reporting_status == "reported_with_omissions" for item in reports), - unchecked_evidence_workstreams=sum( - item.handoff_ref is not None and item.evidence_checks == "not_checked" for item in reports - ), - unavailable_evidence_workstreams=sum( - item.reporting_status == "evidence_unavailable" for item in reports - ), - activity_without_handoff_workstreams=sum( - item.activity_status == "activity_without_handoff" for item in reports - ), - activity_after_handoff_workstreams=sum( - item.handoff_activity_relation == "activity_after_handoff" for item in reports - ), - unknown_time_events=sum( - event.effective_period_time() is None for item in reports for event in item.activities - ) - + sum(event.effective_period_time() is None for event in unassigned), - unassigned_activity_count=len(unassigned), - unassigned_activity_events=len(unassigned), - activity_coverage=activity_coverage, - ), - summary=ReportSummary( - continuable_count=sum(item.work_status == "continuable" for item in reports), - blocked_count=sum(item.work_status == "blocked" for item in reports), - complete_count=sum(item.work_status == "complete" for item in reports), - no_handoff_count=sum(item.work_status == "no_handoff" for item in reports), - ), - unassigned_activity=unassigned, - workstreams=reports, - ) - return finalize_digests(report) - - async def _revision_history( - self, - scope_id: str, - selected_ref: ArtifactRef, - /, - ) -> tuple[int, tuple[HandoffRevisionSummary, ...]]: - revisions = await self._handoffs.revisions(scope_id) - lifecycle = tuple( - handoff - for handoff in revisions - if handoff.as_ref().family == selected_ref.family and handoff.artifact_id == selected_ref.artifact_id - ) - references = tuple(handoff.as_ref() for handoff in lifecycle) - if tuple(reference.revision for reference in references) != tuple( - sorted({reference.revision for reference in references}) - ): - raise HandoffReportInconsistentError(scope_id) - try: - selected_index = references.index(selected_ref) - except ValueError as error: - raise HandoffReportInconsistentError(scope_id) from error - selected_history = lifecycle[: selected_index + 1] - recent_history = selected_history[-MAX_REPORT_HANDOFF_HISTORY:] - return len(selected_history), tuple(_revision_summary(handoff) for handoff in recent_history) - - -def _validate_inputs( - project: ProjectDescriptor, - workstreams: Sequence[WorkstreamDescriptor], - activities: Sequence[ReportActivityEvent], - activity_cursor: int, -) -> tuple[WorkstreamDescriptor, ...]: - if not isinstance(activity_cursor, int) or isinstance(activity_cursor, bool) or activity_cursor < 0: - raise ValueError("activity_cursor must be a non-negative integer") # noqa: TRY003 - if len(workstreams) > MAX_REPORT_WORKSTREAMS: - raise ValueError(f"a Handoff Report selects at most {MAX_REPORT_WORKSTREAMS} Workstreams") # noqa: TRY003 - if len(activities) > MAX_REPORT_ACTIVITIES: - raise ValueError(f"a Handoff Report selects at most {MAX_REPORT_ACTIVITIES} Activity Events") # noqa: TRY003 - ordered = tuple(sorted(workstreams, key=lambda value: value.scope_id)) - for workstream in ordered: - if workstream.project_id != project.project_id: - raise ValueError("every Workstream must belong to the requested Project") # noqa: TRY003 - if len({workstream.scope_id for workstream in ordered}) != len(ordered): - raise ValueError("Workstream scope_id values must be unique") # noqa: TRY003 - for event in activities: - if event.project_id != project.project_id: - raise ValueError("every Activity Event must belong to the requested Project") # noqa: TRY003 - return ordered - - -def _revision_summary(handoff: Handoff, /) -> HandoffRevisionSummary: - next_action = handoff.content.next_action - return HandoffRevisionSummary( - reference=handoff.as_ref(), - objective_excerpt=_history_excerpt(handoff.content.objective), - disposition=handoff.content.disposition, - next_action_excerpt=None if next_action is None else _history_excerpt(next_action.text), - state_count=len(handoff.content.state), - omission_count=len(handoff.content.omissions), - ) - - -def _history_excerpt(value: str, /) -> str: - compact = " ".join(value.split()) - if len(compact) <= MAX_REPORT_HANDOFF_HISTORY_EXCERPT_LENGTH: - return compact - return compact[: MAX_REPORT_HANDOFF_HISTORY_EXCERPT_LENGTH - 1].rstrip() + "…" - - -def _group_activities( - activities: Sequence[ReportActivityEvent], - workstreams: Sequence[WorkstreamDescriptor], -) -> tuple[dict[str, tuple[ReportActivityEvent, ...]], tuple[ReportActivityEvent, ...]]: - known_scopes = {workstream.scope_id for workstream in workstreams} - grouped: dict[str, list[ReportActivityEvent]] = {} - unassigned: list[ReportActivityEvent] = [] - for event in sorted(activities, key=activity_sort_key): - if event.scope_id is None or event.scope_id not in known_scopes: - unassigned.append(event) - else: - grouped.setdefault(event.scope_id, []).append(event) - return {scope_id: tuple(values) for scope_id, values in grouped.items()}, tuple(unassigned) - - -def _reporting_status( - omissions: tuple[object, ...], - checks: ReportEvidenceChecks, - evidence_unavailable: bool, -) -> ReportReportingStatus: - if evidence_unavailable or (checks != "not_checked" and any(check.status == "unavailable" for check in checks)): - return "evidence_unavailable" - if omissions: - return "reported_with_omissions" - return "reported" - - -def _activity_status(events: tuple[ReportActivityEvent, ...]) -> ReportActivityStatus: - if not events: - return "no_observed_activity" - if all(event.time_basis == "current_only" for event in events): - return "current_only" - # Existing Handoff has no authoritative commit timestamp, so the first slice - # must not claim that an observation happened after it. - return "unknown" - - -__all__ = ["HandoffReportService"] diff --git a/src/powercontext/builtin/handoff_report/sqlite.py b/src/powercontext/builtin/handoff_report/sqlite.py deleted file mode 100644 index 137ec61ca..000000000 --- a/src/powercontext/builtin/handoff_report/sqlite.py +++ /dev/null @@ -1,454 +0,0 @@ -# Copyright (c) 2026 OceanBase. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""SQLite-compatible relational Activity Event Store owned by Handoff Report.""" - -from __future__ import annotations - -import asyncio -import json -from collections.abc import Iterable, Mapping -from datetime import UTC, datetime -from typing import Any, cast - -from pydantic import ValidationError -from sqlalchemy import ( - BigInteger, - Column, - Index, - MetaData, - Table, - Text, - UniqueConstraint, - delete, - insert, - select, - update, -) -from sqlalchemy.dialects.mysql import insert as mysql_insert -from sqlalchemy.dialects.sqlite import insert as sqlite_insert -from sqlalchemy.exc import IntegrityError -from sqlalchemy.ext.asyncio import AsyncConnection - -from powercontext.builtin.handoff_report.catalog_store import HANDOFF_REPORT_CATALOG_TABLES -from powercontext.builtin.handoff_report.models import MAX_REPORT_ID_LENGTH, ReportActivityEvent -from powercontext.builtin.handoff_report.report import MAX_REPORT_ACTIVITIES -from powercontext.builtin.handoff_report.repository import ( - ActivityEventConflictError, - ActivityEventLike, - ActivityEventSerializationError, - ActivityTimeBasis, - InvalidActivityEventError, - InvalidActivityRepositoryArgumentError, - StoredActivityEvent, - StoredActivityEventError, -) -from powercontext.builtin.handoff_report.workspace_store import HANDOFF_REPORT_WORKSPACE_TABLES -from powercontext.builtin.persistence.tables import identity_string -from powercontext.limits import MAX_SCOPE_ID_LENGTH - -HANDOFF_REPORT_METADATA = MetaData() - -HANDOFF_REPORT_ACTIVITY_HEADS_TABLE = Table( - "pc_handoff_report_activity_heads", - HANDOFF_REPORT_METADATA, - Column("project_id", identity_string(MAX_REPORT_ID_LENGTH), primary_key=True), - Column("cursor", BigInteger, nullable=False), -) - -HANDOFF_REPORT_ACTIVITIES_TABLE = Table( - "pc_handoff_report_activities", - HANDOFF_REPORT_METADATA, - Column("project_id", identity_string(MAX_REPORT_ID_LENGTH), primary_key=True), - Column("cursor", BigInteger, primary_key=True), - Column("event_id", identity_string(MAX_REPORT_ID_LENGTH), nullable=False, unique=True), - Column("scope_id", identity_string(MAX_SCOPE_ID_LENGTH)), - Column("source", identity_string(64), nullable=False), - Column("source_event_id", identity_string(MAX_REPORT_ID_LENGTH), nullable=False), - Column("occurred_at", identity_string(32)), - Column("observed_at", identity_string(32), nullable=False), - Column("period_at", identity_string(32)), - Column("time_basis", identity_string(32), nullable=False), - Column("payload", Text, nullable=False), - UniqueConstraint("source", "source_event_id", name="uq_pc_handoff_report_activities_source_event"), -) -Index( - "ix_pc_handoff_report_activities_project_period", - HANDOFF_REPORT_ACTIVITIES_TABLE.c.project_id, - HANDOFF_REPORT_ACTIVITIES_TABLE.c.period_at, - HANDOFF_REPORT_ACTIVITIES_TABLE.c.cursor, -) -Index( - "ix_pc_handoff_report_activities_project_source_cursor", - HANDOFF_REPORT_ACTIVITIES_TABLE.c.project_id, - HANDOFF_REPORT_ACTIVITIES_TABLE.c.source, - HANDOFF_REPORT_ACTIVITIES_TABLE.c.cursor, -) - -HANDOFF_REPORT_TABLES = ( - *HANDOFF_REPORT_CATALOG_TABLES, - *HANDOFF_REPORT_WORKSPACE_TABLES, - HANDOFF_REPORT_ACTIVITY_HEADS_TABLE, - HANDOFF_REPORT_ACTIVITIES_TABLE, -) - -_TIME_BASES: frozenset[str] = frozenset({"source_reported", "host_observed", "first_seen", "current_only", "unknown"}) -_MAX_ACTIVITY_LIST_LIMIT = MAX_REPORT_ACTIVITIES + 1 - - -class SQLiteActivityEventRepository: - """Persist activity events without reading or writing Handoff/Core tables. - - The coroutine lock serializes ``record`` calls only through this repository - instance. Multiple instances or processes still rely on SQLite's writer lock - and configured busy timeout; deployments must handle lock timeout failures. - """ - - def __init__(self) -> None: - self._record_lock = asyncio.Lock() - - async def record(self, connection: AsyncConnection, event: ActivityEventLike, /) -> StoredActivityEvent: - payload, indexed = _canonical_event(event) - async with self._record_lock: - return await self._record_locked(connection, payload, indexed) - - async def _record_locked( - self, - connection: AsyncConnection, - payload: str, - indexed: Mapping[str, Any], - ) -> StoredActivityEvent: - # Take SQLite's writer reservation before the idempotency read. This - # avoids two DEFERRED/WAL transactions reading the same old snapshot - # and then both attempting to upgrade it to a writer. - await _reserve_project_writer(connection, str(indexed["project_id"])) - existing = await self._find_by_source_identity(connection, indexed["source"], indexed["source_event_id"]) - if existing is not None: - return _idempotent_result(existing, payload) - - cursor = await _allocate_cursor(connection, indexed["project_id"]) - try: - await connection.execute( - insert(HANDOFF_REPORT_ACTIVITIES_TABLE).values( - project_id=indexed["project_id"], - cursor=cursor, - event_id=indexed["event_id"], - scope_id=indexed["scope_id"], - source=indexed["source"], - source_event_id=indexed["source_event_id"], - occurred_at=indexed["occurred_at"], - observed_at=indexed["observed_at"], - period_at=indexed["period_at"], - time_basis=indexed["time_basis"], - payload=payload, - ) - ) - except IntegrityError: - existing = await self._find_by_source_identity(connection, indexed["source"], indexed["source_event_id"]) - if existing is None: - raise - return _idempotent_result(existing, payload) - - row = await self._find_by_project_cursor(connection, indexed["project_id"], cursor) - if row is None: # pragma: no cover - a successful insert must be visible in its transaction - raise StoredActivityEventError("event", "inserted row is not readable") - return _decode_row(row) - - async def list( - self, - connection: AsyncConnection, - project_id: str, - /, - *, - period_start: datetime | None = None, - period_end: datetime | None = None, - sources: Iterable[str] | None = None, - after_cursor: int = 0, - through_cursor: int | None = None, - limit: int | None = 50, - ) -> tuple[StoredActivityEvent, ...]: - project_id, start, end, normalized_sources = _validate_list_arguments( - project_id, - period_start=period_start, - period_end=period_end, - sources=sources, - after_cursor=after_cursor, - through_cursor=through_cursor, - limit=limit, - ) - if normalized_sources == (): - return () - statement = _activity_list_statement( - project_id, - start=start, - end=end, - sources=normalized_sources, - after_cursor=after_cursor, - through_cursor=through_cursor, - limit=limit, - ) - rows = (await connection.execute(statement)).mappings() - return tuple(_decode_row(row) for row in rows) - - async def high_watermark(self, connection: AsyncConnection, project_id: str, /) -> int: - project_id = _identifier("project_id", project_id, maximum=MAX_REPORT_ID_LENGTH) - value = await connection.scalar( - select(HANDOFF_REPORT_ACTIVITY_HEADS_TABLE.c.cursor).where( - HANDOFF_REPORT_ACTIVITY_HEADS_TABLE.c.project_id == project_id - ) - ) - return 0 if value is None else int(value) - - async def purge(self, connection: AsyncConnection, project_id: str, observed_before: datetime, /) -> int: - project_id = _identifier("project_id", project_id, maximum=MAX_REPORT_ID_LENGTH) - boundary = _utc_text("observed_before", observed_before) - result = await connection.execute( - delete(HANDOFF_REPORT_ACTIVITIES_TABLE).where( - HANDOFF_REPORT_ACTIVITIES_TABLE.c.project_id == project_id, - HANDOFF_REPORT_ACTIVITIES_TABLE.c.observed_at < boundary, - ) - ) - return int(result.rowcount or 0) - - async def _find_by_source_identity( - self, connection: AsyncConnection, source: str, source_event_id: str - ) -> Mapping[Any, Any] | None: - return ( - ( - await connection.execute( - select(HANDOFF_REPORT_ACTIVITIES_TABLE).where( - HANDOFF_REPORT_ACTIVITIES_TABLE.c.source == source, - HANDOFF_REPORT_ACTIVITIES_TABLE.c.source_event_id == source_event_id, - ) - ) - ) - .mappings() - .one_or_none() - ) - - async def _find_by_project_cursor( - self, connection: AsyncConnection, project_id: str, cursor: int - ) -> Mapping[Any, Any] | None: - return ( - ( - await connection.execute( - select(HANDOFF_REPORT_ACTIVITIES_TABLE).where( - HANDOFF_REPORT_ACTIVITIES_TABLE.c.project_id == project_id, - HANDOFF_REPORT_ACTIVITIES_TABLE.c.cursor == cursor, - ) - ) - ) - .mappings() - .one_or_none() - ) - - -def _validate_list_arguments( - project_id: str, - *, - period_start: datetime | None, - period_end: datetime | None, - sources: Iterable[str] | None, - after_cursor: int, - through_cursor: int | None, - limit: int | None, -) -> tuple[str, str | None, str | None, tuple[str, ...] | None]: - project_id = _identifier("project_id", project_id, maximum=MAX_REPORT_ID_LENGTH) - _require_integer("after_cursor", after_cursor, minimum=0) - if through_cursor is not None: - _require_integer("through_cursor", through_cursor, minimum=0) - if limit is not None: - _require_integer("limit", limit, minimum=1, maximum=_MAX_ACTIVITY_LIST_LIMIT) - start = None if period_start is None else _utc_text("period_start", period_start) - end = None if period_end is None else _utc_text("period_end", period_end) - if start is not None and end is not None and start >= end: - raise InvalidActivityRepositoryArgumentError("period", "start must be before end") - normalized_sources = None - if sources is not None: - normalized_sources = tuple(dict.fromkeys(_identifier("source", item, maximum=64) for item in sources)) - return project_id, start, end, normalized_sources - - -def _activity_list_statement( - project_id: str, - *, - start: str | None, - end: str | None, - sources: tuple[str, ...] | None, - after_cursor: int, - through_cursor: int | None, - limit: int | None, -): - statement = select(HANDOFF_REPORT_ACTIVITIES_TABLE).where( - HANDOFF_REPORT_ACTIVITIES_TABLE.c.project_id == project_id, - HANDOFF_REPORT_ACTIVITIES_TABLE.c.cursor > after_cursor, - ) - if through_cursor is not None: - statement = statement.where(HANDOFF_REPORT_ACTIVITIES_TABLE.c.cursor <= through_cursor) - if start is not None: - statement = statement.where(HANDOFF_REPORT_ACTIVITIES_TABLE.c.period_at >= start) - if end is not None: - statement = statement.where(HANDOFF_REPORT_ACTIVITIES_TABLE.c.period_at < end) - if sources is not None: - statement = statement.where(HANDOFF_REPORT_ACTIVITIES_TABLE.c.source.in_(sources)) - statement = statement.order_by(HANDOFF_REPORT_ACTIVITIES_TABLE.c.cursor) - return statement if limit is None else statement.limit(limit) - - -async def _reserve_project_writer(connection: AsyncConnection, project_id: str) -> None: - dialect = connection.dialect.name - if dialect == "sqlite": - statement = sqlite_insert(HANDOFF_REPORT_ACTIVITY_HEADS_TABLE).values(project_id=project_id, cursor=0) - statement = statement.on_conflict_do_update( - index_elements=(HANDOFF_REPORT_ACTIVITY_HEADS_TABLE.c.project_id,), - set_={"cursor": HANDOFF_REPORT_ACTIVITY_HEADS_TABLE.c.cursor}, - ) - elif dialect == "mysql": - statement = mysql_insert(HANDOFF_REPORT_ACTIVITY_HEADS_TABLE).values(project_id=project_id, cursor=0) - statement = statement.on_duplicate_key_update(cursor=HANDOFF_REPORT_ACTIVITY_HEADS_TABLE.c.cursor) - else: - raise InvalidActivityRepositoryArgumentError("dialect", f"unsupported database dialect: {dialect}") - await connection.execute(statement) - - -async def _allocate_cursor(connection: AsyncConnection, project_id: str) -> int: - result = await connection.execute( - update(HANDOFF_REPORT_ACTIVITY_HEADS_TABLE) - .where(HANDOFF_REPORT_ACTIVITY_HEADS_TABLE.c.project_id == project_id) - .values(cursor=HANDOFF_REPORT_ACTIVITY_HEADS_TABLE.c.cursor + 1) - ) - if result.rowcount != 1: - raise StoredActivityEventError("cursor", "Project allocator is missing") - value = await connection.scalar( - select(HANDOFF_REPORT_ACTIVITY_HEADS_TABLE.c.cursor).where( - HANDOFF_REPORT_ACTIVITY_HEADS_TABLE.c.project_id == project_id - ) - ) - if not isinstance(value, int) or isinstance(value, bool) or value < 1: - raise StoredActivityEventError("cursor", "must be a positive integer") - return value - - -def _canonical_event(event: ActivityEventLike) -> tuple[str, dict[str, Any]]: - try: - dumped = event.model_dump(mode="json", by_alias=True) - except (AttributeError, TypeError, ValueError) as error: - raise ActivityEventSerializationError("model_dump", "JSON mode failed") from error - if not isinstance(dumped, dict): - raise ActivityEventSerializationError("model_dump", "result must be a dictionary") - try: - candidate_payload = json.dumps( - dumped, - ensure_ascii=False, - sort_keys=True, - separators=(",", ":"), - allow_nan=False, - ) - except (TypeError, ValueError) as error: - raise ActivityEventSerializationError("JSON encoding", "not serializable") from error # noqa: TRY003 - try: - validated = ReportActivityEvent.model_validate_json(candidate_payload) - except ValidationError as error: - raise InvalidActivityEventError("payload", "does not match ReportActivityEvent") from error - - canonical = validated.model_dump(mode="json", by_alias=True) - payload = json.dumps(canonical, ensure_ascii=False, sort_keys=True, separators=(",", ":"), allow_nan=False) - event_id = _identifier("event_id", validated.event_id, maximum=MAX_REPORT_ID_LENGTH) - project_id = _identifier("project_id", validated.project_id, maximum=MAX_REPORT_ID_LENGTH) - scope_id = ( - None if validated.scope_id is None else _identifier("scope_id", validated.scope_id, maximum=MAX_SCOPE_ID_LENGTH) - ) - source = _identifier("source", validated.source, maximum=64) - source_event_id = _identifier("source_event_id", validated.source_event_id, maximum=MAX_REPORT_ID_LENGTH) - observed_at = _utc_text("observed_at", validated.observed_at) - occurred_at = None if validated.occurred_at is None else _utc_text("occurred_at", validated.occurred_at) - period_time = validated.effective_period_time() - period_at = None if period_time is None else _utc_text("period_at", period_time) - return payload, { - "event_id": event_id, - "project_id": project_id, - "scope_id": scope_id, - "source": source, - "source_event_id": source_event_id, - "occurred_at": occurred_at, - "observed_at": observed_at, - "period_at": period_at, - "time_basis": validated.time_basis, - } - - -def _idempotent_result(row: Mapping[Any, Any], payload: str) -> StoredActivityEvent: - if _semantic_payload(str(row["payload"])) != _semantic_payload(payload): - raise ActivityEventConflictError(str(row["source"]), str(row["source_event_id"])) - return _decode_row(row) - - -def _semantic_payload(payload: str) -> str: - try: - value = json.loads(payload) - except (TypeError, ValueError) as error: - raise StoredActivityEventError("payload", "must be valid JSON") from error - if not isinstance(value, dict): - raise StoredActivityEventError("payload", "must be a JSON object") - value.pop("event_id", None) - value.pop("observed_at", None) - return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"), allow_nan=False) - - -def _decode_row(row: Mapping[Any, Any]) -> StoredActivityEvent: - time_basis = str(row["time_basis"]) - if time_basis not in _TIME_BASES: - raise StoredActivityEventError("time_basis", "unsupported value") - payload = json.loads(str(row["payload"])) - if not isinstance(payload, dict): - raise StoredActivityEventError("payload", "must be a JSON object") - return StoredActivityEvent( - cursor=int(row["cursor"]), - event_id=str(row["event_id"]), - project_id=str(row["project_id"]), - scope_id=None if row["scope_id"] is None else str(row["scope_id"]), - source=str(row["source"]), - source_event_id=str(row["source_event_id"]), - occurred_at=None if row["occurred_at"] is None else _parse_utc(str(row["occurred_at"])), - observed_at=_parse_utc(str(row["observed_at"])), - time_basis=cast(ActivityTimeBasis, time_basis), - payload=payload, - ) - - -def _identifier(field: str, value: object, *, maximum: int) -> str: - if not isinstance(value, str) or not value or value != value.strip(): - raise InvalidActivityRepositoryArgumentError(field, "must be a non-empty trimmed string") - if len(value) > maximum: - raise InvalidActivityRepositoryArgumentError(field, f"must not exceed {maximum} characters") - return value - - -def _require_integer(field: str, value: object, *, minimum: int, maximum: int | None = None) -> None: - if not isinstance(value, int) or isinstance(value, bool): - raise InvalidActivityRepositoryArgumentError(field, "must be an integer") - if value < minimum: - raise InvalidActivityRepositoryArgumentError(field, f"must be at least {minimum}") - if maximum is not None and value > maximum: - raise InvalidActivityRepositoryArgumentError(field, f"must not exceed {maximum}") - - -def _utc_text(field: str, value: datetime) -> str: - if not isinstance(value, datetime) or value.tzinfo is None or value.utcoffset() is None: - raise InvalidActivityRepositoryArgumentError(field, "must be timezone-aware") - return value.astimezone(UTC).isoformat(timespec="microseconds").replace("+00:00", "Z") - - -def _parse_utc(value: str) -> datetime: - return datetime.fromisoformat(value.replace("Z", "+00:00")).astimezone(UTC) diff --git a/src/powercontext/builtin/handoff_report/workspace.py b/src/powercontext/builtin/handoff_report/workspace.py deleted file mode 100644 index 04e92a5af..000000000 --- a/src/powercontext/builtin/handoff_report/workspace.py +++ /dev/null @@ -1,101 +0,0 @@ -# Copyright (c) 2026 OceanBase. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Explicit WorkspaceBinding application operations.""" - -from __future__ import annotations - -from datetime import UTC, datetime - -from sqlalchemy.ext.asyncio import AsyncConnection - -from powercontext.builtin.handoff_report.catalog import HandoffReportCatalog -from powercontext.builtin.handoff_report.errors import ( - WorkspaceBindingConflictError, - WorkspaceBindingNotFoundError, -) -from powercontext.builtin.handoff_report.models import RepositoryRef, WorkspaceBinding, normalize_repository_ref -from powercontext.builtin.handoff_report.workspace_store import WorkspaceBindingRepository - - -class WorkspaceBindingService: - """Attach and detach a workspace only after exact Project selection.""" - - def __init__( - self, - catalog: HandoffReportCatalog | None = None, - repository: WorkspaceBindingRepository | None = None, - ) -> None: - self._catalog = HandoffReportCatalog() if catalog is None else catalog - self._repository = WorkspaceBindingRepository() if repository is None else repository - - async def get(self, connection: AsyncConnection, workspace_instance_id: str, /) -> WorkspaceBinding: - return await self._repository.get_confirmed(connection, workspace_instance_id) - - async def attach( - self, - connection: AsyncConnection, - *, - workspace_instance_id: str, - project_id: str, - repository_ref: RepositoryRef, - expected_version: int | None, - confirmed_at: datetime | None = None, - ) -> WorkspaceBinding: - await self._catalog.get_project(connection, project_id) - normalized_ref = normalize_repository_ref(repository_ref) - current = None - if expected_version is not None: - try: - current = await self._repository.get(connection, workspace_instance_id) - except WorkspaceBindingNotFoundError as error: - raise WorkspaceBindingConflictError( - workspace_instance_id, - expected_version, - None, - detail="workspace binding record is missing", - ) from error - version = 1 if expected_version is None else expected_version + 1 - binding = WorkspaceBinding( - workspace_instance_id=workspace_instance_id, - project_id=project_id, - repository_ref=normalized_ref, - state="confirmed", - confirmed_at=datetime.now(UTC) if confirmed_at is None else confirmed_at, - version=version, - ) - if current is not None and current.state == "confirmed" and current.project_id != project_id: - raise WorkspaceBindingConflictError( - workspace_instance_id, - expected_version, - current.version, - detail="detach the confirmed binding before attaching another Project", - ) - return await self._repository.attach(connection, binding, expected_version) - - async def detach( - self, - connection: AsyncConnection, - workspace_instance_id: str, - expected_version: int, - ) -> WorkspaceBinding: - return await self._repository.detach(connection, workspace_instance_id, expected_version) - - async def get_record(self, connection: AsyncConnection, workspace_instance_id: str, /) -> WorkspaceBinding: - """Read a detached record for explicit re-attach workflows.""" - - return await self._repository.get(connection, workspace_instance_id) - - -__all__ = ["WorkspaceBindingService"] diff --git a/src/powercontext/builtin/handoff_report/workspace_store.py b/src/powercontext/builtin/handoff_report/workspace_store.py deleted file mode 100644 index 9c8f466fc..000000000 --- a/src/powercontext/builtin/handoff_report/workspace_store.py +++ /dev/null @@ -1,322 +0,0 @@ -# Copyright (c) 2026 OceanBase. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Report-owned persistence for explicit WorkspaceBinding CAS transitions.""" - -from __future__ import annotations - -import json -from collections.abc import Mapping -from datetime import UTC, datetime -from typing import Any - -from pydantic import ValidationError -from sqlalchemy import CheckConstraint, Column, Integer, MetaData, Table, Text, insert, select, update -from sqlalchemy.exc import IntegrityError -from sqlalchemy.ext.asyncio import AsyncConnection - -from powercontext.builtin.handoff_report.errors import ( - HandoffReportCatalogArgumentError, - InvalidStoredCatalogError, - WorkspaceBindingConflictError, - WorkspaceBindingNotFoundError, -) -from powercontext.builtin.handoff_report.models import ( - MAX_REPORT_ID_LENGTH, - MAX_REPORT_NORMALIZED_REMOTE_LENGTH, - MAX_REPORT_REPOSITORY_ID_LENGTH, - MAX_REPORT_SUBPATH_LENGTH, - MAX_WORKSPACE_INSTANCE_ID_LENGTH, - WorkspaceBinding, -) -from powercontext.builtin.persistence.tables import identity_string - -HANDOFF_REPORT_WORKSPACE_METADATA = MetaData() - -HANDOFF_REPORT_WORKSPACE_BINDINGS_TABLE = Table( - "pc_handoff_report_workspace_bindings", - HANDOFF_REPORT_WORKSPACE_METADATA, - Column("workspace_instance_id", identity_string(MAX_WORKSPACE_INSTANCE_ID_LENGTH), primary_key=True), - Column("project_id", identity_string(MAX_REPORT_ID_LENGTH), nullable=False), - Column("provider", identity_string(32), nullable=False), - Column("repository_id", identity_string(MAX_REPORT_REPOSITORY_ID_LENGTH)), - Column("normalized_remote", identity_string(MAX_REPORT_NORMALIZED_REMOTE_LENGTH)), - Column("subpath", identity_string(MAX_REPORT_SUBPATH_LENGTH)), - Column("state", identity_string(16), nullable=False), - Column("confirmed_at", identity_string(32), nullable=False), - Column("version", Integer, nullable=False), - Column("payload", Text, nullable=False), - CheckConstraint("version > 0", name="ck_pc_handoff_report_workspace_bindings_version_positive"), -) - -HANDOFF_REPORT_WORKSPACE_TABLES = (HANDOFF_REPORT_WORKSPACE_BINDINGS_TABLE,) - - -class WorkspaceBindingRepository: - """Store one mutable binding record per local workspace instance.""" - - async def get( - self, - connection: AsyncConnection, - workspace_instance_id: str, - /, - ) -> WorkspaceBinding: - workspace_instance_id = _identifier( - "workspace_instance_id", - workspace_instance_id, - MAX_WORKSPACE_INSTANCE_ID_LENGTH, - ) - row = await self._find(connection, workspace_instance_id) - if row is None: - raise WorkspaceBindingNotFoundError(workspace_instance_id) - return _decode_binding(row) - - async def get_confirmed( - self, - connection: AsyncConnection, - workspace_instance_id: str, - /, - ) -> WorkspaceBinding: - binding = await self.get(connection, workspace_instance_id) - if binding.state != "confirmed": - raise WorkspaceBindingNotFoundError(workspace_instance_id) - return binding - - async def attach( - self, - connection: AsyncConnection, - binding: WorkspaceBinding, - expected_version: int | None, - /, - ) -> WorkspaceBinding: - _validate_binding(binding) - if binding.state != "confirmed": - raise HandoffReportCatalogArgumentError("state", "attach requires a confirmed binding") - _optional_version(expected_version) - current = await self._find(connection, binding.workspace_instance_id) - if expected_version is None: - return await self._attach_absent(connection, binding, current) - return await self._attach_existing(connection, binding, expected_version, current) - - async def _attach_absent( - self, - connection: AsyncConnection, - binding: WorkspaceBinding, - current: Mapping[Any, Any] | None, - ) -> WorkspaceBinding: - if binding.version != 1: - raise HandoffReportCatalogArgumentError( - "version", - "an expect-absent attach must create version 1", - ) - if current is not None: - raise WorkspaceBindingConflictError( - binding.workspace_instance_id, - None, - int(current["version"]), - detail="workspace already has a binding record", - ) - try: - await connection.execute(insert(HANDOFF_REPORT_WORKSPACE_BINDINGS_TABLE).values(_row(binding))) - except IntegrityError as error: - raise WorkspaceBindingConflictError( - binding.workspace_instance_id, - None, - None, - detail="workspace already has a binding record", - ) from error - return binding - - async def _attach_existing( - self, - connection: AsyncConnection, - binding: WorkspaceBinding, - expected_version: int, - current: Mapping[Any, Any] | None, - ) -> WorkspaceBinding: - if current is None: - raise WorkspaceBindingConflictError( - binding.workspace_instance_id, - expected_version, - None, - detail="workspace binding record is missing", - ) - current_binding = _decode_binding(current) - if current_binding.version != expected_version: - raise WorkspaceBindingConflictError( - binding.workspace_instance_id, - expected_version, - current_binding.version, - ) - if binding.version != expected_version + 1: - raise HandoffReportCatalogArgumentError( - "version", - "updated binding version must equal expected_version + 1", - ) - if current_binding.state == "confirmed" and current_binding.project_id != binding.project_id: - raise WorkspaceBindingConflictError( - binding.workspace_instance_id, - expected_version, - current_binding.version, - detail="detach the confirmed binding before attaching another Project", - ) - result = await connection.execute( - update(HANDOFF_REPORT_WORKSPACE_BINDINGS_TABLE) - .where( - HANDOFF_REPORT_WORKSPACE_BINDINGS_TABLE.c.workspace_instance_id == binding.workspace_instance_id, - HANDOFF_REPORT_WORKSPACE_BINDINGS_TABLE.c.version == expected_version, - ) - .values(_row(binding)) - ) - if result.rowcount != 1: - latest = await self.get(connection, binding.workspace_instance_id) - raise WorkspaceBindingConflictError( - binding.workspace_instance_id, - expected_version, - latest.version, - ) - return binding - - async def detach( - self, - connection: AsyncConnection, - workspace_instance_id: str, - expected_version: int, - /, - ) -> WorkspaceBinding: - workspace_instance_id = _identifier( - "workspace_instance_id", - workspace_instance_id, - MAX_WORKSPACE_INSTANCE_ID_LENGTH, - ) - _required_version(expected_version) - current = await self._find(connection, workspace_instance_id) - if current is None: - raise WorkspaceBindingConflictError( - workspace_instance_id, - expected_version, - None, - detail="workspace binding record is missing", - ) - binding = _decode_binding(current) - if binding.version != expected_version: - raise WorkspaceBindingConflictError(workspace_instance_id, expected_version, binding.version) - if binding.state != "confirmed": - raise WorkspaceBindingConflictError( - workspace_instance_id, - expected_version, - binding.version, - detail="workspace binding is already detached", - ) - detached = binding.model_copy(update={"state": "detached", "version": expected_version + 1}) - result = await connection.execute( - update(HANDOFF_REPORT_WORKSPACE_BINDINGS_TABLE) - .where( - HANDOFF_REPORT_WORKSPACE_BINDINGS_TABLE.c.workspace_instance_id == workspace_instance_id, - HANDOFF_REPORT_WORKSPACE_BINDINGS_TABLE.c.version == expected_version, - ) - .values(_row(detached)) - ) - if result.rowcount != 1: - latest = await self.get(connection, workspace_instance_id) - raise WorkspaceBindingConflictError( - workspace_instance_id, - expected_version, - latest.version, - ) - return detached - - async def _find(self, connection: AsyncConnection, workspace_instance_id: str) -> Mapping[Any, Any] | None: - return ( - ( - await connection.execute( - select(HANDOFF_REPORT_WORKSPACE_BINDINGS_TABLE).where( - HANDOFF_REPORT_WORKSPACE_BINDINGS_TABLE.c.workspace_instance_id == workspace_instance_id - ) - ) - ) - .mappings() - .one_or_none() - ) - - -def _validate_binding(value: WorkspaceBinding) -> None: - if not isinstance(value, WorkspaceBinding): - raise HandoffReportCatalogArgumentError("binding", "must be a WorkspaceBinding") - - -def _row(binding: WorkspaceBinding) -> dict[str, object]: - reference = binding.repository_ref - return { - "workspace_instance_id": binding.workspace_instance_id, - "project_id": binding.project_id, - "provider": reference.provider, - "repository_id": reference.repository_id, - "normalized_remote": reference.normalized_remote, - "subpath": reference.subpath, - "state": binding.state, - "confirmed_at": _utc_text(binding.confirmed_at), - "version": binding.version, - "payload": json.dumps( - binding.model_dump(mode="json", by_alias=True), - ensure_ascii=False, - sort_keys=True, - separators=(",", ":"), - allow_nan=False, - ), - } - - -def _decode_binding(row: Mapping[Any, Any]) -> WorkspaceBinding: - try: - value = WorkspaceBinding.model_validate_json(str(row["payload"])) - except ValidationError as error: - raise InvalidStoredCatalogError("WorkspaceBinding", "does not match its schema") from error - if value.workspace_instance_id != str(row["workspace_instance_id"]) or value.version != int(row["version"]): - raise InvalidStoredCatalogError( - "WorkspaceBinding", - "identity does not match indexed columns", - ) - return value - - -def _identifier(field: str, value: object, maximum: int) -> str: - if not isinstance(value, str) or not value or value != value.strip(): - raise HandoffReportCatalogArgumentError(field, "must be a non-empty trimmed string") - if len(value) > maximum: - raise HandoffReportCatalogArgumentError(field, f"must not exceed {maximum} characters") - return value - - -def _required_version(value: object) -> None: - if not isinstance(value, int) or isinstance(value, bool) or value < 1: - raise HandoffReportCatalogArgumentError("expected_version", "must be a positive integer") - - -def _optional_version(value: object) -> None: - if value is not None: - _required_version(value) - - -def _utc_text(value: datetime) -> str: - if value.tzinfo is None or value.utcoffset() is None: - raise HandoffReportCatalogArgumentError("confirmed_at", "must include a UTC offset") - return value.astimezone(UTC).isoformat(timespec="microseconds").replace("+00:00", "Z") - - -__all__ = [ - "HANDOFF_REPORT_WORKSPACE_BINDINGS_TABLE", - "HANDOFF_REPORT_WORKSPACE_TABLES", - "WorkspaceBindingRepository", -] diff --git a/src/powercontext/builtin/runtime/composition.py b/src/powercontext/builtin/runtime/composition.py index 59f9dd132..16f1bebb9 100644 --- a/src/powercontext/builtin/runtime/composition.py +++ b/src/powercontext/builtin/runtime/composition.py @@ -38,9 +38,8 @@ MemoryReranker, ) from powercontext.builtin.artifacts.skill import AgentSkillProvider, ExternalSkillProvider, SkillGenerator -from powercontext.builtin.handoff_report.adapters import RuntimeHandoffReadAdapter, RuntimeWorkContinuityReadAdapter +from powercontext.builtin.handoff_report.adapters import RuntimeHandoffReadAdapter from powercontext.builtin.handoff_report.application import HandoffReportApplication -from powercontext.builtin.handoff_report.sqlite import HANDOFF_REPORT_TABLES from powercontext.builtin.inference import EmbeddingModel, TokenEstimator, character_token_estimator from powercontext.builtin.inference.usage import ( UsageReportingEmbeddingModel, @@ -287,10 +286,8 @@ async def open_builtin_runtime( ) if config.handoff_report.enabled: runtime.handoff_report = HandoffReportApplication( - contexts.database, + contexts.scopes, RuntimeHandoffReadAdapter(runtime.handoff), - continuity=RuntimeWorkContinuityReadAdapter(runtime.work), - scope_ids=contexts.handoff_scope_ids, ) if config.runtime.schedule_seconds is not None and configured_pipeline is None: raise BuiltinConfigurationError("scheduled-pipeline") @@ -324,7 +321,6 @@ async def open_builtin_contexts( """Open the selected database and expose scope-bound PowerContext providers.""" database = config.database - report_tables = HANDOFF_REPORT_TABLES if config.handoff_report.enabled else () configured_token_estimator = character_token_estimator() if token_estimator is None else token_estimator if isinstance(database, SQLiteConfig): experience_index = SQLiteExperienceFTSIndex() @@ -334,7 +330,7 @@ async def open_builtin_contexts( index = CompositeMemoryIndex(*indexes) async with SQLiteProfile.open( database, - tables=BUILTIN_TABLES + report_tables + index.tables, + tables=BUILTIN_TABLES + index.tables, load_vector_extension=embedding_model is not None, ) as profile: async with profile.database.transaction() as connection: @@ -363,7 +359,7 @@ async def open_builtin_contexts( if embedding_model is not None: indexes.append(OceanBaseMemoryVectorIndex(embedding_model.profile)) index = CompositeMemoryIndex(*indexes) - tables = BUILTIN_TABLES + report_tables + index.tables + tables = BUILTIN_TABLES + index.tables if isinstance(database, OceanBaseConfig): profile_context = OceanBaseProfile.open(database, tables=tables) elif isinstance(database, SeekDBConfig): diff --git a/src/powercontext/builtin/statistics/aggregation.py b/src/powercontext/builtin/statistics/aggregation.py index d6d47f3ac..f203d65e7 100644 --- a/src/powercontext/builtin/statistics/aggregation.py +++ b/src/powercontext/builtin/statistics/aggregation.py @@ -20,7 +20,7 @@ from collections.abc import Callable, Iterable from datetime import datetime -from powercontext.builtin.scope import ScopeSelection +from powercontext.builtin.scope.models import ScopeSelection from powercontext.builtin.statistics.models import ( ArtifactInventoryStatistics, CandidateFamilyCount, diff --git a/src/powercontext/builtin/statistics/models.py b/src/powercontext/builtin/statistics/models.py index 45bfcc154..64990ca88 100644 --- a/src/powercontext/builtin/statistics/models.py +++ b/src/powercontext/builtin/statistics/models.py @@ -23,7 +23,7 @@ from pydantic import BaseModel, Field, model_validator from powercontext.builtin.inference import TokenEstimatorProfile -from powercontext.builtin.scope import ScopeSelection +from powercontext.builtin.scope.models import ScopeSelection class StatisticsPeriod(StrEnum): diff --git a/src/powercontext/client/client.py b/src/powercontext/client/client.py index 5ab056e08..42e887e46 100644 --- a/src/powercontext/client/client.py +++ b/src/powercontext/client/client.py @@ -32,16 +32,16 @@ ArtifactCandidate, ArtifactCandidatePage, ArtifactPublication, - AttachHandoffReportWorkspaceRequest, Capabilities, CaptureContentSourceRequest, CaptureContentSourceResponse, + ClearScopeBindingRequest, + ClearScopeBindingResponse, CommitHandoffRequest, CommittedHandoff, ContinueHandoffRequest, - CreateHandoffReportProjectRequest, + CreateScopeRequest, CreateWorkContractRequest, - DetachHandoffReportWorkspaceRequest, ErrorResponse, ExperienceArtifact, ExternalSkillResolution, @@ -53,30 +53,22 @@ GenerateSkillRequest, GetArtifactCandidateRequest, GetExperienceRequest, - GetHandoffReportProjectRequest, GetHandoffReportRequest, - GetHandoffReportWorkspaceRequest, GetMemoryEntryRequest, + GetScopeRequest, GetSkillRequest, GetStatsRequest, HandoffAcknowledgement, HandoffActivation, HandoffCurrentWorkRequest, HandoffDraft, - HandoffReportActivityPage, HandoffReportResponse, - HandoffReportWorkspaceBinding, HandoffResolution, HealthResponse, ImportExternalSkillRequest, - KnownHandoffScopePage, ListArtifactCandidatesRequest, ListExternalSkillsRequest, ListExternalSkillsResponse, - ListHandoffReportActivitiesRequest, - ListHandoffReportKnownScopesRequest, - ListHandoffReportProjectsRequest, - ListHandoffReportWorkstreamsRequest, ListMemoryChangesRequest, ListMemoryChangesResponse, ListMemoryEntriesRequest, @@ -88,91 +80,84 @@ PreparedHandoff, PreparedWorkHandoff, PrepareHandoffRequest, - ProjectDescriptor, - ProjectPage, ProposeExperienceRequest, ProposeSkillRequest, PublishArtifactRequest, - PurgeHandoffReportActivitiesRequest, - PurgeHandoffReportActivitiesResponse, ReadinessResponse, - RecordHandoffReportActivityRequest, RecordTaskOutcomeRequest, - RegisterHandoffReportWorkstreamRequest, RejectArtifactCandidateRequest, RememberMemoryRequest, ResolveExternalSkillRequest, + ResolveScopeBindingRequest, + ResolveScopeSelectionRequest, RetireMemoryEntryRequest, ReviseArtifactCandidateRequest, ReviseMemoryEntryRequest, ScanExternalSkillsRequest, ScanExternalSkillsResponse, + ScopeBinding, + ScopeDescriptor, ScopedStats, + ScopePage, SearchMemoryRequest, SearchMemoryResponse, + SetDefaultScopeRequest, + SetScopeBindingRequest, SkillArtifact, - StoredHandoffReportActivity, - UpdateHandoffReportProjectRequest, - UpdateHandoffReportWorkstreamRequest, + UpdateScopeRequest, WorkSourceReceipt, - WorkstreamDescriptor, - WorkstreamPage, ) from powercontext.http._generated.operations import ( ACKNOWLEDGE_HANDOFF, ACTIVATE_HANDOFF, APPROVE_ARTIFACT_CANDIDATE, - ATTACH_HANDOFF_REPORT_WORKSPACE, CAPTURE_CONTENT_SOURCE, + CLEAR_SCOPE_BINDING, COMMIT_HANDOFF, CONTINUE_HANDOFF, - CREATE_HANDOFF_REPORT_PROJECT, + CREATE_SCOPE, CREATE_WORK_CONTRACT, - DETACH_HANDOFF_REPORT_WORKSPACE, FINALIZE_HANDOFF, FLUSH_MEMORY, GENERATE_EXPERIENCE, GENERATE_SKILL, GET_ARTIFACT_CANDIDATE, GET_CAPABILITIES, + GET_DEFAULT_SCOPE, GET_EXPERIENCE, GET_HANDOFF_REPORT, - GET_HANDOFF_REPORT_PROJECT, - GET_HANDOFF_REPORT_WORKSPACE, GET_LIVENESS, GET_MEMORY_ENTRY, GET_READINESS, + GET_SCOPE, GET_SKILL, GET_STATS, HANDOFF_CURRENT_WORK, IMPORT_EXTERNAL_SKILL, LIST_ARTIFACT_CANDIDATES, LIST_EXTERNAL_SKILLS, - LIST_HANDOFF_REPORT_ACTIVITIES, - LIST_HANDOFF_REPORT_KNOWN_SCOPES, - LIST_HANDOFF_REPORT_PROJECTS, - LIST_HANDOFF_REPORT_WORKSTREAMS, LIST_MEMORY_CHANGES, LIST_MEMORY_ENTRIES, + LIST_SCOPES, PREPARE_CONTEXT, PREPARE_HANDOFF, PROPOSE_EXPERIENCE, PROPOSE_SKILL, PUBLISH_ARTIFACT, - PURGE_HANDOFF_REPORT_ACTIVITIES, - RECORD_HANDOFF_REPORT_ACTIVITY, RECORD_TASK_OUTCOME, - REGISTER_HANDOFF_REPORT_WORKSTREAM, REJECT_ARTIFACT_CANDIDATE, REMEMBER_MEMORY, RESOLVE_EXTERNAL_SKILL, + RESOLVE_SCOPE_BINDING, + RESOLVE_SCOPE_SELECTION, RETIRE_MEMORY_ENTRY, REVISE_ARTIFACT_CANDIDATE, REVISE_MEMORY_ENTRY, SCAN_EXTERNAL_SKILLS, SEARCH_MEMORY, - UPDATE_HANDOFF_REPORT_PROJECT, - UPDATE_HANDOFF_REPORT_WORKSTREAM, + SET_DEFAULT_SCOPE, + SET_SCOPE_BINDING, + UPDATE_SCOPE, Operation, ) from powercontext.transport import is_plaintext_non_loopback @@ -249,127 +234,65 @@ async def get_capabilities(self) -> Capabilities: return await self._request(GET_CAPABILITIES) - async def publish_artifact(self, request: PublishArtifactRequest) -> ArtifactPublication: - """Deliver one exact Artifact revision into another Scope.""" - - return await self._request(PUBLISH_ARTIFACT, request) - - async def get_stats(self, request: GetStatsRequest) -> ScopedStats: - """Read current inventory and bounded usage for one scope.""" - - return await self._request(GET_STATS, request) - - async def create_handoff_report_project( - self, - request: CreateHandoffReportProjectRequest, - ) -> ProjectDescriptor: - """Create one explicit Report Project.""" - - return await self._request(CREATE_HANDOFF_REPORT_PROJECT, request) - - async def get_handoff_report_project( - self, - request: GetHandoffReportProjectRequest, - ) -> ProjectDescriptor: - """Read one current Report Project descriptor.""" - - return await self._request(GET_HANDOFF_REPORT_PROJECT, request) - - async def update_handoff_report_project( - self, - request: UpdateHandoffReportProjectRequest, - ) -> ProjectDescriptor: - """CAS-update one Report Project descriptor.""" + async def list_scopes(self) -> ScopePage: + """List durable Scope descriptors.""" - return await self._request(UPDATE_HANDOFF_REPORT_PROJECT, request) + return await self._request(LIST_SCOPES) - async def list_handoff_report_projects( - self, - request: ListHandoffReportProjectsRequest, - ) -> ProjectPage: - """List Report Projects with cursor pagination.""" + async def create_scope(self, request: CreateScopeRequest) -> ScopeDescriptor: + """Create one independent Scope boundary.""" - return await self._request(LIST_HANDOFF_REPORT_PROJECTS, request) + return await self._request(CREATE_SCOPE, request) - async def list_handoff_report_known_scopes( - self, - request: ListHandoffReportKnownScopesRequest, - ) -> KnownHandoffScopePage: - """List scopes that contain a committed Handoff.""" + async def get_scope(self, request: GetScopeRequest) -> ScopeDescriptor: + """Read one exact Scope descriptor.""" - return await self._request(LIST_HANDOFF_REPORT_KNOWN_SCOPES, request) + return await self._request(GET_SCOPE, request) - async def register_handoff_report_workstream( - self, - request: RegisterHandoffReportWorkstreamRequest, - ) -> WorkstreamDescriptor: - """Register one existing scope as a Report Workstream.""" + async def update_scope(self, request: UpdateScopeRequest) -> ScopeDescriptor: + """Replace mutable Scope metadata and relationships.""" - return await self._request(REGISTER_HANDOFF_REPORT_WORKSTREAM, request) + return await self._request(UPDATE_SCOPE, request) - async def list_handoff_report_workstreams( - self, - request: ListHandoffReportWorkstreamsRequest, - ) -> WorkstreamPage: - """List Workstreams belonging to one Report Project.""" + async def get_default_scope(self) -> ScopeDescriptor: + """Read the host's default Scope target.""" - return await self._request(LIST_HANDOFF_REPORT_WORKSTREAMS, request) + return await self._request(GET_DEFAULT_SCOPE) - async def update_handoff_report_workstream( - self, - request: UpdateHandoffReportWorkstreamRequest, - ) -> WorkstreamDescriptor: - """CAS-update one Report Workstream descriptor.""" + async def set_default_scope(self, request: SetDefaultScopeRequest) -> ScopeDescriptor: + """Change the host's default Scope target.""" - return await self._request(UPDATE_HANDOFF_REPORT_WORKSTREAM, request) + return await self._request(SET_DEFAULT_SCOPE, request) - async def record_handoff_report_activity( - self, - request: RecordHandoffReportActivityRequest, - ) -> StoredHandoffReportActivity: - """Record one explicit Report-owned Activity observation.""" + async def resolve_scope_selection(self, request: ResolveScopeSelectionRequest) -> ScopePage: + """Resolve all, exact, or subtree into exact Scope descriptors.""" - return await self._request(RECORD_HANDOFF_REPORT_ACTIVITY, request) + return await self._request(RESOLVE_SCOPE_SELECTION, request) - async def list_handoff_report_activities( - self, - request: ListHandoffReportActivitiesRequest, - ) -> HandoffReportActivityPage: - """List one frozen cursor page of Report-owned Activities.""" + async def resolve_scope_binding(self, request: ResolveScopeBindingRequest) -> ScopeDescriptor: + """Resolve explicit and external host bindings to one Scope.""" - return await self._request(LIST_HANDOFF_REPORT_ACTIVITIES, request) + return await self._request(RESOLVE_SCOPE_BINDING, request) - async def purge_handoff_report_activities( - self, - request: PurgeHandoffReportActivitiesRequest, - ) -> PurgeHandoffReportActivitiesResponse: - """Purge Report-owned Activities before an observation boundary.""" + async def set_scope_binding(self, request: SetScopeBindingRequest) -> ScopeBinding: + """Bind one external integration identity to a Scope.""" - return await self._request(PURGE_HANDOFF_REPORT_ACTIVITIES, request) + return await self._request(SET_SCOPE_BINDING, request) - async def get_handoff_report_workspace( - self, - request: GetHandoffReportWorkspaceRequest, - ) -> HandoffReportWorkspaceBinding: - """Read one confirmed Workspace-to-Project binding.""" + async def clear_scope_binding(self, request: ClearScopeBindingRequest) -> ClearScopeBindingResponse: + """Clear one external integration binding.""" - return await self._request(GET_HANDOFF_REPORT_WORKSPACE, request) + return await self._request(CLEAR_SCOPE_BINDING, request) - async def attach_handoff_report_workspace( - self, - request: AttachHandoffReportWorkspaceRequest, - ) -> HandoffReportWorkspaceBinding: - """Attach a Workspace to an exact Report Project using CAS.""" + async def publish_artifact(self, request: PublishArtifactRequest) -> ArtifactPublication: + """Deliver one exact Artifact revision into another Scope.""" - return await self._request(ATTACH_HANDOFF_REPORT_WORKSPACE, request) + return await self._request(PUBLISH_ARTIFACT, request) - async def detach_handoff_report_workspace( - self, - request: DetachHandoffReportWorkspaceRequest, - ) -> HandoffReportWorkspaceBinding: - """Detach a Workspace binding using its exact version.""" + async def get_stats(self, request: GetStatsRequest) -> ScopedStats: + """Read current inventory and bounded usage for one scope.""" - return await self._request(DETACH_HANDOFF_REPORT_WORKSPACE, request) + return await self._request(GET_STATS, request) async def get_handoff_report(self, request: GetHandoffReportRequest) -> HandoffReportResponse | str: """Generate the current canonical Handoff Report projection.""" diff --git a/src/powercontext/http/__init__.py b/src/powercontext/http/__init__.py index 6459ef7e2..6a16412b0 100644 --- a/src/powercontext/http/__init__.py +++ b/src/powercontext/http/__init__.py @@ -17,6 +17,7 @@ from powercontext.http._generated.models import ( AcknowledgeHandoffRequest, ActivateHandoffRequest, + AgentKind, ApproveArtifactCandidateRequest, ArtifactAddress, ArtifactCandidate, @@ -24,7 +25,7 @@ ArtifactInventoryStatistics, ArtifactPublication, ArtifactReference, - AttachHandoffReportWorkspaceRequest, + AuthorizationNote, CandidateFamily, CandidateFamilyCount, CandidateInventoryStatistics, @@ -37,16 +38,17 @@ ClearScopeBindingResponse, CommitHandoffRequest, CommittedHandoff, + CompletionCriterion, + ContextReference, ContinueHandoffRequest, - CreateHandoffReportProjectRequest, CreateScopeRequest, CreateWorkContractRequest, CurrentWorkHandoff, - DetachHandoffReportWorkspaceRequest, EntryChange, EntryChangeOperation, ErrorDetail, ErrorResponse, + Exclusion, ExperienceArtifact, ExperienceProposal, ExternalSkillImportMode, @@ -65,14 +67,13 @@ GenerateSkillRequest, GetArtifactCandidateRequest, GetExperienceRequest, - GetHandoffReportProjectRequest, GetHandoffReportRequest, - GetHandoffReportWorkspaceRequest, GetMemoryEntryRequest, GetScopeRequest, GetSkillRequest, GetStatsRequest, HandoffAcknowledgement, + HandoffAcknowledgementSelection, HandoffActivation, HandoffActivationStatus, HandoffArtifactCitation, @@ -87,15 +88,7 @@ HandoffMemoryCitation, HandoffOmission, HandoffReceiptStatus, - HandoffReportActivity, - HandoffReportActivityAgent, - HandoffReportActivityPage, - HandoffReportActivityVcsContext, - HandoffReportExternalReference, - HandoffReportPeriodRequest, - HandoffReportRepositoryRef, HandoffReportResponse, - HandoffReportWorkspaceBinding, HandoffResolution, HandoffResolutionStatus, HandoffSchema, @@ -104,20 +97,19 @@ HandoffStatement, HealthResponse, ImportExternalSkillRequest, + InScopeItem, InventoryStatistics, - KnownHandoffScope, - KnownHandoffScopePage, + Kind, + Kind1, + Kind2, ListArtifactCandidatesRequest, ListExternalSkillsRequest, ListExternalSkillsResponse, - ListHandoffReportActivitiesRequest, - ListHandoffReportKnownScopesRequest, - ListHandoffReportProjectsRequest, - ListHandoffReportWorkstreamsRequest, ListMemoryChangesRequest, ListMemoryChangesResponse, ListMemoryEntriesRequest, ListMemoryEntriesResponse, + LiveStateCheckStatus, MemoryCitation, MemoryEntry, MemoryEntryInventoryStatistics, @@ -133,6 +125,8 @@ ModelUsagePurposeBreakdown, ModelUsageStatistics, ModelUsageValue, + Omission, + OpenQuestion, PrepareContextRequest, PreparedContext, PreparedContextSchema, @@ -141,28 +135,22 @@ PreparedHandoffSchema, PreparedWorkHandoff, PrepareHandoffRequest, - ProjectDescriptor, - ProjectPage, ProposeExperienceRequest, ProposeSkillRequest, + Provider, PublishArtifactRequest, - PurgeHandoffReportActivitiesRequest, - PurgeHandoffReportActivitiesResponse, ReadinessResponse, ReadinessStatus, RecallTokenDay, RecallTokenStatistics, RecallTokenValue, - RecordHandoffReportActivityRequest, + ReceiverChecks, + ReceiverReadinessCheckStatus, RecordTaskOutcomeRequest, - RegisterHandoffReportWorkstreamRequest, RejectArtifactCandidateRequest, + RemainingWorkItem, RememberMemoryRequest, - ReportActivitySource, - ReportCatalogState, ReportFormat, - ReportLocale, - ReportTimeBasis, ResolvedUsagePeriod, ResolveExternalSkillRequest, ResolveScopeBindingRequest, @@ -172,6 +160,9 @@ ReviseMemoryEntryRequest, ScanExternalSkillsRequest, ScanExternalSkillsResponse, + Schema, + Schema1, + Schema2, ScopeBinding, ScopeBindingKey, ScopeDescriptor, @@ -193,14 +184,15 @@ SourceInventoryStatistics, SourceReference, StatsPeriod, - StoredHandoffReportActivity, TaskCheck, TaskCheckStatus, TaskOutcome, TaskOutcomeStatus, + Timezone, TokenEstimatorProfile, - UpdateHandoffReportProjectRequest, - UpdateHandoffReportWorkstreamRequest, + Trust, + Trust2, + Trust3, UpdateScopeRequest, UsageStatistics, WorkClaim, @@ -208,14 +200,12 @@ WorkContract, WorkSourceKind, WorkSourceReceipt, - WorkstreamDescriptor, - WorkstreamKind, - WorkstreamPage, ) __all__ = [ "AcknowledgeHandoffRequest", "ActivateHandoffRequest", + "AgentKind", "ApproveArtifactCandidateRequest", "ArtifactAddress", "ArtifactCandidate", @@ -223,7 +213,7 @@ "ArtifactInventoryStatistics", "ArtifactPublication", "ArtifactReference", - "AttachHandoffReportWorkspaceRequest", + "AuthorizationNote", "CandidateFamily", "CandidateFamilyCount", "CandidateInventoryStatistics", @@ -236,16 +226,17 @@ "ClearScopeBindingResponse", "CommitHandoffRequest", "CommittedHandoff", + "CompletionCriterion", + "ContextReference", "ContinueHandoffRequest", - "CreateHandoffReportProjectRequest", "CreateScopeRequest", "CreateWorkContractRequest", "CurrentWorkHandoff", - "DetachHandoffReportWorkspaceRequest", "EntryChange", "EntryChangeOperation", "ErrorDetail", "ErrorResponse", + "Exclusion", "ExperienceArtifact", "ExperienceProposal", "ExternalSkillImportMode", @@ -264,14 +255,13 @@ "GeneratedCandidateStatus", "GetArtifactCandidateRequest", "GetExperienceRequest", - "GetHandoffReportProjectRequest", "GetHandoffReportRequest", - "GetHandoffReportWorkspaceRequest", "GetMemoryEntryRequest", "GetScopeRequest", "GetSkillRequest", "GetStatsRequest", "HandoffAcknowledgement", + "HandoffAcknowledgementSelection", "HandoffActivation", "HandoffActivationStatus", "HandoffArtifactCitation", @@ -286,15 +276,7 @@ "HandoffMemoryCitation", "HandoffOmission", "HandoffReceiptStatus", - "HandoffReportActivity", - "HandoffReportActivityAgent", - "HandoffReportActivityPage", - "HandoffReportActivityVcsContext", - "HandoffReportExternalReference", - "HandoffReportPeriodRequest", - "HandoffReportRepositoryRef", "HandoffReportResponse", - "HandoffReportWorkspaceBinding", "HandoffResolution", "HandoffResolutionStatus", "HandoffSchema", @@ -303,20 +285,19 @@ "HandoffStatement", "HealthResponse", "ImportExternalSkillRequest", + "InScopeItem", "InventoryStatistics", - "KnownHandoffScope", - "KnownHandoffScopePage", + "Kind", + "Kind1", + "Kind2", "ListArtifactCandidatesRequest", "ListExternalSkillsRequest", "ListExternalSkillsResponse", - "ListHandoffReportActivitiesRequest", - "ListHandoffReportKnownScopesRequest", - "ListHandoffReportProjectsRequest", - "ListHandoffReportWorkstreamsRequest", "ListMemoryChangesRequest", "ListMemoryChangesResponse", "ListMemoryEntriesRequest", "ListMemoryEntriesResponse", + "LiveStateCheckStatus", "MemoryCitation", "MemoryEntry", "MemoryEntryInventoryStatistics", @@ -332,6 +313,8 @@ "ModelUsagePurposeBreakdown", "ModelUsageStatistics", "ModelUsageValue", + "Omission", + "OpenQuestion", "PrepareContextRequest", "PrepareHandoffRequest", "PreparedContext", @@ -340,28 +323,22 @@ "PreparedHandoff", "PreparedHandoffSchema", "PreparedWorkHandoff", - "ProjectDescriptor", - "ProjectPage", "ProposeExperienceRequest", "ProposeSkillRequest", + "Provider", "PublishArtifactRequest", - "PurgeHandoffReportActivitiesRequest", - "PurgeHandoffReportActivitiesResponse", "ReadinessResponse", "ReadinessStatus", "RecallTokenDay", "RecallTokenStatistics", "RecallTokenValue", - "RecordHandoffReportActivityRequest", + "ReceiverChecks", + "ReceiverReadinessCheckStatus", "RecordTaskOutcomeRequest", - "RegisterHandoffReportWorkstreamRequest", "RejectArtifactCandidateRequest", + "RemainingWorkItem", "RememberMemoryRequest", - "ReportActivitySource", - "ReportCatalogState", "ReportFormat", - "ReportLocale", - "ReportTimeBasis", "ResolveExternalSkillRequest", "ResolveScopeBindingRequest", "ResolveScopeSelectionRequest", @@ -371,6 +348,9 @@ "ReviseMemoryEntryRequest", "ScanExternalSkillsRequest", "ScanExternalSkillsResponse", + "Schema", + "Schema1", + "Schema2", "ScopeBinding", "ScopeBindingKey", "ScopeDescriptor", @@ -392,14 +372,15 @@ "SourceInventoryStatistics", "SourceReference", "StatsPeriod", - "StoredHandoffReportActivity", "TaskCheck", "TaskCheckStatus", "TaskOutcome", "TaskOutcomeStatus", + "Timezone", "TokenEstimatorProfile", - "UpdateHandoffReportProjectRequest", - "UpdateHandoffReportWorkstreamRequest", + "Trust", + "Trust2", + "Trust3", "UpdateScopeRequest", "UsageStatistics", "WorkClaim", @@ -407,7 +388,4 @@ "WorkContract", "WorkSourceKind", "WorkSourceReceipt", - "WorkstreamDescriptor", - "WorkstreamKind", - "WorkstreamPage", ] diff --git a/src/powercontext/http/_generated/models.py b/src/powercontext/http/_generated/models.py index 04048b8ef..a4320d4ed 100644 --- a/src/powercontext/http/_generated/models.py +++ b/src/powercontext/http/_generated/models.py @@ -543,254 +543,11 @@ class GetSkillRequest(BaseModel): artifact: ArtifactReference -class ListHandoffReportProjectsRequest(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - cursor: StrictStr | None = None - limit: Annotated[StrictInt, Field(ge=1, le=100)] = 50 - include_archived: StrictBool = False - - -class GetHandoffReportProjectRequest(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - project_id: Annotated[StrictStr, Field(max_length=256, min_length=1)] - - -class Label(RootModel[StrictStr]): - root: Annotated[StrictStr, Field(max_length=128, min_length=1)] - - -class ListHandoffReportWorkstreamsRequest(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - project_id: Annotated[StrictStr, Field(max_length=256, min_length=1)] - cursor: StrictStr | None = None - limit: Annotated[StrictInt, Field(ge=1, le=100)] = 50 - include_archived: StrictBool = False - - -class ListHandoffReportKnownScopesRequest(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - cursor: StrictStr | None = None - limit: Annotated[StrictInt, Field(ge=1, le=100)] = 50 - - -class KnownHandoffScope(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - scope_id: Annotated[StrictStr, Field(max_length=256, min_length=1)] - - -class KnownHandoffScopePage(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - items: list[KnownHandoffScope] - next_cursor: StrictStr | None = None - - -class HandoffReportPeriodRequest(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - start: AwareDatetime - end: AwareDatetime - timezone: Annotated[StrictStr | None, Field(max_length=256, min_length=1)] = None - compare_to_previous_period: StrictBool = False - - -class ReportActivitySource(StrEnum): - HANDOFF_OBSERVATION = "handoff_observation" - GIT_COMMIT = "git_commit" - GIT_WORKTREE = "git_worktree" - CODING_SESSION = "coding_session" - OTHER = "other" - - -class ReportTimeBasis(StrEnum): - SOURCE_REPORTED = "source_reported" - HOST_OBSERVED = "host_observed" - FIRST_SEEN = "first_seen" - CURRENT_ONLY = "current_only" - UNKNOWN = "unknown" - - -class HandoffReportActivityAgent(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - provider: Annotated[StrictStr | None, Field(max_length=64, min_length=1)] = None - label: Annotated[StrictStr | None, Field(max_length=128, min_length=1)] = None - - -class HandoffReportActivityVcsContext(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - branch: Annotated[StrictStr | None, Field(max_length=256, min_length=1)] = None - head_revision: Annotated[StrictStr | None, Field(max_length=256, min_length=1)] = None - - -class Schema3(StrEnum): - POWERCONTEXT_HANDOFF_REPORT_ACTIVITY_V1 = "powercontext.handoff-report-activity.v1" - - -class Trust4(StrEnum): - UNTRUSTED_OBSERVATION = "untrusted_observation" - - -class ListHandoffReportActivitiesRequest(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - project_id: Annotated[StrictStr, Field(max_length=256, min_length=1)] - period_start: AwareDatetime | None = None - period_end: AwareDatetime | None = None - sources: Annotated[list[ReportActivitySource] | None, Field(max_length=5)] = None - after_cursor: Annotated[StrictInt, Field(ge=0)] = 0 - through_cursor: Annotated[StrictInt | None, Field(ge=0)] = None - limit: Annotated[StrictInt, Field(ge=1, le=100)] = 50 - - -class PurgeHandoffReportActivitiesRequest(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - project_id: Annotated[StrictStr, Field(max_length=256, min_length=1)] - observed_before: AwareDatetime - - -class PurgeHandoffReportActivitiesResponse(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - deleted_count: Annotated[StrictInt, Field(ge=0)] - - -class Provider1(StrEnum): - GITHUB = "github" - GITLAB = "gitlab" - LOCAL = "local" - OTHER = "other" - - -class HandoffReportRepositoryRef(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - provider: Provider1 - repository_id: Annotated[StrictStr | None, Field(max_length=256, min_length=1)] - normalized_remote: Annotated[StrictStr | None, Field(max_length=2048, min_length=1)] - subpath: Annotated[StrictStr | None, Field(max_length=1024, min_length=1)] - - -class Schema4(StrEnum): - POWERCONTEXT_WORKSPACE_BINDING_V1 = "powercontext.workspace-binding.v1" - - -class State(StrEnum): - CONFIRMED = "confirmed" - DETACHED = "detached" - - -class HandoffReportWorkspaceBinding(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - schema_: Annotated[Schema4, Field(alias="schema")] - workspace_instance_id: Annotated[StrictStr, Field(max_length=256, min_length=1)] - project_id: Annotated[StrictStr, Field(max_length=256, min_length=1)] - repository_ref: HandoffReportRepositoryRef - state: State - confirmed_at: AwareDatetime - version: Annotated[StrictInt, Field(ge=1)] - - -class GetHandoffReportWorkspaceRequest(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - workspace_instance_id: Annotated[StrictStr, Field(max_length=256, min_length=1)] - - -class AttachHandoffReportWorkspaceRequest(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - workspace_instance_id: Annotated[StrictStr, Field(max_length=256, min_length=1)] - project_id: Annotated[StrictStr, Field(max_length=256, min_length=1)] - repository_ref: HandoffReportRepositoryRef - expected_version: Annotated[StrictInt | None, Field(ge=1)] - - -class DetachHandoffReportWorkspaceRequest(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - workspace_instance_id: Annotated[StrictStr, Field(max_length=256, min_length=1)] - expected_version: Annotated[StrictInt, Field(ge=1)] - - -class Schema5(StrEnum): - POWERCONTEXT_PROJECT_V1 = "powercontext.project.v1" - - -class Schema6(StrEnum): - POWERCONTEXT_WORKSTREAM_V1 = "powercontext.workstream.v1" - - -class Kind3(StrEnum): - ISSUE = "issue" - TASK = "task" - PULL_REQUEST = "pull_request" - BRANCH = "branch" - FEATURE = "feature" - RELEASE = "release" - PROGRAM = "program" - OTHER = "other" - - -class HandoffReportExternalReference(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - kind: Kind3 - provider: Annotated[StrictStr, Field(max_length=64, min_length=1)] - external_id: Annotated[StrictStr, Field(max_length=256, min_length=1)] - url: Annotated[StrictStr | None, Field(max_length=2048)] - - -class ReportLocale(StrEnum): - ZH_CN = "zh-CN" - EN = "en" - - class ReportFormat(StrEnum): JSON = "json" MARKDOWN = "markdown" -class ReportCatalogState(StrEnum): - INCLUDED = "included" - ARCHIVED = "archived" - - -class WorkstreamKind(StrEnum): - FEATURE = "feature" - BUG = "bug" - REFACTOR = "refactor" - OPERATIONS = "operations" - RESEARCH = "research" - OTHER = "other" - - class HealthResponse(BaseModel): model_config = ConfigDict( extra="forbid", @@ -1317,51 +1074,13 @@ class GetMemoryEntryRequest(BaseModel): citation: MemoryCitation -class CreateHandoffReportProjectRequest(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - project_key: Annotated[StrictStr, Field(max_length=64, min_length=1)] - title: Annotated[StrictStr, Field(max_length=256, min_length=1)] - description: Annotated[StrictStr | None, Field(max_length=2000)] = None - default_locale: ReportLocale = ReportLocale.ZH_CN - timezone: Annotated[StrictStr, Field(max_length=256, min_length=1)] = "UTC" - - -class RegisterHandoffReportWorkstreamRequest(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - project_id: Annotated[StrictStr, Field(max_length=256, min_length=1)] - scope_id: Annotated[StrictStr, Field(max_length=256, min_length=1)] - key: Annotated[StrictStr | None, Field(max_length=64, min_length=1)] = None - title: Annotated[StrictStr, Field(max_length=256, min_length=1)] - kind: WorkstreamKind - catalog_state: ReportCatalogState = ReportCatalogState.INCLUDED - external_refs: Annotated[list[HandoffReportExternalReference], Field(max_length=32, validate_default=True)] = [] - labels: Annotated[list[Label], Field(max_length=32, validate_default=True)] = [] - - class GetHandoffReportRequest(BaseModel): model_config = ConfigDict( extra="forbid", ) - scope_id: Annotated[StrictStr, Field(max_length=256, min_length=1)] - project_id: Annotated[ - StrictStr | None, - Field( - deprecated=True, - description="Retained for wire compatibility and ignored when generating a scope report.", - max_length=256, - min_length=1, - ), - ] = None - locale: ReportLocale | None = None - include_evidence_checks: StrictBool = True - format: ReportFormat = ReportFormat.MARKDOWN - include_archived: StrictBool = False + selection: ScopeSelection + format: ReportFormat = ReportFormat.JSON download: StrictBool = False - period: HandoffReportPeriodRequest | None = None class HandoffReportResponse(BaseModel): @@ -1375,112 +1094,6 @@ class HandoffReportResponse(BaseModel): report_digest: Annotated[StrictStr, Field(pattern="^sha256:[0-9a-f]{64}$")] -class RecordHandoffReportActivityRequest(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - project_id: Annotated[StrictStr, Field(max_length=256, min_length=1)] - scope_id: Annotated[StrictStr | None, Field(max_length=256, min_length=1)] = None - source: ReportActivitySource - source_event_id: Annotated[StrictStr, Field(max_length=256, min_length=1)] - source_ref: HandoffReportExternalReference | None = None - occurred_at: AwareDatetime | None = None - time_basis: ReportTimeBasis - title: Annotated[StrictStr | None, Field(max_length=256, min_length=1)] = None - summary: Annotated[StrictStr | None, Field(max_length=2000, min_length=1)] = None - agent: HandoffReportActivityAgent | None = None - session_id: Annotated[StrictStr | None, Field(max_length=256, min_length=1)] = None - vcs_context: HandoffReportActivityVcsContext | None = None - evidence_refs: Annotated[list[HandoffReportExternalReference], Field(max_length=32, validate_default=True)] = [] - - -class HandoffReportActivity(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - schema_: Annotated[Schema3, Field(alias="schema")] - event_id: Annotated[StrictStr, Field(max_length=256, min_length=1)] - project_id: Annotated[StrictStr, Field(max_length=256, min_length=1)] - scope_id: Annotated[StrictStr | None, Field(max_length=256, min_length=1)] - source: ReportActivitySource - source_event_id: Annotated[StrictStr, Field(max_length=256, min_length=1)] - source_ref: Annotated[HandoffReportExternalReference | None, Field(...)] - occurred_at: Annotated[AwareDatetime | None, Field(...)] - observed_at: AwareDatetime - time_basis: ReportTimeBasis - title: Annotated[StrictStr | None, Field(max_length=256, min_length=1)] - summary: Annotated[StrictStr | None, Field(max_length=2000, min_length=1)] - agent: Annotated[HandoffReportActivityAgent | None, Field(...)] - session_id: Annotated[StrictStr | None, Field(max_length=256, min_length=1)] - vcs_context: Annotated[HandoffReportActivityVcsContext | None, Field(...)] - evidence_refs: Annotated[list[HandoffReportExternalReference], Field(max_length=32)] - trust: Trust4 - - -class StoredHandoffReportActivity(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - cursor: Annotated[StrictInt, Field(ge=1)] - event: HandoffReportActivity - - -class HandoffReportActivityPage(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - items: Annotated[list[HandoffReportActivity], Field(max_length=100)] - next_cursor: Annotated[StrictInt | None, Field(ge=1)] - high_watermark: Annotated[StrictInt, Field(ge=0)] - - -class ProjectDescriptor(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - schema_: Annotated[Schema5, Field(alias="schema")] - project_id: Annotated[StrictStr, Field(max_length=256, min_length=1)] - project_key: Annotated[StrictStr, Field(max_length=64, min_length=1)] - title: Annotated[StrictStr, Field(max_length=256, min_length=1)] - description: Annotated[StrictStr | None, Field(max_length=2000)] - default_locale: ReportLocale - timezone: Annotated[StrictStr, Field(max_length=256, min_length=1)] - catalog_state: ReportCatalogState - version: Annotated[StrictInt, Field(ge=1)] - - -class ProjectPage(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - items: Annotated[list[ProjectDescriptor], Field(max_length=100)] - next_cursor: Annotated[StrictStr | None, Field(...)] - - -class WorkstreamDescriptor(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - schema_: Annotated[Schema6, Field(alias="schema")] - scope_id: Annotated[StrictStr, Field(max_length=256, min_length=1)] - project_id: Annotated[StrictStr, Field(max_length=256, min_length=1)] - key: Annotated[StrictStr | None, Field(max_length=64)] - title: Annotated[StrictStr, Field(max_length=256, min_length=1)] - kind: WorkstreamKind - catalog_state: ReportCatalogState - external_refs: Annotated[list[HandoffReportExternalReference], Field(max_length=32)] - labels: Annotated[list[Label], Field(max_length=32)] - version: Annotated[StrictInt, Field(ge=1)] - - -class WorkstreamPage(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - items: Annotated[list[WorkstreamDescriptor], Field(max_length=100)] - next_cursor: Annotated[StrictStr | None, Field(...)] - - class ListArtifactCandidatesRequest(BaseModel): model_config = ConfigDict( extra="forbid", @@ -1816,22 +1429,6 @@ class SkillArtifact(BaseModel): artifact_refs: list[ArtifactReference] -class UpdateHandoffReportProjectRequest(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - project: ProjectDescriptor - expected_version: Annotated[StrictInt, Field(ge=1)] - - -class UpdateHandoffReportWorkstreamRequest(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - workstream: WorkstreamDescriptor - expected_version: Annotated[StrictInt, Field(ge=1)] - - class ListMemoryChangesResponse(BaseModel): model_config = ConfigDict( extra="forbid", diff --git a/src/powercontext/http/_generated/operations.py b/src/powercontext/http/_generated/operations.py index 4f0129d0e..dc15d9010 100644 --- a/src/powercontext/http/_generated/operations.py +++ b/src/powercontext/http/_generated/operations.py @@ -13,7 +13,6 @@ ArtifactCandidate, ArtifactCandidatePage, ArtifactPublication, - AttachHandoffReportWorkspaceRequest, Capabilities, CaptureContentSourceRequest, CaptureContentSourceResponse, @@ -22,10 +21,8 @@ CommitHandoffRequest, CommittedHandoff, ContinueHandoffRequest, - CreateHandoffReportProjectRequest, CreateScopeRequest, CreateWorkContractRequest, - DetachHandoffReportWorkspaceRequest, ExperienceArtifact, ExternalSkillResolution, FinalizeHandoffRequest, @@ -36,9 +33,7 @@ GenerateSkillRequest, GetArtifactCandidateRequest, GetExperienceRequest, - GetHandoffReportProjectRequest, GetHandoffReportRequest, - GetHandoffReportWorkspaceRequest, GetMemoryEntryRequest, GetScopeRequest, GetSkillRequest, @@ -47,20 +42,13 @@ HandoffActivation, HandoffCurrentWorkRequest, HandoffDraft, - HandoffReportActivityPage, HandoffReportResponse, - HandoffReportWorkspaceBinding, HandoffResolution, HealthResponse, ImportExternalSkillRequest, - KnownHandoffScopePage, ListArtifactCandidatesRequest, ListExternalSkillsRequest, ListExternalSkillsResponse, - ListHandoffReportActivitiesRequest, - ListHandoffReportKnownScopesRequest, - ListHandoffReportProjectsRequest, - ListHandoffReportWorkstreamsRequest, ListMemoryChangesRequest, ListMemoryChangesResponse, ListMemoryEntriesRequest, @@ -72,17 +60,11 @@ PreparedHandoff, PreparedWorkHandoff, PrepareHandoffRequest, - ProjectDescriptor, - ProjectPage, ProposeExperienceRequest, ProposeSkillRequest, PublishArtifactRequest, - PurgeHandoffReportActivitiesRequest, - PurgeHandoffReportActivitiesResponse, ReadinessResponse, - RecordHandoffReportActivityRequest, RecordTaskOutcomeRequest, - RegisterHandoffReportWorkstreamRequest, RejectArtifactCandidateRequest, RememberMemoryRequest, ResolveExternalSkillRequest, @@ -102,13 +84,8 @@ SetDefaultScopeRequest, SetScopeBindingRequest, SkillArtifact, - StoredHandoffReportActivity, - UpdateHandoffReportProjectRequest, - UpdateHandoffReportWorkstreamRequest, UpdateScopeRequest, WorkSourceReceipt, - WorkstreamDescriptor, - WorkstreamPage, ) OPENAPI_VERSION = "3.0.3" @@ -1205,183 +1182,6 @@ class Operation(BaseModel, Generic[RequestT, ResponseT]): }, ) -CREATE_HANDOFF_REPORT_PROJECT = Operation[CreateHandoffReportProjectRequest, ProjectDescriptor]( - method="POST", - path="/v1/handoff-reports/projects/create", - operation_id="create_handoff_report_project", - request_type=CreateHandoffReportProjectRequest, - request_location="body", - response_type=ProjectDescriptor, - success_status=201, - summary="Create a Handoff Report Project", - tags=("handoff-reports",), - responses={ - 201: { - "description": "The created Report Project.", - "headers": {"X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}}, - }, - 409: {"$ref": "#/components/responses/Conflict"}, - 401: {"$ref": "#/components/responses/Unauthorized"}, - 422: {"$ref": "#/components/responses/InvalidRequest"}, - 500: {"$ref": "#/components/responses/InternalError"}, - }, -) - -LIST_HANDOFF_REPORT_PROJECTS = Operation[ListHandoffReportProjectsRequest, ProjectPage]( - method="POST", - path="/v1/handoff-reports/projects/list", - operation_id="list_handoff_report_projects", - request_type=ListHandoffReportProjectsRequest, - request_location="body", - response_type=ProjectPage, - success_status=200, - summary="List Handoff Report Projects", - tags=("handoff-reports",), - responses={ - 200: { - "description": "A cursor-paginated page of Report Projects.", - "headers": {"X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}}, - }, - 401: {"$ref": "#/components/responses/Unauthorized"}, - 422: {"$ref": "#/components/responses/InvalidRequest"}, - 500: {"$ref": "#/components/responses/InternalError"}, - }, -) - -LIST_HANDOFF_REPORT_KNOWN_SCOPES = Operation[ListHandoffReportKnownScopesRequest, KnownHandoffScopePage]( - method="POST", - path="/v1/handoff-reports/scopes/list-known", - operation_id="list_handoff_report_known_scopes", - request_type=ListHandoffReportKnownScopesRequest, - request_location="body", - response_type=KnownHandoffScopePage, - success_status=200, - summary="List scopes that contain a committed Handoff", - tags=("handoff-reports",), - responses={ - 200: { - "description": "A cursor-paginated page of scopes that can be rendered as Handoff Reports.", - "headers": {"X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}}, - }, - 401: {"$ref": "#/components/responses/Unauthorized"}, - 422: {"$ref": "#/components/responses/InvalidRequest"}, - 500: {"$ref": "#/components/responses/InternalError"}, - }, -) - -GET_HANDOFF_REPORT_PROJECT = Operation[GetHandoffReportProjectRequest, ProjectDescriptor]( - method="POST", - path="/v1/handoff-reports/projects/get", - operation_id="get_handoff_report_project", - request_type=GetHandoffReportProjectRequest, - request_location="body", - response_type=ProjectDescriptor, - success_status=200, - summary="Get a Handoff Report Project", - tags=("handoff-reports",), - responses={ - 200: { - "description": "The exact current Report Project descriptor.", - "headers": {"X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}}, - }, - 404: {"$ref": "#/components/responses/NotFound"}, - 401: {"$ref": "#/components/responses/Unauthorized"}, - 422: {"$ref": "#/components/responses/InvalidRequest"}, - 500: {"$ref": "#/components/responses/InternalError"}, - }, -) - -UPDATE_HANDOFF_REPORT_PROJECT = Operation[UpdateHandoffReportProjectRequest, ProjectDescriptor]( - method="POST", - path="/v1/handoff-reports/projects/update", - operation_id="update_handoff_report_project", - request_type=UpdateHandoffReportProjectRequest, - request_location="body", - response_type=ProjectDescriptor, - success_status=200, - summary="Update a Handoff Report Project", - tags=("handoff-reports",), - responses={ - 200: { - "description": "The updated Report Project descriptor.", - "headers": {"X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}}, - }, - 404: {"$ref": "#/components/responses/NotFound"}, - 409: {"$ref": "#/components/responses/Conflict"}, - 401: {"$ref": "#/components/responses/Unauthorized"}, - 422: {"$ref": "#/components/responses/InvalidRequest"}, - 500: {"$ref": "#/components/responses/InternalError"}, - }, -) - -REGISTER_HANDOFF_REPORT_WORKSTREAM = Operation[RegisterHandoffReportWorkstreamRequest, WorkstreamDescriptor]( - method="POST", - path="/v1/handoff-reports/workstreams/register", - operation_id="register_handoff_report_workstream", - request_type=RegisterHandoffReportWorkstreamRequest, - request_location="body", - response_type=WorkstreamDescriptor, - success_status=201, - summary="Register a Handoff Report Workstream", - tags=("handoff-reports",), - responses={ - 201: { - "description": "The registered Report Workstream.", - "headers": {"X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}}, - }, - 404: {"$ref": "#/components/responses/NotFound"}, - 409: {"$ref": "#/components/responses/Conflict"}, - 401: {"$ref": "#/components/responses/Unauthorized"}, - 422: {"$ref": "#/components/responses/InvalidRequest"}, - 500: {"$ref": "#/components/responses/InternalError"}, - }, -) - -LIST_HANDOFF_REPORT_WORKSTREAMS = Operation[ListHandoffReportWorkstreamsRequest, WorkstreamPage]( - method="POST", - path="/v1/handoff-reports/workstreams/list", - operation_id="list_handoff_report_workstreams", - request_type=ListHandoffReportWorkstreamsRequest, - request_location="body", - response_type=WorkstreamPage, - success_status=200, - summary="List Handoff Report Workstreams", - tags=("handoff-reports",), - responses={ - 200: { - "description": "A cursor-paginated page of Report Workstreams.", - "headers": {"X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}}, - }, - 404: {"$ref": "#/components/responses/NotFound"}, - 401: {"$ref": "#/components/responses/Unauthorized"}, - 422: {"$ref": "#/components/responses/InvalidRequest"}, - 500: {"$ref": "#/components/responses/InternalError"}, - }, -) - -UPDATE_HANDOFF_REPORT_WORKSTREAM = Operation[UpdateHandoffReportWorkstreamRequest, WorkstreamDescriptor]( - method="POST", - path="/v1/handoff-reports/workstreams/update", - operation_id="update_handoff_report_workstream", - request_type=UpdateHandoffReportWorkstreamRequest, - request_location="body", - response_type=WorkstreamDescriptor, - success_status=200, - summary="Update a Handoff Report Workstream", - tags=("handoff-reports",), - responses={ - 200: { - "description": "The updated Report Workstream descriptor.", - "headers": {"X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}}, - }, - 404: {"$ref": "#/components/responses/NotFound"}, - 409: {"$ref": "#/components/responses/Conflict"}, - 401: {"$ref": "#/components/responses/Unauthorized"}, - 422: {"$ref": "#/components/responses/InvalidRequest"}, - 500: {"$ref": "#/components/responses/InternalError"}, - }, -) - GET_HANDOFF_REPORT = Operation[GetHandoffReportRequest, HandoffReportResponse]( method="POST", path="/v1/handoff-reports/get", @@ -1423,138 +1223,3 @@ class Operation(BaseModel, Generic[RequestT, ResponseT]): 500: {"$ref": "#/components/responses/InternalError"}, }, ) - -RECORD_HANDOFF_REPORT_ACTIVITY = Operation[RecordHandoffReportActivityRequest, StoredHandoffReportActivity]( - method="POST", - path="/v1/handoff-reports/activities/record", - operation_id="record_handoff_report_activity", - request_type=RecordHandoffReportActivityRequest, - request_location="body", - response_type=StoredHandoffReportActivity, - success_status=201, - summary="Record a Handoff Report Activity", - tags=("handoff-reports",), - responses={ - 201: { - "description": "The idempotently recorded Report Activity.", - "headers": {"X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}}, - }, - 404: {"$ref": "#/components/responses/NotFound"}, - 409: {"$ref": "#/components/responses/Conflict"}, - 401: {"$ref": "#/components/responses/Unauthorized"}, - 422: {"$ref": "#/components/responses/InvalidRequest"}, - 500: {"$ref": "#/components/responses/InternalError"}, - }, -) - -LIST_HANDOFF_REPORT_ACTIVITIES = Operation[ListHandoffReportActivitiesRequest, HandoffReportActivityPage]( - method="POST", - path="/v1/handoff-reports/activities/list", - operation_id="list_handoff_report_activities", - request_type=ListHandoffReportActivitiesRequest, - request_location="body", - response_type=HandoffReportActivityPage, - success_status=200, - summary="List Handoff Report Activities", - tags=("handoff-reports",), - responses={ - 200: { - "description": "A frozen cursor page of Report Activities.", - "headers": {"X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}}, - }, - 404: {"$ref": "#/components/responses/NotFound"}, - 401: {"$ref": "#/components/responses/Unauthorized"}, - 422: {"$ref": "#/components/responses/InvalidRequest"}, - 500: {"$ref": "#/components/responses/InternalError"}, - }, -) - -PURGE_HANDOFF_REPORT_ACTIVITIES = Operation[PurgeHandoffReportActivitiesRequest, PurgeHandoffReportActivitiesResponse]( - method="POST", - path="/v1/handoff-reports/activities/purge", - operation_id="purge_handoff_report_activities", - request_type=PurgeHandoffReportActivitiesRequest, - request_location="body", - response_type=PurgeHandoffReportActivitiesResponse, - success_status=200, - summary="Purge Handoff Report Activities", - tags=("handoff-reports",), - responses={ - 200: { - "description": "The number of deleted Report-owned Activity rows.", - "headers": {"X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}}, - }, - 404: {"$ref": "#/components/responses/NotFound"}, - 401: {"$ref": "#/components/responses/Unauthorized"}, - 422: {"$ref": "#/components/responses/InvalidRequest"}, - 500: {"$ref": "#/components/responses/InternalError"}, - }, -) - -GET_HANDOFF_REPORT_WORKSPACE = Operation[GetHandoffReportWorkspaceRequest, HandoffReportWorkspaceBinding]( - method="POST", - path="/v1/handoff-reports/workspace-bindings/get", - operation_id="get_handoff_report_workspace", - request_type=GetHandoffReportWorkspaceRequest, - request_location="body", - response_type=HandoffReportWorkspaceBinding, - success_status=200, - summary="Get a Handoff Report Workspace Binding", - tags=("handoff-reports",), - responses={ - 200: { - "description": "The confirmed Workspace binding.", - "headers": {"X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}}, - }, - 404: {"$ref": "#/components/responses/NotFound"}, - 401: {"$ref": "#/components/responses/Unauthorized"}, - 422: {"$ref": "#/components/responses/InvalidRequest"}, - 500: {"$ref": "#/components/responses/InternalError"}, - }, -) - -ATTACH_HANDOFF_REPORT_WORKSPACE = Operation[AttachHandoffReportWorkspaceRequest, HandoffReportWorkspaceBinding]( - method="POST", - path="/v1/handoff-reports/workspace-bindings/attach", - operation_id="attach_handoff_report_workspace", - request_type=AttachHandoffReportWorkspaceRequest, - request_location="body", - response_type=HandoffReportWorkspaceBinding, - success_status=200, - summary="Attach a Handoff Report Workspace Binding", - tags=("handoff-reports",), - responses={ - 200: { - "description": "The confirmed Workspace binding.", - "headers": {"X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}}, - }, - 404: {"$ref": "#/components/responses/NotFound"}, - 409: {"$ref": "#/components/responses/Conflict"}, - 401: {"$ref": "#/components/responses/Unauthorized"}, - 422: {"$ref": "#/components/responses/InvalidRequest"}, - 500: {"$ref": "#/components/responses/InternalError"}, - }, -) - -DETACH_HANDOFF_REPORT_WORKSPACE = Operation[DetachHandoffReportWorkspaceRequest, HandoffReportWorkspaceBinding]( - method="POST", - path="/v1/handoff-reports/workspace-bindings/detach", - operation_id="detach_handoff_report_workspace", - request_type=DetachHandoffReportWorkspaceRequest, - request_location="body", - response_type=HandoffReportWorkspaceBinding, - success_status=200, - summary="Detach a Handoff Report Workspace Binding", - tags=("handoff-reports",), - responses={ - 200: { - "description": "The detached Workspace binding record.", - "headers": {"X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}}, - }, - 404: {"$ref": "#/components/responses/NotFound"}, - 409: {"$ref": "#/components/responses/Conflict"}, - 401: {"$ref": "#/components/responses/Unauthorized"}, - 422: {"$ref": "#/components/responses/InvalidRequest"}, - 500: {"$ref": "#/components/responses/InternalError"}, - }, -) diff --git a/src/powercontext/http/_generated/schema.py b/src/powercontext/http/_generated/schema.py index 7fa5fd1ee..59f41e53a 100644 --- a/src/powercontext/http/_generated/schema.py +++ b/src/powercontext/http/_generated/schema.py @@ -1281,219 +1281,6 @@ }, } }, - "/v1/handoff-reports/projects/create": { - "post": { - "tags": ["handoff-reports"], - "summary": "Create a Handoff Report Project", - "operationId": "create_handoff_report_project", - "requestBody": { - "content": { - "application/json": { - "schema": {"$ref": "#/components/schemas/CreateHandoffReportProjectRequest"} - } - }, - "required": True, - }, - "responses": { - "201": { - "description": "The created Report Project.", - "headers": {"X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}}, - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ProjectDescriptor"}}}, - }, - "409": {"$ref": "#/components/responses/Conflict"}, - "401": {"$ref": "#/components/responses/Unauthorized"}, - "422": {"$ref": "#/components/responses/InvalidRequest"}, - "500": {"$ref": "#/components/responses/InternalError"}, - }, - } - }, - "/v1/handoff-reports/projects/list": { - "post": { - "tags": ["handoff-reports"], - "summary": "List Handoff Report Projects", - "operationId": "list_handoff_report_projects", - "requestBody": { - "content": { - "application/json": { - "schema": {"$ref": "#/components/schemas/ListHandoffReportProjectsRequest"} - } - }, - "required": True, - }, - "responses": { - "200": { - "description": "A cursor-paginated page of Report Projects.", - "headers": {"X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}}, - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ProjectPage"}}}, - }, - "401": {"$ref": "#/components/responses/Unauthorized"}, - "422": {"$ref": "#/components/responses/InvalidRequest"}, - "500": {"$ref": "#/components/responses/InternalError"}, - }, - } - }, - "/v1/handoff-reports/scopes/list-known": { - "post": { - "tags": ["handoff-reports"], - "summary": "List scopes that contain a committed Handoff", - "operationId": "list_handoff_report_known_scopes", - "requestBody": { - "content": { - "application/json": { - "schema": {"$ref": "#/components/schemas/ListHandoffReportKnownScopesRequest"} - } - }, - "required": True, - }, - "responses": { - "200": { - "description": "A cursor-paginated page of scopes that can be rendered as Handoff Reports.", - "headers": {"X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}}, - "content": { - "application/json": {"schema": {"$ref": "#/components/schemas/KnownHandoffScopePage"}} - }, - }, - "401": {"$ref": "#/components/responses/Unauthorized"}, - "422": {"$ref": "#/components/responses/InvalidRequest"}, - "500": {"$ref": "#/components/responses/InternalError"}, - }, - } - }, - "/v1/handoff-reports/projects/get": { - "post": { - "tags": ["handoff-reports"], - "summary": "Get a Handoff Report Project", - "operationId": "get_handoff_report_project", - "requestBody": { - "content": { - "application/json": {"schema": {"$ref": "#/components/schemas/GetHandoffReportProjectRequest"}} - }, - "required": True, - }, - "responses": { - "200": { - "description": "The exact current Report Project descriptor.", - "headers": {"X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}}, - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ProjectDescriptor"}}}, - }, - "404": {"$ref": "#/components/responses/NotFound"}, - "401": {"$ref": "#/components/responses/Unauthorized"}, - "422": {"$ref": "#/components/responses/InvalidRequest"}, - "500": {"$ref": "#/components/responses/InternalError"}, - }, - } - }, - "/v1/handoff-reports/projects/update": { - "post": { - "tags": ["handoff-reports"], - "summary": "Update a Handoff Report Project", - "operationId": "update_handoff_report_project", - "requestBody": { - "content": { - "application/json": { - "schema": {"$ref": "#/components/schemas/UpdateHandoffReportProjectRequest"} - } - }, - "required": True, - }, - "responses": { - "200": { - "description": "The updated Report Project descriptor.", - "headers": {"X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}}, - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ProjectDescriptor"}}}, - }, - "404": {"$ref": "#/components/responses/NotFound"}, - "409": {"$ref": "#/components/responses/Conflict"}, - "401": {"$ref": "#/components/responses/Unauthorized"}, - "422": {"$ref": "#/components/responses/InvalidRequest"}, - "500": {"$ref": "#/components/responses/InternalError"}, - }, - } - }, - "/v1/handoff-reports/workstreams/register": { - "post": { - "tags": ["handoff-reports"], - "summary": "Register a Handoff Report Workstream", - "operationId": "register_handoff_report_workstream", - "requestBody": { - "content": { - "application/json": { - "schema": {"$ref": "#/components/schemas/RegisterHandoffReportWorkstreamRequest"} - } - }, - "required": True, - }, - "responses": { - "201": { - "description": "The registered Report Workstream.", - "headers": {"X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}}, - "content": { - "application/json": {"schema": {"$ref": "#/components/schemas/WorkstreamDescriptor"}} - }, - }, - "404": {"$ref": "#/components/responses/NotFound"}, - "409": {"$ref": "#/components/responses/Conflict"}, - "401": {"$ref": "#/components/responses/Unauthorized"}, - "422": {"$ref": "#/components/responses/InvalidRequest"}, - "500": {"$ref": "#/components/responses/InternalError"}, - }, - } - }, - "/v1/handoff-reports/workstreams/list": { - "post": { - "tags": ["handoff-reports"], - "summary": "List Handoff Report Workstreams", - "operationId": "list_handoff_report_workstreams", - "requestBody": { - "content": { - "application/json": { - "schema": {"$ref": "#/components/schemas/ListHandoffReportWorkstreamsRequest"} - } - }, - "required": True, - }, - "responses": { - "200": { - "description": "A cursor-paginated page of Report Workstreams.", - "headers": {"X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}}, - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/WorkstreamPage"}}}, - }, - "404": {"$ref": "#/components/responses/NotFound"}, - "401": {"$ref": "#/components/responses/Unauthorized"}, - "422": {"$ref": "#/components/responses/InvalidRequest"}, - "500": {"$ref": "#/components/responses/InternalError"}, - }, - } - }, - "/v1/handoff-reports/workstreams/update": { - "post": { - "tags": ["handoff-reports"], - "summary": "Update a Handoff Report Workstream", - "operationId": "update_handoff_report_workstream", - "requestBody": { - "content": { - "application/json": { - "schema": {"$ref": "#/components/schemas/UpdateHandoffReportWorkstreamRequest"} - } - }, - "required": True, - }, - "responses": { - "200": { - "description": "The updated Report Workstream descriptor.", - "headers": {"X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}}, - "content": { - "application/json": {"schema": {"$ref": "#/components/schemas/WorkstreamDescriptor"}} - }, - }, - "404": {"$ref": "#/components/responses/NotFound"}, - "409": {"$ref": "#/components/responses/Conflict"}, - "401": {"$ref": "#/components/responses/Unauthorized"}, - "422": {"$ref": "#/components/responses/InvalidRequest"}, - "500": {"$ref": "#/components/responses/InternalError"}, - }, - } - }, "/v1/handoff-reports/get": { "post": { "tags": ["handoff-reports"], @@ -1541,185 +1328,6 @@ }, } }, - "/v1/handoff-reports/activities/record": { - "post": { - "tags": ["handoff-reports"], - "summary": "Record a Handoff Report Activity", - "operationId": "record_handoff_report_activity", - "requestBody": { - "content": { - "application/json": { - "schema": {"$ref": "#/components/schemas/RecordHandoffReportActivityRequest"} - } - }, - "required": True, - }, - "responses": { - "201": { - "description": "The idempotently recorded Report Activity.", - "headers": {"X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}}, - "content": { - "application/json": {"schema": {"$ref": "#/components/schemas/StoredHandoffReportActivity"}} - }, - }, - "404": {"$ref": "#/components/responses/NotFound"}, - "409": {"$ref": "#/components/responses/Conflict"}, - "401": {"$ref": "#/components/responses/Unauthorized"}, - "422": {"$ref": "#/components/responses/InvalidRequest"}, - "500": {"$ref": "#/components/responses/InternalError"}, - }, - } - }, - "/v1/handoff-reports/activities/list": { - "post": { - "tags": ["handoff-reports"], - "summary": "List Handoff Report Activities", - "operationId": "list_handoff_report_activities", - "requestBody": { - "content": { - "application/json": { - "schema": {"$ref": "#/components/schemas/ListHandoffReportActivitiesRequest"} - } - }, - "required": True, - }, - "responses": { - "200": { - "description": "A frozen cursor page of Report Activities.", - "headers": {"X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}}, - "content": { - "application/json": {"schema": {"$ref": "#/components/schemas/HandoffReportActivityPage"}} - }, - }, - "404": {"$ref": "#/components/responses/NotFound"}, - "401": {"$ref": "#/components/responses/Unauthorized"}, - "422": {"$ref": "#/components/responses/InvalidRequest"}, - "500": {"$ref": "#/components/responses/InternalError"}, - }, - } - }, - "/v1/handoff-reports/activities/purge": { - "post": { - "tags": ["handoff-reports"], - "summary": "Purge Handoff Report Activities", - "operationId": "purge_handoff_report_activities", - "requestBody": { - "content": { - "application/json": { - "schema": {"$ref": "#/components/schemas/PurgeHandoffReportActivitiesRequest"} - } - }, - "required": True, - }, - "responses": { - "200": { - "description": "The number of deleted Report-owned Activity rows.", - "headers": {"X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}}, - "content": { - "application/json": { - "schema": {"$ref": "#/components/schemas/PurgeHandoffReportActivitiesResponse"} - } - }, - }, - "404": {"$ref": "#/components/responses/NotFound"}, - "401": {"$ref": "#/components/responses/Unauthorized"}, - "422": {"$ref": "#/components/responses/InvalidRequest"}, - "500": {"$ref": "#/components/responses/InternalError"}, - }, - } - }, - "/v1/handoff-reports/workspace-bindings/get": { - "post": { - "tags": ["handoff-reports"], - "summary": "Get a Handoff Report Workspace Binding", - "operationId": "get_handoff_report_workspace", - "requestBody": { - "content": { - "application/json": { - "schema": {"$ref": "#/components/schemas/GetHandoffReportWorkspaceRequest"} - } - }, - "required": True, - }, - "responses": { - "200": { - "description": "The confirmed Workspace binding.", - "headers": {"X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}}, - "content": { - "application/json": { - "schema": {"$ref": "#/components/schemas/HandoffReportWorkspaceBinding"} - } - }, - }, - "404": {"$ref": "#/components/responses/NotFound"}, - "401": {"$ref": "#/components/responses/Unauthorized"}, - "422": {"$ref": "#/components/responses/InvalidRequest"}, - "500": {"$ref": "#/components/responses/InternalError"}, - }, - } - }, - "/v1/handoff-reports/workspace-bindings/attach": { - "post": { - "tags": ["handoff-reports"], - "summary": "Attach a Handoff Report Workspace Binding", - "operationId": "attach_handoff_report_workspace", - "requestBody": { - "content": { - "application/json": { - "schema": {"$ref": "#/components/schemas/AttachHandoffReportWorkspaceRequest"} - } - }, - "required": True, - }, - "responses": { - "200": { - "description": "The confirmed Workspace binding.", - "headers": {"X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}}, - "content": { - "application/json": { - "schema": {"$ref": "#/components/schemas/HandoffReportWorkspaceBinding"} - } - }, - }, - "404": {"$ref": "#/components/responses/NotFound"}, - "409": {"$ref": "#/components/responses/Conflict"}, - "401": {"$ref": "#/components/responses/Unauthorized"}, - "422": {"$ref": "#/components/responses/InvalidRequest"}, - "500": {"$ref": "#/components/responses/InternalError"}, - }, - } - }, - "/v1/handoff-reports/workspace-bindings/detach": { - "post": { - "tags": ["handoff-reports"], - "summary": "Detach a Handoff Report Workspace Binding", - "operationId": "detach_handoff_report_workspace", - "requestBody": { - "content": { - "application/json": { - "schema": {"$ref": "#/components/schemas/DetachHandoffReportWorkspaceRequest"} - } - }, - "required": True, - }, - "responses": { - "200": { - "description": "The detached Workspace binding record.", - "headers": {"X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}}, - "content": { - "application/json": { - "schema": {"$ref": "#/components/schemas/HandoffReportWorkspaceBinding"} - } - }, - }, - "404": {"$ref": "#/components/responses/NotFound"}, - "409": {"$ref": "#/components/responses/Conflict"}, - "401": {"$ref": "#/components/responses/Unauthorized"}, - "422": {"$ref": "#/components/responses/InvalidRequest"}, - "500": {"$ref": "#/components/responses/InternalError"}, - }, - } - }, }, "components": { "schemas": { @@ -3109,142 +2717,15 @@ "type": "object", "required": ["scope_id", "artifact"], }, - "CreateHandoffReportProjectRequest": { - "properties": { - "project_key": {"type": "string", "maxLength": 64, "minLength": 1}, - "title": {"type": "string", "maxLength": 256, "minLength": 1}, - "description": {"type": "string", "maxLength": 2000, "nullable": True}, - "default_locale": {"$ref": "#/components/schemas/ReportLocale", "default": "zh-CN"}, - "timezone": {"type": "string", "maxLength": 256, "minLength": 1, "default": "UTC"}, - }, - "additionalProperties": False, - "type": "object", - "required": ["project_key", "title"], - }, - "ListHandoffReportProjectsRequest": { - "properties": { - "cursor": {"type": "string", "nullable": True}, - "limit": {"type": "integer", "maximum": 100.0, "minimum": 1.0, "default": 50}, - "include_archived": {"type": "boolean", "default": False}, - }, - "additionalProperties": False, - "type": "object", - }, - "GetHandoffReportProjectRequest": { - "properties": {"project_id": {"type": "string", "maxLength": 256, "minLength": 1}}, - "additionalProperties": False, - "type": "object", - "required": ["project_id"], - }, - "UpdateHandoffReportProjectRequest": { - "properties": { - "project": {"$ref": "#/components/schemas/ProjectDescriptor"}, - "expected_version": {"type": "integer", "minimum": 1.0}, - }, - "additionalProperties": False, - "type": "object", - "required": ["project", "expected_version"], - }, - "RegisterHandoffReportWorkstreamRequest": { - "properties": { - "project_id": {"type": "string", "maxLength": 256, "minLength": 1}, - "scope_id": {"type": "string", "maxLength": 256, "minLength": 1}, - "key": {"type": "string", "maxLength": 64, "minLength": 1, "nullable": True}, - "title": {"type": "string", "maxLength": 256, "minLength": 1}, - "kind": {"$ref": "#/components/schemas/WorkstreamKind"}, - "catalog_state": {"$ref": "#/components/schemas/ReportCatalogState", "default": "included"}, - "external_refs": { - "items": {"$ref": "#/components/schemas/HandoffReportExternalReference"}, - "type": "array", - "maxItems": 32, - "default": [], - }, - "labels": { - "items": {"type": "string", "maxLength": 128, "minLength": 1}, - "type": "array", - "maxItems": 32, - "default": [], - }, - }, - "additionalProperties": False, - "type": "object", - "required": ["project_id", "scope_id", "title", "kind"], - }, - "ListHandoffReportWorkstreamsRequest": { - "properties": { - "project_id": {"type": "string", "maxLength": 256, "minLength": 1}, - "cursor": {"type": "string", "nullable": True}, - "limit": {"type": "integer", "maximum": 100.0, "minimum": 1.0, "default": 50}, - "include_archived": {"type": "boolean", "default": False}, - }, - "additionalProperties": False, - "type": "object", - "required": ["project_id"], - }, - "UpdateHandoffReportWorkstreamRequest": { - "properties": { - "workstream": {"$ref": "#/components/schemas/WorkstreamDescriptor"}, - "expected_version": {"type": "integer", "minimum": 1.0}, - }, - "additionalProperties": False, - "type": "object", - "required": ["workstream", "expected_version"], - }, "GetHandoffReportRequest": { "properties": { - "scope_id": {"type": "string", "maxLength": 256, "minLength": 1}, - "project_id": { - "type": "string", - "maxLength": 256, - "minLength": 1, - "description": "Retained for wire compatibility and ignored when generating a scope report.", - "deprecated": True, - "nullable": True, - }, - "locale": {"$ref": "#/components/schemas/ReportLocale", "nullable": True}, - "include_evidence_checks": {"type": "boolean", "default": True}, - "format": {"$ref": "#/components/schemas/ReportFormat", "default": "markdown"}, - "include_archived": {"type": "boolean", "default": False}, + "selection": {"$ref": "#/components/schemas/ScopeSelection"}, + "format": {"$ref": "#/components/schemas/ReportFormat", "default": "json"}, "download": {"type": "boolean", "default": False}, - "period": {"$ref": "#/components/schemas/HandoffReportPeriodRequest", "nullable": True}, - }, - "additionalProperties": False, - "type": "object", - "required": ["scope_id"], - }, - "ListHandoffReportKnownScopesRequest": { - "properties": { - "cursor": {"type": "string", "nullable": True}, - "limit": {"type": "integer", "maximum": 100.0, "minimum": 1.0, "default": 50}, - }, - "additionalProperties": False, - "type": "object", - }, - "KnownHandoffScope": { - "properties": {"scope_id": {"type": "string", "maxLength": 256, "minLength": 1}}, - "additionalProperties": False, - "type": "object", - "required": ["scope_id"], - }, - "KnownHandoffScopePage": { - "properties": { - "items": {"items": {"$ref": "#/components/schemas/KnownHandoffScope"}, "type": "array"}, - "next_cursor": {"type": "string", "nullable": True}, }, "additionalProperties": False, "type": "object", - "required": ["items"], - }, - "HandoffReportPeriodRequest": { - "properties": { - "start": {"type": "string", "format": "date-time"}, - "end": {"type": "string", "format": "date-time"}, - "timezone": {"type": "string", "maxLength": 256, "minLength": 1, "nullable": True}, - "compare_to_previous_period": {"type": "boolean", "default": False}, - }, - "additionalProperties": False, - "type": "object", - "required": ["start", "end"], + "required": ["selection"], }, "HandoffReportResponse": { "properties": { @@ -3258,326 +2739,7 @@ "type": "object", "required": ["format", "report", "markdown", "selection_digest", "report_digest"], }, - "ReportActivitySource": { - "type": "string", - "enum": ["handoff_observation", "git_commit", "git_worktree", "coding_session", "other"], - }, - "ReportTimeBasis": { - "type": "string", - "enum": ["source_reported", "host_observed", "first_seen", "current_only", "unknown"], - }, - "HandoffReportActivityAgent": { - "properties": { - "provider": {"type": "string", "maxLength": 64, "minLength": 1, "nullable": True}, - "label": {"type": "string", "maxLength": 128, "minLength": 1, "nullable": True}, - }, - "additionalProperties": False, - "type": "object", - }, - "HandoffReportActivityVcsContext": { - "properties": { - "branch": {"type": "string", "maxLength": 256, "minLength": 1, "nullable": True}, - "head_revision": {"type": "string", "maxLength": 256, "minLength": 1, "nullable": True}, - }, - "additionalProperties": False, - "type": "object", - }, - "RecordHandoffReportActivityRequest": { - "properties": { - "project_id": {"type": "string", "maxLength": 256, "minLength": 1}, - "scope_id": {"type": "string", "maxLength": 256, "minLength": 1, "nullable": True}, - "source": {"$ref": "#/components/schemas/ReportActivitySource"}, - "source_event_id": {"type": "string", "maxLength": 256, "minLength": 1}, - "source_ref": {"$ref": "#/components/schemas/HandoffReportExternalReference", "nullable": True}, - "occurred_at": {"type": "string", "format": "date-time", "nullable": True}, - "time_basis": {"$ref": "#/components/schemas/ReportTimeBasis"}, - "title": {"type": "string", "maxLength": 256, "minLength": 1, "nullable": True}, - "summary": {"type": "string", "maxLength": 2000, "minLength": 1, "nullable": True}, - "agent": {"$ref": "#/components/schemas/HandoffReportActivityAgent", "nullable": True}, - "session_id": {"type": "string", "maxLength": 256, "minLength": 1, "nullable": True}, - "vcs_context": {"$ref": "#/components/schemas/HandoffReportActivityVcsContext", "nullable": True}, - "evidence_refs": { - "items": {"$ref": "#/components/schemas/HandoffReportExternalReference"}, - "type": "array", - "maxItems": 32, - "default": [], - }, - }, - "additionalProperties": False, - "type": "object", - "required": ["project_id", "source", "source_event_id", "time_basis"], - }, - "HandoffReportActivity": { - "properties": { - "schema": {"type": "string", "enum": ["powercontext.handoff-report-activity.v1"]}, - "event_id": {"type": "string", "maxLength": 256, "minLength": 1}, - "project_id": {"type": "string", "maxLength": 256, "minLength": 1}, - "scope_id": {"type": "string", "maxLength": 256, "minLength": 1, "nullable": True}, - "source": {"$ref": "#/components/schemas/ReportActivitySource"}, - "source_event_id": {"type": "string", "maxLength": 256, "minLength": 1}, - "source_ref": {"$ref": "#/components/schemas/HandoffReportExternalReference", "nullable": True}, - "occurred_at": {"type": "string", "format": "date-time", "nullable": True}, - "observed_at": {"type": "string", "format": "date-time"}, - "time_basis": {"$ref": "#/components/schemas/ReportTimeBasis"}, - "title": {"type": "string", "maxLength": 256, "minLength": 1, "nullable": True}, - "summary": {"type": "string", "maxLength": 2000, "minLength": 1, "nullable": True}, - "agent": {"$ref": "#/components/schemas/HandoffReportActivityAgent", "nullable": True}, - "session_id": {"type": "string", "maxLength": 256, "minLength": 1, "nullable": True}, - "vcs_context": {"$ref": "#/components/schemas/HandoffReportActivityVcsContext", "nullable": True}, - "evidence_refs": { - "items": {"$ref": "#/components/schemas/HandoffReportExternalReference"}, - "type": "array", - "maxItems": 32, - }, - "trust": {"type": "string", "enum": ["untrusted_observation"]}, - }, - "additionalProperties": False, - "type": "object", - "required": [ - "schema", - "event_id", - "project_id", - "scope_id", - "source", - "source_event_id", - "source_ref", - "occurred_at", - "observed_at", - "time_basis", - "title", - "summary", - "agent", - "session_id", - "vcs_context", - "evidence_refs", - "trust", - ], - }, - "StoredHandoffReportActivity": { - "properties": { - "cursor": {"type": "integer", "minimum": 1.0}, - "event": {"$ref": "#/components/schemas/HandoffReportActivity"}, - }, - "additionalProperties": False, - "type": "object", - "required": ["cursor", "event"], - }, - "ListHandoffReportActivitiesRequest": { - "properties": { - "project_id": {"type": "string", "maxLength": 256, "minLength": 1}, - "period_start": {"type": "string", "format": "date-time", "nullable": True}, - "period_end": {"type": "string", "format": "date-time", "nullable": True}, - "sources": { - "items": {"$ref": "#/components/schemas/ReportActivitySource"}, - "type": "array", - "maxItems": 5, - "nullable": True, - }, - "after_cursor": {"type": "integer", "minimum": 0.0, "default": 0}, - "through_cursor": {"type": "integer", "minimum": 0.0, "nullable": True}, - "limit": {"type": "integer", "maximum": 100.0, "minimum": 1.0, "default": 50}, - }, - "additionalProperties": False, - "type": "object", - "required": ["project_id"], - }, - "HandoffReportActivityPage": { - "properties": { - "items": { - "items": {"$ref": "#/components/schemas/HandoffReportActivity"}, - "type": "array", - "maxItems": 100, - }, - "next_cursor": {"type": "integer", "minimum": 1.0, "nullable": True}, - "high_watermark": {"type": "integer", "minimum": 0.0}, - }, - "additionalProperties": False, - "type": "object", - "required": ["items", "next_cursor", "high_watermark"], - }, - "PurgeHandoffReportActivitiesRequest": { - "properties": { - "project_id": {"type": "string", "maxLength": 256, "minLength": 1}, - "observed_before": {"type": "string", "format": "date-time"}, - }, - "additionalProperties": False, - "type": "object", - "required": ["project_id", "observed_before"], - }, - "PurgeHandoffReportActivitiesResponse": { - "properties": {"deleted_count": {"type": "integer", "minimum": 0.0}}, - "additionalProperties": False, - "type": "object", - "required": ["deleted_count"], - }, - "HandoffReportRepositoryRef": { - "properties": { - "provider": {"type": "string", "enum": ["github", "gitlab", "local", "other"]}, - "repository_id": {"type": "string", "maxLength": 256, "minLength": 1, "nullable": True}, - "normalized_remote": {"type": "string", "maxLength": 2048, "minLength": 1, "nullable": True}, - "subpath": {"type": "string", "maxLength": 1024, "minLength": 1, "nullable": True}, - }, - "additionalProperties": False, - "type": "object", - "required": ["provider", "repository_id", "normalized_remote", "subpath"], - }, - "HandoffReportWorkspaceBinding": { - "properties": { - "schema": {"type": "string", "enum": ["powercontext.workspace-binding.v1"]}, - "workspace_instance_id": {"type": "string", "maxLength": 256, "minLength": 1}, - "project_id": {"type": "string", "maxLength": 256, "minLength": 1}, - "repository_ref": {"$ref": "#/components/schemas/HandoffReportRepositoryRef"}, - "state": {"type": "string", "enum": ["confirmed", "detached"]}, - "confirmed_at": {"type": "string", "format": "date-time"}, - "version": {"type": "integer", "minimum": 1.0}, - }, - "additionalProperties": False, - "type": "object", - "required": [ - "schema", - "workspace_instance_id", - "project_id", - "repository_ref", - "state", - "confirmed_at", - "version", - ], - }, - "GetHandoffReportWorkspaceRequest": { - "properties": {"workspace_instance_id": {"type": "string", "maxLength": 256, "minLength": 1}}, - "additionalProperties": False, - "type": "object", - "required": ["workspace_instance_id"], - }, - "AttachHandoffReportWorkspaceRequest": { - "properties": { - "workspace_instance_id": {"type": "string", "maxLength": 256, "minLength": 1}, - "project_id": {"type": "string", "maxLength": 256, "minLength": 1}, - "repository_ref": {"$ref": "#/components/schemas/HandoffReportRepositoryRef"}, - "expected_version": {"type": "integer", "minimum": 1.0, "nullable": True}, - }, - "additionalProperties": False, - "type": "object", - "required": ["workspace_instance_id", "project_id", "repository_ref", "expected_version"], - }, - "DetachHandoffReportWorkspaceRequest": { - "properties": { - "workspace_instance_id": {"type": "string", "maxLength": 256, "minLength": 1}, - "expected_version": {"type": "integer", "minimum": 1.0}, - }, - "additionalProperties": False, - "type": "object", - "required": ["workspace_instance_id", "expected_version"], - }, - "ProjectDescriptor": { - "properties": { - "schema": {"type": "string", "enum": ["powercontext.project.v1"]}, - "project_id": {"type": "string", "maxLength": 256, "minLength": 1}, - "project_key": {"type": "string", "maxLength": 64, "minLength": 1}, - "title": {"type": "string", "maxLength": 256, "minLength": 1}, - "description": {"type": "string", "maxLength": 2000, "nullable": True}, - "default_locale": {"$ref": "#/components/schemas/ReportLocale"}, - "timezone": {"type": "string", "maxLength": 256, "minLength": 1}, - "catalog_state": {"$ref": "#/components/schemas/ReportCatalogState"}, - "version": {"type": "integer", "minimum": 1.0}, - }, - "additionalProperties": False, - "type": "object", - "required": [ - "schema", - "project_id", - "project_key", - "title", - "description", - "default_locale", - "timezone", - "catalog_state", - "version", - ], - }, - "ProjectPage": { - "properties": { - "items": { - "items": {"$ref": "#/components/schemas/ProjectDescriptor"}, - "type": "array", - "maxItems": 100, - }, - "next_cursor": {"type": "string", "nullable": True}, - }, - "additionalProperties": False, - "type": "object", - "required": ["items", "next_cursor"], - }, - "WorkstreamDescriptor": { - "properties": { - "schema": {"type": "string", "enum": ["powercontext.workstream.v1"]}, - "scope_id": {"type": "string", "maxLength": 256, "minLength": 1}, - "project_id": {"type": "string", "maxLength": 256, "minLength": 1}, - "key": {"type": "string", "maxLength": 64, "nullable": True}, - "title": {"type": "string", "maxLength": 256, "minLength": 1}, - "kind": {"$ref": "#/components/schemas/WorkstreamKind"}, - "catalog_state": {"$ref": "#/components/schemas/ReportCatalogState"}, - "external_refs": { - "items": {"$ref": "#/components/schemas/HandoffReportExternalReference"}, - "type": "array", - "maxItems": 32, - }, - "labels": { - "items": {"type": "string", "maxLength": 128, "minLength": 1}, - "type": "array", - "maxItems": 32, - }, - "version": {"type": "integer", "minimum": 1.0}, - }, - "additionalProperties": False, - "type": "object", - "required": [ - "schema", - "scope_id", - "project_id", - "key", - "title", - "kind", - "catalog_state", - "external_refs", - "labels", - "version", - ], - }, - "WorkstreamPage": { - "properties": { - "items": { - "items": {"$ref": "#/components/schemas/WorkstreamDescriptor"}, - "type": "array", - "maxItems": 100, - }, - "next_cursor": {"type": "string", "nullable": True}, - }, - "additionalProperties": False, - "type": "object", - "required": ["items", "next_cursor"], - }, - "HandoffReportExternalReference": { - "properties": { - "kind": { - "type": "string", - "enum": ["issue", "task", "pull_request", "branch", "feature", "release", "program", "other"], - }, - "provider": {"type": "string", "maxLength": 64, "minLength": 1}, - "external_id": {"type": "string", "maxLength": 256, "minLength": 1}, - "url": {"type": "string", "maxLength": 2048, "nullable": True}, - }, - "additionalProperties": False, - "type": "object", - "required": ["kind", "provider", "external_id", "url"], - }, - "ReportLocale": {"type": "string", "enum": ["zh-CN", "en"]}, "ReportFormat": {"type": "string", "enum": ["json", "markdown"]}, - "ReportCatalogState": {"type": "string", "enum": ["included", "archived"]}, - "WorkstreamKind": { - "type": "string", - "enum": ["feature", "bug", "refactor", "operations", "research", "other"], - }, "HealthResponse": { "properties": {"status": {"type": "string"}}, "additionalProperties": False, diff --git a/src/powercontext/server/app.py b/src/powercontext/server/app.py index f125fc81f..c689b5864 100644 --- a/src/powercontext/server/app.py +++ b/src/powercontext/server/app.py @@ -22,11 +22,9 @@ from collections.abc import Awaitable, Callable, Sequence from contextlib import suppress from copy import deepcopy -from datetime import UTC, datetime from functools import wraps from time import perf_counter from typing import TYPE_CHECKING, Annotated, Any, Protocol, TypeVar, cast -from uuid import uuid4 from fastapi import Depends, FastAPI, Request, Response, status from fastapi.exceptions import RequestValidationError @@ -66,35 +64,9 @@ ) from powercontext.builtin.handoff_report import ( HandoffReportApplication, - HandoffReportBusyError, - HandoffReportCatalogArgumentError, HandoffReportError, HandoffReportInconsistentError, HandoffReportTooLargeError, - ProjectConflictError, - ProjectNotFoundError, - ReportPeriodInput, - ScopeAlreadyGroupedError, - WorkspaceBindingConflictError, - WorkspaceBindingNotFoundError, - WorkstreamConflictError, - WorkstreamNotFoundError, -) -from powercontext.builtin.handoff_report.models import ( - ExternalReference as ReportExternalReference, -) -from powercontext.builtin.handoff_report.models import ( - ProjectDescriptor as DomainProjectDescriptor, -) -from powercontext.builtin.handoff_report.models import ReportActivityEvent as DomainReportActivityEvent -from powercontext.builtin.handoff_report.models import RepositoryRef as DomainRepositoryRef -from powercontext.builtin.handoff_report.models import ( - WorkstreamDescriptor as DomainWorkstreamDescriptor, -) -from powercontext.builtin.handoff_report.repository import ( - ActivityEventConflictError, - InvalidActivityEventError, - InvalidActivityRepositoryArgumentError, ) from powercontext.builtin.inference.errors import InferenceTimeoutError, InferenceUnavailableError from powercontext.builtin.publication import ( @@ -249,7 +221,6 @@ ApproveArtifactCandidateRequest, ArtifactCandidate, ArtifactCandidatePage, - AttachHandoffReportWorkspaceRequest, Capabilities, CaptureContentSourceRequest, CaptureContentSourceResponse, @@ -258,10 +229,8 @@ CommitHandoffRequest, CommittedHandoff, ContinueHandoffRequest, - CreateHandoffReportProjectRequest, CreateScopeRequest, CreateWorkContractRequest, - DetachHandoffReportWorkspaceRequest, ErrorDetail, ErrorResponse, ExperienceArtifact, @@ -274,31 +243,20 @@ GenerateSkillRequest, GetArtifactCandidateRequest, GetExperienceRequest, - GetHandoffReportProjectRequest, GetHandoffReportRequest, - GetHandoffReportWorkspaceRequest, GetMemoryEntryRequest, GetScopeRequest, GetSkillRequest, GetStatsRequest, HandoffAcknowledgement, HandoffCurrentWorkRequest, - HandoffReportActivity, - HandoffReportActivityPage, HandoffReportResponse, - HandoffReportWorkspaceBinding, HandoffSelection, HealthResponse, ImportExternalSkillRequest, - KnownHandoffScope, - KnownHandoffScopePage, ListArtifactCandidatesRequest, ListExternalSkillsRequest, ListExternalSkillsResponse, - ListHandoffReportActivitiesRequest, - ListHandoffReportKnownScopesRequest, - ListHandoffReportProjectsRequest, - ListHandoffReportWorkstreamsRequest, ListMemoryChangesRequest, ListMemoryChangesResponse, ListMemoryEntriesRequest, @@ -309,18 +267,12 @@ PreparedContext, PreparedWorkHandoff, PrepareHandoffRequest, - ProjectDescriptor, - ProjectPage, ProposeExperienceRequest, ProposeSkillRequest, PublishArtifactRequest, - PurgeHandoffReportActivitiesRequest, - PurgeHandoffReportActivitiesResponse, ReadinessResponse, ReadinessStatus, - RecordHandoffReportActivityRequest, RecordTaskOutcomeRequest, - RegisterHandoffReportWorkstreamRequest, RejectArtifactCandidateRequest, RememberMemoryRequest, ResolveExternalSkillRequest, @@ -342,13 +294,8 @@ SetDefaultScopeRequest, SetScopeBindingRequest, SkillArtifact, - StoredHandoffReportActivity, - UpdateHandoffReportProjectRequest, - UpdateHandoffReportWorkstreamRequest, UpdateScopeRequest, WorkSourceReceipt, - WorkstreamDescriptor, - WorkstreamPage, ) from powercontext.http import ( ArtifactPublication as TransportArtifactPublication, @@ -372,15 +319,12 @@ API_TITLE, API_VERSION, APPROVE_ARTIFACT_CANDIDATE, - ATTACH_HANDOFF_REPORT_WORKSPACE, CAPTURE_CONTENT_SOURCE, CLEAR_SCOPE_BINDING, COMMIT_HANDOFF, CONTINUE_HANDOFF, - CREATE_HANDOFF_REPORT_PROJECT, CREATE_SCOPE, CREATE_WORK_CONTRACT, - DETACH_HANDOFF_REPORT_WORKSPACE, FINALIZE_HANDOFF, FLUSH_MEMORY, GENERATE_EXPERIENCE, @@ -390,8 +334,6 @@ GET_DEFAULT_SCOPE, GET_EXPERIENCE, GET_HANDOFF_REPORT, - GET_HANDOFF_REPORT_PROJECT, - GET_HANDOFF_REPORT_WORKSPACE, GET_LIVENESS, GET_MEMORY_ENTRY, GET_READINESS, @@ -402,10 +344,6 @@ IMPORT_EXTERNAL_SKILL, LIST_ARTIFACT_CANDIDATES, LIST_EXTERNAL_SKILLS, - LIST_HANDOFF_REPORT_ACTIVITIES, - LIST_HANDOFF_REPORT_KNOWN_SCOPES, - LIST_HANDOFF_REPORT_PROJECTS, - LIST_HANDOFF_REPORT_WORKSTREAMS, LIST_MEMORY_CHANGES, LIST_MEMORY_ENTRIES, LIST_SCOPES, @@ -415,10 +353,7 @@ PROPOSE_EXPERIENCE, PROPOSE_SKILL, PUBLISH_ARTIFACT, - PURGE_HANDOFF_REPORT_ACTIVITIES, - RECORD_HANDOFF_REPORT_ACTIVITY, RECORD_TASK_OUTCOME, - REGISTER_HANDOFF_REPORT_WORKSTREAM, REJECT_ARTIFACT_CANDIDATE, REMEMBER_MEMORY, RESOLVE_EXTERNAL_SKILL, @@ -431,8 +366,6 @@ SEARCH_MEMORY, SET_DEFAULT_SCOPE, SET_SCOPE_BINDING, - UPDATE_HANDOFF_REPORT_PROJECT, - UPDATE_HANDOFF_REPORT_WORKSTREAM, UPDATE_SCOPE, Operation, ) @@ -720,20 +653,6 @@ async def unexpected_error(request: Request, error: Exception) -> JSONResponse: _add_route(app, PUBLISH_ARTIFACT, publish_artifact) _add_route(app, GET_STATS, get_stats) if handoff_report_enabled: - _add_route(app, CREATE_HANDOFF_REPORT_PROJECT, create_handoff_report_project) - _add_route(app, GET_HANDOFF_REPORT_PROJECT, get_handoff_report_project) - _add_route(app, UPDATE_HANDOFF_REPORT_PROJECT, update_handoff_report_project) - _add_route(app, LIST_HANDOFF_REPORT_PROJECTS, list_handoff_report_projects) - _add_route(app, LIST_HANDOFF_REPORT_KNOWN_SCOPES, list_handoff_report_known_scopes) - _add_route(app, REGISTER_HANDOFF_REPORT_WORKSTREAM, register_handoff_report_workstream) - _add_route(app, LIST_HANDOFF_REPORT_WORKSTREAMS, list_handoff_report_workstreams) - _add_route(app, UPDATE_HANDOFF_REPORT_WORKSTREAM, update_handoff_report_workstream) - _add_route(app, RECORD_HANDOFF_REPORT_ACTIVITY, record_handoff_report_activity) - _add_route(app, LIST_HANDOFF_REPORT_ACTIVITIES, list_handoff_report_activities) - _add_route(app, PURGE_HANDOFF_REPORT_ACTIVITIES, purge_handoff_report_activities) - _add_route(app, GET_HANDOFF_REPORT_WORKSPACE, get_handoff_report_workspace) - _add_route(app, ATTACH_HANDOFF_REPORT_WORKSPACE, attach_handoff_report_workspace) - _add_route(app, DETACH_HANDOFF_REPORT_WORKSPACE, detach_handoff_report_workspace) _add_route(app, GET_HANDOFF_REPORT, get_handoff_report) _add_route(app, CAPTURE_CONTENT_SOURCE, capture_content_source) _add_route(app, FLUSH_MEMORY, flush_memory) @@ -942,204 +861,12 @@ async def get_stats( return mapping.statistics_response(result) -async def create_handoff_report_project( - request: CreateHandoffReportProjectRequest, - report: Annotated[HandoffReportApplication, Depends(_require_handoff_report_application)], -) -> ProjectDescriptor: - result = await report.create_project( - project_key=request.project_key, - title=request.title, - description=request.description, - default_locale=request.default_locale.value, - timezone=request.timezone, - ) - return _project_descriptor_response(result) - - -async def get_handoff_report_project( - request: GetHandoffReportProjectRequest, - report: Annotated[HandoffReportApplication, Depends(_require_handoff_report_application)], -) -> ProjectDescriptor: - return _project_descriptor_response(await report.get_project(request.project_id)) - - -async def update_handoff_report_project( - request: UpdateHandoffReportProjectRequest, - report: Annotated[HandoffReportApplication, Depends(_require_handoff_report_application)], -) -> ProjectDescriptor: - descriptor = DomainProjectDescriptor.model_validate_json(request.project.model_dump_json(by_alias=True)) - return _project_descriptor_response(await report.update_project(descriptor, request.expected_version)) - - -async def list_handoff_report_projects( - request: ListHandoffReportProjectsRequest, - report: Annotated[HandoffReportApplication, Depends(_require_handoff_report_application)], -) -> ProjectPage: - result = await report.list_projects( - cursor=request.cursor, - limit=request.limit, - include_archived=request.include_archived, - ) - return ProjectPage( - items=[_project_descriptor_response(item) for item in result.items], - next_cursor=result.next_cursor, - ) - - -async def list_handoff_report_known_scopes( - request: ListHandoffReportKnownScopesRequest, - report: Annotated[HandoffReportApplication, Depends(_require_handoff_report_application)], -) -> KnownHandoffScopePage: - result = await report.list_known_scopes(cursor=request.cursor, limit=request.limit) - return KnownHandoffScopePage( - items=[KnownHandoffScope(scope_id=scope_id) for scope_id in result.items], - next_cursor=result.next_cursor, - ) - - -async def register_handoff_report_workstream( - request: RegisterHandoffReportWorkstreamRequest, - report: Annotated[HandoffReportApplication, Depends(_require_handoff_report_application)], -) -> WorkstreamDescriptor: - values = request.model_dump(mode="json") - result = await report.register_workstream( - project_id=request.project_id, - scope_id=request.scope_id, - title=request.title, - kind=request.kind.value, - key=request.key, - catalog_state=request.catalog_state.value, - external_refs=tuple(ReportExternalReference.model_validate(value) for value in values["external_refs"]), - labels=tuple(str(value) for value in values["labels"]), - ) - return _workstream_descriptor_response(result) - - -async def list_handoff_report_workstreams( - request: ListHandoffReportWorkstreamsRequest, - report: Annotated[HandoffReportApplication, Depends(_require_handoff_report_application)], -) -> WorkstreamPage: - result = await report.list_workstreams( - request.project_id, - cursor=request.cursor, - limit=request.limit, - include_archived=request.include_archived, - ) - return WorkstreamPage( - items=[_workstream_descriptor_response(item) for item in result.items], - next_cursor=result.next_cursor, - ) - - -async def update_handoff_report_workstream( - request: UpdateHandoffReportWorkstreamRequest, - report: Annotated[HandoffReportApplication, Depends(_require_handoff_report_application)], -) -> WorkstreamDescriptor: - descriptor = DomainWorkstreamDescriptor.model_validate_json(request.workstream.model_dump_json(by_alias=True)) - return _workstream_descriptor_response(await report.update_workstream(descriptor, request.expected_version)) - - -async def record_handoff_report_activity( - request: RecordHandoffReportActivityRequest, - report: Annotated[HandoffReportApplication, Depends(_require_handoff_report_application)], -) -> StoredHandoffReportActivity: - values = request.model_dump(mode="json") - event = DomainReportActivityEvent.model_validate_json( - json.dumps({ - **values, - "event_id": f"evt_{uuid4().hex}", - "observed_at": datetime.now(UTC).isoformat(), - "trust": "untrusted_observation", - }) - ) - stored = await report.record_activity(event) - return StoredHandoffReportActivity( - cursor=stored.cursor, - event=HandoffReportActivity.model_validate(stored.payload), - ) - - -async def list_handoff_report_activities( - request: ListHandoffReportActivitiesRequest, - report: Annotated[HandoffReportApplication, Depends(_require_handoff_report_application)], -) -> HandoffReportActivityPage: - page = await report.list_activities( - request.project_id, - period_start=request.period_start, - period_end=request.period_end, - sources=None if request.sources is None else tuple(value.value for value in request.sources), - after_cursor=request.after_cursor, - through_cursor=request.through_cursor, - limit=request.limit, - ) - return HandoffReportActivityPage( - items=[ - HandoffReportActivity.model_validate(item.model_dump(mode="json", by_alias=True)) for item in page.items - ], - next_cursor=page.next_cursor, - high_watermark=page.high_watermark, - ) - - -async def purge_handoff_report_activities( - request: PurgeHandoffReportActivitiesRequest, - report: Annotated[HandoffReportApplication, Depends(_require_handoff_report_application)], -) -> PurgeHandoffReportActivitiesResponse: - deleted = await report.purge_activities(request.project_id, request.observed_before) - return PurgeHandoffReportActivitiesResponse(deleted_count=deleted) - - -async def get_handoff_report_workspace( - request: GetHandoffReportWorkspaceRequest, - report: Annotated[HandoffReportApplication, Depends(_require_handoff_report_application)], -) -> HandoffReportWorkspaceBinding: - binding = await report.get_workspace_binding(request.workspace_instance_id) - return HandoffReportWorkspaceBinding.model_validate(binding.model_dump(mode="json", by_alias=True)) - - -async def attach_handoff_report_workspace( - request: AttachHandoffReportWorkspaceRequest, - report: Annotated[HandoffReportApplication, Depends(_require_handoff_report_application)], -) -> HandoffReportWorkspaceBinding: - binding = await report.attach_workspace_binding( - workspace_instance_id=request.workspace_instance_id, - project_id=request.project_id, - repository_ref=DomainRepositoryRef.model_validate(request.repository_ref.model_dump(mode="json")), - expected_version=request.expected_version, - ) - return HandoffReportWorkspaceBinding.model_validate(binding.model_dump(mode="json", by_alias=True)) - - -async def detach_handoff_report_workspace( - request: DetachHandoffReportWorkspaceRequest, - report: Annotated[HandoffReportApplication, Depends(_require_handoff_report_application)], -) -> HandoffReportWorkspaceBinding: - binding = await report.detach_workspace_binding(request.workspace_instance_id, request.expected_version) - return HandoffReportWorkspaceBinding.model_validate(binding.model_dump(mode="json", by_alias=True)) - - async def get_handoff_report( request: GetHandoffReportRequest, response: Response, report: Annotated[HandoffReportApplication, Depends(_require_handoff_report_application)], ) -> HandoffReportResponse | Response: - result = await report.get_report( - request.scope_id, - locale=None if request.locale is None else request.locale.value, - include_evidence_checks=request.include_evidence_checks, - report_format=request.format.value, - include_archived=request.include_archived, - period=( - None - if request.period is None - else ReportPeriodInput( - start=request.period.start, - end=request.period.end, - timezone=request.period.timezone, - compare_to_previous_period=request.period.compare_to_previous_period, - ) - ), - ) + result = await report.get_report(_domain_scope_selection(request.selection)) selection_digest = cast(str, result.selection_digest) report_digest = cast(str, result.report_digest) response.headers["Cache-Control"] = "no-store" @@ -1189,8 +916,7 @@ def _require_report_size(estimated_bytes: int, report: Any) -> None: return raise HandoffReportTooLargeError( estimated_bytes=estimated_bytes, - selected_workstreams=report.coverage.selected_workstreams, - selected_activities=len(report.activity_selection), + selected_scopes=len(report.scopes), ) @@ -1555,14 +1281,6 @@ def _require_handoff_report_application(request: Request) -> HandoffReportApplic return application.handoff_report -def _project_descriptor_response(value: DomainProjectDescriptor) -> ProjectDescriptor: - return ProjectDescriptor.model_validate(value.model_dump(mode="json", by_alias=True)) - - -def _workstream_descriptor_response(value: DomainWorkstreamDescriptor) -> WorkstreamDescriptor: - return WorkstreamDescriptor.model_validate(value.model_dump(mode="json", by_alias=True)) - - def _scope_descriptor_response(value: DomainScopeDescriptor) -> ScopeDescriptor: return ScopeDescriptor.model_validate(value.model_dump(mode="json")) @@ -1845,46 +1563,14 @@ def _map_candidate_error(error: Exception) -> tuple[int, str, str, dict[str, Any def _map_report_error(error: Exception) -> tuple[int, str, str, dict[str, Any] | None] | None: - if isinstance(error, (ProjectNotFoundError, WorkstreamNotFoundError, WorkspaceBindingNotFoundError)): - return status.HTTP_404_NOT_FOUND, error.code, "The requested Handoff Report catalog value was not found.", None - if isinstance( - error, - (ProjectConflictError, WorkstreamConflictError, ScopeAlreadyGroupedError, WorkspaceBindingConflictError), - ): - details = { - name: getattr(error, name) - for name in ("expected_version", "current_version", "project_id", "scope_id", "workspace_instance_id") - if hasattr(error, name) - } - return ( - status.HTTP_409_CONFLICT, - error.code, - "The Handoff Report catalog value is stale or conflicting.", - details, - ) - if isinstance(error, ActivityEventConflictError): - return ( - status.HTTP_409_CONFLICT, - "activity_event_conflict", - "The Activity idempotency key already identifies different content.", - {"source": error.source, "source_event_id": error.source_event_id}, - ) - if isinstance(error, HandoffReportBusyError): - return ( - status.HTTP_409_CONFLICT, - "handoff_report_busy", - "Handoff heads changed while the report was being assembled.", - {"attempts": error.attempts}, - ) if isinstance(error, HandoffReportTooLargeError): return ( status.HTTP_413_CONTENT_TOO_LARGE, "handoff_report_too_large", - "The Handoff Report is too large; narrow the Workstream or Activity selection.", + "The Handoff Report is too large; narrow the Scope selection.", { "estimated_bytes": error.estimated_bytes, - "selected_workstreams": error.selected_workstreams, - "selected_activities": error.selected_activities, + "selected_scopes": error.selected_scopes, }, ) if isinstance(error, HandoffReportInconsistentError): @@ -1894,11 +1580,6 @@ def _map_report_error(error: Exception) -> tuple[int, str, str, dict[str, Any] | "The frozen Handoff selection could not be read consistently.", {"scope_id": error.scope_id}, ) - if isinstance( - error, - (HandoffReportCatalogArgumentError, InvalidActivityEventError, InvalidActivityRepositoryArgumentError), - ): - return status.HTTP_422_UNPROCESSABLE_CONTENT, "invalid_request", "The request is invalid.", None if isinstance(error, HandoffReportError): return status.HTTP_503_SERVICE_UNAVAILABLE, "handoff_report_unavailable", "Handoff Report is unavailable.", None return None diff --git a/src/powercontext/server/handoff_picker.py b/src/powercontext/server/handoff_picker.py deleted file mode 100644 index 9cebcbd02..000000000 --- a/src/powercontext/server/handoff_picker.py +++ /dev/null @@ -1,505 +0,0 @@ -# Copyright (c) 2026 OceanBase. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -# ruff: noqa: RUF001 -"""Interactive MCP selection for one Handoff Report Workstream.""" - -from __future__ import annotations - -from collections.abc import Callable, Sequence -from dataclasses import dataclass -from typing import Annotated, Generic, Literal, TypeVar, cast - -import httpx -from fastmcp import Context, FastMCP -from fastmcp.server.elicitation import AcceptedElicitation -from mcp.types import ClientCapabilities, ElicitationCapability, ToolAnnotations -from pydantic import BaseModel, ConfigDict, Field - -from powercontext.client import PowerContextClient -from powercontext.http import ( - ListHandoffReportProjectsRequest, - ListHandoffReportWorkstreamsRequest, - ProjectDescriptor, - WorkstreamDescriptor, -) - -PickerLocale = Literal["zh-CN", "en"] -PickerStatus = Literal["selected", "needs_selection", "empty", "cancelled", "declined"] -PickerStage = Literal["project", "workstream"] - -_MAX_PICKER_CHOICES = 100 -_FORM_ELICITATION_CAPABILITY = ClientCapabilities(elicitation=ElicitationCapability()) -_ChoiceT = TypeVar("_ChoiceT") - - -class HandoffProjectChoice(BaseModel): - """One project that can be used to narrow the Workstream picker.""" - - model_config = ConfigDict(extra="forbid", frozen=True) - - project_id: str - project_key: str - title: str - - -class HandoffWorkstreamChoice(BaseModel): - """One validated Workstream selection returned to an Agent.""" - - model_config = ConfigDict(extra="forbid", frozen=True) - - work_id: str - scope_id: str - project_id: str - project_key: str - title: str - kind: str - catalog_version: int - - -class HandoffWorkstreamSelection(BaseModel): - """Stable result for native and text-fallback selection flows.""" - - model_config = ConfigDict(extra="forbid", frozen=True) - - status: PickerStatus - message: str - stage: PickerStage | None = None - selected: HandoffWorkstreamChoice | None = None - project_choices: list[HandoffProjectChoice] = Field(default_factory=list, max_length=_MAX_PICKER_CHOICES) - workstream_choices: list[HandoffWorkstreamChoice] = Field(default_factory=list, max_length=_MAX_PICKER_CHOICES) - truncated: bool = False - - -def register_handoff_workstream_picker(server: FastMCP, http_client: httpx.AsyncClient) -> None: - """Register the Report-backed Workstream picker on an existing MCP server.""" - - # ``http_client`` is the Server's own in-process ASGI transport; ``http://fastapi`` is only a - # routing label, so vouch for it explicitly rather than have the loopback guard reject it. - picker = _HandoffWorkstreamPicker( - PowerContextClient("http://fastapi", http_client=http_client, trust_transport_security=True) - ) - server.tool( - picker.select, - name="select_handoff_workstream", - title="Select Handoff Workstream", - description=( - "Select one Report Workstream before handoff_current_work or continue_handoff. " - "Uses a native MCP picker when supported and returns validated structured choices otherwise." - ), - annotations=ToolAnnotations( - readOnlyHint=True, - destructiveHint=False, - idempotentHint=True, - openWorldHint=False, - ), - ) - - -class _HandoffWorkstreamPicker: - def __init__(self, client: PowerContextClient) -> None: - self._client = client - - async def select( - self, - ctx: Context, - project_id: Annotated[ - str | None, - Field( - default=None, - min_length=1, - max_length=256, - pattern=r".*\S.*", - description="Exact Report Project ID. Omit it to choose a Project interactively.", - ), - ] = None, - work_id: Annotated[ - str | None, - Field( - default=None, - min_length=1, - max_length=256, - pattern=r".*\S.*", - description="Workstream key or scope ID returned by an earlier picker result.", - ), - ] = None, - query: Annotated[ - str | None, - Field( - default=None, - min_length=1, - max_length=256, - pattern=r".*\S.*", - description="Optional case-insensitive filter over Workstream title, key, scope, kind, and labels.", - ), - ] = None, - include_archived: Annotated[ - bool, - Field(description="Include archived Projects and Workstreams in the choices."), - ] = False, - locale: Annotated[ - PickerLocale, - Field(description="Language used for picker prompts and result messages."), - ] = "zh-CN", - ) -> HandoffWorkstreamSelection: - """Select a catalog Workstream before handing off or continuing work. - - A client with MCP form elicitation gets a native picker. Other clients - receive structured choices and can call this tool again with project_id - and work_id. Selecting a Workstream never creates or commits a Handoff. - """ - - projects, projects_truncated = await _list_projects(self._client, include_archived=include_archived) - if not projects: - return _selection(status="empty", locale=locale, message_key="no_projects") - - project_result = await _choose_project( - ctx, - projects=projects, - project_id=project_id, - locale=locale, - truncated=projects_truncated, - ) - if isinstance(project_result, HandoffWorkstreamSelection): - return project_result - project = project_result - - workstreams, workstreams_truncated = await _list_workstreams( - self._client, - project.project_id, - include_archived=include_archived, - ) - filtered_workstreams = _filter_workstreams(workstreams, query) - choices = [_workstream_choice(item, project) for item in filtered_workstreams] - truncated = projects_truncated or workstreams_truncated - if not choices: - return _selection( - status="empty", - locale=locale, - message_key="no_matching_workstreams" if query else "no_workstreams", - stage="workstream", - truncated=truncated, - ) - - return await _choose_workstream( - ctx, - choices=choices, - work_id=work_id, - locale=locale, - truncated=truncated, - ) - - -@dataclass(frozen=True, slots=True) -class _ElicitedChoice(Generic[_ChoiceT]): - status: Literal["selected", "cancelled", "declined"] - value: _ChoiceT | None - - -async def _elicit_choice( - ctx: Context, - *, - message: str, - response_title: str, - values: Sequence[_ChoiceT], - title: Callable[[_ChoiceT], str], -) -> _ElicitedChoice[_ChoiceT]: - option_values = {f"option-{index + 1}": value for index, value in enumerate(values)} - options = {token: {"title": title(value)} for token, value in option_values.items()} - result = await ctx.elicit( - message, - options, - response_title=response_title, - ) - if not isinstance(result, AcceptedElicitation): - status: Literal["cancelled", "declined"] = "cancelled" if result.action == "cancel" else "declined" - return _ElicitedChoice(status=status, value=None) - selected_token = str(result.data) - return _ElicitedChoice(status="selected", value=option_values[selected_token]) - - -async def _choose_project( - ctx: Context, - *, - projects: Sequence[ProjectDescriptor], - project_id: str | None, - locale: PickerLocale, - truncated: bool, -) -> ProjectDescriptor | HandoffWorkstreamSelection: - project = _project_by_id(projects, project_id) - if project_id is not None and project is None: - return _selection( - status="needs_selection", - locale=locale, - message_key="project_not_found", - stage="project", - project_choices=[_project_choice(item) for item in projects], - truncated=truncated, - ) - if project is not None: - return project - if len(projects) == 1: - return projects[0] - if not _supports_form_elicitation(ctx): - return _selection( - status="needs_selection", - locale=locale, - message_key="choose_project_fallback", - stage="project", - project_choices=[_project_choice(item) for item in projects], - truncated=truncated, - ) - result = await _elicit_choice( - ctx, - message=_copy(locale, "choose_project"), - response_title=_copy(locale, "project_field"), - values=projects, - title=_project_title, - ) - if result.status != "selected": - return _selection( - status=result.status, - locale=locale, - message_key=result.status, - stage="project", - ) - return cast(ProjectDescriptor, result.value) - - -async def _choose_workstream( - ctx: Context, - *, - choices: Sequence[HandoffWorkstreamChoice], - work_id: str | None, - locale: PickerLocale, - truncated: bool, -) -> HandoffWorkstreamSelection: - selected = _workstream_by_id(choices, work_id) - if work_id is not None and selected is None: - return _selection( - status="needs_selection", - locale=locale, - message_key="workstream_not_found", - stage="workstream", - workstream_choices=list(choices), - truncated=truncated, - ) - if selected is None and len(choices) == 1: - selected = choices[0] - if selected is None and not _supports_form_elicitation(ctx): - return _selection( - status="needs_selection", - locale=locale, - message_key="choose_workstream_fallback", - stage="workstream", - workstream_choices=list(choices), - truncated=truncated, - ) - if selected is None: - result = await _elicit_choice( - ctx, - message=_copy(locale, "choose_workstream"), - response_title=_copy(locale, "workstream_field"), - values=choices, - title=_workstream_title, - ) - if result.status != "selected": - return _selection( - status=result.status, - locale=locale, - message_key=result.status, - stage="workstream", - ) - selected = cast(HandoffWorkstreamChoice, result.value) - return _selection( - status="selected", - locale=locale, - message_key="selected", - selected=selected, - truncated=truncated, - ) - - -async def _list_projects( - client: PowerContextClient, - *, - include_archived: bool, -) -> tuple[list[ProjectDescriptor], bool]: - page = await client.list_handoff_report_projects( - ListHandoffReportProjectsRequest( - limit=_MAX_PICKER_CHOICES, - include_archived=include_archived, - ) - ) - return page.items, page.next_cursor is not None - - -async def _list_workstreams( - client: PowerContextClient, - project_id: str, - *, - include_archived: bool, -) -> tuple[list[WorkstreamDescriptor], bool]: - page = await client.list_handoff_report_workstreams( - ListHandoffReportWorkstreamsRequest( - project_id=project_id, - limit=_MAX_PICKER_CHOICES, - include_archived=include_archived, - ) - ) - return page.items, page.next_cursor is not None - - -def _supports_form_elicitation(ctx: Context) -> bool: - return ctx.session.check_client_capability(_FORM_ELICITATION_CAPABILITY) - - -def _project_by_id(projects: Sequence[ProjectDescriptor], project_id: str | None) -> ProjectDescriptor | None: - if project_id is None: - return None - return next((project for project in projects if project.project_id == project_id), None) - - -def _workstream_by_id( - choices: Sequence[HandoffWorkstreamChoice], - work_id: str | None, -) -> HandoffWorkstreamChoice | None: - if work_id is None: - return None - matches = [choice for choice in choices if choice.work_id == work_id or choice.scope_id == work_id] - return matches[0] if len(matches) == 1 else None - - -def _filter_workstreams( - workstreams: Sequence[WorkstreamDescriptor], - query: str | None, -) -> list[WorkstreamDescriptor]: - normalized_query = "" if query is None else query.strip().casefold() - if not normalized_query: - return list(workstreams) - return [workstream for workstream in workstreams if normalized_query in _workstream_search_text(workstream)] - - -def _workstream_search_text(workstream: WorkstreamDescriptor) -> str: - values = ( - workstream.title, - workstream.key or "", - workstream.scope_id, - str(workstream.kind), - *(label.root for label in workstream.labels), - ) - return "\n".join(values).casefold() - - -def _project_choice(project: ProjectDescriptor) -> HandoffProjectChoice: - return HandoffProjectChoice( - project_id=project.project_id, - project_key=project.project_key, - title=project.title, - ) - - -def _workstream_choice( - workstream: WorkstreamDescriptor, - project: ProjectDescriptor, -) -> HandoffWorkstreamChoice: - return HandoffWorkstreamChoice( - work_id=workstream.key or workstream.scope_id, - scope_id=workstream.scope_id, - project_id=project.project_id, - project_key=project.project_key, - title=workstream.title, - kind=str(workstream.kind), - catalog_version=workstream.version, - ) - - -def _project_title(project: ProjectDescriptor) -> str: - return f"{project.title} · {project.project_key}" - - -def _workstream_title(workstream: HandoffWorkstreamChoice) -> str: - return f"{workstream.title} · {workstream.work_id} · {workstream.kind}" - - -def _selection( - *, - status: PickerStatus, - locale: PickerLocale, - message_key: str, - stage: PickerStage | None = None, - selected: HandoffWorkstreamChoice | None = None, - project_choices: list[HandoffProjectChoice] | None = None, - workstream_choices: list[HandoffWorkstreamChoice] | None = None, - truncated: bool = False, -) -> HandoffWorkstreamSelection: - return HandoffWorkstreamSelection( - status=status, - message=_copy(locale, message_key), - stage=stage, - selected=selected, - project_choices=[] if project_choices is None else project_choices, - workstream_choices=[] if workstream_choices is None else workstream_choices, - truncated=truncated, - ) - - -def _copy(locale: PickerLocale, key: str) -> str: - return _COPY[locale][key] - - -_COPY: dict[PickerLocale, dict[str, str]] = { - "zh-CN": { - "cancelled": "已取消工作选择,未产生任何交接写入。", - "choose_project": "选择这次交接所属的项目。", - "choose_project_fallback": "当前客户端不支持原生选择框,请从 project_choices 选择并重新调用。", - "choose_workstream": "选择要交接或继续的工作。", - "choose_workstream_fallback": "当前客户端不支持原生选择框,请从 workstream_choices 选择并重新调用。", - "declined": "已拒绝工作选择,未产生任何交接写入。", - "no_matching_workstreams": "没有与查询条件匹配的工作。", - "no_projects": "没有可供选择的交接项目。", - "no_workstreams": "所选项目中没有可供选择的工作。", - "project_field": "项目", - "project_not_found": "找不到指定项目,请从 project_choices 重新选择。", - "selected": "已选择工作;此操作尚未创建或提交交接。", - "workstream_field": "工作", - "workstream_not_found": "找不到指定工作,请从 workstream_choices 重新选择。", - }, - "en": { - "cancelled": "Work selection was cancelled; no Handoff data was written.", - "choose_project": "Choose the Project that owns this Handoff.", - "choose_project_fallback": "This client has no native picker; choose from project_choices and call again.", - "choose_workstream": "Choose the work to hand off or continue.", - "choose_workstream_fallback": ( - "This client has no native picker; choose from workstream_choices and call again." - ), - "declined": "Work selection was declined; no Handoff data was written.", - "no_matching_workstreams": "No work matches the query.", - "no_projects": "No Handoff Projects are available.", - "no_workstreams": "The selected Project has no available work.", - "project_field": "Project", - "project_not_found": "The requested Project was not found; choose from project_choices.", - "selected": "Work selected; this operation has not created or committed a Handoff.", - "workstream_field": "Work", - "workstream_not_found": "The requested work was not found; choose from workstream_choices.", - }, -} - - -__all__ = [ - "HandoffProjectChoice", - "HandoffWorkstreamChoice", - "HandoffWorkstreamSelection", - "register_handoff_workstream_picker", -] diff --git a/src/powercontext/server/mcp.py b/src/powercontext/server/mcp.py index b18dfaae4..f289884bc 100644 --- a/src/powercontext/server/mcp.py +++ b/src/powercontext/server/mcp.py @@ -44,14 +44,10 @@ FINALIZE_HANDOFF, GET_ARTIFACT_CANDIDATE, GET_HANDOFF_REPORT, - GET_HANDOFF_REPORT_WORKSPACE, GET_MEMORY_ENTRY, GET_SCOPE, HANDOFF_CURRENT_WORK, LIST_ARTIFACT_CANDIDATES, - LIST_HANDOFF_REPORT_KNOWN_SCOPES, - LIST_HANDOFF_REPORT_PROJECTS, - LIST_HANDOFF_REPORT_WORKSTREAMS, LIST_MEMORY_ENTRIES, LIST_SCOPES, PUBLISH_ARTIFACT, @@ -71,7 +67,6 @@ current_request_id, reset_internal_bridge, ) -from powercontext.server.handoff_picker import register_handoff_workstream_picker from powercontext.server.metrics import McpMetricsMiddleware, ServerMetrics from powercontext.server.tracing import McpTracingMiddleware, ServerTracing @@ -93,8 +88,6 @@ REMEMBER_MEMORY.operation_id, REVISE_MEMORY_ENTRY.operation_id, GET_HANDOFF_REPORT.operation_id, - LIST_HANDOFF_REPORT_KNOWN_SCOPES.operation_id, - GET_HANDOFF_REPORT_WORKSPACE.operation_id, RETIRE_MEMORY_ENTRY.operation_id, LIST_ARTIFACT_CANDIDATES.operation_id, GET_ARTIFACT_CANDIDATE.operation_id, @@ -114,8 +107,6 @@ LIST_MEMORY_ENTRIES.operation_id, GET_MEMORY_ENTRY.operation_id, GET_HANDOFF_REPORT.operation_id, - LIST_HANDOFF_REPORT_KNOWN_SCOPES.operation_id, - GET_HANDOFF_REPORT_WORKSPACE.operation_id, LIST_ARTIFACT_CANDIDATES.operation_id, GET_ARTIFACT_CANDIDATE.operation_id, LIST_SCOPES.operation_id, @@ -141,6 +132,7 @@ def _annotate_mcp_component( component.annotations = ToolAnnotations( readOnlyHint=True, destructiveHint=False, + idempotentHint=True, openWorldHint=False, ) elif route.operation_id == HANDOFF_CURRENT_WORK.operation_id: @@ -183,11 +175,6 @@ def create_mcp_server( validate_output=False, ) server = FastMCP(name=MCP_SERVER_NAME, providers=[provider]) - if { - LIST_HANDOFF_REPORT_PROJECTS.path, - LIST_HANDOFF_REPORT_WORKSTREAMS.path, - }.issubset(server_app.openapi()["paths"]): - register_handoff_workstream_picker(server, client) server.add_middleware(McpTracingMiddleware(resolved_tracing)) if access_log: server.add_middleware(McpAccessLogMiddleware()) diff --git a/src/powercontext/server/static/dashboard.js b/src/powercontext/server/static/dashboard.js index 93232d626..5eacf73dd 100644 --- a/src/powercontext/server/static/dashboard.js +++ b/src/powercontext/server/static/dashboard.js @@ -23,6 +23,7 @@ import { storeServerToken } from "./auth.js?v=optional-auth"; import {createPageUi, createRequestGate} from "./page-ui.js?v=locale-complete"; +import {buildScopeSelectionChoices} from "./scope-selection.js?v=selection-v1"; const translations = { en: { @@ -236,7 +237,7 @@ async function authenticate(token, scopeId = "") { showPageStatus("noScopes", {}, true); return; } - const choices = selectionChoices(currentScopes); + const choices = buildScopeSelectionChoices(currentScopes, translate); const selectedKey = choices.some((choice) => choice.key === scopeId) ? scopeId : "all"; currentScopeId = selectedKey; await loadStatistics(token, selectedKey, request); @@ -261,7 +262,7 @@ async function loadStatistics(token, scopeId, request = null) { currentScopeId = scopeId; scopeSelect.disabled = true; try { - const choice = selectionChoices(currentScopes).find((item) => item.key === scopeId); + const choice = buildScopeSelectionChoices(currentScopes, translate).find((item) => item.key === scopeId); if (!choice) { showPageStatus("scopeUnavailable", {}, true); return; @@ -376,7 +377,7 @@ function renderDashboard(view) { function renderScopes(scopes, selectedKey) { scopeSelect.replaceChildren(); - for (const choice of selectionChoices(scopes)) { + for (const choice of buildScopeSelectionChoices(scopes, translate)) { const option = document.createElement("option"); option.value = choice.key; option.textContent = choice.label; @@ -385,25 +386,6 @@ function renderScopes(scopes, selectedKey) { } } -function selectionChoices(scopes) { - const choices = [{key: "all", label: translate("allScopes"), selection: {mode: "all"}}]; - for (const scope of scopes.filter((item) => item.parent_scope_id === null)) { - choices.push({ - key: `subtree:${scope.scope_id}`, - label: translate("subtreeView", {title: scope.display_name}), - selection: {mode: "subtree", root_scope_id: scope.scope_id} - }); - } - for (const scope of scopes) { - choices.push({ - key: `exact:${scope.scope_id}`, - label: translate("exactFocus", {title: scope.display_name}), - selection: {mode: "exact", scope_ids: [scope.scope_id]} - }); - } - return choices; -} - function renderArtifactFamilies(inventory) { const rows = document.getElementById("family-rows"); rows.replaceChildren(); diff --git a/src/powercontext/server/static/handoff-report.js b/src/powercontext/server/static/handoff-report.js index 8d0cac06b..6f4bac42f 100644 --- a/src/powercontext/server/static/handoff-report.js +++ b/src/powercontext/server/static/handoff-report.js @@ -3,15 +3,6 @@ * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. */ "use strict"; @@ -22,16 +13,9 @@ import { readServerToken, storeServerToken } from "./auth.js?v=optional-auth"; -import {formatDateRange, resolvePeriodSelection, validateDateRange} from "./handoff-period.js"; import {createPageUi, createRequestGate} from "./page-ui.js?v=locale-complete"; +import {buildScopeSelectionChoices} from "./scope-selection.js?v=selection-v1"; -const selectedProjectKey = "powercontext.handoff-report.project"; -const selectedWorkKey = "powercontext.handoff-report.work"; -const autoRefreshIntervalMilliseconds = 5_000; -const continuityTimelineRecentLimit = 6; -const workstreamSearchThreshold = 8; -const projectOptionRenderLimit = 50; -const authenticationRequired = document.documentElement.dataset.serverAuthRequired === "true"; const translations = { en: { pageTitle: "PowerContext Handoff Report", @@ -41,7 +25,6 @@ const translations = { handoffReportTitle: "Handoff Report", brandHomeLabel: "PowerContext Dashboard", primaryNavigation: "Primary navigation", - handoffSummary: "Handoff summary", maintainedBy: "Maintained by OceanBase.", signOut: "Sign out", authTitle: "Connect to PowerContext", @@ -50,200 +33,52 @@ const translations = { continue: "Continue", refresh: "Refresh", downloadMarkdown: "Download Markdown", - projects: "Scopes", - searchProjectsPlaceholder: "Search by scope ID", - projectSearchCount: "{count} scopes", - projectSearchMatches: "{count} matching scopes", - projectSearchLimited: "Showing the first {shown} of {total} matches. Keep typing to narrow the list.", - noMatchingProjects: "No scopes match this search.", - reportPeriod: "Report period", - activity: "Activity", - activitySubtitle: "Period controls affect Activity counts only. Handoff status stays on the current exact selection.", - activityPeriod: "Activity period", - activityByWorkstream: "Activity by Workstream", - day: "Day", - week: "Week", - month: "Month", - custom: "Custom", - periodStart: "Start date", - periodEnd: "End date", - apply: "Apply", - periodSummary: "{preset} / {range} / {timezone}", - periodComparison: "Activity: {current} current / {previous} previous / {delta} change", - periodBoundaryUnavailable: "Handoff status uses the current exact selection; this period filters Activity but cannot reconstruct historical Handoff boundaries.", - periodDatesRequired: "Select both a start date and an end date.", - periodInvalidRange: "The start date must not be later than the end date.", + scopeHandoffState: "Scope Handoff state", + reportDescription: "An exact Handoff projection over the selected Scope view.", + scopeView: "Scope view", + scopeViewDescription: "All, subtree, and exact use the same selection semantics as Dashboard.", + selectScope: "Scope", + allScopes: "All", + subtreeView: "{title} and descendants", + exactFocus: "Focus: {title}", + handoffSummary: "Handoff summary", continuable: "Continuable", blocked: "Blocked", complete: "Complete", noHandoff: "No Handoff", - coverage: "Report coverage", - workstreams: "Workstreams", - activities: "Activities", - evidenceUnavailable: "Evidence unavailable", - blockers: "Blockers", - blockersSubtitle: "Workstreams that cannot be continued without intervention", - workstreamsSubtitle: "Exact Handoff state and the next action for each scope", - workstreamNavigation: "Workstream navigation", - workstreamPagination: "Workstream pagination", - searchWorkstreams: "Search Workstreams", - searchWorkstreamsPlaceholder: "Search by name or scope", - previousWorkstream: "Previous", - nextWorkstream: "Next", - workstreamPosition: "{current} / {total}", - workstreamMatchCount: "{count} matches", - noMatchingWorkstreams: "No Workstreams match this search.", - workstream: "Workstream", + no_handoff: "No Handoff", + selectedScopes: "Selected Scopes", + selectedScopesDescription: "Parent describes organization; each row reports only that Scope's exact latest Handoff.", + scope: "Scope", + parent: "Parent", status: "Status", - reporting: "Reporting", + objective: "Objective", nextAction: "Next action", - state: "Current state", - next_action: "Next action", - available: "Available", - unavailable: "Unavailable", - noWorkstreams: "No Workstreams are registered for this Project.", - handoffContents: "Handoff content", - handoffContentsSubtitle: "Edit the Handoff as one document. Every save creates a new Handoff Revision.", - currentSnapshot: "Current Handoff snapshot", exactRevision: "Exact Revision", - editHandoffContent: "Edit", - editHandoffContentLabel: "Edit all Handoff content", - cancelEdit: "Cancel", - saveRevision: "Save Revision", - createFirstRevision: "Create first Revision", - firstRevisionNote: "The first Revision needs an Objective and at least one Current state item.", - emptyField: "Not provided.", - savingRevision: "Saving a new Handoff Revision...", - revisionSaved: "Saved as Handoff Revision @{revision}.", - revisionSaveFailed: "The Handoff Revision could not be saved (HTTP {status}).", - objective: "Objective", - currentState: "Current state", - omissions: "Known omissions", - currentStateLines: "Current state, one item per line", - disposition: "Disposition", - omissionLines: "Known omissions, one item per line", - liveStateCheck: "Live workspace", - capabilityCheck: "Capability", - authorizationCheck: "Authorization", - notChecked: "Not checked", - confirmed: "Confirmed", - mismatch: "Mismatch", - insufficient: "Insufficient", - receiverIdentity: "Receiver identity", - continuityTimeline: "Continuity timeline", - continuityOrderNote: "Ordered by Source journal position, not wall-clock time.", - revisionHistory: "Handoff Revision history", - revisionHistorySummary: "{total} Revisions. Latest first.", - revisionHistoryTruncated: "Showing the latest {shown} of {total} Revisions.", - revisionHistoryEmpty: "No committed Handoff Revisions are available.", - revisionCurrent: "Current", - revisionNextAction: "Next: {value}", - revisionCounts: "{state} state items / {omissions} omissions", - transferState: "Transfer", - outcomeState: "Outcome", - editorRequired: "Objective and at least one current-state item are required.", - timelineEmpty: "No high-level Work continuity records are available for this scope.", - timelineTruncated: "Only the latest {count} of {total} continuity events are shown.", - timelineInvalid: "{count} Work record(s) could not be read and were excluded.", - timelineShowEarlier: "Show {count} earlier events", - timelineShowRecent: "Show latest {count}", - eventSource: "Source", - eventRevision: "Handoff Revision", - eventReceipt: "Receipt", - eventReceiverChecks: "Receiver checks", - eventSchema: "Record schema", - eventNoDetails: "No additional details were recorded.", - autoRefreshActive: "Auto-refresh every 5 seconds", - autoRefreshEditing: "Auto-refresh paused while Handoff content is being edited.", - autoRefreshBusy: "Auto-refresh paused during this action.", - autoRefreshing: "Refreshing report...", - autoRefreshUpdated: "Auto-refreshed just now", - autoRefreshFailed: "Auto-refresh failed. Use Refresh to retry.", - eventActor: "Receiver: {actor}", - "work-contract": "Delegation contract", - "handoff-boundary": "Handoff sent", - "handoff-receipt": "Receiver decision", - "task-outcome": "Task outcome", - delegated: "Delegated", - accepted: "Accepted", - needs_clarification: "Needs clarification", - declined: "Declined", - succeeded: "Succeeded", - partial: "Partial", - failed: "Failed", - cancelled: "Cancelled", - awaiting_receipt: "Awaiting receipt", - awaiting_outcome: "Awaiting outcome", - not_expected: "Not expected", - not_applicable: "Not applicable", - covered: "Covered", metadata: "Report metadata", - selectionConsistency: "Selection consistency", - activityCoverage: "Activity coverage", + generatedAt: "Generated at", selectionDigest: "Selection digest", reportDigest: "Report digest", - dark: "Dark", - light: "Light", + noScopes: "No Scopes are available.", + requestFailed: "The Handoff Report request failed with HTTP {status}.", + serverUnavailable: "The Server is unavailable.", + authRejected: "The Server rejected this token.", + retry: "Retry", switchDark: "Switch to dark mode", switchLight: "Switch to light mode", switchChinese: "Switch to Chinese", switchEnglish: "Switch to English", languageChinese: "中文", - languageEnglish: "EN", - updated: "Updated {value}", - projectOption: "{projectId}", - coverageCaptured: "Captured Activity is included through cursor {cursor}. Counts describe observed events, not completion percentage.", - coverageNotConfigured: "Activity adapters are not configured. Missing Activity must not be read as no work occurring.", - coverageUnavailable: "Activity coverage is unavailable for this report.", - noProjects: "No scope contains a committed Handoff.", - previewReportTitle: "Handoff Report template", - preview: "Preview", - previewNotice: "This data-free preview shows the report's main Handoff sections. Values shown as \u201c\u2014\u201d do not represent real scope status.", - previewRetryHint: "Commit a Handoff for a scope, then retry to load its report.", - previewPlaceholder: "\u2014", - previewProjectSummary: "Project summary and scope", - previewProjectSummarySubtitle: "Project identity, selected scope, and reporting period", - previewProject: "Project", - previewScope: "Scope", - previewWorkstreamsTitle: "Workstreams and Handoff", - previewWorkstreamsSubtitle: "Current Handoff state and continuation content for each scope", - previewHandoffContentsSubtitle: "Objective, current state, disposition, next action, and known omissions", - previewActivitySubtitle: "Coverage and period comparison become available with a real Project report.", - previewCoverageDescription: "Coverage values appear here after a Project report is available.", - previewActivityComparison: "Activity and period comparison", - previewActivityComparisonSubtitle: "Observed Activity in the selected and previous periods", - previewCurrentPeriod: "Current period", - previewPreviousPeriod: "Previous period", - previewChange: "Change", - authRejected: "The Server rejected this token.", - requestFailed: "The Handoff Report request failed with HTTP {status}.", - serverUnavailable: "The Server is unavailable.", - retry: "Retry", - reportUnavailable: "The selected scope report is unavailable.", - downloadFailed: "The Markdown download failed with HTTP {status}.", - reported: "Reported", - reported_with_omissions: "Reported with omissions", - evidence_unavailable: "Evidence unavailable", - no_handoff: "No Handoff", - activity_after_handoff: "Activity after Handoff", - activity_without_handoff: "Activity without Handoff", - no_observed_activity: "No observed Activity", - current_only: "Current only", - exact_input: "Exact input", - optimistic_stable: "Optimistically stable", - captured: "Captured", - not_configured: "Not configured", - unknown: "Unknown" + languageEnglish: "EN" }, zh: { - pageTitle: "PowerContext 项目交接报告", + pageTitle: "PowerContext 交接报告", dashboardTitle: "仪表盘", skillsTitle: "技能", reviewTitle: "审核", handoffReportTitle: "交接报告", brandHomeLabel: "PowerContext 仪表盘", primaryNavigation: "主导航", - handoffSummary: "交接摘要", maintainedBy: "由 OceanBase 维护。", signOut: "退出", authTitle: "连接 PowerContext", @@ -252,193 +87,47 @@ const translations = { continue: "继续", refresh: "刷新", downloadMarkdown: "下载 Markdown", - projects: "范围", - searchProjectsPlaceholder: "按范围标识搜索", - projectSearchCount: "共 {count} 个范围", - projectSearchMatches: "匹配 {count} 个范围", - projectSearchLimited: "显示前 {shown} 个,共匹配 {total} 个。继续输入可缩小范围。", - noMatchingProjects: "没有匹配的范围。", - reportPeriod: "报告周期", - activity: "活动", - activitySubtitle: "周期控件只影响活动数量,交接状态始终采用当前精确选择。", - activityPeriod: "活动周期", - activityByWorkstream: "各工作项活动", - day: "日", - week: "周", - month: "月", - custom: "自定义", - periodStart: "开始日期", - periodEnd: "结束日期", - apply: "应用", - periodSummary: "{preset} / {range} / {timezone}", - periodComparison: "活动:本期 {current} / 上期 {previous} / 变化 {delta}", - periodBoundaryUnavailable: "交接状态采用当前精确选择;该周期只筛选活动,不能还原历史交接边界。", - periodDatesRequired: "请选择开始日期和结束日期。", - periodInvalidRange: "开始日期不能晚于结束日期。", + scopeHandoffState: "Scope 交接状态", + reportDescription: "按所选 Scope 视图汇总各 Scope 的精确交接状态。", + scopeView: "Scope 视图", + scopeViewDescription: "全部、下级范围和精确聚焦与仪表盘使用相同的选择语义。", + selectScope: "作用域", + allScopes: "全部", + subtreeView: "{title}及其下级", + exactFocus: "聚焦:{title}", + handoffSummary: "交接摘要", continuable: "可继续", blocked: "阻塞", complete: "已完成", noHandoff: "无交接", - coverage: "报告覆盖范围", - workstreams: "工作项", - activities: "活动", - evidenceUnavailable: "证据不可用", - blockers: "阻塞事项", - blockersSubtitle: "需要人工处理后才能继续的工作项", - workstreamsSubtitle: "每个工作范围的精确交接状态与下一步", - workstreamNavigation: "工作项导航", - workstreamPagination: "工作项翻页", - searchWorkstreams: "搜索工作项", - searchWorkstreamsPlaceholder: "按名称或范围标识搜索", - previousWorkstream: "上一项", - nextWorkstream: "下一项", - workstreamPosition: "{current} / {total}", - workstreamMatchCount: "匹配 {count} 项", - noMatchingWorkstreams: "没有匹配的工作项。", - workstream: "工作项", + no_handoff: "无交接", + selectedScopes: "所选 Scope", + selectedScopesDescription: "Parent 只表达组织关系;每一行只报告该 Scope 自身最新的精确 Handoff。", + scope: "Scope", + parent: "上级", status: "状态", - reporting: "汇报状态", + objective: "目标", nextAction: "下一步", - state: "当前状态", - next_action: "下一步", - available: "可用", - unavailable: "不可用", - noWorkstreams: "该项目尚未登记工作项。", - handoffContents: "交接内容", - handoffContentsSubtitle: "统一编辑整份交接内容,每次保存都会生成新的交接版本。", - currentSnapshot: "当前交接快照", exactRevision: "精确版本", - editHandoffContent: "编辑", - editHandoffContentLabel: "编辑全部交接内容", - cancelEdit: "取消", - saveRevision: "保存新版本", - createFirstRevision: "创建首个版本", - firstRevisionNote: "首个版本必须包含目标和至少一项当前状态。", - emptyField: "未填写。", - savingRevision: "正在保存新的交接版本...", - revisionSaved: "已保存为交接版本 @{revision}。", - revisionSaveFailed: "交接版本保存失败(HTTP {status})。", - objective: "目标", - currentState: "当前状态", - omissions: "已知缺失", - currentStateLines: "当前状态,每行一项", - disposition: "处置状态", - omissionLines: "已知缺失,每行一项", - liveStateCheck: "实时工作区", - capabilityCheck: "能力", - authorizationCheck: "授权", - notChecked: "未检查", - confirmed: "已确认", - mismatch: "不匹配", - insufficient: "不足", - receiverIdentity: "接手方身份", - continuityTimeline: "连续性时间线", - continuityOrderNote: "按来源日志位置排序,不代表实际发生时间。", - revisionHistory: "交接版本历史", - revisionHistorySummary: "共 {total} 个版本,按最新优先显示。", - revisionHistoryTruncated: "共 {total} 个版本,显示最近 {shown} 个。", - revisionHistoryEmpty: "该工作项尚无已提交的交接版本。", - revisionCurrent: "当前版本", - revisionNextAction: "下一步:{value}", - revisionCounts: "状态 {state} 项 / 缺失 {omissions} 项", - transferState: "交接状态", - outcomeState: "结果状态", - editorRequired: "目标和至少一项当前状态不能为空。", - timelineEmpty: "该工作范围尚无高层工作连续性记录。", - timelineTruncated: "仅显示最近 {count} 条,共有 {total} 条连续性事件。", - timelineInvalid: "有 {count} 条工作记录无法读取,已明确排除。", - timelineShowEarlier: "查看更早的 {count} 条记录", - timelineShowRecent: "收起,仅看最近 {count} 条", - eventSource: "来源", - eventRevision: "交接版本", - eventReceipt: "接手回执", - eventReceiverChecks: "接手检查", - eventSchema: "记录格式", - eventNoDetails: "该事件没有记录更多详情。", - autoRefreshActive: "每 5 秒自动刷新", - autoRefreshEditing: "正在编辑交接内容,自动刷新已暂停。", - autoRefreshBusy: "当前操作进行中,自动刷新已暂停。", - autoRefreshing: "正在刷新报告...", - autoRefreshUpdated: "刚刚已自动刷新", - autoRefreshFailed: "自动刷新失败,请使用刷新按钮重试。", - eventActor: "接手方:{actor}", - "work-contract": "委派契约", - "handoff-boundary": "发送交接", - "handoff-receipt": "接手选择", - "task-outcome": "任务结果", - delegated: "已委派", - accepted: "已接手", - needs_clarification: "需要补充", - declined: "无法接手", - succeeded: "成功", - partial: "部分完成", - failed: "失败", - cancelled: "已取消", - awaiting_receipt: "等待接手选择", - awaiting_outcome: "等待任务结果", - not_expected: "暂不需要", - not_applicable: "暂不适用", - covered: "已覆盖", metadata: "报告元数据", - selectionConsistency: "选择一致性", - activityCoverage: "活动覆盖范围", - selectionDigest: "选择摘要哈希", - reportDigest: "报告摘要哈希", - dark: "深色", - light: "浅色", + generatedAt: "生成时间", + selectionDigest: "选择摘要", + reportDigest: "报告摘要", + noScopes: "当前没有可用 Scope。", + requestFailed: "交接报告请求失败(HTTP {status})。", + serverUnavailable: "服务器无法访问。", + authRejected: "服务器拒绝了该访问令牌。", + retry: "重试", switchDark: "切换至深色模式", switchLight: "切换至浅色模式", switchChinese: "切换至中文", switchEnglish: "切换至英文", languageChinese: "中文", - languageEnglish: "EN", - updated: "更新于 {value}", - projectOption: "{projectId}", - coverageCaptured: "已纳入游标 {cursor} 之前捕获的活动。数量表示已观察事件,不代表完成百分比。", - coverageNotConfigured: "活动适配器尚未配置;缺少活动不能解释为没有发生工作。", - coverageUnavailable: "当前报告无法取得活动覆盖信息。", - noProjects: "尚无包含已提交交接的范围。", - previewReportTitle: "交接报告模板", - preview: "预览", - previewNotice: "此无数据预览展示报告的主要交接部分。以“—”显示的值不代表真实范围状态。", - previewRetryHint: "为某个范围提交交接后,点击重试以加载真实报告。", - previewPlaceholder: "—", - previewProjectSummary: "项目摘要与范围", - previewProjectSummarySubtitle: "项目身份、所选范围和报告周期", - previewProject: "项目", - previewScope: "范围", - previewWorkstreamsTitle: "工作项与交接", - previewWorkstreamsSubtitle: "每个范围的当前交接状态与继续工作所需内容", - previewHandoffContentsSubtitle: "目标、当前状态、处置状态、下一步和已知缺失", - previewActivitySubtitle: "配置真实项目后,将显示覆盖范围和周期对比。", - previewCoverageDescription: "项目报告可用后,此处将显示覆盖数据。", - previewActivityComparison: "活动与周期对比", - previewActivityComparisonSubtitle: "所选周期与上一周期内观察到的活动", - previewCurrentPeriod: "本期", - previewPreviousPeriod: "上期", - previewChange: "变化", - authRejected: "服务器拒绝了该访问令牌。", - requestFailed: "交接报告请求失败(HTTP {status})。", - serverUnavailable: "服务器无法访问。", - retry: "重试", - reportUnavailable: "当前范围的交接报告不可用。", - downloadFailed: "Markdown 下载失败(HTTP {status})。", - reported: "已汇报", - reported_with_omissions: "已汇报但有缺失", - evidence_unavailable: "证据不可用", - no_handoff: "无交接记录", - activity_after_handoff: "交接后有活动", - activity_without_handoff: "有活动但无交接记录", - no_observed_activity: "未观察到活动", - current_only: "仅当前状态", - exact_input: "精确输入", - optimistic_stable: "乐观稳定", - captured: "已捕获", - not_configured: "未配置", - unknown: "未知" + languageEnglish: "EN" } }; +const authenticationRequired = document.documentElement.dataset.serverAuthRequired === "true"; const authShell = document.getElementById("auth-shell"); const authForm = document.getElementById("auth-form"); const authError = document.getElementById("auth-error"); @@ -446,240 +135,41 @@ const tokenInput = document.getElementById("token"); const pageStatus = document.getElementById("page-status"); const pageStatusMessage = document.getElementById("page-status-message"); const pageStatusRetry = document.getElementById("page-status-retry"); -const previewShell = document.getElementById("handoff-report-preview"); -const previewRetryButton = document.getElementById("preview-retry"); const reportShell = document.getElementById("handoff-report"); -const reportError = document.getElementById("report-error"); -const projectCombobox = document.getElementById("project-combobox"); -const projectSearchInput = document.getElementById("project-search"); -const projectOptions = document.getElementById("project-options"); -const projectSearchStatus = document.getElementById("project-search-status"); +const signOut = document.getElementById("sign-out"); +const scopeSelect = document.getElementById("scope-select"); const refreshButton = document.getElementById("refresh-report"); const downloadButton = document.getElementById("download-report"); -const periodButtons = Array.from(document.querySelectorAll("[data-period-mode]")); -const customPeriodForm = document.getElementById("custom-period-form"); -const periodStartInput = document.getElementById("period-start"); -const periodEndInput = document.getElementById("period-end"); -const applyCustomPeriodButton = document.getElementById("apply-custom-period"); -const periodError = document.getElementById("period-error"); -const autoRefreshStatus = document.getElementById("auto-refresh-status"); -const signOut = document.getElementById("sign-out"); -const handoffSaveStatus = document.getElementById("handoff-save-status"); -const handoffEditorActions = document.getElementById("handoff-editor-actions"); -const editHandoffContentButton = document.getElementById("edit-handoff-content"); -const saveHandoffRevisionButton = document.getElementById("save-handoff-revision"); -const cancelHandoffEditButton = document.getElementById("cancel-handoff-edit"); -const continuityTimelineToggle = document.getElementById("continuity-timeline-toggle"); -const workstreamSwitcherToolbar = document.getElementById("workstream-switcher-toolbar"); -const workstreamSearchField = document.getElementById("workstream-search-field"); -const workstreamSearchInput = document.getElementById("workstream-search"); -const workstreamSwitcherNavigation = document.getElementById("workstream-switcher-navigation"); -const previousWorkstreamButton = document.getElementById("previous-workstream"); -const nextWorkstreamButton = document.getElementById("next-workstream"); -const workstreamPosition = document.getElementById("workstream-position"); -const workstreamListPanel = document.querySelector(".workstream-list-panel"); -const workstreamList = document.getElementById("workstream-list"); -const workstreamFilterEmpty = document.getElementById("workstream-filter-empty"); -let currentProjects = []; -let currentHandoffWorks = []; -let currentProject = null; +const requests = createRequestGate(); +let scopes = []; +let selectedKey = "all"; let currentReport = null; -let currentAuthError = null; -let currentPageStatus = null; -let currentPeriodMode = "day"; -let currentPeriodSelection = null; -let appliedCustomRange = null; -let currentWorkstreamScope = null; -let revisionSaving = false; -let reportLoading = false; -let editorDirty = false; -let autoRefreshTimer = null; -let currentWorkstreamQuery = ""; -let projectActiveIndex = -1; -let lastCenteredWorkstreamKey = null; -let pendingWorkstreamLayoutFrame = null; -const handoffDrafts = new Map(); -const pendingHandoffAttempts = new Map(); -const expandedContinuityScopes = new Set(); -const openContinuityEvents = new Map(); -const ui = createPageUi(translations, ({userInitiated = false} = {}) => { - renderAuthError(); - renderPageStatus(); - if (currentProject !== null) { - renderProjectCombobox(currentProjects, currentProject.project_id); - } +let currentStatus = null; + +const ui = createPageUi(translations, () => { + renderChoices(); if (currentReport !== null) { renderReport(currentReport); - } else { - renderPeriodControls(); - } - updateAutoRefreshStatus(); - if (userInitiated && currentProject !== null && readServerToken()) { - void loadReport(readServerToken(), currentProject.project_id, { - background: true, - selectedScopeId: currentWorkstreamScope - }); } + renderStatus(); }); -const {formatDateTime, formatNumber, translate} = ui; -const reportRequests = createRequestGate(); +const {formatDateTime, translate} = ui; authForm.addEventListener("submit", async (event) => { event.preventDefault(); await authenticate(tokenInput.value); }); - signOut.addEventListener("click", () => { - stopAutoRefresh(); clearServerToken(); - tokenInput.value = ""; showLogin(); }); - -pageStatusRetry.addEventListener("click", async () => { - const token = readServerToken(); - if (currentProject === null) { - await authenticate(token); - } else { - await loadReport(token, currentProject.project_id); - } -}); - -previewRetryButton.addEventListener("click", async () => { - await authenticate(readServerToken()); -}); - -refreshButton.addEventListener("click", async () => { - if (currentProject !== null) { - await loadReport(readServerToken(), currentProject.project_id); - } -}); - -editHandoffContentButton.addEventListener("click", () => { - const item = currentReport?.workstreams.find( - (candidate) => candidate.workstream.scope_id === currentWorkstreamScope - ) || null; - if (item !== null) { - startHandoffEdit(item); - } -}); - -cancelHandoffEditButton.addEventListener("click", () => { - cancelHandoffEdit(); -}); - -downloadButton.addEventListener("click", async () => { - await downloadMarkdown(); -}); - -projectSearchInput.addEventListener("focus", () => { - if (projectOptions.hidden) { - projectSearchInput.value = ""; - } - openProjectOptions(); -}); - -projectSearchInput.addEventListener("input", () => { - projectActiveIndex = -1; - renderProjectOptionsList(); - openProjectOptions(); -}); - -projectSearchInput.addEventListener("keydown", (event) => { - handleProjectSearchKeydown(event); -}); - -projectCombobox.addEventListener("focusout", (event) => { - if (!projectCombobox.contains(event.relatedTarget)) { - closeProjectOptions({restoreSelection: true}); - } -}); - -workstreamSearchInput.addEventListener("input", () => { - currentWorkstreamQuery = normalizeWorkstreamQuery(workstreamSearchInput.value); - lastCenteredWorkstreamKey = null; - applyWorkstreamFilter(); -}); - -workstreamSearchInput.addEventListener("keydown", (event) => { - if (event.key === "Escape" && workstreamSearchInput.value) { - event.preventDefault(); - resetWorkstreamSearch(); - applyWorkstreamFilter(); - workstreamSearchInput.focus(); - return; - } - if (event.key === "Enter") { - const visibleButtons = visibleWorkstreamButtons(); - const selected = visibleButtons.find((button) => button.getAttribute("aria-current") === "true"); - const target = selected || visibleButtons[0]; - if (target !== undefined) { - event.preventDefault(); - activateWorkstream(target.dataset.scopeId); - } - } -}); - -previousWorkstreamButton.addEventListener("click", () => { - activateAdjacentWorkstream(-1); -}); - -nextWorkstreamButton.addEventListener("click", () => { - activateAdjacentWorkstream(1); -}); - -new ResizeObserver(() => { - scheduleWorkstreamLayoutUpdate(); -}).observe(workstreamListPanel); - -continuityTimelineToggle.addEventListener("click", () => { - if (currentWorkstreamScope === null) { - return; - } - if (expandedContinuityScopes.has(currentWorkstreamScope)) { - expandedContinuityScopes.delete(currentWorkstreamScope); - } else { - expandedContinuityScopes.add(currentWorkstreamScope); - } - const item = currentReport?.workstreams.find( - (candidate) => candidate.workstream.scope_id === currentWorkstreamScope - ) || null; - renderContinuity(item?.continuity || null); -}); - -for (const button of periodButtons) { - button.addEventListener("click", async () => { - currentPeriodMode = button.dataset.periodMode; - clearPeriodError(); - if (currentProject !== null) { - await loadReport(readServerToken(), currentProject.project_id); - } - }); -} - -customPeriodForm.addEventListener("submit", async (event) => { - event.preventDefault(); - try { - validateDateRange(periodStartInput.value, periodEndInput.value); - } catch (error) { - showPeriodError(error.message); - return; - } - appliedCustomRange = {startDate: periodStartInput.value, endDate: periodEndInput.value}; - currentPeriodMode = "custom"; - clearPeriodError(); - if (currentProject !== null) { - await loadReport(readServerToken(), currentProject.project_id); - } -}); - -periodStartInput.addEventListener("change", updatePeriodInputBounds); -periodEndInput.addEventListener("change", updatePeriodInputBounds); -document.addEventListener("visibilitychange", () => { - if (!document.hidden) { - void autoRefreshReport(); - } +scopeSelect.addEventListener("change", async () => { + selectedKey = scopeSelect.value; + await loadReport(readServerToken()); }); +refreshButton.addEventListener("click", async () => loadReport(readServerToken())); +pageStatusRetry.addEventListener("click", async () => authenticate(readServerToken())); +downloadButton.addEventListener("click", async () => downloadMarkdown(readServerToken())); async function authenticate(token) { if (authenticationRequired && !token) { @@ -690,1552 +180,187 @@ async function authenticate(token) { storeServerToken(token); } tokenInput.value = ""; - currentAuthError = null; - const request = beginReportRequest(); + const request = requests.start(); try { - const projects = await listProjects(token); - if (!request.isCurrent()) { + const response = await fetchWithBearer("/dashboard/scopes", token); + if (!request.isCurrent()) return; + if (response.status === 401) { + clearServerToken(); + showLogin("authRejected"); return; } - currentProjects = projects; - if (currentProjects.length === 0) { - stopAutoRefresh(); - currentHandoffWorks = []; - currentProject = null; - currentReport = null; - currentWorkstreamScope = null; - showReportPreview(); + if (!response.ok) { + showStatus("requestFailed", {status: response.status}); return; } - const rememberedProjectId = readSelectedProject(); - const rememberedWork = readSelectedWorkLocation(); - const selectedProject = currentProjects.find( - (project) => project.project_id === rememberedWork?.projectId - ) || currentProjects.find( - (project) => project.project_id === rememberedProjectId - ) || currentProjects[0]; - currentHandoffWorks = await listHandoffWorks(token, selectedProject); - if (!request.isCurrent()) { + scopes = await response.json(); + if (scopes.length === 0) { + showStatus("noScopes"); return; } - const selectedScopeId = rememberedWork?.projectId === selectedProject.project_id - && currentHandoffWorks.some((item) => item.workstream.scope_id === rememberedWork.scopeId) - ? rememberedWork.scopeId - : currentHandoffWorks[0]?.workstream.scope_id || null; - await loadReportData(token, selectedProject.project_id, request, { - selectedScopeId - }); - if (request.isCurrent()) { - startAutoRefresh(); - } + const choices = buildScopeSelectionChoices(scopes, translate); + if (!choices.some((choice) => choice.key === selectedKey)) selectedKey = "all"; + renderChoices(); + await loadReport(token, request); } catch (error) { - if (request.isCurrent()) { - handleRequestError(error); - } - } finally { - request.finish(); + if (request.isCurrent()) showStatus("serverUnavailable"); } } -async function listProjects(token) { - const projects = []; - let cursor = null; - do { - const payload = {limit: 100}; - if (cursor !== null) { - payload.cursor = cursor; - } - const page = await requestJson("/v1/handoff-reports/scopes/list-known", token, payload); - projects.push(...page.items.map(({scope_id: scopeId}) => ({ - project_id: scopeId, - project_key: scopeId, - title: scopeId, - default_locale: null, - timezone: "UTC" - }))); - cursor = page.next_cursor; - } while (cursor !== null); - return projects.sort((left, right) => ( - left.title.localeCompare(right.title, ui.localeTag(), {numeric: true, sensitivity: "base"}) - || left.project_id.localeCompare(right.project_id) - )); -} - -async function listHandoffWorks(token, project) { - void token; - return [{project, workstream: {scope_id: project.project_id}}]; -} - -async function loadReport(token, projectId, {background = false, selectedScopeId = null} = {}) { - if (reportLoading) { - return false; - } - if (authenticationRequired && !token) { - showLogin(); - return false; - } - reportLoading = true; - if (background) { - setAutoRefreshStatus("refreshing"); - } else { - clearReportError(); - } - const request = beginReportRequest({busy: !background}); +async function loadReport(token, request = requests.start()) { + const choice = selectedChoice(); + if (choice === null) return; + setBusy(true); try { - if (currentProject?.project_id !== projectId) { - const project = currentProjects.find((item) => item.project_id === projectId); - if (project === undefined) { - throw new Error("reportUnavailable"); - } - currentHandoffWorks = await listHandoffWorks(token, project); - if (!request.isCurrent()) { - return false; - } - } - await loadReportData(token, projectId, request, {selectedScopeId}); - if (!request.isCurrent()) { - return false; - } - if (background) { - setAutoRefreshStatus("updated"); - } - return true; - } catch (error) { - if (!request.isCurrent()) { - return false; - } - if (currentProject !== null) { - renderProjectCombobox(currentProjects, currentProject.project_id); - } - if (background) { - if (error.status === 401) { - handleRequestError(error); - } else { - setAutoRefreshStatus("failed"); - } - return false; - } - handleRequestError(error); - return false; - } finally { - reportLoading = false; - request.finish(); - syncHandoffEditingState(); - if (!background && request.isCurrent()) { - updateAutoRefreshStatus(); - } - } -} - -async function loadReportData(token, projectId, request, {selectedScopeId = null} = {}) { - const projectChanged = currentProject?.project_id !== projectId; - const project = currentProjects.find((item) => item.project_id === projectId) || currentProject; - const defaultLocale = projectUiLocale(project); - if (!ui.hasLocalePreference() && defaultLocale !== null && defaultLocale !== ui.locale()) { - ui.applyLocale(defaultLocale, false); - } - const periodSelection = resolveSelectedPeriod(project); - const response = await requestJson("/v1/handoff-reports/get", token, { - scope_id: projectId, - locale: ui.locale() === "zh" ? "zh-CN" : "en", - include_evidence_checks: false, - format: "json", - include_archived: false, - download: false, - period: periodSelection.period - }); - if (!request.isCurrent()) { - return; - } - if (response.report === null) { - throw new Error("reportUnavailable"); - } - if (projectChanged) { - resetWorkstreamSearch(); - lastCenteredWorkstreamKey = null; - } - currentProject = currentProjects.find((item) => item.project_id === projectId) || response.report.project; - currentReport = response.report; - currentPeriodSelection = periodSelection; - if (selectedScopeId !== null) { - currentWorkstreamScope = selectedScopeId; - } - rememberSelectedProject(projectId); - renderProjectCombobox(currentProjects, projectId); - renderReport(currentReport); -} - -function projectUiLocale(project) { - if (typeof project?.default_locale !== "string") { - return null; - } - return project.default_locale.toLowerCase().startsWith("zh") ? "zh" : "en"; -} - -function beginReportRequest({busy = true} = {}) { - if (busy) { - setBusy(true); - } - const request = reportRequests.start(); - return { - finish() { - if (busy && request.isCurrent()) { - setBusy(false); - } - }, - isCurrent: request.isCurrent - }; -} - -async function requestJson(path, token, payload) { - const response = await fetchWithBearer(path, token, { - method: "POST", - headers: {"Content-Type": "application/json"}, - body: JSON.stringify(payload) - }); - if (response.status === 401) { - const error = new Error("authRejected"); - error.status = 401; - throw error; - } - if (!response.ok) { - const error = new Error("requestFailed"); - error.status = response.status; - throw error; - } - return response.json(); -} - -function handleRequestError(error) { - if (error.status === 401) { - clearServerToken(); - showLogin("authRejected"); - return; - } - const key = error.message === "reportUnavailable" ? "reportUnavailable" : "serverUnavailable"; - if (typeof error.status === "number") { - showReportFailure("requestFailed", {status: error.status}); - return; - } - showReportFailure(key); -} - -function showReportFailure(key, values = {}) { - if (currentReport === null) { - showPageStatus(key, values, true); - return; - } - currentPageStatus = null; - pageStatus.hidden = true; - previewShell.hidden = true; - reportShell.hidden = false; - signOut.hidden = !authenticationRequired; - showReportError(key, values); -} - -function showLogin(messageKey = "", values = {}) { - stopAutoRefresh(); - reportRequests.cancel(); - setBusy(false); - reportLoading = false; - revisionSaving = false; - handoffDrafts.clear(); - pendingHandoffAttempts.clear(); - editorDirty = false; - currentProjects = []; - currentHandoffWorks = []; - currentProject = null; - currentReport = null; - currentWorkstreamScope = null; - currentPeriodSelection = null; - currentPageStatus = null; - currentAuthError = messageKey ? {key: messageKey, values} : null; - closeProjectOptions(); - projectSearchInput.value = ""; - projectSearchInput.disabled = false; - projectSearchStatus.textContent = ""; - handoffSaveStatus.textContent = ""; - renderAuthError(); - clearReport(); - authShell.hidden = false; - pageStatus.hidden = true; - previewShell.hidden = true; - reportShell.hidden = true; - signOut.hidden = true; - tokenInput.focus(); -} - -function showPageStatus(messageKey, values = {}, retryable = false) { - currentPageStatus = {key: messageKey, values, retryable}; - renderPageStatus(); - authShell.hidden = true; - pageStatus.hidden = false; - previewShell.hidden = true; - reportShell.hidden = true; - signOut.hidden = !authenticationRequired; -} - -function showReportPreview() { - currentPageStatus = null; - clearReport(); - authShell.hidden = true; - pageStatus.hidden = true; - previewShell.hidden = false; - reportShell.hidden = true; - signOut.hidden = !authenticationRequired; -} - -function renderPageStatus() { - if (currentPageStatus === null) { - pageStatusMessage.textContent = ""; - pageStatusRetry.hidden = true; - return; - } - pageStatusMessage.textContent = translate(currentPageStatus.key, currentPageStatus.values); - pageStatusRetry.hidden = !currentPageStatus.retryable; -} - -function renderAuthError() { - authError.textContent = currentAuthError === null - ? "" - : translate(currentAuthError.key, currentAuthError.values); -} - -function renderProjectCombobox(projects, selectedProjectId) { - const selected = projects.find((project) => project.project_id === selectedProjectId) || null; - if (projectOptions.hidden) { - projectSearchInput.value = selected === null ? "" : projectOptionLabel(selected); - projectSearchStatus.textContent = translate("projectSearchCount", {count: formatNumber(projects.length)}); - return; - } - renderProjectOptionsList(); -} - -function projectOptionLabel(project) { - return translate("projectOption", {title: project.title, projectId: project.project_id}); -} - -function normalizedProjectQuery(value) { - return value.trim().toLocaleLowerCase(); -} - -function matchingProjects() { - const query = normalizedProjectQuery(projectSearchInput.value); - if (!query) { - return currentProjects; - } - return currentProjects.filter((project) => ( - `${project.title}\n${project.project_id}\n${project.project_key}`.toLocaleLowerCase().includes(query) - )); -} - -function renderProjectOptionsList() { - const matches = matchingProjects(); - const visible = matches.slice(0, projectOptionRenderLimit); - projectOptions.replaceChildren(); - projectActiveIndex = Math.min(projectActiveIndex, visible.length - 1); - for (const [index, project] of visible.entries()) { - const option = document.createElement("button"); - option.className = "project-option"; - option.id = `project-option-${index}`; - option.type = "button"; - option.role = "option"; - option.dataset.projectId = project.project_id; - option.setAttribute("aria-selected", String(project.project_id === currentProject?.project_id)); - - const title = document.createElement("strong"); - title.textContent = project.title; - const identity = document.createElement("code"); - identity.textContent = project.project_id; - option.append(title, identity); - option.addEventListener("click", () => { - void selectProject(project.project_id); - }); - projectOptions.appendChild(option); - } - if (matches.length === 0) { - const empty = document.createElement("p"); - empty.className = "project-options-empty"; - empty.textContent = translate("noMatchingProjects"); - projectOptions.appendChild(empty); - } - if (matches.length > visible.length) { - projectSearchStatus.textContent = translate("projectSearchLimited", { - shown: formatNumber(visible.length), - total: formatNumber(matches.length) - }); - } else { - projectSearchStatus.textContent = translate( - projectSearchInput.value ? "projectSearchMatches" : "projectSearchCount", - {count: formatNumber(matches.length)} - ); - } - updateActiveProjectOption(); -} - -function openProjectOptions() { - projectOptions.hidden = false; - projectSearchInput.setAttribute("aria-expanded", "true"); - renderProjectOptionsList(); -} - -function closeProjectOptions({restoreSelection = false} = {}) { - projectOptions.hidden = true; - projectSearchInput.setAttribute("aria-expanded", "false"); - projectSearchInput.removeAttribute("aria-activedescendant"); - projectActiveIndex = -1; - if (restoreSelection) { - const selected = currentProjects.find((project) => project.project_id === currentProject?.project_id); - projectSearchInput.value = selected === undefined ? "" : projectOptionLabel(selected); - projectSearchStatus.textContent = translate("projectSearchCount", {count: formatNumber(currentProjects.length)}); - } -} - -function handleProjectSearchKeydown(event) { - if (event.key === "Escape") { - event.preventDefault(); - closeProjectOptions({restoreSelection: true}); - return; - } - if (!["ArrowDown", "ArrowUp", "Enter", "Home", "End"].includes(event.key)) { - return; - } - const options = Array.from(projectOptions.querySelectorAll(".project-option")); - if (projectOptions.hidden) { - openProjectOptions(); - } - if (options.length === 0) { - return; - } - event.preventDefault(); - if (event.key === "Enter") { - const target = options[projectActiveIndex] || options[0]; - void selectProject(target.dataset.projectId); - return; - } - if (event.key === "Home") { - projectActiveIndex = 0; - } else if (event.key === "End") { - projectActiveIndex = options.length - 1; - } else if (event.key === "ArrowDown") { - projectActiveIndex = Math.min(projectActiveIndex + 1, options.length - 1); - } else { - projectActiveIndex = projectActiveIndex <= 0 ? options.length - 1 : projectActiveIndex - 1; - } - updateActiveProjectOption(); -} - -function updateActiveProjectOption() { - const options = Array.from(projectOptions.querySelectorAll(".project-option")); - options.forEach((option, index) => { - option.dataset.active = String(index === projectActiveIndex); - }); - const active = options[projectActiveIndex]; - if (active === undefined) { - projectSearchInput.removeAttribute("aria-activedescendant"); - return; - } - projectSearchInput.setAttribute("aria-activedescendant", active.id); - active.scrollIntoView({block: "nearest"}); -} - -async function selectProject(projectId) { - const selected = currentProjects.find((project) => project.project_id === projectId); - if (selected === undefined) { - return; - } - projectSearchInput.value = projectOptionLabel(selected); - closeProjectOptions(); - if (projectId !== currentProject?.project_id) { - currentWorkstreamScope = null; - await loadReport(readServerToken(), projectId); - } -} - -function renderReport(report) { - currentPageStatus = null; - authShell.hidden = true; - pageStatus.hidden = true; - previewShell.hidden = true; - reportShell.hidden = false; - signOut.hidden = !authenticationRequired; - clearReportError(); - setText("project-name", currentWorkstreamScope || report.workstreams[0]?.workstream.scope_id || translate("handoffReportTitle")); - setText("report-updated", translate("updated", {value: formatDateTime(report.generated_at)})); - setText("continuable-count", formatNumber(report.summary.continuable_count)); - setText("blocked-count", formatNumber(report.summary.blocked_count)); - setText("complete-count", formatNumber(report.summary.complete_count)); - setText("no-handoff-count", formatNumber(report.summary.no_handoff_count)); - setText("selected-workstreams", formatNumber(report.coverage.selected_workstreams)); - setText("activity-count", formatNumber(report.activity_selection.length)); - setText("evidence-unavailable", formatNumber(report.coverage.unavailable_evidence_workstreams)); - setText("coverage-description", coverageDescription(report)); - setText("selection-consistency", statusLabel(report.selection_consistency)); - setText("activity-coverage", statusLabel(report.coverage.activity_coverage)); - setText("selection-digest", report.selection_digest || "-"); - setText("report-digest", report.report_digest || "-"); - renderPeriodControls(report); - renderBlockers(report.workstreams.filter((item) => item.work_status === "blocked")); - renderHandoffWorkstreams(report.workstreams); - renderActivityBreakdown(report.workstreams); -} - -function clearReport() { - setText("project-name", translate("handoffReportTitle")); - setText("report-updated", ""); - for (const id of [ - "continuable-count", - "blocked-count", - "complete-count", - "no-handoff-count", - "selected-workstreams", - "activity-count", - "evidence-unavailable" - ]) { - setText(id, "0"); - } - setText("coverage-description", ""); - setText("selection-consistency", "-"); - setText("activity-coverage", "-"); - setText("selection-digest", "-"); - setText("report-digest", "-"); - currentPeriodSelection = null; - renderPeriodControls(); - renderBlockers([]); - renderHandoffWorkstreams([]); - renderActivityBreakdown([]); -} - -function coverageDescription(report) { - const status = report.coverage.activity_coverage; - if (status === "captured") { - return translate("coverageCaptured", {cursor: formatNumber(report.activity_cursor)}); - } - if (status === "not_configured") { - return translate("coverageNotConfigured"); - } - return translate("coverageUnavailable"); -} - -function renderBlockers(blockers) { - const section = document.getElementById("blockers-section"); - const list = document.getElementById("blocker-list"); - list.replaceChildren(); - section.hidden = blockers.length === 0; - for (const item of blockers) { - const card = document.createElement("article"); - card.className = "blocker-card"; - const heading = document.createElement("h3"); - heading.textContent = item.workstream.title; - const scope = document.createElement("code"); - scope.textContent = item.workstream.scope_id; - const detail = document.createElement("p"); - detail.textContent = item.content?.next_action?.text || item.content?.objective || translate("blocked"); - card.append(heading, scope, detail); - list.appendChild(card); - } -} - -function renderWorkstreams(workstreams) { - const empty = document.getElementById("workstream-empty"); - const existingButtons = new Map( - Array.from(workstreamList.querySelectorAll(".workstream-list-item")) - .map((button) => [button.dataset.scopeId, button]) - ); - empty.hidden = workstreams.length !== 0; - workstreamSearchField.hidden = workstreams.length <= workstreamSearchThreshold; - if (workstreamSearchField.hidden && currentWorkstreamQuery) { - resetWorkstreamSearch(); - } - for (const item of workstreams) { - const selected = item.workstream.scope_id === currentWorkstreamScope; - const button = existingButtons.get(item.workstream.scope_id) || document.createElement("button"); - if (!button.classList.contains("workstream-list-item")) { - button.className = "workstream-list-item"; - button.type = "button"; - button.addEventListener("click", () => { - activateWorkstream(button.dataset.scopeId); - }); - } - button.dataset.scopeId = item.workstream.scope_id; - button.dataset.searchText = normalizeWorkstreamQuery(`${item.workstream.title}\n${item.workstream.scope_id}`); - button.setAttribute("aria-current", String(selected)); - - const header = document.createElement("span"); - header.className = "workstream-list-item-header"; - const title = document.createElement("strong"); - title.textContent = item.workstream.title; - header.append(title, statusBadge(item.work_status)); - - const scope = document.createElement("code"); - scope.textContent = item.workstream.scope_id; - button.replaceChildren(header, scope); - workstreamList.appendChild(button); - existingButtons.delete(item.workstream.scope_id); - } - for (const button of existingButtons.values()) { - button.remove(); - } - applyWorkstreamFilter(); -} - -function normalizeWorkstreamQuery(value) { - return value.trim().toLocaleLowerCase(); -} - -function resetWorkstreamSearch() { - currentWorkstreamQuery = ""; - workstreamSearchInput.value = ""; -} - -function visibleWorkstreamButtons() { - return Array.from(workstreamList.querySelectorAll(".workstream-list-item:not([hidden])")); -} - -function applyWorkstreamFilter() { - const buttons = Array.from(workstreamList.querySelectorAll(".workstream-list-item")); - for (const button of buttons) { - button.hidden = currentWorkstreamQuery !== "" && !button.dataset.searchText.includes(currentWorkstreamQuery); - } - workstreamFilterEmpty.hidden = buttons.length === 0 || visibleWorkstreamButtons().length !== 0; - scheduleWorkstreamLayoutUpdate(); -} - -function scheduleWorkstreamLayoutUpdate() { - if (pendingWorkstreamLayoutFrame !== null) { - window.cancelAnimationFrame(pendingWorkstreamLayoutFrame); - } - pendingWorkstreamLayoutFrame = window.requestAnimationFrame(() => { - pendingWorkstreamLayoutFrame = null; - updateWorkstreamSwitcherControls(); - centerSelectedWorkstream(); - }); -} - -function updateWorkstreamSwitcherControls() { - const buttons = visibleWorkstreamButtons(); - const selectedIndex = buttons.findIndex((button) => button.getAttribute("aria-current") === "true"); - const overflowing = workstreamList.scrollWidth > workstreamListPanel.clientWidth + 1; - workstreamSwitcherNavigation.hidden = !overflowing; - workstreamSwitcherToolbar.hidden = workstreamSearchField.hidden && workstreamSwitcherNavigation.hidden; - if (buttons.length === 0) { - workstreamPosition.textContent = translate("noMatchingWorkstreams"); - } else if (selectedIndex === -1) { - workstreamPosition.textContent = translate("workstreamMatchCount", {count: formatNumber(buttons.length)}); - } else { - workstreamPosition.textContent = translate("workstreamPosition", { - current: formatNumber(selectedIndex + 1), - total: formatNumber(buttons.length) + const response = await fetchWithBearer("/v1/handoff-reports/get", token, { + method: "POST", + headers: {"Content-Type": "application/json"}, + body: JSON.stringify({selection: choice.selection, format: "json"}) }); - } - previousWorkstreamButton.disabled = editorDirty || revisionSaving || buttons.length === 0 || selectedIndex === 0; - nextWorkstreamButton.disabled = editorDirty - || revisionSaving - || buttons.length === 0 - || selectedIndex === buttons.length - 1; -} - -function activateAdjacentWorkstream(direction) { - const buttons = visibleWorkstreamButtons(); - if (buttons.length === 0) { - return; - } - const selectedIndex = buttons.findIndex((button) => button.getAttribute("aria-current") === "true"); - const targetIndex = selectedIndex === -1 - ? (direction < 0 ? buttons.length - 1 : 0) - : selectedIndex + direction; - if (targetIndex < 0 || targetIndex >= buttons.length) { - return; - } - activateWorkstream(buttons[targetIndex].dataset.scopeId); -} - -function centerSelectedWorkstream() { - const selected = workstreamList.querySelector('.workstream-list-item[aria-current="true"]:not([hidden])'); - if (selected === null || currentProject === null) { - return; - } - const selectionKey = `${currentProject.project_id}:${selected.dataset.scopeId}`; - if (selectionKey === lastCenteredWorkstreamKey) { - return; - } - selected.scrollIntoView({block: "nearest", inline: "center"}); - lastCenteredWorkstreamKey = selectionKey; -} - -function renderHandoffContents(workstreams) { - const list = document.getElementById("handoff-content-list"); - list.replaceChildren(); - const item = workstreams.find((candidate) => candidate.workstream.scope_id === currentWorkstreamScope) - || workstreams[0] - || null; - if (item === null) { - const empty = document.createElement("p"); - empty.className = "handoff-content-empty"; - empty.textContent = translate("noWorkstreams"); - list.appendChild(empty); - renderHandoffEditorActions(null, null); - return; - } - - const card = document.createElement("div"); - card.className = "handoff-content-card"; - const header = document.createElement("header"); - const identity = document.createElement("div"); - const title = document.createElement("h4"); - title.className = "handoff-content-title"; - title.textContent = item.workstream.title; - const scope = document.createElement("code"); - scope.textContent = item.workstream.scope_id; - identity.append(title, scope); - const state = document.createElement("div"); - state.className = "handoff-snapshot-state"; - state.appendChild(statusBadge(item.work_status)); - const reference = document.createElement("code"); - reference.textContent = `${translate("exactRevision")}: ${formatArtifactRef(item.handoff_ref)}`; - state.appendChild(reference); - header.append(identity, state); - card.appendChild(header); - - const draft = handoffDrafts.get(item.workstream.scope_id) || null; - if (draft !== null) { - card.appendChild(createHandoffEditor(item, draft)); - } else { - if (item.content === null) { - const note = document.createElement("p"); - note.className = "handoff-content-empty"; - note.textContent = translate("firstRevisionNote"); - card.appendChild(note); - } - for (const field of handoffFieldDefinitions(item)) { - appendHandoffBlock(card, field); - } - } - list.appendChild(card); - renderHandoffEditorActions(item, draft); - syncHandoffEditingState(); -} - -function handoffFieldDefinitions(item, values = draftFromWorkstream(item)) { - return [ - {name: "objective", label: translate("objective"), kind: "text", rows: 3, value: values.objective}, - {name: "state", label: translate("currentState"), kind: "lines", rows: 5, value: values.state}, - {name: "disposition", label: translate("disposition"), kind: "disposition", value: values.disposition}, - {name: "nextAction", label: translate("nextAction"), kind: "text", rows: 3, value: values.nextAction}, - {name: "omissions", label: translate("omissions"), kind: "lines", rows: 3, value: values.omissions} - ]; -} - -function appendHandoffBlock(card, field) { - const section = document.createElement("section"); - section.className = "handoff-content-block"; - section.dataset.field = field.name; - const header = document.createElement("header"); - const heading = document.createElement("h4"); - heading.textContent = field.label; - header.appendChild(heading); - section.appendChild(header); - appendHandoffFieldValue(section, field); - card.appendChild(section); -} - -function appendHandoffFieldValue(section, field) { - const value = field.value.trim(); - if (field.kind === "disposition") { - section.appendChild(statusBadge(field.value)); - return; - } - const entries = field.kind === "lines" ? normalizedLines(value) : [value]; - if (!value || entries.length === 0) { - const empty = document.createElement("p"); - empty.className = "handoff-block-empty"; - empty.textContent = translate("emptyField"); - section.appendChild(empty); - return; - } - if (field.kind === "lines") { - const list = document.createElement("ul"); - for (const entry of entries) { - const row = document.createElement("li"); - row.textContent = entry; - list.appendChild(row); - } - section.appendChild(list); - return; - } - const paragraph = document.createElement("p"); - paragraph.textContent = value; - section.appendChild(paragraph); -} - -function createHandoffEditor(item, draft) { - const form = document.createElement("form"); - form.className = "handoff-content-editor"; - form.id = "handoff-content-editor"; - if (item.content === null) { - const note = document.createElement("p"); - note.className = "handoff-content-empty"; - note.textContent = translate("firstRevisionNote"); - form.appendChild(note); - } - for (const field of handoffFieldDefinitions(item)) { - const section = document.createElement("section"); - section.className = "handoff-content-block is-editing"; - section.dataset.field = field.name; - const label = document.createElement("label"); - const text = document.createElement("span"); - text.textContent = field.kind === "lines" && field.name === "state" - ? translate("currentStateLines") - : field.kind === "lines" && field.name === "omissions" - ? translate("omissionLines") - : field.label; - const control = createHandoffControl( - field, - draft.values[field.name], - `${item.workstream.scope_id}-${field.name}` - ); - label.htmlFor = control.id; - const update = () => { - draft.values[field.name] = control.value; - draft.dirty = handoffDraftChanged(draft); - syncHandoffEditingState(); - }; - control.addEventListener("input", update); - control.addEventListener("change", update); - label.appendChild(text); - section.append(label, control); - form.appendChild(section); - } - form.addEventListener("submit", (event) => { - event.preventDefault(); - void saveHandoffRevision(item, draft); - }); - return form; -} - -function createHandoffControl(field, value, id) { - let control; - if (field.kind === "disposition") { - control = document.createElement("select"); - for (const status of ["continuable", "blocked", "complete"]) { - const option = document.createElement("option"); - option.value = status; - option.textContent = statusLabel(status); - option.selected = status === value; - control.appendChild(option); + if (!request.isCurrent()) return; + if (response.status === 401) { + clearServerToken(); + showLogin("authRejected"); + return; } - } else { - control = document.createElement("textarea"); - control.rows = field.rows; - control.maxLength = 8192; - control.value = value; - } - control.id = id.replaceAll(/[^a-zA-Z0-9_-]/g, "-"); - control.setAttribute("aria-label", field.label); - return control; -} - -function startHandoffEdit(item) { - const values = draftFromWorkstream(item); - handoffDrafts.clear(); - handoffDrafts.set(item.workstream.scope_id, { - initialValues: {...values}, - values: {...values}, - dirty: false - }); - syncHandoffEditingState(); - handoffSaveStatus.textContent = ""; - handoffSaveStatus.classList.remove("is-error"); - renderHandoffContents(currentReport?.workstreams || []); - document.querySelector(".handoff-content-editor :is(textarea, select)")?.focus(); -} - -function cancelHandoffEdit() { - if (currentWorkstreamScope === null || revisionSaving) { - return; - } - handoffDrafts.delete(currentWorkstreamScope); - pendingHandoffAttempts.delete(currentWorkstreamScope); - syncHandoffEditingState(); - renderHandoffContents(currentReport?.workstreams || []); - editHandoffContentButton.focus(); -} - -function handoffDraftChanged(draft) { - return Object.keys(draft.values).some((name) => draft.values[name] !== draft.initialValues[name]); -} - -function renderHandoffEditorActions(item, draft) { - const available = item !== null; - const editing = draft !== null; - handoffEditorActions.hidden = !available; - editHandoffContentButton.hidden = !available || editing; - saveHandoffRevisionButton.hidden = !editing; - cancelHandoffEditButton.hidden = !editing; - if (available) { - const editKey = item.content === null ? "createFirstRevision" : "editHandoffContent"; - editHandoffContentButton.textContent = translate(editKey); - editHandoffContentButton.setAttribute( - "aria-label", - translate(item.content === null ? "createFirstRevision" : "editHandoffContentLabel") - ); - } - editHandoffContentButton.disabled = revisionSaving || reportLoading; - saveHandoffRevisionButton.disabled = revisionSaving || !draft?.dirty; - cancelHandoffEditButton.disabled = revisionSaving; -} - -function syncHandoffEditingState() { - editorDirty = handoffDrafts.size > 0; - projectSearchInput.disabled = reportLoading || revisionSaving || editorDirty; - workstreamSearchInput.disabled = revisionSaving || editorDirty; - document.querySelectorAll(".workstream-list-item").forEach((button) => { - button.disabled = revisionSaving || editorDirty; - }); - const activeDraft = currentWorkstreamScope === null ? null : handoffDrafts.get(currentWorkstreamScope) || null; - const item = currentReport?.workstreams.find( - (candidate) => candidate.workstream.scope_id === currentWorkstreamScope - ) || null; - renderHandoffEditorActions(item, activeDraft); - updateWorkstreamSwitcherControls(); - updateAutoRefreshStatus(); -} - -function renderActivityBreakdown(workstreams) { - const list = document.getElementById("activity-breakdown-list"); - list.replaceChildren(); - if (workstreams.length === 0) { - const empty = document.createElement("p"); - empty.className = "empty-state"; - empty.textContent = translate("noWorkstreams"); - list.appendChild(empty); - return; - } - for (const item of workstreams) { - const row = document.createElement("div"); - row.className = "activity-breakdown-item"; - const identity = document.createElement("div"); - const title = document.createElement("strong"); - title.textContent = item.workstream.title; - const scope = document.createElement("code"); - scope.textContent = item.workstream.scope_id; - identity.append(title, scope); - const reporting = document.createElement("span"); - reporting.textContent = statusLabel(item.reporting_status); - const count = document.createElement("strong"); - count.textContent = formatNumber(item.observed_activity_count); - row.append(identity, reporting, count); - list.appendChild(row); - } -} - -function renderHandoffWorkstreams(workstreams) { - const selected = workstreams.some((item) => item.workstream.scope_id === currentWorkstreamScope) - ? currentWorkstreamScope - : workstreams[0]?.workstream.scope_id || null; - if (workstreams.length === 0) { - currentWorkstreamScope = null; - renderWorkstreams([]); - renderHandoffContents([]); - renderRevisionHistory(null); - renderContinuity(null); - return; - } - - activateWorkstream(selected); -} - -function activateWorkstream(scopeId) { - const item = currentReport?.workstreams.find((candidate) => candidate.workstream.scope_id === scopeId) || null; - if (item === null) { - return; - } - const scopeChanged = currentWorkstreamScope !== scopeId; - currentWorkstreamScope = scopeId; - rememberSelectedWork(currentProject.project_id, scopeId); - if (scopeChanged) { - handoffSaveStatus.textContent = ""; - handoffSaveStatus.classList.remove("is-error"); - } - renderWorkstreams(currentReport.workstreams); - renderHandoffContents(currentReport.workstreams); - renderRevisionHistory(item); - renderContinuity(item.continuity || null); -} - -function artifactRefsEqual(left, right) { - return left !== null - && right !== null - && left.family === right.family - && left.artifact_id === right.artifact_id - && left.revision === right.revision; -} - -function formatArtifactRef(reference) { - return reference === null ? "-" : `${reference.family}/${reference.artifact_id}@${reference.revision}`; -} - -function draftFromWorkstream(item) { - const content = item.content; - if (content === null) { - return {objective: "", state: "", disposition: "continuable", nextAction: "", omissions: ""}; - } - return { - objective: content.objective, - state: content.state.map((statement) => statement.text).join("\n"), - disposition: content.disposition, - nextAction: content.next_action?.text || "", - omissions: content.omissions.map((omission) => omission.text).join("\n") - }; -} - -function normalizedLines(value) { - return [...new Set(value.split("\n").map((line) => line.trim()).filter(Boolean))]; -} - -async function saveHandoffRevision(item, draft) { - if (currentWorkstreamScope === null || revisionSaving || currentWorkstreamScope !== item.workstream.scope_id) { - return; - } - const values = {...draft.values}; - const objective = values.objective.trim(); - const state = normalizedLines(values.state); - if (!objective || state.length === 0) { - setHandoffSaveStatus("editorRequired", {}, true); - return; - } - - const scopeId = currentWorkstreamScope; - const handoff = { - schema: "powercontext.current-work-handoff.v1", - trust: "untrusted_input", - objective, - state: state.map(declaredClaim), - disposition: values.disposition, - next_action: values.nextAction.trim() ? declaredClaim(values.nextAction.trim()) : null, - omissions: normalizedLines(values.omissions) - }; - const attempt = pendingHandoffAttempt(scopeId, handoff); - setRevisionSaving(true); - setHandoffSaveStatus("savingRevision"); - try { - const prepared = attempt.prepared || await requestJson( - "/v1/work/handoffs/prepare-current", - readServerToken(), - {scope_id: scopeId, source_id: attempt.sourceId, handoff} - ); - attempt.prepared = prepared; - const committed = await requestJson("/v1/handoff/commit", readServerToken(), { - scope_id: scopeId, - handoff: prepared.handoff - }); - pendingHandoffAttempts.delete(scopeId); - handoffDrafts.delete(scopeId); - syncHandoffEditingState(); - await loadReport(readServerToken(), currentProject.project_id, {selectedScopeId: scopeId}); - setHandoffSaveStatus("revisionSaved", {revision: committed.reference.revision}); - } catch (error) { - if (error.status === 401) { - handleRequestError(error); + if (!response.ok) { + showStatus("requestFailed", {status: response.status}); return; } - setHandoffSaveStatus("revisionSaveFailed", {status: error.status || "network"}, true); + const payload = await response.json(); + currentReport = payload.report; + currentStatus = null; + authShell.hidden = true; + pageStatus.hidden = true; + reportShell.hidden = false; + signOut.hidden = !authenticationRequired; + renderReport(currentReport); + } catch (error) { + if (request.isCurrent()) showStatus("serverUnavailable"); } finally { - setRevisionSaving(false); - } -} - -function setHandoffSaveStatus(key, values = {}, isError = false) { - handoffSaveStatus.textContent = translate(key, values); - handoffSaveStatus.classList.toggle("is-error", isError); -} - -function pendingHandoffAttempt(scopeId, handoff) { - const fingerprint = JSON.stringify(handoff); - const existing = pendingHandoffAttempts.get(scopeId); - if (existing?.fingerprint === fingerprint) { - return existing; + if (request.isCurrent()) setBusy(false); } - const attempt = { - fingerprint, - sourceId: revisionSourceId("handoff-boundary"), - prepared: null - }; - pendingHandoffAttempts.set(scopeId, attempt); - return attempt; } -function declaredClaim(text) { - return {text, basis: "declared", evidence: []}; -} - -function setRevisionSaving(saving) { - revisionSaving = saving; - document.querySelectorAll( - ".handoff-content-card :is(button, input, select, textarea), .handoff-editor-actions button" - ).forEach((element) => { - element.disabled = saving; - }); - projectSearchInput.disabled = saving || reportLoading || editorDirty; - syncHandoffEditingState(); -} - -function revisionSourceId(kind) { - const unique = typeof window.crypto?.randomUUID === "function" - ? window.crypto.randomUUID() - : `${Date.now()}-${Math.random().toString(16).slice(2)}`; - return `handoff-report:${kind}:${unique}`; -} - -function renderRevisionHistory(item) { - const list = document.getElementById("handoff-revision-history"); - const summary = document.getElementById("revision-history-summary"); - list.replaceChildren(); - const history = Array.isArray(item?.handoff_history) ? item.handoff_history : []; - if (history.length === 0) { - summary.textContent = translate("revisionHistoryEmpty"); - return; - } - const total = Number(item.handoff_revision_count) || history.length; - summary.textContent = translate( - item.handoff_history_truncated ? "revisionHistoryTruncated" : "revisionHistorySummary", - {shown: history.length, total} - ); - for (const revision of [...history].reverse()) { - const current = artifactRefsEqual(revision.reference, item.handoff_ref); - const row = document.createElement("li"); - row.className = "revision-history-item"; - row.dataset.current = String(current); - if (current) { - row.setAttribute("aria-current", "true"); - } - - const header = document.createElement("div"); - header.className = "revision-history-item-header"; - const reference = document.createElement("code"); - reference.textContent = `@${revision.reference.revision}`; - const disposition = document.createElement("span"); - disposition.className = "revision-history-disposition"; - disposition.textContent = statusLabel(revision.disposition); - header.append(reference, disposition); - if (current) { - const currentLabel = document.createElement("strong"); - currentLabel.textContent = translate("revisionCurrent"); - header.appendChild(currentLabel); - } - - const objective = document.createElement("p"); - objective.className = "revision-history-objective"; - objective.textContent = revision.objective_excerpt; - row.append(header, objective); - if (revision.next_action_excerpt) { - const nextAction = document.createElement("p"); - nextAction.className = "revision-history-next"; - nextAction.textContent = translate("revisionNextAction", {value: revision.next_action_excerpt}); - row.appendChild(nextAction); - } - const counts = document.createElement("p"); - counts.className = "revision-history-counts"; - counts.textContent = translate("revisionCounts", { - state: formatNumber(revision.state_count), - omissions: formatNumber(revision.omission_count) - }); - row.appendChild(counts); - list.appendChild(row); - } -} - -function renderContinuity(continuity) { - const timeline = document.getElementById("continuity-timeline"); - const note = document.getElementById("continuity-note"); - const transferState = document.getElementById("transfer-state-status"); - const outcomeState = document.getElementById("outcome-state-status"); - timeline.replaceChildren(); - continuityTimelineToggle.hidden = true; - if (continuity === null) { - transferState.textContent = "-"; - outcomeState.textContent = "-"; - transferState.removeAttribute("data-state"); - outcomeState.removeAttribute("data-state"); - note.textContent = translate("timelineEmpty"); - return; - } - transferState.textContent = statusLabel(continuity.coverage.transfer_state); - transferState.dataset.state = continuity.coverage.transfer_state; - outcomeState.textContent = statusLabel(continuity.coverage.outcome_state); - outcomeState.dataset.state = continuity.coverage.outcome_state; - const expanded = expandedContinuityScopes.has(continuity.scope_id); - const hiddenEventCount = Math.max(0, continuity.events.length - continuityTimelineRecentLimit); - const visibleEvents = expanded - ? continuity.events - : continuity.events.slice(-continuityTimelineRecentLimit); - for (const event of visibleEvents) { - timeline.appendChild(renderContinuityEvent(continuity.scope_id, event, timeline)); - } - if (hiddenEventCount > 0) { - continuityTimelineToggle.hidden = false; - continuityTimelineToggle.setAttribute("aria-expanded", String(expanded)); - continuityTimelineToggle.textContent = translate( - expanded ? "timelineShowRecent" : "timelineShowEarlier", - expanded ? {count: continuityTimelineRecentLimit} : {count: hiddenEventCount} - ); - } - const notes = []; - if (continuity.events.length === 0) { - notes.push(translate("timelineEmpty")); - } - if (continuity.truncated) { - notes.push(translate("timelineTruncated", { - count: continuity.events.length, - total: continuity.total_event_count - })); - } - if (continuity.invalid_record_count > 0) { - notes.push(translate("timelineInvalid", {count: continuity.invalid_record_count})); - } - note.textContent = notes.join(" "); -} - -function renderContinuityEvent(scopeId, event, timeline) { - const item = document.createElement("li"); - item.dataset.kind = event.kind; - item.dataset.status = event.status; - - const disclosure = document.createElement("details"); - disclosure.className = "continuity-event"; - disclosure.open = openContinuityEvents.get(scopeId) === event.position; - item.dataset.open = String(disclosure.open); - - const toggle = document.createElement("summary"); - const position = document.createElement("span"); - position.className = "continuity-position"; - position.textContent = `#${event.position}`; - const heading = document.createElement("span"); - heading.className = "continuity-event-heading"; - const title = document.createElement("strong"); - title.className = "continuity-event-title"; - title.textContent = statusLabel(event.kind); - const detail = event.summary || (event.actor ? translate("eventActor", {actor: event.actor}) : ""); - const preview = document.createElement("span"); - preview.className = "continuity-event-preview"; - preview.textContent = detail || translate("eventNoDetails"); - heading.append(title, preview); - const status = document.createElement("span"); - status.className = "continuity-event-status"; - status.textContent = statusLabel(event.status); - const arrow = document.createElement("span"); - arrow.className = "continuity-event-arrow"; - arrow.setAttribute("aria-hidden", "true"); - arrow.textContent = "↘"; - toggle.append(position, heading, status, arrow); - - const body = document.createElement("div"); - body.className = "continuity-event-body"; - const metadata = document.createElement("dl"); - metadata.className = "continuity-event-meta"; - if (event.actor && event.summary) { - appendContinuityMeta(metadata, translate("receiverIdentity"), event.actor); - } - if (event.selected_revision !== null) { - appendContinuityMeta(metadata, translate("eventRevision"), formatArtifactRef(event.selected_revision), {code: true}); - } - if (event.handoff_receipt_ref !== null) { - appendContinuityMeta(metadata, translate("eventReceipt"), formatSourceRef(event.handoff_receipt_ref), {code: true}); - } - if (event.receiver_checks !== null) { - appendContinuityMeta(metadata, translate("eventReceiverChecks"), formatReceiverChecks(event.receiver_checks)); - } - appendContinuityMeta(metadata, translate("eventSchema"), event.record_schema, {code: true}); - appendContinuityMeta(metadata, translate("eventSource"), formatSourceRef(event.source_ref), {code: true}); - body.appendChild(metadata); - - disclosure.append(toggle, body); - disclosure.addEventListener("toggle", () => { - item.dataset.open = String(disclosure.open); - if (disclosure.open) { - openContinuityEvents.set(scopeId, event.position); - for (const other of timeline.querySelectorAll("details[open]")) { - if (other !== disclosure) { - other.open = false; - } - } - } else if (openContinuityEvents.get(scopeId) === event.position) { - openContinuityEvents.delete(scopeId); - } - }); - item.appendChild(disclosure); - return item; -} - -function appendContinuityMeta(metadata, labelText, value, {code = false} = {}) { - const item = document.createElement("div"); - const label = document.createElement("dt"); - label.textContent = labelText; - const detail = document.createElement("dd"); - const content = document.createElement(code ? "code" : "span"); - content.textContent = value; - detail.appendChild(content); - item.append(label, detail); - metadata.appendChild(item); -} - -function formatSourceRef(reference) { - const sourceType = reference.source_type || reference.name; - return `${sourceType}/${reference.source_id}`; -} - -function formatReceiverChecks(checks) { - return [ - `${translate("liveStateCheck")}: ${statusLabel(checks.live_state)}`, - `${translate("capabilityCheck")}: ${statusLabel(checks.capability)}`, - `${translate("authorizationCheck")}: ${statusLabel(checks.authorization)}` - ].join(" / "); -} - -function statusBadge(status) { - const badge = document.createElement("span"); - badge.className = `status-badge status-${status.replaceAll("_", "-")}`; - badge.textContent = statusLabel(status); - return badge; -} - -function statusLabel(status) { - return translate(status); -} - -async function downloadMarkdown() { - const token = readServerToken(); - if (!token || currentProject === null) { - showLogin(); - return; - } +async function downloadMarkdown(token) { + const choice = selectedChoice(); + if (choice === null) return; setBusy(true); - clearReportError(); try { - const periodSelection = resolveSelectedPeriod(currentProject); const response = await fetchWithBearer("/v1/handoff-reports/get", token, { method: "POST", headers: {"Content-Type": "application/json"}, - body: JSON.stringify({ - scope_id: currentProject.project_id, - locale: ui.locale() === "zh" ? "zh-CN" : "en", - include_evidence_checks: true, - format: "markdown", - include_archived: false, - download: true, - period: periodSelection.period - }) + body: JSON.stringify({selection: choice.selection, format: "markdown", download: true}) }); - if (response.status === 401) { - clearServerToken(); - showLogin("authRejected"); - return; - } if (!response.ok) { - showReportError("downloadFailed", {status: response.status}); + showStatus("requestFailed", {status: response.status}); return; } - const blob = await response.blob(); - const url = URL.createObjectURL(blob); const link = document.createElement("a"); - link.href = url; + link.href = URL.createObjectURL(await response.blob()); link.download = "handoff-report.md"; link.click(); - URL.revokeObjectURL(url); + URL.revokeObjectURL(link.href); } catch (error) { - showReportError("serverUnavailable"); + showStatus("serverUnavailable"); } finally { setBusy(false); } } -function setBusy(busy) { - previewRetryButton.disabled = busy; - refreshButton.disabled = busy; - downloadButton.disabled = busy; - applyCustomPeriodButton.disabled = busy; - periodStartInput.disabled = busy; - periodEndInput.disabled = busy; - periodButtons.forEach((button) => { - button.disabled = busy; - }); - projectSearchInput.disabled = busy || revisionSaving || editorDirty; - if (busy) { - closeProjectOptions({restoreSelection: true}); - } +function selectedChoice() { + return buildScopeSelectionChoices(scopes, translate).find((choice) => choice.key === selectedKey) || null; } -function startAutoRefresh() { - if (autoRefreshTimer === null) { - autoRefreshTimer = window.setInterval(() => { - void autoRefreshReport(); - }, autoRefreshIntervalMilliseconds); +function renderChoices() { + if (scopeSelect === null) return; + scopeSelect.replaceChildren(); + for (const choice of buildScopeSelectionChoices(scopes, translate)) { + const option = document.createElement("option"); + option.value = choice.key; + option.textContent = choice.label; + option.selected = choice.key === selectedKey; + scopeSelect.appendChild(option); } - updateAutoRefreshStatus(); } -function stopAutoRefresh() { - if (autoRefreshTimer !== null) { - window.clearInterval(autoRefreshTimer); - autoRefreshTimer = null; - } - autoRefreshStatus.textContent = ""; - autoRefreshStatus.dataset.state = "inactive"; -} - -async function autoRefreshReport() { - const token = readServerToken(); - if (document.hidden || reportLoading || token === null || currentProject === null) { - return; - } - if (editorDirty || revisionSaving) { - updateAutoRefreshStatus(); - return; - } - await loadReport(token, currentProject.project_id, {background: true}); -} - -function updateAutoRefreshStatus() { - if (autoRefreshTimer === null) { - return; - } - if (editorDirty) { - setAutoRefreshStatus("editing"); - } else if (revisionSaving) { - setAutoRefreshStatus("busy"); - } else { - setAutoRefreshStatus("active"); - } -} - -function setAutoRefreshStatus(state) { - const translationKeys = { - active: "autoRefreshActive", - busy: "autoRefreshBusy", - editing: "autoRefreshEditing", - failed: "autoRefreshFailed", - refreshing: "autoRefreshing", - updated: "autoRefreshUpdated" - }; - autoRefreshStatus.dataset.state = state; - autoRefreshStatus.textContent = translate(translationKeys[state]); -} - -function resolveSelectedPeriod(project) { - if (project === null || project === undefined) { - throw new Error("reportUnavailable"); - } - return resolvePeriodSelection( - currentPeriodMode, - project.timezone, - appliedCustomRange || {startDate: periodStartInput.value, endDate: periodEndInput.value} - ); -} - -function renderPeriodControls(report = null) { - periodButtons.forEach((button) => { - button.setAttribute("aria-pressed", String(button.dataset.periodMode === currentPeriodMode)); - }); - customPeriodForm.classList.toggle("is-active", currentPeriodMode === "custom"); - const project = currentProject || currentProjects[0] || null; - if (currentPeriodSelection === null && project !== null && currentPeriodMode !== "custom") { - currentPeriodSelection = resolveSelectedPeriod(project); - } - const selection = currentPeriodSelection; - if (selection === null) { - setText("period-summary-label", ""); - setText("period-comparison", ""); - setText("period-boundary-note", ""); - return; - } - if (currentPeriodMode !== "custom") { - periodStartInput.value = selection.startDate; - periodEndInput.value = selection.endDate; - } - updatePeriodInputBounds(); - setText("period-summary-label", translate("periodSummary", { - preset: translate(currentPeriodMode), - range: formatDateRange(selection.startDate, selection.endDate, ui.localeTag()), - timezone: selection.period.timezone - })); - const comparison = report?.period_comparison; - setText("period-comparison", comparison === null || comparison === undefined - ? "" - : translate("periodComparison", { - current: formatNumber(comparison.current_activity_count), - previous: formatNumber(comparison.previous_activity_count), - delta: formatSignedNumber(comparison.activity_delta) - })); - setText("period-boundary-note", comparison?.handoff_boundary_coverage === "unavailable" - ? translate("periodBoundaryUnavailable") - : ""); -} - -function updatePeriodInputBounds() { - periodStartInput.setAttribute("aria-invalid", "false"); - periodEndInput.setAttribute("aria-invalid", "false"); -} - -function showPeriodError(key) { - periodError.textContent = translate(key); - periodStartInput.setAttribute("aria-invalid", "true"); - periodEndInput.setAttribute("aria-invalid", "true"); -} - -function clearPeriodError() { - periodError.textContent = ""; - updatePeriodInputBounds(); -} - -function showReportError(key, values = {}) { - reportError.textContent = translate(key, values); -} - -function clearReportError() { - reportError.textContent = ""; -} - -function rememberSelectedProject(projectId) { - try { - sessionStorage.setItem(selectedProjectKey, projectId); - } catch (error) { - // Project selection remains valid for the current render. - } +function renderReport(report) { + setText("continuable-count", report.summary.continuable_count); + setText("blocked-count", report.summary.blocked_count); + setText("complete-count", report.summary.complete_count); + setText("no-handoff-count", report.summary.no_handoff_count); + setText("generated-at", formatDateTime(report.generated_at)); + setText("selection-digest", report.selection_digest); + setText("report-digest", report.report_digest); + const rows = document.getElementById("scope-report-rows"); + rows.replaceChildren(); + for (const entry of report.scopes) { + const row = document.createElement("tr"); + appendCell(row, entry.scope.title, entry.scope.scope_id); + appendCell(row, entry.scope.parent_scope_id || "—"); + appendCell(row, translate(entry.status)); + appendCell(row, entry.content?.objective || "—"); + appendCell(row, entry.content?.next_action?.text || "—"); + appendCell(row, formatAddress(entry.handoff), null, true); + rows.appendChild(row); + } +} + +function appendCell(row, value, detail = null, code = false) { + const cell = document.createElement("td"); + const primary = document.createElement(code ? "code" : "span"); + primary.textContent = value; + cell.appendChild(primary); + if (detail !== null) { + const secondary = document.createElement("code"); + secondary.textContent = detail; + cell.appendChild(document.createElement("br")); + cell.appendChild(secondary); + } + row.appendChild(cell); +} + +function formatAddress(address) { + if (address === null) return "—"; + const artifact = address.artifact; + return `${address.scope_id}/${artifact.family}/${artifact.artifact_id}@${artifact.revision}`; +} + +function showLogin(messageKey = "") { + requests.cancel(); + currentReport = null; + reportShell.hidden = true; + pageStatus.hidden = true; + authShell.hidden = false; + signOut.hidden = true; + authError.textContent = messageKey ? translate(messageKey) : ""; + tokenInput.focus(); } -function readSelectedProject() { - try { - return sessionStorage.getItem(selectedProjectKey); - } catch (error) { - return null; - } +function showStatus(key, values = {}) { + currentStatus = {key, values}; + currentReport = null; + authShell.hidden = true; + reportShell.hidden = true; + pageStatus.hidden = false; + pageStatusRetry.hidden = false; + renderStatus(); } -function rememberSelectedWork(projectId, scopeId) { - try { - sessionStorage.setItem(selectedWorkKey, JSON.stringify([projectId, scopeId])); - } catch (error) { - // Work selection remains valid for the current render. +function renderStatus() { + if (currentStatus !== null) { + pageStatusMessage.textContent = translate(currentStatus.key, currentStatus.values); } } -function readSelectedWorkLocation() { - try { - const value = JSON.parse(sessionStorage.getItem(selectedWorkKey)); - return Array.isArray(value) && value.length === 2 && value.every((part) => typeof part === "string") - ? {projectId: value[0], scopeId: value[1]} - : null; - } catch (error) { - return null; - } +function setBusy(busy) { + scopeSelect.disabled = busy; + refreshButton.disabled = busy; + downloadButton.disabled = busy; } function setText(id, value) { - document.getElementById(id).textContent = value; -} - -function formatSignedNumber(value) { - return new Intl.NumberFormat(ui.localeTag(), {signDisplay: "always"}).format(value); + document.getElementById(id).textContent = String(value); } ui.initialize(); diff --git a/src/powercontext/server/static/scope-selection.js b/src/powercontext/server/static/scope-selection.js new file mode 100644 index 000000000..510d3e496 --- /dev/null +++ b/src/powercontext/server/static/scope-selection.js @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2026 OceanBase. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + */ + +"use strict"; + +export function buildScopeSelectionChoices(scopes, translate) { + const choices = [{key: "all", label: translate("allScopes"), selection: {mode: "all"}}]; + for (const scope of scopes.filter((item) => item.parent_scope_id === null)) { + choices.push({ + key: `subtree:${scope.scope_id}`, + label: translate("subtreeView", {title: scope.display_name || scope.title}), + selection: {mode: "subtree", root_scope_id: scope.scope_id} + }); + } + for (const scope of scopes) { + choices.push({ + key: `exact:${scope.scope_id}`, + label: translate("exactFocus", {title: scope.display_name || scope.title}), + selection: {mode: "exact", scope_ids: [scope.scope_id]} + }); + } + return choices; +} diff --git a/src/powercontext/server/templates/pages/handoff_report.html b/src/powercontext/server/templates/pages/handoff_report.html index 3a9cc941a..0d93f3321 100644 --- a/src/powercontext/server/templates/pages/handoff_report.html +++ b/src/powercontext/server/templates/pages/handoff_report.html @@ -1,17 +1,6 @@ {% extends "base.html" %} @@ -25,485 +14,65 @@ {% set status_title = "Handoff Report" %} {% include "components/status.html" %} - -