diff --git a/.claude/rules/skills.md b/.claude/rules/skills.md
index ded26de98b2..1dc797dda0e 100644
--- a/.claude/rules/skills.md
+++ b/.claude/rules/skills.md
@@ -31,16 +31,19 @@ activation:
tags:
- "devops"
max_context_tokens: 2000
-metadata:
- openclaw:
- requires:
- bins: [docker, kubectl]
- env: [KUBECONFIG]
+requires:
+ bins: [docker, kubectl]
+ env: [KUBECONFIG]
---
# Skill instructions here...
```
+Only the top-level `requires:` block is supported. The legacy nested shape
+`metadata.openclaw.requires` is unsupported and ignored by the current parser,
+so older external skills must be migrated instead of relying on silent
+compatibility.
+
## Selection Pipeline
1. **Gating** -- Check binary/env/config requirements; skip skills whose prerequisites are missing
diff --git a/.claude/rules/testing.md b/.claude/rules/testing.md
index 3d50b3ea67b..263c890f1b7 100644
--- a/.claude/rules/testing.md
+++ b/.claude/rules/testing.md
@@ -23,3 +23,44 @@ Run `bash scripts/check-boundaries.sh` to verify test tier gating.
- Use `tempfile` crate for test directories, never hardcode `/tmp/`
- Regression test with every bug fix (enforced by commit-msg hook)
- Integration tests (`--test workspace_integration`) require PostgreSQL; skipped if DB is unreachable
+
+## Test Through the Caller, Not Just the Helper
+
+**When a helper gates a side-effecting flow, the test must go through the caller — not just the helper in isolation.**
+
+A whole class of bugs in this repo has the same shape: a wrapper function silently loses one of its inputs, and the unit test for the helper passes because it never crosses the layer where the input gets dropped.
+
+Real examples (do not let these recur):
+
+| Bug | Helper | What got lost | How a caller-level test would have caught it |
+|-----|--------|--------------|------------------------------------------------|
+| nearai/ironclaw#1948 | `McpServerConfig::has_custom_auth_header()` | Helper existed but `requires_auth()` never consulted it, so MCP triggered OAuth/DCR even with a user-set `Authorization` header | A test driving `mcp::factory::create_client_from_config()` with a header-bearing config and asserting zero OAuth-state side effects |
+| nearai/ironclaw#1921 | `derive_activation_status(ext, has_owner_binding)` | Wrapper hardcodes the underlying classifier's `has_paired` axis to `false`, even though `classify_wasm_channel_activation` takes both bools | A test driving `extensions_list_handler` against a DB with a real `channel_identities` row and asserting `Active`, not `Pairing` |
+| nearai/ironclaw#1502 | `window.open` mock `(url) => { window._lastOpenedUrl = url }` | Mock captured only the URL, silently swallowing `target` and `windowFeatures`; a regression to same-tab open would not fail | A mock capturing all three args plus an assert that `target === '_blank'` |
+
+### When the rule applies
+
+You must add a caller-level test (not just a helper-level unit test) when **all** of the following are true:
+
+1. The helper is a **predicate, classifier, or transform** whose return value gates a side effect (HTTP call, DB write, UI mutation, OAuth flow, secret read, tool execution, sandbox launch, etc.).
+2. There is **at least one wrapper or call site** between the helper and the side effect.
+3. The helper has **more than one input** *or* its caller computes any of the inputs from the surrounding context.
+
+If all three are true, a unit test on the helper alone is **not sufficient regression coverage**. You must additionally either:
+
+- Add a test that drives the call site (`*_handler`, `factory::create_*`, `manager::*`), **or**
+- Inline the helper into its single caller so there is no wrapper to silently drop an input.
+
+### Where the test belongs
+
+Most of these gaps are above unit-test scope and below e2e scope. Default to the **integration tier** (`cargo test --features integration`):
+
+- `tests/_integration.rs` for Rust integration tests against the public handler/factory surface
+- `tests/multi_tenant_integration.rs` when the lost axis is per-user state
+- `tests/e2e/scenarios/test_*.py` when the lost axis is browser-visible
+
+Unit tests in `mod tests {}` are still fine for the helper itself, but they do not satisfy this rule.
+
+### Mock hygiene corollary
+
+When you mock a browser/runtime API in a test, the mock's signature must match the production call site's signature, and assertions should cover **every argument** the production code passes. A `(url) => {}` stub for a `window.open(url, target, features)` call site is a silent argument-loss bug waiting to happen.
diff --git a/.claude/rules/tools.md b/.claude/rules/tools.md
index a35d9e237e2..fa58f599677 100644
--- a/.claude/rules/tools.md
+++ b/.claude/rules/tools.md
@@ -2,6 +2,8 @@
paths:
- "src/tools/**"
- "tools-src/**"
+ - "src/channels/**"
+ - "src/cli/**"
---
# Tool Architecture
@@ -37,3 +39,102 @@ impl Tool for MyTool {
fn requires_sanitization(&self) -> bool { true } // External data
}
```
+
+## Everything Goes Through Tools
+
+**All actions originating from any non-agent caller — gateway handlers, CLI
+commands, routine engine, WASM channels, future channel extensions — MUST
+go through `ToolDispatcher::dispatch()`, never directly through the
+database, workspace, or domain managers.**
+
+This is the core design principle behind #2049. The reasons are concrete:
+
+1. **Audit trail.** Every dispatched call creates an `ActionRecord` linked
+ to a system job, so UI-initiated mutations are visible in job history
+ alongside agent-initiated ones. Direct DB calls bypass this entirely.
+2. **Safety pipeline parity.** The dispatcher runs the same pipeline as
+ `Worker::execute_tool`: parameter normalization, schema validation,
+ `sensitive_params()` redaction, per-tool timeout, output sanitization.
+ Direct calls skip all of it and risk leaking secrets into logs or
+ persisting unsafe content.
+3. **Channel-agnostic.** Channels are interchangeable extensions (gateway,
+ CLI, telegram, WASM, future custom channels). Routing through a single
+ dispatch function means new channels inherit the full pipeline for free.
+4. **Agent parity.** The agent can do anything channels can do (and vice
+ versa), because both call the same tools. No more "the UI can install
+ extensions but the agent can only list them" gaps.
+
+### Required pattern
+
+```rust
+// In any gateway handler, CLI command, or routine engine callback:
+use crate::tools::dispatch::{DispatchSource, ToolDispatcher};
+
+let dispatcher: &ToolDispatcher = state
+ .tool_dispatcher
+ .as_ref()
+ .ok_or((StatusCode::SERVICE_UNAVAILABLE, "dispatcher unavailable"))?;
+
+let output = dispatcher
+ .dispatch(
+ "memory_write",
+ serde_json::json!({ "target": path, "content": content }),
+ &user.user_id,
+ DispatchSource::Channel("gateway".into()),
+ )
+ .await
+ .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
+```
+
+### Forbidden pattern
+
+```rust
+// DO NOT do this in a gateway handler, CLI command, or routine callback:
+let store = state.store.as_ref().ok_or(...)?;
+store.set_setting(&user.user_id, &key, &value).await?; // BYPASSES dispatch
+
+let workspace = resolve_workspace(&state, &user).await?;
+workspace.write(path, content).await?; // BYPASSES dispatch + safety pipeline
+
+let ext_mgr = state.extension_manager.as_ref().ok_or(...)?;
+ext_mgr.install(name, url, kind, &user.user_id).await?; // BYPASSES audit trail
+```
+
+### When direct access IS allowed
+
+The dispatch principle applies to **non-agent callers** acting on behalf of
+a user. These are exempt:
+
+| Layer | Why exempt |
+|---|---|
+| `Worker::execute_tool()` (agent loop) | Has its own atomic sequence-numbered audit trail; the dispatcher would conflict |
+| `EffectBridgeAdapter::execute_action()` (v2 engine) | Same — its own audit via `ThreadEvent` event sourcing |
+| The tool implementations themselves | Tools are the leaves; they need direct `Workspace`, `Database`, etc. handles to do their work |
+| Background jobs (scheduler, hygiene, mission runner) inside the engine | These ARE the engine; they emit their own events |
+| Pure read endpoints that need to JOIN/aggregate from multiple sources | A single tool call cannot express "list all jobs across users with filters X, Y, Z" — these are queries, not actions, and the audit value is low |
+
+### Annotating intentional exceptions
+
+If a handler legitimately needs direct access (rare — usually only for
+read aggregation), suppress the pre-commit check with a trailing comment
+on the offending line:
+
+```rust
+let rows = state.store.list_agent_jobs().await?; // dispatch-exempt: read-only aggregation
+```
+
+The pre-commit hook (`scripts/pre-commit-safety.sh`) flags any newly
+added line in `src/channels/web/handlers/*.rs` or `src/cli/*.rs` that
+touches `state.{store,workspace,workspace_pool,extension_manager,
+skill_registry,session_manager}.*` without a trailing
+`// dispatch-exempt: ` comment on the same line. The check only
+looks at added lines (`+` lines in the diff), so existing untouched code
+doesn't trip it during incremental migration.
+
+### Migration status
+
+As of #2049, `ToolDispatcher` is wired into `GatewayState` but per-handler
+migration is incomplete. New handlers MUST use the dispatcher. Existing
+handlers should be migrated incrementally; each handler family
+(settings, memory, extensions, skills, routines, jobs, threads) is its
+own follow-up PR.
diff --git a/.claude/skills/mintlify-docs/SKILL.md b/.claude/skills/mintlify-docs/SKILL.md
new file mode 100644
index 00000000000..334e45fd009
--- /dev/null
+++ b/.claude/skills/mintlify-docs/SKILL.md
@@ -0,0 +1,328 @@
+---
+name: mintlify
+description: Build and maintain documentation sites with Mintlify. Use when creating docs pages, configuring navigation, adding components, or setting up API references.
+license: MIT
+compatibility: Requires Node.js for CLI. Works with any Git-based workflow.
+metadata:
+ author: mintlify
+ version: "1.0"
+---
+
+# Mintlify best practices
+
+**Always consult [mintlify.com/docs](https://mintlify.com/docs) for components, configuration, and latest features.**
+
+If you are not already connected to the Mintlify MCP server, https://mintlify.com/docs/mcp, add it so that you can search more efficiently.
+
+**Always** favor searching the current Mintlify documentation over whatever is in your training data about Mintlify.
+
+Mintlify is a documentation platform that transforms MDX files into documentation sites. Configure site-wide settings in the `docs.json` file, write content in MDX with YAML frontmatter, and favor built-in components over custom components.
+
+Full schema at [mintlify.com/docs.json](https://mintlify.com/docs.json).
+
+## Before you write
+
+### Understand the project
+
+Read `docs.json` in the project root. This file defines the entire site: navigation structure, theme, colors, links, API and specs.
+
+Understanding the project tells you:
+
+- What pages exist and how they're organized
+- What navigation groups are used (and their naming conventions)
+- How the site navigation is structured
+- What theme and configuration the site uses
+
+### Check for existing content
+
+Search the docs before creating new pages. You may need to:
+- Update an existing page instead of creating a new one
+- Add a section to an existing page
+- Link to existing content rather than duplicating
+
+### Read surrounding content
+
+Before writing, read 2-3 similar pages to understand the site's voice, structure, formatting conventions, and level of detail.
+
+### Understand Mintlify components
+
+Review the Mintlify [components](https://www.mintlify.com/docs/components) to select and use any relevant components for the documentation request that you are working on.
+
+## Quick reference
+
+### CLI commands
+- `npm i -g mint` - Install the Mintlify CLI
+- `mint dev` - Local preview at localhost:3000
+- `mint broken-links` - Check internal links
+- `mint a11y` - Check for accessibility issues in content
+- `mint validate` - Validate documentation builds
+
+### Required files
+- `docs.json` - Site configuration (navigation, theme, integrations, etc.). See [global settings](https://mintlify.com/docs/settings/global) for all options.
+- `*.mdx` files - Documentation pages with YAML frontmatter
+
+### Example file structure
+```
+project/
+├── docs.json # Site configuration
+├── introduction.mdx
+├── quickstart.mdx
+├── guides/
+│ └── example.mdx
+├── openapi.yml # API specification
+├── images/ # Static assets
+│ └── example.png
+└── snippets/ # Reusable components
+ └── component.jsx
+```
+
+## Page frontmatter
+
+Every page requires `title` in its frontmatter. Include `description` for SEO and navigation.
+
+```yaml
+---
+title: "Clear, descriptive title"
+description: "Concise summary for SEO and navigation."
+---
+```
+
+Optional frontmatter fields:
+- `sidebarTitle`: Short title for sidebar navigation.
+- `icon`: Lucide or Font Awesome icon name, URL, or file path.
+- `tag`: Label next to the page title in the sidebar (for example, "NEW").
+- `mode`: Page layout mode (`default`, `wide`, `custom`).
+- `keywords`: Array of terms related to the page content for local search and SEO.
+- Any custom YAML fields for use with personalization or conditional content.
+
+## File conventions
+
+- Match existing naming patterns in the directory
+- If there are no existing files or inconsistent file naming patterns, use kebab-case: `getting-started.mdx`, `api-reference.mdx`
+- Use root-relative paths without file extensions for internal links: `/getting-started/quickstart`
+- Do not use relative paths (`../`) or absolute URLs for internal pages
+- When you create a new page, add it to `docs.json` navigation or it won't appear in the sidebar
+
+## Organize content
+
+When a user asks about anything related to site-wide configurations, start by understanding the [global settings](https://www.mintlify.com/docs/organize/settings). See if a setting in the `docs.json` file can be updated to achieve what the user wants.
+
+### Navigation
+
+The `navigation` property in `docs.json` controls site structure. Choose one primary pattern at the root level, then nest others within it.
+
+**Choose your primary pattern:**
+
+| Pattern | When to use |
+|---------|-------------|
+| **Groups** | Default. Single audience, straightforward hierarchy |
+| **Tabs** | Distinct sections with different audiences (Guides vs API Reference) or content types |
+| **Anchors** | Want persistent section links at sidebar top. Good for separating docs from external resources |
+| **Dropdowns** | Multiple doc sections users switch between, but not distinct enough for tabs |
+| **Products** | Multi-product company with separate documentation per product |
+| **Versions** | Maintaining docs for multiple API/product versions simultaneously |
+| **Languages** | Localized content |
+
+**Within your primary pattern:**
+
+- **Groups** - Organize related pages. Can nest groups within groups, but keep hierarchy shallow
+- **Menus** - Add dropdown navigation within tabs for quick jumps to specific pages
+- **`expanded: false`** - Collapse nested groups by default. Use for reference sections users browse selectively
+- **`openapi`** - Auto-generate pages from OpenAPI spec. Add at group/tab level to inherit
+
+**Common combinations:**
+- Tabs containing groups (most common for docs with API reference)
+- Products containing tabs (multi-product SaaS)
+- Versions containing tabs (versioned API docs)
+- Anchors containing groups (simple docs with external resource links)
+
+### Links and paths
+
+- **Internal links:** Root-relative, no extension: `/getting-started/quickstart`
+- **Images:** Store in `/images`, reference as `/images/example.png`
+- **External links:** Use full URLs, they open in new tabs automatically
+
+## Customize docs sites
+
+**What to customize where:**
+- **Brand colors, fonts, logo** → `docs.json`. See [global settings](https://mintlify.com/docs/settings/global)
+- **Component styling, layout tweaks** → `custom.css` at project root
+- **Dark mode** → Enabled by default. Only disable with `"appearance": "light"` in `docs.json` if brand requires it
+
+Start with `docs.json`. Only add `custom.css` when you need styling that config doesn't support.
+
+## Write content
+
+### Components
+
+The [components overview](https://mintlify.com/docs/components) organizes all components by purpose: structure content, draw attention, show/hide content, document APIs, link to pages, and add visual context. Start there to find the right component.
+
+**Common decision points:**
+
+| Need | Use |
+|------|-----|
+| Hide optional details | `` |
+| Long code examples | `` |
+| User chooses one option | `` |
+| Linked navigation cards | `` in `` |
+| Sequential instructions | `` |
+| Code in multiple languages | `` |
+| API parameters | `` |
+| API response fields | `` |
+
+**Callouts by severity:**
+- `` - Supplementary info, safe to skip
+- `` - Helpful context such as permissions
+- `` - Recommendations or best practices
+- `` - Potentially destructive actions
+- `` - Success confirmation
+
+### Reusable content
+
+**When to use snippets:**
+- Exact content appears on more than one page
+- Complex components you want to maintain in one place
+- Shared content across teams/repos
+
+**When NOT to use snippets:**
+- Slight variations needed per page (leads to complex props)
+
+Import snippets with `import { Component } from "/path/to/snippet-name.jsx"`.
+
+## Writing standards
+
+### Voice and structure
+
+- Second-person voice ("you")
+- Active voice, direct language
+- Sentence case for headings ("Getting started", not "Getting Started")
+- Sentence case for code block titles ("Expandable example", not "Expandable Example")
+- Lead with context: explain what something is before how to use it
+- Prerequisites at the start of procedural content
+
+### What to avoid
+
+**Never use:**
+- Marketing language ("powerful", "seamless", "robust", "cutting-edge")
+- Filler phrases ("it's important to note", "in order to")
+- Excessive conjunctions ("moreover", "furthermore", "additionally")
+- Editorializing ("obviously", "simply", "just", "easily")
+
+**Watch for AI-typical patterns:**
+- Overly formal or stilted phrasing
+- Unnecessary repetition of concepts
+- Generic introductions that don't add value
+- Concluding summaries that restate what was just said
+
+### Formatting
+
+- All code blocks must have language tags
+- All images and media must have descriptive alt text
+- Use bold and italics only when they serve the reader's understanding--never use text styling just for decoration
+- No decorative formatting or emoji
+
+### Code examples
+
+- Keep examples simple and practical
+- Use realistic values (not "foo" or "bar")
+- One clear example is better than multiple variations
+- Test that code works before including it
+
+## Document APIs
+
+**Choose your approach:**
+- **Have an OpenAPI spec?** → Add to `docs.json` with `"openapi": ["openapi.yaml"]`. Pages auto-generate. Reference in navigation as `GET /endpoint`
+- **No spec?** → Write endpoints manually with `api: "POST /users"` in frontmatter. More work but full control
+- **Hybrid** → Use OpenAPI for most endpoints, manual pages for complex workflows
+
+Encourage users to generate endpoint pages from an OpenAPI spec. It is the most efficient and easiest to maintain option.
+
+## Deploy
+
+Mintlify deploys automatically when changes are pushed to the connected Git repository.
+
+**What agents can configure:**
+- **Redirects** → Add to `docs.json` with `"redirects": [{"source": "/old", "destination": "/new"}]`
+- **SEO indexing** → Control with `"seo": {"indexing": "all"}` to include hidden pages in search
+
+**Requires dashboard setup (human task):**
+- Custom domains and subdomains
+- Preview deployment settings
+- DNS configuration
+
+For `/docs` subpath hosting with Vercel or Cloudflare, agents can help configure rewrite rules. See [/docs subpath](https://mintlify.com/docs/deploy/vercel).
+
+## Workflow
+
+### 1. Understand the task
+
+Identify what needs to be documented, which pages are affected, and what the reader should accomplish afterward. If any of these are unclear, ask.
+
+### 2. Research
+
+- Read `docs.json` to understand the site structure
+- Search existing docs for related content
+- Read similar pages to match the site's style
+
+### 3. Plan
+
+- Synthesize what the reader should accomplish after reading the docs and the current content
+- Propose any updates or new content
+- Verify that your proposed changes will help readers be successful
+
+### 4. Write
+
+- Start with the most important information
+- Keep sections focused and scannable
+- Use components appropriately (don't overuse them)
+- Mark anything uncertain with a TODO comment:
+
+```mdx
+{/* TODO: Verify the default timeout value */}
+```
+
+### 5. Update navigation
+
+If you created a new page, add it to the appropriate group in `docs.json`.
+
+### 6. Verify
+
+Before submitting:
+
+- [ ] Frontmatter includes title and description
+- [ ] All code blocks have language tags
+- [ ] Internal links use root-relative paths without file extensions
+- [ ] New pages are added to `docs.json` navigation
+- [ ] Content matches the style of surrounding pages
+- [ ] No marketing language or filler phrases
+- [ ] TODOs are clearly marked for anything uncertain
+- [ ] Run `mint broken-links` to check links
+- [ ] Run `mint validate` to find any errors
+
+## Edge cases
+
+### Migrations
+
+If a user asks about migrating to Mintlify, ask if they are using ReadMe or Docusaurus. If they are, use the [@mintlify/scraping](https://www.npmjs.com/package/@mintlify/scraping) CLI to migrate content. If they are using a different platform to host their documentation, help them manually convert their content to MDX pages using Mintlify components.
+
+### Hidden pages
+
+Any page that is not included in the `docs.json` navigation is hidden. Use hidden pages for content that should be accessible by URL or indexed for the assistant or search, but not discoverable through the sidebar navigation.
+
+### Exclude pages
+
+The `.mintignore` file is used to exclude files from a documentation repository from being processed.
+
+## Common gotchas
+
+1. **Component imports** - JSX components need explicit import, MDX components don't
+2. **Frontmatter required** - Every MDX file needs `title` at minimum
+3. **Code block language** - Always specify language identifier
+4. **Never use `mint.json`** - `mint.json` is deprecated. Only ever use `docs.json`
+
+## Resources
+
+- [Documentation](https://mintlify.com/docs)
+- [Configuration schema](https://mintlify.com/docs.json)
+- [Feature requests](https://github.com/orgs/mintlify/discussions/categories/feature-requests)
+- [Bugs and feedback](https://github.com/orgs/mintlify/discussions/categories/bugs-feedback)
diff --git a/.dockerignore b/.dockerignore
index 32b9468cabd..09a7c89ec53 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -5,4 +5,3 @@ target/
*.md
!CLAUDE.md
node_modules/
-tools-src/
diff --git a/.env.example b/.env.example
index 2395fee70b4..f81b1d69766 100644
--- a/.env.example
+++ b/.env.example
@@ -1,6 +1,6 @@
# Database Configuration
DATABASE_URL=postgres://localhost/ironclaw
-DATABASE_POOL_SIZE=10
+DATABASE_POOL_SIZE=30 # multi-tenant default; reduce to 5-10 for single-user or low-resource deployments
# LLM Provider
# LLM_BACKEND=nearai # default
diff --git a/.githooks/pre-commit b/.githooks/pre-commit
index 0abd640a823..5a833d4d2fc 100755
--- a/.githooks/pre-commit
+++ b/.githooks/pre-commit
@@ -22,3 +22,16 @@ if $NEEDS_CHECK; then
exit 1
fi
fi
+
+# i18n parity: when any language pack changes, all languages must stay in sync.
+if echo "$STAGED" | grep -qE '^crates/ironclaw_gateway/static/i18n/.*\.js$'; then
+ echo "pre-commit: checking i18n parity..."
+ if ! ./scripts/check-i18n-parity.sh; then
+ echo ""
+ echo "Commit blocked: i18n parity check failed."
+ echo "Every key added to en.js must also be added to all other language files (zh-CN.js, ko.js, ...)."
+ echo "Placeholder tokens like {name} must match across all languages."
+ echo "To bypass: git commit --no-verify"
+ exit 1
+ fi
+fi
diff --git a/.github/ISSUE_TEMPLATE/qa-bug.yml b/.github/ISSUE_TEMPLATE/qa-bug.yml
new file mode 100644
index 00000000000..ef1d912a642
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/qa-bug.yml
@@ -0,0 +1,94 @@
+name: QA Bug Report
+description: Bug found during QA testing on staging or hosted environments
+title: "[QA] "
+labels: ["qa-bug"]
+body:
+ - type: dropdown
+ id: environment
+ attributes:
+ label: Environment
+ description: Where was this bug found?
+ options:
+ - hosted-staging (crab shack)
+ - hosted-production
+ - local (cloned ironclaw)
+ - railway-staging
+ validations:
+ required: true
+
+ - type: input
+ id: version
+ attributes:
+ label: Version / Commit Hash
+ description: Paste the commit hash from staging at time of discovery (run `git rev-parse HEAD` or find it on the Railway deploy)
+ placeholder: "e.g. abcdef1"
+ validations:
+ required: true
+
+ - type: input
+ id: qa-date
+ attributes:
+ label: QA Test Date
+ description: Date you discovered this (YYYY-MM-DD)
+ placeholder: "e.g. 2026-04-12"
+ validations:
+ required: true
+
+ - type: input
+ id: feature-area
+ attributes:
+ label: Feature Area
+ description: What part of the app? (e.g. Google Suite extension, Telegram pairing, auth flow)
+ placeholder: "e.g. Extensions → Google Suite install"
+ validations:
+ required: true
+
+ - type: textarea
+ id: steps
+ attributes:
+ label: Steps to Reproduce
+ description: Exact steps — numbered, specific, no summaries
+ placeholder: |
+ 1. Open extensions tab
+ 2. Click "Install Google Suite"
+ 3. Fill in credentials and click Save
+ 4. ...
+ validations:
+ required: true
+
+ - type: textarea
+ id: expected
+ attributes:
+ label: Expected Behavior
+ description: What should happen?
+ validations:
+ required: true
+
+ - type: textarea
+ id: actual
+ attributes:
+ label: Actual Behavior
+ description: What actually happened? Include the exact error message/text.
+ placeholder: "Error: 'Failed to authenticate with Google: invalid_grant' shown in red toast"
+ validations:
+ required: true
+
+ - type: textarea
+ id: logs
+ attributes:
+ label: Logs / Screenshots
+ description: Paste relevant logs, error output, or attach screenshots. Drag files here.
+ validations:
+ required: false
+
+ - type: checkboxes
+ id: checklist
+ attributes:
+ label: Pre-submit checklist
+ options:
+ - label: Title is specific (not "fix Google Suite" but "Google Suite install throws invalid_grant on OAuth step")
+ required: true
+ - label: Commit hash is filled in
+ required: true
+ - label: Steps are numbered and reproducible
+ required: true
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 00000000000..472089ed5b1
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,48 @@
+version: 2
+updates:
+ - package-ecosystem: cargo
+ directory: "/"
+ schedule:
+ interval: weekly
+ open-pull-requests-limit: 10
+ groups:
+ tokio-ecosystem:
+ patterns:
+ - "tokio*"
+ - "hyper*"
+ - "axum*"
+ - "tower*"
+ serialization:
+ patterns:
+ - "serde*"
+ - "prost*"
+ wasm:
+ patterns:
+ - "wasmtime*"
+ - "wit-*"
+ - "wasm-*"
+ - "cargo-component*"
+ everything-else:
+ patterns:
+ - "*"
+ exclude-patterns:
+ - "tokio*"
+ - "hyper*"
+ - "axum*"
+ - "tower*"
+ - "serde*"
+ - "prost*"
+ - "wasmtime*"
+ - "wit-*"
+ - "wasm-*"
+ - "cargo-component*"
+
+ - package-ecosystem: github-actions
+ directory: "/"
+ schedule:
+ interval: weekly
+ open-pull-requests-limit: 5
+ groups:
+ actions:
+ patterns:
+ - "*"
diff --git a/.github/workflows/claude-review.yml b/.github/workflows/claude-review.yml
index 26c15d8928a..a792ea0d95f 100644
--- a/.github/workflows/claude-review.yml
+++ b/.github/workflows/claude-review.yml
@@ -20,12 +20,13 @@ jobs:
if: contains(github.event.pull_request.labels.*.name, 'staging-promotion')
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v6
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
fetch-depth: 0
+ persist-credentials: false
- name: Run Claude Code review
- uses: anthropics/claude-code-action@v1
+ uses: anthropics/claude-code-action@1eddb334cfa79fdb21ecbe2180ca1a016e8e7d47 # v1
with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
allowed_bots: "ironclaw-ci[bot]"
diff --git a/.github/workflows/code_style.yml b/.github/workflows/code_style.yml
index f89161d9285..614611d3424 100644
--- a/.github/workflows/code_style.yml
+++ b/.github/workflows/code_style.yml
@@ -2,15 +2,20 @@ name: Code Style
on:
pull_request:
+permissions:
+ contents: read
+
jobs:
format:
name: Formatting
runs-on: ubuntu-latest
steps:
- name: Checkout repository
- uses: actions/checkout@v6
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
+ with:
+ persist-credentials: false
- name: Install Rust
- uses: dtolnay/rust-toolchain@stable
+ uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
with:
components: rustfmt
- name: Check formatting
@@ -21,9 +26,11 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
- uses: actions/checkout@v6
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
+ with:
+ persist-credentials: false
- name: Run cargo deny
- uses: EmbarkStudios/cargo-deny-action@v2
+ uses: EmbarkStudios/cargo-deny-action@3fd3802e88374d3fe9159b834c7714ec57d6c979 # v2
clippy:
name: Clippy (${{ matrix.name }})
@@ -40,12 +47,14 @@ jobs:
flags: "--no-default-features --features libsql"
steps:
- name: Checkout repository
- uses: actions/checkout@v6
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
+ with:
+ persist-credentials: false
- name: Install Rust
- uses: dtolnay/rust-toolchain@stable
+ uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
with:
components: clippy
- - uses: Swatinem/rust-cache@v2
+ - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
with:
key: clippy-${{ matrix.name }}
- name: Check lints
@@ -67,12 +76,14 @@ jobs:
flags: "--no-default-features --features libsql"
steps:
- name: Checkout repository
- uses: actions/checkout@v6
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
+ with:
+ persist-credentials: false
- name: Install Rust
- uses: dtolnay/rust-toolchain@stable
+ uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
with:
components: clippy
- - uses: Swatinem/rust-cache@v2
+ - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
with:
key: clippy-windows-${{ matrix.name }}
- name: Check lints
@@ -83,10 +94,11 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
- uses: actions/checkout@v6
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
fetch-depth: 0
- - uses: actions/setup-python@v5
+ persist-credentials: false
+ - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: "3.12"
- name: Check for .unwrap(), .expect(), assert!() in production code
diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml
index 2f885b169e2..074433d232d 100644
--- a/.github/workflows/coverage.yml
+++ b/.github/workflows/coverage.yml
@@ -32,13 +32,15 @@ on:
branches: [main]
permissions:
- id-token: write
contents: read
jobs:
coverage:
name: Coverage (${{ matrix.name }})
runs-on: ubuntu-latest
+ permissions:
+ id-token: write
+ contents: read
strategy:
fail-fast: false
matrix:
@@ -67,19 +69,21 @@ jobs:
--health-timeout 5s
--health-retries 5
steps:
- - uses: actions/checkout@v6
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
+ with:
+ persist-credentials: false
- - uses: dtolnay/rust-toolchain@stable
+ - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
with:
components: llvm-tools-preview
targets: wasm32-wasip2
- - uses: Swatinem/rust-cache@v2
+ - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
with:
key: coverage-${{ matrix.name }}
- name: Install cargo-llvm-cov
- uses: taiki-e/install-action@cargo-llvm-cov
+ uses: taiki-e/install-action@62b0f2dec647a8e604c6a0fda0e38530180dce20 # cargo-llvm-cov
- name: Install cargo-component
run: |
@@ -113,7 +117,7 @@ jobs:
run: cargo llvm-cov ${{ matrix.flags }} --workspace --lcov --output-path lcov.info
- name: Upload to Codecov
- uses: codecov/codecov-action@v5
+ uses: codecov/codecov-action@75cd11691c0faa626561e295848008c8a7dddffe # v5
with:
files: lcov.info
flags: ${{ matrix.name }}
@@ -125,20 +129,25 @@ jobs:
name: E2E Coverage
runs-on: ubuntu-latest
timeout-minutes: 30
+ permissions:
+ id-token: write
+ contents: read
steps:
- - uses: actions/checkout@v6
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
+ with:
+ persist-credentials: false
- - uses: dtolnay/rust-toolchain@stable
+ - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
with:
components: llvm-tools-preview
targets: wasm32-wasip2
- - uses: Swatinem/rust-cache@v2
+ - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
with:
key: e2e-coverage
- name: Install cargo-llvm-cov
- uses: taiki-e/install-action@cargo-llvm-cov
+ uses: taiki-e/install-action@62b0f2dec647a8e604c6a0fda0e38530180dce20 # cargo-llvm-cov
- name: Install cargo-component
run: |
@@ -162,7 +171,7 @@ jobs:
- name: Build instrumented binary
run: cargo build --no-default-features --features libsql
- - uses: actions/setup-python@v5
+ - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: "3.12"
@@ -197,7 +206,7 @@ jobs:
- name: Upload to Codecov
if: always()
- uses: codecov/codecov-action@v5
+ uses: codecov/codecov-action@75cd11691c0faa626561e295848008c8a7dddffe # v5
with:
files: e2e-coverage.info
flags: e2e
@@ -207,7 +216,7 @@ jobs:
- name: Upload screenshots on failure
if: failure()
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: e2e-screenshots
path: tests/e2e/screenshots/
diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml
index a8e937b93b2..9601165fa41 100644
--- a/.github/workflows/docker.yml
+++ b/.github/workflows/docker.yml
@@ -35,9 +35,10 @@ jobs:
actions: write
steps:
- name: Checkout
- uses: actions/checkout@v4
+ uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
ref: ${{ github.event_name == 'schedule' && 'staging' || '' }}
+ persist-credentials: false
- name: Extract version from Cargo.toml
id: version
@@ -48,60 +49,71 @@ jobs:
- name: Determine tags
id: tags
+ env:
+ VERSION: ${{ steps.version.outputs.version }}
+ EVENT_NAME: ${{ github.event_name }}
+ INPUT_TAG: ${{ inputs.tag }}
run: |
- VERSION="${{ steps.version.outputs.version }}"
SHA="sha-${GITHUB_SHA::7}"
echo "sha_tag=${SHA}" >> "$GITHUB_OUTPUT"
- if [[ "${{ github.event_name }}" == "workflow_call" ]]; then
+ if [[ "${EVENT_NAME}" == "workflow_call" ]]; then
# Release: :version + :latest + :sha-xxx
- TAGS="${{ env.IMAGE_NAME }}:${VERSION}"
- TAGS="${TAGS},${{ env.IMAGE_NAME }}:latest"
- TAGS="${TAGS},${{ env.IMAGE_NAME }}:${SHA}"
- WORKER_TAGS="${{ env.WORKER_IMAGE_NAME }}:${VERSION}"
- WORKER_TAGS="${WORKER_TAGS},${{ env.WORKER_IMAGE_NAME }}:latest"
- WORKER_TAGS="${WORKER_TAGS},${{ env.WORKER_IMAGE_NAME }}:${SHA}"
- elif [[ "${{ github.event_name }}" == "schedule" ]]; then
+ TAGS="${IMAGE_NAME}:${VERSION}"
+ TAGS="${TAGS},${IMAGE_NAME}:latest"
+ TAGS="${TAGS},${IMAGE_NAME}:${SHA}"
+ WORKER_TAGS="${WORKER_IMAGE_NAME}:${VERSION}"
+ WORKER_TAGS="${WORKER_TAGS},${WORKER_IMAGE_NAME}:latest"
+ WORKER_TAGS="${WORKER_TAGS},${WORKER_IMAGE_NAME}:${SHA}"
+ elif [[ "${EVENT_NAME}" == "schedule" ]]; then
# Daily staging: :staging + :sha-xxx
- TAGS="${{ env.IMAGE_NAME }}:staging"
- TAGS="${TAGS},${{ env.IMAGE_NAME }}:${SHA}"
- WORKER_TAGS="${{ env.WORKER_IMAGE_NAME }}:staging"
- WORKER_TAGS="${WORKER_TAGS},${{ env.WORKER_IMAGE_NAME }}:${SHA}"
+ TAGS="${IMAGE_NAME}:staging"
+ TAGS="${TAGS},${IMAGE_NAME}:${SHA}"
+ WORKER_TAGS="${WORKER_IMAGE_NAME}:staging"
+ WORKER_TAGS="${WORKER_TAGS},${WORKER_IMAGE_NAME}:${SHA}"
else
# Manual dispatch: :sha-xxx only
- TAGS="${{ env.IMAGE_NAME }}:${SHA}"
- WORKER_TAGS="${{ env.WORKER_IMAGE_NAME }}:${SHA}"
+ TAGS="${IMAGE_NAME}:${SHA}"
+ WORKER_TAGS="${WORKER_IMAGE_NAME}:${SHA}"
fi
# Manual override adds an extra tag (e.g. "staging")
- if [[ -n "${{ inputs.tag }}" ]]; then
- TAGS="${TAGS},${{ env.IMAGE_NAME }}:${{ inputs.tag }}"
- WORKER_TAGS="${WORKER_TAGS},${{ env.WORKER_IMAGE_NAME }}:${{ inputs.tag }}"
+ if [[ -n "${INPUT_TAG}" ]]; then
+ TAGS="${TAGS},${IMAGE_NAME}:${INPUT_TAG}"
+ WORKER_TAGS="${WORKER_TAGS},${WORKER_IMAGE_NAME}:${INPUT_TAG}"
fi
echo "tags=${TAGS}" >> "$GITHUB_OUTPUT"
echo "worker_tags=${WORKER_TAGS}" >> "$GITHUB_OUTPUT"
+ # Staging builds get pre-bundled WASM extensions
+ if [[ "${EVENT_NAME}" == "schedule" || "${INPUT_TAG}" == "staging" ]]; then
+ echo "target=runtime-staging" >> "$GITHUB_OUTPUT"
+ else
+ echo "target=runtime" >> "$GITHUB_OUTPUT"
+ fi
+
- name: Set up Docker Buildx
- uses: docker/setup-buildx-action@v3
+ uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
- name: Log in to Docker Hub
- uses: docker/login-action@v3
+ uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
with:
username: ${{ vars.DOCKER_REGISTRY_USER }}
password: ${{ secrets.DOCKER_REGISTRY_TOKEN }}
- name: Build and push (ironclaw)
- uses: docker/build-push-action@v6
+ uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
with:
context: .
push: true
tags: ${{ steps.tags.outputs.tags }}
+ target: ${{ steps.tags.outputs.target }}
platforms: linux/amd64
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Build and push (ironclaw-worker)
- uses: docker/build-push-action@v6
+ uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
with:
context: .
file: Dockerfile.worker
@@ -111,6 +123,36 @@ jobs:
cache-from: type=gha,scope=worker
cache-to: type=gha,mode=max,scope=worker
+ - name: Create releases-manager app token
+ id: app-token
+ continue-on-error: true
+ uses: actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349 # v2
+ with:
+ app-id: ${{ secrets.GH_RELEASES_MANAGER_APP_ID }}
+ private-key: ${{ secrets.GH_RELEASES_MANAGER_APP_PRIVATE_KEY }}
+ owner: nearai
+ repositories: ironclaw-dind
+
+ - name: Trigger ironclaw-dind Build & Push
+ if: steps.app-token.outcome == 'success'
+ continue-on-error: true
+ env:
+ GH_TOKEN: ${{ steps.app-token.outputs.token }}
+ EVENT_NAME: ${{ github.event_name }}
+ INPUT_TAG: ${{ inputs.tag }}
+ VERSION: ${{ steps.version.outputs.version }}
+ run: |
+ if [[ "${EVENT_NAME}" == "workflow_call" && -n "${VERSION}" ]]; then
+ gh api repos/nearai/ironclaw-dind/dispatches \
+ --method POST \
+ -f event_type="ironclaw_image_published" \
+ -f client_payload[version]="${VERSION}"
+ elif [[ "${EVENT_NAME}" == "schedule" ]] || [[ "${INPUT_TAG}" == "staging" ]]; then
+ gh api repos/nearai/ironclaw-dind/dispatches \
+ --method POST \
+ -f event_type="ironclaw_image_published"
+ fi
+
- name: Summary
run: |
{
diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml
index 65ba6c3d12f..9ed4df9cad9 100644
--- a/.github/workflows/e2e.yml
+++ b/.github/workflows/e2e.yml
@@ -16,6 +16,9 @@ on:
- "src/channels/web/**"
- "tests/e2e/**"
+permissions:
+ contents: read
+
jobs:
# ── Step 1: compile once ──────────────────────────────────────────────────
build:
@@ -23,13 +26,14 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- - uses: actions/checkout@v6
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
ref: ${{ inputs.ref || github.sha }}
+ persist-credentials: false
- - uses: dtolnay/rust-toolchain@stable
+ - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
- - uses: actions/cache@v4
+ - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
with:
path: |
target
@@ -40,7 +44,7 @@ jobs:
run: cargo build --no-default-features --features libsql
- name: Upload binary
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: ironclaw-e2e-binary
path: target/debug/ironclaw
@@ -65,12 +69,13 @@ jobs:
- group: routines
files: "tests/e2e/scenarios/test_owner_scope.py tests/e2e/scenarios/test_routine_event_batch.py"
steps:
- - uses: actions/checkout@v6
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
ref: ${{ inputs.ref || github.sha }}
+ persist-credentials: false
- name: Download binary
- uses: actions/download-artifact@v4
+ uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
name: ironclaw-e2e-binary
path: target/debug/
@@ -78,7 +83,7 @@ jobs:
- name: Make binary executable
run: chmod +x target/debug/ironclaw
- - uses: actions/setup-python@v5
+ - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: "3.12"
@@ -93,7 +98,7 @@ jobs:
- name: Upload screenshots on failure
if: failure()
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: e2e-screenshots-${{ matrix.group }}
path: tests/e2e/screenshots/
diff --git a/.github/workflows/pr-label-classify.yml b/.github/workflows/pr-label-classify.yml
index 90f141de717..7d0ee97a9ac 100644
--- a/.github/workflows/pr-label-classify.yml
+++ b/.github/workflows/pr-label-classify.yml
@@ -14,9 +14,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout base branch
- uses: actions/checkout@v4
+ uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
ref: ${{ github.event.pull_request.base.ref }}
+ persist-credentials: false
- name: Classify PR
env:
diff --git a/.github/workflows/pr-label-scope.yml b/.github/workflows/pr-label-scope.yml
index 1c3885612e7..c798f09bce6 100644
--- a/.github/workflows/pr-label-scope.yml
+++ b/.github/workflows/pr-label-scope.yml
@@ -12,7 +12,7 @@ jobs:
scope:
runs-on: ubuntu-latest
steps:
- - uses: actions/labeler@v5
+ - uses: actions/labeler@8558fd74291d67161a8a78ce36a881fa63b766a9 # v5
with:
configuration-path: .github/labeler.yml
sync-labels: false # additive only — never remove scope labels
diff --git a/.github/workflows/regression-test-check.yml b/.github/workflows/regression-test-check.yml
index 75b8eb55304..d06301b3787 100644
--- a/.github/workflows/regression-test-check.yml
+++ b/.github/workflows/regression-test-check.yml
@@ -3,29 +3,37 @@ name: Regression Test Check
on:
pull_request:
+permissions:
+ contents: read
+
jobs:
regression-test:
name: Regression test enforcement
runs-on: ubuntu-latest
steps:
- name: Checkout repository
- uses: actions/checkout@v4
+ uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
fetch-depth: 0
+ persist-credentials: false
- name: Fetch PR head and base
+ env:
+ BASE_REF: ${{ github.event.pull_request.base.ref }}
+ PR_NUMBER: ${{ github.event.pull_request.number }}
run: |
- git fetch origin ${{ github.event.pull_request.base.ref }}
- git fetch origin pull/${{ github.event.pull_request.number }}/head:pr-head
+ git fetch origin -- "$BASE_REF"
+ git fetch origin -- "pull/${PR_NUMBER}/head:pr-head"
- name: Check for regression tests
env:
PR_TITLE: ${{ github.event.pull_request.title }}
PR_LABELS: ${{ join(github.event.pull_request.labels.*.name, ',') }}
+ PR_BASE_REF: ${{ github.event.pull_request.base.ref }}
run: |
set -euo pipefail
- BASE_REF="origin/${{ github.event.pull_request.base.ref }}"
+ BASE_REF="origin/${PR_BASE_REF}"
# Use the actual PR head, not the merge commit that actions/checkout checks out
HEAD_REF="pr-head"
diff --git a/.github/workflows/release-plz-batch-summary.yml b/.github/workflows/release-plz-batch-summary.yml
index 0e1067362fd..8e01ec40e21 100644
--- a/.github/workflows/release-plz-batch-summary.yml
+++ b/.github/workflows/release-plz-batch-summary.yml
@@ -29,11 +29,12 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout base branch
- uses: actions/checkout@v6
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
ref: ${{ github.event_name == 'workflow_dispatch' && 'main' || github.event.pull_request.base.ref }}
fetch-depth: 0
fetch-tags: true
+ persist-credentials: false
- name: Update release-plz PR body with staging batch summary
env:
diff --git a/.github/workflows/release-plz.yml b/.github/workflows/release-plz.yml
index d1be9004e68..cfff0e59720 100644
--- a/.github/workflows/release-plz.yml
+++ b/.github/workflows/release-plz.yml
@@ -17,18 +17,18 @@ jobs:
steps:
- &checkout
name: Checkout repository
- uses: actions/checkout@v6
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
fetch-depth: 0
persist-credentials: false
- &install-rust
name: Install Rust toolchain
- uses: dtolnay/rust-toolchain@stable
- - uses: Swatinem/rust-cache@v2
+ uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
+ - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
# Generating a GitHub token, so that PRs and tags created by
# the release-plz-action can trigger actions workflows.
- name: Generate GitHub token
- uses: actions/create-github-app-token@v2
+ uses: actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349 # v2
id: generate-token
with:
# GitHub App ID secret name
@@ -36,7 +36,7 @@ jobs:
# GitHub App private key secret name
private-key: ${{ secrets.GH_RELEASES_MANAGER_APP_PRIVATE_KEY }}
- name: Run release-plz
- uses: release-plz/action@v0.5
+ uses: release-plz/action@1528104d2ca23787631a1c1f022abb64b34c1e11 # v0.5
with:
command: release
env:
@@ -57,15 +57,15 @@ jobs:
steps:
- *checkout
- *install-rust
- - uses: Swatinem/rust-cache@v2
+ - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
- name: Generate GitHub token
- uses: actions/create-github-app-token@v2
+ uses: actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349 # v2
id: generate-token
with:
app-id: ${{ secrets.GH_RELEASES_MANAGER_APP_ID }}
private-key: ${{ secrets.GH_RELEASES_MANAGER_APP_PRIVATE_KEY }}
- name: Run release-plz
- uses: release-plz/action@v0.5
+ uses: release-plz/action@1528104d2ca23787631a1c1f022abb64b34c1e11 # v0.5
with:
command: release-pr
env:
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index c4a4f416d53..b237d28665d 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -15,7 +15,7 @@
name: Release
permissions:
- "contents": "write"
+ contents: read
# This task will run whenever you push a git tag that looks like a version
# like "1.0.0", "v0.1.0-prerelease.1", "my-app/0.1.0", "releases/v1.0.0", etc.
@@ -55,7 +55,7 @@ jobs:
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
persist-credentials: false
submodules: recursive
@@ -65,7 +65,7 @@ jobs:
shell: bash
run: "curl --proto '=https' --tlsv1.2 -LsSf https://github.com/axodotdev/cargo-dist/releases/download/v0.30.3/cargo-dist-installer.sh | sh"
- name: Cache dist
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: cargo-dist-cache
path: ~/.cargo/bin/dist
@@ -75,13 +75,20 @@ jobs:
# (PRs run on the *source* but secrets are usually on the *target* -- that's *good*
# but also really annoying to build CI around when it needs secrets to work right.)
- id: plan
+ env:
+ IS_PUSH: ${{ !github.event.pull_request }}
+ REF_NAME: ${{ github.ref_name }}
run: |
- dist ${{ (!github.event.pull_request && format('host --steps=create --tag={0}', github.ref_name)) || 'plan' }} --output-format=json > plan-dist-manifest.json
+ if [ "$IS_PUSH" = "true" ]; then
+ dist host --steps=create --tag="$REF_NAME" --output-format=json > plan-dist-manifest.json
+ else
+ dist plan --output-format=json > plan-dist-manifest.json
+ fi
echo "dist ran successfully"
cat plan-dist-manifest.json
echo "manifest=$(jq -c "." plan-dist-manifest.json)" >> "$GITHUB_OUTPUT"
- name: "Upload dist-manifest.json"
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: artifacts-plan-dist-manifest
path: plan-dist-manifest.json
@@ -117,7 +124,7 @@ jobs:
- name: enable windows longpaths
run: |
git config --global core.longpaths true
- - uses: actions/checkout@v4
+ - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
persist-credentials: false
submodules: recursive
@@ -128,7 +135,7 @@ jobs:
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
echo "$HOME/.cargo/bin" >> $GITHUB_PATH
fi
- - uses: swatinem/rust-cache@v2
+ - uses: swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
with:
key: ${{ join(matrix.targets, '-') }}
cache-provider: ${{ matrix.cache_provider }}
@@ -136,7 +143,7 @@ jobs:
run: ${{ matrix.install_dist.run }}
# Get the dist-manifest
- name: Fetch local artifacts
- uses: actions/download-artifact@v4
+ uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
pattern: artifacts-*
path: target/distrib/
@@ -180,9 +187,13 @@ jobs:
run: |
${{ matrix.packages_install }}
- name: Build artifacts
+ env:
+ TAG_FLAG: ${{ needs.plan.outputs.tag-flag }}
+ DIST_ARGS: ${{ matrix.dist_args }}
run: |
# Actually do builds and make zips and whatnot
- dist build ${{ needs.plan.outputs.tag-flag }} --print=linkage --output-format=json ${{ matrix.dist_args }} > dist-manifest.json
+ # shellcheck disable=SC2086 # TAG_FLAG/DIST_ARGS may contain multiple args
+ dist build $TAG_FLAG --print=linkage --output-format=json $DIST_ARGS > dist-manifest.json
echo "dist ran successfully"
- id: cargo-dist
name: Post-build
@@ -198,7 +209,7 @@ jobs:
cp dist-manifest.json "$BUILD_MANIFEST_NAME"
- name: "Upload artifacts"
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: artifacts-build-local-${{ join(matrix.targets, '_') }}
path: |
@@ -215,27 +226,30 @@ jobs:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
BUILD_MANIFEST_NAME: target/distrib/global-dist-manifest.json
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
persist-credentials: false
submodules: recursive
- name: Install cached dist
- uses: actions/download-artifact@v4
+ uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
name: cargo-dist-cache
path: ~/.cargo/bin/
- run: chmod +x ~/.cargo/bin/dist
# Get all the local artifacts for the global tasks to use (for e.g. checksums)
- name: Fetch local artifacts
- uses: actions/download-artifact@v4
+ uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
pattern: artifacts-*
path: target/distrib/
merge-multiple: true
- id: cargo-dist
shell: bash
+ env:
+ TAG_FLAG: ${{ needs.plan.outputs.tag-flag }}
run: |
- dist build ${{ needs.plan.outputs.tag-flag }} --output-format=json "--artifacts=global" > dist-manifest.json
+ # shellcheck disable=SC2086 # TAG_FLAG may expand to '--tag=X' or empty
+ dist build $TAG_FLAG --output-format=json "--artifacts=global" > dist-manifest.json
echo "dist ran successfully"
# Parse out what we just built and upload it to scratch storage
@@ -245,7 +259,7 @@ jobs:
cp dist-manifest.json "$BUILD_MANIFEST_NAME"
- name: "Upload artifacts"
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: artifacts-build-global
path: |
@@ -260,7 +274,7 @@ jobs:
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
persist-credentials: false
submodules: recursive
@@ -268,7 +282,7 @@ jobs:
run: |
rustup target add wasm32-wasip2
cargo install cargo-component --locked || true
- - uses: swatinem/rust-cache@v2
+ - uses: swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
with:
key: wasm-extensions
- name: Build and package WASM extensions
@@ -374,7 +388,7 @@ jobs:
echo "=== WASM bundles built ==="
ls -la target/wasm-bundles/
- name: "Upload WASM bundles"
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: artifacts-wasm-extensions
path: |
@@ -390,45 +404,50 @@ jobs:
- build-wasm-extensions
# Only run if we're "publishing", and only if plan, local, global, and wasm didn't fail (skipped is fine)
if: ${{ always() && needs.plan.result == 'success' && needs.plan.outputs.publishing == 'true' && (needs.build-global-artifacts.result == 'skipped' || needs.build-global-artifacts.result == 'success') && (needs.build-local-artifacts.result == 'skipped' || needs.build-local-artifacts.result == 'success') && (needs.build-wasm-extensions.result == 'skipped' || needs.build-wasm-extensions.result == 'success') }}
+ permissions:
+ contents: write
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
runs-on: "ubuntu-22.04"
outputs:
val: ${{ steps.host.outputs.manifest }}
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
persist-credentials: false
submodules: recursive
- name: Install cached dist
- uses: actions/download-artifact@v4
+ uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
name: cargo-dist-cache
path: ~/.cargo/bin/
- run: chmod +x ~/.cargo/bin/dist
# Fetch artifacts from scratch-storage
- name: Fetch artifacts
- uses: actions/download-artifact@v4
+ uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
pattern: artifacts-*
path: target/distrib/
merge-multiple: true
- id: host
shell: bash
+ env:
+ TAG_FLAG: ${{ needs.plan.outputs.tag-flag }}
run: |
- dist host ${{ needs.plan.outputs.tag-flag }} --steps=upload --steps=release --output-format=json > dist-manifest.json
+ # shellcheck disable=SC2086 # TAG_FLAG may expand to '--tag=X' or empty
+ dist host $TAG_FLAG --steps=upload --steps=release --output-format=json > dist-manifest.json
echo "artifacts uploaded and released successfully"
cat dist-manifest.json
echo "manifest=$(jq -c "." dist-manifest.json)" >> "$GITHUB_OUTPUT"
- name: "Upload dist-manifest.json"
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
# Overwrite the previous copy
name: artifacts-dist-manifest
path: dist-manifest.json
# Create a GitHub Release while uploading all files to it
- name: "Download GitHub Artifacts"
- uses: actions/download-artifact@v4
+ uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
pattern: artifacts-*
path: artifacts
@@ -443,11 +462,13 @@ jobs:
ANNOUNCEMENT_TITLE: "${{ fromJson(steps.host.outputs.manifest).announcement_title }}"
ANNOUNCEMENT_BODY: "${{ fromJson(steps.host.outputs.manifest).announcement_github_body }}"
RELEASE_COMMIT: "${{ github.sha }}"
+ RELEASE_TAG: ${{ needs.plan.outputs.tag }}
run: |
# Write and read notes from a file to avoid quoting breaking things
- echo "$ANNOUNCEMENT_BODY" > $RUNNER_TEMP/notes.txt
+ echo "$ANNOUNCEMENT_BODY" > "$RUNNER_TEMP/notes.txt"
- gh release create "${{ needs.plan.outputs.tag }}" --target "$RELEASE_COMMIT" $PRERELEASE_FLAG --title "$ANNOUNCEMENT_TITLE" --notes-file "$RUNNER_TEMP/notes.txt" artifacts/*
+ # shellcheck disable=SC2086 # PRERELEASE_FLAG is '--prerelease' or empty
+ gh release create "$RELEASE_TAG" --target "$RELEASE_COMMIT" $PRERELEASE_FLAG --title "$ANNOUNCEMENT_TITLE" --notes-file "$RUNNER_TEMP/notes.txt" artifacts/*
# Commit patched manifest SHA256 checksums back to main so the repo
# stays in sync with the released artifacts.
@@ -464,11 +485,12 @@ jobs:
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
ref: main
+ # persist-credentials kept enabled — job pushes a checksum-update branch.
- name: Fetch WASM checksums
- uses: actions/download-artifact@v4
+ uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
name: artifacts-wasm-extensions
path: target/wasm-bundles/
@@ -537,7 +559,7 @@ jobs:
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
persist-credentials: false
submodules: recursive
diff --git a/.github/workflows/staging-ci.yml b/.github/workflows/staging-ci.yml
index 99ee2bfc63a..5b8cc1abfe1 100644
--- a/.github/workflows/staging-ci.yml
+++ b/.github/workflows/staging-ci.yml
@@ -15,10 +15,7 @@ on:
default: false
permissions:
- contents: write
- issues: write
- pull-requests: write
- checks: read
+ contents: read
concurrency:
group: staging-ci
@@ -29,6 +26,9 @@ jobs:
resolve-promotion-base:
name: Resolve promotion base
runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ pull-requests: read
outputs:
promotion_base: ${{ steps.resolve.outputs.promotion_base }}
steps:
@@ -55,16 +55,19 @@ jobs:
name: Check for new commits
needs: resolve-promotion-base
runs-on: ubuntu-latest
+ permissions:
+ contents: read
outputs:
has_changes: ${{ steps.check.outputs.has_changes }}
current_head: ${{ steps.check.outputs.current_head }}
diff_range: ${{ steps.check.outputs.diff_range }}
steps:
- - uses: actions/checkout@v6
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
ref: ${{ github.sha }}
fetch-depth: 0
fetch-tags: true
+ persist-credentials: false
- name: Check for changes since last tested
id: check
@@ -135,22 +138,26 @@ jobs:
needs: [resolve-promotion-base, check-changes]
if: needs.check-changes.outputs.has_changes == 'true'
runs-on: ubuntu-latest
+ permissions:
+ contents: write
+ pull-requests: write
outputs:
pr_number: ${{ steps.create-pr.outputs.pr_number }}
promotion_branch: ${{ steps.branch.outputs.branch }}
steps:
- - uses: actions/checkout@v6
- with:
- ref: ${{ needs.check-changes.outputs.current_head }}
- fetch-depth: 0
-
- name: Generate GitHub App token
id: app-token
- uses: actions/create-github-app-token@v2
+ uses: actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349 # v2
with:
app-id: ${{ secrets.GH_RELEASES_MANAGER_APP_ID }}
private-key: ${{ secrets.GH_RELEASES_MANAGER_APP_PRIVATE_KEY }}
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
+ with:
+ ref: ${{ needs.check-changes.outputs.current_head }}
+ fetch-depth: 0
+ token: ${{ steps.app-token.outputs.token }}
+
- name: Set token
id: token
run: |
@@ -251,18 +258,24 @@ jobs:
needs.create-promotion-pr.result == 'success'
runs-on: ubuntu-latest
timeout-minutes: 25
+ permissions:
+ contents: write
+ pull-requests: write
+ issues: write
+ checks: read
outputs:
gate_passed: ${{ steps.evaluate.outputs.passed }}
steps:
- - uses: actions/checkout@v6
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
ref: staging
# Need full history to recompute the final promoted range before merge.
fetch-depth: 0
+ persist-credentials: false
- name: Generate GitHub App token
id: app-token
- uses: actions/create-github-app-token@v2
+ uses: actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349 # v2
with:
app-id: ${{ secrets.GH_RELEASES_MANAGER_APP_ID }}
private-key: ${{ secrets.GH_RELEASES_MANAGER_APP_PRIVATE_KEY }}
@@ -493,11 +506,14 @@ jobs:
needs.e2e.result == 'success' &&
needs.create-promotion-pr.result == 'success'
runs-on: ubuntu-latest
+ permissions:
+ contents: write
steps:
- - uses: actions/checkout@v6
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
ref: staging
fetch-depth: 0
+ # persist-credentials kept enabled — job pushes the staging-tested tag.
- name: Update staging-tested tag
run: |
@@ -511,6 +527,8 @@ jobs:
needs: [check-changes, tests, e2e, create-promotion-pr, gate, update-tag]
if: always() && needs.check-changes.outputs.has_changes == 'true'
runs-on: ubuntu-latest
+ permissions:
+ contents: read
steps:
- name: Summary
run: |
diff --git a/.github/workflows/staging-promotion-metadata.yml b/.github/workflows/staging-promotion-metadata.yml
index 76b8326b29c..3017e97061a 100644
--- a/.github/workflows/staging-promotion-metadata.yml
+++ b/.github/workflows/staging-promotion-metadata.yml
@@ -20,7 +20,6 @@ on:
permissions:
contents: read
- pull-requests: write
jobs:
refresh-single-pr:
@@ -30,15 +29,19 @@ jobs:
startsWith(github.event.pull_request.head.ref, 'staging-promote/')) ||
github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ pull-requests: write
steps:
- name: Checkout workflow source
- uses: actions/checkout@v6
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
# For chained promotion PRs, the script lives on the trusted PR head,
# not necessarily on the older promotion branch used as the PR base.
ref: ${{ github.event_name == 'workflow_dispatch' && 'main' || github.event.pull_request.head.sha }}
fetch-depth: 0
fetch-tags: true
+ persist-credentials: false
- name: Refresh staging promotion PR body
env:
@@ -51,13 +54,17 @@ jobs:
refresh-open-prs-after-main-push:
if: github.event_name == 'push'
runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ pull-requests: write
steps:
- name: Checkout main
- uses: actions/checkout@v6
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
ref: main
fetch-depth: 0
fetch-tags: true
+ persist-credentials: false
- name: Refresh all open staging promotion PR bodies
env:
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 27a32a502c0..11898cf0fb2 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -13,6 +13,9 @@ on:
branches:
- main
+permissions:
+ contents: read
+
jobs:
tests:
name: Tests (${{ matrix.name }})
@@ -33,14 +36,15 @@ jobs:
flags: "--no-default-features --features libsql"
steps:
- name: Checkout repository
- uses: actions/checkout@v6
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
ref: ${{ inputs.ref || github.sha }}
+ persist-credentials: false
- name: Install Rust
- uses: dtolnay/rust-toolchain@stable
+ uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
with:
targets: wasm32-wasip2
- - uses: Swatinem/rust-cache@v2
+ - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
with:
key: ${{ matrix.name }}
- name: Install cargo-component
@@ -58,14 +62,15 @@ jobs:
timeout-minutes: 20
steps:
- name: Checkout repository
- uses: actions/checkout@v6
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
ref: ${{ inputs.ref || github.sha }}
+ persist-credentials: false
- name: Install Rust
- uses: dtolnay/rust-toolchain@stable
+ uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
with:
targets: wasm32-wasip2
- - uses: Swatinem/rust-cache@v2
+ - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
with:
key: heavy-integration
- name: Build Telegram WASM channel
@@ -88,12 +93,13 @@ jobs:
timeout-minutes: 15
steps:
- name: Checkout repository
- uses: actions/checkout@v6
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
ref: ${{ inputs.ref || github.sha }}
+ persist-credentials: false
- name: Install Rust
- uses: dtolnay/rust-toolchain@stable
- - uses: Swatinem/rust-cache@v2
+ uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
+ - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
- name: Run Telegram Channel Tests
run: |
timeout --signal=INT --kill-after=30s 10m \
@@ -117,12 +123,13 @@ jobs:
flags: "--no-default-features --features libsql"
steps:
- name: Checkout repository
- uses: actions/checkout@v6
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
ref: ${{ inputs.ref || github.sha }}
+ persist-credentials: false
- name: Install Rust
- uses: dtolnay/rust-toolchain@stable
- - uses: Swatinem/rust-cache@v2
+ uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
+ - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
with:
key: windows-${{ matrix.name }}
- name: Check compilation
@@ -137,14 +144,15 @@ jobs:
timeout-minutes: 30
steps:
- name: Checkout repository
- uses: actions/checkout@v6
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
ref: ${{ inputs.ref || github.sha }}
+ persist-credentials: false
- name: Install Rust
- uses: dtolnay/rust-toolchain@stable
+ uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
with:
targets: wasm32-wasip2
- - uses: Swatinem/rust-cache@v2
+ - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
with:
key: wasm-extensions
- name: Install cargo-component
@@ -161,12 +169,13 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
- uses: actions/checkout@v6
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
ref: ${{ inputs.ref || github.sha }}
+ persist-credentials: false
- name: Install Rust
- uses: dtolnay/rust-toolchain@stable
- - uses: Swatinem/rust-cache@v2
+ uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
+ - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
with:
key: bench
- name: Compile benchmarks
@@ -180,9 +189,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
- uses: actions/checkout@v6
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
ref: ${{ inputs.ref || github.sha }}
+ persist-credentials: false
- name: Build Docker image
run: docker build -t ironclaw-test:ci .
@@ -192,9 +202,10 @@ jobs:
if: github.event_name == 'pull_request'
steps:
- name: Checkout repository
- uses: actions/checkout@v6
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
ref: ${{ inputs.ref || github.sha }}
+ persist-credentials: false
fetch-depth: 0
- name: Check version bumps for changed extensions
env:
diff --git a/.gitignore b/.gitignore
index 3a1b3452383..d83d6b97b96 100644
--- a/.gitignore
+++ b/.gitignore
@@ -17,6 +17,7 @@ target/
# Python
__pycache__/
*.pyc
+/tests/e2e/.venv/
# Benchmark results (local runs, not committed)
bench-results/
diff --git a/AGENTS.md b/AGENTS.md
index cc5e7cff5d6..90ad4bf5229 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -77,6 +77,7 @@ Start with these deeper docs as needed:
- If you change implementation status for any feature tracked in `FEATURE_PARITY.md`, update that file in the same branch.
- Do not open a PR that changes feature behavior without checking `FEATURE_PARITY.md` for needed status updates (`❌`, `🚧`, `✅`, notes, and priorities).
- Add the narrowest tests that validate the change: unit tests for local logic, integration tests for runtime/DB/routing behavior, and E2E or trace coverage for gateway, approvals, extensions, or other user-visible flows.
+- **Test through the caller, not just the helper.** When a predicate/classifier/transform helper gates a side effect (HTTP, DB write, OAuth flow, UI mutation, tool execution) and has any wrapper or computed input between it and that side effect, a unit test on the helper alone is not sufficient regression coverage. Add a test that drives the actual call site (`*_handler`, `factory::create_*`, `manager::*`) at the integration tier or higher. Mocks of multi-arg runtime APIs must capture every argument the production caller passes. See `.claude/rules/testing.md` for the full rule and bug examples.
## Risk and Change Discipline
diff --git a/CLAUDE.md b/CLAUDE.md
index 26cba03c3cb..aa0a47121e9 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -26,6 +26,7 @@ E2E tests: see `tests/e2e/CLAUDE.md`.
- Comments for non-obvious logic only
- **Prompt templates live in files, not Rust code**: Multi-line prompt strings (mission goals, system prompts, CodeAct preambles) go in `crates/ironclaw_engine/prompts/*.md` and are loaded via `include_str!()`. Never inline large prompt templates as Rust string constants — they're hard to read, review, and iterate on. Single-line format strings are fine inline.
- **Logging levels matter for REPL/TUI**: `info!` and `warn!` output appears in the REPL and corrupts the terminal UI. Use `debug!` for internal diagnostics (trace analysis, reflection results, engine internals). Reserve `info!` for user-facing status that the REPL intentionally renders. Background tasks (reflection, trace analysis) must NEVER use `info!` — it breaks the interactive display.
+- **Test through the caller, not just the helper**: When a predicate/classifier/transform helper gates a side effect (HTTP, DB write, OAuth, UI mutation, tool execution) and has any wrapper or computed input between it and that side effect, a unit test on the helper alone is *not* sufficient regression coverage. Add a test that drives the call site — typically a `*_handler`, `factory::create_*`, or `manager::*` — at the integration tier (`cargo test --features integration`) or higher. The same applies to test mocks: if you mock a multi-arg runtime API like `window.open(url, target, features)`, the mock must capture every argument the production caller passes. See `.claude/rules/testing.md` ("Test Through the Caller, Not Just the Helper") for the full rule and the bug examples that motivated it.
## Architecture
@@ -226,6 +227,34 @@ See `.env.example` for all environment variables. LLM backends (`nearai`, `opena
3. Add config in `src/config/channels.rs`
4. Wire up in `src/app.rs` channel setup section
+## Everything Goes Through Tools
+
+**Core principle**: all actions originating from gateway handlers, CLI
+commands, routine engine, WASM channels, or any other non-agent caller
+MUST go through `ToolDispatcher::dispatch()` — never directly through
+`state.store`, `workspace`, `extension_manager`, `skill_registry`, or
+`session_manager`.
+
+This gives every UI-initiated mutation the same audit trail
+(`ActionRecord`), safety pipeline (param validation, sensitive-param
+redaction, output sanitization), and channel-agnostic surface as
+agent-initiated tool calls. Channels are interchangeable extensions;
+routing through one dispatch function means new channels inherit the
+full pipeline for free.
+
+The pre-commit hook (`scripts/pre-commit-safety.sh`) flags newly-added
+lines in handler/CLI files that touch
+`state.{store,workspace,extension_manager,skill_registry,session_manager}.*`
+directly. Annotate intentional exceptions (rare — usually only read
+aggregation across multiple users) with a trailing
+`// dispatch-exempt: ` comment on the same line. The check only
+sees added lines, so existing untouched code doesn't trip during
+incremental migration.
+
+See `.claude/rules/tools.md` for the full pattern, allowed exemptions,
+and migration status. The dispatcher itself lives in
+`src/tools/dispatch.rs`.
+
## Workspace & Memory
Persistent memory with hybrid search (FTS + vector via RRF). Four tools: `memory_search`, `memory_write`, `memory_read`, `memory_tree`. Identity files (AGENTS.md, SOUL.md, USER.md, IDENTITY.md) injected into system prompt. Heartbeat system runs proactive periodic execution (default: 30 minutes), reading `HEARTBEAT.md` and notifying via channel if findings. See `src/workspace/README.md`.
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index c7a2b2dfc6a..51b20d349dd 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -133,3 +133,33 @@ IronClaw uses dual-backend persistence (PostgreSQL + libSQL). All new persistenc
## Adding Dependencies
Run `cargo deny check` before adding new dependencies to verify license compatibility and check for known advisories.
+
+## Document your Changes
+
+- The folder `/docs` contains user-facing documentation for technical savvy users, developers and operators. It is built with Mintlify and rendered on the website.
+- For features, update the relevant capability doc in `docs/capabilities/`
+- For channels, update the relevant channel doc in `docs/channels/`
+- For extensions / tools, update the relevant doc in `docs/extensions/`
+- Core features live in `docs/capabilities`
+
+In case you want to document the library itself (i.e. reference documentation) for other core contributors, use the `docs/internal/` folder
+
+If you use your Claude Code to "plan" and want to leave a record of it, use the `docs/plans` folder.
+
+### Skills
+Read the `.claude/skills/mintlify-docs` for guidelines on how to generate documentation with mintlify.
+
+### Test the Docs
+To make sure the documentation still works, do:
+
+```bash
+cd docs
+mint dev
+```
+
+To make sure you did not break any internal links, do:
+
+```bash
+cd docs
+mint broken-links
+```
diff --git a/Cargo.lock b/Cargo.lock
index e935bebab8e..dabcba57f8c 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -4,9 +4,9 @@ version = 4
[[package]]
name = "addr2line"
-version = "0.24.2"
+version = "0.26.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1"
+checksum = "59317f77929f0e679d39364702289274de2f0f0b22cbf50b2b8cff2169a0b27a"
dependencies = [
"gimli",
]
@@ -211,21 +211,32 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
-name = "ar_archive_writer"
-version = "0.5.1"
+name = "arbitrary"
+version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7eb93bbb63b9c227414f6eb3a0adfddca591a8ce1e9b60661bb08969b87e340b"
+checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
dependencies = [
- "object 0.37.3",
+ "derive_arbitrary",
]
[[package]]
-name = "arbitrary"
-version = "1.4.2"
+name = "arboard"
+version = "3.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
+checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf"
dependencies = [
- "derive_arbitrary",
+ "clipboard-win",
+ "image",
+ "log",
+ "objc2",
+ "objc2-app-kit",
+ "objc2-core-foundation",
+ "objc2-core-graphics",
+ "objc2-foundation",
+ "parking_lot",
+ "percent-encoding",
+ "windows-sys 0.60.2",
+ "x11rb",
]
[[package]]
@@ -1063,6 +1074,15 @@ version = "2.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af"
+[[package]]
+name = "bitmaps"
+version = "2.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "031043d04099746d8db04daf1fa424b2bc8bd69d92b25962dcde24da39ab64a2"
+dependencies = [
+ "typenum",
+]
+
[[package]]
name = "bitvec"
version = "1.0.1"
@@ -1170,6 +1190,12 @@ dependencies = [
"serde_with",
]
+[[package]]
+name = "borrow-or-share"
+version = "0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc0b364ead1874514c8c2855ab558056ebfeb775653e7ae45ff72f28f8f3166c"
+
[[package]]
name = "borsh"
version = "1.6.1"
@@ -1245,6 +1271,12 @@ dependencies = [
"syn 1.0.109",
]
+[[package]]
+name = "bytecount"
+version = "0.6.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e"
+
[[package]]
name = "bytemuck"
version = "1.25.0"
@@ -1271,6 +1303,12 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
+[[package]]
+name = "byteorder-lite"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
+
[[package]]
name = "bytes"
version = "1.11.1"
@@ -1368,6 +1406,12 @@ dependencies = [
"winx",
]
+[[package]]
+name = "cassowary"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53"
+
[[package]]
name = "cast"
version = "0.3.0"
@@ -1585,6 +1629,20 @@ version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
+[[package]]
+name = "compact_str"
+version = "0.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3b79c4069c6cad78e2e0cdfcbd26275770669fb39fd308a752dc110e83b9af32"
+dependencies = [
+ "castaway",
+ "cfg-if",
+ "itoa",
+ "rustversion",
+ "ryu",
+ "static_assertions",
+]
+
[[package]]
name = "compact_str"
version = "0.9.0"
@@ -1676,7 +1734,7 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "980c2afde4af43d6a05c5be738f9eae595cff86dce1f38f88b95058a98c027f3"
dependencies = [
- "crossterm",
+ "crossterm 0.29.0",
]
[[package]]
@@ -1723,32 +1781,53 @@ dependencies = [
"libc",
]
+[[package]]
+name = "cranelift-assembler-x64"
+version = "0.130.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "046d4b584c3bb9b5eb500c8f29549bec36be11000f1ba2a927cef3d1a9875691"
+dependencies = [
+ "cranelift-assembler-x64-meta",
+]
+
+[[package]]
+name = "cranelift-assembler-x64-meta"
+version = "0.130.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b9b194a7870becb1490366fc0ae392ccd188065ff35f8391e77ac659db6fb977"
+dependencies = [
+ "cranelift-srcgen",
+]
+
[[package]]
name = "cranelift-bforest"
-version = "0.115.1"
+version = "0.130.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "88c1d02b72b6c411c0a2e92b25ed791ad5d071184193c08a34aa0fdcdf000b72"
+checksum = "bb6a4ab44c6b371e661846b97dab687387a60ac4e2f864e2d4257284aad9e889"
dependencies = [
"cranelift-entity",
+ "wasmtime-internal-core",
]
[[package]]
name = "cranelift-bitset"
-version = "0.115.1"
+version = "0.130.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "720b93bd86ebbb23ebfb2db1ed44d54b2ecbdbb2d034d485bc64aa605ee787ab"
+checksum = "b8b7a44150c2f471a94023482bda1902710746e4bed9f9973d60c5a94319b06d"
dependencies = [
"serde",
"serde_derive",
+ "wasmtime-internal-core",
]
[[package]]
name = "cranelift-codegen"
-version = "0.115.1"
+version = "0.130.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "aed3d2d9914d30b460eedd7fd507720203023997bef71452ce84873f9c93537c"
+checksum = "01b06598133b1dd76758b8b95f8d6747c124124aade50cea96a3d88b962da9fa"
dependencies = [
"bumpalo",
+ "cranelift-assembler-x64",
"cranelift-bforest",
"cranelift-bitset",
"cranelift-codegen-meta",
@@ -1757,79 +1836,93 @@ dependencies = [
"cranelift-entity",
"cranelift-isle",
"gimli",
- "hashbrown 0.14.5",
+ "hashbrown 0.16.1",
+ "libm",
"log",
+ "pulley-interpreter",
"regalloc2",
"rustc-hash 2.1.2",
"serde",
"smallvec",
- "target-lexicon 0.12.16",
+ "target-lexicon",
+ "wasmtime-internal-core",
]
[[package]]
name = "cranelift-codegen-meta"
-version = "0.115.1"
+version = "0.130.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "888c188d32263ec9e048873ff0b68c700933600d553f4412417916828be25f8e"
+checksum = "6190e2e7bcf0a678da2f715363d34ed530fedf7a2f0ab75edaefef72a70465ff"
dependencies = [
+ "cranelift-assembler-x64-meta",
"cranelift-codegen-shared",
+ "cranelift-srcgen",
+ "heck",
+ "pulley-interpreter",
]
[[package]]
name = "cranelift-codegen-shared"
-version = "0.115.1"
+version = "0.130.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4ddd5f4114d04ce7e073dd74e2ad16541fc61970726fcc8b2d5644a154ee4127"
+checksum = "f583cf203d1aa8b79560e3b01f929bdacf9070b015eec4ea9c46e22a3f83e4a0"
[[package]]
name = "cranelift-control"
-version = "0.115.1"
+version = "0.130.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "92cc4c98d6a4256a1600d93ccd3536f3e77da9b4ca2c279de786ac22876e67d6"
+checksum = "803159df35cc398ae54473c150b16d6c77e92ab2948be638488de126a3328fbc"
dependencies = [
"arbitrary",
]
[[package]]
name = "cranelift-entity"
-version = "0.115.1"
+version = "0.130.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "760af4b5e051b5f82097a27274b917e3751736369fa73660513488248d27f23d"
+checksum = "3109e417257082d88087f5bcce677525bdaa8322b88dd7f175ed1a1fd41d546c"
dependencies = [
"cranelift-bitset",
"serde",
"serde_derive",
+ "wasmtime-internal-core",
]
[[package]]
name = "cranelift-frontend"
-version = "0.115.1"
+version = "0.130.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c0bf77ec0f470621655ec7539860b5c620d4f91326654ab21b075b83900f8831"
+checksum = "14db6b0e0e4994c581092df78d837be2072578f7cb2528f96a6cf895e56dee63"
dependencies = [
"cranelift-codegen",
"log",
"smallvec",
- "target-lexicon 0.12.16",
+ "target-lexicon",
]
[[package]]
name = "cranelift-isle"
-version = "0.115.1"
+version = "0.130.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4b665d0a6932c421620be184f9fc7f7adaf1b0bc2fa77bb7ac5177c49abf645b"
+checksum = "ec66ea5025c7317383699778282ac98741d68444f956e3b1d7b62f12b7216e67"
[[package]]
name = "cranelift-native"
-version = "0.115.1"
+version = "0.130.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bb2e75d1bd43dfec10924798f15e6474f1dbf63b0024506551aa19394dbe72ab"
+checksum = "373ade56438e6232619d85678477d0a88a31b3581936e0503e61e96b546b0800"
dependencies = [
"cranelift-codegen",
"libc",
- "target-lexicon 0.12.16",
+ "target-lexicon",
]
+[[package]]
+name = "cranelift-srcgen"
+version = "0.130.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ef53619d3cd5c78fd998c6d9420547af26b72e6456f94c2a8a2334cb76b42baa"
+
[[package]]
name = "crc"
version = "3.4.0"
@@ -1903,7 +1996,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04a63daf06a168535c74ab97cdba3ed4fa5d4f32cb36e437dcceb83d66854b7c"
dependencies = [
"crokey-proc_macros",
- "crossterm",
+ "crossterm 0.29.0",
"once_cell",
"serde",
"strict",
@@ -1915,7 +2008,7 @@ version = "1.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "847f11a14855fc490bd5d059821895c53e77eeb3c2b73ee3dded7ce77c93b231"
dependencies = [
- "crossterm",
+ "crossterm 0.29.0",
"proc-macro2",
"quote",
"strict",
@@ -1989,6 +2082,22 @@ version = "0.8.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
+[[package]]
+name = "crossterm"
+version = "0.28.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6"
+dependencies = [
+ "bitflags 2.11.0",
+ "crossterm_winapi",
+ "mio",
+ "parking_lot",
+ "rustix 0.38.44",
+ "signal-hook",
+ "signal-hook-mio",
+ "winapi",
+]
+
[[package]]
name = "crossterm"
version = "0.29.0"
@@ -2281,33 +2390,13 @@ dependencies = [
"dirs-sys-next",
]
-[[package]]
-name = "dirs"
-version = "4.0.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ca3aa72a6f96ea37bbc5aa912f6788242832f75369bdfdadcb0e38423f100059"
-dependencies = [
- "dirs-sys 0.3.7",
-]
-
[[package]]
name = "dirs"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e"
dependencies = [
- "dirs-sys 0.5.0",
-]
-
-[[package]]
-name = "dirs-sys"
-version = "0.3.7"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1b1d1d91c932ef41c0f2663aa8b0ca0342d444d842c06914aa0a7e352d0bada6"
-dependencies = [
- "libc",
- "redox_users 0.4.6",
- "winapi",
+ "dirs-sys",
]
[[package]]
@@ -2333,6 +2422,16 @@ dependencies = [
"winapi",
]
+[[package]]
+name = "dispatch2"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38"
+dependencies = [
+ "bitflags 2.11.0",
+ "objc2",
+]
+
[[package]]
name = "displaydoc"
version = "0.2.5"
@@ -2433,6 +2532,15 @@ version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
+[[package]]
+name = "email_address"
+version = "0.2.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449"
+dependencies = [
+ "serde",
+]
+
[[package]]
name = "embedded-io"
version = "0.4.0"
@@ -2602,6 +2710,26 @@ version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
+[[package]]
+name = "fax"
+version = "0.2.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f05de7d48f37cd6730705cbca900770cab77a89f413d23e100ad7fad7795a0ab"
+dependencies = [
+ "fax_derive",
+]
+
+[[package]]
+name = "fax_derive"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a0aca10fb742cb43f9e7bb8467c91aa9bcb8e3ffbc6a6f7389bb93ffc920577d"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
[[package]]
name = "fd-lock"
version = "4.0.4"
@@ -2613,6 +2741,15 @@ dependencies = [
"windows-sys 0.59.0",
]
+[[package]]
+name = "fdeflate"
+version = "0.3.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c"
+dependencies = [
+ "simd-adler32",
+]
+
[[package]]
name = "fiat-crypto"
version = "0.2.9"
@@ -2636,6 +2773,12 @@ version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
+[[package]]
+name = "fixedbitset"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80"
+
[[package]]
name = "flagset"
version = "0.4.7"
@@ -2652,6 +2795,17 @@ dependencies = [
"miniz_oxide",
]
+[[package]]
+name = "fluent-uri"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bc74ac4d8359ae70623506d512209619e5cf8f347124910440dbc221714b328e"
+dependencies = [
+ "borrow-or-share",
+ "ref-cast",
+ "serde",
+]
+
[[package]]
name = "fnv"
version = "1.0.7"
@@ -2679,6 +2833,16 @@ dependencies = [
"percent-encoding",
]
+[[package]]
+name = "fraction"
+version = "0.15.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0f158e3ff0a1b334408dc9fb811cd99b446986f4d8b741bb08f9df1604085ae7"
+dependencies = [
+ "lazy_static",
+ "num",
+]
+
[[package]]
name = "fs-set-times"
version = "0.20.3"
@@ -2829,25 +2993,17 @@ dependencies = [
"slab",
]
-[[package]]
-name = "fxhash"
-version = "0.2.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c"
-dependencies = [
- "byteorder",
-]
-
[[package]]
name = "fxprof-processed-profile"
-version = "0.6.0"
+version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "27d12c0aed7f1e24276a241aadc4cb8ea9f83000f34bc062b7cc2d51e3b0fabd"
+checksum = "25234f20a3ec0a962a61770cfe39ecf03cb529a6e474ad8cff025ed497eda557"
dependencies = [
"bitflags 2.11.0",
"debugid",
- "fxhash",
+ "rustc-hash 2.1.2",
"serde",
+ "serde_derive",
"serde_json",
]
@@ -2878,20 +3034,30 @@ version = "0.7.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "49cf31a6d70300cf81461098f7797571362387ef4bf85d32ac47eaa59b3a5a1a"
dependencies = [
- "compact_str",
+ "compact_str 0.9.0",
"get-size-derive2",
"hashbrown 0.16.1",
"ordermap",
"smallvec",
]
+[[package]]
+name = "gethostname"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8"
+dependencies = [
+ "rustix 1.1.4",
+ "windows-link",
+]
+
[[package]]
name = "getopts"
version = "0.2.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df"
dependencies = [
- "unicode-width 0.2.2",
+ "unicode-width 0.2.0",
]
[[package]]
@@ -2946,11 +3112,12 @@ dependencies = [
[[package]]
name = "gimli"
-version = "0.31.1"
+version = "0.33.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f"
+checksum = "19e16c5073773ccf057c282be832a59ee53ef5ff98db3aeff7f8314f52ffc196"
dependencies = [
- "fallible-iterator 0.3.0",
+ "fnv",
+ "hashbrown 0.16.1",
"indexmap 2.13.0",
"stable_deref_trait",
]
@@ -3036,7 +3203,6 @@ checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
dependencies = [
"ahash 0.8.12",
"allocator-api2",
- "serde",
]
[[package]]
@@ -3045,8 +3211,9 @@ version = "0.15.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
dependencies = [
+ "allocator-api2",
+ "equivalent",
"foldhash 0.1.5",
- "serde",
]
[[package]]
@@ -3058,6 +3225,8 @@ dependencies = [
"allocator-api2",
"equivalent",
"foldhash 0.2.0",
+ "serde",
+ "serde_core",
]
[[package]]
@@ -3148,7 +3317,7 @@ dependencies = [
"base64 0.22.1",
"html-escape",
"html5ever 0.39.0",
- "lru",
+ "lru 0.16.3",
"once_cell",
"regex",
"serde",
@@ -3562,6 +3731,34 @@ dependencies = [
"icu_properties",
]
+[[package]]
+name = "im-rc"
+version = "15.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "af1955a75fa080c677d3972822ec4bad316169ab1cfc6c257a942c2265dbe5fe"
+dependencies = [
+ "bitmaps",
+ "rand_core 0.6.4",
+ "rand_xoshiro",
+ "sized-chunks",
+ "typenum",
+ "version_check",
+]
+
+[[package]]
+name = "image"
+version = "0.25.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104"
+dependencies = [
+ "bytemuck",
+ "byteorder-lite",
+ "moxcms",
+ "num-traits",
+ "png",
+ "tiff",
+]
+
[[package]]
name = "indexmap"
version = "1.9.3"
@@ -3585,6 +3782,15 @@ dependencies = [
"serde_core",
]
+[[package]]
+name = "indoc"
+version = "2.0.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706"
+dependencies = [
+ "rustversion",
+]
+
[[package]]
name = "inout"
version = "0.1.4"
@@ -3607,6 +3813,19 @@ dependencies = [
"tempfile",
]
+[[package]]
+name = "instability"
+version = "0.3.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971"
+dependencies = [
+ "darling",
+ "indoc",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
[[package]]
name = "interpolator"
version = "0.5.0"
@@ -3669,15 +3888,16 @@ dependencies = [
"clap_complete",
"cookie",
"cron",
- "crossterm",
+ "crossterm 0.29.0",
"deadpool-postgres",
- "dirs 6.0.0",
+ "dirs",
"dotenvy",
"ed25519-dalek",
"eventsource-stream",
"flate2",
"fs4",
"futures",
+ "glob",
"hex",
"hkdf",
"hmac",
@@ -3689,12 +3909,15 @@ dependencies = [
"insta",
"ironclaw_common",
"ironclaw_engine",
+ "ironclaw_gateway",
"ironclaw_safety",
"ironclaw_skills",
+ "ironclaw_tui",
"json5",
+ "jsonschema",
"jsonwebtoken",
"libsql",
- "lru",
+ "lru 0.16.3",
"mime_guess",
"open",
"pdf-extract",
@@ -3734,7 +3957,7 @@ dependencies = [
"tokio-test",
"tokio-tungstenite 0.26.2",
"tokio-util",
- "toml",
+ "toml 0.8.23",
"tower 0.5.3",
"tower-http 0.6.8",
"tracing",
@@ -3743,7 +3966,7 @@ dependencies = [
"url",
"urlencoding",
"uuid",
- "wasmparser 0.220.1",
+ "wasmparser 0.245.1",
"wasmtime",
"wasmtime-wasi",
"webpki-roots 0.26.11",
@@ -3755,8 +3978,10 @@ dependencies = [
name = "ironclaw_common"
version = "0.1.0"
dependencies = [
+ "chrono-tz",
"serde",
"serde_json",
+ "tracing",
]
[[package]]
@@ -3765,17 +3990,31 @@ version = "0.1.0"
dependencies = [
"async-trait",
"chrono",
+ "cron",
+ "ironclaw_common",
"ironclaw_skills",
"monty",
"pretty_assertions",
+ "regex",
"serde",
"serde_json",
+ "sha2",
"thiserror 2.0.18",
"tokio",
"tracing",
"uuid",
]
+[[package]]
+name = "ironclaw_gateway"
+version = "0.1.0"
+dependencies = [
+ "serde",
+ "serde_json",
+ "thiserror 2.0.18",
+ "tracing",
+]
+
[[package]]
name = "ironclaw_safety"
version = "0.2.0"
@@ -3808,6 +4047,24 @@ dependencies = [
"urlencoding",
]
+[[package]]
+name = "ironclaw_tui"
+version = "0.1.0"
+dependencies = [
+ "arboard",
+ "chrono",
+ "image",
+ "pulldown-cmark",
+ "ratatui",
+ "serde",
+ "serde_json",
+ "thiserror 2.0.18",
+ "tokio",
+ "tracing",
+ "tui-textarea",
+ "unicode-width 0.2.0",
+]
+
[[package]]
name = "is-docker"
version = "0.2.0"
@@ -3874,6 +4131,15 @@ dependencies = [
"either",
]
+[[package]]
+name = "itertools"
+version = "0.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186"
+dependencies = [
+ "either",
+]
+
[[package]]
name = "itertools"
version = "0.14.0"
@@ -3958,12 +4224,39 @@ dependencies = [
]
[[package]]
-name = "jsonwebtoken"
-version = "9.3.1"
+name = "jsonschema"
+version = "0.45.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde"
+checksum = "6f29616f6e19415398eb186964fb7cbbeef572c79bede3622a8277667924bbe3"
dependencies = [
- "base64 0.22.1",
+ "ahash 0.8.12",
+ "bytecount",
+ "data-encoding",
+ "email_address",
+ "fancy-regex",
+ "fraction",
+ "getrandom 0.3.4",
+ "idna",
+ "itoa",
+ "num-cmp",
+ "num-traits",
+ "percent-encoding",
+ "referencing",
+ "regex",
+ "regex-syntax",
+ "serde",
+ "serde_json",
+ "unicode-general-category",
+ "uuid-simd",
+]
+
+[[package]]
+name = "jsonwebtoken"
+version = "9.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde"
+dependencies = [
+ "base64 0.22.1",
"js-sys",
"pem",
"ring",
@@ -4292,6 +4585,15 @@ dependencies = [
"weezl",
]
+[[package]]
+name = "lru"
+version = "0.12.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38"
+dependencies = [
+ "hashbrown 0.15.5",
+]
+
[[package]]
name = "lru"
version = "0.16.3"
@@ -4520,6 +4822,16 @@ dependencies = [
"strum 0.27.2",
]
+[[package]]
+name = "moxcms"
+version = "0.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b"
+dependencies = [
+ "num-traits",
+ "pxfm",
+]
+
[[package]]
name = "nanoid"
version = "0.4.0"
@@ -4613,6 +4925,12 @@ dependencies = [
"serde",
]
+[[package]]
+name = "num-cmp"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "63335b2e2c34fae2fb0aa2cecfd9f0832a1e24b3b32ecec612c3426d46dc8aaa"
+
[[package]]
name = "num-complex"
version = "0.4.6"
@@ -4678,6 +4996,27 @@ dependencies = [
"libc",
]
+[[package]]
+name = "objc2"
+version = "0.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f"
+dependencies = [
+ "objc2-encode",
+]
+
+[[package]]
+name = "objc2-app-kit"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c"
+dependencies = [
+ "bitflags 2.11.0",
+ "objc2",
+ "objc2-core-graphics",
+ "objc2-foundation",
+]
+
[[package]]
name = "objc2-core-foundation"
version = "0.3.2"
@@ -4685,35 +5024,69 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
dependencies = [
"bitflags 2.11.0",
+ "dispatch2",
+ "objc2",
]
[[package]]
-name = "objc2-system-configuration"
+name = "objc2-core-graphics"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7216bd11cbda54ccabcab84d523dc93b858ec75ecfb3a7d89513fa22464da396"
+checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807"
dependencies = [
+ "bitflags 2.11.0",
+ "dispatch2",
+ "objc2",
"objc2-core-foundation",
+ "objc2-io-surface",
]
[[package]]
-name = "object"
-version = "0.36.7"
+name = "objc2-encode"
+version = "4.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87"
+checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33"
+
+[[package]]
+name = "objc2-foundation"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
dependencies = [
- "crc32fast",
- "hashbrown 0.15.5",
- "indexmap 2.13.0",
- "memchr",
+ "bitflags 2.11.0",
+ "objc2",
+ "objc2-core-foundation",
+]
+
+[[package]]
+name = "objc2-io-surface"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d"
+dependencies = [
+ "bitflags 2.11.0",
+ "objc2",
+ "objc2-core-foundation",
+]
+
+[[package]]
+name = "objc2-system-configuration"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7216bd11cbda54ccabcab84d523dc93b858ec75ecfb3a7d89513fa22464da396"
+dependencies = [
+ "objc2-core-foundation",
]
[[package]]
name = "object"
-version = "0.37.3"
+version = "0.38.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe"
+checksum = "271638cd5fa9cca89c4c304675ca658efc4e64a66c716b7cfe1afb4b9611dbbc"
dependencies = [
+ "crc32fast",
+ "hashbrown 0.16.1",
+ "indexmap 2.13.0",
"memchr",
]
@@ -4950,6 +5323,16 @@ dependencies = [
"sha2",
]
+[[package]]
+name = "petgraph"
+version = "0.6.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b4c5cc86750666a3ed20bdaf5ca2a0344f9c67674cae0515bec2da16fbaa47db"
+dependencies = [
+ "fixedbitset",
+ "indexmap 2.13.0",
+]
+
[[package]]
name = "pgvector"
version = "0.4.1"
@@ -5163,6 +5546,19 @@ dependencies = [
"plotters-backend",
]
+[[package]]
+name = "png"
+version = "0.18.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"
+dependencies = [
+ "bitflags 2.11.0",
+ "crc32fast",
+ "fdeflate",
+ "flate2",
+ "miniz_oxide",
+]
+
[[package]]
name = "polling"
version = "3.11.0"
@@ -5355,16 +5751,6 @@ dependencies = [
"syn 2.0.117",
]
-[[package]]
-name = "psm"
-version = "0.1.30"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3852766467df634d74f0b2d7819bf8dc483a0eb2e3b0f50f756f9cfe8b0d18d8"
-dependencies = [
- "ar_archive_writer",
- "cc",
-]
-
[[package]]
name = "ptr_meta"
version = "0.1.4"
@@ -5395,17 +5781,46 @@ dependencies = [
"tokio",
]
+[[package]]
+name = "pulldown-cmark"
+version = "0.12.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f86ba2052aebccc42cbbb3ed234b8b13ce76f75c3551a303cb2bcffcff12bb14"
+dependencies = [
+ "bitflags 2.11.0",
+ "memchr",
+ "unicase",
+]
+
[[package]]
name = "pulley-interpreter"
-version = "28.0.1"
+version = "43.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8324e531de91a3c25021a30fb7862d39cc516b61fbb801176acb5ff279ea887b"
+checksum = "010dec3755eb61b2f1051ecb3611b718460b7a74c131e474de2af20a845938af"
dependencies = [
"cranelift-bitset",
"log",
- "sptr",
+ "pulley-macros",
+ "wasmtime-internal-core",
]
+[[package]]
+name = "pulley-macros"
+version = "43.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ad360c32e85ca4b083ac0e2b6856e8f11c3d5060dafa7d5dc57b370857fa3018"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "pxfm"
+version = "0.1.28"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b5a041e753da8b807c9255f28de81879c78c876392ff2469cde94799b2896b9d"
+
[[package]]
name = "pyo3"
version = "0.28.3"
@@ -5428,7 +5843,7 @@ version = "0.28.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e368e7ddfdeb98c9bca7f8383be1648fd84ab466bf2bc015e94008db6d35611e"
dependencies = [
- "target-lexicon 0.13.5",
+ "target-lexicon",
]
[[package]]
@@ -5466,6 +5881,12 @@ dependencies = [
"syn 2.0.117",
]
+[[package]]
+name = "quick-error"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
+
[[package]]
name = "quinn"
version = "0.11.9"
@@ -5639,12 +6060,42 @@ dependencies = [
"getrandom 0.3.4",
]
+[[package]]
+name = "rand_xoshiro"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6f97cdb2a36ed4183de61b2f824cc45c9f1037f28afe0a322e9fff4c108b5aaa"
+dependencies = [
+ "rand_core 0.6.4",
+]
+
[[package]]
name = "rangemap"
version = "1.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "973443cf09a9c8656b574a866ab68dfa19f0867d0340648c7d2f6a71b8a8ea68"
+[[package]]
+name = "ratatui"
+version = "0.29.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b"
+dependencies = [
+ "bitflags 2.11.0",
+ "cassowary",
+ "compact_str 0.8.1",
+ "crossterm 0.28.1",
+ "indoc",
+ "instability",
+ "itertools 0.13.0",
+ "lru 0.12.5",
+ "paste",
+ "strum 0.26.3",
+ "unicode-segmentation",
+ "unicode-truncate",
+ "unicode-width 0.2.0",
+]
+
[[package]]
name = "rayon"
version = "1.11.0"
@@ -5752,6 +6203,21 @@ dependencies = [
"syn 2.0.117",
]
+[[package]]
+name = "referencing"
+version = "0.45.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8a618c14f8ba29d8193bb55e2bf13e4fb2b1115313ecb7ae94b43100c7ac7d5"
+dependencies = [
+ "ahash 0.8.12",
+ "fluent-uri",
+ "getrandom 0.3.4",
+ "hashbrown 0.16.1",
+ "parking_lot",
+ "percent-encoding",
+ "serde_json",
+]
+
[[package]]
name = "refinery"
version = "0.8.16"
@@ -5778,7 +6244,7 @@ dependencies = [
"time",
"tokio",
"tokio-postgres",
- "toml",
+ "toml 0.8.23",
"url",
"walkdir",
]
@@ -5799,9 +6265,9 @@ dependencies = [
[[package]]
name = "regalloc2"
-version = "0.11.2"
+version = "0.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "dc06e6b318142614e4a48bc725abbf08ff166694835c43c9dae5a9009704639a"
+checksum = "952ddbfc6f9f64d006c3efd8c9851a6ba2f2b944ba94730db255d55006e0ffda"
dependencies = [
"allocator-api2",
"bumpalo",
@@ -5983,7 +6449,7 @@ source = "git+https://github.com/astral-sh/ruff.git?rev=6ded4bed1651e30b34dd04cd
dependencies = [
"aho-corasick",
"bitflags 2.11.0",
- "compact_str",
+ "compact_str 0.9.0",
"get-size2",
"is-macro",
"memchr",
@@ -6001,7 +6467,7 @@ source = "git+https://github.com/astral-sh/ruff.git?rev=6ded4bed1651e30b34dd04cd
dependencies = [
"bitflags 2.11.0",
"bstr",
- "compact_str",
+ "compact_str 0.9.0",
"get-size2",
"memchr",
"ruff_python_ast",
@@ -6275,7 +6741,7 @@ dependencies = [
"radix_trie",
"rustyline-derive",
"unicode-segmentation",
- "unicode-width 0.2.2",
+ "unicode-width 0.2.0",
"utf8parse",
"windows-sys 0.60.2",
]
@@ -6587,6 +7053,15 @@ dependencies = [
"serde",
]
+[[package]]
+name = "serde_spanned"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26"
+dependencies = [
+ "serde_core",
+]
+
[[package]]
name = "serde_urlencoded"
version = "0.7.1"
@@ -6630,6 +7105,19 @@ dependencies = [
"syn 2.0.117",
]
+[[package]]
+name = "serde_yaml"
+version = "0.9.34+deprecated"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47"
+dependencies = [
+ "indexmap 2.13.0",
+ "itoa",
+ "ryu",
+ "serde",
+ "unsafe-libyaml",
+]
+
[[package]]
name = "serde_yml"
version = "0.0.12"
@@ -6691,15 +7179,6 @@ dependencies = [
"lazy_static",
]
-[[package]]
-name = "shellexpand"
-version = "2.1.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7ccc8076840c4da029af4f87e4e8daeb0fca6b87bbb02e10cb60b791450e11e4"
-dependencies = [
- "dirs 4.0.0",
-]
-
[[package]]
name = "shlex"
version = "1.3.0"
@@ -6782,6 +7261,16 @@ version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e"
+[[package]]
+name = "sized-chunks"
+version = "0.6.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "16d69225bde7a69b235da73377861095455d298f2b970996eec25ddbb42b3d1e"
+dependencies = [
+ "bitmaps",
+ "typenum",
+]
+
[[package]]
name = "slab"
version = "0.4.12"
@@ -6847,12 +7336,6 @@ dependencies = [
"der",
]
-[[package]]
-name = "sptr"
-version = "0.3.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3b9b39299b249ad65f3b7e96443bad61c02ca5cd3589f46cb6d610a0fd6c0d6a"
-
[[package]]
name = "stable_deref_trait"
version = "1.2.1"
@@ -6935,6 +7418,15 @@ dependencies = [
"syn 2.0.117",
]
+[[package]]
+name = "strum"
+version = "0.26.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06"
+dependencies = [
+ "strum_macros 0.26.4",
+]
+
[[package]]
name = "strum"
version = "0.27.2"
@@ -6953,6 +7445,19 @@ dependencies = [
"strum_macros 0.28.0",
]
+[[package]]
+name = "strum_macros"
+version = "0.26.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be"
+dependencies = [
+ "heck",
+ "proc-macro2",
+ "quote",
+ "rustversion",
+ "syn 2.0.117",
+]
+
[[package]]
name = "strum_macros"
version = "0.27.2"
@@ -7085,12 +7590,6 @@ dependencies = [
"xattr",
]
-[[package]]
-name = "target-lexicon"
-version = "0.12.16"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1"
-
[[package]]
name = "target-lexicon"
version = "0.13.5"
@@ -7243,6 +7742,20 @@ dependencies = [
"cfg-if",
]
+[[package]]
+name = "tiff"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52"
+dependencies = [
+ "fax",
+ "flate2",
+ "half",
+ "quick-error",
+ "weezl",
+ "zune-jpeg",
+]
+
[[package]]
name = "time"
version = "0.3.47"
@@ -7537,11 +8050,26 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
dependencies = [
"serde",
- "serde_spanned",
+ "serde_spanned 0.6.9",
"toml_datetime 0.6.11",
"toml_edit 0.22.27",
]
+[[package]]
+name = "toml"
+version = "0.9.12+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863"
+dependencies = [
+ "indexmap 2.13.0",
+ "serde_core",
+ "serde_spanned 1.1.1",
+ "toml_datetime 0.7.5+spec-1.1.0",
+ "toml_parser",
+ "toml_writer",
+ "winnow 0.7.15",
+]
+
[[package]]
name = "toml_datetime"
version = "0.6.11"
@@ -7551,6 +8079,15 @@ dependencies = [
"serde",
]
+[[package]]
+name = "toml_datetime"
+version = "0.7.5+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347"
+dependencies = [
+ "serde_core",
+]
+
[[package]]
name = "toml_datetime"
version = "1.1.0+spec-1.1.0"
@@ -7568,7 +8105,7 @@ checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
dependencies = [
"indexmap 2.13.0",
"serde",
- "serde_spanned",
+ "serde_spanned 0.6.9",
"toml_datetime 0.6.11",
"toml_write",
"winnow 0.7.15",
@@ -7601,6 +8138,12 @@ version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
+[[package]]
+name = "toml_writer"
+version = "1.1.1+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db"
+
[[package]]
name = "tonic"
version = "0.11.0"
@@ -7850,6 +8393,17 @@ version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
+[[package]]
+name = "tui-textarea"
+version = "0.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0a5318dd619ed73c52a9417ad19046724effc1287fb75cdcc4eca1d6ac1acbae"
+dependencies = [
+ "crossterm 0.28.1",
+ "ratatui",
+ "unicode-width 0.2.0",
+]
+
[[package]]
name = "tungstenite"
version = "0.26.2"
@@ -7939,6 +8493,12 @@ version = "0.3.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5"
+[[package]]
+name = "unicode-general-category"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b993bddc193ae5bd0d623b49ec06ac3e9312875fdae725a975c51db1cc1677f"
+
[[package]]
name = "unicode-ident"
version = "1.0.24"
@@ -7966,6 +8526,17 @@ version = "1.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c"
+[[package]]
+name = "unicode-truncate"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf"
+dependencies = [
+ "itertools 0.13.0",
+ "unicode-segmentation",
+ "unicode-width 0.1.14",
+]
+
[[package]]
name = "unicode-width"
version = "0.1.14"
@@ -7974,9 +8545,9 @@ checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
[[package]]
name = "unicode-width"
-version = "0.2.2"
+version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
+checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd"
[[package]]
name = "unicode-xid"
@@ -8016,6 +8587,12 @@ dependencies = [
"subtle",
]
+[[package]]
+name = "unsafe-libyaml"
+version = "0.2.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
+
[[package]]
name = "untrusted"
version = "0.9.0"
@@ -8078,6 +8655,16 @@ dependencies = [
"wasm-bindgen",
]
+[[package]]
+name = "uuid-simd"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "23b082222b4f6619906941c17eb2297fff4c2fb96cb60164170522942a200bd8"
+dependencies = [
+ "outref",
+ "vsimd",
+]
+
[[package]]
name = "v_htmlescape"
version = "0.15.8"
@@ -8220,13 +8807,24 @@ dependencies = [
]
[[package]]
-name = "wasm-encoder"
-version = "0.221.3"
+name = "wasm-compose"
+version = "0.245.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "dc8444fe4920de80a4fe5ab564fff2ae58b6b73166b89751f8c6c93509da32e5"
+checksum = "5fd23d12cc95c451c1306db5bc63075fbebb612bb70c53b4237b1ce5bc178343"
dependencies = [
- "leb128",
- "wasmparser 0.221.3",
+ "anyhow",
+ "heck",
+ "im-rc",
+ "indexmap 2.13.0",
+ "log",
+ "petgraph",
+ "serde",
+ "serde_derive",
+ "serde_yaml",
+ "smallvec",
+ "wasm-encoder 0.245.1",
+ "wasmparser 0.245.1",
+ "wat",
]
[[package]]
@@ -8274,33 +8872,6 @@ dependencies = [
"web-sys",
]
-[[package]]
-name = "wasmparser"
-version = "0.220.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8d07b6a3b550fefa1a914b6d54fc175dd11c3392da11eee604e6ffc759805d25"
-dependencies = [
- "ahash 0.8.12",
- "bitflags 2.11.0",
- "hashbrown 0.14.5",
- "indexmap 2.13.0",
- "semver",
- "serde",
-]
-
-[[package]]
-name = "wasmparser"
-version = "0.221.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d06bfa36ab3ac2be0dee563380147a5b81ba10dd8885d7fbbc9eb574be67d185"
-dependencies = [
- "bitflags 2.11.0",
- "hashbrown 0.15.5",
- "indexmap 2.13.0",
- "semver",
- "serde",
-]
-
[[package]]
name = "wasmparser"
version = "0.244.0"
@@ -8320,135 +8891,166 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4f08c9adee0428b7bddf3890fc27e015ac4b761cc608c822667102b8bfd6995e"
dependencies = [
"bitflags 2.11.0",
+ "hashbrown 0.16.1",
"indexmap 2.13.0",
"semver",
+ "serde",
]
[[package]]
name = "wasmprinter"
-version = "0.221.3"
+version = "0.245.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7343c42a97f2926c7819ff81b64012092ae954c5d83ddd30c9fcdefd97d0b283"
+checksum = "5f41517a3716fbb8ccf46daa9c1325f760fcbff5168e75c7392288e410b91ac8"
dependencies = [
"anyhow",
"termcolor",
- "wasmparser 0.221.3",
+ "wasmparser 0.245.1",
]
[[package]]
name = "wasmtime"
-version = "28.0.1"
+version = "43.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "edd30973c65eceb0f37dfcc430d83abd5eb24015fdfcab6912f52949287e04f0"
+checksum = "ce205cd643d661b5ba5ba4717e13730262e8cdbc8f2eacbc7b906d45c1a74026"
dependencies = [
"addr2line",
- "anyhow",
"async-trait",
"bitflags 2.11.0",
"bumpalo",
"cc",
"cfg-if",
"encoding_rs",
+ "futures",
"fxprof-processed-profile",
"gimli",
- "hashbrown 0.14.5",
- "indexmap 2.13.0",
"ittapi",
"libc",
- "libm",
"log",
"mach2",
"memfd",
- "object 0.36.7",
+ "object",
"once_cell",
- "paste",
"postcard",
- "psm",
"pulley-interpreter",
"rayon",
- "rustix 0.38.44",
+ "rustix 1.1.4",
"semver",
"serde",
"serde_derive",
"serde_json",
"smallvec",
- "sptr",
- "target-lexicon 0.12.16",
- "wasm-encoder 0.221.3",
- "wasmparser 0.221.3",
- "wasmtime-asm-macros",
- "wasmtime-cache",
- "wasmtime-component-macro",
- "wasmtime-component-util",
- "wasmtime-cranelift",
+ "target-lexicon",
+ "tempfile",
+ "wasm-compose",
+ "wasm-encoder 0.245.1",
+ "wasmparser 0.245.1",
"wasmtime-environ",
- "wasmtime-fiber",
- "wasmtime-jit-debug",
- "wasmtime-jit-icache-coherence",
- "wasmtime-slab",
- "wasmtime-versioned-export-macros",
- "wasmtime-winch",
+ "wasmtime-internal-cache",
+ "wasmtime-internal-component-macro",
+ "wasmtime-internal-component-util",
+ "wasmtime-internal-core",
+ "wasmtime-internal-cranelift",
+ "wasmtime-internal-fiber",
+ "wasmtime-internal-jit-debug",
+ "wasmtime-internal-jit-icache-coherence",
+ "wasmtime-internal-unwinder",
+ "wasmtime-internal-versioned-export-macros",
+ "wasmtime-internal-winch",
"wat",
- "windows-sys 0.59.0",
+ "windows-sys 0.61.2",
]
[[package]]
-name = "wasmtime-asm-macros"
-version = "28.0.1"
+name = "wasmtime-environ"
+version = "43.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c6c21dd30d1f3f93ee390ac1a7ec304ecdbfdab6390e1add41a1f52727b0992b"
+checksum = "0b8b78abf3677d4a0a5db82e5015b4d085ff3a1b8b472cbb8c70d4b769f019ce"
dependencies = [
- "cfg-if",
+ "anyhow",
+ "cpp_demangle",
+ "cranelift-bforest",
+ "cranelift-bitset",
+ "cranelift-entity",
+ "gimli",
+ "hashbrown 0.16.1",
+ "indexmap 2.13.0",
+ "log",
+ "object",
+ "postcard",
+ "rustc-demangle",
+ "semver",
+ "serde",
+ "serde_derive",
+ "sha2",
+ "smallvec",
+ "target-lexicon",
+ "wasm-encoder 0.245.1",
+ "wasmparser 0.245.1",
+ "wasmprinter",
+ "wasmtime-internal-component-util",
+ "wasmtime-internal-core",
]
[[package]]
-name = "wasmtime-cache"
-version = "28.0.1"
+name = "wasmtime-internal-cache"
+version = "43.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "cabd563cfbfe75c5bf514081f624ca8d18391a37520d8c794abce702474e688c"
+checksum = "8e4fd4103ba413c0da2e636f73490c6c8e446d708cbde7573703941bc3d6a448"
dependencies = [
- "anyhow",
- "base64 0.21.7",
+ "base64 0.22.1",
"directories-next",
"log",
"postcard",
- "rustix 0.38.44",
+ "rustix 1.1.4",
"serde",
"serde_derive",
"sha2",
- "toml",
- "windows-sys 0.59.0",
+ "toml 0.9.12+spec-1.1.0",
+ "wasmtime-environ",
+ "windows-sys 0.61.2",
"zstd",
]
[[package]]
-name = "wasmtime-component-macro"
-version = "28.0.1"
+name = "wasmtime-internal-component-macro"
+version = "43.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9f948a6ef3119d52c9f12936970de28ddf3f9bea04bc65571f4a92d2e5ab38f4"
+checksum = "0d3d6914f34be2f9d78d8ee9f422e834dfc204e71ccce697205fae95fed87892"
dependencies = [
"anyhow",
"proc-macro2",
"quote",
"syn 2.0.117",
- "wasmtime-component-util",
- "wasmtime-wit-bindgen",
- "wit-parser 0.221.3",
+ "wasmtime-internal-component-util",
+ "wasmtime-internal-wit-bindgen",
+ "wit-parser 0.245.1",
]
[[package]]
-name = "wasmtime-component-util"
-version = "28.0.1"
+name = "wasmtime-internal-component-util"
+version = "43.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b9275aa01ceaaa2fa6c0ecaa5267518d80b9d6e9ae7c7ea42f4c6e073e6a69ef"
+checksum = "3751b0616b914fdd87fe1bf804694a078f321b000338e6476bc48a4d6e454f21"
[[package]]
-name = "wasmtime-cranelift"
-version = "28.0.1"
+name = "wasmtime-internal-core"
+version = "43.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0701a44a323267aae4499672dae422b266cee3135a23b640972ec8c0e10a44a2"
+checksum = "22632b187e1b0716f1b9ac57ad29013bed33175fcb19e10bb6896126f82fac67"
dependencies = [
"anyhow",
+ "hashbrown 0.16.1",
+ "libm",
+ "serde",
+]
+
+[[package]]
+name = "wasmtime-internal-cranelift"
+version = "43.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8b3ca07b3e0bb3429674b173b5800577719d600774dd81bff58f775c0aaa64ee"
+dependencies = [
"cfg-if",
"cranelift-codegen",
"cranelift-control",
@@ -8456,93 +9058,77 @@ dependencies = [
"cranelift-frontend",
"cranelift-native",
"gimli",
- "itertools 0.12.1",
+ "itertools 0.14.0",
"log",
- "object 0.36.7",
+ "object",
+ "pulley-interpreter",
"smallvec",
- "target-lexicon 0.12.16",
- "thiserror 1.0.69",
- "wasmparser 0.221.3",
+ "target-lexicon",
+ "thiserror 2.0.18",
+ "wasmparser 0.245.1",
"wasmtime-environ",
- "wasmtime-versioned-export-macros",
+ "wasmtime-internal-core",
+ "wasmtime-internal-unwinder",
+ "wasmtime-internal-versioned-export-macros",
]
[[package]]
-name = "wasmtime-environ"
-version = "28.0.1"
+name = "wasmtime-internal-fiber"
+version = "43.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "264c968c1b81d340355ece2be0bc31a10f567ccb6ce08512c3b7d10e26f3cbe5"
+checksum = "20c8b2c9704eb1f33ead025ec16038277ccb63d0a14c31e99d5b765d7c36da55"
dependencies = [
- "anyhow",
- "cpp_demangle",
- "cranelift-bitset",
- "cranelift-entity",
- "gimli",
- "indexmap 2.13.0",
- "log",
- "object 0.36.7",
- "postcard",
- "rustc-demangle",
- "semver",
- "serde",
- "serde_derive",
- "smallvec",
- "target-lexicon 0.12.16",
- "wasm-encoder 0.221.3",
- "wasmparser 0.221.3",
- "wasmprinter",
- "wasmtime-component-util",
-]
-
-[[package]]
-name = "wasmtime-fiber"
-version = "28.0.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "78505221fd5bd7b07b4e1fa2804edea49dc231e626ad6861adc8f531812973e6"
-dependencies = [
- "anyhow",
"cc",
"cfg-if",
- "rustix 0.38.44",
- "wasmtime-asm-macros",
- "wasmtime-versioned-export-macros",
- "windows-sys 0.59.0",
+ "libc",
+ "rustix 1.1.4",
+ "wasmtime-environ",
+ "wasmtime-internal-versioned-export-macros",
+ "windows-sys 0.61.2",
]
[[package]]
-name = "wasmtime-jit-debug"
-version = "28.0.1"
+name = "wasmtime-internal-jit-debug"
+version = "43.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0cec0a8e5620ae71bfcaaec78e3076be5b6ebf869f4e6191925d73242224a915"
+checksum = "d950310d07391d34369f62c48336ebb14eacbd4d6f772bb5f349c24e838e0664"
dependencies = [
- "object 0.36.7",
- "rustix 0.38.44",
- "wasmtime-versioned-export-macros",
+ "cc",
+ "object",
+ "rustix 1.1.4",
+ "wasmtime-internal-versioned-export-macros",
]
[[package]]
-name = "wasmtime-jit-icache-coherence"
-version = "28.0.1"
+name = "wasmtime-internal-jit-icache-coherence"
+version = "43.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9bedb677ca1b549d98f95e9e1f9251b460090d99a2c196a0614228c064bf2e59"
+checksum = "3606662c156962d096be3127b8b8ae8ee2f8be3f896dad29259ff01ddb64abfd"
dependencies = [
- "anyhow",
"cfg-if",
"libc",
- "windows-sys 0.59.0",
+ "wasmtime-internal-core",
+ "windows-sys 0.61.2",
]
[[package]]
-name = "wasmtime-slab"
-version = "28.0.1"
+name = "wasmtime-internal-unwinder"
+version = "43.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "564905638c132c275d365c1fa074f0b499790568f43148d29de84ccecfb5cb31"
+checksum = "75eef0747e52dc545b075f64fd0e0cc237ae738e641266b1970e07e2d744bc32"
+dependencies = [
+ "cfg-if",
+ "cranelift-codegen",
+ "log",
+ "object",
+ "wasmtime-environ",
+]
[[package]]
-name = "wasmtime-versioned-export-macros"
-version = "28.0.1"
+name = "wasmtime-internal-versioned-export-macros"
+version = "43.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1e91092e6cf77390eeccee273846a9327f3e8f91c3c6280f60f37809f0e62d29"
+checksum = "d8b0a5dab02a8fb527f547855ecc0e05f9fdc3d5bd57b8b080349408f9a6cece"
dependencies = [
"proc-macro2",
"quote",
@@ -8550,12 +9136,41 @@ dependencies = [
]
[[package]]
-name = "wasmtime-wasi"
-version = "28.0.1"
+name = "wasmtime-internal-winch"
+version = "43.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1a8e04b9a4c68ad018b330a4f4914b82b01dc3582d715ce21a93564c7f26b19f"
+checksum = "8007342bd12ff400293a817973f7ecd6f1d9a8549a53369a9c1af357166f1f1e"
+dependencies = [
+ "cranelift-codegen",
+ "gimli",
+ "log",
+ "object",
+ "target-lexicon",
+ "wasmparser 0.245.1",
+ "wasmtime-environ",
+ "wasmtime-internal-cranelift",
+ "winch-codegen",
+]
+
+[[package]]
+name = "wasmtime-internal-wit-bindgen"
+version = "43.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7900c3e3c1d6e475bc225d73b02d6d5484815f260022e6964dca9558e50dd01a"
dependencies = [
"anyhow",
+ "bitflags 2.11.0",
+ "heck",
+ "indexmap 2.13.0",
+ "wit-parser 0.245.1",
+]
+
+[[package]]
+name = "wasmtime-wasi"
+version = "43.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed3e3ddcfad69e9eb025bd19bff70dad45bafe1d6eacd134c0ffdfc4c161d045"
+dependencies = [
"async-trait",
"bitflags 2.11.0",
"bytes",
@@ -8568,44 +9183,29 @@ dependencies = [
"futures",
"io-extras",
"io-lifetimes",
- "rustix 0.38.44",
+ "rustix 1.1.4",
"system-interface",
- "thiserror 1.0.69",
+ "thiserror 2.0.18",
"tokio",
"tracing",
"url",
"wasmtime",
+ "wasmtime-wasi-io",
"wiggle",
- "windows-sys 0.59.0",
-]
-
-[[package]]
-name = "wasmtime-winch"
-version = "28.0.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b111d909dc604c741bd8ac2f4af373eaa5c68c34b5717271bcb687688212cef8"
-dependencies = [
- "anyhow",
- "cranelift-codegen",
- "gimli",
- "object 0.36.7",
- "target-lexicon 0.12.16",
- "wasmparser 0.221.3",
- "wasmtime-cranelift",
- "wasmtime-environ",
- "winch-codegen",
+ "windows-sys 0.61.2",
]
[[package]]
-name = "wasmtime-wit-bindgen"
-version = "28.0.1"
+name = "wasmtime-wasi-io"
+version = "43.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5f38f7a5eb2f06f53fe943e7fb8bf4197f7cf279f1bc52c0ce56e9d3ffd750a4"
+checksum = "3ca5dd3b9f04a851c422d05f333366722742da46bff9369ae0191f32cf83565a"
dependencies = [
- "anyhow",
- "heck",
- "indexmap 2.13.0",
- "wit-parser 0.221.3",
+ "async-trait",
+ "bytes",
+ "futures",
+ "tracing",
+ "wasmtime",
]
[[package]]
@@ -8626,7 +9226,7 @@ dependencies = [
"bumpalo",
"leb128fmt",
"memchr",
- "unicode-width 0.2.2",
+ "unicode-width 0.2.0",
"wasm-encoder 0.245.1",
]
@@ -8722,39 +9322,37 @@ dependencies = [
[[package]]
name = "wiggle"
-version = "28.0.1"
+version = "43.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3b23e3dc273d1e35cab9f38a5f76487aeeedcfa6a3fb594e209ee7b6f8b41dcc"
+checksum = "cc1b1135efc8e5a008971897bea8d41ca56d8d501d4efb807842ae0a1c78f639"
dependencies = [
- "anyhow",
- "async-trait",
"bitflags 2.11.0",
- "thiserror 1.0.69",
+ "thiserror 2.0.18",
"tracing",
"wasmtime",
+ "wasmtime-environ",
"wiggle-macro",
]
[[package]]
name = "wiggle-generate"
-version = "28.0.1"
+version = "43.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8738c5a7ef3a9de0fae10f8b84091a2aa4e059d8fef23de202ab689812b6bc6e"
+checksum = "a7bc2b0d50ec8773b44fbfe1da6cb5cc44a92deaf8483233dcf0831e6db33172"
dependencies = [
- "anyhow",
"heck",
"proc-macro2",
"quote",
- "shellexpand",
"syn 2.0.117",
+ "wasmtime-environ",
"witx",
]
[[package]]
name = "wiggle-macro"
-version = "28.0.1"
+version = "43.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e882267ac583e013a38a5aaeb83a49b219456ba3aa6e6772440f7213b176e8ff"
+checksum = "2d6c7d44ea552e1fbfdcd7a2cd83f5c2d1e803d5b1a11e3462c06888b77f455f"
dependencies = [
"proc-macro2",
"quote",
@@ -8795,19 +9393,21 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "winch-codegen"
-version = "28.0.1"
+version = "43.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6232f40a795be2ce10fc761ed3b403825126a60d12491ac556ea104a932fd18a"
+checksum = "eb9f45f7172a2628c8317766e427babc0a400f9d10b1c0f0b0617c5ed5b79de6"
dependencies = [
- "anyhow",
+ "cranelift-assembler-x64",
"cranelift-codegen",
"gimli",
"regalloc2",
"smallvec",
- "target-lexicon 0.12.16",
- "wasmparser 0.221.3",
- "wasmtime-cranelift",
+ "target-lexicon",
+ "thiserror 2.0.18",
+ "wasmparser 0.245.1",
"wasmtime-environ",
+ "wasmtime-internal-core",
+ "wasmtime-internal-cranelift",
]
[[package]]
@@ -9211,9 +9811,9 @@ dependencies = [
[[package]]
name = "wit-parser"
-version = "0.221.3"
+version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "896112579ed56b4a538b07a3d16e562d101ff6265c46b515ce0c701eef16b2ac"
+checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736"
dependencies = [
"anyhow",
"id-arena",
@@ -9224,16 +9824,17 @@ dependencies = [
"serde_derive",
"serde_json",
"unicode-xid",
- "wasmparser 0.221.3",
+ "wasmparser 0.244.0",
]
[[package]]
name = "wit-parser"
-version = "0.244.0"
+version = "0.245.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736"
+checksum = "330698718e82983499419494dd1e3d7811a457a9bf9f69734e8c5f07a2547929"
dependencies = [
"anyhow",
+ "hashbrown 0.16.1",
"id-arena",
"indexmap 2.13.0",
"log",
@@ -9242,7 +9843,7 @@ dependencies = [
"serde_derive",
"serde_json",
"unicode-xid",
- "wasmparser 0.244.0",
+ "wasmparser 0.245.1",
]
[[package]]
@@ -9272,6 +9873,23 @@ dependencies = [
"tap",
]
+[[package]]
+name = "x11rb"
+version = "0.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414"
+dependencies = [
+ "gethostname",
+ "rustix 1.1.4",
+ "x11rb-protocol",
+]
+
+[[package]]
+name = "x11rb-protocol"
+version = "0.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd"
+
[[package]]
name = "x509-cert"
version = "0.2.5"
@@ -9580,6 +10198,21 @@ dependencies = [
"pkg-config",
]
+[[package]]
+name = "zune-core"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9"
+
+[[package]]
+name = "zune-jpeg"
+version = "0.5.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296"
+dependencies = [
+ "zune-core",
+]
+
[[package]]
name = "zvariant"
version = "4.2.0"
diff --git a/Cargo.toml b/Cargo.toml
index a7f91ba5678..4130032581a 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,10 +1,11 @@
[workspace]
-members = [".", "crates/ironclaw_common", "crates/ironclaw_safety", "crates/ironclaw_skills", "crates/ironclaw_engine"]
+members = [".", "crates/ironclaw_common", "crates/ironclaw_safety", "crates/ironclaw_skills", "crates/ironclaw_engine", "crates/ironclaw_gateway", "crates/ironclaw_tui"]
exclude = [
"channels-src/discord",
"channels-src/telegram",
"channels-src/slack",
"channels-src/whatsapp",
+ "tools-src/composio",
"tools-src/github",
"tools-src/gmail",
"tools-src/google-calendar",
@@ -106,13 +107,20 @@ tower-http = { version = "0.6", features = ["trace", "cors", "set-header", "catc
# Cron scheduling for routines
cron = "0.13"
+# JSON Schema validation (workspace document metadata).
+# default-features disabled to avoid pulling a second `reqwest` major version
+# for remote $ref resolution (we only validate against in-memory schemas).
+jsonschema = { version = "0.45", default-features = false }
+
# Shared types
ironclaw_common = { path = "crates/ironclaw_common", version = "0.1.0" }
# Safety/sanitization
ironclaw_engine = { path = "crates/ironclaw_engine", version = "0.1.0" }
+ironclaw_gateway = { path = "crates/ironclaw_gateway", version = "0.1.0" }
ironclaw_safety = { path = "crates/ironclaw_safety", version = "0.2.0" }
ironclaw_skills = { path = "crates/ironclaw_skills", version = "0.1.0" }
+ironclaw_tui = { path = "crates/ironclaw_tui", version = "0.1.0", optional = true }
regex = "1"
aho-corasick = "1"
@@ -122,6 +130,7 @@ serde_yml = "0.0.12"
# Filesystem paths
dirs = "6"
fs4 = "0.6"
+glob = "0.3"
# Semantic versioning
semver = "1"
@@ -141,9 +150,9 @@ open = "5"
pgvector = { version = "0.4", features = ["postgres"], optional = true }
# WASM sandbox for untrusted tool execution
-wasmtime = { version = "28", features = ["component-model"] }
-wasmtime-wasi = "28" # WASI support for component model
-wasmparser = "0.220" # WASM binary parsing for validation
+wasmtime = { version = "43.0.1", features = ["component-model"] }
+wasmtime-wasi = "43.0.1" # WASI support for component model
+wasmparser = "0.245.1" # WASM binary parsing for validation
# Cryptography for secrets management
aes-gcm = "0.10"
@@ -220,7 +229,7 @@ tempfile = "3"
insta = "1.46.3"
[features]
-default = ["postgres", "libsql", "html-to-markdown"]
+default = ["postgres", "libsql", "html-to-markdown", "tui"]
postgres = [
"dep:deadpool-postgres",
"dep:tokio-postgres",
@@ -239,6 +248,7 @@ libsql = ["dep:libsql"]
integration = []
html-to-markdown = ["dep:html-to-markdown-rs", "dep:readabilityrs"]
bedrock = ["dep:aws-config", "dep:aws-sdk-bedrockruntime", "dep:aws-smithy-types"]
+tui = ["dep:ironclaw_tui"]
import = ["dep:json5", "libsql"]
[[test]]
diff --git a/Dockerfile b/Dockerfile
index 89b9366597b..9c015e83317 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -32,6 +32,7 @@ COPY tests/ tests/
COPY migrations/ migrations/
COPY registry/ registry/
COPY channels-src/ channels-src/
+COPY tools-src/ tools-src/
COPY wit/ wit/
COPY providers.json providers.json
@@ -59,13 +60,59 @@ COPY tests/ tests/
COPY migrations/ migrations/
COPY registry/ registry/
COPY channels-src/ channels-src/
+COPY tools-src/ tools-src/
COPY wit/ wit/
COPY providers.json providers.json
+COPY profiles/ profiles/
RUN cargo build --profile dist --bin ironclaw
-# Stage 5: Minimal runtime
-FROM debian:bookworm-slim
+# Stage 4b: Build all WASM extensions from source (only used by runtime-staging)
+FROM builder AS wasm-builder
+ARG CACHE_BUST
+
+RUN apt-get update && apt-get install -y --no-install-recommends jq && rm -rf /var/lib/apt/lists/*
+RUN echo "cache-bust=${CACHE_BUST}"
+
+RUN set -eux; \
+ mkdir -p /app/wasm-bundles/tools /app/wasm-bundles/channels; \
+ for manifest in registry/tools/*.json registry/channels/*.json; do \
+ [ -f "$manifest" ] || continue; \
+ kind=$(jq -r '.kind' "$manifest"); \
+ ext_name=$(jq -r '.name' "$manifest"); \
+ source_dir=$(jq -r '.source.dir' "$manifest"); \
+ caps_file=$(jq -r '.source.capabilities' "$manifest"); \
+ crate_name=$(jq -r '.source.crate_name' "$manifest"); \
+ [ -d "$source_dir" ] || continue; \
+ # Telegram is embedded in the binary at build time; skip it
+ [ "$ext_name" = "telegram" ] && continue; \
+ echo "=== Building $ext_name from $source_dir ==="; \
+ if [ -f "$source_dir/Cargo.lock" ]; then \
+ CARGO_TARGET_DIR=/app/target cargo build --locked --release --target wasm32-wasip2 \
+ --manifest-path "$source_dir/Cargo.toml" || { echo "WARN: build failed for $ext_name"; continue; }; \
+ else \
+ CARGO_TARGET_DIR=/app/target cargo build --release --target wasm32-wasip2 \
+ --manifest-path "$source_dir/Cargo.toml" || { echo "WARN: build failed for $ext_name"; continue; }; \
+ fi; \
+ wasm_artifact=$(echo "${crate_name}" | tr '-' '_'); \
+ raw_wasm="/app/target/wasm32-wasip2/release/${wasm_artifact}.wasm"; \
+ [ -f "$raw_wasm" ] || continue; \
+ dest_dir="/app/wasm-bundles/tools"; \
+ [ "$kind" = "channel" ] && dest_dir="/app/wasm-bundles/channels"; \
+ wasm-tools component new "$raw_wasm" -o "$dest_dir/${ext_name}.wasm" 2>/dev/null \
+ || cp "$raw_wasm" "$dest_dir/${ext_name}.wasm"; \
+ wasm-tools strip "$dest_dir/${ext_name}.wasm" -o "$dest_dir/${ext_name}.wasm.tmp" 2>/dev/null \
+ && mv "$dest_dir/${ext_name}.wasm.tmp" "$dest_dir/${ext_name}.wasm" \
+ || true; \
+ [ -f "$source_dir/$caps_file" ] && cp "$source_dir/$caps_file" "$dest_dir/${ext_name}.capabilities.json"; \
+ echo " -> $dest_dir/${ext_name}.wasm"; \
+ done; \
+ count=$(find /app/wasm-bundles -name '*.wasm' | wc -l); \
+ echo "Built $count WASM extensions"; \
+ [ "$count" -gt 0 ] || { echo "ERROR: No WASM extensions were built"; exit 1; }
+
+# Stage 5a: Shared runtime base
+FROM debian:bookworm-slim AS runtime-base
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates \
@@ -80,10 +127,19 @@ RUN useradd -m -d /home/ironclaw -u 1000 ironclaw \
&& mkdir -p /home/ironclaw/.ironclaw \
&& chown -R ironclaw:ironclaw /home/ironclaw
WORKDIR /home/ironclaw
-USER ironclaw
EXPOSE 3000
ENV RUST_LOG=ironclaw=info
ENTRYPOINT ["ironclaw"]
+
+# Stage 5b: Staging runtime (with pre-built WASM extensions)
+FROM runtime-base AS runtime-staging
+COPY --from=wasm-builder --chown=ironclaw:ironclaw /app/wasm-bundles/tools/ /home/ironclaw/.ironclaw/tools/
+COPY --from=wasm-builder --chown=ironclaw:ironclaw /app/wasm-bundles/channels/ /home/ironclaw/.ironclaw/channels/
+USER ironclaw
+
+# Stage 5c: Production runtime (default — no pre-bundled extensions)
+FROM runtime-base AS runtime
+USER ironclaw
diff --git a/FEATURE_PARITY.md b/FEATURE_PARITY.md
index 34040379bc8..b52c3d82ab4 100644
--- a/FEATURE_PARITY.md
+++ b/FEATURE_PARITY.md
@@ -69,7 +69,7 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
| REPL (simple) | ✅ | ✅ | - | For testing |
| WASM channels | ❌ | ✅ | - | IronClaw innovation; host resolves owner scope vs sender identity |
| WhatsApp | ✅ | ❌ | P1 | Baileys (Web), same-phone mode with echo detection |
-| Telegram | ✅ | ✅ | - | WASM channel(MTProto), DM pairing, caption, /start, bot_username, DM topics, setup-time owner auto-verification, owner-scoped persistence |
+| Telegram | ✅ | ✅ | - | WASM channel(MTProto), polling-first setup, DM pairing, caption, /start, bot_username, DM topics, web/UI ownership claim flow, owner-scoped persistence |
| Discord | ✅ | 🚧 | P2 | Gateway `MESSAGE_CREATE` intake restored via websocket queue + WASM poll; Gateway DMs now respect pairing; thread parent binding inheritance and reply/thread parity still incomplete |
| Signal | ✅ | ✅ | P2 | signal-cli daemonPC, SSE listener HTTP/JSON-R, user/group allowlists, DM pairing |
| Slack | ✅ | ✅ | - | WASM tool |
@@ -560,7 +560,7 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
### P1 - High Priority
- ❌ Slack channel (real implementation)
-- ✅ Telegram channel (WASM, DM pairing, caption, /start)
+- ✅ Telegram channel (WASM, polling-first setup, DM pairing, caption, /start)
- ❌ WhatsApp channel
- ✅ Multi-provider failover (`FailoverProvider` with retryable error classification)
- ✅ Hooks system (core lifecycle hooks + bundled/plugin/workspace hooks + outbound webhooks)
diff --git a/README.ja.md b/README.ja.md
index 887cf67e5f4..cc6e31b4110 100644
--- a/README.ja.md
+++ b/README.ja.md
@@ -18,7 +18,8 @@
English |
简体中文 |
Русский |
- 日本語
+ 日本語 |
+ 한국어
@@ -180,7 +181,7 @@ LLM_API_KEY=sk-or-...
LLM_MODEL=anthropic/claude-sonnet-4
```
-完全なプロバイダーガイドは[docs/LLM_PROVIDERS.md](docs/LLM_PROVIDERS.md)をご覧ください。
+完全なプロバイダーガイドは[docs/capabilities/llm-providers.md](docs/capabilities/llm-providers.md)をご覧ください。
## セキュリティ
@@ -306,7 +307,7 @@ cargo test
cargo test test_name
```
-- **Telegramチャネル**: セットアップとDMペアリングについては[docs/TELEGRAM_SETUP.md](docs/TELEGRAM_SETUP.md)を参照してください。
+- **チャネル**: Telegram、Discord、その他のチャネルの設定は[docs/channels/overview.mdx](docs/channels/overview.mdx)を参照してください。
- **チャネルソースの変更**: `cargo build`の前に`./channels-src/telegram/build.sh`を実行して、更新されたWASMをバンドルしてください。
## OpenClawの系譜
diff --git a/README.ko.md b/README.ko.md
new file mode 100644
index 00000000000..903b8d1c2a1
--- /dev/null
+++ b/README.ko.md
@@ -0,0 +1,338 @@
+
+
+
+
+IronClaw
+
+
+ 언제나 당신 편인 안전한 개인 AI 어시스턴트
+
+
+
+
+
+
+
+
+
+
+
+
+ English |
+ 简体中文 |
+ Русский |
+ 日本語 |
+ 한국어
+
+
+
+ 철학 •
+ 기능 •
+ 설치 •
+ 설정 •
+ 보안 •
+ 아키텍처
+
+
+---
+
+## 철학
+
+IronClaw는 단순한 원칙 위에 만들어졌습니다: **AI 어시스턴트는 당신을 위해 일해야 하며, 당신을 거슬러서는 안 됩니다**.
+
+AI 시스템이 데이터 처리에 대해 점점 더 불투명해지고 기업의 이익에 맞춰지는 세상에서, IronClaw는 다른 접근 방식을 취합니다:
+
+- **데이터는 당신의 것** - 모든 정보는 로컬에 저장되고 암호화되며, 절대 당신의 통제를 벗어나지 않습니다
+- **설계에 의한 투명성** - 오픈 소스, 감사 가능, 숨겨진 텔레메트리나 데이터 수집 없음
+- **자가 확장 기능** - 공급업체의 업데이트를 기다리지 않고 즉석에서 새로운 도구를 만들 수 있습니다
+- **심층 방어** - 프롬프트 인젝션 및 데이터 유출로부터 보호하는 다중 보안 계층
+
+IronClaw는 개인적, 직업적 삶에서 실제로 신뢰할 수 있는 AI 어시스턴트입니다.
+
+## 기능
+
+### 보안 우선
+
+- **WASM 샌드박스** - 신뢰할 수 없는 도구는 권한 기반의 격리된 WebAssembly 컨테이너에서 실행됩니다
+- **자격 증명 보호** - 비밀은 도구에 노출되지 않고, 누출 감지와 함께 호스트 경계에서 주입됩니다
+- **프롬프트 인젝션 방어** - 패턴 감지, 콘텐츠 정화, 정책 시행
+- **엔드포인트 화이트리스트** - HTTP 요청은 명시적으로 승인된 호스트와 경로로만 전송됩니다
+
+### 항상 사용 가능
+
+- **다중 채널** - REPL, HTTP 웹훅, WASM 채널 (Telegram, Slack), 웹 게이트웨이
+- **Docker 샌드박스** - 작업별 토큰과 오케스트레이터/워커 패턴을 사용한 격리된 컨테이너 실행
+- **웹 게이트웨이** - 실시간 SSE/WebSocket 스트리밍이 있는 브라우저 UI
+- **루틴** - 백그라운드 자동화를 위한 cron 일정, 이벤트 트리거, 웹훅 핸들러
+- **하트비트 시스템** - 모니터링 및 유지 보수 작업을 위한 사전 백그라운드 실행
+- **병렬 작업** - 격리된 컨텍스트로 여러 요청을 동시에 처리합니다
+- **자가 복구** - 중단된 작업의 자동 감지 및 복구
+
+### 자가 확장
+
+- **동적 도구 빌드** - 필요한 것을 설명하면 IronClaw가 WASM 도구로 만들어 줍니다
+- **MCP 프로토콜** - 추가 기능을 위해 Model Context Protocol 서버에 연결합니다
+- **플러그인 아키텍처** - 재시작 없이 새로운 WASM 도구와 채널을 추가할 수 있습니다
+
+### 영구 메모리
+
+- **하이브리드 검색** - Reciprocal Rank Fusion을 사용한 전체 텍스트 + 벡터 검색
+- **워크스페이스 파일시스템** - 노트, 로그, 컨텍스트를 위한 유연한 경로 기반 저장소
+- **아이덴티티 파일** - 세션 간 일관된 성격과 선호도를 유지합니다
+
+## 설치
+
+### 사전 요구 사항
+
+- Rust 1.85+
+- [pgvector](https://github.com/pgvector/pgvector) 확장이 있는 PostgreSQL 15+
+- NEAR AI 계정 (인증은 설정 마법사를 통해 처리됨)
+
+## 다운로드 또는 빌드
+
+[릴리스 페이지](https://github.com/nearai/ironclaw/releases/)를 방문하여 최신 업데이트를 확인하세요.
+
+
+ Windows 인스톨러로 설치 (Windows)
+
+[Windows 인스톨러](https://github.com/nearai/ironclaw/releases/latest/download/ironclaw-x86_64-pc-windows-msvc.msi)를 다운로드하여 실행하세요.
+
+
+
+
+ PowerShell 스크립트로 설치 (Windows)
+
+```sh
+irm https://github.com/nearai/ironclaw/releases/latest/download/ironclaw-installer.ps1 | iex
+```
+
+
+
+
+ 셸 스크립트로 설치 (macOS, Linux, Windows/WSL)
+
+```sh
+curl --proto '=https' --tlsv1.2 -LsSf https://github.com/nearai/ironclaw/releases/latest/download/ironclaw-installer.sh | sh
+```
+
+
+
+ Homebrew로 설치 (macOS/Linux)
+
+```sh
+brew install ironclaw
+```
+
+
+
+
+ 소스 코드 컴파일 (Windows, Linux, macOS의 Cargo)
+
+`cargo`로 설치하세요. 컴퓨터에 [Rust](https://rustup.rs)가 설치되어 있는지 확인하세요.
+
+```bash
+# 저장소 복제
+git clone https://github.com/nearai/ironclaw.git
+cd ironclaw
+
+# 빌드
+cargo build --release
+
+# 테스트 실행
+cargo test
+```
+
+**전체 릴리스**의 경우 (채널 소스를 수정한 후), `./scripts/build-all.sh`를 실행하여 채널을 먼저 다시 빌드하세요.
+
+
+
+### 데이터베이스 설정
+
+```bash
+# 데이터베이스 생성
+createdb ironclaw
+
+# pgvector 활성화
+psql ironclaw -c "CREATE EXTENSION IF NOT EXISTS vector;"
+```
+
+## 설정
+
+설정 마법사를 실행하여 IronClaw를 구성하세요:
+
+```bash
+ironclaw onboard
+```
+
+마법사는 데이터베이스 연결, NEAR AI 인증 (브라우저 OAuth를 통해),
+그리고 비밀 암호화 (시스템 키체인 사용)를 처리합니다. 설정은 연결된
+데이터베이스에 저장됩니다. 부트스트랩 변수 (예: `DATABASE_URL`, `LLM_BACKEND`)는
+데이터베이스가 연결되기 전에 사용할 수 있도록 `~/.ironclaw/.env`에 기록됩니다.
+
+### 대체 LLM 공급자
+
+IronClaw는 기본적으로 NEAR AI를 사용하지만 많은 LLM 공급자를 기본 지원합니다.
+내장 공급자에는 **Anthropic**, **OpenAI**, **GitHub Copilot**, **Google Gemini**, **MiniMax**,
+**Mistral**, **Ollama** (로컬)이 포함됩니다. **OpenRouter**
+(300+ 모델), **Together AI**, **Fireworks AI**, 자체 호스팅 서버 (**vLLM**,
+**LiteLLM**) 같은 OpenAI 호환 서비스도 지원됩니다.
+
+마법사에서 공급자를 선택하거나 환경 변수를 직접 설정하세요:
+
+```env
+# 예: MiniMax (내장, 204K 컨텍스트)
+LLM_BACKEND=minimax
+MINIMAX_API_KEY=...
+
+# 예: OpenAI 호환 엔드포인트
+LLM_BACKEND=openai_compatible
+LLM_BASE_URL=https://openrouter.ai/api/v1
+LLM_API_KEY=sk-or-...
+LLM_MODEL=anthropic/claude-sonnet-4
+```
+
+전체 공급자 가이드는 [docs/capabilities/llm-providers.md](docs/capabilities/llm-providers.md)를 참조하세요.
+
+## 보안
+
+IronClaw는 데이터를 보호하고 오용을 방지하기 위해 심층 방어를 구현합니다.
+
+### WASM 샌드박스
+
+신뢰할 수 없는 모든 도구는 격리된 WebAssembly 컨테이너에서 실행됩니다:
+
+- **권한 기반 권한** - HTTP, 비밀, 도구 호출에 대한 명시적 옵트인
+- **엔드포인트 화이트리스트** - HTTP 요청은 승인된 호스트/경로로만 전송됩니다
+- **자격 증명 주입** - 비밀은 호스트 경계에서 주입되며, WASM 코드에 절대 노출되지 않습니다
+- **누출 감지** - 비밀 유출 시도에 대해 요청과 응답을 스캔합니다
+- **속도 제한** - 남용을 방지하기 위한 도구별 요청 제한
+- **리소스 제한** - 메모리, CPU, 실행 시간 제약
+
+```
+WASM ──► 화이트리스트 ──► 누출 스캔 ──► 자격 증명 ──► 실행 ──► 누출 스캔 ──► WASM
+ 검증기 (요청) 주입기 요청 (응답)
+```
+
+### 프롬프트 인젝션 방어
+
+외부 콘텐츠는 여러 보안 계층을 통과합니다:
+
+- 인젝션 시도의 패턴 기반 감지
+- 콘텐츠 정화 및 이스케이핑
+- 심각도 수준이 있는 정책 규칙 (차단/경고/검토/정화)
+- 안전한 LLM 컨텍스트 주입을 위한 도구 출력 래핑
+
+### 데이터 보호
+
+- 모든 데이터는 로컬 PostgreSQL 데이터베이스에 저장됩니다
+- 비밀은 AES-256-GCM으로 암호화됩니다
+- 텔레메트리, 분석, 데이터 공유 없음
+- 모든 도구 실행에 대한 전체 감사 로그
+
+## 아키텍처
+
+```
+┌────────────────────────────────────────────────────────────────┐
+│ 채널 │
+│ ┌──────┐ ┌──────┐ ┌─────────────┐ ┌─────────────┐ │
+│ │ REPL │ │ HTTP │ │ WASM 채널 │ │ 웹 게이트웨이│ │
+│ └──┬───┘ └──┬───┘ └──────┬──────┘ │ (SSE + WS) │ │
+│ │ │ │ └──────┬──────┘ │
+│ └─────────┴──────────────┴────────────────┘ │
+│ │ │
+│ ┌─────────▼─────────┐ │
+│ │ 에이전트 루프 │ 의도 라우팅 │
+│ └────┬──────────┬───┘ │
+│ │ │ │
+│ ┌──────────▼────┐ ┌──▼───────────────┐ │
+│ │ 스케줄러 │ │ 루틴 엔진 │ │
+│ │ (병렬 작업) │ │ (cron, 이벤트, wh)│ │
+│ └──────┬────────┘ └────────┬─────────┘ │
+│ │ │ │
+│ ┌─────────────┼────────────────────┘ │
+│ │ │ │
+│ ┌───▼─────┐ ┌────▼────────────────┐ │
+│ │ 로컬 │ │ 오케스트레이터 │ │
+│ │ 워커 │ │ ┌───────────────┐ │ │
+│ │(인프로세스)│ │ │Docker 샌드박스│ │ │
+│ └───┬─────┘ │ │ 컨테이너 │ │ │
+│ │ │ │ ┌───────────┐ │ │ │
+│ │ │ │ │Worker / CC│ │ │ │
+│ │ │ │ └───────────┘ │ │ │
+│ │ │ └───────────────┘ │ │
+│ │ └─────────┬───────────┘ │
+│ └──────────────────┤ │
+│ │ │
+│ ┌───────────▼──────────┐ │
+│ │ 도구 레지스트리 │ │
+│ │ 내장, MCP, WASM │ │
+│ └──────────────────────┘ │
+└────────────────────────────────────────────────────────────────┘
+```
+
+### 핵심 구성 요소
+
+| 구성 요소 | 목적 |
+|-----------|---------|
+| **에이전트 루프** | 주요 메시지 처리 및 작업 조정 |
+| **라우터** | 사용자 의도 분류 (명령, 쿼리, 작업) |
+| **스케줄러** | 우선순위가 있는 병렬 작업 실행 관리 |
+| **워커** | LLM 추론과 도구 호출로 작업 실행 |
+| **오케스트레이터** | 컨테이너 라이프사이클, LLM 프록시, 작업별 인증 |
+| **웹 게이트웨이** | 채팅, 메모리, 작업, 로그, 확장, 루틴이 있는 브라우저 UI |
+| **루틴 엔진** | 예약된 (cron) 및 반응형 (이벤트, 웹훅) 백그라운드 작업 |
+| **워크스페이스** | 하이브리드 검색이 있는 영구 메모리 |
+| **안전 계층** | 프롬프트 인젝션 방어 및 콘텐츠 정화 |
+
+## 사용법
+
+```bash
+# 첫 설정 (데이터베이스, 인증 등 구성)
+ironclaw onboard
+
+# 대화형 REPL 시작
+cargo run
+
+# 디버그 로깅 사용
+RUST_LOG=ironclaw=debug cargo run
+```
+
+## 개발
+
+```bash
+# 코드 포맷
+cargo fmt
+
+# 린트
+cargo clippy --all --benches --tests --examples --all-features
+
+# 테스트 실행
+createdb ironclaw_test
+cargo test
+
+# 특정 테스트 실행
+cargo test test_name
+```
+
+- **채널**: Telegram, Discord 및 기타 채널 설정은 [docs/channels/overview.mdx](docs/channels/overview.mdx)를 참조하세요.
+- **채널 소스 변경**: 업데이트된 WASM이 번들되도록 `cargo build` 전에 `./channels-src/telegram/build.sh`를 실행하세요.
+
+## OpenClaw 역사
+
+IronClaw는 [OpenClaw](https://github.com/openclaw/openclaw)에서 영감을 받은 Rust 재구현입니다. 전체 추적 매트릭스는 [FEATURE_PARITY.md](FEATURE_PARITY.md)를 참조하세요.
+
+주요 차이점:
+
+- **Rust vs TypeScript** - 네이티브 성능, 메모리 안전, 단일 바이너리
+- **WASM 샌드박스 vs Docker** - 가벼운 권한 기반 보안
+- **PostgreSQL vs SQLite** - 프로덕션 준비된 영속성
+- **보안 우선 설계** - 다중 방어 계층, 자격 증명 보호
+
+## 라이선스
+
+다음 중 하나를 선택하여 라이선스가 부여됩니다:
+
+- Apache License, Version 2.0 ([LICENSE-APACHE](LICENSE-APACHE))
+- MIT License ([LICENSE-MIT](LICENSE-MIT))
+
+원하는 대로 선택할 수 있습니다.
diff --git a/README.md b/README.md
index cb759236be6..c99e0f4b561 100644
--- a/README.md
+++ b/README.md
@@ -21,7 +21,8 @@
English |
简体中文 |
Русский |
- 日本語
+ 日本語 |
+ 한국어
@@ -190,7 +191,7 @@ LLM_API_KEY=sk-or-...
LLM_MODEL=anthropic/claude-sonnet-4
```
-See [docs/LLM_PROVIDERS.md](docs/LLM_PROVIDERS.md) for a full provider guide.
+See [docs/capabilities/llm-providers.md](docs/capabilities/llm-providers.md) for a full provider guide.
## Security
@@ -313,7 +314,7 @@ cargo test
cargo test test_name
```
-- **Telegram channel**: See [docs/TELEGRAM_SETUP.md](docs/TELEGRAM_SETUP.md) for setup and DM pairing.
+- **Channels**: See [docs/channels/overview.mdx](docs/channels/overview.mdx) for setup of Telegram, Discord, and other channels.
- **Changing channel sources**: Run `./channels-src/telegram/build.sh` before `cargo build` so the updated WASM is bundled.
## OpenClaw Heritage
diff --git a/README.ru.md b/README.ru.md
index 0546e7f44c5..06689c04d59 100644
--- a/README.ru.md
+++ b/README.ru.md
@@ -18,7 +18,8 @@
English |
简体中文 |
Русский |
- 日本語
+ 日本語 |
+ 한국어
@@ -184,7 +185,7 @@ LLM_API_KEY=sk-or-...
LLM_MODEL=anthropic/claude-sonnet-4
```
-Смотрите [docs/LLM_PROVIDERS.md](docs/LLM_PROVIDERS.md) для получения полного руководства по провайдерам.
+Смотрите [docs/capabilities/llm-providers.md](docs/capabilities/llm-providers.md) для получения полного руководства по провайдерам.
## Безопасность
@@ -308,7 +309,7 @@ cargo test
cargo test название_теста
```
-- **Telegram-канал**: Смотрите [docs/TELEGRAM_SETUP.md](docs/TELEGRAM_SETUP.md) для настройки и привязки аккаунта.
+- **Каналы**: Смотрите [docs/channels/overview.mdx](docs/channels/overview.mdx) для настройки Telegram, Discord и других каналов.
- **Изменение исходников каналов**: Перед `cargo build` выполните `./channels-src/telegram/build.sh`, чтобы обновить встроенный WASM.
## Наследие OpenClaw
diff --git a/README.zh-CN.md b/README.zh-CN.md
index d818872acfa..d840793b618 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -18,7 +18,8 @@
English |
简体中文 |
Русский |
- 日本語
+ 日本語 |
+ 한국어
@@ -181,7 +182,7 @@ LLM_API_KEY=sk-or-...
LLM_MODEL=anthropic/claude-sonnet-4
```
-详见 [docs/LLM_PROVIDERS.md](docs/LLM_PROVIDERS.md) 获取完整的提供商指南。
+详见 [docs/capabilities/llm-providers.md](docs/capabilities/llm-providers.md) 获取完整的提供商指南。
## 安全机制
@@ -304,7 +305,7 @@ cargo test
cargo test test_name
```
-- **Telegram 渠道**:参见 [docs/TELEGRAM_SETUP.md](docs/TELEGRAM_SETUP.md) 了解设置和私信配对。
+- **渠道**:参见 [docs/channels/overview.mdx](docs/channels/overview.mdx) 了解 Telegram、Discord 和其他渠道的设置。
- **修改渠道源码**:在 `cargo build` 之前运行 `./channels-src/telegram/build.sh` 以便打包更新后的 WASM。
## OpenClaw 传承
diff --git a/channels-src/discord/src/lib.rs b/channels-src/discord/src/lib.rs
index 271e13110c8..e06736c7453 100644
--- a/channels-src/discord/src/lib.rs
+++ b/channels-src/discord/src/lib.rs
@@ -1115,10 +1115,8 @@ fn check_sender_permission(
channel_host::LogLevel::Info,
&format!("Pairing request for user {}: code {}", user_id, result.code),
);
- if result.created {
- if let Some(ctx) = reply_ctx {
- let _ = send_pairing_reply(ctx, &result.code);
- }
+ if let Some(ctx) = reply_ctx {
+ let _ = send_pairing_reply(ctx, &result.code);
}
}
Err(e) => {
diff --git a/channels-src/feishu/src/lib.rs b/channels-src/feishu/src/lib.rs
index 5f74198aed4..fc2787a4181 100644
--- a/channels-src/feishu/src/lib.rs
+++ b/channels-src/feishu/src/lib.rs
@@ -539,15 +539,28 @@ fn handle_message_event(event_data: &serde_json::Value) {
"chat_id": msg_event.message.chat_id,
"chat_type": chat_type,
});
- let _ = channel_host::pairing_upsert_request(
- "feishu",
- sender_id,
- &meta.to_string(),
- );
- channel_host::log(
- channel_host::LogLevel::Info,
- &format!("Pairing request created for {}", sender_id),
- );
+ match channel_host::pairing_upsert_request("feishu", sender_id, &meta.to_string()) {
+ Ok(result) => {
+ channel_host::log(
+ channel_host::LogLevel::Info,
+ &format!("Pairing request created for {}: {}", sender_id, result.code),
+ );
+ let _ = send_message(
+ sender_id,
+ "open_id",
+ &format!(
+ "Enter this code in IronClaw to pair your feishu account: `{}`. CLI fallback: `ironclaw pairing approve feishu {}`",
+ result.code, result.code
+ ),
+ );
+ }
+ Err(e) => {
+ channel_host::log(
+ channel_host::LogLevel::Error,
+ &format!("Pairing upsert failed: {}", e),
+ );
+ }
+ }
return;
}
Err(e) => {
diff --git a/channels-src/slack/src/lib.rs b/channels-src/slack/src/lib.rs
index b03203231a7..3b6b212c6b4 100644
--- a/channels-src/slack/src/lib.rs
+++ b/channels-src/slack/src/lib.rs
@@ -262,84 +262,83 @@ impl Guest for SlackChannel {
}
fn on_respond(response: AgentResponse) -> Result<(), String> {
- // Parse metadata to get channel info
let metadata: SlackMessageMetadata = serde_json::from_str(&response.metadata_json)
.map_err(|e| format!("Failed to parse metadata: {}", e))?;
- // Build Slack API request
- let mut payload = serde_json::json!({
- "channel": metadata.channel,
- "text": response.content,
- });
-
let thread_ts = response.thread_id.or(metadata.thread_ts);
- if let Some(ref thread_ts) = thread_ts {
- payload["thread_ts"] = serde_json::Value::String(thread_ts.clone());
+
+ let ts = post_slack_message(
+ &metadata.channel,
+ &response.content,
+ thread_ts.as_deref(),
+ )?;
+
+ if let Some(thread_ts) = thread_ts {
+ if let Err(e) = track_active_thread(&metadata.channel, &thread_ts) {
+ channel_host::log(
+ channel_host::LogLevel::Warn,
+ &format!("Failed to track active thread: {}", e),
+ );
+ }
}
- let payload_bytes = serde_json::to_vec(&payload)
- .map_err(|e| format!("Failed to serialize payload: {}", e))?;
-
- // Make HTTP request to Slack API
- // The bot token is injected by the host based on credential configuration
- let headers = serde_json::json!({
- "Content-Type": "application/json"
- });
-
- let result = channel_host::http_request(
- "POST",
- "https://slack.com/api/chat.postMessage",
- &headers.to_string(),
- Some(&payload_bytes),
- None,
+ channel_host::log(
+ channel_host::LogLevel::Debug,
+ &format!(
+ "Posted message to Slack channel {}: ts={}",
+ metadata.channel,
+ ts.unwrap_or_default()
+ ),
);
- match result {
- Ok(http_response) => {
- if http_response.status != 200 {
- return Err(format!(
- "Slack API returned status {}",
- http_response.status
- ));
- }
+ Ok(())
+ }
- // Parse Slack response
- let slack_response: SlackPostMessageResponse =
- serde_json::from_slice(&http_response.body)
- .map_err(|e| format!("Failed to parse Slack response: {}", e))?;
-
- if !slack_response.ok {
- return Err(format!(
- "Slack API error: {}",
- slack_response
- .error
- .unwrap_or_else(|| "unknown".to_string())
- ));
- }
+ fn on_status(_update: StatusUpdate) {}
- if let Some(thread_ts) = thread_ts {
- track_active_thread(&metadata.channel, &thread_ts)?;
- }
+ fn on_broadcast(user_id: String, response: AgentResponse) -> Result<(), String> {
+ let target = resolve_broadcast_target(&user_id);
+ if target.is_empty() {
+ return Err(
+ "broadcast failed: no target specified. Pass a Slack channel ID (C0...) \
+ or user ID (U0...) as the target."
+ .to_string(),
+ );
+ }
+
+ if !looks_like_slack_id(target) {
+ return Err(format!(
+ "Broadcast target '{}' is not a valid Slack ID (expected C/U/D/G/W prefix). \
+ Use a channel ID (C0...) or user ID (U0...), not a channel name.",
+ target
+ ));
+ }
+ let ts = post_slack_message(target, &response.content, response.thread_id.as_deref())?;
+
+ // Track the thread so replies to this broadcast are recognized as
+ // active threads. Use the explicit thread_id if provided, otherwise
+ // fall back to the message timestamp returned by Slack (which becomes
+ // the thread root if someone replies to this message).
+ if let Some(thread_ts) = response.thread_id.as_deref().or(ts.as_deref()) {
+ if let Err(e) = track_active_thread(target, thread_ts) {
channel_host::log(
- channel_host::LogLevel::Debug,
- &format!(
- "Posted message to Slack channel {}: ts={}",
- metadata.channel,
- slack_response.ts.unwrap_or_default()
- ),
+ channel_host::LogLevel::Warn,
+ &format!("Failed to track active thread: {}", e),
);
-
- Ok(())
}
- Err(e) => Err(format!("HTTP request failed: {}", e)),
}
- }
- fn on_status(_update: StatusUpdate) {}
+ channel_host::log(
+ channel_host::LogLevel::Debug,
+ &format!(
+ "Broadcast message to Slack target {}: ts={}",
+ target,
+ ts.unwrap_or_default()
+ ),
+ );
- fn on_broadcast(_user_id: String, _response: AgentResponse) -> Result<(), String> {
- Err("broadcast not yet implemented for Slack channel".to_string())
+ Ok(())
}
fn on_shutdown() {
@@ -737,9 +736,7 @@ fn check_sender_permission(user_id: &str, channel_id: &str, is_dm: bool) -> bool
channel_host::LogLevel::Info,
&format!("Pairing request for user {}: code {}", user_id, result.code),
);
- if result.created {
- let _ = send_pairing_reply(channel_id, &result.code);
- }
+ let _ = send_pairing_reply(channel_id, &result.code);
}
Err(e) => {
channel_host::log(
@@ -788,6 +785,95 @@ fn send_pairing_reply(channel_id: &str, code: &str) -> Result<(), String> {
}
}
+/// Post a message via Slack `chat.postMessage` and return the message timestamp.
+///
+/// The bot token is injected by the host credential system — this function
+/// only sets `Content-Type`. Used by both `on_respond` and `on_broadcast`.
+fn post_slack_message(
+ channel: &str,
+ text: &str,
+ thread_ts: Option<&str>,
+) -> Result, String> {
+ let payload = build_broadcast_payload(channel, text, thread_ts);
+ let payload_bytes = serde_json::to_vec(&payload)
+ .map_err(|e| format!("Failed to serialize payload: {}", e))?;
+
+ let headers = serde_json::json!({
+ "Content-Type": "application/json"
+ });
+
+ let result = channel_host::http_request(
+ "POST",
+ "https://slack.com/api/chat.postMessage",
+ &headers.to_string(),
+ Some(&payload_bytes),
+ None,
+ );
+
+ match result {
+ Ok(http_response) => {
+ if http_response.status != 200 {
+ return Err(format!(
+ "Slack API returned status {}",
+ http_response.status
+ ));
+ }
+
+ let slack_response: SlackPostMessageResponse =
+ serde_json::from_slice(&http_response.body)
+ .map_err(|e| format!("Failed to parse Slack response: {}", e))?;
+
+ if !slack_response.ok {
+ return Err(format!(
+ "Slack API error: {}",
+ slack_response
+ .error
+ .unwrap_or_else(|| "unknown".to_string())
+ ));
+ }
+
+ Ok(slack_response.ts)
+ }
+ Err(e) => Err(format!("HTTP request failed: {}", e)),
+ }
+}
+
+/// Normalize a broadcast target by stripping a leading `#` if present.
+///
+/// The message tool passes the target as `user_id` (e.g. `#C0123ABC`,
+/// `C0123ABC`, or `U0123ABC`). The Slack API expects a channel ID (C0...)
+/// or user ID (U0...), not a channel name.
+fn resolve_broadcast_target(raw: &str) -> &str {
+ raw.strip_prefix('#').unwrap_or(raw)
+}
+
+/// Check if a string looks like a Slack ID (starts with C, U, D, G, or W followed by alphanumeric).
+fn looks_like_slack_id(s: &str) -> bool {
+ let mut chars = s.chars();
+ match chars.next() {
+ Some('C' | 'U' | 'D' | 'G' | 'W') => {
+ chars.next().is_some_and(|c| c.is_ascii_alphanumeric())
+ }
+ _ => false,
+ }
+}
+
+/// Build the JSON payload for a Slack `chat.postMessage` broadcast.
+fn build_broadcast_payload(
+ target: &str,
+ content: &str,
+ thread_ts: Option<&str>,
+) -> serde_json::Value {
+ let mut payload = serde_json::json!({
+ "channel": target,
+ "text": content,
+ });
+ if let Some(ts) = thread_ts {
+ payload["thread_ts"] = serde_json::Value::String(ts.to_string());
+ }
+ payload
+}
+
/// Strip leading bot mention from text.
fn strip_bot_mention(text: &str) -> String {
// Slack mentions look like <@U12345678>
@@ -992,4 +1078,74 @@ mod tests {
now_millis
));
}
+
+ #[test]
+ fn test_resolve_broadcast_target_strips_hash() {
+ assert_eq!(resolve_broadcast_target("#general"), "general");
+ assert_eq!(resolve_broadcast_target("#staging-eli5"), "staging-eli5");
+ }
+
+ #[test]
+ fn test_resolve_broadcast_target_preserves_ids() {
+ assert_eq!(resolve_broadcast_target("C0123ABC"), "C0123ABC");
+ assert_eq!(resolve_broadcast_target("U0123ABC"), "U0123ABC");
+ }
+
+ #[test]
+ fn test_resolve_broadcast_target_empty_input() {
+ assert_eq!(resolve_broadcast_target(""), "");
+ assert_eq!(resolve_broadcast_target("#"), "");
+ }
+
+ #[test]
+ fn test_build_broadcast_payload_without_thread() {
+ let payload = build_broadcast_payload("C0123", "hello world", None);
+ assert_eq!(payload["channel"], "C0123");
+ assert_eq!(payload["text"], "hello world");
+ assert!(payload.get("thread_ts").is_none());
+ }
+
+ #[test]
+ fn test_build_broadcast_payload_with_thread() {
+ let payload = build_broadcast_payload("C0123", "threaded reply", Some("1742486400.000100"));
+ assert_eq!(payload["channel"], "C0123");
+ assert_eq!(payload["text"], "threaded reply");
+ assert_eq!(payload["thread_ts"], "1742486400.000100");
+ }
+
+ #[test]
+ fn test_looks_like_slack_id_valid() {
+ assert!(looks_like_slack_id("C0123ABC"));
+ assert!(looks_like_slack_id("U0123ABC"));
+ assert!(looks_like_slack_id("D0123ABC"));
+ assert!(looks_like_slack_id("G0123ABC"));
+ assert!(looks_like_slack_id("W0123ABC"));
+ }
+
+ #[test]
+ fn test_looks_like_slack_id_invalid() {
+ assert!(!looks_like_slack_id("general"));
+ assert!(!looks_like_slack_id("staging-eli5"));
+ assert!(!looks_like_slack_id(""));
+ assert!(!looks_like_slack_id("C")); // too short, no second char
+ assert!(!looks_like_slack_id("c0123")); // lowercase
+ }
+
+ #[test]
+ fn test_resolve_broadcast_target_rejects_names_via_id_check() {
+ // After stripping '#', channel names fail the ID check
+ let target = resolve_broadcast_target("#general");
+ assert!(!looks_like_slack_id(target));
+
+ let target = resolve_broadcast_target("random-channel");
+ assert!(!looks_like_slack_id(target));
+ }
+
+ #[test]
+ fn test_resolve_broadcast_target_accepts_prefixed_ids() {
+ // IDs with '#' prefix are accepted after stripping
+ let target = resolve_broadcast_target("#C0123ABC");
+ assert!(looks_like_slack_id(target));
+ assert_eq!(target, "C0123ABC");
+ }
}
diff --git a/channels-src/telegram/src/lib.rs b/channels-src/telegram/src/lib.rs
index bdc16b726e1..238b458b47c 100644
--- a/channels-src/telegram/src/lib.rs
+++ b/channels-src/telegram/src/lib.rs
@@ -310,8 +310,7 @@ struct TelegramMessageMetadata {
/// Channel configuration injected by host.
///
/// The host injects runtime values like tunnel_url and webhook_secret.
-/// The channel doesn't need to know about polling vs webhook mode - it just
-/// checks if tunnel_url is set to determine behavior.
+/// Telegram defaults to polling; webhook mode must be enabled explicitly.
#[derive(Debug, Deserialize)]
struct TelegramConfig {
/// Bot username (without @) for mention detection in groups.
@@ -336,7 +335,6 @@ struct TelegramConfig {
respond_to_all_group_messages: bool,
/// Public tunnel URL for webhook mode (injected by host from global settings).
- /// When set, webhook mode is enabled and polling is disabled.
#[serde(default)]
tunnel_url: Option,
@@ -345,9 +343,21 @@ struct TelegramConfig {
#[serde(default)]
webhook_secret: Option,
+ /// When true, use webhook mode if tunnel_url is available.
+ #[serde(default)]
+ webhook_enabled: bool,
+
/// When true, use polling mode even if tunnel_url is available.
#[serde(default)]
polling_enabled: bool,
+
+ /// Poll interval in milliseconds (default 30000).
+ #[serde(default)]
+ poll_interval_ms: Option,
+}
+
+fn webhook_mode(config: &TelegramConfig) -> bool {
+ config.webhook_enabled && config.tunnel_url.is_some() && !config.polling_enabled
}
// ============================================================================
@@ -526,8 +536,11 @@ impl Guest for TelegramChannel {
// Clear any stale owner_id from a previous config
let _ = channel_host::workspace_write(OWNER_ID_PATH, "");
channel_host::log(
- channel_host::LogLevel::Warn,
- "No owner_id configured, bot is open to all users",
+ channel_host::LogLevel::Debug,
+ &format!(
+ "No owner_id configured; dm_policy={}",
+ config.dm_policy.as_deref().unwrap_or("pairing")
+ ),
);
}
@@ -535,27 +548,26 @@ impl Guest for TelegramChannel {
let dm_policy = config.dm_policy.as_deref().unwrap_or("pairing").to_string();
let _ = channel_host::workspace_write(DM_POLICY_PATH, &dm_policy);
- let allow_from_json = serde_json::to_string(&config.allow_from.unwrap_or_default())
+ let allow_from_json = serde_json::to_string(&config.allow_from.clone().unwrap_or_default())
.unwrap_or_else(|_| "[]".to_string());
let _ = channel_host::workspace_write(ALLOW_FROM_PATH, &allow_from_json);
// Persist bot_username and respond_to_all_group_messages for group handling
let _ = channel_host::workspace_write(
BOT_USERNAME_PATH,
- &config.bot_username.unwrap_or_default(),
+ &config.bot_username.clone().unwrap_or_default(),
);
let _ = channel_host::workspace_write(
RESPOND_TO_ALL_GROUP_PATH,
&config.respond_to_all_group_messages.to_string(),
);
- // Mode: use polling if explicitly enabled, otherwise use webhooks when tunnel available.
- let webhook_mode = config.tunnel_url.is_some() && !config.polling_enabled;
+ let webhook_mode = webhook_mode(&config);
if webhook_mode {
channel_host::log(
channel_host::LogLevel::Info,
- "Webhook mode enabled (tunnel configured)",
+ "Webhook mode enabled (explicitly configured)",
);
// Register webhook with Telegram API — propagate errors so a bad token
@@ -575,7 +587,7 @@ impl Guest for TelegramChannel {
} else {
channel_host::log(
channel_host::LogLevel::Info,
- "Polling mode enabled (no tunnel configured)",
+ "Polling mode enabled",
);
// Delete any existing webhook before polling. Telegram returns success
@@ -586,7 +598,7 @@ impl Guest for TelegramChannel {
// Configure polling only if not in webhook mode
let poll = if !webhook_mode {
Some(PollConfig {
- interval_ms: 30000, // 30 seconds minimum
+ interval_ms: config.poll_interval_ms.unwrap_or(30000),
enabled: true,
})
} else {
@@ -2054,9 +2066,7 @@ fn handle_message(message: TelegramMessage) {
from.id, message.chat.id, result.code
),
);
- if result.created {
- let _ = send_pairing_reply(message.chat.id, &result.code);
- }
+ let _ = send_pairing_reply(message.chat.id, &result.code);
}
Err(e) => {
channel_host::log(
@@ -2707,6 +2717,33 @@ mod tests {
);
}
+ #[test]
+ fn test_webhook_mode_requires_explicit_enable() {
+ let config: TelegramConfig = serde_json::from_str(
+ r#"{
+ "tunnel_url": "https://example.ngrok.app",
+ "polling_enabled": false
+ }"#,
+ )
+ .unwrap();
+
+ assert!(!webhook_mode(&config));
+ }
+
+ #[test]
+ fn test_webhook_mode_enabled_with_tunnel() {
+ let config: TelegramConfig = serde_json::from_str(
+ r#"{
+ "tunnel_url": "https://example.ngrok.app",
+ "webhook_enabled": true,
+ "polling_enabled": false
+ }"#,
+ )
+ .unwrap();
+
+ assert!(webhook_mode(&config));
+ }
+
#[test]
fn test_classify_status_update_tool_result_ignored() {
let update = StatusUpdate {
diff --git a/channels-src/telegram/telegram.capabilities.json b/channels-src/telegram/telegram.capabilities.json
index 13e177d2da7..5fa8da5f340 100644
--- a/channels-src/telegram/telegram.capabilities.json
+++ b/channels-src/telegram/telegram.capabilities.json
@@ -72,6 +72,7 @@
"bot_username": null,
"owner_id": null,
"respond_to_all_group_messages": false,
+ "webhook_enabled": false,
"polling_enabled": false,
"poll_interval_ms": 30000,
"dm_policy": "pairing",
diff --git a/channels-src/whatsapp/src/lib.rs b/channels-src/whatsapp/src/lib.rs
index 6287cece67a..e77b1146f08 100644
--- a/channels-src/whatsapp/src/lib.rs
+++ b/channels-src/whatsapp/src/lib.rs
@@ -872,9 +872,7 @@ fn check_sender_permission(
sender_phone, result.code
),
);
- if result.created {
- let _ = send_pairing_reply(sender_phone, phone_number_id, &result.code);
- }
+ let _ = send_pairing_reply(sender_phone, phone_number_id, &result.code);
}
Err(e) => {
channel_host::log(
diff --git a/crates/ironclaw_common/Cargo.toml b/crates/ironclaw_common/Cargo.toml
index 6e7db5a42df..2308ff9c417 100644
--- a/crates/ironclaw_common/Cargo.toml
+++ b/crates/ironclaw_common/Cargo.toml
@@ -13,5 +13,7 @@ repository = "https://github.com/nearai/ironclaw"
dist = false
[dependencies]
+chrono-tz = "0.10"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
+tracing = "0.1"
diff --git a/crates/ironclaw_common/src/event.rs b/crates/ironclaw_common/src/event.rs
index 8800cc2af98..54a33b6b728 100644
--- a/crates/ironclaw_common/src/event.rs
+++ b/crates/ironclaw_common/src/event.rs
@@ -59,6 +59,8 @@ pub enum AppEvent {
ToolStarted {
name: String,
#[serde(skip_serializing_if = "Option::is_none")]
+ detail: Option,
+ #[serde(skip_serializing_if = "Option::is_none")]
thread_id: Option,
},
#[serde(rename = "tool_completed")]
@@ -128,6 +130,24 @@ pub enum AppEvent {
#[serde(skip_serializing_if = "Option::is_none")]
thread_id: Option,
},
+ #[serde(rename = "pairing_required")]
+ PairingRequired {
+ channel: String,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ instructions: Option,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ onboarding: Option,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ thread_id: Option,
+ },
+ #[serde(rename = "pairing_completed")]
+ PairingCompleted {
+ channel: String,
+ success: bool,
+ message: String,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ thread_id: Option,
+ },
#[serde(rename = "gate_required")]
GateRequired {
request_id: String,
@@ -316,6 +336,8 @@ impl AppEvent {
Self::ApprovalNeeded { .. } => "approval_needed",
Self::AuthRequired { .. } => "auth_required",
Self::AuthCompleted { .. } => "auth_completed",
+ Self::PairingRequired { .. } => "pairing_required",
+ Self::PairingCompleted { .. } => "pairing_completed",
Self::GateRequired { .. } => "gate_required",
Self::GateResolved { .. } => "gate_resolved",
Self::Error { .. } => "error",
@@ -360,6 +382,7 @@ mod tests {
},
AppEvent::ToolStarted {
name: String::new(),
+ detail: None,
thread_id: None,
},
AppEvent::ToolCompleted {
@@ -408,6 +431,18 @@ mod tests {
message: String::new(),
thread_id: None,
},
+ AppEvent::PairingRequired {
+ channel: String::new(),
+ instructions: None,
+ onboarding: None,
+ thread_id: None,
+ },
+ AppEvent::PairingCompleted {
+ channel: String::new(),
+ success: true,
+ message: String::new(),
+ thread_id: None,
+ },
AppEvent::Error {
message: String::new(),
thread_id: None,
diff --git a/crates/ironclaw_common/src/lib.rs b/crates/ironclaw_common/src/lib.rs
index c297342d65e..852374dfd4f 100644
--- a/crates/ironclaw_common/src/lib.rs
+++ b/crates/ironclaw_common/src/lib.rs
@@ -1,9 +1,11 @@
//! Shared types and utilities for the IronClaw workspace.
mod event;
+mod timezone;
mod util;
pub use event::{AppEvent, PlanStepDto, ToolDecisionDto};
+pub use timezone::{ValidTimezone, deserialize_option_lenient};
pub use util::truncate_preview;
/// Maximum worker agent loop iterations. Used by the orchestrator (server-side
diff --git a/crates/ironclaw_common/src/timezone.rs b/crates/ironclaw_common/src/timezone.rs
new file mode 100644
index 00000000000..4eea97421af
--- /dev/null
+++ b/crates/ironclaw_common/src/timezone.rs
@@ -0,0 +1,166 @@
+//! Validated IANA timezone type.
+
+use serde::{Deserialize, Serialize};
+
+/// A validated IANA timezone.
+///
+/// Wraps `chrono_tz::Tz` and guarantees the timezone string was valid at
+/// construction time. Use `ValidTimezone::parse()` to create — it returns
+/// `None` for empty or unrecognized timezone strings.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct ValidTimezone(chrono_tz::Tz);
+
+impl ValidTimezone {
+ /// Parse an IANA timezone string. Returns `None` for empty or invalid input.
+ pub fn parse(s: &str) -> Option {
+ let trimmed = s.trim();
+ if trimmed.is_empty() {
+ return None;
+ }
+ trimmed.parse::().ok().map(Self)
+ }
+
+ /// The underlying `chrono_tz::Tz` value.
+ pub fn tz(&self) -> chrono_tz::Tz {
+ self.0
+ }
+
+ /// The IANA name (e.g. "America/New_York").
+ pub fn name(&self) -> &str {
+ self.0.name()
+ }
+}
+
+impl std::fmt::Display for ValidTimezone {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ f.write_str(self.name())
+ }
+}
+
+impl Serialize for ValidTimezone {
+ fn serialize(&self, serializer: S) -> Result {
+ self.name().serialize(serializer)
+ }
+}
+
+impl<'de> Deserialize<'de> for ValidTimezone {
+ fn deserialize>(deserializer: D) -> Result {
+ let s = String::deserialize(deserializer)?;
+ Self::parse(&s)
+ .ok_or_else(|| serde::de::Error::custom(format!("invalid IANA timezone: '{s}'")))
+ }
+}
+
+/// Lenient deserializer for `Option`.
+///
+/// Use with `#[serde(default, deserialize_with = "...")]` on fields that may
+/// contain invalid timezone strings from historical data. Invalid or empty
+/// values deserialize as `None` instead of failing the whole record. Each
+/// drop is logged at `debug!` so a typo in fresh user config is at least
+/// observable in the logs even though the record loads.
+pub fn deserialize_option_lenient<'de, D: serde::Deserializer<'de>>(
+ deserializer: D,
+) -> Result, D::Error> {
+ let opt: Option = Option::deserialize(deserializer)?;
+ match opt {
+ Some(s) => match ValidTimezone::parse(&s) {
+ Some(tz) => Ok(Some(tz)),
+ None => {
+ tracing::debug!(
+ raw = %s,
+ "lenient deserializer dropped invalid IANA timezone string to None"
+ );
+ Ok(None)
+ }
+ },
+ None => Ok(None),
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn parse_valid_timezone() {
+ let tz = ValidTimezone::parse("America/New_York").unwrap();
+ assert_eq!(tz.name(), "America/New_York");
+ }
+
+ #[test]
+ fn parse_with_whitespace() {
+ let tz = ValidTimezone::parse(" Europe/London ").unwrap();
+ assert_eq!(tz.name(), "Europe/London");
+ }
+
+ #[test]
+ fn parse_empty_returns_none() {
+ assert!(ValidTimezone::parse("").is_none());
+ assert!(ValidTimezone::parse(" ").is_none());
+ }
+
+ #[test]
+ fn parse_invalid_returns_none() {
+ assert!(ValidTimezone::parse("NotATimezone").is_none());
+ assert!(ValidTimezone::parse("US/FakeCity").is_none());
+ }
+
+ #[test]
+ fn serde_roundtrip() {
+ let tz = ValidTimezone::parse("Asia/Tokyo").unwrap();
+ let json = serde_json::to_string(&tz).unwrap();
+ assert_eq!(json, "\"Asia/Tokyo\"");
+ let back: ValidTimezone = serde_json::from_str(&json).unwrap();
+ assert_eq!(back, tz);
+ }
+
+ #[test]
+ fn deserialize_invalid_fails() {
+ let result: Result = serde_json::from_str("\"NotReal\"");
+ assert!(result.is_err());
+ }
+
+ #[test]
+ fn lenient_deserialize_valid() {
+ #[derive(serde::Deserialize)]
+ struct T {
+ #[serde(default, deserialize_with = "super::deserialize_option_lenient")]
+ tz: Option,
+ }
+ let t: T = serde_json::from_str(r#"{"tz":"America/Chicago"}"#).unwrap();
+ assert_eq!(t.tz.unwrap().name(), "America/Chicago");
+ }
+
+ #[test]
+ fn lenient_deserialize_invalid_becomes_none() {
+ #[derive(serde::Deserialize)]
+ struct T {
+ #[serde(default, deserialize_with = "super::deserialize_option_lenient")]
+ tz: Option,
+ }
+ let t: T = serde_json::from_str(r#"{"tz":"NotReal"}"#).unwrap();
+ assert!(t.tz.is_none(), "invalid timezone should become None");
+ }
+
+ #[test]
+ fn lenient_deserialize_null_becomes_none() {
+ #[derive(serde::Deserialize)]
+ struct T {
+ #[serde(default, deserialize_with = "super::deserialize_option_lenient")]
+ tz: Option,
+ }
+ let t: T = serde_json::from_str(r#"{"tz":null}"#).unwrap();
+ assert!(t.tz.is_none());
+ }
+
+ #[test]
+ fn lenient_deserialize_missing_becomes_none() {
+ #[derive(serde::Deserialize)]
+ struct T {
+ #[serde(default, deserialize_with = "super::deserialize_option_lenient")]
+ tz: Option,
+ }
+ let t: T = serde_json::from_str(r#"{}"#).unwrap();
+ assert!(t.tz.is_none());
+ }
+}
diff --git a/crates/ironclaw_engine/CLAUDE.md b/crates/ironclaw_engine/CLAUDE.md
index 346b8c9c119..0ea1b6a1fbc 100644
--- a/crates/ironclaw_engine/CLAUDE.md
+++ b/crates/ironclaw_engine/CLAUDE.md
@@ -83,11 +83,12 @@ Validated by `ThreadState::can_transition_to()`. Terminal states: `Done`, `Faile
## Learning Missions
-Three event-driven missions fire automatically after thread completion:
+Four event-driven missions fire automatically after thread completion:
1. **Error diagnosis** (`self-improvement`) — fires when a thread completes with trace issues. Diagnoses root cause and applies prompt overlays or orchestrator patches.
-2. **Skill extraction** (`skill-extraction`) — fires when a thread succeeds with 5+ steps and 3+ tool actions. Extracts reusable skills with activation metadata, CodeAct code snippets, and domain tags. Output stored as `DocType::Skill` MemoryDoc.
-3. **Conversation insights** (`conversation-insights`) — fires every 5 completed threads in a project. Extracts user preferences, domain knowledge, and workflow patterns.
+2. **Skill repair** (`skill-repair`) — fires when a completed thread used an active skill but the trace suggests the skill instructions were stale, incomplete, or missing verification. Applies the smallest safe versioned update to the implicated skill.
+3. **Skill extraction** (`skill-extraction`) — fires when a thread succeeds with 5+ steps and 3+ tool actions. Extracts reusable skills with activation metadata, CodeAct code snippets, and domain tags. Output stored as `DocType::Skill` MemoryDoc.
+4. **Conversation insights** (`conversation-insights`) — fires every 5 completed threads in a project. Extracts user preferences, domain knowledge, and workflow patterns.
Created by `MissionManager::ensure_learning_missions()` at project bootstrap.
diff --git a/crates/ironclaw_engine/Cargo.toml b/crates/ironclaw_engine/Cargo.toml
index 634e7ca1cc3..726b59a3a19 100644
--- a/crates/ironclaw_engine/Cargo.toml
+++ b/crates/ironclaw_engine/Cargo.toml
@@ -15,15 +15,19 @@ dist = false
[dependencies]
async-trait = "0.1"
+cron = "0.13"
+ironclaw_common = { path = "../ironclaw_common", version = "0.1.0" }
ironclaw_skills = { path = "../ironclaw_skills", version = "0.1.0", default-features = false }
chrono = { version = "0.4", features = ["serde"] }
monty = { git = "https://github.com/pydantic/monty.git", rev = "7a0d4b75b72e6ddacafaf36e26486186cdb6eb68" }
+regex = "1"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
thiserror = "2"
tokio = { version = "1", features = ["sync", "time", "macros", "rt"] }
tracing = "0.1"
uuid = { version = "1", features = ["v4", "serde"] }
+sha2 = "0.10"
[dev-dependencies]
pretty_assertions = "1"
diff --git a/crates/ironclaw_engine/orchestrator/default.py b/crates/ironclaw_engine/orchestrator/default.py
index a9b32d5f310..ef8a7a82f93 100644
--- a/crates/ironclaw_engine/orchestrator/default.py
+++ b/crates/ironclaw_engine/orchestrator/default.py
@@ -16,6 +16,9 @@
# __retrieve_docs__(goal, max_docs) -> list of doc dicts
# __check_budget__() -> budget dict
# __get_actions__() -> list of action dicts
+# __list_skills__() -> list of skill dicts
+# __record_skill_usage__(doc_id, success) -> None
+# __regex_match__(pattern, text) -> bool
#
# Context variables (injected by Rust before execution):
# context - list of prior messages [{role, content}]
@@ -182,6 +185,14 @@ def format_docs(docs):
return "\n".join(parts)
+# Conservative fallback heuristic matching the old Rust-side estimator.
+# These MUST be defined before `estimate_context_tokens` (and therefore
+# before the `FINAL(result)` entry-point call below). Moving them after the
+# entry point is a latent NameError every time `compact_if_needed` runs.
+CHARS_PER_TOKEN = 4
+MESSAGE_OVERHEAD_CHARS = 4
+
+
def estimate_context_tokens(messages):
"""Estimate token count for a transcript using a rough chars/token heuristic."""
total_chars = 0
@@ -268,8 +279,15 @@ def compact_if_needed(state, config):
# ── Skill selection and injection (self-modifiable) ────────
-def score_skill(skill, message_lower):
- """Score a skill against a user message. Returns 0 if vetoed."""
+def score_skill(skill, message_lower, message_original):
+ """Score a skill against a user message. Returns 0 if vetoed.
+
+ Scoring is aligned with the v1 `ironclaw_skills::selector::score_skill`:
+ - exclude_keyword veto: any match => score 0
+ - keyword: exact word = 10, substring = 5 (cap 30)
+ - tag: substring = 3 (cap 15)
+ - regex pattern: each match = 20 (cap 40)
+ """
meta = skill.get("metadata", {})
activation = meta.get("activation", {})
@@ -282,7 +300,11 @@ def score_skill(skill, message_lower):
# Keyword scoring: exact word = 10, substring = 5 (cap 30)
kw_score = 0
- words = message_lower.split()
+ words = []
+ for word in message_lower.split():
+ trimmed = word.strip(".,!?;:'\"()[]{}<>`~@#$%^&*-_=+/\\|")
+ if trimmed:
+ words.append(trimmed)
for kw in activation.get("keywords", []):
kw_lower = kw.lower()
if kw_lower in words:
@@ -298,6 +320,14 @@ def score_skill(skill, message_lower):
tag_score += 3
score += min(tag_score, 15)
+ # Regex pattern scoring: each match = 20 (cap 40). Monty has no `re`
+ # module, so we call out to a host function that uses Rust's regex crate.
+ rx_score = 0
+ for pat in activation.get("patterns", []):
+ if __regex_match__(str(pat), message_original):
+ rx_score += 20
+ score += min(rx_score, 40)
+
# Confidence factor for extracted skills
source = meta.get("source", "authored")
if source == "extracted":
@@ -316,9 +346,10 @@ def select_skills(skills, goal, max_candidates=3, max_tokens=4000):
return []
message_lower = goal.lower()
+ message_original = goal
scored = []
for skill in skills:
- s = score_skill(skill, message_lower)
+ s = score_skill(skill, message_lower, message_original)
if s > 0:
scored.append((s, skill))
@@ -478,6 +509,20 @@ def run_loop(context, goal, actions, state, config):
all_skills = __list_skills__()
active_skills = select_skills(all_skills, goal, max_candidates=3, max_tokens=4000)
if active_skills:
+ __set_active_skills__([
+ {
+ "doc_id": s.get("doc_id", ""),
+ "name": s.get("metadata", {}).get("name", "?"),
+ "version": s.get("metadata", {}).get("version", 1),
+ "snippet_names": [
+ sn.get("name", "")
+ for sn in s.get("metadata", {}).get("code_snippets", [])
+ if sn.get("name")
+ ],
+ "force_activated": False,
+ }
+ for s in active_skills
+ ])
skill_text = format_skills(active_skills)
append_system_append(working_messages, skill_text)
# Emit skill activation event for CLI/gateway display
@@ -630,22 +675,56 @@ def run_loop(context, goal, actions, state, config):
# NOTE: consecutive_nudges is NOT reset here (V1 semantics).
# Only non-intent text responses reset the counter.
calls = response.get("calls", [])
+
+ # Handle FINAL emitted as a structured tool call. FINAL is a
+ # CodeAct sentinel for completion — when the LLM tries to call
+ # it via tool_calls instead of inside a code block, the engine's
+ # action executor has no lease for it and the call fails. If FINAL
+ # is co-emitted with other calls, execute the non-FINAL calls first
+ # so persistence side effects are not silently dropped.
+ final_call = None
+ duplicate_finals_dropped = 0
+ executable_calls = []
+ for c in calls:
+ if c.get("name", "") == "FINAL":
+ # First FINAL wins; any extras are dropped (not appended
+ # to executable_calls) so they don't try to run as a
+ # normal action and fail with a lease error.
+ if final_call is None:
+ final_call = c
+ else:
+ duplicate_finals_dropped += 1
+ continue
+ executable_calls.append(c)
+
+ if duplicate_finals_dropped > 0:
+ # Surface the drop so traces show why fewer FINALs were
+ # executed than the LLM emitted.
+ __emit_event__(
+ "duplicate_final_dropped",
+ count=duplicate_finals_dropped,
+ )
+
+ # Append the assistant message with only the executable calls.
+ # FINAL is filtered out of `action_calls` so the message history
+ # does not record a FINAL action with no matching ActionResult,
+ # which would confuse context replay on resume.
append_message(
working_messages,
"Assistant",
response.get("content", "") or "",
- action_calls=calls,
+ action_calls=executable_calls,
)
# Execute all tool calls in parallel via the batch host function.
# Rust handles preflight (lease/policy), parallel execution via
# JoinSet, and event emission in call order.
- results = __execute_actions_parallel__(calls)
+ results = __execute_actions_parallel__(executable_calls)
for idx in range(len(results)):
r = results[idx]
if r is None:
continue
- call = calls[idx] if idx < len(calls) else {}
+ call = executable_calls[idx] if idx < len(executable_calls) else {}
call_id = call.get("call_id", "")
action_name = r.get("action_name", call.get("name", ""))
output = r.get("output")
@@ -672,7 +751,7 @@ def run_loop(context, goal, actions, state, config):
})
gate = r
# Get action info from the original call or the result
- orig_call = calls[r_idx] if r_idx < len(calls) else {}
+ orig_call = executable_calls[r_idx] if r_idx < len(executable_calls) else {}
__transition_to__("waiting", "gate paused: " + gate.get("gate_name", "unknown"))
return {
"outcome": "gate_paused",
@@ -715,6 +794,48 @@ def run_loop(context, goal, actions, state, config):
"parameters": r.get("parameters", {}),
}
+ if final_call is not None:
+ raw_params = final_call.get("params", {})
+ # Some LLMs pass FINAL with the answer as a positional string
+ # argument instead of a named param dict. Handle that case so
+ # the answer is not silently dropped.
+ if isinstance(raw_params, str):
+ answer = raw_params
+ else:
+ params = raw_params or {}
+ answer = (
+ params.get("answer")
+ or params.get("result")
+ or params.get("value")
+ or params.get("content")
+ or params.get("text")
+ )
+ if not answer:
+ # Fall back to the assistant's content text. This may
+ # contain the model's full explanation rather than the
+ # intended terse answer — truncate aggressively so we
+ # don't ship thousands of tokens of reasoning as the
+ # final answer, and emit a trace event so the
+ # ambiguity is visible.
+ fallback_content = response.get("content", "") or ""
+ FINAL_FALLBACK_MAX_CHARS = 500
+ truncated = False
+ if len(fallback_content) > FINAL_FALLBACK_MAX_CHARS:
+ fallback_content = (
+ fallback_content[:FINAL_FALLBACK_MAX_CHARS]
+ + "… [truncated by orchestrator: FINAL was emitted with no recognizable answer param]"
+ )
+ truncated = True
+ answer = fallback_content
+ __emit_event__(
+ "final_fallback",
+ reason="no recognizable answer param on FINAL",
+ truncated=truncated,
+ original_length=len(response.get("content", "") or ""),
+ )
+ __transition_to__("completed", "FINAL via tool_calls")
+ return complete_result(state, "completed", str(answer))
+
__save_checkpoint__(state, {
"nudge_count": consecutive_nudges,
"consecutive_errors": consecutive_errors,
@@ -729,6 +850,3 @@ def run_loop(context, goal, actions, state, config):
# Entry point: call run_loop with injected context variables
result = run_loop(context, goal, actions, state, config)
FINAL(result)
-# Conservative fallback heuristic matching the old Rust-side estimator.
-CHARS_PER_TOKEN = 4
-MESSAGE_OVERHEAD_CHARS = 4
diff --git a/crates/ironclaw_engine/prompts/codeact_preamble.md b/crates/ironclaw_engine/prompts/codeact_preamble.md
index 0f960d81a19..b124d7500f6 100644
--- a/crates/ironclaw_engine/prompts/codeact_preamble.md
+++ b/crates/ironclaw_engine/prompts/codeact_preamble.md
@@ -33,7 +33,7 @@ This is much faster than calling tools sequentially. Use `asyncio.gather()` when
- `llm_query_batched(prompts, context=None)` — Same but for multiple prompts in parallel. Returns a list of strings.
- `rlm_query(prompt)` — Spawn a full sub-agent with its own tools and iteration budget. Use for complex sub-tasks that need tool access. Returns the sub-agent's final answer as a string. More powerful but more expensive than llm_query.
- `FINAL(answer)` — Call this when you have the final answer. The argument is returned to the user.
-- `mission_create(name, goal, cadence="manual", success_criteria=None)` — Create a long-running mission that spawns threads over time. Cadence: "manual", cron expression (e.g. "0 9 * * *"), "event:pattern", or "webhook:path". Returns {"mission_id": "...", "status": "created"}.
+- `mission_create(name, goal, cadence="manual", success_criteria=None)` — Create a long-running mission that spawns threads over time. Cadence: "manual", cron expression (e.g. "0 9 * * *"), "event:pattern", or "webhook:path". Cron expressions accept 5-field (`min hr dom mon dow`), 6-field (`sec min hr dom mon dow` — NOT Quartz-style with year), or 7-field (`sec min hr dom mon dow year`). Cron missions default to the user's timezone from `user_timezone`; pass an explicit `timezone` param to override. Returns {"mission_id": "...", "name": "...", "status": "created"}. When telling the user about a created mission, refer to it by `name`, not by `mission_id` (the UUID is internal).
- `mission_list()` — List all missions with their status, goal, and current focus.
- `mission_fire(id)` — Manually trigger a mission to spawn a thread now.
- `mission_pause(id)` / `mission_resume(id)` — Pause or resume a mission.
@@ -45,6 +45,7 @@ This is much faster than calling tools sequentially. Use `asyncio.gather()` when
- `step_number` — Current execution step
- `state` — Dict of persisted data from previous steps. Contains tool results keyed by tool name (e.g. `state['web_search']`) and return values (`state['last_return']`, `state['step_0_return']`). Use this to access data from previous steps without re-calling tools.
- `previous_results` — Dict of prior tool call results (from ActionResult messages)
+- `user_timezone` — The user's IANA timezone (e.g. "America/New_York", "Europe/London"). Defaults to "UTC". Use this for time-aware operations, scheduling, and cron timezone parameters.
## Important rules
diff --git a/crates/ironclaw_engine/prompts/mission_skill_repair.md b/crates/ironclaw_engine/prompts/mission_skill_repair.md
new file mode 100644
index 00000000000..122f58f5689
--- /dev/null
+++ b/crates/ironclaw_engine/prompts/mission_skill_repair.md
@@ -0,0 +1,70 @@
+You are the skill-repair learning mission for the IronClaw engine. You receive trigger payloads from completed threads where an active skill was relevant, but execution suggests the skill instructions were incomplete, stale, incorrectly ordered, or missing verification or workarounds.
+
+## Input
+
+`state["trigger_payload"]` contains:
+- `source_thread_id` — the completed thread that exposed the skill gap
+- `goal` — what the thread was trying to accomplish
+- `active_skills` — implicated skills with `doc_id`, `name`, `version`, and snippet names
+- `issues` — trace issues from the thread
+- `error_messages` — action failure text
+- `observed_actions` — actions actually attempted during execution
+- `repair_hints` — conservative hint categories such as `missing_prerequisite`, `stale_command_path`, `missing_pitfall`, `missing_verification`
+
+## Mission
+
+Choose the single most likely implicated skill and produce the smallest safe repair.
+
+Classify the gap as exactly one of:
+- `missing_prerequisite`
+- `wrong_ordering`
+- `stale_command_path`
+- `missing_branch`
+- `missing_pitfall`
+- `missing_verification`
+
+## Process
+
+1. Inspect the implicated skill and source context with tools (`memory_search`, `memory_read`, `read_file`, `shell`, etc.).
+2. Confirm the gap from the thread evidence. If the evidence points to engine behavior instead of the skill, do not repair the skill.
+3. Generate the smallest safe content patch:
+ - add an auth or setup prerequisite check
+ - add a missing ordering note
+ - fix one exact command or path
+ - add one platform-specific branch or workaround
+ - add one verification or smoke-test step
+4. Keep the skill focused. Do not rewrite the entire skill unless the existing content is unusable.
+
+## Output Format
+
+Return a single JSON object in `FINAL(...)` with this shape:
+
+```json
+{
+ "doc_id": "",
+ "repair_type": "missing_prerequisite",
+ "summary": "Added GitHub auth prerequisite before gh commands.",
+ "updated_content": "",
+ "description": "",
+ "activation": {
+ "keywords": ["github", "pull request"],
+ "patterns": [],
+ "tags": ["github"],
+ "exclude_keywords": [],
+ "max_context_tokens": 1200
+ },
+ "code_snippets": [],
+ "next_focus": "Watch for repeated failures in repo-cloning flows.",
+ "goal_achieved": false
+}
+```
+
+Only include `description`, `activation`, or `code_snippets` if they truly need to change.
+
+## Rules
+
+- Repair only one skill per thread.
+- Only target a `doc_id` from `active_skills`.
+- Prefer additive edits over broad rewrites.
+- Do not write the skill doc directly with `memory_write`; return structured JSON and let the runtime apply the versioned update.
+- If the evidence is weak or the gap is not skill-related, call `FINAL("No safe skill repair identified")`.
diff --git a/crates/ironclaw_engine/src/capability/lease.rs b/crates/ironclaw_engine/src/capability/lease.rs
index cf0e20b48e2..7e35c5dce8b 100644
--- a/crates/ironclaw_engine/src/capability/lease.rs
+++ b/crates/ironclaw_engine/src/capability/lease.rs
@@ -118,6 +118,22 @@ impl LeaseManager {
Ok(())
}
+ /// Update the granted actions for an existing lease in place.
+ pub async fn update_granted_actions(
+ &self,
+ lease_id: LeaseId,
+ granted_actions: GrantedActions,
+ ) -> Result {
+ let mut leases = self.active.write().await;
+ let lease = leases
+ .get_mut(&lease_id)
+ .ok_or_else(|| EngineError::LeaseNotFound {
+ lease_id: format!("{lease_id:?}"),
+ })?;
+ lease.granted_actions = granted_actions;
+ Ok(lease.clone())
+ }
+
/// Revoke a lease by ID with a reason for audit trail.
pub async fn revoke(&self, lease_id: LeaseId, reason: &str) {
let mut leases = self.active.write().await;
diff --git a/crates/ironclaw_engine/src/executor/loop_engine.rs b/crates/ironclaw_engine/src/executor/loop_engine.rs
index 33e59e45110..f7fd43ae91e 100644
--- a/crates/ironclaw_engine/src/executor/loop_engine.rs
+++ b/crates/ironclaw_engine/src/executor/loop_engine.rs
@@ -394,7 +394,7 @@ mod tests {
')' => {
depth -= 1;
if depth == 0 {
- let answer = remaining[..i].trim();
+ let answer = remaining[..i].trim(); // safety: i is from char_indices(), always a valid boundary
if !answer.is_empty() {
return Some(answer.to_string());
}
@@ -617,8 +617,12 @@ mod tests {
let outcome = exec.run().await.unwrap();
assert!(matches!(outcome, ThreadOutcome::Completed { response: Some(r) } if r == "Done!"));
assert_eq!(exec.thread.step_count, 2);
- // Should have: system(nudge not counted), assistant+actions, action_result, assistant
- assert!(exec.thread.messages.len() >= 3);
+ // Orchestrator-driven flow: working messages live in `internal_messages`
+ // (set by `sync_runtime_state` when the orchestrator persists state),
+ // while `thread.messages` only carries the system prompt + final
+ // assistant response. The full conversation transcript (system,
+ // assistant+actions, action_result, assistant) is in internal_messages.
+ assert!(exec.thread.internal_messages.len() >= 3);
}
#[tokio::test]
@@ -735,10 +739,12 @@ mod tests {
matches!(outcome, ThreadOutcome::Completed { response: Some(r) } if r == "The answer is 42")
);
assert_eq!(exec.thread.step_count, 2);
- // Should have nudge system message
+ // Nudge is injected into the orchestrator's working messages, which
+ // are persisted as `thread.internal_messages` (not the user-visible
+ // `messages` transcript).
assert!(
exec.thread
- .messages
+ .internal_messages
.iter()
.any(|m| m.content.contains("did not include any tool calls"))
);
@@ -833,7 +839,7 @@ mod tests {
matches!(outcome, ThreadOutcome::Completed { response: Some(r) } if r == "got result")
);
// Should have at least 1 action result recorded
- assert!(!exec.thread.messages.is_empty());
+ assert!(!exec.thread.internal_messages.is_empty());
}
#[tokio::test]
@@ -873,10 +879,11 @@ mod tests {
matches!(outcome, ThreadOutcome::Completed { response: Some(r) } if r == "done, x was 30")
);
assert_eq!(exec.thread.step_count, 2);
- // The output metadata from first step should be in messages
+ // The first step's stdout/output metadata is persisted as part of
+ // the orchestrator's working messages → `internal_messages`.
assert!(
exec.thread
- .messages
+ .internal_messages
.iter()
.any(|m| m.content.contains("x = 30"))
);
@@ -904,7 +911,7 @@ mod tests {
// First step should have error in output metadata
assert!(
exec.thread
- .messages
+ .internal_messages
.iter()
.any(|m| { m.content.contains("NameError") || m.content.contains("Error") })
);
diff --git a/crates/ironclaw_engine/src/executor/orchestrator.rs b/crates/ironclaw_engine/src/executor/orchestrator.rs
index b6996b81e58..2797453ee4a 100644
--- a/crates/ironclaw_engine/src/executor/orchestrator.rs
+++ b/crates/ironclaw_engine/src/executor/orchestrator.rs
@@ -19,6 +19,7 @@
//! - `__get_actions__` — available tool definitions
use std::sync::Arc;
+use std::sync::atomic::{AtomicU64, Ordering};
use std::collections::HashMap;
@@ -26,11 +27,12 @@ use monty::{
ExtFunctionResult, LimitedTracker, MontyObject, MontyRun, NameLookupResult, PrintWriter,
ResourceLimits, RunProgress,
};
-use tracing::debug;
+use tracing::{debug, warn};
use crate::capability::lease::LeaseManager;
use crate::capability::policy::PolicyEngine;
use crate::memory::RetrievalEngine;
+use crate::runtime::lease_refresh::reconcile_dynamic_tool_lease;
use crate::runtime::messaging::{SignalReceiver, ThreadOutcome, ThreadSignal};
use crate::traits::effect::{EffectExecutor, ThreadExecutionContext};
use crate::traits::llm::{LlmBackend, LlmCallConfig};
@@ -40,8 +42,9 @@ use crate::types::event::{EventKind, ThreadEvent, summarize_params};
use crate::types::message::ThreadMessage;
use crate::types::project::ProjectId;
use crate::types::shared_owner_id;
-use crate::types::step::{StepId, TokenUsage};
-use crate::types::thread::{Thread, ThreadState};
+use crate::types::step::{ActionCall, StepId, TokenUsage};
+use crate::types::thread::{ActiveSkillProvenance, Thread, ThreadState};
+use ironclaw_common::ValidTimezone;
use super::scripting::{execute_code, json_to_monty, monty_to_json, monty_to_string};
@@ -71,6 +74,15 @@ fn thread_source_channel(thread: &Thread) -> Option {
.map(String::from)
}
+/// Extract and validate user_timezone from thread metadata (set by bridge router).
+fn thread_user_timezone(thread: &Thread) -> Option {
+ thread
+ .metadata
+ .get("user_timezone")
+ .and_then(|v| v.as_str())
+ .and_then(ValidTimezone::parse)
+}
+
fn normalize_pause_outcome(
thread: &mut Thread,
outcome: &ThreadOutcome,
@@ -97,6 +109,23 @@ const MAX_FAILURES_BEFORE_ROLLBACK: u64 = 3;
/// Well-known title for orchestrator failure tracking.
const FAILURE_TRACKER_TITLE: &str = "orchestrator:failures";
+const LEASE_REFRESH_WARN_INTERVAL_SECS: u64 = 60;
+
+fn warn_on_lease_refresh_failure(context: &'static str, error: &crate::types::error::EngineError) {
+ static LAST_WARN_TS: AtomicU64 = AtomicU64::new(0);
+
+ let now = chrono::Utc::now().timestamp().max(0) as u64;
+ let last = LAST_WARN_TS.load(Ordering::Relaxed);
+ if now.saturating_sub(last) >= LEASE_REFRESH_WARN_INTERVAL_SECS
+ && LAST_WARN_TS
+ .compare_exchange(last, now, Ordering::Relaxed, Ordering::Relaxed)
+ .is_ok()
+ {
+ warn!(context, error = %error, "dynamic lease refresh failed");
+ } else {
+ debug!(context, error = %error, "dynamic lease refresh failed");
+ }
+}
/// Load orchestrator code: runtime version from Store, or compiled-in default.
///
@@ -394,9 +423,12 @@ pub async fn execute_orchestrator(
args,
kwargs,
thread,
- llm,
- effects,
- leases,
+ LlmCompleteDeps {
+ llm,
+ effects,
+ leases,
+ store,
+ },
&mut total_tokens,
)
.await
@@ -447,7 +479,7 @@ pub async fn execute_orchestrator(
"__check_budget__" => handle_check_budget(thread),
// __get_actions__()
- "__get_actions__" => handle_get_actions(thread, effects, leases).await,
+ "__get_actions__" => handle_get_actions(thread, effects, leases, store).await,
// __list_skills__(max_candidates, max_tokens)
"__list_skills__" => handle_list_skills(args, thread, store).await,
@@ -455,6 +487,16 @@ pub async fn execute_orchestrator(
// __record_skill_usage__(doc_id, success)
"__record_skill_usage__" => handle_record_skill_usage(args, store).await,
+ // __regex_match__(pattern, text) -> bool
+ // Evaluates a regex against text using Rust's regex crate.
+ // Invalid patterns return False silently. Monty has no `re`
+ // module, so this host function bridges the gap for the
+ // skill selector's pattern-based scoring.
+ "__regex_match__" => handle_regex_match(args),
+
+ // __set_active_skills__(skills)
+ "__set_active_skills__" => handle_set_active_skills(args, thread),
+
// Unknown — let Monty resolve it (user-defined functions, builtins)
other => ExtFunctionResult::NotFound(other.to_string()),
};
@@ -523,6 +565,13 @@ pub async fn execute_orchestrator(
// ── Host function handlers ──────────────────────────────────
+struct LlmCompleteDeps<'a> {
+ llm: &'a Arc,
+ effects: &'a Arc,
+ leases: &'a Arc,
+ store: Option<&'a Arc>,
+}
+
/// Handle `__llm_complete__(messages, actions, config)`.
///
/// Calls the LLM and returns the response as a dict:
@@ -532,9 +581,7 @@ async fn handle_llm_complete(
args: &[MontyObject],
_kwargs: &[(MontyObject, MontyObject)],
thread: &mut Thread,
- llm: &Arc,
- effects: &Arc,
- leases: &Arc,
+ deps: LlmCompleteDeps<'_>,
total_tokens: &mut TokenUsage,
) -> ExtFunctionResult {
use crate::types::step::LlmResponse;
@@ -546,8 +593,21 @@ async fn handle_llm_complete(
.and_then(json_to_thread_messages)
.unwrap_or_else(|| thread.messages.clone());
- let active_leases = leases.active_for_thread(thread.id).await;
- let actions = effects
+ if let Err(e) = reconcile_dynamic_tool_lease(
+ thread,
+ deps.effects,
+ deps.leases,
+ deps.store,
+ &crate::LeasePlanner::new(),
+ )
+ .await
+ {
+ warn_on_lease_refresh_failure("llm_complete", &e);
+ }
+
+ let active_leases = deps.leases.active_for_thread(thread.id).await;
+ let actions = deps
+ .effects
.available_actions(&active_leases)
.await
.unwrap_or_default();
@@ -572,7 +632,7 @@ async fn handle_llm_complete(
metadata: HashMap::new(),
};
- match llm.complete(&messages, &actions, &config).await {
+ match deps.llm.complete(&messages, &actions, &config).await {
Ok(output) => {
total_tokens.input_tokens += output.usage.input_tokens;
total_tokens.output_tokens += output.usage.output_tokens;
@@ -592,16 +652,9 @@ async fn handle_llm_complete(
serde_json::json!({"type": "code", "code": code, "usage": usage})
}
LlmResponse::ActionCalls { calls, content } => {
- let calls_json: Vec = calls
- .iter()
- .map(|c| {
- serde_json::json!({
- "name": c.action_name,
- "call_id": c.id,
- "params": c.parameters,
- })
- })
- .collect();
+ // Single source of truth for the Python interchange
+ // shape — must round-trip via `python_json_to_action_calls`.
+ let calls_json = action_calls_to_python_json(&calls);
serde_json::json!({
"type": "actions",
"content": content,
@@ -658,6 +711,7 @@ async fn handle_execute_code_step(
step_id: StepId::new(),
current_call_id: None,
source_channel: thread_source_channel(thread),
+ user_timezone: thread_user_timezone(thread),
};
// Run user code in a nested Monty VM (same pattern as rlm_query)
@@ -685,6 +739,37 @@ async fn handle_execute_code_step(
}
thread.events.push(event);
}
+ // If the CodeAct snippet itself failed (Python SyntaxError, runtime
+ // error, etc.), surface it as an ActionFailed event so traces and
+ // observers see the failure. Without this, parse errors silently
+ // fall back to the LLM via the result dict and never warn callers.
+ if result.had_error {
+ let error_msg = if !result.stdout.is_empty() {
+ let snippet: String = result.stdout.chars().take(500).collect();
+ format!("CodeAct execution failed: {snippet}")
+ } else {
+ "CodeAct execution failed (no stdout)".to_string()
+ };
+ let failed_event = ThreadEvent::new(
+ thread.id,
+ EventKind::ActionFailed {
+ step_id: exec_ctx.step_id,
+ action_name: "__codeact__".to_string(),
+ // Synthetic call_id derived from the step id —
+ // CodeAct snippet failures don't have an LLM-provided
+ // call_id, but `loop_engine.rs:1277` asserts that
+ // ActionFailed events carry a non-empty call_id for
+ // trace correlation.
+ call_id: format!("codeact-step-{}", exec_ctx.step_id.0),
+ error: error_msg,
+ params_summary: None,
+ },
+ );
+ if let Some(tx) = event_tx {
+ let _ = tx.send(failed_event.clone());
+ }
+ thread.events.push(failed_event);
+ }
thread.updated_at = chrono::Utc::now();
let action_results: Vec = result
@@ -776,6 +861,7 @@ async fn handle_execute_action(
step_id: StepId::new(),
current_call_id: Some(call_id.clone()),
source_channel: thread_source_channel(thread),
+ user_timezone: thread_user_timezone(thread),
};
// Helper: emit event only. The orchestrator owns transcript recording.
@@ -887,32 +973,88 @@ async fn handle_execute_action(
}
}
- // 3. Consume a lease use
- if let Err(e) = leases.consume_use(lease.id).await {
- debug!(error = %e, "lease consumption failed (non-fatal)");
- }
-
- // 4. Execute
- let ps = summarize_params(&name, ¶ms);
- match effects
- .execute_action(&name, params, &lease, &exec_ctx)
- .await
- {
- Ok(r) => {
+ // 3. Atomically re-find + consume a lease use under a single write
+ // lock. This closes the TOCTOU window between the read-only
+ // `find_lease_for_action` (used above for the policy check) and the
+ // consume — without it, two concurrent calls could both observe a
+ // lease with one remaining use and both proceed to execute. Mirrors
+ // `structured.rs::execute_action_batch_with_results`.
+ let lease = match leases.find_and_consume(thread.id, &name).await {
+ Ok(l) => l,
+ Err(e) => {
+ debug!(error = %e, "atomic lease find_and_consume failed");
+ let error = format!("lease consumption failed for action '{name}': {e}");
+ let output = serde_json::json!({"error": &error});
emit_and_record(
thread,
event_tx,
- EventKind::ActionExecuted {
+ EventKind::ActionFailed {
step_id: exec_ctx.step_id,
action_name: name.clone(),
call_id: call_id.clone(),
- duration_ms: r.duration.as_millis() as u64,
- params_summary: ps.clone(),
+ error,
+ params_summary: None,
},
&call_id,
&name,
- &r.output,
+ &output,
);
+ let result = serde_json::json!({
+ "output": output,
+ "is_error": true,
+ });
+ return ExtFunctionResult::Return(json_to_monty(&result));
+ }
+ };
+
+ // 4. Execute
+ let ps = summarize_params(&name, ¶ms);
+ match effects
+ .execute_action(&name, params, &lease, &exec_ctx)
+ .await
+ {
+ Ok(r) => {
+ // Effect adapters wrap tool errors as `Ok(ActionResult { is_error: true })`
+ // — surface them as `ActionFailed` so traces and observers see the
+ // failure. See `resolve_tool_future` in `scripting.rs` for the same
+ // pattern on the structured-tool path.
+ if r.is_error {
+ let error_msg = r
+ .output
+ .get("error")
+ .and_then(|v| v.as_str())
+ .map(String::from)
+ .unwrap_or_else(|| r.output.to_string());
+ emit_and_record(
+ thread,
+ event_tx,
+ EventKind::ActionFailed {
+ step_id: exec_ctx.step_id,
+ action_name: name.clone(),
+ call_id: call_id.clone(),
+ error: error_msg,
+ params_summary: ps.clone(),
+ },
+ &call_id,
+ &name,
+ &r.output,
+ );
+ } else {
+ emit_and_record(
+ thread,
+ event_tx,
+ EventKind::ActionExecuted {
+ step_id: exec_ctx.step_id,
+ action_name: name.clone(),
+ call_id: call_id.clone(),
+ duration_ms: r.duration.as_millis() as u64,
+ params_summary: ps.clone(),
+ },
+ &call_id,
+ &name,
+ &r.output,
+ );
+ }
let result = serde_json::json!({
"action_name": r.action_name,
"output": r.output,
@@ -1183,10 +1325,35 @@ async fn handle_execute_actions_parallel(
}
}
- // Consume lease
- if let Err(e) = leases.consume_use(lease.id).await {
- debug!(error = %e, "lease consumption failed (non-fatal)");
- }
+ // Atomically re-find + consume a lease use under a single write
+ // lock, closing the TOCTOU window between the read-only
+ // `find_lease_for_action` above and the consume. Mirrors
+ // `structured.rs::execute_action_batch_with_results`.
+ let lease = match leases.find_and_consume(thread.id, &pc.name).await {
+ Ok(l) => l,
+ Err(e) => {
+ debug!(error = %e, "atomic lease find_and_consume failed");
+ let error = format!("lease consumption failed for action '{}': {e}", pc.name);
+ let output = serde_json::json!({"error": &error});
+ let result_json = serde_json::json!({
+ "output": &output,
+ "is_error": true,
+ });
+ let event = EventKind::ActionFailed {
+ step_id,
+ action_name: pc.name.clone(),
+ call_id: pc.call_id.clone(),
+ error,
+ params_summary: None,
+ };
+ preflight.push(Some(PfOutcome::Error {
+ result_json,
+ event,
+ output,
+ }));
+ continue;
+ }
+ };
preflight.push(Some(PfOutcome::Runnable { lease }));
}
@@ -1229,7 +1396,13 @@ async fn handle_execute_actions_parallel(
user_id: thread.user_id.clone(),
step_id,
current_call_id: Some(pc.call_id.clone()),
- source_channel: None,
+ // Read source_channel from thread metadata so downstream tools
+ // (e.g. mission_create) can default notify_channels to the
+ // originating channel. Hardcoding `None` here was a bug — it
+ // silently dropped the gateway routing for any tool dispatched
+ // through the parallel batch path.
+ source_channel: thread_source_channel(thread),
+ user_timezone: thread_user_timezone(thread),
};
let ps = summarize_params(&pc.name, &pc.params);
let (result_json, event, output) = execute_single_action(
@@ -1252,6 +1425,10 @@ async fn handle_execute_actions_parallel(
// Multiple calls: execute in parallel via JoinSet
let mut join_set = tokio::task::JoinSet::new();
let effects = effects.clone();
+ // Capture once outside the loop — the thread's metadata is stable
+ // for the duration of the parallel batch.
+ let parallel_source_channel = thread_source_channel(thread);
+ let parallel_user_timezone = thread_user_timezone(thread);
for (idx, lease) in runnable {
let pc_name = parsed[idx].name.clone();
@@ -1266,7 +1443,9 @@ async fn handle_execute_actions_parallel(
user_id: thread.user_id.clone(),
step_id,
current_call_id: Some(pc_call_id.clone()),
- source_channel: None,
+ // See comment above — read from thread metadata, not None.
+ source_channel: parallel_source_channel.clone(),
+ user_timezone: parallel_user_timezone,
};
let ps = summarize_params(&pc_name, &pc_params);
@@ -1341,12 +1520,30 @@ async fn execute_single_action(
) -> (serde_json::Value, EventKind, serde_json::Value) {
match effects.execute_action(name, params, lease, exec_ctx).await {
Ok(r) => {
- let event = EventKind::ActionExecuted {
- step_id: exec_ctx.step_id,
- action_name: name.to_string(),
- call_id: call_id.to_string(),
- duration_ms: r.duration.as_millis() as u64,
- params_summary: params_summary.clone(),
+ // Surface wrapped errors as ActionFailed (see resolve_tool_future
+ // and the parallel execute path for the same pattern).
+ let event = if r.is_error {
+ let error_msg = r
+ .output
+ .get("error")
+ .and_then(|v| v.as_str())
+ .map(String::from)
+ .unwrap_or_else(|| r.output.to_string());
+ EventKind::ActionFailed {
+ step_id: exec_ctx.step_id,
+ action_name: name.to_string(),
+ call_id: call_id.to_string(),
+ error: error_msg,
+ params_summary: params_summary.clone(),
+ }
+ } else {
+ EventKind::ActionExecuted {
+ step_id: exec_ctx.step_id,
+ action_name: name.to_string(),
+ call_id: call_id.to_string(),
+ duration_ms: r.duration.as_millis() as u64,
+ params_summary: params_summary.clone(),
+ }
};
let result_json = serde_json::json!({
"action_name": r.action_name,
@@ -1654,10 +1851,18 @@ fn handle_check_budget(thread: &Thread) -> ExtFunctionResult {
/// Handle `__get_actions__()`.
async fn handle_get_actions(
- thread: &Thread,
+ thread: &mut Thread,
effects: &Arc,
leases: &Arc,
+ store: Option<&Arc>,
) -> ExtFunctionResult {
+ if let Err(e) =
+ reconcile_dynamic_tool_lease(thread, effects, leases, store, &crate::LeasePlanner::new())
+ .await
+ {
+ warn_on_lease_refresh_failure("get_actions", &e);
+ }
+
let active_leases = leases.active_for_thread(thread.id).await;
match effects.available_actions(&active_leases).await {
Ok(actions) => {
@@ -1756,6 +1961,75 @@ async fn handle_record_skill_usage(
ExtFunctionResult::Return(MontyObject::None)
}
+/// Handle `__regex_match__(pattern, text) -> bool`.
+///
+/// Compiles `pattern` with a bounded size limit and returns whether it
+/// matches anywhere in `text`. Invalid regex or a size-limit violation
+/// returns `False` silently. Used by the Python skill selector for regex
+/// pattern scoring (Monty has no `re` module).
+///
+/// **Security: ReDoS safety.** This handler accepts arbitrary patterns from
+/// the Python orchestrator (which itself receives them from skill manifests)
+/// and runs them on user-supplied text. Safety relies on the `regex` crate's
+/// linear-time matching guarantee (no backreferences, no lookaround) plus the
+/// 64 KiB compiled-size cap and DFA-size cap below. If the `regex` crate is
+/// ever swapped for `fancy-regex` (which supports backreferences and is NOT
+/// linear-time), this becomes a real ReDoS vector. This is enforced by
+/// convention and documentation only — see the top-of-crate comment in
+/// `crates/ironclaw_engine/src/lib.rs`. (A `#[cfg(feature = "fancy-regex")]
+/// compile_error!` tripwire was evaluated but conflicts with
+/// `cargo clippy --all-features` which is the standard CI command.)
+fn handle_regex_match(args: &[MontyObject]) -> ExtFunctionResult {
+ let pattern = args.first().map(monty_to_string).unwrap_or_default();
+ let text = args.get(1).map(monty_to_string).unwrap_or_default();
+ if pattern.is_empty() {
+ return ExtFunctionResult::Return(MontyObject::Bool(false));
+ }
+ // Cap compiled regex size to prevent ReDoS (matches the 64 KiB limit used
+ // by `LoadedSkill::compile_patterns` in `ironclaw_skills`). Also cap the
+ // lazy-DFA cache: the `regex` crate's DFA can grow beyond `size_limit`
+ // during matching, so `dfa_size_limit` is a separate defensive cap on
+ // memory allocation from a crafted pattern over untrusted skill manifests.
+ const MAX_REGEX_SIZE: usize = 1 << 16;
+ let matched = match regex::RegexBuilder::new(&pattern)
+ .size_limit(MAX_REGEX_SIZE)
+ .dfa_size_limit(MAX_REGEX_SIZE)
+ .build()
+ {
+ Ok(re) => re.is_match(&text),
+ Err(e) => {
+ debug!("__regex_match__: invalid pattern '{pattern}': {e}");
+ false
+ }
+ };
+ ExtFunctionResult::Return(MontyObject::Bool(matched))
+}
+
+/// Handle `__set_active_skills__(skills)`.
+///
+/// Persists the selected skill provenance onto the thread so post-run learning
+/// flows can reason about the exact skill versions and snippets that were active.
+fn handle_set_active_skills(args: &[MontyObject], thread: &mut Thread) -> ExtFunctionResult {
+ let skills_json = args
+ .first()
+ .map(monty_to_json)
+ .unwrap_or_else(|| serde_json::json!([]));
+
+ let skills = match serde_json::from_value::>(skills_json) {
+ Ok(skills) => skills,
+ Err(e) => {
+ debug!("__set_active_skills__: invalid payload: {e}");
+ return ExtFunctionResult::Return(MontyObject::None);
+ }
+ };
+
+ if let Err(e) = thread.set_active_skills(&skills) {
+ debug!("__set_active_skills__: failed to persist active skills: {e}");
+ }
+
+ ExtFunctionResult::Return(MontyObject::None)
+}
+
// ── Helpers ─────────────────────────────────────────────────
/// Build the context variables injected into the orchestrator Python.
@@ -1781,12 +2055,27 @@ fn build_orchestrator_inputs(
let context: Vec = bootstrap_messages
.iter()
.map(|m| {
+ // Serialize action_calls through the Python interchange shape
+ // (`{name, call_id, params}`) so the bootstrap context is
+ // round-trip compatible with `python_json_to_action_calls`.
+ // Using bare `m.action_calls` here produces the canonical Rust
+ // serde format (`{action_name, id, parameters}`), which the
+ // Python orchestrator passes back verbatim on the next
+ // `__llm_complete__` call — and `python_json_to_action_calls`
+ // then fails with "missing field `name`", orphaning every
+ // subsequent tool result. This is the SECOND code path (after
+ // `handle_llm_complete`) that feeds action_calls into the
+ // Python working transcript; both must use the same shape.
+ let calls_json = m
+ .action_calls
+ .as_ref()
+ .map(|calls| serde_json::Value::Array(action_calls_to_python_json(calls)));
serde_json::json!({
"role": format!("{:?}", m.role),
"content": m.content,
"action_name": m.action_name,
"action_call_id": m.action_call_id,
- "action_calls": m.action_calls,
+ "action_calls": calls_json,
})
})
.collect();
@@ -1818,6 +2107,164 @@ fn build_orchestrator_inputs(
(names, values)
}
+/// JSON shape used to interchange `ActionCall`s with the Python orchestrator.
+///
+/// This is the *single* place that defines the field naming convention used
+/// across the Python boundary. It is intentionally separate from the
+/// canonical `ActionCall` type because:
+///
+/// - `ActionCall` uses Rust-idiomatic field names (`id`, `action_name`,
+/// `parameters`) and is also persisted into Step records and ThreadEvents.
+/// Renaming its serde fields would invalidate every existing row.
+/// - The Python orchestrator uses friendlier names (`call_id`, `name`,
+/// `params`) that read naturally in CodeAct prompts and `default.py`.
+///
+/// Without this type, the round-trip is asymmetric: Rust → Python uses one
+/// shape, Python → Rust used `serde_json::from_value::>`
+/// which silently fails (`.ok()` swallows the error) and produces `None`,
+/// which means assistant messages came back without `action_calls`. The
+/// downstream effect is that every tool result looks orphaned to
+/// `sanitize_tool_messages` and gets rewritten as a user message — losing
+/// the assistant ↔ tool_result linkage the LLM needs to reason about prior
+/// tool calls.
+#[derive(Debug, serde::Serialize, serde::Deserialize)]
+struct PythonActionCall {
+ name: String,
+ call_id: String,
+ params: serde_json::Value,
+}
+
+impl From<&ActionCall> for PythonActionCall {
+ fn from(c: &ActionCall) -> Self {
+ Self {
+ name: c.action_name.clone(),
+ call_id: c.id.clone(),
+ params: c.parameters.clone(),
+ }
+ }
+}
+
+impl From for ActionCall {
+ fn from(p: PythonActionCall) -> Self {
+ Self {
+ id: p.call_id,
+ action_name: p.name,
+ parameters: p.params,
+ }
+ }
+}
+
+/// Serialize a slice of `ActionCall`s into the Python interchange shape.
+///
+/// On serialization failure (essentially unreachable for `String + String +
+/// Value`, but still possible if the `serde_json::Value` parameters tree
+/// contains a key whose stringification fails), the entry is **dropped**
+/// from the output rather than replaced with `Value::Null`. The previous
+/// `unwrap_or_else(|_| Value::Null)` corrupted the array — Python's
+/// `default.py` accesses `c.get("name")` / `c.get("call_id")` /
+/// `c.get("params")` on each entry, so a `null` would crash with a Python
+/// `AttributeError` and lose the entire LLM step. `filter_map` produces a
+/// shorter array, which Python's tool-result loop handles correctly because
+/// it iterates `range(len(results))` against the shortened call list. The
+/// warn log is preserved so operators have a breadcrumb if it ever fires.
+fn action_calls_to_python_json(calls: &[ActionCall]) -> Vec {
+ calls
+ .iter()
+ .filter_map(|c| match serde_json::to_value(PythonActionCall::from(c)) {
+ Ok(value) => Some(value),
+ Err(e) => {
+ warn!(
+ error = %e,
+ action_name = %c.action_name,
+ "Failed to serialize ActionCall for Python orchestrator — dropping entry"
+ );
+ None
+ }
+ })
+ .collect()
+}
+
+/// Build a PII-safe summary of an `action_calls` JSON value for log output.
+///
+/// The action_calls payload contains tool parameters, which can carry user
+/// PII (search queries, file names, email content, conversation text).
+/// Dumping the full value into a `warn!` log would leak that PII to log
+/// aggregation systems (Datadog, CloudWatch, Sentry) the moment the parser
+/// fails — and the parser only fails when the Python ↔ Rust shape drifts,
+/// which is exactly when an operator is most likely to be grepping logs.
+///
+/// We emit only the structural information operators actually need to
+/// debug a shape drift: array length and the keys of the first entry. The
+/// keys themselves are not user data — they're field names like
+/// `name`/`call_id`/`params` that are static across all calls.
+fn summarize_action_calls_for_log(value: &serde_json::Value) -> String {
+ match value.as_array() {
+ Some(arr) if arr.is_empty() => "empty array".to_string(),
+ Some(arr) => {
+ let first_keys = arr
+ .first()
+ .and_then(|v| v.as_object())
+ .map(|obj| {
+ let mut keys: Vec<&str> = obj.keys().map(String::as_str).collect();
+ keys.sort_unstable();
+ keys.join(",")
+ })
+ .unwrap_or_else(|| "".to_string());
+ format!(
+ "array of {} entries; first entry keys: [{}]",
+ arr.len(),
+ first_keys
+ )
+ }
+ None => format!("non-array value of type {}", json_value_type_name(value)),
+ }
+}
+
+/// Cheap type-name string for a `serde_json::Value`. Used by
+/// `summarize_action_calls_for_log` to surface the wrong-shape case
+/// (e.g. Python passed a string instead of an array) without leaking the
+/// actual contents.
+fn json_value_type_name(value: &serde_json::Value) -> &'static str {
+ match value {
+ serde_json::Value::Null => "null",
+ serde_json::Value::Bool(_) => "bool",
+ serde_json::Value::Number(_) => "number",
+ serde_json::Value::String(_) => "string",
+ serde_json::Value::Array(_) => "array",
+ serde_json::Value::Object(_) => "object",
+ }
+}
+
+/// Deserialize an `action_calls` JSON array (in Python interchange shape)
+/// back into canonical `ActionCall`s.
+///
+/// Logs a warning on failure rather than swallowing silently. The whole
+/// commit that introduced this helper exists to undo a `.ok()` swallow that
+/// dropped action_calls without any signal — replacing it with another
+/// `.ok()?` would re-introduce the same trap, just one layer deeper. If the
+/// shape ever drifts again (Python orchestrator field rename, extra
+/// required field, partial migration), the warning is the operator-visible
+/// breadcrumb that explains why subsequent tool results suddenly look
+/// orphaned to `sanitize_tool_messages`.
+///
+/// The warn log emits a structural summary (`summarize_action_calls_for_log`)
+/// instead of the raw value because tool parameters can contain user PII.
+fn python_json_to_action_calls(value: &serde_json::Value) -> Option> {
+ match serde_json::from_value::>(value.clone()) {
+ Ok(parsed) => Some(parsed.into_iter().map(ActionCall::from).collect()),
+ Err(e) => {
+ warn!(
+ error = %e,
+ shape = %summarize_action_calls_for_log(value),
+ "Failed to parse action_calls from Python orchestrator — \
+ assistant message will lose tool_call linkage and downstream \
+ tool results will be rewritten as user messages"
+ );
+ None
+ }
+ }
+}
+
fn json_to_thread_messages(value: &serde_json::Value) -> Option> {
let arr = value.as_array()?;
let mut messages = Vec::with_capacity(arr.len());
@@ -1828,9 +2275,15 @@ fn json_to_thread_messages(value: &serde_json::Value) -> Option ThreadMessage::system(content),
@@ -2009,7 +2462,7 @@ mod tests {
let helpers_end = DEFAULT_ORCHESTRATOR
.find("\ndef run_loop(")
.unwrap_or(DEFAULT_ORCHESTRATOR.len());
- let helpers = &DEFAULT_ORCHESTRATOR[..helpers_end];
+ let helpers = &DEFAULT_ORCHESTRATOR[..helpers_end]; // safety: find() returns a char boundary on this ASCII-only constant
let code = format!("{helpers}\nFINAL({expr})");
@@ -2044,12 +2497,15 @@ mod tests {
other => panic!("FINAL() received non-bool: {other:?}"),
};
}
- // Unknown host function — return None and continue
+ // Dispatch the real host functions the test exercises so
+ // e.g. `__regex_match__` routes through the production
+ // handler instead of being stubbed out to `None`.
+ let ext_result = match call.function_name.as_str() {
+ "__regex_match__" => handle_regex_match(&call.args),
+ _ => ExtFunctionResult::Return(MontyObject::None),
+ };
progress = call
- .resume(
- ExtFunctionResult::Return(MontyObject::None),
- PrintWriter::Collect(&mut stdout),
- )
+ .resume(ext_result, PrintWriter::Collect(&mut stdout))
.expect("resume failed");
}
RunProgress::NameLookup(lookup) => {
@@ -2065,6 +2521,26 @@ mod tests {
}
}
+ // ── __regex_match__ host function reachability ───────────────
+
+ #[test]
+ fn regex_match_host_function_is_callable_from_monty() {
+ // Regression test for PR #1736 review (serrrfirat, 3059161877):
+ // verify that Monty's NameLookup + FunctionCall dispatch actually
+ // reaches `handle_regex_match` when default.py calls
+ // `__regex_match__(...)`. If Monty ever starts resolving the name
+ // before the call, this test will fail with a NameError.
+ assert!(eval_python_bool(
+ r#"bool(__regex_match__("abc", "xxabcxx"))"#
+ ));
+ assert!(!eval_python_bool(
+ r#"bool(__regex_match__("zzz", "xxabcxx"))"#
+ ));
+ // Invalid pattern should return false silently (the host function
+ // swallows the compile error).
+ assert!(!eval_python_bool(r#"bool(__regex_match__("[", "abc"))"#));
+ }
+
// ── True positives (should trigger nudge) ───────────────────
#[test]
@@ -2471,4 +2947,385 @@ mod tests {
let outcome = parse_outcome(&result);
assert!(matches!(outcome, ThreadOutcome::Stopped));
}
+
+ // ── Python ↔ Rust ActionCall round-trip ───────────────────────────────
+ //
+ // Regression tests for the orphaned-tool-result bug. The Python
+ // orchestrator stores `action_calls` on assistant messages using the
+ // shape `{name, call_id, params}`, but the canonical Rust `ActionCall`
+ // uses `{action_name, id, parameters}`. Without the explicit
+ // `PythonActionCall` interchange type, `serde_json::from_value` would
+ // silently fail (`.ok()` swallows the error) and the Python-shaped
+ // assistant message would be parsed back as a plain assistant message
+ // with no tool calls, causing every subsequent ActionResult to be
+ // detected as orphaned by `sanitize_tool_messages` in the host crate.
+
+ #[test]
+ fn python_action_call_round_trips_through_serde() {
+ let original = ActionCall {
+ id: "call_abc123".to_string(),
+ action_name: "google_drive_tool".to_string(),
+ parameters: serde_json::json!({"query": "expenses"}),
+ };
+
+ let python_json = serde_json::to_value(PythonActionCall::from(&original))
+ .expect("PythonActionCall must serialize");
+ // Python-friendly field names — match what default.py reads.
+ assert_eq!(python_json["name"], "google_drive_tool");
+ assert_eq!(python_json["call_id"], "call_abc123");
+ assert_eq!(
+ python_json["params"],
+ serde_json::json!({"query": "expenses"})
+ );
+
+ let parsed: PythonActionCall =
+ serde_json::from_value(python_json).expect("must deserialize");
+ let round_tripped: ActionCall = parsed.into();
+ assert_eq!(round_tripped.id, original.id);
+ assert_eq!(round_tripped.action_name, original.action_name);
+ assert_eq!(round_tripped.parameters, original.parameters);
+ }
+
+ #[test]
+ fn action_calls_to_python_json_uses_python_field_names() {
+ let calls = vec![
+ ActionCall {
+ id: "call_1".to_string(),
+ action_name: "notion_notion_search".to_string(),
+ parameters: serde_json::json!({"query": "name"}),
+ },
+ ActionCall {
+ id: "call_2".to_string(),
+ action_name: "google_drive_tool".to_string(),
+ parameters: serde_json::json!({"action": "list"}),
+ },
+ ];
+ let json = action_calls_to_python_json(&calls);
+ assert_eq!(json.len(), 2);
+ assert_eq!(json[0]["name"], "notion_notion_search");
+ assert_eq!(json[0]["call_id"], "call_1");
+ assert_eq!(json[1]["name"], "google_drive_tool");
+ assert_eq!(json[1]["call_id"], "call_2");
+ }
+
+ #[test]
+ fn python_json_to_action_calls_parses_python_field_names() {
+ // The exact shape default.py produces (and stores on assistant
+ // messages via `append_message(..., action_calls=calls)`).
+ let python_json = serde_json::json!([
+ {"name": "notion_notion_search", "call_id": "call_xyz", "params": {"q": "foo"}},
+ {"name": "google_drive_tool", "call_id": "call_abc", "params": {"action": "list"}},
+ ]);
+ let parsed = python_json_to_action_calls(&python_json).expect("must parse");
+ assert_eq!(parsed.len(), 2);
+ assert_eq!(parsed[0].action_name, "notion_notion_search");
+ assert_eq!(parsed[0].id, "call_xyz");
+ assert_eq!(parsed[0].parameters, serde_json::json!({"q": "foo"}));
+ assert_eq!(parsed[1].action_name, "google_drive_tool");
+ assert_eq!(parsed[1].id, "call_abc");
+ }
+
+ #[test]
+ fn python_json_to_action_calls_rejects_canonical_field_names() {
+ // Sanity check: the parser is strict about Python field names.
+ // If `default.py` ever changes the shape, the test must catch it.
+ let canonical_json = serde_json::json!([
+ {"action_name": "search", "id": "call_x", "parameters": {}}
+ ]);
+ // Missing "name", "call_id", "params" → returns None.
+ assert!(python_json_to_action_calls(&canonical_json).is_none());
+ }
+
+ #[test]
+ fn summarize_action_calls_for_log_does_not_leak_user_pii() {
+ // The whole point of this helper is that the warn log path on a
+ // shape-drift failure must NOT dump tool parameters (which can
+ // contain user PII like search queries, file names, email content)
+ // into log aggregation systems. The summary should expose only
+ // structural information: array length and the keys of the first
+ // entry. The keys themselves are static (`name`, `call_id`,
+ // `params`), not user data.
+ let pii_value = serde_json::json!([
+ {
+ "name": "google_drive_tool",
+ "call_id": "call_xyz",
+ "params": {
+ "query": "salary spreadsheet for joe",
+ "secret_token": "very-sensitive-token-do-not-log"
+ }
+ },
+ {
+ "name": "gmail",
+ "call_id": "call_abc",
+ "params": {
+ "subject": "private message about layoffs"
+ }
+ }
+ ]);
+ let summary = summarize_action_calls_for_log(&pii_value);
+
+ // Structural info present.
+ assert!(summary.contains("array of 2 entries"));
+ assert!(summary.contains("call_id"));
+ assert!(summary.contains("name"));
+ assert!(summary.contains("params"));
+
+ // PII fields and their values must NOT appear.
+ assert!(
+ !summary.contains("salary"),
+ "summary must not leak user PII from params: {summary}"
+ );
+ assert!(
+ !summary.contains("very-sensitive-token"),
+ "summary must not leak credential-shaped values: {summary}"
+ );
+ assert!(
+ !summary.contains("layoffs"),
+ "summary must not leak free-text content: {summary}"
+ );
+ assert!(
+ !summary.contains("google_drive_tool"),
+ "summary must not leak the tool name itself (could expose intent): {summary}"
+ );
+ }
+
+ #[test]
+ fn summarize_action_calls_for_log_handles_edge_cases() {
+ assert_eq!(
+ summarize_action_calls_for_log(&serde_json::json!([])),
+ "empty array"
+ );
+ assert!(
+ summarize_action_calls_for_log(&serde_json::json!("not an array")).contains("string")
+ );
+ assert!(
+ summarize_action_calls_for_log(&serde_json::json!({"foo": "bar"})).contains("object")
+ );
+ assert!(summarize_action_calls_for_log(&serde_json::json!(null)).contains("null"));
+ }
+
+ /// Caller-level regression test: feeds `json_to_thread_messages` the
+ /// exact JSON shape that `default.py` produces for an assistant message
+ /// with tool calls followed by tool results, and asserts that the
+ /// resulting `ThreadMessage`s preserve the `action_calls` ↔
+ /// `action_call_id` linkage. Without the `PythonActionCall` parser the
+ /// assistant message would come back with `action_calls = None` and
+ /// every following ActionResult would look orphaned to the bridge.
+ #[test]
+ fn json_to_thread_messages_preserves_action_calls_from_python_orchestrator() {
+ // This is the literal shape `default.py` writes into
+ // `state["working_messages"]` after a Tier 0 step:
+ //
+ // append_message(working_messages, "Assistant", "...", action_calls=calls)
+ // append_message(working_messages, "ActionResult", "...", action_name=..., action_call_id=...)
+ //
+ // where `calls` came from the LLM response and has shape
+ // `[{"name": ..., "call_id": ..., "params": ...}]`.
+ let working_messages = serde_json::json!([
+ {"role": "User", "content": "search in notion for my name"},
+ {
+ "role": "Assistant",
+ "content": "",
+ "action_calls": [
+ {
+ "name": "notion_notion_search",
+ "call_id": "call_xyz",
+ "params": {"query": "Illia"}
+ }
+ ]
+ },
+ {
+ "role": "ActionResult",
+ "content": "found 3 results",
+ "action_name": "notion_notion_search",
+ "action_call_id": "call_xyz"
+ }
+ ]);
+
+ let messages = json_to_thread_messages(&working_messages).expect("must parse");
+ assert_eq!(messages.len(), 3);
+
+ // The assistant message MUST have action_calls populated, with
+ // matching call_id. If this assertion fails, the bridge layer
+ // will treat the following ActionResult as orphaned and rewrite
+ // it as a user message — losing the model's ability to reason
+ // about prior tool output.
+ let assistant = &messages[1];
+ assert_eq!(
+ assistant.role,
+ crate::types::message::MessageRole::Assistant
+ );
+ let calls = assistant
+ .action_calls
+ .as_ref()
+ .expect("assistant message must carry action_calls after round-trip");
+ assert_eq!(calls.len(), 1);
+ assert_eq!(calls[0].id, "call_xyz");
+ assert_eq!(calls[0].action_name, "notion_notion_search");
+ assert_eq!(calls[0].parameters, serde_json::json!({"query": "Illia"}));
+
+ // The ActionResult must reference the same call_id so the bridge
+ // can pair them.
+ let result = &messages[2];
+ assert_eq!(
+ result.role,
+ crate::types::message::MessageRole::ActionResult
+ );
+ assert_eq!(result.action_call_id.as_deref(), Some("call_xyz"));
+ assert_eq!(result.action_name.as_deref(), Some("notion_notion_search"));
+ }
+
+ /// Regression for the gate-resume / bootstrap path: when a thread
+ /// resumes after approval or auth, `build_orchestrator_inputs`
+ /// serializes `thread.internal_messages` into the bootstrap context
+ /// that Python reads into `working_messages`. If `action_calls` is
+ /// serialized with canonical `ActionCall` field names (`action_name`,
+ /// `id`, `parameters`) instead of the Python interchange names
+ /// (`name`, `call_id`, `params`), the next `__llm_complete__` call
+ /// passes them back through `json_to_thread_messages` which fails
+ /// with "missing field `name`" and orphans every subsequent tool
+ /// result.
+ ///
+ /// This test simulates the full round-trip: build a `ThreadMessage`
+ /// with action_calls → serialize through `build_orchestrator_inputs`'s
+ /// exact serialization pattern → parse back through
+ /// `json_to_thread_messages` → assert the calls survive. If anyone
+ /// adds a THIRD serialization path in the future and uses canonical
+ /// names, this test documents the pattern they should follow.
+ #[test]
+ fn bootstrap_context_action_calls_round_trip_through_python_interchange() {
+ // Build a thread message the way the engine does: an assistant
+ // message with action_calls in canonical ActionCall format (the
+ // shape stored in the DB / internal_messages).
+ let msg = ThreadMessage::assistant_with_actions(
+ Some("I'll search for that".to_string()),
+ vec![ActionCall {
+ id: "call_resume_test".to_string(),
+ action_name: "google_drive_tool".to_string(),
+ parameters: serde_json::json!({"query": "budget"}),
+ }],
+ );
+
+ // Serialize through the SAME pattern `build_orchestrator_inputs`
+ // uses. This is the exact code path that was broken before the
+ // fix — it was using `"action_calls": m.action_calls` which
+ // produced canonical field names.
+ let calls_json = msg
+ .action_calls
+ .as_ref()
+ .map(|calls| serde_json::Value::Array(action_calls_to_python_json(calls)));
+ let serialized = serde_json::json!([{
+ "role": "Assistant",
+ "content": msg.content,
+ "action_name": msg.action_name,
+ "action_call_id": msg.action_call_id,
+ "action_calls": calls_json,
+ }]);
+
+ // Parse back through the same path Python's working_messages
+ // takes when it calls __llm_complete__.
+ let parsed = json_to_thread_messages(&serialized).expect("must parse");
+ assert_eq!(parsed.len(), 1);
+
+ let assistant = &parsed[0];
+ let calls = assistant.action_calls.as_ref().expect(
+ "bootstrap context action_calls must survive the round-trip. \
+ If this fails, a serialization path is using canonical ActionCall \
+ field names instead of PythonActionCall interchange names.",
+ );
+ assert_eq!(calls.len(), 1);
+ assert_eq!(calls[0].id, "call_resume_test");
+ assert_eq!(calls[0].action_name, "google_drive_tool");
+ assert_eq!(calls[0].parameters, serde_json::json!({"query": "budget"}));
+ }
+
+ /// Negative regression: verify that canonical ActionCall field names
+ /// do NOT round-trip. If this test ever PASSES, it means someone
+ /// added `#[serde(rename)]` to ActionCall or changed the parser to
+ /// accept both formats — which is fine, but the PythonActionCall
+ /// interchange type can then be removed. This test documents the
+ /// current contract: canonical names are rejected by the parser.
+ #[test]
+ fn canonical_action_call_field_names_do_not_round_trip() {
+ let serialized_with_canonical_names = serde_json::json!([{
+ "role": "Assistant",
+ "content": "",
+ "action_calls": [{
+ "action_name": "search",
+ "id": "call_x",
+ "parameters": {}
+ }],
+ }]);
+ let parsed =
+ json_to_thread_messages(&serialized_with_canonical_names).expect("messages parse");
+ // The assistant message should have NO action_calls because the
+ // parser rejects canonical field names.
+ assert!(
+ parsed[0].action_calls.is_none(),
+ "canonical ActionCall field names must NOT parse as action_calls. \
+ If this assertion fails, the PythonActionCall interchange type \
+ is no longer needed — either remove it or update the contract."
+ );
+ }
+
+ /// Regression: `action_calls: null` is Python's legitimate "this
+ /// message has no tool calls" signal (text-only response). Before the
+ /// null filter, `python_json_to_action_calls` would fire a warn log
+ /// with "invalid type: null, expected a sequence" on every text-only
+ /// assistant message — a false alarm that masked real drift issues.
+ #[test]
+ fn json_to_thread_messages_handles_null_action_calls_gracefully() {
+ let messages = serde_json::json!([
+ {
+ "role": "Assistant",
+ "content": "Here is your answer.",
+ "action_calls": null
+ }
+ ]);
+ let parsed = json_to_thread_messages(&messages).expect("must parse");
+ assert_eq!(parsed.len(), 1);
+ assert_eq!(
+ parsed[0].role,
+ crate::types::message::MessageRole::Assistant
+ );
+ assert_eq!(parsed[0].content, "Here is your answer.");
+ assert!(
+ parsed[0].action_calls.is_none(),
+ "null action_calls must produce None, not a parse error"
+ );
+ }
+
+ /// Verify that messages WITHOUT the action_calls key at all (the most
+ /// common case for text responses) also parse correctly — this is the
+ /// baseline that the null-filtering regression test extends.
+ #[test]
+ fn json_to_thread_messages_handles_absent_action_calls() {
+ let messages = serde_json::json!([
+ {"role": "Assistant", "content": "Just text, no tools."}
+ ]);
+ let parsed = json_to_thread_messages(&messages).expect("must parse");
+ assert_eq!(parsed.len(), 1);
+ assert!(parsed[0].action_calls.is_none());
+ }
+
+ /// Empty action_calls array is valid (LLM decided not to call any
+ /// tools this turn but the response still has the array field). Must
+ /// produce `Some(vec![])`, not `None`.
+ #[test]
+ fn json_to_thread_messages_handles_empty_action_calls_array() {
+ let messages = serde_json::json!([
+ {
+ "role": "Assistant",
+ "content": "No tools needed.",
+ "action_calls": []
+ }
+ ]);
+ let parsed = json_to_thread_messages(&messages).expect("must parse");
+ assert_eq!(parsed.len(), 1);
+ let calls = parsed[0]
+ .action_calls
+ .as_ref()
+ .expect("empty array should produce Some(vec![])");
+ assert!(calls.is_empty());
+ }
}
diff --git a/crates/ironclaw_engine/src/executor/scripting.rs b/crates/ironclaw_engine/src/executor/scripting.rs
index 814484eca8a..eb6e7bcf571 100644
--- a/crates/ironclaw_engine/src/executor/scripting.rs
+++ b/crates/ironclaw_engine/src/executor/scripting.rs
@@ -35,6 +35,7 @@ use crate::types::event::EventKind;
use crate::types::message::{MessageRole, ThreadMessage};
use crate::types::step::{ActionResult, LlmResponse, TokenUsage};
use crate::types::thread::Thread;
+use ironclaw_common::ValidTimezone;
// ── Configuration ───────────────────────────────────────────
@@ -226,6 +227,17 @@ fn build_context_inputs(
names.push("previous_results".into());
values.push(MontyObject::dict(result_pairs));
+ // `user_timezone` — validated IANA timezone from the user's channel (e.g. "America/New_York")
+ let tz = thread
+ .metadata
+ .get("user_timezone")
+ .and_then(|v| v.as_str())
+ .and_then(ValidTimezone::parse)
+ .map(|vtz| vtz.name().to_string())
+ .unwrap_or_else(|| "UTC".into());
+ names.push("user_timezone".into());
+ values.push(MontyObject::String(tz));
+
(names, values)
}
@@ -1268,13 +1280,35 @@ async fn resolve_tool_future(
) -> ExtFunctionResult {
match handle.await {
Ok(Ok(result)) => {
- events.push(EventKind::ActionExecuted {
- step_id: context.step_id,
- action_name: action_name.into(),
- call_id: call_id.into(),
- duration_ms: result.duration.as_millis() as u64,
- params_summary,
- });
+ // If the effect adapter wrapped a tool error as an Ok(ActionResult)
+ // with is_error=true (current convention in
+ // `EffectBridgeAdapter::execute_action_internal`), surface it as
+ // ActionFailed so traces, observers, and approval flows see the
+ // failure correctly. Without this, every wrapped error looked like
+ // a successful tool call to downstream consumers.
+ if result.is_error {
+ let error_msg = result
+ .output
+ .get("error")
+ .and_then(|v| v.as_str())
+ .map(String::from)
+ .unwrap_or_else(|| result.output.to_string());
+ events.push(EventKind::ActionFailed {
+ step_id: context.step_id,
+ action_name: action_name.into(),
+ call_id: call_id.into(),
+ error: error_msg,
+ params_summary,
+ });
+ } else {
+ events.push(EventKind::ActionExecuted {
+ step_id: context.step_id,
+ action_name: action_name.into(),
+ call_id: call_id.into(),
+ duration_ms: result.duration.as_millis() as u64,
+ params_summary,
+ });
+ }
let monty_val = json_to_monty(&result.output);
action_results.push(result);
ExtFunctionResult::Return(monty_val)
@@ -1480,6 +1514,19 @@ mod tests {
use crate::types::thread::{Thread, ThreadConfig, ThreadType};
use std::sync::Mutex;
+ /// Truncate a string to at most `max_bytes`, snapping to a UTF-8 char
+ /// boundary so assertion messages never panic on multibyte output.
+ fn truncate_for_assert(s: &str, max_bytes: usize) -> &str {
+ if s.len() <= max_bytes {
+ return s;
+ }
+ let mut end = max_bytes;
+ while end > 0 && !s.is_char_boundary(end) {
+ end -= 1;
+ }
+ &s[..end] // safety: end is walked down to a valid char boundary above
+ }
+
struct MockEffects {
results: Mutex>>,
actions: Vec,
@@ -1554,6 +1601,7 @@ mod tests {
step_id: StepId::new(),
current_call_id: None,
source_channel: None,
+ user_timezone: None,
}
}
@@ -1973,7 +2021,7 @@ while True:
assert!(
r.had_error || r.stdout.contains("Error") || r.stdout.contains("limit"),
"resource limit should terminate infinite loop, got stdout: {}",
- &r.stdout[..r.stdout.len().min(500)],
+ truncate_for_assert(&r.stdout, 500),
);
}
// Err(_) is also acceptable — means the VM was killed by resource limits
@@ -2115,7 +2163,7 @@ while True:
assert!(
r.had_error || r.stdout.contains("Error") || r.stdout.contains("limit"),
"cpu-bound loop should be terminated, stdout: {}",
- &r.stdout[..r.stdout.len().min(500)],
+ truncate_for_assert(&r.stdout, 500),
);
}
// Err(_) is also acceptable — means the VM was killed by resource limits
diff --git a/crates/ironclaw_engine/src/executor/structured.rs b/crates/ironclaw_engine/src/executor/structured.rs
index b249b581d24..6d197b52b4d 100644
--- a/crates/ironclaw_engine/src/executor/structured.rs
+++ b/crates/ironclaw_engine/src/executor/structured.rs
@@ -323,12 +323,31 @@ fn classify_exec_result(
match result {
Ok(mut action_result) => {
action_result.call_id = call.id.clone();
- let event = EventKind::ActionExecuted {
- step_id: context.step_id,
- action_name: call.action_name.clone(),
- call_id: call.id.clone(),
- duration_ms: action_result.duration.as_millis() as u64,
- params_summary: None,
+ // Effect adapters wrap tool errors as `Ok(ActionResult { is_error: true })`
+ // — emit ActionFailed in that case so traces and downstream
+ // observers see the failure rather than treating it as success.
+ let event = if action_result.is_error {
+ let error_msg = action_result
+ .output
+ .get("error")
+ .and_then(|v| v.as_str())
+ .map(String::from)
+ .unwrap_or_else(|| action_result.output.to_string());
+ EventKind::ActionFailed {
+ step_id: context.step_id,
+ action_name: call.action_name.clone(),
+ call_id: call.id.clone(),
+ error: error_msg,
+ params_summary: None,
+ }
+ } else {
+ EventKind::ActionExecuted {
+ step_id: context.step_id,
+ action_name: call.action_name.clone(),
+ call_id: call.id.clone(),
+ duration_ms: action_result.duration.as_millis() as u64,
+ params_summary: None,
+ }
};
(action_result, event)
}
@@ -470,6 +489,7 @@ mod tests {
step_id: StepId::new(),
current_call_id: None,
source_channel: None,
+ user_timezone: None,
}
}
diff --git a/crates/ironclaw_engine/src/executor/trace.rs b/crates/ironclaw_engine/src/executor/trace.rs
index 00f942ca207..0252c30c3df 100644
--- a/crates/ironclaw_engine/src/executor/trace.rs
+++ b/crates/ironclaw_engine/src/executor/trace.rs
@@ -1,12 +1,15 @@
-//! Execution trace recording and analysis.
+//! Execution trace analysis.
//!
-//! Records full execution traces to JSON files for debugging. Optionally
-//! runs a post-execution analysis to detect common issues.
+//! Builds an in-memory `ExecutionTrace` from a completed `Thread` and runs a
+//! retrospective analyzer that flags common failure patterns. Used by the
+//! self-improvement mission and surfaced in debug logs.
//!
-//! Enable with `ENGINE_V2_TRACE=1` env var. Traces are written to
-//! `engine_trace_{timestamp}.json` in the current directory.
-
-use std::path::PathBuf;
+//! **There is no separate engine trace file.** Live trace recording for the
+//! whole system is handled by `RecordingLlm` in the host crate
+//! (`src/llm/recording.rs`), gated by `IRONCLAW_RECORD_TRACE`. Because the
+//! engine's `LlmBackend` is wired to the same provider chain, engine LLM
+//! interactions are captured by that single recorder — no engine-side env var
+//! and no second JSON file.
use chrono::Utc;
use serde::Serialize;
@@ -15,13 +18,6 @@ use tracing::debug;
use crate::types::event::ThreadEvent;
use crate::types::thread::{Thread, ThreadId, ThreadState};
-/// Check if trace recording is enabled.
-pub fn is_trace_enabled() -> bool {
- std::env::var("ENGINE_V2_TRACE")
- .map(|v| v == "1" || v == "true")
- .unwrap_or(false)
-}
-
/// A complete execution trace for a single thread.
#[derive(Debug, Serialize)]
pub struct ExecutionTrace {
@@ -108,29 +104,6 @@ pub fn build_trace(thread: &Thread) -> ExecutionTrace {
}
}
-/// Write a trace to a JSON file.
-pub fn write_trace(trace: &ExecutionTrace) -> Option {
- let filename = format!("engine_trace_{}.json", Utc::now().format("%Y%m%dT%H%M%S"));
- let path = PathBuf::from(&filename);
-
- match serde_json::to_string_pretty(trace) {
- Ok(json) => match std::fs::write(&path, json) {
- Ok(()) => {
- debug!(path = %path.display(), "Execution trace written");
- Some(path)
- }
- Err(e) => {
- debug!("Failed to write trace: {e}");
- None
- }
- },
- Err(e) => {
- debug!("Failed to serialize trace: {e}");
- None
- }
- }
-}
-
/// Print a summary of the trace to the log.
pub fn log_trace_summary(trace: &ExecutionTrace) {
debug!(
@@ -582,7 +555,15 @@ mod tests {
));
let trace = build_trace(&thread);
- match &trace.events[0].kind {
+ // `Thread::add_message` records a `MessageAdded` event for each
+ // message, so the `ApprovalRequested` event is no longer at index 0
+ // — it's mixed in with the message events. Find it by kind.
+ let approval = trace
+ .events
+ .iter()
+ .find(|e| matches!(&e.kind, EventKind::ApprovalRequested { .. }))
+ .expect("trace should contain an ApprovalRequested event");
+ match &approval.kind {
EventKind::ApprovalRequested {
action_name,
call_id,
@@ -610,7 +591,10 @@ mod tests {
assert!(json.contains("\"ApprovalRequested\""));
assert!(json.contains("\"action_name\":\"tool_install\""));
assert!(json.contains("\"call_id\":\"call_install_1\""));
- assert!(json.contains("\"parameters\":{\"name\":\"notion\",\"kind\":\"mcp_server\"}"));
+ // Parameter map key order isn't stable across serde_json versions; check
+ // both required keys are present rather than the exact serialized form.
+ assert!(json.contains("\"name\":\"notion\""));
+ assert!(json.contains("\"kind\":\"mcp_server\""));
assert!(json.contains("\"description\":\"Install an extension\""));
assert!(json.contains("\"allow_always\":true"));
assert!(json.contains("\"gate_name\":\"approval\""));
diff --git a/crates/ironclaw_engine/src/lib.rs b/crates/ironclaw_engine/src/lib.rs
index fb39628384a..5894d7a7a64 100644
--- a/crates/ironclaw_engine/src/lib.rs
+++ b/crates/ironclaw_engine/src/lib.rs
@@ -14,6 +14,15 @@
//! [`Store`], [`EffectExecutor`]) that the host crate implements via bridge
//! adapters over existing infrastructure.
+// Security: `__regex_match__` (in `executor/orchestrator.rs`) accepts
+// arbitrary patterns from the Python orchestrator and runs them on
+// user-supplied text. The default `regex` crate is linear-time. The
+// `fancy-regex` crate supports backreferences and is NOT linear-time, which
+// would turn that handler into a ReDoS vector. Cargo.toml depends on
+// `regex = "1"` with default features only — do NOT add `fancy-regex` to
+// this crate's dependency tree without first redesigning `__regex_match__`
+// to enforce a wall-clock matching budget.
+
pub mod capability;
pub mod executor;
pub mod gate;
@@ -33,19 +42,22 @@ pub use types::error::{CapabilityError, EngineError, StepError, ThreadError};
pub use types::event::{EventId, EventKind, ThreadEvent};
pub use types::memory::{DocId, DocType, MemoryDoc};
pub use types::message::{MessageRole, ThreadMessage};
-pub use types::mission::{Mission, MissionCadence, MissionId, MissionStatus};
+pub use types::mission::{Mission, MissionCadence, MissionId, MissionStatus, ValidTimezone};
pub use types::project::{Project, ProjectId};
pub use types::provenance::Provenance;
pub use types::step::{
ActionCall, ActionResult, ExecutionTier, LlmResponse, Step, StepId, StepStatus, TokenUsage,
};
-pub use types::thread::{Thread, ThreadConfig, ThreadId, ThreadState, ThreadType};
+pub use types::thread::{
+ ActiveSkillProvenance, Thread, ThreadConfig, ThreadId, ThreadState, ThreadType,
+};
// ── Re-exports: traits ──────────────────────────────────────
pub use traits::effect::{EffectExecutor, ThreadExecutionContext};
pub use traits::llm::{LlmBackend, LlmCallConfig, LlmOutput};
pub use traits::store::Store;
+pub use traits::workspace::WorkspaceReader;
// ── Re-exports: capability ────────────────────────────────────
@@ -69,7 +81,9 @@ pub use executor::prompt::PlatformInfo;
pub use runtime::conversation::ConversationManager;
pub use runtime::manager::ThreadManager;
pub use runtime::messaging::ThreadOutcome;
-pub use runtime::mission::{MissionManager, MissionNotification, MissionUpdate};
+pub use runtime::mission::{
+ BudgetGate, FireRateLimit, MissionManager, MissionNotification, MissionUpdate,
+};
pub use runtime::tree::ThreadTree;
pub use types::conversation::{
diff --git a/crates/ironclaw_engine/src/memory/skill_tracker.rs b/crates/ironclaw_engine/src/memory/skill_tracker.rs
index a0fbd76ef85..c3d7f57e3e8 100644
--- a/crates/ironclaw_engine/src/memory/skill_tracker.rs
+++ b/crates/ironclaw_engine/src/memory/skill_tracker.rs
@@ -6,7 +6,8 @@
use std::sync::Arc;
-use ironclaw_skills::v2::V2SkillMetadata;
+use ironclaw_skills::v2::{SkillRevision, V2SkillMetadata};
+use sha2::{Digest, Sha256};
use crate::traits::store::Store;
use crate::types::error::EngineError;
@@ -17,6 +18,19 @@ pub struct SkillTracker {
store: Arc,
}
+fn compute_content_hash(content: &str) -> String {
+ let mut hasher = Sha256::new();
+ hasher.update(content.as_bytes());
+ format!(
+ "sha256:{}",
+ hasher
+ .finalize()
+ .iter()
+ .map(|b| format!("{:02x}", b))
+ .collect::()
+ )
+}
+
impl SkillTracker {
pub fn new(store: Arc) -> Self {
Self { store }
@@ -25,8 +39,9 @@ impl SkillTracker {
/// Record that a skill was used in a completed thread.
///
/// Loads the skill's MemoryDoc, updates metrics in the metadata JSON,
- /// and saves it back. If the doc is not found or has invalid metadata,
- /// the error is logged and the operation is skipped.
+ /// and saves it back. Returns `Err(EngineError::Skill)` if the doc is
+ /// missing, not a Skill, or has invalid metadata — callers decide whether
+ /// to propagate or log-and-swallow.
pub async fn record_usage(&self, doc_id: DocId, success: bool) -> Result<(), EngineError> {
let doc = self
.store
@@ -74,6 +89,7 @@ impl SkillTracker {
&self,
doc_id: DocId,
new_content: String,
+ expected_version: Option,
updater: impl FnOnce(&mut V2SkillMetadata),
) -> Result<(), EngineError> {
let doc = self
@@ -89,9 +105,43 @@ impl SkillTracker {
reason: format!("invalid skill metadata: {e}"),
})?;
+ if let Some(expected) = expected_version
+ && meta.version != expected
+ {
+ return Err(EngineError::Skill {
+ reason: format!(
+ "skill {} version conflict: expected {expected}, found {}",
+ doc_id.0, meta.version
+ ),
+ });
+ }
+
+ // Always recompute from actual content — meta.content_hash may have
+ // drifted if the doc was updated outside this tracker (e.g. direct
+ // memory_write).
+ let archived_hash = compute_content_hash(&doc.content);
+ meta.revisions.push(SkillRevision {
+ version: meta.version,
+ content: doc.content.clone(),
+ description: meta.description.clone(),
+ activation: meta.activation.clone(),
+ code_snippets: meta.code_snippets.clone(),
+ content_hash: archived_hash,
+ archived_at: Some(chrono::Utc::now()),
+ });
+ // Cap in-memory revisions at 10 to bound metadata size on every
+ // load_memory_doc. This is a pragmatic trade-off: full prompt
+ // snapshots embedded in the skill JSON can grow to many KB per
+ // revision. Older revisions are dropped; if long-term retention is
+ // needed, they should be externalized to separate MemoryDocs.
+ if meta.revisions.len() > 10 {
+ let keep_from = meta.revisions.len() - 10;
+ meta.revisions.drain(0..keep_from);
+ }
meta.parent_version = Some(meta.version);
meta.version += 1;
updater(&mut meta);
+ meta.content_hash = compute_content_hash(&new_content);
let updated_doc = MemoryDoc {
content: new_content,
@@ -107,9 +157,9 @@ impl SkillTracker {
/// Rollback a skill to its previous version.
///
- /// Decrements the version to `parent_version` if available. This is a
- /// simple version decrement — the actual content rollback requires the
- /// caller to also restore the content from a backup.
+ /// If an archived revision exists for `parent_version`, restores the full
+ /// content and metadata snapshot. Otherwise falls back to a simple version
+ /// decrement without content restoration for older skills.
pub async fn rollback_skill(&self, doc_id: DocId) -> Result<(), EngineError> {
let doc = self
.store
@@ -128,10 +178,32 @@ impl SkillTracker {
reason: format!("skill {} has no parent version to rollback to", doc_id.0),
})?;
- meta.version = parent;
- meta.parent_version = None;
+ let revision_opt = meta
+ .revisions
+ .iter()
+ .position(|revision| revision.version == parent);
+
+ let rolled_content = if let Some(revision_index) = revision_opt {
+ let revision = meta.revisions[revision_index].clone();
+ meta.version = revision.version;
+ meta.description = revision.description;
+ meta.activation = revision.activation;
+ meta.code_snippets = revision.code_snippets;
+ meta.content_hash = revision.content_hash;
+ meta.revisions
+ .retain(|archived| archived.version < revision.version);
+ meta.repairs
+ .retain(|repair| repair.to_version <= revision.version);
+ meta.parent_version = meta.revisions.iter().map(|archived| archived.version).max();
+ revision.content
+ } else {
+ meta.version = parent;
+ meta.parent_version = None;
+ doc.content.clone()
+ };
let updated_doc = MemoryDoc {
+ content: rolled_content,
metadata: serde_json::to_value(&meta).map_err(|e| EngineError::Skill {
reason: format!("failed to serialize skill metadata: {e}"),
})?,
@@ -166,6 +238,8 @@ mod tests {
last_used: None,
},
parent_version: None,
+ revisions: vec![],
+ repairs: vec![],
content_hash: String::new(),
};
@@ -227,7 +301,7 @@ mod tests {
let tracker = SkillTracker::new(store.clone());
tracker
- .update_skill(doc_id, "Updated content".to_string(), |meta| {
+ .update_skill(doc_id, "Updated content".to_string(), None, |meta| {
meta.description = "Updated description".to_string();
})
.await
@@ -240,6 +314,8 @@ mod tests {
assert_eq!(meta.version, 2);
assert_eq!(meta.parent_version, Some(1));
assert_eq!(meta.description, "Updated description");
+ assert_eq!(meta.revisions.len(), 1);
+ assert_eq!(meta.revisions[0].version, 1);
}
#[tokio::test]
@@ -253,7 +329,7 @@ mod tests {
// First update to version 2
tracker
- .update_skill(doc_id, "v2 content".to_string(), |_| {})
+ .update_skill(doc_id, "v2 content".to_string(), None, |_| {})
.await
.unwrap();
@@ -264,6 +340,8 @@ mod tests {
let meta: V2SkillMetadata = serde_json::from_value(rolled.metadata).unwrap();
assert_eq!(meta.version, 1);
assert_eq!(meta.parent_version, None);
+ assert_eq!(rolled.content, "Test skill prompt");
+ assert!(meta.revisions.is_empty());
}
#[tokio::test]
@@ -287,4 +365,25 @@ mod tests {
let result = tracker.record_usage(DocId::new(), true).await;
assert!(result.is_err());
}
+
+ #[tokio::test]
+ async fn test_update_skill_version_conflict() {
+ let project_id = ProjectId::new();
+ let doc = make_skill_doc(project_id);
+ let doc_id = doc.id;
+
+ let store = Arc::new(crate::tests::InMemoryStore::with_docs(vec![doc]));
+ let tracker = SkillTracker::new(store);
+
+ let result = tracker
+ .update_skill(doc_id, "Updated content".to_string(), Some(2), |_| {})
+ .await;
+
+ assert!(result.is_err());
+ let error = result.unwrap_err().to_string();
+ assert!(
+ error.contains("version conflict"),
+ "expected version conflict error, got: {error}"
+ );
+ }
}
diff --git a/crates/ironclaw_engine/src/runtime/conversation.rs b/crates/ironclaw_engine/src/runtime/conversation.rs
index 32ed6e2760f..0c4b1291342 100644
--- a/crates/ironclaw_engine/src/runtime/conversation.rs
+++ b/crates/ironclaw_engine/src/runtime/conversation.rs
@@ -8,7 +8,7 @@
use std::collections::HashMap;
use std::sync::Arc;
-use tokio::sync::RwLock;
+use tokio::sync::{Mutex, RwLock};
use tracing::debug;
use crate::runtime::manager::ThreadManager;
@@ -20,6 +20,7 @@ use crate::types::message::ThreadMessage;
use crate::types::project::ProjectId;
use crate::types::thread::{ThreadConfig, ThreadId, ThreadState, ThreadType};
+#[derive(Clone, Copy)]
enum ActiveForeground {
Running(ThreadId),
Resumable(ThreadId),
@@ -31,10 +32,23 @@ enum ActiveForeground {
/// 1. Spawn a new foreground thread for the message
/// 2. Inject the message into an existing active thread
/// 3. Create a new conversation if none exists for this channel+user
+///
+/// ## Locking strategy
+///
+/// `conversations` is a *directory*: the global `RwLock` is held only for
+/// HashMap lookups/inserts and is never held across an `.await`. Each
+/// `ConversationSurface` is wrapped in a `tokio::sync::Mutex` so concurrent
+/// messages to *different* conversations run fully in parallel.
+///
+/// **Lock ordering invariant:** NEVER hold the global `RwLock` and a
+/// per-conversation `Mutex` simultaneously. `get_conversation_lock()` enforces
+/// this — it drops the read guard before returning the `Arc>`.
pub struct ConversationManager {
thread_manager: Arc,
store: Arc,
- conversations: RwLock>,
+ // LOCK ORDER: when acquiring both write locks, always take `conversations` before
+ // `channel_user_index`. Reversing this order will deadlock under concurrent access.
+ conversations: RwLock>>>,
/// Maps (channel, user_id) → conversation ID for lookup.
channel_user_index: RwLock>,
}
@@ -49,22 +63,47 @@ impl ConversationManager {
}
}
+ /// Get the per-conversation lock. Holds the global RwLock only briefly
+ /// (HashMap lookup), then releases it. Returns Err if the conversation
+ /// does not exist.
+ async fn get_conversation_lock(
+ &self,
+ conversation_id: ConversationId,
+ ) -> Result>, EngineError> {
+ let map = self.conversations.read().await;
+ map.get(&conversation_id)
+ .map(Arc::clone)
+ .ok_or_else(|| EngineError::Store {
+ reason: format!("conversation {conversation_id} not found"),
+ })
+ } // RwLockReadGuard dropped here
+
/// Restore persisted conversations for a user into the in-memory index.
pub async fn bootstrap_user(&self, user_id: &str) -> Result {
let conversations = self.store.list_conversations(user_id).await?;
- let count = conversations.len();
let mut convs = self.conversations.write().await;
let mut index = self.channel_user_index.write().await;
+ let mut inserted = 0usize;
for conversation in conversations {
+ if convs.contains_key(&conversation.id) {
+ // Still upsert the index — it may be missing if a prior
+ // get_or_create_conversation inserted the conv but then rolled
+ // back the index entry on a failed save_conversation.
+ index
+ .entry((conversation.channel.clone(), conversation.user_id.clone()))
+ .or_insert(conversation.id);
+ continue;
+ }
index.insert(
(conversation.channel.clone(), conversation.user_id.clone()),
conversation.id,
);
- convs.insert(conversation.id, conversation);
+ convs.insert(conversation.id, Arc::new(Mutex::new(conversation)));
+ inserted += 1;
}
- Ok(count)
+ Ok(inserted)
}
/// Get or create a conversation for a channel+user pair.
@@ -93,20 +132,48 @@ impl ConversationManager {
let conv_id = conv.id;
let mut convs = self.conversations.write().await;
let mut index = self.channel_user_index.write().await;
- convs.insert(conv_id, conv);
+ // Double-check: another task may have inserted while we did I/O.
+ if let Some(existing_id) = index.get(&key) {
+ return Ok(*existing_id);
+ }
+ convs.insert(conv_id, Arc::new(Mutex::new(conv)));
index.insert(key, conv_id);
return Ok(conv_id);
}
- // Create new conversation
+ // Create new conversation.
let conv = ConversationSurface::new(channel, user_id);
let conv_id = conv.id;
- let mut convs = self.conversations.write().await;
- let mut index = self.channel_user_index.write().await;
- convs.insert(conv_id, conv.clone());
- index.insert(key, conv_id);
- self.store.save_conversation(&conv).await?;
+ {
+ let mut convs = self.conversations.write().await;
+ let mut index = self.channel_user_index.write().await;
+ // Double-check: another task may have inserted while we did I/O.
+ if let Some(existing_id) = index.get(&key) {
+ return Ok(*existing_id);
+ }
+ convs.insert(conv_id, Arc::new(Mutex::new(conv.clone())));
+ index.insert(key.clone(), conv_id);
+ } // write locks released before the async save
+
+ if let Err(e) = self.store.save_conversation(&conv).await {
+ // Known limitation: a concurrent caller that observed the new conv_id via the
+ // double-check fast path (between our insert and this rollback) will hold a
+ // now-deleted, never-persisted ConversationId. This race requires simultaneous
+ // first-time logins from the same user+channel AND a store write failure — it
+ // is unlikely in practice and accepted as a structural trade-off of optimistic
+ // in-memory caching with async persistence. The alternative (holding write
+ // locks across the async save) would re-introduce cross-tenant serialization.
+ // Roll back the in-memory insertion so the next caller does not
+ // receive an unpersisted ConversationId.
+ let mut convs = self.conversations.write().await;
+ let mut index = self.channel_user_index.write().await;
+ convs.remove(&conv_id);
+ index.remove(&key);
+ return Err(EngineError::Store {
+ reason: e.to_string(),
+ });
+ }
debug!(conversation_id = %conv_id, channel, user_id, "created conversation");
Ok(conv_id)
@@ -118,6 +185,10 @@ impl ConversationManager {
/// injected into it. Otherwise, a new foreground thread is spawned.
///
/// Returns the thread ID that is handling the message.
+ ///
+ /// The per-conversation `Mutex` is held for the entire operation — from
+ /// the active-thread check through `save_conversation`. This eliminates
+ /// the TOCTOU double-spawn window present in the old 5-phase split.
pub async fn handle_user_message(
&self,
conversation_id: ConversationId,
@@ -125,11 +196,10 @@ impl ConversationManager {
project_id: ProjectId,
user_id: &str,
thread_config: ThreadConfig,
+ user_timezone: Option<&str>,
) -> Result {
- let mut convs = self.conversations.write().await;
- let conv = convs.get_mut(&conversation_id).ok_or(EngineError::Store {
- reason: format!("conversation {conversation_id} not found"),
- })?;
+ let conv_arc = self.get_conversation_lock(conversation_id).await?;
+ let mut conv = conv_arc.lock().await;
// Tenant isolation: verify the requesting user owns this conversation.
if conv.user_id != user_id {
@@ -139,24 +209,33 @@ impl ConversationManager {
});
}
- // Record the user entry
- conv.add_entry(ConversationEntry::user(content));
+ // Snapshot what find_active_foreground needs before the async calls.
+ // NOTE: do NOT add the user entry yet — it will be added after the thread
+ // operation succeeds to avoid orphaned entries if the async op fails.
+ let active_thread_ids = conv.active_threads.clone();
+ let channel_name = conv.channel.clone();
- // Check for an active foreground thread
- let active_foreground = self.find_active_foreground(conv).await;
+ // Async I/O to find the active foreground thread — allowed here because
+ // we hold a tokio::sync::Mutex (not std::sync::Mutex).
+ let active_foreground = self.find_active_foreground(&active_thread_ids).await;
- match active_foreground {
+ let thread_id = match active_foreground {
Some(ActiveForeground::Running(thread_id)) => {
debug!(
conversation_id = %conversation_id,
thread_id = %thread_id,
"injecting message into active thread"
);
+ // Known limitation: a tz change mid-turn (user travels between
+ // messages of the same active thread) is not propagated. The
+ // running ExecutionLoop holds an in-memory copy of the Thread
+ // and cannot be updated externally without a new signal type.
+ // Updating the persisted record here would not affect the live
+ // step. Rare in practice; defer to a follow-up if needed.
self.thread_manager
.inject_message(thread_id, user_id, ThreadMessage::user(content))
.await?;
- self.store.save_conversation(conv).await?;
- Ok(thread_id)
+ thread_id
}
Some(ActiveForeground::Resumable(thread_id)) => {
debug!(
@@ -164,6 +243,24 @@ impl ConversationManager {
thread_id = %thread_id,
"resuming suspended foreground thread"
);
+ // Resume reloads the thread from the store, so writing fresh
+ // user_timezone to the persisted record before resume_thread
+ // means the resumed execution sees the up-to-date value — but
+ // only if this write actually lands. A store failure here
+ // would silently leave the resumed thread with the prior
+ // timezone, so log explicitly rather than swallowing.
+ if let Some(tz) = user_timezone
+ && let Err(e) = self
+ .thread_manager
+ .set_thread_metadata(thread_id, "user_timezone", tz)
+ .await
+ {
+ debug!(
+ thread_id = %thread_id,
+ error = %e,
+ "failed to refresh user_timezone on resume; thread will use previous value"
+ );
+ }
self.thread_manager
.resume_thread(
thread_id,
@@ -173,20 +270,42 @@ impl ConversationManager {
None,
)
.await?;
- conv.add_entry(ConversationEntry::system_for_thread(
- thread_id,
- "Thread resumed",
- ));
- self.store.save_conversation(conv).await?;
- Ok(thread_id)
+ thread_id
}
None => {
- // Build conversation history from prior entries for context continuity
+ // Build conversation history from prior entries for context continuity.
+ // Clone here (None branch only) — inject/resume paths don't need history,
+ // so deferring avoids an O(entries) allocation on those fast paths.
let history = build_history_from_entries(&conv.entries);
- // Spawn new foreground thread with conversation history
- let thread_id = self
- .thread_manager
+ // Build initial thread metadata. Must be applied *before* the
+ // executor's background task starts — `set_thread_metadata`
+ // only updates the persisted record, not the in-memory Thread
+ // the loop is reading from, so the first step would otherwise
+ // miss `user_timezone` / `source_channel`. The bridge router
+ // validates the timezone string before passing it in here.
+ // The orchestrator reads `source_channel` on the very first
+ // step to populate `ThreadExecutionContext.source_channel`,
+ // which `mission_create` consults to default `notify_channels`.
+ let base_channel = channel_name
+ .split(':')
+ .next()
+ .unwrap_or(&channel_name)
+ .to_string();
+ let mut initial_metadata = serde_json::Map::new();
+ initial_metadata.insert(
+ "source_channel".into(),
+ serde_json::Value::String(base_channel),
+ );
+ if let Some(tz) = user_timezone {
+ initial_metadata.insert(
+ "user_timezone".into(),
+ serde_json::Value::String(tz.to_string()),
+ );
+ }
+
+ // Spawn new foreground thread with conversation history.
+ self.thread_manager
.spawn_thread_with_history(
content, // use message as goal
ThreadType::Foreground,
@@ -195,37 +314,45 @@ impl ConversationManager {
None,
user_id,
history,
+ initial_metadata,
)
- .await?;
-
- // Store the base channel name in thread metadata so the
- // orchestrator can populate `source_channel` in the execution
- // context (used by mission_create to default notify_channels).
- let base_channel = conv
- .channel
- .split(':')
- .next()
- .unwrap_or(&conv.channel)
- .to_string();
- self.thread_manager
- .set_thread_metadata(thread_id, "source_channel", &base_channel)
- .await;
+ .await?
+ }
+ };
+ // Final in-memory mutations under the already-held per-conv Mutex.
+ // The user entry is added here — after the thread operation succeeded — to
+ // prevent orphaned entries if inject_message/resume_thread/spawn_thread_with_history
+ // returned an error above.
+ conv.add_entry(ConversationEntry::user(content));
+ match active_foreground {
+ Some(ActiveForeground::Running(_)) => {
+ // No additional in-memory mutation needed beyond the user entry above.
+ }
+ Some(ActiveForeground::Resumable(_)) => {
+ conv.add_entry(ConversationEntry::system_for_thread(
+ thread_id,
+ "Thread resumed",
+ ));
+ }
+ None => {
conv.track_thread(thread_id);
conv.add_entry(ConversationEntry::system_for_thread(
thread_id,
"Thread started",
));
- self.store.save_conversation(conv).await?;
-
debug!(
conversation_id = %conversation_id,
thread_id = %thread_id,
"spawned new foreground thread"
);
- Ok(thread_id)
}
}
+
+ // Persist outside the global RwLock (per-conv Mutex is still held).
+ self.store.save_conversation(&conv).await?;
+
+ Ok(thread_id)
}
/// Record a thread's outcome in its conversation.
@@ -235,50 +362,81 @@ impl ConversationManager {
thread_id: ThreadId,
outcome: &ThreadOutcome,
) -> Result<(), EngineError> {
- let mut convs = self.conversations.write().await;
- if let Some(conv) = convs.get_mut(&conversation_id) {
- match outcome {
- ThreadOutcome::Completed { response } => {
- if let Some(text) = response {
- conv.add_entry(ConversationEntry::agent(thread_id, text));
- }
- conv.untrack_thread(thread_id);
- }
- ThreadOutcome::Stopped => {
- conv.add_entry(ConversationEntry::system_for_thread(
- thread_id,
- "Thread stopped",
- ));
- conv.untrack_thread(thread_id);
- }
- ThreadOutcome::MaxIterations => {
- conv.add_entry(ConversationEntry::system_for_thread(
- thread_id,
- "Thread reached max iterations",
- ));
- conv.untrack_thread(thread_id);
- }
- ThreadOutcome::Failed { error } => {
- conv.add_entry(ConversationEntry::system_for_thread(
- thread_id,
- format!("Thread failed: {error}"),
- ));
- conv.untrack_thread(thread_id);
- }
- ThreadOutcome::GatePaused {
- gate_name,
- action_name,
- ..
- } => {
- conv.add_entry(ConversationEntry::system_for_thread(
- thread_id,
- format!("Gate '{gate_name}' paused execution of action: {action_name}"),
- ));
- // Thread stays active — waiting for gate resolution
+ let conv_arc = self.get_conversation_lock(conversation_id).await?;
+ let mut conv = conv_arc.lock().await;
+ match outcome {
+ ThreadOutcome::Completed { response } => {
+ if let Some(text) = response {
+ conv.add_entry(ConversationEntry::agent(thread_id, text));
}
+ conv.untrack_thread(thread_id);
+ }
+ ThreadOutcome::Stopped => {
+ conv.add_entry(ConversationEntry::system_for_thread(
+ thread_id,
+ "Thread stopped",
+ ));
+ conv.untrack_thread(thread_id);
+ }
+ ThreadOutcome::MaxIterations => {
+ conv.add_entry(ConversationEntry::system_for_thread(
+ thread_id,
+ "Thread reached max iterations",
+ ));
+ conv.untrack_thread(thread_id);
+ }
+ ThreadOutcome::Failed { error } => {
+ conv.add_entry(ConversationEntry::system_for_thread(
+ thread_id,
+ format!("Thread failed: {error}"),
+ ));
+ conv.untrack_thread(thread_id);
+ }
+ ThreadOutcome::GatePaused {
+ gate_name,
+ action_name,
+ ..
+ } => {
+ conv.add_entry(ConversationEntry::system_for_thread(
+ thread_id,
+ format!("Gate '{gate_name}' paused execution of action: {action_name}"),
+ ));
+ // Thread stays active — waiting for gate resolution
}
- self.store.save_conversation(conv).await?;
}
+ // Known limitation: if save_conversation fails, the in-memory mutations (add_entry,
+ // untrack_thread) are already applied but not persisted. Memory and DB diverge until
+ // the next successful save. Rolling back would require snapshotting the prior state,
+ // which is not implemented here — accepted as a low-probability failure mode.
+ self.store.save_conversation(&conv).await?;
+ Ok(())
+ }
+
+ /// Append an agent message to a conversation that originated *outside*
+ /// the conversation's own thread tree (e.g. a mission's notification
+ /// thread). The entry is recorded as an `Agent` entry tagged with the
+ /// originating `thread_id`, so subsequent foreground messages will see
+ /// it in their conversation history via `build_history_from_entries`.
+ ///
+ /// Tenant isolation: rejects calls whose `user_id` does not own the
+ /// conversation, mirroring `handle_user_message`.
+ pub async fn record_external_agent_message(
+ &self,
+ conversation_id: ConversationId,
+ thread_id: ThreadId,
+ user_id: &str,
+ content: impl Into,
+ ) -> Result<(), EngineError> {
+ let conv_arc = self.get_conversation_lock(conversation_id).await?;
+ let mut conv = conv_arc.lock().await;
+ if conv.user_id != user_id {
+ return Err(EngineError::AccessDenied {
+ user_id: user_id.to_string(),
+ entity: format!("conversation {conversation_id}"),
+ });
+ }
+ conv.add_entry(ConversationEntry::agent(thread_id, content));
+ self.store.save_conversation(&conv).await?;
Ok(())
}
@@ -291,21 +449,20 @@ impl ConversationManager {
conversation_id: ConversationId,
user_id: &str,
) -> Result<(), EngineError> {
- let mut convs = self.conversations.write().await;
- if let Some(conv) = convs.get_mut(&conversation_id) {
- // Tenant isolation: verify ownership.
- if conv.user_id != user_id {
- return Err(EngineError::AccessDenied {
- user_id: user_id.to_string(),
- entity: format!("conversation {conversation_id}"),
- });
- }
- conv.active_threads.clear();
- conv.entries.clear();
- conv.updated_at = chrono::Utc::now();
- self.store.save_conversation(conv).await?;
- debug!(conversation_id = %conversation_id, "cleared conversation");
+ let conv_arc = self.get_conversation_lock(conversation_id).await?;
+ let mut conv = conv_arc.lock().await;
+ // Tenant isolation: verify ownership.
+ if conv.user_id != user_id {
+ return Err(EngineError::AccessDenied {
+ user_id: user_id.to_string(),
+ entity: format!("conversation {conversation_id}"),
+ });
}
+ conv.active_threads.clear();
+ conv.entries.clear();
+ conv.updated_at = chrono::Utc::now();
+ self.store.save_conversation(&conv).await?;
+ debug!(conversation_id = %conversation_id, "cleared conversation");
Ok(())
}
@@ -314,23 +471,47 @@ impl ConversationManager {
&self,
conversation_id: ConversationId,
) -> Option {
- let convs = self.conversations.read().await;
- convs.get(&conversation_id).cloned()
+ let arc = {
+ let convs = self.conversations.read().await;
+ convs.get(&conversation_id).map(Arc::clone)
+ }?;
+ Some(arc.lock().await.clone())
}
- /// List all conversations for a user.
+ /// Returns conversations for the given user.
+ ///
+ /// Uses `channel_user_index` to pre-filter by user before acquiring any
+ /// per-conversation locks, keeping lock scope minimal. This is a best-effort
+ /// snapshot: each conversation is locked and read individually, so concurrent
+ /// mutations between locks may be partially visible.
pub async fn list_conversations(&self, user_id: &str) -> Vec {
- let convs = self.conversations.read().await;
- convs
- .values()
- .filter(|c| c.user_id == user_id)
- .cloned()
- .collect()
+ let arcs: Vec>> = {
+ let convs = self.conversations.read().await;
+ let index = self.channel_user_index.read().await;
+ index
+ .iter()
+ .filter(|((_, uid), _)| uid == user_id)
+ .filter_map(|(_, id)| convs.get(id).cloned())
+ .collect()
+ };
+ let mut result = Vec::with_capacity(arcs.len());
+ for arc in arcs {
+ result.push(arc.lock().await.clone());
+ }
+ result
}
- /// Find an active foreground thread in a conversation.
- async fn find_active_foreground(&self, conv: &ConversationSurface) -> Option {
- for &tid in &conv.active_threads {
+ /// Find an active foreground thread given a snapshot of active thread IDs.
+ ///
+ /// Accepts a plain slice rather than a `&ConversationSurface` so callers
+ /// can drop the conversations write lock before invoking this method —
+ /// it performs async I/O (is_running, load_thread) that must not be held
+ /// under any lock.
+ async fn find_active_foreground(
+ &self,
+ active_thread_ids: &[ThreadId],
+ ) -> Option {
+ for &tid in active_thread_ids {
if self.thread_manager.is_running(tid).await {
return Some(ActiveForeground::Running(tid));
}
@@ -343,27 +524,34 @@ impl ConversationManager {
}
None
}
+
+ /// Test helper: track a thread in a conversation without accessing the
+ /// internal HashMap directly.
+ #[cfg(test)]
+ pub async fn track_thread_in_conversation(&self, conv_id: ConversationId, thread_id: ThreadId) {
+ let arc = self
+ .get_conversation_lock(conv_id)
+ .await
+ .expect("conversation exists in test");
+ arc.lock().await.track_thread(thread_id);
+ }
}
/// Build ThreadMessage history from conversation entries.
///
/// Converts user and agent entries into ThreadMessages so a new thread
/// inherits context from prior turns in the same conversation.
+///
+/// The caller passes a snapshot taken *before* the current user message was
+/// appended, so all entries here are prior-turn history — include them all.
+/// System entries (thread lifecycle notifications) are skipped as they are not
+/// useful LLM context.
fn build_history_from_entries(
entries: &[ConversationEntry],
) -> Vec {
use crate::types::conversation::EntrySender;
- // Skip the last entry (it's the current user message, added by the caller
- // before this function runs). Also skip system entries (thread lifecycle
- // notifications aren't useful as LLM context).
- let history_entries = if entries.len() > 1 {
- &entries[..entries.len() - 1]
- } else {
- return Vec::new();
- };
-
- history_entries
+ entries
.iter()
.filter_map(|entry| match &entry.sender {
EntrySender::User => Some(crate::types::message::ThreadMessage::user(&entry.content)),
@@ -388,6 +576,7 @@ mod tests {
use crate::types::conversation::{ConversationId, ConversationSurface, EntrySender};
use crate::types::event::ThreadEvent;
use crate::types::memory::{DocId, MemoryDoc};
+ use crate::types::message::MessageRole;
use crate::types::project::Project;
use crate::types::step::{ActionResult, LlmResponse, Step, TokenUsage};
use crate::types::thread::ThreadState;
@@ -648,7 +837,14 @@ mod tests {
let project = ProjectId::new();
let tid = cm
- .handle_user_message(conv_id, "Hello", project, "user1", ThreadConfig::default())
+ .handle_user_message(
+ conv_id,
+ "Hello",
+ project,
+ "user1",
+ ThreadConfig::default(),
+ None,
+ )
.await
.unwrap();
@@ -706,11 +902,7 @@ mod tests {
.unwrap();
store.save_thread(&thread).await.unwrap();
- {
- let mut convs = cm.conversations.write().await;
- let conv = convs.get_mut(&conv_id).unwrap();
- conv.track_thread(thread.id);
- }
+ cm.track_thread_in_conversation(conv_id, thread.id).await;
let resumed = cm
.handle_user_message(
@@ -719,6 +911,7 @@ mod tests {
project,
"user1",
ThreadConfig::default(),
+ None,
)
.await
.unwrap();
@@ -735,11 +928,7 @@ mod tests {
let tid = ThreadId::new();
// Manually track a thread
- {
- let mut convs = cm.conversations.write().await;
- let conv = convs.get_mut(&conv_id).unwrap();
- conv.track_thread(tid);
- }
+ cm.track_thread_in_conversation(conv_id, tid).await;
// Record completion
cm.record_thread_outcome(
@@ -815,7 +1004,14 @@ mod tests {
// Spawn a thread so the conversation has entries and active threads
let tid = cm
- .handle_user_message(conv_id, "Hello", project, "user1", ThreadConfig::default())
+ .handle_user_message(
+ conv_id,
+ "Hello",
+ project,
+ "user1",
+ ThreadConfig::default(),
+ None,
+ )
.await
.unwrap();
@@ -843,4 +1039,171 @@ mod tests {
assert!(conv.entries.is_empty());
assert!(conv.active_threads.is_empty());
}
+
+ #[tokio::test]
+ async fn concurrent_handle_user_message_spawns_one_thread() {
+ // T1: Two concurrent handle_user_message calls on the same conversation
+ // must serialize — only ONE new thread should be spawned.
+ let (_, cm) = make_conv_manager();
+ let conv_id = cm.get_or_create_conversation("web", "user1").await.unwrap();
+ let project = ProjectId::new();
+ let cm = Arc::new(cm);
+
+ let cm1 = Arc::clone(&cm);
+ let cm2 = Arc::clone(&cm);
+
+ let t1 = tokio::spawn(async move {
+ cm1.handle_user_message(
+ conv_id,
+ "message one",
+ project,
+ "user1",
+ ThreadConfig::default(),
+ None,
+ )
+ .await
+ });
+ let t2 = tokio::spawn(async move {
+ cm2.handle_user_message(
+ conv_id,
+ "message two",
+ project,
+ "user1",
+ ThreadConfig::default(),
+ None,
+ )
+ .await
+ });
+
+ let r1 = t1.await.unwrap();
+ let r2 = t2.await.unwrap();
+
+ // Both calls must succeed.
+ assert!(r1.is_ok(), "first handle_user_message failed: {r1:?}");
+ assert!(r2.is_ok(), "second handle_user_message failed: {r2:?}");
+
+ // The per-conv Mutex serializes the two calls. The second call sees the
+ // first thread as Running (or the same thread ID if inject_message is used),
+ // so at most one NEW thread should exist in active_threads.
+ let conv = cm.get_conversation(conv_id).await.unwrap();
+ assert_eq!(
+ conv.active_threads.len(),
+ 1,
+ "expected exactly 1 active thread, got {}: {:?}",
+ conv.active_threads.len(),
+ conv.active_threads
+ );
+ }
+
+ #[tokio::test]
+ async fn record_external_agent_message_appears_in_history() {
+ // Regression: when a mission's notification is recorded into a
+ // conversation via `record_external_agent_message`, the next foreground
+ // user message must spawn a thread whose history includes the mission
+ // output. Otherwise the agent has no idea the mission ran and replies
+ // to follow-ups as if no digest was ever delivered.
+ let (_tm, cm) = make_conv_manager();
+ let conv_id = cm
+ .get_or_create_conversation("gateway", "user1")
+ .await
+ .unwrap();
+ let mission_thread_id = ThreadId::new();
+ let mission_output = "**[daily-news-digest]** - Headline A\n- Headline B";
+
+ cm.record_external_agent_message(
+ conv_id,
+ mission_thread_id,
+ "user1",
+ mission_output.to_string(),
+ )
+ .await
+ .unwrap();
+
+ // The new entry must be visible on the conversation snapshot.
+ let conv = cm.get_conversation(conv_id).await.unwrap();
+ assert_eq!(
+ conv.entries.len(),
+ 1,
+ "expected exactly one entry after recording mission output"
+ );
+ assert!(matches!(
+ &conv.entries[0].sender,
+ EntrySender::Agent { thread_id } if *thread_id == mission_thread_id
+ ));
+ assert_eq!(conv.entries[0].content, mission_output);
+
+ // The user's follow-up turn must observe the mission output: a fresh
+ // foreground thread spawns with the entries-derived history, which now
+ // contains the mission's assistant entry as prior context.
+ // `build_history_from_entries` strips the trailing entry (the current
+ // user message added by the caller), so we exercise the full
+ // `handle_user_message` path and inspect what the new thread sees.
+ let project = ProjectId::new();
+ let _tid = cm
+ .handle_user_message(
+ conv_id,
+ "Tell me more about the first headline you sent",
+ project,
+ "user1",
+ ThreadConfig::default(),
+ None,
+ )
+ .await
+ .unwrap();
+ let conv_after = cm.get_conversation(conv_id).await.unwrap();
+ let history_after = build_history_from_entries(&conv_after.entries);
+ assert!(
+ history_after
+ .iter()
+ .any(|m| m.role == MessageRole::Assistant && m.content == mission_output),
+ "follow-up turn history should contain the mission output: {history_after:#?}"
+ );
+ }
+
+ #[tokio::test]
+ async fn record_external_agent_message_rejects_wrong_user() {
+ let (_, cm) = make_conv_manager();
+ let conv_id = cm
+ .get_or_create_conversation("gateway", "owner")
+ .await
+ .unwrap();
+
+ let result = cm
+ .record_external_agent_message(
+ conv_id,
+ ThreadId::new(),
+ "intruder",
+ "should be rejected".to_string(),
+ )
+ .await;
+
+ assert!(
+ matches!(result, Err(EngineError::AccessDenied { .. })),
+ "expected AccessDenied for cross-tenant write, got: {result:?}"
+ );
+ }
+
+ #[tokio::test]
+ async fn record_thread_outcome_unknown_conv_returns_err() {
+ // T4: After C1 fix, record_thread_outcome with an unknown ConversationId
+ // must return Err, not silently succeed.
+ let (_, cm) = make_conv_manager();
+ let unknown_conv_id = ConversationId::new();
+ let tid = ThreadId::new();
+
+ let result = cm
+ .record_thread_outcome(
+ unknown_conv_id,
+ tid,
+ &ThreadOutcome::Completed {
+ response: Some("irrelevant".into()),
+ },
+ )
+ .await;
+
+ assert!(
+ result.is_err(),
+ "expected Err for unknown conversation, got Ok"
+ );
+ }
}
diff --git a/crates/ironclaw_engine/src/runtime/lease_refresh.rs b/crates/ironclaw_engine/src/runtime/lease_refresh.rs
new file mode 100644
index 00000000000..a3ffd6421d6
--- /dev/null
+++ b/crates/ironclaw_engine/src/runtime/lease_refresh.rs
@@ -0,0 +1,99 @@
+use std::collections::HashSet;
+use std::sync::Arc;
+
+use crate::Capability;
+use crate::capability::lease::LeaseManager;
+use crate::capability::planner::LeasePlanner;
+use crate::capability::registry::CapabilityRegistry;
+use crate::traits::effect::EffectExecutor;
+use crate::traits::store::Store;
+use crate::types::capability::GrantedActions;
+use crate::types::error::EngineError;
+use crate::types::thread::Thread;
+
+pub(crate) async fn reconcile_dynamic_tool_lease(
+ thread: &mut Thread,
+ effects: &Arc,
+ leases: &Arc,
+ store: Option<&Arc>,
+ lease_planner: &LeasePlanner,
+) -> Result<(), EngineError> {
+ let active_leases = leases.active_for_thread(thread.id).await;
+ let actions = effects.available_actions(&active_leases).await?;
+ if actions.is_empty() {
+ return Ok(());
+ }
+
+ let mut capabilities = CapabilityRegistry::new();
+ capabilities.register(Capability {
+ name: "tools".into(),
+ description: "Available tools".into(),
+ actions,
+ knowledge: vec![],
+ policies: vec![],
+ });
+
+ let Some(grant) = lease_planner
+ .plan_for_thread(thread.thread_type, &capabilities)
+ .into_iter()
+ .find(|grant| grant.capability_name == "tools")
+ else {
+ return Ok(());
+ };
+
+ let desired_actions: HashSet = match grant.granted_actions {
+ GrantedActions::All => return Ok(()),
+ GrantedActions::Specific(actions) => actions.into_iter().collect(),
+ };
+
+ if desired_actions.is_empty() {
+ return Ok(());
+ }
+
+ if let Some(existing) = active_leases
+ .iter()
+ .find(|lease| lease.capability_name == "tools")
+ {
+ if existing.granted_actions.is_all() {
+ return Ok(());
+ }
+
+ let mut merged: HashSet =
+ existing.granted_actions.actions().iter().cloned().collect();
+ let before = merged.len();
+ merged.extend(desired_actions);
+ if merged.len() == before {
+ return Ok(());
+ }
+
+ let mut merged_actions: Vec = merged.into_iter().collect();
+ merged_actions.sort();
+ let updated = leases
+ .update_granted_actions(existing.id, GrantedActions::Specific(merged_actions))
+ .await?;
+ if let Some(store) = store {
+ store.save_lease(&updated).await?;
+ }
+ return Ok(());
+ }
+
+ let mut actions: Vec = desired_actions.into_iter().collect();
+ actions.sort();
+ let lease = leases
+ .grant(
+ thread.id,
+ "tools",
+ GrantedActions::Specific(actions),
+ None,
+ None,
+ )
+ .await?;
+ if let Some(store) = store {
+ store.save_lease(&lease).await?;
+ }
+ if !thread.capability_leases.contains(&lease.id) {
+ thread.capability_leases.push(lease.id);
+ }
+
+ Ok(())
+}
diff --git a/crates/ironclaw_engine/src/runtime/manager.rs b/crates/ironclaw_engine/src/runtime/manager.rs
index f04e9ce1736..5fb7eb4cdb9 100644
--- a/crates/ironclaw_engine/src/runtime/manager.rs
+++ b/crates/ironclaw_engine/src/runtime/manager.rs
@@ -11,6 +11,7 @@ use crate::capability::planner::LeasePlanner;
use crate::capability::policy::PolicyEngine;
use crate::capability::registry::CapabilityRegistry;
use crate::executor::ExecutionLoop;
+use crate::runtime::lease_refresh::reconcile_dynamic_tool_lease;
use crate::runtime::messaging::{self, SignalSender, ThreadOutcome, ThreadSignal};
use crate::runtime::tree::ThreadTree;
use crate::traits::effect::EffectExecutor;
@@ -101,11 +102,22 @@ impl ThreadManager {
parent_id,
user_id,
Vec::new(),
+ serde_json::Map::new(),
)
.await
}
/// Spawn a thread with initial conversation history.
+ ///
+ /// `initial_metadata` is applied to the thread's metadata map *before* the
+ /// background execution task starts, so the executor's in-memory `Thread`
+ /// observes those keys on the first step. This is the only correct way to
+ /// stamp metadata that the very first orchestrator step needs to read
+ /// (e.g. `source_channel` for `mission_create` notify-channel defaulting,
+ /// or `user_timezone` for cron resolution). Setting metadata after spawn
+ /// via `set_thread_metadata` is a race — the spawned task owns its own
+ /// in-memory copy of the `Thread`, and the late update only lands on the
+ /// persisted copy that the running task never re-reads.
#[allow(clippy::too_many_arguments)]
pub async fn spawn_thread_with_history(
&self,
@@ -116,6 +128,7 @@ impl ThreadManager {
parent_id: Option,
user_id: impl Into,
initial_messages: Vec,
+ initial_metadata: serde_json::Map,
) -> Result {
let user_id = user_id.into();
let mut thread = Thread::new(goal, thread_type, project_id, &user_id, config);
@@ -124,6 +137,16 @@ impl ThreadManager {
}
let thread_id = thread.id;
+ // Apply initial metadata before save_thread + start_thread so the
+ // executor's in-memory thread observes it on the first step.
+ if !initial_metadata.is_empty()
+ && let Some(obj) = thread.metadata.as_object_mut()
+ {
+ for (k, v) in initial_metadata {
+ obj.insert(k, v);
+ }
+ }
+
// Register in tree
if let Some(pid) = parent_id {
self.tree.write().await.add_child(pid, thread_id);
@@ -216,12 +239,21 @@ impl ThreadManager {
}
if let Some(ref call_id) = resolved_call_id {
- thread
- .messages
- .retain(|existing| !is_resolved_call_message(existing, call_id));
+ let preserve_assistant_call = injected_message.as_ref().is_some_and(|message| {
+ message.role == MessageRole::ActionResult
+ && message.action_call_id.as_deref() == Some(call_id.as_str())
+ });
+ thread.messages.retain(|existing| {
+ if preserve_assistant_call {
+ !is_resolved_action_result_message(existing, call_id)
+ } else {
+ !is_resolved_call_message(existing, call_id)
+ }
+ });
}
if let Some(message) = injected_message {
+ thread.add_internal_message(message.clone());
thread.add_message(message);
}
@@ -241,12 +273,21 @@ impl ThreadManager {
async fn start_thread(
&self,
- thread: Thread,
+ mut thread: Thread,
user_id: String,
is_resume: bool,
) -> Result {
let thread_id = thread.id;
+ reconcile_dynamic_tool_lease(
+ &mut thread,
+ &self.effects,
+ &self.leases,
+ Some(&self.store),
+ &self.lease_planner,
+ )
+ .await?;
+
// Create signal channel
let (tx, rx) = messaging::signal_channel(32);
@@ -290,11 +331,9 @@ impl ThreadManager {
tracing::debug!(thread_id = %thread_id, "failed to transition to Done: {e}");
}
- // Write trace file if enabled
- if crate::executor::trace::is_trace_enabled() {
- crate::executor::trace::log_trace_summary(&trace);
- crate::executor::trace::write_trace(&trace);
- }
+ // Trace recording is handled centrally by `RecordingLlm` in the
+ // host crate (gated by `IRONCLAW_RECORD_TRACE`). The engine no
+ // longer writes its own JSON trace file.
if let Err(e) = store_for_task.append_events(&exec.thread.events).await {
tracing::debug!(
@@ -414,17 +453,40 @@ impl ThreadManager {
}
}
- /// Set a metadata key on a thread (best-effort, for tagging).
- pub async fn set_thread_metadata(&self, thread_id: ThreadId, key: &str, value: &str) {
- if let Ok(Some(mut thread)) = self.store.load_thread(thread_id).await {
- if let Some(obj) = thread.metadata.as_object_mut() {
- obj.insert(
- key.to_string(),
- serde_json::Value::String(value.to_string()),
- );
- }
- let _ = self.store.save_thread(&thread).await;
+ /// Set a metadata key on the persisted thread record.
+ ///
+ /// Note: this updates the **store**, not the in-memory `Thread` that an
+ /// already-running `ExecutionLoop` is reading from. Callers that need the
+ /// next executor step to observe the new value must apply this *before*
+ /// the executor task is spawned (initial-create path) or before
+ /// `resume_thread`, which reloads from the store.
+ pub async fn set_thread_metadata(
+ &self,
+ thread_id: ThreadId,
+ key: &str,
+ value: &str,
+ ) -> Result<(), EngineError> {
+ let mut thread = self
+ .store
+ .load_thread(thread_id)
+ .await
+ .map_err(|e| EngineError::Store {
+ reason: format!("set_thread_metadata: load failed: {e}"),
+ })?
+ .ok_or(EngineError::ThreadNotFound(thread_id))?;
+ if let Some(obj) = thread.metadata.as_object_mut() {
+ obj.insert(
+ key.to_string(),
+ serde_json::Value::String(value.to_string()),
+ );
}
+ self.store
+ .save_thread(&thread)
+ .await
+ .map_err(|e| EngineError::Store {
+ reason: format!("set_thread_metadata: save failed: {e}"),
+ })?;
+ Ok(())
}
/// Check if a thread is still running.
@@ -596,6 +658,10 @@ fn is_resolved_call_message(message: &ThreadMessage, call_id: &str) -> bool {
.is_some_and(|calls| calls.iter().any(|call| call.id == call_id))
}
+fn is_resolved_action_result_message(message: &ThreadMessage, call_id: &str) -> bool {
+ message.role == MessageRole::ActionResult && message.action_call_id.as_deref() == Some(call_id)
+}
+
#[cfg(test)]
mod tests {
use super::*;
@@ -652,6 +718,34 @@ mod tests {
struct MockEffects;
+ struct DynamicEffects {
+ actions: RwLock>,
+ calls: RwLock>,
+ install_reveals: RwLock>>,
+ }
+
+ impl DynamicEffects {
+ fn new(actions: Vec) -> Arc {
+ Arc::new(Self {
+ actions: RwLock::new(actions),
+ calls: RwLock::new(Vec::new()),
+ install_reveals: RwLock::new(None),
+ })
+ }
+
+ async fn set_actions(&self, actions: Vec) {
+ *self.actions.write().await = actions;
+ }
+
+ async fn set_install_reveals(&self, actions: Vec) {
+ *self.install_reveals.write().await = Some(actions);
+ }
+
+ async fn recorded_calls(&self) -> Vec {
+ self.calls.read().await.clone()
+ }
+ }
+
#[async_trait::async_trait]
impl EffectExecutor for MockEffects {
async fn execute_action(
@@ -678,9 +772,42 @@ mod tests {
}
}
+ #[async_trait::async_trait]
+ impl EffectExecutor for DynamicEffects {
+ async fn execute_action(
+ &self,
+ action_name: &str,
+ _: serde_json::Value,
+ _: &CapabilityLease,
+ _: &crate::traits::effect::ThreadExecutionContext,
+ ) -> Result {
+ self.calls.write().await.push(action_name.to_string());
+ if action_name == "tool_install"
+ && let Some(actions) = self.install_reveals.read().await.clone()
+ {
+ *self.actions.write().await = actions;
+ }
+ Ok(ActionResult {
+ call_id: String::new(),
+ action_name: action_name.to_string(),
+ output: serde_json::json!({}),
+ is_error: false,
+ duration: Duration::from_millis(1),
+ })
+ }
+
+ async fn available_actions(
+ &self,
+ _: &[CapabilityLease],
+ ) -> Result, EngineError> {
+ Ok(self.actions.read().await.clone())
+ }
+ }
+
struct MockStore {
threads: RwLock>,
events: RwLock>>,
+ leases: RwLock>,
}
impl MockStore {
@@ -688,6 +815,7 @@ mod tests {
Self {
threads: RwLock::new(HashMap::new()),
events: RwLock::new(HashMap::new()),
+ leases: RwLock::new(HashMap::new()),
}
}
}
@@ -779,20 +907,31 @@ mod tests {
) -> Result, EngineError> {
Ok(vec![])
}
- async fn save_lease(&self, _: &CapabilityLease) -> Result<(), EngineError> {
+ async fn save_lease(&self, lease: &CapabilityLease) -> Result<(), EngineError> {
+ self.leases.write().await.insert(lease.id, lease.clone());
Ok(())
}
async fn load_active_leases(
&self,
- _: ThreadId,
+ thread_id: ThreadId,
) -> Result, EngineError> {
- Ok(vec![])
+ Ok(self
+ .leases
+ .read()
+ .await
+ .values()
+ .filter(|lease| lease.thread_id == thread_id && lease.is_valid())
+ .cloned()
+ .collect())
}
async fn revoke_lease(
&self,
- _: crate::types::capability::LeaseId,
+ lease_id: crate::types::capability::LeaseId,
_: &str,
) -> Result<(), EngineError> {
+ if let Some(lease) = self.leases.write().await.get_mut(&lease_id) {
+ lease.revoked = true;
+ }
Ok(())
}
async fn save_mission(
@@ -875,6 +1014,36 @@ mod tests {
)
}
+ fn make_manager_with_effects(
+ llm: Arc,
+ store: Arc,
+ effects: Arc,
+ ) -> ThreadManager {
+ let mut caps = CapabilityRegistry::new();
+ caps.register(Capability {
+ name: "tools".into(),
+ description: "Tools".into(),
+ actions: vec![ActionDef {
+ name: "tool_install".into(),
+ description: "Install a tool".into(),
+ parameters_schema: serde_json::json!({}),
+ effects: vec![EffectType::WriteLocal],
+ requires_approval: false,
+ }],
+ knowledge: vec![],
+ policies: vec![],
+ });
+
+ ThreadManager::new(
+ llm,
+ effects,
+ store,
+ Arc::new(caps),
+ Arc::new(LeaseManager::new()),
+ Arc::new(PolicyEngine::new()),
+ )
+ }
+
// ── Tests ───────────────────────────────────────────────
#[tokio::test]
@@ -898,6 +1067,200 @@ mod tests {
assert!(matches!(outcome, ThreadOutcome::Completed { response: Some(r) } if r == "Hello!"));
}
+ #[tokio::test]
+ async fn resume_reconciles_tool_lease_with_newly_available_actions() {
+ let store = Arc::new(MockStore::new());
+ let effects = DynamicEffects::new(vec![ActionDef {
+ name: "tool_install".into(),
+ description: "Install a tool".into(),
+ parameters_schema: serde_json::json!({}),
+ effects: vec![EffectType::WriteLocal],
+ requires_approval: false,
+ }]);
+ let mgr = make_manager_with_effects(MockLlm::text("done"), store, effects.clone());
+
+ let thread_id = ThreadId::new();
+ let mut thread = Thread::new(
+ "use notion",
+ ThreadType::Foreground,
+ ProjectId::new(),
+ "user",
+ ThreadConfig::default(),
+ );
+ thread.id = thread_id;
+ thread.state = ThreadState::Waiting;
+ mgr.store.save_thread(&thread).await.unwrap();
+
+ let lease = mgr
+ .leases
+ .grant(
+ thread_id,
+ "tools",
+ crate::types::capability::GrantedActions::Specific(vec!["tool_install".into()]),
+ None,
+ None,
+ )
+ .await
+ .unwrap();
+ mgr.store.save_lease(&lease).await.unwrap();
+
+ effects
+ .set_actions(vec![
+ ActionDef {
+ name: "tool_install".into(),
+ description: "Install a tool".into(),
+ parameters_schema: serde_json::json!({}),
+ effects: vec![EffectType::WriteLocal],
+ requires_approval: false,
+ },
+ ActionDef {
+ name: "notion_search".into(),
+ description: "Search Notion".into(),
+ parameters_schema: serde_json::json!({}),
+ effects: vec![EffectType::ReadExternal],
+ requires_approval: false,
+ },
+ ])
+ .await;
+
+ mgr.resume_thread(thread_id, "user", None, None, None)
+ .await
+ .unwrap();
+ let _ = mgr.join_thread(thread_id).await.unwrap();
+
+ let refreshed = mgr
+ .leases
+ .find_lease_for_action(thread_id, "notion_search")
+ .await;
+ assert!(
+ refreshed.is_some(),
+ "resume should refresh tools lease for newly available actions"
+ );
+ }
+
+ #[tokio::test]
+ async fn spawn_reconciles_tool_lease_with_stale_capability_snapshot() {
+ let store = Arc::new(MockStore::new());
+ let effects = DynamicEffects::new(vec![
+ ActionDef {
+ name: "tool_install".into(),
+ description: "Install a tool".into(),
+ parameters_schema: serde_json::json!({}),
+ effects: vec![EffectType::WriteLocal],
+ requires_approval: false,
+ },
+ ActionDef {
+ name: "notion_search".into(),
+ description: "Search Notion".into(),
+ parameters_schema: serde_json::json!({}),
+ effects: vec![EffectType::ReadExternal],
+ requires_approval: false,
+ },
+ ]);
+ let mgr = make_manager_with_effects(MockLlm::text("done"), store, effects);
+
+ let tid = mgr
+ .spawn_thread(
+ "use notion",
+ ThreadType::Foreground,
+ ProjectId::new(),
+ ThreadConfig::default(),
+ None,
+ "user",
+ )
+ .await
+ .unwrap();
+
+ let lease = mgr.leases.find_lease_for_action(tid, "notion_search").await;
+ assert!(
+ lease.is_some(),
+ "spawn should refresh tools lease for actions exposed after the capability snapshot"
+ );
+ }
+
+ #[tokio::test]
+ async fn running_thread_can_install_then_use_new_tool_without_user_bounce() {
+ let store = Arc::new(MockStore::new());
+ let initial_actions = vec![ActionDef {
+ name: "tool_install".into(),
+ description: "Install a tool".into(),
+ parameters_schema: serde_json::json!({}),
+ effects: vec![EffectType::WriteLocal],
+ requires_approval: false,
+ }];
+ let revealed_actions = vec![
+ ActionDef {
+ name: "tool_install".into(),
+ description: "Install a tool".into(),
+ parameters_schema: serde_json::json!({}),
+ effects: vec![EffectType::WriteLocal],
+ requires_approval: false,
+ },
+ ActionDef {
+ name: "notion_search".into(),
+ description: "Search Notion".into(),
+ parameters_schema: serde_json::json!({}),
+ effects: vec![EffectType::ReadExternal],
+ requires_approval: false,
+ },
+ ];
+ let effects = DynamicEffects::new(initial_actions);
+ effects.set_install_reveals(revealed_actions).await;
+ let llm = Arc::new(MockLlm {
+ responses: Mutex::new(vec![
+ LlmOutput {
+ response: LlmResponse::ActionCalls {
+ calls: vec![crate::types::step::ActionCall {
+ id: "call_install".into(),
+ action_name: "tool_install".into(),
+ parameters: serde_json::json!({"name": "notion"}),
+ }],
+ content: None,
+ },
+ usage: TokenUsage::default(),
+ },
+ LlmOutput {
+ response: LlmResponse::ActionCalls {
+ calls: vec![crate::types::step::ActionCall {
+ id: "call_search".into(),
+ action_name: "notion_search".into(),
+ parameters: serde_json::json!({"query": "latest meeting note"}),
+ }],
+ content: None,
+ },
+ usage: TokenUsage::default(),
+ },
+ LlmOutput {
+ response: LlmResponse::Text("done".into()),
+ usage: TokenUsage::default(),
+ },
+ ]),
+ });
+ let mgr = make_manager_with_effects(llm, store, effects.clone());
+
+ let tid = mgr
+ .spawn_thread(
+ "install notion and get the latest meeting note",
+ ThreadType::Foreground,
+ ProjectId::new(),
+ ThreadConfig::default(),
+ None,
+ "user",
+ )
+ .await
+ .unwrap();
+
+ let outcome = mgr.join_thread(tid).await.unwrap();
+ assert!(matches!(outcome, ThreadOutcome::Completed { .. }));
+
+ let calls = effects.recorded_calls().await;
+ assert_eq!(
+ calls,
+ vec!["tool_install".to_string(), "notion_search".to_string()],
+ "thread should continue from install into the newly exposed tool without pausing for a new user turn"
+ );
+ }
+
#[tokio::test]
async fn stop_thread_works() {
// LLM that returns many action responses
diff --git a/crates/ironclaw_engine/src/runtime/mission.rs b/crates/ironclaw_engine/src/runtime/mission.rs
index 8f0c88e3fa0..c7c95ae8928 100644
--- a/crates/ironclaw_engine/src/runtime/mission.rs
+++ b/crates/ironclaw_engine/src/runtime/mission.rs
@@ -4,21 +4,82 @@
//! progress. The manager handles lifecycle (create, pause, resume, complete)
//! and delegates thread spawning to [`ThreadManager`].
+use std::collections::{HashMap, HashSet, VecDeque};
use std::sync::Arc;
+use std::time::Duration;
+use serde::Deserialize;
use tokio::sync::RwLock;
-use tracing::debug;
+use tracing::{debug, warn};
-use crate::memory::RetrievalEngine;
+use ironclaw_skills::types::ActivationCriteria;
+use ironclaw_skills::v2::{CodeSnippet, SkillRepairRecord, SkillRepairType, V2SkillMetadata};
+
+use crate::executor::trace::{ExecutionTrace, IssueSeverity};
+use crate::memory::{RetrievalEngine, SkillTracker};
use crate::runtime::manager::ThreadManager;
use crate::runtime::messaging::ThreadOutcome;
use crate::traits::store::Store;
+use crate::traits::workspace::WorkspaceReader;
use crate::types::error::EngineError;
-use crate::types::memory::MemoryDoc;
-use crate::types::mission::{Mission, MissionCadence, MissionId, MissionStatus};
+use crate::types::memory::{DocId, DocType, MemoryDoc};
+use crate::types::mission::{
+ Mission, MissionCadence, MissionId, MissionStatus, next_cron_fire, next_cron_fire_required,
+};
use crate::types::project::ProjectId;
use crate::types::shared_owner_id;
-use crate::types::thread::{ThreadConfig, ThreadId, ThreadType};
+use crate::types::thread::{
+ ActiveSkillProvenance, Thread, ThreadConfig, ThreadId, ThreadState, ThreadType,
+};
+
+/// Per-mission compiled regex cache. We compile patterns lazily on first
+/// match attempt and discard them when the mission updates or deletes its
+/// cadence. The cache is process-local — restarts repopulate on demand.
+type EventRegexCache = HashMap;
+
+/// Maximum compiled regex size, mirroring the v1 routine engine. Patterns
+/// that exceed this are refused at compile time so a hostile or buggy
+/// mission cannot pin the matcher with a pathological regex.
+const MAX_EVENT_REGEX_SIZE: usize = 64 * 1024;
+
+/// Per-user fire-rate ceiling expressed as a token bucket. Independent of
+/// per-mission `cooldown_secs`, this is a *global* cap across all of a
+/// user's missions so a user that owns many event-triggered missions can't
+/// collectively flood the LLM.
+#[derive(Debug, Clone)]
+pub struct FireRateLimit {
+ /// Maximum number of fires permitted within `window`.
+ pub max_fires: u32,
+ /// Sliding-window duration. Fires older than this are evicted.
+ pub window: std::time::Duration,
+}
+
+impl Default for FireRateLimit {
+ /// 100 mission firings per user per hour. Generous enough that normal
+ /// cron + a handful of event-driven missions don't notice it; tight
+ /// enough that a misbehaving pattern is bounded.
+ fn default() -> Self {
+ Self {
+ max_fires: 100,
+ window: std::time::Duration::from_secs(3600),
+ }
+ }
+}
+
+/// Engine-side budget abstraction. Implementations decide whether the
+/// `user_id` still has enough LLM/financial budget to spawn another
+/// mission thread. The host implements this over its existing
+/// `CostGuard`.
+///
+/// When `MissionManager` has no `BudgetGate` attached, all fires are
+/// allowed (back-compat for embedders that don't use a budget).
+#[async_trait::async_trait]
+pub trait BudgetGate: Send + Sync {
+ /// Returns `true` if a mission fire is allowed for `user_id`. The
+ /// `mission_id` is included so adapters can apply per-mission policies
+ /// if they wish; most implementations will only consult `user_id`.
+ async fn allow_mission_fire(&self, user_id: &str, mission_id: MissionId) -> bool;
+}
/// Notification emitted when a mission thread completes.
///
@@ -32,6 +93,9 @@ pub struct MissionNotification {
pub user_id: String,
/// Channels to notify (from `Mission.notify_channels`).
pub notify_channels: Vec,
+ /// Optional per-channel recipient (from `Mission.notify_user`). When
+ /// `None`, the channel's default recipient is used.
+ pub notify_user: Option,
/// The thread's response text (None if failed/no output).
pub response: Option,
/// True if the thread failed.
@@ -39,16 +103,26 @@ pub struct MissionNotification {
}
/// Optional updates to apply to a mission via [`MissionManager::update_mission`].
-#[derive(Debug, Default)]
+#[derive(Debug, Default, Clone)]
pub struct MissionUpdate {
pub name: Option,
+ pub description: Option,
pub goal: Option,
pub cadence: Option,
pub notify_channels: Option>,
+ pub notify_user: Option,
+ pub context_paths: Option>,
pub max_threads_per_day: Option,
pub success_criteria: Option,
+ pub cooldown_secs: Option,
+ pub max_concurrent: Option,
+ pub dedup_window_secs: Option,
}
+/// In-memory dedup state for event-triggered missions. Keyed by
+/// (mission_id, dedup-key) → last fire timestamp.
+type DedupKey = (MissionId, String);
+
/// Manages mission lifecycle and thread spawning.
pub struct MissionManager {
store: Arc,
@@ -57,8 +131,41 @@ pub struct MissionManager {
active: RwLock>,
/// Broadcast channel for mission outcome notifications.
notification_tx: tokio::sync::broadcast::Sender,
+ /// Per-mission in-memory cooldown timestamp, recorded after each
+ /// `fire_mission` attempt regardless of whether `save_mission` succeeded.
+ ///
+ /// `tick` consults this to suppress re-firing the same mission within
+ /// [`FIRE_COOLDOWN`] when a transient store failure has prevented
+ /// `next_fire_at` / `threads_today` from advancing in the persisted record.
+ /// Without this guard, a save failure after a successful fire would cause
+ /// the next 60 s tick (and every subsequent tick) to re-fire the same
+ /// mission until the store recovers, spawning duplicate threads up to the
+ /// daily budget.
+ last_fire_attempt: RwLock>>,
+ /// Optional workspace reader used to load `Mission.context_paths` at
+ /// fire time. When `None`, context preloading is silently skipped.
+ workspace: Option>,
+ /// Per-mission dedup table for event-triggered firings. Cleared
+ /// opportunistically when entries fall outside the dedup window.
+ dedup_table: RwLock>>,
+ /// Compiled regex cache for `OnEvent` mission patterns. Lazily filled
+ /// on first match attempt; entries are evicted on mission update/delete.
+ event_regex_cache: RwLock,
+ /// Per-user sliding-window fire log used by the global rate limiter.
+ /// Each `VecDeque` holds firing timestamps within the configured window.
+ user_fire_log: RwLock>>>,
+ /// Global per-user fire-rate ceiling.
+ rate_limit: FireRateLimit,
+ /// Optional budget gate consulted before each fire.
+ budget_gate: Option>,
}
+/// Minimum gap between successive `fire_mission` attempts for the same
+/// mission ID, enforced in-memory by `tick`. Chosen to comfortably exceed the
+/// 60 s tick interval so a single tick gap is always honored, while still
+/// allowing recovery within a few minutes if the store comes back.
+const FIRE_COOLDOWN: Duration = Duration::from_secs(90);
+
impl MissionManager {
pub fn new(store: Arc, thread_manager: Arc) -> Self {
let (notification_tx, _) = tokio::sync::broadcast::channel(64);
@@ -67,9 +174,37 @@ impl MissionManager {
thread_manager,
active: RwLock::new(Vec::new()),
notification_tx,
+ last_fire_attempt: RwLock::new(HashMap::new()),
+ workspace: None,
+ dedup_table: RwLock::new(HashMap::new()),
+ event_regex_cache: RwLock::new(HashMap::new()),
+ user_fire_log: RwLock::new(HashMap::new()),
+ rate_limit: FireRateLimit::default(),
+ budget_gate: None,
}
}
+ /// Attach a workspace reader so `context_paths` are loaded at fire time.
+ /// Builder-style for back-compat with existing call sites that don't yet
+ /// supply a reader.
+ pub fn with_workspace_reader(mut self, reader: Arc) -> Self {
+ self.workspace = Some(reader);
+ self
+ }
+
+ /// Attach a budget gate so each fire consults the host's spend limit.
+ /// When unattached, all fires are allowed (back-compat).
+ pub fn with_budget_gate(mut self, gate: Arc) -> Self {
+ self.budget_gate = Some(gate);
+ self
+ }
+
+ /// Override the per-user fire-rate limit. Defaults to 100 fires/hour.
+ pub fn with_rate_limit(mut self, limit: FireRateLimit) -> Self {
+ self.rate_limit = limit;
+ self
+ }
+
/// Subscribe to mission outcome notifications.
///
/// The bridge uses this to route mission results to channels.
@@ -77,15 +212,105 @@ impl MissionManager {
self.notification_tx.subscribe()
}
+ /// Test-only handle to the broadcast sender so unit tests can drive
+ /// `process_mission_outcome_and_notify` without going through the full
+ /// thread lifecycle. Not part of the public API.
+ #[cfg(test)]
+ pub(crate) fn notification_tx_for_test(
+ &self,
+ ) -> &tokio::sync::broadcast::Sender {
+ &self.notification_tx
+ }
+
/// Populate the active mission index from persisted mission state.
+ ///
+ /// Also backfills `next_fire_at` for active cron missions created before
+ /// the scheduling fix — without this, legacy cron missions would remain
+ /// stuck with `next_fire_at = None` and never fire.
pub async fn bootstrap_project(&self, project_id: ProjectId) -> Result {
// System operation: load all missions for the project regardless of user.
let missions = self.store.list_all_missions(project_id).await?;
- let active_ids: Vec = missions
- .into_iter()
- .filter(|mission| mission.status == MissionStatus::Active)
- .map(|mission| mission.id)
- .collect();
+ let mut active_ids = Vec::new();
+
+ for mission in missions {
+ if mission.status != MissionStatus::Active {
+ continue;
+ }
+ // Backfill next_fire_at for cron missions that predate the
+ // scheduling fix. Match all three branches of next_cron_fire so a
+ // mission with an unschedulable cron (Ok(None) — e.g. a year-locked
+ // expression in the past) or an invalid expression (Err) is at
+ // least observable in the logs instead of silently staying stuck.
+ //
+ // Lenient `next_cron_fire` (not `_required`): startup backfill must
+ // never block — a single corrupt persisted expression cannot fail
+ // bootstrap, since the rest of the active missions still need to
+ // register. See `next_cron_fire_required` for the strict variant
+ // used at lifecycle entry points.
+ if let MissionCadence::Cron {
+ ref expression,
+ ref timezone,
+ } = mission.cadence
+ && mission.next_fire_at.is_none()
+ {
+ match next_cron_fire(expression, timezone.as_ref()) {
+ Ok(Some(next)) => {
+ // Re-load the mission immediately before save to narrow
+ // the TOCTOU window between the initial list_all_missions
+ // snapshot and our save. If a concurrent fire/update has
+ // already populated next_fire_at, skip — that writer's
+ // copy is fresher than ours. The remaining race window
+ // (between this re-load and save_mission) is much smaller
+ // than the original list-then-save window, and a strict
+ // CAS would require a new Store trait method.
+ match self.store.load_mission(mission.id).await {
+ Ok(Some(mut fresh)) if fresh.next_fire_at.is_none() => {
+ fresh.next_fire_at = Some(next);
+ match self.store.save_mission(&fresh).await {
+ Ok(()) => debug!(
+ mission_id = %mission.id,
+ next = %next,
+ "backfilled next_fire_at for legacy cron mission"
+ ),
+ Err(e) => debug!(
+ mission_id = %mission.id,
+ error = %e,
+ "failed to persist next_fire_at backfill; mission will retry on next bootstrap"
+ ),
+ }
+ }
+ Ok(Some(_)) => debug!(
+ mission_id = %mission.id,
+ "next_fire_at already set by concurrent writer; skipping backfill"
+ ),
+ Ok(None) => debug!(
+ mission_id = %mission.id,
+ "mission deleted between bootstrap list and backfill; skipping"
+ ),
+ Err(e) => debug!(
+ mission_id = %mission.id,
+ error = %e,
+ "failed to re-load mission for backfill"
+ ),
+ }
+ }
+ Ok(None) => debug!(
+ mission_id = %mission.id,
+ expression = %expression,
+ timezone = ?timezone,
+ "legacy cron mission has no upcoming fire time; leaving next_fire_at unset"
+ ),
+ Err(e) => debug!(
+ mission_id = %mission.id,
+ expression = %expression,
+ timezone = ?timezone,
+ error = %e,
+ "failed to compute next_fire_at for legacy cron mission; leaving next_fire_at unset"
+ ),
+ }
+ }
+ active_ids.push(mission.id);
+ }
let count = active_ids.len();
*self.active.write().await = active_ids;
@@ -104,6 +329,15 @@ impl MissionManager {
notify_channels: Vec,
) -> Result {
let mut mission = Mission::new(project_id, user_id, name, goal, cadence);
+ if let MissionCadence::Cron {
+ ref expression,
+ ref timezone,
+ } = mission.cadence
+ {
+ // Reject Ok(None) at the create boundary — an Active cron mission
+ // with `next_fire_at = None` is the original #1944 failure mode.
+ mission.next_fire_at = Some(next_cron_fire_required(expression, timezone.as_ref())?);
+ }
mission.notify_channels = notify_channels;
let id = mission.id;
self.store.save_mission(&mission).await?;
@@ -127,7 +361,12 @@ impl MissionManager {
reason: format!("mission {id} not found"),
})?;
- if !mission.owner_id().is_shared() && !mission.is_owned_by(user_id) {
+ let allowed = if mission.owner_id().is_shared() {
+ crate::types::is_shared_owner(user_id)
+ } else {
+ mission.is_owned_by(user_id)
+ };
+ if !allowed {
return Err(EngineError::AccessDenied {
user_id: user_id.to_string(),
entity: format!("mission {id}"),
@@ -137,38 +376,90 @@ impl MissionManager {
if let Some(name) = updates.name {
mission.name = name;
}
+ if let Some(description) = updates.description {
+ mission.description = Some(description);
+ }
if let Some(goal) = updates.goal {
mission.goal = goal;
}
if let Some(cadence) = updates.cadence {
mission.cadence = cadence;
+ // Recompute scheduling state to match the new cadence. Without this,
+ // a Manual -> Cron switch leaves next_fire_at = None and the ticker
+ // never picks the mission up; a Cron expression/timezone change
+ // keeps firing on the old schedule until the mission is paused and
+ // resumed. Clear next_fire_at for non-cron cadences so a stale
+ // value can't trigger an unrelated cron path. Reject cron schedules
+ // that are valid but have no future fire time so we don't persist
+ // an Active mission that can never run.
+ //
+ // Strict `next_cron_fire_required`: an `Err` here returns from
+ // `update_mission` BEFORE the `save_mission` call below, leaving
+ // the persisted record on its previous (valid) cadence. The
+ // `mission` local is dropped without ever being persisted —
+ // `save_mission` is the only persistence boundary in this
+ // function, so failing before it leaves the store untouched.
+ // Verified by `update_mission_rejects_switch_to_unschedulable_cron`.
+ mission.next_fire_at = match &mission.cadence {
+ MissionCadence::Cron {
+ expression,
+ timezone,
+ } => Some(next_cron_fire_required(expression, timezone.as_ref())?),
+ _ => None,
+ };
}
if let Some(channels) = updates.notify_channels {
mission.notify_channels = channels;
}
+ if let Some(notify_user) = updates.notify_user {
+ mission.notify_user = Some(notify_user);
+ }
+ if let Some(context_paths) = updates.context_paths {
+ mission.context_paths = context_paths;
+ }
if let Some(max) = updates.max_threads_per_day {
mission.max_threads_per_day = max;
}
if let Some(criteria) = updates.success_criteria {
mission.success_criteria = Some(criteria);
}
+ if let Some(secs) = updates.cooldown_secs {
+ mission.cooldown_secs = secs;
+ }
+ if let Some(max) = updates.max_concurrent {
+ mission.max_concurrent = max;
+ }
+ if let Some(secs) = updates.dedup_window_secs {
+ mission.dedup_window_secs = secs;
+ }
mission.updated_at = chrono::Utc::now();
self.store.save_mission(&mission).await?;
+ // The cadence (and therefore event_pattern) may have changed.
+ // Drop the cached compiled regex; the next match attempt
+ // recompiles from the current pattern.
+ self.evict_event_regex(id).await;
debug!(mission_id = %id, "mission updated");
Ok(())
}
/// Pause an active mission. No new threads will be spawned.
///
- /// For shared missions, the caller (web handler) must
- /// verify admin role before calling this. The engine only checks ownership.
+ /// Shared missions can only be managed by shared owners (system user).
pub async fn pause_mission(&self, id: MissionId, user_id: &str) -> Result<(), EngineError> {
- // Validate ownership. Shared missions require admin role (checked by caller).
- if let Some(mission) = self.store.load_mission(id).await?
- && !mission.is_owned_by(user_id)
- && !mission.owner_id().is_shared()
- {
+ let mission = self
+ .store
+ .load_mission(id)
+ .await?
+ .ok_or_else(|| EngineError::Store {
+ reason: format!("mission {id} not found"),
+ })?;
+ let allowed = if mission.owner_id().is_shared() {
+ crate::types::is_shared_owner(user_id)
+ } else {
+ mission.is_owned_by(user_id)
+ };
+ if !allowed {
return Err(EngineError::AccessDenied {
user_id: user_id.to_string(),
entity: format!("mission {id}"),
@@ -178,28 +469,65 @@ impl MissionManager {
.update_mission_status(id, MissionStatus::Paused)
.await?;
self.active.write().await.retain(|mid| *mid != id);
+ // Drop the in-memory cooldown entry — a paused mission can't fire,
+ // so the cooldown is dead state and would otherwise leak until the
+ // process restarts.
+ self.last_fire_attempt.write().await.remove(&id);
debug!(mission_id = %id, "mission paused");
Ok(())
}
/// Resume a paused mission.
///
- /// For shared missions, the caller (web handler) must
- /// verify admin role before calling this. The engine only checks ownership.
+ /// Shared missions can only be managed by shared owners (system user).
+ /// Only `Paused` missions can be resumed — `Completed` and `Failed` are
+ /// terminal states and must not be resurrected by a stray resume call,
+ /// so anything else is rejected with a `Store` error.
pub async fn resume_mission(&self, id: MissionId, user_id: &str) -> Result<(), EngineError> {
- // Validate ownership. Shared missions require admin role (checked by caller).
- if let Some(mission) = self.store.load_mission(id).await?
- && !mission.is_owned_by(user_id)
- && !mission.owner_id().is_shared()
- {
+ let mut mission = self
+ .store
+ .load_mission(id)
+ .await?
+ .ok_or_else(|| EngineError::Store {
+ reason: format!("mission {id} not found"),
+ })?;
+ let allowed = if mission.owner_id().is_shared() {
+ crate::types::is_shared_owner(user_id)
+ } else {
+ mission.is_owned_by(user_id)
+ };
+ if !allowed {
return Err(EngineError::AccessDenied {
user_id: user_id.to_string(),
entity: format!("mission {id}"),
});
}
- self.store
- .update_mission_status(id, MissionStatus::Active)
- .await?;
+ if mission.status != MissionStatus::Paused {
+ return Err(EngineError::Store {
+ reason: format!(
+ "mission {id} is in state {:?}, only Paused missions can be resumed",
+ mission.status
+ ),
+ });
+ }
+ // Mutate-and-save in a single round-trip. The previous implementation
+ // did `update_mission_status(Active)` and then a separate `load+save`
+ // to recompute next_fire_at — between the two writes, a concurrent
+ // `update_mission`/`fire_mission` could modify other fields that the
+ // second save would then silently overwrite with the stale reload.
+ mission.status = MissionStatus::Active;
+ if let MissionCadence::Cron {
+ ref expression,
+ ref timezone,
+ } = mission.cadence
+ {
+ // Reject Ok(None): resuming a cron mission whose schedule has no
+ // upcoming fire time would silently re-create the #1944 stuck
+ // state. Surface the error so the caller can fix the schedule.
+ mission.next_fire_at = Some(next_cron_fire_required(expression, timezone.as_ref())?);
+ }
+ mission.updated_at = chrono::Utc::now();
+ self.store.save_mission(&mission).await?;
let mut active = self.active.write().await;
if !active.contains(&id) {
active.push(id);
@@ -214,6 +542,10 @@ impl MissionManager {
.update_mission_status(id, MissionStatus::Completed)
.await?;
self.active.write().await.retain(|mid| *mid != id);
+ // Terminal state — drop the cooldown entry so the in-memory map
+ // doesn't accumulate an entry per mission ever fired.
+ self.last_fire_attempt.write().await.remove(&id);
+ self.evict_event_regex(id).await;
debug!(mission_id = %id, "mission completed");
Ok(())
}
@@ -259,13 +591,100 @@ impl MissionManager {
return Ok(None);
}
+ // Cooldown: refuse to fire if the last successful fire was within
+ // `cooldown_secs` of now. 0 = disabled.
+ if mission.cooldown_secs > 0
+ && let Some(last) = mission.last_fire_at
+ {
+ let elapsed = chrono::Utc::now().signed_duration_since(last).num_seconds();
+ if elapsed >= 0 && (elapsed as u64) < mission.cooldown_secs {
+ debug!(
+ mission_id = %id,
+ elapsed_secs = elapsed,
+ cooldown_secs = mission.cooldown_secs,
+ "mission cooldown not yet elapsed"
+ );
+ return Ok(None);
+ }
+ }
+
+ // max_concurrent: count threads from this mission that are still in
+ // a non-terminal state. 0 = unlimited.
+ if mission.max_concurrent > 0 {
+ let running = self.count_running_threads(&mission).await;
+ if running >= mission.max_concurrent as usize {
+ debug!(
+ mission_id = %id,
+ running,
+ max_concurrent = mission.max_concurrent,
+ "mission max_concurrent reached"
+ );
+ return Ok(None);
+ }
+ }
+
+ // Per-user global rate limit. Independent of per-mission cooldown,
+ // this is a sliding-window cap across *all* of the user's missions
+ // so a user with many event-triggered missions can't collectively
+ // flood the LLM. We only *check* here — recording is deferred until
+ // after the spawn succeeds so a downstream failure (store error,
+ // budget refusal, spawn error) doesn't consume a slot and slowly
+ // self-DoS the user.
+ if !self.check_user_rate(&mission.user_id).await {
+ debug!(
+ mission_id = %id,
+ user_id = %mission.user_id,
+ max_fires = self.rate_limit.max_fires,
+ window_secs = self.rate_limit.window.as_secs(),
+ "per-user mission fire rate limit reached"
+ );
+ return Ok(None);
+ }
+
+ // Budget gate: when the host wires a `BudgetGate` (typically over
+ // its CostGuard), refuse to fire when the user is out of budget.
+ // Unattached gate = always allow.
+ if !self.budget_allows(&mission.user_id, id).await {
+ debug!(
+ mission_id = %id,
+ user_id = %mission.user_id,
+ "mission fire refused by budget gate"
+ );
+ return Ok(None);
+ }
+
+ // Load context_paths from the workspace if a reader is attached.
+ // Failures are logged but never block the fire — context loading is
+ // a best-effort enrichment, not a precondition.
+ let mut context_blocks: Vec<(String, String)> = Vec::new();
+ if let Some(reader) = self.workspace.as_ref() {
+ for path in &mission.context_paths {
+ match reader.read_doc(path).await {
+ Ok(content) => context_blocks.push((path.clone(), content)),
+ Err(error) => debug!(
+ mission_id = %id,
+ path = %path,
+ error = %error,
+ "failed to load mission context_path; skipping"
+ ),
+ }
+ }
+ } else if !mission.context_paths.is_empty() {
+ debug!(
+ mission_id = %id,
+ paths = mission.context_paths.len(),
+ "mission has context_paths but no WorkspaceReader is attached"
+ );
+ }
+
// Build meta-prompt from mission state + project docs
let retrieval = RetrievalEngine::new(Arc::clone(&self.store));
let project_docs = retrieval
.retrieve_context(mission.project_id, &mission.user_id, &mission.goal, 10)
.await
.unwrap_or_default();
- let meta_prompt = build_meta_prompt(&mission, &project_docs, &trigger_payload);
+ let meta_prompt =
+ build_meta_prompt(&mission, &project_docs, &trigger_payload, &context_blocks);
// Spawn thread with meta-prompt as initial user message
let thread_id = self
@@ -280,15 +699,121 @@ impl MissionManager {
)
.await?;
+ // Capture the fire instant once and use it for both the persisted
+ // `last_fire_at` and the in-memory `last_fire_attempt` map. The two
+ // writes MUST share the same value: tick's stale-state detection
+ // compares them as equal-or-not to decide whether the cooldown
+ // applies, and using two separate `Utc::now()` calls would produce
+ // microsecond drift that breaks the equality check on the success
+ // path.
+ let fire_instant = chrono::Utc::now();
+
+ // Install the outcome watcher *before* persisting the mission update.
+ // The watcher only depends on `thread_id` (it joins via ThreadManager
+ // and reloads the mission record itself), so installing it first
+ // ensures a transient `save_mission` failure below cannot orphan the
+ // running thread by skipping the watcher install. Pass `fire_instant`
+ // through so the outcome processor can reconcile `last_fire_at`
+ // back to the original moment if the save below fails — without
+ // this the reconciled value would be the *outcome* time, which can
+ // be many seconds-to-hours later for long-running mission threads.
+ self.spawn_mission_outcome_watcher(id, thread_id, fire_instant);
+
// Record the thread + trigger payload in mission history
let mut updated = mission;
+ let user_id_for_rate = updated.user_id.clone();
updated.record_thread(thread_id);
updated.threads_today += 1;
updated.last_trigger_payload = trigger_payload;
- self.store.save_mission(&updated).await?;
+ // Advance next_fire_at for cron missions so the ticker schedules the
+ // next cycle. Computed from `now()`, not from the previous fire time:
+ // if a tick was delayed (process down, busy loop) and several windows
+ // are missed, they coalesce into a single fire here rather than
+ // backfilling each missed slot. This is the catch-up semantics we want
+ // for long-running missions.
+ //
+ // Lenient `next_cron_fire` (not `_required`): a parse error here is
+ // unlikely (the expression validated at create time) but possible if
+ // persisted data is corrupt. We log and preserve the existing
+ // `next_fire_at` rather than aborting fire — the thread is already
+ // running and the watcher is already installed, and at worst the
+ // schedule is delayed by one cycle until the next tick.
+ //
+ // `cron_advanced` tracks whether scheduling actually progressed. When
+ // false (parse error on a corrupt expression), we deliberately leave
+ // `last_fire_at` at its OLD persisted value. The in-memory
+ // `last_fire_attempt[mid]` will still be set to `fire_instant` below,
+ // so the in-memory vs persisted mismatch arms tick's cooldown via
+ // the same code path as a save failure. Without this, a corrupt
+ // expression with a past `next_fire_at` would re-fire on every tick
+ // (cooldown matched, schedule never advanced) and exhaust the daily
+ // budget — same root cause as #1944.
+ let mut cron_advanced = true;
+ if let MissionCadence::Cron {
+ ref expression,
+ ref timezone,
+ } = updated.cadence
+ {
+ match next_cron_fire(expression, timezone.as_ref()) {
+ Ok(next) => updated.next_fire_at = next,
+ Err(e) => {
+ cron_advanced = false;
+ debug!(
+ mission_id = %id,
+ expression = %expression,
+ error = %e,
+ "failed to advance next_fire_at after fire; preserving existing value and arming cooldown via mismatch"
+ );
+ }
+ }
+ }
+ if cron_advanced {
+ updated.last_fire_at = Some(fire_instant);
+ }
+
+ // Arm the in-memory cooldown BEFORE the persistence call, not after.
+ //
+ // Why first: a concurrent tick observing the post-`save_mission`
+ // state but pre-`last_fire_attempt`-insert state would see the
+ // freshly-persisted `last_fire_at = fire_instant` AND no in-memory
+ // entry, evaluate `is_some_and(...)` to false, and (if the schedule
+ // is still in the past) re-fire immediately. Inserting first closes
+ // that race: while save is in flight, in-memory has `fire_instant`
+ // and persisted still has the OLD `last_fire_at`, so tick sees a
+ // mismatch and arms the cooldown. Once save lands the values match
+ // (success path) or stay mismatched (failure path) — both correct.
+ //
+ // Held briefly under the write lock; the map is keyed by mission ID
+ // and only mutated here, in tick (prune), and in pause/complete.
+ self.last_fire_attempt
+ .write()
+ .await
+ .insert(id, fire_instant);
+
+ // Persistence is best-effort: if save_mission fails on a transient store
+ // error, the thread is already running and the outcome watcher is already
+ // installed (above), so failing here would orphan the work AND — for cron
+ // cadences — leave next_fire_at un-advanced, causing the next tick to
+ // re-fire the same mission in a runaway loop. Log and continue. The
+ // caller still gets Ok(Some(thread_id)) so the spawned thread is visible.
+ // The in-memory `last_fire_attempt` cooldown above catches runaway re-fires
+ // by comparing in-memory vs persisted `last_fire_at` (see tick).
+ if let Err(e) = self.store.save_mission(&updated).await {
+ debug!(
+ mission_id = %id,
+ thread_id = %thread_id,
+ error = %e,
+ "failed to persist mission update after fire; thread is running and watched, in-memory cooldown will suppress re-fire"
+ );
+ }
+
+ // Now that the spawn + persist have succeeded, consume a slot in
+ // the per-user rate window. Doing this here (rather than at the
+ // earlier check site) means store errors, budget refusals, and
+ // spawn failures all leave the user's window untouched.
+ self.record_user_rate(&user_id_for_rate).await;
debug!(mission_id = %id, thread_id = %thread_id, "mission fired");
- self.spawn_mission_outcome_watcher(id, thread_id);
Ok(Some(thread_id))
}
@@ -321,7 +846,11 @@ impl MissionManager {
self.thread_manager
.resume_thread(thread_id, user_id.to_string(), None, None, None)
.await?;
- self.spawn_mission_outcome_watcher(mission_id, thread_id);
+ // Resumed threads are already in `thread_history` from the
+ // original fire, so the outcome processor will see
+ // `needs_reconcile = false` and never read this value. Pass
+ // `now` as a safe placeholder; nothing depends on it.
+ self.spawn_mission_outcome_watcher(mission_id, thread_id, chrono::Utc::now());
resumed.push(thread_id);
}
}
@@ -396,11 +925,147 @@ impl MissionManager {
MissionCadence::OnSystemEvent {
source: s,
event_type: et,
- } => s == source && et == event_type,
+ filters,
+ } => {
+ s == source
+ && et == event_type
+ && payload_matches_filters(filters, payload.as_ref())
+ }
_ => false,
};
- if matches && let Some(tid) = self.fire_mission(mid, user_id, payload.clone()).await? {
+ if !matches {
+ continue;
+ }
+
+ // Dedup: skip if an identical event key fired this mission within
+ // its dedup window. The default key is the SHA-256 of the payload
+ // serialization (compact and stable for typical webhook bodies).
+ if mission.dedup_window_secs > 0 {
+ let key = payload_dedup_key(payload.as_ref());
+ if self.dedup_event(mid, &key, mission.dedup_window_secs).await {
+ debug!(
+ mission_id = %mid,
+ dedup_window_secs = mission.dedup_window_secs,
+ "skipping system_event fire — dedup window not yet elapsed"
+ );
+ continue;
+ }
+ }
+
+ if let Some(tid) = self.fire_mission(mid, user_id, payload.clone()).await? {
+ spawned.push(tid);
+ }
+ }
+
+ Ok(spawned)
+ }
+
+ /// Fire all active `OnEvent` missions whose `event_pattern` matches
+ /// `text` and (if a channel filter is set) whose `channel` matches the
+ /// incoming message channel case-insensitively.
+ ///
+ /// `payload` is forwarded as `trigger_payload` to each mission's thread.
+ /// Pattern matching uses simple substring matching to keep this dependency-
+ /// free; callers needing regex semantics should normalize first or
+ /// extend the matcher.
+ pub async fn fire_on_message_event(
+ &self,
+ channel: &str,
+ text: &str,
+ user_id: &str,
+ payload: Option,
+ ) -> Result, EngineError> {
+ let active_ids = self.active.read().await.clone();
+ let mut spawned = Vec::new();
+
+ for mid in active_ids {
+ let mission = match self.store.load_mission(mid).await? {
+ Some(m) if m.status == MissionStatus::Active => m,
+ _ => continue,
+ };
+
+ if !mission.is_owned_by(user_id) && !mission.owner_id().is_shared() {
+ continue;
+ }
+
+ let channel_ok = match &mission.cadence {
+ MissionCadence::OnEvent {
+ channel: cadence_channel,
+ ..
+ } => cadence_channel
+ .as_ref()
+ .is_none_or(|c| c.eq_ignore_ascii_case(channel)),
+ _ => continue,
+ };
+ if !channel_ok {
+ continue;
+ }
+ // Regex match (with size-limited compile + per-mission cache).
+ // The substring fallback used previously was too loose: it
+ // matched "the review was requested yesterday" against
+ // "review requested" and would flood on busy channels.
+ if !self.event_regex_matches(&mission, text).await {
+ continue;
+ }
+
+ if mission.dedup_window_secs > 0 {
+ let key = payload_dedup_key(payload.as_ref());
+ if self.dedup_event(mid, &key, mission.dedup_window_secs).await {
+ continue;
+ }
+ }
+
+ if let Some(tid) = self.fire_mission(mid, user_id, payload.clone()).await? {
+ spawned.push(tid);
+ }
+ }
+
+ Ok(spawned)
+ }
+
+ /// Fire the active `Webhook` mission whose registered `path` matches the
+ /// incoming webhook path. The bridge layer is responsible for HMAC
+ /// validation against `Webhook.secret` *before* calling this; the engine
+ /// just routes payloads to mission threads.
+ ///
+ /// Returns the IDs of any threads spawned.
+ pub async fn fire_on_webhook(
+ &self,
+ webhook_path: &str,
+ user_id: &str,
+ payload: Option,
+ ) -> Result, EngineError> {
+ let active_ids = self.active.read().await.clone();
+ let mut spawned = Vec::new();
+
+ for mid in active_ids {
+ let mission = match self.store.load_mission(mid).await? {
+ Some(m) if m.status == MissionStatus::Active => m,
+ _ => continue,
+ };
+
+ if !mission.is_owned_by(user_id) && !mission.owner_id().is_shared() {
+ continue;
+ }
+
+ let matches = matches!(
+ &mission.cadence,
+ MissionCadence::Webhook { path, .. } if path == webhook_path
+ );
+
+ if !matches {
+ continue;
+ }
+
+ if mission.dedup_window_secs > 0 {
+ let key = payload_dedup_key(payload.as_ref());
+ if self.dedup_event(mid, &key, mission.dedup_window_secs).await {
+ continue;
+ }
+ }
+
+ if let Some(tid) = self.fire_mission(mid, user_id, payload.clone()).await? {
spawned.push(tid);
}
}
@@ -414,10 +1079,12 @@ impl MissionManager {
/// Subscribes to the ThreadManager's event broadcast channel and watches
/// for `StateChanged { to: Done }`. For each completed non-Mission thread:
///
- /// 1. **Error diagnosis** — if trace has issues, fires `thread_completed_with_issues`
- /// 2. **Skill extraction** — if thread succeeded with many steps/actions,
+ /// 1. **Skill repair** — if an active skill looks stale or incomplete,
+ /// fires `thread_completed_with_skill_gap`
+ /// 2. **Error diagnosis** — if trace has issues, fires `thread_completed_with_issues`
+ /// 3. **Skill extraction** — if thread succeeded with many steps/actions,
/// fires `thread_completed_with_learnings`
- /// 3. **Conversation insights** — after every N threads in a conversation,
+ /// 4. **Conversation insights** — after every N threads in a conversation,
/// fires `conversation_insights_due`
pub fn start_event_listener(self: &Arc, _owner_id: String) {
let mgr = Arc::clone(self);
@@ -438,17 +1105,9 @@ impl MissionManager {
loop {
match rx.recv().await {
Ok(event) => {
- // Only react to threads transitioning to Done
- let is_done = matches!(
- event.kind,
- crate::types::event::EventKind::StateChanged {
- to: crate::types::thread::ThreadState::Done,
- ..
- }
- );
- if !is_done {
+ let Some(terminal_state) = learning_terminal_state(&event.kind) else {
continue;
- }
+ };
// Load the completed thread
let thread = match mgr.store.load_thread(event.thread_id).await {
@@ -461,8 +1120,49 @@ impl MissionManager {
continue;
}
- // ── Trigger 1: Error diagnosis ──────────────────
let trace = crate::executor::trace::build_trace(&thread);
+ // Single pass over events for both skill-repair and
+ // error-diagnosis triggers (avoids repeated iteration
+ // on large event logs).
+ let (error_messages, _observed_actions) =
+ collect_errors_and_actions(&thread);
+ let active_skills = thread.active_skills();
+
+ // ── Trigger 1: Skill repair ───────────────────────
+ // NOTE: skill-repair and error-diagnosis can both fire
+ // for the same thread. Each targets a different mission
+ // so they won't collide, but both may spawn concurrent
+ // threads. This is intentional — skill-repair fixes the
+ // *skill* while error-diagnosis fixes the *prompt/orchestrator*.
+ if !active_skills.is_empty() {
+ let tracker = SkillTracker::new(Arc::clone(&mgr.store));
+ let success = thread_completed_successfully(&thread, &trace);
+ for skill in &active_skills {
+ if let Err(e) = tracker.record_usage(skill.doc_id, success).await {
+ debug!(
+ skill_doc_id = %skill.doc_id.0,
+ thread_id = %thread.id,
+ "event listener: failed to record skill usage: {e}"
+ );
+ }
+ }
+
+ if let Some(payload) =
+ build_skill_gap_payload(&thread, &trace, &active_skills)
+ && let Err(e) = mgr
+ .fire_on_system_event(
+ "engine",
+ "thread_completed_with_skill_gap",
+ &thread.user_id,
+ Some(payload),
+ )
+ .await
+ {
+ debug!("event listener: failed to fire skill repair: {e}");
+ }
+ }
+
+ // ── Trigger 2: Error diagnosis ──────────────────
if !trace.issues.is_empty() {
let issues: Vec = trace
.issues
@@ -470,31 +1170,13 @@ impl MissionManager {
.map(|i| {
serde_json::json!({
"severity": format!("{:?}", i.severity),
- "category": i.category,
- "description": i.description,
+ "category": i.category.clone(),
+ "description": i.description.clone(),
"step": i.step,
})
})
.collect();
- let error_messages: Vec = thread
- .events
- .iter()
- .filter_map(|e| {
- if let crate::types::event::EventKind::ActionFailed {
- action_name,
- error,
- ..
- } = &e.kind
- {
- Some(format!("{action_name}: {error}"))
- } else {
- None
- }
- })
- .take(10)
- .collect();
-
let payload = serde_json::json!({
"source_thread_id": event.thread_id.0.to_string(),
"goal": thread.goal,
@@ -515,7 +1197,7 @@ impl MissionManager {
}
}
- // ── Trigger 2: Skill extraction ──────────────────
+ // ── Trigger 3: Skill extraction ──────────────────
let action_count = thread
.events
.iter()
@@ -527,7 +1209,7 @@ impl MissionManager {
})
.count();
- if thread.state == crate::types::thread::ThreadState::Done
+ if terminal_state == crate::types::thread::ThreadState::Done
&& trace
.issues
.iter()
@@ -573,54 +1255,59 @@ impl MissionManager {
}
}
- // ── Trigger 3: Conversation insights ────────────
- // Use the thread's project_id as a proxy for conversation scope.
- let conv_key = thread.project_id.0.to_string();
- let count = conv_thread_counts.entry(conv_key.clone()).or_insert(0);
- *count += 1;
-
- if (*count).is_multiple_of(CONVERSATION_INSIGHTS_INTERVAL) {
- // Collect recent thread goals for context
- let thread_goals: Vec = match mgr
- .store
- .list_threads(thread.project_id, &thread.user_id)
- .await
- {
- Ok(threads) => threads
+ // ── Trigger 4: Conversation insights ────────────
+ // Keep insights tied to successful completions only.
+ if should_count_for_conversation_insights(terminal_state) {
+ // Use the thread's project_id as a proxy for conversation scope.
+ let conv_key = thread.project_id.0.to_string();
+ let count = conv_thread_counts.entry(conv_key.clone()).or_insert(0);
+ *count += 1;
+
+ if (*count).is_multiple_of(CONVERSATION_INSIGHTS_INTERVAL) {
+ // Collect recent thread goals for context
+ let thread_goals: Vec = match mgr
+ .store
+ .list_threads(thread.project_id, &thread.user_id)
+ .await
+ {
+ Ok(threads) => threads
+ .iter()
+ .rev()
+ .take(CONVERSATION_INSIGHTS_INTERVAL as usize)
+ .map(|t| t.goal.clone())
+ .collect(),
+ Err(_) => vec![thread.goal.clone()],
+ };
+
+ // Collect sample user messages from recent threads
+ let sample_messages: Vec = thread
+ .messages
.iter()
- .rev()
- .take(CONVERSATION_INSIGHTS_INTERVAL as usize)
- .map(|t| t.goal.clone())
- .collect(),
- Err(_) => vec![thread.goal.clone()],
- };
-
- // Collect sample user messages from recent threads
- let sample_messages: Vec = thread
- .messages
- .iter()
- .filter(|m| m.role == crate::types::message::MessageRole::User)
- .map(|m| m.content.chars().take(200).collect::())
- .take(10)
- .collect();
-
- let payload = serde_json::json!({
- "project_id": thread.project_id.0.to_string(),
- "completed_thread_count": *count,
- "thread_goals": thread_goals,
- "sample_user_messages": sample_messages,
- });
-
- if let Err(e) = mgr
- .fire_on_system_event(
- "engine",
- "conversation_insights_due",
- &thread.user_id,
- Some(payload),
- )
- .await
- {
- debug!("event listener: failed to fire conversation insights: {e}");
+ .filter(|m| m.role == crate::types::message::MessageRole::User)
+ .map(|m| m.content.chars().take(200).collect::())
+ .take(10)
+ .collect();
+
+ let payload = serde_json::json!({
+ "project_id": thread.project_id.0.to_string(),
+ "completed_thread_count": *count,
+ "thread_goals": thread_goals,
+ "sample_user_messages": sample_messages,
+ });
+
+ if let Err(e) = mgr
+ .fire_on_system_event(
+ "engine",
+ "conversation_insights_due",
+ &thread.user_id,
+ Some(payload),
+ )
+ .await
+ {
+ debug!(
+ "event listener: failed to fire conversation insights: {e}"
+ );
+ }
}
}
}
@@ -666,6 +1353,7 @@ impl MissionManager {
MissionCadence::OnSystemEvent {
source: "engine".into(),
event_type: "thread_completed_with_issues".into(),
+ filters: std::collections::HashMap::new(),
},
);
mission.success_criteria = Some(
@@ -673,6 +1361,18 @@ impl MissionManager {
);
mission.metadata = serde_json::json!({"self_improvement": true});
mission.max_threads_per_day = 5;
+ // Future-proof: today this helper only ever uses OnSystemEvent, but if
+ // a future caller passes a Cron cadence the same `next_fire_at = None`
+ // bug that #1944 fixed in `create_mission` would silently re-emerge
+ // here. Compute next_fire_at on construction so this helper can never
+ // produce a stuck cron mission.
+ if let MissionCadence::Cron {
+ ref expression,
+ ref timezone,
+ } = mission.cadence
+ {
+ mission.next_fire_at = Some(next_cron_fire_required(expression, timezone.as_ref())?);
+ }
let id = mission.id;
self.store.save_mission(&mission).await?;
@@ -701,11 +1401,11 @@ impl MissionManager {
Ok(id)
}
- /// Ensure all three learning missions exist for the given project.
+ /// Ensure the built-in learning missions exist for the given project.
///
- /// Creates (if missing) the self-improvement, skill extraction, and
- /// conversation insights missions. This is the preferred entry point —
- /// call once at project bootstrap.
+ /// Creates (if missing) the self-improvement, skill repair, skill
+ /// extraction, and conversation insights missions. This is the preferred
+ /// entry point — call once at project bootstrap.
pub async fn ensure_learning_missions(
&self,
project_id: ProjectId,
@@ -718,7 +1418,24 @@ impl MissionManager {
self.ensure_self_improvement_mission(project_id, user_id)
.await?;
- // 2. Skill extraction (formerly playbook extraction)
+ // 2. Skill repair
+ self.ensure_mission_by_metadata(
+ project_id,
+ user_id,
+ "skill_repair",
+ "skill-repair",
+ SKILL_REPAIR_GOAL,
+ MissionCadence::OnSystemEvent {
+ source: "engine".into(),
+ event_type: "thread_completed_with_skill_gap".into(),
+ filters: HashMap::new(),
+ },
+ "Repair versioned skills when execution reveals stale or incomplete instructions",
+ 5,
+ )
+ .await?;
+
+ // 3. Skill extraction (formerly playbook extraction)
self.ensure_mission_by_metadata(
project_id,
user_id,
@@ -728,13 +1445,14 @@ impl MissionManager {
MissionCadence::OnSystemEvent {
source: "engine".into(),
event_type: "thread_completed_with_learnings".into(),
+ filters: std::collections::HashMap::new(),
},
"Extract reusable skills from successful multi-step threads",
3, // max 3/day
)
.await?;
- // 3. Conversation insights
+ // 4. Conversation insights
self.ensure_mission_by_metadata(
project_id,
user_id,
@@ -744,13 +1462,14 @@ impl MissionManager {
MissionCadence::OnSystemEvent {
source: "engine".into(),
event_type: "conversation_insights_due".into(),
+ filters: std::collections::HashMap::new(),
},
"Extract user preferences, domain knowledge, and workflow patterns from conversations",
2, // max 2/day
)
.await?;
- // 4. Expected behavior (user feedback loop)
+ // 5. Expected behavior (user feedback loop)
self.ensure_mission_by_metadata(
project_id,
user_id,
@@ -760,6 +1479,7 @@ impl MissionManager {
MissionCadence::OnSystemEvent {
source: "user_feedback".into(),
event_type: "expected_behavior".into(),
+ filters: std::collections::HashMap::new(),
},
"Investigate user-reported expectation gaps and apply fixes",
5, // max 5/day
@@ -849,6 +1569,17 @@ impl MissionManager {
mission.success_criteria = Some(success_criteria.into());
mission.metadata = serde_json::json!({metadata_key: true});
mission.max_threads_per_day = max_per_day;
+ // Future-proof: today every caller passes OnSystemEvent, but a future
+ // caller passing Cron would silently re-introduce the `next_fire_at =
+ // None` bug that #1944 fixed in `create_mission`. Compute it here so
+ // this helper can never produce a stuck cron mission.
+ if let MissionCadence::Cron {
+ ref expression,
+ ref timezone,
+ } = mission.cadence
+ {
+ mission.next_fire_at = Some(next_cron_fire_required(expression, timezone.as_ref())?);
+ }
let id = mission.id;
self.store.save_mission(&mission).await?;
@@ -867,11 +1598,30 @@ impl MissionManager {
let active_ids = self.active.read().await.clone();
let mut spawned = Vec::new();
let now = chrono::Utc::now();
+ let cooldown =
+ chrono::Duration::from_std(FIRE_COOLDOWN).unwrap_or(chrono::Duration::zero());
+
+ // Opportunistic prune of `last_fire_attempt`: drop entries whose
+ // cooldown window has already elapsed. This catches stragglers from
+ // missions that were removed without going through the graceful
+ // pause/complete paths (e.g. crash recovery, direct store edits) so
+ // the map can never grow unbounded over a long-lived process.
+ {
+ let mut map = self.last_fire_attempt.write().await;
+ map.retain(|_, last| now.signed_duration_since(*last) < cooldown);
+ }
for mid in active_ids {
- let mission = match self.store.load_mission(mid).await? {
- Some(m) if m.status == MissionStatus::Active => m,
- _ => continue,
+ // Per-mission error isolation: a transient store/load error or a
+ // single fire failure must not abort the entire tick — the other
+ // active missions still need their chance to fire on this cycle.
+ let mission = match self.store.load_mission(mid).await {
+ Ok(Some(m)) if m.status == MissionStatus::Active => m,
+ Ok(_) => continue,
+ Err(e) => {
+ debug!(mission_id = %mid, error = %e, "tick: failed to load mission; skipping");
+ continue;
+ }
};
let should_fire = match &mission.cadence {
@@ -885,18 +1635,231 @@ impl MissionManager {
| MissionCadence::Webhook { .. } => false,
};
- // Fire cron missions with the mission's own user_id so artifacts
- // are scoped to the correct tenant.
- if should_fire && let Some(tid) = self.fire_mission(mid, &mission.user_id, None).await?
- {
- spawned.push(tid);
+ if !should_fire {
+ continue;
}
- }
+
+ // In-memory cooldown — only armed when we can prove
+ // `fire_mission`'s post-spawn state didn't make scheduling
+ // progress. The detection: `fire_mission` writes the **same**
+ // instant to both `last_fire_attempt[mid]` (always, before
+ // save_mission) and the persisted `Mission.last_fire_at` (only
+ // when both `save_mission` succeeds *and* cron advance
+ // succeeded). The two paths that leave the values *unequal*:
+ //
+ // - `save_mission` failed → persisted `last_fire_at` still
+ // holds the OLD value (or None).
+ // - The cron expression couldn't be parsed (corrupt
+ // persisted state) → fire_mission deliberately leaves
+ // `last_fire_at` at the OLD value so this same mismatch
+ // arms the cooldown without inventing a new signal.
+ //
+ // On the success path the two values match and tick treats
+ // the cooldown as transparent — a normally-firing every-minute
+ // cron passes through the check regardless of how short the
+ // schedule is. Once the outcome processor reconciles
+ // `last_fire_at` back to the in-memory instant the mismatch
+ // resolves even before the 90 s window elapses.
+ //
+ // **Precision requirement (load-bearing):** the equality check
+ // requires the `Store` implementation to round-trip
+ // `DateTime` without precision loss. The bridge's
+ // `HybridStoreAdapter::load_mission` returns from an in-memory
+ // `HashMap` cache populated by `save_mission`,
+ // so it preserves nanosecond precision. JSON persistence via
+ // `serde` also preserves nanoseconds via RFC3339. A future
+ // backend that truncates timestamps (e.g. PostgreSQL TIMESTAMPTZ
+ // → microseconds) would silently break the success-path detection
+ // and arm the cooldown on every fire — the comparison would need
+ // to be relaxed to "within one microsecond" before that lands.
+ let on_cooldown =
+ self.last_fire_attempt
+ .read()
+ .await
+ .get(&mid)
+ .is_some_and(|in_mem_last| {
+ now.signed_duration_since(*in_mem_last) < cooldown
+ && mission.last_fire_at != Some(*in_mem_last)
+ });
+ if on_cooldown {
+ debug!(
+ mission_id = %mid,
+ "tick: detected stale persisted last_fire_at after fire; suppressing re-fire until reconcile"
+ );
+ continue;
+ }
+
+ // Per-mission error isolation: a single fire failure must not
+ // abort the entire tick — the other active missions still need
+ // their chance on this cycle. `fire_mission` enforces
+ // `cooldown_secs` and `max_concurrent` independently of the cron
+ // next_fire_at, so a cron mission whose schedule fires faster
+ // than its cooldown will simply skip the intervening firings
+ // rather than backlog them. Cron missions are fired with the
+ // mission's own user_id so artifacts stay tenant-scoped.
+ match self.fire_mission(mid, &mission.user_id, None).await {
+ Ok(Some(tid)) => spawned.push(tid),
+ Ok(None) => {}
+ Err(e) => debug!(
+ mission_id = %mid,
+ error = %e,
+ "tick: fire_mission failed; continuing with remaining missions"
+ ),
+ }
+ }
Ok(spawned)
}
- fn spawn_mission_outcome_watcher(&self, mission_id: MissionId, thread_id: ThreadId) {
+ /// Count threads spawned by `mission` that are still in a non-terminal
+ /// state (anything other than `Done`/`Failed`). Used by `max_concurrent`
+ /// enforcement. Walks the in-memory thread cache; threads that the store
+ /// no longer knows about are treated as terminal.
+ async fn count_running_threads(&self, mission: &Mission) -> usize {
+ let mut running = 0;
+ for tid in mission.thread_history.iter().rev() {
+ match self.store.load_thread(*tid).await {
+ Ok(Some(thread)) => {
+ if !matches!(thread.state, ThreadState::Done | ThreadState::Failed) {
+ running += 1;
+ }
+ }
+ _ => continue,
+ }
+ }
+ running
+ }
+
+ /// Returns `true` if `(mission_id, dedup_key)` was last seen within
+ /// `window_secs`. Updates the table to record `now` for the next call.
+ ///
+ /// Eviction is done **per entry** against this mission's own window —
+ /// never globally — because different missions can have different
+ /// `dedup_window_secs` values. A previous implementation called
+ /// `table.retain` with the current mission's window across the whole
+ /// table, which would silently drop fresh entries belonging to a
+ /// longer-window mission and cause duplicate firings.
+ async fn dedup_event(&self, mission_id: MissionId, dedup_key: &str, window_secs: u64) -> bool {
+ if window_secs == 0 {
+ return false;
+ }
+ let now = chrono::Utc::now();
+ let window = chrono::Duration::seconds(window_secs as i64);
+ let mut table = self.dedup_table.write().await;
+ let key = (mission_id, dedup_key.to_string());
+ match table.get(&key) {
+ Some(last) if now.signed_duration_since(*last) < window => {
+ // Within this mission's own window — duplicate.
+ true
+ }
+ _ => {
+ // Either no entry, or the entry has aged past this
+ // mission's window. Overwrite (or insert) and report
+ // first-seen. We deliberately do NOT touch entries for
+ // other missions.
+ table.insert(key, now);
+ false
+ }
+ }
+ }
+
+ /// Test whether `text` matches `mission`'s OnEvent regex. Compiles the
+ /// pattern lazily on first match attempt and caches it. Patterns that
+ /// fail to compile (or exceed `MAX_EVENT_REGEX_SIZE`) are logged at
+ /// warn level and never match.
+ async fn event_regex_matches(&self, mission: &Mission, text: &str) -> bool {
+ let MissionCadence::OnEvent { event_pattern, .. } = &mission.cadence else {
+ return false;
+ };
+
+ // Cache hit fast path.
+ if let Some(re) = self.event_regex_cache.read().await.get(&mission.id) {
+ return re.is_match(text);
+ }
+
+ // Compile under the write lock and double-check (another caller may
+ // have raced ahead and inserted the same key).
+ let mut cache = self.event_regex_cache.write().await;
+ if let Some(re) = cache.get(&mission.id) {
+ return re.is_match(text);
+ }
+ match regex::RegexBuilder::new(event_pattern)
+ .size_limit(MAX_EVENT_REGEX_SIZE)
+ .build()
+ {
+ Ok(re) => {
+ let matches = re.is_match(text);
+ cache.insert(mission.id, re);
+ matches
+ }
+ Err(error) => {
+ warn!(
+ mission_id = %mission.id,
+ pattern = %event_pattern,
+ error = %error,
+ "OnEvent mission regex failed to compile (or exceeded size limit); refusing to match"
+ );
+ false
+ }
+ }
+ }
+
+ /// Drop the compiled regex for `mission_id`, forcing recompile on the
+ /// next match attempt. Called when a mission's cadence changes or it is
+ /// deleted.
+ async fn evict_event_regex(&self, mission_id: MissionId) {
+ self.event_regex_cache.write().await.remove(&mission_id);
+ }
+
+ /// Per-user global rate limiter — read-only check. Sliding window of
+ /// timestamps; returns `true` if a new fire is currently allowed.
+ /// Evicts expired entries from the user's window as a side effect, but
+ /// does NOT record a new entry — call [`record_user_rate`] only after
+ /// the fire has actually succeeded so a failed spawn cannot consume a
+ /// slot (otherwise sustained store errors would self-DoS the user).
+ ///
+ /// [`record_user_rate`]: Self::record_user_rate
+ async fn check_user_rate(&self, user_id: &str) -> bool {
+ let now = chrono::Utc::now();
+ let window = chrono::Duration::from_std(self.rate_limit.window)
+ .unwrap_or_else(|_| chrono::Duration::seconds(self.rate_limit.window.as_secs() as i64));
+ let cutoff = now - window;
+
+ let mut log = self.user_fire_log.write().await;
+ let entries = log.entry(user_id.to_string()).or_default();
+ while entries.front().is_some_and(|ts| *ts < cutoff) {
+ entries.pop_front();
+ }
+ (entries.len() as u32) < self.rate_limit.max_fires
+ }
+
+ /// Record a successful fire against the per-user rate window. Pair with
+ /// [`check_user_rate`] — call only after the spawn has actually
+ /// completed so failed fires don't consume a slot.
+ ///
+ /// [`check_user_rate`]: Self::check_user_rate
+ async fn record_user_rate(&self, user_id: &str) {
+ let now = chrono::Utc::now();
+ let mut log = self.user_fire_log.write().await;
+ let entries = log.entry(user_id.to_string()).or_default();
+ entries.push_back(now);
+ }
+
+ /// Consult the budget gate (if attached). Returns `true` when the gate
+ /// is unattached or explicitly allows the fire.
+ async fn budget_allows(&self, user_id: &str, mission_id: MissionId) -> bool {
+ match self.budget_gate.as_ref() {
+ Some(gate) => gate.allow_mission_fire(user_id, mission_id).await,
+ None => true,
+ }
+ }
+
+ fn spawn_mission_outcome_watcher(
+ &self,
+ mission_id: MissionId,
+ thread_id: ThreadId,
+ original_fire_at: chrono::DateTime,
+ ) {
let tm = Arc::clone(&self.thread_manager);
let store = Arc::clone(&self.store);
let notification_tx = self.notification_tx.clone();
@@ -909,6 +1872,7 @@ impl MissionManager {
thread_id,
&outcome,
¬ification_tx,
+ Some(original_fire_at),
)
.await
{
@@ -929,10 +1893,49 @@ impl MissionManager {
///
/// Assembles the mission's goal, current focus, approach history, and
/// relevant project docs into a structured prompt that guides the thread.
+/// Returns `true` if every `(key, value)` pair in `filters` matches the
+/// payload's top-level field exactly. An empty filter map always matches.
+/// `None` payload only matches an empty filter map.
+fn payload_matches_filters(
+ filters: &HashMap,
+ payload: Option<&serde_json::Value>,
+) -> bool {
+ if filters.is_empty() {
+ return true;
+ }
+ let Some(payload) = payload else {
+ return false;
+ };
+ let Some(obj) = payload.as_object() else {
+ return false;
+ };
+ filters
+ .iter()
+ .all(|(key, expected)| obj.get(key).is_some_and(|actual| actual == expected))
+}
+
+/// Compute a stable dedup key for an event payload. Hashes the canonicalized
+/// JSON serialization with the standard library hasher (non-cryptographic but
+/// sufficient for in-memory dedup of trusted host-sourced events). Empty/None
+/// payloads collapse to a single fixed key so a flood of identical empty
+/// events is suppressed.
+fn payload_dedup_key(payload: Option<&serde_json::Value>) -> String {
+ use std::collections::hash_map::DefaultHasher;
+ use std::hash::{Hash, Hasher};
+ let serialized = match payload {
+ Some(value) => serde_json::to_string(value).unwrap_or_default(),
+ None => String::new(),
+ };
+ let mut hasher = DefaultHasher::new();
+ serialized.hash(&mut hasher);
+ format!("{:016x}", hasher.finish())
+}
+
fn build_meta_prompt(
mission: &Mission,
project_docs: &[MemoryDoc],
trigger_payload: &Option,
+ context_blocks: &[(String, String)],
) -> String {
let mut parts = Vec::new();
@@ -941,10 +1944,22 @@ fn build_meta_prompt(
mission.name, mission.goal
));
+ if let Some(description) = &mission.description {
+ parts.push(format!("\n{description}"));
+ }
+
if let Some(criteria) = &mission.success_criteria {
parts.push(format!("Success criteria: {criteria}"));
}
+ // Preloaded workspace context (`Mission.context_paths`).
+ if !context_blocks.is_empty() {
+ parts.push("\n## Loaded Context".into());
+ for (path, content) in context_blocks {
+ parts.push(format!("### {path}\n\n{content}"));
+ }
+ }
+
// Current focus
if let Some(focus) = &mission.current_focus {
parts.push(format!("\n## Current Focus\n{focus}"));
@@ -1015,8 +2030,15 @@ async fn process_mission_outcome(
outcome: &ThreadOutcome,
) -> Result<(), EngineError> {
let (notification_tx, _) = tokio::sync::broadcast::channel(1);
- process_mission_outcome_and_notify(store, mission_id, thread_id, outcome, ¬ification_tx)
- .await
+ process_mission_outcome_and_notify(
+ store,
+ mission_id,
+ thread_id,
+ outcome,
+ ¬ification_tx,
+ None,
+ )
+ .await
}
async fn process_mission_outcome_and_notify(
@@ -1025,12 +2047,76 @@ async fn process_mission_outcome_and_notify(
thread_id: ThreadId,
outcome: &ThreadOutcome,
notification_tx: &tokio::sync::broadcast::Sender,
+ original_fire_at: Option>,
) -> Result<(), EngineError> {
let mut mission = match store.load_mission(mission_id).await? {
Some(m) => m,
None => return Ok(()),
};
+ // Reconcile fire-accounting fields that `fire_mission` failed to persist.
+ //
+ // `fire_mission` is best-effort about its post-spawn `save_mission`: a
+ // transient store error there leaves the persisted mission missing this
+ // thread's record (`thread_history`, `threads_today`, `last_fire_at`,
+ // and — for cron cadences — the advanced `next_fire_at`). The in-memory
+ // `last_fire_attempt` cooldown holds the runaway-re-fire path closed
+ // for ~90 s, but once the cooldown elapses tick would otherwise re-fire
+ // against the stale persisted state. The outcome processor is the
+ // natural reconciliation point: by the time we run, the thread has
+ // completed and we know exactly which `thread_id` should be present.
+ // Append idempotently — repeated invocations or replays are safe — and
+ // immediately overwrite our save below, achieving eventual consistency
+ // for transient store failures even after retries are exhausted.
+ let needs_reconcile = !mission.thread_history.contains(&thread_id);
+ if needs_reconcile {
+ debug!(
+ mission_id = %mission_id,
+ thread_id = %thread_id,
+ "outcome processor: reconciling fire-accounting fields missing from persisted mission (fire_mission save likely failed)"
+ );
+ // `record_thread` also bumps `updated_at`; matches the fire_mission
+ // path so the two routes don't diverge on field-mutation patterns.
+ mission.record_thread(thread_id);
+ mission.threads_today = mission.threads_today.saturating_add(1);
+ // Reconcile `last_fire_at` back to the **original** fire instant
+ // when we know it (passed through from fire_mission via the
+ // outcome watcher). Otherwise fall back to `now` as a conservative
+ // approximation. Using the original instant matters for users with
+ // a configured `cooldown_secs`: if the thread ran for N seconds,
+ // a `now`-based reconcile would extend the user's cooldown window
+ // by N, gradually drifting the schedule.
+ mission.last_fire_at = Some(original_fire_at.unwrap_or_else(chrono::Utc::now));
+ // For cron missions, advance `next_fire_at` so the ticker doesn't
+ // immediately re-fire against the stale schedule. Use the lenient
+ // `next_cron_fire` (not `_required`) — a corrupt expression here
+ // should not block the outcome save.
+ if let MissionCadence::Cron {
+ ref expression,
+ ref timezone,
+ } = mission.cadence
+ {
+ let now = chrono::Utc::now();
+ let needs_advance = mission.next_fire_at.is_none_or(|next| next <= now);
+ if needs_advance {
+ match next_cron_fire(expression, timezone.as_ref()) {
+ Ok(Some(next)) => mission.next_fire_at = Some(next),
+ Ok(None) => debug!(
+ mission_id = %mission_id,
+ expression = %expression,
+ "reconcile: cron has no upcoming fire time; leaving next_fire_at unset"
+ ),
+ Err(e) => debug!(
+ mission_id = %mission_id,
+ expression = %expression,
+ error = %e,
+ "reconcile: failed to recompute next_fire_at; leaving as-is"
+ ),
+ }
+ }
+ }
+ }
+
// Build notification fields while processing the outcome.
let mut notify_response: Option = None;
let mut is_error = false;
@@ -1073,6 +2159,15 @@ async fn process_mission_outcome_and_notify(
"failed to process self-improvement output: {e}"
);
}
+
+ if is_skill_repair_mission(&mission)
+ && let Err(e) = process_skill_repair_output(store, &mission, text).await
+ {
+ debug!(
+ mission_id = %mission_id,
+ "failed to process skill-repair output: {e}"
+ );
+ }
}
ThreadOutcome::Completed { response: None } => {}
ThreadOutcome::Failed { error } => {
@@ -1092,13 +2187,20 @@ async fn process_mission_outcome_and_notify(
// Emit notification if there are channels to notify.
if !mission.notify_channels.is_empty() && notify_response.is_some() {
+ // Truncate before broadcasting. Mission threads can produce
+ // arbitrarily long output (especially full-job missions); a multi-MB
+ // notification is unusable in any chat surface and can OOM Slack/
+ // Discord adapters that buffer outbound bodies. The full text is
+ // already preserved untruncated in `mission.approach_history`.
+ let response = notify_response.map(|text| truncate_notification_text(&text));
let notification = MissionNotification {
mission_id,
mission_name: mission.name.clone(),
thread_id,
user_id: mission.user_id.clone(),
notify_channels: mission.notify_channels.clone(),
- response: notify_response,
+ notify_user: mission.notify_user.clone(),
+ response,
is_error,
};
// Best-effort: ignore send errors (no subscribers = no problem).
@@ -1109,6 +2211,29 @@ async fn process_mission_outcome_and_notify(
store.save_mission(&mission).await
}
+/// UTF-8-safe ellipsis truncation for mission notification responses.
+///
+/// Mirrors the v1 routine engine's `truncate` helper (which uses
+/// `floor_char_boundary` from the host `util` module). The engine crate
+/// has no `util` so we inline a small helper. The full response text is
+/// always preserved untruncated in `Mission.approach_history`; truncation
+/// here only affects what is broadcast to notify_channels.
+const MAX_NOTIFICATION_RESPONSE_BYTES: usize = 4000;
+
+fn truncate_notification_text(text: &str) -> String {
+ if text.len() <= MAX_NOTIFICATION_RESPONSE_BYTES {
+ return text.to_string();
+ }
+ // Walk back from the byte cap to the nearest char boundary so we
+ // never split a multi-byte UTF-8 sequence. `is_char_boundary(0)`
+ // is always true so the loop is bounded.
+ let mut end = MAX_NOTIFICATION_RESPONSE_BYTES;
+ while !text.is_char_boundary(end) {
+ end -= 1;
+ }
+ format!("{}…", &text[..end]) // safety: end walked back to a valid char boundary above
+}
+
/// Check if a mission is the self-improvement mission.
fn is_self_improvement_mission(mission: &Mission) -> bool {
mission
@@ -1118,6 +2243,15 @@ fn is_self_improvement_mission(mission: &Mission) -> bool {
.unwrap_or(false)
}
+/// Check if a mission is the skill-repair mission.
+fn is_skill_repair_mission(mission: &Mission) -> bool {
+ mission
+ .metadata
+ .get("skill_repair")
+ .and_then(|v| v.as_bool())
+ .unwrap_or(false)
+}
+
/// Process output from a self-improvement mission thread.
///
/// Two paths:
@@ -1287,6 +2421,491 @@ fn extract_json_from_response(response: &str) -> Option {
.filter(|v| v.is_object())
}
+#[derive(Debug, Deserialize)]
+struct SkillRepairMissionOutput {
+ doc_id: DocId,
+ repair_type: SkillRepairType,
+ updated_content: String,
+ #[serde(default)]
+ summary: String,
+ #[serde(default)]
+ description: Option,
+ #[serde(default)]
+ activation: Option,
+ #[serde(default)]
+ code_snippets: Option>,
+}
+
+async fn process_skill_repair_output(
+ store: &Arc,
+ mission: &Mission,
+ response: &str,
+) -> Result<(), EngineError> {
+ let json_val = match extract_json_from_response(response) {
+ Some(v) => v,
+ None => {
+ debug!("skill-repair: no structured JSON in response");
+ return Ok(());
+ }
+ };
+ let repair: SkillRepairMissionOutput =
+ serde_json::from_value(json_val).map_err(|e| EngineError::Skill {
+ reason: format!("invalid skill-repair output: {e}"),
+ })?;
+
+ let Some(triggered_skill) = triggered_skill_provenance(mission, repair.doc_id) else {
+ return Err(EngineError::Skill {
+ reason: if has_skill_trigger_payload(mission) {
+ format!(
+ "skill-repair attempted to modify untriggered skill {}",
+ repair.doc_id.0
+ )
+ } else {
+ "skill-repair requires an active skill trigger payload".into()
+ },
+ });
+ };
+ if repair.updated_content.trim().is_empty() {
+ return Err(EngineError::Skill {
+ reason: format!(
+ "skill-repair produced empty updated_content for skill {}",
+ repair.doc_id.0
+ ),
+ });
+ }
+
+ let existing =
+ store
+ .load_memory_doc(repair.doc_id)
+ .await?
+ .ok_or_else(|| EngineError::Skill {
+ reason: format!("skill doc not found: {}", repair.doc_id.0),
+ })?;
+ if existing.project_id != mission.project_id {
+ return Err(EngineError::Skill {
+ reason: format!(
+ "skill-repair attempted to modify skill {} outside mission project",
+ repair.doc_id.0
+ ),
+ });
+ }
+ if !existing.is_owned_by(&mission.user_id) {
+ return Err(EngineError::AccessDenied {
+ user_id: mission.user_id.clone(),
+ entity: format!("skill {}", repair.doc_id.0),
+ });
+ }
+ if existing.doc_type != DocType::Skill {
+ return Err(EngineError::Skill {
+ reason: format!(
+ "skill-repair attempted to modify non-skill doc {} ({:?})",
+ repair.doc_id.0, existing.doc_type
+ ),
+ });
+ }
+ serde_json::from_value::(existing.metadata.clone()).map_err(|e| {
+ EngineError::Skill {
+ reason: format!("invalid skill metadata for {}: {e}", repair.doc_id.0),
+ }
+ })?;
+ let from_version = triggered_skill.version;
+ let source_thread_id = mission
+ .last_trigger_payload
+ .as_ref()
+ .and_then(|payload| payload.get("source_thread_id"))
+ .and_then(|value| value.as_str())
+ .map(ToString::to_string);
+ let summary = if repair.summary.trim().is_empty() {
+ format!("Applied {:?} repair", repair.repair_type)
+ } else {
+ repair.summary.clone()
+ };
+
+ let tracker = SkillTracker::new(Arc::clone(store));
+ tracker
+ .update_skill(
+ repair.doc_id,
+ repair.updated_content,
+ Some(triggered_skill.version),
+ move |meta| {
+ if let Some(description) = repair.description {
+ meta.description = description;
+ }
+ if let Some(activation) = repair.activation {
+ meta.activation = activation;
+ }
+ if let Some(code_snippets) = repair.code_snippets {
+ meta.code_snippets = code_snippets;
+ }
+ meta.repairs.push(SkillRepairRecord {
+ source_thread_id,
+ from_version,
+ to_version: meta.version,
+ repair_type: repair.repair_type,
+ summary,
+ repaired_at: Some(chrono::Utc::now()),
+ });
+ if meta.repairs.len() > 10 {
+ let keep_from = meta.repairs.len() - 10;
+ meta.repairs.drain(0..keep_from);
+ }
+ },
+ )
+ .await
+}
+
+fn has_skill_trigger_payload(mission: &Mission) -> bool {
+ mission
+ .last_trigger_payload
+ .as_ref()
+ .and_then(|payload| payload.get("active_skills"))
+ .and_then(|value| value.as_array())
+ .is_some_and(|skills| !skills.is_empty())
+}
+
+fn triggered_skill_provenance(mission: &Mission, doc_id: DocId) -> Option {
+ mission
+ .last_trigger_payload
+ .as_ref()
+ .and_then(|payload| payload.get("active_skills"))
+ .cloned()
+ .and_then(|value| serde_json::from_value::>(value).ok())
+ .and_then(|skills| skills.into_iter().find(|skill| skill.doc_id == doc_id))
+}
+
+/// Collects error messages and deduplicated observed action names in a single
+/// pass over `thread.events`. Previous implementation used separate passes
+/// which is wasteful for threads with large event logs.
+fn collect_errors_and_actions(thread: &Thread) -> (Vec, Vec) {
+ let mut error_messages = Vec::new();
+ let mut actions = Vec::new();
+ let mut seen = HashSet::new();
+
+ for event in &thread.events {
+ match &event.kind {
+ crate::types::event::EventKind::ActionFailed {
+ action_name, error, ..
+ } => {
+ if !is_recoverable_action_failure(error) && error_messages.len() < 10 {
+ error_messages.push(format!("{action_name}: {error}"));
+ }
+ if seen.insert(action_name.clone()) {
+ actions.push(action_name.clone());
+ }
+ }
+ crate::types::event::EventKind::ActionExecuted { action_name, .. } => {
+ if seen.insert(action_name.clone()) {
+ actions.push(action_name.clone());
+ }
+ }
+ _ => {}
+ }
+ }
+
+ (error_messages, actions)
+}
+
+fn learning_terminal_state(
+ event_kind: &crate::types::event::EventKind,
+) -> Option {
+ match event_kind {
+ crate::types::event::EventKind::StateChanged {
+ to: crate::types::thread::ThreadState::Done,
+ ..
+ } => Some(crate::types::thread::ThreadState::Done),
+ crate::types::event::EventKind::StateChanged {
+ to: crate::types::thread::ThreadState::Failed,
+ ..
+ } => Some(crate::types::thread::ThreadState::Failed),
+ _ => None,
+ }
+}
+
+fn should_count_for_conversation_insights(
+ terminal_state: crate::types::thread::ThreadState,
+) -> bool {
+ terminal_state == crate::types::thread::ThreadState::Done
+}
+
+fn has_action_failures(thread: &Thread) -> bool {
+ thread.events.iter().any(|event| match &event.kind {
+ crate::types::event::EventKind::ActionFailed { error, .. } => {
+ !is_recoverable_action_failure(error)
+ }
+ _ => false,
+ })
+}
+
+fn is_recoverable_auth_failure_text(text: &str) -> bool {
+ text.to_ascii_lowercase()
+ .contains("authentication required for credential ")
+}
+
+fn is_recoverable_action_failure(error: &str) -> bool {
+ is_recoverable_auth_failure_text(error)
+}
+
+fn action_params_summary(event: &crate::types::event::ThreadEvent) -> Option<&str> {
+ match &event.kind {
+ crate::types::event::EventKind::ActionExecuted { params_summary, .. }
+ | crate::types::event::EventKind::ActionFailed { params_summary, .. } => {
+ params_summary.as_deref()
+ }
+ _ => None,
+ }
+}
+
+fn contains_word(haystack: &str, word: &str) -> bool {
+ for (start, _) in haystack.match_indices(word) {
+ let before_ok = start == 0 || haystack.as_bytes()[start - 1].is_ascii_whitespace();
+ let end = start + word.len();
+ let after_ok = end == haystack.len() || haystack.as_bytes()[end].is_ascii_whitespace();
+ if before_ok && after_ok {
+ return true;
+ }
+ }
+ false
+}
+
+fn has_shell_verification_action(thread: &Thread) -> bool {
+ const PHRASE_PATTERNS: &[&str] = &[
+ "cargo test",
+ "pytest",
+ "npm test",
+ "pnpm test",
+ "yarn test",
+ "go test",
+ "git diff",
+ "git status",
+ "gh pr view",
+ "gh issue view",
+ "cat ",
+ "head ",
+ "tail ",
+ "grep ",
+ "rg ",
+ "find ",
+ "stat ",
+ ];
+ const WORD_PATTERNS: &[&str] = &["ls", "diff", "status", "view", "show"];
+
+ thread.events.iter().any(|event| match &event.kind {
+ crate::types::event::EventKind::ActionExecuted { action_name, .. }
+ if action_name == "shell" =>
+ {
+ action_params_summary(event)
+ .map(|summary| {
+ let lower = summary.to_lowercase();
+ PHRASE_PATTERNS
+ .iter()
+ .any(|pattern| lower.contains(pattern))
+ || WORD_PATTERNS.iter().any(|word| contains_word(&lower, word))
+ })
+ .unwrap_or(false)
+ }
+ crate::types::event::EventKind::ActionFailed { action_name, .. }
+ if action_name == "shell" =>
+ {
+ action_params_summary(event)
+ .map(|summary| {
+ let lower = summary.to_lowercase();
+ PHRASE_PATTERNS
+ .iter()
+ .any(|pattern| lower.contains(pattern))
+ || WORD_PATTERNS.iter().any(|word| contains_word(&lower, word))
+ })
+ .unwrap_or(false)
+ }
+ _ => false,
+ })
+}
+
+fn has_mutating_shell_or_git_action(thread: &Thread) -> bool {
+ const PHRASE_PATTERNS: &[&str] = &[
+ "apply_patch",
+ "git commit",
+ "git push",
+ "git pull",
+ "git merge",
+ "git rebase",
+ "git cherry-pick",
+ "git revert",
+ "git reset",
+ "git checkout",
+ "git switch",
+ "cargo fmt",
+ "rustfmt",
+ "npm install",
+ "pnpm install",
+ "yarn install",
+ "mkdir ",
+ "rm ",
+ "mv ",
+ "cp ",
+ "touch ",
+ "tee ",
+ "sed -i",
+ "perl -pi",
+ ];
+ const WORD_PATTERNS: &[&str] = &[
+ "write", "create", "delete", "remove", "rename", "patch", "install", "format",
+ ];
+
+ thread.events.iter().any(|event| match &event.kind {
+ crate::types::event::EventKind::ActionExecuted { action_name, .. }
+ | crate::types::event::EventKind::ActionFailed { action_name, .. }
+ if action_name == "shell" || action_name == "git" =>
+ {
+ action_params_summary(event)
+ .map(|summary| {
+ let lower = summary.to_lowercase();
+ PHRASE_PATTERNS
+ .iter()
+ .any(|pattern| lower.contains(pattern))
+ || WORD_PATTERNS.iter().any(|word| contains_word(&lower, word))
+ })
+ .unwrap_or(false)
+ }
+ _ => false,
+ })
+}
+
+fn infer_skill_repair_hints(
+ thread: &Thread,
+ trace: &ExecutionTrace,
+ error_messages: &[String],
+ observed_actions: &[String],
+) -> Vec {
+ let mut hints = Vec::new();
+ let mut push_hint = |hint| {
+ if !hints.contains(&hint) {
+ hints.push(hint);
+ }
+ };
+
+ let lower_signals = error_messages
+ .iter()
+ .map(|message| message.to_lowercase())
+ .chain(
+ trace
+ .issues
+ .iter()
+ .filter(|issue| {
+ !(issue.category == "tool_error"
+ && is_recoverable_auth_failure_text(&issue.description))
+ })
+ .map(|issue| issue.description.to_lowercase()),
+ )
+ .collect::>();
+
+ let recoverable_auth_failures = thread
+ .events
+ .iter()
+ .filter_map(|event| {
+ if let crate::types::event::EventKind::ActionFailed { error, .. } = &event.kind
+ && is_recoverable_auth_failure_text(error)
+ {
+ Some(error.to_lowercase())
+ } else {
+ None
+ }
+ })
+ .collect::>();
+
+ if lower_signals
+ .iter()
+ .chain(recoverable_auth_failures.iter())
+ .any(|message| {
+ ["auth", "login", "token", "credential", "permission denied"]
+ .iter()
+ .any(|needle| message.contains(needle))
+ })
+ {
+ push_hint(SkillRepairType::MissingPrerequisite);
+ }
+
+ if lower_signals.iter().any(|message| {
+ [
+ "command not found",
+ "no such file",
+ "not found",
+ "could not find",
+ "unknown file",
+ "unknown path",
+ ]
+ .iter()
+ .any(|needle| message.contains(needle))
+ }) {
+ push_hint(SkillRepairType::StaleCommandPath);
+ }
+
+ let mutating_actions = observed_actions.iter().any(|action| {
+ matches!(
+ action.as_str(),
+ "write_file" | "apply_patch" | "memory_write" | "skill_install" | "skill_remove"
+ )
+ }) || has_mutating_shell_or_git_action(thread);
+ let verification_actions = observed_actions.iter().any(|action| {
+ matches!(
+ action.as_str(),
+ "read_file" | "memory_read" | "memory_search" | "cargo_test" | "pytest"
+ )
+ }) || has_shell_verification_action(thread);
+ if mutating_actions && !verification_actions {
+ push_hint(SkillRepairType::MissingVerification);
+ }
+
+ if !error_messages.is_empty() && thread.state == crate::types::thread::ThreadState::Done {
+ push_hint(SkillRepairType::MissingPitfall);
+ }
+
+ hints
+}
+
+fn build_skill_gap_payload(
+ thread: &Thread,
+ trace: &ExecutionTrace,
+ active_skills: &[ActiveSkillProvenance],
+) -> Option {
+ let (error_messages, observed_actions) = collect_errors_and_actions(thread);
+ let repair_hints = infer_skill_repair_hints(thread, trace, &error_messages, &observed_actions);
+ if repair_hints.is_empty() {
+ return None;
+ }
+
+ let issues: Vec = trace
+ .issues
+ .iter()
+ .map(|issue| {
+ serde_json::json!({
+ "severity": format!("{:?}", issue.severity),
+ "category": issue.category.clone(),
+ "description": issue.description.clone(),
+ "step": issue.step,
+ })
+ })
+ .collect();
+
+ Some(serde_json::json!({
+ "source_thread_id": thread.id.0.to_string(),
+ "goal": thread.goal,
+ "active_skills": active_skills,
+ "issues": issues,
+ "error_messages": error_messages,
+ "observed_actions": observed_actions,
+ "repair_hints": repair_hints,
+ }))
+}
+
+fn thread_completed_successfully(thread: &Thread, trace: &ExecutionTrace) -> bool {
+ thread.state == crate::types::thread::ThreadState::Done
+ && !has_action_failures(thread)
+ && trace
+ .issues
+ .iter()
+ .all(|issue| issue.severity != IssueSeverity::Error)
+}
+
/// The goal for the self-improvement mission (autoresearch-style program).
///
/// This is the "program.md" — a concrete, step-by-step prompt that tells the
@@ -1303,6 +2922,9 @@ pub const FIX_PATTERN_DB_TAG: &str = "fix_patterns";
/// The goal for the skill extraction mission.
const SKILL_EXTRACTION_GOAL: &str = include_str!("../../prompts/mission_skill_extraction.md");
+/// The goal for the skill-repair mission.
+const SKILL_REPAIR_GOAL: &str = include_str!("../../prompts/mission_skill_repair.md");
+
/// The goal for the conversation insights mission.
const CONVERSATION_INSIGHTS_GOAL: &str =
include_str!("../../prompts/mission_conversation_insights.md");
@@ -1329,7 +2951,6 @@ mod tests {
use std::collections::HashMap;
use std::sync::Mutex;
- use std::time::Duration;
use crate::capability::lease::LeaseManager;
use crate::capability::policy::PolicyEngine;
@@ -1340,11 +2961,15 @@ mod tests {
use crate::types::capability::{ActionDef, CapabilityLease};
use crate::types::error::EngineError;
use crate::types::event::ThreadEvent;
- use crate::types::memory::{DocId, MemoryDoc};
+ use crate::types::memory::{DocId, DocType, MemoryDoc};
use crate::types::mission::{Mission, MissionCadence, MissionId, MissionStatus};
use crate::types::project::{Project, ProjectId};
+ use crate::types::step::StepId;
use crate::types::step::{ActionResult, LlmResponse, Step, TokenUsage};
- use crate::types::thread::{Thread, ThreadId, ThreadState};
+ use crate::types::thread::{ActiveSkillProvenance, Thread, ThreadId, ThreadState, ThreadType};
+ use ironclaw_skills::SkillTrust;
+ use ironclaw_skills::types::ActivationCriteria;
+ use ironclaw_skills::v2::{SkillMetrics, SkillRepairType, V2SkillMetadata, V2SkillSource};
// ── TestStore — in-memory Store that persists missions ───
@@ -1352,6 +2977,13 @@ mod tests {
threads: tokio::sync::RwLock>,
missions: tokio::sync::RwLock>,
docs: tokio::sync::RwLock>,
+ /// Optional gate that blocks the next `save_mission` call until
+ /// the test releases it. Used by `fire_mission_arms_cooldown_before_save`
+ /// to deterministically observe the in-flight save state.
+ save_mission_gate: tokio::sync::Mutex>>,
+ /// Notified when `save_mission` enters the gated wait so the test
+ /// knows save is in progress (rather than not yet called).
+ save_mission_started: tokio::sync::Notify,
}
impl TestStore {
@@ -1360,8 +2992,45 @@ mod tests {
threads: tokio::sync::RwLock::new(HashMap::new()),
missions: tokio::sync::RwLock::new(HashMap::new()),
docs: tokio::sync::RwLock::new(Vec::new()),
+ save_mission_gate: tokio::sync::Mutex::new(None),
+ save_mission_started: tokio::sync::Notify::new(),
}
}
+
+ /// Block the next `save_mission` call. Returns a sender the test
+ /// must signal once it's done observing the in-flight state.
+ async fn block_next_save_mission(&self) -> tokio::sync::oneshot::Sender<()> {
+ let (tx, rx) = tokio::sync::oneshot::channel();
+ *self.save_mission_gate.lock().await = Some(rx);
+ tx
+ }
+ }
+
+ fn make_skill_doc(project_id: ProjectId, user_id: &str, name: &str) -> MemoryDoc {
+ let meta = V2SkillMetadata {
+ name: name.to_string(),
+ version: 1,
+ description: format!("{name} description"),
+ activation: ActivationCriteria::default(),
+ source: V2SkillSource::Extracted,
+ trust: SkillTrust::Trusted,
+ code_snippets: vec![],
+ metrics: SkillMetrics::default(),
+ parent_version: None,
+ revisions: vec![],
+ repairs: vec![],
+ content_hash: "sha256:test".to_string(),
+ };
+
+ let mut doc = MemoryDoc::new(
+ project_id,
+ user_id,
+ DocType::Skill,
+ format!("skill:{name}"),
+ "Original skill content",
+ );
+ doc.metadata = serde_json::to_value(&meta).expect("serialize test skill metadata");
+ doc
}
#[async_trait::async_trait]
@@ -1454,6 +3123,14 @@ mod tests {
// ── Mission (fully implemented) ──
async fn save_mission(&self, mission: &Mission) -> Result<(), EngineError> {
+ // Honor the test gate if one is installed. Take the receiver out
+ // of the slot so subsequent saves are unblocked by default — the
+ // gate is one-shot per `block_next_save_mission` call.
+ let gate = self.save_mission_gate.lock().await.take();
+ if let Some(rx) = gate {
+ self.save_mission_started.notify_one();
+ let _ = rx.await;
+ }
self.missions
.write()
.await
@@ -1646,6 +3323,53 @@ mod tests {
assert_eq!(mission.status, MissionStatus::Active);
}
+ #[tokio::test]
+ async fn resume_mission_rejects_terminal_states() {
+ // Regression: resume_mission must not resurrect Completed/Failed
+ // missions. Only Paused → Active is permitted.
+ let store = Arc::new(TestStore::new());
+ let mgr = make_mission_manager(Arc::clone(&store) as Arc);
+ let project_id = ProjectId::new();
+
+ let id = mgr
+ .create_mission(
+ project_id,
+ "alice",
+ "terminal-state-test",
+ "goal",
+ MissionCadence::Manual,
+ Vec::new(),
+ )
+ .await
+ .unwrap();
+
+ // Active → resume must fail (only Paused is resumable).
+ let err = mgr
+ .resume_mission(id, "alice")
+ .await
+ .expect_err("resume_mission must reject Active missions");
+ match err {
+ EngineError::Store { reason } => assert!(reason.contains("Active")),
+ other => panic!("expected Store error, got {other:?}"),
+ }
+
+ // Drive the mission into a terminal state via complete_mission and
+ // confirm resume is still rejected.
+ mgr.complete_mission(id).await.unwrap();
+ let err = mgr
+ .resume_mission(id, "alice")
+ .await
+ .expect_err("resume_mission must reject Completed missions");
+ match err {
+ EngineError::Store { reason } => assert!(reason.contains("Completed")),
+ other => panic!("expected Store error, got {other:?}"),
+ }
+
+ // Make sure the status didn't drift after the failed resume calls.
+ let mission = mgr.get_mission(id).await.unwrap().unwrap();
+ assert_eq!(mission.status, MissionStatus::Completed);
+ }
+
#[tokio::test]
async fn complete_removes_from_active() {
let store = Arc::new(TestStore::new());
@@ -1746,7 +3470,7 @@ mod tests {
let mgr = make_mission_manager(Arc::clone(&store) as Arc);
let project_id = ProjectId::new();
- // Create a cron mission with next_fire_at in the past
+ // Create a cron mission — create_mission now computes next_fire_at
let id = mgr
.create_mission(
project_id,
@@ -1762,8 +3486,15 @@ mod tests {
.await
.unwrap();
- // Set next_fire_at to the past so tick() will fire it
- {
+ // Verify next_fire_at was populated by create_mission
+ let mission = mgr.get_mission(id).await.unwrap().unwrap();
+ assert!(
+ mission.next_fire_at.is_some(),
+ "create_mission should compute next_fire_at for cron cadence"
+ );
+
+ // Move next_fire_at to the past so tick() will fire it
+ {
let mut missions = store.missions.write().await;
if let Some(mission) = missions.get_mut(&id) {
mission.next_fire_at = Some(chrono::Utc::now() - chrono::Duration::seconds(60));
@@ -2041,6 +3772,7 @@ mod tests {
MissionCadence::OnSystemEvent {
source: "engine".into(),
event_type: "thread_completed_with_issues".into(),
+ filters: std::collections::HashMap::new(),
},
Vec::new(),
)
@@ -2074,6 +3806,7 @@ mod tests {
MissionCadence::OnSystemEvent {
source: "github".into(),
event_type: "push".into(),
+ filters: std::collections::HashMap::new(),
},
Vec::new(),
)
@@ -2137,6 +3870,7 @@ mod tests {
MissionCadence::OnSystemEvent {
source: "engine".into(),
event_type: "thread_completed_with_issues".into(),
+ filters: std::collections::HashMap::new(),
},
);
mission.metadata = serde_json::json!({"self_improvement": true});
@@ -2508,12 +4242,26 @@ mod tests {
}
#[tokio::test]
- async fn system_mission_requires_system_user_to_manage() {
+ async fn shared_mission_management_is_open_at_engine_layer() {
+ // Contract pinned by this test (matches the doc-comment on
+ // `resume_mission` and the ownership tightening in PR #2126/#2130):
+ //
+ // "Shared missions can only be managed by shared owners
+ // (system user)."
+ //
+ // i.e. shared (system-owned) missions are NOT manageable by regular
+ // users at the engine layer. The web handler used to be expected to
+ // gate admin-role; the engine now enforces shared-owner identity
+ // directly so the contract holds even when the engine is called
+ // outside the web handler.
+ //
+ // The user-vs-user case for non-shared missions is covered by
+ // `pause_resume_does_not_cross_users`.
let store = Arc::new(TestStore::new());
let mgr = make_mission_manager(Arc::clone(&store) as Arc);
let project_id = ProjectId::new();
- // Create a system mission
+ // "system" maps to OwnerId::Shared via LEGACY_SHARED_OWNER_ID.
let system_id = mgr
.create_mission(
project_id,
@@ -2525,20 +4273,35 @@ mod tests {
)
.await
.unwrap();
+ let mission = mgr.get_mission(system_id).await.unwrap().unwrap();
+ assert!(
+ mission.owner_id().is_shared(),
+ "missions owned by 'system' must be classified as shared"
+ );
- // Regular user cannot pause system mission
- let result = mgr.pause_mission(system_id, "alice").await;
+ // Regular users CANNOT pause a shared mission — engine returns
+ // AccessDenied.
+ let alice_pause = mgr.pause_mission(system_id, "alice").await;
assert!(
- matches!(result.unwrap_err(), EngineError::AccessDenied { .. }),
- "regular user cannot manage system missions"
+ matches!(alice_pause, Err(EngineError::AccessDenied { .. })),
+ "regular users must not pause shared missions; got {:?}",
+ alice_pause
);
- // System user can pause (admin path passes "system" as user_id)
+ // The system user (canonical shared-owner identity) can manage it.
mgr.pause_mission(system_id, "system").await.unwrap();
let m = mgr.get_mission(system_id).await.unwrap().unwrap();
assert_eq!(m.status, MissionStatus::Paused);
- // System user can resume
+ // Regular users also cannot resume.
+ let bob_resume = mgr.resume_mission(system_id, "bob").await;
+ assert!(
+ matches!(bob_resume, Err(EngineError::AccessDenied { .. })),
+ "regular users must not resume shared missions; got {:?}",
+ bob_resume
+ );
+
+ // System user resume works.
mgr.resume_mission(system_id, "system").await.unwrap();
let m = mgr.get_mission(system_id).await.unwrap().unwrap();
assert_eq!(m.status, MissionStatus::Active);
@@ -2646,29 +4409,2344 @@ mod tests {
);
}
+ /// Helper: create an event mission with the reactive-default guardrails
+ /// disabled so the test can fire it repeatedly without tripping cooldown
+ /// or daily caps. Patterns are caller-supplied; everything else stays
+ /// at the engine defaults *except* the guardrails we explicitly null out.
+ async fn create_unguarded_event_mission(
+ mgr: &MissionManager,
+ project_id: ProjectId,
+ user_id: &str,
+ name: &str,
+ pattern: &str,
+ channel: Option<&str>,
+ ) -> MissionId {
+ let id = mgr
+ .create_mission(
+ project_id,
+ user_id,
+ name,
+ "react to events",
+ MissionCadence::OnEvent {
+ event_pattern: pattern.to_string(),
+ channel: channel.map(String::from),
+ },
+ Vec::new(),
+ )
+ .await
+ .unwrap();
+ // Disable reactive defaults for tests that want to assert the
+ // matcher behavior without tripping cooldown / max_concurrent.
+ mgr.update_mission(
+ id,
+ user_id,
+ MissionUpdate {
+ cooldown_secs: Some(0),
+ max_concurrent: Some(0),
+ max_threads_per_day: Some(0),
+ ..Default::default()
+ },
+ )
+ .await
+ .unwrap();
+ id
+ }
+
#[tokio::test]
- async fn ensure_learning_missions_idempotent_per_user() {
+ async fn fire_on_message_event_matches_pattern_and_channel_filter() {
let store = Arc::new(TestStore::new());
let mgr = make_mission_manager(Arc::clone(&store) as Arc);
let project_id = ProjectId::new();
- // Call twice for the same user
- mgr.ensure_learning_missions(project_id, "alice")
+ // Mission with a channel-scoped message event trigger.
+ let id = create_unguarded_event_mission(
+ &mgr,
+ project_id,
+ "alice",
+ "PR review nudge",
+ "review requested",
+ Some("github"),
+ )
+ .await;
+
+ // Wrong channel — should NOT fire even though pattern matches.
+ let spawned = mgr
+ .fire_on_message_event("slack", "review requested on PR #42", "alice", None)
.await
.unwrap();
- mgr.ensure_learning_missions(project_id, "alice")
+ assert!(spawned.is_empty(), "wrong channel should not fire");
+
+ // Right channel, wrong pattern — should NOT fire.
+ let spawned = mgr
+ .fire_on_message_event("github", "build green", "alice", None)
.await
.unwrap();
+ assert!(spawned.is_empty(), "wrong pattern should not fire");
- // Should not create duplicates
- let alice_missions = store.list_missions(project_id, "alice").await.unwrap();
- let self_imp_count = alice_missions
- .iter()
- .filter(|m| is_self_improvement_mission(m))
- .count();
+ // Right channel, right pattern — SHOULD fire.
+ let spawned = mgr
+ .fire_on_message_event(
+ "github",
+ "review requested on PR #42",
+ "alice",
+ Some(serde_json::json!({"pr": 42})),
+ )
+ .await
+ .unwrap();
assert_eq!(
- self_imp_count, 1,
- "should not duplicate self-improvement mission"
+ spawned.len(),
+ 1,
+ "matching event should fire exactly one mission"
+ );
+
+ // Channel filter is case-insensitive.
+ let spawned = mgr
+ .fire_on_message_event("GitHub", "review requested again", "alice", None)
+ .await
+ .unwrap();
+ assert_eq!(spawned.len(), 1, "channel match should be case-insensitive");
+
+ // Mission's thread history should now reflect both fires.
+ let mission = mgr.get_mission(id).await.unwrap().unwrap();
+ assert_eq!(mission.thread_history.len(), 2);
+ }
+
+ #[tokio::test]
+ async fn fire_on_message_event_without_channel_filter_matches_any_channel() {
+ let store = Arc::new(TestStore::new());
+ let mgr = make_mission_manager(Arc::clone(&store) as Arc);
+ let project_id = ProjectId::new();
+
+ // Mission with no channel filter — should match any channel.
+ create_unguarded_event_mission(
+ &mgr,
+ project_id,
+ "alice",
+ "Universal pattern",
+ "deploy now",
+ None,
+ )
+ .await;
+
+ for channel in &["github", "slack", "gateway", "repl"] {
+ let spawned = mgr
+ .fire_on_message_event(channel, "please deploy now thanks", "alice", None)
+ .await
+ .unwrap();
+ assert_eq!(
+ spawned.len(),
+ 1,
+ "no channel filter should match channel {channel}"
+ );
+ }
+ }
+
+ #[tokio::test]
+ async fn fire_on_message_event_respects_owner_scope() {
+ let store = Arc::new(TestStore::new());
+ let mgr = make_mission_manager(Arc::clone(&store) as Arc);
+ let project_id = ProjectId::new();
+
+ // Alice owns a mission.
+ create_unguarded_event_mission(&mgr, project_id, "alice", "Alice mission", "ping", None)
+ .await;
+
+ // Bob fires the event with a matching pattern — should NOT fire
+ // alice's mission (per-user scoping).
+ let spawned = mgr
+ .fire_on_message_event("gateway", "ping", "bob", None)
+ .await
+ .unwrap();
+ assert!(
+ spawned.is_empty(),
+ "events from other users must not fire missions they don't own"
+ );
+
+ // Alice fires the event — SHOULD fire her mission.
+ let spawned = mgr
+ .fire_on_message_event("gateway", "ping", "alice", None)
+ .await
+ .unwrap();
+ assert_eq!(spawned.len(), 1);
+ }
+
+ #[tokio::test]
+ async fn fire_on_webhook_matches_path() {
+ let store = Arc::new(TestStore::new());
+ let mgr = make_mission_manager(Arc::clone(&store) as Arc);
+ let project_id = ProjectId::new();
+
+ mgr.create_mission(
+ project_id,
+ "alice",
+ "GitHub webhook",
+ "Handle GitHub events",
+ MissionCadence::Webhook {
+ path: "github".into(),
+ secret: None,
+ },
+ Vec::new(),
+ )
+ .await
+ .unwrap();
+
+ // Wrong path — should NOT fire.
+ let spawned = mgr.fire_on_webhook("slack", "alice", None).await.unwrap();
+ assert!(spawned.is_empty());
+
+ // Right path — SHOULD fire.
+ let spawned = mgr
+ .fire_on_webhook(
+ "github",
+ "alice",
+ Some(serde_json::json!({"action": "opened"})),
+ )
+ .await
+ .unwrap();
+ assert_eq!(spawned.len(), 1);
+ }
+
+ /// Regression for the substring-match flooding bug:
+ /// `text.contains("review requested")` would match unrelated phrases
+ /// like "I just reviewed your request" — way too loose. The matcher
+ /// is now regex-based, so word-boundary-aware patterns no longer
+ /// flood on accidental substrings.
+ #[tokio::test]
+ async fn fire_on_message_event_uses_regex_with_word_boundaries() {
+ let store = Arc::new(TestStore::new());
+ let mgr = make_mission_manager(Arc::clone(&store) as Arc);
+ let project_id = ProjectId::new();
+
+ // Word-boundary regex for "deploy".
+ create_unguarded_event_mission(
+ &mgr,
+ project_id,
+ "alice",
+ "Deploy watcher",
+ r"\bdeploy\b",
+ None,
+ )
+ .await;
+
+ // Should NOT match: "deployed" / "deployment" / "redeploy".
+ for noisy in &[
+ "I just deployed the change",
+ "the deployment finished",
+ "going to redeploy later",
+ ] {
+ let spawned = mgr
+ .fire_on_message_event("gateway", noisy, "alice", None)
+ .await
+ .unwrap();
+ assert!(spawned.is_empty(), "regex with \\b must not match: {noisy}");
+ }
+
+ // SHOULD match: standalone "deploy".
+ let spawned = mgr
+ .fire_on_message_event("gateway", "please deploy now", "alice", None)
+ .await
+ .unwrap();
+ assert_eq!(spawned.len(), 1, "standalone 'deploy' must match");
+ }
+
+ /// Regression: an OnEvent mission created via `create_mission` without
+ /// explicit guardrails must inherit reactive defaults (cooldown 300s,
+ /// max_concurrent 1, daily cap 24) so accidentally-loose patterns
+ /// can't burn the LLM budget.
+ #[tokio::test]
+ async fn event_triggered_missions_get_reactive_defaults() {
+ let store = Arc::new(TestStore::new());
+ let mgr = make_mission_manager(Arc::clone(&store) as Arc);
+ let project_id = ProjectId::new();
+
+ let id = mgr
+ .create_mission(
+ project_id,
+ "alice",
+ "Default reactive mission",
+ "react",
+ MissionCadence::OnEvent {
+ event_pattern: "anything".into(),
+ channel: None,
+ },
+ Vec::new(),
+ )
+ .await
+ .unwrap();
+
+ let mission = mgr.get_mission(id).await.unwrap().unwrap();
+ assert_eq!(
+ mission.cooldown_secs, 300,
+ "OnEvent missions default to a 5-minute cooldown"
+ );
+ assert_eq!(
+ mission.max_concurrent, 1,
+ "OnEvent missions default to single-instance"
+ );
+ assert_eq!(
+ mission.max_threads_per_day, 24,
+ "OnEvent missions default to 24 fires/day"
+ );
+ }
+
+ /// Manual / Cron missions retain the prior generous defaults — they
+ /// are self-paced and don't risk flooding from external events.
+ #[tokio::test]
+ async fn manual_and_cron_missions_keep_proactive_defaults() {
+ let store = Arc::new(TestStore::new());
+ let mgr = make_mission_manager(Arc::clone(&store) as Arc);
+ let project_id = ProjectId::new();
+
+ let manual_id = mgr
+ .create_mission(
+ project_id,
+ "alice",
+ "manual",
+ "do it on demand",
+ MissionCadence::Manual,
+ Vec::new(),
+ )
+ .await
+ .unwrap();
+ let manual = mgr.get_mission(manual_id).await.unwrap().unwrap();
+ assert_eq!(manual.cooldown_secs, 0);
+ assert_eq!(manual.max_concurrent, 0);
+ assert_eq!(manual.max_threads_per_day, 10);
+
+ let cron_id = mgr
+ .create_mission(
+ project_id,
+ "alice",
+ "cron",
+ "every six hours",
+ MissionCadence::Cron {
+ expression: "0 */6 * * *".into(),
+ timezone: None,
+ },
+ Vec::new(),
+ )
+ .await
+ .unwrap();
+ let cron = mgr.get_mission(cron_id).await.unwrap().unwrap();
+ assert_eq!(cron.cooldown_secs, 0);
+ assert_eq!(cron.max_concurrent, 0);
+ assert_eq!(cron.max_threads_per_day, 10);
+ }
+
+ /// The per-user sliding-window rate limiter must refuse fires once
+ /// the cap is reached and recover after the window slides past.
+ #[tokio::test]
+ async fn per_user_rate_limit_blocks_excess_fires() {
+ let store = Arc::new(TestStore::new());
+ let mgr = make_mission_manager(Arc::clone(&store) as Arc).with_rate_limit(
+ FireRateLimit {
+ max_fires: 3,
+ window: std::time::Duration::from_secs(60),
+ },
+ );
+ let project_id = ProjectId::new();
+
+ create_unguarded_event_mission(
+ &mgr,
+ project_id,
+ "alice",
+ "rate-limited mission",
+ r"go",
+ None,
+ )
+ .await;
+
+ // First 3 fires should succeed; the 4th should be silently dropped.
+ for i in 0..3 {
+ let spawned = mgr
+ .fire_on_message_event("gateway", "go", "alice", None)
+ .await
+ .unwrap();
+ assert_eq!(spawned.len(), 1, "fire {i} should succeed");
+ }
+ let spawned = mgr
+ .fire_on_message_event("gateway", "go", "alice", None)
+ .await
+ .unwrap();
+ assert!(spawned.is_empty(), "rate-limited fire should be dropped");
+ }
+
+ /// `BudgetGate::allow_mission_fire` returning false must abort the
+ /// fire without spawning a thread or recording history.
+ #[tokio::test]
+ async fn budget_gate_can_refuse_mission_fires() {
+ struct DenyAll;
+ #[async_trait::async_trait]
+ impl BudgetGate for DenyAll {
+ async fn allow_mission_fire(&self, _user_id: &str, _mission_id: MissionId) -> bool {
+ false
+ }
+ }
+
+ let store = Arc::new(TestStore::new());
+ let mgr = make_mission_manager(Arc::clone(&store) as Arc)
+ .with_budget_gate(Arc::new(DenyAll));
+ let project_id = ProjectId::new();
+
+ let id =
+ create_unguarded_event_mission(&mgr, project_id, "alice", "blocked", r"go", None).await;
+
+ let spawned = mgr
+ .fire_on_message_event("gateway", "go", "alice", None)
+ .await
+ .unwrap();
+ assert!(spawned.is_empty(), "BudgetGate denial must block the fire");
+
+ let mission = mgr.get_mission(id).await.unwrap().unwrap();
+ assert!(
+ mission.thread_history.is_empty(),
+ "denied fire must not record any threads"
+ );
+ }
+
+ /// Updating a mission must evict its cached compiled regex so the next
+ /// match attempt picks up the new pattern.
+ #[tokio::test]
+ async fn updating_event_pattern_invalidates_regex_cache() {
+ let store = Arc::new(TestStore::new());
+ let mgr = make_mission_manager(Arc::clone(&store) as Arc);
+ let project_id = ProjectId::new();
+
+ let id =
+ create_unguarded_event_mission(&mgr, project_id, "alice", "swappable", r"alpha", None)
+ .await;
+
+ // Initial pattern matches "alpha".
+ let spawned = mgr
+ .fire_on_message_event("gateway", "alpha", "alice", None)
+ .await
+ .unwrap();
+ assert_eq!(spawned.len(), 1);
+
+ // Swap the cadence to a new pattern.
+ mgr.update_mission(
+ id,
+ "alice",
+ MissionUpdate {
+ cadence: Some(MissionCadence::OnEvent {
+ event_pattern: r"beta".into(),
+ channel: None,
+ }),
+ ..Default::default()
+ },
+ )
+ .await
+ .unwrap();
+
+ // The old pattern must no longer match.
+ let spawned = mgr
+ .fire_on_message_event("gateway", "alpha", "alice", None)
+ .await
+ .unwrap();
+ assert!(spawned.is_empty(), "stale regex cache must be evicted");
+
+ // The new pattern must match.
+ let spawned = mgr
+ .fire_on_message_event("gateway", "beta", "alice", None)
+ .await
+ .unwrap();
+ assert_eq!(spawned.len(), 1, "new pattern must take effect");
+ }
+
+ // ── routine-fix-history regression tests ─────────────────────────
+ //
+ // Tests in this section pin invariants whose v1 routine analogs were
+ // historically broken (or whose fix went into a v1 routine code path
+ // that has no v2 equivalent — we add them here to make sure missions
+ // never regress the same bug).
+
+ /// Mirrors v1 routine fix #1372 / #1374: a fired mission with
+ /// `max_concurrent = N` and N already-running threads must refuse to
+ /// fire again. The check is in `fire_mission` after the cooldown gate.
+ /// This test pins it through the public surface so a future refactor
+ /// can't drop the check without failing here.
+ #[tokio::test]
+ async fn fire_mission_blocks_when_max_concurrent_reached() {
+ use crate::types::thread::{Thread, ThreadConfig, ThreadType};
+ let store = Arc::new(TestStore::new());
+ let mgr = make_mission_manager(Arc::clone(&store) as Arc);
+ let project_id = ProjectId::new();
+
+ let id = mgr
+ .create_mission(
+ project_id,
+ "alice",
+ "single instance",
+ "do exactly one thing at a time",
+ MissionCadence::Manual,
+ Vec::new(),
+ )
+ .await
+ .unwrap();
+ // Set max_concurrent=1 explicitly (Manual missions default to 0).
+ mgr.update_mission(
+ id,
+ "alice",
+ MissionUpdate {
+ max_concurrent: Some(1),
+ ..Default::default()
+ },
+ )
+ .await
+ .unwrap();
+
+ // Pre-seed a Running thread for this mission so the next fire
+ // sees max_concurrent already saturated. ThreadType::Mission with
+ // the default `Created` state is non-terminal in
+ // count_running_threads (which only treats Done/Failed as
+ // terminal).
+ let thread = Thread::new(
+ "preseeded",
+ ThreadType::Mission,
+ project_id,
+ "alice",
+ ThreadConfig::default(),
+ );
+ let preseeded_id = thread.id;
+ store.save_thread(&thread).await.unwrap();
+ let mut mission = mgr.get_mission(id).await.unwrap().unwrap();
+ mission.thread_history.push(preseeded_id);
+ store.save_mission(&mission).await.unwrap();
+
+ // Fire — should be refused with Ok(None), not an error.
+ let outcome = mgr.fire_mission(id, "alice", None).await.unwrap();
+ assert!(
+ outcome.is_none(),
+ "max_concurrent=1 with one running thread must block the next fire"
+ );
+
+ // The mission's thread_history must NOT have grown.
+ let after = mgr.get_mission(id).await.unwrap().unwrap();
+ assert_eq!(
+ after.thread_history.len(),
+ 1,
+ "blocked fire must not record a new thread"
+ );
+ }
+
+ /// Mirrors v1 routine fix #1321: notification summaries must be
+ /// truncated before broadcasting so a runaway response can't OOM
+ /// chat-channel adapters or saturate SSE buffers. The full text stays
+ /// in `mission.approach_history` untruncated.
+ #[test]
+ fn truncate_notification_text_caps_long_strings() {
+ let huge = "x".repeat(MAX_NOTIFICATION_RESPONSE_BYTES * 3);
+ let truncated = truncate_notification_text(&huge);
+ assert!(
+ truncated.len() <= MAX_NOTIFICATION_RESPONSE_BYTES + 4,
+ "truncated text must fit within the cap (plus the ellipsis byte): got {}",
+ truncated.len()
+ );
+ assert!(
+ truncated.ends_with('…'),
+ "truncation must end with an ellipsis"
+ );
+
+ let small = "fits within the cap";
+ assert_eq!(
+ truncate_notification_text(small),
+ small,
+ "strings under the cap must pass through unchanged"
+ );
+ }
+
+ /// Mirrors v1 routine fix's `floor_char_boundary` change: truncation
+ /// MUST NOT split a multi-byte UTF-8 sequence. The naive approach
+ /// (`&s[..MAX]`) would panic on a multi-byte character that straddles
+ /// the byte index.
+ #[test]
+ fn truncate_notification_text_is_utf8_safe() {
+ // Construct a string where a multi-byte char straddles the byte cap.
+ // "ñ" is 2 bytes (0xC3 0xB1). We want byte position MAX_BYTES to
+ // land in the middle of one.
+ let prefix = "a".repeat(MAX_NOTIFICATION_RESPONSE_BYTES - 1);
+ let mut input = prefix;
+ input.push('ñ'); // 2 bytes — second byte is at MAX_BYTES
+ input.push_str(&"b".repeat(100));
+ assert!(input.len() > MAX_NOTIFICATION_RESPONSE_BYTES);
+
+ // Must not panic — the bug would slice a multi-byte char in half.
+ let truncated = truncate_notification_text(&input);
+ // And the result must be valid UTF-8 (it's a String, so by
+ // construction it is — but the assertion makes the invariant
+ // explicit).
+ assert!(truncated.is_char_boundary(truncated.len()));
+ // The 'ñ' must NOT have been split: either it's in the result
+ // wholly, or it was dropped wholly.
+ assert!(
+ !truncated.ends_with('a'),
+ "truncation should have stopped at the multi-byte char boundary, not after it"
+ );
+ }
+
+ /// Mirrors v1 routine fix #1255: when a mission is deleted (the v2
+ /// analog of `routine_delete`), its compiled regex cache entry MUST
+ /// be evicted so a future mission with the same id can't accidentally
+ /// pick up a stale pattern. This pins the eviction call already in
+ /// `complete_mission`.
+ #[tokio::test]
+ async fn complete_mission_evicts_event_regex_cache() {
+ let store = Arc::new(TestStore::new());
+ let mgr = make_mission_manager(Arc::clone(&store) as Arc);
+ let project_id = ProjectId::new();
+
+ let id = create_unguarded_event_mission(
+ &mgr,
+ project_id,
+ "alice",
+ "to be deleted",
+ r"hello",
+ None,
+ )
+ .await;
+
+ // Force regex compile + cache populate.
+ let _ = mgr
+ .fire_on_message_event("gateway", "hello", "alice", None)
+ .await
+ .unwrap();
+ assert!(
+ mgr.event_regex_cache.read().await.contains_key(&id),
+ "regex cache should hold the compiled pattern after first match"
+ );
+
+ mgr.complete_mission(id).await.unwrap();
+ assert!(
+ !mgr.event_regex_cache.read().await.contains_key(&id),
+ "complete_mission must evict the cached compiled regex"
+ );
+ }
+
+ /// Mirrors v1 routine fix #1374: failure-path outcomes must produce a
+ /// notification, not silently swallow the error. Without this, a
+ /// failed mission run leaves the user with no signal that anything
+ /// went wrong.
+ #[tokio::test]
+ async fn failed_outcome_emits_error_notification() {
+ let store = Arc::new(TestStore::new());
+ let mgr = make_mission_manager(Arc::clone(&store) as Arc);
+ let project_id = ProjectId::new();
+
+ let id = mgr
+ .create_mission(
+ project_id,
+ "alice",
+ "may fail",
+ "do the risky thing",
+ MissionCadence::Manual,
+ vec!["gateway".to_string()],
+ )
+ .await
+ .unwrap();
+
+ let mut rx = mgr.subscribe_notifications();
+
+ let synthetic_thread_id = crate::types::thread::ThreadId::new();
+ process_mission_outcome_and_notify(
+ &(Arc::clone(&store) as Arc),
+ id,
+ synthetic_thread_id,
+ &ThreadOutcome::Failed {
+ error: "container exited 137".into(),
+ },
+ mgr.notification_tx_for_test(),
+ None,
+ )
+ .await
+ .unwrap();
+
+ let notification = rx
+ .try_recv()
+ .expect("Failed outcome must emit a notification");
+ assert!(notification.is_error, "is_error flag must be set");
+ assert_eq!(notification.notify_channels, vec!["gateway".to_string()]);
+ assert!(
+ notification
+ .response
+ .as_deref()
+ .is_some_and(|r| r.contains("container exited 137")),
+ "notification response must surface the underlying error message; got {:?}",
+ notification.response
+ );
+
+ // Same for MaxIterations — historically the silent-fail case.
+ process_mission_outcome_and_notify(
+ &(Arc::clone(&store) as Arc),
+ id,
+ synthetic_thread_id,
+ &ThreadOutcome::MaxIterations,
+ mgr.notification_tx_for_test(),
+ None,
+ )
+ .await
+ .unwrap();
+
+ let notification = rx
+ .try_recv()
+ .expect("MaxIterations must emit a notification");
+ assert!(notification.is_error, "MaxIterations must set is_error");
+ }
+
+ #[tokio::test]
+ async fn outcome_processor_reconciles_missing_fire_accounting() {
+ // Regression: when `fire_mission`'s post-spawn `save_mission` fails,
+ // the persisted mission is missing the new thread_id, threads_today
+ // bump, last_fire_at stamp, and (for cron) advanced next_fire_at.
+ // The outcome processor must reconcile these fields the next time
+ // it runs so the durable state catches up — otherwise tick re-fires
+ // against the stale schedule once the in-memory cooldown elapses.
+ let store = Arc::new(TestStore::new());
+ let mgr = make_mission_manager(Arc::clone(&store) as Arc);
+ let project_id = ProjectId::new();
+
+ // Create a cron mission with `next_fire_at` already in the past, to
+ // mimic the post-failed-fire state directly. (Going through
+ // `fire_mission` with a fault-injecting store would require a new
+ // TestStore variant; this is the equivalent end state.)
+ let id = mgr
+ .create_mission(
+ project_id,
+ "alice",
+ "reconcile-test",
+ "g",
+ MissionCadence::Cron {
+ expression: "* * * * *".into(),
+ timezone: None,
+ },
+ vec![],
+ )
+ .await
+ .unwrap();
+ {
+ let mut missions = store.missions.write().await;
+ let mission = missions.get_mut(&id).unwrap();
+ mission.next_fire_at = Some(chrono::Utc::now() - chrono::Duration::seconds(120));
+ mission.threads_today = 0;
+ mission.thread_history.clear();
+ mission.last_fire_at = None;
+ }
+
+ // Run the outcome processor with a thread_id that the persisted
+ // mission has never seen — exactly the state a failed `save_mission`
+ // would leave us in.
+ let orphan_thread_id = crate::types::thread::ThreadId::new();
+ // Pass an explicit `original_fire_at` so the test exercises the
+ // production-equivalent path where fire_mission's instant flows
+ // through the watcher into reconcile (instead of falling back to
+ // `now`).
+ let original_fire_at = chrono::Utc::now() - chrono::Duration::seconds(30);
+ process_mission_outcome_and_notify(
+ &(Arc::clone(&store) as Arc),
+ id,
+ orphan_thread_id,
+ &ThreadOutcome::Completed {
+ response: Some("done".into()),
+ },
+ mgr.notification_tx_for_test(),
+ Some(original_fire_at),
+ )
+ .await
+ .unwrap();
+
+ let reloaded = mgr.get_mission(id).await.unwrap().unwrap();
+ assert!(
+ reloaded.thread_history.contains(&orphan_thread_id),
+ "outcome processor must idempotently append the missing thread_id"
+ );
+ assert_eq!(
+ reloaded.threads_today, 1,
+ "threads_today must catch up after reconcile"
+ );
+ assert_eq!(
+ reloaded.last_fire_at,
+ Some(original_fire_at),
+ "last_fire_at must be reconciled to the original fire instant, not `now`"
+ );
+ assert!(
+ reloaded
+ .next_fire_at
+ .is_some_and(|next| next > chrono::Utc::now()),
+ "next_fire_at must be advanced past now() after reconcile, got {:?}",
+ reloaded.next_fire_at
+ );
+
+ // Reconcile is idempotent: replaying with the same thread_id must
+ // not double-count threads_today or duplicate the history entry.
+ process_mission_outcome_and_notify(
+ &(Arc::clone(&store) as Arc),
+ id,
+ orphan_thread_id,
+ &ThreadOutcome::Completed {
+ response: Some("done".into()),
+ },
+ mgr.notification_tx_for_test(),
+ Some(original_fire_at),
+ )
+ .await
+ .unwrap();
+ let reloaded = mgr.get_mission(id).await.unwrap().unwrap();
+ assert_eq!(
+ reloaded
+ .thread_history
+ .iter()
+ .filter(|t| **t == orphan_thread_id)
+ .count(),
+ 1,
+ "thread_history must not duplicate on replay"
+ );
+ assert_eq!(
+ reloaded.threads_today, 1,
+ "threads_today must not double-count on replay"
+ );
+ }
+
+ /// A pattern that fails to compile (or exceeds the size cap) must be
+ /// logged and never match — it must not panic, hang, or fall through
+ /// to a substring search.
+ #[tokio::test]
+ async fn invalid_event_regex_never_matches() {
+ let store = Arc::new(TestStore::new());
+ let mgr = make_mission_manager(Arc::clone(&store) as Arc);
+ let project_id = ProjectId::new();
+
+ // `[` is not a valid regex; compilation must fail.
+ create_unguarded_event_mission(&mgr, project_id, "alice", "broken pattern", "[", None)
+ .await;
+
+ let spawned = mgr
+ .fire_on_message_event("gateway", "anything", "alice", None)
+ .await
+ .unwrap();
+ assert!(spawned.is_empty(), "invalid regex must not match anything");
+ }
+
+ #[tokio::test]
+ async fn ensure_learning_missions_idempotent_per_user() {
+ let store = Arc::new(TestStore::new());
+ let mgr = make_mission_manager(Arc::clone(&store) as Arc);
+ let project_id = ProjectId::new();
+
+ // Call twice for the same user
+ mgr.ensure_learning_missions(project_id, "alice")
+ .await
+ .unwrap();
+ mgr.ensure_learning_missions(project_id, "alice")
+ .await
+ .unwrap();
+
+ // Should not create duplicates
+ let alice_missions = store.list_missions(project_id, "alice").await.unwrap();
+ let self_imp_count = alice_missions
+ .iter()
+ .filter(|m| is_self_improvement_mission(m))
+ .count();
+ assert_eq!(
+ self_imp_count, 1,
+ "should not duplicate self-improvement mission"
+ );
+ }
+
+ // ── Cron scheduling tests (#1944) ─────────────────────────
+
+ #[tokio::test]
+ async fn create_cron_mission_sets_next_fire_at() {
+ let store = Arc::new(TestStore::new());
+ let mgr = make_mission_manager(Arc::clone(&store) as Arc);
+ let project_id = ProjectId::new();
+
+ let id = mgr
+ .create_mission(
+ project_id,
+ "test-user",
+ "cron test",
+ "periodic goal",
+ MissionCadence::Cron {
+ expression: "0 */6 * * *".into(),
+ timezone: None,
+ },
+ Vec::new(),
+ )
+ .await
+ .unwrap();
+
+ let mission = mgr.get_mission(id).await.unwrap().unwrap();
+ assert!(
+ mission.next_fire_at.is_some(),
+ "cron mission should have next_fire_at computed on creation"
+ );
+ assert!(
+ mission.next_fire_at.unwrap() > chrono::Utc::now(),
+ "next_fire_at should be in the future"
+ );
+ }
+
+ #[tokio::test]
+ async fn create_manual_mission_has_no_next_fire_at() {
+ let store = Arc::new(TestStore::new());
+ let mgr = make_mission_manager(Arc::clone(&store) as Arc);
+ let project_id = ProjectId::new();
+
+ let id = mgr
+ .create_mission(
+ project_id,
+ "test-user",
+ "manual test",
+ "goal",
+ MissionCadence::Manual,
+ Vec::new(),
+ )
+ .await
+ .unwrap();
+
+ let mission = mgr.get_mission(id).await.unwrap().unwrap();
+ assert!(
+ mission.next_fire_at.is_none(),
+ "manual mission should not have next_fire_at"
+ );
+ }
+
+ #[tokio::test]
+ async fn fire_mission_advances_next_fire_at() {
+ let store = Arc::new(TestStore::new());
+ let mgr = make_mission_manager(Arc::clone(&store) as Arc);
+ let project_id = ProjectId::new();
+
+ let id = mgr
+ .create_mission(
+ project_id,
+ "test-user",
+ "cron advance",
+ "periodic goal",
+ MissionCadence::Cron {
+ expression: "* * * * *".into(),
+ timezone: None,
+ },
+ Vec::new(),
+ )
+ .await
+ .unwrap();
+
+ // Move next_fire_at to the past so tick fires it
+ {
+ let mut missions = store.missions.write().await;
+ if let Some(mission) = missions.get_mut(&id) {
+ mission.next_fire_at = Some(chrono::Utc::now() - chrono::Duration::seconds(60));
+ }
+ }
+
+ let spawned = mgr.tick("test-user").await.unwrap();
+ assert_eq!(spawned.len(), 1);
+
+ // After firing, next_fire_at should be advanced to the future
+ let mission = mgr.get_mission(id).await.unwrap().unwrap();
+ assert!(
+ mission.next_fire_at.is_some(),
+ "next_fire_at should be set after firing"
+ );
+ assert!(
+ mission.next_fire_at.unwrap() > chrono::Utc::now(),
+ "next_fire_at should be strictly in the future after firing"
+ );
+ }
+
+ #[tokio::test]
+ async fn resume_cron_mission_recomputes_next_fire_at() {
+ let store = Arc::new(TestStore::new());
+ let mgr = make_mission_manager(Arc::clone(&store) as Arc);
+ let project_id = ProjectId::new();
+
+ let id = mgr
+ .create_mission(
+ project_id,
+ "test-user",
+ "cron resume",
+ "periodic goal",
+ MissionCadence::Cron {
+ expression: "0 */6 * * *".into(),
+ timezone: None,
+ },
+ Vec::new(),
+ )
+ .await
+ .unwrap();
+
+ // Pause the mission — this clears it from active list
+ mgr.pause_mission(id, "test-user").await.unwrap();
+
+ // Manually set next_fire_at to a stale past value
+ {
+ let mut missions = store.missions.write().await;
+ if let Some(mission) = missions.get_mut(&id) {
+ mission.next_fire_at = Some(chrono::Utc::now() - chrono::Duration::hours(24));
+ }
+ }
+
+ // Resume — should recompute next_fire_at
+ mgr.resume_mission(id, "test-user").await.unwrap();
+
+ let mission = mgr.get_mission(id).await.unwrap().unwrap();
+ assert!(
+ mission.next_fire_at.is_some(),
+ "resume should recompute next_fire_at for cron missions"
+ );
+ assert!(
+ mission.next_fire_at.unwrap() > chrono::Utc::now(),
+ "recomputed next_fire_at should be in the future"
+ );
+ }
+
+ #[tokio::test]
+ async fn update_mission_manual_to_cron_sets_next_fire_at() {
+ // Regression: a Manual -> Cron switch left next_fire_at = None and the
+ // mission never fired. update_mission must recompute the schedule.
+ let store = Arc::new(TestStore::new());
+ let mgr = make_mission_manager(Arc::clone(&store) as Arc);
+ let project_id = ProjectId::new();
+
+ let id = mgr
+ .create_mission(
+ project_id,
+ "test-user",
+ "starts manual",
+ "goal",
+ MissionCadence::Manual,
+ Vec::new(),
+ )
+ .await
+ .unwrap();
+
+ let mission = mgr.get_mission(id).await.unwrap().unwrap();
+ assert!(mission.next_fire_at.is_none());
+
+ mgr.update_mission(
+ id,
+ "test-user",
+ MissionUpdate {
+ cadence: Some(MissionCadence::Cron {
+ expression: "0 */6 * * *".into(),
+ timezone: None,
+ }),
+ ..Default::default()
+ },
+ )
+ .await
+ .unwrap();
+
+ let mission = mgr.get_mission(id).await.unwrap().unwrap();
+ assert!(
+ mission.next_fire_at.is_some(),
+ "Manual -> Cron update should compute next_fire_at"
+ );
+ assert!(
+ mission.next_fire_at.unwrap() > chrono::Utc::now(),
+ "next_fire_at should be in the future"
+ );
+ }
+
+ #[tokio::test]
+ async fn update_mission_cron_to_manual_clears_next_fire_at() {
+ // Regression: a stale next_fire_at must be cleared when switching away
+ // from Cron, otherwise the ticker could fire a non-cron mission.
+ let store = Arc::new(TestStore::new());
+ let mgr = make_mission_manager(Arc::clone(&store) as Arc);
+ let project_id = ProjectId::new();
+
+ let id = mgr
+ .create_mission(
+ project_id,
+ "test-user",
+ "starts cron",
+ "goal",
+ MissionCadence::Cron {
+ expression: "0 */6 * * *".into(),
+ timezone: None,
+ },
+ Vec::new(),
+ )
+ .await
+ .unwrap();
+ assert!(
+ mgr.get_mission(id)
+ .await
+ .unwrap()
+ .unwrap()
+ .next_fire_at
+ .is_some()
+ );
+
+ mgr.update_mission(
+ id,
+ "test-user",
+ MissionUpdate {
+ cadence: Some(MissionCadence::Manual),
+ ..Default::default()
+ },
+ )
+ .await
+ .unwrap();
+
+ let mission = mgr.get_mission(id).await.unwrap().unwrap();
+ assert!(
+ mission.next_fire_at.is_none(),
+ "non-cron cadence must clear next_fire_at"
+ );
+ }
+
+ #[tokio::test]
+ async fn create_cron_mission_with_timezone_uses_tz_for_schedule() {
+ // Regression: every other cron test in this file passes timezone: None,
+ // so the tz path is only exercised at the unit level inside types/mission.
+ // This test threads a real ValidTimezone through MissionManager and
+ // asserts the resulting next_fire_at differs from the UTC equivalent —
+ // proving the bridge router → mission_create → next_cron_fire chain
+ // actually honors the user's timezone end-to-end.
+ let store = Arc::new(TestStore::new());
+ let mgr = make_mission_manager(Arc::clone(&store) as Arc);
+ let project_id = ProjectId::new();
+ let tz = crate::types::mission::ValidTimezone::parse("America/New_York").unwrap();
+
+ let id_tz = mgr
+ .create_mission(
+ project_id,
+ "test-user",
+ "tz-aware",
+ "fires at 9am NY local",
+ MissionCadence::Cron {
+ expression: "0 9 * * *".into(),
+ timezone: Some(tz),
+ },
+ Vec::new(),
+ )
+ .await
+ .unwrap();
+
+ let id_utc = mgr
+ .create_mission(
+ project_id,
+ "test-user",
+ "tz-naive",
+ "fires at 9am UTC",
+ MissionCadence::Cron {
+ expression: "0 9 * * *".into(),
+ timezone: None,
+ },
+ Vec::new(),
+ )
+ .await
+ .unwrap();
+
+ let m_tz = mgr.get_mission(id_tz).await.unwrap().unwrap();
+ let m_utc = mgr.get_mission(id_utc).await.unwrap().unwrap();
+ let next_tz = m_tz.next_fire_at.expect("tz cron should have next_fire_at");
+ let next_utc = m_utc
+ .next_fire_at
+ .expect("utc cron should have next_fire_at");
+
+ // 9am NY = 13:00 or 14:00 UTC depending on DST; 9am UTC = 09:00 UTC.
+ use chrono::Timelike;
+ assert_ne!(
+ next_tz.hour(),
+ next_utc.hour(),
+ "tz-aware and tz-naive cron schedules must produce different UTC instants"
+ );
+ let tz_hour = next_tz.hour();
+ assert!(
+ tz_hour == 13 || tz_hour == 14,
+ "9am NY should land on UTC 13 or 14, got {tz_hour}"
+ );
+ assert_eq!(next_utc.hour(), 9, "9am UTC should land on UTC 9");
+ }
+
+ #[tokio::test]
+ async fn update_mission_cron_expression_change_recomputes_next_fire_at() {
+ // Regression: changing the cron expression must reset the schedule.
+ let store = Arc::new(TestStore::new());
+ let mgr = make_mission_manager(Arc::clone(&store) as Arc);
+ let project_id = ProjectId::new();
+
+ let id = mgr
+ .create_mission(
+ project_id,
+ "test-user",
+ "cron edit",
+ "goal",
+ MissionCadence::Cron {
+ // Year-locked to 2099 so the next fire is deterministically
+ // far in the future regardless of the calendar date the
+ // test runs on. The original `0 0 1 1 *` ("once a year on
+ // Jan 1") was racy around New Year's, when the yearly
+ // schedule's next fire could land within seconds and
+ // invert the `after < before` ordering below.
+ expression: "0 0 0 1 1 * 2099".into(),
+ timezone: None,
+ },
+ Vec::new(),
+ )
+ .await
+ .unwrap();
+ let before = mgr.get_mission(id).await.unwrap().unwrap().next_fire_at;
+
+ mgr.update_mission(
+ id,
+ "test-user",
+ MissionUpdate {
+ cadence: Some(MissionCadence::Cron {
+ expression: "* * * * *".into(), // every minute
+ timezone: None,
+ }),
+ ..Default::default()
+ },
+ )
+ .await
+ .unwrap();
+
+ let after = mgr.get_mission(id).await.unwrap().unwrap().next_fire_at;
+ assert!(after.is_some());
+ assert_ne!(
+ before, after,
+ "schedule must be recomputed on cadence change"
+ );
+ assert!(
+ after.unwrap() < before.unwrap(),
+ "every-minute schedule should fire sooner than once-a-year"
+ );
+ }
+
+ #[tokio::test]
+ async fn fire_mission_with_corrupt_cron_expression_does_not_orphan_thread() {
+ // Regression: previously fire_mission used `?` on next_cron_fire after
+ // spawning the thread. A persisted mission with a corrupt cron string
+ // would spawn the thread, then abort fire_mission with an Err — leaving
+ // the thread running with no entry in thread_history, no incremented
+ // budget, and (when also reordered) no outcome watcher installed.
+ let store = Arc::new(TestStore::new());
+ let mgr = make_mission_manager(Arc::clone(&store) as Arc);
+ let project_id = ProjectId::new();
+
+ let id = mgr
+ .create_mission(
+ project_id,
+ "test-user",
+ "corrupt cron",
+ "goal",
+ MissionCadence::Cron {
+ expression: "0 */6 * * *".into(),
+ timezone: None,
+ },
+ Vec::new(),
+ )
+ .await
+ .unwrap();
+
+ // Capture the original next_fire_at — we expect fire to *preserve* it
+ // (rather than replace with None or recompute) when the expression
+ // can't be parsed.
+ let original_next = mgr
+ .get_mission(id)
+ .await
+ .unwrap()
+ .unwrap()
+ .next_fire_at
+ .expect("create should populate next_fire_at");
+
+ // Corrupt the persisted expression directly in the test store.
+ {
+ let mut missions = store.missions.write().await;
+ if let Some(m) = missions.get_mut(&id)
+ && let MissionCadence::Cron {
+ ref mut expression, ..
+ } = m.cadence
+ {
+ *expression = "this is not a cron".to_string();
+ }
+ }
+
+ // Fire must succeed despite the corrupt expression.
+ let thread_id = mgr
+ .fire_mission(id, "test-user", None)
+ .await
+ .expect("fire_mission must not fail on corrupt cron");
+ assert!(thread_id.is_some(), "fire should spawn a thread");
+ let thread_id = thread_id.unwrap();
+
+ // The mission record must reflect the fire: thread tracked + budget
+ // incremented. Without the fix, save_mission was never reached.
+ let mission = mgr.get_mission(id).await.unwrap().unwrap();
+ assert!(
+ mission.thread_history.contains(&thread_id),
+ "thread should be recorded in thread_history"
+ );
+ assert_eq!(
+ mission.threads_today, 1,
+ "threads_today should be incremented even if next_fire_at couldn't recompute"
+ );
+ // next_fire_at should be preserved (not cleared) since we couldn't
+ // compute a new one.
+ assert_eq!(
+ mission.next_fire_at,
+ Some(original_next),
+ "next_fire_at must be preserved when next_cron_fire fails"
+ );
+ }
+
+ #[tokio::test]
+ async fn resume_mission_preserves_concurrent_field_changes() {
+ // Regression: resume_mission used to do update_mission_status() then a
+ // separate load+save round-trip to recompute next_fire_at. Now it does
+ // a single mutate-and-save with the mission already loaded for the
+ // ownership check, eliminating the extra interleave window.
+ //
+ // We can't deterministically exercise the TOCTOU window in a unit
+ // test, but we can assert the new contract: resume_mission writes the
+ // mission's other fields (e.g. threads_today) faithfully and does not
+ // depend on a separate update_mission_status round-trip succeeding.
+ let store = Arc::new(TestStore::new());
+ let mgr = make_mission_manager(Arc::clone(&store) as Arc);
+ let project_id = ProjectId::new();
+
+ let id = mgr
+ .create_mission(
+ project_id,
+ "test-user",
+ "resume preserve",
+ "goal",
+ MissionCadence::Cron {
+ expression: "0 */6 * * *".into(),
+ timezone: None,
+ },
+ Vec::new(),
+ )
+ .await
+ .unwrap();
+
+ mgr.pause_mission(id, "test-user").await.unwrap();
+
+ // Simulate a concurrent writer that bumps threads_today between pause
+ // and resume. With the old two-write resume path, the second
+ // load+save could clobber this. With the single-save path it cannot
+ // be clobbered by THIS resume call.
+ {
+ let mut missions = store.missions.write().await;
+ if let Some(m) = missions.get_mut(&id) {
+ m.threads_today = 7;
+ m.goal = "concurrently updated goal".to_string();
+ }
+ }
+
+ mgr.resume_mission(id, "test-user").await.unwrap();
+
+ let mission = mgr.get_mission(id).await.unwrap().unwrap();
+ assert_eq!(mission.status, MissionStatus::Active);
+ // The concurrent update happened *before* resume_mission's load, so
+ // the resume should observe and preserve those values rather than
+ // resetting to creation-time defaults.
+ assert_eq!(
+ mission.threads_today, 7,
+ "resume must not reset threads_today to a stale value"
+ );
+ assert_eq!(
+ mission.goal, "concurrently updated goal",
+ "resume must not clobber goal updated before its load"
+ );
+ assert!(
+ mission.next_fire_at.is_some(),
+ "resume should still recompute next_fire_at for cron"
+ );
+ }
+
+ #[tokio::test]
+ async fn ensure_mission_by_metadata_with_cron_cadence_computes_next_fire_at() {
+ // Regression: ensure_mission_by_metadata used to construct
+ // Mission::new + save_mission directly, bypassing the next_fire_at
+ // computation that create_mission performs. Today every caller passes
+ // OnSystemEvent so the bug is latent, but a future caller passing
+ // Cron would silently re-introduce the original `next_fire_at = None`
+ // bug that #1944 fixes.
+ let store = Arc::new(TestStore::new());
+ let mgr = make_mission_manager(Arc::clone(&store) as Arc);
+ let project_id = ProjectId::new();
+
+ let id = mgr
+ .ensure_mission_by_metadata(
+ project_id,
+ "test-user",
+ "synthetic_cron",
+ "synthetic-cron",
+ "synthetic goal",
+ MissionCadence::Cron {
+ expression: "0 9 * * *".into(),
+ timezone: None,
+ },
+ "synthetic criteria",
+ 3,
+ )
+ .await
+ .unwrap();
+
+ let mission = mgr.get_mission(id).await.unwrap().unwrap();
+ assert!(
+ mission.next_fire_at.is_some(),
+ "ensure_mission_by_metadata must compute next_fire_at for Cron cadence"
+ );
+ assert!(
+ mission.next_fire_at.unwrap() > chrono::Utc::now(),
+ "next_fire_at must be in the future"
+ );
+ }
+
+ #[test]
+ fn conversation_insights_count_only_done_threads() {
+ assert!(should_count_for_conversation_insights(ThreadState::Done));
+ assert!(!should_count_for_conversation_insights(ThreadState::Failed));
+ }
+
+ #[test]
+ fn build_skill_gap_payload_uses_active_skill_provenance() {
+ let project_id = ProjectId::new();
+ let mut thread = Thread::new(
+ "repair a github workflow",
+ ThreadType::Foreground,
+ project_id,
+ "alice",
+ ThreadConfig::default(),
+ );
+ thread.state = ThreadState::Done;
+ let skill_doc_id = DocId::new();
+ thread
+ .set_active_skills(&[ActiveSkillProvenance {
+ doc_id: skill_doc_id,
+ name: "github-pr-workflow".to_string(),
+ version: 3,
+ snippet_names: vec!["list_prs".to_string()],
+ force_activated: false,
+ }])
+ .unwrap();
+ thread.add_event(crate::types::event::EventKind::ActionFailed {
+ step_id: StepId::new(),
+ action_name: "shell".to_string(),
+ call_id: "call_1".to_string(),
+ error: "gh auth status: not authenticated".to_string(),
+ params_summary: None,
+ });
+
+ let trace = crate::executor::trace::build_trace(&thread);
+ let active_skills = thread.active_skills();
+ let payload = build_skill_gap_payload(&thread, &trace, &active_skills).unwrap();
+
+ assert_eq!(
+ payload["active_skills"][0]["doc_id"],
+ serde_json::Value::String(skill_doc_id.0.to_string())
+ );
+ let hints = payload["repair_hints"].as_array().unwrap();
+ assert!(
+ hints
+ .iter()
+ .any(|hint| hint.as_str() == Some("missing_prerequisite")),
+ "repair hints should include missing_prerequisite: {payload}"
+ );
+ }
+
+ #[test]
+ fn build_skill_gap_payload_preserves_recoverable_auth_prerequisite_hints() {
+ let project_id = ProjectId::new();
+ let mut thread = Thread::new(
+ "repair a github workflow",
+ ThreadType::Foreground,
+ project_id,
+ "alice",
+ ThreadConfig::default(),
+ );
+ thread.state = ThreadState::Done;
+ thread
+ .set_active_skills(&[ActiveSkillProvenance {
+ doc_id: DocId::new(),
+ name: "github-pr-workflow".to_string(),
+ version: 3,
+ snippet_names: vec![],
+ force_activated: false,
+ }])
+ .unwrap();
+ thread.add_event(crate::types::event::EventKind::ActionFailed {
+ step_id: StepId::new(),
+ action_name: "shell".to_string(),
+ call_id: "call_1".to_string(),
+ error: "authentication required for credential github".to_string(),
+ params_summary: None,
+ });
+
+ let trace = crate::executor::trace::build_trace(&thread);
+ let payload = build_skill_gap_payload(&thread, &trace, &thread.active_skills()).unwrap();
+ let hints = payload["repair_hints"].as_array().unwrap();
+
+ assert!(
+ hints
+ .iter()
+ .any(|hint| hint.as_str() == Some("missing_prerequisite")),
+ "recoverable auth failures should still produce missing_prerequisite: {payload}"
+ );
+ }
+
+ #[test]
+ fn learning_terminal_state_accepts_failed_threads() {
+ let failed_event = crate::types::event::EventKind::StateChanged {
+ from: ThreadState::Running,
+ to: ThreadState::Failed,
+ reason: Some("boom".into()),
+ };
+ assert_eq!(
+ learning_terminal_state(&failed_event),
+ Some(ThreadState::Failed)
+ );
+
+ let done_event = crate::types::event::EventKind::StateChanged {
+ from: ThreadState::Completed,
+ to: ThreadState::Done,
+ reason: None,
+ };
+ assert_eq!(
+ learning_terminal_state(&done_event),
+ Some(ThreadState::Done)
+ );
+ }
+
+ #[test]
+ fn thread_completed_successfully_requires_done_without_action_failures() {
+ let project_id = ProjectId::new();
+
+ let mut clean_thread = Thread::new(
+ "clean success",
+ ThreadType::Foreground,
+ project_id,
+ "alice",
+ ThreadConfig::default(),
+ );
+ clean_thread.state = ThreadState::Done;
+ let clean_trace = crate::executor::trace::build_trace(&clean_thread);
+ assert!(thread_completed_successfully(&clean_thread, &clean_trace));
+
+ let mut failing_thread = Thread::new(
+ "tool failure",
+ ThreadType::Foreground,
+ project_id,
+ "alice",
+ ThreadConfig::default(),
+ );
+ failing_thread.state = ThreadState::Done;
+ failing_thread.add_event(crate::types::event::EventKind::ActionFailed {
+ step_id: StepId::new(),
+ action_name: "shell".to_string(),
+ call_id: "call_1".to_string(),
+ error: "gh auth status: not authenticated".to_string(),
+ params_summary: Some("gh auth status".to_string()),
+ });
+ let failing_trace = crate::executor::trace::build_trace(&failing_thread);
+ assert!(!thread_completed_successfully(
+ &failing_thread,
+ &failing_trace
+ ));
+ }
+
+ #[tokio::test]
+ async fn process_skill_repair_output_updates_skill_and_records_repair() {
+ let store = Arc::new(TestStore::new());
+ let project_id = ProjectId::new();
+ let skill_doc = make_skill_doc(project_id, "alice", "github-pr-workflow");
+ let skill_doc_id = skill_doc.id;
+ store.save_memory_doc(&skill_doc).await.unwrap();
+
+ let mut mission = Mission::new(
+ project_id,
+ "alice",
+ "skill-repair",
+ SKILL_REPAIR_GOAL,
+ MissionCadence::Manual,
+ );
+ mission.metadata = serde_json::json!({"skill_repair": true});
+ mission.last_trigger_payload = Some(serde_json::json!({
+ "source_thread_id": "thread-123",
+ "active_skills": [{
+ "doc_id": skill_doc_id,
+ "name": "github-pr-workflow",
+ "version": 1,
+ "snippet_names": [],
+ "force_activated": false
+ }]
+ }));
+
+ let response = serde_json::json!({
+ "doc_id": skill_doc_id,
+ "repair_type": "missing_verification",
+ "summary": "Added a smoke-test step after the gh command.",
+ "updated_content": "1. Run `gh auth status`\n2. Run the PR command\n3. Verify with `gh pr view`",
+ "description": "GitHub PR workflow with auth and verification",
+ })
+ .to_string();
+
+ process_skill_repair_output(&(store.clone() as Arc), &mission, &response)
+ .await
+ .unwrap();
+
+ let updated = store.load_memory_doc(skill_doc_id).await.unwrap().unwrap();
+ let meta: V2SkillMetadata = serde_json::from_value(updated.metadata).unwrap();
+ assert_eq!(meta.version, 2);
+ assert_eq!(meta.parent_version, Some(1));
+ assert_eq!(
+ updated.content,
+ "1. Run `gh auth status`\n2. Run the PR command\n3. Verify with `gh pr view`"
+ );
+ assert_eq!(meta.repairs.len(), 1);
+ assert_eq!(
+ meta.repairs[0].repair_type,
+ SkillRepairType::MissingVerification
+ );
+ assert_eq!(
+ meta.repairs[0].source_thread_id.as_deref(),
+ Some("thread-123")
+ );
+ assert_eq!(meta.revisions.len(), 1);
+ assert_eq!(meta.revisions[0].content, "Original skill content");
+ }
+
+ #[tokio::test]
+ async fn process_skill_repair_output_rejects_stale_trigger_version() {
+ let store = Arc::new(TestStore::new());
+ let project_id = ProjectId::new();
+ let mut skill_doc = make_skill_doc(project_id, "alice", "github-pr-workflow");
+ let skill_doc_id = skill_doc.id;
+ skill_doc.content = "Skill content already updated to v2".to_string();
+ let mut meta: V2SkillMetadata = serde_json::from_value(skill_doc.metadata.clone()).unwrap();
+ meta.version = 2;
+ meta.parent_version = Some(1);
+ skill_doc.metadata = serde_json::to_value(&meta).unwrap();
+ store.save_memory_doc(&skill_doc).await.unwrap();
+
+ let mut mission = Mission::new(
+ project_id,
+ "alice",
+ "skill-repair",
+ SKILL_REPAIR_GOAL,
+ MissionCadence::Manual,
+ );
+ mission.metadata = serde_json::json!({"skill_repair": true});
+ mission.last_trigger_payload = Some(serde_json::json!({
+ "source_thread_id": "thread-123",
+ "active_skills": [{
+ "doc_id": skill_doc_id,
+ "name": "github-pr-workflow",
+ "version": 1,
+ "snippet_names": [],
+ "force_activated": false
+ }]
+ }));
+
+ let response = serde_json::json!({
+ "doc_id": skill_doc_id,
+ "repair_type": "missing_verification",
+ "summary": "Stale repair output.",
+ "updated_content": "1. Run the stale command\n2. Verify it"
+ })
+ .to_string();
+
+ let err =
+ process_skill_repair_output(&(store.clone() as Arc), &mission, &response)
+ .await
+ .unwrap_err();
+ match err {
+ EngineError::Skill { reason } => assert!(
+ reason.contains("version conflict"),
+ "expected version conflict, got: {reason}"
+ ),
+ other => panic!("expected skill error, got: {other:?}"),
+ }
+
+ let updated = store.load_memory_doc(skill_doc_id).await.unwrap().unwrap();
+ let updated_meta: V2SkillMetadata = serde_json::from_value(updated.metadata).unwrap();
+ assert_eq!(updated.content, "Skill content already updated to v2");
+ assert_eq!(updated_meta.version, 2);
+ assert!(updated_meta.repairs.is_empty());
+ }
+
+ #[tokio::test]
+ async fn process_skill_repair_output_rejects_empty_content() {
+ let store = Arc::new(TestStore::new());
+ let project_id = ProjectId::new();
+ let skill_doc = make_skill_doc(project_id, "alice", "github-pr-workflow");
+ let skill_doc_id = skill_doc.id;
+ store.save_memory_doc(&skill_doc).await.unwrap();
+
+ let mut mission = Mission::new(
+ project_id,
+ "alice",
+ "skill-repair",
+ SKILL_REPAIR_GOAL,
+ MissionCadence::Manual,
+ );
+ mission.metadata = serde_json::json!({"skill_repair": true});
+ mission.last_trigger_payload = Some(serde_json::json!({
+ "source_thread_id": "thread-123",
+ "active_skills": [{
+ "doc_id": skill_doc_id,
+ "name": "github-pr-workflow",
+ "version": 1,
+ "snippet_names": [],
+ "force_activated": false
+ }]
+ }));
+
+ let response = serde_json::json!({
+ "doc_id": skill_doc_id,
+ "repair_type": "missing_verification",
+ "summary": "This should be rejected.",
+ "updated_content": " "
+ })
+ .to_string();
+
+ let err =
+ process_skill_repair_output(&(store.clone() as Arc), &mission, &response)
+ .await
+ .unwrap_err();
+ match err {
+ EngineError::Skill { reason } => assert!(
+ reason.contains("empty updated_content"),
+ "expected empty-content validation, got: {reason}"
+ ),
+ other => panic!("expected skill error, got: {other:?}"),
+ }
+
+ let updated = store.load_memory_doc(skill_doc_id).await.unwrap().unwrap();
+ let updated_meta: V2SkillMetadata = serde_json::from_value(updated.metadata).unwrap();
+ assert_eq!(updated.content, "Original skill content");
+ assert_eq!(updated_meta.version, 1);
+ assert!(updated_meta.repairs.is_empty());
+ }
+
+ #[tokio::test]
+ async fn process_skill_repair_output_rejects_shared_skill_updates() {
+ let store = Arc::new(TestStore::new());
+ let project_id = ProjectId::new();
+ let skill_doc = make_skill_doc(project_id, shared_owner_id(), "github-pr-workflow");
+ let skill_doc_id = skill_doc.id;
+ store.save_memory_doc(&skill_doc).await.unwrap();
+
+ let mut mission = Mission::new(
+ project_id,
+ "alice",
+ "skill-repair",
+ SKILL_REPAIR_GOAL,
+ MissionCadence::Manual,
+ );
+ mission.metadata = serde_json::json!({"skill_repair": true});
+ mission.last_trigger_payload = Some(serde_json::json!({
+ "source_thread_id": "thread-123",
+ "active_skills": [{
+ "doc_id": skill_doc_id,
+ "name": "github-pr-workflow",
+ "version": 1,
+ "snippet_names": [],
+ "force_activated": false
+ }]
+ }));
+
+ let response = serde_json::json!({
+ "doc_id": skill_doc_id,
+ "repair_type": "missing_verification",
+ "summary": "Attempted shared skill update.",
+ "updated_content": "1. Verify auth\n2. Run the command"
+ })
+ .to_string();
+
+ let err =
+ process_skill_repair_output(&(store.clone() as Arc), &mission, &response)
+ .await
+ .unwrap_err();
+ match err {
+ EngineError::AccessDenied { user_id, entity } => {
+ assert_eq!(user_id, "alice");
+ assert!(entity.contains(&skill_doc_id.0.to_string()));
+ }
+ other => panic!("expected access denied, got: {other:?}"),
+ }
+
+ let unchanged = store.load_memory_doc(skill_doc_id).await.unwrap().unwrap();
+ let meta: V2SkillMetadata = serde_json::from_value(unchanged.metadata).unwrap();
+ assert_eq!(unchanged.content, "Original skill content");
+ assert_eq!(meta.version, 1);
+ assert!(meta.repairs.is_empty());
+ }
+
+ #[tokio::test]
+ async fn dedup_event_does_not_evict_entries_from_other_missions() {
+ // Regression for the cross-mission dedup window collision: a
+ // mission with a *short* window must not be able to evict a fresh
+ // entry belonging to a mission with a *longer* window.
+ let store = Arc::new(TestStore::new());
+ let mgr = make_mission_manager(Arc::clone(&store) as Arc);
+
+ let mission_a = MissionId::new();
+ let mission_b = MissionId::new();
+
+ // Mission B (long window) gets a stale entry for key "y" — set it
+ // 120 seconds in the past so a 60s-window check would consider it
+ // expired but a 3600s-window check still considers it fresh.
+ {
+ let mut table = mgr.dedup_table.write().await;
+ table.insert(
+ (mission_b, "y".to_string()),
+ chrono::Utc::now() - chrono::Duration::seconds(120),
+ );
+ }
+
+ // Mission A (short 60s window) fires for an unrelated key.
+ let first = mgr.dedup_event(mission_a, "x", 60).await;
+ assert!(!first, "first sighting of (A, x) should not be flagged");
+
+ // Mission B's entry must survive — its own window is 3600s, and
+ // 120s < 3600s, so the next dedup call from B for "y" should still
+ // see it as a duplicate.
+ let b_again = mgr.dedup_event(mission_b, "y", 3600).await;
+ assert!(
+ b_again,
+ "(B, y) is 120s old with a 3600s window — must still register as duplicate after A's call"
+ );
+ }
+
+ #[tokio::test]
+ async fn user_rate_slot_not_consumed_by_failed_fire() {
+ // Regression for the rate-limiter self-DoS: when fire_mission
+ // refuses (e.g. budget/concurrent gate) the per-user slot must
+ // remain available so sustained refusals don't lock the user out.
+ let store = Arc::new(TestStore::new());
+ let mgr = make_mission_manager(Arc::clone(&store) as Arc);
+
+ // Sanity: an empty window allows fires.
+ assert!(mgr.check_user_rate("alice").await);
+
+ // Drive a fire that's guaranteed to early-out before record_user_rate.
+ // The simplest deterministic refusal is `fire_mission` against a
+ // mission whose owner doesn't match — that returns AccessDenied
+ // before reaching the rate check, so it doesn't exercise the
+ // rate-limit path. Instead, drive `record_user_rate` and
+ // `check_user_rate` directly to pin the contract: a check that
+ // doesn't get followed by a record leaves the slot free.
+ let allowed_before = mgr.check_user_rate("alice").await;
+ assert!(allowed_before);
+
+ // Snapshot the queue size — must be unchanged after a check-only.
+ let snapshot_after_check = {
+ let log = mgr.user_fire_log.read().await;
+ log.get("alice").map(|q| q.len()).unwrap_or(0)
+ };
+ assert_eq!(
+ snapshot_after_check, 0,
+ "check_user_rate must not consume a slot on its own"
+ );
+
+ // After a successful fire would have called record_user_rate the
+ // queue grows by exactly one.
+ mgr.record_user_rate("alice").await;
+ let snapshot_after_record = {
+ let log = mgr.user_fire_log.read().await;
+ log.get("alice").map(|q| q.len()).unwrap_or(0)
+ };
+ assert_eq!(
+ snapshot_after_record, 1,
+ "record_user_rate must append exactly one entry"
+ );
+ }
+
+ #[test]
+ fn build_skill_gap_payload_skips_read_only_shell_workflows() {
+ let project_id = ProjectId::new();
+ let mut thread = Thread::new(
+ "inspect github pull requests",
+ ThreadType::Foreground,
+ project_id,
+ "alice",
+ ThreadConfig::default(),
+ );
+ thread.state = ThreadState::Done;
+ thread
+ .set_active_skills(&[ActiveSkillProvenance {
+ doc_id: DocId::new(),
+ name: "github-pr-workflow".to_string(),
+ version: 1,
+ snippet_names: vec![],
+ force_activated: false,
+ }])
+ .unwrap();
+ thread.add_event(crate::types::event::EventKind::ActionExecuted {
+ step_id: StepId::new(),
+ action_name: "shell".to_string(),
+ call_id: "call_1".to_string(),
+ params_summary: Some("gh pr list --repo nearai/ironclaw".to_string()),
+ duration_ms: 15,
+ });
+
+ let trace = crate::executor::trace::build_trace(&thread);
+ assert!(
+ build_skill_gap_payload(&thread, &trace, &thread.active_skills()).is_none(),
+ "read-only shell workflows should not trigger skill repair"
+ );
+ }
+
+ // ── next_cron_fire_required + cooldown regression tests ──────
+
+ /// A 7-field cron expression year-locked to a year that's already in the
+ /// past. `cron::Schedule` parses it cleanly but `upcoming(...).next()`
+ /// returns `None`, which is exactly the `Ok(None)` case the
+ /// `next_cron_fire_required` helper guards against.
+ const PAST_YEAR_CRON: &str = "0 0 0 1 1 * 2020";
+
+ #[tokio::test]
+ async fn create_mission_rejects_unschedulable_cron() {
+ // Regression: previously `create_mission` accepted Ok(None) and
+ // persisted an Active mission with `next_fire_at = None` — the
+ // exact failure mode of #1944. Now it must fail fast.
+ let store = Arc::new(TestStore::new());
+ let mgr = make_mission_manager(Arc::clone(&store) as Arc);
+ let project_id = ProjectId::new();
+
+ let err = mgr
+ .create_mission(
+ project_id,
+ "alice",
+ "unschedulable",
+ "g",
+ MissionCadence::Cron {
+ expression: PAST_YEAR_CRON.into(),
+ timezone: None,
+ },
+ vec![],
+ )
+ .await
+ .expect_err("create_mission must reject cron with no upcoming fire time");
+
+ assert!(
+ matches!(err, EngineError::InvalidCadence { .. }),
+ "expected InvalidCadence, got: {err:?}"
+ );
+
+ // No mission should be persisted, no entry in active.
+ assert!(store.missions.read().await.is_empty());
+ }
+
+ #[tokio::test]
+ async fn update_mission_rejects_switch_to_unschedulable_cron() {
+ let store = Arc::new(TestStore::new());
+ let mgr = make_mission_manager(Arc::clone(&store) as Arc);
+ let project_id = ProjectId::new();
+
+ let id = mgr
+ .create_mission(
+ project_id,
+ "alice",
+ "manual-then-cron",
+ "g",
+ MissionCadence::Manual,
+ vec![],
+ )
+ .await
+ .unwrap();
+
+ let err = mgr
+ .update_mission(
+ id,
+ "alice",
+ MissionUpdate {
+ cadence: Some(MissionCadence::Cron {
+ expression: PAST_YEAR_CRON.into(),
+ timezone: None,
+ }),
+ ..Default::default()
+ },
+ )
+ .await
+ .expect_err("update_mission must reject cron with no upcoming fire time");
+
+ assert!(matches!(err, EngineError::InvalidCadence { .. }));
+ // Original Manual cadence should be preserved on the persisted record.
+ let reloaded = mgr.get_mission(id).await.unwrap().unwrap();
+ assert!(matches!(reloaded.cadence, MissionCadence::Manual));
+ }
+
+ #[tokio::test]
+ async fn resume_mission_rejects_unschedulable_cron() {
+ // Build a paused cron mission whose schedule is fine, then mutate the
+ // persisted record to a year-locked expression and try to resume.
+ let store = Arc::new(TestStore::new());
+ let mgr = make_mission_manager(Arc::clone(&store) as Arc);
+ let project_id = ProjectId::new();
+
+ let id = mgr
+ .create_mission(
+ project_id,
+ "alice",
+ "resume-bad",
+ "g",
+ MissionCadence::Cron {
+ expression: "0 9 * * *".into(),
+ timezone: None,
+ },
+ vec![],
+ )
+ .await
+ .unwrap();
+ mgr.pause_mission(id, "alice").await.unwrap();
+
+ // Tamper with the persisted cadence to simulate a stored mission that
+ // can no longer fire (e.g. operator edited the database, or year-locked
+ // schedule rolled past).
+ {
+ let mut missions = store.missions.write().await;
+ if let Some(m) = missions.get_mut(&id) {
+ m.cadence = MissionCadence::Cron {
+ expression: PAST_YEAR_CRON.into(),
+ timezone: None,
+ };
+ }
+ }
+
+ let err = mgr
+ .resume_mission(id, "alice")
+ .await
+ .expect_err("resume_mission must reject cron with no upcoming fire time");
+ assert!(matches!(err, EngineError::InvalidCadence { .. }));
+
+ // Mission must remain paused — resume failed before any state change.
+ let reloaded = mgr.get_mission(id).await.unwrap().unwrap();
+ assert_eq!(reloaded.status, MissionStatus::Paused);
+ }
+
+ #[tokio::test]
+ async fn pause_and_complete_drop_cooldown_entry() {
+ // Regression: `last_fire_attempt` was previously only ever inserted,
+ // never pruned. Pausing or completing a mission must drop its
+ // cooldown entry so the in-memory map can't grow unbounded.
+ let store = Arc::new(TestStore::new());
+ let mgr = make_mission_manager(Arc::clone(&store) as Arc);
+ let project_id = ProjectId::new();
+
+ // Mission A — paused after a fire.
+ let id_a = mgr
+ .create_mission(
+ project_id,
+ "alice",
+ "pause-cleanup",
+ "g",
+ MissionCadence::Cron {
+ expression: "* * * * *".into(),
+ timezone: None,
+ },
+ vec![],
+ )
+ .await
+ .unwrap();
+ mgr.fire_mission(id_a, "alice", None).await.unwrap();
+ assert!(mgr.last_fire_attempt.read().await.contains_key(&id_a));
+ mgr.pause_mission(id_a, "alice").await.unwrap();
+ assert!(
+ !mgr.last_fire_attempt.read().await.contains_key(&id_a),
+ "pause_mission must drop the cooldown entry"
+ );
+
+ // Mission B — completed after a fire.
+ let id_b = mgr
+ .create_mission(
+ project_id,
+ "alice",
+ "complete-cleanup",
+ "g",
+ MissionCadence::Cron {
+ expression: "* * * * *".into(),
+ timezone: None,
+ },
+ vec![],
+ )
+ .await
+ .unwrap();
+ mgr.fire_mission(id_b, "alice", None).await.unwrap();
+ assert!(mgr.last_fire_attempt.read().await.contains_key(&id_b));
+ mgr.complete_mission(id_b).await.unwrap();
+ assert!(
+ !mgr.last_fire_attempt.read().await.contains_key(&id_b),
+ "complete_mission must drop the cooldown entry"
+ );
+ }
+
+ #[tokio::test]
+ async fn tick_cooldown_suppresses_re_fire_on_save_failure() {
+ // Regression for the runaway-re-fire concern: when save_mission fails
+ // after a successful spawn, the persisted `next_fire_at` AND
+ // `last_fire_at` stay at their pre-fire values, but the in-memory
+ // `last_fire_attempt[mid]` is set to the new fire instant. The
+ // mismatch between in-memory and persisted `last_fire_at` is what
+ // tells tick to arm the cooldown — without that signal every
+ // subsequent tick would re-fire the same mission and spawn
+ // duplicate threads up to the daily budget.
+ let store = Arc::new(TestStore::new());
+ let mgr = make_mission_manager(Arc::clone(&store) as Arc);
+ let project_id = ProjectId::new();
+
+ let id = mgr
+ .create_mission(
+ project_id,
+ "alice",
+ "cooldown",
+ "g",
+ MissionCadence::Cron {
+ expression: "* * * * *".into(),
+ timezone: None,
+ },
+ vec![],
+ )
+ .await
+ .unwrap();
+
+ // First fire arms the in-memory `last_fire_attempt` map.
+ let first = mgr.fire_mission(id, "alice", None).await.unwrap();
+ assert!(first.is_some(), "first fire should spawn a thread");
+
+ // Simulate the post-save-failure state explicitly: rewind
+ // `next_fire_at` into the past, reset `threads_today` so the budget
+ // can't be what's blocking, AND clobber `last_fire_at` so it no
+ // longer matches the in-memory `last_fire_attempt[mid]` instant.
+ // Together these mimic exactly the state a failed `save_mission`
+ // call would leave: in-memory recorded the fire, the store didn't.
+ {
+ let mut missions = store.missions.write().await;
+ let mission = missions.get_mut(&id).unwrap();
+ mission.next_fire_at = Some(chrono::Utc::now() - chrono::Duration::seconds(120));
+ mission.threads_today = 0;
+ mission.last_fire_at = None;
+ }
+
+ // tick must suppress the second fire — it can prove the persisted
+ // record is stale because in-memory last_fire_attempt holds an
+ // instant the persisted `last_fire_at` doesn't.
+ let spawned = mgr.tick("alice").await.unwrap();
+ assert!(
+ spawned.is_empty(),
+ "tick must skip mission whose persisted last_fire_at is stale, got: {spawned:?}"
+ );
+ }
+
+ #[tokio::test]
+ async fn fire_mission_arms_cooldown_before_save_mission() {
+ // Race regression: a concurrent tick observing the state between
+ // `save_mission` completion and the in-memory cooldown insert would
+ // see no cooldown entry, evaluate the mismatch check to false, and
+ // (if next_fire_at is in the past) re-fire immediately. Fix: insert
+ // the cooldown entry BEFORE calling save_mission.
+ //
+ // We verify the order by gating save_mission with a oneshot channel
+ // and asserting `last_fire_attempt[mid]` is already populated while
+ // save is still in flight.
+ use tokio::sync::Notify;
+
+ let store = Arc::new(TestStore::new());
+ let mgr = Arc::new(make_mission_manager(Arc::clone(&store) as Arc));
+ let project_id = ProjectId::new();
+
+ let id = mgr
+ .create_mission(
+ project_id,
+ "alice",
+ "race-test",
+ "g",
+ MissionCadence::Cron {
+ expression: "0 9 * * *".into(),
+ timezone: None,
+ },
+ vec![],
+ )
+ .await
+ .unwrap();
+
+ // Block the next save_mission. The first save_mission call below
+ // (from create_mission's path) already happened — `block_next_save_mission`
+ // installs the gate AFTER create, so it only catches fire_mission's save.
+ let release = store.block_next_save_mission().await;
+ let started = Arc::new(Notify::new());
+ let started_clone = Arc::clone(&started);
+ let store_clone = Arc::clone(&store);
+ // Spawn a watcher that translates `save_mission_started` into our own
+ // `started` notification. We can't share the TestStore's Notify across
+ // tasks via `notified()` cleanly without a permit, so wrap it.
+ tokio::spawn(async move {
+ store_clone.save_mission_started.notified().await;
+ started_clone.notify_one();
+ });
+
+ // Spawn fire_mission in a task — it will block inside save_mission.
+ let mgr_clone = Arc::clone(&mgr);
+ let fire_task = tokio::spawn(async move {
+ mgr_clone
+ .fire_mission(id, "alice", None)
+ .await
+ .expect("fire should succeed once save is unblocked")
+ });
+
+ // Wait until save_mission has begun (inside the gate).
+ started.notified().await;
+
+ // At this point save_mission is parked. The cooldown MUST already be
+ // armed because the fix inserts before save.
+ assert!(
+ mgr.last_fire_attempt.read().await.contains_key(&id),
+ "last_fire_attempt[mid] must be populated before save_mission begins; \
+ a concurrent tick in this window would otherwise see no cooldown entry"
+ );
+
+ // Unblock save and let fire_mission complete.
+ release.send(()).unwrap();
+ let thread_id = fire_task.await.unwrap();
+ assert!(thread_id.is_some(), "fire should spawn a thread");
+ }
+
+ #[tokio::test]
+ async fn tick_does_not_re_fire_corrupted_cron_within_cooldown_window() {
+ // Regression: when `next_cron_fire(expression)` returns Err inside
+ // fire_mission (corrupted persisted expression), the previous code
+ // stamped `last_fire_at = fire_instant` anyway. Save then succeeded
+ // with last_fire_at matching the in-memory value, so tick's
+ // mismatch detector saw "save succeeded" and the cooldown was
+ // never armed. With `next_fire_at` still in the past (preserved
+ // because the cron crate couldn't compute a new value), every
+ // subsequent tick re-fired the same mission until `max_threads_per_day`
+ // was exhausted.
+ //
+ // Fix: only stamp `last_fire_at = fire_instant` when cron actually
+ // advanced. On a parse error, leave persisted last_fire_at at its
+ // OLD value so the in-memory vs persisted mismatch arms the
+ // cooldown via the same code path as a save failure.
+ let store = Arc::new(TestStore::new());
+ let mgr = make_mission_manager(Arc::clone(&store) as Arc);
+ let project_id = ProjectId::new();
+
+ let id = mgr
+ .create_mission(
+ project_id,
+ "alice",
+ "corrupt-cron",
+ "g",
+ MissionCadence::Cron {
+ expression: "* * * * *".into(),
+ timezone: None,
+ },
+ vec![],
+ )
+ .await
+ .unwrap();
+
+ // Corrupt the persisted expression. fire_mission's cron advance
+ // call will fail and (with the fix) leave last_fire_at at the OLD
+ // value the test fixture started with.
+ {
+ let mut missions = store.missions.write().await;
+ let mission = missions.get_mut(&id).unwrap();
+ if let MissionCadence::Cron {
+ ref mut expression, ..
+ } = mission.cadence
+ {
+ *expression = "this is not a cron".to_string();
+ }
+ // Force next_fire_at into the past so should_fire is true.
+ mission.next_fire_at = Some(chrono::Utc::now() - chrono::Duration::seconds(60));
+ }
+
+ // First fire spawns a thread successfully despite the corrupt cron
+ // (regression test `fire_mission_with_corrupt_cron_expression_does_not_orphan_thread`
+ // pins this behavior). next_fire_at stays at its past value.
+ let first = mgr.fire_mission(id, "alice", None).await.unwrap();
+ assert!(
+ first.is_some(),
+ "first fire should spawn despite corrupt cron"
+ );
+
+ // tick must NOT re-fire the corrupted mission. Without the fix the
+ // cooldown was not armed (persisted last_fire_at == fire_instant ==
+ // in-memory), and tick would call fire_mission again every cycle.
+ let spawned = mgr.tick("alice").await.unwrap();
+ assert!(
+ spawned.is_empty(),
+ "tick must not re-fire a mission whose cron advance failed; \
+ cooldown should be armed via last_fire_at vs in-memory mismatch, \
+ got: {spawned:?}"
+ );
+
+ // Sanity: the persisted last_fire_at is still its pre-fire value
+ // (None for a freshly-created mission whose first fire failed to
+ // advance), confirming the mismatch-arming mechanism.
+ let reloaded = mgr.get_mission(id).await.unwrap().unwrap();
+ assert_eq!(
+ reloaded.last_fire_at, None,
+ "last_fire_at must NOT be stamped when cron advance failed"
+ );
+ }
+
+ #[tokio::test]
+ async fn tick_does_not_throttle_high_frequency_cron_after_successful_fire() {
+ // Regression for the inverse failure mode: the cooldown must NOT
+ // throttle a normally-firing high-frequency cron. An earlier
+ // implementation armed the cooldown unconditionally on every
+ // successful fire, which silently dropped roughly half of the
+ // events for `* * * * *` (every-minute) crons because the 60 s
+ // tick interval fell inside the 90 s cooldown window. The fix:
+ // only arm the cooldown when the persisted `last_fire_at` does
+ // NOT match the in-memory `last_fire_attempt` value — i.e. only
+ // in the failed-save regime.
+ let store = Arc::new(TestStore::new());
+ let mgr = make_mission_manager(Arc::clone(&store) as Arc);
+ let project_id = ProjectId::new();
+
+ let id = mgr
+ .create_mission(
+ project_id,
+ "alice",
+ "high-freq",
+ "g",
+ MissionCadence::Cron {
+ expression: "* * * * *".into(),
+ timezone: None,
+ },
+ vec![],
+ )
+ .await
+ .unwrap();
+
+ // First fire records the in-memory cooldown entry AND persists
+ // `last_fire_at = fire_instant`. The two values are the same
+ // (`fire_mission` uses a single `Utc::now()` for both writes).
+ let first = mgr.fire_mission(id, "alice", None).await.unwrap();
+ assert!(first.is_some(), "first fire should spawn a thread");
+
+ // Mimic "tick runs ~1 minute later, schedule advanced normally":
+ // rewind `next_fire_at` to a moment in the past that is STRICTLY
+ // LATER than the fire instant. Crucially, leave `last_fire_at`
+ // alone — it still equals `last_fire_attempt[mid]`, so the
+ // cooldown's mismatch detector says "save succeeded, do not
+ // throttle." Reset `threads_today` so the daily budget isn't
+ // what's blocking.
+ {
+ let mut missions = store.missions.write().await;
+ let mission = missions.get_mut(&id).unwrap();
+ // 1 ms earlier than now, but still later than the original
+ // fire instant since fire_mission ran microseconds ago.
+ mission.next_fire_at = Some(chrono::Utc::now() - chrono::Duration::milliseconds(1));
+ mission.threads_today = 0;
+ }
+
+ let spawned = mgr.tick("alice").await.unwrap();
+ assert_eq!(
+ spawned.len(),
+ 1,
+ "tick must fire a high-frequency cron after a successful fire — \
+ cooldown must not throttle the success path, got spawned={spawned:?}"
);
}
}
diff --git a/crates/ironclaw_engine/src/runtime/mod.rs b/crates/ironclaw_engine/src/runtime/mod.rs
index 6d757a7bfd1..a10822ecc14 100644
--- a/crates/ironclaw_engine/src/runtime/mod.rs
+++ b/crates/ironclaw_engine/src/runtime/mod.rs
@@ -5,6 +5,7 @@
//! - [`messaging`] — inter-thread signal channel
pub mod conversation;
+pub mod lease_refresh;
pub mod manager;
pub mod messaging;
pub mod mission;
diff --git a/crates/ironclaw_engine/src/traits/effect.rs b/crates/ironclaw_engine/src/traits/effect.rs
index 42cbdd18334..1fed99bd6c7 100644
--- a/crates/ironclaw_engine/src/traits/effect.rs
+++ b/crates/ironclaw_engine/src/traits/effect.rs
@@ -9,6 +9,7 @@ use crate::types::error::EngineError;
use crate::types::project::ProjectId;
use crate::types::step::{ActionResult, StepId};
use crate::types::thread::{ThreadId, ThreadType};
+use ironclaw_common::ValidTimezone;
/// Contextual information about the thread requesting an effect.
///
@@ -25,6 +26,9 @@ pub struct ThreadExecutionContext {
/// The channel this thread's conversation originated from (e.g. "gateway", "repl").
/// Used by mission_create to default `notify_channels` to the current channel.
pub source_channel: Option,
+ /// Validated IANA timezone of the user (e.g. "America/New_York").
+ /// Used by mission_create to default cron timezone, and exposed to CodeAct scripts.
+ pub user_timezone: Option,
}
/// Abstraction over capability action execution.
diff --git a/crates/ironclaw_engine/src/traits/mod.rs b/crates/ironclaw_engine/src/traits/mod.rs
index 6b835e6903e..21345426a1f 100644
--- a/crates/ironclaw_engine/src/traits/mod.rs
+++ b/crates/ironclaw_engine/src/traits/mod.rs
@@ -6,3 +6,4 @@
pub mod effect;
pub mod llm;
pub mod store;
+pub mod workspace;
diff --git a/crates/ironclaw_engine/src/traits/workspace.rs b/crates/ironclaw_engine/src/traits/workspace.rs
new file mode 100644
index 00000000000..a2fbccb49a5
--- /dev/null
+++ b/crates/ironclaw_engine/src/traits/workspace.rs
@@ -0,0 +1,20 @@
+//! Workspace document reader.
+//!
+//! Used by the mission runtime to load `context_paths` files into a fired
+//! mission's meta-prompt. The host (main `ironclaw` crate) implements this
+//! over the existing `Workspace` API.
+//!
+//! Kept deliberately small: just enough surface to read a single document
+//! by relative path. The engine does not write to the workspace.
+
+use crate::types::error::EngineError;
+
+/// Reads workspace documents by path. Implementations must be tenant-safe:
+/// the workspace they wrap is the one belonging to the mission's owner.
+#[async_trait::async_trait]
+pub trait WorkspaceReader: Send + Sync {
+ /// Read a document by relative workspace path. Returns the document body
+ /// as a string. Implementations should return an error rather than panic
+ /// when the file does not exist or cannot be decoded.
+ async fn read_doc(&self, path: &str) -> Result;
+}
diff --git a/crates/ironclaw_engine/src/types/error.rs b/crates/ironclaw_engine/src/types/error.rs
index 130dc41e4ce..7ab42db496e 100644
--- a/crates/ironclaw_engine/src/types/error.rs
+++ b/crates/ironclaw_engine/src/types/error.rs
@@ -26,6 +26,9 @@ pub enum EngineError {
#[error("effect execution error: {reason}")]
Effect { reason: String },
+ #[error("invalid cadence: {reason}")]
+ InvalidCadence { reason: String },
+
#[error("invalid state transition: {from} -> {to}")]
InvalidTransition { from: ThreadState, to: ThreadState },
diff --git a/crates/ironclaw_engine/src/types/event.rs b/crates/ironclaw_engine/src/types/event.rs
index 812cfde46df..4c707dcdd0d 100644
--- a/crates/ironclaw_engine/src/types/event.rs
+++ b/crates/ironclaw_engine/src/types/event.rs
@@ -67,7 +67,7 @@ fn truncate(s: &str, max: usize) -> String {
while end > 0 && !s.is_char_boundary(end) {
end -= 1;
}
- format!("{}...", &s[..end])
+ format!("{}...", &s[..end]) // safety: end is validated by is_char_boundary loop above
}
}
use crate::types::step::{StepId, TokenUsage};
diff --git a/crates/ironclaw_engine/src/types/mission.rs b/crates/ironclaw_engine/src/types/mission.rs
index 39156108060..19edf9de529 100644
--- a/crates/ironclaw_engine/src/types/mission.rs
+++ b/crates/ironclaw_engine/src/types/mission.rs
@@ -4,15 +4,21 @@
//! threads to make progress. Missions can run on a schedule (cron),
//! in response to events, or be triggered manually.
+use std::collections::HashMap;
+use std::str::FromStr;
+
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
+use crate::types::error::EngineError;
use crate::types::project::ProjectId;
use crate::types::thread::ThreadId;
use super::{OwnerId, default_user_id};
+pub use ironclaw_common::ValidTimezone;
+
/// Strongly-typed mission identifier.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct MissionId(pub Uuid);
@@ -58,12 +64,29 @@ pub enum MissionCadence {
/// Spawn on a cron schedule (e.g., "0 */6 * * *" for every 6 hours).
Cron {
expression: String,
- timezone: Option,
+ #[serde(
+ default,
+ deserialize_with = "ironclaw_common::deserialize_option_lenient"
+ )]
+ timezone: Option,
+ },
+ /// Spawn in response to a channel message matching a regex pattern.
+ /// `channel`, when set, restricts firing to messages from a specific
+ /// channel name (case-insensitive).
+ OnEvent {
+ event_pattern: String,
+ #[serde(default)]
+ channel: Option,
},
- /// Spawn in response to a channel message matching a pattern.
- OnEvent { event_pattern: String },
/// Spawn in response to a structured system event (from tools or external).
- OnSystemEvent { source: String, event_type: String },
+ /// `filters`, when non-empty, requires every key/value pair to match
+ /// against the event payload's top-level fields exactly.
+ OnSystemEvent {
+ source: String,
+ event_type: String,
+ #[serde(default)]
+ filters: HashMap,
+ },
/// Spawn when an external webhook is received at a registered path.
/// The bridge registers the webhook endpoint and routes payloads here.
Webhook {
@@ -83,6 +106,10 @@ pub struct Mission {
#[serde(default = "default_user_id")]
pub user_id: String,
pub name: String,
+ /// Optional human-readable description (separate from the goal statement).
+ /// Routine `description` fields map here.
+ #[serde(default)]
+ pub description: Option,
pub goal: String,
pub status: MissionStatus,
pub cadence: MissionCadence,
@@ -104,12 +131,42 @@ pub struct Mission {
/// Empty means no proactive notification (results only in approach_history).
#[serde(default)]
pub notify_channels: Vec,
+ /// Optional per-channel user/recipient target for notifications. Maps from
+ /// routine `delivery.user`. When `None`, the channel's last-seen
+ /// recipient is used.
+ #[serde(default)]
+ pub notify_user: Option,
+
+ // ── Context preloading ──
+ /// Workspace paths whose contents are loaded into the thread's meta-prompt
+ /// when the mission fires (e.g. `["MEMORY.md", "context/profile.json"]`).
+ /// Maps from routine `execution.context_paths`.
+ #[serde(default)]
+ pub context_paths: Vec,
- // ── Budget ──
+ // ── Budget / guardrails ──
/// Maximum threads per day (0 = unlimited).
pub max_threads_per_day: u32,
/// Threads spawned today (reset daily by the cron ticker).
pub threads_today: u32,
+ /// Cooldown between firings, in seconds. 0 = no cooldown. Maps from
+ /// routine `guardrails.cooldown_secs`.
+ #[serde(default)]
+ pub cooldown_secs: u64,
+ /// Maximum number of mission threads that may be running concurrently
+ /// (in non-terminal states). 0 = unlimited. Maps from routine
+ /// `guardrails.max_concurrent`.
+ #[serde(default)]
+ pub max_concurrent: u32,
+ /// Deduplication window for event-triggered firings, in seconds. 0 = no
+ /// dedup. When set, identical event-key payloads within this window are
+ /// suppressed. Maps from routine `guardrails.dedup_window`.
+ #[serde(default)]
+ pub dedup_window_secs: u64,
+ /// Timestamp of the most recent successful fire. Used by cooldown
+ /// enforcement.
+ #[serde(default)]
+ pub last_fire_at: Option>,
// ── Trigger payload ──
/// Payload from the most recent trigger (webhook body, event data, etc.).
@@ -132,11 +189,38 @@ impl Mission {
cadence: MissionCadence,
) -> Self {
let now = Utc::now();
+
+ // Event-triggered cadences (OnEvent / OnSystemEvent / Webhook) are
+ // *reactive*: a single noisy channel can fire them on every
+ // matching message. Cron / Manual cadences are *proactive* and
+ // self-paced. Set tighter defaults for the reactive variants so a
+ // mission created without explicit guardrails cannot accidentally
+ // flood the LLM if its pattern is too loose. The routine alias
+ // path overrides these via post-create update when the LLM
+ // supplies explicit guardrails / advanced settings.
+ let is_reactive = matches!(
+ cadence,
+ MissionCadence::OnEvent { .. }
+ | MissionCadence::OnSystemEvent { .. }
+ | MissionCadence::Webhook { .. }
+ );
+ let (default_max_threads_per_day, default_cooldown_secs, default_max_concurrent) =
+ if is_reactive {
+ // 5-minute cooldown + 24/day cap + single-instance — same
+ // floor v1 routine_create used for event-driven routines.
+ (24, 300, 1)
+ } else {
+ // Existing defaults for cron/manual missions; no cooldown,
+ // no concurrency cap, generous daily budget.
+ (10, 0, 0)
+ };
+
Self {
id: MissionId::new(),
project_id,
user_id: user_id.into(),
name: name.into(),
+ description: None,
goal: goal.into(),
status: MissionStatus::Active,
cadence,
@@ -145,8 +229,14 @@ impl Mission {
thread_history: Vec::new(),
success_criteria: None,
notify_channels: Vec::new(),
- max_threads_per_day: 10,
+ notify_user: None,
+ context_paths: Vec::new(),
+ max_threads_per_day: default_max_threads_per_day,
threads_today: 0,
+ cooldown_secs: default_cooldown_secs,
+ max_concurrent: default_max_concurrent,
+ dedup_window_secs: 0,
+ last_fire_at: None,
last_trigger_payload: None,
metadata: serde_json::Value::Object(serde_json::Map::new()),
created_at: now,
@@ -182,3 +272,394 @@ impl Mission {
)
}
}
+
+/// Normalize a cron expression to the 7-field format expected by the `cron` crate.
+///
+/// Field formats accepted:
+/// - **5-field** (standard Vixie cron): `min hr dom mon dow` — prepend `0`
+/// (seconds) and append `*` (year).
+/// - **6-field**: assumed to be `sec min hr dom mon dow` (the `cron` crate's
+/// native format minus year) and append `*` (year). **Note:** this is *not*
+/// the Quartz `min hr dom mon dow year` interpretation. A user passing
+/// `"0 9 * * * 2027"` intending "at 09:00 every day in 2027" will instead
+/// get "at second 0 of minute 9 of every hour, every day, every year". Use
+/// the explicit 7-field form `0 0 9 * * * 2027` to disambiguate.
+/// - **7-field**: `sec min hr dom mon dow year` — passed through unchanged.
+///
+/// Returns an error for any other field count rather than passing the input
+/// through to `cron::Schedule::from_str`, which would surface a confusing
+/// low-level parse error.
+fn normalize_cron_expression(expression: &str) -> Result {
+ let trimmed = expression.trim();
+ let fields: Vec<&str> = trimmed.split_whitespace().collect();
+ match fields.len() {
+ 5 => Ok(format!("0 {} *", fields.join(" "))),
+ 6 => {
+ // Disambiguate the Quartz-style 6-field form. A user (or LLM)
+ // typing `"0 9 * * * 2027"` almost certainly means
+ // "at 09:00 every day in 2027" (Quartz: `min hr dom mon dow year`),
+ // not "at second 0 of minute 9, every hour, every day, dow=2027".
+ // The cron crate would treat the year-shaped final field as a
+ // (nonsensical) day-of-week and silently produce a wrong schedule.
+ // Reject early with a message that points at the explicit
+ // 7-field form so the caller can fix it instead of debugging a
+ // schedule that never fires.
+ if let Some(last) = fields.last()
+ && is_year_field(last)
+ {
+ return Err(EngineError::InvalidCadence {
+ reason: format!(
+ "ambiguous 6-field cron expression '{expression}': the trailing '{last}' \
+ looks like a year. The 6-field form is `sec min hr dom mon dow`, NOT the \
+ Quartz `min hr dom mon dow year`. Use the explicit 7-field form \
+ `0 {} {} {} {} {} {last}` to mean 'at the given time in {last}'.",
+ fields[0], fields[1], fields[2], fields[3], fields[4]
+ ),
+ });
+ }
+ Ok(format!("{} *", fields.join(" ")))
+ }
+ 7 => Ok(trimmed.to_string()),
+ n => Err(EngineError::InvalidCadence {
+ reason: format!(
+ "invalid cron expression '{expression}': expected 5, 6, or 7 fields, got {n}"
+ ),
+ }),
+ }
+}
+
+/// True if `field` is a literal 4-digit year in the plausible cron range.
+///
+/// Used to detect the Quartz-style `min hr dom mon dow year` mistake in
+/// 6-field input. Range chosen to cover the cron crate's accepted year span
+/// without firing on field values that happen to be 4 digits but mean
+/// something else (none of the standard cron field ranges produce 4-digit
+/// literals).
+fn is_year_field(field: &str) -> bool {
+ field.len() == 4
+ && field.bytes().all(|b| b.is_ascii_digit())
+ && field
+ .parse::()
+ .is_ok_and(|y| (1970..=2099).contains(&y))
+}
+
+/// Parse a cron expression and compute the next fire time from now.
+///
+/// Accepts standard 5-field, 6-field, or 7-field cron expressions (auto-normalized).
+/// When a [`ValidTimezone`] is provided, the schedule is evaluated in that
+/// timezone and the result is converted back to UTC. Otherwise UTC is used.
+///
+/// Cron parse failures return [`EngineError::InvalidCadence`] (validation, not
+/// storage), so callers can map them to user-facing errors.
+pub fn next_cron_fire(
+ expression: &str,
+ timezone: Option<&ValidTimezone>,
+) -> Result>, EngineError> {
+ let normalized = normalize_cron_expression(expression)?;
+ let schedule =
+ cron::Schedule::from_str(&normalized).map_err(|e| EngineError::InvalidCadence {
+ reason: format!("invalid cron expression '{expression}': {e}"),
+ })?;
+ if let Some(vtz) = timezone {
+ Ok(schedule
+ .upcoming(vtz.tz())
+ .next()
+ .map(|dt| dt.with_timezone(&Utc)))
+ } else {
+ Ok(schedule.upcoming(Utc).next())
+ }
+}
+
+/// Like [`next_cron_fire`], but treats `Ok(None)` as a validation error.
+///
+/// `next_cron_fire` returns `Ok(None)` for cron expressions that are
+/// syntactically valid but will never fire again (e.g. `0 0 9 * * * 2020` —
+/// year-locked to a year that's already passed). At lifecycle entry points
+/// (`create_mission`, cadence updates, `resume_mission`) this is the same
+/// failure mode as the original #1944 bug: an Active mission with
+/// `next_fire_at = None` that the ticker can never pick up. Surface it as
+/// `InvalidCadence` so callers fail fast and the operator gets a clear error.
+///
+/// `fire_mission` and `bootstrap_project` intentionally tolerate `Ok(None)`
+/// (logged) and should keep using `next_cron_fire` directly — the thread is
+/// already running or the data is already persisted, and aborting would do
+/// more harm than logging.
+pub fn next_cron_fire_required(
+ expression: &str,
+ timezone: Option<&ValidTimezone>,
+) -> Result, EngineError> {
+ next_cron_fire(expression, timezone)?.ok_or_else(|| EngineError::InvalidCadence {
+ reason: format!(
+ "cron expression '{expression}' has no upcoming fire time (year-locked or otherwise unschedulable)"
+ ),
+ })
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use chrono::{Datelike, Timelike};
+
+ #[test]
+ fn rejects_four_field_cron() {
+ // Four-field input is not a recognized cron format. Surface a clear
+ // error rather than passing through to a low-level parse failure.
+ let err = next_cron_fire("* * * *", None).unwrap_err();
+ let msg = err.to_string();
+ assert!(msg.contains("expected 5, 6, or 7 fields"), "got: {msg}");
+ }
+
+ #[test]
+ fn accepts_five_field_cron() {
+ let next = next_cron_fire("0 9 * * *", None).unwrap();
+ assert!(next.is_some(), "5-field cron should produce a fire time");
+ }
+
+ #[test]
+ fn next_cron_fire_respects_timezone() {
+ // "0 9 * * *" in America/New_York should produce a UTC instant whose
+ // wall-clock time in NY is 09:00 on some date — and the resulting UTC
+ // hour should differ from a UTC-evaluated schedule (since NY is offset
+ // from UTC year-round).
+ let tz = ValidTimezone::parse("America/New_York").unwrap();
+ let in_ny = next_cron_fire("0 9 * * *", Some(&tz))
+ .unwrap()
+ .expect("schedule should produce a fire time");
+ let in_utc = next_cron_fire("0 9 * * *", None)
+ .unwrap()
+ .expect("schedule should produce a fire time");
+
+ // NY 09:00 in UTC is either 13:00 (EDT) or 14:00 (EST). UTC 09:00 is 09:00.
+ let ny_utc_hour = in_ny.hour();
+ assert!(
+ ny_utc_hour == 13 || ny_utc_hour == 14,
+ "NY 09:00 should map to UTC 13 or 14, got {ny_utc_hour}"
+ );
+ assert_eq!(in_utc.hour(), 9, "UTC schedule should fire at hour 9");
+ assert_ne!(
+ in_ny.hour(),
+ in_utc.hour(),
+ "tz-aware and tz-naive schedules should differ"
+ );
+
+ // Sanity: result is a real future date, not the epoch. Compare
+ // against `Utc::now()` so the assertion stays stable across calendar
+ // years rather than being pinned to a hard-coded threshold.
+ assert!(in_ny > Utc::now(), "next cron fire must be in the future");
+ }
+
+ #[test]
+ fn normalize_six_field_cron() {
+ // 6-field (with seconds) should be accepted.
+ let next = next_cron_fire("0 0 9 * * *", None).unwrap();
+ assert!(next.is_some());
+ }
+
+ #[test]
+ fn six_field_cron_is_sec_min_hr_dom_mon_dow_not_quartz_with_year() {
+ // Pin the 6-field interpretation: `sec min hr dom mon dow`, NOT the
+ // Quartz-style `min hr dom mon dow year`. A 6-field input gets `*`
+ // appended for the year position. This test exists so a future change
+ // doesn't silently flip the interpretation and break existing
+ // missions.
+ let normalized = normalize_cron_expression("0 0 9 * * *").unwrap();
+ assert_eq!(
+ normalized, "0 0 9 * * * *",
+ "6-field input must be treated as `sec min hr dom mon dow` and appended with `*` (year)"
+ );
+
+ // Sanity: the resulting schedule fires at 09:00:00 wall-clock daily.
+ let next = next_cron_fire("0 0 9 * * *", None).unwrap().unwrap();
+ assert_eq!(next.hour(), 9);
+ assert_eq!(next.minute(), 0);
+ assert_eq!(next.second(), 0);
+ }
+
+ #[test]
+ fn normalize_seven_field_cron() {
+ // 7-field (sec min hr dom mon dow year) should pass through.
+ let next = next_cron_fire("0 0 9 * * * 2027", None).unwrap();
+ assert!(next.is_some());
+ }
+
+ #[test]
+ fn six_field_cron_with_year_shaped_last_field_is_rejected() {
+ // A user (or LLM) typing the Quartz-style `min hr dom mon dow year`
+ // form gets a clear error pointing at the explicit 7-field form,
+ // rather than a silently misparsed schedule. The 6-field form is
+ // `sec min hr dom mon dow`, so `2027` would otherwise be interpreted
+ // as a (nonsensical) day-of-week.
+ let err = next_cron_fire("0 9 * * * 2027", None).unwrap_err();
+ let msg = err.to_string();
+ assert!(
+ matches!(err, EngineError::InvalidCadence { .. }),
+ "expected InvalidCadence, got: {err:?}"
+ );
+ assert!(
+ msg.contains("looks like a year") && msg.contains("0 0 9 * * * 2027"),
+ "error should explain Quartz ambiguity and suggest 7-field form, got: {msg}"
+ );
+
+ // Cover all year boundaries.
+ for year in ["1970", "1999", "2000", "2026", "2099"] {
+ let expr = format!("0 0 * * * {year}");
+ assert!(
+ matches!(
+ next_cron_fire(&expr, None),
+ Err(EngineError::InvalidCadence { .. })
+ ),
+ "year {year} should be rejected as Quartz-style ambiguity"
+ );
+ }
+
+ // Out-of-range 4-digit values are NOT treated as years and fall
+ // through to the regular 6-field interpretation (which the cron
+ // crate may then reject for its own reasons).
+ let normalized = normalize_cron_expression("0 0 9 * * 1969");
+ assert!(
+ normalized.is_ok(),
+ "1969 (out of year range) should not trigger the Quartz heuristic"
+ );
+
+ // 5-field cron with a literal day-of-week numeric value must still
+ // work — the year heuristic only applies to 6-field input.
+ assert!(next_cron_fire("0 9 * * 3", None).unwrap().is_some());
+ }
+
+ #[test]
+ fn invalid_cron_returns_invalid_cadence_error() {
+ // Cron parse errors are validation errors, not store errors.
+ let err = next_cron_fire("not a cron", None).unwrap_err();
+ assert!(
+ matches!(err, EngineError::InvalidCadence { .. }),
+ "expected InvalidCadence, got: {err:?}"
+ );
+
+ let err = next_cron_fire("nope nope nope nope nope", None).unwrap_err();
+ assert!(matches!(err, EngineError::InvalidCadence { .. }));
+ }
+
+ // ── DST tests (#1944) ─────────────────────────────────────
+ //
+ // The whole point of carrying user_timezone through the engine is so that
+ // cron schedules respect DST. These tests pin the cron crate's behavior on
+ // the two tricky transitions in `America/New_York`:
+ //
+ // * Spring-forward: 2027-03-14 02:00 jumps to 03:00. Local times in
+ // [02:00, 03:00) do not exist on that day.
+ // * Fall-back: 2027-11-07 02:00 jumps back to 01:00. Local times in
+ // [01:00, 02:00) occur twice (once EDT, once EST).
+ //
+ // We don't test specific calendar dates (those would rot); instead we use
+ // explicit reference instants via the `cron` crate's `after()` method to
+ // assert behavior in a year-independent way.
+
+ use chrono::TimeZone;
+
+ fn schedule_after(
+ expression: &str,
+ tz: &ValidTimezone,
+ after_utc: DateTime,
+ ) -> DateTime {
+ let normalized = normalize_cron_expression(expression).unwrap(); // safety: test helper
+ let schedule = cron::Schedule::from_str(&normalized).unwrap(); // safety: test helper
+ let after_local = after_utc.with_timezone(&tz.tz());
+ schedule
+ .after(&after_local)
+ .next()
+ .expect("schedule should produce a fire time") // safety: test helper
+ .with_timezone(&Utc)
+ }
+
+ #[test]
+ fn dst_spring_forward_skips_missing_local_hour() {
+ // 2027-03-14 in America/New_York: clocks jump 02:00 -> 03:00 EDT.
+ // A cron at "30 2 * * *" requests a wall-clock time that does not
+ // exist on that day. The cron crate skips that occurrence and fires
+ // on the next valid day at 02:30 (which is then EDT, UTC-4).
+ let tz = ValidTimezone::parse("America/New_York").unwrap();
+
+ // Reference: 2027-03-13 00:00 UTC = 2027-03-12 19:00 EST, well
+ // before the spring-forward day. We just need a stable anchor.
+ let after = Utc.with_ymd_and_hms(2027, 3, 13, 0, 0, 0).unwrap();
+ let fire = schedule_after("30 2 * * *", &tz, after);
+
+ // The first fire on 2027-03-13 is 02:30 EST = 07:30 UTC. The next
+ // fire would be 2027-03-14 02:30 — but that doesn't exist on DST
+ // day, so the schedule skips to 2027-03-15 02:30 EDT = 06:30 UTC.
+ // Whichever the cron crate picks, it must NOT land in the missing
+ // local interval [02:00, 03:00) on 2027-03-14.
+ let fire_local = fire.with_timezone(&tz.tz());
+ if fire_local.year() == 2027 && fire_local.month() == 3 && fire_local.day() == 14 {
+ // If it lands on DST day, the wall-clock hour must be >= 3 (EDT).
+ assert!(
+ fire_local.hour() >= 3,
+ "fire on DST day must not be in skipped [02:00, 03:00) window, got {fire_local}"
+ );
+ }
+ // Sanity: the result is a real future instant.
+ assert!(fire > after);
+ }
+
+ #[test]
+ fn dst_fall_back_picks_one_of_overlapping_hours() {
+ // 2027-11-07 in America/New_York: clocks jump 02:00 EDT -> 01:00 EST.
+ // Local times in [01:00, 02:00) occur twice. A cron at "30 1 * * *"
+ // could fire at 01:30 EDT (05:30 UTC) or 01:30 EST (06:30 UTC).
+ // The cron crate picks one consistently — we just assert it picks
+ // exactly one and that the result is correct in UTC.
+ let tz = ValidTimezone::parse("America/New_York").unwrap();
+ let after = Utc.with_ymd_and_hms(2027, 11, 6, 12, 0, 0).unwrap();
+ let fire = schedule_after("30 1 * * *", &tz, after);
+
+ let fire_local = fire.with_timezone(&tz.tz());
+ // Whatever date the cron crate lands on, the local time must be 01:30.
+ assert_eq!(
+ fire_local.hour(),
+ 1,
+ "expected hour 1 local, got {fire_local}"
+ );
+ assert_eq!(
+ fire_local.minute(),
+ 30,
+ "expected minute 30 local, got {fire_local}"
+ );
+
+ // And the UTC instant must be exactly one of the two valid 01:30 NY
+ // instants on the fall-back day, OR a 01:30 NY on a neighbouring day.
+ // Either way, converting back must round-trip to the same wall clock.
+ let round_trip = fire.with_timezone(&tz.tz());
+ assert_eq!(round_trip, fire_local);
+ }
+
+ #[test]
+ fn dst_aware_schedule_advances_correctly_across_transition() {
+ // Across a DST transition the absolute UTC interval between two
+ // consecutive 09:00 local fires shifts by an hour. This is the
+ // "load-bearing tz" property the PR exists to enable.
+ let tz = ValidTimezone::parse("America/New_York").unwrap();
+ // Pick an anchor in EST (winter, before spring-forward).
+ let anchor = Utc.with_ymd_and_hms(2027, 3, 1, 0, 0, 0).unwrap();
+ let normalized = normalize_cron_expression("0 9 * * *").unwrap();
+ let schedule = cron::Schedule::from_str(&normalized).unwrap();
+ let anchor_local = anchor.with_timezone(&tz.tz());
+
+ // Take 30 consecutive fires — long enough to cross spring-forward.
+ let fires: Vec<_> = schedule.after(&anchor_local).take(30).collect();
+ assert_eq!(fires.len(), 30);
+
+ // All fires must be at 09:00 local wall clock, regardless of DST.
+ for f in &fires {
+ assert_eq!(f.hour(), 9, "every fire must be 09:00 local, got {f}");
+ }
+
+ // The UTC hour shifts when crossing DST: 09:00 EST = 14:00 UTC,
+ // 09:00 EDT = 13:00 UTC. Both must appear across the 30-day window.
+ let utc_hours: std::collections::BTreeSet =
+ fires.iter().map(|f| f.with_timezone(&Utc).hour()).collect();
+ assert!(
+ utc_hours.contains(&13) && utc_hours.contains(&14),
+ "30-day window straddling spring-forward should contain both 13:00 and 14:00 UTC fires; got {utc_hours:?}"
+ );
+ }
+}
diff --git a/crates/ironclaw_engine/src/types/thread.rs b/crates/ironclaw_engine/src/types/thread.rs
index 919c9cffa31..5154fdde1fb 100644
--- a/crates/ironclaw_engine/src/types/thread.rs
+++ b/crates/ironclaw_engine/src/types/thread.rs
@@ -12,6 +12,7 @@ use uuid::Uuid;
use crate::types::capability::LeaseId;
use crate::types::error::EngineError;
use crate::types::event::{EventKind, ThreadEvent};
+use crate::types::memory::DocId;
use crate::types::message::ThreadMessage;
use crate::types::project::ProjectId;
@@ -166,6 +167,20 @@ impl Default for ThreadConfig {
}
}
+/// Provenance for a skill that was active during thread execution.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct ActiveSkillProvenance {
+ pub doc_id: DocId,
+ pub name: String,
+ pub version: u32,
+ #[serde(default)]
+ pub snippet_names: Vec,
+ #[serde(default)]
+ pub force_activated: bool,
+}
+
+const ACTIVE_SKILLS_METADATA_KEY: &str = "active_skills";
+
// ── Thread ──────────────────────────────────────────────────
/// A thread — the unit of work.
@@ -246,6 +261,36 @@ impl Thread {
self.owner_id().matches_user(user_id)
}
+ /// Persist active skill provenance in thread metadata.
+ pub fn set_active_skills(
+ &mut self,
+ active_skills: &[ActiveSkillProvenance],
+ ) -> Result<(), EngineError> {
+ let metadata = self
+ .metadata
+ .as_object_mut()
+ .ok_or_else(|| EngineError::Store {
+ reason: "thread metadata is not a JSON object".into(),
+ })?;
+ metadata.insert(
+ ACTIVE_SKILLS_METADATA_KEY.into(),
+ serde_json::to_value(active_skills).map_err(|e| EngineError::Store {
+ reason: format!("failed to serialize active skill provenance: {e}"),
+ })?,
+ );
+ self.updated_at = Utc::now();
+ Ok(())
+ }
+
+ /// Load active skill provenance from thread metadata.
+ pub fn active_skills(&self) -> Vec {
+ self.metadata
+ .get(ACTIVE_SKILLS_METADATA_KEY)
+ .cloned()
+ .and_then(|value| serde_json::from_value(value).ok())
+ .unwrap_or_default()
+ }
+
/// Transition to a new state, recording an event.
pub fn transition_to(
&mut self,
@@ -310,6 +355,7 @@ impl Thread {
#[cfg(test)]
mod tests {
use super::*;
+ use crate::types::memory::DocId;
fn make_thread() -> Thread {
Thread::new(
@@ -463,4 +509,20 @@ mod tests {
.with_parent(parent.id);
assert_eq!(child.parent_id, Some(parent.id));
}
+
+ #[test]
+ fn active_skill_provenance_roundtrips_through_metadata() {
+ let mut thread = make_thread();
+ let skills = vec![ActiveSkillProvenance {
+ doc_id: DocId::new(),
+ name: "github-pr-workflow".to_string(),
+ version: 3,
+ snippet_names: vec!["list_prs".to_string()],
+ force_activated: true,
+ }];
+
+ thread.set_active_skills(&skills).unwrap();
+
+ assert_eq!(thread.active_skills(), skills);
+ }
}
diff --git a/crates/ironclaw_gateway/Cargo.toml b/crates/ironclaw_gateway/Cargo.toml
new file mode 100644
index 00000000000..0eaa87881e9
--- /dev/null
+++ b/crates/ironclaw_gateway/Cargo.toml
@@ -0,0 +1,16 @@
+[package]
+name = "ironclaw_gateway"
+version = "0.1.0"
+edition = "2024"
+rust-version = "1.92"
+description = "Gateway frontend assets, layout configuration, and widget extension system for IronClaw"
+license = "MIT OR Apache-2.0"
+
+[package.metadata.dist]
+dist = false
+
+[dependencies]
+serde = { version = "1", features = ["derive"] }
+serde_json = "1"
+thiserror = "2"
+tracing = "0.1"
diff --git a/crates/ironclaw_gateway/src/assets.rs b/crates/ironclaw_gateway/src/assets.rs
new file mode 100644
index 00000000000..3faca18eea8
--- /dev/null
+++ b/crates/ironclaw_gateway/src/assets.rs
@@ -0,0 +1,40 @@
+//! Embedded static assets for the IronClaw web gateway.
+//!
+//! All frontend files are compiled into the binary via `include_str!()` /
+//! `include_bytes!()`. The web gateway serves these as the default baseline;
+//! workspace-stored customizations (layout config, widgets, CSS overrides)
+//! are layered on top at runtime.
+
+// ==================== Core Files ====================
+
+/// Main HTML page (SPA shell).
+pub const INDEX_HTML: &str = include_str!("../static/index.html");
+
+/// Main application JavaScript.
+pub const APP_JS: &str = include_str!("../static/app.js");
+
+/// Base stylesheet.
+pub const STYLE_CSS: &str = include_str!("../static/style.css");
+
+/// Theme initialization script (runs synchronously in `` to prevent FOUC).
+pub const THEME_INIT_JS: &str = include_str!("../static/theme-init.js");
+
+/// Favicon.
+pub const FAVICON_ICO: &[u8] = include_bytes!("../static/favicon.ico");
+
+// ==================== Internationalization ====================
+
+/// i18n core library.
+pub const I18N_INDEX_JS: &str = include_str!("../static/i18n/index.js");
+
+/// English translations.
+pub const I18N_EN_JS: &str = include_str!("../static/i18n/en.js");
+
+/// Chinese (Simplified) translations.
+pub const I18N_ZH_CN_JS: &str = include_str!("../static/i18n/zh-CN.js");
+
+/// Korean translations.
+pub const I18N_KO_JS: &str = include_str!("../static/i18n/ko.js");
+
+/// i18n integration with the app.
+pub const I18N_APP_JS: &str = include_str!("../static/i18n-app.js");
diff --git a/crates/ironclaw_gateway/src/bundle.rs b/crates/ironclaw_gateway/src/bundle.rs
new file mode 100644
index 00000000000..0b615df6ad4
--- /dev/null
+++ b/crates/ironclaw_gateway/src/bundle.rs
@@ -0,0 +1,757 @@
+//! Frontend bundle assembly.
+//!
+//! Combines the embedded base HTML with workspace customizations (layout
+//! config, widgets, CSS overrides) into the final served page.
+
+use crate::layout::LayoutConfig;
+use crate::widget::{WidgetManifest, scope_css};
+
+/// Escape HTML special characters to prevent XSS in text content.
+fn escape_html(s: &str) -> String {
+ s.replace('&', "&")
+ .replace('<', "<")
+ .replace('>', ">")
+}
+
+/// Escape HTML attribute value (includes quotes).
+fn escape_html_attr(s: &str) -> String {
+ s.replace('&', "&")
+ .replace('<', "<")
+ .replace('>', ">")
+ .replace('"', """)
+}
+
+/// Rewrite any occurrence of `needle` (ASCII, case-insensitive) in `s` by
+/// inserting a backslash between the leading `<` and `/`, turning `` or ``, which let a `` sequence inside a color
+ // value close the tag early and inject arbitrary HTML. Apply the same
+ // escape here so branding stays in lock-step with the other paths.
+ let css_vars = bundle.layout.branding.to_css_vars();
+ if !css_vars.is_empty() {
+ let safe_vars = escape_tag_close(&css_vars, "{}", safe_vars));
+ }
+
+ // --- Body injections ---
+
+ // Layout config as global variable. JSON strings can contain ``
+ // (serde_json does not escape `<` or `/` by default), so neutralize any
+ // ` tag.
+ //
+ // The `nonce` attribute carries the [`NONCE_PLACEHOLDER`] sentinel — the
+ // gateway swaps it for a fresh per-response nonce that matches the
+ // response's `Content-Security-Policy` header. Without the nonce the
+ // browser blocks this inline script under the gateway's CSP.
+ match serde_json::to_string(&bundle.layout) {
+ Ok(layout_json) => {
+ let safe_layout = escape_tag_close(&layout_json, "window.__IRONCLAW_LAYOUT__ = {safe_layout};"
+ ));
+ }
+ Err(e) => {
+ // `LayoutConfig` and every nested type derive `Serialize` cleanly,
+ // so this branch is unreachable on well-typed input. Surface it
+ // anyway — a silent drop here would mean the customized HTML
+ // ships without `window.__IRONCLAW_LAYOUT__`, and the IIFE in
+ // `app.js` would no-op all branding/tab/chat customizations
+ // without leaving a trace. A loud warn at the failure site is
+ // cheap insurance against a future refactor that introduces a
+ // serialization-fallible field.
+ tracing::warn!(
+ error = %e,
+ "failed to serialize LayoutConfig for window.__IRONCLAW_LAYOUT__ injection — \
+ customizations will not apply"
+ );
+ }
+ }
+
+ // Widget CSS (scoped) and JS
+ for widget in &bundle.widgets {
+ if let Some(ref css) = widget.css {
+ let scoped = scope_css(css, &widget.manifest.id);
+ if !scoped.trim().is_empty() {
+ // Neutralize any `` would otherwise break out of the tag.
+ //
+ // No nonce needed: the gateway's CSP allows `'unsafe-inline'`
+ // for `style-src`. Scripts are the only nonce-gated tags.
+ let safe_css = escape_tag_close(&scoped, "{}",
+ escape_html_attr(&widget.manifest.id),
+ safe_css
+ ));
+ }
+ }
+
+ // Widget JS inlined (avoids auth issues with ` to prevent tag breakout (XSS) and
+ // stamp the CSP nonce placeholder so the gateway can authorize this
+ // script under its `script-src 'nonce-…'` policy.
+ let safe_js = escape_tag_close(&widget.js, "\n{}\n",
+ NONCE_PLACEHOLDER,
+ escape_html_attr(&widget.manifest.id),
+ safe_js
+ ));
+ }
+
+ // Custom CSS
+ if let Some(ref custom_css) = bundle.custom_css
+ && !custom_css.trim().is_empty()
+ {
+ // Same reasoning as widget CSS — neutralize `{}", safe_custom));
+ }
+
+ // --- Assemble ---
+
+ let mut result = base_html.to_string();
+
+ // Inject before
+ if !head_injections.is_empty() {
+ let head_block = head_injections.join("\n");
+ if let Some(pos) = result.rfind("") {
+ result.insert_str(pos, &format!("\n{}\n", head_block));
+ }
+ }
+
+ // Override if branding title is set (HTML-escaped to prevent XSS)
+ if let Some(ref title) = bundle.layout.branding.title
+ && let Some(start) = result.find("")
+ && let Some(end) = result[start..].find(" ")
+ {
+ let end = start + end + " ".len();
+ result.replace_range(
+ start..end,
+ &format!("{} ", escape_html(title)),
+ );
+ }
+
+ // Inject before
+ if !body_injections.is_empty() {
+ let body_block = body_injections.join("\n");
+ if let Some(pos) = result.rfind("") {
+ result.insert_str(pos, &format!("\n{}\n", body_block));
+ }
+ }
+
+ result
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::layout::*;
+ use crate::widget::*;
+
+ const MINIMAL_HTML: &str =
+ "