diff --git a/docs/releases/0.7.0.md b/docs/releases/0.7.0.md new file mode 100644 index 00000000..bcd450db --- /dev/null +++ b/docs/releases/0.7.0.md @@ -0,0 +1,112 @@ +# v0.7.0 β€” PostgreSQL goes to production + +**Released:** 2026-06-23 Β· **Tag:** `v0.7.0` Β· **Previous:** `v0.6.1` +**Diff:** [v0.6.1...v0.7.0](https://github.com/abilityai/trinity/compare/v0.6.1...v0.7.0) + +The headline of this release: **Trinity now runs on PostgreSQL as the recommended +production database.** SQLite stays the zero-config default for local development +and evaluation, but production instances should move to Postgres. This release adds +the configurable backend, Alembic-managed Postgres migrations, a dated SQLite +end-of-support path, and a gated SQLiteβ†’Postgres migration flow shipped in the +Trinity Ops Agent. + +Alongside Postgres, v0.7.0 lands a pluggable **Codex harness** beside Claude Code, +a body of **execution-correctness** work (fire-and-forget dispatch, status-as-projection), +agent-server inbound-auth hardening, and credential **hot-reload** rotation. + +--- + +## 🐘 PostgreSQL β€” the theme + +- **#300** Configurable database backend β€” SQLAlchemy Core abstraction over **SQLite + PostgreSQL**. Set a single `DATABASE_URL` and both the backend and the scheduler switch over. Selection is **non-sticky and non-destructive**: comment the variable out and you're back on SQLite on the next restart. +- **#1183** Adopted **Alembic** for PostgreSQL migrations (dual-track alongside the SQLite bespoke runner; a schema change lands in both until SQLite is retired). +- **#1278** **SQLite end-of-support dated 2026-09-01** β€” PostgreSQL is the forward path for production, with migration notes in the docs. + +**Migrating an existing SQLite instance?** Use the **Trinity Ops Agent**'s +`/migrate-to-postgres` skill ([abilityai/trinity-ops-public](https://github.com/abilityai/trinity-ops-public)) β€” +a gated *validate-then-cutover* flow that stands up a parallel Postgres container, +copies and validates your data, then cuts over in a short downtime window. Your +SQLite file is **never written**, so rollback is always one line. + +- Stand up a **new** instance on Postgres β†’ [`docs/POSTGRESQL_SETUP.md`](../POSTGRESQL_SETUP.md) +- **Migrate** an existing SQLite instance β†’ Ops Agent `/migrate-to-postgres` + +```bash +# Opt in to the bundled PostgreSQL container +DATABASE_URL=postgresql://trinity:your-password@postgres:5432/trinity +docker compose --profile postgres up -d +``` + +--- + +## Features + +- **#1187** Codex harness MVP β€” pluggable agentic execution engine alongside Claude Code +- **#1169** Agent runtime data volumes β€” declared `data_paths` with snapshot/restore and portable export +- **#1089** Credential rotation via hot-reload, not container recreate +- **#668** Agent deployment compatibility validation β€” server-side checks with auto-fix offers +- **#1115** Per-schedule performance scorecards on Agent Detail (Overview + Schedules tab) +- **#1116** In-app bug reporting from the floating Help widget (hosted intake β†’ GitHub issues) +- **#1104** Respond to / resolve Operator Queue items over MCP (#1101 follow-up) +- **#1315** WhatsApp outbound media attachments β€” deliver `ChannelResponse.files` via Twilio MediaUrl +- **#82** Email verification for admin login (email code + password) +- **#679** Plumb cancel signal into the agent task-runner reply (#671 defense-in-depth) +- **#1095** Transactional agent executions β€” discard workspace changes unless validated as success (research-gated) +- **#941** Enterprise: audit log dashboard β€” admin viewer for compliance review (v1: list/filter/detail) + entitlement seam +- **trinity-enterprise#5** _(enterprise)_ Two-factor authentication (2FA) via TOTP + +## Fixes + +- **#1159** πŸ”’ agent-server HTTP API was unauthenticated on the shared agent network (cross-agent credential theft) β€” now a per-agent HMAC `X-Trinity-Agent-Token` +- **#1160** Migration runner β€” close the DROP-rebuild data-loss window + add cross-process serialization +- **#1201** Agent-side timeout (504) no longer drops execution cost/context/tool-call telemetry +- **#858** First-time setup token silently lost β€” block-buffered `print()` broke fresh installs +- **#1165** First-time setup token was per-worker β€” prod (`--workers 2`) onboarding still ~50% flaky after #858 +- **#1199** `GET /api/ops/auth-report` 500 β€” SQLAlchemy auto-correlation (v0.6.1 regression) +- **#1200** `GET/PUT /api/agents/{name}/capabilities` 500 β€” facade delegation (v0.6.1 regression) +- **#1267** Boot-time NameError in lifespan transport startup (misleading Telegram/WhatsApp error on every boot) +- **#1264** Per-agent GitHub PAT never propagated to an existing container β€” all pushes failed +- **#1265** Dashboard & timeline took 20s+ to load metrics with 10+ agents +- **#1022** Scheduler wrote `status='failed'` with empty error on a 30s dispatch timeout +- **#799** SUB-003 auto-switch had no per-agent lock β€” concurrent 429s raced the restart +- **#1197** Agent creation crashed with an opaque ValueError on non-integer CPU in template resources +- **#1230** Backend Docker healthcheck flapped to "unhealthy" under batch load (10s timeout too tight for 2 workers) +- **#1231** Agent `/tmp` tmpfs filled and silently broke autonomous git commits β€” size now configurable +- **#1237** CI: path-filtered required checks bricked unrelated PRs +- **#1260** Fixed test-suite bugs surfaced by the full integration run (fixtures, stale mocks, isolation, flakes) +- **#722** Config validation: 5 critical config issues +- **#767** CB probe executions left open until backend restart inflated failure duration on the timeline +- **#953** Freshly deployed agents reported `M .gitignore` against `origin/main` (startup.sh append) +- **#954** Agent Detail panel width jerked when switching to/from the Chat or Session tab (scrollbar gutter) +- **#957** "Failed to generate avatar" now classifies image-gen failures with actionable detail +- **#958** Build Info dialog displayed "unknown" for every field in local development +- **#960** Visual artifact on the Agents list row hover in light theme + +## Refactors + +- **#1083** Fire-and-forget dispatch β€” a hung turn holds zero backend resource +- **#1082** status-as-projection β€” `schedule_executions.status` is never read as authoritative for "is running" +- **#1025** Harden headless drain/finalize β€” daemon-thread exception capture + finalize snapshot isolation +- **#1027** Split `db/schedules.py`; replace 15+ param insert signatures with request objects +- **#1088** Unify the failure classifier into one shared package + +## Breaking Changes + +None. + +## Upgrade Notes + +- **Database:** PostgreSQL is now the recommended production backend (#300). Opt in with a single `DATABASE_URL`; SQLite remains the default. **SQLite end-of-support is 2026-09-01** (#1278) β€” migrate with the Ops Agent `/migrate-to-postgres` skill. +- **#1159** introduces `AGENT_AUTH_SECRET` (auto-generated by `start.sh`, like `SECRET_KEY`). Existing agents do one self-reconciling recreate pass to pick up their injected token. +- **#1187** adds the `AGENT_RUNTIME` selector (Codex / Gemini / Claude Code); existing agents default to Claude Code. + +## CLI + +`trinity-cli` auto-publishes **0.2.7** via the main-push path (license β†’ Apache 2.0 +#1192; credential + email-send removed from `trinity init` #1162). + +--- + +**Contributors:** Eugene Vyborov, dolho, andrii.pasternak, vybe, obasilakis, +oleksandr-korin, Pavlo Shulin, Oleksii Dolhov, Alex, dependabot, chrisyangxiaoqi, +webmixgamer. diff --git a/src/backend/db/schedules.py b/src/backend/db/schedules.py index 62e40a37..c6cb1d38 100644 --- a/src/backend/db/schedules.py +++ b/src/backend/db/schedules.py @@ -1934,68 +1934,75 @@ def get_agent_schedules_summary(self, agent_name: str, hours: int) -> Dict: cap = _PERCENTILE_ROWSET_CAP FAILED_STATES = (TaskExecutionStatus.FAILED, "error") - with get_db_connection() as conn: - cursor = conn.cursor() - - # Schedules (non-deleted) β€” the authoritative row set so a - # zero-run schedule still appears. - cursor.execute( - """ - SELECT id, name, message, cron_expression, enabled - FROM agent_schedules - WHERE agent_name = ? AND deleted_at IS NULL - ORDER BY created_at ASC - """, - (agent_name,), - ) - schedule_rows = cursor.fetchall() + # #300/#1115: ported to SQLAlchemy Core so the summary works on both + # SQLite and PostgreSQL (the earlier raw-sqlite path NameError'd on the + # dropped get_db_connection import, and the bare-column-with-MAX last-run + # trick is SQLite-only β€” replaced by a portable ROW_NUMBER window below). + se = schedule_executions.c + sch = agent_schedules.c + base_where = and_(se.agent_name == agent_name, se.started_at > cutoff) + + # Schedules (non-deleted) β€” the authoritative row set so a + # zero-run schedule still appears. + q_sched = ( + select(sch.id, sch.name, sch.message, sch.cron_expression, sch.enabled) + .where(and_(sch.agent_name == agent_name, sch.deleted_at.is_(None))) + .order_by(sch.created_at.asc()) + ) - # One grouped aggregate for every schedule's executions in window. - cursor.execute( - """ - SELECT - schedule_id, - COUNT(*) AS total, - SUM(CASE WHEN status = 'success' THEN 1 ELSE 0 END) AS success_count, - SUM(CASE WHEN status IN ('failed', 'error') THEN 1 ELSE 0 END) AS failed_count, - SUM(CASE WHEN status = 'cancelled' THEN 1 ELSE 0 END) AS cancelled_count, - SUM(COALESCE(cost, 0)) AS cost_total, - AVG(CASE WHEN duration_ms IS NOT NULL THEN duration_ms END) AS avg_duration_ms, - AVG(CASE WHEN context_used IS NOT NULL THEN context_used END) AS context_avg - FROM schedule_executions - WHERE agent_name = ? AND started_at > ? - GROUP BY schedule_id - """, - (agent_name, cutoff), + # One grouped aggregate for every schedule's executions in window. + # AVG skips NULLs natively, matching the prior CASE-wrapped AVG. + q_agg = ( + select( + se.schedule_id, + func.count().label("total"), + func.sum(case((se.status == "success", 1), else_=0)).label("success_count"), + func.sum(case((se.status.in_(("failed", "error")), 1), else_=0)).label("failed_count"), + func.sum(case((se.status == "cancelled", 1), else_=0)).label("cancelled_count"), + func.sum(func.coalesce(se.cost, 0)).label("cost_total"), + func.avg(se.duration_ms).label("avg_duration_ms"), + func.avg(se.context_used).label("context_avg"), ) - agg_by_sched = {r["schedule_id"]: r for r in cursor.fetchall()} + .where(base_where) + .group_by(se.schedule_id) + ) - # Last-run outcome per schedule. SQLite's bare-column-with-MAX - # rule returns the row holding the max started_at. - cursor.execute( - """ - SELECT schedule_id, MAX(started_at) AS last_run_at, status AS last_status - FROM schedule_executions - WHERE agent_name = ? AND started_at > ? - GROUP BY schedule_id - """, - (agent_name, cutoff), + # Last-run outcome per schedule β€” portable ROW_NUMBER window (replaces + # SQLite's bare-column-with-MAX, which errors on PostgreSQL) keeping the + # in-window semantics of the original query. + _last_rn = func.row_number().over( + partition_by=se.schedule_id, order_by=se.started_at.desc() + ).label("rn") + _last_subq = ( + select( + se.schedule_id, + se.started_at.label("last_run_at"), + se.status.label("last_status"), + _last_rn, ) - last_by_sched = {r["schedule_id"]: r for r in cursor.fetchall()} + .where(base_where) + .subquery() + ) + q_last = select( + _last_subq.c.schedule_id, + _last_subq.c.last_run_at, + _last_subq.c.last_status, + ).where(_last_subq.c.rn == 1) + + # Tool-call totals β€” bounded JSON parse over newest rows agent-wide + # (cap + 1 to detect sampling), attributed back per schedule. + q_tools = ( + select(se.schedule_id, se.tool_calls) + .where(and_(base_where, se.tool_calls.isnot(None))) + .order_by(se.started_at.desc()) + .limit(cap + 1) + ) - # Tool-call totals β€” bounded JSON parse over newest rows agent-wide - # (cap + 1 to detect sampling), attributed back per schedule. - cursor.execute( - """ - SELECT schedule_id, tool_calls - FROM schedule_executions - WHERE agent_name = ? AND started_at > ? AND tool_calls IS NOT NULL - ORDER BY started_at DESC - LIMIT ? - """, - (agent_name, cutoff, cap + 1), - ) - tool_rows = cursor.fetchall() + with get_engine().connect() as conn: + schedule_rows = conn.execute(q_sched).mappings().all() + agg_by_sched = {r["schedule_id"]: r for r in conn.execute(q_agg).mappings().all()} + last_by_sched = {r["schedule_id"]: r for r in conn.execute(q_last).mappings().all()} + tool_rows = conn.execute(q_tools).mappings().all() tool_calls_sampled = len(tool_rows) > cap tool_total_by_sched: Dict[str, int] = defaultdict(int) diff --git a/tests/unit/test_login_rate_limit_split.py b/tests/unit/test_login_rate_limit_split.py index 904ead37..5c53dbfd 100644 --- a/tests/unit/test_login_rate_limit_split.py +++ b/tests/unit/test_login_rate_limit_split.py @@ -118,6 +118,9 @@ def auth_module(monkeypatch): config_mod.ACCESS_TOKEN_EXPIRE_MINUTES = 60 config_mod.EMAIL_AUTH_ENABLED = False config_mod.REDIS_URL = "redis://stub" + # auth.py grew a PUBLIC_ACCESS_REQUESTS_ENABLED dependency (trinity-enterprise#10); + # keep this stub in sync or its import fails under HEAD. + config_mod.PUBLIC_ACCESS_REQUESTS_ENABLED = False stubs["config"] = config_mod database_mod = types.ModuleType("database")