diff --git a/.github/workflows/parity-check.yml b/.github/workflows/parity-check.yml new file mode 100644 index 0000000..218d726 --- /dev/null +++ b/.github/workflows/parity-check.yml @@ -0,0 +1,129 @@ +name: parity-check + +# Soft-enforcement reminder when a PR modifies a file tracked in +# parity-manifest.json. Posts a comment listing the touched tracked +# files and asks the author to cross-reference the private cueapi +# monorepo. NEVER blocks merge — exits 0 regardless. See HOSTED_ONLY.md +# for the open-core policy this enforces. + +on: + pull_request: + types: [opened, synchronize, reopened] + +permissions: + contents: read + pull-requests: write + +jobs: + parity-check: + runs-on: ubuntu-latest + steps: + - name: Checkout PR head + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + with: + fetch-depth: 0 + + - name: Check if manifest exists + id: manifest_exists + run: | + if [ -f parity-manifest.json ]; then + echo "exists=true" >> "$GITHUB_OUTPUT" + else + echo "exists=false" >> "$GITHUB_OUTPUT" + echo "::notice::parity-manifest.json not found — skipping parity check." + fi + + - name: Compute touched tracked files + if: steps.manifest_exists.outputs.exists == 'true' + id: compute + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: | + set -euo pipefail + + # Union of staged + PR diff + git diff --name-only "$BASE_SHA" "$HEAD_SHA" > /tmp/touched.txt || true + + # Extract every tracked path from parity-manifest.json + # (flat list across all subdirectory groups) + python3 - <<'PY' > /tmp/tracked.txt + import json + with open("parity-manifest.json") as f: + manifest = json.load(f) + files = manifest.get("files", {}) + if isinstance(files, list): + entries = files + else: + entries = [] + for group in files.values(): + entries.extend(group) + for e in entries: + print(e["path"]) + PY + + # Intersection — tracked files this PR touches + grep -Fxf /tmp/tracked.txt /tmp/touched.txt > /tmp/matches.txt || true + + if [ -s /tmp/matches.txt ]; then + echo "has_matches=true" >> "$GITHUB_OUTPUT" + { + echo 'matches<> "$GITHUB_OUTPUT" + else + echo "has_matches=false" >> "$GITHUB_OUTPUT" + fi + + - name: Post parity-check comment + if: steps.manifest_exists.outputs.exists == 'true' && steps.compute.outputs.has_matches == 'true' + uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 + env: + MATCHES: ${{ steps.compute.outputs.matches }} + with: + script: | + const matches = process.env.MATCHES.trim().split('\n').filter(Boolean); + const fileList = matches.map(f => `- \`${f}\``).join('\n'); + const marker = ''; + const body = `${marker} + ## Parity check + + This PR modifies files tracked in [\`parity-manifest.json\`](../blob/main/parity-manifest.json): + + ${fileList} + + Please confirm **one** of the following in a reply or PR description update: + + 1. **The equivalent change has been applied to the private cueapi monorepo.** Link the PR. + 2. **This change is OSS-only and does not need porting.** Briefly explain why (e.g. "fixes a bug that only exists in the OSS build"). + 3. **A follow-up issue has been filed to port the reverse direction.** Link the issue. + + This is a soft check — it does not block merge. The goal is visibility, not friction. See [HOSTED_ONLY.md](../blob/main/HOSTED_ONLY.md) for the open-core policy.`; + + // Update-in-place: if we've commented before, edit instead of spamming + const {data: comments} = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + }); + const existing = comments.find(c => c.body && c.body.includes(marker)); + if (existing) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existing.id, + body, + }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body, + }); + } + + - name: No tracked files touched + if: steps.manifest_exists.outputs.exists == 'true' && steps.compute.outputs.has_matches != 'true' + run: echo "::notice::No parity-tracked files modified in this PR." diff --git a/CHANGELOG.md b/CHANGELOG.md index 94fa054..a5ccf2a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,10 @@ All notable changes to cueapi-core will be documented here. - **Dedup** — alerts collapse on `(user_id, alert_type, execution_id)` inside a 5-minute window. - **Migrations 018 + 019** — alerts table with indexes and CHECK constraints; two columns on users. - `examples/alert_webhook_receiver.py` — 30-line Flask receiver demonstrating signature verification. +- `HOSTED_ONLY.md` documenting the open-core policy — which features are OSS, which are intentionally hosted-only on cueapi.ai, and why. +- `parity-manifest.json` enumerating files that have a same-path counterpart in the private cueapi monorepo. Used by the new parity-check workflow. +- `.github/workflows/parity-check.yml` — soft-enforcement CI that posts a comment on PRs which touch tracked files, asking the author to cross-reference the private repo. Never blocks merge; exits 0 regardless. +- README "Open core model" section near the top, linking to `HOSTED_ONLY.md`. ### Changed - **`POST /v1/executions/{id}/verify`** now accepts `{valid: bool, reason: str?}`. `valid=true` (default, preserving legacy behavior) transitions to `verified_success`; `valid=false` transitions to `verification_failed` and records the reason onto `evidence_summary` (truncated to 500 chars). Accepted starting states expanded to include `reported_failure`. diff --git a/HOSTED_ONLY.md b/HOSTED_ONLY.md new file mode 100644 index 0000000..700cc42 --- /dev/null +++ b/HOSTED_ONLY.md @@ -0,0 +1,51 @@ +# Hosted-only features + +cueapi-core is the open-source primitive. [cueapi.ai](https://cueapi.ai) is the hosted product built on top. Some capabilities are intentionally hosted-only; this document explains which and why. + +The line is drawn at: **scheduling + delivery + outcome reporting is OSS. Everything that's a SaaS business layer, a paid-API integration, or experimental product surface is hosted-only.** If you're self-hosting, you're running the same scheduling/delivery/outcome engine that powers cueapi.ai — no crippled OSS tier. + +## Hosted-only capabilities + +| Feature | Why hosted-only | +|---|---| +| Stripe billing | SaaS business layer. Self-hosters manage their own limits and don't need a payment processor. | +| GDPR endpoints (deletion, export, processing records) | Hosted-service compliance obligation. Self-hosters own their own legal surface and know which jurisdictions apply to them; a one-size policy baked into OSS would be wrong for most. | +| Blog content pipeline | Marketing infrastructure for cueapi.ai. Not a general-purpose feature. | +| Memory blocks | Product experiment not yet public. May graduate to OSS once the shape stabilizes. | +| Support tickets → GitHub issues routing | cueapi.ai customer-support automation. Self-hosters file issues directly on this repo. | +| Jenny docs chatbot | cueapi.ai-specific documentation UI. | +| Deploy hook (Railway staging) | cueapi.ai CI/CD infrastructure. | +| Dashboard (React UI) | Hosted-only. cueapi-core is API-first — build your own UI, or use the hosted dashboard at cueapi.ai. | +| Email alert delivery (SendGrid) | Paid-API integration. OSS ships **webhook-based** alert delivery instead: configure an `alert_webhook_url` on your user and forward alerts to your own Slack/Discord/ntfy/SMTP-relay pipeline. See README's "Alerts" section. | + +## What's in cueapi-core + +Everything needed to run a production scheduler with outcome tracking: + +- Cue CRUD, scheduling, cron parsing, timezone handling +- Execution lifecycle, worker transport, webhook transport, heartbeats, replays +- Outcome reporting with verification modes and evidence +- Webhook HMAC signing, SSRF protection, retry-with-backoff +- Alert firing (via webhook delivery; add your own `alert_webhook_url`) +- API keys, device-code auth, session refresh, rate limiting +- At-least-once delivery via transactional outbox + +Full feature list: see [README.md](README.md). + +## Contributing a port + +If you need a hosted-only feature in cueapi-core, open a GitHub issue with: + +1. Your use case (what you're building, what breaks without it) +2. A rough idea of the OSS-compatible design (e.g. "swap SendGrid for a pluggable `AlertDeliveryBackend` interface") +3. Whether you'd be willing to submit the PR yourself + +Community-driven ports are welcome. The hosted-only list is not permanent — features may move to OSS over time based on demand and on whether a self-hostable design exists. + +## Maintainer note + +If you're a cueapi maintainer porting a private-monorepo change: + +- Check [`parity-manifest.json`](parity-manifest.json) to see whether the file you're touching has an OSS counterpart. +- The `parity-check` GitHub Action posts a soft warning on PRs that modify tracked files, prompting you to link the OSS PR (or file a follow-up issue). +- See the private monorepo's internal docs for the reverse direction — what to sync when OSS changes first. diff --git a/README.md b/README.md index eba5139..b9a5761 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,12 @@ The open source server for CueAPI. Run it yourself with Docker. Hosted at [cueapi.ai](https://cueapi.ai). +## Open core model + +cueapi-core is the scheduling + delivery + outcome-tracking engine. Hosted cueapi.ai adds a dashboard, managed email alerts, billing, and a few other SaaS-business-layer features — see [HOSTED_ONLY.md](HOSTED_ONLY.md) for the full list and reasoning. Nothing in the OSS scheduler is crippled; what's here is what runs in production. + +If you want a hosted-only feature ported to OSS, [open an issue](https://github.com/cueapi/cueapi-core/issues/new) — see the "Contributing a port" section in [HOSTED_ONLY.md](HOSTED_ONLY.md). + --- ## The problem with cron diff --git a/parity-manifest.json b/parity-manifest.json new file mode 100644 index 0000000..84c2e0e --- /dev/null +++ b/parity-manifest.json @@ -0,0 +1,115 @@ +{ + "manifest_version": 1, + "description": "Files in cueapi-core that have a same-path counterpart in the private cueapi monorepo. Changes to the private counterpart should be considered for porting here; changes here should be cross-referenced to the private repo. The parity-check GitHub Action posts a soft warning on PRs that touch any of these paths. See HOSTED_ONLY.md for the open-core policy.", + "last_full_audit": "2026-04-16", + "methodology": "Enumerate app/**/*.py and alembic/**/*.py in cueapi-core; include every file whose same-path counterpart exists in the private cueapi clone at audit time. Files that are OSS-only (no private counterpart) are intentionally excluded — modifications to them do not need a parity cross-reference.", + "oss_only_exclusions": [ + { + "path": "app/services/email_service.py", + "reason": "OSS ships a minimal email service that delegates to SMTP. Private uses SendGrid and a different module layout." + }, + { + "path": "alembic/versions/017_add_verification_mode.py", + "reason": "OSS-specific filename. Hosted ships equivalent verification_mode functionality at a different migration number (hosted's 017 is 017_add_support_ticket_updated_at.py). Feature parity is at the app-layer (cue.py, schemas, services) — those files ARE in the manifest." + }, + { + "path": "alembic/versions/018_add_alerts_table.py", + "reason": "OSS-specific filename. Hosted's alerts table was added earlier at a different migration number. App-layer parity is tracked via alert.py / alerts.py / alert_service.py entries." + }, + { + "path": "alembic/versions/019_add_user_alert_webhook.py", + "reason": "OSS-specific filename. Hosted back-ported this as migration 038_add_user_alert_webhook.py (staging PR #259). Feature is the same; path differs due to independent migration histories." + }, + { + "path": "examples/alert_webhook_receiver.py", + "reason": "OSS-only example. Hosted has no examples/ directory; this is a self-hoster-facing reference." + } + ], + "files": { + "alembic": [ + {"path": "alembic/env.py", "private_counterpart": "alembic/env.py", "last_synced": "2026-04-16"}, + {"path": "alembic/versions/001_initial_tables.py", "private_counterpart": "alembic/versions/001_initial_tables.py", "last_synced": "2026-04-16"}, + {"path": "alembic/versions/002_executions_and_outbox.py", "private_counterpart": "alembic/versions/002_executions_and_outbox.py", "last_synced": "2026-04-16"}, + {"path": "alembic/versions/003_usage_monthly.py", "private_counterpart": "alembic/versions/003_usage_monthly.py", "last_synced": "2026-04-16"}, + {"path": "alembic/versions/004_device_codes.py", "private_counterpart": "alembic/versions/004_device_codes.py", "last_synced": "2026-04-16"}, + {"path": "alembic/versions/005_add_retry_ready_status.py", "private_counterpart": "alembic/versions/005_add_retry_ready_status.py", "last_synced": "2026-04-16"}, + {"path": "alembic/versions/006_add_outcome_columns.py", "private_counterpart": "alembic/versions/006_add_outcome_columns.py", "last_synced": "2026-04-16"}, + {"path": "alembic/versions/007_worker_transport.py", "private_counterpart": "alembic/versions/007_worker_transport.py", "last_synced": "2026-04-16"}, + {"path": "alembic/versions/008_add_webhook_secret.py", "private_counterpart": "alembic/versions/008_add_webhook_secret.py", "last_synced": "2026-04-16"}, + {"path": "alembic/versions/009_fired_count_and_missed_status.py", "private_counterpart": "alembic/versions/009_fired_count_and_missed_status.py", "last_synced": "2026-04-16"}, + {"path": "alembic/versions/010_unique_user_cue_name.py", "private_counterpart": "alembic/versions/010_unique_user_cue_name.py", "last_synced": "2026-04-16"}, + {"path": "alembic/versions/011_add_blog_posts.py", "private_counterpart": "alembic/versions/011_add_blog_posts.py", "last_synced": "2026-04-16"}, + {"path": "alembic/versions/012_add_content_trends.py", "private_counterpart": "alembic/versions/012_add_content_trends.py", "last_synced": "2026-04-16"}, + {"path": "alembic/versions/013_add_blog_image_data.py", "private_counterpart": "alembic/versions/013_add_blog_image_data.py", "last_synced": "2026-04-16"}, + {"path": "alembic/versions/014_add_support_tickets.py", "private_counterpart": "alembic/versions/014_add_support_tickets.py", "last_synced": "2026-04-16"}, + {"path": "alembic/versions/015_widen_device_code_column.py", "private_counterpart": "alembic/versions/015_widen_device_code_column.py", "last_synced": "2026-04-16"}, + {"path": "alembic/versions/016_add_on_failure_column.py", "private_counterpart": "alembic/versions/016_add_on_failure_column.py", "last_synced": "2026-04-16"} + ], + "app_core": [ + {"path": "app/__init__.py", "private_counterpart": "app/__init__.py", "last_synced": "2026-04-16"}, + {"path": "app/auth.py", "private_counterpart": "app/auth.py", "last_synced": "2026-04-16"}, + {"path": "app/config.py", "private_counterpart": "app/config.py", "last_synced": "2026-04-16"}, + {"path": "app/database.py", "private_counterpart": "app/database.py", "last_synced": "2026-04-16"}, + {"path": "app/main.py", "private_counterpart": "app/main.py", "last_synced": "2026-04-16"}, + {"path": "app/redis.py", "private_counterpart": "app/redis.py", "last_synced": "2026-04-16"} + ], + "middleware": [ + {"path": "app/middleware/__init__.py", "private_counterpart": "app/middleware/__init__.py", "last_synced": "2026-04-16"}, + {"path": "app/middleware/body_limit.py", "private_counterpart": "app/middleware/body_limit.py", "last_synced": "2026-04-16"}, + {"path": "app/middleware/rate_limit.py", "private_counterpart": "app/middleware/rate_limit.py", "last_synced": "2026-04-16"}, + {"path": "app/middleware/request_id.py", "private_counterpart": "app/middleware/request_id.py", "last_synced": "2026-04-16"} + ], + "models": [ + {"path": "app/models/__init__.py", "private_counterpart": "app/models/__init__.py", "last_synced": "2026-04-16"}, + {"path": "app/models/alert.py", "private_counterpart": "app/models/alert.py", "last_synced": "2026-04-17"}, + {"path": "app/models/cue.py", "private_counterpart": "app/models/cue.py", "last_synced": "2026-04-16"}, + {"path": "app/models/device_code.py", "private_counterpart": "app/models/device_code.py", "last_synced": "2026-04-16"}, + {"path": "app/models/dispatch_outbox.py", "private_counterpart": "app/models/dispatch_outbox.py", "last_synced": "2026-04-16"}, + {"path": "app/models/execution.py", "private_counterpart": "app/models/execution.py", "last_synced": "2026-04-16"}, + {"path": "app/models/usage_monthly.py", "private_counterpart": "app/models/usage_monthly.py", "last_synced": "2026-04-16"}, + {"path": "app/models/user.py", "private_counterpart": "app/models/user.py", "last_synced": "2026-04-16"}, + {"path": "app/models/worker.py", "private_counterpart": "app/models/worker.py", "last_synced": "2026-04-16"} + ], + "routers": [ + {"path": "app/routers/__init__.py", "private_counterpart": "app/routers/__init__.py", "last_synced": "2026-04-16"}, + {"path": "app/routers/alerts.py", "private_counterpart": "app/routers/alerts.py", "last_synced": "2026-04-17"}, + {"path": "app/routers/auth_routes.py", "private_counterpart": "app/routers/auth_routes.py", "last_synced": "2026-04-16"}, + {"path": "app/routers/cues.py", "private_counterpart": "app/routers/cues.py", "last_synced": "2026-04-16"}, + {"path": "app/routers/device_code.py", "private_counterpart": "app/routers/device_code.py", "last_synced": "2026-04-16"}, + {"path": "app/routers/echo.py", "private_counterpart": "app/routers/echo.py", "last_synced": "2026-04-16"}, + {"path": "app/routers/executions.py", "private_counterpart": "app/routers/executions.py", "last_synced": "2026-04-16"}, + {"path": "app/routers/health.py", "private_counterpart": "app/routers/health.py", "last_synced": "2026-04-16"}, + {"path": "app/routers/usage.py", "private_counterpart": "app/routers/usage.py", "last_synced": "2026-04-16"}, + {"path": "app/routers/webhook_secret.py", "private_counterpart": "app/routers/webhook_secret.py", "last_synced": "2026-04-16"}, + {"path": "app/routers/workers.py", "private_counterpart": "app/routers/workers.py", "last_synced": "2026-04-16"} + ], + "schemas": [ + {"path": "app/schemas/__init__.py", "private_counterpart": "app/schemas/__init__.py", "last_synced": "2026-04-16"}, + {"path": "app/schemas/alert.py", "private_counterpart": "app/schemas/alert.py", "last_synced": "2026-04-17"}, + {"path": "app/schemas/cue.py", "private_counterpart": "app/schemas/cue.py", "last_synced": "2026-04-16"}, + {"path": "app/schemas/execution.py", "private_counterpart": "app/schemas/execution.py", "last_synced": "2026-04-16"}, + {"path": "app/schemas/outcome.py", "private_counterpart": "app/schemas/outcome.py", "last_synced": "2026-04-16"}, + {"path": "app/schemas/worker.py", "private_counterpart": "app/schemas/worker.py", "last_synced": "2026-04-16"} + ], + "services": [ + {"path": "app/services/__init__.py", "private_counterpart": "app/services/__init__.py", "last_synced": "2026-04-16"}, + {"path": "app/services/alert_service.py", "private_counterpart": "app/services/alert_service.py", "last_synced": "2026-04-17"}, + {"path": "app/services/alert_webhook.py", "private_counterpart": "app/services/alert_webhook.py", "last_synced": "2026-04-17"}, + {"path": "app/services/cue_service.py", "private_counterpart": "app/services/cue_service.py", "last_synced": "2026-04-16"}, + {"path": "app/services/device_code_service.py", "private_counterpart": "app/services/device_code_service.py", "last_synced": "2026-04-16"}, + {"path": "app/services/outcome_service.py", "private_counterpart": "app/services/outcome_service.py", "last_synced": "2026-04-16"}, + {"path": "app/services/usage_service.py", "private_counterpart": "app/services/usage_service.py", "last_synced": "2026-04-16"}, + {"path": "app/services/webhook.py", "private_counterpart": "app/services/webhook.py", "last_synced": "2026-04-16"} + ], + "utils": [ + {"path": "app/utils/__init__.py", "private_counterpart": "app/utils/__init__.py", "last_synced": "2026-04-16"}, + {"path": "app/utils/auth_rate_limit.py", "private_counterpart": "app/utils/auth_rate_limit.py", "last_synced": "2026-04-16"}, + {"path": "app/utils/ids.py", "private_counterpart": "app/utils/ids.py", "last_synced": "2026-04-16"}, + {"path": "app/utils/logging.py", "private_counterpart": "app/utils/logging.py", "last_synced": "2026-04-16"}, + {"path": "app/utils/session.py", "private_counterpart": "app/utils/session.py", "last_synced": "2026-04-16"}, + {"path": "app/utils/signing.py", "private_counterpart": "app/utils/signing.py", "last_synced": "2026-04-16"}, + {"path": "app/utils/templates.py", "private_counterpart": "app/utils/templates.py", "last_synced": "2026-04-16"}, + {"path": "app/utils/url_validation.py", "private_counterpart": "app/utils/url_validation.py", "last_synced": "2026-04-16"} + ] + } +}