From c4eba4b2dd8d9cf5bfe2bfe91e1ff1f2ced64d08 Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Sat, 3 Oct 2026 13:33:19 -0700 Subject: [PATCH 1/9] Add reviewed container operations and worker capacity controls --- CHANGELOG.md | 16 + README.md | 18 +- desktop/RELEASE.md | 8 +- desktop/package-lock.json | 4 +- desktop/package.json | 2 +- desktop/src-tauri/Cargo.lock | 2 +- desktop/src-tauri/Cargo.toml | 2 +- desktop/src-tauri/tauri.conf.json | 6 +- docs/README.md | 4 +- docs/capabilities.md | 4 +- docs/diagrams/operations-control-plane.svg | 54 + docs/diagrams/operations-plan-lifecycle.svg | 54 + docs/diagrams/worker-capacity.svg | 47 + docs/guides/ai-workers.md | 2 +- docs/guides/desktop.md | 48 +- docs/guides/learning-path.md | 6 +- docs/guides/local-platform.md | 6 +- docs/guides/lumi.md | 2 +- docs/guides/standalone.md | 6 +- docs/guides/studio-step-reference.md | 4 +- docs/guides/studio.md | 36 +- docs/installation.md | 28 +- docs/operations/cluster-management.md | 264 ++++ docs/operations/operations-runner.md | 279 ++++ docs/operations/upgrades.md | 1 + docs/reference/api.md | 72 + docs/reference/local-runtime.md | 2 +- docs/reference/native-openapi.md | 2 +- docs/reference/sdk.md | 34 + migrations/versions/0029_deployments.py | 95 ++ migrations/versions/0030_worker_presence.py | 45 + mkdocs.yml | 2 + pyproject.toml | 2 +- src/firefly_weave/__init__.py | 2 +- src/firefly_weave/access/models.py | 6 + src/firefly_weave/access/roles.py | 6 + src/firefly_weave/api/deployments.py | 265 ++++ src/firefly_weave/api/workers.py | 21 + src/firefly_weave/app.py | 1 + src/firefly_weave/cli/cluster_operations.py | 202 +++ src/firefly_weave/cli/main.py | 4 +- src/firefly_weave/contracts/deployments.py | 297 ++++ src/firefly_weave/contracts/schema_export.py | 58 + src/firefly_weave/contracts/surface.py | 274 +++- src/firefly_weave/contracts/workers.py | 18 + .../deployment_runner/__init__.py | 17 + .../deployment_runner/application.py | 138 ++ src/firefly_weave/deployment_runner/azure.py | 296 ++++ .../deployment_runner/command.py | 126 ++ .../deployment_runner/compose.py | 232 ++++ .../deployment_runner/kubernetes.py | 299 +++++ src/firefly_weave/deployment_runner/policy.py | 104 ++ .../deployment_runner/runtime.py | 231 ++++ src/firefly_weave/deployments/__init__.py | 17 + src/firefly_weave/deployments/leases.py | 353 +++++ src/firefly_weave/deployments/repository.py | 124 ++ src/firefly_weave/deployments/service.py | 662 +++++++++ src/firefly_weave/persistence/migrations.py | 2 +- src/firefly_weave/sdk/client.py | 26 +- src/firefly_weave/sdk/platform.py | 5 + src/firefly_weave/studio/host.py | 18 + src/firefly_weave/studio/service.py | 6 + src/firefly_weave/workers/leases.py | 9 + src/firefly_weave/workers/models.py | 34 +- src/firefly_weave/workers/repository.py | 40 +- src/firefly_weave/workers/service.py | 87 +- studio/src/app/app.html | 6 + studio/src/app/app.ts | 8 +- studio/src/app/icon.ts | 1 + .../app/operations/deployment-contracts.ts | 210 +++ .../src/app/operations/deployment-editor.ts | 219 +++ .../app/operations/deployment-plan-builder.ts | 146 ++ .../operations/deployment-reconciliation.ts | 117 ++ studio/src/app/operations/deployment-store.ts | 279 ++++ studio/src/app/operations/operations-view.css | 130 ++ studio/src/app/operations/operations-view.ts | 1190 +++++++++++++++++ studio/src/app/operations/records-model.ts | 4 +- studio/src/app/operations/records-view.ts | 17 +- studio/src/app/operations/target-wizard.ts | 371 +++++ studio/src/app/operations/worker-controls.ts | 178 +++ studio/src/app/routes.ts | 5 + studio/src/app/status-labels.ts | 2 +- .../browser/deployment-operations.spec.ts | 1047 +++++++++++++++ .../tests/browser/worker-operations.spec.ts | 122 ++ studio/tests/deployment-store.test.ts | 213 +++ studio/tests/records-model.test.ts | 17 +- tests/integration/test_compose_operations.py | 131 ++ tests/integration/test_deployments.py | 546 ++++++++ tests/integration/test_worker_presence.py | 263 ++++ tests/unit/cli/test_platform_sign_in.py | 14 +- tests/unit/sdk/test_worker_control_sdk.py | 73 + tests/unit/test_deployment_azure.py | 180 +++ tests/unit/test_deployment_command.py | 75 ++ tests/unit/test_deployment_compose.py | 249 ++++ tests/unit/test_deployment_contracts.py | 157 +++ tests/unit/test_deployment_kubernetes.py | 324 +++++ tests/unit/test_deployment_policy.py | 63 + tests/unit/test_deployment_runner.py | 246 ++++ tests/unit/test_operations_cli.py | 41 + tests/unit/test_operations_runner_config.py | 67 + tests/unit/test_runtime_setup_ports.py | 1 + tests/unit/test_worker_presence_contracts.py | 65 + uv.lock | 2 +- workers/agentic/pyproject.toml | 4 +- workers/agentic/uv.lock | 4 +- workers/files/pyproject.toml | 4 +- workers/files/uv.lock | 4 +- 107 files changed, 11789 insertions(+), 143 deletions(-) create mode 100644 docs/diagrams/operations-control-plane.svg create mode 100644 docs/diagrams/operations-plan-lifecycle.svg create mode 100644 docs/diagrams/worker-capacity.svg create mode 100644 docs/operations/cluster-management.md create mode 100644 docs/operations/operations-runner.md create mode 100644 migrations/versions/0029_deployments.py create mode 100644 migrations/versions/0030_worker_presence.py create mode 100644 src/firefly_weave/api/deployments.py create mode 100644 src/firefly_weave/cli/cluster_operations.py create mode 100644 src/firefly_weave/contracts/deployments.py create mode 100644 src/firefly_weave/deployment_runner/__init__.py create mode 100644 src/firefly_weave/deployment_runner/application.py create mode 100644 src/firefly_weave/deployment_runner/azure.py create mode 100644 src/firefly_weave/deployment_runner/command.py create mode 100644 src/firefly_weave/deployment_runner/compose.py create mode 100644 src/firefly_weave/deployment_runner/kubernetes.py create mode 100644 src/firefly_weave/deployment_runner/policy.py create mode 100644 src/firefly_weave/deployment_runner/runtime.py create mode 100644 src/firefly_weave/deployments/__init__.py create mode 100644 src/firefly_weave/deployments/leases.py create mode 100644 src/firefly_weave/deployments/repository.py create mode 100644 src/firefly_weave/deployments/service.py create mode 100644 studio/src/app/operations/deployment-contracts.ts create mode 100644 studio/src/app/operations/deployment-editor.ts create mode 100644 studio/src/app/operations/deployment-plan-builder.ts create mode 100644 studio/src/app/operations/deployment-reconciliation.ts create mode 100644 studio/src/app/operations/deployment-store.ts create mode 100644 studio/src/app/operations/operations-view.css create mode 100644 studio/src/app/operations/operations-view.ts create mode 100644 studio/src/app/operations/target-wizard.ts create mode 100644 studio/src/app/operations/worker-controls.ts create mode 100644 studio/tests/browser/deployment-operations.spec.ts create mode 100644 studio/tests/browser/worker-operations.spec.ts create mode 100644 studio/tests/deployment-store.test.ts create mode 100644 tests/integration/test_compose_operations.py create mode 100644 tests/integration/test_deployments.py create mode 100644 tests/integration/test_worker_presence.py create mode 100644 tests/unit/sdk/test_worker_control_sdk.py create mode 100644 tests/unit/test_deployment_azure.py create mode 100644 tests/unit/test_deployment_command.py create mode 100644 tests/unit/test_deployment_compose.py create mode 100644 tests/unit/test_deployment_contracts.py create mode 100644 tests/unit/test_deployment_kubernetes.py create mode 100644 tests/unit/test_deployment_policy.py create mode 100644 tests/unit/test_deployment_runner.py create mode 100644 tests/unit/test_operations_cli.py create mode 100644 tests/unit/test_operations_runner_config.py create mode 100644 tests/unit/test_worker_presence_contracts.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 714e5061..bb34fcb4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,22 @@ SPDX-License-Identifier: Apache-2.0 # Changelog +## 0.1.0a12 + +- Add environment-scoped deployment targets, desired state, observations, + immutable approval plans, fenced runner jobs, and explicit reconciliation + for uncertain external effects. Provider credentials remain on an outbound + runner with local destination and image allowlists. +- Add Operations in Studio and the CLI for reviewed container deployment + changes. Support trusted Compose deployment and bounded existing Kubernetes + and Azure Container Apps changes; provisioning cloud clusters is separate. +- Report dated worker contact and capacity, with revision-checked drain and + resume controls that preserve active leases. +- Add migrations `0029_deployments` and `0030_worker_presence`, with explicit + scoped Operations roles. Existing workflow roles gain no deployment authority. +- Publish worker packages 0.1.4 pinned to core 0.1.0a12 and desktop alpha12. + Keep the verified alpha10 Azure acceptance history separate from this release. + ## 0.1.0a11 - Guide AI provider connections, Lumi settings, and workflow AI profiles through diff --git a/README.md b/README.md index 11a60c77..5200215e 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ maps service tasks, user tasks, gateways, and timers to Weave steps. ## What you get -The **v0.1.0a11 alpha** release provides the **API, CLI, Python SDK, and Studio** +The **v0.1.0a12 alpha** release provides the **API, CLI, Python SDK, and Studio** visual workspace, with human-task inboxes, email conversations, execution management, and administration of people and access. Run Weave as a standalone service or embed it in another product. @@ -81,7 +81,7 @@ See the illustrated [AI workflow guide](docs/guides/ai-workers.md) and Read the [capability matrix](docs/capabilities.md) for tested boundaries and live-provider checks that remain environment-specific. -Studio runs in your browser from the [installed CLI](docs/guides/studio.md#install-the-alpha11-browser-application) +Studio runs in your browser from the [installed CLI](docs/guides/studio.md#install-the-alpha12-browser-application) or as a [desktop app](docs/guides/desktop.md). The macOS desktop bundles are ad-hoc signed, not Developer ID signed or notarized, so macOS may ask you to approve them; do not use the alpha5 macOS installers, which were damaged. The @@ -119,7 +119,7 @@ integration code. Each has its own guide, so you can stop at the result you need ## Install and discover the CLI On macOS, Linux, or WSL, install **Python 3.12 or newer** with `venv` support, -then run this block in Bash or Zsh. It installs the pinned **v0.1.0a11 alpha** +then run this block in Bash or Zsh. It installs the pinned **v0.1.0a12 alpha** into your user account without `sudo`, Git, or Docker: ```sh @@ -127,12 +127,12 @@ into your user account without `sudo`, Git, or Docker: # Stop if downloading the installer fails. set -o pipefail curl --proto '=https' --tlsv1.2 -fsSL \ - https://github.com/fireflyframework/firefly-weave/releases/download/v0.1.0a11/install.sh \ - | sh -s -- --version v0.1.0a11 + https://github.com/fireflyframework/firefly-weave/releases/download/v0.1.0a12/install.sh \ + | sh -s -- --version v0.1.0a12 ) ``` -Expected: `Installed Firefly Weave 0.1.0a11:` followed by the command's path. Then +Expected: `Installed Firefly Weave 0.1.0a12:` followed by the command's path. Then make the default command directory available in this terminal and look around: ```sh @@ -146,7 +146,7 @@ weave help workflow weave docs platform ``` -Expected: `Firefly Weave 0.1.0a11`, the command overview, the `workflow` +Expected: `Firefly Weave 0.1.0a12`, the command overview, the `workflow` commands, and the address of the platform guide. You do not need to learn every command first: help explains each family and its next steps. The [installation guide](docs/installation.md) covers choosing Python, a permanent @@ -179,7 +179,7 @@ Compose files and setup helpers. Clone the tag that matches the CLI: ```sh # Keep the platform files at the same version as the CLI. -git clone --branch v0.1.0a11 --single-branch https://github.com/fireflyframework/firefly-weave.git +git clone --branch v0.1.0a12 --single-branch https://github.com/fireflyframework/firefly-weave.git cd firefly-weave # Check prerequisites, then prepare private settings and dependencies once. @@ -265,7 +265,7 @@ path, and the detailed diagrams. ## Current release and limits -The recommended installation is **v0.1.0a11**, an **alpha** release. Download +The recommended installation is **v0.1.0a12**, an **alpha** release. Download packages and checksums from [GitHub Releases](https://github.com/fireflyframework/firefly-weave/releases). The documentation on a branch describes the source on that branch; a release tag diff --git a/desktop/RELEASE.md b/desktop/RELEASE.md index 85393bf6..ccb856ce 100644 --- a/desktop/RELEASE.md +++ b/desktop/RELEASE.md @@ -17,16 +17,16 @@ SPDX-License-Identifier: Apache-2.0 --> # Desktop release versions -The alpha11 product version is `0.1.0-alpha.11` in npm, Cargo and Tauri. It corresponds -to Python `0.1.0a11` and the repository prerelease tag `v0.1.0a11`. +The alpha12 product version is `0.1.0-alpha.12` in npm, Cargo and Tauri. It corresponds +to Python `0.1.0a12` and the repository prerelease tag `v0.1.0a12`. -Windows MSI uses the explicit numeric version `0.1.11`. Tauri's pinned bundler rejects +Windows MSI uses the explicit numeric version `0.1.12`. Tauri's pinned bundler rejects nonnumeric prerelease identifiers when deriving MSI versions; its `windows.wix.version` override supplies the valid numeric installer version while filenames retain the product version. MSI compares only the first three fields. Future desktop alpha and stable releases must advance this numeric installer counter: the stable product `0.1.0` must not reset the MSI counter to `0.1.0`. The macOS internal bundle version also uses -numeric `0.1.11`; its displayed product version remains the alpha product version. +numeric `0.1.12`; its displayed product version remains the alpha product version. The Desktop installers workflow builds on four native runners and is dispatchable by an exact branch/tag ref. Versioned, target-specific unsigned artifacts contain only diff --git a/desktop/package-lock.json b/desktop/package-lock.json index aced224e..7c134d30 100644 --- a/desktop/package-lock.json +++ b/desktop/package-lock.json @@ -1,12 +1,12 @@ { "name": "@firefly-weave/desktop", - "version": "0.1.0-alpha.11", + "version": "0.1.0-alpha.12", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@firefly-weave/desktop", - "version": "0.1.0-alpha.11", + "version": "0.1.0-alpha.12", "license": "Apache-2.0", "devDependencies": { "@tauri-apps/cli": "2.12.1" diff --git a/desktop/package.json b/desktop/package.json index 930f3b88..b360f7ac 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@firefly-weave/desktop", - "version": "0.1.0-alpha.11", + "version": "0.1.0-alpha.12", "private": true, "license": "Apache-2.0", "scripts": { diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index a0041c50..b4c2068d 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -794,7 +794,7 @@ checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" [[package]] name = "firefly-weave-studio" -version = "0.1.0-alpha.11" +version = "0.1.0-alpha.12" dependencies = [ "serde", "serde_json", diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index e417c2f6..6757c348 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -16,7 +16,7 @@ [package] name = "firefly-weave-studio" -version = "0.1.0-alpha.11" +version = "0.1.0-alpha.12" description = "Firefly Weave Studio desktop" authors = ["Firefly Software Foundation"] license = "Apache-2.0" diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json index dec374a4..a529c386 100644 --- a/desktop/src-tauri/tauri.conf.json +++ b/desktop/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Firefly Weave Studio", - "version": "0.1.0-alpha.11", + "version": "0.1.0-alpha.12", "identifier": "io.getfirefly.weave.studio", "build": { "frontendDist": "../bootstrap" @@ -52,7 +52,7 @@ "y": 160 } }, - "bundleVersion": "0.1.11", + "bundleVersion": "0.1.12", "signingIdentity": "-", "entitlements": "entitlements.plist" }, @@ -61,7 +61,7 @@ "type": "downloadBootstrapper" }, "wix": { - "version": "0.1.11" + "version": "0.1.12" } }, "icon": [ diff --git a/docs/README.md b/docs/README.md index e287a956..ce341d41 100644 --- a/docs/README.md +++ b/docs/README.md @@ -47,7 +47,7 @@ gives the steps and what you finish with. | An administrator | Install the platform, configure sign-in, and give people access | [Start a local platform](guides/local-platform.md) | [Identity and secrets](operations/identity-and-secrets.md), [remote deployment](operations/remote-deployment.md), [people and access](guides/people-and-access.md), and [configuration](operations/configuration.md) | | An operator | Keep runs healthy, fix incidents, upgrade, and back up | [Connect the CLI](guides/connect-to-api.md), then [manage runs](guides/execution-management.md) | [Incidents](reference/incident-operations.md), [troubleshooting](operations/troubleshooting.md), [observability](operations/observability.md), [upgrades](operations/upgrades.md), [backup](operations/backup-restore.md), and [debug-session retention](operations/retention.md) | -Start each path by [installing the CLI](installation.md) from the v0.1.0a11 +Start each path by [installing the CLI](installation.md) from the v0.1.0a12 release. [Who does what](guides/roles-and-lifecycle.md) explains these roles with an @@ -104,7 +104,7 @@ Run one command block, check its **Expected** result, then continue. Comments inside code blocks explain why each command is there. When a terminal shows server logs, keep it open and use a second terminal for client commands. -The guides pin the **v0.1.0a11 alpha** release, a preview, which includes every +The guides pin the **v0.1.0a12 alpha** release, a preview, which includes every feature they describe, such as saved platforms, REST calls without code, and the Studio editor. When a guide shows how to work with an alpha6 or earlier client or server, it says so. diff --git a/docs/capabilities.md b/docs/capabilities.md index 34b82d2c..fdd40185 100644 --- a/docs/capabilities.md +++ b/docs/capabilities.md @@ -51,8 +51,8 @@ permissions, and real delivery still need checking in your deployment. ## Which release this page describes Weave is an alpha workflow and integration platform. This branch prepares release -**`0.1.0a11`**, with PyFly 26.9.15 and database schema revision -`0028_files`; published packages are on +**`0.1.0a12`**, with PyFly 26.9.15 and database schema revision +`0030_worker_presence`; published packages are on [GitHub Releases](https://github.com/fireflyframework/firefly-weave/releases). It includes [Studio](guides/studio.md), [human tasks and operator pause and resume](guides/human-tasks.md), [email conversations and diff --git a/docs/diagrams/operations-control-plane.svg b/docs/diagrams/operations-control-plane.svg new file mode 100644 index 00000000..e8eca9fc --- /dev/null +++ b/docs/diagrams/operations-control-plane.svg @@ -0,0 +1,54 @@ + + + +Infrastructure stays under local controlPeople review scoped plans in Studio or the CLI. The Weave API stores their authority and results. A runner beside the destination polls for jobs and uses its own local credentials. + + + +FIREFLY WEAVE · OPERATIONS +Infrastructure stays under local control +One workspace owns the plan. One destination owns the credentials. + + +People use Studio or the CLI +Choose the destination and desired capacity. +Review the affected resources, image digests, and risks. + +Authenticated requests for the selected tenant, project, and environment + + +Weave records and authorizes the work +The API checks roles, revisions, approvals, and job leases. +PostgreSQL keeps the plan, progress, and safe receipts. +A browser action cannot send an arbitrary shell command. + +The runner polls over outgoing HTTPS and receives a leased job + +INSIDE THE DESTINATION’S OPERATIONAL BOUNDARY + + +The runner checks its own allowlist +Only configured resources, capabilities, and image repositories. +Provider credentials and private configuration remain here. + +Fixed provider operations with drift and readiness checks +Docker / Compose · Kubernetes / AKS / EKS / GKE · Azure Container Apps +An observation reports evidence; it does not provision a new cloud account. +Unknown outcome? Inspect and reconcile before applying another change. + diff --git a/docs/diagrams/operations-plan-lifecycle.svg b/docs/diagrams/operations-plan-lifecycle.svg new file mode 100644 index 00000000..eee4da47 --- /dev/null +++ b/docs/diagrams/operations-plan-lifecycle.svg @@ -0,0 +1,54 @@ + + + +From a reviewed plan to a verified resultObserve current state, review a plan tied to revisions and image digests, approve the exact plan, then apply it through a runner. Failed or ambiguous effects stay visible and are never blindly retried. + + + +FIREFLY WEAVE · OPERATIONS +From a reviewed plan to a verified result +Each stage answers a different question. Queued does not mean deployed. + + +1. Observe what is actually there +The runner reports images, replica counts, ownership, and readiness. +Check when it was observed and whether the snapshot is complete. + + + +2. Review and approve the exact change +The plan names the desired revision, image digests, and affected resources. +Changing intent or letting the plan expire requires a new review. + + + +3. Apply with a renewable job lease +The runner rechecks authority and provider state before writing. +Job progress remains in Weave if Studio closes. + + + +4. Read the final observation and receipt +Succeeded: the observed resources match the reviewed intent. +Then run a product check: container readiness is not workflow acceptance. + +If an external effect might still be running +Reconciliation required: inspect the provider, stop any remaining operation, +and obtain fresh settled evidence. Closing the incident does not retry it. + diff --git a/docs/diagrams/worker-capacity.svg b/docs/diagrams/worker-capacity.svg new file mode 100644 index 00000000..40ec07f0 --- /dev/null +++ b/docs/diagrams/worker-capacity.svg @@ -0,0 +1,47 @@ + + + +Capacity is more than a replica countContainer replicas, authenticated worker presence, and task slots are distinct. Pausing new task claims allows active leases to finish; scaling containers down can terminate them. + + + +FIREFLY WEAVE · OPERATIONS +Capacity is more than a replica count +Read each signal for what it proves, and keep its timestamp visible. + + +Container capacity +Desired replicas say what the deployment should run. +Observed ready replicas say what the provider reports now. + + +Worker presence +Recent means the worker has contacted Weave within its presence window. +Unknown or stale does not mean that all its task slots are free. + + +Task capacity +Active leases count work that is still owned by this worker. +Available slots are shown only for a recent, eligible worker. + +A safe sequence when reducing capacity +Pause new tasks → let active leases finish → review a scale-down plan. +Resume lets the worker claim again. Revocation removes its authority. +Provider scaling does not automatically drain a matching worker instance. + diff --git a/docs/guides/ai-workers.md b/docs/guides/ai-workers.md index b29d1a57..04758b8e 100644 --- a/docs/guides/ai-workers.md +++ b/docs/guides/ai-workers.md @@ -27,7 +27,7 @@ Use [Lumi](lumi.md) when *you*, the person using Studio, want an explanation or a proposed definition change. Lumi has separate configuration and does not execute workflow AI tasks. -The examples here use Weave **0.1.0a11** and the **0.1.3 Agentic worker package**, +The examples here use Weave **0.1.0a12** and the **0.1.4 Agentic worker package**, whose core dependency is pinned to that Weave version. The catalog references below retain their own `1.0.0` definition and task versions. diff --git a/docs/guides/desktop.md b/docs/guides/desktop.md index 28951607..3704888b 100644 --- a/docs/guides/desktop.md +++ b/docs/guides/desktop.md @@ -44,31 +44,31 @@ start them: ## Choose an installer Choose a matching installer **actually attached** to the -[v0.1.0a11 release](https://github.com/fireflyframework/firefly-weave/releases/tag/v0.1.0a11). -The desktop product version is `0.1.0-alpha.11`; its bundled Python host is -`0.1.0a11`. macOS bundles use **ad-hoc signing** for resource integrity; they are +[v0.1.0a12 release](https://github.com/fireflyframework/firefly-weave/releases/tag/v0.1.0a12). +The desktop product version is `0.1.0-alpha.12`; its bundled Python host is +`0.1.0a12`. macOS bundles use **ad-hoc signing** for resource integrity; they are **not Developer ID signed and not notarized**. Windows installers remain unsigned. Build and frozen-host smoke checks do not establish interactive GUI testing on every operating system. Your organization's installation policy still applies. | Computer | Target | Installer filename | | --- | --- | --- | -| macOS Apple silicon | `aarch64-apple-darwin` | `firefly-weave-studio-0.1.0-alpha.11-aarch64-apple-darwin.dmg` | -| macOS Intel | `x86_64-apple-darwin` | `firefly-weave-studio-0.1.0-alpha.11-x86_64-apple-darwin.dmg` | -| Windows x64 | `x86_64-pc-windows-msvc` | `firefly-weave-studio-0.1.0-alpha.11-x86_64-pc-windows-msvc.exe` or `.msi` | -| Linux x64 | `x86_64-unknown-linux-gnu` | `firefly-weave-studio-0.1.0-alpha.11-x86_64-unknown-linux-gnu.deb` or `.AppImage` | +| macOS Apple silicon | `aarch64-apple-darwin` | `firefly-weave-studio-0.1.0-alpha.12-aarch64-apple-darwin.dmg` | +| macOS Intel | `x86_64-apple-darwin` | `firefly-weave-studio-0.1.0-alpha.12-x86_64-apple-darwin.dmg` | +| Windows x64 | `x86_64-pc-windows-msvc` | `firefly-weave-studio-0.1.0-alpha.12-x86_64-pc-windows-msvc.exe` or `.msi` | +| Linux x64 | `x86_64-unknown-linux-gnu` | `firefly-weave-studio-0.1.0-alpha.12-x86_64-unknown-linux-gnu.deb` or `.AppImage` | Download the chosen installer and its target's checksum inventory, `weave-studio-TARGET-SHA256SUMS`, from that same release. The matrix describes filenames, not a promise that every asset is already available. If your matching -asset is absent, use the [browser installation](studio.md#install-the-alpha11-browser-application) +asset is absent, use the [browser installation](studio.md#install-the-alpha12-browser-application) instead. Alpha4 Python releases do not contain Studio or desktop installers. **Do not use the alpha5 macOS installers.** Their outer application bundle lacked its resource seal, causing macOS to report that the application was damaged. Alpha6 added explicit ad-hoc bundle signing and a strict verification gate for both -the built application and the application enclosed in its DMG, and alpha11 keeps -them. Download the matching alpha11 asset rather than attempting to bypass the +the built application and the application enclosed in its DMG, and alpha12 keeps +them. Download the matching alpha12 asset rather than attempting to bypass the alpha5 failure. Ad-hoc integrity does not establish publisher trust or Gatekeeper acceptance. @@ -110,9 +110,9 @@ This follows Tauri's [sidecar model](https://v2.tauri.app/develop/sidecar/) and ```sh # Inspect the downloaded installer without opening it. cd ~/Downloads - shasum -a 256 firefly-weave-studio-0.1.0-alpha.11-aarch64-apple-darwin.dmg + shasum -a 256 firefly-weave-studio-0.1.0-alpha.12-aarch64-apple-darwin.dmg # Show the expected digest for this exact filename. - grep 'firefly-weave-studio-0.1.0-alpha.11-aarch64-apple-darwin.dmg$' weave-studio-aarch64-apple-darwin-SHA256SUMS + grep 'firefly-weave-studio-0.1.0-alpha.12-aarch64-apple-darwin.dmg$' weave-studio-aarch64-apple-darwin-SHA256SUMS ``` Both displayed digests must match. For Intel, replace `aarch64-apple-darwin` @@ -149,9 +149,9 @@ matching `weave-studio-x86_64-pc-windows-msvc-SHA256SUMS` inventory first. ```powershell # Inspect the downloaded EXE before running it. Set-Location "$HOME\Downloads" -Get-FileHash .\firefly-weave-studio-0.1.0-alpha.11-x86_64-pc-windows-msvc.exe -Algorithm SHA256 +Get-FileHash .\firefly-weave-studio-0.1.0-alpha.12-x86_64-pc-windows-msvc.exe -Algorithm SHA256 # Compare the digest with the line for this exact filename. -Select-String -Path .\weave-studio-x86_64-pc-windows-msvc-SHA256SUMS -Pattern 'firefly-weave-studio-0.1.0-alpha.11-x86_64-pc-windows-msvc.exe$' +Select-String -Path .\weave-studio-x86_64-pc-windows-msvc-SHA256SUMS -Pattern 'firefly-weave-studio-0.1.0-alpha.12-x86_64-pc-windows-msvc.exe$' ``` Expected: `Get-FileHash` prints a `Hash` value, and `Select-String` prints the @@ -159,8 +159,8 @@ inventory line for the same file. For MSI, substitute `.msi` in both commands. Compare the two digests without regard to letter case; stop on a mismatch. Double-click the verified installer, complete its setup, then open **Firefly Weave Studio** from the Start menu. The installer may need network access to provision WebView2. Unsigned-installation warnings remain -subject to your organization's policy. The MSI's internal version is `0.1.11`, a -monotonic Windows Installer counter; the displayed product remains alpha11. +subject to your organization's policy. The MSI's internal version is `0.1.12`, a +monotonic Windows Installer counter; the displayed product remains alpha12. ## Install on Linux @@ -171,8 +171,8 @@ application where your distribution supports its runtime. Download the matching ```sh # Enter the download directory, verify this exact file, then install only on success. cd ~/Downloads && - grep 'firefly-weave-studio-0.1.0-alpha.11-x86_64-unknown-linux-gnu.deb$' weave-studio-x86_64-unknown-linux-gnu-SHA256SUMS | sha256sum --check && - sudo apt install ./firefly-weave-studio-0.1.0-alpha.11-x86_64-unknown-linux-gnu.deb + grep 'firefly-weave-studio-0.1.0-alpha.12-x86_64-unknown-linux-gnu.deb$' weave-studio-x86_64-unknown-linux-gnu-SHA256SUMS | sha256sum --check && + sudo apt install ./firefly-weave-studio-0.1.0-alpha.12-x86_64-unknown-linux-gnu.deb ``` Expected: `sha256sum` prints the file name followed by `OK`, and only then does @@ -182,9 +182,9 @@ application launcher. For AppImage: ```sh # Verify this exact portable file; a failed or missing checksum prevents launch. cd ~/Downloads && - grep 'firefly-weave-studio-0.1.0-alpha.11-x86_64-unknown-linux-gnu.AppImage$' weave-studio-x86_64-unknown-linux-gnu-SHA256SUMS | sha256sum --check && - chmod +x firefly-weave-studio-0.1.0-alpha.11-x86_64-unknown-linux-gnu.AppImage && - ./firefly-weave-studio-0.1.0-alpha.11-x86_64-unknown-linux-gnu.AppImage + grep 'firefly-weave-studio-0.1.0-alpha.12-x86_64-unknown-linux-gnu.AppImage$' weave-studio-x86_64-unknown-linux-gnu-SHA256SUMS | sha256sum --check && + chmod +x firefly-weave-studio-0.1.0-alpha.12-x86_64-unknown-linux-gnu.AppImage && + ./firefly-weave-studio-0.1.0-alpha.12-x86_64-unknown-linux-gnu.AppImage ``` Linux requires the distribution's WebKitGTK runtime, and AppImage execution may @@ -479,7 +479,7 @@ with a text editor. **Untested platform cases.** On macOS 11.0 to 11.2 the webview lacks the download support the app relies on, so an export may save nothing even though the confirmation appears; use macOS 11.3 or later, or the -[browser installation](studio.md#install-the-alpha11-browser-application). macOS +[browser installation](studio.md#install-the-alpha12-browser-application). macOS may ask whether Firefly Weave Studio can use your Downloads folder the first time you export. On Windows, WebView2 may ask whether to allow the second of the two files. @@ -633,8 +633,8 @@ source checkout, you can run that same gate on an explicitly downloaded DMG: ```sh # Verify resource integrity without launching the application. -# Replace the path with the actual alpha11 installer you downloaded. -.venv/bin/python desktop/scripts/verify_macos_bundle.py /path/to/firefly-weave-studio-0.1.0-alpha.11-aarch64-apple-darwin.dmg +# Replace the path with the actual alpha12 installer you downloaded. +.venv/bin/python desktop/scripts/verify_macos_bundle.py /path/to/firefly-weave-studio-0.1.0-alpha.12-aarch64-apple-darwin.dmg ``` The build also smoke-tests the frozen host after Tauri signs it. Hardened runtime diff --git a/docs/guides/learning-path.md b/docs/guides/learning-path.md index 84ff9357..0354c10f 100644 --- a/docs/guides/learning-path.md +++ b/docs/guides/learning-path.md @@ -65,7 +65,7 @@ have what you need. One person often has several roles, especially on a laptop. [Who does what](roles-and-lifecycle.md) explains the roles and the permissions each one needs. -**Every path starts with the v0.1.0a11 release.** [Install the CLI](../installation.md) +**Every path starts with the v0.1.0a12 release.** [Install the CLI](../installation.md) first. The release includes everything these paths use: the Studio editor and its API action builder, saved platforms (`weave auth setup`), REST calls without code, and `weave platform user`. @@ -75,7 +75,7 @@ code, and `weave platform user`. You draw processes, add approvals and API calls, and run them. **What you need:** the CLI and -[Studio's browser application](studio.md#install-the-alpha11-browser-application), +[Studio's browser application](studio.md#install-the-alpha12-browser-application), or the [desktop app](desktop.md), and, from step 4 on, a platform: your team's server address and an account, or a [local platform](local-platform.md), which needs Docker. @@ -84,7 +84,7 @@ needs Docker. | --- | --- | --- | | 1 | [Concepts](../concepts.md), including [Coming from BPM/BPMN](../concepts.md#coming-from-bpmbpmn) | The vocabulary: workflow, step, activation, run, and how BPMN ideas map to them | | 2 | [Your first workflow](../quickstart.md) | A workflow validated and simulated on your computer, with no platform | -| 3 | [Install Studio](studio.md#install-the-alpha11-browser-application), then [draw your first workflow](studio.md#try-it-draw-your-first-workflow) | A process drawn in the visual editor and checked as you edit | +| 3 | [Install Studio](studio.md#install-the-alpha12-browser-application), then [draw your first workflow](studio.md#try-it-draw-your-first-workflow) | A process drawn in the visual editor and checked as you edit | | 4 | [Start a local platform](local-platform.md), its steps 1 to 8, or [connect Studio](studio.md#connect-to-a-platform) to your team's platform | A platform where you can publish, run, and approve; on the local platform, create your person with every role, as its step 5 explains | | 5 | [Step reference](studio-step-reference.md) | The right properties for each kind of step | | 6 | [Human tasks](human-tasks.md) | An approval assigned to real reviewers; a task manager creates the reviewer binding with the CLI | diff --git a/docs/guides/local-platform.md b/docs/guides/local-platform.md index 268eaf06..6ca6f628 100644 --- a/docs/guides/local-platform.md +++ b/docs/guides/local-platform.md @@ -79,8 +79,8 @@ decides which features the platform has. With the alpha7 CLI from directory, and keep any checkout you already use for development untouched: ```sh -# Download the alpha11 release's operator files into a new directory. -git clone --branch v0.1.0a11 --single-branch https://github.com/fireflyframework/firefly-weave.git firefly-weave-local +# Download the alpha12 release's operator files into a new directory. +git clone --branch v0.1.0a12 --single-branch https://github.com/fireflyframework/firefly-weave.git firefly-weave-local # Run the following steps from that matching checkout. cd firefly-weave-local ``` @@ -345,7 +345,7 @@ Studio opens already connected to the platform you saved in step 6. **First install Studio's browser application** if you have not yet: download the matching alpha7 bundle and install it as in -[Install the alpha11 browser application](studio.md#install-the-alpha11-browser-application). +[Install the alpha11 browser application](studio.md#install-the-alpha12-browser-application). Then start Studio: ```sh diff --git a/docs/guides/lumi.md b/docs/guides/lumi.md index c9e417e3..7355fb04 100644 --- a/docs/guides/lumi.md +++ b/docs/guides/lumi.md @@ -29,7 +29,7 @@ Lumi has its own configuration per environment. It never reads a workflow's `llmProfiles`, and changing an LLM step does not change the assistant. The Studio **AI setup** examples and named provider-connection selection -shown here use Weave **0.1.0a11**. Use the matching **0.1.3 Agentic worker +shown here use Weave **0.1.0a12**. Use the matching **0.1.4 Agentic worker package** for the independently deployed Lumi gateway. ## Follow a question through review diff --git a/docs/guides/standalone.md b/docs/guides/standalone.md index c8f29f38..c6cd79d5 100644 --- a/docs/guides/standalone.md +++ b/docs/guides/standalone.md @@ -96,18 +96,18 @@ checkout. If you do not already have it, run: ```sh # Clone the alpha7 release that matches the CLI, then install its locked dependencies. -git clone --branch v0.1.0a11 --single-branch \ +git clone --branch v0.1.0a12 --single-branch \ https://github.com/fireflyframework/firefly-weave.git cd firefly-weave uv sync --locked --python 3.12 ``` -The clone selects the same **v0.1.0a11** release as the CLI installation guide. +The clone selects the same **v0.1.0a12** release as the CLI installation guide. A detached-HEAD message is expected when Git opens a release tag; this tutorial does not require creating a branch or editing application source. If you already have a checkout, enter its root and run `git describe --tags --exact-match`. -For this released walkthrough, the result must be `v0.1.0a11`. If the checkout has +For this released walkthrough, the result must be `v0.1.0a12`. If the checkout has another version or local development work, preserve it and clone the release into a separate directory by adding a new directory name to the clone command above. Contributors intentionally using unreleased source should use that checkout's diff --git a/docs/guides/studio-step-reference.md b/docs/guides/studio-step-reference.md index d44fc290..8fc5bae3 100644 --- a/docs/guides/studio-step-reference.md +++ b/docs/guides/studio-step-reference.md @@ -31,13 +31,13 @@ Studio writes the same versioned definition language that you can edit as YAML or build with the CLI and the Python SDK, so every choice here is visible in the **Source** tab. -**This page describes the Studio 0.1.0a11 editor.** The step kinds and native +**This page describes the Studio 0.1.0a12 editor.** The step kinds and native human tasks are also part of alpha6, but several controls described here, such as the canvas step picker, the searchable action picker, the **Value** / typed input rows with **Use data** and **Calculate…**, the schema designer, the API action builder, and the simulation setup and panel, are new in 0.1.0a7. An alpha6 or earlier browser bundle does not have them; to see the same screens, -[install the alpha11 browser application](studio.md#install-the-alpha11-browser-application). +[install the alpha12 browser application](studio.md#install-the-alpha12-browser-application). **How to use this page:** diff --git a/docs/guides/studio.md b/docs/guides/studio.md index 0872d3a8..faac7c3a 100644 --- a/docs/guides/studio.md +++ b/docs/guides/studio.md @@ -38,10 +38,10 @@ computer at `127.0.0.1`. That host keeps your saved platforms, which it shares with the `weave` command line, and keeps your sign-in in the operating system's credential store. The page never receives your tokens. -**This page describes Studio 0.1.0a11.** An alpha6 or earlier browser bundle +**This page describes Studio 0.1.0a12.** An alpha6 or earlier browser bundle shows an earlier connection assistant, which imports a login configuration file, and lacks the editor features and API actions described here. To follow this -page, [install the alpha11 browser application](#install-the-alpha11-browser-application). +page, [install the alpha12 browser application](#install-the-alpha12-browser-application). ![Studio and runtime boundaries](../diagrams/studio-and-runtime.svg) @@ -55,7 +55,7 @@ happens on the platform and keeps running after you close Studio. | Your goal | What must be running | Start here | | --- | --- | --- | | Open Studio as a native desktop app | A matching desktop build for your operating system | [Install desktop](desktop.md) | -| Draw, import, or validate a workflow on your computer | Studio's local host; no account | [Install Studio](#install-the-alpha11-browser-application), then [work locally](#work-locally-without-signing-in) | +| Draw, import, or validate a workflow on your computer | Studio's local host; no account | [Install Studio](#install-the-alpha12-browser-application), then [work locally](#work-locally-without-signing-in) | | Learn the editor with a small example | Studio's local host | [Try it: draw your first workflow](#try-it-draw-your-first-workflow) | | Save and run workflows on your laptop | Studio plus a separate local Weave platform | [Start the platform](local-platform.md), then [connect Studio](#connect-to-a-platform) | | Work with your team's platform | Studio on your computer and the server address from your administrator | [Connect to a platform](#connect-to-a-platform) | @@ -71,45 +71,45 @@ computer. You can create, import, edit, and validate workflow definitions. Connect when you want to save them to a shared platform, run processes, or handle tasks. This status does not mean your computer has lost its internet connection. -## Install the alpha11 browser application +## Install the alpha12 browser application Alpha7 includes the Studio host. Its browser assets are an optional, matching release bundle; the Python wheel does not embed the Angular application. Alpha4 does not include Studio. Download all files from the same -[v0.1.0a11 release](https://github.com/fireflyframework/firefly-weave/releases/tag/v0.1.0a11). +[v0.1.0a12 release](https://github.com/fireflyframework/firefly-weave/releases/tag/v0.1.0a12). The CLI installer does not automatically fetch or install the Studio ZIP. -Use Python 3.12 or newer. The alpha11 CLI installer includes the Studio host +Use Python 3.12 or newer. The alpha12 CLI installer includes the Studio host and authentication dependencies, but downloads no browser ZIP automatically. These Bash/Zsh commands need neither Node nor a source checkout: ```sh # Install the pinned CLI into its isolated installation directory. # Review the installer first using the download-and-inspect alternative in Installation. -curl --fail --location https://github.com/fireflyframework/firefly-weave/releases/download/v0.1.0a11/install.sh \ - | sh -s -- --version v0.1.0a11 +curl --fail --location https://github.com/fireflyframework/firefly-weave/releases/download/v0.1.0a12/install.sh \ + | sh -s -- --version v0.1.0a12 # Make the installed command available in this terminal. export PATH="$HOME/.local/bin:$PATH" # Check that the host version matches the browser bundle you will install. weave --version # Keep the downloaded optional browser bundle and its digest together. -mkdir weave-studio-alpha11 -cd weave-studio-alpha11 -curl --fail --location --remote-name https://github.com/fireflyframework/firefly-weave/releases/download/v0.1.0a11/firefly-weave-studio-0.1.0a11.zip -curl --fail --location --remote-name https://github.com/fireflyframework/firefly-weave/releases/download/v0.1.0a11/firefly-weave-studio-0.1.0a11.zip.sha256 +mkdir weave-studio-alpha12 +cd weave-studio-alpha12 +curl --fail --location --remote-name https://github.com/fireflyframework/firefly-weave/releases/download/v0.1.0a12/firefly-weave-studio-0.1.0a12.zip +curl --fail --location --remote-name https://github.com/fireflyframework/firefly-weave/releases/download/v0.1.0a12/firefly-weave-studio-0.1.0a12.zip.sha256 # Verify the bundle on macOS. Stop if the check fails. -shasum -a 256 --check firefly-weave-studio-0.1.0a11.zip.sha256 +shasum -a 256 --check firefly-weave-studio-0.1.0a12.zip.sha256 # On Linux, use sha256sum --check in place of shasum -a 256 --check. # Install only the bundle whose version and verified digest match this host. -weave studio install --bundle firefly-weave-studio-0.1.0a11.zip \ - --sha256 "$(awk '{print $1}' firefly-weave-studio-0.1.0a11.zip.sha256)" +weave studio install --bundle firefly-weave-studio-0.1.0a12.zip \ + --sha256 "$(awk '{print $1}' firefly-weave-studio-0.1.0a12.zip.sha256)" # Start the local host without opening a browser automatically. weave studio --no-browser ``` -Expected: `weave --version` prints `Firefly Weave 0.1.0a11`, the checksum line +Expected: `weave --version` prints `Firefly Weave 0.1.0a12`, the checksum line ends with `OK`, and the install prints `Studio installed at PATH. Start it with: weave studio`. The host then prints `Firefly Weave Studio · Local authoring (no platform selected)`, or `Firefly Weave Studio · Platform: NAME` when a saved @@ -131,7 +131,7 @@ or the [remote deployment guide](../operations/remote-deployment.md). **This route is for contributors,** and for trying changes made after the latest release; the release bundle above already has everything this page -describes. To reproduce the alpha11 release exactly, check out `v0.1.0a11` +describes. To reproduce the alpha12 release exactly, check out `v0.1.0a12` instead; development `main` can differ from released assets. From the repository root, install the development dependencies first. Node 24 @@ -167,7 +167,7 @@ while you use Studio. **Already installed a release browser bundle?** Installed bundles are kept per version, and a checkout can report the same version as the latest release, for -example `0.1.0a11`. A plain `weave studio` then keeps serving the installed +example `0.1.0a12`. A plain `weave studio` then keeps serving the installed release screens, and `weave studio install` keeps that bundle instead of replacing it. Always start a source build with `--assets studio/dist/studio/browser`. diff --git a/docs/installation.md b/docs/installation.md index 857a9324..93ad9fa4 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -27,18 +27,18 @@ The installer puts the CLI in its own Python environment, so your applications' packages are never affected. It includes local authoring, the API client with sign-in support, OpenAPI import, and the host that runs Studio. Studio's browser application is a separate download; see the -[Studio guide](guides/studio.md#install-the-alpha11-browser-application). +[Studio guide](guides/studio.md#install-the-alpha12-browser-application). ## Choose the installation path -**Recommended:** install the pinned **v0.1.0a11 alpha** below. You do not need Git, +**Recommended:** install the pinned **v0.1.0a12 alpha** below. You do not need Git, a source checkout, Docker, or `sudo`. An alpha is a preview release: use it to evaluate Weave, and check the [current limits](capabilities.md). | Your situation | Start here | | --- | --- | | You want to write, check, and simulate workflows, connect to your team's platform by its address (`weave auth setup`), or call REST APIs without code | [Install a release](#install-a-release), then [check the result](#check-the-result) | -| You want the visual editor in your browser | [Install Studio](guides/studio.md#install-the-alpha11-browser-application) | +| You want the visual editor in your browser | [Install Studio](guides/studio.md#install-the-alpha12-browser-application) | | You want a native desktop app | [Desktop installation](guides/desktop.md) | | You contribute code, or want to try changes made after the release | [Install the current source](#install-the-current-source) | | You already have the CLI | [Upgrade or select another version](#installation-locations-and-upgrades) | @@ -89,8 +89,8 @@ explicitly: # Stop if downloading the installer fails. set -o pipefail curl --proto '=https' --tlsv1.2 -fsSL \ - https://github.com/fireflyframework/firefly-weave/releases/download/v0.1.0a11/install.sh \ - | sh -s -- --version v0.1.0a11 + https://github.com/fireflyframework/firefly-weave/releases/download/v0.1.0a12/install.sh \ + | sh -s -- --version v0.1.0a12 ) ``` @@ -100,7 +100,7 @@ environment; and tests the new command before it makes it available. The first installation can take several minutes. No services start and no cloud resources are created. -Expected, at the end: `Installed Firefly Weave 0.1.0a11: PATH`, then `Run: PATH +Expected, at the end: `Installed Firefly Weave 0.1.0a12: PATH`, then `Run: PATH --help`, where `PATH` is the new command (by default `~/.local/bin/weave`). When that directory is not on your `PATH`, the installer also prints the `export` line to add. The last line says where earlier environments are kept for @@ -112,7 +112,7 @@ instead: ```sh # Download the installer to a file instead of running it directly. curl --proto '=https' --tlsv1.2 -fsSL \ - https://github.com/fireflyframework/firefly-weave/releases/download/v0.1.0a11/install.sh \ + https://github.com/fireflyframework/firefly-weave/releases/download/v0.1.0a12/install.sh \ -o weave-install.sh ``` @@ -121,7 +121,7 @@ editor, then run it: ```sh # Install the same pinned release with the reviewed file. -sh weave-install.sh --version v0.1.0a11 +sh weave-install.sh --version v0.1.0a12 ``` Expected: the same output as above. Keep the file if you want to reuse it for @@ -147,7 +147,7 @@ weave version --output json weave docs platform ``` -Expected: `Firefly Weave 0.1.0a11`; the help with its command groups and +Expected: `Firefly Weave 0.1.0a12`; the help with its command groups and examples; one JSON object with `version`, `apiVersion`, and `irVersion`; and the address of the platform guide. The Firefly banner appears only in the main help; it never appears in command results or JSON output. @@ -183,7 +183,7 @@ remove or move the Python interpreter the environment was built with. ### Select a version or release channel -The explicit `--version v0.1.0a11` makes an installation repeatable. To upgrade, +The explicit `--version v0.1.0a12` makes an installation repeatable. To upgrade, use the installer URL and `--version` value of the new release shown on [GitHub Releases](https://github.com/fireflyframework/firefly-weave/releases). Tags start with `v`; `weave --version` shows the package version without it. @@ -192,7 +192,7 @@ With a downloaded `weave-install.sh`, you can select a channel instead: | Command | What it installs | | --- | --- | -| `sh weave-install.sh --version v0.1.0a11` | Exactly this documented alpha | +| `sh weave-install.sh --version v0.1.0a12` | Exactly this documented alpha | | `sh weave-install.sh --prerelease` | The most recently published release, including alpha previews | | `sh weave-install.sh` | The latest stable release only | @@ -236,7 +236,7 @@ removal option: # Stop if downloading the installer fails. set -o pipefail curl --proto '=https' --tlsv1.2 -fsSL \ - https://github.com/fireflyframework/firefly-weave/releases/download/v0.1.0a11/install.sh \ + https://github.com/fireflyframework/firefly-weave/releases/download/v0.1.0a12/install.sh \ | sh -s -- --uninstall ) ``` @@ -257,7 +257,7 @@ delete them yourself if you want no trace. | What you see | Why | What to do | | --- | --- | --- | | A Python version or `venv` error | The selected Python is older than 3.12, or lacks `venv` | Set `WEAVE_INSTALL_PYTHON` to Python 3.12 or newer; install your distribution's `venv` package | -| No installable release was found | You asked for the latest stable release, and only alphas exist | Run the pinned `v0.1.0a11` command above, or add `--prerelease` | +| No installable release was found | You asked for the latest stable release, and only alphas exist | Run the pinned `v0.1.0a12` command above, or add `--prerelease` | | Missing installer assets | The selected release is older than this installer | Choose `v0.1.0a2` or later; never mix files from different releases | | A checksum mismatch | A downloaded file is not the published one | Stop, and download the complete release again from the same trusted source | | The installer refuses to replace `weave` | Another program already uses that command path | Choose a different `--bin-dir`, or inspect the old command before replacing it yourself | @@ -268,7 +268,7 @@ delete them yourself if you want no trace. This route installs **unreleased development source from `main`**, for contributors and for trying changes made after the latest release. You do not -need it for any feature this documentation describes: the v0.1.0a11 release +need it for any feature this documentation describes: the v0.1.0a12 release includes them. Behavior on `main` can change before the next release. Follow the documentation of the same checkout. diff --git a/docs/operations/cluster-management.md b/docs/operations/cluster-management.md new file mode 100644 index 00000000..71f786bd --- /dev/null +++ b/docs/operations/cluster-management.md @@ -0,0 +1,264 @@ + + +# Manage container deployments + +Operations connects an existing container destination to Weave, records what +is there, and sends reviewed changes to a runner beside that destination. +It does not create a cloud subscription, Kubernetes cluster, network, or +identity provider. Provision those foundations with your existing infrastructure +tools before registering a target. + +![How people, the API, and the destination runner work together](../diagrams/operations-control-plane.svg) + +This guide follows the same sequence in Studio and the CLI: + +```mermaid +flowchart LR + A[Register target] --> B[Configure outbound runner] + B --> C[Observe destination] + C --> D[Record desired deployment] + D --> E[Generate and review plan] + E --> F[Approve exact digest] + F --> G[Apply once] + G --> H[Inspect receipt and final observation] +``` + +## 1. Prepare the destination and identities + +Use a running Weave API, a saved CLI or Studio platform connection, and an +existing Docker Compose project, Kubernetes namespace, or supported cloud +container destination. The runner host needs the corresponding provider CLI, +network access to the destination and Weave API, and locally provisioned +provider credentials. The browser and Weave database do not receive those +credentials. + +An administrator creates a dedicated **application** principal and links its +verified machine identity using the existing +[identity and membership commands](../guides/people-and-access.md). +Do not use a worker principal or copy a person's Studio token. Record this +application's ID when registering the target. After the target has an ID, +grant `deployment_runner` in its exact environment, with `resources` containing +only that target ID. The runner must authenticate as the same application. + +Give people only the roles they need: + +| Person or application | Role | +| --- | --- | +| Inventory reader | `deployment_reader` | +| Target registrar and desired-deployment editor | `deployment_planner` | +| Plan reviewer | `deployment_approver` | +| Operator applying or cancelling a reviewed change | `deployment_operator` | +| Dedicated runner application | `deployment_runner`, restricted to its target | + +Target creation requires an environment-level planner grant because its target +ID does not exist yet. Subsequent work can use target-resource grants. The +approver and operator may be separate people; reconciliation requires both +roles. Signing in, being a workflow developer, or seeing a registered worker +does not grant deployment authority. + +## 2. Register an existing target + +In Studio, open **Operations → Register target**. Choose the adapter, the +existing destination's pinned identity and boundary, the dedicated runner +principal, and capabilities that its local policy will support. Registration +records metadata; it does not verify provider access or deploy anything. + +The CLI equivalent uses a `TargetRequest` JSON file: + +```sh +weave operations targets create --request target.json \ + --idempotency-key register-target-01 +``` + +Example `target.json` for Docker Compose; replace the illustrative daemon +identity and principal ID with your actual non-secret identifiers: + +```json +{ + "name": "team-runtime", + "adapter": "docker-compose", + "external_identity": "pinned-docker-daemon-id", + "boundary": "team-weave", + "runner_principal_id": "00000000-0000-0000-0000-000000000001", + "capabilities": ["observe", "deploy", "update", "scale_workers"] +} +``` + +Save the returned target ID. Grant the runner role now, with that ID in the +binding's resource list. Configure only capabilities actually supported by the +chosen adapter; a backend enum is not proof a destination supports an action. + +## 3. Configure and start the outbound runner + +On the runner host, use the same package version as the platform: + +```sh +weave operations runner setup --output /absolute/private/runner.json +weave operations runner check --config /absolute/private/runner.json +weave operations runner run --config /absolute/private/runner.json +``` + +Setup writes a new private local file. Supply the exact workspace and target +IDs, pinned provider identity, explicit provider context, allowed component +names and image repositories, and the path to the dedicated machine's OAuth +configuration. The OAuth file and cloud credentials remain local. + +**Check** validates local policy and reads the destination; it does not register +the runner or apply a job. **Run** registers and polls for authorized work. +Operate it under your existing service supervisor. A runner's recent contact +shows presence, not container health or worker task capacity. + +## 4. Observe, then record desired components + +Open the target in Studio and select **Observe target**. Wait for its durable +operation to succeed. Inspect the dated observation, its completeness, and +resource ownership before choosing **Record desired deployment**. + +The CLI starts the same read-only job using an `ObserveRequest`: + +```json +{"target_id": "00000000-0000-0000-0000-000000000002"} +``` + +```sh +weave operations observations create --request observe.json \ + --idempotency-key observe-target-01 +weave operations jobs read "$JOB_ID" +weave operations observations read "$OBSERVATION_ID" +weave operations deployments create --request deployment.json \ + --idempotency-key record-deployment-01 +``` + +Use the actual returned IDs. `deployment.json` follows the canonical +`DeploymentRequest` schema: target ID, name, declared `imported` or `managed` +ownership, and bounded components. Each component has its role, an immutable +`repository@sha256:…` image, local configuration alias, replica count, CPU and +memory limits. Worker components additionally require an admitted worker +release ID. Configuration aliases must match the runner's local allowlist; +they are not environment-variable or secret payloads. + +An API/scheduler component must have exactly one replica. Scaling workers to +zero stops containers; it does not perform graceful worker draining. Use the +separate worker-instance **Drain** control first when active work must settle. +Imported ownership is declared intent requiring explicit review before changes, +not an automatic ownership transition after applying a plan. + +## 5. Generate and review the immutable plan + +In the desired deployment, select **Create plan**, choose an intent and a fresh +complete observation, then **Generate reviewable plan**. Read every component, +image digest, replica count, expected external version, risk, and expiry. +Cost and duration estimates are not provided. + +CLI `plan.json` uses the returned desired-deployment revision and observation: + +```json +{ + "deployment_id": "00000000-0000-0000-0000-000000000003", + "deployment_revision": 1, + "observation_id": "00000000-0000-0000-0000-000000000004", + "intent": "update", + "ttl_seconds": 300 +} +``` + +```sh +weave operations plans create --request plan.json --idempotency-key plan-01 +weave operations plans read "$PLAN_ID" +``` + +Choose `deploy` only when the adapter supports deployment of its trusted local +template. `update` changes supported existing components; `scale_workers` +requires the same observed worker image. It cannot silently become an image +update. Desired-deployment edits use `deployments update` with the current +`--revision`; conflicts require reloading and reviewing the current intent. + +## 6. Approve and apply that exact plan + +The reviewer chooses **Approve reviewed plan**. The operator chooses **Apply +approved plan**. Approval stores the exact digest. Applying rechecks revisions, +freshness, capabilities, and the approver's current role. A saved approval can +therefore become unusable after a revocation or change. + +For the CLI, create `reviewed-digest.json` containing only `digest` with the +exact digest you reviewed: + +```sh +weave operations plans approve "$PLAN_ID" --request reviewed-digest.json +weave operations plans approval "$PLAN_ID" +weave operations plans apply "$PLAN_ID" --request reviewed-digest.json \ + --idempotency-key apply-reviewed-plan-01 +weave operations jobs read "$JOB_ID" +``` + +Retain each idempotency key for retries of that same request. A different key +does not permit applying one plan twice. Only one job is active per target. +The runner renews a fenced lease and checks authority before external writes. + +## 7. Verify the receipt or reconcile uncertainty + +A successful apply has a final complete, settled observation matching the +reviewed images, replicas, readiness, and managed resources. Read the job's +receipt and observation, rather than treating registration or an accepted job +as proof of deployment success. + +If a lease expires or execution is interrupted after changes may have begun, +the job becomes **reconciliation required**. Cancelling is not rollback. +New mutations stay blocked, while a read-only observation can inspect the +destination. First establish that the external operation has stopped; then +request a new complete, settled observation. An operator holding both apply +and approve authority can close the ambiguous job with its exact original +plan digest and the new observation ID: + +```sh +weave operations jobs reconcile "$JOB_ID" --request reconciliation.json +``` + +`reconciliation.json` contains `plan_digest`, `observation_id`, and +`external_operation_stopped: true`. This acknowledgement does not reverse +changes or trigger another apply. Review the new state and create a new plan +if further work is needed. + +The [API inventory](../reference/api.md#deployment-operations-authority) lists +the same authority checks. Python applications use the canonical models with +[WeaveClient.invoke](../reference/sdk.md#deployment-operations-through-the-sdk). + +## Current bounds + +Each environment permits up to 100 targets, desired deployments, and runner +registrations; 1,000 observations, plans, approvals, and jobs per collection; +and 10,000 runner report records. Requests exceeding a collection limit return +an explicit capacity error. This first interface has no deletion or retention +command for those records. Restarting a runner with the same application, +target, adapter version, and capabilities reuses its non-revoked logical +registration. Revoked registrations are never revived. Keep the collection +limits in view when operating a long-lived environment. + +Observations expire after 15 minutes. Plans last 30–900 seconds, bounded by +their observation's expiry. Jobs have a 15-minute deadline and renewable +60-second leases. A running operation may outlive its original observation's +freshness once external changes begin, but never its job deadline or current +lease authority. + +## Read worker capacity correctly + +![Replicas, worker presence, and task slots are different signals](../diagrams/worker-capacity.svg) + +See [Install an Operations runner](operations-runner.md) for local configuration, +provider setup, capability limits, and recovery after an uncertain operation. diff --git a/docs/operations/operations-runner.md b/docs/operations/operations-runner.md new file mode 100644 index 00000000..b5cbb8a8 --- /dev/null +++ b/docs/operations/operations-runner.md @@ -0,0 +1,279 @@ + + +# Install an Operations runner + +An Operations runner is a small process beside a container destination. It asks +Weave for authorized work over an outgoing connection, checks its own local +policy, and calls the destination's existing tools. Studio never runs provider +commands or receives provider credentials. + +Start with [Manage container deployments](cluster-management.md) to create the +target and grant its dedicated application identity access. This page explains +what to install on the runner host and how to configure each adapter. + +![The runner keeps infrastructure authority beside the destination](../diagrams/operations-control-plane.svg) + +## 1. Choose where the runner will live + +Use a dedicated Linux or macOS host, VM, or container with network access to both +the Weave API and the destination. The runner uses POSIX process ownership and +file locks. Windows users can run it in a Linux VM or container; Studio and the +ordinary CLI remain available on Windows. + +Install the **same Weave package version** as the API, including its client extra. +Follow the [CLI installation guide](../installation.md) first. +The `client` extra supplies the HTTP and OAuth client used by the runner. +Install and authenticate the appropriate provider tool on that host: + +| Destination | Local tool | What the pinned identity means | +| --- | --- | --- | +| Docker Compose, including on-premises hosts | Docker CLI with Compose v2 | Docker daemon ID; boundary is a Compose project name | +| Kubernetes, including existing AKS, EKS, and GKE clusters | `kubectl` | Namespace UID; boundary is that namespace | +| Azure Container Apps | Azure CLI with Container Apps support | Managed environment ARM resource ID; boundary is a resource group | + +The cloud's name does not change the Kubernetes adapter. A namespace in an +existing EKS or GKE cluster uses the same contracts and checks as another +Kubernetes namespace. Creating a cloud account, cluster, network, or database +remains an infrastructure provisioning step. + +For Docker, use an explicitly named context. Access to a Docker socket grants +substantial host authority: isolate the runner and keep its provider permissions +within the destination it operates. For Kubernetes, grant only the namespace and +resource permissions needed by the allowed deployments. API-component updates +also require reading that namespace's horizontal pod autoscalers so the runner +can reject a conflicting scaling policy. + +## 2. Copy the destination's actual identity + +Run the relevant read-only command on the runner host. Replace the illustrative +names with your own context, namespace, subscription, and environment. + +```sh +# Record the daemon identity, not just the context's display name. +docker --context team-runtime info --format '{{.ID}}' + +# A namespace UID changes when someone deletes and recreates the namespace. +# That change must invalidate the old runner binding. +kubectl --context team-cluster get namespace weave \ + -o jsonpath='{.metadata.uid}' + +# Register this environment resource ID as the ACA target's external identity. +az containerapp env show --subscription YOUR_SUBSCRIPTION_UUID \ + --resource-group team-weave --name team-environment --query id --output tsv +``` + +Register the target in **Studio → Operations → Register target** using that exact +identity. Keep the returned target ID for the local setup wizard. Target +registration records intent; an observation from the runner verifies access. + +## 3. Prepare a dedicated Weave machine identity + +The runner's Weave identity and provider identity serve different purposes. +The first authorizes jobs in one Weave workspace; the second authorizes the +local Docker, Kubernetes, or Azure command. Neither is a person's Studio login. + +Create an application principal and link the verified machine identity using +[People and access](../guides/people-and-access.md). Grant `deployment_runner` +with the target ID in its resource list. Use your identity provider's OAuth +client-credentials endpoint; this is not tied to Keycloak. + +A private OAuth configuration file looks like this: + +```json +{ + "token_endpoint": "https://identity.example.com/oauth2/token", + "client_id": "weave-operations-runner", + "scope": "weave.operations", + "client_secret_file": "/run/secrets/weave-runner-client-secret" +} +``` + +Use the actual scope accepted by your identity provider and Weave audience +configuration. Mount the secret at the referenced path. Do not put its value +in this file, a workflow, an image, or a Studio form. The client reads the secret +again when acquiring a new token and renews tokens before expiry. + +For an operator-managed short-lived token, use `token_file` instead of +`oauth_file` in the runner configuration. It is read again for every API request, +so your credential agent can rotate it. Supply exactly one method. The runner +does not fall back to an interactive profile or keychain. + +## 4. Run the setup wizard + +```sh +# Create a new private file. Setup never overwrites an existing configuration. +weave operations runner setup --output /opt/weave/private/runner.json + +# Verify the local policy and read the provider, without claiming any jobs. +weave operations runner check --config /opt/weave/private/runner.json + +# Start outgoing polling. Only authorized jobs matching this policy can run. +weave operations runner run --config /opt/weave/private/runner.json +``` + +The wizard asks for four groups of information: the Weave workspace, the pinned +destination, allowed components and image repositories, and machine credentials. +It starts with observation only. Enable mutation capabilities only when that +host is ready to manage the specified resources. + +Run the last command under your service supervisor or container restart policy. +Use a stable configuration and mount the required local files. Keep the process +in the foreground; the supervisor owns its lifecycle. `--once` is available for +diagnostics: it registers, claims at most one job, and exits. It does not wait +for a future job to arrive. + +## 5. Understand the local policy + +The API's approved plan is checked again against the runner's private policy. +A target grant alone cannot expand that local allowlist. This Kubernetes example +allows only one existing worker deployment: + +```json +{ + "base_url": "https://weave.example.com", + "scope": { + "tenant_id": "00000000-0000-0000-0000-000000000001", + "project_id": "00000000-0000-0000-0000-000000000002", + "environment_id": "00000000-0000-0000-0000-000000000003" + }, + "oauth_file": "/opt/weave/private/oauth.json", + "poll_seconds": 2, + "destination": { + "target_id": "00000000-0000-0000-0000-000000000004", + "adapter": "kubernetes", + "external_identity": "YOUR_NAMESPACE_UID", + "boundary": "weave", + "executable": "/usr/local/bin/kubectl", + "context": "team-cluster", + "capabilities": ["observe", "update", "scale_workers"], + "components": [ + { + "name": "integration-worker", + "kind": "worker", + "container": "worker", + "configuration": "production", + "max_replicas": 10, + "max_cpu_millis": 4000, + "max_memory_mib": 8192 + } + ], + "image_repositories": ["registry.example.com/weave/integration-worker"] + } +} +``` + +Replace every illustrative ID. Image repositories are exact repository names, +not wildcards; plans must use immutable `@sha256:` images. A component's +`configuration` is an operator-owned alias, not a file path from the browser. +The runner does not accept shell commands, arbitrary Kubernetes manifests, or +credential values from a plan. + +The default local ceilings are ten replicas, 4,000 CPU millicores, and 8,192 MiB +per component. Lower them for smaller destinations. Raising desired capacity in +Studio cannot bypass these ceilings. They are separate from workflow quotas, +worker task slots, and provider account limits. + +## 6. Prepare the selected adapter + +### Docker Compose + +Set `compose_file` to an absolute operator-owned Compose file and `lock_file` to +an absolute private path shared by every runner process for this destination. +The file must contain exactly the services listed in `components`. Define +networks, mounts, configuration, and secrets locally; the API cannot add them. +Provision dependencies before using a plan, since changes use `--no-deps`. + +The runner keeps a private `.compose.json` override alongside the +lock. Preserve it across restarts: it records the accepted images, resources, +labels, and replica counts, including services scaled to zero. Back up this +file with the local deployment configuration. Keep those files out of Git. + +Before a mutation, the runner validates the rendered configuration, runs a +Compose dry run, and rechecks the observation under the destination lock. It +then applies only the named services and waits for container readiness. Add +meaningful health checks to your Compose services; without one, a running +process is the only readiness evidence Docker provides. + +### Kubernetes + +The adapter manages **existing deployments**, not arbitrary resources. Each +allowed deployment must have the matching local configuration alias in its pod +template: + +```yaml +# This is part of the operator-owned Deployment, not a workflow definition. +spec: + template: + metadata: + annotations: + firefly.weave/configuration: production +``` + +Every patch tests the deployment UID and resource version. The runner asks the +API server to dry-run all patches before applying them, then checks the +admission result and rollout. API components must keep one replica and +`Recreate`; a matching horizontal pod autoscaler blocks the update. Maintain +that singleton rule in your cluster admission and operator policies too. + +The adapter changes only the selected container's image/resources, replicas, +and its ownership label. A worker scaling plan does not change the image. +Objects already labeled as belonging to another Weave target are rejected. + +### Azure Container Apps + +Set `context` to the subscription UUID, `boundary` to the resource group, +`external_identity` to the managed environment resource ID, `lock_file` to a +shared private path, and each component's `container` to its existing container +name. Use the Azure public cloud management endpoint. + +Observation supports API, worker, and Lumi components. Mutation supports +existing **worker and Lumi apps in Single revision mode**. Apps with custom +scale rules are rejected because this interface configures fixed capacity. +API/scheduler upgrades continue to use the +[Azure deployment runbook](azure.md): Single revision mode alone does not prove +that API revisions cannot overlap during an upgrade. + +Positive worker capacity sets both replica bounds to the reviewed value. +Scaling a worker to zero deactivates its active revisions. Updating an image at +zero replicas is not supported; keep that as a separate reviewed change. +The adapter preserves the app's existing environment, mounts, and other template +configuration while updating the requested fields. + +Container Apps PATCH has no documented atomic resource-version precondition. +The runner serializes its own operations and checks for drift immediately before +writing, but external Azure operators still need to coordinate changes. See the +[official API contract](https://learn.microsoft.com/en-us/rest/api/resource-manager/containerapps/container-apps/update?view=rest-resource-manager-containerapps-2025-07-01). + +## 7. Review a job's result + +![A reviewed plan can succeed, fail before changing anything, or need reconciliation](../diagrams/operations-plan-lifecycle.svg) + +A queued job is not a completed deployment. Follow it until a terminal state and +read its final observation. Provider readiness does not prove a workflow or AI +model works: run the corresponding product acceptance check afterward. + +If a runner loses authority or contact after an effect may have started, Weave +keeps the job in **Reconciliation required**. Inspect the destination, stop any +remaining external operation, request a fresh settled observation, and use the +explicit reconciliation flow. It records the resolution; it does not roll back +or automatically repeat the earlier change. + +Pausing a worker and removing container replicas are different actions. Use +**Workers → Pause new tasks** to let existing leases finish. Reducing container +capacity can terminate tasks if those instances have not drained. diff --git a/docs/operations/upgrades.md b/docs/operations/upgrades.md index 15c42729..d518abd8 100644 --- a/docs/operations/upgrades.md +++ b/docs/operations/upgrades.md @@ -41,6 +41,7 @@ the desktop app never migrates a platform. | alpha9 | `0028_files` | No new schema migration. Adds guided AI configuration and renewable worker OAuth2 authentication. | | alpha10 | `0028_files` | No new schema migration. Restores Lumi access through the Studio host; explicit selection supersedes pending canvas fitting, and initial empty fields no longer steal focus. | | alpha11 | `0028_files` | Studio AI setup wizards and explicit same-execution shared AI context; no server behavior or schema change. Updating the local Studio host and assets does not require redeploying an alpha10 server. | +| alpha12 | `0030_worker_presence` | Adds scoped deployment Operations persistence, runner fencing and reconciliation, and worker presence/drain state. Explicitly migrate the server before using Operations; new roles are never granted automatically. Provider credentials stay on separately operated runners. | ![Schema, compatibility and execution acceptance gates](../diagrams/operations-upgrade.svg) diff --git a/docs/reference/api.md b/docs/reference/api.md index 56ade96d..d51ab6f2 100644 --- a/docs/reference/api.md +++ b/docs/reference/api.md @@ -600,6 +600,78 @@ operation in the [full API reference](api-explorer.md); to generate a client, | `debug.read` | `GET /api/v1/tenants/{tenant}/projects/{project}/debug/sessions/{identifier}` | simulate | | `debug.command` | `POST /api/v1/tenants/{tenant}/projects/{project}/debug/sessions/{identifier}/commands` | simulate | +| `deployment_plans.approval` | `GET /api/v1/tenants/{tenant}/projects/{project}/environments/{environment}/deployment-plans/{identifier}/approval` | deployment.read | +| `deployment_jobs.reconcile` | `POST /api/v1/tenants/{tenant}/projects/{project}/environments/{environment}/deployment-jobs/{identifier}/reconcile` | deployment.apply and deployment.approve | +| `deployment_targets.list` | `GET /api/v1/tenants/{tenant}/projects/{project}/environments/{environment}/deployment-targets` | deployment.read | +| `deployment_targets.read` | `GET /api/v1/tenants/{tenant}/projects/{project}/environments/{environment}/deployment-targets/{identifier}` | deployment.read | +| `deployments.list` | `GET /api/v1/tenants/{tenant}/projects/{project}/environments/{environment}/deployments` | deployment.read | +| `deployments.read` | `GET /api/v1/tenants/{tenant}/projects/{project}/environments/{environment}/deployments/{identifier}` | deployment.read | +| `deployment_observations.list` | `GET /api/v1/tenants/{tenant}/projects/{project}/environments/{environment}/deployment-observations` | deployment.read | +| `deployment_observations.read` | `GET /api/v1/tenants/{tenant}/projects/{project}/environments/{environment}/deployment-observations/{identifier}` | deployment.read | +| `deployment_plans.list` | `GET /api/v1/tenants/{tenant}/projects/{project}/environments/{environment}/deployment-plans` | deployment.read | +| `deployment_plans.read` | `GET /api/v1/tenants/{tenant}/projects/{project}/environments/{environment}/deployment-plans/{identifier}` | deployment.read | +| `deployment_jobs.list` | `GET /api/v1/tenants/{tenant}/projects/{project}/environments/{environment}/deployment-jobs` | deployment.read | +| `deployment_jobs.read` | `GET /api/v1/tenants/{tenant}/projects/{project}/environments/{environment}/deployment-jobs/{identifier}` | deployment.read | +| `deployment_runners.list` | `GET /api/v1/tenants/{tenant}/projects/{project}/environments/{environment}/deployment-runners` | deployment.read | +| `deployment_runners.read` | `GET /api/v1/tenants/{tenant}/projects/{project}/environments/{environment}/deployment-runners/{identifier}` | deployment.read | +| `deployment_targets.create` | `POST /api/v1/tenants/{tenant}/projects/{project}/environments/{environment}/deployment-targets` | target.manage | +| `deployments.create` | `POST /api/v1/tenants/{tenant}/projects/{project}/environments/{environment}/deployments` | target.manage | +| `deployment_observations.create` | `POST /api/v1/tenants/{tenant}/projects/{project}/environments/{environment}/deployment-observations` | deployment.plan | +| `deployment_plans.create` | `POST /api/v1/tenants/{tenant}/projects/{project}/environments/{environment}/deployment-plans` | deployment.plan | +| `deployment_targets.update` | `PUT /api/v1/tenants/{tenant}/projects/{project}/environments/{environment}/deployment-targets/{identifier}` | target.manage | +| `deployments.update` | `PUT /api/v1/tenants/{tenant}/projects/{project}/environments/{environment}/deployments/{identifier}` | target.manage | +| `deployment_plans.approve` | `POST /api/v1/tenants/{tenant}/projects/{project}/environments/{environment}/deployment-plans/{identifier}/approve` | deployment.approve | +| `deployment_plans.apply` | `POST /api/v1/tenants/{tenant}/projects/{project}/environments/{environment}/deployment-plans/{identifier}/apply` | deployment.apply | +| `deployment_jobs.cancel` | `POST /api/v1/tenants/{tenant}/projects/{project}/environments/{environment}/deployment-jobs/{identifier}/cancel` | deployment.cancel | +| `deployment_runners.create` | `POST /api/v1/tenants/{tenant}/projects/{project}/environments/{environment}/deployment-runners` | runner.register | +| `deployment_runners.claim` | `POST /api/v1/tenants/{tenant}/projects/{project}/environments/{environment}/deployment-runners/claim` | runner.claim | +| `deployment_runners.renew` | `POST /api/v1/tenants/{tenant}/projects/{project}/environments/{environment}/deployment-runners/renew` | runner.renew | +| `deployment_runners.report` | `POST /api/v1/tenants/{tenant}/projects/{project}/environments/{environment}/deployment-runners/report` | runner.report | +| `deployment_runners.revoke` | `POST /api/v1/tenants/{tenant}/projects/{project}/environments/{environment}/deployment-runners/{identifier}/revoke` | target.manage | +| `workers.drain` | `POST /api/v1/tenants/{tenant}/projects/{project}/environments/{environment}/workers/{identifier}/drain` | worker.drain | +| `workers.resume` | `POST /api/v1/tenants/{tenant}/projects/{project}/environments/{environment}/workers/{identifier}/resume` | worker.drain | + +## Deployment Operations authority + +Follow [Manage container deployments](../operations/cluster-management.md) +for the complete Studio and CLI sequence. + +Deployment targets, desired deployments, observations, plans, approvals, jobs, +and runner registrations belong to one environment. Child-resource reads check +the target grant. Lists filter authorized target IDs before pagination; use +`target_id` to select a target, and `deployment_id` to narrow plan history. +An observation is a dated snapshot, not a promise of current health. + +The server creates an immutable plan from the desired revision and a fresh, +complete observation. Approval records its exact digest. Applying checks the +current target and desired revisions, freshness, and the approver's current +authority again. `deployment_plans.approval` returns the recorded approval or +`null`; a recorded approval does not bypass those final checks. + +| Role | Responsibility | +| --- | --- | +| `deployment_reader` | Read authorized target inventory and evidence | +| `deployment_planner` | Register target metadata, edit desired deployment, observe, and plan | +| `deployment_approver` | Approve the exact immutable plan | +| `deployment_operator` | Apply or cancel work | +| `deployment_runner` | Dedicated application's registration, claims, renewals, and reports | +| `worker_operator` | Drain or resume a specific worker instance | + +Provider credentials and configuration templates stay on the outbound runner. +The API accepts typed intent, immutable image digests, and local configuration +aliases; it does not accept commands or cloud credentials. API/scheduler +components require exactly one instance. Worker components require an admitted +release. Available actions are bounded by both the registered target and the +runner's local adapter policy. + +Only one job may be active per target. Losing authority after external changes +begin requires reconciliation, never automatic re-execution. An operator with +both apply and approve capabilities must acknowledge that the external +operation stopped and select a new complete, settled observation to close the +ambiguous job. Closing it does not reverse changes or apply another plan. +Worker drain prevents new claims and lets existing leases settle; scaling a +provider replica count to zero is a different operation. + ## Compatibility and retention authority **Compatibility.** A project viewer with `status.read` can read the project's diff --git a/docs/reference/local-runtime.md b/docs/reference/local-runtime.md index c8641cdb..5c0d9db0 100644 --- a/docs/reference/local-runtime.md +++ b/docs/reference/local-runtime.md @@ -411,7 +411,7 @@ supervision, none of which the local setup scripts provision. The locked framework is the published **PyFly 26.9.15**; the exact wheel hash and upstream commit are in the [project metadata](../../pyproject.toml). The API -package version is `0.1.0a11`; a checkout of `main` can carry unreleased changes +package version is `0.1.0a12`; a checkout of `main` can carry unreleased changes on top of it. Validate readiness, an authorized workflow, and your [backup and restore procedure](../operations/backup-restore.md) in the environment you intend to operate. diff --git a/docs/reference/native-openapi.md b/docs/reference/native-openapi.md index 8a2567fc..48f7450d 100644 --- a/docs/reference/native-openapi.md +++ b/docs/reference/native-openapi.md @@ -118,7 +118,7 @@ each of these. ## Versions and what the document proves - The document's `info.version` is the API version, `1`. It is separate from - the package version (`0.1.0a11`) and from the workflow language version + the package version (`0.1.0a12`) and from the workflow language version (`weave/v1alpha1`). - The exporter uses PyFly 26.9.15. The exact URL, SHA-256, and upstream provenance are in [project metadata](../../pyproject.toml) and the lockfile. diff --git a/docs/reference/sdk.md b/docs/reference/sdk.md index 9ca444fa..6b5d8196 100644 --- a/docs/reference/sdk.md +++ b/docs/reference/sdk.md @@ -355,6 +355,8 @@ capability. | `human_tasks`, `read_human_task`, `claim_human_task`, `release_human_task`, `complete_human_task`, `reassign_human_task` | Human task inbox and decisions | | `assignment_bindings`, `put_assignment_binding`, `put_human_group` | Who may work on which human task | | `register_release`, `read_release`, `list_releases`, `register_worker`, `read_worker`, `list_workers`, `revoke_worker`, `grant_connection` | Trusted worker releases and instances | +| `drain_worker`, `resume_worker` | Revision-checked worker controls; existing leases can settle while drained | +| `invoke("deployment_targets.…")`, `invoke("deployments.…")`, `invoke("deployment_observations.…")`, `invoke("deployment_plans.…")`, `invoke("deployment_jobs.…")`, `invoke("deployment_runners.…")` | Typed, environment-scoped Operations contracts; operation names and required authority are in the [API inventory](api.md#operation-inventory) | | `claim_tasks`, `heartbeat`, `complete_task`, `fail_task`, `request_credentials` | The typed [worker protocol](worker-protocol.md); `WorkerTransport` remains supported | | `create_trigger`, `read_trigger`, `list_triggers`, `disable_trigger` | Immutable signed webhook triggers | | `save_schedule`, `read_schedule`, `list_schedules`, `schedule_history`, `change_schedule` | UTC schedules with revisions and occurrence pages | @@ -375,6 +377,38 @@ code. Paginated methods return a typed `Page`, except run history, whose belong to the client's scope. Secret leases use the `CredentialLease` model, whose ordinary serialization leaves out the resolved value. +## Deployment Operations through the SDK + +Use `WeaveClient.invoke` with models from +`firefly_weave.contracts.deployments`. It resolves only registered operations, +validates request and response contracts, and uses the client's existing +authentication and exact environment scope. For example, inside an active +client context: + +```python +from uuid import UUID +from firefly_weave.contracts.deployments import ObserveRequest + +job = await client.invoke( + "deployment_observations.create", + body=ObserveRequest(target_id=UUID(target_id)), + idempotency_key=request_id, +) +``` + +Retain `request_id` when retrying that same request. Poll `deployment_jobs.read` +with `identifier=job.id`; successful observation jobs identify the persisted +snapshot. Build a plan using its observation ID and the desired deployment's +exact revision. Read and review the returned plan before approving its digest +and applying it with a separate retained idempotency key. Updates use +`revision=` for `If-Match`; lists accept bounded `query` filters and cursors. + +Approval and apply require explicit grants. The outbound runner uses a +separate application identity and local provider configuration. Do not put +cloud credentials, command strings, or raw manifests in API bodies. See +[Operations authority and reconciliation](api.md#deployment-operations-authority) +for the checks that still apply after a plan was approved. + ## Device login, PKCE and stores Use this section when your own application signs people in, instead of diff --git a/migrations/versions/0029_deployments.py b/migrations/versions/0029_deployments.py new file mode 100644 index 00000000..f37c291e --- /dev/null +++ b/migrations/versions/0029_deployments.py @@ -0,0 +1,95 @@ +# Copyright 2026 Firefly Software Foundation. +# +# 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. +# Author: Firefly Software Foundation +# SPDX-License-Identifier: Apache-2.0 + +"""Persist environment-scoped deployment intent, approvals and fenced runner jobs.""" + +from alembic import op + +revision = "0029_deployments" +down_revision = "0028_files" + + +def upgrade(): + roles = ( + "platform_admin", + "tenant_admin", + "developer", + "deployer", + "operator", + "viewer", + "worker", + "task_participant", + "task_manager", + "email_reader", + "email_sender", + "email_manager", + "execution_manager", + "lumi_user", + "lumi_manager", + "file_reader", + "file_manager", + "deployment_reader", + "deployment_planner", + "deployment_approver", + "deployment_operator", + "deployment_runner", + "worker_operator", + ) + accepted = ",".join("'" + role + "'" for role in roles) + op.execute("ALTER TABLE role_bindings DROP CONSTRAINT role_bindings_role_check") + op.execute(f"ALTER TABLE role_bindings ADD CONSTRAINT role_bindings_role_check CHECK(role IN ({accepted}))") + scope = "tenant_id uuid NOT NULL,project_id uuid NOT NULL,environment_id uuid NOT NULL" + key = "tenant_id,project_id,environment_id" + tables = ("targets", "deployments", "observations", "plans", "approvals", "runners", "jobs", "reports") + for name in tables: + extra = "" + if name == "jobs": + extra = ( + ",state text NOT NULL,generation integer NOT NULL DEFAULT 0,runner_id uuid," + "lease_token uuid,lease_expires_at timestamptz" + ) + if name == "reports": + extra = ",job_id uuid NOT NULL,runner_id uuid NOT NULL,request_hash text NOT NULL" + parent = "" if name == "targets" else f",FOREIGN KEY({key},target_id) REFERENCES deployment_targets({key},id)" + op.execute(f"""CREATE TABLE deployment_{name}( + id uuid PRIMARY KEY,{scope},target_id uuid NOT NULL,deployment_id uuid, + revision integer NOT NULL DEFAULT 1 CHECK(revision>0), + created_at timestamptz NOT NULL DEFAULT clock_timestamp(), + payload jsonb NOT NULL CHECK(octet_length(payload::text)<=262144){extra}, + UNIQUE({key},id),FOREIGN KEY({key}) REFERENCES environments(tenant_id,project_id,id){parent})""") + op.execute(f"CREATE INDEX deployment_{name}_scope ON deployment_{name}({key},target_id,id)") + condition = ( + "tenant_id=nullif(current_setting('weave.tenant_id',true),'')::uuid AND " + "project_id=nullif(current_setting('weave.project_id',true),'')::uuid AND " + "environment_id=nullif(current_setting('weave.environment_id',true),'')::uuid" + ) + op.execute(f"ALTER TABLE deployment_{name} ENABLE ROW LEVEL SECURITY") + op.execute(f"ALTER TABLE deployment_{name} FORCE ROW LEVEL SECURITY") + op.execute( + f"CREATE POLICY exact_scope ON deployment_{name} TO weave_app USING ({condition}) WITH CHECK ({condition})" + ) + privileges = ( + "SELECT,INSERT" if name in {"observations", "plans", "approvals", "reports"} else "SELECT,INSERT,UPDATE" + ) + op.execute(f"GRANT {privileges} ON deployment_{name} TO weave_app") + op.execute("""CREATE UNIQUE INDEX deployment_one_active_target ON deployment_jobs(target_id) + WHERE state IN ('queued','claimed','running','verifying')""") + op.execute("CREATE INDEX deployment_jobs_claim ON deployment_jobs(tenant_id,project_id,environment_id,state,id)") + op.execute("UPDATE weave_schema_version SET version='0029_deployments'") + + +def downgrade(): + raise RuntimeError("Destructive downgrades are not supported") diff --git a/migrations/versions/0030_worker_presence.py b/migrations/versions/0030_worker_presence.py new file mode 100644 index 00000000..61f1740a --- /dev/null +++ b/migrations/versions/0030_worker_presence.py @@ -0,0 +1,45 @@ +# Copyright 2026 Firefly Software Foundation. +# +# 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. +# Author: Firefly Software Foundation +# SPDX-License-Identifier: Apache-2.0 + +"""Scoped observed worker presence and revisioned drain control without lease revocation.""" + +from alembic import op + +revision = "0030_worker_presence" +down_revision = "0029_deployments" + + +def upgrade(): + op.execute( + "ALTER TABLE worker_instances ADD COLUMN last_seen_at timestamptz, " + "ADD COLUMN draining boolean NOT NULL DEFAULT false, " + "ADD COLUMN control_revision bigint NOT NULL DEFAULT 1 CHECK(control_revision>0)" + ) + op.execute("GRANT UPDATE(last_seen_at,draining,control_revision) ON worker_instances TO weave_app") + op.execute("DROP POLICY tenant_scope ON worker_instances") + condition = ( + "tenant_id=nullif(current_setting('weave.tenant_id',true),'')::uuid AND " + "project_id=nullif(current_setting('weave.project_id',true),'')::uuid AND " + "environment_id=nullif(current_setting('weave.environment_id',true),'')::uuid" + ) + op.execute( + f"CREATE POLICY exact_scope ON worker_instances TO weave_app USING ({condition}) WITH CHECK ({condition})" + ) + op.execute("UPDATE weave_schema_version SET version='0030_worker_presence'") + + +def downgrade(): + raise RuntimeError("Destructive downgrades are not supported") diff --git a/mkdocs.yml b/mkdocs.yml index 247dae3f..d1ebbbb8 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -150,6 +150,8 @@ nav: - Set up a local platform manually: guides/standalone.md - Deploy a worker locally: operations/deployment.md - Troubleshoot a platform: operations/troubleshooting.md + - Manage clusters and deployments: operations/cluster-management.md + - Install an Operations runner: operations/operations-runner.md - Export metrics and traces: operations/observability.md - Upgrade a platform: operations/upgrades.md - Back up and restore: operations/backup-restore.md diff --git a/pyproject.toml b/pyproject.toml index 3c41c35d..c24f6ccb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,7 +20,7 @@ build-backend = "hatchling.build" [project] name = "firefly-weave" -version = "0.1.0a11" +version = "0.1.0a12" description = "API-first workflow orchestration and integration platform built on PyFly" readme = "README.md" license = "Apache-2.0" diff --git a/src/firefly_weave/__init__.py b/src/firefly_weave/__init__.py index e9294f7d..ea48ee18 100644 --- a/src/firefly_weave/__init__.py +++ b/src/firefly_weave/__init__.py @@ -16,4 +16,4 @@ """Firefly Weave's offline contracts and workflow tooling.""" -__version__ = "0.1.0a11" +__version__ = "0.1.0a12" diff --git a/src/firefly_weave/access/models.py b/src/firefly_weave/access/models.py index 97a6f28b..01fe31ab 100644 --- a/src/firefly_weave/access/models.py +++ b/src/firefly_weave/access/models.py @@ -41,6 +41,12 @@ "lumi_manager", "file_reader", "file_manager", + "deployment_reader", + "deployment_planner", + "deployment_approver", + "deployment_operator", + "deployment_runner", + "worker_operator", ] diff --git a/src/firefly_weave/access/roles.py b/src/firefly_weave/access/roles.py index 80832d3e..b115477e 100644 --- a/src/firefly_weave/access/roles.py +++ b/src/firefly_weave/access/roles.py @@ -58,6 +58,12 @@ "lumi_manager": frozenset({"lumi.use", "lumi.manage"}), "file_reader": frozenset({"file.read"}), "file_manager": frozenset({"file.read", "file.manage"}), + "deployment_reader": frozenset({"deployment.read"}), + "deployment_planner": frozenset({"deployment.read", "target.manage", "deployment.plan"}), + "deployment_approver": frozenset({"deployment.read", "deployment.approve"}), + "deployment_operator": frozenset({"deployment.read", "deployment.apply", "deployment.cancel"}), + "deployment_runner": frozenset({"runner.register", "runner.claim", "runner.renew", "runner.report"}), + "worker_operator": frozenset({"status.read", "worker.drain"}), "worker": frozenset({"worker.register", "task.claim", "task.heartbeat", "task.complete", "credential.lease"}), } ) diff --git a/src/firefly_weave/api/deployments.py b/src/firefly_weave/api/deployments.py new file mode 100644 index 00000000..f7b3ec3e --- /dev/null +++ b/src/firefly_weave/api/deployments.py @@ -0,0 +1,265 @@ +# Copyright 2026 Firefly Software Foundation. +# +# 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. +# Author: Firefly Software Foundation +# SPDX-License-Identifier: Apache-2.0 + +"""Native typed Operations endpoints; the browser never supplies executable code.""" + +from typing import Any +from uuid import UUID + +from pyfly.container.stereotypes import rest_controller +from pyfly.web import request_mapping +from starlette.requests import Request +from starlette.responses import JSONResponse + +from firefly_weave.api.access import request_scope +from firefly_weave.api.surface import operation +from firefly_weave.api.transport import etag, page_request, page_response, parse_revision +from firefly_weave.contracts.deployments import ( + ApplyPlanRequest, + DeploymentLeaseProof, + DeploymentRequest, + JobCancelRequest, + ObserveRequest, + PlanApprovalRequest, + PlanRequest, + ReconcileJobRequest, + RunnerClaimRequest, + RunnerRegistration, + RunnerReport, + TargetRequest, + TargetUpdate, +) +from firefly_weave.deployments.service import DeploymentService + + +@rest_controller +@request_mapping("") +class DeploymentController: + def __init__(self, service: DeploymentService): + self.service = service + + def authority(self, request: Request) -> dict[str, Any]: + return dict( + actor=request.state.principal, + scope=request_scope(request, environment=True), + context=request.state.audit_context, + ) + + async def listing(self, request: Request, collection: str) -> JSONResponse: + authority = self.authority(request) + limit, after = page_request(request, authority["scope"], collection) + target = request.query_params.get("target_id") + deployment = request.query_params.get("deployment_id") if collection == "plans" else None + result = await self.service.list( + collection, + limit=limit, + after=after, + target_id=UUID(target) if target else None, + deployment_id=UUID(deployment) if deployment else None, + **authority, + ) + return JSONResponse(page_response(result, authority["scope"], collection, request.url.path)) + + async def reading(self, request: Request, collection: str) -> JSONResponse: + result = await self.service.read(collection, UUID(request.path_params["identifier"]), **self.authority(request)) + return JSONResponse(result.model_dump(mode="json")) + + @operation("deployment_targets.list") + async def deployment_targets_list(self, request: Request) -> JSONResponse: + return await self.listing(request, "targets") + + @operation("deployment_targets.read") + async def deployment_targets_read(self, request: Request) -> JSONResponse: + return await self.reading(request, "targets") + + @operation("deployments.list") + async def deployments_list(self, request: Request) -> JSONResponse: + return await self.listing(request, "deployments") + + @operation("deployments.read") + async def deployments_read(self, request: Request) -> JSONResponse: + return await self.reading(request, "deployments") + + @operation("deployment_observations.list") + async def deployment_observations_list(self, request: Request) -> JSONResponse: + return await self.listing(request, "observations") + + @operation("deployment_observations.read") + async def deployment_observations_read(self, request: Request) -> JSONResponse: + return await self.reading(request, "observations") + + @operation("deployment_plans.list") + async def deployment_plans_list(self, request: Request) -> JSONResponse: + return await self.listing(request, "plans") + + @operation("deployment_plans.read") + async def deployment_plans_read(self, request: Request) -> JSONResponse: + return await self.reading(request, "plans") + + @operation("deployment_jobs.list") + async def deployment_jobs_list(self, request: Request) -> JSONResponse: + return await self.listing(request, "jobs") + + @operation("deployment_jobs.read") + async def deployment_jobs_read(self, request: Request) -> JSONResponse: + return await self.reading(request, "jobs") + + @operation("deployment_runners.list") + async def deployment_runners_list(self, request: Request) -> JSONResponse: + return await self.listing(request, "runners") + + @operation("deployment_runners.read") + async def deployment_runners_read(self, request: Request) -> JSONResponse: + return await self.reading(request, "runners") + + @operation("deployment_targets.create") + async def deployment_targets_create(self, request: Request) -> JSONResponse: + result = await self.service.create_target( + TargetRequest.model_validate_json(await request.body()), + request.headers.get("Idempotency-Key", ""), + **self.authority(request), + ) + return JSONResponse(result.model_dump(mode="json") if result is not None else None, status_code=201) + + @operation("deployment_targets.update") + async def deployment_targets_update(self, request: Request) -> JSONResponse: + result = await self.service.update_target( + UUID(request.path_params["identifier"]), + TargetUpdate.model_validate_json(await request.body()), + int(parse_revision(request.headers.get("If-Match"), required=True) or 0), + **self.authority(request), + ) + return JSONResponse( + result.model_dump(mode="json") if result is not None else None, + status_code=200, + headers={"ETag": etag(result.revision, request.url.path)}, + ) + + @operation("deployments.create") + async def deployments_create(self, request: Request) -> JSONResponse: + result = await self.service.create_deployment( + DeploymentRequest.model_validate_json(await request.body()), + request.headers.get("Idempotency-Key", ""), + **self.authority(request), + ) + return JSONResponse(result.model_dump(mode="json") if result is not None else None, status_code=201) + + @operation("deployments.update") + async def deployments_update(self, request: Request) -> JSONResponse: + result = await self.service.update_deployment( + UUID(request.path_params["identifier"]), + DeploymentRequest.model_validate_json(await request.body()), + int(parse_revision(request.headers.get("If-Match"), required=True) or 0), + **self.authority(request), + ) + return JSONResponse( + result.model_dump(mode="json") if result is not None else None, + status_code=200, + headers={"ETag": etag(result.revision, request.url.path)}, + ) + + @operation("deployment_observations.create") + async def deployment_observations_create(self, request: Request) -> JSONResponse: + result = await self.service.observe( + ObserveRequest.model_validate_json(await request.body()), + request.headers.get("Idempotency-Key", ""), + **self.authority(request), + ) + return JSONResponse(result.model_dump(mode="json") if result is not None else None, status_code=201) + + @operation("deployment_plans.create") + async def deployment_plans_create(self, request: Request) -> JSONResponse: + result = await self.service.plan( + PlanRequest.model_validate_json(await request.body()), + request.headers.get("Idempotency-Key", ""), + **self.authority(request), + ) + return JSONResponse(result.model_dump(mode="json") if result is not None else None, status_code=201) + + @operation("deployment_plans.approve") + async def deployment_plans_approve(self, request: Request) -> JSONResponse: + result = await self.service.approve( + UUID(request.path_params["identifier"]), + PlanApprovalRequest.model_validate_json(await request.body()), + **self.authority(request), + ) + return JSONResponse(result.model_dump(mode="json") if result is not None else None, status_code=200) + + @operation("deployment_plans.apply") + async def deployment_plans_apply(self, request: Request) -> JSONResponse: + result = await self.service.apply( + UUID(request.path_params["identifier"]), + ApplyPlanRequest.model_validate_json(await request.body()), + request.headers.get("Idempotency-Key", ""), + **self.authority(request), + ) + return JSONResponse(result.model_dump(mode="json") if result is not None else None, status_code=201) + + @operation("deployment_jobs.cancel") + async def deployment_jobs_cancel(self, request: Request) -> JSONResponse: + result = await self.service.cancel( + UUID(request.path_params["identifier"]), + JobCancelRequest.model_validate_json(await request.body()), + **self.authority(request), + ) + return JSONResponse(result.model_dump(mode="json") if result is not None else None, status_code=200) + + @operation("deployment_runners.create") + async def deployment_runners_create(self, request: Request) -> JSONResponse: + result = await self.service.register_runner( + RunnerRegistration.model_validate_json(await request.body()), **self.authority(request) + ) + return JSONResponse(result.model_dump(mode="json") if result is not None else None, status_code=201) + + @operation("deployment_runners.claim") + async def deployment_runners_claim(self, request: Request) -> JSONResponse: + result = await self.service.claim( + RunnerClaimRequest.model_validate_json(await request.body()), **self.authority(request) + ) + return JSONResponse(result.model_dump(mode="json") if result is not None else None, status_code=200) + + @operation("deployment_runners.renew") + async def deployment_runners_renew(self, request: Request) -> JSONResponse: + result = await self.service.renew( + DeploymentLeaseProof.model_validate_json(await request.body()), **self.authority(request) + ) + return JSONResponse(result.model_dump(mode="json") if result is not None else None, status_code=200) + + @operation("deployment_runners.report") + async def deployment_runners_report(self, request: Request) -> JSONResponse: + result = await self.service.report( + RunnerReport.model_validate_json(await request.body()), **self.authority(request) + ) + return JSONResponse(result.model_dump(mode="json") if result is not None else None, status_code=200) + + @operation("deployment_runners.revoke") + async def deployment_runners_revoke(self, request: Request) -> JSONResponse: + result = await self.service.revoke_runner(UUID(request.path_params["identifier"]), **self.authority(request)) + return JSONResponse(result.model_dump(mode="json") if result is not None else None, status_code=200) + + @operation("deployment_jobs.reconcile") + async def reconcile(self, request: Request) -> JSONResponse: + result = await self.service.reconcile( + UUID(request.path_params["identifier"]), + ReconcileJobRequest.model_validate_json(await request.body()), + **self.authority(request), + ) + return JSONResponse(result.model_dump(mode="json")) + + @operation("deployment_plans.approval") + async def approval(self, request: Request) -> JSONResponse: + result = await self.service.approval(UUID(request.path_params["identifier"]), **self.authority(request)) + return JSONResponse(result.model_dump(mode="json") if result is not None else None) diff --git a/src/firefly_weave/api/workers.py b/src/firefly_weave/api/workers.py index b1f380c6..2d0347dc 100644 --- a/src/firefly_weave/api/workers.py +++ b/src/firefly_weave/api/workers.py @@ -36,6 +36,7 @@ InstanceRequest, LeaseProof, ReleaseRequest, + WorkerControlRequest, ) from firefly_weave.workers.leases import TaskService from firefly_weave.workers.service import WorkerService @@ -86,6 +87,26 @@ async def revoke(self, request: Request) -> JSONResponse: ) return JSONResponse({"revoked": True}) + @operation("workers.drain") + async def drain(self, request: Request) -> JSONResponse: + return await self.control(request, draining=True) + + @operation("workers.resume") + async def resume(self, request: Request) -> JSONResponse: + return await self.control(request, draining=False) + + async def control(self, request: Request, *, draining: bool) -> JSONResponse: + result = await self.service.control( + request.state.principal, + request_scope(request, environment=True), + UUID(request.path_params["identifier"]), + WorkerControlRequest.model_validate_json(await request.body()), + request.headers.get("Idempotency-Key", ""), + draining=draining, + context=request.state.audit_context, + ) + return JSONResponse(result.model_dump(mode="json"), headers={"Cache-Control": "no-store"}) + @operation("workers.grant") async def grant(self, request: Request) -> JSONResponse: await self.service.grant_connection( diff --git a/src/firefly_weave/app.py b/src/firefly_weave/app.py index fe2bde47..8ca066b0 100644 --- a/src/firefly_weave/app.py +++ b/src/firefly_weave/app.py @@ -121,6 +121,7 @@ "firefly_weave.access.members", "firefly_weave.human_tasks", "firefly_weave.files", + "firefly_weave.deployments", "firefly_weave.email", "firefly_weave.access.authorization", "firefly_weave.access.authentication", diff --git a/src/firefly_weave/cli/cluster_operations.py b/src/firefly_weave/cli/cluster_operations.py new file mode 100644 index 00000000..65ce5370 --- /dev/null +++ b/src/firefly_weave/cli/cluster_operations.py @@ -0,0 +1,202 @@ +# Copyright 2026 Firefly Software Foundation. +# +# 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. +# Author: Firefly Software Foundation +# SPDX-License-Identifier: Apache-2.0 + +"""Guided local runner setup and canonical remote Operations commands.""" + +from __future__ import annotations + +import json +import os +import shutil +from pathlib import Path +from typing import Any + +import click + +from firefly_weave.cli.remote import family, machine_result + + +@click.group("operations") +def operations() -> None: + """Manage container destinations with reviewed plans and an outbound runner. + + Observe a target, set desired components, create a plan, review and approve + its digest, then apply it. The runner keeps provider credentials locally. + """ + + +for name, prefix in ( + ("targets", "deployment_targets"), + ("deployments", "deployments"), + ("observations", "deployment_observations"), + ("plans", "deployment_plans"), + ("jobs", "deployment_jobs"), + ("runners", "deployment_runners"), +): + operations.add_command(family(name, prefix)) + + +@operations.group("runner") +def runner() -> None: + """Configure and run the Operations agent on a Linux or macOS destination host.""" + + +@runner.command("setup") +@click.option( + "--output", type=click.Path(path_type=Path), required=True, help="Create a new private runner configuration." +) +def setup(output: Path) -> None: + """Walk through the destination, workspace, allowlist, and machine identity. + + Create a target in Studio first. Copy its ID and exact destination identity. + This writes local configuration only; it does not sign in or change infrastructure. + """ + from firefly_weave.deployment_runner.application import RunnerConfiguration + + if output.exists(): + raise click.ClickException("That file already exists. Choose a new path; existing configuration is preserved.") + click.echo("1 of 4 — Choose the Weave workspace that owns this runner.") + base_url = click.prompt("Weave API origin (https://...)") + scope = { + key: click.prompt(label, type=click.UUID) + for key, label in ( + ("tenant_id", "Tenant ID"), + ("project_id", "Project ID"), + ("environment_id", "Environment ID"), + ) + } + click.echo("2 of 4 — Bind the runner to one explicitly registered destination.") + target_id = click.prompt("Target ID from Studio", type=click.UUID) + adapter = click.prompt( + "Container adapter", type=click.Choice(["docker-compose", "kubernetes", "azure-container-apps"]) + ) + executable_name = {"docker-compose": "docker", "kubernetes": "kubectl", "azure-container-apps": "az"}[adapter] + executable = click.prompt( + "Absolute executable path", default=shutil.which(executable_name) or "/usr/local/bin/" + executable_name + ) + context = click.prompt( + "Azure subscription UUID" if adapter == "azure-container-apps" else "Explicit " + executable_name + " context" + ) + boundary = click.prompt( + { + "docker-compose": "Compose project name", + "kubernetes": "Kubernetes namespace", + "azure-container-apps": "Azure resource group", + }[adapter] + ) + identity = click.prompt( + { + "docker-compose": "Pinned Docker daemon ID", + "kubernetes": "Pinned namespace UID", + "azure-container-apps": "Pinned Container Apps environment resource ID", + }[adapter] + ) + destination: dict[str, Any] = { + "target_id": str(target_id), + "adapter": adapter, + "external_identity": identity, + "boundary": boundary, + "executable": executable, + "context": context, + "capabilities": ["observe"], + } + if adapter == "docker-compose": + destination["compose_file"] = click.prompt("Absolute operator-owned Compose file") + if adapter in {"docker-compose", "azure-container-apps"}: + destination["lock_file"] = click.prompt("Absolute shared runner lock file") + click.echo("3 of 4 — Allow only the components and image repositories you operate.") + components: list[dict[str, str]] = [] + while len(components) < 100: + component = { + "name": click.prompt("Service or deployment name"), + "kind": click.prompt("Component role", type=click.Choice(["api", "worker", "lumi"])), + "configuration": click.prompt("Local configuration alias", default="current"), + } + if adapter in {"kubernetes", "azure-container-apps"}: + component["container"] = click.prompt("Container name inside that deployment") + components.append(component) + if not click.confirm("Allow another component?", default=False): + break + destination["components"] = components + destination["image_repositories"] = [ + item.strip() + for item in click.prompt("Allowed image repositories (comma-separated; no tags or digests)").split(",") + ] + if click.confirm("Allow reviewed updates and worker scaling?", default=False): + destination["capabilities"] += ["update", "scale_workers"] + if adapter == "docker-compose" and click.confirm( + "Allow deploying the trusted Compose template?", default=False + ): + destination["capabilities"].append("deploy") + click.echo("4 of 4 — Use a dedicated runner identity, separate from your Studio login.") + oauth_file = click.prompt("Absolute OAuth client-credentials configuration file") + try: + config = RunnerConfiguration.model_validate_json( + json.dumps( + { + "base_url": base_url, + "scope": {key: str(value) for key, value in scope.items()}, + "destination": destination, + "oauth_file": oauth_file, + } + ) + ) + output.parent.mkdir(parents=True, exist_ok=True) + descriptor = os.open(output, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + with os.fdopen(descriptor, "w") as handle: + handle.write(config.model_dump_json(indent=2) + "\n") + except (ValueError, OSError): + raise click.ClickException( + "Configuration could not be saved. Check the destination IDs, absolute paths, and allowlist." + ) from None + click.echo("Configuration saved. Run 'weave operations runner check --config PATH' before starting the runner.") + + +@runner.command("check") +@click.option("--config", type=click.Path(exists=True, dir_okay=False, path_type=Path), required=True) +def check(config: Path) -> None: + """Validate local policy and read the destination; do not register or apply jobs.""" + import asyncio + + from firefly_weave.deployment_runner.application import adapter_for, load_configuration + + try: + settings = load_configuration(config) + machine_result(asyncio.run(adapter_for(settings.destination).observe())) + except Exception: + raise click.ClickException( + "Runner check failed. Inspect the local policy, provider access, and pinned destination identity." + ) from None + + +@runner.command("run") +@click.option("--config", type=click.Path(exists=True, dir_okay=False, path_type=Path), required=True) +@click.option("--once", is_flag=True, help="Register, claim at most one job, and exit.") +def run(config: Path, once: bool) -> None: + """Poll Weave over HTTPS and execute only leased, approved local operations.""" + import asyncio + + from firefly_weave.deployment_runner.application import load_configuration, serve + + try: + settings = load_configuration(config) + asyncio.run(serve(settings, once=once)) + except (KeyboardInterrupt, asyncio.CancelledError): + return + except Exception: + raise click.ClickException( + "Runner stopped. Inspect identity, API access, destination policy, and the job receipt before restarting." + ) from None diff --git a/src/firefly_weave/cli/main.py b/src/firefly_weave/cli/main.py index e3a92054..4e3567bb 100644 --- a/src/firefly_weave/cli/main.py +++ b/src/firefly_weave/cli/main.py @@ -28,6 +28,7 @@ from firefly_weave.cli import EXIT_USAGE, emit_result, error_result from firefly_weave.cli.admin import admin from firefly_weave.cli.auth import auth +from firefly_weave.cli.cluster_operations import operations from firefly_weave.cli.connections import connections from firefly_weave.cli.connectors import connector from firefly_weave.cli.debug import simulate @@ -128,7 +129,7 @@ def format_commands(self, ctx: click.Context, formatter: click.HelpFormatter) -> "provider-receipts", ), ), - ("Deploy and administer", ("worker", "admin", "retention", "compatibility", "remote")), + ("Deploy and administer", ("worker", "operations", "admin", "retention", "compatibility", "remote")), ("Help and version", ("help", "version")), ) summaries = { @@ -302,6 +303,7 @@ def version(output: str) -> None: cli.add_command(schema) cli.add_command(connector) cli.add_command(worker) +cli.add_command(operations) cli.add_command(admin) cli.add_command(auth) cli.add_command(run) diff --git a/src/firefly_weave/contracts/deployments.py b/src/firefly_weave/contracts/deployments.py new file mode 100644 index 00000000..d0ce9be3 --- /dev/null +++ b/src/firefly_weave/contracts/deployments.py @@ -0,0 +1,297 @@ +# Copyright 2026 Firefly Software Foundation. +# +# 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. +# Author: Firefly Software Foundation +# SPDX-License-Identifier: Apache-2.0 + +"""Typed, bounded Operations plans; infrastructure credentials remain with the runner.""" + +from typing import Annotated, Literal, Self +from uuid import UUID + +from pydantic import AwareDatetime, Field, model_validator + +from firefly_weave.contracts.access import Scope +from firefly_weave.contracts.catalog import Digest +from firefly_weave.contracts.definitions import ContractModel + +# Names are data identifiers, never paths, shell fragments, or executable arguments. +type DeploymentName = Annotated[str, Field(pattern=r"^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,99}$")] +type ExternalIdentity = Annotated[str, Field(min_length=1, max_length=512, pattern=r"^[a-zA-Z0-9/][a-zA-Z0-9_./:@-]*$")] +type ImageReference = Annotated[ + str, Field(max_length=512, pattern=r"^[a-zA-Z0-9][a-zA-Z0-9._:/-]*@sha256:[0-9a-f]{64}$") +] +type AdapterKind = Literal["docker-compose", "kubernetes", "azure-container-apps"] +type DeploymentCapability = Literal["observe", "deploy", "update", "scale_workers", "drain_workers"] +type JobState = Literal[ + "queued", "claimed", "running", "verifying", "succeeded", "failed", "cancelled", "reconciliation_required" +] + + +class TargetRequest(ContractModel): + name: DeploymentName + adapter: AdapterKind + external_identity: ExternalIdentity + boundary: DeploymentName + runner_principal_id: UUID + capabilities: list[DeploymentCapability] = Field(min_length=1, max_length=5) + + @model_validator(mode="after") + def distinct_capabilities(self) -> Self: + if len(set(self.capabilities)) != len(self.capabilities) or "observe" not in self.capabilities: + raise ValueError("Distinct capabilities including observe are required") + return self + + +class DeploymentTarget(TargetRequest): + id: UUID + scope: Scope + revision: int = Field(ge=1) + disabled: bool = False + created_at: AwareDatetime + + +class TargetUpdate(ContractModel): + runner_principal_id: UUID + capabilities: list[DeploymentCapability] = Field(min_length=1, max_length=5) + disabled: bool = False + + +class ComponentSpec(ContractModel): + name: DeploymentName + kind: Literal["api", "worker", "lumi", "migration"] + image: ImageReference + configuration: DeploymentName + replicas: int = Field(default=1, ge=0, le=100) + cpu_millis: int = Field(default=500, ge=100, le=16000) + memory_mib: int = Field(default=1024, ge=128, le=65536) + worker_release_id: UUID | None = None + + @model_validator(mode="after") + def singleton_and_release(self) -> Self: + if self.kind in {"api", "migration"} and self.replicas != 1: + raise ValueError("API/scheduler and migration components require exactly one instance") + if self.kind != "worker" and self.worker_release_id is not None: + raise ValueError("Only worker components have a worker release") + if self.kind == "worker" and self.worker_release_id is None: + raise ValueError("Worker components require an admitted release") + return self + + +class DeploymentRequest(ContractModel): + target_id: UUID + name: DeploymentName + ownership: Literal["imported", "managed"] = "imported" + components: list[ComponentSpec] = Field(min_length=1, max_length=100) + + @model_validator(mode="after") + def distinct_components(self) -> Self: + if len({item.name for item in self.components}) != len(self.components): + raise ValueError("Component names must be distinct") + if sum(item.kind == "api" for item in self.components) > 1: + raise ValueError("One API/scheduler component per deployment") + return self + + +class Deployment(DeploymentRequest): + id: UUID + scope: Scope + revision: int = Field(ge=1) + created_at: AwareDatetime + + +class ObservedResource(ContractModel): + name: DeploymentName + external_identity: ExternalIdentity + kind: Literal["api", "worker", "lumi", "migration", "unknown"] + image: ImageReference | None = None + replicas: int = Field(ge=0, le=10000) + ready_replicas: int = Field(ge=0, le=10000) + version: ExternalIdentity + ownership: Literal["managed", "imported", "unknown"] + state: Literal["ready", "progressing", "stopped", "failed", "unknown"] + + +class ObservationReport(ContractModel): + settled: bool = False + resources: list[ObservedResource] = Field(max_length=100) + complete: bool + + @model_validator(mode="after") + def distinct_facts(self) -> Self: + if len({r.name for r in self.resources}) != len(self.resources): + raise ValueError("Observed resource names must be distinct") + if any(r.ready_replicas > r.replicas for r in self.resources): + raise ValueError("Ready replicas cannot exceed observed replicas") + return self + + +class DeploymentObservation(ObservationReport): + id: UUID + target_id: UUID + target_revision: int = Field(ge=1) + observed_at: AwareDatetime + expires_at: AwareDatetime + digest: Digest + + +class PlanRequest(ContractModel): + deployment_id: UUID + deployment_revision: int = Field(ge=1) + observation_id: UUID + intent: Literal["deploy", "update", "scale_workers", "drain_workers"] + ttl_seconds: int = Field(default=300, ge=30, le=900) + + +class PlanStep(ContractModel): + action: Literal["deploy", "update", "scale_workers", "drain_workers"] + component: ComponentSpec + expected_version: ExternalIdentity | None = None + + +class DeploymentPlan(ContractModel): + id: UUID + scope: Scope + target_id: UUID + target_revision: int = Field(ge=1) + deployment_id: UUID + deployment_revision: int = Field(ge=1) + adapter: AdapterKind + adapter_version: Literal["1"] = "1" + intent: Literal["deploy", "update", "scale_workers", "drain_workers"] + observation_id: UUID + observation_digest: Digest + steps: list[PlanStep] = Field(min_length=1, max_length=100) + risks: list[Literal["service_interruption", "external_effects", "adoption", "worker_drain"]] = Field(max_length=4) + created_at: AwareDatetime + expires_at: AwareDatetime + digest: Digest + + +class PlanApprovalRequest(ContractModel): + digest: Digest + + +class PlanApproval(ContractModel): + plan_id: UUID + digest: Digest + principal_id: UUID + approved_at: AwareDatetime + + +class ApplyPlanRequest(ContractModel): + digest: Digest + + +class ObserveRequest(ContractModel): + target_id: UUID + + +class JobCancelRequest(ContractModel): + reason: Literal["operator_cancelled"] = "operator_cancelled" + + +class SafeDeploymentReceipt(ContractModel): + code: Literal[ + "observed", + "applied", + "unsupported", + "precondition_failed", + "provider_failed", + "cancelled", + "ambiguous", + "reconciled", + ] + changed_resources: list[ExternalIdentity] = Field(default_factory=list, max_length=100) + external_effects_may_continue: bool = False + + +class DeploymentJob(ContractModel): + id: UUID + scope: Scope + target_id: UUID + target_revision: int = Field(ge=1) + kind: Literal["observe", "apply"] + plan_id: UUID | None = None + plan_digest: Digest | None = None + state: JobState + revision: int = Field(ge=1) + operation_key: UUID + created_at: AwareDatetime + deadline: AwareDatetime + receipt: SafeDeploymentReceipt | None = None + observation_id: UUID | None = None + reconciliation_started_at: AwareDatetime | None = None + + +class RunnerRegistration(ContractModel): + target_id: UUID + adapter: AdapterKind + adapter_version: Literal["1"] = "1" + capabilities: list[DeploymentCapability] = Field(min_length=1, max_length=5) + + +class DeploymentRunner(RunnerRegistration): + id: UUID + principal_id: UUID + last_seen: AwareDatetime + expires_at: AwareDatetime + revoked: bool = False + + +class RunnerClaimRequest(ContractModel): + runner_id: UUID + + +class DeploymentLeaseProof(ContractModel): + job_id: UUID + runner_id: UUID + generation: int = Field(ge=1) + token: UUID + + +class DeploymentLease(ContractModel): + proof: DeploymentLeaseProof + expires_at: AwareDatetime + job: DeploymentJob + target: DeploymentTarget + deployment: Deployment | None = None + plan: DeploymentPlan | None = None + observation: DeploymentObservation | None = None + + +class RunnerReport(ContractModel): + lease: DeploymentLeaseProof + report_id: UUID + state: Literal["running", "verifying", "succeeded", "failed", "cancelled", "reconciliation_required"] + receipt: SafeDeploymentReceipt | None = None + observation: ObservationReport | None = None + + @model_validator(mode="after") + def terminal_receipt(self) -> Self: + terminal = self.state not in {"running", "verifying"} + if terminal != (self.receipt is not None): + raise ValueError("Only terminal reports require a safe receipt") + if self.receipt is not None and self.receipt.external_effects_may_continue != ( + self.state == "reconciliation_required" + ): + raise ValueError("Ambiguous effects require reconciliation and must not unlock the target") + if self.state == "succeeded" and (self.observation is None or not self.observation.complete): + raise ValueError("Successful jobs require a complete final observation") + return self + + +class ReconcileJobRequest(ContractModel): + plan_digest: Digest + observation_id: UUID + external_operation_stopped: Literal[True] diff --git a/src/firefly_weave/contracts/schema_export.py b/src/firefly_weave/contracts/schema_export.py index a5f95da5..853d3c42 100644 --- a/src/firefly_weave/contracts/schema_export.py +++ b/src/firefly_weave/contracts/schema_export.py @@ -90,6 +90,34 @@ def contract_models() -> dict[str, SchemaModel]: from firefly_weave.contracts.client_configuration import ClientConfiguration from firefly_weave.contracts.connector_descriptors import ConnectorDescriptorView from firefly_weave.contracts.connectors import ConnectionRequest, ConnectionRevision, ConnectionTestResult + from firefly_weave.contracts.deployments import ( + ApplyPlanRequest, + ComponentSpec, + Deployment, + DeploymentJob, + DeploymentLease, + DeploymentLeaseProof, + DeploymentObservation, + DeploymentPlan, + DeploymentRequest, + DeploymentRunner, + DeploymentTarget, + JobCancelRequest, + ObservationReport, + ObservedResource, + ObserveRequest, + PlanApproval, + PlanApprovalRequest, + PlanRequest, + PlanStep, + ReconcileJobRequest, + RunnerClaimRequest, + RunnerRegistration, + RunnerReport, + SafeDeploymentReceipt, + TargetRequest, + TargetUpdate, + ) from firefly_weave.contracts.email import ( EmailConversation, EmailConversationDetail, @@ -186,8 +214,10 @@ def contract_models() -> dict[str, SchemaModel]: TaskError, TaskLease, UnavailableCompletionReceipt, + WorkerControlRequest, WorkerInstance, WorkerRelease, + WorkerStatus, ) from firefly_weave.operations.debug.models import DebugCommand, DebugCreate, DebugSession, DebugView from firefly_weave.operations.redaction import SafeProjection @@ -195,6 +225,32 @@ def contract_models() -> dict[str, SchemaModel]: from firefly_weave.triggers.models import Trigger, TriggerReceipt, TriggerRequest return { + "operations-target-request": TargetRequest, + "operations-deployment-target": DeploymentTarget, + "operations-target-update": TargetUpdate, + "operations-component-spec": ComponentSpec, + "operations-deployment-request": DeploymentRequest, + "operations-deployment": Deployment, + "operations-observed-resource": ObservedResource, + "operations-observation-report": ObservationReport, + "operations-deployment-observation": DeploymentObservation, + "operations-plan-request": PlanRequest, + "operations-plan-step": PlanStep, + "operations-deployment-plan": DeploymentPlan, + "operations-plan-approval-request": PlanApprovalRequest, + "operations-plan-approval": PlanApproval, + "operations-apply-plan-request": ApplyPlanRequest, + "operations-observe-request": ObserveRequest, + "operations-job-cancel-request": JobCancelRequest, + "operations-safe-deployment-receipt": SafeDeploymentReceipt, + "operations-deployment-job": DeploymentJob, + "operations-runner-registration": RunnerRegistration, + "operations-deployment-runner": DeploymentRunner, + "operations-runner-claim-request": RunnerClaimRequest, + "operations-deployment-lease-proof": DeploymentLeaseProof, + "operations-deployment-lease": DeploymentLease, + "operations-runner-report": RunnerReport, + "operations-reconcile-job-request": ReconcileJobRequest, "human-file-create": HumanFileCreate, "human-file-command": HumanFileCommand, "human-file-chunk": HumanFileChunk, @@ -310,6 +366,8 @@ def contract_models() -> dict[str, SchemaModel]: "worker-release": WorkerRelease, "worker-instance-request": InstanceRequest, "worker-instance": WorkerInstance, + "worker-status": WorkerStatus, + "worker-control-request": WorkerControlRequest, "lease-proof": LeaseProof, "task-lease": TaskLease, "completion-receipt": CompletionReceipt, diff --git a/src/firefly_weave/contracts/surface.py b/src/firefly_weave/contracts/surface.py index 23b1567f..73f0f8cf 100644 --- a/src/firefly_weave/contracts/surface.py +++ b/src/firefly_weave/contracts/surface.py @@ -40,6 +40,29 @@ from firefly_weave.contracts.compatibility import CompatibilityReport from firefly_weave.contracts.connector_descriptors import AdapterName, ConnectorDescriptorView from firefly_weave.contracts.connectors import ConnectionRequest, ConnectionRevision, ConnectionTestResult +from firefly_weave.contracts.deployments import ( + ApplyPlanRequest, + Deployment, + DeploymentJob, + DeploymentLease, + DeploymentLeaseProof, + DeploymentObservation, + DeploymentPlan, + DeploymentRequest, + DeploymentRunner, + DeploymentTarget, + JobCancelRequest, + ObserveRequest, + PlanApproval, + PlanApprovalRequest, + PlanRequest, + ReconcileJobRequest, + RunnerClaimRequest, + RunnerRegistration, + RunnerReport, + TargetRequest, + TargetUpdate, +) from firefly_weave.contracts.email import ( EmailConversation, EmailConversationDetail, @@ -154,8 +177,10 @@ ReleaseRequest, TaskExecutionContext, TaskLease, + WorkerControlRequest, WorkerInstance, WorkerRelease, + WorkerStatus, ) from firefly_weave.operations.debug.models import DebugCommand, DebugCreate, DebugSession from firefly_weave.triggers.models import Trigger, TriggerReceipt, TriggerRequest @@ -249,6 +274,19 @@ def native(self) -> OpenAPIOperation: ] if self.id == "whatsapp_statuses.read": params.append(OpenAPIParameter("message_id", "query", MessageId)) + if self.page and self.id.startswith( + ( + "deployment_targets.", + "deployments.", + "deployment_observations.", + "deployment_plans.", + "deployment_jobs.", + "deployment_runners.", + ) + ): + params.append(OpenAPIParameter("target_id", "query", UUID, required=False)) + if self.id == "deployment_plans.list": + params.append(OpenAPIParameter("deployment_id", "query", UUID, required=False)) if self.page: params += [ OpenAPIParameter("limit", "query", PageLimit, required=False, default=50), @@ -365,6 +403,226 @@ def native(self) -> OpenAPIOperation: OPERATIONS = { item.id: item for item in ( + Operation( + "deployment_plans.approval", + ENVIRONMENT + "/deployment-plans/{identifier}/approval", + "GET", + PlanApproval | None, + "deployment.read", + ), + Operation( + "deployment_jobs.reconcile", + ENVIRONMENT + "/deployment-jobs/{identifier}/reconcile", + "POST", + DeploymentJob, + "deployment.apply and deployment.approve", + ReconcileJobRequest, + ), + Operation( + "deployment_targets.list", + ENVIRONMENT + "/deployment-targets", + "GET", + Page[DeploymentTarget], + "deployment.read", + page=True, + ), + Operation( + "deployment_targets.read", + ENVIRONMENT + "/deployment-targets/{identifier}", + "GET", + DeploymentTarget, + "deployment.read", + ), + Operation( + "deployments.list", ENVIRONMENT + "/deployments", "GET", Page[Deployment], "deployment.read", page=True + ), + Operation("deployments.read", ENVIRONMENT + "/deployments/{identifier}", "GET", Deployment, "deployment.read"), + Operation( + "deployment_observations.list", + ENVIRONMENT + "/deployment-observations", + "GET", + Page[DeploymentObservation], + "deployment.read", + page=True, + ), + Operation( + "deployment_observations.read", + ENVIRONMENT + "/deployment-observations/{identifier}", + "GET", + DeploymentObservation, + "deployment.read", + ), + Operation( + "deployment_plans.list", + ENVIRONMENT + "/deployment-plans", + "GET", + Page[DeploymentPlan], + "deployment.read", + page=True, + ), + Operation( + "deployment_plans.read", + ENVIRONMENT + "/deployment-plans/{identifier}", + "GET", + DeploymentPlan, + "deployment.read", + ), + Operation( + "deployment_jobs.list", + ENVIRONMENT + "/deployment-jobs", + "GET", + Page[DeploymentJob], + "deployment.read", + page=True, + ), + Operation( + "deployment_jobs.read", + ENVIRONMENT + "/deployment-jobs/{identifier}", + "GET", + DeploymentJob, + "deployment.read", + ), + Operation( + "deployment_runners.list", + ENVIRONMENT + "/deployment-runners", + "GET", + Page[DeploymentRunner], + "deployment.read", + page=True, + ), + Operation( + "deployment_runners.read", + ENVIRONMENT + "/deployment-runners/{identifier}", + "GET", + DeploymentRunner, + "deployment.read", + ), + Operation( + "deployment_targets.create", + ENVIRONMENT + "/deployment-targets", + "POST", + DeploymentTarget, + "target.manage", + TargetRequest, + (201,), + idempotency=True, + ), + Operation( + "deployments.create", + ENVIRONMENT + "/deployments", + "POST", + Deployment, + "target.manage", + DeploymentRequest, + (201,), + idempotency=True, + ), + Operation( + "deployment_observations.create", + ENVIRONMENT + "/deployment-observations", + "POST", + DeploymentJob, + "deployment.plan", + ObserveRequest, + (201,), + idempotency=True, + ), + Operation( + "deployment_plans.create", + ENVIRONMENT + "/deployment-plans", + "POST", + DeploymentPlan, + "deployment.plan", + PlanRequest, + (201,), + idempotency=True, + ), + Operation( + "deployment_targets.update", + ENVIRONMENT + "/deployment-targets/{identifier}", + "PUT", + DeploymentTarget, + "target.manage", + TargetUpdate, + revision="required", + etag=True, + ), + Operation( + "deployments.update", + ENVIRONMENT + "/deployments/{identifier}", + "PUT", + Deployment, + "target.manage", + DeploymentRequest, + revision="required", + etag=True, + ), + Operation( + "deployment_plans.approve", + ENVIRONMENT + "/deployment-plans/{identifier}/approve", + "POST", + PlanApproval, + "deployment.approve", + PlanApprovalRequest, + ), + Operation( + "deployment_plans.apply", + ENVIRONMENT + "/deployment-plans/{identifier}/apply", + "POST", + DeploymentJob, + "deployment.apply", + ApplyPlanRequest, + (201,), + idempotency=True, + ), + Operation( + "deployment_jobs.cancel", + ENVIRONMENT + "/deployment-jobs/{identifier}/cancel", + "POST", + DeploymentJob, + "deployment.cancel", + JobCancelRequest, + ), + Operation( + "deployment_runners.create", + ENVIRONMENT + "/deployment-runners", + "POST", + DeploymentRunner, + "runner.register", + RunnerRegistration, + (201,), + ), + Operation( + "deployment_runners.claim", + ENVIRONMENT + "/deployment-runners/claim", + "POST", + DeploymentLease | None, + "runner.claim", + RunnerClaimRequest, + ), + Operation( + "deployment_runners.renew", + ENVIRONMENT + "/deployment-runners/renew", + "POST", + DeploymentLease, + "runner.renew", + DeploymentLeaseProof, + ), + Operation( + "deployment_runners.report", + ENVIRONMENT + "/deployment-runners/report", + "POST", + DeploymentJob, + "runner.report", + RunnerReport, + ), + Operation( + "deployment_runners.revoke", + ENVIRONMENT + "/deployment-runners/{identifier}/revoke", + "POST", + DeploymentRunner, + "target.manage", + ), Operation( "human_files.create", ENVIRONMENT + "/human-tasks/{identifier}/files/create", @@ -1157,8 +1415,20 @@ def native(self) -> OpenAPIOperation: InstanceRequest, (201,), ), - Operation("workers.list", ENVIRONMENT + "/workers", "GET", Page[WorkerInstance], "status.read", page=True), - Operation("workers.read", ENVIRONMENT + "/workers/{identifier}", "GET", WorkerInstance, "status.read"), + Operation("workers.list", ENVIRONMENT + "/workers", "GET", Page[WorkerStatus], "status.read", page=True), + Operation("workers.read", ENVIRONMENT + "/workers/{identifier}", "GET", WorkerStatus, "status.read"), + *( + Operation( + "workers." + action, + ENVIRONMENT + "/workers/{identifier}/" + action, + "POST", + WorkerStatus, + "worker.drain", + WorkerControlRequest, + idempotency=True, + ) + for action in ("drain", "resume") + ), Operation("workers.revoke", ENVIRONMENT + "/workers/{identifier}/revoke", "POST", Revoked, "release.retire"), Operation( "workers.grant", diff --git a/src/firefly_weave/contracts/workers.py b/src/firefly_weave/contracts/workers.py index 4538cf72..a9dec108 100644 --- a/src/firefly_weave/contracts/workers.py +++ b/src/firefly_weave/contracts/workers.py @@ -75,6 +75,24 @@ class WorkerInstance(InstanceRequest): revoked: bool = False +class WorkerControlRequest(ContractModel): + expected_revision: int = Field(ge=1) + + +class WorkerStatus(WorkerInstance): + """A scoped observation, separate from the stable worker registration protocol.""" + + revision: int = Field(ge=1) + draining: bool + presence: Literal["unknown", "recent", "stale"] + last_seen_at: AwareDatetime | None + presence_expires_at: AwareDatetime | None + presence_ttl_seconds: Literal[60] = 60 + observed_at: AwareDatetime + active_leases: int = Field(ge=0) + available_capacity: int | None = Field(ge=0, le=100) + + class LeaseProof(ContractModel): task_id: UUID generation: int = Field(ge=1) diff --git a/src/firefly_weave/deployment_runner/__init__.py b/src/firefly_weave/deployment_runner/__init__.py new file mode 100644 index 00000000..c0400c4a --- /dev/null +++ b/src/firefly_weave/deployment_runner/__init__.py @@ -0,0 +1,17 @@ +# Copyright 2026 Firefly Software Foundation. +# +# 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. +# Author: Firefly Software Foundation +# SPDX-License-Identifier: Apache-2.0 + +"""Outbound deployment operations with locally held infrastructure authority.""" diff --git a/src/firefly_weave/deployment_runner/application.py b/src/firefly_weave/deployment_runner/application.py new file mode 100644 index 00000000..9f88e8ee --- /dev/null +++ b/src/firefly_weave/deployment_runner/application.py @@ -0,0 +1,138 @@ +# Copyright 2026 Firefly Software Foundation. +# +# 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. +# Author: Firefly Software Foundation +# SPDX-License-Identifier: Apache-2.0 + +"""Runner startup: private local authority and renewable dedicated machine identity.""" + +from __future__ import annotations + +import asyncio +import signal +from pathlib import Path +from typing import Self, cast +from urllib.parse import urlsplit + +from pydantic import Field, model_validator + +from firefly_weave.contracts.access import Scope +from firefly_weave.contracts.definitions import ContractModel +from firefly_weave.contracts.deployments import ( + DeploymentLease, + DeploymentRunner, + RunnerClaimRequest, + RunnerRegistration, +) +from firefly_weave.deployment_runner.policy import DestinationPolicy +from firefly_weave.deployment_runner.runtime import InfrastructureAdapter, OperationsRunner +from firefly_weave.sdk.client import AsyncTokenProvider, WeaveClient +from firefly_weave.sdk.worker_auth import ClientCredentialsTokenProvider, _read_mount + + +class RunnerConfiguration(ContractModel): + base_url: str = Field(min_length=1, max_length=2048) + scope: Scope + destination: DestinationPolicy + oauth_file: str | None = None + token_file: str | None = None + poll_seconds: float = Field(default=2, ge=1, le=60) + + @model_validator(mode="after") + def dedicated_credentials_and_scope(self) -> Self: + target = urlsplit(self.base_url) + if ( + target.scheme not in {"https", "http"} + or not target.hostname + or target.username + or target.password + or target.query + or target.fragment + or target.path not in {"", "/"} + or (target.scheme == "http" and target.hostname not in {"localhost", "127.0.0.1", "::1"}) + or self.scope.project_id is None + or self.scope.environment_id is None + or bool(self.oauth_file) == bool(self.token_file) + or any(value is not None and not Path(value).is_absolute() for value in (self.oauth_file, self.token_file)) + ): + raise ValueError("Use one mounted machine identity, an API origin, and a complete workspace") + return self + + +def load_configuration(path: Path) -> RunnerConfiguration: + return RunnerConfiguration.model_validate_json(_read_mount(path, 256 * 1024)) + + +def adapter_for(policy: DestinationPolicy) -> InfrastructureAdapter: + if policy.adapter == "docker-compose": + from firefly_weave.deployment_runner.compose import ComposeAdapter + + return ComposeAdapter(policy) + if policy.adapter == "kubernetes": + from firefly_weave.deployment_runner.kubernetes import KubernetesAdapter + + return KubernetesAdapter(policy) + from firefly_weave.deployment_runner.azure import AzureContainerAppsAdapter + + return AzureContainerAppsAdapter(policy) + + +class MountedToken: + """Rotatable short-lived machine token; no interactive profile or keychain access.""" + + def __init__(self, origin: str, path: Path) -> None: + self.origin, self.path = origin.rstrip("/"), path + + async def get_access_token(self, target: str) -> str: + if target.rstrip("/") != self.origin: + raise ValueError("Runner identity belongs to another API origin") + return _read_mount(self.path, 16384).strip() + + +async def serve(config: RunnerConfiguration, *, once: bool = False) -> None: + adapter = adapter_for(config.destination) + provider: AsyncTokenProvider + if config.oauth_file: + provider = ClientCredentialsTokenProvider.from_file(Path(config.oauth_file), config.base_url) + else: + assert config.token_file is not None + provider = MountedToken(config.base_url, Path(config.token_file)) + async with WeaveClient(config.base_url, provider, config.scope, timeout=20) as client: + registration = cast( + DeploymentRunner, + await client.invoke( + "deployment_runners.create", + body=RunnerRegistration( + target_id=config.destination.target_id, + adapter=config.destination.adapter, + capabilities=config.destination.capabilities, + ), + ), + ) + runner = OperationsRunner(client, config.destination, adapter) + loop, task = asyncio.get_running_loop(), asyncio.current_task() + assert task is not None + loop.add_signal_handler(signal.SIGTERM, task.cancel) + try: + while True: + lease = cast( + DeploymentLease | None, + await client.invoke("deployment_runners.claim", body=RunnerClaimRequest(runner_id=registration.id)), + ) + if lease is not None: + await runner.execute(lease) + if once: + return + await asyncio.sleep(config.poll_seconds) + finally: + loop.remove_signal_handler(signal.SIGTERM) diff --git a/src/firefly_weave/deployment_runner/azure.py b/src/firefly_weave/deployment_runner/azure.py new file mode 100644 index 00000000..44edecee --- /dev/null +++ b/src/firefly_weave/deployment_runner/azure.py @@ -0,0 +1,296 @@ +# Copyright 2026 Firefly Software Foundation. +# +# 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. +# Author: Firefly Software Foundation +# SPDX-License-Identifier: Apache-2.0 + +"""Allowlisted Azure Container Apps observation and serialized worker changes. + +ARM's Container Apps PATCH has no documented resource-version precondition. +This adapter rechecks drift under a local operator-shared lock and uses the +platform's target fence. Provider activity after an uncertain call must be +reconciled; it does not claim atomic exclusion against external Azure operators. +""" + +from __future__ import annotations + +import asyncio +import copy +import fcntl +import json +import os +import stat +import tempfile +from collections.abc import Awaitable, Callable +from typing import Any, cast +from uuid import UUID + +from firefly_weave.compiler.canonical import canonical_digest +from firefly_weave.contracts.deployments import DeploymentPlan, ObservationReport, ObservedResource +from firefly_weave.contracts.values import JsonObject +from firefly_weave.deployment_runner.command import run_command +from firefly_weave.deployment_runner.policy import ComponentBinding, DestinationPolicy +from firefly_weave.deployment_runner.runtime import RunnerPolicyError + +_SHOW = ( + "{id:id,environment:properties.environmentId,managedEnvironment:properties.managedEnvironmentId," + "mode:properties.configuration.activeRevisionsMode,state:properties.provisioningState," + "latest:properties.latestRevisionName,tags:tags,template:properties.template}" +) +_REVISIONS = ( + "[?properties.active].{name:name,replicas:properties.replicas,health:properties.healthState," + "state:properties.runningState,provisioning:properties.provisioningState," + "images:properties.template.containers[].image}" +) +_API = "2025-07-01" + + +class AzureContainerAppsAdapter: + def __init__(self, policy: DestinationPolicy, *, command: Callable[..., Awaitable[bytes]] = run_command) -> None: + if policy.adapter != "azure-container-apps" or not policy.lock_file: + raise RunnerPolicyError() + try: + UUID(policy.context) + except ValueError: + raise RunnerPolicyError() from None + self.policy, self.command = policy, command + + def _args(self, name: str) -> list[str]: + return [ + "--subscription", + self.policy.context, + "--resource-group", + self.policy.boundary, + "--name", + name, + "--only-show-errors", + "--output", + "json", + ] + + async def _read(self, binding: ComponentBinding) -> tuple[dict[str, Any], list[dict[str, Any]]]: + app = json.loads( + await self.command( + [self.policy.executable, "containerapp", "show", *self._args(binding.name), "--query", _SHOW] + ) + ) + expected = ( + f"/subscriptions/{self.policy.context}/resourceGroups/{self.policy.boundary}" + f"/providers/Microsoft.App/containerApps/{binding.name}" + ) + if ( + not isinstance(app, dict) + or str(app.get("id", "")).lower() != expected.lower() + or str(app.get("environment") or app.get("managedEnvironment", "")).lower() + != self.policy.external_identity.lower() + ): + raise RunnerPolicyError() + revisions = json.loads( + await self.command( + [ + self.policy.executable, + "containerapp", + "revision", + "list", + *self._args(binding.name), + "--query", + _REVISIONS, + ] + ) + ) + if ( + not isinstance(revisions, list) + or len(revisions) > 100 + or any(not isinstance(item, dict) for item in revisions) + ): + raise RunnerPolicyError() + return app, revisions + + def _fact( + self, binding: ComponentBinding, app: dict[str, Any], revisions: list[dict[str, Any]] + ) -> ObservedResource: + containers = [item for item in app["template"]["containers"] if item["name"] == binding.container] + if len(containers) != 1: + raise RunnerPolicyError() + image = containers[0]["image"] + immutable = image if self.policy.allows_image(image) else None + replicas = sum(item.get("replicas") or 0 for item in revisions) + ready = sum( + (item.get("replicas") or 0) + for item in revisions + if item.get("health") == "Healthy" and item.get("provisioning") == "Provisioned" + ) + stopped = not revisions + stable = app.get("state") == "Succeeded" and ( + stopped + or ( + len(revisions) == 1 + and revisions[0].get("name") == app.get("latest") + and ready == replicas + and revisions[0].get("health") == "Healthy" + and image in revisions[0].get("images", []) + ) + ) + if revisions and any(image not in item.get("images", []) for item in revisions): + immutable = None + return ObservedResource( + name=binding.name, + external_identity=app["id"], + kind=binding.kind, + image=immutable, + replicas=replicas, + ready_replicas=ready, + version=canonical_digest(cast(JsonObject, {"app": app, "revisions": revisions})), + ownership="managed" + if (app.get("tags") or {}).get("firefly-weave-target") == str(self.policy.target_id) + else "imported", + state="stopped" if stopped and stable else "ready" if stable else "progressing", + ) + + async def observe(self) -> ObservationReport: + resources = [] + for binding in self.policy.components: + app, revisions = await self._read(binding) + resources.append(self._fact(binding, app, revisions)) + return ObservationReport( + resources=resources, complete=True, settled=all(item.state in {"ready", "stopped"} for item in resources) + ) + + async def _patch(self, identifier: str, body: dict[str, Any]) -> None: + with tempfile.NamedTemporaryFile(mode="w", suffix=".json") as handle: + json.dump(body, handle) + handle.flush() + await self.command( + [ + self.policy.executable, + "rest", + "--method", + "PATCH", + "--url", + f"https://management.azure.com{identifier}?api-version={_API}", + "--body", + "@" + handle.name, + "--subscription", + self.policy.context, + "--query", + "{id:id}", + "--only-show-errors", + "--output", + "json", + ], + timeout=120, + ) + + async def apply(self, plan: DeploymentPlan, before_write: Callable[[], Awaitable[None]]) -> ObservationReport: + if ( + plan.target_id != self.policy.target_id + or plan.adapter != self.policy.adapter + or plan.intent not in {"update", "scale_workers"} + or plan.intent not in self.policy.capabilities + ): + raise RunnerPolicyError() + assert self.policy.lock_file is not None + descriptor = os.open(self.policy.lock_file, os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW, 0o600) + try: + if not stat.S_ISREG(os.fstat(descriptor).st_mode): + raise RunnerPolicyError() + fcntl.flock(descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB) + return await self._apply_locked(plan, before_write) + finally: + os.close(descriptor) + + async def _apply_locked( + self, plan: DeploymentPlan, before_write: Callable[[], Awaitable[None]] + ) -> ObservationReport: + bindings = {binding.name: binding for binding in self.policy.components} + prepared = [] + for step in plan.steps: + component = step.component + binding = bindings.get(component.name) + # ACA API revisions can overlap even in Single mode. Keep scheduler/API + # upgrades in the deployment runbook; don't imply singleton-safe rolling updates. + if ( + binding is None + or binding.kind not in {"worker", "lumi"} + or component.kind != binding.kind + or component.configuration != binding.configuration + or not self.policy.allows_image(component.image) + or step.action != plan.intent + or (step.action == "scale_workers" and component.kind != "worker") + ): + raise RunnerPolicyError() + app, revisions = await self._read(binding) + fact = self._fact(binding, app, revisions) + if ( + fact.version != step.expected_version + or app.get("mode") != "Single" + or fact.state not in {"ready", "stopped"} + ): + raise RunnerPolicyError() + if (app.get("tags") or {}).get("firefly-weave-target") not in {None, str(self.policy.target_id)}: + raise RunnerPolicyError() + template = copy.deepcopy(app["template"]) + scale = template.setdefault("scale", {}) + if scale.get("rules") or (step.action == "scale_workers" and component.image != fact.image): + raise RunnerPolicyError() + tags = {**(app.get("tags") or {}), "firefly-weave-target": str(self.policy.target_id)} + body: dict[str, Any] = {"tags": tags} + if component.replicas: + container = next(item for item in template["containers"] if item["name"] == binding.container) + if step.action == "update": + container["image"] = component.image + container["resources"] = { + **container.get("resources", {}), + "cpu": component.cpu_millis / 1000, + "memory": f"{component.memory_mib / 1024:g}Gi", + } + scale["minReplicas"] = scale["maxReplicas"] = component.replicas + template["revisionSuffix"] = "weave-" + plan.id.hex[:12] + body["properties"] = {"template": template} + elif step.action != "scale_workers": + raise RunnerPolicyError() + prepared.append((binding, step, app, revisions, body)) + for binding, step, app, revisions, body in prepared: + latest, active = await self._read(binding) + if self._fact(binding, latest, active).version != step.expected_version: + raise RunnerPolicyError() + await before_write() + await self._patch(app["id"], body) + if step.component.replicas == 0: + for revision in revisions: + await before_write() + await self.command( + [ + self.policy.executable, + "containerapp", + "revision", + "deactivate", + *self._args(binding.name), + "--revision", + revision["name"], + ], + timeout=120, + ) + async with asyncio.timeout(180): + while True: + report = await self.observe() + facts = {item.name: item for item in report.resources} + if report.settled and all( + facts[step.component.name].image == step.component.image + and facts[step.component.name].ready_replicas == step.component.replicas + and facts[step.component.name].replicas == step.component.replicas + and facts[step.component.name].ownership == "managed" + for step in plan.steps + ): + return report + await asyncio.sleep(2) diff --git a/src/firefly_weave/deployment_runner/command.py b/src/firefly_weave/deployment_runner/command.py new file mode 100644 index 00000000..9f2d2f55 --- /dev/null +++ b/src/firefly_weave/deployment_runner/command.py @@ -0,0 +1,126 @@ +# Copyright 2026 Firefly Software Foundation. +# +# 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. +# Author: Firefly Software Foundation +# SPDX-License-Identifier: Apache-2.0 + +"""Bounded infrastructure commands; provider diagnostics never cross the runner.""" + +from __future__ import annotations + +import asyncio +import math +import os +import signal +from contextlib import suppress +from pathlib import Path + + +class CommandError(Exception): + def __init__(self) -> None: + super().__init__("Infrastructure command failed; inspect the target using operator access.") + + +async def _reap(process: asyncio.subprocess.Process) -> None: + # The command owns a new session, including any child processes it creates. + with suppress(ProcessLookupError): + os.killpg(process.pid, signal.SIGKILL) + await process.wait() + + +async def _finish_cleanup(process: asyncio.subprocess.Process) -> None: + cleanup = asyncio.create_task(_reap(process)) + cancelled = False + while not cleanup.done(): + try: + await asyncio.shield(cleanup) + except asyncio.CancelledError: + cancelled = True + cleanup.result() + if cancelled: + raise asyncio.CancelledError + + +async def run_command( + argv: list[str], + *, + timeout: float = 30, + limit: int = 1024 * 1024, + stdin: bytes | None = None, + cwd: Path | None = None, + env: dict[str, str] | None = None, +) -> bytes: + """Run fixed adapter argv without a shell; bound all output, time, and input. + + This POSIX runner owns process cleanup even when the caller is cancelled. + Adapters, not API payloads, choose executables and argument structure. + """ + if ( + os.name != "posix" + or not argv + or len(argv) > 128 + or any(not isinstance(value, str) or len(value) > 4096 or "\0" in value for value in argv) + or not Path(argv[0]).is_absolute() + or not math.isfinite(timeout) + or not 0 < timeout <= 120 + or not 1 <= limit <= 1024 * 1024 + or (stdin is not None and len(stdin) > 256 * 1024) + ): + raise CommandError() + try: + process = await asyncio.create_subprocess_exec( + *argv, + stdin=asyncio.subprocess.PIPE if stdin is not None else asyncio.subprocess.DEVNULL, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + start_new_session=True, + cwd=cwd, + env=env, + ) + except (OSError, ValueError): + raise CommandError() from None + count = 0 + + async def consume(stream: asyncio.StreamReader, *, keep: bool) -> bytes: + nonlocal count + output = bytearray() + while chunk := await stream.read(16384): + count += len(chunk) + if count > limit: + raise CommandError() + if keep: + output.extend(chunk) + return bytes(output) + + async def feed() -> None: + if process.stdin is not None: + process.stdin.write(stdin or b"") + await process.stdin.drain() + process.stdin.close() + await process.stdin.wait_closed() + + try: + async with asyncio.timeout(timeout): + assert process.stdout is not None and process.stderr is not None + async with asyncio.TaskGroup() as tasks: + stdout = tasks.create_task(consume(process.stdout, keep=True)) + tasks.create_task(consume(process.stderr, keep=False)) + tasks.create_task(feed()) + status = await process.wait() + if status != 0: + raise CommandError() + return stdout.result() + except Exception: + raise CommandError() from None + finally: + await _finish_cleanup(process) diff --git a/src/firefly_weave/deployment_runner/compose.py b/src/firefly_weave/deployment_runner/compose.py new file mode 100644 index 00000000..a4bd454e --- /dev/null +++ b/src/firefly_weave/deployment_runner/compose.py @@ -0,0 +1,232 @@ +# Copyright 2026 Firefly Software Foundation. +# +# 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. +# Author: Firefly Software Foundation +# SPDX-License-Identifier: Apache-2.0 + +"""Compose adapter for a pinned daemon and an operator-owned project template.""" + +from __future__ import annotations + +import fcntl +import json +import os +import stat +import tempfile +from collections.abc import Awaitable, Callable +from pathlib import Path +from typing import Any, cast + +from firefly_weave.compiler.canonical import canonical_digest +from firefly_weave.contracts.deployments import DeploymentPlan, ObservationReport, ObservedResource +from firefly_weave.contracts.values import JsonObject +from firefly_weave.deployment_runner.command import run_command +from firefly_weave.deployment_runner.policy import DestinationPolicy +from firefly_weave.deployment_runner.runtime import RunnerPolicyError + +_INSPECT = ( + '{"id":{{json .Id}},"image":{{json .Config.Image}},"state":{{json .State.Status}},' + '"started":{{json .State.StartedAt}},"restart":{{json .RestartCount}},' + '"project":{{json (index .Config.Labels "com.docker.compose.project")}},' + '"service":{{json (index .Config.Labels "com.docker.compose.service")}},' + '"target":{{json (index .Config.Labels "firefly.weave.target")}},' + '"health":{{with (index .State "Health")}}{{json .Status}}{{else}}""{{end}}}' +) + + +class ComposeAdapter: + def __init__(self, policy: DestinationPolicy, *, command: Callable[..., Awaitable[bytes]] = run_command) -> None: + if policy.adapter != "docker-compose" or not policy.compose_file or not policy.lock_file: + raise RunnerPolicyError() + self.policy, self.command = policy, command + self.base = [policy.executable, "--context", policy.context] + self.compose = [ + *self.base, + "compose", + "--project-name", + policy.boundary, + "--project-directory", + str(Path(policy.compose_file).parent), + "--file", + policy.compose_file, + ] + # This private override is the durable configuration of zero-replica services. + # Its path is derived only from the operator's configured lock, never the API. + self.overrides = Path(policy.lock_file + ".compose.json") + + async def _json(self, argv: list[str], **kwargs: Any) -> Any: + return json.loads(await self.command(argv, **kwargs)) + + async def _identity(self) -> None: + if await self._json([*self.base, "info", "--format", "{{json .ID}}"]) != self.policy.external_identity: + raise RunnerPolicyError() + + def _files(self, override: Path | None = None) -> list[str]: + path = override or self.overrides + return [*self.compose, *(["--file", str(path)] if path.exists() else [])] + + async def _configuration(self, override: Path | None = None) -> dict[str, Any]: + result = await self._json([*self._files(override), "config", "--format", "json"]) + services = result.get("services") + allowed = {binding.name for binding in self.policy.components} + if not isinstance(services, dict) or set(services) != allowed: + raise RunnerPolicyError() + if any( + spec.get("labels", {}).get("firefly.weave.target") not in {None, str(self.policy.target_id)} + for spec in services.values() + ): + raise RunnerPolicyError() + return cast(dict[str, Any], result) + + async def observe(self) -> ObservationReport: + await self._identity() + configuration = await self._configuration() + raw = await self.command([*self._files(), "ps", "--all", "--no-trunc", "--format", "json"]) + # Compose versions return either an array or one JSON object per line; + # an empty project emits no bytes rather than an empty array. + inventory = ( + json.loads(raw) + if raw.lstrip().startswith(b"[") + else [json.loads(line) for line in raw.splitlines() if line.strip()] + ) + if not isinstance(inventory, list) or len(inventory) > 1000: + raise RunnerPolicyError() + bindings = {binding.name: binding for binding in self.policy.components} + groups: dict[str, list[dict[str, Any]]] = {name: [] for name in bindings} + for entry in inventory: + name, identifier = entry.get("Service"), entry.get("ID") + if ( + name not in groups + or not isinstance(identifier, str) + or len(identifier) != 64 + or any(c not in "0123456789abcdef" for c in identifier) + ): + raise RunnerPolicyError() + fact = await self._json([*self.base, "inspect", "--format", _INSPECT, identifier]) + if ( + fact.get("project") != self.policy.boundary + or fact.get("service") != name + or fact.get("id") != identifier + or fact.get("target") not in {None, "", str(self.policy.target_id)} + ): + raise RunnerPolicyError() + groups[name].append(fact) + resources = [] + settled = True + for name, binding in bindings.items(): + members = sorted(groups[name], key=lambda member: member["id"]) + spec = configuration["services"][name] + image = spec.get("image") + images = {member.get("image") for member in members} + if images and images != {image}: + image = None + if not isinstance(image, str) or not self.policy.allows_image(image): + image = None + ready = sum(item.get("state") == "running" and item.get("health") in {"", "healthy"} for item in members) + desired = spec.get("deploy", {}).get("replicas", 1) + stopped = not members and desired == 0 + stable = stopped or (ready == len(members) == desired) + settled = settled and stable + managed = spec.get("labels", {}).get("firefly.weave.target") == str(self.policy.target_id) and all( + item.get("target") == str(self.policy.target_id) for item in members + ) + resources.append( + ObservedResource( + name=name, + external_identity=f"compose/{self.policy.boundary}/{name}", + kind=binding.kind, + image=image, + replicas=len(members), + ready_replicas=ready, + version=canonical_digest(cast(JsonObject, {"configuration": configuration, "members": members})), + ownership="managed" if managed else "imported", + state="stopped" if stopped else "ready" if stable else "progressing", + ) + ) + return ObservationReport(resources=resources, complete=True, settled=settled) + + async def apply(self, plan: DeploymentPlan, before_write: Callable[[], Awaitable[None]]) -> ObservationReport: + assert self.policy.lock_file is not None + descriptor = os.open(self.policy.lock_file, os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW, 0o600) + try: + if not stat.S_ISREG(os.fstat(descriptor).st_mode): + raise RunnerPolicyError() + fcntl.flock(descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB) + return await self._apply_locked(plan, before_write) + finally: + os.close(descriptor) + + async def _apply_locked( + self, plan: DeploymentPlan, before_write: Callable[[], Awaitable[None]] + ) -> ObservationReport: + current = await self.observe() + observed = {resource.name: resource for resource in current.resources} + if self.overrides.exists(): + if self.overrides.stat().st_size > 256 * 1024: + raise RunnerPolicyError() + override = json.loads(self.overrides.read_text()) + else: + override = {"services": {}} + scales = [] + for step in plan.steps: + component = step.component + fact = observed.get(component.name) + if step.action not in self.policy.capabilities or fact is None or step.expected_version != fact.version: + raise RunnerPolicyError() + if step.action == "scale_workers" and (component.kind != "worker" or component.image != fact.image): + raise RunnerPolicyError() + spec = override["services"].setdefault(component.name, {}) + spec.setdefault("labels", {})["firefly.weave.target"] = str(self.policy.target_id) + spec.setdefault("deploy", {})["replicas"] = component.replicas + if step.action in {"deploy", "update"}: + spec["image"] = component.image + spec["cpus"] = component.cpu_millis / 1000 + spec["mem_limit"] = f"{component.memory_mib}M" + spec["deploy"].setdefault("resources", {})["limits"] = { + "cpus": str(component.cpu_millis / 1000), + "memory": f"{component.memory_mib}M", + } + scales += ["--scale", f"{component.name}={component.replicas}"] + with tempfile.NamedTemporaryFile(mode="w", dir=self.overrides.parent, suffix=".json", delete=False) as handle: + candidate = Path(handle.name) + json.dump(override, handle) + try: + await self._configuration(candidate) + arguments = [ + "up", + "--detach", + "--no-deps", + "--wait", + "--wait-timeout", + "90", + *scales, + *[step.component.name for step in plan.steps], + ] + await self.command([*self._files(candidate), "--dry-run", *arguments], timeout=120) + latest = await self.observe() + if latest != current: + raise RunnerPolicyError() + await before_write() + os.replace(candidate, self.overrides) + await self.command([*self._files(), *arguments], timeout=120) + result = await self.observe() + facts = {fact.name: fact for fact in result.resources} + if not result.settled or any( + facts[step.component.name].image != step.component.image + or facts[step.component.name].ready_replicas != step.component.replicas + for step in plan.steps + ): + raise RunnerPolicyError() + return result + finally: + candidate.unlink(missing_ok=True) diff --git a/src/firefly_weave/deployment_runner/kubernetes.py b/src/firefly_weave/deployment_runner/kubernetes.py new file mode 100644 index 00000000..8efd12ce --- /dev/null +++ b/src/firefly_weave/deployment_runner/kubernetes.py @@ -0,0 +1,299 @@ +# Copyright 2026 Firefly Software Foundation. +# +# 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. +# Author: Firefly Software Foundation +# SPDX-License-Identifier: Apache-2.0 + +"""Pinned-namespace Kubernetes observation and conditional changes to existing deployments.""" + +from __future__ import annotations + +import asyncio +import json +import re +from collections.abc import Awaitable, Callable +from decimal import Decimal +from typing import Any + +from firefly_weave.contracts.deployments import ComponentSpec, DeploymentPlan, ObservationReport, ObservedResource +from firefly_weave.deployment_runner.command import run_command +from firefly_weave.deployment_runner.policy import ComponentBinding, DestinationPolicy +from firefly_weave.deployment_runner.runtime import RunnerPolicyError + +TARGET_LABEL = "firefly.weave/target" +CONFIGURATION_ANNOTATION = "firefly.weave/configuration" + + +def _quantity(value: Any) -> Decimal: + match = re.fullmatch(r"([0-9]+(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?)([numkKMGTPE]|[KMGTPE]i)?", str(value)) + if match is None or len(str(value)) > 64: + raise RunnerPolicyError() + suffix = match[2] or "" + factors: dict[str, Decimal | int] = { + "": 1, + "n": Decimal("1e-9"), + "u": Decimal("1e-6"), + "m": Decimal("1e-3"), + "k": 1000, + } + factors.update({unit: 1000**power for power, unit in enumerate("KMGTPE", 1)}) + factors.update({unit + "i": 1024**power for power, unit in enumerate("KMGTPE", 1)}) + return Decimal(match[1]) * factors[suffix] + + +class KubernetesAdapter: + def __init__(self, policy: DestinationPolicy, *, command: Callable[..., Awaitable[bytes]] = run_command) -> None: + if policy.adapter != "kubernetes": + raise RunnerPolicyError() + self.policy, self.command = policy, command + + async def _json(self, *args: str, stdin: bytes | None = None) -> dict[str, Any]: + raw = await self.command( + [ + self.policy.executable, + "--context", + self.policy.context, + "--namespace", + self.policy.boundary, + "--request-timeout=20s", + *args, + ], + stdin=stdin, + timeout=30, + limit=1024 * 1024, + ) + try: + value = json.loads(raw) if raw.strip() else {} + if not isinstance(value, dict): + raise ValueError() + return value + except (ValueError, TypeError): + raise RunnerPolicyError() from None + + async def _identity(self) -> None: + namespace = await self._json("get", "namespace", self.policy.boundary, "-o=json") + metadata = namespace.get("metadata", {}) + if metadata.get("name") != self.policy.boundary or metadata.get("uid") != self.policy.external_identity: + raise RunnerPolicyError() + + async def _deployment(self, binding: ComponentBinding) -> dict[str, Any]: + value = await self._json("get", "deployments.apps", binding.name, "--ignore-not-found=true", "-o=json") + if value: + metadata = value.get("metadata", {}) + if ( + value.get("apiVersion") != "apps/v1" + or value.get("kind") != "Deployment" + or metadata.get("name") != binding.name + or metadata.get("namespace") != self.policy.boundary + or not metadata.get("uid") + or not metadata.get("resourceVersion") + ): + raise RunnerPolicyError() + return value + + def _container(self, value: dict[str, Any], binding: ComponentBinding) -> tuple[int, dict[str, Any]]: + containers = value.get("spec", {}).get("template", {}).get("spec", {}).get("containers", []) + found = [(i, item) for i, item in enumerate(containers) if item.get("name") == binding.container] + if len(found) != 1: + raise RunnerPolicyError() + return found[0] + + def _fact(self, value: dict[str, Any], binding: ComponentBinding) -> ObservedResource: + _, container = self._container(value, binding) + metadata, spec, status = value["metadata"], value["spec"], value.get("status", {}) + desired = spec.get("replicas", 1) + replicas = max(desired, status.get("replicas", 0)) + ready = status.get("readyReplicas", 0) + generation = metadata.get("generation") + settled = ( + isinstance(generation, int) + and status.get("observedGeneration", -1) >= generation + and replicas == desired + and ready == desired + and status.get("updatedReplicas", 0) == desired + and status.get("availableReplicas", 0) == desired + ) + image = container.get("image", "") + # An imported tag is observable, but cannot be presented as an immutable release. + immutable = image if self.policy.allows_image(image) else None + return ObservedResource( + name=binding.name, + external_identity="deployment/" + str(metadata["uid"]), + kind=binding.kind, + image=immutable, + replicas=replicas, + ready_replicas=ready, + version=metadata["resourceVersion"], + ownership="managed" + if metadata.get("labels", {}).get(TARGET_LABEL) == str(self.policy.target_id) + else "imported", + state=("stopped" if desired == 0 else "ready") if settled else "progressing", + ) + + async def observe(self) -> ObservationReport: + await self._identity() + resources = [] + for binding in self.policy.components: + value = await self._deployment(binding) + if value: + resources.append(self._fact(value, binding)) + return ObservationReport( + resources=resources, complete=True, settled=all(item.state in {"ready", "stopped"} for item in resources) + ) + + async def _singleton_policy(self, binding: ComponentBinding) -> None: + if binding.kind != "api": + return + autoscalers = await self._json("get", "horizontalpodautoscalers.autoscaling", "--chunk-size=101", "-o=json") + items = autoscalers.get("items") + if not isinstance(items, list) or len(items) > 100 or autoscalers.get("metadata", {}).get("continue"): + raise RunnerPolicyError() + if any( + item.get("spec", {}).get("scaleTargetRef", {}).get("name") == binding.name + and item.get("spec", {}).get("scaleTargetRef", {}).get("kind") == "Deployment" + for item in items + ): + raise RunnerPolicyError() + + async def apply(self, plan: DeploymentPlan, before_write: Callable[[], Awaitable[None]]) -> ObservationReport: + if ( + plan.target_id != self.policy.target_id + or plan.adapter != "kubernetes" + or plan.intent not in {"update", "scale_workers"} + or plan.intent not in self.policy.capabilities + ): + raise RunnerPolicyError() + await self._identity() + bindings = {item.name: item for item in self.policy.components} + prepared = [] + for step in plan.steps: + component = step.component + binding = bindings.get(component.name) + if ( + binding is None + or binding.kind != component.kind + or binding.configuration != component.configuration + or step.action != plan.intent + or not self.policy.allows_image(component.image) + or (step.action == "scale_workers" and component.kind != "worker") + ): + raise RunnerPolicyError() + await self._singleton_policy(binding) + value = await self._deployment(binding) + if not value or value["metadata"]["resourceVersion"] != step.expected_version: + raise RunnerPolicyError() + index, container = self._container(value, binding) + spec = value["spec"] + if ( + value["metadata"].get("labels", {}).get(TARGET_LABEL) not in {None, str(self.policy.target_id)} + or spec["template"].get("metadata", {}).get("annotations", {}).get(CONFIGURATION_ANNOTATION) + != binding.configuration + or ( + component.kind == "api" + and (spec.get("strategy", {}).get("type") != "Recreate" or spec.get("replicas", 1) != 1) + ) + or (step.action == "scale_workers" and container.get("image") != component.image) + ): + raise RunnerPolicyError() + patch: list[dict[str, Any]] = [ + {"op": "test", "path": "/metadata/uid", "value": value["metadata"]["uid"]}, + {"op": "test", "path": "/metadata/resourceVersion", "value": step.expected_version}, + ] + if "labels" not in value["metadata"]: + patch.append({"op": "add", "path": "/metadata/labels", "value": {}}) + patch.extend( + [ + { + "op": "add", + "path": "/metadata/labels/firefly.weave~1target", + "value": str(self.policy.target_id), + }, + {"op": "add", "path": "/spec/replicas", "value": component.replicas}, + ] + ) + base = f"/spec/template/spec/containers/{index}" + if step.action == "update": + amounts = {"cpu": f"{component.cpu_millis}m", "memory": f"{component.memory_mib}Mi"} + resources = container.get("resources", {}) + patch.extend( + [ + {"op": "add", "path": base + "/image", "value": component.image}, + { + "op": "add", + "path": base + "/resources", + "value": { + **resources, + "requests": {**resources.get("requests", {}), **amounts}, + "limits": {**resources.get("limits", {}), **amounts}, + }, + }, + ] + ) + else: + patch.append({"op": "test", "path": base + "/image", "value": component.image}) + prepared.append((binding, component, json.dumps(patch, separators=(",", ":")).encode())) + # Validate all components before the first effect; each actual write still has server-side preconditions. + for binding, component, patch_bytes in prepared: + predicted = await self._patch(binding, patch_bytes, dry=True) + self._desired(predicted, binding, component, resources=plan.intent == "update") + for binding, component, patch_bytes in prepared: + await self._identity() + await self._singleton_policy(binding) + await before_write() + applied = await self._patch(binding, patch_bytes, dry=False) + self._desired(applied, binding, component, resources=plan.intent == "update") + async with asyncio.timeout(90): + while True: + result = await self.observe() + for binding, component, _ in prepared: + self._desired( + await self._deployment(binding), binding, component, resources=plan.intent == "update" + ) + facts = {item.name: item for item in result.resources} + if all( + step.component.name in facts + and facts[step.component.name].image == step.component.image + and facts[step.component.name].replicas == step.component.replicas + and facts[step.component.name].ready_replicas == step.component.replicas + and facts[step.component.name].ownership == "managed" + and facts[step.component.name].state in {"ready", "stopped"} + for step in plan.steps + ): + return result + await asyncio.sleep(1) + + def _desired( + self, value: dict[str, Any], binding: ComponentBinding, component: ComponentSpec, *, resources: bool + ) -> None: + _, container = self._container(value, binding) + if ( + (binding.kind == "api" and value.get("spec", {}).get("strategy", {}).get("type") != "Recreate") + or container.get("image") != component.image + or value.get("spec", {}).get("replicas") != component.replicas + or value.get("metadata", {}).get("labels", {}).get(TARGET_LABEL) != str(self.policy.target_id) + ): + raise RunnerPolicyError() + if resources: + for field in ("requests", "limits"): + amounts = container.get("resources", {}).get(field, {}) + if ( + _quantity(amounts.get("cpu")) != Decimal(component.cpu_millis) / 1000 + or _quantity(amounts.get("memory")) != component.memory_mib * 1024**2 + ): + raise RunnerPolicyError() + + async def _patch(self, binding: ComponentBinding, patch: bytes, *, dry: bool) -> dict[str, Any]: + args = ["patch", "deployments.apps", binding.name, "--type=json", "--patch-file=/dev/stdin", "-o=json"] + if dry: + args.append("--dry-run=server") + return await self._json(*args, stdin=patch) diff --git a/src/firefly_weave/deployment_runner/policy.py b/src/firefly_weave/deployment_runner/policy.py new file mode 100644 index 00000000..1d8e1120 --- /dev/null +++ b/src/firefly_weave/deployment_runner/policy.py @@ -0,0 +1,104 @@ +# Copyright 2026 Firefly Software Foundation. +# +# 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. +# Author: Firefly Software Foundation +# SPDX-License-Identifier: Apache-2.0 + +"""Local runner authority, independent of the platform's requested operations.""" + +from pathlib import Path +from typing import Literal, Self +from uuid import UUID + +from pydantic import Field, model_validator + +from firefly_weave.contracts.definitions import ContractModel +from firefly_weave.contracts.deployments import ( + AdapterKind, + DeploymentCapability, + DeploymentName, + ExternalIdentity, +) + + +class ComponentBinding(ContractModel): + name: DeploymentName + kind: Literal["api", "worker", "lumi"] + configuration: DeploymentName + container: DeploymentName | None = None + max_replicas: int = Field(default=10, ge=1, le=100) + max_cpu_millis: int = Field(default=4000, ge=100, le=16000) + max_memory_mib: int = Field(default=8192, ge=128, le=65536) + + +class DestinationPolicy(ContractModel): + target_id: UUID + adapter: AdapterKind + external_identity: ExternalIdentity + boundary: DeploymentName + executable: str = Field(min_length=1, max_length=4096) + context: DeploymentName + capabilities: list[DeploymentCapability] = Field(min_length=1, max_length=5) + components: list[ComponentBinding] = Field(min_length=1, max_length=100) + image_repositories: list[str] = Field(min_length=1, max_length=100) + compose_file: str | None = None + lock_file: str | None = None + + @model_validator(mode="after") + def bounded_local_authority(self) -> Self: + if not Path(self.executable).is_absolute() or "\0" in self.executable: + raise ValueError("An absolute operator-owned executable is required") + supported = {"observe", "update", "scale_workers"} + if self.adapter == "azure-container-apps" and not self.lock_file: + raise ValueError("ACA requires a shared local runner lock") + if self.adapter == "docker-compose": + supported.add("deploy") + if not self.compose_file or not self.lock_file: + raise ValueError("Compose requires an operator-owned file and shared lock path") + if "observe" not in self.capabilities or not set(self.capabilities) <= supported: + raise ValueError("Only implemented capabilities including observe may be enabled") + if len(set(self.capabilities)) != len(self.capabilities): + raise ValueError("Capabilities must be distinct") + if len({item.name for item in self.components}) != len(self.components): + raise ValueError("Local component bindings must be distinct") + if self.adapter in {"kubernetes", "azure-container-apps"} and any( + not item.container for item in self.components + ): + raise ValueError("Kubernetes requires an explicit container for each deployment") + for path in (self.compose_file, self.lock_file): + if path is not None and (not Path(path).is_absolute() or "\0" in path): + raise ValueError("Runner paths must be absolute") + for repository in self.image_repositories: + if ( + not repository + or len(repository) > 440 + or "://" in repository + or any(part in {"", ".", ".."} for part in repository.split("/")) + or any( + char not in "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789._:/-" + for char in repository + ) + or "@" in repository + or repository.startswith("-") + ): + raise ValueError("An exact OCI repository allowlist is required") + return self + + def allows_image(self, reference: str) -> bool: + repository, separator, digest = reference.partition("@sha256:") + return bool( + separator + and repository in self.image_repositories + and len(digest) == 64 + and all(char in "0123456789abcdef" for char in digest) + ) diff --git a/src/firefly_weave/deployment_runner/runtime.py b/src/firefly_weave/deployment_runner/runtime.py new file mode 100644 index 00000000..d1c998cb --- /dev/null +++ b/src/firefly_weave/deployment_runner/runtime.py @@ -0,0 +1,231 @@ +# Copyright 2026 Firefly Software Foundation. +# +# 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. +# Author: Firefly Software Foundation +# SPDX-License-Identifier: Apache-2.0 + +"""Outbound, leased Operations execution with an independent local policy.""" + +from __future__ import annotations + +import asyncio +from collections.abc import Awaitable, Callable +from datetime import UTC, datetime +from typing import Protocol, cast +from uuid import uuid4 + +from firefly_weave.compiler.canonical import canonical_digest +from firefly_weave.contracts.deployments import ( + DeploymentLease, + DeploymentPlan, + ObservationReport, + RunnerReport, + SafeDeploymentReceipt, +) +from firefly_weave.deployment_runner.policy import DestinationPolicy +from firefly_weave.sdk.client import WeaveClient + + +class RunnerPolicyError(Exception): + def __init__(self) -> None: + super().__init__("The operation does not match the runner's local authority or current observation.") + + +class InfrastructureAdapter(Protocol): + async def observe(self) -> ObservationReport: ... + async def apply(self, plan: DeploymentPlan, before_write: Callable[[], Awaitable[None]]) -> ObservationReport: ... + + +def validate_lease(lease: DeploymentLease, policy: DestinationPolicy) -> None: + now = datetime.now(UTC) + target, job = lease.target, lease.job + if ( + target.id != policy.target_id + or target.disabled + or target.adapter != policy.adapter + or target.external_identity != policy.external_identity + or target.boundary != policy.boundary + or target.id != job.target_id + or target.revision != job.target_revision + or target.scope != job.scope + or lease.proof.job_id != job.id + or min(lease.expires_at, job.deadline) <= now + or "observe" not in target.capabilities + or "observe" not in policy.capabilities + ): + raise RunnerPolicyError() + if job.kind == "observe": + return + plan, deployment, observed = lease.plan, lease.deployment, lease.observation + if plan is None or deployment is None or observed is None: + raise RunnerPolicyError() + if ( + plan.id != job.plan_id + or plan.digest != job.plan_digest + or plan.target_id != target.id + or plan.target_revision != target.revision + or plan.scope != job.scope + or deployment.scope != job.scope + or plan.deployment_id != deployment.id + or plan.deployment_revision != deployment.revision + or deployment.target_id != target.id + or (deployment.ownership != "managed" and "adoption" not in plan.risks) + or plan.adapter != policy.adapter + or plan.adapter_version != "1" + or plan.intent not in policy.capabilities + or plan.intent not in target.capabilities + or (job.state == "claimed" and plan.expires_at <= now) + or plan.digest != canonical_digest(plan.model_dump(mode="json", exclude={"digest"})) + or observed.id != plan.observation_id + or observed.digest != plan.observation_digest + or observed.target_id != target.id + or observed.target_revision != target.revision + or not observed.complete + or (job.state == "claimed" and observed.expires_at <= now) + or observed.digest != canonical_digest(observed.model_dump(mode="json", exclude={"digest"})) + or len({step.component.name for step in plan.steps}) != len(plan.steps) + ): + raise RunnerPolicyError() + bindings = {item.name: item for item in policy.components} + desired = {item.name: item for item in deployment.components} + resources = {item.name: item for item in observed.resources} + if len(resources) != len(observed.resources): + raise RunnerPolicyError() + for step in plan.steps: + component = step.component + binding, resource = bindings.get(component.name), resources.get(component.name) + if ( + binding is None + or step.action != plan.intent + or desired.get(component.name) != component + or binding.kind != component.kind + or binding.configuration != component.configuration + or component.replicas > binding.max_replicas + or component.cpu_millis > binding.max_cpu_millis + or component.memory_mib > binding.max_memory_mib + or not policy.allows_image(component.image) + or (step.action == "scale_workers" and component.kind != "worker") + or (step.expected_version is None and resource is not None) + or (step.expected_version is not None and (resource is None or resource.version != step.expected_version)) + ): + raise RunnerPolicyError() + + +class OperationsRunner: + """Every effect has one owner; lease loss cancels it and requires reconciliation. + + Provider requests cannot be atomically committed with PostgreSQL. An unknown + result is therefore reported as ambiguous, never automatically retried. + """ + + def __init__( + self, + client: WeaveClient, + policy: DestinationPolicy, + adapter: InfrastructureAdapter, + *, + renew_interval: float = 15, + ) -> None: + if not 0 < renew_interval <= 15: + raise ValueError("Renew within the bounded lease window") + self.client, self.policy, self.adapter = client, policy, adapter + self.renew_interval = renew_interval + + async def execute(self, initial: DeploymentLease) -> None: + lease = initial + effects_started = False + done = asyncio.Event() + renewal_lock = asyncio.Lock() + + async def report( + state: str, *, receipt: SafeDeploymentReceipt | None = None, observation: ObservationReport | None = None + ) -> None: + request = RunnerReport.model_validate( + { + "lease": lease.proof, + "report_id": uuid4(), + "state": state, + "receipt": receipt, + "observation": observation, + } + ) + await self.client.invoke("deployment_runners.report", body=request) + + async def renew() -> None: + nonlocal lease + async with renewal_lock: + refreshed = cast( + DeploymentLease, await self.client.invoke("deployment_runners.renew", body=lease.proof) + ) + if refreshed.proof != lease.proof: + raise RunnerPolicyError() + validate_lease(refreshed, self.policy) + lease = refreshed + + async def before_write() -> None: + nonlocal effects_started + await renew() + effects_started = True + + async def heartbeat() -> None: + while not done.is_set(): + try: + await asyncio.wait_for(done.wait(), self.renew_interval) + except TimeoutError: + await renew() + + async def work() -> ObservationReport: + try: + if lease.job.kind == "observe": + return await self.adapter.observe() + await report("running") + assert lease.plan is not None + result = await self.adapter.apply(lease.plan, before_write) + await report("verifying") + return result + finally: + done.set() + + try: + validate_lease(lease, self.policy) + timeout = min(600.0, (lease.job.deadline - datetime.now(UTC)).total_seconds()) + async with asyncio.timeout(timeout), asyncio.TaskGroup() as tasks: + tasks.create_task(heartbeat()) + result = tasks.create_task(work()) + observed = result.result() + if not observed.complete: + raise RunnerPolicyError() + await report( + "succeeded", + observation=observed, + receipt=SafeDeploymentReceipt( + code="observed" if lease.job.kind == "observe" else "applied", + changed_resources=[ + item.external_identity + for item in observed.resources + if lease.plan and item.name in {step.component.name for step in lease.plan.steps} + ], + ), + ) + except asyncio.CancelledError: + # The durable lease expires to reconciliation if the caller is stopping; + # don't delay cancellation with an unowned best-effort HTTP request. + raise + except Exception: + await report( + "reconciliation_required" if effects_started else "failed", + receipt=SafeDeploymentReceipt( + code="ambiguous" if effects_started else "precondition_failed", + external_effects_may_continue=effects_started, + ), + ) diff --git a/src/firefly_weave/deployments/__init__.py b/src/firefly_weave/deployments/__init__.py new file mode 100644 index 00000000..f7baff60 --- /dev/null +++ b/src/firefly_weave/deployments/__init__.py @@ -0,0 +1,17 @@ +# Copyright 2026 Firefly Software Foundation. +# +# 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. +# Author: Firefly Software Foundation +# SPDX-License-Identifier: Apache-2.0 + +"""Typed deployment control plane; external effects belong to scoped runners.""" diff --git a/src/firefly_weave/deployments/leases.py b/src/firefly_weave/deployments/leases.py new file mode 100644 index 00000000..6d4944bb --- /dev/null +++ b/src/firefly_weave/deployments/leases.py @@ -0,0 +1,353 @@ +# Copyright 2026 Firefly Software Foundation. +# +# 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. +# Author: Firefly Software Foundation +# SPDX-License-Identifier: Apache-2.0 + +"""Runner-owned fenced work; lease loss never blindly repeats external effects.""" + +import json +from datetime import datetime, timedelta +from typing import TYPE_CHECKING, Any +from uuid import UUID, uuid4 + +from sqlalchemy import text + +from firefly_weave.access.audit import AuditContext +from firefly_weave.access.authorization import AccessDenied +from firefly_weave.access.models import Principal +from firefly_weave.compiler.canonical import canonical_digest +from firefly_weave.contracts.access import Scope +from firefly_weave.contracts.deployments import ( + Deployment, + DeploymentJob, + DeploymentLease, + DeploymentLeaseProof, + DeploymentObservation, + DeploymentPlan, + DeploymentRunner, + DeploymentTarget, + RunnerClaimRequest, + RunnerRegistration, + RunnerReport, +) +from firefly_weave.definitions.models import CatalogError +from firefly_weave.deployments.repository import SCOPE, DeploymentRepository + +if TYPE_CHECKING: + from firefly_weave.deployments.service import DeploymentService + + +class DeploymentLeases: + def __init__(self, service: "DeploymentService"): + self.service = service + + async def registration( + self, request: RunnerRegistration, *, actor: Principal, scope: Scope, context: AuditContext + ) -> DeploymentRunner: + async with self.service.uow.open(scope) as tx: + actor = await self.service.authorize(tx, actor, "runner.register", context, request.target_id) + repo = DeploymentRepository(tx) + target = await self.service.target(repo, request.target_id, lock=True) + if ( + actor.kind != "application" + or actor.id != target.runner_principal_id + or request.adapter != target.adapter + or not set(request.capabilities) <= set(target.capabilities) + or len(set(request.capabilities)) != len(request.capabilities) + ): + raise AccessDenied() + now = await repo.now() + rows = await repo.rows( + f"SELECT payload FROM deployment_runners WHERE {SCOPE} AND target_id=:target " + "AND payload->>'principal_id'=:principal ORDER BY id", + target=target.id, + principal=str(actor.id), + ) + for row in rows: + prior = DeploymentRunner.model_validate_json(json.dumps(row["payload"])) + if ( + not prior.revoked + and prior.adapter == request.adapter + and prior.adapter_version == request.adapter_version + and set(prior.capabilities) == set(request.capabilities) + ): + current = prior.model_copy(update={"last_seen": now, "expires_at": now + timedelta(seconds=90)}) + await repo.update("runners", prior.id, current) + await self.service.record(tx, actor, "runner.register", prior.id, context) + return current + value = DeploymentRunner( + **request.model_dump(), + id=uuid4(), + principal_id=actor.id, + last_seen=now, + expires_at=now + timedelta(seconds=90), + ) + await repo.insert("runners", value.id, target.id, value) + await self.service.record(tx, actor, "runner.register", value.id, context) + return value + + async def runner( + self, repo: DeploymentRepository, identifier: UUID, actor: Principal, capability: str, context: AuditContext + ) -> tuple[DeploymentRunner, DeploymentTarget]: + runner = await repo.get("runners", identifier, DeploymentRunner, lock=True) + await self.service.authorize(repo.tx, actor, capability, context, runner.target_id) + target = await self.service.target(repo, runner.target_id, lock=True) + if runner.revoked or runner.principal_id != actor.id or target.runner_principal_id != actor.id: + raise AccessDenied() + now = await repo.now() + runner = runner.model_copy(update={"last_seen": now, "expires_at": now + timedelta(seconds=90)}) + await repo.update("runners", identifier, runner) + return runner, target + + async def context( + self, + repo: DeploymentRepository, + job: DeploymentJob, + target: DeploymentTarget, + proof: DeploymentLeaseProof, + expires: datetime, + ) -> DeploymentLease: + plan = await repo.get("plans", job.plan_id, DeploymentPlan) if job.plan_id else None + deployment = await repo.get("deployments", plan.deployment_id, Deployment) if plan else None + observation = await repo.get("observations", plan.observation_id, DeploymentObservation) if plan else None + return DeploymentLease( + proof=proof, + expires_at=expires, + job=job, + target=target, + plan=plan, + deployment=deployment, + observation=observation, + ) + + async def claim( + self, request: RunnerClaimRequest, *, actor: Principal, scope: Scope, context: AuditContext + ) -> DeploymentLease | None: + async with self.service.uow.open(scope) as tx: + repo = DeploymentRepository(tx) + runner, target = await self.runner(repo, request.runner_id, actor, "runner.claim", context) + await self.service.expire(repo, target.id) + rows = await repo.rows( + f"SELECT * FROM deployment_jobs WHERE {SCOPE} AND target_id=:target " + "AND state='queued' ORDER BY id LIMIT 1 FOR UPDATE SKIP LOCKED", + target=target.id, + ) + if not rows: + return None + row = rows[0] + job = DeploymentJob.model_validate_json(json.dumps(row["payload"])) + plan = await repo.get("plans", job.plan_id, DeploymentPlan) if job.plan_id else None + capability = plan.intent if plan else "observe" + if capability not in runner.capabilities or capability not in target.capabilities: + return None + if plan: + await self.service.valid_plan(repo, plan, plan.digest) + generation = row["generation"] + 1 + token = uuid4() + expires = min(await repo.now() + timedelta(seconds=60), job.deadline) + job = job.model_copy(update={"state": "claimed", "revision": job.revision + 1}) + await repo.update( + "jobs", + job.id, + job, + extra={ + "state": "claimed", + "generation": generation, + "runner_id": runner.id, + "lease_token": token, + "lease_expires_at": expires, + }, + ) + return await self.context( + repo, + job, + target, + DeploymentLeaseProof(job_id=job.id, runner_id=runner.id, generation=generation, token=token), + expires, + ) + + async def checked( + self, + repo: DeploymentRepository, + proof: DeploymentLeaseProof, + actor: Principal, + capability: str, + context: AuditContext, + ) -> tuple[dict[str, Any], DeploymentJob, DeploymentTarget]: + runner, target = await self.runner(repo, proof.runner_id, actor, capability, context) + row = await repo.row("jobs", proof.job_id, lock=True) + job = DeploymentJob.model_validate_json(json.dumps(row["payload"])) + if ( + job.target_id != target.id + or job.target_revision != target.revision + or row["runner_id"] != runner.id + or row["generation"] != proof.generation + or row["lease_token"] != proof.token + ): + raise CatalogError(409, "WV-RUNNER-FENCE", "Runner lease is no longer authoritative") + if job.plan_id: + plan = await repo.get("plans", job.plan_id, DeploymentPlan) + deployment = await repo.get("deployments", plan.deployment_id, Deployment) + if deployment.revision != plan.deployment_revision: + raise CatalogError(409, "WV-RUNNER-FENCE", "Deployment intent changed during the lease") + return row, job, target + + async def renew( + self, proof: DeploymentLeaseProof, *, actor: Principal, scope: Scope, context: AuditContext + ) -> DeploymentLease: + async with self.service.uow.open(scope) as tx: + repo = DeploymentRepository(tx) + row, job, target = await self.checked(repo, proof, actor, "runner.renew", context) + now = await repo.now() + if ( + job.state not in {"claimed", "running", "verifying"} + or row["lease_expires_at"] <= now + or job.deadline <= now + ): + raise CatalogError(409, "WV-RUNNER-FENCE", "Runner lease expired or job stopped") + expires = min(now + timedelta(seconds=60), job.deadline) + await repo.update("jobs", job.id, job, extra={"lease_expires_at": expires}) + return await self.context(repo, job, target, proof, expires) + + async def report( + self, request: RunnerReport, *, actor: Principal, scope: Scope, context: AuditContext + ) -> DeploymentJob: + async with self.service.uow.open(scope) as tx: + repo = DeploymentRepository(tx) + row, job, target = await self.checked(repo, request.lease, actor, "runner.report", context) + fingerprint = canonical_digest(request.model_dump(mode="json")) + prior = await repo.rows(f"SELECT * FROM deployment_reports WHERE {SCOPE} AND id=:id", id=request.report_id) + if prior: + if ( + prior[0]["request_hash"] != fingerprint + or prior[0]["job_id"] != job.id + or prior[0]["runner_id"] != request.lease.runner_id + ): + raise CatalogError(409, "WV-IDEMPOTENCY-CONFLICT", "Runner report identifier conflicts") + return DeploymentJob.model_validate_json(json.dumps(prior[0]["payload"])) + now = await repo.now() + if ( + job.state not in {"claimed", "running", "verifying"} + or row["lease_expires_at"] <= now + or job.deadline <= now + ): + raise CatalogError(409, "WV-RUNNER-FENCE", "Runner lease expired or job stopped") + allowed = { + "claimed": {"running", "failed", "cancelled", "reconciliation_required"}, + "running": {"verifying", "failed", "reconciliation_required"}, + "verifying": {"succeeded", "failed", "reconciliation_required"}, + } + if job.kind == "observe": + allowed = {"claimed": {"succeeded", "failed", "cancelled"}} + if request.state not in allowed[job.state]: + raise CatalogError(409, "WV-JOB-TRANSITION", "Invalid runner job transition") + plan = await repo.get("plans", job.plan_id, DeploymentPlan) if job.plan_id else None + if request.state == "running" and plan: + await self.service.valid_plan(repo, plan, plan.digest) + approval = await repo.row("approvals", plan.id) + from firefly_weave.access.repository import load_principal + + approver = await load_principal(tx.session, UUID(approval["payload"]["principal_id"])) + self.service.authorization.require( + approver, scope, "deployment.approve", resource=str(target.id), context=context + ) + if request.state == "succeeded": + assert request.receipt is not None and request.observation is not None + if ( + request.receipt.code != ("applied" if plan else "observed") + or request.receipt.external_effects_may_continue + ): + raise CatalogError(422, "WV-JOB-RECEIPT", "Successful receipt must prove a final observation") + if plan: + if not request.observation.settled: + raise CatalogError( + 422, "WV-JOB-RECEIPT", "Final observation must establish settled external operations" + ) + observed = {r.name: r for r in request.observation.resources} + for step in plan.steps: + fact = observed.get(step.component.name) + expected_replicas = step.component.replicas + if ( + fact is None + or fact.image != step.component.image + or fact.replicas != expected_replicas + or fact.ready_replicas != expected_replicas + or fact.ownership != "managed" + or fact.state not in ({"ready", "stopped"} if expected_replicas == 0 else {"ready"}) + ): + raise CatalogError( + 422, "WV-JOB-RECEIPT", "Final observation does not establish reviewed intent" + ) + if request.state == "reconciliation_required" and ( + request.receipt is None or not request.receipt.external_effects_may_continue + ): + raise CatalogError(422, "WV-JOB-RECEIPT", "Ambiguous effects must remain explicit") + observation_id = None + if request.observation: + data = dict( + **request.observation.model_dump(), + id=uuid4(), + target_id=target.id, + target_revision=target.revision, + observed_at=now, + expires_at=now + timedelta(minutes=15), + ) + draft = DeploymentObservation(**data, digest="0" * 64) + observation = draft.model_copy( + update={"digest": canonical_digest(draft.model_dump(mode="json", exclude={"digest"}))} + ) + await repo.insert("observations", observation.id, target.id, observation) + observation_id = observation.id + result = job.model_copy( + update={ + "state": request.state, + "revision": job.revision + 1, + "receipt": request.receipt, + "reconciliation_started_at": now if request.state == "reconciliation_required" else None, + "observation_id": observation_id or job.observation_id, + } + ) + await repo.update("jobs", job.id, result, extra={"state": request.state}) + await repo.insert( + "reports", + request.report_id, + target.id, + result, + extra={"job_id": job.id, "runner_id": request.lease.runner_id, "request_hash": fingerprint}, + ) + await self.service.record(tx, actor, "runner.report", job.id, context) + return result + + async def revoke( + self, identifier: UUID, *, actor: Principal, scope: Scope, context: AuditContext + ) -> DeploymentRunner: + async with self.service.uow.open(scope) as tx: + repo = DeploymentRepository(tx) + runner = await repo.get("runners", identifier, DeploymentRunner, lock=True) + await self.service.authorize(tx, actor, "target.manage", context, runner.target_id) + result = runner.model_copy(update={"revoked": True}) + await repo.update("runners", identifier, result) + rows = await repo.rows( + f"SELECT id FROM deployment_jobs WHERE {SCOPE} AND runner_id=:runner " + "AND state IN ('claimed','running','verifying')", + runner=identifier, + ) + for row in rows: + await repo.tx.session.execute( + text("UPDATE deployment_jobs SET lease_expires_at=clock_timestamp() WHERE id=:id"), + {"id": row["id"]}, + ) + await self.service.expire(repo, runner.target_id) + await self.service.record(tx, actor, "target.manage", identifier, context) + return result diff --git a/src/firefly_weave/deployments/repository.py b/src/firefly_weave/deployments/repository.py new file mode 100644 index 00000000..9e02c8be --- /dev/null +++ b/src/firefly_weave/deployments/repository.py @@ -0,0 +1,124 @@ +# Copyright 2026 Firefly Software Foundation. +# +# 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. +# Author: Firefly Software Foundation +# SPDX-License-Identifier: Apache-2.0 + +"""Exact-scope persistence with explicit bounded collections.""" + +import json +from datetime import datetime +from typing import Any +from uuid import UUID + +from sqlalchemy import text + +from firefly_weave.contracts.definitions import ContractModel +from firefly_weave.definitions.models import CatalogError +from firefly_weave.persistence.uow import Transaction + +COLLECTIONS = frozenset({"targets", "deployments", "observations", "plans", "approvals", "runners", "jobs", "reports"}) +SCOPE = "tenant_id=:tenant AND project_id=:project AND environment_id=:environment" + + +class DeploymentRepository: + def __init__(self, tx: Transaction): + self.tx = tx + self.bound = dict(tenant=tx.scope.tenant_id, project=tx.scope.project_id, environment=tx.scope.environment_id) + + def table(self, collection: str) -> str: + if collection not in COLLECTIONS: + raise ValueError("Unknown deployment collection") + return "deployment_" + collection + + async def now(self) -> datetime: + value = await self.tx.session.scalar(text("SELECT clock_timestamp()")) + assert isinstance(value, datetime) + return value + + async def rows(self, sql: str, **values: Any) -> list[dict[str, Any]]: + return [dict(row) for row in (await self.tx.session.execute(text(sql), {**self.bound, **values})).mappings()] + + async def row(self, collection: str, identifier: UUID, *, lock: bool = False) -> dict[str, Any]: + rows = await self.rows( + f"SELECT * FROM {self.table(collection)} WHERE {SCOPE} AND id=:id" + (" FOR UPDATE" if lock else ""), + id=identifier, + ) + if not rows: + raise CatalogError(404, "WV-NOT-FOUND", "Deployment resource not found") + return rows[0] + + async def get[T: ContractModel]( + self, collection: str, identifier: UUID, model: type[T], *, lock: bool = False + ) -> T: + return model.model_validate_json(json.dumps((await self.row(collection, identifier, lock=lock))["payload"])) + + async def insert( + self, + collection: str, + identifier: UUID, + target_id: UUID, + payload: ContractModel, + *, + deployment_id: UUID | None = None, + extra: dict[str, Any] | None = None, + ) -> None: + table = self.table(collection) + count = await self.tx.session.scalar(text(f"SELECT count(*) FROM {table} WHERE {SCOPE}"), self.bound) + maximum = ( + 100 if collection in {"targets", "deployments", "runners"} else 10000 if collection == "reports" else 1000 + ) + if count is not None and count >= maximum: + raise CatalogError(409, "WV-DEPLOYMENT-CAPACITY", "Deployment collection limit reached") + values = extra or {} + allowed = {"state", "job_id", "runner_id", "request_hash"} + if not set(values) <= allowed: + raise ValueError("Invalid deployment persistence fields") + encoded = payload.model_dump_json() + if len(encoded.encode()) > 262144: + raise CatalogError(422, "WV-DEPLOYMENT-SIZE", "Deployment document exceeds its byte budget") + cols = "".join("," + key for key in values) + params = "".join(",:" + key for key in values) + await self.tx.session.execute( + text( + f"INSERT INTO {table}(id,tenant_id,project_id,environment_id,target_id,deployment_id,payload{cols}) " + f"VALUES(:id,:tenant,:project,:environment,:target,:deployment,cast(:payload AS jsonb){params})" + ), + { + **self.bound, + "id": identifier, + "target": target_id, + "deployment": deployment_id, + "payload": encoded, + **values, + }, + ) + + async def update( + self, collection: str, identifier: UUID, payload: ContractModel, *, extra: dict[str, Any] | None = None + ) -> None: + values = extra or {} + allowed = {"state", "generation", "runner_id", "lease_token", "lease_expires_at"} + if not set(values) <= allowed: + raise ValueError("Invalid deployment persistence fields") + sets = "".join("," + key + "=:" + key for key in values) + encoded = payload.model_dump_json() + if len(encoded.encode()) > 262144: + raise CatalogError(422, "WV-DEPLOYMENT-SIZE", "Deployment document exceeds its byte budget") + await self.tx.session.execute( + text( + f"UPDATE {self.table(collection)} SET payload=cast(:payload AS jsonb),revision=revision+1{sets} " + f"WHERE {SCOPE} AND id=:id" + ), + {**self.bound, "id": identifier, "payload": encoded, **values}, + ) diff --git a/src/firefly_weave/deployments/service.py b/src/firefly_weave/deployments/service.py new file mode 100644 index 00000000..be05552f --- /dev/null +++ b/src/firefly_weave/deployments/service.py @@ -0,0 +1,662 @@ +# Copyright 2026 Firefly Software Foundation. +# +# 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. +# Author: Firefly Software Foundation +# SPDX-License-Identifier: Apache-2.0 + +"""Environment-scoped intent and immutable approval; runners own external effects.""" + +import json +from datetime import timedelta +from typing import Any +from uuid import UUID, uuid4 + +from pyfly.container import service + +from firefly_weave.access.audit import AuditContext +from firefly_weave.access.authorization import AccessDenied, AuthorizationService, contains +from firefly_weave.access.models import Principal +from firefly_weave.access.repository import load_principal +from firefly_weave.access.roles import ROLE_CAPABILITIES +from firefly_weave.access.service import audit +from firefly_weave.compiler.canonical import canonical_digest +from firefly_weave.contracts.access import Scope +from firefly_weave.contracts.definitions import ContractModel +from firefly_weave.contracts.deployments import ( + ApplyPlanRequest, + Deployment, + DeploymentJob, + DeploymentLease, + DeploymentLeaseProof, + DeploymentObservation, + DeploymentPlan, + DeploymentRequest, + DeploymentRunner, + DeploymentTarget, + JobCancelRequest, + ObserveRequest, + PlanApproval, + PlanApprovalRequest, + PlanRequest, + PlanStep, + ReconcileJobRequest, + RunnerClaimRequest, + RunnerRegistration, + RunnerReport, + SafeDeploymentReceipt, + TargetRequest, + TargetUpdate, +) +from firefly_weave.definitions.models import CatalogError +from firefly_weave.deployments.repository import SCOPE, DeploymentRepository +from firefly_weave.persistence.idempotency import Idempotency +from firefly_weave.persistence.uow import Transaction, UnitOfWork + +MODELS: dict[str, type[ContractModel]] = { + "targets": DeploymentTarget, + "deployments": Deployment, + "observations": DeploymentObservation, + "plans": DeploymentPlan, + "jobs": DeploymentJob, + "runners": DeploymentRunner, +} + + +@service +class DeploymentService: + def __init__(self, uow: UnitOfWork, authorization: AuthorizationService): + self.uow, self.authorization = uow, authorization + + @staticmethod + def is_uuid(value: str) -> bool: + try: + UUID(value) + return True + except ValueError: + return False + + async def authorize( + self, tx: Transaction, actor: Principal, capability: str, context: AuditContext, target_id: UUID | None = None + ) -> Principal: + if tx.scope.environment_id is None: + raise AccessDenied() + current = await load_principal(tx.session, actor.id) + for principal in (actor, current): + self.authorization.require( + principal, tx.scope, capability, resource=str(target_id) if target_id else None, context=context + ) + return current + + async def record( + self, tx: Transaction, actor: Principal, capability: str, identifier: UUID, context: AuditContext + ) -> None: + await audit( + tx.session, actor, capability, str(identifier), scope=tx.scope, capability=capability, context=context + ) + + async def target(self, repo: DeploymentRepository, identifier: UUID, *, lock: bool = False) -> DeploymentTarget: + target = await repo.get("targets", identifier, DeploymentTarget, lock=lock) + if target.disabled: + raise CatalogError(409, "WV-TARGET-DISABLED", "Deployment target is disabled") + return target + + async def principal(self, tx: Transaction, identifier: UUID) -> None: + principal = await load_principal(tx.session, identifier) + if not principal.active or principal.kind != "application": + raise CatalogError(422, "WV-RUNNER-IDENTITY", "A dedicated active application principal is required") + + async def list( + self, + collection: str, + *, + actor: Principal, + scope: Scope, + context: AuditContext, + limit: int = 50, + after: UUID | None = None, + target_id: UUID | None = None, + deployment_id: UUID | None = None, + ) -> dict[str, Any]: + if collection not in MODELS or not 1 <= limit <= 100: + raise ValueError("Invalid deployment list") + async with self.uow.open(scope, mutation=False) as tx: + current = await load_principal(tx.session, actor.id) + sets = [] + for principal in (actor, current): + if not principal.active or principal.kind == "worker" or scope.environment_id is None: + raise AccessDenied() + grants = [ + g + for g in principal.grants + if contains(g.scope, scope) and "deployment.read" in ROLE_CAPABILITIES[g.role] + ] + if not grants: + raise AccessDenied() + sets.append(None if any(not g.resources for g in grants) else {r for g in grants for r in g.resources}) + bounded = sets[0] if sets[1] is None else sets[1] if sets[0] is None else sets[0] & sets[1] + permitted = None if bounded is None else [UUID(value) for value in bounded if self.is_uuid(value)] + if target_id: + await self.authorize(tx, actor, "deployment.read", context, target_id) + repo = DeploymentRepository(tx) + clauses = ( + (" AND target_id=ANY(cast(:permitted AS uuid[]))" if permitted is not None else "") + + (" AND target_id=:target" if target_id else "") + + (" AND deployment_id=:deployment" if deployment_id else "") + ) + rows = await repo.rows( + f"SELECT id,payload FROM {repo.table(collection)} WHERE {SCOPE}{clauses} " + "AND (cast(:after AS uuid) IS NULL OR id>cast(:after AS uuid)) ORDER BY id LIMIT :limit", + permitted=permitted, + after=after, + target=target_id, + deployment=deployment_id, + limit=limit + 1, + ) + return { + "items": [row["payload"] for row in rows[:limit]], + "next_cursor": str(rows[limit - 1]["id"]) if len(rows) > limit else None, + } + + async def read( + self, collection: str, identifier: UUID, *, actor: Principal, scope: Scope, context: AuditContext + ) -> ContractModel: + if collection not in MODELS: + raise ValueError("Invalid deployment collection") + async with self.uow.open(scope, mutation=False) as tx: + repo = DeploymentRepository(tx) + row = await repo.row(collection, identifier) + await self.authorize(tx, actor, "deployment.read", context, row["target_id"]) + return MODELS[collection].model_validate_json(json.dumps(row["payload"])) + + async def create_target( + self, request: TargetRequest, key: str, *, actor: Principal, scope: Scope, context: AuditContext + ) -> DeploymentTarget: + async with self.uow.open(scope) as tx: + actor = await self.authorize(tx, actor, "target.manage", context) + idem = Idempotency( + tx, + actor.id, + "deployment_targets.create", + key, + {"scope": scope.model_dump(mode="json"), **request.model_dump(mode="json")}, + ) + if saved := await idem.replay(): + return DeploymentTarget.model_validate_json(json.dumps(saved)) + await self.principal(tx, request.runner_principal_id) + repo = DeploymentRepository(tx) + identifier = uuid4() + target = DeploymentTarget( + **request.model_dump(), id=identifier, scope=scope, revision=1, created_at=await repo.now() + ) + duplicates = await repo.rows( + f"SELECT id FROM deployment_targets WHERE {SCOPE} AND payload->>'adapter'=:adapter " + "AND payload->>'external_identity'=:identity AND payload->>'boundary'=:boundary", + adapter=request.adapter, + identity=request.external_identity, + boundary=request.boundary, + ) + if duplicates: + raise CatalogError(409, "WV-TARGET-EXISTS", "This target boundary is already registered") + await repo.insert("targets", identifier, identifier, target) + await idem.save(target.model_dump(mode="json")) + await self.record(tx, actor, "target.manage", identifier, context) + return target + + async def update_target( + self, + identifier: UUID, + request: TargetUpdate, + revision: int, + *, + actor: Principal, + scope: Scope, + context: AuditContext, + ) -> DeploymentTarget: + async with self.uow.open(scope) as tx: + actor = await self.authorize(tx, actor, "target.manage", context, identifier) + repo = DeploymentRepository(tx) + prior = await repo.get("targets", identifier, DeploymentTarget, lock=True) + if prior.revision != revision: + raise CatalogError(409, "WV-REVISION", "Deployment target changed") + await self.principal(tx, request.runner_principal_id) + value = DeploymentTarget.model_validate( + {**prior.model_dump(), **request.model_dump(), "revision": revision + 1} + ) + await repo.update("targets", identifier, value) + await self.expire(repo, identifier) + await self.record(tx, actor, "target.manage", identifier, context) + return value + + async def admitted_components(self, repo: DeploymentRepository, request: DeploymentRequest) -> None: + from firefly_weave.workers.repository import WorkerRepository + + for component in request.components: + if component.worker_release_id is not None: + await WorkerRepository(repo.tx).release(component.worker_release_id) + + async def create_deployment( + self, request: DeploymentRequest, key: str, *, actor: Principal, scope: Scope, context: AuditContext + ) -> Deployment: + async with self.uow.open(scope) as tx: + actor = await self.authorize(tx, actor, "target.manage", context, request.target_id) + repo = DeploymentRepository(tx) + await self.target(repo, request.target_id, lock=True) + idem = Idempotency( + tx, + actor.id, + "deployments.create", + key, + {"scope": scope.model_dump(mode="json"), **request.model_dump(mode="json")}, + ) + if saved := await idem.replay(): + return Deployment.model_validate_json(json.dumps(saved)) + await self.admitted_components(repo, request) + value = Deployment(**request.model_dump(), id=uuid4(), scope=scope, revision=1, created_at=await repo.now()) + await repo.insert("deployments", value.id, request.target_id, value, deployment_id=value.id) + await idem.save(value.model_dump(mode="json")) + await self.record(tx, actor, "target.manage", value.id, context) + return value + + async def update_deployment( + self, + identifier: UUID, + request: DeploymentRequest, + revision: int, + *, + actor: Principal, + scope: Scope, + context: AuditContext, + ) -> Deployment: + async with self.uow.open(scope) as tx: + repo = DeploymentRepository(tx) + prior = await repo.get("deployments", identifier, Deployment, lock=True) + actor = await self.authorize(tx, actor, "target.manage", context, prior.target_id) + await self.target(repo, prior.target_id, lock=True) + if prior.revision != revision or prior.target_id != request.target_id or prior.name != request.name: + raise CatalogError(409, "WV-REVISION", "Deployment changed or immutable identity differs") + await self.admitted_components(repo, request) + value = Deployment.model_validate({**prior.model_dump(), **request.model_dump(), "revision": revision + 1}) + await repo.update("deployments", identifier, value) + await self.expire(repo, prior.target_id) + await self.record(tx, actor, "target.manage", identifier, context) + return value + + async def expire(self, repo: DeploymentRepository, target_id: UUID) -> None: + now = await repo.now() + rows = await repo.rows( + f"SELECT * FROM deployment_jobs WHERE {SCOPE} AND target_id=:target " + "AND state IN ('queued','claimed','running','verifying') FOR UPDATE", + target=target_id, + ) + for row in rows: + job = DeploymentJob.model_validate_json(json.dumps(row["payload"])) + target = await repo.get("targets", target_id, DeploymentTarget) + stale = target.disabled or target.revision != job.target_revision + if job.plan_id: + plan = await repo.get("plans", job.plan_id, DeploymentPlan) + deployment = await repo.get("deployments", plan.deployment_id, Deployment) + stale = stale or deployment.revision != plan.deployment_revision + if job.state in {"queued", "claimed"}: + stale = stale or plan.expires_at <= now + expired = job.deadline <= now or row["lease_expires_at"] is not None and row["lease_expires_at"] <= now + if not stale and not expired: + continue + ambiguous = job.state in {"running", "verifying"} + state = ( + "reconciliation_required" if ambiguous else "cancelled" if stale or job.deadline <= now else "queued" + ) + receipt = ( + SafeDeploymentReceipt( + code="ambiguous" if ambiguous else "cancelled", external_effects_may_continue=ambiguous + ) + if state != "queued" + else None + ) + changed = job.model_copy( + update={ + "state": state, + "revision": job.revision + 1, + "receipt": receipt, + "reconciliation_started_at": now if ambiguous else None, + } + ) + await repo.update( + "jobs", + job.id, + changed, + extra={"state": state, "runner_id": None, "lease_token": None, "lease_expires_at": None}, + ) + + async def new_job( + self, repo: DeploymentRepository, target: DeploymentTarget, *, plan: DeploymentPlan | None = None + ) -> DeploymentJob: + await self.expire(repo, target.id) + blocked = await repo.rows( + f"SELECT id FROM deployment_jobs WHERE {SCOPE} AND target_id=:target AND " + "(state IN ('queued','claimed','running','verifying') OR (:mutating AND state='reconciliation_required'))", + target=target.id, + mutating=plan is not None, + ) + if blocked: + raise CatalogError(409, "WV-TARGET-BUSY", "Target has active or unresolved external work") + now = await repo.now() + job = DeploymentJob( + id=uuid4(), + scope=repo.tx.scope, + target_id=target.id, + target_revision=target.revision, + kind="apply" if plan else "observe", + plan_id=plan.id if plan else None, + plan_digest=plan.digest if plan else None, + state="queued", + revision=1, + operation_key=uuid4(), + created_at=now, + deadline=now + timedelta(minutes=15), + ) + await repo.insert( + "jobs", + job.id, + target.id, + job, + deployment_id=plan.deployment_id if plan else None, + extra={"state": "queued"}, + ) + return job + + async def observe( + self, request: ObserveRequest, key: str, *, actor: Principal, scope: Scope, context: AuditContext + ) -> DeploymentJob: + async with self.uow.open(scope) as tx: + actor = await self.authorize(tx, actor, "deployment.plan", context, request.target_id) + repo = DeploymentRepository(tx) + target = await self.target(repo, request.target_id, lock=True) + idem = Idempotency( + tx, + actor.id, + "deployment_observations.create", + key, + {"scope": scope.model_dump(mode="json"), **request.model_dump(mode="json")}, + ) + if saved := await idem.replay(): + return DeploymentJob.model_validate_json(json.dumps(saved)) + result = await self.new_job(repo, target) + await idem.save(result.model_dump(mode="json")) + await self.record(tx, actor, "deployment.plan", result.id, context) + return result + + async def valid_plan(self, repo: DeploymentRepository, plan: DeploymentPlan, digest: str) -> DeploymentTarget: + target = await self.target(repo, plan.target_id, lock=True) + deployment = await repo.get("deployments", plan.deployment_id, Deployment, lock=True) + observation = await repo.get("observations", plan.observation_id, DeploymentObservation) + now = await repo.now() + if ( + plan.digest != digest + or plan.expires_at <= now + or observation.expires_at <= now + or target.revision != plan.target_revision + or deployment.revision != plan.deployment_revision + or observation.digest != plan.observation_digest + or plan.intent not in target.capabilities + ): + raise CatalogError(409, "WV-PLAN-STALE", "Reobserve and replan before applying changed intent") + return target + + async def plan( + self, request: PlanRequest, key: str, *, actor: Principal, scope: Scope, context: AuditContext + ) -> DeploymentPlan: + async with self.uow.open(scope) as tx: + repo = DeploymentRepository(tx) + deployment = await repo.get("deployments", request.deployment_id, Deployment, lock=True) + actor = await self.authorize(tx, actor, "deployment.plan", context, deployment.target_id) + target = await self.target(repo, deployment.target_id, lock=True) + idem = Idempotency( + tx, + actor.id, + "deployment_plans.create", + key, + {"scope": scope.model_dump(mode="json"), **request.model_dump(mode="json")}, + ) + if saved := await idem.replay(): + return DeploymentPlan.model_validate_json(json.dumps(saved)) + observed = await repo.get("observations", request.observation_id, DeploymentObservation) + now = await repo.now() + if ( + deployment.revision != request.deployment_revision + or observed.target_id != target.id + or observed.target_revision != target.revision + or not observed.complete + or observed.expires_at <= now + ): + raise CatalogError( + 409, "WV-PLAN-STALE", "Fresh complete target observation and current intent required" + ) + if request.intent == "drain_workers": + raise CatalogError(422, "WV-DEPLOYMENT-UNSUPPORTED", "Use explicit worker-instance drain controls") + if request.intent not in target.capabilities: + raise CatalogError(422, "WV-DEPLOYMENT-UNSUPPORTED", "Target does not support the requested capability") + selected = [ + c + for c in deployment.components + if request.intent not in {"scale_workers", "drain_workers"} or c.kind == "worker" + ] + if not selected: + raise CatalogError(422, "WV-DEPLOYMENT-UNSUPPORTED", "No worker component selected") + by_name = {r.name: r for r in observed.resources} + if request.intent == "scale_workers" and any( + c.name not in by_name or by_name[c.name].image != c.image for c in selected + ): + raise CatalogError( + 409, "WV-PLAN-STALE", "Scale requires the observed worker image; use update to change images" + ) + steps = [ + PlanStep( + action=request.intent, + component=c, + expected_version=by_name[c.name].version if c.name in by_name else None, + ) + for c in selected + ] + risks = ["external_effects"] + if any(c.kind == "api" for c in selected): + risks.append("service_interruption") + if deployment.ownership == "imported": + risks.append("adoption") + data: dict[str, Any] = dict( + id=uuid4(), + scope=scope, + target_id=target.id, + target_revision=target.revision, + deployment_id=deployment.id, + deployment_revision=deployment.revision, + adapter=target.adapter, + intent=request.intent, + observation_id=observed.id, + observation_digest=observed.digest, + steps=steps, + risks=risks, + created_at=now, + expires_at=min(now + timedelta(seconds=request.ttl_seconds), observed.expires_at), + ) + draft = DeploymentPlan(**data, digest="0" * 64) + result = draft.model_copy( + update={"digest": canonical_digest(draft.model_dump(mode="json", exclude={"digest"}))} + ) + await repo.insert("plans", result.id, target.id, result, deployment_id=deployment.id) + await idem.save(result.model_dump(mode="json")) + await self.record(tx, actor, "deployment.plan", result.id, context) + return result + + async def approve( + self, identifier: UUID, request: PlanApprovalRequest, *, actor: Principal, scope: Scope, context: AuditContext + ) -> PlanApproval: + async with self.uow.open(scope) as tx: + repo = DeploymentRepository(tx) + plan = await repo.get("plans", identifier, DeploymentPlan) + actor = await self.authorize(tx, actor, "deployment.approve", context, plan.target_id) + await self.valid_plan(repo, plan, request.digest) + rows = await repo.rows(f"SELECT payload FROM deployment_approvals WHERE {SCOPE} AND id=:id", id=identifier) + if rows: + return PlanApproval.model_validate_json(json.dumps(rows[0]["payload"])) + result = PlanApproval( + plan_id=identifier, digest=plan.digest, principal_id=actor.id, approved_at=await repo.now() + ) + await repo.insert("approvals", identifier, plan.target_id, result, deployment_id=plan.deployment_id) + await self.record(tx, actor, "deployment.approve", identifier, context) + return result + + async def apply( + self, + identifier: UUID, + request: ApplyPlanRequest, + key: str, + *, + actor: Principal, + scope: Scope, + context: AuditContext, + ) -> DeploymentJob: + async with self.uow.open(scope) as tx: + repo = DeploymentRepository(tx) + plan = await repo.get("plans", identifier, DeploymentPlan) + actor = await self.authorize(tx, actor, "deployment.apply", context, plan.target_id) + idem = Idempotency( + tx, + actor.id, + "deployment_plans.apply", + key, + {"scope": scope.model_dump(mode="json"), "plan": str(identifier), "digest": request.digest}, + ) + if saved := await idem.replay(): + return DeploymentJob.model_validate_json(json.dumps(saved)) + target = await self.valid_plan(repo, plan, request.digest) + approval = await repo.get("approvals", identifier, PlanApproval) + approver = await load_principal(tx.session, approval.principal_id) + self.authorization.require(approver, scope, "deployment.approve", resource=str(target.id), context=context) + if approval.digest != plan.digest: + raise CatalogError(409, "WV-PLAN-STALE", "Approval differs from reviewed plan") + previous = await repo.rows( + f"SELECT id FROM deployment_jobs WHERE {SCOPE} AND payload->>'plan_id'=:plan", plan=str(identifier) + ) + if previous: + raise CatalogError(409, "WV-PLAN-APPLIED", "A plan may be applied only once") + result = await self.new_job(repo, target, plan=plan) + await idem.save(result.model_dump(mode="json")) + await self.record(tx, actor, "deployment.apply", result.id, context) + return result + + async def cancel( + self, identifier: UUID, request: JobCancelRequest, *, actor: Principal, scope: Scope, context: AuditContext + ) -> DeploymentJob: + async with self.uow.open(scope) as tx: + repo = DeploymentRepository(tx) + job = await repo.get("jobs", identifier, DeploymentJob, lock=True) + actor = await self.authorize(tx, actor, "deployment.cancel", context, job.target_id) + if job.state in {"succeeded", "failed", "cancelled", "reconciliation_required"}: + return job + ambiguous = job.state in {"running", "verifying"} + state = "reconciliation_required" if ambiguous else "cancelled" + result = job.model_copy( + update={ + "state": state, + "revision": job.revision + 1, + "reconciliation_started_at": await repo.now() if ambiguous else None, + "receipt": SafeDeploymentReceipt( + code="ambiguous" if ambiguous else "cancelled", external_effects_may_continue=ambiguous + ), + } + ) + await repo.update( + "jobs", identifier, result, extra={"state": state, "lease_token": None, "lease_expires_at": None} + ) + await self.record(tx, actor, "deployment.cancel", identifier, context) + return result + + async def register_runner(self, request: RunnerRegistration, **authority: Any) -> DeploymentRunner: + from firefly_weave.deployments.leases import DeploymentLeases + + return await DeploymentLeases(self).registration(request, **authority) + + async def claim(self, request: RunnerClaimRequest, **authority: Any) -> DeploymentLease | None: + from firefly_weave.deployments.leases import DeploymentLeases + + return await DeploymentLeases(self).claim(request, **authority) + + async def renew(self, request: DeploymentLeaseProof, **authority: Any) -> DeploymentLease: + from firefly_weave.deployments.leases import DeploymentLeases + + return await DeploymentLeases(self).renew(request, **authority) + + async def report(self, request: RunnerReport, **authority: Any) -> DeploymentJob: + from firefly_weave.deployments.leases import DeploymentLeases + + return await DeploymentLeases(self).report(request, **authority) + + async def revoke_runner(self, identifier: UUID, **authority: Any) -> DeploymentRunner: + from firefly_weave.deployments.leases import DeploymentLeases + + return await DeploymentLeases(self).revoke(identifier, **authority) + + async def reconcile( + self, identifier: UUID, request: ReconcileJobRequest, *, actor: Principal, scope: Scope, context: AuditContext + ) -> DeploymentJob: + async with self.uow.open(scope) as tx: + repo = DeploymentRepository(tx) + job = await repo.get("jobs", identifier, DeploymentJob, lock=True) + actor = await self.authorize(tx, actor, "deployment.apply", context, job.target_id) + await self.authorize(tx, actor, "deployment.approve", context, job.target_id) + target = await self.target(repo, job.target_id, lock=True) + if job.state != "reconciliation_required" or job.plan_digest != request.plan_digest: + raise CatalogError( + 409, "WV-JOB-RECONCILIATION", "An ambiguous job and its exact prior plan are required" + ) + observed = await repo.get("observations", request.observation_id, DeploymentObservation) + if ( + not observed.complete + or not observed.settled + or observed.target_id != target.id + or observed.target_revision != target.revision + or observed.expires_at <= await repo.now() + or job.reconciliation_started_at is None + or observed.observed_at <= job.reconciliation_started_at + ): + raise CatalogError(409, "WV-JOB-RECONCILIATION", "A new complete settled observation is required") + active = await repo.rows( + f"SELECT id FROM deployment_jobs WHERE {SCOPE} AND target_id=:target " + "AND state IN ('queued','claimed','running','verifying')", + target=target.id, + ) + if active: + raise CatalogError(409, "WV-TARGET-BUSY", "Wait for target inspection to finish") + result = job.model_copy( + update={ + "state": "failed", + "revision": job.revision + 1, + "receipt": SafeDeploymentReceipt(code="reconciled"), + "observation_id": observed.id, + } + ) + await repo.update( + "jobs", identifier, result, extra={"state": "failed", "lease_token": None, "lease_expires_at": None} + ) + await self.record(tx, actor, "deployment.approve", identifier, context) + return result + + async def approval( + self, identifier: UUID, *, actor: Principal, scope: Scope, context: AuditContext + ) -> PlanApproval | None: + async with self.uow.open(scope, mutation=False) as tx: + repo = DeploymentRepository(tx) + plan = await repo.get("plans", identifier, DeploymentPlan) + await self.authorize(tx, actor, "deployment.read", context, plan.target_id) + rows = await repo.rows(f"SELECT payload FROM deployment_approvals WHERE {SCOPE} AND id=:id", id=identifier) + return PlanApproval.model_validate_json(json.dumps(rows[0]["payload"])) if rows else None diff --git a/src/firefly_weave/persistence/migrations.py b/src/firefly_weave/persistence/migrations.py index 632832cc..ca7f5bbc 100644 --- a/src/firefly_weave/persistence/migrations.py +++ b/src/firefly_weave/persistence/migrations.py @@ -29,7 +29,7 @@ from firefly_weave.settings import Settings, operations_from_env -SCHEMA_VERSION = "0028_files" +SCHEMA_VERSION = "0030_worker_presence" async def check_schema(connection: AsyncConnection) -> None: diff --git a/src/firefly_weave/sdk/client.py b/src/firefly_weave/sdk/client.py index 3b07602e..2b023707 100644 --- a/src/firefly_weave/sdk/client.py +++ b/src/firefly_weave/sdk/client.py @@ -125,8 +125,10 @@ LeaseProof, ReleaseRequest, TaskLease, + WorkerControlRequest, WorkerInstance, WorkerRelease, + WorkerStatus, ) from firefly_weave.operations.debug.models import DebugCommand, DebugCreate, DebugSession from firefly_weave.sdk.errors import ContractError, PreconditionFailed, TransportError, WeaveError @@ -556,8 +558,24 @@ async def register_worker(self, request: InstanceRequest) -> WorkerInstance: async def read_release(self, identifier: UUID) -> WorkerRelease: return cast(WorkerRelease, await self.invoke("releases.read", identifier=identifier)) - async def read_worker(self, identifier: UUID) -> WorkerInstance: - return cast(WorkerInstance, await self.invoke("workers.read", identifier=identifier)) + async def read_worker(self, identifier: UUID) -> WorkerStatus: + return cast(WorkerStatus, await self.invoke("workers.read", identifier=identifier)) + + async def drain_worker( + self, identifier: UUID, request: WorkerControlRequest, *, idempotency_key: str + ) -> WorkerStatus: + return cast( + WorkerStatus, + await self.invoke("workers.drain", identifier=identifier, body=request, idempotency_key=idempotency_key), + ) + + async def resume_worker( + self, identifier: UUID, request: WorkerControlRequest, *, idempotency_key: str + ) -> WorkerStatus: + return cast( + WorkerStatus, + await self.invoke("workers.resume", identifier=identifier, body=request, idempotency_key=idempotency_key), + ) async def revoke_worker(self, identifier: UUID) -> Revoked: return cast(Revoked, await self.invoke("workers.revoke", identifier=identifier)) @@ -712,8 +730,8 @@ async def list_runs( ), ) - async def list_workers(self, *, limit: int = 50, cursor: str | None = None) -> Page[WorkerInstance]: - return cast(Page[WorkerInstance], await self.invoke("workers.list", query=self._page(limit, cursor))) + async def list_workers(self, *, limit: int = 50, cursor: str | None = None) -> Page[WorkerStatus]: + return cast(Page[WorkerStatus], await self.invoke("workers.list", query=self._page(limit, cursor))) async def list_releases(self, *, limit: int = 50, cursor: str | None = None) -> Page[WorkerRelease]: return cast(Page[WorkerRelease], await self.invoke("releases.list", query=self._page(limit, cursor))) diff --git a/src/firefly_weave/sdk/platform.py b/src/firefly_weave/sdk/platform.py index c1e340c7..7831c117 100644 --- a/src/firefly_weave/sdk/platform.py +++ b/src/firefly_weave/sdk/platform.py @@ -84,6 +84,11 @@ # runs, and works on human tasks in the demo workspace; administrators choose others explicitly. DEFAULT_PERSON_ROLES = ("developer", "deployer", "operator", "viewer", "task_participant") PERSON_ROLES = ( + "deployment_reader", + "deployment_planner", + "deployment_approver", + "deployment_operator", + "worker_operator", "file_reader", "file_manager", "lumi_user", diff --git a/src/firefly_weave/studio/host.py b/src/firefly_weave/studio/host.py index dce5eb3b..81c52936 100644 --- a/src/firefly_weave/studio/host.py +++ b/src/firefly_weave/studio/host.py @@ -347,6 +347,24 @@ async def empty() -> Message: ).selection() return await self.connection.apply_scope(generation, selection) + @get_mapping("/studio/contracts/deployment-target") + async def deployment_target_contract(self, request: Request) -> Response: + from firefly_weave.contracts.deployments import TargetRequest + + return JSONResponse(TargetRequest.model_json_schema(), headers={"Cache-Control": "no-store"}) + + @get_mapping("/studio/contracts/deployment-target-update") + async def deployment_target_update_contract(self, request: Request) -> Response: + from firefly_weave.contracts.deployments import TargetUpdate + + return JSONResponse(TargetUpdate.model_json_schema(), headers={"Cache-Control": "no-store"}) + + @get_mapping("/studio/contracts/deployment") + async def deployment_contract(self, request: Request) -> Response: + from firefly_weave.contracts.deployments import DeploymentRequest + + return JSONResponse(DeploymentRequest.model_json_schema(), headers={"Cache-Control": "no-store"}) + @get_mapping("/studio/contracts/llm-profile") async def llm_profile_contract(self, request: Request) -> Response: from firefly_weave.contracts.llm import LLMProfile diff --git a/src/firefly_weave/studio/service.py b/src/firefly_weave/studio/service.py index 988cc683..add2dbd2 100644 --- a/src/firefly_weave/studio/service.py +++ b/src/firefly_weave/studio/service.py @@ -74,6 +74,12 @@ "lumi", "connector_descriptors", "workers", + "deployment_targets", + "deployments", + "deployment_observations", + "deployment_plans", + "deployment_jobs", + "deployment_runners", "environments", "incidents", # Draft save/retire and Simulate are authorized server-side like every other family. diff --git a/src/firefly_weave/workers/leases.py b/src/firefly_weave/workers/leases.py index 57d391c3..89254618 100644 --- a/src/firefly_weave/workers/leases.py +++ b/src/firefly_weave/workers/leases.py @@ -101,6 +101,9 @@ async def claim(self, tx: Transaction, worker_id: UUID, limit: int) -> list[Task instance = await self.authority(enlisted, worker_id, "task.claim") repository = WorkerRepository(enlisted) claimed: list[TaskLease] = [] + if await repository.draining(worker_id): + await repository.observe(worker_id) + return claimed for identifier in await self.runtime.task_candidates(enlisted, instance.release_id, instance.task_types): if len(claimed) >= limit: break @@ -119,6 +122,8 @@ async def claim(self, tx: Transaction, worker_id: UUID, limit: int) -> list[Task if capability not in instance.task_types: continue instance = await self.authority(enlisted, worker_id, "task.claim", capability, lock=True) + if await repository.draining(worker_id): + break if await self.runtime.observe_unavailable(enlisted, run): continue await RuntimeRepository(enlisted).require_work(run["id"], run["state"]) @@ -197,6 +202,9 @@ async def claim(self, tx: Transaction, worker_id: UUID, limit: int) -> list[Task capability="task.claim", context=self.context, ) + # Keep run/task -> instance lock order, including when an idle poll + # records presence. A drain only owns the instance row. + await repository.observe(worker_id) return claimed async def check(self, tx: Transaction, proof: LeaseProof, operation: str, *, live: bool = True) -> VerifiedTask: @@ -249,6 +257,7 @@ async def heartbeat(self, tx: Transaction, lease: LeaseProof) -> TaskLease: generation=lease.generation, expires=expires, ) + await repository.observe(lease.owner) return TaskLease( proof=lease, input=verified.task["payload"]["input"], diff --git a/src/firefly_weave/workers/models.py b/src/firefly_weave/workers/models.py index 2915af02..8a047778 100644 --- a/src/firefly_weave/workers/models.py +++ b/src/firefly_weave/workers/models.py @@ -19,9 +19,10 @@ import hashlib import secrets from dataclasses import dataclass -from datetime import datetime -from typing import Any +from datetime import datetime, timedelta +from typing import Any, Literal +from firefly_weave.contracts.workers import WorkerInstance, WorkerStatus from firefly_weave.definitions.models import CatalogError from firefly_weave.persistence.uow import Transaction from firefly_weave.runtime.kernel import checked_deadline @@ -43,6 +44,35 @@ def unavailable() -> CatalogError: return CatalogError(409, "WV-LEASE", "Lease or worker authority unavailable") +def observed_worker( + instance: WorkerInstance, + *, + last_seen_at: datetime | None, + draining: bool, + revision: int, + active_leases: int, + observed_at: datetime, +) -> WorkerStatus: + expires = last_seen_at + timedelta(seconds=60) if last_seen_at else None + presence: Literal["unknown", "recent", "stale"] = ( + "unknown" if expires is None else "recent" if expires > observed_at else "stale" + ) + available = None if presence != "recent" else max(0, instance.capacity - active_leases) + if draining or instance.revoked: + available = 0 + return WorkerStatus( + **instance.model_dump(), + revision=revision, + draining=draining, + presence=presence, + last_seen_at=last_seen_at, + presence_expires_at=expires, + observed_at=observed_at, + active_leases=active_leases, + available_capacity=available, + ) + + @dataclass(frozen=True) class VerifiedTask: """In-process lease authority. Never deserialized from requests or exposed by HTTP.""" diff --git a/src/firefly_weave/workers/repository.py b/src/firefly_weave/workers/repository.py index 71b5db09..959d08c9 100644 --- a/src/firefly_weave/workers/repository.py +++ b/src/firefly_weave/workers/repository.py @@ -23,9 +23,10 @@ from sqlalchemy import text -from firefly_weave.contracts.workers import CompletionReceipt, WorkerInstance, WorkerRelease +from firefly_weave.contracts.workers import CompletionReceipt, WorkerInstance, WorkerRelease, WorkerStatus +from firefly_weave.definitions.models import CatalogError from firefly_weave.persistence.uow import Transaction -from firefly_weave.workers.models import unavailable +from firefly_weave.workers.models import observed_worker, unavailable SCOPE = "tenant_id=:tenant AND project_id=:project AND environment_id=:environment" @@ -61,6 +62,41 @@ async def instance(self, identifier: UUID, *, lock: bool = False) -> WorkerInsta raise unavailable() return WorkerInstance.model_validate_json(json.dumps(rows[0]["payload"])) + async def draining(self, identifier: UUID) -> bool: + rows = await self.rows(f"SELECT draining FROM worker_instances WHERE {SCOPE} AND id=:id", id=identifier) + if not rows: + raise unavailable() + return bool(rows[0]["draining"]) + + async def observe(self, identifier: UUID) -> None: + # Idle polling is frequent; coalesce observations without inventing liveness. + await self.execute( + f"UPDATE worker_instances SET last_seen_at=clock_timestamp() WHERE {SCOPE} AND id=:id " + "AND NOT revoked AND (last_seen_at IS NULL OR last_seen_at WorkerStatus: + now = observed_at or await self.now() + rows = await self.rows(f"SELECT * FROM worker_instances WHERE {SCOPE} AND id=:id", id=identifier) + if not rows: + raise CatalogError(404, "WV-NOT-FOUND", "Worker resource not found") + row = rows[0] + active = await self.rows( + f"SELECT count(*) AS count FROM task_leases WHERE {SCOPE} AND owner=:id " + "AND status='active' AND expires_at>:now AND deadline>:now", + id=identifier, + now=now, + ) + return observed_worker( + WorkerInstance.model_validate_json(json.dumps({**row["payload"], "revoked": row["revoked"]})), + last_seen_at=row["last_seen_at"], + draining=row["draining"], + revision=row["control_revision"], + active_leases=active[0]["count"], + observed_at=now, + ) + async def lease(self, task: UUID, generation: int) -> dict[str, Any]: rows = await self.rows( f"SELECT * FROM task_leases WHERE {SCOPE} AND task_id=:task AND generation=:generation FOR UPDATE", diff --git a/src/firefly_weave/workers/service.py b/src/firefly_weave/workers/service.py index 619311ac..a2db6405 100644 --- a/src/firefly_weave/workers/service.py +++ b/src/firefly_weave/workers/service.py @@ -17,6 +17,7 @@ """Immutable release admission and scoped worker identities.""" import json +from datetime import datetime from typing import Any from uuid import UUID, uuid4 @@ -37,14 +38,16 @@ CredentialGrantRequest, InstanceRequest, ReleaseRequest, + WorkerControlRequest, WorkerInstance, WorkerRelease, + WorkerStatus, ) from firefly_weave.definitions.models import CatalogError from firefly_weave.definitions.ports import ConnectionBindingPort, WorkerAdmissionPort from firefly_weave.definitions.repository import DefinitionRepository from firefly_weave.definitions.service import DefinitionService -from firefly_weave.persistence.idempotency import lock +from firefly_weave.persistence.idempotency import Idempotency, lock from firefly_weave.persistence.uow import Transaction from firefly_weave.workers.repository import SCOPE, WorkerRepository @@ -159,6 +162,7 @@ async def register_instance( instance = WorkerInstance(id=uuid4(), principal_id=actor.id, **request.model_dump()) await repository.execute( "INSERT INTO worker_instances " + "(id,tenant_id,project_id,environment_id,release_id,principal_id,payload,revoked) " "VALUES(:id,:tenant,:project,:environment,:release,:principal," "cast(:payload AS jsonb),false)", id=instance.id, @@ -183,7 +187,9 @@ async def revoke( async with self.definitions.transaction(scope, tx) as tx: await self.require(actor, scope, "release.retire", context, tx) await WorkerRepository(tx).execute( - f"UPDATE worker_instances SET revoked=true WHERE {SCOPE} AND id=:id", id=identifier + f"UPDATE worker_instances SET revoked=true,control_revision=control_revision+1 " + f"WHERE {SCOPE} AND id=:id AND NOT revoked", + id=identifier, ) await audit( tx.session, @@ -195,6 +201,60 @@ async def revoke( context=context, ) + async def control( + self, + actor: Principal, + scope: Scope, + identifier: UUID, + request: WorkerControlRequest, + idempotency_key: str, + *, + draining: bool, + context: AuditContext, + tx: Transaction | None = None, + ) -> WorkerStatus: + async with self.definitions.transaction(scope, tx) as enlisted: + await self.require(actor, scope, "worker.drain", context, enlisted, str(identifier)) + command = "drain" if draining else "resume" + replay = Idempotency( + enlisted, + actor.id, + f"worker.{command}:{scope.environment_id}:{identifier}", + idempotency_key, + request.model_dump(mode="json"), + ) + if prior := await replay.replay(): + return WorkerStatus.model_validate_json(json.dumps(prior)) + repository = WorkerRepository(enlisted) + rows = await repository.rows( + f"SELECT revoked,control_revision FROM worker_instances WHERE {SCOPE} AND id=:id FOR UPDATE", + id=identifier, + ) + if not rows: + raise CatalogError(404, "WV-NOT-FOUND", "Worker resource not found") + if rows[0]["revoked"]: + raise CatalogError(409, "WV-WORKER-REVOKED", "A revoked worker cannot be drained or resumed") + if rows[0]["control_revision"] != request.expected_revision: + raise CatalogError(409, "WV-WORKER-REVISION", "Worker control state changed; refresh before retrying") + await repository.execute( + f"UPDATE worker_instances SET draining=:draining,control_revision=control_revision+1 " + f"WHERE {SCOPE} AND id=:id", + id=identifier, + draining=draining, + ) + result = await repository.status(identifier) + await audit( + enlisted.session, + actor, + f"worker.{command}", + str(identifier), + scope=scope, + capability="worker.drain", + context=context, + ) + await replay.save(result.model_dump(mode="json")) + return result + async def admit( self, actor: Principal, @@ -368,10 +428,13 @@ async def list( async with self.definitions.transaction(scope, None, mutation=False) as tx: await self.require(actor, scope, capability, context, tx) ids = await page_ids(tx, "worker_releases" if releases else "worker_instances", limit, cursor) + observed_at = await WorkerRepository(tx).now() items = [ - (await self.read(actor, scope, identifier, releases=releases, context=context, tx=tx)).model_dump( - mode="json", by_alias=True - ) + ( + await self.read( + actor, scope, identifier, releases=releases, context=context, tx=tx, observed_at=observed_at + ) + ).model_dump(mode="json", by_alias=True) for identifier in ids[:limit] ] return {"items": items, "next_cursor": str(ids[limit - 1]) if len(ids) > limit else None} @@ -385,9 +448,17 @@ async def read( releases: bool = False, context: AuditContext, tx: Transaction | None = None, - ) -> WorkerInstance | WorkerRelease: + observed_at: datetime | None = None, + ) -> WorkerStatus | WorkerRelease: async with self.definitions.transaction(scope, tx, mutation=False) as enlisted: - await self.require(actor, scope, "catalog.read" if releases else "status.read", context, enlisted) + await self.require( + actor, + scope, + "catalog.read" if releases else "status.read", + context, + enlisted, + None if releases else str(identifier), + ) table = "worker_releases" if releases else "worker_instances" rows = await WorkerRepository(enlisted).rows( f"SELECT * FROM {table} WHERE {SCOPE} AND id=:id", id=identifier @@ -396,4 +467,4 @@ async def read( raise CatalogError(404, "WV-NOT-FOUND", "Worker resource not found") if releases: return WorkerRelease.model_validate_json(json.dumps(rows[0]["payload"])) - return WorkerInstance.model_validate_json(json.dumps({**rows[0]["payload"], "revoked": rows[0]["revoked"]})) + return await WorkerRepository(enlisted).status(identifier, observed_at) diff --git a/studio/src/app/app.html b/studio/src/app/app.html index 4e22e7eb..0b44a92e 100644 --- a/studio/src/app/app.html +++ b/studio/src/app/app.html @@ -417,6 +417,12 @@

Your workflows, woven together.

} + } @else if (view === "operations") { +
+ @defer (on immediate) { + + } +
} @else if (view === "settings") {
@defer (on immediate) { diff --git a/studio/src/app/app.ts b/studio/src/app/app.ts index 8410bd27..8c12a68a 100644 --- a/studio/src/app/app.ts +++ b/studio/src/app/app.ts @@ -146,6 +146,7 @@ import { workspaceShort, } from "./format"; // The operations pages and Settings load lazily (@defer): only these classes. +import { OperationsView } from "./operations/operations-view"; import { RecordsView } from "./operations/records-view"; import { SettingsPage } from "./settings/settings-page"; import { LumiPanel } from "./lumi/lumi-panel"; @@ -168,6 +169,7 @@ export type View = | "email" | "connections" | "workers" + | "operations" | "settings" | "connect"; type PlatformNoticeKind = @@ -318,6 +320,7 @@ const sideEffects: Record = { ToastHost, DesignerView, RecordsView, + OperationsView, SettingsPage, LumiPanel, ], @@ -535,12 +538,13 @@ export class App { { id: "tasks", label: "My tasks" }, { id: "email", label: "Email" }, { id: "workers", label: "Workers" }, + { id: "operations", label: "Operations" }, { id: "settings", label: "Settings" }, ]; readonly navGroups = [ { label: "", items: this.nav.slice(0, 1) }, { label: "Build", items: this.nav.slice(1, 3) }, - { label: "Operate", items: this.nav.slice(3, 7) }, + { label: "Operate", items: this.nav.slice(3, 8) }, ]; kinds = kinds; paletteQuery = ""; @@ -4972,7 +4976,7 @@ export class App { await this.loadAdministration(); return; } - if (this.view === "connect") return; + if (this.view === "connect" || this.view === "operations") return; if (this.view === "home") { await this.refreshHome(); return; diff --git a/studio/src/app/icon.ts b/studio/src/app/icon.ts index 81d6ca4f..897b6e4f 100644 --- a/studio/src/app/icon.ts +++ b/studio/src/app/icon.ts @@ -32,6 +32,7 @@ export const iconPaths: Readonly> = { tasks: "M8 4V2h8v2M5 4h14v18H5zM8 11l2 2 5-5M8 17h7", email: "M3 5h18v14H3zM3 6l9 7 9-7", connections: "M8 2v5M16 2v5M5 7h14v3a7 7 0 0 1-14 0zM12 17v5", + operations: "M3 3h7v6H3zM14 3h7v6h-7zM8 16h8v5H8zM6 9v4h12V9M12 13v3", workers: "M5 5h14v14H5zM9 1v4M15 1v4M9 19v4M15 19v4M1 9h4M1 15h4M19 9h4M19 15h4M9 9h6v6H9z", settings: diff --git a/studio/src/app/operations/deployment-contracts.ts b/studio/src/app/operations/deployment-contracts.ts new file mode 100644 index 00000000..c411c579 --- /dev/null +++ b/studio/src/app/operations/deployment-contracts.ts @@ -0,0 +1,210 @@ +/* +Copyright 2026 Firefly Software Foundation. + +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. +Author: Firefly Software Foundation +SPDX-License-Identifier: Apache-2.0 +*/ +/** Mirrors the canonical contracts/deployments.py responses; no provider credentials. */ +export type Adapter = "docker-compose" | "kubernetes" | "azure-container-apps"; +export type Capability = + | "observe" + | "deploy" + | "update" + | "scale_workers" + | "drain_workers"; +export type JobState = + | "queued" + | "claimed" + | "running" + | "verifying" + | "succeeded" + | "failed" + | "cancelled" + | "reconciliation_required"; +export interface Scope { + tenant_id: string; + project_id: string | null; + environment_id: string | null; +} +export interface TargetRequest { + name: string; + adapter: Adapter; + external_identity: string; + boundary: string; + runner_principal_id: string; + capabilities: Capability[]; +} +export interface TargetUpdate { + runner_principal_id: string; + capabilities: Capability[]; + disabled: boolean; +} +export interface Target extends TargetRequest { + id: string; + scope: Scope; + revision: number; + disabled: boolean; + created_at: string; +} +export interface ComponentSpec { + name: string; + kind: "api" | "worker" | "lumi" | "migration"; + image: string; + configuration: string; + replicas: number; + cpu_millis: number; + memory_mib: number; + worker_release_id: string | null; +} +export interface DeploymentRequest { + target_id: string; + name: string; + ownership: "imported" | "managed"; + components: ComponentSpec[]; +} +export interface Deployment extends DeploymentRequest { + id: string; + scope: Scope; + revision: number; + created_at: string; +} +export interface ObservedResource { + name: string; + external_identity: string; + kind: ComponentSpec["kind"] | "unknown"; + image: string | null; + replicas: number; + ready_replicas: number; + version: string; + ownership: "managed" | "imported" | "unknown"; + state: "ready" | "progressing" | "stopped" | "failed" | "unknown"; +} +export interface Observation { + id: string; + target_id: string; + target_revision: number; + observed_at: string; + expires_at: string; + digest: string; + resources: ObservedResource[]; + complete: boolean; + settled: boolean; +} +export interface PlanStep { + action: Exclude; + component: ComponentSpec; + expected_version: string | null; +} +export interface Plan { + id: string; + scope: Scope; + target_id: string; + target_revision: number; + deployment_id: string; + deployment_revision: number; + adapter: Adapter; + adapter_version: "1"; + intent: Exclude; + observation_id: string; + observation_digest: string; + steps: PlanStep[]; + risks: ( + | "service_interruption" + | "external_effects" + | "adoption" + | "worker_drain" + )[]; + created_at: string; + expires_at: string; + digest: string; +} +export interface Approval { + plan_id: string; + digest: string; + principal_id: string; + approved_at: string; +} +export interface Receipt { + code: + | "observed" + | "applied" + | "unsupported" + | "precondition_failed" + | "provider_failed" + | "cancelled" + | "ambiguous" + | "reconciled"; + changed_resources: string[]; + external_effects_may_continue: boolean; +} +export interface Job { + id: string; + scope: Scope; + target_id: string; + target_revision: number; + kind: "observe" | "apply"; + plan_id: string | null; + plan_digest: string | null; + state: JobState; + revision: number; + operation_key: string; + created_at: string; + deadline: string; + receipt: Receipt | null; + observation_id: string | null; + reconciliation_started_at: string | null; +} +export interface Runner { + id: string; + principal_id: string; + target_id: string; + adapter: Adapter; + adapter_version: "1"; + capabilities: Capability[]; + last_seen: string; + expires_at: string; + revoked: boolean; +} +export interface Collections { + targets: Target; + deployments: Deployment; + observations: Observation; + plans: Plan; + jobs: Job; + runners: Runner; +} +export type Collection = keyof Collections; +export interface Page { + items: T[]; + next_cursor: string | null; +} +export const terminalJobs = new Set([ + "succeeded", + "failed", + "cancelled", + "reconciliation_required", +]); +export const adapterLabels: Record = { + "docker-compose": "Docker / Compose", + kubernetes: "Kubernetes", + "azure-container-apps": "Azure Container Apps", +}; +export function observationState( + value: Observation | null, + now = Date.now(), +): "unknown" | "incomplete" | "expired" | "current" { + if (!value) return "unknown"; + if (!value.complete) return "incomplete"; + return Date.parse(value.expires_at) > now ? "current" : "expired"; +} diff --git a/studio/src/app/operations/deployment-editor.ts b/studio/src/app/operations/deployment-editor.ts new file mode 100644 index 00000000..f0b7999f --- /dev/null +++ b/studio/src/app/operations/deployment-editor.ts @@ -0,0 +1,219 @@ +/* +Copyright 2026 Firefly Software Foundation. + +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. +Author: Firefly Software Foundation +SPDX-License-Identifier: Apache-2.0 +*/ +import { Component, Input, OnChanges, output } from "@angular/core"; +import { AiSetupWizard } from "../integrations/ai-setup-wizard"; +import { TaskForm } from "../task-form"; +import { fieldsOf } from "../forms/core/resolve"; +import type { Schema } from "../task-schema"; +import { missingRequired } from "../task-schema"; +import type { + Deployment, + DeploymentRequest, + Target, +} from "./deployment-contracts"; + +@Component({ + selector: "weave-deployment-editor", + standalone: true, + imports: [AiSetupWizard, TaskForm], + template: ` + +
+

+ Target: {{ target.name }}. This saves metadata only; it does not start or change containers. +

+ +

+ Imported intent requires explicit plan review before changes. Managed + intent also requires a reviewed deployment plan. +

+
+
+

+ Use immutable image digests and named configuration references already + allowed by the runner. Keep secrets in the runner environment. Unset + optional limits use the canonical defaults shown in review. +

+ +

+ API/scheduler and migrations require exactly one instance. Workers + require an admitted worker release. Reducing replicas terminates + containers; drain worker instances before reducing a pool. +

+
+
+
+
Target
+
{{ target.name }}
+
Deployment
+
{{ identity["name"] }}
+
Management intent
+
{{ identity["ownership"] }}
+
Source revision
+
{{ existing?.revision ?? "New deployment" }}
+
+
    + @for (item of items; track $index) { +
  • + {{ item.name }} · {{ item.kind }} · + {{ item.replicas }} desired replicas
    {{ + item.image + }}
    Configuration reference: {{ item.configuration }} · + {{ item.cpu_millis }} CPU millicores · {{ item.memory_mib }} MiB + memory +
  • + } +
+

+ Next, collect a fresh observation and generate an immutable plan. + Saving this desired state grants no infrastructure authority. +

+
+ @if (error) { +

{{ error }}

+ } + +
+ `, + styles: ` + :host { + display: block; + min-width: 0; + } + dd { + margin: 4px 0 12px; + } + li { + margin: 12px 0; + overflow-wrap: anywhere; + } + code { + font-size: 12px; + overflow-wrap: anywhere; + } + `, +}) +export class DeploymentEditor implements OnChanges { + @Input({ required: true }) schema!: Schema; + @Input({ required: true }) target!: Target; + @Input() existing: Deployment | null = null; + @Input() busy = false; + @Input() error = ""; + submit = output(); + cancel = output(); + step = 0; + identityValid = true; + componentsValid = true; + identitySchema: Schema = {}; + componentsSchema: Schema = {}; + identityInitial: Record = {}; + componentsInitial: Record = {}; + identity: Record = {}; + components: Record = {}; + private componentDefaults: Record = {}; + ngOnChanges(changes: Record) { + if (!changes["schema"] && !changes["existing"] && !changes["target"]) + return; + const properties = this.schema.properties ?? {}; + this.componentDefaults = Object.fromEntries( + fieldsOf(properties["components"]?.items, { root: this.schema }) + .filter((field) => field.default !== undefined) + .map((field) => [field.key, field.default]), + ); + this.identitySchema = { + ...this.schema, + properties: { + name: this.existing + ? { ...properties["name"], enum: [this.existing.name] } + : properties["name"], + ownership: properties["ownership"], + }, + required: ["name", "ownership"], + }; + this.componentsSchema = { + ...this.schema, + properties: { components: properties["components"] }, + required: ["components"], + }; + this.identityInitial = { + name: this.existing?.name ?? "", + ownership: this.existing?.ownership ?? "imported", + }; + this.componentsInitial = { + components: this.existing + ? structuredClone(this.existing.components) + : [], + }; + this.identity = structuredClone(this.identityInitial); + this.components = structuredClone(this.componentsInitial); + this.step = 0; + this.identityValid = true; + this.componentsValid = true; + } + get items() { + return ( + (this.components["components"] ?? []) as DeploymentRequest["components"] + ).map((item) => ({ ...this.componentDefaults, ...item })); + } + get valid() { + const identity = + this.identityValid && + !missingRequired(this.identitySchema, this.identity).length; + return ( + identity && + (this.step === 0 || (this.componentsValid && this.items.length > 0)) + ); + } + save() { + if (this.valid && !this.busy) + this.submit.emit({ + target_id: this.target.id, + name: String(this.identity["name"]), + ownership: this.identity["ownership"] as DeploymentRequest["ownership"], + components: structuredClone(this.items), + }); + } +} diff --git a/studio/src/app/operations/deployment-plan-builder.ts b/studio/src/app/operations/deployment-plan-builder.ts new file mode 100644 index 00000000..a2cd0bf5 --- /dev/null +++ b/studio/src/app/operations/deployment-plan-builder.ts @@ -0,0 +1,146 @@ +/* +Copyright 2026 Firefly Software Foundation. + +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. +Author: Firefly Software Foundation +SPDX-License-Identifier: Apache-2.0 +*/ +import { Component, input, output } from "@angular/core"; +import { Select } from "../forms/ui/select"; +import type { + Deployment, + Observation, + Plan, + Target, +} from "./deployment-contracts"; + +export interface PlanRequest { + deployment_id: string; + deployment_revision: number; + observation_id: string; + intent: Plan["intent"]; + ttl_seconds: number; +} +@Component({ + selector: "weave-deployment-plan-builder", + standalone: true, + imports: [Select], + template: ` +

Generate an immutable plan

+

+ Desired revision {{ deployment().revision }} will be checked against one + complete, unexpired observation of target revision + {{ target().revision }}. +

+ + + @if (!choices.length) { +

+ No complete, current observation is available. Observe the target before + planning. +

+ } +

+ Plan validity is at most five minutes and never outlives its observation. + Cost and downtime estimates are unavailable. Infrastructure changes start + only after review, approval and apply. +

+ @if (error()) { +

{{ error() }}

+ } +
+ +
+ `, + styles: ` + :host { + display: grid; + gap: 14px; + } + .actions { + display: flex; + gap: 10px; + flex-wrap: wrap; + } + `, +}) +export class DeploymentPlanBuilder { + target = input.required(); + deployment = input.required(); + observations = input.required(); + busy = input(false); + error = input(""); + submit = output(); + cancel = output(); + intent: Plan["intent"] = "deploy"; + observationId = ""; + get operations() { + const labels = { + deploy: "Deploy / adopt deployment", + update: "Update deployment", + scale_workers: "Scale worker replicas", + }; + return Object.entries(labels) + .filter( + ([value]) => + this.target().capabilities.includes(value as Plan["intent"]) && + (value !== "scale_workers" || + this.deployment().components.some((c) => c.kind === "worker")), + ) + .map(([value, label]) => ({ value, label })); + } + get choices() { + return this.observations() + .filter( + (o) => + o.target_id === this.target().id && + o.target_revision === this.target().revision && + o.complete && + Date.parse(o.expires_at) > Date.now(), + ) + .map((o) => ({ + value: o.id, + label: "Current · " + o.observed_at + " · " + o.id.slice(0, 8), + })); + } + get valid() { + return ( + !this.target().disabled && + this.operations.some((o) => o.value === this.intent) && + this.choices.some((o) => o.value === this.observationId) + ); + } + save() { + if (this.valid && !this.busy()) + this.submit.emit({ + deployment_id: this.deployment().id, + deployment_revision: this.deployment().revision, + observation_id: this.observationId, + intent: this.intent, + ttl_seconds: 300, + }); + } +} diff --git a/studio/src/app/operations/deployment-reconciliation.ts b/studio/src/app/operations/deployment-reconciliation.ts new file mode 100644 index 00000000..93c847bb --- /dev/null +++ b/studio/src/app/operations/deployment-reconciliation.ts @@ -0,0 +1,117 @@ +/* +Copyright 2026 Firefly Software Foundation. + +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. +Author: Firefly Software Foundation +SPDX-License-Identifier: Apache-2.0 +*/ +import { Component, input, output } from "@angular/core"; +import { Select } from "../forms/ui/select"; +import type { Job, Observation, Target } from "./deployment-contracts"; +export interface ReconcileRequest { + plan_digest: string; + observation_id: string; + external_operation_stopped: true; +} +@Component({ + selector: "weave-deployment-reconciliation", + standalone: true, + imports: [Select], + template: `

Reconcile an uncertain result

+

+ First stop or verify completion of the external operation, then collect a + complete settled observation. This records the outcome as reconciled and + failed; it does not undo effects or apply another plan. +

+ + @if (!choices.length) { +

+ No fresh settled observation collected after this uncertainty is + available. Ask the authorized runner operator to verify the external + operation, then observe the target. +

+ } + + `, + styles: ` + :host { + display: grid; + gap: 14px; + padding-top: 18px; + margin-top: 18px; + border-top: 1px solid var(--line); + } + label { + display: flex; + gap: 10px; + align-items: flex-start; + } + `, +}) +export class DeploymentReconciliation { + job = input.required(); + target = input.required(); + observations = input.required(); + busy = input(false); + submit = output(); + observationId = ""; + confirmed = false; + get choices() { + const job = this.job(); + return this.observations() + .filter( + (o) => + o.target_id === job.target_id && + o.target_revision === this.target().revision && + o.complete && + o.settled && + Date.parse(o.expires_at) > Date.now() && + !!job.reconciliation_started_at && + Date.parse(o.observed_at) > Date.parse(job.reconciliation_started_at), + ) + .map((o) => ({ + value: o.id, + label: o.observed_at + " · " + o.id.slice(0, 8), + })); + } + get valid() { + return ( + this.job().state === "reconciliation_required" && + !!this.job().plan_digest && + this.confirmed && + this.choices.some((o) => o.value === this.observationId) + ); + } + save() { + if (this.valid && !this.busy()) + this.submit.emit({ + plan_digest: this.job().plan_digest!, + observation_id: this.observationId, + external_operation_stopped: true, + }); + } +} diff --git a/studio/src/app/operations/deployment-store.ts b/studio/src/app/operations/deployment-store.ts new file mode 100644 index 00000000..174cb761 --- /dev/null +++ b/studio/src/app/operations/deployment-store.ts @@ -0,0 +1,279 @@ +/* +Copyright 2026 Firefly Software Foundation. + +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. +Author: Firefly Software Foundation +SPDX-License-Identifier: Apache-2.0 +*/ +import { canonicalJson } from "../forms/core/json"; +import { describeError } from "../errors"; +import type { + Approval, + Collection, + Collections, + Page, +} from "./deployment-contracts"; + +export interface OperationsTransport { + request( + path: string, + method?: string, + body?: unknown, + headers?: Record, + ): Promise; +} +const collections: Record = { + targets: "deployment-targets", + deployments: "deployments", + observations: "deployment-observations", + plans: "deployment-plans", + jobs: "deployment-jobs", + runners: "deployment-runners", +}; +const emptyPages = (): { [K in Collection]: Collections[K][] } => ({ + targets: [], + deployments: [], + observations: [], + plans: [], + jobs: [], + runners: [], +}); + +/** Owns only this view's scoped responses. Platform authorization remains server-side. */ +export class OperationsStore { + private scope = ""; + private readable = false; + private authority = ""; + private filters: Partial> = {}; + private generation = 0; + private sequence: Partial> = {}; + private pages = emptyPages(); + cursors: Partial> = {}; + loading = new Set(); + errors: Partial> = {}; + mutating = false; + mutationError = ""; + mutationErrorCode = ""; + mutationUncertain = false; + private pendingMutation: { identity: string; key: string } | null = null; + constructor( + private transport: OperationsTransport, + private changed = () => {}, + ) {} + get targets() { + return this.pages.targets; + } + get deployments() { + return this.pages.deployments; + } + get observations() { + return this.pages.observations; + } + get plans() { + return this.pages.plans; + } + get jobs() { + return this.pages.jobs; + } + get runners() { + return this.pages.runners; + } + setScope(scope: string, readable: boolean, authority = "") { + if ( + scope === this.scope && + readable === this.readable && + authority === this.authority + ) + return; + this.authority = authority; + this.filters = {}; + this.generation++; + this.scope = scope; + this.readable = readable; + this.pages = emptyPages(); + this.cursors = {}; + this.errors = {}; + this.loading.clear(); + this.sequence = {}; + this.mutating = false; + this.cancelMutation(); + this.changed(); + } + async load( + collection: K, + append = false, + filters: { target_id?: string; deployment_id?: string } = {}, + ): Promise { + if (!this.scope || !this.readable || (append && !this.cursors[collection])) + return; + const filterKey = JSON.stringify(filters); + if (this.filters[collection] !== filterKey) { + this.filters[collection] = filterKey; + this.pages[collection] = []; + this.cursors[collection] = null; + append = false; + } + const generation = this.generation; + const sequence = (this.sequence[collection] ?? 0) + 1; + this.sequence[collection] = sequence; + const params = new URLSearchParams({ limit: "50" }); + if (append && this.cursors[collection]) + params.set("cursor", this.cursors[collection]!); + for (const [name, value] of Object.entries(filters)) + if (value) params.set(name, value); + this.loading.add(collection); + delete this.errors[collection]; + this.changed(); + try { + const result = await this.transport.request>( + `${this.scope}/${collections[collection]}?${params}`, + ); + if ( + generation !== this.generation || + sequence !== this.sequence[collection] + ) + return; + // Do not retain stale rows when refreshing a target-filtered collection. + this.pages[collection] = ( + append ? [...this.pages[collection], ...result.items] : result.items + ) as (typeof this.pages)[K]; + this.cursors[collection] = result.next_cursor; + } catch (error) { + if ( + generation === this.generation && + sequence === this.sequence[collection] + ) { + this.errors[collection] = describeError(error).message; + if (!append) { + this.pages[collection] = []; + this.cursors[collection] = null; + } + } + } finally { + if ( + generation === this.generation && + sequence === this.sequence[collection] + ) { + this.loading.delete(collection); + this.changed(); + } + } + } + async read( + collection: K, + id: string, + ): Promise { + if (!this.scope || !this.readable) return null; + const generation = this.generation; + const result = await this.transport.request( + `${this.scope}/${collections[collection]}/${encodeURIComponent(id)}`, + ); + return generation === this.generation ? result : null; + } + async approval(id: string): Promise { + if (!this.scope || !this.readable) return null; + const generation = this.generation; + const result = await this.transport.request( + `${this.scope}/deployment-plans/${encodeURIComponent(id)}/approval`, + ); + return generation === this.generation ? result : null; + } + canRecover(path: string, body: unknown, method = "POST", revision?: number) { + return ( + this.mutationUncertain && + this.pendingMutation?.identity === + canonicalJson([ + this.scope, + this.authority, + path, + method, + body, + revision ?? null, + ]) + ); + } + cancelMutation() { + if (this.mutating) return; + this.pendingMutation = null; + this.mutationError = ""; + this.mutationErrorCode = ""; + this.mutationUncertain = false; + } + async mutate( + path: string, + body: unknown, + method = "POST", + revision?: number, + key?: string, + ): Promise { + if (!this.scope || !this.readable || this.mutating) return null; + if ( + !/^(deployment-targets|deployments|deployment-observations|deployment-plans|deployment-jobs)(\/[A-Za-z0-9-]+){0,2}$/.test( + path, + ) + ) + throw Error("Unsupported Operations command"); + const identity = canonicalJson([ + this.scope, + this.authority, + path, + method, + body, + revision ?? null, + ]); + const intent = { + identity, + key: + key ?? + (this.pendingMutation?.identity === identity + ? this.pendingMutation.key + : crypto.randomUUID()), + }; + this.pendingMutation = intent; + const generation = this.generation; + this.mutating = true; + this.mutationError = ""; + this.changed(); + this.mutationErrorCode = ""; + this.mutationUncertain = false; + const headers: Record = { "Idempotency-Key": intent.key }; + if (revision !== undefined) headers["If-Match"] = `"${revision}"`; + try { + const result = await this.transport.request( + `${this.scope}/${path}`, + method, + body, + headers, + ); + if (generation === this.generation && this.pendingMutation === intent) + this.pendingMutation = null; + return generation === this.generation ? result : null; + } catch (error) { + if (generation === this.generation) { + const plain = describeError(error); + this.mutationError = plain.message; + this.mutationErrorCode = plain.code; + this.mutationUncertain = + plain.status === 0 || plain.status === 408 || plain.status >= 500; + if (!this.mutationUncertain && this.pendingMutation === intent) + this.pendingMutation = null; + } + return null; + } finally { + if (generation === this.generation) { + this.mutating = false; + this.changed(); + } + } + } +} diff --git a/studio/src/app/operations/operations-view.css b/studio/src/app/operations/operations-view.css new file mode 100644 index 00000000..e82447fc --- /dev/null +++ b/studio/src/app/operations/operations-view.css @@ -0,0 +1,130 @@ +/* +Copyright 2026 Firefly Software Foundation. + +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. +Author: Firefly Software Foundation +SPDX-License-Identifier: Apache-2.0 +*/ +:host { + display: block; + max-width: 1200px; + margin: 0 auto; + min-width: 0; +} +.operations-heading, +.panel-heading { + display: flex; + justify-content: space-between; + gap: 20px; + align-items: flex-start; +} +.operations-heading { + margin-bottom: 24px; +} +h1, +h2, +h3 { + margin-top: 0; +} +.eyebrow { + font-size: 11px; + font-weight: 650; + letter-spacing: 0.09em; + color: var(--muted); + margin: 0 0 8px; +} +.operations-heading p { + max-width: 72ch; +} +.operations-scope { + display: flex; + flex-wrap: wrap; + gap: 12px; + padding: 12px 16px; + background: var(--selected); + border: 1px solid var(--line); + border-radius: 8px; + margin-bottom: 20px; +} +.operations-panel { + border: 1px solid var(--line); + border-radius: 12px; + padding: 24px; + margin: 18px 0; + background: var(--surface); +} +.resource-cards { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(280px, 100%), 1fr)); + list-style: none; + padding: 0; + gap: 12px; +} +.resource-cards li { + display: grid; + align-content: start; + gap: 8px; + padding: 16px; + border: 1px solid var(--line); + border-radius: 8px; + min-width: 0; +} +.resource-cards button { + text-align: left; +} +.hint { + color: var(--muted); + font-size: 13px; +} +code, +dd, +li, +p { + overflow-wrap: anywhere; +} +code { + font-size: 12px; +} +dt { + font-weight: 600; +} +dd { + margin: 4px 0 14px; +} +.actions { + display: flex; + gap: 10px; + flex-wrap: wrap; +} +.notice { + padding: 14px; + border: 1px solid var(--line); + border-left: 3px solid var(--forest); + border-radius: 6px; +} +@media (max-width: 600px) { + .operations-heading, + .panel-heading { + display: grid; + gap: 12px; + } + .operations-panel { + padding: 16px; + } + .operations-scope { + display: grid; + } + .actions button { + width: 100%; + } +} diff --git a/studio/src/app/operations/operations-view.ts b/studio/src/app/operations/operations-view.ts new file mode 100644 index 00000000..8e779331 --- /dev/null +++ b/studio/src/app/operations/operations-view.ts @@ -0,0 +1,1190 @@ +/* +Copyright 2026 Firefly Software Foundation. + +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. +Author: Firefly Software Foundation +SPDX-License-Identifier: Apache-2.0 +*/ +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + DoCheck, + ElementRef, + HostListener, + Input, + OnDestroy, + inject, +} from "@angular/core"; +import { NgTemplateOutlet } from "@angular/common"; +import { Router } from "@angular/router"; +import type { App } from "../app"; +import { describeError } from "../errors"; +import { + DeploymentReconciliation, + type ReconcileRequest, +} from "./deployment-reconciliation"; +import { DeploymentEditor } from "./deployment-editor"; +import { + DeploymentPlanBuilder, + type PlanRequest, +} from "./deployment-plan-builder"; +import type { Schema } from "../task-schema"; +import { TargetWizard } from "./target-wizard"; +import { OperationsStore } from "./deployment-store"; +import { + adapterLabels, + observationState, + terminalJobs, + type Target, + type TargetRequest, + type TargetUpdate, + type Deployment, + type DeploymentRequest, + type Plan, + type Job, + type Approval, + type Collection, +} from "./deployment-contracts"; + +@Component({ + selector: "weave-operations-view", + standalone: true, + changeDetection: ChangeDetectionStrategy.Eager, + imports: [ + TargetWizard, + NgTemplateOutlet, + DeploymentEditor, + DeploymentPlanBuilder, + DeploymentReconciliation, + ], + styleUrl: "./operations-view.css", + template: ` +
+
+

CONTAINER OPERATIONS

+

Operations

+

+ Targets, deployments and their verified observations in this + environment. +

+
+ @if (host.profile && canRead) { + + } +
+ @if (!host.profile) { +
+

Connect to manage deployment targets

+

+ Infrastructure state comes from authorized outbound runners. Local + workflow authoring does not connect to Docker, Kubernetes or a cloud + account. +

+ +
+ } @else if (host.signInEnded || !host.identity) { +
+

Sign in to view Operations

+

+ A current platform identity is required before target information can + be requested. +

+
+ } @else if (!canRead) { +
+

Operations access is required

+

+ Ask an administrator for deployment.read in this environment or on the + target you manage. +

+
+ } @else { +
+ {{ host.profile.environmentId }}API and scheduler: one instance. Worker replicas are managed + separately. +
+ @if (error || store.mutationError) { + + } + @if (store.mutationUncertain) { +

+ The response was lost; the command may have been accepted. Retrying + unchanged values in this view reuses the same request identity to + recover its original result. Studio does not automatically retry this + uncertain result. +

+ } + @if (screen !== "overview") { + + } + @if (editingAuthority && target && targetUpdateSchema) { +
+

Edit target authority

+ + @if (store.mutationErrorCode === "WV-REVISION") { +

+ The reviewed revision changed. Reload the target, then review a + new change before saving. +

+ + } +
+ } @else if (editing && editorTarget && deploymentSchema) { +
+

+ {{ + deployment + ? "Edit desired deployment" + : "Record desired deployment" + }} +

+ +
+ } @else if (registering) { +
+

Register an existing target

+ +
+ } @else if (screen === "target" && target) { +
+

DEPLOYMENT TARGET

+

{{ target.name }}

+
+
Runtime
+
{{ adapterLabels[target.adapter] }}
+
External identity
+
{{ target.external_identity }}
+
Allowed boundary
+
{{ target.boundary }}
+
Authority revision
+
{{ target.revision }}
+
Permitted operations
+
{{ target.capabilities.join(", ") }}
+
+ @if (target.disabled) { +

+ This target is disabled. No new operation can start. +

+ } +

+ Permitted operations are policy, not proof of a working adapter. A + runner must independently support the operation within its local + authority. +

+ @if (can("target.manage")) { + + } + @if (can("deployment.plan")) { + + } +
+
+

Observed resources

+ @if (store.loading.has("observations")) { +

Loading observation…

+ } @else if (store.errors.observations) { +

{{ store.errors.observations }}

+ } @else if (latestObservation; as observation) { +

+ {{ observationState(observation) }} · Observed + {{ observation.observed_at }} · Expires + {{ observation.expires_at }} +

+

+ An observation is a bounded snapshot. It does not guarantee + current availability. +

+
    + @for ( + resource of observation.resources; + track resource.external_identity + ) { +
  • + {{ resource.name }}{{ resource.kind }} · {{ resource.state }}{{ resource.ready_replicas }} ready of + {{ resource.replicas }} observed replicas{{ + resource.image || "Image identity unavailable" + }} +
  • + } @empty { +
  • No resources were reported in this observation.
  • + } +
+ } @else { +

+ No observation yet. Request one after installing the authorized + runner. +

+ } +
+
+

Outbound runners

+

+ Current contact is presence, not container health or available + worker capacity. +

+ @if (store.errors.runners) { +

{{ store.errors.runners }}

+ } +
    + @for (runner of store.runners; track runner.id) { +
  • + {{ runner.id }}{{ + runner.revoked + ? "Revoked" + : fresh(runner.expires_at) + ? "Recent contact" + : "Contact expired" + }}Last contact {{ runner.last_seen }}Reported capabilities: + {{ runner.capabilities.join(", ") }} +
  • + } @empty { +
  • No runner registration has been returned.
  • + } +
+
+
+

Deployments

+ @if (can("target.manage")) { + + } + +
+ } @else if (screen === "deployment" && deployment) { +
+

DESIRED DEPLOYMENT

+

{{ deployment.name }}

+

Revision {{ deployment.revision }} · {{ deployment.ownership }}

+

+ Desired components are intent, not observed availability. Imported + intent requires explicit review of a deployment plan before changes. + Ownership here records declared intent; it is not automatically + changed by a successful job. +

+
+ @if (can("target.manage")) { + + } + @if (can("deployment.plan")) { + + } + +
+
    + @for (component of deployment.components; track component.name) { +
  • + {{ component.name }}{{ component.kind }} · {{ component.replicas }} desired + replicas{{ component.image }}Runner configuration reference: + {{ component.configuration }} +
  • + } +
+
+ @if (planning && relatedTarget) { +
+ + @if (store.cursors.observations) { + + } +
+ } +
+

Plans

+ @if (store.errors.plans) { +

{{ store.errors.plans }}

+ } +
    + @for (plan of store.plans; track plan.id) { +
  • + {{ + fresh(plan.expires_at) ? "Review required" : "Expired" + }}{{ plan.digest }} +
  • + } @empty { +
  • No plan has been returned for this deployment.
  • + } +
+
+ } @else if (screen === "plan" && plan) { +
+

IMMUTABLE PLAN

+

Review {{ plan.intent }}

+

+ Target revision {{ plan.target_revision }} · Deployment revision + {{ plan.deployment_revision }} +

+
+
Reviewed digest
+
+ {{ plan.digest }} +
+
Observation digest
+
+ {{ plan.observation_digest }} +
+
Expires
+
{{ plan.expires_at }}
+
Estimated cost / duration
+
Not available
+
+ @if (!fresh(plan.expires_at)) { +

+ @if (recoverApply) { + This plan has expired. An unchanged recovery request can + retrieve an earlier accepted result; a new apply remains + blocked. + } @else { + This plan has expired. Create and review a new plan before + applying. + } +

+ } +

Changes and preconditions

+
    + @for (step of plan.steps; track step.component.name) { +
  • + {{ step.action }} {{ step.component.name }}{{ step.component.kind }} · + {{ step.component.replicas }} desired replicas{{ step.component.image }}Expected external version: + {{ step.expected_version || "Resource must not exist" }} +
  • + } +
+

Operational risks

+
    + @for (risk of plan.risks; track risk) { +
  • {{ riskLabel(risk) }}
  • + } @empty { +
  • No additional risks were reported by the planner.
  • + } +
+

+ Approval and apply use this exact digest. The server rechecks + authority, revisions and observation. Cancellation cannot undo + external effects. +

+ @if (approval) { +

Approval recorded for {{ approval.digest }}

+

+ Recorded approval is historical evidence. Apply rechecks the + approver’s current authority and all plan preconditions. +

+ } @else { +

+ Approval has not been confirmed in this view. Applying requires a + recorded approval for this exact digest; the server checks it + again. +

+ } +
+ @if (can("deployment.approve")) { + + } + @if (can("deployment.apply")) { + + } +
+
+ } @else if (screen === "job" && job) { +
+

DURABLE OPERATION

+

+ {{ + job.kind === "observe" + ? "Observe target" + : "Apply deployment plan" + }} +

+

+ {{ job.state.replaceAll("_", " ") }} +

+
+
Job ID
+
+ {{ job.id }} +
+
Created
+
{{ job.created_at }}
+
Deadline
+
{{ job.deadline }}
+
Target revision
+
{{ job.target_revision }}
+
+ @if (job.state === "reconciliation_required") { +

+ The external result is uncertain. Observe the target and review + what changed before creating another plan. Do not retry blindly. +

+ } + @if (job.receipt; as receipt) { +

Operation receipt

+

{{ receipt.code.replaceAll("_", " ") }}

+
    + @for (resource of receipt.changed_resources; track resource) { +
  • {{ resource }}
  • + } +
+ @if (receipt.external_effects_may_continue) { +

+ External effects may continue. Cancellation is not a rollback. +

+ } + } + @if ( + job.state === "reconciliation_required" && + can("deployment.apply") && + can("deployment.approve") && + relatedTarget + ) { + + @if (store.cursors.observations) { + + } + } + @if (can("deployment.cancel") && !terminalJobs.has(job.state)) { + + } + +
+ } @else if (screen === "overview") { +
+
+
+

Deployment targets

+

Existing container runtimes and their explicit boundaries.

+
+ @if (can("target.manage")) { + + } +
+ @if (store.loading.has("targets")) { +

Loading targets…

+ } + @if (store.errors.targets) { +

{{ store.errors.targets }}

+ } +
    + @for (item of store.targets; track item.id) { +
  • + {{ adapterLabels[item.adapter] }} · {{ item.boundary }}{{ item.disabled ? "Disabled" : "Registered" }} +
  • + } @empty { + @if (!store.loading.has("targets") && !store.errors.targets) { +
  • + No deployment target has been returned in this environment. +
  • + } + } +
+ @if (store.cursors.targets) { + + } +
+
+

Deployments

+ +
+
+

Operations history

+ @if (store.errors.jobs) { +

{{ store.errors.jobs }}

+ } +
    + @for (item of store.jobs; track item.id) { +
  • + {{ item.state.replaceAll("_", " ") }} +
  • + } @empty { +
  • No operation has been returned.
  • + } +
+ @if (store.cursors.jobs) { + + } +
+ } @else { +

Loading selected resource…

+ } + + @if (store.errors.deployments) { +

{{ store.errors.deployments }}

+ } +
    + @for (item of store.deployments; track item.id) { +
  • + {{ item.ownership }} · desired revision + {{ item.revision }} +
  • + } @empty { +
  • No deployment has been returned.
  • + } +
+ @if (store.cursors.deployments) { + + } +
+ } + `, +}) +export class OperationsView implements DoCheck, OnDestroy { + @Input({ required: true }) host!: App; + private cdr = inject(ChangeDetectorRef); + private element = inject>(ElementRef); + private router = inject(Router); + readonly store = new OperationsStore( + { + request: (path, method, body, headers) => + this.host.api.request(path, method, body, headers), + }, + () => this.cdr.markForCheck(), + ); + readonly adapterLabels = adapterLabels; + readonly terminalJobs = terminalJobs; + readonly observationState = observationState; + screen: "overview" | "target" | "deployment" | "plan" | "job" = "overview"; + target: Target | null = null; + deployment: Deployment | null = null; + plan: Plan | null = null; + job: Job | null = null; + approval: Approval | null = null; + registering = false; + editingAuthority = false; + targetUpdateSchema: Schema | null = null; + editing = false; + planning = false; + relatedTarget: Target | null = null; + editorTarget: Target | null = null; + deploymentSchema: Schema | null = null; + error = ""; + private scopeKey = ""; + private alive = true; + private detailSequence = 0; + private jobReadSequence = 0; + private poll = setInterval(() => { + this.cdr.markForCheck(); + if ( + this.alive && + document.visibilityState === "visible" && + this.job && + !terminalJobs.has(this.job.state) && + !this.store.mutating + ) + void this.refresh(); + }, 10000); + get canRead() { + return ( + !this.host.signInEnded && + (this.host.can("deployment.read") || + !!this.host.identity?.grants.some((g) => + g.resources.some((id) => this.host.can("deployment.read", id)), + )) + ); + } + can(capability: string) { + return this.host.can( + capability, + this.target?.id ?? + this.deployment?.target_id ?? + this.plan?.target_id ?? + this.job?.target_id, + ); + } + fresh(expires: string) { + return Date.parse(expires) > Date.now(); + } + get latestObservation() { + return ( + [...this.store.observations].sort((a, b) => + b.observed_at.localeCompare(a.observed_at), + )[0] ?? null + ); + } + ngDoCheck() { + let scope = ""; + try { + if (this.host.profile) scope = this.host.api.environment; + } catch { + /* No authorized environment yet. */ + } + const authority = JSON.stringify(this.host.identity); + const key = scope + ":" + this.canRead + ":" + authority; + if (key === this.scopeKey) return; + this.scopeKey = key; + this.detailSequence++; + this.target = null; + this.deployment = null; + this.plan = null; + this.job = null; + this.approval = null; + this.registering = false; + this.editing = false; + this.editingAuthority = false; + this.planning = false; + this.relatedTarget = null; + this.editorTarget = null; + this.error = ""; + this.store.setScope(scope, this.canRead, authority); + if (scope && this.canRead) void this.readLocation(); + } + ngOnDestroy() { + this.alive = false; + clearInterval(this.poll); + this.detailSequence++; + this.store.setScope("", false); + } + @HostListener("window:popstate") locationChanged() { + void this.readLocation(); + } + private async readLocation() { + this.resetDetails(); + const match = + /^\/operations\/(targets|deployments|plans|jobs)\/([0-9a-f-]{36})$/.exec( + location.pathname, + ); + if (!match) { + this.screen = "overview"; + await this.refresh(); + return; + } + const [_, collection, id] = match; + this.screen = ( + { + targets: "target", + deployments: "deployment", + plans: "plan", + jobs: "job", + } as const + )[collection as "targets" | "deployments" | "plans" | "jobs"]; + const seq = ++this.detailSequence; + try { + const value = await this.store.read(collection as "targets", id); + if (!this.alive || seq !== this.detailSequence || !value) return; + if (collection === "targets") await this.openTarget(value, false); + else if (collection === "deployments") + await this.openDeployment(value as unknown as Deployment, false); + else if (collection === "plans") + await this.openPlan(value as unknown as Plan, false); + else await this.openJob(value as unknown as Job, false); + } catch (error) { + if (seq === this.detailSequence) { + this.error = describeError(error).message; + this.cdr.markForCheck(); + } + } + } + private resetDetails() { + this.target = null; + this.deployment = null; + this.plan = null; + this.job = null; + this.approval = null; + this.error = ""; + this.registering = false; + this.editing = false; + this.editingAuthority = false; + this.planning = false; + this.relatedTarget = null; + this.editorTarget = null; + this.detailSequence++; + } + async overview() { + this.resetDetails(); + this.screen = "overview"; + await this.router.navigateByUrl("/operations"); + await this.refresh(); + } + async openTarget(value: Target, navigate = true) { + this.resetDetails(); + this.target = value; + this.screen = "target"; + if (navigate) + await this.router.navigateByUrl("/operations/targets/" + value.id); + this.focusDetail(); + await this.refresh(); + } + async openDeployment(value: Deployment, navigate = true) { + this.resetDetails(); + this.deployment = value; + this.screen = "deployment"; + if (navigate) + await this.router.navigateByUrl("/operations/deployments/" + value.id); + this.focusDetail(); + await this.refresh(); + } + async openPlan(value: Plan, navigate = true) { + this.resetDetails(); + this.plan = value; + this.screen = "plan"; + if (navigate) + await this.router.navigateByUrl("/operations/plans/" + value.id); + this.focusDetail(); + await this.refresh(); + } + async openJob(value: Job, navigate = true) { + this.resetDetails(); + this.job = value; + this.screen = "job"; + if (navigate) + await this.router.navigateByUrl("/operations/jobs/" + value.id); + this.focusDetail(); + await this.refresh(); + } + private focusDetail() { + const seq = this.detailSequence; + this.cdr.detectChanges(); + requestAnimationFrame(() => { + if (!this.alive || seq !== this.detailSequence) return; + const heading = this.element.nativeElement.querySelector( + ".operations-panel h2[tabindex]", + ); + heading?.focus(); + }); + } + async readTarget(id: string) { + const seq = ++this.detailSequence; + try { + const result = await this.store.read("targets", id); + if (result && seq === this.detailSequence) await this.openTarget(result); + } catch (error) { + if (seq === this.detailSequence) + this.error = describeError(error).message; + } + } + async refresh() { + if (!this.canRead || !this.host.profile) return; + if (this.target) + await Promise.all( + ["observations", "runners", "deployments"].map((c) => + this.store.load(c as Collection, false, { + target_id: this.target!.id, + }), + ), + ); + else if (this.deployment) { + const deployment = this.deployment; + const seq = this.detailSequence; + await Promise.all([ + this.store.load("plans", false, { + target_id: deployment.target_id, + deployment_id: deployment.id, + }), + this.store.load("observations", false, { + target_id: deployment.target_id, + }), + this.store + .read("targets", deployment.target_id) + .then((value) => { + if (seq === this.detailSequence) this.relatedTarget = value; + }) + .catch((error) => { + if (seq === this.detailSequence) + this.error = describeError(error).message; + }), + ]); + } else if (this.job) { + const selected = this.job; + const seq = this.detailSequence; + const read = ++this.jobReadSequence; + try { + const result = await this.store.read("jobs", selected.id); + if ( + result && + seq === this.detailSequence && + read === this.jobReadSequence + ) + this.job = result; + } catch (error) { + if (seq === this.detailSequence) + this.error = describeError(error).message; + } + if ( + this.job?.state === "reconciliation_required" && + seq === this.detailSequence + ) + await Promise.all([ + this.store.load("observations", false, { + target_id: selected.target_id, + }), + this.store + .read("targets", selected.target_id) + .then((value) => { + if (seq === this.detailSequence) this.relatedTarget = value; + }) + .catch((error) => { + if (seq === this.detailSequence) + this.error = describeError(error).message; + }), + ]); + } else if (this.plan) { + const seq = this.detailSequence; + try { + const result = await this.store.approval(this.plan.id); + if (seq === this.detailSequence) this.approval = result; + } catch (error) { + if (seq === this.detailSequence) + this.error = describeError(error).message; + } + } else if (!this.plan) + await Promise.all( + ["targets", "deployments", "jobs"].map((c) => + this.store.load(c as Collection), + ), + ); + this.cdr.markForCheck(); + } + loadMore(collection: Collection) { + const target_id = + this.target?.id ?? this.deployment?.target_id ?? this.job?.target_id; + return this.store.load(collection, true, { + ...(target_id ? { target_id } : {}), + ...(collection === "plans" && this.deployment + ? { deployment_id: this.deployment.id } + : {}), + }); + } + cancelDraft() { + if (this.store.mutating) return; + this.editing = false; + this.editingAuthority = false; + this.registering = false; + this.planning = false; + this.store.cancelMutation(); + } + async editTargetAuthority() { + if (!this.target || !this.can("target.manage") || this.store.mutating) + return; + const seq = this.detailSequence; + try { + const schema = await this.host.api.request( + "/studio/contracts/deployment-target-update", + ); + if (seq !== this.detailSequence) return; + this.targetUpdateSchema = schema; + this.editingAuthority = true; + this.store.cancelMutation(); + this.cdr.markForCheck(); + } catch (error) { + if (seq === this.detailSequence) + this.error = describeError(error).message; + } + } + async saveTargetAuthority(request: TargetUpdate) { + if ( + !this.target || + !this.can("target.manage") || + this.store.mutationErrorCode === "WV-REVISION" + ) + return; + const seq = this.detailSequence; + const result = await this.store.mutate( + "deployment-targets/" + this.target.id, + request, + "PUT", + this.target.revision, + ); + if (result && seq === this.detailSequence) await this.openTarget(result); + } + async reloadTargetAuthority() { + if (!this.target || this.store.mutating) return; + const id = this.target.id; + const seq = ++this.detailSequence; + try { + const target = await this.store.read("targets", id); + if (target && seq === this.detailSequence) { + this.store.cancelMutation(); + await this.openTarget(target); + } + } catch (error) { + if (seq === this.detailSequence) + this.error = describeError(error).message; + } finally { + if (this.alive) this.cdr.markForCheck(); + } + } + async editDeployment() { + if (!this.can("target.manage")) return; + const seq = this.detailSequence; + const target = this.target ?? this.relatedTarget; + if (!target || target.disabled) return; + try { + const schema = await this.host.api.request( + "/studio/contracts/deployment", + ); + if (seq !== this.detailSequence) return; + this.deploymentSchema = schema; + this.editorTarget = target; + this.editing = true; + this.cdr.markForCheck(); + } catch (error) { + if (seq === this.detailSequence) + this.error = describeError(error).message; + } + } + async saveDeployment(request: DeploymentRequest) { + if (!this.can("target.manage")) return; + const seq = this.detailSequence; + const current = this.deployment; + const result = await this.store.mutate( + "deployments" + (current ? "/" + current.id : ""), + request, + current ? "PUT" : "POST", + current?.revision, + ); + if (result && seq === this.detailSequence) + await this.openDeployment(result); + } + async createPlan(request: PlanRequest) { + if (!this.can("deployment.plan")) return; + const seq = this.detailSequence; + const result = await this.store.mutate("deployment-plans", request); + if (result && seq === this.detailSequence) await this.openPlan(result); + } + async createTarget(request: TargetRequest) { + if (!this.can("target.manage")) return; + const seq = this.detailSequence; + const target = await this.store.mutate( + "deployment-targets", + request, + ); + if (target && seq === this.detailSequence) await this.openTarget(target); + } + async observe() { + if (!this.target || !this.can("deployment.plan")) return; + const seq = this.detailSequence; + const job = await this.store.mutate("deployment-observations", { + target_id: this.target.id, + }); + if (job && seq === this.detailSequence) await this.openJob(job); + } + async approve() { + if ( + !this.plan || + !this.can("deployment.approve") || + !this.fresh(this.plan.expires_at) + ) + return; + const seq = this.detailSequence; + const result = await this.store.mutate( + "deployment-plans/" + this.plan.id + "/approve", + { digest: this.plan.digest }, + ); + if (seq === this.detailSequence) this.approval = result; + } + get recoverApply() { + return ( + !!this.plan && + this.store.canRecover("deployment-plans/" + this.plan.id + "/apply", { + digest: this.plan.digest, + }) + ); + } + async apply() { + if ( + !this.plan || + !this.can("deployment.apply") || + (!this.fresh(this.plan.expires_at) && !this.recoverApply) + ) + return; + const seq = this.detailSequence; + const job = await this.store.mutate( + "deployment-plans/" + this.plan.id + "/apply", + { digest: this.plan.digest }, + ); + if (job && seq === this.detailSequence) await this.openJob(job); + } + async cancelJob() { + if ( + !this.job || + !this.can("deployment.cancel") || + terminalJobs.has(this.job.state) + ) + return; + const seq = this.detailSequence; + const job = await this.store.mutate( + "deployment-jobs/" + this.job.id + "/cancel", + { reason: "operator_cancelled" }, + "POST", + this.job.revision, + ); + if (job && seq === this.detailSequence) { + this.job = job; + this.jobReadSequence++; + } + } + async reconcile(request: ReconcileRequest) { + if ( + !this.job || + this.job.state !== "reconciliation_required" || + !this.can("deployment.apply") || + !this.can("deployment.approve") + ) + return; + const seq = this.detailSequence; + const result = await this.store.mutate( + "deployment-jobs/" + this.job.id + "/reconcile", + request, + ); + if (result && seq === this.detailSequence) { + this.job = result; + this.jobReadSequence++; + } + } + riskLabel(risk: string) { + return ( + ( + { + service_interruption: + "Service interruption is possible. Schedule the change appropriately.", + external_effects: + "External resources may change; cancellation is not rollback.", + adoption: "Existing resources will be adopted into management.", + worker_drain: "Worker reduction requires draining admitted work.", + } as Record + )[risk] ?? risk + ); + } +} diff --git a/studio/src/app/operations/records-model.ts b/studio/src/app/operations/records-model.ts index e86239dc..9d1e855d 100644 --- a/studio/src/app/operations/records-model.ts +++ b/studio/src/app/operations/records-model.ts @@ -65,7 +65,7 @@ export const columns: Record = { tasks: ["Task", "Status", "Due"], connections: ["Connection", "Connector", "Revision"], email: ["Conversation", "Last message", "Status"], - workers: ["Worker", "Status", "Capacity"], + workers: ["Worker", "Status", "Registered limit"], }; const nouns: Record = { @@ -220,7 +220,7 @@ export function rowStatus( ? "unavailable" : record["revoked"] ? "revoked" - : "active", + : "registered", ); case "email": { const state = text(record["last_state"]) || text(record["state"]); diff --git a/studio/src/app/operations/records-view.ts b/studio/src/app/operations/records-view.ts index f2d0b79e..43358dba 100644 --- a/studio/src/app/operations/records-view.ts +++ b/studio/src/app/operations/records-view.ts @@ -45,6 +45,7 @@ import { toneAttribute, type Tone, } from "../status-labels"; +import { WorkerControls } from "./worker-controls"; import { RunDetail } from "./run-detail"; import { columns, @@ -132,6 +133,7 @@ const runStatusFilters: [string, string][] = [ ConnectionDetail, NewMenu, RunDetail, + WorkerControls, NgTemplateOutlet, ], styleUrl: "./records-view.css", @@ -659,14 +661,13 @@ const runStatusFilters: [string, string][] = [ @if (record["unavailable"]) {

Studio can't show this worker.

} @else { - + +

+ Task types: + {{ + $any(record["task_types"])?.join(", ") || "Unknown" + }} +

Technical details
} @if (h.integrationIssues(); as issues) { @if (issues.length) { @@ -246,7 +272,9 @@ import type { App } from "../app"; [open]="h.sectionOpen('connection')" (toggle)="h.sectionToggled('connection', $event)" > - Connection + + Connection +
{{ h.connectionSlotButton() }} } @if (!h.actionContract || h.actionRequirement()) { - +
+ Manage workflow connection slots + +
}
@@ -306,7 +340,9 @@ import type { App } from "../app"; [open]="h.sectionOpen('input')" (toggle)="h.sectionToggled('input', $event)" > - Input + + Input +
@if (h.actionContract && h.actionInputFields()) { @if (h.actionInputMode === "fields") { @@ -325,7 +361,7 @@ import type { App } from "../app"; class="action-input-fields" [disabled]="!h.bufferReadable" > - @for (key of [selected.step.id]; track key) { + @for (session of [h.inspectorSession]; track session) { @defer (on immediate) { - Output + + Output +

Later steps can read this action's result at @@ -425,6 +463,27 @@ import type { App } from "../app"; export class ActionInspector { /** The editor shell. */ host = input.required(); + private readonly element = inject>(ElementRef); + private readonly injector = inject(Injector); + slotsOpen = false; + addConnectionSlot() { + const h = this.host(); + h.addConnectionSlot(); + if (h.slotError || h.error) return; + this.slotsOpen = true; + const name = h.bufferedConnection(); + afterNextRender( + () => { + const field = [ + ...this.element.nativeElement.querySelectorAll( + ".slot-management .slot-row input", + ), + ].find((input) => input.value === name); + field?.focus(); + }, + { injector: this.injector }, + ); + } readonly manualHiddenFields = ["connection", "with"]; connectionOptions(): SelectOption[] { const h = this.host(); diff --git a/studio/src/app/designer/decision-inspector.ts b/studio/src/app/designer/decision-inspector.ts index 99b6de5b..82294a67 100644 --- a/studio/src/app/designer/decision-inspector.ts +++ b/studio/src/app/designer/decision-inspector.ts @@ -94,6 +94,7 @@ import type { Step } from "../model";

| null { + const contract = this.host.decisionContracts.get( + String(this.step["uses"] ?? ""), + ); + const spec = contract?.["spec"] as Record | undefined; + const schema = spec?.["inputSchema"]; + return schema && typeof schema === "object" && !Array.isArray(schema) + ? (schema as Record) + : null; + } value(event: Event) { return (event.target as HTMLInputElement).value; } diff --git a/studio/src/app/designer/designer-view.ts b/studio/src/app/designer/designer-view.ts index 947b896e..3d86e27b 100644 --- a/studio/src/app/designer/designer-view.ts +++ b/studio/src/app/designer/designer-view.ts @@ -62,8 +62,7 @@ import type { App } from "../app"; // shell's main column. styles: [":host { display: contents; }"], template: `@let h = host(); - +
@@ -128,110 +127,140 @@ import type { App } from "../app"; }
@@ -276,17 +305,31 @@ import type { App } from "../app"; (pointerup)="h.resizing = null" (keydown)="h.paneKey($event, 'palette')" > -

Steps

- -

- Click to add after the selected step, or drag onto a + on the canvas. -

+
+
+

Steps

+
+ + i + +

+ Click to add after the selected step, or drag onto a + on the + canvas. +

+
+
+ +
@for (group of h.palette; track group.label) {
} @@ -941,114 +985,119 @@ import type { App } from "../app";

Loading the action…

} } -
- - {{ - h.selected.step.kind === "switch" - ? "Paths" - : h.selected.step.kind === "parallel" - ? "Branches" - : "Properties" - }} - - @if (!h.advancedProperties) { - @for (stepId of [h.selected.step.id]; track stepId) { - @if (h.selected.step.kind === "switch") { - @defer (on immediate) { - - } - } @else if (h.selected.step.kind === "humanTask") { - @defer (on immediate) { - - } @placeholder { -

Loading the human task…

- } - } @else if (h.selected.step.kind === "decisionTable") { - @defer (on immediate) { - - } @placeholder { -

Loading the decision table…

- } - } @else if (h.selected.step.kind === "llm") { - @defer (on immediate) { - - } @placeholder { -

Loading the AI task…

- } - } @else { - @defer (on immediate) { - - } @placeholder { -

Loading the properties…

+ @if ( + h.selected.step.kind !== "action" || + h.advancedProperties || + !h.inspectorHiddenFields.includes("with") + ) { +
+ + {{ sectionTitle() }} + + @if (!h.advancedProperties) { + @for (session of [h.inspectorSession]; track session) { + @if (h.selected.step.kind === "switch") { + @defer (on immediate) { + + } + } @else if (h.selected.step.kind === "humanTask") { + @defer (on immediate) { + + } @placeholder { +

Loading the human task…

+ } + } @else if (h.selected.step.kind === "decisionTable") { + @defer (on immediate) { + + } @placeholder { +

Loading the decision table…

+ } + } @else if (h.selected.step.kind === "llm") { + @defer (on immediate) { + + } @placeholder { +

Loading the AI task…

+ } + } @else { + @defer (on immediate) { + + } @placeholder { +

Loading the properties…

+ } } } + } @else { + + @if (!h.propertyValid()) { + + } } - } @else { - - @if (!h.propertyValid()) { - + @if (h.selected.step.kind === "parallel") { +
+

Parallel branches

+

+ Every branch runs. Move contained steps before removing a + branch. +

+ @for (name of h.branchNames(); track name) { +
+ + +
+ } + +
} - } - @if (h.selected.step.kind === "parallel") { -
-

Parallel branches

-

- Every branch runs. Move contained steps before removing a - branch. -

- @for (name of h.branchNames(); track name) { -
- - -
- } - -
- } -
+
+ }
-
-

Deadlines

+
+ Deadlines Optional => Expires after: the task expires and can no longer be answered; the workflow receives a timeout.

-
+ `, styles: [ ` diff --git a/studio/src/app/designer/llm-inspector.ts b/studio/src/app/designer/llm-inspector.ts index 904b9cbc..905572ae 100644 --- a/studio/src/app/designer/llm-inspector.ts +++ b/studio/src/app/designer/llm-inspector.ts @@ -26,10 +26,13 @@ import { inject, } from "@angular/core"; import type { App } from "../app"; +import { Modal } from "../dialog"; import { ExpressionEditor } from "../property-grid"; import { aiConnector } from "../integrations/ai-provider-connection"; import { compatibleSlots, slotName } from "../integrations/slot-binding"; import { Select } from "../forms/ui/select"; +import { CatalogPicker } from "../integrations/catalog-picker"; +import type { ActionPickerChoice } from "../integrations/action-picker"; import { AiProfileEditor } from "../integrations/ai-profile-editor"; import { AiSetupWizard } from "../integrations/ai-setup-wizard"; import { AiSharedContextPicker } from "../integrations/ai-shared-context-picker"; @@ -53,6 +56,8 @@ const object = (value: unknown): RecordValue => changeDetection: ChangeDetectionStrategy.Eager, imports: [ ExpressionEditor, + CatalogPicker, + Modal, AiProfileEditor, AiSetupWizard, AiSharedContextPicker, @@ -69,17 +74,48 @@ const object = (value: unknown): RecordValue => A durable worker runs this task using the selected workflow profile and authorized connection.

- + @if (host.profile && host.can("catalog.read")) { + +

Choose an action that runs the Agentic worker.

+ @if (actionLoading) { +

Checking the AI action…

+ } + @if (host.catalogState === "error") { +

{{ host.catalogError?.message }}

+ + } + } @else { + + @if (invalid.has("uses")) { + + } + } @if (host.profile && host.catalogState === "ready" && !installed) {

- The selected action is not published in this project. Publish the - Agentic worker action before activating this workflow. + The selected action is not in the loaded catalog. Load more actions or + publish the Agentic worker action before activating this workflow.

}
@@ -91,6 +127,20 @@ const object = (value: unknown): RecordValue => (choose)="selectProfile($event)" />
+ @if (!profilesOpen) { + + @if (!aiSlots.length) { +

+ Configure a workflow AI profile to add a connection slot. +

+ } + } @if (!profileNames.includes(String(step["profile"]))) {

Configure a profile below to choose its provider, model and result @@ -104,8 +154,10 @@ const object = (value: unknown): RecordValue =>

/>

Typed result: /steps/{{ step.id }}/output/result

-
- Configure workflow AI profiles -

- Profile changes stay in this draft until you review and apply them. - All steps using the same named profile share its settings. -

- - - @if (schema) { - -
-
+ } + @if (error) { + + } + + } @if (applied) {

Workflow AI settings applied.

} - @if (error) { + @if (error && !profilesOpen) { } @@ -330,8 +396,60 @@ export class LlmInspector implements OnInit, DoCheck, OnDestroy { draftSlot = ""; newSlot = ""; applied = false; + readonly validateProfile = (profile: RecordValue) => + this.host.api.validateLlmProfile({ ...profile, outputSchema: {} }); private loadedProfile = ""; - private invalid = new Set(); + profileTouched = false; + private profileBaseline = ""; + private readonly draftGuard = () => + this.actionLoading + ? "Wait for the selected AI action to finish loading before continuing." + : (this.profileTouched && (!this.profileValid || !this.resultValid)) || + this.profileSnapshot() !== this.profileBaseline + ? "Apply or cancel the workflow AI profile changes before continuing. Your draft is still here." + : ""; + private profileSnapshot() { + return JSON.stringify([ + this.profileName, + this.profile, + this.resultSchema, + this.draftSlot, + this.newSlot, + ]); + } + invalid = new Set(); + actionLoading = false; + async chooseAction(choice: ActionPickerChoice) { + if (!choice.id || this.actionLoading) return; + const scope = this.host.profile; + this.actionLoading = true; + this.error = ""; + try { + const result = await this.host.api.request( + `${this.host.api.project}/actions/${encodeURIComponent(choice.id)}/export`, + ); + if (!this.alive || scope !== this.host.profile) return; + const document = object(result["document"] ?? result["definition"]); + const implementation = object(object(document["spec"])["implementation"]); + if ( + implementation["kind"] !== "worker" || + implementation["taskType"] !== "weave-agentic.generate" || + implementation["taskVersion"] !== "1.0.0" + ) { + this.error = + "Choose an action implemented by the Agentic worker. The current action is unchanged."; + return; + } + this.host.cacheContract(choice.uses, document); + this.edit("uses", choice.uses); + this.validity("uses", true); + } catch (error) { + if (this.alive) this.error = describeError(error).message; + } finally { + this.actionLoading = false; + this.cdr.markForCheck(); + } + } get step() { return JSON.parse(this.host.inspectorBuffer) as Step; } @@ -352,6 +470,7 @@ export class LlmInspector implements OnInit, DoCheck, OnDestroy { return compatibleSlots({ connector: aiConnector }, this.host.workflowSlots); } addAiSlot() { + this.profileTouched = true; this.newSlot = slotName( "ai", this.host.workflowSlots.map((slot) => slot.name), @@ -416,6 +535,7 @@ export class LlmInspector implements OnInit, DoCheck, OnDestroy { return (event.target as HTMLInputElement).value; } ngOnInit() { + this.host.inspectorDraftGuard = this.draftGuard; void this.loadSchema(); void this.host.loadActionCatalog(); } @@ -427,6 +547,8 @@ export class LlmInspector implements OnInit, DoCheck, OnDestroy { } ngOnDestroy() { this.alive = false; + if (this.host.inspectorDraftGuard === this.draftGuard) + this.host.inspectorDraftGuard = null; } edit(key: string, value: unknown) { this.host.stepEdit({ ...this.step, [key]: value }, key); @@ -438,6 +560,7 @@ export class LlmInspector implements OnInit, DoCheck, OnDestroy { this.host.inspectorFieldValidity({ path, valid }); } textField(key: string, event: Event) { + this.host.touchInspector("step"); const text = this.value(event); const [name, version, extra] = text.split("@"); const valid = @@ -447,8 +570,21 @@ export class LlmInspector implements OnInit, DoCheck, OnDestroy { this.validity(key, valid); if (valid) this.edit(key, text); } + private keepProfileDraft() { + const problem = this.draftGuard(); + if (problem) this.error = problem; + return !!problem; + } + chooseProfile(name: string) { + if (!this.keepProfileDraft()) this.loadProfile(name); + } + selectConnection(name: string) { + if (!name || this.keepProfileDraft()) return; + this.edit("connection", name); + this.loadProfile(String(this.step["profile"] ?? "")); + } selectProfile(name: string) { - if (!name) return; + if (!name || this.keepProfileDraft()) return; this.edit("profile", name); this.loadProfile(name); } @@ -461,7 +597,6 @@ export class LlmInspector implements OnInit, DoCheck, OnDestroy { this.applied = false; this.resultValid = true; this.existingProfile = !!this.profiles[name]; - if (!this.existingProfile) this.profilesOpen = true; const loaded = object(this.profiles[name]); this.resultSchema = object(loaded["outputSchema"]); if (!Object.keys(this.resultSchema).length) @@ -471,16 +606,27 @@ export class LlmInspector implements OnInit, DoCheck, OnDestroy { this.profile = structuredClone(rest); // TaskForm clears field errors on reload without emitting initial validity. this.profileValid = true; + this.profileTouched = false; + this.profileBaseline = this.profileSnapshot(); } newProfile() { + if (this.keepProfileDraft()) return; let name = "default"; let i = 1; while (this.profiles[name]) name = `profile-${i++}`; this.loadProfile(name); + this.profileTouched = true; + } + closeProfile() { + if (this.keepProfileDraft()) return; + this.error = ""; + this.profilesOpen = false; } cancelProfile() { this.loadProfile(String(this.step["profile"] ?? "")); this.profilesOpen = false; + this.error = ""; + this.host.error = ""; } saveProfile() { if ( @@ -519,6 +665,10 @@ export class LlmInspector implements OnInit, DoCheck, OnDestroy { this.newSlot = ""; this.profilesOpen = false; this.applied = true; + this.error = ""; + this.profileTouched = false; + this.profileBaseline = this.profileSnapshot(); + this.host.error = ""; } async loadSchema() { this.loading = true; diff --git a/studio/src/app/designer/path-inspector.ts b/studio/src/app/designer/path-inspector.ts index 4d7e4bea..45e62191 100644 --- a/studio/src/app/designer/path-inspector.ts +++ b/studio/src/app/designer/path-inspector.ts @@ -79,6 +79,7 @@ const object = (value: unknown): Record => [references]="refs(index, 'when')" [readOnly]="locked" [conditionOnly]="true" + [expectedSchema]="{ type: 'boolean' }" [label]="'Path ' + (index + 1) + ' rule'" (valueChange)="edit(index, 'when', $event)" (validityChange)="validity(index, 'when', $event)" diff --git a/studio/src/app/forms/ui/anchored-popover.ts b/studio/src/app/forms/ui/anchored-popover.ts index c7cacd25..0af7aa8d 100644 --- a/studio/src/app/forms/ui/anchored-popover.ts +++ b/studio/src/app/forms/ui/anchored-popover.ts @@ -82,6 +82,8 @@ function anchorVisible(anchor: HTMLElement) { top = Math.max(top, bounds.top); bottom = Math.min(bottom, bounds.bottom); } + // A viewport-fixed dialog escapes the clipping ancestors that own its DOM. + if (style.position === "fixed") break; } return right > left && bottom > top; } diff --git a/studio/src/app/integrations/ai-profile-editor.ts b/studio/src/app/integrations/ai-profile-editor.ts index a88c66c1..895b52be 100644 --- a/studio/src/app/integrations/ai-profile-editor.ts +++ b/studio/src/app/integrations/ai-profile-editor.ts @@ -15,9 +15,27 @@ limitations under the License. Author: Firefly Software Foundation SPDX-License-Identifier: Apache-2.0 */ -import { Component, input, output } from "@angular/core"; +import { + Component, + ChangeDetectorRef, + OnChanges, + OnDestroy, + SimpleChanges, + input, + inject, + output, +} from "@angular/core"; import { TaskForm } from "../task-form"; +import type { ProfileValidation } from "../api"; import type { Schema } from "../task-schema"; +import { missingData } from "../forms/core/form-model"; +import { isRecord, type Data } from "../task-schema"; +import { + aiProfileSections, + mergeProfileSection, + profileSectionData, + type ProfileSection, +} from "./ai-profile-presentation"; /** Uses the host's canonical contract for both workflow and assistant profiles. */ @Component({ @@ -27,15 +45,43 @@ import type { Schema } from "../task-schema"; template: `

Choose a provider and model. For Azure OpenAI, enter the deployment name. - Generation and reasoning limits apply to each AI request; credentials - belong to the connection in the next step. + Use a model approved for this environment. Credentials belong to the + connection in the next step.

+
+ + Advanced model settings + @if (!advancedValid) { + · Needs attention + } + +

+ Optional generation controls, reasoning strategy and execution limits. + Leave optional values empty to use the provider's behavior. Available + options depend on the selected model. +

+ +
+ @if (checking) { +

Checking model settings…

+ } + @for (message of validationMessages; track $index) { + + } + @if (validationFailed) { + + } `, styles: ` :host { @@ -46,11 +92,113 @@ import type { Schema } from "../task-schema"; .hint { margin: 0; } + .advanced { + border-top: 1px solid var(--line); + } + summary { + cursor: pointer; + padding: 12px 0; + } + .advanced > .hint { + margin-bottom: 12px; + } `, }) -export class AiProfileEditor { +export class AiProfileEditor implements OnChanges, OnDestroy { schema = input.required(); initialData = input.required>(); dataChange = output>(); validityChange = output(); + validateProfile = input< + ((profile: Data) => Promise) | null + >(null); + basic: ProfileSection = { schema: {}, paths: [] }; + advanced: ProfileSection = { schema: {}, paths: [] }; + basicInitial: Data = {}; + advancedInitial: Data = {}; + basicValid = true; + advancedValid = true; + checking = false; + validationFailed = false; + validationMessages: string[] = []; + private values: Data = {}; + private generation = 0; + private timer: ReturnType | undefined; + private alive = true; + private cdr = inject(ChangeDetectorRef); + + ngOnChanges(changes: SimpleChanges) { + if (changes["schema"] || changes["initialData"]) { + const sections = aiProfileSections(this.schema()); + this.basic = sections.basic; + this.advanced = sections.advanced; + this.values = structuredClone(this.initialData()); + this.basicInitial = profileSectionData(this.values, this.basic); + this.advancedInitial = profileSectionData(this.values, this.advanced); + this.basicValid = this.advancedValid = true; + } + queueMicrotask(() => { + if (this.alive) this.validate(); + }); + } + ngOnDestroy() { + this.alive = false; + this.generation++; + clearTimeout(this.timer); + } + change(section: "basic" | "advanced", data: Data) { + this.values = mergeProfileSection(this.values, data, this[section]); + this.dataChange.emit(structuredClone(this.values)); + this.validate(); + } + setValidity(section: "basic" | "advanced", valid: boolean) { + if (section === "basic") this.basicValid = valid; + else this.advancedValid = valid; + this.validate(); + } + validate() { + const generation = ++this.generation; + clearTimeout(this.timer); + this.validationMessages = []; + this.validationFailed = false; + this.checking = false; + if ( + !this.basicValid || + !this.advancedValid || + missingData(this.schema(), this.values).length + ) { + this.validityChange.emit(false); + return; + } + const validate = this.validateProfile(); + if (!validate) { + this.validityChange.emit(true); + return; + } + this.checking = true; + this.validityChange.emit(false); + const profile = isRecord(this.values["profile"]) + ? this.values["profile"] + : this.values; + this.timer = setTimeout(async () => { + try { + const result = await validate(structuredClone(profile)); + if (!this.alive || generation !== this.generation) return; + this.validationMessages = result.issues.map((issue) => issue.message); + this.validityChange.emit(result.valid); + } catch { + if (!this.alive || generation !== this.generation) return; + this.validationFailed = true; + this.validationMessages = [ + "Model settings could not be validated. Try again.", + ]; + this.validityChange.emit(false); + } finally { + if (this.alive && generation === this.generation) { + this.checking = false; + this.cdr.markForCheck(); + } + } + }, 200); + } } diff --git a/studio/src/app/integrations/ai-profile-presentation.ts b/studio/src/app/integrations/ai-profile-presentation.ts new file mode 100644 index 00000000..0eb334c0 --- /dev/null +++ b/studio/src/app/integrations/ai-profile-presentation.ts @@ -0,0 +1,101 @@ +/* +Copyright 2026 Firefly Software Foundation. + +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. +Author: Firefly Software Foundation +SPDX-License-Identifier: Apache-2.0 +*/ +import { getAt } from "../forms/core/json"; +import { resolveSchema } from "../forms/core/resolve"; +import { isRecord, withValue, type Data, type Schema } from "../task-schema"; + +export interface ProfileSection { + schema: Schema; + paths: string[][]; +} + +/** Presentation copies retain the host's constraints and local reference definitions. */ +export function aiProfileSections(schema: Schema) { + const root = resolveSchema(schema).schema; + const properties = isRecord(root["properties"]) ? root["properties"] : {}; + const prefix = properties["profile"] ? ["profile"] : []; + const basics = [["provider"], ["model"], ["options", "max_tokens"]].map( + (path) => [...prefix, ...path].join("/"), + ); + const section = (basic: boolean): ProfileSection => { + const paths: string[][] = []; + const project = (source: unknown, path: string[]): Schema | null => { + const resolved = resolveSchema(source, { root: schema }); + const node = resolved.schema; + const children = isRecord(node["properties"]) ? node["properties"] : {}; + if (Object.keys(children).length && !resolved.cyclic) { + const selected: Record = {}; + for (const [key, value] of Object.entries(children)) { + const child = project(value, [...path, key]); + if (child) selected[key] = child; + } + if (!Object.keys(selected).length) return null; + const key = path.at(-1); + const title = + key === "profile" + ? "Model settings" + : key === "options" + ? basic + ? "Response length" + : "Generation options" + : key === "reasoning" + ? "Reasoning strategy" + : node["title"]; + return { + ...node, + ...(typeof title === "string" ? { title } : {}), + properties: selected, + required: Array.isArray(node["required"]) + ? node["required"].filter( + (name): name is string => + typeof name === "string" && name in selected, + ) + : [], + } as Schema; + } + if (!path.length || basics.includes(path.join("/")) !== basic) + return null; + paths.push(path); + return { ...node } as Schema; + }; + return { + schema: { ...project(schema, []), $defs: root["$defs"] } as Schema, + paths, + }; + }; + return { basic: section(true), advanced: section(false) }; +} + +export function profileSectionData(data: Data, section: ProfileSection): Data { + let selected: Data = {}; + for (const path of section.paths) + selected = withValue(selected, path, getAt(data, path)); + return selected; +} + +/** Replace owned leaves, including removals; other sections and unknown data survive. */ +export function mergeProfileSection( + data: Data, + change: Data, + section: ProfileSection, +): Data { + let merged = structuredClone(data); + for (const path of section.paths) + merged = withValue(merged, path, getAt(change, path)); + return merged; +} diff --git a/studio/src/app/integrations/ai-provider-connection-form.ts b/studio/src/app/integrations/ai-provider-connection-form.ts index ccaaf462..545c2fa4 100644 --- a/studio/src/app/integrations/ai-provider-connection-form.ts +++ b/studio/src/app/integrations/ai-provider-connection-form.ts @@ -34,6 +34,7 @@ import { Select } from "../forms/ui/select"; import { aiConnector, aiConnectionRequest, + aiProviderEndpoint, aiProviders, aiSetupRows, type AiConnection, @@ -54,11 +55,12 @@ import { } @else if (done) {

Connection created

{{ done.name }} · revision {{ done.revision }}

+

Saved · Not tested

- The platform checked this configuration. No model request was sent. A - platform operator must authorize the worker release to use this - connection before a workflow can run; Lumi uses its separately - configured gateway. + The configuration was validated. Provider access has not been tested. No + model request was sent. A platform operator must authorize the worker + release to use this connection before a workflow can run; Lumi uses its + separately configured gateway.

} @else { @@ -104,35 +106,30 @@ import { label="Provider" [options]="providers" [value]="draft.provider" - (choose)="draft.provider = $event" + (choose)="chooseProvider($event)" [disabled]="busy || loading" /> } @else if (step === 1) { -

- Endpoint and secret handle -

+

Provider access

- Use the operator-approved endpoint and secret handle for - {{ providerLabel }} in this environment. -

- -

- Copy the exact base endpoint approved by your operator. The worker - or Lumi gateway must allow this endpoint; this connection permits - only its HTTPS origin. + Choose the credential handle approved for {{ providerLabel }} in + this environment.

@if (draft.provider.startsWith("azure-")) { + +

+ Enter the base endpoint for your Azure resource. +