Compare commits

..
Author SHA1 Message Date
github-actions[bot] 413ecf8697 chore: update WASM artifact SHA256 checksums [skip ci] 2026-03-06 16:21:32 +00:00
31 changed files with 66 additions and 884 deletions
+2 -3
View File
@@ -108,9 +108,8 @@ HEARTBEAT_NOTIFY_USER=default
# Memory hygiene settings (automatic cleanup of stale workspace documents)
# Runs on each heartbeat tick; identity files (IDENTITY.md, SOUL.md) are never deleted
# MEMORY_HYGIENE_ENABLED=true
# MEMORY_HYGIENE_DAILY_RETENTION_DAYS=30 # delete daily/ docs older than this many days
# MEMORY_HYGIENE_CONVERSATION_RETENTION_DAYS=7 # delete conversations/ docs older than this many days
# MEMORY_HYGIENE_CADENCE_HOURS=12 # minimum hours between cleanup passes
# MEMORY_HYGIENE_RETENTION_DAYS=30 # delete daily/ docs older than this many days
# MEMORY_HYGIENE_CADENCE_HOURS=12 # minimum hours between cleanup passes
# Safety settings
SAFETY_MAX_OUTPUT_LENGTH=100000
-4
View File
@@ -64,10 +64,6 @@ create "scope: dependencies" "90A4AE" "Dependency updates"
echo "==> Creating workflow labels..."
create "skip-regression-check" "9E9E9E" "Acknowledged: fix without regression test"
create "staging-ci-review" "D93F0B" "Auto-created by staging CI Claude Code review"
create "skip-claude-gate" "FBCA04" "Override: bypass Claude CRITICAL gate on staging CI"
create "low-confidence" "C5DEF5" "Claude review finding with <50 confidence"
create "staging-promotion" "0E8A16" "Auto-created staging→main promotion PR"
echo "==> Creating contributor labels..."
create "contributor: new" "FFF9C4" "First-time contributor"
-49
View File
@@ -1,49 +0,0 @@
name: Claude Code Review
on:
pull_request:
types: [opened, synchronize]
permissions:
contents: read
pull-requests: write
issues: write
id-token: write
jobs:
review:
name: Claude Code Review
if: contains(github.event.pull_request.labels.*.name, 'staging-promotion')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Run Claude Code review
uses: anthropics/claude-code-action@v1
with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
prompt: |
Review this PR for security vulnerabilities, bugs, and code quality issues.
Prefix EVERY review comment with a severity and confidence tag:
[SEVERITY:CONFIDENCE] where SEVERITY is CRITICAL/HIGH/MEDIUM/LOW
and CONFIDENCE is 0-100.
Example: [CRITICAL:92] This .unwrap() can panic in production when the config file is missing.
Severity guide:
- CRITICAL: security vulns, panics in prod (.unwrap/.expect), data exfiltration, race conditions
- HIGH: logic bugs, missing error handling, breaking API/schema changes
- MEDIUM: missing tests, unnecessary complexity, performance issues
- LOW: documentation gaps, naming suggestions
Confidence guide:
- 90-100: certain this is a real issue
- 70-89: very likely but needs human verification
- 50-69: possible issue, not fully sure of context
- 0-49: speculative, might be false positive
Only report real issues you're confident about. Be concise. No nitpicks.
claude_args: "--max-turns 5"
+1 -18
View File
@@ -44,32 +44,15 @@ jobs:
- name: Check lints
run: cargo clippy --all --benches --tests --examples ${{ matrix.flags }} -- -D warnings
version-check:
name: Version Bump Check
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Check version bumps for changed extensions
env:
PR_LABELS: ${{ join(github.event.pull_request.labels.*.name, ',') }}
run: ./scripts/check-version-bumps.sh
# Roll-up job for branch protection
code-style:
name: Code Style (fmt + clippy)
runs-on: ubuntu-latest
if: always()
needs: [format, clippy, version-check]
needs: [format, clippy]
steps:
- run: |
if [[ "${{ needs.format.result }}" != "success" || "${{ needs.clippy.result }}" != "success" ]]; then
echo "One or more jobs failed"
exit 1
fi
if [[ "${{ needs.version-check.result }}" != "success" ]]; then
echo "Version bump check did not succeed (status: ${{ needs.version-check.result }})"
exit 1
fi
+1 -3
View File
@@ -1,9 +1,7 @@
name: Code Coverage
on:
push:
branches: [main] # Runs when staging merges to main
workflow_call: # Optional: staging-ci can invoke
workflow_dispatch: # Manual trigger
branches: [main]
permissions:
id-token: write
+4 -1
View File
@@ -2,8 +2,11 @@ name: E2E Tests
on:
schedule:
- cron: "0 6 * * 1" # Weekly Monday 6 AM UTC
workflow_call: # Called by staging-ci.yml
workflow_dispatch:
pull_request:
paths:
- "src/channels/web/**"
- "tests/e2e/**"
jobs:
# ── Step 1: compile once ──────────────────────────────────────────────────
-414
View File
@@ -1,414 +0,0 @@
name: Staging CI (Batched)
on:
schedule:
- cron: "*/30 * * * *" # Every 30 minutes
workflow_dispatch:
inputs:
force:
description: "Force run even if no new commits"
type: boolean
default: false
skip_claude_gate:
description: "Skip Claude review gate (bypass blocking findings)"
type: boolean
default: false
permissions:
contents: write
issues: write
pull-requests: write
concurrency:
group: staging-ci
cancel-in-progress: false # Let running suites finish
jobs:
# ── Check for new commits ──────────────────────────────────────
check-changes:
name: Check for new commits
runs-on: ubuntu-latest
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
with:
ref: staging
fetch-depth: 0
- name: Check for changes since last tested
id: check
run: |
CURRENT_HEAD=$(git rev-parse HEAD)
echo "current_head=${CURRENT_HEAD}" >> "$GITHUB_OUTPUT"
if git rev-parse staging-tested >/dev/null 2>&1; then
LAST_TESTED=$(git rev-parse staging-tested)
else
LAST_TESTED=""
fi
DIFF_RANGE=""
if [ -n "$LAST_TESTED" ] && [ "$LAST_TESTED" = "$CURRENT_HEAD" ]; then
echo "No new commits since last tested (${CURRENT_HEAD})"
HAS_CHANGES=false
else
HAS_CHANGES=true
if [ -n "$LAST_TESTED" ]; then
COMMIT_COUNT=$(git rev-list --count "${LAST_TESTED}..HEAD")
echo "Found ${COMMIT_COUNT} new commit(s) since last tested"
DIFF_RANGE="${LAST_TESTED}..${CURRENT_HEAD}"
else
git fetch origin main
MERGE_BASE=$(git merge-base origin/main HEAD)
echo "First run -- reviewing from merge-base ${MERGE_BASE}"
DIFF_RANGE="${MERGE_BASE}..${CURRENT_HEAD}"
fi
fi
# Force override from workflow_dispatch
if [ "${{ inputs.force }}" = "true" ]; then
echo "Force run requested"
HAS_CHANGES=true
if [ -z "$DIFF_RANGE" ]; then
DIFF_RANGE="${CURRENT_HEAD}..${CURRENT_HEAD}"
fi
fi
echo "has_changes=${HAS_CHANGES}" >> "$GITHUB_OUTPUT"
echo "diff_range=${DIFF_RANGE}" >> "$GITHUB_OUTPUT"
# ── Run full test suite ──────────────────────────────────────────
tests:
name: Test Suite
needs: check-changes
if: needs.check-changes.outputs.has_changes == 'true'
uses: ./.github/workflows/test.yml
# ── Run E2E browser tests ────────────────────────────────────────
e2e:
name: E2E Browser Tests
needs: check-changes
if: needs.check-changes.outputs.has_changes == 'true'
uses: ./.github/workflows/e2e.yml
# ── Create promotion PR (triggers claude-review.yml on the PR) ──
create-promotion-pr:
name: Create Promotion PR
needs: check-changes
if: needs.check-changes.outputs.has_changes == 'true'
runs-on: ubuntu-latest
outputs:
pr_number: ${{ steps.create-pr.outputs.pr_number }}
steps:
- uses: actions/checkout@v6
with:
ref: staging
fetch-depth: 0
- name: Generate GitHub App token
id: app-token
uses: actions/create-github-app-token@v2
with:
app-id: ${{ secrets.GH_RELEASES_MANAGER_APP_ID }}
private-key: ${{ secrets.GH_RELEASES_MANAGER_APP_PRIVATE_KEY }}
- name: Check if staging is ahead of main
id: ahead-check
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
run: |
git fetch origin main
AHEAD=$(git rev-list --count origin/main..origin/staging)
echo "commits_ahead=${AHEAD}" >> "$GITHUB_OUTPUT"
if [ "$AHEAD" -eq 0 ]; then
echo "Staging is not ahead of main. Nothing to promote."
else
echo "Staging is ${AHEAD} commits ahead of main."
fi
- name: Close stale promotion PRs
if: steps.ahead-check.outputs.commits_ahead != '0'
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
run: |
# Close any existing staging->main PRs to avoid duplicates
EXISTING=$(gh pr list --base main --head staging --state open --json number -q '.[].number')
for PR in $EXISTING; do
echo "Closing stale promotion PR #${PR}"
gh pr close "$PR" --comment "Superseded by new staging-ci batch run"
done
- name: Create promotion PR
id: create-pr
if: steps.ahead-check.outputs.commits_ahead != '0'
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
run: |
RANGE="${{ needs.check-changes.outputs.diff_range }}"
TIMESTAMP=$(date -u +"%Y-%m-%d %H:%M UTC")
PR_URL=$(gh pr create \
--base main \
--head staging \
--title "chore: promote staging to main (${TIMESTAMP})" \
--body "## Auto-promotion from staging CI
**Batch range:** \`${RANGE}\`
**Triggered by:** Staging CI batch at ${TIMESTAMP}
Waiting for gates:
- Tests: pending
- E2E: pending
- Claude Code review: pending (will post comments on this PR)
---
*Auto-created by staging-ci workflow*" \
--label "staging-promotion")
PR_NUM=$(echo "$PR_URL" | grep -oE '[0-9]+$')
echo "pr_number=${PR_NUM}" >> "$GITHUB_OUTPUT"
echo "Created promotion PR #${PR_NUM}"
# ── Gate: wait for all checks, process findings, merge or block ──
gate:
name: Staging Gate
needs: [check-changes, tests, e2e, create-promotion-pr]
if: >
always() &&
needs.check-changes.outputs.has_changes == 'true' &&
needs.tests.result == 'success' &&
needs.e2e.result == 'success' &&
needs.create-promotion-pr.result == 'success'
runs-on: ubuntu-latest
outputs:
gate_passed: ${{ steps.evaluate.outputs.passed }}
steps:
- name: Generate GitHub App token
id: app-token
uses: actions/create-github-app-token@v2
with:
app-id: ${{ secrets.GH_RELEASES_MANAGER_APP_ID }}
private-key: ${{ secrets.GH_RELEASES_MANAGER_APP_PRIVATE_KEY }}
- name: Wait for Claude review on PR
id: wait-review
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
PR_NUMBER: ${{ needs.create-promotion-pr.outputs.pr_number }}
run: |
if [ -z "$PR_NUMBER" ]; then
echo "No PR number — skipping Claude review wait"
echo "review_done=false" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "Waiting for Claude Code Review check on PR #${PR_NUMBER}..."
TIMEOUT=600 # 10 minutes
ELAPSED=0
INTERVAL=15
while [ "$ELAPSED" -lt "$TIMEOUT" ]; do
# Check if the claude-review check has completed
STATUS=$(gh pr checks "$PR_NUMBER" --json name,state \
--jq '.[] | select(.name == "Claude Code Review") | .state' 2>/dev/null || echo "PENDING")
if [ "$STATUS" = "SUCCESS" ] || [ "$STATUS" = "FAILURE" ]; then
echo "Claude review completed with status: ${STATUS}"
echo "review_status=${STATUS}" >> "$GITHUB_OUTPUT"
echo "review_done=true" >> "$GITHUB_OUTPUT"
break
fi
echo "Claude review status: ${STATUS} (${ELAPSED}s elapsed)"
sleep "$INTERVAL"
ELAPSED=$((ELAPSED + INTERVAL))
done
if [ "$ELAPSED" -ge "$TIMEOUT" ]; then
echo "::warning::Claude review timed out after ${TIMEOUT}s"
echo "review_status=TIMEOUT" >> "$GITHUB_OUTPUT"
echo "review_done=false" >> "$GITHUB_OUTPUT"
fi
- name: Process Claude review comments and create issues
id: process-findings
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
PR_NUMBER: ${{ needs.create-promotion-pr.outputs.pr_number }}
REPO: ${{ github.repository }}
run: |
HAS_BLOCKING=false
ISSUES_CREATED=0
if [ -z "$PR_NUMBER" ]; then
echo "No PR — skipping finding processing"
echo "has_blocking=false" >> "$GITHUB_OUTPUT"
exit 0
fi
# Get all review comments from Claude on this PR
COMMENTS=$(gh api "repos/${REPO}/pulls/${PR_NUMBER}/comments" \
--jq '[.[] | select(.user.login == "claude[bot]" or .user.type == "Bot") | {body: .body, path: .path, line: .line, url: .html_url}]' 2>/dev/null || echo "[]")
COMMENT_COUNT=$(echo "$COMMENTS" | jq 'length')
echo "Found ${COMMENT_COUNT} Claude review comment(s)"
# Also check PR review body comments
REVIEW_COMMENTS=$(gh api "repos/${REPO}/pulls/${PR_NUMBER}/reviews" \
--jq '[.[] | select(.user.login == "claude[bot]" or .user.type == "Bot") | {body: .body, url: .html_url}]' 2>/dev/null || echo "[]")
# Combine all comments
ALL_COMMENTS=$(echo "$COMMENTS $REVIEW_COMMENTS" | jq -s 'add // []')
# Parse [SEVERITY:CONFIDENCE] tags from each comment
echo "$ALL_COMMENTS" | jq -c '.[]' | while read -r comment; do
BODY=$(echo "$comment" | jq -r '.body // ""')
URL=$(echo "$comment" | jq -r '.url // ""')
FILE=$(echo "$comment" | jq -r '.path // "unknown"')
LINE=$(echo "$comment" | jq -r '.line // 0')
# Extract [SEVERITY:CONFIDENCE] tag
TAG=$(echo "$BODY" | grep -oE '\[(CRITICAL|HIGH|MEDIUM|LOW):[0-9]+\]' | head -1 || true)
if [ -z "$TAG" ]; then
continue
fi
SEVERITY=$(echo "$TAG" | sed 's/\[\(.*\):\(.*\)\]/\1/')
CONFIDENCE=$(echo "$TAG" | sed 's/\[\(.*\):\(.*\)\]/\2/')
# Strip tag from body for issue description
DESC=$(echo "$BODY" | sed "s/\[${SEVERITY}:${CONFIDENCE}\] *//" | head -5)
echo "Found: [${SEVERITY}:${CONFIDENCE}] in ${FILE}:${LINE}"
# Determine if this should create an issue (confidence matrix)
CREATE_ISSUE=false
case "$SEVERITY" in
CRITICAL) CREATE_ISSUE=true ;; # Always
HIGH) [ "$CONFIDENCE" -ge 50 ] && CREATE_ISSUE=true ;;
MEDIUM) [ "$CONFIDENCE" -ge 80 ] && CREATE_ISSUE=true ;;
LOW) [ "$CONFIDENCE" -ge 80 ] && CREATE_ISSUE=true ;;
esac
# Check if blocking (CRITICAL ≥80)
if [ "$SEVERITY" = "CRITICAL" ] && [ "$CONFIDENCE" -ge 80 ]; then
HAS_BLOCKING=true
fi
if [ "$CREATE_ISSUE" = "true" ]; then
# Determine labels
case "$SEVERITY" in
CRITICAL) LABELS="bug,risk: high,staging-ci-review" ;;
HIGH) LABELS="bug,risk: medium,staging-ci-review" ;;
MEDIUM) LABELS="risk: medium,staging-ci-review" ;;
LOW) LABELS="risk: low,staging-ci-review" ;;
esac
if [ "$SEVERITY" = "CRITICAL" ] && [ "$CONFIDENCE" -lt 50 ]; then
LABELS="${LABELS},low-confidence"
fi
TITLE=$(echo "$DESC" | head -1 | cut -c1-80)
{
echo "## ${SEVERITY} Issue Found by Staging CI Review"
echo ""
echo "**Severity:** ${SEVERITY}"
echo "**Confidence:** ${CONFIDENCE}/100"
echo "**File:** \`${FILE}:${LINE}\`"
echo "**PR comment:** ${URL}"
echo ""
echo "### Description"
echo "$DESC"
echo ""
echo "---"
echo "*Auto-created by staging-ci Claude Code review*"
} > /tmp/issue-body.md
gh issue create \
--title "[${SEVERITY}] ${TITLE}" \
--body-file /tmp/issue-body.md \
--label "${LABELS}" || echo "::warning::Failed to create issue for ${SEVERITY} finding"
ISSUES_CREATED=$((ISSUES_CREATED + 1))
fi
done
echo "Created ${ISSUES_CREATED} issues"
echo "has_blocking=${HAS_BLOCKING}" >> "$GITHUB_OUTPUT"
- name: Evaluate gate
id: evaluate
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
PR_NUMBER: ${{ needs.create-promotion-pr.outputs.pr_number }}
run: |
HAS_BLOCKING="${{ steps.process-findings.outputs.has_blocking }}"
SKIP_INPUT="${{ inputs.skip_claude_gate }}"
if [ "$HAS_BLOCKING" = "true" ]; then
echo "::warning::Claude review found blocking issues (CRITICAL ≥80 confidence)"
if [ "$SKIP_INPUT" = "true" ]; then
echo "::warning::Gate overridden by skip_claude_gate workflow input"
echo "passed=true" >> "$GITHUB_OUTPUT"
else
echo "::error::Blocking promotion due to CRITICAL findings (≥80 confidence)"
echo "::error::PR #${PR_NUMBER} left open with review comments"
echo "passed=false" >> "$GITHUB_OUTPUT"
exit 1
fi
else
echo "No blocking findings. Gate passed."
echo "passed=true" >> "$GITHUB_OUTPUT"
fi
# Merge the promotion PR
if [ -n "$PR_NUMBER" ]; then
echo "Merging promotion PR #${PR_NUMBER}"
gh pr merge "$PR_NUMBER" --merge --auto || echo "::warning::Auto-merge failed for PR #${PR_NUMBER}"
fi
# ── Update tested tag on success ─────────────────────────────────
update-tag:
name: Update staging-tested tag
needs: [check-changes, gate]
if: >
always() &&
needs.check-changes.outputs.has_changes == 'true' &&
needs.gate.result == 'success'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
ref: staging
fetch-depth: 1
- name: Update staging-tested tag
run: |
git tag -f staging-tested "${{ needs.check-changes.outputs.current_head }}"
git push origin staging-tested --force
echo "Updated staging-tested tag to ${{ needs.check-changes.outputs.current_head }}"
# ── Report ───────────────────────────────────────────────────────
report:
name: Staging CI Summary
needs: [check-changes, tests, e2e, create-promotion-pr, gate, update-tag]
if: always() && needs.check-changes.outputs.has_changes == 'true'
runs-on: ubuntu-latest
steps:
- name: Summary
run: |
echo "## Staging CI Batch Results" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "| Check | Result |" >> "$GITHUB_STEP_SUMMARY"
echo "|-------|--------|" >> "$GITHUB_STEP_SUMMARY"
echo "| Tests | ${{ needs.tests.result }} |" >> "$GITHUB_STEP_SUMMARY"
echo "| E2E | ${{ needs.e2e.result }} |" >> "$GITHUB_STEP_SUMMARY"
echo "| Promotion PR | ${{ needs.create-promotion-pr.result }} |" >> "$GITHUB_STEP_SUMMARY"
echo "| Gate | ${{ needs.gate.result }} |" >> "$GITHUB_STEP_SUMMARY"
echo "| Tag Updated | ${{ needs.update-tag.result }} |" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "Range: ${{ needs.check-changes.outputs.diff_range }}" >> "$GITHUB_STEP_SUMMARY"
PR_NUM="${{ needs.create-promotion-pr.outputs.pr_number }}"
if [ -n "$PR_NUM" ]; then
echo "Promotion PR: #${PR_NUM}" >> "$GITHUB_STEP_SUMMARY"
fi
+24 -3
View File
@@ -1,7 +1,9 @@
name: Run Tests
on:
workflow_call: # Called by staging-ci.yml
workflow_dispatch: # Manual escape hatch
pull_request:
push:
branches:
- main
jobs:
tests:
@@ -79,15 +81,34 @@ jobs:
- name: Build Docker image
run: docker build -t ironclaw-test:ci .
version-check:
name: Version Bump Check
runs-on: ubuntu-latest
if: github.event_name == 'pull_request'
steps:
- name: Checkout repository
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Check version bumps for changed extensions
env:
PR_LABELS: ${{ join(github.event.pull_request.labels.*.name, ',') }}
run: ./scripts/check-version-bumps.sh
# Roll-up job for branch protection
run-tests:
name: Run Tests
runs-on: ubuntu-latest
if: always()
needs: [tests, telegram-tests, wasm-wit-compat, docker-build]
needs: [tests, telegram-tests, wasm-wit-compat, docker-build, version-check]
steps:
- run: |
if [[ "${{ needs.tests.result }}" != "success" || "${{ needs.telegram-tests.result }}" != "success" || "${{ needs.wasm-wit-compat.result }}" != "success" || "${{ needs.docker-build.result }}" != "success" ]]; then
echo "One or more jobs failed"
exit 1
fi
# version-check only runs on PRs, so skip/success are both acceptable
if [[ "${{ needs.version-check.result }}" == "failure" ]]; then
echo "Version bump check failed"
exit 1
fi
-6
View File
@@ -7,12 +7,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [0.16.1](https://github.com/nearai/ironclaw/compare/v0.16.0...v0.16.1) - 2026-03-06
### Fixed
- revert WASM artifact SHA256 checksums to null ([#627](https://github.com/nearai/ironclaw/pull/627))
## [0.16.0](https://github.com/nearai/ironclaw/compare/v0.15.0...v0.16.0) - 2026-03-06
### Added
Generated
+1 -1
View File
@@ -2828,7 +2828,7 @@ dependencies = [
[[package]]
name = "ironclaw"
version = "0.16.1"
version = "0.16.0"
dependencies = [
"aes-gcm",
"aho-corasick",
+1 -1
View File
@@ -18,7 +18,7 @@ exclude = [
[package]
name = "ironclaw"
version = "0.16.1"
version = "0.16.0"
edition = "2024"
rust-version = "1.92"
description = "Secure personal AI assistant that protects your data and expands its capabilities on the fly"
+1 -1
View File
@@ -19,7 +19,7 @@
"artifacts": {
"wasm32-wasip2": {
"url": "https://github.com/nearai/ironclaw/releases/latest/download/discord-wasm32-wasip2.tar.gz",
"sha256": null
"sha256": "56b8d92e3e0f32d9cfaf0dc1ac8aa8c42b98cda4d92cd42a74e0af01eeee60a1"
}
},
"auth_summary": {
+1 -1
View File
@@ -19,7 +19,7 @@
"artifacts": {
"wasm32-wasip2": {
"url": "https://github.com/nearai/ironclaw/releases/latest/download/slack-wasm32-wasip2.tar.gz",
"sha256": null
"sha256": "536b52179094d228e18d51b203286c80f800e0132b42fa08ce292c76a13a70e8"
}
},
"auth_summary": {
+1 -1
View File
@@ -19,7 +19,7 @@
"artifacts": {
"wasm32-wasip2": {
"url": "https://github.com/nearai/ironclaw/releases/latest/download/telegram-wasm32-wasip2.tar.gz",
"sha256": null
"sha256": "9bcc39d717c2b7e4e3327d31fc0032fbe4bf666852622c69b88dbaee236f77ee"
}
},
"auth_summary": {
+1 -1
View File
@@ -19,7 +19,7 @@
"artifacts": {
"wasm32-wasip2": {
"url": "https://github.com/nearai/ironclaw/releases/latest/download/whatsapp-wasm32-wasip2.tar.gz",
"sha256": null
"sha256": "54dc4a6c2b4e07bce6de54cab415920be518c94a096df45f1104f0fa5c8f6382"
}
},
"auth_summary": {
+1 -1
View File
@@ -20,7 +20,7 @@
"artifacts": {
"wasm32-wasip2": {
"url": "https://github.com/nearai/ironclaw/releases/latest/download/github-wasm32-wasip2.tar.gz",
"sha256": null
"sha256": "a068208d454be34585e8809816991c1853c255cd6f04c6ca9277e21219b745bc"
}
},
"auth_summary": {
+1 -1
View File
@@ -19,7 +19,7 @@
"artifacts": {
"wasm32-wasip2": {
"url": "https://github.com/nearai/ironclaw/releases/latest/download/gmail-wasm32-wasip2.tar.gz",
"sha256": null
"sha256": "557e3d485de337340e059948b3099dcacae1c2e937e2bf66bad52b105c5be9bc"
}
},
"auth_summary": {
+1 -1
View File
@@ -19,7 +19,7 @@
"artifacts": {
"wasm32-wasip2": {
"url": "https://github.com/nearai/ironclaw/releases/latest/download/google-calendar-wasm32-wasip2.tar.gz",
"sha256": null
"sha256": "787d57cb55cf492af013b69bfed8472a1cdd349fbb137f6084c0adcb51a467a6"
}
},
"auth_summary": {
+1 -1
View File
@@ -19,7 +19,7 @@
"artifacts": {
"wasm32-wasip2": {
"url": "https://github.com/nearai/ironclaw/releases/latest/download/google-docs-wasm32-wasip2.tar.gz",
"sha256": null
"sha256": "64407e650b6b8fcf9892255ef237a7683469fd465e3ba84e709fd71cd77fcbab"
}
},
"auth_summary": {
+1 -1
View File
@@ -19,7 +19,7 @@
"artifacts": {
"wasm32-wasip2": {
"url": "https://github.com/nearai/ironclaw/releases/latest/download/google-drive-wasm32-wasip2.tar.gz",
"sha256": null
"sha256": "3618689278d9e8546e37490c7ba05ee4dffb0acfaa89f0865161616326c41ee8"
}
},
"auth_summary": {
+1 -1
View File
@@ -19,7 +19,7 @@
"artifacts": {
"wasm32-wasip2": {
"url": "https://github.com/nearai/ironclaw/releases/latest/download/google-sheets-wasm32-wasip2.tar.gz",
"sha256": null
"sha256": "e486d228891a5b431b9993cebe0820c5331130916555ac3ba33b5b23bfd4ad75"
}
},
"auth_summary": {
+1 -1
View File
@@ -18,7 +18,7 @@
"artifacts": {
"wasm32-wasip2": {
"url": "https://github.com/nearai/ironclaw/releases/latest/download/google-slides-wasm32-wasip2.tar.gz",
"sha256": null
"sha256": "9f2ba32c6d43cf87f50e2ae6720a1e9d89c00666aab2047080aadb503968c66a"
}
},
"auth_summary": {
+1 -1
View File
@@ -18,7 +18,7 @@
"artifacts": {
"wasm32-wasip2": {
"url": "https://github.com/nearai/ironclaw/releases/latest/download/slack-tool-wasm32-wasip2.tar.gz",
"sha256": null
"sha256": "536b52179094d228e18d51b203286c80f800e0132b42fa08ce292c76a13a70e8"
}
},
"auth_summary": {
+1 -1
View File
@@ -19,7 +19,7 @@
"artifacts": {
"wasm32-wasip2": {
"url": "https://github.com/nearai/ironclaw/releases/latest/download/telegram-mtproto-wasm32-wasip2.tar.gz",
"sha256": null
"sha256": "9bcc39d717c2b7e4e3327d31fc0032fbe4bf666852622c69b88dbaee236f77ee"
}
},
"auth_summary": {
+1 -1
View File
@@ -19,7 +19,7 @@
"artifacts": {
"wasm32-wasip2": {
"url": "https://github.com/nearai/ironclaw/releases/latest/download/web-search-wasm32-wasip2.tar.gz",
"sha256": null
"sha256": "003b0667878035ba0e6029ec322bf5e6e7bab06c3022d5fe897ce288d2d156e0"
}
},
"auth_summary": {
-1
View File
@@ -164,7 +164,6 @@ impl HeartbeatRunner {
if report.had_work() {
tracing::info!(
daily_logs_deleted = report.daily_logs_deleted,
conversation_docs_deleted = report.conversation_docs_deleted,
"heartbeat: memory hygiene deleted stale documents"
);
}
-2
View File
@@ -2319,7 +2319,6 @@ async fn gateway_status_handler(
.unwrap_or(false);
Json(GatewayStatusResponse {
version: env!("CARGO_PKG_VERSION").to_string(),
sse_connections,
ws_connections,
total_connections: sse_connections + ws_connections,
@@ -2341,7 +2340,6 @@ struct ModelUsageEntry {
#[derive(serde::Serialize)]
struct GatewayStatusResponse {
version: String,
sse_connections: u64,
ws_connections: u64,
total_connections: u64,
-6
View File
@@ -3294,12 +3294,6 @@ function fetchGatewayStatus() {
var popover = document.getElementById('gateway-popover');
var html = '';
// Version
if (data.version) {
html += '<div class="gw-section-label">IronClaw v' + escapeHtml(data.version) + '</div>';
html += '<div class="gw-divider"></div>';
}
// Connection info
html += '<div class="gw-section-label">Connections</div>';
html += '<div class="gw-stat"><span>SSE</span><span>' + (data.sse_connections || 0) + '</span></div>';
+5 -13
View File
@@ -10,10 +10,8 @@ use crate::error::ConfigError;
pub struct HygieneConfig {
/// Whether hygiene is enabled. Env: `MEMORY_HYGIENE_ENABLED` (default: true).
pub enabled: bool,
/// Days before `daily/` documents are deleted. Env: `MEMORY_HYGIENE_DAILY_RETENTION_DAYS` (default: 30).
pub daily_retention_days: u32,
/// Days before `conversations/` documents are deleted. Env: `MEMORY_HYGIENE_CONVERSATION_RETENTION_DAYS` (default: 7).
pub conversation_retention_days: u32,
/// Days before `daily/` documents are deleted. Env: `MEMORY_HYGIENE_RETENTION_DAYS` (default: 30).
pub retention_days: u32,
/// Minimum hours between hygiene passes. Env: `MEMORY_HYGIENE_CADENCE_HOURS` (default: 12).
pub cadence_hours: u32,
}
@@ -22,8 +20,7 @@ impl Default for HygieneConfig {
fn default() -> Self {
Self {
enabled: true,
daily_retention_days: 30,
conversation_retention_days: 7,
retention_days: 30,
cadence_hours: 12,
}
}
@@ -33,11 +30,7 @@ impl HygieneConfig {
pub(crate) fn resolve() -> Result<Self, ConfigError> {
Ok(Self {
enabled: parse_bool_env("MEMORY_HYGIENE_ENABLED", true)?,
daily_retention_days: parse_optional_env("MEMORY_HYGIENE_DAILY_RETENTION_DAYS", 30)?,
conversation_retention_days: parse_optional_env(
"MEMORY_HYGIENE_CONVERSATION_RETENTION_DAYS",
7,
)?,
retention_days: parse_optional_env("MEMORY_HYGIENE_RETENTION_DAYS", 30)?,
cadence_hours: parse_optional_env("MEMORY_HYGIENE_CADENCE_HOURS", 12)?,
})
}
@@ -47,8 +40,7 @@ impl HygieneConfig {
pub fn to_workspace_config(&self) -> crate::workspace::hygiene::HygieneConfig {
crate::workspace::hygiene::HygieneConfig {
enabled: self.enabled,
daily_retention_days: self.daily_retention_days,
conversation_retention_days: self.conversation_retention_days,
retention_days: self.retention_days,
cadence_hours: self.cadence_hours,
state_dir: ironclaw_base_dir(),
}
+11 -341
View File
@@ -1,8 +1,8 @@
//! Memory hygiene: automatic cleanup of stale workspace documents.
//!
//! Runs on a configurable cadence and deletes daily log entries and conversation
//! documents older than their respective retention periods. Identity files
//! (`IDENTITY.md`, `SOUL.md`, etc.) are never touched.
//! Runs on a configurable cadence and deletes daily log entries older
//! than the retention period. Identity files (`IDENTITY.md`, `SOUL.md`,
//! etc.) are never touched.
//!
//! A global [`AtomicBool`] guard prevents concurrent hygiene passes, which
//! avoids TOCTOU races on the state file and Windows file-locking errors
@@ -17,10 +17,8 @@
//! │ 1. Check cadence (skip if ran recently) │
//! │ 2. Save state (claim the cadence window) │
//! │ 3. List daily/ documents │
//! │ 4. Delete those older than daily_retention │
//! │ 5. List conversations/ documents
//! │ 6. Delete those older than conversation_ret │
//! │ 7. Log summary │
//! │ 4. Delete those older than retention_days
//! │ 5. Log summary
//! └─────────────────────────────────────────────┘
//! ```
@@ -36,41 +34,13 @@ use crate::workspace::Workspace;
/// Global guard preventing concurrent hygiene passes.
static RUNNING: AtomicBool = AtomicBool::new(false);
/// Paths that must never be deleted by hygiene, regardless of age.
const IDENTITY_PATHS: &[&str] = &[
crate::workspace::document::paths::MEMORY,
crate::workspace::document::paths::IDENTITY,
crate::workspace::document::paths::SOUL,
crate::workspace::document::paths::AGENTS,
crate::workspace::document::paths::USER,
crate::workspace::document::paths::HEARTBEAT,
crate::workspace::document::paths::README,
crate::workspace::document::paths::TOOLS,
crate::workspace::document::paths::BOOTSTRAP,
];
/// Check if a document path is an identity document that must never be deleted.
///
/// Performs case-insensitive comparison to handle case-insensitive filesystems
/// (Windows, macOS) and prevent accidental deletion of identity docs with
/// different casing (e.g., memory.md, MEMORY.MD, Memory.md).
fn is_identity_path(path: &str) -> bool {
let file_name = path.rsplit('/').next().unwrap_or(path);
let file_name_lower = file_name.to_lowercase();
IDENTITY_PATHS
.iter()
.any(|&p| p.to_lowercase() == file_name_lower)
}
/// Configuration for workspace hygiene.
#[derive(Debug, Clone)]
pub struct HygieneConfig {
/// Whether hygiene is enabled at all.
pub enabled: bool,
/// Documents in `daily/` older than this many days are deleted.
pub daily_retention_days: u32,
/// Documents in `conversations/` older than this many days are deleted.
pub conversation_retention_days: u32,
pub retention_days: u32,
/// Minimum hours between hygiene passes.
pub cadence_hours: u32,
/// Directory to store state file (default: `~/.ironclaw`).
@@ -81,8 +51,7 @@ impl Default for HygieneConfig {
fn default() -> Self {
Self {
enabled: true,
daily_retention_days: 30,
conversation_retention_days: 7,
retention_days: 30,
cadence_hours: 12,
state_dir: ironclaw_base_dir(),
}
@@ -100,8 +69,6 @@ struct HygieneState {
pub struct HygieneReport {
/// Number of daily log documents deleted.
pub daily_logs_deleted: u32,
/// Number of conversation documents deleted.
pub conversation_docs_deleted: u32,
/// Whether the run was skipped (cadence not yet elapsed).
pub skipped: bool,
}
@@ -109,7 +76,7 @@ pub struct HygieneReport {
impl HygieneReport {
/// True if any cleanup work was done.
pub fn had_work(&self) -> bool {
self.daily_logs_deleted > 0 || self.conversation_docs_deleted > 0
self.daily_logs_deleted > 0
}
}
@@ -169,29 +136,21 @@ pub async fn run_if_due(workspace: &Workspace, config: &HygieneConfig) -> Hygien
save_state(&state_file);
tracing::info!(
daily_retention_days = config.daily_retention_days,
conversation_retention_days = config.conversation_retention_days,
retention_days = config.retention_days,
"memory hygiene: starting cleanup pass"
);
let mut report = HygieneReport::default();
// Delete old daily logs
match cleanup_daily_logs(workspace, config.daily_retention_days).await {
match cleanup_daily_logs(workspace, config.retention_days).await {
Ok(count) => report.daily_logs_deleted = count,
Err(e) => tracing::warn!("memory hygiene: failed to clean daily logs: {e}"),
}
// Delete old conversation documents
match cleanup_conversation_docs(workspace, config.conversation_retention_days).await {
Ok(count) => report.conversation_docs_deleted = count,
Err(e) => tracing::warn!("memory hygiene: failed to clean conversation docs: {e}"),
}
if report.had_work() {
tracing::info!(
daily_logs_deleted = report.daily_logs_deleted,
conversation_docs_deleted = report.conversation_docs_deleted,
"memory hygiene: cleanup complete"
);
} else {
@@ -224,11 +183,6 @@ async fn cleanup_daily_logs(
continue;
}
// Never delete identity documents
if is_identity_path(&entry.path) {
continue;
}
// Check if the document is old enough to delete
if let Some(updated_at) = entry.updated_at
&& updated_at < cutoff
@@ -251,50 +205,6 @@ async fn cleanup_daily_logs(
Ok(deleted)
}
/// Delete conversation documents older than `retention_days`.
async fn cleanup_conversation_docs(
workspace: &Workspace,
retention_days: u32,
) -> Result<u32, anyhow::Error> {
let cutoff = Utc::now() - chrono::Duration::days(i64::from(retention_days));
let entries = workspace.list("conversations/").await?;
let mut deleted = 0u32;
for entry in entries {
if entry.is_directory {
continue;
}
// Never delete identity documents
if is_identity_path(&entry.path) {
continue;
}
// Check if the document is old enough to delete
if let Some(updated_at) = entry.updated_at
&& updated_at < cutoff
{
let path = if entry.path.starts_with("conversations/") {
entry.path.clone()
} else {
format!("conversations/{}", entry.path)
};
if let Err(e) = workspace.delete(&path).await {
tracing::warn!(
path,
"memory hygiene: failed to delete conversation doc: {e}"
);
} else {
tracing::debug!(path, "memory hygiene: deleted old conversation doc");
deleted += 1;
}
}
}
Ok(deleted)
}
fn state_path_dir(state_file: &std::path::Path) -> Option<&std::path::Path> {
state_file.parent()
}
@@ -349,8 +259,7 @@ mod tests {
fn default_config_is_reasonable() {
let cfg = HygieneConfig::default();
assert!(cfg.enabled);
assert_eq!(cfg.daily_retention_days, 30);
assert_eq!(cfg.conversation_retention_days, 7);
assert_eq!(cfg.retention_days, 30);
assert_eq!(cfg.cadence_hours, 12);
}
@@ -365,83 +274,11 @@ mod tests {
fn report_had_work_when_deleted() {
let report = HygieneReport {
daily_logs_deleted: 3,
conversation_docs_deleted: 0,
skipped: false,
};
assert!(report.had_work());
}
#[test]
fn report_had_work_when_conversation_deleted() {
let report = HygieneReport {
daily_logs_deleted: 0,
conversation_docs_deleted: 2,
skipped: false,
};
assert!(report.had_work());
}
#[test]
fn is_identity_path_excludes_sacred_docs() {
for name in [
"MEMORY.md",
"IDENTITY.md",
"SOUL.md",
"AGENTS.md",
"USER.md",
"HEARTBEAT.md",
"README.md",
"TOOLS.md",
"BOOTSTRAP.md",
] {
assert!(is_identity_path(name), "{name} should be excluded");
assert!(
is_identity_path(&format!("conversations/{name}")),
"conversations/{name} should be excluded via path"
);
}
}
#[test]
fn is_identity_path_case_insensitive() {
// Verify case-insensitive matching for case-insensitive filesystems
assert!(
is_identity_path("memory.md"),
"lowercase memory.md should be excluded"
);
assert!(
is_identity_path("Memory.md"),
"mixed case Memory.md should be excluded"
);
assert!(
is_identity_path("MEMORY.MD"),
"uppercase MEMORY.MD should be excluded"
);
assert!(
is_identity_path("identity.md"),
"lowercase identity.md should be excluded"
);
assert!(
is_identity_path("conversations/soul.md"),
"conversations/soul.md should be excluded"
);
assert!(
is_identity_path("conversations/SOUL.MD"),
"conversations/SOUL.MD should be excluded"
);
}
#[test]
fn is_identity_path_allows_normal_docs() {
for path in [
"daily/2024-01-01.md",
"conversations/chat-abc.md",
"notes.md",
] {
assert!(!is_identity_path(path), "{path} should not be excluded");
}
}
#[test]
fn load_state_returns_none_for_missing_file() {
assert!(load_state(std::path::Path::new("/tmp/nonexistent_hygiene.json")).is_none());
@@ -491,9 +328,6 @@ mod tests {
fn running_guard_prevents_reentry() {
let _lock = RUNNING_TESTS.lock().unwrap();
// Reset the global flag to ensure a clean state
RUNNING.store(false, Ordering::SeqCst);
// Simulate acquiring the guard
assert!(
RUNNING
@@ -522,168 +356,4 @@ mod tests {
);
RUNNING.store(false, Ordering::SeqCst);
}
// ================================================================
// Async integration tests (require libsql backend)
// ================================================================
#[cfg(feature = "libsql")]
mod async_tests {
use super::*;
use crate::db::Database;
use std::sync::Arc;
/// Helper to create a test database with migrations.
async fn create_test_db() -> (Arc<dyn crate::db::Database>, tempfile::TempDir) {
use crate::db::libsql::LibSqlBackend;
let temp_dir = tempfile::tempdir().expect("tempdir");
let db_path = temp_dir.path().join("test_hygiene.db");
let backend = LibSqlBackend::new_local(&db_path)
.await
.expect("LibSqlBackend::new_local");
backend.run_migrations().await.expect("run_migrations");
let db: Arc<dyn Database> = Arc::new(backend);
(db, temp_dir)
}
/// Helper to create a workspace from a test database.
fn create_workspace(db: &Arc<dyn Database>) -> Arc<Workspace> {
Arc::new(Workspace::new_with_db("default", db.clone()))
}
#[tokio::test]
async fn cleanup_daily_logs_preserves_identity_documents() {
let (db, _tmp) = create_test_db().await;
let ws = create_workspace(&db);
// Write several regular documents (non-identity)
ws.write("daily/2024-01-15.md", "Old log")
.await
.expect("write log 1");
ws.write("daily/2024-01-20.md", "Another log")
.await
.expect("write log 2");
// Write an identity document
ws.write("MEMORY.md", "Long-term curated memory")
.await
.expect("write identity");
// List before cleanup
let before = ws.list("daily/").await.expect("list before");
let daily_count_before = before.iter().filter(|e| !e.is_directory).count();
assert!(daily_count_before >= 2, "should have at least 2 daily logs");
// Run cleanup with 0-day retention (deletes everything old)
// This tests that even with aggressive cleanup, identity docs survive
let deleted = cleanup_daily_logs(&ws, 0)
.await
.expect("cleanup_daily_logs");
// Should have deleted some documents (the daily logs)
assert!(deleted > 0, "should have deleted old daily documents");
// Verify identity doc still exists
let identity = db
.get_document_by_path("default", None, "MEMORY.md")
.await
.expect("get identity doc");
assert_eq!(identity.path, "MEMORY.md");
assert_eq!(identity.content, "Long-term curated memory");
}
#[tokio::test]
async fn cleanup_conversation_docs_handles_empty_directory() {
let (db, _tmp) = create_test_db().await;
let ws = create_workspace(&db);
// Run cleanup on an empty directory (conversations/ doesn't exist)
let deleted = cleanup_conversation_docs(&ws, 7)
.await
.expect("cleanup_conversation_docs");
// Should delete 0 (nothing to delete)
assert_eq!(deleted, 0, "should delete 0 from empty directory");
}
#[tokio::test]
async fn cleanup_respects_cadence_prevents_concurrent_runs() {
let (db, _tmp) = create_test_db().await;
let ws = create_workspace(&db);
let config = HygieneConfig {
enabled: true,
daily_retention_days: 30,
conversation_retention_days: 7,
cadence_hours: 12,
state_dir: _tmp.path().to_path_buf(),
};
// First run should succeed
let report1 = run_if_due(&ws, &config).await;
assert!(!report1.skipped, "first run should not be skipped");
// Second run immediately should be skipped (cadence not elapsed)
let report2 = run_if_due(&ws, &config).await;
assert!(report2.skipped, "second run should be skipped by cadence");
// Report structure should be correct
assert_eq!(
report1.daily_logs_deleted + report1.conversation_docs_deleted,
0,
"first run should have clean counts"
);
}
#[tokio::test]
async fn cleanup_reports_deletion_counts_correctly() {
let (db, _tmp) = create_test_db().await;
let ws = create_workspace(&db);
// Write some documents
ws.write("daily/log1.md", "content 1")
.await
.expect("write doc 1");
ws.write("daily/log2.md", "content 2")
.await
.expect("write doc 2");
ws.write("conversations/chat1.md", "content 3")
.await
.expect("write doc 3");
// Run with 0-day retention to delete everything non-identity
let deleted_daily = cleanup_daily_logs(&ws, 0).await.expect("cleanup daily");
let deleted_conv = cleanup_conversation_docs(&ws, 0)
.await
.expect("cleanup conversations");
// Both should report deletions
assert!(deleted_daily > 0, "should report deleted daily logs");
assert_eq!(deleted_conv, 1, "should report 1 deleted conversation doc");
// Create a HygieneReport and verify aggregation works
let report = HygieneReport {
daily_logs_deleted: deleted_daily,
conversation_docs_deleted: deleted_conv,
skipped: false,
};
// Verify HygieneReport structure
assert!(!report.skipped, "should not be skipped");
assert!(report.had_work(), "report should indicate work was done");
assert!(
report.daily_logs_deleted > 0 || report.conversation_docs_deleted > 0,
"report should have at least one deletion count > 0"
);
// Verify had_work() correctly combines both counts
let no_work = HygieneReport {
daily_logs_deleted: 0,
conversation_docs_deleted: 0,
skipped: false,
};
assert!(!no_work.had_work(), "empty report should indicate no work");
}
}
}
+2 -4
View File
@@ -348,8 +348,7 @@ mod tests {
let hygiene_config = HygieneConfig {
enabled: false,
daily_retention_days: 30,
conversation_retention_days: 7,
retention_days: 30,
cadence_hours: 24,
state_dir: _tmp.path().to_path_buf(),
};
@@ -400,8 +399,7 @@ mod tests {
let hygiene_config = HygieneConfig {
enabled: false,
daily_retention_days: 30,
conversation_retention_days: 7,
retention_days: 30,
cadence_hours: 24,
state_dir: _tmp.path().to_path_buf(),
};