From dd80249b58ecb388a5681c12188011cb624e4464 Mon Sep 17 00:00:00 2001 From: JasonDeng <15301580353@163.com> Date: Thu, 29 Oct 2026 19:18:08 +0800 Subject: [PATCH 01/45] feat(runtime): durable Work Ledger rebased onto current master Re-apply the Work Ledger onto master's migration framework, which replaced Alembic revision discovery with frozen bundles (detached DDL, fingerprints and a single linear `down_revision` chain). The ledger keeps its own tables and semantics; only the registration changes. Registration * The revision is registered as `v1_4_1_005` and follows `v1_4_1_004` instead of `s1402`. Master's owner-recovery revision already occupies the slot after `s1402`, and the bundle rejects a second head, so the ledger appends to the single chain. The release-based name follows the naming rule that revision introduced, which keeps `server db-migrate status` able to describe the target. * Both manifests (`server_resources`, `server_sources/1.4.0`) register the revision resources, its fingerprint, its readiness entry and the two MySQL state layouts, keeping master's `v1_4_1_004` entries intact. * The SQLite fingerprint is measured by replaying `s1300` through the new head; it changed only because the recorded layout now includes `pc_access_owner_recovery`. The persistence review digest in `release/upgrade-support.json` is recomputed and the rationale states what the revision adds. Tests * Migration tests assert the extended chain (`s1400, s1401, s1402, v1_4_1_004, v1_4_1_005`) and the new head revision. * `previous_bundle` and the owner-recovery test now drop later revisions through one shared `drop_revision` helper, in head-first order, so the synthetic "before" bundle keeps a linear chain. Verified: `make check` (prek, ruff, ty, integration manifest), the full `tests/builtin/persistence` suite (691 passed, 110 skipped) and the migration CLI, revision-name and maintenance CLI suites. --- .env.example | 36 + .licenserc.yaml | 2 + docker/README.md | 7 +- docker/compose.distributed.yaml | 112 + docs/en/development/core-protocol.md | 7 +- .../remote-access-implementation.md | 6 +- docs/en/docs/develop/http-api.md | 23 + docs/en/docs/develop/interfaces.md | 10 +- docs/en/docs/operate/database-migrations.md | 13 +- docs/en/docs/operate/deploy-server.md | 44 +- docs/en/docs/operate/troubleshoot.md | 2 +- .../rfcs/0011_remote_access_architecture.md | 4 + .../rfcs/0019_local_source_memory_runtime.md | 4 + ...020_runtime_backed_memory_remote_access.md | 4 + .../rfcs/1430_distributed_server_workers.md | 225 + docs/zh/development/core-protocol.md | 5 +- .../remote-access-implementation.md | 5 +- docs/zh/docs/develop/http-api.md | 21 + docs/zh/docs/develop/interfaces.md | 10 +- docs/zh/docs/operate/database-migrations.md | 11 +- docs/zh/docs/operate/deploy-server.md | 41 +- docs/zh/docs/operate/troubleshoot.md | 2 +- .../rfcs/0011_remote_access_architecture.md | 3 + .../rfcs/0019_local_source_memory_runtime.md | 3 + ...020_runtime_backed_memory_remote_access.md | 3 + .../rfcs/1430_distributed_server_workers.md | 199 + .../dsh/plugins/powercontext/lib/index.js | 54 +- release/upgrade-support.json | 4 +- .../builtin/artifacts/profile/service.py | 377 +- .../builtin/persistence/__init__.py | 41 + .../builtin/persistence/coordination.py | 416 ++ .../builtin/persistence/cursors.py | 45 +- .../builtin/persistence/database.py | 65 +- .../migrations/server_resources/manifest.json | 24 +- .../mysql/versions/v1_4_1_005.py | 29 + .../schemas/mysql-v1_4_1_005.json | 4234 +++++++++++++++++ .../schemas/sqlite-v1_4_1_005.json | 19 + .../versions/v1_4_1_005_work_ledger.py | 36 + .../server_sources/1.4.0/manifest.json | 24 +- .../persistence/migrations/vector_profile.py | 18 + .../builtin/persistence/oceanbase/profile.py | 16 +- .../builtin/persistence/rate_limit.py | 216 + .../builtin/persistence/seekdb/profile.py | 6 +- .../builtin/persistence/sqlite/profile.py | 6 +- .../builtin/persistence/tables.py | 168 + src/powercontext/builtin/persistence/work.py | 1375 ++++++ src/powercontext/builtin/runtime/config.py | 124 +- src/powercontext/builtin/runtime/cron.py | 121 + .../builtin/runtime/durable_scheduler.py | 273 ++ .../builtin/runtime/membership.py | 197 + .../builtin/runtime/operations.py | 211 + .../builtin/runtime/processing_registry.py | 5 + src/powercontext/builtin/runtime/protocols.py | 21 +- .../builtin/runtime/relational.py | 19 + .../builtin/runtime/work_handlers.py | 676 +++ .../builtin/runtime/work_observability.py | 69 + src/powercontext/builtin/runtime/worker.py | 418 ++ src/powercontext/client/__init__.py | 4 + src/powercontext/client/errors.py | 20 + src/powercontext/server/metrics.py | 100 +- src/powercontext/server/tracing.py | 52 +- .../artifacts/profile/test_scheduler.py | 11 +- .../builtin/persistence/test_coordination.py | 112 + tests/builtin/persistence/test_database.py | 47 + .../test_full_server_migrations.py | 4 +- .../test_mysql_migration_runner.py | 16 +- .../builtin/persistence/test_mysql_schema.py | 10 +- .../test_owner_recovery_migration.py | 20 +- .../test_portable_storage_migration.py | 49 +- tests/builtin/persistence/test_rate_limit.py | 45 + .../test_release140_server_migrations.py | 2 +- tests/builtin/persistence/test_work.py | 418 ++ .../builtin/runtime/test_deployment_config.py | 92 + .../runtime/test_durable_model_usage.py | 115 + .../builtin/runtime/test_durable_scheduler.py | 191 + tests/builtin/runtime/test_membership.py | 73 + .../runtime/test_operation_maintenance.py | 85 + tests/builtin/runtime/test_profile_work.py | 129 + .../builtin/runtime/test_recurrence_ledger.py | 60 +- tests/builtin/runtime/test_worker.py | 339 ++ .../test_artifact_processing_persistence.py | 4 +- tests/e2e/test_durable_experience_work.py | 160 + tests/e2e/test_operation_api.py | 108 + .../native/test_personal_service_lifecycle.py | 8 + tests/test_config_cli.py | 44 + tests/test_docker_contract.py | 28 + tests/test_managed_atomic_maintenance.py | 4 +- tests/test_managed_processing_maintenance.py | 2 +- tests/test_migration_revision_names.py | 14 +- .../test_registered_source_maintenance_cli.py | 2 +- tests/test_server_metrics.py | 32 + tests/test_server_migration_cli.py | 71 + tests/test_server_tracing.py | 22 + 93 files changed, 12335 insertions(+), 267 deletions(-) create mode 100644 docker/compose.distributed.yaml create mode 100644 docs/en/rfcs/1430_distributed_server_workers.md create mode 100644 docs/zh/rfcs/1430_distributed_server_workers.md create mode 100644 src/powercontext/builtin/persistence/coordination.py create mode 100644 src/powercontext/builtin/persistence/migrations/server_resources/mysql/versions/v1_4_1_005.py create mode 100644 src/powercontext/builtin/persistence/migrations/server_resources/schemas/mysql-v1_4_1_005.json create mode 100644 src/powercontext/builtin/persistence/migrations/server_resources/schemas/sqlite-v1_4_1_005.json create mode 100644 src/powercontext/builtin/persistence/migrations/server_resources/versions/v1_4_1_005_work_ledger.py create mode 100644 src/powercontext/builtin/persistence/rate_limit.py create mode 100644 src/powercontext/builtin/persistence/work.py create mode 100644 src/powercontext/builtin/runtime/cron.py create mode 100644 src/powercontext/builtin/runtime/durable_scheduler.py create mode 100644 src/powercontext/builtin/runtime/membership.py create mode 100644 src/powercontext/builtin/runtime/operations.py create mode 100644 src/powercontext/builtin/runtime/work_handlers.py create mode 100644 src/powercontext/builtin/runtime/work_observability.py create mode 100644 src/powercontext/builtin/runtime/worker.py create mode 100644 tests/builtin/persistence/test_coordination.py create mode 100644 tests/builtin/persistence/test_database.py create mode 100644 tests/builtin/persistence/test_rate_limit.py create mode 100644 tests/builtin/persistence/test_work.py create mode 100644 tests/builtin/runtime/test_deployment_config.py create mode 100644 tests/builtin/runtime/test_durable_model_usage.py create mode 100644 tests/builtin/runtime/test_durable_scheduler.py create mode 100644 tests/builtin/runtime/test_membership.py create mode 100644 tests/builtin/runtime/test_operation_maintenance.py create mode 100644 tests/builtin/runtime/test_profile_work.py create mode 100644 tests/builtin/runtime/test_worker.py create mode 100644 tests/e2e/test_durable_experience_work.py create mode 100644 tests/e2e/test_operation_api.py create mode 100644 tests/test_server_migration_cli.py diff --git a/.env.example b/.env.example index 98cbe73216..6cd0a3dd7e 100644 --- a/.env.example +++ b/.env.example @@ -68,6 +68,42 @@ POWERCONTEXT_SERVER_DATABASE_KIND=sqlite # POWERCONTEXT_SERVER_DATABASE_KIND=seekdb # POWERCONTEXT_SERVER_DATABASE_PATH=/absolute/path/to/seekdb +# Deployment and durable work ------------------------------------------------- +# Local installs run every role in one process. Distributed mode requires OceanBase and one role per process. +POWERCONTEXT_SERVER_DEPLOYMENT_MODE=single_node +POWERCONTEXT_SERVER_DEPLOYMENT_ROLE=all +POWERCONTEXT_SERVER_DEPLOYMENT_ID=local +POWERCONTEXT_SERVER_DEPLOYMENT_BEHAVIOR_REVISION=default + +# Scheduler and member leases use database time. Renewal must stay within the validated lease ratios. +POWERCONTEXT_SERVER_COORDINATION_SCHEDULER_LEASE_SECONDS=30 +POWERCONTEXT_SERVER_COORDINATION_SCHEDULER_RENEW_SECONDS=10 +POWERCONTEXT_SERVER_COORDINATION_SCAN_PAGE_SIZE=100 +POWERCONTEXT_SERVER_COORDINATION_MEMBER_TTL_SECONDS=30 +POWERCONTEXT_SERVER_COORDINATION_MEMBER_HEARTBEAT_SECONDS=10 +POWERCONTEXT_SERVER_COORDINATION_EMIT_PAYLOAD_VERSION=1 + +POWERCONTEXT_SERVER_WORKER_CONCURRENCY=4 +POWERCONTEXT_SERVER_WORKER_LEASE_SECONDS=120 +POWERCONTEXT_SERVER_WORKER_HEARTBEAT_SECONDS=30 +POWERCONTEXT_SERVER_WORKER_SHUTDOWN_GRACE_SECONDS=90 +POWERCONTEXT_SERVER_WORKER_MAX_ATTEMPTS=5 +POWERCONTEXT_SERVER_WORKER_RETRY_BASE_SECONDS=2 +POWERCONTEXT_SERVER_WORKER_RETRY_MAX_SECONDS=300 +POWERCONTEXT_SERVER_WORKER_POLL_SECONDS=1 + +POWERCONTEXT_SERVER_OPERATIONS_DEFAULT_WAIT_SECONDS=10 +POWERCONTEXT_SERVER_OPERATIONS_MAXIMUM_WAIT_SECONDS=30 +POWERCONTEXT_SERVER_OPERATIONS_POLL_SECONDS=0.2 +POWERCONTEXT_SERVER_OPERATIONS_RETENTION_DAYS=30 +POWERCONTEXT_SERVER_OPERATIONS_CLEANUP_BATCH_SIZE=500 +POWERCONTEXT_SERVER_OPERATIONS_CLEANUP_INTERVAL_SECONDS=3600 + +# Shared OceanBase fixed-window limiting is optional and disabled by default. +POWERCONTEXT_SERVER_RATE_LIMIT_ENABLED=false +POWERCONTEXT_SERVER_RATE_LIMIT_REQUESTS=120 +POWERCONTEXT_SERVER_RATE_LIMIT_WINDOW_SECONDS=60 + # Runtime --------------------------------------------------------------------- POWERCONTEXT_SERVER_RUNTIME_SOURCE_WINDOW_LIMIT=100 # Maximum sum of explicit assembly section limits; per-family limits still apply. diff --git a/.licenserc.yaml b/.licenserc.yaml index de4604d537..4d32394405 100644 --- a/.licenserc.yaml +++ b/.licenserc.yaml @@ -68,6 +68,8 @@ header: - 'src/powercontext/server/dashboard/static/vendor/**' - '.gitattributes' - '**/*.service.in' + # license-eye cannot determine the comment style of Alembic's Mako template. + - 'src/powercontext/builtin/persistence/migrations/script.py.mako' - '**/*.jsonl' # license-eye cannot determine the comment style of patch files. - '**/*.patch' diff --git a/docker/README.md b/docker/README.md index cbebd87af5..1ffba2a954 100644 --- a/docker/README.md +++ b/docker/README.md @@ -11,7 +11,7 @@ docker build \ . ``` -Run the Server with persistent SQLite and scheduler data: +Run the Server with persistent SQLite application and Work Ledger data: ```bash docker run --rm \ @@ -34,6 +34,11 @@ requests share one refresh. Checks use the Runtime's credentials and never expos another database or inference provider with the same `POWERCONTEXT_SERVER_*` environment variables used by a regular Server installation. +For a role-separated OceanBase topology, use `docker/compose.distributed.yaml` as a deployment template. It runs a +one-shot migrator, two API replicas, two fenced Scheduler replicas, and two lease-based Worker replicas. Replace the +example database credentials, authentication token, and provider configuration before starting it; only Workers need +model credentials. + ## Network exposure PowerContext refuses to start an unauthenticated Server on a non-loopback address unless diff --git a/docker/compose.distributed.yaml b/docker/compose.distributed.yaml new file mode 100644 index 0000000000..2ec3652b30 --- /dev/null +++ b/docker/compose.distributed.yaml @@ -0,0 +1,112 @@ +name: powercontext-distributed + +x-runtime: &runtime + image: ${POWERCONTEXT_IMAGE:-powercontext-server:local} + restart: unless-stopped + environment: &shared-environment + POWERCONTEXT_SERVER_DATABASE_KIND: oceanbase + POWERCONTEXT_SERVER_DATABASE_URL: ${POWERCONTEXT_SERVER_DATABASE_URL:?set an OceanBase URL} + POWERCONTEXT_SERVER_DEPLOYMENT_MODE: distributed + POWERCONTEXT_SERVER_DEPLOYMENT_BEHAVIOR_REVISION: ${POWERCONTEXT_BEHAVIOR_REVISION:-distributed-v1} + POWERCONTEXT_SERVER_RUNTIME_SCHEDULE_SECONDS: ${POWERCONTEXT_MEMORY_SCHEDULE_SECONDS:-60} + POWERCONTEXT_SERVER_RUNTIME_EXPERIENCE_SCHEDULE_SECONDS: ${POWERCONTEXT_EXPERIENCE_SCHEDULE_SECONDS:-60} + +services: + migrate: + <<: *runtime + restart: "no" + command: ["server", "migrate"] + environment: + <<: *shared-environment + # The migration command does not start a Runtime or register this role; + # a valid distributed role is still required while parsing ServerSettings. + POWERCONTEXT_SERVER_DEPLOYMENT_ROLE: api + POWERCONTEXT_SERVER_DEPLOYMENT_ID: migrator + + api-a: + <<: *runtime + command: ["server", "run"] + environment: + <<: *shared-environment + POWERCONTEXT_SERVER_DEPLOYMENT_ROLE: api + POWERCONTEXT_SERVER_DEPLOYMENT_ID: api-a + POWERCONTEXT_SERVER_AUTH_ENABLED: "true" + POWERCONTEXT_SERVER_AUTH_TOKEN: ${POWERCONTEXT_SERVER_AUTH_TOKEN:?set an API bearer token} + POWERCONTEXT_SERVER_RATE_LIMIT_ENABLED: "true" + ports: ["127.0.0.1:8001:8000"] + depends_on: + migrate: + condition: service_completed_successfully + + api-b: + <<: *runtime + command: ["server", "run"] + environment: + <<: *shared-environment + POWERCONTEXT_SERVER_DEPLOYMENT_ROLE: api + POWERCONTEXT_SERVER_DEPLOYMENT_ID: api-b + POWERCONTEXT_SERVER_AUTH_ENABLED: "true" + POWERCONTEXT_SERVER_AUTH_TOKEN: ${POWERCONTEXT_SERVER_AUTH_TOKEN:?set an API bearer token} + POWERCONTEXT_SERVER_RATE_LIMIT_ENABLED: "true" + ports: ["127.0.0.1:8002:8000"] + depends_on: + migrate: + condition: service_completed_successfully + + scheduler-a: + <<: *runtime + command: ["server", "run"] + environment: + <<: *shared-environment + POWERCONTEXT_SERVER_DEPLOYMENT_ROLE: scheduler + POWERCONTEXT_SERVER_DEPLOYMENT_ID: scheduler-a + POWERCONTEXT_SERVER_DASHBOARD_ENABLED: "false" + POWERCONTEXT_SERVER_MCP_ENABLED: "false" + depends_on: + migrate: + condition: service_completed_successfully + + scheduler-b: + <<: *runtime + command: ["server", "run"] + environment: + <<: *shared-environment + POWERCONTEXT_SERVER_DEPLOYMENT_ROLE: scheduler + POWERCONTEXT_SERVER_DEPLOYMENT_ID: scheduler-b + POWERCONTEXT_SERVER_DASHBOARD_ENABLED: "false" + POWERCONTEXT_SERVER_MCP_ENABLED: "false" + depends_on: + migrate: + condition: service_completed_successfully + + worker-a: + <<: *runtime + command: ["server", "run"] + environment: + <<: *shared-environment + POWERCONTEXT_SERVER_DEPLOYMENT_ROLE: worker + POWERCONTEXT_SERVER_DEPLOYMENT_ID: worker-a + POWERCONTEXT_SERVER_DASHBOARD_ENABLED: "false" + POWERCONTEXT_SERVER_MCP_ENABLED: "false" + POWERCONTEXT_SERVER_INFERENCE_GENERATION_MODEL: ${POWERCONTEXT_GENERATION_MODEL:?set a generation model} + OPENAI_API_KEY: ${OPENAI_API_KEY:-} + ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-} + depends_on: + migrate: + condition: service_completed_successfully + + worker-b: + <<: *runtime + command: ["server", "run"] + environment: + <<: *shared-environment + POWERCONTEXT_SERVER_DEPLOYMENT_ROLE: worker + POWERCONTEXT_SERVER_DEPLOYMENT_ID: worker-b + POWERCONTEXT_SERVER_DASHBOARD_ENABLED: "false" + POWERCONTEXT_SERVER_MCP_ENABLED: "false" + POWERCONTEXT_SERVER_INFERENCE_GENERATION_MODEL: ${POWERCONTEXT_GENERATION_MODEL:?set a generation model} + OPENAI_API_KEY: ${OPENAI_API_KEY:-} + ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-} + depends_on: + migrate: + condition: service_completed_successfully diff --git a/docs/en/development/core-protocol.md b/docs/en/development/core-protocol.md index 1348e07c53..209fa5f635 100644 --- a/docs/en/development/core-protocol.md +++ b/docs/en/development/core-protocol.md @@ -97,8 +97,9 @@ conflicts and persistence; the family service is responsible for its domain beha A `Trigger` is a policy over a signal and prior state. It returns a `PolicyTransition` containing the next state and zero or more actions. A Trigger should not open storage, schedule itself, or perform the action it selects. -APScheduler belongs to the Builtin runtime lifecycle. It decides when to evaluate a policy. The Trigger decides what -the observed signal means. +The durable Scheduler belongs to the Builtin runtime lifecycle. It decides when to evaluate a policy and records work +in the database ledger. The Trigger decides what the observed signal means; a fenced Worker performs the selected +action. ## Ownership boundaries @@ -109,7 +110,7 @@ the observed signal means. | Environment-backed process configuration | `powercontext.client.settings`, `powercontext.server.settings` | | HTTP lifecycle and optional MCP transport | `powercontext.server` | | Provider-specific generation and embedding | Inference integration | -| Database and scheduler resource lifetime | Application entry point or Builtin runtime instance | +| Database, Scheduler, and Worker resource lifetime | Application entry point or Builtin runtime instance | Core models use Pydantic `BaseModel`. Add a validator when the value has a real domain constraint. Do not add wrapper properties for stored fields, custom JSON value hierarchies, or a second definition object when the model or protocol diff --git a/docs/en/development/remote-access-implementation.md b/docs/en/development/remote-access-implementation.md index 79077568d8..f7921f4a7e 100644 --- a/docs/en/development/remote-access-implementation.md +++ b/docs/en/development/remote-access-implementation.md @@ -76,8 +76,9 @@ and OceanBase uses HNSW for `vector` and `hybrid` searches. Inference configuration is documented in [Configure Pydantic AI inference](pydantic-ai-inference.md). Set `POWERCONTEXT_SERVER_RUNTIME_SCHEDULE_SECONDS` to process pending Source windows on a persisted interval. Scheduled -jobs use the SQLite sidecar at `POWERCONTEXT_HOME/scheduler.db`. Scheduling works with either application database and -requires a configured generation pipeline. +discovery and execution use the primary database Work Ledger. In `single_node/all` mode the embedded Scheduler and +Worker share that ledger; distributed OceanBase deployments run them as separate roles. A generation pipeline is +required by the Worker. ## HTTP surface @@ -91,6 +92,7 @@ The source contract is `openapi/powercontext.yaml`. Generated Pydantic models an | Capabilities | source types, Artifact families, extraction, search modes | | Sources | capture durable content evidence | | Memory | flush pending Sources, remember explicit entries, search | +| Operations | inspect, list, cancel, and retry durable background work | | Memory entries | list, get, revise, retire | | History | list Memory changes | diff --git a/docs/en/docs/develop/http-api.md b/docs/en/docs/develop/http-api.md index 095f136314..e4fe637a45 100644 --- a/docs/en/docs/develop/http-api.md +++ b/docs/en/docs/develop/http-api.md @@ -93,6 +93,27 @@ curl --fail \ "$POWERCONTEXT_URL/v1/memory/search" ``` +## Flush through a durable operation + +A Memory flush can finish during the request or continue on any Worker. Ask for an immediate handle when the caller +does not want to wait: + +```bash +curl --fail-with-body --include \ + --request POST \ + --header 'Content-Type: application/json' \ + --header 'Prefer: respond-async' \ + --header "$POWERCONTEXT_AUTH_HEADER" \ + --data '{"scope_id":"project:example"}' \ + "$POWERCONTEXT_URL/v1/memory/flush" +``` + +HTTP `200` contains the completed `FlushMemoryResponse`. HTTP `202` contains an operation ID and includes relative +`Location` and `Retry-After` headers. `Prefer: wait=N` waits up to 30 seconds. Poll +`GET /v1/operations/{operation_id}`; use the returned `state_version` as `expected_version` when cancelling a queued or +running operation, or retrying a blocked failed operation. A failed logical window returns `409 operation_blocked` on +another flush until an operator retries or cancels it. + ## Scope-owned operational Prompts Operational Prompts use `family=prompt` and the same Scope access boundary as other Artifacts. Under enforced Access, @@ -211,6 +232,7 @@ use the same policy enforcement point; MCP tool visibility is not permission. | External Skills | `/v1/external-skills/*` | Scan configured targets and resolve or import packages | | Handoff Reports | `/v1/handoff-reports/*` | Generate a read-only report for a Scope selection | | Statistics | `/v1/stats` | Read scoped usage statistics | +| Durable operations | `/v1/operations/*` | Inspect, list, cancel, or recover Memory and Experience background work | Create an Artifact with `POST /v1/scopes/{scope_id}/artifacts`, specifying `family` in the request body. Profile candidate review uses `/v1/candidates/*`. @@ -244,6 +266,7 @@ Common statuses are: | --- | --- | | `401` | The Server requires a valid bearer token | | `403` | The authenticated Principal is not authorized for the requested action and resource | +| `429` | The shared request window is exhausted; wait for `Retry-After` | | `404` | The requested immutable value does not exist | | `409` | The request conflicts with current immutable state or an expected version | | `413` | A selected Handoff Report exceeds its output limit | diff --git a/docs/en/docs/develop/interfaces.md b/docs/en/docs/develop/interfaces.md index fc3ea13a78..c7e929e077 100644 --- a/docs/en/docs/develop/interfaces.md +++ b/docs/en/docs/develop/interfaces.md @@ -236,12 +236,12 @@ selector retains that selector, and Experience incubation uses its independent c ## Scheduled Experience incubation An integration can capture a completed task as a Content Source with metadata `"kind": "task-outcome"`. When the -Experience schedule is configured, APScheduler scans bounded Source windows and asks the configured schema-bound -pipeline for reusable situation, action, outcome, and lesson proposals. Each proposal cites exact Sources and enters -the Review Inbox as a pending Experience Candidate. +Experience schedule is configured, the durable Scheduler scans bounded Source windows and enqueues versioned work. +A fenced Worker asks the configured schema-bound pipeline for reusable situation, action, outcome, and lesson +proposals. Each proposal cites exact Sources and enters the Review Inbox as a pending Experience Candidate. -Experience incubation has its own persisted Source cursor, independent from Memory extraction. Candidate writes and -cursor advancement commit together; a generation or write failure leaves the window available for retry. Ordinary +Experience incubation has its own persisted Source cursor, independent from Memory extraction. Candidate writes, +cursor advancement, and Work success commit together; a generation or write failure leaves the window available for retry. Ordinary prompt Sources are not Task Outcomes and are ignored by this job. Scheduling stops at the review boundary. It never approves an Experience, includes pending content in diff --git a/docs/en/docs/operate/database-migrations.md b/docs/en/docs/operate/database-migrations.md index 3e65a35739..5a4a3bb14b 100644 --- a/docs/en/docs/operate/database-migrations.md +++ b/docs/en/docs/operate/database-migrations.md @@ -16,8 +16,8 @@ The design follows [RFC #1771](https://github.com/oceanbase/powercontext/pull/17 The full Server framework registers two installed-release sources for the PowerContext 1.4.1 target (stored compatibility IDs shown below): -- **1.3.0:** `s1300 → s1400 → s1401 → s1402 → v1_4_1_004`. -- **1.4.0:** `r1400 → s1400 → s1401 → s1402 → v1_4_1_004` through a separate frozen source bridge. +- **1.3.0:** `s1300 → s1400 → s1401 → s1402 → v1_4_1_004 → v1_4_1_005`. +- **1.4.0:** `r1400 → s1400 → s1401 → s1402 → v1_4_1_004 → v1_4_1_005` through a separate frozen source bridge. Application release numbers and schema revisions are distinct. The 1.4.0 release is not the `s1400` checkpoint: its existing Candidate schema is current, but its Topic attempt storage still needs the registered bridge. @@ -51,6 +51,15 @@ maintenance for existing managed storage. Runtime startup does not add the recei Revision `v1_4_1_004` registers `pc_access_owner_recovery` for durable Artifact and Candidate ownership intents. Both installed-release source paths and vector profiles include this table. Existing managed databases require explicit maintenance; fresh databases create the registered empty table. Pending intents retain their original deployment, principal and idempotency identity across Server restarts. Earlier revision resources and checksums remain unchanged. Unregistered database layouts are rejected rather than adopted from a table name alone. This registration does not establish installed-release or external-Provider upgrade acceptance. +Revision `v1_4_1_005` registers the durable Work Ledger and its scheduler coordination tables: `pc_work_lanes`, +`pc_work_items`, `pc_work_keys`, `pc_work_attempts`, `pc_scheduler_leases`, `pc_scheduler_scans`, +`pc_runtime_members`, and the shared `pc_rate_limit_windows`. Ledger execution is at-least-once behind a monotonic +fence token while its commit is at-most-once, so a retried, superseded or cancelled attempt cannot publish a duplicate +result or release another worker's logical key. Both installed-release source paths and vector profiles include these +tables; existing managed databases require explicit maintenance and fresh databases create them through the registered +bootstrap. Earlier revision resources, registered layouts and historical checksums remain unchanged. This registration +does not establish installed-release or external-Provider upgrade acceptance. + An interrupted [portable restore](portable-archive.md) keeps normal startup and workers blocked until its projections are complete. Retry `powercontext archive restore --yes` with the same validated bundle. Only this explicit recovery path can open an incomplete projection set, after validating the registered layout and the configured vector diff --git a/docs/en/docs/operate/deploy-server.md b/docs/en/docs/operate/deploy-server.md index f309cf4f5e..97148e51e3 100644 --- a/docs/en/docs/operate/deploy-server.md +++ b/docs/en/docs/operate/deploy-server.md @@ -93,8 +93,9 @@ export POWERCONTEXT_HOME=/srv/powercontext powercontext server run ``` -The process must be able to create and update this directory. The default SQLite database and scheduler state are -stored below it. Supply the same environment variables whenever your service manager restarts the process. +The process must be able to create and update this directory. The default SQLite database also stores durable +Scheduler, Worker lease, and Operation state. Supply the same environment variables whenever your service manager +restarts the process. `server run` loads `.env` from its current directory when present. Managed deployments should export the variables, configure them in the service manager or container platform, or pass one explicit file so startup does not depend on @@ -140,7 +141,44 @@ docker run --rm \ ``` The image listens on `0.0.0.0:8000` inside the container, so the host-side address in `--publish` is important. The -named volume persists the SQLite database and scheduler state after the container stops. +named volume persists the SQLite database and durable work state after the container stops. + +## Run distributed roles + +Distributed mode requires OceanBase and a schema migration before any role starts. The repository includes +`docker/compose.distributed.yaml` as a topology example with two APIs, two Schedulers, and two Workers. One Scheduler is +leader and the other remains ready to take over; both APIs and both Workers are active. + +The distributed Work Ledger schedules Memory, Experience, and Profile work. Topic Memory processing is not supported +in distributed v1; leave its schedule unset. The single-node Artifact Processing Supervisor role must remain `all` +because `POWERCONTEXT_SERVER_DEPLOYMENT_ROLE` owns process separation in this topology. + +Export secrets and deployment choices without writing them into Compose: + +```bash +export POWERCONTEXT_SERVER_DATABASE_URL="$OCEANBASE_URL" +export POWERCONTEXT_SERVER_AUTH_TOKEN="$POWERCONTEXT_DEPLOYMENT_TOKEN" +export POWERCONTEXT_GENERATION_MODEL="openai:gpt-4.1-mini" +export OPENAI_API_KEY +docker compose --file docker/compose.distributed.yaml up migrate +docker compose --file docker/compose.distributed.yaml up -d api-a api-b scheduler-a scheduler-b worker-a worker-b +``` + +The two API examples listen on host ports 8001 and 8002. Put a TLS-terminating load balancer in front of them and use +round-robin routing without session affinity. Distributed MCP is stateless. The example intentionally supplies model +credentials only to Workers and no public port to Scheduler or Worker roles. A production deployment should also use +separate least-privilege database users instead of the shared demonstration URL. + +Every replica in one rollout must use the same behavior revision. Upgrade in this order: + +1. run `powercontext server migrate` with a dedicated DDL account; +2. replace Workers and wait for readiness; +3. replace Schedulers and confirm a leader can scan; +4. replace APIs. + +Keep `POWERCONTEXT_SERVER_COORDINATION_EMIT_PAYLOAD_VERSION` on the older supported value until old Workers have drained. +Rollback in the reverse order and never start a distributed role against a schema that has not reached the packaged +revision. ## Enable authentication diff --git a/docs/en/docs/operate/troubleshoot.md b/docs/en/docs/operate/troubleshoot.md index 06d9b97b9f..8ac1f33a60 100644 --- a/docs/en/docs/operate/troubleshoot.md +++ b/docs/en/docs/operate/troubleshoot.md @@ -269,7 +269,7 @@ so the previous database remains available for recovery: ```bash obloader -D --csv \ - --table 'pc_scopes,pc_source_journal_heads,pc_sources,pc_artifacts,pc_portable_restore_receipts,pc_source_cursors,pc_memory_source_windows,pc_artifact_processing_leases,pc_artifact_processing_binding_states,pc_artifact_processing_pending,pc_artifact_processing_auto_wave_targets,pc_artifact_processing_sequences,pc_artifact_processing_intents,pc_topic_memory_processing_targets,pc_artifact_processing_schema,pc_artifact_processing_migration_receipts,pc_topic_memory_work_budgets,pc_topic_memory_work_blocks,pc_topic_memory_retrieval_shape,pc_connector_checkpoints,pc_source_definition_manifests,pc_external_skill_registrations,pc_skill_packages,pc_agent_skill_targets,pc_skill_publications,pc_model_usage_daily,pc_recall_token_daily,pc_recall_effort_daily,pc_receipt_migration_review,pc_decision_observations,pc_lexical_projection_versions' \ + --table 'pc_scopes,pc_source_journal_heads,pc_sources,pc_artifacts,pc_portable_restore_receipts,pc_source_cursors,pc_memory_source_windows,pc_artifact_processing_leases,pc_artifact_processing_binding_states,pc_artifact_processing_pending,pc_artifact_processing_auto_wave_targets,pc_artifact_processing_sequences,pc_artifact_processing_intents,pc_topic_memory_processing_targets,pc_artifact_processing_schema,pc_artifact_processing_migration_receipts,pc_topic_memory_work_budgets,pc_topic_memory_work_blocks,pc_topic_memory_retrieval_shape,pc_connector_checkpoints,pc_source_definition_manifests,pc_external_skill_registrations,pc_skill_packages,pc_agent_skill_targets,pc_skill_publications,pc_model_usage_daily,pc_recall_token_daily,pc_recall_effort_daily,pc_receipt_migration_review,pc_decision_observations,pc_lexical_projection_versions,pc_work_lanes,pc_work_items,pc_work_keys,pc_work_attempts' \ -f ``` diff --git a/docs/en/rfcs/0011_remote_access_architecture.md b/docs/en/rfcs/0011_remote_access_architecture.md index 54e239653d..c158027d85 100644 --- a/docs/en/rfcs/0011_remote_access_architecture.md +++ b/docs/en/rfcs/0011_remote_access_architecture.md @@ -2,6 +2,10 @@ - Start Date: 2026-07-16 - RFC PR: [oceanbase/powercontext#11](https://github.com/oceanbase/powercontext/pull/11) +> **Deployment boundary:** [RFC 1430](1430_distributed_server_workers.md) defines the accepted durable Operation, +> stateless multi-replica API, role separation, and distributed coordination contract. It supersedes this RFC where +> this document leaves execution and deployment semantics open. + # Summary This RFC proposes a remote access boundary for PowerContext. A Server exposes application services through an diff --git a/docs/en/rfcs/0019_local_source_memory_runtime.md b/docs/en/rfcs/0019_local_source_memory_runtime.md index c671677729..f18c49b963 100644 --- a/docs/en/rfcs/0019_local_source_memory_runtime.md +++ b/docs/en/rfcs/0019_local_source_memory_runtime.md @@ -5,6 +5,10 @@ > **Note:** PowerContext now uses bundled sqlite-vec for SQLite vector search. Statements about Vec1 in this RFC no > longer apply and remain only as a record of the original design. +> **Execution update:** [RFC 1430](1430_distributed_server_workers.md) replaces the APScheduler sidecar and +> single-process execution assumptions with one database-backed Work Ledger for local and distributed modes. This RFC +> remains authoritative for Source window, cursor, Memory, and domain commit semantics. + # Summary This RFC proposes backend-neutral Runtime storage contracts and a built-in SQLite profile. The Runtime uses the diff --git a/docs/en/rfcs/0020_runtime_backed_memory_remote_access.md b/docs/en/rfcs/0020_runtime_backed_memory_remote_access.md index 251b9c34d6..ef5d598031 100644 --- a/docs/en/rfcs/0020_runtime_backed_memory_remote_access.md +++ b/docs/en/rfcs/0020_runtime_backed_memory_remote_access.md @@ -6,6 +6,10 @@ > **Note:** PowerContext now uses bundled sqlite-vec for SQLite vector search. Statements about Vec1 in this RFC no > longer apply and remain only as a record of the original design. +> **Execution update:** [RFC 1430](1430_distributed_server_workers.md) supersedes this RFC's single-process scheduler, +> synchronous-only flush, and non-durable Operation assumptions. This RFC continues to define the HTTP mapping and +> generated-contract boundary. + # Summary This RFC defines the first concrete remote API built from the architecture in RFC 0011 and the local Runtime in diff --git a/docs/en/rfcs/1430_distributed_server_workers.md b/docs/en/rfcs/1430_distributed_server_workers.md new file mode 100644 index 0000000000..b498dd5eee --- /dev/null +++ b/docs/en/rfcs/1430_distributed_server_workers.md @@ -0,0 +1,225 @@ +- Proposal Name: `distributed_server_workers` +- Start Date: 2026-09-03 +- Tracking Issue: [oceanbase/powercontext#1430](https://github.com/oceanbase/powercontext/issues/1430) +- Related RFCs: [RFC 0011](0011_remote_access_architecture.md), [RFC 0019](0019_local_source_memory_runtime.md), + [RFC 0020](0020_runtime_backed_memory_remote_access.md), and [RFC 0046](0046_observability_foundations.md) + +# Summary + +PowerContext uses one database-backed Work Ledger for both local and distributed background execution. An API replica +can enqueue and inspect work without process affinity. Schedulers elect one fenced leader while retaining durable +keyset scan positions. Workers claim only available capacity, renew database-time leases, and safely recover abandoned +attempts. Model calls remain at-least-once, while one logical Source window can commit at most one database result. + +OceanBase is the only coordination backend in distributed mode. The same ledger also replaces the APScheduler SQLite +sidecar in `single_node/all`, so there is one execution protocol to understand and test. Redis, Kafka, a public queue +SPI, arbitrary user code, DAG scheduling, priorities, multi-region consensus, and connector-specific checkpoints are +outside this RFC. + +# Decisions + +The design deliberately minimizes sources of truth: + +- OceanBase owns work state, leases, cursors, and domain commits in distributed mode. +- Execution is at-least-once. Provider work may repeat after a crash, but fencing and the domain cursor prevent an old + attempt from committing. +- Memory activation and Experience incubation use the ledger first. Future handlers keep their domain checkpoints in + their own modules. +- The default remains `single_node/all`; distributed processes use exactly one of `api`, `scheduler`, or `worker`. +- Work payloads are versioned references and numeric window boundaries, not Source bodies, prompts, model responses, + credentials, or exception text. + +This avoids entropy from parallel database, broker, and local-sidecar protocols. The compensating complexity is made +explicit as database constraints, a small state machine, fencing tokens, startup validation, and compatibility +advertisements rather than tacit assumptions about one process. + +# Architecture + +```text +API / Scheduler + | short transaction: identify window, deduplicate, enqueue + v +OceanBase Work Ledger + | short transaction: claim lane head and issue lease fence + v +Worker ---- provider/model call outside a transaction ----> + | short transaction: validate fence, commit domain state + cursor + work + v +one logical database result +``` + +The ledger consists of: + +| Table | Responsibility | +| --- | --- | +| `pc_work_items` | Versioned work, scope, lane sequence, safe payload, state, lease, attempt budget, and safe result/error | +| `pc_work_attempts` | Append-only owner/fence/timing/outcome audit and non-sensitive trace identity | +| `pc_work_lanes` | Strict sequence and one active head for each consistency domain | +| `pc_work_keys` | Unique reservation for the current logical window | +| `pc_scheduler_leases` | Leader and migrator ownership with monotonic fences and database-time expiry | +| `pc_scheduler_scans` | Next run time and keyset continuation for each discoverer | +| `pc_runtime_members` | Live role and schema/payload/behavior compatibility advertisements | +| `pc_rate_limit_windows` | Optional shared fixed-window counters keyed by an opaque principal digest and policy | + +The logical key for a Memory window is the SHA-256 digest of its handler kind, scope, cursor name, cursor generation, +and previous cursor. The enqueue-time high watermark and `through` boundary are stored in the work payload but are not +part of that key. Manual and scheduled discovery therefore join the same unadvanced cursor window; Sources arriving +later are handled by the next window. + +Memory and Experience use independent lanes (`memory:{scope}` and `experience:{scope}` before hashing). Different lanes +may execute concurrently, while a lane advances strictly in sequence. A terminal failure retains its logical-key and +lane reservation until an operator retries or cancels it, preventing a poison window from being recreated forever. + +# State machine + +```text +queued ----> running ----> succeeded + | |----> retry_wait ----> running + | |----> failed + | `----> cancelling ----> cancelled + `-----------------------------------> cancelled + +failed -- explicit operator retry --> queued +``` + +Cancellation linearizes at the work row. If cancellation wins before the final transaction, the Worker discards the +prepared provider result and completes the attempt as cancelled. If success has already committed, cancellation returns +`409`. Cancelling one operation does not pause future discovery for its scope. + +# Lock and transaction protocol + +OceanBase 4.3.5 with Read Committed isolation is the baseline. Correctness does not depend on an unlocked read, a +single predicate update, `SKIP LOCKED`, or `GET_LOCK()`. Every path follows this order: + +```text +scheduler lease (scheduler paths only) + -> lane + -> logical key + -> work item + -> domain cursor/head +``` + +Stable lease and lane rows are explicitly locked. A missing row is inserted under a unique key and the operation is +retried after a conflict. Claiming starts with a bounded indexed candidate scan, then locks and claims one lane at a +time in a short transaction. Workers never prefetch more work than their free execution slots. + +Scheduler enqueue and scan updates revalidate the exact leader owner, fence, and database expiry. Worker heartbeats, +failures, cancellation convergence, and completion validate the exact `(work_id, owner, fence)`. All expiry decisions +use `CURRENT_TIMESTAMP(6)` from the database; application time is used only for polling and deadlines. + +Provider, connector, network, and filesystem calls never run under a database transaction. The final Worker +transaction validates an unexpired, uncancelled claim and then invokes the handler's domain commit. Memory and +Experience reuse their existing cursor/head compare-and-swap. Domain writes, cursor advancement, attempt completion, +and Work success either commit together or roll back together. + +# Scheduler and Worker behavior + +Each discoverer stores a durable keyset continuation and scans at most 100 scopes per page. Repeating a page after a +crash is safe because logical keys deduplicate it. A former leader cannot enqueue or save a continuation after another +Scheduler acquires a higher fence. + +A Worker claims only its current free slots. Its attempt lease defaults to 120 seconds and is renewed every 30 seconds. +Expired attempts are closed in the audit trail, moved through retry state, and reclaimed with a higher fence. Retryable +failures use full-jitter exponential backoff, beginning at 2 seconds and capped at 5 minutes. Five automatic generation +attempts are allowed by default. Unsupported payload versions stay visible at the lane head and make Worker readiness +`misconfigured`; they are never guessed, silently dropped, or moved aside. + +# Process roles + +Configuration adds `deployment`, `coordination`, `worker`, `operations`, and `rate_limit` groups. + +- `single_node/all` runs API, Scheduler, and Worker together using the same ledger. A database ownership lease rejects a + mistakenly started second instance. +- `distributed/api` exposes HTTP, Dashboard, MCP, authentication, shared rate limiting, enqueue, and operation queries. + It does not run discovery or background Worker handlers. +- `distributed/scheduler` exposes only health and metrics, owns no provider credentials, and performs bounded discovery. +- `distributed/worker` exposes only health and metrics and owns only the provider credentials required by its handlers. + +Distributed mode requires OceanBase and rejects `role=all`, SQLite, seekDB, and explicit host-local External Skill +targets. All roles use one image but should use separate least-privilege database accounts. DDL belongs to a migrator +account and is never performed automatically by a distributed role. + +Every process heartbeats a boot-unique member identity with build version, current schema and payload range, and a +non-sensitive `behavior_revision`. Credentials, secret URLs, authorization data, and hashes that permit offline secret +guessing are not member metadata. + +# HTTP and Client contract + +`openapi/powercontext.yaml` remains authoritative. `POST /v1/memory/flush` behaves as follows: + +- no pending Source, or completion within the wait budget: `200 FlushMemoryResponse`; +- still queued, running, or waiting to retry: `202 OperationAccepted`, a relative `Location`, and `Retry-After: 2`; +- a failed operation already blocks that logical key: `409 operation_blocked` with its operation ID. + +`Prefer: respond-async` selects an immediate handle. `Prefer: wait=N` selects a bounded wait up to 30 seconds; the +default is 10 seconds. Operation endpoints provide authorized get/list, optimistic cancel, and operator retry. Mutation +bodies carry `expected_version`; illegal or stale transitions return `409`. Public status values are `queued`, +`running`, `retry_wait`, `cancelling`, `succeeded`, `failed`, and `cancelled`. Internal maintenance work is not exposed by +the Operation API. + +`PowerContextClient.flush_memory()` preserves its synchronous-looking result by submitting and polling until its total +deadline. If that deadline expires it raises `OperationPendingError` with the operation ID. Callers that want explicit +control use `submit_memory_flush()`, `get_operation()`, `list_operations()`, `cancel_operation()`, and +`retry_operation()`. + +Operation endpoints are not projected as MCP tools. In distributed mode FastMCP uses stateless HTTP, so consecutive +requests may reach different API replicas. Server-to-client elicitation and sampling are disabled; the Workstream picker +returns a structured `needs_selection` response. The internal ASGI bridge carries the already authenticated principal, +and tool visibility is never treated as authorization. + +# Health, shutdown, retention, and observability + +Liveness means the process can respond. API readiness requires database, schema, authentication policy, membership, +and behavior compatibility. Missing Scheduler or Worker members make API readiness degraded rather than preventing +durable enqueue and reads. A healthy Scheduler standby is ready. A Worker that lacks a required provider or handler +version stops claiming and reports the precise failing check. + +On SIGTERM, API readiness is removed first. Scheduler stops discovery and conditionally releases its lease. Worker +stops claiming, continues heartbeats for in-flight work, and drains for at most 90 seconds; an ungraceful exit recovers +through lease expiry. + +Succeeded and cancelled work and attempts are retained for 30 days. A durable maintenance handler deletes no more than +500 records per batch and also removes expired rate-limit counters. Failed blocking work is retained until operator +action. Future Source retention must treat every non-terminal work window as a retention root. + +Metrics use only bounded labels such as kind, status, outcome, role, and error category. They cover queue depth and age, +claim and attempt latency, lease expiry, retries, throughput, leadership changes, and member counts. Scope, principal, +and work IDs are not metric labels. Enqueue, claim, execute, commit, and retry are separate spans; retry attempts use span +links rather than pretending to be one uninterrupted trace. + +Logs, spans, work rows, and attempt rows must never contain Source content, prompts, model output, credentials, +authorization headers, or complete secret URLs. Persistent errors use bounded category and code values. + +# Schema and rollout + +Alembic owns a forward-only schema chain. `powercontext server migrate` acquires a database lease and upgrades through +the packaged head. A new database starts at the baseline; a known complete legacy schema is validated, receives only +recognized expansions, and is stamped before the ledger migration. Unknown or partially installed schemas are rejected. +Single-node startup runs the same migration path automatically; distributed roles only validate the current revision. + +Schema evolution follows expand, mixed-version deployment, then contract. Release N+1 must read N and N+1 layouts, and +destructive removal waits until N+2. Workers support the current and previous payload during an actual mixed-version +transition. `emit_payload_version` stays on the old format until old Workers drain. + +The deployment sequence is migrate, Workers, Schedulers, then APIs. Rollback reverses that order and drains any newer +payload before an older Worker is restored. The bridge release first puts `single_node/all` on the Work Ledger; only +after the APScheduler execution path is absent may the same database be served by multiple roles. The old sidecar is an +operator backup artifact and is not deleted automatically. + +# Validation + +Acceptance requires round-robin HTTP, Dashboard API, and stateless MCP tests across two API replicas; overlapping work +on different lanes and serialization on one lane; manual/scheduled deduplication; Worker crash points before claim, +during provider execution, during final commit, and after commit; Scheduler takeover fencing; retry, cancellation, +operator recovery, retention, and privacy tests; and real multi-process OceanBase 4.3.5 tests. Golden schema/payload +fixtures cover mixed versions and upgrade/rollback. SQLite and seekDB remain supported only for single-node regression +tests. + +# Alternatives rejected + +- Redis or Kafka would add another durable truth and a cross-system commit problem before throughput requires it. +- A public Queue/Coordinator SPI would freeze multiple coordination semantics before any second implementation exists. +- `GET_LOCK()` is session-scoped and unsafe as a pooled-connection fencing primitive. +- Keeping APScheduler for local mode would preserve two subtly different execution and recovery protocols. +- Exactly-once provider execution is not achievable across external calls; the enforceable contract is at-least-once + execution with at-most-one fenced database commit. diff --git a/docs/zh/development/core-protocol.md b/docs/zh/development/core-protocol.md index 914f4e83e0..934f839f96 100644 --- a/docs/zh/development/core-protocol.md +++ b/docs/zh/development/core-protocol.md @@ -93,7 +93,8 @@ service 负责领域行为。 `Trigger` 是基于 signal 和先前 state 的策略。它返回 `PolicyTransition`,其中包含下一状态和零个或多个 action。 Trigger 不应打开存储、调度自身或执行它选择的 action。 -APScheduler 属于 Builtin runtime 的生命周期,负责决定何时评估策略。Trigger 只解释当前 signal 的含义。 +持久化 Scheduler 属于 Builtin runtime 生命周期,负责决定何时评估策略并把任务写入数据库 ledger。Trigger 只解释 +当前 signal 的含义;带 fence 的 Worker 执行被选择的 action。 ## 职责边界 @@ -104,7 +105,7 @@ APScheduler 属于 Builtin runtime 的生命周期,负责决定何时评估策 | 基于环境变量的进程配置 | `powercontext.client.settings`、`powercontext.server.settings` | | HTTP 生命周期与可选 MCP transport | `powercontext.server` | | Provider 相关的生成和 embedding | 推理集成 | -| 数据库和 scheduler 资源生命周期 | 应用入口或 Builtin runtime instance | +| 数据库、Scheduler 和 Worker 资源生命周期 | 应用入口或 Builtin runtime instance | Core model 使用 Pydantic `BaseModel`。只有存在真实领域约束时才增加 validator。对于普通存储字段,不需要包装 property;模型和协议已经能表达边界时,也不需要自定义 JSON value 层级或第二套 definition 对象。 diff --git a/docs/zh/development/remote-access-implementation.md b/docs/zh/development/remote-access-implementation.md index 8f2b656aa6..6f79afb3d0 100644 --- a/docs/zh/development/remote-access-implementation.md +++ b/docs/zh/development/remote-access-implementation.md @@ -72,8 +72,8 @@ export POWERCONTEXT_SERVER_DATABASE_URL="mysql+aoceanbase://user:password@host:2 inference 配置见[配置 Pydantic AI 推理](pydantic-ai-inference.md)。 设置 `POWERCONTEXT_SERVER_RUNTIME_SCHEDULE_SECONDS` 可以按持久化 interval 处理待消费的 Source window。 -定时 job 使用 `POWERCONTEXT_HOME/scheduler.db` 作为 SQLite sidecar。调度可以配合任一 application database -使用,但必须配置 generation pipeline。 +定时发现和执行使用主数据库中的 Work Ledger。`single_node/all` 模式内嵌 Scheduler 与 Worker;分布式 +OceanBase 部署将它们作为独立角色运行。Worker 必须配置 generation pipeline。 ## HTTP 接口 @@ -87,6 +87,7 @@ inference 配置见[配置 Pydantic AI 推理](pydantic-ai-inference.md)。 | Capabilities | source type、Artifact family、extraction、search mode | | Sources | capture 持久化 content evidence | | Memory | flush 待处理 Source、remember 显式 entry、search | +| Operations | 查询、列举、取消和重试持久后台任务 | | Memory entries | list、get、revise、retire | | History | list Memory change | diff --git a/docs/zh/docs/develop/http-api.md b/docs/zh/docs/develop/http-api.md index c3725619df..bb5e672eaa 100644 --- a/docs/zh/docs/develop/http-api.md +++ b/docs/zh/docs/develop/http-api.md @@ -86,6 +86,25 @@ curl --fail \ "$POWERCONTEXT_URL/v1/memory/search" ``` +## 通过持久 Operation 执行 flush + +Memory flush 可以在当前请求内完成,也可以由任意 Worker 继续执行。不希望等待时,显式请求立即返回句柄: + +```bash +curl --fail-with-body --include \ + --request POST \ + --header 'Content-Type: application/json' \ + --header 'Prefer: respond-async' \ + --header "$POWERCONTEXT_AUTH_HEADER" \ + --data '{"scope_id":"project:example"}' \ + "$POWERCONTEXT_URL/v1/memory/flush" +``` + +HTTP `200` 返回已经完成的 `FlushMemoryResponse`;HTTP `202` 返回 operation ID,并携带相对 `Location` 和 +`Retry-After` header。`Prefer: wait=N` 最多等待 30 秒。通过 `GET /v1/operations/{operation_id}` 轮询;取消 queued +或 running operation,以及恢复被阻塞的 failed operation 时,必须把响应中的 `state_version` 作为 +`expected_version` 传回。同一逻辑窗口失败后,后续 flush 返回 `409 operation_blocked`,直到 operator retry 或 cancel。 + ## Scope 内的操作提示词 操作提示词使用 `family=prompt`,遵循 Artifact 的 Scope 权限边界。由于 Prompt 会影响整个 Scope 的运行行为,启用 @@ -192,6 +211,7 @@ Principal,并注入 Authorization Provider。HTTP 与 MCP 使用同一个策 | 外部 Skill | `/v1/external-skills/*` | 扫描已配置 target,解析或导入 package | | Handoff Report | `/v1/handoff-reports/*` | 按 Scope selection 生成只读报告 | | 统计 | `/v1/stats` | 读取指定 scope 的使用统计 | +| 持久 Operation | `/v1/operations/*` | 检查、列出、取消或恢复 Memory/Experience 后台任务 | 创建 Artifact 使用 `POST /v1/scopes/{scope_id}/artifacts`,在请求体中指定 `family`。 Profile 的候选审核复用 `/v1/candidates/*`。 @@ -223,6 +243,7 @@ Atomic Memory 正文最多为 8192 个 UTF-8 字节,超过限制返回 HTTP `4 | --- | --- | | `401` | Server 要求有效的 Bearer token | | `403` | 已认证 Principal 无权对目标资源执行请求的 action | +| `429` | 共享请求窗口已耗尽;按 `Retry-After` 等待 | | `404` | 请求的不可变值不存在 | | `409` | 请求与当前不可变状态或 expected version 冲突 | | `413` | 选中的 Handoff Report 超过输出限制 | diff --git a/docs/zh/docs/develop/interfaces.md b/docs/zh/docs/develop/interfaces.md index f299c65a2a..29f9048e54 100644 --- a/docs/zh/docs/develop/interfaces.md +++ b/docs/zh/docs/develop/interfaces.md @@ -216,12 +216,12 @@ selector;Experience 孵化继续使用独立的游标和定时任务。 ## 后台 Experience 孵化 Integration 可以把已完成任务采集为 metadata 含 `"kind": "task-outcome"` 的 Content Source。启用 -Experience schedule 后,APScheduler 会扫描有上限的 Source window,并让配置好的 schema-bound pipeline -生成可复用的 situation、action、outcome 和 lesson。每条 proposal 都引用精确 Source,并以 pending -Experience Candidate 进入 Review Inbox。 +Experience schedule 后,持久化 Scheduler 扫描有上限的 Source window 并写入带版本任务;带 fence 的 Worker 再让 +配置好的 schema-bound pipeline 生成可复用的 situation、action、outcome 和 lesson。每条 proposal 都引用精确 Source, +并以 pending Experience Candidate 进入 Review Inbox。 -Experience 孵化使用独立于 Memory extraction 的持久化 Source cursor。Candidate 写入和 cursor 推进会在同一 -事务提交;generation 或写入失败时,该 window 保留给下次重试。普通 Prompt Source 不是 Task Outcome, +Experience 孵化使用独立于 Memory extraction 的持久化 Source cursor。Candidate 写入、cursor 推进和 Work success +会在同一事务提交;generation 或写入失败时,该 window 保留给下次重试。普通 Prompt Source 不是 Task Outcome, 不会进入这个 job。 后台流程止于审核边界:它不会批准 Experience、把 pending 内容放入 PreparedContext、派生 managed Skill、 diff --git a/docs/zh/docs/operate/database-migrations.md b/docs/zh/docs/operate/database-migrations.md index 721787250c..74fe42f660 100644 --- a/docs/zh/docs/operate/database-migrations.md +++ b/docs/zh/docs/operate/database-migrations.md @@ -14,8 +14,8 @@ description: 了解数据库迁移命令、备份策略、服务启停及当前 完整 Server 框架登记了两个真实安装包来源: -- **1.3.0:**`s1300 → s1400 → s1401 → s1402 → v1_4_1_004`。 -- **1.4.0:**通过独立冻结的来源桥接,执行 `r1400 → s1400 → s1401 → s1402 → v1_4_1_004`。 +- **1.3.0:**`s1300 → s1400 → s1401 → s1402 → v1_4_1_004 → v1_4_1_005`。 +- **1.4.0:**通过独立冻结的来源桥接,执行 `r1400 → s1400 → s1401 → s1402 → v1_4_1_004 → v1_4_1_005`。 应用发布版本与 schema revision 分别编号。1.4.0 发行包不等于 `s1400` 检查点:其 Candidate 结构已经是当前格式, 但 Topic 尝试状态仍需登记的桥接。两条路径保留既有引用和历史,到达相同的受管目标结构;原有资源摘要分别保留, @@ -44,6 +44,13 @@ payload 身份及待处理 intent。兼容的当前任务原样保留,不套 修订 `v1_4_1_004` 将 `pc_access_owner_recovery` 纳入管理,用于持久保存 Artifact 和 Candidate 的所有权写入意图。两条已登记发布源路径及向量配置均包含此表。已有受管数据库需要显式维护,新数据库创建已登记的空表。待恢复意图在 Server 重启后保留原有 deployment、Principal 与幂等身份;历史修订资源及校验和保持不变。未登记布局不会仅凭表名被自动接纳。此登记不代表已通过安装发布版或外部 Provider 的升级验收。 +修订 `v1_4_1_005` 登记持久化 Work Ledger 及其调度协调表:`pc_work_lanes`、`pc_work_items`、`pc_work_keys`、 +`pc_work_attempts`、`pc_scheduler_leases`、`pc_scheduler_scans`、`pc_runtime_members`,以及共用的 +`pc_rate_limit_windows`。账本执行是至少一次语义,并以单调 fence token 保护提交;提交至多一次,因此重试、被 +取代或已取消的尝试不会重复发布结果,也不会释放其他 worker 的逻辑键。两条已登记发布源路径及向量配置均包含这些 +表;已有受管数据库需要显式维护,新数据库通过已登记的初始化流程创建。历史修订资源、已登记布局及既有校验和保持 +不变。此登记不代表已通过安装发布版或外部 Provider 的升级验收。 + [可移植归档恢复](portable-archive.md) 中断后,普通启动和 worker 会继续拒绝未完成的检索投影。 请使用同一个通过校验的归档,重新执行 `powercontext archive restore --yes`。 仅此显式恢复路径可在验证已登记结构和配置的向量 profile 后打开投影尚未完整的数据库,且不启动 worker。 diff --git a/docs/zh/docs/operate/deploy-server.md b/docs/zh/docs/operate/deploy-server.md index 443a1347e4..28072ee468 100644 --- a/docs/zh/docs/operate/deploy-server.md +++ b/docs/zh/docs/operate/deploy-server.md @@ -86,8 +86,8 @@ export POWERCONTEXT_HOME=/srv/powercontext powercontext server run ``` -运行进程必须能创建和更新该目录。默认 SQLite 数据库和 scheduler 状态都保存在这里。服务管理器每次重启进程时都应 -提供相同的环境变量。 +运行进程必须能创建和更新该目录。默认 SQLite 数据库也保存持久 Scheduler、Worker lease 和 Operation 状态。 +服务管理器每次重启进程时都应提供相同的环境变量。 当前目录存在 `.env` 时,`server run` 会自动加载。托管部署应导出变量、由服务管理器或容器平台提供,或者显式传入文件, 避免启动行为依赖工作目录: @@ -130,7 +130,42 @@ docker run --rm \ ``` 镜像内部监听 `0.0.0.0:8000`,所以 `--publish` 中的宿主机地址非常重要。容器停止后,named volume 仍会保留 -SQLite 数据库和 scheduler 状态。 +SQLite 数据库和持久 work 状态。 + +## 运行分布式角色 + +分布式模式要求 OceanBase,并且任何角色启动前都必须先迁移 schema。仓库中的 +`docker/compose.distributed.yaml` 提供两 API、两 Scheduler、两 Worker 的拓扑示例。一个 Scheduler 成为 leader, +另一个保持 ready 并可接管;两个 API 和两个 Worker 都会同时工作。 + +分布式 Work Ledger 负责调度 Memory、Experience 和 Profile 工作。分布式 v1 不支持 Topic Memory processing,必须 +保持其周期未设置。该拓扑由 `POWERCONTEXT_SERVER_DEPLOYMENT_ROLE` 拆分进程,因此单机 Artifact Processing +Supervisor 的 role 必须保持为 `all`。 + +通过环境传入 secret 和部署选择,不要把它们写进 Compose: + +```bash +export POWERCONTEXT_SERVER_DATABASE_URL="$OCEANBASE_URL" +export POWERCONTEXT_SERVER_AUTH_TOKEN="$POWERCONTEXT_DEPLOYMENT_TOKEN" +export POWERCONTEXT_GENERATION_MODEL="openai:gpt-4.1-mini" +export OPENAI_API_KEY +docker compose --file docker/compose.distributed.yaml up migrate +docker compose --file docker/compose.distributed.yaml up -d api-a api-b scheduler-a scheduler-b worker-a worker-b +``` + +两个 API 示例分别监听宿主机 8001 和 8002 端口。应在它们前面配置终止 TLS 的 load balancer,并使用不带 session +affinity 的 round-robin;分布式 MCP 是 stateless。示例有意只把模型 credential 交给 Worker,也不向宿主机发布 +Scheduler 或 Worker 的端口。生产环境还应为各角色使用独立最小权限数据库账号,而不是复用示例 URL。 + +同一轮发布的所有副本必须使用相同 behavior revision。升级顺序如下: + +1. 使用专门的 DDL 账号运行 `powercontext server migrate`; +2. 替换 Worker 并等待 readiness; +3. 替换 Scheduler 并确认 leader 可以扫描; +4. 替换 API。 + +旧 Worker 排空前,`POWERCONTEXT_SERVER_COORDINATION_EMIT_PAYLOAD_VERSION` 必须保持旧的受支持值。回滚顺序相反, +并且绝不能让分布式角色连接尚未升级到 packaged revision 的 schema。 ## 启用鉴权 diff --git a/docs/zh/docs/operate/troubleshoot.md b/docs/zh/docs/operate/troubleshoot.md index a619c3b45a..46bcc702e0 100644 --- a/docs/zh/docs/operate/troubleshoot.md +++ b/docs/zh/docs/operate/troubleshoot.md @@ -260,7 +260,7 @@ collation,但不会包含数据库 URL 或凭据。 ```bash obloader -D --csv \ - --table 'pc_scopes,pc_source_journal_heads,pc_sources,pc_artifacts,pc_portable_restore_receipts,pc_source_cursors,pc_memory_source_windows,pc_artifact_processing_leases,pc_artifact_processing_binding_states,pc_artifact_processing_pending,pc_artifact_processing_auto_wave_targets,pc_artifact_processing_sequences,pc_artifact_processing_intents,pc_topic_memory_processing_targets,pc_artifact_processing_schema,pc_artifact_processing_migration_receipts,pc_topic_memory_work_budgets,pc_topic_memory_work_blocks,pc_topic_memory_retrieval_shape,pc_connector_checkpoints,pc_source_definition_manifests,pc_external_skill_registrations,pc_skill_packages,pc_agent_skill_targets,pc_skill_publications,pc_model_usage_daily,pc_recall_token_daily,pc_recall_effort_daily,pc_receipt_migration_review,pc_decision_observations,pc_lexical_projection_versions' \ + --table 'pc_scopes,pc_source_journal_heads,pc_sources,pc_artifacts,pc_portable_restore_receipts,pc_source_cursors,pc_memory_source_windows,pc_artifact_processing_leases,pc_artifact_processing_binding_states,pc_artifact_processing_pending,pc_artifact_processing_auto_wave_targets,pc_artifact_processing_sequences,pc_artifact_processing_intents,pc_topic_memory_processing_targets,pc_artifact_processing_schema,pc_artifact_processing_migration_receipts,pc_topic_memory_work_budgets,pc_topic_memory_work_blocks,pc_topic_memory_retrieval_shape,pc_connector_checkpoints,pc_source_definition_manifests,pc_external_skill_registrations,pc_skill_packages,pc_agent_skill_targets,pc_skill_publications,pc_model_usage_daily,pc_recall_token_daily,pc_recall_effort_daily,pc_receipt_migration_review,pc_decision_observations,pc_lexical_projection_versions,pc_work_lanes,pc_work_items,pc_work_keys,pc_work_attempts' \ -f ``` diff --git a/docs/zh/rfcs/0011_remote_access_architecture.md b/docs/zh/rfcs/0011_remote_access_architecture.md index 9224796f60..ebfe2a348f 100644 --- a/docs/zh/rfcs/0011_remote_access_architecture.md +++ b/docs/zh/rfcs/0011_remote_access_architecture.md @@ -2,6 +2,9 @@ - Start Date: 2026-07-16 - RFC PR: [oceanbase/powercontext#11](https://github.com/oceanbase/powercontext/pull/11) +> **部署边界:** [RFC 1430](1430_distributed_server_workers.md) 定义已接受的持久 Operation、无状态多副本 API、 +> 角色拆分和分布式协调契约。本 RFC 未定义执行与部署语义之处,以 RFC 1430 为准。 + # Summary 本 RFC 提议为 PowerContext 建立远程访问边界。Server 通过 OpenAPI 定义的 HTTP contract 暴露 application diff --git a/docs/zh/rfcs/0019_local_source_memory_runtime.md b/docs/zh/rfcs/0019_local_source_memory_runtime.md index 692001d2d1..636650210d 100644 --- a/docs/zh/rfcs/0019_local_source_memory_runtime.md +++ b/docs/zh/rfcs/0019_local_source_memory_runtime.md @@ -2,6 +2,9 @@ - Start Date: 2026-07-24 - RFC PR: [oceanbase/powercontext#19](https://github.com/oceanbase/powercontext/pull/19) +> **执行更新:** [RFC 1430](1430_distributed_server_workers.md) 使用同一套数据库 Work Ledger 替代 APScheduler +> sidecar 和单进程执行假设,并同时服务本地与分布式模式。本 RFC 仍负责 Source window、cursor、Memory 和领域提交语义。 + > **注意:** PowerContext 当前使用随 builtin 依赖捆绑的 sqlite-vec 提供 SQLite 向量检索。本 RFC 中关于 Vec1 的 > 表述已不再适用,仅作为原始设计记录保留。 diff --git a/docs/zh/rfcs/0020_runtime_backed_memory_remote_access.md b/docs/zh/rfcs/0020_runtime_backed_memory_remote_access.md index 5a47c8ae39..be119de1a5 100644 --- a/docs/zh/rfcs/0020_runtime_backed_memory_remote_access.md +++ b/docs/zh/rfcs/0020_runtime_backed_memory_remote_access.md @@ -6,6 +6,9 @@ > **注意:** PowerContext 当前使用随 builtin 依赖捆绑的 sqlite-vec 提供 SQLite 向量检索。本 RFC 中关于 Vec1 的 > 表述已不再适用,仅作为原始设计记录保留。 +> **执行更新:** [RFC 1430](1430_distributed_server_workers.md) 取代本 RFC 中的单进程调度、仅同步 flush 和 +> 非持久 Operation 假设。本 RFC 继续定义 HTTP 映射和生成契约边界。 + # Summary 本 RFC 定义首个基于 RFC 0011 架构和 RFC 0019 本地 Runtime 的具体远程 API。FastAPI Server 暴露 diff --git a/docs/zh/rfcs/1430_distributed_server_workers.md b/docs/zh/rfcs/1430_distributed_server_workers.md new file mode 100644 index 0000000000..f6049127bd --- /dev/null +++ b/docs/zh/rfcs/1430_distributed_server_workers.md @@ -0,0 +1,199 @@ +- Proposal Name: `distributed_server_workers` +- Start Date: 2026-09-03 +- Tracking Issue: [oceanbase/powercontext#1430](https://github.com/oceanbase/powercontext/issues/1430) +- Related RFCs: [RFC 0011](0011_remote_access_architecture.md)、[RFC 0019](0019_local_source_memory_runtime.md)、 + [RFC 0020](0020_runtime_backed_memory_remote_access.md) 和 [RFC 0046](0046_observability_foundations.md) + +# Summary + +PowerContext 在本地和分布式后台执行中统一使用数据库持久化的 Work Ledger。API 副本无需进程粘性即可入队和查询; +Scheduler 通过 fence 选出一个 leader,并持久保存 keyset 扫描位置;Worker 只领取当前空闲容量的任务,以数据库时间续租, +并安全恢复遗留 attempt。模型调用保持 at-least-once,但同一逻辑 Source window 最多提交一个数据库结果。 + +分布式模式只使用 OceanBase 作为协调后端。同一套 ledger 也替代 `single_node/all` 中的 APScheduler SQLite sidecar, +从而只保留一套需要理解和测试的执行协议。Redis、Kafka、公开 Queue SPI、任意用户代码、DAG、优先级、多区域共识以及 +Connector 自有 checkpoint 不属于本 RFC。 + +# 决策 + +本设计有意减少真相来源: + +- 分布式模式下,OceanBase 同时持有任务状态、租约、cursor 和领域提交。 +- 执行为 at-least-once。崩溃后 provider 调用可能重复,但 fence 和领域 cursor 会阻止旧 attempt 提交。 +- Memory activation 和 Experience incubation 首先接入 ledger;后续 handler 的领域 checkpoint 仍由各自模块持有。 +- 默认保持 `single_node/all`;分布式进程必须且只能选择 `api`、`scheduler` 或 `worker`。 +- Work payload 只保存带版本的引用和数字窗口边界,不保存 Source 正文、prompt、模型响应、credential 或原始异常。 + +这避免了数据库、broker 和本地 sidecar 多套协议自然积累的熵。新增复杂度被固化为数据库约束、小型状态机、fencing token、 +启动校验和兼容性声明,而不再依赖“系统只有一个进程”之类的隐性经验。 + +# 架构 + +```text +API / Scheduler + | 短事务:确定窗口、去重、入队 + v +OceanBase Work Ledger + | 短事务:领取 lane head 并签发 lease fence + v +Worker ---- 在事务外执行 provider/model 调用 ----> + | 短事务:验证 fence,提交领域状态 + cursor + work + v +一个逻辑数据库结果 +``` + +Ledger 包含: + +| 表 | 职责 | +| --- | --- | +| `pc_work_items` | 带版本任务、scope、lane sequence、安全 payload、状态、租约、attempt 预算及安全结果/错误 | +| `pc_work_attempts` | append-only owner/fence/时间/结果审计和非敏感 trace identity | +| `pc_work_lanes` | 每个一致性域的严格 sequence 和唯一 active head | +| `pc_work_keys` | 当前逻辑窗口的唯一占位 | +| `pc_scheduler_leases` | Scheduler leader 与 migrator 的 owner、单调 fence 和数据库时间 expiry | +| `pc_scheduler_scans` | 各 discoverer 的下次执行时间和 keyset continuation | +| `pc_runtime_members` | 存活角色以及 schema/payload/behavior 兼容性声明 | +| `pc_rate_limit_windows` | 可选共享固定窗口计数,以 principal 摘要和 policy 为键 | + +Memory 窗口的 logical key 是 handler kind、scope、cursor name、cursor generation 和 previous cursor 的 SHA-256。 +入队时的 high watermark 与 `through` 保存在 payload 中,但不进入 key。因此 manual 与 scheduled discovery 面对同一个 +未推进 cursor 时会加入同一任务;之后到达的 Source 由下一窗口处理。 + +Memory 和 Experience 使用独立 lane(散列前分别为 `memory:{scope}` 与 `experience:{scope}`)。不同 lane 可以并行, +同一 lane 严格按 sequence 推进。终态失败会保留 logical-key 和 lane 占位,直到 operator retry 或 cancel,避免 poison +window 被无限重建。 + +# 状态机 + +```text +queued ----> running ----> succeeded + | |----> retry_wait ----> running + | |----> failed + | `----> cancelling ----> cancelled + `-----------------------------------> cancelled + +failed -- 显式 operator retry --> queued +``` + +取消在 work row 上线性化。如果取消先于最终事务,Worker 丢弃已经准备好的 provider 结果,并把 attempt 完成为 +cancelled;如果成功已经提交,取消返回 `409`。取消某个 operation 不会暂停该 scope 后续的 discovery。 + +# 锁与事务协议 + +基线是 OceanBase 4.3.5 默认 Read Committed。正确性不依赖无锁读取、单条谓词 update、`SKIP LOCKED` 或 +`GET_LOCK()`。所有路径采用统一锁顺序: + +```text +scheduler lease(仅 Scheduler 路径) + -> lane + -> logical key + -> work item + -> domain cursor/head +``` + +稳定存在的 lease/lane row 会被显式锁定。缺失 row 通过唯一键插入,冲突后重试。claim 先做带索引且有上限的候选扫描, +再逐 lane 用短事务领取。Worker 永远不会预取超过空闲执行槽的任务。 + +Scheduler 的 enqueue 和 scan 更新都会重新验证精确 leader owner、fence 与数据库 expiry。Worker heartbeat、失败、 +取消收敛和最终提交都会验证 `(work_id, owner, fence)`。所有 expiry 判断使用数据库的 `CURRENT_TIMESTAMP(6)`; +应用时钟只用于 poll 和 deadline。 + +Provider、Connector、网络和文件系统调用绝不处于数据库事务中。Worker 最终事务先验证 claim 未过期且未取消,随后调用 +handler 的领域 commit。Memory 与 Experience 复用现有 cursor/head CAS。领域写入、cursor 推进、attempt 完成和 +Work success 要么一起提交,要么一起回滚。 + +# Scheduler 与 Worker + +每个 discoverer 持久保存 keyset continuation,每页最多扫描 100 个 scope。崩溃后重复扫描是安全的,因为 logical key +会去重。旧 leader 在另一个 Scheduler 获得更高 fence 后,不能继续 enqueue 或保存 continuation。 + +Worker 只 claim 当前空闲槽。attempt lease 默认 120 秒,每 30 秒续租。过期 attempt 会先写完审计并进入 retry,再由 +更高 fence 重新领取。可重试错误使用 full-jitter 指数退避,默认从 2 秒开始,上限 5 分钟,最多 5 次自动 generation +attempt。无法识别的 payload version 保持在 lane head 可见,并使 Worker readiness 成为 `misconfigured`;不得猜测、 +静默丢弃或跳过。 + +# 进程角色 + +配置新增 `deployment`、`coordination`、`worker`、`operations` 和 `rate_limit` 组。 + +- `single_node/all` 在同一进程运行 API、Scheduler 和 Worker,但仍走统一 ledger。数据库 owner lease 会拒绝误启动的 + 第二个实例。 +- `distributed/api` 暴露 HTTP、Dashboard、MCP、鉴权、共享限流、入队与 operation 查询,不运行 discovery 或后台 + Worker handler。 +- `distributed/scheduler` 只暴露 health 和 metrics,不持有 provider credential,只进行有界 discovery。 +- `distributed/worker` 只暴露 health 和 metrics,仅持有所注册 handler 必需的 provider credential。 + +分布式模式要求 OceanBase,并拒绝 `role=all`、SQLite、seekDB 和显式 host-local External Skill target。所有角色可以 +复用同一镜像,但应使用独立的最小权限数据库账号。DDL 只属于 migrator 账号,分布式角色绝不自动执行。 + +每个进程用 boot-unique member identity 心跳上报 build version、当前 schema/payload range 与非敏感 +`behavior_revision`。Member metadata 不保存 credential、secret URL、authorization 数据或允许离线猜测 secret 的 hash。 + +# HTTP 与 Client 契约 + +`openapi/powercontext.yaml` 仍是唯一来源。`POST /v1/memory/flush` 的行为是: + +- 没有待处理 Source,或在等待预算内完成:`200 FlushMemoryResponse`; +- 仍为 queued、running 或 retry_wait:`202 OperationAccepted`,带相对 `Location` 和 `Retry-After: 2`; +- 同一 logical key 已被失败任务阻塞:`409 operation_blocked`,返回 operation ID。 + +`Prefer: respond-async` 立即返回句柄;`Prefer: wait=N` 在最长 30 秒内等待,默认 10 秒。Operation endpoint 提供授权后 +的 get/list、乐观并发 cancel 和 operator retry。Mutation body 必须包含 `expected_version`,非法或过期转换返回 `409`。 +公开状态为 `queued`、`running`、`retry_wait`、`cancelling`、`succeeded`、`failed`、`cancelled`。内部 maintenance work +不会通过 Operation API 暴露。 + +`PowerContextClient.flush_memory()` 通过 submit + poll 保持原有同步式返回;总 deadline 到期时抛出携带 operation ID 的 +`OperationPendingError`。需要显式控制的调用方使用 `submit_memory_flush()`、`get_operation()`、`list_operations()`、 +`cancel_operation()` 和 `retry_operation()`。 + +Operation endpoint 不投影为 MCP tool。分布式模式强制 FastMCP stateless HTTP,连续请求可以命中不同 API 副本。 +Server-to-client elicitation 和 sampling 关闭;Workstream picker 返回结构化 `needs_selection`。内部 ASGI bridge 会传递 +已经认证的 principal,工具可见性绝不作为授权边界。 + +# 健康、关闭、保留与可观测性 + +Liveness 只表示进程能响应。API readiness 要求 database、schema、authentication policy、membership 和 behavior +兼容;缺失 Scheduler 或 Worker 只让 API degraded,仍允许持久入队和读取。健康的 Scheduler standby 也是 ready。 +缺少必需 provider 或 handler version 的 Worker 停止 claim,并报告精确失败项。 + +SIGTERM 时,API 先摘除 readiness;Scheduler 停止 discovery 并条件释放 lease;Worker 停止 claim,继续为在途任务 +heartbeat,并最多 drain 90 秒。非优雅退出由 lease expiry 恢复。 + +成功与取消的 work/attempt 默认保留 30 天。持久 maintenance handler 每批最多删除 500 条,并清理过期限流窗口。 +阻塞失败任务在 operator 处理前不得删除。未来 Source retention 必须把所有非终态 work window 当作 retention root。 + +Metric 只使用 kind、status、outcome、role 和 error category 等有界标签,覆盖 queue depth/age、claim/attempt latency、 +lease expiry、retry、throughput、leader change 和 member count。Scope、principal 和 work ID 不能作为 metric label。 +Enqueue、claim、execute、commit 和 retry 使用独立 span;retry attempt 通过 span link 关联。 + +日志、span、work row 与 attempt row 均不得包含 Source 正文、prompt、模型输出、credential、authorization header 或完整 +secret URL。持久错误只保存有界 category 与 code。 + +# Schema 与发布 + +Alembic 管理 forward-only schema chain。`powercontext server migrate` 获取数据库 lease 后升级到 packaged head。新库从 +baseline 创建;已知且完整的 legacy schema 先校验,只应用明确识别的扩展,再 stamp 并创建 ledger。未知或部分安装的 +schema 直接拒绝。单机启动自动运行同一迁移链;分布式角色只读校验当前 revision。 + +Schema 遵循 expand、mixed-version deploy、contract。N+1 必须能读取 N/N+1 layout,破坏性删除最早在 N+2。 +实际混部期间 Worker 支持当前和前一版 payload;`emit_payload_version` 在旧 Worker 排空前继续发送旧格式。 + +部署顺序是 migrate、Worker、Scheduler、API;回滚顺序相反,并在恢复旧 Worker 前排空新版 payload。Bridge release +先让 `single_node/all` 全部走 Work Ledger;只有 APScheduler 执行路径完全退出后,才允许同一数据库扩为多角色。 +旧 sidecar 只作为 operator 备份产物保留,系统不自动删除。 + +# 验收 + +验收覆盖:两个 API 副本间 round-robin 的 HTTP、Dashboard API 与 stateless MCP;不同 lane 并行与同 lane 串行; +manual/scheduled 去重;Worker 在 claim 前、provider 执行中、最终事务中和 commit 后的崩溃恢复;Scheduler takeover +fencing;retry、cancel、operator recovery、retention 与隐私;以及 OceanBase 4.3.5 真实多进程测试。Golden +schema/payload fixture 覆盖混合版本与升级回滚。SQLite 和 seekDB 只保留单机回归支持。 + +# 未采用方案 + +- Redis 或 Kafka 会在吞吐尚不需要时引入第二个持久真相和跨系统提交问题。 +- 公开 Queue/Coordinator SPI 会在不存在第二种实现时过早冻结多套协调语义。 +- `GET_LOCK()` 是 session 级锁,不适合作为连接池环境中的 fencing 原语。 +- 单机继续使用 APScheduler 会保留两套细微不同的执行和恢复协议。 +- 外部 provider 不可能实现真正 exactly-once;可执行的契约是 at-least-once execution 加 at-most-one fenced + database commit。 diff --git a/integrations/dsh/plugins/powercontext/lib/index.js b/integrations/dsh/plugins/powercontext/lib/index.js index c1df7f1d68..b28c360f58 100644 --- a/integrations/dsh/plugins/powercontext/lib/index.js +++ b/integrations/dsh/plugins/powercontext/lib/index.js @@ -760,8 +760,8 @@ const OPERATIONS = { scopeMode: "current", pathParameters: [], queryParams: [], - headerParams: [], - successStatuses: [200], + headerParams: ["Prefer"], + successStatuses: [200, 202], emptyStatuses: [] }, remember_memory: { @@ -1253,6 +1253,56 @@ const OPERATIONS = { successStatuses: [200], emptyStatuses: [] }, + list_operations: { + method: "GET", + path: "/v1/operations", + location: "query", + scopeMode: "none", + pathParameters: [], + queryParams: [ + "scope_id", + "kind", + "status", + "cursor", + "limit" + ], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, + get_operation: { + method: "GET", + path: "/v1/operations/{operation_id}", + location: null, + scopeMode: "none", + pathParameters: ["operation_id"], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, + cancel_operation: { + method: "POST", + path: "/v1/operations/{operation_id}/cancel", + location: "body", + scopeMode: "none", + pathParameters: ["operation_id"], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, + retry_operation: { + method: "POST", + path: "/v1/operations/{operation_id}/retry", + location: "body", + scopeMode: "none", + pathParameters: ["operation_id"], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, get_handoff_report: { method: "POST", path: "/v1/handoff-reports/get", diff --git a/release/upgrade-support.json b/release/upgrade-support.json index 52a02d606e..e78eadd4da 100644 --- a/release/upgrade-support.json +++ b/release/upgrade-support.json @@ -3,8 +3,8 @@ "target_version": "1.4.1", "mode": "upgrade-required", "schema_review": { - "persistence_sha256": "99d448c2b9f3a3ef23a5e348a747d9e277c4cca13ad3bee475b26d10faabac98", - "rationale": "The reusable Server migration framework registers actual PowerContext 1.3.0 through s1300 -> s1400 -> s1401 -> s1402 and actual 1.4.0 through the separate immutable r1400 -> s1400 -> s1401 -> s1402 source bridge. Original 1.3 resources and checksums remain byte-identical. Real 1.4.0 SQLite, embedded seekDB and OceanBase structures were captured from the official wheel in all twelve vector/Code configurations; provenance and exact source layouts are packaged with the bridge. The 1.4 bridge preserves current Candidate, Artifact and task data, adds the missing Topic budget/block structure, revokes old lease fences and joins the existing archive/portable-storage path. Empty retained tables align the final schema without inventing 1.3 Candidate history. Source histories retain their identity through subsequent upgrades and recovery; shared head names never authorize adopting unknown layouts. Vector profiles remain bound to historical model, profile ID, dimension, unit normalization and L2 distance, with no provider calls during maintenance. Complete schema, domain, projection, profile and retained-history checks precede startup and partial maintenance. Local SQLite and embedded seekDB startup reject an observable active maintenance lock through a read-only probe. SQLite runtime admission validates the default adjacent journal, rejecting unfinished full or partial maintenance, changed recorded revision checksums and missing required retained tables. Absent journals do not invent retention obligations; custom evidence directories require explicit operator verification and remote coordinator locks remain subject to deployment orchestration. Native read-only readiness uses a consistent snapshot and does not acquire Atomic Memory head locks; normal business transaction isolation is unchanged. Offline projection repairs retain the original locked state comparison and write-side CAS protection. Target locks, backup choices, original recovery evidence and explicit remote acknowledgments remain enforced. The installed-artifact catalog retains twelve 1.3.0 source/backend/vector/Code combinations. Embedded seekDB uses pylibseekdb 1.4.0.post2 or newer with the recovered-transaction lock fix. Main and publication require all twelve configurations, including embedded seekDB full-server-code and full-server-vector-code. No configuration is waived; incomplete, failed or skipped evidence rejects acceptance. The seekdb-code diagnostic selection cannot authorize publication. The registered 1.4.0 bridge remains implemented but is outside the CI upgrade acceptance matrix; passing this gate does not certify that path. The gate preserves source-specific Processing counters and isolates OceanBase source databases. Any failed, skipped or missing automatic case rejects publication. The policy remains upgrade-required until the exact target artifact passes the required automatic matrix and the reviewed support declaration is updated. Captured source structure, local tests and checkpoint replay are distinct from installed-release acceptance, native interruption/observer recovery and backup restoration. The Candidate generation prototype observes actual SQLite/seekDB/OceanBase migration checkpoints outside the production bundle; it does not replace frozen registrations or weaken recovery checks. Existing target schema and data formats, including batched persistence interfaces, remain governed by the shared registered path. The synchronized master adds blank-text validation at Source creation and managed worker logging. It changes neither registered table layout nor migration data encodings; existing stored Source values are not rewritten. The source bridges and frozen history remain unchanged. Compatibility commands identify Server-owned storage before opening a writable legacy profile and delegate source admission to the shared framework, including actual 1.4.0 and rejection of unknown layouts. Partial Processing and Atomic Memory projection maintenance verify the original recorded revision checksums; native admission also validates typed journal identity and its completed position against the database revision. Persisted vector profiles reject unsupported partial maintenance before plans, backups or operation evidence, including when the supplied configuration omits embeddings. These are admission-only changes; frozen migration resources, registered layouts, historical checksums and data transformations remain unchanged. PowerContext 1.4.1 assigns release-based presentation names v1_4_1_001 through v1_4_1_003 to the existing shared upgrade steps; the 1.3.0 and 1.4.0 source baselines remain independently registered. Existing revision IDs, bundle and revision checksums, accepted plan identities and journal schemas are unchanged. New Server revisions use software-version-plus-sequence names directly; software releases without storage changes may reuse the same database revision. Naming does not grant acceptance or replace source registration. Full Server status, plan, apply and verify reject unfinished Processing configuration and Atomic Memory projection journals through the same typed admission check used by partial maintenance and SQLite startup. The owning domain runner can still resume its own journal. This only changes admission; frozen revisions, journal formats, database layouts and data transformations remain unchanged. Revision v1_4_1_004 registers the pc_access_owner_recovery outbox on the shared Server chain for both installed-release sources and all registered vector/Code profiles. It creates an empty table through explicit maintenance for existing managed databases and through the registered bootstrap for fresh databases. Earlier revision resources, fingerprints, checksums and existing domain row encodings remain unchanged. External relationship writers use version-1 Artifact and Candidate intents committed with domain data; replay retains the original deployment, principal, resource or target, and idempotency identity. Stable operation and audit event identities prevent lost acknowledgements from duplicating ownership or audit. Conflicting intents are rejected and unknown historical ownership is not inferred. Unregistered outbox layouts are not adopted merely from a table name. This registration does not establish installed-release or external-Provider upgrade acceptance. The policy remains upgrade-required with the full automatic matrix and publication restrictions described above." + "persistence_sha256": "581696bc537f224b2642ae8080671b02ce2e2fe4e6b7b53ba0d2bcfc805b5b44", + "rationale": "The reusable Server migration framework registers actual PowerContext 1.3.0 through s1300 -> s1400 -> s1401 -> s1402 and actual 1.4.0 through the separate immutable r1400 -> s1400 -> s1401 -> s1402 source bridge. Original 1.3 resources and checksums remain byte-identical. Real 1.4.0 SQLite, embedded seekDB and OceanBase structures were captured from the official wheel in all twelve vector/Code configurations; provenance and exact source layouts are packaged with the bridge. The 1.4 bridge preserves current Candidate, Artifact and task data, adds the missing Topic budget/block structure, revokes old lease fences and joins the existing archive/portable-storage path. Empty retained tables align the final schema without inventing 1.3 Candidate history. Source histories retain their identity through subsequent upgrades and recovery; shared head names never authorize adopting unknown layouts. Vector profiles remain bound to historical model, profile ID, dimension, unit normalization and L2 distance, with no provider calls during maintenance. Complete schema, domain, projection, profile and retained-history checks precede startup and partial maintenance. Local SQLite and embedded seekDB startup reject an observable active maintenance lock through a read-only probe. SQLite runtime admission validates the default adjacent journal, rejecting unfinished full or partial maintenance, changed recorded revision checksums and missing required retained tables. Absent journals do not invent retention obligations; custom evidence directories require explicit operator verification and remote coordinator locks remain subject to deployment orchestration. Native read-only readiness uses a consistent snapshot and does not acquire Atomic Memory head locks; normal business transaction isolation is unchanged. Offline projection repairs retain the original locked state comparison and write-side CAS protection. Target locks, backup choices, original recovery evidence and explicit remote acknowledgments remain enforced. The installed-artifact catalog retains twelve 1.3.0 source/backend/vector/Code combinations. Embedded seekDB uses pylibseekdb 1.4.0.post2 or newer with the recovered-transaction lock fix. Main and publication require all twelve configurations, including embedded seekDB full-server-code and full-server-vector-code. No configuration is waived; incomplete, failed or skipped evidence rejects acceptance. The seekdb-code diagnostic selection cannot authorize publication. The registered 1.4.0 bridge remains implemented but is outside the CI upgrade acceptance matrix; passing this gate does not certify that path. The gate preserves source-specific Processing counters and isolates OceanBase source databases. Any failed, skipped or missing automatic case rejects publication. The policy remains upgrade-required until the exact target artifact passes the required automatic matrix and the reviewed support declaration is updated. Captured source structure, local tests and checkpoint replay are distinct from installed-release acceptance, native interruption/observer recovery and backup restoration. The Candidate generation prototype observes actual SQLite/seekDB/OceanBase migration checkpoints outside the production bundle; it does not replace frozen registrations or weaken recovery checks. Existing target schema and data formats, including batched persistence interfaces, remain governed by the shared registered path. The synchronized master adds blank-text validation at Source creation and managed worker logging. It changes neither registered table layout nor migration data encodings; existing stored Source values are not rewritten. The source bridges and frozen history remain unchanged. Compatibility commands identify Server-owned storage before opening a writable legacy profile and delegate source admission to the shared framework, including actual 1.4.0 and rejection of unknown layouts. Partial Processing and Atomic Memory projection maintenance verify the original recorded revision checksums; native admission also validates typed journal identity and its completed position against the database revision. Persisted vector profiles reject unsupported partial maintenance before plans, backups or operation evidence, including when the supplied configuration omits embeddings. These are admission-only changes; frozen migration resources, registered layouts, historical checksums and data transformations remain unchanged. PowerContext 1.4.1 assigns release-based presentation names v1_4_1_001 through v1_4_1_003 to the existing shared upgrade steps; the 1.3.0 and 1.4.0 source baselines remain independently registered. Existing revision IDs, bundle and revision checksums, accepted plan identities and journal schemas are unchanged. New Server revisions use software-version-plus-sequence names directly; software releases without storage changes may reuse the same database revision. Naming does not grant acceptance or replace source registration. Full Server status, plan, apply and verify reject unfinished Processing configuration and Atomic Memory projection journals through the same typed admission check used by partial maintenance and SQLite startup. The owning domain runner can still resume its own journal. This only changes admission; frozen revisions, journal formats, database layouts and data transformations remain unchanged. Revision v1_4_1_004 registers the pc_access_owner_recovery outbox on the shared Server chain for both installed-release sources and all registered vector/Code profiles. It creates an empty table through explicit maintenance for existing managed databases and through the registered bootstrap for fresh databases. Earlier revision resources, fingerprints, checksums and existing domain row encodings remain unchanged. External relationship writers use version-1 Artifact and Candidate intents committed with domain data; replay retains the original deployment, principal, resource or target, and idempotency identity. Stable operation and audit event identities prevent lost acknowledgements from duplicating ownership or audit. Conflicting intents are rejected and unknown historical ownership is not inferred. Unregistered outbox layouts are not adopted merely from a table name. This registration does not establish installed-release or external-Provider upgrade acceptance. The policy remains upgrade-required with the full automatic matrix and publication restrictions described above. The rebased distributed Work Ledger appends revision v1_4_1_005 to the shared Server chain: eight work and coordination tables registered in the manifest with frozen SQLite and MySQL DDL compiled from the current metadata, a schema fingerprint measured by replaying s1300 through v1_4_1_005, a named unique constraint on pc_work_keys.work_id so the frozen layout can register and verify it, and the four durable ledger tables listed in the obloader parent-free restore layer. The two database primitives the ledger claims on (naive-UTC database_now and conflict-tolerant insert_if_absent) are restored beside master's reworked engine layer with their semantics documented. Unregistered Alembic scaffolding that no runner executes is removed. No existing table layout, frozen resource, source bridge or historical checksum changes; the policy remains upgrade-required." }, "cases": [], "automatic_sources": { diff --git a/src/powercontext/builtin/artifacts/profile/service.py b/src/powercontext/builtin/artifacts/profile/service.py index 2aac6a888c..bdfc56d610 100644 --- a/src/powercontext/builtin/artifacts/profile/service.py +++ b/src/powercontext/builtin/artifacts/profile/service.py @@ -21,6 +21,7 @@ import logging from collections.abc import Awaitable, Callable from contextlib import AbstractAsyncContextManager, nullcontext +from dataclasses import dataclass from datetime import UTC, datetime from typing import TYPE_CHECKING, Any, Protocol from uuid import uuid4 @@ -46,7 +47,7 @@ ) from powercontext.builtin.persistence.artifacts import ArtifactRepository from powercontext.builtin.persistence.candidates import CandidateRepository -from powercontext.builtin.persistence.cursors import SourceCursorRepository +from powercontext.builtin.persistence.cursors import SourceCursorRepository, StoredSourceCursor from powercontext.builtin.persistence.database import AsyncDatabase, is_transaction_contention from powercontext.builtin.persistence.errors import GenerationConflictError, RepositoryNotFoundError from powercontext.builtin.persistence.processing_intents import ArtifactProcessingIntentRepository @@ -86,6 +87,30 @@ def _profile_prompt_refs(): return () if selection is None or selection.artifact is None else (selection.artifact,) +@dataclass(frozen=True) +class PreparedProfileFlush: + """One Profile window prepared outside its fenced commit transaction. + + ``result`` carries a terminal outcome that requires no database write. + Otherwise the snapshot must be applied through ``RelationalProfileService. + commit_flush`` on a caller-provided connection, so the Profile, its cursor + and the caller's own coordination state commit or roll back together. + """ + + scope_id: str + after: int + high: int + evidence_count: int + result: ProfileFlushResult | None + policy: ProfilePolicy | None = None + cursor: StoredSourceCursor | None = None + current: Profile | None = None + evidence: tuple[Any, ...] = () + prompt_refs: tuple[Any, ...] = () + markdown: str | None = None + through: int = 0 + + class RelationalProfileService: def __init__( self, @@ -181,6 +206,8 @@ async def flush( processing: ScopeInvocation | None = None, authorize_commit: Callable[[AsyncConnection, Profile | None], Awaitable[None]] | None = None, ) -> ProfileFlushResult: + """Prepare one window, generate outside transactions, then commit fenced.""" + async with self.operation_context(): if self._prompt_service is None: return await self._flush( @@ -201,16 +228,45 @@ async def flush( authorize_commit=authorize_commit, ) - async def _flush( # noqa: C901 + async def prepare_flush( self, scope_id: str, *, high_watermark: int | None = None, authorize_snapshot: Callable[[Profile | None], Awaitable[None]] | None = None, - on_commit=None, processing: ScopeInvocation | None = None, - authorize_commit: Callable[[AsyncConnection, Profile | None], Awaitable[None]] | None = None, - ) -> ProfileFlushResult: + ) -> PreparedProfileFlush: + """Snapshot one window and generate without committing anything. + + The returned snapshot carries either a terminal ``result`` or the + prepared window for ``commit_flush``. Generation runs outside any + database transaction; nothing is written until ``commit_flush``. + """ + + async with self.operation_context(): + if self._prompt_service is None: + return await self._prepare_flush( + scope_id, + high_watermark=high_watermark, + authorize_snapshot=authorize_snapshot, + processing=processing, + ) + async with self._prompt_service.bind(scope_id, "profile.generate"): + return await self._prepare_flush( + scope_id, + high_watermark=high_watermark, + authorize_snapshot=authorize_snapshot, + processing=processing, + ) + + async def _prepare_flush( + self, + scope_id: str, + *, + high_watermark: int | None, + authorize_snapshot: Callable[[Profile | None], Awaitable[None]] | None, + processing: ScopeInvocation | None, + ) -> PreparedProfileFlush: async with self.database.transaction() as connection: if processing is not None: await processing.start(connection) @@ -223,11 +279,25 @@ async def _flush( # noqa: C901 if policy is None or not policy.generation_enabled: if processing is not None: await processing.complete(connection, remaining_work=after < high) - return ProfileFlushResult(status="disabled", **base) + return PreparedProfileFlush( + scope_id=scope_id, + after=after, + high=high, + evidence_count=0, + result=ProfileFlushResult(status="disabled", **base), + ) if policy.pending_candidate_id is not None: if processing is not None: await processing.complete(connection, remaining_work=True) - return ProfileFlushResult(status="review_pending", candidate_id=policy.pending_candidate_id, **base) + return PreparedProfileFlush( + scope_id=scope_id, + after=after, + high=high, + evidence_count=0, + result=ProfileFlushResult( + status="review_pending", candidate_id=policy.pending_candidate_id, **base + ), + ) current = await self.latest(connection, scope_id) prompt_refs = _profile_prompt_refs() reserved_artifacts = (0 if current is None else 1) + len(prompt_refs) @@ -240,121 +310,200 @@ async def _flush( # noqa: C901 if not window and processing is not None: await processing.complete(connection, remaining_work=False) if not window: - return ProfileFlushResult(status="noop", **base) + return PreparedProfileFlush( + scope_id=scope_id, + after=after, + high=high, + evidence_count=0, + result=ProfileFlushResult(status="noop", **base), + ) # Authorize the exact Head used by generation. The commit CAS below also # rejects a Head created or changed after this snapshot was authorized. if authorize_snapshot is not None: await authorize_snapshot(current) through = window[-1].journal_position evidence = tuple(item for item in window if is_generation_eligible(item.value)) - markdown = None - if evidence: - if self.generator is None: - # Import locally to avoid a cycle through the shared Review service. - from powercontext.builtin.review.generation import GenerationCapabilityUnavailableError - - raise GenerationCapabilityUnavailableError("profile") - markdown = await self.generator.generate( - ProfileGenerationInput( - previous_content=None if current is None else current.content.content, - sources=tuple(item.value.model_dump_json() for item in evidence), - ) + markdown = await self._generate_markdown(current, evidence) + return PreparedProfileFlush( + scope_id=scope_id, + after=after, + high=high, + evidence_count=len(evidence), + result=None, + policy=policy, + cursor=cursor, + current=current, + evidence=evidence, + prompt_refs=prompt_refs, + markdown=markdown, + through=through, + ) + + async def _generate_markdown(self, current: Profile | None, evidence: tuple[Any, ...]) -> str | None: + """Run one generation outside any transaction; ``None`` keeps the window.""" + + if not evidence: + return None + if self.generator is None: + # Import locally to avoid a cycle through the shared Review service. + from powercontext.builtin.review.generation import GenerationCapabilityUnavailableError + + raise GenerationCapabilityUnavailableError("profile") + markdown = await self.generator.generate( + ProfileGenerationInput( + previous_content=None if current is None else current.content.content, + sources=tuple(item.value.model_dump_json() for item in evidence), ) - if markdown is not None: - try: - markdown = normalize_profile_markdown(markdown) - except ValueError as error: - from powercontext.builtin.inference import InvalidInferenceOutputError - - raise InvalidInferenceOutputError("profile-generate", "invalid Markdown") from error - if current is not None and current.content.content == markdown: - markdown = None + ) + if markdown is None: + return None try: - async with self.database.transaction() as connection: - if processing is not None: - await processing.guard(connection) - locked = await self.policies.get(connection, scope_id, for_update=True) - actual_cursor = await self.cursors.load( - connection, scope_id, PROFILE_SOURCE_WINDOW_BINDING, for_update=True - ) - actual = await self.latest(connection, scope_id) - if ( - locked != policy - or actual_cursor != cursor - or (None if actual is None else actual.as_ref()) != (None if current is None else current.as_ref()) - ): - raise BaseValueConflictError("profile_processing", (scope_id,)) # noqa: TRY301 - if authorize_commit is not None: - await authorize_commit(connection, current) - # CAS also serializes SQLite, whose SELECT FOR UPDATE is a no-op. - updated = await self.policies.update(connection, policy) - refs = tuple(item.ref for item in evidence) - parents = () if current is None else (current.as_ref(),) - artifacts = (*parents, *prompt_refs) - source_window = SourceWindow(after=after, through=through) - candidate_id = None - candidate = None - artifact = None - if markdown is not None and policy.activation_mode == "review_required": - candidate_id = self.id_factory("candidate") - candidate = await self.candidates.create( - connection, - scope_id, - candidate_id, - "profile", - ProfileCandidateProposal( - content=markdown, - source_window=source_window, + markdown = normalize_profile_markdown(markdown) + except ValueError as error: + from powercontext.builtin.inference import InvalidInferenceOutputError + + raise InvalidInferenceOutputError("profile-generate", "invalid Markdown") from error + if current is not None and current.content.content == markdown: + return None + return markdown + + async def commit_flush( + self, + connection: AsyncConnection, + prepared: PreparedProfileFlush, + /, + *, + authorize_commit: Callable[[AsyncConnection, Profile | None], Awaitable[None]] | None = None, + on_commit=None, + processing: ScopeInvocation | None = None, + ) -> ProfileFlushResult: + """Apply a prepared window on the caller's transaction. + + No transaction is opened here and no conflict is absorbed: the caller + owns the transaction boundary and decides how contention is retried, + which is what lets a Work item share this commit with its ledger write. + """ + + if prepared.result is not None: + raise ValueError("a terminal Profile flush has no commit") # noqa: TRY003 + scope_id = prepared.scope_id + policy = prepared.policy + cursor = prepared.cursor + current = prepared.current + if processing is not None: + await processing.guard(connection) + locked = await self.policies.get(connection, scope_id, for_update=True) + actual_cursor = await self.cursors.load(connection, scope_id, PROFILE_SOURCE_WINDOW_BINDING, for_update=True) + actual = await self.latest(connection, scope_id) + if ( + locked is None + or policy is None + or locked != policy + or actual_cursor != cursor + or (None if actual is None else actual.as_ref()) != (None if current is None else current.as_ref()) + ): + raise BaseValueConflictError("profile_processing", (scope_id,)) + if authorize_commit is not None: + await authorize_commit(connection, current) + # CAS also serializes SQLite, whose SELECT FOR UPDATE is a no-op. + updated = await self.policies.update(connection, policy) + refs = tuple(item.ref for item in prepared.evidence) + parents = () if current is None else (current.as_ref(),) + artifacts = (*parents, *prepared.prompt_refs) + source_window = SourceWindow(after=prepared.after, through=prepared.through) + candidate_id = None + candidate = None + artifact = None + if prepared.markdown is not None and policy.activation_mode == "review_required": + candidate_id = self.id_factory("candidate") + candidate = await self.candidates.create( + connection, + scope_id, + candidate_id, + "profile", + ProfileCandidateProposal( + content=prepared.markdown, + source_window=source_window, + generator_id=PROFILE_SOURCE_WINDOW_BINDING, + generator_version="1", + created_at=datetime.now(UTC), + ), + sources=refs, + artifacts=artifacts, + target=None if current is None else current.as_ref(), + reason="New source evidence", + ) + await self.policies.update(connection, updated, pending_candidate_id=candidate_id) + else: + if prepared.markdown is not None: + draft = ProfileDraft( + content=ProfileContent( + content=prepared.markdown, + generation=ProfileGeneration( + mode="automatic", + created_at=datetime.now(UTC), generator_id=PROFILE_SOURCE_WINDOW_BINDING, generator_version="1", - created_at=datetime.now(UTC), + source_window=source_window, ), - sources=refs, - artifacts=artifacts, - target=None if current is None else current.as_ref(), - reason="New source evidence", - ) - await self.policies.update(connection, updated, pending_candidate_id=candidate_id) - else: - if markdown is not None: - draft = ProfileDraft( - content=ProfileContent( - content=markdown, - generation=ProfileGeneration( - mode="automatic", - created_at=datetime.now(UTC), - generator_id=PROFILE_SOURCE_WINDOW_BINDING, - generator_version="1", - source_window=source_window, - ), - ), - sources=refs, - artifacts=artifacts, - ) - artifact = ( - await self.artifacts.create(connection, scope_id, PROFILE_ARTIFACT_ID, draft) - if current is None - else await self.artifacts.revise(connection, scope_id, current, draft) - ) - await self.cursors.save( - connection, - scope_id, - PROFILE_SOURCE_WINDOW_BINDING, - SourceCursor(sequence=through), - expected_generation=None if cursor is None else cursor.generation, - ) - if on_commit is not None: - await on_commit(connection, artifact, candidate) - if processing is not None: - await processing.complete(connection, remaining_work=candidate_id is not None or through < high) - return ProfileFlushResult( - status="review_pending" if candidate_id else ("updated" if artifact else "noop"), - previous_cursor=after, - current_cursor=after if candidate_id else through, - high_watermark=high, - processed_source_count=len(evidence), - candidate_id=candidate_id, - artifact=None if artifact is None else artifact.as_ref(), + ), + sources=refs, + artifacts=artifacts, + ) + artifact = ( + await self.artifacts.create(connection, scope_id, PROFILE_ARTIFACT_ID, draft) + if current is None + else await self.artifacts.revise(connection, scope_id, current, draft) + ) + await self.cursors.save( + connection, + scope_id, + PROFILE_SOURCE_WINDOW_BINDING, + SourceCursor(sequence=prepared.through), + expected_generation=None if cursor is None else cursor.generation, + ) + if on_commit is not None: + await on_commit(connection, artifact, candidate) + if processing is not None: + await processing.complete( + connection, remaining_work=candidate_id is not None or prepared.through < prepared.high + ) + return ProfileFlushResult( + status="review_pending" if candidate_id else ("updated" if artifact else "noop"), + previous_cursor=prepared.after, + current_cursor=prepared.after if candidate_id else prepared.through, + high_watermark=prepared.high, + processed_source_count=prepared.evidence_count, + candidate_id=candidate_id, + artifact=None if artifact is None else artifact.as_ref(), + ) + + async def _flush( + self, + scope_id: str, + *, + high_watermark: int | None, + authorize_snapshot: Callable[[Profile | None], Awaitable[None]] | None, + on_commit, + processing: ScopeInvocation | None, + authorize_commit: Callable[[AsyncConnection, Profile | None], Awaitable[None]] | None, + ) -> ProfileFlushResult: + prepared = await self._prepare_flush( + scope_id, + high_watermark=high_watermark, + authorize_snapshot=authorize_snapshot, + processing=processing, + ) + if prepared.result is not None: + return prepared.result + try: + async with self.database.transaction() as connection: + return await self.commit_flush( + connection, + prepared, + authorize_commit=authorize_commit, + on_commit=on_commit, + processing=processing, ) except ( BaseValueConflictError, @@ -369,10 +518,10 @@ async def _flush( # noqa: C901 refreshed = await self.cursors.load(connection, scope_id, PROFILE_SOURCE_WINDOW_BINDING) return ProfileFlushResult( status="conflict", - previous_cursor=after, + previous_cursor=prepared.after, current_cursor=0 if refreshed is None else refreshed.cursor.sequence, - high_watermark=high, - processed_source_count=len(evidence), + high_watermark=prepared.high, + processed_source_count=prepared.evidence_count, ) async def scan(self, *, max_concurrency: int = 4) -> None: diff --git a/src/powercontext/builtin/persistence/__init__.py b/src/powercontext/builtin/persistence/__init__.py index cfefdfd7b3..4ac2dfd1f5 100644 --- a/src/powercontext/builtin/persistence/__init__.py +++ b/src/powercontext/builtin/persistence/__init__.py @@ -21,6 +21,15 @@ ) from powercontext.builtin.persistence.candidates import CandidateRepository from powercontext.builtin.persistence.connectors import ConnectorCheckpointRepository +from powercontext.builtin.persistence.coordination import ( + CoordinationRepository, + CoordinatorLease, + RuntimeMember, + RuntimeMemberSpec, + SchedulerScan, + StaleCoordinatorLeaseError, + StaleScanStateError, +) from powercontext.builtin.persistence.database import AsyncDatabase from powercontext.builtin.persistence.decision_observations import DecisionObservationRepository from powercontext.builtin.persistence.errors import ( @@ -38,6 +47,7 @@ StoredPayloadConflictError, ) from powercontext.builtin.persistence.external_skills import ExternalSkillRepository +from powercontext.builtin.persistence.rate_limit import RateLimitDecision, RateLimitRepository from powercontext.builtin.persistence.recurrence import RecurrenceRepository from powercontext.builtin.persistence.skill_packages import SkillPackageRepository from powercontext.builtin.persistence.skill_publications import ( @@ -66,6 +76,18 @@ NoTopicMemoryIndex, TopicMemoryIndex, ) +from powercontext.builtin.persistence.work import ( + EnqueueResult, + StaleWorkClaimError, + StoredWork, + WorkClaim, + WorkFailure, + WorkRepository, + WorkResult, + WorkSpec, + WorkStateConflictError, + WorkStatus, +) __all__ = ( "GLOBAL_ARTIFACT_PROCESSING_SUPERVISOR_GROUP", @@ -78,8 +100,11 @@ "CandidateRepository", "CompositeTopicMemoryIndex", "ConnectorCheckpointRepository", + "CoordinationRepository", + "CoordinatorLease", "DatabaseClosedError", "DecisionObservationRepository", + "EnqueueResult", "ExternalSkillRepository", "GenerationConflictError", "IdentityMismatchError", @@ -88,17 +113,25 @@ "InvalidStoredPayloadError", "NoTopicMemoryIndex", "PersistenceError", + "RateLimitDecision", + "RateLimitRepository", "RecurrenceRepository", "RemoteAgentSkillTarget", "RemoteAgentSkillTargetRepository", "RemoteAgentSkillTargetState", "RepositoryError", "RepositoryNotFoundError", + "RuntimeMember", + "RuntimeMemberSpec", + "SchedulerScan", "SkillPackageRepository", "SkillPublication", "SkillPublicationDesiredState", "SkillPublicationRepository", "SourceDefinitionManifestRepository", + "StaleCoordinatorLeaseError", + "StaleScanStateError", + "StaleWorkClaimError", "StatisticsRepository", "StoredArtifactProcessingBindingState", "StoredArtifactProcessingLease", @@ -106,6 +139,14 @@ "StoredModelUsage", "StoredPayloadConflictError", "StoredRecallTokenUsage", + "StoredWork", "TopicMemoryIndex", "TopicMemoryRepository", + "WorkClaim", + "WorkFailure", + "WorkRepository", + "WorkResult", + "WorkSpec", + "WorkStateConflictError", + "WorkStatus", ) diff --git a/src/powercontext/builtin/persistence/coordination.py b/src/powercontext/builtin/persistence/coordination.py new file mode 100644 index 0000000000..f163ec5c22 --- /dev/null +++ b/src/powercontext/builtin/persistence/coordination.py @@ -0,0 +1,416 @@ +# 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. + +"""Database-time coordination primitives for schedulers and role members.""" + +from __future__ import annotations + +from collections.abc import Mapping +from datetime import UTC, datetime, timedelta +from typing import Any, Literal + +from pydantic import BaseModel, ConfigDict +from sqlalchemy import select, update +from sqlalchemy.ext.asyncio import AsyncConnection + +from powercontext.builtin.persistence.database import database_now, insert_if_absent +from powercontext.builtin.persistence.errors import ( + InvalidRepositoryArgumentError, + InvalidStoredColumnError, + RepositoryError, +) +from powercontext.builtin.persistence.tables import ( + RUNTIME_MEMBERS_TABLE, + SCHEDULER_LEASES_TABLE, + SCHEDULER_SCANS_TABLE, +) +from powercontext.limits import MAX_SCOPE_ID_LENGTH + + +class CoordinatorLease(BaseModel): + """Monotonic fencing token held until database time reaches its expiry.""" + + model_config = ConfigDict(frozen=True) + + lease_name: str + owner_id: str + fence: int + lease_expires_at: datetime + + +class SchedulerScan(BaseModel): + """Durable keyset continuation for one bounded discoverer scan.""" + + model_config = ConfigDict(frozen=True) + + discoverer: str + next_run_at: datetime + continuation: str | None + state_version: int + updated_at: datetime + + +class RuntimeMember(BaseModel): + """Non-sensitive compatibility advertisement for one live process.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + + member_id: str + role: Literal["all", "api", "scheduler", "worker"] + build_version: str + schema_min: int + schema_max: int + payload_min: int + payload_max: int + behavior_revision: str + heartbeat_at: datetime + expires_at: datetime + + +class RuntimeMemberSpec(BaseModel): + """Compatibility data supplied on member registration and heartbeat.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + + member_id: str + role: Literal["all", "api", "scheduler", "worker"] + build_version: str + schema_min: int + schema_max: int + payload_min: int + payload_max: int + behavior_revision: str + + +class StaleCoordinatorLeaseError(RepositoryError): + """Raised when an old owner or fence attempts a coordinated write.""" + + def __init__(self, lease_name: str) -> None: + self.lease_name = lease_name + super().__init__(f"coordinator lease {lease_name!r} is no longer current") + + +class StaleScanStateError(RepositoryError): + """Raised when a scheduler scan continuation loses its version race.""" + + def __init__(self, discoverer: str) -> None: + self.discoverer = discoverer + super().__init__(f"scheduler scan {discoverer!r} changed concurrently") + + +class CoordinationRepository: + """Coordinate leases, scans, and role discovery in caller transactions.""" + + async def acquire_lease( + self, + connection: AsyncConnection, + *, + lease_name: str, + owner_id: str, + lease_seconds: int, + ) -> CoordinatorLease | None: + _require_text("lease_name", lease_name, 64) + _require_text("owner_id", owner_id, 128) + _require_positive("lease_seconds", lease_seconds) + now = await database_now(connection) + row = await _lock_or_create_lease(connection, lease_name, now) + current_owner = None if row["owner_id"] is None else str(row["owner_id"]) + current_expiry = _optional_datetime(row["lease_expires_at"], "lease_expires_at") + current_fence = _nonnegative_integer(row["fence"], "fence") + if current_owner == owner_id and current_expiry is not None and current_expiry > now: + fence = current_fence + elif current_expiry is None or current_expiry <= now: + fence = current_fence + 1 + else: + return None + expires_at = now + timedelta(seconds=lease_seconds) + await connection.execute( + update(SCHEDULER_LEASES_TABLE) + .where(SCHEDULER_LEASES_TABLE.c.lease_name == lease_name) + .values(owner_id=owner_id, fence=fence, lease_expires_at=expires_at, updated_at=now) + ) + return CoordinatorLease( + lease_name=lease_name, + owner_id=owner_id, + fence=fence, + lease_expires_at=expires_at, + ) + + async def assert_lease(self, connection: AsyncConnection, lease: CoordinatorLease, /) -> None: + row = await _lock_lease(connection, lease.lease_name) + now = await database_now(connection) + expires_at = None if row is None else _optional_datetime(row["lease_expires_at"], "lease_expires_at") + if ( + row is None + or row["owner_id"] != lease.owner_id + or row["fence"] != lease.fence + or expires_at is None + or expires_at <= now + ): + raise StaleCoordinatorLeaseError(lease.lease_name) + + async def release_lease(self, connection: AsyncConnection, lease: CoordinatorLease, /) -> bool: + now = await database_now(connection) + result = await connection.execute( + update(SCHEDULER_LEASES_TABLE) + .where( + SCHEDULER_LEASES_TABLE.c.lease_name == lease.lease_name, + SCHEDULER_LEASES_TABLE.c.owner_id == lease.owner_id, + SCHEDULER_LEASES_TABLE.c.fence == lease.fence, + ) + .values(owner_id=None, lease_expires_at=now, updated_at=now) + ) + return result.rowcount == 1 + + async def load_scan(self, connection: AsyncConnection, discoverer: str, /) -> SchedulerScan | None: + _require_text("discoverer", discoverer, 128) + row = ( + ( + await connection.execute( + select(SCHEDULER_SCANS_TABLE).where(SCHEDULER_SCANS_TABLE.c.discoverer == discoverer) + ) + ) + .mappings() + .one_or_none() + ) + return None if row is None else _decode_scan(row) + + async def save_scan( + self, + connection: AsyncConnection, + discoverer: str, + /, + *, + next_run_at: datetime, + continuation: str | None, + expected_version: int | None, + ) -> SchedulerScan: + _require_text("discoverer", discoverer, 128) + if continuation is not None: + _require_text("continuation", continuation, MAX_SCOPE_ID_LENGTH) + next_run_at = _normalized_datetime(next_run_at) + now = await database_now(connection) + if expected_version is None: + created = await insert_if_absent( + connection, + SCHEDULER_SCANS_TABLE, + { + "discoverer": discoverer, + "next_run_at": next_run_at, + "continuation": continuation, + "state_version": 1, + "updated_at": now, + }, + ) + if not created: + raise StaleScanStateError(discoverer) from None + else: + _require_positive("expected_version", expected_version) + result = await connection.execute( + update(SCHEDULER_SCANS_TABLE) + .where( + SCHEDULER_SCANS_TABLE.c.discoverer == discoverer, + SCHEDULER_SCANS_TABLE.c.state_version == expected_version, + ) + .values( + next_run_at=next_run_at, + continuation=continuation, + state_version=expected_version + 1, + updated_at=now, + ) + ) + if result.rowcount != 1: + raise StaleScanStateError(discoverer) + result = await self.load_scan(connection, discoverer) + if result is None: + raise InvalidStoredColumnError("discoverer", "a persisted scheduler scan") + return result + + async def heartbeat_member( + self, + connection: AsyncConnection, + spec: RuntimeMemberSpec, + /, + *, + ttl_seconds: int, + ) -> RuntimeMember: + _validate_member(spec) + _require_positive("ttl_seconds", ttl_seconds) + now = await database_now(connection) + expires_at = now + timedelta(seconds=ttl_seconds) + values = { + **spec.model_dump(mode="python"), + "heartbeat_at": now, + "expires_at": expires_at, + } + result = await connection.execute( + update(RUNTIME_MEMBERS_TABLE).where(RUNTIME_MEMBERS_TABLE.c.member_id == spec.member_id).values(**values) + ) + if result.rowcount != 1: + created = await insert_if_absent(connection, RUNTIME_MEMBERS_TABLE, values) + if not created: + result = await connection.execute( + update(RUNTIME_MEMBERS_TABLE) + .where(RUNTIME_MEMBERS_TABLE.c.member_id == spec.member_id) + .values(**values) + ) + if result.rowcount != 1: + raise InvalidStoredColumnError("member_id", "a concurrently persisted runtime member") + return RuntimeMember.model_validate(values) + + async def live_members( + self, + connection: AsyncConnection, + /, + *, + role: Literal["all", "api", "scheduler", "worker"] | None = None, + ) -> tuple[RuntimeMember, ...]: + now = await database_now(connection) + statement = select(RUNTIME_MEMBERS_TABLE).where(RUNTIME_MEMBERS_TABLE.c.expires_at > now) + if role is not None: + statement = statement.where(RUNTIME_MEMBERS_TABLE.c.role == role) + rows = (await connection.execute(statement.order_by(RUNTIME_MEMBERS_TABLE.c.member_id))).mappings().all() + return tuple(_decode_member(row) for row in rows) + + +async def _lock_or_create_lease( + connection: AsyncConnection, + lease_name: str, + now: datetime, +) -> Mapping[Any, Any]: + row = await _lock_lease(connection, lease_name) + if row is not None: + return row + await insert_if_absent( + connection, + SCHEDULER_LEASES_TABLE, + { + "lease_name": lease_name, + "owner_id": None, + "fence": 0, + "lease_expires_at": None, + "updated_at": now, + }, + ) + row = await _lock_lease(connection, lease_name) + if row is None: + raise InvalidStoredColumnError("lease_name", "an initialized coordinator lease") + return row + + +async def _lock_lease(connection: AsyncConnection, lease_name: str) -> Mapping[Any, Any] | None: + await connection.execute( + update(SCHEDULER_LEASES_TABLE) + .where(SCHEDULER_LEASES_TABLE.c.lease_name == lease_name) + .values(fence=SCHEDULER_LEASES_TABLE.c.fence) + ) + return ( + ( + await connection.execute( + select(SCHEDULER_LEASES_TABLE) + .where(SCHEDULER_LEASES_TABLE.c.lease_name == lease_name) + .with_for_update() + ) + ) + .mappings() + .one_or_none() + ) + + +def _decode_scan(row: Mapping[Any, Any]) -> SchedulerScan: + return SchedulerScan( + discoverer=str(row["discoverer"]), + next_run_at=_stored_datetime(row["next_run_at"], "next_run_at"), + continuation=None if row["continuation"] is None else str(row["continuation"]), + state_version=_positive_integer(row["state_version"], "state_version"), + updated_at=_stored_datetime(row["updated_at"], "updated_at"), + ) + + +def _decode_member(row: Mapping[Any, Any]) -> RuntimeMember: + return RuntimeMember.model_validate({ + "member_id": str(row["member_id"]), + "role": str(row["role"]), + "build_version": str(row["build_version"]), + "schema_min": _positive_integer(row["schema_min"], "schema_min"), + "schema_max": _positive_integer(row["schema_max"], "schema_max"), + "payload_min": _positive_integer(row["payload_min"], "payload_min"), + "payload_max": _positive_integer(row["payload_max"], "payload_max"), + "behavior_revision": str(row["behavior_revision"]), + "heartbeat_at": _stored_datetime(row["heartbeat_at"], "heartbeat_at"), + "expires_at": _stored_datetime(row["expires_at"], "expires_at"), + }) + + +def _validate_member(spec: RuntimeMemberSpec) -> None: + _require_text("member_id", spec.member_id, 128) + _require_text("build_version", spec.build_version, 64) + _require_text("behavior_revision", spec.behavior_revision, 128) + for name in ("schema_min", "schema_max", "payload_min", "payload_max"): + _require_positive(name, getattr(spec, name)) + if spec.schema_max < spec.schema_min: + raise InvalidRepositoryArgumentError("schema_max", "must not be less than schema_min") + if spec.payload_max < spec.payload_min: + raise InvalidRepositoryArgumentError("payload_max", "must not be less than payload_min") + + +def _stored_datetime(value: object, column: str) -> datetime: + if not isinstance(value, datetime): + raise InvalidStoredColumnError(column, "a datetime") + return _normalized_datetime(value) + + +def _optional_datetime(value: object, column: str) -> datetime | None: + return None if value is None else _stored_datetime(value, column) + + +def _normalized_datetime(value: datetime) -> datetime: + if value.tzinfo is None: + return value + return value.astimezone(UTC).replace(tzinfo=None) + + +def _positive_integer(value: object, column: str) -> int: + if not isinstance(value, int) or isinstance(value, bool) or value < 1: + raise InvalidStoredColumnError(column, "a positive integer") + return value + + +def _nonnegative_integer(value: object, column: str) -> int: + if not isinstance(value, int) or isinstance(value, bool) or value < 0: + raise InvalidStoredColumnError(column, "a non-negative integer") + return value + + +def _require_positive(field: str, value: int) -> None: + if not isinstance(value, int) or isinstance(value, bool) or value < 1: + raise InvalidRepositoryArgumentError(field, "must be a positive integer") + + +def _require_text(field: str, value: str, maximum: int) -> None: + if not isinstance(value, str) or not value.strip() or value != value.strip(): + raise InvalidRepositoryArgumentError(field, "must be a non-empty trimmed string") + if len(value) > maximum: + raise InvalidRepositoryArgumentError(field, f"must not exceed {maximum} characters") + + +__all__ = [ + "CoordinationRepository", + "CoordinatorLease", + "RuntimeMember", + "RuntimeMemberSpec", + "SchedulerScan", + "StaleCoordinatorLeaseError", + "StaleScanStateError", +] diff --git a/src/powercontext/builtin/persistence/cursors.py b/src/powercontext/builtin/persistence/cursors.py index 42212503c7..537d134ccd 100644 --- a/src/powercontext/builtin/persistence/cursors.py +++ b/src/powercontext/builtin/persistence/cursors.py @@ -21,12 +21,10 @@ from pydantic import BaseModel from sqlalchemy import select, update -from sqlalchemy.dialects.mysql import insert as mysql_insert -from sqlalchemy.dialects.sqlite import insert as sqlite_insert from sqlalchemy.ext.asyncio import AsyncConnection from powercontext.builtin.persistence.codec import dump_model, load_model, stored_bytes -from powercontext.builtin.persistence.database import SELECTION_BATCH_SIZE +from powercontext.builtin.persistence.database import SELECTION_BATCH_SIZE, insert_if_absent from powercontext.builtin.persistence.errors import ( GenerationConflictError, InvalidRepositoryArgumentError, @@ -160,12 +158,15 @@ async def save( payload = dump_model(cursor, kind="source-cursor", name=binding_name) if expected_generation is None: generation = 1 - created = await _insert_if_absent( + created = await insert_if_absent( connection, - scope_id=scope_id, - binding_name=binding_name, - cursor=payload, - generation=generation, + SOURCE_CURSORS_TABLE, + { + "scope_id": scope_id, + "binding_name": binding_name, + "cursor": payload, + "generation": generation, + }, ) if not created: # The unique constraint is the initial-generation CAS. Read only @@ -205,34 +206,6 @@ async def save( ) -async def _insert_if_absent( - connection: AsyncConnection, - *, - scope_id: str, - binding_name: str, - cursor: bytes, - generation: int, -) -> bool: - values = { - "scope_id": scope_id, - "binding_name": binding_name, - "cursor": cursor, - "generation": generation, - } - if connection.dialect.name == "sqlite": - statement = sqlite_insert(SOURCE_CURSORS_TABLE).values(**values).on_conflict_do_nothing() - elif connection.dialect.name == "mysql": - # OceanBase MySQL mode accepts SAVEPOINT but does not retain it for - # RELEASE/ROLLBACK. INSERT IGNORE keeps first creation atomic without - # relying on a nested transaction; all values are validated first, so - # the only ignored error is the table's cursor identity conflict. - statement = mysql_insert(SOURCE_CURSORS_TABLE).values(**values).prefix_with("IGNORE") - else: - raise InvalidRepositoryArgumentError("dialect", f"{connection.dialect.name!r} does not support source cursors") - result = await connection.execute(statement) - return result.rowcount == 1 - - def _decode_row(row: Mapping[Any, Any]) -> StoredSourceCursor: return StoredSourceCursor( scope_id=str(row["scope_id"]), diff --git a/src/powercontext/builtin/persistence/database.py b/src/powercontext/builtin/persistence/database.py index d73c1aca91..aaee7d42b1 100644 --- a/src/powercontext/builtin/persistence/database.py +++ b/src/powercontext/builtin/persistence/database.py @@ -18,17 +18,23 @@ import asyncio import time -from collections.abc import AsyncIterator, Callable, Collection +from collections.abc import AsyncIterator, Callable, Collection, Mapping from contextlib import AbstractAsyncContextManager, asynccontextmanager, nullcontext +from datetime import UTC, datetime from sqlite3 import SQLITE_INTERRUPT from typing import Any from aiosqlite import Connection as SQLiteConnection from aiosqlite import Cursor as SQLiteCursor -from sqlalchemy.exc import OperationalError, PendingRollbackError +from sqlalchemy import Table, insert +from sqlalchemy.exc import IntegrityError, OperationalError, PendingRollbackError from sqlalchemy.ext.asyncio import AsyncConnection, AsyncEngine -from powercontext.builtin.persistence.errors import DatabaseClosedError, PersistenceError +from powercontext.builtin.persistence.errors import ( + DatabaseClosedError, + InvalidStoredColumnError, + PersistenceError, +) # Repositories that read a whole Scope selection in one statement chunk it to stay # below the lowest bind-parameter ceiling across the supported backends. @@ -515,3 +521,56 @@ def is_transaction_contention(error: OperationalError) -> bool: if isinstance(sqlite_code, int) and sqlite_code & 0xFF in {5, 6}: return True return bool(original is not None and original.args and original.args[0] in {1205, 1213}) + + +async def database_now(connection: AsyncConnection, /) -> datetime: + """Return the database clock as normalized *naive* UTC. + + Coordination decisions (Lease expiry, fence ordering, ``lease_expires_at`` + comparisons) are made against columns the Work Ledger stores as naive UTC, + so this helper deliberately strips the offset. ``dream.database_now`` + returns an aware datetime for its own records and is not interchangeable + here: mixing the two produces "can't compare offset-naive and + offset-aware datetimes" inside the claim path. + """ + + statement = "SELECT UTC_TIMESTAMP(6)" if connection.dialect.name == "mysql" else "SELECT CURRENT_TIMESTAMP" + value = (await connection.exec_driver_sql(statement)).scalar_one() + if isinstance(value, str): + value = datetime.fromisoformat(value.replace("Z", "+00:00")) + if not isinstance(value, datetime): + raise InvalidStoredColumnError("CURRENT_TIMESTAMP", "a datetime") + if value.tzinfo is not None: + return value.astimezone(UTC).replace(tzinfo=None) + return value + + +async def insert_if_absent( + connection: AsyncConnection, + table: Table, + values: Mapping[str, Any], + /, +) -> bool: + """Insert validated values without aborting the transaction on a unique-key race. + + OceanBase's async dialect deliberately leaves ``do_begin`` empty. A + zero-row locking update therefore may not establish a server transaction, + which makes a SAVEPOINT-based insert race unsafe. The supported SQL + dialects provide a conflict-tolerant insert that starts the real + transaction and reports whether this caller created the row. + """ + + statement = insert(table).values(**values) + if connection.dialect.name == "mysql": + result = await connection.execute(statement.prefix_with("IGNORE")) + return result.rowcount == 1 + if connection.dialect.name == "sqlite": + result = await connection.execute(statement.prefix_with("OR IGNORE")) + return result.rowcount == 1 + + try: + async with connection.begin_nested(): + await connection.execute(statement) + except IntegrityError: + return False + return True diff --git a/src/powercontext/builtin/persistence/migrations/server_resources/manifest.json b/src/powercontext/builtin/persistence/migrations/server_resources/manifest.json index 65c518659d..5d5399a8f3 100644 --- a/src/powercontext/builtin/persistence/migrations/server_resources/manifest.json +++ b/src/powercontext/builtin/persistence/migrations/server_resources/manifest.json @@ -24,6 +24,10 @@ "v1_4_1_004": [ "schemas/sqlite-v1_4_1_004.json", "schemas/mysql-v1_4_1_004.json" + ], + "v1_4_1_005": [ + "schemas/sqlite-v1_4_1_005.json", + "schemas/mysql-v1_4_1_005.json" ] }, "sqlite_baselines": { @@ -44,6 +48,9 @@ ], "v1_4_1_004": [ "84d736b5e722a56ebe8a32318732543d43548a63a43709dbf0c571e7e4af83b5" + ], + "v1_4_1_005": [ + "92eaad28512ba8a03917acdd9a0996523a67450092e03161eca68e4e0f9d4cb6" ] }, "affected_objects": [ @@ -138,7 +145,15 @@ "pc_retained_dream_candidate_versions", "pc_memory_artifact_archive", "pc_portable_restore_receipts", - "pc_access_owner_recovery" + "pc_access_owner_recovery", + "pc_work_lanes", + "pc_work_items", + "pc_work_keys", + "pc_work_attempts", + "pc_scheduler_leases", + "pc_scheduler_scans", + "pc_runtime_members", + "pc_rate_limit_windows" ], "readiness_scope": "server", "retained_objects": [ @@ -195,6 +210,10 @@ "v1_4_1_004": [ "server_upgrade", "portable_storage" + ], + "v1_4_1_005": [ + "server_upgrade", + "portable_storage" ] }, "retained_fingerprints": { @@ -210,7 +229,8 @@ "schemas/mysql-s1400.json", "schemas/mysql-s1401.json", "schemas/mysql-s1402.json", - "schemas/mysql-v1_4_1_004.json" + "schemas/mysql-v1_4_1_004.json", + "schemas/mysql-v1_4_1_005.json" ], "mysql_history": "mysql", "mysql_schema_extensions": { diff --git a/src/powercontext/builtin/persistence/migrations/server_resources/mysql/versions/v1_4_1_005.py b/src/powercontext/builtin/persistence/migrations/server_resources/mysql/versions/v1_4_1_005.py new file mode 100644 index 0000000000..0ca30e1afa --- /dev/null +++ b/src/powercontext/builtin/persistence/migrations/server_resources/mysql/versions/v1_4_1_005.py @@ -0,0 +1,29 @@ +# 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. + +"""Register the Work Ledger tables through the shared MySQL DDL executor. + +The revision follows ``v1_4_1_004`` so both the SQLite and MySQL histories stay +linear behind master's owner-recovery revision, and carries the release-based +name that revision introduced. +""" + +from alembic import context + +revision = "v1_4_1_005" +down_revision = "v1_4_1_004" + + +def upgrade() -> None: + context.config.attributes["execute_revision"](revision) diff --git a/src/powercontext/builtin/persistence/migrations/server_resources/schemas/mysql-v1_4_1_005.json b/src/powercontext/builtin/persistence/migrations/server_resources/schemas/mysql-v1_4_1_005.json new file mode 100644 index 0000000000..89b9895831 --- /dev/null +++ b/src/powercontext/builtin/persistence/migrations/server_resources/schemas/mysql-v1_4_1_005.json @@ -0,0 +1,4234 @@ +{ + "format": "powercontext.mysql-state-layouts.v1", + "revisions": { + "v1_4_1_005": { + "statements": [ + "CREATE TABLE pc_work_lanes (\n\tlane_key VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, \n\thead_sequence BIGINT, \n\tnext_sequence BIGINT NOT NULL, \n\tPRIMARY KEY (lane_key), \n\tCONSTRAINT ck_pc_work_lanes_head_positive CHECK (head_sequence IS NULL OR head_sequence > 0), \n\tCONSTRAINT ck_pc_work_lanes_next_positive CHECK (next_sequence > 0)\n)", + "CREATE TABLE pc_work_items (\n\twork_id VARCHAR(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, \n\tlogical_key VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, \n\tlane_key VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, \n\tlane_sequence BIGINT NOT NULL, \n\tkind VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, \n\tpayload_version INTEGER NOT NULL, \n\tscope_id VARCHAR(256) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, \n\tpayload MEDIUMBLOB NOT NULL, \n\tstatus VARCHAR(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, \n\tavailable_at DATETIME(6) NOT NULL, \n\tlease_owner VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin, \n\tlease_fence BIGINT NOT NULL, \n\tlease_expires_at DATETIME(6), \n\tattempt_count INTEGER NOT NULL, \n\tgeneration_attempt_count INTEGER NOT NULL, \n\trecovery_generation INTEGER NOT NULL, \n\tmax_attempts INTEGER NOT NULL, \n\tcancel_requested BOOL NOT NULL, \n\tresult_code VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin, \n\tresult_payload MEDIUMBLOB, \n\terror_category VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin, \n\terror_code VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin, \n\tstate_version BIGINT NOT NULL, \n\tcreated_at DATETIME(6) NOT NULL, \n\tupdated_at DATETIME(6) NOT NULL, \n\tcompleted_at DATETIME(6), \n\tPRIMARY KEY (work_id), \n\tCONSTRAINT uq_pc_work_items_lane_sequence UNIQUE (lane_key, lane_sequence), \n\tCONSTRAINT ck_pc_work_items_status CHECK (status IN ('queued', 'running', 'retry_wait', 'cancelling', 'succeeded', 'failed', 'cancelled')), \n\tCONSTRAINT ck_pc_work_items_lane_sequence_positive CHECK (lane_sequence > 0), \n\tCONSTRAINT ck_pc_work_items_payload_version_positive CHECK (payload_version > 0), \n\tCONSTRAINT ck_pc_work_items_fence_nonnegative CHECK (lease_fence >= 0), \n\tCONSTRAINT ck_pc_work_items_attempts_nonnegative CHECK (attempt_count >= 0), \n\tCONSTRAINT ck_pc_work_items_generation_attempts_nonnegative CHECK (generation_attempt_count >= 0), \n\tCONSTRAINT ck_pc_work_items_recovery_nonnegative CHECK (recovery_generation >= 0), \n\tCONSTRAINT ck_pc_work_items_max_attempts_positive CHECK (max_attempts > 0), \n\tCONSTRAINT ck_pc_work_items_state_version_positive CHECK (state_version > 0)\n)", + "CREATE INDEX ix_pc_work_items_claim ON pc_work_items (status, available_at)", + "CREATE INDEX ix_pc_work_items_completed ON pc_work_items (completed_at)", + "CREATE INDEX ix_pc_work_items_lane_status ON pc_work_items (lane_key, status)", + "CREATE INDEX ix_pc_work_items_scope_created ON pc_work_items (scope_id, created_at)", + "CREATE TABLE pc_work_keys (\n\tlogical_key VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, \n\twork_id VARCHAR(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, \n\tlane_key VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, \n\tcreated_at DATETIME(6) NOT NULL, \n\tPRIMARY KEY (logical_key), \n\tCONSTRAINT uq_pc_work_keys_work_id UNIQUE (work_id)\n)", + "CREATE TABLE pc_work_attempts (\n\twork_id VARCHAR(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, \n\tattempt_no INTEGER NOT NULL, \n\trecovery_generation INTEGER NOT NULL, \n\towner_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, \n\tfence BIGINT NOT NULL, \n\tstarted_at DATETIME(6) NOT NULL, \n\theartbeat_at DATETIME(6) NOT NULL, \n\tfinished_at DATETIME(6), \n\toutcome VARCHAR(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin, \n\terror_category VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin, \n\terror_code VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin, \n\ttrace_id VARCHAR(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin, \n\tspan_id VARCHAR(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin, \n\tPRIMARY KEY (work_id, attempt_no), \n\tCONSTRAINT ck_pc_work_attempts_attempt_positive CHECK (attempt_no > 0), \n\tCONSTRAINT ck_pc_work_attempts_recovery_nonnegative CHECK (recovery_generation >= 0), \n\tCONSTRAINT ck_pc_work_attempts_fence_positive CHECK (fence > 0)\n)", + "CREATE INDEX ix_pc_work_attempts_finished ON pc_work_attempts (finished_at)", + "CREATE TABLE pc_scheduler_leases (\n\tlease_name VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, \n\towner_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin, \n\tfence BIGINT NOT NULL, \n\tlease_expires_at DATETIME(6), \n\tupdated_at DATETIME(6) NOT NULL, \n\tPRIMARY KEY (lease_name), \n\tCONSTRAINT ck_pc_scheduler_leases_fence_nonnegative CHECK (fence >= 0)\n)", + "CREATE TABLE pc_scheduler_scans (\n\tdiscoverer VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, \n\tnext_run_at DATETIME(6) NOT NULL, \n\tcontinuation VARCHAR(256) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin, \n\tstate_version BIGINT NOT NULL, \n\tupdated_at DATETIME(6) NOT NULL, \n\tPRIMARY KEY (discoverer), \n\tCONSTRAINT ck_pc_scheduler_scans_version_positive CHECK (state_version > 0)\n)", + "CREATE TABLE pc_runtime_members (\n\tmember_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, \n\t`role` VARCHAR(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, \n\tbuild_version VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, \n\tschema_min INTEGER NOT NULL, \n\tschema_max INTEGER NOT NULL, \n\tpayload_min INTEGER NOT NULL, \n\tpayload_max INTEGER NOT NULL, \n\tbehavior_revision VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, \n\theartbeat_at DATETIME(6) NOT NULL, \n\texpires_at DATETIME(6) NOT NULL, \n\tPRIMARY KEY (member_id), \n\tCONSTRAINT ck_pc_runtime_members_role CHECK (role IN ('all', 'api', 'scheduler', 'worker')), \n\tCONSTRAINT ck_pc_runtime_members_schema_range CHECK (schema_min > 0 AND schema_max >= schema_min), \n\tCONSTRAINT ck_pc_runtime_members_payload_range CHECK (payload_min > 0 AND payload_max >= payload_min)\n)", + "CREATE INDEX ix_pc_runtime_members_role_expiry ON pc_runtime_members (`role`, expires_at)", + "CREATE TABLE pc_rate_limit_windows (\n\tprincipal_key VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, \n\tpolicy_id VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, \n\twindow_started_at DATETIME(6) NOT NULL, \n\trequest_count BIGINT NOT NULL, \n\texpires_at DATETIME(6) NOT NULL, \n\tPRIMARY KEY (principal_key, policy_id, window_started_at), \n\tCONSTRAINT ck_pc_rate_limit_windows_count_positive CHECK (request_count > 0)\n)", + "CREATE INDEX ix_pc_rate_limit_windows_expiry ON pc_rate_limit_windows (expires_at)" + ], + "changes": [ + { + "pc_work_lanes": { + "columns": { + "lane_key": { + "type": [ + "varchar", + [ + "64" + ] + ], + "nullable": false, + "default": null, + "charset": "utf8mb4", + "collation": "utf8mb4_bin", + "extra": [] + }, + "head_sequence": { + "type": [ + "bigint", + [] + ], + "nullable": true, + "default": null, + "charset": null, + "collation": null, + "extra": [] + }, + "next_sequence": { + "type": [ + "bigint", + [] + ], + "nullable": false, + "default": null, + "charset": null, + "collation": null, + "extra": [] + } + }, + "primary_key": [ + [ + "lane_key", + null, + "asc" + ] + ], + "foreign_keys": [], + "checks": { + "ck_pc_work_lanes_head_positive": { + "expression": [ + "binary", + "or", + [ + "is_null", + [ + "column", + "head_sequence" + ] + ], + [ + "binary", + ">", + [ + "column", + "head_sequence" + ], + [ + "number", + 0 + ] + ] + ], + "enforced": true + }, + "ck_pc_work_lanes_next_positive": { + "expression": [ + "binary", + ">", + [ + "column", + "next_sequence" + ], + [ + "number", + 0 + ] + ], + "enforced": true + } + }, + "indexes": {} + } + }, + { + "pc_work_items": { + "columns": { + "work_id": { + "type": [ + "varchar", + [ + "36" + ] + ], + "nullable": false, + "default": null, + "charset": "utf8mb4", + "collation": "utf8mb4_bin", + "extra": [] + }, + "logical_key": { + "type": [ + "varchar", + [ + "64" + ] + ], + "nullable": false, + "default": null, + "charset": "utf8mb4", + "collation": "utf8mb4_bin", + "extra": [] + }, + "lane_key": { + "type": [ + "varchar", + [ + "64" + ] + ], + "nullable": false, + "default": null, + "charset": "utf8mb4", + "collation": "utf8mb4_bin", + "extra": [] + }, + "lane_sequence": { + "type": [ + "bigint", + [] + ], + "nullable": false, + "default": null, + "charset": null, + "collation": null, + "extra": [] + }, + "kind": { + "type": [ + "varchar", + [ + "128" + ] + ], + "nullable": false, + "default": null, + "charset": "utf8mb4", + "collation": "utf8mb4_bin", + "extra": [] + }, + "payload_version": { + "type": [ + "int", + [] + ], + "nullable": false, + "default": null, + "charset": null, + "collation": null, + "extra": [] + }, + "scope_id": { + "type": [ + "varchar", + [ + "256" + ] + ], + "nullable": false, + "default": null, + "charset": "utf8mb4", + "collation": "utf8mb4_bin", + "extra": [] + }, + "payload": { + "type": [ + "mediumblob", + [] + ], + "nullable": false, + "default": null, + "charset": null, + "collation": null, + "extra": [] + }, + "status": { + "type": [ + "varchar", + [ + "16" + ] + ], + "nullable": false, + "default": null, + "charset": "utf8mb4", + "collation": "utf8mb4_bin", + "extra": [] + }, + "available_at": { + "type": [ + "datetime", + [ + "6" + ] + ], + "nullable": false, + "default": null, + "charset": null, + "collation": null, + "extra": [] + }, + "lease_owner": { + "type": [ + "varchar", + [ + "128" + ] + ], + "nullable": true, + "default": null, + "charset": "utf8mb4", + "collation": "utf8mb4_bin", + "extra": [] + }, + "lease_fence": { + "type": [ + "bigint", + [] + ], + "nullable": false, + "default": null, + "charset": null, + "collation": null, + "extra": [] + }, + "lease_expires_at": { + "type": [ + "datetime", + [ + "6" + ] + ], + "nullable": true, + "default": null, + "charset": null, + "collation": null, + "extra": [] + }, + "attempt_count": { + "type": [ + "int", + [] + ], + "nullable": false, + "default": null, + "charset": null, + "collation": null, + "extra": [] + }, + "generation_attempt_count": { + "type": [ + "int", + [] + ], + "nullable": false, + "default": null, + "charset": null, + "collation": null, + "extra": [] + }, + "recovery_generation": { + "type": [ + "int", + [] + ], + "nullable": false, + "default": null, + "charset": null, + "collation": null, + "extra": [] + }, + "max_attempts": { + "type": [ + "int", + [] + ], + "nullable": false, + "default": null, + "charset": null, + "collation": null, + "extra": [] + }, + "cancel_requested": { + "type": [ + "bool", + [] + ], + "nullable": false, + "default": null, + "charset": null, + "collation": null, + "extra": [] + }, + "result_code": { + "type": [ + "varchar", + [ + "128" + ] + ], + "nullable": true, + "default": null, + "charset": "utf8mb4", + "collation": "utf8mb4_bin", + "extra": [] + }, + "result_payload": { + "type": [ + "mediumblob", + [] + ], + "nullable": true, + "default": null, + "charset": null, + "collation": null, + "extra": [] + }, + "error_category": { + "type": [ + "varchar", + [ + "64" + ] + ], + "nullable": true, + "default": null, + "charset": "utf8mb4", + "collation": "utf8mb4_bin", + "extra": [] + }, + "error_code": { + "type": [ + "varchar", + [ + "128" + ] + ], + "nullable": true, + "default": null, + "charset": "utf8mb4", + "collation": "utf8mb4_bin", + "extra": [] + }, + "state_version": { + "type": [ + "bigint", + [] + ], + "nullable": false, + "default": null, + "charset": null, + "collation": null, + "extra": [] + }, + "created_at": { + "type": [ + "datetime", + [ + "6" + ] + ], + "nullable": false, + "default": null, + "charset": null, + "collation": null, + "extra": [] + }, + "updated_at": { + "type": [ + "datetime", + [ + "6" + ] + ], + "nullable": false, + "default": null, + "charset": null, + "collation": null, + "extra": [] + }, + "completed_at": { + "type": [ + "datetime", + [ + "6" + ] + ], + "nullable": true, + "default": null, + "charset": null, + "collation": null, + "extra": [] + } + }, + "primary_key": [ + [ + "work_id", + null, + "asc" + ] + ], + "foreign_keys": [], + "checks": { + "ck_pc_work_items_status": { + "expression": [ + "in", + [ + "column", + "status" + ], + [ + [ + "literal", + "queued" + ], + [ + "literal", + "running" + ], + [ + "literal", + "retry_wait" + ], + [ + "literal", + "cancelling" + ], + [ + "literal", + "succeeded" + ], + [ + "literal", + "failed" + ], + [ + "literal", + "cancelled" + ] + ] + ], + "enforced": true + }, + "ck_pc_work_items_lane_sequence_positive": { + "expression": [ + "binary", + ">", + [ + "column", + "lane_sequence" + ], + [ + "number", + 0 + ] + ], + "enforced": true + }, + "ck_pc_work_items_payload_version_positive": { + "expression": [ + "binary", + ">", + [ + "column", + "payload_version" + ], + [ + "number", + 0 + ] + ], + "enforced": true + }, + "ck_pc_work_items_fence_nonnegative": { + "expression": [ + "binary", + ">=", + [ + "column", + "lease_fence" + ], + [ + "number", + 0 + ] + ], + "enforced": true + }, + "ck_pc_work_items_attempts_nonnegative": { + "expression": [ + "binary", + ">=", + [ + "column", + "attempt_count" + ], + [ + "number", + 0 + ] + ], + "enforced": true + }, + "ck_pc_work_items_generation_attempts_nonnegative": { + "expression": [ + "binary", + ">=", + [ + "column", + "generation_attempt_count" + ], + [ + "number", + 0 + ] + ], + "enforced": true + }, + "ck_pc_work_items_recovery_nonnegative": { + "expression": [ + "binary", + ">=", + [ + "column", + "recovery_generation" + ], + [ + "number", + 0 + ] + ], + "enforced": true + }, + "ck_pc_work_items_max_attempts_positive": { + "expression": [ + "binary", + ">", + [ + "column", + "max_attempts" + ], + [ + "number", + 0 + ] + ], + "enforced": true + }, + "ck_pc_work_items_state_version_positive": { + "expression": [ + "binary", + ">", + [ + "column", + "state_version" + ], + [ + "number", + 0 + ] + ], + "enforced": true + } + }, + "indexes": { + "uq_pc_work_items_lane_sequence": { + "columns": [ + [ + "lane_key", + null, + "asc" + ], + [ + "lane_sequence", + null, + "asc" + ] + ], + "unique": true + } + } + } + }, + { + "pc_work_items": { + "columns": { + "work_id": { + "type": [ + "varchar", + [ + "36" + ] + ], + "nullable": false, + "default": null, + "charset": "utf8mb4", + "collation": "utf8mb4_bin", + "extra": [] + }, + "logical_key": { + "type": [ + "varchar", + [ + "64" + ] + ], + "nullable": false, + "default": null, + "charset": "utf8mb4", + "collation": "utf8mb4_bin", + "extra": [] + }, + "lane_key": { + "type": [ + "varchar", + [ + "64" + ] + ], + "nullable": false, + "default": null, + "charset": "utf8mb4", + "collation": "utf8mb4_bin", + "extra": [] + }, + "lane_sequence": { + "type": [ + "bigint", + [] + ], + "nullable": false, + "default": null, + "charset": null, + "collation": null, + "extra": [] + }, + "kind": { + "type": [ + "varchar", + [ + "128" + ] + ], + "nullable": false, + "default": null, + "charset": "utf8mb4", + "collation": "utf8mb4_bin", + "extra": [] + }, + "payload_version": { + "type": [ + "int", + [] + ], + "nullable": false, + "default": null, + "charset": null, + "collation": null, + "extra": [] + }, + "scope_id": { + "type": [ + "varchar", + [ + "256" + ] + ], + "nullable": false, + "default": null, + "charset": "utf8mb4", + "collation": "utf8mb4_bin", + "extra": [] + }, + "payload": { + "type": [ + "mediumblob", + [] + ], + "nullable": false, + "default": null, + "charset": null, + "collation": null, + "extra": [] + }, + "status": { + "type": [ + "varchar", + [ + "16" + ] + ], + "nullable": false, + "default": null, + "charset": "utf8mb4", + "collation": "utf8mb4_bin", + "extra": [] + }, + "available_at": { + "type": [ + "datetime", + [ + "6" + ] + ], + "nullable": false, + "default": null, + "charset": null, + "collation": null, + "extra": [] + }, + "lease_owner": { + "type": [ + "varchar", + [ + "128" + ] + ], + "nullable": true, + "default": null, + "charset": "utf8mb4", + "collation": "utf8mb4_bin", + "extra": [] + }, + "lease_fence": { + "type": [ + "bigint", + [] + ], + "nullable": false, + "default": null, + "charset": null, + "collation": null, + "extra": [] + }, + "lease_expires_at": { + "type": [ + "datetime", + [ + "6" + ] + ], + "nullable": true, + "default": null, + "charset": null, + "collation": null, + "extra": [] + }, + "attempt_count": { + "type": [ + "int", + [] + ], + "nullable": false, + "default": null, + "charset": null, + "collation": null, + "extra": [] + }, + "generation_attempt_count": { + "type": [ + "int", + [] + ], + "nullable": false, + "default": null, + "charset": null, + "collation": null, + "extra": [] + }, + "recovery_generation": { + "type": [ + "int", + [] + ], + "nullable": false, + "default": null, + "charset": null, + "collation": null, + "extra": [] + }, + "max_attempts": { + "type": [ + "int", + [] + ], + "nullable": false, + "default": null, + "charset": null, + "collation": null, + "extra": [] + }, + "cancel_requested": { + "type": [ + "bool", + [] + ], + "nullable": false, + "default": null, + "charset": null, + "collation": null, + "extra": [] + }, + "result_code": { + "type": [ + "varchar", + [ + "128" + ] + ], + "nullable": true, + "default": null, + "charset": "utf8mb4", + "collation": "utf8mb4_bin", + "extra": [] + }, + "result_payload": { + "type": [ + "mediumblob", + [] + ], + "nullable": true, + "default": null, + "charset": null, + "collation": null, + "extra": [] + }, + "error_category": { + "type": [ + "varchar", + [ + "64" + ] + ], + "nullable": true, + "default": null, + "charset": "utf8mb4", + "collation": "utf8mb4_bin", + "extra": [] + }, + "error_code": { + "type": [ + "varchar", + [ + "128" + ] + ], + "nullable": true, + "default": null, + "charset": "utf8mb4", + "collation": "utf8mb4_bin", + "extra": [] + }, + "state_version": { + "type": [ + "bigint", + [] + ], + "nullable": false, + "default": null, + "charset": null, + "collation": null, + "extra": [] + }, + "created_at": { + "type": [ + "datetime", + [ + "6" + ] + ], + "nullable": false, + "default": null, + "charset": null, + "collation": null, + "extra": [] + }, + "updated_at": { + "type": [ + "datetime", + [ + "6" + ] + ], + "nullable": false, + "default": null, + "charset": null, + "collation": null, + "extra": [] + }, + "completed_at": { + "type": [ + "datetime", + [ + "6" + ] + ], + "nullable": true, + "default": null, + "charset": null, + "collation": null, + "extra": [] + } + }, + "primary_key": [ + [ + "work_id", + null, + "asc" + ] + ], + "foreign_keys": [], + "checks": { + "ck_pc_work_items_status": { + "expression": [ + "in", + [ + "column", + "status" + ], + [ + [ + "literal", + "queued" + ], + [ + "literal", + "running" + ], + [ + "literal", + "retry_wait" + ], + [ + "literal", + "cancelling" + ], + [ + "literal", + "succeeded" + ], + [ + "literal", + "failed" + ], + [ + "literal", + "cancelled" + ] + ] + ], + "enforced": true + }, + "ck_pc_work_items_lane_sequence_positive": { + "expression": [ + "binary", + ">", + [ + "column", + "lane_sequence" + ], + [ + "number", + 0 + ] + ], + "enforced": true + }, + "ck_pc_work_items_payload_version_positive": { + "expression": [ + "binary", + ">", + [ + "column", + "payload_version" + ], + [ + "number", + 0 + ] + ], + "enforced": true + }, + "ck_pc_work_items_fence_nonnegative": { + "expression": [ + "binary", + ">=", + [ + "column", + "lease_fence" + ], + [ + "number", + 0 + ] + ], + "enforced": true + }, + "ck_pc_work_items_attempts_nonnegative": { + "expression": [ + "binary", + ">=", + [ + "column", + "attempt_count" + ], + [ + "number", + 0 + ] + ], + "enforced": true + }, + "ck_pc_work_items_generation_attempts_nonnegative": { + "expression": [ + "binary", + ">=", + [ + "column", + "generation_attempt_count" + ], + [ + "number", + 0 + ] + ], + "enforced": true + }, + "ck_pc_work_items_recovery_nonnegative": { + "expression": [ + "binary", + ">=", + [ + "column", + "recovery_generation" + ], + [ + "number", + 0 + ] + ], + "enforced": true + }, + "ck_pc_work_items_max_attempts_positive": { + "expression": [ + "binary", + ">", + [ + "column", + "max_attempts" + ], + [ + "number", + 0 + ] + ], + "enforced": true + }, + "ck_pc_work_items_state_version_positive": { + "expression": [ + "binary", + ">", + [ + "column", + "state_version" + ], + [ + "number", + 0 + ] + ], + "enforced": true + } + }, + "indexes": { + "uq_pc_work_items_lane_sequence": { + "columns": [ + [ + "lane_key", + null, + "asc" + ], + [ + "lane_sequence", + null, + "asc" + ] + ], + "unique": true + }, + "ix_pc_work_items_claim": { + "columns": [ + [ + "status", + null, + "asc" + ], + [ + "available_at", + null, + "asc" + ] + ], + "unique": false + } + } + } + }, + { + "pc_work_items": { + "columns": { + "work_id": { + "type": [ + "varchar", + [ + "36" + ] + ], + "nullable": false, + "default": null, + "charset": "utf8mb4", + "collation": "utf8mb4_bin", + "extra": [] + }, + "logical_key": { + "type": [ + "varchar", + [ + "64" + ] + ], + "nullable": false, + "default": null, + "charset": "utf8mb4", + "collation": "utf8mb4_bin", + "extra": [] + }, + "lane_key": { + "type": [ + "varchar", + [ + "64" + ] + ], + "nullable": false, + "default": null, + "charset": "utf8mb4", + "collation": "utf8mb4_bin", + "extra": [] + }, + "lane_sequence": { + "type": [ + "bigint", + [] + ], + "nullable": false, + "default": null, + "charset": null, + "collation": null, + "extra": [] + }, + "kind": { + "type": [ + "varchar", + [ + "128" + ] + ], + "nullable": false, + "default": null, + "charset": "utf8mb4", + "collation": "utf8mb4_bin", + "extra": [] + }, + "payload_version": { + "type": [ + "int", + [] + ], + "nullable": false, + "default": null, + "charset": null, + "collation": null, + "extra": [] + }, + "scope_id": { + "type": [ + "varchar", + [ + "256" + ] + ], + "nullable": false, + "default": null, + "charset": "utf8mb4", + "collation": "utf8mb4_bin", + "extra": [] + }, + "payload": { + "type": [ + "mediumblob", + [] + ], + "nullable": false, + "default": null, + "charset": null, + "collation": null, + "extra": [] + }, + "status": { + "type": [ + "varchar", + [ + "16" + ] + ], + "nullable": false, + "default": null, + "charset": "utf8mb4", + "collation": "utf8mb4_bin", + "extra": [] + }, + "available_at": { + "type": [ + "datetime", + [ + "6" + ] + ], + "nullable": false, + "default": null, + "charset": null, + "collation": null, + "extra": [] + }, + "lease_owner": { + "type": [ + "varchar", + [ + "128" + ] + ], + "nullable": true, + "default": null, + "charset": "utf8mb4", + "collation": "utf8mb4_bin", + "extra": [] + }, + "lease_fence": { + "type": [ + "bigint", + [] + ], + "nullable": false, + "default": null, + "charset": null, + "collation": null, + "extra": [] + }, + "lease_expires_at": { + "type": [ + "datetime", + [ + "6" + ] + ], + "nullable": true, + "default": null, + "charset": null, + "collation": null, + "extra": [] + }, + "attempt_count": { + "type": [ + "int", + [] + ], + "nullable": false, + "default": null, + "charset": null, + "collation": null, + "extra": [] + }, + "generation_attempt_count": { + "type": [ + "int", + [] + ], + "nullable": false, + "default": null, + "charset": null, + "collation": null, + "extra": [] + }, + "recovery_generation": { + "type": [ + "int", + [] + ], + "nullable": false, + "default": null, + "charset": null, + "collation": null, + "extra": [] + }, + "max_attempts": { + "type": [ + "int", + [] + ], + "nullable": false, + "default": null, + "charset": null, + "collation": null, + "extra": [] + }, + "cancel_requested": { + "type": [ + "bool", + [] + ], + "nullable": false, + "default": null, + "charset": null, + "collation": null, + "extra": [] + }, + "result_code": { + "type": [ + "varchar", + [ + "128" + ] + ], + "nullable": true, + "default": null, + "charset": "utf8mb4", + "collation": "utf8mb4_bin", + "extra": [] + }, + "result_payload": { + "type": [ + "mediumblob", + [] + ], + "nullable": true, + "default": null, + "charset": null, + "collation": null, + "extra": [] + }, + "error_category": { + "type": [ + "varchar", + [ + "64" + ] + ], + "nullable": true, + "default": null, + "charset": "utf8mb4", + "collation": "utf8mb4_bin", + "extra": [] + }, + "error_code": { + "type": [ + "varchar", + [ + "128" + ] + ], + "nullable": true, + "default": null, + "charset": "utf8mb4", + "collation": "utf8mb4_bin", + "extra": [] + }, + "state_version": { + "type": [ + "bigint", + [] + ], + "nullable": false, + "default": null, + "charset": null, + "collation": null, + "extra": [] + }, + "created_at": { + "type": [ + "datetime", + [ + "6" + ] + ], + "nullable": false, + "default": null, + "charset": null, + "collation": null, + "extra": [] + }, + "updated_at": { + "type": [ + "datetime", + [ + "6" + ] + ], + "nullable": false, + "default": null, + "charset": null, + "collation": null, + "extra": [] + }, + "completed_at": { + "type": [ + "datetime", + [ + "6" + ] + ], + "nullable": true, + "default": null, + "charset": null, + "collation": null, + "extra": [] + } + }, + "primary_key": [ + [ + "work_id", + null, + "asc" + ] + ], + "foreign_keys": [], + "checks": { + "ck_pc_work_items_status": { + "expression": [ + "in", + [ + "column", + "status" + ], + [ + [ + "literal", + "queued" + ], + [ + "literal", + "running" + ], + [ + "literal", + "retry_wait" + ], + [ + "literal", + "cancelling" + ], + [ + "literal", + "succeeded" + ], + [ + "literal", + "failed" + ], + [ + "literal", + "cancelled" + ] + ] + ], + "enforced": true + }, + "ck_pc_work_items_lane_sequence_positive": { + "expression": [ + "binary", + ">", + [ + "column", + "lane_sequence" + ], + [ + "number", + 0 + ] + ], + "enforced": true + }, + "ck_pc_work_items_payload_version_positive": { + "expression": [ + "binary", + ">", + [ + "column", + "payload_version" + ], + [ + "number", + 0 + ] + ], + "enforced": true + }, + "ck_pc_work_items_fence_nonnegative": { + "expression": [ + "binary", + ">=", + [ + "column", + "lease_fence" + ], + [ + "number", + 0 + ] + ], + "enforced": true + }, + "ck_pc_work_items_attempts_nonnegative": { + "expression": [ + "binary", + ">=", + [ + "column", + "attempt_count" + ], + [ + "number", + 0 + ] + ], + "enforced": true + }, + "ck_pc_work_items_generation_attempts_nonnegative": { + "expression": [ + "binary", + ">=", + [ + "column", + "generation_attempt_count" + ], + [ + "number", + 0 + ] + ], + "enforced": true + }, + "ck_pc_work_items_recovery_nonnegative": { + "expression": [ + "binary", + ">=", + [ + "column", + "recovery_generation" + ], + [ + "number", + 0 + ] + ], + "enforced": true + }, + "ck_pc_work_items_max_attempts_positive": { + "expression": [ + "binary", + ">", + [ + "column", + "max_attempts" + ], + [ + "number", + 0 + ] + ], + "enforced": true + }, + "ck_pc_work_items_state_version_positive": { + "expression": [ + "binary", + ">", + [ + "column", + "state_version" + ], + [ + "number", + 0 + ] + ], + "enforced": true + } + }, + "indexes": { + "uq_pc_work_items_lane_sequence": { + "columns": [ + [ + "lane_key", + null, + "asc" + ], + [ + "lane_sequence", + null, + "asc" + ] + ], + "unique": true + }, + "ix_pc_work_items_claim": { + "columns": [ + [ + "status", + null, + "asc" + ], + [ + "available_at", + null, + "asc" + ] + ], + "unique": false + }, + "ix_pc_work_items_completed": { + "columns": [ + [ + "completed_at", + null, + "asc" + ] + ], + "unique": false + } + } + } + }, + { + "pc_work_items": { + "columns": { + "work_id": { + "type": [ + "varchar", + [ + "36" + ] + ], + "nullable": false, + "default": null, + "charset": "utf8mb4", + "collation": "utf8mb4_bin", + "extra": [] + }, + "logical_key": { + "type": [ + "varchar", + [ + "64" + ] + ], + "nullable": false, + "default": null, + "charset": "utf8mb4", + "collation": "utf8mb4_bin", + "extra": [] + }, + "lane_key": { + "type": [ + "varchar", + [ + "64" + ] + ], + "nullable": false, + "default": null, + "charset": "utf8mb4", + "collation": "utf8mb4_bin", + "extra": [] + }, + "lane_sequence": { + "type": [ + "bigint", + [] + ], + "nullable": false, + "default": null, + "charset": null, + "collation": null, + "extra": [] + }, + "kind": { + "type": [ + "varchar", + [ + "128" + ] + ], + "nullable": false, + "default": null, + "charset": "utf8mb4", + "collation": "utf8mb4_bin", + "extra": [] + }, + "payload_version": { + "type": [ + "int", + [] + ], + "nullable": false, + "default": null, + "charset": null, + "collation": null, + "extra": [] + }, + "scope_id": { + "type": [ + "varchar", + [ + "256" + ] + ], + "nullable": false, + "default": null, + "charset": "utf8mb4", + "collation": "utf8mb4_bin", + "extra": [] + }, + "payload": { + "type": [ + "mediumblob", + [] + ], + "nullable": false, + "default": null, + "charset": null, + "collation": null, + "extra": [] + }, + "status": { + "type": [ + "varchar", + [ + "16" + ] + ], + "nullable": false, + "default": null, + "charset": "utf8mb4", + "collation": "utf8mb4_bin", + "extra": [] + }, + "available_at": { + "type": [ + "datetime", + [ + "6" + ] + ], + "nullable": false, + "default": null, + "charset": null, + "collation": null, + "extra": [] + }, + "lease_owner": { + "type": [ + "varchar", + [ + "128" + ] + ], + "nullable": true, + "default": null, + "charset": "utf8mb4", + "collation": "utf8mb4_bin", + "extra": [] + }, + "lease_fence": { + "type": [ + "bigint", + [] + ], + "nullable": false, + "default": null, + "charset": null, + "collation": null, + "extra": [] + }, + "lease_expires_at": { + "type": [ + "datetime", + [ + "6" + ] + ], + "nullable": true, + "default": null, + "charset": null, + "collation": null, + "extra": [] + }, + "attempt_count": { + "type": [ + "int", + [] + ], + "nullable": false, + "default": null, + "charset": null, + "collation": null, + "extra": [] + }, + "generation_attempt_count": { + "type": [ + "int", + [] + ], + "nullable": false, + "default": null, + "charset": null, + "collation": null, + "extra": [] + }, + "recovery_generation": { + "type": [ + "int", + [] + ], + "nullable": false, + "default": null, + "charset": null, + "collation": null, + "extra": [] + }, + "max_attempts": { + "type": [ + "int", + [] + ], + "nullable": false, + "default": null, + "charset": null, + "collation": null, + "extra": [] + }, + "cancel_requested": { + "type": [ + "bool", + [] + ], + "nullable": false, + "default": null, + "charset": null, + "collation": null, + "extra": [] + }, + "result_code": { + "type": [ + "varchar", + [ + "128" + ] + ], + "nullable": true, + "default": null, + "charset": "utf8mb4", + "collation": "utf8mb4_bin", + "extra": [] + }, + "result_payload": { + "type": [ + "mediumblob", + [] + ], + "nullable": true, + "default": null, + "charset": null, + "collation": null, + "extra": [] + }, + "error_category": { + "type": [ + "varchar", + [ + "64" + ] + ], + "nullable": true, + "default": null, + "charset": "utf8mb4", + "collation": "utf8mb4_bin", + "extra": [] + }, + "error_code": { + "type": [ + "varchar", + [ + "128" + ] + ], + "nullable": true, + "default": null, + "charset": "utf8mb4", + "collation": "utf8mb4_bin", + "extra": [] + }, + "state_version": { + "type": [ + "bigint", + [] + ], + "nullable": false, + "default": null, + "charset": null, + "collation": null, + "extra": [] + }, + "created_at": { + "type": [ + "datetime", + [ + "6" + ] + ], + "nullable": false, + "default": null, + "charset": null, + "collation": null, + "extra": [] + }, + "updated_at": { + "type": [ + "datetime", + [ + "6" + ] + ], + "nullable": false, + "default": null, + "charset": null, + "collation": null, + "extra": [] + }, + "completed_at": { + "type": [ + "datetime", + [ + "6" + ] + ], + "nullable": true, + "default": null, + "charset": null, + "collation": null, + "extra": [] + } + }, + "primary_key": [ + [ + "work_id", + null, + "asc" + ] + ], + "foreign_keys": [], + "checks": { + "ck_pc_work_items_status": { + "expression": [ + "in", + [ + "column", + "status" + ], + [ + [ + "literal", + "queued" + ], + [ + "literal", + "running" + ], + [ + "literal", + "retry_wait" + ], + [ + "literal", + "cancelling" + ], + [ + "literal", + "succeeded" + ], + [ + "literal", + "failed" + ], + [ + "literal", + "cancelled" + ] + ] + ], + "enforced": true + }, + "ck_pc_work_items_lane_sequence_positive": { + "expression": [ + "binary", + ">", + [ + "column", + "lane_sequence" + ], + [ + "number", + 0 + ] + ], + "enforced": true + }, + "ck_pc_work_items_payload_version_positive": { + "expression": [ + "binary", + ">", + [ + "column", + "payload_version" + ], + [ + "number", + 0 + ] + ], + "enforced": true + }, + "ck_pc_work_items_fence_nonnegative": { + "expression": [ + "binary", + ">=", + [ + "column", + "lease_fence" + ], + [ + "number", + 0 + ] + ], + "enforced": true + }, + "ck_pc_work_items_attempts_nonnegative": { + "expression": [ + "binary", + ">=", + [ + "column", + "attempt_count" + ], + [ + "number", + 0 + ] + ], + "enforced": true + }, + "ck_pc_work_items_generation_attempts_nonnegative": { + "expression": [ + "binary", + ">=", + [ + "column", + "generation_attempt_count" + ], + [ + "number", + 0 + ] + ], + "enforced": true + }, + "ck_pc_work_items_recovery_nonnegative": { + "expression": [ + "binary", + ">=", + [ + "column", + "recovery_generation" + ], + [ + "number", + 0 + ] + ], + "enforced": true + }, + "ck_pc_work_items_max_attempts_positive": { + "expression": [ + "binary", + ">", + [ + "column", + "max_attempts" + ], + [ + "number", + 0 + ] + ], + "enforced": true + }, + "ck_pc_work_items_state_version_positive": { + "expression": [ + "binary", + ">", + [ + "column", + "state_version" + ], + [ + "number", + 0 + ] + ], + "enforced": true + } + }, + "indexes": { + "uq_pc_work_items_lane_sequence": { + "columns": [ + [ + "lane_key", + null, + "asc" + ], + [ + "lane_sequence", + null, + "asc" + ] + ], + "unique": true + }, + "ix_pc_work_items_claim": { + "columns": [ + [ + "status", + null, + "asc" + ], + [ + "available_at", + null, + "asc" + ] + ], + "unique": false + }, + "ix_pc_work_items_completed": { + "columns": [ + [ + "completed_at", + null, + "asc" + ] + ], + "unique": false + }, + "ix_pc_work_items_lane_status": { + "columns": [ + [ + "lane_key", + null, + "asc" + ], + [ + "status", + null, + "asc" + ] + ], + "unique": false + } + } + } + }, + { + "pc_work_items": { + "columns": { + "work_id": { + "type": [ + "varchar", + [ + "36" + ] + ], + "nullable": false, + "default": null, + "charset": "utf8mb4", + "collation": "utf8mb4_bin", + "extra": [] + }, + "logical_key": { + "type": [ + "varchar", + [ + "64" + ] + ], + "nullable": false, + "default": null, + "charset": "utf8mb4", + "collation": "utf8mb4_bin", + "extra": [] + }, + "lane_key": { + "type": [ + "varchar", + [ + "64" + ] + ], + "nullable": false, + "default": null, + "charset": "utf8mb4", + "collation": "utf8mb4_bin", + "extra": [] + }, + "lane_sequence": { + "type": [ + "bigint", + [] + ], + "nullable": false, + "default": null, + "charset": null, + "collation": null, + "extra": [] + }, + "kind": { + "type": [ + "varchar", + [ + "128" + ] + ], + "nullable": false, + "default": null, + "charset": "utf8mb4", + "collation": "utf8mb4_bin", + "extra": [] + }, + "payload_version": { + "type": [ + "int", + [] + ], + "nullable": false, + "default": null, + "charset": null, + "collation": null, + "extra": [] + }, + "scope_id": { + "type": [ + "varchar", + [ + "256" + ] + ], + "nullable": false, + "default": null, + "charset": "utf8mb4", + "collation": "utf8mb4_bin", + "extra": [] + }, + "payload": { + "type": [ + "mediumblob", + [] + ], + "nullable": false, + "default": null, + "charset": null, + "collation": null, + "extra": [] + }, + "status": { + "type": [ + "varchar", + [ + "16" + ] + ], + "nullable": false, + "default": null, + "charset": "utf8mb4", + "collation": "utf8mb4_bin", + "extra": [] + }, + "available_at": { + "type": [ + "datetime", + [ + "6" + ] + ], + "nullable": false, + "default": null, + "charset": null, + "collation": null, + "extra": [] + }, + "lease_owner": { + "type": [ + "varchar", + [ + "128" + ] + ], + "nullable": true, + "default": null, + "charset": "utf8mb4", + "collation": "utf8mb4_bin", + "extra": [] + }, + "lease_fence": { + "type": [ + "bigint", + [] + ], + "nullable": false, + "default": null, + "charset": null, + "collation": null, + "extra": [] + }, + "lease_expires_at": { + "type": [ + "datetime", + [ + "6" + ] + ], + "nullable": true, + "default": null, + "charset": null, + "collation": null, + "extra": [] + }, + "attempt_count": { + "type": [ + "int", + [] + ], + "nullable": false, + "default": null, + "charset": null, + "collation": null, + "extra": [] + }, + "generation_attempt_count": { + "type": [ + "int", + [] + ], + "nullable": false, + "default": null, + "charset": null, + "collation": null, + "extra": [] + }, + "recovery_generation": { + "type": [ + "int", + [] + ], + "nullable": false, + "default": null, + "charset": null, + "collation": null, + "extra": [] + }, + "max_attempts": { + "type": [ + "int", + [] + ], + "nullable": false, + "default": null, + "charset": null, + "collation": null, + "extra": [] + }, + "cancel_requested": { + "type": [ + "bool", + [] + ], + "nullable": false, + "default": null, + "charset": null, + "collation": null, + "extra": [] + }, + "result_code": { + "type": [ + "varchar", + [ + "128" + ] + ], + "nullable": true, + "default": null, + "charset": "utf8mb4", + "collation": "utf8mb4_bin", + "extra": [] + }, + "result_payload": { + "type": [ + "mediumblob", + [] + ], + "nullable": true, + "default": null, + "charset": null, + "collation": null, + "extra": [] + }, + "error_category": { + "type": [ + "varchar", + [ + "64" + ] + ], + "nullable": true, + "default": null, + "charset": "utf8mb4", + "collation": "utf8mb4_bin", + "extra": [] + }, + "error_code": { + "type": [ + "varchar", + [ + "128" + ] + ], + "nullable": true, + "default": null, + "charset": "utf8mb4", + "collation": "utf8mb4_bin", + "extra": [] + }, + "state_version": { + "type": [ + "bigint", + [] + ], + "nullable": false, + "default": null, + "charset": null, + "collation": null, + "extra": [] + }, + "created_at": { + "type": [ + "datetime", + [ + "6" + ] + ], + "nullable": false, + "default": null, + "charset": null, + "collation": null, + "extra": [] + }, + "updated_at": { + "type": [ + "datetime", + [ + "6" + ] + ], + "nullable": false, + "default": null, + "charset": null, + "collation": null, + "extra": [] + }, + "completed_at": { + "type": [ + "datetime", + [ + "6" + ] + ], + "nullable": true, + "default": null, + "charset": null, + "collation": null, + "extra": [] + } + }, + "primary_key": [ + [ + "work_id", + null, + "asc" + ] + ], + "foreign_keys": [], + "checks": { + "ck_pc_work_items_status": { + "expression": [ + "in", + [ + "column", + "status" + ], + [ + [ + "literal", + "queued" + ], + [ + "literal", + "running" + ], + [ + "literal", + "retry_wait" + ], + [ + "literal", + "cancelling" + ], + [ + "literal", + "succeeded" + ], + [ + "literal", + "failed" + ], + [ + "literal", + "cancelled" + ] + ] + ], + "enforced": true + }, + "ck_pc_work_items_lane_sequence_positive": { + "expression": [ + "binary", + ">", + [ + "column", + "lane_sequence" + ], + [ + "number", + 0 + ] + ], + "enforced": true + }, + "ck_pc_work_items_payload_version_positive": { + "expression": [ + "binary", + ">", + [ + "column", + "payload_version" + ], + [ + "number", + 0 + ] + ], + "enforced": true + }, + "ck_pc_work_items_fence_nonnegative": { + "expression": [ + "binary", + ">=", + [ + "column", + "lease_fence" + ], + [ + "number", + 0 + ] + ], + "enforced": true + }, + "ck_pc_work_items_attempts_nonnegative": { + "expression": [ + "binary", + ">=", + [ + "column", + "attempt_count" + ], + [ + "number", + 0 + ] + ], + "enforced": true + }, + "ck_pc_work_items_generation_attempts_nonnegative": { + "expression": [ + "binary", + ">=", + [ + "column", + "generation_attempt_count" + ], + [ + "number", + 0 + ] + ], + "enforced": true + }, + "ck_pc_work_items_recovery_nonnegative": { + "expression": [ + "binary", + ">=", + [ + "column", + "recovery_generation" + ], + [ + "number", + 0 + ] + ], + "enforced": true + }, + "ck_pc_work_items_max_attempts_positive": { + "expression": [ + "binary", + ">", + [ + "column", + "max_attempts" + ], + [ + "number", + 0 + ] + ], + "enforced": true + }, + "ck_pc_work_items_state_version_positive": { + "expression": [ + "binary", + ">", + [ + "column", + "state_version" + ], + [ + "number", + 0 + ] + ], + "enforced": true + } + }, + "indexes": { + "uq_pc_work_items_lane_sequence": { + "columns": [ + [ + "lane_key", + null, + "asc" + ], + [ + "lane_sequence", + null, + "asc" + ] + ], + "unique": true + }, + "ix_pc_work_items_claim": { + "columns": [ + [ + "status", + null, + "asc" + ], + [ + "available_at", + null, + "asc" + ] + ], + "unique": false + }, + "ix_pc_work_items_completed": { + "columns": [ + [ + "completed_at", + null, + "asc" + ] + ], + "unique": false + }, + "ix_pc_work_items_lane_status": { + "columns": [ + [ + "lane_key", + null, + "asc" + ], + [ + "status", + null, + "asc" + ] + ], + "unique": false + }, + "ix_pc_work_items_scope_created": { + "columns": [ + [ + "scope_id", + null, + "asc" + ], + [ + "created_at", + null, + "asc" + ] + ], + "unique": false + } + } + } + }, + { + "pc_work_keys": { + "columns": { + "logical_key": { + "type": [ + "varchar", + [ + "64" + ] + ], + "nullable": false, + "default": null, + "charset": "utf8mb4", + "collation": "utf8mb4_bin", + "extra": [] + }, + "work_id": { + "type": [ + "varchar", + [ + "36" + ] + ], + "nullable": false, + "default": null, + "charset": "utf8mb4", + "collation": "utf8mb4_bin", + "extra": [] + }, + "lane_key": { + "type": [ + "varchar", + [ + "64" + ] + ], + "nullable": false, + "default": null, + "charset": "utf8mb4", + "collation": "utf8mb4_bin", + "extra": [] + }, + "created_at": { + "type": [ + "datetime", + [ + "6" + ] + ], + "nullable": false, + "default": null, + "charset": null, + "collation": null, + "extra": [] + } + }, + "primary_key": [ + [ + "logical_key", + null, + "asc" + ] + ], + "foreign_keys": [], + "checks": {}, + "indexes": { + "uq_pc_work_keys_work_id": { + "columns": [ + [ + "work_id", + null, + "asc" + ] + ], + "unique": true + } + } + } + }, + { + "pc_work_attempts": { + "columns": { + "work_id": { + "type": [ + "varchar", + [ + "36" + ] + ], + "nullable": false, + "default": null, + "charset": "utf8mb4", + "collation": "utf8mb4_bin", + "extra": [] + }, + "attempt_no": { + "type": [ + "int", + [] + ], + "nullable": false, + "default": null, + "charset": null, + "collation": null, + "extra": [] + }, + "recovery_generation": { + "type": [ + "int", + [] + ], + "nullable": false, + "default": null, + "charset": null, + "collation": null, + "extra": [] + }, + "owner_id": { + "type": [ + "varchar", + [ + "128" + ] + ], + "nullable": false, + "default": null, + "charset": "utf8mb4", + "collation": "utf8mb4_bin", + "extra": [] + }, + "fence": { + "type": [ + "bigint", + [] + ], + "nullable": false, + "default": null, + "charset": null, + "collation": null, + "extra": [] + }, + "started_at": { + "type": [ + "datetime", + [ + "6" + ] + ], + "nullable": false, + "default": null, + "charset": null, + "collation": null, + "extra": [] + }, + "heartbeat_at": { + "type": [ + "datetime", + [ + "6" + ] + ], + "nullable": false, + "default": null, + "charset": null, + "collation": null, + "extra": [] + }, + "finished_at": { + "type": [ + "datetime", + [ + "6" + ] + ], + "nullable": true, + "default": null, + "charset": null, + "collation": null, + "extra": [] + }, + "outcome": { + "type": [ + "varchar", + [ + "32" + ] + ], + "nullable": true, + "default": null, + "charset": "utf8mb4", + "collation": "utf8mb4_bin", + "extra": [] + }, + "error_category": { + "type": [ + "varchar", + [ + "64" + ] + ], + "nullable": true, + "default": null, + "charset": "utf8mb4", + "collation": "utf8mb4_bin", + "extra": [] + }, + "error_code": { + "type": [ + "varchar", + [ + "128" + ] + ], + "nullable": true, + "default": null, + "charset": "utf8mb4", + "collation": "utf8mb4_bin", + "extra": [] + }, + "trace_id": { + "type": [ + "varchar", + [ + "32" + ] + ], + "nullable": true, + "default": null, + "charset": "utf8mb4", + "collation": "utf8mb4_bin", + "extra": [] + }, + "span_id": { + "type": [ + "varchar", + [ + "16" + ] + ], + "nullable": true, + "default": null, + "charset": "utf8mb4", + "collation": "utf8mb4_bin", + "extra": [] + } + }, + "primary_key": [ + [ + "work_id", + null, + "asc" + ], + [ + "attempt_no", + null, + "asc" + ] + ], + "foreign_keys": [], + "checks": { + "ck_pc_work_attempts_attempt_positive": { + "expression": [ + "binary", + ">", + [ + "column", + "attempt_no" + ], + [ + "number", + 0 + ] + ], + "enforced": true + }, + "ck_pc_work_attempts_recovery_nonnegative": { + "expression": [ + "binary", + ">=", + [ + "column", + "recovery_generation" + ], + [ + "number", + 0 + ] + ], + "enforced": true + }, + "ck_pc_work_attempts_fence_positive": { + "expression": [ + "binary", + ">", + [ + "column", + "fence" + ], + [ + "number", + 0 + ] + ], + "enforced": true + } + }, + "indexes": {} + } + }, + { + "pc_work_attempts": { + "columns": { + "work_id": { + "type": [ + "varchar", + [ + "36" + ] + ], + "nullable": false, + "default": null, + "charset": "utf8mb4", + "collation": "utf8mb4_bin", + "extra": [] + }, + "attempt_no": { + "type": [ + "int", + [] + ], + "nullable": false, + "default": null, + "charset": null, + "collation": null, + "extra": [] + }, + "recovery_generation": { + "type": [ + "int", + [] + ], + "nullable": false, + "default": null, + "charset": null, + "collation": null, + "extra": [] + }, + "owner_id": { + "type": [ + "varchar", + [ + "128" + ] + ], + "nullable": false, + "default": null, + "charset": "utf8mb4", + "collation": "utf8mb4_bin", + "extra": [] + }, + "fence": { + "type": [ + "bigint", + [] + ], + "nullable": false, + "default": null, + "charset": null, + "collation": null, + "extra": [] + }, + "started_at": { + "type": [ + "datetime", + [ + "6" + ] + ], + "nullable": false, + "default": null, + "charset": null, + "collation": null, + "extra": [] + }, + "heartbeat_at": { + "type": [ + "datetime", + [ + "6" + ] + ], + "nullable": false, + "default": null, + "charset": null, + "collation": null, + "extra": [] + }, + "finished_at": { + "type": [ + "datetime", + [ + "6" + ] + ], + "nullable": true, + "default": null, + "charset": null, + "collation": null, + "extra": [] + }, + "outcome": { + "type": [ + "varchar", + [ + "32" + ] + ], + "nullable": true, + "default": null, + "charset": "utf8mb4", + "collation": "utf8mb4_bin", + "extra": [] + }, + "error_category": { + "type": [ + "varchar", + [ + "64" + ] + ], + "nullable": true, + "default": null, + "charset": "utf8mb4", + "collation": "utf8mb4_bin", + "extra": [] + }, + "error_code": { + "type": [ + "varchar", + [ + "128" + ] + ], + "nullable": true, + "default": null, + "charset": "utf8mb4", + "collation": "utf8mb4_bin", + "extra": [] + }, + "trace_id": { + "type": [ + "varchar", + [ + "32" + ] + ], + "nullable": true, + "default": null, + "charset": "utf8mb4", + "collation": "utf8mb4_bin", + "extra": [] + }, + "span_id": { + "type": [ + "varchar", + [ + "16" + ] + ], + "nullable": true, + "default": null, + "charset": "utf8mb4", + "collation": "utf8mb4_bin", + "extra": [] + } + }, + "primary_key": [ + [ + "work_id", + null, + "asc" + ], + [ + "attempt_no", + null, + "asc" + ] + ], + "foreign_keys": [], + "checks": { + "ck_pc_work_attempts_attempt_positive": { + "expression": [ + "binary", + ">", + [ + "column", + "attempt_no" + ], + [ + "number", + 0 + ] + ], + "enforced": true + }, + "ck_pc_work_attempts_recovery_nonnegative": { + "expression": [ + "binary", + ">=", + [ + "column", + "recovery_generation" + ], + [ + "number", + 0 + ] + ], + "enforced": true + }, + "ck_pc_work_attempts_fence_positive": { + "expression": [ + "binary", + ">", + [ + "column", + "fence" + ], + [ + "number", + 0 + ] + ], + "enforced": true + } + }, + "indexes": { + "ix_pc_work_attempts_finished": { + "columns": [ + [ + "finished_at", + null, + "asc" + ] + ], + "unique": false + } + } + } + }, + { + "pc_scheduler_leases": { + "columns": { + "lease_name": { + "type": [ + "varchar", + [ + "64" + ] + ], + "nullable": false, + "default": null, + "charset": "utf8mb4", + "collation": "utf8mb4_bin", + "extra": [] + }, + "owner_id": { + "type": [ + "varchar", + [ + "128" + ] + ], + "nullable": true, + "default": null, + "charset": "utf8mb4", + "collation": "utf8mb4_bin", + "extra": [] + }, + "fence": { + "type": [ + "bigint", + [] + ], + "nullable": false, + "default": null, + "charset": null, + "collation": null, + "extra": [] + }, + "lease_expires_at": { + "type": [ + "datetime", + [ + "6" + ] + ], + "nullable": true, + "default": null, + "charset": null, + "collation": null, + "extra": [] + }, + "updated_at": { + "type": [ + "datetime", + [ + "6" + ] + ], + "nullable": false, + "default": null, + "charset": null, + "collation": null, + "extra": [] + } + }, + "primary_key": [ + [ + "lease_name", + null, + "asc" + ] + ], + "foreign_keys": [], + "checks": { + "ck_pc_scheduler_leases_fence_nonnegative": { + "expression": [ + "binary", + ">=", + [ + "column", + "fence" + ], + [ + "number", + 0 + ] + ], + "enforced": true + } + }, + "indexes": {} + } + }, + { + "pc_scheduler_scans": { + "columns": { + "discoverer": { + "type": [ + "varchar", + [ + "128" + ] + ], + "nullable": false, + "default": null, + "charset": "utf8mb4", + "collation": "utf8mb4_bin", + "extra": [] + }, + "next_run_at": { + "type": [ + "datetime", + [ + "6" + ] + ], + "nullable": false, + "default": null, + "charset": null, + "collation": null, + "extra": [] + }, + "continuation": { + "type": [ + "varchar", + [ + "256" + ] + ], + "nullable": true, + "default": null, + "charset": "utf8mb4", + "collation": "utf8mb4_bin", + "extra": [] + }, + "state_version": { + "type": [ + "bigint", + [] + ], + "nullable": false, + "default": null, + "charset": null, + "collation": null, + "extra": [] + }, + "updated_at": { + "type": [ + "datetime", + [ + "6" + ] + ], + "nullable": false, + "default": null, + "charset": null, + "collation": null, + "extra": [] + } + }, + "primary_key": [ + [ + "discoverer", + null, + "asc" + ] + ], + "foreign_keys": [], + "checks": { + "ck_pc_scheduler_scans_version_positive": { + "expression": [ + "binary", + ">", + [ + "column", + "state_version" + ], + [ + "number", + 0 + ] + ], + "enforced": true + } + }, + "indexes": {} + } + }, + { + "pc_runtime_members": { + "columns": { + "member_id": { + "type": [ + "varchar", + [ + "128" + ] + ], + "nullable": false, + "default": null, + "charset": "utf8mb4", + "collation": "utf8mb4_bin", + "extra": [] + }, + "role": { + "type": [ + "varchar", + [ + "16" + ] + ], + "nullable": false, + "default": null, + "charset": "utf8mb4", + "collation": "utf8mb4_bin", + "extra": [] + }, + "build_version": { + "type": [ + "varchar", + [ + "64" + ] + ], + "nullable": false, + "default": null, + "charset": "utf8mb4", + "collation": "utf8mb4_bin", + "extra": [] + }, + "schema_min": { + "type": [ + "int", + [] + ], + "nullable": false, + "default": null, + "charset": null, + "collation": null, + "extra": [] + }, + "schema_max": { + "type": [ + "int", + [] + ], + "nullable": false, + "default": null, + "charset": null, + "collation": null, + "extra": [] + }, + "payload_min": { + "type": [ + "int", + [] + ], + "nullable": false, + "default": null, + "charset": null, + "collation": null, + "extra": [] + }, + "payload_max": { + "type": [ + "int", + [] + ], + "nullable": false, + "default": null, + "charset": null, + "collation": null, + "extra": [] + }, + "behavior_revision": { + "type": [ + "varchar", + [ + "128" + ] + ], + "nullable": false, + "default": null, + "charset": "utf8mb4", + "collation": "utf8mb4_bin", + "extra": [] + }, + "heartbeat_at": { + "type": [ + "datetime", + [ + "6" + ] + ], + "nullable": false, + "default": null, + "charset": null, + "collation": null, + "extra": [] + }, + "expires_at": { + "type": [ + "datetime", + [ + "6" + ] + ], + "nullable": false, + "default": null, + "charset": null, + "collation": null, + "extra": [] + } + }, + "primary_key": [ + [ + "member_id", + null, + "asc" + ] + ], + "foreign_keys": [], + "checks": { + "ck_pc_runtime_members_role": { + "expression": [ + "in", + [ + "column", + "role" + ], + [ + [ + "literal", + "all" + ], + [ + "literal", + "api" + ], + [ + "literal", + "scheduler" + ], + [ + "literal", + "worker" + ] + ] + ], + "enforced": true + }, + "ck_pc_runtime_members_schema_range": { + "expression": [ + "binary", + "and", + [ + "binary", + ">", + [ + "column", + "schema_min" + ], + [ + "number", + 0 + ] + ], + [ + "binary", + ">=", + [ + "column", + "schema_max" + ], + [ + "column", + "schema_min" + ] + ] + ], + "enforced": true + }, + "ck_pc_runtime_members_payload_range": { + "expression": [ + "binary", + "and", + [ + "binary", + ">", + [ + "column", + "payload_min" + ], + [ + "number", + 0 + ] + ], + [ + "binary", + ">=", + [ + "column", + "payload_max" + ], + [ + "column", + "payload_min" + ] + ] + ], + "enforced": true + } + }, + "indexes": {} + } + }, + { + "pc_runtime_members": { + "columns": { + "member_id": { + "type": [ + "varchar", + [ + "128" + ] + ], + "nullable": false, + "default": null, + "charset": "utf8mb4", + "collation": "utf8mb4_bin", + "extra": [] + }, + "role": { + "type": [ + "varchar", + [ + "16" + ] + ], + "nullable": false, + "default": null, + "charset": "utf8mb4", + "collation": "utf8mb4_bin", + "extra": [] + }, + "build_version": { + "type": [ + "varchar", + [ + "64" + ] + ], + "nullable": false, + "default": null, + "charset": "utf8mb4", + "collation": "utf8mb4_bin", + "extra": [] + }, + "schema_min": { + "type": [ + "int", + [] + ], + "nullable": false, + "default": null, + "charset": null, + "collation": null, + "extra": [] + }, + "schema_max": { + "type": [ + "int", + [] + ], + "nullable": false, + "default": null, + "charset": null, + "collation": null, + "extra": [] + }, + "payload_min": { + "type": [ + "int", + [] + ], + "nullable": false, + "default": null, + "charset": null, + "collation": null, + "extra": [] + }, + "payload_max": { + "type": [ + "int", + [] + ], + "nullable": false, + "default": null, + "charset": null, + "collation": null, + "extra": [] + }, + "behavior_revision": { + "type": [ + "varchar", + [ + "128" + ] + ], + "nullable": false, + "default": null, + "charset": "utf8mb4", + "collation": "utf8mb4_bin", + "extra": [] + }, + "heartbeat_at": { + "type": [ + "datetime", + [ + "6" + ] + ], + "nullable": false, + "default": null, + "charset": null, + "collation": null, + "extra": [] + }, + "expires_at": { + "type": [ + "datetime", + [ + "6" + ] + ], + "nullable": false, + "default": null, + "charset": null, + "collation": null, + "extra": [] + } + }, + "primary_key": [ + [ + "member_id", + null, + "asc" + ] + ], + "foreign_keys": [], + "checks": { + "ck_pc_runtime_members_role": { + "expression": [ + "in", + [ + "column", + "role" + ], + [ + [ + "literal", + "all" + ], + [ + "literal", + "api" + ], + [ + "literal", + "scheduler" + ], + [ + "literal", + "worker" + ] + ] + ], + "enforced": true + }, + "ck_pc_runtime_members_schema_range": { + "expression": [ + "binary", + "and", + [ + "binary", + ">", + [ + "column", + "schema_min" + ], + [ + "number", + 0 + ] + ], + [ + "binary", + ">=", + [ + "column", + "schema_max" + ], + [ + "column", + "schema_min" + ] + ] + ], + "enforced": true + }, + "ck_pc_runtime_members_payload_range": { + "expression": [ + "binary", + "and", + [ + "binary", + ">", + [ + "column", + "payload_min" + ], + [ + "number", + 0 + ] + ], + [ + "binary", + ">=", + [ + "column", + "payload_max" + ], + [ + "column", + "payload_min" + ] + ] + ], + "enforced": true + } + }, + "indexes": { + "ix_pc_runtime_members_role_expiry": { + "columns": [ + [ + "role", + null, + "asc" + ], + [ + "expires_at", + null, + "asc" + ] + ], + "unique": false + } + } + } + }, + { + "pc_rate_limit_windows": { + "columns": { + "principal_key": { + "type": [ + "varchar", + [ + "64" + ] + ], + "nullable": false, + "default": null, + "charset": "utf8mb4", + "collation": "utf8mb4_bin", + "extra": [] + }, + "policy_id": { + "type": [ + "varchar", + [ + "64" + ] + ], + "nullable": false, + "default": null, + "charset": "utf8mb4", + "collation": "utf8mb4_bin", + "extra": [] + }, + "window_started_at": { + "type": [ + "datetime", + [ + "6" + ] + ], + "nullable": false, + "default": null, + "charset": null, + "collation": null, + "extra": [] + }, + "request_count": { + "type": [ + "bigint", + [] + ], + "nullable": false, + "default": null, + "charset": null, + "collation": null, + "extra": [] + }, + "expires_at": { + "type": [ + "datetime", + [ + "6" + ] + ], + "nullable": false, + "default": null, + "charset": null, + "collation": null, + "extra": [] + } + }, + "primary_key": [ + [ + "principal_key", + null, + "asc" + ], + [ + "policy_id", + null, + "asc" + ], + [ + "window_started_at", + null, + "asc" + ] + ], + "foreign_keys": [], + "checks": { + "ck_pc_rate_limit_windows_count_positive": { + "expression": [ + "binary", + ">", + [ + "column", + "request_count" + ], + [ + "number", + 0 + ] + ], + "enforced": true + } + }, + "indexes": {} + } + }, + { + "pc_rate_limit_windows": { + "columns": { + "principal_key": { + "type": [ + "varchar", + [ + "64" + ] + ], + "nullable": false, + "default": null, + "charset": "utf8mb4", + "collation": "utf8mb4_bin", + "extra": [] + }, + "policy_id": { + "type": [ + "varchar", + [ + "64" + ] + ], + "nullable": false, + "default": null, + "charset": "utf8mb4", + "collation": "utf8mb4_bin", + "extra": [] + }, + "window_started_at": { + "type": [ + "datetime", + [ + "6" + ] + ], + "nullable": false, + "default": null, + "charset": null, + "collation": null, + "extra": [] + }, + "request_count": { + "type": [ + "bigint", + [] + ], + "nullable": false, + "default": null, + "charset": null, + "collation": null, + "extra": [] + }, + "expires_at": { + "type": [ + "datetime", + [ + "6" + ] + ], + "nullable": false, + "default": null, + "charset": null, + "collation": null, + "extra": [] + } + }, + "primary_key": [ + [ + "principal_key", + null, + "asc" + ], + [ + "policy_id", + null, + "asc" + ], + [ + "window_started_at", + null, + "asc" + ] + ], + "foreign_keys": [], + "checks": { + "ck_pc_rate_limit_windows_count_positive": { + "expression": [ + "binary", + ">", + [ + "column", + "request_count" + ], + [ + "number", + 0 + ] + ], + "enforced": true + } + }, + "indexes": { + "ix_pc_rate_limit_windows_expiry": { + "columns": [ + [ + "expires_at", + null, + "asc" + ] + ], + "unique": false + } + } + } + } + ] + } + } +} diff --git a/src/powercontext/builtin/persistence/migrations/server_resources/schemas/sqlite-v1_4_1_005.json b/src/powercontext/builtin/persistence/migrations/server_resources/schemas/sqlite-v1_4_1_005.json new file mode 100644 index 0000000000..b9a9cf34e0 --- /dev/null +++ b/src/powercontext/builtin/persistence/migrations/server_resources/schemas/sqlite-v1_4_1_005.json @@ -0,0 +1,19 @@ +{ + "create": [ + "CREATE TABLE pc_work_lanes (\n\tlane_key VARCHAR(64) NOT NULL, \n\thead_sequence BIGINT, \n\tnext_sequence BIGINT NOT NULL, \n\tPRIMARY KEY (lane_key), \n\tCONSTRAINT ck_pc_work_lanes_head_positive CHECK (head_sequence IS NULL OR head_sequence > 0), \n\tCONSTRAINT ck_pc_work_lanes_next_positive CHECK (next_sequence > 0)\n)", + "CREATE TABLE pc_work_items (\n\twork_id VARCHAR(36) NOT NULL, \n\tlogical_key VARCHAR(64) NOT NULL, \n\tlane_key VARCHAR(64) NOT NULL, \n\tlane_sequence BIGINT NOT NULL, \n\tkind VARCHAR(128) NOT NULL, \n\tpayload_version INTEGER NOT NULL, \n\tscope_id VARCHAR(256) NOT NULL, \n\tpayload BLOB NOT NULL, \n\tstatus VARCHAR(16) NOT NULL, \n\tavailable_at DATETIME NOT NULL, \n\tlease_owner VARCHAR(128), \n\tlease_fence BIGINT NOT NULL, \n\tlease_expires_at DATETIME, \n\tattempt_count INTEGER NOT NULL, \n\tgeneration_attempt_count INTEGER NOT NULL, \n\trecovery_generation INTEGER NOT NULL, \n\tmax_attempts INTEGER NOT NULL, \n\tcancel_requested BOOLEAN NOT NULL, \n\tresult_code VARCHAR(128), \n\tresult_payload BLOB, \n\terror_category VARCHAR(64), \n\terror_code VARCHAR(128), \n\tstate_version BIGINT NOT NULL, \n\tcreated_at DATETIME NOT NULL, \n\tupdated_at DATETIME NOT NULL, \n\tcompleted_at DATETIME, \n\tPRIMARY KEY (work_id), \n\tCONSTRAINT uq_pc_work_items_lane_sequence UNIQUE (lane_key, lane_sequence), \n\tCONSTRAINT ck_pc_work_items_status CHECK (status IN ('queued', 'running', 'retry_wait', 'cancelling', 'succeeded', 'failed', 'cancelled')), \n\tCONSTRAINT ck_pc_work_items_lane_sequence_positive CHECK (lane_sequence > 0), \n\tCONSTRAINT ck_pc_work_items_payload_version_positive CHECK (payload_version > 0), \n\tCONSTRAINT ck_pc_work_items_fence_nonnegative CHECK (lease_fence >= 0), \n\tCONSTRAINT ck_pc_work_items_attempts_nonnegative CHECK (attempt_count >= 0), \n\tCONSTRAINT ck_pc_work_items_generation_attempts_nonnegative CHECK (generation_attempt_count >= 0), \n\tCONSTRAINT ck_pc_work_items_recovery_nonnegative CHECK (recovery_generation >= 0), \n\tCONSTRAINT ck_pc_work_items_max_attempts_positive CHECK (max_attempts > 0), \n\tCONSTRAINT ck_pc_work_items_state_version_positive CHECK (state_version > 0)\n)", + "CREATE INDEX ix_pc_work_items_claim ON pc_work_items (status, available_at)", + "CREATE INDEX ix_pc_work_items_completed ON pc_work_items (completed_at)", + "CREATE INDEX ix_pc_work_items_lane_status ON pc_work_items (lane_key, status)", + "CREATE INDEX ix_pc_work_items_scope_created ON pc_work_items (scope_id, created_at)", + "CREATE TABLE pc_work_keys (\n\tlogical_key VARCHAR(64) NOT NULL, \n\twork_id VARCHAR(36) NOT NULL, \n\tlane_key VARCHAR(64) NOT NULL, \n\tcreated_at DATETIME NOT NULL, \n\tPRIMARY KEY (logical_key), \n\tCONSTRAINT uq_pc_work_keys_work_id UNIQUE (work_id)\n)", + "CREATE TABLE pc_work_attempts (\n\twork_id VARCHAR(36) NOT NULL, \n\tattempt_no INTEGER NOT NULL, \n\trecovery_generation INTEGER NOT NULL, \n\towner_id VARCHAR(128) NOT NULL, \n\tfence BIGINT NOT NULL, \n\tstarted_at DATETIME NOT NULL, \n\theartbeat_at DATETIME NOT NULL, \n\tfinished_at DATETIME, \n\toutcome VARCHAR(32), \n\terror_category VARCHAR(64), \n\terror_code VARCHAR(128), \n\ttrace_id VARCHAR(32), \n\tspan_id VARCHAR(16), \n\tPRIMARY KEY (work_id, attempt_no), \n\tCONSTRAINT ck_pc_work_attempts_attempt_positive CHECK (attempt_no > 0), \n\tCONSTRAINT ck_pc_work_attempts_recovery_nonnegative CHECK (recovery_generation >= 0), \n\tCONSTRAINT ck_pc_work_attempts_fence_positive CHECK (fence > 0)\n)", + "CREATE INDEX ix_pc_work_attempts_finished ON pc_work_attempts (finished_at)", + "CREATE TABLE pc_scheduler_leases (\n\tlease_name VARCHAR(64) NOT NULL, \n\towner_id VARCHAR(128), \n\tfence BIGINT NOT NULL, \n\tlease_expires_at DATETIME, \n\tupdated_at DATETIME NOT NULL, \n\tPRIMARY KEY (lease_name), \n\tCONSTRAINT ck_pc_scheduler_leases_fence_nonnegative CHECK (fence >= 0)\n)", + "CREATE TABLE pc_scheduler_scans (\n\tdiscoverer VARCHAR(128) NOT NULL, \n\tnext_run_at DATETIME NOT NULL, \n\tcontinuation VARCHAR(256), \n\tstate_version BIGINT NOT NULL, \n\tupdated_at DATETIME NOT NULL, \n\tPRIMARY KEY (discoverer), \n\tCONSTRAINT ck_pc_scheduler_scans_version_positive CHECK (state_version > 0)\n)", + "CREATE TABLE pc_runtime_members (\n\tmember_id VARCHAR(128) NOT NULL, \n\trole VARCHAR(16) NOT NULL, \n\tbuild_version VARCHAR(64) NOT NULL, \n\tschema_min INTEGER NOT NULL, \n\tschema_max INTEGER NOT NULL, \n\tpayload_min INTEGER NOT NULL, \n\tpayload_max INTEGER NOT NULL, \n\tbehavior_revision VARCHAR(128) NOT NULL, \n\theartbeat_at DATETIME NOT NULL, \n\texpires_at DATETIME NOT NULL, \n\tPRIMARY KEY (member_id), \n\tCONSTRAINT ck_pc_runtime_members_role CHECK (role IN ('all', 'api', 'scheduler', 'worker')), \n\tCONSTRAINT ck_pc_runtime_members_schema_range CHECK (schema_min > 0 AND schema_max >= schema_min), \n\tCONSTRAINT ck_pc_runtime_members_payload_range CHECK (payload_min > 0 AND payload_max >= payload_min)\n)", + "CREATE INDEX ix_pc_runtime_members_role_expiry ON pc_runtime_members (role, expires_at)", + "CREATE TABLE pc_rate_limit_windows (\n\tprincipal_key VARCHAR(64) NOT NULL, \n\tpolicy_id VARCHAR(64) NOT NULL, \n\twindow_started_at DATETIME NOT NULL, \n\trequest_count BIGINT NOT NULL, \n\texpires_at DATETIME NOT NULL, \n\tPRIMARY KEY (principal_key, policy_id, window_started_at), \n\tCONSTRAINT ck_pc_rate_limit_windows_count_positive CHECK (request_count > 0)\n)", + "CREATE INDEX ix_pc_rate_limit_windows_expiry ON pc_rate_limit_windows (expires_at)" + ] +} diff --git a/src/powercontext/builtin/persistence/migrations/server_resources/versions/v1_4_1_005_work_ledger.py b/src/powercontext/builtin/persistence/migrations/server_resources/versions/v1_4_1_005_work_ledger.py new file mode 100644 index 0000000000..8f41bbe770 --- /dev/null +++ b/src/powercontext/builtin/persistence/migrations/server_resources/versions/v1_4_1_005_work_ledger.py @@ -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. + +"""Add the durable Work Ledger and its scheduler coordination tables. + +The revision follows ``v1_4_1_004`` rather than branching off ``s1402``: the +bundle requires one linear chain, and master's owner-recovery revision already +claimed the slot after ``s1402``. The release-based name follows the same rule +that named ``v1_4_1_004``. +""" + +import json +from pathlib import Path + +from alembic import op + +revision = "v1_4_1_005" +down_revision = "v1_4_1_004" + + +def upgrade(): + resource = Path(__file__).parents[1] / "schemas/sqlite-v1_4_1_005.json" + connection = op.get_bind() + for statement in json.loads(resource.read_text())["create"]: + connection.exec_driver_sql(statement) diff --git a/src/powercontext/builtin/persistence/migrations/server_sources/1.4.0/manifest.json b/src/powercontext/builtin/persistence/migrations/server_sources/1.4.0/manifest.json index 0e52e9ff79..945d2754cb 100644 --- a/src/powercontext/builtin/persistence/migrations/server_sources/1.4.0/manifest.json +++ b/src/powercontext/builtin/persistence/migrations/server_sources/1.4.0/manifest.json @@ -23,6 +23,10 @@ "v1_4_1_004": [ "schemas/sqlite-v1_4_1_004.json", "schemas/mysql-v1_4_1_004.json" + ], + "v1_4_1_005": [ + "schemas/sqlite-v1_4_1_005.json", + "schemas/mysql-v1_4_1_005.json" ] }, "sqlite_baselines": { @@ -43,6 +47,9 @@ ], "v1_4_1_004": [ "84d736b5e722a56ebe8a32318732543d43548a63a43709dbf0c571e7e4af83b5" + ], + "v1_4_1_005": [ + "92eaad28512ba8a03917acdd9a0996523a67450092e03161eca68e4e0f9d4cb6" ] }, "affected_objects": [ @@ -137,7 +144,15 @@ "pc_retained_dream_candidate_versions", "pc_memory_artifact_archive", "pc_portable_restore_receipts", - "pc_access_owner_recovery" + "pc_access_owner_recovery", + "pc_work_lanes", + "pc_work_items", + "pc_work_keys", + "pc_work_attempts", + "pc_scheduler_leases", + "pc_scheduler_scans", + "pc_runtime_members", + "pc_rate_limit_windows" ], "readiness_scope": "server", "retained_objects": [ @@ -199,6 +214,10 @@ "v1_4_1_004": [ "server_upgrade", "portable_storage" + ], + "v1_4_1_005": [ + "server_upgrade", + "portable_storage" ] }, "retained_fingerprints": { @@ -214,7 +233,8 @@ "schemas/mysql-s1400.json", "schemas/mysql-s1401.json", "schemas/mysql-s1402.json", - "schemas/mysql-v1_4_1_004.json" + "schemas/mysql-v1_4_1_004.json", + "schemas/mysql-v1_4_1_005.json" ], "mysql_history": "mysql", "mysql_schema_extensions": { diff --git a/src/powercontext/builtin/persistence/migrations/vector_profile.py b/src/powercontext/builtin/persistence/migrations/vector_profile.py index f2a4d2814d..afeb9b28a0 100644 --- a/src/powercontext/builtin/persistence/migrations/vector_profile.py +++ b/src/powercontext/builtin/persistence/migrations/vector_profile.py @@ -85,6 +85,9 @@ def sqlite_layouts(self, directory: Path) -> dict[str, list[list[Any]]]: layouts["v1_4_1_004"] = layouts["s1402"] + [ ["table", "pc_access_owner_recovery", "pc_access_owner_recovery", owner] ] + if "v1_4_1_005" in manifest["revision_resources"]: + ledger = json.loads((directory / "schemas/sqlite-v1_4_1_005.json").read_text(encoding="utf-8"))["create"] + layouts["v1_4_1_005"] = layouts["v1_4_1_004"] + [registry_row(statement) for statement in ledger] for entries in layouts.values(): entries.sort(key=lambda row: (row[0], row[1])) return layouts @@ -109,6 +112,21 @@ def mysql_tables(self) -> dict[str, str]: return copy.deepcopy(self.layout["mysql"]) +def registry_row(statement: str) -> list[str]: + """Map one frozen DDL statement onto the identity row SQLite would record.""" + + tokens = statement.split() + if len(tokens) < 3 or tokens[0].upper() != "CREATE": + raise MigrationError("invalid_bundle", "A frozen schema statement is not a CREATE.") + kind = tokens[1].upper() + if kind == "TABLE": + name = tokens[2] + return ["table", name, name, statement] + if kind == "INDEX" and len(tokens) > 4 and tokens[3].upper() == "ON": + return ["index", tokens[2], tokens[4], statement] + raise MigrationError("invalid_bundle", "A frozen schema statement has no registry identity.") + + def read_embedding_profile(connection: Connection) -> EmbeddingProfile | None: """Read a managed configuration declaration; never infer it from vector bytes.""" if "pc_server_storage_profile" not in inspect(connection).get_table_names(): diff --git a/src/powercontext/builtin/persistence/oceanbase/profile.py b/src/powercontext/builtin/persistence/oceanbase/profile.py index bf8b779b90..eafda4f4ae 100644 --- a/src/powercontext/builtin/persistence/oceanbase/profile.py +++ b/src/powercontext/builtin/persistence/oceanbase/profile.py @@ -121,6 +121,7 @@ async def open( config: OceanBaseConfig, *, tables: tuple[Table, ...], + create_schema: bool = True, _archive_recovery: bool = False, ) -> AsyncIterator[OceanBaseProfile]: """Create, initialize and exclusively own an official OceanBase engine.""" @@ -139,7 +140,9 @@ async def open( ) database = AsyncDatabase.own(engine) async with _initialized_profile( - cls(database=database, tables=tables), _archive_recovery=_archive_recovery + cls(database=database, tables=tables), + create_schema=create_schema, + _archive_recovery=_archive_recovery, ) as profile: yield profile @@ -150,18 +153,22 @@ async def attach( engine: AsyncEngine, *, tables: tuple[Table, ...], + create_schema: bool = True, ) -> AsyncIterator[OceanBaseProfile]: """Initialize a caller-owned official OceanBase engine without disposing it.""" _validate_oceanbase_url(engine.url) database = AsyncDatabase.attach(engine) - async with _initialized_profile(cls(database=database, tables=tables)) as profile: + async with _initialized_profile(cls(database=database, tables=tables), create_schema=create_schema) as profile: yield profile @asynccontextmanager async def _initialized_profile( - profile: OceanBaseProfile, *, _archive_recovery: bool = False + profile: OceanBaseProfile, + *, + create_schema: bool, + _archive_recovery: bool = False, ) -> AsyncIterator[OceanBaseProfile]: try: async with profile.database.transaction() as connection: @@ -173,7 +180,8 @@ async def _initialized_profile( ) async with profile.database.transaction() as connection: await _require_compatible_identity_collations(connection, profile.tables) - await create_tables(connection, profile.tables) + if create_schema: + await create_tables(connection, profile.tables) yield profile finally: await profile.database.close() diff --git a/src/powercontext/builtin/persistence/rate_limit.py b/src/powercontext/builtin/persistence/rate_limit.py new file mode 100644 index 0000000000..a59c5131ba --- /dev/null +++ b/src/powercontext/builtin/persistence/rate_limit.py @@ -0,0 +1,216 @@ +# 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. + +"""Database-time fixed-window rate limiting for horizontally scaled APIs.""" + +from __future__ import annotations + +import math +from collections.abc import Mapping +from dataclasses import dataclass +from datetime import UTC, datetime, timedelta +from typing import Any + +from sqlalchemy import delete, select, update +from sqlalchemy.ext.asyncio import AsyncConnection + +from powercontext.builtin.persistence.database import database_now, insert_if_absent +from powercontext.builtin.persistence.errors import InvalidRepositoryArgumentError, InvalidStoredColumnError +from powercontext.builtin.persistence.tables import RATE_LIMIT_WINDOWS_TABLE + + +@dataclass(frozen=True, slots=True) +class RateLimitDecision: + """One atomic counter decision.""" + + allowed: bool + remaining: int + retry_after_seconds: int + + +class RateLimitRepository: + """Increment one shared fixed window under a row lock.""" + + async def consume( + self, + connection: AsyncConnection, + *, + principal_key: str, + policy_id: str, + limit: int, + window_seconds: int, + ) -> RateLimitDecision: + _require_digest(principal_key) + _require_text("policy_id", policy_id, 64) + _require_positive("limit", limit) + _require_positive("window_seconds", window_seconds) + now = await database_now(connection) + window_start = _window_start(now, window_seconds) + expires_at = window_start + timedelta(seconds=window_seconds) + row, created = await _lock_or_create_window( + connection, + principal_key=principal_key, + policy_id=policy_id, + window_start=window_start, + expires_at=expires_at, + ) + count = _positive_integer(row["request_count"], "request_count") + retry_after = max(1, math.ceil((expires_at - now).total_seconds())) + if created: + return RateLimitDecision( + allowed=True, + remaining=max(limit - count, 0), + retry_after_seconds=retry_after, + ) + if count >= limit: + return RateLimitDecision(allowed=False, remaining=0, retry_after_seconds=retry_after) + next_count = count + 1 + await connection.execute( + update(RATE_LIMIT_WINDOWS_TABLE) + .where( + RATE_LIMIT_WINDOWS_TABLE.c.principal_key == principal_key, + RATE_LIMIT_WINDOWS_TABLE.c.policy_id == policy_id, + RATE_LIMIT_WINDOWS_TABLE.c.window_started_at == window_start, + ) + .values(request_count=next_count) + ) + return RateLimitDecision( + allowed=True, + remaining=max(limit - next_count, 0), + retry_after_seconds=retry_after, + ) + + async def purge_expired(self, connection: AsyncConnection, /, *, limit: int = 500) -> int: + """Delete one bounded batch of expired fixed-window counters.""" + + if limit < 1 or limit > 500: + raise InvalidRepositoryArgumentError("limit", "must be between 1 and 500") + now = await database_now(connection) + rows = ( + await connection.execute( + select( + RATE_LIMIT_WINDOWS_TABLE.c.principal_key, + RATE_LIMIT_WINDOWS_TABLE.c.policy_id, + RATE_LIMIT_WINDOWS_TABLE.c.window_started_at, + ) + .where(RATE_LIMIT_WINDOWS_TABLE.c.expires_at <= now) + .order_by(RATE_LIMIT_WINDOWS_TABLE.c.expires_at) + .limit(limit) + ) + ).all() + deleted = 0 + for principal_key, policy_id, window_started_at in rows: + result = await connection.execute( + delete(RATE_LIMIT_WINDOWS_TABLE).where( + RATE_LIMIT_WINDOWS_TABLE.c.principal_key == principal_key, + RATE_LIMIT_WINDOWS_TABLE.c.policy_id == policy_id, + RATE_LIMIT_WINDOWS_TABLE.c.window_started_at == window_started_at, + RATE_LIMIT_WINDOWS_TABLE.c.expires_at <= now, + ) + ) + deleted += result.rowcount + return deleted + + +async def _lock_or_create_window( + connection: AsyncConnection, + *, + principal_key: str, + policy_id: str, + window_start: datetime, + expires_at: datetime, +) -> tuple[Mapping[Any, Any], bool]: + row = await _lock_window(connection, principal_key, policy_id, window_start) + if row is not None: + return row, False + created = await insert_if_absent( + connection, + RATE_LIMIT_WINDOWS_TABLE, + { + "principal_key": principal_key, + "policy_id": policy_id, + "window_started_at": window_start, + "request_count": 1, + "expires_at": expires_at, + }, + ) + row = await _lock_window(connection, principal_key, policy_id, window_start) + if row is None: + raise InvalidStoredColumnError( # noqa: TRY003 + "rate limit window", + "an initialized fixed window", + ) + return row, created + + +async def _lock_window( + connection: AsyncConnection, + principal_key: str, + policy_id: str, + window_start: datetime, +) -> Mapping[Any, Any] | None: + await connection.execute( + update(RATE_LIMIT_WINDOWS_TABLE) + .where( + RATE_LIMIT_WINDOWS_TABLE.c.principal_key == principal_key, + RATE_LIMIT_WINDOWS_TABLE.c.policy_id == policy_id, + RATE_LIMIT_WINDOWS_TABLE.c.window_started_at == window_start, + ) + .values(request_count=RATE_LIMIT_WINDOWS_TABLE.c.request_count) + ) + return ( + ( + await connection.execute( + select(RATE_LIMIT_WINDOWS_TABLE) + .where( + RATE_LIMIT_WINDOWS_TABLE.c.principal_key == principal_key, + RATE_LIMIT_WINDOWS_TABLE.c.policy_id == policy_id, + RATE_LIMIT_WINDOWS_TABLE.c.window_started_at == window_start, + ) + .with_for_update() + ) + ) + .mappings() + .one_or_none() + ) + + +def _window_start(now: datetime, window_seconds: int) -> datetime: + aware = now.replace(tzinfo=UTC) if now.tzinfo is None else now.astimezone(UTC) + epoch = int(aware.timestamp()) + return datetime.fromtimestamp(epoch - epoch % window_seconds, tz=UTC).replace(tzinfo=None) + + +def _require_digest(value: str) -> None: + if len(value) != 64 or any(character not in "0123456789abcdef" for character in value): + raise InvalidRepositoryArgumentError("principal_key", "must be a lowercase SHA-256 digest") + + +def _require_text(field: str, value: str, maximum: int) -> None: + if not value.strip() or value != value.strip() or len(value) > maximum: + raise InvalidRepositoryArgumentError(field, f"must be trimmed and contain at most {maximum} characters") + + +def _require_positive(field: str, value: int) -> None: + if isinstance(value, bool) or value < 1: + raise InvalidRepositoryArgumentError(field, "must be a positive integer") + + +def _positive_integer(value: object, column: str) -> int: + if not isinstance(value, int) or isinstance(value, bool) or value < 1: + raise InvalidStoredColumnError(column, "a positive integer") + return value + + +__all__ = ["RateLimitDecision", "RateLimitRepository"] diff --git a/src/powercontext/builtin/persistence/seekdb/profile.py b/src/powercontext/builtin/persistence/seekdb/profile.py index 4ffa376e8d..d69e06e398 100644 --- a/src/powercontext/builtin/persistence/seekdb/profile.py +++ b/src/powercontext/builtin/persistence/seekdb/profile.py @@ -109,6 +109,7 @@ async def open( config: SeekDBConfig, *, tables: tuple[Table, ...], + create_schema: bool = True, _archive_recovery: bool = False, ) -> AsyncIterator[SeekDBProfile]: """Start seekdb locally and connect through its async Unix socket.""" @@ -130,8 +131,9 @@ async def open( await prepare_topic_memory_recovery_schema( engine, initialize=any(table.name == BUDGET for table in tables) ) - async with database.transaction() as connection: - await create_tables(connection, tables) + if create_schema: + async with database.transaction() as connection: + await create_tables(connection, tables) yield profile finally: close_task = asyncio.create_task(database.close()) diff --git a/src/powercontext/builtin/persistence/sqlite/profile.py b/src/powercontext/builtin/persistence/sqlite/profile.py index 236c4a0d57..6849ecd225 100644 --- a/src/powercontext/builtin/persistence/sqlite/profile.py +++ b/src/powercontext/builtin/persistence/sqlite/profile.py @@ -97,6 +97,7 @@ async def open( *, tables: tuple[Table, ...], load_vector_extension: bool = False, + create_schema: bool = True, _archive_recovery: bool = False, ) -> AsyncIterator[SQLiteProfile]: """Create, initialize and exclusively own one SQLite engine.""" @@ -121,8 +122,9 @@ async def open( engine, initialize=any(table.name == BUDGET for table in tables) ) await _warm_sqlite(engine, config) - async with database.transaction() as connection: - await create_tables(connection, tables) + if create_schema: + async with database.transaction() as connection: + await create_tables(connection, tables) yield profile finally: await database.close() diff --git a/src/powercontext/builtin/persistence/tables.py b/src/powercontext/builtin/persistence/tables.py index a548e5f4e6..303f60178c 100644 --- a/src/powercontext/builtin/persistence/tables.py +++ b/src/powercontext/builtin/persistence/tables.py @@ -89,6 +89,12 @@ def _entry_text_type(): return Text().with_variant(MEDIUMTEXT(), "mysql") +def _coordination_timestamp_type(): + """Preserve the microsecond precision used by database-time leases.""" + + return DateTime(timezone=False).with_variant(DATETIME(fsp=6), "mysql") + + SCOPES_TABLE = Table( "pc_scopes", SHARED_METADATA, @@ -1092,6 +1098,166 @@ def _entry_text_type(): ) +WORK_LANES_TABLE = Table( + "pc_work_lanes", + SHARED_METADATA, + Column("lane_key", identity_string(64), primary_key=True), + Column("head_sequence", BigInteger), + Column("next_sequence", BigInteger, nullable=False), + CheckConstraint("head_sequence IS NULL OR head_sequence > 0", name="ck_pc_work_lanes_head_positive"), + CheckConstraint("next_sequence > 0", name="ck_pc_work_lanes_next_positive"), +) + +WORK_ITEMS_TABLE = Table( + "pc_work_items", + SHARED_METADATA, + Column("work_id", identity_string(36), primary_key=True), + Column("logical_key", identity_string(64), nullable=False), + Column("lane_key", identity_string(64), nullable=False), + Column("lane_sequence", BigInteger, nullable=False), + Column("kind", identity_string(128), nullable=False), + Column("payload_version", Integer, nullable=False), + Column("scope_id", identity_string(MAX_SCOPE_ID_LENGTH), nullable=False), + Column("payload", _canonical_payload_type(), nullable=False), + Column("status", identity_string(16), nullable=False), + Column("available_at", _coordination_timestamp_type(), nullable=False), + Column("lease_owner", identity_string(128)), + Column("lease_fence", BigInteger, nullable=False), + Column("lease_expires_at", _coordination_timestamp_type()), + Column("attempt_count", Integer, nullable=False), + Column("generation_attempt_count", Integer, nullable=False), + Column("recovery_generation", Integer, nullable=False), + Column("max_attempts", Integer, nullable=False), + Column("cancel_requested", Boolean, nullable=False), + Column("result_code", identity_string(128)), + Column("result_payload", _canonical_payload_type()), + Column("error_category", identity_string(64)), + Column("error_code", identity_string(128)), + Column("state_version", BigInteger, nullable=False), + Column("created_at", _coordination_timestamp_type(), nullable=False), + Column("updated_at", _coordination_timestamp_type(), nullable=False), + Column("completed_at", _coordination_timestamp_type()), + UniqueConstraint("lane_key", "lane_sequence", name="uq_pc_work_items_lane_sequence"), + CheckConstraint( + "status IN ('queued', 'running', 'retry_wait', 'cancelling', 'succeeded', 'failed', 'cancelled')", + name="ck_pc_work_items_status", + ), + CheckConstraint("lane_sequence > 0", name="ck_pc_work_items_lane_sequence_positive"), + CheckConstraint("payload_version > 0", name="ck_pc_work_items_payload_version_positive"), + CheckConstraint("lease_fence >= 0", name="ck_pc_work_items_fence_nonnegative"), + CheckConstraint("attempt_count >= 0", name="ck_pc_work_items_attempts_nonnegative"), + CheckConstraint("generation_attempt_count >= 0", name="ck_pc_work_items_generation_attempts_nonnegative"), + CheckConstraint("recovery_generation >= 0", name="ck_pc_work_items_recovery_nonnegative"), + CheckConstraint("max_attempts > 0", name="ck_pc_work_items_max_attempts_positive"), + CheckConstraint("state_version > 0", name="ck_pc_work_items_state_version_positive"), +) +Index("ix_pc_work_items_claim", WORK_ITEMS_TABLE.c.status, WORK_ITEMS_TABLE.c.available_at) +Index("ix_pc_work_items_lane_status", WORK_ITEMS_TABLE.c.lane_key, WORK_ITEMS_TABLE.c.status) +Index("ix_pc_work_items_completed", WORK_ITEMS_TABLE.c.completed_at) +Index("ix_pc_work_items_scope_created", WORK_ITEMS_TABLE.c.scope_id, WORK_ITEMS_TABLE.c.created_at) + +WORK_KEYS_TABLE = Table( + "pc_work_keys", + SHARED_METADATA, + Column("logical_key", identity_string(64), primary_key=True), + Column("work_id", identity_string(36), nullable=False), + Column("lane_key", identity_string(64), nullable=False), + Column("created_at", _coordination_timestamp_type(), nullable=False), + # Named so the frozen migration bundle can register the exact constraint: + # an anonymous UNIQUE has no identity to verify on OceanBase. + UniqueConstraint("work_id", name="uq_pc_work_keys_work_id"), +) + +WORK_ATTEMPTS_TABLE = Table( + "pc_work_attempts", + SHARED_METADATA, + Column("work_id", identity_string(36), primary_key=True), + Column("attempt_no", Integer, primary_key=True), + Column("recovery_generation", Integer, nullable=False), + Column("owner_id", identity_string(128), nullable=False), + Column("fence", BigInteger, nullable=False), + Column("started_at", _coordination_timestamp_type(), nullable=False), + Column("heartbeat_at", _coordination_timestamp_type(), nullable=False), + Column("finished_at", _coordination_timestamp_type()), + Column("outcome", identity_string(32)), + Column("error_category", identity_string(64)), + Column("error_code", identity_string(128)), + Column("trace_id", identity_string(32)), + Column("span_id", identity_string(16)), + CheckConstraint("attempt_no > 0", name="ck_pc_work_attempts_attempt_positive"), + CheckConstraint("recovery_generation >= 0", name="ck_pc_work_attempts_recovery_nonnegative"), + CheckConstraint("fence > 0", name="ck_pc_work_attempts_fence_positive"), +) +Index("ix_pc_work_attempts_finished", WORK_ATTEMPTS_TABLE.c.finished_at) + +WORK_TABLES = ( + WORK_LANES_TABLE, + WORK_ITEMS_TABLE, + WORK_KEYS_TABLE, + WORK_ATTEMPTS_TABLE, +) + +SCHEDULER_LEASES_TABLE = Table( + "pc_scheduler_leases", + SHARED_METADATA, + Column("lease_name", identity_string(64), primary_key=True), + Column("owner_id", identity_string(128)), + Column("fence", BigInteger, nullable=False), + Column("lease_expires_at", _coordination_timestamp_type()), + Column("updated_at", _coordination_timestamp_type(), nullable=False), + CheckConstraint("fence >= 0", name="ck_pc_scheduler_leases_fence_nonnegative"), +) + +SCHEDULER_SCANS_TABLE = Table( + "pc_scheduler_scans", + SHARED_METADATA, + Column("discoverer", identity_string(128), primary_key=True), + Column("next_run_at", _coordination_timestamp_type(), nullable=False), + Column("continuation", identity_string(MAX_SCOPE_ID_LENGTH)), + Column("state_version", BigInteger, nullable=False), + Column("updated_at", _coordination_timestamp_type(), nullable=False), + CheckConstraint("state_version > 0", name="ck_pc_scheduler_scans_version_positive"), +) + +RUNTIME_MEMBERS_TABLE = Table( + "pc_runtime_members", + SHARED_METADATA, + Column("member_id", identity_string(128), primary_key=True), + Column("role", identity_string(16), nullable=False), + Column("build_version", identity_string(64), nullable=False), + Column("schema_min", Integer, nullable=False), + Column("schema_max", Integer, nullable=False), + Column("payload_min", Integer, nullable=False), + Column("payload_max", Integer, nullable=False), + Column("behavior_revision", identity_string(128), nullable=False), + Column("heartbeat_at", _coordination_timestamp_type(), nullable=False), + Column("expires_at", _coordination_timestamp_type(), nullable=False), + CheckConstraint("role IN ('all', 'api', 'scheduler', 'worker')", name="ck_pc_runtime_members_role"), + CheckConstraint("schema_min > 0 AND schema_max >= schema_min", name="ck_pc_runtime_members_schema_range"), + CheckConstraint("payload_min > 0 AND payload_max >= payload_min", name="ck_pc_runtime_members_payload_range"), +) +Index("ix_pc_runtime_members_role_expiry", RUNTIME_MEMBERS_TABLE.c.role, RUNTIME_MEMBERS_TABLE.c.expires_at) + +RATE_LIMIT_WINDOWS_TABLE = Table( + "pc_rate_limit_windows", + SHARED_METADATA, + Column("principal_key", identity_string(64), primary_key=True), + Column("policy_id", identity_string(64), primary_key=True), + Column("window_started_at", _coordination_timestamp_type(), primary_key=True), + Column("request_count", BigInteger, nullable=False), + Column("expires_at", _coordination_timestamp_type(), nullable=False), + CheckConstraint("request_count > 0", name="ck_pc_rate_limit_windows_count_positive"), +) +Index("ix_pc_rate_limit_windows_expiry", RATE_LIMIT_WINDOWS_TABLE.c.expires_at) + +COORDINATION_TABLES = ( + SCHEDULER_LEASES_TABLE, + SCHEDULER_SCANS_TABLE, + RUNTIME_MEMBERS_TABLE, + RATE_LIMIT_WINDOWS_TABLE, +) + + MAX_MEMORY_ENTRY_ID_LENGTH = 128 MAX_MEMORY_ENTRY_KIND_LENGTH = 128 MAX_MEMORY_HASH_LENGTH = 64 @@ -1412,6 +1578,8 @@ def _entry_text_type(): + TOPIC_MEMORY_TABLES + MEMORY_TABLES + STATISTICS_TABLES + + WORK_TABLES + + COORDINATION_TABLES + DECISION_OBSERVATION_TABLES + RECURRENCE_TABLES + ( diff --git a/src/powercontext/builtin/persistence/work.py b/src/powercontext/builtin/persistence/work.py new file mode 100644 index 0000000000..36cd4d1fdb --- /dev/null +++ b/src/powercontext/builtin/persistence/work.py @@ -0,0 +1,1375 @@ +# 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 work ledger with lane serialization, leases, and fencing.""" + +from __future__ import annotations + +import json +from collections.abc import Awaitable, Callable, Mapping +from contextlib import suppress +from datetime import UTC, datetime, timedelta +from enum import StrEnum +from typing import TYPE_CHECKING, Any +from uuid import uuid4 + +from pydantic import BaseModel, ConfigDict, Field, JsonValue, field_validator +from sqlalchemy import and_, delete, false, func, insert, not_, or_, select, update +from sqlalchemy.ext.asyncio import AsyncConnection + +from powercontext.builtin.persistence.codec import stored_bytes +from powercontext.builtin.persistence.database import database_now, insert_if_absent +from powercontext.builtin.persistence.errors import ( + InvalidRepositoryArgumentError, + InvalidStoredColumnError, + InvalidStoredPayloadError, + RepositoryError, + RepositoryNotFoundError, +) +from powercontext.builtin.persistence.tables import ( + WORK_ATTEMPTS_TABLE, + WORK_ITEMS_TABLE, + WORK_KEYS_TABLE, + WORK_LANES_TABLE, +) +from powercontext.limits import MAX_SCOPE_ID_LENGTH + +if TYPE_CHECKING: + from powercontext.builtin.runtime.work_observability import WorkObserver + +_MAX_PAYLOAD_BYTES = 16 * 1024 +_TERMINAL_STATUSES = frozenset({"succeeded", "cancelled"}) + + +class WorkStatus(StrEnum): + """Persisted lifecycle states for one logical work window.""" + + QUEUED = "queued" + RUNNING = "running" + RETRY_WAIT = "retry_wait" + CANCELLING = "cancelling" + SUCCEEDED = "succeeded" + FAILED = "failed" + CANCELLED = "cancelled" + + +class WorkSpec(BaseModel): + """Safe, versioned references needed to execute one logical window.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + + kind: str + payload_version: int = Field(ge=1) + scope_id: str + lane_key: str + logical_key: str + payload: dict[str, JsonValue] + max_attempts: int = Field(default=5, ge=1, le=100) + available_at: datetime | None = None + + @field_validator("kind") + @classmethod + def validate_kind(cls, value: str) -> str: + return _validated_text("kind", value, 128) + + @field_validator("scope_id") + @classmethod + def validate_scope_id(cls, value: str) -> str: + return _validated_text("scope_id", value, MAX_SCOPE_ID_LENGTH) + + @field_validator("lane_key", "logical_key") + @classmethod + def validate_digest(cls, value: str, info: Any) -> str: + if len(value) != 64 or any(character not in "0123456789abcdef" for character in value): + raise ValueError(f"{info.field_name} must be a lowercase SHA-256 digest") # noqa: TRY003 + return value + + +class WorkResult(BaseModel): + """Sanitized result metadata persisted after a successful commit.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + + code: str + payload: dict[str, JsonValue] = Field(default_factory=dict) + + @field_validator("code") + @classmethod + def validate_code(cls, value: str) -> str: + return _validated_text("code", value, 128) + + +WorkCommit = Callable[[AsyncConnection], Awaitable[WorkResult | None]] + + +class WorkFailure(BaseModel): + """Bounded failure classification; raw exceptions are never persisted.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + + category: str + code: str + retryable: bool + + @field_validator("category") + @classmethod + def validate_category(cls, value: str) -> str: + return _validated_text("category", value, 64) + + @field_validator("code") + @classmethod + def validate_code(cls, value: str) -> str: + return _validated_text("code", value, 128) + + +class StoredWork(BaseModel): + """Decoded work item returned by repository operations.""" + + model_config = ConfigDict(frozen=True) + + work_id: str + logical_key: str + lane_key: str + lane_sequence: int + kind: str + payload_version: int + scope_id: str + payload: dict[str, JsonValue] + status: WorkStatus + available_at: datetime + lease_owner: str | None + lease_fence: int + lease_expires_at: datetime | None + attempt_count: int + generation_attempt_count: int + recovery_generation: int + max_attempts: int + cancel_requested: bool + result_code: str | None + result_payload: dict[str, JsonValue] | None + error_category: str | None + error_code: str | None + state_version: int + created_at: datetime + updated_at: datetime + completed_at: datetime | None + + +class EnqueueResult(BaseModel): + """Whether enqueue created a row or joined an existing logical window.""" + + model_config = ConfigDict(frozen=True) + + created: bool + work: StoredWork + + +class WorkClaim(BaseModel): + """A fenced lease plus the safe payload needed by a Worker handler.""" + + model_config = ConfigDict(frozen=True) + + work_id: str + logical_key: str + lane_key: str + lane_sequence: int + kind: str + payload_version: int + scope_id: str + payload: dict[str, JsonValue] + owner_id: str + fence: int + attempt_no: int + generation_attempt_no: int + recovery_generation: int + created_at: datetime + claimed_at: datetime + previous_trace_id: str | None = None + previous_span_id: str | None = None + lease_expires_at: datetime + + +class WorkQueueStatistic(BaseModel): + """One bounded queue gauge sample grouped by kind and state.""" + + model_config = ConfigDict(frozen=True) + + kind: str + status: WorkStatus + depth: int + oldest_age_seconds: float + + +class WorkStateConflictError(RepositoryError): + """Raised when an optimistic operation observes a different state.""" + + def __init__(self, work_id: str, expected_version: int, status: WorkStatus, actual_version: int) -> None: + self.work_id = work_id + self.expected_version = expected_version + self.status = status + self.actual_version = actual_version + super().__init__( + f"work {work_id!r} changed: expected version {expected_version}, " + f"found {status.value} at version {actual_version}" + ) + + +class StaleWorkClaimError(RepositoryError): + """Raised when a lease owner or fence can no longer mutate a work item.""" + + def __init__(self, work_id: str) -> None: + self.work_id = work_id + super().__init__(f"work claim for {work_id!r} is no longer current") + + +class WorkRepository: + """Persist and transition work using short caller-owned transactions.""" + + def __init__(self, *, observer: WorkObserver | None = None) -> None: + self._observer = observer + + async def enqueue(self, connection: AsyncConnection, spec: WorkSpec, /) -> EnqueueResult: + payload = _dump_payload(spec.payload, kind="work", name=spec.logical_key) + now = await database_now(connection) + lane = await _lock_or_create_lane(connection, spec.lane_key) + existing = await _work_for_logical_key(connection, spec.logical_key, for_update=True) + if existing is not None: + _verify_logical_identity(existing, spec) + self._observe_enqueue(spec.kind, created=False) + return EnqueueResult(created=False, work=existing) + + work_id = str(uuid4()) + created = await insert_if_absent( + connection, + WORK_KEYS_TABLE, + { + "logical_key": spec.logical_key, + "work_id": work_id, + "lane_key": spec.lane_key, + "created_at": now, + }, + ) + if not created: + existing = await _work_for_logical_key(connection, spec.logical_key, for_update=True) + if existing is None: + raise InvalidStoredColumnError("logical_key", "a concurrently persisted work key") + _verify_logical_identity(existing, spec) + self._observe_enqueue(spec.kind, created=False) + return EnqueueResult(created=False, work=existing) + + sequence = _positive_integer(lane["next_sequence"], "next_sequence") + available_at = _normalized_datetime(spec.available_at) if spec.available_at is not None else now + await connection.execute( + update(WORK_LANES_TABLE) + .where(WORK_LANES_TABLE.c.lane_key == spec.lane_key) + .values( + next_sequence=sequence + 1, + head_sequence=sequence if lane["head_sequence"] is None else lane["head_sequence"], + ) + ) + await connection.execute( + insert(WORK_ITEMS_TABLE).values( + work_id=work_id, + logical_key=spec.logical_key, + lane_key=spec.lane_key, + lane_sequence=sequence, + kind=spec.kind, + payload_version=spec.payload_version, + scope_id=spec.scope_id, + payload=payload, + status=WorkStatus.QUEUED.value, + available_at=available_at, + lease_owner=None, + lease_fence=0, + lease_expires_at=None, + attempt_count=0, + generation_attempt_count=0, + recovery_generation=0, + max_attempts=spec.max_attempts, + cancel_requested=False, + result_code=None, + result_payload=None, + error_category=None, + error_code=None, + state_version=1, + created_at=now, + updated_at=now, + completed_at=None, + ) + ) + self._observe_enqueue(spec.kind, created=True) + return EnqueueResult(created=True, work=await self.get(connection, work_id)) + + async def claim( + self, + connection: AsyncConnection, + *, + worker_id: str, + supported: Mapping[str, frozenset[int]], + lease_seconds: int, + limit: int, + expired_retry_delay_seconds: int = 0, + ) -> tuple[WorkClaim, ...]: + _validated_text("worker_id", worker_id, 128) + _require_positive("lease_seconds", lease_seconds) + _require_positive("limit", limit) + _require_nonnegative("expired_retry_delay_seconds", expired_retry_delay_seconds) + if not supported: + return () + _validate_supported(supported) + + now = await database_now(connection) + supported_pairs = [ + and_(WORK_ITEMS_TABLE.c.kind == kind, WORK_ITEMS_TABLE.c.payload_version.in_(versions)) + for kind, versions in supported.items() + ] + supported_due = and_( + or_(*supported_pairs), + WORK_ITEMS_TABLE.c.status.in_((WorkStatus.QUEUED.value, WorkStatus.RETRY_WAIT.value)), + WORK_ITEMS_TABLE.c.available_at <= now, + ) + expired = and_( + WORK_ITEMS_TABLE.c.status.in_((WorkStatus.RUNNING.value, WorkStatus.CANCELLING.value)), + WORK_ITEMS_TABLE.c.lease_expires_at <= now, + ) + candidate_ids = ( + await connection.scalars( + select(WORK_ITEMS_TABLE.c.work_id) + .join( + WORK_LANES_TABLE, + and_( + WORK_LANES_TABLE.c.lane_key == WORK_ITEMS_TABLE.c.lane_key, + WORK_LANES_TABLE.c.head_sequence == WORK_ITEMS_TABLE.c.lane_sequence, + ), + ) + .where(or_(supported_due, expired)) + .order_by(WORK_ITEMS_TABLE.c.available_at, WORK_ITEMS_TABLE.c.created_at) + .limit(max(32, limit * 4)) + ) + ).all() + + claims: list[WorkClaim] = [] + for work_id_value in candidate_ids: + if len(claims) >= limit: + break + claim = await self._claim_candidate( + connection, + str(work_id_value), + worker_id=worker_id, + supported=supported, + lease_seconds=lease_seconds, + expired_retry_delay_seconds=expired_retry_delay_seconds, + ) + if claim is not None: + claims.append(claim) + return tuple(claims) + + async def heartbeat( + self, + connection: AsyncConnection, + claim: WorkClaim, + /, + *, + lease_seconds: int, + ) -> StoredWork: + _require_positive("lease_seconds", lease_seconds) + await _lock_lane(connection, claim.lane_key) + await _lock_logical_key(connection, claim.logical_key, claim.work_id) + work = await _lock_work(connection, claim.work_id) + now = await database_now(connection) + _verify_claim(work, claim, now) + if work.cancel_requested or work.status is WorkStatus.CANCELLING: + raise StaleWorkClaimError(claim.work_id) + expires_at = now + timedelta(seconds=lease_seconds) + await connection.execute( + update(WORK_ITEMS_TABLE) + .where(WORK_ITEMS_TABLE.c.work_id == claim.work_id) + .values(lease_expires_at=expires_at, updated_at=now, state_version=work.state_version + 1) + ) + await connection.execute( + update(WORK_ATTEMPTS_TABLE) + .where( + WORK_ATTEMPTS_TABLE.c.work_id == claim.work_id, + WORK_ATTEMPTS_TABLE.c.attempt_no == claim.attempt_no, + WORK_ATTEMPTS_TABLE.c.owner_id == claim.owner_id, + WORK_ATTEMPTS_TABLE.c.fence == claim.fence, + ) + .values(heartbeat_at=now) + ) + return await self.get(connection, claim.work_id) + + async def record_attempt_trace( + self, + connection: AsyncConnection, + claim: WorkClaim, + /, + *, + trace_id: str, + span_id: str, + ) -> None: + """Bind one non-sensitive span identity to the current fenced attempt.""" + + _require_hex("trace_id", trace_id, 32) + _require_hex("span_id", span_id, 16) + await _lock_lane(connection, claim.lane_key) + await _lock_logical_key(connection, claim.logical_key, claim.work_id) + work = await _lock_work(connection, claim.work_id) + _verify_claim(work, claim, await database_now(connection)) + result = await connection.execute( + update(WORK_ATTEMPTS_TABLE) + .where( + WORK_ATTEMPTS_TABLE.c.work_id == claim.work_id, + WORK_ATTEMPTS_TABLE.c.attempt_no == claim.attempt_no, + WORK_ATTEMPTS_TABLE.c.owner_id == claim.owner_id, + WORK_ATTEMPTS_TABLE.c.fence == claim.fence, + WORK_ATTEMPTS_TABLE.c.finished_at.is_(None), + ) + .values(trace_id=trace_id, span_id=span_id) + ) + if result.rowcount != 1: + raise StaleWorkClaimError(claim.work_id) + + async def complete( + self, + connection: AsyncConnection, + claim: WorkClaim, + result: WorkResult | None, + /, + *, + commit: WorkCommit | None = None, + ) -> StoredWork: + if result is None and commit is None: + raise InvalidRepositoryArgumentError("result", "result or commit is required") + await _lock_lane(connection, claim.lane_key) + await _lock_logical_key(connection, claim.logical_key, claim.work_id) + work = await _lock_work(connection, claim.work_id) + now = await database_now(connection) + _verify_claim(work, claim, now) + if work.cancel_requested or work.status is WorkStatus.CANCELLING: + raise StaleWorkClaimError(claim.work_id) + if commit is not None: + committed_result = await commit(connection) + if committed_result is not None: + result = committed_result + if result is None: + raise InvalidRepositoryArgumentError("result", "commit did not produce a result") + result_payload = _dump_payload(result.payload, kind="work-result", name=claim.work_id) + await connection.execute( + update(WORK_ITEMS_TABLE) + .where(WORK_ITEMS_TABLE.c.work_id == claim.work_id) + .values( + status=WorkStatus.SUCCEEDED.value, + lease_owner=None, + lease_expires_at=None, + result_code=result.code, + result_payload=result_payload, + error_category=None, + error_code=None, + state_version=work.state_version + 1, + updated_at=now, + completed_at=now, + ) + ) + await self._finish_attempt(connection, claim, now=now, outcome="succeeded") + self._observe_attempt(claim, now=now, outcome="succeeded") + await _release_logical_key(connection, work) + await _advance_lane(connection, work) + return await self.get(connection, claim.work_id) + + async def fail( + self, + connection: AsyncConnection, + claim: WorkClaim, + failure: WorkFailure, + /, + *, + retry_delay_seconds: int, + ) -> StoredWork: + _require_nonnegative("retry_delay_seconds", retry_delay_seconds) + await _lock_lane(connection, claim.lane_key) + await _lock_logical_key(connection, claim.logical_key, claim.work_id) + work = await _lock_work(connection, claim.work_id) + now = await database_now(connection) + _verify_claim(work, claim, now) + if work.cancel_requested or work.status is WorkStatus.CANCELLING: + return await self._cancel_running(connection, work, claim=claim, now=now) + retry = failure.retryable and work.generation_attempt_count < work.max_attempts + status = WorkStatus.RETRY_WAIT if retry else WorkStatus.FAILED + await connection.execute( + update(WORK_ITEMS_TABLE) + .where(WORK_ITEMS_TABLE.c.work_id == claim.work_id) + .values( + status=status.value, + available_at=now + timedelta(seconds=retry_delay_seconds) if retry else work.available_at, + lease_owner=None, + lease_expires_at=None, + error_category=failure.category, + error_code=failure.code, + state_version=work.state_version + 1, + updated_at=now, + ) + ) + await self._finish_attempt( + connection, + claim, + now=now, + outcome="retry_wait" if retry else "failed", + failure=failure, + ) + self._observe_attempt( + claim, + now=now, + outcome="retry_wait" if retry else "failed", + error_category=failure.category, + ) + return await self.get(connection, claim.work_id) + + async def retry(self, connection: AsyncConnection, work_id: str, /, *, expected_version: int) -> StoredWork: + _require_work_id(work_id) + _require_positive("expected_version", expected_version) + unlocked = await self.get(connection, work_id) + await _lock_lane(connection, unlocked.lane_key) + await _lock_logical_key(connection, unlocked.logical_key, work_id) + work = await _lock_work(connection, work_id) + _verify_version(work, expected_version) + if work.status is not WorkStatus.FAILED: + raise WorkStateConflictError(work_id, expected_version, work.status, work.state_version) + now = await database_now(connection) + await connection.execute( + update(WORK_ITEMS_TABLE) + .where(WORK_ITEMS_TABLE.c.work_id == work_id) + .values( + status=WorkStatus.QUEUED.value, + available_at=now, + generation_attempt_count=0, + recovery_generation=work.recovery_generation + 1, + cancel_requested=False, + error_category=None, + error_code=None, + state_version=work.state_version + 1, + updated_at=now, + ) + ) + return await self.get(connection, work_id) + + async def cancel(self, connection: AsyncConnection, work_id: str, /, *, expected_version: int) -> StoredWork: + _require_work_id(work_id) + _require_positive("expected_version", expected_version) + unlocked = await self.get(connection, work_id) + await _lock_lane(connection, unlocked.lane_key) + logical_key_locked = await _lock_logical_key_if_present(connection, unlocked.logical_key) + work = await _lock_work(connection, work_id) + _verify_version(work, expected_version) + if work.status in {WorkStatus.CANCELLING, WorkStatus.SUCCEEDED, WorkStatus.CANCELLED}: + raise WorkStateConflictError(work_id, expected_version, work.status, work.state_version) + if not logical_key_locked: + raise StaleWorkClaimError(work_id) + now = await database_now(connection) + if work.status is WorkStatus.RUNNING: + await connection.execute( + update(WORK_ITEMS_TABLE) + .where(WORK_ITEMS_TABLE.c.work_id == work_id) + .values( + status=WorkStatus.CANCELLING.value, + cancel_requested=True, + state_version=work.state_version + 1, + updated_at=now, + ) + ) + else: + await connection.execute( + update(WORK_ITEMS_TABLE) + .where(WORK_ITEMS_TABLE.c.work_id == work_id) + .values( + status=WorkStatus.CANCELLED.value, + cancel_requested=True, + lease_owner=None, + lease_expires_at=None, + state_version=work.state_version + 1, + updated_at=now, + completed_at=now, + ) + ) + await _release_logical_key(connection, work) + await _advance_lane(connection, work) + return await self.get(connection, work_id) + + async def get(self, connection: AsyncConnection, work_id: str, /) -> StoredWork: + _require_work_id(work_id) + row = ( + (await connection.execute(select(WORK_ITEMS_TABLE).where(WORK_ITEMS_TABLE.c.work_id == work_id))) + .mappings() + .one_or_none() + ) + if row is None: + raise RepositoryNotFoundError("work", work_id) + return _decode_work(row) + + async def list( + self, + connection: AsyncConnection, + /, + *, + scope_id: str | None = None, + kind: str | None = None, + allowed_kinds: frozenset[str] | None = None, + status: WorkStatus | None = None, + limit: int = 100, + before: tuple[datetime, str] | None = None, + ) -> tuple[StoredWork, ...]: + statement = _work_list_statement( + scope_id=scope_id, + kind=kind, + allowed_kinds=allowed_kinds, + status=status, + before=before, + limit=limit, + ) + if statement is None: + return () + rows = ( + ( + await connection.execute( + statement.order_by(WORK_ITEMS_TABLE.c.created_at.desc(), WORK_ITEMS_TABLE.c.work_id.desc()).limit( + limit + ) + ) + ) + .mappings() + .all() + ) + return tuple(_decode_work(row) for row in rows) + + async def purge_terminal( + self, + connection: AsyncConnection, + /, + *, + completed_before: datetime, + limit: int = 500, + ) -> int: + """Delete one bounded batch of successful or cancelled operation history.""" + + if limit < 1 or limit > 500: + raise InvalidRepositoryArgumentError("limit", "must be between 1 and 500") + cutoff = _normalized_datetime(completed_before) + work_ids = tuple( + str(value) + for value in ( + await connection.scalars( + select(WORK_ITEMS_TABLE.c.work_id) + .where( + WORK_ITEMS_TABLE.c.status.in_(_TERMINAL_STATUSES), + WORK_ITEMS_TABLE.c.completed_at.is_not(None), + WORK_ITEMS_TABLE.c.completed_at < cutoff, + ) + .order_by(WORK_ITEMS_TABLE.c.completed_at, WORK_ITEMS_TABLE.c.work_id) + .limit(limit) + ) + ).all() + ) + if not work_ids: + return 0 + await connection.execute(delete(WORK_ATTEMPTS_TABLE).where(WORK_ATTEMPTS_TABLE.c.work_id.in_(work_ids))) + result = await connection.execute( + delete(WORK_ITEMS_TABLE).where( + WORK_ITEMS_TABLE.c.work_id.in_(work_ids), + WORK_ITEMS_TABLE.c.status.in_(_TERMINAL_STATUSES), + WORK_ITEMS_TABLE.c.completed_at < cutoff, + ) + ) + return result.rowcount + + async def unsupported_head_count( + self, + connection: AsyncConnection, + supported: Mapping[str, frozenset[int]], + /, + ) -> int: + """Count visible lane heads that no registered handler can decode.""" + + _validate_supported(supported) + supported_pairs = [ + and_(WORK_ITEMS_TABLE.c.kind == kind, WORK_ITEMS_TABLE.c.payload_version.in_(versions)) + for kind, versions in supported.items() + ] + compatible = or_(*supported_pairs) if supported_pairs else false() + value = await connection.scalar( + select(func.count()) + .select_from(WORK_ITEMS_TABLE) + .join( + WORK_LANES_TABLE, + and_( + WORK_LANES_TABLE.c.lane_key == WORK_ITEMS_TABLE.c.lane_key, + WORK_LANES_TABLE.c.head_sequence == WORK_ITEMS_TABLE.c.lane_sequence, + ), + ) + .where( + WORK_ITEMS_TABLE.c.status.not_in(_TERMINAL_STATUSES), + not_(compatible), + ) + ) + return int(value or 0) + + async def queue_statistics(self, connection: AsyncConnection, /) -> tuple[WorkQueueStatistic, ...]: + """Aggregate non-terminal queue state without high-cardinality labels.""" + + now = await database_now(connection) + rows = ( + await connection.execute( + select( + WORK_ITEMS_TABLE.c.kind, + WORK_ITEMS_TABLE.c.status, + func.count().label("depth"), + func.min(WORK_ITEMS_TABLE.c.created_at).label("oldest_created_at"), + ) + .where(WORK_ITEMS_TABLE.c.status.not_in(_TERMINAL_STATUSES)) + .group_by(WORK_ITEMS_TABLE.c.kind, WORK_ITEMS_TABLE.c.status) + ) + ).mappings() + return tuple( + WorkQueueStatistic( + kind=str(row["kind"]), + status=WorkStatus(str(row["status"])), + depth=int(row["depth"]), + oldest_age_seconds=max( + 0.0, + (now - _stored_datetime(row["oldest_created_at"], "oldest_created_at")).total_seconds(), + ), + ) + for row in rows + ) + + async def _claim_locked( + self, + connection: AsyncConnection, + work: StoredWork, + *, + worker_id: str, + lease_seconds: int, + now: datetime, + ) -> WorkClaim: + previous_trace_id, previous_span_id = await _previous_attempt_trace(connection, work.work_id) + fence = work.lease_fence + 1 + attempt_no = work.attempt_count + 1 + expires_at = now + timedelta(seconds=lease_seconds) + result = await connection.execute( + update(WORK_ITEMS_TABLE) + .where( + WORK_ITEMS_TABLE.c.work_id == work.work_id, + WORK_ITEMS_TABLE.c.state_version == work.state_version, + WORK_ITEMS_TABLE.c.status == work.status.value, + ) + .values( + status=WorkStatus.RUNNING.value, + lease_owner=worker_id, + lease_fence=fence, + lease_expires_at=expires_at, + attempt_count=attempt_no, + generation_attempt_count=work.generation_attempt_count + 1, + state_version=work.state_version + 1, + updated_at=now, + ) + ) + if result.rowcount != 1: + raise StaleWorkClaimError(work.work_id) + await connection.execute( + insert(WORK_ATTEMPTS_TABLE).values( + work_id=work.work_id, + attempt_no=attempt_no, + recovery_generation=work.recovery_generation, + owner_id=worker_id, + fence=fence, + started_at=now, + heartbeat_at=now, + finished_at=None, + outcome=None, + error_category=None, + error_code=None, + trace_id=None, + span_id=None, + ) + ) + self._observe_claim(work, now=now) + return WorkClaim( + work_id=work.work_id, + logical_key=work.logical_key, + lane_key=work.lane_key, + lane_sequence=work.lane_sequence, + kind=work.kind, + payload_version=work.payload_version, + scope_id=work.scope_id, + payload=work.payload, + owner_id=worker_id, + fence=fence, + attempt_no=attempt_no, + generation_attempt_no=work.generation_attempt_count + 1, + recovery_generation=work.recovery_generation, + created_at=work.created_at, + claimed_at=now, + previous_trace_id=previous_trace_id, + previous_span_id=previous_span_id, + lease_expires_at=expires_at, + ) + + async def _claim_candidate( + self, + connection: AsyncConnection, + work_id: str, + *, + worker_id: str, + supported: Mapping[str, frozenset[int]], + lease_seconds: int, + expired_retry_delay_seconds: int, + ) -> WorkClaim | None: + unlocked = await self.get(connection, work_id) + await _lock_lane(connection, unlocked.lane_key) + await _lock_logical_key(connection, unlocked.logical_key, work_id) + work = await _lock_work(connection, work_id) + if not await _is_lane_head(connection, work): + return None + now = await database_now(connection) + if work.status in {WorkStatus.RUNNING, WorkStatus.CANCELLING}: + if work.lease_expires_at is None or work.lease_expires_at > now: + return None + await self._expire_claim( + connection, + work, + now=now, + retry_delay_seconds=expired_retry_delay_seconds, + ) + return None + if work.status not in {WorkStatus.QUEUED, WorkStatus.RETRY_WAIT} or work.available_at > now: + return None + if work.payload_version not in supported.get(work.kind, frozenset()): + return None + return await self._claim_locked( + connection, + work, + worker_id=worker_id, + lease_seconds=lease_seconds, + now=now, + ) + + async def _expire_claim( + self, + connection: AsyncConnection, + work: StoredWork, + *, + now: datetime, + retry_delay_seconds: int, + ) -> None: + claim = _claim_from_work(work) + if work.status is WorkStatus.CANCELLING or work.cancel_requested: + await self._cancel_running(connection, work, claim=claim, now=now) + return + retry = work.generation_attempt_count < work.max_attempts + await connection.execute( + update(WORK_ITEMS_TABLE) + .where(WORK_ITEMS_TABLE.c.work_id == work.work_id) + .values( + status=WorkStatus.RETRY_WAIT.value if retry else WorkStatus.FAILED.value, + available_at=now + timedelta(seconds=retry_delay_seconds) if retry else work.available_at, + lease_owner=None, + lease_expires_at=None, + error_category="lease", + error_code="lease_expired", + state_version=work.state_version + 1, + updated_at=now, + ) + ) + await self._finish_attempt( + connection, + claim, + now=now, + outcome="lease_expired_retry" if retry else "lease_expired_failed", + failure=WorkFailure(category="lease", code="lease_expired", retryable=retry), + ) + self._observe_attempt( + claim, + now=now, + outcome="lease_expired_retry" if retry else "lease_expired_failed", + error_category="lease", + ) + if self._observer is not None: + with suppress(Exception): + self._observer.observe_work_lease_expiry( + work.kind, + outcome="retry_wait" if retry else "failed", + ) + + async def _cancel_running( + self, + connection: AsyncConnection, + work: StoredWork, + *, + claim: WorkClaim, + now: datetime, + ) -> StoredWork: + await connection.execute( + update(WORK_ITEMS_TABLE) + .where(WORK_ITEMS_TABLE.c.work_id == work.work_id) + .values( + status=WorkStatus.CANCELLED.value, + lease_owner=None, + lease_expires_at=None, + cancel_requested=True, + state_version=work.state_version + 1, + updated_at=now, + completed_at=now, + ) + ) + await self._finish_attempt(connection, claim, now=now, outcome="cancelled") + self._observe_attempt(claim, now=now, outcome="cancelled") + await _release_logical_key(connection, work) + await _advance_lane(connection, work) + return await self.get(connection, work.work_id) + + async def _finish_attempt( + self, + connection: AsyncConnection, + claim: WorkClaim, + *, + now: datetime, + outcome: str, + failure: WorkFailure | None = None, + ) -> None: + result = await connection.execute( + update(WORK_ATTEMPTS_TABLE) + .where( + WORK_ATTEMPTS_TABLE.c.work_id == claim.work_id, + WORK_ATTEMPTS_TABLE.c.attempt_no == claim.attempt_no, + WORK_ATTEMPTS_TABLE.c.owner_id == claim.owner_id, + WORK_ATTEMPTS_TABLE.c.fence == claim.fence, + WORK_ATTEMPTS_TABLE.c.finished_at.is_(None), + ) + .values( + heartbeat_at=now, + finished_at=now, + outcome=outcome, + error_category=None if failure is None else failure.category, + error_code=None if failure is None else failure.code, + ) + ) + if result.rowcount != 1: + raise StaleWorkClaimError(claim.work_id) + + def _observe_enqueue(self, kind: str, *, created: bool) -> None: + if self._observer is not None: + with suppress(Exception): + self._observer.observe_work_enqueue(kind, created=created) + + def _observe_claim(self, work: StoredWork, *, now: datetime) -> None: + if self._observer is not None: + with suppress(Exception): + self._observer.observe_work_claim( + work.kind, + latency_seconds=max(0.0, (now - work.created_at).total_seconds()), + ) + + def _observe_attempt( + self, + claim: WorkClaim, + *, + now: datetime, + outcome: str, + error_category: str = "none", + ) -> None: + if self._observer is not None: + with suppress(Exception): + self._observer.observe_work_attempt( + claim.kind, + outcome=outcome, + error_category=error_category, + duration_seconds=max(0.0, (now - claim.claimed_at).total_seconds()), + ) + + +async def _lock_or_create_lane(connection: AsyncConnection, lane_key: str) -> Mapping[Any, Any]: + await connection.execute( + update(WORK_LANES_TABLE) + .where(WORK_LANES_TABLE.c.lane_key == lane_key) + .values(next_sequence=WORK_LANES_TABLE.c.next_sequence) + ) + row = await _lane_row(connection, lane_key) + if row is not None: + return row + await insert_if_absent( + connection, + WORK_LANES_TABLE, + {"lane_key": lane_key, "head_sequence": None, "next_sequence": 1}, + ) + row = await _lane_row(connection, lane_key) + if row is None: + raise InvalidStoredColumnError("lane_key", "an initialized work lane") + return row + + +async def _previous_attempt_trace(connection: AsyncConnection, work_id: str) -> tuple[str | None, str | None]: + row = ( + await connection.execute( + select(WORK_ATTEMPTS_TABLE.c.trace_id, WORK_ATTEMPTS_TABLE.c.span_id) + .where(WORK_ATTEMPTS_TABLE.c.work_id == work_id) + .order_by(WORK_ATTEMPTS_TABLE.c.attempt_no.desc()) + .limit(1) + ) + ).one_or_none() + if row is None or (row.trace_id is None and row.span_id is None): + return None, None + if row.trace_id is None or row.span_id is None: + raise InvalidStoredColumnError("attempt trace", "a complete trace/span pair") # noqa: TRY003 + trace_id = str(row.trace_id) + span_id = str(row.span_id) + _require_hex("trace_id", trace_id, 32) + _require_hex("span_id", span_id, 16) + return trace_id, span_id + + +async def _lock_lane(connection: AsyncConnection, lane_key: str) -> None: + await _lock_or_create_lane(connection, lane_key) + + +async def _lane_row(connection: AsyncConnection, lane_key: str) -> Mapping[Any, Any] | None: + return ( + ( + await connection.execute( + select(WORK_LANES_TABLE).where(WORK_LANES_TABLE.c.lane_key == lane_key).with_for_update() + ) + ) + .mappings() + .one_or_none() + ) + + +async def _lock_logical_key(connection: AsyncConnection, logical_key: str, work_id: str) -> None: + if not await _lock_logical_key_if_present(connection, logical_key): + raise StaleWorkClaimError(work_id) + + +async def _lock_logical_key_if_present(connection: AsyncConnection, logical_key: str) -> bool: + row = ( + await connection.execute( + select(WORK_KEYS_TABLE.c.work_id).where(WORK_KEYS_TABLE.c.logical_key == logical_key).with_for_update() + ) + ).one_or_none() + return row is not None + + +async def _lock_work(connection: AsyncConnection, work_id: str) -> StoredWork: + row = ( + ( + await connection.execute( + select(WORK_ITEMS_TABLE).where(WORK_ITEMS_TABLE.c.work_id == work_id).with_for_update() + ) + ) + .mappings() + .one_or_none() + ) + if row is None: + raise RepositoryNotFoundError("work", work_id) + return _decode_work(row) + + +async def _work_for_logical_key( + connection: AsyncConnection, + logical_key: str, + *, + for_update: bool, +) -> StoredWork | None: + statement = ( + select(WORK_ITEMS_TABLE) + .join(WORK_KEYS_TABLE, WORK_KEYS_TABLE.c.work_id == WORK_ITEMS_TABLE.c.work_id) + .where(WORK_KEYS_TABLE.c.logical_key == logical_key) + ) + if for_update: + statement = statement.with_for_update() + row = (await connection.execute(statement)).mappings().one_or_none() + return None if row is None else _decode_work(row) + + +async def _is_lane_head(connection: AsyncConnection, work: StoredWork) -> bool: + head = await connection.scalar( + select(WORK_LANES_TABLE.c.head_sequence).where(WORK_LANES_TABLE.c.lane_key == work.lane_key) + ) + return head == work.lane_sequence + + +async def _release_logical_key(connection: AsyncConnection, work: StoredWork) -> None: + result = await connection.execute( + delete(WORK_KEYS_TABLE).where( + WORK_KEYS_TABLE.c.logical_key == work.logical_key, + WORK_KEYS_TABLE.c.work_id == work.work_id, + ) + ) + if result.rowcount != 1: + raise StaleWorkClaimError(work.work_id) + + +async def _advance_lane(connection: AsyncConnection, work: StoredWork) -> None: + next_sequence = await connection.scalar( + select(WORK_ITEMS_TABLE.c.lane_sequence) + .where( + WORK_ITEMS_TABLE.c.lane_key == work.lane_key, + WORK_ITEMS_TABLE.c.lane_sequence > work.lane_sequence, + WORK_ITEMS_TABLE.c.status.not_in(_TERMINAL_STATUSES), + ) + .order_by(WORK_ITEMS_TABLE.c.lane_sequence) + .limit(1) + ) + await connection.execute( + update(WORK_LANES_TABLE) + .where( + WORK_LANES_TABLE.c.lane_key == work.lane_key, + WORK_LANES_TABLE.c.head_sequence == work.lane_sequence, + ) + .values(head_sequence=next_sequence) + ) + + +def _work_list_statement( + *, + scope_id: str | None, + kind: str | None, + allowed_kinds: frozenset[str] | None, + status: WorkStatus | None, + before: tuple[datetime, str] | None, + limit: int, +) -> Any | None: + if not _validate_work_list(scope_id=scope_id, kind=kind, allowed_kinds=allowed_kinds, limit=limit): + return None + + statement = select(WORK_ITEMS_TABLE) + if scope_id is not None: + statement = statement.where(WORK_ITEMS_TABLE.c.scope_id == scope_id) + if kind is not None: + statement = statement.where(WORK_ITEMS_TABLE.c.kind == kind) + if allowed_kinds is not None: + statement = statement.where(WORK_ITEMS_TABLE.c.kind.in_(allowed_kinds)) + if status is not None: + statement = statement.where(WORK_ITEMS_TABLE.c.status == status.value) + if before is not None: + created_at, work_id = before + created_at = _normalized_datetime(created_at) + _require_work_id(work_id) + statement = statement.where( + or_( + WORK_ITEMS_TABLE.c.created_at < created_at, + and_(WORK_ITEMS_TABLE.c.created_at == created_at, WORK_ITEMS_TABLE.c.work_id < work_id), + ) + ) + return statement + + +def _validate_work_list( + *, + scope_id: str | None, + kind: str | None, + allowed_kinds: frozenset[str] | None, + limit: int, +) -> bool: + if scope_id is not None: + _validated_text("scope_id", scope_id, MAX_SCOPE_ID_LENGTH) + if kind is not None: + _validated_text("kind", kind, 128) + if allowed_kinds is not None: + if not allowed_kinds: + return False + for allowed_kind in allowed_kinds: + _validated_text("allowed_kind", allowed_kind, 128) + if limit < 1 or limit > 100: + raise InvalidRepositoryArgumentError("limit", "must be between 1 and 100") + return True + + +def _decode_work(row: Mapping[Any, Any]) -> StoredWork: + work_id = str(row["work_id"]) + return StoredWork( + work_id=work_id, + logical_key=str(row["logical_key"]), + lane_key=str(row["lane_key"]), + lane_sequence=_positive_integer(row["lane_sequence"], "lane_sequence"), + kind=str(row["kind"]), + payload_version=_positive_integer(row["payload_version"], "payload_version"), + scope_id=str(row["scope_id"]), + payload=_load_payload(row["payload"], kind="work", name=work_id), + status=WorkStatus(str(row["status"])), + available_at=_stored_datetime(row["available_at"], "available_at"), + lease_owner=None if row["lease_owner"] is None else str(row["lease_owner"]), + lease_fence=_nonnegative_integer(row["lease_fence"], "lease_fence"), + lease_expires_at=_optional_datetime(row["lease_expires_at"], "lease_expires_at"), + attempt_count=_nonnegative_integer(row["attempt_count"], "attempt_count"), + generation_attempt_count=_nonnegative_integer(row["generation_attempt_count"], "generation_attempt_count"), + recovery_generation=_nonnegative_integer(row["recovery_generation"], "recovery_generation"), + max_attempts=_positive_integer(row["max_attempts"], "max_attempts"), + cancel_requested=bool(row["cancel_requested"]), + result_code=None if row["result_code"] is None else str(row["result_code"]), + result_payload=( + None + if row["result_payload"] is None + else _load_payload(row["result_payload"], kind="work-result", name=work_id) + ), + error_category=None if row["error_category"] is None else str(row["error_category"]), + error_code=None if row["error_code"] is None else str(row["error_code"]), + state_version=_positive_integer(row["state_version"], "state_version"), + created_at=_stored_datetime(row["created_at"], "created_at"), + updated_at=_stored_datetime(row["updated_at"], "updated_at"), + completed_at=_optional_datetime(row["completed_at"], "completed_at"), + ) + + +def _verify_logical_identity(work: StoredWork, spec: WorkSpec) -> None: + if work.kind != spec.kind or work.scope_id != spec.scope_id or work.lane_key != spec.lane_key: + raise RepositoryError( # noqa: TRY003 + f"logical work key {spec.logical_key!r} is bound to a different work identity" + ) + + +def _verify_claim(work: StoredWork, claim: WorkClaim, now: datetime) -> None: + if ( + work.status not in {WorkStatus.RUNNING, WorkStatus.CANCELLING} + or work.lease_owner != claim.owner_id + or work.lease_fence != claim.fence + or work.attempt_count != claim.attempt_no + or work.lease_expires_at is None + or work.lease_expires_at <= now + ): + raise StaleWorkClaimError(claim.work_id) + + +def _verify_version(work: StoredWork, expected_version: int) -> None: + if work.state_version != expected_version: + raise WorkStateConflictError(work.work_id, expected_version, work.status, work.state_version) + + +def _claim_from_work(work: StoredWork) -> WorkClaim: + if work.lease_owner is None or work.lease_expires_at is None or work.attempt_count < 1: + raise InvalidStoredColumnError("work lease", "a complete active lease") # noqa: TRY003 + return WorkClaim( + work_id=work.work_id, + logical_key=work.logical_key, + lane_key=work.lane_key, + lane_sequence=work.lane_sequence, + kind=work.kind, + payload_version=work.payload_version, + scope_id=work.scope_id, + payload=work.payload, + owner_id=work.lease_owner, + fence=work.lease_fence, + attempt_no=work.attempt_count, + generation_attempt_no=work.generation_attempt_count, + recovery_generation=work.recovery_generation, + created_at=work.created_at, + claimed_at=work.updated_at, + previous_trace_id=None, + previous_span_id=None, + lease_expires_at=work.lease_expires_at, + ) + + +def _dump_payload(payload: dict[str, JsonValue], *, kind: str, name: str) -> bytes: + try: + encoded = json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode() + except (TypeError, ValueError) as error: + raise InvalidStoredPayloadError(kind, name, "value is not JSON serializable") from error + if len(encoded) > _MAX_PAYLOAD_BYTES: + raise InvalidRepositoryArgumentError("payload", f"must not exceed {_MAX_PAYLOAD_BYTES} encoded bytes") + return encoded + + +def _load_payload(payload: object, *, kind: str, name: str) -> dict[str, JsonValue]: + try: + decoded = json.loads(stored_bytes(payload, column="payload")) + except (UnicodeDecodeError, json.JSONDecodeError) as error: + raise InvalidStoredPayloadError(kind, name, "payload is not valid JSON") from error + if not isinstance(decoded, dict) or any(not isinstance(key, str) for key in decoded): + raise InvalidStoredPayloadError(kind, name, "payload is not an object") + return decoded + + +def _stored_datetime(value: object, column: str) -> datetime: + if not isinstance(value, datetime): + raise InvalidStoredColumnError(column, "a datetime") + return _normalized_datetime(value) + + +def _optional_datetime(value: object, column: str) -> datetime | None: + return None if value is None else _stored_datetime(value, column) + + +def _normalized_datetime(value: datetime) -> datetime: + if value.tzinfo is None: + return value + return value.astimezone(UTC).replace(tzinfo=None) + + +def _positive_integer(value: object, column: str) -> int: + if not isinstance(value, int) or isinstance(value, bool) or value < 1: + raise InvalidStoredColumnError(column, "a positive integer") + return value + + +def _nonnegative_integer(value: object, column: str) -> int: + if not isinstance(value, int) or isinstance(value, bool) or value < 0: + raise InvalidStoredColumnError(column, "a non-negative integer") + return value + + +def _require_positive(field: str, value: int) -> None: + if not isinstance(value, int) or isinstance(value, bool) or value < 1: + raise InvalidRepositoryArgumentError(field, "must be a positive integer") + + +def _require_nonnegative(field: str, value: int) -> None: + if not isinstance(value, int) or isinstance(value, bool) or value < 0: + raise InvalidRepositoryArgumentError(field, "must be a non-negative integer") + + +def _require_work_id(value: str) -> None: + _validated_text("work_id", value, 36) + + +def _require_hex(field: str, value: str, length: int) -> None: + if len(value) != length or any(character not in "0123456789abcdef" for character in value): + raise InvalidRepositoryArgumentError(field, f"must be {length} lowercase hexadecimal characters") + + +def _validate_supported(supported: Mapping[str, frozenset[int]]) -> None: + for kind, versions in supported.items(): + _validated_text("supported kind", kind, 128) + if not versions or any(version < 1 for version in versions): + raise InvalidRepositoryArgumentError("supported", "each kind must have positive payload versions") + + +def _validated_text(field: str, value: str, maximum: int) -> str: + if not isinstance(value, str) or not value.strip() or value != value.strip(): + raise ValueError(f"{field} must be a non-empty trimmed string") # noqa: TRY003 + if len(value) > maximum: + raise ValueError(f"{field} must not exceed {maximum} characters") # noqa: TRY003 + return value + + +__all__ = [ + "EnqueueResult", + "StaleWorkClaimError", + "StoredWork", + "WorkClaim", + "WorkCommit", + "WorkFailure", + "WorkQueueStatistic", + "WorkRepository", + "WorkResult", + "WorkSpec", + "WorkStateConflictError", + "WorkStatus", +] diff --git a/src/powercontext/builtin/runtime/config.py b/src/powercontext/builtin/runtime/config.py index 1fbacc9e48..d573caa78a 100644 --- a/src/powercontext/builtin/runtime/config.py +++ b/src/powercontext/builtin/runtime/config.py @@ -194,15 +194,14 @@ def validate_memory_capacity_order(self) -> RuntimeConfig: @model_validator(mode="after") def validate_profile_schedule(self): - from zoneinfo import ZoneInfo, ZoneInfoNotFoundError + from zoneinfo import ZoneInfoNotFoundError - from apscheduler.triggers.cron import CronTrigger + from powercontext.builtin.runtime.cron import CronSchedule try: - timezone = ZoneInfo(self.profile_timezone) + CronSchedule.parse(self.profile_cron, self.profile_timezone) except ZoneInfoNotFoundError as error: raise ValueError("invalid Profile schedule timezone") from error # noqa: TRY003 - CronTrigger.from_crontab(self.profile_cron, timezone=timezone) return self @model_validator(mode="after") @@ -298,6 +297,95 @@ def validate_topic_memory_history_candidates(self) -> RuntimeConfig: return self +class DeploymentConfig(BaseModel): + """Process topology and non-sensitive compatibility identity.""" + + mode: Literal["single_node", "distributed"] = "single_node" + role: Literal["all", "api", "scheduler", "worker"] = "all" + id: str = Field(default="local", min_length=1, max_length=128) + behavior_revision: str = Field(default="default", min_length=1, max_length=128) + + @field_validator("id", "behavior_revision") + @classmethod + def validate_trimmed_identifier(cls, value: str) -> str: + if value != value.strip(): + raise ValueError("deployment identifiers must be trimmed") # noqa: TRY003 + return value + + +class CoordinationConfig(BaseModel): + """Database-backed scheduler and member lease policy.""" + + scheduler_lease_seconds: int = Field(default=30, ge=3) + scheduler_renew_seconds: int = Field(default=10, ge=1) + scan_page_size: int = Field(default=100, ge=1, le=100) + member_ttl_seconds: int = Field(default=30, ge=3) + member_heartbeat_seconds: int = Field(default=10, ge=1) + emit_payload_version: int = Field(default=1, ge=1, le=1) + + @model_validator(mode="after") + def validate_lease_intervals(self) -> CoordinationConfig: + if self.scheduler_renew_seconds * 3 > self.scheduler_lease_seconds: + raise ValueError( # noqa: TRY003 + "scheduler_renew_seconds must not exceed one third of scheduler_lease_seconds" + ) + if self.member_heartbeat_seconds * 3 > self.member_ttl_seconds: + raise ValueError( # noqa: TRY003 + "member_heartbeat_seconds must not exceed one third of member_ttl_seconds" + ) + return self + + +class WorkerConfig(BaseModel): + """Worker claim, retry, heartbeat, and drain policy.""" + + concurrency: int = Field(default=4, ge=1, le=256) + lease_seconds: int = Field(default=120, ge=3) + heartbeat_seconds: int = Field(default=30, ge=1) + shutdown_grace_seconds: int = Field(default=90, ge=0) + max_attempts: int = Field(default=5, ge=1, le=100) + retry_base_seconds: float = Field(default=2.0, gt=0) + retry_max_seconds: float = Field(default=300.0, gt=0) + poll_seconds: float = Field(default=1.0, gt=0) + + @model_validator(mode="after") + def validate_lease_intervals(self) -> WorkerConfig: + if self.heartbeat_seconds * 3 >= self.lease_seconds: + raise ValueError( # noqa: TRY003 + "heartbeat_seconds must be less than one third of lease_seconds" + ) + if self.shutdown_grace_seconds >= self.lease_seconds: + raise ValueError("shutdown_grace_seconds must be less than lease_seconds") # noqa: TRY003 + if self.retry_max_seconds < self.retry_base_seconds: + raise ValueError("retry_max_seconds must not be less than retry_base_seconds") # noqa: TRY003 + return self + + +class OperationsConfig(BaseModel): + """Synchronous wait facade and durable operation retention policy.""" + + default_wait_seconds: float = Field(default=10.0, ge=0, le=30) + maximum_wait_seconds: float = Field(default=30.0, ge=0, le=30) + poll_seconds: float = Field(default=0.2, gt=0) + retention_days: int = Field(default=30, ge=1) + cleanup_batch_size: int = Field(default=500, ge=1, le=500) + cleanup_interval_seconds: float = Field(default=3600.0, gt=0) + + @model_validator(mode="after") + def validate_wait_policy(self) -> OperationsConfig: + if self.default_wait_seconds > self.maximum_wait_seconds: + raise ValueError("default_wait_seconds must not exceed maximum_wait_seconds") # noqa: TRY003 + return self + + +class RateLimitConfig(BaseModel): + """Optional shared fixed-window request limit.""" + + enabled: bool = False + requests: int = Field(default=120, ge=1) + window_seconds: int = Field(default=60, ge=1) + + class HandoffReportConfig(BaseModel): """Optional Handoff Report feature registration.""" @@ -508,6 +596,10 @@ class BuiltinConfig(BaseModel): handoff_report: HandoffReportConfig = Field(default_factory=HandoffReportConfig) inference: InferenceConfig = Field(default_factory=InferenceConfig) external_skills: ExternalSkillsConfig = Field(default_factory=ExternalSkillsConfig) + deployment: DeploymentConfig = Field(default_factory=DeploymentConfig) + coordination: CoordinationConfig = Field(default_factory=CoordinationConfig) + worker: WorkerConfig = Field(default_factory=WorkerConfig) + operations: OperationsConfig = Field(default_factory=OperationsConfig) @model_validator(mode="before") @classmethod @@ -515,7 +607,24 @@ def default_database_to_sqlite(cls, value: Any) -> Any: return normalize_database_discriminator(value) @model_validator(mode="after") - def validate_artifact_processing_role(self) -> BuiltinConfig: + def validate_deployment(self) -> BuiltinConfig: + if self.deployment.mode == "single_node" and self.deployment.role != "all": + raise ValueError("single_node deployment role must be 'all'") # noqa: TRY003 + if self.deployment.mode == "distributed": + if not isinstance(self.database, OceanBaseConfig): + raise ValueError("distributed deployment requires OceanBase") # noqa: TRY003 + if self.deployment.role == "all": + raise ValueError("distributed deployment role must be api, scheduler, or worker") # noqa: TRY003 + if self.runtime.artifact_processing_role != "all": + raise ValueError( # noqa: TRY003 + "distributed deployment does not use the process-local artifact processing role" + ) + if self.runtime.topic_memory_schedule_seconds is not None: + raise ValueError("distributed deployment does not support Topic Memory processing") # noqa: TRY003 + if self.external_skills.agent_targets: + raise ValueError( # noqa: TRY003 + "distributed deployment does not support host-local external Skill targets" + ) if not isinstance(self.database, OceanBaseConfig) and self.runtime.artifact_processing_role != "all": raise ValueError( # noqa: TRY003 "runtime.artifact_processing_role must be 'all' for SQLite and embedded seekdb" @@ -525,9 +634,14 @@ def validate_artifact_processing_role(self) -> BuiltinConfig: __all__ = [ "BuiltinConfig", + "CoordinationConfig", "DatabaseConfig", + "DeploymentConfig", "ExternalSkillsConfig", "HandoffReportConfig", "InferenceConfig", + "OperationsConfig", + "RateLimitConfig", "RuntimeConfig", + "WorkerConfig", ] diff --git a/src/powercontext/builtin/runtime/cron.py b/src/powercontext/builtin/runtime/cron.py new file mode 100644 index 0000000000..b18d80304d --- /dev/null +++ b/src/powercontext/builtin/runtime/cron.py @@ -0,0 +1,121 @@ +# 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. + +"""Small five-field cron schedule used by durable work discovery.""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import UTC, date, datetime, time, timedelta +from zoneinfo import ZoneInfo + +_MONTH_NAMES = { + name: index + for index, name in enumerate( + ["jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"], 1 + ) +} +_WEEKDAY_NAMES = {name: index for index, name in enumerate(["mon", "tue", "wed", "thu", "fri", "sat", "sun"])} + + +@dataclass(frozen=True, slots=True) +class CronSchedule: + """Validated crontab expression with deterministic UTC next-run calculation.""" + + minutes: frozenset[int] + hours: frozenset[int] + days: frozenset[int] + months: frozenset[int] + weekdays: frozenset[int] + timezone: ZoneInfo + + @classmethod + def parse(cls, expression: str, timezone: str, /) -> CronSchedule: + fields = expression.split() + if len(fields) != 5: + raise ValueError("cron expression must contain five fields") # noqa: TRY003 + return cls( + minutes=_parse_field(fields[0], minimum=0, maximum=59), + hours=_parse_field(fields[1], minimum=0, maximum=23), + days=_parse_field(fields[2], minimum=1, maximum=31), + months=_parse_field(fields[3], minimum=1, maximum=12, names=_MONTH_NAMES), + weekdays=_parse_field(fields[4], minimum=0, maximum=6, names=_WEEKDAY_NAMES), + timezone=ZoneInfo(timezone), + ) + + def next_after(self, value: datetime, /) -> datetime: + """Return the first matching instant after ``value`` as UTC-naive time.""" + + utc = value.replace(tzinfo=UTC) if value.tzinfo is None else value.astimezone(UTC) + start = (utc.astimezone(self.timezone) + timedelta(minutes=1)).replace(second=0, microsecond=0) + for offset in range(366 * 8): + candidate_date = start.date() + timedelta(days=offset) + if not self._matches_date(candidate_date): + continue + for hour in sorted(self.hours): + for minute in sorted(self.minutes): + candidate = datetime.combine(candidate_date, time(hour, minute), self.timezone) + if candidate < start or not _is_local_time(candidate): + continue + return candidate.astimezone(UTC).replace(tzinfo=None) + raise ValueError("cron expression has no occurrence in the next eight years") # noqa: TRY003 + + def _matches_date(self, value: date) -> bool: + return value.month in self.months and value.day in self.days and value.weekday() in self.weekdays + + +def _parse_field( + value: str, + *, + minimum: int, + maximum: int, + names: dict[str, int] | None = None, +) -> frozenset[int]: + selected: set[int] = set() + for part in value.casefold().split(","): + if not part: + raise ValueError("cron fields cannot contain empty items") # noqa: TRY003 + base, separator, step_text = part.partition("/") + if separator and (not step_text.isdecimal() or int(step_text) < 1): + raise ValueError("cron step must be a positive integer") # noqa: TRY003 + step = int(step_text) if separator else 1 + if base == "*": + first, last = minimum, maximum + elif "-" in base: + first_text, last_text = base.split("-", 1) + first = _field_value(first_text, names) + last = _field_value(last_text, names) + else: + first = _field_value(base, names) + last = maximum if separator else first + if first < minimum or last > maximum or first > last: + raise ValueError("cron field is outside its valid range") # noqa: TRY003 + selected.update(range(first, last + 1, step)) + return frozenset(selected) + + +def _field_value(value: str, names: dict[str, int] | None) -> int: + if names is not None and value in names: + return names[value] + if not value.isdecimal(): + raise ValueError("cron field must be numeric or a supported name") # noqa: TRY003 + return int(value) + + +def _is_local_time(value: datetime) -> bool: + round_trip = value.astimezone(UTC).astimezone(value.tzinfo) + return round_trip.replace(tzinfo=None) == value.replace(tzinfo=None) + + +__all__ = ["CronSchedule"] diff --git a/src/powercontext/builtin/runtime/durable_scheduler.py b/src/powercontext/builtin/runtime/durable_scheduler.py new file mode 100644 index 0000000000..87876053a1 --- /dev/null +++ b/src/powercontext/builtin/runtime/durable_scheduler.py @@ -0,0 +1,273 @@ +# 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. + +"""Fenced leader scheduler with durable bounded scan continuations.""" + +from __future__ import annotations + +import asyncio +from collections.abc import Iterable +from contextlib import AbstractContextManager, nullcontext, suppress +from dataclasses import dataclass +from datetime import timedelta +from typing import Protocol + +from powercontext.builtin.persistence.coordination import ( + CoordinationRepository, + CoordinatorLease, + StaleCoordinatorLeaseError, +) +from powercontext.builtin.persistence.database import AsyncDatabase, database_now +from powercontext.builtin.persistence.work import WorkRepository, WorkSpec +from powercontext.builtin.runtime.config import CoordinationConfig +from powercontext.builtin.runtime.protocols import RuntimeSpan, RuntimeTracing +from powercontext.builtin.runtime.readiness import ReadinessCheckStatus +from powercontext.builtin.runtime.work_observability import WorkObserver, refresh_work_queue + +_SCHEDULER_LEASE_NAME = "work-discovery" +_EMPTY_DISCOVERERS = "at least one work discoverer is required" +_INVALID_DISCOVERER_INTERVAL = "discoverer interval must be positive" +_INVALID_DISCOVERER_NAME = "discoverer name must be a non-empty trimmed string" + + +@dataclass(frozen=True) +class DiscoveryPage: + """One bounded page and its next opaque keyset position.""" + + specs: tuple[WorkSpec, ...] + continuation: str | None + + +class WorkDiscoverer(Protocol): + """Find logical work without executing external side effects.""" + + name: str + interval_seconds: float + + async def page(self, continuation: str | None, limit: int, /) -> DiscoveryPage: ... + + +class DurableScheduler: + """Acquire one leader lease and enqueue pages under its exact fence.""" + + def __init__( + self, + *, + database: AsyncDatabase, + scheduler_id: str, + discoverers: Iterable[WorkDiscoverer], + config: CoordinationConfig, + coordination: CoordinationRepository | None = None, + work: WorkRepository | None = None, + observer: WorkObserver | None = None, + tracing: RuntimeTracing | None = None, + ) -> None: + self._database = database + self._scheduler_id = scheduler_id + self._discoverers = _discoverer_map(discoverers) + self._config = config + self._coordination = CoordinationRepository() if coordination is None else coordination + self._work = WorkRepository(observer=observer) if work is None else work + self._observer = observer + self._tracing = tracing + self._poll_seconds = min( + float(config.scheduler_renew_seconds), + *(discoverer.interval_seconds for discoverer in self._discoverers.values()), + ) + self._lease: CoordinatorLease | None = None + self._stop_requested = asyncio.Event() + self._tick_lock = asyncio.Lock() + self._failed = False + + async def tick(self) -> bool: + """Renew or acquire leadership and process at most one page per discoverer.""" + + async with self._tick_lock: + if self._stop_requested.is_set(): + return False + return await self._tick() + + async def _tick(self) -> bool: + """Run one tick while the caller owns the scheduler lifecycle lock.""" + + previous = self._lease + with self._background( + "scheduler.tick", + operation="scheduler.tick", + attributes={"powercontext.scheduler.discoverer_count": len(self._discoverers)}, + ) as span: + async with self._database.transaction() as connection: + lease = await self._coordination.acquire_lease( + connection, + lease_name=_SCHEDULER_LEASE_NAME, + owner_id=self._scheduler_id, + lease_seconds=self._config.scheduler_lease_seconds, + ) + self._lease = lease + self._observe_leadership(previous, lease) + if lease is None: + if span is not None: + span.set_outcome("standby") + return False + for discoverer in self._discoverers.values(): + await self._scan_once(discoverer, lease) + if span is not None: + span.set_outcome("leader") + await refresh_work_queue(self._database, self._work, self._observer) + return True + + async def run(self) -> None: + """Maintain leadership until stopped; standby instances keep polling.""" + + while not self._stop_requested.is_set(): + try: + await self.tick() + self._failed = False + except asyncio.CancelledError: + raise + except StaleCoordinatorLeaseError: + self._observe_leadership(self._lease, None) + self._lease = None + except Exception: + self._observe_leadership(self._lease, None) + self._lease = None + self._failed = True + with suppress(TimeoutError): + await asyncio.wait_for( + self._stop_requested.wait(), + timeout=self._poll_seconds, + ) + + async def readiness(self) -> str: + """Treat both a healthy leader and a healthy standby as ready.""" + + return ReadinessCheckStatus.UNAVAILABLE if self._failed else ReadinessCheckStatus.READY + + async def stop(self) -> None: + """Stop discovery and conditionally release current leadership.""" + + self._stop_requested.set() + async with self._tick_lock: + lease = self._lease + self._lease = None + self._observe_leadership(lease, None) + if lease is None: + return + async with self._database.transaction() as connection: + await self._coordination.release_lease(connection, lease) + + async def _scan_once(self, discoverer: WorkDiscoverer, lease: CoordinatorLease) -> None: + async with self._database.transaction() as connection: + await self._coordination.assert_lease(connection, lease) + scan = await self._coordination.load_scan(connection, discoverer.name) + now = await database_now(connection) + if scan is not None and scan.next_run_at > now: + return + + continuation = None if scan is None else scan.continuation + page = await discoverer.page(continuation, self._config.scan_page_size) + if len(page.specs) > self._config.scan_page_size: + message = f"discoverer {discoverer.name} exceeded the configured scan page" + raise ValueError(message) + for spec in page.specs: + with self._stage( + "work.enqueue", + attributes={ + "powercontext.work.kind": spec.kind, + "powercontext.work.payload_version": spec.payload_version, + }, + ) as span: + async with self._database.transaction() as connection: + await self._coordination.assert_lease(connection, lease) + result = await self._work.enqueue(connection, spec) + if span is not None: + span.set_outcome("created" if result.created else "joined") + + async with self._database.transaction() as connection: + await self._coordination.assert_lease(connection, lease) + now = await database_now(connection) + next_run = getattr(discoverer, "next_run_at", None) + next_run_at = now + timedelta(seconds=discoverer.interval_seconds) if next_run is None else next_run(now) + if next_run_at <= now: + message = f"discoverer {discoverer.name} produced a non-future next run" + raise ValueError(message) + await self._coordination.save_scan( + connection, + discoverer.name, + next_run_at=now if page.continuation is not None else next_run_at, + continuation=page.continuation, + expected_version=None if scan is None else scan.state_version, + ) + + def _observe_leadership( + self, + previous: CoordinatorLease | None, + current: CoordinatorLease | None, + ) -> None: + if self._observer is None: + return + outcome = None + if previous is None and current is not None: + outcome = "acquired" + elif previous is not None and current is None: + outcome = "lost" + elif previous is not None and current is not None and previous.fence != current.fence: + outcome = "reacquired" + if outcome is not None: + with suppress(Exception): + self._observer.observe_scheduler_leadership(outcome=outcome) + + def _stage( + self, + name: str, + *, + attributes: dict[str, str | bool | int | float], + ) -> AbstractContextManager[RuntimeSpan | None]: + if self._tracing is None: + return nullcontext(None) + return self._tracing.stage(name, attributes=attributes) + + def _background( + self, + name: str, + *, + operation: str, + attributes: dict[str, str | bool | int | float], + ) -> AbstractContextManager[RuntimeSpan | None]: + if self._tracing is None: + return nullcontext(None) + return self._tracing.background(name, operation=operation, attributes=attributes) + + +def _discoverer_map(discoverers: Iterable[WorkDiscoverer]) -> dict[str, WorkDiscoverer]: + registered: dict[str, WorkDiscoverer] = {} + for discoverer in discoverers: + if not discoverer.name.strip() or discoverer.name != discoverer.name.strip(): + raise ValueError(_INVALID_DISCOVERER_NAME) + if discoverer.interval_seconds <= 0: + raise ValueError(_INVALID_DISCOVERER_INTERVAL) + if discoverer.name in registered: + message = f"duplicate discoverer name: {discoverer.name}" + raise ValueError(message) + registered[discoverer.name] = discoverer + if not registered: + raise ValueError(_EMPTY_DISCOVERERS) + return registered + + +__all__ = [ + "DiscoveryPage", + "DurableScheduler", + "WorkDiscoverer", +] diff --git a/src/powercontext/builtin/runtime/membership.py b/src/powercontext/builtin/runtime/membership.py new file mode 100644 index 0000000000..3d69880b38 --- /dev/null +++ b/src/powercontext/builtin/runtime/membership.py @@ -0,0 +1,197 @@ +# 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. + +"""Runtime role membership and single-node ownership leases.""" + +from __future__ import annotations + +import asyncio +import hashlib +from contextlib import suppress +from uuid import uuid4 + +from sqlalchemy.ext.asyncio import AsyncConnection + +from powercontext.builtin.persistence.coordination import ( + CoordinationRepository, + CoordinatorLease, + RuntimeMemberSpec, +) +from powercontext.builtin.persistence.database import AsyncDatabase +from powercontext.builtin.runtime.config import CoordinationConfig, DeploymentConfig +from powercontext.builtin.runtime.readiness import ReadinessCheckStatus +from powercontext.builtin.runtime.work_observability import WorkObserver + +_SINGLE_NODE_LEASE = "single-node-runtime" +_SCHEMA_VERSION = 2 +_PAYLOAD_VERSION = 1 + + +class DuplicateSingleNodeError(RuntimeError): + """Raised before serving when another single-node runtime owns the database.""" + + def __init__(self) -> None: + super().__init__("another single-node runtime already owns this database") + + +class RuntimeMembership: + """Advertise compatibility and maintain exclusive local ownership.""" + + def __init__( + self, + *, + database: AsyncDatabase, + deployment: DeploymentConfig, + coordination: CoordinationConfig, + build_version: str, + repository: CoordinationRepository | None = None, + observer: WorkObserver | None = None, + ) -> None: + self._database = database + self._deployment = deployment + self._config = coordination + self._build_version = build_version + self._repository = CoordinationRepository() if repository is None else repository + self._observer = observer + boot_id = uuid4().hex + self._member_id = hashlib.sha256(f"{deployment.id}\0{boot_id}".encode()).hexdigest() + self._single_node_lease: CoordinatorLease | None = None + self._stop_requested = asyncio.Event() + self._failed = False + + @property + def instance_id(self) -> str: + """Return the boot-unique, non-sensitive owner used for fencing.""" + + return self._member_id + + async def start(self) -> None: + """Acquire required ownership and publish the first heartbeat.""" + + async with self._database.transaction() as connection: + if self._deployment.mode == "single_node": + self._single_node_lease = await self._repository.acquire_lease( + connection, + lease_name=_SINGLE_NODE_LEASE, + owner_id=self._member_id, + lease_seconds=self._config.member_ttl_seconds, + ) + if self._single_node_lease is None: + raise DuplicateSingleNodeError + await self._heartbeat(connection) + + async def run(self) -> None: + """Renew role and optional owner leases until shutdown.""" + + try: + while not self._stop_requested.is_set(): + with suppress(TimeoutError): + await asyncio.wait_for( + self._stop_requested.wait(), + timeout=self._config.member_heartbeat_seconds, + ) + if self._stop_requested.is_set(): + return + async with self._database.transaction() as connection: + if self._single_node_lease is not None: + renewed = await self._repository.acquire_lease( + connection, + lease_name=_SINGLE_NODE_LEASE, + owner_id=self._member_id, + lease_seconds=self._config.member_ttl_seconds, + ) + self._single_node_lease = _validated_renewal(self._single_node_lease, renewed) + await self._heartbeat(connection) + except asyncio.CancelledError: + raise + except Exception: + self._failed = True + raise + + async def stop(self) -> None: + """Stop heartbeats and conditionally release single-node ownership.""" + + self._stop_requested.set() + lease = self._single_node_lease + self._single_node_lease = None + if lease is not None: + async with self._database.transaction() as connection: + await self._repository.release_lease(connection, lease) + + async def readiness(self) -> str: + """Verify this member is still live and compatible with its peers.""" + + if self._failed: + return ReadinessCheckStatus.UNAVAILABLE + async with self._database.transaction() as connection: + members = await self._repository.live_members(connection) + current = next((member for member in members if member.member_id == self._member_id), None) + if current is None: + return ReadinessCheckStatus.UNAVAILABLE + if any( + member.behavior_revision != self._deployment.behavior_revision + or member.schema_min > _SCHEMA_VERSION + or member.schema_max < _SCHEMA_VERSION + or member.payload_min > _PAYLOAD_VERSION + or member.payload_max < _PAYLOAD_VERSION + for member in members + ): + return ReadinessCheckStatus.MISCONFIGURED + return ReadinessCheckStatus.READY + + async def role_readiness(self, role: str) -> str: + """Report whether at least one compatible live member serves ``role``.""" + + async with self._database.transaction() as connection: + members = await self._repository.live_members(connection) + return ( + ReadinessCheckStatus.READY + if any( + member.role in {role, "all"} and member.behavior_revision == self._deployment.behavior_revision + for member in members + ) + else ReadinessCheckStatus.UNAVAILABLE + ) + + async def _heartbeat(self, connection: AsyncConnection) -> None: + await self._repository.heartbeat_member( + connection, + RuntimeMemberSpec( + member_id=self._member_id, + role=self._deployment.role, + build_version=self._build_version, + schema_min=_SCHEMA_VERSION, + schema_max=_SCHEMA_VERSION, + payload_min=_PAYLOAD_VERSION, + payload_max=_PAYLOAD_VERSION, + behavior_revision=self._deployment.behavior_revision, + ), + ttl_seconds=self._config.member_ttl_seconds, + ) + if self._observer is not None: + members = await self._repository.live_members(connection) + counts = dict.fromkeys(("all", "api", "scheduler", "worker"), 0) + for member in members: + counts[member.role] += 1 + with suppress(Exception): + self._observer.set_runtime_members(counts) + + +def _validated_renewal(current: CoordinatorLease, renewed: CoordinatorLease | None) -> CoordinatorLease: + if renewed is None or renewed.fence != current.fence: + raise DuplicateSingleNodeError + return renewed + + +__all__ = ["DuplicateSingleNodeError", "RuntimeMembership"] diff --git a/src/powercontext/builtin/runtime/operations.py b/src/powercontext/builtin/runtime/operations.py new file mode 100644 index 0000000000..35efa361bf --- /dev/null +++ b/src/powercontext/builtin/runtime/operations.py @@ -0,0 +1,211 @@ +# 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 durable operation submission and control.""" + +from __future__ import annotations + +import asyncio +import base64 +import binascii +import json +from contextlib import AbstractContextManager, nullcontext +from dataclasses import dataclass +from datetime import datetime +from time import monotonic + +from powercontext.builtin.persistence.database import AsyncDatabase +from powercontext.builtin.persistence.errors import InvalidRepositoryArgumentError, RepositoryNotFoundError +from powercontext.builtin.persistence.work import StoredWork, WorkRepository, WorkStatus +from powercontext.builtin.runtime.config import OperationsConfig, WorkerConfig +from powercontext.builtin.runtime.protocols import RuntimeSpan, RuntimeTracing +from powercontext.builtin.runtime.relational import RelationalContexts +from powercontext.builtin.runtime.work_handlers import EXPERIENCE_WORK_KIND +from powercontext.builtin.runtime.work_observability import WorkObserver, refresh_work_queue +from powercontext.builtin.runtime.worker import DurableWorker + +_TERMINAL = frozenset({WorkStatus.SUCCEEDED, WorkStatus.FAILED, WorkStatus.CANCELLED}) +_PUBLIC_KINDS = frozenset({EXPERIENCE_WORK_KIND}) + + +@dataclass(frozen=True) +class OperationPage: + items: tuple[StoredWork, ...] + next_cursor: str | None + + +class RuntimeOperationError(RuntimeError): + """Base class for durable operation facade errors.""" + + +class RuntimeOperationPendingError(RuntimeOperationError): + def __init__(self, operation_id: str) -> None: + self.operation_id = operation_id + super().__init__(f"operation {operation_id} is still pending") + + +class RuntimeOperationFailedError(RuntimeOperationError): + def __init__(self, operation: StoredWork) -> None: + self.operation = operation + super().__init__(f"operation {operation.work_id} failed with {operation.error_code or 'unknown'}") + + +class RuntimeOperationCancelledError(RuntimeOperationError): + def __init__(self, operation_id: str) -> None: + self.operation_id = operation_id + super().__init__(f"operation {operation_id} was cancelled") + + +class OperationManager: + """Submit, wait for, query, and mutate durable work records.""" + + def __init__( + self, + *, + contexts: RelationalContexts, + operations: OperationsConfig, + worker: WorkerConfig, + local_worker: DurableWorker | None, + payload_version: int, + repository: WorkRepository | None = None, + observer: WorkObserver | None = None, + tracing: RuntimeTracing | None = None, + ) -> None: + self._contexts = contexts + self._database: AsyncDatabase = contexts.database + self._operations = operations + self._worker_config = worker + self._local_worker = local_worker + self._payload_version = payload_version + self._repository = WorkRepository(observer=observer) if repository is None else repository + self._observer = observer + self._tracing = tracing + + @property + def default_wait_seconds(self) -> float: + return self._operations.default_wait_seconds + + @property + def maximum_wait_seconds(self) -> float: + return self._operations.maximum_wait_seconds + + @property + def database(self) -> AsyncDatabase: + """Expose the shared database to process-level coordination adapters.""" + + return self._database + + async def wait(self, operation_id: str, /, *, timeout_seconds: float) -> StoredWork | None: + if timeout_seconds < 0 or timeout_seconds > self._operations.maximum_wait_seconds: + raise InvalidRepositoryArgumentError( + "timeout_seconds", + f"must be between 0 and {self._operations.maximum_wait_seconds}", + ) + deadline = monotonic() + timeout_seconds + while True: + operation = await self.get(operation_id) + if operation.status in _TERMINAL: + return operation + remaining = deadline - monotonic() + if remaining <= 0: + return None + await asyncio.sleep(min(self._operations.poll_seconds, remaining)) + + async def get(self, operation_id: str, /) -> StoredWork: + async with self._database.transaction() as connection: + operation = await self._repository.get(connection, operation_id) + return _require_public_operation(operation) + + async def list( + self, + /, + *, + scope_id: str | None, + kind: str | None, + status: WorkStatus | None, + cursor: str | None, + limit: int, + ) -> OperationPage: + before = None if cursor is None else _decode_cursor(cursor) + async with self._database.transaction() as connection: + items = await self._repository.list( + connection, + scope_id=scope_id, + kind=kind, + allowed_kinds=_PUBLIC_KINDS, + status=status, + limit=limit, + before=before, + ) + next_cursor = None + if len(items) == limit: + last = items[-1] + next_cursor = _encode_cursor(last.created_at, last.work_id) + return OperationPage(items=items, next_cursor=next_cursor) + + async def cancel(self, operation_id: str, /, *, expected_version: int) -> StoredWork: + async with self._database.transaction() as connection: + _require_public_operation(await self._repository.get(connection, operation_id)) + operation = await self._repository.cancel(connection, operation_id, expected_version=expected_version) + await refresh_work_queue(self._database, self._repository, self._observer) + return operation + + async def retry(self, operation_id: str, /, *, expected_version: int) -> StoredWork: + async with self._database.transaction() as connection: + _require_public_operation(await self._repository.get(connection, operation_id)) + operation = await self._repository.retry(connection, operation_id, expected_version=expected_version) + if self._local_worker is not None: + self._local_worker.notify() + await refresh_work_queue(self._database, self._repository, self._observer) + return operation + + def _stage( + self, + name: str, + *, + attributes: dict[str, str | bool | int | float], + ) -> AbstractContextManager[RuntimeSpan | None]: + if self._tracing is None: + return nullcontext(None) + return self._tracing.stage(name, attributes=attributes) + + +def _require_public_operation(operation: StoredWork) -> StoredWork: + if operation.kind not in _PUBLIC_KINDS: + raise RepositoryNotFoundError("operation", operation.work_id) + return operation + + +def _encode_cursor(created_at: datetime, work_id: str) -> str: + payload = json.dumps([created_at.isoformat(), work_id], separators=(",", ":")).encode() + return base64.urlsafe_b64encode(payload).decode().rstrip("=") + + +def _decode_cursor(value: str) -> tuple[datetime, str]: + try: + padding = "=" * (-len(value) % 4) + created_at, work_id = json.loads(base64.urlsafe_b64decode(f"{value}{padding}")) + return datetime.fromisoformat(created_at), str(work_id) + except (ValueError, TypeError, json.JSONDecodeError, binascii.Error): + raise InvalidRepositoryArgumentError("cursor", "must be a valid operation cursor") from None + + +__all__ = [ + "OperationManager", + "OperationPage", + "RuntimeOperationCancelledError", + "RuntimeOperationError", + "RuntimeOperationFailedError", + "RuntimeOperationPendingError", +] diff --git a/src/powercontext/builtin/runtime/processing_registry.py b/src/powercontext/builtin/runtime/processing_registry.py index 9ca1a9f303..2207bb0dc5 100644 --- a/src/powercontext/builtin/runtime/processing_registry.py +++ b/src/powercontext/builtin/runtime/processing_registry.py @@ -47,6 +47,11 @@ def processing_capabilities(config: BuiltinConfig) -> tuple[str, ...]: """Use explicit cross-role declarations or infer executable built-ins.""" + # Distributed v1 executes Memory, Experience, and Profile work through the + # database-backed Work Ledger. The process-local Artifact Processing + # Supervisor remains the single-node execution engine. + if config.deployment.mode == "distributed": + return () declared = config.runtime.artifact_processing_families if declared is not None: return tuple(sorted(declared)) diff --git a/src/powercontext/builtin/runtime/protocols.py b/src/powercontext/builtin/runtime/protocols.py index ce44b6107f..0a295031b1 100644 --- a/src/powercontext/builtin/runtime/protocols.py +++ b/src/powercontext/builtin/runtime/protocols.py @@ -16,9 +16,10 @@ from __future__ import annotations -from collections.abc import Mapping +from collections.abc import Mapping, Sequence from contextlib import AbstractContextManager -from typing import Protocol, TypeVar +from dataclasses import dataclass +from typing import Any, Protocol, TypeVar from powercontext.builtin.artifacts.handoff import ActivateHandoff, HandoffActivation from powercontext.builtin.runtime.models import ( @@ -38,6 +39,14 @@ TraceAttribute = str | bool | int | float +@dataclass(frozen=True, slots=True) +class RuntimeTraceContext: + """Portable non-sensitive trace identity used to link background attempts.""" + + trace_id: str + span_id: str + + class RuntimeSpan(Protocol): """Record bounded attributes and a deferred outcome for one internal Runtime stage.""" @@ -62,9 +71,17 @@ def background( *, operation: str, attributes: Mapping[str, TraceAttribute], + links: Sequence[RuntimeTraceContext] = (), ) -> AbstractContextManager[RuntimeSpan]: ... +def runtime_trace_context(span: RuntimeSpan | None) -> RuntimeTraceContext | None: + """Read an optional concrete tracing context without expanding the port.""" + + value: Any = getattr(span, "trace_context", None) + return value if isinstance(value, RuntimeTraceContext) else None + + class PowerContextProvider(Protocol[SourcesT, ArtifactsT, TriggersT]): """Resolve an already composed context without transferring lifecycle ownership.""" diff --git a/src/powercontext/builtin/runtime/relational.py b/src/powercontext/builtin/runtime/relational.py index 03aebe12aa..0e7edef63c 100644 --- a/src/powercontext/builtin/runtime/relational.py +++ b/src/powercontext/builtin/runtime/relational.py @@ -1399,6 +1399,25 @@ async def scope_ids(self) -> tuple[str, ...]: ).scalars() return tuple(str(value) for value in values) + async def scope_ids_page(self, after: str | None, limit: int, /) -> tuple[str, ...]: + """Return one bounded keyset page of scopes that own a Source journal. + + Restored for the Work Ledger discoverers: the scheduler scans scopes in + keyset pages, so a full ``scope_ids()`` materialisation is neither + bounded nor restartable. + """ + + if limit < 1 or limit > 100: + raise ValueError("scope page limit must be between 1 and 100") # noqa: TRY003 + async with self.database.transaction() as connection: + statement = select(SOURCE_JOURNAL_HEADS_TABLE.c.scope_id) + if after is not None: + statement = statement.where(SOURCE_JOURNAL_HEADS_TABLE.c.scope_id > validate_scope_id(after)) + values = ( + await connection.execute(statement.order_by(SOURCE_JOURNAL_HEADS_TABLE.c.scope_id).limit(limit)) + ).scalars() + return tuple(str(value) for value in values) + async def unconsumed_source_scope_ids(self) -> tuple[str, ...]: """Return built-in Source windows that still have journal input.""" diff --git a/src/powercontext/builtin/runtime/work_handlers.py b/src/powercontext/builtin/runtime/work_handlers.py new file mode 100644 index 0000000000..cfa807ccdc --- /dev/null +++ b/src/powercontext/builtin/runtime/work_handlers.py @@ -0,0 +1,676 @@ +# 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. + +"""Built-in Memory and Experience work discovery and exact-window handlers.""" + +from __future__ import annotations + +import asyncio +import hashlib +import json +from collections.abc import Awaitable, Callable +from datetime import datetime, timedelta +from typing import Literal + +from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator +from sqlalchemy import select +from sqlalchemy.exc import IntegrityError, OperationalError +from sqlalchemy.ext.asyncio import AsyncConnection + +from powercontext.builtin.artifacts.experience import EXPERIENCE_INCUBATION_CURSOR_NAME +from powercontext.builtin.artifacts.profile.models import ( + PROFILE_SOURCE_WINDOW_BINDING, + ProfileFlushResult, +) +from powercontext.builtin.artifacts.profile.service import PreparedProfileFlush, RelationalProfileService +from powercontext.builtin.artifacts.prompt.service import current_prompt +from powercontext.builtin.inference.usage import bind_usage_reporter +from powercontext.builtin.persistence.cursors import StoredSourceCursor +from powercontext.builtin.persistence.database import database_now, is_transaction_contention +from powercontext.builtin.persistence.errors import GenerationConflictError +from powercontext.builtin.persistence.rate_limit import RateLimitRepository +from powercontext.builtin.persistence.tables import PROFILE_POLICIES_TABLE +from powercontext.builtin.persistence.work import ( + WorkClaim, + WorkRepository, + WorkResult, + WorkSpec, +) +from powercontext.builtin.records import BaseValueConflictError +from powercontext.builtin.runtime.cron import CronSchedule +from powercontext.builtin.runtime.durable_scheduler import DiscoveryPage +from powercontext.builtin.runtime.models import ExperienceIncubationResult +from powercontext.builtin.runtime.relational import RelationalContexts, _validate_experience_plans +from powercontext.builtin.runtime.worker import PreparedWork, WorkExecutionError +from powercontext.builtin.sources import SourceCursor, validate_scope_id +from powercontext.builtin.statistics import ModelUsagePurpose +from powercontext.builtin.triggers import SourceHighWatermark, SourceWindowTrigger +from powercontext.errors import RevisionConflictError + +EXPERIENCE_WORK_KIND = "powercontext.experience.incubation" +PROFILE_WORK_KIND = "powercontext.profile.source-window" +MAINTENANCE_WORK_KIND = "powercontext.maintenance.operations" +CURRENT_WORK_PAYLOAD_VERSION = 1 + +ProfileWorkRunner = Callable[[str, int, RelationalProfileService], Awaitable[PreparedProfileFlush]] + + +class WorkRequester(BaseModel): + """Non-sensitive identity reference carried across async execution.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + + type: Literal["user", "service"] + id: str = Field(min_length=1, max_length=255) + + @field_validator("id") + @classmethod + def validate_id(cls, value: str) -> str: + if value != value.strip(): + raise ValueError("requester id must be trimmed") # noqa: TRY003 + return value + + +class SourceWindowPayload(BaseModel): + """Reference-only snapshot of one bounded Source cursor transition.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + + cursor_name: str + cursor_generation: int = Field(ge=0) + after: int = Field(ge=0) + through: int = Field(ge=1) + high_watermark: int = Field(ge=1) + requester: WorkRequester | None = None + + +class ProfileWorkPayload(BaseModel): + """Reference-only boundary for one scheduled Profile catch-up.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + + cursor_generation: int = Field(ge=0) + after: int = Field(ge=0) + high_watermark: int = Field(ge=1) + + +class ExperienceWorkDiscoverer: + """Scan Source scopes and describe pending Experience windows.""" + + name = EXPERIENCE_WORK_KIND + + def __init__( + self, + contexts: RelationalContexts, + *, + interval_seconds: float, + window_limit: int, + max_attempts: int, + payload_version: int = CURRENT_WORK_PAYLOAD_VERSION, + ) -> None: + self._contexts = contexts + self.interval_seconds = interval_seconds + self._window_limit = window_limit + self._max_attempts = max_attempts + self._payload_version = payload_version + + async def page(self, continuation: str | None, limit: int, /) -> DiscoveryPage: + scopes = await self._contexts.scope_ids_page(continuation, limit) + specs: list[WorkSpec] = [] + for scope_id in scopes: + spec = await experience_work_spec( + self._contexts, + scope_id, + limit=self._window_limit, + max_attempts=self._max_attempts, + payload_version=self._payload_version, + ) + if spec is not None: + specs.append(spec) + return DiscoveryPage( + specs=tuple(specs), + continuation=scopes[-1] if len(scopes) == limit else None, + ) + + +class ProfileWorkDiscoverer: + """Scan enabled Profile policies on a persistent cron schedule.""" + + name = PROFILE_WORK_KIND + interval_seconds = 60.0 + + def __init__( + self, + contexts: RelationalContexts, + *, + cron: str, + timezone: str, + max_attempts: int, + payload_version: int = CURRENT_WORK_PAYLOAD_VERSION, + ) -> None: + self._contexts = contexts + self._schedule = CronSchedule.parse(cron, timezone) + self._max_attempts = max_attempts + self._payload_version = payload_version + + def next_run_at(self, now: datetime, /) -> datetime: + return self._schedule.next_after(now) + + async def page(self, continuation: str | None, limit: int, /) -> DiscoveryPage: + async with self._contexts.database.transaction() as connection: + query = select(PROFILE_POLICIES_TABLE.c.scope_id).where( + PROFILE_POLICIES_TABLE.c.generation_enabled.is_(True), + PROFILE_POLICIES_TABLE.c.pending_candidate_id.is_(None), + ) + if continuation is not None: + query = query.where(PROFILE_POLICIES_TABLE.c.scope_id > validate_scope_id(continuation)) + values = ( + await connection.execute(query.order_by(PROFILE_POLICIES_TABLE.c.scope_id).limit(limit)) + ).scalars() + scopes = tuple(str(value) for value in values) + specs: list[WorkSpec] = [] + for scope_id in scopes: + spec = await profile_work_spec( + self._contexts, + scope_id, + max_attempts=self._max_attempts, + payload_version=self._payload_version, + ) + if spec is not None: + specs.append(spec) + return DiscoveryPage( + specs=tuple(specs), + continuation=scopes[-1] if len(scopes) == limit else None, + ) + + +class OperationMaintenanceDiscoverer: + """Schedule one globally serialized, bounded history-retention batch.""" + + name = MAINTENANCE_WORK_KIND + + def __init__(self, *, interval_seconds: float, max_attempts: int) -> None: + self.interval_seconds = interval_seconds + self._max_attempts = max_attempts + + async def page(self, continuation: str | None, _limit: int, /) -> DiscoveryPage: + if continuation is not None: + return DiscoveryPage(specs=(), continuation=None) + return DiscoveryPage( + specs=( + WorkSpec( + kind=MAINTENANCE_WORK_KIND, + payload_version=CURRENT_WORK_PAYLOAD_VERSION, + scope_id="system:operations", + lane_key=_digest("maintenance:operations"), + logical_key=_digest(MAINTENANCE_WORK_KIND), + payload={}, + max_attempts=self._max_attempts, + ), + ), + continuation=None, + ) + + +class ExperienceWorkHandler: + """Prepare Experience proposals and commit Candidates with cursor and Work.""" + + kind = EXPERIENCE_WORK_KIND + supported_versions = frozenset({CURRENT_WORK_PAYLOAD_VERSION}) + + def __init__(self, contexts: RelationalContexts) -> None: + self._contexts = contexts + + async def prepare(self, claim: WorkClaim, /) -> PreparedWork: + payload = _payload(claim) + services = self._contexts._services_for(claim.scope_id) + pipeline = services.experience_pipeline + if pipeline is None: + raise WorkExecutionError(category="configuration", code="experience_pipeline_unavailable", retryable=False) + async with self._contexts.database.transaction() as connection: + state_row = await services.repositories.cursors.load( + connection, + claim.scope_id, + EXPERIENCE_INCUBATION_CURSOR_NAME, + ) + current_sequence, current_generation = _cursor_position(state_row) + if current_sequence >= payload.through: + return PreparedWork( + result=_experience_result( + payload, + source_count=payload.through - payload.after, + candidate_count=0, + code="already_committed", + ) + ) + if _is_superseded_window(payload, current_sequence, current_generation): + # A committed racing window consumed this snapshot's prefix. + # Retire instead of failing so the key is released and the lane + # keeps moving; discovery re-enqueues the remaining Sources. + return PreparedWork( + result=_experience_result( + payload, + source_count=0, + candidate_count=0, + code="superseded", + current_cursor=current_sequence, + ) + ) + _require_exact_cursor(payload, current_sequence, current_generation) + rows = await services.repositories.sources.list_window( + connection, + claim.scope_id, + after=payload.after, + through=payload.through, + ) + eligible_rows = await services.generation_sources().list_window_for_generation( + connection, + claim.scope_id, + after=payload.after, + through=payload.through, + ) + _require_complete_window(rows, payload) + try: + async with services.prompts.bind(claim.scope_id, "experience.incubate"): + with bind_usage_reporter( + self._contexts.model_usage_reporter(claim.scope_id), + generation_purpose=ModelUsagePurpose.EXPERIENCE_GENERATION, + ): + plans = ( + () if not eligible_rows else await pipeline.incubate(tuple(row.value for row in eligible_rows)) + ) + selection = current_prompt("experience.incubate") + prompt_refs = () if selection is None or selection.artifact is None else (selection.artifact,) + finally: + await self._contexts.flush_model_usage() + _validate_experience_plans(plans, eligible_rows) + + async def commit(connection: AsyncConnection) -> WorkResult: + locked = await services.repositories.cursors.load( + connection, + claim.scope_id, + EXPERIENCE_INCUBATION_CURSOR_NAME, + for_update=True, + ) + sequence, generation = _cursor_position(locked) + _require_exact_cursor(payload, sequence, generation) + review = services.review(connection) + candidate_ids: list[str] = [] + for plan in plans: + candidate = await review.propose_experience( + plan.proposal, + sources=plan.sources, + artifacts=prompt_refs, + target=None, + reason=plan.reason, + ) + candidate_ids.append(candidate.candidate_id) + for proposal in await services.recurrence_ledger().record_window(connection, eligible_rows): + target_content = await services.repositories.artifacts.get( + connection, + claim.scope_id, + proposal.target, + ) + candidate = await review.propose_experience( + proposal.proposal, + sources=proposal.sources, + artifacts=(target_content.as_ref(),), + target=proposal.target, + reason=proposal.reason, + ) + candidate_ids.append(candidate.candidate_id) + await services.repositories.cursors.save( + connection, + claim.scope_id, + EXPERIENCE_INCUBATION_CURSOR_NAME, + SourceCursor(sequence=payload.through), + expected_generation=None if payload.cursor_generation == 0 else payload.cursor_generation, + ) + return _experience_result( + payload, + source_count=len(eligible_rows), + candidate_count=len(candidate_ids), + candidate_ids=tuple(candidate_ids), + code="processed", + ) + + return PreparedWork(result=None, commit=commit) + + +class ProfileWorkHandler: + """Prepare one Profile window outside locks and commit it with Work success. + + One attempt prepares and commits exactly one window: the model call runs + outside any transaction during ``prepare``, and the Profile, its cursor + and the Work success commit inside the single fenced completion + transaction. Remaining windows are re-discovered on the next scheduled + scan, so an expired or superseded Worker can never publish a Profile. + """ + + kind = PROFILE_WORK_KIND + supported_versions = frozenset({CURRENT_WORK_PAYLOAD_VERSION}) + + def __init__( + self, + profiles: RelationalProfileService, + *, + max_concurrency: int, + runner: ProfileWorkRunner | None = None, + ) -> None: + self._profiles = profiles + self._runner = runner + self._semaphore = asyncio.Semaphore(max_concurrency) + + async def prepare(self, claim: WorkClaim, /) -> PreparedWork: + payload = _profile_payload(claim) + async with self._semaphore: + prepared = ( + await self._runner(claim.scope_id, payload.high_watermark, self._profiles) + if self._runner is not None + else await self._profiles.prepare_flush(claim.scope_id, high_watermark=payload.high_watermark) + ) + if prepared.result is not None: + return PreparedWork(result=_profile_result(prepared.result)) + + async def commit(connection: AsyncConnection) -> WorkResult: + try: + flushed = await self._profiles.commit_flush(connection, prepared) + except ( + BaseValueConflictError, + GenerationConflictError, + RevisionConflictError, + IntegrityError, + OperationalError, + ) as error: + if isinstance(error, OperationalError) and not is_transaction_contention(error): + raise + raise WorkExecutionError(category="conflict", code="profile_cursor_changed", retryable=True) from error + return _profile_result(flushed) + + return PreparedWork(result=None, commit=commit) + + +class OperationMaintenanceHandler: + """Delete one bounded batch of expired successful/cancelled operations.""" + + kind = MAINTENANCE_WORK_KIND + supported_versions = frozenset({CURRENT_WORK_PAYLOAD_VERSION}) + + def __init__(self, *, retention_days: int, batch_size: int) -> None: + self._retention_days = retention_days + self._batch_size = batch_size + self._repository = WorkRepository() + self._rate_limits = RateLimitRepository() + + async def prepare(self, claim: WorkClaim, /) -> PreparedWork: + if claim.payload: + raise WorkExecutionError(category="payload", code="invalid_payload", retryable=False) + + async def commit(connection: AsyncConnection) -> WorkResult: + now = await database_now(connection) + deleted = await self._repository.purge_terminal( + connection, + completed_before=now - timedelta(days=self._retention_days), + limit=self._batch_size, + ) + counters_deleted = await self._rate_limits.purge_expired(connection, limit=self._batch_size) + return WorkResult( + code="cleaned", + payload={ + "operations_deleted": deleted, + "rate_limit_windows_deleted": counters_deleted, + }, + ) + + return PreparedWork(result=None, commit=commit) + + +async def experience_work_spec( + contexts: RelationalContexts, + scope_id: str, + /, + *, + limit: int, + max_attempts: int, + payload_version: int, +) -> WorkSpec | None: + return await _discover_spec( + contexts, + scope_id, + cursor_name=EXPERIENCE_INCUBATION_CURSOR_NAME, + kind=EXPERIENCE_WORK_KIND, + limit=limit, + max_attempts=max_attempts, + payload_version=payload_version, + ) + + +async def profile_work_spec( + contexts: RelationalContexts, + scope_id: str, + /, + *, + max_attempts: int, + payload_version: int, +) -> WorkSpec | None: + scope = validate_scope_id(scope_id) + async with contexts.database.transaction() as connection: + services = contexts._services_for(scope) + cursor = await services.repositories.cursors.load(connection, scope, PROFILE_SOURCE_WINDOW_BINDING) + high_watermark = await services.repositories.sources.journal_position(connection, scope) + after, generation = _cursor_position(cursor) + if after >= high_watermark: + return None + payload = ProfileWorkPayload( + cursor_generation=generation, + after=after, + high_watermark=high_watermark, + ) + return WorkSpec( + kind=PROFILE_WORK_KIND, + payload_version=payload_version, + scope_id=scope, + lane_key=_digest(f"profile:{scope}"), + logical_key=_digest( + json.dumps( + [PROFILE_WORK_KIND, scope, PROFILE_SOURCE_WINDOW_BINDING, generation, after], + ensure_ascii=False, + separators=(",", ":"), + ) + ), + payload=payload.model_dump(mode="json"), + max_attempts=max_attempts, + ) + + +async def _discover_spec( + contexts: RelationalContexts, + scope_id: str, + *, + cursor_name: str, + kind: str, + limit: int, + max_attempts: int, + payload_version: int, +) -> WorkSpec | None: + scope = validate_scope_id(scope_id) + async with contexts.database.transaction() as connection: + state_row, high_watermark = await _window_state(contexts, connection, scope, cursor_name) + payload = _window_payload(state_row, high_watermark, cursor_name, limit) + return ( + None + if payload is None + else _work_spec( + kind=kind, + scope_id=scope, + payload=payload, + max_attempts=max_attempts, + payload_version=payload_version, + ) + ) + + +async def _window_state( + contexts: RelationalContexts, + connection: AsyncConnection, + scope_id: str, + cursor_name: str, +) -> tuple[StoredSourceCursor | None, int]: + services = contexts._services_for(scope_id) + state_row = await services.repositories.cursors.load(connection, scope_id, cursor_name) + high_watermark = await services.repositories.sources.journal_position(connection, scope_id) + return state_row, high_watermark + + +def _window_payload( + state_row: StoredSourceCursor | None, + high_watermark: int, + cursor_name: str, + limit: int, + *, + requester: WorkRequester | None = None, +) -> SourceWindowPayload | None: + state = SourceCursor() if state_row is None else state_row.cursor + transition = SourceWindowTrigger().activate( + SourceHighWatermark(sequence=high_watermark, limit=limit), + state, + ) + if not transition.actions: + return None + action = transition.actions[0] + return SourceWindowPayload( + cursor_name=cursor_name, + cursor_generation=0 if state_row is None else state_row.generation, + after=action.after, + through=action.through, + high_watermark=high_watermark, + requester=requester, + ) + + +def _work_spec( + *, + kind: str, + scope_id: str, + payload: SourceWindowPayload, + max_attempts: int, + payload_version: int, +) -> WorkSpec: + lane_key = _digest(f"{kind.split('.')[1]}:{scope_id}") + logical_key = _digest( + json.dumps( + [kind, scope_id, payload.cursor_name, payload.cursor_generation, payload.after], + ensure_ascii=False, + separators=(",", ":"), + ) + ) + return WorkSpec( + kind=kind, + payload_version=payload_version, + scope_id=scope_id, + lane_key=lane_key, + logical_key=logical_key, + payload=payload.model_dump(mode="json", exclude_none=True), + max_attempts=max_attempts, + ) + + +def _payload(claim: WorkClaim) -> SourceWindowPayload: + try: + return SourceWindowPayload.model_validate(claim.payload) + except ValidationError: + raise WorkExecutionError(category="payload", code="invalid_payload", retryable=False) from None + + +def _profile_payload(claim: WorkClaim) -> ProfileWorkPayload: + try: + return ProfileWorkPayload.model_validate(claim.payload) + except ValidationError: + raise WorkExecutionError(category="payload", code="invalid_payload", retryable=False) from None + + +def _cursor_position(state_row: StoredSourceCursor | None) -> tuple[int, int]: + return (0, 0) if state_row is None else (state_row.cursor.sequence, state_row.generation) + + +def _require_exact_cursor(payload: SourceWindowPayload, sequence: int, generation: int) -> None: + if sequence != payload.after or generation != payload.cursor_generation: + raise WorkExecutionError(category="conflict", code="cursor_changed", retryable=False) + + +def _is_superseded_window(payload: SourceWindowPayload, sequence: int, generation: int) -> bool: + """True when a committed racing window already consumed this snapshot's prefix. + + A generation mismatch means the Source cursor was regenerated and the + window identity no longer holds, which stays a terminal failure — except + for snapshots taken before the cursor row existed at all: the racing + window's first commit creates that row at generation 1, which is an + ordinary advance, not a reset. + """ + + if payload.cursor_generation == 0: + return generation == 1 and payload.after < sequence < payload.through + return generation == payload.cursor_generation and payload.after < sequence < payload.through + + +def _require_complete_window(rows, payload: SourceWindowPayload) -> None: + expected = payload.through - payload.after + if len(rows) != expected or any( + row.journal_position != payload.after + offset for offset, row in enumerate(rows, start=1) + ): + raise WorkExecutionError(category="retention", code="source_window_unavailable", retryable=False) + + +def _experience_result( + payload: SourceWindowPayload, + *, + source_count: int, + candidate_count: int, + candidate_ids: tuple[str, ...] = (), + code: str, + current_cursor: int | None = None, +) -> WorkResult: + result = ExperienceIncubationResult( + previous_cursor=payload.after, + current_cursor=payload.through if current_cursor is None else current_cursor, + high_watermark=payload.high_watermark, + source_count=source_count, + candidate_count=candidate_count, + candidate_ids=candidate_ids, + ) + return WorkResult(code=code, payload=result.model_dump(mode="json")) + + +def _profile_result(result: ProfileFlushResult) -> WorkResult: + return WorkResult(code=result.status, payload=result.model_dump(mode="json", exclude_none=True)) + + +def _digest(value: str) -> str: + return hashlib.sha256(value.encode()).hexdigest() + + +__all__ = [ + "CURRENT_WORK_PAYLOAD_VERSION", + "EXPERIENCE_WORK_KIND", + "PROFILE_WORK_KIND", + "ExperienceWorkDiscoverer", + "ExperienceWorkHandler", + "ProfileWorkDiscoverer", + "ProfileWorkHandler", + "ProfileWorkPayload", + "ProfileWorkRunner", + "SourceWindowPayload", + "WorkRequester", + "experience_work_spec", +] diff --git a/src/powercontext/builtin/runtime/work_observability.py b/src/powercontext/builtin/runtime/work_observability.py new file mode 100644 index 0000000000..143f835c72 --- /dev/null +++ b/src/powercontext/builtin/runtime/work_observability.py @@ -0,0 +1,69 @@ +# 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. + +"""Bounded-label observability port for durable background work.""" + +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from typing import TYPE_CHECKING, Protocol + +if TYPE_CHECKING: + from powercontext.builtin.persistence.database import AsyncDatabase + from powercontext.builtin.persistence.work import WorkQueueStatistic, WorkRepository + + +class WorkObserver(Protocol): + """Receive work events containing only bounded dimensions.""" + + def observe_work_enqueue(self, kind: str, *, created: bool) -> None: ... + + def observe_work_claim(self, kind: str, *, latency_seconds: float) -> None: ... + + def observe_work_attempt( + self, + kind: str, + *, + outcome: str, + error_category: str, + duration_seconds: float, + ) -> None: ... + + def observe_work_lease_expiry(self, kind: str, *, outcome: str) -> None: ... + + def observe_scheduler_leadership(self, *, outcome: str) -> None: ... + + def set_work_queue(self, samples: Sequence[WorkQueueStatistic]) -> None: ... + + def set_runtime_members(self, counts: Mapping[str, int]) -> None: ... + + +async def refresh_work_queue( + database: AsyncDatabase, + repository: WorkRepository, + observer: WorkObserver | None, +) -> None: + """Best-effort refresh aggregate queue gauges from durable state.""" + + if observer is None: + return + try: + async with database.transaction() as connection: + samples = await repository.queue_statistics(connection) + observer.set_work_queue(samples) + except Exception: + return + + +__all__ = ["WorkObserver", "refresh_work_queue"] diff --git a/src/powercontext/builtin/runtime/worker.py b/src/powercontext/builtin/runtime/worker.py new file mode 100644 index 0000000000..c9eb0ffd1c --- /dev/null +++ b/src/powercontext/builtin/runtime/worker.py @@ -0,0 +1,418 @@ +# 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. + +"""Lease-aware Worker loop over the durable Work Ledger.""" + +from __future__ import annotations + +import asyncio +import logging +import random +from collections.abc import Awaitable, Callable, Iterable +from contextlib import AbstractContextManager, nullcontext, suppress +from dataclasses import dataclass +from typing import Protocol + +from powercontext._logging import log_safely +from powercontext.builtin.persistence.database import AsyncDatabase +from powercontext.builtin.persistence.work import ( + StaleWorkClaimError, + StoredWork, + WorkClaim, + WorkCommit, + WorkFailure, + WorkRepository, + WorkResult, +) +from powercontext.builtin.runtime.config import WorkerConfig +from powercontext.builtin.runtime.protocols import ( + RuntimeSpan, + RuntimeTraceContext, + RuntimeTracing, + runtime_trace_context, +) +from powercontext.builtin.runtime.readiness import ReadinessCheckStatus +from powercontext.builtin.runtime.work_observability import WorkObserver, refresh_work_queue + +ClaimReadiness = Callable[[], Awaitable[str]] +WorkAuthorizer = Callable[[WorkClaim], Awaitable[None]] +WorkSucceededObserver = Callable[[StoredWork], Awaitable[None]] +_EMPTY_HANDLERS = "at least one handler is required" +_INVALID_HANDLER_KIND = "handler kind must be a non-empty trimmed string" +_INVALID_HANDLER_VERSIONS = "handler versions must be positive" + +logger = logging.getLogger(__name__) + + +class WorkHandler(Protocol): + """Prepare one versioned task outside a database transaction.""" + + kind: str + supported_versions: frozenset[int] + + async def prepare(self, claim: WorkClaim, /) -> PreparedWork: ... + + +@dataclass(frozen=True) +class PreparedWork: + """Sanitized result and optional domain write for the final transaction.""" + + result: WorkResult | None + commit: WorkCommit | None = None + + +class WorkExecutionError(RuntimeError): + """A handler failure safe to classify in persistent operation metadata.""" + + def __init__(self, *, category: str, code: str, retryable: bool) -> None: + self.failure = WorkFailure(category=category, code=code, retryable=retryable) + super().__init__(f"work handler failed with {category}/{code}") + + +class DurableWorker: + """Claim no more than available slots and recover through lease expiry.""" + + def __init__( + self, + *, + database: AsyncDatabase, + worker_id: str, + handlers: Iterable[WorkHandler], + config: WorkerConfig, + repository: WorkRepository | None = None, + random_source: Callable[[float, float], float] = random.uniform, + claim_readiness: ClaimReadiness | None = None, + authorizer: WorkAuthorizer | None = None, + succeeded_observer: WorkSucceededObserver | None = None, + observer: WorkObserver | None = None, + tracing: RuntimeTracing | None = None, + ) -> None: + self._database = database + self._worker_id = worker_id + self._config = config + self._repository = WorkRepository(observer=observer) if repository is None else repository + self._random_source = random_source + self._claim_readiness = claim_readiness + self._authorizer = authorizer + self._succeeded_observer = succeeded_observer + self._observer = observer + self._tracing = tracing + self._handlers = _handler_map(handlers) + self._stop_requested = asyncio.Event() + self._wake_requested = asyncio.Event() + self._claim_lock = asyncio.Lock() + self._running: set[asyncio.Task[None]] = set() + self._failed = False + + @property + def supported(self) -> dict[str, frozenset[int]]: + """Return bounded handler compatibility advertised by this Worker.""" + + return {kind: handler.supported_versions for kind, handler in self._handlers.items()} + + async def run_once(self) -> int: + """Claim current free capacity and await those attempts.""" + + async with self._claim_lock: + capacity = self._config.concurrency - len(self._running) + if capacity <= 0 or self._stop_requested.is_set(): + return 0 + if self._claim_readiness is not None and await self._claim_readiness() != ReadinessCheckStatus.READY: + return 0 + with self._background( + "work.claim", + operation="work.claim", + attributes={"powercontext.work.claim.capacity": capacity}, + ) as span: + claims = await self._claim_capacity(capacity) + if span is not None: + span.set_attributes({"powercontext.work.claim.count": len(claims)}) + tasks = {asyncio.create_task(self._execute(claim)) for claim in claims} + self._running.update(tasks) + for task in tasks: + task.add_done_callback(self._running.discard) + if tasks: + await asyncio.gather(*tasks) + await refresh_work_queue(self._database, self._repository, self._observer) + return len(claims) + + async def _claim_capacity(self, capacity: int) -> tuple[WorkClaim, ...]: + """Claim at most one lane per short transaction.""" + + claims: list[WorkClaim] = [] + supported = self.supported + for _ in range(capacity): + async with self._database.transaction() as connection: + claimed = await self._repository.claim( + connection, + worker_id=self._worker_id, + supported=supported, + lease_seconds=self._config.lease_seconds, + limit=1, + ) + if claimed: + claims.extend(claimed) + else: + break + return tuple(claims) + + async def run(self) -> None: + """Poll until shutdown, executing each claimed batch concurrently.""" + + while not self._stop_requested.is_set(): + self._wake_requested.clear() + try: + claimed = await self.run_once() + self._failed = False + except asyncio.CancelledError: + raise + except Exception: + self._failed = True + claimed = 0 + if claimed: + continue + with suppress(TimeoutError): + await asyncio.wait_for(self._wake_requested.wait(), timeout=self._config.poll_seconds) + + def notify(self) -> None: + """Wake the polling loop after a producer commits new work.""" + + self._wake_requested.set() + + async def readiness(self) -> str: + """Report database-loop and registered payload compatibility.""" + + if self._failed: + return ReadinessCheckStatus.UNAVAILABLE + if self._claim_readiness is not None: + dependency = await self._claim_readiness() + if dependency != ReadinessCheckStatus.READY: + return dependency + async with self._database.transaction() as connection: + unknown = await self._repository.unsupported_head_count(connection, self.supported) + return ReadinessCheckStatus.MISCONFIGURED if unknown else ReadinessCheckStatus.READY + + async def stop(self) -> None: + """Stop claiming, then give in-flight handlers their configured grace.""" + + self._stop_requested.set() + self._wake_requested.set() + if not self._running: + return + _, pending = await asyncio.wait(self._running, timeout=self._config.shutdown_grace_seconds) + for task in pending: + task.cancel() + if pending: + await asyncio.gather(*pending, return_exceptions=True) + + async def _execute(self, claim: WorkClaim) -> None: + links = ( + () + if claim.previous_trace_id is None or claim.previous_span_id is None + else (RuntimeTraceContext(trace_id=claim.previous_trace_id, span_id=claim.previous_span_id),) + ) + with self._background( + "work.execute", + operation="work.execute", + attributes={ + "powercontext.work.kind": claim.kind, + "powercontext.work.payload_version": claim.payload_version, + "powercontext.work.attempt": claim.attempt_no, + "powercontext.work.recovery_generation": claim.recovery_generation, + }, + links=links, + ) as span: + context = runtime_trace_context(span) + if context is not None and not await self._bind_attempt_trace(claim, context): + if span is not None: + span.set_outcome("stale") + return + outcome = await self._execute_claim(claim) + if span is not None: + span.set_outcome(outcome) + + async def _execute_claim(self, claim: WorkClaim) -> str: + handler = self._handlers[claim.kind] + heartbeat_stop = asyncio.Event() + heartbeat = asyncio.create_task(self._heartbeat(claim, heartbeat_stop)) + try: + if self._authorizer is not None: + await self._authorizer(claim) + prepared = await handler.prepare(claim) + if heartbeat.done() and heartbeat.exception() is not None: + await heartbeat + with self._stage( + "work.commit", + attributes={ + "powercontext.work.kind": claim.kind, + "powercontext.work.payload_version": claim.payload_version, + }, + ): + async with self._database.transaction() as connection: + completed = await self._repository.complete( + connection, + claim, + prepared.result, + commit=prepared.commit, + ) + except asyncio.CancelledError: + raise + except StaleWorkClaimError: + return await self._finish_cancel_if_owned(claim) + except WorkExecutionError as error: + return await self._record_failure(claim, error.failure) + except Exception: + return await self._record_failure( + claim, + WorkFailure(category="internal", code="unhandled_handler_error", retryable=True), + ) + else: + await self._observe_success(completed) + return "succeeded" + finally: + heartbeat_stop.set() + await asyncio.gather(heartbeat, return_exceptions=True) + + async def _observe_success(self, work: StoredWork) -> None: + if self._succeeded_observer is None: + return + try: + await self._succeeded_observer(work) + except asyncio.CancelledError: + raise + except Exception as error: + log_safely( + logger, + logging.ERROR, + "Work success observer failed", + extra={ + "event": "work.success_observer_failed", + "work_kind": work.kind, + "error_type": type(error).__name__, + }, + ) + + async def _finish_cancel_if_owned(self, claim: WorkClaim) -> str: + """Converge a fenced cancelling claim without waiting for lease expiry.""" + + try: + async with self._database.transaction() as connection: + work = await self._repository.fail( + connection, + claim, + WorkFailure(category="cancellation", code="cancel_requested", retryable=False), + retry_delay_seconds=0, + ) + except StaleWorkClaimError: + return "stale" + return work.status.value + + async def _heartbeat(self, claim: WorkClaim, stop: asyncio.Event) -> None: + while not stop.is_set(): + try: + await asyncio.wait_for(stop.wait(), timeout=self._config.heartbeat_seconds) + except TimeoutError: + async with self._database.transaction() as connection: + await self._repository.heartbeat( + connection, + claim, + lease_seconds=self._config.lease_seconds, + ) + + async def _bind_attempt_trace(self, claim: WorkClaim, context: RuntimeTraceContext) -> bool: + try: + async with self._database.transaction() as connection: + await self._repository.record_attempt_trace( + connection, + claim, + trace_id=context.trace_id, + span_id=context.span_id, + ) + except StaleWorkClaimError: + return False + return True + + async def _record_failure(self, claim: WorkClaim, failure: WorkFailure) -> str: + ceiling = min( + self._config.retry_max_seconds, + self._config.retry_base_seconds * (2 ** max(claim.generation_attempt_no - 1, 0)), + ) + delay = self._random_source(0.0, ceiling) + try: + with self._stage( + "work.retry", + attributes={ + "powercontext.work.kind": claim.kind, + "powercontext.work.error_category": failure.category, + "powercontext.work.retryable": failure.retryable, + }, + ) as span: + async with self._database.transaction() as connection: + work = await self._repository.fail( + connection, + claim, + failure, + retry_delay_seconds=max(0, round(delay)), + ) + if span is not None: + span.set_outcome(work.status.value) + return work.status.value + except StaleWorkClaimError: + return "stale" + + def _stage( + self, + name: str, + *, + attributes: dict[str, str | bool | int | float], + ) -> AbstractContextManager[RuntimeSpan | None]: + if self._tracing is None: + return nullcontext(None) + return self._tracing.stage(name, attributes=attributes) + + def _background( + self, + name: str, + *, + operation: str, + attributes: dict[str, str | bool | int | float], + links: tuple[RuntimeTraceContext, ...] = (), + ) -> AbstractContextManager[RuntimeSpan | None]: + if self._tracing is None: + return nullcontext(None) + return self._tracing.background(name, operation=operation, attributes=attributes, links=links) + + +def _handler_map(handlers: Iterable[WorkHandler]) -> dict[str, WorkHandler]: + registered: dict[str, WorkHandler] = {} + for handler in handlers: + if not handler.kind.strip() or handler.kind != handler.kind.strip(): + raise ValueError(_INVALID_HANDLER_KIND) + if not handler.supported_versions or any(version < 1 for version in handler.supported_versions): + raise ValueError(_INVALID_HANDLER_VERSIONS) + if handler.kind in registered: + message = f"duplicate handler kind: {handler.kind}" + raise ValueError(message) + registered[handler.kind] = handler + if not registered: + raise ValueError(_EMPTY_HANDLERS) + return registered + + +__all__ = [ + "DurableWorker", + "PreparedWork", + "WorkExecutionError", + "WorkHandler", +] diff --git a/src/powercontext/client/__init__.py b/src/powercontext/client/__init__.py index 0faaa4d411..4a2b1538bf 100644 --- a/src/powercontext/client/__init__.py +++ b/src/powercontext/client/__init__.py @@ -19,6 +19,8 @@ ClientError, ForbiddenResponseError, InvalidResponseError, + OperationFailedError, + OperationPendingError, ServerResponseError, TransportError, UnauthorizedResponseError, @@ -43,6 +45,8 @@ "ClientError", "ForbiddenResponseError", "InvalidResponseError", + "OperationFailedError", + "OperationPendingError", "PowerContextClient", "ReceiverSyncResult", "RemoteConnectorWorker", diff --git a/src/powercontext/client/errors.py b/src/powercontext/client/errors.py index c261735f3a..9a9216b718 100644 --- a/src/powercontext/client/errors.py +++ b/src/powercontext/client/errors.py @@ -42,6 +42,24 @@ def __init__(self, path: str, *, request_id: str | None) -> None: super().__init__(f"response from {path} violated the API schema") +class OperationPendingError(ClientError): + """Raised when a durable operation outlives the Client deadline.""" + + def __init__(self, operation_id: str) -> None: + self.operation_id = operation_id + super().__init__(f"operation {operation_id} is still pending") + + +class OperationFailedError(ClientError): + """Raised when a durable operation reaches failed or cancelled.""" + + def __init__(self, operation_id: str, *, code: str | None) -> None: + self.operation_id = operation_id + self.code = code + suffix = "" if code is None else f" ({code})" + super().__init__(f"operation {operation_id} did not succeed{suffix}") + + class ServerResponseError(ClientError): """Raised when the Server returns a non-success status.""" @@ -103,6 +121,8 @@ def server_response_error( "ClientError", "ForbiddenResponseError", "InvalidResponseError", + "OperationFailedError", + "OperationPendingError", "ServerResponseError", "TransportError", "UnauthorizedResponseError", diff --git a/src/powercontext/server/metrics.py b/src/powercontext/server/metrics.py index cedff16918..b985949797 100644 --- a/src/powercontext/server/metrics.py +++ b/src/powercontext/server/metrics.py @@ -19,7 +19,7 @@ import asyncio import math import re -from collections.abc import Iterator, Mapping +from collections.abc import Iterator, Mapping, Sequence from contextlib import suppress from threading import RLock from time import perf_counter, time @@ -41,6 +41,7 @@ from starlette.types import ASGIApp, Message, Receive, Scope, Send from typing_extensions import override +from powercontext.builtin.persistence.work import WorkQueueStatistic from powercontext.builtin.runtime.processing_contracts import ArtifactProcessingModelUsageDelta from powercontext.limits import MAX_ARTIFACT_FAMILY_LENGTH from powercontext.server.context import is_internal_bridge @@ -292,6 +293,61 @@ def __init__(self, *, role: str = "server") -> None: "Failed passive Runtime snapshot refreshes.", registry=self.registry, ) + self.work_enqueues = Counter( + "powercontext_work_enqueues_total", + "Durable logical work enqueue decisions.", + ("kind", "outcome"), + registry=self.registry, + ) + self.work_claim_latency = Histogram( + "powercontext_work_claim_latency_seconds", + "Time from durable work creation to a Worker claim.", + ("kind",), + registry=self.registry, + ) + self.work_attempts = Counter( + "powercontext_work_attempts_total", + "Completed durable work attempts.", + ("kind", "outcome", "error_category"), + registry=self.registry, + ) + self.work_attempt_duration = Histogram( + "powercontext_work_attempt_duration_seconds", + "Durable work attempt execution duration.", + ("kind", "outcome"), + registry=self.registry, + ) + self.work_lease_expirations = Counter( + "powercontext_work_lease_expirations_total", + "Worker leases recovered after database-time expiry.", + ("kind", "outcome"), + registry=self.registry, + ) + self.scheduler_leadership_changes = Counter( + "powercontext_scheduler_leadership_changes_total", + "Scheduler leadership transitions observed by this process.", + ("outcome",), + registry=self.registry, + ) + self.work_queue_depth = Gauge( + "powercontext_work_queue_depth", + "Durable non-terminal work grouped by bounded kind and state.", + ("kind", "status"), + registry=self.registry, + ) + self.work_queue_oldest_age = Gauge( + "powercontext_work_queue_oldest_age_seconds", + "Age of the oldest durable work item in each bounded queue group.", + ("kind", "status"), + registry=self.registry, + ) + self.runtime_role_members = Gauge( + "powercontext_runtime_role_members", + "Compatible live runtime members grouped by role.", + ("role",), + registry=self.registry, + ) + self._work_queue_labels: set[tuple[str, str]] = set() self._processing = _ProcessingMetricsCollector() self.registry.register(self) self.set_runtime_scopes(0, 0) @@ -413,6 +469,48 @@ def refresh_runtime(self, runtime: BuiltinRuntime | None) -> None: self._processing = processing self.operational.publish(operational) + def observe_work_enqueue(self, kind: str, *, created: bool) -> None: + self.work_enqueues.labels(kind=kind, outcome="created" if created else "joined").inc() + + def observe_work_claim(self, kind: str, *, latency_seconds: float) -> None: + self.work_claim_latency.labels(kind=kind).observe(max(0.0, latency_seconds)) + + def observe_work_attempt( + self, + kind: str, + *, + outcome: str, + error_category: str, + duration_seconds: float, + ) -> None: + self.work_attempts.labels( + kind=kind, + outcome=outcome, + error_category=error_category, + ).inc() + self.work_attempt_duration.labels(kind=kind, outcome=outcome).observe(max(0.0, duration_seconds)) + + def observe_work_lease_expiry(self, kind: str, *, outcome: str) -> None: + self.work_lease_expirations.labels(kind=kind, outcome=outcome).inc() + + def observe_scheduler_leadership(self, *, outcome: str) -> None: + self.scheduler_leadership_changes.labels(outcome=outcome).inc() + + def set_work_queue(self, samples: Sequence[WorkQueueStatistic]) -> None: + current = {(sample.kind, str(sample.status)) for sample in samples} + for kind, status in self._work_queue_labels - current: + self.work_queue_depth.labels(kind=kind, status=status).set(0) + self.work_queue_oldest_age.labels(kind=kind, status=status).set(0) + for sample in samples: + status = str(sample.status) + self.work_queue_depth.labels(kind=sample.kind, status=status).set(sample.depth) + self.work_queue_oldest_age.labels(kind=sample.kind, status=status).set(sample.oldest_age_seconds) + self._work_queue_labels = current + + def set_runtime_members(self, counts: Mapping[str, int]) -> None: + for role in ("all", "api", "scheduler", "worker"): + self.runtime_role_members.labels(role=role).set(counts.get(role, 0)) + def collect(self) -> Iterator[Metric]: # Capture the pair before yielding: registry readers in other threads must # never combine processing counts with a different freshness observation. diff --git a/src/powercontext/server/tracing.py b/src/powercontext/server/tracing.py index 5821c35a01..77b649c85d 100644 --- a/src/powercontext/server/tracing.py +++ b/src/powercontext/server/tracing.py @@ -17,7 +17,7 @@ from __future__ import annotations import asyncio -from collections.abc import Iterator, Mapping +from collections.abc import Iterator, Mapping, Sequence from contextlib import contextmanager, suppress from contextvars import ContextVar from importlib import import_module @@ -32,11 +32,22 @@ from opentelemetry.sdk.trace.export import BatchSpanProcessor from opentelemetry.sdk.trace.id_generator import RandomIdGenerator from opentelemetry.sdk.trace.sampling import ALWAYS_OFF, ParentBased -from opentelemetry.trace import Span, SpanKind, Status, StatusCode, Tracer, set_span_in_context +from opentelemetry.trace import ( + Link, + Span, + SpanContext, + SpanKind, + Status, + StatusCode, + TraceFlags, + Tracer, + set_span_in_context, +) from starlette.types import ASGIApp, Message, Receive, Scope, Send from typing_extensions import override from powercontext._logging import error_category +from powercontext.builtin.runtime.protocols import RuntimeTraceContext from powercontext.server.context import bind_request_id, is_internal_bridge, reset_request_id from powercontext.server.settings import TracingConfig from powercontext.server.telemetry import service_resource @@ -122,6 +133,7 @@ def start_span( kind: SpanKind, attributes: dict[str, Any], context: Context | None = None, + links: Sequence[Link] = (), ) -> _ActiveSpan: return _ActiveSpan.start( self.tracer, @@ -129,6 +141,7 @@ def start_span( kind=kind, attributes=attributes, context=context, + links=links, ) @contextmanager @@ -166,6 +179,7 @@ def background( *, operation: str, attributes: Mapping[str, _TraceAttribute], + links: Sequence[RuntimeTraceContext] = (), ) -> Iterator[_ActiveSpan]: """Trace one scheduled activation as an independent trace root.""" @@ -184,6 +198,7 @@ def background( "powercontext.operation.name": operation, "powercontext.operation.unit": "background", }, + links=_otel_links(links), ) try: yield span @@ -244,6 +259,19 @@ def __init__(self, span: Span | None, token: Token[Context] | None) -> None: def outcome(self) -> str | None: return self._outcome + @property + def trace_context(self) -> RuntimeTraceContext | None: + if self.span is None: + return None + with suppress(Exception): + context = self.span.get_span_context() + if context.is_valid: + return RuntimeTraceContext( + trace_id=f"{context.trace_id:032x}", + span_id=f"{context.span_id:016x}", + ) + return None + def set_outcome(self, outcome: str) -> None: self._outcome = outcome @@ -256,10 +284,11 @@ def start( kind: SpanKind, attributes: dict[str, Any], context: Context | None, + links: Sequence[Link] = (), ) -> _ActiveSpan: span: Span | None = None try: - span = tracer.start_span(name, context=context, kind=kind, attributes=attributes) + span = tracer.start_span(name, context=context, kind=kind, attributes=attributes, links=links) token = otel_context.attach(set_span_in_context(span, context)) except Exception: if span is not None: @@ -415,6 +444,23 @@ async def on_request( return result +def _otel_links(contexts: Sequence[RuntimeTraceContext]) -> tuple[Link, ...]: + links: list[Link] = [] + for context in contexts: + try: + span_context = SpanContext( + trace_id=int(context.trace_id, 16), + span_id=int(context.span_id, 16), + is_remote=True, + trace_flags=TraceFlags(TraceFlags.SAMPLED), + ) + except (TypeError, ValueError): + continue + if span_context.is_valid: + links.append(Link(span_context)) + return tuple(links) + + def configure_server_tracing(config: TracingConfig, *, role: str = "server") -> ServerTracing: """Build request context and optional OTLP export from standard OTel configuration.""" diff --git a/tests/builtin/artifacts/profile/test_scheduler.py b/tests/builtin/artifacts/profile/test_scheduler.py index 72b4cc241c..10088514ea 100644 --- a/tests/builtin/artifacts/profile/test_scheduler.py +++ b/tests/builtin/artifacts/profile/test_scheduler.py @@ -14,12 +14,10 @@ import asyncio -from datetime import datetime +from datetime import UTC, datetime from functools import partial -from zoneinfo import ZoneInfo import pytest -from apscheduler.triggers.cron import CronTrigger from pydantic import ValidationError from powercontext.builtin.artifacts.profile.models import PROFILE_SOURCE_WINDOW_BINDING @@ -31,6 +29,7 @@ SpawnArtifactProcessingWorkerLauncher, ) from powercontext.builtin.runtime.composition import open_builtin_contexts +from powercontext.builtin.runtime.cron import CronSchedule from powercontext.builtin.runtime.family_processing import process_family_invocation from powercontext.builtin.scope import ScopeDraft @@ -38,9 +37,9 @@ def test_profile_cron_defaults_and_validation(): config = RuntimeConfig() assert not config.profile_schedule_enabled - trigger = CronTrigger.from_crontab(config.profile_cron, timezone=ZoneInfo(config.profile_timezone)) - now = datetime(2026, 9, 7, 1, 0, tzinfo=ZoneInfo("Asia/Shanghai")) - assert trigger.get_next_fire_time(None, now) == now.replace(hour=2) + schedule = CronSchedule.parse(config.profile_cron, config.profile_timezone) + now = datetime(2026, 9, 6, 17, 0, tzinfo=UTC) + assert schedule.next_after(now) == now.replace(hour=18, tzinfo=None) with pytest.raises((ValidationError, ValueError)): RuntimeConfig(profile_cron="invalid") with pytest.raises((ValidationError, KeyError)): diff --git a/tests/builtin/persistence/test_coordination.py b/tests/builtin/persistence/test_coordination.py new file mode 100644 index 0000000000..24ac7454b6 --- /dev/null +++ b/tests/builtin/persistence/test_coordination.py @@ -0,0 +1,112 @@ +# 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 +from datetime import UTC, datetime + +import pytest +from sqlalchemy import update + +from powercontext.builtin.persistence.coordination import ( + CoordinationRepository, + StaleCoordinatorLeaseError, + StaleScanStateError, +) +from powercontext.builtin.persistence.sqlite import SQLiteConfig, SQLiteProfile +from powercontext.builtin.persistence.tables import COORDINATION_TABLES, SCHEDULER_LEASES_TABLE + + +def test_scheduler_lease_takeover_increments_fence_and_rejects_the_old_owner() -> None: + async def scenario() -> None: + async with SQLiteProfile.open(SQLiteConfig(), tables=COORDINATION_TABLES) as profile: + repository = CoordinationRepository() + async with profile.database.transaction() as connection: + first = await repository.acquire_lease( + connection, + lease_name="scheduler", + owner_id="scheduler-a", + lease_seconds=30, + ) + assert first is not None + renewed = await repository.acquire_lease( + connection, + lease_name="scheduler", + owner_id="scheduler-a", + lease_seconds=30, + ) + assert renewed is not None + assert renewed.fence == first.fence + + async with profile.database.transaction() as connection: + await connection.execute( + update(SCHEDULER_LEASES_TABLE) + .where(SCHEDULER_LEASES_TABLE.c.lease_name == "scheduler") + .values(lease_expires_at=datetime(2000, 1, 1, tzinfo=UTC)) + ) + second = await repository.acquire_lease( + connection, + lease_name="scheduler", + owner_id="scheduler-b", + lease_seconds=30, + ) + + assert second is not None + assert second.fence > first.fence + with pytest.raises(StaleCoordinatorLeaseError): + async with profile.database.transaction() as connection: + await repository.assert_lease(connection, first) + + asyncio.run(scenario()) + + +def test_scheduler_scan_state_uses_versioned_keyset_continuation() -> None: + async def scenario() -> None: + async with SQLiteProfile.open(SQLiteConfig(), tables=COORDINATION_TABLES) as profile: + repository = CoordinationRepository() + async with profile.database.transaction() as connection: + initial = await repository.load_scan(connection, "memory") + assert initial is None + first = await repository.save_scan( + connection, + "memory", + next_run_at=datetime(2030, 1, 1, tzinfo=UTC), + continuation="project:a", + expected_version=None, + ) + assert first.state_version == 1 + assert first.continuation == "project:a" + + async with profile.database.transaction() as connection: + second = await repository.save_scan( + connection, + "memory", + next_run_at=datetime(2030, 1, 2, tzinfo=UTC), + continuation=None, + expected_version=first.state_version, + ) + assert second.state_version == 2 + + with pytest.raises(StaleScanStateError): + async with profile.database.transaction() as connection: + await repository.save_scan( + connection, + "memory", + next_run_at=datetime(2030, 1, 3, tzinfo=UTC), + continuation=None, + expected_version=first.state_version, + ) + + asyncio.run(scenario()) diff --git a/tests/builtin/persistence/test_database.py b/tests/builtin/persistence/test_database.py new file mode 100644 index 0000000000..784d125f10 --- /dev/null +++ b/tests/builtin/persistence/test_database.py @@ -0,0 +1,47 @@ +# 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 +from datetime import UTC, datetime +from types import SimpleNamespace +from typing import cast + +from sqlalchemy.ext.asyncio import AsyncConnection + +from powercontext.builtin.persistence.database import database_now + + +def test_mysql_coordination_time_is_queried_in_utc() -> None: + statements: list[str] = [] + expected = datetime(2026, 9, 4, 12, 0, tzinfo=UTC).replace(tzinfo=None) + + class Result: + @staticmethod + def scalar_one() -> datetime: + return expected + + class Connection: + dialect = SimpleNamespace(name="mysql") + + @staticmethod + async def exec_driver_sql(statement: str) -> Result: + statements.append(statement) + return Result() + + value = asyncio.run(database_now(cast(AsyncConnection, Connection()))) + + assert value == expected + assert statements == ["SELECT UTC_TIMESTAMP(6)"] diff --git a/tests/builtin/persistence/test_full_server_migrations.py b/tests/builtin/persistence/test_full_server_migrations.py index 8a764c7b55..fcdb7c7529 100644 --- a/tests/builtin/persistence/test_full_server_migrations.py +++ b/tests/builtin/persistence/test_full_server_migrations.py @@ -73,8 +73,8 @@ def dump(path): def test_complete_upgrade_preserves_history_backup_and_repeated_apply(source): before = dump(source.database) plan = source.plan() - assert plan.source_revision == "s1300" and plan.target_revision == "v1_4_1_004" - assert plan.revisions == ("s1400", "s1401", "s1402", "v1_4_1_004") + assert plan.source_revision == "s1300" and plan.target_revision == "v1_4_1_005" + assert plan.revisions == ("s1400", "s1401", "s1402", "v1_4_1_004", "v1_4_1_005") assert dump(source.database) == before result = apply(source) assert result.readiness_scope == "server" and result.backup_state == "completed" diff --git a/tests/builtin/persistence/test_mysql_migration_runner.py b/tests/builtin/persistence/test_mysql_migration_runner.py index 61cb536104..e66ca5ec1a 100644 --- a/tests/builtin/persistence/test_mysql_migration_runner.py +++ b/tests/builtin/persistence/test_mysql_migration_runner.py @@ -561,7 +561,7 @@ def test_oceanbase_server_upgrade_with_separate_code_cache_preserves_history( ) assert runner.plan(backup_policy="manual").source_revision == "s1300" result = _apply(runner, policy="manual") - assert result.readiness_scope == "server" and result.revision == "v1_4_1_004" + assert result.readiness_scope == "server" and result.revision == "v1_4_1_005" assert runner.verify().state == "ready" prepare_server_storage(settings) assert not _apply(runner, policy="manual").changed @@ -692,7 +692,7 @@ def inspect_history(connection, identity, guard): ).scalar_one(), ) - assert backend.connections().run(inspect_history) == ("v1_4_1_004", "dedicated", b'{"history":"unchanged"}') + assert backend.connections().run(inspect_history) == ("v1_4_1_005", "dedicated", b'{"history":"unchanged"}') (main_path,) = backend.evidence_directory.glob("*/maintenance.json") evidence = json.loads(main_path.read_text()) @@ -702,7 +702,7 @@ def inspect_history(connection, identity, guard): for inspect in (runner.plan, runner.verify, runner.apply): with pytest.raises(MigrationError, match="checksum_conflict"): inspect() - assert backend.connections().run(inspect_history) == ("v1_4_1_004", "dedicated", b'{"history":"unchanged"}') + assert backend.connections().run(inspect_history) == ("v1_4_1_005", "dedicated", b'{"history":"unchanged"}') assert { path: path.read_bytes() for path in backend.evidence_directory.rglob("maintenance.json") } == original_evidence @@ -769,7 +769,7 @@ def inspect_history(connection, identity, guard): ).scalar_one(), ) - assert backend.connections().run(inspect_history) == ("v1_4_1_004", b'{"history":"unchanged"}') + assert backend.connections().run(inspect_history) == ("v1_4_1_005", b'{"history":"unchanged"}') def test_mysql_registered_projection_repair_preserves_authority_and_revision(backend: _BackendTarget) -> None: @@ -837,7 +837,7 @@ def inspect_content(connection, identity, guard): ).scalar_one(), ) - assert backend.connections().run(inspect_content) == ("v1_4_1_004", content, "Preserve immutable nebula evidence.") + assert backend.connections().run(inspect_content) == ("v1_4_1_005", content, "Preserve immutable nebula evidence.") @pytest.mark.parametrize("origin", ("empty", "released")) @@ -992,7 +992,7 @@ def test_mysql_full_server_interruption_preserves_history_and_original_maintenan _load_full_server_baseline(backend) runner = _full_server_runner(backend) original = runner.plan(backup_policy="manual") - assert original.source_revision == "s1300" and original.target_revision == "v1_4_1_004" + assert original.source_revision == "s1300" and original.target_revision == "v1_4_1_005" _interrupt_after_ddl(runner, interruption) journals = list(backend.evidence_directory.rglob("maintenance.json")) assert len(journals) == 1 @@ -1014,7 +1014,7 @@ def test_mysql_full_server_interruption_preserves_history_and_original_maintenan result = _apply(resumed, policy="manual") - assert result.readiness_scope == "server" and result.revision == "v1_4_1_004" + assert result.readiness_scope == "server" and result.revision == "v1_4_1_005" assert result.backup_state == "user_confirmed" assert result.backup_ref == interrupted["manual_ref"] assert resumed.verify().state == "ready" @@ -1094,7 +1094,7 @@ def client_active(connection: Connection | None, _identity: BackendIdentity, _gu # interruption. An arbitrary remote failure cannot be acknowledged blindly. resumed = _full_server_runner(backend, acknowledge_previous_run=token) result = _apply(resumed, policy="manual") - assert result.state == "ready" and result.readiness_scope == "server" and result.revision == "v1_4_1_004" + assert result.state == "ready" and result.readiness_scope == "server" and result.revision == "v1_4_1_005" assert result.backup_state == "user_confirmed" and result.backup_ref == interrupted["manual_ref"] assert resumed.verify().state == "ready" completed = json.loads(journals[0].read_text(encoding="utf-8")) diff --git a/tests/builtin/persistence/test_mysql_schema.py b/tests/builtin/persistence/test_mysql_schema.py index 5c0c5e7a79..32fc45aba4 100644 --- a/tests/builtin/persistence/test_mysql_schema.py +++ b/tests/builtin/persistence/test_mysql_schema.py @@ -150,6 +150,12 @@ def _assert_restore_layers_are_parent_first( def test_documented_obloader_restore_layers_are_parent_first() -> None: + transient_tables = { + "pc_rate_limit_windows", + "pc_runtime_members", + "pc_scheduler_leases", + "pc_scheduler_scans", + } restore_guides = ( Path("docs/en/docs/operate/troubleshoot.md"), Path("docs/zh/docs/operate/troubleshoot.md"), @@ -165,7 +171,9 @@ def test_documented_obloader_restore_layers_are_parent_first() -> None: assert len(set(restore_plans)) == 1 restore_layers = restore_plans[0] - _assert_restore_layers_are_parent_first(restore_layers, BUILTIN_TABLES) + durable_tables = tuple(table for table in BUILTIN_TABLES if table.name not in transient_tables) + assert {table.name for table in BUILTIN_TABLES} - {table.name for table in durable_tables} == transient_tables + _assert_restore_layers_are_parent_first(restore_layers, durable_tables) def test_every_mysql_utf8mb4_key_stays_below_the_innodb_limit() -> None: diff --git a/tests/builtin/persistence/test_owner_recovery_migration.py b/tests/builtin/persistence/test_owner_recovery_migration.py index e91b321acb..a9fe8e0ce0 100644 --- a/tests/builtin/persistence/test_owner_recovery_migration.py +++ b/tests/builtin/persistence/test_owner_recovery_migration.py @@ -28,7 +28,12 @@ from powercontext.builtin.persistence.migrations.mysql_schema import MySQLSchema from powercontext.builtin.persistence.migrations.sqlite import SQLiteMigrationRunner from tests.builtin.persistence.test_mysql_migration_schema import _inventory, _ReflectedDatabase -from tests.builtin.persistence.test_portable_storage_migration import apply, configuration +from tests.builtin.persistence.test_portable_storage_migration import ( + LEDGER_TABLES, + apply, + configuration, + drop_revision, +) @pytest.mark.parametrize("vectors", [False, True]) @@ -39,13 +44,8 @@ def test_registered_owner_recovery_upgrade_preserves_history_and_revision_checks shutil.copytree(current.directory, directory, ignore=shutil.ignore_patterns("__pycache__")) manifest_path = directory / "manifest.json" manifest = json.loads(manifest_path.read_text(encoding="utf-8")) - resources = manifest["revision_resources"].pop("v1_4_1_004") - for resource in (*resources, "versions/v1_4_1_004_owner_recovery.py", "mysql/versions/v1_4_1_004.py"): - (directory / resource).unlink() - del manifest["sqlite_fingerprints"]["v1_4_1_004"] - del manifest["domain_readiness"]["v1_4_1_004"] - manifest["mysql_state_layouts"].remove("schemas/mysql-v1_4_1_004.json") - manifest["affected_objects"].remove("pc_access_owner_recovery") + drop_revision(directory, manifest, "v1_4_1_005", tables=LEDGER_TABLES) + drop_revision(directory, manifest, "v1_4_1_004", tables=("pc_access_owner_recovery",)) manifest_path.write_text(json.dumps(manifest), encoding="utf-8") previous = MigrationBundle(directory, embedding_profile=embedding) database = tmp_path / "managed.db" @@ -59,8 +59,8 @@ def test_registered_owner_recovery_upgrade_preserves_history_and_revision_checks original_evidence = json.loads(old.evidence_path.read_text(encoding="utf-8")) upgraded = SQLiteMigrationRunner(database, current, configuration=configuration(embedding)) plan = upgraded.plan() - assert plan.source_revision == "s1402" and plan.revisions == ("v1_4_1_004",) - assert apply(upgraded).revision == "v1_4_1_004" + assert plan.source_revision == "s1402" and plan.revisions == ("v1_4_1_004", "v1_4_1_005") + assert apply(upgraded).revision == "v1_4_1_005" assert upgraded.verify().state == "ready" assert not apply(upgraded).changed evidence = json.loads(upgraded.evidence_path.read_text(encoding="utf-8")) diff --git a/tests/builtin/persistence/test_portable_storage_migration.py b/tests/builtin/persistence/test_portable_storage_migration.py index 16724dd2b2..ed7fd511aa 100644 --- a/tests/builtin/persistence/test_portable_storage_migration.py +++ b/tests/builtin/persistence/test_portable_storage_migration.py @@ -29,27 +29,42 @@ from powercontext.builtin.runtime.config import BuiltinConfig from powercontext.builtin.runtime.processing_registry import canonical_processing_manifest +LEDGER_TABLES = ( + "pc_work_lanes", + "pc_work_items", + "pc_work_keys", + "pc_work_attempts", + "pc_scheduler_leases", + "pc_scheduler_scans", + "pc_runtime_members", + "pc_rate_limit_windows", +) + + +def drop_revision(directory, manifest, revision, *, tables=()): + """Freeze the bundle as if a later revision had never been registered.""" + + for resource in manifest["revision_resources"].pop(revision): + (directory / resource).unlink() + for path in (directory / "versions").glob(f"{revision}_*.py"): + path.unlink() + (directory / "mysql/versions" / f"{revision}.py").unlink() + del manifest["sqlite_fingerprints"][revision] + del manifest["domain_readiness"][revision] + manifest["domain_steps"] = [step for step in manifest["domain_steps"] if step["revision"] != revision] + manifest["mysql_state_layouts"].remove(f"schemas/mysql-{revision}.json") + for table in tables: + manifest["affected_objects"].remove(table) + def previous_bundle(directory, embedding): current = server_bundle(embedding_profile=embedding) shutil.copytree(current.directory, directory, ignore=shutil.ignore_patterns("__pycache__")) manifest_path = directory / "manifest.json" manifest = json.loads(manifest_path.read_text()) - owner_resources = manifest["revision_resources"].pop("v1_4_1_004") - for resource in (*owner_resources, "versions/v1_4_1_004_owner_recovery.py", "mysql/versions/v1_4_1_004.py"): - (directory / resource).unlink() - del manifest["sqlite_fingerprints"]["v1_4_1_004"] - del manifest["domain_readiness"]["v1_4_1_004"] - manifest["mysql_state_layouts"].remove("schemas/mysql-v1_4_1_004.json") - manifest["affected_objects"].remove("pc_access_owner_recovery") - removed = manifest["revision_resources"].pop("s1402") - for resource in (*removed, "versions/s1402_portable_storage.py", "mysql/versions/s1402.py", "portable_storage.py"): - (directory / resource).unlink() - del manifest["sqlite_fingerprints"]["s1402"] - del manifest["domain_readiness"]["s1402"] - manifest["domain_steps"] = [step for step in manifest["domain_steps"] if step["revision"] != "s1402"] - manifest["mysql_state_layouts"].remove("schemas/mysql-s1402.json") - manifest["affected_objects"].remove("pc_portable_restore_receipts") + drop_revision(directory, manifest, "v1_4_1_005", tables=LEDGER_TABLES) + drop_revision(directory, manifest, "v1_4_1_004", tables=("pc_access_owner_recovery",)) + drop_revision(directory, manifest, "s1402", tables=("pc_portable_restore_receipts",)) manifest_path.write_text(json.dumps(manifest), encoding="utf-8") return MigrationBundle(directory, embedding_profile=embedding) @@ -93,8 +108,8 @@ def test_registered_s1401_upgrade_preserves_completed_evidence_and_matches_fresh upgraded = SQLiteMigrationRunner(database, bundle, configuration=config) plan = upgraded.plan() assert plan.source_revision == "s1401" - assert plan.revisions == ("s1402", "v1_4_1_004") - assert apply(upgraded).revision == "v1_4_1_004" + assert plan.revisions == ("s1402", "v1_4_1_004", "v1_4_1_005") + assert apply(upgraded).revision == "v1_4_1_005" assert upgraded.verify().state == "ready" assert not apply(upgraded).changed fresh = SQLiteMigrationRunner(tmp_path / "fresh.db", bundle, configuration=config) diff --git a/tests/builtin/persistence/test_rate_limit.py b/tests/builtin/persistence/test_rate_limit.py new file mode 100644 index 0000000000..8df8614eec --- /dev/null +++ b/tests/builtin/persistence/test_rate_limit.py @@ -0,0 +1,45 @@ +# 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 + +from powercontext.builtin.persistence.rate_limit import RateLimitRepository +from powercontext.builtin.persistence.sqlite import SQLiteConfig, SQLiteProfile +from powercontext.builtin.persistence.tables import RATE_LIMIT_WINDOWS_TABLE + + +def test_fixed_window_counter_is_shared_by_repository_callers() -> None: + async def scenario() -> None: + async with SQLiteProfile.open(SQLiteConfig(), tables=(RATE_LIMIT_WINDOWS_TABLE,)) as profile: + repository = RateLimitRepository() + decisions = [] + for _ in range(3): + async with profile.database.transaction() as connection: + decisions.append( + await repository.consume( + connection, + principal_key="a" * 64, + policy_id="api.default", + limit=2, + window_seconds=60, + ) + ) + + assert [decision.allowed for decision in decisions] == [True, True, False] + assert [decision.remaining for decision in decisions] == [1, 0, 0] + assert decisions[-1].retry_after_seconds > 0 + + asyncio.run(scenario()) diff --git a/tests/builtin/persistence/test_release140_server_migrations.py b/tests/builtin/persistence/test_release140_server_migrations.py index fcaeef4336..9d3743b7ad 100644 --- a/tests/builtin/persistence/test_release140_server_migrations.py +++ b/tests/builtin/persistence/test_release140_server_migrations.py @@ -134,7 +134,7 @@ def test_registered_sources_choose_distinct_lineages(tmp_path, release, expected _settings, _embedding, runner = _source(tmp_path, release=release) plan = runner.plan() assert plan.source_revision == expected - assert plan.revisions == ("s1400", "s1401", "s1402", "v1_4_1_004") + assert plan.revisions == ("s1400", "s1401", "s1402", "v1_4_1_004", "v1_4_1_005") @pytest.mark.parametrize(("vectors", "code"), [(False, False), (True, False), (False, True), (True, True)]) diff --git a/tests/builtin/persistence/test_work.py b/tests/builtin/persistence/test_work.py new file mode 100644 index 0000000000..833d8e9a9a --- /dev/null +++ b/tests/builtin/persistence/test_work.py @@ -0,0 +1,418 @@ +# 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 +from datetime import UTC, datetime +from pathlib import Path + +import pytest +from sqlalchemy import func, select, update + +from powercontext.builtin.persistence.errors import RepositoryNotFoundError +from powercontext.builtin.persistence.sqlite import SQLiteConfig, SQLiteProfile +from powercontext.builtin.persistence.tables import WORK_ATTEMPTS_TABLE, WORK_ITEMS_TABLE, WORK_TABLES +from powercontext.builtin.persistence.work import ( + StaleWorkClaimError, + WorkFailure, + WorkRepository, + WorkResult, + WorkSpec, + WorkStateConflictError, + WorkStatus, +) + + +def _spec(*, logical_key: str = "a" * 64, lane_key: str = "b" * 64) -> WorkSpec: + return WorkSpec( + kind="powercontext.memory.source-window.v1", + payload_version=1, + scope_id="project:test", + lane_key=lane_key, + logical_key=logical_key, + payload={"after": 0, "through": 2}, + ) + + +def test_enqueue_deduplicates_one_logical_window_and_orders_each_lane() -> None: + async def scenario() -> None: + async with SQLiteProfile.open(SQLiteConfig(), tables=WORK_TABLES) as profile: + repository = WorkRepository() + async with profile.database.transaction() as connection: + first = await repository.enqueue(connection, _spec()) + duplicate = await repository.enqueue(connection, _spec()) + second = await repository.enqueue(connection, _spec(logical_key="c" * 64)) + + assert first.created is True + assert duplicate.created is False + assert duplicate.work.work_id == first.work.work_id + assert first.work.lane_sequence == 1 + assert second.work.lane_sequence == 2 + + asyncio.run(scenario()) + + +def test_only_the_lane_head_can_be_claimed_and_completion_advances_it() -> None: + async def scenario() -> None: + async with SQLiteProfile.open(SQLiteConfig(), tables=WORK_TABLES) as profile: + repository = WorkRepository() + async with profile.database.transaction() as connection: + first = await repository.enqueue(connection, _spec()) + second = await repository.enqueue(connection, _spec(logical_key="c" * 64)) + + async with profile.database.transaction() as connection: + claims = await repository.claim( + connection, + worker_id="worker-a", + supported={first.work.kind: frozenset({1})}, + lease_seconds=120, + limit=2, + ) + assert len(claims) == 1 + assert claims[0].work_id == first.work.work_id + + async with profile.database.transaction() as connection: + await repository.complete( + connection, + claims[0], + WorkResult(code="processed", payload={"current_cursor": 2}), + ) + async with profile.database.transaction() as connection: + next_claims = await repository.claim( + connection, + worker_id="worker-b", + supported={second.work.kind: frozenset({1})}, + lease_seconds=120, + limit=2, + ) + + assert len(next_claims) == 1 + assert next_claims[0].work_id == second.work.work_id + + asyncio.run(scenario()) + + +def test_concurrent_workers_cannot_claim_the_same_work(tmp_path: Path) -> None: + async def scenario() -> None: + config = SQLiteConfig(url=f"sqlite+aiosqlite:///{tmp_path / 'work.db'}") + async with SQLiteProfile.open(config, tables=WORK_TABLES) as first_profile: + async with SQLiteProfile.open(config, tables=WORK_TABLES) as second_profile: + repository = WorkRepository() + async with first_profile.database.transaction() as connection: + enqueued = await repository.enqueue(connection, _spec()) + + async def claim(profile: SQLiteProfile, worker_id: str): + async with profile.database.transaction() as connection: + return await repository.claim( + connection, + worker_id=worker_id, + supported={enqueued.work.kind: frozenset({1})}, + lease_seconds=120, + limit=1, + ) + + results = await asyncio.gather( + claim(first_profile, "worker-a"), + claim(second_profile, "worker-b"), + ) + + assert sum(len(result) for result in results) == 1 + + asyncio.run(scenario()) + + +def test_expired_claim_is_fenced_before_another_attempt_runs() -> None: + async def scenario() -> None: + async with SQLiteProfile.open(SQLiteConfig(), tables=WORK_TABLES) as profile: + repository = WorkRepository() + async with profile.database.transaction() as connection: + enqueued = await repository.enqueue(connection, _spec()) + first = ( + await repository.claim( + connection, + worker_id="worker-a", + supported={enqueued.work.kind: frozenset({1})}, + lease_seconds=120, + limit=1, + ) + )[0] + + async with profile.database.transaction() as connection: + await connection.execute( + update(WORK_ITEMS_TABLE) + .where(WORK_ITEMS_TABLE.c.work_id == first.work_id) + .values(lease_expires_at=datetime(2000, 1, 1, tzinfo=UTC)) + ) + assert ( + await repository.claim( + connection, + worker_id="worker-b", + supported={enqueued.work.kind: frozenset({1})}, + lease_seconds=120, + limit=1, + expired_retry_delay_seconds=0, + ) + ) == () + + async with profile.database.transaction() as connection: + second = ( + await repository.claim( + connection, + worker_id="worker-b", + supported={enqueued.work.kind: frozenset({1})}, + lease_seconds=120, + limit=1, + ) + )[0] + + assert second.fence > first.fence + with pytest.raises(StaleWorkClaimError): + async with profile.database.transaction() as connection: + await repository.complete(connection, first, WorkResult(code="late", payload={})) + + asyncio.run(scenario()) + + +def test_retry_budget_blocks_the_lane_until_an_operator_recovers_or_cancels() -> None: + async def scenario() -> None: + async with SQLiteProfile.open(SQLiteConfig(), tables=WORK_TABLES) as profile: + repository = WorkRepository() + async with profile.database.transaction() as connection: + enqueued = await repository.enqueue(connection, _spec().model_copy(update={"max_attempts": 1})) + claim = ( + await repository.claim( + connection, + worker_id="worker-a", + supported={enqueued.work.kind: frozenset({1})}, + lease_seconds=120, + limit=1, + ) + )[0] + failed = await repository.fail( + connection, + claim, + WorkFailure(category="provider", code="temporarily_unavailable", retryable=True), + retry_delay_seconds=0, + ) + + assert failed.status is WorkStatus.FAILED + async with profile.database.transaction() as connection: + duplicate = await repository.enqueue(connection, _spec()) + assert duplicate.created is False + assert duplicate.work.status is WorkStatus.FAILED + + async with profile.database.transaction() as connection: + recovered = await repository.retry( + connection, + failed.work_id, + expected_version=failed.state_version, + ) + assert recovered.status is WorkStatus.QUEUED + assert recovered.recovery_generation == 1 + + with pytest.raises(WorkStateConflictError): + async with profile.database.transaction() as connection: + await repository.cancel( + connection, + recovered.work_id, + expected_version=failed.state_version, + ) + + asyncio.run(scenario()) + + +def test_running_cancel_linearizes_before_commit_and_recovers_after_expiry() -> None: + async def scenario() -> None: + async with SQLiteProfile.open(SQLiteConfig(), tables=WORK_TABLES) as profile: + repository = WorkRepository() + async with profile.database.transaction() as connection: + enqueued = await repository.enqueue(connection, _spec()) + claim = ( + await repository.claim( + connection, + worker_id="worker-a", + supported={enqueued.work.kind: frozenset({1})}, + lease_seconds=120, + limit=1, + ) + )[0] + running = await repository.get(connection, claim.work_id) + + async with profile.database.transaction() as connection: + cancelling = await repository.cancel( + connection, + claim.work_id, + expected_version=running.state_version, + ) + assert cancelling.status is WorkStatus.CANCELLING + with pytest.raises(WorkStateConflictError): + async with profile.database.transaction() as connection: + await repository.cancel( + connection, + claim.work_id, + expected_version=cancelling.state_version, + ) + + committed = False + + async def domain_commit(_connection): + nonlocal committed + committed = True + return WorkResult(code="late", payload={}) + + with pytest.raises(StaleWorkClaimError): + async with profile.database.transaction() as connection: + await repository.complete(connection, claim, None, commit=domain_commit) + assert committed is False + + async with profile.database.transaction() as connection: + await connection.execute( + update(WORK_ITEMS_TABLE) + .where(WORK_ITEMS_TABLE.c.work_id == claim.work_id) + .values(lease_expires_at=datetime(2000, 1, 1, tzinfo=UTC)) + ) + assert ( + await repository.claim( + connection, + worker_id="worker-b", + supported={enqueued.work.kind: frozenset({1})}, + lease_seconds=120, + limit=1, + ) + ) == () + cancelled = await repository.get(connection, claim.work_id) + assert cancelled.status is WorkStatus.CANCELLED + + asyncio.run(scenario()) + + +def test_retry_claim_links_to_the_previous_attempt_trace_context() -> None: + async def scenario() -> None: + async with SQLiteProfile.open(SQLiteConfig(), tables=WORK_TABLES) as profile: + repository = WorkRepository() + async with profile.database.transaction() as connection: + enqueued = await repository.enqueue(connection, _spec()) + first = ( + await repository.claim( + connection, + worker_id="worker-a", + supported={enqueued.work.kind: frozenset({1})}, + lease_seconds=120, + limit=1, + ) + )[0] + await repository.record_attempt_trace( + connection, + first, + trace_id="1" * 32, + span_id="2" * 16, + ) + await repository.fail( + connection, + first, + WorkFailure(category="provider", code="unavailable", retryable=True), + retry_delay_seconds=0, + ) + + async with profile.database.transaction() as connection: + second = ( + await repository.claim( + connection, + worker_id="worker-b", + supported={enqueued.work.kind: frozenset({1})}, + lease_seconds=120, + limit=1, + ) + )[0] + + assert second.previous_trace_id == "1" * 32 + assert second.previous_span_id == "2" * 16 + + asyncio.run(scenario()) + + +def test_retention_purges_only_old_success_and_cancelled_history() -> None: + async def scenario() -> None: + async with SQLiteProfile.open(SQLiteConfig(), tables=WORK_TABLES) as profile: + repository = WorkRepository() + async with profile.database.transaction() as connection: + succeeded = await repository.enqueue(connection, _spec()) + success_claim = ( + await repository.claim( + connection, + worker_id="worker-a", + supported={succeeded.work.kind: frozenset({1})}, + lease_seconds=120, + limit=1, + ) + )[0] + await repository.complete(connection, success_claim, WorkResult(code="done", payload={})) + + cancelled = await repository.enqueue( + connection, + _spec(logical_key="c" * 64, lane_key="d" * 64), + ) + await repository.cancel( + connection, + cancelled.work.work_id, + expected_version=cancelled.work.state_version, + ) + + failed = await repository.enqueue( + connection, + _spec(logical_key="e" * 64, lane_key="f" * 64).model_copy(update={"max_attempts": 1}), + ) + failed_claim = ( + await repository.claim( + connection, + worker_id="worker-a", + supported={failed.work.kind: frozenset({1})}, + lease_seconds=120, + limit=1, + ) + )[0] + await repository.fail( + connection, + failed_claim, + WorkFailure(category="provider", code="secret_free_code", retryable=False), + retry_delay_seconds=0, + ) + + old = datetime(2000, 1, 1, tzinfo=UTC) + await connection.execute( + update(WORK_ITEMS_TABLE) + .where(WORK_ITEMS_TABLE.c.work_id.in_((succeeded.work.work_id, cancelled.work.work_id))) + .values(completed_at=old) + ) + assert ( + await repository.purge_terminal( + connection, + completed_before=datetime(2020, 1, 1, tzinfo=UTC), + limit=500, + ) + == 2 + ) + attempts = await connection.scalar(select(func.count()).select_from(WORK_ATTEMPTS_TABLE)) + + assert attempts == 1 + async with profile.database.transaction() as connection: + with pytest.raises(RepositoryNotFoundError): + await repository.get(connection, succeeded.work.work_id) + with pytest.raises(RepositoryNotFoundError): + await repository.get(connection, cancelled.work.work_id) + blocked = await repository.get(connection, failed.work.work_id) + assert blocked.status is WorkStatus.FAILED + + asyncio.run(scenario()) diff --git a/tests/builtin/runtime/test_deployment_config.py b/tests/builtin/runtime/test_deployment_config.py new file mode 100644 index 0000000000..672718ad5d --- /dev/null +++ b/tests/builtin/runtime/test_deployment_config.py @@ -0,0 +1,92 @@ +# 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 pytest +from pydantic import SecretStr, ValidationError + +from powercontext.builtin.persistence.oceanbase import OceanBaseConfig +from powercontext.builtin.persistence.sqlite import SQLiteConfig +from powercontext.builtin.runtime.config import ( + BuiltinConfig, + CoordinationConfig, + DeploymentConfig, + InferenceConfig, + RuntimeConfig, + WorkerConfig, +) +from powercontext.builtin.runtime.processing_registry import processing_capabilities + +_OCEANBASE = OceanBaseConfig(url=SecretStr("mysql+aoceanbase://root@localhost:2881/powercontext?charset=utf8mb4")) + + +def test_single_node_all_is_the_backwards_compatible_default() -> None: + config = BuiltinConfig(database=SQLiteConfig()) + + assert config.deployment.mode == "single_node" + assert config.deployment.role == "all" + assert config.worker.concurrency == 4 + assert config.worker.lease_seconds == 120 + + +def test_distributed_requires_oceanbase_and_one_process_role() -> None: + with pytest.raises(ValidationError, match="distributed deployment requires OceanBase"): + BuiltinConfig( + database=SQLiteConfig(), + deployment=DeploymentConfig(mode="distributed", role="api", id="api-a"), + ) + + with pytest.raises(ValidationError, match="distributed deployment role must be"): + BuiltinConfig( + database=_OCEANBASE, + deployment=DeploymentConfig(mode="distributed", role="all", id="all-a"), + ) + + +def test_distributed_uses_the_work_ledger_instead_of_process_local_supervisors() -> None: + deployment = DeploymentConfig(mode="distributed", role="worker", id="worker-a") + config = BuiltinConfig( + database=_OCEANBASE, + deployment=deployment, + inference=InferenceConfig(generation_model="test"), + ) + + assert processing_capabilities(config) == () + + with pytest.raises(ValidationError, match="process-local artifact processing role"): + BuiltinConfig( + database=_OCEANBASE, + deployment=deployment, + runtime=RuntimeConfig(artifact_processing_role="background"), + ) + with pytest.raises(ValidationError, match="does not support Topic Memory processing"): + BuiltinConfig( + database=_OCEANBASE, + deployment=deployment, + runtime=RuntimeConfig(topic_memory_schedule_seconds=60), + ) + + +def test_worker_heartbeat_and_shutdown_must_fit_inside_the_lease() -> None: + with pytest.raises(ValidationError, match="heartbeat_seconds must be less than one third"): + WorkerConfig(lease_seconds=120, heartbeat_seconds=40) + + with pytest.raises(ValidationError, match="shutdown_grace_seconds must be less than lease_seconds"): + WorkerConfig(lease_seconds=120, shutdown_grace_seconds=120) + + +def test_scheduler_emits_only_the_supported_payload_version() -> None: + with pytest.raises(ValidationError): + CoordinationConfig.model_validate({"emit_payload_version": 2}) diff --git a/tests/builtin/runtime/test_durable_model_usage.py b/tests/builtin/runtime/test_durable_model_usage.py new file mode 100644 index 0000000000..acb4082887 --- /dev/null +++ b/tests/builtin/runtime/test_durable_model_usage.py @@ -0,0 +1,115 @@ +# 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 +from datetime import UTC, datetime + +import pytest + +from powercontext.builtin.artifacts.experience import ExperienceCandidateInput +from powercontext.builtin.inference import GenerationResult, InferenceUsage +from powercontext.builtin.inference.usage import UsageReportingStructuredGenerator +from powercontext.builtin.persistence.sqlite import SQLiteConfig +from powercontext.builtin.persistence.statistics import StatisticsRepository +from powercontext.builtin.persistence.work import WorkRepository, WorkStatus +from powercontext.builtin.runtime import BuiltinConfig, RuntimeConfig, open_builtin_contexts +from powercontext.builtin.runtime.config import WorkerConfig +from powercontext.builtin.runtime.work_handlers import ExperienceWorkHandler, experience_work_spec +from powercontext.builtin.runtime.worker import DurableWorker +from powercontext.builtin.scope import ScopeDraft +from powercontext.builtin.sources import ContentCapture +from powercontext.builtin.statistics import ModelUsageOperation, ModelUsagePurpose +from powercontext.sources import Source + + +class _Model: + async def generate(self, value: str, /) -> GenerationResult[str]: + return GenerationResult(output=value, usage=InferenceUsage(requests=1, input_tokens=7, output_tokens=3)) + + +class _Pipeline: + def __init__(self, *, fail_after_generation: bool) -> None: + self._fail = fail_after_generation + self._generator = UsageReportingStructuredGenerator(_Model()) + + async def _generate(self) -> None: + async with asyncio.timeout(0.02): + await self._generator.generate("bounded model request") + if self._fail: + raise RuntimeError("generation result rejected") # noqa: TRY003 + + async def incubate(self, sources: tuple[Source, ...], /) -> tuple[ExperienceCandidateInput, ...]: + await self._generate() + return () + + +@pytest.mark.parametrize("fail_after_generation", [False, True]) +def test_durable_usage_survives_slow_accounting_and_failed_generation( + tmp_path, monkeypatch: pytest.MonkeyPatch, fail_after_generation: bool +) -> None: + original_record = StatisticsRepository.record + + async def slow_record(repository, connection, *args): + # This delay exceeds the model deadline and must be owned by the recorder. + await asyncio.sleep(0.05) + await original_record(repository, connection, *args) + + monkeypatch.setattr(StatisticsRepository, "record", slow_record) + + async def scenario() -> None: + config = BuiltinConfig( + database=SQLiteConfig(url=f"sqlite+aiosqlite:///{tmp_path / 'usage.db'}"), + runtime=RuntimeConfig(model_usage_write_timeout_seconds=2, model_usage_flush_timeout_seconds=1), + ) + pipeline = _Pipeline(fail_after_generation=fail_after_generation) + async with open_builtin_contexts(config, experience_pipeline=pipeline) as contexts: + scope = await contexts.scopes.create( + ScopeDraft(title="Usage", summary="Durable usage accounting", idempotency_key="usage") + ) + context = await contexts.get(scope.scope_id) + await context.sources.capture(ContentCapture(source_id="one", content="One durable source.")) + spec = await experience_work_spec(contexts, scope.scope_id, limit=100, max_attempts=1, payload_version=1) + assert spec is not None + repository = WorkRepository() + async with contexts.database.transaction() as connection: + queued = await repository.enqueue(connection, spec) + worker = DurableWorker( + database=contexts.database, + worker_id="usage-worker", + handlers=(ExperienceWorkHandler(contexts),), + config=WorkerConfig(concurrency=1), + ) + assert await worker.run_once() == 1 + day = datetime.now(UTC).date() + # Read persisted rows directly: the completed attempt must drain its + # accepted usage without relying on a statistics read or shutdown. + async with contexts.database.transaction() as connection: + work = await repository.get(connection, queued.work.work_id) + model_usage, _ = await contexts.repositories.statistics.daily_usage_many( + connection, + (scope.scope_id,), + day, + day, + estimator=None, + ) + usage = model_usage[scope.scope_id] + assert work.status is (WorkStatus.FAILED if fail_after_generation else WorkStatus.SUCCEEDED) + assert len(usage) == 1 + assert usage[0].purpose is ModelUsagePurpose.EXPERIENCE_GENERATION + assert usage[0].operation is ModelUsageOperation.GENERATION + assert (usage[0].requests, usage[0].input_tokens, usage[0].output_tokens) == (1, 7, 3) + + asyncio.run(scenario()) diff --git a/tests/builtin/runtime/test_durable_scheduler.py b/tests/builtin/runtime/test_durable_scheduler.py new file mode 100644 index 0000000000..351a7d4fab --- /dev/null +++ b/tests/builtin/runtime/test_durable_scheduler.py @@ -0,0 +1,191 @@ +# 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 +from datetime import UTC, datetime + +import pytest +from sqlalchemy import update +from sqlalchemy.ext.asyncio import AsyncConnection + +from powercontext.builtin.persistence.coordination import ( + CoordinationRepository, + CoordinatorLease, + StaleCoordinatorLeaseError, +) +from powercontext.builtin.persistence.sqlite import SQLiteConfig, SQLiteProfile +from powercontext.builtin.persistence.tables import COORDINATION_TABLES, SCHEDULER_LEASES_TABLE, WORK_TABLES +from powercontext.builtin.persistence.work import WorkRepository, WorkSpec +from powercontext.builtin.runtime.config import CoordinationConfig +from powercontext.builtin.runtime.durable_scheduler import DiscoveryPage, DurableScheduler + + +class _Discoverer: + name = "test-discoverer" + interval_seconds = 60.0 + + def __init__(self) -> None: + self.pages: list[str | None] = [] + + async def page(self, continuation: str | None, limit: int) -> DiscoveryPage: + self.pages.append(continuation) + if continuation is None: + return DiscoveryPage(specs=(_spec(1),), continuation="next") + return DiscoveryPage(specs=(_spec(2),), continuation=None) + + +class _PausingDiscoverer: + name = "pausing-discoverer" + interval_seconds = 60.0 + + def __init__(self) -> None: + self.entered = asyncio.Event() + self.resume = asyncio.Event() + + async def page(self, continuation: str | None, limit: int) -> DiscoveryPage: + del continuation, limit + self.entered.set() + await self.resume.wait() + return DiscoveryPage(specs=(_spec(1),), continuation=None) + + +class _PausingCoordinationRepository(CoordinationRepository): + def __init__(self) -> None: + self.entered = asyncio.Event() + self.resume = asyncio.Event() + + async def acquire_lease( + self, + connection: AsyncConnection, + *, + lease_name: str, + owner_id: str, + lease_seconds: int, + ) -> CoordinatorLease | None: + self.entered.set() + await self.resume.wait() + return await super().acquire_lease( + connection, + lease_name=lease_name, + owner_id=owner_id, + lease_seconds=lease_seconds, + ) + + +def _spec(index: int) -> WorkSpec: + return WorkSpec( + kind="test.handler.v1", + payload_version=1, + scope_id=f"scope:{index}", + lane_key=f"{index + 1:064x}", + logical_key=f"{index + 101:064x}", + payload={}, + ) + + +def test_scheduler_persists_keyset_continuation_and_enqueues_under_its_fence() -> None: + async def scenario() -> None: + tables = WORK_TABLES + COORDINATION_TABLES + async with SQLiteProfile.open(SQLiteConfig(), tables=tables) as profile: + discoverer = _Discoverer() + scheduler = DurableScheduler( + database=profile.database, + scheduler_id="scheduler-a", + discoverers=(discoverer,), + config=CoordinationConfig(), + ) + assert await scheduler.tick() is True + assert await scheduler.tick() is True + + async with profile.database.transaction() as connection: + work = await WorkRepository().list(connection) + assert len(work) == 2 + assert discoverer.pages == [None, "next"] + + await scheduler.stop() + + asyncio.run(scenario()) + + +def test_old_scheduler_cannot_enqueue_after_a_higher_fence_takes_over() -> None: + async def scenario() -> None: + tables = WORK_TABLES + COORDINATION_TABLES + async with SQLiteProfile.open(SQLiteConfig(), tables=tables) as profile: + discoverer = _PausingDiscoverer() + scheduler = DurableScheduler( + database=profile.database, + scheduler_id="scheduler-a", + discoverers=(discoverer,), + config=CoordinationConfig(), + ) + stale_tick = asyncio.create_task(scheduler.tick()) + await asyncio.wait_for(discoverer.entered.wait(), timeout=1) + + coordination = CoordinationRepository() + async with profile.database.transaction() as connection: + await connection.execute( + update(SCHEDULER_LEASES_TABLE) + .where(SCHEDULER_LEASES_TABLE.c.lease_name == "work-discovery") + .values(lease_expires_at=datetime(2000, 1, 1, tzinfo=UTC)) + ) + replacement = await coordination.acquire_lease( + connection, + lease_name="work-discovery", + owner_id="scheduler-b", + lease_seconds=30, + ) + assert replacement is not None + + discoverer.resume.set() + with pytest.raises(StaleCoordinatorLeaseError): + await stale_tick + async with profile.database.transaction() as connection: + assert await WorkRepository().list(connection) == () + + asyncio.run(scenario()) + + +def test_scheduler_stop_releases_lease_acquired_during_shutdown() -> None: + async def scenario() -> None: + tables = WORK_TABLES + COORDINATION_TABLES + async with SQLiteProfile.open(SQLiteConfig(), tables=tables) as profile: + coordination = _PausingCoordinationRepository() + scheduler = DurableScheduler( + database=profile.database, + scheduler_id="scheduler-a", + discoverers=(_Discoverer(),), + config=CoordinationConfig(), + coordination=coordination, + ) + run_task = asyncio.create_task(scheduler.run()) + await asyncio.wait_for(coordination.entered.wait(), timeout=1) + + stop_task = asyncio.create_task(scheduler.stop()) + await asyncio.sleep(0) + coordination.resume.set() + await stop_task + await run_task + + async with profile.database.transaction() as connection: + replacement = await CoordinationRepository().acquire_lease( + connection, + lease_name="work-discovery", + owner_id="scheduler-b", + lease_seconds=30, + ) + assert replacement is not None + + asyncio.run(scenario()) diff --git a/tests/builtin/runtime/test_membership.py b/tests/builtin/runtime/test_membership.py new file mode 100644 index 0000000000..81b256d45d --- /dev/null +++ b/tests/builtin/runtime/test_membership.py @@ -0,0 +1,73 @@ +# 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.builtin.persistence.sqlite import SQLiteConfig, SQLiteProfile +from powercontext.builtin.persistence.tables import COORDINATION_TABLES +from powercontext.builtin.runtime.config import CoordinationConfig, DeploymentConfig +from powercontext.builtin.runtime.membership import DuplicateSingleNodeError, RuntimeMembership + + +def test_single_node_membership_rejects_a_second_live_runtime() -> None: + async def scenario() -> None: + async with SQLiteProfile.open(SQLiteConfig(), tables=COORDINATION_TABLES) as profile: + config = CoordinationConfig(member_ttl_seconds=3, member_heartbeat_seconds=1) + deployment = DeploymentConfig() + first = RuntimeMembership( + database=profile.database, + deployment=deployment, + coordination=config, + build_version="test", + ) + second = RuntimeMembership( + database=profile.database, + deployment=deployment, + coordination=config, + build_version="test", + ) + + await first.start() + with pytest.raises(DuplicateSingleNodeError): + await second.start() + + await first.stop() + await second.start() + assert await second.readiness() == "ready" + await second.stop() + + asyncio.run(scenario()) + + +def test_api_membership_reports_missing_worker_and_scheduler_as_degraded_dependencies() -> None: + async def scenario() -> None: + async with SQLiteProfile.open(SQLiteConfig(), tables=COORDINATION_TABLES) as profile: + membership = RuntimeMembership( + database=profile.database, + deployment=DeploymentConfig(mode="distributed", role="api", id="api-a"), + coordination=CoordinationConfig(), + build_version="test", + ) + await membership.start() + + assert await membership.readiness() == "ready" + assert await membership.role_readiness("scheduler") == "unavailable" + assert await membership.role_readiness("worker") == "unavailable" + await membership.stop() + + asyncio.run(scenario()) diff --git a/tests/builtin/runtime/test_operation_maintenance.py b/tests/builtin/runtime/test_operation_maintenance.py new file mode 100644 index 0000000000..df213c8a41 --- /dev/null +++ b/tests/builtin/runtime/test_operation_maintenance.py @@ -0,0 +1,85 @@ +# 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 +from datetime import UTC, datetime + +import pytest +from sqlalchemy import update + +from powercontext.builtin.persistence.errors import RepositoryNotFoundError +from powercontext.builtin.persistence.sqlite import SQLiteConfig, SQLiteProfile +from powercontext.builtin.persistence.tables import COORDINATION_TABLES, WORK_ITEMS_TABLE, WORK_TABLES +from powercontext.builtin.persistence.work import WorkRepository, WorkResult, WorkSpec, WorkStatus +from powercontext.builtin.runtime.config import WorkerConfig +from powercontext.builtin.runtime.work_handlers import OperationMaintenanceDiscoverer, OperationMaintenanceHandler +from powercontext.builtin.runtime.worker import DurableWorker + + +def test_operation_retention_runs_as_bounded_durable_work() -> None: + async def scenario() -> None: + async with SQLiteProfile.open(SQLiteConfig(), tables=WORK_TABLES + COORDINATION_TABLES) as profile: + repository = WorkRepository() + old_spec = WorkSpec( + kind="test.completed", + payload_version=1, + scope_id="project:test", + lane_key="a" * 64, + logical_key="b" * 64, + payload={}, + ) + async with profile.database.transaction() as connection: + old = await repository.enqueue(connection, old_spec) + claim = ( + await repository.claim( + connection, + worker_id="setup", + supported={old_spec.kind: frozenset({1})}, + lease_seconds=120, + limit=1, + ) + )[0] + await repository.complete(connection, claim, WorkResult(code="done", payload={})) + await connection.execute( + update(WORK_ITEMS_TABLE) + .where(WORK_ITEMS_TABLE.c.work_id == old.work.work_id) + .values(completed_at=datetime(2000, 1, 1, tzinfo=UTC)) + ) + + discoverer = OperationMaintenanceDiscoverer(interval_seconds=3600, max_attempts=5) + page = await discoverer.page(None, 100) + async with profile.database.transaction() as connection: + maintenance = await repository.enqueue(connection, page.specs[0]) + + worker = DurableWorker( + database=profile.database, + worker_id="maintenance-worker", + handlers=(OperationMaintenanceHandler(retention_days=1, batch_size=500),), + config=WorkerConfig(concurrency=1), + ) + assert await worker.run_once() == 1 + + async with profile.database.transaction() as connection: + with pytest.raises(RepositoryNotFoundError): + await repository.get(connection, old.work.work_id) + completed = await repository.get(connection, maintenance.work.work_id) + assert completed.status is WorkStatus.SUCCEEDED + assert completed.result_payload == { + "operations_deleted": 1, + "rate_limit_windows_deleted": 0, + } + + asyncio.run(scenario()) diff --git a/tests/builtin/runtime/test_profile_work.py b/tests/builtin/runtime/test_profile_work.py new file mode 100644 index 0000000000..40585ca6de --- /dev/null +++ b/tests/builtin/runtime/test_profile_work.py @@ -0,0 +1,129 @@ +# 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. + +"""Expired Profile workers must not publish outside the fenced completion transaction.""" + +from __future__ import annotations + +import asyncio +from datetime import UTC, datetime + +import pytest +from sqlalchemy import update + +from powercontext.builtin.artifacts.profile.models import PROFILE_SOURCE_WINDOW_BINDING as BINDING +from powercontext.builtin.persistence.cursors import SourceCursorRepository +from powercontext.builtin.persistence.sqlite import SQLiteConfig, SQLiteProfile +from powercontext.builtin.persistence.tables import BUILTIN_TABLES, WORK_ITEMS_TABLE +from powercontext.builtin.persistence.work import StaleWorkClaimError, WorkRepository, WorkStatus +from powercontext.builtin.runtime.config import BuiltinConfig +from powercontext.builtin.runtime.relational import RelationalContexts +from powercontext.builtin.runtime.work_handlers import ( + CURRENT_WORK_PAYLOAD_VERSION, + PROFILE_WORK_KIND, + ProfileWorkHandler, + profile_work_spec, +) +from powercontext.builtin.scope import ScopeDraft + + +class _Generator: + async def generate(self, value): + return "# Preferences\n\nGenerated profile for the fenced work commit." + + +def test_profile_work_publishes_only_inside_the_fenced_completion_transaction(tmp_path) -> None: + async def scenario() -> None: + config = BuiltinConfig(database=SQLiteConfig(url=f"sqlite+aiosqlite:///{tmp_path / 'profile-work.db'}")) + assert isinstance(config.database, SQLiteConfig) + async with SQLiteProfile.open(config.database, tables=BUILTIN_TABLES) as profile: + contexts = RelationalContexts(database=profile.database) + scope = (await contexts.scopes.create(ScopeDraft(title="p", summary="p", idempotency_key="p"))).scope_id + await contexts.records.create_source(scope, "content", "Profile evidence for the fenced commit.") + await contexts.profiles.put_policy(scope, generation_enabled=True, expected_version=0) + contexts.profiles.generator = _Generator() + + spec = await profile_work_spec( + contexts, scope, max_attempts=5, payload_version=CURRENT_WORK_PAYLOAD_VERSION + ) + assert spec is not None + repository = WorkRepository() + async with profile.database.transaction() as connection: + enqueued = await repository.enqueue(connection, spec) + (claim_a,) = await repository.claim( + connection, + worker_id="worker-a", + supported={PROFILE_WORK_KIND: frozenset({CURRENT_WORK_PAYLOAD_VERSION})}, + lease_seconds=120, + limit=1, + ) + + handler = ProfileWorkHandler(contexts.profiles, max_concurrency=1) + prepared_a = await handler.prepare(claim_a) + + # prepare() must not publish: nothing is durable before the fence. + async with profile.database.transaction() as connection: + cursor = await SourceCursorRepository().load(connection, scope, BINDING) + artifact = await contexts.profiles.latest(connection, scope) + assert cursor is None + assert artifact is None + + # The lease expires and a superseding Worker takes over with a higher fence. + # The first pass converts the expired attempt to retry_wait; the second + # pass claims it with fence 2. + async with profile.database.transaction() as connection: + await connection.execute( + update(WORK_ITEMS_TABLE) + .where(WORK_ITEMS_TABLE.c.work_id == enqueued.work.work_id) + .values(lease_expires_at=datetime(2000, 1, 1, tzinfo=UTC)) + ) + assert not await repository.claim( + connection, + worker_id="worker-b", + supported={PROFILE_WORK_KIND: frozenset({CURRENT_WORK_PAYLOAD_VERSION})}, + lease_seconds=120, + limit=1, + expired_retry_delay_seconds=0, + ) + (claim_b,) = await repository.claim( + connection, + worker_id="worker-b", + supported={PROFILE_WORK_KIND: frozenset({CURRENT_WORK_PAYLOAD_VERSION})}, + lease_seconds=120, + limit=1, + expired_retry_delay_seconds=0, + ) + + # Worker A's fenced completion is rejected and still publishes nothing. + with pytest.raises(StaleWorkClaimError): + async with profile.database.transaction() as connection: + await repository.complete(connection, claim_a, prepared_a.result, commit=prepared_a.commit) + async with profile.database.transaction() as connection: + cursor = await SourceCursorRepository().load(connection, scope, BINDING) + artifact = await contexts.profiles.latest(connection, scope) + assert cursor is None + assert artifact is None + + # Worker B commits the Profile, its cursor and the Work success together. + prepared_b = await handler.prepare(claim_b) + async with profile.database.transaction() as connection: + stored = await repository.complete(connection, claim_b, prepared_b.result, commit=prepared_b.commit) + assert stored.status is WorkStatus.SUCCEEDED + async with profile.database.transaction() as connection: + cursor = await SourceCursorRepository().load(connection, scope, BINDING) + artifact = await contexts.profiles.latest(connection, scope) + assert cursor is not None and cursor.cursor.sequence == 1 + assert artifact is not None + + asyncio.run(scenario()) diff --git a/tests/builtin/runtime/test_recurrence_ledger.py b/tests/builtin/runtime/test_recurrence_ledger.py index bc1581444e..c19b94608c 100644 --- a/tests/builtin/runtime/test_recurrence_ledger.py +++ b/tests/builtin/runtime/test_recurrence_ledger.py @@ -27,7 +27,7 @@ from sqlalchemy.ext.asyncio import AsyncConnection from powercontext.artifacts import ArtifactRef -from powercontext.builtin.artifacts.experience import Experience +from powercontext.builtin.artifacts.experience import Experience, ExperienceCandidateInput from powercontext.builtin.artifacts.experience.models import ( ExperienceContent, FailureRecord, @@ -53,7 +53,13 @@ SCOPES_TABLE, SHARED_TABLES, ) +from powercontext.builtin.persistence.work import WorkRepository, WorkStatus +from powercontext.builtin.review.models import CandidateStatus +from powercontext.builtin.runtime import BuiltinConfig, open_builtin_contexts +from powercontext.builtin.runtime.config import WorkerConfig from powercontext.builtin.runtime.recurrence import RelationalRecurrenceLedger +from powercontext.builtin.runtime.work_handlers import ExperienceWorkHandler, experience_work_spec +from powercontext.builtin.runtime.worker import DurableWorker from powercontext.builtin.sources.content import CONTENT_SOURCE_ADAPTER, ContentSource from powercontext.builtin.work.models import ( HandoffReceipt, @@ -62,7 +68,7 @@ TaskOutcomeStatus, WorkClaim, ) -from powercontext.sources import SourceMaterialization, SourceRef +from powercontext.sources import Source, SourceMaterialization, SourceRef SCOPE = "scope-a" CUE = "openapi contract changed without regenerating the client" @@ -609,3 +615,53 @@ def test_the_ledger_module_performs_no_read_path_instrumentation() -> None: source = Path("src/powercontext/builtin/artifacts/experience/recurrence.py").read_text(encoding="utf-8") for forbidden in ("sqlalchemy", "AsyncConnection", "Repository"): assert forbidden not in source, forbidden + + +class _NoExperienceCandidates: + async def incubate(self, sources: tuple[Source, ...], /) -> tuple[ExperienceCandidateInput, ...]: + return () + + +def test_durable_experience_windows_preserve_recurrence_and_repair_candidates(tmp_path) -> None: + async def scenario() -> None: + config = BuiltinConfig(database=SQLiteConfig(url=f"sqlite+aiosqlite:///{tmp_path / 'recurrence.db'}")) + async with open_builtin_contexts(config, experience_pipeline=_NoExperienceCandidates()) as contexts: + sources = contexts.repositories.sources + repository = WorkRepository() + async with contexts.database.transaction() as connection: + await _seed_scope(connection) + await _seed_artifacts(connection, sources) + await _add_receipt(sources, connection) + worker = DurableWorker( + database=contexts.database, + worker_id="recurrence-worker", + handlers=(ExperienceWorkHandler(contexts),), + config=WorkerConfig(concurrency=1), + ) + for ordinal in range(1, 4): + async with contexts.database.transaction() as connection: + await _add_source(sources, connection, f"outcome-{ordinal}", _failed_outcome().model_dump_json()) + spec = await experience_work_spec(contexts, SCOPE, limit=100, max_attempts=1, payload_version=1) + assert spec is not None + async with contexts.database.transaction() as connection: + queued = await repository.enqueue(connection, spec) + assert await worker.run_once() == 1 + async with contexts.database.transaction() as connection: + completed = await repository.get(connection, queued.work.work_id) + observations = await contexts.repositories.recurrence.observations(connection, SCOPE) + assert completed.status is WorkStatus.SUCCEEDED + assert completed.result_payload is not None + assert completed.result_payload["candidate_count"] == (1 if ordinal == 3 else 0) + assert sum(observation.event == "recurred" for observation in observations) == ordinal + + candidates = await contexts.review(SCOPE).list_candidates( + status=CandidateStatus.PENDING, family="experience", cursor=None, limit=10 + ) + assert len(candidates.candidates) == 1 + assert candidates.candidates[0].target == EXPERIENCE_REF + assert candidates.candidates[0].reason is not None + assert "recurred 3 times" in candidates.candidates[0].reason + assert await experience_work_spec(contexts, SCOPE, limit=100, max_attempts=1, payload_version=1) is None + assert await worker.run_once() == 0 + + asyncio.run(scenario()) diff --git a/tests/builtin/runtime/test_worker.py b/tests/builtin/runtime/test_worker.py new file mode 100644 index 0000000000..ffc7930715 --- /dev/null +++ b/tests/builtin/runtime/test_worker.py @@ -0,0 +1,339 @@ +# 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 logging +from collections.abc import Iterator, Mapping, Sequence +from contextlib import contextmanager + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncConnection + +from powercontext.builtin.persistence.sqlite import SQLiteConfig, SQLiteProfile +from powercontext.builtin.persistence.tables import WORK_ATTEMPTS_TABLE, WORK_ITEMS_TABLE, WORK_TABLES +from powercontext.builtin.persistence.work import WorkRepository, WorkResult, WorkSpec, WorkStatus +from powercontext.builtin.runtime.config import WorkerConfig +from powercontext.builtin.runtime.protocols import RuntimeTraceContext +from powercontext.builtin.runtime.worker import DurableWorker, PreparedWork, WorkExecutionError + + +class _Handler: + kind = "test.handler.v1" + supported_versions = frozenset({1}) + + def __init__(self, executed: list[str]) -> None: + self._executed = executed + + async def prepare(self, claim) -> PreparedWork: + async def commit(_connection: AsyncConnection) -> None: + self._executed.append(claim.work_id) + + return PreparedWork(result=WorkResult(code="done", payload={}), commit=commit) + + +class _OverlapHandler: + kind = "test.handler.v1" + supported_versions = frozenset({1}) + + def __init__(self) -> None: + self.active = 0 + self.maximum_active = 0 + self.both_entered = asyncio.Event() + + async def prepare(self, _claim) -> PreparedWork: + self.active += 1 + self.maximum_active = max(self.maximum_active, self.active) + if self.active == 2: + self.both_entered.set() + await asyncio.wait_for(self.both_entered.wait(), timeout=1) + self.active -= 1 + return PreparedWork(result=WorkResult(code="done", payload={})) + + +class _FlakyHandler: + kind = "test.handler.v1" + supported_versions = frozenset({1}) + + def __init__(self) -> None: + self.calls = 0 + + async def prepare(self, _claim) -> PreparedWork: + self.calls += 1 + if self.calls == 1: + raise WorkExecutionError(category="provider", code="unavailable", retryable=True) + return PreparedWork(result=WorkResult(code="done", payload={})) + + +class _BlockingHandler: + kind = "test.handler.v1" + supported_versions = frozenset({1}) + + def __init__(self) -> None: + self.started = asyncio.Event() + self.release = asyncio.Event() + + async def prepare(self, _claim) -> PreparedWork: + self.started.set() + await self.release.wait() + return PreparedWork(result=WorkResult(code="done", payload={})) + + +class _SensitiveFailureHandler: + kind = "test.handler.v1" + supported_versions = frozenset({1}) + + def __init__(self, sensitive_text: str) -> None: + self._sensitive_text = sensitive_text + + async def prepare(self, _claim) -> PreparedWork: + raise RuntimeError(self._sensitive_text) + + +class _TraceSpan: + def __init__(self, trace_context: RuntimeTraceContext | None = None) -> None: + self.trace_context = trace_context + + def set_attributes(self, _attributes) -> None: + return + + def set_outcome(self, _outcome: str) -> None: + return + + +class _Tracing: + def __init__(self) -> None: + self.execute_links: list[tuple[RuntimeTraceContext, ...]] = [] + self.attributes: list[dict[str, str | bool | int | float]] = [] + + @contextmanager + def stage( + self, + name: str, + *, + attributes: Mapping[str, str | bool | int | float], + ) -> Iterator[_TraceSpan]: + del name + self.attributes.append(dict(attributes)) + yield _TraceSpan() + + @contextmanager + def background( + self, + name: str, + *, + operation: str, + attributes: Mapping[str, str | bool | int | float], + links: Sequence[RuntimeTraceContext] = (), + ) -> Iterator[_TraceSpan]: + del operation + self.attributes.append(dict(attributes)) + links = tuple(links) + if name == "work.execute": + self.execute_links.append(links) + index = len(self.execute_links) + yield _TraceSpan(RuntimeTraceContext(trace_id=f"{index:032x}", span_id=f"{index:016x}")) + return + yield _TraceSpan() + + +def _spec(index: int) -> WorkSpec: + return WorkSpec( + kind="test.handler.v1", + payload_version=1, + scope_id=f"scope:{index}", + lane_key=f"{index + 1:064x}", + logical_key=f"{index + 101:064x}", + payload={}, + ) + + +def test_worker_claims_only_free_slots_and_executes_different_lanes_concurrently() -> None: + async def scenario() -> None: + async with SQLiteProfile.open(SQLiteConfig(), tables=WORK_TABLES) as profile: + executed: list[str] = [] + handler = _Handler(executed) + repository = WorkRepository() + async with profile.database.transaction() as connection: + first = await repository.enqueue(connection, _spec(1)) + second = await repository.enqueue(connection, _spec(2)) + + worker = DurableWorker( + database=profile.database, + worker_id="worker-a", + handlers=(handler,), + config=WorkerConfig(concurrency=2), + ) + assert await worker.run_once() == 2 + + async with profile.database.transaction() as connection: + first_stored = await repository.get(connection, first.work.work_id) + second_stored = await repository.get(connection, second.work.work_id) + assert first_stored.status is WorkStatus.SUCCEEDED + assert second_stored.status is WorkStatus.SUCCEEDED + assert set(executed) == {first.work.work_id, second.work.work_id} + + asyncio.run(scenario()) + + +def test_worker_overlaps_different_lanes_but_never_claims_an_unknown_payload() -> None: + async def scenario() -> None: + async with SQLiteProfile.open(SQLiteConfig(), tables=WORK_TABLES) as profile: + handler = _OverlapHandler() + repository = WorkRepository() + async with profile.database.transaction() as connection: + await repository.enqueue(connection, _spec(1)) + await repository.enqueue(connection, _spec(2)) + + worker = DurableWorker( + database=profile.database, + worker_id="worker-a", + handlers=(handler,), + config=WorkerConfig(concurrency=2), + ) + assert await worker.run_once() == 2 + assert handler.maximum_active == 2 + + unknown = _spec(3).model_copy(update={"payload_version": 2}) + async with profile.database.transaction() as connection: + enqueued = await repository.enqueue(connection, unknown) + assert await worker.readiness() == "misconfigured" + assert await worker.run_once() == 0 + async with profile.database.transaction() as connection: + stored = await repository.get(connection, enqueued.work.work_id) + assert stored.status is WorkStatus.QUEUED + + asyncio.run(scenario()) + + +def test_retried_worker_attempt_links_to_the_persisted_previous_span() -> None: + async def scenario() -> None: + async with SQLiteProfile.open(SQLiteConfig(), tables=WORK_TABLES) as profile: + repository = WorkRepository() + async with profile.database.transaction() as connection: + enqueued = await repository.enqueue(connection, _spec(1)) + + tracing = _Tracing() + worker = DurableWorker( + database=profile.database, + worker_id="worker-a", + handlers=(_FlakyHandler(),), + config=WorkerConfig(concurrency=1, retry_base_seconds=0.01, retry_max_seconds=0.01), + random_source=lambda _low, _high: 0, + tracing=tracing, + ) + assert await worker.run_once() == 1 + assert await worker.run_once() == 1 + async with profile.database.transaction() as connection: + completed = await repository.get(connection, enqueued.work.work_id) + + assert completed.status is WorkStatus.SUCCEEDED + assert tracing.execute_links == [ + (), + (RuntimeTraceContext(trace_id=f"{1:032x}", span_id=f"{1:016x}"),), + ] + + asyncio.run(scenario()) + + +def test_worker_converges_a_running_cancel_without_waiting_for_lease_expiry() -> None: + async def scenario() -> None: + async with SQLiteProfile.open(SQLiteConfig(), tables=WORK_TABLES) as profile: + repository = WorkRepository() + handler = _BlockingHandler() + async with profile.database.transaction() as connection: + enqueued = await repository.enqueue(connection, _spec(1)) + + worker = DurableWorker( + database=profile.database, + worker_id="worker-a", + handlers=(handler,), + config=WorkerConfig(concurrency=1), + ) + attempt = asyncio.create_task(worker.run_once()) + await asyncio.wait_for(handler.started.wait(), timeout=1) + async with profile.database.transaction() as connection: + running = await repository.get(connection, enqueued.work.work_id) + cancelling = await repository.cancel( + connection, + running.work_id, + expected_version=running.state_version, + ) + assert cancelling.status is WorkStatus.CANCELLING + + handler.release.set() + assert await asyncio.wait_for(attempt, timeout=1) == 1 + async with profile.database.transaction() as connection: + cancelled = await repository.get(connection, enqueued.work.work_id) + outcome = await connection.scalar( + select(WORK_ATTEMPTS_TABLE.c.outcome).where(WORK_ATTEMPTS_TABLE.c.work_id == enqueued.work.work_id) + ) + + assert cancelled.status is WorkStatus.CANCELLED + assert outcome == "cancelled" + + asyncio.run(scenario()) + + +def test_worker_does_not_persist_or_observe_sensitive_exception_text(caplog) -> None: + sensitive_text = "source-content=private credential=https://user:secret@example.test" + + async def scenario() -> None: + async with SQLiteProfile.open(SQLiteConfig(), tables=WORK_TABLES) as profile: + repository = WorkRepository() + async with profile.database.transaction() as connection: + enqueued = await repository.enqueue(connection, _spec(1)) + + tracing = _Tracing() + worker = DurableWorker( + database=profile.database, + worker_id="worker-a", + handlers=(_SensitiveFailureHandler(sensitive_text),), + config=WorkerConfig(concurrency=1), + random_source=lambda _low, _high: 0, + tracing=tracing, + ) + with caplog.at_level(logging.DEBUG): + assert await worker.run_once() == 1 + + async with profile.database.transaction() as connection: + work = ( + ( + await connection.execute( + select(WORK_ITEMS_TABLE).where(WORK_ITEMS_TABLE.c.work_id == enqueued.work.work_id) + ) + ) + .mappings() + .one() + ) + attempt = ( + ( + await connection.execute( + select(WORK_ATTEMPTS_TABLE).where(WORK_ATTEMPTS_TABLE.c.work_id == enqueued.work.work_id) + ) + ) + .mappings() + .one() + ) + + assert work["status"] == WorkStatus.RETRY_WAIT.value + assert work["error_category"] == "internal" + assert work["error_code"] == "unhandled_handler_error" + assert sensitive_text not in repr(dict(work)) + assert sensitive_text not in repr(dict(attempt)) + assert sensitive_text not in caplog.text + assert sensitive_text not in repr(tracing.attributes) + + asyncio.run(scenario()) diff --git a/tests/e2e/test_artifact_processing_persistence.py b/tests/e2e/test_artifact_processing_persistence.py index 43bf5037dc..246eed47c4 100644 --- a/tests/e2e/test_artifact_processing_persistence.py +++ b/tests/e2e/test_artifact_processing_persistence.py @@ -28,7 +28,7 @@ from powercontext.builtin.persistence.processing_intents import ArtifactProcessingIntentRepository from powercontext.builtin.persistence.sources import SourceRepository from powercontext.builtin.persistence.sqlite import SQLiteConfig, SQLiteProfile -from powercontext.builtin.persistence.tables import SHARED_TABLES +from powercontext.builtin.persistence.tables import BUILTIN_TABLES, SHARED_TABLES from powercontext.builtin.runtime import ( ArtifactProcessingBinding, ArtifactProcessingWorkAssignment, @@ -122,6 +122,8 @@ async def scenario() -> None: def test_runtime_capture_rolls_back_source_when_pending_write_fails(tmp_path: Path) -> None: async def scenario() -> None: config = _sqlite_config(tmp_path / "rollback.db") + async with SQLiteProfile.open(config, tables=BUILTIN_TABLES): + pass async with ( SQLiteProfile.open(config, tables=SHARED_TABLES) as profile, profile.database.transaction() as connection, diff --git a/tests/e2e/test_durable_experience_work.py b/tests/e2e/test_durable_experience_work.py new file mode 100644 index 0000000000..ba72a56035 --- /dev/null +++ b/tests/e2e/test_durable_experience_work.py @@ -0,0 +1,160 @@ +# 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 + +from powercontext.builtin.artifacts.experience import ( + EXPERIENCE_INCUBATION_CURSOR_NAME, + ExperienceCandidateInput, + ExperienceContent, +) +from powercontext.builtin.persistence.cursors import SourceCursorRepository +from powercontext.builtin.persistence.sqlite import SQLiteConfig +from powercontext.builtin.persistence.work import EnqueueResult, WorkRepository, WorkStatus +from powercontext.builtin.runtime import BuiltinConfig, open_builtin_contexts +from powercontext.builtin.runtime.config import WorkerConfig +from powercontext.builtin.runtime.work_handlers import ExperienceWorkHandler, experience_work_spec +from powercontext.builtin.runtime.worker import DurableWorker +from powercontext.builtin.scope import ScopeDraft +from powercontext.builtin.sources import ContentCapture, ContentSource +from powercontext.sources import Source, SourceRef + + +class _TaskOutcomePipeline: + async def incubate(self, sources: tuple[Source, ...], /) -> tuple[ExperienceCandidateInput, ...]: + return tuple( + ExperienceCandidateInput( + proposal=ExperienceContent( + situation="A durable worker processed one bounded Source window.", + action="Incubate every captured fact into a reviewed proposal.", + outcome=source.content, + lesson="Keep the window bounded so each commit stays fenced.", + ), + sources=(SourceRef(source_type="content", source_id=source.name),), + ) + for source in sources + if isinstance(source, ContentSource) + ) + + +def test_manual_and_scheduled_discovery_join_one_window_and_commit_once(tmp_path) -> None: + async def scenario() -> None: + config = BuiltinConfig( + database=SQLiteConfig(url=f"sqlite+aiosqlite:///{tmp_path / 'durable.db'}"), + ) + async with open_builtin_contexts(config, experience_pipeline=_TaskOutcomePipeline()) as contexts: + scope = await contexts.scopes.create( + ScopeDraft(title="Durable work", summary="Joined window commit", idempotency_key="join") + ) + context = await contexts.get(scope.scope_id) + await context.sources.capture(ContentCapture(source_id="one", content="First durable fact.")) + await context.sources.capture(ContentCapture(source_id="two", content="Second durable fact.")) + + manual_spec = await experience_work_spec( + contexts, scope.scope_id, limit=100, max_attempts=5, payload_version=1 + ) + scheduled_spec = await experience_work_spec( + contexts, scope.scope_id, limit=100, max_attempts=5, payload_version=1 + ) + assert manual_spec is not None and scheduled_spec is not None + assert manual_spec.logical_key == scheduled_spec.logical_key + + repository = WorkRepository() + async with contexts.database.transaction() as connection: + manual = await repository.enqueue(connection, manual_spec) + scheduled = await repository.enqueue(connection, scheduled_spec) + assert isinstance(manual, EnqueueResult) and isinstance(scheduled, EnqueueResult) + assert manual.created is True + assert scheduled.created is False + assert manual.work.work_id == scheduled.work.work_id + + worker = DurableWorker( + database=contexts.database, + worker_id="worker-a", + handlers=(ExperienceWorkHandler(contexts),), + config=WorkerConfig(concurrency=1), + ) + assert await worker.run_once() == 1 + + async with contexts.database.transaction() as connection: + stored = await repository.get(connection, manual.work.work_id) + assert stored.status is WorkStatus.SUCCEEDED + assert stored.result_payload is not None + assert stored.result_payload["current_cursor"] == 2 + assert stored.result_payload["candidate_count"] == 2 + + asyncio.run(scenario()) + + +def test_stale_discovery_snapshot_retires_instead_of_blocking_the_lane(tmp_path) -> None: + async def scenario() -> None: + config = BuiltinConfig( + database=SQLiteConfig(url=f"sqlite+aiosqlite:///{tmp_path / 'stale-window.db'}"), + ) + async with open_builtin_contexts(config, experience_pipeline=_TaskOutcomePipeline()) as contexts: + scope = await contexts.scopes.create( + ScopeDraft(title="Durable work", summary="Stale snapshot retirement", idempotency_key="stale") + ) + context = await contexts.get(scope.scope_id) + await context.sources.capture(ContentCapture(source_id="one", content="First durable fact.")) + await context.sources.capture(ContentCapture(source_id="two", content="Second durable fact.")) + + # A scheduled discovery snapshot taken before the manual window exists. + stale = await experience_work_spec(contexts, scope.scope_id, limit=2, max_attempts=5, payload_version=1) + assert stale is not None + + # The manual window 0 -> 1 wins the race and commits first. + manual = await experience_work_spec(contexts, scope.scope_id, limit=1, max_attempts=5, payload_version=1) + assert manual is not None + repository = WorkRepository() + async with contexts.database.transaction() as connection: + await repository.enqueue(connection, manual) + worker = DurableWorker( + database=contexts.database, + worker_id="worker-a", + handlers=(ExperienceWorkHandler(contexts),), + config=WorkerConfig(concurrency=1), + ) + assert await worker.run_once() == 1 + + # The stale snapshot is enqueued only after that commit released the key. + async with contexts.database.transaction() as connection: + stale_enqueued = await repository.enqueue(connection, stale) + assert stale_enqueued.created is True + + assert await worker.run_once() == 1 + async with contexts.database.transaction() as connection: + stored = await repository.get(connection, stale_enqueued.work.work_id) + assert stored.status is WorkStatus.SUCCEEDED + assert stored.result_code == "superseded" + + # The lane kept moving: discovery covers the remainder and both facts land. + remainder = await experience_work_spec(contexts, scope.scope_id, limit=2, max_attempts=5, payload_version=1) + assert remainder is not None + async with contexts.database.transaction() as connection: + remainder_enqueued = await repository.enqueue(connection, remainder) + assert remainder_enqueued.created is True + assert await worker.run_once() == 1 + async with contexts.database.transaction() as connection: + stored = await repository.get(connection, remainder_enqueued.work.work_id) + cursor_state = await SourceCursorRepository().load( + connection, scope.scope_id, EXPERIENCE_INCUBATION_CURSOR_NAME + ) + assert stored.status is WorkStatus.SUCCEEDED + assert stored.result_code == "processed" + assert cursor_state is not None and cursor_state.cursor.sequence == 2 + + asyncio.run(scenario()) diff --git a/tests/e2e/test_operation_api.py b/tests/e2e/test_operation_api.py new file mode 100644 index 0000000000..4dac8b09e2 --- /dev/null +++ b/tests/e2e/test_operation_api.py @@ -0,0 +1,108 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# 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.builtin.persistence.errors import RepositoryNotFoundError +from powercontext.builtin.persistence.sqlite import SQLiteConfig +from powercontext.builtin.persistence.work import ( + StaleWorkClaimError, + WorkRepository, + WorkSpec, + WorkStateConflictError, + WorkStatus, +) +from powercontext.builtin.runtime import BuiltinConfig, open_builtin_contexts +from powercontext.builtin.runtime.config import OperationsConfig, WorkerConfig +from powercontext.builtin.runtime.operations import OperationManager +from powercontext.builtin.runtime.work_handlers import EXPERIENCE_WORK_KIND +from powercontext.builtin.sources import ContentCapture + + +def _public_spec() -> WorkSpec: + return WorkSpec( + kind=EXPERIENCE_WORK_KIND, + payload_version=1, + scope_id="project", + lane_key="a" * 64, + logical_key="b" * 64, + payload={ + "cursor_name": "experience.incubation", + "cursor_generation": 0, + "after": 0, + "through": 1, + "high_watermark": 1, + }, + ) + + +def _internal_spec() -> WorkSpec: + return WorkSpec( + kind="powercontext.maintenance.operations", + payload_version=1, + scope_id="system:operations", + lane_key="c" * 64, + logical_key="d" * 64, + payload={}, + ) + + +def test_operation_manager_queries_public_operations_and_mutates_optimistically(tmp_path) -> None: + async def scenario() -> None: + config = BuiltinConfig(database=SQLiteConfig(url=f"sqlite+aiosqlite:///{tmp_path / 'operations.db'}")) + async with open_builtin_contexts(config) as contexts: + context = await contexts.get("project") + await context.sources.capture(ContentCapture(source_id="one", content="queued work")) + manager = OperationManager( + contexts=contexts, + operations=OperationsConfig(), + worker=WorkerConfig(), + local_worker=None, + payload_version=1, + ) + repository = WorkRepository() + async with contexts.database.transaction() as connection: + public = await repository.enqueue(connection, _public_spec()) + internal = await repository.enqueue(connection, _internal_spec()) + + stored = await manager.get(public.work.work_id) + assert stored.status is WorkStatus.QUEUED + + # Maintenance work stays private: the public facade hides it. + with pytest.raises(RepositoryNotFoundError): + await manager.get(internal.work.work_id) + + page = await manager.list(scope_id="project", kind=None, status=None, cursor=None, limit=10) + assert [item.work_id for item in page.items] == [public.work.work_id] + assert page.next_cursor is None + + # Optimistic mutations: a stale version loses, the current version wins. + with pytest.raises(WorkStateConflictError): + await manager.cancel(public.work.work_id, expected_version=stored.state_version + 1) + cancelled = await manager.cancel(public.work.work_id, expected_version=stored.state_version) + assert cancelled.status is WorkStatus.CANCELLED + + # Cancellation releases the logical key, so the closed operation can + # neither be rejoined nor retried. + with pytest.raises(StaleWorkClaimError): + await manager.retry(public.work.work_id, expected_version=cancelled.state_version) + + observed = await manager.wait(public.work.work_id, timeout_seconds=0) + assert observed is not None and observed.status is WorkStatus.CANCELLED + + asyncio.run(scenario()) diff --git a/tests/native/test_personal_service_lifecycle.py b/tests/native/test_personal_service_lifecycle.py index 6d17946926..4e55410c90 100644 --- a/tests/native/test_personal_service_lifecycle.py +++ b/tests/native/test_personal_service_lifecycle.py @@ -63,6 +63,8 @@ SupportState, ) +_TEST_MEMBER_TTL_SECONDS = 3 + pytestmark = [ pytest.mark.native_service, pytest.mark.skipif( @@ -99,6 +101,9 @@ def test_native_personal_service_lifecycle(tmp_path: Path) -> None: assert adapter.loaded_registration().state is ManagerOwnershipState.OWNED assert adapter.manager_state() is ManagerState.INACTIVE + if isinstance(adapter, WindowsTaskSchedulerAdapter): + # schtasks /End cannot run the Server shutdown hook that releases the lease. + time.sleep(_TEST_MEMBER_TTL_SECONDS + 1) adapter.start(reload_definition=False) restarted = _wait_for_status(controller) assert restarted.ok, f"{restarted}\n\n{_failure_diagnostics(adapter, tmp_path)}" @@ -800,6 +805,9 @@ def _environment_file(tmp_path: Path) -> Path: "\n".join(( f"POWERCONTEXT_HOME={data_dir}", f"POWERCONTEXT_SERVER_HTTP_PORT={_unused_loopback_port()}", + "POWERCONTEXT_SERVER_DASHBOARD_ENABLED=false", + f"POWERCONTEXT_SERVER_COORDINATION_MEMBER_TTL_SECONDS={_TEST_MEMBER_TTL_SECONDS}", + "POWERCONTEXT_SERVER_COORDINATION_MEMBER_HEARTBEAT_SECONDS=1", "", )), encoding="utf-8", diff --git a/tests/test_config_cli.py b/tests/test_config_cli.py index d23f48f76b..cc79e494a6 100644 --- a/tests/test_config_cli.py +++ b/tests/test_config_cli.py @@ -168,6 +168,50 @@ def test_validate_accepts_minimal_server_environment_without_inference_models(tm assert settings.http.port == 8888 +@pytest.mark.parametrize("role", ("api", "scheduler")) +def test_validate_accepts_non_executing_distributed_roles_without_inference_models( + role: str, + tmp_path: Path, +) -> None: + environment = tmp_path / "server.env" + environment.write_text( + "\n".join(( + "POWERCONTEXT_SERVER_DATABASE_KIND=oceanbase", + "POWERCONTEXT_SERVER_DATABASE_URL=mysql+aoceanbase://root@127.0.0.1:2881/powercontext?charset=utf8mb4", + "POWERCONTEXT_SERVER_DEPLOYMENT_MODE=distributed", + f"POWERCONTEXT_SERVER_DEPLOYMENT_ROLE={role}", + "POWERCONTEXT_SERVER_RUNTIME_SCHEDULE_SECONDS=60", + "POWERCONTEXT_SERVER_RUNTIME_EXPERIENCE_SCHEDULE_SECONDS=60", + "", + )), + encoding="utf-8", + ) + + result = CliRunner().invoke(config_cli.app, ["validate", "--env-file", str(environment)]) + + assert result.exit_code == 0 + assert "Configuration is valid" in result.output + + +def test_validate_rejects_distributed_worker_without_inference_model(tmp_path: Path) -> None: + environment = tmp_path / "server.env" + environment.write_text( + "\n".join(( + "POWERCONTEXT_SERVER_DATABASE_KIND=oceanbase", + "POWERCONTEXT_SERVER_DATABASE_URL=mysql+aoceanbase://root@127.0.0.1:2881/powercontext?charset=utf8mb4", + "POWERCONTEXT_SERVER_DEPLOYMENT_MODE=distributed", + "POWERCONTEXT_SERVER_DEPLOYMENT_ROLE=worker", + "", + )), + encoding="utf-8", + ) + + result = CliRunner().invoke(config_cli.app, ["validate", "--env-file", str(environment)]) + + assert result.exit_code == 2 + assert "built-in runtime cannot be configured" in result.output + + @pytest.mark.parametrize("token", ["literal-${PR1525_ABSENT}", "token'with-apostrophe"]) def test_server_settings_context_preserves_strict_env_file_values( monkeypatch: pytest.MonkeyPatch, diff --git a/tests/test_docker_contract.py b/tests/test_docker_contract.py index e3c60655c2..214bb8668d 100644 --- a/tests/test_docker_contract.py +++ b/tests/test_docker_contract.py @@ -29,12 +29,14 @@ from unittest.mock import Mock import pytest +import yaml from typer.testing import CliRunner from powercontext.cli.app import create_cli from powercontext.server.cli import app as server_app _DOCKERFILE = Path(__file__).resolve().parent.parent / "docker" / "Dockerfile" +_DISTRIBUTED_COMPOSE = _DOCKERFILE.with_name("compose.distributed.yaml") _SERVER_ENV_PATTERN = re.compile(r"(POWERCONTEXT_SERVER_[A-Z0-9_]+)=([^\s\\]+)") @@ -69,3 +71,29 @@ def test_documented_container_invocation_starts_out_of_the_box(monkeypatch: pyte assert result.exit_code == 0, result.output run_server.assert_called_once() assert run_server.call_args.kwargs["host"] == "0.0.0.0" # noqa: S104 - the image's documented bind. + + +def test_distributed_compose_separates_roles_and_model_credentials() -> None: + document = yaml.safe_load(_DISTRIBUTED_COMPOSE.read_text(encoding="utf-8")) + services = document["services"] + assert set(services) == {"migrate", "api-a", "api-b", "scheduler-a", "scheduler-b", "worker-a", "worker-b"} + + assert services["migrate"]["command"] == ["server", "migrate"] + assert services["migrate"]["environment"]["POWERCONTEXT_SERVER_DEPLOYMENT_ROLE"] == "api" + assert services["migrate"]["environment"]["POWERCONTEXT_SERVER_DEPLOYMENT_ID"] == "migrator" + for name, service in services.items(): + environment = service["environment"] + assert environment["POWERCONTEXT_SERVER_DATABASE_KIND"] == "oceanbase" + assert environment["POWERCONTEXT_SERVER_DEPLOYMENT_MODE"] == "distributed" + if name == "migrate": + continue + role = name.split("-", 1)[0] + assert environment["POWERCONTEXT_SERVER_DEPLOYMENT_ROLE"] == role + assert environment["POWERCONTEXT_SERVER_DEPLOYMENT_ID"] == name + if role in {"scheduler", "worker"}: + assert "ports" not in service + assert environment["POWERCONTEXT_SERVER_DASHBOARD_ENABLED"] == "false" + assert environment["POWERCONTEXT_SERVER_MCP_ENABLED"] == "false" + assert ("POWERCONTEXT_SERVER_INFERENCE_GENERATION_MODEL" in environment) is (role == "worker") + assert ("OPENAI_API_KEY" in environment) is (role == "worker") + assert ("ANTHROPIC_API_KEY" in environment) is (role == "worker") diff --git a/tests/test_managed_atomic_maintenance.py b/tests/test_managed_atomic_maintenance.py index 68f5f77e5c..17dcdcf851 100644 --- a/tests/test_managed_atomic_maintenance.py +++ b/tests/test_managed_atomic_maintenance.py @@ -76,7 +76,7 @@ def test_atomic_alias_upgrades_complete_historical_server_with_shared_plan_and_b assert inspected.exit_code == 0, inspected.output plan = json.loads(inspected.output) assert plan["source_revision"] == "s1300" - assert plan["target_revision"] == "v1_4_1_004" + assert plan["target_revision"] == "v1_4_1_005" assert plan["readiness_scope"] == "server" assert historical.read_bytes() == original @@ -103,7 +103,7 @@ def test_atomic_alias_upgrades_complete_historical_server_with_shared_plan_and_b with closing(sqlite3.connect(result["backup_ref"])) as connection: assert tuple(connection.iterdump()) == original_dump with closing(sqlite3.connect(historical)) as connection: - assert connection.execute("SELECT version_num FROM pc_schema_revision").fetchall() == [("v1_4_1_004",)] + assert connection.execute("SELECT version_num FROM pc_schema_revision").fetchall() == [("v1_4_1_005",)] assert connection.execute("SELECT content FROM pc_artifacts").fetchall() == [(b'{"history":"unchanged"}',)] verified = CliRunner().invoke(cli, [*arguments, "--action", "verify"]) assert verified.exit_code == 0, verified.output diff --git a/tests/test_managed_processing_maintenance.py b/tests/test_managed_processing_maintenance.py index d1d06578f0..76aa1849d9 100644 --- a/tests/test_managed_processing_maintenance.py +++ b/tests/test_managed_processing_maintenance.py @@ -75,7 +75,7 @@ def test_registered_mode_switch_backs_up_preserves_revision_and_fences_old_worke assert result.backup_state == "completed" assert result.backup_ref and Path(result.backup_ref).is_file() with closing(sqlite3.connect(path)) as connection: - assert connection.execute("SELECT version_num FROM pc_schema_revision").fetchall() == [("v1_4_1_004",)] + assert connection.execute("SELECT version_num FROM pc_schema_revision").fetchall() == [("v1_4_1_005",)] assert ( json.loads(connection.execute("SELECT config_manifest FROM pc_artifact_processing_schema").fetchone()[0])[ "mode" diff --git a/tests/test_migration_revision_names.py b/tests/test_migration_revision_names.py index 3db38c5731..83e3b1171b 100644 --- a/tests/test_migration_revision_names.py +++ b/tests/test_migration_revision_names.py @@ -59,9 +59,10 @@ def invoke(action, *options): "v1_4_1_002", "v1_4_1_003", "v1_4_1_004", + "v1_4_1_005", ] assert [item["revision"] for item in plan["pending_migrations"]] == list(reviewed.revisions) - target = {"revision": "v1_4_1_004", "name": "v1_4_1_004", "software_version": "1.4.1", "sequence": 4} + target = {"revision": "v1_4_1_005", "name": "v1_4_1_005", "software_version": "1.4.1", "sequence": 5} assert plan["target_migration"] == target applied = invoke( @@ -76,11 +77,18 @@ def invoke(action, *options): ) assert applied["migration"] == target and applied["server_ready"] with closing(sqlite3.connect(runner.database)) as connection: - assert connection.execute("SELECT version_num FROM pc_schema_revision").fetchall() == [("v1_4_1_004",)] + assert connection.execute("SELECT version_num FROM pc_schema_revision").fetchall() == [("v1_4_1_005",)] evidence = runner.evidence_path.read_bytes() recorded = json.loads(evidence) assert recorded["source_revision"] == expected_source["revision"] - assert set(recorded["release_checksums"]) == {expected_source["revision"], "s1400", "s1401", "s1402", "v1_4_1_004"} + assert set(recorded["release_checksums"]) == { + expected_source["revision"], + "s1400", + "s1401", + "s1402", + "v1_4_1_004", + "v1_4_1_005", + } assert runner.verify().state == "ready" assert invoke("verify")["migration"] == target repeated = invoke("apply") diff --git a/tests/test_registered_source_maintenance_cli.py b/tests/test_registered_source_maintenance_cli.py index 7cdbb5ce2c..deda3e6d2b 100644 --- a/tests/test_registered_source_maintenance_cli.py +++ b/tests/test_registered_source_maintenance_cli.py @@ -63,7 +63,7 @@ def test_release140_compatibility_commands_preserve_source_and_share_full_upgrad planned = CliRunner().invoke(cli, [*arguments, "plan", "--backup", "skip"]) assert planned.exit_code == 0, planned.output plan = json.loads(planned.output) - assert plan["source_revision"] == "r1400" and plan["target_revision"] == "v1_4_1_004" + assert plan["source_revision"] == "r1400" and plan["target_revision"] == "v1_4_1_005" assert plan["readiness_scope"] == "server" full = CliRunner().invoke(cli, ["server", "db-migrate", "plan", "--backup", "skip"]) assert full.exit_code == 0, full.output diff --git a/tests/test_server_metrics.py b/tests/test_server_metrics.py index 4efc73c36e..377f3dcf07 100644 --- a/tests/test_server_metrics.py +++ b/tests/test_server_metrics.py @@ -26,6 +26,7 @@ from prometheus_client.parser import text_string_to_metric_families from powercontext.builtin.persistence.sqlite import SQLiteConfig +from powercontext.builtin.persistence.work import WorkQueueStatistic, WorkStatus from powercontext.builtin.runtime import RuntimeConfig from powercontext.server.app import create_app from powercontext.server.factory import create_server_app @@ -210,3 +211,34 @@ def observe_application(self, operation: str, outcome: str, started_at: float) - response = TestClient(create_app(metrics=metrics)).get("/v1/capabilities") assert response.status_code == 200 + + +def test_work_metrics_use_only_bounded_kind_status_and_outcome_labels() -> None: + metrics = ServerMetrics() + metrics.observe_work_enqueue("powercontext.memory.source-window", created=True) + metrics.observe_work_claim("powercontext.memory.source-window", latency_seconds=0.5) + metrics.observe_work_attempt( + "powercontext.memory.source-window", + outcome="retry_wait", + error_category="provider", + duration_seconds=1.25, + ) + metrics.observe_work_lease_expiry("powercontext.memory.source-window", outcome="retry_wait") + metrics.observe_scheduler_leadership(outcome="acquired") + metrics.set_work_queue(( + WorkQueueStatistic( + kind="powercontext.memory.source-window", + status=WorkStatus.QUEUED, + depth=2, + oldest_age_seconds=3.5, + ), + )) + metrics.set_runtime_members({"api": 2, "worker": 3}) + + rendered = metrics.render().decode() + assert 'powercontext_work_queue_depth{kind="powercontext.memory.source-window",status="queued"} 2.0' in rendered + assert 'powercontext_runtime_role_members{role="api"} 2.0' in rendered + assert 'powercontext_runtime_role_members{role="worker"} 3.0' in rendered + assert "scope_id" not in rendered + assert "work_id" not in rendered + assert "principal" not in rendered diff --git a/tests/test_server_migration_cli.py b/tests/test_server_migration_cli.py new file mode 100644 index 0000000000..8ca00277b6 --- /dev/null +++ b/tests/test_server_migration_cli.py @@ -0,0 +1,71 @@ +# 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 json +import sqlite3 + +from typer.testing import CliRunner + +from powercontext.cli.app import create_cli +from powercontext.server.cli import app as server_app + + +def test_server_migrate_runs_the_packaged_forward_only_chain(tmp_path) -> None: + database = tmp_path / "runtime.db" + environment = tmp_path / "server.env" + environment.write_text( + "\n".join(( + "POWERCONTEXT_SERVER_DATABASE_KIND=sqlite", + f"POWERCONTEXT_SERVER_DATABASE_URL=sqlite+aiosqlite:///{database}", + )), + encoding="utf-8", + ) + + common = ["--env-file", str(environment)] + plan = json.loads( + CliRunner() + .invoke( + create_cli((server_app,)), + ["server", "db-migrate", "plan", *common, "--backup", "skip"], + ) + .output + ) + + result = CliRunner().invoke( + create_cli((server_app,)), + [ + "server", + "db-migrate", + "apply", + *common, + "--backup", + "skip", + "--plan-id", + plan["plan_id"], + "--yes", + "--maintenance-confirmed", + "--accept-no-backup", + ], + ) + + assert result.exit_code == 0, result.output + with sqlite3.connect(database) as connection: + revision = connection.execute("SELECT version_num FROM pc_schema_revision").fetchall() + work_table = connection.execute( + "SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'pc_work_items'" + ).fetchone() + assert revision == [("v1_4_1_005",)] + assert work_table == ("pc_work_items",) diff --git a/tests/test_server_tracing.py b/tests/test_server_tracing.py index 9c1063dcda..be997e6f41 100644 --- a/tests/test_server_tracing.py +++ b/tests/test_server_tracing.py @@ -27,6 +27,7 @@ from opentelemetry.trace import SpanKind, StatusCode from powercontext.builtin.persistence.sqlite import SQLiteConfig +from powercontext.builtin.runtime.protocols import RuntimeTraceContext from powercontext.client import PowerContextClient from powercontext.server.factory import create_server_app from powercontext.server.settings import ( @@ -495,6 +496,27 @@ def test_background_stage_starts_a_fresh_trace_outside_an_ambient_span() -> None assert attributes["powercontext.background.source_count"] == 0 +def test_background_stage_links_a_recovered_attempt_without_parenting_it() -> None: + tracing, exporter = _tracing() + + with tracing.background("work.execute", operation="work.execute", attributes={}) as first: + context = first.trace_context + assert context is not None + with tracing.background( + "work.execute", + operation="work.execute", + attributes={}, + links=(RuntimeTraceContext(trace_id=context.trace_id, span_id=context.span_id),), + ): + pass + + first_span, recovered_span = exporter.get_finished_spans() + assert recovered_span.parent is None + assert len(recovered_span.links) == 1 + assert recovered_span.links[0].context.trace_id == first_span.context.trace_id + assert recovered_span.links[0].context.span_id == first_span.context.span_id + + def test_background_isolates_child_spans_when_root_start_fails(monkeypatch) -> None: tracing, exporter = _tracing() ambient = tracing.start_span("HTTP flush_memory", kind=SpanKind.SERVER, attributes={}) From 32435724d6a34c469f4d34713a7b145acc389561 Mon Sep 17 00:00:00 2001 From: JasonDeng <15301580353@163.com> Date: Thu, 29 Oct 2026 20:15:32 +0800 Subject: [PATCH 02/45] fix(migration): register the Work Ledger boolean column as TINYINT(1) MySQL reflects `BOOL` as `tinyint(1)`, but the frozen state layout for the new revision declared the column as `bool`. `MySQLSchema.recovery()` compares the live inventory against every registered statement prefix after each DDL statement, so once `pc_work_items` was created the schema looked like an uncontrolled state and every MySQL-family upgrade aborted with `incompatible_schema: Actual MySQL schema is not a controlled next-revision DDL state`. SQLite was unaffected, which is why only the seekDB and OceanBase jobs failed. Register the column the way the rest of the frozen chain does, `TINYINT(1)`, and declare the matching reflected type in the layout. Verified by replaying the revision against a live MySQL server: every one of the sixteen intermediate prefixes now matches the reflected inventory exactly, and `recovery()` returns the expected pending statements from the previous head. `make check` and the persistence suite stay green. --- release/upgrade-support.json | 2 +- .../schemas/mysql-v1_4_1_005.json | 32 ++++++++++++------- 2 files changed, 22 insertions(+), 12 deletions(-) diff --git a/release/upgrade-support.json b/release/upgrade-support.json index e78eadd4da..a2e7768fc4 100644 --- a/release/upgrade-support.json +++ b/release/upgrade-support.json @@ -3,7 +3,7 @@ "target_version": "1.4.1", "mode": "upgrade-required", "schema_review": { - "persistence_sha256": "581696bc537f224b2642ae8080671b02ce2e2fe4e6b7b53ba0d2bcfc805b5b44", + "persistence_sha256": "fcc35b910b68ae40abb887273c2eb429367e2c0948856173e732724217a96c28", "rationale": "The reusable Server migration framework registers actual PowerContext 1.3.0 through s1300 -> s1400 -> s1401 -> s1402 and actual 1.4.0 through the separate immutable r1400 -> s1400 -> s1401 -> s1402 source bridge. Original 1.3 resources and checksums remain byte-identical. Real 1.4.0 SQLite, embedded seekDB and OceanBase structures were captured from the official wheel in all twelve vector/Code configurations; provenance and exact source layouts are packaged with the bridge. The 1.4 bridge preserves current Candidate, Artifact and task data, adds the missing Topic budget/block structure, revokes old lease fences and joins the existing archive/portable-storage path. Empty retained tables align the final schema without inventing 1.3 Candidate history. Source histories retain their identity through subsequent upgrades and recovery; shared head names never authorize adopting unknown layouts. Vector profiles remain bound to historical model, profile ID, dimension, unit normalization and L2 distance, with no provider calls during maintenance. Complete schema, domain, projection, profile and retained-history checks precede startup and partial maintenance. Local SQLite and embedded seekDB startup reject an observable active maintenance lock through a read-only probe. SQLite runtime admission validates the default adjacent journal, rejecting unfinished full or partial maintenance, changed recorded revision checksums and missing required retained tables. Absent journals do not invent retention obligations; custom evidence directories require explicit operator verification and remote coordinator locks remain subject to deployment orchestration. Native read-only readiness uses a consistent snapshot and does not acquire Atomic Memory head locks; normal business transaction isolation is unchanged. Offline projection repairs retain the original locked state comparison and write-side CAS protection. Target locks, backup choices, original recovery evidence and explicit remote acknowledgments remain enforced. The installed-artifact catalog retains twelve 1.3.0 source/backend/vector/Code combinations. Embedded seekDB uses pylibseekdb 1.4.0.post2 or newer with the recovered-transaction lock fix. Main and publication require all twelve configurations, including embedded seekDB full-server-code and full-server-vector-code. No configuration is waived; incomplete, failed or skipped evidence rejects acceptance. The seekdb-code diagnostic selection cannot authorize publication. The registered 1.4.0 bridge remains implemented but is outside the CI upgrade acceptance matrix; passing this gate does not certify that path. The gate preserves source-specific Processing counters and isolates OceanBase source databases. Any failed, skipped or missing automatic case rejects publication. The policy remains upgrade-required until the exact target artifact passes the required automatic matrix and the reviewed support declaration is updated. Captured source structure, local tests and checkpoint replay are distinct from installed-release acceptance, native interruption/observer recovery and backup restoration. The Candidate generation prototype observes actual SQLite/seekDB/OceanBase migration checkpoints outside the production bundle; it does not replace frozen registrations or weaken recovery checks. Existing target schema and data formats, including batched persistence interfaces, remain governed by the shared registered path. The synchronized master adds blank-text validation at Source creation and managed worker logging. It changes neither registered table layout nor migration data encodings; existing stored Source values are not rewritten. The source bridges and frozen history remain unchanged. Compatibility commands identify Server-owned storage before opening a writable legacy profile and delegate source admission to the shared framework, including actual 1.4.0 and rejection of unknown layouts. Partial Processing and Atomic Memory projection maintenance verify the original recorded revision checksums; native admission also validates typed journal identity and its completed position against the database revision. Persisted vector profiles reject unsupported partial maintenance before plans, backups or operation evidence, including when the supplied configuration omits embeddings. These are admission-only changes; frozen migration resources, registered layouts, historical checksums and data transformations remain unchanged. PowerContext 1.4.1 assigns release-based presentation names v1_4_1_001 through v1_4_1_003 to the existing shared upgrade steps; the 1.3.0 and 1.4.0 source baselines remain independently registered. Existing revision IDs, bundle and revision checksums, accepted plan identities and journal schemas are unchanged. New Server revisions use software-version-plus-sequence names directly; software releases without storage changes may reuse the same database revision. Naming does not grant acceptance or replace source registration. Full Server status, plan, apply and verify reject unfinished Processing configuration and Atomic Memory projection journals through the same typed admission check used by partial maintenance and SQLite startup. The owning domain runner can still resume its own journal. This only changes admission; frozen revisions, journal formats, database layouts and data transformations remain unchanged. Revision v1_4_1_004 registers the pc_access_owner_recovery outbox on the shared Server chain for both installed-release sources and all registered vector/Code profiles. It creates an empty table through explicit maintenance for existing managed databases and through the registered bootstrap for fresh databases. Earlier revision resources, fingerprints, checksums and existing domain row encodings remain unchanged. External relationship writers use version-1 Artifact and Candidate intents committed with domain data; replay retains the original deployment, principal, resource or target, and idempotency identity. Stable operation and audit event identities prevent lost acknowledgements from duplicating ownership or audit. Conflicting intents are rejected and unknown historical ownership is not inferred. Unregistered outbox layouts are not adopted merely from a table name. This registration does not establish installed-release or external-Provider upgrade acceptance. The policy remains upgrade-required with the full automatic matrix and publication restrictions described above. The rebased distributed Work Ledger appends revision v1_4_1_005 to the shared Server chain: eight work and coordination tables registered in the manifest with frozen SQLite and MySQL DDL compiled from the current metadata, a schema fingerprint measured by replaying s1300 through v1_4_1_005, a named unique constraint on pc_work_keys.work_id so the frozen layout can register and verify it, and the four durable ledger tables listed in the obloader parent-free restore layer. The two database primitives the ledger claims on (naive-UTC database_now and conflict-tolerant insert_if_absent) are restored beside master's reworked engine layer with their semantics documented. Unregistered Alembic scaffolding that no runner executes is removed. No existing table layout, frozen resource, source bridge or historical checksum changes; the policy remains upgrade-required." }, "cases": [], diff --git a/src/powercontext/builtin/persistence/migrations/server_resources/schemas/mysql-v1_4_1_005.json b/src/powercontext/builtin/persistence/migrations/server_resources/schemas/mysql-v1_4_1_005.json index 89b9895831..90b7bc281d 100644 --- a/src/powercontext/builtin/persistence/migrations/server_resources/schemas/mysql-v1_4_1_005.json +++ b/src/powercontext/builtin/persistence/migrations/server_resources/schemas/mysql-v1_4_1_005.json @@ -4,7 +4,7 @@ "v1_4_1_005": { "statements": [ "CREATE TABLE pc_work_lanes (\n\tlane_key VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, \n\thead_sequence BIGINT, \n\tnext_sequence BIGINT NOT NULL, \n\tPRIMARY KEY (lane_key), \n\tCONSTRAINT ck_pc_work_lanes_head_positive CHECK (head_sequence IS NULL OR head_sequence > 0), \n\tCONSTRAINT ck_pc_work_lanes_next_positive CHECK (next_sequence > 0)\n)", - "CREATE TABLE pc_work_items (\n\twork_id VARCHAR(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, \n\tlogical_key VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, \n\tlane_key VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, \n\tlane_sequence BIGINT NOT NULL, \n\tkind VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, \n\tpayload_version INTEGER NOT NULL, \n\tscope_id VARCHAR(256) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, \n\tpayload MEDIUMBLOB NOT NULL, \n\tstatus VARCHAR(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, \n\tavailable_at DATETIME(6) NOT NULL, \n\tlease_owner VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin, \n\tlease_fence BIGINT NOT NULL, \n\tlease_expires_at DATETIME(6), \n\tattempt_count INTEGER NOT NULL, \n\tgeneration_attempt_count INTEGER NOT NULL, \n\trecovery_generation INTEGER NOT NULL, \n\tmax_attempts INTEGER NOT NULL, \n\tcancel_requested BOOL NOT NULL, \n\tresult_code VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin, \n\tresult_payload MEDIUMBLOB, \n\terror_category VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin, \n\terror_code VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin, \n\tstate_version BIGINT NOT NULL, \n\tcreated_at DATETIME(6) NOT NULL, \n\tupdated_at DATETIME(6) NOT NULL, \n\tcompleted_at DATETIME(6), \n\tPRIMARY KEY (work_id), \n\tCONSTRAINT uq_pc_work_items_lane_sequence UNIQUE (lane_key, lane_sequence), \n\tCONSTRAINT ck_pc_work_items_status CHECK (status IN ('queued', 'running', 'retry_wait', 'cancelling', 'succeeded', 'failed', 'cancelled')), \n\tCONSTRAINT ck_pc_work_items_lane_sequence_positive CHECK (lane_sequence > 0), \n\tCONSTRAINT ck_pc_work_items_payload_version_positive CHECK (payload_version > 0), \n\tCONSTRAINT ck_pc_work_items_fence_nonnegative CHECK (lease_fence >= 0), \n\tCONSTRAINT ck_pc_work_items_attempts_nonnegative CHECK (attempt_count >= 0), \n\tCONSTRAINT ck_pc_work_items_generation_attempts_nonnegative CHECK (generation_attempt_count >= 0), \n\tCONSTRAINT ck_pc_work_items_recovery_nonnegative CHECK (recovery_generation >= 0), \n\tCONSTRAINT ck_pc_work_items_max_attempts_positive CHECK (max_attempts > 0), \n\tCONSTRAINT ck_pc_work_items_state_version_positive CHECK (state_version > 0)\n)", + "CREATE TABLE pc_work_items (\n\twork_id VARCHAR(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, \n\tlogical_key VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, \n\tlane_key VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, \n\tlane_sequence BIGINT NOT NULL, \n\tkind VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, \n\tpayload_version INTEGER NOT NULL, \n\tscope_id VARCHAR(256) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, \n\tpayload MEDIUMBLOB NOT NULL, \n\tstatus VARCHAR(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, \n\tavailable_at DATETIME(6) NOT NULL, \n\tlease_owner VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin, \n\tlease_fence BIGINT NOT NULL, \n\tlease_expires_at DATETIME(6), \n\tattempt_count INTEGER NOT NULL, \n\tgeneration_attempt_count INTEGER NOT NULL, \n\trecovery_generation INTEGER NOT NULL, \n\tmax_attempts INTEGER NOT NULL, \n\tcancel_requested TINYINT(1) NOT NULL, \n\tresult_code VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin, \n\tresult_payload MEDIUMBLOB, \n\terror_category VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin, \n\terror_code VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin, \n\tstate_version BIGINT NOT NULL, \n\tcreated_at DATETIME(6) NOT NULL, \n\tupdated_at DATETIME(6) NOT NULL, \n\tcompleted_at DATETIME(6), \n\tPRIMARY KEY (work_id), \n\tCONSTRAINT uq_pc_work_items_lane_sequence UNIQUE (lane_key, lane_sequence), \n\tCONSTRAINT ck_pc_work_items_status CHECK (status IN ('queued', 'running', 'retry_wait', 'cancelling', 'succeeded', 'failed', 'cancelled')), \n\tCONSTRAINT ck_pc_work_items_lane_sequence_positive CHECK (lane_sequence > 0), \n\tCONSTRAINT ck_pc_work_items_payload_version_positive CHECK (payload_version > 0), \n\tCONSTRAINT ck_pc_work_items_fence_nonnegative CHECK (lease_fence >= 0), \n\tCONSTRAINT ck_pc_work_items_attempts_nonnegative CHECK (attempt_count >= 0), \n\tCONSTRAINT ck_pc_work_items_generation_attempts_nonnegative CHECK (generation_attempt_count >= 0), \n\tCONSTRAINT ck_pc_work_items_recovery_nonnegative CHECK (recovery_generation >= 0), \n\tCONSTRAINT ck_pc_work_items_max_attempts_positive CHECK (max_attempts > 0), \n\tCONSTRAINT ck_pc_work_items_state_version_positive CHECK (state_version > 0)\n)", "CREATE INDEX ix_pc_work_items_claim ON pc_work_items (status, available_at)", "CREATE INDEX ix_pc_work_items_completed ON pc_work_items (completed_at)", "CREATE INDEX ix_pc_work_items_lane_status ON pc_work_items (lane_key, status)", @@ -323,8 +323,10 @@ }, "cancel_requested": { "type": [ - "bool", - [] + "tinyint", + [ + "1" + ] ], "nullable": false, "default": null, @@ -832,8 +834,10 @@ }, "cancel_requested": { "type": [ - "bool", - [] + "tinyint", + [ + "1" + ] ], "nullable": false, "default": null, @@ -1356,8 +1360,10 @@ }, "cancel_requested": { "type": [ - "bool", - [] + "tinyint", + [ + "1" + ] ], "nullable": false, "default": null, @@ -1890,8 +1896,10 @@ }, "cancel_requested": { "type": [ - "bool", - [] + "tinyint", + [ + "1" + ] ], "nullable": false, "default": null, @@ -2439,8 +2447,10 @@ }, "cancel_requested": { "type": [ - "bool", - [] + "tinyint", + [ + "1" + ] ], "nullable": false, "default": null, From 3026ca2be29af9061121191ef479f3f24393356a Mon Sep 17 00:00:00 2001 From: JasonDeng <15301580353@163.com> Date: Thu, 29 Oct 2026 20:44:35 +0800 Subject: [PATCH 03/45] test(native): expect the new head revision after the personal-service upgrade The service lifecycle and OceanBase engine-recovery suites asserted that a completed upgrade reports `v1_4_1_004`. The rebased ledger appends `v1_4_1_005`, so those assertions now compare against the actual head. Both jobs failed only on that comparison; the database was already `ready`. --- tests/native/test_oceanbase_engine_recovery.py | 2 +- tests/native/test_personal_service_lifecycle.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/native/test_oceanbase_engine_recovery.py b/tests/native/test_oceanbase_engine_recovery.py index 7f144e8f6b..0503d987f1 100644 --- a/tests/native/test_oceanbase_engine_recovery.py +++ b/tests/native/test_oceanbase_engine_recovery.py @@ -175,7 +175,7 @@ def ping(connection, _identity, _guard): _apply(_full_server_runner(backend), policy="manual") resumed = _full_server_runner(backend, acknowledge_previous_run=token) result = _apply(resumed, policy="manual") - assert result.state == "ready" and result.revision == "v1_4_1_004" and result.readiness_scope == "server" + assert result.state == "ready" and result.revision == "v1_4_1_005" and result.readiness_scope == "server" assert result.backup_state == "user_confirmed" and result.backup_ref == interrupted["manual_ref"] assert resumed.verify().state == "ready" completed = json.loads(journal.read_text()) diff --git a/tests/native/test_personal_service_lifecycle.py b/tests/native/test_personal_service_lifecycle.py index 4e55410c90..568a4c9d45 100644 --- a/tests/native/test_personal_service_lifecycle.py +++ b/tests/native/test_personal_service_lifecycle.py @@ -406,7 +406,7 @@ def test_native_public_migration_switches_an_installed_130_service( if failure in {"startup", "switch"}: assert applied.exit_code == 1, applied.output assert result["error"] == "service_start_failed" - assert result["database_state"] == "ready" and result["revision"] == "v1_4_1_004" + assert result["database_state"] == "ready" and result["revision"] == "v1_4_1_005" assert adapter.manager_state() in {ManagerState.INACTIVE, ManagerState.FAILED} phase = "switching" if failure == "switch" else "start_failed" assert json.loads(controller.maintenance_path.read_text())["phase"] == phase From 9683d25e7b9960e3d800a9fd18fbe00b1e00ce1c Mon Sep 17 00:00:00 2001 From: JasonDeng <15301580353@163.com> Date: Thu, 29 Oct 2026 21:38:53 +0800 Subject: [PATCH 04/45] refactor(migration): name the Work Ledger revision v1_4_1_006 PR #2192 registers `v1_4_1_005` for the atomic memory description revision and opened six days earlier, so it keeps that number. Two open pull requests claiming one revision name would ship two different DDL sets and two different offline fingerprints under a single name, and the winner would depend on merge order. This PR moves to the next free number instead. The chain is otherwise unchanged: `down_revision` stays `v1_4_1_004`, the SQLite fingerprint is unchanged because inventory never contains the revision name, and `release/upgrade-support.json` is re-digested with `scripts/upgrade_release_gate.py::managed_digest`. When #2192 lands first this revision will rebase onto it and point `down_revision` at its `v1_4_1_005`. Verified with `tests/test_migration_revision_names.py`, `test_full_server_migrations.py`, `test_portable_storage_migration.py`, `test_owner_recovery_migration.py`, `test_release140_server_migrations.py` and `test_server_migration_cli.py` (46 passed), plus a full `s1300`-to-head replay that reproduces every recorded fingerprint. --- docs/en/docs/operate/database-migrations.md | 6 +++--- docs/zh/docs/operate/database-migrations.md | 6 +++--- release/upgrade-support.json | 4 ++-- .../migrations/server_resources/manifest.json | 12 ++++++------ .../versions/{v1_4_1_005.py => v1_4_1_006.py} | 2 +- ...sql-v1_4_1_005.json => mysql-v1_4_1_006.json} | 2 +- ...te-v1_4_1_005.json => sqlite-v1_4_1_006.json} | 0 ..._work_ledger.py => v1_4_1_006_work_ledger.py} | 4 ++-- .../server_sources/1.4.0/manifest.json | 12 ++++++------ .../persistence/migrations/vector_profile.py | 6 +++--- .../persistence/test_full_server_migrations.py | 4 ++-- .../persistence/test_mysql_migration_runner.py | 16 ++++++++-------- .../persistence/test_owner_recovery_migration.py | 6 +++--- .../test_portable_storage_migration.py | 6 +++--- .../test_release140_server_migrations.py | 2 +- tests/native/test_oceanbase_engine_recovery.py | 2 +- tests/native/test_personal_service_lifecycle.py | 2 +- tests/test_managed_atomic_maintenance.py | 4 ++-- tests/test_managed_processing_maintenance.py | 2 +- tests/test_migration_revision_names.py | 8 ++++---- tests/test_registered_source_maintenance_cli.py | 2 +- tests/test_server_migration_cli.py | 2 +- 22 files changed, 55 insertions(+), 55 deletions(-) rename src/powercontext/builtin/persistence/migrations/server_resources/mysql/versions/{v1_4_1_005.py => v1_4_1_006.py} (97%) rename src/powercontext/builtin/persistence/migrations/server_resources/schemas/{mysql-v1_4_1_005.json => mysql-v1_4_1_006.json} (99%) rename src/powercontext/builtin/persistence/migrations/server_resources/schemas/{sqlite-v1_4_1_005.json => sqlite-v1_4_1_006.json} (100%) rename src/powercontext/builtin/persistence/migrations/server_resources/versions/{v1_4_1_005_work_ledger.py => v1_4_1_006_work_ledger.py} (97%) diff --git a/docs/en/docs/operate/database-migrations.md b/docs/en/docs/operate/database-migrations.md index 5a4a3bb14b..f195b720d4 100644 --- a/docs/en/docs/operate/database-migrations.md +++ b/docs/en/docs/operate/database-migrations.md @@ -16,8 +16,8 @@ The design follows [RFC #1771](https://github.com/oceanbase/powercontext/pull/17 The full Server framework registers two installed-release sources for the PowerContext 1.4.1 target (stored compatibility IDs shown below): -- **1.3.0:** `s1300 → s1400 → s1401 → s1402 → v1_4_1_004 → v1_4_1_005`. -- **1.4.0:** `r1400 → s1400 → s1401 → s1402 → v1_4_1_004 → v1_4_1_005` through a separate frozen source bridge. +- **1.3.0:** `s1300 → s1400 → s1401 → s1402 → v1_4_1_004 → v1_4_1_006`. +- **1.4.0:** `r1400 → s1400 → s1401 → s1402 → v1_4_1_004 → v1_4_1_006` through a separate frozen source bridge. Application release numbers and schema revisions are distinct. The 1.4.0 release is not the `s1400` checkpoint: its existing Candidate schema is current, but its Topic attempt storage still needs the registered bridge. @@ -51,7 +51,7 @@ maintenance for existing managed storage. Runtime startup does not add the recei Revision `v1_4_1_004` registers `pc_access_owner_recovery` for durable Artifact and Candidate ownership intents. Both installed-release source paths and vector profiles include this table. Existing managed databases require explicit maintenance; fresh databases create the registered empty table. Pending intents retain their original deployment, principal and idempotency identity across Server restarts. Earlier revision resources and checksums remain unchanged. Unregistered database layouts are rejected rather than adopted from a table name alone. This registration does not establish installed-release or external-Provider upgrade acceptance. -Revision `v1_4_1_005` registers the durable Work Ledger and its scheduler coordination tables: `pc_work_lanes`, +Revision `v1_4_1_006` registers the durable Work Ledger and its scheduler coordination tables: `pc_work_lanes`, `pc_work_items`, `pc_work_keys`, `pc_work_attempts`, `pc_scheduler_leases`, `pc_scheduler_scans`, `pc_runtime_members`, and the shared `pc_rate_limit_windows`. Ledger execution is at-least-once behind a monotonic fence token while its commit is at-most-once, so a retried, superseded or cancelled attempt cannot publish a duplicate diff --git a/docs/zh/docs/operate/database-migrations.md b/docs/zh/docs/operate/database-migrations.md index 74fe42f660..c8dd66883b 100644 --- a/docs/zh/docs/operate/database-migrations.md +++ b/docs/zh/docs/operate/database-migrations.md @@ -14,8 +14,8 @@ description: 了解数据库迁移命令、备份策略、服务启停及当前 完整 Server 框架登记了两个真实安装包来源: -- **1.3.0:**`s1300 → s1400 → s1401 → s1402 → v1_4_1_004 → v1_4_1_005`。 -- **1.4.0:**通过独立冻结的来源桥接,执行 `r1400 → s1400 → s1401 → s1402 → v1_4_1_004 → v1_4_1_005`。 +- **1.3.0:**`s1300 → s1400 → s1401 → s1402 → v1_4_1_004 → v1_4_1_006`。 +- **1.4.0:**通过独立冻结的来源桥接,执行 `r1400 → s1400 → s1401 → s1402 → v1_4_1_004 → v1_4_1_006`。 应用发布版本与 schema revision 分别编号。1.4.0 发行包不等于 `s1400` 检查点:其 Candidate 结构已经是当前格式, 但 Topic 尝试状态仍需登记的桥接。两条路径保留既有引用和历史,到达相同的受管目标结构;原有资源摘要分别保留, @@ -44,7 +44,7 @@ payload 身份及待处理 intent。兼容的当前任务原样保留,不套 修订 `v1_4_1_004` 将 `pc_access_owner_recovery` 纳入管理,用于持久保存 Artifact 和 Candidate 的所有权写入意图。两条已登记发布源路径及向量配置均包含此表。已有受管数据库需要显式维护,新数据库创建已登记的空表。待恢复意图在 Server 重启后保留原有 deployment、Principal 与幂等身份;历史修订资源及校验和保持不变。未登记布局不会仅凭表名被自动接纳。此登记不代表已通过安装发布版或外部 Provider 的升级验收。 -修订 `v1_4_1_005` 登记持久化 Work Ledger 及其调度协调表:`pc_work_lanes`、`pc_work_items`、`pc_work_keys`、 +修订 `v1_4_1_006` 登记持久化 Work Ledger 及其调度协调表:`pc_work_lanes`、`pc_work_items`、`pc_work_keys`、 `pc_work_attempts`、`pc_scheduler_leases`、`pc_scheduler_scans`、`pc_runtime_members`,以及共用的 `pc_rate_limit_windows`。账本执行是至少一次语义,并以单调 fence token 保护提交;提交至多一次,因此重试、被 取代或已取消的尝试不会重复发布结果,也不会释放其他 worker 的逻辑键。两条已登记发布源路径及向量配置均包含这些 diff --git a/release/upgrade-support.json b/release/upgrade-support.json index a2e7768fc4..df7b19c6ae 100644 --- a/release/upgrade-support.json +++ b/release/upgrade-support.json @@ -3,8 +3,8 @@ "target_version": "1.4.1", "mode": "upgrade-required", "schema_review": { - "persistence_sha256": "fcc35b910b68ae40abb887273c2eb429367e2c0948856173e732724217a96c28", - "rationale": "The reusable Server migration framework registers actual PowerContext 1.3.0 through s1300 -> s1400 -> s1401 -> s1402 and actual 1.4.0 through the separate immutable r1400 -> s1400 -> s1401 -> s1402 source bridge. Original 1.3 resources and checksums remain byte-identical. Real 1.4.0 SQLite, embedded seekDB and OceanBase structures were captured from the official wheel in all twelve vector/Code configurations; provenance and exact source layouts are packaged with the bridge. The 1.4 bridge preserves current Candidate, Artifact and task data, adds the missing Topic budget/block structure, revokes old lease fences and joins the existing archive/portable-storage path. Empty retained tables align the final schema without inventing 1.3 Candidate history. Source histories retain their identity through subsequent upgrades and recovery; shared head names never authorize adopting unknown layouts. Vector profiles remain bound to historical model, profile ID, dimension, unit normalization and L2 distance, with no provider calls during maintenance. Complete schema, domain, projection, profile and retained-history checks precede startup and partial maintenance. Local SQLite and embedded seekDB startup reject an observable active maintenance lock through a read-only probe. SQLite runtime admission validates the default adjacent journal, rejecting unfinished full or partial maintenance, changed recorded revision checksums and missing required retained tables. Absent journals do not invent retention obligations; custom evidence directories require explicit operator verification and remote coordinator locks remain subject to deployment orchestration. Native read-only readiness uses a consistent snapshot and does not acquire Atomic Memory head locks; normal business transaction isolation is unchanged. Offline projection repairs retain the original locked state comparison and write-side CAS protection. Target locks, backup choices, original recovery evidence and explicit remote acknowledgments remain enforced. The installed-artifact catalog retains twelve 1.3.0 source/backend/vector/Code combinations. Embedded seekDB uses pylibseekdb 1.4.0.post2 or newer with the recovered-transaction lock fix. Main and publication require all twelve configurations, including embedded seekDB full-server-code and full-server-vector-code. No configuration is waived; incomplete, failed or skipped evidence rejects acceptance. The seekdb-code diagnostic selection cannot authorize publication. The registered 1.4.0 bridge remains implemented but is outside the CI upgrade acceptance matrix; passing this gate does not certify that path. The gate preserves source-specific Processing counters and isolates OceanBase source databases. Any failed, skipped or missing automatic case rejects publication. The policy remains upgrade-required until the exact target artifact passes the required automatic matrix and the reviewed support declaration is updated. Captured source structure, local tests and checkpoint replay are distinct from installed-release acceptance, native interruption/observer recovery and backup restoration. The Candidate generation prototype observes actual SQLite/seekDB/OceanBase migration checkpoints outside the production bundle; it does not replace frozen registrations or weaken recovery checks. Existing target schema and data formats, including batched persistence interfaces, remain governed by the shared registered path. The synchronized master adds blank-text validation at Source creation and managed worker logging. It changes neither registered table layout nor migration data encodings; existing stored Source values are not rewritten. The source bridges and frozen history remain unchanged. Compatibility commands identify Server-owned storage before opening a writable legacy profile and delegate source admission to the shared framework, including actual 1.4.0 and rejection of unknown layouts. Partial Processing and Atomic Memory projection maintenance verify the original recorded revision checksums; native admission also validates typed journal identity and its completed position against the database revision. Persisted vector profiles reject unsupported partial maintenance before plans, backups or operation evidence, including when the supplied configuration omits embeddings. These are admission-only changes; frozen migration resources, registered layouts, historical checksums and data transformations remain unchanged. PowerContext 1.4.1 assigns release-based presentation names v1_4_1_001 through v1_4_1_003 to the existing shared upgrade steps; the 1.3.0 and 1.4.0 source baselines remain independently registered. Existing revision IDs, bundle and revision checksums, accepted plan identities and journal schemas are unchanged. New Server revisions use software-version-plus-sequence names directly; software releases without storage changes may reuse the same database revision. Naming does not grant acceptance or replace source registration. Full Server status, plan, apply and verify reject unfinished Processing configuration and Atomic Memory projection journals through the same typed admission check used by partial maintenance and SQLite startup. The owning domain runner can still resume its own journal. This only changes admission; frozen revisions, journal formats, database layouts and data transformations remain unchanged. Revision v1_4_1_004 registers the pc_access_owner_recovery outbox on the shared Server chain for both installed-release sources and all registered vector/Code profiles. It creates an empty table through explicit maintenance for existing managed databases and through the registered bootstrap for fresh databases. Earlier revision resources, fingerprints, checksums and existing domain row encodings remain unchanged. External relationship writers use version-1 Artifact and Candidate intents committed with domain data; replay retains the original deployment, principal, resource or target, and idempotency identity. Stable operation and audit event identities prevent lost acknowledgements from duplicating ownership or audit. Conflicting intents are rejected and unknown historical ownership is not inferred. Unregistered outbox layouts are not adopted merely from a table name. This registration does not establish installed-release or external-Provider upgrade acceptance. The policy remains upgrade-required with the full automatic matrix and publication restrictions described above. The rebased distributed Work Ledger appends revision v1_4_1_005 to the shared Server chain: eight work and coordination tables registered in the manifest with frozen SQLite and MySQL DDL compiled from the current metadata, a schema fingerprint measured by replaying s1300 through v1_4_1_005, a named unique constraint on pc_work_keys.work_id so the frozen layout can register and verify it, and the four durable ledger tables listed in the obloader parent-free restore layer. The two database primitives the ledger claims on (naive-UTC database_now and conflict-tolerant insert_if_absent) are restored beside master's reworked engine layer with their semantics documented. Unregistered Alembic scaffolding that no runner executes is removed. No existing table layout, frozen resource, source bridge or historical checksum changes; the policy remains upgrade-required." + "persistence_sha256": "96fc678e14fa0cc85ee6668169b860bd345d0bc40f7f31e11c82291d5fdfaf56", + "rationale": "The reusable Server migration framework registers actual PowerContext 1.3.0 through s1300 -> s1400 -> s1401 -> s1402 and actual 1.4.0 through the separate immutable r1400 -> s1400 -> s1401 -> s1402 source bridge. Original 1.3 resources and checksums remain byte-identical. Real 1.4.0 SQLite, embedded seekDB and OceanBase structures were captured from the official wheel in all twelve vector/Code configurations; provenance and exact source layouts are packaged with the bridge. The 1.4 bridge preserves current Candidate, Artifact and task data, adds the missing Topic budget/block structure, revokes old lease fences and joins the existing archive/portable-storage path. Empty retained tables align the final schema without inventing 1.3 Candidate history. Source histories retain their identity through subsequent upgrades and recovery; shared head names never authorize adopting unknown layouts. Vector profiles remain bound to historical model, profile ID, dimension, unit normalization and L2 distance, with no provider calls during maintenance. Complete schema, domain, projection, profile and retained-history checks precede startup and partial maintenance. Local SQLite and embedded seekDB startup reject an observable active maintenance lock through a read-only probe. SQLite runtime admission validates the default adjacent journal, rejecting unfinished full or partial maintenance, changed recorded revision checksums and missing required retained tables. Absent journals do not invent retention obligations; custom evidence directories require explicit operator verification and remote coordinator locks remain subject to deployment orchestration. Native read-only readiness uses a consistent snapshot and does not acquire Atomic Memory head locks; normal business transaction isolation is unchanged. Offline projection repairs retain the original locked state comparison and write-side CAS protection. Target locks, backup choices, original recovery evidence and explicit remote acknowledgments remain enforced. The installed-artifact catalog retains twelve 1.3.0 source/backend/vector/Code combinations. Embedded seekDB uses pylibseekdb 1.4.0.post2 or newer with the recovered-transaction lock fix. Main and publication require all twelve configurations, including embedded seekDB full-server-code and full-server-vector-code. No configuration is waived; incomplete, failed or skipped evidence rejects acceptance. The seekdb-code diagnostic selection cannot authorize publication. The registered 1.4.0 bridge remains implemented but is outside the CI upgrade acceptance matrix; passing this gate does not certify that path. The gate preserves source-specific Processing counters and isolates OceanBase source databases. Any failed, skipped or missing automatic case rejects publication. The policy remains upgrade-required until the exact target artifact passes the required automatic matrix and the reviewed support declaration is updated. Captured source structure, local tests and checkpoint replay are distinct from installed-release acceptance, native interruption/observer recovery and backup restoration. The Candidate generation prototype observes actual SQLite/seekDB/OceanBase migration checkpoints outside the production bundle; it does not replace frozen registrations or weaken recovery checks. Existing target schema and data formats, including batched persistence interfaces, remain governed by the shared registered path. The synchronized master adds blank-text validation at Source creation and managed worker logging. It changes neither registered table layout nor migration data encodings; existing stored Source values are not rewritten. The source bridges and frozen history remain unchanged. Compatibility commands identify Server-owned storage before opening a writable legacy profile and delegate source admission to the shared framework, including actual 1.4.0 and rejection of unknown layouts. Partial Processing and Atomic Memory projection maintenance verify the original recorded revision checksums; native admission also validates typed journal identity and its completed position against the database revision. Persisted vector profiles reject unsupported partial maintenance before plans, backups or operation evidence, including when the supplied configuration omits embeddings. These are admission-only changes; frozen migration resources, registered layouts, historical checksums and data transformations remain unchanged. PowerContext 1.4.1 assigns release-based presentation names v1_4_1_001 through v1_4_1_003 to the existing shared upgrade steps; the 1.3.0 and 1.4.0 source baselines remain independently registered. Existing revision IDs, bundle and revision checksums, accepted plan identities and journal schemas are unchanged. New Server revisions use software-version-plus-sequence names directly; software releases without storage changes may reuse the same database revision. Naming does not grant acceptance or replace source registration. Full Server status, plan, apply and verify reject unfinished Processing configuration and Atomic Memory projection journals through the same typed admission check used by partial maintenance and SQLite startup. The owning domain runner can still resume its own journal. This only changes admission; frozen revisions, journal formats, database layouts and data transformations remain unchanged. Revision v1_4_1_004 registers the pc_access_owner_recovery outbox on the shared Server chain for both installed-release sources and all registered vector/Code profiles. It creates an empty table through explicit maintenance for existing managed databases and through the registered bootstrap for fresh databases. Earlier revision resources, fingerprints, checksums and existing domain row encodings remain unchanged. External relationship writers use version-1 Artifact and Candidate intents committed with domain data; replay retains the original deployment, principal, resource or target, and idempotency identity. Stable operation and audit event identities prevent lost acknowledgements from duplicating ownership or audit. Conflicting intents are rejected and unknown historical ownership is not inferred. Unregistered outbox layouts are not adopted merely from a table name. This registration does not establish installed-release or external-Provider upgrade acceptance. The policy remains upgrade-required with the full automatic matrix and publication restrictions described above. The rebased distributed Work Ledger appends revision v1_4_1_006 to the shared Server chain: eight work and coordination tables registered in the manifest with frozen SQLite and MySQL DDL compiled from the current metadata, a schema fingerprint measured by replaying s1300 through v1_4_1_006, a named unique constraint on pc_work_keys.work_id so the frozen layout can register and verify it, and the four durable ledger tables listed in the obloader parent-free restore layer. The two database primitives the ledger claims on (naive-UTC database_now and conflict-tolerant insert_if_absent) are restored beside master's reworked engine layer with their semantics documented. Unregistered Alembic scaffolding that no runner executes is removed. No existing table layout, frozen resource, source bridge or historical checksum changes; the policy remains upgrade-required." }, "cases": [], "automatic_sources": { diff --git a/src/powercontext/builtin/persistence/migrations/server_resources/manifest.json b/src/powercontext/builtin/persistence/migrations/server_resources/manifest.json index 5d5399a8f3..eafafbb507 100644 --- a/src/powercontext/builtin/persistence/migrations/server_resources/manifest.json +++ b/src/powercontext/builtin/persistence/migrations/server_resources/manifest.json @@ -25,9 +25,9 @@ "schemas/sqlite-v1_4_1_004.json", "schemas/mysql-v1_4_1_004.json" ], - "v1_4_1_005": [ - "schemas/sqlite-v1_4_1_005.json", - "schemas/mysql-v1_4_1_005.json" + "v1_4_1_006": [ + "schemas/sqlite-v1_4_1_006.json", + "schemas/mysql-v1_4_1_006.json" ] }, "sqlite_baselines": { @@ -49,7 +49,7 @@ "v1_4_1_004": [ "84d736b5e722a56ebe8a32318732543d43548a63a43709dbf0c571e7e4af83b5" ], - "v1_4_1_005": [ + "v1_4_1_006": [ "92eaad28512ba8a03917acdd9a0996523a67450092e03161eca68e4e0f9d4cb6" ] }, @@ -211,7 +211,7 @@ "server_upgrade", "portable_storage" ], - "v1_4_1_005": [ + "v1_4_1_006": [ "server_upgrade", "portable_storage" ] @@ -230,7 +230,7 @@ "schemas/mysql-s1401.json", "schemas/mysql-s1402.json", "schemas/mysql-v1_4_1_004.json", - "schemas/mysql-v1_4_1_005.json" + "schemas/mysql-v1_4_1_006.json" ], "mysql_history": "mysql", "mysql_schema_extensions": { diff --git a/src/powercontext/builtin/persistence/migrations/server_resources/mysql/versions/v1_4_1_005.py b/src/powercontext/builtin/persistence/migrations/server_resources/mysql/versions/v1_4_1_006.py similarity index 97% rename from src/powercontext/builtin/persistence/migrations/server_resources/mysql/versions/v1_4_1_005.py rename to src/powercontext/builtin/persistence/migrations/server_resources/mysql/versions/v1_4_1_006.py index 0ca30e1afa..dd9efd78b3 100644 --- a/src/powercontext/builtin/persistence/migrations/server_resources/mysql/versions/v1_4_1_005.py +++ b/src/powercontext/builtin/persistence/migrations/server_resources/mysql/versions/v1_4_1_006.py @@ -21,7 +21,7 @@ from alembic import context -revision = "v1_4_1_005" +revision = "v1_4_1_006" down_revision = "v1_4_1_004" diff --git a/src/powercontext/builtin/persistence/migrations/server_resources/schemas/mysql-v1_4_1_005.json b/src/powercontext/builtin/persistence/migrations/server_resources/schemas/mysql-v1_4_1_006.json similarity index 99% rename from src/powercontext/builtin/persistence/migrations/server_resources/schemas/mysql-v1_4_1_005.json rename to src/powercontext/builtin/persistence/migrations/server_resources/schemas/mysql-v1_4_1_006.json index 90b7bc281d..e4ceb1fbda 100644 --- a/src/powercontext/builtin/persistence/migrations/server_resources/schemas/mysql-v1_4_1_005.json +++ b/src/powercontext/builtin/persistence/migrations/server_resources/schemas/mysql-v1_4_1_006.json @@ -1,7 +1,7 @@ { "format": "powercontext.mysql-state-layouts.v1", "revisions": { - "v1_4_1_005": { + "v1_4_1_006": { "statements": [ "CREATE TABLE pc_work_lanes (\n\tlane_key VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, \n\thead_sequence BIGINT, \n\tnext_sequence BIGINT NOT NULL, \n\tPRIMARY KEY (lane_key), \n\tCONSTRAINT ck_pc_work_lanes_head_positive CHECK (head_sequence IS NULL OR head_sequence > 0), \n\tCONSTRAINT ck_pc_work_lanes_next_positive CHECK (next_sequence > 0)\n)", "CREATE TABLE pc_work_items (\n\twork_id VARCHAR(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, \n\tlogical_key VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, \n\tlane_key VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, \n\tlane_sequence BIGINT NOT NULL, \n\tkind VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, \n\tpayload_version INTEGER NOT NULL, \n\tscope_id VARCHAR(256) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, \n\tpayload MEDIUMBLOB NOT NULL, \n\tstatus VARCHAR(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, \n\tavailable_at DATETIME(6) NOT NULL, \n\tlease_owner VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin, \n\tlease_fence BIGINT NOT NULL, \n\tlease_expires_at DATETIME(6), \n\tattempt_count INTEGER NOT NULL, \n\tgeneration_attempt_count INTEGER NOT NULL, \n\trecovery_generation INTEGER NOT NULL, \n\tmax_attempts INTEGER NOT NULL, \n\tcancel_requested TINYINT(1) NOT NULL, \n\tresult_code VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin, \n\tresult_payload MEDIUMBLOB, \n\terror_category VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin, \n\terror_code VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin, \n\tstate_version BIGINT NOT NULL, \n\tcreated_at DATETIME(6) NOT NULL, \n\tupdated_at DATETIME(6) NOT NULL, \n\tcompleted_at DATETIME(6), \n\tPRIMARY KEY (work_id), \n\tCONSTRAINT uq_pc_work_items_lane_sequence UNIQUE (lane_key, lane_sequence), \n\tCONSTRAINT ck_pc_work_items_status CHECK (status IN ('queued', 'running', 'retry_wait', 'cancelling', 'succeeded', 'failed', 'cancelled')), \n\tCONSTRAINT ck_pc_work_items_lane_sequence_positive CHECK (lane_sequence > 0), \n\tCONSTRAINT ck_pc_work_items_payload_version_positive CHECK (payload_version > 0), \n\tCONSTRAINT ck_pc_work_items_fence_nonnegative CHECK (lease_fence >= 0), \n\tCONSTRAINT ck_pc_work_items_attempts_nonnegative CHECK (attempt_count >= 0), \n\tCONSTRAINT ck_pc_work_items_generation_attempts_nonnegative CHECK (generation_attempt_count >= 0), \n\tCONSTRAINT ck_pc_work_items_recovery_nonnegative CHECK (recovery_generation >= 0), \n\tCONSTRAINT ck_pc_work_items_max_attempts_positive CHECK (max_attempts > 0), \n\tCONSTRAINT ck_pc_work_items_state_version_positive CHECK (state_version > 0)\n)", diff --git a/src/powercontext/builtin/persistence/migrations/server_resources/schemas/sqlite-v1_4_1_005.json b/src/powercontext/builtin/persistence/migrations/server_resources/schemas/sqlite-v1_4_1_006.json similarity index 100% rename from src/powercontext/builtin/persistence/migrations/server_resources/schemas/sqlite-v1_4_1_005.json rename to src/powercontext/builtin/persistence/migrations/server_resources/schemas/sqlite-v1_4_1_006.json diff --git a/src/powercontext/builtin/persistence/migrations/server_resources/versions/v1_4_1_005_work_ledger.py b/src/powercontext/builtin/persistence/migrations/server_resources/versions/v1_4_1_006_work_ledger.py similarity index 97% rename from src/powercontext/builtin/persistence/migrations/server_resources/versions/v1_4_1_005_work_ledger.py rename to src/powercontext/builtin/persistence/migrations/server_resources/versions/v1_4_1_006_work_ledger.py index 8f41bbe770..6659cf6c63 100644 --- a/src/powercontext/builtin/persistence/migrations/server_resources/versions/v1_4_1_005_work_ledger.py +++ b/src/powercontext/builtin/persistence/migrations/server_resources/versions/v1_4_1_006_work_ledger.py @@ -25,12 +25,12 @@ from alembic import op -revision = "v1_4_1_005" +revision = "v1_4_1_006" down_revision = "v1_4_1_004" def upgrade(): - resource = Path(__file__).parents[1] / "schemas/sqlite-v1_4_1_005.json" + resource = Path(__file__).parents[1] / "schemas/sqlite-v1_4_1_006.json" connection = op.get_bind() for statement in json.loads(resource.read_text())["create"]: connection.exec_driver_sql(statement) diff --git a/src/powercontext/builtin/persistence/migrations/server_sources/1.4.0/manifest.json b/src/powercontext/builtin/persistence/migrations/server_sources/1.4.0/manifest.json index 945d2754cb..20444099fb 100644 --- a/src/powercontext/builtin/persistence/migrations/server_sources/1.4.0/manifest.json +++ b/src/powercontext/builtin/persistence/migrations/server_sources/1.4.0/manifest.json @@ -24,9 +24,9 @@ "schemas/sqlite-v1_4_1_004.json", "schemas/mysql-v1_4_1_004.json" ], - "v1_4_1_005": [ - "schemas/sqlite-v1_4_1_005.json", - "schemas/mysql-v1_4_1_005.json" + "v1_4_1_006": [ + "schemas/sqlite-v1_4_1_006.json", + "schemas/mysql-v1_4_1_006.json" ] }, "sqlite_baselines": { @@ -48,7 +48,7 @@ "v1_4_1_004": [ "84d736b5e722a56ebe8a32318732543d43548a63a43709dbf0c571e7e4af83b5" ], - "v1_4_1_005": [ + "v1_4_1_006": [ "92eaad28512ba8a03917acdd9a0996523a67450092e03161eca68e4e0f9d4cb6" ] }, @@ -215,7 +215,7 @@ "server_upgrade", "portable_storage" ], - "v1_4_1_005": [ + "v1_4_1_006": [ "server_upgrade", "portable_storage" ] @@ -234,7 +234,7 @@ "schemas/mysql-s1401.json", "schemas/mysql-s1402.json", "schemas/mysql-v1_4_1_004.json", - "schemas/mysql-v1_4_1_005.json" + "schemas/mysql-v1_4_1_006.json" ], "mysql_history": "mysql", "mysql_schema_extensions": { diff --git a/src/powercontext/builtin/persistence/migrations/vector_profile.py b/src/powercontext/builtin/persistence/migrations/vector_profile.py index afeb9b28a0..f03113282d 100644 --- a/src/powercontext/builtin/persistence/migrations/vector_profile.py +++ b/src/powercontext/builtin/persistence/migrations/vector_profile.py @@ -85,9 +85,9 @@ def sqlite_layouts(self, directory: Path) -> dict[str, list[list[Any]]]: layouts["v1_4_1_004"] = layouts["s1402"] + [ ["table", "pc_access_owner_recovery", "pc_access_owner_recovery", owner] ] - if "v1_4_1_005" in manifest["revision_resources"]: - ledger = json.loads((directory / "schemas/sqlite-v1_4_1_005.json").read_text(encoding="utf-8"))["create"] - layouts["v1_4_1_005"] = layouts["v1_4_1_004"] + [registry_row(statement) for statement in ledger] + if "v1_4_1_006" in manifest["revision_resources"]: + ledger = json.loads((directory / "schemas/sqlite-v1_4_1_006.json").read_text(encoding="utf-8"))["create"] + layouts["v1_4_1_006"] = layouts["v1_4_1_004"] + [registry_row(statement) for statement in ledger] for entries in layouts.values(): entries.sort(key=lambda row: (row[0], row[1])) return layouts diff --git a/tests/builtin/persistence/test_full_server_migrations.py b/tests/builtin/persistence/test_full_server_migrations.py index fcdb7c7529..c8d7e304f3 100644 --- a/tests/builtin/persistence/test_full_server_migrations.py +++ b/tests/builtin/persistence/test_full_server_migrations.py @@ -73,8 +73,8 @@ def dump(path): def test_complete_upgrade_preserves_history_backup_and_repeated_apply(source): before = dump(source.database) plan = source.plan() - assert plan.source_revision == "s1300" and plan.target_revision == "v1_4_1_005" - assert plan.revisions == ("s1400", "s1401", "s1402", "v1_4_1_004", "v1_4_1_005") + assert plan.source_revision == "s1300" and plan.target_revision == "v1_4_1_006" + assert plan.revisions == ("s1400", "s1401", "s1402", "v1_4_1_004", "v1_4_1_006") assert dump(source.database) == before result = apply(source) assert result.readiness_scope == "server" and result.backup_state == "completed" diff --git a/tests/builtin/persistence/test_mysql_migration_runner.py b/tests/builtin/persistence/test_mysql_migration_runner.py index e66ca5ec1a..0357bbcf43 100644 --- a/tests/builtin/persistence/test_mysql_migration_runner.py +++ b/tests/builtin/persistence/test_mysql_migration_runner.py @@ -561,7 +561,7 @@ def test_oceanbase_server_upgrade_with_separate_code_cache_preserves_history( ) assert runner.plan(backup_policy="manual").source_revision == "s1300" result = _apply(runner, policy="manual") - assert result.readiness_scope == "server" and result.revision == "v1_4_1_005" + assert result.readiness_scope == "server" and result.revision == "v1_4_1_006" assert runner.verify().state == "ready" prepare_server_storage(settings) assert not _apply(runner, policy="manual").changed @@ -692,7 +692,7 @@ def inspect_history(connection, identity, guard): ).scalar_one(), ) - assert backend.connections().run(inspect_history) == ("v1_4_1_005", "dedicated", b'{"history":"unchanged"}') + assert backend.connections().run(inspect_history) == ("v1_4_1_006", "dedicated", b'{"history":"unchanged"}') (main_path,) = backend.evidence_directory.glob("*/maintenance.json") evidence = json.loads(main_path.read_text()) @@ -702,7 +702,7 @@ def inspect_history(connection, identity, guard): for inspect in (runner.plan, runner.verify, runner.apply): with pytest.raises(MigrationError, match="checksum_conflict"): inspect() - assert backend.connections().run(inspect_history) == ("v1_4_1_005", "dedicated", b'{"history":"unchanged"}') + assert backend.connections().run(inspect_history) == ("v1_4_1_006", "dedicated", b'{"history":"unchanged"}') assert { path: path.read_bytes() for path in backend.evidence_directory.rglob("maintenance.json") } == original_evidence @@ -769,7 +769,7 @@ def inspect_history(connection, identity, guard): ).scalar_one(), ) - assert backend.connections().run(inspect_history) == ("v1_4_1_005", b'{"history":"unchanged"}') + assert backend.connections().run(inspect_history) == ("v1_4_1_006", b'{"history":"unchanged"}') def test_mysql_registered_projection_repair_preserves_authority_and_revision(backend: _BackendTarget) -> None: @@ -837,7 +837,7 @@ def inspect_content(connection, identity, guard): ).scalar_one(), ) - assert backend.connections().run(inspect_content) == ("v1_4_1_005", content, "Preserve immutable nebula evidence.") + assert backend.connections().run(inspect_content) == ("v1_4_1_006", content, "Preserve immutable nebula evidence.") @pytest.mark.parametrize("origin", ("empty", "released")) @@ -992,7 +992,7 @@ def test_mysql_full_server_interruption_preserves_history_and_original_maintenan _load_full_server_baseline(backend) runner = _full_server_runner(backend) original = runner.plan(backup_policy="manual") - assert original.source_revision == "s1300" and original.target_revision == "v1_4_1_005" + assert original.source_revision == "s1300" and original.target_revision == "v1_4_1_006" _interrupt_after_ddl(runner, interruption) journals = list(backend.evidence_directory.rglob("maintenance.json")) assert len(journals) == 1 @@ -1014,7 +1014,7 @@ def test_mysql_full_server_interruption_preserves_history_and_original_maintenan result = _apply(resumed, policy="manual") - assert result.readiness_scope == "server" and result.revision == "v1_4_1_005" + assert result.readiness_scope == "server" and result.revision == "v1_4_1_006" assert result.backup_state == "user_confirmed" assert result.backup_ref == interrupted["manual_ref"] assert resumed.verify().state == "ready" @@ -1094,7 +1094,7 @@ def client_active(connection: Connection | None, _identity: BackendIdentity, _gu # interruption. An arbitrary remote failure cannot be acknowledged blindly. resumed = _full_server_runner(backend, acknowledge_previous_run=token) result = _apply(resumed, policy="manual") - assert result.state == "ready" and result.readiness_scope == "server" and result.revision == "v1_4_1_005" + assert result.state == "ready" and result.readiness_scope == "server" and result.revision == "v1_4_1_006" assert result.backup_state == "user_confirmed" and result.backup_ref == interrupted["manual_ref"] assert resumed.verify().state == "ready" completed = json.loads(journals[0].read_text(encoding="utf-8")) diff --git a/tests/builtin/persistence/test_owner_recovery_migration.py b/tests/builtin/persistence/test_owner_recovery_migration.py index a9fe8e0ce0..86b4f466b0 100644 --- a/tests/builtin/persistence/test_owner_recovery_migration.py +++ b/tests/builtin/persistence/test_owner_recovery_migration.py @@ -44,7 +44,7 @@ def test_registered_owner_recovery_upgrade_preserves_history_and_revision_checks shutil.copytree(current.directory, directory, ignore=shutil.ignore_patterns("__pycache__")) manifest_path = directory / "manifest.json" manifest = json.loads(manifest_path.read_text(encoding="utf-8")) - drop_revision(directory, manifest, "v1_4_1_005", tables=LEDGER_TABLES) + drop_revision(directory, manifest, "v1_4_1_006", tables=LEDGER_TABLES) drop_revision(directory, manifest, "v1_4_1_004", tables=("pc_access_owner_recovery",)) manifest_path.write_text(json.dumps(manifest), encoding="utf-8") previous = MigrationBundle(directory, embedding_profile=embedding) @@ -59,8 +59,8 @@ def test_registered_owner_recovery_upgrade_preserves_history_and_revision_checks original_evidence = json.loads(old.evidence_path.read_text(encoding="utf-8")) upgraded = SQLiteMigrationRunner(database, current, configuration=configuration(embedding)) plan = upgraded.plan() - assert plan.source_revision == "s1402" and plan.revisions == ("v1_4_1_004", "v1_4_1_005") - assert apply(upgraded).revision == "v1_4_1_005" + assert plan.source_revision == "s1402" and plan.revisions == ("v1_4_1_004", "v1_4_1_006") + assert apply(upgraded).revision == "v1_4_1_006" assert upgraded.verify().state == "ready" assert not apply(upgraded).changed evidence = json.loads(upgraded.evidence_path.read_text(encoding="utf-8")) diff --git a/tests/builtin/persistence/test_portable_storage_migration.py b/tests/builtin/persistence/test_portable_storage_migration.py index ed7fd511aa..5130965590 100644 --- a/tests/builtin/persistence/test_portable_storage_migration.py +++ b/tests/builtin/persistence/test_portable_storage_migration.py @@ -62,7 +62,7 @@ def previous_bundle(directory, embedding): shutil.copytree(current.directory, directory, ignore=shutil.ignore_patterns("__pycache__")) manifest_path = directory / "manifest.json" manifest = json.loads(manifest_path.read_text()) - drop_revision(directory, manifest, "v1_4_1_005", tables=LEDGER_TABLES) + drop_revision(directory, manifest, "v1_4_1_006", tables=LEDGER_TABLES) drop_revision(directory, manifest, "v1_4_1_004", tables=("pc_access_owner_recovery",)) drop_revision(directory, manifest, "s1402", tables=("pc_portable_restore_receipts",)) manifest_path.write_text(json.dumps(manifest), encoding="utf-8") @@ -108,8 +108,8 @@ def test_registered_s1401_upgrade_preserves_completed_evidence_and_matches_fresh upgraded = SQLiteMigrationRunner(database, bundle, configuration=config) plan = upgraded.plan() assert plan.source_revision == "s1401" - assert plan.revisions == ("s1402", "v1_4_1_004", "v1_4_1_005") - assert apply(upgraded).revision == "v1_4_1_005" + assert plan.revisions == ("s1402", "v1_4_1_004", "v1_4_1_006") + assert apply(upgraded).revision == "v1_4_1_006" assert upgraded.verify().state == "ready" assert not apply(upgraded).changed fresh = SQLiteMigrationRunner(tmp_path / "fresh.db", bundle, configuration=config) diff --git a/tests/builtin/persistence/test_release140_server_migrations.py b/tests/builtin/persistence/test_release140_server_migrations.py index 9d3743b7ad..04dffb6cda 100644 --- a/tests/builtin/persistence/test_release140_server_migrations.py +++ b/tests/builtin/persistence/test_release140_server_migrations.py @@ -134,7 +134,7 @@ def test_registered_sources_choose_distinct_lineages(tmp_path, release, expected _settings, _embedding, runner = _source(tmp_path, release=release) plan = runner.plan() assert plan.source_revision == expected - assert plan.revisions == ("s1400", "s1401", "s1402", "v1_4_1_004", "v1_4_1_005") + assert plan.revisions == ("s1400", "s1401", "s1402", "v1_4_1_004", "v1_4_1_006") @pytest.mark.parametrize(("vectors", "code"), [(False, False), (True, False), (False, True), (True, True)]) diff --git a/tests/native/test_oceanbase_engine_recovery.py b/tests/native/test_oceanbase_engine_recovery.py index 0503d987f1..298d731c1a 100644 --- a/tests/native/test_oceanbase_engine_recovery.py +++ b/tests/native/test_oceanbase_engine_recovery.py @@ -175,7 +175,7 @@ def ping(connection, _identity, _guard): _apply(_full_server_runner(backend), policy="manual") resumed = _full_server_runner(backend, acknowledge_previous_run=token) result = _apply(resumed, policy="manual") - assert result.state == "ready" and result.revision == "v1_4_1_005" and result.readiness_scope == "server" + assert result.state == "ready" and result.revision == "v1_4_1_006" and result.readiness_scope == "server" assert result.backup_state == "user_confirmed" and result.backup_ref == interrupted["manual_ref"] assert resumed.verify().state == "ready" completed = json.loads(journal.read_text()) diff --git a/tests/native/test_personal_service_lifecycle.py b/tests/native/test_personal_service_lifecycle.py index 568a4c9d45..0c17d121be 100644 --- a/tests/native/test_personal_service_lifecycle.py +++ b/tests/native/test_personal_service_lifecycle.py @@ -406,7 +406,7 @@ def test_native_public_migration_switches_an_installed_130_service( if failure in {"startup", "switch"}: assert applied.exit_code == 1, applied.output assert result["error"] == "service_start_failed" - assert result["database_state"] == "ready" and result["revision"] == "v1_4_1_005" + assert result["database_state"] == "ready" and result["revision"] == "v1_4_1_006" assert adapter.manager_state() in {ManagerState.INACTIVE, ManagerState.FAILED} phase = "switching" if failure == "switch" else "start_failed" assert json.loads(controller.maintenance_path.read_text())["phase"] == phase diff --git a/tests/test_managed_atomic_maintenance.py b/tests/test_managed_atomic_maintenance.py index 17dcdcf851..473d68eabe 100644 --- a/tests/test_managed_atomic_maintenance.py +++ b/tests/test_managed_atomic_maintenance.py @@ -76,7 +76,7 @@ def test_atomic_alias_upgrades_complete_historical_server_with_shared_plan_and_b assert inspected.exit_code == 0, inspected.output plan = json.loads(inspected.output) assert plan["source_revision"] == "s1300" - assert plan["target_revision"] == "v1_4_1_005" + assert plan["target_revision"] == "v1_4_1_006" assert plan["readiness_scope"] == "server" assert historical.read_bytes() == original @@ -103,7 +103,7 @@ def test_atomic_alias_upgrades_complete_historical_server_with_shared_plan_and_b with closing(sqlite3.connect(result["backup_ref"])) as connection: assert tuple(connection.iterdump()) == original_dump with closing(sqlite3.connect(historical)) as connection: - assert connection.execute("SELECT version_num FROM pc_schema_revision").fetchall() == [("v1_4_1_005",)] + assert connection.execute("SELECT version_num FROM pc_schema_revision").fetchall() == [("v1_4_1_006",)] assert connection.execute("SELECT content FROM pc_artifacts").fetchall() == [(b'{"history":"unchanged"}',)] verified = CliRunner().invoke(cli, [*arguments, "--action", "verify"]) assert verified.exit_code == 0, verified.output diff --git a/tests/test_managed_processing_maintenance.py b/tests/test_managed_processing_maintenance.py index 76aa1849d9..806d1555b9 100644 --- a/tests/test_managed_processing_maintenance.py +++ b/tests/test_managed_processing_maintenance.py @@ -75,7 +75,7 @@ def test_registered_mode_switch_backs_up_preserves_revision_and_fences_old_worke assert result.backup_state == "completed" assert result.backup_ref and Path(result.backup_ref).is_file() with closing(sqlite3.connect(path)) as connection: - assert connection.execute("SELECT version_num FROM pc_schema_revision").fetchall() == [("v1_4_1_005",)] + assert connection.execute("SELECT version_num FROM pc_schema_revision").fetchall() == [("v1_4_1_006",)] assert ( json.loads(connection.execute("SELECT config_manifest FROM pc_artifact_processing_schema").fetchone()[0])[ "mode" diff --git a/tests/test_migration_revision_names.py b/tests/test_migration_revision_names.py index 83e3b1171b..ecac342b44 100644 --- a/tests/test_migration_revision_names.py +++ b/tests/test_migration_revision_names.py @@ -59,10 +59,10 @@ def invoke(action, *options): "v1_4_1_002", "v1_4_1_003", "v1_4_1_004", - "v1_4_1_005", + "v1_4_1_006", ] assert [item["revision"] for item in plan["pending_migrations"]] == list(reviewed.revisions) - target = {"revision": "v1_4_1_005", "name": "v1_4_1_005", "software_version": "1.4.1", "sequence": 5} + target = {"revision": "v1_4_1_006", "name": "v1_4_1_006", "software_version": "1.4.1", "sequence": 6} assert plan["target_migration"] == target applied = invoke( @@ -77,7 +77,7 @@ def invoke(action, *options): ) assert applied["migration"] == target and applied["server_ready"] with closing(sqlite3.connect(runner.database)) as connection: - assert connection.execute("SELECT version_num FROM pc_schema_revision").fetchall() == [("v1_4_1_005",)] + assert connection.execute("SELECT version_num FROM pc_schema_revision").fetchall() == [("v1_4_1_006",)] evidence = runner.evidence_path.read_bytes() recorded = json.loads(evidence) assert recorded["source_revision"] == expected_source["revision"] @@ -87,7 +87,7 @@ def invoke(action, *options): "s1401", "s1402", "v1_4_1_004", - "v1_4_1_005", + "v1_4_1_006", } assert runner.verify().state == "ready" assert invoke("verify")["migration"] == target diff --git a/tests/test_registered_source_maintenance_cli.py b/tests/test_registered_source_maintenance_cli.py index deda3e6d2b..68f737b4c0 100644 --- a/tests/test_registered_source_maintenance_cli.py +++ b/tests/test_registered_source_maintenance_cli.py @@ -63,7 +63,7 @@ def test_release140_compatibility_commands_preserve_source_and_share_full_upgrad planned = CliRunner().invoke(cli, [*arguments, "plan", "--backup", "skip"]) assert planned.exit_code == 0, planned.output plan = json.loads(planned.output) - assert plan["source_revision"] == "r1400" and plan["target_revision"] == "v1_4_1_005" + assert plan["source_revision"] == "r1400" and plan["target_revision"] == "v1_4_1_006" assert plan["readiness_scope"] == "server" full = CliRunner().invoke(cli, ["server", "db-migrate", "plan", "--backup", "skip"]) assert full.exit_code == 0, full.output diff --git a/tests/test_server_migration_cli.py b/tests/test_server_migration_cli.py index 8ca00277b6..4b17949ec0 100644 --- a/tests/test_server_migration_cli.py +++ b/tests/test_server_migration_cli.py @@ -67,5 +67,5 @@ def test_server_migrate_runs_the_packaged_forward_only_chain(tmp_path) -> None: work_table = connection.execute( "SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'pc_work_items'" ).fetchone() - assert revision == [("v1_4_1_005",)] + assert revision == [("v1_4_1_006",)] assert work_table == ("pc_work_items",) From b311e14a3eb6e130ece16c5c5130140a4dae143e Mon Sep 17 00:00:00 2001 From: JasonDeng <15301580353@163.com> Date: Thu, 29 Oct 2026 21:42:07 +0800 Subject: [PATCH 05/45] feat(server): compose the durable Work Ledger into the runtime The ledger modules, the Work handlers and the OperationManager all existed, but nothing in `src/` ever constructed them: `create_server_app` rebuilt `BuiltinConfig` without the deployment, coordination, worker and operations sections, `open_builtin_runtime` never started a scheduler, worker or member, and `ServerSettings` did not accept those sections at all, so a distributed deployment silently kept the single-process behaviour. Composition now owns the lifecycle: - `ServerSettings` accepts `deployment`, `coordination`, `worker`, `operations` and `rate_limit`, and the Server, the background runner and the offline migration command pass them into `BuiltinConfig` instead of taking defaults. - `_open_work_background` starts `RuntimeMembership` plus the role's components: `all` runs the scheduler and the worker, `scheduler` and `worker` run only their own, and `api` never scans or claims work. A single-node deployment starts the ledger only when a background schedule is configured, so an unconfigured install keeps its current behaviour. Shutdown drains the worker before leadership and membership disappear. - `runtime.operations` exposes the `OperationManager` so the HTTP layer can inspect, cancel and retry history regardless of role. `MemoryWorkHandler` and `MemoryWorkDiscoverer` are restored against the current Atomic Memory service: a claimed window runs through `RelationalContexts.process_memory`, which keeps the model calls outside the lock and lets the processor own the single transaction that commits the cursor and the artifacts. A retried attempt re-reads the cursor, so a window that already committed retires instead of repeating the domain write. `SharedRateLimitMiddleware` closes the other half of the advertised behaviour: `rate_limit.enabled=True` used to leave requests unlimited. The window lives in the deployment database, runs after principal binding, keys a bound principal by type and id (falling back to the client host), and exempts liveness, readiness and metrics. Verified with `tests/builtin/runtime/test_work_composition.py`, `test_memory_work.py` and `tests/test_server_rate_limit.py` (8 passed) plus the existing `tests/builtin/runtime` and Server suites. --- .../builtin/runtime/application.py | 9 + .../builtin/runtime/composition.py | 215 ++++++++++++++++++ src/powercontext/builtin/runtime/config.py | 1 + .../builtin/runtime/work_handlers.py | 146 +++++++++++- src/powercontext/server/cli.py | 10 + src/powercontext/server/factory.py | 12 + src/powercontext/server/rate_limit.py | 122 ++++++++++ src/powercontext/server/settings.py | 10 + tests/builtin/runtime/test_memory_work.py | 111 +++++++++ .../builtin/runtime/test_work_composition.py | 123 ++++++++++ tests/test_server_rate_limit.py | 53 +++++ 11 files changed, 810 insertions(+), 2 deletions(-) create mode 100644 src/powercontext/server/rate_limit.py create mode 100644 tests/builtin/runtime/test_memory_work.py create mode 100644 tests/builtin/runtime/test_work_composition.py create mode 100644 tests/test_server_rate_limit.py diff --git a/src/powercontext/builtin/runtime/application.py b/src/powercontext/builtin/runtime/application.py index ff3503973e..3680fefcfd 100644 --- a/src/powercontext/builtin/runtime/application.py +++ b/src/powercontext/builtin/runtime/application.py @@ -4044,6 +4044,9 @@ def __init__( self.archive = archive_service self.handoff_report: HandoffReportApplication | None = None self.artifact_processing_supervisor: ArtifactProcessingSupervisors | None = None + # Composition assigns this after the ledger components start; it stays a + # plain attribute so the runtime does not depend on the composition module. + self.work_background: Any | None = None source_ids = scope_ids if source_scope_ids is None else source_scope_ids self.processor = None if source_ids is None else ScheduledSourceProcessor(self, source_ids) self.experience_processor = ( @@ -4053,6 +4056,12 @@ def __init__( async def __aenter__(self) -> BuiltinRuntime: return self + @property + def operations(self) -> Any | None: + """Return the durable Work Ledger operations composed for this process.""" + + return None if self.work_background is None else self.work_background.operations + @property def primary_database(self) -> AsyncDatabase: """Borrow the primary database while the composed Runtime remains open.""" diff --git a/src/powercontext/builtin/runtime/composition.py b/src/powercontext/builtin/runtime/composition.py index 4c21e9c5a6..918bf4f4ae 100644 --- a/src/powercontext/builtin/runtime/composition.py +++ b/src/powercontext/builtin/runtime/composition.py @@ -16,6 +16,7 @@ from __future__ import annotations +import asyncio import json import logging import os @@ -896,6 +897,9 @@ async def run_profile(scope_id, high): ) ) contexts.profiles.operation_context = runtime._operation + runtime.work_background = await resources.enter_async_context( + _open_work_background(config, contexts, tracing=tracing) + ) if config.handoff_report.enabled: runtime.handoff_report = HandoffReportApplication( contexts.scopes, @@ -1114,6 +1118,217 @@ async def _open_artifact_processing_supervisor( yield ArtifactProcessingSupervisors(tuple(supervisors)) +class WorkBackground: + """Role-specific durable Work Ledger services attached to one runtime. + + ``operations`` is always present so the HTTP layer can inspect, cancel and + retry history regardless of role; the execution components stay ``None`` when + the role does not run them or no background schedule is configured. + """ + + __slots__ = ("membership", "operations", "scheduler", "worker") + + def __init__( + self, + *, + operations: Any, + membership: Any = None, + scheduler: Any = None, + worker: Any = None, + ) -> None: + self.operations = operations + self.membership = membership + self.scheduler = scheduler + self.worker = worker + + +def _work_background_requested(config: BuiltinConfig) -> bool: + """True when this deployment asked for ledger-driven background execution.""" + + runtime = config.runtime + return ( + config.deployment.mode == "distributed" + or runtime.memory_schedule_seconds is not None + or runtime.experience_schedule_seconds is not None + or runtime.profile_schedule_enabled + ) + + +def _work_discoverers(config: BuiltinConfig, contexts: RelationalContexts) -> tuple[Any, ...]: + from powercontext.builtin.runtime.work_handlers import ( + CURRENT_WORK_PAYLOAD_VERSION, + ExperienceWorkDiscoverer, + MemoryWorkDiscoverer, + OperationMaintenanceDiscoverer, + ProfileWorkDiscoverer, + ) + + runtime = config.runtime + maximum = config.worker.max_attempts + discoverers: list[Any] = [] + if runtime.memory_schedule_seconds is not None: + discoverers.append( + MemoryWorkDiscoverer( + contexts, + interval_seconds=runtime.memory_schedule_seconds, + window_limit=runtime.source_window_limit, + max_attempts=maximum, + payload_version=CURRENT_WORK_PAYLOAD_VERSION, + ) + ) + if runtime.experience_schedule_seconds is not None: + discoverers.append( + ExperienceWorkDiscoverer( + contexts, + interval_seconds=runtime.experience_schedule_seconds, + window_limit=runtime.source_window_limit, + max_attempts=maximum, + payload_version=CURRENT_WORK_PAYLOAD_VERSION, + ) + ) + if runtime.profile_schedule_enabled: + discoverers.append( + ProfileWorkDiscoverer( + contexts, + cron=runtime.profile_cron, + timezone=runtime.profile_timezone, + max_attempts=maximum, + payload_version=CURRENT_WORK_PAYLOAD_VERSION, + ) + ) + discoverers.append( + OperationMaintenanceDiscoverer( + interval_seconds=config.operations.cleanup_interval_seconds, + max_attempts=maximum, + ) + ) + return tuple(discoverers) + + +def _work_handlers(config: BuiltinConfig, contexts: RelationalContexts) -> tuple[Any, ...]: + from powercontext.builtin.runtime.work_handlers import ( + ExperienceWorkHandler, + MemoryWorkHandler, + OperationMaintenanceHandler, + ProfileWorkHandler, + ) + + runtime = config.runtime + return ( + MemoryWorkHandler(contexts, window_limit=runtime.source_window_limit), + ExperienceWorkHandler(contexts), + ProfileWorkHandler( + contexts.profiles, + max_concurrency=runtime.profile_max_concurrency, + ), + OperationMaintenanceHandler( + retention_days=config.operations.retention_days, + batch_size=config.operations.cleanup_batch_size, + ), + ) + + +def _build_version() -> str: + from importlib.metadata import PackageNotFoundError, version + + try: + return version("powercontext") + except PackageNotFoundError: # pragma: no cover - source checkouts without installed metadata + return "unknown" + + +@asynccontextmanager +async def _open_work_background( + config: BuiltinConfig, + contexts: RelationalContexts, + *, + tracing: RuntimeTracing | None = None, +) -> AsyncIterator[WorkBackground]: + """Start the durable Work Ledger components this role is responsible for. + + A single-node deployment keeps every role in one process and only starts the + ledger when a background schedule is configured, so an unconfigured install + behaves exactly as before. A distributed deployment splits the roles and the + API process never claims or scans work. + """ + + from powercontext.builtin.runtime.durable_scheduler import DurableScheduler + from powercontext.builtin.runtime.membership import RuntimeMembership + from powercontext.builtin.runtime.operations import OperationManager + from powercontext.builtin.runtime.work_handlers import CURRENT_WORK_PAYLOAD_VERSION + from powercontext.builtin.runtime.worker import DurableWorker + + if not _work_background_requested(config): + yield WorkBackground( + operations=OperationManager( + contexts=contexts, + operations=config.operations, + worker=config.worker, + local_worker=None, + payload_version=CURRENT_WORK_PAYLOAD_VERSION, + ) + ) + return + + role = config.deployment.role + async with AsyncExitStack() as stack: + membership = RuntimeMembership( + database=contexts.database, + deployment=config.deployment, + coordination=config.coordination, + build_version=_build_version(), + ) + await membership.start() + stack.push_async_callback(membership.stop) + scheduler = ( + DurableScheduler( + database=contexts.database, + scheduler_id=f"{membership.instance_id}:scheduler", + discoverers=_work_discoverers(config, contexts), + config=config.coordination, + tracing=tracing, + ) + if role in ("all", "scheduler") + else None + ) + worker = ( + DurableWorker( + database=contexts.database, + worker_id=f"{membership.instance_id}:worker", + handlers=_work_handlers(config, contexts), + config=config.worker, + tracing=tracing, + ) + if role in ("all", "worker") + else None + ) + components = [component for component in (membership, scheduler, worker) if component is not None] + tasks = [ + asyncio.create_task(component.run(), name=f"powercontext-work-{index}") + for index, component in enumerate(components) + ] + try: + yield WorkBackground( + operations=OperationManager( + contexts=contexts, + operations=config.operations, + worker=config.worker, + local_worker=worker, + payload_version=CURRENT_WORK_PAYLOAD_VERSION, + ), + membership=membership, + scheduler=scheduler, + worker=worker, + ) + finally: + # Drain the worker before leadership and membership disappear so an + # in-flight claim still commits under a valid lease. + for component in (worker, scheduler, membership): + if component is not None: + await component.stop() + await asyncio.gather(*tasks, return_exceptions=True) + + @asynccontextmanager async def open_builtin_contexts( # noqa: C901 -- Backend admission and resource lifetimes stay together. config: BuiltinConfig, diff --git a/src/powercontext/builtin/runtime/config.py b/src/powercontext/builtin/runtime/config.py index d573caa78a..24f68380bb 100644 --- a/src/powercontext/builtin/runtime/config.py +++ b/src/powercontext/builtin/runtime/config.py @@ -600,6 +600,7 @@ class BuiltinConfig(BaseModel): coordination: CoordinationConfig = Field(default_factory=CoordinationConfig) worker: WorkerConfig = Field(default_factory=WorkerConfig) operations: OperationsConfig = Field(default_factory=OperationsConfig) + rate_limit: RateLimitConfig = Field(default_factory=RateLimitConfig) @model_validator(mode="before") @classmethod diff --git a/src/powercontext/builtin/runtime/work_handlers.py b/src/powercontext/builtin/runtime/work_handlers.py index cfa807ccdc..85faa27833 100644 --- a/src/powercontext/builtin/runtime/work_handlers.py +++ b/src/powercontext/builtin/runtime/work_handlers.py @@ -28,6 +28,7 @@ from sqlalchemy.exc import IntegrityError, OperationalError from sqlalchemy.ext.asyncio import AsyncConnection +from powercontext.builtin.artifacts.atomic_memory.errors import AtomicMemoryConflictError from powercontext.builtin.artifacts.experience import EXPERIENCE_INCUBATION_CURSOR_NAME from powercontext.builtin.artifacts.profile.models import ( PROFILE_SOURCE_WINDOW_BINDING, @@ -50,14 +51,15 @@ from powercontext.builtin.records import BaseValueConflictError from powercontext.builtin.runtime.cron import CronSchedule from powercontext.builtin.runtime.durable_scheduler import DiscoveryPage -from powercontext.builtin.runtime.models import ExperienceIncubationResult +from powercontext.builtin.runtime.models import ExperienceIncubationResult, MemoryFlushResult from powercontext.builtin.runtime.relational import RelationalContexts, _validate_experience_plans from powercontext.builtin.runtime.worker import PreparedWork, WorkExecutionError from powercontext.builtin.sources import SourceCursor, validate_scope_id from powercontext.builtin.statistics import ModelUsagePurpose -from powercontext.builtin.triggers import SourceHighWatermark, SourceWindowTrigger +from powercontext.builtin.triggers import SOURCE_WINDOW_TRIGGER_NAME, SourceHighWatermark, SourceWindowTrigger from powercontext.errors import RevisionConflictError +MEMORY_WORK_KIND = "powercontext.memory.source-window" EXPERIENCE_WORK_KIND = "powercontext.experience.incubation" PROFILE_WORK_KIND = "powercontext.profile.source-window" MAINTENANCE_WORK_KIND = "powercontext.maintenance.operations" @@ -105,6 +107,45 @@ class ProfileWorkPayload(BaseModel): high_watermark: int = Field(ge=1) +class MemoryWorkDiscoverer: + """Scan Source scopes and describe pending Memory windows.""" + + name = MEMORY_WORK_KIND + + def __init__( + self, + contexts: RelationalContexts, + *, + interval_seconds: float, + window_limit: int, + max_attempts: int, + payload_version: int = CURRENT_WORK_PAYLOAD_VERSION, + ) -> None: + self._contexts = contexts + self.interval_seconds = interval_seconds + self._window_limit = window_limit + self._max_attempts = max_attempts + self._payload_version = payload_version + + async def page(self, continuation: str | None, limit: int, /) -> DiscoveryPage: + scopes = await self._contexts.scope_ids_page(continuation, limit) + specs: list[WorkSpec] = [] + for scope_id in scopes: + spec = await memory_work_spec( + self._contexts, + scope_id, + limit=self._window_limit, + max_attempts=self._max_attempts, + payload_version=self._payload_version, + ) + if spec is not None: + specs.append(spec) + return DiscoveryPage( + specs=tuple(specs), + continuation=scopes[-1] if len(scopes) == limit else None, + ) + + class ExperienceWorkDiscoverer: """Scan Source scopes and describe pending Experience windows.""" @@ -223,6 +264,66 @@ async def page(self, continuation: str | None, _limit: int, /) -> DiscoveryPage: ) +class MemoryWorkHandler: + """Run one claimed Memory window through the Atomic Memory processor. + + The window, its model calls and its commit stay one Atomic Memory flush: this + handler owns the retry and fencing story, while the processor keeps the + single-transaction cursor/artifact commit. A retried attempt re-reads the + cursor, so a window that already committed retires instead of repeating the + domain write. + """ + + kind = MEMORY_WORK_KIND + supported_versions = frozenset({CURRENT_WORK_PAYLOAD_VERSION}) + + def __init__(self, contexts: RelationalContexts, *, window_limit: int) -> None: + self._contexts = contexts + self._window_limit = window_limit + + async def prepare(self, claim: WorkClaim, /) -> PreparedWork: + payload = _payload(claim) + services = self._contexts._services_for(claim.scope_id) + async with self._contexts.database.transaction() as connection: + state_row = await services.repositories.cursors.load( + connection, + claim.scope_id, + SOURCE_WINDOW_TRIGGER_NAME, + ) + current_sequence, current_generation = _cursor_position(state_row) + if current_sequence >= payload.through: + return PreparedWork( + result=_memory_result( + payload, + current=current_sequence, + source_count=0, + code="already_committed", + ) + ) + if _is_superseded_window(payload, current_sequence, current_generation): + # A committed racing window consumed this snapshot's prefix; retire + # the key so the lane keeps moving and discovery re-enqueues the rest. + return PreparedWork( + result=_memory_result( + payload, + current=current_sequence, + source_count=0, + code="superseded", + ) + ) + _require_exact_cursor(payload, current_sequence, current_generation) + try: + flushed = await self._contexts.process_memory(claim.scope_id, self._window_limit) + except (AtomicMemoryConflictError, GenerationConflictError, RevisionConflictError) as error: + raise WorkExecutionError(category="conflict", code="memory_conflict", retryable=True) from error + return PreparedWork( + result=WorkResult( + code="processed" if flushed.processed else "idle", + payload=flushed.model_dump(mode="json", exclude_none=True), + ) + ) + + class ExperienceWorkHandler: """Prepare Experience proposals and commit Candidates with cursor and Work.""" @@ -436,6 +537,26 @@ async def commit(connection: AsyncConnection) -> WorkResult: return PreparedWork(result=None, commit=commit) +async def memory_work_spec( + contexts: RelationalContexts, + scope_id: str, + /, + *, + limit: int, + max_attempts: int, + payload_version: int, +) -> WorkSpec | None: + return await _discover_spec( + contexts, + scope_id, + cursor_name=SOURCE_WINDOW_TRIGGER_NAME, + kind=MEMORY_WORK_KIND, + limit=limit, + max_attempts=max_attempts, + payload_version=payload_version, + ) + + async def experience_work_spec( contexts: RelationalContexts, scope_id: str, @@ -632,6 +753,23 @@ def _require_complete_window(rows, payload: SourceWindowPayload) -> None: raise WorkExecutionError(category="retention", code="source_window_unavailable", retryable=False) +def _memory_result( + payload: SourceWindowPayload, + *, + current: int, + source_count: int, + code: str, +) -> WorkResult: + result = MemoryFlushResult( + previous_cursor=payload.after, + current_cursor=current, + high_watermark=payload.high_watermark, + source_count=source_count, + memory_ref=None, + ) + return WorkResult(code=code, payload=result.model_dump(mode="json", exclude_none=True)) + + def _experience_result( payload: SourceWindowPayload, *, @@ -663,9 +801,12 @@ def _digest(value: str) -> str: __all__ = [ "CURRENT_WORK_PAYLOAD_VERSION", "EXPERIENCE_WORK_KIND", + "MEMORY_WORK_KIND", "PROFILE_WORK_KIND", "ExperienceWorkDiscoverer", "ExperienceWorkHandler", + "MemoryWorkDiscoverer", + "MemoryWorkHandler", "ProfileWorkDiscoverer", "ProfileWorkHandler", "ProfileWorkPayload", @@ -673,4 +814,5 @@ def _digest(value: str) -> str: "SourceWindowPayload", "WorkRequester", "experience_work_spec", + "memory_work_spec", ] diff --git a/src/powercontext/server/cli.py b/src/powercontext/server/cli.py index 8de06de50c..927c5c1b6d 100644 --- a/src/powercontext/server/cli.py +++ b/src/powercontext/server/cli.py @@ -244,6 +244,11 @@ async def _processing_maintenance( inference=settings.inference, handoff_report=settings.handoff_report, external_skills=settings.external_skills, + deployment=settings.deployment, + coordination=settings.coordination, + worker=settings.worker, + operations=settings.operations, + rate_limit=settings.rate_limit, ) manifest = canonical_processing_manifest(config) database = settings.database @@ -749,6 +754,11 @@ async def _run_background_async(settings: ServerSettings, tracing: Any) -> None: handoff_report=settings.handoff_report, inference=settings.inference, external_skills=settings.external_skills, + deployment=settings.deployment, + coordination=settings.coordination, + worker=settings.worker, + operations=settings.operations, + rate_limit=settings.rate_limit, ) from powercontext.builtin.persistence.migrations.server_readiness import prepare_server_storage diff --git a/src/powercontext/server/factory.py b/src/powercontext/server/factory.py index d2cfa9a554..58ffeb8079 100644 --- a/src/powercontext/server/factory.py +++ b/src/powercontext/server/factory.py @@ -94,6 +94,7 @@ from powercontext.server.metrics import CONTENT_TYPE_LATEST, HttpMetricsMiddleware, ServerMetrics from powercontext.server.middleware import AuthenticationMiddleware from powercontext.server.processing_security import build_worker_security +from powercontext.server.rate_limit import SharedRateLimitMiddleware from powercontext.server.request_id import HttpRequestIdMiddleware from powercontext.server.settings import MissingAuthenticationProviderError, ServerSettings from powercontext.server.tracing import HttpTracingMiddleware, ServerTracing @@ -173,6 +174,11 @@ def create_server_app( # noqa: C901 handoff_report=resolved.handoff_report, inference=resolved.inference, external_skills=resolved.external_skills, + deployment=resolved.deployment, + coordination=resolved.coordination, + worker=resolved.worker, + operations=resolved.operations, + rate_limit=resolved.rate_limit, ) metrics = ServerMetrics(role=resolved.runtime.artifact_processing_role) if resolved.metrics.enabled else None resolved_tracing = ( @@ -356,6 +362,12 @@ def current_capabilities() -> Capabilities: dashboard_enabled=resolved.dashboard.enabled, ), ) + if resolved.rate_limit.enabled: + # Appended last so it runs inside the authentication middleware and sees + # the principal that request was bound to. + configured_middleware.append( + Middleware(SharedRateLimitMiddleware, config=resolved.rate_limit), + ) app = create_app( lifespan=lifespan, diff --git a/src/powercontext/server/rate_limit.py b/src/powercontext/server/rate_limit.py new file mode 100644 index 0000000000..3219a549a2 --- /dev/null +++ b/src/powercontext/server/rate_limit.py @@ -0,0 +1,122 @@ +# 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. + +"""Shared fixed-window admission for external HTTP requests. + +The window lives in the deployment database rather than in process memory, so +every API replica charges the same principal budget. The middleware runs after +principal binding: without a bound principal it falls back to the client host, +which keeps an unauthenticated deployment from sharing one global window. +""" + +from __future__ import annotations + +import hashlib + +from starlette.responses import JSONResponse +from starlette.types import ASGIApp, Receive, Scope, Send + +from powercontext.builtin.persistence.database import AsyncDatabase +from powercontext.builtin.persistence.rate_limit import RateLimitDecision, RateLimitRepository +from powercontext.builtin.runtime.config import RateLimitConfig +from powercontext.http import ErrorDetail, ErrorResponse +from powercontext.server.context import current_principal, is_internal_bridge + +POLICY_ID = "server.http" + +# Liveness, readiness and metrics are scraped on a fixed interval and must not +# consume a caller's budget. +_EXEMPT_PREFIXES = ("/health/live", "/health/ready", "/metrics") + + +class SharedRateLimitMiddleware: + """Charge each external request to its principal's fixed window.""" + + def __init__( + self, + app: ASGIApp, + *, + config: RateLimitConfig, + repository: RateLimitRepository | None = None, + ) -> None: + self.app = app + self._config = config + self._repository = RateLimitRepository() if repository is None else repository + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + if scope["type"] != "http" or is_internal_bridge(): + await self.app(scope, receive, send) + return + if scope.get("path", "").startswith(_EXEMPT_PREFIXES): + await self.app(scope, receive, send) + return + database = _runtime_database(scope) + if database is None: + await self.app(scope, receive, send) + return + decision = await self._consume(database, _principal_key(scope)) + if not decision.allowed: + response = JSONResponse( + ErrorResponse( + error=ErrorDetail( + code="rate_limited", + message="The request budget for this principal is exhausted.", + details=None, + ) + ).model_dump(mode="json"), + status_code=429, + headers={"Retry-After": str(decision.retry_after_seconds)}, + ) + await response(scope, receive, send) + return + await self.app(scope, receive, send) + + async def _consume(self, database: AsyncDatabase, principal_key: str) -> RateLimitDecision: + async with database.transaction() as connection: + return await self._repository.consume( + connection, + principal_key=principal_key, + policy_id=POLICY_ID, + limit=self._config.requests, + window_seconds=self._config.window_seconds, + ) + + +def _runtime_database(scope: Scope) -> AsyncDatabase | None: + """Return the composed runtime's database, or ``None`` outside the data plane.""" + + app = scope.get("app") + application = None if app is None else getattr(app.state, "application", None) + if application is None: + return None + try: + return application.primary_database + except RuntimeError: + # The lifespan has not opened the primary database yet (or already closed it). + return None + + +def _principal_key(scope: Scope) -> str: + """Derive the 64-character digest the repository requires as a window key.""" + + principal = current_principal() + if principal is not None: + identity = f"{principal.type}:{principal.id}" + else: + client = scope.get("client") + identity = f"anonymous:{client[0] if client else 'unknown'}" + return hashlib.sha256(identity.encode()).hexdigest() + + +__all__ = ["POLICY_ID", "SharedRateLimitMiddleware"] diff --git a/src/powercontext/server/settings.py b/src/powercontext/server/settings.py index 42e4cd8700..a3d2e28bea 100644 --- a/src/powercontext/server/settings.py +++ b/src/powercontext/server/settings.py @@ -31,11 +31,16 @@ from powercontext.builtin.persistence.oceanbase import OceanBaseConfig from powercontext.builtin.persistence.sqlite import SQLiteConfig from powercontext.builtin.runtime.config import ( + CoordinationConfig, DatabaseConfig, + DeploymentConfig, ExternalSkillsConfig, HandoffReportConfig, InferenceConfig, + OperationsConfig, + RateLimitConfig, RuntimeConfig, + WorkerConfig, ) from powercontext.paths import default_database_path, default_seekdb_path, powercontext_data_dir, sqlite_url from powercontext.transport import is_loopback_host @@ -230,6 +235,11 @@ class ServerSettings(BaseSettings): handoff_report: HandoffReportConfig = Field(default_factory=HandoffReportConfig) inference: InferenceConfig = Field(default_factory=InferenceConfig) external_skills: ExternalSkillsConfig = Field(default_factory=ExternalSkillsConfig) + deployment: DeploymentConfig = Field(default_factory=DeploymentConfig) + coordination: CoordinationConfig = Field(default_factory=CoordinationConfig) + worker: WorkerConfig = Field(default_factory=WorkerConfig) + operations: OperationsConfig = Field(default_factory=OperationsConfig) + rate_limit: RateLimitConfig = Field(default_factory=RateLimitConfig) @field_validator("cursor_signing_secret") @classmethod diff --git a/tests/builtin/runtime/test_memory_work.py b/tests/builtin/runtime/test_memory_work.py new file mode 100644 index 0000000000..89f808d84d --- /dev/null +++ b/tests/builtin/runtime/test_memory_work.py @@ -0,0 +1,111 @@ +# 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 + +from powercontext.builtin.persistence.sqlite import SQLiteConfig +from powercontext.builtin.persistence.work import WorkRepository +from powercontext.builtin.runtime import BuiltinConfig, open_builtin_contexts +from powercontext.builtin.runtime.models import MemoryFlushResult +from powercontext.builtin.runtime.work_handlers import ( + MEMORY_WORK_KIND, + MemoryWorkHandler, + memory_work_spec, +) +from powercontext.builtin.sources import ContentCapture, SourceCursor +from powercontext.builtin.triggers import SOURCE_WINDOW_TRIGGER_NAME + + +def _config(tmp_path): + return BuiltinConfig(database=SQLiteConfig(url=f"sqlite+aiosqlite:///{tmp_path / 'memory.db'}")) + + +async def _claim(contexts, spec): + repository = WorkRepository() + async with contexts.database.transaction() as connection: + await repository.enqueue(connection, spec) + claims = await repository.claim( + connection, + worker_id="worker-a", + supported={MEMORY_WORK_KIND: frozenset({1})}, + lease_seconds=30, + limit=1, + ) + return claims[0] + + +def test_memory_work_spec_tracks_the_unconsumed_source_window(tmp_path) -> None: + async def scenario() -> None: + async with open_builtin_contexts(_config(tmp_path)) as contexts: + assert await memory_work_spec(contexts, "project", limit=10, max_attempts=3, payload_version=1) is None + context = await contexts.get("project") + await context.sources.capture(ContentCapture(source_id="one", content="remember this")) + + spec = await memory_work_spec(contexts, "project", limit=10, max_attempts=3, payload_version=1) + assert spec is not None + assert spec.kind == MEMORY_WORK_KIND + assert spec.scope_id == "project" + assert spec.payload["cursor_name"] == SOURCE_WINDOW_TRIGGER_NAME + assert spec.payload["after"] == 0 + assert spec.payload["through"] == 1 + + asyncio.run(scenario()) + + +def test_memory_work_handler_runs_the_atomic_memory_flush(tmp_path, monkeypatch) -> None: + async def scenario() -> None: + async with open_builtin_contexts(_config(tmp_path)) as contexts: + context = await contexts.get("project") + await context.sources.capture(ContentCapture(source_id="one", content="remember this")) + spec = await memory_work_spec(contexts, "project", limit=10, max_attempts=3, payload_version=1) + assert spec is not None + claim = await _claim(contexts, spec) + + seen: list[tuple[str, int]] = [] + + async def fake_process_memory(scope_id: str, limit: int, **_: object) -> MemoryFlushResult: + seen.append((scope_id, limit)) + return MemoryFlushResult( + previous_cursor=0, + current_cursor=1, + high_watermark=1, + source_count=1, + memory_ref=None, + ) + + monkeypatch.setattr(contexts, "process_memory", fake_process_memory) + prepared = await MemoryWorkHandler(contexts, window_limit=10).prepare(claim) + # The Atomic Memory flush owns the domain commit, so the fenced Work + # commit only records the outcome. + assert prepared.commit is None + assert prepared.result is not None + assert prepared.result.code == "processed" + assert prepared.result.payload["current_cursor"] == 1 + assert seen == [("project", 10)] + + async with contexts.database.transaction() as connection: + await contexts.repositories.cursors.save( + connection, + "project", + SOURCE_WINDOW_TRIGGER_NAME, + SourceCursor(sequence=1), + expected_generation=None, + ) + repeated = await MemoryWorkHandler(contexts, window_limit=10).prepare(claim) + assert repeated.result is not None + assert repeated.result.code == "already_committed" + + asyncio.run(scenario()) diff --git a/tests/builtin/runtime/test_work_composition.py b/tests/builtin/runtime/test_work_composition.py new file mode 100644 index 0000000000..d9da052712 --- /dev/null +++ b/tests/builtin/runtime/test_work_composition.py @@ -0,0 +1,123 @@ +# 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 +from typing import cast + +import pytest + +from powercontext.builtin.persistence.sqlite import SQLiteConfig +from powercontext.builtin.runtime import BuiltinConfig, open_builtin_contexts +from powercontext.builtin.runtime.composition import ( + _open_work_background, + _work_background_requested, + _work_discoverers, + _work_handlers, +) +from powercontext.builtin.runtime.config import RuntimeConfig +from powercontext.builtin.runtime.membership import DuplicateSingleNodeError +from powercontext.builtin.runtime.relational import RelationalContexts +from powercontext.builtin.runtime.work_handlers import ( + EXPERIENCE_WORK_KIND, + MAINTENANCE_WORK_KIND, + MEMORY_WORK_KIND, + PROFILE_WORK_KIND, +) + + +def _config(tmp_path, **overrides): + return BuiltinConfig( + database=SQLiteConfig(url=f"sqlite+aiosqlite:///{tmp_path / 'work.db'}"), + **overrides, + ) + + +def test_ledger_background_only_starts_when_a_schedule_is_configured() -> None: + assert _work_background_requested(BuiltinConfig()) is False + assert _work_background_requested(BuiltinConfig(runtime=RuntimeConfig(memory_schedule_seconds=30))) is True + assert _work_background_requested(BuiltinConfig(runtime=RuntimeConfig(experience_schedule_seconds=30))) is True + assert _work_background_requested(BuiltinConfig(runtime=RuntimeConfig(profile_schedule_enabled=True))) is True + + +def test_composition_registers_every_discoverer_and_handler(tmp_path) -> None: + sentinel = object() + config = _config( + tmp_path, + runtime=RuntimeConfig( + memory_schedule_seconds=30, + experience_schedule_seconds=30, + profile_schedule_enabled=True, + ), + ) + + discoverers = _work_discoverers(config, cast("RelationalContexts", sentinel)) + assert [discoverer.name for discoverer in discoverers] == [ + MEMORY_WORK_KIND, + EXPERIENCE_WORK_KIND, + PROFILE_WORK_KIND, + MAINTENANCE_WORK_KIND, + ] + + class _Profiles: + """Stand-in for the shared Profile service the handler only stores.""" + + fake_contexts = cast("RelationalContexts", type("Contexts", (), {"profiles": _Profiles()})()) + handlers = _work_handlers(config, fake_contexts) + assert [handler.kind for handler in handlers] == [ + MEMORY_WORK_KIND, + EXPERIENCE_WORK_KIND, + PROFILE_WORK_KIND, + MAINTENANCE_WORK_KIND, + ] + + +def test_unconfigured_runtime_exposes_operations_without_execution(tmp_path) -> None: + async def scenario() -> None: + config = _config(tmp_path) + async with ( + open_builtin_contexts(config) as contexts, + _open_work_background(BuiltinConfig(), contexts) as background, + ): + assert background.operations is not None + assert background.scheduler is None + assert background.worker is None + assert background.membership is None + + asyncio.run(scenario()) + + +def test_configured_single_node_runtime_owns_the_ledger_and_shuts_down(tmp_path) -> None: + async def scenario() -> None: + config = _config(tmp_path, runtime=RuntimeConfig(memory_schedule_seconds=30)) + async with ( + open_builtin_contexts(config) as contexts, + _open_work_background(config, contexts) as background, + ): + assert background.membership is not None + assert background.scheduler is not None + assert background.worker is not None + assert set(background.worker.supported) == { + MEMORY_WORK_KIND, + EXPERIENCE_WORK_KIND, + PROFILE_WORK_KIND, + MAINTENANCE_WORK_KIND, + } + # A second runtime may not claim the same single-node ownership. + with pytest.raises(DuplicateSingleNodeError): + async with _open_work_background(config, contexts): + pass + + asyncio.run(scenario()) diff --git a/tests/test_server_rate_limit.py b/tests/test_server_rate_limit.py new file mode 100644 index 0000000000..cce2b93d18 --- /dev/null +++ b/tests/test_server_rate_limit.py @@ -0,0 +1,53 @@ +# 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 + +from fastapi.testclient import TestClient + +from powercontext.builtin.persistence.sqlite import SQLiteConfig +from powercontext.builtin.runtime.config import RateLimitConfig +from powercontext.server.factory import create_server_app +from powercontext.server.settings import McpConfig, ServerSettings + + +def _settings(tmp_path, *, enabled: bool): + return ServerSettings( + database=SQLiteConfig(url=f"sqlite+aiosqlite:///{tmp_path / 'server.db'}"), + rate_limit=RateLimitConfig(enabled=enabled, requests=1, window_seconds=60), + mcp=McpConfig(enabled=False), + ) + + +def test_enabled_rate_limit_charges_a_principal_budget(tmp_path) -> None: + app = create_server_app(settings=_settings(tmp_path, enabled=True)) + with TestClient(app) as client: + first = client.get("/v1/operations") + second = client.get("/v1/operations") + probe = client.get("/health/live") + + assert first.status_code != 429 + assert second.status_code == 429 + assert second.json()["error"]["code"] == "rate_limited" + assert int(second.headers["retry-after"]) > 0 + # Liveness probes never consume the caller's window. + assert probe.status_code == 200 + + +def test_disabled_rate_limit_leaves_requests_unlimited(tmp_path) -> None: + app = create_server_app(settings=_settings(tmp_path, enabled=False)) + with TestClient(app) as client: + statuses = {client.get("/v1/operations").status_code for _ in range(3)} + + assert 429 not in statuses From 02ca7955e9ff91dbc5164e5b3c3bccf5de5e93b9 Mon Sep 17 00:00:00 2001 From: JasonDeng <15301580353@163.com> Date: Thu, 29 Oct 2026 22:01:27 +0800 Subject: [PATCH 06/45] fix(runtime): keep the Work Ledger and the APScheduler supervisors exclusive The ledger was started for any `single_node` install that configured a background schedule. The artifact-processing supervisor starts for exactly the same configurations (`runtime/artifact_processing_role` must be `all`), so both paths claimed the same source window in one process: the ledger advanced the cursor, and the supervisor's next tick then saw an empty window and recorded a noop instead of doing the work. That is what pushed `tests/e2e/test_worker_otlp.py` and `tests/e2e/test_worker_model_usage.py` into `outcome=noop` with no model spans. RFC 1430 already fixes the ordering: the bridge release moves `single_node/all` onto the ledger only once the APScheduler path has fully retired. Until then: * `_work_background_requested` is true only for an explicit `distributed` deployment, so a `single_node` install keeps the supervisors it has today and only exposes the `OperationManager` facade. * `_ledger_families` reports the Families the ledger drives, and `_supervisor_bindings` drops exactly those, so a distributed run never lets the supervisor compete for a window the ledger already owns. * `_work_role_components` states the role split as a pure function: `api` runs neither component, `scheduler` only discovers, `worker` only claims, `all` does both. The role wiring, membership, rate limiting and Memory ledger handler added earlier are unchanged; only the activation boundary moved. Verified locally: the four `test_worker_otlp` and six `test_worker_model_usage` cases that failed on `b311e14a` now pass (10 passed), and `tests/builtin/runtime/test_work_composition.py` covers the new boundary. --- .../builtin/runtime/composition.py | 77 ++++++++++--- .../builtin/runtime/test_work_composition.py | 102 +++++++++++------- 2 files changed, 129 insertions(+), 50 deletions(-) diff --git a/src/powercontext/builtin/runtime/composition.py b/src/powercontext/builtin/runtime/composition.py index 918bf4f4ae..c2d7aa3305 100644 --- a/src/powercontext/builtin/runtime/composition.py +++ b/src/powercontext/builtin/runtime/composition.py @@ -890,7 +890,7 @@ async def run_profile(scope_id, high): _open_artifact_processing_supervisor( config, contexts, - processing_bindings, + _supervisor_bindings(config, processing_bindings), tracing=tracing, recovery=_archive_recovery, processing_observer=processing_observer, @@ -1143,15 +1143,60 @@ def __init__( def _work_background_requested(config: BuiltinConfig) -> bool: - """True when this deployment asked for ledger-driven background execution.""" + """True when this deployment opted into ledger-driven background execution. + + The ledger and the APScheduler-driven artifact supervisors both drive the + same source windows, so exactly one of them may own a Family. RFC 1430 makes + that ordering explicit: a bridge release moves ``single_node/all`` onto the + ledger only once the APScheduler path has fully retired. Until then a + ``single_node`` install keeps the supervisors it already has, and the ledger + starts only for an explicit ``distributed`` deployment, where the roles are + split across processes and no single process can own every window. + """ + + return config.deployment.mode == "distributed" + + +def _ledger_families(config: BuiltinConfig) -> frozenset[str]: + """Families the durable Work Ledger drives, or empty when it is inactive. + + The supervisor must skip these Families, otherwise both paths claim the same + window: the ledger advances the cursor and the supervisor's next tick sees an + empty window and records a noop instead of doing the work. + """ + if not _work_background_requested(config): + return frozenset() runtime = config.runtime - return ( - config.deployment.mode == "distributed" - or runtime.memory_schedule_seconds is not None - or runtime.experience_schedule_seconds is not None - or runtime.profile_schedule_enabled - ) + families = set() + if runtime.memory_schedule_seconds is not None: + families.add("memory") + if runtime.experience_schedule_seconds is not None: + families.add("experience") + if runtime.profile_schedule_enabled: + families.add("profile") + return frozenset(families) + + +def _work_role_components(role: str) -> tuple[bool, bool]: + """Report which ledger components a deployment role runs: (scheduler, worker). + + ``api`` serves requests only, ``scheduler`` only discovers work, ``worker`` + only claims it, and ``all`` does both. + """ + + return role in ("all", "scheduler"), role in ("all", "worker") + + +def _supervisor_bindings( + config: BuiltinConfig, bindings: Sequence[ArtifactProcessingBinding] +) -> tuple[ArtifactProcessingBinding, ...]: + """Drop the Families the ledger owns so the two paths never share a window.""" + + owned = _ledger_families(config) + if not owned: + return tuple(bindings) + return tuple(binding for binding in bindings if binding.artifact_family not in owned) def _work_discoverers(config: BuiltinConfig, contexts: RelationalContexts) -> tuple[Any, ...]: @@ -1246,10 +1291,13 @@ async def _open_work_background( ) -> AsyncIterator[WorkBackground]: """Start the durable Work Ledger components this role is responsible for. - A single-node deployment keeps every role in one process and only starts the - ledger when a background schedule is configured, so an unconfigured install - behaves exactly as before. A distributed deployment splits the roles and the - API process never claims or scans work. + The ledger drives Families that a supervisor no longer owns, so it is opened + only for an explicit ``distributed`` deployment (see + ``_work_background_requested``). A ``single_node`` install keeps its + APScheduler supervisors and only gets the ``OperationManager`` facade, which + is what an unconfigured install has always exposed. A distributed deployment + splits the roles: ``api`` never claims or scans work, ``scheduler`` only + scans, ``worker`` only claims, and ``all`` does both. """ from powercontext.builtin.runtime.durable_scheduler import DurableScheduler @@ -1271,6 +1319,7 @@ async def _open_work_background( return role = config.deployment.role + runs_scheduler, runs_worker = _work_role_components(role) async with AsyncExitStack() as stack: membership = RuntimeMembership( database=contexts.database, @@ -1288,7 +1337,7 @@ async def _open_work_background( config=config.coordination, tracing=tracing, ) - if role in ("all", "scheduler") + if runs_scheduler else None ) worker = ( @@ -1299,7 +1348,7 @@ async def _open_work_background( config=config.worker, tracing=tracing, ) - if role in ("all", "worker") + if runs_worker else None ) components = [component for component in (membership, scheduler, worker) if component is not None] diff --git a/tests/builtin/runtime/test_work_composition.py b/tests/builtin/runtime/test_work_composition.py index d9da052712..57f4694c7c 100644 --- a/tests/builtin/runtime/test_work_composition.py +++ b/tests/builtin/runtime/test_work_composition.py @@ -15,20 +15,22 @@ from __future__ import annotations import asyncio -from typing import cast - -import pytest +from types import SimpleNamespace +from typing import Literal, cast from powercontext.builtin.persistence.sqlite import SQLiteConfig from powercontext.builtin.runtime import BuiltinConfig, open_builtin_contexts +from powercontext.builtin.runtime.artifact_processing import ArtifactProcessingBinding from powercontext.builtin.runtime.composition import ( + _ledger_families, _open_work_background, + _supervisor_bindings, _work_background_requested, _work_discoverers, _work_handlers, + _work_role_components, ) -from powercontext.builtin.runtime.config import RuntimeConfig -from powercontext.builtin.runtime.membership import DuplicateSingleNodeError +from powercontext.builtin.runtime.config import DeploymentConfig, RuntimeConfig from powercontext.builtin.runtime.relational import RelationalContexts from powercontext.builtin.runtime.work_handlers import ( EXPERIENCE_WORK_KIND, @@ -45,11 +47,62 @@ def _config(tmp_path, **overrides): ) -def test_ledger_background_only_starts_when_a_schedule_is_configured() -> None: +def _distributed(runtime: RuntimeConfig, role: Literal["api", "scheduler", "worker"] = "worker") -> BuiltinConfig: + """A distributed config without an OceanBase handle: only top-level helpers read it.""" + + return cast( + "BuiltinConfig", + SimpleNamespace( + deployment=DeploymentConfig(mode="distributed", role=role), + runtime=runtime, + ), + ) + + +def test_ledger_only_starts_for_a_distributed_deployment() -> None: + # A single-node install keeps its APScheduler supervisors: RFC 1430 moves + # single_node/all onto the ledger only once that path has fully retired. assert _work_background_requested(BuiltinConfig()) is False - assert _work_background_requested(BuiltinConfig(runtime=RuntimeConfig(memory_schedule_seconds=30))) is True - assert _work_background_requested(BuiltinConfig(runtime=RuntimeConfig(experience_schedule_seconds=30))) is True - assert _work_background_requested(BuiltinConfig(runtime=RuntimeConfig(profile_schedule_enabled=True))) is True + assert _work_background_requested(BuiltinConfig(runtime=RuntimeConfig(memory_schedule_seconds=30))) is False + assert _work_background_requested(BuiltinConfig(runtime=RuntimeConfig(profile_schedule_enabled=True))) is False + assert _work_background_requested(_distributed(RuntimeConfig(memory_schedule_seconds=30))) is True + + +def test_ledger_owned_families_follow_the_configured_schedules() -> None: + assert _ledger_families(BuiltinConfig(runtime=RuntimeConfig(memory_schedule_seconds=30))) == frozenset() + + families = _ledger_families( + _distributed( + RuntimeConfig( + memory_schedule_seconds=30, + experience_schedule_seconds=30, + profile_schedule_enabled=True, + ) + ) + ) + assert families == {"memory", "experience", "profile"} + + # An unscheduled Family stays with the supervisor even in a distributed run. + assert _ledger_families(_distributed(RuntimeConfig(memory_schedule_seconds=30))) == {"memory"} + + +def test_supervisor_drops_only_the_families_the_ledger_owns() -> None: + fakes = [SimpleNamespace(artifact_family=family) for family in ("memory", "experience", "skill")] + bindings = cast("list[ArtifactProcessingBinding]", fakes) + config = _distributed(RuntimeConfig(memory_schedule_seconds=30, profile_schedule_enabled=True)) + + kept = _supervisor_bindings(config, bindings) + assert [binding.artifact_family for binding in kept] == ["experience", "skill"] + + # A single-node config owns nothing, so every binding reaches the supervisor. + assert len(_supervisor_bindings(BuiltinConfig(), bindings)) == 3 + + +def test_work_roles_split_scanning_from_claiming() -> None: + assert _work_role_components("all") == (True, True) + assert _work_role_components("api") == (False, False) + assert _work_role_components("scheduler") == (True, False) + assert _work_role_components("worker") == (False, True) def test_composition_registers_every_discoverer_and_handler(tmp_path) -> None: @@ -84,40 +137,17 @@ class _Profiles: ] -def test_unconfigured_runtime_exposes_operations_without_execution(tmp_path) -> None: +def test_single_node_runtime_exposes_operations_without_ledger_execution(tmp_path) -> None: async def scenario() -> None: - config = _config(tmp_path) + config = _config(tmp_path, runtime=RuntimeConfig(memory_schedule_seconds=30)) async with ( open_builtin_contexts(config) as contexts, - _open_work_background(BuiltinConfig(), contexts) as background, + _open_work_background(config, contexts) as background, ): assert background.operations is not None + # The supervisor owns every Family here, so the ledger stays idle. assert background.scheduler is None assert background.worker is None assert background.membership is None asyncio.run(scenario()) - - -def test_configured_single_node_runtime_owns_the_ledger_and_shuts_down(tmp_path) -> None: - async def scenario() -> None: - config = _config(tmp_path, runtime=RuntimeConfig(memory_schedule_seconds=30)) - async with ( - open_builtin_contexts(config) as contexts, - _open_work_background(config, contexts) as background, - ): - assert background.membership is not None - assert background.scheduler is not None - assert background.worker is not None - assert set(background.worker.supported) == { - MEMORY_WORK_KIND, - EXPERIENCE_WORK_KIND, - PROFILE_WORK_KIND, - MAINTENANCE_WORK_KIND, - } - # A second runtime may not claim the same single-node ownership. - with pytest.raises(DuplicateSingleNodeError): - async with _open_work_background(config, contexts): - pass - - asyncio.run(scenario()) From 8f74ab283b4d95cff044a75d082f69926141f0dc Mon Sep 17 00:00:00 2001 From: JasonDeng <15301580353@163.com> Date: Thu, 29 Oct 2026 22:05:28 +0800 Subject: [PATCH 07/45] docs: describe which scheduler owns a Family in each deployment mode The paragraph claimed a single_node install already runs scheduling and execution on the Work Ledger. It does not: the in-process artifact-processing supervisor owns the Family and the source window until RFC 1430's bridge step retires it. The text now states the real split and why a ledger-owned Family is removed from the supervisor bindings. --- .../development/remote-access-implementation.md | 15 +++++++++++---- .../development/remote-access-implementation.md | 10 ++++++++-- 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/docs/en/development/remote-access-implementation.md b/docs/en/development/remote-access-implementation.md index f7921f4a7e..dc7119768e 100644 --- a/docs/en/development/remote-access-implementation.md +++ b/docs/en/development/remote-access-implementation.md @@ -75,10 +75,17 @@ and OceanBase uses HNSW for `vector` and `hybrid` searches. Inference configuration is documented in [Configure Pydantic AI inference](pydantic-ai-inference.md). -Set `POWERCONTEXT_SERVER_RUNTIME_SCHEDULE_SECONDS` to process pending Source windows on a persisted interval. Scheduled -discovery and execution use the primary database Work Ledger. In `single_node/all` mode the embedded Scheduler and -Worker share that ledger; distributed OceanBase deployments run them as separate roles. A generation pipeline is -required by the Worker. +Set `POWERCONTEXT_SERVER_RUNTIME_SCHEDULE_SECONDS` to process pending Source windows on a persisted interval. A +`single_node/all` deployment drives Memory, Experience, and Profile through the in-process artifact-processing +supervisor, which owns the Family and the source window. A distributed OceanBase deployment replaces that scheduling +with the primary database Work Ledger and splits the roles: `scheduler` only discovers work, `worker` only claims it, and +`api` does neither. + +Exactly one path owns a Family. A Family the ledger drives is removed from the supervisor's binding set, so the two never +claim the same window: otherwise the ledger advances the cursor and the supervisor's next tick records a noop instead of +doing the work. Moving `single_node/all` itself onto the ledger is the bridge step RFC 1430 schedules for after the +in-process supervisor retires. Scheduling works with either application database and requires a configured generation +pipeline. ## HTTP surface diff --git a/docs/zh/development/remote-access-implementation.md b/docs/zh/development/remote-access-implementation.md index 6f79afb3d0..175c0751d5 100644 --- a/docs/zh/development/remote-access-implementation.md +++ b/docs/zh/development/remote-access-implementation.md @@ -72,8 +72,14 @@ export POWERCONTEXT_SERVER_DATABASE_URL="mysql+aoceanbase://user:password@host:2 inference 配置见[配置 Pydantic AI 推理](pydantic-ai-inference.md)。 设置 `POWERCONTEXT_SERVER_RUNTIME_SCHEDULE_SECONDS` 可以按持久化 interval 处理待消费的 Source window。 -定时发现和执行使用主数据库中的 Work Ledger。`single_node/all` 模式内嵌 Scheduler 与 Worker;分布式 -OceanBase 部署将它们作为独立角色运行。Worker 必须配置 generation pipeline。 +`single_node/all` 部署由进程内的 artifact processing supervisor 驱动 Memory、Experience 和 Profile,由它持有 +Family 与 Source window。分布式 OceanBase 部署用主数据库 Work Ledger 取代这一调度,并拆分角色:`scheduler` +只做发现,`worker` 只做领取,`api` 两者都不做。 + +每个 Family 只能有一个持有者。账本接管的 Family 会从 supervisor 的 binding 集合中移除,两者永不争抢同一个 +window:否则账本推进游标后,supervisor 的下一次 tick 只会记录 noop 而不再执行实际工作。把 `single_node/all` +本身迁到账本,是 RFC 1430 安排在进程内 supervisor 退役之后的桥接步骤。调度在任一应用数据库上都可用,且需要 +配置 generation pipeline。 ## HTTP 接口 From 8005d53fa7e70036bda47ec013268e5667ef4b04 Mon Sep 17 00:00:00 2001 From: JasonDeng <15301580353@163.com> Date: Thu, 29 Oct 2026 23:21:07 +0800 Subject: [PATCH 08/45] fix(dsh): rebuild the plugin bundle from the committed operation catalog The checked-in `lib/index.js` still carried the Operation endpoints plus the `Prefer`/`202` bindings on `flush_memory`, while neither `openapi/powercontext.yaml` nor the generated `src/operations.generated.ts` declares them. `make js-test` rebuilds the bundle and requires no diff, so the stale bundle failed the gate. Rebuilding from the committed catalog drops those 52 lines and makes the bundle, the generated descriptor, and the contract agree. Nothing is lost: the Operation endpoints are not in the contract yet, which is the open review item. --- .../dsh/plugins/powercontext/lib/index.js | 54 +------------------ 1 file changed, 2 insertions(+), 52 deletions(-) diff --git a/integrations/dsh/plugins/powercontext/lib/index.js b/integrations/dsh/plugins/powercontext/lib/index.js index b28c360f58..c1df7f1d68 100644 --- a/integrations/dsh/plugins/powercontext/lib/index.js +++ b/integrations/dsh/plugins/powercontext/lib/index.js @@ -760,8 +760,8 @@ const OPERATIONS = { scopeMode: "current", pathParameters: [], queryParams: [], - headerParams: ["Prefer"], - successStatuses: [200, 202], + headerParams: [], + successStatuses: [200], emptyStatuses: [] }, remember_memory: { @@ -1253,56 +1253,6 @@ const OPERATIONS = { successStatuses: [200], emptyStatuses: [] }, - list_operations: { - method: "GET", - path: "/v1/operations", - location: "query", - scopeMode: "none", - pathParameters: [], - queryParams: [ - "scope_id", - "kind", - "status", - "cursor", - "limit" - ], - headerParams: [], - successStatuses: [200], - emptyStatuses: [] - }, - get_operation: { - method: "GET", - path: "/v1/operations/{operation_id}", - location: null, - scopeMode: "none", - pathParameters: ["operation_id"], - queryParams: [], - headerParams: [], - successStatuses: [200], - emptyStatuses: [] - }, - cancel_operation: { - method: "POST", - path: "/v1/operations/{operation_id}/cancel", - location: "body", - scopeMode: "none", - pathParameters: ["operation_id"], - queryParams: [], - headerParams: [], - successStatuses: [200], - emptyStatuses: [] - }, - retry_operation: { - method: "POST", - path: "/v1/operations/{operation_id}/retry", - location: "body", - scopeMode: "none", - pathParameters: ["operation_id"], - queryParams: [], - headerParams: [], - successStatuses: [200], - emptyStatuses: [] - }, get_handoff_report: { method: "POST", path: "/v1/handoff-reports/get", From 1fa2a0c51987b3473f55c2e27b9ebc706c9f7729 Mon Sep 17 00:00:00 2001 From: JasonDeng <15301580353@163.com> Date: Fri, 30 Oct 2026 00:49:28 +0800 Subject: [PATCH 09/45] fix(config): validate a distributed deployment against its real role MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `tests/test_config_cli.py` failed on Python 3.14 in three cases: `api` and `scheduler` could not be validated without a generation model, and a bare `worker` validated even though it has none. Three separate gaps produced that inverted result. 1. `cli/config.py` built its `BuiltinConfig` from only five of the `ServerSettings` sections, so `POWERCONTEXT_SERVER_DEPLOYMENT_MODE` reached the settings object and then stopped. Validation therefore always ran the `single_node` rules — the same omission fixed earlier in `server/factory.py` and `server/cli.py`. 2. `ServerSettings` injected the workspace host-local Skill targets by default. A distributed deployment rejects those targets, so every distributed configuration was invalid before the deployment rules even ran. The defaults are now skipped for `distributed`; an explicit `external_skills` is untouched. 3. Two validators still assumed local execution: the `scheduled-pipeline` bindings check and the `preflight_builtin_runtime` interval checks. A distributed deployment runs no local supervisor or scheduler, so a persisted interval there only paces the Work Ledger discoverer. Both are now limited to local execution, and `BuiltinConfig` states the rule the worker does need: a `distributed` worker requires a generation model, because every Family it claims calls one. `tests/test_config_cli.py` 45 passed; `tests/builtin/runtime` plus the server metric/tracing/health suites 1055 passed, 1 skipped. --- .../builtin/runtime/composition.py | 21 ++++++++++++++++--- src/powercontext/builtin/runtime/config.py | 4 ++++ src/powercontext/cli/config.py | 5 +++++ src/powercontext/server/settings.py | 8 ++++++- 4 files changed, 34 insertions(+), 4 deletions(-) diff --git a/src/powercontext/builtin/runtime/composition.py b/src/powercontext/builtin/runtime/composition.py index c2d7aa3305..471e8e02a2 100644 --- a/src/powercontext/builtin/runtime/composition.py +++ b/src/powercontext/builtin/runtime/composition.py @@ -971,7 +971,10 @@ def _artifact_processing_bindings( # noqa: C901 - validate and assemble one reg "handoff": None, "prompt": None, } - for family, schedule in automatic.items(): + # A distributed deployment never runs the process-local supervisor, so a + # schedule there only paces the Work Ledger discoverer and cannot require a + # local candidate pipeline. + for family, schedule in automatic.items() if config.deployment.mode != "distributed" else (): if schedule is not None and family not in declared | registered: if ( family == "topic-memory" @@ -2156,9 +2159,21 @@ async def preflight_builtin_runtime( ) if config.inference.embedding_model is not None: await _embedding_models(config.inference, resources, None) - if config.runtime.schedule_seconds is not None and config.inference.generation_model is None: + # A distributed deployment runs no local schedule: the Work Ledger + # paces discovery and the Worker executes elsewhere, so a persisted + # interval there does not require a model in this process. + executes_scheduled_work_locally = config.deployment.mode != "distributed" + if ( + executes_scheduled_work_locally + and config.runtime.schedule_seconds is not None + and config.inference.generation_model is None + ): raise BuiltinConfigurationError("scheduled-pipeline") - if config.runtime.experience_schedule_seconds is not None and config.inference.generation_model is None: + if ( + executes_scheduled_work_locally + and config.runtime.experience_schedule_seconds is not None + and config.inference.generation_model is None + ): raise BuiltinConfigurationError("scheduled-experience-pipeline") if config.runtime.memory_rerank_enabled and ( config.inference.generation_model is None and config.inference.rerank_model is None diff --git a/src/powercontext/builtin/runtime/config.py b/src/powercontext/builtin/runtime/config.py index 24f68380bb..5aefe4e6b4 100644 --- a/src/powercontext/builtin/runtime/config.py +++ b/src/powercontext/builtin/runtime/config.py @@ -626,6 +626,10 @@ def validate_deployment(self) -> BuiltinConfig: raise ValueError( # noqa: TRY003 "distributed deployment does not support host-local external Skill targets" ) + if self.deployment.role == "worker" and self.inference.generation_model is None: + # The worker executes Memory, Experience and Profile work, and + # every one of those paths calls a generation model. + raise ValueError("distributed worker requires a generation model") # noqa: TRY003 if not isinstance(self.database, OceanBaseConfig) and self.runtime.artifact_processing_role != "all": raise ValueError( # noqa: TRY003 "runtime.artifact_processing_role must be 'all' for SQLite and embedded seekdb" diff --git a/src/powercontext/cli/config.py b/src/powercontext/cli/config.py index 6bca3bf554..1f185a94b7 100644 --- a/src/powercontext/cli/config.py +++ b/src/powercontext/cli/config.py @@ -665,6 +665,11 @@ def _validate_builtin_runtime(values: Mapping[str, str]) -> None: handoff_report=settings.handoff_report, inference=settings.inference, external_skills=settings.external_skills, + deployment=settings.deployment, + coordination=settings.coordination, + worker=settings.worker, + operations=settings.operations, + rate_limit=settings.rate_limit, ) ) ) diff --git a/src/powercontext/server/settings.py b/src/powercontext/server/settings.py index a3d2e28bea..247ddbcdb6 100644 --- a/src/powercontext/server/settings.py +++ b/src/powercontext/server/settings.py @@ -266,7 +266,13 @@ def resolve_workspace(cls, value: Path) -> Path: @model_validator(mode="after") def configure_default_local_skill_targets(self) -> ServerSettings: if "external_skills" not in self.model_fields_set: - self.external_skills = _default_local_external_skills(self.workspace) + if self.deployment.mode == "distributed": + # A distributed deployment rejects host-local Skill targets, so + # injecting the workspace defaults here would make every + # distributed configuration invalid before it is read. + self.external_skills = ExternalSkillsConfig() + else: + self.external_skills = _default_local_external_skills(self.workspace) return self @field_validator("public_url") From feb9e7f2b82e7e2c85bb931320280243ed2d0870 Mon Sep 17 00:00:00 2001 From: JasonDeng <15301580353@163.com> Date: Fri, 30 Oct 2026 10:40:34 +0800 Subject: [PATCH 10/45] test(zcode): re-issue the bounded boundary flush instead of racing its 800 ms budget `stop.mjs` bounds the boundary flush with `AbortSignal.timeout(800)`, so one Stop attempt is allowed to record `unknown` when the server round trip does not fit. The chain test asserted `cursor_reached` after a single attempt, which encodes machine speed into the assertion and failed on a loaded Python 3.14 runner. The flush is idempotent and belongs to a bounded-attempt contract, so the test now re-runs Stop until the recorded observation reaches the cursor, with the same 30 s ceiling as the surrounding harness. The assertion itself is unchanged. --- tests/e2e/test_zcode_service_chain.py | 96 +++++++++++++++------------ 1 file changed, 54 insertions(+), 42 deletions(-) diff --git a/tests/e2e/test_zcode_service_chain.py b/tests/e2e/test_zcode_service_chain.py index b01c89ccd7..8d95c7d56a 100644 --- a/tests/e2e/test_zcode_service_chain.py +++ b/tests/e2e/test_zcode_service_chain.py @@ -193,48 +193,60 @@ def run_scope(action: str, *extra: str) -> dict[str, Any]: remaining = 5 - time.time() % 5 if remaining < 1.5: time.sleep(remaining + 0.05) - stopped = subprocess.run( - [node, str(HOOK.parent / "stop.mjs")], - input=json.dumps({ - "hookEventName": "Stop", - "cwd": str(HOOK.parents[5]), - "sessionId": "old-session", - "stopHookActive": False, - }), - env={ - **environment, - "POWERCONTEXT_ZCODE_SCOPE_ID": scope_id, - "POWERCONTEXT_ZCODE_BOUNDARY_FLUSH": "true", - }, - text=True, - capture_output=True, - check=False, - timeout=2, - ) - assert stopped.returncode == 0, stopped.stderr - assert json.loads(stopped.stdout) == {} - status = subprocess.run( - [ - node, - str(HOOK.parent.parent / "scripts/status.mjs"), - "--cwd", - str(HOOK.parents[5]), - "--session-id", - "old-session", - ], - env={ - **environment, - "POWERCONTEXT_ZCODE_SCOPE_ID": scope_id, - "POWERCONTEXT_ZCODE_BOUNDARY_FLUSH": "true", - }, - text=True, - capture_output=True, - check=False, - timeout=10, - ) - assert status.returncode == 0, status.stdout + status.stderr - result = json.loads(status.stdout) - assert result["observation"]["stages"]["flush"]["state"] == "cursor_reached", result + # The Stop hook bounds its boundary flush with ``AbortSignal.timeout(800)``, + # so a single attempt is allowed to end ``unknown`` on a loaded machine. + # Re-issue the bounded flush until it observes the cursor instead of + # encoding machine speed into the assertion. + flush_state = None + result = None + deadline = time.monotonic() + 30 + while True: + stopped = subprocess.run( + [node, str(HOOK.parent / "stop.mjs")], + input=json.dumps({ + "hookEventName": "Stop", + "cwd": str(HOOK.parents[5]), + "sessionId": "old-session", + "stopHookActive": False, + }), + env={ + **environment, + "POWERCONTEXT_ZCODE_SCOPE_ID": scope_id, + "POWERCONTEXT_ZCODE_BOUNDARY_FLUSH": "true", + }, + text=True, + capture_output=True, + check=False, + timeout=2, + ) + assert stopped.returncode == 0, stopped.stderr + assert json.loads(stopped.stdout) == {} + status = subprocess.run( + [ + node, + str(HOOK.parent.parent / "scripts/status.mjs"), + "--cwd", + str(HOOK.parents[5]), + "--session-id", + "old-session", + ], + env={ + **environment, + "POWERCONTEXT_ZCODE_SCOPE_ID": scope_id, + "POWERCONTEXT_ZCODE_BOUNDARY_FLUSH": "true", + }, + text=True, + capture_output=True, + check=False, + timeout=10, + ) + assert status.returncode == 0, status.stdout + status.stderr + result = json.loads(status.stdout) + flush_state = result["observation"]["stages"]["flush"]["state"] + if flush_state == "cursor_reached" or time.monotonic() >= deadline: + break + time.sleep(0.25) + assert flush_state == "cursor_reached", result assert result["pending"]["scopes"] == [] entries = client.post("/v1/memory/entries/list", json={"scope_id": scope_id}) entries.raise_for_status() From 11f4319c3ecd7e0efd0457304404a7e6cf1c7740 Mon Sep 17 00:00:00 2001 From: JasonDeng <15301580353@163.com> Date: Fri, 30 Oct 2026 11:07:31 +0800 Subject: [PATCH 11/45] feat(server): serve the durable operation endpoints the docs promise The runtime facade has been able to inspect, cancel and retry durable work since the ledger landed, but nothing was reachable over HTTP: `docs/.../http-api.md` advertised `/v1/operations/*` while `openapi/powercontext.yaml` had no such paths, so every request returned 404. This closes that gap. The endpoints hang off the owning Scope: | Route | Access | | --- | --- | | `GET /v1/scopes/{scope_id}/operations` | `scope.read` | | `GET /v1/scopes/{scope_id}/operations/{operation_id}` | `scope.read` | | `POST /v1/scopes/{scope_id}/operations/{operation_id}/cancel` | `scope.contribute` | | `POST /v1/scopes/{scope_id}/operations/{operation_id}/retry` | `scope.contribute` | Scope-scoped paths are the point, not a detail. A durable row carries its own `scope_id`, so authorizing from the path keeps the existing resolver vocabulary and needs no database lookup inside access resolution. The path Scope is then an ownership assertion in the handler: another Scope's row, and internal maintenance work, both read as `404`, so identifiers cannot be probed. Cancel and retry take `expected_version`, map a lost optimistic race to `409 operation_conflict`, and leave the original state untouched. The response projects a safe subset of `StoredWork`; payload, logical key and lease internals stay private. `_PUBLIC_KINDS` now covers the Memory and Profile source-window families in addition to Experience, so a Scope owner can act on the work its own Sources produced while maintenance work stays invisible. Docs are corrected to match: the async `Prefer: respond-async` handle, the `202`, `409 operation_blocked` and the client polling helpers are still proposal material, and the RFC now says so instead of describing them as shipped. Contract, generated Python and JS operations, the DSH bundle and the Desktop contract digest are regenerated; `OperationStatus` is a named schema so the generator cannot invent a colliding `Status2`. --- .../src-tauri/src/transport/operations.json | 2 +- desktop/ui/src/generated/operations.ts | 2 +- docs/en/docs/develop/http-api.md | 38 +- .../rfcs/1430_distributed_server_workers.md | 8 + docs/zh/docs/develop/http-api.md | 36 +- .../rfcs/1430_distributed_server_workers.md | 6 + .../dsh/plugins/powercontext/lib/index.js | 49 ++ .../powercontext/src/operations.generated.ts | 4 + .../powercontext/src/operations.generated.ts | 4 + .../powercontext/src/operations.generated.ts | 4 + openapi/powercontext.yaml | 282 +++++++++++ .../builtin/runtime/operations.py | 10 +- src/powercontext/http/_generated/models.py | 76 +++ .../http/_generated/operations.py | 120 +++++ src/powercontext/http/_generated/schema.py | 439 ++++++++++++++++++ src/powercontext/server/app.py | 137 ++++++ tests/test_operation_routes.py | 200 ++++++++ 17 files changed, 1379 insertions(+), 38 deletions(-) create mode 100644 tests/test_operation_routes.py diff --git a/desktop/src-tauri/src/transport/operations.json b/desktop/src-tauri/src/transport/operations.json index b041e70422..f24b23ed70 100644 --- a/desktop/src-tauri/src/transport/operations.json +++ b/desktop/src-tauri/src/transport/operations.json @@ -1,5 +1,5 @@ { - "contractSha256": "c12901b88b8e958c9cd63469298b9a04b9d5e2b0b4564d83b50f7ae95542519a", + "contractSha256": "c7f00e81a12848249c7105ba99c444b6233f4b2a4849d1d4443c419f53ac764f", "operations": { "get_liveness": { "method": "GET", diff --git a/desktop/ui/src/generated/operations.ts b/desktop/ui/src/generated/operations.ts index 06889f38b5..9d402f2dcc 100644 --- a/desktop/ui/src/generated/operations.ts +++ b/desktop/ui/src/generated/operations.ts @@ -15,7 +15,7 @@ */ // Generated from openapi/powercontext.yaml. Do not edit. -export const contractSha256 = "c12901b88b8e958c9cd63469298b9a04b9d5e2b0b4564d83b50f7ae95542519a"; +export const contractSha256 = "c7f00e81a12848249c7105ba99c444b6233f4b2a4849d1d4443c419f53ac764f"; export const operations = { "get_liveness": { "method": "GET", diff --git a/docs/en/docs/develop/http-api.md b/docs/en/docs/develop/http-api.md index e4fe637a45..b1e5e2b515 100644 --- a/docs/en/docs/develop/http-api.md +++ b/docs/en/docs/develop/http-api.md @@ -93,26 +93,28 @@ curl --fail \ "$POWERCONTEXT_URL/v1/memory/search" ``` -## Flush through a durable operation +## Inspect, cancel and retry durable operations -A Memory flush can finish during the request or continue on any Worker. Ask for an immediate handle when the caller -does not want to wait: +Memory and Experience background processing is a row per bounded window in the Work Ledger. Those rows can be +inspected and controlled across processes, and only for the Scope that owns them: -```bash -curl --fail-with-body --include \ - --request POST \ - --header 'Content-Type: application/json' \ - --header 'Prefer: respond-async' \ - --header "$POWERCONTEXT_AUTH_HEADER" \ - --data '{"scope_id":"project:example"}' \ - "$POWERCONTEXT_URL/v1/memory/flush" -``` +| Method and path | Purpose | Access | +| --- | --- | --- | +| `GET /v1/scopes/{scope_id}/operations` | Page the Scope's public operations, optionally filtered by `kind` and `status` | `scope.read` | +| `GET /v1/scopes/{scope_id}/operations/{operation_id}` | Read one operation's current state | `scope.read` | +| `POST /v1/scopes/{scope_id}/operations/{operation_id}/cancel` | Request cancellation | `scope.contribute` | +| `POST /v1/scopes/{scope_id}/operations/{operation_id}/retry` | Grant one fresh bounded attempt to a failed operation | `scope.contribute` | + +Cancel and retry must send the `state_version` that was read as `expected_version`; `409 operation_conflict` means a +concurrent transition won and the original state is preserved. A running operation moves to `cancelling` and stops at +its next fence. After success, failure or cancellation `state_version` stops changing, so repeated reads are safe. + +An operation owned by another Scope, and internal maintenance work, both return `404`: neither is listed, and neither +can be probed by id. -HTTP `200` contains the completed `FlushMemoryResponse`. HTTP `202` contains an operation ID and includes relative -`Location` and `Retry-After` headers. `Prefer: wait=N` waits up to 30 seconds. Poll -`GET /v1/operations/{operation_id}`; use the returned `state_version` as `expected_version` when cancelling a queued or -running operation, or retrying a blocked failed operation. A failed logical window returns `409 operation_blocked` on -another flush until an operator retries or cancels it. +These operations come from the Work Ledger, so they appear in a `distributed` deployment; a `single_node` install keeps +processing Source windows through the APScheduler supervisor and has no such rows. `POST /v1/memory/flush` still +completes inside the request with `200` and does not return a pollable handle for `Prefer: respond-async`. ## Scope-owned operational Prompts @@ -232,7 +234,7 @@ use the same policy enforcement point; MCP tool visibility is not permission. | External Skills | `/v1/external-skills/*` | Scan configured targets and resolve or import packages | | Handoff Reports | `/v1/handoff-reports/*` | Generate a read-only report for a Scope selection | | Statistics | `/v1/stats` | Read scoped usage statistics | -| Durable operations | `/v1/operations/*` | Inspect, list, cancel, or recover Memory and Experience background work | +| Durable operations | `/v1/scopes/{scope_id}/operations*` | Inspect, list, cancel, or recover Memory and Experience background work | Create an Artifact with `POST /v1/scopes/{scope_id}/artifacts`, specifying `family` in the request body. Profile candidate review uses `/v1/candidates/*`. diff --git a/docs/en/rfcs/1430_distributed_server_workers.md b/docs/en/rfcs/1430_distributed_server_workers.md index b498dd5eee..547d77c6d6 100644 --- a/docs/en/rfcs/1430_distributed_server_workers.md +++ b/docs/en/rfcs/1430_distributed_server_workers.md @@ -157,6 +157,14 @@ bodies carry `expected_version`; illegal or stale transitions return `409`. Publ `running`, `retry_wait`, `cancelling`, `succeeded`, `failed`, and `cancelled`. Internal maintenance work is not exposed by the Operation API. +Implementation status in this change: the Operation API is served under the owning Scope +(`GET|POST /v1/scopes/{scope_id}/operations[...]`), so authorization resolves from the request and a row belonging to +another Scope reads as `404` exactly like maintenance work. Optimistic cancel and operator retry, the seven public +states, and the maintenance whitelist are implemented. Still proposed and not implemented: the `202 OperationAccepted` +handle, `Prefer: respond-async` / `Prefer: wait=N`, `409 operation_blocked` from a blocking failed window, and the +client `submit_memory_flush()` / polling helpers. Until those land, `POST /v1/memory/flush` keeps answering `200` +synchronously. + `PowerContextClient.flush_memory()` preserves its synchronous-looking result by submitting and polling until its total deadline. If that deadline expires it raises `OperationPendingError` with the operation ID. Callers that want explicit control use `submit_memory_flush()`, `get_operation()`, `list_operations()`, `cancel_operation()`, and diff --git a/docs/zh/docs/develop/http-api.md b/docs/zh/docs/develop/http-api.md index bb5e672eaa..68c6669e62 100644 --- a/docs/zh/docs/develop/http-api.md +++ b/docs/zh/docs/develop/http-api.md @@ -86,24 +86,28 @@ curl --fail \ "$POWERCONTEXT_URL/v1/memory/search" ``` -## 通过持久 Operation 执行 flush +## 检查、取消与重试持久 Operation -Memory flush 可以在当前请求内完成,也可以由任意 Worker 继续执行。不希望等待时,显式请求立即返回句柄: +Memory 与 Experience 的后台处理是一行行持久化的 Work Ledger operation。它们可以跨进程检查和控制,且只 +暴露给该 Scope 的访问者: -```bash -curl --fail-with-body --include \ - --request POST \ - --header 'Content-Type: application/json' \ - --header 'Prefer: respond-async' \ - --header "$POWERCONTEXT_AUTH_HEADER" \ - --data '{"scope_id":"project:example"}' \ - "$POWERCONTEXT_URL/v1/memory/flush" -``` +| 方法与路径 | 作用 | 权限 | +| --- | --- | --- | +| `GET /v1/scopes/{scope_id}/operations` | 分页列出该 Scope 的公开 operation,可用 `kind`、`status` 过滤 | `scope.read` | +| `GET /v1/scopes/{scope_id}/operations/{operation_id}` | 读取一个 operation 的当前状态 | `scope.read` | +| `POST /v1/scopes/{scope_id}/operations/{operation_id}/cancel` | 请求取消 | `scope.contribute` | +| `POST /v1/scopes/{scope_id}/operations/{operation_id}/retry` | 为 failed operation 再给一次有界尝试 | `scope.contribute` | + +取消与重试都必须把读取时的 `state_version` 作为 `expected_version` 传回;`409 operation_conflict` 表示该 +operation 已被并发状态变更抢先,原状态不受影响。running operation 收到取消请求后进入 `cancelling`,在下一次 +fence 停止。成功、失败、取消之后 `state_version` 不再变化,重复读取是安全的。 + +另一个 Scope 的 operation 与内部 maintenance work 一律返回 `404`:两者既不出现在列表里,也不能通过 ID 探测 +是否存在。 -HTTP `200` 返回已经完成的 `FlushMemoryResponse`;HTTP `202` 返回 operation ID,并携带相对 `Location` 和 -`Retry-After` header。`Prefer: wait=N` 最多等待 30 秒。通过 `GET /v1/operations/{operation_id}` 轮询;取消 queued -或 running operation,以及恢复被阻塞的 failed operation 时,必须把响应中的 `state_version` 作为 -`expected_version` 传回。同一逻辑窗口失败后,后续 flush 返回 `409 operation_blocked`,直到 operator retry 或 cancel。 +这些 operation 只由 Work Ledger 产生,因此出现在 `distributed` 部署中;`single_node` 仍由 APScheduler +supervisor 处理 Source 窗口,没有对应的 operation 行。`POST /v1/memory/flush` 目前仍在请求内同步完成并返回 +`200`,不会为 `Prefer: respond-async` 返回可轮询的句柄。 ## Scope 内的操作提示词 @@ -211,7 +215,7 @@ Principal,并注入 Authorization Provider。HTTP 与 MCP 使用同一个策 | 外部 Skill | `/v1/external-skills/*` | 扫描已配置 target,解析或导入 package | | Handoff Report | `/v1/handoff-reports/*` | 按 Scope selection 生成只读报告 | | 统计 | `/v1/stats` | 读取指定 scope 的使用统计 | -| 持久 Operation | `/v1/operations/*` | 检查、列出、取消或恢复 Memory/Experience 后台任务 | +| 持久 Operation | `/v1/scopes/{scope_id}/operations*` | 检查、列出、取消或恢复 Memory/Experience 后台任务 | 创建 Artifact 使用 `POST /v1/scopes/{scope_id}/artifacts`,在请求体中指定 `family`。 Profile 的候选审核复用 `/v1/candidates/*`。 diff --git a/docs/zh/rfcs/1430_distributed_server_workers.md b/docs/zh/rfcs/1430_distributed_server_workers.md index f6049127bd..e8961bbf85 100644 --- a/docs/zh/rfcs/1430_distributed_server_workers.md +++ b/docs/zh/rfcs/1430_distributed_server_workers.md @@ -142,6 +142,12 @@ attempt。无法识别的 payload version 保持在 lane head 可见,并使 Wo 公开状态为 `queued`、`running`、`retry_wait`、`cancelling`、`succeeded`、`failed`、`cancelled`。内部 maintenance work 不会通过 Operation API 暴露。 +本次变更的实现状态:Operation API 挂在所属 Scope 下(`GET|POST /v1/scopes/{scope_id}/operations[...]`),授权直接 +从请求解析,属于其它 Scope 的 operation 与 maintenance work 一样返回 `404`。乐观并发 cancel、operator retry、七个 +公开状态与 maintenance 白名单已实现。仍处于提案、尚未实现的部分:`202 OperationAccepted` 句柄、`Prefer: +respond-async` / `Prefer: wait=N`、失败窗口导致的 `409 operation_blocked`,以及客户端 +`submit_memory_flush()` 与轮询辅助方法。在这些落地之前,`POST /v1/memory/flush` 仍同步返回 `200`。 + `PowerContextClient.flush_memory()` 通过 submit + poll 保持原有同步式返回;总 deadline 到期时抛出携带 operation ID 的 `OperationPendingError`。需要显式控制的调用方使用 `submit_memory_flush()`、`get_operation()`、`list_operations()`、 `cancel_operation()` 和 `retry_operation()`。 diff --git a/integrations/dsh/plugins/powercontext/lib/index.js b/integrations/dsh/plugins/powercontext/lib/index.js index c1df7f1d68..81e98ee6be 100644 --- a/integrations/dsh/plugins/powercontext/lib/index.js +++ b/integrations/dsh/plugins/powercontext/lib/index.js @@ -238,6 +238,55 @@ const OPERATIONS = { successStatuses: [200, 304], emptyStatuses: [304] }, + list_operations: { + method: "GET", + path: "/v1/scopes/{scope_id}/operations", + location: "query", + scopeMode: "none", + pathParameters: ["scope_id"], + queryParams: [ + "kind", + "status", + "limit", + "cursor" + ], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, + get_operation: { + method: "GET", + path: "/v1/scopes/{scope_id}/operations/{operation_id}", + location: null, + scopeMode: "none", + pathParameters: ["scope_id", "operation_id"], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, + cancel_operation: { + method: "POST", + path: "/v1/scopes/{scope_id}/operations/{operation_id}/cancel", + location: "body", + scopeMode: "none", + pathParameters: ["scope_id", "operation_id"], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, + retry_operation: { + method: "POST", + path: "/v1/scopes/{scope_id}/operations/{operation_id}/retry", + location: "body", + scopeMode: "none", + pathParameters: ["scope_id", "operation_id"], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, create_subject_source: { method: "POST", path: "/v1/scopes/{scope_id}/subject-sources", diff --git a/integrations/dsh/plugins/powercontext/src/operations.generated.ts b/integrations/dsh/plugins/powercontext/src/operations.generated.ts index 4d3805f2e8..45d1e305d9 100644 --- a/integrations/dsh/plugins/powercontext/src/operations.generated.ts +++ b/integrations/dsh/plugins/powercontext/src/operations.generated.ts @@ -24,6 +24,10 @@ export const OPERATIONS = { preview_atomic_memory_restoration: { method: 'POST', path: '/v1/atomic-memory/restoration-previews', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, restore_atomic_memory: { method: 'POST', path: '/v1/atomic-memory/restorations', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, get_atomic_memory_state: { method: 'GET', path: '/v1/scopes/{scope_id}/artifacts/atomic-memory/{artifact_id}/state', location: null, scopeMode: 'current', pathParameters: ['scope_id', 'artifact_id'], queryParams: [], headerParams: ['If-None-Match'], successStatuses: [200,304], emptyStatuses: [304] }, + list_operations: { method: 'GET', path: '/v1/scopes/{scope_id}/operations', location: "query", scopeMode: 'none', pathParameters: ['scope_id'], queryParams: ['kind','status','limit','cursor'], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + get_operation: { method: 'GET', path: '/v1/scopes/{scope_id}/operations/{operation_id}', location: null, scopeMode: 'none', pathParameters: ['scope_id', 'operation_id'], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + cancel_operation: { method: 'POST', path: '/v1/scopes/{scope_id}/operations/{operation_id}/cancel', location: "body", scopeMode: 'none', pathParameters: ['scope_id', 'operation_id'], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + retry_operation: { method: 'POST', path: '/v1/scopes/{scope_id}/operations/{operation_id}/retry', location: "body", scopeMode: 'none', pathParameters: ['scope_id', 'operation_id'], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, create_subject_source: { method: 'POST', path: '/v1/scopes/{scope_id}/subject-sources', location: "body", scopeMode: 'none', pathParameters: ['scope_id'], queryParams: [], headerParams: [], successStatuses: [201], emptyStatuses: [] }, get_profile_policy: { method: 'GET', path: '/v1/scopes/{scope_id}/profile-policy', location: null, scopeMode: 'none', pathParameters: ['scope_id'], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, put_profile_policy: { method: 'PUT', path: '/v1/scopes/{scope_id}/profile-policy', location: "body", scopeMode: 'none', pathParameters: ['scope_id'], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, diff --git a/integrations/opencode/plugins/powercontext/src/operations.generated.ts b/integrations/opencode/plugins/powercontext/src/operations.generated.ts index 4d3805f2e8..45d1e305d9 100644 --- a/integrations/opencode/plugins/powercontext/src/operations.generated.ts +++ b/integrations/opencode/plugins/powercontext/src/operations.generated.ts @@ -24,6 +24,10 @@ export const OPERATIONS = { preview_atomic_memory_restoration: { method: 'POST', path: '/v1/atomic-memory/restoration-previews', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, restore_atomic_memory: { method: 'POST', path: '/v1/atomic-memory/restorations', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, get_atomic_memory_state: { method: 'GET', path: '/v1/scopes/{scope_id}/artifacts/atomic-memory/{artifact_id}/state', location: null, scopeMode: 'current', pathParameters: ['scope_id', 'artifact_id'], queryParams: [], headerParams: ['If-None-Match'], successStatuses: [200,304], emptyStatuses: [304] }, + list_operations: { method: 'GET', path: '/v1/scopes/{scope_id}/operations', location: "query", scopeMode: 'none', pathParameters: ['scope_id'], queryParams: ['kind','status','limit','cursor'], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + get_operation: { method: 'GET', path: '/v1/scopes/{scope_id}/operations/{operation_id}', location: null, scopeMode: 'none', pathParameters: ['scope_id', 'operation_id'], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + cancel_operation: { method: 'POST', path: '/v1/scopes/{scope_id}/operations/{operation_id}/cancel', location: "body", scopeMode: 'none', pathParameters: ['scope_id', 'operation_id'], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + retry_operation: { method: 'POST', path: '/v1/scopes/{scope_id}/operations/{operation_id}/retry', location: "body", scopeMode: 'none', pathParameters: ['scope_id', 'operation_id'], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, create_subject_source: { method: 'POST', path: '/v1/scopes/{scope_id}/subject-sources', location: "body", scopeMode: 'none', pathParameters: ['scope_id'], queryParams: [], headerParams: [], successStatuses: [201], emptyStatuses: [] }, get_profile_policy: { method: 'GET', path: '/v1/scopes/{scope_id}/profile-policy', location: null, scopeMode: 'none', pathParameters: ['scope_id'], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, put_profile_policy: { method: 'PUT', path: '/v1/scopes/{scope_id}/profile-policy', location: "body", scopeMode: 'none', pathParameters: ['scope_id'], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, diff --git a/integrations/pi/plugins/powercontext/src/operations.generated.ts b/integrations/pi/plugins/powercontext/src/operations.generated.ts index 4d3805f2e8..45d1e305d9 100644 --- a/integrations/pi/plugins/powercontext/src/operations.generated.ts +++ b/integrations/pi/plugins/powercontext/src/operations.generated.ts @@ -24,6 +24,10 @@ export const OPERATIONS = { preview_atomic_memory_restoration: { method: 'POST', path: '/v1/atomic-memory/restoration-previews', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, restore_atomic_memory: { method: 'POST', path: '/v1/atomic-memory/restorations', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, get_atomic_memory_state: { method: 'GET', path: '/v1/scopes/{scope_id}/artifacts/atomic-memory/{artifact_id}/state', location: null, scopeMode: 'current', pathParameters: ['scope_id', 'artifact_id'], queryParams: [], headerParams: ['If-None-Match'], successStatuses: [200,304], emptyStatuses: [304] }, + list_operations: { method: 'GET', path: '/v1/scopes/{scope_id}/operations', location: "query", scopeMode: 'none', pathParameters: ['scope_id'], queryParams: ['kind','status','limit','cursor'], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + get_operation: { method: 'GET', path: '/v1/scopes/{scope_id}/operations/{operation_id}', location: null, scopeMode: 'none', pathParameters: ['scope_id', 'operation_id'], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + cancel_operation: { method: 'POST', path: '/v1/scopes/{scope_id}/operations/{operation_id}/cancel', location: "body", scopeMode: 'none', pathParameters: ['scope_id', 'operation_id'], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + retry_operation: { method: 'POST', path: '/v1/scopes/{scope_id}/operations/{operation_id}/retry', location: "body", scopeMode: 'none', pathParameters: ['scope_id', 'operation_id'], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, create_subject_source: { method: 'POST', path: '/v1/scopes/{scope_id}/subject-sources', location: "body", scopeMode: 'none', pathParameters: ['scope_id'], queryParams: [], headerParams: [], successStatuses: [201], emptyStatuses: [] }, get_profile_policy: { method: 'GET', path: '/v1/scopes/{scope_id}/profile-policy', location: null, scopeMode: 'none', pathParameters: ['scope_id'], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, put_profile_policy: { method: 'PUT', path: '/v1/scopes/{scope_id}/profile-policy', location: "body", scopeMode: 'none', pathParameters: ['scope_id'], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, diff --git a/openapi/powercontext.yaml b/openapi/powercontext.yaml index f08902602e..da065750f0 100644 --- a/openapi/powercontext.yaml +++ b/openapi/powercontext.yaml @@ -281,6 +281,183 @@ paths: '422': {$ref: '#/components/responses/InvalidRequest'} '500': {$ref: '#/components/responses/InternalError'} '503': {$ref: '#/components/responses/Unavailable'} + /v1/scopes/{scope_id}/operations: + get: + tags: [operations] + summary: List durable operations in one Scope + description: >- + List a snapshot-bounded page of public durable work in one Scope, newest first. Only the operation + families a Scope owner can act on are listed; internal maintenance work stays invisible and reads as + not found. Use the returned next_cursor to continue. + operationId: list_operations + x-powercontext-access: + action: "scope.read" + resource: + type: "scope" + scope-id-from: "scope_id" + parameters: + - name: "scope_id" + in: "path" + required: true + schema: {type: string, minLength: 1, maxLength: 256, pattern: '.*\S.*'} + - name: "kind" + in: "query" + required: false + schema: {type: string, minLength: 1, maxLength: 128} + - name: "status" + in: "query" + required: false + schema: + type: string + enum: [queued, running, retry_wait, cancelling, succeeded, failed, cancelled] + - name: "limit" + in: "query" + required: false + schema: {type: integer, minimum: 1, maximum: 100, default: 50} + - name: "cursor" + in: "query" + required: false + schema: {type: string, minLength: 1, maxLength: 4096} + responses: + '200': + description: One stable page of public durable operations. + headers: + X-PowerContext-Request-ID: {$ref: '#/components/headers/RequestId'} + content: + application/json: + schema: {$ref: '#/components/schemas/DurableOperationPage'} + '400': {$ref: '#/components/responses/BadRequest'} + '401': {$ref: '#/components/responses/Unauthorized'} + '403': {$ref: '#/components/responses/Forbidden'} + '404': {$ref: '#/components/responses/NotFound'} + '410': {$ref: '#/components/responses/CursorExpired'} + '422': {$ref: '#/components/responses/InvalidRequest'} + '500': {$ref: '#/components/responses/InternalError'} + '503': {$ref: '#/components/responses/Unavailable'} + /v1/scopes/{scope_id}/operations/{operation_id}: + get: + tags: [operations] + summary: Read one durable operation + description: >- + Read the current state of one public durable operation owned by this Scope. An operation belonging to + another Scope, or internal maintenance work, reads as not found rather than revealing its existence. + operationId: get_operation + x-powercontext-access: + action: "scope.read" + resource: + type: "scope" + scope-id-from: "scope_id" + parameters: + - name: "scope_id" + in: "path" + required: true + schema: {type: string, minLength: 1, maxLength: 256, pattern: '.*\S.*'} + - name: "operation_id" + in: "path" + required: true + schema: {type: string, minLength: 1, maxLength: 256, pattern: '.*\S.*'} + responses: + '200': + description: The current operation state. + headers: + X-PowerContext-Request-ID: {$ref: '#/components/headers/RequestId'} + content: + application/json: + schema: {$ref: '#/components/schemas/DurableOperation'} + '401': {$ref: '#/components/responses/Unauthorized'} + '403': {$ref: '#/components/responses/Forbidden'} + '404': {$ref: '#/components/responses/NotFound'} + '422': {$ref: '#/components/responses/InvalidRequest'} + '500': {$ref: '#/components/responses/InternalError'} + '503': {$ref: '#/components/responses/Unavailable'} + /v1/scopes/{scope_id}/operations/{operation_id}/cancel: + post: + tags: [operations] + summary: Cancel one durable operation + description: >- + Request cancellation of a queued or running operation. expected_version must be the state_version of the + read that decided to cancel, so a concurrent transition fails with 409 instead of silently winning. A + running operation moves to cancelling and stops at its next fence; repeating the request with the + current version is idempotent. + operationId: cancel_operation + x-powercontext-access: + action: "scope.contribute" + resource: + type: "scope" + scope-id-from: "scope_id" + parameters: + - name: "scope_id" + in: "path" + required: true + schema: {type: string, minLength: 1, maxLength: 256, pattern: '.*\S.*'} + - name: "operation_id" + in: "path" + required: true + schema: {type: string, minLength: 1, maxLength: 256, pattern: '.*\S.*'} + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/MutateOperationRequest'} + responses: + '200': + description: The operation after the cancellation request. + headers: + X-PowerContext-Request-ID: {$ref: '#/components/headers/RequestId'} + content: + application/json: + schema: {$ref: '#/components/schemas/DurableOperation'} + '401': {$ref: '#/components/responses/Unauthorized'} + '403': {$ref: '#/components/responses/Forbidden'} + '404': {$ref: '#/components/responses/NotFound'} + '409': {$ref: '#/components/responses/Conflict'} + '422': {$ref: '#/components/responses/InvalidRequest'} + '500': {$ref: '#/components/responses/InternalError'} + '503': {$ref: '#/components/responses/Unavailable'} + /v1/scopes/{scope_id}/operations/{operation_id}/retry: + post: + tags: [operations] + summary: Retry one failed durable operation + description: >- + Grant one fresh bounded attempt to a failed operation, without advancing the Cursor or discarding the + Sources it was selected from. expected_version must be the state_version of the read that decided to + retry. Only a failed operation can be retried; any other state returns 409 and the original state is + preserved. + operationId: retry_operation + x-powercontext-access: + action: "scope.contribute" + resource: + type: "scope" + scope-id-from: "scope_id" + parameters: + - name: "scope_id" + in: "path" + required: true + schema: {type: string, minLength: 1, maxLength: 256, pattern: '.*\S.*'} + - name: "operation_id" + in: "path" + required: true + schema: {type: string, minLength: 1, maxLength: 256, pattern: '.*\S.*'} + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/MutateOperationRequest'} + responses: + '200': + description: The operation after the retry was accepted. + headers: + X-PowerContext-Request-ID: {$ref: '#/components/headers/RequestId'} + content: + application/json: + schema: {$ref: '#/components/schemas/DurableOperation'} + '401': {$ref: '#/components/responses/Unauthorized'} + '403': {$ref: '#/components/responses/Forbidden'} + '404': {$ref: '#/components/responses/NotFound'} + '409': {$ref: '#/components/responses/Conflict'} + '422': {$ref: '#/components/responses/InvalidRequest'} + '500': {$ref: '#/components/responses/InternalError'} + '503': {$ref: '#/components/responses/Unavailable'} /v1/scopes/{scope_id}/subject-sources: post: tags: ["profile"] @@ -9853,6 +10030,111 @@ components: properties: scope_id: {type: string, minLength: 1, maxLength: 256, pattern: '.*\S.*'} block_id: {type: string, minLength: 1, maxLength: 36} + OperationStatus: + type: string + description: Lifecycle state of one durable operation. + enum: [queued, running, retry_wait, cancelling, succeeded, failed, cancelled] + DurableOperation: + type: object + additionalProperties: false + required: + - operation_id + - kind + - scope_id + - status + - state_version + - attempt_count + - max_attempts + - cancel_requested + - available_at + - created_at + - updated_at + - completed_at + - result_code + - error_category + - error_code + properties: + operation_id: {type: string, minLength: 1, maxLength: 256} + kind: + type: string + minLength: 1 + maxLength: 128 + description: Durable work family, for example powercontext.memory.source-window. + scope_id: {type: string, minLength: 1, maxLength: 256, pattern: '.*\S.*'} + status: {$ref: '#/components/schemas/OperationStatus'} + state_version: + type: integer + minimum: 1 + description: Monotonic version to send as expected_version when cancelling or retrying. + attempt_count: {type: integer, minimum: 0} + max_attempts: {type: integer, minimum: 1} + cancel_requested: + type: boolean + description: True once cancellation was requested and the operation has not stopped yet. + available_at: {type: string, format: date-time} + created_at: {type: string, format: date-time} + updated_at: {type: string, format: date-time} + completed_at: + type: string + format: date-time + nullable: true + description: Set once the operation reached a terminal state. + result_code: + type: string + nullable: true + description: Sanitized result classification for a succeeded operation. + error_category: + type: string + nullable: true + error_code: + type: string + nullable: true + ListOperationsRequest: + type: object + additionalProperties: false + properties: + kind: + type: string + minLength: 1 + maxLength: 128 + nullable: true + status: + type: string + minLength: 1 + maxLength: 32 + nullable: true + description: One lifecycle state; unknown values are rejected with 422. + limit: + type: integer + minimum: 1 + maximum: 100 + default: 50 + cursor: + type: string + minLength: 1 + maxLength: 4096 + nullable: true + DurableOperationPage: + type: object + additionalProperties: false + required: [items, next_cursor] + properties: + items: + type: array + items: {$ref: '#/components/schemas/DurableOperation'} + next_cursor: + type: string + nullable: true + description: Opaque cursor for the next page; null when this page is the last. + MutateOperationRequest: + type: object + additionalProperties: false + required: [expected_version] + properties: + expected_version: + type: integer + minimum: 1 + description: The state_version read before deciding to cancel or retry. TopicMemoryProcessingBlock: type: object additionalProperties: false diff --git a/src/powercontext/builtin/runtime/operations.py b/src/powercontext/builtin/runtime/operations.py index 35efa361bf..73c553b415 100644 --- a/src/powercontext/builtin/runtime/operations.py +++ b/src/powercontext/builtin/runtime/operations.py @@ -31,12 +31,18 @@ from powercontext.builtin.runtime.config import OperationsConfig, WorkerConfig from powercontext.builtin.runtime.protocols import RuntimeSpan, RuntimeTracing from powercontext.builtin.runtime.relational import RelationalContexts -from powercontext.builtin.runtime.work_handlers import EXPERIENCE_WORK_KIND +from powercontext.builtin.runtime.work_handlers import ( + EXPERIENCE_WORK_KIND, + MEMORY_WORK_KIND, + PROFILE_WORK_KIND, +) from powercontext.builtin.runtime.work_observability import WorkObserver, refresh_work_queue from powercontext.builtin.runtime.worker import DurableWorker _TERMINAL = frozenset({WorkStatus.SUCCEEDED, WorkStatus.FAILED, WorkStatus.CANCELLED}) -_PUBLIC_KINDS = frozenset({EXPERIENCE_WORK_KIND}) +# Scope owners may inspect, cancel and retry the work their own Sources produced. +# Maintenance work stays private: it is neither listed nor reachable by id. +_PUBLIC_KINDS = frozenset({MEMORY_WORK_KIND, PROFILE_WORK_KIND, EXPERIENCE_WORK_KIND}) @dataclass(frozen=True) diff --git a/src/powercontext/http/_generated/models.py b/src/powercontext/http/_generated/models.py index f97992dcb7..9dcbfeaa9d 100644 --- a/src/powercontext/http/_generated/models.py +++ b/src/powercontext/http/_generated/models.py @@ -1696,6 +1696,82 @@ class RetryTopicMemoryProcessingRequest(BaseModel): block_id: Annotated[StrictStr, Field(max_length=36, min_length=1)] +class OperationStatus(StrEnum): + QUEUED = "queued" + RUNNING = "running" + RETRY_WAIT = "retry_wait" + CANCELLING = "cancelling" + SUCCEEDED = "succeeded" + FAILED = "failed" + CANCELLED = "cancelled" + + +class DurableOperation(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + operation_id: Annotated[StrictStr, Field(max_length=256, min_length=1)] + kind: Annotated[ + StrictStr, + Field( + description="Durable work family, for example powercontext.memory.source-window.", + max_length=128, + min_length=1, + ), + ] + scope_id: Annotated[StrictStr, Field(max_length=256, min_length=1, pattern=".*\\S.*")] + status: OperationStatus + state_version: Annotated[ + StrictInt, Field(description="Monotonic version to send as expected_version when cancelling or retrying.", ge=1) + ] + attempt_count: Annotated[StrictInt, Field(ge=0)] + max_attempts: Annotated[StrictInt, Field(ge=1)] + cancel_requested: Annotated[ + StrictBool, Field(description="True once cancellation was requested and the operation has not stopped yet.") + ] + available_at: AwareDatetime + created_at: AwareDatetime + updated_at: AwareDatetime + completed_at: Annotated[AwareDatetime | None, Field(description="Set once the operation reached a terminal state.")] + result_code: Annotated[ + StrictStr | None, Field(description="Sanitized result classification for a succeeded operation.") + ] + error_category: Annotated[StrictStr | None, Field(...)] + error_code: Annotated[StrictStr | None, Field(...)] + + +class ListOperationsRequest(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + kind: Annotated[StrictStr | None, Field(max_length=128, min_length=1)] = None + status: Annotated[ + StrictStr | None, + Field(description="One lifecycle state; unknown values are rejected with 422.", max_length=32, min_length=1), + ] = None + limit: Annotated[StrictInt, Field(ge=1, le=100)] = 50 + cursor: Annotated[StrictStr | None, Field(max_length=4096, min_length=1)] = None + + +class DurableOperationPage(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + items: list[DurableOperation] + next_cursor: Annotated[ + StrictStr | None, Field(description="Opaque cursor for the next page; null when this page is the last.") + ] + + +class MutateOperationRequest(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + expected_version: Annotated[ + StrictInt, Field(description="The state_version read before deciding to cancel or retry.", ge=1) + ] + + class TopicMemoryProcessingBlock(BaseModel): model_config = ConfigDict( extra="forbid", diff --git a/src/powercontext/http/_generated/operations.py b/src/powercontext/http/_generated/operations.py index b96333617a..303528cb4b 100644 --- a/src/powercontext/http/_generated/operations.py +++ b/src/powercontext/http/_generated/operations.py @@ -66,6 +66,8 @@ DownloadRemoteSkillPackageRequest, DreamRun, DreamRunPage, + DurableOperation, + DurableOperationPage, EnrollRemoteSkillTargetRequest, ExperienceArtifact, ExternalSkillResolution, @@ -118,6 +120,7 @@ ListMemoryChangesResponse, ListMemoryEntriesRequest, ListMemoryEntriesResponse, + ListOperationsRequest, ListRemoteSkillTargetsRequest, ListRemoteSkillTargetsResponse, ListScopesRequest, @@ -125,6 +128,7 @@ MemoryCapacity, MemoryMutationResponse, MergeAtomicMemoryRequest, + MutateOperationRequest, PrepareContextRequest, PreparedContext, PreparedHandoff, @@ -436,6 +440,122 @@ class AccessRequirement(BaseModel): access=AccessRequirement(action=None, resource=None, scope_id_field=None, resolver="atomic_memory_state_access"), ) +LIST_OPERATIONS = Operation[ListOperationsRequest, DurableOperationPage]( + method="GET", + path="/v1/scopes/{scope_id}/operations", + operation_id="list_operations", + request_type=ListOperationsRequest, + request_location="query", + path_parameters=("scope_id",), + response_type=DurableOperationPage, + success_status=200, + summary="List durable operations in one Scope", + tags=("operations",), + scope_mode="none", + responses={ + 200: { + "description": "One stable page of public durable operations.", + "headers": {"X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}}, + }, + 400: {"$ref": "#/components/responses/BadRequest"}, + 401: {"$ref": "#/components/responses/Unauthorized"}, + 403: {"$ref": "#/components/responses/Forbidden"}, + 404: {"$ref": "#/components/responses/NotFound"}, + 410: {"$ref": "#/components/responses/CursorExpired"}, + 422: {"$ref": "#/components/responses/InvalidRequest"}, + 500: {"$ref": "#/components/responses/InternalError"}, + 503: {"$ref": "#/components/responses/Unavailable"}, + }, + access=AccessRequirement(action="scope.read", resource="scope", scope_id_field="scope_id", resolver="request"), +) + +GET_OPERATION = Operation[None, DurableOperation]( + method="GET", + path="/v1/scopes/{scope_id}/operations/{operation_id}", + operation_id="get_operation", + request_type=None, + request_location=None, + path_parameters=("scope_id", "operation_id"), + response_type=DurableOperation, + success_status=200, + summary="Read one durable operation", + tags=("operations",), + scope_mode="none", + responses={ + 200: { + "description": "The current operation state.", + "headers": {"X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}}, + }, + 401: {"$ref": "#/components/responses/Unauthorized"}, + 403: {"$ref": "#/components/responses/Forbidden"}, + 404: {"$ref": "#/components/responses/NotFound"}, + 422: {"$ref": "#/components/responses/InvalidRequest"}, + 500: {"$ref": "#/components/responses/InternalError"}, + 503: {"$ref": "#/components/responses/Unavailable"}, + }, + access=AccessRequirement(action="scope.read", resource="scope", scope_id_field="scope_id", resolver="request"), +) + +CANCEL_OPERATION = Operation[MutateOperationRequest, DurableOperation]( + method="POST", + path="/v1/scopes/{scope_id}/operations/{operation_id}/cancel", + operation_id="cancel_operation", + request_type=MutateOperationRequest, + request_location="body", + path_parameters=("scope_id", "operation_id"), + response_type=DurableOperation, + success_status=200, + summary="Cancel one durable operation", + tags=("operations",), + scope_mode="none", + responses={ + 200: { + "description": "The operation after the cancellation request.", + "headers": {"X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}}, + }, + 401: {"$ref": "#/components/responses/Unauthorized"}, + 403: {"$ref": "#/components/responses/Forbidden"}, + 404: {"$ref": "#/components/responses/NotFound"}, + 409: {"$ref": "#/components/responses/Conflict"}, + 422: {"$ref": "#/components/responses/InvalidRequest"}, + 500: {"$ref": "#/components/responses/InternalError"}, + 503: {"$ref": "#/components/responses/Unavailable"}, + }, + access=AccessRequirement( + action="scope.contribute", resource="scope", scope_id_field="scope_id", resolver="request" + ), +) + +RETRY_OPERATION = Operation[MutateOperationRequest, DurableOperation]( + method="POST", + path="/v1/scopes/{scope_id}/operations/{operation_id}/retry", + operation_id="retry_operation", + request_type=MutateOperationRequest, + request_location="body", + path_parameters=("scope_id", "operation_id"), + response_type=DurableOperation, + success_status=200, + summary="Retry one failed durable operation", + tags=("operations",), + scope_mode="none", + responses={ + 200: { + "description": "The operation after the retry was accepted.", + "headers": {"X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}}, + }, + 401: {"$ref": "#/components/responses/Unauthorized"}, + 403: {"$ref": "#/components/responses/Forbidden"}, + 404: {"$ref": "#/components/responses/NotFound"}, + 409: {"$ref": "#/components/responses/Conflict"}, + 422: {"$ref": "#/components/responses/InvalidRequest"}, + 500: {"$ref": "#/components/responses/InternalError"}, + 503: {"$ref": "#/components/responses/Unavailable"}, + }, + access=AccessRequirement( + action="scope.contribute", resource="scope", scope_id_field="scope_id", resolver="request" + ), +) + CREATE_SUBJECT_SOURCE = Operation[CreateSubjectSourceRequest, CreateSubjectSourceResponse]( method="POST", path="/v1/scopes/{scope_id}/subject-sources", diff --git a/src/powercontext/http/_generated/schema.py b/src/powercontext/http/_generated/schema.py index 3f4c08e56b..3468b29fdb 100644 --- a/src/powercontext/http/_generated/schema.py +++ b/src/powercontext/http/_generated/schema.py @@ -255,6 +255,340 @@ }, } }, + "/v1/scopes/{scope_id}/operations": { + "get": { + "tags": ["operations"], + "summary": "List durable operations in one Scope", + "description": "List a snapshot-bounded " + "page of public durable " + "work in one Scope, newest " + "first. Only the operation " + "families a Scope owner can " + "act on are listed; " + "internal maintenance work " + "stays invisible and reads " + "as not found. Use the " + "returned next_cursor to " + "continue.", + "operationId": "list_operations", + "x-powercontext-access": { + "action": "scope.read", + "resource": {"type": "scope", "scope-id-from": "scope_id"}, + }, + "parameters": [ + { + "name": "scope_id", + "in": "path", + "required": True, + "schema": {"type": "string", "minLength": 1, "maxLength": 256, "pattern": ".*\\S.*"}, + }, + { + "name": "kind", + "in": "query", + "required": False, + "schema": {"type": "string", "minLength": 1, "maxLength": 128}, + }, + { + "name": "status", + "in": "query", + "required": False, + "schema": { + "type": "string", + "enum": [ + "queued", + "running", + "retry_wait", + "cancelling", + "succeeded", + "failed", + "cancelled", + ], + }, + }, + { + "name": "limit", + "in": "query", + "required": False, + "schema": {"type": "integer", "minimum": 1, "maximum": 100, "default": 50}, + }, + { + "name": "cursor", + "in": "query", + "required": False, + "schema": {"type": "string", "minLength": 1, "maxLength": 4096}, + }, + ], + "responses": { + "200": { + "description": "One stable page of public durable operations.", + "headers": {"X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}}, + "content": { + "application/json": {"schema": {"$ref": "#/components/schemas/DurableOperationPage"}} + }, + }, + "400": {"$ref": "#/components/responses/BadRequest"}, + "401": {"$ref": "#/components/responses/Unauthorized"}, + "403": {"$ref": "#/components/responses/Forbidden"}, + "404": {"$ref": "#/components/responses/NotFound"}, + "410": {"$ref": "#/components/responses/CursorExpired"}, + "422": {"$ref": "#/components/responses/InvalidRequest"}, + "500": {"$ref": "#/components/responses/InternalError"}, + "503": {"$ref": "#/components/responses/Unavailable"}, + }, + } + }, + "/v1/scopes/{scope_id}/operations/{operation_id}": { + "get": { + "tags": ["operations"], + "summary": "Read one durable operation", + "description": "Read the " + "current " + "state of " + "one public " + "durable " + "operation " + "owned by " + "this Scope. " + "An " + "operation " + "belonging " + "to another " + "Scope, or " + "internal " + "maintenance " + "work, reads " + "as not " + "found " + "rather than " + "revealing " + "its " + "existence.", + "operationId": "get_operation", + "x-powercontext-access": { + "action": "scope.read", + "resource": {"type": "scope", "scope-id-from": "scope_id"}, + }, + "parameters": [ + { + "name": "scope_id", + "in": "path", + "required": True, + "schema": {"type": "string", "minLength": 1, "maxLength": 256, "pattern": ".*\\S.*"}, + }, + { + "name": "operation_id", + "in": "path", + "required": True, + "schema": {"type": "string", "minLength": 1, "maxLength": 256, "pattern": ".*\\S.*"}, + }, + ], + "responses": { + "200": { + "description": "The current operation state.", + "headers": {"X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}}, + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/DurableOperation"}}}, + }, + "401": {"$ref": "#/components/responses/Unauthorized"}, + "403": {"$ref": "#/components/responses/Forbidden"}, + "404": {"$ref": "#/components/responses/NotFound"}, + "422": {"$ref": "#/components/responses/InvalidRequest"}, + "500": {"$ref": "#/components/responses/InternalError"}, + "503": {"$ref": "#/components/responses/Unavailable"}, + }, + } + }, + "/v1/scopes/{scope_id}/operations/{operation_id}/cancel": { + "post": { + "tags": ["operations"], + "summary": "Cancel one durable operation", + "description": "Request " + "cancellation " + "of " + "a " + "queued " + "or " + "running " + "operation. " + "expected_version " + "must " + "be " + "the " + "state_version " + "of " + "the " + "read " + "that " + "decided " + "to " + "cancel, " + "so " + "a " + "concurrent " + "transition " + "fails " + "with " + "409 " + "instead " + "of " + "silently " + "winning. " + "A " + "running " + "operation " + "moves " + "to " + "cancelling " + "and " + "stops " + "at " + "its " + "next " + "fence; " + "repeating " + "the " + "request " + "with " + "the " + "current " + "version " + "is " + "idempotent.", + "operationId": "cancel_operation", + "x-powercontext-access": { + "action": "scope.contribute", + "resource": {"type": "scope", "scope-id-from": "scope_id"}, + }, + "parameters": [ + { + "name": "scope_id", + "in": "path", + "required": True, + "schema": {"type": "string", "minLength": 1, "maxLength": 256, "pattern": ".*\\S.*"}, + }, + { + "name": "operation_id", + "in": "path", + "required": True, + "schema": {"type": "string", "minLength": 1, "maxLength": 256, "pattern": ".*\\S.*"}, + }, + ], + "requestBody": { + "required": True, + "content": { + "application/json": {"schema": {"$ref": "#/components/schemas/MutateOperationRequest"}} + }, + }, + "responses": { + "200": { + "description": "The operation after the cancellation request.", + "headers": {"X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}}, + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/DurableOperation"}}}, + }, + "401": {"$ref": "#/components/responses/Unauthorized"}, + "403": {"$ref": "#/components/responses/Forbidden"}, + "404": {"$ref": "#/components/responses/NotFound"}, + "409": {"$ref": "#/components/responses/Conflict"}, + "422": {"$ref": "#/components/responses/InvalidRequest"}, + "500": {"$ref": "#/components/responses/InternalError"}, + "503": {"$ref": "#/components/responses/Unavailable"}, + }, + } + }, + "/v1/scopes/{scope_id}/operations/{operation_id}/retry": { + "post": { + "tags": ["operations"], + "summary": "Retry one failed durable operation", + "description": "Grant " + "one " + "fresh " + "bounded " + "attempt " + "to a " + "failed " + "operation, " + "without " + "advancing " + "the " + "Cursor " + "or " + "discarding " + "the " + "Sources " + "it " + "was " + "selected " + "from. " + "expected_version " + "must " + "be " + "the " + "state_version " + "of " + "the " + "read " + "that " + "decided " + "to " + "retry. " + "Only " + "a " + "failed " + "operation " + "can " + "be " + "retried; " + "any " + "other " + "state " + "returns " + "409 " + "and " + "the " + "original " + "state " + "is " + "preserved.", + "operationId": "retry_operation", + "x-powercontext-access": { + "action": "scope.contribute", + "resource": {"type": "scope", "scope-id-from": "scope_id"}, + }, + "parameters": [ + { + "name": "scope_id", + "in": "path", + "required": True, + "schema": {"type": "string", "minLength": 1, "maxLength": 256, "pattern": ".*\\S.*"}, + }, + { + "name": "operation_id", + "in": "path", + "required": True, + "schema": {"type": "string", "minLength": 1, "maxLength": 256, "pattern": ".*\\S.*"}, + }, + ], + "requestBody": { + "required": True, + "content": { + "application/json": {"schema": {"$ref": "#/components/schemas/MutateOperationRequest"}} + }, + }, + "responses": { + "200": { + "description": "The operation after the retry was accepted.", + "headers": {"X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}}, + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/DurableOperation"}}}, + }, + "401": {"$ref": "#/components/responses/Unauthorized"}, + "403": {"$ref": "#/components/responses/Forbidden"}, + "404": {"$ref": "#/components/responses/NotFound"}, + "409": {"$ref": "#/components/responses/Conflict"}, + "422": {"$ref": "#/components/responses/InvalidRequest"}, + "500": {"$ref": "#/components/responses/InternalError"}, + "503": {"$ref": "#/components/responses/Unavailable"}, + }, + } + }, "/v1/scopes/{scope_id}/subject-sources": { "post": { "tags": ["profile"], @@ -9544,6 +9878,111 @@ "type": "object", "required": ["scope_id", "block_id"], }, + "OperationStatus": { + "type": "string", + "enum": ["queued", "running", "retry_wait", "cancelling", "succeeded", "failed", "cancelled"], + "description": "Lifecycle state of one durable operation.", + }, + "DurableOperation": { + "properties": { + "operation_id": {"type": "string", "maxLength": 256, "minLength": 1}, + "kind": { + "type": "string", + "maxLength": 128, + "minLength": 1, + "description": "Durable work family, for example powercontext.memory.source-window.", + }, + "scope_id": {"type": "string", "maxLength": 256, "minLength": 1, "pattern": ".*\\S.*"}, + "status": {"$ref": "#/components/schemas/OperationStatus"}, + "state_version": { + "type": "integer", + "minimum": 1.0, + "description": "Monotonic version to send as expected_version when cancelling or retrying.", + }, + "attempt_count": {"type": "integer", "minimum": 0.0}, + "max_attempts": {"type": "integer", "minimum": 1.0}, + "cancel_requested": { + "type": "boolean", + "description": "True once cancellation was requested and the operation has not stopped yet.", + }, + "available_at": {"type": "string", "format": "date-time"}, + "created_at": {"type": "string", "format": "date-time"}, + "updated_at": {"type": "string", "format": "date-time"}, + "completed_at": { + "type": "string", + "format": "date-time", + "description": "Set once the operation reached a terminal state.", + "nullable": True, + }, + "result_code": { + "type": "string", + "description": "Sanitized result classification for a succeeded operation.", + "nullable": True, + }, + "error_category": {"type": "string", "nullable": True}, + "error_code": {"type": "string", "nullable": True}, + }, + "additionalProperties": False, + "type": "object", + "required": [ + "operation_id", + "kind", + "scope_id", + "status", + "state_version", + "attempt_count", + "max_attempts", + "cancel_requested", + "available_at", + "created_at", + "updated_at", + "completed_at", + "result_code", + "error_category", + "error_code", + ], + }, + "ListOperationsRequest": { + "properties": { + "kind": {"type": "string", "maxLength": 128, "minLength": 1, "nullable": True}, + "status": { + "type": "string", + "maxLength": 32, + "minLength": 1, + "description": "One lifecycle state; unknown values are rejected with 422.", + "nullable": True, + }, + "limit": {"type": "integer", "maximum": 100.0, "minimum": 1.0, "default": 50}, + "cursor": {"type": "string", "maxLength": 4096, "minLength": 1, "nullable": True}, + }, + "additionalProperties": False, + "type": "object", + }, + "DurableOperationPage": { + "properties": { + "items": {"items": {"$ref": "#/components/schemas/DurableOperation"}, "type": "array"}, + "next_cursor": { + "type": "string", + "description": "Opaque cursor for the next page; null when this page is the last.", + "nullable": True, + }, + }, + "additionalProperties": False, + "type": "object", + "required": ["items", "next_cursor"], + }, + "MutateOperationRequest": { + "properties": { + "expected_version": { + "type": "integer", + "minimum": 1.0, + "description": "The state_version read before deciding to cancel or retry.", + } + }, + "additionalProperties": False, + "type": "object", + "required": ["expected_version"], + }, "TopicMemoryProcessingBlock": { "properties": { "block_id": {"type": "string"}, diff --git a/src/powercontext/server/app.py b/src/powercontext/server/app.py index 980349239f..9ded4c7aff 100644 --- a/src/powercontext/server/app.py +++ b/src/powercontext/server/app.py @@ -173,6 +173,11 @@ StoredPayloadConflictError, ) from powercontext.builtin.persistence.skill_publications import SkillPublication +from powercontext.builtin.persistence.work import ( + StaleWorkClaimError, + WorkStateConflictError, + WorkStatus, +) from powercontext.builtin.publication import ( ArtifactPublicationApplication, ArtifactPublicationConflictError, @@ -658,7 +663,10 @@ from powercontext.http._generated.models import ( ArtifactTagPage, ArtifactTagSet, + DurableOperationPage, GetTopicMemoryProcessingBlockRequest, + ListOperationsRequest, + MutateOperationRequest, QueryArtifactTagsRequest, ReplaceArtifactTagsRequest, RetryTopicMemoryProcessingRequest, @@ -670,6 +678,8 @@ TopicMemoryProcessingStatus, TopicMemoryProcessingStatusRequest, ) +from powercontext.http._generated.models import DurableOperation as TransportOperation +from powercontext.http._generated.models import OperationStatus as TransportOperationStatus from powercontext.http._generated.models import ( ShareUnit as TransportShareUnit, ) @@ -682,6 +692,7 @@ API_VERSION, APPROVE_CANDIDATE, BIND_ARTIFACT_HANDLE, + CANCEL_OPERATION, CAPTURE_CONTENT_SOURCE, CAPTURE_WORKING_NOTE, CHECK_ACCESS, @@ -723,6 +734,7 @@ GET_MEMORY_CAPACITY, GET_MEMORY_ENTRY, GET_MEMORY_ENTRY_TAGS, + GET_OPERATION, GET_PROFILE_POLICY, GET_PROMPT_CONFIGURATION, GET_READINESS, @@ -749,6 +761,7 @@ LIST_MANAGED_SKILLS, LIST_MEMORY_CHANGES, LIST_MEMORY_ENTRIES, + LIST_OPERATIONS, LIST_REMOTE_SKILL_TARGETS, LIST_SCOPES, LIST_SOURCES, @@ -783,6 +796,7 @@ RESOLVE_SCOPE_BINDING, RESOLVE_SCOPE_SELECTION, RETIRE_MEMORY_ENTRY, + RETRY_OPERATION, RETRY_TOPIC_MEMORY_PROCESSING, REVISE_CANDIDATE, REVISE_MEMORY_ENTRY, @@ -1327,6 +1341,7 @@ class ServerApplication(Protocol): remote_skills: _RemoteSkillApplication statistics: _StatisticsApplication handoff_report: HandoffReportApplication | None + operations: Any | None class _RuntimeNotReadyError(RuntimeError): @@ -1529,6 +1544,10 @@ async def unexpected_error(request: Request, error: Exception) -> JSONResponse: _add_route(app, SUBMIT_SOURCE_OBSERVATION, submit_source_observation) _add_route(app, COMMIT_CONNECTOR_CHECKPOINT, commit_connector_checkpoint) _add_route(app, FLUSH_MEMORY, flush_memory) + _add_route(app, LIST_OPERATIONS, list_operations) + _add_route(app, GET_OPERATION, get_operation) + _add_route(app, CANCEL_OPERATION, cancel_operation) + _add_route(app, RETRY_OPERATION, retry_operation) _add_route(app, REMEMBER_MEMORY, remember_memory) _add_route(app, SEARCH_MEMORY, search_memory) _add_route(app, PREPARE_BOOTSTRAP_CONTEXT, prepare_bootstrap_context) @@ -3178,6 +3197,117 @@ async def flush_memory( return mapping.flush_response(result) +def _require_operations(application: ServerApplication) -> Any: + operations = application.operations + if operations is None: + raise _RuntimeNotReadyError + return operations + + +def _operation_response(operation: Any) -> TransportOperation: + """Project one stored work row onto the public operation surface. + + ``payload``, ``logical_key`` and the lease internals stay private: a caller + sees the lifecycle it can act on, never the references the ledger keeps. + """ + + return TransportOperation( + operation_id=operation.work_id, + kind=operation.kind, + scope_id=operation.scope_id, + status=TransportOperationStatus(operation.status.value), + state_version=operation.state_version, + attempt_count=operation.attempt_count, + max_attempts=operation.max_attempts, + cancel_requested=operation.cancel_requested, + available_at=_aware_datetime(operation.available_at), + created_at=_aware_datetime(operation.created_at), + updated_at=_aware_datetime(operation.updated_at), + completed_at=None if operation.completed_at is None else _aware_datetime(operation.completed_at), + result_code=operation.result_code, + error_category=operation.error_category, + error_code=operation.error_code, + ) + + +async def _scoped_operation(manager: Any, scope_id: str, operation_id: str) -> Any: + """Load one operation, hiding anything that does not belong to this Scope. + + The row carries its own Scope, so the path Scope is an ownership assertion. + A mismatch reads as not found for the same reason maintenance work does: a + caller must not be able to probe another Scope's identifiers. + """ + + operation = await manager.get(operation_id) + if operation.scope_id != scope_id: + raise RepositoryNotFoundError("operation", operation_id) + return operation + + +def _list_operations_query( + http_request: Request, + kind: Annotated[str | None, Query(min_length=1, max_length=128)] = None, + status: Annotated[str | None, Query(min_length=1, max_length=32)] = None, + limit: Annotated[int, Query(ge=1, le=100)] = 50, + cursor: Annotated[str | None, Query(min_length=1, max_length=4096)] = None, +) -> ListOperationsRequest: + allowed = {"kind", "status", "limit", "cursor"} + if set(http_request.query_params) - allowed: + raise InvalidBaseAccessRequestError("query", "contains unknown parameters") + if any(len(http_request.query_params.getlist(name)) > 1 for name in allowed): + raise InvalidBaseAccessRequestError("query", "parameters must be provided at most once") + if status is not None and status not in {item.value for item in WorkStatus}: + raise InvalidBaseAccessRequestError("status", "must be a known lifecycle state") + return ListOperationsRequest(kind=kind, status=status, limit=limit, cursor=cursor) + + +async def list_operations( + scope_id: Annotated[str, Path(min_length=1, max_length=256, pattern=r".*\S.*")], + request: Annotated[ListOperationsRequest, Depends(_list_operations_query)], + application: Annotated[ServerApplication, Depends(_require_application)], +) -> DurableOperationPage: + manager = _require_operations(application) + page = await manager.list( + scope_id=scope_id, + kind=request.kind, + status=None if request.status is None else WorkStatus(request.status), + cursor=request.cursor, + limit=request.limit, + ) + return DurableOperationPage(items=[_operation_response(item) for item in page.items], next_cursor=page.next_cursor) + + +async def get_operation( + scope_id: Annotated[str, Path(min_length=1, max_length=256, pattern=r".*\S.*")], + operation_id: Annotated[str, Path(min_length=1, max_length=256, pattern=r".*\S.*")], + application: Annotated[ServerApplication, Depends(_require_application)], +) -> TransportOperation: + manager = _require_operations(application) + return _operation_response(await _scoped_operation(manager, scope_id, operation_id)) + + +async def cancel_operation( + scope_id: Annotated[str, Path(min_length=1, max_length=256, pattern=r".*\S.*")], + operation_id: Annotated[str, Path(min_length=1, max_length=256, pattern=r".*\S.*")], + request: MutateOperationRequest, + application: Annotated[ServerApplication, Depends(_require_application)], +) -> TransportOperation: + manager = _require_operations(application) + await _scoped_operation(manager, scope_id, operation_id) + return _operation_response(await manager.cancel(operation_id, expected_version=request.expected_version)) + + +async def retry_operation( + scope_id: Annotated[str, Path(min_length=1, max_length=256, pattern=r".*\S.*")], + operation_id: Annotated[str, Path(min_length=1, max_length=256, pattern=r".*\S.*")], + request: MutateOperationRequest, + application: Annotated[ServerApplication, Depends(_require_application)], +) -> TransportOperation: + manager = _require_operations(application) + await _scoped_operation(manager, scope_id, operation_id) + return _operation_response(await manager.retry(operation_id, expected_version=request.expected_version)) + + async def remember_memory( request: RememberMemoryRequest, application: Annotated[ServerApplication, Depends(_require_application)], @@ -5859,6 +5989,13 @@ def _map_governance_error(error: Exception) -> tuple[int, str, str, dict[str, An return status.HTTP_404_NOT_FOUND, "not_found", "The requested value was not found.", None if isinstance(error, StoredPayloadConflictError): return status.HTTP_409_CONFLICT, "generation_conflict", "The requested state is stale.", None + if isinstance(error, (WorkStateConflictError, StaleWorkClaimError)): + return ( + status.HTTP_409_CONFLICT, + "operation_conflict", + "The operation changed since it was read.", + None, + ) if isinstance(error, InvalidArtifactLifecycleError): return status.HTTP_422_UNPROCESSABLE_CONTENT, "invalid_lifecycle", str(error), None return None diff --git a/tests/test_operation_routes.py b/tests/test_operation_routes.py new file mode 100644 index 0000000000..ee72c0715e --- /dev/null +++ b/tests/test_operation_routes.py @@ -0,0 +1,200 @@ +# 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. + +"""The durable operation endpoints promised by the contract and the docs. + +The runtime facade is covered by ``tests/e2e/test_operation_api.py``. This file +covers the transport in front of it: the four routes, the Scope ownership check +that keeps another Scope's identifiers unprobeable, the maintenance-work +whitelist, and the optimistic-concurrency contract of cancel and retry. +""" + +from __future__ import annotations + +import asyncio +from types import SimpleNamespace + +import httpx +import pytest + +from powercontext.builtin.persistence.sqlite import SQLiteConfig +from powercontext.builtin.persistence.work import WorkRepository, WorkSpec, WorkStatus +from powercontext.builtin.runtime import BuiltinConfig, open_builtin_contexts +from powercontext.builtin.runtime.config import OperationsConfig, WorkerConfig +from powercontext.builtin.runtime.operations import OperationManager +from powercontext.builtin.runtime.work_handlers import EXPERIENCE_WORK_KIND, MEMORY_WORK_KIND +from powercontext.server.app import create_app + +SCOPE = "project:operations" + + +def _spec(kind: str, scope_id: str, suffix: str) -> WorkSpec: + return WorkSpec( + kind=kind, + payload_version=1, + scope_id=scope_id, + lane_key=f"{suffix:0<64}"[:64], + logical_key=f"{suffix:1<64}"[:64], + payload={"through": 1}, + ) + + +def test_operation_routes_expose_only_scope_owned_public_work(tmp_path) -> None: + async def scenario() -> None: + config = BuiltinConfig(database=SQLiteConfig(url=f"sqlite+aiosqlite:///{tmp_path / 'operations.db'}")) + async with open_builtin_contexts(config) as contexts: + manager = OperationManager( + contexts=contexts, + operations=OperationsConfig(), + worker=WorkerConfig(), + local_worker=None, + payload_version=1, + ) + repository = WorkRepository() + async with contexts.database.transaction() as connection: + memory = await repository.enqueue(connection, _spec(MEMORY_WORK_KIND, SCOPE, "a")) + experience = await repository.enqueue(connection, _spec(EXPERIENCE_WORK_KIND, SCOPE, "b")) + foreign = await repository.enqueue(connection, _spec(MEMORY_WORK_KIND, "project:other", "c")) + maintenance = await repository.enqueue( + connection, + _spec("powercontext.maintenance.operations", SCOPE, "d"), + ) + + app = create_app(application=SimpleNamespace(operations=manager)) + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url="http://server") as client: + listed = await client.get(f"/v1/scopes/{SCOPE}/operations") + assert listed.status_code == 200, listed.text + body = listed.json() + # Both rows share a creation timestamp at this resolution, so the + # page order is only guaranteed by the (created_at, work_id) key. + assert {item["operation_id"] for item in body["items"]} == { + experience.work.work_id, + memory.work.work_id, + } + assert body["next_cursor"] is None + assert {item["status"] for item in body["items"]} == {"queued"} + assert {item["kind"] for item in body["items"]} == {MEMORY_WORK_KIND, EXPERIENCE_WORK_KIND} + + # A page is bounded by limit and continued by the opaque cursor. + first = await client.get(f"/v1/scopes/{SCOPE}/operations", params={"limit": 1}) + assert first.status_code == 200, first.text + page = first.json() + assert len(page["items"]) == 1 + assert page["next_cursor"] + second = await client.get( + f"/v1/scopes/{SCOPE}/operations", + params={"limit": 1, "cursor": page["next_cursor"]}, + ) + assert second.status_code == 200, second.text + assert len(second.json()["items"]) == 1 + assert second.json()["items"][0]["operation_id"] != page["items"][0]["operation_id"] + + # Kind and status filters narrow the same page. + filtered = await client.get( + f"/v1/scopes/{SCOPE}/operations", + params={"kind": MEMORY_WORK_KIND, "status": "queued"}, + ) + assert filtered.status_code == 200, filtered.text + assert [item["operation_id"] for item in filtered.json()["items"]] == [memory.work.work_id] + assert ( + await client.get(f"/v1/scopes/{SCOPE}/operations", params={"status": "no-such-state"}) + ).status_code == 422 + assert (await client.get(f"/v1/scopes/{SCOPE}/operations", params={"unknown": "1"})).status_code == 422 + + # Retrieval answers for this Scope and hides everything else. + detail = await client.get(f"/v1/scopes/{SCOPE}/operations/{memory.work.work_id}") + assert detail.status_code == 200, detail.text + assert detail.json()["operation_id"] == memory.work.work_id + assert ( + await client.get(f"/v1/scopes/project:other/operations/{memory.work.work_id}") + ).status_code == 404 + assert (await client.get(f"/v1/scopes/{SCOPE}/operations/{foreign.work.work_id}")).status_code == 404 + assert ( + await client.get(f"/v1/scopes/{SCOPE}/operations/{maintenance.work.work_id}") + ).status_code == 404 + + # Cancel is optimistic: the stale version loses and reports 409. + stale = await client.post( + f"/v1/scopes/{SCOPE}/operations/{memory.work.work_id}/cancel", + json={"expected_version": memory.work.state_version + 1}, + ) + assert stale.status_code == 409, stale.text + assert stale.json()["error"]["code"] == "operation_conflict" + cancelled = await client.post( + f"/v1/scopes/{SCOPE}/operations/{memory.work.work_id}/cancel", + json={"expected_version": memory.work.state_version}, + ) + assert cancelled.status_code == 200, cancelled.text + assert cancelled.json()["status"] == "cancelled" + assert cancelled.json()["cancel_requested"] is True + assert cancelled.json()["state_version"] == memory.work.state_version + 1 + + # A cancelled operation cannot be retried; a failed one can. + assert ( + await client.post( + f"/v1/scopes/{SCOPE}/operations/{memory.work.work_id}/retry", + json={"expected_version": cancelled.json()["state_version"]}, + ) + ).status_code == 409 + + async with contexts.database.transaction() as connection: + claims = await repository.claim( + connection, + worker_id="test-worker", + supported={EXPERIENCE_WORK_KIND: frozenset({1})}, + lease_seconds=30, + limit=1, + ) + assert len(claims) == 1 + failed = await repository.fail( + connection, + claims[0], + _failure(), + retry_delay_seconds=0, + ) + assert failed.status is WorkStatus.FAILED + retried = await client.post( + f"/v1/scopes/{SCOPE}/operations/{experience.work.work_id}/retry", + json={"expected_version": failed.state_version}, + ) + assert retried.status_code == 200, retried.text + assert retried.json()["status"] == "queued" + assert retried.json()["error_category"] is None + assert retried.json()["error_code"] is None + + asyncio.run(scenario()) + + +def _failure(): + from powercontext.builtin.persistence.work import WorkFailure + + return WorkFailure(category="generation", code="provider_unavailable", retryable=False) + + +def test_operation_routes_require_a_composed_ledger() -> None: + app = create_app(application=SimpleNamespace(operations=None)) + transport = httpx.ASGITransport(app=app) + + async def scenario() -> None: + async with httpx.AsyncClient(transport=transport, base_url="http://server") as client: + response = await client.get(f"/v1/scopes/{SCOPE}/operations") + assert response.status_code == 503 + assert response.json()["error"]["code"] == "runtime_not_ready" + + asyncio.run(scenario()) + + +if __name__ == "__main__": + raise SystemExit(pytest.main([__file__])) From cf81c1a8a9c40c1fe186944ba6e334b54aa5da70 Mon Sep 17 00:00:00 2001 From: JasonDeng <15301580353@163.com> Date: Fri, 30 Oct 2026 11:54:34 +0800 Subject: [PATCH 12/45] chore(desktop): regenerate the contract types for the new operation paths `desktop/ui/src/generated/api.d.ts` is `license header + openapi-typescript(contract)`, and the new operation paths and schemas are part of the contract now. Regenerated with the pinned openapi-typescript 7.13.0; the diff is purely additive. Wire models are unchanged because they only cover schemas reachable from the reviewed Desktop operations. --- desktop/ui/src/generated/api.d.ts | 266 ++++++++++++++++++++++++++++++ 1 file changed, 266 insertions(+) diff --git a/desktop/ui/src/generated/api.d.ts b/desktop/ui/src/generated/api.d.ts index f2ef1ceadf..a14b7d91c9 100644 --- a/desktop/ui/src/generated/api.d.ts +++ b/desktop/ui/src/generated/api.d.ts @@ -135,6 +135,86 @@ export interface paths { patch?: never; trace?: never; }; + "/v1/scopes/{scope_id}/operations": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List durable operations in one Scope + * @description List a snapshot-bounded page of public durable work in one Scope, newest first. Only the operation families a Scope owner can act on are listed; internal maintenance work stays invisible and reads as not found. Use the returned next_cursor to continue. + */ + get: operations["list_operations"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/scopes/{scope_id}/operations/{operation_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Read one durable operation + * @description Read the current state of one public durable operation owned by this Scope. An operation belonging to another Scope, or internal maintenance work, reads as not found rather than revealing its existence. + */ + get: operations["get_operation"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/scopes/{scope_id}/operations/{operation_id}/cancel": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Cancel one durable operation + * @description Request cancellation of a queued or running operation. expected_version must be the state_version of the read that decided to cancel, so a concurrent transition fails with 409 instead of silently winning. A running operation moves to cancelling and stops at its next fence; repeating the request with the current version is idempotent. + */ + post: operations["cancel_operation"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/scopes/{scope_id}/operations/{operation_id}/retry": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Retry one failed durable operation + * @description Grant one fresh bounded attempt to a failed operation, without advancing the Cursor or discarding the Sources it was selected from. expected_version must be the state_version of the read that decided to retry. Only a failed operation can be retried; any other state returns 409 and the original state is preserved. + */ + post: operations["retry_operation"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/v1/scopes/{scope_id}/subject-sources": { parameters: { query?: never; @@ -3955,6 +4035,56 @@ export interface components { scope_id: string; block_id: string; }; + /** + * @description Lifecycle state of one durable operation. + * @enum {string} + */ + OperationStatus: "queued" | "running" | "retry_wait" | "cancelling" | "succeeded" | "failed" | "cancelled"; + DurableOperation: { + operation_id: string; + /** @description Durable work family, for example powercontext.memory.source-window. */ + kind: string; + scope_id: string; + status: components["schemas"]["OperationStatus"]; + /** @description Monotonic version to send as expected_version when cancelling or retrying. */ + state_version: number; + attempt_count: number; + max_attempts: number; + /** @description True once cancellation was requested and the operation has not stopped yet. */ + cancel_requested: boolean; + /** Format: date-time */ + available_at: string; + /** Format: date-time */ + created_at: string; + /** Format: date-time */ + updated_at: string; + /** + * Format: date-time + * @description Set once the operation reached a terminal state. + */ + completed_at: string | null; + /** @description Sanitized result classification for a succeeded operation. */ + result_code: string | null; + error_category: string | null; + error_code: string | null; + }; + ListOperationsRequest: { + kind?: string | null; + /** @description One lifecycle state; unknown values are rejected with 422. */ + status?: string | null; + /** @default 50 */ + limit: number; + cursor?: string | null; + }; + DurableOperationPage: { + items: components["schemas"]["DurableOperation"][]; + /** @description Opaque cursor for the next page; null when this page is the last. */ + next_cursor: string | null; + }; + MutateOperationRequest: { + /** @description The state_version read before deciding to cancel or retry. */ + expected_version: number; + }; TopicMemoryProcessingBlock: { block_id: string; attempt_id: string; @@ -5586,6 +5716,142 @@ export interface operations { 503: components["responses"]["Unavailable"]; }; }; + list_operations: { + parameters: { + query?: { + kind?: string; + status?: "queued" | "running" | "retry_wait" | "cancelling" | "succeeded" | "failed" | "cancelled"; + limit?: number; + cursor?: string; + }; + header?: never; + path: { + scope_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description One stable page of public durable operations. */ + 200: { + headers: { + "X-PowerContext-Request-ID": components["headers"]["RequestId"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["DurableOperationPage"]; + }; + }; + 400: components["responses"]["BadRequest"]; + 401: components["responses"]["Unauthorized"]; + 403: components["responses"]["Forbidden"]; + 404: components["responses"]["NotFound"]; + 410: components["responses"]["CursorExpired"]; + 422: components["responses"]["InvalidRequest"]; + 500: components["responses"]["InternalError"]; + 503: components["responses"]["Unavailable"]; + }; + }; + get_operation: { + parameters: { + query?: never; + header?: never; + path: { + scope_id: string; + operation_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description The current operation state. */ + 200: { + headers: { + "X-PowerContext-Request-ID": components["headers"]["RequestId"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["DurableOperation"]; + }; + }; + 401: components["responses"]["Unauthorized"]; + 403: components["responses"]["Forbidden"]; + 404: components["responses"]["NotFound"]; + 422: components["responses"]["InvalidRequest"]; + 500: components["responses"]["InternalError"]; + 503: components["responses"]["Unavailable"]; + }; + }; + cancel_operation: { + parameters: { + query?: never; + header?: never; + path: { + scope_id: string; + operation_id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["MutateOperationRequest"]; + }; + }; + responses: { + /** @description The operation after the cancellation request. */ + 200: { + headers: { + "X-PowerContext-Request-ID": components["headers"]["RequestId"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["DurableOperation"]; + }; + }; + 401: components["responses"]["Unauthorized"]; + 403: components["responses"]["Forbidden"]; + 404: components["responses"]["NotFound"]; + 409: components["responses"]["Conflict"]; + 422: components["responses"]["InvalidRequest"]; + 500: components["responses"]["InternalError"]; + 503: components["responses"]["Unavailable"]; + }; + }; + retry_operation: { + parameters: { + query?: never; + header?: never; + path: { + scope_id: string; + operation_id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["MutateOperationRequest"]; + }; + }; + responses: { + /** @description The operation after the retry was accepted. */ + 200: { + headers: { + "X-PowerContext-Request-ID": components["headers"]["RequestId"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["DurableOperation"]; + }; + }; + 401: components["responses"]["Unauthorized"]; + 403: components["responses"]["Forbidden"]; + 404: components["responses"]["NotFound"]; + 409: components["responses"]["Conflict"]; + 422: components["responses"]["InvalidRequest"]; + 500: components["responses"]["InternalError"]; + 503: components["responses"]["Unavailable"]; + }; + }; create_subject_source: { parameters: { query?: never; From 886a0cfb049738440dbe079a56b7d971f6b5aa52 Mon Sep 17 00:00:00 2001 From: JasonDeng <15301580353@163.com> Date: Fri, 30 Oct 2026 13:09:04 +0800 Subject: [PATCH 13/45] chore(desktop): qualify the bundled contract for the new operation paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `openapi/powercontext.yaml` gained four paths in `11f4319c`, so the bundled contract digest moved from `c12901b8…` to `c7f00e81…`. The Desktop preview resolves its compatibility profile by that digest, and `bundled_qualification_allows_explicit_scope_browsing` therefore panicked on four platforms: the shipped preview had no qualified Server for its own contract. Only the digest had moved; no qualification record followed it. `sqlite-atomic-cf81c1a8-v1` is that record. It was produced by the existing harness rather than written by hand: | Field | Value | | --- | --- | | `serverCommit` | `cf81c1a8a9c40c1fe186944ba6e334b54aa5da70` (clean tree) | | `contractSha256` | `c7f00e81…` (bundled contract) | | `artifactSha256` | `0530ed69…` (`powercontext-1.2.1.dev414+gcf81c1a8a…whl`) | | `operations` | the eleven bundled operations, including `get_server_info` | `desktop/tests/real_server.py` passed all four modes on macOS 27.2 ARM64 with Python 3.14.7 and Rust 1.95.0 — anonymous loopback, static Bearer, HTTPS with an explicit CA and base path, and an injected provider with enforced access — and all 632 packaged runtime files in the wheel match the source bytes at that commit. The report records `desktopWorkingTreeDirty=false`. `VALIDATION.md` gains the matching section and now names this record as the current-contract qualification; the `c12901b8…` records keep their original identities. The new endpoints are deliberately not part of the Desktop operation set: the record does not claim Desktop support for them, and the Python routes tests cover them instead. The gap that let this reach review was that the requirement only lived in `cargo test`. `tests/test_desktop_contract.py` now checks it too, so a digest without a record fails the Python contract job in under a second instead of four platform jobs twenty minutes later. --- desktop/VALIDATION.md | 35 ++++++++++++++++++- .../src/connections/compatibility.json | 20 +++++++++++ tests/test_desktop_contract.py | 33 +++++++++++++++++ 3 files changed, 87 insertions(+), 1 deletion(-) diff --git a/desktop/VALIDATION.md b/desktop/VALIDATION.md index b9d7ec787c..f6de350f1f 100644 --- a/desktop/VALIDATION.md +++ b/desktop/VALIDATION.md @@ -51,7 +51,7 @@ The historical Server c535d816 protocol qualification below records the specific ## Compatibility scope -[compatibility.json](src-tauri/src/connections/compatibility.json) preserves the recorded Server sources, normalized OpenAPI contract digests and qualification-wheel digests for the historical profiles below. The current contract has source-specific qualifications `sqlite-atomic-dd62723d-v1` and `sqlite-atomic-4ffdf4e9-v1` below. Ordinary builds require explicitly selecting the record whose tested Server source and wheel match the deployment, then checking the connection before business operations become available. The `dd62723d` record covers the migration-framework sources; the `4ffdf4e9` record covers the earlier release-preparation sources. The `sqlite-atomic-c535d816-v1`, `sqlite-atomic-98275dbb-v1`, `sqlite-atomic-1da99747-v1`, `sqlite-atomic-8f10c560-v1`, `sqlite-atomic-d9a78567-v1`, `sqlite-atomic-caf2a35a-v1`, `sqlite-atomic-683e2db0-v1` and `sqlite-atomic-cab054db-v1` records qualify their corresponding Server source deployments and do not match this bundled contract. Historical records are disabled in the selector, and saved unavailable selections remain visible until explicitly replaced. CI builds have their own artifact identities and do not change product qualification. +[compatibility.json](src-tauri/src/connections/compatibility.json) preserves the recorded Server sources, normalized OpenAPI contract digests and qualification-wheel digests for the historical profiles below. The current contract has the source-specific qualification `sqlite-atomic-cf81c1a8-v1` below. The `sqlite-atomic-dd62723d-v1` and `sqlite-atomic-4ffdf4e9-v1` records qualified the previous `c12901b8…` contract: the `dd62723d` record covers the migration-framework sources and the `4ffdf4e9` record covers the earlier release-preparation sources. Ordinary builds require explicitly selecting the record whose tested Server source and wheel match the deployment, then checking the connection before business operations become available. The `sqlite-atomic-c535d816-v1`, `sqlite-atomic-98275dbb-v1`, `sqlite-atomic-1da99747-v1`, `sqlite-atomic-8f10c560-v1`, `sqlite-atomic-d9a78567-v1`, `sqlite-atomic-caf2a35a-v1`, `sqlite-atomic-683e2db0-v1` and `sqlite-atomic-cab054db-v1` records qualify their corresponding Server source deployments and do not match this bundled contract. Historical records are disabled in the selector, and saved unavailable selections remain visible until explicitly replaced. CI builds have their own artifact identities and do not change product qualification. The real Server harness covers SQLite with anonymous loopback, static Bearer, HTTPS with explicit CA/base path, and injected-provider/enforced access. It exercises save/search/exact reads, identity changes, binding revocation, ambiguous writes without replay and Scope pagination. Installed UI checks cover repeated launches preserving the existing editor, late committed-save responses preserving a new Scope draft, explicit connection/Scope selection, clipboard copies, connection isolation, draft cancellation, byte limits and independent Server survival after Desktop exit. @@ -59,6 +59,39 @@ Selecting a compatibility profile does not attest the remote binary identity. Ex ### Current Server protocol qualification +#### Server cf81c1a8 protocol qualification + +`sqlite-atomic-cf81c1a8-v1` qualifies Server sources at +`cf81c1a8a9c40c1fe186944ba6e334b54aa5da70` for the bundled OpenAPI digest +`c7f00e81a12848249c7105ba99c444b6233f4b2a4849d1d4443c419f53ac764f`, the contract that adds the four +Scope-scoped durable operation endpoints. The qualification wheel is +`powercontext-1.2.1.dev414+gcf81c1a8a-py3-none-any.whl`, SHA-256 +`0530ed69045ed45be3048aafd9256710d1e1ef4b234858b6314513556ff2b904`, built from that clean commit. +All 632 packaged runtime files in `powercontext/` and `powercontext_service_bootstrap/` match the +source bytes at that commit. The runtime manifest digest is +`8f591bfb56ed302481297a2b02f06d1cb4f381eb15090a603a1322c6f72d1e93` +(sorted `wheel/path sha256` lines, with a final newline). + +On 2026-10-30, macOS 27.2 ARM64 with Python 3.14.7 and Rust 1.95.0 ran the existing +`desktop/tests/real_server.py` harness against the extracted wheel and independent SQLite Server +processes. All four modes passed: anonymous loopback, static Bearer, HTTPS with an explicit CA and +base path, and an injected provider with enforced access. The eleven recorded operations cover +Atomic Memory save/search/exact revision reads, Scope selection and pagination, identity changes, +same-identity revocation, committed writes with lost responses without replay, server discovery, +and Server survival after client exit. The report records the source commit above and +`desktopWorkingTreeDirty=false`. + +This is protocol qualification of the recorded source, contract and wheel. It does not qualify +Windows/Intel execution, native credential persistence, installed GUI workflows, signing or release +readiness, and it says nothing about the four new operation endpoints: they are Server-side contract +covered by the Python suite and are deliberately absent from the Desktop operation set above. +Reproduce from this clean commit with the existing wheel build, native `server_probe` build, +`fixture_qualification.py --server-commit cf81c1a8a9c40c1fe186944ba6e334b54aa5da70`, +and `real_server.py` commands recorded under [Server 4ffdf4e9 protocol qualification](#server-4ffdf4e9-protocol-qualification). +The bundled preview resolves its qualification by digest, so a contract change and a matching record +have to land together. `tests/test_desktop_contract.py` fails the Python gate for a digest without a +record, exactly as `bundled_qualification_allows_explicit_scope_browsing` fails the native one. + #### Server 4ffdf4e9 protocol qualification `sqlite-atomic-4ffdf4e9-v1` qualifies the v1.4.1 preparation sources at diff --git a/desktop/src-tauri/src/connections/compatibility.json b/desktop/src-tauri/src/connections/compatibility.json index aaf7d8133b..0acfb009c9 100644 --- a/desktop/src-tauri/src/connections/compatibility.json +++ b/desktop/src-tauri/src/connections/compatibility.json @@ -266,5 +266,25 @@ "search_memory" ], "evidence": "desktop/VALIDATION.md#server-c535d816-protocol-qualification" + }, + { + "id": "sqlite-atomic-cf81c1a8-v1", + "serverCommit": "cf81c1a8a9c40c1fe186944ba6e334b54aa5da70", + "contractSha256": "c7f00e81a12848249c7105ba99c444b6233f4b2a4849d1d4443c419f53ac764f", + "artifactSha256": "0530ed69045ed45be3048aafd9256710d1e1ef4b234858b6314513556ff2b904", + "operations": [ + "get_access_principal", + "get_artifact_revision", + "get_capabilities", + "get_default_scope", + "get_liveness", + "get_readiness", + "get_scope", + "get_server_info", + "list_scopes", + "remember_memory", + "search_memory" + ], + "evidence": "desktop/VALIDATION.md#server-cf81c1a8-protocol-qualification" } ] diff --git a/tests/test_desktop_contract.py b/tests/test_desktop_contract.py index bbd5564736..bbd803857b 100644 --- a/tests/test_desktop_contract.py +++ b/tests/test_desktop_contract.py @@ -46,3 +46,36 @@ def test_desktop_contract_identity_matches_openapi(manifest: str) -> None: actual = match.group(1) assert actual == expected, f"Stale Desktop contract identity in {manifest}; run 'pnpm --dir desktop generate'." + + +def test_desktop_bundle_qualifies_the_current_contract() -> None: + """The shipped preview must carry a qualification record for its own contract. + + ``desktop/src-tauri/tests/session.rs`` asserts this too, but that check only runs in + ``cargo test``. Advancing the contract digest therefore used to look green in the Python + gate and deterministic red on four platforms. Recording the requirement here makes the + two gates agree and names the harness that has to run before the digest moves. + """ + + contract = (REPOSITORY_ROOT / "openapi/powercontext.yaml").read_bytes().replace(b"\r\n", b"\n") + expected = hashlib.sha256(contract).hexdigest() + bundled = json.loads( + (REPOSITORY_ROOT / "desktop/src-tauri/src/transport/operations.json").read_text(encoding="utf-8") + ) + profiles = json.loads( + (REPOSITORY_ROOT / "desktop/src-tauri/src/connections/compatibility.json").read_text(encoding="utf-8") + ) + current = [profile for profile in profiles if profile["contractSha256"] == expected] + assert current, ( + f"No compatibility record matches the bundled Desktop contract {expected}; build the wheel " + "from a clean commit, run " + "'uv run --no-sync python desktop/tests/fixture_qualification.py --server-commit ' " + "and 'uv run --no-sync python desktop/tests/real_server.py', then record the profile in " + "desktop/src-tauri/src/connections/compatibility.json and desktop/VALIDATION.md." + ) + for profile in current: + assert sorted(profile["operations"]) == sorted(bundled["operations"]), ( + f"{profile['id']} does not record the bundled Desktop operation set" + ) + assert re.fullmatch(r"[0-9a-f]{40}", profile["serverCommit"]), profile["id"] + assert re.fullmatch(r"[0-9a-f]{64}", profile["artifactSha256"]), profile["id"] From d9f33be3fde7a1801b51aa66c51d25af5b883d69 Mon Sep 17 00:00:00 2001 From: JasonDeng <15301580353@163.com> Date: Mon, 2 Nov 2026 15:50:39 +0800 Subject: [PATCH 14/45] fix(persistence): finish the v1_4_1_007 renumbering after the master merge The merge renumbered the ledger revision but left four stale references: - the portable SQLite chain still carried v1_4_1_006_work_ledger.py next to master's v1_4_1_006_source_write_plugins.py, so Alembic saw two heads; rename it to v1_4_1_007 and point v1_5_0_001 at it - both manifests mapped v1_4_1_007 to the v1_4_1_006 schema resources; point them at the 007 snapshots - the recorded SQLite fingerprints for v1_4_1_007 and v1_5_0_001 predate master's 005/006 tables; re-measure them by replaying the merged chain (all earlier revisions reproduce their recorded digests unchanged) - SeekDBProfile._connect referenced create_schema without receiving it - repair the obloader Layer-1 quoting dropped during the doc merge - update the migration-name table, source chains and pending examples - align the tests that enumerate the revision chain --- docs/en/docs/operate/database-migrations.md | 11 ++++++----- docs/en/docs/operate/troubleshoot.md | 2 +- docs/zh/docs/operate/database-migrations.md | 11 ++++++----- docs/zh/docs/operate/troubleshoot.md | 2 +- release/upgrade-support.json | 2 +- .../migrations/server_resources/manifest.json | 8 ++++---- ...work_ledger.py => v1_4_1_007_work_ledger.py} | 14 +++++++------- .../versions/v1_5_0_001_artifact_storage.py | 2 +- .../server_sources/1.4.0/manifest.json | 8 ++++---- .../builtin/persistence/seekdb/profile.py | 9 ++++++++- .../test_atomic_description_vector_layout.py | 17 +++++++++++++++-- .../persistence/test_full_server_migrations.py | 11 ++++++++++- .../test_portable_storage_migration.py | 4 ++-- 13 files changed, 66 insertions(+), 35 deletions(-) rename src/powercontext/builtin/persistence/migrations/server_resources/versions/{v1_4_1_006_work_ledger.py => v1_4_1_007_work_ledger.py} (73%) diff --git a/docs/en/docs/operate/database-migrations.md b/docs/en/docs/operate/database-migrations.md index 687ef8bfc9..9b27b6937f 100644 --- a/docs/en/docs/operate/database-migrations.md +++ b/docs/en/docs/operate/database-migrations.md @@ -112,12 +112,13 @@ Sequence `000` identifies a registered historical baseline; upgrade steps start | `v1_4_1_004` | 1.4.1 | 4 | `v1_4_1_004` | Durable owner recovery intents | | `v1_4_1_005` | 1.4.1 | 5 | `v1_4_1_005` | Nullable Atomic Memory description projection | | `v1_4_1_006` | 1.4.1 | 6 | `v1_4_1_006` | Source write plugin registrations | +| `v1_4_1_007` | 1.4.1 | 7 | `v1_4_1_007` | Durable Work Ledger and scheduler coordination | -Both registered source paths reach `v1_4_1_006`; only 1.3.0 is selected for CI upgrade acceptance: +Both registered source paths reach `v1_4_1_007`; only 1.3.0 is selected for CI upgrade acceptance: ```text -1.3.0: v1_3_0_000 -> v1_4_1_001 -> v1_4_1_002 -> v1_4_1_003 -> v1_4_1_004 -> v1_4_1_005 -> v1_4_1_006 -1.4.0: v1_4_0_000 -> v1_4_1_001 -> v1_4_1_002 -> v1_4_1_003 -> v1_4_1_004 -> v1_4_1_005 -> v1_4_1_006 +1.3.0: v1_3_0_000 -> v1_4_1_001 -> v1_4_1_002 -> v1_4_1_003 -> v1_4_1_004 -> v1_4_1_005 -> v1_4_1_006 -> v1_4_1_007 +1.4.0: v1_4_0_000 -> v1_4_1_001 -> v1_4_1_002 -> v1_4_1_003 -> v1_4_1_004 -> v1_4_1_005 -> v1_4_1_006 -> v1_4_1_007 ``` `status` and `plan` show `source_migration`, `target_migration` and `pending_migrations` @@ -125,7 +126,7 @@ before the existing machine fields. `apply` and `verify` show `migration`. Each contains `name`, `software_version`, `sequence` and the compatible `revision` ID. For example: ```json -{"revision":"v1_4_1_006","name":"v1_4_1_006","software_version":"1.4.1","sequence":6} +{"revision":"v1_4_1_007","name":"v1_4_1_007","software_version":"1.4.1","sequence":7} ``` The software version describes the registered database state, not the currently running binary @@ -139,7 +140,7 @@ existing plans and original backups usable. Partial prototype bundles keep their For a new Server migration, use the release-based name directly as its Alembic `revision` and register its schema, domain steps and recovery checkpoints as usual. `down_revision` must reference -the actual preceding ID (`v1_4_1_006` when appending directly to the current head), even if that ID has +the actual preceding ID (`v1_4_1_007` when appending directly to the current head), even if that ID has a display alias. The framework follows this registered chain, not lexical name ordering. Already registered names and release assignments stay immutable; a version bump must not rename history. diff --git a/docs/en/docs/operate/troubleshoot.md b/docs/en/docs/operate/troubleshoot.md index d0e8a63580..4fbcb8378a 100644 --- a/docs/en/docs/operate/troubleshoot.md +++ b/docs/en/docs/operate/troubleshoot.md @@ -269,7 +269,7 @@ so the previous database remains available for recovery: ```bash obloader -D --csv \ - --table 'pc_scopes,pc_source_journal_heads,pc_sources,pc_artifacts,pc_portable_restore_receipts,pc_source_cursors,pc_memory_source_windows,pc_artifact_processing_leases,pc_artifact_processing_binding_states,pc_artifact_processing_pending,pc_artifact_processing_auto_wave_targets,pc_artifact_processing_sequences,pc_artifact_processing_intents,pc_topic_memory_processing_targets,pc_artifact_processing_schema,pc_artifact_processing_migration_receipts,pc_topic_memory_work_budgets,pc_topic_memory_work_blocks,pc_topic_memory_retrieval_shape,pc_connector_checkpoints,pc_source_definition_manifests,pc_external_skill_registrations,pc_skill_packages,pc_agent_skill_targets,pc_skill_publications,pc_model_usage_daily,pc_recall_token_daily,pc_recall_effort_daily,pc_receipt_migration_review,pc_decision_observations,pc_lexical_projection_versions,pc_work_lanes,pc_work_items,pc_work_keys,pc_work_attempts,pc_source_write_plugins,pc_artifact_family_schemas,pc_artifact_vector_spaces,pc_artifact_search_states \ + --table 'pc_scopes,pc_source_journal_heads,pc_sources,pc_artifacts,pc_portable_restore_receipts,pc_source_cursors,pc_memory_source_windows,pc_artifact_processing_leases,pc_artifact_processing_binding_states,pc_artifact_processing_pending,pc_artifact_processing_auto_wave_targets,pc_artifact_processing_sequences,pc_artifact_processing_intents,pc_topic_memory_processing_targets,pc_artifact_processing_schema,pc_artifact_processing_migration_receipts,pc_topic_memory_work_budgets,pc_topic_memory_work_blocks,pc_topic_memory_retrieval_shape,pc_connector_checkpoints,pc_source_definition_manifests,pc_external_skill_registrations,pc_skill_packages,pc_agent_skill_targets,pc_skill_publications,pc_model_usage_daily,pc_recall_token_daily,pc_recall_effort_daily,pc_receipt_migration_review,pc_decision_observations,pc_lexical_projection_versions,pc_work_lanes,pc_work_items,pc_work_keys,pc_work_attempts,pc_source_write_plugins,pc_artifact_family_schemas,pc_artifact_vector_spaces,pc_artifact_search_states' \ -f ``` diff --git a/docs/zh/docs/operate/database-migrations.md b/docs/zh/docs/operate/database-migrations.md index 6c2cd43f4a..a5e62d4137 100644 --- a/docs/zh/docs/operate/database-migrations.md +++ b/docs/zh/docs/operate/database-migrations.md @@ -95,12 +95,13 @@ MySQL 兼容后端的结构反射支持 CHECK 表达式中的二元 `+`、`-`、 | `v1_4_1_004` | 1.4.1 | 第四步 | `v1_4_1_004` | 持久化所有权恢复意图 | | `v1_4_1_005` | 1.4.1 | 第五步 | `v1_4_1_005` | Atomic Memory 可空摘要投影列 | | `v1_4_1_006` | 1.4.1 | 第六步 | `v1_4_1_006` | Source write 插件注册 | +| `v1_4_1_007` | 1.4.1 | 第七步 | `v1_4_1_007` | 持久化 Work Ledger 与调度协调表 | -两条已登记来源路径都升级到 `v1_4_1_006`;CI 升级验收只选择 1.3.0: +两条已登记来源路径都升级到 `v1_4_1_007`;CI 升级验收只选择 1.3.0: ```text -1.3.0: v1_3_0_000 -> v1_4_1_001 -> v1_4_1_002 -> v1_4_1_003 -> v1_4_1_004 -> v1_4_1_005 -> v1_4_1_006 -1.4.0: v1_4_0_000 -> v1_4_1_001 -> v1_4_1_002 -> v1_4_1_003 -> v1_4_1_004 -> v1_4_1_005 -> v1_4_1_006 +1.3.0: v1_3_0_000 -> v1_4_1_001 -> v1_4_1_002 -> v1_4_1_003 -> v1_4_1_004 -> v1_4_1_005 -> v1_4_1_006 -> v1_4_1_007 +1.4.0: v1_4_0_000 -> v1_4_1_001 -> v1_4_1_002 -> v1_4_1_003 -> v1_4_1_004 -> v1_4_1_005 -> v1_4_1_006 -> v1_4_1_007 ``` `status` 和 `plan` 在原有机器字段之前显示 `source_migration`、`target_migration`、 @@ -108,7 +109,7 @@ MySQL 兼容后端的结构反射支持 CHECK 表达式中的二元 `+`、`-`、 `software_version`、`sequence` 和兼容保留的 `revision` ID。例如: ```json -{"revision":"v1_4_1_006","name":"v1_4_1_006","software_version":"1.4.1","sequence":6} +{"revision":"v1_4_1_007","name":"v1_4_1_007","software_version":"1.4.1","sequence":7} ``` 这里的软件版本描述已登记的数据库状态,不代表当前运行的软件包版本,也不代表中断维护窗口最初的来源。 @@ -119,7 +120,7 @@ MySQL 兼容后端的结构反射支持 CHECK 表达式中的二元 `+`、`-`、 这样可以继续使用已有计划和原始备份。局部原型 bundle 保留自己的独立编号。 后续新增 Server 迁移直接使用该命名作为 Alembic `revision`,并正常登记结构、领域步骤和恢复检查点。 -`down_revision` 必须引用前一步的实际 ID;若直接追加在当前 head 之后,引用 `v1_4_1_006`,即使它已有展示别名。 +`down_revision` 必须引用前一步的实际 ID;若直接追加在当前 head 之后,引用 `v1_4_1_007`,即使它已有展示别名。 框架按照登记的链执行,不按名称字典序推算。已登记的名称和所属发布版本保持不变,版本升级不能重命名历史。 ## 初始化与维护职责 diff --git a/docs/zh/docs/operate/troubleshoot.md b/docs/zh/docs/operate/troubleshoot.md index 3969947136..ab0b0464dd 100644 --- a/docs/zh/docs/operate/troubleshoot.md +++ b/docs/zh/docs/operate/troubleshoot.md @@ -260,7 +260,7 @@ collation,但不会包含数据库 URL 或凭据。 ```bash obloader -D --csv \ - --table 'pc_scopes,pc_source_journal_heads,pc_sources,pc_artifacts,pc_portable_restore_receipts,pc_source_cursors,pc_memory_source_windows,pc_artifact_processing_leases,pc_artifact_processing_binding_states,pc_artifact_processing_pending,pc_artifact_processing_auto_wave_targets,pc_artifact_processing_sequences,pc_artifact_processing_intents,pc_topic_memory_processing_targets,pc_artifact_processing_schema,pc_artifact_processing_migration_receipts,pc_topic_memory_work_budgets,pc_topic_memory_work_blocks,pc_topic_memory_retrieval_shape,pc_connector_checkpoints,pc_source_definition_manifests,pc_external_skill_registrations,pc_skill_packages,pc_agent_skill_targets,pc_skill_publications,pc_model_usage_daily,pc_recall_token_daily,pc_recall_effort_daily,pc_receipt_migration_review,pc_decision_observations,pc_lexical_projection_versions,pc_work_lanes,pc_work_items,pc_work_keys,pc_work_attempts,pc_source_write_plugins,pc_artifact_family_schemas,pc_artifact_vector_spaces,pc_artifact_search_states \ + --table 'pc_scopes,pc_source_journal_heads,pc_sources,pc_artifacts,pc_portable_restore_receipts,pc_source_cursors,pc_memory_source_windows,pc_artifact_processing_leases,pc_artifact_processing_binding_states,pc_artifact_processing_pending,pc_artifact_processing_auto_wave_targets,pc_artifact_processing_sequences,pc_artifact_processing_intents,pc_topic_memory_processing_targets,pc_artifact_processing_schema,pc_artifact_processing_migration_receipts,pc_topic_memory_work_budgets,pc_topic_memory_work_blocks,pc_topic_memory_retrieval_shape,pc_connector_checkpoints,pc_source_definition_manifests,pc_external_skill_registrations,pc_skill_packages,pc_agent_skill_targets,pc_skill_publications,pc_model_usage_daily,pc_recall_token_daily,pc_recall_effort_daily,pc_receipt_migration_review,pc_decision_observations,pc_lexical_projection_versions,pc_work_lanes,pc_work_items,pc_work_keys,pc_work_attempts,pc_source_write_plugins,pc_artifact_family_schemas,pc_artifact_vector_spaces,pc_artifact_search_states' \ -f ``` diff --git a/release/upgrade-support.json b/release/upgrade-support.json index f8b31a9b8f..fe108892bf 100644 --- a/release/upgrade-support.json +++ b/release/upgrade-support.json @@ -3,7 +3,7 @@ "target_version": "1.4.1", "mode": "upgrade-required", "schema_review": { - "persistence_sha256": "7132e0660d02041f5c140ca0cb8644a971de3ea7a213981c04f36480fe3bbb52", + "persistence_sha256": "0577c14e8b0ee06ba0d5bc28fca7bdd29c23ea9c0154e08c2c17127142cfafdb", "rationale": "The reusable Server migration framework registers actual PowerContext 1.3.0 through s1300 -> s1400 -> s1401 -> s1402 and actual 1.4.0 through the separate immutable r1400 -> s1400 -> s1401 -> s1402 source bridge. Original 1.3 resources and checksums remain byte-identical. Real 1.4.0 SQLite, embedded seekDB and OceanBase structures were captured from the official wheel in all twelve vector/Code configurations; provenance and exact source layouts are packaged with the bridge. The 1.4 bridge preserves current Candidate, Artifact and task data, adds the missing Topic budget/block structure, revokes old lease fences and joins the existing archive/portable-storage path. Empty retained tables align the final schema without inventing 1.3 Candidate history. Source histories retain their identity through subsequent upgrades and recovery; shared head names never authorize adopting unknown layouts. Vector profiles remain bound to historical model, profile ID, dimension, unit normalization and L2 distance, with no provider calls during maintenance. Complete schema, domain, projection, profile and retained-history checks precede startup and partial maintenance. Local SQLite and embedded seekDB startup reject an observable active maintenance lock through a read-only probe. SQLite runtime admission validates the default adjacent journal, rejecting unfinished full or partial maintenance, changed recorded revision checksums and missing required retained tables. Absent journals do not invent retention obligations; custom evidence directories require explicit operator verification and remote coordinator locks remain subject to deployment orchestration. Native read-only readiness uses a consistent snapshot and does not acquire Atomic Memory head locks; normal business transaction isolation is unchanged. Offline projection repairs retain the original locked state comparison and write-side CAS protection. Target locks, backup choices, original recovery evidence and explicit remote acknowledgments remain enforced. The installed-artifact catalog retains twelve 1.3.0 source/backend/vector/Code combinations. Embedded seekDB uses pylibseekdb 1.4.0.post2 or newer with the recovered-transaction lock fix. Main and publication require all twelve configurations, including embedded seekDB full-server-code and full-server-vector-code. No configuration is waived; incomplete, failed or skipped evidence rejects acceptance. The seekdb-code diagnostic selection cannot authorize publication. The registered 1.4.0 bridge remains implemented but is outside the CI upgrade acceptance matrix; passing this gate does not certify that path. The gate preserves source-specific Processing counters and isolates OceanBase source databases. Any failed, skipped or missing automatic case rejects publication. The policy remains upgrade-required until the exact target artifact passes the required automatic matrix and the reviewed support declaration is updated. Captured source structure, local tests and checkpoint replay are distinct from installed-release acceptance, native interruption/observer recovery and backup restoration. The Candidate generation prototype observes actual SQLite/seekDB/OceanBase migration checkpoints outside the production bundle; it does not replace frozen registrations or weaken recovery checks. Existing target schema and data formats, including batched persistence interfaces, remain governed by the shared registered path. The synchronized master adds blank-text validation at Source creation and managed worker logging. It changes neither registered table layout nor migration data encodings; existing stored Source values are not rewritten. The source bridges and frozen history remain unchanged. Compatibility commands identify Server-owned storage before opening a writable legacy profile and delegate source admission to the shared framework, including actual 1.4.0 and rejection of unknown layouts. Partial Processing and Atomic Memory projection maintenance verify the original recorded revision checksums; native admission also validates typed journal identity and its completed position against the database revision. Persisted vector profiles reject unsupported partial maintenance before plans, backups or operation evidence, including when the supplied configuration omits embeddings. These are admission-only changes; frozen migration resources, registered layouts, historical checksums and data transformations remain unchanged. PowerContext 1.4.1 assigns release-based presentation names v1_4_1_001 through v1_4_1_003 to the existing shared upgrade steps; the 1.3.0 and 1.4.0 source baselines remain independently registered. Existing revision IDs, bundle and revision checksums, accepted plan identities and journal schemas are unchanged. New Server revisions use software-version-plus-sequence names directly; software releases without storage changes may reuse the same database revision. Naming does not grant acceptance or replace source registration. Full Server status, plan, apply and verify reject unfinished Processing configuration and Atomic Memory projection journals through the same typed admission check used by partial maintenance and SQLite startup. The owning domain runner can still resume its own journal. This only changes admission; frozen revisions, journal formats, database layouts and data transformations remain unchanged. Revision v1_4_1_004 registers the pc_access_owner_recovery outbox on the shared Server chain for both installed-release sources and all registered vector/Code profiles. It creates an empty table through explicit maintenance for existing managed databases and through the registered bootstrap for fresh databases. Earlier revision resources, fingerprints, checksums and existing domain row encodings remain unchanged. External relationship writers use version-1 Artifact and Candidate intents committed with domain data; replay retains the original deployment, principal, resource or target, and idempotency identity. Stable operation and audit event identities prevent lost acknowledgements from duplicating ownership or audit. Conflicting intents are rejected and unknown historical ownership is not inferred. Unregistered outbox layouts are not adopted merely from a table name. This registration does not establish installed-release or external-Provider upgrade acceptance. The policy remains upgrade-required with the full automatic matrix and publication restrictions described above. Database selection now defaults to powercontext for seekDB and for OceanBase URLs without a database, while explicit database names remain authoritative. An implicit seekDB selection is rejected before initialization when the legacy test database contains pc_ tables; operators can explicitly select database=test to retain those records, and no database is moved or deleted automatically. Bootstrap reuses an accessible selected database without CREATE privilege and creates only a confirmed missing database, with idempotent creation and explicit permission errors. Read-only maintenance on an initialized MySQL-compatible engine requires the selected database and never provisions it; an absent or empty seekDB instance remains a no-engine initial plan. MySQL initialization creates only missing requested tables and indexes, and sorts only those requested tables; existing registered tables are preserved. Code graph connections use the selected database, retain historical graph identities for test, and distinguish other database identities. Server identity initialization avoids redundant MySQL DDL on an existing table. Frozen migration resources, revision identifiers and checksums, registered table definitions, journal formats, and existing row encodings are unchanged. These runtime selection and admission changes do not establish an accepted upgrade path; the policy remains upgrade-required and the installed-release acceptance gate still applies. MySQL recovery rejects different registered table sets before normalization. Backend-default normalization copies only the column and index maps it changes and compares each table without cloning whole inventories. Admitted layouts, registered transitions, recovery checks, schema equality and caller-owned inventories remain preserved. SeekDB status and verify distinguish a missing configured database from an uninitialized engine and denied access. Read-only plan can represent a missing selected database as an uninitialized target without creating it; apply provisions that selected database only after the operator accepts its bound plan, then rechecks the target under the maintenance lock. Installed-release seekDB acceptance explicitly retains the historical test database and separately verifies that omitted selection is refused. Registered schema resources, revision checksums, row encodings and write-side provisioning remain unchanged. Server startup can apply an uninitialized plan to provision a missing selected seekDB database; plan, status and verify still never provision it. OceanBase 4.6 SHOW CREATE TABLE can reflect FTS_INDEX_TYPE=MATCH or PHRASE_MATCH on FULLTEXT indexes. Reflection accepts these known types without changing the logical inventory; unknown values remain rejected and indexed columns, parser identity and token-size properties remain significant. PHRASE_MATCH additionally supports phrase matching; the registered layouts require term matching and do not declare a phrase-only contract. Frozen migration resources, checksums, recovery evidence and persisted layouts remain unchanged. Native 4.6.3.1 DDL was captured from a real installed 1.3.0 database; replay of this fixture does not establish complete native upgrade acceptance. The CI database image and master full automatic upgrade matrix remain unchanged. Revision v1_4_1_005 adds nullable Atomic Memory description projection metadata after the registered owner-recovery revision, through explicit SQLite and MySQL schema transitions for both source chains and all vector/Code profiles. Earlier revision resources, fingerprints and checksums remain unchanged. The additive change neither rewrites authoritative Artifact history nor generates summaries or scans stored bodies. Existing managed databases require explicit migration; existing descriptions remain null until a new write or explicit projection repair copies authoritative metadata. Intermediate projection validation uses only columns available at that revision and still rejects inconsistent existing fields. Unmanaged SDK initialization retains its nullable-column compatibility path. The installed-release acceptance matrix and publication restrictions remain required; local regression checks do not establish native upgrade acceptance. Artifact plugin storage extends the shared Server chain from v1_4_1_006 to v1_5_0_001 with registration columns, family declarations, search state and projections, index coverage and changes, and vector-space/index registrations. SQLite also registers portable vectors and FTS auxiliary structures. Native registration columns are added in one ALTER with a matching recovery checkpoint; final column definitions and constraints are unchanged. Native vector tables are created online from registered spaces and are admitted to later maintenance inventories only after exact layout validation. Historical migration resources, retained objects, and existing data are preserved. This review does not expand the supported upgrade matrix or certify installed-release acceptance. Source write plugin registrations use the additive v1_4_1_006 revision after v1_4_1_005 on both source chains. It creates only pc_source_write_plugins; all earlier master revision scripts, schema resources and checksums remain unchanged. Code publication recovery adds an optional per-binding publication.json journal that records a generation identifier before its database commit and final directory rename. Caches created before this journal existed remain readable without synthesizing historical publication records; identifiers for already orphaned rows are not inferred. New seekDB generations retain the existing identifier, graph descriptor, manifest and row encodings, while reclamation validates the recorded identifier and scopes deletion to its binding. Live or reader-retained generation directories and the in-flight publication are protected from journal-window reclamation. SQLite graph rows remain owned by their generation directory. This additive local cache journal changes recovery behavior but does not change registered SQL table layouts, migration resources, revision identifiers, historical checksums or the Server migration journal. The policy remains upgrade-required, and the complete installed-release acceptance matrix remains mandatory; this compatibility review is not evidence of completed native upgrade acceptance. Managed Ops admission validates a private main-file and WAL snapshot through the exact target Server bundle, retaining the original database identity and maintenance evidence. Snapshot admission is read-only and cannot initialize, migrate or recover storage. It adds no schema revision, changes no persisted row or journal format, and does not expand supported upgrade declarations or the installed-release acceptance matrix. PreparedContext Receipt records the actual FTS mode on Experience search outcomes. This adds only an in-memory observation field; it does not change stored Experience rows, SQL selection, schema registrations, migration resources, or data encodings. The rebased distributed Work Ledger appends revision v1_4_1_007 to the shared Server chain: eight work and coordination tables registered in the manifest with frozen SQLite and MySQL DDL compiled from the current metadata, a schema fingerprint measured by replaying s1300 through v1_4_1_007, a named unique constraint on pc_work_keys.work_id so the frozen layout can register and verify it, and the four durable ledger tables listed in the obloader parent-free restore layer. The two database primitives the ledger claims on (naive-UTC database_now and conflict-tolerant insert_if_absent) are restored beside master's reworked engine layer with their semantics documented. Unregistered Alembic scaffolding that no runner executes is removed. No existing table layout, frozen resource, source bridge or historical checksum changes; the policy remains upgrade-required." }, "cases": [], diff --git a/src/powercontext/builtin/persistence/migrations/server_resources/manifest.json b/src/powercontext/builtin/persistence/migrations/server_resources/manifest.json index 85e3ee4564..2cf75162b6 100644 --- a/src/powercontext/builtin/persistence/migrations/server_resources/manifest.json +++ b/src/powercontext/builtin/persistence/migrations/server_resources/manifest.json @@ -38,8 +38,8 @@ "schemas/mysql-v1_4_1_006.json" ], "v1_4_1_007": [ - "schemas/sqlite-v1_4_1_006.json", - "schemas/mysql-v1_4_1_006.json" + "schemas/sqlite-v1_4_1_007.json", + "schemas/mysql-v1_4_1_007.json" ] }, "sqlite_baselines": { @@ -65,13 +65,13 @@ "2e24be9f699c202cb9d7985690b2be947bdc98f8f64c1c4e4b8e16e35a580fc4" ], "v1_5_0_001": [ - "8f2d4a32f3558612b1bd390c262b8b3e355b172e34cc99692f38c11876226ebd" + "3cf07debf9cd41cc83f1d4e99175d2079418d4ae26e92a6fb6e9814ba615df9c" ], "v1_4_1_006": [ "cacce80bdfb14aff889b7babebc9ab8487b7f0a11d2dbbd3d77a3717a23df74c" ], "v1_4_1_007": [ - "92eaad28512ba8a03917acdd9a0996523a67450092e03161eca68e4e0f9d4cb6" + "c8efae95d653545314c51aa1d2de7b1ff97ba4a389449c5a76538989851ecb40" ] }, "affected_objects": [ diff --git a/src/powercontext/builtin/persistence/migrations/server_resources/versions/v1_4_1_006_work_ledger.py b/src/powercontext/builtin/persistence/migrations/server_resources/versions/v1_4_1_007_work_ledger.py similarity index 73% rename from src/powercontext/builtin/persistence/migrations/server_resources/versions/v1_4_1_006_work_ledger.py rename to src/powercontext/builtin/persistence/migrations/server_resources/versions/v1_4_1_007_work_ledger.py index 6659cf6c63..68d4bfb76e 100644 --- a/src/powercontext/builtin/persistence/migrations/server_resources/versions/v1_4_1_006_work_ledger.py +++ b/src/powercontext/builtin/persistence/migrations/server_resources/versions/v1_4_1_007_work_ledger.py @@ -14,10 +14,10 @@ """Add the durable Work Ledger and its scheduler coordination tables. -The revision follows ``v1_4_1_004`` rather than branching off ``s1402``: the -bundle requires one linear chain, and master's owner-recovery revision already -claimed the slot after ``s1402``. The release-based name follows the same rule -that named ``v1_4_1_004``. +The revision follows ``v1_4_1_006`` rather than branching off ``s1402``: the +bundle requires one linear chain, and the earlier owner-recovery and +source-write-plugin revisions already claimed the slots after ``s1402``. The +release-based name follows the same rule that named ``v1_4_1_004``. """ import json @@ -25,12 +25,12 @@ from alembic import op -revision = "v1_4_1_006" -down_revision = "v1_4_1_004" +revision = "v1_4_1_007" +down_revision = "v1_4_1_006" def upgrade(): - resource = Path(__file__).parents[1] / "schemas/sqlite-v1_4_1_006.json" + resource = Path(__file__).parents[1] / "schemas/sqlite-v1_4_1_007.json" connection = op.get_bind() for statement in json.loads(resource.read_text())["create"]: connection.exec_driver_sql(statement) diff --git a/src/powercontext/builtin/persistence/migrations/server_resources/versions/v1_5_0_001_artifact_storage.py b/src/powercontext/builtin/persistence/migrations/server_resources/versions/v1_5_0_001_artifact_storage.py index a8dd25e458..433c159023 100644 --- a/src/powercontext/builtin/persistence/migrations/server_resources/versions/v1_5_0_001_artifact_storage.py +++ b/src/powercontext/builtin/persistence/migrations/server_resources/versions/v1_5_0_001_artifact_storage.py @@ -20,7 +20,7 @@ from alembic import op revision = "v1_5_0_001" -down_revision = "v1_4_1_006" +down_revision = "v1_4_1_007" def upgrade(): diff --git a/src/powercontext/builtin/persistence/migrations/server_sources/1.4.0/manifest.json b/src/powercontext/builtin/persistence/migrations/server_sources/1.4.0/manifest.json index 023182848f..dd97621935 100644 --- a/src/powercontext/builtin/persistence/migrations/server_sources/1.4.0/manifest.json +++ b/src/powercontext/builtin/persistence/migrations/server_sources/1.4.0/manifest.json @@ -37,8 +37,8 @@ "schemas/mysql-v1_4_1_006.json" ], "v1_4_1_007": [ - "schemas/sqlite-v1_4_1_006.json", - "schemas/mysql-v1_4_1_006.json" + "schemas/sqlite-v1_4_1_007.json", + "schemas/mysql-v1_4_1_007.json" ] }, "sqlite_baselines": { @@ -64,13 +64,13 @@ "2e24be9f699c202cb9d7985690b2be947bdc98f8f64c1c4e4b8e16e35a580fc4" ], "v1_5_0_001": [ - "8f2d4a32f3558612b1bd390c262b8b3e355b172e34cc99692f38c11876226ebd" + "3cf07debf9cd41cc83f1d4e99175d2079418d4ae26e92a6fb6e9814ba615df9c" ], "v1_4_1_006": [ "cacce80bdfb14aff889b7babebc9ab8487b7f0a11d2dbbd3d77a3717a23df74c" ], "v1_4_1_007": [ - "92eaad28512ba8a03917acdd9a0996523a67450092e03161eca68e4e0f9d4cb6" + "c8efae95d653545314c51aa1d2de7b1ff97ba4a389449c5a76538989851ecb40" ] }, "affected_objects": [ diff --git a/src/powercontext/builtin/persistence/seekdb/profile.py b/src/powercontext/builtin/persistence/seekdb/profile.py index fe448b7c30..620185eab2 100644 --- a/src/powercontext/builtin/persistence/seekdb/profile.py +++ b/src/powercontext/builtin/persistence/seekdb/profile.py @@ -135,7 +135,13 @@ async def open( ) -> AsyncIterator[SeekDBProfile]: """Start seekdb locally and connect through its async Unix socket.""" - async with cls._connect(config, tables=tables, provision=True, _archive_recovery=_archive_recovery) as profile: + async with cls._connect( + config, + tables=tables, + provision=True, + create_schema=create_schema, + _archive_recovery=_archive_recovery, + ) as profile: yield profile @classmethod @@ -159,6 +165,7 @@ async def _connect( *, tables: tuple[Table, ...], provision: bool, + create_schema: bool = True, _archive_recovery: bool = False, ) -> AsyncIterator[SeekDBProfile]: from powercontext.builtin.persistence.migrations.locking import assert_no_local_migration diff --git a/tests/builtin/persistence/test_atomic_description_vector_layout.py b/tests/builtin/persistence/test_atomic_description_vector_layout.py index 1c41ce29e4..013b6c0ed5 100644 --- a/tests/builtin/persistence/test_atomic_description_vector_layout.py +++ b/tests/builtin/persistence/test_atomic_description_vector_layout.py @@ -56,7 +56,9 @@ def test_mysql_vector_admission_retains_description_and_exact_previous_revision( assert vector.vector_profile is not None inventory = {"tables": {}, "views": [], "triggers": []} for resource in json.loads((plain.directory / "manifest.json").read_text())["mysql_state_layouts"]: - if resource.endswith("mysql-v1_5_0_001.json"): + # Inspect the description revision: later Source-plugin and Work Ledger + # registrations only add their own tables and are skipped outright. + if resource.endswith("mysql-v1_5_0_001.json") or resource.endswith("mysql-v1_4_1_007.json"): continue for entry in json.loads((plain.directory / resource).read_text())["revisions"].values(): for change in entry["changes"]: @@ -103,7 +105,7 @@ def test_description_upgrade_preserves_registered_owner_recovery_and_evidence(tm shutil.copytree(current.snapshot_directory, directory, ignore=shutil.ignore_patterns("__pycache__")) manifest_path = directory / "manifest.json" manifest = json.loads(manifest_path.read_text(encoding="utf-8")) - removed_revisions = ("v1_4_1_005", "v1_4_1_006", "v1_5_0_001") + removed_revisions = ("v1_4_1_005", "v1_4_1_006", "v1_4_1_007", "v1_5_0_001") for revision in removed_revisions: for resource in manifest["revision_resources"].pop(revision): (directory / resource).unlink() @@ -116,6 +118,17 @@ def test_description_upgrade_preserves_registered_owner_recovery_and_evidence(tm manifest["domain_steps"] = [step for step in manifest["domain_steps"] if step["revision"] not in removed_revisions] (directory / "artifact_storage_readiness.py").unlink() manifest["affected_objects"].remove("pc_source_write_plugins") + for table in ( + "pc_work_lanes", + "pc_work_items", + "pc_work_keys", + "pc_work_attempts", + "pc_scheduler_leases", + "pc_scheduler_scans", + "pc_runtime_members", + "pc_rate_limit_windows", + ): + manifest["affected_objects"].remove(table) manifest_path.write_text(json.dumps(manifest, ensure_ascii=False), encoding="utf-8") previous = MigrationBundle(directory, embedding_profile=profile) configuration = { diff --git a/tests/builtin/persistence/test_full_server_migrations.py b/tests/builtin/persistence/test_full_server_migrations.py index 3f74335524..a3767d8dd6 100644 --- a/tests/builtin/persistence/test_full_server_migrations.py +++ b/tests/builtin/persistence/test_full_server_migrations.py @@ -74,7 +74,16 @@ def test_complete_upgrade_preserves_history_backup_and_repeated_apply(source): before = dump(source.database) plan = source.plan() assert plan.source_revision == "s1300" and plan.target_revision == "v1_5_0_001" - assert plan.revisions == ("s1400", "s1401", "s1402", "v1_4_1_004", "v1_4_1_005", "v1_4_1_006", "v1_5_0_001") + assert plan.revisions == ( + "s1400", + "s1401", + "s1402", + "v1_4_1_004", + "v1_4_1_005", + "v1_4_1_006", + "v1_4_1_007", + "v1_5_0_001", + ) assert dump(source.database) == before result = apply(source) assert result.readiness_scope == "server" and result.backup_state == "completed" diff --git a/tests/builtin/persistence/test_portable_storage_migration.py b/tests/builtin/persistence/test_portable_storage_migration.py index 1862db1f63..3153310851 100644 --- a/tests/builtin/persistence/test_portable_storage_migration.py +++ b/tests/builtin/persistence/test_portable_storage_migration.py @@ -199,9 +199,9 @@ def test_source_write_plugin_upgrade_preserves_data_and_registration(tmp_path, v bundle = server_bundle(embedding_profile=embedding) upgraded = SQLiteMigrationRunner(database, bundle, configuration=config) assert upgraded.plan().revisions == ( - ("v1_4_1_005", "v1_4_1_006", "v1_5_0_001") + ("v1_4_1_005", "v1_4_1_006", "v1_4_1_007", "v1_5_0_001") if include_owner - else ("v1_4_1_004", "v1_4_1_005", "v1_4_1_006", "v1_5_0_001") + else ("v1_4_1_004", "v1_4_1_005", "v1_4_1_006", "v1_4_1_007", "v1_5_0_001") ) assert apply(upgraded).revision == "v1_5_0_001" definition = b'{"configuration":{"id":"tickets","entry":"tickets"}}' From 5fa749d85cca031089fd2316a98d19676ce8ded7 Mon Sep 17 00:00:00 2001 From: JasonDeng <15301580353@163.com> Date: Mon, 2 Nov 2026 16:49:48 +0800 Subject: [PATCH 15/45] chore(desktop): qualify the merged contract for the renumbered chain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The merge moved the bundled contract digest to 3a89f413…, which no record covered. Build the wheel from the clean d9f33be3 commit, verify all 685 packaged files byte-for-byte against it, and run the real Server harness: all four modes pass with desktopWorkingTreeDirty=false. Record sqlite-atomic-d9f33be3-v1 with its section in VALIDATION.md, restore the sqlite-atomic-cf81c1a8-v1 record that the merge dropped, and point the Compatibility scope paragraph at the new record. --- desktop/VALIDATION.md | 36 ++++++++++++++++- .../src/connections/compatibility.json | 40 +++++++++++++++++++ 2 files changed, 75 insertions(+), 1 deletion(-) diff --git a/desktop/VALIDATION.md b/desktop/VALIDATION.md index f4afc1b482..a08ec8bcd3 100644 --- a/desktop/VALIDATION.md +++ b/desktop/VALIDATION.md @@ -118,7 +118,7 @@ The historical Server c535d816 protocol qualification below records the specific ## Compatibility scope -[compatibility.json](src-tauri/src/connections/compatibility.json) preserves each recorded Server source, normalized OpenAPI contract digest, qualification-wheel digest and operation set. `sqlite-atomic-bae5d714-v1` records the current bundled contract's eleven protocol operations qualified below. Historical records retain their exact identities and evidence, including `sqlite-source-artifact-87b6fae2-v1` and the preceding `sqlite-artifact-merge-87d01f68-v1` record. The `sqlite-atomic-dd62723d-v1` and `sqlite-atomic-4ffdf4e9-v1` records retain their migration-framework and release-preparation source qualifications with their original contract digest. Other historical records also retain their exact identities and evidence. Ordinary builds require explicitly selecting the record whose tested Server source and wheel match the deployment, then checking the connection before business operations become available. Records with a different contract digest are disabled in the selector, and saved unavailable selections remain visible until explicitly replaced. Selecting a profile does not extend its recorded operation set or qualify a different Server source. CI builds have their own artifact identities and do not change product qualification. +[compatibility.json](src-tauri/src/connections/compatibility.json) preserves each recorded Server source, normalized OpenAPI contract digest, qualification-wheel digest and operation set. `sqlite-atomic-d9f33be3-v1` records the current bundled contract's eleven protocol operations qualified below. Historical records retain their exact identities and evidence, including `sqlite-atomic-cf81c1a8-v1` and `sqlite-atomic-bae5d714-v1`, which qualified the preceding `c7f00e81…` and `0f6befcd…` contracts respectively, and `sqlite-source-artifact-87b6fae2-v1` with the preceding `sqlite-artifact-merge-87d01f68-v1` record. The `sqlite-atomic-dd62723d-v1` and `sqlite-atomic-4ffdf4e9-v1` records retain their migration-framework and release-preparation source qualifications with their original contract digest. Other historical records also retain their exact identities and evidence. Ordinary builds require explicitly selecting the record whose tested Server source and wheel match the deployment, then checking the connection before business operations become available. Records with a different contract digest are disabled in the selector, and saved unavailable selections remain visible until explicitly replaced. Selecting a profile does not extend its recorded operation set or qualify a different Server source. CI builds have their own artifact identities and do not change product qualification. The real Server harness covers SQLite with anonymous loopback, static Bearer, HTTPS with explicit CA/base path, and injected-provider/enforced access. It exercises save/search/exact reads, identity changes, binding revocation, ambiguous writes without replay and Scope pagination. Installed UI checks cover repeated launches preserving the existing editor, late committed-save responses preserving a new Scope draft, explicit connection/Scope selection, clipboard copies, connection isolation, draft cancellation, byte limits and independent Server survival after Desktop exit. @@ -128,6 +128,40 @@ Selecting a compatibility profile does not attest the remote binary identity. Ex ### Server protocol qualification records +#### Server d9f33be3 protocol qualification + +`sqlite-atomic-d9f33be3-v1` qualifies Server sources at +`d9f33be3fde7a1801b51aa66c51d25af5b883d69` for the bundled OpenAPI digest +`3a89f4131cf822d458a55e9ea4c57e1314aaffbaff764fd4f4935c7fc9a5f3cb`, the merged contract that carries +master's 1.5.0 artifact-storage chain plus the four Scope-scoped durable operation endpoints. The +qualification wheel is `powercontext-1.2.1.dev498+gd9f33be3f-py3-none-any.whl`, SHA-256 +`c23438d67cb79ee6e23d58307917853167d2f4dd1916138b35f2ceb169d48202`, built from that clean commit. +All 685 packaged runtime files in `powercontext/` and `powercontext_service_bootstrap/` match the +source bytes at that commit. The runtime manifest digest is +`d81c6ed801f5d30686b7e7565ccc16a145af90dbf19b6788334a16d305a45cf3` +(sorted `wheel/path sha256` lines, with a final newline). + +On 2026-11-02, macOS 27.2 ARM64 with Python 3.14.7 and Rust 1.95.0 ran the existing +`desktop/tests/real_server.py` harness against the extracted wheel and independent SQLite Server +processes. All four modes passed: anonymous loopback, static Bearer, HTTPS with an explicit CA and +base path, and an injected provider with enforced access. The eleven recorded operations cover +Atomic Memory save/search/exact revision reads, Scope selection and pagination, identity changes, +same-identity revocation, committed writes with lost responses without replay, server discovery, +and Server survival after client exit. The report records the source commit above and +`desktopWorkingTreeDirty=false`. + +This is protocol qualification of the recorded source, contract and wheel. It does not qualify +Windows/Intel execution, native credential persistence, installed GUI workflows, signing or release +readiness, and it says nothing about the four new operation endpoints: they are Server-side contract +covered by the Python suite and are deliberately absent from the Desktop operation set above. +Reproduce from this clean commit with `uv build --wheel --out-dir desktop/.artifacts/server-wheel`, +`cargo build --locked --example server_probe --example diagnostic_cli_probe`, +`uv run --no-sync python desktop/tests/fixture_qualification.py --server-commit d9f33be3fde7a1801b51aa66c51d25af5b883d69`, +and `uv run --no-sync python desktop/tests/real_server.py`. +The bundled preview resolves its qualification by digest, so a contract change and a matching record +have to land together. `tests/test_desktop_contract.py` fails the Python gate for a digest without a +record, exactly as `bundled_qualification_allows_explicit_scope_browsing` fails the native one. + #### Server bae5d714 protocol qualification `sqlite-atomic-bae5d714-v1` qualifies Server source diff --git a/desktop/src-tauri/src/connections/compatibility.json b/desktop/src-tauri/src/connections/compatibility.json index 58fd48f9eb..86f54b5747 100644 --- a/desktop/src-tauri/src/connections/compatibility.json +++ b/desktop/src-tauri/src/connections/compatibility.json @@ -1,4 +1,24 @@ [ + { + "id": "sqlite-atomic-d9f33be3-v1", + "serverCommit": "d9f33be3fde7a1801b51aa66c51d25af5b883d69", + "contractSha256": "3a89f4131cf822d458a55e9ea4c57e1314aaffbaff764fd4f4935c7fc9a5f3cb", + "artifactSha256": "c23438d67cb79ee6e23d58307917853167d2f4dd1916138b35f2ceb169d48202", + "operations": [ + "get_access_principal", + "get_artifact_revision", + "get_capabilities", + "get_default_scope", + "get_liveness", + "get_readiness", + "get_scope", + "get_server_info", + "list_scopes", + "remember_memory", + "search_memory" + ], + "evidence": "desktop/VALIDATION.md#server-d9f33be3-protocol-qualification" + }, { "id": "sqlite-6e237568-v1", "serverCommit": "6e2375688bc38a667840c6b2dc8269d8848027bc", @@ -247,6 +267,26 @@ ], "evidence": "desktop/VALIDATION.md#server-cc5980f0-protocol-qualification" }, + { + "id": "sqlite-atomic-cf81c1a8-v1", + "serverCommit": "cf81c1a8a9c40c1fe186944ba6e334b54aa5da70", + "contractSha256": "c7f00e81a12848249c7105ba99c444b6233f4b2a4849d1d4443c419f53ac764f", + "artifactSha256": "0530ed69045ed45be3048aafd9256710d1e1ef4b234858b6314513556ff2b904", + "operations": [ + "get_access_principal", + "get_artifact_revision", + "get_capabilities", + "get_default_scope", + "get_liveness", + "get_readiness", + "get_scope", + "get_server_info", + "list_scopes", + "remember_memory", + "search_memory" + ], + "evidence": "desktop/VALIDATION.md#server-cf81c1a8-protocol-qualification" + }, { "id": "sqlite-atomic-4ffdf4e9-v1", "serverCommit": "4ffdf4e97ad43c55d8718a1df581596db2e6097d", From 20bd46241118671d4ab197ffc7f46c95f1058794 Mon Sep 17 00:00:00 2001 From: JasonDeng <15301580353@163.com> Date: Wed, 4 Nov 2026 09:25:49 +0800 Subject: [PATCH 16/45] fix(migrations): append the Work Ledger after the published v1_5_0_001 head The renumbered ledger revision re-parented v1_5_0_001 (down_revision v1_4_1_006 -> v1_4_1_007), which broke databases already migrated by a released binary: they are stamped at v1_5_0_001, appear ready, and never create the ledger tables. Reproduced with the actual base resources from master (745ebf2e): a fresh database applied to v1_5_0_001, verified ready, and had no pc_work_items. Append instead of insert. The Work Ledger is now v1_5_0_002 with down_revision v1_5_0_001, in both the SQLite and MySQL chains; the artifact-storage revision keeps master's down_revision v1_4_1_006. Manifest fingerprints move with the states (v1_5_0_001 returns to master's 8f2d4a32..., v1_5_0_002 takes the ledger-inclusive 3cf07deb...) and domain_readiness[v1_5_0_002] follows the new head position (artifact_storage + portable_storage). vector_profile composes the artifact layout from v1_4_1_006 and appends the ledger tables after it. Measured on the rerun: a master-shaped database upgraded with the PR bundle plans exactly ('v1_5_0_002',), applies to ready, and ends with all eight ledger tables present. The tests/test_server_migration_cli.py fresh-migration assertion now expects the real bundle head; the lifecycle probe revision moves to v1_5_0_003 to avoid the collision. --- docs/en/docs/operate/database-migrations.md | 25 +++++++++++-------- docs/zh/docs/operate/database-migrations.md | 23 +++++++++-------- .../migrations/server_resources/manifest.json | 24 +++++++++--------- .../mysql/versions/v1_5_0_001.py | 2 +- .../versions/{v1_4_1_007.py => v1_5_0_002.py} | 10 ++++---- ...-v1_4_1_007.json => mysql-v1_5_0_002.json} | 2 +- ...v1_4_1_007.json => sqlite-v1_5_0_002.json} | 0 .../versions/v1_5_0_001_artifact_storage.py | 2 +- ...rk_ledger.py => v1_5_0_002_work_ledger.py} | 15 +++++------ .../server_sources/1.4.0/manifest.json | 24 +++++++++--------- .../persistence/migrations/vector_profile.py | 10 ++++---- .../test_artifact_storage_lifecycle.py | 6 ++--- .../test_atomic_description_vector_layout.py | 8 +++--- .../test_full_server_migrations.py | 4 +-- .../test_owner_recovery_migration.py | 8 +++--- .../test_portable_storage_migration.py | 12 ++++----- .../test_release140_server_migrations.py | 2 +- tests/test_migration_revision_names.py | 8 +++--- .../test_registered_source_maintenance_cli.py | 2 +- tests/test_server_migration_cli.py | 2 +- 20 files changed, 98 insertions(+), 91 deletions(-) rename src/powercontext/builtin/persistence/migrations/server_resources/mysql/versions/{v1_4_1_007.py => v1_5_0_002.py} (73%) rename src/powercontext/builtin/persistence/migrations/server_resources/schemas/{mysql-v1_4_1_007.json => mysql-v1_5_0_002.json} (99%) rename src/powercontext/builtin/persistence/migrations/server_resources/schemas/{sqlite-v1_4_1_007.json => sqlite-v1_5_0_002.json} (100%) rename src/powercontext/builtin/persistence/migrations/server_resources/versions/{v1_4_1_007_work_ledger.py => v1_5_0_002_work_ledger.py} (64%) diff --git a/docs/en/docs/operate/database-migrations.md b/docs/en/docs/operate/database-migrations.md index 9b27b6937f..1c1ca04c3c 100644 --- a/docs/en/docs/operate/database-migrations.md +++ b/docs/en/docs/operate/database-migrations.md @@ -14,10 +14,10 @@ The design follows [RFC #1771](https://github.com/oceanbase/powercontext/pull/17 `powercontext server db-migrate status/plan/apply/verify` default to `--bundle server` and use the configured persistent SQLite, embedded seekDB, or OceanBase MySQL database. Explicit environment files are authoritative; arbitrary bundle paths are not accepted. -The full Server framework registers two installed-release sources for the PowerContext 1.4.1 target (stored compatibility IDs shown below): +The full Server framework registers two installed-release sources for the current managed target (stored compatibility IDs shown below): -- **1.3.0:** `s1300 → s1400 → s1401 → s1402 → v1_4_1_004 → v1_4_1_005 → v1_4_1_006 → v1_4_1_007`. -- **1.4.0:** `r1400 → s1400 → s1401 → s1402 → v1_4_1_004 → v1_4_1_005 → v1_4_1_006 → v1_4_1_007` through a separate frozen source bridge. +- **1.3.0:** `s1300 → s1400 → s1401 → s1402 → v1_4_1_004 → v1_4_1_005 → v1_4_1_006 → v1_5_0_001 → v1_5_0_002`. +- **1.4.0:** `r1400 → s1400 → s1401 → s1402 → v1_4_1_004 → v1_4_1_005 → v1_4_1_006 → v1_5_0_001 → v1_5_0_002` through a separate frozen source bridge. Application release numbers and schema revisions are distinct. The 1.4.0 release is not the `s1400` checkpoint: its existing Candidate schema is current, but its Topic attempt storage still needs the registered bridge. @@ -54,7 +54,9 @@ Revision `v1_4_1_004` registers `pc_access_owner_recovery` for durable Artifact Revision `v1_4_1_005` adds nullable `pc_atomic_memory_current.description` metadata for text and vector profiles. Existing managed databases require explicit migration; startup does not alter this column. The additive revision leaves historical Artifact bytes unchanged and existing descriptions null until a new write or explicit projection rebuild copies authoritative metadata. -Revision `v1_4_1_007` registers the durable Work Ledger and its scheduler coordination tables: `pc_work_lanes`, +Revision `v1_5_0_001` registers fixed Artifact plugin storage without mutating historical layouts; existing managed databases adopt it through explicit maintenance and fresh databases create it through the registered bootstrap. This revision follows the published chain order and does not re-parent any released revision. + +Revision `v1_5_0_002` registers the durable Work Ledger and its scheduler coordination tables: `pc_work_lanes`, `pc_work_items`, `pc_work_keys`, `pc_work_attempts`, `pc_scheduler_leases`, `pc_scheduler_scans`, `pc_runtime_members`, and the shared `pc_rate_limit_windows`. Ledger execution is at-least-once behind a monotonic fence token while its commit is at-most-once, so a retried, superseded or cancelled attempt cannot publish a duplicate @@ -98,7 +100,7 @@ read-only no-op. See [Migrate to Atomic Memory](atomic-memory-migration.md). ## Migration names and software versions -The target release is **PowerContext 1.4.1**. Migration names use +The latest registered steps target **PowerContext 1.5.0**; earlier steps keep their 1.4.1 identities. Migration names use `v___`, with a three-digit sequence within the release. Sequence `000` identifies a registered historical baseline; upgrade steps start at `001`. @@ -112,13 +114,14 @@ Sequence `000` identifies a registered historical baseline; upgrade steps start | `v1_4_1_004` | 1.4.1 | 4 | `v1_4_1_004` | Durable owner recovery intents | | `v1_4_1_005` | 1.4.1 | 5 | `v1_4_1_005` | Nullable Atomic Memory description projection | | `v1_4_1_006` | 1.4.1 | 6 | `v1_4_1_006` | Source write plugin registrations | -| `v1_4_1_007` | 1.4.1 | 7 | `v1_4_1_007` | Durable Work Ledger and scheduler coordination | +| `v1_5_0_001` | 1.5.0 | 1 | `v1_5_0_001` | Registered fixed Artifact plugin storage | +| `v1_5_0_002` | 1.5.0 | 2 | `v1_5_0_002` | Durable Work Ledger and scheduler coordination | -Both registered source paths reach `v1_4_1_007`; only 1.3.0 is selected for CI upgrade acceptance: +Both registered source paths reach `v1_5_0_002`; only 1.3.0 is selected for CI upgrade acceptance: ```text -1.3.0: v1_3_0_000 -> v1_4_1_001 -> v1_4_1_002 -> v1_4_1_003 -> v1_4_1_004 -> v1_4_1_005 -> v1_4_1_006 -> v1_4_1_007 -1.4.0: v1_4_0_000 -> v1_4_1_001 -> v1_4_1_002 -> v1_4_1_003 -> v1_4_1_004 -> v1_4_1_005 -> v1_4_1_006 -> v1_4_1_007 +1.3.0: v1_3_0_000 -> v1_4_1_001 -> v1_4_1_002 -> v1_4_1_003 -> v1_4_1_004 -> v1_4_1_005 -> v1_4_1_006 -> v1_5_0_001 -> v1_5_0_002 +1.4.0: v1_4_0_000 -> v1_4_1_001 -> v1_4_1_002 -> v1_4_1_003 -> v1_4_1_004 -> v1_4_1_005 -> v1_4_1_006 -> v1_5_0_001 -> v1_5_0_002 ``` `status` and `plan` show `source_migration`, `target_migration` and `pending_migrations` @@ -126,7 +129,7 @@ before the existing machine fields. `apply` and `verify` show `migration`. Each contains `name`, `software_version`, `sequence` and the compatible `revision` ID. For example: ```json -{"revision":"v1_4_1_007","name":"v1_4_1_007","software_version":"1.4.1","sequence":7} +{"revision":"v1_5_0_002","name":"v1_5_0_002","software_version":"1.5.0","sequence":2} ``` The software version describes the registered database state, not the currently running binary @@ -140,7 +143,7 @@ existing plans and original backups usable. Partial prototype bundles keep their For a new Server migration, use the release-based name directly as its Alembic `revision` and register its schema, domain steps and recovery checkpoints as usual. `down_revision` must reference -the actual preceding ID (`v1_4_1_007` when appending directly to the current head), even if that ID has +the actual preceding ID (`v1_5_0_002` when appending directly to the current head), even if that ID has a display alias. The framework follows this registered chain, not lexical name ordering. Already registered names and release assignments stay immutable; a version bump must not rename history. diff --git a/docs/zh/docs/operate/database-migrations.md b/docs/zh/docs/operate/database-migrations.md index a5e62d4137..80a13f31f5 100644 --- a/docs/zh/docs/operate/database-migrations.md +++ b/docs/zh/docs/operate/database-migrations.md @@ -14,8 +14,8 @@ description: 了解数据库迁移命令、备份策略、服务启停及当前 完整 Server 框架登记了两个真实安装包来源: -- **1.3.0:**`s1300 → s1400 → s1401 → s1402 → v1_4_1_004 → v1_4_1_005 → v1_4_1_006 → v1_4_1_007`。 -- **1.4.0:**通过独立冻结的来源桥接,执行 `r1400 → s1400 → s1401 → s1402 → v1_4_1_004 → v1_4_1_005 → v1_4_1_006 → v1_4_1_007`。 +- **1.3.0:**`s1300 → s1400 → s1401 → s1402 → v1_4_1_004 → v1_4_1_005 → v1_4_1_006 → v1_5_0_001 → v1_5_0_002`。 +- **1.4.0:**通过独立冻结的来源桥接,执行 `r1400 → s1400 → s1401 → s1402 → v1_4_1_004 → v1_4_1_005 → v1_4_1_006 → v1_5_0_001 → v1_5_0_002`。 应用发布版本与 schema revision 分别编号。1.4.0 发行包不等于 `s1400` 检查点:其 Candidate 结构已经是当前格式, 但 Topic 尝试状态仍需登记的桥接。两条路径保留既有引用和历史,到达相同的受管目标结构;原有资源摘要分别保留, @@ -47,7 +47,9 @@ payload 身份及待处理 intent。兼容的当前任务原样保留,不套 `v1_4_1_005` 为文本和向量配置添加可空的 `pc_atomic_memory_current.description` 列。已有受管库需要显式迁移,启动不会修改该列。新增列不改写历史 Artifact 内容;已有投影的摘要保持空值,直到新写入或显式投影重建复制权威摘要。 -修订 `v1_4_1_007` 登记持久化 Work Ledger 及其调度协调表:`pc_work_lanes`、`pc_work_items`、`pc_work_keys`、 +修订 `v1_5_0_001` 登记固定的 Artifact 插件存储,不改动历史布局;既有托管存储通过显式维护采纳,新数据库通过登记的引导流程创建。该修订沿已发布的链序追加,不改变任何已发布 revision 的父级。 + +修订 `v1_5_0_002` 登记持久化 Work Ledger 及其调度协调表:`pc_work_lanes`、`pc_work_items`、`pc_work_keys`、 `pc_work_attempts`、`pc_scheduler_leases`、`pc_scheduler_scans`、`pc_runtime_members`,以及共用的 `pc_rate_limit_windows`。账本执行是至少一次语义,并以单调 fence token 保护提交;提交至多一次,因此重试、被 取代或已取消的尝试不会重复发布结果,也不会释放其他 worker 的逻辑键。两条已登记发布源路径及向量配置均包含这些 @@ -81,7 +83,7 @@ MySQL 兼容后端的结构反射支持 CHECK 表达式中的二元 `+`、`-`、 ## 迁移编号与软件版本 -目标发布版本为 **PowerContext 1.4.1**。迁移名称采用 +最新登记的升级步骤面向 **PowerContext 1.5.0**,更早的步骤保留 1.4.1 标识。迁移名称采用 `v<主版本>_<次版本>_<补丁版本>_<序号>`,序号在同一版本内使用三位数字。 `000` 表示已登记的历史基线,升级步骤从 `001` 开始。 @@ -95,13 +97,14 @@ MySQL 兼容后端的结构反射支持 CHECK 表达式中的二元 `+`、`-`、 | `v1_4_1_004` | 1.4.1 | 第四步 | `v1_4_1_004` | 持久化所有权恢复意图 | | `v1_4_1_005` | 1.4.1 | 第五步 | `v1_4_1_005` | Atomic Memory 可空摘要投影列 | | `v1_4_1_006` | 1.4.1 | 第六步 | `v1_4_1_006` | Source write 插件注册 | -| `v1_4_1_007` | 1.4.1 | 第七步 | `v1_4_1_007` | 持久化 Work Ledger 与调度协调表 | +| `v1_5_0_001` | 1.5.0 | 第一步 | `v1_5_0_001` | 登记固定的 Artifact 插件存储 | +| `v1_5_0_002` | 1.5.0 | 第二步 | `v1_5_0_002` | 持久化 Work Ledger 与调度协调表 | -两条已登记来源路径都升级到 `v1_4_1_007`;CI 升级验收只选择 1.3.0: +两条已登记来源路径都升级到 `v1_5_0_002`;CI 升级验收只选择 1.3.0: ```text -1.3.0: v1_3_0_000 -> v1_4_1_001 -> v1_4_1_002 -> v1_4_1_003 -> v1_4_1_004 -> v1_4_1_005 -> v1_4_1_006 -> v1_4_1_007 -1.4.0: v1_4_0_000 -> v1_4_1_001 -> v1_4_1_002 -> v1_4_1_003 -> v1_4_1_004 -> v1_4_1_005 -> v1_4_1_006 -> v1_4_1_007 +1.3.0: v1_3_0_000 -> v1_4_1_001 -> v1_4_1_002 -> v1_4_1_003 -> v1_4_1_004 -> v1_4_1_005 -> v1_4_1_006 -> v1_5_0_001 -> v1_5_0_002 +1.4.0: v1_4_0_000 -> v1_4_1_001 -> v1_4_1_002 -> v1_4_1_003 -> v1_4_1_004 -> v1_4_1_005 -> v1_4_1_006 -> v1_5_0_001 -> v1_5_0_002 ``` `status` 和 `plan` 在原有机器字段之前显示 `source_migration`、`target_migration`、 @@ -109,7 +112,7 @@ MySQL 兼容后端的结构反射支持 CHECK 表达式中的二元 `+`、`-`、 `software_version`、`sequence` 和兼容保留的 `revision` ID。例如: ```json -{"revision":"v1_4_1_007","name":"v1_4_1_007","software_version":"1.4.1","sequence":7} +{"revision":"v1_5_0_002","name":"v1_5_0_002","software_version":"1.5.0","sequence":2} ``` 这里的软件版本描述已登记的数据库状态,不代表当前运行的软件包版本,也不代表中断维护窗口最初的来源。 @@ -120,7 +123,7 @@ MySQL 兼容后端的结构反射支持 CHECK 表达式中的二元 `+`、`-`、 这样可以继续使用已有计划和原始备份。局部原型 bundle 保留自己的独立编号。 后续新增 Server 迁移直接使用该命名作为 Alembic `revision`,并正常登记结构、领域步骤和恢复检查点。 -`down_revision` 必须引用前一步的实际 ID;若直接追加在当前 head 之后,引用 `v1_4_1_007`,即使它已有展示别名。 +`down_revision` 必须引用前一步的实际 ID;若直接追加在当前 head 之后,引用 `v1_5_0_002`,即使它已有展示别名。 框架按照登记的链执行,不按名称字典序推算。已登记的名称和所属发布版本保持不变,版本升级不能重命名历史。 ## 初始化与维护职责 diff --git a/src/powercontext/builtin/persistence/migrations/server_resources/manifest.json b/src/powercontext/builtin/persistence/migrations/server_resources/manifest.json index 2cf75162b6..8b1bc00ef5 100644 --- a/src/powercontext/builtin/persistence/migrations/server_resources/manifest.json +++ b/src/powercontext/builtin/persistence/migrations/server_resources/manifest.json @@ -33,13 +33,13 @@ "schemas/sqlite-v1_5_0_001.json", "schemas/mysql-v1_5_0_001.json" ], + "v1_5_0_002": [ + "schemas/sqlite-v1_5_0_002.json", + "schemas/mysql-v1_5_0_002.json" + ], "v1_4_1_006": [ "schemas/sqlite-v1_4_1_006.json", "schemas/mysql-v1_4_1_006.json" - ], - "v1_4_1_007": [ - "schemas/sqlite-v1_4_1_007.json", - "schemas/mysql-v1_4_1_007.json" ] }, "sqlite_baselines": { @@ -65,13 +65,13 @@ "2e24be9f699c202cb9d7985690b2be947bdc98f8f64c1c4e4b8e16e35a580fc4" ], "v1_5_0_001": [ + "8f2d4a32f3558612b1bd390c262b8b3e355b172e34cc99692f38c11876226ebd" + ], + "v1_5_0_002": [ "3cf07debf9cd41cc83f1d4e99175d2079418d4ae26e92a6fb6e9814ba615df9c" ], "v1_4_1_006": [ "cacce80bdfb14aff889b7babebc9ab8487b7f0a11d2dbbd3d77a3717a23df74c" - ], - "v1_4_1_007": [ - "c8efae95d653545314c51aa1d2de7b1ff97ba4a389449c5a76538989851ecb40" ] }, "affected_objects": [ @@ -279,11 +279,11 @@ "artifact_storage", "portable_storage" ], - "v1_4_1_006": [ - "server_upgrade", + "v1_5_0_002": [ + "artifact_storage", "portable_storage" ], - "v1_4_1_007": [ + "v1_4_1_006": [ "server_upgrade", "portable_storage" ] @@ -304,8 +304,8 @@ "schemas/mysql-v1_4_1_004.json", "schemas/mysql-v1_4_1_005.json", "schemas/mysql-v1_5_0_001.json", - "schemas/mysql-v1_4_1_006.json", - "schemas/mysql-v1_4_1_007.json" + "schemas/mysql-v1_5_0_002.json", + "schemas/mysql-v1_4_1_006.json" ], "mysql_history": "mysql", "mysql_schema_extensions": { diff --git a/src/powercontext/builtin/persistence/migrations/server_resources/mysql/versions/v1_5_0_001.py b/src/powercontext/builtin/persistence/migrations/server_resources/mysql/versions/v1_5_0_001.py index 168e45817f..10e33ea9e4 100644 --- a/src/powercontext/builtin/persistence/migrations/server_resources/mysql/versions/v1_5_0_001.py +++ b/src/powercontext/builtin/persistence/migrations/server_resources/mysql/versions/v1_5_0_001.py @@ -17,7 +17,7 @@ from alembic import context revision = "v1_5_0_001" -down_revision = "v1_4_1_007" +down_revision = "v1_4_1_006" def upgrade() -> None: diff --git a/src/powercontext/builtin/persistence/migrations/server_resources/mysql/versions/v1_4_1_007.py b/src/powercontext/builtin/persistence/migrations/server_resources/mysql/versions/v1_5_0_002.py similarity index 73% rename from src/powercontext/builtin/persistence/migrations/server_resources/mysql/versions/v1_4_1_007.py rename to src/powercontext/builtin/persistence/migrations/server_resources/mysql/versions/v1_5_0_002.py index 53e1eff5f2..2066e68428 100644 --- a/src/powercontext/builtin/persistence/migrations/server_resources/mysql/versions/v1_4_1_007.py +++ b/src/powercontext/builtin/persistence/migrations/server_resources/mysql/versions/v1_5_0_002.py @@ -14,15 +14,15 @@ """Register the Work Ledger tables through the shared MySQL DDL executor. -The revision follows ``v1_4_1_006`` so both the SQLite and MySQL histories stay -linear behind master's Source-write-plugin revision, and keeps the release-based -name that history introduced. +The revision follows master's published ``v1_5_0_001`` head so databases +already migrated by a released binary keep an upgrade path, and both the +SQLite and MySQL histories stay linear with the same release-based name. """ from alembic import context -revision = "v1_4_1_007" -down_revision = "v1_4_1_006" +revision = "v1_5_0_002" +down_revision = "v1_5_0_001" def upgrade() -> None: diff --git a/src/powercontext/builtin/persistence/migrations/server_resources/schemas/mysql-v1_4_1_007.json b/src/powercontext/builtin/persistence/migrations/server_resources/schemas/mysql-v1_5_0_002.json similarity index 99% rename from src/powercontext/builtin/persistence/migrations/server_resources/schemas/mysql-v1_4_1_007.json rename to src/powercontext/builtin/persistence/migrations/server_resources/schemas/mysql-v1_5_0_002.json index a4159d0f1c..e926a3e797 100644 --- a/src/powercontext/builtin/persistence/migrations/server_resources/schemas/mysql-v1_4_1_007.json +++ b/src/powercontext/builtin/persistence/migrations/server_resources/schemas/mysql-v1_5_0_002.json @@ -1,7 +1,7 @@ { "format": "powercontext.mysql-state-layouts.v1", "revisions": { - "v1_4_1_007": { + "v1_5_0_002": { "statements": [ "CREATE TABLE pc_work_lanes (\n\tlane_key VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, \n\thead_sequence BIGINT, \n\tnext_sequence BIGINT NOT NULL, \n\tPRIMARY KEY (lane_key), \n\tCONSTRAINT ck_pc_work_lanes_head_positive CHECK (head_sequence IS NULL OR head_sequence > 0), \n\tCONSTRAINT ck_pc_work_lanes_next_positive CHECK (next_sequence > 0)\n)", "CREATE TABLE pc_work_items (\n\twork_id VARCHAR(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, \n\tlogical_key VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, \n\tlane_key VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, \n\tlane_sequence BIGINT NOT NULL, \n\tkind VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, \n\tpayload_version INTEGER NOT NULL, \n\tscope_id VARCHAR(256) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, \n\tpayload MEDIUMBLOB NOT NULL, \n\tstatus VARCHAR(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, \n\tavailable_at DATETIME(6) NOT NULL, \n\tlease_owner VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin, \n\tlease_fence BIGINT NOT NULL, \n\tlease_expires_at DATETIME(6), \n\tattempt_count INTEGER NOT NULL, \n\tgeneration_attempt_count INTEGER NOT NULL, \n\trecovery_generation INTEGER NOT NULL, \n\tmax_attempts INTEGER NOT NULL, \n\tcancel_requested TINYINT(1) NOT NULL, \n\tresult_code VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin, \n\tresult_payload MEDIUMBLOB, \n\terror_category VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin, \n\terror_code VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin, \n\tstate_version BIGINT NOT NULL, \n\tcreated_at DATETIME(6) NOT NULL, \n\tupdated_at DATETIME(6) NOT NULL, \n\tcompleted_at DATETIME(6), \n\tPRIMARY KEY (work_id), \n\tCONSTRAINT uq_pc_work_items_lane_sequence UNIQUE (lane_key, lane_sequence), \n\tCONSTRAINT ck_pc_work_items_status CHECK (status IN ('queued', 'running', 'retry_wait', 'cancelling', 'succeeded', 'failed', 'cancelled')), \n\tCONSTRAINT ck_pc_work_items_lane_sequence_positive CHECK (lane_sequence > 0), \n\tCONSTRAINT ck_pc_work_items_payload_version_positive CHECK (payload_version > 0), \n\tCONSTRAINT ck_pc_work_items_fence_nonnegative CHECK (lease_fence >= 0), \n\tCONSTRAINT ck_pc_work_items_attempts_nonnegative CHECK (attempt_count >= 0), \n\tCONSTRAINT ck_pc_work_items_generation_attempts_nonnegative CHECK (generation_attempt_count >= 0), \n\tCONSTRAINT ck_pc_work_items_recovery_nonnegative CHECK (recovery_generation >= 0), \n\tCONSTRAINT ck_pc_work_items_max_attempts_positive CHECK (max_attempts > 0), \n\tCONSTRAINT ck_pc_work_items_state_version_positive CHECK (state_version > 0)\n)", diff --git a/src/powercontext/builtin/persistence/migrations/server_resources/schemas/sqlite-v1_4_1_007.json b/src/powercontext/builtin/persistence/migrations/server_resources/schemas/sqlite-v1_5_0_002.json similarity index 100% rename from src/powercontext/builtin/persistence/migrations/server_resources/schemas/sqlite-v1_4_1_007.json rename to src/powercontext/builtin/persistence/migrations/server_resources/schemas/sqlite-v1_5_0_002.json diff --git a/src/powercontext/builtin/persistence/migrations/server_resources/versions/v1_5_0_001_artifact_storage.py b/src/powercontext/builtin/persistence/migrations/server_resources/versions/v1_5_0_001_artifact_storage.py index 433c159023..a8dd25e458 100644 --- a/src/powercontext/builtin/persistence/migrations/server_resources/versions/v1_5_0_001_artifact_storage.py +++ b/src/powercontext/builtin/persistence/migrations/server_resources/versions/v1_5_0_001_artifact_storage.py @@ -20,7 +20,7 @@ from alembic import op revision = "v1_5_0_001" -down_revision = "v1_4_1_007" +down_revision = "v1_4_1_006" def upgrade(): diff --git a/src/powercontext/builtin/persistence/migrations/server_resources/versions/v1_4_1_007_work_ledger.py b/src/powercontext/builtin/persistence/migrations/server_resources/versions/v1_5_0_002_work_ledger.py similarity index 64% rename from src/powercontext/builtin/persistence/migrations/server_resources/versions/v1_4_1_007_work_ledger.py rename to src/powercontext/builtin/persistence/migrations/server_resources/versions/v1_5_0_002_work_ledger.py index 68d4bfb76e..79e71e471f 100644 --- a/src/powercontext/builtin/persistence/migrations/server_resources/versions/v1_4_1_007_work_ledger.py +++ b/src/powercontext/builtin/persistence/migrations/server_resources/versions/v1_5_0_002_work_ledger.py @@ -14,10 +14,11 @@ """Add the durable Work Ledger and its scheduler coordination tables. -The revision follows ``v1_4_1_006`` rather than branching off ``s1402``: the -bundle requires one linear chain, and the earlier owner-recovery and -source-write-plugin revisions already claimed the slots after ``s1402``. The -release-based name follows the same rule that named ``v1_4_1_004``. +The revision follows master's published ``v1_5_0_001`` head rather than being +inserted before it: databases already migrated by a released binary are stamped +at ``v1_5_0_001``, and re-parenting that revision would leave them with no +upgrade path to create the ledger tables. The release-based name follows the +same rule that named ``v1_4_1_004``. """ import json @@ -25,12 +26,12 @@ from alembic import op -revision = "v1_4_1_007" -down_revision = "v1_4_1_006" +revision = "v1_5_0_002" +down_revision = "v1_5_0_001" def upgrade(): - resource = Path(__file__).parents[1] / "schemas/sqlite-v1_4_1_007.json" + resource = Path(__file__).parents[1] / "schemas/sqlite-v1_5_0_002.json" connection = op.get_bind() for statement in json.loads(resource.read_text())["create"]: connection.exec_driver_sql(statement) diff --git a/src/powercontext/builtin/persistence/migrations/server_sources/1.4.0/manifest.json b/src/powercontext/builtin/persistence/migrations/server_sources/1.4.0/manifest.json index dd97621935..046f4a3b7e 100644 --- a/src/powercontext/builtin/persistence/migrations/server_sources/1.4.0/manifest.json +++ b/src/powercontext/builtin/persistence/migrations/server_sources/1.4.0/manifest.json @@ -32,13 +32,13 @@ "schemas/sqlite-v1_5_0_001.json", "schemas/mysql-v1_5_0_001.json" ], + "v1_5_0_002": [ + "schemas/sqlite-v1_5_0_002.json", + "schemas/mysql-v1_5_0_002.json" + ], "v1_4_1_006": [ "schemas/sqlite-v1_4_1_006.json", "schemas/mysql-v1_4_1_006.json" - ], - "v1_4_1_007": [ - "schemas/sqlite-v1_4_1_007.json", - "schemas/mysql-v1_4_1_007.json" ] }, "sqlite_baselines": { @@ -64,13 +64,13 @@ "2e24be9f699c202cb9d7985690b2be947bdc98f8f64c1c4e4b8e16e35a580fc4" ], "v1_5_0_001": [ + "8f2d4a32f3558612b1bd390c262b8b3e355b172e34cc99692f38c11876226ebd" + ], + "v1_5_0_002": [ "3cf07debf9cd41cc83f1d4e99175d2079418d4ae26e92a6fb6e9814ba615df9c" ], "v1_4_1_006": [ "cacce80bdfb14aff889b7babebc9ab8487b7f0a11d2dbbd3d77a3717a23df74c" - ], - "v1_4_1_007": [ - "c8efae95d653545314c51aa1d2de7b1ff97ba4a389449c5a76538989851ecb40" ] }, "affected_objects": [ @@ -283,11 +283,11 @@ "artifact_storage", "portable_storage" ], - "v1_4_1_006": [ - "server_upgrade", + "v1_5_0_002": [ + "artifact_storage", "portable_storage" ], - "v1_4_1_007": [ + "v1_4_1_006": [ "server_upgrade", "portable_storage" ] @@ -308,8 +308,8 @@ "schemas/mysql-v1_4_1_004.json", "schemas/mysql-v1_4_1_005.json", "schemas/mysql-v1_5_0_001.json", - "schemas/mysql-v1_4_1_006.json", - "schemas/mysql-v1_4_1_007.json" + "schemas/mysql-v1_5_0_002.json", + "schemas/mysql-v1_4_1_006.json" ], "mysql_history": "mysql", "mysql_schema_extensions": { diff --git a/src/powercontext/builtin/persistence/migrations/vector_profile.py b/src/powercontext/builtin/persistence/migrations/vector_profile.py index 01ab9599ea..e3625ee8c7 100644 --- a/src/powercontext/builtin/persistence/migrations/vector_profile.py +++ b/src/powercontext/builtin/persistence/migrations/vector_profile.py @@ -106,19 +106,19 @@ def sqlite_layouts(self, directory: Path) -> dict[str, list[list[Any]]]: # noqa layouts["v1_4_1_006"] = layouts["v1_4_1_005"] + [ ["table", "pc_source_write_plugins", "pc_source_write_plugins", plugin] ] - if "v1_4_1_007" in manifest["revision_resources"]: - ledger = json.loads((directory / "schemas/sqlite-v1_4_1_007.json").read_text(encoding="utf-8"))["create"] - layouts["v1_4_1_007"] = layouts["v1_4_1_006"] + [registry_row(statement) for statement in ledger] artifact_revision = "v1_5_0_001" if artifact_revision in manifest["revision_resources"]: artifact = json.loads((directory / f"schemas/sqlite-{artifact_revision}.json").read_text())["layout"] - base_revision = "v1_4_1_007" if "v1_4_1_007" in layouts else "v1_4_1_006" - previous = {(row[0], row[1]): row for row in layouts[base_revision]} + previous = {(row[0], row[1]): row for row in layouts["v1_4_1_006"]} for row in artifact: key = (row[0], row[1]) if key not in previous or row[1] == "pc_artifact_processing_binding_states": previous[key] = row layouts[artifact_revision] = list(previous.values()) + if "v1_5_0_002" in manifest["revision_resources"]: + ledger = json.loads((directory / "schemas/sqlite-v1_5_0_002.json").read_text(encoding="utf-8"))["create"] + base = layouts.get("v1_5_0_001", layouts["v1_4_1_006"]) + layouts["v1_5_0_002"] = base + [registry_row(statement) for statement in ledger] for entries in layouts.values(): entries.sort(key=lambda row: (row[0], row[1])) return layouts diff --git a/tests/builtin/persistence/test_artifact_storage_lifecycle.py b/tests/builtin/persistence/test_artifact_storage_lifecycle.py index 133afa3075..e272191b28 100644 --- a/tests/builtin/persistence/test_artifact_storage_lifecycle.py +++ b/tests/builtin/persistence/test_artifact_storage_lifecycle.py @@ -78,7 +78,7 @@ def test_fixed_artifact_storage_resumes_after_registration_columns(backend): # def _next_fixed_revision(bundle, directory): """A subsequent fixed-table addition leaves the space format untouched.""" shutil.copytree(bundle.directory, directory) - revision = "v1_5_0_002" + revision = "v1_5_0_003" sql = "CREATE TABLE pc_lifecycle_probe (id INTEGER NOT NULL, PRIMARY KEY (id))" name, layout = _parse_table(sql) resource = "schemas/mysql-lifecycle-probe.json" @@ -88,10 +88,10 @@ def _next_fixed_revision(bundle, directory): "revisions": {revision: {"statements": [sql], "changes": [{name: layout}]}}, }) ) - (directory / "versions/v1_5_0_002_probe.py").write_text( + (directory / "versions/v1_5_0_003_probe.py").write_text( f"revision = '{revision}'\ndown_revision = '{bundle.head}'\ndef upgrade():\n pass\n" ) - (directory / "mysql/versions/v1_5_0_002.py").write_text( + (directory / "mysql/versions/v1_5_0_003.py").write_text( "from alembic import context\n" f"revision = '{revision}'\ndown_revision = '{bundle.head}'\n" "def upgrade():\n context.config.attributes['execute_revision'](revision)\n" diff --git a/tests/builtin/persistence/test_atomic_description_vector_layout.py b/tests/builtin/persistence/test_atomic_description_vector_layout.py index 013b6c0ed5..a442d68a5a 100644 --- a/tests/builtin/persistence/test_atomic_description_vector_layout.py +++ b/tests/builtin/persistence/test_atomic_description_vector_layout.py @@ -42,7 +42,7 @@ def test_managed_sqlite_vector_schema_includes_registered_nullable_description(t database = tmp_path / "vector.db" runner = SQLiteMigrationRunner(database, bundle, configuration=configuration) result = runner.apply(plan_id=runner.plan().plan_id, accepted=True, maintenance_confirmed=True) - assert result.revision == "v1_5_0_001" + assert result.revision == "v1_5_0_002" with closing(sqlite3.connect(database)) as connection: columns = {row[1]: row for row in connection.execute("PRAGMA table_info(pc_atomic_memory_current)")} assert columns["description"][2:4] == ("VARCHAR(200)", 0) @@ -58,7 +58,7 @@ def test_mysql_vector_admission_retains_description_and_exact_previous_revision( for resource in json.loads((plain.directory / "manifest.json").read_text())["mysql_state_layouts"]: # Inspect the description revision: later Source-plugin and Work Ledger # registrations only add their own tables and are skipped outright. - if resource.endswith("mysql-v1_5_0_001.json") or resource.endswith("mysql-v1_4_1_007.json"): + if resource.endswith("mysql-v1_5_0_001.json") or resource.endswith("mysql-v1_5_0_002.json"): continue for entry in json.loads((plain.directory / resource).read_text())["revisions"].values(): for change in entry["changes"]: @@ -105,7 +105,7 @@ def test_description_upgrade_preserves_registered_owner_recovery_and_evidence(tm shutil.copytree(current.snapshot_directory, directory, ignore=shutil.ignore_patterns("__pycache__")) manifest_path = directory / "manifest.json" manifest = json.loads(manifest_path.read_text(encoding="utf-8")) - removed_revisions = ("v1_4_1_005", "v1_4_1_006", "v1_4_1_007", "v1_5_0_001") + removed_revisions = ("v1_4_1_005", "v1_4_1_006", "v1_5_0_001", "v1_5_0_002") for revision in removed_revisions: for resource in manifest["revision_resources"].pop(revision): (directory / resource).unlink() @@ -147,7 +147,7 @@ def test_description_upgrade_preserves_registered_owner_recovery_and_evidence(tm upgraded = SQLiteMigrationRunner(database, current, configuration=configuration) plan = upgraded.plan() assert plan.source_revision == "v1_4_1_004" and plan.revisions == removed_revisions - assert upgraded.apply(plan_id=plan.plan_id, accepted=True, maintenance_confirmed=True).revision == "v1_5_0_001" + assert upgraded.apply(plan_id=plan.plan_id, accepted=True, maintenance_confirmed=True).revision == "v1_5_0_002" assert upgraded.verify().state == "ready" evidence = json.loads(upgraded.evidence_path.read_text(encoding="utf-8")) for revision, checksum in original_evidence["release_checksums"].items(): diff --git a/tests/builtin/persistence/test_full_server_migrations.py b/tests/builtin/persistence/test_full_server_migrations.py index a3767d8dd6..c16fd4cccd 100644 --- a/tests/builtin/persistence/test_full_server_migrations.py +++ b/tests/builtin/persistence/test_full_server_migrations.py @@ -73,7 +73,7 @@ def dump(path): def test_complete_upgrade_preserves_history_backup_and_repeated_apply(source): before = dump(source.database) plan = source.plan() - assert plan.source_revision == "s1300" and plan.target_revision == "v1_5_0_001" + assert plan.source_revision == "s1300" and plan.target_revision == "v1_5_0_002" assert plan.revisions == ( "s1400", "s1401", @@ -81,8 +81,8 @@ def test_complete_upgrade_preserves_history_backup_and_repeated_apply(source): "v1_4_1_004", "v1_4_1_005", "v1_4_1_006", - "v1_4_1_007", "v1_5_0_001", + "v1_5_0_002", ) assert dump(source.database) == before result = apply(source) diff --git a/tests/builtin/persistence/test_owner_recovery_migration.py b/tests/builtin/persistence/test_owner_recovery_migration.py index 7fcc944310..9693ac84f4 100644 --- a/tests/builtin/persistence/test_owner_recovery_migration.py +++ b/tests/builtin/persistence/test_owner_recovery_migration.py @@ -48,11 +48,11 @@ def test_registered_owner_recovery_upgrade_preserves_history_and_revision_checks manifest_path = directory / "manifest.json" manifest = json.loads(manifest_path.read_text(encoding="utf-8")) removed_revisions = ( - ("v1_4_1_007", "v1_5_0_001") + ("v1_5_0_001", "v1_5_0_002") if previous_revision == "v1_4_1_006" - else ("v1_4_1_005", "v1_4_1_006", "v1_4_1_007", "v1_5_0_001") + else ("v1_4_1_005", "v1_4_1_006", "v1_5_0_001", "v1_5_0_002") if previous_revision == "v1_4_1_004" - else ("v1_4_1_004", "v1_4_1_005", "v1_4_1_006", "v1_4_1_007", "v1_5_0_001") + else ("v1_4_1_004", "v1_4_1_005", "v1_4_1_006", "v1_5_0_001", "v1_5_0_002") ) for revision in removed_revisions: for resource in manifest["revision_resources"].pop(revision): @@ -92,7 +92,7 @@ def test_registered_owner_recovery_upgrade_preserves_history_and_revision_checks upgraded = SQLiteMigrationRunner(database, current, configuration=configuration(embedding)) plan = upgraded.plan() assert plan.source_revision == previous_revision and plan.revisions == removed_revisions - assert apply(upgraded).revision == "v1_5_0_001" + assert apply(upgraded).revision == "v1_5_0_002" assert upgraded.verify().state == "ready" assert not apply(upgraded).changed evidence = json.loads(upgraded.evidence_path.read_text(encoding="utf-8")) diff --git a/tests/builtin/persistence/test_portable_storage_migration.py b/tests/builtin/persistence/test_portable_storage_migration.py index 3153310851..ebb813a684 100644 --- a/tests/builtin/persistence/test_portable_storage_migration.py +++ b/tests/builtin/persistence/test_portable_storage_migration.py @@ -63,8 +63,8 @@ def previous_bundle(directory, embedding, *, include_portable=False, include_own manifest_path = directory / "manifest.json" manifest = json.loads(manifest_path.read_text()) later_revisions = [ + ("v1_5_0_002", LEDGER_TABLES), ("v1_5_0_001", ()), - ("v1_4_1_007", LEDGER_TABLES), ("v1_4_1_006", ("pc_source_write_plugins",)), ("v1_4_1_005", ()), ] @@ -139,8 +139,8 @@ def test_registered_s1401_upgrade_preserves_completed_evidence_and_matches_fresh upgraded = SQLiteMigrationRunner(database, bundle, configuration=config) plan = upgraded.plan() assert plan.source_revision == "s1401" - assert plan.revisions == ("s1402", "v1_4_1_004", "v1_4_1_005", "v1_4_1_006", "v1_4_1_007", "v1_5_0_001") - assert apply(upgraded).revision == "v1_5_0_001" + assert plan.revisions == ("s1402", "v1_4_1_004", "v1_4_1_005", "v1_4_1_006", "v1_5_0_001", "v1_5_0_002") + assert apply(upgraded).revision == "v1_5_0_002" assert upgraded.verify().state == "ready" assert not apply(upgraded).changed fresh = SQLiteMigrationRunner(tmp_path / "fresh.db", bundle, configuration=config) @@ -199,11 +199,11 @@ def test_source_write_plugin_upgrade_preserves_data_and_registration(tmp_path, v bundle = server_bundle(embedding_profile=embedding) upgraded = SQLiteMigrationRunner(database, bundle, configuration=config) assert upgraded.plan().revisions == ( - ("v1_4_1_005", "v1_4_1_006", "v1_4_1_007", "v1_5_0_001") + ("v1_4_1_005", "v1_4_1_006", "v1_5_0_001", "v1_5_0_002") if include_owner - else ("v1_4_1_004", "v1_4_1_005", "v1_4_1_006", "v1_4_1_007", "v1_5_0_001") + else ("v1_4_1_004", "v1_4_1_005", "v1_4_1_006", "v1_5_0_001", "v1_5_0_002") ) - assert apply(upgraded).revision == "v1_5_0_001" + assert apply(upgraded).revision == "v1_5_0_002" definition = b'{"configuration":{"id":"tickets","entry":"tickets"}}' with closing(sqlite3.connect(database)) as connection, connection: assert connection.execute("SELECT title FROM pc_scopes").fetchall() == [("preserved",)] diff --git a/tests/builtin/persistence/test_release140_server_migrations.py b/tests/builtin/persistence/test_release140_server_migrations.py index 31d6a62461..a3bcb8fbbc 100644 --- a/tests/builtin/persistence/test_release140_server_migrations.py +++ b/tests/builtin/persistence/test_release140_server_migrations.py @@ -141,8 +141,8 @@ def test_registered_sources_choose_distinct_lineages(tmp_path, release, expected "v1_4_1_004", "v1_4_1_005", "v1_4_1_006", - "v1_4_1_007", "v1_5_0_001", + "v1_5_0_002", ) diff --git a/tests/test_migration_revision_names.py b/tests/test_migration_revision_names.py index 4b5ec7348b..37b664bc50 100644 --- a/tests/test_migration_revision_names.py +++ b/tests/test_migration_revision_names.py @@ -61,11 +61,11 @@ def invoke(action, *options): "v1_4_1_004", "v1_4_1_005", "v1_4_1_006", - "v1_4_1_007", "v1_5_0_001", + "v1_5_0_002", ] assert [item["revision"] for item in plan["pending_migrations"]] == list(reviewed.revisions) - target = {"revision": "v1_5_0_001", "name": "v1_5_0_001", "software_version": "1.5.0", "sequence": 1} + target = {"revision": "v1_5_0_002", "name": "v1_5_0_002", "software_version": "1.5.0", "sequence": 2} assert plan["target_migration"] == target applied = invoke( @@ -80,7 +80,7 @@ def invoke(action, *options): ) assert applied["migration"] == target and applied["server_ready"] with closing(sqlite3.connect(runner.database)) as connection: - assert connection.execute("SELECT version_num FROM pc_schema_revision").fetchall() == [("v1_5_0_001",)] + assert connection.execute("SELECT version_num FROM pc_schema_revision").fetchall() == [("v1_5_0_002",)] evidence = runner.evidence_path.read_bytes() recorded = json.loads(evidence) assert recorded["source_revision"] == expected_source["revision"] @@ -92,8 +92,8 @@ def invoke(action, *options): "v1_4_1_004", "v1_4_1_005", "v1_4_1_006", - "v1_4_1_007", "v1_5_0_001", + "v1_5_0_002", } assert runner.verify().state == "ready" assert invoke("verify")["migration"] == target diff --git a/tests/test_registered_source_maintenance_cli.py b/tests/test_registered_source_maintenance_cli.py index c593e6c0c4..2fa5251866 100644 --- a/tests/test_registered_source_maintenance_cli.py +++ b/tests/test_registered_source_maintenance_cli.py @@ -63,7 +63,7 @@ def test_release140_compatibility_commands_preserve_source_and_share_full_upgrad planned = CliRunner().invoke(cli, [*arguments, "plan", "--backup", "skip"]) assert planned.exit_code == 0, planned.output plan = json.loads(planned.output) - assert plan["source_revision"] == "r1400" and plan["target_revision"] == "v1_5_0_001" + assert plan["source_revision"] == "r1400" and plan["target_revision"] == "v1_5_0_002" assert plan["readiness_scope"] == "server" full = CliRunner().invoke(cli, ["server", "db-migrate", "plan", "--backup", "skip"]) assert full.exit_code == 0, full.output diff --git a/tests/test_server_migration_cli.py b/tests/test_server_migration_cli.py index 4b17949ec0..05d9ee2e93 100644 --- a/tests/test_server_migration_cli.py +++ b/tests/test_server_migration_cli.py @@ -67,5 +67,5 @@ def test_server_migrate_runs_the_packaged_forward_only_chain(tmp_path) -> None: work_table = connection.execute( "SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'pc_work_items'" ).fetchone() - assert revision == [("v1_4_1_006",)] + assert revision == [("v1_5_0_002",)] assert work_table == ("pc_work_items",) From c9934b7010dd2ba85b8d2c22484670990133be6e Mon Sep 17 00:00:00 2001 From: JasonDeng <15301580353@163.com> Date: Wed, 4 Nov 2026 09:31:26 +0800 Subject: [PATCH 17/45] fix(docker): run the migrator through the supported db-migrate plan/apply workflow `compose.distributed.yaml` shipped `command: ["server", "migrate"]`, but the CLI registers migrations as `server db-migrate plan/apply`; the container exited 2 and every role stayed blocked on service_completed_successfully. The migrate service now overrides the entrypoint with a shell script that plans, extracts plan_id, and applies with --maintenance-confirmed --yes, mounting a durable evidence directory with --lock-coordination single-host (OceanBase requirements). Backup policy defaults to a declared manual backup and can be switched to an explicit no-backup run via POWERCONTEXT_MIGRATION_BACKUP_POLICY=skip. Non-interactive apply requires an explicit plan, so `plan` uses `--backup ` only and `apply` carries the matching confirmation flags. Verified end to end against the exact-head CLI: the script migrated a fresh database to v1_5_0_002 with server_ready=true. The Docker contract test now asserts the workflow, evidence dir, coordination and confirmations instead of the invalid command; both deployment guides describe the same supported workflow. --- docker/compose.distributed.yaml | 26 +++++++++++++++++++++++++- docs/en/docs/operate/deploy-server.md | 4 +++- docs/zh/docs/operate/deploy-server.md | 3 ++- tests/test_docker_contract.py | 10 +++++++++- 4 files changed, 39 insertions(+), 4 deletions(-) diff --git a/docker/compose.distributed.yaml b/docker/compose.distributed.yaml index 2ec3652b30..7fc88fb505 100644 --- a/docker/compose.distributed.yaml +++ b/docker/compose.distributed.yaml @@ -15,13 +15,37 @@ services: migrate: <<: *runtime restart: "no" - command: ["server", "migrate"] + # The supported migration entry point is the explicit db-migrate plan/apply workflow; there is no + # `server migrate` command. OceanBase migration commands require a durable evidence directory on + # one fixed maintenance host (--lock-coordination single-host binds it). The host directory must + # be writable by the container user (uid 10001). `manual` backup is a declaration only; set + # POWERCONTEXT_MIGRATION_BACKUP_POLICY=skip to explicitly accept a no-backup run. + entrypoint: ["/bin/sh", "-ec"] + command: + - | + set -eu + policy="${POWERCONTEXT_MIGRATION_BACKUP_POLICY:-manual}" + case "$policy" in + manual) apply_backup="--backup manual --backup-confirmed" ;; + skip) apply_backup="--backup skip --accept-no-backup" ;; + *) echo "POWERCONTEXT_MIGRATION_BACKUP_POLICY must be manual or skip" >&2; exit 2 ;; + esac + plan_id="$(powercontext server db-migrate plan \ + --evidence-dir /var/lib/powercontext/migration-state \ + --lock-coordination single-host --backup "$policy" \ + | python3 -c 'import json,sys; print(json.load(sys.stdin)["plan_id"])')" + exec powercontext server db-migrate apply \ + --evidence-dir /var/lib/powercontext/migration-state \ + --lock-coordination single-host $apply_backup \ + --plan-id "$plan_id" --maintenance-confirmed --yes environment: <<: *shared-environment # The migration command does not start a Runtime or register this role; # a valid distributed role is still required while parsing ServerSettings. POWERCONTEXT_SERVER_DEPLOYMENT_ROLE: api POWERCONTEXT_SERVER_DEPLOYMENT_ID: migrator + volumes: + - ${POWERCONTEXT_MIGRATION_STATE_DIR:?set a durable migration evidence directory on one fixed host}:/var/lib/powercontext/migration-state api-a: <<: *runtime diff --git a/docs/en/docs/operate/deploy-server.md b/docs/en/docs/operate/deploy-server.md index 97148e51e3..d8ea2cb615 100644 --- a/docs/en/docs/operate/deploy-server.md +++ b/docs/en/docs/operate/deploy-server.md @@ -171,7 +171,9 @@ separate least-privilege database users instead of the shared demonstration URL. Every replica in one rollout must use the same behavior revision. Upgrade in this order: -1. run `powercontext server migrate` with a dedicated DDL account; +1. run the `migrate` job (`server db-migrate plan` then `apply`, as wired in the Compose file) with a + dedicated DDL account, its durable evidence directory on one fixed maintenance host, and the + documented backup and confirmation options; 2. replace Workers and wait for readiness; 3. replace Schedulers and confirm a leader can scan; 4. replace APIs. diff --git a/docs/zh/docs/operate/deploy-server.md b/docs/zh/docs/operate/deploy-server.md index 28072ee468..ac0f2d2168 100644 --- a/docs/zh/docs/operate/deploy-server.md +++ b/docs/zh/docs/operate/deploy-server.md @@ -159,7 +159,8 @@ Scheduler 或 Worker 的端口。生产环境还应为各角色使用独立最 同一轮发布的所有副本必须使用相同 behavior revision。升级顺序如下: -1. 使用专门的 DDL 账号运行 `powercontext server migrate`; +1. 使用专门的 DDL 账号运行 `migrate` 任务(即 Compose 文件中接线的 `server db-migrate plan` → + `apply` 流程),配备固定维护主机上的持久 evidence 目录以及文档要求的备份与确认选项; 2. 替换 Worker 并等待 readiness; 3. 替换 Scheduler 并确认 leader 可以扫描; 4. 替换 API。 diff --git a/tests/test_docker_contract.py b/tests/test_docker_contract.py index 214bb8668d..c2926c6662 100644 --- a/tests/test_docker_contract.py +++ b/tests/test_docker_contract.py @@ -78,7 +78,15 @@ def test_distributed_compose_separates_roles_and_model_credentials() -> None: services = document["services"] assert set(services) == {"migrate", "api-a", "api-b", "scheduler-a", "scheduler-b", "worker-a", "worker-b"} - assert services["migrate"]["command"] == ["server", "migrate"] + # `server migrate` is not a registered command; the migrator must use the supported explicit + # db-migrate plan/apply workflow with OceanBase's evidence directory and host coordination. + assert services["migrate"]["entrypoint"] == ["/bin/sh", "-ec"] + (script,) = services["migrate"]["command"] + assert "server db-migrate plan" in script + assert "server db-migrate apply" in script and "--plan-id" in script + assert "--evidence-dir /var/lib/powercontext/migration-state" in script + assert "--lock-coordination single-host" in script + assert "--maintenance-confirmed" in script and "--yes" in script assert services["migrate"]["environment"]["POWERCONTEXT_SERVER_DEPLOYMENT_ROLE"] == "api" assert services["migrate"]["environment"]["POWERCONTEXT_SERVER_DEPLOYMENT_ID"] == "migrator" for name, service in services.items(): From 719c82651b55cdc8c391e178cb95b113825c8142 Mon Sep 17 00:00:00 2001 From: JasonDeng <15301580353@163.com> Date: Wed, 4 Nov 2026 09:48:55 +0800 Subject: [PATCH 18/45] fix(runtime): fence the Memory publication with the Work completion MemoryWorkHandler.prepare() published Memory artifacts and advanced the Source cursor through the independently committing process_memory() flush before DurableWorker checked the claim/cancellation in WorkRepository.complete(). A cancellation accepted while extraction was paused still ended with a cancelled operation, one persisted Memory artifact and an advanced cursor; the later ledger fence could not roll the domain write back. Lease takeover had the same gap. Split the Atomic Memory flush into a prepare phase and a publication phase. AtomicMemorySourceWindowProcessor.prepare_window() selects the window and runs the model calls without a domain write and returns a PreparedMemoryWindow whose publish(connection, processing) runs the guarded publication on the caller's open transaction; flush() keeps its existing prepare-and-publish-in-one-transaction behavior for the API/Scheduler path (ScopeInvocation guards unchanged). RelationalContexts gains prepare_memory() holding the same per-scope activation lock during preparation. MemoryWorkHandler.prepare() now returns the publication as a PreparedWork.commit closure. WorkRepository.complete() verifies the live claim, lease and cancellation in the completion transaction before invoking the closure, so a cancel accepted before publication rolls the Memory artifacts and cursor advance back together with the fenced Work transition; publication conflicts surface as retryable memory_conflict failures as before. Measured: tests/builtin/runtime/test_memory_work.py gains the cancellation-fencing regression (cancel before commit -> no publish, no cursor advance, StaleWorkClaimError) and the deferred-publication happy path; tests/builtin/runtime 993 passed, process_memory e2e set 64 passed. --- .../runtime/atomic_memory_processing.py | 400 +++++++++++------- .../builtin/runtime/relational.py | 42 ++ .../builtin/runtime/work_handlers.py | 44 +- tests/builtin/runtime/test_memory_work.py | 141 ++++-- 4 files changed, 436 insertions(+), 191 deletions(-) diff --git a/src/powercontext/builtin/runtime/atomic_memory_processing.py b/src/powercontext/builtin/runtime/atomic_memory_processing.py index 31e97973b7..6002eadb5d 100644 --- a/src/powercontext/builtin/runtime/atomic_memory_processing.py +++ b/src/powercontext/builtin/runtime/atomic_memory_processing.py @@ -129,6 +129,174 @@ def from_runtime(cls, runtime: RuntimeConfig) -> AtomicMemoryProcessingConfig: ) +class PreparedMemoryWindow: + """A prepared window whose publication can join the caller's transaction. + + ``publish`` runs the guarded cursor/artifact publication on the caller's + open transaction and is the only place that advances the Source cursor. + The Work Ledger handler runs it inside the Work completion transaction, + so a cancellation or lease takeover accepted before publication discards + the prepared result together with the rolled-back transaction. + """ + + def __init__( + self, + processor: "AtomicMemorySourceWindowProcessor", + *, + scope_id: str, + context: ArtifactSearchExecutionContext | None, + cursor, + transition, + action, + window: tuple, + eligible: tuple, + consulted_artifacts: tuple, + workset: AtomicMemoryWindowWorkset, + prepared: tuple, + decision, + observation, + high_watermark: int, + ) -> None: + self.processor = processor + self.scope_id = scope_id + self.context = context + self.cursor = cursor + self.transition = transition + self.action = action + self.window = window + self.eligible = eligible + self.consulted_artifacts = consulted_artifacts + self.workset = workset + self.prepared = prepared + self.decision = decision + self.observation = observation + self.high_watermark = high_watermark + self.finalized = None + + def result(self) -> MemoryFlushResult: + decision = self.decision + return _flush_result( + self.action.after, + self.action.through, + self.high_watermark, + len(self.eligible), + held=decision is not None and decision.verdict is MemoryWriteVerdict.HOLD, + hold_codes=() if decision is None or decision.code is None else (decision.code.value,), + ) + + def emit_finalized(self) -> None: + if self.finalized is not None: + emit_decision_observation(self.finalized) + + def emit_failed(self) -> None: + self.processor._emit_failed_observation(self.observation) + + async def publish(self, connection, processing) -> MemoryFlushResult: + processor = self.processor + application = processor.application + scope_id = self.scope_id + context = self.context + action = self.action + cursor = self.cursor + writes = tuple(write for item in self.prepared for write in item.plan.writes) + with processor._stage( + "memory.commit", + { + "powercontext.memory.commit.memory_changed": bool(writes), + "powercontext.memory.commit.artifact_revision_count": sum( + write.draft is not None for write in writes + ), + }, + ): + # Every Atomic write uses journal -> heads. Source capture's journal + # reservation prevents the exact window changing under validation. + await connection.execute( + update(SOURCE_JOURNAL_HEADS_TABLE) + .where(SOURCE_JOURNAL_HEADS_TABLE.c.scope_id == scope_id) + .values(position=SOURCE_JOURNAL_HEADS_TABLE.c.position) + ) + if processing is not None: + await processing.guard(connection) + actual = await processor.cursors.load(connection, scope_id, SOURCE_WINDOW_TRIGGER_NAME, for_update=True) + actual_generation = None if actual is None else actual.generation + expected_generation = None if cursor is None else cursor.generation + if ( + 0 if actual is None else actual.cursor.sequence + ) != action.after or actual_generation != expected_generation: + raise GenerationConflictError(SOURCE_WINDOW_TRIGGER_NAME, expected_generation, actual_generation) + current_window = await processor.sources.list_window( + connection, scope_id, after=action.after, through=action.through + ) + processor._require_window(current_window, action.after, action.through) + if current_window != self.window: + _conflict("Source window changed during preparation") + if tuple(item for item in current_window if is_generation_eligible(item.value)) != self.eligible: + _conflict("Source generation eligibility changed during preparation") + local_policy = False + if context is not None and context.access is not None: + from powercontext.server.authz import BuiltinAuthorizationProvider + from powercontext.server.authz.casbin import CasbinAuthorizationProvider + + local_policy = ( + type(context.access.provider) in {BuiltinAuthorizationProvider, CasbinAuthorizationProvider} + and context.access.supports_read_snapshot(connection.engine) + and context.access.supports_write_transaction(connection.engine) + ) + async with processor._prefetch_artifact_policy( + connection, + scope_id, + context, + self.consulted_artifacts if local_policy else (), + include_scope=True, + ): + await application.security.authorize(connection, scope_id, context, "read") + await application.security.authorize(connection, scope_id, context, "create") + await application.security.authorize_sources( + connection, scope_id, context, tuple(item.ref for item in self.eligible) + ) + for ref in self.consulted_artifacts: + await processor._authorize_artifact(connection, scope_id, context, ref) + results = () + if self.decision is None or self.decision.verdict is not MemoryWriteVerdict.HOLD: + source_read_set = await processor.sources.preload( + connection, + scope_id, + tuple( + source for write in writes if write.draft is not None for source in write.draft.sources + ), + known=current_window, + ) + results = await application.service.commit_window( + connection, + scope_id, + self.prepared, + context, + read_set=tuple(self.workset.reads.values()), + source_read_set=source_read_set, + ) + self.finalized = await processor._finalize_observation( + connection, + self.observation, + final_action=( + "memory_write_hold" + if self.decision is not None and self.decision.verdict is MemoryWriteVerdict.HOLD + else "memory_write_applied" + ), + subject_refs=tuple(_artifact_reference(result.primary.ref) for result in results), + ) + await processor.cursors.save( + connection, + scope_id, + SOURCE_WINDOW_TRIGGER_NAME, + self.transition.state, + expected_generation=expected_generation, + ) + await processor.windows.clear_consumed(connection, scope_id, action.through) + if processing is not None: + await processing.complete(connection, remaining_work=action.through < self.high_watermark) + return self.result() + + class AtomicMemorySourceWindowProcessor: """Keep the existing cursor/binding while replacing collection Memory extraction.""" @@ -182,6 +350,35 @@ async def flush( return await self._retry_window(scope_id, limit, processing, selected_context, span) return await self._retry_window(scope_id, limit, processing, selected_context, span) + async def prepare_window( + self, + scope_id: str, + limit: int, + *, + context: ArtifactSearchExecutionContext | None = None, + ) -> PreparedMemoryWindow | MemoryFlushResult: + """Prepare one Source window's decisions without publishing. + + Returns a ``PreparedMemoryWindow`` whose ``publish`` commits the Memory + artifacts and advances the cursor on the caller's open transaction, or a + ``MemoryFlushResult`` when the trigger has no window to consume. The + caller owns conflict recovery: unlike ``flush``, a conflicting + publication raises instead of retrying internally. + """ + selected_context = context + async with deferred_decision_audit(selected_context): + with self._stage("memory.flush", {"powercontext.memory.flush.source_count": 0}) as span: + if self.prompts is not None: + legacy = await self.prompts.read_configuration(scope_id, "memory.extract") + if legacy.mode == "custom": + raise PromptError("legacy_memory_prompt_unsupported", during_inference=True) + async with ( + self.prompts.bind(scope_id, "atomic_memory.extract"), + self.prompts.bind(scope_id, "atomic_memory.reconcile"), + ): + return await self._prepare_window(scope_id, limit, None, selected_context, span) + return await self._prepare_window(scope_id, limit, None, selected_context, span) + async def _retry_window(self, scope_id, limit, processing, context, span) -> MemoryFlushResult: for attempt in range(3): try: @@ -197,6 +394,25 @@ async def _retry_window(self, scope_id, limit, processing, context, span) -> Mem raise AssertionError("unreachable") async def _flush_once(self, scope_id, limit, processing, context, span) -> MemoryFlushResult: + prepared = await self._prepare_window(scope_id, limit, processing, context, span) + if isinstance(prepared, MemoryFlushResult): + return prepared + try: + async with self.application.database.transaction() as connection: + result = await prepared.publish(connection, processing) + except Exception: + prepared.emit_failed() + raise + prepared.emit_finalized() + return result + + async def _prepare_window(self, scope_id, limit, processing, context, span): + """Select one Source window and prepare its decisions without publishing. + + Returns a ``PreparedMemoryWindow`` whose ``publish`` commits the Memory + artifacts and advances the cursor on the caller's transaction, or a + ``MemoryFlushResult`` when the trigger has no window to consume. + """ application = self.application trigger = SourceWindowTrigger() async with application.database.transaction() as connection: @@ -243,35 +459,21 @@ async def _flush_once(self, scope_id, limit, processing, context, span) -> Memor if reducible and action.through - action.after > 1: await self._reduce_window(scope_id, context, processing, cursor, state, action, high_watermark) raise - try: - finalized = await self._publish( - scope_id, - context, - processing, - cursor, - transition, - action, - window, - eligible, - consulted_artifacts, - workset, - prepared, - decision, - observation, - high_watermark, - ) - except Exception: - self._emit_failed_observation(observation) - raise - if finalized is not None: - emit_decision_observation(finalized) - return self._result( - action.after, - action.through, - high_watermark, - len(eligible), - held=decision is not None and decision.verdict is MemoryWriteVerdict.HOLD, - hold_codes=() if decision is None or decision.code is None else (decision.code.value,), + return PreparedMemoryWindow( + self, + scope_id=scope_id, + context=context, + cursor=cursor, + transition=transition, + action=action, + window=window, + eligible=eligible, + consulted_artifacts=consulted_artifacts, + workset=workset, + prepared=prepared, + decision=decision, + observation=observation, + high_watermark=high_watermark, ) async def _reduce_window(self, scope_id, context, processing, cursor, state, action, high_watermark): @@ -338,122 +540,6 @@ async def _inspect_decisions(self, scope_id, workset, context): decision, observation = await self._assess_write_gate(scope_id, workset, prepared) return prepared, decision, observation - async def _publish( - self, - scope_id, - context, - processing, - cursor, - transition, - action, - window, - eligible, - consulted_artifacts, - workset, - prepared, - decision, - observation, - high_watermark, - ): - application = self.application - writes = tuple(write for item in prepared for write in item.plan.writes) - finalized = None - with self._stage( - "memory.commit", - { - "powercontext.memory.commit.memory_changed": bool(writes), - "powercontext.memory.commit.artifact_revision_count": sum(write.draft is not None for write in writes), - }, - ): - async with application.database.transaction() as connection: - # Every Atomic write uses journal -> heads. Source capture's journal - # reservation prevents the exact window changing under validation. - await connection.execute( - update(SOURCE_JOURNAL_HEADS_TABLE) - .where(SOURCE_JOURNAL_HEADS_TABLE.c.scope_id == scope_id) - .values(position=SOURCE_JOURNAL_HEADS_TABLE.c.position) - ) - if processing is not None: - await processing.guard(connection) - actual = await self.cursors.load(connection, scope_id, SOURCE_WINDOW_TRIGGER_NAME, for_update=True) - actual_generation = None if actual is None else actual.generation - expected_generation = None if cursor is None else cursor.generation - if ( - 0 if actual is None else actual.cursor.sequence - ) != action.after or actual_generation != expected_generation: - raise GenerationConflictError(SOURCE_WINDOW_TRIGGER_NAME, expected_generation, actual_generation) - current_window = await self.sources.list_window( - connection, scope_id, after=action.after, through=action.through - ) - self._require_window(current_window, action.after, action.through) - if current_window != window: - _conflict("Source window changed during preparation") - if tuple(item for item in current_window if is_generation_eligible(item.value)) != eligible: - _conflict("Source generation eligibility changed during preparation") - local_policy = False - if context is not None and context.access is not None: - from powercontext.server.authz import BuiltinAuthorizationProvider - from powercontext.server.authz.casbin import CasbinAuthorizationProvider - - local_policy = ( - type(context.access.provider) in {BuiltinAuthorizationProvider, CasbinAuthorizationProvider} - and context.access.supports_read_snapshot(connection.engine) - and context.access.supports_write_transaction(connection.engine) - ) - async with self._prefetch_artifact_policy( - connection, - scope_id, - context, - consulted_artifacts if local_policy else (), - include_scope=True, - ): - await application.security.authorize(connection, scope_id, context, "read") - await application.security.authorize(connection, scope_id, context, "create") - await application.security.authorize_sources( - connection, scope_id, context, tuple(item.ref for item in eligible) - ) - for ref in consulted_artifacts: - await self._authorize_artifact(connection, scope_id, context, ref) - results = () - if decision is None or decision.verdict is not MemoryWriteVerdict.HOLD: - source_read_set = await self.sources.preload( - connection, - scope_id, - tuple( - source for write in writes if write.draft is not None for source in write.draft.sources - ), - known=current_window, - ) - results = await application.service.commit_window( - connection, - scope_id, - prepared, - context, - read_set=tuple(workset.reads.values()), - source_read_set=source_read_set, - ) - finalized = await self._finalize_observation( - connection, - observation, - final_action=( - "memory_write_hold" - if decision is not None and decision.verdict is MemoryWriteVerdict.HOLD - else "memory_write_applied" - ), - subject_refs=tuple(_artifact_reference(result.primary.ref) for result in results), - ) - await self.cursors.save( - connection, - scope_id, - SOURCE_WINDOW_TRIGGER_NAME, - transition.state, - expected_generation=expected_generation, - ) - await self.windows.clear_consumed(connection, scope_id, action.through) - if processing is not None: - await processing.complete(connection, remaining_work=action.through < high_watermark) - return finalized - async def _prepare(self, scope_id, eligible, pipeline, context): workset = AtomicMemoryWindowWorkset() artifacts: dict[tuple[str, str, int], ArtifactRef] = {} @@ -942,16 +1028,7 @@ def _emit_failed_observation(observation): @staticmethod def _result(after, through, high_watermark, count, *, held=False, hold_codes=()): - return MemoryFlushResult( - previous_cursor=after, - current_cursor=through, - high_watermark=high_watermark, - source_count=count, - memory_ref=None, - remaining_work=through < high_watermark, - held_count=1 if held else 0, - hold_codes=hold_codes, - ) + return _flush_result(after, through, high_watermark, count, held=held, hold_codes=hold_codes) @dataclass(slots=True) @@ -990,6 +1067,19 @@ def _window_operation_id(prepared) -> str: return f"atomic-memory-window:{sha256(identities.encode('utf-8')).hexdigest()}" +def _flush_result(after, through, high_watermark, count, *, held=False, hold_codes=()): + return MemoryFlushResult( + previous_cursor=after, + current_cursor=through, + high_watermark=high_watermark, + source_count=count, + memory_ref=None, + remaining_work=through < high_watermark, + held_count=1 if held else 0, + hold_codes=hold_codes, + ) + + def _conflict(detail: str) -> NoReturn: raise AtomicMemoryConflictError(detail) diff --git a/src/powercontext/builtin/runtime/relational.py b/src/powercontext/builtin/runtime/relational.py index 0e7edef63c..5d190bff21 100644 --- a/src/powercontext/builtin/runtime/relational.py +++ b/src/powercontext/builtin/runtime/relational.py @@ -188,6 +188,7 @@ from powercontext.builtin.runtime.atomic_memory_processing import ( AtomicMemoryProcessingConfig, AtomicMemorySourceWindowProcessor, + PreparedMemoryWindow, ) from powercontext.builtin.runtime.decision_model import DecisionModel from powercontext.builtin.runtime.models import ( @@ -1561,6 +1562,28 @@ async def process_memory( atomic_context=atomic_context, ) + async def prepare_memory( + self, + scope_id: str, + limit: int, + /, + *, + atomic_context: ArtifactSearchExecutionContext | None = None, + ) -> "PreparedMemoryWindow | MemoryFlushResult": + """Prepare one Memory window's decisions without publishing it. + + The returned window's ``publish`` commits the Memory artifacts and + advances the Source cursor on the caller's open transaction, so the + Work Ledger handler can run it inside the fenced Work completion + transaction. A ``MemoryFlushResult`` return means nothing to publish. + """ + services = self._services_for(scope_id) + return await _RelationalTriggers( + services=services, + lock=self._state_for(services.scope_id).scope_locks.setdefault("activation", asyncio.Lock()), + tracing=self._tracing, + ).prepare(limit=limit, atomic_context=atomic_context) + async def incubate_experience( self, scope_id: str, @@ -1824,6 +1847,25 @@ async def flush( context=atomic_context, ) + async def prepare( + self, + *, + limit: int, + atomic_context: ArtifactSearchExecutionContext | None = None, + ) -> "PreparedMemoryWindow | MemoryFlushResult": + """Prepare one window's decisions while holding the activation lock. + + The lock is released before the caller publishes, so the fenced Work + completion transaction never runs model calls under it; publication + relies on the cursor row lock and generation check instead. + """ + async with self._lock: + return await self._services.atomic_memory_processor.prepare_window( + self._services.scope_id, + limit, + context=atomic_context, + ) + def _stage( self, name: str, diff --git a/src/powercontext/builtin/runtime/work_handlers.py b/src/powercontext/builtin/runtime/work_handlers.py index 85faa27833..b9910fde56 100644 --- a/src/powercontext/builtin/runtime/work_handlers.py +++ b/src/powercontext/builtin/runtime/work_handlers.py @@ -267,11 +267,14 @@ async def page(self, continuation: str | None, _limit: int, /) -> DiscoveryPage: class MemoryWorkHandler: """Run one claimed Memory window through the Atomic Memory processor. - The window, its model calls and its commit stay one Atomic Memory flush: this - handler owns the retry and fencing story, while the processor keeps the - single-transaction cursor/artifact commit. A retried attempt re-reads the - cursor, so a window that already committed retires instead of repeating the - domain write. + Preparation selects the window and runs the model calls without any + domain write. The publication (Memory artifacts plus cursor advance) is + returned as a ``PreparedWork.commit`` closure and runs inside the fenced + Work completion transaction, after ``WorkRepository.complete`` verified + the live claim and cancellation: a cancel accepted during preparation + discards the prepared result with the rolled-back transaction. A retried + attempt re-reads the cursor, so a window that already committed retires + instead of repeating the domain write. """ kind = MEMORY_WORK_KIND @@ -312,16 +315,33 @@ async def prepare(self, claim: WorkClaim, /) -> PreparedWork: ) ) _require_exact_cursor(payload, current_sequence, current_generation) - try: - flushed = await self._contexts.process_memory(claim.scope_id, self._window_limit) - except (AtomicMemoryConflictError, GenerationConflictError, RevisionConflictError) as error: - raise WorkExecutionError(category="conflict", code="memory_conflict", retryable=True) from error - return PreparedWork( - result=WorkResult( + prepared = await self._contexts.prepare_memory(claim.scope_id, self._window_limit) + if isinstance(prepared, MemoryFlushResult): + # The trigger selected no window; there is nothing to publish and + # nothing the ledger fence could discard. + return PreparedWork( + result=WorkResult( + code="processed" if prepared.processed else "idle", + payload=prepared.model_dump(mode="json", exclude_none=True), + ) + ) + + async def commit(connection: AsyncConnection) -> WorkResult: + try: + # Runs inside the Work completion transaction after + # WorkRepository.complete has verified the live claim and + # cancellation: a cancel accepted before publication rolls this + # domain write back with the fenced completion. + flushed = await prepared.publish(connection, processing=None) + except (AtomicMemoryConflictError, GenerationConflictError, RevisionConflictError) as error: + raise WorkExecutionError(category="conflict", code="memory_conflict", retryable=True) from error + prepared.emit_finalized() + return WorkResult( code="processed" if flushed.processed else "idle", payload=flushed.model_dump(mode="json", exclude_none=True), ) - ) + + return PreparedWork(result=None, commit=commit) class ExperienceWorkHandler: diff --git a/tests/builtin/runtime/test_memory_work.py b/tests/builtin/runtime/test_memory_work.py index 89f808d84d..ebb7fd7af0 100644 --- a/tests/builtin/runtime/test_memory_work.py +++ b/tests/builtin/runtime/test_memory_work.py @@ -16,8 +16,10 @@ import asyncio +import pytest + from powercontext.builtin.persistence.sqlite import SQLiteConfig -from powercontext.builtin.persistence.work import WorkRepository +from powercontext.builtin.persistence.work import StaleWorkClaimError, WorkRepository from powercontext.builtin.runtime import BuiltinConfig, open_builtin_contexts from powercontext.builtin.runtime.models import MemoryFlushResult from powercontext.builtin.runtime.work_handlers import ( @@ -47,6 +49,44 @@ async def _claim(contexts, spec): return claims[0] +class _StubPreparedWindow: + """A prepared window whose publication records its connection.""" + + def __init__(self, contexts, published): + self._contexts = contexts + self._published = published + + async def publish(self, connection, processing): + await self._contexts.repositories.cursors.save( + connection, + "project", + SOURCE_WINDOW_TRIGGER_NAME, + SourceCursor(sequence=1), + expected_generation=None, + ) + self._published.append(connection) + return MemoryFlushResult( + previous_cursor=0, + current_cursor=1, + high_watermark=1, + source_count=1, + memory_ref=None, + ) + + def emit_finalized(self) -> None: + pass + + def emit_failed(self) -> None: + pass + + +def _stub_prepare_memory(contexts, published): + async def fake_prepare_memory(scope_id: str, limit: int, **_: object) -> _StubPreparedWindow: + return _StubPreparedWindow(contexts, published) + + return fake_prepare_memory + + def test_memory_work_spec_tracks_the_unconsumed_source_window(tmp_path) -> None: async def scenario() -> None: async with open_builtin_contexts(_config(tmp_path)) as contexts: @@ -65,7 +105,7 @@ async def scenario() -> None: asyncio.run(scenario()) -def test_memory_work_handler_runs_the_atomic_memory_flush(tmp_path, monkeypatch) -> None: +def test_memory_work_handler_defers_publication_to_the_fenced_commit(tmp_path, monkeypatch) -> None: async def scenario() -> None: async with open_builtin_contexts(_config(tmp_path)) as contexts: context = await contexts.get("project") @@ -74,38 +114,91 @@ async def scenario() -> None: assert spec is not None claim = await _claim(contexts, spec) - seen: list[tuple[str, int]] = [] + published: list[object] = [] + monkeypatch.setattr(contexts, "prepare_memory", _stub_prepare_memory(contexts, published)) + prepared = await MemoryWorkHandler(contexts, window_limit=10).prepare(claim) + # Preparation publishes nothing; the domain write joins the fenced + # Work completion transaction through the commit closure. + assert prepared.result is None + assert prepared.commit is not None + assert published == [] - async def fake_process_memory(scope_id: str, limit: int, **_: object) -> MemoryFlushResult: - seen.append((scope_id, limit)) + repository = WorkRepository() + async with contexts.database.transaction() as connection: + work = await repository.complete(connection, claim, None, commit=prepared.commit) + assert work.status.value == "succeeded" + assert len(published) == 1 + async with contexts.database.transaction() as connection: + row = await contexts.repositories.cursors.load(connection, "project", SOURCE_WINDOW_TRIGGER_NAME) + assert row is not None and row.cursor.sequence == 1 + + repeated = await MemoryWorkHandler(contexts, window_limit=10).prepare(claim) + assert repeated.result is not None + assert repeated.result.code == "already_committed" + + asyncio.run(scenario()) + + +def test_memory_publication_is_fenced_by_a_cancellation_before_commit(tmp_path, monkeypatch) -> None: + async def scenario() -> None: + async with open_builtin_contexts(_config(tmp_path)) as contexts: + context = await contexts.get("project") + await context.sources.capture(ContentCapture(source_id="one", content="remember this")) + spec = await memory_work_spec(contexts, "project", limit=10, max_attempts=3, payload_version=1) + assert spec is not None + claim = await _claim(contexts, spec) + + published: list[object] = [] + monkeypatch.setattr(contexts, "prepare_memory", _stub_prepare_memory(contexts, published)) + prepared = await MemoryWorkHandler(contexts, window_limit=10).prepare(claim) + assert prepared.commit is not None + + # A cancel accepted while preparation is paused must fence the + # later publication: the completion transaction sees cancel_requested + # and rolls the domain write back with it. + repository = WorkRepository() + async with contexts.database.transaction() as connection: + stored = await repository.get(connection, claim.work_id) + await repository.cancel( + connection, + claim.work_id, + expected_version=stored.state_version, + ) + with pytest.raises(StaleWorkClaimError): + async with contexts.database.transaction() as connection: + await repository.complete(connection, claim, None, commit=prepared.commit) + assert published == [] + async with contexts.database.transaction() as connection: + row = await contexts.repositories.cursors.load(connection, "project", SOURCE_WINDOW_TRIGGER_NAME) + assert row is None or row.cursor.sequence == 0 + + asyncio.run(scenario()) + + +def test_memory_work_handler_returns_the_trigger_result_without_a_commit(tmp_path, monkeypatch) -> None: + async def scenario() -> None: + async with open_builtin_contexts(_config(tmp_path)) as contexts: + context = await contexts.get("project") + await context.sources.capture(ContentCapture(source_id="one", content="remember this")) + spec = await memory_work_spec(contexts, "project", limit=10, max_attempts=3, payload_version=1) + assert spec is not None + claim = await _claim(contexts, spec) + + async def fake_prepare_memory(scope_id: str, limit: int, **_: object) -> MemoryFlushResult: + # The trigger selected nothing to publish for this attempt. return MemoryFlushResult( - previous_cursor=0, + previous_cursor=1, current_cursor=1, high_watermark=1, - source_count=1, + source_count=0, memory_ref=None, ) - monkeypatch.setattr(contexts, "process_memory", fake_process_memory) + monkeypatch.setattr(contexts, "prepare_memory", fake_prepare_memory) prepared = await MemoryWorkHandler(contexts, window_limit=10).prepare(claim) - # The Atomic Memory flush owns the domain commit, so the fenced Work - # commit only records the outcome. assert prepared.commit is None assert prepared.result is not None - assert prepared.result.code == "processed" + assert prepared.result.code == "idle" assert prepared.result.payload["current_cursor"] == 1 - assert seen == [("project", 10)] - - async with contexts.database.transaction() as connection: - await contexts.repositories.cursors.save( - connection, - "project", - SOURCE_WINDOW_TRIGGER_NAME, - SourceCursor(sequence=1), - expected_generation=None, - ) - repeated = await MemoryWorkHandler(contexts, window_limit=10).prepare(claim) - assert repeated.result is not None - assert repeated.result.code == "already_committed" asyncio.run(scenario()) From 33eb25e9fb2176801f3d54955615997b5daa4c96 Mon Sep 17 00:00:00 2001 From: JasonDeng <15301580353@163.com> Date: Wed, 4 Nov 2026 09:59:15 +0800 Subject: [PATCH 19/45] fix(server): gate the data plane by deployment role in the factory output create_server_app() with deployment.mode="distributed" and role="scheduler" or "worker" still built the full data-plane app: /v1/memory/flush, the scoped operations endpoints and every provider-backed business route stayed registered on the Scheduler/Worker listener even with Dashboard and MCP disabled, so an unauthenticated container on the Compose network could read or mutate the shared database through them (RFC 1430 health/metrics-only boundary). create_app() gains a route_profile parameter: "health" registers only /health/live and /health/ready and disables the OpenAPI document, leaving the metrics route to the caller. create_server_app() selects the health profile for distributed scheduler/worker roles and rejects enabling the Dashboard or MCP for them. The single-node and distributed api profiles keep the full route table unchanged. Covered by tests/test_server_role_routes.py against the actual factory output: scheduler/worker expose exactly the health/metrics boundary with no /v1/ paths, api and single_node keep /v1/memory/flush and the operations endpoints, and health-profile roles reject Dashboard/MCP configuration. 83 passed across the API-contract, health, rate-limit, metrics and desktop contract suites. --- src/powercontext/server/app.py | 19 ++++++-- src/powercontext/server/factory.py | 13 +++++ tests/test_server_role_routes.py | 77 ++++++++++++++++++++++++++++++ 3 files changed, 105 insertions(+), 4 deletions(-) create mode 100644 tests/test_server_role_routes.py diff --git a/src/powercontext/server/app.py b/src/powercontext/server/app.py index b1f8e29194..0f3b693257 100644 --- a/src/powercontext/server/app.py +++ b/src/powercontext/server/app.py @@ -1373,6 +1373,7 @@ def create_app( *, application: ServerApplication | None = None, infrastructure_only: bool = False, + route_profile: Literal["full", "health"] = "full", capability_provider: CapabilityProvider | None = None, readiness_probe: ReadinessProbe | None = None, lifespan: Lifespan[FastAPI] | None = None, @@ -1385,13 +1386,18 @@ def create_app( authentication_provider: AuthenticationProvider | None = None, allow_insecure_remote_http: bool = False, ) -> FastAPI: - """Build the HTTP adapter around an optional Runtime application binding.""" + """Build the HTTP adapter around an optional Runtime application binding. + + ``route_profile="health"`` exposes only the health and metrics boundary + (RFC 1430) for distributed Scheduler/Worker roles; the data plane is + served by the API replicas. + """ app = FastAPI( title=API_TITLE, version=API_VERSION, description=API_DESCRIPTION, - openapi_url=None if infrastructure_only else "/openapi.json", + openapi_url=None if infrastructure_only or route_profile == "health" else "/openapi.json", docs_url=None, redoc_url=None, lifespan=lifespan, @@ -1492,11 +1498,16 @@ async def unexpected_error(request: Request, error: Exception) -> JSONResponse: _add_route(app, GET_LIVENESS, get_liveness) return app + _add_route(app, GET_LIVENESS, get_liveness) + _add_route(app, GET_READINESS, get_readiness) + if route_profile == "health": + # Health/metrics boundary: metrics are mounted by the caller. Every + # business route stays on the API replicas. + return app + _add_route(app, CREATE_DREAM_RUN, create_dream_run) _add_route(app, GET_DREAM_RUN, get_dream_run) _add_route(app, LIST_DREAM_RUNS, list_dream_runs) - _add_route(app, GET_LIVENESS, get_liveness) - _add_route(app, GET_READINESS, get_readiness) _add_route(app, GET_SERVER_INFO, get_server_info) _add_route(app, GET_CAPABILITIES, get_capabilities) _add_route(app, LIST_ARTIFACT_PLUGINS, list_artifact_plugins) diff --git a/src/powercontext/server/factory.py b/src/powercontext/server/factory.py index 0759bcf280..42b043ca29 100644 --- a/src/powercontext/server/factory.py +++ b/src/powercontext/server/factory.py @@ -195,6 +195,18 @@ def create_server_app( # noqa: C901 rate_limit=resolved.rate_limit, ) metrics = ServerMetrics(role=resolved.runtime.artifact_processing_role) if resolved.metrics.enabled else None + # Distributed Scheduler/Worker roles expose only the health/metrics boundary + # (RFC 1430); the authenticated API replicas serve the data plane. + route_profile = ( + "health" + if resolved.deployment.mode == "distributed" and resolved.deployment.role in {"scheduler", "worker"} + else "full" + ) + if route_profile == "health" and (resolved.dashboard.enabled or resolved.mcp.enabled): + raise ValueError( + "Distributed Scheduler and Worker roles expose only health and metrics endpoints; " + "disable the Dashboard and MCP for these roles" # noqa: TRY003 + ) resolved_tracing = ( ServerTracing.context_only(role=resolved.runtime.artifact_processing_role) if tracing is None else tracing ) @@ -440,6 +452,7 @@ def current_capabilities() -> Capabilities: app = create_app( lifespan=lifespan, readiness_probe=readiness_probe, + route_profile=route_profile, middleware=configured_middleware, metrics=metrics, tracing=resolved_tracing, diff --git a/tests/test_server_role_routes.py b/tests/test_server_role_routes.py new file mode 100644 index 0000000000..f73f7d16f0 --- /dev/null +++ b/tests/test_server_role_routes.py @@ -0,0 +1,77 @@ +# 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. + +"""The deployment role gates the actual factory route table, not only composition.""" + +import pytest + +from powercontext.builtin.persistence.oceanbase.profile import OceanBaseConfig +from powercontext.builtin.runtime.config import DeploymentConfig +from powercontext.server.factory import create_server_app +from powercontext.server.settings import ServerSettings + + +def _settings(role: str, **overrides) -> ServerSettings: + values = { + "deployment": DeploymentConfig(mode="distributed", role=role, id=f"node-{role}"), + "database": OceanBaseConfig( + url="mysql+aoceanbase://user:pass@localhost:2881/powercontext?charset=utf8mb4" + ), + # A distributed Worker validates a generation model at composition time; + # the route table assertions below never open the runtime. + "inference": {"generation_model": "stub:model"}, + "mcp": {"enabled": False}, + } + values.update(overrides) + return ServerSettings(**values) + + +def _paths(app) -> set[str]: + return {route.path for route in app.routes} + + +@pytest.mark.parametrize("role", ["scheduler", "worker"]) +def test_distributed_roles_expose_only_the_health_metrics_boundary(role): + app = create_server_app(settings=_settings(role)) + paths = _paths(app) + assert "/health/live" in paths + assert "/health/ready" in paths + assert "/metrics" in paths + data_plane = [path for path in paths if path.startswith("/v1/")] + assert data_plane == [] + assert "/openapi.json" not in paths + assert "/docs" not in paths + + +def test_distributed_api_keeps_the_data_plane(): + app = create_server_app(settings=_settings("api")) + paths = _paths(app) + assert "/health/live" in paths + assert "/v1/memory/flush" in paths + assert "/v1/scopes/{scope_id}/operations" in paths + + +def test_single_node_keeps_the_data_plane(): + settings = ServerSettings( + database=OceanBaseConfig(url="mysql+aoceanbase://user:pass@localhost:2881/powercontext?charset=utf8mb4"), + mcp={"enabled": False}, + ) + paths = _paths(create_server_app(settings=settings)) + assert "/v1/memory/flush" in paths + assert "/v1/scopes/{scope_id}/operations" in paths + + +def test_health_boundary_roles_reject_dashboard_and_mcp(): + with pytest.raises(ValueError, match="health and metrics"): + create_server_app(settings=_settings("worker", mcp={"enabled": True})) From ccef69787d492003ec54da2bac6becf4b325a5d3 Mon Sep 17 00:00:00 2001 From: JasonDeng <15301580353@163.com> Date: Wed, 4 Nov 2026 10:26:03 +0800 Subject: [PATCH 20/45] fix(runtime): keep the cron next run in the future across DST transitions Constructing the candidate with the default fold=0 and comparing aware datetimes that share a tzinfo compares wall clocks, so during the fall-back repeated hour next_after() returned a UTC instant before the input (e.g. 05:16 for a 06:15 input in America/New_York), which then failed the scheduler's non-future next-run guard. Enumerate every real occurrence of a local wall time, earliest UTC instant first, and compare the candidate against the input instant in UTC. Ambiguous wall times now yield both occurrences, nonexistent spring-forward times yield none, and ordinary days keep the documented UTC-naive semantics. --- src/powercontext/builtin/runtime/cron.py | 26 ++++++-- tests/builtin/runtime/test_cron.py | 85 ++++++++++++++++++++++++ 2 files changed, 107 insertions(+), 4 deletions(-) create mode 100644 tests/builtin/runtime/test_cron.py diff --git a/src/powercontext/builtin/runtime/cron.py b/src/powercontext/builtin/runtime/cron.py index b18d80304d..8a53df34e6 100644 --- a/src/powercontext/builtin/runtime/cron.py +++ b/src/powercontext/builtin/runtime/cron.py @@ -16,6 +16,7 @@ from __future__ import annotations +from collections.abc import Iterator from dataclasses import dataclass from datetime import UTC, date, datetime, time, timedelta from zoneinfo import ZoneInfo @@ -65,10 +66,11 @@ def next_after(self, value: datetime, /) -> datetime: continue for hour in sorted(self.hours): for minute in sorted(self.minutes): - candidate = datetime.combine(candidate_date, time(hour, minute), self.timezone) - if candidate < start or not _is_local_time(candidate): - continue - return candidate.astimezone(UTC).replace(tzinfo=None) + naive = datetime.combine(candidate_date, time(hour, minute)) + for candidate in _local_occurrences(naive, self.timezone): + if candidate.astimezone(UTC) <= utc: + continue + return candidate.astimezone(UTC).replace(tzinfo=None) raise ValueError("cron expression has no occurrence in the next eight years") # noqa: TRY003 def _matches_date(self, value: date) -> bool: @@ -113,6 +115,22 @@ def _field_value(value: str, names: dict[str, int] | None) -> int: return int(value) +def _local_occurrences(naive: datetime, timezone: ZoneInfo) -> Iterator[datetime]: + """Yield every real instant of a local wall time, earliest UTC instant first. + + Ambiguous local times (DST fall-back repeated hour) occur twice; nonexistent + local times (spring-forward gap) never occur and yield nothing. + """ + + first = naive.replace(tzinfo=timezone) + if not _is_local_time(first): + return + yield first + second = naive.replace(tzinfo=timezone, fold=1) + if second.astimezone(UTC) != first.astimezone(UTC): + yield second + + def _is_local_time(value: datetime) -> bool: round_trip = value.astimezone(UTC).astimezone(value.tzinfo) return round_trip.replace(tzinfo=None) == value.replace(tzinfo=None) diff --git a/tests/builtin/runtime/test_cron.py b/tests/builtin/runtime/test_cron.py new file mode 100644 index 0000000000..fc5629d847 --- /dev/null +++ b/tests/builtin/runtime/test_cron.py @@ -0,0 +1,85 @@ +# 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. + +"""Regression tests for cron scheduling across DST transitions.""" + +from datetime import datetime + +from powercontext.builtin.runtime.cron import CronSchedule + + +def test_next_after_stays_in_the_future_during_the_repeated_hour() -> None: + """The fall-back repeated hour must never yield a UTC instant before the input.""" + + schedule = CronSchedule.parse("* * * * *", "America/New_York") + result = schedule.next_after(datetime(2026, 11, 1, 6, 15)) # noqa: DTZ001 - naive on purpose + assert result > datetime(2026, 11, 1, 6, 15) # noqa: DTZ001 - naive on purpose + # 06:15 UTC is 01:15 EDT->EST territory; the next minute is 01:16 EST = 06:16 UTC. + assert result == datetime(2026, 11, 1, 6, 16) # noqa: DTZ001 - naive on purpose + + +def test_next_after_walks_every_minute_through_the_repeated_hour() -> None: + """Minute-by-minute scans advance one real minute per step across the transition.""" + + schedule = CronSchedule.parse("* * * * *", "America/New_York") + # 05:59 UTC is 01:59 EDT, the last minute before the fall-back. + previous = schedule.next_after(datetime(2026, 11, 1, 5, 59)) # noqa: DTZ001 - naive on purpose + assert previous == datetime(2026, 11, 1, 6, 0) # noqa: DTZ001 - naive on purpose + for expected in (datetime(2026, 11, 1, 6, minute) for minute in range(1, 60)): # noqa: DTZ001 - naive on purpose + result = schedule.next_after(previous) + assert result == expected + previous = result + # The first minute after the repeated hour is 02:00 EST = 07:00 UTC. + assert schedule.next_after(previous) == datetime(2026, 11, 1, 7, 0) # noqa: DTZ001 - naive on purpose + + +def test_next_after_emits_both_occurrences_of_an_ambiguous_wall_time() -> None: + """01:30 happens twice on the fall-back day and each occurrence is returned once.""" + + schedule = CronSchedule.parse("30 1 * * *", "America/New_York") + first = schedule.next_after(datetime(2026, 11, 1, 5, 0)) # noqa: DTZ001 - naive on purpose + assert first == datetime(2026, 11, 1, 5, 30) # 01:30 EDT # noqa: DTZ001 - naive on purpose + second = schedule.next_after(first) + assert second == datetime(2026, 11, 1, 6, 30) # 01:30 EST, the repeated hour # noqa: DTZ001 - naive on purpose + third = schedule.next_after(second) + assert third == datetime(2026, 11, 2, 6, 30) # next day, 01:30 EST # noqa: DTZ001 - naive on purpose + + +def test_next_after_skips_nonexistent_wall_times_in_the_spring_forward_gap() -> None: + """02:30 does not exist on the spring-forward day; the next day's match is used.""" + + schedule = CronSchedule.parse("30 2 * * *", "America/New_York") + result = schedule.next_after(datetime(2026, 3, 8, 6, 0)) # noqa: DTZ001 - naive on purpose + # 02:30 EST is skipped by the gap on 2026-03-08; the next 02:30 is on 03-09 (EDT, 06:30 UTC). + assert result == datetime(2026, 3, 9, 6, 30) # noqa: DTZ001 - naive on purpose + + +def test_next_after_is_monotonic_under_repeated_feedback() -> None: + """Feeding a returned instant back must always strictly advance.""" + + schedule = CronSchedule.parse("*/15 * * * *", "America/New_York") + current = datetime(2026, 11, 1, 5, 0) # noqa: DTZ001 - naive on purpose + for _ in range(24): + following = schedule.next_after(current) + assert following > current + current = following + + +def test_next_after_ordinary_days_are_unchanged() -> None: + """Non-transition days keep the documented UTC-naive semantics.""" + + schedule = CronSchedule.parse("30 9 * * *", "America/New_York") + # 2026-06-15: 09:30 EDT = 13:30 UTC. + assert schedule.next_after(datetime(2026, 6, 15, 12, 0)) == datetime(2026, 6, 15, 13, 30) # noqa: DTZ001 - naive on purpose + assert schedule.next_after(datetime(2026, 6, 15, 13, 30)) == datetime(2026, 6, 16, 13, 30) # noqa: DTZ001 - naive on purpose From 7512efb1701edfc44e003625f08872d543e7f5cc Mon Sep 17 00:00:00 2001 From: JasonDeng <15301580353@163.com> Date: Wed, 4 Nov 2026 10:29:56 +0800 Subject: [PATCH 21/45] fix(runtime): bound the rate limit window table in single-node installs Enabling the shared rate limit in the supported single-node configuration created persistent window rows with no cleanup path: the only caller of RateLimitRepository.purge_expired() is the ledger's maintenance handler, and single_node mode starts no scheduler or Worker. With the default 60-second window one continuously active principal left 1,440 expired rows per day indefinitely. Start a bounded maintenance loop next to the OperationManager in single-node installs. It deletes one purge_expired() batch per configured cleanup interval, independently of distributed ledger execution, and shuts down with the background scope. --- .../builtin/runtime/composition.py | 84 ++++++++++++++++--- 1 file changed, 74 insertions(+), 10 deletions(-) diff --git a/src/powercontext/builtin/runtime/composition.py b/src/powercontext/builtin/runtime/composition.py index cde682433b..c57339ced3 100644 --- a/src/powercontext/builtin/runtime/composition.py +++ b/src/powercontext/builtin/runtime/composition.py @@ -1121,6 +1121,59 @@ async def _open_artifact_processing_supervisor( yield ArtifactProcessingSupervisors(tuple(supervisors)) +class _RateLimitWindowMaintenance: + """Delete bounded batches of expired fixed-window counters on an interval. + + Single-node installs start no ledger scheduler or Worker, so the + ``OperationMaintenanceHandler`` purge would never run and every active + principal would accumulate one window row per interval indefinitely. This + loop keeps the table bounded independently of distributed ledger execution. + """ + + def __init__( + self, + *, + database: Any, + interval_seconds: float, + batch_size: int, + repository: Any = None, + ) -> None: + from powercontext.builtin.persistence.rate_limit import RateLimitRepository + + self._database = database + self._interval_seconds = interval_seconds + self._batch_size = batch_size + self._repository = RateLimitRepository() if repository is None else repository + self._stop_requested = asyncio.Event() + self._task: asyncio.Task[None] | None = None + + def start(self) -> None: + self._task = asyncio.create_task(self._run(), name="powercontext-rate-limit-maintenance") + + async def stop(self) -> None: + self._stop_requested.set() + if self._task is not None: + await asyncio.gather(self._task, return_exceptions=True) + self._task = None + + async def _run(self) -> None: + while not self._stop_requested.is_set(): + try: + await self.purge_once() + except Exception as error: + log_safely(logging.WARNING, "rate limit window maintenance failed: %s", error) + try: + await asyncio.wait_for(self._stop_requested.wait(), timeout=self._interval_seconds) + except TimeoutError: + continue + + async def purge_once(self) -> int: + """Delete one bounded batch of expired counters and return the row count.""" + + async with self._database.transaction() as connection: + return await self._repository.purge_expired(connection, limit=self._batch_size) + + class WorkBackground: """Role-specific durable Work Ledger services attached to one runtime. @@ -1129,7 +1182,7 @@ class WorkBackground: the role does not run them or no background schedule is configured. """ - __slots__ = ("membership", "operations", "scheduler", "worker") + __slots__ = ("membership", "operations", "rate_limit_maintenance", "scheduler", "worker") def __init__( self, @@ -1138,11 +1191,13 @@ def __init__( membership: Any = None, scheduler: Any = None, worker: Any = None, + rate_limit_maintenance: Any = None, ) -> None: self.operations = operations self.membership = membership self.scheduler = scheduler self.worker = worker + self.rate_limit_maintenance = rate_limit_maintenance def _work_background_requested(config: BuiltinConfig) -> bool: @@ -1310,15 +1365,25 @@ async def _open_work_background( from powercontext.builtin.runtime.worker import DurableWorker if not _work_background_requested(config): - yield WorkBackground( - operations=OperationManager( - contexts=contexts, - operations=config.operations, - worker=config.worker, - local_worker=None, - payload_version=CURRENT_WORK_PAYLOAD_VERSION, - ) + maintenance = _RateLimitWindowMaintenance( + database=contexts.database, + interval_seconds=config.operations.cleanup_interval_seconds, + batch_size=config.operations.cleanup_batch_size, ) + maintenance.start() + try: + yield WorkBackground( + operations=OperationManager( + contexts=contexts, + operations=config.operations, + worker=config.worker, + local_worker=None, + payload_version=CURRENT_WORK_PAYLOAD_VERSION, + ), + rate_limit_maintenance=maintenance, + ) + finally: + await maintenance.stop() return role = config.deployment.role @@ -1396,7 +1461,6 @@ def compose_artifact_search(contexts, embedding_model=None, *, observer=None): topic_memory_searcher = TopicMemorySearcher( search=contexts.search_topic_memories, embedding_model=embedding_model, - observer=observer, capabilities=contexts.topic_memory_index.capabilities, ) artifact_search.register(topic_memory_searcher, embedding_purpose=ModelUsagePurpose.TOPIC_MEMORY_RECALL) From 448e1529f516016dca5bdbecc3666daff9503fef Mon Sep 17 00:00:00 2001 From: JasonDeng <15301580353@163.com> Date: Wed, 4 Nov 2026 10:30:07 +0800 Subject: [PATCH 22/45] fix(runtime): wire the WorkObserver into the composed ledger _open_work_background() constructed RuntimeMembership, DurableScheduler, DurableWorker and OperationManager without an observer and open_builtin_runtime() had no WorkObserver input, so the durable work counters and the queue/member gauges never saw real work. Thread the observer from the Server composition (and the background CLI) through open_builtin_runtime(work_observer=...) into every ledger component, so /metrics reports durable work instead of only direct calls. --- .../builtin/runtime/composition.py | 11 +- src/powercontext/server/cli.py | 1 + src/powercontext/server/factory.py | 5 +- .../builtin/runtime/test_work_composition.py | 223 +++++++++++++++++- 4 files changed, 236 insertions(+), 4 deletions(-) diff --git a/src/powercontext/builtin/runtime/composition.py b/src/powercontext/builtin/runtime/composition.py index c57339ced3..a2f64b6562 100644 --- a/src/powercontext/builtin/runtime/composition.py +++ b/src/powercontext/builtin/runtime/composition.py @@ -216,6 +216,7 @@ from powercontext.builtin.runtime.topic_memory_recovery import TopicMemoryRecoveryService from powercontext.builtin.runtime.topic_memory_scope import topic_memory_processing_block from powercontext.builtin.runtime.topic_memory_search import TopicMemorySearcher +from powercontext.builtin.runtime.work_observability import WorkObserver from powercontext.builtin.runtime.worker_observability import worker_model_usage_collector from powercontext.builtin.sources import ( BUILTIN_SOURCE_REGISTRY, @@ -537,6 +538,7 @@ async def open_builtin_runtime( artifact_processing_bindings: Sequence[ArtifactProcessingBinding] = (), worker_security: dict[str, Any] | None = None, processing_observer: Callable[[str, str, float], None] | None = None, + work_observer: WorkObserver | None = None, worker_initializer: Callable[[], None] | WorkerInitializerPreparation | None = None, worker_entrypoint_wrapper: Callable[[WorkerEntrypoint], WorkerEntrypoint] | None = None, scheduled_source_runner: ScheduledSourceRunner | None = None, @@ -898,7 +900,7 @@ async def run_profile(scope_id, high): ) contexts.profiles.operation_context = runtime._operation runtime.work_background = await resources.enter_async_context( - _open_work_background(config, contexts, tracing=tracing) + _open_work_background(config, contexts, tracing=tracing, observer=work_observer) ) if config.handoff_report.enabled: runtime.handoff_report = HandoffReportApplication( @@ -1346,6 +1348,7 @@ async def _open_work_background( contexts: RelationalContexts, *, tracing: RuntimeTracing | None = None, + observer: WorkObserver | None = None, ) -> AsyncIterator[WorkBackground]: """Start the durable Work Ledger components this role is responsible for. @@ -1379,6 +1382,7 @@ async def _open_work_background( worker=config.worker, local_worker=None, payload_version=CURRENT_WORK_PAYLOAD_VERSION, + observer=observer, ), rate_limit_maintenance=maintenance, ) @@ -1394,6 +1398,7 @@ async def _open_work_background( deployment=config.deployment, coordination=config.coordination, build_version=_build_version(), + observer=observer, ) await membership.start() stack.push_async_callback(membership.stop) @@ -1403,6 +1408,7 @@ async def _open_work_background( scheduler_id=f"{membership.instance_id}:scheduler", discoverers=_work_discoverers(config, contexts), config=config.coordination, + observer=observer, tracing=tracing, ) if runs_scheduler @@ -1414,6 +1420,7 @@ async def _open_work_background( worker_id=f"{membership.instance_id}:worker", handlers=_work_handlers(config, contexts), config=config.worker, + observer=observer, tracing=tracing, ) if runs_worker @@ -1432,6 +1439,7 @@ async def _open_work_background( worker=config.worker, local_worker=worker, payload_version=CURRENT_WORK_PAYLOAD_VERSION, + observer=observer, ), membership=membership, scheduler=scheduler, @@ -1461,6 +1469,7 @@ def compose_artifact_search(contexts, embedding_model=None, *, observer=None): topic_memory_searcher = TopicMemorySearcher( search=contexts.search_topic_memories, embedding_model=embedding_model, + observer=observer, capabilities=contexts.topic_memory_index.capabilities, ) artifact_search.register(topic_memory_searcher, embedding_purpose=ModelUsagePurpose.TOPIC_MEMORY_RECALL) diff --git a/src/powercontext/server/cli.py b/src/powercontext/server/cli.py index ecc608d346..a4b3f42f58 100644 --- a/src/powercontext/server/cli.py +++ b/src/powercontext/server/cli.py @@ -865,6 +865,7 @@ async def _run_background_async(settings: ServerSettings, tracing: Any) -> None: model_usage_observer=None if metrics is None else metrics.observe_model_usage, worker_model_usage_observer=None if metrics is None else metrics.observe_worker_model_usage, processing_observer=None if metrics is None else metrics.observe_processing, + work_observer=metrics, worker_initializer=logging_bootstrap, worker_entrypoint_wrapper=WorkerTracing(settings.tracing).wrap, ) diff --git a/src/powercontext/server/factory.py b/src/powercontext/server/factory.py index 42b043ca29..0a293347ee 100644 --- a/src/powercontext/server/factory.py +++ b/src/powercontext/server/factory.py @@ -203,9 +203,9 @@ def create_server_app( # noqa: C901 else "full" ) if route_profile == "health" and (resolved.dashboard.enabled or resolved.mcp.enabled): - raise ValueError( + raise ValueError( # noqa: TRY003 "Distributed Scheduler and Worker roles expose only health and metrics endpoints; " - "disable the Dashboard and MCP for these roles" # noqa: TRY003 + "disable the Dashboard and MCP for these roles" ) resolved_tracing = ( ServerTracing.context_only(role=resolved.runtime.artifact_processing_role) if tracing is None else tracing @@ -297,6 +297,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: # noqa: C901 - shared model_usage_observer=None if metrics is None else metrics.observe_model_usage, worker_model_usage_observer=None if metrics is None else metrics.observe_worker_model_usage, processing_observer=None if metrics is None else metrics.observe_processing, + work_observer=metrics, scheduled_source_runner=scheduled_source_runner, scheduled_experience_runner=scheduled_experience_runner, worker_security=worker_security, diff --git a/tests/builtin/runtime/test_work_composition.py b/tests/builtin/runtime/test_work_composition.py index 57f4694c7c..fd24787622 100644 --- a/tests/builtin/runtime/test_work_composition.py +++ b/tests/builtin/runtime/test_work_composition.py @@ -15,10 +15,16 @@ from __future__ import annotations import asyncio +from datetime import UTC, datetime, timedelta +from time import monotonic from types import SimpleNamespace from typing import Literal, cast +from sqlalchemy import func, insert, select + +from powercontext.builtin.persistence.rate_limit import RATE_LIMIT_WINDOWS_TABLE from powercontext.builtin.persistence.sqlite import SQLiteConfig +from powercontext.builtin.persistence.work import WorkRepository from powercontext.builtin.runtime import BuiltinConfig, open_builtin_contexts from powercontext.builtin.runtime.artifact_processing import ArtifactProcessingBinding from powercontext.builtin.runtime.composition import ( @@ -30,14 +36,22 @@ _work_handlers, _work_role_components, ) -from powercontext.builtin.runtime.config import DeploymentConfig, RuntimeConfig +from powercontext.builtin.runtime.config import ( + CoordinationConfig, + DeploymentConfig, + OperationsConfig, + RuntimeConfig, + WorkerConfig, +) from powercontext.builtin.runtime.relational import RelationalContexts from powercontext.builtin.runtime.work_handlers import ( EXPERIENCE_WORK_KIND, MAINTENANCE_WORK_KIND, MEMORY_WORK_KIND, PROFILE_WORK_KIND, + memory_work_spec, ) +from powercontext.builtin.sources import ContentCapture def _config(tmp_path, **overrides): @@ -151,3 +165,210 @@ async def scenario() -> None: assert background.membership is None asyncio.run(scenario()) + + +class _RecordingObserver: + """Record-only WorkObserver standing in for the ServerMetrics registry.""" + + def __init__(self) -> None: + self.enqueues: list[tuple[str, bool]] = [] + self.queue_samples: object = None + + def observe_work_enqueue(self, kind: str, *, created: bool) -> None: + self.enqueues.append((kind, created)) + + def observe_work_claim(self, kind: str, *, latency_seconds: float) -> None: + pass + + def observe_work_attempt( + self, + kind: str, + *, + outcome: str, + error_category: str, + duration_seconds: float, + ) -> None: + pass + + def observe_work_lease_expiry(self, kind: str, *, outcome: str) -> None: + pass + + def observe_scheduler_leadership(self, *, outcome: str) -> None: + pass + + def set_work_queue(self, samples) -> None: + self.queue_samples = list(samples) + + def set_runtime_members(self, counts) -> None: + pass + + +def test_operations_refresh_the_queue_gauges_through_the_wired_observer(tmp_path) -> None: + async def scenario() -> None: + observer = _RecordingObserver() + config = _config(tmp_path, runtime=RuntimeConfig(memory_schedule_seconds=30)) + async with ( + open_builtin_contexts(config) as contexts, + _open_work_background(config, contexts, observer=observer) as background, + ): + assert background.operations is not None + context = await contexts.get("project") + await context.sources.capture(ContentCapture(source_id="one", content="remember this")) + spec = await memory_work_spec(contexts, "project", limit=10, max_attempts=3, payload_version=1) + assert spec is not None + + repository = WorkRepository(observer=observer) + async with contexts.database.transaction() as connection: + await repository.enqueue(connection, spec) + assert observer.enqueues == [(MEMORY_WORK_KIND, True)] + + page = await background.operations.list( + scope_id="project", kind=MEMORY_WORK_KIND, status=None, cursor=None, limit=10 + ) + stored = page.items[0] + await background.operations.cancel(stored.work_id, expected_version=stored.state_version) + # The cancel path refreshes the durable queue gauges through the + # same observer the production composition wired in. + assert observer.queue_samples is not None + + asyncio.run(scenario()) + + +def test_distributed_ledger_wires_the_work_observer_into_every_component(monkeypatch) -> None: + """Distributed+SQLite is rejected by BuiltinConfig, so spy the ledger components.""" + + async def scenario() -> None: + observer = _RecordingObserver() + constructed: dict[str, object] = {} + + class _SpyComponent: + def __init__(self, *args, **kwargs) -> None: + constructed[self._role] = kwargs + self._observer = kwargs.get("observer") + self._stopped = asyncio.Event() + + _role = "" + instance_id = "instance-1" + + async def start(self) -> None: + pass + + async def run(self) -> None: + await self._stopped.wait() + + async def stop(self) -> None: + self._stopped.set() + + class _SpyMembership(_SpyComponent): + _role = "membership" + + class _SpyScheduler(_SpyComponent): + _role = "scheduler" + + class _SpyWorker(_SpyComponent): + _role = "worker" + + monkeypatch.setattr("powercontext.builtin.runtime.membership.RuntimeMembership", _SpyMembership) + monkeypatch.setattr("powercontext.builtin.runtime.durable_scheduler.DurableScheduler", _SpyScheduler) + monkeypatch.setattr("powercontext.builtin.runtime.worker.DurableWorker", _SpyWorker) + monkeypatch.setattr("powercontext.builtin.runtime.composition._work_discoverers", lambda config, contexts: ()) + monkeypatch.setattr("powercontext.builtin.runtime.composition._work_handlers", lambda config, contexts: ()) + + config = cast( + "BuiltinConfig", + SimpleNamespace( + deployment=DeploymentConfig(mode="distributed", role="all"), + runtime=RuntimeConfig(memory_schedule_seconds=30), + coordination=CoordinationConfig(), + worker=WorkerConfig(), + operations=OperationsConfig(), + ), + ) + contexts = cast("RelationalContexts", SimpleNamespace(database=None)) + async with _open_work_background(config, contexts, observer=observer) as background: + assert background.membership is not None + assert background.scheduler is not None + assert background.worker is not None + assert background.operations is not None + # Every ledger component feeds the same bounded-label observer so + # /metrics reports real durable work, not only direct test calls. + assert background.membership._observer is observer + assert background.scheduler._observer is observer + assert background.worker._observer is observer + assert background.operations._observer is observer + assert constructed["scheduler"]["observer"] is observer + assert constructed["worker"]["observer"] is observer + assert constructed["membership"]["observer"] is observer + + asyncio.run(scenario()) + + +def test_single_node_maintenance_purges_expired_rate_limit_windows(tmp_path) -> None: + """The single-node background must bound the rate limit window table.""" + + async def scenario() -> None: + config = _config( + tmp_path, + operations=OperationsConfig(cleanup_interval_seconds=0.1, cleanup_batch_size=500), + ) + async with ( + open_builtin_contexts(config) as contexts, + _open_work_background(config, contexts) as background, + ): + assert background.rate_limit_maintenance is not None + expired_at = datetime.now(tz=UTC) - timedelta(hours=1) + async with contexts.database.transaction() as connection: + await connection.execute( + insert(RATE_LIMIT_WINDOWS_TABLE).values( + principal_key="a" * 64, + policy_id="server.http", + window_started_at=expired_at, + expires_at=expired_at + timedelta(seconds=60), + request_count=1, + ) + ) + deadline = monotonic() + 5 + rows = -1 + while monotonic() < deadline: + async with contexts.database.transaction() as connection: + rows = ( + await connection.execute(select(func.count()).select_from(RATE_LIMIT_WINDOWS_TABLE)) + ).scalar() + if rows == 0: + break + await asyncio.sleep(0.05) + # The maintenance loop deleted the expired window row without any + # distributed ledger execution. + assert rows == 0 + + asyncio.run(scenario()) + + +def test_rate_limit_maintenance_stops_when_requested(tmp_path) -> None: + async def scenario() -> None: + async with open_builtin_contexts(_config(tmp_path)) as contexts: + purges: list[int] = [] + + class _PurgeSpy: + async def purge_expired(self, connection, *, limit): + purges.append(limit) + return 0 + + from powercontext.builtin.runtime.composition import _RateLimitWindowMaintenance + + maintenance = _RateLimitWindowMaintenance( + database=contexts.database, + interval_seconds=0.05, + batch_size=10, + repository=_PurgeSpy(), + ) + maintenance.start() + await asyncio.sleep(0.25) + while_count = len(purges) + assert while_count >= 2 + await maintenance.stop() + after_stop = len(purges) + await asyncio.sleep(0.15) + assert len(purges) == after_stop + + asyncio.run(scenario()) From 4e8b30aaa5873cf8f7bca636d2f8eb5f76c17a6e Mon Sep 17 00:00:00 2001 From: JasonDeng <15301580353@163.com> Date: Wed, 4 Nov 2026 11:23:03 +0800 Subject: [PATCH 23/45] chore(desktop): regenerate the contract artifacts for the merged OpenAPI The merge moved the bundled contract digest to 741791d3...; refresh operations.json and operations.ts from the merged openapi/powercontext.yaml so the Desktop identity matches the contract it ships with. --- desktop/src-tauri/src/transport/operations.json | 2 +- desktop/ui/src/generated/operations.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/desktop/src-tauri/src/transport/operations.json b/desktop/src-tauri/src/transport/operations.json index 42228ca048..ffff4e2840 100644 --- a/desktop/src-tauri/src/transport/operations.json +++ b/desktop/src-tauri/src/transport/operations.json @@ -1,5 +1,5 @@ { - "contractSha256": "c90024304fa0430c91e63904e992b4828cb2b5467f2d625862973544378aafed", + "contractSha256": "741791d3eddfaf835d8516f955ebe7f6bde49c9894b93d0263e0d0fbf7eb7e12", "operations": { "get_liveness": { "method": "GET", diff --git a/desktop/ui/src/generated/operations.ts b/desktop/ui/src/generated/operations.ts index a505e7a278..48fbd0aae4 100644 --- a/desktop/ui/src/generated/operations.ts +++ b/desktop/ui/src/generated/operations.ts @@ -15,7 +15,7 @@ */ // Generated from openapi/powercontext.yaml. Do not edit. -export const contractSha256 = "c90024304fa0430c91e63904e992b4828cb2b5467f2d625862973544378aafed"; +export const contractSha256 = "741791d3eddfaf835d8516f955ebe7f6bde49c9894b93d0263e0d0fbf7eb7e12"; export const operations = { "get_liveness": { "method": "GET", From a8cb90eea6d1374c5e46dfa40bd32b886fcf7f02 Mon Sep 17 00:00:00 2001 From: JasonDeng <15301580353@163.com> Date: Wed, 4 Nov 2026 11:28:01 +0800 Subject: [PATCH 24/45] chore(desktop): qualify the merged bundled contract (4e8b30aa) Build the wheel from the clean merge commit, run the real_server harness against the extracted wheel and record sqlite-atomic-4e8b30aa-v1: - contract 741791d3..., wheel 90ccd361..., 696 runtime files byte-identical - four harness modes passed (anonymous loopback, static Bearer, HTTPS with explicit CA/base path, injected provider with enforced access) - macOS 27.2 ARM64, Python 3.14.7, Rust 1.68.1, dirty working tree false --- desktop/VALIDATION.md | 39 ++++++++++++++++++- .../src/connections/compatibility.json | 20 ++++++++++ 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/desktop/VALIDATION.md b/desktop/VALIDATION.md index 89766b0094..c14b565f09 100644 --- a/desktop/VALIDATION.md +++ b/desktop/VALIDATION.md @@ -118,7 +118,7 @@ The historical Server c535d816 protocol qualification below records the specific ## Compatibility scope -[compatibility.json](src-tauri/src/connections/compatibility.json) preserves each recorded Server source, normalized OpenAPI contract digest, qualification-wheel digest and operation set. The merge of master into this branch moved the bundled API contract digest to `741791d3…`; a fresh qualification wheel and harness run for that contract are pending, so until the next record lands no recorded profile matches the bundled contract and the selector keeps every record disabled. `sqlite-atomic-d9f33be3-v1` qualifies this branch's pre-merge bundled contract and `sqlite-atomic-2d782c2a-v1` qualifies master's; both retain their exact identities and evidence. Historical records retain their exact identities and evidence as well, including `sqlite-atomic-cf81c1a8-v1` and `sqlite-atomic-bae5d714-v1`, which qualified the preceding `c7f00e81…` and `0f6befcd…` contracts respectively. The `sqlite-atomic-dd62723d-v1` and `sqlite-atomic-4ffdf4e9-v1` records retain their migration-framework and release-preparation source qualifications with their original contract digest. Ordinary builds require explicitly selecting the record whose tested Server source and wheel match the deployment, then checking the connection before business operations become available. Records with a different contract digest are disabled in the selector, and saved unavailable selections remain visible until explicitly replaced. Selecting a profile does not extend its recorded operation set or qualify a different Server source. CI builds have their own artifact identities and do not change product qualification. +[compatibility.json](src-tauri/src/connections/compatibility.json) preserves each recorded Server source, normalized OpenAPI contract digest, qualification-wheel digest and operation set. The merge of master into this branch moved the bundled API contract digest to `741791d3…`, and `sqlite-atomic-4e8b30aa-v1` records the merged contract's eleven protocol operations qualified against the exact Server source and wheel below. `sqlite-atomic-d9f33be3-v1` qualifies this branch's pre-merge bundled contract and `sqlite-atomic-2d782c2a-v1` qualifies master's; both retain their exact identities and evidence. Historical records retain their exact identities and evidence as well, including `sqlite-atomic-cf81c1a8-v1` and `sqlite-atomic-bae5d714-v1`, which qualified the preceding `c7f00e81…` and `0f6befcd…` contracts respectively. The `sqlite-atomic-dd62723d-v1` and `sqlite-atomic-4ffdf4e9-v1` records retain their migration-framework and release-preparation source qualifications with their original contract digest. Ordinary builds require explicitly selecting the record whose tested Server source and wheel match the deployment, then checking the connection before business operations become available. Records with a different contract digest are disabled in the selector, and saved unavailable selections remain visible until explicitly replaced. Selecting a profile does not extend its recorded operation set or qualify a different Server source. CI builds have their own artifact identities and do not change product qualification. The real Server harness covers SQLite with anonymous loopback, static Bearer, HTTPS with explicit CA/base path, and injected-provider/enforced access. It exercises save/search/exact reads, identity changes, binding revocation, ambiguous writes without replay and Scope pagination. Installed UI checks cover repeated launches preserving the existing editor, late committed-save responses preserving a new Scope draft, explicit connection/Scope selection, clipboard copies, connection isolation, draft cancellation, byte limits and independent Server survival after Desktop exit. @@ -128,6 +128,43 @@ Selecting a compatibility profile does not attest the remote binary identity. Ex ### Server protocol qualification records +#### Server 4e8b30aa protocol qualification + +`sqlite-atomic-4e8b30aa-v1` qualifies Server sources at +`4e8b30aaa5873cf8f7bca636d2f8eb5f76c17a6e` for the bundled OpenAPI digest +`741791d3eddfaf835d8516f955ebe7f6bde49c9894b93d0263e0d0fbf7eb7e12`, the merged contract that carries +master's 1.5.0 artifact-storage chain, the private Unix socket and inference privacy work, and the +four Scope-scoped durable operation endpoints. The qualification wheel is +`powercontext-1.2.1.dev517+g4e8b30aaa-py3-none-any.whl`, SHA-256 +`90ccd36193b3c4b91d0fca151413694385229d720e55f9f34653328b3955ff8f`, built from that clean commit. +All 696 packaged runtime files in `powercontext/` and `powercontext_service_bootstrap/` match the +source bytes at that commit. The runtime manifest digest is +`6a88c0b314c0ea5e1a31fdadb5d0f6a5583a73eb26eff6bd5fd1b6e780dc06f9` +(sorted `wheel/path sha256` lines, with a final newline). + +On 2026-11-04, macOS 27.2 ARM64 with Python 3.14.7 and Rust 1.68.1 ran the existing +`desktop/tests/real_server.py` harness against the extracted wheel and independent SQLite Server +processes. All four modes passed: anonymous loopback, static Bearer, HTTPS with an explicit CA and +base path, and an injected provider with enforced access. The eleven recorded operations cover +Atomic Memory save/search/exact revision reads, Scope selection and pagination, identity changes, +same-identity revocation, committed writes with lost responses without replay, server discovery, +and Server survival after client exit. The report records the source commit above and +`desktopWorkingTreeDirty=false`. Native probe medians (nearest rank, 20 samples): loopback-anonymous +save 19.16 ms / search 15.84 ms / exact read 13.48 ms; loopback-bearer 34.53 / 25.68 / 24.36 ms; +HTTPS bearer with base path 33.62 / 24.77 / 23.65 ms; injected provider 36.75 / 24.77 / 23.71 ms. + +This is protocol qualification of the recorded source, contract and wheel. It does not qualify +Windows/Intel execution, native credential persistence, installed GUI workflows, signing or release +readiness, and it says nothing about the four new operation endpoints: they are Server-side contract +covered by the Python suite and are deliberately absent from the Desktop operation set above. +Reproduce from this clean commit with `uv build --wheel --out-dir desktop/.artifacts/server-wheel`, +`cargo build --locked --example server_probe --example diagnostic_cli_probe`, +`uv run --no-sync python desktop/tests/fixture_qualification.py --server-commit 4e8b30aaa5873cf8f7bca636d2f8eb5f76c17a6e`, +and `uv run --no-sync python desktop/tests/real_server.py`. +The bundled preview resolves its qualification by digest, so a contract change and a matching record +have to land together. `tests/test_desktop_contract.py` fails the Python gate for a digest without a +record, exactly as `bundled_qualification_allows_explicit_scope_browsing` fails the native one. + #### Server d9f33be3 protocol qualification `sqlite-atomic-d9f33be3-v1` qualifies Server sources at diff --git a/desktop/src-tauri/src/connections/compatibility.json b/desktop/src-tauri/src/connections/compatibility.json index a0b2852eb2..b47c7a1ed7 100644 --- a/desktop/src-tauri/src/connections/compatibility.json +++ b/desktop/src-tauri/src/connections/compatibility.json @@ -1,4 +1,24 @@ [ + { + "id": "sqlite-atomic-4e8b30aa-v1", + "serverCommit": "4e8b30aaa5873cf8f7bca636d2f8eb5f76c17a6e", + "contractSha256": "741791d3eddfaf835d8516f955ebe7f6bde49c9894b93d0263e0d0fbf7eb7e12", + "artifactSha256": "90ccd36193b3c4b91d0fca151413694385229d720e55f9f34653328b3955ff8f", + "operations": [ + "get_access_principal", + "get_artifact_revision", + "get_capabilities", + "get_default_scope", + "get_liveness", + "get_readiness", + "get_scope", + "get_server_info", + "list_scopes", + "remember_memory", + "search_memory" + ], + "evidence": "desktop/VALIDATION.md#server-4e8b30aa-protocol-qualification" + }, { "id": "sqlite-atomic-d9f33be3-v1", "serverCommit": "d9f33be3fde7a1801b51aa66c51d25af5b883d69", From b175d3aebeae484acfa1ae3581623a59e9e8a88f Mon Sep 17 00:00:00 2001 From: JasonDeng <15301580353@163.com> Date: Wed, 4 Nov 2026 18:41:49 +0800 Subject: [PATCH 25/45] test(migration): expect the appended v1_5_0_004 head Master published v1_5_0_002 (evidence) and v1_5_0_003 (projection) while this branch was open, so the Work Ledger is appended as v1_5_0_004. Update every head expectation, the recorded fingerprint for the new revision (computed from the replayed migration chain: 05e19278...) and the MySQL state-layout revision key, then refresh the upgrade policy digest it protects. make upgrade-policy-check passes at this head. --- release/upgrade-support.json | 4 ++-- .../migrations/server_resources/manifest.json | 2 +- .../schemas/mysql-v1_5_0_004.json | 2 +- .../server_sources/1.4.0/manifest.json | 2 +- .../test_atomic_description_vector_layout.py | 8 ++++---- .../persistence/test_full_server_migrations.py | 3 ++- .../test_mysql_initialization_preflight.py | 6 +++--- .../persistence/test_mysql_migration_runner.py | 16 ++++++++-------- .../test_portable_storage_migration.py | 9 +++++---- .../test_release140_server_migrations.py | 1 + .../test_atomic_memory_description_upgrade.py | 4 ++-- tests/native/test_oceanbase_engine_recovery.py | 2 +- tests/native/test_personal_service_lifecycle.py | 2 +- tests/test_managed_atomic_maintenance.py | 4 ++-- tests/test_managed_processing_maintenance.py | 2 +- tests/test_migration_revision_names.py | 6 ++++-- tests/test_registered_source_maintenance_cli.py | 2 +- 17 files changed, 40 insertions(+), 35 deletions(-) diff --git a/release/upgrade-support.json b/release/upgrade-support.json index 0e5a90f9cd..59c9272785 100644 --- a/release/upgrade-support.json +++ b/release/upgrade-support.json @@ -3,8 +3,8 @@ "target_version": "1.5.0", "mode": "upgrade-required", "schema_review": { - "persistence_sha256": "1c3d8a5a79fe56abd1c1c2da9457b1f9dcb73f3cd38c0e82b4264afd99cba701", - "rationale": "The reusable Server migration framework registers actual PowerContext 1.3.0 through s1300 -> s1400 -> s1401 -> s1402 and actual 1.4.0 through the separate immutable r1400 -> s1400 -> s1401 -> s1402 source bridge. Original 1.3 resources and checksums remain byte-identical. Real 1.4.0 SQLite, embedded seekDB and OceanBase structures were captured from the official wheel in all twelve vector/Code configurations; provenance and exact source layouts are packaged with the bridge. The 1.4 bridge preserves current Candidate, Artifact and task data, adds the missing Topic budget/block structure, revokes old lease fences and joins the existing archive/portable-storage path. Empty retained tables align the final schema without inventing 1.3 Candidate history. Source histories retain their identity through subsequent upgrades and recovery; shared head names never authorize adopting unknown layouts. Vector profiles remain bound to historical model, profile ID, dimension, unit normalization and L2 distance, with no provider calls during maintenance. Complete schema, domain, projection, profile and retained-history checks precede startup and partial maintenance. Local SQLite and embedded seekDB startup reject an observable active maintenance lock through a read-only probe. SQLite runtime admission validates the default adjacent journal, rejecting unfinished full or partial maintenance, changed recorded revision checksums and missing required retained tables. Absent journals do not invent retention obligations; custom evidence directories require explicit operator verification and remote coordinator locks remain subject to deployment orchestration. Native read-only readiness uses a consistent snapshot and does not acquire Atomic Memory head locks; normal business transaction isolation is unchanged. Offline projection repairs retain the original locked state comparison and write-side CAS protection. Target locks, backup choices, original recovery evidence and explicit remote acknowledgments remain enforced. The installed-artifact catalog retains twelve 1.3.0 source/backend/vector/Code combinations. Embedded seekDB uses pylibseekdb 1.4.0.post2 or newer with the recovered-transaction lock fix. Main and publication require all twelve configurations, including embedded seekDB full-server-code and full-server-vector-code. No configuration is waived; incomplete, failed or skipped evidence rejects acceptance. The seekdb-code diagnostic selection cannot authorize publication. The registered 1.4.0 bridge remains implemented but is outside the CI upgrade acceptance matrix; passing this gate does not certify that path. The gate preserves source-specific Processing counters and isolates OceanBase source databases. Any failed, skipped or missing automatic case rejects publication. The policy remains upgrade-required until the exact target artifact passes the required automatic matrix and the reviewed support declaration is updated. Captured source structure, local tests and checkpoint replay are distinct from installed-release acceptance, native interruption/observer recovery and backup restoration. The Candidate generation prototype observes actual SQLite/seekDB/OceanBase migration checkpoints outside the production bundle; it does not replace frozen registrations or weaken recovery checks. Existing target schema and data formats, including batched persistence interfaces, remain governed by the shared registered path. The synchronized master adds blank-text validation at Source creation and managed worker logging. It changes neither registered table layout nor migration data encodings; existing stored Source values are not rewritten. The source bridges and frozen history remain unchanged. Compatibility commands identify Server-owned storage before opening a writable legacy profile and delegate source admission to the shared framework, including actual 1.4.0 and rejection of unknown layouts. Partial Processing and Atomic Memory projection maintenance verify the original recorded revision checksums; native admission also validates typed journal identity and its completed position against the database revision. Persisted vector profiles reject unsupported partial maintenance before plans, backups or operation evidence, including when the supplied configuration omits embeddings. These are admission-only changes; frozen migration resources, registered layouts, historical checksums and data transformations remain unchanged. PowerContext 1.4.1 assigns release-based presentation names v1_4_1_001 through v1_4_1_003 to the existing shared upgrade steps; the 1.3.0 and 1.4.0 source baselines remain independently registered. Existing revision IDs, bundle and revision checksums, accepted plan identities and journal schemas are unchanged. New Server revisions use software-version-plus-sequence names directly; software releases without storage changes may reuse the same database revision. Naming does not grant acceptance or replace source registration. Full Server status, plan, apply and verify reject unfinished Processing configuration and Atomic Memory projection journals through the same typed admission check used by partial maintenance and SQLite startup. The owning domain runner can still resume its own journal. This only changes admission; frozen revisions, journal formats, database layouts and data transformations remain unchanged. Revision v1_4_1_004 registers the pc_access_owner_recovery outbox on the shared Server chain for both installed-release sources and all registered vector/Code profiles. It creates an empty table through explicit maintenance for existing managed databases and through the registered bootstrap for fresh databases. Earlier revision resources, fingerprints, checksums and existing domain row encodings remain unchanged. External relationship writers use version-1 Artifact and Candidate intents committed with domain data; replay retains the original deployment, principal, resource or target, and idempotency identity. Stable operation and audit event identities prevent lost acknowledgements from duplicating ownership or audit. Conflicting intents are rejected and unknown historical ownership is not inferred. Unregistered outbox layouts are not adopted merely from a table name. This registration does not establish installed-release or external-Provider upgrade acceptance. The policy remains upgrade-required with the full automatic matrix and publication restrictions described above. Database selection now defaults to powercontext for seekDB and for OceanBase URLs without a database, while explicit database names remain authoritative. An implicit seekDB selection is rejected before initialization when the legacy test database contains pc_ tables; operators can explicitly select database=test to retain those records, and no database is moved or deleted automatically. Bootstrap reuses an accessible selected database without CREATE privilege and creates only a confirmed missing database, with idempotent creation and explicit permission errors. Read-only maintenance on an initialized MySQL-compatible engine requires the selected database and never provisions it; an absent or empty seekDB instance remains a no-engine initial plan. MySQL initialization creates only missing requested tables and indexes, and sorts only those requested tables; existing registered tables are preserved. Code graph connections use the selected database, retain historical graph identities for test, and distinguish other database identities. Server identity initialization avoids redundant MySQL DDL on an existing table. Frozen migration resources, revision identifiers and checksums, registered table definitions, journal formats, and existing row encodings are unchanged. These runtime selection and admission changes do not establish an accepted upgrade path; the policy remains upgrade-required and the installed-release acceptance gate still applies. MySQL recovery rejects different registered table sets before normalization. Backend-default normalization copies only the column and index maps it changes and compares each table without cloning whole inventories. Admitted layouts, registered transitions, recovery checks, schema equality and caller-owned inventories remain preserved. SeekDB status and verify distinguish a missing configured database from an uninitialized engine and denied access. Read-only plan can represent a missing selected database as an uninitialized target without creating it; apply provisions that selected database only after the operator accepts its bound plan, then rechecks the target under the maintenance lock. Installed-release seekDB acceptance explicitly retains the historical test database and separately verifies that omitted selection is refused. Registered schema resources, revision checksums, row encodings and write-side provisioning remain unchanged. Server startup can apply an uninitialized plan to provision a missing selected seekDB database; plan, status and verify still never provision it. OceanBase 4.6 SHOW CREATE TABLE can reflect FTS_INDEX_TYPE=MATCH or PHRASE_MATCH on FULLTEXT indexes. Reflection accepts these known types without changing the logical inventory; unknown values remain rejected and indexed columns, parser identity and token-size properties remain significant. PHRASE_MATCH additionally supports phrase matching; the registered layouts require term matching and do not declare a phrase-only contract. Frozen migration resources, checksums, recovery evidence and persisted layouts remain unchanged. Native 4.6.3.1 DDL was captured from a real installed 1.3.0 database; replay of this fixture does not establish complete native upgrade acceptance. The CI database image and master full automatic upgrade matrix remain unchanged. Revision v1_4_1_005 adds nullable Atomic Memory description projection metadata after the registered owner-recovery revision, through explicit SQLite and MySQL schema transitions for both source chains and all vector/Code profiles. Earlier revision resources, fingerprints and checksums remain unchanged. The additive change neither rewrites authoritative Artifact history nor generates summaries or scans stored bodies. Existing managed databases require explicit migration; existing descriptions remain null until a new write or explicit projection repair copies authoritative metadata. Intermediate projection validation uses only columns available at that revision and still rejects inconsistent existing fields. Unmanaged SDK initialization retains its nullable-column compatibility path. The installed-release acceptance matrix and publication restrictions remain required; local regression checks do not establish native upgrade acceptance. Artifact plugin storage extends the shared Server chain from v1_4_1_006 to v1_5_0_001 with registration columns, family declarations, search state and projections, index coverage and changes, and vector-space/index registrations. SQLite also registers portable vectors and FTS auxiliary structures. Native registration columns are added in one ALTER with a matching recovery checkpoint; final column definitions and constraints are unchanged. Native vector tables are created online from registered spaces and are admitted to later maintenance inventories only after exact layout validation. Historical migration resources, retained objects, and existing data are preserved. This review does not expand the supported upgrade matrix or certify installed-release acceptance. Source write plugin registrations use the additive v1_4_1_006 revision after v1_4_1_005 on both source chains. It creates only pc_source_write_plugins; all earlier master revision scripts, schema resources and checksums remain unchanged. Revision v1_5_0_002 follows v1_5_0_001 and adds retained Source evidence bindings, exact Atomic Memory evidence snapshots and their schema marker on all registered backends. A new versioned domain step verifies retained evidence for lexical and vector profiles; earlier released domain resources and recorded revision checksums remain byte-identical. Existing Sources and Memory revisions without accepted declarations stay neutral, with no trust backfill. Current projection maintenance and rebuild explicitly require evidence validation. This additive registration does not establish installed-release acceptance; the upgrade-required gate and publication restrictions are unchanged. Revision v1_5_0_003 adds the disposable exact-revision pc_atomic_memory_evidence projection through the existing index adapters. Transactional body writes publish it from accepted evidence; scoped portable restore and maintenance rebuild derive it from retained history without current-registry trust backfill or model calls. Domain conformance compares historical and derived values, including neutral legacy declarations, on lexical and vector layouts. Earlier revision resources and fingerprints remain unchanged. Local SQLite recovery and real OceanBase 4.4.2.1 evidence conformance were exercised; these do not certify installed-release upgrades or change publication restrictions. Source evidence maintenance now accepts the deployment's own Source definition registry, reports one bounded neutralization reason through operational logs, batches exact-revision reads and caches retained-schema validation per manifest fingerprint. Indexed repair accepts that registry explicitly; the migration bundle still admits evidence against the builtin registry and says so. These changes alter neither registered table layouts nor retained encodings, and the runtime and Server evidence DDL remain byte-identical. The policy remains upgrade-required with the full automatic matrix and publication restrictions described above. Source definition manifests derive every field whose default is null from the validation schema, so an already accepted Definition keeps its fingerprint and re-registers after upgrade; populating such a field requires the nested model to serialize by alias. Registered layouts and retained encodings are unchanged. The runtime MySQL-family table fast path now runs the registered additive evidence migration before creating other tables, so an unmanaged OceanBase or seekDB database cannot retain evidence tables without their p0005 marker.. Code publication recovery adds an optional per-binding publication.json journal that records a generation identifier before its database commit and final directory rename. Caches created before this journal existed remain readable without synthesizing historical publication records; identifiers for already orphaned rows are not inferred. New seekDB generations retain the existing identifier, graph descriptor, manifest and row encodings, while reclamation validates the recorded identifier and scopes deletion to its binding. Live or reader-retained generation directories and the in-flight publication are protected from journal-window reclamation. SQLite graph rows remain owned by their generation directory. This additive local cache journal changes recovery behavior but does not change registered SQL table layouts, migration resources, revision identifiers, historical checksums or the Server migration journal. The policy remains upgrade-required, and the complete installed-release acceptance matrix remains mandatory; this compatibility review is not evidence of completed native upgrade acceptance. Managed Ops admission validates a private main-file and WAL snapshot through the exact target Server bundle, retaining the original database identity and maintenance evidence. Snapshot admission is read-only and cannot initialize, migrate or recover storage. It adds no schema revision, changes no persisted row or journal format, and does not expand supported upgrade declarations or the installed-release acceptance matrix. PreparedContext Receipt records the actual FTS mode on Experience search outcomes. This adds only an in-memory observation field; it does not change stored Experience rows, SQL selection, schema registrations, migration resources, or data encodings. Unmanaged historical Server databases are admitted by required structure and domain state, then normalized before adopting an existing checkpoint. Historical Memory conversion, Scope preservation, SQLite rollback and native DDL recovery use the shared maintenance flow. Independent extension objects and harmless DDL differences do not require clean-install fingerprints. Existing immutable revision resources and the installed-release acceptance matrix remain unchanged. Local historical tests do not certify arbitrary tenant defaults, every vector/Code configuration or future releases. SQLite compatibility validation includes effective primary-key collations and rejects conflicting identity semantics. Historical normalization and the frozen s1400 rebuild preserve generated extension definitions while copying only writable columns. Both virtual and stored generated values remain computed by SQLite; immutable revision resources and the native backend paths are unchanged. SQLite table rebuilds preserve declared collations on ordinary and generated extension columns, including their inherited index comparison semantics. Expression-local COLLATE clauses remain part of their expressions and do not change the column collation. This changes only SQLite compatibility normalization; frozen revisions and native backend execution remain unchanged. Historical Memory admission recognizes lineage ownership columns and default non-merge references while rejecting unmapped payloads. Native compatibility journals record temporary Processing counter ownership before DDL and clear it after cleanup, including completed-phase recovery. SQLite extension preservation also covers the registered r1400 bridge. Frozen migration resources and the installed-release acceptance matrix remain unchanged. Native historical Atomic conversion creates nullable description metadata with the registered UTF-8 binary collation, allowing the later frozen ADD transition to recognize its completed postcondition without duplicate DDL. SQLite historical normalization and registered source bridges preserve dependent views and their triggers during same-name table replacement. Legacy rename behavior is limited to the exact staging-to-final rename statements and the original connection setting is restored; genuine table renames still update dependent references. Frozen migration resources and native backend behavior remain unchanged. SQLite admission validates required extension defaults using SQLite deterministic-expression restrictions and a non-NULL evaluation on an isolated connection. Connection-state, random and clock-dependent expressions are rejected; direct CURRENT_DATE, CURRENT_TIME and CURRENT_TIMESTAMP defaults remain supported alongside usable constant expressions. Regression coverage verifies consecutive Source writes in distinct scopes within the same transaction. Read-only verification and startup admission share this rule; supported defaults remain usable through Source write and readback. Independent extension tables remain preserved through managed upgrades and initialization recovery; missing recovery evidence and missing vector configuration still block maintenance. Registered native column additions and modifications materialize the charset and collation declared by their immutable postcondition when the SQL would otherwise inherit a historical table default. This preserves GBK-origin upgrade recovery without changing frozen resources, existing table defaults, independent extension encodings or readiness requirements. SQLite Candidate extension columns and their values survive both stages of the registered Dream rebuild, including generated columns and dependent views. Frozen source introspection projects the already-validated managed columns without altering stored extension data. Independent indexes use SQLite-rewritten definitions after genuine table renames. Native CHECK removal resolves the actual constraint name from the registered expression and enforcement semantics, including recovery after committed DDL. Frozen resources, registered revision checksums and the installed-release acceptance matrix remain unchanged. SQLite admission rejects additional NOT NULL generated columns because computed values can reject valid Source writes. Nullable virtual and stored extensions remain preserved across historical upgrades and support Source write/readback. Frozen migrations and native backend behavior are unchanged. Ownership recovery preflight derives candidate-family filters from the exact migration identities, including Prompt Candidates that do not have standalone Artifact owners. Static Scope permission previews now use the same complete internal receipt keys as bootstrap for long Scope IDs. These authorization-only changes add no stored fields, row encoding, schema, or migration registration. Static Scope preset keys retain existing short identities and use SHA-256 for oversized relationship keys. Bootstrap and read-only preview recognize both bounded keys and historical receipts, including revoked bindings. No schema or migration registration changes." + "persistence_sha256": "d18b43d49d1078b8989a8e9dcc984b6220a619fdcef2d7cf8fbf90993bb951f5", + "rationale": "The reusable Server migration framework registers actual PowerContext 1.3.0 through s1300 -> s1400 -> s1401 -> s1402 and actual 1.4.0 through the separate immutable r1400 -> s1400 -> s1401 -> s1402 source bridge. Original 1.3 resources and checksums remain byte-identical. Real 1.4.0 SQLite, embedded seekDB and OceanBase structures were captured from the official wheel in all twelve vector/Code configurations; provenance and exact source layouts are packaged with the bridge. The 1.4 bridge preserves current Candidate, Artifact and task data, adds the missing Topic budget/block structure, revokes old lease fences and joins the existing archive/portable-storage path. Empty retained tables align the final schema without inventing 1.3 Candidate history. Source histories retain their identity through subsequent upgrades and recovery; shared head names never authorize adopting unknown layouts. Vector profiles remain bound to historical model, profile ID, dimension, unit normalization and L2 distance, with no provider calls during maintenance. Complete schema, domain, projection, profile and retained-history checks precede startup and partial maintenance. Local SQLite and embedded seekDB startup reject an observable active maintenance lock through a read-only probe. SQLite runtime admission validates the default adjacent journal, rejecting unfinished full or partial maintenance, changed recorded revision checksums and missing required retained tables. Absent journals do not invent retention obligations; custom evidence directories require explicit operator verification and remote coordinator locks remain subject to deployment orchestration. Native read-only readiness uses a consistent snapshot and does not acquire Atomic Memory head locks; normal business transaction isolation is unchanged. Offline projection repairs retain the original locked state comparison and write-side CAS protection. Target locks, backup choices, original recovery evidence and explicit remote acknowledgments remain enforced. The installed-artifact catalog retains twelve 1.3.0 source/backend/vector/Code combinations. Embedded seekDB uses pylibseekdb 1.4.0.post2 or newer with the recovered-transaction lock fix. Main and publication require all twelve configurations, including embedded seekDB full-server-code and full-server-vector-code. No configuration is waived; incomplete, failed or skipped evidence rejects acceptance. The seekdb-code diagnostic selection cannot authorize publication. The registered 1.4.0 bridge remains implemented but is outside the CI upgrade acceptance matrix; passing this gate does not certify that path. The gate preserves source-specific Processing counters and isolates OceanBase source databases. Any failed, skipped or missing automatic case rejects publication. The policy remains upgrade-required until the exact target artifact passes the required automatic matrix and the reviewed support declaration is updated. Captured source structure, local tests and checkpoint replay are distinct from installed-release acceptance, native interruption/observer recovery and backup restoration. The Candidate generation prototype observes actual SQLite/seekDB/OceanBase migration checkpoints outside the production bundle; it does not replace frozen registrations or weaken recovery checks. Existing target schema and data formats, including batched persistence interfaces, remain governed by the shared registered path. The synchronized master adds blank-text validation at Source creation and managed worker logging. It changes neither registered table layout nor migration data encodings; existing stored Source values are not rewritten. The source bridges and frozen history remain unchanged. Compatibility commands identify Server-owned storage before opening a writable legacy profile and delegate source admission to the shared framework, including actual 1.4.0 and rejection of unknown layouts. Partial Processing and Atomic Memory projection maintenance verify the original recorded revision checksums; native admission also validates typed journal identity and its completed position against the database revision. Persisted vector profiles reject unsupported partial maintenance before plans, backups or operation evidence, including when the supplied configuration omits embeddings. These are admission-only changes; frozen migration resources, registered layouts, historical checksums and data transformations remain unchanged. PowerContext 1.4.1 assigns release-based presentation names v1_4_1_001 through v1_4_1_003 to the existing shared upgrade steps; the 1.3.0 and 1.4.0 source baselines remain independently registered. Existing revision IDs, bundle and revision checksums, accepted plan identities and journal schemas are unchanged. New Server revisions use software-version-plus-sequence names directly; software releases without storage changes may reuse the same database revision. Naming does not grant acceptance or replace source registration. Full Server status, plan, apply and verify reject unfinished Processing configuration and Atomic Memory projection journals through the same typed admission check used by partial maintenance and SQLite startup. The owning domain runner can still resume its own journal. This only changes admission; frozen revisions, journal formats, database layouts and data transformations remain unchanged. Revision v1_4_1_004 registers the pc_access_owner_recovery outbox on the shared Server chain for both installed-release sources and all registered vector/Code profiles. It creates an empty table through explicit maintenance for existing managed databases and through the registered bootstrap for fresh databases. Earlier revision resources, fingerprints, checksums and existing domain row encodings remain unchanged. External relationship writers use version-1 Artifact and Candidate intents committed with domain data; replay retains the original deployment, principal, resource or target, and idempotency identity. Stable operation and audit event identities prevent lost acknowledgements from duplicating ownership or audit. Conflicting intents are rejected and unknown historical ownership is not inferred. Unregistered outbox layouts are not adopted merely from a table name. This registration does not establish installed-release or external-Provider upgrade acceptance. The policy remains upgrade-required with the full automatic matrix and publication restrictions described above. Database selection now defaults to powercontext for seekDB and for OceanBase URLs without a database, while explicit database names remain authoritative. An implicit seekDB selection is rejected before initialization when the legacy test database contains pc_ tables; operators can explicitly select database=test to retain those records, and no database is moved or deleted automatically. Bootstrap reuses an accessible selected database without CREATE privilege and creates only a confirmed missing database, with idempotent creation and explicit permission errors. Read-only maintenance on an initialized MySQL-compatible engine requires the selected database and never provisions it; an absent or empty seekDB instance remains a no-engine initial plan. MySQL initialization creates only missing requested tables and indexes, and sorts only those requested tables; existing registered tables are preserved. Code graph connections use the selected database, retain historical graph identities for test, and distinguish other database identities. Server identity initialization avoids redundant MySQL DDL on an existing table. Frozen migration resources, revision identifiers and checksums, registered table definitions, journal formats, and existing row encodings are unchanged. These runtime selection and admission changes do not establish an accepted upgrade path; the policy remains upgrade-required and the installed-release acceptance gate still applies. MySQL recovery rejects different registered table sets before normalization. Backend-default normalization copies only the column and index maps it changes and compares each table without cloning whole inventories. Admitted layouts, registered transitions, recovery checks, schema equality and caller-owned inventories remain preserved. SeekDB status and verify distinguish a missing configured database from an uninitialized engine and denied access. Read-only plan can represent a missing selected database as an uninitialized target without creating it; apply provisions that selected database only after the operator accepts its bound plan, then rechecks the target under the maintenance lock. Installed-release seekDB acceptance explicitly retains the historical test database and separately verifies that omitted selection is refused. Registered schema resources, revision checksums, row encodings and write-side provisioning remain unchanged. Server startup can apply an uninitialized plan to provision a missing selected seekDB database; plan, status and verify still never provision it. OceanBase 4.6 SHOW CREATE TABLE can reflect FTS_INDEX_TYPE=MATCH or PHRASE_MATCH on FULLTEXT indexes. Reflection accepts these known types without changing the logical inventory; unknown values remain rejected and indexed columns, parser identity and token-size properties remain significant. PHRASE_MATCH additionally supports phrase matching; the registered layouts require term matching and do not declare a phrase-only contract. Frozen migration resources, checksums, recovery evidence and persisted layouts remain unchanged. Native 4.6.3.1 DDL was captured from a real installed 1.3.0 database; replay of this fixture does not establish complete native upgrade acceptance. The CI database image and master full automatic upgrade matrix remain unchanged. Revision v1_4_1_005 adds nullable Atomic Memory description projection metadata after the registered owner-recovery revision, through explicit SQLite and MySQL schema transitions for both source chains and all vector/Code profiles. Earlier revision resources, fingerprints and checksums remain unchanged. The additive change neither rewrites authoritative Artifact history nor generates summaries or scans stored bodies. Existing managed databases require explicit migration; existing descriptions remain null until a new write or explicit projection repair copies authoritative metadata. Intermediate projection validation uses only columns available at that revision and still rejects inconsistent existing fields. Unmanaged SDK initialization retains its nullable-column compatibility path. The installed-release acceptance matrix and publication restrictions remain required; local regression checks do not establish native upgrade acceptance. Artifact plugin storage extends the shared Server chain from v1_4_1_006 to v1_5_0_001 with registration columns, family declarations, search state and projections, index coverage and changes, and vector-space/index registrations. SQLite also registers portable vectors and FTS auxiliary structures. Native registration columns are added in one ALTER with a matching recovery checkpoint; final column definitions and constraints are unchanged. Native vector tables are created online from registered spaces and are admitted to later maintenance inventories only after exact layout validation. Historical migration resources, retained objects, and existing data are preserved. This review does not expand the supported upgrade matrix or certify installed-release acceptance. Source write plugin registrations use the additive v1_4_1_006 revision after v1_4_1_005 on both source chains. It creates only pc_source_write_plugins; all earlier master revision scripts, schema resources and checksums remain unchanged. Revision v1_5_0_002 follows v1_5_0_001 and adds retained Source evidence bindings, exact Atomic Memory evidence snapshots and their schema marker on all registered backends. A new versioned domain step verifies retained evidence for lexical and vector profiles; earlier released domain resources and recorded revision checksums remain byte-identical. Existing Sources and Memory revisions without accepted declarations stay neutral, with no trust backfill. Current projection maintenance and rebuild explicitly require evidence validation. This additive registration does not establish installed-release acceptance; the upgrade-required gate and publication restrictions are unchanged. Revision v1_5_0_003 adds the disposable exact-revision pc_atomic_memory_evidence projection through the existing index adapters. Transactional body writes publish it from accepted evidence; scoped portable restore and maintenance rebuild derive it from retained history without current-registry trust backfill or model calls. Domain conformance compares historical and derived values, including neutral legacy declarations, on lexical and vector layouts. Earlier revision resources and fingerprints remain unchanged. Local SQLite recovery and real OceanBase 4.4.2.1 evidence conformance were exercised; these do not certify installed-release upgrades or change publication restrictions. Source evidence maintenance now accepts the deployment's own Source definition registry, reports one bounded neutralization reason through operational logs, batches exact-revision reads and caches retained-schema validation per manifest fingerprint. Indexed repair accepts that registry explicitly; the migration bundle still admits evidence against the builtin registry and says so. These changes alter neither registered table layouts nor retained encodings, and the runtime and Server evidence DDL remain byte-identical. The policy remains upgrade-required with the full automatic matrix and publication restrictions described above. Source definition manifests derive every field whose default is null from the validation schema, so an already accepted Definition keeps its fingerprint and re-registers after upgrade; populating such a field requires the nested model to serialize by alias. Registered layouts and retained encodings are unchanged. The runtime MySQL-family table fast path now runs the registered additive evidence migration before creating other tables, so an unmanaged OceanBase or seekDB database cannot retain evidence tables without their p0005 marker.. Code publication recovery adds an optional per-binding publication.json journal that records a generation identifier before its database commit and final directory rename. Caches created before this journal existed remain readable without synthesizing historical publication records; identifiers for already orphaned rows are not inferred. New seekDB generations retain the existing identifier, graph descriptor, manifest and row encodings, while reclamation validates the recorded identifier and scopes deletion to its binding. Live or reader-retained generation directories and the in-flight publication are protected from journal-window reclamation. SQLite graph rows remain owned by their generation directory. This additive local cache journal changes recovery behavior but does not change registered SQL table layouts, migration resources, revision identifiers, historical checksums or the Server migration journal. The policy remains upgrade-required, and the complete installed-release acceptance matrix remains mandatory; this compatibility review is not evidence of completed native upgrade acceptance. Managed Ops admission validates a private main-file and WAL snapshot through the exact target Server bundle, retaining the original database identity and maintenance evidence. Snapshot admission is read-only and cannot initialize, migrate or recover storage. It adds no schema revision, changes no persisted row or journal format, and does not expand supported upgrade declarations or the installed-release acceptance matrix. PreparedContext Receipt records the actual FTS mode on Experience search outcomes. This adds only an in-memory observation field; it does not change stored Experience rows, SQL selection, schema registrations, migration resources, or data encodings. Unmanaged historical Server databases are admitted by required structure and domain state, then normalized before adopting an existing checkpoint. Historical Memory conversion, Scope preservation, SQLite rollback and native DDL recovery use the shared maintenance flow. Independent extension objects and harmless DDL differences do not require clean-install fingerprints. Existing immutable revision resources and the installed-release acceptance matrix remain unchanged. Local historical tests do not certify arbitrary tenant defaults, every vector/Code configuration or future releases. SQLite compatibility validation includes effective primary-key collations and rejects conflicting identity semantics. Historical normalization and the frozen s1400 rebuild preserve generated extension definitions while copying only writable columns. Both virtual and stored generated values remain computed by SQLite; immutable revision resources and the native backend paths are unchanged. SQLite table rebuilds preserve declared collations on ordinary and generated extension columns, including their inherited index comparison semantics. Expression-local COLLATE clauses remain part of their expressions and do not change the column collation. This changes only SQLite compatibility normalization; frozen revisions and native backend execution remain unchanged. Historical Memory admission recognizes lineage ownership columns and default non-merge references while rejecting unmapped payloads. Native compatibility journals record temporary Processing counter ownership before DDL and clear it after cleanup, including completed-phase recovery. SQLite extension preservation also covers the registered r1400 bridge. Frozen migration resources and the installed-release acceptance matrix remain unchanged. Native historical Atomic conversion creates nullable description metadata with the registered UTF-8 binary collation, allowing the later frozen ADD transition to recognize its completed postcondition without duplicate DDL. SQLite historical normalization and registered source bridges preserve dependent views and their triggers during same-name table replacement. Legacy rename behavior is limited to the exact staging-to-final rename statements and the original connection setting is restored; genuine table renames still update dependent references. Frozen migration resources and native backend behavior remain unchanged. SQLite admission validates required extension defaults using SQLite deterministic-expression restrictions and a non-NULL evaluation on an isolated connection. Connection-state, random and clock-dependent expressions are rejected; direct CURRENT_DATE, CURRENT_TIME and CURRENT_TIMESTAMP defaults remain supported alongside usable constant expressions. Regression coverage verifies consecutive Source writes in distinct scopes within the same transaction. Read-only verification and startup admission share this rule; supported defaults remain usable through Source write and readback. Independent extension tables remain preserved through managed upgrades and initialization recovery; missing recovery evidence and missing vector configuration still block maintenance. Registered native column additions and modifications materialize the charset and collation declared by their immutable postcondition when the SQL would otherwise inherit a historical table default. This preserves GBK-origin upgrade recovery without changing frozen resources, existing table defaults, independent extension encodings or readiness requirements. SQLite Candidate extension columns and their values survive both stages of the registered Dream rebuild, including generated columns and dependent views. Frozen source introspection projects the already-validated managed columns without altering stored extension data. Independent indexes use SQLite-rewritten definitions after genuine table renames. Native CHECK removal resolves the actual constraint name from the registered expression and enforcement semantics, including recovery after committed DDL. Frozen resources, registered revision checksums and the installed-release acceptance matrix remain unchanged. SQLite admission rejects additional NOT NULL generated columns because computed values can reject valid Source writes. Nullable virtual and stored extensions remain preserved across historical upgrades and support Source write/readback. Frozen migrations and native backend behavior are unchanged. Ownership recovery preflight derives candidate-family filters from the exact migration identities, including Prompt Candidates that do not have standalone Artifact owners. Static Scope permission previews now use the same complete internal receipt keys as bootstrap for long Scope IDs. These authorization-only changes add no stored fields, row encoding, schema, or migration registration. Static Scope preset keys retain existing short identities and use SHA-256 for oversized relationship keys. Bootstrap and read-only preview recognize both bounded keys and historical receipts, including revoked bindings. No schema or migration registration changes. Revision v1_5_0_004 follows v1_5_0_003 and registers the durable Work Ledger tables (pc_work_lanes, pc_work_items, pc_work_keys, pc_work_attempts, pc_scheduler_leases, pc_scheduler_scans, pc_runtime_members and the shared pc_rate_limit_windows) on every registered backend; it only creates new tables and leaves earlier revision scripts, schema resources and checksums unchanged. Ledger execution stays at-least-once behind a monotonic fence token while its commit is at-most-once, so a retried, superseded or cancelled attempt cannot publish a duplicate result or release another worker's logical key. This additive registration does not establish installed-release acceptance; the upgrade-required gate and publication restrictions are unchanged." }, "cases": [], "automatic_sources": { diff --git a/src/powercontext/builtin/persistence/migrations/server_resources/manifest.json b/src/powercontext/builtin/persistence/migrations/server_resources/manifest.json index 2a2bd9e229..e0eae22522 100644 --- a/src/powercontext/builtin/persistence/migrations/server_resources/manifest.json +++ b/src/powercontext/builtin/persistence/migrations/server_resources/manifest.json @@ -85,7 +85,7 @@ "357287e3bae474ca1810dd0949fe56326dff48f5adf0db851b4b4cf3cf01a308" ], "v1_5_0_004": [ - "3cf07debf9cd41cc83f1d4e99175d2079418d4ae26e92a6fb6e9814ba615df9c" + "05e19278f66734ed98cc632b5ffe860ea4fc49ae7f5de49c824b9ade1562da46" ] }, "affected_objects": [ diff --git a/src/powercontext/builtin/persistence/migrations/server_resources/schemas/mysql-v1_5_0_004.json b/src/powercontext/builtin/persistence/migrations/server_resources/schemas/mysql-v1_5_0_004.json index e926a3e797..6fd15cd034 100644 --- a/src/powercontext/builtin/persistence/migrations/server_resources/schemas/mysql-v1_5_0_004.json +++ b/src/powercontext/builtin/persistence/migrations/server_resources/schemas/mysql-v1_5_0_004.json @@ -1,7 +1,7 @@ { "format": "powercontext.mysql-state-layouts.v1", "revisions": { - "v1_5_0_002": { + "v1_5_0_004": { "statements": [ "CREATE TABLE pc_work_lanes (\n\tlane_key VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, \n\thead_sequence BIGINT, \n\tnext_sequence BIGINT NOT NULL, \n\tPRIMARY KEY (lane_key), \n\tCONSTRAINT ck_pc_work_lanes_head_positive CHECK (head_sequence IS NULL OR head_sequence > 0), \n\tCONSTRAINT ck_pc_work_lanes_next_positive CHECK (next_sequence > 0)\n)", "CREATE TABLE pc_work_items (\n\twork_id VARCHAR(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, \n\tlogical_key VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, \n\tlane_key VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, \n\tlane_sequence BIGINT NOT NULL, \n\tkind VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, \n\tpayload_version INTEGER NOT NULL, \n\tscope_id VARCHAR(256) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, \n\tpayload MEDIUMBLOB NOT NULL, \n\tstatus VARCHAR(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, \n\tavailable_at DATETIME(6) NOT NULL, \n\tlease_owner VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin, \n\tlease_fence BIGINT NOT NULL, \n\tlease_expires_at DATETIME(6), \n\tattempt_count INTEGER NOT NULL, \n\tgeneration_attempt_count INTEGER NOT NULL, \n\trecovery_generation INTEGER NOT NULL, \n\tmax_attempts INTEGER NOT NULL, \n\tcancel_requested TINYINT(1) NOT NULL, \n\tresult_code VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin, \n\tresult_payload MEDIUMBLOB, \n\terror_category VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin, \n\terror_code VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin, \n\tstate_version BIGINT NOT NULL, \n\tcreated_at DATETIME(6) NOT NULL, \n\tupdated_at DATETIME(6) NOT NULL, \n\tcompleted_at DATETIME(6), \n\tPRIMARY KEY (work_id), \n\tCONSTRAINT uq_pc_work_items_lane_sequence UNIQUE (lane_key, lane_sequence), \n\tCONSTRAINT ck_pc_work_items_status CHECK (status IN ('queued', 'running', 'retry_wait', 'cancelling', 'succeeded', 'failed', 'cancelled')), \n\tCONSTRAINT ck_pc_work_items_lane_sequence_positive CHECK (lane_sequence > 0), \n\tCONSTRAINT ck_pc_work_items_payload_version_positive CHECK (payload_version > 0), \n\tCONSTRAINT ck_pc_work_items_fence_nonnegative CHECK (lease_fence >= 0), \n\tCONSTRAINT ck_pc_work_items_attempts_nonnegative CHECK (attempt_count >= 0), \n\tCONSTRAINT ck_pc_work_items_generation_attempts_nonnegative CHECK (generation_attempt_count >= 0), \n\tCONSTRAINT ck_pc_work_items_recovery_nonnegative CHECK (recovery_generation >= 0), \n\tCONSTRAINT ck_pc_work_items_max_attempts_positive CHECK (max_attempts > 0), \n\tCONSTRAINT ck_pc_work_items_state_version_positive CHECK (state_version > 0)\n)", diff --git a/src/powercontext/builtin/persistence/migrations/server_sources/1.4.0/manifest.json b/src/powercontext/builtin/persistence/migrations/server_sources/1.4.0/manifest.json index 6a52cb43ec..e4f2a65a59 100644 --- a/src/powercontext/builtin/persistence/migrations/server_sources/1.4.0/manifest.json +++ b/src/powercontext/builtin/persistence/migrations/server_sources/1.4.0/manifest.json @@ -84,7 +84,7 @@ "357287e3bae474ca1810dd0949fe56326dff48f5adf0db851b4b4cf3cf01a308" ], "v1_5_0_004": [ - "3cf07debf9cd41cc83f1d4e99175d2079418d4ae26e92a6fb6e9814ba615df9c" + "05e19278f66734ed98cc632b5ffe860ea4fc49ae7f5de49c824b9ade1562da46" ] }, "affected_objects": [ diff --git a/tests/builtin/persistence/test_atomic_description_vector_layout.py b/tests/builtin/persistence/test_atomic_description_vector_layout.py index 534a838205..7b05f1ddaf 100644 --- a/tests/builtin/persistence/test_atomic_description_vector_layout.py +++ b/tests/builtin/persistence/test_atomic_description_vector_layout.py @@ -43,7 +43,7 @@ def test_managed_sqlite_vector_schema_includes_registered_nullable_description(t database = tmp_path / "vector.db" runner = SQLiteMigrationRunner(database, bundle, configuration=configuration) result = runner.apply(plan_id=runner.plan().plan_id, accepted=True, maintenance_confirmed=True) - assert result.revision == "v1_5_0_003" + assert result.revision == "v1_5_0_004" with closing(sqlite3.connect(database)) as connection: columns = {row[1]: row for row in connection.execute("PRAGMA table_info(pc_atomic_memory_current)")} assert columns["description"][2:4] == ("VARCHAR(200)", 0) @@ -156,11 +156,11 @@ def test_description_upgrade_preserves_registered_owner_recovery_and_evidence(tm upgraded = SQLiteMigrationRunner(database, current, configuration=configuration) plan = upgraded.plan() assert plan.source_revision == "v1_4_1_004" - assert plan.target_revision == "v1_5_0_003" - assert plan.revisions == ("v1_4_1_005", "v1_4_1_006", "v1_5_0_001", "v1_5_0_002", "v1_5_0_003") + assert plan.target_revision == "v1_5_0_004" + assert plan.revisions == ("v1_4_1_005", "v1_4_1_006", "v1_5_0_001", "v1_5_0_002", "v1_5_0_003", "v1_5_0_004") assert plan.domain_steps == ("artifact_storage", "atomic_memory_evidence", "atomic_memory_evidence_projection") assert upgraded.evidence_path.read_bytes() == original_evidence_bytes - assert upgraded.apply(plan_id=plan.plan_id, accepted=True, maintenance_confirmed=True).revision == "v1_5_0_003" + assert upgraded.apply(plan_id=plan.plan_id, accepted=True, maintenance_confirmed=True).revision == "v1_5_0_004" assert upgraded.verify().state == "ready" evidence = json.loads(upgraded.evidence_path.read_text(encoding="utf-8")) for revision, checksum in original_evidence["release_checksums"].items(): diff --git a/tests/builtin/persistence/test_full_server_migrations.py b/tests/builtin/persistence/test_full_server_migrations.py index 6a9abe288a..74af6392ae 100644 --- a/tests/builtin/persistence/test_full_server_migrations.py +++ b/tests/builtin/persistence/test_full_server_migrations.py @@ -73,7 +73,7 @@ def dump(path): def test_complete_upgrade_preserves_history_backup_and_repeated_apply(source): before = dump(source.database) plan = source.plan() - assert plan.source_revision == "s1300" and plan.target_revision == "v1_5_0_003" + assert plan.source_revision == "s1300" and plan.target_revision == "v1_5_0_004" assert plan.revisions == ( "s1400", "s1401", @@ -84,6 +84,7 @@ def test_complete_upgrade_preserves_history_backup_and_repeated_apply(source): "v1_5_0_001", "v1_5_0_002", "v1_5_0_003", + "v1_5_0_004", ) assert dump(source.database) == before result = apply(source) diff --git a/tests/builtin/persistence/test_mysql_initialization_preflight.py b/tests/builtin/persistence/test_mysql_initialization_preflight.py index 085a49aef0..89cc0162dc 100644 --- a/tests/builtin/persistence/test_mysql_initialization_preflight.py +++ b/tests/builtin/persistence/test_mysql_initialization_preflight.py @@ -253,7 +253,7 @@ def test_evidence_recovery_resumes_before_final_schema_marker(tmp_path, monkeypa before = database.read_bytes(), journal.path.read_bytes() plan = runner.plan() assert plan.state == "recovery_required" and plan.source_revision == predecessor - assert plan.revisions == ("v1_5_0_002", "v1_5_0_003") + assert plan.revisions == ("v1_5_0_002", "v1_5_0_003", "v1_5_0_004") assert (database.read_bytes(), journal.path.read_bytes()) == before assert connection.exec_driver_sql("SELECT schema_version FROM pc_atomic_memory_evidence_schema").all() == [] @@ -278,7 +278,7 @@ def execute_revision(connection, _config, _source, target, guard): monkeypatch.setattr(runner, "_upgrade", execute_revision) result = runner.apply(plan_id=plan.plan_id, accepted=True, maintenance_confirmed=True) - assert result.state == "ready" and result.revision == "v1_5_0_003" + assert result.state == "ready" and result.revision == "v1_5_0_004" assert result.backup_state == "not_required" assert runner.verify().state == "ready" assert connection.exec_driver_sql("SELECT schema_version FROM pc_atomic_memory_evidence_schema").all() == [ @@ -288,7 +288,7 @@ def execute_revision(connection, _config, _source, target, guard): assert completed is not None and completed.state == "complete" assert completed.maintenance_window_id == "interrupted-evidence-ddl" assert completed.source_revision is None and completed.backup_state == "not_required" - assert completed.last_revision == "v1_5_0_003" and completed.pending_revision is None + assert completed.last_revision == "v1_5_0_004" and completed.pending_revision is None assert completed.release_checksums == runner.checksums assert not runner.apply(plan_id=runner.plan().plan_id, accepted=True, maintenance_confirmed=True).changed diff --git a/tests/builtin/persistence/test_mysql_migration_runner.py b/tests/builtin/persistence/test_mysql_migration_runner.py index b5e27d1ec1..2894087ff8 100644 --- a/tests/builtin/persistence/test_mysql_migration_runner.py +++ b/tests/builtin/persistence/test_mysql_migration_runner.py @@ -706,7 +706,7 @@ def test_oceanbase_server_upgrade_with_separate_code_cache_preserves_history( ) assert runner.plan(backup_policy="manual").source_revision == "s1300" result = _apply(runner, policy="manual") - assert result.readiness_scope == "server" and result.revision == "v1_5_0_003" + assert result.readiness_scope == "server" and result.revision == "v1_5_0_004" assert runner.verify().state == "ready" prepare_server_storage(settings) assert not _apply(runner, policy="manual").changed @@ -837,7 +837,7 @@ def inspect_history(connection, identity, guard): ).scalar_one(), ) - assert backend.connections().run(inspect_history) == ("v1_5_0_003", "dedicated", b'{"history":"unchanged"}') + assert backend.connections().run(inspect_history) == ("v1_5_0_004", "dedicated", b'{"history":"unchanged"}') (main_path,) = backend.evidence_directory.glob("*/maintenance.json") evidence = json.loads(main_path.read_text()) @@ -847,7 +847,7 @@ def inspect_history(connection, identity, guard): for inspect in (runner.plan, runner.verify, runner.apply): with pytest.raises(MigrationError, match="checksum_conflict"): inspect() - assert backend.connections().run(inspect_history) == ("v1_5_0_003", "dedicated", b'{"history":"unchanged"}') + assert backend.connections().run(inspect_history) == ("v1_5_0_004", "dedicated", b'{"history":"unchanged"}') assert { path: path.read_bytes() for path in backend.evidence_directory.rglob("maintenance.json") } == original_evidence @@ -914,7 +914,7 @@ def inspect_history(connection, identity, guard): ).scalar_one(), ) - assert backend.connections().run(inspect_history) == ("v1_5_0_003", b'{"history":"unchanged"}') + assert backend.connections().run(inspect_history) == ("v1_5_0_004", b'{"history":"unchanged"}') def test_mysql_registered_projection_repair_preserves_authority_and_revision(backend: _BackendTarget) -> None: @@ -982,7 +982,7 @@ def inspect_content(connection, identity, guard): ).scalar_one(), ) - assert backend.connections().run(inspect_content) == ("v1_5_0_003", content, "Preserve immutable nebula evidence.") + assert backend.connections().run(inspect_content) == ("v1_5_0_004", content, "Preserve immutable nebula evidence.") @pytest.mark.parametrize("origin", ("empty", "released")) @@ -1146,7 +1146,7 @@ def test_mysql_full_server_interruption_preserves_history_and_original_maintenan _load_full_server_baseline(backend) runner = _full_server_runner(backend) original = runner.plan(backup_policy="manual") - assert original.source_revision == "s1300" and original.target_revision == "v1_5_0_003" + assert original.source_revision == "s1300" and original.target_revision == "v1_5_0_004" _interrupt_after_ddl(runner, interruption) journals = list(backend.evidence_directory.rglob("maintenance.json")) assert len(journals) == 1 @@ -1168,7 +1168,7 @@ def test_mysql_full_server_interruption_preserves_history_and_original_maintenan result = _apply(resumed, policy="manual") - assert result.readiness_scope == "server" and result.revision == "v1_5_0_003" + assert result.readiness_scope == "server" and result.revision == "v1_5_0_004" assert result.backup_state == "user_confirmed" assert result.backup_ref == interrupted["manual_ref"] assert resumed.verify().state == "ready" @@ -1248,7 +1248,7 @@ def client_active(connection: Connection | None, _identity: BackendIdentity, _gu # interruption. An arbitrary remote failure cannot be acknowledged blindly. resumed = _full_server_runner(backend, acknowledge_previous_run=token) result = _apply(resumed, policy="manual") - assert result.state == "ready" and result.readiness_scope == "server" and result.revision == "v1_5_0_003" + assert result.state == "ready" and result.readiness_scope == "server" and result.revision == "v1_5_0_004" assert result.backup_state == "user_confirmed" and result.backup_ref == interrupted["manual_ref"] assert resumed.verify().state == "ready" completed = json.loads(journals[0].read_text(encoding="utf-8")) diff --git a/tests/builtin/persistence/test_portable_storage_migration.py b/tests/builtin/persistence/test_portable_storage_migration.py index b083790f77..d016bed258 100644 --- a/tests/builtin/persistence/test_portable_storage_migration.py +++ b/tests/builtin/persistence/test_portable_storage_migration.py @@ -126,8 +126,9 @@ def test_registered_s1401_upgrade_preserves_completed_evidence_and_matches_fresh "v1_5_0_001", "v1_5_0_002", "v1_5_0_003", + "v1_5_0_004", ) - assert apply(upgraded).revision == "v1_5_0_003" + assert apply(upgraded).revision == "v1_5_0_004" assert upgraded.verify().state == "ready" assert not apply(upgraded).changed fresh = SQLiteMigrationRunner(tmp_path / "fresh.db", bundle, configuration=config) @@ -186,11 +187,11 @@ def test_source_write_plugin_upgrade_preserves_data_and_registration(tmp_path, v bundle = server_bundle(embedding_profile=embedding) upgraded = SQLiteMigrationRunner(database, bundle, configuration=config) assert upgraded.plan().revisions == ( - ("v1_4_1_005", "v1_4_1_006", "v1_5_0_001", "v1_5_0_002", "v1_5_0_003") + ("v1_4_1_005", "v1_4_1_006", "v1_5_0_001", "v1_5_0_002", "v1_5_0_003", "v1_5_0_004") if include_owner - else ("v1_4_1_004", "v1_4_1_005", "v1_4_1_006", "v1_5_0_001", "v1_5_0_002", "v1_5_0_003") + else ("v1_4_1_004", "v1_4_1_005", "v1_4_1_006", "v1_5_0_001", "v1_5_0_002", "v1_5_0_003", "v1_5_0_004") ) - assert apply(upgraded).revision == "v1_5_0_003" + assert apply(upgraded).revision == "v1_5_0_004" definition = b'{"configuration":{"id":"tickets","entry":"tickets"}}' with closing(sqlite3.connect(database)) as connection, connection: assert connection.execute("SELECT title FROM pc_scopes").fetchall() == [("preserved",)] diff --git a/tests/builtin/persistence/test_release140_server_migrations.py b/tests/builtin/persistence/test_release140_server_migrations.py index ae499d8836..c34faf9f72 100644 --- a/tests/builtin/persistence/test_release140_server_migrations.py +++ b/tests/builtin/persistence/test_release140_server_migrations.py @@ -144,6 +144,7 @@ def test_registered_sources_choose_distinct_lineages(tmp_path, release, expected "v1_5_0_001", "v1_5_0_002", "v1_5_0_003", + "v1_5_0_004", ) diff --git a/tests/e2e/test_atomic_memory_description_upgrade.py b/tests/e2e/test_atomic_memory_description_upgrade.py index bebf24b2b8..48db983ef8 100644 --- a/tests/e2e/test_atomic_memory_description_upgrade.py +++ b/tests/e2e/test_atomic_memory_description_upgrade.py @@ -85,9 +85,9 @@ def test_nonempty_released_memory_upgrades_without_rewriting_history(tmp_path, r ) result = _apply(runner) - assert result.state == "ready" and result.readiness_scope == "server" and result.revision == "v1_5_0_003" + assert result.state == "ready" and result.readiness_scope == "server" and result.revision == "v1_5_0_004" verified = runner.verify() - assert verified.state == "ready" and verified.readiness_scope == "server" and verified.revision == "v1_5_0_003" + assert verified.state == "ready" and verified.readiness_scope == "server" and verified.revision == "v1_5_0_004" assert not _apply(runner).changed with closing(sqlite3.connect(runner.database)) as connection: assert ( diff --git a/tests/native/test_oceanbase_engine_recovery.py b/tests/native/test_oceanbase_engine_recovery.py index 0a11c51009..5e9ae7da8d 100644 --- a/tests/native/test_oceanbase_engine_recovery.py +++ b/tests/native/test_oceanbase_engine_recovery.py @@ -175,7 +175,7 @@ def ping(connection, _identity, _guard): _apply(_full_server_runner(backend), policy="manual") resumed = _full_server_runner(backend, acknowledge_previous_run=token) result = _apply(resumed, policy="manual") - assert result.state == "ready" and result.revision == "v1_5_0_003" and result.readiness_scope == "server" + assert result.state == "ready" and result.revision == "v1_5_0_004" and result.readiness_scope == "server" assert result.backup_state == "user_confirmed" and result.backup_ref == interrupted["manual_ref"] assert resumed.verify().state == "ready" completed = json.loads(journal.read_text()) diff --git a/tests/native/test_personal_service_lifecycle.py b/tests/native/test_personal_service_lifecycle.py index 138cf15077..76c00512fe 100644 --- a/tests/native/test_personal_service_lifecycle.py +++ b/tests/native/test_personal_service_lifecycle.py @@ -406,7 +406,7 @@ def test_native_public_migration_switches_an_installed_130_service( if failure in {"startup", "switch"}: assert applied.exit_code == 1, applied.output assert result["error"] == "service_start_failed" - assert result["database_state"] == "ready" and result["revision"] == "v1_5_0_003" + assert result["database_state"] == "ready" and result["revision"] == "v1_5_0_004" assert adapter.manager_state() in {ManagerState.INACTIVE, ManagerState.FAILED} phase = "switching" if failure == "switch" else "start_failed" assert json.loads(controller.maintenance_path.read_text())["phase"] == phase diff --git a/tests/test_managed_atomic_maintenance.py b/tests/test_managed_atomic_maintenance.py index 450c804fba..50992d4929 100644 --- a/tests/test_managed_atomic_maintenance.py +++ b/tests/test_managed_atomic_maintenance.py @@ -76,7 +76,7 @@ def test_atomic_alias_upgrades_complete_historical_server_with_shared_plan_and_b assert inspected.exit_code == 0, inspected.output plan = json.loads(inspected.output) assert plan["source_revision"] == "s1300" - assert plan["target_revision"] == "v1_5_0_003" + assert plan["target_revision"] == "v1_5_0_004" assert plan["readiness_scope"] == "server" assert historical.read_bytes() == original @@ -103,7 +103,7 @@ def test_atomic_alias_upgrades_complete_historical_server_with_shared_plan_and_b with closing(sqlite3.connect(result["backup_ref"])) as connection: assert tuple(connection.iterdump()) == original_dump with closing(sqlite3.connect(historical)) as connection: - assert connection.execute("SELECT version_num FROM pc_schema_revision").fetchall() == [("v1_5_0_003",)] + assert connection.execute("SELECT version_num FROM pc_schema_revision").fetchall() == [("v1_5_0_004",)] assert connection.execute("SELECT content FROM pc_artifacts").fetchall() == [(b'{"history":"unchanged"}',)] verified = CliRunner().invoke(cli, [*arguments, "--action", "verify"]) assert verified.exit_code == 0, verified.output diff --git a/tests/test_managed_processing_maintenance.py b/tests/test_managed_processing_maintenance.py index 7377ea3eeb..989ca7e786 100644 --- a/tests/test_managed_processing_maintenance.py +++ b/tests/test_managed_processing_maintenance.py @@ -75,7 +75,7 @@ def test_registered_mode_switch_backs_up_preserves_revision_and_fences_old_worke assert result.backup_state == "completed" assert result.backup_ref and Path(result.backup_ref).is_file() with closing(sqlite3.connect(path)) as connection: - assert connection.execute("SELECT version_num FROM pc_schema_revision").fetchall() == [("v1_5_0_003",)] + assert connection.execute("SELECT version_num FROM pc_schema_revision").fetchall() == [("v1_5_0_004",)] assert ( json.loads(connection.execute("SELECT config_manifest FROM pc_artifact_processing_schema").fetchone()[0])[ "mode" diff --git a/tests/test_migration_revision_names.py b/tests/test_migration_revision_names.py index 89f7799b42..3de63bddb7 100644 --- a/tests/test_migration_revision_names.py +++ b/tests/test_migration_revision_names.py @@ -64,9 +64,10 @@ def invoke(action, *options): "v1_5_0_001", "v1_5_0_002", "v1_5_0_003", + "v1_5_0_004", ] assert [item["revision"] for item in plan["pending_migrations"]] == list(reviewed.revisions) - target = {"revision": "v1_5_0_003", "name": "v1_5_0_003", "software_version": "1.5.0", "sequence": 3} + target = {"revision": "v1_5_0_004", "name": "v1_5_0_004", "software_version": "1.5.0", "sequence": 4} assert plan["target_migration"] == target applied = invoke( @@ -81,7 +82,7 @@ def invoke(action, *options): ) assert applied["migration"] == target and applied["server_ready"] with closing(sqlite3.connect(runner.database)) as connection: - assert connection.execute("SELECT version_num FROM pc_schema_revision").fetchall() == [("v1_5_0_003",)] + assert connection.execute("SELECT version_num FROM pc_schema_revision").fetchall() == [("v1_5_0_004",)] evidence = runner.evidence_path.read_bytes() recorded = json.loads(evidence) assert recorded["source_revision"] == expected_source["revision"] @@ -96,6 +97,7 @@ def invoke(action, *options): "v1_5_0_001", "v1_5_0_002", "v1_5_0_003", + "v1_5_0_004", } assert runner.verify().state == "ready" assert invoke("verify")["migration"] == target diff --git a/tests/test_registered_source_maintenance_cli.py b/tests/test_registered_source_maintenance_cli.py index 5809d3781d..1a1e73264c 100644 --- a/tests/test_registered_source_maintenance_cli.py +++ b/tests/test_registered_source_maintenance_cli.py @@ -66,7 +66,7 @@ def test_release140_compatibility_commands_preserve_source_and_share_full_upgrad planned = CliRunner().invoke(cli, [*arguments, "plan", "--backup", "skip"]) assert planned.exit_code == 0, planned.output plan = json.loads(planned.output) - assert plan["source_revision"] == "r1400" and plan["target_revision"] == "v1_5_0_003" + assert plan["source_revision"] == "r1400" and plan["target_revision"] == "v1_5_0_004" assert plan["readiness_scope"] == "server" assert plan["compatibility_steps"] == [] full = CliRunner().invoke(cli, ["server", "db-migrate", "plan", "--backup", "skip"]) From da48a236bbd776c6b66a2cfa24c02595ba9eddb1 Mon Sep 17 00:00:00 2001 From: JasonDeng <15301580353@163.com> Date: Wed, 4 Nov 2026 18:41:54 +0800 Subject: [PATCH 26/45] fix(runtime): return the earliest future cron occurrence on transition days next_after() returned the first candidate that was future while walking the day by wall clock, so on a fall-back day 01:00 fold=1 (06:00Z) was accepted before 01:16 fold=0 (05:16Z): an input of 05:15Z produced 06:00Z and skipped every valid run in the first repeated hour. Collect each matching wall time's real occurrences for the day, order them by UTC instant and return the earliest one after the input. The window now starts one local day before the input so a repeated hour cannot hide an earlier occurrence. --- src/powercontext/builtin/runtime/cron.py | 31 ++++++++++++++++-------- tests/builtin/runtime/test_cron.py | 11 +++++++++ 2 files changed, 32 insertions(+), 10 deletions(-) diff --git a/src/powercontext/builtin/runtime/cron.py b/src/powercontext/builtin/runtime/cron.py index 8a53df34e6..ec0fef0784 100644 --- a/src/powercontext/builtin/runtime/cron.py +++ b/src/powercontext/builtin/runtime/cron.py @@ -59,18 +59,29 @@ def next_after(self, value: datetime, /) -> datetime: """Return the first matching instant after ``value`` as UTC-naive time.""" utc = value.replace(tzinfo=UTC) if value.tzinfo is None else value.astimezone(UTC) - start = (utc.astimezone(self.timezone) + timedelta(minutes=1)).replace(second=0, microsecond=0) - for offset in range(366 * 8): - candidate_date = start.date() + timedelta(days=offset) + # A repeated fall-back hour maps a later wall time to an earlier instant, so + # the earliest future occurrence can belong to the previous local date. + first_date = utc.astimezone(self.timezone).date() - timedelta(days=1) + for offset in range(366 * 8 + 1): + candidate_date = first_date + timedelta(days=offset) if not self._matches_date(candidate_date): continue - for hour in sorted(self.hours): - for minute in sorted(self.minutes): - naive = datetime.combine(candidate_date, time(hour, minute)) - for candidate in _local_occurrences(naive, self.timezone): - if candidate.astimezone(UTC) <= utc: - continue - return candidate.astimezone(UTC).replace(tzinfo=None) + # Order the day's occurrences by real instant, not by wall clock: on a + # transition day 01:00 fold=1 (06:00Z) precedes 01:16 fold=0 (05:16Z). + occurrences = sorted( + ( + instant + for hour in self.hours + for minute in self.minutes + for instant in _local_occurrences( + datetime.combine(candidate_date, time(hour, minute)), self.timezone + ) + ), + key=lambda instant: instant.astimezone(UTC), + ) + for candidate in occurrences: + if candidate.astimezone(UTC) > utc: + return candidate.astimezone(UTC).replace(tzinfo=None) raise ValueError("cron expression has no occurrence in the next eight years") # noqa: TRY003 def _matches_date(self, value: date) -> bool: diff --git a/tests/builtin/runtime/test_cron.py b/tests/builtin/runtime/test_cron.py index fc5629d847..1a89679a61 100644 --- a/tests/builtin/runtime/test_cron.py +++ b/tests/builtin/runtime/test_cron.py @@ -29,6 +29,17 @@ def test_next_after_stays_in_the_future_during_the_repeated_hour() -> None: assert result == datetime(2026, 11, 1, 6, 16) # noqa: DTZ001 - naive on purpose +def test_next_after_returns_the_first_repeated_hour_occurrence() -> None: + """The earlier occurrence inside the repeated hour must not be skipped.""" + + schedule = CronSchedule.parse("* * * * *", "America/New_York") + # 05:15 UTC is 01:15 EDT (fold=0); 01:16 EDT = 05:16 UTC comes first. + assert schedule.next_after(datetime(2026, 11, 1, 5, 15)) == datetime(2026, 11, 1, 5, 16) # noqa: DTZ001 - naive on purpose + # 05:00 UTC is 01:00 EDT; the earliest future minute is 01:01 EDT = 05:01 UTC, + # not 02:00 EST = 07:00 UTC and not 01:01 EST = 06:01 UTC. + assert schedule.next_after(datetime(2026, 11, 1, 5, 0)) == datetime(2026, 11, 1, 5, 1) # noqa: DTZ001 - naive on purpose + + def test_next_after_walks_every_minute_through_the_repeated_hour() -> None: """Minute-by-minute scans advance one real minute per step across the transition.""" From 5a0a1f43a44547a40b04b59418806db1e218f9d5 Mon Sep 17 00:00:00 2001 From: JasonDeng <15301580353@163.com> Date: Wed, 4 Nov 2026 18:41:59 +0800 Subject: [PATCH 27/45] fix(docker): escape the migrator's shell variables from Compose Compose interpolates the command string before the container starts, so the shell locals policy, apply_backup and plan_id were replaced with empty strings: the rendered script ran case "" in and exited 2, blocking every dependent service. Escape them as $$ and supply the backup policy through the service environment instead. The regression now executes the Compose-rendered command with a stub CLI, so it fails on the previous file and passes on this one. --- docker/compose.distributed.yaml | 16 +++--- tests/test_docker_contract.py | 92 +++++++++++++++++++++++++++++++++ 2 files changed, 102 insertions(+), 6 deletions(-) diff --git a/docker/compose.distributed.yaml b/docker/compose.distributed.yaml index 7fc88fb505..29f9f8319e 100644 --- a/docker/compose.distributed.yaml +++ b/docker/compose.distributed.yaml @@ -20,30 +20,34 @@ services: # one fixed maintenance host (--lock-coordination single-host binds it). The host directory must # be writable by the container user (uid 10001). `manual` backup is a declaration only; set # POWERCONTEXT_MIGRATION_BACKUP_POLICY=skip to explicitly accept a no-backup run. + # Container-side shell variables are escaped as `$$`; an unescaped `$policy` would be + # replaced by Compose with an empty string before the container ever runs the script. entrypoint: ["/bin/sh", "-ec"] command: - | set -eu - policy="${POWERCONTEXT_MIGRATION_BACKUP_POLICY:-manual}" - case "$policy" in + policy="$${POWERCONTEXT_MIGRATION_BACKUP_POLICY:-manual}" + case "$$policy" in manual) apply_backup="--backup manual --backup-confirmed" ;; skip) apply_backup="--backup skip --accept-no-backup" ;; *) echo "POWERCONTEXT_MIGRATION_BACKUP_POLICY must be manual or skip" >&2; exit 2 ;; esac - plan_id="$(powercontext server db-migrate plan \ + plan_id="$$(powercontext server db-migrate plan \ --evidence-dir /var/lib/powercontext/migration-state \ - --lock-coordination single-host --backup "$policy" \ + --lock-coordination single-host --backup "$$policy" \ | python3 -c 'import json,sys; print(json.load(sys.stdin)["plan_id"])')" exec powercontext server db-migrate apply \ --evidence-dir /var/lib/powercontext/migration-state \ - --lock-coordination single-host $apply_backup \ - --plan-id "$plan_id" --maintenance-confirmed --yes + --lock-coordination single-host $$apply_backup \ + --plan-id "$$plan_id" --maintenance-confirmed --yes environment: <<: *shared-environment # The migration command does not start a Runtime or register this role; # a valid distributed role is still required while parsing ServerSettings. POWERCONTEXT_SERVER_DEPLOYMENT_ROLE: api POWERCONTEXT_SERVER_DEPLOYMENT_ID: migrator + # Compose supplies the host value (default `manual`) to the script above. + POWERCONTEXT_MIGRATION_BACKUP_POLICY: ${POWERCONTEXT_MIGRATION_BACKUP_POLICY:-manual} volumes: - ${POWERCONTEXT_MIGRATION_STATE_DIR:?set a durable migration evidence directory on one fixed host}:/var/lib/powercontext/migration-state diff --git a/tests/test_docker_contract.py b/tests/test_docker_contract.py index c2926c6662..c02a982802 100644 --- a/tests/test_docker_contract.py +++ b/tests/test_docker_contract.py @@ -25,6 +25,7 @@ import os import re +import subprocess from pathlib import Path from unittest.mock import Mock @@ -105,3 +106,94 @@ def test_distributed_compose_separates_roles_and_model_credentials() -> None: assert ("POWERCONTEXT_SERVER_INFERENCE_GENERATION_MODEL" in environment) is (role == "worker") assert ("OPENAI_API_KEY" in environment) is (role == "worker") assert ("ANTHROPIC_API_KEY" in environment) is (role == "worker") + + +_COMPOSE_VARIABLE = re.compile( + r"\$\$|\$\{([A-Za-z_][A-Za-z0-9_]*)(?::([-?])([^}]*))?\}|\$([A-Za-z_][A-Za-z0-9_]*)" +) + + +def _compose_interpolate(text: str, environment: dict[str, str]) -> str: + """Apply Compose's own interpolation rules to a raw YAML string. + + ``$$`` becomes a literal ``$`` for the container, ``${NAME:-default}`` falls back to + the default, and an unset ``$NAME`` -- bare or braced -- is replaced with an empty + string: the exact behavior that turned ``case "$policy" in`` into ``case "" in``. + """ + + def replace(match: re.Match[str]) -> str: + if match.group(0) == "$$": + return "$" + name = match.group(1) or match.group(4) + operator, argument = match.group(2), match.group(3) + value = environment.get(name) + if operator == "?": + assert value, f"{name} is required by this Compose file" + return value + return value if value else (argument or "") + + return _COMPOSE_VARIABLE.sub(replace, text) + + +def _run_compose_rendered_migrator(tmp_path: Path, environment: dict[str, str]) -> list[str]: + """Render the migrator command the way Compose does, then execute it with a stub CLI.""" + + document = yaml.safe_load(_DISTRIBUTED_COMPOSE.read_text(encoding="utf-8")) + (rendered,) = document["services"]["migrate"]["command"] + # Compose interpolates the YAML string before the container starts; the container's + # shell then sees the resolved script. + rendered = _compose_interpolate(rendered, environment) + + bin_directory = tmp_path / "bin" + bin_directory.mkdir() + recorded = tmp_path / "apply-arguments.txt" + stub = bin_directory / "powercontext" + stub.write_text( + "#!/bin/sh\n" + 'if [ "$1 $2 $3" = "server db-migrate plan" ]; then\n' + ' echo \'{"plan_id": "plan-abc"}\'\n' + " exit 0\n" + "fi\n" + f'echo "$@" > "{recorded}"\n', + encoding="utf-8", + ) + stub.chmod(0o755) + + result = subprocess.run( # noqa: S603 - fixed argument list, local stub binary. + ["/bin/sh", "-ec", rendered], + capture_output=True, + text=True, + env={ + **environment, + "PATH": f"{bin_directory}{os.pathsep}{os.environ.get('PATH', '')}", + }, + check=False, + ) + assert result.returncode == 0, result.stderr + return recorded.read_text(encoding="utf-8").split() + + +def test_compose_rendered_migrator_runs_with_the_default_backup_policy(tmp_path: Path) -> None: + """Compose must not blank out the script's own shell variables.""" + + # Every deployment variable is present; the shell bookkeeping variables are not host variables. + arguments = _run_compose_rendered_migrator( + tmp_path, + {"POWERCONTEXT_MIGRATION_STATE_DIR": "/tmp/migration-state"}, + ) + assert arguments[:3] == ["server", "db-migrate", "apply"] + assert arguments[arguments.index("--plan-id") + 1] == "plan-abc" + assert arguments[arguments.index("--backup") + 1] == "manual" + assert "--backup-confirmed" in arguments + + +def test_compose_rendered_migrator_honors_the_skip_backup_policy(tmp_path: Path) -> None: + arguments = _run_compose_rendered_migrator( + tmp_path, + { + "POWERCONTEXT_MIGRATION_STATE_DIR": "/tmp/migration-state", + "POWERCONTEXT_MIGRATION_BACKUP_POLICY": "skip", + }, + ) + assert arguments[arguments.index("--backup") + 1] == "skip" + assert "--accept-no-backup" in arguments From 3c03a47b72337eafdc934e1a064757b758c09f99 Mon Sep 17 00:00:00 2001 From: JasonDeng <15301580353@163.com> Date: Wed, 4 Nov 2026 18:41:59 +0800 Subject: [PATCH 28/45] fix(runtime): pin Memory preparation to the persisted window MemoryWorkHandler.prepare passed only the scope and the worker limit, so prepare_window re-read the live high watermark: a Source captured between discovery and execution was absorbed into the claim (current_cursor=2, high_watermark=2) even though the Work record described after=0, through=1. Thread the persisted (after, through) boundaries through prepare_memory into the processor. The trigger receives them as its own bounded signal, and a cursor that already left the window raises a conflict instead of publishing a different input than the claim reports. --- .../runtime/atomic_memory_processing.py | 26 +++++++-- .../builtin/runtime/relational.py | 8 ++- .../builtin/runtime/work_handlers.py | 9 +++- tests/builtin/runtime/test_memory_work.py | 53 +++++++++++++++++++ 4 files changed, 90 insertions(+), 6 deletions(-) diff --git a/src/powercontext/builtin/runtime/atomic_memory_processing.py b/src/powercontext/builtin/runtime/atomic_memory_processing.py index 28c2c97ced..d8f74e6cbf 100644 --- a/src/powercontext/builtin/runtime/atomic_memory_processing.py +++ b/src/powercontext/builtin/runtime/atomic_memory_processing.py @@ -357,9 +357,14 @@ async def prepare_window( limit: int, *, context: ArtifactSearchExecutionContext | None = None, + window: tuple[int, int] | None = None, ) -> PreparedMemoryWindow | MemoryFlushResult: """Prepare one Source window's decisions without publishing. + ``window`` pins the logical input to persisted boundaries ``(after, through)`` + instead of re-reading the live high watermark, so a retried or delayed claim + processes exactly the rows its Work record describes. + Returns a ``PreparedMemoryWindow`` whose ``publish`` commits the Memory artifacts and advances the cursor on the caller's open transaction, or a ``MemoryFlushResult`` when the trigger has no window to consume. The @@ -377,8 +382,8 @@ async def prepare_window( self.prompts.bind(scope_id, "atomic_memory.extract"), self.prompts.bind(scope_id, "atomic_memory.reconcile"), ): - return await self._prepare_window(scope_id, limit, None, selected_context, span) - return await self._prepare_window(scope_id, limit, None, selected_context, span) + return await self._prepare_window(scope_id, limit, None, selected_context, span, window) + return await self._prepare_window(scope_id, limit, None, selected_context, span, window) async def _retry_window(self, scope_id, limit, processing, context, span) -> MemoryFlushResult: for attempt in range(3): @@ -407,9 +412,14 @@ async def _flush_once(self, scope_id, limit, processing, context, span) -> Memor prepared.emit_finalized() return result - async def _prepare_window(self, scope_id, limit, processing, context, span): + async def _prepare_window(self, scope_id, limit, processing, context, span, window=None): """Select one Source window and prepare its decisions without publishing. + ``window`` pins the logical input to the persisted ``(after, through)`` + boundaries. Preparing the live window instead would silently absorb Sources + that arrived after the Work record was written, so the claim would process a + different input than the one it reports. + Returns a ``PreparedMemoryWindow`` whose ``publish`` commits the Memory artifacts and advances the cursor on the caller's transaction, or a ``MemoryFlushResult`` when the trigger has no window to consume. @@ -423,7 +433,15 @@ async def _prepare_window(self, scope_id, limit, processing, context, span): await application.security.authorize(connection, scope_id, context, "create") cursor = await self.cursors.load(connection, scope_id, SOURCE_WINDOW_TRIGGER_NAME) state = trigger.initial_state() if cursor is None else cursor.cursor - high_watermark, limit = await self.windows.signal(connection, scope_id, state.sequence, limit) + if window is None: + high_watermark, limit = await self.windows.signal(connection, scope_id, state.sequence, limit) + else: + after, through = window + if state.sequence != after or through <= after: + raise AtomicMemoryConflictError("The Source cursor moved before window preparation") + # Express the persisted window as the trigger's own bounded signal: + # sequence=through with limit=through-after activates exactly this span. + high_watermark, limit = through, through - after signal = SourceHighWatermark(sequence=high_watermark, limit=limit) transition = trigger.activate(signal, state) if not transition.actions: diff --git a/src/powercontext/builtin/runtime/relational.py b/src/powercontext/builtin/runtime/relational.py index 787e85812c..ad53fdb78c 100644 --- a/src/powercontext/builtin/runtime/relational.py +++ b/src/powercontext/builtin/runtime/relational.py @@ -1574,9 +1574,13 @@ async def prepare_memory( /, *, atomic_context: ArtifactSearchExecutionContext | None = None, + window: tuple[int, int] | None = None, ) -> "PreparedMemoryWindow | MemoryFlushResult": """Prepare one Memory window's decisions without publishing it. + ``window`` pins the logical input to persisted ``(after, through)`` boundaries + so a retried or delayed claim processes the rows its Work record describes. + The returned window's ``publish`` commits the Memory artifacts and advances the Source cursor on the caller's open transaction, so the Work Ledger handler can run it inside the fenced Work completion @@ -1587,7 +1591,7 @@ async def prepare_memory( services=services, lock=self._state_for(services.scope_id).scope_locks.setdefault("activation", asyncio.Lock()), tracing=self._tracing, - ).prepare(limit=limit, atomic_context=atomic_context) + ).prepare(limit=limit, atomic_context=atomic_context, window=window) async def incubate_experience( self, @@ -1857,6 +1861,7 @@ async def prepare( *, limit: int, atomic_context: ArtifactSearchExecutionContext | None = None, + window: tuple[int, int] | None = None, ) -> "PreparedMemoryWindow | MemoryFlushResult": """Prepare one window's decisions while holding the activation lock. @@ -1869,6 +1874,7 @@ async def prepare( self._services.scope_id, limit, context=atomic_context, + window=window, ) def _stage( diff --git a/src/powercontext/builtin/runtime/work_handlers.py b/src/powercontext/builtin/runtime/work_handlers.py index b9910fde56..4b8aabddd8 100644 --- a/src/powercontext/builtin/runtime/work_handlers.py +++ b/src/powercontext/builtin/runtime/work_handlers.py @@ -315,7 +315,14 @@ async def prepare(self, claim: WorkClaim, /) -> PreparedWork: ) ) _require_exact_cursor(payload, current_sequence, current_generation) - prepared = await self._contexts.prepare_memory(claim.scope_id, self._window_limit) + # Pin preparation to the persisted window: re-reading the live high watermark + # would absorb Sources that arrived after this Work record was written, so the + # claim would publish rows outside the window it reports. + prepared = await self._contexts.prepare_memory( + claim.scope_id, + self._window_limit, + window=(payload.after, payload.through), + ) if isinstance(prepared, MemoryFlushResult): # The trigger selected no window; there is nothing to publish and # nothing the ledger fence could discard. diff --git a/tests/builtin/runtime/test_memory_work.py b/tests/builtin/runtime/test_memory_work.py index ebb7fd7af0..53508adf12 100644 --- a/tests/builtin/runtime/test_memory_work.py +++ b/tests/builtin/runtime/test_memory_work.py @@ -18,6 +18,7 @@ import pytest +from powercontext.builtin.artifacts.atomic_memory.errors import AtomicMemoryConflictError from powercontext.builtin.persistence.sqlite import SQLiteConfig from powercontext.builtin.persistence.work import StaleWorkClaimError, WorkRepository from powercontext.builtin.runtime import BuiltinConfig, open_builtin_contexts @@ -202,3 +203,55 @@ async def fake_prepare_memory(scope_id: str, limit: int, **_: object) -> MemoryF assert prepared.result.payload["current_cursor"] == 1 asyncio.run(scenario()) + + +def test_memory_work_handler_pins_preparation_to_the_persisted_window(tmp_path, monkeypatch) -> None: + """A Source captured after the Work record must stay outside this attempt's window.""" + + async def scenario() -> None: + async with open_builtin_contexts(_config(tmp_path)) as contexts: + context = await contexts.get("project") + await context.sources.capture(ContentCapture(source_id="one", content="remember this")) + spec = await memory_work_spec(contexts, "project", limit=100, max_attempts=3, payload_version=1) + assert spec is not None + assert (spec.payload["after"], spec.payload["through"]) == (0, 1) + claim = await _claim(contexts, spec) + + # The second Source lands between discovery and execution. Preparing the + # live window would absorb it into this claim and report high_watermark=2. + await context.sources.capture(ContentCapture(source_id="two", content="arrived later")) + + requested: dict[str, object] = {} + + async def fake_prepare_memory(scope_id: str, limit: int, *, window=None, **_: object): + requested["window"] = window + return _StubPreparedWindow(contexts, []) + + monkeypatch.setattr(contexts, "prepare_memory", fake_prepare_memory) + prepared = await MemoryWorkHandler(contexts, window_limit=100).prepare(claim) + + assert prepared.commit is not None + # The persisted boundaries, not the live high watermark (0, 2). + assert requested["window"] == (0, 1) + + asyncio.run(scenario()) + + +def test_prepare_memory_refuses_a_window_the_cursor_already_left(tmp_path) -> None: + """A claim whose persisted window no longer matches the cursor must not publish.""" + + async def scenario() -> None: + async with open_builtin_contexts(_config(tmp_path)) as contexts: + await contexts.get("project") + async with contexts.database.transaction() as connection: + await contexts.repositories.cursors.save( + connection, + "project", + SOURCE_WINDOW_TRIGGER_NAME, + SourceCursor(sequence=1), + expected_generation=None, + ) + with pytest.raises(AtomicMemoryConflictError): + await contexts.prepare_memory("project", 100, window=(0, 1)) + + asyncio.run(scenario()) From fad4d4641f6c3c054409e9c1e897d2f86379a0ff Mon Sep 17 00:00:00 2001 From: JasonDeng <15301580353@163.com> Date: Wed, 4 Nov 2026 18:42:03 +0800 Subject: [PATCH 29/45] docs(operate): use the Server-prefixed generation model variable The distributed deployment snippet exported POWERCONTEXT_GENERATION_MODEL, which the Server never reads; the Compose template and the configuration reference both use POWERCONTEXT_SERVER_INFERENCE_GENERATION_MODEL. --- docs/en/docs/operate/deploy-server.md | 2 +- docs/zh/docs/operate/deploy-server.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/en/docs/operate/deploy-server.md b/docs/en/docs/operate/deploy-server.md index d8f55df4b3..f86640d40d 100644 --- a/docs/en/docs/operate/deploy-server.md +++ b/docs/en/docs/operate/deploy-server.md @@ -289,7 +289,7 @@ Export secrets and deployment choices without writing them into Compose: ```bash export POWERCONTEXT_SERVER_DATABASE_URL="$OCEANBASE_URL" export POWERCONTEXT_SERVER_AUTH_TOKEN="$POWERCONTEXT_DEPLOYMENT_TOKEN" -export POWERCONTEXT_GENERATION_MODEL="openai:gpt-4.1-mini" +export POWERCONTEXT_SERVER_INFERENCE_GENERATION_MODEL="openai:gpt-4.1-mini" export OPENAI_API_KEY docker compose --file docker/compose.distributed.yaml up migrate docker compose --file docker/compose.distributed.yaml up -d api-a api-b scheduler-a scheduler-b worker-a worker-b diff --git a/docs/zh/docs/operate/deploy-server.md b/docs/zh/docs/operate/deploy-server.md index 0bc00afd83..a5f114d636 100644 --- a/docs/zh/docs/operate/deploy-server.md +++ b/docs/zh/docs/operate/deploy-server.md @@ -266,7 +266,7 @@ Supervisor 的 role 必须保持为 `all`。 ```bash export POWERCONTEXT_SERVER_DATABASE_URL="$OCEANBASE_URL" export POWERCONTEXT_SERVER_AUTH_TOKEN="$POWERCONTEXT_DEPLOYMENT_TOKEN" -export POWERCONTEXT_GENERATION_MODEL="openai:gpt-4.1-mini" +export POWERCONTEXT_SERVER_INFERENCE_GENERATION_MODEL="openai:gpt-4.1-mini" export OPENAI_API_KEY docker compose --file docker/compose.distributed.yaml up migrate docker compose --file docker/compose.distributed.yaml up -d api-a api-b scheduler-a scheduler-b worker-a worker-b From 6e7bac2f28035b8e4372ef12638ea5b6c64efe23 Mon Sep 17 00:00:00 2001 From: JasonDeng <15301580353@163.com> Date: Wed, 4 Nov 2026 18:42:10 +0800 Subject: [PATCH 30/45] docs(desktop): keep this branch's protocol qualification records The merge of master replaced VALIDATION.md wholesale; restore the cf81c1a8, d9f33be3 and 4e8b30aa sections so every record in compatibility.json keeps its evidence anchor. A record for the merged contract (b4d241e6...) follows. --- desktop/VALIDATION.md | 109 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 108 insertions(+), 1 deletion(-) diff --git a/desktop/VALIDATION.md b/desktop/VALIDATION.md index af7256fbfc..ec566d76c2 100644 --- a/desktop/VALIDATION.md +++ b/desktop/VALIDATION.md @@ -187,6 +187,113 @@ Selecting a compatibility profile does not attest the remote binary identity. Ex ### Server protocol qualification records +#### Server cf81c1a8 protocol qualification + +`sqlite-atomic-cf81c1a8-v1` qualifies Server sources at +`cf81c1a8a9c40c1fe186944ba6e334b54aa5da70` for the bundled OpenAPI digest +`c7f00e81a12848249c7105ba99c444b6233f4b2a4849d1d4443c419f53ac764f`, the contract that adds the four +Scope-scoped durable operation endpoints. The qualification wheel is +`powercontext-1.2.1.dev414+gcf81c1a8a-py3-none-any.whl`, SHA-256 +`0530ed69045ed45be3048aafd9256710d1e1ef4b234858b6314513556ff2b904`, built from that clean commit. +All 632 packaged runtime files in `powercontext/` and `powercontext_service_bootstrap/` match the +source bytes at that commit. The runtime manifest digest is +`8f591bfb56ed302481297a2b02f06d1cb4f381eb15090a603a1322c6f72d1e93` +(sorted `wheel/path sha256` lines, with a final newline). + +On 2026-10-30, macOS 27.2 ARM64 with Python 3.14.7 and Rust 1.95.0 ran the existing +`desktop/tests/real_server.py` harness against the extracted wheel and independent SQLite Server +processes. All four modes passed: anonymous loopback, static Bearer, HTTPS with an explicit CA and +base path, and an injected provider with enforced access. The eleven recorded operations cover +Atomic Memory save/search/exact revision reads, Scope selection and pagination, identity changes, +same-identity revocation, committed writes with lost responses without replay, server discovery, +and Server survival after client exit. The report records the source commit above and +`desktopWorkingTreeDirty=false`. + +This is protocol qualification of the recorded source, contract and wheel. It does not qualify +Windows/Intel execution, native credential persistence, installed GUI workflows, signing or release +readiness, and it says nothing about the four new operation endpoints: they are Server-side contract +covered by the Python suite and are deliberately absent from the Desktop operation set above. +Reproduce from this clean commit with the existing wheel build, native `server_probe` build, +`fixture_qualification.py --server-commit cf81c1a8a9c40c1fe186944ba6e334b54aa5da70`, +and `real_server.py` commands recorded under [Server 4ffdf4e9 protocol qualification](#server-4ffdf4e9-protocol-qualification). +The bundled preview resolves its qualification by digest, so a contract change and a matching record +have to land together. `tests/test_desktop_contract.py` fails the Python gate for a digest without a +record, exactly as `bundled_qualification_allows_explicit_scope_browsing` fails the native one. + + + +#### Server d9f33be3 protocol qualification + +`sqlite-atomic-d9f33be3-v1` qualifies Server sources at +`d9f33be3fde7a1801b51aa66c51d25af5b883d69` for the bundled OpenAPI digest +`3a89f4131cf822d458a55e9ea4c57e1314aaffbaff764fd4f4935c7fc9a5f3cb`, the merged contract that carries +master's 1.5.0 artifact-storage chain plus the four Scope-scoped durable operation endpoints. The +qualification wheel is `powercontext-1.2.1.dev498+gd9f33be3f-py3-none-any.whl`, SHA-256 +`c23438d67cb79ee6e23d58307917853167d2f4dd1916138b35f2ceb169d48202`, built from that clean commit. +All 685 packaged runtime files in `powercontext/` and `powercontext_service_bootstrap/` match the +source bytes at that commit. The runtime manifest digest is +`d81c6ed801f5d30686b7e7565ccc16a145af90dbf19b6788334a16d305a45cf3` +(sorted `wheel/path sha256` lines, with a final newline). + +On 2026-11-02, macOS 27.2 ARM64 with Python 3.14.7 and Rust 1.95.0 ran the existing +`desktop/tests/real_server.py` harness against the extracted wheel and independent SQLite Server +processes. All four modes passed: anonymous loopback, static Bearer, HTTPS with an explicit CA and +base path, and an injected provider with enforced access. The eleven recorded operations cover +Atomic Memory save/search/exact revision reads, Scope selection and pagination, identity changes, +same-identity revocation, committed writes with lost responses without replay, server discovery, +and Server survival after client exit. The report records the source commit above and +`desktopWorkingTreeDirty=false`. + +This is protocol qualification of the recorded source, contract and wheel. It does not qualify +Windows/Intel execution, native credential persistence, installed GUI workflows, signing or release +readiness, and it says nothing about the four new operation endpoints: they are Server-side contract +covered by the Python suite and are deliberately absent from the Desktop operation set above. +Reproduce from this clean commit with `uv build --wheel --out-dir desktop/.artifacts/server-wheel`, +`cargo build --locked --example server_probe --example diagnostic_cli_probe`, +`uv run --no-sync python desktop/tests/fixture_qualification.py --server-commit d9f33be3fde7a1801b51aa66c51d25af5b883d69`, +and `uv run --no-sync python desktop/tests/real_server.py`. +The bundled preview resolves its qualification by digest, so a contract change and a matching record +have to land together. `tests/test_desktop_contract.py` fails the Python gate for a digest without a +record, exactly as `bundled_qualification_allows_explicit_scope_browsing` fails the native one. + + +#### Server 4e8b30aa protocol qualification + +`sqlite-atomic-4e8b30aa-v1` qualifies Server sources at +`4e8b30aaa5873cf8f7bca636d2f8eb5f76c17a6e` for the bundled OpenAPI digest +`741791d3eddfaf835d8516f955ebe7f6bde49c9894b93d0263e0d0fbf7eb7e12`, the merged contract that carries +master's 1.5.0 artifact-storage chain, the private Unix socket and inference privacy work, and the +four Scope-scoped durable operation endpoints. The qualification wheel is +`powercontext-1.2.1.dev517+g4e8b30aaa-py3-none-any.whl`, SHA-256 +`90ccd36193b3c4b91d0fca151413694385229d720e55f9f34653328b3955ff8f`, built from that clean commit. +All 696 packaged runtime files in `powercontext/` and `powercontext_service_bootstrap/` match the +source bytes at that commit. The runtime manifest digest is +`6a88c0b314c0ea5e1a31fdadb5d0f6a5583a73eb26eff6bd5fd1b6e780dc06f9` +(sorted `wheel/path sha256` lines, with a final newline). + +On 2026-11-04, macOS 27.2 ARM64 with Python 3.14.7 and Rust 1.68.1 ran the existing +`desktop/tests/real_server.py` harness against the extracted wheel and independent SQLite Server +processes. All four modes passed: anonymous loopback, static Bearer, HTTPS with an explicit CA and +base path, and an injected provider with enforced access. The eleven recorded operations cover +Atomic Memory save/search/exact revision reads, Scope selection and pagination, identity changes, +same-identity revocation, committed writes with lost responses without replay, server discovery, +and Server survival after client exit. The report records the source commit above and +`desktopWorkingTreeDirty=false`. Native probe medians (nearest rank, 20 samples): loopback-anonymous +save 19.16 ms / search 15.84 ms / exact read 13.48 ms; loopback-bearer 34.53 / 25.68 / 24.36 ms; +HTTPS bearer with base path 33.62 / 24.77 / 23.65 ms; injected provider 36.75 / 24.77 / 23.71 ms. + +This is protocol qualification of the recorded source, contract and wheel. It does not qualify +Windows/Intel execution, native credential persistence, installed GUI workflows, signing or release +readiness, and it says nothing about the four new operation endpoints: they are Server-side contract +covered by the Python suite and are deliberately absent from the Desktop operation set above. +Reproduce from this clean commit with `uv build --wheel --out-dir desktop/.artifacts/server-wheel`, +`cargo build --locked --example server_probe --example diagnostic_cli_probe`, +`uv run --no-sync python desktop/tests/fixture_qualification.py --server-commit 4e8b30aaa5873cf8f7bca636d2f8eb5f76c17a6e`, +and `uv run --no-sync python desktop/tests/real_server.py`. +The bundled preview resolves its qualification by digest, so a contract change and a matching record +have to land together. `tests/test_desktop_contract.py` fails the Python gate for a digest without a +record, exactly as `bundled_qualification_allows_explicit_scope_browsing` fails the native one. + #### Server 112db11e protocol qualification `sqlite-atomic-112db11e-v1` qualifies Server source @@ -1557,4 +1664,4 @@ headers, and the CI fixture profile was supplied through `OUT_DIR`. The local ha retained the 180-second fixture startup allowance; protocol assertions and product timeouts were unchanged. This qualifies SQLite protocol behavior for this snapshot and wheel, not Windows/macOS execution, native credential persistence, installed UI or release readiness. -Historical qualification records retain their identities and evidence. +Historical qualification records retain their identities and evidence. \ No newline at end of file From 3c37a15727dd1484e9c6d058cba967b5272788d2 Mon Sep 17 00:00:00 2001 From: JasonDeng <15301580353@163.com> Date: Wed, 4 Nov 2026 18:43:55 +0800 Subject: [PATCH 31/45] style: apply ruff formatting to the review fixes --- .../builtin/runtime/atomic_memory_processing.py | 12 ++++-------- src/powercontext/builtin/runtime/relational.py | 4 ++-- tests/test_docker_contract.py | 10 ++++------ 3 files changed, 10 insertions(+), 16 deletions(-) diff --git a/src/powercontext/builtin/runtime/atomic_memory_processing.py b/src/powercontext/builtin/runtime/atomic_memory_processing.py index d8f74e6cbf..f21e9b6c77 100644 --- a/src/powercontext/builtin/runtime/atomic_memory_processing.py +++ b/src/powercontext/builtin/runtime/atomic_memory_processing.py @@ -142,7 +142,7 @@ class PreparedMemoryWindow: def __init__( self, - processor: "AtomicMemorySourceWindowProcessor", + processor: AtomicMemorySourceWindowProcessor, *, scope_id: str, context: ArtifactSearchExecutionContext | None, @@ -204,9 +204,7 @@ async def publish(self, connection, processing) -> MemoryFlushResult: "memory.commit", { "powercontext.memory.commit.memory_changed": bool(writes), - "powercontext.memory.commit.artifact_revision_count": sum( - write.draft is not None for write in writes - ), + "powercontext.memory.commit.artifact_revision_count": sum(write.draft is not None for write in writes), }, ): # Every Atomic write uses journal -> heads. Source capture's journal @@ -262,9 +260,7 @@ async def publish(self, connection, processing) -> MemoryFlushResult: source_read_set = await processor.sources.preload( connection, scope_id, - tuple( - source for write in writes if write.draft is not None for source in write.draft.sources - ), + tuple(source for write in writes if write.draft is not None for source in write.draft.sources), known=current_window, ) results = await application.service.commit_window( @@ -438,7 +434,7 @@ async def _prepare_window(self, scope_id, limit, processing, context, span, wind else: after, through = window if state.sequence != after or through <= after: - raise AtomicMemoryConflictError("The Source cursor moved before window preparation") + raise AtomicMemoryConflictError("The Source cursor moved before window preparation") # noqa: TRY003 # Express the persisted window as the trigger's own bounded signal: # sequence=through with limit=through-after activates exactly this span. high_watermark, limit = through, through - after diff --git a/src/powercontext/builtin/runtime/relational.py b/src/powercontext/builtin/runtime/relational.py index ad53fdb78c..3d2bb41c3b 100644 --- a/src/powercontext/builtin/runtime/relational.py +++ b/src/powercontext/builtin/runtime/relational.py @@ -1575,7 +1575,7 @@ async def prepare_memory( *, atomic_context: ArtifactSearchExecutionContext | None = None, window: tuple[int, int] | None = None, - ) -> "PreparedMemoryWindow | MemoryFlushResult": + ) -> PreparedMemoryWindow | MemoryFlushResult: """Prepare one Memory window's decisions without publishing it. ``window`` pins the logical input to persisted ``(after, through)`` boundaries @@ -1862,7 +1862,7 @@ async def prepare( limit: int, atomic_context: ArtifactSearchExecutionContext | None = None, window: tuple[int, int] | None = None, - ) -> "PreparedMemoryWindow | MemoryFlushResult": + ) -> PreparedMemoryWindow | MemoryFlushResult: """Prepare one window's decisions while holding the activation lock. The lock is released before the caller publishes, so the fenced Work diff --git a/tests/test_docker_contract.py b/tests/test_docker_contract.py index c02a982802..ff5f44b722 100644 --- a/tests/test_docker_contract.py +++ b/tests/test_docker_contract.py @@ -108,9 +108,7 @@ def test_distributed_compose_separates_roles_and_model_credentials() -> None: assert ("ANTHROPIC_API_KEY" in environment) is (role == "worker") -_COMPOSE_VARIABLE = re.compile( - r"\$\$|\$\{([A-Za-z_][A-Za-z0-9_]*)(?::([-?])([^}]*))?\}|\$([A-Za-z_][A-Za-z0-9_]*)" -) +_COMPOSE_VARIABLE = re.compile(r"\$\$|\$\{([A-Za-z_][A-Za-z0-9_]*)(?::([-?])([^}]*))?\}|\$([A-Za-z_][A-Za-z0-9_]*)") def _compose_interpolate(text: str, environment: dict[str, str]) -> str: @@ -159,7 +157,7 @@ def _run_compose_rendered_migrator(tmp_path: Path, environment: dict[str, str]) ) stub.chmod(0o755) - result = subprocess.run( # noqa: S603 - fixed argument list, local stub binary. + result = subprocess.run( ["/bin/sh", "-ec", rendered], capture_output=True, text=True, @@ -179,7 +177,7 @@ def test_compose_rendered_migrator_runs_with_the_default_backup_policy(tmp_path: # Every deployment variable is present; the shell bookkeeping variables are not host variables. arguments = _run_compose_rendered_migrator( tmp_path, - {"POWERCONTEXT_MIGRATION_STATE_DIR": "/tmp/migration-state"}, + {"POWERCONTEXT_MIGRATION_STATE_DIR": str(tmp_path / "migration-state")}, ) assert arguments[:3] == ["server", "db-migrate", "apply"] assert arguments[arguments.index("--plan-id") + 1] == "plan-abc" @@ -191,7 +189,7 @@ def test_compose_rendered_migrator_honors_the_skip_backup_policy(tmp_path: Path) arguments = _run_compose_rendered_migrator( tmp_path, { - "POWERCONTEXT_MIGRATION_STATE_DIR": "/tmp/migration-state", + "POWERCONTEXT_MIGRATION_STATE_DIR": str(tmp_path / "migration-state"), "POWERCONTEXT_MIGRATION_BACKUP_POLICY": "skip", }, ) From deda9e169cc541e1d610207b450eea9a440e2faa Mon Sep 17 00:00:00 2001 From: JasonDeng <15301580353@163.com> Date: Wed, 4 Nov 2026 18:54:17 +0800 Subject: [PATCH 32/45] fix(migrations): back up the Work Ledger tables with the rest of the schema The maintenance backup scope (affected_objects) still listed only the tables released before v1_5_0_004, so a backup taken by the new revision would have missed all eight Work Ledger tables. Register them in both bundled manifests and refresh the schema review digest and rationale. --- release/upgrade-support.json | 4 ++-- .../migrations/server_resources/manifest.json | 10 +++++++++- .../migrations/server_sources/1.4.0/manifest.json | 10 +++++++++- 3 files changed, 20 insertions(+), 4 deletions(-) diff --git a/release/upgrade-support.json b/release/upgrade-support.json index 59c9272785..7cec6f4e5e 100644 --- a/release/upgrade-support.json +++ b/release/upgrade-support.json @@ -3,8 +3,8 @@ "target_version": "1.5.0", "mode": "upgrade-required", "schema_review": { - "persistence_sha256": "d18b43d49d1078b8989a8e9dcc984b6220a619fdcef2d7cf8fbf90993bb951f5", - "rationale": "The reusable Server migration framework registers actual PowerContext 1.3.0 through s1300 -> s1400 -> s1401 -> s1402 and actual 1.4.0 through the separate immutable r1400 -> s1400 -> s1401 -> s1402 source bridge. Original 1.3 resources and checksums remain byte-identical. Real 1.4.0 SQLite, embedded seekDB and OceanBase structures were captured from the official wheel in all twelve vector/Code configurations; provenance and exact source layouts are packaged with the bridge. The 1.4 bridge preserves current Candidate, Artifact and task data, adds the missing Topic budget/block structure, revokes old lease fences and joins the existing archive/portable-storage path. Empty retained tables align the final schema without inventing 1.3 Candidate history. Source histories retain their identity through subsequent upgrades and recovery; shared head names never authorize adopting unknown layouts. Vector profiles remain bound to historical model, profile ID, dimension, unit normalization and L2 distance, with no provider calls during maintenance. Complete schema, domain, projection, profile and retained-history checks precede startup and partial maintenance. Local SQLite and embedded seekDB startup reject an observable active maintenance lock through a read-only probe. SQLite runtime admission validates the default adjacent journal, rejecting unfinished full or partial maintenance, changed recorded revision checksums and missing required retained tables. Absent journals do not invent retention obligations; custom evidence directories require explicit operator verification and remote coordinator locks remain subject to deployment orchestration. Native read-only readiness uses a consistent snapshot and does not acquire Atomic Memory head locks; normal business transaction isolation is unchanged. Offline projection repairs retain the original locked state comparison and write-side CAS protection. Target locks, backup choices, original recovery evidence and explicit remote acknowledgments remain enforced. The installed-artifact catalog retains twelve 1.3.0 source/backend/vector/Code combinations. Embedded seekDB uses pylibseekdb 1.4.0.post2 or newer with the recovered-transaction lock fix. Main and publication require all twelve configurations, including embedded seekDB full-server-code and full-server-vector-code. No configuration is waived; incomplete, failed or skipped evidence rejects acceptance. The seekdb-code diagnostic selection cannot authorize publication. The registered 1.4.0 bridge remains implemented but is outside the CI upgrade acceptance matrix; passing this gate does not certify that path. The gate preserves source-specific Processing counters and isolates OceanBase source databases. Any failed, skipped or missing automatic case rejects publication. The policy remains upgrade-required until the exact target artifact passes the required automatic matrix and the reviewed support declaration is updated. Captured source structure, local tests and checkpoint replay are distinct from installed-release acceptance, native interruption/observer recovery and backup restoration. The Candidate generation prototype observes actual SQLite/seekDB/OceanBase migration checkpoints outside the production bundle; it does not replace frozen registrations or weaken recovery checks. Existing target schema and data formats, including batched persistence interfaces, remain governed by the shared registered path. The synchronized master adds blank-text validation at Source creation and managed worker logging. It changes neither registered table layout nor migration data encodings; existing stored Source values are not rewritten. The source bridges and frozen history remain unchanged. Compatibility commands identify Server-owned storage before opening a writable legacy profile and delegate source admission to the shared framework, including actual 1.4.0 and rejection of unknown layouts. Partial Processing and Atomic Memory projection maintenance verify the original recorded revision checksums; native admission also validates typed journal identity and its completed position against the database revision. Persisted vector profiles reject unsupported partial maintenance before plans, backups or operation evidence, including when the supplied configuration omits embeddings. These are admission-only changes; frozen migration resources, registered layouts, historical checksums and data transformations remain unchanged. PowerContext 1.4.1 assigns release-based presentation names v1_4_1_001 through v1_4_1_003 to the existing shared upgrade steps; the 1.3.0 and 1.4.0 source baselines remain independently registered. Existing revision IDs, bundle and revision checksums, accepted plan identities and journal schemas are unchanged. New Server revisions use software-version-plus-sequence names directly; software releases without storage changes may reuse the same database revision. Naming does not grant acceptance or replace source registration. Full Server status, plan, apply and verify reject unfinished Processing configuration and Atomic Memory projection journals through the same typed admission check used by partial maintenance and SQLite startup. The owning domain runner can still resume its own journal. This only changes admission; frozen revisions, journal formats, database layouts and data transformations remain unchanged. Revision v1_4_1_004 registers the pc_access_owner_recovery outbox on the shared Server chain for both installed-release sources and all registered vector/Code profiles. It creates an empty table through explicit maintenance for existing managed databases and through the registered bootstrap for fresh databases. Earlier revision resources, fingerprints, checksums and existing domain row encodings remain unchanged. External relationship writers use version-1 Artifact and Candidate intents committed with domain data; replay retains the original deployment, principal, resource or target, and idempotency identity. Stable operation and audit event identities prevent lost acknowledgements from duplicating ownership or audit. Conflicting intents are rejected and unknown historical ownership is not inferred. Unregistered outbox layouts are not adopted merely from a table name. This registration does not establish installed-release or external-Provider upgrade acceptance. The policy remains upgrade-required with the full automatic matrix and publication restrictions described above. Database selection now defaults to powercontext for seekDB and for OceanBase URLs without a database, while explicit database names remain authoritative. An implicit seekDB selection is rejected before initialization when the legacy test database contains pc_ tables; operators can explicitly select database=test to retain those records, and no database is moved or deleted automatically. Bootstrap reuses an accessible selected database without CREATE privilege and creates only a confirmed missing database, with idempotent creation and explicit permission errors. Read-only maintenance on an initialized MySQL-compatible engine requires the selected database and never provisions it; an absent or empty seekDB instance remains a no-engine initial plan. MySQL initialization creates only missing requested tables and indexes, and sorts only those requested tables; existing registered tables are preserved. Code graph connections use the selected database, retain historical graph identities for test, and distinguish other database identities. Server identity initialization avoids redundant MySQL DDL on an existing table. Frozen migration resources, revision identifiers and checksums, registered table definitions, journal formats, and existing row encodings are unchanged. These runtime selection and admission changes do not establish an accepted upgrade path; the policy remains upgrade-required and the installed-release acceptance gate still applies. MySQL recovery rejects different registered table sets before normalization. Backend-default normalization copies only the column and index maps it changes and compares each table without cloning whole inventories. Admitted layouts, registered transitions, recovery checks, schema equality and caller-owned inventories remain preserved. SeekDB status and verify distinguish a missing configured database from an uninitialized engine and denied access. Read-only plan can represent a missing selected database as an uninitialized target without creating it; apply provisions that selected database only after the operator accepts its bound plan, then rechecks the target under the maintenance lock. Installed-release seekDB acceptance explicitly retains the historical test database and separately verifies that omitted selection is refused. Registered schema resources, revision checksums, row encodings and write-side provisioning remain unchanged. Server startup can apply an uninitialized plan to provision a missing selected seekDB database; plan, status and verify still never provision it. OceanBase 4.6 SHOW CREATE TABLE can reflect FTS_INDEX_TYPE=MATCH or PHRASE_MATCH on FULLTEXT indexes. Reflection accepts these known types without changing the logical inventory; unknown values remain rejected and indexed columns, parser identity and token-size properties remain significant. PHRASE_MATCH additionally supports phrase matching; the registered layouts require term matching and do not declare a phrase-only contract. Frozen migration resources, checksums, recovery evidence and persisted layouts remain unchanged. Native 4.6.3.1 DDL was captured from a real installed 1.3.0 database; replay of this fixture does not establish complete native upgrade acceptance. The CI database image and master full automatic upgrade matrix remain unchanged. Revision v1_4_1_005 adds nullable Atomic Memory description projection metadata after the registered owner-recovery revision, through explicit SQLite and MySQL schema transitions for both source chains and all vector/Code profiles. Earlier revision resources, fingerprints and checksums remain unchanged. The additive change neither rewrites authoritative Artifact history nor generates summaries or scans stored bodies. Existing managed databases require explicit migration; existing descriptions remain null until a new write or explicit projection repair copies authoritative metadata. Intermediate projection validation uses only columns available at that revision and still rejects inconsistent existing fields. Unmanaged SDK initialization retains its nullable-column compatibility path. The installed-release acceptance matrix and publication restrictions remain required; local regression checks do not establish native upgrade acceptance. Artifact plugin storage extends the shared Server chain from v1_4_1_006 to v1_5_0_001 with registration columns, family declarations, search state and projections, index coverage and changes, and vector-space/index registrations. SQLite also registers portable vectors and FTS auxiliary structures. Native registration columns are added in one ALTER with a matching recovery checkpoint; final column definitions and constraints are unchanged. Native vector tables are created online from registered spaces and are admitted to later maintenance inventories only after exact layout validation. Historical migration resources, retained objects, and existing data are preserved. This review does not expand the supported upgrade matrix or certify installed-release acceptance. Source write plugin registrations use the additive v1_4_1_006 revision after v1_4_1_005 on both source chains. It creates only pc_source_write_plugins; all earlier master revision scripts, schema resources and checksums remain unchanged. Revision v1_5_0_002 follows v1_5_0_001 and adds retained Source evidence bindings, exact Atomic Memory evidence snapshots and their schema marker on all registered backends. A new versioned domain step verifies retained evidence for lexical and vector profiles; earlier released domain resources and recorded revision checksums remain byte-identical. Existing Sources and Memory revisions without accepted declarations stay neutral, with no trust backfill. Current projection maintenance and rebuild explicitly require evidence validation. This additive registration does not establish installed-release acceptance; the upgrade-required gate and publication restrictions are unchanged. Revision v1_5_0_003 adds the disposable exact-revision pc_atomic_memory_evidence projection through the existing index adapters. Transactional body writes publish it from accepted evidence; scoped portable restore and maintenance rebuild derive it from retained history without current-registry trust backfill or model calls. Domain conformance compares historical and derived values, including neutral legacy declarations, on lexical and vector layouts. Earlier revision resources and fingerprints remain unchanged. Local SQLite recovery and real OceanBase 4.4.2.1 evidence conformance were exercised; these do not certify installed-release upgrades or change publication restrictions. Source evidence maintenance now accepts the deployment's own Source definition registry, reports one bounded neutralization reason through operational logs, batches exact-revision reads and caches retained-schema validation per manifest fingerprint. Indexed repair accepts that registry explicitly; the migration bundle still admits evidence against the builtin registry and says so. These changes alter neither registered table layouts nor retained encodings, and the runtime and Server evidence DDL remain byte-identical. The policy remains upgrade-required with the full automatic matrix and publication restrictions described above. Source definition manifests derive every field whose default is null from the validation schema, so an already accepted Definition keeps its fingerprint and re-registers after upgrade; populating such a field requires the nested model to serialize by alias. Registered layouts and retained encodings are unchanged. The runtime MySQL-family table fast path now runs the registered additive evidence migration before creating other tables, so an unmanaged OceanBase or seekDB database cannot retain evidence tables without their p0005 marker.. Code publication recovery adds an optional per-binding publication.json journal that records a generation identifier before its database commit and final directory rename. Caches created before this journal existed remain readable without synthesizing historical publication records; identifiers for already orphaned rows are not inferred. New seekDB generations retain the existing identifier, graph descriptor, manifest and row encodings, while reclamation validates the recorded identifier and scopes deletion to its binding. Live or reader-retained generation directories and the in-flight publication are protected from journal-window reclamation. SQLite graph rows remain owned by their generation directory. This additive local cache journal changes recovery behavior but does not change registered SQL table layouts, migration resources, revision identifiers, historical checksums or the Server migration journal. The policy remains upgrade-required, and the complete installed-release acceptance matrix remains mandatory; this compatibility review is not evidence of completed native upgrade acceptance. Managed Ops admission validates a private main-file and WAL snapshot through the exact target Server bundle, retaining the original database identity and maintenance evidence. Snapshot admission is read-only and cannot initialize, migrate or recover storage. It adds no schema revision, changes no persisted row or journal format, and does not expand supported upgrade declarations or the installed-release acceptance matrix. PreparedContext Receipt records the actual FTS mode on Experience search outcomes. This adds only an in-memory observation field; it does not change stored Experience rows, SQL selection, schema registrations, migration resources, or data encodings. Unmanaged historical Server databases are admitted by required structure and domain state, then normalized before adopting an existing checkpoint. Historical Memory conversion, Scope preservation, SQLite rollback and native DDL recovery use the shared maintenance flow. Independent extension objects and harmless DDL differences do not require clean-install fingerprints. Existing immutable revision resources and the installed-release acceptance matrix remain unchanged. Local historical tests do not certify arbitrary tenant defaults, every vector/Code configuration or future releases. SQLite compatibility validation includes effective primary-key collations and rejects conflicting identity semantics. Historical normalization and the frozen s1400 rebuild preserve generated extension definitions while copying only writable columns. Both virtual and stored generated values remain computed by SQLite; immutable revision resources and the native backend paths are unchanged. SQLite table rebuilds preserve declared collations on ordinary and generated extension columns, including their inherited index comparison semantics. Expression-local COLLATE clauses remain part of their expressions and do not change the column collation. This changes only SQLite compatibility normalization; frozen revisions and native backend execution remain unchanged. Historical Memory admission recognizes lineage ownership columns and default non-merge references while rejecting unmapped payloads. Native compatibility journals record temporary Processing counter ownership before DDL and clear it after cleanup, including completed-phase recovery. SQLite extension preservation also covers the registered r1400 bridge. Frozen migration resources and the installed-release acceptance matrix remain unchanged. Native historical Atomic conversion creates nullable description metadata with the registered UTF-8 binary collation, allowing the later frozen ADD transition to recognize its completed postcondition without duplicate DDL. SQLite historical normalization and registered source bridges preserve dependent views and their triggers during same-name table replacement. Legacy rename behavior is limited to the exact staging-to-final rename statements and the original connection setting is restored; genuine table renames still update dependent references. Frozen migration resources and native backend behavior remain unchanged. SQLite admission validates required extension defaults using SQLite deterministic-expression restrictions and a non-NULL evaluation on an isolated connection. Connection-state, random and clock-dependent expressions are rejected; direct CURRENT_DATE, CURRENT_TIME and CURRENT_TIMESTAMP defaults remain supported alongside usable constant expressions. Regression coverage verifies consecutive Source writes in distinct scopes within the same transaction. Read-only verification and startup admission share this rule; supported defaults remain usable through Source write and readback. Independent extension tables remain preserved through managed upgrades and initialization recovery; missing recovery evidence and missing vector configuration still block maintenance. Registered native column additions and modifications materialize the charset and collation declared by their immutable postcondition when the SQL would otherwise inherit a historical table default. This preserves GBK-origin upgrade recovery without changing frozen resources, existing table defaults, independent extension encodings or readiness requirements. SQLite Candidate extension columns and their values survive both stages of the registered Dream rebuild, including generated columns and dependent views. Frozen source introspection projects the already-validated managed columns without altering stored extension data. Independent indexes use SQLite-rewritten definitions after genuine table renames. Native CHECK removal resolves the actual constraint name from the registered expression and enforcement semantics, including recovery after committed DDL. Frozen resources, registered revision checksums and the installed-release acceptance matrix remain unchanged. SQLite admission rejects additional NOT NULL generated columns because computed values can reject valid Source writes. Nullable virtual and stored extensions remain preserved across historical upgrades and support Source write/readback. Frozen migrations and native backend behavior are unchanged. Ownership recovery preflight derives candidate-family filters from the exact migration identities, including Prompt Candidates that do not have standalone Artifact owners. Static Scope permission previews now use the same complete internal receipt keys as bootstrap for long Scope IDs. These authorization-only changes add no stored fields, row encoding, schema, or migration registration. Static Scope preset keys retain existing short identities and use SHA-256 for oversized relationship keys. Bootstrap and read-only preview recognize both bounded keys and historical receipts, including revoked bindings. No schema or migration registration changes. Revision v1_5_0_004 follows v1_5_0_003 and registers the durable Work Ledger tables (pc_work_lanes, pc_work_items, pc_work_keys, pc_work_attempts, pc_scheduler_leases, pc_scheduler_scans, pc_runtime_members and the shared pc_rate_limit_windows) on every registered backend; it only creates new tables and leaves earlier revision scripts, schema resources and checksums unchanged. Ledger execution stays at-least-once behind a monotonic fence token while its commit is at-most-once, so a retried, superseded or cancelled attempt cannot publish a duplicate result or release another worker's logical key. This additive registration does not establish installed-release acceptance; the upgrade-required gate and publication restrictions are unchanged." + "persistence_sha256": "cd75d58c73c3175cc1fcd04faf17f430054ad57de25d61854c85bebb89c5e823", + "rationale": "The reusable Server migration framework registers actual PowerContext 1.3.0 through s1300 -> s1400 -> s1401 -> s1402 and actual 1.4.0 through the separate immutable r1400 -> s1400 -> s1401 -> s1402 source bridge. Original 1.3 resources and checksums remain byte-identical. Real 1.4.0 SQLite, embedded seekDB and OceanBase structures were captured from the official wheel in all twelve vector/Code configurations; provenance and exact source layouts are packaged with the bridge. The 1.4 bridge preserves current Candidate, Artifact and task data, adds the missing Topic budget/block structure, revokes old lease fences and joins the existing archive/portable-storage path. Empty retained tables align the final schema without inventing 1.3 Candidate history. Source histories retain their identity through subsequent upgrades and recovery; shared head names never authorize adopting unknown layouts. Vector profiles remain bound to historical model, profile ID, dimension, unit normalization and L2 distance, with no provider calls during maintenance. Complete schema, domain, projection, profile and retained-history checks precede startup and partial maintenance. Local SQLite and embedded seekDB startup reject an observable active maintenance lock through a read-only probe. SQLite runtime admission validates the default adjacent journal, rejecting unfinished full or partial maintenance, changed recorded revision checksums and missing required retained tables. Absent journals do not invent retention obligations; custom evidence directories require explicit operator verification and remote coordinator locks remain subject to deployment orchestration. Native read-only readiness uses a consistent snapshot and does not acquire Atomic Memory head locks; normal business transaction isolation is unchanged. Offline projection repairs retain the original locked state comparison and write-side CAS protection. Target locks, backup choices, original recovery evidence and explicit remote acknowledgments remain enforced. The installed-artifact catalog retains twelve 1.3.0 source/backend/vector/Code combinations. Embedded seekDB uses pylibseekdb 1.4.0.post2 or newer with the recovered-transaction lock fix. Main and publication require all twelve configurations, including embedded seekDB full-server-code and full-server-vector-code. No configuration is waived; incomplete, failed or skipped evidence rejects acceptance. The seekdb-code diagnostic selection cannot authorize publication. The registered 1.4.0 bridge remains implemented but is outside the CI upgrade acceptance matrix; passing this gate does not certify that path. The gate preserves source-specific Processing counters and isolates OceanBase source databases. Any failed, skipped or missing automatic case rejects publication. The policy remains upgrade-required until the exact target artifact passes the required automatic matrix and the reviewed support declaration is updated. Captured source structure, local tests and checkpoint replay are distinct from installed-release acceptance, native interruption/observer recovery and backup restoration. The Candidate generation prototype observes actual SQLite/seekDB/OceanBase migration checkpoints outside the production bundle; it does not replace frozen registrations or weaken recovery checks. Existing target schema and data formats, including batched persistence interfaces, remain governed by the shared registered path. The synchronized master adds blank-text validation at Source creation and managed worker logging. It changes neither registered table layout nor migration data encodings; existing stored Source values are not rewritten. The source bridges and frozen history remain unchanged. Compatibility commands identify Server-owned storage before opening a writable legacy profile and delegate source admission to the shared framework, including actual 1.4.0 and rejection of unknown layouts. Partial Processing and Atomic Memory projection maintenance verify the original recorded revision checksums; native admission also validates typed journal identity and its completed position against the database revision. Persisted vector profiles reject unsupported partial maintenance before plans, backups or operation evidence, including when the supplied configuration omits embeddings. These are admission-only changes; frozen migration resources, registered layouts, historical checksums and data transformations remain unchanged. PowerContext 1.4.1 assigns release-based presentation names v1_4_1_001 through v1_4_1_003 to the existing shared upgrade steps; the 1.3.0 and 1.4.0 source baselines remain independently registered. Existing revision IDs, bundle and revision checksums, accepted plan identities and journal schemas are unchanged. New Server revisions use software-version-plus-sequence names directly; software releases without storage changes may reuse the same database revision. Naming does not grant acceptance or replace source registration. Full Server status, plan, apply and verify reject unfinished Processing configuration and Atomic Memory projection journals through the same typed admission check used by partial maintenance and SQLite startup. The owning domain runner can still resume its own journal. This only changes admission; frozen revisions, journal formats, database layouts and data transformations remain unchanged. Revision v1_4_1_004 registers the pc_access_owner_recovery outbox on the shared Server chain for both installed-release sources and all registered vector/Code profiles. It creates an empty table through explicit maintenance for existing managed databases and through the registered bootstrap for fresh databases. Earlier revision resources, fingerprints, checksums and existing domain row encodings remain unchanged. External relationship writers use version-1 Artifact and Candidate intents committed with domain data; replay retains the original deployment, principal, resource or target, and idempotency identity. Stable operation and audit event identities prevent lost acknowledgements from duplicating ownership or audit. Conflicting intents are rejected and unknown historical ownership is not inferred. Unregistered outbox layouts are not adopted merely from a table name. This registration does not establish installed-release or external-Provider upgrade acceptance. The policy remains upgrade-required with the full automatic matrix and publication restrictions described above. Database selection now defaults to powercontext for seekDB and for OceanBase URLs without a database, while explicit database names remain authoritative. An implicit seekDB selection is rejected before initialization when the legacy test database contains pc_ tables; operators can explicitly select database=test to retain those records, and no database is moved or deleted automatically. Bootstrap reuses an accessible selected database without CREATE privilege and creates only a confirmed missing database, with idempotent creation and explicit permission errors. Read-only maintenance on an initialized MySQL-compatible engine requires the selected database and never provisions it; an absent or empty seekDB instance remains a no-engine initial plan. MySQL initialization creates only missing requested tables and indexes, and sorts only those requested tables; existing registered tables are preserved. Code graph connections use the selected database, retain historical graph identities for test, and distinguish other database identities. Server identity initialization avoids redundant MySQL DDL on an existing table. Frozen migration resources, revision identifiers and checksums, registered table definitions, journal formats, and existing row encodings are unchanged. These runtime selection and admission changes do not establish an accepted upgrade path; the policy remains upgrade-required and the installed-release acceptance gate still applies. MySQL recovery rejects different registered table sets before normalization. Backend-default normalization copies only the column and index maps it changes and compares each table without cloning whole inventories. Admitted layouts, registered transitions, recovery checks, schema equality and caller-owned inventories remain preserved. SeekDB status and verify distinguish a missing configured database from an uninitialized engine and denied access. Read-only plan can represent a missing selected database as an uninitialized target without creating it; apply provisions that selected database only after the operator accepts its bound plan, then rechecks the target under the maintenance lock. Installed-release seekDB acceptance explicitly retains the historical test database and separately verifies that omitted selection is refused. Registered schema resources, revision checksums, row encodings and write-side provisioning remain unchanged. Server startup can apply an uninitialized plan to provision a missing selected seekDB database; plan, status and verify still never provision it. OceanBase 4.6 SHOW CREATE TABLE can reflect FTS_INDEX_TYPE=MATCH or PHRASE_MATCH on FULLTEXT indexes. Reflection accepts these known types without changing the logical inventory; unknown values remain rejected and indexed columns, parser identity and token-size properties remain significant. PHRASE_MATCH additionally supports phrase matching; the registered layouts require term matching and do not declare a phrase-only contract. Frozen migration resources, checksums, recovery evidence and persisted layouts remain unchanged. Native 4.6.3.1 DDL was captured from a real installed 1.3.0 database; replay of this fixture does not establish complete native upgrade acceptance. The CI database image and master full automatic upgrade matrix remain unchanged. Revision v1_4_1_005 adds nullable Atomic Memory description projection metadata after the registered owner-recovery revision, through explicit SQLite and MySQL schema transitions for both source chains and all vector/Code profiles. Earlier revision resources, fingerprints and checksums remain unchanged. The additive change neither rewrites authoritative Artifact history nor generates summaries or scans stored bodies. Existing managed databases require explicit migration; existing descriptions remain null until a new write or explicit projection repair copies authoritative metadata. Intermediate projection validation uses only columns available at that revision and still rejects inconsistent existing fields. Unmanaged SDK initialization retains its nullable-column compatibility path. The installed-release acceptance matrix and publication restrictions remain required; local regression checks do not establish native upgrade acceptance. Artifact plugin storage extends the shared Server chain from v1_4_1_006 to v1_5_0_001 with registration columns, family declarations, search state and projections, index coverage and changes, and vector-space/index registrations. SQLite also registers portable vectors and FTS auxiliary structures. Native registration columns are added in one ALTER with a matching recovery checkpoint; final column definitions and constraints are unchanged. Native vector tables are created online from registered spaces and are admitted to later maintenance inventories only after exact layout validation. Historical migration resources, retained objects, and existing data are preserved. This review does not expand the supported upgrade matrix or certify installed-release acceptance. Source write plugin registrations use the additive v1_4_1_006 revision after v1_4_1_005 on both source chains. It creates only pc_source_write_plugins; all earlier master revision scripts, schema resources and checksums remain unchanged. Revision v1_5_0_002 follows v1_5_0_001 and adds retained Source evidence bindings, exact Atomic Memory evidence snapshots and their schema marker on all registered backends. A new versioned domain step verifies retained evidence for lexical and vector profiles; earlier released domain resources and recorded revision checksums remain byte-identical. Existing Sources and Memory revisions without accepted declarations stay neutral, with no trust backfill. Current projection maintenance and rebuild explicitly require evidence validation. This additive registration does not establish installed-release acceptance; the upgrade-required gate and publication restrictions are unchanged. Revision v1_5_0_003 adds the disposable exact-revision pc_atomic_memory_evidence projection through the existing index adapters. Transactional body writes publish it from accepted evidence; scoped portable restore and maintenance rebuild derive it from retained history without current-registry trust backfill or model calls. Domain conformance compares historical and derived values, including neutral legacy declarations, on lexical and vector layouts. Earlier revision resources and fingerprints remain unchanged. Local SQLite recovery and real OceanBase 4.4.2.1 evidence conformance were exercised; these do not certify installed-release upgrades or change publication restrictions. Source evidence maintenance now accepts the deployment's own Source definition registry, reports one bounded neutralization reason through operational logs, batches exact-revision reads and caches retained-schema validation per manifest fingerprint. Indexed repair accepts that registry explicitly; the migration bundle still admits evidence against the builtin registry and says so. These changes alter neither registered table layouts nor retained encodings, and the runtime and Server evidence DDL remain byte-identical. The policy remains upgrade-required with the full automatic matrix and publication restrictions described above. Source definition manifests derive every field whose default is null from the validation schema, so an already accepted Definition keeps its fingerprint and re-registers after upgrade; populating such a field requires the nested model to serialize by alias. Registered layouts and retained encodings are unchanged. The runtime MySQL-family table fast path now runs the registered additive evidence migration before creating other tables, so an unmanaged OceanBase or seekDB database cannot retain evidence tables without their p0005 marker.. Code publication recovery adds an optional per-binding publication.json journal that records a generation identifier before its database commit and final directory rename. Caches created before this journal existed remain readable without synthesizing historical publication records; identifiers for already orphaned rows are not inferred. New seekDB generations retain the existing identifier, graph descriptor, manifest and row encodings, while reclamation validates the recorded identifier and scopes deletion to its binding. Live or reader-retained generation directories and the in-flight publication are protected from journal-window reclamation. SQLite graph rows remain owned by their generation directory. This additive local cache journal changes recovery behavior but does not change registered SQL table layouts, migration resources, revision identifiers, historical checksums or the Server migration journal. The policy remains upgrade-required, and the complete installed-release acceptance matrix remains mandatory; this compatibility review is not evidence of completed native upgrade acceptance. Managed Ops admission validates a private main-file and WAL snapshot through the exact target Server bundle, retaining the original database identity and maintenance evidence. Snapshot admission is read-only and cannot initialize, migrate or recover storage. It adds no schema revision, changes no persisted row or journal format, and does not expand supported upgrade declarations or the installed-release acceptance matrix. PreparedContext Receipt records the actual FTS mode on Experience search outcomes. This adds only an in-memory observation field; it does not change stored Experience rows, SQL selection, schema registrations, migration resources, or data encodings. Unmanaged historical Server databases are admitted by required structure and domain state, then normalized before adopting an existing checkpoint. Historical Memory conversion, Scope preservation, SQLite rollback and native DDL recovery use the shared maintenance flow. Independent extension objects and harmless DDL differences do not require clean-install fingerprints. Existing immutable revision resources and the installed-release acceptance matrix remain unchanged. Local historical tests do not certify arbitrary tenant defaults, every vector/Code configuration or future releases. SQLite compatibility validation includes effective primary-key collations and rejects conflicting identity semantics. Historical normalization and the frozen s1400 rebuild preserve generated extension definitions while copying only writable columns. Both virtual and stored generated values remain computed by SQLite; immutable revision resources and the native backend paths are unchanged. SQLite table rebuilds preserve declared collations on ordinary and generated extension columns, including their inherited index comparison semantics. Expression-local COLLATE clauses remain part of their expressions and do not change the column collation. This changes only SQLite compatibility normalization; frozen revisions and native backend execution remain unchanged. Historical Memory admission recognizes lineage ownership columns and default non-merge references while rejecting unmapped payloads. Native compatibility journals record temporary Processing counter ownership before DDL and clear it after cleanup, including completed-phase recovery. SQLite extension preservation also covers the registered r1400 bridge. Frozen migration resources and the installed-release acceptance matrix remain unchanged. Native historical Atomic conversion creates nullable description metadata with the registered UTF-8 binary collation, allowing the later frozen ADD transition to recognize its completed postcondition without duplicate DDL. SQLite historical normalization and registered source bridges preserve dependent views and their triggers during same-name table replacement. Legacy rename behavior is limited to the exact staging-to-final rename statements and the original connection setting is restored; genuine table renames still update dependent references. Frozen migration resources and native backend behavior remain unchanged. SQLite admission validates required extension defaults using SQLite deterministic-expression restrictions and a non-NULL evaluation on an isolated connection. Connection-state, random and clock-dependent expressions are rejected; direct CURRENT_DATE, CURRENT_TIME and CURRENT_TIMESTAMP defaults remain supported alongside usable constant expressions. Regression coverage verifies consecutive Source writes in distinct scopes within the same transaction. Read-only verification and startup admission share this rule; supported defaults remain usable through Source write and readback. Independent extension tables remain preserved through managed upgrades and initialization recovery; missing recovery evidence and missing vector configuration still block maintenance. Registered native column additions and modifications materialize the charset and collation declared by their immutable postcondition when the SQL would otherwise inherit a historical table default. This preserves GBK-origin upgrade recovery without changing frozen resources, existing table defaults, independent extension encodings or readiness requirements. SQLite Candidate extension columns and their values survive both stages of the registered Dream rebuild, including generated columns and dependent views. Frozen source introspection projects the already-validated managed columns without altering stored extension data. Independent indexes use SQLite-rewritten definitions after genuine table renames. Native CHECK removal resolves the actual constraint name from the registered expression and enforcement semantics, including recovery after committed DDL. Frozen resources, registered revision checksums and the installed-release acceptance matrix remain unchanged. SQLite admission rejects additional NOT NULL generated columns because computed values can reject valid Source writes. Nullable virtual and stored extensions remain preserved across historical upgrades and support Source write/readback. Frozen migrations and native backend behavior are unchanged. Ownership recovery preflight derives candidate-family filters from the exact migration identities, including Prompt Candidates that do not have standalone Artifact owners. Static Scope permission previews now use the same complete internal receipt keys as bootstrap for long Scope IDs. These authorization-only changes add no stored fields, row encoding, schema, or migration registration. Static Scope preset keys retain existing short identities and use SHA-256 for oversized relationship keys. Bootstrap and read-only preview recognize both bounded keys and historical receipts, including revoked bindings. No schema or migration registration changes. Revision v1_5_0_004 follows v1_5_0_003 and registers the durable Work Ledger tables (pc_work_lanes, pc_work_items, pc_work_keys, pc_work_attempts, pc_scheduler_leases, pc_scheduler_scans, pc_runtime_members and the shared pc_rate_limit_windows) on every registered backend; it only creates new tables and leaves earlier revision scripts, schema resources and checksums unchanged. Ledger execution stays at-least-once behind a monotonic fence token while its commit is at-most-once, so a retried, superseded or cancelled attempt cannot publish a duplicate result or release another worker's logical key. This additive registration does not establish installed-release acceptance; the upgrade-required gate and publication restrictions are unchanged. The maintenance backup scope (affected_objects) lists the same eight tables, so a backup taken before this revision covers everything it creates." }, "cases": [], "automatic_sources": { diff --git a/src/powercontext/builtin/persistence/migrations/server_resources/manifest.json b/src/powercontext/builtin/persistence/migrations/server_resources/manifest.json index e0eae22522..81bfce2c47 100644 --- a/src/powercontext/builtin/persistence/migrations/server_resources/manifest.json +++ b/src/powercontext/builtin/persistence/migrations/server_resources/manifest.json @@ -161,6 +161,7 @@ "pc_model_usage_daily", "pc_portable_restore_receipts", "pc_profile_policies", + "pc_rate_limit_windows", "pc_recall_effort_daily", "pc_recall_token_daily", "pc_receipt_migration_review", @@ -168,6 +169,9 @@ "pc_recurrence_observation", "pc_retained_dream_candidate_heads", "pc_retained_dream_candidate_versions", + "pc_runtime_members", + "pc_scheduler_leases", + "pc_scheduler_scans", "pc_scope_bindings", "pc_scope_context_references", "pc_scope_creation_requests", @@ -202,7 +206,11 @@ "pc_topic_memory_topic_fts_docsize", "pc_topic_memory_topic_fts_idx", "pc_topic_memory_work_blocks", - "pc_topic_memory_work_budgets" + "pc_topic_memory_work_budgets", + "pc_work_attempts", + "pc_work_items", + "pc_work_keys", + "pc_work_lanes" ], "readiness_scope": "server", "retained_objects": [ diff --git a/src/powercontext/builtin/persistence/migrations/server_sources/1.4.0/manifest.json b/src/powercontext/builtin/persistence/migrations/server_sources/1.4.0/manifest.json index e4f2a65a59..a497852357 100644 --- a/src/powercontext/builtin/persistence/migrations/server_sources/1.4.0/manifest.json +++ b/src/powercontext/builtin/persistence/migrations/server_sources/1.4.0/manifest.json @@ -160,6 +160,7 @@ "pc_model_usage_daily", "pc_portable_restore_receipts", "pc_profile_policies", + "pc_rate_limit_windows", "pc_recall_effort_daily", "pc_recall_token_daily", "pc_receipt_migration_review", @@ -167,6 +168,9 @@ "pc_recurrence_observation", "pc_retained_dream_candidate_heads", "pc_retained_dream_candidate_versions", + "pc_runtime_members", + "pc_scheduler_leases", + "pc_scheduler_scans", "pc_scope_bindings", "pc_scope_context_references", "pc_scope_creation_requests", @@ -201,7 +205,11 @@ "pc_topic_memory_topic_fts_docsize", "pc_topic_memory_topic_fts_idx", "pc_topic_memory_work_blocks", - "pc_topic_memory_work_budgets" + "pc_topic_memory_work_budgets", + "pc_work_attempts", + "pc_work_items", + "pc_work_keys", + "pc_work_lanes" ], "readiness_scope": "server", "retained_objects": [ From 3df12c9e7d83818bd35c07c1bbef1f00926b0fc7 Mon Sep 17 00:00:00 2001 From: JasonDeng <15301580353@163.com> Date: Wed, 4 Nov 2026 18:54:19 +0800 Subject: [PATCH 33/45] test(persistence): follow the v1_5_0_004 head in the evidence upgrade history The upgrade-history fixture asserted the projection revision as the chain tail. Point it at the Work Ledger revision, list both appended steps in the plan and keep the projection's domain step expectation, since the ledger adds tables only. --- .../test_atomic_evidence_upgrade_history.py | 54 +++++++++++++------ 1 file changed, 38 insertions(+), 16 deletions(-) diff --git a/tests/builtin/persistence/test_atomic_evidence_upgrade_history.py b/tests/builtin/persistence/test_atomic_evidence_upgrade_history.py index 6e677d1d05..1e718bb29f 100644 --- a/tests/builtin/persistence/test_atomic_evidence_upgrade_history.py +++ b/tests/builtin/persistence/test_atomic_evidence_upgrade_history.py @@ -34,6 +34,17 @@ EVIDENCE_REVISION = "v1_5_0_002" PROJECTION_REVISION = "v1_5_0_003" +LEDGER_REVISION = "v1_5_0_004" +LEDGER_TABLES = { + "pc_work_lanes", + "pc_work_items", + "pc_work_keys", + "pc_work_attempts", + "pc_scheduler_leases", + "pc_scheduler_scans", + "pc_runtime_members", + "pc_rate_limit_windows", +} EVIDENCE_TABLES = { "pc_atomic_memory_evidence_schema", "pc_atomic_memory_evidence_history", @@ -159,8 +170,15 @@ def test_evidence_upgrade_accepts_upstream_history_and_preserves_data(tmp_path, plan = upgraded.plan() assert plan.state == "migration_required" assert plan.source_revision == "v1_4_1_004" - assert plan.target_revision == PROJECTION_REVISION - assert plan.revisions == ("v1_4_1_005", "v1_4_1_006", "v1_5_0_001", EVIDENCE_REVISION, PROJECTION_REVISION) + assert plan.target_revision == LEDGER_REVISION + assert plan.revisions == ( + "v1_4_1_005", + "v1_4_1_006", + "v1_5_0_001", + EVIDENCE_REVISION, + PROJECTION_REVISION, + LEDGER_REVISION, + ) assert plan.domain_steps == ( "artifact_storage", "atomic_memory_evidence", @@ -169,7 +187,7 @@ def test_evidence_upgrade_accepts_upstream_history_and_preserves_data(tmp_path, assert upgraded.evidence_path.read_bytes() == old_evidence_bytes result = upgraded.apply(plan_id=plan.plan_id, accepted=True, maintenance_confirmed=True) assert result.changed - assert result.revision == PROJECTION_REVISION + assert result.revision == LEDGER_REVISION assert result.backup_state == "completed" # Reconstruct both bundle and runner: verification must use retained on-disk @@ -186,11 +204,12 @@ def test_evidence_upgrade_accepts_upstream_history_and_preserves_data(tmp_path, "v1_5_0_001", EVIDENCE_REVISION, PROJECTION_REVISION, + LEDGER_REVISION, } ready = reopened.plan() assert ready.state == "ready" assert ready.revisions == () - assert reopened.verify().revision == PROJECTION_REVISION + assert reopened.verify().revision == LEDGER_REVISION assert not apply(reopened).changed assert reopened.evidence_path.read_bytes() == completed_evidence_bytes with closing(sqlite3.connect(database)) as connection: @@ -230,18 +249,23 @@ def test_evidence_projection_upgrade_preserves_registered_evidence_revision(tmp_ shutil.copytree(current.directory, directory, ignore=shutil.ignore_patterns("__pycache__")) manifest_path = directory / "manifest.json" manifest = json.loads(manifest_path.read_text(encoding="utf-8")) - removed = manifest["revision_resources"].pop(PROJECTION_REVISION) - removed.append("atomic_memory_evidence_projection.py") - revision = current.script.get_revision(PROJECTION_REVISION) - removed.append(Path(revision.path).relative_to(current.snapshot_directory).as_posix()) - removed.append(f"mysql/versions/{PROJECTION_REVISION}.py") + # Drop every step appended after the evidence revision: the projection and the + # Work Ledger, so this copy ends where the older released bundle ended. + removed: list[str] = [] + for dropped in (PROJECTION_REVISION, LEDGER_REVISION): + removed.extend(manifest["revision_resources"].pop(dropped)) + revision = current.script.get_revision(dropped) + removed.append(Path(revision.path).relative_to(current.snapshot_directory).as_posix()) + removed.append(f"mysql/versions/{dropped}.py") + del manifest["sqlite_fingerprints"][dropped] + del manifest["domain_readiness"][dropped] + manifest["domain_steps"] = [step for step in manifest["domain_steps"] if step["revision"] != dropped] for resource in removed: (directory / resource).unlink() - del manifest["sqlite_fingerprints"][PROJECTION_REVISION] - del manifest["domain_readiness"][PROJECTION_REVISION] - manifest["domain_steps"] = [step for step in manifest["domain_steps"] if step["revision"] != PROJECTION_REVISION] manifest["mysql_state_layouts"] = [name for name in manifest["mysql_state_layouts"] if name not in removed] manifest["affected_objects"].remove("pc_atomic_memory_evidence") + for table in LEDGER_TABLES: + manifest["affected_objects"].remove(table) manifest_path.write_text(json.dumps(manifest), encoding="utf-8") previous = MigrationBundle(directory, embedding_profile=embedding) assert previous.head == EVIDENCE_REVISION @@ -254,12 +278,10 @@ def test_evidence_projection_upgrade_preserves_registered_evidence_revision(tmp_ before = old.evidence_path.read_bytes() upgraded = SQLiteMigrationRunner(database, current, configuration=config) plan = upgraded.plan() - assert plan.revisions == (PROJECTION_REVISION,) + assert plan.revisions == (PROJECTION_REVISION, LEDGER_REVISION) assert plan.domain_steps == ("atomic_memory_evidence_projection",) assert old.evidence_path.read_bytes() == before - assert ( - upgraded.apply(plan_id=plan.plan_id, accepted=True, maintenance_confirmed=True).revision == PROJECTION_REVISION - ) + assert upgraded.apply(plan_id=plan.plan_id, accepted=True, maintenance_confirmed=True).revision == LEDGER_REVISION assert upgraded.verify().state == "ready" with closing(sqlite3.connect(database)) as connection: assert connection.execute("SELECT COUNT(*) FROM pc_atomic_memory_evidence").fetchone() == (0,) From e03e4ee3c7bf97a608f30c9138e6b23bac76cc1a Mon Sep 17 00:00:00 2001 From: JasonDeng <15301580353@163.com> Date: Wed, 4 Nov 2026 19:01:35 +0800 Subject: [PATCH 34/45] docs(desktop): qualify the merged Desktop contract b4d241e6 The merge with master changed the normalized OpenAPI digest, so the bundled preview had no qualification record for its own contract. Record sqlite-atomic-3df12c9e-v1 from the built wheel powercontext-1.2.1.dev620+g3df12c9e7 (718 packaged runtime files byte-equal to the clean commit, four isolated SQLite Server modes passed, desktopWorkingTreeDirty=false) and describe it in VALIDATION.md. --- desktop/VALIDATION.md | 41 ++++++++++++++++++- .../src/connections/compatibility.json | 20 +++++++++ 2 files changed, 60 insertions(+), 1 deletion(-) diff --git a/desktop/VALIDATION.md b/desktop/VALIDATION.md index ec566d76c2..2e7b6bb1ca 100644 --- a/desktop/VALIDATION.md +++ b/desktop/VALIDATION.md @@ -177,7 +177,7 @@ The historical Server c535d816 protocol qualification below records the specific ## Compatibility scope -[compatibility.json](src-tauri/src/connections/compatibility.json) preserves each recorded Server source, normalized OpenAPI contract digest, qualification-wheel digest and operation set. The `sqlite-atomic-3cefa45a-v1` record matches the bundled API contract and qualifies its eleven recorded SQLite protocol operations against the exact Server source and wheel below. Historical records retain their exact identities and evidence. Ordinary builds require explicitly selecting the record whose tested Server source and wheel match the deployment, then checking the connection before business operations become available. Records with a different contract digest are disabled in the selector, and saved unavailable selections remain visible until explicitly replaced. Selecting a profile does not extend its recorded operation set or qualify a different Server source. CI builds have their own artifact identities and do not change product qualification. +[compatibility.json](src-tauri/src/connections/compatibility.json) preserves each recorded Server source, normalized OpenAPI contract digest, qualification-wheel digest and operation set. The `sqlite-atomic-3df12c9e-v1` record matches the bundled API contract and qualifies its eleven recorded SQLite protocol operations against the exact Server source and wheel below. Historical records retain their exact identities and evidence. Ordinary builds require explicitly selecting the record whose tested Server source and wheel match the deployment, then checking the connection before business operations become available. Records with a different contract digest are disabled in the selector, and saved unavailable selections remain visible until explicitly replaced. Selecting a profile does not extend its recorded operation set or qualify a different Server source. CI builds have their own artifact identities and do not change product qualification. The real Server harness covers SQLite with anonymous loopback, static Bearer, HTTPS with explicit CA/base path, and injected-provider/enforced access. It exercises save/search/exact reads, identity changes, binding revocation, ambiguous writes without replay and Scope pagination. Installed UI checks cover repeated launches preserving the existing editor, late committed-save responses preserving a new Scope draft, explicit connection/Scope selection, clipboard copies, connection isolation, draft cancellation, byte limits and independent Server survival after Desktop exit. Historical record `sqlite-atomic-f3a90b2f-v1` remains preserved with its original identities and is disabled for this contract. @@ -187,6 +187,45 @@ Selecting a compatibility profile does not attest the remote binary identity. Ex ### Server protocol qualification records +#### Server 3df12c9e protocol qualification + +`sqlite-atomic-3df12c9e-v1` qualifies Server sources at +`3df12c9e7d83818bd35c07c1bbef1f00926b0fc7` for the bundled OpenAPI digest +`b4d241e67b5a00fddd0a5d851cf86485fa8ff999e97ec067ee9b15b46a59d968`, the merged contract that carries +master's 1.5.0 artifact-storage chain, the atomic-memory evidence projection and the Work Ledger +revision, together with the four Scope-scoped durable operation endpoints. The qualification wheel is +`powercontext-1.2.1.dev620+g3df12c9e7-py3-none-any.whl`, SHA-256 +`29b2e69fc451508f16682981acd131be09e7242c3a8eab7ff578b35ba6221db5`, built from that clean commit. +All 718 packaged runtime files in `powercontext/` and `powercontext_service_bootstrap/` match the +source bytes at that commit. The runtime manifest digest is +`5cf1df36b5e1676601b9729e54b15704cfa9e48748d6229560c3c7e61c53075a` +(sorted `wheel/path sha256` lines, with a final newline). + +On 2026-11-04, macOS 27.2 ARM64 with Python 3.14.7 and Rust 1.68.1 ran the existing +`desktop/tests/real_server.py` harness against the extracted wheel and independent SQLite Server +processes. All four modes passed: anonymous loopback, static Bearer, HTTPS with an explicit CA and +base path, and an injected provider with enforced access. The eleven recorded operations cover +Atomic Memory save/search/exact revision reads, Scope selection and pagination, identity changes, +same-identity revocation, committed writes with lost responses without replay, server discovery, +and Server survival after client exit. The report records the source commit above and +`desktopWorkingTreeDirty=false`. Native probe medians (nearest rank, 20 samples): loopback-anonymous +save 30.41 ms / search 15.98 ms / exact read 13.72 ms; loopback-bearer 41.88 / 24.10 / 23.13 ms; +HTTPS bearer with base path 42.52 / 27.67 / 26.52 ms; injected provider 42.73 / 24.82 / 23.74 ms. + +This is protocol qualification of the recorded source, contract and wheel. It does not qualify +Windows/Intel execution, native credential persistence, installed GUI workflows, signing or release +readiness, and it says nothing about the four new operation endpoints: they are Server-side contract +covered by the Python suite and are deliberately absent from the Desktop operation set above. The +Work Ledger revision this wheel carries is additive and does not change the eleven qualified +operations. Reproduce from this clean commit with +`uv build --wheel --out-dir desktop/.artifacts/server-wheel`, +`cargo build --locked --example server_probe --example diagnostic_cli_probe`, +`uv run --no-sync python desktop/tests/fixture_qualification.py --server-commit 3df12c9e7d83818bd35c07c1bbef1f00926b0fc7`, +and `uv run --no-sync python desktop/tests/real_server.py`. +The bundled preview resolves its qualification by digest, so a contract change and a matching record +have to land together. `tests/test_desktop_contract.py` fails the Python gate for a digest without a +record, exactly as `bundled_qualification_allows_explicit_scope_browsing` fails the native one. + #### Server cf81c1a8 protocol qualification `sqlite-atomic-cf81c1a8-v1` qualifies Server sources at diff --git a/desktop/src-tauri/src/connections/compatibility.json b/desktop/src-tauri/src/connections/compatibility.json index d62d8f117d..04870a3df7 100644 --- a/desktop/src-tauri/src/connections/compatibility.json +++ b/desktop/src-tauri/src/connections/compatibility.json @@ -1,4 +1,24 @@ [ + { + "id": "sqlite-atomic-3df12c9e-v1", + "serverCommit": "3df12c9e7d83818bd35c07c1bbef1f00926b0fc7", + "contractSha256": "b4d241e67b5a00fddd0a5d851cf86485fa8ff999e97ec067ee9b15b46a59d968", + "artifactSha256": "29b2e69fc451508f16682981acd131be09e7242c3a8eab7ff578b35ba6221db5", + "operations": [ + "get_access_principal", + "get_artifact_revision", + "get_capabilities", + "get_default_scope", + "get_liveness", + "get_readiness", + "get_scope", + "get_server_info", + "list_scopes", + "remember_memory", + "search_memory" + ], + "evidence": "desktop/VALIDATION.md#server-3df12c9e-protocol-qualification" + }, { "id": "sqlite-atomic-4e8b30aa-v1", "serverCommit": "4e8b30aaa5873cf8f7bca636d2f8eb5f76c17a6e", From b1eef2f84fb3da1ee054826366348eb88533e002 Mon Sep 17 00:00:00 2001 From: JasonDeng <15301580353@163.com> Date: Wed, 4 Nov 2026 19:05:10 +0800 Subject: [PATCH 35/45] refactor(server): keep create_app inside the complexity budget Mounting the reduced route profiles added a second early return to create_app and pushed it to 11 against a limit of 10. Move the infrastructure-only and health-profile boundary into _mount_boundary_routes, which reports whether the data plane stays unmounted. Behaviour and route ordering are unchanged; the liveness route still precedes readiness. --- src/powercontext/server/app.py | 30 +++++++++++++++++++++--------- 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/src/powercontext/server/app.py b/src/powercontext/server/app.py index 008d9617e8..387e7fdd3a 100644 --- a/src/powercontext/server/app.py +++ b/src/powercontext/server/app.py @@ -1423,6 +1423,26 @@ class _PreconditionRequiredError(RuntimeError): """Raised when an Artifact mutation omits If-Match.""" +def _mount_boundary_routes( + app: FastAPI, + *, + infrastructure_only: bool, + route_profile: Literal["full", "health"], +) -> bool: + """Mount the health boundary and report whether the data plane stays unmounted. + + ``infrastructure_only`` exposes liveness alone. ``route_profile="health"`` adds readiness for + distributed Scheduler/Worker roles; metrics are mounted by the caller and every business route + stays on the API replicas. + """ + + _add_route(app, GET_LIVENESS, get_liveness) + if infrastructure_only: + return True + _add_route(app, GET_READINESS, get_readiness) + return route_profile == "health" + + def create_app( *, application: ServerApplication | None = None, @@ -1554,15 +1574,7 @@ async def unexpected_error(request: Request, error: Exception) -> JSONResponse: response.headers[REQUEST_ID_HEADER] = request_id return response - if infrastructure_only: - _add_route(app, GET_LIVENESS, get_liveness) - return app - - _add_route(app, GET_LIVENESS, get_liveness) - _add_route(app, GET_READINESS, get_readiness) - if route_profile == "health": - # Health/metrics boundary: metrics are mounted by the caller. Every - # business route stays on the API replicas. + if _mount_boundary_routes(app, infrastructure_only=infrastructure_only, route_profile=route_profile): return app _add_route(app, CREATE_DREAM_RUN, create_dream_run) From 2dcc253fdd06880cd435f551b880e6abb423bb0f Mon Sep 17 00:00:00 2001 From: JasonDeng <15301580353@163.com> Date: Wed, 4 Nov 2026 19:05:10 +0800 Subject: [PATCH 36/45] style: restore the formatter and end-of-file output CI enforces VALIDATION.md lost its final newline when the qualification records were merged, and two test files added on this branch were wrapped at a width ruff format rejects. Both blocks pass ruff format --check and the touched suites again. --- desktop/VALIDATION.md | 2 +- tests/builtin/persistence/test_mysql_schema.py | 4 +--- tests/test_server_role_routes.py | 4 +--- 3 files changed, 3 insertions(+), 7 deletions(-) diff --git a/desktop/VALIDATION.md b/desktop/VALIDATION.md index 2e7b6bb1ca..772d379c45 100644 --- a/desktop/VALIDATION.md +++ b/desktop/VALIDATION.md @@ -1703,4 +1703,4 @@ headers, and the CI fixture profile was supplied through `OUT_DIR`. The local ha retained the 180-second fixture startup allowance; protocol assertions and product timeouts were unchanged. This qualifies SQLite protocol behavior for this snapshot and wheel, not Windows/macOS execution, native credential persistence, installed UI or release readiness. -Historical qualification records retain their identities and evidence. \ No newline at end of file +Historical qualification records retain their identities and evidence. diff --git a/tests/builtin/persistence/test_mysql_schema.py b/tests/builtin/persistence/test_mysql_schema.py index 846ce6a0f6..257e848660 100644 --- a/tests/builtin/persistence/test_mysql_schema.py +++ b/tests/builtin/persistence/test_mysql_schema.py @@ -202,9 +202,7 @@ def test_documented_obloader_restore_layers_are_parent_first() -> None: restore_layers = restore_plans[0] durable_tables = tuple( - table - for table in BUILTIN_TABLES - if table.name not in transient_tables and table.name != "pc_artifact_vectors" + table for table in BUILTIN_TABLES if table.name not in transient_tables and table.name != "pc_artifact_vectors" ) assert {table.name for table in BUILTIN_TABLES} - {table.name for table in durable_tables} == ( transient_tables | {"pc_artifact_vectors"} diff --git a/tests/test_server_role_routes.py b/tests/test_server_role_routes.py index f73f7d16f0..2000a97dca 100644 --- a/tests/test_server_role_routes.py +++ b/tests/test_server_role_routes.py @@ -25,9 +25,7 @@ def _settings(role: str, **overrides) -> ServerSettings: values = { "deployment": DeploymentConfig(mode="distributed", role=role, id=f"node-{role}"), - "database": OceanBaseConfig( - url="mysql+aoceanbase://user:pass@localhost:2881/powercontext?charset=utf8mb4" - ), + "database": OceanBaseConfig(url="mysql+aoceanbase://user:pass@localhost:2881/powercontext?charset=utf8mb4"), # A distributed Worker validates a generation model at composition time; # the route table assertions below never open the runtime. "inference": {"generation_model": "stub:model"}, From 51c3e612a9928b86afc44d9918f57589217ea6d4 Mon Sep 17 00:00:00 2001 From: JasonDeng <15301580353@163.com> Date: Wed, 4 Nov 2026 19:09:23 +0800 Subject: [PATCH 37/45] fix(runtime): report rate limit maintenance failures instead of dropping them log_safely takes (logger, level, message); the maintenance loop passed logging.WARNING as the logger, so the call raised before any record was emitted and the 'safe' wrapper hid the failure. Pass the module logger, keep the formatted message with exc_info, tag the record with rate-limit.window.maintenance-failed, and cover it with a test that fails when the loop cannot report a failing purge. --- .../builtin/runtime/composition.py | 8 +++- .../builtin/runtime/test_work_composition.py | 48 ++++++++++++++++++- 2 files changed, 54 insertions(+), 2 deletions(-) diff --git a/src/powercontext/builtin/runtime/composition.py b/src/powercontext/builtin/runtime/composition.py index 44e2cd2206..00ceac0efc 100644 --- a/src/powercontext/builtin/runtime/composition.py +++ b/src/powercontext/builtin/runtime/composition.py @@ -1210,7 +1210,13 @@ async def _run(self) -> None: try: await self.purge_once() except Exception as error: - log_safely(logging.WARNING, "rate limit window maintenance failed: %s", error) + log_safely( + logger, + logging.WARNING, + f"rate limit window maintenance failed: {error}", + exc_info=error, + extra={"event": "rate-limit.window.maintenance-failed"}, + ) try: await asyncio.wait_for(self._stop_requested.wait(), timeout=self._interval_seconds) except TimeoutError: diff --git a/tests/builtin/runtime/test_work_composition.py b/tests/builtin/runtime/test_work_composition.py index fd24787622..1509823e9e 100644 --- a/tests/builtin/runtime/test_work_composition.py +++ b/tests/builtin/runtime/test_work_composition.py @@ -15,6 +15,7 @@ from __future__ import annotations import asyncio +import logging from datetime import UTC, datetime, timedelta from time import monotonic from types import SimpleNamespace @@ -239,7 +240,7 @@ def test_distributed_ledger_wires_the_work_observer_into_every_component(monkeyp async def scenario() -> None: observer = _RecordingObserver() - constructed: dict[str, object] = {} + constructed: dict[str, dict[str, object]] = {} class _SpyComponent: def __init__(self, *args, **kwargs) -> None: @@ -344,6 +345,51 @@ async def scenario() -> None: asyncio.run(scenario()) +def test_rate_limit_maintenance_reports_a_failed_purge_and_keeps_running(tmp_path, caplog) -> None: + """A failing purge must be logged through the module logger, not dropped, and must not stop the loop.""" + + async def scenario() -> None: + async with open_builtin_contexts(_config(tmp_path)) as contexts: + attempts: list[int] = [] + twice = asyncio.Event() + + class _FailingPurge: + async def purge_expired(self, connection, *, limit): + attempts.append(limit) + if len(attempts) >= 2: + twice.set() + raise RuntimeError("purge failed") # noqa: TRY003 + + from powercontext.builtin.runtime.composition import _RateLimitWindowMaintenance + + maintenance = _RateLimitWindowMaintenance( + database=contexts.database, + interval_seconds=0.05, + batch_size=7, + repository=_FailingPurge(), + ) + maintenance.start() + await asyncio.wait_for(twice.wait(), timeout=5) + await maintenance.stop() + + # The loop retried after the failure instead of exiting. + assert len(attempts) >= 2 + assert set(attempts) == {7} + failures = [ + record + for record in caplog.records + if record.name == "powercontext.builtin.runtime.composition" + and getattr(record, "event", None) == "rate-limit.window.maintenance-failed" + ] + assert len(failures) == len(attempts) + assert all( + record.getMessage() == "rate limit window maintenance failed: purge failed" for record in failures + ) + assert all(record.levelno == logging.WARNING for record in failures) + + asyncio.run(scenario()) + + def test_rate_limit_maintenance_stops_when_requested(tmp_path) -> None: async def scenario() -> None: async with open_builtin_contexts(_config(tmp_path)) as contexts: From 294a3d9b8f54ca85b432833fba4d541bf3fe418e Mon Sep 17 00:00:00 2001 From: JasonDeng <15301580353@163.com> Date: Wed, 4 Nov 2026 19:09:27 +0800 Subject: [PATCH 38/45] test: satisfy the repository type gate for the new Work Ledger fixtures ty rejects bare tuple annotations in PreparedMemoryWindow and the object-typed fixture dict, and it cannot infer ServerSettings from a dict unpacked into keyword arguments. Annotate the prepared window with its stored Source, ArtifactRef and PreparedAtomicMemory element types and build ServerSettings with the real McpConfig and InferenceConfig models instead of dict literals. --- .../runtime/atomic_memory_processing.py | 12 +++--- tests/test_server_role_routes.py | 37 ++++++++++--------- 2 files changed, 25 insertions(+), 24 deletions(-) diff --git a/src/powercontext/builtin/runtime/atomic_memory_processing.py b/src/powercontext/builtin/runtime/atomic_memory_processing.py index f21e9b6c77..623fe77cfb 100644 --- a/src/powercontext/builtin/runtime/atomic_memory_processing.py +++ b/src/powercontext/builtin/runtime/atomic_memory_processing.py @@ -37,7 +37,7 @@ project_atomic_memory_evidence, require_atomic_memory_pipeline, ) -from powercontext.builtin.artifacts.atomic_memory.models import AtomicMemoryContent +from powercontext.builtin.artifacts.atomic_memory.models import AtomicMemoryContent, PreparedAtomicMemory from powercontext.builtin.artifacts.atomic_memory.reconciliation import ( ATOMIC_MEMORY_RECONCILIATION_INSTRUCTIONS, AtomicMemoryArtifactEvidence, @@ -73,7 +73,7 @@ RepositoryNotFoundError, ) from powercontext.builtin.persistence.memory_windows import MemorySourceWindowRepository -from powercontext.builtin.persistence.sources import SourceRepository +from powercontext.builtin.persistence.sources import SourceRepository, StoredSource from powercontext.builtin.persistence.tables import ( ARTIFACT_HEADS_TABLE, SOURCE_JOURNAL_HEADS_TABLE, @@ -149,11 +149,11 @@ def __init__( cursor, transition, action, - window: tuple, - eligible: tuple, - consulted_artifacts: tuple, + window: tuple[StoredSource, ...], + eligible: tuple[StoredSource, ...], + consulted_artifacts: tuple[ArtifactRef, ...], workset: AtomicMemoryWindowWorkset, - prepared: tuple, + prepared: tuple[PreparedAtomicMemory, ...], decision, observation, high_watermark: int, diff --git a/tests/test_server_role_routes.py b/tests/test_server_role_routes.py index 2000a97dca..f606b31edf 100644 --- a/tests/test_server_role_routes.py +++ b/tests/test_server_role_routes.py @@ -14,25 +14,29 @@ """The deployment role gates the actual factory route table, not only composition.""" +from typing import Literal + import pytest +from pydantic import SecretStr from powercontext.builtin.persistence.oceanbase.profile import OceanBaseConfig -from powercontext.builtin.runtime.config import DeploymentConfig +from powercontext.builtin.runtime.config import DeploymentConfig, InferenceConfig from powercontext.server.factory import create_server_app -from powercontext.server.settings import ServerSettings +from powercontext.server.settings import McpConfig, ServerSettings + +Role = Literal["all", "api", "scheduler", "worker"] +DATABASE_URL = SecretStr("mysql+aoceanbase://user:pass@localhost:2881/powercontext?charset=utf8mb4") -def _settings(role: str, **overrides) -> ServerSettings: - values = { - "deployment": DeploymentConfig(mode="distributed", role=role, id=f"node-{role}"), - "database": OceanBaseConfig(url="mysql+aoceanbase://user:pass@localhost:2881/powercontext?charset=utf8mb4"), +def _settings(role: Role, *, mcp: McpConfig) -> ServerSettings: + return ServerSettings( + deployment=DeploymentConfig(mode="distributed", role=role, id=f"node-{role}"), + database=OceanBaseConfig(url=DATABASE_URL), # A distributed Worker validates a generation model at composition time; # the route table assertions below never open the runtime. - "inference": {"generation_model": "stub:model"}, - "mcp": {"enabled": False}, - } - values.update(overrides) - return ServerSettings(**values) + inference=InferenceConfig(generation_model="stub:model"), + mcp=mcp, + ) def _paths(app) -> set[str]: @@ -41,7 +45,7 @@ def _paths(app) -> set[str]: @pytest.mark.parametrize("role", ["scheduler", "worker"]) def test_distributed_roles_expose_only_the_health_metrics_boundary(role): - app = create_server_app(settings=_settings(role)) + app = create_server_app(settings=_settings(role, mcp=McpConfig(enabled=False))) paths = _paths(app) assert "/health/live" in paths assert "/health/ready" in paths @@ -53,7 +57,7 @@ def test_distributed_roles_expose_only_the_health_metrics_boundary(role): def test_distributed_api_keeps_the_data_plane(): - app = create_server_app(settings=_settings("api")) + app = create_server_app(settings=_settings("api", mcp=McpConfig(enabled=False))) paths = _paths(app) assert "/health/live" in paths assert "/v1/memory/flush" in paths @@ -61,10 +65,7 @@ def test_distributed_api_keeps_the_data_plane(): def test_single_node_keeps_the_data_plane(): - settings = ServerSettings( - database=OceanBaseConfig(url="mysql+aoceanbase://user:pass@localhost:2881/powercontext?charset=utf8mb4"), - mcp={"enabled": False}, - ) + settings = ServerSettings(database=OceanBaseConfig(url=DATABASE_URL), mcp=McpConfig(enabled=False)) paths = _paths(create_server_app(settings=settings)) assert "/v1/memory/flush" in paths assert "/v1/scopes/{scope_id}/operations" in paths @@ -72,4 +73,4 @@ def test_single_node_keeps_the_data_plane(): def test_health_boundary_roles_reject_dashboard_and_mcp(): with pytest.raises(ValueError, match="health and metrics"): - create_server_app(settings=_settings("worker", mcp={"enabled": True})) + create_server_app(settings=_settings("worker", mcp=McpConfig(enabled=True))) From 44d6b76ec045d25fa77346ff5ecf0f7a34e316c5 Mon Sep 17 00:00:00 2001 From: JasonDeng <15301580353@163.com> Date: Wed, 4 Nov 2026 19:22:32 +0800 Subject: [PATCH 39/45] test(cli): expect the Work Ledger head from the packaged migration chain The packaged forward-only chain now ends at v1_5_0_004, so the CLI regression asserted a stale revision after a successful migrate. --- tests/test_server_migration_cli.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_server_migration_cli.py b/tests/test_server_migration_cli.py index 05d9ee2e93..82ed9719d9 100644 --- a/tests/test_server_migration_cli.py +++ b/tests/test_server_migration_cli.py @@ -67,5 +67,5 @@ def test_server_migrate_runs_the_packaged_forward_only_chain(tmp_path) -> None: work_table = connection.execute( "SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'pc_work_items'" ).fetchone() - assert revision == [("v1_5_0_002",)] + assert revision == [("v1_5_0_004",)] assert work_table == ("pc_work_items",) From 8ea1ae22bc75617419bb247f635417c852a6788d Mon Sep 17 00:00:00 2001 From: JasonDeng <15301580353@163.com> Date: Thu, 5 Nov 2026 09:10:22 +0800 Subject: [PATCH 40/45] docs(desktop): qualify the merged contract after restoring statistics The second master merge restored get_stats in the bundled transport, so the eleven-operation record no longer covered the shipped operation set. Record sqlite-atomic-6c2aa7ac-v1 from the built wheel powercontext-1.2.1.dev647+g6c2aa7ac8 (718 packaged runtime files byte-equal to the clean commit, four isolated SQLite Server modes passed, desktopWorkingTreeDirty=false) and supersede the earlier record. --- desktop/VALIDATION.md | 18 +++++++++++++++++- .../src/connections/compatibility.json | 9 +++++---- 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/desktop/VALIDATION.md b/desktop/VALIDATION.md index c8133e871a..47a3b62267 100644 --- a/desktop/VALIDATION.md +++ b/desktop/VALIDATION.md @@ -177,7 +177,7 @@ The historical Server c535d816 protocol qualification below records the specific ## Compatibility scope -[compatibility.json](src-tauri/src/connections/compatibility.json) preserves each recorded Server source, normalized OpenAPI contract digest, qualification-wheel digest and operation set. `sqlite-atomic-3df12c9e-v1` records the bundled contract's twelve operations, including statistics, qualified below. `sqlite-stats-63e9665c-v1` and `sqlite-atomic-3cefa45a-v1` retain their independently qualified operations at the upstream contract digest `d59da9040814d0f2b25d930ecb36b93d932ff8906440606d259d3b9456364d3d`. `sqlite-stats-9c8f061c-v1` retains its original historical qualification and digest. `sqlite-atomic-112db11e-v1` retains its independently qualified eleven operations. Historical records retain their exact identities and evidence, including `sqlite-source-artifact-87b6fae2-v1` and the preceding `sqlite-artifact-merge-87d01f68-v1` record. The `sqlite-atomic-dd62723d-v1` and `sqlite-atomic-4ffdf4e9-v1` records retain their migration-framework and release-preparation source qualifications with their original contract digest. Other historical records also retain their exact identities and evidence. Ordinary builds require explicitly selecting the record whose tested Server source and wheel match the deployment, then checking the connection before business operations become available. Records with a different contract digest are disabled in the selector, and saved unavailable selections remain visible until explicitly replaced. Selecting a profile does not extend its recorded operation set or qualify a different Server source. CI builds have their own artifact identities and do not change product qualification. +[compatibility.json](src-tauri/src/connections/compatibility.json) preserves each recorded Server source, normalized OpenAPI contract digest, qualification-wheel digest and operation set. `sqlite-atomic-6c2aa7ac-v1` records the bundled contract's twelve operations, including statistics, qualified below. It supersedes the eleven-operation `sqlite-atomic-3df12c9e-v1` qualification at the same contract digest, which predates the restored statistics operation in the bundled transport. `sqlite-stats-63e9665c-v1` and `sqlite-atomic-3cefa45a-v1` retain their independently qualified operations at the upstream contract digest `d59da9040814d0f2b25d930ecb36b93d932ff8906440606d259d3b9456364d3d`. `sqlite-stats-9c8f061c-v1` retains its original historical qualification and digest. `sqlite-atomic-112db11e-v1` retains its independently qualified eleven operations. Historical records retain their exact identities and evidence, including `sqlite-source-artifact-87b6fae2-v1` and the preceding `sqlite-artifact-merge-87d01f68-v1` record. The `sqlite-atomic-dd62723d-v1` and `sqlite-atomic-4ffdf4e9-v1` records retain their migration-framework and release-preparation source qualifications with their original contract digest. Other historical records also retain their exact identities and evidence. Ordinary builds require explicitly selecting the record whose tested Server source and wheel match the deployment, then checking the connection before business operations become available. Records with a different contract digest are disabled in the selector, and saved unavailable selections remain visible until explicitly replaced. Selecting a profile does not extend its recorded operation set or qualify a different Server source. CI builds have their own artifact identities and do not change product qualification. The real Server harness covers SQLite with anonymous loopback, static Bearer, HTTPS with explicit CA/base path, and injected-provider/enforced access. It exercises save/search/exact reads, identity changes, binding revocation, ambiguous writes without replay and Scope pagination. Installed UI checks cover repeated launches preserving the existing editor, late committed-save responses preserving a new Scope draft, explicit connection/Scope selection, clipboard copies, connection isolation, draft cancellation, byte limits and independent Server survival after Desktop exit. Historical record `sqlite-atomic-f3a90b2f-v1` remains preserved with its original identities and is disabled for this contract. @@ -185,6 +185,22 @@ Selecting a compatibility profile does not attest the remote binary identity. Ex ### Current-Scope statistics qualification +#### Server 6c2aa7ac protocol qualification + +`sqlite-atomic-6c2aa7ac-v1` qualifies all twelve Desktop operations, including +`get_stats`, against clean Server source +`6c2aa7ac899aabc1a250ca9c56e4b5c94ce64525` and normalized OpenAPI digest +`b4d241e67b5a00fddd0a5d851cf86485fa8ff999e97ec067ee9b15b46a59d968`. +Wheel: `powercontext-1.2.1.dev647+g6c2aa7ac8-py3-none-any.whl`. +Wheel SHA-256: `252205374de37bc9aa06e0b3050f0c689585ca65b512f1c739511c59e3268f97`. +All 718 packaged runtime files in `powercontext/` and +`powercontext_service_bootstrap/` match the clean source byte for byte. +The sorted newline-terminated `wheel/path sha256` manifest hashes to +`e7934ef04d2ec87eb62802609fe38aff950f42ffeb5cc9396173cbaa36d832a6`. +The four isolated SQLite Server configurations (anonymous loopback, static +Bearer, HTTPS with an explicit CA and reverse-proxy base path, and an enforced +authentication Provider) passed with `desktopWorkingTreeDirty=false`. + #### Server 63e9665c statistics product qualification `sqlite-stats-63e9665c-v1` qualifies all twelve Desktop operations, including diff --git a/desktop/src-tauri/src/connections/compatibility.json b/desktop/src-tauri/src/connections/compatibility.json index d7b634f46c..39f5da132c 100644 --- a/desktop/src-tauri/src/connections/compatibility.json +++ b/desktop/src-tauri/src/connections/compatibility.json @@ -1,9 +1,9 @@ [ { - "id": "sqlite-atomic-3df12c9e-v1", - "serverCommit": "3df12c9e7d83818bd35c07c1bbef1f00926b0fc7", + "id": "sqlite-atomic-6c2aa7ac-v1", + "serverCommit": "6c2aa7ac899aabc1a250ca9c56e4b5c94ce64525", "contractSha256": "b4d241e67b5a00fddd0a5d851cf86485fa8ff999e97ec067ee9b15b46a59d968", - "artifactSha256": "29b2e69fc451508f16682981acd131be09e7242c3a8eab7ff578b35ba6221db5", + "artifactSha256": "252205374de37bc9aa06e0b3050f0c689585ca65b512f1c739511c59e3268f97", "operations": [ "get_access_principal", "get_artifact_revision", @@ -13,11 +13,12 @@ "get_readiness", "get_scope", "get_server_info", + "get_stats", "list_scopes", "remember_memory", "search_memory" ], - "evidence": "desktop/VALIDATION.md#server-3df12c9e-protocol-qualification" + "evidence": "desktop/VALIDATION.md#server-6c2aa7ac-protocol-qualification" }, { "id": "sqlite-atomic-4e8b30aa-v1", From 3535c29be95a7084c59fceb64e1c81083bde93c6 Mon Sep 17 00:00:00 2001 From: JasonDeng <15301580353@163.com> Date: Thu, 5 Nov 2026 15:00:40 +0800 Subject: [PATCH 41/45] perf(migrations): declare the Work Ledger indexes inline on MySQL The revision issued fifteen DDL statements on OceanBase: eight tables and seven separate CREATE INDEX operations. Every clean migration replays the whole chain, and GitHub's OceanBase jobs spend the bulk of a fresh-Server case in DDL, so the extra schema changes pushed full-server cases from roughly 88s to 145s and historical-upgrade cases to 167s against the 180s per-case deadline. Declare each secondary index in its CREATE TABLE statement and keep the changes list one-to-one with the statements. OceanBase now runs eight DDL statements for this revision instead of fifteen, with the same columns, constraint names, index names and column order. Refresh the schema review digest for the new managed input. --- release/upgrade-support.json | 4 +- .../schemas/mysql-v1_5_0_004.json | 2853 +---------------- 2 files changed, 81 insertions(+), 2776 deletions(-) diff --git a/release/upgrade-support.json b/release/upgrade-support.json index 7cec6f4e5e..4ac4aaf650 100644 --- a/release/upgrade-support.json +++ b/release/upgrade-support.json @@ -3,8 +3,8 @@ "target_version": "1.5.0", "mode": "upgrade-required", "schema_review": { - "persistence_sha256": "cd75d58c73c3175cc1fcd04faf17f430054ad57de25d61854c85bebb89c5e823", - "rationale": "The reusable Server migration framework registers actual PowerContext 1.3.0 through s1300 -> s1400 -> s1401 -> s1402 and actual 1.4.0 through the separate immutable r1400 -> s1400 -> s1401 -> s1402 source bridge. Original 1.3 resources and checksums remain byte-identical. Real 1.4.0 SQLite, embedded seekDB and OceanBase structures were captured from the official wheel in all twelve vector/Code configurations; provenance and exact source layouts are packaged with the bridge. The 1.4 bridge preserves current Candidate, Artifact and task data, adds the missing Topic budget/block structure, revokes old lease fences and joins the existing archive/portable-storage path. Empty retained tables align the final schema without inventing 1.3 Candidate history. Source histories retain their identity through subsequent upgrades and recovery; shared head names never authorize adopting unknown layouts. Vector profiles remain bound to historical model, profile ID, dimension, unit normalization and L2 distance, with no provider calls during maintenance. Complete schema, domain, projection, profile and retained-history checks precede startup and partial maintenance. Local SQLite and embedded seekDB startup reject an observable active maintenance lock through a read-only probe. SQLite runtime admission validates the default adjacent journal, rejecting unfinished full or partial maintenance, changed recorded revision checksums and missing required retained tables. Absent journals do not invent retention obligations; custom evidence directories require explicit operator verification and remote coordinator locks remain subject to deployment orchestration. Native read-only readiness uses a consistent snapshot and does not acquire Atomic Memory head locks; normal business transaction isolation is unchanged. Offline projection repairs retain the original locked state comparison and write-side CAS protection. Target locks, backup choices, original recovery evidence and explicit remote acknowledgments remain enforced. The installed-artifact catalog retains twelve 1.3.0 source/backend/vector/Code combinations. Embedded seekDB uses pylibseekdb 1.4.0.post2 or newer with the recovered-transaction lock fix. Main and publication require all twelve configurations, including embedded seekDB full-server-code and full-server-vector-code. No configuration is waived; incomplete, failed or skipped evidence rejects acceptance. The seekdb-code diagnostic selection cannot authorize publication. The registered 1.4.0 bridge remains implemented but is outside the CI upgrade acceptance matrix; passing this gate does not certify that path. The gate preserves source-specific Processing counters and isolates OceanBase source databases. Any failed, skipped or missing automatic case rejects publication. The policy remains upgrade-required until the exact target artifact passes the required automatic matrix and the reviewed support declaration is updated. Captured source structure, local tests and checkpoint replay are distinct from installed-release acceptance, native interruption/observer recovery and backup restoration. The Candidate generation prototype observes actual SQLite/seekDB/OceanBase migration checkpoints outside the production bundle; it does not replace frozen registrations or weaken recovery checks. Existing target schema and data formats, including batched persistence interfaces, remain governed by the shared registered path. The synchronized master adds blank-text validation at Source creation and managed worker logging. It changes neither registered table layout nor migration data encodings; existing stored Source values are not rewritten. The source bridges and frozen history remain unchanged. Compatibility commands identify Server-owned storage before opening a writable legacy profile and delegate source admission to the shared framework, including actual 1.4.0 and rejection of unknown layouts. Partial Processing and Atomic Memory projection maintenance verify the original recorded revision checksums; native admission also validates typed journal identity and its completed position against the database revision. Persisted vector profiles reject unsupported partial maintenance before plans, backups or operation evidence, including when the supplied configuration omits embeddings. These are admission-only changes; frozen migration resources, registered layouts, historical checksums and data transformations remain unchanged. PowerContext 1.4.1 assigns release-based presentation names v1_4_1_001 through v1_4_1_003 to the existing shared upgrade steps; the 1.3.0 and 1.4.0 source baselines remain independently registered. Existing revision IDs, bundle and revision checksums, accepted plan identities and journal schemas are unchanged. New Server revisions use software-version-plus-sequence names directly; software releases without storage changes may reuse the same database revision. Naming does not grant acceptance or replace source registration. Full Server status, plan, apply and verify reject unfinished Processing configuration and Atomic Memory projection journals through the same typed admission check used by partial maintenance and SQLite startup. The owning domain runner can still resume its own journal. This only changes admission; frozen revisions, journal formats, database layouts and data transformations remain unchanged. Revision v1_4_1_004 registers the pc_access_owner_recovery outbox on the shared Server chain for both installed-release sources and all registered vector/Code profiles. It creates an empty table through explicit maintenance for existing managed databases and through the registered bootstrap for fresh databases. Earlier revision resources, fingerprints, checksums and existing domain row encodings remain unchanged. External relationship writers use version-1 Artifact and Candidate intents committed with domain data; replay retains the original deployment, principal, resource or target, and idempotency identity. Stable operation and audit event identities prevent lost acknowledgements from duplicating ownership or audit. Conflicting intents are rejected and unknown historical ownership is not inferred. Unregistered outbox layouts are not adopted merely from a table name. This registration does not establish installed-release or external-Provider upgrade acceptance. The policy remains upgrade-required with the full automatic matrix and publication restrictions described above. Database selection now defaults to powercontext for seekDB and for OceanBase URLs without a database, while explicit database names remain authoritative. An implicit seekDB selection is rejected before initialization when the legacy test database contains pc_ tables; operators can explicitly select database=test to retain those records, and no database is moved or deleted automatically. Bootstrap reuses an accessible selected database without CREATE privilege and creates only a confirmed missing database, with idempotent creation and explicit permission errors. Read-only maintenance on an initialized MySQL-compatible engine requires the selected database and never provisions it; an absent or empty seekDB instance remains a no-engine initial plan. MySQL initialization creates only missing requested tables and indexes, and sorts only those requested tables; existing registered tables are preserved. Code graph connections use the selected database, retain historical graph identities for test, and distinguish other database identities. Server identity initialization avoids redundant MySQL DDL on an existing table. Frozen migration resources, revision identifiers and checksums, registered table definitions, journal formats, and existing row encodings are unchanged. These runtime selection and admission changes do not establish an accepted upgrade path; the policy remains upgrade-required and the installed-release acceptance gate still applies. MySQL recovery rejects different registered table sets before normalization. Backend-default normalization copies only the column and index maps it changes and compares each table without cloning whole inventories. Admitted layouts, registered transitions, recovery checks, schema equality and caller-owned inventories remain preserved. SeekDB status and verify distinguish a missing configured database from an uninitialized engine and denied access. Read-only plan can represent a missing selected database as an uninitialized target without creating it; apply provisions that selected database only after the operator accepts its bound plan, then rechecks the target under the maintenance lock. Installed-release seekDB acceptance explicitly retains the historical test database and separately verifies that omitted selection is refused. Registered schema resources, revision checksums, row encodings and write-side provisioning remain unchanged. Server startup can apply an uninitialized plan to provision a missing selected seekDB database; plan, status and verify still never provision it. OceanBase 4.6 SHOW CREATE TABLE can reflect FTS_INDEX_TYPE=MATCH or PHRASE_MATCH on FULLTEXT indexes. Reflection accepts these known types without changing the logical inventory; unknown values remain rejected and indexed columns, parser identity and token-size properties remain significant. PHRASE_MATCH additionally supports phrase matching; the registered layouts require term matching and do not declare a phrase-only contract. Frozen migration resources, checksums, recovery evidence and persisted layouts remain unchanged. Native 4.6.3.1 DDL was captured from a real installed 1.3.0 database; replay of this fixture does not establish complete native upgrade acceptance. The CI database image and master full automatic upgrade matrix remain unchanged. Revision v1_4_1_005 adds nullable Atomic Memory description projection metadata after the registered owner-recovery revision, through explicit SQLite and MySQL schema transitions for both source chains and all vector/Code profiles. Earlier revision resources, fingerprints and checksums remain unchanged. The additive change neither rewrites authoritative Artifact history nor generates summaries or scans stored bodies. Existing managed databases require explicit migration; existing descriptions remain null until a new write or explicit projection repair copies authoritative metadata. Intermediate projection validation uses only columns available at that revision and still rejects inconsistent existing fields. Unmanaged SDK initialization retains its nullable-column compatibility path. The installed-release acceptance matrix and publication restrictions remain required; local regression checks do not establish native upgrade acceptance. Artifact plugin storage extends the shared Server chain from v1_4_1_006 to v1_5_0_001 with registration columns, family declarations, search state and projections, index coverage and changes, and vector-space/index registrations. SQLite also registers portable vectors and FTS auxiliary structures. Native registration columns are added in one ALTER with a matching recovery checkpoint; final column definitions and constraints are unchanged. Native vector tables are created online from registered spaces and are admitted to later maintenance inventories only after exact layout validation. Historical migration resources, retained objects, and existing data are preserved. This review does not expand the supported upgrade matrix or certify installed-release acceptance. Source write plugin registrations use the additive v1_4_1_006 revision after v1_4_1_005 on both source chains. It creates only pc_source_write_plugins; all earlier master revision scripts, schema resources and checksums remain unchanged. Revision v1_5_0_002 follows v1_5_0_001 and adds retained Source evidence bindings, exact Atomic Memory evidence snapshots and their schema marker on all registered backends. A new versioned domain step verifies retained evidence for lexical and vector profiles; earlier released domain resources and recorded revision checksums remain byte-identical. Existing Sources and Memory revisions without accepted declarations stay neutral, with no trust backfill. Current projection maintenance and rebuild explicitly require evidence validation. This additive registration does not establish installed-release acceptance; the upgrade-required gate and publication restrictions are unchanged. Revision v1_5_0_003 adds the disposable exact-revision pc_atomic_memory_evidence projection through the existing index adapters. Transactional body writes publish it from accepted evidence; scoped portable restore and maintenance rebuild derive it from retained history without current-registry trust backfill or model calls. Domain conformance compares historical and derived values, including neutral legacy declarations, on lexical and vector layouts. Earlier revision resources and fingerprints remain unchanged. Local SQLite recovery and real OceanBase 4.4.2.1 evidence conformance were exercised; these do not certify installed-release upgrades or change publication restrictions. Source evidence maintenance now accepts the deployment's own Source definition registry, reports one bounded neutralization reason through operational logs, batches exact-revision reads and caches retained-schema validation per manifest fingerprint. Indexed repair accepts that registry explicitly; the migration bundle still admits evidence against the builtin registry and says so. These changes alter neither registered table layouts nor retained encodings, and the runtime and Server evidence DDL remain byte-identical. The policy remains upgrade-required with the full automatic matrix and publication restrictions described above. Source definition manifests derive every field whose default is null from the validation schema, so an already accepted Definition keeps its fingerprint and re-registers after upgrade; populating such a field requires the nested model to serialize by alias. Registered layouts and retained encodings are unchanged. The runtime MySQL-family table fast path now runs the registered additive evidence migration before creating other tables, so an unmanaged OceanBase or seekDB database cannot retain evidence tables without their p0005 marker.. Code publication recovery adds an optional per-binding publication.json journal that records a generation identifier before its database commit and final directory rename. Caches created before this journal existed remain readable without synthesizing historical publication records; identifiers for already orphaned rows are not inferred. New seekDB generations retain the existing identifier, graph descriptor, manifest and row encodings, while reclamation validates the recorded identifier and scopes deletion to its binding. Live or reader-retained generation directories and the in-flight publication are protected from journal-window reclamation. SQLite graph rows remain owned by their generation directory. This additive local cache journal changes recovery behavior but does not change registered SQL table layouts, migration resources, revision identifiers, historical checksums or the Server migration journal. The policy remains upgrade-required, and the complete installed-release acceptance matrix remains mandatory; this compatibility review is not evidence of completed native upgrade acceptance. Managed Ops admission validates a private main-file and WAL snapshot through the exact target Server bundle, retaining the original database identity and maintenance evidence. Snapshot admission is read-only and cannot initialize, migrate or recover storage. It adds no schema revision, changes no persisted row or journal format, and does not expand supported upgrade declarations or the installed-release acceptance matrix. PreparedContext Receipt records the actual FTS mode on Experience search outcomes. This adds only an in-memory observation field; it does not change stored Experience rows, SQL selection, schema registrations, migration resources, or data encodings. Unmanaged historical Server databases are admitted by required structure and domain state, then normalized before adopting an existing checkpoint. Historical Memory conversion, Scope preservation, SQLite rollback and native DDL recovery use the shared maintenance flow. Independent extension objects and harmless DDL differences do not require clean-install fingerprints. Existing immutable revision resources and the installed-release acceptance matrix remain unchanged. Local historical tests do not certify arbitrary tenant defaults, every vector/Code configuration or future releases. SQLite compatibility validation includes effective primary-key collations and rejects conflicting identity semantics. Historical normalization and the frozen s1400 rebuild preserve generated extension definitions while copying only writable columns. Both virtual and stored generated values remain computed by SQLite; immutable revision resources and the native backend paths are unchanged. SQLite table rebuilds preserve declared collations on ordinary and generated extension columns, including their inherited index comparison semantics. Expression-local COLLATE clauses remain part of their expressions and do not change the column collation. This changes only SQLite compatibility normalization; frozen revisions and native backend execution remain unchanged. Historical Memory admission recognizes lineage ownership columns and default non-merge references while rejecting unmapped payloads. Native compatibility journals record temporary Processing counter ownership before DDL and clear it after cleanup, including completed-phase recovery. SQLite extension preservation also covers the registered r1400 bridge. Frozen migration resources and the installed-release acceptance matrix remain unchanged. Native historical Atomic conversion creates nullable description metadata with the registered UTF-8 binary collation, allowing the later frozen ADD transition to recognize its completed postcondition without duplicate DDL. SQLite historical normalization and registered source bridges preserve dependent views and their triggers during same-name table replacement. Legacy rename behavior is limited to the exact staging-to-final rename statements and the original connection setting is restored; genuine table renames still update dependent references. Frozen migration resources and native backend behavior remain unchanged. SQLite admission validates required extension defaults using SQLite deterministic-expression restrictions and a non-NULL evaluation on an isolated connection. Connection-state, random and clock-dependent expressions are rejected; direct CURRENT_DATE, CURRENT_TIME and CURRENT_TIMESTAMP defaults remain supported alongside usable constant expressions. Regression coverage verifies consecutive Source writes in distinct scopes within the same transaction. Read-only verification and startup admission share this rule; supported defaults remain usable through Source write and readback. Independent extension tables remain preserved through managed upgrades and initialization recovery; missing recovery evidence and missing vector configuration still block maintenance. Registered native column additions and modifications materialize the charset and collation declared by their immutable postcondition when the SQL would otherwise inherit a historical table default. This preserves GBK-origin upgrade recovery without changing frozen resources, existing table defaults, independent extension encodings or readiness requirements. SQLite Candidate extension columns and their values survive both stages of the registered Dream rebuild, including generated columns and dependent views. Frozen source introspection projects the already-validated managed columns without altering stored extension data. Independent indexes use SQLite-rewritten definitions after genuine table renames. Native CHECK removal resolves the actual constraint name from the registered expression and enforcement semantics, including recovery after committed DDL. Frozen resources, registered revision checksums and the installed-release acceptance matrix remain unchanged. SQLite admission rejects additional NOT NULL generated columns because computed values can reject valid Source writes. Nullable virtual and stored extensions remain preserved across historical upgrades and support Source write/readback. Frozen migrations and native backend behavior are unchanged. Ownership recovery preflight derives candidate-family filters from the exact migration identities, including Prompt Candidates that do not have standalone Artifact owners. Static Scope permission previews now use the same complete internal receipt keys as bootstrap for long Scope IDs. These authorization-only changes add no stored fields, row encoding, schema, or migration registration. Static Scope preset keys retain existing short identities and use SHA-256 for oversized relationship keys. Bootstrap and read-only preview recognize both bounded keys and historical receipts, including revoked bindings. No schema or migration registration changes. Revision v1_5_0_004 follows v1_5_0_003 and registers the durable Work Ledger tables (pc_work_lanes, pc_work_items, pc_work_keys, pc_work_attempts, pc_scheduler_leases, pc_scheduler_scans, pc_runtime_members and the shared pc_rate_limit_windows) on every registered backend; it only creates new tables and leaves earlier revision scripts, schema resources and checksums unchanged. Ledger execution stays at-least-once behind a monotonic fence token while its commit is at-most-once, so a retried, superseded or cancelled attempt cannot publish a duplicate result or release another worker's logical key. This additive registration does not establish installed-release acceptance; the upgrade-required gate and publication restrictions are unchanged. The maintenance backup scope (affected_objects) lists the same eight tables, so a backup taken before this revision covers everything it creates." + "persistence_sha256": "ada20a3a970e61659a1fd723686246cf3b36301747c57193dfe429b9b2ff3b07", + "rationale": "The reusable Server migration framework registers actual PowerContext 1.3.0 through s1300 -> s1400 -> s1401 -> s1402 and actual 1.4.0 through the separate immutable r1400 -> s1400 -> s1401 -> s1402 source bridge. Original 1.3 resources and checksums remain byte-identical. Real 1.4.0 SQLite, embedded seekDB and OceanBase structures were captured from the official wheel in all twelve vector/Code configurations; provenance and exact source layouts are packaged with the bridge. The 1.4 bridge preserves current Candidate, Artifact and task data, adds the missing Topic budget/block structure, revokes old lease fences and joins the existing archive/portable-storage path. Empty retained tables align the final schema without inventing 1.3 Candidate history. Source histories retain their identity through subsequent upgrades and recovery; shared head names never authorize adopting unknown layouts. Vector profiles remain bound to historical model, profile ID, dimension, unit normalization and L2 distance, with no provider calls during maintenance. Complete schema, domain, projection, profile and retained-history checks precede startup and partial maintenance. Local SQLite and embedded seekDB startup reject an observable active maintenance lock through a read-only probe. SQLite runtime admission validates the default adjacent journal, rejecting unfinished full or partial maintenance, changed recorded revision checksums and missing required retained tables. Absent journals do not invent retention obligations; custom evidence directories require explicit operator verification and remote coordinator locks remain subject to deployment orchestration. Native read-only readiness uses a consistent snapshot and does not acquire Atomic Memory head locks; normal business transaction isolation is unchanged. Offline projection repairs retain the original locked state comparison and write-side CAS protection. Target locks, backup choices, original recovery evidence and explicit remote acknowledgments remain enforced. The installed-artifact catalog retains twelve 1.3.0 source/backend/vector/Code combinations. Embedded seekDB uses pylibseekdb 1.4.0.post2 or newer with the recovered-transaction lock fix. Main and publication require all twelve configurations, including embedded seekDB full-server-code and full-server-vector-code. No configuration is waived; incomplete, failed or skipped evidence rejects acceptance. The seekdb-code diagnostic selection cannot authorize publication. The registered 1.4.0 bridge remains implemented but is outside the CI upgrade acceptance matrix; passing this gate does not certify that path. The gate preserves source-specific Processing counters and isolates OceanBase source databases. Any failed, skipped or missing automatic case rejects publication. The policy remains upgrade-required until the exact target artifact passes the required automatic matrix and the reviewed support declaration is updated. Captured source structure, local tests and checkpoint replay are distinct from installed-release acceptance, native interruption/observer recovery and backup restoration. The Candidate generation prototype observes actual SQLite/seekDB/OceanBase migration checkpoints outside the production bundle; it does not replace frozen registrations or weaken recovery checks. Existing target schema and data formats, including batched persistence interfaces, remain governed by the shared registered path. The synchronized master adds blank-text validation at Source creation and managed worker logging. It changes neither registered table layout nor migration data encodings; existing stored Source values are not rewritten. The source bridges and frozen history remain unchanged. Compatibility commands identify Server-owned storage before opening a writable legacy profile and delegate source admission to the shared framework, including actual 1.4.0 and rejection of unknown layouts. Partial Processing and Atomic Memory projection maintenance verify the original recorded revision checksums; native admission also validates typed journal identity and its completed position against the database revision. Persisted vector profiles reject unsupported partial maintenance before plans, backups or operation evidence, including when the supplied configuration omits embeddings. These are admission-only changes; frozen migration resources, registered layouts, historical checksums and data transformations remain unchanged. PowerContext 1.4.1 assigns release-based presentation names v1_4_1_001 through v1_4_1_003 to the existing shared upgrade steps; the 1.3.0 and 1.4.0 source baselines remain independently registered. Existing revision IDs, bundle and revision checksums, accepted plan identities and journal schemas are unchanged. New Server revisions use software-version-plus-sequence names directly; software releases without storage changes may reuse the same database revision. Naming does not grant acceptance or replace source registration. Full Server status, plan, apply and verify reject unfinished Processing configuration and Atomic Memory projection journals through the same typed admission check used by partial maintenance and SQLite startup. The owning domain runner can still resume its own journal. This only changes admission; frozen revisions, journal formats, database layouts and data transformations remain unchanged. Revision v1_4_1_004 registers the pc_access_owner_recovery outbox on the shared Server chain for both installed-release sources and all registered vector/Code profiles. It creates an empty table through explicit maintenance for existing managed databases and through the registered bootstrap for fresh databases. Earlier revision resources, fingerprints, checksums and existing domain row encodings remain unchanged. External relationship writers use version-1 Artifact and Candidate intents committed with domain data; replay retains the original deployment, principal, resource or target, and idempotency identity. Stable operation and audit event identities prevent lost acknowledgements from duplicating ownership or audit. Conflicting intents are rejected and unknown historical ownership is not inferred. Unregistered outbox layouts are not adopted merely from a table name. This registration does not establish installed-release or external-Provider upgrade acceptance. The policy remains upgrade-required with the full automatic matrix and publication restrictions described above. Database selection now defaults to powercontext for seekDB and for OceanBase URLs without a database, while explicit database names remain authoritative. An implicit seekDB selection is rejected before initialization when the legacy test database contains pc_ tables; operators can explicitly select database=test to retain those records, and no database is moved or deleted automatically. Bootstrap reuses an accessible selected database without CREATE privilege and creates only a confirmed missing database, with idempotent creation and explicit permission errors. Read-only maintenance on an initialized MySQL-compatible engine requires the selected database and never provisions it; an absent or empty seekDB instance remains a no-engine initial plan. MySQL initialization creates only missing requested tables and indexes, and sorts only those requested tables; existing registered tables are preserved. Code graph connections use the selected database, retain historical graph identities for test, and distinguish other database identities. Server identity initialization avoids redundant MySQL DDL on an existing table. Frozen migration resources, revision identifiers and checksums, registered table definitions, journal formats, and existing row encodings are unchanged. These runtime selection and admission changes do not establish an accepted upgrade path; the policy remains upgrade-required and the installed-release acceptance gate still applies. MySQL recovery rejects different registered table sets before normalization. Backend-default normalization copies only the column and index maps it changes and compares each table without cloning whole inventories. Admitted layouts, registered transitions, recovery checks, schema equality and caller-owned inventories remain preserved. SeekDB status and verify distinguish a missing configured database from an uninitialized engine and denied access. Read-only plan can represent a missing selected database as an uninitialized target without creating it; apply provisions that selected database only after the operator accepts its bound plan, then rechecks the target under the maintenance lock. Installed-release seekDB acceptance explicitly retains the historical test database and separately verifies that omitted selection is refused. Registered schema resources, revision checksums, row encodings and write-side provisioning remain unchanged. Server startup can apply an uninitialized plan to provision a missing selected seekDB database; plan, status and verify still never provision it. OceanBase 4.6 SHOW CREATE TABLE can reflect FTS_INDEX_TYPE=MATCH or PHRASE_MATCH on FULLTEXT indexes. Reflection accepts these known types without changing the logical inventory; unknown values remain rejected and indexed columns, parser identity and token-size properties remain significant. PHRASE_MATCH additionally supports phrase matching; the registered layouts require term matching and do not declare a phrase-only contract. Frozen migration resources, checksums, recovery evidence and persisted layouts remain unchanged. Native 4.6.3.1 DDL was captured from a real installed 1.3.0 database; replay of this fixture does not establish complete native upgrade acceptance. The CI database image and master full automatic upgrade matrix remain unchanged. Revision v1_4_1_005 adds nullable Atomic Memory description projection metadata after the registered owner-recovery revision, through explicit SQLite and MySQL schema transitions for both source chains and all vector/Code profiles. Earlier revision resources, fingerprints and checksums remain unchanged. The additive change neither rewrites authoritative Artifact history nor generates summaries or scans stored bodies. Existing managed databases require explicit migration; existing descriptions remain null until a new write or explicit projection repair copies authoritative metadata. Intermediate projection validation uses only columns available at that revision and still rejects inconsistent existing fields. Unmanaged SDK initialization retains its nullable-column compatibility path. The installed-release acceptance matrix and publication restrictions remain required; local regression checks do not establish native upgrade acceptance. Artifact plugin storage extends the shared Server chain from v1_4_1_006 to v1_5_0_001 with registration columns, family declarations, search state and projections, index coverage and changes, and vector-space/index registrations. SQLite also registers portable vectors and FTS auxiliary structures. Native registration columns are added in one ALTER with a matching recovery checkpoint; final column definitions and constraints are unchanged. Native vector tables are created online from registered spaces and are admitted to later maintenance inventories only after exact layout validation. Historical migration resources, retained objects, and existing data are preserved. This review does not expand the supported upgrade matrix or certify installed-release acceptance. Source write plugin registrations use the additive v1_4_1_006 revision after v1_4_1_005 on both source chains. It creates only pc_source_write_plugins; all earlier master revision scripts, schema resources and checksums remain unchanged. Revision v1_5_0_002 follows v1_5_0_001 and adds retained Source evidence bindings, exact Atomic Memory evidence snapshots and their schema marker on all registered backends. A new versioned domain step verifies retained evidence for lexical and vector profiles; earlier released domain resources and recorded revision checksums remain byte-identical. Existing Sources and Memory revisions without accepted declarations stay neutral, with no trust backfill. Current projection maintenance and rebuild explicitly require evidence validation. This additive registration does not establish installed-release acceptance; the upgrade-required gate and publication restrictions are unchanged. Revision v1_5_0_003 adds the disposable exact-revision pc_atomic_memory_evidence projection through the existing index adapters. Transactional body writes publish it from accepted evidence; scoped portable restore and maintenance rebuild derive it from retained history without current-registry trust backfill or model calls. Domain conformance compares historical and derived values, including neutral legacy declarations, on lexical and vector layouts. Earlier revision resources and fingerprints remain unchanged. Local SQLite recovery and real OceanBase 4.4.2.1 evidence conformance were exercised; these do not certify installed-release upgrades or change publication restrictions. Source evidence maintenance now accepts the deployment's own Source definition registry, reports one bounded neutralization reason through operational logs, batches exact-revision reads and caches retained-schema validation per manifest fingerprint. Indexed repair accepts that registry explicitly; the migration bundle still admits evidence against the builtin registry and says so. These changes alter neither registered table layouts nor retained encodings, and the runtime and Server evidence DDL remain byte-identical. The policy remains upgrade-required with the full automatic matrix and publication restrictions described above. Source definition manifests derive every field whose default is null from the validation schema, so an already accepted Definition keeps its fingerprint and re-registers after upgrade; populating such a field requires the nested model to serialize by alias. Registered layouts and retained encodings are unchanged. The runtime MySQL-family table fast path now runs the registered additive evidence migration before creating other tables, so an unmanaged OceanBase or seekDB database cannot retain evidence tables without their p0005 marker.. Code publication recovery adds an optional per-binding publication.json journal that records a generation identifier before its database commit and final directory rename. Caches created before this journal existed remain readable without synthesizing historical publication records; identifiers for already orphaned rows are not inferred. New seekDB generations retain the existing identifier, graph descriptor, manifest and row encodings, while reclamation validates the recorded identifier and scopes deletion to its binding. Live or reader-retained generation directories and the in-flight publication are protected from journal-window reclamation. SQLite graph rows remain owned by their generation directory. This additive local cache journal changes recovery behavior but does not change registered SQL table layouts, migration resources, revision identifiers, historical checksums or the Server migration journal. The policy remains upgrade-required, and the complete installed-release acceptance matrix remains mandatory; this compatibility review is not evidence of completed native upgrade acceptance. Managed Ops admission validates a private main-file and WAL snapshot through the exact target Server bundle, retaining the original database identity and maintenance evidence. Snapshot admission is read-only and cannot initialize, migrate or recover storage. It adds no schema revision, changes no persisted row or journal format, and does not expand supported upgrade declarations or the installed-release acceptance matrix. PreparedContext Receipt records the actual FTS mode on Experience search outcomes. This adds only an in-memory observation field; it does not change stored Experience rows, SQL selection, schema registrations, migration resources, or data encodings. Unmanaged historical Server databases are admitted by required structure and domain state, then normalized before adopting an existing checkpoint. Historical Memory conversion, Scope preservation, SQLite rollback and native DDL recovery use the shared maintenance flow. Independent extension objects and harmless DDL differences do not require clean-install fingerprints. Existing immutable revision resources and the installed-release acceptance matrix remain unchanged. Local historical tests do not certify arbitrary tenant defaults, every vector/Code configuration or future releases. SQLite compatibility validation includes effective primary-key collations and rejects conflicting identity semantics. Historical normalization and the frozen s1400 rebuild preserve generated extension definitions while copying only writable columns. Both virtual and stored generated values remain computed by SQLite; immutable revision resources and the native backend paths are unchanged. SQLite table rebuilds preserve declared collations on ordinary and generated extension columns, including their inherited index comparison semantics. Expression-local COLLATE clauses remain part of their expressions and do not change the column collation. This changes only SQLite compatibility normalization; frozen revisions and native backend execution remain unchanged. Historical Memory admission recognizes lineage ownership columns and default non-merge references while rejecting unmapped payloads. Native compatibility journals record temporary Processing counter ownership before DDL and clear it after cleanup, including completed-phase recovery. SQLite extension preservation also covers the registered r1400 bridge. Frozen migration resources and the installed-release acceptance matrix remain unchanged. Native historical Atomic conversion creates nullable description metadata with the registered UTF-8 binary collation, allowing the later frozen ADD transition to recognize its completed postcondition without duplicate DDL. SQLite historical normalization and registered source bridges preserve dependent views and their triggers during same-name table replacement. Legacy rename behavior is limited to the exact staging-to-final rename statements and the original connection setting is restored; genuine table renames still update dependent references. Frozen migration resources and native backend behavior remain unchanged. SQLite admission validates required extension defaults using SQLite deterministic-expression restrictions and a non-NULL evaluation on an isolated connection. Connection-state, random and clock-dependent expressions are rejected; direct CURRENT_DATE, CURRENT_TIME and CURRENT_TIMESTAMP defaults remain supported alongside usable constant expressions. Regression coverage verifies consecutive Source writes in distinct scopes within the same transaction. Read-only verification and startup admission share this rule; supported defaults remain usable through Source write and readback. Independent extension tables remain preserved through managed upgrades and initialization recovery; missing recovery evidence and missing vector configuration still block maintenance. Registered native column additions and modifications materialize the charset and collation declared by their immutable postcondition when the SQL would otherwise inherit a historical table default. This preserves GBK-origin upgrade recovery without changing frozen resources, existing table defaults, independent extension encodings or readiness requirements. SQLite Candidate extension columns and their values survive both stages of the registered Dream rebuild, including generated columns and dependent views. Frozen source introspection projects the already-validated managed columns without altering stored extension data. Independent indexes use SQLite-rewritten definitions after genuine table renames. Native CHECK removal resolves the actual constraint name from the registered expression and enforcement semantics, including recovery after committed DDL. Frozen resources, registered revision checksums and the installed-release acceptance matrix remain unchanged. SQLite admission rejects additional NOT NULL generated columns because computed values can reject valid Source writes. Nullable virtual and stored extensions remain preserved across historical upgrades and support Source write/readback. Frozen migrations and native backend behavior are unchanged. Ownership recovery preflight derives candidate-family filters from the exact migration identities, including Prompt Candidates that do not have standalone Artifact owners. Static Scope permission previews now use the same complete internal receipt keys as bootstrap for long Scope IDs. These authorization-only changes add no stored fields, row encoding, schema, or migration registration. Static Scope preset keys retain existing short identities and use SHA-256 for oversized relationship keys. Bootstrap and read-only preview recognize both bounded keys and historical receipts, including revoked bindings. No schema or migration registration changes. Revision v1_5_0_004 follows v1_5_0_003 and registers the durable Work Ledger tables (pc_work_lanes, pc_work_items, pc_work_keys, pc_work_attempts, pc_scheduler_leases, pc_scheduler_scans, pc_runtime_members and the shared pc_rate_limit_windows) on every registered backend; it only creates new tables and leaves earlier revision scripts, schema resources and checksums unchanged. Ledger execution stays at-least-once behind a monotonic fence token while its commit is at-most-once, so a retried, superseded or cancelled attempt cannot publish a duplicate result or release another worker's logical key. This additive registration does not establish installed-release acceptance; the upgrade-required gate and publication restrictions are unchanged. The maintenance backup scope (affected_objects) lists the same eight tables, so a backup taken before this revision covers everything it creates. The revision creates its eight tables with their secondary indexes declared inline, so an OceanBase clean migration issues eight DDL statements instead of fifteen." }, "cases": [], "automatic_sources": { diff --git a/src/powercontext/builtin/persistence/migrations/server_resources/schemas/mysql-v1_5_0_004.json b/src/powercontext/builtin/persistence/migrations/server_resources/schemas/mysql-v1_5_0_004.json index 6fd15cd034..56342f0700 100644 --- a/src/powercontext/builtin/persistence/migrations/server_resources/schemas/mysql-v1_5_0_004.json +++ b/src/powercontext/builtin/persistence/migrations/server_resources/schemas/mysql-v1_5_0_004.json @@ -4,20 +4,13 @@ "v1_5_0_004": { "statements": [ "CREATE TABLE pc_work_lanes (\n\tlane_key VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, \n\thead_sequence BIGINT, \n\tnext_sequence BIGINT NOT NULL, \n\tPRIMARY KEY (lane_key), \n\tCONSTRAINT ck_pc_work_lanes_head_positive CHECK (head_sequence IS NULL OR head_sequence > 0), \n\tCONSTRAINT ck_pc_work_lanes_next_positive CHECK (next_sequence > 0)\n)", - "CREATE TABLE pc_work_items (\n\twork_id VARCHAR(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, \n\tlogical_key VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, \n\tlane_key VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, \n\tlane_sequence BIGINT NOT NULL, \n\tkind VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, \n\tpayload_version INTEGER NOT NULL, \n\tscope_id VARCHAR(256) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, \n\tpayload MEDIUMBLOB NOT NULL, \n\tstatus VARCHAR(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, \n\tavailable_at DATETIME(6) NOT NULL, \n\tlease_owner VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin, \n\tlease_fence BIGINT NOT NULL, \n\tlease_expires_at DATETIME(6), \n\tattempt_count INTEGER NOT NULL, \n\tgeneration_attempt_count INTEGER NOT NULL, \n\trecovery_generation INTEGER NOT NULL, \n\tmax_attempts INTEGER NOT NULL, \n\tcancel_requested TINYINT(1) NOT NULL, \n\tresult_code VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin, \n\tresult_payload MEDIUMBLOB, \n\terror_category VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin, \n\terror_code VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin, \n\tstate_version BIGINT NOT NULL, \n\tcreated_at DATETIME(6) NOT NULL, \n\tupdated_at DATETIME(6) NOT NULL, \n\tcompleted_at DATETIME(6), \n\tPRIMARY KEY (work_id), \n\tCONSTRAINT uq_pc_work_items_lane_sequence UNIQUE (lane_key, lane_sequence), \n\tCONSTRAINT ck_pc_work_items_status CHECK (status IN ('queued', 'running', 'retry_wait', 'cancelling', 'succeeded', 'failed', 'cancelled')), \n\tCONSTRAINT ck_pc_work_items_lane_sequence_positive CHECK (lane_sequence > 0), \n\tCONSTRAINT ck_pc_work_items_payload_version_positive CHECK (payload_version > 0), \n\tCONSTRAINT ck_pc_work_items_fence_nonnegative CHECK (lease_fence >= 0), \n\tCONSTRAINT ck_pc_work_items_attempts_nonnegative CHECK (attempt_count >= 0), \n\tCONSTRAINT ck_pc_work_items_generation_attempts_nonnegative CHECK (generation_attempt_count >= 0), \n\tCONSTRAINT ck_pc_work_items_recovery_nonnegative CHECK (recovery_generation >= 0), \n\tCONSTRAINT ck_pc_work_items_max_attempts_positive CHECK (max_attempts > 0), \n\tCONSTRAINT ck_pc_work_items_state_version_positive CHECK (state_version > 0)\n)", - "CREATE INDEX ix_pc_work_items_claim ON pc_work_items (status, available_at)", - "CREATE INDEX ix_pc_work_items_completed ON pc_work_items (completed_at)", - "CREATE INDEX ix_pc_work_items_lane_status ON pc_work_items (lane_key, status)", - "CREATE INDEX ix_pc_work_items_scope_created ON pc_work_items (scope_id, created_at)", + "CREATE TABLE pc_work_items (\n\twork_id VARCHAR(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, \n\tlogical_key VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, \n\tlane_key VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, \n\tlane_sequence BIGINT NOT NULL, \n\tkind VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, \n\tpayload_version INTEGER NOT NULL, \n\tscope_id VARCHAR(256) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, \n\tpayload MEDIUMBLOB NOT NULL, \n\tstatus VARCHAR(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, \n\tavailable_at DATETIME(6) NOT NULL, \n\tlease_owner VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin, \n\tlease_fence BIGINT NOT NULL, \n\tlease_expires_at DATETIME(6), \n\tattempt_count INTEGER NOT NULL, \n\tgeneration_attempt_count INTEGER NOT NULL, \n\trecovery_generation INTEGER NOT NULL, \n\tmax_attempts INTEGER NOT NULL, \n\tcancel_requested TINYINT(1) NOT NULL, \n\tresult_code VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin, \n\tresult_payload MEDIUMBLOB, \n\terror_category VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin, \n\terror_code VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin, \n\tstate_version BIGINT NOT NULL, \n\tcreated_at DATETIME(6) NOT NULL, \n\tupdated_at DATETIME(6) NOT NULL, \n\tcompleted_at DATETIME(6), \n\tPRIMARY KEY (work_id), \n\tCONSTRAINT uq_pc_work_items_lane_sequence UNIQUE (lane_key, lane_sequence), \n\tCONSTRAINT ck_pc_work_items_status CHECK (status IN ('queued', 'running', 'retry_wait', 'cancelling', 'succeeded', 'failed', 'cancelled')), \n\tCONSTRAINT ck_pc_work_items_lane_sequence_positive CHECK (lane_sequence > 0), \n\tCONSTRAINT ck_pc_work_items_payload_version_positive CHECK (payload_version > 0), \n\tCONSTRAINT ck_pc_work_items_fence_nonnegative CHECK (lease_fence >= 0), \n\tCONSTRAINT ck_pc_work_items_attempts_nonnegative CHECK (attempt_count >= 0), \n\tCONSTRAINT ck_pc_work_items_generation_attempts_nonnegative CHECK (generation_attempt_count >= 0), \n\tCONSTRAINT ck_pc_work_items_recovery_nonnegative CHECK (recovery_generation >= 0), \n\tCONSTRAINT ck_pc_work_items_max_attempts_positive CHECK (max_attempts > 0), \n\tCONSTRAINT ck_pc_work_items_state_version_positive CHECK (state_version > 0),\n\tKEY ix_pc_work_items_claim (status, available_at),\n\tKEY ix_pc_work_items_completed (completed_at),\n\tKEY ix_pc_work_items_lane_status (lane_key, status),\n\tKEY ix_pc_work_items_scope_created (scope_id, created_at)\n)", "CREATE TABLE pc_work_keys (\n\tlogical_key VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, \n\twork_id VARCHAR(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, \n\tlane_key VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, \n\tcreated_at DATETIME(6) NOT NULL, \n\tPRIMARY KEY (logical_key), \n\tCONSTRAINT uq_pc_work_keys_work_id UNIQUE (work_id)\n)", - "CREATE TABLE pc_work_attempts (\n\twork_id VARCHAR(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, \n\tattempt_no INTEGER NOT NULL, \n\trecovery_generation INTEGER NOT NULL, \n\towner_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, \n\tfence BIGINT NOT NULL, \n\tstarted_at DATETIME(6) NOT NULL, \n\theartbeat_at DATETIME(6) NOT NULL, \n\tfinished_at DATETIME(6), \n\toutcome VARCHAR(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin, \n\terror_category VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin, \n\terror_code VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin, \n\ttrace_id VARCHAR(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin, \n\tspan_id VARCHAR(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin, \n\tPRIMARY KEY (work_id, attempt_no), \n\tCONSTRAINT ck_pc_work_attempts_attempt_positive CHECK (attempt_no > 0), \n\tCONSTRAINT ck_pc_work_attempts_recovery_nonnegative CHECK (recovery_generation >= 0), \n\tCONSTRAINT ck_pc_work_attempts_fence_positive CHECK (fence > 0)\n)", - "CREATE INDEX ix_pc_work_attempts_finished ON pc_work_attempts (finished_at)", + "CREATE TABLE pc_work_attempts (\n\twork_id VARCHAR(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, \n\tattempt_no INTEGER NOT NULL, \n\trecovery_generation INTEGER NOT NULL, \n\towner_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, \n\tfence BIGINT NOT NULL, \n\tstarted_at DATETIME(6) NOT NULL, \n\theartbeat_at DATETIME(6) NOT NULL, \n\tfinished_at DATETIME(6), \n\toutcome VARCHAR(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin, \n\terror_category VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin, \n\terror_code VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin, \n\ttrace_id VARCHAR(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin, \n\tspan_id VARCHAR(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin, \n\tPRIMARY KEY (work_id, attempt_no), \n\tCONSTRAINT ck_pc_work_attempts_attempt_positive CHECK (attempt_no > 0), \n\tCONSTRAINT ck_pc_work_attempts_recovery_nonnegative CHECK (recovery_generation >= 0), \n\tCONSTRAINT ck_pc_work_attempts_fence_positive CHECK (fence > 0),\n\tKEY ix_pc_work_attempts_finished (finished_at)\n)", "CREATE TABLE pc_scheduler_leases (\n\tlease_name VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, \n\towner_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin, \n\tfence BIGINT NOT NULL, \n\tlease_expires_at DATETIME(6), \n\tupdated_at DATETIME(6) NOT NULL, \n\tPRIMARY KEY (lease_name), \n\tCONSTRAINT ck_pc_scheduler_leases_fence_nonnegative CHECK (fence >= 0)\n)", "CREATE TABLE pc_scheduler_scans (\n\tdiscoverer VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, \n\tnext_run_at DATETIME(6) NOT NULL, \n\tcontinuation VARCHAR(256) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin, \n\tstate_version BIGINT NOT NULL, \n\tupdated_at DATETIME(6) NOT NULL, \n\tPRIMARY KEY (discoverer), \n\tCONSTRAINT ck_pc_scheduler_scans_version_positive CHECK (state_version > 0)\n)", - "CREATE TABLE pc_runtime_members (\n\tmember_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, \n\t`role` VARCHAR(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, \n\tbuild_version VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, \n\tschema_min INTEGER NOT NULL, \n\tschema_max INTEGER NOT NULL, \n\tpayload_min INTEGER NOT NULL, \n\tpayload_max INTEGER NOT NULL, \n\tbehavior_revision VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, \n\theartbeat_at DATETIME(6) NOT NULL, \n\texpires_at DATETIME(6) NOT NULL, \n\tPRIMARY KEY (member_id), \n\tCONSTRAINT ck_pc_runtime_members_role CHECK (role IN ('all', 'api', 'scheduler', 'worker')), \n\tCONSTRAINT ck_pc_runtime_members_schema_range CHECK (schema_min > 0 AND schema_max >= schema_min), \n\tCONSTRAINT ck_pc_runtime_members_payload_range CHECK (payload_min > 0 AND payload_max >= payload_min)\n)", - "CREATE INDEX ix_pc_runtime_members_role_expiry ON pc_runtime_members (`role`, expires_at)", - "CREATE TABLE pc_rate_limit_windows (\n\tprincipal_key VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, \n\tpolicy_id VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, \n\twindow_started_at DATETIME(6) NOT NULL, \n\trequest_count BIGINT NOT NULL, \n\texpires_at DATETIME(6) NOT NULL, \n\tPRIMARY KEY (principal_key, policy_id, window_started_at), \n\tCONSTRAINT ck_pc_rate_limit_windows_count_positive CHECK (request_count > 0)\n)", - "CREATE INDEX ix_pc_rate_limit_windows_expiry ON pc_rate_limit_windows (expires_at)" + "CREATE TABLE pc_runtime_members (\n\tmember_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, \n\t`role` VARCHAR(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, \n\tbuild_version VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, \n\tschema_min INTEGER NOT NULL, \n\tschema_max INTEGER NOT NULL, \n\tpayload_min INTEGER NOT NULL, \n\tpayload_max INTEGER NOT NULL, \n\tbehavior_revision VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, \n\theartbeat_at DATETIME(6) NOT NULL, \n\texpires_at DATETIME(6) NOT NULL, \n\tPRIMARY KEY (member_id), \n\tCONSTRAINT ck_pc_runtime_members_role CHECK (role IN ('all', 'api', 'scheduler', 'worker')), \n\tCONSTRAINT ck_pc_runtime_members_schema_range CHECK (schema_min > 0 AND schema_max >= schema_min), \n\tCONSTRAINT ck_pc_runtime_members_payload_range CHECK (payload_min > 0 AND payload_max >= payload_min),\n\tKEY ix_pc_runtime_members_role_expiry (`role`, expires_at)\n)", + "CREATE TABLE pc_rate_limit_windows (\n\tprincipal_key VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, \n\tpolicy_id VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, \n\twindow_started_at DATETIME(6) NOT NULL, \n\trequest_count BIGINT NOT NULL, \n\texpires_at DATETIME(6) NOT NULL, \n\tPRIMARY KEY (principal_key, policy_id, window_started_at), \n\tCONSTRAINT ck_pc_rate_limit_windows_count_positive CHECK (request_count > 0),\n\tKEY ix_pc_rate_limit_windows_expiry (expires_at)\n)" ], "changes": [ { @@ -605,2130 +598,6 @@ "enforced": true } }, - "indexes": { - "uq_pc_work_items_lane_sequence": { - "columns": [ - [ - "lane_key", - null, - "asc" - ], - [ - "lane_sequence", - null, - "asc" - ] - ], - "unique": true - } - } - } - }, - { - "pc_work_items": { - "columns": { - "work_id": { - "type": [ - "varchar", - [ - "36" - ] - ], - "nullable": false, - "default": null, - "charset": "utf8mb4", - "collation": "utf8mb4_bin", - "extra": [] - }, - "logical_key": { - "type": [ - "varchar", - [ - "64" - ] - ], - "nullable": false, - "default": null, - "charset": "utf8mb4", - "collation": "utf8mb4_bin", - "extra": [] - }, - "lane_key": { - "type": [ - "varchar", - [ - "64" - ] - ], - "nullable": false, - "default": null, - "charset": "utf8mb4", - "collation": "utf8mb4_bin", - "extra": [] - }, - "lane_sequence": { - "type": [ - "bigint", - [] - ], - "nullable": false, - "default": null, - "charset": null, - "collation": null, - "extra": [] - }, - "kind": { - "type": [ - "varchar", - [ - "128" - ] - ], - "nullable": false, - "default": null, - "charset": "utf8mb4", - "collation": "utf8mb4_bin", - "extra": [] - }, - "payload_version": { - "type": [ - "int", - [] - ], - "nullable": false, - "default": null, - "charset": null, - "collation": null, - "extra": [] - }, - "scope_id": { - "type": [ - "varchar", - [ - "256" - ] - ], - "nullable": false, - "default": null, - "charset": "utf8mb4", - "collation": "utf8mb4_bin", - "extra": [] - }, - "payload": { - "type": [ - "mediumblob", - [] - ], - "nullable": false, - "default": null, - "charset": null, - "collation": null, - "extra": [] - }, - "status": { - "type": [ - "varchar", - [ - "16" - ] - ], - "nullable": false, - "default": null, - "charset": "utf8mb4", - "collation": "utf8mb4_bin", - "extra": [] - }, - "available_at": { - "type": [ - "datetime", - [ - "6" - ] - ], - "nullable": false, - "default": null, - "charset": null, - "collation": null, - "extra": [] - }, - "lease_owner": { - "type": [ - "varchar", - [ - "128" - ] - ], - "nullable": true, - "default": null, - "charset": "utf8mb4", - "collation": "utf8mb4_bin", - "extra": [] - }, - "lease_fence": { - "type": [ - "bigint", - [] - ], - "nullable": false, - "default": null, - "charset": null, - "collation": null, - "extra": [] - }, - "lease_expires_at": { - "type": [ - "datetime", - [ - "6" - ] - ], - "nullable": true, - "default": null, - "charset": null, - "collation": null, - "extra": [] - }, - "attempt_count": { - "type": [ - "int", - [] - ], - "nullable": false, - "default": null, - "charset": null, - "collation": null, - "extra": [] - }, - "generation_attempt_count": { - "type": [ - "int", - [] - ], - "nullable": false, - "default": null, - "charset": null, - "collation": null, - "extra": [] - }, - "recovery_generation": { - "type": [ - "int", - [] - ], - "nullable": false, - "default": null, - "charset": null, - "collation": null, - "extra": [] - }, - "max_attempts": { - "type": [ - "int", - [] - ], - "nullable": false, - "default": null, - "charset": null, - "collation": null, - "extra": [] - }, - "cancel_requested": { - "type": [ - "tinyint", - [ - "1" - ] - ], - "nullable": false, - "default": null, - "charset": null, - "collation": null, - "extra": [] - }, - "result_code": { - "type": [ - "varchar", - [ - "128" - ] - ], - "nullable": true, - "default": null, - "charset": "utf8mb4", - "collation": "utf8mb4_bin", - "extra": [] - }, - "result_payload": { - "type": [ - "mediumblob", - [] - ], - "nullable": true, - "default": null, - "charset": null, - "collation": null, - "extra": [] - }, - "error_category": { - "type": [ - "varchar", - [ - "64" - ] - ], - "nullable": true, - "default": null, - "charset": "utf8mb4", - "collation": "utf8mb4_bin", - "extra": [] - }, - "error_code": { - "type": [ - "varchar", - [ - "128" - ] - ], - "nullable": true, - "default": null, - "charset": "utf8mb4", - "collation": "utf8mb4_bin", - "extra": [] - }, - "state_version": { - "type": [ - "bigint", - [] - ], - "nullable": false, - "default": null, - "charset": null, - "collation": null, - "extra": [] - }, - "created_at": { - "type": [ - "datetime", - [ - "6" - ] - ], - "nullable": false, - "default": null, - "charset": null, - "collation": null, - "extra": [] - }, - "updated_at": { - "type": [ - "datetime", - [ - "6" - ] - ], - "nullable": false, - "default": null, - "charset": null, - "collation": null, - "extra": [] - }, - "completed_at": { - "type": [ - "datetime", - [ - "6" - ] - ], - "nullable": true, - "default": null, - "charset": null, - "collation": null, - "extra": [] - } - }, - "primary_key": [ - [ - "work_id", - null, - "asc" - ] - ], - "foreign_keys": [], - "checks": { - "ck_pc_work_items_status": { - "expression": [ - "in", - [ - "column", - "status" - ], - [ - [ - "literal", - "queued" - ], - [ - "literal", - "running" - ], - [ - "literal", - "retry_wait" - ], - [ - "literal", - "cancelling" - ], - [ - "literal", - "succeeded" - ], - [ - "literal", - "failed" - ], - [ - "literal", - "cancelled" - ] - ] - ], - "enforced": true - }, - "ck_pc_work_items_lane_sequence_positive": { - "expression": [ - "binary", - ">", - [ - "column", - "lane_sequence" - ], - [ - "number", - 0 - ] - ], - "enforced": true - }, - "ck_pc_work_items_payload_version_positive": { - "expression": [ - "binary", - ">", - [ - "column", - "payload_version" - ], - [ - "number", - 0 - ] - ], - "enforced": true - }, - "ck_pc_work_items_fence_nonnegative": { - "expression": [ - "binary", - ">=", - [ - "column", - "lease_fence" - ], - [ - "number", - 0 - ] - ], - "enforced": true - }, - "ck_pc_work_items_attempts_nonnegative": { - "expression": [ - "binary", - ">=", - [ - "column", - "attempt_count" - ], - [ - "number", - 0 - ] - ], - "enforced": true - }, - "ck_pc_work_items_generation_attempts_nonnegative": { - "expression": [ - "binary", - ">=", - [ - "column", - "generation_attempt_count" - ], - [ - "number", - 0 - ] - ], - "enforced": true - }, - "ck_pc_work_items_recovery_nonnegative": { - "expression": [ - "binary", - ">=", - [ - "column", - "recovery_generation" - ], - [ - "number", - 0 - ] - ], - "enforced": true - }, - "ck_pc_work_items_max_attempts_positive": { - "expression": [ - "binary", - ">", - [ - "column", - "max_attempts" - ], - [ - "number", - 0 - ] - ], - "enforced": true - }, - "ck_pc_work_items_state_version_positive": { - "expression": [ - "binary", - ">", - [ - "column", - "state_version" - ], - [ - "number", - 0 - ] - ], - "enforced": true - } - }, - "indexes": { - "uq_pc_work_items_lane_sequence": { - "columns": [ - [ - "lane_key", - null, - "asc" - ], - [ - "lane_sequence", - null, - "asc" - ] - ], - "unique": true - }, - "ix_pc_work_items_claim": { - "columns": [ - [ - "status", - null, - "asc" - ], - [ - "available_at", - null, - "asc" - ] - ], - "unique": false - } - } - } - }, - { - "pc_work_items": { - "columns": { - "work_id": { - "type": [ - "varchar", - [ - "36" - ] - ], - "nullable": false, - "default": null, - "charset": "utf8mb4", - "collation": "utf8mb4_bin", - "extra": [] - }, - "logical_key": { - "type": [ - "varchar", - [ - "64" - ] - ], - "nullable": false, - "default": null, - "charset": "utf8mb4", - "collation": "utf8mb4_bin", - "extra": [] - }, - "lane_key": { - "type": [ - "varchar", - [ - "64" - ] - ], - "nullable": false, - "default": null, - "charset": "utf8mb4", - "collation": "utf8mb4_bin", - "extra": [] - }, - "lane_sequence": { - "type": [ - "bigint", - [] - ], - "nullable": false, - "default": null, - "charset": null, - "collation": null, - "extra": [] - }, - "kind": { - "type": [ - "varchar", - [ - "128" - ] - ], - "nullable": false, - "default": null, - "charset": "utf8mb4", - "collation": "utf8mb4_bin", - "extra": [] - }, - "payload_version": { - "type": [ - "int", - [] - ], - "nullable": false, - "default": null, - "charset": null, - "collation": null, - "extra": [] - }, - "scope_id": { - "type": [ - "varchar", - [ - "256" - ] - ], - "nullable": false, - "default": null, - "charset": "utf8mb4", - "collation": "utf8mb4_bin", - "extra": [] - }, - "payload": { - "type": [ - "mediumblob", - [] - ], - "nullable": false, - "default": null, - "charset": null, - "collation": null, - "extra": [] - }, - "status": { - "type": [ - "varchar", - [ - "16" - ] - ], - "nullable": false, - "default": null, - "charset": "utf8mb4", - "collation": "utf8mb4_bin", - "extra": [] - }, - "available_at": { - "type": [ - "datetime", - [ - "6" - ] - ], - "nullable": false, - "default": null, - "charset": null, - "collation": null, - "extra": [] - }, - "lease_owner": { - "type": [ - "varchar", - [ - "128" - ] - ], - "nullable": true, - "default": null, - "charset": "utf8mb4", - "collation": "utf8mb4_bin", - "extra": [] - }, - "lease_fence": { - "type": [ - "bigint", - [] - ], - "nullable": false, - "default": null, - "charset": null, - "collation": null, - "extra": [] - }, - "lease_expires_at": { - "type": [ - "datetime", - [ - "6" - ] - ], - "nullable": true, - "default": null, - "charset": null, - "collation": null, - "extra": [] - }, - "attempt_count": { - "type": [ - "int", - [] - ], - "nullable": false, - "default": null, - "charset": null, - "collation": null, - "extra": [] - }, - "generation_attempt_count": { - "type": [ - "int", - [] - ], - "nullable": false, - "default": null, - "charset": null, - "collation": null, - "extra": [] - }, - "recovery_generation": { - "type": [ - "int", - [] - ], - "nullable": false, - "default": null, - "charset": null, - "collation": null, - "extra": [] - }, - "max_attempts": { - "type": [ - "int", - [] - ], - "nullable": false, - "default": null, - "charset": null, - "collation": null, - "extra": [] - }, - "cancel_requested": { - "type": [ - "tinyint", - [ - "1" - ] - ], - "nullable": false, - "default": null, - "charset": null, - "collation": null, - "extra": [] - }, - "result_code": { - "type": [ - "varchar", - [ - "128" - ] - ], - "nullable": true, - "default": null, - "charset": "utf8mb4", - "collation": "utf8mb4_bin", - "extra": [] - }, - "result_payload": { - "type": [ - "mediumblob", - [] - ], - "nullable": true, - "default": null, - "charset": null, - "collation": null, - "extra": [] - }, - "error_category": { - "type": [ - "varchar", - [ - "64" - ] - ], - "nullable": true, - "default": null, - "charset": "utf8mb4", - "collation": "utf8mb4_bin", - "extra": [] - }, - "error_code": { - "type": [ - "varchar", - [ - "128" - ] - ], - "nullable": true, - "default": null, - "charset": "utf8mb4", - "collation": "utf8mb4_bin", - "extra": [] - }, - "state_version": { - "type": [ - "bigint", - [] - ], - "nullable": false, - "default": null, - "charset": null, - "collation": null, - "extra": [] - }, - "created_at": { - "type": [ - "datetime", - [ - "6" - ] - ], - "nullable": false, - "default": null, - "charset": null, - "collation": null, - "extra": [] - }, - "updated_at": { - "type": [ - "datetime", - [ - "6" - ] - ], - "nullable": false, - "default": null, - "charset": null, - "collation": null, - "extra": [] - }, - "completed_at": { - "type": [ - "datetime", - [ - "6" - ] - ], - "nullable": true, - "default": null, - "charset": null, - "collation": null, - "extra": [] - } - }, - "primary_key": [ - [ - "work_id", - null, - "asc" - ] - ], - "foreign_keys": [], - "checks": { - "ck_pc_work_items_status": { - "expression": [ - "in", - [ - "column", - "status" - ], - [ - [ - "literal", - "queued" - ], - [ - "literal", - "running" - ], - [ - "literal", - "retry_wait" - ], - [ - "literal", - "cancelling" - ], - [ - "literal", - "succeeded" - ], - [ - "literal", - "failed" - ], - [ - "literal", - "cancelled" - ] - ] - ], - "enforced": true - }, - "ck_pc_work_items_lane_sequence_positive": { - "expression": [ - "binary", - ">", - [ - "column", - "lane_sequence" - ], - [ - "number", - 0 - ] - ], - "enforced": true - }, - "ck_pc_work_items_payload_version_positive": { - "expression": [ - "binary", - ">", - [ - "column", - "payload_version" - ], - [ - "number", - 0 - ] - ], - "enforced": true - }, - "ck_pc_work_items_fence_nonnegative": { - "expression": [ - "binary", - ">=", - [ - "column", - "lease_fence" - ], - [ - "number", - 0 - ] - ], - "enforced": true - }, - "ck_pc_work_items_attempts_nonnegative": { - "expression": [ - "binary", - ">=", - [ - "column", - "attempt_count" - ], - [ - "number", - 0 - ] - ], - "enforced": true - }, - "ck_pc_work_items_generation_attempts_nonnegative": { - "expression": [ - "binary", - ">=", - [ - "column", - "generation_attempt_count" - ], - [ - "number", - 0 - ] - ], - "enforced": true - }, - "ck_pc_work_items_recovery_nonnegative": { - "expression": [ - "binary", - ">=", - [ - "column", - "recovery_generation" - ], - [ - "number", - 0 - ] - ], - "enforced": true - }, - "ck_pc_work_items_max_attempts_positive": { - "expression": [ - "binary", - ">", - [ - "column", - "max_attempts" - ], - [ - "number", - 0 - ] - ], - "enforced": true - }, - "ck_pc_work_items_state_version_positive": { - "expression": [ - "binary", - ">", - [ - "column", - "state_version" - ], - [ - "number", - 0 - ] - ], - "enforced": true - } - }, - "indexes": { - "uq_pc_work_items_lane_sequence": { - "columns": [ - [ - "lane_key", - null, - "asc" - ], - [ - "lane_sequence", - null, - "asc" - ] - ], - "unique": true - }, - "ix_pc_work_items_claim": { - "columns": [ - [ - "status", - null, - "asc" - ], - [ - "available_at", - null, - "asc" - ] - ], - "unique": false - }, - "ix_pc_work_items_completed": { - "columns": [ - [ - "completed_at", - null, - "asc" - ] - ], - "unique": false - } - } - } - }, - { - "pc_work_items": { - "columns": { - "work_id": { - "type": [ - "varchar", - [ - "36" - ] - ], - "nullable": false, - "default": null, - "charset": "utf8mb4", - "collation": "utf8mb4_bin", - "extra": [] - }, - "logical_key": { - "type": [ - "varchar", - [ - "64" - ] - ], - "nullable": false, - "default": null, - "charset": "utf8mb4", - "collation": "utf8mb4_bin", - "extra": [] - }, - "lane_key": { - "type": [ - "varchar", - [ - "64" - ] - ], - "nullable": false, - "default": null, - "charset": "utf8mb4", - "collation": "utf8mb4_bin", - "extra": [] - }, - "lane_sequence": { - "type": [ - "bigint", - [] - ], - "nullable": false, - "default": null, - "charset": null, - "collation": null, - "extra": [] - }, - "kind": { - "type": [ - "varchar", - [ - "128" - ] - ], - "nullable": false, - "default": null, - "charset": "utf8mb4", - "collation": "utf8mb4_bin", - "extra": [] - }, - "payload_version": { - "type": [ - "int", - [] - ], - "nullable": false, - "default": null, - "charset": null, - "collation": null, - "extra": [] - }, - "scope_id": { - "type": [ - "varchar", - [ - "256" - ] - ], - "nullable": false, - "default": null, - "charset": "utf8mb4", - "collation": "utf8mb4_bin", - "extra": [] - }, - "payload": { - "type": [ - "mediumblob", - [] - ], - "nullable": false, - "default": null, - "charset": null, - "collation": null, - "extra": [] - }, - "status": { - "type": [ - "varchar", - [ - "16" - ] - ], - "nullable": false, - "default": null, - "charset": "utf8mb4", - "collation": "utf8mb4_bin", - "extra": [] - }, - "available_at": { - "type": [ - "datetime", - [ - "6" - ] - ], - "nullable": false, - "default": null, - "charset": null, - "collation": null, - "extra": [] - }, - "lease_owner": { - "type": [ - "varchar", - [ - "128" - ] - ], - "nullable": true, - "default": null, - "charset": "utf8mb4", - "collation": "utf8mb4_bin", - "extra": [] - }, - "lease_fence": { - "type": [ - "bigint", - [] - ], - "nullable": false, - "default": null, - "charset": null, - "collation": null, - "extra": [] - }, - "lease_expires_at": { - "type": [ - "datetime", - [ - "6" - ] - ], - "nullable": true, - "default": null, - "charset": null, - "collation": null, - "extra": [] - }, - "attempt_count": { - "type": [ - "int", - [] - ], - "nullable": false, - "default": null, - "charset": null, - "collation": null, - "extra": [] - }, - "generation_attempt_count": { - "type": [ - "int", - [] - ], - "nullable": false, - "default": null, - "charset": null, - "collation": null, - "extra": [] - }, - "recovery_generation": { - "type": [ - "int", - [] - ], - "nullable": false, - "default": null, - "charset": null, - "collation": null, - "extra": [] - }, - "max_attempts": { - "type": [ - "int", - [] - ], - "nullable": false, - "default": null, - "charset": null, - "collation": null, - "extra": [] - }, - "cancel_requested": { - "type": [ - "tinyint", - [ - "1" - ] - ], - "nullable": false, - "default": null, - "charset": null, - "collation": null, - "extra": [] - }, - "result_code": { - "type": [ - "varchar", - [ - "128" - ] - ], - "nullable": true, - "default": null, - "charset": "utf8mb4", - "collation": "utf8mb4_bin", - "extra": [] - }, - "result_payload": { - "type": [ - "mediumblob", - [] - ], - "nullable": true, - "default": null, - "charset": null, - "collation": null, - "extra": [] - }, - "error_category": { - "type": [ - "varchar", - [ - "64" - ] - ], - "nullable": true, - "default": null, - "charset": "utf8mb4", - "collation": "utf8mb4_bin", - "extra": [] - }, - "error_code": { - "type": [ - "varchar", - [ - "128" - ] - ], - "nullable": true, - "default": null, - "charset": "utf8mb4", - "collation": "utf8mb4_bin", - "extra": [] - }, - "state_version": { - "type": [ - "bigint", - [] - ], - "nullable": false, - "default": null, - "charset": null, - "collation": null, - "extra": [] - }, - "created_at": { - "type": [ - "datetime", - [ - "6" - ] - ], - "nullable": false, - "default": null, - "charset": null, - "collation": null, - "extra": [] - }, - "updated_at": { - "type": [ - "datetime", - [ - "6" - ] - ], - "nullable": false, - "default": null, - "charset": null, - "collation": null, - "extra": [] - }, - "completed_at": { - "type": [ - "datetime", - [ - "6" - ] - ], - "nullable": true, - "default": null, - "charset": null, - "collation": null, - "extra": [] - } - }, - "primary_key": [ - [ - "work_id", - null, - "asc" - ] - ], - "foreign_keys": [], - "checks": { - "ck_pc_work_items_status": { - "expression": [ - "in", - [ - "column", - "status" - ], - [ - [ - "literal", - "queued" - ], - [ - "literal", - "running" - ], - [ - "literal", - "retry_wait" - ], - [ - "literal", - "cancelling" - ], - [ - "literal", - "succeeded" - ], - [ - "literal", - "failed" - ], - [ - "literal", - "cancelled" - ] - ] - ], - "enforced": true - }, - "ck_pc_work_items_lane_sequence_positive": { - "expression": [ - "binary", - ">", - [ - "column", - "lane_sequence" - ], - [ - "number", - 0 - ] - ], - "enforced": true - }, - "ck_pc_work_items_payload_version_positive": { - "expression": [ - "binary", - ">", - [ - "column", - "payload_version" - ], - [ - "number", - 0 - ] - ], - "enforced": true - }, - "ck_pc_work_items_fence_nonnegative": { - "expression": [ - "binary", - ">=", - [ - "column", - "lease_fence" - ], - [ - "number", - 0 - ] - ], - "enforced": true - }, - "ck_pc_work_items_attempts_nonnegative": { - "expression": [ - "binary", - ">=", - [ - "column", - "attempt_count" - ], - [ - "number", - 0 - ] - ], - "enforced": true - }, - "ck_pc_work_items_generation_attempts_nonnegative": { - "expression": [ - "binary", - ">=", - [ - "column", - "generation_attempt_count" - ], - [ - "number", - 0 - ] - ], - "enforced": true - }, - "ck_pc_work_items_recovery_nonnegative": { - "expression": [ - "binary", - ">=", - [ - "column", - "recovery_generation" - ], - [ - "number", - 0 - ] - ], - "enforced": true - }, - "ck_pc_work_items_max_attempts_positive": { - "expression": [ - "binary", - ">", - [ - "column", - "max_attempts" - ], - [ - "number", - 0 - ] - ], - "enforced": true - }, - "ck_pc_work_items_state_version_positive": { - "expression": [ - "binary", - ">", - [ - "column", - "state_version" - ], - [ - "number", - 0 - ] - ], - "enforced": true - } - }, - "indexes": { - "uq_pc_work_items_lane_sequence": { - "columns": [ - [ - "lane_key", - null, - "asc" - ], - [ - "lane_sequence", - null, - "asc" - ] - ], - "unique": true - }, - "ix_pc_work_items_claim": { - "columns": [ - [ - "status", - null, - "asc" - ], - [ - "available_at", - null, - "asc" - ] - ], - "unique": false - }, - "ix_pc_work_items_completed": { - "columns": [ - [ - "completed_at", - null, - "asc" - ] - ], - "unique": false - }, - "ix_pc_work_items_lane_status": { - "columns": [ - [ - "lane_key", - null, - "asc" - ], - [ - "status", - null, - "asc" - ] - ], - "unique": false - } - } - } - }, - { - "pc_work_items": { - "columns": { - "work_id": { - "type": [ - "varchar", - [ - "36" - ] - ], - "nullable": false, - "default": null, - "charset": "utf8mb4", - "collation": "utf8mb4_bin", - "extra": [] - }, - "logical_key": { - "type": [ - "varchar", - [ - "64" - ] - ], - "nullable": false, - "default": null, - "charset": "utf8mb4", - "collation": "utf8mb4_bin", - "extra": [] - }, - "lane_key": { - "type": [ - "varchar", - [ - "64" - ] - ], - "nullable": false, - "default": null, - "charset": "utf8mb4", - "collation": "utf8mb4_bin", - "extra": [] - }, - "lane_sequence": { - "type": [ - "bigint", - [] - ], - "nullable": false, - "default": null, - "charset": null, - "collation": null, - "extra": [] - }, - "kind": { - "type": [ - "varchar", - [ - "128" - ] - ], - "nullable": false, - "default": null, - "charset": "utf8mb4", - "collation": "utf8mb4_bin", - "extra": [] - }, - "payload_version": { - "type": [ - "int", - [] - ], - "nullable": false, - "default": null, - "charset": null, - "collation": null, - "extra": [] - }, - "scope_id": { - "type": [ - "varchar", - [ - "256" - ] - ], - "nullable": false, - "default": null, - "charset": "utf8mb4", - "collation": "utf8mb4_bin", - "extra": [] - }, - "payload": { - "type": [ - "mediumblob", - [] - ], - "nullable": false, - "default": null, - "charset": null, - "collation": null, - "extra": [] - }, - "status": { - "type": [ - "varchar", - [ - "16" - ] - ], - "nullable": false, - "default": null, - "charset": "utf8mb4", - "collation": "utf8mb4_bin", - "extra": [] - }, - "available_at": { - "type": [ - "datetime", - [ - "6" - ] - ], - "nullable": false, - "default": null, - "charset": null, - "collation": null, - "extra": [] - }, - "lease_owner": { - "type": [ - "varchar", - [ - "128" - ] - ], - "nullable": true, - "default": null, - "charset": "utf8mb4", - "collation": "utf8mb4_bin", - "extra": [] - }, - "lease_fence": { - "type": [ - "bigint", - [] - ], - "nullable": false, - "default": null, - "charset": null, - "collation": null, - "extra": [] - }, - "lease_expires_at": { - "type": [ - "datetime", - [ - "6" - ] - ], - "nullable": true, - "default": null, - "charset": null, - "collation": null, - "extra": [] - }, - "attempt_count": { - "type": [ - "int", - [] - ], - "nullable": false, - "default": null, - "charset": null, - "collation": null, - "extra": [] - }, - "generation_attempt_count": { - "type": [ - "int", - [] - ], - "nullable": false, - "default": null, - "charset": null, - "collation": null, - "extra": [] - }, - "recovery_generation": { - "type": [ - "int", - [] - ], - "nullable": false, - "default": null, - "charset": null, - "collation": null, - "extra": [] - }, - "max_attempts": { - "type": [ - "int", - [] - ], - "nullable": false, - "default": null, - "charset": null, - "collation": null, - "extra": [] - }, - "cancel_requested": { - "type": [ - "tinyint", - [ - "1" - ] - ], - "nullable": false, - "default": null, - "charset": null, - "collation": null, - "extra": [] - }, - "result_code": { - "type": [ - "varchar", - [ - "128" - ] - ], - "nullable": true, - "default": null, - "charset": "utf8mb4", - "collation": "utf8mb4_bin", - "extra": [] - }, - "result_payload": { - "type": [ - "mediumblob", - [] - ], - "nullable": true, - "default": null, - "charset": null, - "collation": null, - "extra": [] - }, - "error_category": { - "type": [ - "varchar", - [ - "64" - ] - ], - "nullable": true, - "default": null, - "charset": "utf8mb4", - "collation": "utf8mb4_bin", - "extra": [] - }, - "error_code": { - "type": [ - "varchar", - [ - "128" - ] - ], - "nullable": true, - "default": null, - "charset": "utf8mb4", - "collation": "utf8mb4_bin", - "extra": [] - }, - "state_version": { - "type": [ - "bigint", - [] - ], - "nullable": false, - "default": null, - "charset": null, - "collation": null, - "extra": [] - }, - "created_at": { - "type": [ - "datetime", - [ - "6" - ] - ], - "nullable": false, - "default": null, - "charset": null, - "collation": null, - "extra": [] - }, - "updated_at": { - "type": [ - "datetime", - [ - "6" - ] - ], - "nullable": false, - "default": null, - "charset": null, - "collation": null, - "extra": [] - }, - "completed_at": { - "type": [ - "datetime", - [ - "6" - ] - ], - "nullable": true, - "default": null, - "charset": null, - "collation": null, - "extra": [] - } - }, - "primary_key": [ - [ - "work_id", - null, - "asc" - ] - ], - "foreign_keys": [], - "checks": { - "ck_pc_work_items_status": { - "expression": [ - "in", - [ - "column", - "status" - ], - [ - [ - "literal", - "queued" - ], - [ - "literal", - "running" - ], - [ - "literal", - "retry_wait" - ], - [ - "literal", - "cancelling" - ], - [ - "literal", - "succeeded" - ], - [ - "literal", - "failed" - ], - [ - "literal", - "cancelled" - ] - ] - ], - "enforced": true - }, - "ck_pc_work_items_lane_sequence_positive": { - "expression": [ - "binary", - ">", - [ - "column", - "lane_sequence" - ], - [ - "number", - 0 - ] - ], - "enforced": true - }, - "ck_pc_work_items_payload_version_positive": { - "expression": [ - "binary", - ">", - [ - "column", - "payload_version" - ], - [ - "number", - 0 - ] - ], - "enforced": true - }, - "ck_pc_work_items_fence_nonnegative": { - "expression": [ - "binary", - ">=", - [ - "column", - "lease_fence" - ], - [ - "number", - 0 - ] - ], - "enforced": true - }, - "ck_pc_work_items_attempts_nonnegative": { - "expression": [ - "binary", - ">=", - [ - "column", - "attempt_count" - ], - [ - "number", - 0 - ] - ], - "enforced": true - }, - "ck_pc_work_items_generation_attempts_nonnegative": { - "expression": [ - "binary", - ">=", - [ - "column", - "generation_attempt_count" - ], - [ - "number", - 0 - ] - ], - "enforced": true - }, - "ck_pc_work_items_recovery_nonnegative": { - "expression": [ - "binary", - ">=", - [ - "column", - "recovery_generation" - ], - [ - "number", - 0 - ] - ], - "enforced": true - }, - "ck_pc_work_items_max_attempts_positive": { - "expression": [ - "binary", - ">", - [ - "column", - "max_attempts" - ], - [ - "number", - 0 - ] - ], - "enforced": true - }, - "ck_pc_work_items_state_version_positive": { - "expression": [ - "binary", - ">", - [ - "column", - "state_version" - ], - [ - "number", - 0 - ] - ], - "enforced": true - } - }, "indexes": { "uq_pc_work_items_lane_sequence": { "columns": [ @@ -2771,345 +640,115 @@ "unique": false }, "ix_pc_work_items_lane_status": { - "columns": [ - [ - "lane_key", - null, - "asc" - ], - [ - "status", - null, - "asc" - ] - ], - "unique": false - }, - "ix_pc_work_items_scope_created": { - "columns": [ - [ - "scope_id", - null, - "asc" - ], - [ - "created_at", - null, - "asc" - ] - ], - "unique": false - } - } - } - }, - { - "pc_work_keys": { - "columns": { - "logical_key": { - "type": [ - "varchar", - [ - "64" - ] - ], - "nullable": false, - "default": null, - "charset": "utf8mb4", - "collation": "utf8mb4_bin", - "extra": [] - }, - "work_id": { - "type": [ - "varchar", - [ - "36" - ] - ], - "nullable": false, - "default": null, - "charset": "utf8mb4", - "collation": "utf8mb4_bin", - "extra": [] - }, - "lane_key": { - "type": [ - "varchar", - [ - "64" - ] - ], - "nullable": false, - "default": null, - "charset": "utf8mb4", - "collation": "utf8mb4_bin", - "extra": [] - }, - "created_at": { - "type": [ - "datetime", - [ - "6" - ] - ], - "nullable": false, - "default": null, - "charset": null, - "collation": null, - "extra": [] - } - }, - "primary_key": [ - [ - "logical_key", - null, - "asc" - ] - ], - "foreign_keys": [], - "checks": {}, - "indexes": { - "uq_pc_work_keys_work_id": { - "columns": [ - [ - "work_id", - null, - "asc" - ] - ], - "unique": true - } - } - } - }, - { - "pc_work_attempts": { - "columns": { - "work_id": { - "type": [ - "varchar", - [ - "36" - ] - ], - "nullable": false, - "default": null, - "charset": "utf8mb4", - "collation": "utf8mb4_bin", - "extra": [] - }, - "attempt_no": { - "type": [ - "int", - [] - ], - "nullable": false, - "default": null, - "charset": null, - "collation": null, - "extra": [] - }, - "recovery_generation": { - "type": [ - "int", - [] - ], - "nullable": false, - "default": null, - "charset": null, - "collation": null, - "extra": [] - }, - "owner_id": { - "type": [ - "varchar", - [ - "128" - ] - ], - "nullable": false, - "default": null, - "charset": "utf8mb4", - "collation": "utf8mb4_bin", - "extra": [] - }, - "fence": { - "type": [ - "bigint", - [] - ], - "nullable": false, - "default": null, - "charset": null, - "collation": null, - "extra": [] - }, - "started_at": { - "type": [ - "datetime", - [ - "6" - ] - ], - "nullable": false, - "default": null, - "charset": null, - "collation": null, - "extra": [] - }, - "heartbeat_at": { - "type": [ - "datetime", - [ - "6" - ] - ], - "nullable": false, - "default": null, - "charset": null, - "collation": null, - "extra": [] - }, - "finished_at": { - "type": [ - "datetime", + "columns": [ [ - "6" + "lane_key", + null, + "asc" + ], + [ + "status", + null, + "asc" ] ], - "nullable": true, - "default": null, - "charset": null, - "collation": null, - "extra": [] + "unique": false }, - "outcome": { - "type": [ - "varchar", + "ix_pc_work_items_scope_created": { + "columns": [ [ - "32" + "scope_id", + null, + "asc" + ], + [ + "created_at", + null, + "asc" ] ], - "nullable": true, - "default": null, - "charset": "utf8mb4", - "collation": "utf8mb4_bin", - "extra": [] - }, - "error_category": { + "unique": false + } + } + } + }, + { + "pc_work_keys": { + "columns": { + "logical_key": { "type": [ "varchar", [ "64" ] ], - "nullable": true, + "nullable": false, "default": null, "charset": "utf8mb4", "collation": "utf8mb4_bin", "extra": [] }, - "error_code": { + "work_id": { "type": [ "varchar", [ - "128" + "36" ] ], - "nullable": true, + "nullable": false, "default": null, "charset": "utf8mb4", "collation": "utf8mb4_bin", "extra": [] }, - "trace_id": { + "lane_key": { "type": [ "varchar", [ - "32" + "64" ] ], - "nullable": true, + "nullable": false, "default": null, "charset": "utf8mb4", "collation": "utf8mb4_bin", "extra": [] }, - "span_id": { + "created_at": { "type": [ - "varchar", + "datetime", [ - "16" + "6" ] ], - "nullable": true, + "nullable": false, "default": null, - "charset": "utf8mb4", - "collation": "utf8mb4_bin", + "charset": null, + "collation": null, "extra": [] } }, "primary_key": [ [ - "work_id", - null, - "asc" - ], - [ - "attempt_no", + "logical_key", null, "asc" ] ], "foreign_keys": [], - "checks": { - "ck_pc_work_attempts_attempt_positive": { - "expression": [ - "binary", - ">", - [ - "column", - "attempt_no" - ], - [ - "number", - 0 - ] - ], - "enforced": true - }, - "ck_pc_work_attempts_recovery_nonnegative": { - "expression": [ - "binary", - ">=", - [ - "column", - "recovery_generation" - ], - [ - "number", - 0 - ] - ], - "enforced": true - }, - "ck_pc_work_attempts_fence_positive": { - "expression": [ - "binary", - ">", - [ - "column", - "fence" - ], + "checks": {}, + "indexes": { + "uq_pc_work_keys_work_id": { + "columns": [ [ - "number", - 0 + "work_id", + null, + "asc" ] ], - "enforced": true + "unique": true } - }, - "indexes": {} + } } }, { @@ -3449,134 +1088,13 @@ } }, { - "pc_scheduler_scans": { - "columns": { - "discoverer": { - "type": [ - "varchar", - [ - "128" - ] - ], - "nullable": false, - "default": null, - "charset": "utf8mb4", - "collation": "utf8mb4_bin", - "extra": [] - }, - "next_run_at": { - "type": [ - "datetime", - [ - "6" - ] - ], - "nullable": false, - "default": null, - "charset": null, - "collation": null, - "extra": [] - }, - "continuation": { - "type": [ - "varchar", - [ - "256" - ] - ], - "nullable": true, - "default": null, - "charset": "utf8mb4", - "collation": "utf8mb4_bin", - "extra": [] - }, - "state_version": { - "type": [ - "bigint", - [] - ], - "nullable": false, - "default": null, - "charset": null, - "collation": null, - "extra": [] - }, - "updated_at": { - "type": [ - "datetime", - [ - "6" - ] - ], - "nullable": false, - "default": null, - "charset": null, - "collation": null, - "extra": [] - } - }, - "primary_key": [ - [ - "discoverer", - null, - "asc" - ] - ], - "foreign_keys": [], - "checks": { - "ck_pc_scheduler_scans_version_positive": { - "expression": [ - "binary", - ">", - [ - "column", - "state_version" - ], - [ - "number", - 0 - ] - ], - "enforced": true - } - }, - "indexes": {} - } - }, - { - "pc_runtime_members": { - "columns": { - "member_id": { - "type": [ - "varchar", - [ - "128" - ] - ], - "nullable": false, - "default": null, - "charset": "utf8mb4", - "collation": "utf8mb4_bin", - "extra": [] - }, - "role": { - "type": [ - "varchar", - [ - "16" - ] - ], - "nullable": false, - "default": null, - "charset": "utf8mb4", - "collation": "utf8mb4_bin", - "extra": [] - }, - "build_version": { + "pc_scheduler_scans": { + "columns": { + "discoverer": { "type": [ "varchar", [ - "64" + "128" ] ], "nullable": false, @@ -3585,43 +1103,12 @@ "collation": "utf8mb4_bin", "extra": [] }, - "schema_min": { - "type": [ - "int", - [] - ], - "nullable": false, - "default": null, - "charset": null, - "collation": null, - "extra": [] - }, - "schema_max": { - "type": [ - "int", - [] - ], - "nullable": false, - "default": null, - "charset": null, - "collation": null, - "extra": [] - }, - "payload_min": { - "type": [ - "int", - [] - ], - "nullable": false, - "default": null, - "charset": null, - "collation": null, - "extra": [] - }, - "payload_max": { + "next_run_at": { "type": [ - "int", - [] + "datetime", + [ + "6" + ] ], "nullable": false, "default": null, @@ -3629,25 +1116,23 @@ "collation": null, "extra": [] }, - "behavior_revision": { + "continuation": { "type": [ "varchar", [ - "128" + "256" ] ], - "nullable": false, + "nullable": true, "default": null, "charset": "utf8mb4", "collation": "utf8mb4_bin", "extra": [] }, - "heartbeat_at": { + "state_version": { "type": [ - "datetime", - [ - "6" - ] + "bigint", + [] ], "nullable": false, "default": null, @@ -3655,7 +1140,7 @@ "collation": null, "extra": [] }, - "expires_at": { + "updated_at": { "type": [ "datetime", [ @@ -3671,99 +1156,24 @@ }, "primary_key": [ [ - "member_id", + "discoverer", null, "asc" ] ], "foreign_keys": [], "checks": { - "ck_pc_runtime_members_role": { - "expression": [ - "in", - [ - "column", - "role" - ], - [ - [ - "literal", - "all" - ], - [ - "literal", - "api" - ], - [ - "literal", - "scheduler" - ], - [ - "literal", - "worker" - ] - ] - ], - "enforced": true - }, - "ck_pc_runtime_members_schema_range": { - "expression": [ - "binary", - "and", - [ - "binary", - ">", - [ - "column", - "schema_min" - ], - [ - "number", - 0 - ] - ], - [ - "binary", - ">=", - [ - "column", - "schema_max" - ], - [ - "column", - "schema_min" - ] - ] - ], - "enforced": true - }, - "ck_pc_runtime_members_payload_range": { + "ck_pc_scheduler_scans_version_positive": { "expression": [ "binary", - "and", + ">", [ - "binary", - ">", - [ - "column", - "payload_min" - ], - [ - "number", - 0 - ] + "column", + "state_version" ], [ - "binary", - ">=", - [ - "column", - "payload_max" - ], - [ - "column", - "payload_min" - ] + "number", + 0 ] ], "enforced": true @@ -4019,111 +1429,6 @@ }, { "pc_rate_limit_windows": { - "columns": { - "principal_key": { - "type": [ - "varchar", - [ - "64" - ] - ], - "nullable": false, - "default": null, - "charset": "utf8mb4", - "collation": "utf8mb4_bin", - "extra": [] - }, - "policy_id": { - "type": [ - "varchar", - [ - "64" - ] - ], - "nullable": false, - "default": null, - "charset": "utf8mb4", - "collation": "utf8mb4_bin", - "extra": [] - }, - "window_started_at": { - "type": [ - "datetime", - [ - "6" - ] - ], - "nullable": false, - "default": null, - "charset": null, - "collation": null, - "extra": [] - }, - "request_count": { - "type": [ - "bigint", - [] - ], - "nullable": false, - "default": null, - "charset": null, - "collation": null, - "extra": [] - }, - "expires_at": { - "type": [ - "datetime", - [ - "6" - ] - ], - "nullable": false, - "default": null, - "charset": null, - "collation": null, - "extra": [] - } - }, - "primary_key": [ - [ - "principal_key", - null, - "asc" - ], - [ - "policy_id", - null, - "asc" - ], - [ - "window_started_at", - null, - "asc" - ] - ], - "foreign_keys": [], - "checks": { - "ck_pc_rate_limit_windows_count_positive": { - "expression": [ - "binary", - ">", - [ - "column", - "request_count" - ], - [ - "number", - 0 - ] - ], - "enforced": true - } - }, - "indexes": {} - } - }, - { - "pc_rate_limit_windows": { "columns": { "principal_key": { "type": [ From 44cc6b02e54a7f393af88a8e934ec6dee93dd36f Mon Sep 17 00:00:00 2001 From: jasondeng1997 <15301580353@163.com> Date: Thu, 5 Nov 2026 19:20:01 +0800 Subject: [PATCH 42/45] fix(review): rebuild the OpenCode bundle and close the remaining review findings - opencode: regenerate lib/index.js and lib/tui.js so the four Operation descriptors (list/get/cancel/retry) match src/operations.generated.ts; make opencode-test compares the committed bundle with a fresh build. - compose: interpolate the documented POWERCONTEXT_SERVER_INFERENCE_GENERATION_MODEL in both Workers, so the exported deployment guide variables start the stack. - cron: search a full Gregorian cycle instead of eight years, which rejected valid schedules such as '0 2 29 2 mon' whose next hit is 2044-02-29. - memory: window reduction no longer rewrites the Source cursor, so a retrying ledger claim keeps its pinned generation; a pinned claim retires through the fenced completion and discovery honours the persisted window hint. - distributed: build the worker security spec for ledger Workers even though the process-local supervisor advertises no capabilities, and authorize every claimed Scope with the background principal before dispatch. - tests: the fixed-revision probe appends after the bundle head instead of reusing a packaged revision and creating a down_revision cycle. --- docker/compose.distributed.yaml | 4 +- .../plugins/powercontext/lib/index.js | 49 ++++++++++ .../opencode/plugins/powercontext/lib/tui.js | 49 ++++++++++ .../runtime/atomic_memory_processing.py | 96 ++++++++++++++++--- .../builtin/runtime/composition.py | 37 ++++++- src/powercontext/builtin/runtime/cron.py | 30 ++++-- .../builtin/runtime/work_handlers.py | 30 +++++- src/powercontext/server/factory.py | 5 +- .../test_artifact_storage_lifecycle.py | 19 +++- tests/builtin/runtime/test_cron.py | 17 ++++ .../builtin/runtime/test_family_processing.py | 5 +- .../runtime/test_memory_window_recovery.py | 25 +++++ tests/builtin/runtime/test_memory_work.py | 38 ++++++++ .../builtin/runtime/test_work_composition.py | 78 +++++++++++++++ 14 files changed, 447 insertions(+), 35 deletions(-) diff --git a/docker/compose.distributed.yaml b/docker/compose.distributed.yaml index 29f9f8319e..eb13c574f2 100644 --- a/docker/compose.distributed.yaml +++ b/docker/compose.distributed.yaml @@ -116,7 +116,7 @@ services: POWERCONTEXT_SERVER_DEPLOYMENT_ID: worker-a POWERCONTEXT_SERVER_DASHBOARD_ENABLED: "false" POWERCONTEXT_SERVER_MCP_ENABLED: "false" - POWERCONTEXT_SERVER_INFERENCE_GENERATION_MODEL: ${POWERCONTEXT_GENERATION_MODEL:?set a generation model} + POWERCONTEXT_SERVER_INFERENCE_GENERATION_MODEL: ${POWERCONTEXT_SERVER_INFERENCE_GENERATION_MODEL:?set a generation model} OPENAI_API_KEY: ${OPENAI_API_KEY:-} ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-} depends_on: @@ -132,7 +132,7 @@ services: POWERCONTEXT_SERVER_DEPLOYMENT_ID: worker-b POWERCONTEXT_SERVER_DASHBOARD_ENABLED: "false" POWERCONTEXT_SERVER_MCP_ENABLED: "false" - POWERCONTEXT_SERVER_INFERENCE_GENERATION_MODEL: ${POWERCONTEXT_GENERATION_MODEL:?set a generation model} + POWERCONTEXT_SERVER_INFERENCE_GENERATION_MODEL: ${POWERCONTEXT_SERVER_INFERENCE_GENERATION_MODEL:?set a generation model} OPENAI_API_KEY: ${OPENAI_API_KEY:-} ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-} depends_on: diff --git a/integrations/opencode/plugins/powercontext/lib/index.js b/integrations/opencode/plugins/powercontext/lib/index.js index 28c10201a7..61197e0600 100644 --- a/integrations/opencode/plugins/powercontext/lib/index.js +++ b/integrations/opencode/plugins/powercontext/lib/index.js @@ -157,6 +157,55 @@ const OPERATIONS = { successStatuses: [200, 304], emptyStatuses: [304] }, + list_operations: { + method: "GET", + path: "/v1/scopes/{scope_id}/operations", + location: "query", + scopeMode: "none", + pathParameters: ["scope_id"], + queryParams: [ + "kind", + "status", + "limit", + "cursor" + ], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, + get_operation: { + method: "GET", + path: "/v1/scopes/{scope_id}/operations/{operation_id}", + location: null, + scopeMode: "none", + pathParameters: ["scope_id", "operation_id"], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, + cancel_operation: { + method: "POST", + path: "/v1/scopes/{scope_id}/operations/{operation_id}/cancel", + location: "body", + scopeMode: "none", + pathParameters: ["scope_id", "operation_id"], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, + retry_operation: { + method: "POST", + path: "/v1/scopes/{scope_id}/operations/{operation_id}/retry", + location: "body", + scopeMode: "none", + pathParameters: ["scope_id", "operation_id"], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, create_subject_source: { method: "POST", path: "/v1/scopes/{scope_id}/subject-sources", diff --git a/integrations/opencode/plugins/powercontext/lib/tui.js b/integrations/opencode/plugins/powercontext/lib/tui.js index a294dd56d0..d8586fe04b 100644 --- a/integrations/opencode/plugins/powercontext/lib/tui.js +++ b/integrations/opencode/plugins/powercontext/lib/tui.js @@ -157,6 +157,55 @@ const OPERATIONS = { successStatuses: [200, 304], emptyStatuses: [304] }, + list_operations: { + method: "GET", + path: "/v1/scopes/{scope_id}/operations", + location: "query", + scopeMode: "none", + pathParameters: ["scope_id"], + queryParams: [ + "kind", + "status", + "limit", + "cursor" + ], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, + get_operation: { + method: "GET", + path: "/v1/scopes/{scope_id}/operations/{operation_id}", + location: null, + scopeMode: "none", + pathParameters: ["scope_id", "operation_id"], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, + cancel_operation: { + method: "POST", + path: "/v1/scopes/{scope_id}/operations/{operation_id}/cancel", + location: "body", + scopeMode: "none", + pathParameters: ["scope_id", "operation_id"], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, + retry_operation: { + method: "POST", + path: "/v1/scopes/{scope_id}/operations/{operation_id}/retry", + location: "body", + scopeMode: "none", + pathParameters: ["scope_id", "operation_id"], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, create_subject_source: { method: "POST", path: "/v1/scopes/{scope_id}/subject-sources", diff --git a/src/powercontext/builtin/runtime/atomic_memory_processing.py b/src/powercontext/builtin/runtime/atomic_memory_processing.py index 623fe77cfb..883bafe5da 100644 --- a/src/powercontext/builtin/runtime/atomic_memory_processing.py +++ b/src/powercontext/builtin/runtime/atomic_memory_processing.py @@ -24,6 +24,7 @@ from hashlib import sha256 from typing import TYPE_CHECKING, Literal, NoReturn, cast +from pydantic import JsonValue from sqlalchemy import func, literal, select, true, tuple_, update from sqlalchemy.exc import OperationalError @@ -130,6 +131,24 @@ def from_runtime(cls, runtime: RuntimeConfig) -> AtomicMemoryProcessingConfig: ) +class MemoryWindowReducedError(Exception): + """A pinned ledger window was halved instead of being retried as-is. + + Raised after the recovery hint is persisted so the Work Ledger can retire the + oversized claim through its fenced completion and let discovery enqueue the + smaller window against the unchanged cursor generation. + """ + + def __init__(self, *, after: int, through: int, limit: int) -> None: + self.after = after + self.through = through + self.limit = limit + super().__init__(f"memory window {after}:{through} reduced to limit {limit}") + + def result_payload(self) -> dict[str, JsonValue]: + return {"after": self.after, "through": self.through, "window_limit": self.limit} + + class PreparedMemoryWindow: """A prepared window whose publication can join the caller's transaction. @@ -422,6 +441,7 @@ async def _prepare_window(self, scope_id, limit, processing, context, span, wind """ application = self.application trigger = SourceWindowTrigger() + pinned = window is not None async with application.database.transaction() as connection: if processing is not None: await processing.start(connection) @@ -468,12 +488,15 @@ async def _prepare_window(self, scope_id, limit, processing, context, span, wind workset, consulted_artifacts = await self._prepare(scope_id, eligible, pipeline, context) prepared, decision, observation = await self._inspect_decisions(scope_id, workset, context) except (InferenceTimeoutError, InvalidInferenceOutputError) as error: - reducible = (isinstance(error, InferenceTimeoutError) and error.operation == "generate") or ( - isinstance(error, InvalidInferenceOutputError) and error.operation == "atomic-memory-input-budget" + await self._handle_reducible_failure( + error, + pinned=pinned, + processing=processing, + scope_id=scope_id, + cursor=cursor, + action=action, + high_watermark=high_watermark, ) - if reducible and action.through - action.after > 1: - await self._reduce_window(scope_id, context, processing, cursor, state, action, high_watermark) - raise return PreparedMemoryWindow( self, scope_id=scope_id, @@ -491,22 +514,67 @@ async def _prepare_window(self, scope_id, limit, processing, context, span, wind high_watermark=high_watermark, ) - async def _reduce_window(self, scope_id, context, processing, cursor, state, action, high_watermark): + async def _handle_reducible_failure( + self, + error: InferenceTimeoutError | InvalidInferenceOutputError, + *, + pinned: bool, + processing, + scope_id, + cursor, + action, + high_watermark, + ) -> NoReturn: + """Shrink the next window, then end this attempt the way the caller requires. + + A local supervisor keeps the original timeout and retries later against the + persisted hint. A pinned ledger claim cannot shrink in place, so it retires + through ``MemoryWindowReducedError`` and lets discovery re-plan the window. + """ + + if not (self._reducible(error) and action.through - action.after > 1): + raise error + limit = max(1, (action.through - action.after) // 2) + try: + await self._reduce_window(processing, scope_id, cursor, action, high_watermark) + except AtomicMemoryConflictError: + # A concurrent window advanced this cursor first. The local supervisor + # retries against the new cursor; a stale claim still retires below. + if not pinned: + raise + if pinned: + raise MemoryWindowReducedError(after=action.after, through=action.through, limit=limit) from error + raise error + + @staticmethod + def _reducible(error: InferenceTimeoutError | InvalidInferenceOutputError) -> bool: + return (isinstance(error, InferenceTimeoutError) and error.operation == "generate") or ( + isinstance(error, InvalidInferenceOutputError) and error.operation == "atomic-memory-input-budget" + ) + + async def _reduce_window(self, processing, scope_id, cursor, action, high_watermark) -> None: + """Persist a smaller next window without invalidating the cursor generation. + + The hint is written while holding the cursor row so a concurrent window + commit cannot interleave, but the cursor itself is not rewritten: saving it + would bump its generation and turn the retrying Work payload into a + non-retryable ``cursor_changed`` failure, so the hint would never apply. + """ + + limit = max(1, (action.through - action.after) // 2) async with self.application.database.transaction() as connection: if processing is not None: await processing.guard(connection) - await self.cursors.save( - connection, - scope_id, - SOURCE_WINDOW_TRIGGER_NAME, - state, - expected_generation=None if cursor is None else cursor.generation, - ) + current = await self.cursors.load(connection, scope_id, SOURCE_WINDOW_TRIGGER_NAME, for_update=True) + generation = None if current is None else current.generation + if generation != (None if cursor is None else cursor.generation): + # Another window already advanced this cursor; its reduction wins. + raise AtomicMemoryConflictError("The Source cursor moved before window reduction") # noqa: TRY003 await self.windows.reduce( connection, scope_id, source_through=high_watermark, - window_limit=max(1, (action.through - action.after) // 2), + window_limit=limit, ) async def _inspect_decisions(self, scope_id, workset, context): diff --git a/src/powercontext/builtin/runtime/composition.py b/src/powercontext/builtin/runtime/composition.py index 00ceac0efc..d1a7583ff6 100644 --- a/src/powercontext/builtin/runtime/composition.py +++ b/src/powercontext/builtin/runtime/composition.py @@ -933,7 +933,13 @@ async def run_profile(scope_id, high): ) contexts.profiles.operation_context = runtime._operation runtime.work_background = await resources.enter_async_context( - _open_work_background(config, contexts, tracing=tracing, observer=work_observer) + _open_work_background( + config, + contexts, + tracing=tracing, + observer=work_observer, + worker_security=worker_security, + ) ) if config.handoff_report.enabled: runtime.handoff_report = HandoffReportApplication( @@ -1395,6 +1401,33 @@ def _build_version() -> str: return "unknown" +async def _work_authorizer( + stack: AsyncExitStack, + security: dict[str, Any] | None, + contexts: RelationalContexts, +) -> Callable[[Any], Any] | None: + """Authorize every claimed Scope with the configured background principal. + + The Work Ledger replaces the process-local supervisor in a distributed + deployment, so this check is the only place a Worker can refuse a Scope its + background principal may not contribute to. + """ + + if security is None: + return None + from powercontext.server.processing_security import open_worker_security + + opened = await stack.enter_async_context(open_worker_security(security, contexts.database)) + if opened is None: # pragma: no cover - the caller passed a specification + return None + worker_security = opened + + async def authorize(claim: Any) -> None: + await worker_security.authorize_scope(claim.scope_id) + + return authorize + + @asynccontextmanager async def _open_work_background( config: BuiltinConfig, @@ -1402,6 +1435,7 @@ async def _open_work_background( *, tracing: RuntimeTracing | None = None, observer: WorkObserver | None = None, + worker_security: dict[str, Any] | None = None, ) -> AsyncIterator[WorkBackground]: """Start the durable Work Ledger components this role is responsible for. @@ -1473,6 +1507,7 @@ async def _open_work_background( worker_id=f"{membership.instance_id}:worker", handlers=_work_handlers(config, contexts), config=config.worker, + authorizer=await _work_authorizer(stack, worker_security, contexts) if runs_worker else None, observer=observer, tracing=tracing, ) diff --git a/src/powercontext/builtin/runtime/cron.py b/src/powercontext/builtin/runtime/cron.py index ec0fef0784..c4a3827dec 100644 --- a/src/powercontext/builtin/runtime/cron.py +++ b/src/powercontext/builtin/runtime/cron.py @@ -16,11 +16,16 @@ from __future__ import annotations +from calendar import monthrange from collections.abc import Iterator from dataclasses import dataclass from datetime import UTC, date, datetime, time, timedelta from zoneinfo import ZoneInfo +# Feb 29 on a fixed weekday recurs at most every 40 years, and 400 years is a full +# Gregorian cycle, so this horizon reaches every occurrence a valid expression has. +_SEARCH_HORIZON_YEARS = 400 + _MONTH_NAMES = { name: index for index, name in enumerate( @@ -62,10 +67,7 @@ def next_after(self, value: datetime, /) -> datetime: # A repeated fall-back hour maps a later wall time to an earlier instant, so # the earliest future occurrence can belong to the previous local date. first_date = utc.astimezone(self.timezone).date() - timedelta(days=1) - for offset in range(366 * 8 + 1): - candidate_date = first_date + timedelta(days=offset) - if not self._matches_date(candidate_date): - continue + for candidate_date in self._candidate_dates(first_date): # Order the day's occurrences by real instant, not by wall clock: on a # transition day 01:00 fold=1 (06:00Z) precedes 01:16 fold=0 (05:16Z). occurrences = sorted( @@ -82,10 +84,22 @@ def next_after(self, value: datetime, /) -> datetime: for candidate in occurrences: if candidate.astimezone(UTC) > utc: return candidate.astimezone(UTC).replace(tzinfo=None) - raise ValueError("cron expression has no occurrence in the next eight years") # noqa: TRY003 - - def _matches_date(self, value: date) -> bool: - return value.month in self.months and value.day in self.days and value.weekday() in self.weekdays + raise ValueError("cron expression has no occurrence in the next four hundred years") # noqa: TRY003 + + def _candidate_dates(self, first_date: date) -> Iterator[date]: + """Yield matching dates across a full Gregorian cycle, in ascending order. + + Scanning by month keeps the leap-day case (``0 2 29 2 mon``, next hit in + 2044) inside the horizon instead of failing after an arbitrary cutoff. + """ + + for year in range(first_date.year, first_date.year + _SEARCH_HORIZON_YEARS): + for month in sorted(self.months): + last_day = monthrange(year, month)[1] + for day in sorted(candidate_day for candidate_day in self.days if candidate_day <= last_day): + candidate = date(year, month, day) + if candidate >= first_date and candidate.weekday() in self.weekdays: + yield candidate def _parse_field( diff --git a/src/powercontext/builtin/runtime/work_handlers.py b/src/powercontext/builtin/runtime/work_handlers.py index 4b8aabddd8..9209f3f3bf 100644 --- a/src/powercontext/builtin/runtime/work_handlers.py +++ b/src/powercontext/builtin/runtime/work_handlers.py @@ -40,6 +40,7 @@ from powercontext.builtin.persistence.cursors import StoredSourceCursor from powercontext.builtin.persistence.database import database_now, is_transaction_contention from powercontext.builtin.persistence.errors import GenerationConflictError +from powercontext.builtin.persistence.memory_windows import MemorySourceWindowRepository from powercontext.builtin.persistence.rate_limit import RateLimitRepository from powercontext.builtin.persistence.tables import PROFILE_POLICIES_TABLE from powercontext.builtin.persistence.work import ( @@ -49,6 +50,7 @@ WorkSpec, ) from powercontext.builtin.records import BaseValueConflictError +from powercontext.builtin.runtime.atomic_memory_processing import MemoryWindowReducedError from powercontext.builtin.runtime.cron import CronSchedule from powercontext.builtin.runtime.durable_scheduler import DiscoveryPage from powercontext.builtin.runtime.models import ExperienceIncubationResult, MemoryFlushResult @@ -318,11 +320,18 @@ async def prepare(self, claim: WorkClaim, /) -> PreparedWork: # Pin preparation to the persisted window: re-reading the live high watermark # would absorb Sources that arrived after this Work record was written, so the # claim would publish rows outside the window it reports. - prepared = await self._contexts.prepare_memory( - claim.scope_id, - self._window_limit, - window=(payload.after, payload.through), - ) + try: + prepared = await self._contexts.prepare_memory( + claim.scope_id, + self._window_limit, + window=(payload.after, payload.through), + ) + except MemoryWindowReducedError as reduced: + # The processor halved the window and persisted the hint. Retrying this + # claim would repeat the same oversized model call, so retire it through + # the fenced completion; discovery re-enqueues the smaller window from + # the unchanged cursor. + return PreparedWork(result=WorkResult(code="window_reduced", payload=reduced.result_payload())) if isinstance(prepared, MemoryFlushResult): # The trigger selected no window; there is nothing to publish and # nothing the ledger fence could discard. @@ -581,6 +590,7 @@ async def memory_work_spec( limit=limit, max_attempts=max_attempts, payload_version=payload_version, + honor_window_hint=True, ) @@ -651,10 +661,20 @@ async def _discover_spec( limit: int, max_attempts: int, payload_version: int, + honor_window_hint: bool = False, ) -> WorkSpec | None: scope = validate_scope_id(scope_id) async with contexts.database.transaction() as connection: state_row, high_watermark = await _window_state(contexts, connection, scope, cursor_name) + if honor_window_hint: + # A generation timeout persisted a smaller window for this cursor; using it + # here is what lets the retired claim turn into a window that fits. + _, limit = await MemorySourceWindowRepository().signal( + connection, + scope, + 0 if state_row is None else state_row.cursor.sequence, + limit, + ) payload = _window_payload(state_row, high_watermark, cursor_name, limit) return ( None diff --git a/src/powercontext/server/factory.py b/src/powercontext/server/factory.py index 192d535234..7b264315f3 100644 --- a/src/powercontext/server/factory.py +++ b/src/powercontext/server/factory.py @@ -279,7 +279,10 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: # noqa: C901 - shared resolved, active_access_control, legacy_static_principal=static_principal if legacy_static_admin else None, - enabled=config.runtime.artifact_processing_role != "api" and bool(processing_capabilities(config)), + # A distributed deployment advertises no process-local capabilities, + # but its Workers still claim ledger work, so they need the spec. + enabled=config.runtime.artifact_processing_role != "api" + and (bool(processing_capabilities(config)) or config.deployment.mode == "distributed"), injected=configured_access_control is not None, ) runtime = await resources.enter_async_context( diff --git a/tests/builtin/persistence/test_artifact_storage_lifecycle.py b/tests/builtin/persistence/test_artifact_storage_lifecycle.py index e272191b28..a29ef6c431 100644 --- a/tests/builtin/persistence/test_artifact_storage_lifecycle.py +++ b/tests/builtin/persistence/test_artifact_storage_lifecycle.py @@ -16,6 +16,7 @@ import asyncio import json +import re import shutil import pytest @@ -75,10 +76,22 @@ def test_fixed_artifact_storage_resumes_after_registration_columns(backend): # assert completed["source_fingerprint"] == interrupted["source_fingerprint"] +def _next_release_revision(head): + """Return the release revision after ``head`` so the probe never re-declares one.""" + + match = re.fullmatch(r"v(\d+)_(\d+)_(\d+)_(\d+)", head) + if match is None: # pragma: no cover - packaged bundles use the release shape + return f"{head}_probe" + major, minor, patch, build = match.groups() + return f"v{major}_{minor}_{patch}_{int(build) + 1:0{len(build)}d}" + + def _next_fixed_revision(bundle, directory): """A subsequent fixed-table addition leaves the space format untouched.""" shutil.copytree(bundle.directory, directory) - revision = "v1_5_0_003" + # Reusing a packaged revision would rewrite its MySQL version file and create a + # down_revision cycle, so the probe always appends after the current head. + revision = _next_release_revision(bundle.head) sql = "CREATE TABLE pc_lifecycle_probe (id INTEGER NOT NULL, PRIMARY KEY (id))" name, layout = _parse_table(sql) resource = "schemas/mysql-lifecycle-probe.json" @@ -88,10 +101,10 @@ def _next_fixed_revision(bundle, directory): "revisions": {revision: {"statements": [sql], "changes": [{name: layout}]}}, }) ) - (directory / "versions/v1_5_0_003_probe.py").write_text( + (directory / f"versions/{revision}_probe.py").write_text( f"revision = '{revision}'\ndown_revision = '{bundle.head}'\ndef upgrade():\n pass\n" ) - (directory / "mysql/versions/v1_5_0_003.py").write_text( + (directory / f"mysql/versions/{revision}.py").write_text( "from alembic import context\n" f"revision = '{revision}'\ndown_revision = '{bundle.head}'\n" "def upgrade():\n context.config.attributes['execute_revision'](revision)\n" diff --git a/tests/builtin/runtime/test_cron.py b/tests/builtin/runtime/test_cron.py index 1a89679a61..ca274e5b13 100644 --- a/tests/builtin/runtime/test_cron.py +++ b/tests/builtin/runtime/test_cron.py @@ -16,6 +16,8 @@ from datetime import datetime +import pytest + from powercontext.builtin.runtime.cron import CronSchedule @@ -94,3 +96,18 @@ def test_next_after_ordinary_days_are_unchanged() -> None: # 2026-06-15: 09:30 EDT = 13:30 UTC. assert schedule.next_after(datetime(2026, 6, 15, 12, 0)) == datetime(2026, 6, 15, 13, 30) # noqa: DTZ001 - naive on purpose assert schedule.next_after(datetime(2026, 6, 15, 13, 30)) == datetime(2026, 6, 16, 13, 30) # noqa: DTZ001 - naive on purpose + + +def test_next_after_finds_leap_day_matches_beyond_eight_years() -> None: + """29 February on a fixed weekday recurs decades later, not never.""" + + schedule = CronSchedule.parse("0 2 29 2 mon", "UTC") + assert schedule.next_after(datetime(2026, 11, 4)) == datetime(2044, 2, 29, 2, 0) # noqa: DTZ001 - naive on purpose + + +def test_next_after_rejects_dates_that_never_exist() -> None: + """30 February has no occurrence in a Gregorian cycle and must still raise.""" + + schedule = CronSchedule.parse("0 0 30 2 *", "UTC") + with pytest.raises(ValueError, match="four hundred years"): + schedule.next_after(datetime(2026, 11, 4)) # noqa: DTZ001 - naive on purpose diff --git a/tests/builtin/runtime/test_family_processing.py b/tests/builtin/runtime/test_family_processing.py index 63d1762b66..83b35abf3a 100644 --- a/tests/builtin/runtime/test_family_processing.py +++ b/tests/builtin/runtime/test_family_processing.py @@ -189,7 +189,10 @@ async def scenario(): intent = await ArtifactProcessingIntentRepository().load( connection, assignment.scope_id, assignment.binding_name ) - assert cursor is not None and cursor.cursor.sequence == 0 + # Shrinking the window persists a hint only: creating the cursor row + # here would stamp generation 1 and invalidate a ledger claim pinned + # at generation 0, so no row may appear before the first commit. + assert cursor is None or cursor.cursor.sequence == 0 assert intent is not None and intent.handled_generation == 0 for position in range(1, 5): assert (await process_family_invocation(contexts, assignment, config=config)).outcome == "succeeded" diff --git a/tests/builtin/runtime/test_memory_window_recovery.py b/tests/builtin/runtime/test_memory_window_recovery.py index ed14c30a24..637defbeae 100644 --- a/tests/builtin/runtime/test_memory_window_recovery.py +++ b/tests/builtin/runtime/test_memory_window_recovery.py @@ -27,9 +27,11 @@ from powercontext.builtin.persistence.decision_observations import DecisionObservationRepository from powercontext.builtin.persistence.sqlite import SQLiteConfig, SQLiteProfile from powercontext.builtin.persistence.tables import BUILTIN_TABLES +from powercontext.builtin.runtime.atomic_memory_processing import MemoryWindowReducedError from powercontext.builtin.runtime.composition import _initialize_atomic_memory_authority from powercontext.builtin.runtime.memory_write_gate import build_memory_write_gate from powercontext.builtin.runtime.relational import RelationalContexts +from powercontext.builtin.runtime.work_handlers import memory_work_spec from powercontext.builtin.scope import ScopeDraft from powercontext.server.logging import JsonFormatter from tests.e2e.dream_support import atomic_memory_pipeline, memory_source_text @@ -417,3 +419,26 @@ async def scenario(): assert (await context.triggers.flush(limit=100)).current_cursor == 3 asyncio.run(scenario()) + + +def test_pinned_window_timeout_reduces_without_bumping_the_cursor_generation(): + """A ledger claim keeps the generation it is pinned to while the hint shrinks it.""" + + async def scenario(): + pipeline = Pipeline() + async with SQLiteProfile.open(SQLiteConfig(), tables=BUILTIN_TABLES) as profile: + contexts = await _ready_contexts(profile, candidate_pipeline=atomic_memory_pipeline(pipeline)) + scope = await create_scope(contexts, 4) + with pytest.raises(MemoryWindowReducedError) as raised: + await contexts.prepare_memory(scope, 100, window=(0, 4)) + assert (raised.value.after, raised.value.through, raised.value.limit) == (0, 4, 2) + + async with profile.database.transaction() as connection: + row = await contexts.repositories.cursors.load(connection, scope, "powercontext.source-window") + assert row is None, "reduction must not create or rewrite the cursor row" + + spec = await memory_work_spec(contexts, scope, limit=100, max_attempts=3, payload_version=1) + assert spec is not None + assert (spec.payload["after"], spec.payload["through"]) == (0, 2) + + asyncio.run(scenario()) diff --git a/tests/builtin/runtime/test_memory_work.py b/tests/builtin/runtime/test_memory_work.py index 53508adf12..ec0d963970 100644 --- a/tests/builtin/runtime/test_memory_work.py +++ b/tests/builtin/runtime/test_memory_work.py @@ -19,9 +19,11 @@ import pytest from powercontext.builtin.artifacts.atomic_memory.errors import AtomicMemoryConflictError +from powercontext.builtin.persistence.memory_windows import MemorySourceWindowRepository from powercontext.builtin.persistence.sqlite import SQLiteConfig from powercontext.builtin.persistence.work import StaleWorkClaimError, WorkRepository from powercontext.builtin.runtime import BuiltinConfig, open_builtin_contexts +from powercontext.builtin.runtime.atomic_memory_processing import MemoryWindowReducedError from powercontext.builtin.runtime.models import MemoryFlushResult from powercontext.builtin.runtime.work_handlers import ( MEMORY_WORK_KIND, @@ -255,3 +257,39 @@ async def scenario() -> None: await contexts.prepare_memory("project", 100, window=(0, 1)) asyncio.run(scenario()) + + +def test_memory_timeout_retires_the_claim_and_keeps_the_cursor_generation(tmp_path, monkeypatch) -> None: + """A shrunk window retires the claim instead of poisoning the cursor generation.""" + + async def scenario() -> None: + async with open_builtin_contexts(_config(tmp_path)) as contexts: + context = await contexts.get("project") + for index in range(4): + await context.sources.capture(ContentCapture(source_id=f"source-{index}", content=f"Fact {index}")) + spec = await memory_work_spec(contexts, "project", limit=10, max_attempts=3, payload_version=1) + assert spec is not None + assert (spec.payload["after"], spec.payload["through"]) == (0, 4) + claim = await _claim(contexts, spec) + + async def reduced_prepare_memory(scope_id: str, limit: int, **_: object) -> object: + async with contexts.database.transaction() as connection: + await MemorySourceWindowRepository().reduce(connection, scope_id, source_through=4, window_limit=2) + raise MemoryWindowReducedError(after=0, through=4, limit=2) + + monkeypatch.setattr(contexts, "prepare_memory", reduced_prepare_memory) + prepared = await MemoryWorkHandler(contexts, window_limit=10).prepare(claim) + assert prepared.commit is None + assert prepared.result is not None + assert prepared.result.code == "window_reduced" + assert prepared.result.payload == {"after": 0, "through": 4, "window_limit": 2} + + async with contexts.database.transaction() as connection: + row = await contexts.repositories.cursors.load(connection, "project", SOURCE_WINDOW_TRIGGER_NAME) + assert row is None or row.generation == spec.payload["cursor_generation"] + + followup = await memory_work_spec(contexts, "project", limit=10, max_attempts=3, payload_version=1) + assert followup is not None + assert (followup.payload["after"], followup.payload["through"]) == (0, 2) + + asyncio.run(scenario()) diff --git a/tests/builtin/runtime/test_work_composition.py b/tests/builtin/runtime/test_work_composition.py index 1509823e9e..0c00e1dc95 100644 --- a/tests/builtin/runtime/test_work_composition.py +++ b/tests/builtin/runtime/test_work_composition.py @@ -16,6 +16,7 @@ import asyncio import logging +from contextlib import asynccontextmanager from datetime import UTC, datetime, timedelta from time import monotonic from types import SimpleNamespace @@ -418,3 +419,80 @@ async def purge_expired(self, connection, *, limit): assert len(purges) == after_stop asyncio.run(scenario()) + + +def test_distributed_worker_authorizes_every_claimed_scope(monkeypatch) -> None: + """A ledger Worker must check the background principal before preparing work.""" + + async def scenario() -> None: + authorized: list[str] = [] + + class _Security: + async def authorize_scope(self, scope_id: str) -> None: + authorized.append(scope_id) + + @asynccontextmanager + async def _open_security(spec, database): + assert spec == {"principal": {"type": "service", "id": "background"}} + yield _Security() + + monkeypatch.setattr("powercontext.server.processing_security.open_worker_security", _open_security) + + constructed: dict[str, dict[str, object]] = {} + + class _SpyComponent: + def __init__(self, *args, **kwargs) -> None: + constructed[self._role] = kwargs + self._stopped = asyncio.Event() + + _role = "" + instance_id = "instance-1" + + async def start(self) -> None: + pass + + async def run(self) -> None: + await self._stopped.wait() + + async def stop(self) -> None: + self._stopped.set() + + class _SpyMembership(_SpyComponent): + _role = "membership" + + class _SpyScheduler(_SpyComponent): + _role = "scheduler" + + class _SpyWorker(_SpyComponent): + _role = "worker" + + monkeypatch.setattr("powercontext.builtin.runtime.membership.RuntimeMembership", _SpyMembership) + monkeypatch.setattr("powercontext.builtin.runtime.durable_scheduler.DurableScheduler", _SpyScheduler) + monkeypatch.setattr("powercontext.builtin.runtime.worker.DurableWorker", _SpyWorker) + monkeypatch.setattr("powercontext.builtin.runtime.composition._work_discoverers", lambda config, contexts: ()) + monkeypatch.setattr("powercontext.builtin.runtime.composition._work_handlers", lambda config, contexts: ()) + + config = cast( + "BuiltinConfig", + SimpleNamespace( + deployment=DeploymentConfig(mode="distributed", role="worker"), + runtime=RuntimeConfig(memory_schedule_seconds=30), + coordination=CoordinationConfig(), + worker=WorkerConfig(), + operations=OperationsConfig(), + ), + ) + contexts = cast("RelationalContexts", SimpleNamespace(database=None)) + spec = {"principal": {"type": "service", "id": "background"}} + async with _open_work_background(config, contexts, worker_security=spec) as background: + assert background.worker is not None + authorizer = constructed["worker"]["authorizer"] + assert authorizer is not None + await authorizer(SimpleNamespace(scope_id="project")) + assert authorized == ["project"] + + async with _open_work_background(config, contexts) as background: + assert background.worker is not None + assert constructed["worker"]["authorizer"] is None + + asyncio.run(scenario()) From 9efb56f7cde564cd5d4b369c38e1b4f308e4b396 Mon Sep 17 00:00:00 2001 From: JasonDeng <15301580353@163.com> Date: Thu, 5 Nov 2026 19:50:28 +0800 Subject: [PATCH 43/45] fix(desktop): qualify the bundled contract after the Operations paths The Operations paths changed the normalized OpenAPI digest, so the bundled Desktop contract had no qualification record and the Python gate failed before the native suite could run. Build the wheel from the clean commit, run the native probe in all four SQLite modes and record the resulting source, contract, wheel and operation identities. --- desktop/VALIDATION.md | 49 +++++++++++++++++-- .../src/connections/compatibility.json | 21 ++++++++ 2 files changed, 67 insertions(+), 3 deletions(-) diff --git a/desktop/VALIDATION.md b/desktop/VALIDATION.md index cf0dce5e6a..4f01f8652b 100644 --- a/desktop/VALIDATION.md +++ b/desktop/VALIDATION.md @@ -177,9 +177,14 @@ The historical Server c535d816 protocol qualification below records the specific ## Compatibility scope -`sqlite-observability-stats-4568b499-v1` matches the bundled API contract and qualifies -all twelve operations, including statistics, for its exact Server source and wheel below. -The separate `sqlite-observability-4568b499-v1` retains its eleven-operation evidence. +`sqlite-operations-e958f47c-v1` matches the current bundled API contract and qualifies all +twelve Desktop operations for Server source `cd7e4be52e25726094e4a6205d28bf48ae4de402`. +The contract digest moved because the Operations paths were added to `openapi/powercontext.yaml`; +the bundled Desktop operation set itself did not change, so the new record repeats the same +twelve operations against the new contract and wheel. +`sqlite-observability-stats-4568b499-v1` retains its own twelve-operation qualification for +the earlier digest, and the separate `sqlite-observability-4568b499-v1` retains its +eleven-operation evidence. [compatibility.json](src-tauri/src/connections/compatibility.json) preserves each recorded Server source, normalized OpenAPI contract digest, qualification-wheel digest and operation set. Historical records retain their exact identities and evidence. A record qualifies only its recorded Server source, wheel and contract; records with a different contract digest are disabled in the selector. Saved unavailable selections remain visible until explicitly replaced. Selecting a profile does not extend its recorded operation set or qualify a different Server source. CI builds have their own artifact identities and do not change product qualification. @@ -209,6 +214,44 @@ The four isolated SQLite Server configurations (anonymous loopback, static Bearer, HTTPS with an explicit CA and reverse-proxy base path, and an enforced authentication Provider) passed with `desktopWorkingTreeDirty=false`. +#### Server e958f47c protocol qualification + +`sqlite-operations-e958f47c-v1` qualifies all twelve Desktop operations against clean +Server source `cd7e4be52e25726094e4a6205d28bf48ae4de402` and normalized OpenAPI digest +`e958f47cc4b5668d0e06b8be009032530baca108736fe64235c5ffee862ec0e2`. The digest moved +because this change adds the four `/v1/scopes/{scope_id}/operations` paths to +`openapi/powercontext.yaml`; the bundled Desktop operation set is unchanged, so the record +repeats the same twelve operations against the new contract and wheel. +Wheel: `powercontext-1.2.1.dev686+gcd7e4be52-py3-none-any.whl`. +Wheel SHA-256: `40733fd201a95ac3894970d2e39caa1db3c89bb09de6246b1f6bbac1f6849e19`. +All 753 packaged runtime files in `powercontext/` and +`powercontext_service_bootstrap/` match the clean source byte for byte. +The sorted newline-terminated `wheel/path sha256` manifest hashes to +`a00b3e8720fb41552089bb4db403ff978fb91dd83192b96583a9bc081d930dc5`. + +On 2026-11-05, macOS 27.2 ARM64, Python 3.14.7 and Rust 1.95.0 ran the default native +`server_probe`, built without `ci-fixtures`, against the extracted wheel in all four +isolated SQLite configurations: anonymous loopback, static Bearer, HTTPS with an explicit +CA and reverse-proxy base path, and an enforced authentication Provider. The probe reached +this record through ordinary `ConnectionManager` profile checking, activation and +exact-Scope selection, and the report records +`qualificationSource: bundled product records` with +`qualificationId: sqlite-operations-e958f47c-v1`. Coverage is save/search/exact reads, +identity changes, binding revocation, lost write responses without replay, Scope +pagination and Server survival after client exit. + +```sh +cargo +1.95.0 build --locked --manifest-path desktop/src-tauri/Cargo.toml --example server_probe +uv run --no-sync python desktop/tests/real_server.py --product-qualification sqlite-operations-e958f47c-v1 +``` + +The earlier records keep their exact source, contract, wheel and operation identities and +remain disabled for this contract digest. Evidence is retained under ignored +`desktop/.artifacts/real-server.json` and `desktop/.artifacts/server-wheel/`. This record +covers local SQLite protocol behavior only; installed UI, native credential persistence, +Windows execution, external production deployments and release acceptance are not +established. + #### Server 4568b499 statistics product qualification `sqlite-observability-stats-4568b499-v1` qualifies all twelve Desktop operations, diff --git a/desktop/src-tauri/src/connections/compatibility.json b/desktop/src-tauri/src/connections/compatibility.json index d469ce77d4..69759f7ccb 100644 --- a/desktop/src-tauri/src/connections/compatibility.json +++ b/desktop/src-tauri/src/connections/compatibility.json @@ -1,4 +1,25 @@ [ + { + "id": "sqlite-operations-e958f47c-v1", + "serverCommit": "cd7e4be52e25726094e4a6205d28bf48ae4de402", + "contractSha256": "e958f47cc4b5668d0e06b8be009032530baca108736fe64235c5ffee862ec0e2", + "artifactSha256": "40733fd201a95ac3894970d2e39caa1db3c89bb09de6246b1f6bbac1f6849e19", + "operations": [ + "get_access_principal", + "get_artifact_revision", + "get_capabilities", + "get_default_scope", + "get_liveness", + "get_readiness", + "get_scope", + "get_server_info", + "get_stats", + "list_scopes", + "remember_memory", + "search_memory" + ], + "evidence": "desktop/VALIDATION.md#server-e958f47c-protocol-qualification" + }, { "id": "sqlite-atomic-6c2aa7ac-v1", "serverCommit": "6c2aa7ac899aabc1a250ca9c56e4b5c94ce64525", From 89e8170dd57b51d11242be7022ec89f0be081d48 Mon Sep 17 00:00:00 2001 From: JasonDeng <15301580353@163.com> Date: Thu, 5 Nov 2026 19:51:17 +0800 Subject: [PATCH 44/45] docs(desktop): record the clean-tree qualification run --- desktop/VALIDATION.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/desktop/VALIDATION.md b/desktop/VALIDATION.md index 4f01f8652b..d07ea1ac6a 100644 --- a/desktop/VALIDATION.md +++ b/desktop/VALIDATION.md @@ -236,7 +236,9 @@ CA and reverse-proxy base path, and an enforced authentication Provider. The pro this record through ordinary `ConnectionManager` profile checking, activation and exact-Scope selection, and the report records `qualificationSource: bundled product records` with -`qualificationId: sqlite-operations-e958f47c-v1`. Coverage is save/search/exact reads, +`qualificationId: sqlite-operations-e958f47c-v1`. The four configurations passed with +`desktopWorkingTreeDirty=false` at Desktop commit +`9efb56f7cde564cd5d4b369c38e1b4f308e4b396`. Coverage is save/search/exact reads, identity changes, binding revocation, lost write responses without replay, Scope pagination and Server survival after client exit. From 2101fe8876d2094b208e554ee28de8fa406dd959 Mon Sep 17 00:00:00 2001 From: JasonDeng <15301580353@163.com> Date: Thu, 5 Nov 2026 20:22:51 +0800 Subject: [PATCH 45/45] test(work): keep the distributed authorizer check type-checkable ruff-format also restored the two blank lines the merge dropped in app.py. --- src/powercontext/server/app.py | 2 ++ tests/builtin/runtime/test_work_composition.py | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/powercontext/server/app.py b/src/powercontext/server/app.py index 0768f77075..eb6b34f2ed 100644 --- a/src/powercontext/server/app.py +++ b/src/powercontext/server/app.py @@ -1463,6 +1463,8 @@ def build_middleware_stack(self) -> ASGIApp: skip_paths=middleware.kwargs.get("skip_paths", ()), ) return stack + + def create_app( *, application: ServerApplication | None = None, diff --git a/tests/builtin/runtime/test_work_composition.py b/tests/builtin/runtime/test_work_composition.py index 0c00e1dc95..a96a0001bf 100644 --- a/tests/builtin/runtime/test_work_composition.py +++ b/tests/builtin/runtime/test_work_composition.py @@ -16,6 +16,7 @@ import asyncio import logging +from collections.abc import Awaitable, Callable from contextlib import asynccontextmanager from datetime import UTC, datetime, timedelta from time import monotonic @@ -486,8 +487,7 @@ class _SpyWorker(_SpyComponent): spec = {"principal": {"type": "service", "id": "background"}} async with _open_work_background(config, contexts, worker_security=spec) as background: assert background.worker is not None - authorizer = constructed["worker"]["authorizer"] - assert authorizer is not None + authorizer = cast("Callable[[object], Awaitable[None]]", constructed["worker"]["authorizer"]) await authorizer(SimpleNamespace(scope_id="project")) assert authorized == ["project"]