diff --git a/CHANGELOG.md b/CHANGELOG.md
index 714e5061..f59e3245 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -18,6 +18,51 @@ 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.
+- Let Lumi explain explicitly selected Operations records using bounded,
+ permission-checked context. Deployment changes retain their separate plan,
+ approval, and application process.
+- Reject unsupported component and destination changes before plan approval.
+ Mark stopped or stalled compatibility monitors unhealthy so container
+ supervisors can replace the affected API process.
+- Guide target registration with named application accounts, downloadable
+ nonsecret runner setup, observed-resource import, explicit resource capacity,
+ and admitted worker release selection.
+- Allow reviewed Compose plans to restart stopped workers, checking readiness
+ after the restart instead of during the dry run.
+- Keep worker lease renewal retrying explicit capacity rejections while the
+ current lease remains valid, including while a completed task awaits its
+ acknowledgment. Ambiguous transport failures never replay the task handler.
+- Keep invalid inspector edits and unapplied AI profile changes visible before
+ saving, publishing, or switching views. Edit workflow AI profiles in a dialog,
+ with multiline prompts and direct connection-slot selection.
+- Organize editor commands into aligned rows and step settings into purposeful
+ sections. Keep palette search visible and explain data sources through
+ keyboard-accessible information controls. Filter source modes and formulas
+ by the receiving field's type while preserving imported expressions for repair.
+- Separate reusable project actions from creating a new API action, with a
+ direct connection entry point and contextual guidance for local authoring.
+- Keep Studio pages and editor assets loading while platform requests are busy,
+ and retry temporary session-read capacity rejections within a bounded budget.
+- Use standard OpenAI and Anthropic endpoints automatically, retain explicit
+ Azure configuration, and group advanced model settings separately. Check
+ model-setting combinations with the canonical local validator before applying
+ them; configuration validation does not claim live provider readiness.
+- 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/CONTRIBUTING.md b/CONTRIBUTING.md
index f07076ea..e6edfe77 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -49,7 +49,9 @@ update lock or upstream framework provenance casually to resolve an unrelated
failure.
**Studio and desktop changes need their own checks.** Python-only contributors do
-not need Node.js. If you change `studio/`, run the same steps as the Studio CI
+not need Node.js. Use Node 24 LTS; CI pins 24.15.0. Check `node --version`
+before installing dependencies so local builds and browser tests use the same
+major version as CI. If you change `studio/`, run the same steps as the Studio CI
job. Many unit and browser tests run Weave's Python code, including the real
Studio host, from the repository's `.venv`, so run the `uv sync` command above
first.
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/lumi-operations.svg b/docs/diagrams/lumi-operations.svg
new file mode 100644
index 00000000..6234994b
--- /dev/null
+++ b/docs/diagrams/lumi-operations.svg
@@ -0,0 +1,81 @@
+
+
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 @@
+
+
+
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 @@
+
+
+
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 @@
+
+
+
diff --git a/docs/guides/ai-workers.md b/docs/guides/ai-workers.md
index b29d1a57..2cb81b00 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.
@@ -90,6 +90,19 @@ used, which is separate from changing the grant list. Follow
for the supported file and environment providers. Saving a handle in Studio does
not provision its secret or grant access to it.
+Studio's **New AI connection** uses the standard OpenAI or Anthropic endpoint
+automatically. **Advanced: custom endpoint** is available for an approved proxy
+or compatible service. Azure requires the resource endpoint and API version
+from its administrator. Review shows the exact saved endpoint; hostname case
+and the default HTTPS port are normalized, while the base path and trailing
+slash are preserved. The worker or Lumi gateway must still allow that exact
+endpoint. Existing endpoint policies are not changed by saving a connection.
+
+**Saved · Not tested** means that the connection configuration passed validation.
+It does not prove provider access, model availability, or worker/gateway readiness.
+Workflow AI profiles and Lumi settings remain separate and select their own model
+or Azure deployment using the approved connection.
+
**Hosting Weave in Azure does not select an AI provider.** The Azure operator
still deploys the worker, supplies its configuration and token file, permits its
network traffic, and sets up the API's secret grants. An Azure model endpoint is
@@ -104,9 +117,11 @@ After the administrator has prepared the environment:
1. Add an **AI task** in Studio and select the canonical AI Action from the
connected catalog. If it is missing, the administrator must publish it first.
-2. Create a named **workflow AI profile**. Choose an explicit provider and model,
- set token and time limits, and describe the expected result with the schema
- designer. Start with the `none` reasoning pattern for a single structured answer.
+2. Open **Configure workflow AI profiles** and create a named profile. The first
+ fields choose the provider, model and maximum response tokens. Describe the
+ expected result with the schema designer. **Advanced model settings** contains
+ optional generation controls, reasoning strategies and execution limits.
+ Start with the `none` reasoning pattern for a single structured answer.
3. Select that profile on the step. A name such as `summarizer` is local to this
workflow; it is not a provider credential or a Lumi setting.
4. Supply the **prompt** (the instruction) and **context** (the data to use).
@@ -117,6 +132,13 @@ After the administrator has prepared the environment:
6. Validate the workflow, resolve readiness problems, then publish and activate
through the normal workflow process. Starting a run is a separate action.
+Studio checks model settings against the same profile contract as the API before
+allowing you to continue. Errors such as a per-call timeout longer than the total
+step timeout must be corrected first. This is a configuration check, not proof
+that the selected model is approved or reachable. Expanding or collapsing
+advanced settings preserves edits; clearing an optional control omits that value.
+Lumi uses the same basic and advanced presentation for its separate profile.
+
The [AI task inspector reference](studio-step-reference.md#ai-task) explains the
Studio controls. The following complete source example summarizes a supplied
text into a typed string result. `gpt-4o` illustrates an explicit model ID; replace
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..bc37c6aa 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
@@ -113,6 +113,30 @@ flag, pinned connection revision, and model profile separately from workflow AI
profiles. Studio supplies the fixed reply schema from the canonical contract;
changing a workflow's AI profile does not configure Lumi.
+## Explain Operations records
+
+
+
+Open a target, desired deployment, plan, or operation in **Operations**, then
+select **Explain with Lumi**. This opens the attachment choices without sending
+anything. Check the saved records to include, enter a question, and send it.
+For example: “Explain the difference between these desired replicas and this
+observation, and tell me whether the observation is still fresh.”
+
+Lumi receives a bounded summary of the selected records: adapter and permitted
+capabilities, image digests, replica counts, CPU and memory limits, ownership,
+freshness, plan risks, and safe operation receipt codes. Provider configuration,
+registry addresses, external resource identities, local paths, credentials, and
+raw logs are excluded. Each record requires `deployment.read` on its target in
+addition to `lumi.use`; access is checked again before the reply is released.
+
+Operations replies are explanations only. They do not offer draft proposals,
+create plans, approve changes, or run operations. Replica and resource counts
+describe saved deployment records, not available worker task slots or current
+cloud state. Changing the selected record or environment clears the selected
+context and conversation. To change a deployment, return to its normal review,
+approval, and apply controls.
+
## Deploy the private gateway
The Weave API remains on its existing Python runtime. The model gateway uses the
@@ -178,11 +202,13 @@ separate from the administrator's editable environment Lumi configuration.
1. Open **Settings → AI setup → Configure Lumi**, or open **Lumi settings** from
the assistant. These settings belong to the current environment.
-2. In Studio alpha11, the **Model → Connection → Review** wizard
- keeps changes local until **Save Lumi settings**. Alpha10 presents
- these fields in a single form. Choose the provider and an explicit model. For Azure,
- supply the Azure deployment name. Set the model's token, call, and time limits;
- Studio supplies the fixed reply schema. Select **Continue to connection**.
+2. The **Model → Connection → Review** wizard keeps changes local until
+ **Save Lumi settings**. Choose the provider, an explicit model, and maximum
+ response length. For Azure, supply the Azure deployment name. Expand
+ **Advanced model settings** to change generation controls, reasoning, call
+ limits, or time limits. Studio supplies the fixed reply schema and checks
+ incompatible settings before you continue. This local validation does not
+ contact the provider. Select **Continue to connection**.
3. Choose **Provider connection**. The list displays the connection name and
revision and filters for the selected provider. Lumi pins the exact revision,
so a later connection revision does not silently change the assistant.
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..e9a42e68 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:**
@@ -74,8 +74,9 @@ output; it does not need an artificial Transform just to be a workflow.
## Add a step
- **From the palette.** The **Steps** pane groups the kinds under **Logic**
- (Decision, Parallel, Fail), **Data** (Transform), **Waiting** (Wait for time,
- Wait for signal), **People** (Human task), and **Actions** (Call an action);
+ (Decision, Decision table, Parallel, Fail), **Data** (Transform), **Waiting**
+ (Wait for time, Wait for signal), **People** (Human task), and **Actions**
+ (Call an action, AI task);
hover a kind to read its one-line description. Select a kind to insert it
after the selected step, or at the end of the main sequence when nothing is
selected. You can also drag it onto a **+** on the canvas. **Search steps**
@@ -113,18 +114,25 @@ Select a step to edit it. The inspector heading shows the kind's palette label.
that the name is unique and updates every expression that reads the step;
values inside a literal are data and stay unchanged. The hint under the field
says where the step sits, such as "Main sequence" or "Case 1 of decision-1".
-- **Sections.** The step's fields follow in sections you can fold: **Action**,
- **Connection**, **Input**, and **Output** for **Call an action**, then
- **Properties**, which a Decision calls **Paths** and a Parallel calls
- **Branches**. Studio remembers which sections you folded for each kind.
+- **Sections.** The step's fields follow in sections you can fold. A **Call an
+ action** step starts with choosing the action, its connection, and its input;
+ technical contract metadata is under **Action details**, and changing the
+ workflow's slots is under **Manage workflow connection slots**. Other steps
+ use purpose-specific headings such as **Transform data**, **Wait time**,
+ **AI task setup**, **Paths**, and **Branches**. Studio remembers which
+ sections you folded for each kind. Optional human-task deadlines stay folded
+ until you need them; folding a section preserves its controls and drafts.
**Advanced JSON**, at the end, shows the same step as JSON, and **Properties
table** switches back. Fields Studio does not edit are listed under
**Additional properties preserved**; change those in the Source tab.
- **Automatic edits.** Valid fields update the local definition after a short
pause or on leaving the field, with one Undo for the edit. Invalid drafts stay
inline and preserve the last valid graph; valid sibling edits still save.
- Leaving an invalid draft offers **Go back** to continue it. The Source editor
- retains its separate **Apply changes** action.
+ Fix an invalid field before switching steps or views, validating, saving to a
+ file, or publishing. Studio keeps the field visible and explains what needs
+ attention; it does not export an older value behind your back. Hiding the
+ inspector keeps its draft. The Source editor retains its separate **Apply
+ changes** action.
- **Workflow settings.** The link in the inspector header opens the workflow's
own properties. Selecting empty canvas, selecting **Start**, or pressing
Escape does the same.
@@ -187,13 +195,18 @@ Choose **AI task** from the Actions palette. The built-in action reference is
in the connected catalog. Select a **Workflow AI profile** and an **AI connection
slot**, then map **AI prompt** and **AI context** using Value, Data or Formula.
-Open **Configure workflow AI profiles** to create or edit a named profile.
-Studio alpha11 uses the same **Model → Connection → Review** structure as Lumi,
-but saves only to this workflow.
-
-1. **Model:** choose the provider and model explicitly, configure time and token
- limits, and describe the expected result with the schema designer. For Azure,
- **Model** is the deployment name. Select **Continue to connection**.
+Open **Configure workflow AI profiles** to create or edit a named profile in a
+dialog with room for the form. It uses the same **Model → Connection → Review**
+structure as Lumi, but saves only to this workflow. To use another connection
+slot without changing the model, select that slot directly in the inspector.
+
+1. **Model:** choose the provider, model, and maximum response length, then
+ describe the expected result with the schema designer. For Azure, **Model**
+ is the deployment name. Expand **Advanced model settings** only when you need
+ generation controls, a reasoning strategy, or different execution limits.
+ Studio checks combinations of settings against the platform's model contract
+ before enabling **Continue to connection**. This check runs locally; it does
+ not contact the model or verify credentials.
2. **Connection:** choose the **AI connection slot**, or select **Add AI connection
slot** to include a new slot when you apply. An authorized administrator can
also open **New AI connection** to create an environment connection using an
@@ -202,8 +215,9 @@ but saves only to this workflow.
3. **Review:** check the profile, provider, model, slot, and list of affected AI
steps. **Apply workflow AI settings** applies the profile, slot, and current
step's references together. **Back** keeps the draft; **Cancel profile
- changes** discards unapplied profile edits. Existing named profiles affect
- every step listed in the review.
+ changes** discards unapplied profile edits. Apply or cancel these edits before
+ switching to another step. Existing named profiles affect every step listed
+ in the review.
Use **Shared AI context** to select results from earlier AI steps available on
this path. Existing context stays under `data`; selected results appear under
@@ -392,7 +406,7 @@ Choosing an action loads its contract. If exactly one workflow slot fits the
action's connection, Studio selects it. Studio never declares a new slot from
this list; use **Add connection slot** for that.
-The palette's **Published actions** list, under **Actions**, shows up to six
+The palette's **Use an existing action** list, under **Actions**, shows up to six
published actions that match the palette search. Selecting one inserts the step
and its slot as one undoable change: Studio reuses the workflow's only compatible
slot or, when none fits, declares one named after the connector. When several
@@ -400,7 +414,13 @@ slots fit, choose one in the inspector. Screen readers announce what happened,
for example "Inserted call-action-1, which calls get-pet@1.0.0, and added the
connection slot petstore."
-The section shows a different state when the catalog is unavailable:
+Working locally, **Connect to a platform** opens the connection assistant so you
+can browse the project's catalog. The separate **Create an API action** section
+opens **New API action**. You can configure and save an action file locally;
+publishing it for the project requires a platform connection and permission.
+The information controls explain each choice and support Enter and Escape.
+
+The action picker and inspector show a different state when the catalog is unavailable:
| What you see | What it means | What to do |
| --- | --- | --- |
@@ -567,11 +587,49 @@ field menu. Conditions keep rule rows, with **Edit as formula** for complex logi
| Mode | Writes | Use it for |
| --- | --- | --- |
-| **Value** | `literal` | Fixed data of any JSON type |
-| **Data** | `ref` | Workflow input or an earlier step's output |
-| **Formula** | `op` | Comparing or combining values with an operation; it starts at "Choose a formula…"; fixed-arity operations have labelled operands, while combining operations can add inputs |
-| **Fields** | `object` | An object whose fields are expressions (**Add field**) |
-| **List** | `array` | A list whose items are expressions |
+| **Value** | `literal` | Type something that stays the same on every run, such as the message "Ready for review" or the number `3`. |
+| **Data** | `ref` | Read a value that can change on each run, such as the customer ID in the workflow input or a previous step's result. |
+| **Formula** | `op` | Apply a supported rule to values: compare an amount, check whether a list contains an item, or choose the first available value. This is not a JavaScript or Python editor. |
+| **Fields** | `object` | Build one record with named properties, such as `customerId` and `channel`. Each property can use a fixed value, data, or a formula. |
+| **List** | `array` | Build an ordered collection, such as two recipients. Each item can use its own value or data source. |
+
+Start by asking **“Does this value change between runs?”** If not, choose
+**Value**. If it comes from the request or an earlier step, choose **Data**.
+Choose **Fields** when the receiving system expects a record with several named
+properties, and **List** when it expects several items. Use **Formula** only
+when you need a supported operation to produce the value.
+
+The **ⓘ Data source** chip opens a **When should I use each source?** guide
+with explanations and examples. Click it or focus it and press Enter; Escape
+closes it and returns focus. Help stays collapsed until you need it. The editor uses the expected field type to
+offer relevant sources:
+
+- An **AI prompt** or **human-task title** needs text. It offers Value, Data,
+ and Formula. Its formula list offers **first available of**, which selects
+ a value or fallback; comparisons produce Yes/No and do not belong in a prompt.
+- **Human-task context** needs an object. Use Fields to assemble it, Data to
+ reuse an object, or Formula to select a fallback object.
+- **Workflow output** follows the workflow's output schema. A list output
+ offers List; a record output offers Fields.
+- A **Transform** or untyped **AI context** can hold any JSON value, so all
+ sources remain available. For action input, the published action's schema
+ determines each field's controls.
+
+An imported expression is never removed just because it does not match the
+expected type. Studio retains it and shows a repair hint. Choosing a source
+does not prove the complete workflow is valid: use **Validate** after mapping
+data, and check the connection and worker requirements before running it.
+
+For example, to send a customer record to an integration:
+
+1. Select the integration step and open its **Input** section.
+2. If the published action provides input fields, use those rows. Otherwise,
+ choose **Fields** for the whole input and add a field named `customerId`.
+3. Choose **Use data** for `customerId`, then select the customer ID under
+ **Workflow input**. This reads the current request's ID on every run.
+4. Add `channel` and type `email` as a **Value**. It remains the same on every run.
+5. Validate the workflow. If a required input is missing, fill it before
+ publishing. The YAML example below shows the resulting mapping.
**Fields** is the object builder for Transform values, human-task context, and
results. Each row has a name and a value. **Use data** reads another field;
diff --git a/docs/guides/studio.md b/docs/guides/studio.md
index 0872d3a8..3d8b4b55 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).

@@ -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`.
@@ -226,9 +226,11 @@ on this computer". You can create, import, and edit workflows, and Studio checks
their structure, data references, and types as you type. Actions and connections
are checked against a project catalog only after you connect. Studio keeps each
workflow you edit in this browser and lists it on the **On this computer** tab
-of **Workflows**. In the designer, the main button is **Save to file**, next to
-the note "Saving, publishing and runs need a platform." Saving drafts,
-publishing, simulating, activating, and starting runs need a platform.
+of **Workflows**. In the designer, the main button is **Save to file**. This
+works without a platform. The nearby information button explains which
+operations need a connection and opens the connection assistant. Saving a
+shared platform draft, publishing, simulating, activating, and starting runs
+need a platform.
**You can change how Studio starts.** In **Settings → Platforms**, under
**Starting Studio** and **When Studio opens**, choose **Ask how to work** or
@@ -639,6 +641,12 @@ valid inspector edits update that workflow automatically, and Studio checks
your work as you go. Next, [connect to a platform](#connect-to-a-platform)
to publish and run it, or read on to learn every editor feature.
+If a field contains an invalid edit, Studio keeps that draft visible and asks
+you to fix it before switching views, validating, saving to a file, or
+publishing. This prevents a downloaded or published workflow from silently
+using the previous value. Hiding the inspector keeps your draft; reopen it to
+finish editing.
+
### Start a workflow
- On Home, select **New workflow**, select **Use template** on a row under
@@ -675,21 +683,24 @@ Select a **+** on the canvas to open the step picker at that exact position; in
new workflow, select **Add your first step**. The picker's title says where the
step goes, such as "Add a step here, after check". Type to filter under **Search
steps and actions**; everyday words work too, so `approval` finds **Human
-task**. Then choose a step: **Call an action**, **Transform**, **Decision**,
-**Parallel**, **Wait for time**, **Wait for signal**, **Human task**, or
-**Fail**. When you are connected, the picker also lists **Published actions**;
+task**. Then choose a step: **Call an action**, **AI task**, **Transform**,
+**Decision**, **Decision table**, **Parallel**, **Wait for time**,
+**Wait for signal**, **Human task**, or **Fail**. When you are connected, the picker also lists **Published actions**;
choosing one inserts a step that already calls it. Focus moves to the new step;
Escape closes the picker and returns focus to the **+**.
The **Steps** palette on the left groups the same steps under **Logic**
-(Decision, Parallel, Fail), **Data** (Transform), **Waiting** (Wait for time,
-Wait for signal), **People** (Human task), and **Actions** (Call an action).
+(Decision, Decision table, Parallel, Fail), **Data** (Transform), **Waiting**
+(Wait for time, Wait for signal), **People** (Human task), and **Actions**
+(Call an action, AI task).
Hover a step to read what it does. Select a palette step to add it after the
selected step, or drag it onto a **+** to place it there. Under **Actions**,
-**Published actions** lists the actions published in your project while you are
-connected; working locally, it says "Connect to a platform to use published
-actions." In a window 1024 pixels wide or narrower, the palette is hidden, and
-**Insert step** in the toolbar opens it.
+**Use an existing action** lists your project's published actions. Working
+locally, **Connect to a platform** opens the connection assistant to browse that
+catalog. The separate **Create an API action** section opens **New API action**;
+you can configure and save an action file locally before connecting to publish
+it. Each section has an information control for more detail. In a window 1024
+pixels wide or narrower, **Insert step** in the toolbar opens the palette.
- **An empty branch shows a dashed card** with the path's name and **Add a
step**. In a Decision or Parallel, select the card to add the branch's first
@@ -941,7 +952,7 @@ Any of these opens **New API action**:
- In the inspector of a **Call an action** step that has no action yet, select
**New API action** under **Action**.
-- In the palette, under **Published actions**, or in the step's action picker,
+- In the palette, under **Create an API action**, or in the step's action picker,
select **New API action**.
- In **Workflows**, open **More ways to start** and select **New API action**.
@@ -1079,6 +1090,15 @@ a failure is shown inside the dialog so you can correct the bindings. Then selec
## Save, publish, activate and run
+The editor header separates **where you are** from **what you can do**. Its
+first row shows the workflow name, version, save state, and Designer / Source /
+Outline views. The second row groups editing controls, validation and simulation,
+then the main save or run action. Secondary commands are in **More**; narrow
+screens move additional commands there to keep the primary controls reachable.
+Zoom, Fit all, and Tidy layout remain with the canvas because they affect only
+its presentation. The connection information button opens help when you need
+it instead of taking space away from the controls.
+
**Why four separate steps?** A draft is work in progress that colleagues can
open. Publishing freezes a version that can never change. Activating decides
where that version runs and with which connections and people. Starting a run
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..58a6fb87
--- /dev/null
+++ b/docs/operations/cluster-management.md
@@ -0,0 +1,314 @@
+
+
+# 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.
+
+
+
+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
+application, and capabilities that its local policy will support. Administrators
+can select active application accounts by their available display label. Other
+planners can expand the administrator-provided application ID option. Use
+**Settings → People and access** to create/link the application and assign roles;
+provider credentials stay outside Studio. Kubernetes and Container Apps offer
+observation, updates and worker scaling; only Compose offers deployment of its
+trusted local template. 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
+
+The target page shows setup steps and **Download setup template**, containing
+its known API origin, workspace, pinned destination and target ID. Save it privately
+on the runner host, then complete the local context, component allowlist, image
+repositories and credential-file references there. It is an incomplete,
+observation-only template, not a credential or installation bundle. Studio never
+executes these commands or treats registration as provider readiness.
+
+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**. Select observed
+components to copy their names, immutable images and replica counts into the draft.
+Enter local configuration aliases, CPU and memory limits explicitly: observations
+do not report them. Worker components offer admitted releases when you have
+`catalog.read`; select the release paired with the image in your build receipt.
+A release configuration digest and a registry manifest digest are different
+identities; Studio does not verify that pairing. Without catalog access, ask an
+administrator for that access or use the CLI. Selecting an observed component does not grant ownership or
+apply any change.
+
+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 API, worker, or Lumi 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.
+
+Database migrations are not executable deployment components. Run the release's
+migration job separately using the destination's upgrade runbook, then verify
+schema compatibility before resuming the API and workers. A deployment plan
+does not perform or replace that migration step.
+
+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.
+
+Container Apps update plans may contain only worker and Lumi components. If a
+desired deployment includes an API component, record worker/Lumi changes in a
+separate desired deployment and upgrade the API with the
+[Azure upgrade runbook](azure.md). Studio excludes unsupported update choices;
+the API rejects them before saving a plan. Worker scaling can still select the
+worker components of a mixed desired deployment.
+
+## 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
+
+
+
+## Explain a saved record with Lumi
+
+
+
+When Lumi is configured, **Explain with Lumi** opens the assistant from a
+selected Operations record. Choose which saved records to include and send a
+question; nothing is sent automatically. A target, desired deployment,
+observation, plan, or operation can provide context. Both `lumi.use` and the
+record's normal `deployment.read` permission are required.
+
+The explanation uses bounded summaries with image digests, replica and resource
+counts, freshness, ownership, risks, and safe receipt codes. It excludes provider
+configuration, external identities, credentials, paths, and raw logs. It cannot
+create or apply an Operations plan. Treat it as an explanation of saved evidence,
+not proof of live cloud health or available worker task capacity. See the
+[Lumi guide](../guides/lumi.md#explain-operations-records) for the opt-in flow.
+
+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/observability.md b/docs/operations/observability.md
index e144298a..b8d9c2a1 100644
--- a/docs/operations/observability.md
+++ b/docs/operations/observability.md
@@ -47,6 +47,7 @@ independent receipts to establish an outcome, even when export is incomplete.
| Question | Start here | What it can establish |
| --- | --- | --- |
+| Is this replica's compatibility monitor still progressing? | `/health/live` | The monitor is running and has completed a scan within its allowed time |
| Can this replica accept work? | `/health/ready` and the scoped compatibility report | Runtime readiness and blocking requirements |
| How is the service behaving over time? | Collector metrics and traces | Bounded operational observations, subject to export loss |
| Did a run or effect complete? | Authorized run history and receiver or provider receipts | Durable recorded outcome and external acceptance evidence |
@@ -59,6 +60,29 @@ that an operation never happened. See
for authorization logging and [history and replay](../reference/history-and-replay.md)
for run evidence.
+### Read liveness and readiness together
+
+Configure your container platform's liveness probe against `/health/live` and
+its readiness probe against `/health/ready`. They answer different operational
+questions:
+
+| Liveness | Readiness | What to do |
+| --- | --- | --- |
+| HTTP 200 | HTTP 200 | The monitor is progressing and this replica can accept work. |
+| HTTP 200 | HTTP 503 | The monitor is progressing but found a blocking requirement. Inspect the scoped compatibility report and resolve that requirement. |
+| HTTP 503 | HTTP 503 | The monitor stopped or has not completed its work within the allowed time. The container platform can replace the unhealthy process according to its probe policy. |
+
+The completion allowance includes the scan interval, database scan and cleanup,
+and the configured time needed to shut down or initialize runtime effects. A
+scan counts as completed only after its callbacks finish. This prevents a stuck
+callback from indefinitely reporting a live replica. The watchdog does not
+start a second set of runtime effects inside the same process.
+
+After a replacement, verify that readiness returns to HTTP 200 and the
+compatibility report's `checked_at` advances. Inspect affected runs and external
+receipts before retrying uncertain work; a process restart alone does not
+establish whether an external action finished.
+
## Enable export to a collector
The repository's Compose files do not start a collector; bring your own and
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.
+
+
+
+## 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 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. |

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/docs/reference/worker-protocol.md b/docs/reference/worker-protocol.md
index 76a9048e..a38f652e 100644
--- a/docs/reference/worker-protocol.md
+++ b/docs/reference/worker-protocol.md
@@ -150,7 +150,8 @@ the request before admitting it:
| Request | What the SDK does |
| --- | --- |
| Claim | Returns no leases; the worker polls again later without cancelling active handlers |
-| Heartbeat | At most 3 attempts in total, waiting 50 and then 100 ms, within one second of the first rejection |
+| Heartbeat through `Worker` | Retries until the current lease expires, waiting 50, 100, and 200 ms, then 250 ms each time; renewal continues while completion awaits acknowledgment |
+| Direct `WorkerTransport.heartbeat` | At most 3 attempts in total, waiting 50 and then 100 ms, within one second of the first rejection |
| Complete or fail through `Worker` | Retries within the task deadline while the owning worker's lease watchdog remains active, waiting 50, 100, and 200 ms, then 250 ms each time |
| Direct `WorkerTransport.complete` or `.fail` | At most 48 attempts within ten seconds of the first rejection, using the same backoff; the initial request uses the caller's HTTP timeout |
@@ -161,8 +162,10 @@ timeouts, and malformed successful answers fail without automatic retry.
Credential requests are never retried. These retries cannot resolve an
ambiguous outcome or renew expired authority.
The extended settlement window belongs to the exact lease proof and execution
-task; child tasks and unrelated calls retain the direct transport limits. A
-successful heartbeat can extend the current lease, but never the task deadline.
+task. The worker's renewal task owns a separate window that ends at the current
+lease expiry. Unrelated calls and other child tasks retain the direct transport
+limits. A successful heartbeat received before expiry can extend the current
+lease, but never the task deadline.
**Effects and incidents.** Only effects declared safe are retried, within the
pinned attempt and deadline policy. An ambiguous non-idempotent effect raises an
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/scripts/check.py b/scripts/check.py
index 0e83fbd5..1d5c7369 100644
--- a/scripts/check.py
+++ b/scripts/check.py
@@ -105,8 +105,8 @@ def run(root: Path, evidence: Path, *, release: bool, context: str | None, integ
),
(
"integration",
- [python, "-m", "pytest", "tests/integration", "-q", "--tb=short", "--show-capture=no"],
- 1800,
+ [python, "-m", "pytest", "tests/integration", "-v", "--maxfail=1", "--tb=short", "--show-capture=no"],
+ 4800,
),
]
stages = (
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/health.py b/src/firefly_weave/api/health.py
index f3785043..c885f1c8 100644
--- a/src/firefly_weave/api/health.py
+++ b/src/firefly_weave/api/health.py
@@ -32,8 +32,11 @@ def __init__(self, database: DatabaseResources) -> None:
self.database = database
@operation("health.live")
- async def live(self) -> dict[str, str]:
- return {"status": "up"}
+ async def live(self, request: Request) -> JSONResponse:
+ compatibility = getattr(request.app.state, "compatibility", None)
+ if compatibility is not None and compatibility.healthy():
+ return JSONResponse({"status": "up"})
+ return JSONResponse({"status": "unavailable"}, status_code=503)
@operation("health.ready")
async def ready(self, request: Request) -> JSONResponse:
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/agentic.py b/src/firefly_weave/contracts/agentic.py
index 9a1dae11..e13fd9ca 100644
--- a/src/firefly_weave/contracts/agentic.py
+++ b/src/firefly_weave/contracts/agentic.py
@@ -16,6 +16,7 @@
"""Pure catalog for lease-bound model workers; no provider runtime is imported here."""
+from collections.abc import Iterable
from copy import deepcopy
from typing import cast
from urllib.parse import urlsplit
@@ -128,24 +129,43 @@ def action_definition() -> JsonObject:
}
+def provider_origin(endpoint: str) -> str:
+ """Match the HTTPS origin serialized by Studio without changing endpoint policy."""
+ parsed = urlsplit(endpoint)
+ if (
+ parsed.scheme != "https"
+ or not parsed.hostname
+ or parsed.username
+ or parsed.password
+ or parsed.query
+ or parsed.fragment
+ or parsed.port == 0
+ ):
+ raise ValueError("Invalid endpoint")
+ host = parsed.hostname.encode("idna").decode("ascii")
+ if ":" in host:
+ host = f"[{host}]"
+ return f"https://{host}" + (f":{parsed.port}" if parsed.port not in {None, 443} else "")
+
+
+def provider_destination_allowed(endpoint: str, destinations: Iterable[str]) -> bool:
+ origin = provider_origin(endpoint)
+ for destination in destinations:
+ try:
+ if urlsplit(destination).path in {"", "/"} and provider_origin(destination) == origin:
+ return True
+ except ValueError:
+ continue
+ return False
+
+
def validate_connection(request: ConnectionRequest) -> None:
issues = []
endpoint = request.config.get("endpoint")
try:
if not isinstance(endpoint, str):
raise ValueError("Missing endpoint")
- parsed = urlsplit(endpoint)
- if (
- parsed.scheme != "https"
- or not parsed.hostname
- or parsed.username
- or parsed.password
- or parsed.query
- or parsed.fragment
- ):
- raise ValueError("Invalid endpoint")
- origin = f"https://{parsed.netloc}"
- if origin not in request.allowed_destinations:
+ if not provider_destination_allowed(endpoint, request.allowed_destinations):
issues.append(ConnectionIssue("/allowed_destinations", "Add the provider endpoint origin.", "DESTINATION"))
except ValueError:
issues.append(ConnectionIssue("/config/endpoint", "Use a trusted HTTPS provider endpoint."))
diff --git a/src/firefly_weave/contracts/deployments.py b/src/firefly_weave/contracts/deployments.py
new file mode 100644
index 00000000..b49552c6
--- /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"]
+ 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 == "api" and self.replicas != 1:
+ raise ValueError("API/scheduler 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/lumi.py b/src/firefly_weave/contracts/lumi.py
index 4912e612..06806b8e 100644
--- a/src/firefly_weave/contracts/lumi.py
+++ b/src/firefly_weave/contracts/lumi.py
@@ -96,7 +96,16 @@ class LumiMessage(BaseModel):
class LumiAttachment(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True, hide_input_in_errors=True)
- kind: Literal["draft", "run", "simulation"]
+ kind: Literal[
+ "draft",
+ "run",
+ "simulation",
+ "deployment-target",
+ "deployment",
+ "deployment-observation",
+ "deployment-plan",
+ "deployment-job",
+ ]
id: UUID
@@ -118,3 +127,15 @@ class LumiAskRequest(BaseModel):
draft: LumiDraft | None = Field(default=None, exclude_if=lambda value: value is None)
history: list[LumiMessage] = Field(default_factory=list, max_length=16)
attachments: list[LumiAttachment] = Field(default_factory=list, max_length=4)
+
+ @property
+ def explanation_only(self) -> bool:
+ return any(item.kind.startswith("deployment") for item in self.attachments)
+
+ @model_validator(mode="after")
+ def separate_operations_context(self) -> Self:
+ if self.explanation_only and (
+ self.draft is not None or any(not item.kind.startswith("deployment") for item in self.attachments)
+ ):
+ raise ValueError("Operations explanations accept only Operations attachments")
+ return self
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..48da3386
--- /dev/null
+++ b/src/firefly_weave/deployment_runner/compose.py
@@ -0,0 +1,230 @@
+# 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",
+ *scales,
+ *[step.component.name for step in plan.steps],
+ ]
+ # Dry-run cannot make a stopped container ready; only the real command waits.
+ 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, "--wait", "--wait-timeout", "90"], 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..6232d5b0
--- /dev/null
+++ b/src/firefly_weave/deployments/service.py
@@ -0,0 +1,668 @@
+# 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")
+ if target.adapter == "azure-container-apps" and any(c.kind not in {"worker", "lumi"} for c in selected):
+ raise CatalogError(
+ 422,
+ "WV-DEPLOYMENT-UNSUPPORTED",
+ "Container Apps plans support worker and Lumi components only; use the API upgrade runbook",
+ )
+ 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/operations/compatibility.py b/src/firefly_weave/operations/compatibility.py
index e3fe98b8..2b580f54 100644
--- a/src/firefly_weave/operations/compatibility.py
+++ b/src/firefly_weave/operations/compatibility.py
@@ -20,8 +20,8 @@
import json
import logging
import time
-from collections.abc import Awaitable, Callable
-from contextlib import suppress
+from collections.abc import AsyncIterator, Awaitable, Callable
+from contextlib import asynccontextmanager, suppress
from datetime import UTC, datetime
from functools import partial
from typing import Any
@@ -52,6 +52,7 @@
from firefly_weave.settings import Settings
CATALOG_CLEANUP_SECONDS = 5.0
+CATALOG_SCAN_SECONDS = 60.0
INVENTORY_RESCAN_SECONDS = 60.0
INVENTORY_AGE_SECONDS = 5.0
@@ -172,6 +173,8 @@ def __init__(
self._inventory: dict[str, int] = {}
self._observed_at: float | None = None
self._freshness_task: asyncio.Task[None] | None = None
+ self._opened_at: float | None = None
+ self._completed_at: float | None = None
self._catalog_engine: AsyncEngine | None = None
self.on_ready: Callable[[], Awaitable[None]] | None = None
self.on_restricted: Callable[[], Awaitable[None]] | None = None
@@ -190,6 +193,7 @@ def refresh_inventory_age(self) -> None:
async def open(self) -> None:
if self._freshness_task is None:
+ self._opened_at = time.monotonic()
async def refresh() -> None:
next_scan = time.monotonic() + INVENTORY_RESCAN_SECONDS
@@ -219,16 +223,61 @@ async def _dispose_catalog(self) -> None:
await self._catalog_engine.dispose()
self._catalog_engine = None
+ @property
+ def completion_budget_seconds(self) -> float:
+ # A transition can drain retained owners, initialize effects, then drain a
+ # failed initialization. Include both passes without timing out their owner.
+ transition = 2 * (
+ self.settings.shutdown_timeout_seconds
+ + self.settings.database_timeout_seconds
+ + self.settings.broker.cleanup_seconds
+ + 3 * 5.0
+ )
+ return (
+ INVENTORY_RESCAN_SECONDS
+ + 2 * INVENTORY_AGE_SECONDS
+ + CATALOG_SCAN_SECONDS
+ + 2 * CATALOG_CLEANUP_SECONDS
+ + transition
+ )
+
+ def healthy(self) -> bool:
+ observed = self._completed_at if self._completed_at is not None else self._opened_at
+ return (
+ self._freshness_task is not None
+ and not self._freshness_task.done()
+ and observed is not None
+ and time.monotonic() - observed <= self.completion_budget_seconds
+ )
+
+ @property
+ def report(self) -> CompatibilityReport:
+ if self.healthy():
+ return self._report
+ return self._report.model_copy(update={"mode": "restricted", "complete": False})
+
+ @report.setter
+ def report(self, value: CompatibilityReport) -> None:
+ self._report = value
+
@property
def ready(self) -> bool:
- return self.report.mode == "ready" and self.report.complete
+ report = self.report
+ return report.mode == "ready" and report.complete
+
+ @asynccontextmanager
+ async def _completed_scan(self) -> AsyncIterator[None]:
+ try:
+ yield
+ finally:
+ self._completed_at = time.monotonic()
async def scan(self) -> CompatibilityReport:
if self._scan_lock.locked():
from firefly_weave.definitions.models import CatalogError
raise CatalogError(429, "WV-OPERATION-CAPACITY", "Compatibility scan already in progress")
- async with self._scan_lock:
+ async with self._scan_lock, self._completed_scan():
findings = FindingAccumulator()
inspected = 0
complete = False
@@ -259,7 +308,11 @@ async def scan(self) -> CompatibilityReport:
connect_args={"timeout": 5},
)
self._catalog_engine = engine
- async with asyncio.timeout(60), engine.connect() as connection, connection.begin():
+ async with (
+ asyncio.timeout(CATALOG_SCAN_SECONDS),
+ engine.connect() as connection,
+ connection.begin(),
+ ):
await connection.execute(text("SET TRANSACTION READ ONLY"))
await connection.execute(text("SET LOCAL statement_timeout='4000ms'"))
await check_inventory_authority(connection)
@@ -359,8 +412,10 @@ async def scan(self) -> CompatibilityReport:
if failure is not None:
raise failure
self.report = candidate
- self.telemetry.record("compatibility", operation="compatibility", status="ok" if self.ready else "blocked")
- return self.report
+ self.telemetry.record(
+ "compatibility", operation="compatibility", status="ok" if candidate.mode == "ready" else "blocked"
+ )
+ return self.report
async def scoped(
self, actor: Principal, scope: Scope, *, context: AuditContext, refresh: bool = False
diff --git a/src/firefly_weave/operations/lumi.py b/src/firefly_weave/operations/lumi.py
index 46e1f429..aa02ba06 100644
--- a/src/firefly_weave/operations/lumi.py
+++ b/src/firefly_weave/operations/lumi.py
@@ -45,8 +45,10 @@
from firefly_weave.contracts.schema_export import export_schemas
from firefly_weave.contracts.values import JsonValue
from firefly_weave.definitions.models import CatalogError
+from firefly_weave.deployments.service import DeploymentService
from firefly_weave.operations.debug.store import DebugService
from firefly_weave.operations.history import HistoryService
+from firefly_weave.operations.lumi_deployments import COLLECTIONS, deployment_context
from firefly_weave.operations.lumi_gateway import LumiGatewayClient
from firefly_weave.persistence.uow import Transaction
@@ -54,10 +56,16 @@
@service
class LumiService:
def __init__(
- self, connections: ConnectionService, history: HistoryService, debug: DebugService, gateway: LumiGatewayClient
+ self,
+ connections: ConnectionService,
+ history: HistoryService,
+ debug: DebugService,
+ gateway: LumiGatewayClient,
+ deployments: DeploymentService,
) -> None:
self.connections, self.history, self.debug, self.gateway = connections, history, debug, gateway
self.definitions = connections.definitions
+ self.deployments = deployments
async def _require(self, tx: Transaction, actor: Principal, capability: str, context: AuditContext) -> Principal:
if tx.scope.environment_id is None:
@@ -155,7 +163,13 @@ async def _attachments(
result: list[JsonValue] = []
project = scope.model_copy(update={"environment_id": None})
for attachment in request.attachments:
- if attachment.kind == "draft":
+ data: JsonValue
+ if attachment.kind in COLLECTIONS:
+ record = await self.deployments.read(
+ COLLECTIONS[attachment.kind], attachment.id, actor=actor, scope=scope, context=context
+ )
+ data = deployment_context(attachment.kind, record)
+ elif attachment.kind == "draft":
value = await self.definitions.read(actor, project, "drafts", attachment.id, context=context)
data = cast(JsonValue, {"document": value["document"], "revision": value["revision"]})
elif attachment.kind == "run":
@@ -178,6 +192,15 @@ async def _attachments(
measure_value(result)
if len(json.dumps(result, ensure_ascii=False).encode()) > 262144:
raise CatalogError(413, "WV-LUMI-LIMIT", "Selected context is too large")
+ if request.explanation_only:
+ return {
+ "resources": result,
+ "purpose": "explain-operations",
+ "limitations": (
+ "Saved snapshots only, not live cloud state or worker task capacity. "
+ "Explain only; return no proposals."
+ ),
+ }
schemas = export_schemas()
return {
"resources": result,
@@ -205,7 +228,7 @@ async def ask(self, actor: Principal, scope: Scope, request: LumiAskRequest, *,
raise CatalogError(409, "WV-LUMI-REVISION", "Lumi configuration changed; reply discarded")
# Recheck attachment permissions and simulation ownership before releasing output.
await self._attachments(actor, scope, request, context)
- return reply
+ return reply.model_copy(update={"proposals": []}) if request.explanation_only else reply
except TimeoutError:
raise CatalogError(504, "WV-LUMI-TIMEOUT", "Lumi did not respond in time") from None
except SecretUnavailable:
diff --git a/src/firefly_weave/operations/lumi_deployments.py b/src/firefly_weave/operations/lumi_deployments.py
new file mode 100644
index 00000000..dd42d284
--- /dev/null
+++ b/src/firefly_weave/operations/lumi_deployments.py
@@ -0,0 +1,85 @@
+# 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 deployment snapshots for read-only assistant explanations."""
+
+from typing import Any, cast
+
+from firefly_weave.contracts.definitions import ContractModel
+from firefly_weave.contracts.values import JsonValue
+
+COLLECTIONS = {
+ "deployment-target": "targets",
+ "deployment": "deployments",
+ "deployment-observation": "observations",
+ "deployment-plan": "plans",
+ "deployment-job": "jobs",
+}
+FIELDS = {
+ "deployment-target": ("name", "adapter", "revision", "disabled", "capabilities"),
+ "deployment": ("name", "revision", "ownership", "created_at"),
+ "deployment-observation": ("target_revision", "observed_at", "expires_at", "complete", "settled"),
+ "deployment-plan": (
+ "adapter",
+ "target_revision",
+ "deployment_revision",
+ "intent",
+ "risks",
+ "created_at",
+ "expires_at",
+ ),
+ "deployment-job": (
+ "kind",
+ "state",
+ "revision",
+ "target_revision",
+ "created_at",
+ "deadline",
+ "reconciliation_started_at",
+ ),
+}
+
+
+def selected(data: dict[str, Any], fields: tuple[str, ...]) -> dict[str, JsonValue]:
+ return {key: cast(JsonValue, data[key]) for key in fields if key in data}
+
+
+def component(data: dict[str, Any]) -> dict[str, JsonValue]:
+ result = selected(
+ data, ("name", "kind", "replicas", "cpu_millis", "memory_mib", "ready_replicas", "ownership", "state")
+ )
+ if data.get("image"):
+ result["image_digest"] = str(data["image"]).rsplit("@", 1)[-1]
+ return result
+
+
+def deployment_context(kind: str, record: ContractModel) -> dict[str, JsonValue]:
+ data = record.model_dump(mode="json")
+ result = selected(data, FIELDS[kind])
+ if kind == "deployment":
+ result["components"] = [component(item) for item in data["components"]]
+ elif kind == "deployment-observation":
+ result["resources"] = [component(item) for item in data["resources"]]
+ elif kind == "deployment-plan":
+ result["steps"] = [
+ {"action": item["action"], "component": component(item["component"])} for item in data["steps"]
+ ]
+ elif kind == "deployment-job" and data.get("receipt"):
+ receipt = data["receipt"]
+ result["receipt"] = selected(receipt, ("code", "external_effects_may_continue")) | {
+ "changed_resource_count": len(receipt["changed_resources"])
+ }
+ return result
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/sdk/transport.py b/src/firefly_weave/sdk/transport.py
index 5eef1cc5..40736ae2 100644
--- a/src/firefly_weave/sdk/transport.py
+++ b/src/firefly_weave/sdk/transport.py
@@ -56,7 +56,7 @@ async def claim(self, limit: int) -> list[TaskLease]:
return [TaskLease.model_validate_json(json.dumps(value)) for value in response.json()]
async def heartbeat(self, lease: LeaseProof) -> TaskLease:
- response = await self._post_rejected("/tasks/heartbeat", lease.model_dump(mode="json"))
+ response = await self._post_rejected("/tasks/heartbeat", lease.model_dump(mode="json"), lease=lease)
response.raise_for_status()
return TaskLease.model_validate_json(response.content)
@@ -106,8 +106,9 @@ async def _post_rejected(
# Only explicit rollback/admission rejection permits replay. Wire errors remain ambiguous.
# Direct callers retain a finite retry window. The owning Worker can wait
# through a longer rejection burst while its current lease watchdog remains authoritative.
+ # Renewal owns a separate retry scope bounded by the current lease expiry.
attempts, seconds = (48, 10) if settlement else (3, 1)
- deadline = settlement_deadline(lease) if settlement else None
+ deadline = settlement_deadline(lease)
if deadline is None:
window = asyncio.timeout(seconds)
else:
diff --git a/src/firefly_weave/sdk/worker.py b/src/firefly_weave/sdk/worker.py
index d31b6555..ba8d1513 100644
--- a/src/firefly_weave/sdk/worker.py
+++ b/src/firefly_weave/sdk/worker.py
@@ -108,7 +108,10 @@ async def heartbeat() -> None:
while True:
delay = max(0.01, (current_expiry - loop.time()) / 3)
await asyncio.sleep(delay)
- async with asyncio.timeout_at(current_expiry):
+ async with (
+ asyncio.timeout_at(current_expiry),
+ lease_settlement(lease.proof, current_expiry),
+ ):
renewed = await self.client.heartbeat(lease.proof)
# A late response cannot revive expired authority, even if a transport
# suppresses cancellation. Only a timely matching proof extends execution.
diff --git a/src/firefly_weave/studio/host.py b/src/firefly_weave/studio/host.py
index dce5eb3b..e1f32c52 100644
--- a/src/firefly_weave/studio/host.py
+++ b/src/firefly_weave/studio/host.py
@@ -347,12 +347,52 @@ 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
return JSONResponse(LLMProfile.model_json_schema(by_alias=True), headers={"Cache-Control": "no-store"})
+ @post_mapping("/studio/contracts/llm-profile/validate")
+ async def validate_llm_profile(self, request: Request) -> Response:
+ from firefly_weave.contracts.llm import LLMProfile
+
+ raw = await request.body()
+ if len(raw) > 65536:
+ return problem(413, "WV-STUDIO-REQUEST", "AI profile exceeds the 64 KiB limit")
+
+ def work() -> Any:
+ try:
+ LLMProfile.model_validate_json(raw)
+ except ValidationError as exc:
+ # Return field locations and guidance without reflecting profile values.
+ errors = exc.errors(include_input=False, include_context=False, include_url=False)
+ return {
+ "valid": False,
+ "issues": [{"path": list(error["loc"]), "message": error["msg"]} for error in errors[:50]],
+ }
+ return {"valid": True, "issues": []}
+
+ return await local_work(work)
+
@get_mapping("/studio/contracts/file-reference")
async def file_reference_contract(self, request: Request) -> Response:
from firefly_weave.contracts.files import file_reference_schema
@@ -450,12 +490,16 @@ async def http_action(self, request: Request) -> Response:
return await local_work(lambda: author_http_action(body.request).model_dump(by_alias=True, mode="json"))
+STATIC_WAIT_SECONDS = 10
+
+
class StudioBoundary:
"""Reject rebinding/cross-site access before credentials or compiler work is reached."""
def __init__(self, app: ASGIApp, service: StudioService) -> None:
self.app, self.service = app, service
self.slots = asyncio.Semaphore(8)
+ self.asset_slots = asyncio.Semaphore(8)
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
if scope["type"] != "http":
@@ -504,10 +548,19 @@ async def reject(status: int, code: str, message: str) -> None:
if protected and mutation and not secret_matches(headers.get("x-weave-csrf", ""), self.service.csrf):
await reject(403, "WV-STUDIO-CSRF", "Refresh Studio before submitting this change")
return
- if self.slots.locked():
+ # Browser navigation and lazy chunks must not compete with slow API calls.
+ static = not mutation and not request.url.path.startswith("/studio/")
+ slots = self.asset_slots if static else self.slots
+ if not static and slots.locked():
+ await reject(429, "WV-STUDIO-BUSY", "Studio is busy; wait for the current requests")
+ return
+ try:
+ async with asyncio.timeout(STATIC_WAIT_SECONDS):
+ await slots.acquire()
+ except TimeoutError:
await reject(429, "WV-STUDIO-BUSY", "Studio is busy; wait for the current requests")
return
- async with self.slots:
+ try:
body = bytearray()
try:
async with asyncio.timeout(10):
@@ -534,6 +587,8 @@ async def bounded_receive() -> Message:
return await receive()
await self.app(scope, bounded_receive, secure_send)
+ finally:
+ slots.release()
@pyfly_application(name="firefly-weave-studio", scan_packages=["firefly_weave.studio.host"])
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/api.ts b/studio/src/app/api.ts
index 2f22c404..a3c2f3e1 100644
--- a/studio/src/app/api.ts
+++ b/studio/src/app/api.ts
@@ -44,6 +44,10 @@ export interface Validation {
partial?: boolean;
artifact?: unknown;
}
+export interface ProfileValidation {
+ valid: boolean;
+ issues: { path: (string | number)[]; message: string }[];
+}
export class ApiError extends Error {
readonly plain: PlainError;
constructor(
@@ -58,8 +62,12 @@ export class ApiError extends Error {
return this.plain.code;
}
}
-/** The platform's admission rejections: nothing ran, so the request may be sent again. */
-const capacityCodes = new Set(["WV-OPERATION-CAPACITY", "WV-REQUEST-CAPACITY"]);
+/** Host/platform admission rejections: nothing ran, so a safe request may be sent again. */
+const capacityCodes = new Set([
+ "WV-OPERATION-CAPACITY",
+ "WV-REQUEST-CAPACITY",
+ "WV-STUDIO-BUSY",
+]);
const capacityAttempts = 4;
/** `Retry-After` in seconds, kept between a quarter second and five seconds. */
const retryDelay = (header: string | null) => {
@@ -77,7 +85,7 @@ export class StudioApi {
};
/** Called when the local host no longer recognizes this window's pairing. */
onSessionEnded: (() => void) | null = null;
- /** Pauses before replaying a request the platform turned away for capacity. */
+ /** Pauses before replaying a request the host or platform turned away for capacity. */
wait = (milliseconds: number) =>
new Promise((resolve) => setTimeout(resolve, milliseconds));
/**
@@ -97,7 +105,7 @@ export class StudioApi {
if (body !== undefined) h["Content-Type"] = "application/json";
if (method !== "GET" && this.session.csrfToken)
h["X-Weave-CSRF"] = this.session.csrfToken;
- // A capacity rejection means the platform did not admit the request, so a
+ // A capacity rejection means the host or platform did not admit the request, so a
// read, or a change that carries an idempotency key, can be sent again.
const replayable = method === "GET" || "Idempotency-Key" in h;
for (let attempt = 1; ; attempt++) {
@@ -166,6 +174,13 @@ export class StudioApi {
format,
});
}
+ validateLlmProfile(profile: Record) {
+ return this.request(
+ "/studio/contracts/llm-profile/validate",
+ "POST",
+ profile,
+ );
+ }
async page(
collection: string,
environment = false,
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..150d4762 100644
--- a/studio/src/app/app.ts
+++ b/studio/src/app/app.ts
@@ -23,12 +23,13 @@ import {
afterEveryRender,
afterNextRender,
signal,
+ viewChild,
inject,
ChangeDetectorRef,
} from "@angular/core";
import type { FileAccess } from "./forms/core/file-reference";
import { Title } from "@angular/platform-browser";
-import { Router } from "@angular/router";
+import { NavigationEnd, Router } from "@angular/router";
import { Icon } from "./icon";
import { AnchoredPopover } from "./forms/ui/anchored-popover";
import { type Schema, missingRequired } from "./task-schema";
@@ -146,6 +147,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 +170,7 @@ export type View =
| "email"
| "connections"
| "workers"
+ | "operations"
| "settings"
| "connect";
type PlatformNoticeKind =
@@ -318,6 +321,7 @@ const sideEffects: Record = {
ToastHost,
DesignerView,
RecordsView,
+ OperationsView,
SettingsPage,
LumiPanel,
],
@@ -326,6 +330,9 @@ const sideEffects: Record = {
export class App {
private cdr = inject(ChangeDetectorRef);
private router = inject(Router);
+ private historyPosition: number = history.state?.weavePosition ?? 0;
+ private historyUrl = location.href;
+ private restoringHistory = false;
private injector = inject(Injector);
dialogs = inject(DialogService);
crypto = crypto;
@@ -353,6 +360,12 @@ export class App {
detail?: string;
} | null = null;
lumiOpen = false;
+ private readonly operationsView = viewChild(OperationsView);
+ get lumiOperationAttachments() {
+ return this.view === "operations"
+ ? (this.operationsView()?.lumiAttachments ?? [])
+ : [];
+ }
lumiSettingsRequested = false;
platformMenuOpen = false;
platformBusy = "";
@@ -535,12 +548,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 = "";
@@ -659,6 +673,12 @@ export class App {
{ value: "file_manager", label: "File manager" },
{ value: "lumi_user", label: "Lumi user" },
{ value: "lumi_manager", label: "Lumi manager" },
+ { value: "deployment_reader", label: "Deployment viewer" },
+ { value: "deployment_planner", label: "Deployment planner" },
+ { value: "deployment_approver", label: "Deployment approver" },
+ { value: "deployment_operator", label: "Deployment operator" },
+ { value: "deployment_runner", label: "Deployment runner" },
+ { value: "worker_operator", label: "Worker operator" },
];
// Published action catalog, loaded once per workspace and shared by every action step.
actionVersions: Record[] = [];
@@ -703,6 +723,7 @@ export class App {
// The model step JSON the inspector buffers were loaded from.
private inspectorBase = "";
private inspectorStepId = "";
+ inspectorSession = 0;
private scopeCache: { key: string; value: ReferenceContext | null } = {
key: "",
value: null,
@@ -916,6 +937,26 @@ export class App {
} | null = null;
showTemplates = false;
constructor() {
+ history.replaceState(
+ { ...history.state, weavePosition: this.historyPosition },
+ "",
+ );
+ this.router.events.subscribe((event) => {
+ if (!(event instanceof NavigationEnd)) return;
+ const navigation = this.router.currentNavigation();
+ if (
+ navigation?.trigger !== "imperative" ||
+ navigation.extras.skipLocationChange
+ )
+ return;
+ if (!navigation.extras.replaceUrl && location.href !== this.historyUrl)
+ this.historyPosition++;
+ this.historyUrl = location.href;
+ history.replaceState(
+ { ...history.state, weavePosition: this.historyPosition },
+ "",
+ );
+ });
this.api.onSessionEnded = () => this.sessionEnded();
this.loginWatcher.subscribe((event) => this.backgroundLogin(event));
afterEveryRender(() => {
@@ -1290,7 +1331,9 @@ export class App {
}
/** Loads the inspector buffers from the selected model step. */
loadInspector() {
+ this.inspectorSession++;
this.cancelInspectorCommit();
+ this.invalidInspectorFields.clear();
this.actionChoiceRevision = null;
this.renameDraft = this.selected?.step.id ?? "";
this.renameError = "";
@@ -1380,7 +1423,12 @@ export class App {
return true;
}
async navigate(view: View) {
- if (this.view === "designer" && view !== "designer") this.leaveInspector();
+ if (
+ this.view === "designer" &&
+ view !== "designer" &&
+ !this.leaveInspector()
+ )
+ return;
if (
this.view === "designer" &&
view !== "designer" &&
@@ -1521,6 +1569,7 @@ export class App {
}
/** Edits not yet kept anywhere: on this computer or on the platform. */
get unsavedWork() {
+ if (this.inspectorProblem) return true;
if (!this.dirty) return false;
if (this.profile || this.draftRevision !== undefined || this.published)
return true;
@@ -1650,6 +1699,7 @@ export class App {
});
}
newWorkflow() {
+ if (this.view === "designer" && !this.leaveInspector()) return;
this.flushLocalSave();
this.importGeneration++;
this.keptLocally = false;
@@ -1697,7 +1747,7 @@ export class App {
}
return;
}
- this.leaveInspector();
+ if (!this.leaveInspector()) return;
if (reveal) this.showInspector = true;
this.message = `Selected ${node.step.id}, ${this.label(node.step.kind)}, ${ownerLabel(node.owner, this.stepsById()).replace(/^Main sequence$/, "main sequence")}.`;
this.model.selected = node.step.id;
@@ -1779,10 +1829,11 @@ export class App {
return;
this.touched[scope] = true;
}
- /** Commands use the last valid fields and never interrupt with an edit dialog. */
+ /** A specialized inspector may own an explicit Apply/Cancel draft. */
+ inspectorDraftGuard: (() => string) | null = null;
+ private invalidInspectorFields = new Set();
async ensureApplied(): Promise {
- this.flushInspector();
- return true;
+ return this.leaveInspector();
}
private cancelInspectorCommit() {
if (this.inspectorTimer) clearTimeout(this.inspectorTimer);
@@ -1822,9 +1873,14 @@ export class App {
this.model.renameStep(pending.id, pending.text);
this.inspectorStepId = this.model.selected;
this.renameDraft = this.model.selected;
- const step = this.selected?.step;
- this.inspectorBuffer = step ? JSON.stringify(step, null, 2) : "";
- this.propertyStep = step ? structuredClone(step) : null;
+ // Renaming must not remount the grid and erase invalid sibling text.
+ try {
+ const draft = this.bufferStep();
+ draft.id = this.model.selected;
+ this.inspectorBuffer = JSON.stringify(draft, null, 2);
+ } catch {
+ // Advanced JSON may still contain an unfinished edit.
+ }
} else if (pending.scope === "step") {
const step = this.model
.nodes()
@@ -1871,6 +1927,8 @@ export class App {
}
}
inspectorFieldValidity(change: { path: string; valid: boolean }) {
+ if (change.valid) this.invalidInspectorFields.delete(change.path);
+ else this.invalidInspectorFields.add(change.path);
if (!change.valid && this.pendingInspector?.key === change.path)
this.cancelInspectorCommit();
}
@@ -1913,49 +1971,45 @@ export class App {
this.cancelInspectorCommit();
}
}
- private leaveInspector() {
- this.flushInspector();
- if (!this.renameError && !this.editsPending) return;
- const id = this.model.selected;
- const opened = this.model.opened;
- const field =
- document
- .querySelector(
- ".inspector [aria-invalid=true], .inspector .property-error",
- )
- ?.closest("[data-field]")?.dataset["field"] ??
- (this.renameError ? "id" : "");
- this.notify(
- "The invalid edit wasn't saved. The last valid value is kept.",
- {
- label: "Go back",
- run: () => {
- if (opened !== this.model.opened) return;
- this.flushInspector();
- this.model.selected = id;
- this.loadInspector();
- this.showInspector = true;
- this.tab = "Designer";
- this.focusLater(
- () =>
- document.querySelector(
- '.inspector [data-field="' + CSS.escape(field) + '"] input',
- ) ?? document.querySelector(".inspector-header h2"),
- );
- },
- },
+ private get inspectorProblem(): string {
+ return (
+ this.inspectorDraftGuard?.() ||
+ (this.renameError ||
+ this.invalidInspectorFields.size ||
+ this.inspectorDirty ||
+ (this.touched.step &&
+ this.actionInputMode === "fields" &&
+ !!this.actionContract &&
+ !this.actionInputValid()) ||
+ (!this.selected && this.editsPending)
+ ? "Fix the invalid fields before continuing. Your edits are still here."
+ : "")
);
- this.loadInspector();
- this.loadWorkflowSettings();
+ }
+ private inspectorNotice = "";
+ private leaveInspector(): boolean {
+ this.flushInspector();
+ const problem = this.inspectorProblem;
+ if (!problem) {
+ if (this.error === this.inspectorNotice) this.error = "";
+ this.inspectorNotice = "";
+ return true;
+ }
+ this.inspectorNotice = problem;
+ this.error = problem;
+ this.errorCode = "";
+ this.showInspector = true;
+ this.cdr.markForCheck();
+ return false;
}
closeInspector() {
- this.leaveInspector();
+ this.flushInspector();
this.showInspector = false;
}
/** Returns the inspector to the workflow settings. */
async deselect() {
if (!this.model.selected) return true;
- this.leaveInspector();
+ if (!this.leaveInspector()) return false;
this.model.selected = "";
this.loadInspector();
this.loadWorkflowSettings();
@@ -2086,6 +2140,7 @@ export class App {
* "Active in Production". Null for a new local workflow with no edits.
*/
get statusChip(): { text: string; tone: string } | null {
+ if (this.inspectorProblem) return { text: "Unsaved", tone: "warning" };
const version = String(
this.published?.["version"] ?? this.model.definition.metadata.version,
);
@@ -2878,7 +2933,7 @@ export class App {
}
readonly editorViews = ["Designer", "Source", "Outline"];
selectTab(tab: string) {
- if (tab !== this.tab) this.leaveInspector();
+ if (tab !== this.tab && !this.leaveInspector()) return;
this.tab = tab;
if (tab === "Designer") this.outlineNotice = false;
if (tab !== "Designer" && window.innerWidth <= 1280)
@@ -3030,6 +3085,7 @@ export class App {
);
}
switchPropertyMode() {
+ if (!this.leaveInspector()) return;
if (this.advancedProperties) {
try {
const step = JSON.parse(this.inspectorBuffer) as Step;
@@ -3348,6 +3404,8 @@ export class App {
}
stepEdit(value: unknown, field = "step") {
const step = value as Step;
+ // A mounted grid can retain the ID from before a live rename.
+ if (this.selected) step.id = this.selected.step.id;
// Fields edited outside the property table keep their buffered values.
if (step.kind === "action") {
const buffer = this.bufferStep();
@@ -3855,7 +3913,9 @@ export class App {
}
slotUseCount(name: string) {
return this.nodes.filter(
- (node) => node.step.kind === "action" && node.step["connection"] === name,
+ (node) =>
+ ["action", "llm"].includes(node.step.kind) &&
+ node.step["connection"] === name,
).length;
}
slotConnectorOptions() {
@@ -4972,7 +5032,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;
@@ -5840,6 +5900,7 @@ export class App {
if (file) await this.importWorkflow(file);
}
async importWorkflow(file: File) {
+ if (this.view === "designer" && !this.leaveInspector()) return;
this.flushLocalSave();
if (file.size > 1024 * 1024) {
this.error = "Workflow source is limited to 1 MiB.";
@@ -7251,7 +7312,34 @@ export class App {
runNodes() {
return this.runCanvas?.nodes() ?? [];
}
- @HostListener("window:popstate") popstate() {
+ @HostListener("window:popstate", ["$event"]) popstate(event: PopStateEvent) {
+ const position = event.state?.weavePosition;
+ if (this.restoringHistory && position === this.historyPosition) {
+ this.restoringHistory = false;
+ return;
+ }
+ if (this.view === "designer" && !this.leaveInspector()) {
+ if (Number.isInteger(position) && position !== this.historyPosition) {
+ // Return to the existing entry: replacing the URL would erase the
+ // destination, and pushing a replacement would discard Forward entries.
+ this.restoringHistory = true;
+ history.go(this.historyPosition - position);
+ } else {
+ // Entries from an older Studio have no position. Keep that destination
+ // available through Back while restoring the current draft's URL.
+ history.pushState(
+ { weavePosition: this.historyPosition },
+ "",
+ this.historyUrl,
+ );
+ void this.router.navigateByUrl(new URL(this.historyUrl).pathname, {
+ replaceUrl: true,
+ });
+ }
+ return;
+ }
+ this.historyPosition = Number.isInteger(position) ? position : 0;
+ this.historyUrl = location.href;
this.releaseWizard();
this.platformMenuOpen = false;
const designer = designerPath.exec(location.pathname);
@@ -7259,6 +7347,8 @@ export class App {
// Back to the workflow that is open, or to another kept one.
if (designer[1] === this.draftId) {
this.view = "designer";
+ this.loadInspector();
+ this.loadWorkflowSettings();
this.scheduleFit();
} else void this.restoreWorkflow(designer[1]);
return;
diff --git a/studio/src/app/designer/action-inspector.ts b/studio/src/app/designer/action-inspector.ts
index c578451b..dcc3c750 100644
--- a/studio/src/app/designer/action-inspector.ts
+++ b/studio/src/app/designer/action-inspector.ts
@@ -19,7 +19,15 @@ SPDX-License-Identifier: Apache-2.0
// connection slot, its input and its output. Loaded lazily (@defer) so the
// initial bundle stays small; the shell passes itself as the host and keeps
// owning the state.
-import { ChangeDetectionStrategy, Component, input } from "@angular/core";
+import {
+ afterNextRender,
+ ChangeDetectionStrategy,
+ Component,
+ ElementRef,
+ inject,
+ Injector,
+ input,
+} from "@angular/core";
import { Icon } from "../icon";
import { CatalogPicker } from "../integrations/catalog-picker";
import { ConnectionSlotList } from "../integrations/connection-slot-list";
@@ -50,7 +58,9 @@ import type { App } from "../app";
[open]="h.sectionOpen('action')"
(toggle)="h.sectionToggled('action', $event)"
>
- Action
+
+ 1Choose action
+
@if (h.integrationState !== "ready") {
-
- No action yet? Describe the API request, or import an OpenAPI
- document, and Studio builds the action with no code.
-
+
+
+
+ i
+
+
+ Describe the API request, or import an OpenAPI document, and
+ Studio builds the action with no code.
+
- 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.
- Your account can't read the published actions in this workspace.
-
- }
- @case ("error") {
-
+
+
+
+ Use an existing action
+
+
+
+ i
+
- Published actions couldn't be loaded.
- {{ host().catalogError?.message }}
+ Published actions are reusable steps shared in your project. Choose
+ one to add it to this workflow with its saved settings.
- {{
- query().trim()
- ? "No published action matches the search."
- : "This project has no published actions yet."
- }}
+ Your account can't read the published actions in this workspace.
}
+ @case ("error") {
+
+
+ Published actions couldn't be loaded.
+ {{ host().catalogError?.message }}
+
+
+ Try again
+
+
+ }
+ @default {
+ @if (!results().count) {
+
+ {{
+ query().trim()
+ ? "No published action matches the search."
+ : "This project has no published actions yet."
+ }}
+
+ {{ results().hidden }} more. Search the steps to narrow the list.
+
+ }
+
+
+
+
+ Create an API action
+
+
+
+ i
+
+
+ Describe an API request or import its OpenAPI definition. You can
+ configure and save an action file on this computer. Connect to
+ publish it for your project.
+
Ask an administrator for access to Lumi in this environment.
- } @else if (!status?.configured && !loading) {
+ } @else if (!settings && !status?.configured && !loading) {
Lumi is unavailable in this environment. An administrator configures
its model and connection; a platform operator enables the Lumi
@@ -116,8 +116,8 @@ const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
@if (settings) {
- These settings apply only to this assistant in this environment.
- Workflow AI profiles are configured separately.
+ Configure Lumi for this environment. Workflow AI profiles have
+ their own settings.
- Select where Lumi sends requests. Your platform operator
- supplies the approved endpoint and stores the credentials.
+ Choose Lumi's provider connection. Credentials stay with the
+ platform operator.
- @if (host.model.opened) {
+ @if (host.model.opened && host.view !== "operations") {
= 4 &&
+ !attachments.has(attachment.kind + attachment.id)
+ "
(change)="
toggleAttachment(
attachment.kind + attachment.id,
@@ -393,6 +398,13 @@ const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
context you include. This conversation stays in memory for this
identity and environment.
+ @if (host.view === "operations") {
+
+ Lumi explains only the saved Operations records you select.
+ Replica and resource limits do not show worker task capacity
+ or current cloud state. No deployment changes are made.
+
+ }
) =>
+ this.host.api.validateLlmProfile({ ...profile, outputSchema: {} });
private cdr = inject(ChangeDetectorRef);
private element = inject>(ElementRef);
private keepFocus() {
@@ -606,6 +620,9 @@ export class LumiPanel implements DoCheck {
this.host.identity?.principal_id,
this.host.can("lumi.use"),
this.host.can("lumi.manage"),
+ this.host.view === "operations"
+ ? this.host.lumiOperationAttachments.map(({ kind, id }) => [kind, id])
+ : null,
]);
if (this.conversation.sync(key)) {
this.reset();
@@ -667,6 +684,8 @@ export class LumiPanel implements DoCheck {
return unchangedDraft(proposal.base, this.capture());
}
get availableAttachments() {
+ if (this.host.view === "operations")
+ return this.host.lumiOperationAttachments;
const items: {
kind: "draft" | "run" | "simulation";
id: string;
@@ -724,7 +743,7 @@ export class LumiPanel implements DoCheck {
)
return;
this.keepFocus();
- this.host.flushInspector();
+ if (this.host.view !== "operations") this.host.flushInspector();
const base = this.capture();
const generation = this.conversation.generation;
const message = this.message.trim();
@@ -735,7 +754,7 @@ export class LumiPanel implements DoCheck {
.filter((item) => this.attachments.has(item.kind + item.id))
.map(({ kind, id }) => ({ kind, id })),
};
- if (this.includeSource)
+ if (this.includeSource && this.host.view !== "operations")
body["draft"] = {
format: this.host.model.format,
source: this.host.sourceBuffer || this.host.model.source,
@@ -754,12 +773,14 @@ export class LumiPanel implements DoCheck {
this.conversation.turns.push({
role: "assistant",
content: reply.answer,
- proposals: reply.proposals.map((proposal) => ({
- ...proposal,
- base,
- validated: null,
- problems: [],
- })),
+ proposals: (this.host.view === "operations" ? [] : reply.proposals).map(
+ (proposal) => ({
+ ...proposal,
+ base,
+ validated: null,
+ problems: [],
+ }),
+ ),
followUps: reply.followUps,
});
} catch (error) {
diff --git a/studio/src/app/lumi/lumi-state.ts b/studio/src/app/lumi/lumi-state.ts
index 4f18f3f8..ad1bb7c2 100644
--- a/studio/src/app/lumi/lumi-state.ts
+++ b/studio/src/app/lumi/lumi-state.ts
@@ -21,6 +21,16 @@ export interface DraftRevision {
source: string;
buffer: string;
}
+export interface LumiOperationAttachment {
+ kind:
+ | "deployment-target"
+ | "deployment"
+ | "deployment-observation"
+ | "deployment-plan"
+ | "deployment-job";
+ id: string;
+ label: string;
+}
export interface LumiProposal {
title: string;
kind: "workflow" | "decisionTable" | "action" | "connector";
diff --git a/studio/src/app/operations/deployment-contracts.ts b/studio/src/app/operations/deployment-contracts.ts
new file mode 100644
index 00000000..e6c63112
--- /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";
+ 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"] | "migration" | "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..ae48ff99
--- /dev/null
+++ b/studio/src/app/operations/deployment-editor.ts
@@ -0,0 +1,383 @@
+/*
+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 {
+ observedComponents,
+ type AdmittedWorkerRelease,
+ type ComponentDraft,
+} from "./deployment-onboarding";
+import { Select } from "../forms/ui/select";
+import { AiSetupWizard } from "../integrations/ai-setup-wizard";
+import { TaskForm } from "../task-form";
+import { missingBound } from "../forms/core/form-model";
+import { fieldsOf } from "../forms/core/resolve";
+import type { Schema } from "../task-schema";
+import { missingRequired } from "../task-schema";
+import type {
+ Observation,
+ Deployment,
+ DeploymentRequest,
+ Target,
+} from "./deployment-contracts";
+
+@Component({
+ selector: "weave-deployment-editor",
+ standalone: true,
+ imports: [AiSetupWizard, TaskForm, Select],
+ 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.
+
+ This copies the selected names, immutable images and replica counts
+ into your draft. Enter the local configuration alias, CPU and memory
+ limits explicitly. Observations do not report those limits. Other
+ resources require manual review.
+
+ }
+
+ Use immutable image digests and named configuration references already
+ allowed by the runner. Keep secrets in the runner environment. Unset
+ optional limits for new components use the canonical defaults shown in
+ review. Imported components require an explicit CPU and memory choice.
+
+
+
{{ releaseMessage }}
+ @for (item of items; track $index; let index = $index) {
+ @if (item.kind === "worker") {
+
+ }
+ }
+
+ API/scheduler requires exactly one instance. Workers require an
+ admitted worker release. Reducing replicas terminates containers;
+ drain worker instances before reducing a pool. Run database migrations
+ separately with the deployment upgrade runbook.
+
+ Desired revision {{ deployment().revision }} will be checked against one
+ complete, unexpired observation of target revision
+ {{ target().revision }}.
+
+ Container Apps updates support worker and Lumi apps. Upgrade the API
+ separately with the Azure deployment runbook to preserve one scheduler.
+
+ }
+
+
+ @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() }}
+ }
+
+
+ Generate reviewable planCancel plan
+
+ `,
+ 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 = "";
+ supportsComponents(intent: Plan["intent"]) {
+ return supportedPlanComponents(
+ this.target().adapter,
+ intent,
+ this.deployment().components,
+ );
+ }
+ 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"]) &&
+ supportedCapabilities(this.target().adapter).includes(
+ value as Plan["intent"],
+ ) &&
+ this.supportsComponents(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.
+
+ }
+ I verified the external operation has stopped
+
+ Record reconciliation
+ `,
+ 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..a1a002cf
--- /dev/null
+++ b/studio/src/app/operations/operations-view.ts
@@ -0,0 +1,1358 @@
+/*
+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 type { LumiOperationAttachment } from "../lumi/lumi-state";
+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 { RunnerSetup } from "./runner-setup";
+import type {
+ RunnerApplication,
+ AdmittedWorkerRelease,
+} from "./deployment-onboarding";
+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,
+ RunnerSetup,
+ NgTemplateOutlet,
+ DeploymentEditor,
+ DeploymentPlanBuilder,
+ DeploymentReconciliation,
+ ],
+ styleUrl: "./operations-view.css",
+ template: `
+
+
+
CONTAINER OPERATIONS
+
Operations
+
+ Targets, deployments and their verified observations in this
+ environment.
+
+ Infrastructure state comes from authorized outbound runners. Local
+ workflow authoring does not connect to Docker, Kubernetes or a cloud
+ account.
+
+
+ Connect to a platform
+
+
+ } @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) {
+
{{ 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.
+
+ 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 (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) {
+
diff --git a/studio/src/app/operations/runner-setup.ts b/studio/src/app/operations/runner-setup.ts
new file mode 100644
index 00000000..fd86a197
--- /dev/null
+++ b/studio/src/app/operations/runner-setup.ts
@@ -0,0 +1,192 @@
+/*
+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 type { Target } from "./deployment-contracts";
+import { exportFile } from "../export-file";
+import { runnerSetup } from "./deployment-onboarding";
+
+@Component({
+ selector: "weave-runner-setup",
+ standalone: true,
+ template: `
+
+
Connect the outbound runner
+
+ Connect an existing destination. New clusters and networks need separate
+ infrastructure setup.
+
+
+
+
1. Authorize the application
+
+ Link its verified machine identity. Grant
+ deployment_runner only on this target.
+
+
+ Target ID for the grant
+ {{ target().id }}
+
+
+ Open settings — People and access
+
+
+
+
2. Prepare the runner host
+
+ Install the platform's Weave version with its client extra and the
+ provider CLI. Complete local policy and credential-file references.
+
+
+ Download setup template
+
+
+ Known workspace and target IDs included. No credentials.
+
+ {{
+ observed()
+ ? "Observation: current and complete"
+ : "Observation: not yet verified"
+ }}
+
+
+ Check locally, start the runner, then refresh Operations and observe
+ the target. Contact alone does not prove health.
+
+
+
+ @if (notice) {
+
{{ notice }}
+ }
+
+ Local setup template
+
+ Save this nonsecret template as /opt/weave/private/runner.json on the
+ runner host. Set private file permissions. Replace local paths and
+ context; add exact component names, roles, configuration aliases,
+ container names (Kubernetes/ACA), ceilings and allowed repositories.
+ Create the referenced private OAuth file locally. Never paste
+ credentials into Studio.
+
+
{{
+ configuration
+ }}
+
+ Or use the interactive local setup wizard with the workspace and
+ target IDs above:
+
+ The runtime identity and boundary are fixed. Register a separate
+ target for another destination.
+
+ } @else {
+
+ Registering a target records its identity and permitted scope. It
+ does not provision resources, start containers or prove
+ connectivity.
+
+ Target name
+
+ External identity
+
+ {{
+ adapter === "kubernetes"
+ ? "Use the namespace UID from kubectl get namespace NAME -o jsonpath={.metadata.uid}. AKS, EKS and GKE use this adapter."
+ : adapter === "docker-compose"
+ ? "Use the Docker daemon ID from docker --context CONTEXT info (the ID field)."
+ : "Use the existing Container Apps managed environment ARM resource ID."
+ }}
+
+ Allowed boundary
+
+ A Compose project, Kubernetes namespace or managed-container
+ boundary. Registration cannot expand the runner's local policy.
+
+ }
+
+
+ @if (existing()) {
+
+
+ Capabilities are permitted operations, not evidence of adapter
+ support. Observe must remain permitted. Disabling prevents new
+ operations.
+
+ } @else {
+
+
{{ applicationsMessage() }}
+
+ Open settings — People and access
+
+
+ Use an administrator-provided application ID
+ Runner principal ID
+
+
+ An administrator creates a dedicated application principal with
+ runner grants. Install its outbound runner near this target. Keep
+ infrastructure credentials and Docker/Kubernetes access on that
+ runner; never paste them here.
+
+
+
+ These are permissions, not verified features. An operation also
+ requires a runner that supports it and an approved plan.
+
+ Saving changes the authority revision and invalidates current runner
+ leases and prior plan preconditions. External operations already
+ dispatched may continue. Disabling does not stop containers or roll
+ back effects.
+
+ } @else {
+
+
Name
+
{{ name }}
+
Runtime
+
{{ adapterLabels[adapter] }}
+
Identity / boundary
+
{{ external }} / {{ boundary }}
+
Runner principal
+
{{ principalLabel }}
+
Permitted operations
+
{{ allowed.join(", ") }}
+
+
+ After registration, request an observation to discover the existing
+ resources. Import remains separate from adopting management or
+ applying a deployment.
+