Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,8 @@ TRIAGE_CONFIDENCE_THRESHOLD=0.6 # below -> route to human, no auto-close

# Layer 4 — Cost
MAX_INCIDENT_USD=0.50 # circuit breaker per incident
ACCOUNT_RUN_LIMIT=3 # lifetime runs per Google account
# Optional dev override — format email:limit (set in .env only, never commit)
ACCOUNT_RUN_LIMIT=5 # lifetime runs per Google account (code default: 5)
# Optional — production: set GitHub secret QUOTA_OVERRIDE_EMAIL=you@gmail.com:20
QUOTA_OVERRIDE_EMAIL=your@gmail.com:20

# Layer 5 — Durability (reuse existing Upstash creds)
Expand Down
7 changes: 5 additions & 2 deletions .github/workflows/cd-backend.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@
# SLACK_WEBHOOK_URL https://hooks.slack.com/services/...
# SOURCIQ_API_URL https://pqgvdsz0wc.execute-api.eu-north-1.amazonaws.com/prod
# GOOGLE_CLIENT_ID ...apps.googleusercontent.com
# ALLOWED_ORIGINS https://your-opscanvas.vercel.app
# ALLOWED_ORIGINS https://opscanvas-pi.vercel.app
# QUOTA_OVERRIDE_EMAIL you@gmail.com:20 (optional — owner/demo quota boost)
# =================================================================

name: CD — Backend
Expand Down Expand Up @@ -87,7 +88,9 @@ jobs:
SlackWebhookUrl="${{ secrets.SLACK_WEBHOOK_URL }}" \
SourceciqApiUrl="${{ secrets.SOURCIQ_API_URL }}" \
GoogleClientId="${{ secrets.GOOGLE_CLIENT_ID }}" \
AllowedOrigins="${{ secrets.ALLOWED_ORIGINS }}"
AllowedOrigins="${{ secrets.ALLOWED_ORIGINS }}" \
AccountRunLimit="5" \
QuotaOverrideEmail="${{ secrets.QUOTA_OVERRIDE_EMAIL }}"

- name: Get Lambda URL from CloudFormation
id: get-url
Expand Down
184 changes: 147 additions & 37 deletions README.md

Large diffs are not rendered by default.

8 changes: 8 additions & 0 deletions backend/app/core/run_quota.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,14 @@ def _parse_quota_override() -> tuple[str, int] | None:
return email, limit


_override_boot = _parse_quota_override()
if _override_boot:
logger.info(
"QUOTA_OVERRIDE_EMAIL active — limit %d for matching account",
_override_boot[1],
)


def get_run_limit(email: str | None = None) -> int:
"""Return max runs for this account (default ACCOUNT_RUN_LIMIT)."""
if email:
Expand Down
13 changes: 12 additions & 1 deletion backend/template.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,16 @@ Parameters:

AllowedOrigins:
Type: String
Default: "https://opscanvas.vercel.app"
Default: "https://opscanvas-pi.vercel.app"

AccountRunLimit:
Type: String
Default: "5"

QuotaOverrideEmail:
Type: String
NoEcho: true
Default: ""

Resources:

Expand All @@ -79,6 +88,8 @@ Resources:
GOOGLE_CLIENT_ID: !Ref GoogleClientId
ALLOWED_ORIGINS: !Ref AllowedOrigins
APP_ENV: !Ref Environment
ACCOUNT_RUN_LIMIT: !Ref AccountRunLimit
QUOTA_OVERRIDE_EMAIL: !Ref QuotaOverrideEmail

Events:
ApiProxy:
Expand Down
4 changes: 2 additions & 2 deletions docs/ACCEPTANCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,8 +82,8 @@ Use this before calling the system production-ready. **Merge gate** vs **quality
|-------|-------------------|
| Checkpoint pause | Graph pauses before `action`; Redis + LangGraph checkpoint |
| Cold resume | Kill process at pause → new process → approve → continues |
| Idempotent Slack | Resume never double-posts (`opscanvas:sent:{run_id}`) |
| Approval TTL | Pending > `APPROVAL_TTL_HOURS` → `expired` |
| Idempotent Slack | Resume never double-posts (`opscanvas:sent:{run_id}`, 24h) |
| Approval / state TTL | Pending > `APPROVAL_TTL_HOURS` (default 10h) → `expired`; run keys ~12h (10h + 2h grace) |

**Verify:** `pytest tests/test_durability.py -v` + manual kill/resume smoke.

Expand Down
2 changes: 1 addition & 1 deletion docs/COST.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ OpsCanvas meters spend per `run_id` across all LLM and Tavily calls. Totals appe
| Variable | Default | Purpose |
|----------|---------|---------|
| `MAX_INCIDENT_USD` | `0.50` | Circuit breaker per incident |
| `ACCOUNT_RUN_LIMIT` | `5` (code) / `3` (.env.example) | Lifetime runs per Google account |
| `ACCOUNT_RUN_LIMIT` | `5` | Lifetime runs per Google account |

## Interview line

Expand Down
40 changes: 35 additions & 5 deletions frontend/src/pages/DashboardPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -192,11 +192,14 @@ function QuotaBadge({ quota }: { quota: QuotaInfo }) {

const label =
quota.runs_remaining === 0
? "No runs remaining"
: `${quota.runs_remaining} run${quota.runs_remaining !== 1 ? "s" : ""} remaining`;
? `No runs left (${quota.runs_limit} max)`
: `${quota.runs_remaining} of ${quota.runs_limit} runs left`;

return (
<div className={`flex items-center gap-1.5 px-2.5 py-1 rounded-full border text-xs font-medium transition-colors ${cls}`}>
<div
className={`flex items-center gap-1.5 px-2.5 py-1 rounded-full border text-xs font-medium transition-colors ${cls}`}
title={`${quota.runs_used ?? quota.runs_limit - quota.runs_remaining} used · ${quota.runs_limit} lifetime limit`}
>
<span className={`w-1.5 h-1.5 rounded-full flex-shrink-0 ${dotCls}`} />
{label}
</div>
Expand All @@ -208,10 +211,26 @@ export default function DashboardPage() {
const [appStatus, setAppStatus] = useState<AppStatus>({ type: "idle" });
const [pollSeed, setPollSeed] = useState<IncidentRun | null>(null);
const [quota, setQuota] = useState<QuotaInfo | null>(null);
const [quotaLoading, setQuotaLoading] = useState(false);
const [quotaError, setQuotaError] = useState(false);

const refreshQuota = useCallback(() => {
if (!credential) return;
api.getQuota(credential).then(setQuota).catch(() => null);
setQuotaLoading(true);
setQuotaError(false);
api.getQuota(credential)
.then((data) => {
setQuota({
runs_used: data.runs_used,
runs_remaining: data.runs_remaining,
runs_limit: data.runs_limit,
});
})
.catch(() => {
setQuota(null);
setQuotaError(true);
})
.finally(() => setQuotaLoading(false));
}, [credential]);

useEffect(() => {
Expand Down Expand Up @@ -419,7 +438,18 @@ export default function DashboardPage() {
</div>
)}

{quota !== null && <QuotaBadge quota={quota} />}
{quotaLoading && (
<span className="text-xs text-ink-tertiary">Loading quota…</span>
)}
{!quotaLoading && quota !== null && <QuotaBadge quota={quota} />}
{!quotaLoading && quota === null && quotaError && (
<span
className="text-xs text-p1-text border border-p1-border bg-p1-bg px-2 py-1 rounded-full"
title="Could not reach GET /api/quota — redeploy backend or check API URL"
>
Quota unavailable
</span>
)}

{(appStatus.type === "completed" || appStatus.type === "failed") && (
<button
Expand Down
11 changes: 8 additions & 3 deletions infra/secrets-reference.sh
Original file line number Diff line number Diff line change
Expand Up @@ -71,11 +71,16 @@ cat << 'GUIDE'
── Lambda CORS ───────────────────────────────────────────────

ALLOWED_ORIGINS
https://your-opscanvas.vercel.app
Set after first Vercel deploy
https://opscanvas-pi.vercel.app
Production frontend (Vercel)

QUOTA_OVERRIDE_EMAIL (optional)
you@gmail.com:20
Raises lifetime run cap for one Google account in production Lambda.
Must match the email on the id_token exactly (lowercase). Not in git.

================================================================
Total: 15 secrets. Zero stored in repository.
Total: 16 secrets (15 required + 1 optional quota override).
================================================================

Security:
Expand Down
Loading