diff --git a/CODE_SIGNING_POLICY.md b/CODE_SIGNING_POLICY.md
index 4584e84..2871eb4 100644
--- a/CODE_SIGNING_POLICY.md
+++ b/CODE_SIGNING_POLICY.md
@@ -1,10 +1,10 @@
# Code signing policy
-Last updated: 2026-07-25
+Last updated: 2026-07-26
## Current status
-DS Agent `v1.5.0` is intentionally published unsigned. Both `ds-agent.exe` and
+DS Agent `v1.6.0` is intentionally published unsigned. Both `ds-agent.exe` and
the Windows x64 NSIS installer are expected to report Authenticode `NotSigned`.
Windows may therefore display `Unknown publisher` or a Microsoft Defender
SmartScreen warning. Users should download only over HTTPS from the official
@@ -13,7 +13,7 @@ GitHub Release and verify the published SHA-256 before running the installer.
The SignPath Foundation application is submitted and approval is pending. No
DS Agent binary may be represented as SignPath-signed until the application is
approved and a later-version artifact independently verifies as Authenticode
-`Valid`. The project will not replace the immutable `v1.1.0`, `v1.2.0`, `v1.3.0`, `v1.4.0`, or `v1.5.0` tag,
+`Valid`. The project will not replace the immutable `v1.1.0`, `v1.2.0`, `v1.3.0`, `v1.4.0`, `v1.5.0`, or `v1.6.0` tag,
Release, or asset if signing becomes available later.
For releases accepted into that program: **Free code signing provided by
@@ -57,7 +57,7 @@ them; guessed or placeholder identifiers are forbidden.
## Release verification
-For the unsigned `v1.1.0`, `v1.2.0`, `v1.3.0`, `v1.4.0`, and `v1.5.0` exceptions, maintainers verify and disclose the actual
+For the unsigned `v1.1.0`, `v1.2.0`, `v1.3.0`, `v1.4.0`, `v1.5.0`, and `v1.6.0` exceptions, maintainers verify and disclose the actual
`NotSigned` status of both the application executable and installer. Evidence
must bind the exact source commit, file name, product version, byte size, and
SHA-256. The installer downloaded back from GitHub must match the reviewed
diff --git a/PRIVACY.md b/PRIVACY.md
index e7d6298..d34d90d 100644
--- a/PRIVACY.md
+++ b/PRIVACY.md
@@ -1,6 +1,6 @@
# Privacy Policy
-Last updated: 2026-07-25
+Last updated: 2026-07-26
This policy describes the current published DS Agent desktop application and
public project. DS Agent is local-first and does not operate a project cloud
@@ -14,7 +14,7 @@ User-selected workspaces hold approved evidence, exports, reports, work
packages, screenshots, and other artifacts. This information is not silently
synced to a DS Agent-operated server.
-The current stable `v1.5.0` accepts one user-supplied DeepSeek API key through
+The current stable `v1.6.0` accepts one user-supplied DeepSeek API key through
the onboarding screen and stores it in a dedicated Windows DPAPI-protected
local vault. A process-environment key remains an explicit compatibility
fallback and is never silently copied into that vault. The project does not
@@ -31,20 +31,27 @@ coverage counts plus a redacted authorization intent; it does not receive the
private capability proposal, a preparation/compiler command, or a local
completion writer.
-The v1.5.0 package preserves the T1 Office verification engine, which keeps source identities, bounded fact
+The v1.6.0 package preserves the T1 Office verification engine, which keeps source identities, bounded fact
provenance, artifact hashes, render receipts, revision receipts, and
continuation checkpoints locally in the selected workspace or application data.
Ordinary chat does not yet automatically select or sequence the two T1 tools;
their presence does not cause workspace files to be scanned or uploaded merely
because the application is open.
-The v1.5.0 durable Computer Use lifecycle stores bounded application, process,
+The v1.6.0 package preserves the durable Computer Use lifecycle, which stores bounded application, process,
window, frame, target, action, approval, screenshot-evidence, and semantic
receipt identities locally. Its installed reliability tests use generated
files, workbooks, data, fresh Edge profiles, an ephemeral loopback portal, and
isolated Event Stores. Those tests do not read stored browser logins, production
accounts, or the user's normal DS Agent AppData.
+The v1.6.0 durable automation runtime stores definition and revision identities,
+schedule windows, run state, restart checkpoints, leases, effect state,
+reconciliation attempts, dead-letter state, and exact local undo receipts
+locally. TaskCheckpoint UI projections exclude raw worker tokens, lease fences,
+idempotency keys, private evidence, provider bodies, and secrets. A result that
+may have crossed an external-effect boundary is not replayed automatically.
+
Uninstalling the application may not delete a user-selected workspace or every
application-data file. Review and remove those local locations separately when
you no longer want to retain them.
@@ -81,7 +88,7 @@ the user or person operating the application:
The optional local desktop bridge accepts only loopback addresses and is
started and controlled by the user. DS Agent does not install or supervise that
service. Production Microsoft and Google account registration and live
-mail/calendar writes are disabled in `v1.5.0`; offline connector contracts do
+mail/calendar writes are disabled in `v1.6.0`; offline connector contracts do
not authorize a production account or external write.
## What can be included in a model request
diff --git a/README.md b/README.md
index e5f35a5..c4da9de 100644
--- a/README.md
+++ b/README.md
@@ -11,8 +11,8 @@
- v1.5.0 stable ·
- Download for Windows ·
+ v1.6.0 stable ·
+ Download for Windows ·
Apache-2.0
@@ -129,16 +129,11 @@ does not claim completion from model confidence alone. Local files, browser
actions, Office artifacts, and Computer Use are complete only when observable
evidence satisfies the task's completion criteria.
-In v1.5.0, DeepSeek may propose a bounded `GoalEnvelope`, but only the local
-Kernel can validate and freeze it. For the same queued task, the Kernel alone
-derives the capability manifest, risk, and preview shown in one exact-task
-authorization card. Approval creates only exact authority; it does not execute
-a Tool, resume the task, or mark the Goal complete. Completion remains blocked
-until locally authoritative verifier evidence covers every frozen `done_when`
-condition and required artifact identity.
+In v1.6.0, DeepSeek may propose a bounded `GoalEnvelope`, but only the local Kernel can validate and freeze it. For the same queued task, the Kernel alone derives the capability manifest, risk, and preview shown in one exact-task authorization card.
+Approval creates only exact authority; it does not execute a Tool, resume the task, or mark the Goal complete. Completion remains blocked until locally authoritative verifier evidence covers every frozen `done_when` condition and required artifact identity.
-The v1.5.0 binary preserves the T1 Excel/PowerPoint verification engine and adds a production-reachable durable Computer Use session/step UI and Kernel loop.
-Exact installed evidence is limited to generated File Explorer and Excel targets plus installed Edge with a fresh profile and an ephemeral loopback test portal; it does not claim general browser automation, stored-login reuse, or production-site completion.
+The v1.6.0 binary preserves the T1 Excel/PowerPoint verification engine and the production-reachable durable Computer Use session/step loop, then adds production-reachable persistent automation with immutable definition revisions, scheduled and manual runs, checkpointed leases, restart-safe pre-effect recovery, idempotency/effect reconciliation, dead-letter state, and a TaskCheckpoint view.
+Safe pre-effect work can recover automatically. Effect-started or unknown work is never replayed blindly and requires explicit recovery; confirmed external effects are not represented as locally undoable. Exact installed evidence is limited to generated File Explorer and Excel targets plus installed Edge with a fresh profile and an ephemeral loopback test portal.
The release remains honest: ordinary chat does not yet automatically select or sequence the two T1 tools or the complete T1 Office workflow.
## DeepSeek and DS Agent boundary
@@ -164,7 +159,7 @@ high-risk action. See the full [model boundary](docs/AGENT_MODEL_BOUNDARY.md).
reconciliation contracts validated with offline adversarial fake providers.
Production Microsoft/Google account registration and live external-write
-authority remain disabled in v1.5.0. The release does not sign in to real
+authority remain disabled in v1.6.0. The release does not sign in to real
accounts, send real email, or create, change, or cancel real calendar events.
## Why Rust
@@ -177,7 +172,7 @@ remain thin; the Kernel and persistent projections own business state.
## Quick start
-1. Download the [Windows x64 installer](https://github.com/Lee-take/dsagent/releases/download/v1.5.0/DS.Agent_1.5.0_x64-setup.exe).
+1. Download the [Windows x64 installer](https://github.com/Lee-take/dsagent/releases/download/v1.6.0/DS.Agent_1.6.0_x64-setup.exe).
2. Enter your own valid DeepSeek API key in onboarding and run the explicit
balance/model verification. The key is stored locally with Windows DPAPI.
3. Choose one local workspace and let the readiness doctor verify its managed
@@ -189,7 +184,7 @@ A user-supplied DeepSeek API key is a required prerequisite. DS Agent does not
bundle a shared key or bypass DeepSeek access requirements; use remains subject
to DeepSeek's terms and account policies.
-The v1.5.0 application executable and installer are Authenticode `NotSigned`.
+The v1.6.0 application executable and installer are Authenticode `NotSigned`.
Windows may display `Unknown publisher` or a Microsoft Defender SmartScreen
warning. Download only over HTTPS from this repository, verify the SHA-256 in
the GitHub Release, and read the [installation guide](docs/INSTALLATION.md)
@@ -197,10 +192,10 @@ before running the installer.
## Code signing policy
-DS Agent `v1.5.0` is intentionally unsigned. The SignPath Foundation application
+DS Agent `v1.6.0` is intentionally unsigned. The SignPath Foundation application
is submitted and approval is pending; no release is represented as signed.
If the project is accepted, signing starts with a later new version and does not
-replace the immutable v1.1.0, v1.2.0, v1.3.0, v1.4.0, or v1.5.0 tag or asset. For releases accepted into the program:
+replace the immutable v1.1.0, v1.2.0, v1.3.0, v1.4.0, v1.5.0, or v1.6.0 tag or asset. For releases accepted into the program:
**Free code signing provided by SignPath.io, certificate by SignPath
Foundation.** See the full [code signing policy](CODE_SIGNING_POLICY.md) and
[privacy policy](PRIVACY.md).
@@ -218,8 +213,8 @@ example `D:\build-target\ds-agent-v1-release`.
## Stable release
-- Release: [DS Agent v1.5.0](https://github.com/Lee-take/dsagent/releases/tag/v1.5.0)
-- Installer: `DS.Agent_1.5.0_x64-setup.exe`
+- Release: [DS Agent v1.6.0](https://github.com/Lee-take/dsagent/releases/tag/v1.6.0)
+- Installer: `DS.Agent_1.6.0_x64-setup.exe`
- Integrity: verify the final byte size and SHA-256 published in the GitHub
Release before running the installer.
- Onboarding: one user-supplied Key, Windows DPAPI storage, explicit DeepSeek
@@ -233,6 +228,10 @@ example `D:\build-target\ds-agent-v1-release`.
- Task authorization: one Kernel-derived exact-task card with manifest/risk/
preview binding, one user decision, per-capability audit, and exact revocation;
approval does not execute or resume the task.
+- Durable automation: immutable definition revisions, persisted trigger windows,
+ checkpointed leases, restart-safe pre-effect recovery, idempotency/effect
+ reconciliation, bounded backoff/dead-letter state, exact local undo, and
+ truthful no-undo treatment for confirmed external effects.
- Computer Use: production-reachable durable lifecycle and fail-closed execution; exact installed proof is limited to generated Explorer/Excel targets and an isolated Edge/loopback test portal, not general browsing.
- T1 verification engine: exact source identities and provenance, non-overwriting XLSX/PPTX artifacts, actual local render evidence, bounded revisions, and persisted continuation checkpoints; ordinary chat does not yet automatically select or sequence this complete T1 path.
@@ -241,6 +240,7 @@ example `D:\build-target\ds-agent-v1-release`.
- [Installation](docs/INSTALLATION.md)
- [DS Agent and DeepSeek boundary](docs/AGENT_MODEL_BOUNDARY.md)
- [v1 architecture](docs/architecture/DS_AGENT_V1_ARCHITECTURE_PLAN.md)
+- [v1.6.0 release notes](docs/RELEASE_NOTES_v1.6.0.md)
- [v1.5.0 release notes](docs/RELEASE_NOTES_v1.5.0.md)
- [v1.4.0 release notes](docs/RELEASE_NOTES_v1.4.0.md)
- [v1.3.0 release notes](docs/RELEASE_NOTES_v1.3.0.md)
diff --git a/README.zh-CN.md b/README.zh-CN.md
index afdfcf7..cd4b4a4 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -11,8 +11,8 @@
- v1.5.0 正式稳定版 ·
- 下载 Windows 安装包 ·
+ v1.6.0 正式稳定版 ·
+ 下载 Windows 安装包 ·
Apache-2.0
@@ -118,15 +118,19 @@ Kernel 会持久保存任务和审核状态,只对失败步骤进行有边界
宣告完成。本地文件、浏览器操作、Office 产物和 Computer Use,只有在可观察证据满足任务
完成条件后才算真正完成。
-在 v1.5.0 中,DeepSeek 可以提出有界 `GoalEnvelope`,但只有本地 Kernel 能校验并冻结它。
+在 v1.6.0 中,DeepSeek 可以提出有界 `GoalEnvelope`,但只有本地 Kernel 能校验并冻结它。
对于同一个排队任务,也只有 Kernel 能派生 capability manifest、风险和 preview,并显示为
一张 exact-task 授权卡。批准只产生精确权限,不会执行 Tool、恢复任务或把 Goal 标记为完成;
只有本地权威 verifier 证据覆盖冻结目标的全部 `done_when` 和必需产物身份后,任务才可能
进入完成状态。
-v1.5.0 安装包保留 T1 Excel/PowerPoint 验证引擎,并新增生产可达的持久 Computer Use
-会话/步骤 UI 与 Kernel 闭环。准确安装态证据只覆盖生成的 File Explorer、Excel 目标,以及使用全新 profile 和临时回环测试站点的已安装 Edge;本版本不宣称通用浏览器自动化、复用
-已登录状态或生产网站完成能力。普通聊天目前还不会自动选择和串联这两个 T1 工具,也不会自动完成整条 T1 Office 工作流。
+v1.6.0 安装包保留 T1 Excel/PowerPoint 验证引擎和生产可达的持久 Computer Use 闭环,
+并新增生产可达的持久自动化执行:不可变 definition revision、计划/手动运行、带 checkpoint
+的 lease、重启后安全的 effect 前恢复、幂等/effect 对账、dead-letter 状态和 TaskCheckpoint
+视图。effect 尚未开始的安全工作可以自动恢复;effect 已开始或结果未知时绝不会盲目重放,
+必须明确恢复,已确认的外部效果也不会被描述为可以本地撤销。准确安装态证据只覆盖生成的
+File Explorer、Excel 目标,以及使用全新 profile 和临时回环测试站点的已安装 Edge。
+普通聊天目前还不会自动选择和串联这两个 T1 工具,也不会自动串联整条 T1 Office 工作流。
## DeepSeek 与 DS Agent 的工作边界
@@ -148,7 +152,7 @@ DeepSeek 可以提出动作,DS Agent 决定动作是否安全、是否允许
- 已通过离线对抗性 fake provider 验证的 Microsoft/Google 形态邮件、日历、同步、草稿、
外部变更和对账契约。
-v1.5.0 仍未开放生产 Microsoft/Google 账号注册和真实外部写入权限。当前正式版不会登录
+v1.6.0 仍未开放生产 Microsoft/Google 账号注册和真实外部写入权限。当前正式版不会登录
真实账号、发送真实邮件,也不会创建、修改或取消真实日历事件。
## 为什么使用 Rust
@@ -159,7 +163,7 @@ command 与 React UI 保持薄层,业务状态由 Kernel 和持久投影统一
## 快速开始
-1. 下载 [Windows x64 安装包](https://github.com/Lee-take/dsagent/releases/download/v1.5.0/DS.Agent_1.5.0_x64-setup.exe)。
+1. 下载 [Windows x64 安装包](https://github.com/Lee-take/dsagent/releases/download/v1.6.0/DS.Agent_1.6.0_x64-setup.exe)。
2. 在首次设置中输入你自己的有效 DeepSeek API Key,并显式验证余额和模型;Key 使用
Windows DPAPI 保存在本机。
3. 选择一个本地工作目录,由 readiness doctor 检查受管目录和可写状态。
@@ -169,16 +173,16 @@ command 与 React UI 保持薄层,业务状态由 Kernel 和持久投影统一
用户自行提供有效的 DeepSeek API Key 是必备前提。DS Agent 不内置共享 Key,也不会
绕过 DeepSeek 的访问条件;实际使用仍须遵守 DeepSeek 的服务条款和账号规则。
-v1.5.0 应用程序和安装包的 Authenticode 状态均为 `NotSigned`。Windows 可能显示
+v1.6.0 应用程序和安装包的 Authenticode 状态均为 `NotSigned`。Windows 可能显示
`Unknown publisher`(未知发布者)或 Microsoft Defender SmartScreen 警告。请只通过
本仓库的 HTTPS 链接下载,运行前核对 GitHub Release 中的 SHA-256,并阅读
[安装指南](docs/INSTALLATION.md)。
## Code signing policy(代码签名政策)
-DS Agent `v1.5.0` 是如实披露的未签名版本。SignPath Foundation 申请已经提交、仍在等待
+DS Agent `v1.6.0` 是如实披露的未签名版本。SignPath Foundation 申请已经提交、仍在等待
审批,本版本不会被描述为已签名。若以后获批,只从后续新版本开始签名,不替换本版本不可
-移动的 tag 或资产,也不改写 v1.1.0、v1.2.0、v1.3.0 或 v1.4.0。获准加入该计划的 Release 将遵循:**Free code signing provided by
+移动的 tag 或资产,也不改写 v1.1.0、v1.2.0、v1.3.0、v1.4.0 或 v1.5.0。获准加入该计划的 Release 将遵循:**Free code signing provided by
SignPath.io, certificate by SignPath Foundation.** 完整说明见
[代码签名政策](CODE_SIGNING_POLICY.md)和[隐私政策](PRIVACY.md)。
@@ -195,8 +199,8 @@ npx pnpm@9.15.9 --filter @deepseek-agent-os/desktop tauri:dev
## 正式稳定版
-- Release:[DS Agent v1.5.0](https://github.com/Lee-take/dsagent/releases/tag/v1.5.0)
-- 安装包:`DS.Agent_1.5.0_x64-setup.exe`
+- Release:[DS Agent v1.6.0](https://github.com/Lee-take/dsagent/releases/tag/v1.6.0)
+- 安装包:`DS.Agent_1.6.0_x64-setup.exe`
- 完整性:运行安装包前,核对 GitHub Release 中发布的最终字节数和 SHA-256。
- 首次设置:单一用户 Key、Windows DPAPI 本机存储、显式 DeepSeek 余额/V4 模型验证、
无密 readiness 和 workspace doctor。
@@ -206,6 +210,9 @@ npx pnpm@9.15.9 --filter @deepseek-agent-os/desktop tauri:dev
必需产物身份的 fail-closed 证据门。
- 任务授权:一张由 Kernel 派生并绑定 manifest/risk/preview 的 exact-task 卡片、一次用户
决策、逐能力审计和精确撤销;批准不会执行或恢复任务。
+- 持久自动化:不可变 definition revision、持久 trigger window、带 checkpoint 的 lease、
+ 重启后安全的 effect 前恢复、幂等/effect 对账、有界 backoff/dead-letter、准确本地 undo,
+ 以及对已确认外部效果如实显示不可本地撤销。
- Computer Use:生产可达的持久生命周期与 fail-closed 执行;准确安装态证明仅覆盖生成的
Explorer/Excel 目标和隔离的 Edge/回环测试站点,不代表通用浏览能力。
- T1 验证引擎:准确来源身份和 provenance、禁止覆盖的 XLSX/PPTX、真实本地渲染证据、
@@ -216,6 +223,7 @@ npx pnpm@9.15.9 --filter @deepseek-agent-os/desktop tauri:dev
- [安装指南](docs/INSTALLATION.md)
- [DS Agent 与 DeepSeek 的工作边界](docs/AGENT_MODEL_BOUNDARY.md)
- [v1 架构计划](docs/architecture/DS_AGENT_V1_ARCHITECTURE_PLAN.md)
+- [v1.6.0 发布说明](docs/RELEASE_NOTES_v1.6.0.md)
- [v1.5.0 发布说明](docs/RELEASE_NOTES_v1.5.0.md)
- [v1.4.0 发布说明](docs/RELEASE_NOTES_v1.4.0.md)
- [v1.3.0 发布说明](docs/RELEASE_NOTES_v1.3.0.md)
diff --git a/SECURITY.md b/SECURITY.md
index 127a28b..9e92619 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -1,6 +1,6 @@
# Security Policy
-DS Agent is a local-first Windows desktop agent. DS Agent v1.5.0 is the current
+DS Agent is a local-first Windows desktop agent. DS Agent v1.6.0 is the current
published stable release and is not an official DeepSeek product. Security
reports are welcome, especially around local credentials, permission gates,
audit records, Computer Use boundaries, update integrity, code signing, and
@@ -10,6 +10,7 @@ package import/export behavior.
| Version | Supported |
| --- | --- |
+| 1.6.0 | Supported |
| 1.5.0 | Supported |
| 1.4.0 | Supported |
| 1.3.0 | Supported |
@@ -34,7 +35,7 @@ Include:
## Security Boundaries
-- Current stable v1.5.0 stores one user-supplied DeepSeek API key in a dedicated
+- Current stable v1.6.0 stores one user-supplied DeepSeek API key in a dedicated
Windows DPAPI vault. A process-environment key is an explicit compatibility
fallback and is not copied into the vault. Presence alone is never treated as
verified readiness; balance and required V4 model checks produce only a
@@ -52,18 +53,24 @@ Include:
group, projection, manifest, preview, revision, fingerprint, hash, scopes,
targets, and expiry. Approval grants only exact authority; it does not execute
a Tool, resume a task, or mark a Goal complete.
-- The v1.5.0 package preserves the T1 reconciliation and PowerPoint executors,
+- The v1.6.0 package preserves the T1 reconciliation and PowerPoint executors,
which require exact workspace paths, non-overwriting output, grouped authorization, persisted artifact
identity, verifier evidence, and actual local render evidence. DeepSeek cannot
approve those actions or mint their evidence or completion receipts. The
ordinary chat UI does not yet automatically select or sequence these T1 tools.
-- The v1.5.0 durable Computer Use lifecycle binds the application, process,
+- The v1.6.0 package preserves the durable Computer Use lifecycle, which binds the application, process,
window, frame, target, action, approval, screenshots, and semantic receipt.
`ActionStarted` is persisted once before the effect; ambiguous outcomes fail
closed and are not replayed. Installed reliability evidence covers only
generated File Explorer and Excel targets plus installed Edge with a fresh
profile and an ephemeral loopback test portal. It is not evidence of general
browser control, stored-login reuse, or production-site completion.
+- The v1.6.0 durable automation runtime binds immutable definition revisions,
+ trigger windows, run checkpoints, leases, idempotency identities, effect
+ state, reconciliation attempts, dead-letter state, and exact local undo
+ receipts. Safe pre-effect work may recover after restart. Effect-started or
+ unknown outcomes require explicit recovery and are never replayed blindly;
+ confirmed external effects are not represented as locally undoable.
- `pnpm test:secrets` scans tracked and unignored repository files for live
`sk-` style keys and non-empty `DEEPSEEK_API_KEY` assignments without printing
candidate values.
@@ -78,7 +85,7 @@ Include:
memory.
- Release identity must follow the [code signing policy](CODE_SIGNING_POLICY.md).
An unsigned or invalidly signed artifact must not be represented as a signed
- release. DS Agent v1.5.0 is explicitly disclosed as Authenticode `NotSigned`;
+ release. DS Agent v1.6.0 is explicitly disclosed as Authenticode `NotSigned`;
Windows may show `Unknown publisher` or a Microsoft Defender SmartScreen
warning. See also the [privacy policy](PRIVACY.md).
diff --git a/apps/desktop/package.json b/apps/desktop/package.json
index 317cab8..6fec75f 100644
--- a/apps/desktop/package.json
+++ b/apps/desktop/package.json
@@ -1,7 +1,7 @@
{
"name": "@deepseek-agent-os/desktop",
"private": true,
- "version": "1.5.0",
+ "version": "1.6.0",
"author": "Lee take",
"license": "Apache-2.0",
"type": "module",
diff --git a/apps/desktop/src-tauri/Cargo.lock b/apps/desktop/src-tauri/Cargo.lock
index a237687..9ce0edd 100644
--- a/apps/desktop/src-tauri/Cargo.lock
+++ b/apps/desktop/src-tauri/Cargo.lock
@@ -794,7 +794,7 @@ dependencies = [
[[package]]
name = "deepseek-agent-os-desktop"
-version = "1.5.0"
+version = "1.6.0"
dependencies = [
"base64 0.22.1",
"chrono",
diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml
index e228557..55a087f 100644
--- a/apps/desktop/src-tauri/Cargo.toml
+++ b/apps/desktop/src-tauri/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "deepseek-agent-os-desktop"
-version = "1.5.0"
+version = "1.6.0"
description = "Local-first DeepSeek AI work platform"
edition = "2021"
license = "Apache-2.0"
diff --git a/apps/desktop/src-tauri/src/automation_commands.rs b/apps/desktop/src-tauri/src/automation_commands.rs
index 932d20c..6a73961 100644
--- a/apps/desktop/src-tauri/src/automation_commands.rs
+++ b/apps/desktop/src-tauri/src/automation_commands.rs
@@ -1,3 +1,6 @@
+use std::thread;
+use std::time::Duration;
+
use chrono::{DateTime, Utc};
use tauri::State;
use uuid::Uuid;
@@ -7,11 +10,55 @@ use crate::kernel::automation::{
next_scheduled_at, AutomationDefinition, AutomationDefinitionStatus, AutomationRun,
AutomationSchedule, ReviewQueueItemView,
};
+use crate::kernel::event_store::EventStore;
+
+const AUTOMATION_SCHEDULER_INTERVAL: Duration = Duration::from_secs(30);
fn store_error(error: impl std::fmt::Display) -> String {
error.to_string()
}
+fn run_automation_scheduler_once(store: &EventStore, now: DateTime) -> Result {
+ store
+ .recover_expired_automation_runs(now)
+ .map_err(store_error)?;
+ store
+ .reconcile_due_automation_effects(now, 64)
+ .map_err(store_error)?;
+ store
+ .reconcile_automation_agent_runs(now)
+ .map_err(store_error)?;
+ let mut queued = 0;
+ for definition in store.list_automation_definitions().map_err(store_error)? {
+ let conversation_id = format!("automation:{}", definition.id);
+ if store
+ .enqueue_due_automation_agent_run(
+ definition.id,
+ now,
+ "desktop-automation-scheduler".to_string(),
+ conversation_id,
+ )
+ .map_err(store_error)?
+ .is_some()
+ {
+ queued += 1;
+ }
+ }
+ Ok(queued)
+}
+
+pub fn spawn_automation_scheduler(state: AppState) {
+ let _ = thread::Builder::new()
+ .name("ds-agent-automation-scheduler".to_string())
+ .spawn(move || loop {
+ let event_store = state.event_store();
+ if let Ok(store) = event_store.lock() {
+ let _ = run_automation_scheduler_once(&store, Utc::now());
+ }
+ thread::sleep(AUTOMATION_SCHEDULER_INTERVAL);
+ });
+}
+
#[tauri::command]
pub fn list_automation_definitions(
state: State<'_, AppState>,
@@ -268,22 +315,42 @@ pub fn run_due_automation_sweep(state: State<'_, AppState>) -> Result Result<(), String> {
append_agent_tool_invocation_audit(store, invocation)?;
+ store
+ .finish_automation_effect_for_tool_invocation(invocation.id, Utc::now())
+ .map_err(event_store_error)?;
store
.record_goal_completion_for_tool_invocation(invocation)
.map_err(event_store_error)?;
diff --git a/apps/desktop/src-tauri/src/kernel/app_update.rs b/apps/desktop/src-tauri/src/kernel/app_update.rs
index 7dd3f54..1e71eb5 100644
--- a/apps/desktop/src-tauri/src/kernel/app_update.rs
+++ b/apps/desktop/src-tauri/src/kernel/app_update.rs
@@ -17,8 +17,8 @@ pub(crate) const APP_UPDATE_RELEASES_API_URL: &str =
const APP_UPDATE_RELEASE_DOWNLOAD_PATH_PREFIX: &str = "/Lee-take/dsagent/releases/download/";
const APP_UPDATE_LEGACY_RELEASE_DOWNLOAD_PATH_PREFIX: &str =
"/Lee-take/deepseek-agent-os/releases/download/";
-const APP_UPDATE_USER_AGENT: &str = "DS-Agent-Updater/1.5.0";
-const APP_UPDATE_CURRENT_RELEASE_TAG: &str = "v1.5.0";
+const APP_UPDATE_USER_AGENT: &str = "DS-Agent-Updater/1.6.0";
+const APP_UPDATE_CURRENT_RELEASE_TAG: &str = "v1.6.0";
#[cfg(windows)]
const WINDOWS_CREATE_NO_WINDOW: u32 = 0x08000000;
@@ -653,7 +653,7 @@ mod tests {
let status = update_status_from_releases(releases, app_update_current_version());
assert!(!status.update_available);
- assert_eq!(status.current_version, "v1.5.0");
+ assert_eq!(status.current_version, "v1.6.0");
assert_eq!(status.latest_version.as_deref(), Some("0.3.0"));
assert!(status.asset_name.is_none());
}
diff --git a/apps/desktop/src-tauri/src/kernel/automation.rs b/apps/desktop/src-tauri/src/kernel/automation.rs
index 3dc17e2..1846d2e 100644
--- a/apps/desktop/src-tauri/src/kernel/automation.rs
+++ b/apps/desktop/src-tauri/src/kernel/automation.rs
@@ -4,6 +4,11 @@ use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use uuid::Uuid;
+pub const AUTOMATION_CHECKPOINT_CONTRACT_VERSION: &str = "ds-agent.automation-checkpoint/v2";
+pub const AUTOMATION_EFFECT_LEDGER_VERSION: &str = "ds-agent.automation-effect-ledger/v1";
+pub const AUTOMATION_EFFECT_MAX_RECORDS: usize = 64;
+pub const AUTOMATION_EFFECT_MAX_RECONCILIATION_ATTEMPTS: u32 = 7;
+
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum AutomationDefinitionStatus {
@@ -132,6 +137,297 @@ pub struct AutomationCheckpoint {
pub recorded_at: DateTime,
}
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub(crate) struct AutomationCheckpointRecord {
+ pub contract_version: String,
+ pub revision: u64,
+ pub definition_revision: u64,
+ pub agent_run_id: Uuid,
+ pub lease_generation: u64,
+ pub checkpoint: AutomationCheckpoint,
+}
+
+#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
+#[serde(rename_all = "snake_case")]
+pub enum AutomationEffectState {
+ Prepared,
+ EffectStarted,
+ EffectUnknown,
+ KnownApplied,
+ KnownNotApplied,
+ ReconciliationBackoff,
+ DeadLettered,
+}
+
+impl AutomationEffectState {
+ pub fn terminal(self) -> bool {
+ matches!(
+ self,
+ Self::KnownApplied | Self::KnownNotApplied | Self::DeadLettered
+ )
+ }
+
+ pub fn uncertain(self) -> bool {
+ matches!(
+ self,
+ Self::EffectStarted | Self::EffectUnknown | Self::ReconciliationBackoff
+ )
+ }
+
+ pub fn permits_new_effect(self) -> bool {
+ self.terminal() && self != Self::DeadLettered
+ }
+}
+
+#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
+#[serde(rename_all = "snake_case")]
+pub enum AutomationEffectUndoCapability {
+ None,
+ ExactLocal,
+ UnsupportedExternal,
+}
+
+pub(crate) struct AutomationEffectBinding {
+ pub automation_run_id: Uuid,
+ pub definition_revision: u64,
+ pub agent_run_id: Uuid,
+ pub tool_invocation_id: Uuid,
+ pub request_fingerprint: String,
+ pub prepared_checkpoint_revision: u64,
+ pub fence_token: Uuid,
+ pub lease_generation: u64,
+}
+
+#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
+pub struct AutomationEffectRecord {
+ pub automation_run_id: Uuid,
+ pub definition_revision: u64,
+ pub agent_run_id: Uuid,
+ pub tool_invocation_id: Uuid,
+ pub request_fingerprint: String,
+ pub prepared_checkpoint_revision: u64,
+ pub origin_fence_token: Uuid,
+ pub last_fence_token: Uuid,
+ pub origin_lease_generation: u64,
+ pub last_lease_generation: u64,
+ pub idempotency_key: String,
+ pub state: AutomationEffectState,
+ pub reconciliation_attempt_count: u32,
+ pub next_reconciliation_at: Option>,
+ pub evidence_ref: Option,
+ pub safe_reason_code: Option,
+ pub undo_capability: AutomationEffectUndoCapability,
+ pub workspace_checkpoint_id: Option,
+ pub workspace_action_revision: Option,
+ pub created_at: DateTime,
+ pub updated_at: DateTime,
+}
+
+impl AutomationEffectRecord {
+ pub(crate) fn prepared(
+ binding: AutomationEffectBinding,
+ now: DateTime,
+ ) -> Result {
+ let request_fingerprint = required_text(
+ binding.request_fingerprint.clone(),
+ "tool request fingerprint",
+ )?;
+ let idempotency_key = automation_effect_idempotency_key(&binding, &request_fingerprint);
+ let record = Self {
+ automation_run_id: binding.automation_run_id,
+ definition_revision: binding.definition_revision,
+ agent_run_id: binding.agent_run_id,
+ tool_invocation_id: binding.tool_invocation_id,
+ request_fingerprint,
+ prepared_checkpoint_revision: binding.prepared_checkpoint_revision,
+ origin_fence_token: binding.fence_token,
+ last_fence_token: binding.fence_token,
+ origin_lease_generation: binding.lease_generation,
+ last_lease_generation: binding.lease_generation,
+ idempotency_key,
+ state: AutomationEffectState::Prepared,
+ reconciliation_attempt_count: 0,
+ next_reconciliation_at: None,
+ evidence_ref: None,
+ safe_reason_code: None,
+ undo_capability: AutomationEffectUndoCapability::None,
+ workspace_checkpoint_id: None,
+ workspace_action_revision: None,
+ created_at: now,
+ updated_at: now,
+ };
+ record.validate()?;
+ Ok(record)
+ }
+
+ pub fn validate(&self) -> Result<(), String> {
+ if self.automation_run_id.is_nil()
+ || self.agent_run_id.is_nil()
+ || self.tool_invocation_id.is_nil()
+ || self.origin_fence_token.is_nil()
+ || self.last_fence_token.is_nil()
+ || self.request_fingerprint.trim().is_empty()
+ || self.last_lease_generation < self.origin_lease_generation
+ || !valid_automation_effect_idempotency_key(&self.idempotency_key)
+ || self.updated_at < self.created_at
+ {
+ return Err("automation effect binding is invalid".to_string());
+ }
+ if self
+ .safe_reason_code
+ .as_deref()
+ .is_some_and(|value| !valid_safe_effect_text(value))
+ || self
+ .evidence_ref
+ .as_deref()
+ .is_some_and(|value| !valid_safe_effect_text(value))
+ {
+ return Err("automation effect evidence is invalid".to_string());
+ }
+ match self.state {
+ AutomationEffectState::ReconciliationBackoff
+ if self.next_reconciliation_at.is_none()
+ || self.reconciliation_attempt_count == 0 =>
+ {
+ return Err("automation effect backoff is incomplete".to_string());
+ }
+ AutomationEffectState::ReconciliationBackoff => {}
+ AutomationEffectState::DeadLettered
+ if self.next_reconciliation_at.is_some() || self.safe_reason_code.is_none() =>
+ {
+ return Err("automation effect dead letter is incomplete".to_string());
+ }
+ AutomationEffectState::DeadLettered => {}
+ _ if self.next_reconciliation_at.is_some() => {
+ return Err("automation effect has an unexpected retry time".to_string());
+ }
+ _ => {}
+ }
+ match self.undo_capability {
+ AutomationEffectUndoCapability::ExactLocal => {
+ if self.state != AutomationEffectState::KnownApplied
+ || self.workspace_checkpoint_id.is_none()
+ || self
+ .workspace_action_revision
+ .as_deref()
+ .is_none_or(|value| !value.starts_with("undo1:"))
+ {
+ return Err("exact local undo binding is incomplete".to_string());
+ }
+ }
+ AutomationEffectUndoCapability::None
+ | AutomationEffectUndoCapability::UnsupportedExternal => {
+ if self.workspace_checkpoint_id.is_some()
+ || self.workspace_action_revision.is_some()
+ {
+ return Err("unsupported automation undo carries local authority".to_string());
+ }
+ }
+ }
+ Ok(())
+ }
+}
+
+#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
+pub struct AutomationEffectLedger {
+ pub version: String,
+ pub effects: Vec,
+}
+
+impl Default for AutomationEffectLedger {
+ fn default() -> Self {
+ Self {
+ version: AUTOMATION_EFFECT_LEDGER_VERSION.to_string(),
+ effects: Vec::new(),
+ }
+ }
+}
+
+impl AutomationEffectLedger {
+ pub fn validate(&self, automation_run_id: Uuid) -> Result<(), String> {
+ if self.version != AUTOMATION_EFFECT_LEDGER_VERSION
+ || self.effects.len() > AUTOMATION_EFFECT_MAX_RECORDS
+ {
+ return Err("automation effect ledger contract is invalid".to_string());
+ }
+ let mut idempotency_keys = std::collections::BTreeSet::new();
+ let mut tool_invocations = std::collections::BTreeSet::new();
+ for effect in &self.effects {
+ effect.validate()?;
+ if effect.automation_run_id != automation_run_id
+ || !idempotency_keys.insert(effect.idempotency_key.as_str())
+ || !tool_invocations.insert(effect.tool_invocation_id)
+ {
+ return Err("automation effect ledger binding is invalid".to_string());
+ }
+ }
+ Ok(())
+ }
+
+ pub fn current(&self) -> Option<&AutomationEffectRecord> {
+ self.effects.last()
+ }
+
+ pub(crate) fn current_mut(&mut self) -> Option<&mut AutomationEffectRecord> {
+ self.effects.last_mut()
+ }
+
+ pub(crate) fn push(
+ &mut self,
+ automation_run_id: Uuid,
+ effect: AutomationEffectRecord,
+ ) -> Result<(), String> {
+ if self
+ .current()
+ .is_some_and(|current| !current.state.permits_new_effect())
+ {
+ return Err("the prior automation effect requires explicit recovery".to_string());
+ }
+ if self.effects.len() == AUTOMATION_EFFECT_MAX_RECORDS {
+ return Err("automation effect ledger is full".to_string());
+ }
+ self.effects.push(effect);
+ self.validate(automation_run_id)
+ }
+}
+
+pub(crate) fn automation_effect_idempotency_key(
+ binding: &AutomationEffectBinding,
+ request_fingerprint: &str,
+) -> String {
+ let canonical = serde_json::to_vec(&(
+ AUTOMATION_EFFECT_LEDGER_VERSION,
+ binding.automation_run_id,
+ binding.definition_revision,
+ binding.agent_run_id,
+ binding.tool_invocation_id,
+ binding.prepared_checkpoint_revision,
+ binding.fence_token,
+ binding.lease_generation,
+ request_fingerprint,
+ ))
+ .expect("automation effect identity is serializable");
+ format!(
+ "automation-effect1:{}",
+ hex::encode(Sha256::digest(canonical))
+ )
+}
+
+fn valid_automation_effect_idempotency_key(value: &str) -> bool {
+ value.len() == 83
+ && value.starts_with("automation-effect1:")
+ && value[19..].bytes().all(|byte| byte.is_ascii_hexdigit())
+}
+
+fn valid_safe_effect_text(value: &str) -> bool {
+ let value = value.trim();
+ !value.is_empty()
+ && value.len() <= 512
+ && !value.contains('\0')
+ && !value.to_ascii_lowercase().contains("token")
+ && !value.to_ascii_lowercase().contains("secret")
+}
+
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum ReviewQueueItemStatus {
diff --git a/apps/desktop/src-tauri/src/kernel/deepseek.rs b/apps/desktop/src-tauri/src/kernel/deepseek.rs
index cec08b0..3e88dd8 100644
--- a/apps/desktop/src-tauri/src/kernel/deepseek.rs
+++ b/apps/desktop/src-tauri/src/kernel/deepseek.rs
@@ -265,7 +265,7 @@ impl Drop for DeepSeekOperationsBriefingSynt
impl HttpDeepSeekChatCompletionTransport {
pub fn new() -> Result {
let client = reqwest::blocking::Client::builder()
- .user_agent("DS-Agent/1.5.0 deepseek-v4")
+ .user_agent("DS-Agent/1.6.0 deepseek-v4")
.timeout(std::time::Duration::from_secs(
DEEPSEEK_CHAT_HTTP_TIMEOUT_SECS,
))
@@ -982,7 +982,7 @@ mod tests {
assert_eq!(response.first_text(), Some("ok"));
assert!(recorded.raw.starts_with("POST / HTTP/1.1"));
- assert!(normalized_headers.contains("user-agent: ds-agent/1.5.0 deepseek-v4"));
+ assert!(normalized_headers.contains("user-agent: ds-agent/1.6.0 deepseek-v4"));
}
#[test]
diff --git a/apps/desktop/src-tauri/src/kernel/event_store.rs b/apps/desktop/src-tauri/src/kernel/event_store.rs
index 112dc6c..bb20210 100644
--- a/apps/desktop/src-tauri/src/kernel/event_store.rs
+++ b/apps/desktop/src-tauri/src/kernel/event_store.rs
@@ -29,8 +29,11 @@ use crate::kernel::agent_run::{
};
use crate::kernel::automation::{
automation_run_transition_allowed, next_scheduled_at, trigger_window_key, AutomationCheckpoint,
- AutomationDefinition, AutomationDefinitionStatus, AutomationRun, AutomationRunStatus,
- MissedRunPolicy, ReviewQueueItem,
+ AutomationCheckpointRecord, AutomationDefinition, AutomationDefinitionStatus,
+ AutomationEffectBinding, AutomationEffectLedger, AutomationEffectRecord, AutomationEffectState,
+ AutomationEffectUndoCapability, AutomationRun, AutomationRunStatus, MissedRunPolicy,
+ ReviewQueueItem, AUTOMATION_CHECKPOINT_CONTRACT_VERSION,
+ AUTOMATION_EFFECT_MAX_RECONCILIATION_ATTEMPTS,
};
use crate::kernel::capability::{CapabilityInvocation, CapabilityInvocationStatus};
use crate::kernel::connectors::catalog::ConnectorSyncHealthSnapshot;
@@ -120,6 +123,9 @@ use crate::kernel::work_package::{
WorkPackageWorkflowTemplateImportPreview, WorkPackageWorkflowTemplateImportSummary,
};
use crate::kernel::workflow::{OperationsBriefingRun, WorkflowTemplatePackage};
+use crate::kernel::workspace_undo::{
+ WorkspaceCheckpointEffectState, WorkspaceMutationCheckpointStatus,
+};
pub const CAPABILITY_ACCESS_REQUESTED_EVENT: &str = "capability_access.requested";
pub const CAPABILITY_INVOCATION_RECORDED_EVENT: &str = "capability_invocation.recorded";
@@ -1029,6 +1035,76 @@ pub struct EventStore {
conn: Connection,
}
+pub(crate) struct AutomationRunLease {
+ run_id: Uuid,
+ owner_id: String,
+ fence_token: Uuid,
+ generation: u64,
+ lease_expires_at: DateTime,
+}
+
+impl AutomationRunLease {
+ pub(crate) fn run_id(&self) -> Uuid {
+ self.run_id
+ }
+
+ pub(crate) fn owner_id(&self) -> &str {
+ &self.owner_id
+ }
+
+ pub(crate) fn generation(&self) -> u64 {
+ self.generation
+ }
+
+ pub(crate) fn lease_expires_at(&self) -> DateTime {
+ self.lease_expires_at
+ }
+}
+
+pub(crate) struct AutomationEffectExecution {
+ lease: AutomationRunLease,
+ checkpoint_revision: u64,
+ effect: AutomationEffectRecord,
+}
+
+enum DurableAutomationEffectOutcome {
+ KnownApplied {
+ evidence_ref: String,
+ undo_capability: AutomationEffectUndoCapability,
+ workspace_checkpoint_id: Option,
+ workspace_action_revision: Option,
+ },
+ KnownNotApplied {
+ evidence_ref: String,
+ },
+ StillUncertain {
+ reason_code: String,
+ },
+}
+
+impl AutomationEffectExecution {
+ pub(crate) fn effect(&self) -> &AutomationEffectRecord {
+ &self.effect
+ }
+}
+
+#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
+pub(crate) struct AutomationRunRecoverySweep {
+ pub requeued: usize,
+ pub effect_unknown: usize,
+}
+
+#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
+pub(crate) struct AutomationEffectReconciliationSweep {
+ pub inspected: usize,
+ pub known_applied: usize,
+ pub known_not_applied: usize,
+ pub deferred: usize,
+ pub dead_lettered: usize,
+ pub quarantined: usize,
+ pub lost_fence: usize,
+}
+
pub(crate) struct ConnectorSyncRecoveryClaim {
job_id: Uuid,
claim_id: Uuid,
@@ -1266,6 +1342,43 @@ pub(crate) struct ConnectorReconciliationClaim {
claim_expires_at: DateTime,
}
+fn automation_revision_i64(value: u64) -> EventStoreResult {
+ i64::try_from(value).map_err(|_| {
+ EventStoreError::InvalidState("automation effect revision is too large".to_string())
+ })
+}
+
+fn automation_capability_may_have_effect(capability: CapabilityKind) -> bool {
+ matches!(
+ capability,
+ CapabilityKind::FileWrite
+ | CapabilityKind::BrowserSubmit
+ | CapabilityKind::EmailDraft
+ | CapabilityKind::EmailSend
+ | CapabilityKind::ConnectorAttachmentRead
+ | CapabilityKind::ConnectorWrite
+ | CapabilityKind::DriveWrite
+ | CapabilityKind::TerminalWrite
+ | CapabilityKind::ComputerControl
+ | CapabilityKind::AppUpdateDownload
+ | CapabilityKind::AppUpdateInstall
+ )
+}
+
+fn automation_capability_is_external(capability: CapabilityKind) -> bool {
+ matches!(
+ capability,
+ CapabilityKind::BrowserSubmit
+ | CapabilityKind::EmailDraft
+ | CapabilityKind::EmailSend
+ | CapabilityKind::ConnectorWrite
+ | CapabilityKind::DriveWrite
+ | CapabilityKind::TerminalWrite
+ | CapabilityKind::ComputerControl
+ | CapabilityKind::AppUpdateInstall
+ )
+}
+
impl ConnectorReconciliationClaim {
pub(crate) fn claim_id(&self) -> Uuid {
self.claim_id
@@ -1423,7 +1536,13 @@ impl EventStore {
run_json TEXT NOT NULL,
status TEXT NOT NULL,
definition_revision INTEGER NOT NULL DEFAULT 0,
+ definition_snapshot_json TEXT,
claimed_by TEXT,
+ lease_owner_id TEXT,
+ lease_fence_token TEXT,
+ lease_generation INTEGER NOT NULL DEFAULT 0,
+ lease_expires_at TEXT,
+ lease_heartbeat_at TEXT,
updated_at TEXT NOT NULL,
UNIQUE (definition_id, trigger_window_key)
);
@@ -1434,6 +1553,19 @@ impl EventStore {
CREATE TABLE IF NOT EXISTS automation_checkpoints (
automation_run_id TEXT PRIMARY KEY NOT NULL,
checkpoint_json TEXT NOT NULL,
+ contract_version TEXT,
+ revision INTEGER NOT NULL DEFAULT 0,
+ definition_revision INTEGER,
+ agent_run_id TEXT,
+ lease_generation INTEGER,
+ effect_ledger_json TEXT,
+ effect_state TEXT,
+ effect_tool_invocation_id TEXT,
+ effect_idempotency_key TEXT,
+ next_reconciliation_at TEXT,
+ reconciliation_attempt_count INTEGER NOT NULL DEFAULT 0,
+ effect_dead_letter_code TEXT,
+ effect_quarantine_code TEXT,
updated_at TEXT NOT NULL
);
@@ -1676,6 +1808,108 @@ impl EventStore {
"definition_revision",
"ALTER TABLE automation_runs ADD COLUMN definition_revision INTEGER NOT NULL DEFAULT 0",
)?;
+ for (column, sql) in [
+ (
+ "definition_snapshot_json",
+ "ALTER TABLE automation_runs ADD COLUMN definition_snapshot_json TEXT",
+ ),
+ (
+ "lease_owner_id",
+ "ALTER TABLE automation_runs ADD COLUMN lease_owner_id TEXT",
+ ),
+ (
+ "lease_fence_token",
+ "ALTER TABLE automation_runs ADD COLUMN lease_fence_token TEXT",
+ ),
+ (
+ "lease_generation",
+ "ALTER TABLE automation_runs ADD COLUMN lease_generation INTEGER NOT NULL DEFAULT 0",
+ ),
+ (
+ "lease_expires_at",
+ "ALTER TABLE automation_runs ADD COLUMN lease_expires_at TEXT",
+ ),
+ (
+ "lease_heartbeat_at",
+ "ALTER TABLE automation_runs ADD COLUMN lease_heartbeat_at TEXT",
+ ),
+ ] {
+ ensure_sqlite_column(&self.conn, "automation_runs", column, sql)?;
+ }
+ for (column, sql) in [
+ (
+ "contract_version",
+ "ALTER TABLE automation_checkpoints ADD COLUMN contract_version TEXT",
+ ),
+ (
+ "revision",
+ "ALTER TABLE automation_checkpoints ADD COLUMN revision INTEGER NOT NULL DEFAULT 0",
+ ),
+ (
+ "definition_revision",
+ "ALTER TABLE automation_checkpoints ADD COLUMN definition_revision INTEGER",
+ ),
+ (
+ "agent_run_id",
+ "ALTER TABLE automation_checkpoints ADD COLUMN agent_run_id TEXT",
+ ),
+ (
+ "lease_generation",
+ "ALTER TABLE automation_checkpoints ADD COLUMN lease_generation INTEGER",
+ ),
+ (
+ "effect_ledger_json",
+ "ALTER TABLE automation_checkpoints ADD COLUMN effect_ledger_json TEXT",
+ ),
+ (
+ "effect_state",
+ "ALTER TABLE automation_checkpoints ADD COLUMN effect_state TEXT",
+ ),
+ (
+ "effect_tool_invocation_id",
+ "ALTER TABLE automation_checkpoints ADD COLUMN effect_tool_invocation_id TEXT",
+ ),
+ (
+ "effect_idempotency_key",
+ "ALTER TABLE automation_checkpoints ADD COLUMN effect_idempotency_key TEXT",
+ ),
+ (
+ "next_reconciliation_at",
+ "ALTER TABLE automation_checkpoints ADD COLUMN next_reconciliation_at TEXT",
+ ),
+ (
+ "reconciliation_attempt_count",
+ "ALTER TABLE automation_checkpoints ADD COLUMN reconciliation_attempt_count INTEGER NOT NULL DEFAULT 0",
+ ),
+ (
+ "effect_dead_letter_code",
+ "ALTER TABLE automation_checkpoints ADD COLUMN effect_dead_letter_code TEXT",
+ ),
+ (
+ "effect_quarantine_code",
+ "ALTER TABLE automation_checkpoints ADD COLUMN effect_quarantine_code TEXT",
+ ),
+ ] {
+ ensure_sqlite_column(&self.conn, "automation_checkpoints", column, sql)?;
+ }
+ self.conn.execute(
+ r#"CREATE UNIQUE INDEX IF NOT EXISTS idx_automation_effect_idempotency
+ ON automation_checkpoints (effect_idempotency_key)
+ WHERE effect_idempotency_key IS NOT NULL"#,
+ [],
+ )?;
+ self.conn.execute(
+ r#"CREATE INDEX IF NOT EXISTS idx_automation_effect_reconciliation
+ ON automation_checkpoints
+ (effect_state, next_reconciliation_at, reconciliation_attempt_count)
+ WHERE effect_quarantine_code IS NULL"#,
+ [],
+ )?;
+ self.conn.execute(
+ r#"CREATE INDEX IF NOT EXISTS idx_automation_runs_lease
+ ON automation_runs (lease_expires_at, status)"#,
+ [],
+ )?;
artifact::migrate(self)?;
computer_use::migrate(self)?;
connector_draft::migrate(self)?;
@@ -2401,13 +2635,14 @@ impl EventStore {
.collect()
}
+ #[cfg(test)]
pub fn claim_due_automation_run(
&self,
definition_id: Uuid,
now: DateTime,
worker_id: String,
) -> EventStoreResult> {
- let transaction = self.conn.unchecked_transaction()?;
+ let transaction = Transaction::new_unchecked(&self.conn, TransactionBehavior::Immediate)?;
let definition_json = transaction.query_row(
"SELECT definition_json FROM automation_definitions WHERE id = ?1",
params![definition_id.to_string()],
@@ -2451,8 +2686,8 @@ impl EventStore {
let inserted = transaction.execute(
r#"INSERT OR IGNORE INTO automation_runs
(id, definition_id, trigger_window_key, scheduled_for, run_json, status,
- definition_revision, claimed_by, updated_at)
- VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)"#,
+ definition_revision, definition_snapshot_json, claimed_by, updated_at)
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)"#,
params![
run.id.to_string(),
definition.id.to_string(),
@@ -2465,6 +2700,7 @@ impl EventStore {
"automation run definition revision is too large".to_string(),
)
})?,
+ definition_json,
worker_id,
timestamp,
],
@@ -2480,7 +2716,7 @@ impl EventStore {
scheduler_id: String,
conversation_id: String,
) -> EventStoreResult > {
- let transaction = self.conn.unchecked_transaction()?;
+ let transaction = Transaction::new_unchecked(&self.conn, TransactionBehavior::Immediate)?;
let definition_json = transaction.query_row(
"SELECT definition_json FROM automation_definitions WHERE id = ?1",
params![definition_id.to_string()],
@@ -2526,8 +2762,8 @@ impl EventStore {
let inserted = transaction.execute(
r#"INSERT OR IGNORE INTO automation_runs
(id, definition_id, trigger_window_key, scheduled_for, run_json, status,
- definition_revision, claimed_by, updated_at)
- VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)"#,
+ definition_revision, definition_snapshot_json, claimed_by, updated_at)
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)"#,
params![
run.id.to_string(),
definition.id.to_string(),
@@ -2540,6 +2776,7 @@ impl EventStore {
"automation run definition revision is too large".to_string(),
)
})?,
+ definition_json,
scheduler_id,
timestamp,
],
@@ -2572,7 +2809,7 @@ impl EventStore {
now: DateTime,
conversation_id: String,
) -> EventStoreResult<(AutomationRun, AgentRunStart)> {
- let transaction = self.conn.unchecked_transaction()?;
+ let transaction = Transaction::new_unchecked(&self.conn, TransactionBehavior::Immediate)?;
let definition_json = transaction.query_row(
"SELECT definition_json FROM automation_definitions WHERE id = ?1",
params![definition_id.to_string()],
@@ -2606,8 +2843,8 @@ impl EventStore {
let inserted = transaction.execute(
r#"INSERT OR IGNORE INTO automation_runs
(id, definition_id, trigger_window_key, scheduled_for, run_json, status,
- definition_revision, claimed_by, updated_at)
- VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)"#,
+ definition_revision, definition_snapshot_json, claimed_by, updated_at)
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)"#,
params![
run.id.to_string(),
definition.id.to_string(),
@@ -2620,6 +2857,7 @@ impl EventStore {
"automation run definition revision is too large".to_string(),
)
})?,
+ definition_json,
"manual",
now.to_rfc3339_opts(SecondsFormat::Nanos, true),
],
@@ -2656,953 +2894,2970 @@ impl EventStore {
.collect()
}
- pub fn reconcile_automation_agent_runs(
+ pub(crate) fn claim_automation_run(
&self,
- changed_at: DateTime,
- ) -> EventStoreResult {
- let agent_runs = self.list_agent_run_records()?;
- let agent_runs = agent_runs
- .into_iter()
- .map(|run| (run.id, run))
- .collect::>();
- let mut reconciled = 0;
- for run in self.list_automation_runs()? {
- let Some(agent_run_id) = run.agent_run_id else {
- continue;
- };
- let Some(agent_run) = agent_runs.get(&agent_run_id) else {
- continue;
- };
- let target = match agent_run.status {
- AgentRunStatus::Queued => AutomationRunStatus::Queued,
- AgentRunStatus::Running => AutomationRunStatus::Running,
- AgentRunStatus::WaitingForPrerequisite => AutomationRunStatus::WaitingReview,
- AgentRunStatus::WaitingForConfirmation => AutomationRunStatus::WaitingApproval,
- AgentRunStatus::Completed => AutomationRunStatus::Completed,
- AgentRunStatus::Failed | AgentRunStatus::Blocked => AutomationRunStatus::Failed,
- AgentRunStatus::Cancelled | AgentRunStatus::CancelRequested => {
- AutomationRunStatus::Cancelled
- }
- };
- if run.status == target {
- continue;
- }
- self.transition_automation_run(
- run.id,
- target,
- None,
- agent_run.finish_error.clone(),
- changed_at,
- )?;
- reconciled += 1;
+ run_id: Uuid,
+ owner_id: String,
+ lease_seconds: i64,
+ now: DateTime,
+ ) -> EventStoreResult {
+ let owner_id = owner_id.trim().to_string();
+ if owner_id.is_empty() {
+ return Err(EventStoreError::InvalidState(
+ "automation run lease owner is required".to_string(),
+ ));
+ }
+ let lease_seconds = lease_seconds.max(1);
+ if self.automation_run_lease_is_expired(run_id, now)? {
+ self.recover_expired_automation_run(run_id, now)?;
+ }
+ if let Err(error) = self.provably_pre_effect_automation_run(run_id) {
+ let reason = format!("effect_unknown: {error}");
+ self.mark_unclaimed_automation_run_effect_unknown(run_id, &reason, now)?;
+ return Err(error);
}
- Ok(reconciled)
- }
- pub fn automation_run(&self, run_id: Uuid) -> EventStoreResult {
- let json = self.conn.query_row(
- "SELECT run_json FROM automation_runs WHERE id = ?1",
+ let transaction = Transaction::new_unchecked(&self.conn, TransactionBehavior::Immediate)?;
+ let (run_json, generation, existing_token) = transaction.query_row(
+ r#"SELECT run_json, lease_generation, lease_fence_token
+ FROM automation_runs WHERE id = ?1"#,
params![run_id.to_string()],
- |row| row.get::<_, String>(0),
+ |row| {
+ Ok((
+ row.get::<_, String>(0)?,
+ row.get::<_, i64>(1)?,
+ row.get::<_, Option>(2)?,
+ ))
+ },
)?;
- Ok(serde_json::from_str(&json)?)
- }
-
- pub fn transition_automation_run(
- &self,
- run_id: Uuid,
- status: AutomationRunStatus,
- agent_run_id: Option,
- last_error: Option,
- changed_at: DateTime,
- ) -> EventStoreResult {
- let mut run = self.automation_run(run_id)?;
- if !automation_run_transition_allowed(run.status, status) {
+ if existing_token.is_some() {
+ return Err(EventStoreError::InvalidState(
+ "automation run already has a durable lease".to_string(),
+ ));
+ }
+ let generation = u64::try_from(generation)
+ .map_err(|_| EventStoreError::InvalidState("automation lease is invalid".to_string()))?
+ .checked_add(1)
+ .ok_or_else(|| {
+ EventStoreError::InvalidState(
+ "automation run lease generation is exhausted".to_string(),
+ )
+ })?;
+ let fence_token = Uuid::new_v4();
+ let lease_expires_at = now + Duration::seconds(lease_seconds);
+ let mut run: AutomationRun = serde_json::from_str(&run_json)?;
+ if !matches!(
+ run.status,
+ AutomationRunStatus::Queued | AutomationRunStatus::Running
+ ) {
return Err(EventStoreError::InvalidState(format!(
- "automation run cannot transition from {:?} to {:?}",
- run.status, status
+ "automation run cannot be claimed from status {:?}",
+ run.status
)));
}
- if run.status == AutomationRunStatus::Failed && status == AutomationRunStatus::Queued {
- let definition = self.automation_definition(run.definition_id)?;
- if run.attempt >= definition.retry_limit {
- return Err(EventStoreError::InvalidState(
- "automation retry limit reached".to_string(),
- ));
- }
- run.attempt += 1;
+ run.claimed_by = Some(owner_id.clone());
+ run.claimed_at = Some(now);
+ run.updated_at = now;
+ let changed = transaction.execute(
+ r#"UPDATE automation_runs
+ SET run_json = ?2, claimed_by = ?3, lease_owner_id = ?3,
+ lease_fence_token = ?4, lease_generation = ?5,
+ lease_expires_at = ?6, lease_heartbeat_at = ?7, updated_at = ?7
+ WHERE id = ?1 AND lease_fence_token IS NULL
+ AND lease_generation = ?8"#,
+ params![
+ run_id.to_string(),
+ serde_json::to_string(&run)?,
+ &owner_id,
+ fence_token.to_string(),
+ i64::try_from(generation).map_err(|_| EventStoreError::InvalidState(
+ "automation run lease generation is too large".to_string()
+ ))?,
+ lease_expires_at.to_rfc3339_opts(SecondsFormat::Nanos, true),
+ now.to_rfc3339_opts(SecondsFormat::Nanos, true),
+ i64::try_from(generation.saturating_sub(1)).map_err(|_| {
+ EventStoreError::InvalidState(
+ "automation run lease generation is too large".to_string(),
+ )
+ })?,
+ ],
+ )?;
+ if changed != 1 {
+ return Err(EventStoreError::InvalidState(
+ "automation run lease raced with another owner".to_string(),
+ ));
}
- if let Some(agent_run_id) = agent_run_id {
- run.agent_run_id = Some(agent_run_id);
+ transaction.commit()?;
+ Ok(AutomationRunLease {
+ run_id,
+ owner_id,
+ fence_token,
+ generation,
+ lease_expires_at,
+ })
+ }
+
+ pub(crate) fn heartbeat_automation_run_lease(
+ &self,
+ lease: &AutomationRunLease,
+ lease_seconds: i64,
+ now: DateTime,
+ ) -> EventStoreResult {
+ let lease_expires_at = now + Duration::seconds(lease_seconds.max(1));
+ let transaction = Transaction::new_unchecked(&self.conn, TransactionBehavior::Immediate)?;
+ let run_json = transaction
+ .query_row(
+ r#"SELECT run_json FROM automation_runs
+ WHERE id = ?1 AND lease_owner_id = ?2 AND lease_fence_token = ?3
+ AND lease_generation = ?4 AND lease_expires_at > ?5"#,
+ params![
+ lease.run_id.to_string(),
+ &lease.owner_id,
+ lease.fence_token.to_string(),
+ i64::try_from(lease.generation).map_err(|_| {
+ EventStoreError::InvalidState(
+ "automation run lease generation is too large".to_string(),
+ )
+ })?,
+ now.to_rfc3339_opts(SecondsFormat::Nanos, true),
+ ],
+ |row| row.get::<_, String>(0),
+ )
+ .optional()?
+ .ok_or_else(|| {
+ EventStoreError::InvalidState(
+ "automation run lease is stale, expired, or owned by another worker"
+ .to_string(),
+ )
+ })?;
+ let mut run: AutomationRun = serde_json::from_str(&run_json)?;
+ run.updated_at = now;
+ let changed = transaction.execute(
+ r#"UPDATE automation_runs
+ SET run_json = ?6, lease_expires_at = ?5,
+ lease_heartbeat_at = ?7, updated_at = ?7
+ WHERE id = ?1 AND lease_owner_id = ?2 AND lease_fence_token = ?3
+ AND lease_generation = ?4 AND lease_expires_at > ?7"#,
+ params![
+ lease.run_id.to_string(),
+ &lease.owner_id,
+ lease.fence_token.to_string(),
+ i64::try_from(lease.generation).map_err(|_| {
+ EventStoreError::InvalidState(
+ "automation run lease generation is too large".to_string(),
+ )
+ })?,
+ lease_expires_at.to_rfc3339_opts(SecondsFormat::Nanos, true),
+ serde_json::to_string(&run)?,
+ now.to_rfc3339_opts(SecondsFormat::Nanos, true),
+ ],
+ )?;
+ if changed != 1 {
+ return Err(EventStoreError::InvalidState(
+ "automation run heartbeat lost its fence".to_string(),
+ ));
}
- run.status = status;
- run.last_error = last_error;
- run.updated_at = changed_at;
- self.persist_automation_run(&run)?;
- Ok(run)
+ transaction.commit()?;
+ Ok(AutomationRunLease {
+ run_id: lease.run_id,
+ owner_id: lease.owner_id.clone(),
+ fence_token: lease.fence_token,
+ generation: lease.generation,
+ lease_expires_at,
+ })
}
- pub fn link_automation_run_to_agent_run(
+ pub(crate) fn release_automation_run_lease(
&self,
- automation_run_id: Uuid,
- agent_run_id: Uuid,
- changed_at: DateTime,
- ) -> EventStoreResult {
- self.ensure_agent_run_exists(agent_run_id)?;
- let mut run = self.automation_run(automation_run_id)?;
- if let Some(existing_id) = run.agent_run_id {
- if existing_id != agent_run_id {
- return Err(EventStoreError::InvalidState(
- "automation run is already linked to another agent run".to_string(),
- ));
- }
- return Ok(run);
- }
- run.agent_run_id = Some(agent_run_id);
- run.updated_at = changed_at;
- self.persist_automation_run(&run)?;
- Ok(run)
- }
-
- pub fn upsert_automation_checkpoint(
- &self,
- checkpoint: &AutomationCheckpoint,
+ lease: &AutomationRunLease,
+ now: DateTime,
) -> EventStoreResult<()> {
- self.conn.execute(
- r#"INSERT INTO automation_checkpoints (automation_run_id, checkpoint_json, updated_at)
- VALUES (?1, ?2, ?3)
- ON CONFLICT(automation_run_id) DO UPDATE SET
- checkpoint_json = excluded.checkpoint_json,
- updated_at = excluded.updated_at"#,
+ let transaction = Transaction::new_unchecked(&self.conn, TransactionBehavior::Immediate)?;
+ let run_json = transaction
+ .query_row(
+ r#"SELECT run_json FROM automation_runs
+ WHERE id = ?1 AND lease_owner_id = ?2 AND lease_fence_token = ?3
+ AND lease_generation = ?4 AND lease_expires_at > ?5"#,
+ params![
+ lease.run_id.to_string(),
+ &lease.owner_id,
+ lease.fence_token.to_string(),
+ i64::try_from(lease.generation).map_err(|_| {
+ EventStoreError::InvalidState(
+ "automation run lease generation is too large".to_string(),
+ )
+ })?,
+ now.to_rfc3339_opts(SecondsFormat::Nanos, true),
+ ],
+ |row| row.get::<_, String>(0),
+ )
+ .optional()?
+ .ok_or_else(|| {
+ EventStoreError::InvalidState(
+ "automation run lease is stale, expired, or owned by another worker"
+ .to_string(),
+ )
+ })?;
+ let mut run: AutomationRun = serde_json::from_str(&run_json)?;
+ run.claimed_by = None;
+ run.claimed_at = None;
+ run.updated_at = now;
+ let changed = transaction.execute(
+ r#"UPDATE automation_runs
+ SET run_json = ?5, claimed_by = NULL, lease_owner_id = NULL,
+ lease_fence_token = NULL, lease_expires_at = NULL,
+ lease_heartbeat_at = NULL, updated_at = ?6
+ WHERE id = ?1 AND lease_owner_id = ?2 AND lease_fence_token = ?3
+ AND lease_generation = ?4 AND lease_expires_at > ?6"#,
params![
- checkpoint.automation_run_id.to_string(),
- serde_json::to_string(checkpoint)?,
- checkpoint
- .recorded_at
- .to_rfc3339_opts(SecondsFormat::Nanos, true),
+ lease.run_id.to_string(),
+ &lease.owner_id,
+ lease.fence_token.to_string(),
+ i64::try_from(lease.generation).map_err(|_| {
+ EventStoreError::InvalidState(
+ "automation run lease generation is too large".to_string(),
+ )
+ })?,
+ serde_json::to_string(&run)?,
+ now.to_rfc3339_opts(SecondsFormat::Nanos, true),
],
)?;
+ if changed != 1 {
+ return Err(EventStoreError::InvalidState(
+ "automation run release lost its fence".to_string(),
+ ));
+ }
+ transaction.commit()?;
Ok(())
}
- pub fn upsert_review_queue_item(&self, item: &ReviewQueueItem) -> EventStoreResult<()> {
- let existing = self
- .conn
+ pub(crate) fn transition_claimed_automation_run(
+ &self,
+ lease: &AutomationRunLease,
+ status: AutomationRunStatus,
+ last_error: Option,
+ changed_at: DateTime,
+ ) -> EventStoreResult {
+ self.exact_automation_run_binding(lease.run_id)?;
+ let transaction = Transaction::new_unchecked(&self.conn, TransactionBehavior::Immediate)?;
+ let run_json = transaction
.query_row(
- "SELECT item_json FROM review_queue_items WHERE id = ?1",
- params![item.id.to_string()],
+ r#"SELECT run_json FROM automation_runs
+ WHERE id = ?1 AND lease_owner_id = ?2 AND lease_fence_token = ?3
+ AND lease_generation = ?4 AND lease_expires_at > ?5"#,
+ params![
+ lease.run_id.to_string(),
+ &lease.owner_id,
+ lease.fence_token.to_string(),
+ i64::try_from(lease.generation).map_err(|_| {
+ EventStoreError::InvalidState(
+ "automation run lease generation is too large".to_string(),
+ )
+ })?,
+ changed_at.to_rfc3339_opts(SecondsFormat::Nanos, true),
+ ],
|row| row.get::<_, String>(0),
)
.optional()?
- .map(|json| serde_json::from_str::(&json))
- .transpose()?;
- if existing.as_ref() == Some(item) {
- return Ok(());
- }
- if existing
- .as_ref()
- .is_some_and(|current| item.revision != current.revision.saturating_add(1))
- {
- return Err(EventStoreError::InvalidState(
- "review item revision is stale".to_string(),
- ));
- }
- let transaction = self.conn.unchecked_transaction()?;
- let mut run = self.automation_run(item.automation_run_id)?;
- if let Some(existing_id) = run.review_queue_item_id {
- if existing_id != item.id {
- return Err(EventStoreError::InvalidState(
- "automation run already has a review queue item".to_string(),
- ));
- }
+ .ok_or_else(|| {
+ EventStoreError::InvalidState(
+ "automation run transition has a stale or expired fence".to_string(),
+ )
+ })?;
+ let mut run: AutomationRun = serde_json::from_str(&run_json)?;
+ if !automation_run_transition_allowed(run.status, status) {
+ return Err(EventStoreError::InvalidState(format!(
+ "automation run cannot transition from {:?} to {:?}",
+ run.status, status
+ )));
}
- run.review_queue_item_id = Some(item.id);
- run.updated_at = item.updated_at;
+ run.status = status;
+ run.last_error = last_error;
+ run.updated_at = changed_at;
let changed = transaction.execute(
- r#"INSERT INTO review_queue_items (id, automation_run_id, item_json, status, revision, updated_at)
- VALUES (?1, ?2, ?3, ?4, ?5, ?6)
- ON CONFLICT(id) DO UPDATE SET
- item_json = excluded.item_json,
- status = excluded.status,
- revision = excluded.revision,
- updated_at = excluded.updated_at
- WHERE review_queue_items.revision + 1 = excluded.revision"#,
+ r#"UPDATE automation_runs
+ SET run_json = ?5, status = ?6, updated_at = ?7
+ WHERE id = ?1 AND lease_owner_id = ?2 AND lease_fence_token = ?3
+ AND lease_generation = ?4 AND lease_expires_at > ?7"#,
params![
- item.id.to_string(),
- item.automation_run_id.to_string(),
- serde_json::to_string(item)?,
- serde_json::to_string(&item.status)?,
- item.revision,
- item.updated_at.to_rfc3339_opts(SecondsFormat::Nanos, true),
+ lease.run_id.to_string(),
+ &lease.owner_id,
+ lease.fence_token.to_string(),
+ i64::try_from(lease.generation).map_err(|_| {
+ EventStoreError::InvalidState(
+ "automation run lease generation is too large".to_string(),
+ )
+ })?,
+ serde_json::to_string(&run)?,
+ serde_json::to_string(&run.status)?,
+ changed_at.to_rfc3339_opts(SecondsFormat::Nanos, true),
],
)?;
if changed != 1 {
return Err(EventStoreError::InvalidState(
- "review item update raced with another writer".to_string(),
+ "automation run transition lost its fence".to_string(),
));
}
- transaction.execute(
- "UPDATE automation_runs SET run_json = ?2, updated_at = ?3 WHERE id = ?1",
- params![
- run.id.to_string(),
- serde_json::to_string(&run)?,
- run.updated_at.to_rfc3339_opts(SecondsFormat::Nanos, true),
- ],
- )?;
transaction.commit()?;
- Ok(())
+ Ok(run)
}
- pub fn list_review_queue_items(&self) -> EventStoreResult> {
+ pub(crate) fn recover_expired_automation_runs(
+ &self,
+ now: DateTime,
+ ) -> EventStoreResult {
let mut statement = self.conn.prepare(
- "SELECT item_json FROM review_queue_items ORDER BY updated_at ASC, rowid ASC",
+ r#"SELECT id FROM automation_runs
+ WHERE lease_fence_token IS NOT NULL AND lease_expires_at <= ?1
+ ORDER BY lease_expires_at ASC, rowid ASC LIMIT 64"#,
)?;
- let rows = statement
- .query_map([], |row| row.get::<_, String>(0))?
+ let run_ids = statement
+ .query_map(
+ params![now.to_rfc3339_opts(SecondsFormat::Nanos, true)],
+ |row| row.get::<_, String>(0),
+ )?
.collect::, _>>()?;
- rows.into_iter()
- .map(|json| serde_json::from_str(&json).map_err(Into::into))
- .collect()
- }
-
- pub fn review_queue_item(&self, id: Uuid) -> EventStoreResult {
- let json = self.conn.query_row(
- "SELECT item_json FROM review_queue_items WHERE id = ?1",
- params![id.to_string()],
- |row| row.get::<_, String>(0),
- )?;
- Ok(serde_json::from_str(&json)?)
- }
-
- pub fn edit_review_queue_item(
- &self,
- id: Uuid,
- action_revision: &str,
- title: String,
- preview_fingerprint: Option,
- changed_at: DateTime,
- ) -> EventStoreResult {
- let mut item = self.review_queue_item(id)?;
- item.validate_action_revision(action_revision)
- .map_err(EventStoreError::InvalidState)?;
- let previous_revision = item.revision;
- let resolution = self.review_tool_approval_invalidation(&item, "Review preview changed")?;
- item.edit(title, preview_fingerprint, changed_at)
- .map_err(EventStoreError::InvalidState)?;
- self.persist_review_item_transition(&item, previous_revision, resolution)?;
- Ok(item)
- }
-
- pub fn resolve_review_queue_item(
- &self,
- id: Uuid,
- action_revision: &str,
- accepted: bool,
- changed_at: DateTime,
- ) -> EventStoreResult {
- let mut item = self.review_queue_item(id)?;
- item.validate_action_revision(action_revision)
- .map_err(EventStoreError::InvalidState)?;
- let previous_revision = item.revision;
- let resolution = (!accepted)
- .then(|| self.review_tool_approval_invalidation(&item, "Review item rejected"))
- .transpose()?
- .flatten();
- item.resolve(accepted, changed_at)
- .map_err(EventStoreError::InvalidState)?;
- self.persist_review_item_transition(&item, previous_revision, resolution)?;
- Ok(item)
+ drop(statement);
+ let mut sweep = AutomationRunRecoverySweep::default();
+ for run_id in run_ids {
+ match self.recover_expired_automation_run(Uuid::parse_str(&run_id)?, now)? {
+ Some(true) => sweep.requeued += 1,
+ Some(false) => sweep.effect_unknown += 1,
+ None => {}
+ }
+ }
+ Ok(sweep)
}
- fn review_tool_approval_invalidation(
+ fn recover_expired_automation_run(
&self,
- item: &ReviewQueueItem,
- note: &str,
- ) -> EventStoreResult> {
- let Some(tool_invocation_id) = item.tool_invocation_id else {
+ run_id: Uuid,
+ now: DateTime,
+ ) -> EventStoreResult> {
+ let (run_json, generation, token, expires_at) = self.conn.query_row(
+ r#"SELECT run_json, lease_generation, lease_fence_token, lease_expires_at
+ FROM automation_runs WHERE id = ?1"#,
+ params![run_id.to_string()],
+ |row| {
+ Ok((
+ row.get::<_, String>(0)?,
+ row.get::<_, i64>(1)?,
+ row.get::<_, Option>(2)?,
+ row.get::<_, Option>(3)?,
+ ))
+ },
+ )?;
+ let Some(token) = token else {
return Ok(None);
};
- let approval_request_id = self
- .list_tool_invocations()?
- .into_iter()
- .find(|record| record.id == tool_invocation_id)
- .and_then(|record| record.approval_request_id);
- Ok(approval_request_id
- .map(|request_id| PermissionResolution::new(request_id, false, note.to_string())))
- }
-
- fn persist_review_item_transition(
- &self,
- item: &ReviewQueueItem,
- previous_revision: u32,
- resolution: Option,
- ) -> EventStoreResult<()> {
- let mut run = self.automation_run(item.automation_run_id)?;
- if run.review_queue_item_id != Some(item.id) {
- return Err(EventStoreError::InvalidState(
- "automation run is not linked to this review item".to_string(),
- ));
- }
- run.updated_at = item.updated_at;
- let resolution_event = resolution
- .as_ref()
- .map(|value| KernelEvent::new(PERMISSION_RESOLUTION_RECORDED_EVENT, value))
- .transpose()?;
- let transaction = self.conn.unchecked_transaction()?;
- if let Some(event) = resolution_event {
- Self::insert_kernel_event(&transaction, &event)?;
+ let expires_at = expires_at
+ .ok_or_else(|| {
+ EventStoreError::InvalidState("automation run lease expiry is missing".to_string())
+ })
+ .and_then(|value| {
+ DateTime::parse_from_rfc3339(&value)
+ .map(|value| value.with_timezone(&Utc))
+ .map_err(Into::into)
+ })?;
+ if expires_at > now {
+ return Ok(None);
}
+ let mut run: AutomationRun = serde_json::from_str(&run_json)?;
+ let safety = self.provably_pre_effect_automation_run(run_id);
+ let safe = safety.is_ok();
+ let reason = safety
+ .err()
+ .map(|error| format!("effect_unknown: {error}"))
+ .unwrap_or_else(|| {
+ format!(
+ "Recovered provably pre-effect work after automation lease expired at {}.",
+ expires_at.to_rfc3339()
+ )
+ });
+ run.status = if safe {
+ AutomationRunStatus::Queued
+ } else {
+ AutomationRunStatus::Failed
+ };
+ run.last_error = Some(reason.clone());
+ run.claimed_by = None;
+ run.claimed_at = None;
+ run.updated_at = now;
+
+ let blocking_transition = if safe {
+ None
+ } else {
+ self.linked_agent_run_record(&run).and_then(|record| {
+ if matches!(
+ record.status,
+ AgentRunStatus::Completed
+ | AgentRunStatus::Failed
+ | AgentRunStatus::Blocked
+ | AgentRunStatus::Cancelled
+ ) {
+ None
+ } else {
+ AgentRunTransition::new(
+ record.id,
+ AgentRunStatus::Blocked,
+ reason.clone(),
+ self.running_tool_invocation_for_agent_run(record.id)
+ .ok()
+ .flatten(),
+ )
+ .ok()
+ }
+ })
+ };
+ let effect_recovery = self
+ .conn
+ .query_row(
+ r#"SELECT revision, effect_ledger_json
+ FROM automation_checkpoints WHERE automation_run_id = ?1"#,
+ params![run_id.to_string()],
+ |row| Ok((row.get::<_, i64>(0)?, row.get::<_, Option>(1)?)),
+ )
+ .optional()?;
+ let transaction = Transaction::new_unchecked(&self.conn, TransactionBehavior::Immediate)?;
let changed = transaction.execute(
- r#"UPDATE review_queue_items
- SET item_json = ?2, status = ?3, revision = ?4, updated_at = ?5
- WHERE id = ?1 AND revision = ?6"#,
+ r#"UPDATE automation_runs
+ SET run_json = ?5, status = ?6, claimed_by = NULL,
+ lease_owner_id = NULL, lease_fence_token = NULL,
+ lease_expires_at = NULL, lease_heartbeat_at = NULL, updated_at = ?7
+ WHERE id = ?1 AND lease_fence_token = ?2
+ AND lease_generation = ?3 AND lease_expires_at <= ?4"#,
params![
- item.id.to_string(),
- serde_json::to_string(item)?,
- serde_json::to_string(&item.status)?,
- item.revision,
- item.updated_at.to_rfc3339_opts(SecondsFormat::Nanos, true),
- previous_revision,
+ run_id.to_string(),
+ token,
+ generation,
+ now.to_rfc3339_opts(SecondsFormat::Nanos, true),
+ serde_json::to_string(&run)?,
+ serde_json::to_string(&run.status)?,
+ now.to_rfc3339_opts(SecondsFormat::Nanos, true),
],
)?;
if changed != 1 {
- return Err(EventStoreError::InvalidState(
- "review item transition raced with another writer".to_string(),
- ));
+ return Ok(None);
+ }
+ if let Some(transition) = blocking_transition {
+ let event = KernelEvent::new(AGENT_RUN_TRANSITIONED_EVENT, &transition)?;
+ Self::insert_kernel_event(&transaction, &event)?;
+ }
+ if let Some((checkpoint_revision, Some(ledger_json))) = effect_recovery {
+ let parsed = serde_json::from_str::(&ledger_json)
+ .map_err(EventStoreError::from)
+ .and_then(|ledger| {
+ ledger
+ .validate(run_id)
+ .map_err(EventStoreError::InvalidState)?;
+ Ok(ledger)
+ });
+ match parsed {
+ Ok(mut ledger)
+ if ledger.current().is_some_and(|effect| {
+ effect.state == AutomationEffectState::EffectStarted
+ }) =>
+ {
+ let current = ledger.current_mut().expect("validated ledger is not empty");
+ current.state = AutomationEffectState::EffectUnknown;
+ current.evidence_ref = Some(format!(
+ "automation-effect://{}/lease-expired",
+ current.idempotency_key
+ ));
+ current.safe_reason_code =
+ Some("lease_expired_after_effect_started".to_string());
+ current.next_reconciliation_at = None;
+ current.updated_at = now;
+ current.validate().map_err(EventStoreError::InvalidState)?;
+ let next_revision = u64::try_from(checkpoint_revision)
+ .ok()
+ .and_then(|value| value.checked_add(1))
+ .ok_or_else(|| {
+ EventStoreError::InvalidState(
+ "automation effect checkpoint revision is exhausted".to_string(),
+ )
+ })?;
+ let changed = transaction.execute(
+ r#"UPDATE automation_checkpoints
+ SET revision = ?2, effect_ledger_json = ?3, effect_state = ?4,
+ next_reconciliation_at = NULL,
+ effect_dead_letter_code = NULL, effect_quarantine_code = NULL,
+ updated_at = ?5
+ WHERE automation_run_id = ?1 AND revision = ?6
+ AND effect_tool_invocation_id = ?7"#,
+ params![
+ run_id.to_string(),
+ automation_revision_i64(next_revision)?,
+ serde_json::to_string(&ledger)?,
+ serde_json::to_string(&AutomationEffectState::EffectUnknown)?,
+ now.to_rfc3339_opts(SecondsFormat::Nanos, true),
+ checkpoint_revision,
+ ledger
+ .current()
+ .expect("validated ledger is not empty")
+ .tool_invocation_id
+ .to_string(),
+ ],
+ )?;
+ if changed != 1 {
+ return Err(EventStoreError::InvalidState(
+ "expired automation effect recovery lost its revision".to_string(),
+ ));
+ }
+ }
+ Ok(_) => {}
+ Err(_) => {
+ transaction.execute(
+ r#"UPDATE automation_checkpoints
+ SET effect_quarantine_code = 'invalid_effect_ledger',
+ next_reconciliation_at = NULL
+ WHERE automation_run_id = ?1"#,
+ params![run_id.to_string()],
+ )?;
+ }
+ }
}
- transaction.execute(
- "UPDATE automation_runs SET run_json = ?2, updated_at = ?3 WHERE id = ?1",
- params![
- run.id.to_string(),
- serde_json::to_string(&run)?,
- run.updated_at.to_rfc3339_opts(SecondsFormat::Nanos, true),
- ],
- )?;
transaction.commit()?;
- Ok(())
+ Ok(Some(safe))
}
- pub fn automation_checkpoint(
+ fn mark_unclaimed_automation_run_effect_unknown(
&self,
- automation_run_id: Uuid,
- ) -> EventStoreResult {
- let json = self.conn.query_row(
- "SELECT checkpoint_json FROM automation_checkpoints WHERE automation_run_id = ?1",
- params![automation_run_id.to_string()],
- |row| row.get::<_, String>(0),
- )?;
- Ok(serde_json::from_str(&json)?)
- }
-
- fn persist_automation_run(&self, run: &AutomationRun) -> EventStoreResult<()> {
- self.conn.execute(
+ run_id: Uuid,
+ reason: &str,
+ now: DateTime,
+ ) -> EventStoreResult<()> {
+ let mut run = self.automation_run(run_id)?;
+ run.status = AutomationRunStatus::Failed;
+ run.last_error = Some(reason.to_string());
+ run.claimed_by = None;
+ run.claimed_at = None;
+ run.updated_at = now;
+ let blocking_transition = self.linked_agent_run_record(&run).and_then(|record| {
+ if matches!(
+ record.status,
+ AgentRunStatus::Completed
+ | AgentRunStatus::Failed
+ | AgentRunStatus::Blocked
+ | AgentRunStatus::Cancelled
+ ) {
+ None
+ } else {
+ AgentRunTransition::new(
+ record.id,
+ AgentRunStatus::Blocked,
+ reason.to_string(),
+ self.running_tool_invocation_for_agent_run(record.id)
+ .ok()
+ .flatten(),
+ )
+ .ok()
+ }
+ });
+ let transaction = Transaction::new_unchecked(&self.conn, TransactionBehavior::Immediate)?;
+ let changed = transaction.execute(
r#"UPDATE automation_runs
- SET run_json = ?2, status = ?3, definition_revision = ?4,
- claimed_by = ?5, updated_at = ?6
- WHERE id = ?1"#,
+ SET run_json = ?2, status = ?3, claimed_by = NULL, updated_at = ?4
+ WHERE id = ?1 AND lease_fence_token IS NULL"#,
params![
- run.id.to_string(),
- serde_json::to_string(run)?,
+ run_id.to_string(),
+ serde_json::to_string(&run)?,
serde_json::to_string(&run.status)?,
- i64::try_from(run.definition_revision).map_err(|_| {
- EventStoreError::InvalidState(
- "automation run definition revision is too large".to_string(),
- )
- })?,
- run.claimed_by,
- run.updated_at.to_rfc3339_opts(SecondsFormat::Nanos, true),
+ now.to_rfc3339_opts(SecondsFormat::Nanos, true),
],
)?;
- Ok(())
- }
-
- pub fn upsert_connector_account(&self, account: &ConnectorAccount) -> EventStoreResult<()> {
- let transaction = self.conn.unchecked_transaction()?;
- let existing = transaction
- .query_row(
- "SELECT account_json FROM connector_accounts WHERE id = ?1",
- params![account.id.to_string()],
- |row| row.get::<_, String>(0),
- )
- .optional()?
- .map(|json| serde_json::from_str::(&json))
- .transpose()?;
- if existing.as_ref().is_some_and(|stored| {
- matches!(
- stored.health,
- ConnectorHealth::DisconnectPending | ConnectorHealth::RevocationPending
- )
- }) {
+ if changed != 1 {
return Err(EventStoreError::InvalidState(
- "connector pending transition requires its dedicated recovery path".to_string(),
+ "automation run effect-unknown transition raced with a lease owner".to_string(),
));
}
- transaction.execute(
- r#"INSERT OR IGNORE INTO connector_account_generations (account_id, generation)
- VALUES (?1, 0)"#,
- params![account.id.to_string()],
- )?;
- if existing.as_ref().is_some_and(|stored| {
- stored.provider_id != account.provider_id
- || stored.tenant_ref != account.tenant_ref
- || stored.credential_handle != account.credential_handle
- || stored.granted_capabilities != account.granted_capabilities
- }) {
- transaction.execute(
- "UPDATE connector_account_generations SET generation = generation + 1 WHERE account_id = ?1",
- params![account.id.to_string()],
- )?;
- transaction.execute(
- "DELETE FROM connector_sync_projection WHERE account_id = ?1",
- params![account.id.to_string()],
- )?;
- transaction.execute(
- "DELETE FROM connector_sync_streams WHERE account_id = ?1",
- params![account.id.to_string()],
- )?;
+ if let Some(transition) = blocking_transition {
+ let event = KernelEvent::new(AGENT_RUN_TRANSITIONED_EVENT, &transition)?;
+ Self::insert_kernel_event(&transaction, &event)?;
}
- transaction.execute(
- r#"INSERT INTO connector_accounts (id, provider_id, account_json, health, updated_at)
- VALUES (?1, ?2, ?3, ?4, ?5)
- ON CONFLICT(id) DO UPDATE SET
- provider_id = excluded.provider_id,
- account_json = excluded.account_json,
- health = excluded.health,
- updated_at = excluded.updated_at"#,
- params![
- account.id.to_string(),
- account.provider_id,
- serde_json::to_string(account)?,
- serde_json::to_string(&account.health)?,
- account
- .updated_at
- .to_rfc3339_opts(SecondsFormat::Nanos, true),
- ],
- )?;
transaction.commit()?;
Ok(())
}
- pub fn connector_account_sync_generation(
+ fn automation_run_lease_is_expired(
&self,
- account: &ConnectorAccount,
- ) -> EventStoreResult {
- let (account_json, generation) = self.conn.query_row(
- r#"SELECT account.account_json, generation.generation
- FROM connector_accounts AS account
- JOIN connector_account_generations AS generation
- ON generation.account_id = account.id
- WHERE account.id = ?1 AND account.health = ?2"#,
- params![
- account.id.to_string(),
- serde_json::to_string(&ConnectorHealth::Connected)?,
- ],
- |row| Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?)),
+ run_id: Uuid,
+ now: DateTime,
+ ) -> EventStoreResult {
+ let expires_at = self
+ .conn
+ .query_row(
+ "SELECT lease_expires_at FROM automation_runs WHERE id = ?1",
+ params![run_id.to_string()],
+ |row| row.get::<_, Option>(0),
+ )?
+ .map(|value| DateTime::parse_from_rfc3339(&value))
+ .transpose()?
+ .map(|value| value.with_timezone(&Utc));
+ Ok(expires_at.is_some_and(|value| value <= now))
+ }
+
+ fn exact_automation_run_binding(
+ &self,
+ run_id: Uuid,
+ ) -> EventStoreResult<(AutomationRun, AgentRunRecord)> {
+ let (run_json, stored_revision, snapshot_json) = self.conn.query_row(
+ r#"SELECT run_json, definition_revision, definition_snapshot_json
+ FROM automation_runs WHERE id = ?1"#,
+ params![run_id.to_string()],
+ |row| {
+ Ok((
+ row.get::<_, String>(0)?,
+ row.get::<_, i64>(1)?,
+ row.get::<_, Option>(2)?,
+ ))
+ },
)?;
- let stored: ConnectorAccount = serde_json::from_str(&account_json)?;
- if stored.provider_id != account.provider_id
- || stored.tenant_ref != account.tenant_ref
- || stored.credential_handle != account.credential_handle
- || stored.granted_capabilities != account.granted_capabilities
- || stored.health != ConnectorHealth::Connected
+ let run: AutomationRun = serde_json::from_str(&run_json)?;
+ let stored_revision = u64::try_from(stored_revision).map_err(|_| {
+ EventStoreError::InvalidState(
+ "automation run definition revision is invalid".to_string(),
+ )
+ })?;
+ let snapshot_json = snapshot_json.ok_or_else(|| {
+ EventStoreError::InvalidState(
+ "automation run exact definition binding is missing".to_string(),
+ )
+ })?;
+ let snapshot: AutomationDefinition = serde_json::from_str(&snapshot_json)?;
+ if run.definition_id != snapshot.id
+ || run.definition_revision != snapshot.revision
+ || run.definition_revision != stored_revision
+ || run.trigger_window_key.trim().is_empty()
{
return Err(EventStoreError::InvalidState(
- "connector account binding changed".to_string(),
+ "automation run definition revision binding changed".to_string(),
));
}
- u64::try_from(generation).map_err(|_| {
- EventStoreError::InvalidState("connector account generation is invalid".to_string())
- })
- }
-
- pub fn mark_connector_account_needs_repair(
- &self,
- account: &ConnectorAccount,
- expected_generation: u64,
- ) -> EventStoreResult<()> {
- if account.health != ConnectorHealth::NeedsRepair {
+ let agent_run = self.linked_agent_run_record(&run).ok_or_else(|| {
+ EventStoreError::InvalidState(
+ "automation run exact AgentRun binding is missing".to_string(),
+ )
+ })?;
+ if agent_run.prompt != snapshot.goal {
return Err(EventStoreError::InvalidState(
- "connector repair transition requires needs-repair state".to_string(),
+ "automation run AgentRun prompt changed from its definition revision".to_string(),
));
}
- let changed = self.conn.execute(
- r#"UPDATE connector_accounts
- SET provider_id = ?2, account_json = ?3, health = ?4, updated_at = ?5
- WHERE id = ?1 AND health = ?6 AND EXISTS (
- SELECT 1 FROM connector_account_generations
- WHERE account_id = ?1 AND generation = ?7
- )"#,
- params![
- account.id.to_string(),
- account.provider_id,
- serde_json::to_string(account)?,
- serde_json::to_string(&account.health)?,
- account
- .updated_at
- .to_rfc3339_opts(SecondsFormat::Nanos, true),
- serde_json::to_string(&ConnectorHealth::Connected)?,
- i64::try_from(expected_generation).map_err(|_| {
- EventStoreError::InvalidState(
- "connector account generation is too large".to_string(),
- )
- })?,
- ],
+ Ok((run, agent_run))
+ }
+
+ fn linked_agent_run_record(&self, run: &AutomationRun) -> Option {
+ let agent_run_id = run.agent_run_id?;
+ self.list_agent_run_records()
+ .ok()?
+ .into_iter()
+ .find(|record| record.id == agent_run_id)
+ }
+
+ fn running_tool_invocation_for_agent_run(
+ &self,
+ agent_run_id: Uuid,
+ ) -> EventStoreResult> {
+ let running_status = serde_json::to_string(&ToolExecutionStatus::Running)?;
+ let mut statement = self.conn.prepare(
+ "SELECT invocation_json FROM tool_invocation_state WHERE status = ?1 ORDER BY rowid ASC",
)?;
- if changed != 1 {
- return Err(EventStoreError::InvalidState(
- "connector account changed during repair transition".to_string(),
- ));
+ let rows = statement
+ .query_map(params![running_status], |row| row.get::<_, String>(0))?
+ .collect::, _>>()?;
+ for row in rows {
+ let invocation: ToolInvocationRecord = serde_json::from_str(&row)?;
+ if invocation.run_id == Some(agent_run_id) {
+ return Ok(Some(invocation.id));
+ }
}
- Ok(())
+ Ok(None)
}
- pub fn begin_connector_disconnect(
+ fn computer_use_action_started_for_agent_run(
&self,
- account_id: Uuid,
- now: DateTime,
- ) -> EventStoreResult {
- let transaction = self.conn.unchecked_transaction()?;
- let (json, generation) = transaction
+ agent_run_id: Uuid,
+ ) -> EventStoreResult {
+ self.conn
.query_row(
- r#"SELECT account.account_json, generation.generation
- FROM connector_accounts AS account
- JOIN connector_account_generations AS generation
- ON generation.account_id = account.id
- WHERE account.id = ?1"#,
- params![account_id.to_string()],
- |row| Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?)),
+ r#"SELECT EXISTS(
+ SELECT 1
+ FROM computer_use_sessions AS session
+ JOIN computer_use_steps AS step ON step.id = session.active_step_id
+ WHERE session.run_id = ?1
+ AND session.quarantine_code IS NULL
+ AND step.quarantine_code IS NULL
+ AND step.status IN (
+ 'action_started',
+ 'awaiting_verification',
+ 'effect_unknown',
+ 'verification_failed'
+ )
+ )"#,
+ params![agent_run_id.to_string()],
+ |row| row.get::<_, i64>(0),
)
- .optional()?
- .ok_or_else(|| EventStoreError::NotFound("connector account".to_string()))?;
- let mut account: ConnectorAccount = serde_json::from_str(&json)?;
- let generation = u64::try_from(generation).map_err(|_| {
- EventStoreError::InvalidState("connector account generation is invalid".to_string())
- })?;
- if account.health == ConnectorHealth::DisconnectPending {
- return Ok(ConnectorDisconnectTicket::new(account, generation));
- }
- if account.health == ConnectorHealth::RevocationPending {
- return Err(EventStoreError::InvalidState(
- "connector revocation must be reconciled before local disconnect".to_string(),
- ));
- }
- if account.health == ConnectorHealth::Disconnected {
- return Err(EventStoreError::InvalidState(
- "connector account is already disconnected".to_string(),
- ));
+ .map(|value| value != 0)
+ .map_err(Into::into)
+ }
+
+ fn provably_pre_effect_automation_run(&self, run_id: Uuid) -> EventStoreResult {
+ let (run, agent_run) = self.exact_automation_run_binding(run_id)?;
+ if agent_run.status != AgentRunStatus::Queued {
+ return Err(EventStoreError::InvalidState(format!(
+ "linked AgentRun is {:?}, not provably pre-effect queued work",
+ agent_run.status
+ )));
}
- let previous_health = account.health;
- let next_generation = generation.checked_add(1).ok_or_else(|| {
- EventStoreError::InvalidState("connector account generation overflowed".to_string())
- })?;
- account.health = ConnectorHealth::DisconnectPending;
- account.updated_at = now;
- let generation_changed = transaction.execute(
- r#"UPDATE connector_account_generations SET generation = ?2
- WHERE account_id = ?1 AND generation = ?3"#,
- params![
- account_id.to_string(),
- i64::try_from(next_generation).map_err(|_| {
- EventStoreError::InvalidState(
- "connector account generation is too large".to_string(),
- )
- })?,
- i64::try_from(generation).map_err(|_| {
- EventStoreError::InvalidState(
- "connector account generation is too large".to_string(),
- )
- })?,
- ],
- )?;
- if generation_changed != 1 {
+ if self
+ .running_tool_invocation_for_agent_run(agent_run.id)?
+ .is_some()
+ {
return Err(EventStoreError::InvalidState(
- "connector disconnect raced with another account transition".to_string(),
+ "a running tool invocation has an indeterminate possible effect".to_string(),
));
}
- transaction.execute(
- r#"UPDATE connector_attachment_landings
- SET status = 'cleanup_required', failure_kind = 'account_disconnected', updated_at = ?3
- WHERE account_id = ?1 AND account_generation = ?2
- AND status IN ('reserved', 'staging', 'ready')"#,
- params![
- account_id.to_string(),
- i64::try_from(generation).map_err(|_| EventStoreError::InvalidState(
- "connector account generation is too large".to_string()
- ))?,
- now.to_rfc3339_opts(SecondsFormat::Nanos, true),
- ],
- )?;
- transaction.execute(
- "DELETE FROM connector_sync_projection WHERE account_id = ?1",
- params![account_id.to_string()],
- )?;
- transaction.execute(
- "DELETE FROM connector_sync_streams WHERE account_id = ?1",
- params![account_id.to_string()],
- )?;
- let changed = transaction.execute(
- r#"UPDATE connector_accounts
- SET account_json = ?2, health = ?3, updated_at = ?4
- WHERE id = ?1 AND health = ?5"#,
- params![
- account_id.to_string(),
- serde_json::to_string(&account)?,
- serde_json::to_string(&account.health)?,
- now.to_rfc3339_opts(SecondsFormat::Nanos, true),
- serde_json::to_string(&previous_health)?,
- ],
- )?;
- if changed != 1 {
+ if self.computer_use_action_started_for_agent_run(agent_run.id)? {
return Err(EventStoreError::InvalidState(
- "connector disconnect could not be started".to_string(),
+ "ActionStarted or another ambiguous Computer Use effect is present".to_string(),
));
}
- let receipt = ConnectorDisconnectReceipt {
- account_id,
- provider_id: account.provider_id.clone(),
- generation: next_generation,
- phase: ConnectorDisconnectPhase::Started,
- source: ConnectorDisconnectSource::User,
- credential_delete_outcome: None,
- changed_at: now,
- };
- let event = KernelEvent::new("connector.disconnect.started", &receipt)?;
- transaction.execute(
- r#"INSERT INTO kernel_events (id, event_type, payload_json, created_at)
- VALUES (?1, ?2, ?3, ?4)"#,
- params![
- event.id.to_string(),
- event.event_type,
- event.payload_json,
- event.created_at.to_rfc3339_opts(SecondsFormat::Nanos, true),
- ],
- )?;
- transaction.commit()?;
- let _ = self.terminalize_connector_attachment_cleanup_tools_for_account_generation(
- account_id, generation, now,
- );
- Ok(ConnectorDisconnectTicket::new(account, next_generation))
+ Ok(run)
}
- fn terminalize_connector_attachment_cleanup_tools_for_account_generation(
+ fn automation_run_has_durable_lease(&self, run_id: Uuid) -> EventStoreResult {
+ self.conn
+ .query_row(
+ "SELECT lease_fence_token IS NOT NULL FROM automation_runs WHERE id = ?1",
+ params![run_id.to_string()],
+ |row| row.get::<_, i64>(0),
+ )
+ .map(|value| value != 0)
+ .map_err(Into::into)
+ }
+
+ pub fn reconcile_automation_agent_runs(
&self,
- account_id: Uuid,
- generation: u64,
changed_at: DateTime,
- ) -> EventStoreResult<()> {
- let mut statement = self.conn.prepare(
- r#"SELECT id FROM connector_attachment_landings
- WHERE account_id = ?1 AND account_generation = ?2
- AND status = 'cleanup_required'"#,
- )?;
- let landing_ids = statement
- .query_map(
- params![
- account_id.to_string(),
- i64::try_from(generation).map_err(|_| EventStoreError::InvalidState(
- "connector account generation is too large".to_string()
- ))?,
- ],
- |row| row.get::<_, String>(0),
- )?
- .collect::, _>>()?;
- drop(statement);
- for landing_id in landing_ids {
- self.terminalize_connector_attachment_cleanup_tool(
- Uuid::parse_str(&landing_id)?,
+ ) -> EventStoreResult {
+ let agent_runs = self.list_agent_run_records()?;
+ let agent_runs = agent_runs
+ .into_iter()
+ .map(|run| (run.id, run))
+ .collect::>();
+ let mut reconciled = 0;
+ for run in self.list_automation_runs()? {
+ if self.automation_run_has_durable_lease(run.id)? {
+ continue;
+ }
+ let Some(agent_run_id) = run.agent_run_id else {
+ continue;
+ };
+ let Some(agent_run) = agent_runs.get(&agent_run_id) else {
+ continue;
+ };
+ let target = match agent_run.status {
+ AgentRunStatus::Queued => AutomationRunStatus::Queued,
+ AgentRunStatus::Running => AutomationRunStatus::Running,
+ AgentRunStatus::WaitingForPrerequisite => AutomationRunStatus::WaitingReview,
+ AgentRunStatus::WaitingForConfirmation => AutomationRunStatus::WaitingApproval,
+ AgentRunStatus::Completed => AutomationRunStatus::Completed,
+ AgentRunStatus::Failed | AgentRunStatus::Blocked => AutomationRunStatus::Failed,
+ AgentRunStatus::Cancelled | AgentRunStatus::CancelRequested => {
+ AutomationRunStatus::Cancelled
+ }
+ };
+ if run.status == target {
+ continue;
+ }
+ self.transition_automation_run(
+ run.id,
+ target,
+ None,
+ agent_run.finish_error.clone(),
changed_at,
)?;
+ reconciled += 1;
}
- Ok(())
+ Ok(reconciled)
}
- pub fn complete_connector_disconnect(
+ pub fn automation_run(&self, run_id: Uuid) -> EventStoreResult {
+ let json = self.conn.query_row(
+ "SELECT run_json FROM automation_runs WHERE id = ?1",
+ params![run_id.to_string()],
+ |row| row.get::<_, String>(0),
+ )?;
+ Ok(serde_json::from_str(&json)?)
+ }
+
+ pub fn transition_automation_run(
&self,
- ticket: &ConnectorDisconnectTicket,
- source: ConnectorDisconnectSource,
- credential_delete_outcome: ConnectorCredentialDeleteOutcome,
- now: DateTime,
- ) -> EventStoreResult {
- if ticket.account().health != ConnectorHealth::DisconnectPending {
- return Err(EventStoreError::InvalidState(
- "connector disconnect completion requires pending state".to_string(),
- ));
- }
- let transaction = self.conn.unchecked_transaction()?;
- let current_json = transaction
- .query_row(
- r#"SELECT account.account_json
- FROM connector_accounts AS account
- JOIN connector_account_generations AS generation
- ON generation.account_id = account.id
- WHERE account.id = ?1 AND account.health = ?2 AND generation.generation = ?3"#,
- params![
- ticket.account().id.to_string(),
- serde_json::to_string(&ConnectorHealth::DisconnectPending)?,
- i64::try_from(ticket.generation()).map_err(|_| {
- EventStoreError::InvalidState(
- "connector account generation is too large".to_string(),
- )
- })?,
- ],
- |row| row.get::<_, String>(0),
- )
- .optional()?
- .ok_or_else(|| {
- EventStoreError::InvalidState(
- "connector disconnect state changed before completion".to_string(),
- )
- })?;
- let mut current: ConnectorAccount = serde_json::from_str(¤t_json)?;
- if current.provider_id != ticket.account().provider_id
- || current.tenant_ref != ticket.account().tenant_ref
- || current.credential_handle != ticket.account().credential_handle
- || current.granted_capabilities != ticket.account().granted_capabilities
- {
- return Err(EventStoreError::InvalidState(
- "connector disconnect account binding changed".to_string(),
- ));
+ run_id: Uuid,
+ status: AutomationRunStatus,
+ agent_run_id: Option,
+ last_error: Option,
+ changed_at: DateTime,
+ ) -> EventStoreResult {
+ let mut run = self.automation_run(run_id)?;
+ if !automation_run_transition_allowed(run.status, status) {
+ return Err(EventStoreError::InvalidState(format!(
+ "automation run cannot transition from {:?} to {:?}",
+ run.status, status
+ )));
}
- current.health = ConnectorHealth::Disconnected;
- current.updated_at = now;
- let changed = transaction.execute(
- r#"UPDATE connector_accounts
- SET account_json = ?2, health = ?3, updated_at = ?4
- WHERE id = ?1 AND health = ?5 AND EXISTS (
- SELECT 1 FROM connector_account_generations
- WHERE account_id = ?1 AND generation = ?6
- )"#,
- params![
- current.id.to_string(),
- serde_json::to_string(¤t)?,
- serde_json::to_string(¤t.health)?,
- now.to_rfc3339_opts(SecondsFormat::Nanos, true),
- serde_json::to_string(&ConnectorHealth::DisconnectPending)?,
- i64::try_from(ticket.generation()).map_err(|_| {
- EventStoreError::InvalidState(
- "connector account generation is too large".to_string(),
- )
- })?,
- ],
- )?;
- if changed != 1 {
- return Err(EventStoreError::InvalidState(
- "connector disconnect completion raced with another transition".to_string(),
- ));
+ if run.status == AutomationRunStatus::Failed && status == AutomationRunStatus::Queued {
+ let definition = self.automation_definition(run.definition_id)?;
+ if run.attempt >= definition.retry_limit {
+ return Err(EventStoreError::InvalidState(
+ "automation retry limit reached".to_string(),
+ ));
+ }
+ run.attempt += 1;
}
- let receipt = ConnectorDisconnectReceipt {
- account_id: current.id,
- provider_id: current.provider_id.clone(),
- generation: ticket.generation(),
- phase: ConnectorDisconnectPhase::Completed,
- source,
- credential_delete_outcome: Some(credential_delete_outcome),
- changed_at: now,
- };
- let event = KernelEvent::new("connector.disconnect.completed", &receipt)?;
- transaction.execute(
- r#"INSERT INTO kernel_events (id, event_type, payload_json, created_at)
- VALUES (?1, ?2, ?3, ?4)"#,
+ if let Some(agent_run_id) = agent_run_id {
+ run.agent_run_id = Some(agent_run_id);
+ }
+ run.status = status;
+ run.last_error = last_error;
+ run.updated_at = changed_at;
+ self.persist_automation_run(&run)?;
+ Ok(run)
+ }
+
+ pub fn link_automation_run_to_agent_run(
+ &self,
+ automation_run_id: Uuid,
+ agent_run_id: Uuid,
+ changed_at: DateTime,
+ ) -> EventStoreResult {
+ self.ensure_agent_run_exists(agent_run_id)?;
+ let mut run = self.automation_run(automation_run_id)?;
+ if let Some(existing_id) = run.agent_run_id {
+ if existing_id != agent_run_id {
+ return Err(EventStoreError::InvalidState(
+ "automation run is already linked to another agent run".to_string(),
+ ));
+ }
+ return Ok(run);
+ }
+ run.agent_run_id = Some(agent_run_id);
+ run.updated_at = changed_at;
+ self.persist_automation_run(&run)?;
+ Ok(run)
+ }
+
+ #[cfg(test)]
+ pub fn upsert_automation_checkpoint(
+ &self,
+ checkpoint: &AutomationCheckpoint,
+ ) -> EventStoreResult<()> {
+ self.conn.execute(
+ r#"INSERT INTO automation_checkpoints (automation_run_id, checkpoint_json, updated_at)
+ VALUES (?1, ?2, ?3)
+ ON CONFLICT(automation_run_id) DO UPDATE SET
+ checkpoint_json = excluded.checkpoint_json,
+ updated_at = excluded.updated_at"#,
params![
- event.id.to_string(),
- event.event_type,
- event.payload_json,
- event.created_at.to_rfc3339_opts(SecondsFormat::Nanos, true),
+ checkpoint.automation_run_id.to_string(),
+ serde_json::to_string(checkpoint)?,
+ checkpoint
+ .recorded_at
+ .to_rfc3339_opts(SecondsFormat::Nanos, true),
],
)?;
- transaction.commit()?;
- Ok(current)
+ Ok(())
}
- pub fn record_connector_disconnect_failure(
+ pub(crate) fn compare_and_swap_automation_checkpoint(
&self,
- ticket: &ConnectorDisconnectTicket,
- source: ConnectorDisconnectSource,
- now: DateTime,
- ) -> EventStoreResult<()> {
- let transaction = self.conn.unchecked_transaction()?;
- let mut pending = ticket.account().clone();
- pending.updated_at = now;
- let changed = transaction.execute(
- r#"UPDATE connector_accounts SET account_json = ?3, updated_at = ?4
- WHERE id = ?1 AND health = ?2 AND EXISTS (
- SELECT 1 FROM connector_account_generations
- WHERE account_id = ?1 AND generation = ?5
+ checkpoint: &AutomationCheckpoint,
+ lease: &AutomationRunLease,
+ expected_revision: Option,
+ changed_at: DateTime,
+ ) -> EventStoreResult {
+ if checkpoint.automation_run_id != lease.run_id {
+ return Err(EventStoreError::InvalidState(
+ "automation checkpoint is bound to another run".to_string(),
+ ));
+ }
+ let (run, agent_run) = self.exact_automation_run_binding(lease.run_id)?;
+ if checkpoint.dedup_key != run.trigger_window_key {
+ return Err(EventStoreError::InvalidState(
+ "automation checkpoint dedup binding changed".to_string(),
+ ));
+ }
+ if let Some(tool_invocation_id) = checkpoint.tool_invocation_id {
+ let invocation = self
+ .list_tool_invocations()?
+ .into_iter()
+ .find(|invocation| invocation.id == tool_invocation_id)
+ .ok_or_else(|| {
+ EventStoreError::InvalidState(
+ "automation checkpoint tool invocation is missing".to_string(),
+ )
+ })?;
+ if invocation.run_id != Some(agent_run.id) {
+ return Err(EventStoreError::InvalidState(
+ "automation checkpoint tool invocation belongs to another AgentRun".to_string(),
+ ));
+ }
+ }
+
+ let transaction = Transaction::new_unchecked(&self.conn, TransactionBehavior::Immediate)?;
+ let lease_is_current = transaction.query_row(
+ r#"SELECT EXISTS(
+ SELECT 1 FROM automation_runs
+ WHERE id = ?1 AND lease_owner_id = ?2 AND lease_fence_token = ?3
+ AND lease_generation = ?4 AND lease_expires_at > ?5
)"#,
params![
- ticket.account().id.to_string(),
- serde_json::to_string(&ConnectorHealth::DisconnectPending)?,
- serde_json::to_string(&pending)?,
- now.to_rfc3339_opts(SecondsFormat::Nanos, true),
- i64::try_from(ticket.generation()).map_err(|_| {
+ lease.run_id.to_string(),
+ &lease.owner_id,
+ lease.fence_token.to_string(),
+ i64::try_from(lease.generation).map_err(|_| {
EventStoreError::InvalidState(
- "connector account generation is too large".to_string(),
+ "automation run lease generation is too large".to_string(),
)
})?,
+ changed_at.to_rfc3339_opts(SecondsFormat::Nanos, true),
],
- )?;
- if changed != 1 {
+ |row| row.get::<_, i64>(0),
+ )? != 0;
+ if !lease_is_current {
return Err(EventStoreError::InvalidState(
- "connector disconnect failure belongs to a stale transition".to_string(),
+ "automation checkpoint fence is stale, expired, or owned by another worker"
+ .to_string(),
));
}
- let receipt = ConnectorDisconnectReceipt {
- account_id: ticket.account().id,
- provider_id: ticket.account().provider_id.clone(),
- generation: ticket.generation(),
- phase: ConnectorDisconnectPhase::CredentialDeleteFailed,
- source,
- credential_delete_outcome: None,
- changed_at: now,
- };
- let event = KernelEvent::new("connector.disconnect.retry_required", &receipt)?;
- transaction.execute(
- r#"INSERT INTO kernel_events (id, event_type, payload_json, created_at)
- VALUES (?1, ?2, ?3, ?4)"#,
- params![
- event.id.to_string(),
- event.event_type,
- event.payload_json,
- event.created_at.to_rfc3339_opts(SecondsFormat::Nanos, true),
- ],
- )?;
- transaction.commit()?;
- Ok(())
- }
- pub fn list_pending_connector_disconnects(
- &self,
- limit: usize,
- ) -> EventStoreResult> {
- if limit == 0 || limit > 100 {
+ let current_revision = transaction
+ .query_row(
+ "SELECT revision FROM automation_checkpoints WHERE automation_run_id = ?1",
+ params![lease.run_id.to_string()],
+ |row| row.get::<_, i64>(0),
+ )
+ .optional()?
+ .map(u64::try_from)
+ .transpose()
+ .map_err(|_| {
+ EventStoreError::InvalidState(
+ "automation checkpoint revision is invalid".to_string(),
+ )
+ })?;
+ if current_revision != expected_revision {
return Err(EventStoreError::InvalidState(
- "connector disconnect recovery limit is invalid".to_string(),
+ "automation checkpoint compare-and-swap conflict".to_string(),
));
}
- let mut statement = self.conn.prepare(
- r#"SELECT account.account_json, generation.generation
- FROM connector_accounts AS account
- JOIN connector_account_generations AS generation
- ON generation.account_id = account.id
- WHERE account.health = ?1
- ORDER BY account.updated_at ASC, account.rowid ASC
- LIMIT ?2"#,
- )?;
- let tickets = statement
- .query_map(
+ let revision = current_revision
+ .map(|value| {
+ value.checked_add(1).ok_or_else(|| {
+ EventStoreError::InvalidState(
+ "automation checkpoint revision is exhausted".to_string(),
+ )
+ })
+ })
+ .transpose()?
+ .unwrap_or(0);
+ let checkpoint_json = serde_json::to_string(checkpoint)?;
+ let changed = if expected_revision.is_none() {
+ transaction.execute(
+ r#"INSERT OR IGNORE INTO automation_checkpoints
+ (automation_run_id, checkpoint_json, contract_version, revision,
+ definition_revision, agent_run_id, lease_generation, updated_at)
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)"#,
params![
- serde_json::to_string(&ConnectorHealth::DisconnectPending)?,
- i64::try_from(limit).map_err(|_| {
+ lease.run_id.to_string(),
+ &checkpoint_json,
+ AUTOMATION_CHECKPOINT_CONTRACT_VERSION,
+ i64::try_from(revision).map_err(|_| EventStoreError::InvalidState(
+ "automation checkpoint revision is too large".to_string()
+ ))?,
+ i64::try_from(run.definition_revision).map_err(|_| {
EventStoreError::InvalidState(
- "connector disconnect recovery limit is invalid".to_string(),
+ "automation definition revision is too large".to_string(),
+ )
+ })?,
+ agent_run.id.to_string(),
+ i64::try_from(lease.generation).map_err(|_| {
+ EventStoreError::InvalidState(
+ "automation lease generation is too large".to_string(),
)
})?,
+ changed_at.to_rfc3339_opts(SecondsFormat::Nanos, true),
],
- |row| Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?)),
)?
- .map(|row| {
- let (json, generation) = row?;
- let account = serde_json::from_str(&json)?;
- let generation = u64::try_from(generation).map_err(|_| {
- EventStoreError::InvalidState(
- "connector account generation is invalid".to_string(),
- )
- })?;
- Ok(ConnectorDisconnectTicket::new(account, generation))
- })
- .collect();
- tickets
+ } else {
+ transaction.execute(
+ r#"UPDATE automation_checkpoints
+ SET checkpoint_json = ?2, contract_version = ?3, revision = ?4,
+ definition_revision = ?5, agent_run_id = ?6,
+ lease_generation = ?7, updated_at = ?8
+ WHERE automation_run_id = ?1 AND revision = ?9
+ AND contract_version = ?3
+ AND definition_revision = ?5
+ AND agent_run_id = ?6"#,
+ params![
+ lease.run_id.to_string(),
+ &checkpoint_json,
+ AUTOMATION_CHECKPOINT_CONTRACT_VERSION,
+ i64::try_from(revision).map_err(|_| EventStoreError::InvalidState(
+ "automation checkpoint revision is too large".to_string()
+ ))?,
+ i64::try_from(run.definition_revision).map_err(|_| {
+ EventStoreError::InvalidState(
+ "automation definition revision is too large".to_string(),
+ )
+ })?,
+ agent_run.id.to_string(),
+ i64::try_from(lease.generation).map_err(|_| {
+ EventStoreError::InvalidState(
+ "automation lease generation is too large".to_string(),
+ )
+ })?,
+ changed_at.to_rfc3339_opts(SecondsFormat::Nanos, true),
+ i64::try_from(expected_revision.unwrap_or_default()).map_err(|_| {
+ EventStoreError::InvalidState(
+ "automation checkpoint revision is too large".to_string(),
+ )
+ })?,
+ ],
+ )?
+ };
+ if changed != 1 {
+ return Err(EventStoreError::InvalidState(
+ "automation checkpoint compare-and-swap conflict".to_string(),
+ ));
+ }
+ transaction.commit()?;
+ Ok(AutomationCheckpointRecord {
+ contract_version: AUTOMATION_CHECKPOINT_CONTRACT_VERSION.to_string(),
+ revision,
+ definition_revision: run.definition_revision,
+ agent_run_id: agent_run.id,
+ lease_generation: lease.generation,
+ checkpoint: checkpoint.clone(),
+ })
}
- pub fn list_connector_accounts(&self) -> EventStoreResult> {
- let mut statement = self.conn.prepare(
- "SELECT account_json FROM connector_accounts ORDER BY updated_at ASC, rowid ASC",
- )?;
- let rows = statement
- .query_map([], |row| row.get::<_, String>(0))?
- .collect::, _>>()?;
- rows.into_iter()
- .map(|json| serde_json::from_str(&json).map_err(Into::into))
- .collect()
- }
+ pub(crate) fn begin_automation_effect_for_tool_invocation(
+ &self,
+ tool_invocation_id: Uuid,
+ now: DateTime,
+ ) -> EventStoreResult> {
+ let invocation = self.tool_invocation_by_id(tool_invocation_id)?;
+ if !automation_capability_may_have_effect(invocation.capability) {
+ return Ok(None);
+ }
+ if invocation.status != ToolExecutionStatus::Running {
+ return Err(EventStoreError::InvalidState(
+ "automation effect can begin only for a running Tool".to_string(),
+ ));
+ }
+ let Some(agent_run_id) = invocation.run_id else {
+ return Ok(None);
+ };
+ let matching_runs = self
+ .list_automation_runs()?
+ .into_iter()
+ .filter(|run| run.agent_run_id == Some(agent_run_id))
+ .collect::>();
+ if matching_runs.is_empty() {
+ return Ok(None);
+ }
+ if matching_runs.len() != 1 {
+ return Err(EventStoreError::InvalidState(
+ "AgentRun is linked to multiple automation runs".to_string(),
+ ));
+ }
+ let run = matching_runs
+ .into_iter()
+ .next()
+ .expect("non-empty automation run set");
+ let (exact_run, agent_run) = self.exact_automation_run_binding(run.id)?;
+ if exact_run != run
+ || agent_run.id != agent_run_id
+ || agent_run.status != AgentRunStatus::Running
+ || self.running_tool_invocation_for_agent_run(agent_run_id)? != Some(tool_invocation_id)
+ {
+ return Err(EventStoreError::InvalidState(
+ "automation effect Tool binding changed before dispatch".to_string(),
+ ));
+ }
+ if invocation.request_fingerprint.trim().is_empty() {
+ return Err(EventStoreError::InvalidState(
+ "automation effect request fingerprint is missing".to_string(),
+ ));
+ }
- pub fn list_connector_recovery_items(&self) -> EventStoreResult> {
- self.list_connector_recovery_items_with_registries(&EmptyConnectorReconcilerRegistry, None)
- }
+ let transaction = Transaction::new_unchecked(&self.conn, TransactionBehavior::Immediate)?;
+ let (run_json, stored_definition_revision, generation, existing_fence) = transaction
+ .query_row(
+ r#"SELECT run_json, definition_revision, lease_generation, lease_fence_token
+ FROM automation_runs WHERE id = ?1"#,
+ params![run.id.to_string()],
+ |row| {
+ Ok((
+ row.get::<_, String>(0)?,
+ row.get::<_, i64>(1)?,
+ row.get::<_, i64>(2)?,
+ row.get::<_, Option>(3)?,
+ ))
+ },
+ )?;
+ let transaction_run: AutomationRun = serde_json::from_str(&run_json)?;
+ if transaction_run != run
+ || u64::try_from(stored_definition_revision).ok() != Some(run.definition_revision)
+ || existing_fence.is_some()
+ {
+ return Err(EventStoreError::InvalidState(
+ "automation effect lease binding raced with another owner".to_string(),
+ ));
+ }
+ let generation = u64::try_from(generation)
+ .map_err(|_| {
+ EventStoreError::InvalidState("automation effect lease is invalid".to_string())
+ })?
+ .checked_add(1)
+ .ok_or_else(|| {
+ EventStoreError::InvalidState(
+ "automation effect lease generation is exhausted".to_string(),
+ )
+ })?;
+ let owner_id = format!("automation-effect:{tool_invocation_id}");
+ let fence_token = Uuid::new_v4();
+ let lease_expires_at = now + Duration::minutes(15);
+ let mut claimed_run = run.clone();
+ claimed_run.claimed_by = Some(owner_id.clone());
+ claimed_run.claimed_at = Some(now);
+ claimed_run.updated_at = now;
+ let claimed = transaction.execute(
+ r#"UPDATE automation_runs
+ SET run_json = ?2, claimed_by = ?3, lease_owner_id = ?3,
+ lease_fence_token = ?4, lease_generation = ?5,
+ lease_expires_at = ?6, lease_heartbeat_at = ?7, updated_at = ?7
+ WHERE id = ?1 AND lease_fence_token IS NULL AND lease_generation = ?8
+ AND definition_revision = ?9"#,
+ params![
+ run.id.to_string(),
+ serde_json::to_string(&claimed_run)?,
+ &owner_id,
+ fence_token.to_string(),
+ automation_revision_i64(generation)?,
+ lease_expires_at.to_rfc3339_opts(SecondsFormat::Nanos, true),
+ now.to_rfc3339_opts(SecondsFormat::Nanos, true),
+ automation_revision_i64(generation.saturating_sub(1))?,
+ automation_revision_i64(run.definition_revision)?,
+ ],
+ )?;
+ if claimed != 1 {
+ return Err(EventStoreError::InvalidState(
+ "automation effect lease lost its fence".to_string(),
+ ));
+ }
- pub(crate) fn list_connector_recovery_items_with_registry(
- &self,
- registry: &dyn ConnectorReconcilerRegistry,
- ) -> EventStoreResult> {
- self.list_connector_recovery_items_with_registries(registry, None)
+ let current = transaction
+ .query_row(
+ r#"SELECT contract_version, revision, definition_revision, agent_run_id,
+ effect_ledger_json
+ FROM automation_checkpoints WHERE automation_run_id = ?1"#,
+ params![run.id.to_string()],
+ |row| {
+ Ok((
+ row.get::<_, Option>(0)?,
+ row.get::<_, i64>(1)?,
+ row.get::<_, Option>(2)?,
+ row.get::<_, Option>(3)?,
+ row.get::<_, Option>(4)?,
+ ))
+ },
+ )
+ .optional()?;
+ let (current_revision, mut ledger) = match current {
+ Some((
+ contract_version,
+ revision,
+ definition_revision,
+ checkpoint_agent_run_id,
+ ledger_json,
+ )) => {
+ if contract_version.as_deref() != Some(AUTOMATION_CHECKPOINT_CONTRACT_VERSION)
+ || definition_revision.and_then(|value| u64::try_from(value).ok())
+ != Some(run.definition_revision)
+ || checkpoint_agent_run_id.as_deref() != Some(agent_run_id.to_string().as_str())
+ {
+ return Err(EventStoreError::InvalidState(
+ "automation effect checkpoint binding changed".to_string(),
+ ));
+ }
+ let revision = u64::try_from(revision).map_err(|_| {
+ EventStoreError::InvalidState(
+ "automation effect checkpoint revision is invalid".to_string(),
+ )
+ })?;
+ let ledger = ledger_json
+ .map(|json| serde_json::from_str::(&json))
+ .transpose()?
+ .unwrap_or_default();
+ ledger
+ .validate(run.id)
+ .map_err(EventStoreError::InvalidState)?;
+ (Some(revision), ledger)
+ }
+ None => (None, AutomationEffectLedger::default()),
+ };
+ if ledger
+ .effects
+ .iter()
+ .any(|effect| effect.tool_invocation_id == tool_invocation_id)
+ {
+ return Err(EventStoreError::InvalidState(
+ "automation effect invocation was already prepared and will not be replayed"
+ .to_string(),
+ ));
+ }
+ let checkpoint_revision = current_revision
+ .map(|value| {
+ value.checked_add(1).ok_or_else(|| {
+ EventStoreError::InvalidState(
+ "automation effect checkpoint revision is exhausted".to_string(),
+ )
+ })
+ })
+ .transpose()?
+ .unwrap_or(0);
+ let mut effect = AutomationEffectRecord::prepared(
+ AutomationEffectBinding {
+ automation_run_id: run.id,
+ definition_revision: run.definition_revision,
+ agent_run_id,
+ tool_invocation_id,
+ request_fingerprint: invocation.request_fingerprint.clone(),
+ prepared_checkpoint_revision: checkpoint_revision,
+ fence_token,
+ lease_generation: generation,
+ },
+ now,
+ )
+ .map_err(EventStoreError::InvalidState)?;
+ effect.state = AutomationEffectState::EffectStarted;
+ effect.safe_reason_code = Some("effect_started_before_dispatch".to_string());
+ effect.validate().map_err(EventStoreError::InvalidState)?;
+ ledger
+ .push(run.id, effect.clone())
+ .map_err(EventStoreError::InvalidState)?;
+ let checkpoint = AutomationCheckpoint {
+ automation_run_id: run.id,
+ dedup_key: run.trigger_window_key.clone(),
+ tool_invocation_id: Some(tool_invocation_id),
+ evidence_ref: Some(format!(
+ "automation-effect://{}/effect-started",
+ effect.idempotency_key
+ )),
+ recorded_at: now,
+ };
+ let checkpoint_json = serde_json::to_string(&checkpoint)?;
+ let ledger_json = serde_json::to_string(&ledger)?;
+ let effect_state = serde_json::to_string(&effect.state)?;
+ let changed = if let Some(expected_revision) = current_revision {
+ transaction.execute(
+ r#"UPDATE automation_checkpoints
+ SET checkpoint_json = ?2, contract_version = ?3, revision = ?4,
+ definition_revision = ?5, agent_run_id = ?6, lease_generation = ?7,
+ effect_ledger_json = ?8, effect_state = ?9,
+ effect_tool_invocation_id = ?10, effect_idempotency_key = ?11,
+ next_reconciliation_at = NULL, reconciliation_attempt_count = 0,
+ effect_dead_letter_code = NULL, effect_quarantine_code = NULL,
+ updated_at = ?12
+ WHERE automation_run_id = ?1 AND revision = ?13
+ AND contract_version = ?3 AND definition_revision = ?5
+ AND agent_run_id = ?6"#,
+ params![
+ run.id.to_string(),
+ checkpoint_json,
+ AUTOMATION_CHECKPOINT_CONTRACT_VERSION,
+ automation_revision_i64(checkpoint_revision)?,
+ automation_revision_i64(run.definition_revision)?,
+ agent_run_id.to_string(),
+ automation_revision_i64(generation)?,
+ ledger_json,
+ effect_state,
+ tool_invocation_id.to_string(),
+ &effect.idempotency_key,
+ now.to_rfc3339_opts(SecondsFormat::Nanos, true),
+ automation_revision_i64(expected_revision)?,
+ ],
+ )?
+ } else {
+ transaction.execute(
+ r#"INSERT INTO automation_checkpoints
+ (automation_run_id, checkpoint_json, contract_version, revision,
+ definition_revision, agent_run_id, lease_generation, effect_ledger_json,
+ effect_state, effect_tool_invocation_id, effect_idempotency_key,
+ next_reconciliation_at, reconciliation_attempt_count,
+ effect_dead_letter_code, effect_quarantine_code, updated_at)
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11,
+ NULL, 0, NULL, NULL, ?12)"#,
+ params![
+ run.id.to_string(),
+ checkpoint_json,
+ AUTOMATION_CHECKPOINT_CONTRACT_VERSION,
+ automation_revision_i64(checkpoint_revision)?,
+ automation_revision_i64(run.definition_revision)?,
+ agent_run_id.to_string(),
+ automation_revision_i64(generation)?,
+ ledger_json,
+ effect_state,
+ tool_invocation_id.to_string(),
+ &effect.idempotency_key,
+ now.to_rfc3339_opts(SecondsFormat::Nanos, true),
+ ],
+ )?
+ };
+ if changed != 1 {
+ return Err(EventStoreError::InvalidState(
+ "automation effect checkpoint compare-and-swap conflict".to_string(),
+ ));
+ }
+ transaction.commit()?;
+ Ok(Some(effect))
}
- pub(crate) fn list_connector_recovery_items_with_runtime_registries(
+ pub(crate) fn finish_automation_effect_for_tool_invocation(
&self,
- registry: &dyn ConnectorReconcilerRegistry,
- sync_registry: &dyn ConnectorSyncRegistry,
- ) -> EventStoreResult> {
- self.list_connector_recovery_items_with_registries(registry, Some(sync_registry))
+ tool_invocation_id: Uuid,
+ now: DateTime,
+ ) -> EventStoreResult> {
+ let Some(execution) =
+ self.automation_effect_execution_for_invocation(tool_invocation_id, now)?
+ else {
+ return Ok(None);
+ };
+ let outcome = self.classify_automation_effect(&execution.effect)?;
+ let mut effect = execution.effect.clone();
+ match outcome {
+ DurableAutomationEffectOutcome::KnownApplied {
+ evidence_ref,
+ undo_capability,
+ workspace_checkpoint_id,
+ workspace_action_revision,
+ } => {
+ effect.state = AutomationEffectState::KnownApplied;
+ effect.evidence_ref = Some(evidence_ref);
+ effect.safe_reason_code = Some("effect_known_applied".to_string());
+ effect.undo_capability = undo_capability;
+ effect.workspace_checkpoint_id = workspace_checkpoint_id;
+ effect.workspace_action_revision = workspace_action_revision;
+ }
+ DurableAutomationEffectOutcome::KnownNotApplied { evidence_ref } => {
+ effect.state = AutomationEffectState::KnownNotApplied;
+ effect.evidence_ref = Some(evidence_ref);
+ effect.safe_reason_code = Some("effect_known_not_applied".to_string());
+ }
+ DurableAutomationEffectOutcome::StillUncertain { reason_code } => {
+ effect.state = AutomationEffectState::EffectUnknown;
+ effect.evidence_ref = Some(format!(
+ "automation-effect://{}/requires-reconciliation",
+ effect.idempotency_key
+ ));
+ effect.safe_reason_code = Some(reason_code);
+ }
+ }
+ effect.next_reconciliation_at = None;
+ effect.updated_at = now;
+ effect.last_lease_generation = execution.lease.generation;
+ self.persist_automation_effect_transition(&execution, &effect, now)?;
+ self.release_automation_run_lease(&execution.lease, now)?;
+ Ok(Some(effect))
}
- fn list_connector_recovery_items_with_registries(
+ pub(crate) fn reconcile_due_automation_effects(
&self,
- registry: &dyn ConnectorReconcilerRegistry,
- sync_registry: Option<&dyn ConnectorSyncRegistry>,
- ) -> EventStoreResult> {
- let mut items = Vec::new();
-
- let mut attachment_statement = self.conn.prepare(
- r#"SELECT id, metadata_json, failure_kind, workspace_root,
- workspace_identity, storage_identity, updated_at, recovery_revision
- FROM connector_attachment_landings
- WHERE status = 'repair_required'
- ORDER BY updated_at DESC, id ASC LIMIT 100"#,
+ now: DateTime,
+ limit: usize,
+ ) -> EventStoreResult {
+ let effect_unknown = serde_json::to_string(&AutomationEffectState::EffectUnknown)?;
+ let reconciliation_backoff =
+ serde_json::to_string(&AutomationEffectState::ReconciliationBackoff)?;
+ let mut statement = self.conn.prepare(
+ r#"SELECT automation_run_id
+ FROM automation_checkpoints
+ WHERE effect_quarantine_code IS NULL
+ AND effect_state IN (?1, ?2)
+ AND (next_reconciliation_at IS NULL OR next_reconciliation_at <= ?3)
+ ORDER BY COALESCE(next_reconciliation_at, updated_at) ASC, rowid ASC
+ LIMIT ?4"#,
)?;
- let attachment_rows = attachment_statement
- .query_map([], |row| {
- Ok((
- row.get::<_, String>(0)?,
- row.get::<_, String>(1)?,
- row.get::<_, Option>(2)?,
- row.get::<_, Option>(3)?,
- row.get::<_, Option>(4)?,
- row.get::<_, Option>(5)?,
- row.get::<_, String>(6)?,
- row.get::<_, i64>(7)?,
- ))
- })?
+ let run_ids = statement
+ .query_map(
+ params![
+ effect_unknown,
+ reconciliation_backoff,
+ now.to_rfc3339_opts(SecondsFormat::Nanos, true),
+ i64::try_from(limit.min(64)).unwrap_or(64),
+ ],
+ |row| row.get::<_, String>(0),
+ )?
.collect::, _>>()?;
- drop(attachment_statement);
- for (
- id,
- metadata_json,
- failure_kind,
- workspace_root,
- workspace_identity,
- storage_identity,
- updated_at,
- recovery_revision,
- ) in attachment_rows
- {
- let metadata: ConnectorAttachmentMetadata = serde_json::from_str(&metadata_json)?;
- let reason_code = match failure_kind.as_deref() {
- Some("legacy_workspace_unbound") => {
- ConnectorRecoveryReasonCode::AttachmentLegacyWorkspaceUnbound
+ drop(statement);
+
+ let mut sweep = AutomationEffectReconciliationSweep::default();
+ for run_id in run_ids {
+ sweep.inspected += 1;
+ let run_id = match Uuid::parse_str(&run_id) {
+ Ok(run_id) => run_id,
+ Err(_) => {
+ self.conn.execute(
+ r#"UPDATE automation_checkpoints
+ SET effect_quarantine_code = 'invalid_effect_projection',
+ next_reconciliation_at = NULL
+ WHERE automation_run_id = ?1"#,
+ params![run_id],
+ )?;
+ sweep.quarantined += 1;
+ continue;
}
- Some("legacy_completed_unverified") => {
- ConnectorRecoveryReasonCode::AttachmentLegacyReceiptIncomplete
+ };
+ if self.automation_effect_ledger(run_id).is_err() {
+ self.conn.execute(
+ r#"UPDATE automation_checkpoints
+ SET effect_quarantine_code = 'invalid_effect_ledger',
+ next_reconciliation_at = NULL
+ WHERE automation_run_id = ?1"#,
+ params![run_id.to_string()],
+ )?;
+ sweep.quarantined += 1;
+ continue;
+ }
+ let execution = match self.claim_automation_effect_reconciliation(run_id, now) {
+ Ok(execution) => execution,
+ Err(EventStoreError::InvalidState(_)) => {
+ sweep.lost_fence += 1;
+ continue;
}
- Some("retention_identity_conflict") => {
- ConnectorRecoveryReasonCode::AttachmentRetentionIdentityChanged
+ Err(error) => return Err(error),
+ };
+ let outcome = self.classify_automation_effect(&execution.effect);
+ let mut effect = execution.effect.clone();
+ match outcome {
+ Ok(DurableAutomationEffectOutcome::KnownApplied {
+ evidence_ref,
+ undo_capability,
+ workspace_checkpoint_id,
+ workspace_action_revision,
+ }) => {
+ effect.state = AutomationEffectState::KnownApplied;
+ effect.evidence_ref = Some(evidence_ref);
+ effect.safe_reason_code = Some("effect_known_applied".to_string());
+ effect.undo_capability = undo_capability;
+ effect.workspace_checkpoint_id = workspace_checkpoint_id;
+ effect.workspace_action_revision = workspace_action_revision;
+ effect.next_reconciliation_at = None;
+ sweep.known_applied += 1;
}
- Some("ready_identity_conflict")
- | Some("ready_cleanup_identity_conflict")
- | Some("unsafe_cleanup_boundary") => {
- ConnectorRecoveryReasonCode::AttachmentStoredIdentityChanged
+ Ok(DurableAutomationEffectOutcome::KnownNotApplied { evidence_ref }) => {
+ effect.state = AutomationEffectState::KnownNotApplied;
+ effect.evidence_ref = Some(evidence_ref);
+ effect.safe_reason_code = Some("effect_known_not_applied".to_string());
+ effect.next_reconciliation_at = None;
+ sweep.known_not_applied += 1;
}
- Some("recovery_projection_unavailable") => {
- ConnectorRecoveryReasonCode::AttachmentExecutionRecordIncomplete
+ Ok(DurableAutomationEffectOutcome::StillUncertain { reason_code }) => {
+ effect.reconciliation_attempt_count = effect
+ .reconciliation_attempt_count
+ .checked_add(1)
+ .ok_or_else(|| {
+ EventStoreError::InvalidState(
+ "automation effect reconciliation count is exhausted".to_string(),
+ )
+ })?;
+ if effect.reconciliation_attempt_count
+ >= AUTOMATION_EFFECT_MAX_RECONCILIATION_ATTEMPTS
+ {
+ effect.state = AutomationEffectState::DeadLettered;
+ effect.safe_reason_code =
+ Some("effect_dead_letter_manual_recovery_required".to_string());
+ effect.next_reconciliation_at = None;
+ sweep.dead_lettered += 1;
+ } else {
+ let exponent = effect.reconciliation_attempt_count.saturating_sub(1).min(6);
+ let seconds = 30_i64.checked_shl(exponent).unwrap_or(30_i64 << 6);
+ effect.state = AutomationEffectState::ReconciliationBackoff;
+ effect.safe_reason_code = Some(reason_code);
+ effect.next_reconciliation_at = Some(now + Duration::seconds(seconds));
+ sweep.deferred += 1;
+ }
+ }
+ Err(_) => {
+ effect.reconciliation_attempt_count = effect
+ .reconciliation_attempt_count
+ .checked_add(1)
+ .ok_or_else(|| {
+ EventStoreError::InvalidState(
+ "automation effect reconciliation count is exhausted".to_string(),
+ )
+ })?;
+ if effect.reconciliation_attempt_count
+ >= AUTOMATION_EFFECT_MAX_RECONCILIATION_ATTEMPTS
+ {
+ effect.state = AutomationEffectState::DeadLettered;
+ effect.safe_reason_code =
+ Some("effect_dead_letter_manual_recovery_required".to_string());
+ effect.next_reconciliation_at = None;
+ sweep.dead_lettered += 1;
+ } else {
+ let exponent = effect.reconciliation_attempt_count.saturating_sub(1).min(6);
+ let seconds = 30_i64.checked_shl(exponent).unwrap_or(30_i64 << 6);
+ effect.state = AutomationEffectState::ReconciliationBackoff;
+ effect.safe_reason_code = Some("reconciliation_read_failed".to_string());
+ effect.next_reconciliation_at = Some(now + Duration::seconds(seconds));
+ sweep.deferred += 1;
+ }
+ }
+ }
+ effect.last_lease_generation = execution.lease.generation;
+ effect.updated_at = now;
+ self.persist_automation_effect_transition(&execution, &effect, now)?;
+ self.release_automation_run_lease(&execution.lease, now)?;
+ }
+ Ok(sweep)
+ }
+
+ fn claim_automation_effect_reconciliation(
+ &self,
+ run_id: Uuid,
+ now: DateTime,
+ ) -> EventStoreResult {
+ let transaction = Transaction::new_unchecked(&self.conn, TransactionBehavior::Immediate)?;
+ let (checkpoint_revision, ledger_json, checkpoint_definition_revision, agent_run_id) =
+ transaction.query_row(
+ r#"SELECT revision, effect_ledger_json, definition_revision, agent_run_id
+ FROM automation_checkpoints
+ WHERE automation_run_id = ?1 AND effect_quarantine_code IS NULL"#,
+ params![run_id.to_string()],
+ |row| {
+ Ok((
+ row.get::<_, i64>(0)?,
+ row.get::<_, Option>(1)?,
+ row.get::<_, Option>(2)?,
+ row.get::<_, Option>(3)?,
+ ))
+ },
+ )?;
+ let checkpoint_revision = u64::try_from(checkpoint_revision).map_err(|_| {
+ EventStoreError::InvalidState(
+ "automation effect checkpoint revision is invalid".to_string(),
+ )
+ })?;
+ let mut ledger: AutomationEffectLedger =
+ serde_json::from_str(ledger_json.as_deref().ok_or_else(|| {
+ EventStoreError::InvalidState("automation effect ledger is missing".to_string())
+ })?)?;
+ ledger
+ .validate(run_id)
+ .map_err(EventStoreError::InvalidState)?;
+ let effect = ledger.current().cloned().ok_or_else(|| {
+ EventStoreError::InvalidState("automation effect ledger is empty".to_string())
+ })?;
+ if !effect.state.uncertain()
+ || effect.state == AutomationEffectState::EffectStarted
+ || effect
+ .next_reconciliation_at
+ .is_some_and(|retry_at| retry_at > now)
+ || checkpoint_definition_revision.and_then(|value| u64::try_from(value).ok())
+ != Some(effect.definition_revision)
+ || agent_run_id.as_deref() != Some(effect.agent_run_id.to_string().as_str())
+ {
+ return Err(EventStoreError::InvalidState(
+ "automation effect is not due for reconciliation".to_string(),
+ ));
+ }
+
+ let (run_json, generation, existing_fence) = transaction.query_row(
+ r#"SELECT run_json, lease_generation, lease_fence_token
+ FROM automation_runs WHERE id = ?1"#,
+ params![run_id.to_string()],
+ |row| {
+ Ok((
+ row.get::<_, String>(0)?,
+ row.get::<_, i64>(1)?,
+ row.get::<_, Option>(2)?,
+ ))
+ },
+ )?;
+ if existing_fence.is_some() {
+ return Err(EventStoreError::InvalidState(
+ "automation effect reconciliation lease is already owned".to_string(),
+ ));
+ }
+ let generation = u64::try_from(generation)
+ .map_err(|_| {
+ EventStoreError::InvalidState(
+ "automation effect reconciliation lease is invalid".to_string(),
+ )
+ })?
+ .checked_add(1)
+ .ok_or_else(|| {
+ EventStoreError::InvalidState(
+ "automation effect reconciliation lease generation is exhausted".to_string(),
+ )
+ })?;
+ let owner_id = format!("automation-reconcile:{}", effect.tool_invocation_id);
+ let fence_token = Uuid::new_v4();
+ let lease_expires_at = now + Duration::minutes(5);
+ let mut run: AutomationRun = serde_json::from_str(&run_json)?;
+ run.claimed_by = Some(owner_id.clone());
+ run.claimed_at = Some(now);
+ run.updated_at = now;
+ let claimed = transaction.execute(
+ r#"UPDATE automation_runs
+ SET run_json = ?2, claimed_by = ?3, lease_owner_id = ?3,
+ lease_fence_token = ?4, lease_generation = ?5,
+ lease_expires_at = ?6, lease_heartbeat_at = ?7, updated_at = ?7
+ WHERE id = ?1 AND lease_fence_token IS NULL AND lease_generation = ?8"#,
+ params![
+ run_id.to_string(),
+ serde_json::to_string(&run)?,
+ &owner_id,
+ fence_token.to_string(),
+ automation_revision_i64(generation)?,
+ lease_expires_at.to_rfc3339_opts(SecondsFormat::Nanos, true),
+ now.to_rfc3339_opts(SecondsFormat::Nanos, true),
+ automation_revision_i64(generation.saturating_sub(1))?,
+ ],
+ )?;
+ if claimed != 1 {
+ return Err(EventStoreError::InvalidState(
+ "automation effect reconciliation lost its lease fence".to_string(),
+ ));
+ }
+ let mut claimed_effect = effect;
+ claimed_effect.last_fence_token = fence_token;
+ claimed_effect.last_lease_generation = generation;
+ claimed_effect.updated_at = now;
+ *ledger.current_mut().expect("validated ledger is not empty") = claimed_effect.clone();
+ ledger
+ .validate(run_id)
+ .map_err(EventStoreError::InvalidState)?;
+ let claimed_revision = checkpoint_revision.checked_add(1).ok_or_else(|| {
+ EventStoreError::InvalidState(
+ "automation effect checkpoint revision is exhausted".to_string(),
+ )
+ })?;
+ let changed = transaction.execute(
+ r#"UPDATE automation_checkpoints
+ SET revision = ?2, lease_generation = ?3, effect_ledger_json = ?4,
+ updated_at = ?5
+ WHERE automation_run_id = ?1 AND revision = ?6
+ AND effect_tool_invocation_id = ?7 AND effect_idempotency_key = ?8"#,
+ params![
+ run_id.to_string(),
+ automation_revision_i64(claimed_revision)?,
+ automation_revision_i64(generation)?,
+ serde_json::to_string(&ledger)?,
+ now.to_rfc3339_opts(SecondsFormat::Nanos, true),
+ automation_revision_i64(checkpoint_revision)?,
+ claimed_effect.tool_invocation_id.to_string(),
+ &claimed_effect.idempotency_key,
+ ],
+ )?;
+ if changed != 1 {
+ return Err(EventStoreError::InvalidState(
+ "automation effect reconciliation lost its checkpoint revision".to_string(),
+ ));
+ }
+ transaction.commit()?;
+ Ok(AutomationEffectExecution {
+ lease: AutomationRunLease {
+ run_id,
+ owner_id,
+ fence_token,
+ generation,
+ lease_expires_at,
+ },
+ checkpoint_revision: claimed_revision,
+ effect: claimed_effect,
+ })
+ }
+
+ pub(crate) fn automation_effect_ledger(
+ &self,
+ automation_run_id: Uuid,
+ ) -> EventStoreResult> {
+ let ledger_json = self
+ .conn
+ .query_row(
+ "SELECT effect_ledger_json FROM automation_checkpoints WHERE automation_run_id = ?1",
+ params![automation_run_id.to_string()],
+ |row| row.get::<_, Option>(0),
+ )
+ .optional()?
+ .flatten();
+ let Some(ledger_json) = ledger_json else {
+ return Ok(None);
+ };
+ let ledger: AutomationEffectLedger = serde_json::from_str(&ledger_json)?;
+ ledger
+ .validate(automation_run_id)
+ .map_err(EventStoreError::InvalidState)?;
+ Ok(Some(ledger))
+ }
+
+ fn automation_effect_execution_for_invocation(
+ &self,
+ tool_invocation_id: Uuid,
+ now: DateTime,
+ ) -> EventStoreResult> {
+ let row = self
+ .conn
+ .query_row(
+ r#"SELECT checkpoint.automation_run_id, checkpoint.revision,
+ checkpoint.effect_ledger_json, run.lease_owner_id,
+ run.lease_fence_token, run.lease_generation, run.lease_expires_at
+ FROM automation_checkpoints AS checkpoint
+ JOIN automation_runs AS run ON run.id = checkpoint.automation_run_id
+ WHERE checkpoint.effect_tool_invocation_id = ?1
+ AND checkpoint.effect_quarantine_code IS NULL"#,
+ params![tool_invocation_id.to_string()],
+ |row| {
+ Ok((
+ row.get::<_, String>(0)?,
+ row.get::<_, i64>(1)?,
+ row.get::<_, Option>(2)?,
+ row.get::<_, Option>(3)?,
+ row.get::<_, Option>(4)?,
+ row.get::<_, i64>(5)?,
+ row.get::<_, Option>(6)?,
+ ))
+ },
+ )
+ .optional()?;
+ let Some((run_id, revision, ledger_json, owner_id, fence_token, generation, expires_at)) =
+ row
+ else {
+ return Ok(None);
+ };
+ let run_id = Uuid::parse_str(&run_id)?;
+ let ledger_json = ledger_json.ok_or_else(|| {
+ EventStoreError::InvalidState("automation effect ledger is missing".to_string())
+ })?;
+ let ledger: AutomationEffectLedger = serde_json::from_str(&ledger_json)?;
+ ledger
+ .validate(run_id)
+ .map_err(EventStoreError::InvalidState)?;
+ let effect = ledger
+ .current()
+ .filter(|effect| effect.tool_invocation_id == tool_invocation_id)
+ .cloned()
+ .ok_or_else(|| {
+ EventStoreError::InvalidState(
+ "automation effect projection does not match its ledger".to_string(),
+ )
+ })?;
+ if effect.state != AutomationEffectState::EffectStarted {
+ return Err(EventStoreError::InvalidState(
+ "automation effect is not awaiting its first outcome".to_string(),
+ ));
+ }
+ let owner_id = owner_id.ok_or_else(|| {
+ EventStoreError::InvalidState("automation effect lease owner is missing".to_string())
+ })?;
+ let fence_token = fence_token
+ .ok_or_else(|| {
+ EventStoreError::InvalidState("automation effect fence is missing".to_string())
+ })
+ .and_then(|value| Uuid::parse_str(&value).map_err(Into::into))?;
+ let lease_expires_at = expires_at
+ .ok_or_else(|| {
+ EventStoreError::InvalidState(
+ "automation effect lease expiry is missing".to_string(),
+ )
+ })
+ .and_then(|value| {
+ DateTime::parse_from_rfc3339(&value)
+ .map(|value| value.with_timezone(&Utc))
+ .map_err(Into::into)
+ })?;
+ if lease_expires_at <= now
+ || owner_id != format!("automation-effect:{tool_invocation_id}")
+ || fence_token != effect.last_fence_token
+ || u64::try_from(generation).ok() != Some(effect.last_lease_generation)
+ {
+ return Err(EventStoreError::InvalidState(
+ "automation effect fence is stale or expired".to_string(),
+ ));
+ }
+ Ok(Some(AutomationEffectExecution {
+ lease: AutomationRunLease {
+ run_id,
+ owner_id,
+ fence_token,
+ generation: effect.last_lease_generation,
+ lease_expires_at,
+ },
+ checkpoint_revision: u64::try_from(revision).map_err(|_| {
+ EventStoreError::InvalidState(
+ "automation effect checkpoint revision is invalid".to_string(),
+ )
+ })?,
+ effect,
+ }))
+ }
+
+ fn persist_automation_effect_transition(
+ &self,
+ execution: &AutomationEffectExecution,
+ effect: &AutomationEffectRecord,
+ now: DateTime,
+ ) -> EventStoreResult {
+ effect.validate().map_err(EventStoreError::InvalidState)?;
+ if effect.automation_run_id != execution.lease.run_id
+ || effect.tool_invocation_id != execution.effect.tool_invocation_id
+ || effect.idempotency_key != execution.effect.idempotency_key
+ || effect.prepared_checkpoint_revision != execution.effect.prepared_checkpoint_revision
+ || effect.origin_fence_token != execution.effect.origin_fence_token
+ || effect.last_fence_token != execution.lease.fence_token
+ {
+ return Err(EventStoreError::InvalidState(
+ "automation effect transition changed immutable authority".to_string(),
+ ));
+ }
+ let mut ledger = self
+ .automation_effect_ledger(effect.automation_run_id)?
+ .ok_or_else(|| {
+ EventStoreError::InvalidState("automation effect ledger is missing".to_string())
+ })?;
+ let current = ledger.current_mut().ok_or_else(|| {
+ EventStoreError::InvalidState("automation effect ledger is empty".to_string())
+ })?;
+ if current != &execution.effect {
+ return Err(EventStoreError::InvalidState(
+ "automation effect transition raced with another reconciler".to_string(),
+ ));
+ }
+ *current = effect.clone();
+ ledger
+ .validate(effect.automation_run_id)
+ .map_err(EventStoreError::InvalidState)?;
+ let revision = execution
+ .checkpoint_revision
+ .checked_add(1)
+ .ok_or_else(|| {
+ EventStoreError::InvalidState(
+ "automation effect checkpoint revision is exhausted".to_string(),
+ )
+ })?;
+ let checkpoint = AutomationCheckpoint {
+ automation_run_id: effect.automation_run_id,
+ dedup_key: self
+ .automation_run(effect.automation_run_id)?
+ .trigger_window_key,
+ tool_invocation_id: Some(effect.tool_invocation_id),
+ evidence_ref: effect.evidence_ref.clone(),
+ recorded_at: now,
+ };
+ let transaction = Transaction::new_unchecked(&self.conn, TransactionBehavior::Immediate)?;
+ let changed = transaction.execute(
+ r#"UPDATE automation_checkpoints
+ SET checkpoint_json = ?2, revision = ?3, lease_generation = ?4,
+ effect_ledger_json = ?5, effect_state = ?6,
+ next_reconciliation_at = ?7, reconciliation_attempt_count = ?8,
+ effect_dead_letter_code = ?9, effect_quarantine_code = NULL,
+ updated_at = ?10
+ WHERE automation_run_id = ?1 AND revision = ?11
+ AND definition_revision = ?12 AND agent_run_id = ?13
+ AND effect_tool_invocation_id = ?14 AND effect_idempotency_key = ?15
+ AND EXISTS (
+ SELECT 1 FROM automation_runs AS run
+ WHERE run.id = ?1 AND run.lease_owner_id = ?16
+ AND run.lease_fence_token = ?17 AND run.lease_generation = ?4
+ AND run.lease_expires_at > ?10
+ )"#,
+ params![
+ effect.automation_run_id.to_string(),
+ serde_json::to_string(&checkpoint)?,
+ automation_revision_i64(revision)?,
+ automation_revision_i64(execution.lease.generation)?,
+ serde_json::to_string(&ledger)?,
+ serde_json::to_string(&effect.state)?,
+ effect
+ .next_reconciliation_at
+ .map(|value| value.to_rfc3339_opts(SecondsFormat::Nanos, true)),
+ i64::from(effect.reconciliation_attempt_count),
+ (effect.state == AutomationEffectState::DeadLettered)
+ .then(|| effect.safe_reason_code.clone())
+ .flatten(),
+ now.to_rfc3339_opts(SecondsFormat::Nanos, true),
+ automation_revision_i64(execution.checkpoint_revision)?,
+ automation_revision_i64(effect.definition_revision)?,
+ effect.agent_run_id.to_string(),
+ effect.tool_invocation_id.to_string(),
+ &effect.idempotency_key,
+ execution.lease.owner_id(),
+ execution.lease.fence_token.to_string(),
+ ],
+ )?;
+ if changed != 1 {
+ return Err(EventStoreError::InvalidState(
+ "automation effect transition lost its fence or revision".to_string(),
+ ));
+ }
+ transaction.commit()?;
+ Ok(revision)
+ }
+
+ fn classify_automation_effect(
+ &self,
+ effect: &AutomationEffectRecord,
+ ) -> EventStoreResult {
+ match self.workspace_mutation_checkpoint_for_invocation(effect.tool_invocation_id) {
+ Ok(checkpoint) => {
+ if checkpoint.run_id != Some(effect.agent_run_id) {
+ return Ok(DurableAutomationEffectOutcome::StillUncertain {
+ reason_code: "workspace_checkpoint_run_mismatch".to_string(),
+ });
+ }
+ return Ok(match (checkpoint.status, checkpoint.effect_state) {
+ (
+ WorkspaceMutationCheckpointStatus::Ready,
+ WorkspaceCheckpointEffectState::KnownApplied,
+ ) if checkpoint.action_revision().is_some() => {
+ DurableAutomationEffectOutcome::KnownApplied {
+ evidence_ref: format!(
+ "workspace-checkpoint://{}/known-applied",
+ checkpoint.id
+ ),
+ undo_capability: AutomationEffectUndoCapability::ExactLocal,
+ workspace_checkpoint_id: Some(checkpoint.id),
+ workspace_action_revision: checkpoint.action_revision(),
+ }
+ }
+ (
+ WorkspaceMutationCheckpointStatus::Ready
+ | WorkspaceMutationCheckpointStatus::NotUndoable
+ | WorkspaceMutationCheckpointStatus::Undone,
+ WorkspaceCheckpointEffectState::KnownApplied,
+ ) => DurableAutomationEffectOutcome::KnownApplied {
+ evidence_ref: format!(
+ "workspace-checkpoint://{}/known-applied",
+ checkpoint.id
+ ),
+ undo_capability: AutomationEffectUndoCapability::None,
+ workspace_checkpoint_id: None,
+ workspace_action_revision: None,
+ },
+ (
+ WorkspaceMutationCheckpointStatus::Failed,
+ WorkspaceCheckpointEffectState::NoEffect,
+ ) => DurableAutomationEffectOutcome::KnownNotApplied {
+ evidence_ref: format!(
+ "workspace-checkpoint://{}/known-not-applied",
+ checkpoint.id
+ ),
+ },
+ _ => DurableAutomationEffectOutcome::StillUncertain {
+ reason_code: "workspace_effect_requires_repair".to_string(),
+ },
+ });
+ }
+ Err(EventStoreError::NotFound(_)) => {}
+ Err(error) => return Err(error),
+ }
+
+ if let Some(connector) = self
+ .list_connector_invocations()?
+ .into_iter()
+ .find(|invocation| invocation.tool_invocation_id == Some(effect.tool_invocation_id))
+ {
+ return Ok(match connector.status {
+ ConnectorInvocationStatus::Succeeded => {
+ DurableAutomationEffectOutcome::KnownApplied {
+ evidence_ref: format!("connector-effect://{}/known-applied", connector.id),
+ undo_capability: AutomationEffectUndoCapability::UnsupportedExternal,
+ workspace_checkpoint_id: None,
+ workspace_action_revision: None,
+ }
+ }
+ ConnectorInvocationStatus::Failed => {
+ DurableAutomationEffectOutcome::KnownNotApplied {
+ evidence_ref: format!(
+ "connector-effect://{}/known-not-applied",
+ connector.id
+ ),
+ }
+ }
+ ConnectorInvocationStatus::PendingApproval
+ | ConnectorInvocationStatus::Running
+ | ConnectorInvocationStatus::ReconciliationRequired => {
+ DurableAutomationEffectOutcome::StillUncertain {
+ reason_code: "connector_effect_requires_reconciliation".to_string(),
+ }
+ }
+ });
+ }
+
+ let computer_use_status = self
+ .conn
+ .query_row(
+ r#"SELECT step.status
+ FROM computer_use_sessions AS session
+ JOIN computer_use_steps AS step ON step.id = session.active_step_id
+ WHERE session.run_id = ?1 AND session.quarantine_code IS NULL
+ AND step.quarantine_code IS NULL
+ ORDER BY session.updated_at DESC, session.rowid DESC LIMIT 1"#,
+ params![effect.agent_run_id.to_string()],
+ |row| row.get::<_, String>(0),
+ )
+ .optional()?;
+ if let Some(status) = computer_use_status {
+ return Ok(match status.as_str() {
+ "verified" => DurableAutomationEffectOutcome::KnownApplied {
+ evidence_ref: format!(
+ "computer-use-effect://{}/known-applied",
+ effect.tool_invocation_id
+ ),
+ undo_capability: AutomationEffectUndoCapability::UnsupportedExternal,
+ workspace_checkpoint_id: None,
+ workspace_action_revision: None,
+ },
+ "observed" | "awaiting_approval" | "ready" | "needs_replan" | "cancelled" => {
+ DurableAutomationEffectOutcome::KnownNotApplied {
+ evidence_ref: format!(
+ "computer-use-effect://{}/known-not-applied",
+ effect.tool_invocation_id
+ ),
+ }
+ }
+ _ => DurableAutomationEffectOutcome::StillUncertain {
+ reason_code: "computer_use_effect_requires_reconciliation".to_string(),
+ },
+ });
+ }
+
+ let invocation = self.tool_invocation_by_id(effect.tool_invocation_id)?;
+ if invocation.request_fingerprint != effect.request_fingerprint
+ || invocation.run_id != Some(effect.agent_run_id)
+ {
+ return Ok(DurableAutomationEffectOutcome::StillUncertain {
+ reason_code: "tool_effect_binding_changed".to_string(),
+ });
+ }
+ if invocation.status == ToolExecutionStatus::Succeeded && invocation.verification.passed {
+ return Ok(DurableAutomationEffectOutcome::KnownApplied {
+ evidence_ref: format!("tool-effect://{}/verified", effect.tool_invocation_id),
+ undo_capability: if automation_capability_is_external(invocation.capability) {
+ AutomationEffectUndoCapability::UnsupportedExternal
+ } else {
+ AutomationEffectUndoCapability::None
+ },
+ workspace_checkpoint_id: None,
+ workspace_action_revision: None,
+ });
+ }
+ Ok(DurableAutomationEffectOutcome::StillUncertain {
+ reason_code: "tool_effect_requires_reconciliation".to_string(),
+ })
+ }
+
+ pub fn upsert_review_queue_item(&self, item: &ReviewQueueItem) -> EventStoreResult<()> {
+ let existing = self
+ .conn
+ .query_row(
+ "SELECT item_json FROM review_queue_items WHERE id = ?1",
+ params![item.id.to_string()],
+ |row| row.get::<_, String>(0),
+ )
+ .optional()?
+ .map(|json| serde_json::from_str::(&json))
+ .transpose()?;
+ if existing.as_ref() == Some(item) {
+ return Ok(());
+ }
+ if existing
+ .as_ref()
+ .is_some_and(|current| item.revision != current.revision.saturating_add(1))
+ {
+ return Err(EventStoreError::InvalidState(
+ "review item revision is stale".to_string(),
+ ));
+ }
+ let transaction = self.conn.unchecked_transaction()?;
+ let mut run = self.automation_run(item.automation_run_id)?;
+ if let Some(existing_id) = run.review_queue_item_id {
+ if existing_id != item.id {
+ return Err(EventStoreError::InvalidState(
+ "automation run already has a review queue item".to_string(),
+ ));
+ }
+ }
+ run.review_queue_item_id = Some(item.id);
+ run.updated_at = item.updated_at;
+ let changed = transaction.execute(
+ r#"INSERT INTO review_queue_items (id, automation_run_id, item_json, status, revision, updated_at)
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6)
+ ON CONFLICT(id) DO UPDATE SET
+ item_json = excluded.item_json,
+ status = excluded.status,
+ revision = excluded.revision,
+ updated_at = excluded.updated_at
+ WHERE review_queue_items.revision + 1 = excluded.revision"#,
+ params![
+ item.id.to_string(),
+ item.automation_run_id.to_string(),
+ serde_json::to_string(item)?,
+ serde_json::to_string(&item.status)?,
+ item.revision,
+ item.updated_at.to_rfc3339_opts(SecondsFormat::Nanos, true),
+ ],
+ )?;
+ if changed != 1 {
+ return Err(EventStoreError::InvalidState(
+ "review item update raced with another writer".to_string(),
+ ));
+ }
+ transaction.execute(
+ "UPDATE automation_runs SET run_json = ?2, updated_at = ?3 WHERE id = ?1",
+ params![
+ run.id.to_string(),
+ serde_json::to_string(&run)?,
+ run.updated_at.to_rfc3339_opts(SecondsFormat::Nanos, true),
+ ],
+ )?;
+ transaction.commit()?;
+ Ok(())
+ }
+
+ pub fn list_review_queue_items(&self) -> EventStoreResult> {
+ let mut statement = self.conn.prepare(
+ "SELECT item_json FROM review_queue_items ORDER BY updated_at ASC, rowid ASC",
+ )?;
+ let rows = statement
+ .query_map([], |row| row.get::<_, String>(0))?
+ .collect::, _>>()?;
+ rows.into_iter()
+ .map(|json| serde_json::from_str(&json).map_err(Into::into))
+ .collect()
+ }
+
+ pub fn review_queue_item(&self, id: Uuid) -> EventStoreResult {
+ let json = self.conn.query_row(
+ "SELECT item_json FROM review_queue_items WHERE id = ?1",
+ params![id.to_string()],
+ |row| row.get::<_, String>(0),
+ )?;
+ Ok(serde_json::from_str(&json)?)
+ }
+
+ pub fn edit_review_queue_item(
+ &self,
+ id: Uuid,
+ action_revision: &str,
+ title: String,
+ preview_fingerprint: Option,
+ changed_at: DateTime,
+ ) -> EventStoreResult {
+ let mut item = self.review_queue_item(id)?;
+ item.validate_action_revision(action_revision)
+ .map_err(EventStoreError::InvalidState)?;
+ let previous_revision = item.revision;
+ let resolution = self.review_tool_approval_invalidation(&item, "Review preview changed")?;
+ item.edit(title, preview_fingerprint, changed_at)
+ .map_err(EventStoreError::InvalidState)?;
+ self.persist_review_item_transition(&item, previous_revision, resolution)?;
+ Ok(item)
+ }
+
+ pub fn resolve_review_queue_item(
+ &self,
+ id: Uuid,
+ action_revision: &str,
+ accepted: bool,
+ changed_at: DateTime,
+ ) -> EventStoreResult {
+ let mut item = self.review_queue_item(id)?;
+ item.validate_action_revision(action_revision)
+ .map_err(EventStoreError::InvalidState)?;
+ let previous_revision = item.revision;
+ let resolution = (!accepted)
+ .then(|| self.review_tool_approval_invalidation(&item, "Review item rejected"))
+ .transpose()?
+ .flatten();
+ item.resolve(accepted, changed_at)
+ .map_err(EventStoreError::InvalidState)?;
+ self.persist_review_item_transition(&item, previous_revision, resolution)?;
+ Ok(item)
+ }
+
+ fn review_tool_approval_invalidation(
+ &self,
+ item: &ReviewQueueItem,
+ note: &str,
+ ) -> EventStoreResult> {
+ let Some(tool_invocation_id) = item.tool_invocation_id else {
+ return Ok(None);
+ };
+ let approval_request_id = self
+ .list_tool_invocations()?
+ .into_iter()
+ .find(|record| record.id == tool_invocation_id)
+ .and_then(|record| record.approval_request_id);
+ Ok(approval_request_id
+ .map(|request_id| PermissionResolution::new(request_id, false, note.to_string())))
+ }
+
+ fn persist_review_item_transition(
+ &self,
+ item: &ReviewQueueItem,
+ previous_revision: u32,
+ resolution: Option,
+ ) -> EventStoreResult<()> {
+ let mut run = self.automation_run(item.automation_run_id)?;
+ if run.review_queue_item_id != Some(item.id) {
+ return Err(EventStoreError::InvalidState(
+ "automation run is not linked to this review item".to_string(),
+ ));
+ }
+ run.updated_at = item.updated_at;
+ let resolution_event = resolution
+ .as_ref()
+ .map(|value| KernelEvent::new(PERMISSION_RESOLUTION_RECORDED_EVENT, value))
+ .transpose()?;
+ let transaction = self.conn.unchecked_transaction()?;
+ if let Some(event) = resolution_event {
+ Self::insert_kernel_event(&transaction, &event)?;
+ }
+ let changed = transaction.execute(
+ r#"UPDATE review_queue_items
+ SET item_json = ?2, status = ?3, revision = ?4, updated_at = ?5
+ WHERE id = ?1 AND revision = ?6"#,
+ params![
+ item.id.to_string(),
+ serde_json::to_string(item)?,
+ serde_json::to_string(&item.status)?,
+ item.revision,
+ item.updated_at.to_rfc3339_opts(SecondsFormat::Nanos, true),
+ previous_revision,
+ ],
+ )?;
+ if changed != 1 {
+ return Err(EventStoreError::InvalidState(
+ "review item transition raced with another writer".to_string(),
+ ));
+ }
+ transaction.execute(
+ "UPDATE automation_runs SET run_json = ?2, updated_at = ?3 WHERE id = ?1",
+ params![
+ run.id.to_string(),
+ serde_json::to_string(&run)?,
+ run.updated_at.to_rfc3339_opts(SecondsFormat::Nanos, true),
+ ],
+ )?;
+ transaction.commit()?;
+ Ok(())
+ }
+
+ pub fn automation_checkpoint(
+ &self,
+ automation_run_id: Uuid,
+ ) -> EventStoreResult {
+ let json = self.conn.query_row(
+ "SELECT checkpoint_json FROM automation_checkpoints WHERE automation_run_id = ?1",
+ params![automation_run_id.to_string()],
+ |row| row.get::<_, String>(0),
+ )?;
+ Ok(serde_json::from_str(&json)?)
+ }
+
+ pub(crate) fn automation_checkpoint_record(
+ &self,
+ automation_run_id: Uuid,
+ ) -> EventStoreResult {
+ let (
+ checkpoint_json,
+ contract_version,
+ revision,
+ definition_revision,
+ agent_run_id,
+ lease_generation,
+ ) = self.conn.query_row(
+ r#"SELECT checkpoint_json, contract_version, revision,
+ definition_revision, agent_run_id, lease_generation
+ FROM automation_checkpoints WHERE automation_run_id = ?1"#,
+ params![automation_run_id.to_string()],
+ |row| {
+ Ok((
+ row.get::<_, String>(0)?,
+ row.get::<_, Option>(1)?,
+ row.get::<_, i64>(2)?,
+ row.get::<_, Option>(3)?,
+ row.get::<_, Option>(4)?,
+ row.get::<_, Option>(5)?,
+ ))
+ },
+ )?;
+ let contract_version =
+ contract_version.filter(|value| value == AUTOMATION_CHECKPOINT_CONTRACT_VERSION);
+ let (contract_version, definition_revision, agent_run_id, lease_generation) = match (
+ contract_version,
+ definition_revision,
+ agent_run_id,
+ lease_generation,
+ ) {
+ (
+ Some(contract_version),
+ Some(definition_revision),
+ Some(agent_run_id),
+ Some(lease_generation),
+ ) => (
+ contract_version,
+ u64::try_from(definition_revision),
+ Uuid::parse_str(&agent_run_id),
+ u64::try_from(lease_generation),
+ ),
+ _ => {
+ return Err(EventStoreError::InvalidState(
+ "automation checkpoint exact binding is missing".to_string(),
+ ))
+ }
+ };
+ Ok(AutomationCheckpointRecord {
+ contract_version,
+ revision: u64::try_from(revision).map_err(|_| {
+ EventStoreError::InvalidState(
+ "automation checkpoint revision is invalid".to_string(),
+ )
+ })?,
+ definition_revision: definition_revision.map_err(|_| {
+ EventStoreError::InvalidState(
+ "automation checkpoint definition revision is invalid".to_string(),
+ )
+ })?,
+ agent_run_id: agent_run_id?,
+ lease_generation: lease_generation.map_err(|_| {
+ EventStoreError::InvalidState(
+ "automation checkpoint lease generation is invalid".to_string(),
+ )
+ })?,
+ checkpoint: serde_json::from_str(&checkpoint_json)?,
+ })
+ }
+
+ fn persist_automation_run(&self, run: &AutomationRun) -> EventStoreResult<()> {
+ let changed = self.conn.execute(
+ r#"UPDATE automation_runs
+ SET run_json = ?2, status = ?3, definition_revision = ?4,
+ claimed_by = ?5, updated_at = ?6
+ WHERE id = ?1 AND lease_fence_token IS NULL AND lease_generation = 0"#,
+ params![
+ run.id.to_string(),
+ serde_json::to_string(run)?,
+ serde_json::to_string(&run.status)?,
+ i64::try_from(run.definition_revision).map_err(|_| {
+ EventStoreError::InvalidState(
+ "automation run definition revision is too large".to_string(),
+ )
+ })?,
+ run.claimed_by,
+ run.updated_at.to_rfc3339_opts(SecondsFormat::Nanos, true),
+ ],
+ )?;
+ if changed != 1 {
+ return Err(EventStoreError::InvalidState(
+ "automation run requires its current fenced owner".to_string(),
+ ));
+ }
+ Ok(())
+ }
+
+ pub fn upsert_connector_account(&self, account: &ConnectorAccount) -> EventStoreResult<()> {
+ let transaction = self.conn.unchecked_transaction()?;
+ let existing = transaction
+ .query_row(
+ "SELECT account_json FROM connector_accounts WHERE id = ?1",
+ params![account.id.to_string()],
+ |row| row.get::<_, String>(0),
+ )
+ .optional()?
+ .map(|json| serde_json::from_str::(&json))
+ .transpose()?;
+ if existing.as_ref().is_some_and(|stored| {
+ matches!(
+ stored.health,
+ ConnectorHealth::DisconnectPending | ConnectorHealth::RevocationPending
+ )
+ }) {
+ return Err(EventStoreError::InvalidState(
+ "connector pending transition requires its dedicated recovery path".to_string(),
+ ));
+ }
+ transaction.execute(
+ r#"INSERT OR IGNORE INTO connector_account_generations (account_id, generation)
+ VALUES (?1, 0)"#,
+ params![account.id.to_string()],
+ )?;
+ if existing.as_ref().is_some_and(|stored| {
+ stored.provider_id != account.provider_id
+ || stored.tenant_ref != account.tenant_ref
+ || stored.credential_handle != account.credential_handle
+ || stored.granted_capabilities != account.granted_capabilities
+ }) {
+ transaction.execute(
+ "UPDATE connector_account_generations SET generation = generation + 1 WHERE account_id = ?1",
+ params![account.id.to_string()],
+ )?;
+ transaction.execute(
+ "DELETE FROM connector_sync_projection WHERE account_id = ?1",
+ params![account.id.to_string()],
+ )?;
+ transaction.execute(
+ "DELETE FROM connector_sync_streams WHERE account_id = ?1",
+ params![account.id.to_string()],
+ )?;
+ }
+ transaction.execute(
+ r#"INSERT INTO connector_accounts (id, provider_id, account_json, health, updated_at)
+ VALUES (?1, ?2, ?3, ?4, ?5)
+ ON CONFLICT(id) DO UPDATE SET
+ provider_id = excluded.provider_id,
+ account_json = excluded.account_json,
+ health = excluded.health,
+ updated_at = excluded.updated_at"#,
+ params![
+ account.id.to_string(),
+ account.provider_id,
+ serde_json::to_string(account)?,
+ serde_json::to_string(&account.health)?,
+ account
+ .updated_at
+ .to_rfc3339_opts(SecondsFormat::Nanos, true),
+ ],
+ )?;
+ transaction.commit()?;
+ Ok(())
+ }
+
+ pub fn connector_account_sync_generation(
+ &self,
+ account: &ConnectorAccount,
+ ) -> EventStoreResult {
+ let (account_json, generation) = self.conn.query_row(
+ r#"SELECT account.account_json, generation.generation
+ FROM connector_accounts AS account
+ JOIN connector_account_generations AS generation
+ ON generation.account_id = account.id
+ WHERE account.id = ?1 AND account.health = ?2"#,
+ params![
+ account.id.to_string(),
+ serde_json::to_string(&ConnectorHealth::Connected)?,
+ ],
+ |row| Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?)),
+ )?;
+ let stored: ConnectorAccount = serde_json::from_str(&account_json)?;
+ if stored.provider_id != account.provider_id
+ || stored.tenant_ref != account.tenant_ref
+ || stored.credential_handle != account.credential_handle
+ || stored.granted_capabilities != account.granted_capabilities
+ || stored.health != ConnectorHealth::Connected
+ {
+ return Err(EventStoreError::InvalidState(
+ "connector account binding changed".to_string(),
+ ));
+ }
+ u64::try_from(generation).map_err(|_| {
+ EventStoreError::InvalidState("connector account generation is invalid".to_string())
+ })
+ }
+
+ pub fn mark_connector_account_needs_repair(
+ &self,
+ account: &ConnectorAccount,
+ expected_generation: u64,
+ ) -> EventStoreResult<()> {
+ if account.health != ConnectorHealth::NeedsRepair {
+ return Err(EventStoreError::InvalidState(
+ "connector repair transition requires needs-repair state".to_string(),
+ ));
+ }
+ let changed = self.conn.execute(
+ r#"UPDATE connector_accounts
+ SET provider_id = ?2, account_json = ?3, health = ?4, updated_at = ?5
+ WHERE id = ?1 AND health = ?6 AND EXISTS (
+ SELECT 1 FROM connector_account_generations
+ WHERE account_id = ?1 AND generation = ?7
+ )"#,
+ params![
+ account.id.to_string(),
+ account.provider_id,
+ serde_json::to_string(account)?,
+ serde_json::to_string(&account.health)?,
+ account
+ .updated_at
+ .to_rfc3339_opts(SecondsFormat::Nanos, true),
+ serde_json::to_string(&ConnectorHealth::Connected)?,
+ i64::try_from(expected_generation).map_err(|_| {
+ EventStoreError::InvalidState(
+ "connector account generation is too large".to_string(),
+ )
+ })?,
+ ],
+ )?;
+ if changed != 1 {
+ return Err(EventStoreError::InvalidState(
+ "connector account changed during repair transition".to_string(),
+ ));
+ }
+ Ok(())
+ }
+
+ pub fn begin_connector_disconnect(
+ &self,
+ account_id: Uuid,
+ now: DateTime,
+ ) -> EventStoreResult {
+ let transaction = self.conn.unchecked_transaction()?;
+ let (json, generation) = transaction
+ .query_row(
+ r#"SELECT account.account_json, generation.generation
+ FROM connector_accounts AS account
+ JOIN connector_account_generations AS generation
+ ON generation.account_id = account.id
+ WHERE account.id = ?1"#,
+ params![account_id.to_string()],
+ |row| Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?)),
+ )
+ .optional()?
+ .ok_or_else(|| EventStoreError::NotFound("connector account".to_string()))?;
+ let mut account: ConnectorAccount = serde_json::from_str(&json)?;
+ let generation = u64::try_from(generation).map_err(|_| {
+ EventStoreError::InvalidState("connector account generation is invalid".to_string())
+ })?;
+ if account.health == ConnectorHealth::DisconnectPending {
+ return Ok(ConnectorDisconnectTicket::new(account, generation));
+ }
+ if account.health == ConnectorHealth::RevocationPending {
+ return Err(EventStoreError::InvalidState(
+ "connector revocation must be reconciled before local disconnect".to_string(),
+ ));
+ }
+ if account.health == ConnectorHealth::Disconnected {
+ return Err(EventStoreError::InvalidState(
+ "connector account is already disconnected".to_string(),
+ ));
+ }
+ let previous_health = account.health;
+ let next_generation = generation.checked_add(1).ok_or_else(|| {
+ EventStoreError::InvalidState("connector account generation overflowed".to_string())
+ })?;
+ account.health = ConnectorHealth::DisconnectPending;
+ account.updated_at = now;
+ let generation_changed = transaction.execute(
+ r#"UPDATE connector_account_generations SET generation = ?2
+ WHERE account_id = ?1 AND generation = ?3"#,
+ params![
+ account_id.to_string(),
+ i64::try_from(next_generation).map_err(|_| {
+ EventStoreError::InvalidState(
+ "connector account generation is too large".to_string(),
+ )
+ })?,
+ i64::try_from(generation).map_err(|_| {
+ EventStoreError::InvalidState(
+ "connector account generation is too large".to_string(),
+ )
+ })?,
+ ],
+ )?;
+ if generation_changed != 1 {
+ return Err(EventStoreError::InvalidState(
+ "connector disconnect raced with another account transition".to_string(),
+ ));
+ }
+ transaction.execute(
+ r#"UPDATE connector_attachment_landings
+ SET status = 'cleanup_required', failure_kind = 'account_disconnected', updated_at = ?3
+ WHERE account_id = ?1 AND account_generation = ?2
+ AND status IN ('reserved', 'staging', 'ready')"#,
+ params![
+ account_id.to_string(),
+ i64::try_from(generation).map_err(|_| EventStoreError::InvalidState(
+ "connector account generation is too large".to_string()
+ ))?,
+ now.to_rfc3339_opts(SecondsFormat::Nanos, true),
+ ],
+ )?;
+ transaction.execute(
+ "DELETE FROM connector_sync_projection WHERE account_id = ?1",
+ params![account_id.to_string()],
+ )?;
+ transaction.execute(
+ "DELETE FROM connector_sync_streams WHERE account_id = ?1",
+ params![account_id.to_string()],
+ )?;
+ let changed = transaction.execute(
+ r#"UPDATE connector_accounts
+ SET account_json = ?2, health = ?3, updated_at = ?4
+ WHERE id = ?1 AND health = ?5"#,
+ params![
+ account_id.to_string(),
+ serde_json::to_string(&account)?,
+ serde_json::to_string(&account.health)?,
+ now.to_rfc3339_opts(SecondsFormat::Nanos, true),
+ serde_json::to_string(&previous_health)?,
+ ],
+ )?;
+ if changed != 1 {
+ return Err(EventStoreError::InvalidState(
+ "connector disconnect could not be started".to_string(),
+ ));
+ }
+ let receipt = ConnectorDisconnectReceipt {
+ account_id,
+ provider_id: account.provider_id.clone(),
+ generation: next_generation,
+ phase: ConnectorDisconnectPhase::Started,
+ source: ConnectorDisconnectSource::User,
+ credential_delete_outcome: None,
+ changed_at: now,
+ };
+ let event = KernelEvent::new("connector.disconnect.started", &receipt)?;
+ transaction.execute(
+ r#"INSERT INTO kernel_events (id, event_type, payload_json, created_at)
+ VALUES (?1, ?2, ?3, ?4)"#,
+ params![
+ event.id.to_string(),
+ event.event_type,
+ event.payload_json,
+ event.created_at.to_rfc3339_opts(SecondsFormat::Nanos, true),
+ ],
+ )?;
+ transaction.commit()?;
+ let _ = self.terminalize_connector_attachment_cleanup_tools_for_account_generation(
+ account_id, generation, now,
+ );
+ Ok(ConnectorDisconnectTicket::new(account, next_generation))
+ }
+
+ fn terminalize_connector_attachment_cleanup_tools_for_account_generation(
+ &self,
+ account_id: Uuid,
+ generation: u64,
+ changed_at: DateTime,
+ ) -> EventStoreResult<()> {
+ let mut statement = self.conn.prepare(
+ r#"SELECT id FROM connector_attachment_landings
+ WHERE account_id = ?1 AND account_generation = ?2
+ AND status = 'cleanup_required'"#,
+ )?;
+ let landing_ids = statement
+ .query_map(
+ params![
+ account_id.to_string(),
+ i64::try_from(generation).map_err(|_| EventStoreError::InvalidState(
+ "connector account generation is too large".to_string()
+ ))?,
+ ],
+ |row| row.get::<_, String>(0),
+ )?
+ .collect::, _>>()?;
+ drop(statement);
+ for landing_id in landing_ids {
+ self.terminalize_connector_attachment_cleanup_tool(
+ Uuid::parse_str(&landing_id)?,
+ changed_at,
+ )?;
+ }
+ Ok(())
+ }
+
+ pub fn complete_connector_disconnect(
+ &self,
+ ticket: &ConnectorDisconnectTicket,
+ source: ConnectorDisconnectSource,
+ credential_delete_outcome: ConnectorCredentialDeleteOutcome,
+ now: DateTime,
+ ) -> EventStoreResult {
+ if ticket.account().health != ConnectorHealth::DisconnectPending {
+ return Err(EventStoreError::InvalidState(
+ "connector disconnect completion requires pending state".to_string(),
+ ));
+ }
+ let transaction = self.conn.unchecked_transaction()?;
+ let current_json = transaction
+ .query_row(
+ r#"SELECT account.account_json
+ FROM connector_accounts AS account
+ JOIN connector_account_generations AS generation
+ ON generation.account_id = account.id
+ WHERE account.id = ?1 AND account.health = ?2 AND generation.generation = ?3"#,
+ params![
+ ticket.account().id.to_string(),
+ serde_json::to_string(&ConnectorHealth::DisconnectPending)?,
+ i64::try_from(ticket.generation()).map_err(|_| {
+ EventStoreError::InvalidState(
+ "connector account generation is too large".to_string(),
+ )
+ })?,
+ ],
+ |row| row.get::<_, String>(0),
+ )
+ .optional()?
+ .ok_or_else(|| {
+ EventStoreError::InvalidState(
+ "connector disconnect state changed before completion".to_string(),
+ )
+ })?;
+ let mut current: ConnectorAccount = serde_json::from_str(¤t_json)?;
+ if current.provider_id != ticket.account().provider_id
+ || current.tenant_ref != ticket.account().tenant_ref
+ || current.credential_handle != ticket.account().credential_handle
+ || current.granted_capabilities != ticket.account().granted_capabilities
+ {
+ return Err(EventStoreError::InvalidState(
+ "connector disconnect account binding changed".to_string(),
+ ));
+ }
+ current.health = ConnectorHealth::Disconnected;
+ current.updated_at = now;
+ let changed = transaction.execute(
+ r#"UPDATE connector_accounts
+ SET account_json = ?2, health = ?3, updated_at = ?4
+ WHERE id = ?1 AND health = ?5 AND EXISTS (
+ SELECT 1 FROM connector_account_generations
+ WHERE account_id = ?1 AND generation = ?6
+ )"#,
+ params![
+ current.id.to_string(),
+ serde_json::to_string(¤t)?,
+ serde_json::to_string(¤t.health)?,
+ now.to_rfc3339_opts(SecondsFormat::Nanos, true),
+ serde_json::to_string(&ConnectorHealth::DisconnectPending)?,
+ i64::try_from(ticket.generation()).map_err(|_| {
+ EventStoreError::InvalidState(
+ "connector account generation is too large".to_string(),
+ )
+ })?,
+ ],
+ )?;
+ if changed != 1 {
+ return Err(EventStoreError::InvalidState(
+ "connector disconnect completion raced with another transition".to_string(),
+ ));
+ }
+ let receipt = ConnectorDisconnectReceipt {
+ account_id: current.id,
+ provider_id: current.provider_id.clone(),
+ generation: ticket.generation(),
+ phase: ConnectorDisconnectPhase::Completed,
+ source,
+ credential_delete_outcome: Some(credential_delete_outcome),
+ changed_at: now,
+ };
+ let event = KernelEvent::new("connector.disconnect.completed", &receipt)?;
+ transaction.execute(
+ r#"INSERT INTO kernel_events (id, event_type, payload_json, created_at)
+ VALUES (?1, ?2, ?3, ?4)"#,
+ params![
+ event.id.to_string(),
+ event.event_type,
+ event.payload_json,
+ event.created_at.to_rfc3339_opts(SecondsFormat::Nanos, true),
+ ],
+ )?;
+ transaction.commit()?;
+ Ok(current)
+ }
+
+ pub fn record_connector_disconnect_failure(
+ &self,
+ ticket: &ConnectorDisconnectTicket,
+ source: ConnectorDisconnectSource,
+ now: DateTime,
+ ) -> EventStoreResult<()> {
+ let transaction = self.conn.unchecked_transaction()?;
+ let mut pending = ticket.account().clone();
+ pending.updated_at = now;
+ let changed = transaction.execute(
+ r#"UPDATE connector_accounts SET account_json = ?3, updated_at = ?4
+ WHERE id = ?1 AND health = ?2 AND EXISTS (
+ SELECT 1 FROM connector_account_generations
+ WHERE account_id = ?1 AND generation = ?5
+ )"#,
+ params![
+ ticket.account().id.to_string(),
+ serde_json::to_string(&ConnectorHealth::DisconnectPending)?,
+ serde_json::to_string(&pending)?,
+ now.to_rfc3339_opts(SecondsFormat::Nanos, true),
+ i64::try_from(ticket.generation()).map_err(|_| {
+ EventStoreError::InvalidState(
+ "connector account generation is too large".to_string(),
+ )
+ })?,
+ ],
+ )?;
+ if changed != 1 {
+ return Err(EventStoreError::InvalidState(
+ "connector disconnect failure belongs to a stale transition".to_string(),
+ ));
+ }
+ let receipt = ConnectorDisconnectReceipt {
+ account_id: ticket.account().id,
+ provider_id: ticket.account().provider_id.clone(),
+ generation: ticket.generation(),
+ phase: ConnectorDisconnectPhase::CredentialDeleteFailed,
+ source,
+ credential_delete_outcome: None,
+ changed_at: now,
+ };
+ let event = KernelEvent::new("connector.disconnect.retry_required", &receipt)?;
+ transaction.execute(
+ r#"INSERT INTO kernel_events (id, event_type, payload_json, created_at)
+ VALUES (?1, ?2, ?3, ?4)"#,
+ params![
+ event.id.to_string(),
+ event.event_type,
+ event.payload_json,
+ event.created_at.to_rfc3339_opts(SecondsFormat::Nanos, true),
+ ],
+ )?;
+ transaction.commit()?;
+ Ok(())
+ }
+
+ pub fn list_pending_connector_disconnects(
+ &self,
+ limit: usize,
+ ) -> EventStoreResult> {
+ if limit == 0 || limit > 100 {
+ return Err(EventStoreError::InvalidState(
+ "connector disconnect recovery limit is invalid".to_string(),
+ ));
+ }
+ let mut statement = self.conn.prepare(
+ r#"SELECT account.account_json, generation.generation
+ FROM connector_accounts AS account
+ JOIN connector_account_generations AS generation
+ ON generation.account_id = account.id
+ WHERE account.health = ?1
+ ORDER BY account.updated_at ASC, account.rowid ASC
+ LIMIT ?2"#,
+ )?;
+ let tickets = statement
+ .query_map(
+ params![
+ serde_json::to_string(&ConnectorHealth::DisconnectPending)?,
+ i64::try_from(limit).map_err(|_| {
+ EventStoreError::InvalidState(
+ "connector disconnect recovery limit is invalid".to_string(),
+ )
+ })?,
+ ],
+ |row| Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?)),
+ )?
+ .map(|row| {
+ let (json, generation) = row?;
+ let account = serde_json::from_str(&json)?;
+ let generation = u64::try_from(generation).map_err(|_| {
+ EventStoreError::InvalidState(
+ "connector account generation is invalid".to_string(),
+ )
+ })?;
+ Ok(ConnectorDisconnectTicket::new(account, generation))
+ })
+ .collect();
+ tickets
+ }
+
+ pub fn list_connector_accounts(&self) -> EventStoreResult> {
+ let mut statement = self.conn.prepare(
+ "SELECT account_json FROM connector_accounts ORDER BY updated_at ASC, rowid ASC",
+ )?;
+ let rows = statement
+ .query_map([], |row| row.get::<_, String>(0))?
+ .collect::, _>>()?;
+ rows.into_iter()
+ .map(|json| serde_json::from_str(&json).map_err(Into::into))
+ .collect()
+ }
+
+ pub fn list_connector_recovery_items(&self) -> EventStoreResult> {
+ self.list_connector_recovery_items_with_registries(&EmptyConnectorReconcilerRegistry, None)
+ }
+
+ pub(crate) fn list_connector_recovery_items_with_registry(
+ &self,
+ registry: &dyn ConnectorReconcilerRegistry,
+ ) -> EventStoreResult> {
+ self.list_connector_recovery_items_with_registries(registry, None)
+ }
+
+ pub(crate) fn list_connector_recovery_items_with_runtime_registries(
+ &self,
+ registry: &dyn ConnectorReconcilerRegistry,
+ sync_registry: &dyn ConnectorSyncRegistry,
+ ) -> EventStoreResult> {
+ self.list_connector_recovery_items_with_registries(registry, Some(sync_registry))
+ }
+
+ fn list_connector_recovery_items_with_registries(
+ &self,
+ registry: &dyn ConnectorReconcilerRegistry,
+ sync_registry: Option<&dyn ConnectorSyncRegistry>,
+ ) -> EventStoreResult> {
+ let mut items = Vec::new();
+
+ let mut attachment_statement = self.conn.prepare(
+ r#"SELECT id, metadata_json, failure_kind, workspace_root,
+ workspace_identity, storage_identity, updated_at, recovery_revision
+ FROM connector_attachment_landings
+ WHERE status = 'repair_required'
+ ORDER BY updated_at DESC, id ASC LIMIT 100"#,
+ )?;
+ let attachment_rows = attachment_statement
+ .query_map([], |row| {
+ Ok((
+ row.get::<_, String>(0)?,
+ row.get::<_, String>(1)?,
+ row.get::<_, Option>(2)?,
+ row.get::<_, Option>(3)?,
+ row.get::<_, Option>(4)?,
+ row.get::<_, Option>(5)?,
+ row.get::<_, String>(6)?,
+ row.get::<_, i64>(7)?,
+ ))
+ })?
+ .collect::, _>>()?;
+ drop(attachment_statement);
+ for (
+ id,
+ metadata_json,
+ failure_kind,
+ workspace_root,
+ workspace_identity,
+ storage_identity,
+ updated_at,
+ recovery_revision,
+ ) in attachment_rows
+ {
+ let metadata: ConnectorAttachmentMetadata = serde_json::from_str(&metadata_json)?;
+ let reason_code = match failure_kind.as_deref() {
+ Some("legacy_workspace_unbound") => {
+ ConnectorRecoveryReasonCode::AttachmentLegacyWorkspaceUnbound
+ }
+ Some("legacy_completed_unverified") => {
+ ConnectorRecoveryReasonCode::AttachmentLegacyReceiptIncomplete
+ }
+ Some("retention_identity_conflict") => {
+ ConnectorRecoveryReasonCode::AttachmentRetentionIdentityChanged
+ }
+ Some("ready_identity_conflict")
+ | Some("ready_cleanup_identity_conflict")
+ | Some("unsafe_cleanup_boundary") => {
+ ConnectorRecoveryReasonCode::AttachmentStoredIdentityChanged
+ }
+ Some("recovery_projection_unavailable") => {
+ ConnectorRecoveryReasonCode::AttachmentExecutionRecordIncomplete
}
_ => ConnectorRecoveryReasonCode::AttachmentRecoveryRequired,
};
@@ -13714,1801 +15969,3429 @@ impl EventStore {
self.append(&event)
}
- pub fn append_soul_profile_update(
- &self,
- audit: &AgentSoulProfileUpdateAudit,
- ) -> EventStoreResult<()> {
- let event = KernelEvent::new(SOUL_PROFILE_UPDATED_EVENT, audit)?;
+ pub fn append_soul_profile_update(
+ &self,
+ audit: &AgentSoulProfileUpdateAudit,
+ ) -> EventStoreResult<()> {
+ let event = KernelEvent::new(SOUL_PROFILE_UPDATED_EVENT, audit)?;
+ self.append(&event)
+ }
+
+ pub fn list_soul_profile_updates(&self) -> EventStoreResult> {
+ self.list_by_type(SOUL_PROFILE_UPDATED_EVENT, 100)?
+ .into_iter()
+ .map(|event| serde_json::from_str(&event.payload_json).map_err(Into::into))
+ .collect()
+ }
+
+ pub fn list_agent_context_receipts(&self) -> EventStoreResult> {
+ let events = self.list_by_type(AGENT_CONTEXT_RECEIPT_RECORDED_EVENT, 100)?;
+ events
+ .into_iter()
+ .map(|event| {
+ serde_json::from_str::(&event.payload_json).map_err(Into::into)
+ })
+ .collect()
+ }
+
+ pub fn list_deepseek_chat_telemetry(&self) -> EventStoreResult> {
+ let events = self.list_by_type(DEEPSEEK_CHAT_TELEMETRY_RECORDED_EVENT, 500)?;
+ events
+ .into_iter()
+ .map(|event| {
+ serde_json::from_str::(&event.payload_json)
+ .map_err(Into::into)
+ })
+ .collect()
+ }
+
+ pub fn list_permission_audit_entries(&self) -> EventStoreResult> {
+ let events = self.list_by_type(PERMISSION_AUDIT_RECORDED_EVENT, 100)?;
+ events
+ .into_iter()
+ .map(|event| {
+ serde_json::from_str::(&event.payload_json)
+ .map_err(Into::into)
+ })
+ .collect()
+ }
+
+ pub fn append_skill_installation(
+ &self,
+ installation: &SkillInstallationRecord,
+ ) -> EventStoreResult {
+ let existing = self.list_skill_records()?.into_iter().any(|record| {
+ record.manifest.name == installation.manifest.name
+ && record.manifest.version == installation.manifest.version
+ && record.manifest.source.url == installation.manifest.source.url
+ });
+ if existing {
+ return Ok(false);
+ }
+
+ let event = KernelEvent::new(SKILL_INSTALLED_EVENT, installation)?;
+ self.append(&event)?;
+ Ok(true)
+ }
+
+ pub fn list_skill_installations(&self) -> EventStoreResult> {
+ let events = self.list_by_type(SKILL_INSTALLED_EVENT, 500)?;
+ events
+ .into_iter()
+ .map(|event| {
+ serde_json::from_str::(&event.payload_json)
+ .map_err(Into::into)
+ })
+ .collect()
+ }
+
+ pub fn append_skill_enablement_change(
+ &self,
+ change: &SkillEnablementChange,
+ ) -> EventStoreResult<()> {
+ let exists = self
+ .list_skill_installations()?
+ .into_iter()
+ .any(|record| record.id == change.skill_id);
+ if !exists {
+ return Err(EventStoreError::NotFound(format!(
+ "skill installation {}",
+ change.skill_id
+ )));
+ }
+
+ let event = KernelEvent::new(SKILL_ENABLEMENT_CHANGED_EVENT, change)?;
+ self.append(&event)
+ }
+
+ pub fn list_skill_enablement_changes(&self) -> EventStoreResult> {
+ let events = self.list_by_type(SKILL_ENABLEMENT_CHANGED_EVENT, 500)?;
+ events
+ .into_iter()
+ .map(|event| {
+ serde_json::from_str::(&event.payload_json)
+ .map_err(Into::into)
+ })
+ .collect()
+ }
+
+ pub fn append_skill_trust_reset(&self, reset: &SkillTrustReset) -> EventStoreResult<()> {
+ self.ensure_skill_installation_exists(reset.skill_id)?;
+ let event = KernelEvent::new(SKILL_TRUST_RESET_EVENT, reset)?;
+ self.append(&event)
+ }
+
+ pub fn list_skill_trust_resets(&self) -> EventStoreResult> {
+ let events = self.list_by_type(SKILL_TRUST_RESET_EVENT, 500)?;
+ events
+ .into_iter()
+ .map(|event| {
+ serde_json::from_str::(&event.payload_json).map_err(Into::into)
+ })
+ .collect()
+ }
+
+ pub fn append_skill_uninstall(&self, uninstall: &SkillUninstallRecord) -> EventStoreResult<()> {
+ self.ensure_skill_installation_exists(uninstall.skill_id)?;
+ let record = self
+ .list_skill_records()?
+ .into_iter()
+ .find(|record| record.id == uninstall.skill_id)
+ .ok_or_else(|| {
+ EventStoreError::NotFound(format!(
+ "active skill installation {}",
+ uninstall.skill_id
+ ))
+ })?;
+ if record.system_protected {
+ return Err(EventStoreError::InvalidState(format!(
+ "protected system skill cannot be uninstalled: {}",
+ record.manifest.name
+ )));
+ }
+ let event = KernelEvent::new(SKILL_UNINSTALLED_EVENT, uninstall)?;
+ self.append(&event)
+ }
+
+ pub fn list_skill_uninstalls(&self) -> EventStoreResult> {
+ let events = self.list_by_type(SKILL_UNINSTALLED_EVENT, 500)?;
+ events
+ .into_iter()
+ .map(|event| {
+ serde_json::from_str::(&event.payload_json)
+ .map_err(Into::into)
+ })
+ .collect()
+ }
+
+ pub fn append_skill_update(&self, update: &SkillUpdateRecord) -> EventStoreResult<()> {
+ self.ensure_skill_installation_exists(update.skill_id)?;
+ let current = self
+ .list_skill_records()?
+ .into_iter()
+ .find(|record| record.id == update.skill_id)
+ .ok_or_else(|| {
+ EventStoreError::NotFound(format!("active skill installation {}", update.skill_id))
+ })?;
+ if current.manifest.version != update.previous_version {
+ return Err(EventStoreError::InvalidState(format!(
+ "skill update expected version {}, current version is {}",
+ update.previous_version, current.manifest.version
+ )));
+ }
+ if current.manifest.name != update.manifest.name {
+ return Err(EventStoreError::InvalidState(
+ "skill update cannot change package identity".to_string(),
+ ));
+ }
+ if update.manifest.permissions.iter().any(|permission| {
+ !current
+ .manifest
+ .permissions
+ .iter()
+ .any(|current_permission| {
+ current_permission.kind == permission.kind
+ && current_permission.scope == permission.scope
+ })
+ }) {
+ return Err(EventStoreError::InvalidState(
+ "skill update cannot expand declared permissions automatically".to_string(),
+ ));
+ }
+ if sha256_hex(update.entry_content.as_bytes()) != update.entry_sha256 {
+ return Err(EventStoreError::InvalidState(
+ "skill update entry integrity mismatch".to_string(),
+ ));
+ }
+ if let (Some(current_source), Some(next_source)) =
+ (¤t.source_identity, &update.source_identity)
+ {
+ if current_source.provider != next_source.provider
+ || current_source.repository_url != next_source.repository_url
+ || current_source.package_path != next_source.package_path
+ {
+ return Err(EventStoreError::InvalidState(
+ "skill update cannot change canonical source identity".to_string(),
+ ));
+ }
+ }
+
+ let event = KernelEvent::new(SKILL_UPDATED_EVENT, update)?;
self.append(&event)
}
- pub fn list_soul_profile_updates(&self) -> EventStoreResult> {
- self.list_by_type(SOUL_PROFILE_UPDATED_EVENT, 100)?
- .into_iter()
- .map(|event| serde_json::from_str(&event.payload_json).map_err(Into::into))
- .collect()
- }
-
- pub fn list_agent_context_receipts(&self) -> EventStoreResult> {
- let events = self.list_by_type(AGENT_CONTEXT_RECEIPT_RECORDED_EVENT, 100)?;
+ pub fn list_skill_updates(&self) -> EventStoreResult> {
+ let events = self.list_by_type(SKILL_UPDATED_EVENT, 500)?;
events
.into_iter()
.map(|event| {
- serde_json::from_str::(&event.payload_json).map_err(Into::into)
+ serde_json::from_str::(&event.payload_json).map_err(Into::into)
})
.collect()
}
- pub fn list_deepseek_chat_telemetry(&self) -> EventStoreResult> {
- let events = self.list_by_type(DEEPSEEK_CHAT_TELEMETRY_RECORDED_EVENT, 500)?;
- events
- .into_iter()
- .map(|event| {
- serde_json::from_str::(&event.payload_json)
- .map_err(Into::into)
- })
- .collect()
+ pub fn append_skill_update_check(
+ &self,
+ check: &SkillUpdateCheckRecord,
+ ) -> EventStoreResult<()> {
+ self.ensure_skill_installation_exists(check.skill_id)?;
+ let event = KernelEvent::new(SKILL_UPDATE_CHECKED_EVENT, check)?;
+ self.append(&event)
}
- pub fn list_permission_audit_entries(&self) -> EventStoreResult> {
- let events = self.list_by_type(PERMISSION_AUDIT_RECORDED_EVENT, 100)?;
+ pub fn list_skill_update_checks(&self) -> EventStoreResult> {
+ let events = self.list_by_type(SKILL_UPDATE_CHECKED_EVENT, 500)?;
events
.into_iter()
.map(|event| {
- serde_json::from_str::(&event.payload_json)
+ serde_json::from_str::(&event.payload_json)
.map_err(Into::into)
})
.collect()
}
- pub fn append_skill_installation(
+ pub fn append_skill_update_failure(
&self,
- installation: &SkillInstallationRecord,
- ) -> EventStoreResult {
- let existing = self.list_skill_records()?.into_iter().any(|record| {
- record.manifest.name == installation.manifest.name
- && record.manifest.version == installation.manifest.version
- && record.manifest.source.url == installation.manifest.source.url
- });
- if existing {
- return Ok(false);
- }
-
- let event = KernelEvent::new(SKILL_INSTALLED_EVENT, installation)?;
- self.append(&event)?;
- Ok(true)
+ failure: &SkillUpdateFailureRecord,
+ ) -> EventStoreResult<()> {
+ self.ensure_skill_installation_exists(failure.skill_id)?;
+ let event = KernelEvent::new(SKILL_UPDATE_FAILED_EVENT, failure)?;
+ self.append(&event)
}
- pub fn list_skill_installations(&self) -> EventStoreResult> {
- let events = self.list_by_type(SKILL_INSTALLED_EVENT, 500)?;
+ pub fn list_skill_update_failures(&self) -> EventStoreResult> {
+ let events = self.list_by_type(SKILL_UPDATE_FAILED_EVENT, 500)?;
events
.into_iter()
.map(|event| {
- serde_json::from_str::(&event.payload_json)
+ serde_json::from_str::(&event.payload_json)
.map_err(Into::into)
})
.collect()
}
- pub fn append_skill_enablement_change(
- &self,
- change: &SkillEnablementChange,
- ) -> EventStoreResult<()> {
- let exists = self
- .list_skill_installations()?
- .into_iter()
- .any(|record| record.id == change.skill_id);
- if !exists {
- return Err(EventStoreError::NotFound(format!(
- "skill installation {}",
- change.skill_id
- )));
+ pub fn list_skill_records(&self) -> EventStoreResult> {
+ let mut latest_change_by_skill_id = std::collections::HashMap::new();
+ for change in self.list_skill_enablement_changes()? {
+ latest_change_by_skill_id
+ .entry(change.skill_id)
+ .or_insert(change);
}
- let event = KernelEvent::new(SKILL_ENABLEMENT_CHANGED_EVENT, change)?;
- self.append(&event)
- }
+ let mut latest_reset_by_skill_id = std::collections::HashMap::new();
+ for reset in self.list_skill_trust_resets()? {
+ latest_reset_by_skill_id
+ .entry(reset.skill_id)
+ .or_insert(reset);
+ }
- pub fn list_skill_enablement_changes(&self) -> EventStoreResult> {
- let events = self.list_by_type(SKILL_ENABLEMENT_CHANGED_EVENT, 500)?;
- events
+ let mut latest_update_by_skill_id = std::collections::HashMap::new();
+ for update in self.list_skill_updates()? {
+ latest_update_by_skill_id
+ .entry(update.skill_id)
+ .or_insert(update);
+ }
+
+ let mut latest_check_by_skill_id = std::collections::HashMap::new();
+ for check in self.list_skill_update_checks()? {
+ latest_check_by_skill_id
+ .entry(check.skill_id)
+ .or_insert(check);
+ }
+
+ let mut latest_failure_by_skill_id = std::collections::HashMap::new();
+ for failure in self.list_skill_update_failures()? {
+ latest_failure_by_skill_id
+ .entry(failure.skill_id)
+ .or_insert(failure);
+ }
+
+ let uninstalled_skill_ids = self
+ .list_skill_uninstalls()?
.into_iter()
- .map(|event| {
- serde_json::from_str::(&event.payload_json)
- .map_err(Into::into)
+ .map(|record| record.skill_id)
+ .collect::>();
+
+ self.list_skill_installations()?
+ .into_iter()
+ .filter(|installation| !uninstalled_skill_ids.contains(&installation.id))
+ .map(|installation| {
+ let latest_change = latest_change_by_skill_id.remove(&installation.id);
+ let latest_reset = latest_reset_by_skill_id.remove(&installation.id);
+ let latest_update = latest_update_by_skill_id.remove(&installation.id);
+ let latest_check = latest_check_by_skill_id.remove(&installation.id);
+ let latest_failure = latest_failure_by_skill_id.remove(&installation.id);
+ let mut manifest = installation.manifest;
+ let mut installed_from = installation.installed_from;
+ let mut source_identity = installation.source_identity;
+ let mut entry_content = installation.entry_content;
+ let mut entry_sha256 = installation.entry_sha256;
+ let mut updated_at = installation.installed_at;
+ let mut rollback_version = None;
+ let mut rollback_revision = None;
+ if let Some(update) = latest_update.as_ref() {
+ manifest = update.manifest.clone();
+ installed_from = update.updated_from.clone();
+ source_identity = update.source_identity.clone();
+ entry_content = Some(update.entry_content.clone());
+ entry_sha256 = Some(update.entry_sha256.clone());
+ updated_at = update.applied_at;
+ rollback_version = Some(update.previous_version.clone());
+ rollback_revision = update.previous_revision.clone();
+ }
+ let entry_available = entry_content
+ .as_ref()
+ .is_some_and(|content| !content.trim().is_empty())
+ && entry_sha256
+ .as_ref()
+ .is_some_and(|hash| !hash.trim().is_empty());
+ let mut enablement_status = latest_change
+ .as_ref()
+ .map(|change| change.status)
+ .unwrap_or(SkillEnablementStatus::Enabled);
+ let mut last_audit_note = latest_change.as_ref().map(|change| change.note.clone());
+ if let Some(change) = latest_change.as_ref() {
+ updated_at = updated_at.max(change.changed_at);
+ }
+ if let Some(reset) = latest_reset.filter(|reset| {
+ latest_update
+ .as_ref()
+ .is_none_or(|update| reset.reset_at > update.applied_at)
+ }) {
+ manifest.trust_level = SkillTrustLevel::Untrusted;
+ enablement_status = SkillEnablementStatus::Disabled;
+ last_audit_note = Some(reset.note);
+ updated_at = updated_at.max(reset.reset_at);
+ }
+
+ let mut update_state = latest_check
+ .as_ref()
+ .map(|check| match check.status {
+ SkillUpdateCheckStatus::UpdateAvailable => {
+ SkillUpdateState::UpdateAvailable
+ }
+ SkillUpdateCheckStatus::Failed => SkillUpdateState::Failed,
+ SkillUpdateCheckStatus::Current | SkillUpdateCheckStatus::Updated => {
+ SkillUpdateState::Current
+ }
+ })
+ .unwrap_or_default();
+ if latest_failure.as_ref().is_some_and(|failure| {
+ latest_check
+ .as_ref()
+ .is_none_or(|check| failure.failed_at >= check.checked_at)
+ && failure.failed_at >= updated_at
+ }) {
+ update_state = SkillUpdateState::Failed;
+ }
+
+ Ok(SkillRecord {
+ id: installation.id,
+ manifest,
+ installed_from,
+ installed_at: installation.installed_at,
+ enablement_status,
+ last_audit_note,
+ updated_at,
+ package_kind: installation.package_kind,
+ system_protected: installation.system_protected,
+ source_identity,
+ update_policy: installation.update_policy,
+ update_state,
+ last_update_checked_at: latest_check.map(|check| check.checked_at),
+ last_update_failure: latest_failure.map(|failure| failure.error),
+ rollback_version,
+ rollback_revision,
+ entry_available,
+ entry_sha256,
+ })
})
.collect()
}
- pub fn append_skill_trust_reset(&self, reset: &SkillTrustReset) -> EventStoreResult<()> {
- self.ensure_skill_installation_exists(reset.skill_id)?;
- let event = KernelEvent::new(SKILL_TRUST_RESET_EVENT, reset)?;
- self.append(&event)
- }
-
- pub fn list_skill_trust_resets(&self) -> EventStoreResult> {
- let events = self.list_by_type(SKILL_TRUST_RESET_EVENT, 500)?;
- events
+ pub fn prepare_skill_activation(
+ &self,
+ skill_id: Uuid,
+ input_summary: String,
+ ) -> EventStoreResult {
+ let record = self
+ .list_skill_records()?
.into_iter()
- .map(|event| {
- serde_json::from_str::(&event.payload_json).map_err(Into::into)
- })
- .collect()
+ .find(|record| record.id == skill_id)
+ .ok_or_else(|| EventStoreError::NotFound(format!("skill installation {skill_id}")))?;
+ let mut installation = self
+ .list_skill_installations()?
+ .into_iter()
+ .find(|installation| installation.id == skill_id)
+ .ok_or_else(|| EventStoreError::NotFound(format!("skill installation {skill_id}")))?;
+ if let Some(update) = self
+ .list_skill_updates()?
+ .into_iter()
+ .find(|update| update.skill_id == skill_id)
+ {
+ installation.manifest = update.manifest;
+ installation.installed_from = update.updated_from;
+ installation.source_identity = update.source_identity;
+ installation.entry_content = Some(update.entry_content);
+ installation.entry_sha256 = Some(update.entry_sha256);
+ }
+ SkillActivationContext::for_installation(&record, &installation, input_summary)
+ .map_err(EventStoreError::InvalidState)
}
- pub fn append_skill_uninstall(&self, uninstall: &SkillUninstallRecord) -> EventStoreResult<()> {
- self.ensure_skill_installation_exists(uninstall.skill_id)?;
+ pub fn prepare_skill_execution(
+ &self,
+ skill_id: Uuid,
+ input_summary: String,
+ ) -> EventStoreResult {
let record = self
.list_skill_records()?
.into_iter()
- .find(|record| record.id == uninstall.skill_id)
- .ok_or_else(|| {
- EventStoreError::NotFound(format!(
- "active skill installation {}",
- uninstall.skill_id
- ))
- })?;
- if record.system_protected {
- return Err(EventStoreError::InvalidState(format!(
- "protected system skill cannot be uninstalled: {}",
- record.manifest.name
- )));
- }
- let event = KernelEvent::new(SKILL_UNINSTALLED_EVENT, uninstall)?;
+ .find(|record| record.id == skill_id)
+ .ok_or_else(|| EventStoreError::NotFound(format!("skill installation {skill_id}")))?;
+ let execution = SkillExecutionRecord::for_skill(&record, input_summary)
+ .map_err(EventStoreError::InvalidState)?;
+ self.append_skill_execution(&execution)?;
+ Ok(execution)
+ }
+
+ pub fn append_skill_execution(&self, execution: &SkillExecutionRecord) -> EventStoreResult<()> {
+ self.ensure_skill_installation_exists(execution.skill_id)?;
+ let event = KernelEvent::new(SKILL_EXECUTION_RECORDED_EVENT, execution)?;
self.append(&event)
}
- pub fn list_skill_uninstalls(&self) -> EventStoreResult> {
- let events = self.list_by_type(SKILL_UNINSTALLED_EVENT, 500)?;
+ pub fn list_skill_executions(&self) -> EventStoreResult> {
+ let events = self.list_by_type(SKILL_EXECUTION_RECORDED_EVENT, 500)?;
events
.into_iter()
.map(|event| {
- serde_json::from_str::(&event.payload_json)
+ serde_json::from_str::(&event.payload_json)
.map_err(Into::into)
})
.collect()
}
- pub fn append_skill_update(&self, update: &SkillUpdateRecord) -> EventStoreResult<()> {
- self.ensure_skill_installation_exists(update.skill_id)?;
- let current = self
- .list_skill_records()?
+ fn ensure_skill_installation_exists(&self, skill_id: Uuid) -> EventStoreResult<()> {
+ let exists = self
+ .list_skill_installations()?
.into_iter()
- .find(|record| record.id == update.skill_id)
- .ok_or_else(|| {
- EventStoreError::NotFound(format!("active skill installation {}", update.skill_id))
- })?;
- if current.manifest.version != update.previous_version {
- return Err(EventStoreError::InvalidState(format!(
- "skill update expected version {}, current version is {}",
- update.previous_version, current.manifest.version
- )));
- }
- if current.manifest.name != update.manifest.name {
- return Err(EventStoreError::InvalidState(
- "skill update cannot change package identity".to_string(),
- ));
- }
- if update.manifest.permissions.iter().any(|permission| {
- !current
- .manifest
- .permissions
- .iter()
- .any(|current_permission| {
- current_permission.kind == permission.kind
- && current_permission.scope == permission.scope
- })
- }) {
- return Err(EventStoreError::InvalidState(
- "skill update cannot expand declared permissions automatically".to_string(),
- ));
- }
- if sha256_hex(update.entry_content.as_bytes()) != update.entry_sha256 {
- return Err(EventStoreError::InvalidState(
- "skill update entry integrity mismatch".to_string(),
- ));
- }
- if let (Some(current_source), Some(next_source)) =
- (¤t.source_identity, &update.source_identity)
- {
- if current_source.provider != next_source.provider
- || current_source.repository_url != next_source.repository_url
- || current_source.package_path != next_source.package_path
- {
- return Err(EventStoreError::InvalidState(
- "skill update cannot change canonical source identity".to_string(),
- ));
- }
+ .any(|record| record.id == skill_id);
+ if exists {
+ Ok(())
+ } else {
+ Err(EventStoreError::NotFound(format!(
+ "skill installation {skill_id}"
+ )))
}
+ }
- let event = KernelEvent::new(SKILL_UPDATED_EVENT, update)?;
+ pub fn append_capability_access_request(
+ &self,
+ request: &CapabilityAccessRequest,
+ ) -> EventStoreResult<()> {
+ let event = KernelEvent::new(CAPABILITY_ACCESS_REQUESTED_EVENT, request)?;
self.append(&event)
}
- pub fn list_skill_updates(&self) -> EventStoreResult> {
- let events = self.list_by_type(SKILL_UPDATED_EVENT, 500)?;
- events
+ pub fn list_capability_access_requests(
+ &self,
+ ) -> EventStoreResult> {
+ let mut statement = self
+ .conn
+ .prepare("SELECT request_json FROM capability_access_state")?;
+ let rows = statement
+ .query_map([], |row| row.get::<_, String>(0))?
+ .collect::, _>>()?;
+ let mut requests = rows
.into_iter()
- .map(|event| {
- serde_json::from_str::(&event.payload_json).map_err(Into::into)
- })
- .collect()
+ .map(|json| serde_json::from_str::(&json).map_err(Into::into))
+ .collect::>>()?;
+ requests.sort_by_key(|right| std::cmp::Reverse(right.created_at));
+ Ok(requests)
}
- pub fn append_skill_update_check(
+ pub fn append_permission_resolution(
&self,
- check: &SkillUpdateCheckRecord,
+ resolution: &PermissionResolution,
) -> EventStoreResult<()> {
- self.ensure_skill_installation_exists(check.skill_id)?;
- let event = KernelEvent::new(SKILL_UPDATE_CHECKED_EVENT, check)?;
+ let event = KernelEvent::new(PERMISSION_RESOLUTION_RECORDED_EVENT, resolution)?;
self.append(&event)
}
- pub fn list_skill_update_checks(&self) -> EventStoreResult> {
- let events = self.list_by_type(SKILL_UPDATE_CHECKED_EVENT, 500)?;
- events
+ pub fn list_permission_resolutions(&self) -> EventStoreResult> {
+ let mut statement = self.conn.prepare(
+ r#"SELECT resolution_json FROM capability_access_state
+ WHERE resolution_json IS NOT NULL"#,
+ )?;
+ let rows = statement
+ .query_map([], |row| row.get::<_, String>(0))?
+ .collect::, _>>()?;
+ let mut resolutions = rows
.into_iter()
- .map(|event| {
- serde_json::from_str::(&event.payload_json)
- .map_err(Into::into)
- })
+ .map(|json| serde_json::from_str::(&json).map_err(Into::into))
+ .collect::>>()?;
+ resolutions.sort_by_key(|right| std::cmp::Reverse(right.created_at));
+ Ok(resolutions)
+ }
+
+ pub fn list_capability_access_records(&self) -> EventStoreResult> {
+ let mut statement = self.conn.prepare(
+ r#"SELECT request_id FROM connector_approval_consumptions
+ UNION SELECT request_id FROM connector_attachment_approval_consumptions
+ UNION SELECT request_id FROM capability_approval_consumptions"#,
+ )?;
+ let consumptions = statement
+ .query_map([], |row| row.get::<_, String>(0))?
+ .filter_map(Result::ok)
+ .filter_map(|value| Uuid::parse_str(&value).ok())
+ .collect::>();
+ drop(statement);
+ let mut statement = self.conn.prepare(
+ r#"SELECT request_json, resolution_json, effective_status, row_revision
+ FROM capability_access_state ORDER BY created_at DESC, rowid DESC"#,
+ )?;
+ let rows = statement
+ .query_map([], |row| {
+ Ok((
+ row.get::<_, String>(0)?,
+ row.get::<_, Option>(1)?,
+ row.get::<_, String>(2)?,
+ row.get::<_, u64>(3)?,
+ ))
+ })?
+ .collect::, _>>()?;
+ rows.into_iter()
+ .map(
+ |(request_json, resolution_json, effective_status_json, projection_revision)| {
+ let request: CapabilityAccessRequest = serde_json::from_str(&request_json)?;
+ let resolution = resolution_json
+ .map(|json| serde_json::from_str::(&json))
+ .transpose()?;
+ let effective_status: CapabilityAccessStatus =
+ serde_json::from_str(&effective_status_json)?;
+ let grant_state = capability_grant_state(
+ &request,
+ resolution.as_ref(),
+ effective_status,
+ &[],
+ &consumptions,
+ );
+
+ Ok(CapabilityAccessRecord {
+ request,
+ resolution,
+ effective_status,
+ projection_revision,
+ grant_state,
+ })
+ },
+ )
.collect()
}
- pub fn append_skill_update_failure(
+ pub fn list_pending_capability_access_records(
&self,
- failure: &SkillUpdateFailureRecord,
- ) -> EventStoreResult<()> {
- self.ensure_skill_installation_exists(failure.skill_id)?;
- let event = KernelEvent::new(SKILL_UPDATE_FAILED_EVENT, failure)?;
- self.append(&event)
+ ) -> EventStoreResult> {
+ let mut pending = Vec::new();
+ for record in self.list_capability_access_records()? {
+ if record.effective_status == CapabilityAccessStatus::PendingApproval
+ && !grouped_approval::is_grouped_request(self, record.request.id)?
+ {
+ pending.push(record);
+ }
+ }
+ Ok(pending)
}
- pub fn list_skill_update_failures(&self) -> EventStoreResult> {
- let events = self.list_by_type(SKILL_UPDATE_FAILED_EVENT, 500)?;
- events
+ pub fn has_user_approved_capability(
+ &self,
+ capability: CapabilityKind,
+ ) -> EventStoreResult {
+ Ok(self
+ .available_capability_grant_request_id(capability)?
+ .is_some())
+ }
+
+ pub fn available_capability_grant_request_id(
+ &self,
+ capability: CapabilityKind,
+ ) -> EventStoreResult> {
+ Ok(self
+ .list_capability_access_records()?
.into_iter()
- .map(|event| {
- serde_json::from_str::(&event.payload_json)
- .map_err(Into::into)
+ .find(|record| {
+ record.request.capability == capability
+ && matches!(
+ record.grant_state,
+ CapabilityGrantState::Reusable | CapabilityGrantState::OneShotAvailable
+ )
})
- .collect()
+ .map(|record| record.request.id))
}
- pub fn list_skill_records(&self) -> EventStoreResult> {
- let mut latest_change_by_skill_id = std::collections::HashMap::new();
- for change in self.list_skill_enablement_changes()? {
- latest_change_by_skill_id
- .entry(change.skill_id)
- .or_insert(change);
- }
-
- let mut latest_reset_by_skill_id = std::collections::HashMap::new();
- for reset in self.list_skill_trust_resets()? {
- latest_reset_by_skill_id
- .entry(reset.skill_id)
- .or_insert(reset);
+ pub fn resolve_capability_access_request(
+ &self,
+ request_id: Uuid,
+ approved: bool,
+ note: String,
+ ) -> EventStoreResult {
+ if grouped_approval::is_grouped_request(self, request_id)? {
+ return Err(EventStoreError::InvalidState(
+ "task grouped approval requires the single exact-task resolver".to_string(),
+ ));
}
+ let record = self.capability_access_record_by_id(request_id)?;
- let mut latest_update_by_skill_id = std::collections::HashMap::new();
- for update in self.list_skill_updates()? {
- latest_update_by_skill_id
- .entry(update.skill_id)
- .or_insert(update);
+ if record.request.capability == CapabilityKind::ConnectorAttachmentRead {
+ return Err(EventStoreError::InvalidState(
+ "connector attachment approval requires the dedicated exact-preview resolver"
+ .to_string(),
+ ));
}
- let mut latest_check_by_skill_id = std::collections::HashMap::new();
- for check in self.list_skill_update_checks()? {
- latest_check_by_skill_id
- .entry(check.skill_id)
- .or_insert(check);
+ if record.resolution.is_some() {
+ return Err(EventStoreError::InvalidState(
+ "capability access request is already resolved".to_string(),
+ ));
}
- let mut latest_failure_by_skill_id = std::collections::HashMap::new();
- for failure in self.list_skill_update_failures()? {
- latest_failure_by_skill_id
- .entry(failure.skill_id)
- .or_insert(failure);
+ if record.effective_status != CapabilityAccessStatus::PendingApproval {
+ return Err(EventStoreError::InvalidState(
+ "capability access request does not require approval".to_string(),
+ ));
}
- let uninstalled_skill_ids = self
- .list_skill_uninstalls()?
- .into_iter()
- .map(|record| record.skill_id)
- .collect::>();
-
- self.list_skill_installations()?
- .into_iter()
- .filter(|installation| !uninstalled_skill_ids.contains(&installation.id))
- .map(|installation| {
- let latest_change = latest_change_by_skill_id.remove(&installation.id);
- let latest_reset = latest_reset_by_skill_id.remove(&installation.id);
- let latest_update = latest_update_by_skill_id.remove(&installation.id);
- let latest_check = latest_check_by_skill_id.remove(&installation.id);
- let latest_failure = latest_failure_by_skill_id.remove(&installation.id);
- let mut manifest = installation.manifest;
- let mut installed_from = installation.installed_from;
- let mut source_identity = installation.source_identity;
- let mut entry_content = installation.entry_content;
- let mut entry_sha256 = installation.entry_sha256;
- let mut updated_at = installation.installed_at;
- let mut rollback_version = None;
- let mut rollback_revision = None;
- if let Some(update) = latest_update.as_ref() {
- manifest = update.manifest.clone();
- installed_from = update.updated_from.clone();
- source_identity = update.source_identity.clone();
- entry_content = Some(update.entry_content.clone());
- entry_sha256 = Some(update.entry_sha256.clone());
- updated_at = update.applied_at;
- rollback_version = Some(update.previous_version.clone());
- rollback_revision = update.previous_revision.clone();
- }
- let entry_available = entry_content
- .as_ref()
- .is_some_and(|content| !content.trim().is_empty())
- && entry_sha256
- .as_ref()
- .is_some_and(|hash| !hash.trim().is_empty());
- let mut enablement_status = latest_change
- .as_ref()
- .map(|change| change.status)
- .unwrap_or(SkillEnablementStatus::Enabled);
- let mut last_audit_note = latest_change.as_ref().map(|change| change.note.clone());
- if let Some(change) = latest_change.as_ref() {
- updated_at = updated_at.max(change.changed_at);
- }
- if let Some(reset) = latest_reset.filter(|reset| {
- latest_update
- .as_ref()
- .is_none_or(|update| reset.reset_at > update.applied_at)
- }) {
- manifest.trust_level = SkillTrustLevel::Untrusted;
- enablement_status = SkillEnablementStatus::Disabled;
- last_audit_note = Some(reset.note);
- updated_at = updated_at.max(reset.reset_at);
- }
+ let mut resolution = PermissionResolution::new(request_id, approved, note);
+ resolution.expected_request_revision = Some(record.projection_revision);
+ self.append_permission_resolution(&resolution)?;
+ Ok(resolution)
+ }
- let mut update_state = latest_check
- .as_ref()
- .map(|check| match check.status {
- SkillUpdateCheckStatus::UpdateAvailable => {
- SkillUpdateState::UpdateAvailable
- }
- SkillUpdateCheckStatus::Failed => SkillUpdateState::Failed,
- SkillUpdateCheckStatus::Current | SkillUpdateCheckStatus::Updated => {
- SkillUpdateState::Current
- }
- })
- .unwrap_or_default();
- if latest_failure.as_ref().is_some_and(|failure| {
- latest_check
- .as_ref()
- .is_none_or(|check| failure.failed_at >= check.checked_at)
- && failure.failed_at >= updated_at
- }) {
- update_state = SkillUpdateState::Failed;
- }
+ pub fn resolve_connector_mutation_access_request(
+ &self,
+ request_id: Uuid,
+ approved: bool,
+ note: String,
+ expected_request_revision: u64,
+ expected_preview_revision: u32,
+ expected_preview_hash: &str,
+ ) -> EventStoreResult {
+ if grouped_approval::is_grouped_request(self, request_id)? {
+ return Err(EventStoreError::InvalidState(
+ "task grouped approval requires the single exact-task resolver".to_string(),
+ ));
+ }
+ let record = self.capability_access_record_by_id(request_id)?;
+ if record.request.capability != CapabilityKind::ConnectorWrite
+ || record.effective_status != CapabilityAccessStatus::PendingApproval
+ || record.resolution.is_some()
+ || record.projection_revision != expected_request_revision
+ {
+ return Err(EventStoreError::InvalidState(
+ "connector mutation approval is stale or unavailable".to_string(),
+ ));
+ }
+ let scope = record.request.exact_tool.as_ref().ok_or_else(|| {
+ EventStoreError::InvalidState(
+ "connector mutation approval has no exact preview evidence".to_string(),
+ )
+ })?;
+ if scope.tool_id != crate::kernel::tool_runtime::CONNECTOR_MUTATE_TOOL_ID
+ || scope.preview_revision != expected_preview_revision
+ || scope.preview_hash != expected_preview_hash
+ {
+ return Err(EventStoreError::InvalidState(
+ "connector mutation approval preview changed".to_string(),
+ ));
+ }
+ let resolution = PermissionResolution::new_exact(
+ request_id,
+ approved,
+ note,
+ expected_request_revision,
+ scope,
+ )
+ .map_err(EventStoreError::InvalidState)?;
+ self.append_permission_resolution(&resolution)?;
+ Ok(resolution)
+ }
- Ok(SkillRecord {
- id: installation.id,
- manifest,
- installed_from,
- installed_at: installation.installed_at,
- enablement_status,
- last_audit_note,
- updated_at,
- package_kind: installation.package_kind,
- system_protected: installation.system_protected,
- source_identity,
- update_policy: installation.update_policy,
- update_state,
- last_update_checked_at: latest_check.map(|check| check.checked_at),
- last_update_failure: latest_failure.map(|failure| failure.error),
- rollback_version,
- rollback_revision,
- entry_available,
- entry_sha256,
- })
- })
- .collect()
+ #[cfg(test)]
+ fn resolve_connector_attachment_access_request(
+ &self,
+ request_id: Uuid,
+ approved: bool,
+ note: String,
+ expected_request_revision: u64,
+ expected_preview_revision: u32,
+ expected_preview_hash: &str,
+ ) -> EventStoreResult {
+ if grouped_approval::is_grouped_request(self, request_id)? {
+ return Err(EventStoreError::InvalidState(
+ "task grouped approval requires the single exact-task resolver".to_string(),
+ ));
+ }
+ let record = self.capability_access_record_by_id(request_id)?;
+ if record.request.capability != CapabilityKind::ConnectorAttachmentRead
+ || record.effective_status != CapabilityAccessStatus::PendingApproval
+ || record.resolution.is_some()
+ || record.projection_revision != expected_request_revision
+ {
+ return Err(EventStoreError::InvalidState(
+ "connector attachment approval is stale or unavailable".to_string(),
+ ));
+ }
+ let scope = record.request.exact_tool.as_ref().ok_or_else(|| {
+ EventStoreError::InvalidState(
+ "connector attachment approval has no exact preview evidence".to_string(),
+ )
+ })?;
+ if scope.preview_revision != expected_preview_revision
+ || scope.preview_hash != expected_preview_hash
+ {
+ return Err(EventStoreError::InvalidState(
+ "connector attachment approval preview changed".to_string(),
+ ));
+ }
+ let resolution = PermissionResolution::new_exact(
+ request_id,
+ approved,
+ note,
+ expected_request_revision,
+ scope,
+ )
+ .map_err(EventStoreError::InvalidState)?;
+ self.append_permission_resolution(&resolution)?;
+ Ok(resolution)
}
- pub fn prepare_skill_activation(
+ pub fn append_capability_invocation(
&self,
- skill_id: Uuid,
- input_summary: String,
- ) -> EventStoreResult {
- let record = self
- .list_skill_records()?
- .into_iter()
- .find(|record| record.id == skill_id)
- .ok_or_else(|| EventStoreError::NotFound(format!("skill installation {skill_id}")))?;
- let mut installation = self
- .list_skill_installations()?
- .into_iter()
- .find(|installation| installation.id == skill_id)
- .ok_or_else(|| EventStoreError::NotFound(format!("skill installation {skill_id}")))?;
- if let Some(update) = self
- .list_skill_updates()?
- .into_iter()
- .find(|update| update.skill_id == skill_id)
- {
- installation.manifest = update.manifest;
- installation.installed_from = update.updated_from;
- installation.source_identity = update.source_identity;
- installation.entry_content = Some(update.entry_content);
- installation.entry_sha256 = Some(update.entry_sha256);
+ invocation: &CapabilityInvocation,
+ ) -> EventStoreResult<()> {
+ let event = KernelEvent::new(CAPABILITY_INVOCATION_RECORDED_EVENT, invocation)?;
+ self.append(&event)
+ }
+
+ pub fn list_capability_invocations(&self) -> EventStoreResult> {
+ let events = self.list_by_type(CAPABILITY_INVOCATION_RECORDED_EVENT, 100)?;
+ let mut seen = std::collections::HashSet::new();
+ let mut invocations = Vec::new();
+ for event in events {
+ let invocation = serde_json::from_str::(&event.payload_json)?;
+ if seen.insert(invocation.id) {
+ invocations.push(invocation);
+ }
}
- SkillActivationContext::for_installation(&record, &installation, input_summary)
- .map_err(EventStoreError::InvalidState)
+ Ok(invocations)
}
- pub fn prepare_skill_execution(
+ pub fn append_tool_invocation(
&self,
- skill_id: Uuid,
- input_summary: String,
- ) -> EventStoreResult {
- let record = self
- .list_skill_records()?
- .into_iter()
- .find(|record| record.id == skill_id)
- .ok_or_else(|| EventStoreError::NotFound(format!("skill installation {skill_id}")))?;
- let execution = SkillExecutionRecord::for_skill(&record, input_summary)
- .map_err(EventStoreError::InvalidState)?;
- self.append_skill_execution(&execution)?;
- Ok(execution)
+ invocation: &ToolInvocationRecord,
+ ) -> EventStoreResult<()> {
+ let event = KernelEvent::new(TOOL_INVOCATION_RECORDED_EVENT, invocation)?;
+ let transaction = self.conn.unchecked_transaction()?;
+ if invocation.status == ToolExecutionStatus::Running {
+ if let Some(run_id) = invocation.run_id {
+ if let Some(lifecycle) =
+ Self::goal_envelope_projection_from_connection(&transaction, run_id)?
+ {
+ let frozen = lifecycle.frozen().ok_or_else(|| {
+ EventStoreError::InvalidState(
+ "tool invocation goal binding requires a frozen goal".to_string(),
+ )
+ })?;
+ if invocation.request_fingerprint.is_empty() {
+ return Err(EventStoreError::InvalidState(
+ "tool invocation goal binding requires a request fingerprint"
+ .to_string(),
+ ));
+ }
+ let existing = transaction
+ .query_row(
+ r#"SELECT goal_id, goal_revision, frozen_fingerprint,
+ request_fingerprint
+ FROM tool_invocation_goal_binding WHERE invocation_id = ?1"#,
+ params![invocation.id.to_string()],
+ |row| {
+ Ok((
+ row.get::<_, String>(0)?,
+ row.get::<_, String>(1)?,
+ row.get::<_, String>(2)?,
+ row.get::<_, String>(3)?,
+ ))
+ },
+ )
+ .optional()?;
+ let binding = (
+ run_id.to_string(),
+ frozen.revision.clone(),
+ frozen.fingerprint.clone(),
+ invocation.request_fingerprint.clone(),
+ );
+ if let Some(existing) = existing {
+ if existing != binding {
+ return Err(EventStoreError::InvalidState(
+ "tool invocation goal binding changed".to_string(),
+ ));
+ }
+ } else {
+ transaction.execute(
+ r#"INSERT INTO tool_invocation_goal_binding
+ (invocation_id, goal_id, goal_revision, frozen_fingerprint,
+ request_fingerprint, bound_at)
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6)"#,
+ params![
+ invocation.id.to_string(),
+ binding.0,
+ binding.1,
+ binding.2,
+ binding.3,
+ event.created_at.to_rfc3339_opts(SecondsFormat::Nanos, true),
+ ],
+ )?;
+ }
+ }
+ }
+ }
+ Self::insert_kernel_event(&transaction, &event)?;
+ transaction.commit()?;
+ Ok(())
}
- pub fn append_skill_execution(&self, execution: &SkillExecutionRecord) -> EventStoreResult<()> {
- self.ensure_skill_installation_exists(execution.skill_id)?;
- let event = KernelEvent::new(SKILL_EXECUTION_RECORDED_EVENT, execution)?;
- self.append(&event)
+ pub fn list_tool_invocations(&self) -> EventStoreResult> {
+ let mut statement = self.conn.prepare(
+ r#"SELECT invocation_json FROM tool_invocation_state
+ ORDER BY updated_at DESC, rowid DESC LIMIT 500"#,
+ )?;
+ let rows = statement
+ .query_map([], |row| row.get::<_, String>(0))?
+ .collect::, _>>()?;
+ rows.into_iter()
+ .map(|json| serde_json::from_str::