Skip to content

Commit 1f7d7d4

Browse files
committed
release: merge develop into main for v0.18.5
2 parents 2924285 + b5545fd commit 1f7d7d4

File tree

11 files changed

+220
-13
lines changed

11 files changed

+220
-13
lines changed

.claude/agents/clawdia-assistant.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ skills:
2222
- int-sync-meetings
2323
- int-todoist
2424
- schedule-task
25+
- trigger-registry
26+
- schedule
2527
- ops-process-doc
2628
- ops-runbook
2729
- ops-process-optimization

.claude/agents/oracle.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ skills:
2020
- create-agent
2121
- create-command
2222
- create-routine
23+
- trigger-registry
24+
- schedule
2325
- workspace-share
2426
---
2527

.env.example

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,9 @@ BACKUP_S3_PREFIX=evonexus-backups/
125125
# AWS_ACCESS_KEY_ID=
126126
# AWS_SECRET_ACCESS_KEY=
127127
# AWS_ENDPOINT_URL= # For non-AWS (MinIO, R2, etc.)
128+
# Retention: how many backups to keep (empty = unlimited)
129+
# BACKUP_RETAIN_LOCAL=7 # Keep last 7 local backups
130+
# BACKUP_RETAIN_S3=30 # Keep last 30 S3 backups
128131

129132
# ── Social Accounts ──────────────────────────────────
130133
# Managed via the dashboard Integrations page.

ADWs/routines/backup.py

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,20 +11,30 @@
1111

1212

1313
def _do_backup():
14-
"""Run backup and return structured result for runner."""
14+
"""Run backup and return structured result for runner.
15+
16+
If S3 is configured: backup to S3 only, remove local ZIP after upload.
17+
If S3 is not configured: backup to local only (fallback).
18+
"""
1519
files = backup_module.collect_files()
1620
if not files:
1721
return {"ok": True, "summary": "No files to backup"}
1822

19-
# Check if S3 is configured
2023
s3_bucket = os.environ.get("BACKUP_S3_BUCKET")
21-
s3_upload = bool(s3_bucket)
2224

23-
zip_path = backup_module.backup_local(s3_upload=s3_upload)
24-
zip_size = zip_path.stat().st_size
25-
size_str = backup_module._format_size(zip_size)
26-
target = f"local + s3://{s3_bucket}" if s3_upload else "local"
27-
return {"ok": True, "summary": f"{len(files)} files → {zip_path.name} ({size_str}) [{target}]"}
25+
if s3_bucket:
26+
# S3 mode: create local ZIP, upload to S3, then delete local copy
27+
zip_path = backup_module.backup_local(s3_upload=True)
28+
zip_size = zip_path.stat().st_size
29+
size_str = backup_module._format_size(zip_size)
30+
zip_path.unlink(missing_ok=True)
31+
return {"ok": True, "summary": f"{len(files)} files → s3://{s3_bucket}/{zip_path.name} ({size_str})"}
32+
else:
33+
# Local fallback
34+
zip_path = backup_module.backup_local(s3_upload=False)
35+
zip_size = zip_path.stat().st_size
36+
size_str = backup_module._format_size(zip_size)
37+
return {"ok": True, "summary": f"{len(files)} files → {zip_path.name} ({size_str}) [local]"}
2838

2939

3040
def main():

CHANGELOG.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,19 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [0.18.5] - 2026-04-12
9+
10+
### Added
11+
12+
- **Backup retention & auto-cleanup** — configurable via `BACKUP_RETAIN_LOCAL` and `BACKUP_RETAIN_S3` env vars (also editable in dashboard Storage Provider panel). Old backups beyond the limit are auto-deleted after each backup run
13+
- **`boto3` as default dependency** — included in `pyproject.toml` so new installs have S3 support out of the box
14+
- **`trigger-registry` and `schedule` skills** — added to Oracle and Clawdia agents so they can create/manage webhook triggers and scheduled tasks
15+
16+
### Changed
17+
18+
- **S3 backup is now S3-only** — when S3 is configured, daily routine and `make backup-s3` upload to S3 and delete the local copy. Local backup is fallback only when S3 is not configured
19+
- **Dashboard restore runs post-migrate** — restore via the web UI now auto-fixes schema differences (missing columns, corrupted datetimes) after extracting, preventing 500 errors from old backups
20+
821
## [0.18.4] - 2026-04-12
922

1023
### Changed

backup.py

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -251,9 +251,62 @@ def backup_local(s3_upload: bool = False, s3_bucket: str = None) -> Path:
251251
if s3_upload:
252252
backup_s3_upload(zip_path, s3_bucket)
253253

254+
# Auto-cleanup old backups based on retention settings
255+
cleanup_old_backups(s3_bucket=s3_bucket)
256+
254257
return zip_path
255258

256259

260+
# ── Cleanup ─────────────────────────────────────────
261+
262+
263+
def cleanup_old_backups(s3_bucket: str = None):
264+
"""Remove old backups beyond retention limits (BACKUP_RETAIN_LOCAL, BACKUP_RETAIN_S3)."""
265+
266+
# Local cleanup
267+
retain_local = os.environ.get("BACKUP_RETAIN_LOCAL", "").strip()
268+
if retain_local and retain_local.isdigit() and int(retain_local) > 0:
269+
limit = int(retain_local)
270+
if BACKUPS_DIR.exists():
271+
zips = sorted(BACKUPS_DIR.glob("evonexus-backup-*.zip"), reverse=True)
272+
to_delete = zips[limit:]
273+
for z in to_delete:
274+
z.unlink(missing_ok=True)
275+
if to_delete:
276+
msg = f" Cleaned {len(to_delete)} old local backup(s) (keeping {limit})"
277+
if HAS_RICH:
278+
console.print(f" [dim]{msg}[/]")
279+
else:
280+
print(f" {DIM}{msg}{RESET}")
281+
282+
# S3 cleanup
283+
retain_s3 = os.environ.get("BACKUP_RETAIN_S3", "").strip()
284+
bucket = s3_bucket or os.environ.get("BACKUP_S3_BUCKET", "")
285+
if retain_s3 and retain_s3.isdigit() and int(retain_s3) > 0 and bucket:
286+
limit = int(retain_s3)
287+
try:
288+
import boto3
289+
endpoint_url = os.environ.get("AWS_ENDPOINT_URL")
290+
s3 = boto3.client("s3", endpoint_url=endpoint_url) if endpoint_url else boto3.client("s3")
291+
prefix = os.environ.get("BACKUP_S3_PREFIX", "evonexus-backups/")
292+
if not prefix.endswith("/"):
293+
prefix += "/"
294+
resp = s3.list_objects_v2(Bucket=bucket, Prefix=prefix)
295+
objects = [o for o in resp.get("Contents", []) if o["Key"].endswith(".zip")]
296+
objects.sort(key=lambda o: o["LastModified"], reverse=True)
297+
to_delete = objects[limit:]
298+
for obj in to_delete:
299+
s3.delete_object(Bucket=bucket, Key=obj["Key"])
300+
if to_delete:
301+
msg = f" Cleaned {len(to_delete)} old S3 backup(s) (keeping {limit})"
302+
if HAS_RICH:
303+
console.print(f" [dim]{msg}[/]")
304+
else:
305+
print(f" {DIM}{msg}{RESET}")
306+
except Exception as e:
307+
print(f" {YELLOW}S3 cleanup skipped: {e}{RESET}")
308+
309+
257310
# ── Restore ──────────────────────────────────────
258311

259312

@@ -521,7 +574,11 @@ def main():
521574

522575
if args.command == "backup":
523576
s3_upload = args.target == "s3"
524-
backup_local(s3_upload=s3_upload, s3_bucket=args.s3_bucket)
577+
zip_path = backup_local(s3_upload=s3_upload, s3_bucket=args.s3_bucket)
578+
# S3 mode: remove local copy after successful upload
579+
if s3_upload and zip_path and zip_path.exists():
580+
zip_path.unlink(missing_ok=True)
581+
print(f" {GREEN}✓ Local copy removed (S3 only){RESET}")
525582

526583
elif args.command == "restore":
527584
if args.target == "s3":

cli/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@evoapi/evo-nexus",
3-
"version": "0.18.4",
3+
"version": "0.18.5",
44
"description": "Unofficial open source toolkit for Claude Code — AI-powered business operating system",
55
"keywords": [
66
"claude-code",

dashboard/backend/routes/backups.py

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,43 @@
1616
_running_jobs = {}
1717

1818

19+
def _post_restore_migrate():
20+
"""Run schema fixes after restoring a backup (old DBs may have missing columns/bad data)."""
21+
import sqlite3
22+
from flask import current_app
23+
24+
db_path = current_app.config["SQLALCHEMY_DATABASE_URI"].replace("sqlite:///", "")
25+
conn = sqlite3.connect(db_path)
26+
cur = conn.cursor()
27+
28+
# Ensure all tables exist (db.create_all equivalent for new models)
29+
from models import db as _db
30+
_db.create_all()
31+
32+
# Add missing columns
33+
existing = {row[1] for row in cur.execute("PRAGMA table_info(roles)").fetchall()}
34+
if "agent_access_json" not in existing:
35+
cur.execute("ALTER TABLE roles ADD COLUMN agent_access_json TEXT DEFAULT '{\"mode\": \"all\"}'")
36+
37+
# Fix corrupted datetime columns (NULL or non-string crash SQLAlchemy)
38+
for tbl, col in [("roles", "created_at"), ("users", "created_at"), ("users", "last_login")]:
39+
try:
40+
tbl_cols = {row[1] for row in cur.execute(f"PRAGMA table_info({tbl})").fetchall()}
41+
if col in tbl_cols:
42+
cur.execute(f"UPDATE {tbl} SET {col} = datetime('now') WHERE {col} IS NOT NULL AND typeof({col}) != 'text'")
43+
cur.execute(f"UPDATE {tbl} SET {col} = datetime('now') WHERE {col} IS NOT NULL AND {col} != '' AND {col} NOT LIKE '____-__-__%'")
44+
except Exception:
45+
pass
46+
47+
conn.commit()
48+
conn.close()
49+
50+
# Re-seed roles to ensure new permissions exist
51+
from models import seed_roles, seed_systems
52+
seed_roles()
53+
seed_systems()
54+
55+
1956
def _require(resource: str, action: str):
2057
if not has_permission(current_user.role, resource, action):
2158
return jsonify({"error": "Forbidden"}), 403
@@ -71,7 +108,10 @@ def _run():
71108
import sys
72109
sys.path.insert(0, str(WORKSPACE))
73110
import backup as backup_module
74-
backup_module.backup_local(s3_upload=s3_upload)
111+
zip_path = backup_module.backup_local(s3_upload=s3_upload)
112+
# S3 mode: remove local copy after successful upload
113+
if s3_upload and zip_path and zip_path.exists():
114+
zip_path.unlink(missing_ok=True)
75115
_running_jobs["backup"] = {"status": "done"}
76116
except Exception as e:
77117
_running_jobs["backup"] = {"status": "error", "error": str(e)}
@@ -116,6 +156,11 @@ def _run():
116156
sys.path.insert(0, str(WORKSPACE))
117157
import backup as backup_module
118158
backup_module.restore_local(zip_path, mode=mode)
159+
160+
# After restore, run auto-migrate to fix schema differences
161+
# (old backups may have missing columns or corrupted data)
162+
_post_restore_migrate()
163+
119164
_running_jobs["restore"] = {"status": "done", "mode": mode}
120165
except Exception as e:
121166
_running_jobs["restore"] = {"status": "error", "error": str(e)}

dashboard/frontend/src/pages/Backups.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,8 @@ const S3_FIELDS = [
5151
{ envKey: 'AWS_DEFAULT_REGION', label: 'Region', hint: 'ex: us-east-1, sa-east-1, auto', required: false, sensitive: false },
5252
{ envKey: 'AWS_ENDPOINT_URL', label: 'Endpoint URL', hint: 'Para R2, Backblaze, MinIO (ex: https://xxx.r2.cloudflarestorage.com)', required: false, sensitive: false },
5353
{ envKey: 'BACKUP_S3_PREFIX', label: 'Prefix', hint: 'Prefixo das chaves no bucket (ex: backups/evonexus/)', required: false, sensitive: false },
54+
{ envKey: 'BACKUP_RETAIN_LOCAL', label: 'Retenção local', hint: 'Backups locais a manter (ex: 7). Vazio = sem limite', required: false, sensitive: false },
55+
{ envKey: 'BACKUP_RETAIN_S3', label: 'Retenção S3', hint: 'Backups no S3 a manter (ex: 30). Vazio = sem limite', required: false, sensitive: false },
5456
]
5557

5658
function S3ConfigPanel({ config, onSaved }: { config: BackupConfig; onSaved: () => void }) {

pyproject.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "evo-nexus"
3-
version = "0.18.4"
3+
version = "0.18.5"
44
description = "Unofficial open source toolkit for Claude Code — AI-powered business operating system"
55
requires-python = ">=3.10"
66
dependencies = [
@@ -15,6 +15,7 @@ dependencies = [
1515
"requests>=2.31",
1616
"pyyaml>=6.0",
1717
"flask-sock>=0.7",
18+
"boto3>=1.35",
1819
"pywinpty>=3.0.3; sys_platform == 'win32'",
1920
]
2021

0 commit comments

Comments
 (0)