From e39379327f11183525881d055148b77776a0f98b Mon Sep 17 00:00:00 2001 From: Chojan Shang Date: Wed, 2 Sep 2026 15:24:38 +0800 Subject: [PATCH 01/17] feat(scope): add durable organization and bindings --- .../builtin/persistence/tables.py | 86 +++++- src/powercontext/builtin/scope/__init__.py | 52 ++++ src/powercontext/builtin/scope/application.py | 246 +++++++++++++++ src/powercontext/builtin/scope/errors.py | 57 ++++ src/powercontext/builtin/scope/models.py | 232 ++++++++++++++ src/powercontext/builtin/scope/repository.py | 289 ++++++++++++++++++ src/powercontext/limits.py | 8 + tests/builtin/test_scope_application.py | 238 +++++++++++++++ 8 files changed, 1207 insertions(+), 1 deletion(-) 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..5085d1561 100644 --- a/src/powercontext/builtin/persistence/tables.py +++ b/src/powercontext/builtin/persistence/tables.py @@ -39,7 +39,15 @@ 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_EXTERNAL_REFERENCE_VALUE_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 +83,82 @@ 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_EXTERNAL_REFERENCE_VALUE_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_digest", 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 +536,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/scope/__init__.py b/src/powercontext/builtin/scope/__init__.py new file mode 100644 index 000000000..04d76b375 --- /dev/null +++ b/src/powercontext/builtin/scope/__init__.py @@ -0,0 +1,52 @@ +# 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. + +"""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..34b406734 --- /dev/null +++ b/src/powercontext/builtin/scope/application.py @@ -0,0 +1,246 @@ +# 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 Scope ownership, organization, and binding.""" + +from __future__ import annotations + +import asyncio +import hashlib +import secrets +from collections import deque +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 + self._write_lock = asyncio.Lock() + + 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: + async with self._write_lock: + return await self._create(draft) + + 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._write_lock: + return await self._update(scope_id, mutation) + + 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._write_lock, 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._write_lock, 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._write_lock, 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._repository.get_many(connection, selection.scope_ids) + found = {scope.scope_id for scope in resolved} + if missing := next((scope_id for scope_id in selection.scope_ids if scope_id not in found), None): + raise ScopeNotFoundError(missing) + return resolved + root_scope_id = selection.root_scope_id + if root_scope_id is None: + raise AssertionError + scopes = await self._repository.list(connection) + return _subtree(scopes, root_scope_id) + + 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() + + +def _subtree(scopes: tuple[ScopeDescriptor, ...], root_scope_id: str) -> tuple[ScopeDescriptor, ...]: + by_id = {scope.scope_id: scope for scope in scopes} + root = by_id.get(root_scope_id) + if root is None: + raise ScopeNotFoundError(root_scope_id) + children: dict[str, list[ScopeDescriptor]] = {} + for scope in scopes: + if scope.parent_scope_id is not None: + children.setdefault(scope.parent_scope_id, []).append(scope) + resolved = [root] + pending = deque([root.scope_id]) + visited = {root.scope_id} + while pending: + parent_scope_id = pending.popleft() + for child in children.get(parent_scope_id, ()): + if child.scope_id in visited: + continue + visited.add(child.scope_id) + resolved.append(child) + pending.append(child.scope_id) + return tuple(resolved) diff --git a/src/powercontext/builtin/scope/errors.py b/src/powercontext/builtin/scope/errors.py new file mode 100644 index 000000000..a55548877 --- /dev/null +++ b/src/powercontext/builtin/scope/errors.py @@ -0,0 +1,57 @@ +# 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. + +"""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..a3e6b192e --- /dev/null +++ b/src/powercontext/builtin/scope/models.py @@ -0,0 +1,232 @@ +# 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. + +"""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_serializer, 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 tuple(sorted(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 tuple(sorted(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 tuple(sorted(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 + + @model_serializer(mode="plain") + def serialize(self) -> dict[str, object]: + if self.mode == "exact": + return {"mode": self.mode, "scope_ids": self.scope_ids} + if self.mode == "subtree": + return {"mode": self.mode, "root_scope_id": self.root_scope_id} + return {"mode": self.mode} + + +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..f1e7f2008 --- /dev/null +++ b/src/powercontext/builtin/scope/repository.py @@ -0,0 +1,289 @@ +# 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. + +"""Relational persistence for durable Scope organization.""" + +from __future__ import annotations + +from collections import defaultdict +from hashlib import sha256 + +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: + scopes = await self.get_many(connection, (scope_id,)) + return None if not scopes else scopes[0] + + async def get_many( + self, + connection: AsyncConnection, + scope_ids: tuple[str, ...], + /, + ) -> tuple[ScopeDescriptor, ...]: + if not scope_ids: + return () + return await self._load( + connection, + select(SCOPES_TABLE).where(SCOPES_TABLE.c.scope_id.in_(scope_ids)).order_by(SCOPES_TABLE.c.scope_id), + ) + + async def list(self, connection: AsyncConnection, /) -> tuple[ScopeDescriptor, ...]: + return await self._load(connection, select(SCOPES_TABLE).order_by(SCOPES_TABLE.c.scope_id)) + + async def _load(self, connection: AsyncConnection, statement, /) -> tuple[ScopeDescriptor, ...]: + rows = tuple((await connection.execute(statement)).mappings()) + scope_ids = tuple(str(row["scope_id"]) for row in rows) + if not scope_ids: + return () + context_references: dict[str, list[str]] = defaultdict(list) + for row in await connection.execute( + select( + SCOPE_CONTEXT_REFERENCES_TABLE.c.scope_id, + SCOPE_CONTEXT_REFERENCES_TABLE.c.referenced_scope_id, + ) + .where(SCOPE_CONTEXT_REFERENCES_TABLE.c.scope_id.in_(scope_ids)) + .order_by( + SCOPE_CONTEXT_REFERENCES_TABLE.c.scope_id, + SCOPE_CONTEXT_REFERENCES_TABLE.c.referenced_scope_id, + ) + ): + context_references[str(row.scope_id)].append(str(row.referenced_scope_id)) + external_references: dict[str, list[ScopeExternalReference]] = defaultdict(list) + for row in await connection.execute( + select( + SCOPE_EXTERNAL_REFERENCES_TABLE.c.scope_id, + SCOPE_EXTERNAL_REFERENCES_TABLE.c.kind, + SCOPE_EXTERNAL_REFERENCES_TABLE.c.value, + ) + .where(SCOPE_EXTERNAL_REFERENCES_TABLE.c.scope_id.in_(scope_ids)) + .order_by( + SCOPE_EXTERNAL_REFERENCES_TABLE.c.scope_id, + SCOPE_EXTERNAL_REFERENCES_TABLE.c.ordinal, + ) + ): + external_references[str(row.scope_id)].append( + ScopeExternalReference(kind=str(row.kind), value=str(row.value)) + ) + return tuple( + ScopeDescriptor( + scope_id=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=tuple(context_references[scope_id]), + external_references=tuple(external_references[scope_id]), + version=int(row["version"]), + ) + for row, scope_id in zip(rows, scope_ids, strict=True) + ) + + 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, + "value_digest": sha256(reference.value.encode()).hexdigest(), + } + 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..a56c5735a --- /dev/null +++ b/tests/builtin/test_scope_application.py @@ -0,0 +1,238 @@ +# 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 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, + ScopeBindingNotFoundError, + ScopeDraft, + ScopeIdempotencyConflictError, + ScopeMutation, + ScopeRelationshipError, + ScopeSelection, +) + + +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_concurrent_scope_creation_returns_one_scope(tmp_path) -> None: + async def scenario() -> None: + config = SQLiteConfig(url=f"sqlite+aiosqlite:///{tmp_path / 'runtime.db'}") + async with SQLiteProfile.open(config, tables=BUILTIN_TABLES) as profile: + scopes = ScopeApplication(profile.database) + draft = ScopeDraft(title="Feature", summary="Concurrent creation", idempotency_key="concurrent") + + created = await asyncio.gather(*(scopes.create(draft) for _ in range(8))) + + assert len({scope.scope_id for scope in created}) == 1 + + 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_concurrent_parent_updates_cannot_form_a_cycle() -> None: + async def scenario() -> None: + async with SQLiteProfile.open(SQLiteConfig(), tables=BUILTIN_TABLES) as profile: + scopes = ScopeApplication(profile.database) + left = await scopes.create(ScopeDraft(title="Left", summary="Left", idempotency_key="left")) + right = await scopes.create(ScopeDraft(title="Right", summary="Right", idempotency_key="right")) + + updates = await asyncio.gather( + scopes.update( + left.scope_id, + ScopeMutation( + expected_version=left.version, + title=left.title, + summary=left.summary, + parent_scope_id=right.scope_id, + ), + ), + scopes.update( + right.scope_id, + ScopeMutation( + expected_version=right.version, + title=right.title, + summary=right.summary, + parent_scope_id=left.scope_id, + ), + ), + return_exceptions=True, + ) + + assert sum(isinstance(result, ScopeRelationshipError) for result in updates) == 1 + + asyncio.run(scenario()) + + +def test_context_reference_order_does_not_change_scope_creation() -> 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, shared.scope_id), + idempotency_key="current", + ) + ) + + assert current.context_references == tuple(sorted((middle.scope_id, shared.scope_id))) + assert ( + await scopes.create( + ScopeDraft( + title="Current", + summary="Reads only Middle", + context_references=(shared.scope_id, middle.scope_id), + idempotency_key="current", + ) + ) + == current + ) + + asyncio.run(scenario()) + + +def test_exact_selection_is_a_canonical_set() -> None: + async def scenario() -> None: + async with SQLiteProfile.open(SQLiteConfig(), tables=BUILTIN_TABLES) as profile: + scopes = ScopeApplication(profile.database) + left = await scopes.create(ScopeDraft(title="Left", summary="Left", idempotency_key="left")) + right = await scopes.create(ScopeDraft(title="Right", summary="Right", idempotency_key="right")) + + first = ScopeSelection(mode="exact", scope_ids=(left.scope_id, right.scope_id)) + second = ScopeSelection(mode="exact", scope_ids=(right.scope_id, left.scope_id)) + + assert first == second + assert await scopes.resolve_selection(first) == await scopes.resolve_selection(second) + + asyncio.run(scenario()) + + +def test_binding_resolution_uses_explicit_durable_then_default_precedence() -> None: + async def scenario() -> None: + async with SQLiteProfile.open(SQLiteConfig(), tables=BUILTIN_TABLES) as profile: + scopes = ScopeApplication(profile.database) + default = await scopes.bootstrap_default() + durable = await scopes.create( + ScopeDraft(title="Durable", summary="Durable binding", idempotency_key="durable") + ) + explicit = await scopes.create( + ScopeDraft(title="Explicit", summary="Explicit binding", idempotency_key="explicit") + ) + missing_key = ScopeBindingKey(integration="codex", kind="session", external_id="missing") + durable_key = ScopeBindingKey(integration="codex", kind="workspace", external_id="workspace") + await scopes.bind(durable_key, durable.scope_id) + + assert ( + await scopes.resolve_binding( + explicit_scope_id=explicit.scope_id, + binding_keys=(durable_key,), + ) + == explicit + ) + assert await scopes.resolve_binding(binding_keys=(missing_key, durable_key)) == durable + assert await scopes.resolve_binding(binding_keys=(missing_key,)) == default + + await scopes.clear_binding(durable_key) + assert await scopes.resolve_binding(binding_keys=(durable_key,)) == default + + asyncio.run(scenario()) + + +def test_binding_resolution_fails_without_any_binding() -> None: + async def scenario() -> None: + async with SQLiteProfile.open(SQLiteConfig(), tables=BUILTIN_TABLES) as profile: + scopes = ScopeApplication(profile.database) + + with pytest.raises(ScopeBindingNotFoundError): + await scopes.resolve_binding() + + asyncio.run(scenario()) From a1d0fb0f6d97fb1e52a5df8007ee56d5316cc78b Mon Sep 17 00:00:00 2001 From: Chojan Shang Date: Wed, 2 Sep 2026 15:27:35 +0800 Subject: [PATCH 02/17] feat(migration): register existing runtime scopes --- src/powercontext/builtin/scope/application.py | 13 +++++ src/powercontext/builtin/scope/repository.py | 50 ++++++++++++++++++- tests/builtin/test_scope_application.py | 29 ++++++++++- 3 files changed, 90 insertions(+), 2 deletions(-) diff --git a/src/powercontext/builtin/scope/application.py b/src/powercontext/builtin/scope/application.py index 34b406734..6c68451bd 100644 --- a/src/powercontext/builtin/scope/application.py +++ b/src/powercontext/builtin/scope/application.py @@ -62,6 +62,9 @@ async def bootstrap_default(self) -> ScopeDescriptor: existing = await self.default_scope() if existing is not None: return existing + scopes = await self.list() + if len(scopes) == 1: + return await self.set_default(scopes[0].scope_id) created = await self.create( ScopeDraft( title="Default", @@ -72,6 +75,16 @@ async def bootstrap_default(self) -> ScopeDescriptor: await self.set_default(created.scope_id) return created + async def register_existing(self) -> tuple[ScopeDescriptor, ...]: + """Register Scope identities already present in durable runtime data.""" + + async with self._write_lock, self._database.transaction() as connection: + scope_ids = await self._repository.unregistered_runtime_scope_ids(connection) + registered = [] + for scope_id in scope_ids: + registered.append(await self._repository.register_existing(connection, scope_id)) + return tuple(registered) + async def create(self, draft: ScopeDraft, /) -> ScopeDescriptor: async with self._write_lock: return await self._create(draft) diff --git a/src/powercontext/builtin/scope/repository.py b/src/powercontext/builtin/scope/repository.py index f1e7f2008..765610ed4 100644 --- a/src/powercontext/builtin/scope/repository.py +++ b/src/powercontext/builtin/scope/repository.py @@ -19,16 +19,24 @@ from collections import defaultdict from hashlib import sha256 -from sqlalchemy import delete, insert, select, update +from sqlalchemy import delete, insert, select, union, update from sqlalchemy.ext.asyncio import AsyncConnection from powercontext.builtin.persistence.tables import ( + ARTIFACT_CANDIDATE_VERSIONS_TABLE, + ARTIFACTS_TABLE, + EXTERNAL_SKILL_REGISTRATIONS_TABLE, + MODEL_USAGE_DAILY_TABLE, + RECALL_TOKEN_DAILY_TABLE, SCOPE_BINDINGS_TABLE, SCOPE_CONTEXT_REFERENCES_TABLE, SCOPE_CREATION_REQUESTS_TABLE, SCOPE_EXTERNAL_REFERENCES_TABLE, SCOPE_SETTINGS_TABLE, SCOPES_TABLE, + SOURCE_CURSORS_TABLE, + SOURCE_JOURNAL_HEADS_TABLE, + SOURCES_TABLE, ) from powercontext.builtin.scope.models import ( ScopeBinding, @@ -40,9 +48,49 @@ ) _DEFAULT_SETTING = "default" +_RUNTIME_SCOPE_TABLES = ( + SOURCES_TABLE, + SOURCE_JOURNAL_HEADS_TABLE, + ARTIFACTS_TABLE, + ARTIFACT_CANDIDATE_VERSIONS_TABLE, + SOURCE_CURSORS_TABLE, + EXTERNAL_SKILL_REGISTRATIONS_TABLE, + MODEL_USAGE_DAILY_TABLE, + RECALL_TOKEN_DAILY_TABLE, +) class ScopeRepository: + async def unregistered_runtime_scope_ids(self, connection: AsyncConnection, /) -> tuple[str, ...]: + discovered = union(*(select(table.c.scope_id) for table in _RUNTIME_SCOPE_TABLES)).subquery() + values = await connection.execute( + select(discovered.c.scope_id) + .outerjoin(SCOPES_TABLE, SCOPES_TABLE.c.scope_id == discovered.c.scope_id) + .where(SCOPES_TABLE.c.scope_id.is_(None)) + .order_by(discovered.c.scope_id) + ) + return tuple(str(scope_id) for scope_id in values.scalars()) + + async def register_existing( + self, + connection: AsyncConnection, + scope_id: str, + /, + ) -> ScopeDescriptor: + await connection.execute( + insert(SCOPES_TABLE).values( + scope_id=scope_id, + title=scope_id, + summary="Registered from existing runtime data.", + parent_scope_id=None, + version=1, + ) + ) + registered = await self.get(connection, scope_id) + if registered is None: + raise AssertionError + return registered + async def get(self, connection: AsyncConnection, scope_id: str, /) -> ScopeDescriptor | None: scopes = await self.get_many(connection, (scope_id,)) return None if not scopes else scopes[0] diff --git a/tests/builtin/test_scope_application.py b/tests/builtin/test_scope_application.py index a56c5735a..c6ccef741 100644 --- a/tests/builtin/test_scope_application.py +++ b/tests/builtin/test_scope_application.py @@ -20,7 +20,7 @@ import pytest from powercontext.builtin.persistence.sqlite import SQLiteConfig, SQLiteProfile -from powercontext.builtin.persistence.tables import BUILTIN_TABLES +from powercontext.builtin.persistence.tables import BUILTIN_TABLES, SOURCES_TABLE from powercontext.builtin.scope import ( ScopeApplication, ScopeBindingKey, @@ -50,6 +50,33 @@ async def scenario() -> None: asyncio.run(scenario()) +def test_existing_runtime_scope_is_registered_and_reused_as_default() -> None: + async def scenario() -> None: + async with SQLiteProfile.open(SQLiteConfig(), tables=BUILTIN_TABLES) as profile: + async with profile.database.transaction() as connection: + await connection.execute( + SOURCES_TABLE.insert().values( + scope_id="legacy-scope", + source_type="text", + source_id="source-1", + payload=b"existing", + journal_position=1, + ) + ) + scopes = ScopeApplication(profile.database) + + registered = await scopes.register_existing() + default = await scopes.bootstrap_default() + + assert tuple(scope.scope_id for scope in registered) == ("legacy-scope",) + assert await scopes.register_existing() == () + assert default.scope_id == "legacy-scope" + assert default.title == "legacy-scope" + assert default.summary == "Registered from existing runtime data." + + 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: From 99e4088ac13637913da05273c4718605e4255bcb Mon Sep 17 00:00:00 2001 From: Chojan Shang Date: Wed, 2 Sep 2026 15:32:56 +0800 Subject: [PATCH 03/17] feat(artifact): publish exact revisions across scopes --- src/powercontext/__init__.py | 2 + src/powercontext/artifacts/__init__.py | 3 +- src/powercontext/artifacts/models.py | 28 ++- .../builtin/persistence/artifacts.py | 97 ++++++++ .../builtin/persistence/errors.py | 9 + .../builtin/persistence/tables.py | 38 +++ src/powercontext/builtin/publication.py | 221 ++++++++++++++++++ tests/builtin/test_publication.py | 138 +++++++++++ 8 files changed, 534 insertions(+), 2 deletions(-) create mode 100644 src/powercontext/builtin/publication.py create mode 100644 tests/builtin/test_publication.py 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..a151107d1 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,11 +44,37 @@ 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.""" sources: tuple[SourceRef, ...] = () artifacts: tuple[ArtifactRef, ...] = () + publication_source: ArtifactAddress | None = None + publication_digest: str | None = None + + @model_validator(mode="after") + def require_complete_publication_provenance(self): + if (self.publication_source is None) != (self.publication_digest is None): + raise ValueError("publication source and digest must be provided together") # noqa: TRY003 + return self class ArtifactDraft(BaseModel, Generic[ContentT]): diff --git a/src/powercontext/builtin/persistence/artifacts.py b/src/powercontext/builtin/persistence/artifacts.py index 0e65140df..0e8fd1ec7 100644 --- a/src/powercontext/builtin/persistence/artifacts.py +++ b/src/powercontext/builtin/persistence/artifacts.py @@ -26,6 +26,7 @@ from powercontext.artifacts import ( Artifact, + ArtifactAddress, ArtifactDraft, ArtifactLineage, ArtifactRef, @@ -33,6 +34,7 @@ from powercontext.builtin.persistence.codec import dump_model, load_model, stored_bytes from powercontext.builtin.persistence.errors import ( IdentityMismatchError, + InvalidPublicationLineageError, InvalidRepositoryArgumentError, RepositoryNotFoundError, ) @@ -40,6 +42,7 @@ ARTIFACT_HEADS_TABLE, ARTIFACT_LINEAGE_ARTIFACTS_TABLE, ARTIFACT_LINEAGE_SOURCES_TABLE, + ARTIFACT_PUBLICATIONS_TABLE, ARTIFACTS_TABLE, ) from powercontext.errors import ( @@ -240,6 +243,42 @@ 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_address: ArtifactAddress, + source: Artifact[Any], + content_digest: str, + /, + ) -> 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( + publication_source=source.lineage.publication_source or source_address, + publication_digest=source.lineage.publication_digest or content_digest, + ), + ) + 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, @@ -370,6 +409,10 @@ async def _load_lineage( .order_by(ARTIFACT_LINEAGE_ARTIFACTS_TABLE.c.ordinal) ) ).all() + publication_source, publication_digest = await self._load_publication_provenance( + connection, + ArtifactAddress(scope_id=scope_id, artifact=ref), + ) return ArtifactLineage( sources=tuple(SourceRef(source_type=str(row.source_type), source_id=str(row.source_id)) for row in sources), artifacts=tuple( @@ -380,8 +423,62 @@ async def _load_lineage( ) for row in artifacts ), + publication_source=publication_source, + publication_digest=publication_digest, ) + async def _load_publication_provenance( + self, + connection: AsyncConnection, + target: ArtifactAddress, + ) -> tuple[ArtifactAddress | None, str | None]: + """Resolve a publication chain to the Scope that owns the original revision.""" + + current = target + digest: str | None = None + visited: set[tuple[str, str, str, int]] = set() + while True: + key = ( + current.scope_id, + current.artifact.family, + current.artifact.artifact_id, + current.artifact.revision, + ) + if key in visited: + raise InvalidPublicationLineageError("cycle") + visited.add(key) + + publication = ( + ( + await connection.execute( + select(ARTIFACT_PUBLICATIONS_TABLE).where( + ARTIFACT_PUBLICATIONS_TABLE.c.target_scope_id == current.scope_id, + ARTIFACT_PUBLICATIONS_TABLE.c.target_family == current.artifact.family, + ARTIFACT_PUBLICATIONS_TABLE.c.target_artifact_id == current.artifact.artifact_id, + ARTIFACT_PUBLICATIONS_TABLE.c.target_revision == current.artifact.revision, + ) + ) + ) + .mappings() + .one_or_none() + ) + if publication is None: + return (None, None) if digest is None else (current, digest) + + current_digest = str(publication["content_digest"]) + if digest is None: + digest = current_digest + elif digest != current_digest: + raise InvalidPublicationLineageError("digest") + current = ArtifactAddress( + scope_id=str(publication["source_scope_id"]), + artifact=ArtifactRef( + family=str(publication["source_family"]), + artifact_id=str(publication["source_artifact_id"]), + revision=int(publication["source_revision"]), + ), + ) + async def _head_conflict( self, connection: AsyncConnection, diff --git a/src/powercontext/builtin/persistence/errors.py b/src/powercontext/builtin/persistence/errors.py index 8c76e2023..d729fd85a 100644 --- a/src/powercontext/builtin/persistence/errors.py +++ b/src/powercontext/builtin/persistence/errors.py @@ -88,6 +88,15 @@ def __init__(self, column: str, expected: str) -> None: super().__init__(f"stored {column} column is not {expected}") +class InvalidPublicationLineageError(RepositoryError): + """Raised when persisted publication provenance cannot form one exact chain.""" + + def __init__(self, issue: str) -> None: + self.issue = issue + detail = "inconsistent content digests" if issue == "digest" else issue + super().__init__(f"invalid publication lineage: {detail}") + + class GenerationConflictError(RepositoryError): """Raised when Trigger State compare-and-swap observes another generation.""" diff --git a/src/powercontext/builtin/persistence/tables.py b/src/powercontext/builtin/persistence/tables.py index 5085d1561..716f53aee 100644 --- a/src/powercontext/builtin/persistence/tables.py +++ b/src/powercontext/builtin/persistence/tables.py @@ -270,6 +270,43 @@ 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, @@ -438,6 +475,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..8f5b7329f --- /dev/null +++ b/src/powercontext/builtin/publication.py @@ -0,0 +1,221 @@ +# 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 asyncio +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.errors import PowerContextError +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(PowerContextError, 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 + self._write_lock = asyncio.Lock() + + async def publish(self, request: ArtifactPublicationRequest, /) -> ArtifactPublication: + async with self._write_lock: + return await self._publish(request) + + 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) + content_digest = hashlib.sha256(dump_model(source.content, kind="artifact", name=source.family)).hexdigest() + target = await self._artifacts.copy_exact( + connection, + request.target_scope_id, + self._id_factory(), + request.source, + source, + content_digest, + ) + publication = ArtifactPublication( + source=request.source, + target=ArtifactAddress(scope_id=request.target_scope_id, artifact=target.as_ref()), + content_digest=content_digest, + ) + 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/tests/builtin/test_publication.py b/tests/builtin/test_publication.py new file mode 100644 index 000000000..d9baa68d6 --- /dev/null +++ b/tests/builtin/test_publication.py @@ -0,0 +1,138 @@ +# 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.persistence.artifacts import ArtifactRepository +from powercontext.builtin.persistence.sqlite import SQLiteConfig, SQLiteProfile +from powercontext.builtin.persistence.tables import BUILTIN_TABLES +from powercontext.builtin.publication import ( + ArtifactPublicationApplication, + ArtifactPublicationConflictError, + ArtifactPublicationRequest, +) +from powercontext.builtin.scope import ScopeApplication, ScopeDraft +from tests.builtin.persistence.contract import Report, ReportContent, ReportDraft + + +def test_publication_copies_one_exact_revision_with_original_provenance() -> None: + async def scenario() -> None: + async with SQLiteProfile.open(SQLiteConfig(), tables=BUILTIN_TABLES) as profile: + scopes = ScopeApplication(profile.database) + source_scope = await scopes.create( + ScopeDraft(title="Source", summary="Private working state", idempotency_key="source") + ) + target_scope = await scopes.create( + ScopeDraft(title="Target", summary="Accepted result", idempotency_key="target") + ) + relay_scope = await scopes.create( + ScopeDraft(title="Relay", summary="Relayed result", idempotency_key="relay") + ) + artifacts = ArtifactRepository((Report,)) + publications = ArtifactPublicationApplication(profile.database, artifacts, scopes) + async with profile.database.transaction() as connection: + first = await artifacts.create( + connection, + source_scope.scope_id, + "report", + ReportDraft(content=ReportContent(status="ready")), + ) + + request = ArtifactPublicationRequest( + source=ArtifactAddress(scope_id=source_scope.scope_id, artifact=first.as_ref()), + target_scope_id=target_scope.scope_id, + idempotency_key="accepted-report", + ) + results = await asyncio.gather(*(publications.publish(request) for _ in range(8))) + published = results[0] + assert all(result == published for result in results) + + async with profile.database.transaction() as connection: + revised = await artifacts.revise( + connection, + source_scope.scope_id, + first, + ReportDraft(content=ReportContent(status="changed")), + ) + copied = await artifacts.get(connection, published.target.scope_id, published.target.artifact) + + assert revised.revision == 2 + assert copied.content == first.content + assert copied.lineage.publication_source == request.source + assert copied.lineage.publication_digest == published.content_digest + + relayed = await publications.publish( + ArtifactPublicationRequest( + source=published.target, + target_scope_id=relay_scope.scope_id, + idempotency_key="relayed-report", + ) + ) + async with profile.database.transaction() as connection: + relay_copy = await artifacts.get(connection, relayed.target.scope_id, relayed.target.artifact) + + assert relay_copy.lineage.publication_source == request.source + assert relay_copy.lineage.publication_digest == published.content_digest + + asyncio.run(scenario()) + + +def test_publication_idempotency_key_cannot_select_another_revision() -> None: + async def scenario() -> None: + async with SQLiteProfile.open(SQLiteConfig(), tables=BUILTIN_TABLES) as profile: + scopes = ScopeApplication(profile.database) + source_scope = await scopes.create( + ScopeDraft(title="Source", summary="Private working state", idempotency_key="source") + ) + target_scope = await scopes.create( + ScopeDraft(title="Target", summary="Accepted result", idempotency_key="target") + ) + artifacts = ArtifactRepository((Report,)) + publications = ArtifactPublicationApplication(profile.database, artifacts, scopes) + async with profile.database.transaction() as connection: + first = await artifacts.create( + connection, + source_scope.scope_id, + "report", + ReportDraft(content=ReportContent(status="ready")), + ) + second = await artifacts.revise( + connection, + source_scope.scope_id, + first, + ReportDraft(content=ReportContent(status="changed")), + ) + + await publications.publish( + ArtifactPublicationRequest( + source=ArtifactAddress(scope_id=source_scope.scope_id, artifact=first.as_ref()), + target_scope_id=target_scope.scope_id, + idempotency_key="accepted-report", + ) + ) + with pytest.raises(ArtifactPublicationConflictError): + await publications.publish( + ArtifactPublicationRequest( + source=ArtifactAddress(scope_id=source_scope.scope_id, artifact=second.as_ref()), + target_scope_id=target_scope.scope_id, + idempotency_key="accepted-report", + ) + ) + + asyncio.run(scenario()) From bbd4c4a19bc281f9aacc6067d918a551d36635b6 Mon Sep 17 00:00:00 2001 From: Chojan Shang Date: Wed, 2 Sep 2026 16:12:32 +0800 Subject: [PATCH 04/17] refactor(scope): replace project reporting with selections --- .env.example | 18 +- Makefile | 1 - openapi/powercontext.yaml | 3955 ++++++++--------- scripts/generate_api.py | 44 +- scripts/generate_js_operations.py | 38 +- .../builtin/artifacts/handoff/service.py | 48 +- .../builtin/handoff_report/__init__.py | 152 +- .../builtin/handoff_report/adapters.py | 48 +- .../builtin/handoff_report/application.py | 443 +- .../builtin/handoff_report/canonical.py | 99 +- .../builtin/handoff_report/catalog.py | 223 - .../builtin/handoff_report/catalog_store.py | 751 ---- .../builtin/handoff_report/errors.py | 173 +- .../builtin/handoff_report/models.py | 483 -- .../builtin/handoff_report/protocols.py | 41 +- .../builtin/handoff_report/rendering.py | 461 +- .../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 -- src/powercontext/builtin/runtime/__init__.py | 2 + .../builtin/runtime/application.py | 177 +- .../builtin/runtime/composition.py | 24 +- .../builtin/runtime/prepared_context.py | 110 +- src/powercontext/builtin/runtime/recall.py | 121 +- .../builtin/runtime/relational.py | 30 +- .../builtin/runtime/statistics.py | 42 +- src/powercontext/builtin/scope/application.py | 54 +- src/powercontext/builtin/scope/repository.py | 39 +- .../builtin/statistics/__init__.py | 2 + .../builtin/statistics/aggregation.py | 260 ++ src/powercontext/builtin/statistics/models.py | 23 +- src/powercontext/cli/config.py | 42 +- src/powercontext/cli/hosts.py | 6 - src/powercontext/cli/openclaw.py | 9 +- src/powercontext/cli/system.py | 17 - src/powercontext/cli/workbuddy.py | 12 +- src/powercontext/client/cli.py | 26 +- src/powercontext/client/client.py | 228 +- src/powercontext/http/__init__.py | 160 +- src/powercontext/http/_generated/models.py | 605 +-- .../http/_generated/operations.py | 654 ++- src/powercontext/http/_generated/schema.py | 1439 +++--- src/powercontext/server/app.py | 612 ++- src/powercontext/server/factory.py | 1 - src/powercontext/server/handoff_picker.py | 505 --- src/powercontext/server/mcp.py | 32 +- src/powercontext/server/settings.py | 24 - src/powercontext/server/static/dashboard.js | 54 +- .../server/static/handoff-report.js | 2254 +--------- .../server/static/scope-selection.js | 36 + src/powercontext/server/static/site.css | 178 - .../templates/pages/handoff_report.html | 492 +- src/powercontext/server/web.py | 62 +- 57 files changed, 4709 insertions(+), 12433 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 create mode 100644 src/powercontext/builtin/statistics/aggregation.py delete mode 100644 src/powercontext/server/handoff_picker.py create mode 100644 src/powercontext/server/static/scope-selection.js diff --git a/.env.example b/.env.example index 4cff4556a..93a89c57e 100644 --- a/.env.example +++ b/.env.example @@ -18,9 +18,8 @@ POWERCONTEXT_SERVER_AUTH_ENABLED=false # POWERCONTEXT_SERVER_AUTH_TOKEN=replace-me # Dashboard ------------------------------------------------------------------- -# Every Coding Agent below uses this same Scope ID. +# The Dashboard discovers Scopes from the Server. The Server creates a default Scope on first startup. POWERCONTEXT_SERVER_DASHBOARD_ENABLED=true -POWERCONTEXT_SERVER_DASHBOARD_SCOPES='[{"scope_id":"project:quickstart","display_name":"Quick Start"}]' # Logging, metrics, and tracing ----------------------------------------------- POWERCONTEXT_SERVER_LOGGING_LEVEL=INFO @@ -110,13 +109,14 @@ POWERCONTEXT_CLIENT_TIMEOUT=10 # POWERCONTEXT_CLIENT_API_TOKEN=replace-me # Coding Agent integrations --------------------------------------------------- -# Keep these values equal to the Dashboard scope above. Load .env before starting the selected Agent. -POWERCONTEXT_CODEX_SCOPE_ID=project:quickstart -POWERCONTEXT_CLAUDE_SCOPE_ID=project:quickstart -POWERCONTEXT_DSH_SCOPE_ID=project:quickstart -POWERCONTEXT_OPENCODE_SCOPE_ID=project:quickstart -POWERCONTEXT_PI_SCOPE_ID=project:quickstart -POWERCONTEXT_LANGGRAPH_SCOPE_ID=project:quickstart +# Load .env before starting the selected Agent. Codex binds each Session through the Scope service. +# Set an integration's SCOPE_ID only to select an existing Scope explicitly. +# POWERCONTEXT_CODEX_SCOPE_ID=scp_existing +# POWERCONTEXT_CLAUDE_SCOPE_ID=scp_existing +# POWERCONTEXT_DSH_SCOPE_ID=scp_existing +# POWERCONTEXT_OPENCODE_SCOPE_ID=scp_existing +# POWERCONTEXT_PI_SCOPE_ID=scp_existing +# POWERCONTEXT_LANGGRAPH_SCOPE_ID=scp_existing # Local Server endpoints used by integrations. POWERCONTEXT_CLAUDE_SERVER_URL=http://127.0.0.1:8000 diff --git a/Makefile b/Makefile index 7ec2c3b0c..81276ef3f 100644 --- a/Makefile +++ b/Makefile @@ -103,7 +103,6 @@ js-test: ## Install, build, and test the DeepSeek Harness plugin. @pnpm --dir integrations/dsh/plugins/powercontext test @pnpm --dir integrations/dsh/plugins/powercontext build @git diff --exit-code -- \ - integrations/dsh/plugins/powercontext/openapi/powercontext.yaml \ integrations/dsh/plugins/powercontext/src/operations.generated.ts \ integrations/dsh/plugins/powercontext/lib @pnpm --dir integrations/dsh/plugins/powercontext test diff --git a/openapi/powercontext.yaml b/openapi/powercontext.yaml index a38ba8922..7cde60e8f 100644 --- a/openapi/powercontext.yaml +++ b/openapi/powercontext.yaml @@ -79,12 +79,270 @@ 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/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/{scope_id}: + get: + tags: [scopes] + summary: Get one Scope descriptor + operationId: get_scope + parameters: + - name: scope_id + in: path + required: true + schema: + type: string + minLength: 1 + maxLength: 256 + pattern: '.*\S.*' + 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" + put: + tags: [scopes] + summary: Replace mutable Scope metadata and relationships + operationId: update_scope + parameters: + - name: scope_id + in: path + required: true + schema: + type: string + minLength: 1 + maxLength: 256 + pattern: '.*\S.*' + 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] summary: Capture durable ContentSource evidence description: Accept raw content as an idempotent Source without synchronously deriving Artifacts. operationId: capture_content_source + x-powercontext-scope-mode: current requestBody: required: true content: @@ -117,6 +375,7 @@ paths: summary: Prepare bounded context for an Agent turn description: Prepare final, ephemeral context from Runtime-owned sources without persisting or injecting it. operationId: prepare_context + x-powercontext-scope-mode: current requestBody: required: true content: @@ -147,6 +406,7 @@ paths: summary: Create a grounded Work Contract description: Persist an inspectable delegation baseline without granting execution authority. operationId: create_work_contract + x-powercontext-scope-mode: current requestBody: required: true content: @@ -181,6 +441,7 @@ paths: summary: Hand off current work in one high-level operation description: Capture an inspected boundary and prepare a temporary evidence-bearing Handoff without committing it. operationId: handoff_current_work + x-powercontext-scope-mode: current requestBody: required: true content: @@ -215,6 +476,7 @@ paths: summary: Resolve and acknowledge a Handoff description: Re-resolve one prepared or exact Handoff, check evidence, and capture the receiver's explicit live-state, capability, and authorization checks. operationId: acknowledge_handoff + x-powercontext-scope-mode: current requestBody: required: true content: @@ -249,6 +511,7 @@ paths: summary: Record a completion-aware Task Outcome description: Preserve one attempt's status and checks, optionally linked to the exact accepted Handoff Receipt that the result covers. operationId: record_task_outcome + x-powercontext-scope-mode: current requestBody: required: true content: @@ -283,6 +546,7 @@ paths: summary: Activate Handoff generation at a Source boundary description: Evaluate the standard Handoff Trigger and synchronously execute any emitted PrepareHandoff Action. operationId: activate_handoff + x-powercontext-scope-mode: current requestBody: required: true content: @@ -314,6 +578,7 @@ paths: tags: [handoff] summary: Generate an inspectable Handoff Draft operationId: prepare_handoff + x-powercontext-scope-mode: current requestBody: required: true content: @@ -345,6 +610,7 @@ paths: tags: [handoff] summary: Finalize an inspected Handoff Draft operationId: finalize_handoff + x-powercontext-scope-mode: current requestBody: required: true content: @@ -376,6 +642,7 @@ paths: tags: [handoff] summary: Commit an explicit Handoff milestone operationId: commit_handoff + x-powercontext-scope-mode: current requestBody: required: true content: @@ -409,6 +676,7 @@ paths: tags: [handoff] summary: Resolve a Handoff as untrusted historical input operationId: continue_handoff + x-powercontext-scope-mode: current requestBody: required: true content: @@ -441,6 +709,7 @@ paths: summary: Process the pending Source window into Memory description: Run one bounded Source-to-Memory activation for operational control and testing. operationId: flush_memory + x-powercontext-scope-mode: current requestBody: required: true content: @@ -471,6 +740,7 @@ paths: summary: Remember explicit Memory content description: Save one already-curated Memory entry without creating a Source or invoking extraction. operationId: remember_memory + x-powercontext-scope-mode: current requestBody: required: true content: @@ -503,6 +773,7 @@ paths: summary: Search active Memory entries description: Retrieve relevant active Memory entries within one explicit application scope. operationId: search_memory + x-powercontext-scope-mode: current requestBody: required: true content: @@ -537,6 +808,7 @@ paths: Read active entries from the current Memory head. Inactive entries are available only when explicitly requested for audit. operationId: list_memory_entries + x-powercontext-scope-mode: current requestBody: required: true content: @@ -569,6 +841,7 @@ paths: summary: Get an exact Memory entry version description: Resolve an immutable entry citation within one Memory Revision. operationId: get_memory_entry + x-powercontext-scope-mode: current requestBody: required: true content: @@ -601,6 +874,7 @@ paths: summary: Revise an exact Memory entry description: Replace active entry content against an explicit current Memory Revision. operationId: revise_memory_entry + x-powercontext-scope-mode: current requestBody: required: true content: @@ -635,6 +909,7 @@ paths: summary: Retire an exact Memory entry description: Deactivate an entry against an explicit current Memory Revision without deleting history. operationId: retire_memory_entry + x-powercontext-scope-mode: current requestBody: required: true content: @@ -669,6 +944,7 @@ paths: summary: List Memory Revision changes description: Read compact entry changes without expanding entry bodies. operationId: list_memory_changes + x-powercontext-scope-mode: current requestBody: required: true content: @@ -701,6 +977,7 @@ paths: summary: Propose Experience content description: Persist a pending Experience Candidate without creating an Artifact Revision. operationId: propose_experience + x-powercontext-scope-mode: current requestBody: required: true content: @@ -733,6 +1010,7 @@ paths: summary: Generate an Experience Candidate description: Use the configured model and caller-selected exact evidence; persist only a schema-valid pending Candidate. operationId: generate_experience + x-powercontext-scope-mode: current requestBody: required: true content: @@ -765,6 +1043,7 @@ paths: summary: Get an exact Experience Revision description: Read approved Experience content and its exact direct evidence. operationId: get_experience + x-powercontext-scope-mode: current requestBody: required: true content: @@ -797,6 +1076,7 @@ paths: summary: Propose managed Skill content description: Persist a pending managed Skill Candidate without creating an Artifact Revision. operationId: propose_skill + x-powercontext-scope-mode: current requestBody: required: true content: @@ -829,6 +1109,7 @@ paths: summary: Generate a managed Skill Candidate description: Use the configured model with an explicit provenance shape; persist only a schema-valid pending Candidate. operationId: generate_skill + x-powercontext-scope-mode: current requestBody: required: true content: @@ -861,6 +1142,7 @@ paths: summary: Get an exact managed Skill Revision description: Read approved managed Skill content and its exact direct evidence. operationId: get_skill + x-powercontext-scope-mode: current requestBody: required: true content: @@ -893,6 +1175,7 @@ paths: summary: Scan configured external Skill roots description: Replace the current host-local Registry projection without copying or rewriting package content. operationId: scan_external_skills + x-powercontext-scope-mode: current requestBody: required: true content: @@ -923,6 +1206,7 @@ paths: summary: List external Skills visible on this host description: Return live local resolutions; unavailable registrations are omitted unless explicitly requested. operationId: list_external_skills + x-powercontext-scope-mode: current requestBody: required: true content: @@ -953,6 +1237,7 @@ paths: summary: Resolve an exact external Skill fingerprint description: Resolve only the registered local package version requested by the caller; never install or fall back. operationId: resolve_external_skill + x-powercontext-scope-mode: current requestBody: required: true content: @@ -985,6 +1270,7 @@ paths: summary: Import or fork an external Skill into Review description: Capture one exact local snapshot and use the configured model to propose a new managed Skill Candidate. operationId: import_external_skill + x-powercontext-scope-mode: current requestBody: required: true content: @@ -1019,6 +1305,7 @@ paths: summary: List Artifact Candidates description: Page current Candidate heads; pending is the default Review Inbox view. operationId: list_artifact_candidates + x-powercontext-scope-mode: current requestBody: required: true content: @@ -1049,6 +1336,7 @@ paths: summary: Get an Artifact Candidate description: Read the current head and exact immutable proposal version. operationId: get_artifact_candidate + x-powercontext-scope-mode: current requestBody: required: true content: @@ -1081,6 +1369,7 @@ paths: summary: Approve an Artifact Candidate description: Commit the reviewed proposal and mark the Candidate approved in one transaction. operationId: approve_artifact_candidate + x-powercontext-scope-mode: current requestBody: required: true content: @@ -1115,6 +1404,7 @@ paths: summary: Reject an Artifact Candidate description: Move the exact pending version to its rejected terminal state without writing an Artifact. operationId: reject_artifact_candidate + x-powercontext-scope-mode: current requestBody: required: true content: @@ -1149,6 +1439,7 @@ paths: summary: Revise an Artifact Candidate description: Append a complete replacement proposal as the next immutable pending version. operationId: revise_artifact_candidate + x-powercontext-scope-mode: current requestBody: required: true content: @@ -1178,27 +1469,20 @@ 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" + x-powercontext-scope-mode: selection + 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" @@ -1219,2762 +1503,2013 @@ 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: + /v1/handoff-reports/get: post: tags: [handoff-reports] - summary: List Handoff Report Projects - operationId: list_handoff_report_projects + summary: Generate a Handoff Report + operationId: get_handoff_report + x-powercontext-scope-mode: selection requestBody: required: true content: application/json: schema: - $ref: "#/components/schemas/ListHandoffReportProjectsRequest" + $ref: "#/components/schemas/GetHandoffReportRequest" responses: "200": - description: A cursor-paginated page of Report Projects. + description: A canonical JSON report, optionally accompanied by Markdown. headers: X-PowerContext-Request-ID: $ref: "#/components/headers/RequestId" + Cache-Control: + description: Prevent caches from retaining scoped report data. + schema: + type: string + enum: [no-store] + X-PowerContext-Selection-Digest: + description: Digest of the exact report selection. + schema: + type: string + pattern: '^sha256:[0-9a-f]{64}$' + X-PowerContext-Report-Digest: + description: Digest of the selected output projection. + schema: + type: string + pattern: '^sha256:[0-9a-f]{64}$' + Content-Disposition: + description: Safe attachment filename when download is true. + schema: + type: string content: application/json: schema: - $ref: "#/components/schemas/ProjectPage" + $ref: "#/components/schemas/HandoffReportResponse" + text/markdown: + schema: + type: string + "404": + $ref: "#/components/responses/NotFound" "401": $ref: "#/components/responses/Unauthorized" "422": $ref: "#/components/responses/InvalidRequest" + "413": + $ref: "#/components/responses/ReportTooLarge" + "503": + $ref: "#/components/responses/Unavailable" "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] - summary: Generate a Handoff Report - operationId: get_handoff_report - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/GetHandoffReportRequest" - responses: - "200": - description: A canonical JSON report, optionally accompanied by Markdown. - headers: - X-PowerContext-Request-ID: - $ref: "#/components/headers/RequestId" - Cache-Control: - description: Prevent caches from retaining scoped report data. - schema: - type: string - enum: [no-store] - X-PowerContext-Selection-Digest: - description: Digest of the exact report selection. - schema: - type: string - pattern: '^sha256:[0-9a-f]{64}$' - X-PowerContext-Report-Digest: - description: Digest of the selected output projection. - schema: - type: string - pattern: '^sha256:[0-9a-f]{64}$' - Content-Disposition: - description: Safe attachment filename when download is true. - schema: - type: string - content: - application/json: - schema: - $ref: "#/components/schemas/HandoffReportResponse" - text/markdown: - schema: - type: string - "404": - $ref: "#/components/responses/NotFound" - "401": - $ref: "#/components/responses/Unauthorized" - "422": - $ref: "#/components/responses/InvalidRequest" - "413": - $ref: "#/components/responses/ReportTooLarge" - "503": - $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: - type: http - scheme: bearer - description: Static bearer token used when local Server authentication is enabled. - headers: - BearerChallenge: - description: Authentication scheme required by the Server. - schema: - type: string - example: Bearer - RequestId: - description: Opaque identifier for correlating one request. - schema: - type: string - responses: - Unauthorized: - description: A valid bearer token is required by this Server deployment. - headers: - WWW-Authenticate: - $ref: "#/components/headers/BearerChallenge" - X-PowerContext-Request-ID: - $ref: "#/components/headers/RequestId" - content: - application/json: - schema: - $ref: "#/components/schemas/ErrorResponse" - Conflict: - description: The command conflicts with current immutable state. - headers: - X-PowerContext-Request-ID: - $ref: "#/components/headers/RequestId" - content: - application/json: - schema: - $ref: "#/components/schemas/ErrorResponse" - InvalidRequest: - description: The request violates the transport or application contract. - headers: - X-PowerContext-Request-ID: - $ref: "#/components/headers/RequestId" - content: - application/json: - schema: - $ref: "#/components/schemas/ErrorResponse" - ReportTooLarge: - description: The selected Handoff Report exceeds the deterministic output limit. - headers: - X-PowerContext-Request-ID: - $ref: "#/components/headers/RequestId" - content: - application/json: - schema: - $ref: "#/components/schemas/ErrorResponse" - NotFound: - description: The requested immutable Memory value was not found. - headers: - X-PowerContext-Request-ID: - $ref: "#/components/headers/RequestId" - content: - application/json: - schema: - $ref: "#/components/schemas/ErrorResponse" - Unavailable: - description: A required Runtime binding or dependency is unavailable. - headers: - X-PowerContext-Request-ID: - $ref: "#/components/headers/RequestId" - content: - application/json: - schema: - $ref: "#/components/schemas/ErrorResponse" - InternalError: - description: The Server failed without exposing internal details. +components: + securitySchemes: + BearerAuth: + type: http + scheme: bearer + description: Static bearer token used when local Server authentication is enabled. + headers: + BearerChallenge: + description: Authentication scheme required by the Server. + schema: + type: string + example: Bearer + RequestId: + description: Opaque identifier for correlating one request. + schema: + type: string + responses: + Unauthorized: + description: A valid bearer token is required by this Server deployment. headers: + WWW-Authenticate: + $ref: "#/components/headers/BearerChallenge" X-PowerContext-Request-ID: $ref: "#/components/headers/RequestId" - content: - application/json: - schema: - $ref: "#/components/schemas/ErrorResponse" - schemas: - ActivateHandoffRequest: - type: object - additionalProperties: false - required: [scope_id, boundary_source, objective] - properties: - scope_id: - type: string - minLength: 1 - maxLength: 256 - pattern: '.*\S.*' - boundary_source: - $ref: "#/components/schemas/SourceReference" - objective: - type: string - minLength: 1 - maxLength: 8192 - pattern: '.*\S.*' - evidence: - type: array - maxItems: 32 - items: - $ref: "#/components/schemas/HandoffCitation" - default: [] - max_bytes: - type: integer - minimum: 512 - maximum: 32768 - default: 8000 - ArtifactReference: - type: object - additionalProperties: false - required: [family, artifact_id, revision] - properties: - family: - type: string - minLength: 1 - maxLength: 128 - pattern: '^[\x21-\x7E]+$' - artifact_id: - type: string - minLength: 1 - maxLength: 128 - pattern: '^[\x21-\x7E]+$' - revision: - type: integer - minimum: 1 - ArtifactCandidate: - type: object - additionalProperties: false - required: - - candidate_id - - version - - family - - status - - proposal - - source_refs - - artifact_refs - - target - - reason - - result_artifact - - decision_reason - properties: - candidate_id: - type: string - minLength: 1 - maxLength: 128 - pattern: '^[\x21-\x7E]+$' - version: - type: integer - minimum: 1 - family: - $ref: "#/components/schemas/CandidateFamily" - status: - $ref: "#/components/schemas/CandidateStatus" - proposal: - oneOf: - - $ref: "#/components/schemas/ExperienceProposal" - - $ref: "#/components/schemas/SkillProposal" - source_refs: - type: array - maxItems: 32 - description: Exact Source evidence. Counted with artifact_refs toward a combined maximum of 32 references. - items: - $ref: "#/components/schemas/SourceReference" - artifact_refs: - type: array - maxItems: 32 - description: Exact Artifact evidence. Counted with source_refs toward a combined maximum of 32 references. - items: - $ref: "#/components/schemas/ArtifactReference" - target: - $ref: "#/components/schemas/ArtifactReference" - nullable: true - reason: - type: string - minLength: 1 - maxLength: 2000 - nullable: true - result_artifact: - $ref: "#/components/schemas/ArtifactReference" - nullable: true - decision_reason: - type: string - minLength: 1 - maxLength: 2000 - nullable: true - ArtifactCandidatePage: - type: object - additionalProperties: false - required: [candidates, next_cursor] - properties: - candidates: - type: array - items: - $ref: "#/components/schemas/ArtifactCandidate" - next_cursor: - type: string - nullable: true - ApproveArtifactCandidateRequest: - type: object - additionalProperties: false - required: [scope_id, candidate_id, expected_version] - properties: - scope_id: - type: string - minLength: 1 - maxLength: 256 - pattern: '.*\S.*' - candidate_id: - type: string - minLength: 1 - maxLength: 128 - pattern: '^[\x21-\x7E]+$' - expected_version: - type: integer - minimum: 1 - Capabilities: - type: object - additionalProperties: false - required: - [source_types, artifact_families, memory_extraction, handoff_generation, search_modes, context_versions] - properties: - source_types: - type: array - items: - type: string - artifact_families: - type: array - items: - type: string - memory_extraction: - type: boolean - description: Whether pending Sources can be extracted into Memory. - experience_generation: - type: boolean - default: false - description: Whether the configured model can generate reviewed Experience Candidates. - managed_skill_generation: - type: boolean - default: false - description: Whether the configured model can generate reviewed managed Skill Candidates. - external_skill_registry: - type: boolean - default: false - description: Whether host-local external Skill discovery and exact resolution are configured. - handoff_generation: - type: boolean - description: Whether exact evidence can be generated into an inspectable Handoff Draft. - search_modes: - type: array - items: - $ref: "#/components/schemas/MemorySearchMode" - context_versions: - type: array - items: - $ref: "#/components/schemas/PreparedContextSchema" - FamilyCount: - type: object - additionalProperties: false - required: [family, total] - properties: - family: - type: string - minLength: 1 - maxLength: 128 - pattern: '^[\x21-\x7E]+$' - total: - type: integer - minimum: 0 - CandidateFamilyCount: - type: object - additionalProperties: false - required: [family, total, pending, approved, rejected] - properties: - family: - $ref: "#/components/schemas/CandidateFamily" - total: - type: integer - minimum: 0 - pending: - type: integer - minimum: 0 - approved: - type: integer - minimum: 0 - rejected: - type: integer - minimum: 0 - MemoryKindCount: + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + Conflict: + description: The command conflicts with current immutable state. + headers: + X-PowerContext-Request-ID: + $ref: "#/components/headers/RequestId" + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + InvalidRequest: + description: The request violates the transport or application contract. + headers: + X-PowerContext-Request-ID: + $ref: "#/components/headers/RequestId" + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + ReportTooLarge: + description: The selected Handoff Report exceeds the deterministic output limit. + headers: + X-PowerContext-Request-ID: + $ref: "#/components/headers/RequestId" + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + NotFound: + description: The requested immutable Memory value was not found. + headers: + X-PowerContext-Request-ID: + $ref: "#/components/headers/RequestId" + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + Unavailable: + description: A required Runtime binding or dependency is unavailable. + headers: + X-PowerContext-Request-ID: + $ref: "#/components/headers/RequestId" + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + InternalError: + description: The Server failed without exposing internal details. + headers: + X-PowerContext-Request-ID: + $ref: "#/components/headers/RequestId" + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + schemas: + ActivateHandoffRequest: type: object additionalProperties: false - required: [kind, total, active, inactive] + required: [scope_id, boundary_source, objective] properties: - kind: + scope_id: type: string minLength: 1 - maxLength: 128 - total: - type: integer - minimum: 0 - active: - type: integer - minimum: 0 - inactive: - type: integer - minimum: 0 - SourceInventoryStatistics: - type: object - additionalProperties: false - required: [total, memory_processed, memory_pending] - properties: - total: - type: integer - minimum: 0 - memory_processed: - type: integer - minimum: 0 - memory_pending: - type: integer - minimum: 0 - ArtifactInventoryStatistics: - type: object - additionalProperties: false - required: [total, by_family] - properties: - total: - type: integer - minimum: 0 - by_family: - type: array - items: - $ref: "#/components/schemas/FamilyCount" - CandidateInventoryStatistics: - type: object - additionalProperties: false - required: [total, pending, approved, rejected, by_family] - properties: - total: - type: integer - minimum: 0 - pending: - type: integer - minimum: 0 - approved: - type: integer - minimum: 0 - rejected: - type: integer - minimum: 0 - by_family: - type: array - items: - $ref: "#/components/schemas/CandidateFamilyCount" - MemoryEntryInventoryStatistics: - type: object - additionalProperties: false - required: [total, active, inactive, by_kind] - properties: - total: - type: integer - minimum: 0 - active: - type: integer - minimum: 0 - inactive: - type: integer - minimum: 0 - by_kind: - type: array - items: - $ref: "#/components/schemas/MemoryKindCount" - MemoryInventoryStatistics: - type: object - additionalProperties: false - required: [entries] - properties: - entries: - $ref: "#/components/schemas/MemoryEntryInventoryStatistics" - InventoryStatistics: - type: object - additionalProperties: false - required: [sources, artifacts, candidates, memory] - properties: - sources: - $ref: "#/components/schemas/SourceInventoryStatistics" - artifacts: - $ref: "#/components/schemas/ArtifactInventoryStatistics" - candidates: - $ref: "#/components/schemas/CandidateInventoryStatistics" - memory: - $ref: "#/components/schemas/MemoryInventoryStatistics" - ModelUsageValue: - type: object - additionalProperties: false - required: [requests, input_tokens, output_tokens] - properties: - requests: - type: integer - minimum: 0 - input_tokens: - type: integer - minimum: 0 - nullable: true - output_tokens: - type: integer - minimum: 0 - nullable: true - ModelUsageStatistics: - type: object - additionalProperties: false - required: [generation, embedding] - properties: - generation: - $ref: "#/components/schemas/ModelUsageValue" - embedding: - $ref: "#/components/schemas/ModelUsageValue" - ModelUsagePurposeBreakdown: - type: object - additionalProperties: false - required: [purpose, generation, embedding] - properties: - purpose: + maxLength: 256 + pattern: '.*\S.*' + boundary_source: + $ref: "#/components/schemas/SourceReference" + objective: type: string minLength: 1 - maxLength: 64 - generation: - $ref: "#/components/schemas/ModelUsageValue" - embedding: - $ref: "#/components/schemas/ModelUsageValue" - ModelUsageDay: - type: object - additionalProperties: false - required: [date, generation, embedding, by_purpose] - properties: - date: - type: string - format: date - generation: - $ref: "#/components/schemas/ModelUsageValue" - embedding: - $ref: "#/components/schemas/ModelUsageValue" - by_purpose: - type: array - maxItems: 16 - items: - $ref: "#/components/schemas/ModelUsagePurposeBreakdown" - ResolvedUsagePeriod: - type: object - additionalProperties: false - required: [preset, start_date, end_date, timezone] - properties: - preset: - $ref: "#/components/schemas/StatsPeriod" - start_date: - type: string - format: date - end_date: - type: string - format: date - timezone: - type: string - enum: [UTC] - UsageStatistics: - type: object - additionalProperties: false - required: [period, totals, by_purpose, daily] - properties: - period: - $ref: "#/components/schemas/ResolvedUsagePeriod" - totals: - $ref: "#/components/schemas/ModelUsageStatistics" - by_purpose: - type: array - maxItems: 16 - items: - $ref: "#/components/schemas/ModelUsagePurposeBreakdown" - daily: + maxLength: 8192 + pattern: '.*\S.*' + evidence: type: array - maxItems: 30 + maxItems: 32 items: - $ref: "#/components/schemas/ModelUsageDay" - TokenEstimatorProfile: + $ref: "#/components/schemas/HandoffCitation" + default: [] + max_bytes: + type: integer + minimum: 512 + maximum: 32768 + default: 8000 + ArtifactReference: type: object additionalProperties: false - required: [estimator_id, version] + required: [family, artifact_id, revision] properties: - estimator_id: + family: type: string minLength: 1 maxLength: 128 - version: + pattern: '^[\x21-\x7E]+$' + artifact_id: type: string minLength: 1 - maxLength: 64 - RecallTokenValue: + maxLength: 128 + pattern: '^[\x21-\x7E]+$' + revision: + type: integer + minimum: 1 + ArtifactAddress: type: object additionalProperties: false - required: [preparations, ready_preparations, comparable_preparations, baseline_tokens, recalled_tokens, token_reduction] + required: [scope_id, artifact] properties: - preparations: - type: integer - minimum: 0 - ready_preparations: - type: integer - minimum: 0 - comparable_preparations: - type: integer - minimum: 0 - baseline_tokens: - type: integer - minimum: 0 - recalled_tokens: - type: integer - minimum: 0 - token_reduction: - type: integer - RecallTokenDay: + scope_id: + type: string + minLength: 1 + maxLength: 256 + pattern: '.*\S.*' + artifact: + $ref: "#/components/schemas/ArtifactReference" + PublishArtifactRequest: type: object additionalProperties: false - required: [date, preparations, ready_preparations, comparable_preparations, baseline_tokens, recalled_tokens, token_reduction] + required: [source, target_scope_id, idempotency_key] properties: - date: + source: + $ref: "#/components/schemas/ArtifactAddress" + target_scope_id: type: string - format: date - preparations: - type: integer - minimum: 0 - ready_preparations: - type: integer - minimum: 0 - comparable_preparations: - type: integer - minimum: 0 - baseline_tokens: - type: integer - minimum: 0 - recalled_tokens: - type: integer - minimum: 0 - token_reduction: - type: integer - RecallTokenStatistics: + minLength: 1 + maxLength: 256 + pattern: '.*\S.*' + idempotency_key: + type: string + minLength: 1 + maxLength: 256 + pattern: '.*\S.*' + ArtifactPublication: type: object additionalProperties: false - required: [period, estimator, totals, daily] + required: [source, target, content_digest] properties: - period: - $ref: "#/components/schemas/ResolvedUsagePeriod" - estimator: - $ref: "#/components/schemas/TokenEstimatorProfile" - nullable: true - totals: - $ref: "#/components/schemas/RecallTokenValue" - daily: - type: array - maxItems: 30 - items: - $ref: "#/components/schemas/RecallTokenDay" - ScopedStats: + source: + $ref: "#/components/schemas/ArtifactAddress" + target: + $ref: "#/components/schemas/ArtifactAddress" + content_digest: + type: string + pattern: '^[0-9a-f]{64}$' + ScopeExternalReference: type: object additionalProperties: false - required: [scope_id, as_of, inventory, usage, recall] + required: [kind, value] properties: - scope_id: + kind: type: string - as_of: + minLength: 1 + maxLength: 128 + pattern: '.*\S.*' + value: type: string - format: date-time - inventory: - $ref: "#/components/schemas/InventoryStatistics" - usage: - $ref: "#/components/schemas/UsageStatistics" - recall: - $ref: "#/components/schemas/RecallTokenStatistics" - GetStatsRequest: + minLength: 1 + maxLength: 2000 + pattern: '.*\S.*' + ScopeDescriptor: type: object additionalProperties: false - required: [scope_id] + required: [scope_id, title, summary, context_references, external_references, version] properties: scope_id: type: string minLength: 1 maxLength: 256 pattern: '.*\S.*' - period: - $ref: "#/components/schemas/StatsPeriod" - default: 30d - WorkClaimBasis: - type: string - enum: [declared, verified] - WorkClaim: - type: object - additionalProperties: false - required: [text, basis, evidence] - properties: - text: + title: type: string minLength: 1 - maxLength: 8192 + maxLength: 256 pattern: '.*\S.*' - basis: - $ref: "#/components/schemas/WorkClaimBasis" - evidence: - type: array - maxItems: 31 - items: - $ref: "#/components/schemas/HandoffCitation" - WorkContract: - type: object - additionalProperties: false - required: [schema, trust, objective, facts, in_scope, exclusions, completion_criteria, authorization_notes, open_questions] - properties: - schema: - type: string - enum: [powercontext.work-contract.v1] - trust: + summary: type: string - enum: [untrusted_input] - objective: + minLength: 1 + maxLength: 2000 + pattern: '.*\S.*' + parent_scope_id: type: string minLength: 1 - maxLength: 8192 + maxLength: 256 pattern: '.*\S.*' - facts: - type: array - maxItems: 64 - items: - $ref: "#/components/schemas/WorkClaim" - in_scope: + nullable: true + context_references: type: array - minItems: 1 - maxItems: 64 + uniqueItems: true items: type: string minLength: 1 - maxLength: 8192 + maxLength: 256 pattern: '.*\S.*' - exclusions: + external_references: type: array - maxItems: 64 + uniqueItems: true items: - type: string - minLength: 1 - maxLength: 8192 - pattern: '.*\S.*' - completion_criteria: + $ref: "#/components/schemas/ScopeExternalReference" + version: + type: integer + minimum: 1 + ScopePage: + type: object + additionalProperties: false + required: [items] + properties: + items: type: array - minItems: 1 - maxItems: 64 items: - type: string - minLength: 1 - maxLength: 8192 - pattern: '.*\S.*' - authorization_notes: + $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 - maxItems: 64 + uniqueItems: true items: type: string minLength: 1 - maxLength: 8192 + maxLength: 256 pattern: '.*\S.*' - open_questions: + default: [] + external_references: type: array - maxItems: 64 + uniqueItems: true items: - type: string - minLength: 1 - maxLength: 8192 - pattern: '.*\S.*' - CreateWorkContractRequest: + $ref: "#/components/schemas/ScopeExternalReference" + default: [] + idempotency_key: + type: string + minLength: 1 + maxLength: 256 + pattern: '.*\S.*' + UpdateScopeRequest: type: object additionalProperties: false - required: [scope_id, source_id, contract] + required: [expected_version, title, summary] properties: - scope_id: + expected_version: + type: integer + minimum: 1 + title: type: string minLength: 1 maxLength: 256 pattern: '.*\S.*' - source_id: + summary: type: string minLength: 1 - maxLength: 256 + maxLength: 2000 pattern: '.*\S.*' - contract: - $ref: "#/components/schemas/WorkContract" - CurrentWorkHandoff: - type: object - additionalProperties: false - required: [schema, trust, objective, state, disposition, next_action, omissions] - properties: - schema: - type: string - enum: [powercontext.current-work-handoff.v1] - trust: - type: string - enum: [untrusted_input] - objective: + parent_scope_id: type: string minLength: 1 - maxLength: 8192 - pattern: '.*\S.*' - state: - type: array - minItems: 1 - maxItems: 64 - items: - $ref: "#/components/schemas/WorkClaim" - disposition: - $ref: "#/components/schemas/HandoffDisposition" - next_action: - $ref: "#/components/schemas/WorkClaim" + maxLength: 256 + pattern: '.*\S.*' nullable: true - omissions: + context_references: type: array - maxItems: 64 + uniqueItems: true items: type: string minLength: 1 - maxLength: 8192 + maxLength: 256 pattern: '.*\S.*' - HandoffCurrentWorkRequest: + default: [] + external_references: + type: array + uniqueItems: true + items: + $ref: "#/components/schemas/ScopeExternalReference" + default: [] + SetDefaultScopeRequest: type: object additionalProperties: false - required: [scope_id, source_id, handoff] + required: [scope_id] properties: scope_id: type: string minLength: 1 maxLength: 256 pattern: '.*\S.*' - source_id: + AllScopeSelection: + type: object + additionalProperties: false + required: [mode] + properties: + mode: type: string - minLength: 1 - maxLength: 256 - pattern: '.*\S.*' - handoff: - $ref: "#/components/schemas/CurrentWorkHandoff" - WorkSourceKind: - type: string - enum: [work-contract, handoff-boundary, handoff-receipt, task-outcome] - WorkSourceReceipt: + enum: [all] + ExactScopeSelection: type: object additionalProperties: false - required: [kind, source, position, content_digest] + required: [mode, scope_ids] properties: - kind: - $ref: "#/components/schemas/WorkSourceKind" - source: - $ref: "#/components/schemas/SourceReference" - position: - type: integer - minimum: 1 - content_digest: + mode: type: string - minLength: 71 - maxLength: 71 - pattern: '^sha256:[0-9a-f]{64}$' - PreparedWorkHandoff: + enum: [exact] + scope_ids: + type: array + minItems: 1 + uniqueItems: true + items: + type: string + minLength: 1 + maxLength: 256 + pattern: '.*\S.*' + SubtreeScopeSelection: type: object additionalProperties: false - required: [boundary, handoff] + required: [mode, root_scope_id] properties: - boundary: - $ref: "#/components/schemas/WorkSourceReceipt" - handoff: - $ref: "#/components/schemas/PreparedHandoff" - HandoffReceiptStatus: - type: string - enum: [accepted, needs_clarification, declined] - HandoffAcknowledgementSelection: - type: string - enum: [prepared, exact] - LiveStateCheckStatus: - type: string - enum: [confirmed, mismatch, not_checked] - ReceiverReadinessCheckStatus: - type: string - enum: [confirmed, insufficient, not_checked] - ReceiverChecks: + mode: + type: string + enum: [subtree] + root_scope_id: + type: string + minLength: 1 + maxLength: 256 + pattern: '.*\S.*' + ScopeSelection: + oneOf: + - $ref: "#/components/schemas/AllScopeSelection" + - $ref: "#/components/schemas/ExactScopeSelection" + - $ref: "#/components/schemas/SubtreeScopeSelection" + discriminator: + propertyName: mode + ResolveScopeSelectionRequest: type: object additionalProperties: false - description: Untrusted receiver self-attestation kept separate from citation availability. All three values must be confirmed when status is accepted. - required: [live_state, capability, authorization] + required: [selection] properties: - live_state: - $ref: "#/components/schemas/LiveStateCheckStatus" - capability: - $ref: "#/components/schemas/ReceiverReadinessCheckStatus" - authorization: - $ref: "#/components/schemas/ReceiverReadinessCheckStatus" - AcknowledgeHandoffRequest: + selection: + $ref: "#/components/schemas/ScopeSelection" + ScopeBindingKey: type: object additionalProperties: false - required: [scope_id, source_id, receiver, status, selection] + required: [integration, kind, external_id] properties: - scope_id: + integration: type: string minLength: 1 - maxLength: 256 + maxLength: 128 pattern: '.*\S.*' - source_id: + kind: type: string minLength: 1 - maxLength: 256 + maxLength: 64 pattern: '.*\S.*' - receiver: + external_id: type: string minLength: 1 maxLength: 256 pattern: '.*\S.*' - status: - $ref: "#/components/schemas/HandoffReceiptStatus" - selection: - $ref: "#/components/schemas/HandoffAcknowledgementSelection" - receiver_checks: - $ref: "#/components/schemas/ReceiverChecks" - nullable: true - prepared: - $ref: "#/components/schemas/PreparedHandoff" - nullable: true - revision: - $ref: "#/components/schemas/ArtifactReference" - nullable: true - message: + ScopeBinding: + type: object + additionalProperties: false + required: [key, scope_id] + properties: + key: + $ref: "#/components/schemas/ScopeBindingKey" + scope_id: type: string minLength: 1 - maxLength: 8192 + maxLength: 256 pattern: '.*\S.*' - nullable: true - HandoffAcknowledgement: + SetScopeBindingRequest: + $ref: "#/components/schemas/ScopeBinding" + ClearScopeBindingRequest: type: object additionalProperties: false - required: [resolution, receipt] + required: [key] properties: - resolution: - $ref: "#/components/schemas/HandoffResolution" - receipt: - $ref: "#/components/schemas/WorkSourceReceipt" - TaskOutcomeStatus: - type: string - enum: [succeeded, partial, blocked, failed, cancelled, unknown] - TaskCheckStatus: - type: string - enum: [passed, failed, skipped, timed_out, unavailable, cancelled, unknown] - TaskCheck: + key: + $ref: "#/components/schemas/ScopeBindingKey" + ClearScopeBindingResponse: type: object additionalProperties: false - required: [name, status, basis, evidence] + required: [cleared] properties: - name: + cleared: + type: boolean + ResolveScopeBindingRequest: + type: object + additionalProperties: false + properties: + explicit_scope_id: type: string minLength: 1 - maxLength: 8192 + maxLength: 256 pattern: '.*\S.*' - status: - $ref: "#/components/schemas/TaskCheckStatus" - details: + nullable: true + binding_keys: + type: array + items: + $ref: "#/components/schemas/ScopeBindingKey" + default: [] + ArtifactCandidate: + type: object + additionalProperties: false + required: + - candidate_id + - version + - family + - status + - proposal + - source_refs + - artifact_refs + - target + - reason + - result_artifact + - decision_reason + properties: + candidate_id: type: string minLength: 1 - maxLength: 8192 - pattern: '.*\S.*' - nullable: true - basis: - $ref: "#/components/schemas/WorkClaimBasis" - evidence: + maxLength: 128 + pattern: '^[\x21-\x7E]+$' + version: + type: integer + minimum: 1 + family: + $ref: "#/components/schemas/CandidateFamily" + status: + $ref: "#/components/schemas/CandidateStatus" + proposal: + oneOf: + - $ref: "#/components/schemas/ExperienceProposal" + - $ref: "#/components/schemas/SkillProposal" + source_refs: type: array maxItems: 32 + description: Exact Source evidence. Counted with artifact_refs toward a combined maximum of 32 references. items: - $ref: "#/components/schemas/HandoffCitation" - TaskOutcome: + $ref: "#/components/schemas/SourceReference" + artifact_refs: + type: array + maxItems: 32 + description: Exact Artifact evidence. Counted with source_refs toward a combined maximum of 32 references. + items: + $ref: "#/components/schemas/ArtifactReference" + target: + $ref: "#/components/schemas/ArtifactReference" + nullable: true + reason: + type: string + minLength: 1 + maxLength: 2000 + nullable: true + result_artifact: + $ref: "#/components/schemas/ArtifactReference" + nullable: true + decision_reason: + type: string + minLength: 1 + maxLength: 2000 + nullable: true + ArtifactCandidatePage: type: object additionalProperties: false - required: [schema, trust, objective, status, summary, observations, checks, produced_artifacts, remaining_work] + required: [candidates, next_cursor] properties: - schema: - type: string - enum: [powercontext.task-outcome.v1] - trust: + candidates: + type: array + items: + $ref: "#/components/schemas/ArtifactCandidate" + next_cursor: type: string - enum: [untrusted_observation] - objective: + nullable: true + ApproveArtifactCandidateRequest: + type: object + additionalProperties: false + required: [scope_id, candidate_id, expected_version] + properties: + scope_id: type: string minLength: 1 - maxLength: 8192 + maxLength: 256 pattern: '.*\S.*' - status: - $ref: "#/components/schemas/TaskOutcomeStatus" - summary: + candidate_id: type: string minLength: 1 - maxLength: 8192 - pattern: '.*\S.*' - handoff_receipt_ref: - $ref: "#/components/schemas/SourceReference" - nullable: true - observations: - type: array - minItems: 1 - maxItems: 64 + maxLength: 128 + pattern: '^[\x21-\x7E]+$' + expected_version: + type: integer + minimum: 1 + Capabilities: + type: object + additionalProperties: false + required: + [source_types, artifact_families, memory_extraction, handoff_generation, search_modes, context_versions] + properties: + source_types: + type: array items: - $ref: "#/components/schemas/WorkClaim" - checks: + type: string + artifact_families: type: array - maxItems: 64 items: - $ref: "#/components/schemas/TaskCheck" - produced_artifacts: + type: string + memory_extraction: + type: boolean + description: Whether pending Sources can be extracted into Memory. + experience_generation: + type: boolean + default: false + description: Whether the configured model can generate reviewed Experience Candidates. + managed_skill_generation: + type: boolean + default: false + description: Whether the configured model can generate reviewed managed Skill Candidates. + external_skill_registry: + type: boolean + default: false + description: Whether host-local external Skill discovery and exact resolution are configured. + handoff_generation: + type: boolean + description: Whether exact evidence can be generated into an inspectable Handoff Draft. + search_modes: type: array - maxItems: 32 items: - $ref: "#/components/schemas/ArtifactReference" - remaining_work: + $ref: "#/components/schemas/MemorySearchMode" + context_versions: type: array - maxItems: 64 items: - type: string - minLength: 1 - maxLength: 8192 - pattern: '.*\S.*' - RecordTaskOutcomeRequest: + $ref: "#/components/schemas/PreparedContextSchema" + FamilyCount: type: object additionalProperties: false - required: [scope_id, source_id, outcome] + required: [family, total] properties: - scope_id: - type: string - minLength: 1 - maxLength: 256 - pattern: '.*\S.*' - source_id: + family: type: string minLength: 1 - maxLength: 256 - pattern: '.*\S.*' - outcome: - $ref: "#/components/schemas/TaskOutcome" - CaptureContentSourceRequest: + maxLength: 128 + pattern: '^[\x21-\x7E]+$' + total: + type: integer + minimum: 0 + CandidateFamilyCount: type: object additionalProperties: false - required: [scope_id, source_id, content] + required: [family, total, pending, approved, rejected] properties: - scope_id: - type: string - minLength: 1 - maxLength: 256 - pattern: '.*\S.*' - source_id: - type: string - minLength: 1 - maxLength: 256 - content: + family: + $ref: "#/components/schemas/CandidateFamily" + total: + type: integer + minimum: 0 + pending: + type: integer + minimum: 0 + approved: + type: integer + minimum: 0 + rejected: + type: integer + minimum: 0 + MemoryKindCount: + type: object + additionalProperties: false + required: [kind, total, active, inactive] + properties: + kind: type: string minLength: 1 - maxLength: 200000 - metadata: - type: object - additionalProperties: true - nullable: true - CaptureContentSourceResponse: + maxLength: 128 + total: + type: integer + minimum: 0 + active: + type: integer + minimum: 0 + inactive: + type: integer + minimum: 0 + SourceInventoryStatistics: type: object additionalProperties: false - required: [status, source, position] + required: [total, memory_processed, memory_pending] properties: - status: - $ref: "#/components/schemas/CaptureStatus" - source: - $ref: "#/components/schemas/SourceReference" - position: + total: type: integer - minimum: 1 - CommitHandoffRequest: + minimum: 0 + memory_processed: + type: integer + minimum: 0 + memory_pending: + type: integer + minimum: 0 + ArtifactInventoryStatistics: type: object additionalProperties: false - required: [scope_id, handoff] + required: [total, by_family] properties: - scope_id: - type: string - minLength: 1 - maxLength: 256 - pattern: '.*\S.*' - handoff: - $ref: "#/components/schemas/PreparedHandoff" - CommittedHandoff: + total: + type: integer + minimum: 0 + by_family: + type: array + items: + $ref: "#/components/schemas/FamilyCount" + CandidateInventoryStatistics: type: object additionalProperties: false - required: [reference, content, source_refs, artifact_refs] + required: [total, pending, approved, rejected, by_family] properties: - reference: - $ref: "#/components/schemas/ArtifactReference" - content: - $ref: "#/components/schemas/HandoffContent" - source_refs: + total: + type: integer + minimum: 0 + pending: + type: integer + minimum: 0 + approved: + type: integer + minimum: 0 + rejected: + type: integer + minimum: 0 + by_family: type: array items: - $ref: "#/components/schemas/SourceReference" - artifact_refs: + $ref: "#/components/schemas/CandidateFamilyCount" + MemoryEntryInventoryStatistics: + type: object + additionalProperties: false + required: [total, active, inactive, by_kind] + properties: + total: + type: integer + minimum: 0 + active: + type: integer + minimum: 0 + inactive: + type: integer + minimum: 0 + by_kind: type: array items: - $ref: "#/components/schemas/ArtifactReference" - ContinueHandoffRequest: + $ref: "#/components/schemas/MemoryKindCount" + MemoryInventoryStatistics: type: object additionalProperties: false - required: [scope_id, selection] + required: [entries] properties: - scope_id: - type: string - minLength: 1 - maxLength: 256 - pattern: '.*\S.*' - selection: - $ref: "#/components/schemas/HandoffSelection" - prepared: - $ref: "#/components/schemas/PreparedHandoff" + entries: + $ref: "#/components/schemas/MemoryEntryInventoryStatistics" + InventoryStatistics: + type: object + additionalProperties: false + required: [sources, artifacts, candidates, memory] + properties: + sources: + $ref: "#/components/schemas/SourceInventoryStatistics" + artifacts: + $ref: "#/components/schemas/ArtifactInventoryStatistics" + candidates: + $ref: "#/components/schemas/CandidateInventoryStatistics" + memory: + $ref: "#/components/schemas/MemoryInventoryStatistics" + ModelUsageValue: + type: object + additionalProperties: false + required: [requests, input_tokens, output_tokens] + properties: + requests: + type: integer + minimum: 0 + input_tokens: + type: integer + minimum: 0 nullable: true - revision: - $ref: "#/components/schemas/ArtifactReference" + output_tokens: + type: integer + minimum: 0 nullable: true - FinalizeHandoffRequest: + ModelUsageStatistics: type: object additionalProperties: false - required: [scope_id, draft] + required: [generation, embedding] properties: - scope_id: + generation: + $ref: "#/components/schemas/ModelUsageValue" + embedding: + $ref: "#/components/schemas/ModelUsageValue" + ModelUsagePurposeBreakdown: + type: object + additionalProperties: false + required: [purpose, generation, embedding] + properties: + purpose: type: string minLength: 1 - maxLength: 256 - pattern: '.*\S.*' - draft: - $ref: "#/components/schemas/HandoffDraft" - HandoffArtifactCitation: + maxLength: 64 + generation: + $ref: "#/components/schemas/ModelUsageValue" + embedding: + $ref: "#/components/schemas/ModelUsageValue" + ModelUsageDay: type: object additionalProperties: false - required: [kind, artifact_ref] + required: [date, generation, embedding, by_purpose] properties: - kind: + date: type: string - enum: [artifact] - artifact_ref: - $ref: "#/components/schemas/ArtifactReference" - HandoffActivation: + format: date + generation: + $ref: "#/components/schemas/ModelUsageValue" + embedding: + $ref: "#/components/schemas/ModelUsageValue" + by_purpose: + type: array + maxItems: 16 + items: + $ref: "#/components/schemas/ModelUsagePurposeBreakdown" + ResolvedUsagePeriod: type: object additionalProperties: false - required: [status, boundary_source, previous_position, current_position, draft] + required: [preset, start_date, end_date, timezone] 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: + preset: + $ref: "#/components/schemas/StatsPeriod" + start_date: + type: string + format: date + end_date: + type: string + format: date + timezone: + type: string + enum: [UTC] + UsageStatistics: type: object additionalProperties: false - required: [schema, objective, state, disposition, next_action, omissions] + required: [period, totals, by_purpose, daily] properties: - schema: - $ref: "#/components/schemas/HandoffSchema" - objective: - type: string - minLength: 1 - maxLength: 8192 - pattern: '.*\S.*' - state: + period: + $ref: "#/components/schemas/ResolvedUsagePeriod" + totals: + $ref: "#/components/schemas/ModelUsageStatistics" + by_purpose: type: array - minItems: 1 - maxItems: 64 + maxItems: 16 items: - $ref: "#/components/schemas/HandoffStatement" - disposition: - $ref: "#/components/schemas/HandoffDisposition" - next_action: - $ref: "#/components/schemas/HandoffStatement" - nullable: true - omissions: + $ref: "#/components/schemas/ModelUsagePurposeBreakdown" + daily: type: array - maxItems: 64 + maxItems: 30 items: - $ref: "#/components/schemas/HandoffOmission" - HandoffDraft: + $ref: "#/components/schemas/ModelUsageDay" + TokenEstimatorProfile: type: object additionalProperties: false - required: [objective, state, disposition, next_action, omissions] + required: [estimator_id, version] properties: - objective: + estimator_id: 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: + maxLength: 128 + version: + type: string + minLength: 1 + maxLength: 64 + RecallTokenValue: type: object additionalProperties: false - required: [claim, state_index, status, unavailable_evidence] + required: [preparations, ready_preparations, comparable_preparations, baseline_tokens, recalled_tokens, token_reduction] properties: - claim: - $ref: "#/components/schemas/HandoffClaim" - state_index: + preparations: type: integer minimum: 0 - nullable: true - status: - $ref: "#/components/schemas/HandoffEvidenceStatus" - unavailable_evidence: - type: array - maxItems: 32 - items: - $ref: "#/components/schemas/HandoffCitation" - HandoffMemoryCitation: + ready_preparations: + type: integer + minimum: 0 + comparable_preparations: + type: integer + minimum: 0 + baseline_tokens: + type: integer + minimum: 0 + recalled_tokens: + type: integer + minimum: 0 + token_reduction: + type: integer + RecallTokenDay: type: object additionalProperties: false - required: [kind, memory_citation] + required: [date, preparations, ready_preparations, comparable_preparations, baseline_tokens, recalled_tokens, token_reduction] properties: - kind: + date: type: string - enum: [memory] - memory_citation: - $ref: "#/components/schemas/MemoryCitation" - HandoffOmission: + format: date + preparations: + type: integer + minimum: 0 + ready_preparations: + type: integer + minimum: 0 + comparable_preparations: + type: integer + minimum: 0 + baseline_tokens: + type: integer + minimum: 0 + recalled_tokens: + type: integer + minimum: 0 + token_reduction: + type: integer + RecallTokenStatistics: type: object additionalProperties: false - required: [text, citation] + required: [period, estimator, totals, daily] properties: - text: - type: string - minLength: 1 - maxLength: 8192 - pattern: '.*\S.*' - citation: - $ref: "#/components/schemas/HandoffCitation" + period: + $ref: "#/components/schemas/ResolvedUsagePeriod" + estimator: + $ref: "#/components/schemas/TokenEstimatorProfile" nullable: true - HandoffResolution: + totals: + $ref: "#/components/schemas/RecallTokenValue" + daily: + type: array + maxItems: 30 + items: + $ref: "#/components/schemas/RecallTokenDay" + ScopeStats: type: object additionalProperties: false - required: - [trust, status, scope_id, content, selection, selected_revision, current_revision, evidence_checks] + required: [scope_id, inventory, usage, recall] properties: - trust: - type: string - enum: [untrusted_history] - status: - $ref: "#/components/schemas/HandoffResolutionStatus" scope_id: type: string - content: - $ref: "#/components/schemas/HandoffContent" - nullable: true + inventory: + $ref: "#/components/schemas/InventoryStatistics" + usage: + $ref: "#/components/schemas/UsageStatistics" + recall: + $ref: "#/components/schemas/RecallTokenStatistics" + ScopedStats: + type: object + additionalProperties: false + required: [selection, scope_ids, as_of, inventory, usage, recall, by_scope] + properties: 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: + $ref: "#/components/schemas/ScopeSelection" + scope_ids: type: array - maxItems: 65 + uniqueItems: true items: - $ref: "#/components/schemas/HandoffEvidenceCheck" - HandoffSourceCitation: + type: string + as_of: + type: string + format: date-time + inventory: + $ref: "#/components/schemas/InventoryStatistics" + usage: + $ref: "#/components/schemas/UsageStatistics" + recall: + $ref: "#/components/schemas/RecallTokenStatistics" + by_scope: + type: array + items: + $ref: "#/components/schemas/ScopeStats" + GetStatsRequest: type: object additionalProperties: false - required: [kind, source_ref] + required: [selection] properties: - kind: - type: string - enum: [source] - source_ref: - $ref: "#/components/schemas/SourceReference" - HandoffStatement: + selection: + $ref: "#/components/schemas/ScopeSelection" + period: + $ref: "#/components/schemas/StatsPeriod" + default: 30d + WorkClaimBasis: + type: string + enum: [declared, verified] + WorkClaim: type: object additionalProperties: false - required: [text, citations] + required: [text, basis, evidence] properties: text: type: string minLength: 1 maxLength: 8192 pattern: '.*\S.*' - citations: + basis: + $ref: "#/components/schemas/WorkClaimBasis" + evidence: type: array - minItems: 1 - maxItems: 32 + maxItems: 31 items: $ref: "#/components/schemas/HandoffCitation" - PrepareHandoffRequest: + WorkContract: type: object additionalProperties: false - required: [scope_id, objective, evidence] + required: [schema, trust, objective, facts, in_scope, exclusions, completion_criteria, authorization_notes, open_questions] properties: - scope_id: + schema: type: string - minLength: 1 - maxLength: 256 - pattern: '.*\S.*' + enum: [powercontext.work-contract.v1] + trust: + type: string + enum: [untrusted_input] objective: type: string minLength: 1 maxLength: 8192 pattern: '.*\S.*' - evidence: + facts: + type: array + maxItems: 64 + items: + $ref: "#/components/schemas/WorkClaim" + in_scope: type: array minItems: 1 - maxItems: 32 + maxItems: 64 items: - $ref: "#/components/schemas/HandoffCitation" - max_bytes: - type: integer - minimum: 512 - maximum: 32768 - default: 8000 - PreparedHandoff: + type: string + minLength: 1 + maxLength: 8192 + pattern: '.*\S.*' + exclusions: + type: array + maxItems: 64 + items: + type: string + minLength: 1 + maxLength: 8192 + pattern: '.*\S.*' + completion_criteria: + type: array + minItems: 1 + maxItems: 64 + items: + type: string + minLength: 1 + maxLength: 8192 + pattern: '.*\S.*' + authorization_notes: + type: array + maxItems: 64 + items: + type: string + minLength: 1 + maxLength: 8192 + pattern: '.*\S.*' + open_questions: + type: array + maxItems: 64 + items: + type: string + minLength: 1 + maxLength: 8192 + pattern: '.*\S.*' + CreateWorkContractRequest: type: object additionalProperties: false - required: [schema, scope_id, base, content] + required: [scope_id, source_id, contract] properties: - schema: - $ref: "#/components/schemas/PreparedHandoffSchema" scope_id: type: string - base: - $ref: "#/components/schemas/ArtifactReference" - nullable: true - content: - $ref: "#/components/schemas/HandoffContent" - PreparedContext: + minLength: 1 + maxLength: 256 + pattern: '.*\S.*' + source_id: + type: string + minLength: 1 + maxLength: 256 + pattern: '.*\S.*' + contract: + $ref: "#/components/schemas/WorkContract" + CurrentWorkHandoff: type: object additionalProperties: false - required: [schema, status, content, content_bytes] + required: [schema, trust, objective, state, disposition, next_action, omissions] properties: schema: - $ref: "#/components/schemas/PreparedContextSchema" - status: - $ref: "#/components/schemas/PreparedContextStatus" - content: type: string + enum: [powercontext.current-work-handoff.v1] + trust: + type: string + enum: [untrusted_input] + objective: + type: string + minLength: 1 + maxLength: 8192 + pattern: '.*\S.*' + state: + type: array + minItems: 1 + maxItems: 64 + items: + $ref: "#/components/schemas/WorkClaim" + disposition: + $ref: "#/components/schemas/HandoffDisposition" + next_action: + $ref: "#/components/schemas/WorkClaim" nullable: true - content_bytes: - type: integer - minimum: 0 - EntryChange: + omissions: + type: array + maxItems: 64 + items: + type: string + minLength: 1 + maxLength: 8192 + pattern: '.*\S.*' + HandoffCurrentWorkRequest: type: object additionalProperties: false - required: [op, entry_id, from_entry_version_id, to_entry_version_id, reason] + required: [scope_id, source_id, handoff] properties: - op: - $ref: "#/components/schemas/EntryChangeOperation" - entry_id: - type: string - minLength: 1 - maxLength: 128 - pattern: '^[\x21-\x7E]+$' - from_entry_version_id: + scope_id: type: string minLength: 1 - maxLength: 128 - pattern: '^[\x21-\x7E]+$' - nullable: true - to_entry_version_id: + maxLength: 256 + pattern: '.*\S.*' + source_id: type: string minLength: 1 - maxLength: 128 - pattern: '^[\x21-\x7E]+$' - nullable: true - reason: + maxLength: 256 + pattern: '.*\S.*' + handoff: + $ref: "#/components/schemas/CurrentWorkHandoff" + WorkSourceKind: + type: string + enum: [work-contract, handoff-boundary, handoff-receipt, task-outcome] + WorkSourceReceipt: + type: object + additionalProperties: false + required: [kind, source, position, content_digest] + properties: + kind: + $ref: "#/components/schemas/WorkSourceKind" + source: + $ref: "#/components/schemas/SourceReference" + position: + type: integer + minimum: 1 + content_digest: type: string - nullable: true - ExperienceArtifact: + minLength: 71 + maxLength: 71 + pattern: '^sha256:[0-9a-f]{64}$' + PreparedWorkHandoff: type: object additionalProperties: false - required: [artifact, content, source_refs, artifact_refs] + required: [boundary, handoff] 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: + boundary: + $ref: "#/components/schemas/WorkSourceReceipt" + handoff: + $ref: "#/components/schemas/PreparedHandoff" + HandoffReceiptStatus: + type: string + enum: [accepted, needs_clarification, declined] + HandoffAcknowledgementSelection: + type: string + enum: [prepared, exact] + LiveStateCheckStatus: + type: string + enum: [confirmed, mismatch, not_checked] + ReceiverReadinessCheckStatus: + type: string + enum: [confirmed, insufficient, not_checked] + ReceiverChecks: type: object additionalProperties: false - required: [situation, action, outcome, lesson] + description: Untrusted receiver self-attestation kept separate from citation availability. All three values must be confirmed when status is accepted. + required: [live_state, capability, authorization] properties: - situation: + live_state: + $ref: "#/components/schemas/LiveStateCheckStatus" + capability: + $ref: "#/components/schemas/ReceiverReadinessCheckStatus" + authorization: + $ref: "#/components/schemas/ReceiverReadinessCheckStatus" + AcknowledgeHandoffRequest: + type: object + additionalProperties: false + required: [scope_id, source_id, receiver, status, selection] + properties: + scope_id: type: string minLength: 1 - maxLength: 8000 + maxLength: 256 pattern: '.*\S.*' - action: + source_id: type: string minLength: 1 - maxLength: 8000 + maxLength: 256 pattern: '.*\S.*' - outcome: + receiver: type: string minLength: 1 - maxLength: 8000 + maxLength: 256 pattern: '.*\S.*' - lesson: + status: + $ref: "#/components/schemas/HandoffReceiptStatus" + selection: + $ref: "#/components/schemas/HandoffAcknowledgementSelection" + receiver_checks: + $ref: "#/components/schemas/ReceiverChecks" + nullable: true + prepared: + $ref: "#/components/schemas/PreparedHandoff" + nullable: true + revision: + $ref: "#/components/schemas/ArtifactReference" + nullable: true + message: type: string minLength: 1 - maxLength: 8000 + maxLength: 8192 pattern: '.*\S.*' - SkillArtifact: + nullable: true + HandoffAcknowledgement: type: object additionalProperties: false - required: [artifact, content, source_refs, artifact_refs] + required: [resolution, receipt] 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: + resolution: + $ref: "#/components/schemas/HandoffResolution" + receipt: + $ref: "#/components/schemas/WorkSourceReceipt" + TaskOutcomeStatus: + type: string + enum: [succeeded, partial, blocked, failed, cancelled, unknown] + TaskCheckStatus: + type: string + enum: [passed, failed, skipped, timed_out, unavailable, cancelled, unknown] + TaskCheck: type: object additionalProperties: false - required: [name, description, instructions, validation] + required: [name, status, basis, evidence] properties: name: type: string minLength: 1 - maxLength: 128 - pattern: '^\S(?:.*\S)?$' - description: - type: string - minLength: 1 - maxLength: 2000 - pattern: '^\S(?:.*\S)?$' - instructions: + maxLength: 8192 + pattern: '.*\S.*' + status: + $ref: "#/components/schemas/TaskCheckStatus" + details: type: string minLength: 1 - maxLength: 32000 + maxLength: 8192 pattern: '.*\S.*' - validation: + nullable: true + basis: + $ref: "#/components/schemas/WorkClaimBasis" + evidence: type: array - minItems: 1 maxItems: 32 items: - $ref: "#/components/schemas/SkillValidationItem" - SkillValidationItem: - type: string - minLength: 1 - maxLength: 2000 - pattern: '^\S(?:.*\S)?$' - ExternalSkillRegistration: + $ref: "#/components/schemas/HandoffCitation" + TaskOutcome: type: object additionalProperties: false - required: - - external_skill_id - - provider - - agent_kind - - host_id - - installation_scope - - locator - - fingerprint - - name - - description + required: [schema, trust, objective, status, summary, observations, checks, produced_artifacts, remaining_work] 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: + schema: type: string - pattern: '^[0-9a-f]{64}$' - name: + enum: [powercontext.task-outcome.v1] + trust: type: string - minLength: 1 - maxLength: 128 - pattern: '^\S(?:.*\S)?$' - description: + enum: [untrusted_observation] + objective: type: string minLength: 1 - maxLength: 2000 - pattern: '^\S(?:.*\S)?$' - ExternalSkillResolution: - type: object - additionalProperties: false - required: [registration, status, entrypoint] - properties: - registration: - $ref: "#/components/schemas/ExternalSkillRegistration" + maxLength: 8192 + pattern: '.*\S.*' status: - $ref: "#/components/schemas/ExternalSkillResolutionStatus" - entrypoint: + $ref: "#/components/schemas/TaskOutcomeStatus" + summary: type: string + minLength: 1 + maxLength: 8192 + pattern: '.*\S.*' + handoff_receipt_ref: + $ref: "#/components/schemas/SourceReference" 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: + observations: type: array + minItems: 1 + maxItems: 64 items: - $ref: "#/components/schemas/ExternalSkillRegistration" - skipped: - type: integer - minimum: 0 - ListExternalSkillsResponse: - type: object - additionalProperties: false - required: [skills] - properties: - skills: + $ref: "#/components/schemas/WorkClaim" + checks: type: array + maxItems: 64 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: + $ref: "#/components/schemas/TaskCheck" + produced_artifacts: + type: array + maxItems: 32 + items: + $ref: "#/components/schemas/ArtifactReference" + remaining_work: + type: array + maxItems: 64 + items: + type: string + minLength: 1 + maxLength: 8192 + pattern: '.*\S.*' + RecordTaskOutcomeRequest: type: object additionalProperties: false - required: [scope_id] + required: [scope_id, source_id, outcome] 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: + source_id: type: string minLength: 1 maxLength: 256 pattern: '.*\S.*' - citation: - $ref: "#/components/schemas/MemoryCitation" - GetArtifactCandidateRequest: + outcome: + $ref: "#/components/schemas/TaskOutcome" + CaptureContentSourceRequest: type: object additionalProperties: false - required: [scope_id, candidate_id] + required: [scope_id, source_id, content] properties: scope_id: type: string minLength: 1 maxLength: 256 pattern: '.*\S.*' - candidate_id: + source_id: type: string minLength: 1 - maxLength: 128 - pattern: '^[\x21-\x7E]+$' - GetExperienceRequest: + maxLength: 256 + content: + type: string + minLength: 1 + maxLength: 200000 + metadata: + type: object + additionalProperties: true + nullable: true + CaptureContentSourceResponse: + type: object + additionalProperties: false + required: [status, source, position] + properties: + status: + $ref: "#/components/schemas/CaptureStatus" + source: + $ref: "#/components/schemas/SourceReference" + position: + type: integer + minimum: 1 + CommitHandoffRequest: 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 + 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 - deprecated: true - description: Retained for wire compatibility and ignored when generating a scope report. - locale: - $ref: "#/components/schemas/ReportLocale" + selection: + $ref: "#/components/schemas/HandoffSelection" 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" + selected_revision: + $ref: "#/components/schemas/ArtifactReference" + nullable: true + current_revision: + $ref: "#/components/schemas/ArtifactReference" nullable: true - ListHandoffReportKnownScopesRequest: + 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/scripts/generate_api.py b/scripts/generate_api.py index 364eb1617..3bc865113 100644 --- a/scripts/generate_api.py +++ b/scripts/generate_api.py @@ -25,7 +25,17 @@ from datamodel_code_generator import GenerateConfig, InputFileType, generate from datamodel_code_generator.enums import StrictTypes from datamodel_code_generator.format import CodeFormatter, Formatter, PythonVersion -from fastapi.openapi.models import MediaType, OpenAPI, PathItem, Reference, RequestBody, Response, Schema +from fastapi.openapi.models import ( + MediaType, + OpenAPI, + Parameter, + ParameterInType, + PathItem, + Reference, + RequestBody, + Response, + Schema, +) from fastapi.openapi.models import Operation as OpenAPIOperation from pydantic import JsonValue, TypeAdapter, ValidationError @@ -145,7 +155,8 @@ def _generate_operations( if operation.operationId is None or operation.summary is None: raise ContractGenerationError("operation metadata", path) # noqa: TRY003 operation_id = operation.operationId - request_model = _request_model(operation, schemas) + parameters = _operation_parameters(path_item, operation) + request_model = _request_model(operation, parameters, schemas) if request_model is not None: imports.add(request_model[:2]) @@ -160,10 +171,14 @@ def _generate_operations( operation_id=operation_id, request_model=None if request_model is None else request_model[1], request_location=None if request_model is None else request_model[2], + path_parameters=tuple( + parameter.name for parameter in parameters if parameter.in_ is ParameterInType.path + ), response_model=response_model[1], success_status=success_status, summary=operation.summary, tags=tuple(operation.tags or ()), + scope_mode=_scope_mode(operation), responses={ int(code) if code.isdecimal() else code: _response_metadata(response) for code, response in operation.responses.items() @@ -198,10 +213,12 @@ class Operation(BaseModel, Generic[RequestT, ResponseT]): operation_id: str request_type: type[RequestT] | None request_location: Literal["body", "query"] | None + path_parameters: tuple[str, ...] response_type: type[ResponseT] success_status: int summary: str tags: tuple[str, ...] + scope_mode: Literal["none", "current", "selection"] responses: dict[int | str, dict[str, JsonValue]] @@ -293,6 +310,7 @@ def _with_model_validator_import(source: str) -> str: def _request_model( operation: OpenAPIOperation, + parameters: tuple[Parameter, ...], schemas: dict[str, Schema | Reference], ) -> tuple[str, str, Literal["body", "query"]] | None: request_body = operation.requestBody @@ -302,7 +320,7 @@ def _request_model( module, name = _model_for_json_content(request_body.content, schemas, "request body") return module, name, "body" - if not operation.parameters: + if not any(parameter.in_ is ParameterInType.query for parameter in parameters): return None operation_id = operation.operationId if operation_id is None: @@ -313,6 +331,15 @@ def _request_model( return "powercontext.http._generated.models", query_model, "query" +def _operation_parameters(path_item: PathItem, operation: OpenAPIOperation) -> tuple[Parameter, ...]: + parameters: list[Parameter] = [] + for parameter in (*(path_item.parameters or ()), *(operation.parameters or ())): + if not isinstance(parameter, Parameter): + raise ContractGenerationError("parameter reference", parameter) # noqa: TRY003 + parameters.append(parameter) + return tuple(parameters) + + def _success_response( responses: dict[str, Response | object], path: str, @@ -361,10 +388,12 @@ def _render_operation( operation_id: str, request_model: str | None, request_location: Literal["body", "query"] | None, + path_parameters: tuple[str, ...], response_model: str, success_status: int, summary: str, tags: tuple[str, ...], + scope_mode: Literal["none", "current", "selection"], responses: dict[int | str, dict[str, JsonValue]], ) -> str: request_type = "None" if request_model is None else request_model @@ -374,13 +403,22 @@ def _render_operation( operation_id={operation_id!r}, request_type={request_type}, request_location={request_location!r}, + path_parameters={path_parameters!r}, response_type={response_model}, success_status={success_status}, summary={summary!r}, tags={tags!r}, + scope_mode={scope_mode!r}, responses={pformat(responses, width=100, sort_dicts=False)}, )""" +def _scope_mode(operation: OpenAPIOperation) -> Literal["none", "current", "selection"]: + value = (operation.model_extra or {}).get("x-powercontext-scope-mode", "none") + if value not in {"none", "current", "selection"}: + raise ContractGenerationError("x-powercontext-scope-mode", value) + return value + + if __name__ == "__main__": main() diff --git a/scripts/generate_js_operations.py b/scripts/generate_js_operations.py index 64eec9041..73b599b1d 100644 --- a/scripts/generate_js_operations.py +++ b/scripts/generate_js_operations.py @@ -89,7 +89,8 @@ def parse_operations(doc: dict[str, Any]) -> list[dict[str, Any]]: "method": method.upper(), "path": path, "location": _request_location(body_schema, parameters), - "scope": _operation_has_scope(doc, body_schema, parameters), + "scopeMode": _scope_mode(operation), + "pathParameters": _path_parameters(parameters), }) return rows @@ -112,10 +113,10 @@ def main() -> None: def _render_row(row: dict[str, Any]) -> str: location = "null" if row["location"] is None else f'"{row["location"]}"' - scope = "true" if row["scope"] else "false" + path_parameters = ", ".join(f"'{name}'" for name in row["pathParameters"]) return ( f" {row['operationId']}: {{ method: '{row['method']}', path: '{row['path']}', " - f"location: {location}, scope: {scope} }}," + f"location: {location}, scopeMode: '{row['scopeMode']}', pathParameters: [{path_parameters}] }}," ) @@ -142,20 +143,6 @@ def _deref(doc: dict[str, Any], node: Any, seen: set[str] | None = None) -> Any: return node -def _schema_has_scope(doc: dict[str, Any], schema: Any, seen: set[str] | None = None) -> bool: - resolved = _deref(doc, schema, seen or set()) - if not isinstance(resolved, dict): - return False - properties = resolved.get("properties") - if isinstance(properties, dict) and "scope_id" in properties: - return True - for key in ("allOf", "oneOf", "anyOf"): - parts = resolved.get(key) - if isinstance(parts, list) and any(_schema_has_scope(doc, part, set(seen or set())) for part in parts): - return True - return False - - def _json_body_schema(doc: dict[str, Any], operation: dict[str, Any]) -> Any: body = _deref(doc, operation.get("requestBody")) if not isinstance(body, dict): @@ -187,10 +174,19 @@ def _request_location(body_schema: Any, parameters: list[dict[str, Any]]) -> str return None -def _operation_has_scope(doc: dict[str, Any], body_schema: Any, parameters: list[dict[str, Any]]) -> bool: - if body_schema and _schema_has_scope(doc, body_schema): - return True - return any(parameter.get("in") == "query" and parameter.get("name") == "scope_id" for parameter in parameters) +def _scope_mode(operation: dict[str, Any]) -> str: + value = operation.get("x-powercontext-scope-mode", "none") + if value not in {"none", "current", "selection"}: + raise SystemExit("invalid x-powercontext-scope-mode") # noqa: TRY003 + return value + + +def _path_parameters(parameters: list[dict[str, Any]]) -> list[str]: + return [ + name + for parameter in parameters + if parameter.get("in") == "path" and isinstance(name := parameter.get("name"), str) + ] if __name__ == "__main__": diff --git a/src/powercontext/builtin/artifacts/handoff/service.py b/src/powercontext/builtin/artifacts/handoff/service.py index 8c46dbbd1..4456c20bd 100644 --- a/src/powercontext/builtin/artifacts/handoff/service.py +++ b/src/powercontext/builtin/artifacts/handoff/service.py @@ -16,7 +16,7 @@ from __future__ import annotations -from collections.abc import Iterable +from collections.abc import Callable, Iterable from powercontext.artifacts import ArtifactRef from powercontext.builtin.artifacts.handoff.errors import ( @@ -63,12 +63,14 @@ def __init__( artifact_id: str, backend: HandoffBackend, evidence_resolver: HandoffEvidenceResolver, + evidence_resolver_for_scope: Callable[[str], HandoffEvidenceResolver] | None = None, generation_pipeline: HandoffGenerationPipeline | None = None, ) -> None: self.scope_id = scope_id self.artifact_id = artifact_id self._backend = backend self._evidence_resolver = evidence_resolver + self._evidence_resolver_for_scope = evidence_resolver_for_scope self._generation_pipeline = generation_pipeline ArtifactRef(family=Handoff.family, artifact_id=artifact_id, revision=1) @@ -125,9 +127,9 @@ async def latest(self) -> Handoff | None: return await self._backend.latest(self.artifact_id) async def revision(self, reference: ArtifactRef, /) -> Handoff: - """Return one exact committed milestone.""" + """Return one exact local or published Handoff revision.""" - self._require_reference(reference) + self._require_handoff_reference(reference) return await self._backend.get(reference) async def revisions(self) -> tuple[Handoff, ...]: @@ -148,26 +150,31 @@ async def continue_from( ) -> HandoffResolution: """Resolve Handoff content without treating historical claims as current truth.""" - current = await self._backend.latest(self.artifact_id) + evidence_resolver = self._evidence_resolver if isinstance(handoff, PreparedHandoff): + current = await self._backend.latest(self.artifact_id) self._require_prepared(handoff) content = handoff.content selection: HandoffResolutionSelection = "prepared" selected_revision = None else: selected = await self.revision(handoff) + current = await self._backend.latest(selected.artifact_id) content = selected.content selection = "exact" selected_revision = selected.as_ref() + provenance = selected.lineage.publication_source + evidence_resolver = self._resolver_for_scope(self.scope_id if provenance is None else provenance.scope_id) return await self._resolve( content, selection=selection, selected_revision=selected_revision, current=current, + evidence_resolver=evidence_resolver, ) async def continue_latest(self) -> HandoffResolution: - """Resolve the latest milestone after the caller selects the current workstream.""" + """Resolve the latest milestone after the caller selects the current Scope.""" current = await self._backend.latest(self.artifact_id) if current is None: @@ -181,6 +188,7 @@ async def continue_latest(self) -> HandoffResolution: selection="latest", selected_revision=current.as_ref(), current=current, + evidence_resolver=self._evidence_resolver, ) async def _resolve( @@ -190,6 +198,7 @@ async def _resolve( selection: HandoffResolutionSelection, selected_revision: ArtifactRef | None, current: Handoff | None, + evidence_resolver: HandoffEvidenceResolver, ) -> HandoffResolution: return HandoffResolution( status="resolved", @@ -198,9 +207,16 @@ async def _resolve( selection=selection, selected_revision=selected_revision, current_revision=None if current is None else current.as_ref(), - evidence_checks=await self._evidence_checks(content), + evidence_checks=await self._evidence_checks(content, evidence_resolver=evidence_resolver), ) + def _resolver_for_scope(self, scope_id: str) -> HandoffEvidenceResolver: + if scope_id == self.scope_id: + return self._evidence_resolver + if self._evidence_resolver_for_scope is None: + return self._evidence_resolver + return self._evidence_resolver_for_scope(scope_id) + @staticmethod def render( handoff: HandoffDraft | PreparedHandoff | Handoff, @@ -219,9 +235,13 @@ def _require_prepared(self, prepared: PreparedHandoff) -> None: if prepared.scope_id != self.scope_id: raise HandoffScopeMismatchError(self.scope_id, prepared.scope_id) if prepared.base is not None: - self._require_reference(prepared.base) + self._require_lifecycle_reference(prepared.base) + + def _require_handoff_reference(self, reference: ArtifactRef) -> None: + if reference.family != Handoff.family: + raise InvalidHandoffReferenceError(reference) - def _require_reference(self, reference: ArtifactRef) -> None: + def _require_lifecycle_reference(self, reference: ArtifactRef) -> None: if reference.family != Handoff.family or reference.artifact_id != self.artifact_id: raise InvalidHandoffReferenceError(reference) @@ -248,12 +268,18 @@ def _validate_generated_draft(action: PrepareHandoff, draft: object) -> None: if content_bytes > action.max_bytes: raise InvalidHandoffGenerationError("budget") - async def _evidence_checks(self, content: HandoffContent) -> tuple[HandoffEvidenceCheck, ...]: + async def _evidence_checks( + self, + content: HandoffContent, + *, + evidence_resolver: HandoffEvidenceResolver, + ) -> tuple[HandoffEvidenceCheck, ...]: checks = [ await self._check_evidence( statement.citations, claim="state", state_index=index, + evidence_resolver=evidence_resolver, ) for index, statement in enumerate(content.state) ] @@ -262,6 +288,7 @@ async def _evidence_checks(self, content: HandoffContent) -> tuple[HandoffEviden await self._check_evidence( content.next_action.citations, claim="next_action", + evidence_resolver=evidence_resolver, ) ) return tuple(checks) @@ -272,11 +299,12 @@ async def _check_evidence( *, claim: HandoffClaim, state_index: int | None = None, + evidence_resolver: HandoffEvidenceResolver, ) -> HandoffEvidenceCheck: unavailable: list[HandoffCitation] = [] for citation in citations: try: - await self._evidence_resolver.validate(citation) + await evidence_resolver.validate(citation) except HandoffEvidenceUnavailableError: if citation not in unavailable: unavailable.append(citation) diff --git a/src/powercontext/builtin/handoff_report/__init__.py b/src/powercontext/builtin/handoff_report/__init__.py index 16af8671b..873247d2b 100644 --- a/src/powercontext/builtin/handoff_report/__init__.py +++ b/src/powercontext/builtin/handoff_report/__init__.py @@ -12,14 +12,10 @@ # 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 +24,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..6f617edeb 100644 --- a/src/powercontext/builtin/handoff_report/adapters.py +++ b/src/powercontext/builtin/handoff_report/adapters.py @@ -12,19 +12,14 @@ # 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 +27,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 +42,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..eea1b0b1b 100644 --- a/src/powercontext/builtin/handoff_report/canonical.py +++ b/src/powercontext/builtin/handoff_report/canonical.py @@ -12,93 +12,54 @@ # 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 +68,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..771e9f81e 100644 --- a/src/powercontext/builtin/handoff_report/errors.py +++ b/src/powercontext/builtin/handoff_report/errors.py @@ -12,185 +12,26 @@ # 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..8ffc98f2c 100644 --- a/src/powercontext/builtin/handoff_report/protocols.py +++ b/src/powercontext/builtin/handoff_report/protocols.py @@ -12,50 +12,25 @@ # 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: ... - 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: ... - ... - async def get(self, scope_id: str, reference: ArtifactRef, /) -> Handoff: - """Return the exact committed Handoff addressed by ``reference``.""" +class ScopeSelectionResolver(Protocol): + async def resolve_selection(self, selection: ScopeSelection, /) -> tuple[ScopeDescriptor, ...]: ... - ... - 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.""" - - ... - - -class WorkContinuityReadAdapter(Protocol): - """Read the high-level Work loop projection for one scope.""" - - async def get(self, scope_id: str, reference: ArtifactRef | None, /) -> WorkContinuity: ... - - -__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..669177353 100644 --- a/src/powercontext/builtin/handoff_report/rendering.py +++ b/src/powercontext/builtin/handoff_report/rendering.py @@ -12,443 +12,58 @@ # 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/__init__.py b/src/powercontext/builtin/runtime/__init__.py index ef7eba4e3..1268ff1c7 100644 --- a/src/powercontext/builtin/runtime/__init__.py +++ b/src/powercontext/builtin/runtime/__init__.py @@ -151,6 +151,7 @@ RecallTokenStatistics, RecallTokenValue, ResolvedUsagePeriod, + ScopeStatistics, SourceInventoryStatistics, Statistics, StatisticsPeriod, @@ -260,6 +261,7 @@ "RuntimeReadinessStatus", "ScheduledExperienceProcessor", "ScheduledSourceProcessor", + "ScopeStatistics", "ScopedExperienceApplication", "ScopedExternalSkillApplication", "ScopedHandoffApplication", diff --git a/src/powercontext/builtin/runtime/application.py b/src/powercontext/builtin/runtime/application.py index 750b7d20e..4b0a01dc4 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 ( @@ -119,7 +120,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,6 +139,7 @@ RuntimeReadinessChecks, ) from powercontext.builtin.runtime.statistics import RelationalScopedStatistics +from powercontext.builtin.scope import ScopeApplication, ScopeNotFoundError, ScopeSelection from powercontext.builtin.sources import ( ContentCapture, ContentSource, @@ -147,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, @@ -322,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.""" @@ -336,26 +365,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 @@ -374,59 +480,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: @@ -1227,6 +1302,8 @@ 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, tracing: RuntimeTracing | None = None, @@ -1245,6 +1322,8 @@ 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 self._tracing = tracing diff --git a/src/powercontext/builtin/runtime/composition.py b/src/powercontext/builtin/runtime/composition.py index 3867a28e9..014637371 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, @@ -279,16 +278,16 @@ 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, ) ) 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") @@ -322,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() @@ -332,13 +330,13 @@ 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: await index.initialize(connection) await experience_index.initialize(connection) - yield RelationalContexts( + contexts = RelationalContexts( database=profile.database, index=index, experience_index=experience_index, @@ -353,13 +351,16 @@ async def open_builtin_contexts( memory_reranker=memory_reranker, memory_rerank_candidate_limit=config.runtime.memory_rerank_candidate_limit, ) + await contexts.scopes.register_existing() + await contexts.scopes.bootstrap_default() + yield contexts return experience_index = OceanBaseExperienceFTSIndex() indexes = [OceanBaseMemoryFTSIndex()] 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): @@ -370,7 +371,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 +386,9 @@ async def open_builtin_contexts( memory_reranker=memory_reranker, memory_rerank_candidate_limit=config.runtime.memory_rerank_candidate_limit, ) + await contexts.scopes.register_existing() + await contexts.scopes.bootstrap_default() + yield contexts async def _generation_pipelines( 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 534a828f5..debdd0fa4 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 @@ -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, @@ -89,6 +90,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, @@ -228,6 +230,18 @@ def handoff( source_resolver: SourceCatalog, ) -> HandoffService: memory = self.memory(source_resolver) + + def evidence_resolver(scope_id: str) -> RelationalHandoffEvidenceResolver: + services = self if scope_id == self.scope_id else replace(self, scope_id=scope_id) + _, catalog = services.sources() + return RelationalHandoffEvidenceResolver( + database=services.database, + scope_id=scope_id, + sources=services.repositories.sources, + artifacts=services.repositories.artifacts, + memory=services.memory(catalog), + ) + return HandoffService( scope_id=self.scope_id, artifact_id=self.handoff_artifact_id, @@ -243,6 +257,7 @@ def handoff( artifacts=self.repositories.artifacts, memory=memory, ), + evidence_resolver_for_scope=evidence_resolver, generation_pipeline=self.handoff_pipeline, ) @@ -267,9 +282,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, @@ -305,6 +321,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( @@ -318,6 +335,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/builtin/runtime/statistics.py b/src/powercontext/builtin/runtime/statistics.py index 0fe8d5dbf..96d6e9159 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, @@ -51,6 +52,7 @@ RecallTokenStatistics, RecallTokenValue, ResolvedUsagePeriod, + ScopeStatistics, SourceInventoryStatistics, Statistics, StatisticsPeriod, @@ -123,24 +125,36 @@ async def overview(self, period: StatisticsPeriod, as_of: datetime, /) -> Statis processed = 0 if cursor is None else cursor.cursor.sequence artifacts = tuple(FamilyCount(family=family, total=total) for family, total in stored_inventory.artifacts) candidates = _candidate_inventory(stored_inventory.candidates) + inventory = InventoryStatistics( + sources=SourceInventoryStatistics( + total=stored_inventory.sources, + memory_processed=processed, + memory_pending=max(stored_inventory.sources - processed, 0), + ), + artifacts=ArtifactInventoryStatistics( + total=sum(item.total for item in artifacts), + by_family=artifacts, + ), + candidates=candidates, + memory=MemoryInventoryStatistics(entries=_memory_inventory(memory_entries)), + ) + usage = _usage_statistics(resolved_period, usage_rows) + recall = _recall_statistics(resolved_period, self._token_estimator, recall_rows) 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( - total=stored_inventory.sources, - memory_processed=processed, - memory_pending=max(stored_inventory.sources - processed, 0), - ), - artifacts=ArtifactInventoryStatistics( - total=sum(item.total for item in artifacts), - by_family=artifacts, + inventory=inventory, + usage=usage, + recall=recall, + by_scope=( + ScopeStatistics( + scope_id=self._scope_id, + inventory=inventory, + usage=usage, + recall=recall, ), - candidates=candidates, - memory=MemoryInventoryStatistics(entries=_memory_inventory(memory_entries)), ), - usage=_usage_statistics(resolved_period, usage_rows), - recall=_recall_statistics(resolved_period, self._token_estimator, recall_rows), ) async def record( diff --git a/src/powercontext/builtin/scope/application.py b/src/powercontext/builtin/scope/application.py index 6c68451bd..bfdd658fc 100644 --- a/src/powercontext/builtin/scope/application.py +++ b/src/powercontext/builtin/scope/application.py @@ -22,6 +22,9 @@ from collections import deque from collections.abc import Callable, Sequence +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncConnection + from powercontext.builtin.persistence.database import AsyncDatabase from powercontext.builtin.scope.errors import ( ScopeBindingNotFoundError, @@ -91,20 +94,39 @@ async def create(self, draft: ScopeDraft, /) -> ScopeDescriptor: 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) + try: + async with self._database.transaction() as connection: + existing = await self._repository.creation(connection, draft.idempotency_key) + if existing is not None: + return await self._resolve_creation(connection, draft.idempotency_key, digest, existing) + 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) + except IntegrityError: + # The transaction must roll back before reading the request that won + # the unique-key race. An unrelated integrity failure remains visible. + async with self._database.transaction() as connection: + existing = await self._repository.creation(connection, draft.idempotency_key) + if existing is None: + raise + return await self._resolve_creation(connection, draft.idempotency_key, digest, existing) + + async def _resolve_creation( + self, + connection: AsyncConnection, + idempotency_key: str, + request_digest: str, + creation: tuple[str, str], + /, + ) -> ScopeDescriptor: + existing_digest, scope_id = creation + if existing_digest != request_digest: + raise ScopeIdempotencyConflictError(idempotency_key) + return await self._required(connection, scope_id) async def get(self, scope_id: str, /) -> ScopeDescriptor: async with self._database.transaction() as connection: @@ -192,7 +214,7 @@ async def resolve_selection(self, selection: ScopeSelection, /) -> tuple[ScopeDe scopes = await self._repository.list(connection) return _subtree(scopes, root_scope_id) - async def _required(self, connection, scope_id: str) -> ScopeDescriptor: + async def _required(self, connection: AsyncConnection, scope_id: str) -> ScopeDescriptor: scope = await self._repository.get(connection, scope_id) if scope is None: raise ScopeNotFoundError(scope_id) @@ -200,7 +222,7 @@ async def _required(self, connection, scope_id: str) -> ScopeDescriptor: async def _validate_relationships( self, - connection, + connection: AsyncConnection, *, scope_id: str | None, parent_scope_id: str | None, diff --git a/src/powercontext/builtin/scope/repository.py b/src/powercontext/builtin/scope/repository.py index 765610ed4..360b7fcbb 100644 --- a/src/powercontext/builtin/scope/repository.py +++ b/src/powercontext/builtin/scope/repository.py @@ -20,6 +20,7 @@ from hashlib import sha256 from sqlalchemy import delete, insert, select, union, update +from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncConnection from powercontext.builtin.persistence.tables import ( @@ -243,13 +244,22 @@ async def default_scope_id(self, connection: AsyncConnection, /) -> str | 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( + statement = ( update(SCOPE_SETTINGS_TABLE) .where(SCOPE_SETTINGS_TABLE.c.name == _DEFAULT_SETTING) .values(scope_id=scope_id) ) + result = await connection.execute(statement) if result.rowcount == 0: - await connection.execute(insert(SCOPE_SETTINGS_TABLE).values(name=_DEFAULT_SETTING, scope_id=scope_id)) + try: + async with connection.begin_nested(): + await connection.execute( + insert(SCOPE_SETTINGS_TABLE).values(name=_DEFAULT_SETTING, scope_id=scope_id) + ) + except IntegrityError: + await connection.execute(statement) + if await self.default_scope_id(connection) != scope_id: + raise async def binding(self, connection: AsyncConnection, key: ScopeBindingKey, /) -> ScopeBinding | None: value = ( @@ -270,7 +280,7 @@ async def set_binding( scope_id: str, /, ) -> ScopeBinding: - result = await connection.execute( + statement = ( update(SCOPE_BINDINGS_TABLE) .where( SCOPE_BINDINGS_TABLE.c.integration == key.integration, @@ -279,15 +289,22 @@ async def set_binding( ) .values(scope_id=scope_id) ) + result = await connection.execute(statement) 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, - ) - ) + try: + async with connection.begin_nested(): + await connection.execute( + insert(SCOPE_BINDINGS_TABLE).values( + integration=key.integration, + kind=key.kind, + external_id=key.external_id, + scope_id=scope_id, + ) + ) + except IntegrityError: + await connection.execute(statement) + if await self.binding(connection, key) != ScopeBinding(key=key, scope_id=scope_id): + raise return ScopeBinding(key=key, scope_id=scope_id) async def clear_binding(self, connection: AsyncConnection, key: ScopeBindingKey, /) -> bool: diff --git a/src/powercontext/builtin/statistics/__init__.py b/src/powercontext/builtin/statistics/__init__.py index f5663f380..351882285 100644 --- a/src/powercontext/builtin/statistics/__init__.py +++ b/src/powercontext/builtin/statistics/__init__.py @@ -34,6 +34,7 @@ RecallTokenStatistics, RecallTokenValue, ResolvedUsagePeriod, + ScopeStatistics, SourceInventoryStatistics, Statistics, StatisticsPeriod, @@ -60,6 +61,7 @@ "RecallTokenStatistics", "RecallTokenValue", "ResolvedUsagePeriod", + "ScopeStatistics", "SourceInventoryStatistics", "Statistics", "StatisticsPeriod", diff --git a/src/powercontext/builtin/statistics/aggregation.py b/src/powercontext/builtin/statistics/aggregation.py new file mode 100644 index 000000000..c32cd5fd8 --- /dev/null +++ b/src/powercontext/builtin/statistics/aggregation.py @@ -0,0 +1,260 @@ +# 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.models import ScopeSelection +from powercontext.builtin.statistics.models import ( + ArtifactInventoryStatistics, + CandidateFamilyCount, + CandidateInventoryStatistics, + FamilyCount, + InventoryStatistics, + MemoryEntryInventoryStatistics, + MemoryInventoryStatistics, + MemoryKindCount, + ModelUsageDay, + ModelUsagePurpose, + ModelUsagePurposeBreakdown, + ModelUsageStatistics, + ModelUsageValue, + RecallTokenDay, + RecallTokenStatistics, + RecallTokenValue, + ScopeStatistics, + 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), + by_scope=tuple( + ScopeStatistics( + scope_id=scope_id, + inventory=snapshot.inventory, + usage=snapshot.usage, + recall=snapshot.recall, + ) + for scope_id, snapshot in zip(scope_ids, snapshots, strict=True) + ), + ) + + +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..eb8e7276d 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.models import ScopeSelection class StatisticsPeriod(StrEnum): @@ -228,14 +229,31 @@ class RecallTokenStatistics(BaseModel): daily: tuple[RecallTokenDay, ...] -class Statistics(BaseModel): - """Current inventory and bounded model usage for one scope.""" +class ScopeStatistics(BaseModel): + """Statistics retained for one Scope inside a resolved selection.""" scope_id: str + inventory: InventoryStatistics + usage: UsageStatistics + recall: RecallTokenStatistics + + +class Statistics(BaseModel): + """Current inventory and bounded model usage for one frozen Scope selection.""" + + selection: ScopeSelection + scope_ids: tuple[str, ...] as_of: datetime inventory: InventoryStatistics usage: UsageStatistics recall: RecallTokenStatistics + by_scope: tuple[ScopeStatistics, ...] + + @model_validator(mode="after") + def validate_scope_details(self) -> Statistics: + if tuple(item.scope_id for item in self.by_scope) != self.scope_ids: + raise ValueError("by_scope must contain one ordered entry for every resolved Scope") # noqa: TRY003 + return self __all__ = [ @@ -258,6 +276,7 @@ class Statistics(BaseModel): "RecallTokenStatistics", "RecallTokenValue", "ResolvedUsagePeriod", + "ScopeStatistics", "SourceInventoryStatistics", "Statistics", "StatisticsPeriod", diff --git a/src/powercontext/cli/config.py b/src/powercontext/cli/config.py index 166b84edb..752baa7a2 100644 --- a/src/powercontext/cli/config.py +++ b/src/powercontext/cli/config.py @@ -17,7 +17,6 @@ from __future__ import annotations import asyncio -import json import os import re import shlex @@ -70,8 +69,6 @@ class GeneratedConfiguration: """Canonical state rendered into one managed environment block.""" config_version: int - scope_id: str - display_name: str generation: ModelSelection embedding: ModelSelection embedding_profile_id: str @@ -193,7 +190,7 @@ class ApiProtocol: "POWERCONTEXT_OPENCODE_CAPTURE_PROMPTS": "true", "POWERCONTEXT_PI_CAPTURE_PROMPTS": "true", } -_SCOPE_NAMES = ( +_EXPLICIT_SCOPE_NAMES = ( "POWERCONTEXT_CODEX_SCOPE_ID", "POWERCONTEXT_CLAUDE_SCOPE_ID", "POWERCONTEXT_DSH_SCOPE_ID", @@ -208,11 +205,10 @@ class ApiProtocol: } _ALL_FIXED_MANAGED_NAMES = ( set(_BASE_ENVIRONMENT) - | set(_SCOPE_NAMES) + | set(_EXPLICIT_SCOPE_NAMES) | _KNOWN_PROVIDER_NAMES | _OPTIONAL_MANAGED_NAMES | { - "POWERCONTEXT_SERVER_DASHBOARD_SCOPES", "POWERCONTEXT_SERVER_DATABASE_KIND", "POWERCONTEXT_SERVER_RUNTIME_SCHEDULE_SECONDS", "POWERCONTEXT_SERVER_INFERENCE_GENERATION_MODEL", @@ -313,7 +309,7 @@ def _print_summary(configuration: GeneratedConfiguration) -> None: "provider default", ) typer.secho("\nConfiguration", bold=True, fg=typer.colors.CYAN) - typer.echo(f" Scope {configuration.scope_id}") + typer.echo(" Scope Server default; integrations may bind a Session or workspace") typer.echo(f" Generation {configuration.generation.model.partition(':')[2]} ({generation_url})") typer.echo(f" Embedding {configuration.embedding.model.partition(':')[2]} ({embedding_url})") typer.echo(f" Database {configuration.database_kind}") @@ -327,8 +323,6 @@ def collect_configuration( typer.secho("\nPowerContext configuration", bold=True, fg=typer.colors.CYAN) typer.echo("Press Enter to accept a default. Provider details are derived from the API protocol.\n") - scope_id = typer.prompt("Scope ID", default="project:quickstart").strip() - display_name = typer.prompt("Dashboard display name", default="Quick Start").strip() generation, generation_credentials = _collect_connection("generation") generation_protocol = _PROTOCOL_BY_ID.get(generation.protocol_id or "") can_reuse = generation_protocol is not None and generation_protocol.embedding_adapter is not None @@ -355,8 +349,6 @@ def collect_configuration( schedule = 60 return GeneratedConfiguration( config_version=CONFIG_VERSION, - scope_id=scope_id, - display_name=display_name, generation=generation, embedding=embedding, embedding_profile_id=profile, @@ -374,11 +366,6 @@ def render_environment(configuration: GeneratedConfiguration) -> dict[str, str]: values = dict(_BASE_ENVIRONMENT) values.update({ - "POWERCONTEXT_SERVER_DASHBOARD_SCOPES": json.dumps( - [{"scope_id": configuration.scope_id, "display_name": configuration.display_name}], - separators=(",", ":"), - ensure_ascii=False, - ), "POWERCONTEXT_SERVER_DATABASE_KIND": configuration.database_kind, "POWERCONTEXT_SERVER_RUNTIME_SCHEDULE_SECONDS": str(configuration.schedule_seconds), "POWERCONTEXT_SERVER_INFERENCE_GENERATION_MODEL": configuration.generation.model, @@ -386,7 +373,6 @@ def render_environment(configuration: GeneratedConfiguration) -> dict[str, str]: "POWERCONTEXT_SERVER_INFERENCE_EMBEDDING_PROFILE_ID": configuration.embedding_profile_id, "POWERCONTEXT_SERVER_INFERENCE_EMBEDDING_DIMENSION": str(configuration.embedding_dimension), }) - values.update(dict.fromkeys(_SCOPE_NAMES, configuration.scope_id)) if configuration.database_url is not None: values["POWERCONTEXT_SERVER_DATABASE_URL"] = configuration.database_url if configuration.database_path is not None: @@ -446,11 +432,8 @@ def configuration_from_document(content: str) -> GeneratedConfiguration: metadata = _managed_metadata(content) generation_model = _required(values, "POWERCONTEXT_SERVER_INFERENCE_GENERATION_MODEL") embedding_model = _required(values, "POWERCONTEXT_SERVER_INFERENCE_EMBEDDING_MODEL") - scope_id, display_name = _scope(values) return GeneratedConfiguration( config_version=_parse_integer(str(metadata.get("config-version", CONFIG_VERSION)), "config-version"), - scope_id=scope_id, - display_name=display_name, generation=ModelSelection( generation_model, _provider_variables("generation", generation_model, metadata, values) ), @@ -472,16 +455,10 @@ def configuration_from_document(content: str) -> GeneratedConfiguration: def validate_configuration(configuration: GeneratedConfiguration) -> None: - """Validate model, environment, database, Scope, and runtime contracts.""" + """Validate model, environment, database, and runtime contracts.""" if configuration.config_version != CONFIG_VERSION: raise ConfigError(f"unsupported config version: {configuration.config_version}") # noqa: TRY003 - if not configuration.scope_id.strip() or not configuration.display_name.strip(): - raise ConfigError("Scope ID and Dashboard display name are required") # noqa: TRY003 - if len(configuration.scope_id) > 255: - raise ConfigError("Scope ID must contain at most 255 characters") # noqa: TRY003 - if len(configuration.display_name) > 80: - raise ConfigError("Dashboard display name must contain at most 80 characters") # noqa: TRY003 if configuration.embedding_dimension < 1 or configuration.schedule_seconds < 1: raise ConfigError("Embedding dimension and Source interval must be positive") # noqa: TRY003 _validate_model_selection("Generation", configuration.generation) @@ -837,17 +814,6 @@ def _environment_names(selection: ModelSelection) -> str: return ",".join(variable.name for variable in selection.environment) -def _scope(values: Mapping[str, str]) -> tuple[str, str]: - raw = _required(values, "POWERCONTEXT_SERVER_DASHBOARD_SCOPES") - try: - first = json.loads(raw)[0] - return str(first["scope_id"]), str(first["display_name"]) - except (IndexError, KeyError, TypeError, json.JSONDecodeError) as error: - raise ConfigError( # noqa: TRY003 - "Dashboard scopes must contain one scope_id and display_name" - ) from error - - def _required(values: Mapping[str, str], name: str) -> str: value = values.get(name) if value is None or not value.strip(): diff --git a/src/powercontext/cli/hosts.py b/src/powercontext/cli/hosts.py index 6e7cc0d4c..bace9c09f 100644 --- a/src/powercontext/cli/hosts.py +++ b/src/powercontext/cli/hosts.py @@ -186,7 +186,6 @@ def run_setup_select( source: str, ref: str, server_url: str | None, - scope_mode: str, capture_prompts: bool, json_output: bool, ) -> None: @@ -204,7 +203,6 @@ def run_setup_select( source=source, ref=ref, server_url=server_url, - scope_mode=scope_mode, capture_prompts=capture_prompts, ) write_setup_select_report(report, json_output=json_output) @@ -231,7 +229,6 @@ def setup_selected_hosts( source: str, ref: str, server_url: str | None, - scope_mode: str, capture_prompts: bool, ) -> SetupSelectReport: """Install selected hosts and isolate failures from sibling hosts.""" @@ -250,7 +247,6 @@ def setup_selected_hosts( source=source, ref=ref, server_url=server_url, - scope_mode=scope_mode, capture_prompts=capture_prompts, ) verify_host(host.name) @@ -267,7 +263,6 @@ def install_host( source: str, ref: str, server_url: str | None, - scope_mode: str, capture_prompts: bool, ) -> object: """Call the existing installer for one first-class host.""" @@ -297,7 +292,6 @@ def install_host( source=source, ref=ref, server_url=server_url if server_url is not None else DEFAULT_OPENCLAW_SERVER_URL, - scope_mode=scope_mode, ) if name == "opencode": from powercontext.cli.opencode import install_opencode_plugin diff --git a/src/powercontext/cli/openclaw.py b/src/powercontext/cli/openclaw.py index 7ba2943d6..c148344d8 100644 --- a/src/powercontext/cli/openclaw.py +++ b/src/powercontext/cli/openclaw.py @@ -48,16 +48,12 @@ def install_openclaw_plugin( source: str, ref: str, server_url: str, - scope_mode: str, ) -> OpenClawSetupResult: """Build, install, and configure the OpenClaw plugin from one source/ref.""" executable = openclaw_executable() require_supported_openclaw(executable) normalized_url = normalize_server_url(server_url) - if scope_mode not in {"agent", "project"}: - raise SetupError.invalid_openclaw_scope() - data_dir = powercontext_data_dir() try: data_dir.mkdir(parents=True, exist_ok=True) @@ -70,13 +66,11 @@ def install_openclaw_plugin( configure_openclaw( executable=executable, server_url=normalized_url, - scope_mode=scope_mode, ) return OpenClawSetupResult( plugin=OPENCLAW_PLUGIN_NAME, plugin_path=str(plugin_dir), server_url=normalized_url, - scope_mode=scope_mode, data_dir=str(data_dir), ) @@ -233,7 +227,7 @@ def normalize_server_url(value: str) -> str: return urlunsplit((parsed.scheme, parsed.netloc, parsed.path.rstrip("/"), "", "")) -def configure_openclaw(*, executable: str, server_url: str, scope_mode: str) -> None: +def configure_openclaw(*, executable: str, server_url: str) -> None: """Write plugin, memory-slot, and coding-tool allowlist configuration.""" settings = [ @@ -241,7 +235,6 @@ def configure_openclaw(*, executable: str, server_url: str, scope_mode: str) -> {"path": "plugins.entries.memory-powercontext.config.endpoint", "value": server_url}, {"path": "plugins.entries.memory-powercontext.config.autoRecall", "value": True}, {"path": "plugins.entries.memory-powercontext.config.autoCapture", "value": True}, - {"path": "plugins.entries.memory-powercontext.config.scopeMode", "value": scope_mode}, {"path": "plugins.entries.memory-powercontext.hooks.allowConversationAccess", "value": True}, {"path": "plugins.slots.memory", "value": OPENCLAW_PLUGIN_NAME}, ] diff --git a/src/powercontext/cli/system.py b/src/powercontext/cli/system.py index 7ad47ab0e..80e9d0525 100644 --- a/src/powercontext/cli/system.py +++ b/src/powercontext/cli/system.py @@ -43,7 +43,6 @@ DEFAULT_MARKETPLACE_REF = "master" DEFAULT_CLAUDE_CODE_SERVER_URL = "http://127.0.0.1:8000" DEFAULT_OPENCLAW_SERVER_URL = "http://127.0.0.1:8000" -DEFAULT_OPENCLAW_SCOPE_MODE = "agent" PLUGIN_NAME = "powercontext" CLAUDE_MARKETPLACE_NAME = "powercontext" _GITHUB_REPOSITORY = re.compile(r"^[^/\s]+/[^/\s]+$") @@ -105,10 +104,6 @@ def invalid_openclaw_source(cls, source: str) -> SetupError: def unsupported_openclaw_version(cls, version_text: str) -> SetupError: return cls(f"OpenClaw {version_text or 'version unknown'} is unsupported; upgrade to >= 2026.8.1-beta.2") - @classmethod - def invalid_openclaw_scope(cls) -> SetupError: - return cls("OpenClaw scope must be agent or project") - @classmethod def openclaw_server_url_scheme(cls) -> SetupError: return cls("OpenClaw PowerContext Server URL must use HTTP or HTTPS") @@ -347,7 +342,6 @@ class OpenClawSetupResult: plugin: str plugin_path: str server_url: str - scope_mode: str data_dir: str @@ -522,10 +516,6 @@ def setup_openclaw( str, typer.Option(help="PowerContext Server base URL configured for the plugin."), ] = DEFAULT_OPENCLAW_SERVER_URL, - scope_mode: Annotated[ - str, - typer.Option("--scope-mode", help="Memory scope mode: agent or project."), - ] = DEFAULT_OPENCLAW_SCOPE_MODE, json_output: Annotated[ bool, typer.Option("--json", help="Write the result as JSON."), @@ -540,7 +530,6 @@ def setup_openclaw( source=source, ref=ref, server_url=server_url, - scope_mode=scope_mode, ) except SetupError as error: typer.echo(str(error), err=True) @@ -553,7 +542,6 @@ def setup_openclaw( typer.echo(f"Plugin: {result.plugin}") typer.echo(f"Plugin path: {result.plugin_path}") typer.echo(f"Server: {result.server_url}") - typer.echo(f"Scope: {result.scope_mode}") typer.echo(f"Data directory: {result.data_dir}") typer.echo("Next: start a new OpenClaw session.") @@ -696,10 +684,6 @@ def setup_select( str | None, typer.Option(help="PowerContext Server base URL override for Claude Code and OpenClaw."), ] = None, - scope_mode: Annotated[ - str, - typer.Option("--scope-mode", help="OpenClaw memory scope mode: agent or project."), - ] = DEFAULT_OPENCLAW_SCOPE_MODE, capture_prompts: Annotated[ bool, typer.Option(help="Capture Claude Code user prompts as ordinary Source evidence."), @@ -718,7 +702,6 @@ def setup_select( source=source, ref=ref, server_url=server_url, - scope_mode=scope_mode, capture_prompts=capture_prompts, json_output=json_output, ) diff --git a/src/powercontext/cli/workbuddy.py b/src/powercontext/cli/workbuddy.py index c2d0b296c..d4d0b6187 100644 --- a/src/powercontext/cli/workbuddy.py +++ b/src/powercontext/cli/workbuddy.py @@ -52,15 +52,15 @@ WORKBUDDY_SKILL_NAME = "project-context" WORKBUDDY_SKILL_MANIFEST = ".powercontext.json" WORKBUDDY_PYTHON_PLACEHOLDER = "${POWERCONTEXT_PYTHON}" -WORKBUDDY_PROJECT_SCOPE_PLACEHOLDER = "${POWERCONTEXT_PROJECT_SCOPE_SCRIPT}" +WORKBUDDY_SCOPE_BINDING_PLACEHOLDER = "${POWERCONTEXT_SCOPE_BINDING_SCRIPT}" WORKBUDDY_HOOK_DRIVER = "workbuddy_powercontext_hook.py" -WORKBUDDY_SCOPE_RESOLVER = "powercontext_project_scope.py" +WORKBUDDY_SCOPE_RESOLVER = "powercontext_scope_binding.py" WORKBUDDY_HOOK_MODULES = ( "workbuddy_powercontext_hook.py", "workbuddy_settings.py", "prepared_context.py", ) -WORKBUDDY_SCRIPT_MODULES = ("__init__.py", "project_scope.py") +WORKBUDDY_SCRIPT_MODULES = ("__init__.py", "workspace_scope.py") WORKBUDDY_SERVER_URL_ENV = "POWERCONTEXT_WORKBUDDY_SERVER_URL" WORKBUDDY_AUTHORIZATION_ENV = "POWERCONTEXT_WORKBUDDY_AUTHORIZATION" WORKBUDDY_MCP_URL = f"${{{WORKBUDDY_SERVER_URL_ENV}:-http://127.0.0.1:8000}}/mcp" @@ -220,7 +220,7 @@ def _install_hook_files(plugin_dir: Path, hooks_dir: Path) -> None: source_hooks = plugin_dir / WORKBUDDY_HOOKS_DIRNAME for name in WORKBUDDY_HOOK_MODULES: shutil.copy2(source_hooks / name, hooks_dir / name) - shutil.copy2(plugin_dir / "scripts" / "project_scope.py", hooks_dir / WORKBUDDY_SCOPE_RESOLVER) + shutil.copy2(plugin_dir / "scripts" / "workspace_scope.py", hooks_dir / WORKBUDDY_SCOPE_RESOLVER) except OSError as error: raise SetupError.workbuddy_hooks_write(hooks_dir, error) from error @@ -330,7 +330,7 @@ def _install_workbuddy_skill(plugin_dir: Path, skills_dir: Path, hooks_dir: Path content = skill_markdown.read_text(encoding="utf-8") content = content.replace(WORKBUDDY_PYTHON_PLACEHOLDER, _shell_argument(_python_executable())) content = content.replace( - WORKBUDDY_PROJECT_SCOPE_PLACEHOLDER, + WORKBUDDY_SCOPE_BINDING_PLACEHOLDER, _shell_argument((hooks_dir / WORKBUDDY_SCOPE_RESOLVER).as_posix()), ) skill_markdown.write_text(content, encoding="utf-8") @@ -438,7 +438,7 @@ def _skill_diagnostic(skill_file: Path) -> Diagnostic: content = skill_file.read_text(encoding="utf-8") except OSError: return Diagnostic(status=DiagnosticStatus.FAILED, detail=f"cannot read {skill_file}") - if WORKBUDDY_PROJECT_SCOPE_PLACEHOLDER in content or WORKBUDDY_PYTHON_PLACEHOLDER in content: + if WORKBUDDY_SCOPE_BINDING_PLACEHOLDER in content or WORKBUDDY_PYTHON_PLACEHOLDER in content: return Diagnostic( status=DiagnosticStatus.FAILED, detail="PowerContext WorkBuddy skill still contains an unresolved command placeholder", diff --git a/src/powercontext/client/cli.py b/src/powercontext/client/cli.py index 811f4e25b..5cea0bba8 100644 --- a/src/powercontext/client/cli.py +++ b/src/powercontext/client/cli.py @@ -31,6 +31,7 @@ from powercontext.client.projections import SkillExportTarget, export_skill from powercontext.client.settings import ClientSettings from powercontext.http import ( + AllScopeSelection, ApproveArtifactCandidateRequest, ArtifactCandidate, ArtifactCandidatePage, @@ -38,6 +39,7 @@ CandidateFamily, CandidateStatus, Capabilities, + ExactScopeSelection, ExperienceProposal, ExternalSkillImportMode, ExternalSkillResolution, @@ -61,12 +63,15 @@ ScanExternalSkillsRequest, ScanExternalSkillsResponse, ScopedStats, + ScopeId, + ScopeSelection, SkillArtifact, SkillGenerationOrigin, SkillProposal, SkillValidationItem, SourceReference, StatsPeriod, + SubtreeScopeSelection, ) HELP_OPTION_NAMES = ("-h", "--help") @@ -157,12 +162,23 @@ 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(root=SubtreeScopeSelection(mode="subtree", root_scope_id=root_scope_id)) + elif scope_id: + selection = ScopeSelection( + root=ExactScopeSelection(mode="exact", scope_ids=[ScopeId(value) for value in scope_id]) + ) + else: + selection = ScopeSelection(root=AllScopeSelection(mode="all")) + request = GetStatsRequest(selection=selection, period=period) asyncio.run(_execute(context, lambda client: client.get_stats(request))) @@ -762,7 +778,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.root.mode} ({len(response.scope_ids)} Scopes)") typer.echo(f"As of: {response.as_of.isoformat()}") typer.echo( "Sources: " diff --git a/src/powercontext/client/client.py b/src/powercontext/client/client.py index 2c1618f2f..2413b7dbb 100644 --- a/src/powercontext/client/client.py +++ b/src/powercontext/client/client.py @@ -17,8 +17,10 @@ from __future__ import annotations import asyncio +from collections.abc import Mapping from types import TracebackType from typing import Self, TypeVar +from urllib.parse import quote import httpx from pydantic import TypeAdapter, ValidationError @@ -31,16 +33,17 @@ ApproveArtifactCandidateRequest, ArtifactCandidate, ArtifactCandidatePage, - AttachHandoffReportWorkspaceRequest, + ArtifactPublication, Capabilities, CaptureContentSourceRequest, CaptureContentSourceResponse, + ClearScopeBindingRequest, + ClearScopeBindingResponse, CommitHandoffRequest, CommittedHandoff, ContinueHandoffRequest, - CreateHandoffReportProjectRequest, + CreateScopeRequest, CreateWorkContractRequest, - DetachHandoffReportWorkspaceRequest, ErrorResponse, ExperienceArtifact, ExternalSkillResolution, @@ -52,9 +55,7 @@ GenerateSkillRequest, GetArtifactCandidateRequest, GetExperienceRequest, - GetHandoffReportProjectRequest, GetHandoffReportRequest, - GetHandoffReportWorkspaceRequest, GetMemoryEntryRequest, GetSkillRequest, GetStatsRequest, @@ -62,20 +63,13 @@ HandoffActivation, HandoffCurrentWorkRequest, HandoffDraft, - HandoffReportActivityPage, HandoffReportResponse, - HandoffReportWorkspaceBinding, HandoffResolution, HealthResponse, ImportExternalSkillRequest, - KnownHandoffScopePage, ListArtifactCandidatesRequest, ListExternalSkillsRequest, ListExternalSkillsResponse, - ListHandoffReportActivitiesRequest, - ListHandoffReportKnownScopesRequest, - ListHandoffReportProjectsRequest, - ListHandoffReportWorkstreamsRequest, ListMemoryChangesRequest, ListMemoryChangesResponse, ListMemoryEntriesRequest, @@ -87,89 +81,84 @@ PreparedHandoff, PreparedWorkHandoff, PrepareHandoffRequest, - ProjectDescriptor, - ProjectPage, ProposeExperienceRequest, ProposeSkillRequest, - PurgeHandoffReportActivitiesRequest, - PurgeHandoffReportActivitiesResponse, + PublishArtifactRequest, 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, - PURGE_HANDOFF_REPORT_ACTIVITIES, - RECORD_HANDOFF_REPORT_ACTIVITY, + PUBLISH_ARTIFACT, 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 @@ -246,122 +235,65 @@ async def get_capabilities(self) -> Capabilities: return await self._request(GET_CAPABILITIES) - async def get_stats(self, request: GetStatsRequest) -> ScopedStats: - """Read current inventory and bounded usage for one scope.""" + async def list_scopes(self) -> ScopePage: + """List durable Scope descriptors.""" - return await self._request(GET_STATS, request) + return await self._request(LIST_SCOPES) - async def create_handoff_report_project( - self, - request: CreateHandoffReportProjectRequest, - ) -> ProjectDescriptor: - """Create one explicit Report Project.""" + async def create_scope(self, request: CreateScopeRequest) -> ScopeDescriptor: + """Create one independent Scope boundary.""" - return await self._request(CREATE_HANDOFF_REPORT_PROJECT, request) + return await self._request(CREATE_SCOPE, request) - async def get_handoff_report_project( - self, - request: GetHandoffReportProjectRequest, - ) -> ProjectDescriptor: - """Read one current Report Project descriptor.""" + async def get_scope(self, scope_id: str) -> ScopeDescriptor: + """Read one exact Scope descriptor.""" - return await self._request(GET_HANDOFF_REPORT_PROJECT, request) + return await self._request(GET_SCOPE, path_parameters={"scope_id": scope_id}) - async def update_handoff_report_project( - self, - request: UpdateHandoffReportProjectRequest, - ) -> ProjectDescriptor: - """CAS-update one Report Project descriptor.""" + async def update_scope(self, scope_id: str, request: UpdateScopeRequest) -> ScopeDescriptor: + """Replace mutable Scope metadata and relationships.""" - return await self._request(UPDATE_HANDOFF_REPORT_PROJECT, request) + return await self._request(UPDATE_SCOPE, request, path_parameters={"scope_id": scope_id}) - async def list_handoff_report_projects( - self, - request: ListHandoffReportProjectsRequest, - ) -> ProjectPage: - """List Report Projects with cursor pagination.""" + async def get_default_scope(self) -> ScopeDescriptor: + """Read the host's default Scope target.""" - return await self._request(LIST_HANDOFF_REPORT_PROJECTS, request) + return await self._request(GET_DEFAULT_SCOPE) - async def list_handoff_report_known_scopes( - self, - request: ListHandoffReportKnownScopesRequest, - ) -> KnownHandoffScopePage: - """List scopes that contain a committed Handoff.""" + async def set_default_scope(self, request: SetDefaultScopeRequest) -> ScopeDescriptor: + """Change the host's default Scope target.""" - return await self._request(LIST_HANDOFF_REPORT_KNOWN_SCOPES, request) + return await self._request(SET_DEFAULT_SCOPE, request) - async def register_handoff_report_workstream( - self, - request: RegisterHandoffReportWorkstreamRequest, - ) -> WorkstreamDescriptor: - """Register one existing scope as a Report Workstream.""" + async def resolve_scope_selection(self, request: ResolveScopeSelectionRequest) -> ScopePage: + """Resolve all, exact, or subtree into exact Scope descriptors.""" - return await self._request(REGISTER_HANDOFF_REPORT_WORKSTREAM, request) + return await self._request(RESOLVE_SCOPE_SELECTION, request) - async def list_handoff_report_workstreams( - self, - request: ListHandoffReportWorkstreamsRequest, - ) -> WorkstreamPage: - """List Workstreams belonging to one Report Project.""" + 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_WORKSTREAMS, request) + return await self._request(RESOLVE_SCOPE_BINDING, request) - async def update_handoff_report_workstream( - self, - request: UpdateHandoffReportWorkstreamRequest, - ) -> WorkstreamDescriptor: - """CAS-update one Report Workstream descriptor.""" + async def set_scope_binding(self, request: SetScopeBindingRequest) -> ScopeBinding: + """Bind one external integration identity to a Scope.""" - return await self._request(UPDATE_HANDOFF_REPORT_WORKSTREAM, request) + return await self._request(SET_SCOPE_BINDING, request) - async def record_handoff_report_activity( - self, - request: RecordHandoffReportActivityRequest, - ) -> StoredHandoffReportActivity: - """Record one explicit Report-owned Activity observation.""" + async def clear_scope_binding(self, request: ClearScopeBindingRequest) -> ClearScopeBindingResponse: + """Clear one external integration binding.""" - return await self._request(RECORD_HANDOFF_REPORT_ACTIVITY, request) + return await self._request(CLEAR_SCOPE_BINDING, request) - async def list_handoff_report_activities( - self, - request: ListHandoffReportActivitiesRequest, - ) -> HandoffReportActivityPage: - """List one frozen cursor page of Report-owned Activities.""" - - return await self._request(LIST_HANDOFF_REPORT_ACTIVITIES, request) + async def publish_artifact(self, request: PublishArtifactRequest) -> ArtifactPublication: + """Deliver one exact Artifact revision into another Scope.""" - async def purge_handoff_report_activities( - self, - request: PurgeHandoffReportActivitiesRequest, - ) -> PurgeHandoffReportActivitiesResponse: - """Purge Report-owned Activities before an observation boundary.""" + return await self._request(PUBLISH_ARTIFACT, request) - return await self._request(PURGE_HANDOFF_REPORT_ACTIVITIES, request) - - async def get_handoff_report_workspace( - self, - request: GetHandoffReportWorkspaceRequest, - ) -> HandoffReportWorkspaceBinding: - """Read one confirmed Workspace-to-Project binding.""" - - return await self._request(GET_HANDOFF_REPORT_WORKSPACE, request) - - async def attach_handoff_report_workspace( - self, - request: AttachHandoffReportWorkspaceRequest, - ) -> HandoffReportWorkspaceBinding: - """Attach a Workspace to an exact Report Project using CAS.""" - - return await self._request(ATTACH_HANDOFF_REPORT_WORKSPACE, 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.""" @@ -592,7 +524,10 @@ async def _request( self, operation: Operation[_RequestT, _ResponseT], request: _RequestT | None = None, + *, + path_parameters: Mapping[str, str] | None = None, ) -> _ResponseT: + operation_path = _bind_operation_path(operation, path_parameters) json_payload = None query_parameters = None if request is not None: @@ -615,7 +550,7 @@ async def _request( span.inject(headers) response = await self._http_client.request( operation.method, - f"{self._base_url}{operation.path}", + f"{self._base_url}{operation_path}", json=json_payload, headers=headers, params=query_parameters, @@ -625,7 +560,7 @@ async def _request( raise except httpx.HTTPError as exc: span.finish("failure", error=exc) - raise TransportError(operation.path) from exc + raise TransportError(operation_path) from exc except BaseException as error: span.finish("failure", error=error) raise @@ -649,11 +584,38 @@ async def _request( return TypeAdapter(operation.response_type).validate_json(response.content) except ValidationError as exc: raise InvalidResponseError( - operation.path, + operation_path, request_id=request_id, ) from exc +def _bind_operation_path( + operation: Operation[_RequestT, _ResponseT], + path_parameters: Mapping[str, str] | None, +) -> str: + values = {} if path_parameters is None else dict(path_parameters) + expected = set(operation.path_parameters) + provided = set(values) + if provided != expected: + missing = sorted(expected - provided) + unexpected = sorted(provided - expected) + message = f"{operation.operation_id} path parameters do not match" + if missing: + message += f"; missing: {', '.join(missing)}" + if unexpected: + message += f"; unexpected: {', '.join(unexpected)}" + raise TypeError(message) + + path = operation.path + for name in operation.path_parameters: + value = values[name] + if not isinstance(value, str): + message = f"{operation.operation_id} path parameter {name} must be a string" + raise TypeError(message) + path = path.replace(f"{{{name}}}", quote(value, safe="")) + return path + + def _decode_error(content: bytes) -> ErrorResponse | None: try: return ErrorResponse.model_validate_json(content) diff --git a/src/powercontext/http/__init__.py b/src/powercontext/http/__init__.py index 56c05bf89..0ad898141 100644 --- a/src/powercontext/http/__init__.py +++ b/src/powercontext/http/__init__.py @@ -17,12 +17,16 @@ from powercontext.http._generated.models import ( AcknowledgeHandoffRequest, ActivateHandoffRequest, + AgentKind, + AllScopeSelection, ApproveArtifactCandidateRequest, + ArtifactAddress, ArtifactCandidate, ArtifactCandidatePage, ArtifactInventoryStatistics, + ArtifactPublication, ArtifactReference, - AttachHandoffReportWorkspaceRequest, + AuthorizationNote, CandidateFamily, CandidateFamilyCount, CandidateInventoryStatistics, @@ -31,17 +35,22 @@ CaptureContentSourceRequest, CaptureContentSourceResponse, CaptureStatus, + ClearScopeBindingRequest, + ClearScopeBindingResponse, CommitHandoffRequest, CommittedHandoff, + CompletionCriterion, + ContextReference, ContinueHandoffRequest, - CreateHandoffReportProjectRequest, + CreateScopeRequest, CreateWorkContractRequest, CurrentWorkHandoff, - DetachHandoffReportWorkspaceRequest, EntryChange, EntryChangeOperation, ErrorDetail, ErrorResponse, + ExactScopeSelection, + Exclusion, ExperienceArtifact, ExperienceProposal, ExternalSkillImportMode, @@ -60,13 +69,12 @@ GenerateSkillRequest, GetArtifactCandidateRequest, GetExperienceRequest, - GetHandoffReportProjectRequest, GetHandoffReportRequest, - GetHandoffReportWorkspaceRequest, GetMemoryEntryRequest, GetSkillRequest, GetStatsRequest, HandoffAcknowledgement, + HandoffAcknowledgementSelection, HandoffActivation, HandoffActivationStatus, HandoffArtifactCitation, @@ -81,15 +89,7 @@ HandoffMemoryCitation, HandoffOmission, HandoffReceiptStatus, - HandoffReportActivity, - HandoffReportActivityAgent, - HandoffReportActivityPage, - HandoffReportActivityVcsContext, - HandoffReportExternalReference, - HandoffReportPeriodRequest, - HandoffReportRepositoryRef, HandoffReportResponse, - HandoffReportWorkspaceBinding, HandoffResolution, HandoffResolutionStatus, HandoffSchema, @@ -98,20 +98,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, @@ -127,6 +126,8 @@ ModelUsagePurposeBreakdown, ModelUsageStatistics, ModelUsageValue, + Omission, + OpenQuestion, PrepareContextRequest, PreparedContext, PreparedContextSchema, @@ -135,38 +136,47 @@ PreparedHandoffSchema, PreparedWorkHandoff, PrepareHandoffRequest, - ProjectDescriptor, - ProjectPage, ProposeExperienceRequest, ProposeSkillRequest, - PurgeHandoffReportActivitiesRequest, - PurgeHandoffReportActivitiesResponse, + Provider, + PublishArtifactRequest, ReadinessResponse, ReadinessStatus, RecallTokenDay, RecallTokenStatistics, RecallTokenValue, - RecordHandoffReportActivityRequest, + ReceiverChecks, + ReceiverReadinessCheckStatus, RecordTaskOutcomeRequest, - RegisterHandoffReportWorkstreamRequest, RejectArtifactCandidateRequest, + RemainingWorkItem, RememberMemoryRequest, - ReportActivitySource, - ReportCatalogState, ReportFormat, - ReportLocale, - ReportTimeBasis, ResolvedUsagePeriod, ResolveExternalSkillRequest, + ResolveScopeBindingRequest, + ResolveScopeSelectionRequest, RetireMemoryEntryRequest, ReviseArtifactCandidateRequest, ReviseMemoryEntryRequest, ScanExternalSkillsRequest, ScanExternalSkillsResponse, + Schema, + Schema1, + Schema2, + ScopeBinding, + ScopeBindingKey, + ScopeDescriptor, ScopedStats, + ScopeExternalReference, + ScopeId, + ScopePage, + ScopeSelection, SearchMemoryHit, SearchMemoryRequest, SearchMemoryResponse, + SetDefaultScopeRequest, + SetScopeBindingRequest, SkillArtifact, SkillGenerationOrigin, SkillProposal, @@ -174,34 +184,38 @@ SourceInventoryStatistics, SourceReference, StatsPeriod, - StoredHandoffReportActivity, + SubtreeScopeSelection, TaskCheck, TaskCheckStatus, TaskOutcome, TaskOutcomeStatus, + Timezone, TokenEstimatorProfile, - UpdateHandoffReportProjectRequest, - UpdateHandoffReportWorkstreamRequest, + Trust, + Trust2, + Trust3, + UpdateScopeRequest, UsageStatistics, WorkClaim, WorkClaimBasis, WorkContract, WorkSourceKind, WorkSourceReceipt, - WorkstreamDescriptor, - WorkstreamKind, - WorkstreamPage, ) __all__ = [ "AcknowledgeHandoffRequest", "ActivateHandoffRequest", + "AgentKind", + "AllScopeSelection", "ApproveArtifactCandidateRequest", + "ArtifactAddress", "ArtifactCandidate", "ArtifactCandidatePage", "ArtifactInventoryStatistics", + "ArtifactPublication", "ArtifactReference", - "AttachHandoffReportWorkspaceRequest", + "AuthorizationNote", "CandidateFamily", "CandidateFamilyCount", "CandidateInventoryStatistics", @@ -210,17 +224,22 @@ "CaptureContentSourceRequest", "CaptureContentSourceResponse", "CaptureStatus", + "ClearScopeBindingRequest", + "ClearScopeBindingResponse", "CommitHandoffRequest", "CommittedHandoff", + "CompletionCriterion", + "ContextReference", "ContinueHandoffRequest", - "CreateHandoffReportProjectRequest", + "CreateScopeRequest", "CreateWorkContractRequest", "CurrentWorkHandoff", - "DetachHandoffReportWorkspaceRequest", "EntryChange", "EntryChangeOperation", "ErrorDetail", "ErrorResponse", + "ExactScopeSelection", + "Exclusion", "ExperienceArtifact", "ExperienceProposal", "ExternalSkillImportMode", @@ -239,13 +258,12 @@ "GeneratedCandidateStatus", "GetArtifactCandidateRequest", "GetExperienceRequest", - "GetHandoffReportProjectRequest", "GetHandoffReportRequest", - "GetHandoffReportWorkspaceRequest", "GetMemoryEntryRequest", "GetSkillRequest", "GetStatsRequest", "HandoffAcknowledgement", + "HandoffAcknowledgementSelection", "HandoffActivation", "HandoffActivationStatus", "HandoffArtifactCitation", @@ -260,15 +278,7 @@ "HandoffMemoryCitation", "HandoffOmission", "HandoffReceiptStatus", - "HandoffReportActivity", - "HandoffReportActivityAgent", - "HandoffReportActivityPage", - "HandoffReportActivityVcsContext", - "HandoffReportExternalReference", - "HandoffReportPeriodRequest", - "HandoffReportRepositoryRef", "HandoffReportResponse", - "HandoffReportWorkspaceBinding", "HandoffResolution", "HandoffResolutionStatus", "HandoffSchema", @@ -277,20 +287,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", @@ -306,6 +315,8 @@ "ModelUsagePurposeBreakdown", "ModelUsageStatistics", "ModelUsageValue", + "Omission", + "OpenQuestion", "PrepareContextRequest", "PrepareHandoffRequest", "PreparedContext", @@ -314,38 +325,47 @@ "PreparedHandoff", "PreparedHandoffSchema", "PreparedWorkHandoff", - "ProjectDescriptor", - "ProjectPage", "ProposeExperienceRequest", "ProposeSkillRequest", - "PurgeHandoffReportActivitiesRequest", - "PurgeHandoffReportActivitiesResponse", + "Provider", + "PublishArtifactRequest", "ReadinessResponse", "ReadinessStatus", "RecallTokenDay", "RecallTokenStatistics", "RecallTokenValue", - "RecordHandoffReportActivityRequest", + "ReceiverChecks", + "ReceiverReadinessCheckStatus", "RecordTaskOutcomeRequest", - "RegisterHandoffReportWorkstreamRequest", "RejectArtifactCandidateRequest", + "RemainingWorkItem", "RememberMemoryRequest", - "ReportActivitySource", - "ReportCatalogState", "ReportFormat", - "ReportLocale", - "ReportTimeBasis", "ResolveExternalSkillRequest", + "ResolveScopeBindingRequest", + "ResolveScopeSelectionRequest", "ResolvedUsagePeriod", "RetireMemoryEntryRequest", "ReviseArtifactCandidateRequest", "ReviseMemoryEntryRequest", "ScanExternalSkillsRequest", "ScanExternalSkillsResponse", + "Schema", + "Schema1", + "Schema2", + "ScopeBinding", + "ScopeBindingKey", + "ScopeDescriptor", + "ScopeExternalReference", + "ScopeId", + "ScopePage", + "ScopeSelection", "ScopedStats", "SearchMemoryHit", "SearchMemoryRequest", "SearchMemoryResponse", + "SetDefaultScopeRequest", + "SetScopeBindingRequest", "SkillArtifact", "SkillGenerationOrigin", "SkillProposal", @@ -353,21 +373,21 @@ "SourceInventoryStatistics", "SourceReference", "StatsPeriod", - "StoredHandoffReportActivity", + "SubtreeScopeSelection", "TaskCheck", "TaskCheckStatus", "TaskOutcome", "TaskOutcomeStatus", + "Timezone", "TokenEstimatorProfile", - "UpdateHandoffReportProjectRequest", - "UpdateHandoffReportWorkstreamRequest", + "Trust", + "Trust2", + "Trust3", + "UpdateScopeRequest", "UsageStatistics", "WorkClaim", "WorkClaimBasis", "WorkContract", "WorkSourceKind", "WorkSourceReceipt", - "WorkstreamDescriptor", - "WorkstreamKind", - "WorkstreamPage", ] diff --git a/src/powercontext/http/_generated/models.py b/src/powercontext/http/_generated/models.py index ad2a598c1..443c919c4 100644 --- a/src/powercontext/http/_generated/models.py +++ b/src/powercontext/http/_generated/models.py @@ -30,6 +30,188 @@ 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", + ) + 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 UpdateScopeRequest(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + 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(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + scope_id: Annotated[StrictStr, Field(max_length=256, min_length=1, pattern=".*\\S.*")] + + +class Mode(StrEnum): + ALL = "all" + + +class AllScopeSelection(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + mode: Literal["all"] + + +class Mode1(StrEnum): + EXACT = "exact" + + +class ScopeId(RootModel[StrictStr]): + root: Annotated[StrictStr, Field(max_length=256, min_length=1, pattern=".*\\S.*")] + + +class ExactScopeSelection(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + mode: Literal["exact"] + scope_ids: Annotated[list[ScopeId], Field(min_length=1)] + + +class Mode2(StrEnum): + SUBTREE = "subtree" + + +class SubtreeScopeSelection(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + mode: Literal["subtree"] + root_scope_id: Annotated[StrictStr, Field(max_length=256, min_length=1, pattern=".*\\S.*")] + + +class ScopeSelection(RootModel[AllScopeSelection | ExactScopeSelection | SubtreeScopeSelection]): + root: Annotated[AllScopeSelection | ExactScopeSelection | SubtreeScopeSelection, Field(discriminator="mode")] + + +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", @@ -380,254 +562,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", @@ -988,22 +927,34 @@ class RecallTokenStatistics(BaseModel): daily: Annotated[list[RecallTokenDay], Field(max_length=30)] -class ScopedStats(BaseModel): +class ScopeStats(BaseModel): model_config = ConfigDict( extra="forbid", ) scope_id: StrictStr + inventory: InventoryStatistics + usage: UsageStatistics + recall: RecallTokenStatistics + + +class ScopedStats(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + selection: ScopeSelection + scope_ids: list[StrictStr] as_of: AwareDatetime inventory: InventoryStatistics usage: UsageStatistics recall: RecallTokenStatistics + by_scope: list[ScopeStats] 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 @@ -1153,51 +1104,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): @@ -1211,112 +1124,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", @@ -1652,22 +1459,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 2044f5352..3f7c1b235 100644 --- a/src/powercontext/http/_generated/operations.py +++ b/src/powercontext/http/_generated/operations.py @@ -12,16 +12,17 @@ ApproveArtifactCandidateRequest, ArtifactCandidate, ArtifactCandidatePage, - AttachHandoffReportWorkspaceRequest, + ArtifactPublication, Capabilities, CaptureContentSourceRequest, CaptureContentSourceResponse, + ClearScopeBindingRequest, + ClearScopeBindingResponse, CommitHandoffRequest, CommittedHandoff, ContinueHandoffRequest, - CreateHandoffReportProjectRequest, + CreateScopeRequest, CreateWorkContractRequest, - DetachHandoffReportWorkspaceRequest, ExperienceArtifact, ExternalSkillResolution, FinalizeHandoffRequest, @@ -32,9 +33,7 @@ GenerateSkillRequest, GetArtifactCandidateRequest, GetExperienceRequest, - GetHandoffReportProjectRequest, GetHandoffReportRequest, - GetHandoffReportWorkspaceRequest, GetMemoryEntryRequest, GetSkillRequest, GetStatsRequest, @@ -42,20 +41,13 @@ HandoffActivation, HandoffCurrentWorkRequest, HandoffDraft, - HandoffReportActivityPage, HandoffReportResponse, - HandoffReportWorkspaceBinding, HandoffResolution, HealthResponse, ImportExternalSkillRequest, - KnownHandoffScopePage, ListArtifactCandidatesRequest, ListExternalSkillsRequest, ListExternalSkillsResponse, - ListHandoffReportActivitiesRequest, - ListHandoffReportKnownScopesRequest, - ListHandoffReportProjectsRequest, - ListHandoffReportWorkstreamsRequest, ListMemoryChangesRequest, ListMemoryChangesResponse, ListMemoryEntriesRequest, @@ -67,34 +59,32 @@ PreparedHandoff, PreparedWorkHandoff, PrepareHandoffRequest, - ProjectDescriptor, - ProjectPage, ProposeExperienceRequest, ProposeSkillRequest, - PurgeHandoffReportActivitiesRequest, - PurgeHandoffReportActivitiesResponse, + PublishArtifactRequest, 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, ) OPENAPI_VERSION = "3.0.3" @@ -112,10 +102,12 @@ class Operation(BaseModel, Generic[RequestT, ResponseT]): operation_id: str request_type: type[RequestT] | None request_location: Literal["body", "query"] | None + path_parameters: tuple[str, ...] response_type: type[ResponseT] success_status: int summary: str tags: tuple[str, ...] + scope_mode: Literal["none", "current", "selection"] responses: dict[int | str, dict[str, JsonValue]] @@ -125,10 +117,12 @@ class Operation(BaseModel, Generic[RequestT, ResponseT]): operation_id="get_liveness", request_type=None, request_location=None, + path_parameters=(), response_type=HealthResponse, success_status=200, summary="Get process liveness", tags=("health",), + scope_mode="none", responses={ 200: { "description": "The API process is alive.", @@ -143,10 +137,12 @@ class Operation(BaseModel, Generic[RequestT, ResponseT]): operation_id="get_readiness", request_type=None, request_location=None, + path_parameters=(), response_type=ReadinessResponse, success_status=200, summary="Get deployment readiness", tags=("health",), + scope_mode="none", responses={ 200: { "description": "Required Server bindings are ready; optional capabilities may be degraded.", @@ -165,10 +161,12 @@ class Operation(BaseModel, Generic[RequestT, ResponseT]): operation_id="get_capabilities", request_type=None, request_location=None, + path_parameters=(), response_type=Capabilities, success_status=200, summary="Get runtime capabilities", tags=("capabilities",), + scope_mode="none", responses={ 200: { "description": "Behavior enabled by the assembled runtime.", @@ -178,16 +176,236 @@ 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, + path_parameters=(), + response_type=ScopePage, + success_status=200, + summary="List observable Scopes", + tags=("scopes",), + scope_mode="none", + 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", + path_parameters=(), + response_type=ScopeDescriptor, + success_status=201, + summary="Create an independent Scope boundary", + tags=("scopes",), + scope_mode="none", + 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"}, + }, +) + +PUBLISH_ARTIFACT = Operation[PublishArtifactRequest, ArtifactPublication]( + method="POST", + path="/v1/artifact-publications", + operation_id="publish_artifact", + request_type=PublishArtifactRequest, + request_location="body", + path_parameters=(), + response_type=ArtifactPublication, + success_status=201, + summary="Publish one exact Artifact revision into another Scope", + tags=("scopes",), + scope_mode="none", + 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[None, ScopeDescriptor]( + method="GET", + path="/v1/scopes/{scope_id}", + operation_id="get_scope", + request_type=None, + request_location=None, + path_parameters=("scope_id",), + response_type=ScopeDescriptor, + success_status=200, + summary="Get one Scope descriptor", + tags=("scopes",), + scope_mode="none", + responses={ + 200: {"description": "The exact Scope descriptor."}, + 404: {"$ref": "#/components/responses/NotFound"}, + 401: {"$ref": "#/components/responses/Unauthorized"}, + }, +) + +UPDATE_SCOPE = Operation[UpdateScopeRequest, ScopeDescriptor]( + method="PUT", + path="/v1/scopes/{scope_id}", + operation_id="update_scope", + request_type=UpdateScopeRequest, + request_location="body", + path_parameters=("scope_id",), + response_type=ScopeDescriptor, + success_status=200, + summary="Replace mutable Scope metadata and relationships", + tags=("scopes",), + scope_mode="none", + 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, + path_parameters=(), + response_type=ScopeDescriptor, + success_status=200, + summary="Get the default Scope binding target", + tags=("scopes",), + scope_mode="none", + 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", + path_parameters=(), + response_type=ScopeDescriptor, + success_status=200, + summary="Change the default Scope binding target", + tags=("scopes",), + scope_mode="none", + 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", + path_parameters=(), + response_type=ScopePage, + success_status=200, + summary="Resolve an observation selection to a frozen Scope set", + tags=("scopes",), + scope_mode="none", + 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", + path_parameters=(), + response_type=ScopeDescriptor, + success_status=200, + summary="Resolve an explicit durable or default Scope binding", + tags=("scope-bindings",), + scope_mode="none", + 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", + path_parameters=(), + response_type=ScopeBinding, + success_status=200, + summary="Persist an external identity to Scope binding", + tags=("scope-bindings",), + scope_mode="none", + 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", + path_parameters=(), + response_type=ClearScopeBindingResponse, + success_status=200, + summary="Remove one durable external Scope binding", + tags=("scope-bindings",), + scope_mode="none", + 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", operation_id="capture_content_source", request_type=CaptureContentSourceRequest, request_location="body", + path_parameters=(), response_type=CaptureContentSourceResponse, success_status=202, summary="Capture durable ContentSource evidence", tags=("sources",), + scope_mode="current", responses={ 202: { "description": "The Source is durably stored for later processing.", @@ -207,10 +425,12 @@ class Operation(BaseModel, Generic[RequestT, ResponseT]): operation_id="prepare_context", request_type=PrepareContextRequest, request_location="body", + path_parameters=(), response_type=PreparedContext, success_status=200, summary="Prepare bounded context for an Agent turn", tags=("context",), + scope_mode="current", responses={ 200: { "description": "Final context ready for direct injection, or a normal empty result.", @@ -229,10 +449,12 @@ class Operation(BaseModel, Generic[RequestT, ResponseT]): operation_id="create_work_contract", request_type=CreateWorkContractRequest, request_location="body", + path_parameters=(), response_type=WorkSourceReceipt, success_status=202, summary="Create a grounded Work Contract", tags=("work",), + scope_mode="current", responses={ 202: { "description": "The Work Contract is durably captured as exact Source evidence.", @@ -253,10 +475,12 @@ class Operation(BaseModel, Generic[RequestT, ResponseT]): operation_id="handoff_current_work", request_type=HandoffCurrentWorkRequest, request_location="body", + path_parameters=(), response_type=PreparedWorkHandoff, success_status=200, summary="Hand off current work in one high-level operation", tags=("work",), + scope_mode="current", responses={ 200: { "description": "The captured boundary and Prepared Handoff ready for explicit transfer.", @@ -277,10 +501,12 @@ class Operation(BaseModel, Generic[RequestT, ResponseT]): operation_id="acknowledge_handoff", request_type=AcknowledgeHandoffRequest, request_location="body", + path_parameters=(), response_type=HandoffAcknowledgement, success_status=200, summary="Resolve and acknowledge a Handoff", tags=("work",), + scope_mode="current", responses={ 200: { "description": "The resolved Handoff and durable receiver acknowledgement.", @@ -301,10 +527,12 @@ class Operation(BaseModel, Generic[RequestT, ResponseT]): operation_id="record_task_outcome", request_type=RecordTaskOutcomeRequest, request_location="body", + path_parameters=(), response_type=WorkSourceReceipt, success_status=202, summary="Record a completion-aware Task Outcome", tags=("work",), + scope_mode="current", responses={ 202: { "description": "The Task Outcome is durably captured for Handoff evidence and reviewed " @@ -326,10 +554,12 @@ class Operation(BaseModel, Generic[RequestT, ResponseT]): operation_id="activate_handoff", request_type=ActivateHandoffRequest, request_location="body", + path_parameters=(), response_type=HandoffActivation, success_status=200, summary="Activate Handoff generation at a Source boundary", tags=("handoff",), + scope_mode="current", responses={ 200: { "description": "A generated inspectable Draft, or an ignored boundary that was already consumed.", @@ -349,10 +579,12 @@ class Operation(BaseModel, Generic[RequestT, ResponseT]): operation_id="prepare_handoff", request_type=PrepareHandoffRequest, request_location="body", + path_parameters=(), response_type=HandoffDraft, success_status=200, summary="Generate an inspectable Handoff Draft", tags=("handoff",), + scope_mode="current", responses={ 200: { "description": "An uncommitted Draft generated from the selected exact evidence.", @@ -372,10 +604,12 @@ class Operation(BaseModel, Generic[RequestT, ResponseT]): operation_id="finalize_handoff", request_type=FinalizeHandoffRequest, request_location="body", + path_parameters=(), response_type=PreparedHandoff, success_status=200, summary="Finalize an inspected Handoff Draft", tags=("handoff",), + scope_mode="current", responses={ 200: { "description": "A temporary Handoff ready for direct transfer or explicit commit.", @@ -395,10 +629,12 @@ class Operation(BaseModel, Generic[RequestT, ResponseT]): operation_id="commit_handoff", request_type=CommitHandoffRequest, request_location="body", + path_parameters=(), response_type=CommittedHandoff, success_status=200, summary="Commit an explicit Handoff milestone", tags=("handoff",), + scope_mode="current", responses={ 200: { "description": "The committed immutable Handoff Revision.", @@ -419,10 +655,12 @@ class Operation(BaseModel, Generic[RequestT, ResponseT]): operation_id="continue_handoff", request_type=ContinueHandoffRequest, request_location="body", + path_parameters=(), response_type=HandoffResolution, success_status=200, summary="Resolve a Handoff as untrusted historical input", tags=("handoff",), + scope_mode="current", responses={ 200: { "description": "Resolved content and per-statement evidence availability.", @@ -442,10 +680,12 @@ class Operation(BaseModel, Generic[RequestT, ResponseT]): operation_id="flush_memory", request_type=FlushMemoryRequest, request_location="body", + path_parameters=(), response_type=FlushMemoryResponse, success_status=200, summary="Process the pending Source window into Memory", tags=("memory",), + scope_mode="current", responses={ 200: { "description": "The activation completed or found no pending Sources.", @@ -464,10 +704,12 @@ class Operation(BaseModel, Generic[RequestT, ResponseT]): operation_id="remember_memory", request_type=RememberMemoryRequest, request_location="body", + path_parameters=(), response_type=MemoryMutationResponse, success_status=200, summary="Remember explicit Memory content", tags=("memory",), + scope_mode="current", responses={ 200: { "description": "The explicit Memory mutation completed.", @@ -487,10 +729,12 @@ class Operation(BaseModel, Generic[RequestT, ResponseT]): operation_id="search_memory", request_type=SearchMemoryRequest, request_location="body", + path_parameters=(), response_type=SearchMemoryResponse, success_status=200, summary="Search active Memory entries", tags=("memory",), + scope_mode="current", responses={ 200: { "description": "Matching Memory entries, or an empty result when the scope has no Memory.", @@ -510,10 +754,12 @@ class Operation(BaseModel, Generic[RequestT, ResponseT]): operation_id="list_memory_entries", request_type=ListMemoryEntriesRequest, request_location="body", + path_parameters=(), response_type=ListMemoryEntriesResponse, success_status=200, summary="List Memory entries", tags=("memory",), + scope_mode="current", responses={ 200: { "description": "The selected entries from the current Memory head.", @@ -533,10 +779,12 @@ class Operation(BaseModel, Generic[RequestT, ResponseT]): operation_id="get_memory_entry", request_type=GetMemoryEntryRequest, request_location="body", + path_parameters=(), response_type=MemoryEntry, success_status=200, summary="Get an exact Memory entry version", tags=("memory",), + scope_mode="current", responses={ 200: { "description": "The exact Memory entry version.", @@ -556,10 +804,12 @@ class Operation(BaseModel, Generic[RequestT, ResponseT]): operation_id="revise_memory_entry", request_type=ReviseMemoryEntryRequest, request_location="body", + path_parameters=(), response_type=MemoryMutationResponse, success_status=200, summary="Revise an exact Memory entry", tags=("memory",), + scope_mode="current", responses={ 200: { "description": "The Memory entry revision completed.", @@ -580,10 +830,12 @@ class Operation(BaseModel, Generic[RequestT, ResponseT]): operation_id="retire_memory_entry", request_type=RetireMemoryEntryRequest, request_location="body", + path_parameters=(), response_type=MemoryMutationResponse, success_status=200, summary="Retire an exact Memory entry", tags=("memory",), + scope_mode="current", responses={ 200: { "description": "The Memory entry retirement completed.", @@ -604,10 +856,12 @@ class Operation(BaseModel, Generic[RequestT, ResponseT]): operation_id="list_memory_changes", request_type=ListMemoryChangesRequest, request_location="body", + path_parameters=(), response_type=ListMemoryChangesResponse, success_status=200, summary="List Memory Revision changes", tags=("memory",), + scope_mode="current", responses={ 200: { "description": "Compact changes through the selected Memory Revision.", @@ -627,10 +881,12 @@ class Operation(BaseModel, Generic[RequestT, ResponseT]): operation_id="propose_experience", request_type=ProposeExperienceRequest, request_location="body", + path_parameters=(), response_type=ArtifactCandidate, success_status=201, summary="Propose Experience content", tags=("experience",), + scope_mode="current", responses={ 201: { "description": "The pending Experience Candidate.", @@ -650,10 +906,12 @@ class Operation(BaseModel, Generic[RequestT, ResponseT]): operation_id="generate_experience", request_type=GenerateExperienceRequest, request_location="body", + path_parameters=(), response_type=GeneratedCandidateResponse, success_status=200, summary="Generate an Experience Candidate", tags=("experience",), + scope_mode="current", responses={ 200: { "description": "A pending Candidate or an explicit semantic no-op.", @@ -673,10 +931,12 @@ class Operation(BaseModel, Generic[RequestT, ResponseT]): operation_id="get_experience", request_type=GetExperienceRequest, request_location="body", + path_parameters=(), response_type=ExperienceArtifact, success_status=200, summary="Get an exact Experience Revision", tags=("experience",), + scope_mode="current", responses={ 200: { "description": "The exact approved Experience Revision.", @@ -696,10 +956,12 @@ class Operation(BaseModel, Generic[RequestT, ResponseT]): operation_id="propose_skill", request_type=ProposeSkillRequest, request_location="body", + path_parameters=(), response_type=ArtifactCandidate, success_status=201, summary="Propose managed Skill content", tags=("skill",), + scope_mode="current", responses={ 201: { "description": "The pending managed Skill Candidate.", @@ -719,10 +981,12 @@ class Operation(BaseModel, Generic[RequestT, ResponseT]): operation_id="generate_skill", request_type=GenerateSkillRequest, request_location="body", + path_parameters=(), response_type=GeneratedCandidateResponse, success_status=200, summary="Generate a managed Skill Candidate", tags=("skill",), + scope_mode="current", responses={ 200: { "description": "A pending Candidate or an explicit semantic no-op.", @@ -742,10 +1006,12 @@ class Operation(BaseModel, Generic[RequestT, ResponseT]): operation_id="get_skill", request_type=GetSkillRequest, request_location="body", + path_parameters=(), response_type=SkillArtifact, success_status=200, summary="Get an exact managed Skill Revision", tags=("skill",), + scope_mode="current", responses={ 200: { "description": "The exact approved managed Skill Revision.", @@ -765,10 +1031,12 @@ class Operation(BaseModel, Generic[RequestT, ResponseT]): operation_id="scan_external_skills", request_type=ScanExternalSkillsRequest, request_location="body", + path_parameters=(), response_type=ScanExternalSkillsResponse, success_status=200, summary="Scan configured external Skill roots", tags=("skill",), + scope_mode="current", responses={ 200: { "description": "The rebuildable provider snapshot.", @@ -787,10 +1055,12 @@ class Operation(BaseModel, Generic[RequestT, ResponseT]): operation_id="list_external_skills", request_type=ListExternalSkillsRequest, request_location="body", + path_parameters=(), response_type=ListExternalSkillsResponse, success_status=200, summary="List external Skills visible on this host", tags=("skill",), + scope_mode="current", responses={ 200: { "description": "External Skills resolved against the current Agent, host, scope, and fingerprint.", @@ -809,10 +1079,12 @@ class Operation(BaseModel, Generic[RequestT, ResponseT]): operation_id="resolve_external_skill", request_type=ResolveExternalSkillRequest, request_location="body", + path_parameters=(), response_type=ExternalSkillResolution, success_status=200, summary="Resolve an exact external Skill fingerprint", tags=("skill",), + scope_mode="current", responses={ 200: { "description": "The live exact-resolution result, which may be unavailable.", @@ -832,10 +1104,12 @@ class Operation(BaseModel, Generic[RequestT, ResponseT]): operation_id="import_external_skill", request_type=ImportExternalSkillRequest, request_location="body", + path_parameters=(), response_type=GeneratedCandidateResponse, success_status=200, summary="Import or fork an external Skill into Review", tags=("skill",), + scope_mode="current", responses={ 200: { "description": "A pending managed Skill Candidate or an explicit semantic no-op.", @@ -856,10 +1130,12 @@ class Operation(BaseModel, Generic[RequestT, ResponseT]): operation_id="list_artifact_candidates", request_type=ListArtifactCandidatesRequest, request_location="body", + path_parameters=(), response_type=ArtifactCandidatePage, success_status=200, summary="List Artifact Candidates", tags=("review",), + scope_mode="current", responses={ 200: { "description": "The selected current Candidate heads.", @@ -878,10 +1154,12 @@ class Operation(BaseModel, Generic[RequestT, ResponseT]): operation_id="get_artifact_candidate", request_type=GetArtifactCandidateRequest, request_location="body", + path_parameters=(), response_type=ArtifactCandidate, success_status=200, summary="Get an Artifact Candidate", tags=("review",), + scope_mode="current", responses={ 200: { "description": "The current Candidate head.", @@ -901,10 +1179,12 @@ class Operation(BaseModel, Generic[RequestT, ResponseT]): operation_id="approve_artifact_candidate", request_type=ApproveArtifactCandidateRequest, request_location="body", + path_parameters=(), response_type=ArtifactCandidate, success_status=200, summary="Approve an Artifact Candidate", tags=("review",), + scope_mode="current", responses={ 200: { "description": "The approved Candidate and exact result Artifact.", @@ -925,10 +1205,12 @@ class Operation(BaseModel, Generic[RequestT, ResponseT]): operation_id="reject_artifact_candidate", request_type=RejectArtifactCandidateRequest, request_location="body", + path_parameters=(), response_type=ArtifactCandidate, success_status=200, summary="Reject an Artifact Candidate", tags=("review",), + scope_mode="current", responses={ 200: { "description": "The rejected Candidate.", @@ -949,10 +1231,12 @@ class Operation(BaseModel, Generic[RequestT, ResponseT]): operation_id="revise_artifact_candidate", request_type=ReviseArtifactCandidateRequest, request_location="body", + path_parameters=(), response_type=ArtifactCandidate, success_status=200, summary="Revise an Artifact Candidate", tags=("review",), + scope_mode="current", responses={ 200: { "description": "The next pending Candidate version.", @@ -968,18 +1252,20 @@ 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", + path_parameters=(), response_type=ScopedStats, success_status=200, - summary="Get scoped product statistics", + summary="Aggregate product statistics over a Scope selection", tags=("stats",), + scope_mode="selection", 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": { @@ -995,193 +1281,18 @@ 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", operation_id="get_handoff_report", request_type=GetHandoffReportRequest, request_location="body", + path_parameters=(), response_type=HandoffReportResponse, success_status=200, summary="Generate a Handoff Report", tags=("handoff-reports",), + scope_mode="selection", responses={ 200: { "description": "A canonical JSON report, optionally accompanied by Markdown.", @@ -1213,138 +1324,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 be81ba103..c7137c08d 100644 --- a/src/powercontext/http/_generated/schema.py +++ b/src/powercontext/http/_generated/schema.py @@ -60,6 +60,238 @@ }, } }, + "/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/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/{scope_id}": { + "get": { + "tags": ["scopes"], + "summary": "Get one Scope descriptor", + "operationId": "get_scope", + "parameters": [ + { + "name": "scope_id", + "in": "path", + "required": True, + "schema": {"type": "string", "minLength": 1, "maxLength": 256, "pattern": ".*\\S.*"}, + } + ], + "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"}, + }, + }, + "put": { + "tags": ["scopes"], + "summary": "Replace mutable Scope metadata and relationships", + "operationId": "update_scope", + "parameters": [ + { + "name": "scope_id", + "in": "path", + "required": True, + "schema": {"type": "string", "minLength": 1, "maxLength": 256, "pattern": ".*\\S.*"}, + } + ], + "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": { + "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"], @@ -88,6 +320,7 @@ "503": {"$ref": "#/components/responses/Unavailable"}, "500": {"$ref": "#/components/responses/InternalError"}, }, + "x-powercontext-scope-mode": "current", } }, "/v1/context/prepare": { @@ -113,6 +346,7 @@ "503": {"$ref": "#/components/responses/Unavailable"}, "500": {"$ref": "#/components/responses/InternalError"}, }, + "x-powercontext-scope-mode": "current", } }, "/v1/work/contracts/create": { @@ -140,6 +374,7 @@ "503": {"$ref": "#/components/responses/Unavailable"}, "500": {"$ref": "#/components/responses/InternalError"}, }, + "x-powercontext-scope-mode": "current", } }, "/v1/work/handoffs/prepare-current": { @@ -173,6 +408,7 @@ "503": {"$ref": "#/components/responses/Unavailable"}, "500": {"$ref": "#/components/responses/InternalError"}, }, + "x-powercontext-scope-mode": "current", } }, "/v1/work/handoffs/acknowledge": { @@ -207,6 +443,7 @@ "503": {"$ref": "#/components/responses/Unavailable"}, "500": {"$ref": "#/components/responses/InternalError"}, }, + "x-powercontext-scope-mode": "current", } }, "/v1/work/outcomes/record": { @@ -246,6 +483,7 @@ "503": {"$ref": "#/components/responses/Unavailable"}, "500": {"$ref": "#/components/responses/InternalError"}, }, + "x-powercontext-scope-mode": "current", } }, "/v1/handoff/activate": { @@ -280,6 +518,7 @@ "503": {"$ref": "#/components/responses/Unavailable"}, "500": {"$ref": "#/components/responses/InternalError"}, }, + "x-powercontext-scope-mode": "current", } }, "/v1/handoff/prepare": { @@ -303,6 +542,7 @@ "503": {"$ref": "#/components/responses/Unavailable"}, "500": {"$ref": "#/components/responses/InternalError"}, }, + "x-powercontext-scope-mode": "current", } }, "/v1/handoff/finalize": { @@ -328,6 +568,7 @@ "503": {"$ref": "#/components/responses/Unavailable"}, "500": {"$ref": "#/components/responses/InternalError"}, }, + "x-powercontext-scope-mode": "current", } }, "/v1/handoff/commit": { @@ -352,6 +593,7 @@ "503": {"$ref": "#/components/responses/Unavailable"}, "500": {"$ref": "#/components/responses/InternalError"}, }, + "x-powercontext-scope-mode": "current", } }, "/v1/handoff/continue": { @@ -377,6 +619,7 @@ "503": {"$ref": "#/components/responses/Unavailable"}, "500": {"$ref": "#/components/responses/InternalError"}, }, + "x-powercontext-scope-mode": "current", } }, "/v1/memory/flush": { @@ -402,6 +645,7 @@ "503": {"$ref": "#/components/responses/Unavailable"}, "500": {"$ref": "#/components/responses/InternalError"}, }, + "x-powercontext-scope-mode": "current", } }, "/v1/memory/remember": { @@ -430,6 +674,7 @@ "503": {"$ref": "#/components/responses/Unavailable"}, "500": {"$ref": "#/components/responses/InternalError"}, }, + "x-powercontext-scope-mode": "current", } }, "/v1/memory/search": { @@ -456,6 +701,7 @@ "503": {"$ref": "#/components/responses/Unavailable"}, "500": {"$ref": "#/components/responses/InternalError"}, }, + "x-powercontext-scope-mode": "current", } }, "/v1/memory/entries/list": { @@ -487,6 +733,7 @@ "503": {"$ref": "#/components/responses/Unavailable"}, "500": {"$ref": "#/components/responses/InternalError"}, }, + "x-powercontext-scope-mode": "current", } }, "/v1/memory/entries/get": { @@ -511,6 +758,7 @@ "503": {"$ref": "#/components/responses/Unavailable"}, "500": {"$ref": "#/components/responses/InternalError"}, }, + "x-powercontext-scope-mode": "current", } }, "/v1/memory/entries/revise": { @@ -540,6 +788,7 @@ "503": {"$ref": "#/components/responses/Unavailable"}, "500": {"$ref": "#/components/responses/InternalError"}, }, + "x-powercontext-scope-mode": "current", } }, "/v1/memory/entries/retire": { @@ -571,6 +820,7 @@ "503": {"$ref": "#/components/responses/Unavailable"}, "500": {"$ref": "#/components/responses/InternalError"}, }, + "x-powercontext-scope-mode": "current", } }, "/v1/memory/changes": { @@ -599,6 +849,7 @@ "503": {"$ref": "#/components/responses/Unavailable"}, "500": {"$ref": "#/components/responses/InternalError"}, }, + "x-powercontext-scope-mode": "current", } }, "/v1/experience/propose": { @@ -625,6 +876,7 @@ "503": {"$ref": "#/components/responses/Unavailable"}, "500": {"$ref": "#/components/responses/InternalError"}, }, + "x-powercontext-scope-mode": "current", } }, "/v1/experience/generate": { @@ -656,6 +908,7 @@ "503": {"$ref": "#/components/responses/Unavailable"}, "500": {"$ref": "#/components/responses/InternalError"}, }, + "x-powercontext-scope-mode": "current", } }, "/v1/experience/get": { @@ -682,6 +935,7 @@ "503": {"$ref": "#/components/responses/Unavailable"}, "500": {"$ref": "#/components/responses/InternalError"}, }, + "x-powercontext-scope-mode": "current", } }, "/v1/skill/propose": { @@ -706,6 +960,7 @@ "503": {"$ref": "#/components/responses/Unavailable"}, "500": {"$ref": "#/components/responses/InternalError"}, }, + "x-powercontext-scope-mode": "current", } }, "/v1/skill/generate": { @@ -734,6 +989,7 @@ "503": {"$ref": "#/components/responses/Unavailable"}, "500": {"$ref": "#/components/responses/InternalError"}, }, + "x-powercontext-scope-mode": "current", } }, "/v1/skill/get": { @@ -758,6 +1014,7 @@ "503": {"$ref": "#/components/responses/Unavailable"}, "500": {"$ref": "#/components/responses/InternalError"}, }, + "x-powercontext-scope-mode": "current", } }, "/v1/external-skills/scan": { @@ -788,6 +1045,7 @@ "503": {"$ref": "#/components/responses/Unavailable"}, "500": {"$ref": "#/components/responses/InternalError"}, }, + "x-powercontext-scope-mode": "current", } }, "/v1/external-skills/list": { @@ -826,6 +1084,7 @@ "503": {"$ref": "#/components/responses/Unavailable"}, "500": {"$ref": "#/components/responses/InternalError"}, }, + "x-powercontext-scope-mode": "current", } }, "/v1/external-skills/resolve": { @@ -857,6 +1116,7 @@ "503": {"$ref": "#/components/responses/Unavailable"}, "500": {"$ref": "#/components/responses/InternalError"}, }, + "x-powercontext-scope-mode": "current", } }, "/v1/external-skills/import": { @@ -889,6 +1149,7 @@ "503": {"$ref": "#/components/responses/Unavailable"}, "500": {"$ref": "#/components/responses/InternalError"}, }, + "x-powercontext-scope-mode": "current", } }, "/v1/artifact-candidates/list": { @@ -916,6 +1177,7 @@ "503": {"$ref": "#/components/responses/Unavailable"}, "500": {"$ref": "#/components/responses/InternalError"}, }, + "x-powercontext-scope-mode": "current", } }, "/v1/artifact-candidates/get": { @@ -942,6 +1204,7 @@ "503": {"$ref": "#/components/responses/Unavailable"}, "500": {"$ref": "#/components/responses/InternalError"}, }, + "x-powercontext-scope-mode": "current", } }, "/v1/artifact-candidates/approve": { @@ -969,6 +1232,7 @@ "503": {"$ref": "#/components/responses/Unavailable"}, "500": {"$ref": "#/components/responses/InternalError"}, }, + "x-powercontext-scope-mode": "current", } }, "/v1/artifact-candidates/reject": { @@ -999,6 +1263,7 @@ "503": {"$ref": "#/components/responses/Unavailable"}, "500": {"$ref": "#/components/responses/InternalError"}, }, + "x-powercontext-scope-mode": "current", } }, "/v1/artifact-candidates/revise": { @@ -1026,30 +1291,24 @@ "503": {"$ref": "#/components/responses/Unavailable"}, "500": {"$ref": "#/components/responses/InternalError"}, }, + "x-powercontext-scope-mode": "current", } }, "/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": { @@ -1064,476 +1323,315 @@ "503": {"$ref": "#/components/responses/Unavailable"}, "500": {"$ref": "#/components/responses/InternalError"}, }, + "x-powercontext-scope-mode": "selection", } }, - "/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": { + "/v1/handoff-reports/get": { "post": { "tags": ["handoff-reports"], - "summary": "List scopes that contain a committed Handoff", - "operationId": "list_handoff_report_known_scopes", + "summary": "Generate a Handoff Report", + "operationId": "get_handoff_report", "requestBody": { "content": { - "application/json": { - "schema": {"$ref": "#/components/schemas/ListHandoffReportKnownScopesRequest"} - } + "application/json": {"schema": {"$ref": "#/components/schemas/GetHandoffReportRequest"}} }, "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"}}, + "description": "A canonical JSON report, optionally accompanied by Markdown.", + "headers": { + "X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}, + "Cache-Control": { + "description": "Prevent caches from retaining scoped report data.", + "schema": {"type": "string", "enum": ["no-store"]}, + }, + "X-PowerContext-Selection-Digest": { + "description": "Digest of the exact report selection.", + "schema": {"type": "string", "pattern": "^sha256:[0-9a-f]{64}$"}, + }, + "X-PowerContext-Report-Digest": { + "description": "Digest of the selected output projection.", + "schema": {"type": "string", "pattern": "^sha256:[0-9a-f]{64}$"}, + }, + "Content-Disposition": { + "description": "Safe attachment filename when download is true.", + "schema": {"type": "string"}, + }, + }, "content": { - "application/json": {"schema": {"$ref": "#/components/schemas/KnownHandoffScopePage"}} + "application/json": {"schema": {"$ref": "#/components/schemas/HandoffReportResponse"}}, + "text/markdown": {"schema": {"type": "string"}}, }, }, + "404": {"$ref": "#/components/responses/NotFound"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "422": {"$ref": "#/components/responses/InvalidRequest"}, + "413": {"$ref": "#/components/responses/ReportTooLarge"}, + "503": {"$ref": "#/components/responses/Unavailable"}, "500": {"$ref": "#/components/responses/InternalError"}, }, + "x-powercontext-scope-mode": "selection", } }, - "/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"}} + }, + "components": { + "schemas": { + "ActivateHandoffRequest": { + "properties": { + "scope_id": {"type": "string", "maxLength": 256, "minLength": 1, "pattern": ".*\\S.*"}, + "boundary_source": {"$ref": "#/components/schemas/SourceReference"}, + "objective": {"type": "string", "maxLength": 8192, "minLength": 1, "pattern": ".*\\S.*"}, + "evidence": { + "items": {"$ref": "#/components/schemas/HandoffCitation"}, + "type": "array", + "maxItems": 32, + "default": [], }, - "required": True, + "max_bytes": {"type": "integer", "maximum": 32768.0, "minimum": 512.0, "default": 8000}, }, - "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"}, + "additionalProperties": False, + "type": "object", + "required": ["scope_id", "boundary_source", "objective"], + }, + "ArtifactReference": { + "properties": { + "family": {"type": "string", "maxLength": 128, "minLength": 1, "pattern": "^[\\x21-\\x7E]+$"}, + "artifact_id": {"type": "string", "maxLength": 128, "minLength": 1, "pattern": "^[\\x21-\\x7E]+$"}, + "revision": {"type": "integer", "minimum": 1.0}, }, - } - }, - "/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, + "additionalProperties": False, + "type": "object", + "required": ["family", "artifact_id", "revision"], + }, + "ArtifactAddress": { + "properties": { + "scope_id": {"type": "string", "maxLength": 256, "minLength": 1, "pattern": ".*\\S.*"}, + "artifact": {"$ref": "#/components/schemas/ArtifactReference"}, }, - "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"}, + "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.*"}, }, - } - }, - "/v1/handoff-reports/get": { - "post": { - "tags": ["handoff-reports"], - "summary": "Generate a Handoff Report", - "operationId": "get_handoff_report", - "requestBody": { - "content": { - "application/json": {"schema": {"$ref": "#/components/schemas/GetHandoffReportRequest"}} - }, - "required": True, + "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}$"}, }, - "responses": { - "200": { - "description": "A canonical JSON report, optionally accompanied by Markdown.", - "headers": { - "X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}, - "Cache-Control": { - "description": "Prevent caches from retaining scoped report data.", - "schema": {"type": "string", "enum": ["no-store"]}, - }, - "X-PowerContext-Selection-Digest": { - "description": "Digest of the exact report selection.", - "schema": {"type": "string", "pattern": "^sha256:[0-9a-f]{64}$"}, - }, - "X-PowerContext-Report-Digest": { - "description": "Digest of the selected output projection.", - "schema": {"type": "string", "pattern": "^sha256:[0-9a-f]{64}$"}, - }, - "Content-Disposition": { - "description": "Safe attachment filename when download is true.", - "schema": {"type": "string"}, - }, - }, - "content": { - "application/json": {"schema": {"$ref": "#/components/schemas/HandoffReportResponse"}}, - "text/markdown": {"schema": {"type": "string"}}, - }, - }, - "404": {"$ref": "#/components/responses/NotFound"}, - "401": {"$ref": "#/components/responses/Unauthorized"}, - "422": {"$ref": "#/components/responses/InvalidRequest"}, - "413": {"$ref": "#/components/responses/ReportTooLarge"}, - "503": {"$ref": "#/components/responses/Unavailable"}, - "500": {"$ref": "#/components/responses/InternalError"}, + "additionalProperties": False, + "type": "object", + "required": ["source", "target", "content_digest"], + }, + "ScopeExternalReference": { + "properties": { + "kind": {"type": "string", "maxLength": 128, "minLength": 1, "pattern": ".*\\S.*"}, + "value": {"type": "string", "maxLength": 2000, "minLength": 1, "pattern": ".*\\S.*"}, }, - } - }, - "/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"} - } + "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, }, - "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"}} - }, + "context_references": { + "items": {"type": "string", "maxLength": 256, "minLength": 1, "pattern": ".*\\S.*"}, + "type": "array", + "uniqueItems": True, }, - "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"} - } + "external_references": { + "items": {"$ref": "#/components/schemas/ScopeExternalReference"}, + "type": "array", + "uniqueItems": True, }, - "required": True, + "version": {"type": "integer", "minimum": 1.0}, }, - "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"}} - }, + "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, }, - "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"} - } + "context_references": { + "items": {"type": "string", "maxLength": 256, "minLength": 1, "pattern": ".*\\S.*"}, + "type": "array", + "uniqueItems": True, + "default": [], }, - "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"} - } - }, + "external_references": { + "items": {"$ref": "#/components/schemas/ScopeExternalReference"}, + "type": "array", + "uniqueItems": True, + "default": [], }, - "404": {"$ref": "#/components/responses/NotFound"}, - "401": {"$ref": "#/components/responses/Unauthorized"}, - "422": {"$ref": "#/components/responses/InvalidRequest"}, - "500": {"$ref": "#/components/responses/InternalError"}, + "idempotency_key": {"type": "string", "maxLength": 256, "minLength": 1, "pattern": ".*\\S.*"}, }, - } - }, - "/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"} - } + "additionalProperties": False, + "type": "object", + "required": ["title", "summary", "idempotency_key"], + }, + "UpdateScopeRequest": { + "properties": { + "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, }, - "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"} - } - }, + "context_references": { + "items": {"type": "string", "maxLength": 256, "minLength": 1, "pattern": ".*\\S.*"}, + "type": "array", + "uniqueItems": True, + "default": [], }, - "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"} - } + "external_references": { + "items": {"$ref": "#/components/schemas/ScopeExternalReference"}, + "type": "array", + "uniqueItems": True, + "default": [], }, - "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"} - } - }, + "additionalProperties": False, + "type": "object", + "required": ["expected_version", "title", "summary"], + }, + "SetDefaultScopeRequest": { + "properties": {"scope_id": {"type": "string", "maxLength": 256, "minLength": 1, "pattern": ".*\\S.*"}}, + "additionalProperties": False, + "type": "object", + "required": ["scope_id"], + }, + "AllScopeSelection": { + "properties": {"mode": {"type": "string", "enum": ["all"]}}, + "additionalProperties": False, + "type": "object", + "required": ["mode"], + }, + "ExactScopeSelection": { + "properties": { + "mode": {"type": "string", "enum": ["exact"]}, + "scope_ids": { + "items": {"type": "string", "maxLength": 256, "minLength": 1, "pattern": ".*\\S.*"}, + "type": "array", + "minItems": 1, + "uniqueItems": True, }, - "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, + "additionalProperties": False, + "type": "object", + "required": ["mode", "scope_ids"], + }, + "SubtreeScopeSelection": { + "properties": { + "mode": {"type": "string", "enum": ["subtree"]}, + "root_scope_id": {"type": "string", "maxLength": 256, "minLength": 1, "pattern": ".*\\S.*"}, }, - "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"}, + "additionalProperties": False, + "type": "object", + "required": ["mode", "root_scope_id"], + }, + "ScopeSelection": { + "oneOf": [ + {"$ref": "#/components/schemas/AllScopeSelection"}, + {"$ref": "#/components/schemas/ExactScopeSelection"}, + {"$ref": "#/components/schemas/SubtreeScopeSelection"}, + ], + "discriminator": {"propertyName": "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.*"}, }, - } - }, - }, - "components": { - "schemas": { - "ActivateHandoffRequest": { + "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.*"}, - "boundary_source": {"$ref": "#/components/schemas/SourceReference"}, - "objective": {"type": "string", "maxLength": 8192, "minLength": 1, "pattern": ".*\\S.*"}, - "evidence": { - "items": {"$ref": "#/components/schemas/HandoffCitation"}, - "type": "array", - "maxItems": 32, - "default": [], - }, - "max_bytes": {"type": "integer", "maximum": 32768.0, "minimum": 512.0, "default": 8000}, }, "additionalProperties": False, "type": "object", - "required": ["scope_id", "boundary_source", "objective"], + "required": ["key", "scope_id"], }, - "ArtifactReference": { + "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": { - "family": {"type": "string", "maxLength": 128, "minLength": 1, "pattern": "^[\\x21-\\x7E]+$"}, - "artifact_id": {"type": "string", "maxLength": 128, "minLength": 1, "pattern": "^[\\x21-\\x7E]+$"}, - "revision": {"type": "integer", "minimum": 1.0}, + "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", - "required": ["family", "artifact_id", "revision"], }, "ArtifactCandidate": { "properties": { @@ -1903,26 +2001,39 @@ "type": "object", "required": ["period", "estimator", "totals", "daily"], }, - "ScopedStats": { + "ScopeStats": { "properties": { "scope_id": {"type": "string"}, + "inventory": {"$ref": "#/components/schemas/InventoryStatistics"}, + "usage": {"$ref": "#/components/schemas/UsageStatistics"}, + "recall": {"$ref": "#/components/schemas/RecallTokenStatistics"}, + }, + "additionalProperties": False, + "type": "object", + "required": ["scope_id", "inventory", "usage", "recall"], + }, + "ScopedStats": { + "properties": { + "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"}, "recall": {"$ref": "#/components/schemas/RecallTokenStatistics"}, + "by_scope": {"items": {"$ref": "#/components/schemas/ScopeStats"}, "type": "array"}, }, "additionalProperties": False, "type": "object", - "required": ["scope_id", "as_of", "inventory", "usage", "recall"], + "required": ["selection", "scope_ids", "as_of", "inventory", "usage", "recall", "by_scope"], }, "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": { @@ -2677,142 +2788,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": { @@ -2826,326 +2810,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 ebe042b4f..49aac5962 100644 --- a/src/powercontext/server/app.py +++ b/src/powercontext/server/app.py @@ -22,23 +22,23 @@ 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, Query, Request, Response, status +from fastapi import Depends, FastAPI, Request, Response, status +from fastapi import Path as PathParameter from fastapi.exceptions import RequestValidationError from fastapi.responses import JSONResponse from opentelemetry.trace import SpanKind +from pydantic import ValidationError as PydanticValidationError from scalar_fastapi import AgentScalarConfig, get_scalar_api_reference from starlette.middleware import Middleware from starlette.middleware.base import RequestResponseEndpoint 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, @@ -66,37 +66,18 @@ ) 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.inference.errors import InferenceTimeoutError, InferenceUnavailableError +from powercontext.builtin.publication import ( + ArtifactPublicationApplication, + ArtifactPublicationConflictError, ) -from powercontext.builtin.handoff_report.repository import ( - ActivityEventConflictError, - InvalidActivityEventError, - InvalidActivityRepositoryArgumentError, +from powercontext.builtin.publication import ( + ArtifactPublicationRequest as DomainArtifactPublicationRequest, ) -from powercontext.builtin.inference.errors import InferenceTimeoutError, InferenceUnavailableError from powercontext.builtin.review import ( ArtifactTargetConflictError, CandidateConflictError, @@ -193,6 +174,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, ) @@ -220,16 +223,16 @@ ApproveArtifactCandidateRequest, ArtifactCandidate, ArtifactCandidatePage, - AttachHandoffReportWorkspaceRequest, Capabilities, CaptureContentSourceRequest, CaptureContentSourceResponse, + ClearScopeBindingRequest, + ClearScopeBindingResponse, CommitHandoffRequest, CommittedHandoff, ContinueHandoffRequest, - CreateHandoffReportProjectRequest, + CreateScopeRequest, CreateWorkContractRequest, - DetachHandoffReportWorkspaceRequest, ErrorDetail, ErrorResponse, ExperienceArtifact, @@ -242,30 +245,19 @@ GenerateSkillRequest, GetArtifactCandidateRequest, GetExperienceRequest, - GetHandoffReportProjectRequest, GetHandoffReportRequest, - GetHandoffReportWorkspaceRequest, GetMemoryEntryRequest, GetSkillRequest, GetStatsRequest, HandoffAcknowledgement, HandoffCurrentWorkRequest, - HandoffReportActivity, - HandoffReportActivityPage, HandoffReportResponse, - HandoffReportWorkspaceBinding, HandoffSelection, HealthResponse, ImportExternalSkillRequest, - KnownHandoffScope, - KnownHandoffScopePage, ListArtifactCandidatesRequest, ListExternalSkillsRequest, ListExternalSkillsResponse, - ListHandoffReportActivitiesRequest, - ListHandoffReportKnownScopesRequest, - ListHandoffReportProjectsRequest, - ListHandoffReportWorkstreamsRequest, ListMemoryChangesRequest, ListMemoryChangesResponse, ListMemoryEntriesRequest, @@ -276,35 +268,38 @@ PreparedContext, PreparedWorkHandoff, PrepareHandoffRequest, - ProjectDescriptor, - ProjectPage, ProposeExperienceRequest, ProposeSkillRequest, - PurgeHandoffReportActivitiesRequest, - PurgeHandoffReportActivitiesResponse, + PublishArtifactRequest, ReadinessResponse, ReadinessStatus, - RecordHandoffReportActivityRequest, RecordTaskOutcomeRequest, - RegisterHandoffReportWorkstreamRequest, RejectArtifactCandidateRequest, RememberMemoryRequest, ResolveExternalSkillRequest, + ResolveScopeBindingRequest, + ResolveScopeSelectionRequest, RetireMemoryEntryRequest, ReviseArtifactCandidateRequest, ReviseMemoryEntryRequest, ScanExternalSkillsRequest, ScanExternalSkillsResponse, + ScopeBinding, + ScopeBindingKey, + ScopeDescriptor, ScopedStats, + ScopePage, + ScopeSelection, SearchMemoryRequest, SearchMemoryResponse, + SetDefaultScopeRequest, + SetScopeBindingRequest, SkillArtifact, - StoredHandoffReportActivity, - UpdateHandoffReportProjectRequest, - UpdateHandoffReportWorkstreamRequest, + UpdateScopeRequest, WorkSourceReceipt, - WorkstreamDescriptor, - WorkstreamPage, +) +from powercontext.http import ( + ArtifactPublication as TransportArtifactPublication, ) from powercontext.http import ( HandoffActivation as TransportHandoffActivation, @@ -325,57 +320,54 @@ 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, 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, OPENAPI_VERSION, PREPARE_CONTEXT, PREPARE_HANDOFF, PROPOSE_EXPERIENCE, PROPOSE_SKILL, - PURGE_HANDOFF_REPORT_ACTIVITIES, - RECORD_HANDOFF_REPORT_ACTIVITY, + PUBLISH_ARTIFACT, 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.http._generated.schema import OPENAPI_SCHEMA @@ -402,6 +394,7 @@ ReadinessProbe = Callable[[], Awaitable[ReadinessResponse]] _RequestT = TypeVar("_RequestT") _ResponseT = TypeVar("_ResponseT") +_ScopePathId = Annotated[str, PathParameter(min_length=1, max_length=256, pattern=r".*\S.*")] class _ScopedSourceApplication(Protocol): @@ -537,8 +530,17 @@ 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 + publications: ArtifactPublicationApplication | None sources: _SourceApplication context: _ContextApplication experience: _ExperienceApplication @@ -610,7 +612,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", @@ -639,22 +645,19 @@ 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_DEFAULT_SCOPE, get_default_scope) + _add_route(app, SET_DEFAULT_SCOPE, set_default_scope) + _add_route(app, GET_SCOPE, get_scope) + _add_route(app, UPDATE_SCOPE, update_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, 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) @@ -748,192 +751,141 @@ async def get_capabilities(request: Request) -> Capabilities: return request.app.state.capabilities -async def get_stats( - request: Annotated[GetStatsRequest, Query()], - 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) +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 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 _scope_descriptor_response(created) + + +async def get_scope( + scope_id: _ScopePathId, + scopes: Annotated[ScopeApplication, Depends(_require_scope_application)], +) -> ScopeDescriptor: + return _scope_descriptor_response(await scopes.get(scope_id)) + + +async def update_scope( + scope_id: _ScopePathId, + request: UpdateScopeRequest, + scopes: Annotated[ScopeApplication, Depends(_require_scope_application)], +) -> ScopeDescriptor: + updated = await scopes.update( + 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 _project_descriptor_response(result) + return _scope_descriptor_response(updated) -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 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 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 set_default_scope( + request: SetDefaultScopeRequest, + scopes: Annotated[ScopeApplication, Depends(_require_scope_application)], +) -> ScopeDescriptor: + return _scope_descriptor_response(await scopes.set_default(request.scope_id)) -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 resolve_scope_selection( + request: ResolveScopeSelectionRequest, + scopes: Annotated[ScopeApplication, Depends(_require_scope_application)], +) -> ScopePage: + selection = _domain_scope_selection(request.selection) + return ScopePage(items=[_scope_descriptor_response(scope) for scope in await scopes.resolve_selection(selection)]) -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 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 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"]), +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, ) - 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 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 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 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, +async def get_stats( + request: GetStatsRequest, + response: Response, + application: Annotated[ServerApplication, Depends(_require_application)], +) -> ScopedStats: + response.headers["Cache-Control"] = "no-store" + result = await application.statistics.overview( + _domain_scope_selection(request.selection), period=RuntimeStatisticsPeriod(request.period.value) ) - 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)) + return mapping.statistics_response(result) async def get_handoff_report( @@ -941,23 +893,7 @@ async def get_handoff_report( 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" @@ -1007,8 +943,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), ) @@ -1352,6 +1287,20 @@ 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_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: @@ -1359,12 +1308,28 @@ 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 _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 _domain_scope_selection(value: ScopeSelection) -> DomainScopeSelection: + return DomainScopeSelection.model_validate(value.root.model_dump(mode="json")) -def _workstream_descriptor_response(value: DomainWorkstreamDescriptor) -> WorkstreamDescriptor: - return WorkstreamDescriptor.model_validate(value.model_dump(mode="json", by_alias=True)) +def _transport_binding_key(value: DomainScopeBindingKey) -> ScopeBindingKey: + return ScopeBindingKey( + integration=value.integration, + kind=value.kind, + external_id=value.external_id, + ) def _add_route( @@ -1395,6 +1360,7 @@ async def observed_endpoint(*args: Any, **kwargs: Any) -> _ResponseT | Response: started_at = perf_counter() span = _start_application_span(app, operation) try: + await _validate_current_scope(app, operation, args, kwargs) result = await endpoint(*args, **kwargs) except asyncio.CancelledError: _observe_application(app, operation, "cancelled", started_at) @@ -1429,6 +1395,26 @@ async def observed_endpoint(*args: Any, **kwargs: Any) -> _ResponseT | Response: return observed_endpoint +async def _validate_current_scope( + app: FastAPI, + operation: Operation[Any, Any], + args: tuple[Any, ...], + kwargs: dict[str, Any], +) -> None: + if operation.scope_mode != "current" or operation.request_type is None: + return + scopes = getattr(app.state.application, "scopes", None) + if scopes is None: + return + request = next( + (value for value in (*args, *kwargs.values()) if isinstance(value, operation.request_type)), + None, + ) + scope_id = getattr(request, "scope_id", None) + if isinstance(scope_id, str): + await scopes.get(scope_id) + + def _start_application_span(app: FastAPI, operation: Operation[Any, Any]) -> Any | None: if "health" in operation.tags: return None @@ -1506,11 +1492,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 @@ -1542,6 +1528,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 @@ -1554,6 +1543,40 @@ 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, 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): + 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 @@ -1584,46 +1607,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): @@ -1633,11 +1624,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/factory.py b/src/powercontext/server/factory.py index 563b0ab4c..cec366f2d 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/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 9b053db83..f8cce4253 100644 --- a/src/powercontext/server/mcp.py +++ b/src/powercontext/server/mcp.py @@ -36,27 +36,30 @@ 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, + PUBLISH_ARTIFACT, RECORD_TASK_OUTCOME, REJECT_ARTIFACT_CANDIDATE, REMEMBER_MEMORY, + RESOLVE_SCOPE_BINDING, RETIRE_MEMORY_ENTRY, 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 @@ -65,7 +68,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 @@ -87,14 +89,19 @@ 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, 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, + RESOLVE_SCOPE_BINDING.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, @@ -102,10 +109,11 @@ 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, + GET_SCOPE.operation_id, + RESOLVE_SCOPE_BINDING.operation_id, }) _MCP_REVIEW_WRITE_OPERATION_IDS = frozenset({ APPROVE_ARTIFACT_CANDIDATE.operation_id, @@ -132,6 +140,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: @@ -186,11 +195,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/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 d8a588bb4..c7a087dbd 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: { @@ -40,6 +41,10 @@ const translations = { tokenLabel: "Server token", continue: "Continue", selectScope: "View", + allScopes: "All work", + subtreeView: "{title} and related work", + exactFocus: "Focus: {title}", + period30: "Last 30 days", estimatedReduction: "Compared with using the original materials directly", sources: "Work materials", memoryEntries: "Memory", @@ -49,6 +54,7 @@ const translations = { family: "Type", currentArtifacts: "Saved", pendingCandidates: "Awaiting review", + artifactSubtitle: "Current Artifacts and pending Candidates", experience: "Experience", handoff: "Handoff", memory: "Memory", @@ -105,6 +111,10 @@ const translations = { tokenLabel: "服务器访问令牌", continue: "继续", selectScope: "查看", + allScopes: "全部工作", + subtreeView: "{title}及相关工作", + exactFocus: "聚焦:{title}", + period30: "过去 30 天", estimatedReduction: "相比直接使用原始材料", sources: "工作材料", memoryEntries: "记忆", @@ -239,11 +249,10 @@ async function authenticate(token, scopeId = "") { showPageStatus("noScopes"); return; } - const selectedScopeId = currentScopes.some((scope) => scope.scope_id === scopeId) - ? scopeId - : currentScopes[0].scope_id; - currentScopeId = selectedScopeId; - await loadStatistics(token, selectedScopeId, request); + const choices = buildScopeSelectionChoices(currentScopes, translate); + 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); @@ -265,10 +274,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 = buildScopeSelectionChoices(currentScopes, translate).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; } @@ -285,12 +300,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); @@ -359,8 +369,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.active)); @@ -376,13 +386,13 @@ function renderDashboard(view) { renderTrend(recall.daily, comparisonAvailable); } -function renderScopes(scopes, selectedScopeId) { +function renderScopes(scopes, selectedKey) { scopeSelect.replaceChildren(); - for (const scope of scopes) { + for (const choice of buildScopeSelectionChoices(scopes, translate)) { const option = document.createElement("option"); - option.value = scope.scope_id; - option.textContent = scope.display_name; - option.selected = scope.scope_id === selectedScopeId; + option.value = choice.key; + option.textContent = choice.label; + option.selected = choice.key === selectedKey; scopeSelect.appendChild(option); } } diff --git a/src/powercontext/server/static/handoff-report.js b/src/powercontext/server/static/handoff-report.js index 58d7380ea..75d0286df 100644 --- a/src/powercontext/server/static/handoff-report.js +++ b/src/powercontext/server/static/handoff-report.js @@ -22,16 +22,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 +34,6 @@ const translations = { handoffReportTitle: "Handoff Report", brandHomeLabel: "PowerContext Overview", primaryNavigation: "Primary navigation", - handoffSummary: "Handoff summary", maintainedBy: "Maintained by OceanBase.", signOut: "Sign out", authTitle: "Connect to PowerContext", @@ -50,200 +42,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 +96,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 +144,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 +189,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); + if (request.isCurrent()) setBusy(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; - } - 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 startAutoRefresh() { - if (autoRefreshTimer === null) { - autoRefreshTimer = window.setInterval(() => { - void autoRefreshReport(); - }, autoRefreshIntervalMilliseconds); - } - 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 selectedChoice() { + return buildScopeSelectionChoices(scopes, translate).find((choice) => choice.key === selectedKey) || null; } -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; +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); } - 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..2f60c0471 --- /dev/null +++ b/src/powercontext/server/static/scope-selection.js @@ -0,0 +1,36 @@ +/* + * 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. + */ + +"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/static/site.css b/src/powercontext/server/static/site.css index 9a4507c4e..0df7182ee 100644 --- a/src/powercontext/server/static/site.css +++ b/src/powercontext/server/static/site.css @@ -470,12 +470,10 @@ button:disabled { border-color: var(--pc-accent); } -.project-combobox, .scope-combobox { position: relative; } -.project-combobox > input, .scope-combobox > input { width: 100%; min-height: 40px; @@ -486,14 +484,11 @@ button:disabled { padding: 8px 10px; } -.project-combobox > input:hover, -.project-combobox > input:focus, .scope-combobox > input:hover, .scope-combobox > input:focus { border-color: var(--pc-accent); } -.project-options, .scope-options { position: absolute; z-index: 10; @@ -508,7 +503,6 @@ button:disabled { overflow-y: auto; } -.project-option, .scope-option { display: grid; width: 100%; @@ -522,37 +516,30 @@ button:disabled { text-align: left; } -.project-option:last-child, .scope-option:last-child { border-bottom: 0; } -.project-option:hover, -.project-option[data-active="true"], .scope-option:hover, .scope-option[data-active="true"] { background: var(--pc-accent-soft); } -.project-option[aria-selected="true"], .scope-option[aria-selected="true"] { box-shadow: inset 3px 0 0 var(--pc-accent); } -.project-option strong, .scope-option strong { font-size: 13px; overflow-wrap: anywhere; } -.project-option code, .scope-option code { color: var(--pc-muted); font: 10px/1.4 var(--pc-font-code); overflow-wrap: anywhere; } -.project-options-empty, .scope-options-empty { margin: 0; color: var(--pc-muted); @@ -560,7 +547,6 @@ button:disabled { text-align: center; } -.project-search-status, .scope-search-status { min-height: 17px; color: var(--pc-muted); @@ -950,10 +936,6 @@ button:disabled { background: var(--pc-surface-secondary); } -.report-preview-workstream-item { - cursor: default; -} - .report-hero { align-items: flex-start; margin-bottom: 24px; @@ -965,10 +947,6 @@ button:disabled { gap: 10px; } -.report-project-picker { - min-width: min(360px, 70vw); -} - .report-actions { display: flex; flex-wrap: wrap; @@ -1229,78 +1207,6 @@ button:disabled { font-weight: 650; } -.workstream-browser-layout { - display: grid; - gap: 12px; -} - -.workstream-switcher-toolbar { - display: flex; - align-items: end; - justify-content: space-between; - gap: 12px; -} - -.workstream-search { - display: grid; - width: min(360px, 100%); -} - -.workstream-search input { - width: 100%; - min-height: 38px; - border: 1px solid var(--pc-rule-strong); - border-radius: var(--pc-radius-control); - background: var(--pc-surface); - color: var(--pc-ink); - padding: 8px 10px; -} - -.workstream-search input:hover, -.workstream-search input:focus { - border-color: var(--pc-accent); -} - -.workstream-switcher-navigation { - display: flex; - align-items: center; - gap: 8px; - margin-left: auto; -} - -.workstream-navigation-button { - min-height: 38px; - border: 1px solid var(--pc-rule-strong); - border-radius: var(--pc-radius-control); - background: var(--pc-surface); - color: var(--pc-ink); - padding: 0 12px; - white-space: nowrap; -} - -.workstream-navigation-button:hover:not(:disabled) { - border-color: var(--pc-accent); - color: var(--pc-accent); -} - -.workstream-navigation-button:active:not(:disabled) { - transform: translateY(1px); -} - -.workstream-navigation-button:disabled { - color: var(--pc-muted); - cursor: not-allowed; - opacity: 0.55; -} - -.workstream-position { - min-width: 72px; - color: var(--pc-muted); - font: 11px/1.4 var(--pc-font-code); - text-align: center; -} - -.workstream-list-panel, .handoff-snapshot { min-width: 0; border: 1px solid var(--pc-rule-strong); @@ -1308,70 +1214,6 @@ button:disabled { background: var(--pc-surface); } -.workstream-list-panel { - overflow-x: auto; - overflow-y: hidden; - scroll-snap-type: x proximity; - scrollbar-gutter: stable; -} - -.workstream-list { - display: flex; - width: max-content; - min-width: 100%; -} - -.workstream-list-item { - display: grid; - flex: 1 0 260px; - gap: 5px; - border: 0; - border-right: 1px solid var(--pc-rule); - border-radius: 0; - background: transparent; - color: var(--pc-ink); - padding: 12px 15px 14px; - scroll-snap-align: start; - text-align: left; - transition: background-color 160ms ease, box-shadow 160ms ease, transform 160ms ease; -} - -.workstream-list-item:last-child { - border-right: 0; -} - -.workstream-list-item:hover { - background: var(--pc-surface-secondary); -} - -.workstream-list-item:active { - transform: translateY(1px); -} - -.workstream-list-item[aria-current="true"] { - background: var(--pc-accent-soft); - box-shadow: inset 0 -3px 0 var(--pc-accent); -} - -.workstream-list-item-header { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 12px; -} - -.workstream-list-item-header strong { - font-size: 13px; - line-height: 1.45; - overflow-wrap: anywhere; -} - -.workstream-list-item code { - color: var(--pc-muted); - font: 10px/1.45 var(--pc-font-code); - overflow-wrap: anywhere; -} - .handoff-snapshot { padding: 20px 22px 22px; } @@ -4129,12 +3971,6 @@ button:disabled { justify-items: start; } - .report-project-picker { - width: 100%; - min-width: 0; - } - - .project-options, .scope-options { max-height: min(320px, 52vh); } @@ -4255,20 +4091,6 @@ button:disabled { flex-direction: column; } - .workstream-switcher-toolbar { - align-items: stretch; - flex-direction: column; - } - - .workstream-search { - width: 100%; - } - - .workstream-switcher-navigation { - justify-content: space-between; - margin-left: 0; - } - .workbench-selection { width: 100%; align-items: flex-start; diff --git a/src/powercontext/server/templates/pages/handoff_report.html b/src/powercontext/server/templates/pages/handoff_report.html index 3a9cc941a..505f6f28d 100644 --- a/src/powercontext/server/templates/pages/handoff_report.html +++ b/src/powercontext/server/templates/pages/handoff_report.html @@ -25,485 +25,65 @@ {% set status_title = "Handoff Report" %} {% include "components/status.html" %} - -