diff --git a/.env.example b/.env.example
index fd41b47..f61e3ef 100644
--- a/.env.example
+++ b/.env.example
@@ -59,8 +59,8 @@ CHECKPOINT_BACKEND=memory
# Slack — leave unset to disable notifications
# SLACK_WEBHOOK_URL=https://hooks.slack.com/services/xxx/yyy/zzz
-# Proactive polling — set POLL_INTERVAL_MINUTES>0 to enable
-# POLL_INTERVAL_MINUTES=5 # how often to check (minutes); 0 = disabled
+# Proactive polling — set POLL_INTERVAL_SECONDS>0 to enable
+# POLL_INTERVAL_SECONDS=300 # how often to check (seconds); 0 = disabled; 20 for dev testing
# POLL_ERROR_THRESHOLD=5.0 # Lambda error rate % that triggers an investigation
# POLL_REINVESTIGATE_HOURS=1 # don't re-investigate the same alarm within N hours
diff --git a/CLAUDE.md b/CLAUDE.md
index 00a2ea7..834af7b 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -99,7 +99,7 @@ Everything below is built and working in the codebase:
- Starts automatically on app startup if `event_consumer_enabled=True`, `sqs_queue_url` is set, or `init.json` marks the wizard as completed.
### Proactive Polling
-- `polling_loop()` runs every `POLL_INTERVAL_MINUTES` minutes (disabled by default at 0); checks CloudWatch alarms in ALARM state and Lambda error rates above `POLL_ERROR_THRESHOLD`; auto-investigates new anomalies and posts to Slack. In-memory dedup via `_last_investigated` map.
+- `polling_loop()` runs every `POLL_INTERVAL_SECONDS` seconds (disabled by default at 0); checks CloudWatch alarms in ALARM state and Lambda error rates above `POLL_ERROR_THRESHOLD`; auto-investigates new anomalies and posts to Slack. In-memory dedup via `_last_investigated` map.
### Frontend
- React 18 + TypeScript + Vite + Tailwind CSS + `@tailwindcss/typography`
@@ -211,7 +211,7 @@ JWT_EXPIRE_MINUTES=1440 # 24 hours
SLACK_WEBHOOK_URL= # leave unset to disable
# Proactive polling
-POLL_INTERVAL_MINUTES=0 # 0 = disabled
+POLL_INTERVAL_SECONDS=0 # 0 = disabled; set to e.g. 300 (5 min) to enable
POLL_ERROR_THRESHOLD=5.0 # Lambda error rate % to trigger investigation
POLL_REINVESTIGATE_HOURS=1 # dedup window
@@ -254,7 +254,7 @@ EventBridge rules (9 event types)
### Startup sequence
```
db.init() → init_agent(checkpointer)
- optional: asyncio.create_task(polling_loop()) if POLL_INTERVAL_MINUTES > 0
+ optional: asyncio.create_task(polling_loop()) if POLL_INTERVAL_SECONDS > 0
optional: asyncio.create_task(event_consumer_loop()) if SQS configured or init.json present
```
diff --git a/README.md b/README.md
index 9579b03..2f26ea1 100644
--- a/README.md
+++ b/README.md
@@ -13,7 +13,7 @@ and gives actionable mitigation plans — without the AWS DevOps Agent price tag
- **Event-driven incident detection** — EventBridge → SQS → long-poll consumer; 9 EventBridge rules cover CloudWatch alarms, ECS task failures, Lambda async errors, RDS events, EC2 state changes, CodePipeline failures, and AWS Health events; runs alongside the metric poller — see [docs/event_detection.md](docs/event_detection.md)
- **Context enrichment** — before the LLM runs, deterministic boto3 calls fetch facts about the affected resource (alarm details, recent logs, function config, etc.) to reduce tool call count and speed up investigations
- **SNS alert delivery** — after each event-driven investigation, findings are published to a configured SNS topic as structured JSON; fires alongside Slack so both channels receive results simultaneously
-- **Monitoring dashboard** — live incident feed showing all event-driven investigations: confidence level, affected service, root cause summary, SNS status; each alert has a detail page with an **Investigate** button that opens a pre-seeded chat — see [docs/monitoring.md](docs/monitoring.md)
+- **Monitoring dashboard** — live incident feed showing all event-driven investigations: confidence level (or FAILED badge), affected service, root cause summary, SNS status; each alert links back to its original investigation session via **View investigation** so you can follow up without losing context — see [docs/monitoring.md](docs/monitoring.md)
- **AWS Configuration settings tab** — admin-only editable tab in Settings for SNS Topic ARN, SQS Queue URL, and AWS Region; shared org-wide (server-side `init.json`); includes an inline IAM permission checker per service
- **Web UI** — React + Vite SPA served by FastAPI:
- **Chat page** — streaming responses, collapsible tool call inspector, cost/latency card, stop button; supports `?prompt=` deeplink for pre-seeded investigations from the Monitoring dashboard
@@ -251,7 +251,7 @@ docs/ # Feature reference — auth, schema, skills, databases,
- [x] **Event-driven incident detection** — EventBridge → SQS → long-poll consumer; 9 EventBridge rules covering CloudWatch alarms, ECS, Lambda, RDS, EC2, CodePipeline, and AWS Health; runs in parallel with the metric poller; see [docs/event_detection.md](docs/event_detection.md)
- [x] **SNS alert delivery** — findings published to SNS after each event-driven investigation; fires alongside Slack; structured JSON payload for easy downstream processing
- [x] **Context enrichment** — deterministic boto3 calls per event type before LLM runs; reduces tool call count by front-loading relevant resource facts
-- [x] **Monitoring dashboard** — live incident feed, per-service health summary, alert detail page, investigate deeplink to pre-seeded chat; see [docs/monitoring.md](docs/monitoring.md)
+- [x] **Monitoring dashboard** — live incident feed, per-service health summary, alert detail page; "View investigation" opens the original agent session for follow-up; failed investigations flagged separately; see [docs/monitoring.md](docs/monitoring.md)
- [x] **AWS Configuration settings tab** — admin-only editable tab for SNS/SQS/region config; shared org-wide via server-side `init.json`; inline IAM permission checker
### Later
diff --git a/docs/databases.md b/docs/databases.md
index 6828118..0bc539e 100644
--- a/docs/databases.md
+++ b/docs/databases.md
@@ -44,6 +44,10 @@ Uses `aiosqlite` for the app tables and `langgraph-checkpoint-sqlite` for the
LangGraph checkpointer. Both share the same `.db` file via separate connections
with WAL mode enabled.
+SQLite also stores app-level configuration such as init wizard completion and event
+monitoring infrastructure state in `app_config`. `data/init.json` is only a local
+cache/fallback.
+
The file and its parent directory are created automatically on first start.
**When to use:** Single-server deployments, personal use, hobbyist setups where
@@ -81,6 +85,8 @@ DATABASE_URL=postgresql://user:password@localhost:5432/opendevops
Uses `psycopg3` + `AsyncConnectionPool` for the app tables and
`langgraph-checkpoint-postgres` for the LangGraph checkpointer.
The checkpointer schema is created automatically via `AsyncPostgresSaver.setup()`.
+Init wizard and event-infrastructure state are stored in the `app_config` table so
+all app instances share the same setup status.
**When to use:** Production deployments, team environments, when you need full
dashboard analytics, multi-instance horizontal scaling.
diff --git a/docs/event_detection.md b/docs/event_detection.md
index 817557b..0316cde 100644
--- a/docs/event_detection.md
+++ b/docs/event_detection.md
@@ -30,7 +30,7 @@ AWS Service (ECS, Lambda, RDS, EC2…)
### Option A — Init wizard (recommended)
-Go to **Settings → AWS Configuration** after first login. Enter your SNS Topic ARN and AWS Region, save, then click **Run checks** to verify IAM permissions. The SQS queue and EventBridge rules are created automatically the first time the server starts with valid config.
+Go to **Settings → AWS Configuration** after first login. Enter your AWS Region and optional SNS Topic ARN, save, then click **Run checks** to verify IAM permissions. Click **Create Infrastructure** to create the SQS queue, EventBridge rules, and aggregate Lambda CloudWatch alarm.
### Option B — Manual `.env`
@@ -40,7 +40,7 @@ EVENT_CONSUMER_ENABLED=true # or leave unset — consumer also starts if
SNS_TOPIC_ARN=arn:aws:sns:us-east-1:123456789012:opendevops-alerts
```
-The consumer starts automatically on server startup if either `EVENT_CONSUMER_ENABLED=true` or `SQS_QUEUE_URL` is set.
+The consumer starts automatically on server startup if either `EVENT_CONSUMER_ENABLED=true`, `SQS_QUEUE_URL` is set, or the init wizard has enabled event infrastructure in persistent app config.
---
@@ -50,15 +50,15 @@ The consumer starts automatically on server startup if either `EVENT_CONSUMER_EN
| Rule | Events captured |
|---|---|
-| `opendevops-cloudwatch-alarms` | CloudWatch Alarm state changes |
-| `opendevops-ecs-failures` | ECS task stopped with non-zero exit / OOM |
-| `opendevops-lambda-errors` | Lambda async invocation failures |
-| `opendevops-rds-events` | RDS instance failures and failovers |
+| `opendevops-alarm-state` | CloudWatch Alarm state changes to `ALARM` |
+| `opendevops-lambda-failure` | Lambda async invocation failures |
+| `opendevops-lambda-throttle` | Lambda throttling errors |
+| `opendevops-ecs-task-stopped` | ECS task stopped with non-zero exit / OOM |
| `opendevops-ec2-state` | EC2 instance state changes |
-| `opendevops-asg-events` | Auto Scaling launch / terminate failures |
-| `opendevops-codepipeline` | CodePipeline stage failures |
-| `opendevops-ssm-compliance` | SSM patch compliance failures |
-| `opendevops-health-events` | AWS Health personal health events |
+| `opendevops-rds-events` | RDS instance failures and failovers |
+| `opendevops-health` | AWS Health personal health events |
+| `opendevops-codedeploy-failure` | CodeDeploy deployment failures |
+| `opendevops-guardduty` | GuardDuty findings |
---
@@ -82,13 +82,14 @@ Context is appended to the investigation prompt, capped at 3 000 chars to stay w
`event_consumer_loop()` in `src/agent/event_consumer.py`:
-1. Receives up to 10 messages per poll with a 20-second long-poll wait
+1. Receives up to 5 messages per poll with a 20-second long-poll wait
2. Filters noise: only processes events where `_is_real_failure()` returns true (e.g. skips EC2 `running` state changes, healthy RDS events)
-3. Calls `collect_context(event)` to enrich the prompt
-4. Runs a full agent investigation via `agent.ainvoke`
-5. Persists the result to the `alerts` table via `monitor_store.add_alert()`
-6. Delivers findings via SNS and Slack (if configured)
-7. Deletes the SQS message
+3. Deduplicates: checks an in-memory set (`_in_progress`) and the database to skip events already being investigated or investigated within the last 3 minutes
+4. Calls `collect_context(event)` to enrich the prompt
+5. Generates a `session_id` and runs a full agent investigation via `agent.ainvoke`, persisting the session and messages to the database (`source = 'event'`)
+6. Persists the result to the `alerts` table (linked to the session via `session_id`) via `monitor_store.add_alert()`
+7. Delivers findings via SNS and Slack (if configured); failed investigations are flagged with status `'failed'` but are still persisted and notified
+8. Deletes the SQS message
The consumer is started as an `asyncio.Task` in the FastAPI lifespan and shut down cleanly on server stop.
@@ -120,9 +121,10 @@ The IAM role/user used by the agent needs these additional permissions for event
```
sns:Publish, sns:GetTopicAttributes
sqs:CreateQueue, sqs:SetQueueAttributes, sqs:GetQueueAttributes
-sqs:ReceiveMessage, sqs:DeleteMessage, sqs:ListQueues
+sqs:ReceiveMessage, sqs:DeleteMessage, sqs:DeleteQueue, sqs:ListQueues
events:PutRule, events:PutTargets, events:ListRules
events:RemoveTargets, events:DeleteRule
+cloudwatch:PutMetricAlarm, cloudwatch:DeleteAlarms, cloudwatch:DescribeAlarms
```
Use **Settings → AWS Configuration → Run checks** to validate all permissions before going live.
@@ -131,15 +133,19 @@ Use **Settings → AWS Configuration → Run checks** to validate all permission
## Persistence
-Completed investigations are stored in the `alerts` table across all three storage backends (memory, SQLite, PostgreSQL). The Monitoring dashboard reads from this table.
+Each event-driven investigation creates a full session (messages, tool calls, usage events) in the database, linked to the alert via `session_id`. This powers the "View investigation" button on the Monitoring page, which opens the original chat instead of starting a fresh one.
+
+Sessions created by the event consumer carry `source = 'event'` and are hidden from the sidebar by default. They become visible after the user sends a follow-up message in the chat (`user_interacted` is then set to `true`).
+
+The `alerts` table records `status` (`'completed'` or `'failed'`), `dedup_key` (MD5 fingerprint of stable event fields for cross-run deduplication), and `session_id` (FK to the session that produced it).
-For PostgreSQL, run the migration before starting the server:
+All three storage backends (memory, SQLite, PostgreSQL) support alerts. For PostgreSQL, run migrations before starting the server:
```bash
uv run python scripts/setup_db.py
```
-For SQLite, the `alerts` table is created automatically on first start.
+For SQLite, schema migrations are applied automatically on first start.
---
@@ -151,7 +157,7 @@ For SQLite, the `alerts` table is created automatically on first start.
| `src/agent/event_infra.py` | Create/teardown SQS queue + EventBridge rules |
| `src/agent/context_collectors.py` | Per-event-type boto3 context enrichment |
| `src/agent/monitor_store.py` | In-memory service status + DB-backed alert persistence |
-| `src/agent/init_store.py` | Persist init config to `data/init.json` |
+| `src/agent/init_store.py` | Persist init config to DB-backed `app_config` with `data/init.json` fallback/cache |
| `src/agent/permission_checker.py` | IAM permission validation |
| `src/tools/sns.py` | SNS publish wrapper |
| `src/api/routers/init.py` | Setup wizard API endpoints |
diff --git a/docs/iam_setup.md b/docs/iam_setup.md
new file mode 100644
index 0000000..4ee5a57
--- /dev/null
+++ b/docs/iam_setup.md
@@ -0,0 +1,168 @@
+# IAM Setup Guide
+
+This guide covers how to create an AWS IAM user (or role) with the permissions OpenDevOps Agent needs, and how to plug the credentials into your environment.
+
+---
+
+## Credential options
+
+| Method | When to use |
+|---|---|
+| **IAM user + access key** | Self-hosted server, Docker, Railway, Render, local dev without AWS CLI profiles |
+| **Named profile** (`~/.aws/credentials`) | Local dev with AWS CLI already configured |
+| **IAM role** | EC2 / ECS / Lambda — attach the role to the instance/task, no keys needed |
+
+For a typical self-hosted deployment, create an IAM user and paste the keys into `.env`.
+
+---
+
+## Step 1 — Create the IAM user
+
+1. Open **IAM → Users → Create user** in the AWS console
+2. Username: `opendevops-agent` (or any name you prefer)
+3. Select **"Attach policies directly"**
+4. Create and attach the inline policy below (see [Step 2](#step-2--iam-policy))
+5. Open the user → **Security credentials → Create access key**
+6. Choose **"Other"** as the use case
+7. Copy the **Access key ID** and **Secret access key** — you won't see the secret again
+
+---
+
+## Step 2 — IAM policy
+
+Create a **customer-managed policy** named `OpenDevOpsAgentPolicy` with this JSON:
+
+```json
+{
+ "Version": "2012-10-17",
+ "Statement": [
+ {
+ "Sid": "ReadOnlyInvestigation",
+ "Effect": "Allow",
+ "Action": [
+ "cloudwatch:DescribeAlarms",
+ "cloudwatch:DescribeAlarmHistory",
+ "cloudwatch:GetMetricStatistics",
+ "cloudwatch:GetMetricData",
+ "logs:DescribeLogGroups",
+ "logs:FilterLogEvents",
+ "logs:StartQuery",
+ "logs:GetQueryResults",
+ "cloudtrail:LookupEvents",
+ "ecs:ListClusters",
+ "ecs:DescribeClusters",
+ "ecs:ListServices",
+ "ecs:DescribeServices",
+ "ecs:ListTasks",
+ "ecs:DescribeTasks",
+ "lambda:ListFunctions",
+ "lambda:GetFunction",
+ "lambda:GetFunctionConfiguration",
+ "ec2:DescribeInstances",
+ "ec2:DescribeInstanceStatus",
+ "rds:DescribeDBInstances",
+ "rds:DescribeEvents",
+ "iam:ListAttachedRolePolicies",
+ "iam:ListRolePolicies",
+ "sts:GetCallerIdentity"
+ ],
+ "Resource": "*"
+ },
+ {
+ "Sid": "EventMonitoringSetup",
+ "Effect": "Allow",
+ "Action": [
+ "sqs:CreateQueue",
+ "sqs:DeleteQueue",
+ "sqs:GetQueueUrl",
+ "sqs:GetQueueAttributes",
+ "sqs:SetQueueAttributes",
+ "sqs:ListQueues",
+ "sqs:ReceiveMessage",
+ "sqs:DeleteMessage",
+ "sqs:ChangeMessageVisibility",
+ "sqs:SendMessage",
+ "events:PutRule",
+ "events:PutTargets",
+ "events:ListRules",
+ "events:RemoveTargets",
+ "events:DeleteRule",
+ "cloudwatch:PutMetricAlarm",
+ "cloudwatch:DeleteAlarms"
+ ],
+ "Resource": "*"
+ },
+ {
+ "Sid": "SNSNotifications",
+ "Effect": "Allow",
+ "Action": [
+ "sns:Publish",
+ "sns:GetTopicAttributes"
+ ],
+ "Resource": "*"
+ }
+ ]
+}
+```
+
+### What each block does
+
+| Block | Purpose |
+|---|---|
+| `ReadOnlyInvestigation` | Read-only access to CloudWatch, CloudTrail, ECS, Lambda, EC2, RDS, IAM — used by the investigation agent tools |
+| `EventMonitoringSetup` | Create/manage the SQS queue and EventBridge rules during the setup wizard; poll SQS at runtime for incoming events |
+| `SNSNotifications` | Optional — publish alert summaries to an SNS topic after investigations; check topic attributes during permission check. Remove this block if you're not using SNS. |
+
+### Minimal policy (investigation only, no event monitoring)
+
+If you only want the chat interface and manual investigations — no automatic event detection — you can attach just the `ReadOnlyInvestigation` statement and skip the rest.
+
+---
+
+## Step 3 — Add credentials to `.env`
+
+```bash
+# Option A — explicit keys (server, Docker, CI)
+AWS_ACCESS_KEY_ID=AKIA...
+AWS_SECRET_ACCESS_KEY=...
+AWS_REGION=us-east-1
+
+# Option B — named profile (local dev)
+AWS_PROFILE=opendevops-agent
+AWS_REGION=us-east-1
+```
+
+For Option B, add a profile to `~/.aws/credentials`:
+
+```ini
+[opendevops-agent]
+aws_access_key_id = AKIA...
+aws_secret_access_key = ...
+```
+
+---
+
+## Step 4 — Verify in the setup wizard
+
+After starting the app, the setup wizard's **Step 3 (Permission Check)** calls each service with a lightweight read and shows a pass/fail result for:
+
+| Service | Required |
+|---|---|
+| CloudWatch | Yes |
+| Lambda | Yes |
+| SQS | Yes |
+| EventBridge | Yes |
+| ECS | No |
+| RDS | No |
+| EC2 | No |
+| IAM / STS | No |
+| SNS | No |
+| CloudTrail | No |
+
+Required services must pass for event monitoring to work. Optional services are used when available — missing permissions will simply return empty results for that service.
+
+---
+
+## Using an IAM role instead of a user
+
+If running on EC2, ECS, or Lambda, attach the same policy to the instance/task role instead. Remove `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, and `AWS_PROFILE` from `.env` — the SDK picks up the role automatically via the instance metadata service.
diff --git a/docs/monitoring.md b/docs/monitoring.md
index 63e3563..49865f3 100644
--- a/docs/monitoring.md
+++ b/docs/monitoring.md
@@ -13,7 +13,7 @@ OpenDevOps Agent has two complementary detection modes. Both write to the same `
| **How it works** | Agent polls CloudWatch alarms + Lambda error rates on a timer | AWS fires EventBridge rules → SQS → consumer → agent |
| **Setup** | Set `POLL_INTERVAL_MINUTES` in `.env` | Create infrastructure via Settings → AWS Configuration |
| **Detection latency** | Up to N minutes (your poll interval) | 2–5 min for alarm-based events; near-instant for direct events (ECS, RDS, GuardDuty) |
-| **CloudWatch alarm required** | No — reads Lambda error rate directly via API | Yes — EventBridge only fires when an alarm changes state |
+| **CloudWatch alarm required** | No — reads Lambda error rate directly via API | Only for metric/alarm detections; direct AWS service events do not need a CloudWatch alarm |
| **Works without AWS setup** | Yes — no SQS/EventBridge needed | No — requires SQS queue + EventBridge rules |
| **Event types covered** | CloudWatch alarms in ALARM state + Lambda error rates | CloudWatch alarms, ECS task failures, Lambda async errors, RDS events, EC2 state changes, CodePipeline failures, GuardDuty findings, AWS Health events |
| **Appears in /monitoring** | Yes | Yes |
@@ -131,7 +131,7 @@ When you create infrastructure (Settings → AWS Configuration → Create Infras
- Trips when any Lambda has ≥ 1 error in a 60-second window
- Triggers the `opendevops-alarm-state` EventBridge rule → SQS → consumer
-This means you get event-driven Lambda coverage without creating per-function alarms.
+This means you get event-driven Lambda coverage without creating per-function alarms. Because it is account-wide and trips on a single error, it is intentionally easy to test but can be noisy in busy accounts. For production, consider replacing it with scoped per-function or per-service alarms once you know which workloads matter.
### Setup
@@ -140,6 +140,8 @@ Go to **Settings → AWS Configuration → Create Infrastructure**. This creates
- 9 EventBridge rules (all prefixed `opendevops-`)
- Aggregate CloudWatch alarm: `opendevops-lambda-errors-aggregate`
+Wizard and infrastructure state is stored in the database for SQLite/PostgreSQL deployments (`app_config`) and mirrored to `data/init.json` as a local cache/fallback. Memory mode keeps only local process/file state. Teardown deletes only infrastructure created by the wizard; queues supplied through `.env` or pasted manually are disconnected from the app but not deleted.
+
To remove it, click **Teardown**. See [event_detection.md](event_detection.md) for full setup details and IAM permission requirements.
### Context collectors
@@ -205,7 +207,7 @@ Invokes the Lambda with a bad payload, then exits. The `opendevops-lambda-errors
Each alert card shows:
-- **Confidence badge** — `HIGH` (red), `MEDIUM` (amber), or `LOW` (grey)
+- **Status badge** — `HIGH` (red), `MEDIUM` (amber), `LOW` (grey), or `FAILED` (rose) if the agent hit its tool limit or encountered an error
- **Service** — the affected AWS service and resource name
- **Error** — the root cause summary from the agent
- **Time** — when the event was detected
@@ -221,7 +223,16 @@ Shows the full investigation result:
- Resolution steps produced by the agent
- Timestamp and SNS notification status
-**Investigate button** — opens a new chat session with a pre-seeded prompt for deeper analysis. The prompt auto-submits when the chat page loads.
+**Investigate / View investigation button** — behaviour depends on whether the investigation session was persisted:
+
+- **View investigation** — shown for event-driven and poller alerts. Opens the original investigation chat so you can see the agent's tool calls, reasoning, and evidence, and send follow-up messages.
+- **Investigate** — shown for older alerts (created before session persistence was added). Opens a new chat with a pre-seeded prompt for deeper analysis.
+
+### Session continuity and sidebar
+
+When the event consumer or poller runs an investigation, it creates a session (`source = 'event'`) and links it to the resulting alert via `session_id`. These sessions are **hidden from the sidebar by default** — they represent automated runs the user did not initiate.
+
+If you open a "View investigation" chat and send at least one follow-up message, the session is **promoted to the sidebar** (`user_interacted = true`) so you can navigate back to it without going through the Monitoring page again.
### Service Health Panel
diff --git a/docs/schema.md b/docs/schema.md
index 2b75a31..482e8dc 100644
--- a/docs/schema.md
+++ b/docs/schema.md
@@ -15,6 +15,8 @@ psql $DATABASE_URL -f migrations/001_initial.sql
psql $DATABASE_URL -f migrations/002_soft_delete.sql
psql $DATABASE_URL -f migrations/003_usage_events_metadata.sql
psql $DATABASE_URL -f migrations/004_users_rbac.sql
+psql $DATABASE_URL -f migrations/005_alerts.sql
+psql $DATABASE_URL -f migrations/006_app_config.sql
```
All migrations are idempotent (`IF NOT EXISTS`, `IF column does not already exist`).
@@ -134,6 +136,28 @@ Structured root-cause analysis rows extracted from agent final answers. Table ex
### `api_keys` *(future — Phase 3)*
Hashed API keys for programmatic access. Table exists but not yet implemented.
+### `alerts`
+Persisted event-driven and proactive polling investigation results shown on `/monitoring`.
+
+| Column | Type | Notes |
+|---|---|---|
+| `id` | UUID PK | `gen_random_uuid()` |
+| `service` | TEXT | Affected service/resource label |
+| `error` | TEXT | Root cause summary |
+| `resolution` | TEXT | Mitigation steps joined as text |
+| `confidence` | TEXT | `HIGH`, `MEDIUM`, or `LOW` |
+| `sns_sent` | BOOLEAN | Whether SNS publish succeeded |
+| `created_at` | TIMESTAMPTZ | |
+
+### `app_config`
+Application-level key/value configuration shared by all server instances. The init wizard stores its setup and event-infrastructure state under key `init`. `data/init.json` is kept as a local cache/fallback for memory mode and bootstrap recovery.
+
+| Column | Type | Notes |
+|---|---|---|
+| `key` | TEXT PK | e.g. `init` |
+| `value` | JSONB | Setup state, AWS region, SQS URL, rule ARNs |
+| `updated_at` | TIMESTAMPTZ | |
+
---
## Entity Relationship
diff --git a/docs/ui.md b/docs/ui.md
index 0e36db2..66df26e 100644
--- a/docs/ui.md
+++ b/docs/ui.md
@@ -90,7 +90,7 @@ Accessible from the sidebar for all authenticated users. Each card shows the aff
Shows active configuration. Four tabs (admins see all four; regular users see the last three):
-**AWS Configuration** *(admin only, default tab for admins)* — editable fields for SNS Topic ARN, SQS Queue URL, and AWS Region. Values are saved to `data/init.json` server-side and shared across all admin users. Includes:
+**AWS Configuration** *(admin only, default tab for admins)* — editable fields for SNS Topic ARN, SQS Queue URL, and AWS Region. Values are stored in database-backed app config for SQLite/PostgreSQL deployments and mirrored to `data/init.json` as a local cache/fallback. Includes:
- **Event Monitoring** section — shows Enabled / Disabled status; **Create Infrastructure** button provisions the SQS queue and 9 EventBridge rules; **Teardown** button removes them (with confirmation).
- **Run checks** button that validates IAM permissions for every AWS service the agent uses.
@@ -109,9 +109,9 @@ Accessible at `/init` before any users exist. A 4-step wizard guides the admin t
1. **Create account** — email + password for the admin user
2. **AWS Configuration** — region selector and optional SNS Topic ARN for alert delivery
3. **Permission check** — verifies IAM permissions per service; required services (CloudWatch, Lambda, SQS, EventBridge) are highlighted; "Continue anyway" is always available
-4. **Enable event monitoring** — creates the SQS queue and 9 EventBridge rules; shows a success summary with queue URL and rule list; includes a **Skip** option with a disclaimer listing what won't work without it
+4. **Enable event monitoring** — creates the SQS queue, 9 EventBridge rules, and aggregate Lambda alarm; shows a success summary with queue URL and rule list; includes a **Skip** option that finishes onboarding without event infrastructure
-The page redirects away automatically once an admin account exists.
+The page redirects away automatically once onboarding is complete. Event monitoring can still be enabled or torn down later from Settings without forcing users back through onboarding.
---
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index d5c650f..076c2c8 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -11,7 +11,7 @@ import MonitoringPage from './pages/MonitoringPage';
import AlertDetailPage from './pages/AlertDetailPage';
import InitPage from './pages/InitPage';
import ProtectedRoute from './components/ProtectedRoute';
-import { fetchSessions, deleteSession as apiDeleteSession, renameSession as apiRenameSession } from './lib/api';
+import { apiFetch, fetchSessions, deleteSession as apiDeleteSession, renameSession as apiRenameSession } from './lib/api';
import { useAuth } from './context/AuthContext';
import type { Session } from './types';
@@ -29,13 +29,46 @@ function LogoutPage() {
}
function RedirectToSession() {
- const stored = localStorage.getItem('devops-session-id');
- const id = stored ?? (() => {
- const newId = crypto.randomUUID();
- localStorage.setItem('devops-session-id', newId);
- return newId;
- })();
- return ;
+ const [ready, setReady] = useState<'init' | 'chat' | null>(null);
+ const { user, authRequired, loading } = useAuth();
+
+ useEffect(() => {
+ if (loading) return;
+
+ let cancelled = false;
+ const routeAfterInitCheck = async () => {
+ if (authRequired && user?.role !== 'admin') {
+ if (!cancelled) setReady('chat');
+ return;
+ }
+
+ try {
+ const r = await apiFetch('/api/init/status');
+ const d = await r.json();
+ const setupComplete = Boolean(d.setup_complete ?? d.initialized);
+ const accountReady = Boolean(d.has_user || !d.auth_enabled);
+ if (!cancelled) setReady(setupComplete && accountReady ? 'chat' : 'init');
+ } catch {
+ if (!cancelled) setReady('chat');
+ }
+ };
+
+ setReady(null);
+ void routeAfterInitCheck();
+ return () => { cancelled = true; };
+ }, [authRequired, loading, user?.role]);
+
+ if (ready === 'init') return ;
+ if (ready === 'chat') {
+ const stored = localStorage.getItem('devops-session-id');
+ const id = stored ?? (() => {
+ const newId = crypto.randomUUID();
+ localStorage.setItem('devops-session-id', newId);
+ return newId;
+ })();
+ return ;
+ }
+ return null;
}
const PAGE_SIZE = 15;
diff --git a/frontend/src/pages/AlertDetailPage.tsx b/frontend/src/pages/AlertDetailPage.tsx
index 133219e..37df994 100644
--- a/frontend/src/pages/AlertDetailPage.tsx
+++ b/frontend/src/pages/AlertDetailPage.tsx
@@ -21,9 +21,12 @@ export default function AlertDetailPage() {
const investigate = () => {
if (!alert) return;
- const prompt = `Investigate this incident further:\n\nService: ${alert.service}\nError: ${alert.error}\nConfidence: ${alert.confidence}\nTime: ${alert.timestamp}\n\nPlease provide deeper root cause analysis, check related services, and suggest preventive measures.`;
- const sessionId = crypto.randomUUID();
- navigate(`/chat/${sessionId}?prompt=${encodeURIComponent(prompt)}`);
+ if (alert.session_id) {
+ navigate(`/chat/${alert.session_id}`);
+ } else {
+ const prompt = `Investigate this incident further:\n\nService: ${alert.service}\nError: ${alert.error}\nConfidence: ${alert.confidence}\nTime: ${alert.timestamp}\n\nPlease provide deeper root cause analysis, check related services, and suggest preventive measures.`;
+ navigate(`/chat/${crypto.randomUUID()}?prompt=${encodeURIComponent(prompt)}`);
+ }
};
if (loading) {
@@ -50,16 +53,23 @@ export default function AlertDetailPage() {
{alert.service}
-
{alert.confidence}
+ {alert.status === 'failed' ? (
+
+ FAILED
+
+ ) : (
+
{alert.confidence}
+ )}
{new Date(alert.timestamp).toLocaleString()}
@@ -72,7 +82,7 @@ export default function AlertDetailPage() {
- Investigate
+ {alert.session_id ? 'View investigation' : 'Investigate'}
diff --git a/frontend/src/pages/InitPage.tsx b/frontend/src/pages/InitPage.tsx
index 15a25ba..e8aec13 100644
--- a/frontend/src/pages/InitPage.tsx
+++ b/frontend/src/pages/InitPage.tsx
@@ -2,6 +2,8 @@ import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { ArrowRight, Loader2, Terminal, Eye, EyeOff, CheckCircle, XCircle, AlertTriangle, ChevronRight, Check } from 'lucide-react';
import { cn } from '../lib/utils';
+import { useAuth } from '../context/AuthContext';
+import { apiFetch } from '../lib/api';
const inp = 'w-full text-base text-gray-900 dark:text-white bg-gray-50 dark:bg-[#18181B] border border-gray-200 dark:border-[#27272A] rounded-xl px-4 py-3.5 outline-none focus:border-indigo-500 dark:focus:border-indigo-400 focus:ring-2 focus:ring-indigo-500/10 transition-all placeholder:text-gray-400 dark:placeholder:text-[#52525B]';
@@ -43,11 +45,13 @@ type Step = 1 | 2 | 3 | 4;
export default function InitPage() {
const navigate = useNavigate();
+ const { user, login, authRequired, loading: authLoading } = useAuth();
const [step, setStep] = useState(1);
// Step 1
- const [username, setUsername] = useState('admin');
+ const [orgName, setOrgName] = useState('');
+ const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [showPw, setShowPw] = useState(false);
@@ -67,24 +71,35 @@ export default function InitPage() {
const [error, setError] = useState('');
useEffect(() => {
- fetch('/api/init/status')
+ if (authLoading) return;
+ if (user && user.role !== 'admin') { navigate('/', { replace: true }); return; }
+ apiFetch('/api/init/status')
.then(r => r.json())
- .then(d => { if (d.has_user) navigate('/', { replace: true }); })
+ .then(d => {
+ setRegion(d.aws_region || 'us-east-1');
+ setSnsArn(d.sns_topic_arn || '');
+ if (d.initialized && (d.has_user || !d.auth_enabled)) {
+ navigate('/', { replace: true });
+ return;
+ }
+ setStep((user || !authRequired) ? 2 : 1);
+ })
.catch(() => {});
- }, [navigate]);
+ }, [authLoading, authRequired, navigate, user]);
// ─── Step 1: create admin account ─────────────────────────────────────────
const createUser = async () => {
- if (!username.trim() || !password.trim()) { setError('Both fields are required'); return; }
+ if (!username.trim() || !password.trim()) { setError('Email and password are required'); return; }
setLoading(true); setError('');
try {
- const r = await fetch('/api/init/create-user', {
+ const r = await apiFetch('/api/init/create-user', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: username.trim(), password }),
});
const data = await r.json();
- if (data.error) { setError(data.error); return; }
+ if (!r.ok || data.error) { setError(data.detail || data.error || 'Failed to create account'); return; }
+ if (authRequired) await login(username.trim(), password);
setStep(2);
} finally {
setLoading(false);
@@ -95,10 +110,10 @@ export default function InitPage() {
const saveConfig = async () => {
setLoading(true); setError('');
try {
- const r = await fetch('/api/init/setup', {
+ const r = await apiFetch('/api/init/setup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ aws_region: region, sns_topic_arn: snsArn, sqs_queue_url: '' }),
+ body: JSON.stringify({ aws_region: region, sns_topic_arn: snsArn, sqs_queue_url: '', org_name: orgName.trim() }),
});
if (!r.ok) { setError('Failed to save configuration'); return; }
setStep(3);
@@ -111,7 +126,7 @@ export default function InitPage() {
const checkPerms = async () => {
setLoading(true); setError('');
try {
- const r = await fetch('/api/init/check-permissions', { method: 'POST' });
+ const r = await apiFetch('/api/init/check-permissions', { method: 'POST' });
const d = await r.json() as { permissions: Record };
setPerms(d.permissions);
} catch {
@@ -125,18 +140,35 @@ export default function InitPage() {
const createInfra = async () => {
setLoading(true); setError('');
try {
- const r = await fetch('/api/init/complete', { method: 'POST' });
- const d = await r.json() as { initialized: boolean; error: string | null };
- if (!d.initialized) { setError(d.error || 'Infrastructure setup failed'); return; }
+ const r = await apiFetch('/api/init/complete', { method: 'POST' });
+ const d = await r.json() as { initialized: boolean; error?: string | null; detail?: string };
+ if (!r.ok || !d.initialized) { setError(d.error || d.detail || 'Infrastructure setup failed'); return; }
setInfraDone(true);
// Fetch the queue URL from init status to display in summary
- const s = await fetch('/api/init/status').then(res => res.json());
+ const s = await apiFetch('/api/init/status').then(res => res.json());
setQueueUrl(s.sqs_queue_url || 'opendevops-agent-events');
} finally {
setLoading(false);
}
};
+ const finishWithoutInfra = async () => {
+ setLoading(true); setError('');
+ try {
+ const r = await apiFetch('/api/init/skip-services', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ services: ['event-monitoring'] }),
+ });
+ if (!r.ok) throw new Error('finish failed');
+ navigate('/', { replace: true });
+ } catch {
+ setError('Failed to finish setup');
+ } finally {
+ setLoading(false);
+ }
+ };
+
const requiredPassed = Object.entries(perms)
.filter(([svc]) => SVC[svc]?.required)
.every(([, r]) => r.passed);
@@ -188,8 +220,8 @@ export default function InitPage() {
- Username
- setUsername(e.target.value)} placeholder="admin" className={inp} />
+ Email
+ setUsername(e.target.value)} placeholder="you@example.com" className={inp} />
Password
@@ -199,7 +231,7 @@ export default function InitPage() {
value={password}
onChange={e => setPassword(e.target.value)}
onKeyDown={e => e.key === 'Enter' && createUser()}
- placeholder="At least 6 characters"
+ placeholder="At least 8 characters"
className={inp + ' pr-11'}
/>
setShowPw(p => !p)}
@@ -225,6 +257,15 @@ export default function InitPage() {
Configure the AWS region and optional SNS topic for alert delivery.
+
+
+ Organization name (optional)
+
+
setOrgName(e.target.value)} placeholder="Acme Corp" className={inp} />
+
+ Used to group users. You can add team members later from Settings.
+
+
AWS Region
{loading ? : <>Continue >}
- navigate('/', { replace: true })}
+
Skip setup — configure later in Settings
@@ -268,8 +309,17 @@ export default function InitPage() {
{step === 3 && (
<>
Check permissions
-
- Verify that the IAM role has the required permissions for each AWS service.
+
+ Verify that the IAM user or role has the required permissions for each AWS service.
+
+
+ Need to set up IAM permissions first? See{' '}
+
+ docs/iam_setup.md
+
+ {' '}for the exact policy JSON.
{!hasPerms ? (
@@ -397,7 +447,7 @@ export default function InitPage() {
You can enable this anytime from Settings → AWS Configuration .
- navigate('/', { replace: true })}
+
Go to dashboard anyway
diff --git a/frontend/src/pages/MonitoringPage.tsx b/frontend/src/pages/MonitoringPage.tsx
index 457a96b..0bad377 100644
--- a/frontend/src/pages/MonitoringPage.tsx
+++ b/frontend/src/pages/MonitoringPage.tsx
@@ -32,9 +32,9 @@ export default function MonitoringPage() {
useEffect(() => {
if (!isAdmin) return;
- fetch('/api/init/status')
+ apiFetch('/api/init/status')
.then(r => r.json())
- .then(d => setInfraEnabled(!!d.initialized))
+ .then(d => setInfraEnabled(!!d.event_infra_enabled))
.catch(() => setInfraEnabled(false));
}, [isAdmin]);
@@ -75,10 +75,18 @@ export default function MonitoringPage() {
const healthy = services.filter(s => s.status === 'healthy').length;
const errored = services.filter(s => s.status === 'error').length;
+ const emptyTitle = infraEnabled === false ? 'Event monitoring is not enabled' : 'No activity yet';
+ const emptyCopy = infraEnabled === false
+ ? 'Enable event monitoring in Settings, or turn on proactive polling, to populate this feed.'
+ : 'Listening for AWS events. Incidents will appear here when detected.';
const investigate = (a: Alert) => {
- const sessionId = crypto.randomUUID();
- navigate(`/chat/${sessionId}?prompt=${encodeURIComponent(buildPrompt(a))}`);
+ if (a.session_id) {
+ navigate(`/chat/${a.session_id}`);
+ } else {
+ const sessionId = crypto.randomUUID();
+ navigate(`/chat/${sessionId}?prompt=${encodeURIComponent(buildPrompt(a))}`);
+ }
};
return (
@@ -94,12 +102,12 @@ export default function MonitoringPage() {
- Live
+ {infraEnabled === false ? 'Feed' : 'Live'}
{isAdmin && (
@@ -148,9 +156,9 @@ export default function MonitoringPage() {
- No activity yet
+ {emptyTitle}
- Listening for AWS events. Incidents will appear here when detected.
+ {emptyCopy}
)}
@@ -171,18 +179,25 @@ export default function MonitoringPage() {
{a.service}
- {a.confidence}
+ {a.status === 'failed' ? (
+
+ FAILED
+
+ ) : (
+ {a.confidence}
+ )}
{a.error}
@@ -192,7 +207,7 @@ export default function MonitoringPage() {
onClick={e => { e.stopPropagation(); investigate(a); }}
className="hidden group-hover:flex items-center gap-1 text-xs font-medium text-indigo-600 dark:text-indigo-400 bg-indigo-50 dark:bg-indigo-500/10 px-2 py-1 rounded-md hover:bg-indigo-100 dark:hover:bg-indigo-500/20 transition-colors"
>
-
Investigate
+
{a.session_id ? 'View investigation' : 'Investigate'}
diff --git a/frontend/src/pages/SettingsPage.tsx b/frontend/src/pages/SettingsPage.tsx
index 1385c50..c9ec16f 100644
--- a/frontend/src/pages/SettingsPage.tsx
+++ b/frontend/src/pages/SettingsPage.tsx
@@ -77,7 +77,7 @@ export default function SettingsPage() {
setSnsArn(d.sns_topic_arn || '');
setSqsUrl(d.sqs_queue_url || '');
setAwsRegion(d.aws_region || '');
- setInfraEnabled(!!d.initialized);
+ setInfraEnabled(!!d.event_infra_enabled);
setInfraRules(d.eventbridge_rule_arns || {});
})
.catch(() => {});
@@ -88,11 +88,12 @@ export default function SettingsPage() {
const saveAwsConfig = async () => {
setAwsSaving(true);
try {
- await apiFetch('/api/init/setup', {
+ const r = await apiFetch('/api/init/setup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sns_topic_arn: snsArn, sqs_queue_url: sqsUrl, aws_region: awsRegion }),
});
+ if (!r.ok) throw new Error('save failed');
toast.success('AWS configuration saved');
} catch {
toast.error('Failed to save configuration');
@@ -118,8 +119,8 @@ export default function SettingsPage() {
setInfraBusy(true);
try {
const r = await apiFetch('/api/init/complete', { method: 'POST' });
- const d = await r.json() as { initialized: boolean; error: string | null };
- if (!d.initialized) { toast.error(d.error || 'Infrastructure setup failed'); return; }
+ const d = await r.json() as { initialized: boolean; event_infra_enabled?: boolean; error?: string | null; detail?: string };
+ if (!r.ok || !d.event_infra_enabled) { toast.error(d.error || d.detail || 'Infrastructure setup failed'); return; }
toast.success('Event monitoring enabled');
// Refresh status
const s = await apiFetch('/api/init/status').then(res => res.json());
@@ -137,13 +138,17 @@ export default function SettingsPage() {
if (!window.confirm('Remove SQS queue and EventBridge rules? This will stop all automatic incident detection.')) return;
setInfraBusy(true);
try {
- await apiFetch('/api/init/infra', { method: 'DELETE' });
+ const r = await apiFetch('/api/init/infra', { method: 'DELETE' });
+ if (!r.ok) {
+ const d = await r.json().catch(() => ({}));
+ throw new Error(d.detail?.errors?.join('\n') || d.detail || 'Teardown failed');
+ }
toast.success('Event monitoring disabled');
setInfraEnabled(false);
setInfraRules({});
setSqsUrl('');
- } catch {
- toast.error('Teardown failed');
+ } catch (err) {
+ toast.error((err as Error).message || 'Teardown failed');
} finally {
setInfraBusy(false);
}
diff --git a/frontend/src/types.ts b/frontend/src/types.ts
index 1714cfe..202ca3e 100644
--- a/frontend/src/types.ts
+++ b/frontend/src/types.ts
@@ -104,8 +104,10 @@ export interface Alert {
error: string;
resolution: string;
confidence: 'HIGH' | 'MEDIUM' | 'LOW';
+ status: 'completed' | 'failed';
sns_sent: boolean;
timestamp: string;
+ session_id?: string;
}
export interface ServiceStatus {
diff --git a/migrations/006_app_config.sql b/migrations/006_app_config.sql
new file mode 100644
index 0000000..8a37637
--- /dev/null
+++ b/migrations/006_app_config.sql
@@ -0,0 +1,6 @@
+-- Stores application-level configuration such as init wizard and AWS event infra state
+CREATE TABLE IF NOT EXISTS app_config (
+ key TEXT PRIMARY KEY,
+ value JSONB NOT NULL DEFAULT '{}'::jsonb,
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
diff --git a/migrations/007_alerts_dedup_key.sql b/migrations/007_alerts_dedup_key.sql
new file mode 100644
index 0000000..b9c4d42
--- /dev/null
+++ b/migrations/007_alerts_dedup_key.sql
@@ -0,0 +1,3 @@
+-- Add dedup_key to alerts for cross-path deduplication (poller + event consumer)
+ALTER TABLE alerts ADD COLUMN IF NOT EXISTS dedup_key TEXT;
+CREATE INDEX IF NOT EXISTS idx_alerts_dedup_key_created ON alerts (dedup_key, created_at DESC);
diff --git a/migrations/008_alerts_status.sql b/migrations/008_alerts_status.sql
new file mode 100644
index 0000000..088861c
--- /dev/null
+++ b/migrations/008_alerts_status.sql
@@ -0,0 +1,3 @@
+-- Add status to alerts to distinguish completed vs failed investigations
+ALTER TABLE alerts ADD COLUMN IF NOT EXISTS status TEXT NOT NULL DEFAULT 'completed';
+CREATE INDEX IF NOT EXISTS idx_alerts_status ON alerts (status);
diff --git a/migrations/009_session_source_alert_session.sql b/migrations/009_session_source_alert_session.sql
new file mode 100644
index 0000000..919e187
--- /dev/null
+++ b/migrations/009_session_source_alert_session.sql
@@ -0,0 +1,6 @@
+-- Distinguish event-triggered sessions from user-initiated chats
+ALTER TABLE sessions ADD COLUMN IF NOT EXISTS source TEXT NOT NULL DEFAULT 'chat';
+CREATE INDEX IF NOT EXISTS idx_sessions_source ON sessions (source);
+
+-- Link alerts back to the session that produced them
+ALTER TABLE alerts ADD COLUMN IF NOT EXISTS session_id UUID REFERENCES sessions(id) ON DELETE SET NULL;
diff --git a/migrations/010_session_user_interacted.sql b/migrations/010_session_user_interacted.sql
new file mode 100644
index 0000000..e14bab5
--- /dev/null
+++ b/migrations/010_session_user_interacted.sql
@@ -0,0 +1,3 @@
+-- Track whether a user has ever sent a message in an event-triggered session.
+-- FALSE = auto-run only (hidden from sidebar); TRUE = user engaged (show in sidebar).
+ALTER TABLE sessions ADD COLUMN IF NOT EXISTS user_interacted BOOLEAN NOT NULL DEFAULT FALSE;
diff --git a/scripts/test_pipeline.py b/scripts/test_pipeline.py
index eed3fe1..8ef619e 100644
--- a/scripts/test_pipeline.py
+++ b/scripts/test_pipeline.py
@@ -12,7 +12,26 @@
uv run python scripts/test_pipeline.py --region eu-west-1
uv run python scripts/test_pipeline.py --invocations 10
uv run python scripts/test_pipeline.py --list # list available Lambdas
- uv run python scripts/test_pipeline.py --lambda-only # invoke + wait for alarm (no manual SQS push)
+ uv run python scripts/test_pipeline.py --lambda-only # real alarm path (~2-4 min)
+
+─────────────────────────────────────────────────────────────────────────────────
+MANUAL SQS TEST COMMANDS (bypass script — push directly to the queue)
+Replace QUEUE_URL and ACCOUNT_ID with your values from Settings → AWS Configuration.
+─────────────────────────────────────────────────────────────────────────────────
+
+# Syntax error (Runtime.UserCodeSyntaxError) — fast, focused investigation:
+aws sqs send-message --profile devops-agent-readonly --queue-url "https://sqs.us-east-1.amazonaws.com/ACCOUNT_ID/opendevops-agent-events" --message-body "{\"source\":\"aws.lambda\",\"detail-type\":\"Lambda Function Invocation Result - Failure\",\"time\":\"2026-05-17T12:00:00Z\",\"detail\":{\"requestContext\":{\"functionArn\":\"arn:aws:lambda:us-east-1:ACCOUNT_ID:function:opendevops-test-failure\",\"condition\":\"RetriesExhausted\",\"approximateInvokeCount\":3},\"responsePayload\":{\"errorMessage\":\"Syntax error in module lambda_function: unexpected indent (lambda_function.py, line 9)\",\"errorType\":\"Runtime.UserCodeSyntaxError\",\"stackTrace\":[\"File /var/task/lambda_function.py line 9: def lambda_handler(event, context):\"]}}}
+
+# CloudWatch alarm event — slower path (~1 min CloudWatch delay in real scenario):
+aws sqs send-message --profile devops-agent-readonly \
+ --queue-url "https://sqs.us-east-1.amazonaws.com/ACCOUNT_ID/opendevops-agent-events" \
+ --message-body "{\"source\":\"aws.cloudwatch\",\"detail-type\":\"CloudWatch Alarm State Change\",\"time\":\"2026-05-17T12:00:00Z\",\"detail\":{\"alarmName\":\"opendevops-test-lambda-errors\",\"state\":{\"value\":\"ALARM\",\"reason\":\"TEST: Lambda error rate exceeded threshold\"},\"configuration\":{\"metrics\":[{\"metricStat\":{\"metric\":{\"namespace\":\"AWS/Lambda\",\"name\":\"Errors\",\"dimensions\":{\"FunctionName\":\"opendevops-test-failure\"}},\"period\":300,\"stat\":\"Sum\"}}]}}}"
+
+Notes:
+ - Event consumer picks up the message within 20s (SQS long-poll interval)
+ - Use aws.lambda source for faster, more focused investigations
+ - MAX_TOOL_CALLS=30 recommended to avoid recursion errors with weaker models
+─────────────────────────────────────────────────────────────────────────────────
"""
from __future__ import annotations
@@ -26,17 +45,26 @@
import boto3
from botocore.exceptions import ClientError, NoCredentialsError
from dotenv import load_dotenv
+
load_dotenv()
-GREEN = "\033[0;32m"
+GREEN = "\033[0;32m"
YELLOW = "\033[1;33m"
-RED = "\033[0;31m"
-NC = "\033[0m"
+RED = "\033[0;31m"
+NC = "\033[0m"
+
+
+def log(msg: str) -> None:
+ print(f"{GREEN}[+]{NC} {msg}")
+
+
+def warn(msg: str) -> None:
+ print(f"{YELLOW}[!]{NC} {msg}")
-def log(msg: str) -> None: print(f"{GREEN}[+]{NC} {msg}")
-def warn(msg: str) -> None: print(f"{YELLOW}[!]{NC} {msg}")
-def die(msg: str) -> None: print(f"{RED}[✗]{NC} {msg}", file=sys.stderr); sys.exit(1)
+def die(msg: str) -> None:
+ print(f"{RED}[✗]{NC} {msg}", file=sys.stderr)
+ sys.exit(1)
def get_queue_url() -> str:
@@ -61,11 +89,18 @@ def list_functions(lam) -> list[str]:
return sorted(names)
-def run(region: str, invocations: int, function_name: str | None, list_only: bool, lambda_only: bool = False) -> None:
+def run(
+ region: str,
+ invocations: int,
+ function_name: str | None,
+ list_only: bool,
+ lambda_only: bool = False,
+) -> None:
try:
session = boto3.Session()
lam = session.client("lambda", region_name=region)
sqs = session.client("sqs", region_name=region)
+ cw = session.client("cloudwatch", region_name=region)
except NoCredentialsError:
die("No AWS credentials found. Configure via environment variables or ~/.aws/credentials.")
@@ -75,7 +110,10 @@ def run(region: str, invocations: int, function_name: str | None, list_only: boo
identity = sts.get_caller_identity()
log(f"AWS identity: {identity.get('Arn', 'unknown')}")
except ClientError as e:
- die(f"AWS credentials error: {e}\nRun 'aws sso login' or set AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY.")
+ die(
+ f"AWS credentials error: {e}\n"
+ "Run 'aws sso login' or set AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY."
+ )
# ── list mode ─────────────────────────────────────────────────────────────
functions = list_functions(lam)
@@ -103,7 +141,9 @@ def run(region: str, invocations: int, function_name: str | None, list_only: boo
queue_url = get_queue_url()
if not queue_url:
- die("No SQS queue URL found. Run 'Create Infrastructure' in Settings → AWS Configuration first.")
+ die(
+ "No SQS queue URL found. Run 'Create Infrastructure' in Settings first."
+ )
log(f"Region : {region}")
log(f"Queue : {queue_url}")
@@ -114,6 +154,7 @@ def run(region: str, invocations: int, function_name: str | None, list_only: boo
log(f"Invoking '{function_name}' {invocations}× with a bad payload to generate real errors...")
bad_payload = b'{"__test_force_error": true, "trigger": "opendevops-pipeline-test"}'
succeeded = 0
+ errors_observed = 0
for i in range(1, invocations + 1):
try:
resp = lam.invoke(
@@ -125,27 +166,50 @@ def run(region: str, invocations: int, function_name: str | None, list_only: boo
fn_error = resp.get("FunctionError", "")
if fn_error or status >= 400:
print(f" invocation {i}/{invocations} → error (good)")
+ errors_observed += 1
else:
- print(f" invocation {i}/{invocations} → success (function handled bad input gracefully)")
+ print(
+ f" invocation {i}/{invocations} → success "
+ "(function handled bad input gracefully)"
+ )
succeeded += 1
except ClientError as e:
warn(f" invocation {i}/{invocations} → invoke failed: {e}")
if succeeded == 0:
warn("All invocations failed to reach the function — no logs may exist.")
+ elif errors_observed == 0:
+ warn(
+ "The function handled the test payload successfully — Lambda Errors may stay at 0."
+ )
else:
log(f"Real invocation logs at: CloudWatch → /aws/lambda/{function_name}")
print()
# ── lambda-only: skip manual SQS push, let alarm fire naturally ──────────
if lambda_only:
+ if errors_observed == 0:
+ die(
+ "No Lambda function errors were observed; the alarm is unlikely to fire."
+ )
+ try:
+ alarm = cw.describe_alarms(AlarmNames=["opendevops-lambda-errors-aggregate"]).get(
+ "MetricAlarms", []
+ )
+ if alarm and alarm[0].get("StateValue") == "ALARM":
+ warn(
+ "Aggregate alarm is already in ALARM; EventBridge may not emit a transition."
+ )
+ except ClientError as e:
+ warn(f"Could not inspect aggregate alarm state: {e}")
+
sep = "━" * 60
print(f"\n{GREEN}{sep}{NC}")
print(f"{GREEN}✓ Lambda invoked — waiting for alarm to fire automatically{NC}")
print(f" Function : {function_name}")
- print(f" Alarm : opendevops-lambda-errors-aggregate")
+ print(" Alarm : opendevops-lambda-errors-aggregate")
print(f" Real logs : CloudWatch → /aws/lambda/{function_name}")
- print(f" Monitoring: http://localhost/monitoring")
+ print(" Monitoring: http://localhost/monitoring")
print(f"{GREEN}{sep}{NC}\n")
warn("CloudWatch evaluates every 60s — alarm should trip in ~1-2 minutes.")
warn("EventBridge fires → SQS → agent investigates automatically.")
@@ -169,17 +233,19 @@ def run(region: str, invocations: int, function_name: str | None, list_only: boo
),
},
"configuration": {
- "metrics": [{
- "metricStat": {
- "metric": {
- "namespace": "AWS/Lambda",
- "name": "Errors",
- "dimensions": {"FunctionName": function_name},
+ "metrics": [
+ {
+ "metricStat": {
+ "metric": {
+ "namespace": "AWS/Lambda",
+ "name": "Errors",
+ "dimensions": {"FunctionName": function_name},
+ },
+ "period": 60,
+ "stat": "Sum",
},
- "period": 60,
- "stat": "Sum",
- },
- }],
+ }
+ ],
},
},
}
@@ -199,7 +265,7 @@ def run(region: str, invocations: int, function_name: str | None, list_only: boo
print(f" MessageId : {msg_id}")
print(f" Function : {function_name}")
print(f" Real logs : CloudWatch → /aws/lambda/{function_name}")
- print(f" Monitoring: http://localhost/monitoring")
+ print(" Monitoring: http://localhost/monitoring")
print(f"{GREEN}{sep}{NC}\n")
warn("The agent will pull REAL CloudWatch logs from that function.")
warn("Investigation takes ~30–90s. Watch the Monitoring page.")
@@ -209,12 +275,20 @@ def main() -> None:
parser = argparse.ArgumentParser(
description="Trigger a real Lambda failure through the monitoring pipeline"
)
- parser.add_argument("--function", help="Lambda function name to use (auto-picks first if omitted)")
- parser.add_argument("--region", default=os.environ.get("AWS_REGION", "us-east-1"))
+ parser.add_argument(
+ "--function", help="Lambda function name to use (auto-picks first if omitted)"
+ )
+ parser.add_argument("--region", default=os.environ.get("AWS_REGION", "us-east-1"))
parser.add_argument("--invocations", type=int, default=5)
- parser.add_argument("--list", action="store_true", help="List available Lambda functions and exit")
- parser.add_argument("--lambda-only", action="store_true", dest="lambda_only",
- help="Invoke Lambda and let the CloudWatch alarm fire automatically (no manual SQS push)")
+ parser.add_argument(
+ "--list", action="store_true", help="List available Lambda functions and exit"
+ )
+ parser.add_argument(
+ "--lambda-only",
+ action="store_true",
+ dest="lambda_only",
+ help="Invoke Lambda and let the CloudWatch alarm fire automatically",
+ )
args = parser.parse_args()
run(args.region, args.invocations, args.function, args.list, args.lambda_only)
diff --git a/src/agent/context_collectors.py b/src/agent/context_collectors.py
index 0106e9c..01b7099 100644
--- a/src/agent/context_collectors.py
+++ b/src/agent/context_collectors.py
@@ -7,11 +7,16 @@
import boto3
from loguru import logger
+from agent.init_store import get_runtime_aws_region
from config import settings
def _session() -> boto3.Session:
- return boto3.Session(profile_name=settings.aws_profile) if settings.aws_profile else boto3.Session()
+ return (
+ boto3.Session(profile_name=settings.aws_profile)
+ if settings.aws_profile
+ else boto3.Session()
+ )
def collect_context(event: dict) -> dict[str, Any]:
@@ -38,7 +43,12 @@ def collect_context(event: dict) -> dict[str, Any]:
elif source == "aws.guardduty":
return {"type": "guardduty", "detail": detail}
else:
- return {"type": "unknown", "source": source, "detail_type": detail_type, "detail": detail}
+ return {
+ "type": "unknown",
+ "source": source,
+ "detail_type": detail_type,
+ "detail": detail,
+ }
except Exception as e:
logger.error("Context collection failed for {}/{}: {}", source, detail_type, e)
return {"type": source, "error": str(e), "detail": detail}
@@ -47,8 +57,9 @@ def collect_context(event: dict) -> dict[str, Any]:
def _collect_alarm(detail: dict) -> dict:
"""Collect context for a CloudWatch alarm state change."""
import datetime
+
s = _session()
- region = settings.aws_region
+ region = get_runtime_aws_region()
cw = s.client("cloudwatch", region_name=region)
alarm_name = detail.get("alarmName", "")
@@ -67,19 +78,22 @@ def _collect_alarm(detail: dict) -> dict:
start = end - datetime.timedelta(hours=1)
try:
metric_resp = cw.get_metric_data(
- MetricDataQueries=[{
- "Id": "m1",
- "MetricStat": {
- "Metric": {
- "Namespace": alarm.get("Namespace", ""),
- "MetricName": alarm.get("MetricName", ""),
- "Dimensions": alarm.get("Dimensions", []),
+ MetricDataQueries=[
+ {
+ "Id": "m1",
+ "MetricStat": {
+ "Metric": {
+ "Namespace": alarm.get("Namespace", ""),
+ "MetricName": alarm.get("MetricName", ""),
+ "Dimensions": alarm.get("Dimensions", []),
+ },
+ "Period": 60,
+ "Stat": alarm.get("Statistic", "Average"),
},
- "Period": 60,
- "Stat": alarm.get("Statistic", "Average"),
- },
- }],
- StartTime=start, EndTime=end,
+ }
+ ],
+ StartTime=start,
+ EndTime=end,
)
values = metric_resp.get("MetricDataResults", [{}])[0].get("Values", [])
ctx["recent_values"] = values[:10]
@@ -92,13 +106,12 @@ def _collect_alarm(detail: dict) -> dict:
def _collect_lambda(detail: dict) -> dict:
"""Collect context for a Lambda failure."""
s = _session()
- region = settings.aws_region
+ region = get_runtime_aws_region()
lam = s.client("lambda", region_name=region)
logs = s.client("logs", region_name=region)
- fn_name = (
- detail.get("requestContext", {}).get("functionArn", "").split(":")[-1]
- or detail.get("functionName", "")
+ fn_name = detail.get("requestContext", {}).get("functionArn", "").split(":")[-1] or detail.get(
+ "functionName", ""
)
ctx: dict[str, Any] = {"type": "lambda", "function_name": fn_name, "detail": detail}
@@ -131,7 +144,7 @@ def _collect_lambda(detail: dict) -> dict:
def _collect_ecs(detail: dict) -> dict:
"""Collect context for an ECS task stopped event."""
s = _session()
- region = settings.aws_region
+ region = get_runtime_aws_region()
ecs = s.client("ecs", region_name=region)
logs = s.client("logs", region_name=region)
@@ -148,11 +161,13 @@ def _collect_ecs(detail: dict) -> dict:
}
for container in detail.get("containers", []):
- ctx["containers"].append({
- "name": container.get("name", ""),
- "exit_code": container.get("exitCode"),
- "reason": container.get("reason", ""),
- })
+ ctx["containers"].append(
+ {
+ "name": container.get("name", ""),
+ "exit_code": container.get("exitCode"),
+ "reason": container.get("reason", ""),
+ }
+ )
task_def_arn = detail.get("taskDefinitionArn", "")
if task_def_arn:
@@ -168,7 +183,10 @@ def _collect_ecs(detail: dict) -> dict:
task_id = task_arn.split("/")[-1]
stream_name = f"{prefix}/{containers[0]['name']}/{task_id}"
log_events = logs.get_log_events(
- logGroupName=log_group, logStreamName=stream_name, limit=30, startFromHead=False
+ logGroupName=log_group,
+ logStreamName=stream_name,
+ limit=30,
+ startFromHead=False,
)
ctx["recent_logs"] = [e["message"] for e in log_events.get("events", [])]
except Exception:
@@ -180,7 +198,7 @@ def _collect_ecs(detail: dict) -> dict:
def _collect_ec2(detail: dict) -> dict:
"""Collect context for an EC2 state change."""
s = _session()
- region = settings.aws_region
+ region = get_runtime_aws_region()
ec2 = s.client("ec2", region_name=region)
instance_id = detail.get("instance-id", "")
@@ -211,7 +229,7 @@ def _collect_ec2(detail: dict) -> dict:
def _collect_rds(detail: dict) -> dict:
"""Collect context for an RDS event."""
s = _session()
- region = settings.aws_region
+ region = get_runtime_aws_region()
rds = s.client("rds", region_name=region)
source_id = detail.get("SourceIdentifier", "")
diff --git a/src/agent/db/base.py b/src/agent/db/base.py
index e781ef3..40620ef 100644
--- a/src/agent/db/base.py
+++ b/src/agent/db/base.py
@@ -31,6 +31,7 @@ async def upsert_session(
model: str,
aws_region: str,
title: str | None = None,
+ source: str = "chat",
) -> None: ...
@abstractmethod
@@ -102,8 +103,15 @@ async def get_user_by_email(self, email: str) -> dict | None:
async def get_user_by_id(self, user_id: str) -> dict | None:
return None
+ async def create_org(self, name: str, slug: str) -> dict | None:
+ return None
+
+ async def get_first_org(self) -> dict | None:
+ return None
+
async def create_user(
- self, email: str, name: str, password_hash: str, role: str
+ self, email: str, name: str, password_hash: str, role: str,
+ org_id: str | None = None,
) -> dict | None:
return None
@@ -113,6 +121,9 @@ async def list_users(self) -> list[dict]:
async def update_user(self, user_id: str, **fields: Any) -> dict | None:
return None
+ async def assign_org_to_users_without_org(self, org_id: str) -> None:
+ pass
+
async def delete_user(self, user_id: str) -> None:
pass
@@ -126,11 +137,25 @@ async def add_alert(
resolution: str,
confidence: str,
sns_sent: bool,
+ dedup_key: str | None = None,
+ status: str = "completed",
+ session_id: str | None = None,
) -> str:
return ""
+ async def is_recent_alert(self, dedup_key: str, within_minutes: int = 3) -> bool:
+ return False
+
async def get_alerts(self, limit: int = 50) -> list[dict]:
return []
async def get_alert(self, alert_id: str) -> dict | None:
return None
+
+ # ── App config (init wizard / infrastructure state) ──────────────────────
+
+ async def get_app_config(self, key: str) -> dict | None:
+ return None
+
+ async def set_app_config(self, key: str, value: dict) -> None:
+ pass
diff --git a/src/agent/db/memory.py b/src/agent/db/memory.py
index ebefe0a..ffd8b1f 100644
--- a/src/agent/db/memory.py
+++ b/src/agent/db/memory.py
@@ -4,7 +4,7 @@
import uuid
from collections import defaultdict
-from datetime import datetime, timezone
+from datetime import UTC, datetime
from typing import Any
from loguru import logger
@@ -22,6 +22,7 @@ def __init__(self) -> None:
self._tool_calls: dict[str, list[dict]] = defaultdict(list)
self._usage: dict[str, list[dict]] = defaultdict(list)
self._alerts: list[dict] = []
+ self._app_config: dict[str, dict] = {}
async def init(self) -> Any:
from langgraph.checkpoint.memory import MemorySaver
@@ -42,7 +43,7 @@ def checkpointer(self) -> Any:
@staticmethod
def _now() -> str:
- return datetime.now(timezone.utc).isoformat()
+ return datetime.now(UTC).isoformat()
# ── App helpers ───────────────────────────────────────────────────────────
@@ -52,16 +53,21 @@ async def upsert_session(
model: str,
aws_region: str,
title: str | None = None,
+ source: str = "chat",
) -> None:
if session_id in self._sessions:
self._sessions[session_id]["last_active_at"] = self._now()
self._sessions[session_id]["model"] = model
+ if source == "chat":
+ self._sessions[session_id]["user_interacted"] = True
else:
self._sessions[session_id] = {
"id": session_id,
"title": title,
"model": model,
"aws_region": aws_region,
+ "source": source,
+ "user_interacted": source == "chat",
"created_at": self._now(),
"last_active_at": self._now(),
"is_deleted": False,
@@ -132,7 +138,11 @@ async def save_usage_event(
})
async def list_sessions(self, limit: int = 15, offset: int = 0) -> list[dict]:
- active = [s for s in self._sessions.values() if not s.get("is_deleted")]
+ active = [
+ s for s in self._sessions.values()
+ if not s.get("is_deleted")
+ and (s.get("source", "chat") == "chat" or s.get("user_interacted", False))
+ ]
sorted_sessions = sorted(active, key=lambda s: s["last_active_at"], reverse=True)
return sorted_sessions[offset: offset + limit]
@@ -240,6 +250,9 @@ async def add_alert(
resolution: str,
confidence: str,
sns_sent: bool,
+ dedup_key: str | None = None,
+ status: str = "completed",
+ session_id: str | None = None,
) -> str:
alert_id = str(uuid.uuid4())
self._alerts.append({
@@ -250,6 +263,9 @@ async def add_alert(
"confidence": confidence,
"sns_sent": sns_sent,
"timestamp": self._now(),
+ "dedup_key": dedup_key,
+ "status": status,
+ "session_id": session_id,
})
return alert_id
@@ -262,6 +278,12 @@ async def get_alert(self, alert_id: str) -> dict | None:
return a
return None
+ async def get_app_config(self, key: str) -> dict | None:
+ return self._app_config.get(key)
+
+ async def set_app_config(self, key: str, value: dict) -> None:
+ self._app_config[key] = value
+
async def search_sessions(self, query: str, limit: int = 10) -> list[dict]:
if not query.strip():
return []
diff --git a/src/agent/db/postgres.py b/src/agent/db/postgres.py
index 4896791..bc0c8ca 100644
--- a/src/agent/db/postgres.py
+++ b/src/agent/db/postgres.py
@@ -27,8 +27,8 @@ async def init(self) -> Any:
try:
import psycopg # type: ignore
- from psycopg_pool import AsyncConnectionPool
from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver
+ from psycopg_pool import AsyncConnectionPool
self._pool = AsyncConnectionPool(
conninfo=settings.database_url,
@@ -123,16 +123,21 @@ async def upsert_session(
model: str,
aws_region: str,
title: str | None = None,
+ source: str = "chat",
) -> None:
await self._exec(
"""
- INSERT INTO sessions (id, title, model, aws_region)
- VALUES (%s, %s, %s, %s)
+ INSERT INTO sessions (id, title, model, aws_region, source)
+ VALUES (%s, %s, %s, %s, %s)
ON CONFLICT (id) DO UPDATE SET
last_active_at = NOW(),
- model = EXCLUDED.model
+ model = EXCLUDED.model,
+ user_interacted = CASE
+ WHEN EXCLUDED.source = 'chat' THEN TRUE
+ ELSE sessions.user_interacted
+ END
""",
- uuid.UUID(session_id), title, model, aws_region,
+ uuid.UUID(session_id), title, model, aws_region, source,
)
async def save_message(
@@ -198,7 +203,9 @@ async def save_usage_event(
async def list_sessions(self, limit: int = 15, offset: int = 0) -> list[dict]:
rows = await self._fetchall(
- "SELECT id, title, last_active_at, model, aws_region FROM sessions WHERE is_deleted = FALSE ORDER BY last_active_at DESC LIMIT %s OFFSET %s",
+ "SELECT id, title, last_active_at, model, aws_region FROM sessions"
+ " WHERE is_deleted = FALSE AND (source = 'chat' OR user_interacted = TRUE)"
+ " ORDER BY last_active_at DESC LIMIT %s OFFSET %s",
limit, offset,
)
return [
@@ -474,21 +481,54 @@ async def get_user_by_email(self, email: str) -> dict | None:
async def get_user_by_id(self, user_id: str) -> dict | None:
return await self._fetchrow("SELECT * FROM users WHERE id = %s", uuid.UUID(user_id))
- async def create_user(
- self, email: str, name: str, password_hash: str, role: str
- ) -> dict | None:
+ async def create_org(self, name: str, slug: str) -> dict | None:
row = await self._fetchrow(
"""
- INSERT INTO users (email, name, password_hash, role)
- VALUES (%s, %s, %s, %s)
- RETURNING id, email, name, role, created_at
+ INSERT INTO organizations (name, slug)
+ VALUES (%s, %s)
+ ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name
+ RETURNING id, name, slug
""",
- email, name, password_hash, role,
+ name, slug,
+ )
+ if row:
+ row["id"] = str(row["id"])
+ return row
+
+ async def get_first_org(self) -> dict | None:
+ row = await self._fetchrow(
+ "SELECT id, name, slug FROM organizations ORDER BY created_at ASC LIMIT 1"
)
if row:
row["id"] = str(row["id"])
return row
+ async def create_user(
+ self, email: str, name: str, password_hash: str, role: str,
+ org_id: str | None = None,
+ ) -> dict | None:
+ if org_id:
+ row = await self._fetchrow(
+ """
+ INSERT INTO users (email, name, password_hash, role, org_id)
+ VALUES (%s, %s, %s, %s, %s)
+ RETURNING id, email, name, role, created_at
+ """,
+ email, name, password_hash, role, uuid.UUID(org_id),
+ )
+ else:
+ row = await self._fetchrow(
+ """
+ INSERT INTO users (email, name, password_hash, role)
+ VALUES (%s, %s, %s, %s)
+ RETURNING id, email, name, role, created_at
+ """,
+ email, name, password_hash, role,
+ )
+ if row:
+ row["id"] = str(row["id"])
+ return row
+
async def list_users(self) -> list[dict]:
rows = await self._fetchall(
"SELECT id, email, name, role, created_at FROM users ORDER BY created_at ASC"
@@ -516,6 +556,12 @@ async def update_user(self, user_id: str, **fields: Any) -> dict | None:
row["id"] = str(row["id"])
return row
+ async def assign_org_to_users_without_org(self, org_id: str) -> None:
+ await self._exec(
+ "UPDATE users SET org_id = %s WHERE org_id IS NULL",
+ uuid.UUID(org_id),
+ )
+
async def delete_user(self, user_id: str) -> None:
await self._exec("DELETE FROM users WHERE id = %s", uuid.UUID(user_id))
@@ -528,17 +574,30 @@ async def add_alert(
resolution: str,
confidence: str,
sns_sent: bool,
+ dedup_key: str | None = None,
+ status: str = "completed",
+ session_id: str | None = None,
) -> str:
row = await self._fetchrow(
- "INSERT INTO alerts (service, error, resolution, confidence, sns_sent) "
- "VALUES (%s, %s, %s, %s, %s) RETURNING id",
- service, error, resolution, confidence, sns_sent,
+ "INSERT INTO alerts"
+ " (service, error, resolution, confidence, sns_sent, dedup_key, status, session_id)"
+ " VALUES (%s, %s, %s, %s, %s, %s, %s, %s) RETURNING id",
+ service, error, resolution, confidence, sns_sent, dedup_key, status,
+ uuid.UUID(session_id) if session_id else None,
)
return str(row["id"]) if row else ""
+ async def is_recent_alert(self, dedup_key: str, within_minutes: int = 3) -> bool:
+ row = await self._fetchrow(
+ "SELECT 1 FROM alerts WHERE dedup_key = %s"
+ " AND created_at > NOW() - (%s * INTERVAL '1 minute') LIMIT 1",
+ dedup_key, within_minutes,
+ )
+ return row is not None
+
async def get_alerts(self, limit: int = 50) -> list[dict]:
rows = await self._fetchall(
- "SELECT id, service, error, resolution, confidence, sns_sent, created_at "
+ "SELECT id, service, error, resolution, confidence, sns_sent, status, created_at, session_id "
"FROM alerts ORDER BY created_at DESC LIMIT %s",
min(limit, 200),
)
@@ -550,14 +609,16 @@ async def get_alerts(self, limit: int = 50) -> list[dict]:
"resolution": r["resolution"],
"confidence": r["confidence"],
"sns_sent": bool(r["sns_sent"]),
+ "status": r["status"],
"timestamp": r["created_at"].isoformat() if r["created_at"] else None,
+ "session_id": str(r["session_id"]) if r["session_id"] else None,
}
for r in rows
]
async def get_alert(self, alert_id: str) -> dict | None:
row = await self._fetchrow(
- "SELECT id, service, error, resolution, confidence, sns_sent, created_at "
+ "SELECT id, service, error, resolution, confidence, sns_sent, status, created_at, session_id "
"FROM alerts WHERE id = %s",
uuid.UUID(alert_id),
)
@@ -570,9 +631,23 @@ async def get_alert(self, alert_id: str) -> dict | None:
"resolution": row["resolution"],
"confidence": row["confidence"],
"sns_sent": bool(row["sns_sent"]),
+ "status": row["status"],
"timestamp": row["created_at"].isoformat() if row["created_at"] else None,
+ "session_id": str(row["session_id"]) if row["session_id"] else None,
}
+ async def get_app_config(self, key: str) -> dict | None:
+ row = await self._fetchrow("SELECT value FROM app_config WHERE key = %s", key)
+ return row["value"] if row else None
+
+ async def set_app_config(self, key: str, value: dict) -> None:
+ await self._exec(
+ "INSERT INTO app_config (key, value) VALUES (%s, %s) "
+ "ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()",
+ key,
+ self._jsonb(value),
+ )
+
async def search_sessions(self, query: str, limit: int = 10) -> list[dict]:
if not query.strip():
return []
diff --git a/src/agent/db/sqlite.py b/src/agent/db/sqlite.py
index 76c8f30..e30ad76 100644
--- a/src/agent/db/sqlite.py
+++ b/src/agent/db/sqlite.py
@@ -12,17 +12,18 @@
from agent.db.base import DatabaseBackend
from config import settings
-
_DDL = """
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
title TEXT,
model TEXT NOT NULL DEFAULT '',
aws_region TEXT NOT NULL DEFAULT 'us-east-1',
- created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now')),
- last_active_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now')),
- is_deleted INTEGER NOT NULL DEFAULT 0,
- deleted_at TEXT
+ source TEXT NOT NULL DEFAULT 'chat',
+ user_interacted INTEGER NOT NULL DEFAULT 0,
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now')),
+ last_active_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now')),
+ is_deleted INTEGER NOT NULL DEFAULT 0,
+ deleted_at TEXT
);
CREATE TABLE IF NOT EXISTS messages (
@@ -71,6 +72,12 @@
);
CREATE INDEX IF NOT EXISTS alerts_created_at_idx ON alerts(created_at DESC);
CREATE INDEX IF NOT EXISTS alerts_service_idx ON alerts(service);
+
+CREATE TABLE IF NOT EXISTS app_config (
+ key TEXT PRIMARY KEY,
+ value TEXT NOT NULL DEFAULT '{}',
+ updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now'))
+);
"""
_SERVICE_MAP: dict[str, str] = {
@@ -126,6 +133,17 @@ async def init(self) -> Any:
except Exception:
pass # column already exists
+ # Migrate existing sessions tables: add source / user_interacted columns if absent
+ for col_sql in (
+ "ALTER TABLE sessions ADD COLUMN source TEXT NOT NULL DEFAULT 'chat'",
+ "ALTER TABLE sessions ADD COLUMN user_interacted INTEGER NOT NULL DEFAULT 0",
+ ):
+ try:
+ await self._conn.execute(col_sql)
+ await self._conn.commit()
+ except Exception:
+ pass # column already exists
+
# Migrate existing databases: create alerts table if absent
try:
await self._conn.execute(
@@ -133,12 +151,36 @@ async def init(self) -> Any:
"id TEXT PRIMARY KEY, service TEXT NOT NULL, error TEXT NOT NULL, "
"resolution TEXT NOT NULL DEFAULT '', confidence TEXT NOT NULL DEFAULT 'LOW', "
"sns_sent INTEGER NOT NULL DEFAULT 0, "
+ "dedup_key TEXT, status TEXT NOT NULL DEFAULT 'completed', "
+ "session_id TEXT, "
"created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now')))"
)
await self._conn.commit()
except Exception:
pass
+ # Migrate existing alerts tables: add dedup_key / status / session_id if absent
+ for col_sql in (
+ "ALTER TABLE alerts ADD COLUMN dedup_key TEXT",
+ "ALTER TABLE alerts ADD COLUMN status TEXT NOT NULL DEFAULT 'completed'",
+ "ALTER TABLE alerts ADD COLUMN session_id TEXT",
+ ):
+ try:
+ await self._conn.execute(col_sql)
+ await self._conn.commit()
+ except Exception:
+ pass # column already exists
+
+ try:
+ await self._conn.execute(
+ "CREATE TABLE IF NOT EXISTS app_config ("
+ "key TEXT PRIMARY KEY, value TEXT NOT NULL DEFAULT '{}', "
+ "updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now')))"
+ )
+ await self._conn.commit()
+ except Exception:
+ pass
+
# LangGraph checkpointer uses its own connection to the same file
cp_conn = await aiosqlite.connect(self._path)
self._checkpointer = AsyncSqliteSaver(cp_conn)
@@ -203,16 +245,21 @@ async def upsert_session(
model: str,
aws_region: str,
title: str | None = None,
+ source: str = "chat",
) -> None:
await self._exec(
"""
- INSERT INTO sessions (id, title, model, aws_region)
- VALUES (?, ?, ?, ?)
+ INSERT INTO sessions (id, title, model, aws_region, source)
+ VALUES (?, ?, ?, ?, ?)
ON CONFLICT (id) DO UPDATE SET
last_active_at = strftime('%Y-%m-%dT%H:%M:%S', 'now'),
- model = excluded.model
+ model = excluded.model,
+ user_interacted = CASE
+ WHEN excluded.source = 'chat' THEN 1
+ ELSE sessions.user_interacted
+ END
""",
- session_id, title, model, aws_region,
+ session_id, title, model, aws_region, source,
)
async def save_message(
@@ -275,7 +322,9 @@ async def save_usage_event(
async def list_sessions(self, limit: int = 15, offset: int = 0) -> list[dict]:
rows = await self._fetchall(
- "SELECT id, title, last_active_at, model, aws_region FROM sessions WHERE is_deleted = 0 ORDER BY last_active_at DESC LIMIT ? OFFSET ?",
+ "SELECT id, title, last_active_at, model, aws_region FROM sessions"
+ " WHERE is_deleted = 0 AND (source = 'chat' OR user_interacted = 1)"
+ " ORDER BY last_active_at DESC LIMIT ? OFFSET ?",
limit, offset,
)
return [
@@ -608,11 +657,17 @@ async def add_alert(
resolution: str,
confidence: str,
sns_sent: bool,
+ dedup_key: str | None = None,
+ status: str = "completed",
+ session_id: str | None = None,
) -> str:
alert_id = str(uuid.uuid4())
await self._exec(
- "INSERT INTO alerts (id, service, error, resolution, confidence, sns_sent) VALUES (?, ?, ?, ?, ?, ?)",
- alert_id, service, error, resolution, confidence, 1 if sns_sent else 0,
+ "INSERT INTO alerts"
+ " (id, service, error, resolution, confidence, sns_sent, dedup_key, status, session_id)"
+ " VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
+ alert_id, service, error, resolution, confidence,
+ 1 if sns_sent else 0, dedup_key, status, session_id,
)
return alert_id
@@ -653,6 +708,25 @@ async def get_alert(self, alert_id: str) -> dict | None:
"timestamp": row["created_at"],
}
+ async def get_app_config(self, key: str) -> dict | None:
+ row = await self._fetchone("SELECT value FROM app_config WHERE key = ?", key)
+ if not row:
+ return None
+ try:
+ return json.loads(row["value"])
+ except Exception:
+ return None
+
+ async def set_app_config(self, key: str, value: dict) -> None:
+ await self._exec(
+ "INSERT INTO app_config (key, value, updated_at) "
+ "VALUES (?, ?, strftime('%Y-%m-%dT%H:%M:%S', 'now')) "
+ "ON CONFLICT(key) DO UPDATE SET "
+ "value = excluded.value, updated_at = excluded.updated_at",
+ key,
+ json.dumps(value),
+ )
+
async def search_sessions(self, query: str, limit: int = 10) -> list[dict]:
if not query.strip():
return []
diff --git a/src/agent/event_consumer.py b/src/agent/event_consumer.py
index fc6894a..a00c243 100644
--- a/src/agent/event_consumer.py
+++ b/src/agent/event_consumer.py
@@ -3,6 +3,7 @@
from __future__ import annotations
import asyncio
+import hashlib
import json
import uuid
from typing import Any
@@ -10,9 +11,18 @@
import boto3
from loguru import logger
+from agent.init_store import (
+ get_runtime_aws_region,
+ get_runtime_sns_topic_arn,
+ get_runtime_sqs_queue_url,
+)
+from agent.monitor_store import add_alert, is_recent_alert, update_service
from config import settings
-from agent.init_store import load_init
-from agent.monitor_store import add_alert
+
+# In-memory set of dedup keys currently being investigated.
+# Prevents a second identical event from starting a parallel agent run
+# before the first one finishes and writes its key to the DB.
+_in_progress: set[str] = set()
def _build_investigation_prompt(event: dict) -> str:
@@ -25,21 +35,31 @@ def _build_investigation_prompt(event: dict) -> str:
if source == "aws.cloudwatch" and "alarmName" in detail:
alarm_name = detail["alarmName"]
reason = detail.get("state", {}).get("reason", "")
- metric = detail.get("configuration", {}).get("metrics", [{}])[0].get("metricStat", {}).get("metric", {})
+ metric = (
+ detail.get("configuration", {})
+ .get("metrics", [{}])[0]
+ .get("metricStat", {})
+ .get("metric", {})
+ )
namespace = metric.get("namespace", "")
metric_name = metric.get("name", "")
dimensions = metric.get("dimensions", {})
if not dimensions:
dim_hint = (
- "IMPORTANT: This alarm has NO dimensions — it monitors the AGGREGATE metric across all resources. "
+ "IMPORTANT: This alarm has NO dimensions — it monitors the AGGREGATE "
+ "metric across all resources. "
"You MUST first identify which specific resource is causing the errors. "
- "For Lambda errors: use get_metric_data with a SEARCH expression like "
- "SEARCH('{AWS/Lambda,FunctionName} MetricName=\"Errors\"', 'Sum', 300) to find which function(s) have errors. "
+ "For Lambda errors: call list_lambda_functions, then use "
+ "get_lambda_error_rate on likely functions "
+ "to find which function(s) have errors. "
"Then pull that function's CloudWatch logs to find the actual error message."
)
else:
- dim_hint = f"Dimensions: {json.dumps(dimensions)} — use these to identify the specific resource."
+ dim_hint = (
+ f"Dimensions: {json.dumps(dimensions)} — use these to identify the "
+ "specific resource."
+ )
return (
f'CloudWatch alarm "{alarm_name}" triggered at {time}.\n'
@@ -48,7 +68,7 @@ def _build_investigation_prompt(event: dict) -> str:
f"{dim_hint}\n"
f"Reason: {reason}\n\n"
"Investigate step by step:\n"
- "1. Identify the SPECIFIC resource (function name, instance ID, etc.) causing the issue\n"
+ "1. Identify the SPECIFIC resource causing the issue\n"
"2. Pull its recent CloudWatch logs\n"
"3. Find the actual error message or stack trace\n"
"4. Provide actionable fix steps based on the real error"
@@ -86,56 +106,190 @@ def _build_investigation_prompt(event: dict) -> str:
)
-async def _run_investigation(prompt: str) -> dict[str, Any] | None:
- """Run a full agent investigation and return the submit_investigation result."""
+async def _run_investigation(prompt: str, session_id: str) -> dict[str, Any] | None:
+ """Run a full agent investigation using the same streaming path as the chat endpoint."""
from agent.core import get_agent
+ from agent.turns import save_turn
+
+ def _f(obj: Any, key: str, default: Any = None) -> Any:
+ return obj.get(key, default) if isinstance(obj, dict) else getattr(obj, key, default)
- thread_id = str(uuid.uuid4())
config = {
- "configurable": {"thread_id": thread_id},
+ "configurable": {"thread_id": session_id},
"recursion_limit": settings.max_tool_calls * 3 + 15,
}
+ tc_accum: dict[int, dict[str, Any]] = {}
+ pending_calls: dict[str, dict[str, Any]] = {}
+ tool_calls_log: list[dict[str, Any]] = []
+ investigation_result: dict[str, Any] | None = None
+ response_text = ""
+ usage_meta: Any = None
+
try:
- result = await get_agent().ainvoke(
+ async for chunk, _meta in get_agent().astream(
{"messages": [{"role": "user", "content": prompt}]},
config=config,
- )
- for msg in reversed(result.get("messages", [])):
- for tc in getattr(msg, "tool_calls", []) or []:
- name = tc.get("name") if isinstance(tc, dict) else getattr(tc, "name", None)
- args = tc.get("args") if isinstance(tc, dict) else getattr(tc, "args", {})
- if name == "submit_investigation":
- return args
- messages = result.get("messages", [])
- if messages:
- last = messages[-1]
- content = last.content if hasattr(last, "content") else str(last)
- if content:
- return {"root_cause_summary": content, "confidence": "MEDIUM", "mitigation_steps": []}
+ stream_mode="messages",
+ ):
+ um = getattr(chunk, "usage_metadata", None)
+ if um:
+ usage_meta = um
+
+ for tcc in getattr(chunk, "tool_call_chunks", []) or []:
+ idx = _f(tcc, "index", 0)
+ if idx not in tc_accum:
+ tc_accum[idx] = {"id": "", "name": "", "args_str": ""}
+ if tc_id := _f(tcc, "id"):
+ tc_accum[idx]["id"] = tc_id
+ if name := (_f(tcc, "name") or ""):
+ tc_accum[idx]["name"] += name
+ if args := (_f(tcc, "args") or ""):
+ tc_accum[idx]["args_str"] += args
+
+ for tc in getattr(chunk, "tool_calls", []) or []:
+ tc_id = _f(tc, "id") or ""
+ name = _f(tc, "name") or ""
+ args = _f(tc, "args") or {}
+ if tc_id and name:
+ pending_calls[tc_id] = {"tool": name, "args": args if isinstance(args, dict) else {}}
+
+ content = getattr(chunk, "content", "")
+ tc_id = getattr(chunk, "tool_call_id", None)
+
+ if content and isinstance(content, str) and not tc_id:
+ response_text += content
+
+ if tc_id:
+ for entry in tc_accum.values():
+ if eid := entry["id"]:
+ try:
+ eargs: Any = json.loads(entry["args_str"]) if entry["args_str"] else {}
+ except json.JSONDecodeError:
+ eargs = {}
+ pending_calls[eid] = {"tool": entry["name"], "args": eargs}
+ tc_accum.clear()
+
+ call_info = pending_calls.pop(
+ tc_id,
+ {"tool": getattr(chunk, "name", None) or "unknown", "args": {}},
+ )
+ try:
+ result = json.loads(content)
+ except (json.JSONDecodeError, TypeError):
+ result = {"raw": str(content)[:500]}
+
+ tool_calls_log.append({"tool": call_info["tool"], "args": call_info["args"], "result": result})
+ if call_info["tool"] == "submit_investigation":
+ investigation_result = call_info["args"]
+
except Exception as e:
logger.error("Agent investigation failed: {}", e)
- return None
+ investigation_result = {
+ "_status": "failed",
+ "root_cause_summary": f"Investigation failed: {e}",
+ "confidence": "LOW",
+ "mitigation_steps": [
+ "Re-investigate manually via the chat — use a higher MAX_TOOL_CALLS if needed.",
+ ],
+ }
+
+ # Build readable assistant summary
+ if investigation_result and investigation_result.get("root_cause_summary") and "_status" not in investigation_result:
+ steps = investigation_result.get("mitigation_steps", [])
+ steps_text = "\n".join(f"- {s}" for s in steps) if steps else "_No steps provided._"
+ confidence = investigation_result.get("confidence", "MEDIUM")
+ assistant_text = (
+ f"**Root Cause ({confidence} confidence):** "
+ f"{investigation_result['root_cause_summary']}\n\n"
+ f"**Mitigation Steps:**\n{steps_text}"
+ )
+ elif response_text.strip():
+ assistant_text = response_text.strip()
+ else:
+ assistant_text = "Investigation completed. See tool calls for details."
+
+ await save_turn(
+ session_id=session_id,
+ user_message=prompt,
+ assistant_text=assistant_text,
+ tool_calls_log=tool_calls_log,
+ usage={
+ "model": settings.llm_model,
+ "input_tokens": _f(usage_meta, "input_tokens", 0) or 0,
+ "output_tokens": _f(usage_meta, "output_tokens", 0) or 0,
+ "latency_ms": 0,
+ },
+ source="event",
+ )
+
+ return investigation_result
+
+_VOLATILE_KEYS = {
+ "time", "timestamp", "eventTime", "requestId", "eventID",
+ "approximateInvokeCount", "startTime", "endTime", "updatedAt",
+ "stateTransitionedAt", "lastUpdatedTime",
+}
-async def _deliver(result: dict[str, Any], event: dict) -> None:
+
+def _strip_volatile(obj: Any) -> Any:
+ if isinstance(obj, dict):
+ return {k: _strip_volatile(v) for k, v in obj.items() if k not in _VOLATILE_KEYS}
+ if isinstance(obj, list):
+ return [_strip_volatile(i) for i in obj]
+ return obj
+
+
+def _event_dedup_key(event: dict) -> str:
+ """MD5 fingerprint of the event's stable fields — works for any AWS service."""
+ source = event.get("source", "unknown")
+ fingerprint = {
+ "source": source,
+ "detail-type": event.get("detail-type", ""),
+ "detail": _strip_volatile(event.get("detail", {})),
+ }
+ digest = hashlib.md5(
+ json.dumps(fingerprint, sort_keys=True, default=str).encode()
+ ).hexdigest()[:12]
+ return f"{source}:{digest}"
+
+
+def _extract_aws_error(event: dict) -> str:
+ """Pull the original AWS error message out of the event payload."""
+ source = event.get("source", "")
+ detail = event.get("detail", {})
+ if source == "aws.lambda":
+ return detail.get("responsePayload", {}).get("errorMessage", "")
+ if source == "aws.cloudwatch":
+ return detail.get("state", {}).get("reason", "")
+ if source == "aws.ecs":
+ return detail.get("stoppedReason", "")
+ if source == "aws.rds":
+ return detail.get("Message", "")
+ return ""
+
+
+async def _deliver(result: dict[str, Any], event: dict, dedup_key: str, session_id: str) -> None:
"""Send investigation result to SNS + Slack and persist to the monitor store."""
+ status = result.pop("_status", "completed")
+ is_test = bool(event.get("_opendevops_test", False))
services_affected = result.get("services_affected", [])
- service = result.get("service_name", result.get("service",
- services_affected[0] if services_affected else "unknown"
- ))
+ fallback = services_affected[0] if services_affected else event.get("source", "unknown")
+ service = result.get("service_name", result.get("service", fallback))
root_cause = result.get("root_cause_summary", "")
mitigation = result.get("mitigation_steps", [])
confidence = result.get("confidence", "MEDIUM")
resolution = "\n".join(mitigation) if isinstance(mitigation, list) else str(mitigation)
+ aws_error = _extract_aws_error(event)
- init_data = load_init()
- sns_arn = settings.sns_topic_arn or init_data.get("sns_topic_arn")
+ sns_arn = get_runtime_sns_topic_arn()
sns_sent = False
if sns_arn:
try:
from tools.sns import publish_sns_alert
+
services_affected = result.get("services_affected", [])
evidence = result.get("evidence", [])
message = (
@@ -143,12 +297,18 @@ async def _deliver(result: dict[str, Any], event: dict) -> None:
f"Root Cause: {root_cause}\n"
f"Evidence: {chr(10).join(evidence[:5]) if evidence else 'N/A'}\n"
f"Mitigation Steps:\n{resolution}\n"
- f"Services Affected: {', '.join(services_affected) if services_affected else service}\n"
+ "Services Affected: "
+ f"{', '.join(services_affected) if services_affected else service}\n"
f"Confidence: {confidence}\n"
f"Event Time: {event.get('time', '')}"
)
+ subject_prefix = "[FAILED]" if status == "failed" else f"[{confidence}]"
await asyncio.get_event_loop().run_in_executor(
- None, publish_sns_alert, sns_arn, f"[{confidence}] {service} — {root_cause[:80]}", message
+ None,
+ publish_sns_alert,
+ sns_arn,
+ f"{subject_prefix} {service} — {root_cause[:80]}",
+ message,
)
sns_sent = True
except Exception as e:
@@ -157,13 +317,31 @@ async def _deliver(result: dict[str, Any], event: dict) -> None:
# Also notify Slack (keeps parity with the poller)
if settings.slack_webhook_url:
try:
- from integrations.slack_webhook import post_investigation
- session_id = str(uuid.uuid4())
- await post_investigation(settings.slack_webhook_url, result, session_id)
+ from integrations.slack_webhook import post_failed_investigation, post_investigation
+
+ if status == "failed":
+ await post_failed_investigation(
+ settings.slack_webhook_url, service, root_cause,
+ session_id, aws_error=aws_error, is_test=is_test,
+ )
+ else:
+ await post_investigation(
+ settings.slack_webhook_url, result, session_id, is_test=is_test
+ )
except Exception as e:
logger.error("Slack delivery failed from event consumer: {}", e)
- add_alert(service=service, error=root_cause, resolution=resolution, confidence=confidence, sns_sent=sns_sent)
+ update_service(service, status, root_cause)
+ await add_alert(
+ service=service,
+ error=root_cause,
+ resolution=resolution,
+ confidence=confidence,
+ sns_sent=sns_sent,
+ dedup_key=dedup_key,
+ status=status,
+ session_id=session_id,
+ )
logger.info("Alert delivered: service={} confidence={} sns={}", service, confidence, sns_sent)
@@ -186,7 +364,7 @@ def _is_real_failure(source: str, detail: dict) -> bool:
async def _process_event(event: dict) -> None:
- """Filter → enrich prompt → investigate → deliver."""
+ """Filter → dedup check → enrich prompt → investigate → deliver."""
source = event.get("source", "unknown")
detail_type = event.get("detail-type", "")
detail = event.get("detail", {})
@@ -195,6 +373,17 @@ async def _process_event(event: dict) -> None:
logger.debug("Skipping non-failure event: {} / {}", source, detail_type)
return
+ dedup_key = _event_dedup_key(event)
+ if dedup_key in _in_progress or await is_recent_alert(dedup_key):
+ logger.info(
+ "Dedup: skipping {} / {} (key={}) — {}",
+ source, detail_type, dedup_key,
+ "investigation already in progress" if dedup_key in _in_progress
+ else "same event investigated within last 3 min",
+ )
+ return
+
+ _in_progress.add(dedup_key)
logger.info("Processing event: {} / {}", source, detail_type)
prompt = _build_investigation_prompt(event)
@@ -202,31 +391,42 @@ async def _process_event(event: dict) -> None:
# Enrich prompt with deterministic boto3 context (no LLM cost)
try:
from agent.context_collectors import collect_context
+
context = collect_context(event)
if context and not context.get("error"):
context_str = json.dumps(context, default=str)
- prompt = prompt + f"\n\nPre-collected context (use as starting facts, verify with tools):\n{context_str[:3000]}"
+ prompt = (
+ prompt
+ + "\n\nPre-collected context (use as starting facts, verify with tools):\n"
+ + context_str[:3000]
+ )
except Exception as e:
logger.debug("Context collection skipped: {}", e)
- result = await _run_investigation(prompt)
- if result:
- await _deliver(result, event)
- else:
- logger.warning("Investigation produced no result for {} / {}", source, detail_type)
+ session_id = str(uuid.uuid4())
+ try:
+ result = await _run_investigation(prompt, session_id)
+ if not result:
+ logger.warning("Investigation produced no result for {} / {}", source, detail_type)
+ return
+ await _deliver(result, event, dedup_key, session_id)
+ finally:
+ _in_progress.discard(dedup_key)
async def event_consumer_loop() -> None:
"""Main loop — long-polls SQS, processes events."""
- init_data = load_init()
- # Prefer env var over init.json so users can configure via .env without the wizard
- queue_url = settings.sqs_queue_url or init_data.get("sqs_queue_url")
+ queue_url = get_runtime_sqs_queue_url()
if not queue_url:
logger.warning("No SQS queue URL configured — event consumer not starting")
return
- s = boto3.Session(profile_name=settings.aws_profile) if settings.aws_profile else boto3.Session()
- sqs = s.client("sqs", region_name=settings.aws_region)
+ s = (
+ boto3.Session(profile_name=settings.aws_profile)
+ if settings.aws_profile
+ else boto3.Session()
+ )
+ sqs = s.client("sqs", region_name=get_runtime_aws_region())
logger.info("Event consumer started — polling {}", queue_url)
diff --git a/src/agent/event_infra.py b/src/agent/event_infra.py
index 7d41185..f956f37 100644
--- a/src/agent/event_infra.py
+++ b/src/agent/event_infra.py
@@ -5,6 +5,7 @@
import json
import boto3
+from botocore.exceptions import ClientError
from loguru import logger
from config import settings
@@ -24,7 +25,10 @@
},
"opendevops-lambda-throttle": {
"source": ["aws.lambda"],
- "detail-type": ["Lambda Function Invocation Result - Failure", "AWS API Call via CloudTrail"],
+ "detail-type": [
+ "Lambda Function Invocation Result - Failure",
+ "AWS API Call via CloudTrail",
+ ],
"detail": {"errorCode": ["TooManyRequestsException", "Throttling"]},
},
"opendevops-ecs-task-stopped": {
@@ -32,7 +36,11 @@
"detail-type": ["ECS Task State Change"],
"detail": {
"lastStatus": ["STOPPED"],
- "stopCode": ["TaskFailedToStart", "EssentialContainerExited", "ServiceSchedulerInitiated"],
+ "stopCode": [
+ "TaskFailedToStart",
+ "EssentialContainerExited",
+ "ServiceSchedulerInitiated",
+ ],
"containers": {"exitCode": [{"anything-but": 0}]},
},
},
@@ -65,108 +73,166 @@
def _session() -> boto3.Session:
- return boto3.Session(profile_name=settings.aws_profile) if settings.aws_profile else boto3.Session()
+ return (
+ boto3.Session(profile_name=settings.aws_profile)
+ if settings.aws_profile
+ else boto3.Session()
+ )
ALARM_NAME = "opendevops-lambda-errors-aggregate"
-def setup_event_infra() -> dict:
+def _missing_resource(exc: Exception) -> bool:
+ if not isinstance(exc, ClientError):
+ return False
+ code = exc.response.get("Error", {}).get("Code", "")
+ return code in {
+ "ResourceNotFoundException",
+ "AWS.SimpleQueueService.NonExistentQueue",
+ "QueueDoesNotExist",
+ }
+
+
+def setup_event_infra(region: str | None = None) -> dict:
"""Create SQS queue, EventBridge rules, and aggregate Lambda alarm.
Returns {queue_url, queue_arn, rule_arns}.
"""
s = _session()
- region = settings.aws_region
+ region = region or settings.aws_region
sqs = s.client("sqs", region_name=region)
events = s.client("events", region_name=region)
cw = s.client("cloudwatch", region_name=region)
sts = s.client("sts", region_name=region)
account_id = sts.get_caller_identity()["Account"]
-
- resp = sqs.create_queue(
- QueueName=QUEUE_NAME,
- Attributes={
- "MessageRetentionPeriod": "86400",
- "VisibilityTimeout": "120",
- "ReceiveMessageWaitTimeSeconds": "20",
- },
- )
- queue_url = resp["QueueUrl"]
- queue_attrs = sqs.get_queue_attributes(QueueUrl=queue_url, AttributeNames=["QueueArn"])
- queue_arn = queue_attrs["Attributes"]["QueueArn"]
-
- policy = {
- "Version": "2012-10-17",
- "Statement": [{
- "Sid": "AllowEventBridge",
- "Effect": "Allow",
- "Principal": {"Service": "events.amazonaws.com"},
- "Action": "sqs:SendMessage",
- "Resource": queue_arn,
- "Condition": {"ArnLike": {"aws:SourceArn": f"arn:aws:events:{region}:{account_id}:rule/opendevops-*"}},
- }],
- }
- sqs.set_queue_attributes(QueueUrl=queue_url, Attributes={"Policy": json.dumps(policy)})
-
+ queue_url = ""
rule_arns: dict[str, str] = {}
- for rule_name, pattern in RULES.items():
- resp = events.put_rule(
- Name=rule_name,
- EventPattern=json.dumps(pattern),
- State="ENABLED",
- Description=f"OpenDevOps Agent — {rule_name}",
+
+ try:
+ resp = sqs.create_queue(
+ QueueName=QUEUE_NAME,
+ Attributes={
+ "MessageRetentionPeriod": "86400",
+ "VisibilityTimeout": "120",
+ "ReceiveMessageWaitTimeSeconds": "20",
+ },
)
- rule_arns[rule_name] = resp["RuleArn"]
- events.put_targets(
- Rule=rule_name,
- Targets=[{"Id": "opendevops-sqs", "Arn": queue_arn}],
+ queue_url = resp["QueueUrl"]
+ queue_attrs = sqs.get_queue_attributes(QueueUrl=queue_url, AttributeNames=["QueueArn"])
+ queue_arn = queue_attrs["Attributes"]["QueueArn"]
+
+ policy = {
+ "Version": "2012-10-17",
+ "Statement": [
+ {
+ "Sid": "AllowEventBridge",
+ "Effect": "Allow",
+ "Principal": {"Service": "events.amazonaws.com"},
+ "Action": "sqs:SendMessage",
+ "Resource": queue_arn,
+ "Condition": {
+ "ArnLike": {
+ "aws:SourceArn": (
+ f"arn:aws:events:{region}:{account_id}:rule/opendevops-*"
+ )
+ }
+ },
+ }
+ ],
+ }
+ sqs.set_queue_attributes(QueueUrl=queue_url, Attributes={"Policy": json.dumps(policy)})
+
+ for rule_name, pattern in RULES.items():
+ resp = events.put_rule(
+ Name=rule_name,
+ EventPattern=json.dumps(pattern),
+ State="ENABLED",
+ Description=f"OpenDevOps Agent — {rule_name}",
+ )
+ rule_arns[rule_name] = resp["RuleArn"]
+ events.put_targets(
+ Rule=rule_name,
+ Targets=[{"Id": "opendevops-sqs", "Arn": queue_arn}],
+ )
+
+ # Aggregate CloudWatch alarm — fires when ANY Lambda in the account errors.
+ # No dimensions = account-wide. EventBridge alarm-state rule routes it to SQS.
+ cw.put_metric_alarm(
+ AlarmName=ALARM_NAME,
+ AlarmDescription="OpenDevOps Agent — triggers investigation on any Lambda error",
+ Namespace="AWS/Lambda",
+ MetricName="Errors",
+ Statistic="Sum",
+ Period=60,
+ EvaluationPeriods=1,
+ Threshold=1,
+ ComparisonOperator="GreaterThanOrEqualToThreshold",
+ TreatMissingData="notBreaching",
)
+ except Exception:
+ if queue_url or rule_arns:
+ teardown_event_infra(queue_url, rule_arns, region)
+ raise
- # Aggregate CloudWatch alarm — fires when ANY Lambda in the account errors.
- # No dimensions = account-wide. EventBridge alarm-state rule routes it to SQS.
- cw.put_metric_alarm(
- AlarmName=ALARM_NAME,
- AlarmDescription="OpenDevOps Agent — triggers investigation on any Lambda error",
- Namespace="AWS/Lambda",
- MetricName="Errors",
- Statistic="Sum",
- Period=60,
- EvaluationPeriods=1,
- Threshold=1,
- ComparisonOperator="GreaterThanOrEqualToThreshold",
- TreatMissingData="notBreaching",
- )
logger.info(
"Event infra created: queue={} rules={} alarm={}", queue_url, len(rule_arns), ALARM_NAME
)
return {"queue_url": queue_url, "queue_arn": queue_arn, "rule_arns": rule_arns}
-def teardown_event_infra(queue_url: str, rule_arns: dict[str, str]) -> None:
+def teardown_event_infra(
+ queue_url: str | None,
+ rule_arns: dict[str, str] | None,
+ region: str | None = None,
+) -> dict:
"""Remove EventBridge rules, aggregate CloudWatch alarm, and SQS queue."""
s = _session()
- region = settings.aws_region
+ region = region or settings.aws_region
sqs = s.client("sqs", region_name=region)
events = s.client("events", region_name=region)
cw = s.client("cloudwatch", region_name=region)
+ warnings: list[str] = []
+ errors: list[str] = []
- for rule_name in rule_arns:
+ rules_to_delete = list((rule_arns or {}).keys()) or list(RULES.keys())
+ for rule_name in rules_to_delete:
try:
- events.remove_targets(Rule=rule_name, Ids=["opendevops-sqs"])
+ resp = events.remove_targets(Rule=rule_name, Ids=["opendevops-sqs"])
+ if resp.get("FailedEntryCount"):
+ errors.append(f"Failed to remove target for rule {rule_name}: {resp}")
events.delete_rule(Name=rule_name)
except Exception as e:
- logger.warning("Failed to delete rule {}: {}", rule_name, e)
+ message = f"Failed to delete rule {rule_name}: {e}"
+ logger.warning(message)
+ if _missing_resource(e):
+ warnings.append(message)
+ else:
+ errors.append(message)
try:
cw.delete_alarms(AlarmNames=[ALARM_NAME])
except Exception as e:
- logger.warning("Failed to delete alarm {}: {}", ALARM_NAME, e)
-
- try:
- sqs.delete_queue(QueueUrl=queue_url)
- except Exception as e:
- logger.warning("Failed to delete queue: {}", e)
+ message = f"Failed to delete alarm {ALARM_NAME}: {e}"
+ logger.warning(message)
+ if _missing_resource(e):
+ warnings.append(message)
+ else:
+ errors.append(message)
+
+ if queue_url:
+ try:
+ sqs.delete_queue(QueueUrl=queue_url)
+ except Exception as e:
+ message = f"Failed to delete queue: {e}"
+ logger.warning(message)
+ if _missing_resource(e):
+ warnings.append(message)
+ else:
+ errors.append(message)
+ else:
+ warnings.append("No queue URL was configured")
logger.info("Event infra torn down")
+ return {"warnings": warnings, "errors": errors}
diff --git a/src/agent/init_store.py b/src/agent/init_store.py
index 2d01b2b..d837163 100644
--- a/src/agent/init_store.py
+++ b/src/agent/init_store.py
@@ -1,52 +1,166 @@
-"""Init config store — persists initialization state to /init.json."""
+"""Init config store — setup and event-infra state.
-import json
-from pathlib import Path
+Single source of truth:
+ - Postgres / SQLite: app_config table in the DB
+ - Memory: in-memory dict (lost on restart, fine for dev)
+
+_CACHE is the in-process copy. All sync callers (boto3 threads, etc.)
+read from _CACHE — zero DB calls at runtime. Writes update _CACHE first,
+then persist to DB async. The cache is seeded from DB on startup via
+refresh_init_cache_from_db() called in the FastAPI lifespan.
+
+init.json is no longer written or read.
+"""
+
+from __future__ import annotations
+
+from typing import Any
from loguru import logger
from config import settings
-_INIT_FILE = Path(settings.data_dir) / "init.json"
-
-_DEFAULT: dict = {
- "initialized": False,
- "sns_topic_arn": "",
- "aws_region": "us-east-1",
- "sqs_queue_url": "",
- "sqs_queue_arn": "",
- "eventbridge_rule_arns": {},
- "permissions": {
- "cloudwatch": None,
- "cloudtrail": None,
- "ecs": None,
- "lambda": None,
- "ec2": None,
- "rds": None,
- "iam": None,
- "sns": None,
- "sqs": None,
- "events": None,
- },
- "skipped_services": [],
-}
+_INIT_KEY = "init"
+_CACHE: dict[str, Any] | None = None
+
+
+def _clone(data: dict) -> dict:
+ import json
+ return json.loads(json.dumps(data))
+
+
+def _default() -> dict:
+ return {
+ "initialized": False,
+ "setup_complete": False,
+ "event_infra_enabled": False,
+ "event_infra_managed": False,
+ "sns_topic_arn": "",
+ "aws_region": settings.aws_region,
+ "sqs_queue_url": "",
+ "sqs_queue_arn": "",
+ "eventbridge_rule_arns": {},
+ "permissions": {
+ "cloudwatch": None,
+ "cloudtrail": None,
+ "ecs": None,
+ "lambda": None,
+ "ec2": None,
+ "rds": None,
+ "iam": None,
+ "sns": None,
+ "sqs": None,
+ "events": None,
+ },
+ "skipped_services": [],
+ }
+
+def _with_defaults(data: dict) -> dict:
+ merged = _default()
+ permissions = {**merged["permissions"], **data.get("permissions", {})}
+ merged.update(data)
+ merged["permissions"] = permissions
+
+ legacy_initialized = bool(data.get("initialized", False))
+ merged["setup_complete"] = bool(data.get("setup_complete", legacy_initialized))
+ merged["event_infra_enabled"] = bool(
+ data.get(
+ "event_infra_enabled",
+ legacy_initialized
+ and bool(data.get("sqs_queue_url") or data.get("eventbridge_rule_arns")),
+ )
+ )
+ merged["event_infra_managed"] = bool(
+ data.get(
+ "event_infra_managed",
+ bool(data.get("eventbridge_rule_arns") or data.get("sqs_queue_arn")),
+ )
+ )
+ merged["initialized"] = merged["setup_complete"]
+ return merged
+
+
+# ── Sync API (safe to call from boto3 thread executors) ───────────────────────
def load_init() -> dict:
- if not _INIT_FILE.exists():
- return json.loads(json.dumps(_DEFAULT))
+ """Return a copy of the in-memory cache. Always fast — no I/O."""
+ global _CACHE
+ if _CACHE is None:
+ _CACHE = _default()
+ return _clone(_CACHE)
+
+
+def save_init(data: dict) -> None:
+ """Update the in-memory cache synchronously. DB persist happens via save_init_async."""
+ global _CACHE
+ _CACHE = _with_defaults(data)
+
+
+# ── Async API (used by FastAPI route handlers) ────────────────────────────────
+
+async def load_init_async() -> dict:
+ """Load from DB into cache if not yet loaded, then return a copy."""
+ global _CACHE
try:
- return json.loads(_INIT_FILE.read_text(encoding="utf-8"))
+ from agent.db import db
+
+ data = await db.get_app_config(_INIT_KEY)
+ if data is not None:
+ _CACHE = _with_defaults(data)
+ return _clone(_CACHE)
+ # First call with no DB record — seed DB from current cache
+ if _CACHE is None:
+ _CACHE = _default()
+ await db.set_app_config(_INIT_KEY, _CACHE)
except Exception as e:
- logger.warning("Failed to read init.json, returning defaults: {}", e)
- return json.loads(json.dumps(_DEFAULT))
+ logger.debug("DB app_config unavailable, using in-memory cache: {}", e)
+ if _CACHE is None:
+ _CACHE = _default()
+ return _clone(_CACHE)
-def save_init(data: dict) -> None:
- _INIT_FILE.parent.mkdir(parents=True, exist_ok=True)
- _INIT_FILE.write_text(json.dumps(data, indent=2), encoding="utf-8")
- logger.debug("Saved init.json")
+async def save_init_async(data: dict) -> dict:
+ """Update cache and persist to DB."""
+ global _CACHE
+ _CACHE = _with_defaults(data)
+ try:
+ from agent.db import db
+
+ await db.set_app_config(_INIT_KEY, _CACHE)
+ except Exception as e:
+ logger.warning("Failed to persist init config to DB: {}", e)
+ return _clone(_CACHE)
+
+
+async def refresh_init_cache_from_db() -> dict:
+ """Called on startup to warm the cache from DB before any requests arrive."""
+ return await load_init_async()
+
+
+async def reset_init_async() -> dict:
+ """Reset to defaults in cache and DB. Does not touch AWS resources."""
+ return await save_init_async(_default())
+# ── Accessors (read from cache — no I/O) ─────────────────────────────────────
+
def is_initialized() -> bool:
- return load_init().get("initialized", False)
+ return load_init().get("setup_complete", False)
+
+
+def is_event_infra_enabled() -> bool:
+ data = load_init()
+ return bool(data.get("event_infra_enabled") and data.get("sqs_queue_url"))
+
+
+def get_runtime_aws_region() -> str:
+ return str(load_init().get("aws_region") or settings.aws_region)
+
+
+def get_runtime_sqs_queue_url() -> str:
+ return str(settings.sqs_queue_url or load_init().get("sqs_queue_url") or "")
+
+
+def get_runtime_sns_topic_arn() -> str:
+ return str(settings.sns_topic_arn or load_init().get("sns_topic_arn") or "")
diff --git a/src/agent/monitor_store.py b/src/agent/monitor_store.py
index 30cd76e..09d934a 100644
--- a/src/agent/monitor_store.py
+++ b/src/agent/monitor_store.py
@@ -2,7 +2,6 @@
from __future__ import annotations
-import asyncio
from datetime import UTC, datetime
from loguru import logger
@@ -11,21 +10,36 @@
_services: dict[str, dict] = {}
-def add_alert(service: str, error: str, resolution: str, confidence: str, sns_sent: bool) -> None:
- """Persist an alert to the DB backend. Called from async context."""
+async def add_alert(
+ service: str,
+ error: str,
+ resolution: str,
+ confidence: str,
+ sns_sent: bool,
+ dedup_key: str | None = None,
+ status: str = "completed",
+ session_id: str | None = None,
+) -> str:
+ """Persist an alert to the DB backend."""
from agent.db import db
- async def _save() -> None:
- await db.add_alert(service, error, resolution, confidence, sns_sent)
-
try:
- loop = asyncio.get_event_loop()
- if loop.is_running():
- asyncio.ensure_future(_save())
- else:
- loop.run_until_complete(_save())
+ return await db.add_alert(
+ service, error, resolution, confidence, sns_sent, dedup_key, status, session_id
+ )
except Exception as e:
logger.error("Failed to persist alert: {}", e)
+ return ""
+
+
+async def is_recent_alert(dedup_key: str, within_minutes: int = 3) -> bool:
+ """Return True if an alert with this dedup_key was saved within the last N minutes."""
+ from agent.db import db
+
+ try:
+ return await db.is_recent_alert(dedup_key, within_minutes)
+ except Exception:
+ return False
def update_service(name: str, status: str, error: str | None = None) -> None:
@@ -39,11 +53,13 @@ def update_service(name: str, status: str, error: str | None = None) -> None:
async def get_alerts_async(limit: int = 50) -> list[dict]:
from agent.db import db
+
return await db.get_alerts(limit)
async def get_alert_async(alert_id: str) -> dict | None:
from agent.db import db
+
return await db.get_alert(alert_id)
diff --git a/src/agent/permission_checker.py b/src/agent/permission_checker.py
index b18b4c3..e5c85c7 100644
--- a/src/agent/permission_checker.py
+++ b/src/agent/permission_checker.py
@@ -20,10 +20,10 @@ def _check(fn) -> dict:
return {"passed": False, "error": str(e)}
-def check_permissions(sns_topic_arn: str = "") -> dict[str, dict]:
+def check_permissions(sns_topic_arn: str = "", region: str | None = None) -> dict[str, dict]:
"""Run one lightweight read call per service. Returns {service: {passed, error}}."""
s = _session()
- region = settings.aws_region
+ region = region or settings.aws_region
results: dict[str, dict] = {}
diff --git a/src/agent/poller.py b/src/agent/poller.py
index e428af4..9ca0362 100644
--- a/src/agent/poller.py
+++ b/src/agent/poller.py
@@ -1,6 +1,6 @@
"""Proactive anomaly poller — runs as a background asyncio task.
-Every POLL_INTERVAL_MINUTES it:
+Every POLL_INTERVAL_SECONDS it:
1. Fetches CloudWatch alarms in ALARM state.
2. Checks Lambda error rates against POLL_ERROR_THRESHOLD.
3. For any *new* anomaly (not investigated within POLL_REINVESTIGATE_HOURS),
@@ -33,54 +33,159 @@ def _mark_investigated(key: str) -> None:
_last_investigated[key] = datetime.now(UTC)
-async def _run_investigation(prompt: str, trigger_key: str) -> dict[str, Any] | None:
- """Invoke the agent synchronously in a thread and return the submit_investigation args."""
- import uuid
+async def _run_investigation(
+ prompt: str, session_id: str
+) -> tuple[dict[str, Any] | None, list[dict[str, Any]]]:
+ """Run a full agent investigation using the same streaming path as the chat endpoint."""
+ import json
from agent.core import get_agent
+ from agent.turns import save_turn
+
+ def _f(obj: Any, key: str, default: Any = None) -> Any:
+ return obj.get(key, default) if isinstance(obj, dict) else getattr(obj, key, default)
- thread_id = str(uuid.uuid4())
config = {
- "configurable": {"thread_id": thread_id},
+ "configurable": {"thread_id": session_id},
"recursion_limit": settings.max_tool_calls * 3 + 15,
}
+ tc_accum: dict[int, dict[str, Any]] = {}
+ pending_calls: dict[str, dict[str, Any]] = {}
+ tool_calls_log: list[dict[str, Any]] = []
+ investigation_result: dict[str, Any] | None = None
+ response_text = ""
+ usage_meta: Any = None
+
try:
- agent = get_agent()
- result = await agent.ainvoke(
+ async for chunk, _meta in get_agent().astream(
{"messages": [{"role": "user", "content": prompt}]},
config=config,
- )
- # Extract submit_investigation args from tool calls in the message history
- for msg in reversed(result.get("messages", [])):
- for tc in getattr(msg, "tool_calls", []) or []:
- name = tc.get("name") if isinstance(tc, dict) else getattr(tc, "name", None)
- args = tc.get("args") if isinstance(tc, dict) else getattr(tc, "args", {})
- if name == "submit_investigation":
- return args
+ stream_mode="messages",
+ ):
+ um = getattr(chunk, "usage_metadata", None)
+ if um:
+ usage_meta = um
+
+ for tcc in getattr(chunk, "tool_call_chunks", []) or []:
+ idx = _f(tcc, "index", 0)
+ if idx not in tc_accum:
+ tc_accum[idx] = {"id": "", "name": "", "args_str": ""}
+ if tc_id := _f(tcc, "id"):
+ tc_accum[idx]["id"] = tc_id
+ if name := (_f(tcc, "name") or ""):
+ tc_accum[idx]["name"] += name
+ if args := (_f(tcc, "args") or ""):
+ tc_accum[idx]["args_str"] += args
+
+ for tc in getattr(chunk, "tool_calls", []) or []:
+ tc_id = _f(tc, "id") or ""
+ name = _f(tc, "name") or ""
+ args = _f(tc, "args") or {}
+ if tc_id and name:
+ pending_calls[tc_id] = {
+ "tool": name, "args": args if isinstance(args, dict) else {}
+ }
+
+ content = getattr(chunk, "content", "")
+ tc_id = getattr(chunk, "tool_call_id", None)
+
+ if content and isinstance(content, str) and not tc_id:
+ response_text += content
+
+ if tc_id:
+ for entry in tc_accum.values():
+ if eid := entry["id"]:
+ try:
+ eargs: Any = json.loads(entry["args_str"]) if entry["args_str"] else {}
+ except json.JSONDecodeError:
+ eargs = {}
+ pending_calls[eid] = {"tool": entry["name"], "args": eargs}
+ tc_accum.clear()
+
+ call_info = pending_calls.pop(
+ tc_id,
+ {"tool": getattr(chunk, "name", None) or "unknown", "args": {}},
+ )
+ try:
+ result = json.loads(content)
+ except (json.JSONDecodeError, TypeError):
+ result = {"raw": str(content)[:500]}
+
+ tool_calls_log.append({
+ "tool": call_info["tool"], "args": call_info["args"], "result": result
+ })
+ if call_info["tool"] == "submit_investigation":
+ investigation_result = call_info["args"]
+
except Exception as e:
- logger.error("Poller investigation failed for {}: {}", trigger_key, e)
- return None
+ logger.error("Poller investigation failed: {}", e)
+ investigation_result = {
+ "_status": "failed",
+ "root_cause_summary": f"Investigation failed: {e}",
+ "confidence": "LOW",
+ "mitigation_steps": [
+ "Re-investigate manually via the chat — use a higher MAX_TOOL_CALLS if needed.",
+ ],
+ }
+
+ if (investigation_result and investigation_result.get("root_cause_summary")
+ and "_status" not in investigation_result):
+ steps = investigation_result.get("mitigation_steps", [])
+ steps_text = "\n".join(f"- {s}" for s in steps) if steps else "_No steps provided._"
+ confidence = investigation_result.get("confidence", "MEDIUM")
+ assistant_text = (
+ f"**Root Cause ({confidence} confidence):** "
+ f"{investigation_result['root_cause_summary']}\n\n"
+ f"**Mitigation Steps:**\n{steps_text}"
+ )
+ elif response_text.strip():
+ assistant_text = response_text.strip()
+ else:
+ assistant_text = "Investigation completed. See tool calls for details."
+
+ await save_turn(
+ session_id=session_id,
+ user_message=prompt,
+ assistant_text=assistant_text,
+ tool_calls_log=tool_calls_log,
+ usage={
+ "model": settings.llm_model,
+ "input_tokens": _f(usage_meta, "input_tokens", 0) or 0,
+ "output_tokens": _f(usage_meta, "output_tokens", 0) or 0,
+ "latency_ms": 0,
+ },
+ source="event",
+ )
+
+ return investigation_result, tool_calls_log
-async def _persist_and_notify(prompt: str, result: dict[str, Any], session_id: str) -> None:
- from agent.monitor_store import add_alert
- from agent.turns import notify_slack, save_turn
+async def _persist_and_notify(
+ result: dict[str, Any], tool_calls_log: list[dict[str, Any]], session_id: str, dedup_key: str
+) -> None:
+ from agent.monitor_store import add_alert, update_service
+ from agent.turns import notify_slack
- tool_calls_log = [{"tool": "submit_investigation", "args": result, "result": {}}]
- usage = {"model": settings.llm_model, "latency_ms": 0}
- await save_turn(session_id, prompt, "", tool_calls_log, usage)
await notify_slack(session_id, tool_calls_log)
+ status = result.pop("_status", "completed")
services_affected = result.get("services_affected", [])
service = services_affected[0] if services_affected else "unknown"
root_cause = result.get("root_cause_summary", "")
mitigation = result.get("mitigation_steps", [])
confidence = result.get("confidence", "MEDIUM")
resolution = "\n".join(mitigation) if isinstance(mitigation, list) else str(mitigation)
- add_alert(
- service=service, error=root_cause, resolution=resolution,
- confidence=confidence, sns_sent=False,
+ update_service(service, status, root_cause)
+ await add_alert(
+ service=service,
+ error=root_cause,
+ resolution=resolution,
+ confidence=confidence,
+ sns_sent=False,
+ dedup_key=dedup_key,
+ status=status,
+ session_id=session_id,
)
@@ -88,17 +193,22 @@ async def _check_alarms() -> None:
"""Check CloudWatch alarms — each new ALARM state triggers an investigation."""
import uuid
+ from agent.monitor_store import is_recent_alert
from tools.cloudwatch import get_alarms
data = await asyncio.get_event_loop().run_in_executor(None, get_alarms, "ALARM")
for alarm in data.get("alarms", []):
- name = alarm.get("name", "unknown")
- reason = alarm.get("reason", "")
- metric = alarm.get("metric", "")
- key = f"alarm:{name}"
+ name = alarm.get("name", "unknown")
+ reason = alarm.get("reason", "")
+ metric = alarm.get("metric", "")
+ key = f"alarm:{name}"
if not _should_investigate(key):
continue
+ if await is_recent_alert(key):
+ logger.debug("Poller: skipping alarm {} — already investigated recently", name)
+ _mark_investigated(key)
+ continue
logger.info("Poller: new ALARM state detected — {}", name)
_mark_investigated(key)
@@ -110,16 +220,17 @@ async def _check_alarms() -> None:
"Please investigate the root cause and provide mitigation steps."
)
session_id = str(uuid.uuid4())
- result = await _run_investigation(prompt, key)
+ result, tool_calls_log = await _run_investigation(prompt, session_id)
if result:
logger.info("Poller investigation complete for alarm: {}", name)
- await _persist_and_notify(prompt, result, session_id)
+ await _persist_and_notify(result, tool_calls_log, session_id, key)
async def _check_lambda_errors() -> None:
"""Check Lambda functions for error rates above the configured threshold."""
import uuid
+ from agent.monitor_store import is_recent_alert
from tools.lambda_ import get_lambda_error_rate, list_lambda_functions
funcs = await asyncio.get_event_loop().run_in_executor(None, list_lambda_functions)
@@ -129,7 +240,10 @@ async def _check_lambda_errors() -> None:
continue
metrics = await asyncio.get_event_loop().run_in_executor(
- None, get_lambda_error_rate, name, 1 # last 1 hour
+ None,
+ get_lambda_error_rate,
+ name,
+ 1, # last 1 hour
)
error_rate = metrics.get("error_rate_pct", 0) or 0
if error_rate < settings.poll_error_threshold:
@@ -138,6 +252,16 @@ async def _check_lambda_errors() -> None:
key = f"lambda_errors:{name}"
if not _should_investigate(key):
continue
+ if await is_recent_alert(key):
+ logger.debug("Poller: skipping Lambda {} — already investigated recently", name)
+ _mark_investigated(key)
+ continue
+ # The aggregate alarm covers all Lambda errors — if it fired recently, skip.
+ from agent.event_infra import ALARM_NAME
+ if await is_recent_alert(f"alarm:{ALARM_NAME}"):
+ logger.debug("Poller: skipping Lambda {} — aggregate alarm already handled", name)
+ _mark_investigated(key)
+ continue
logger.info("Poller: Lambda {} error rate {:.1f}% exceeds threshold", name, error_rate)
_mark_investigated(key)
@@ -148,18 +272,18 @@ async def _check_lambda_errors() -> None:
"Please investigate the root cause and suggest a fix."
)
session_id = str(uuid.uuid4())
- result = await _run_investigation(prompt, key)
+ result, tool_calls_log = await _run_investigation(prompt, session_id)
if result:
logger.info("Poller investigation complete for Lambda: {}", name)
- await _persist_and_notify(prompt, result, session_id)
+ await _persist_and_notify(result, tool_calls_log, session_id, key)
async def polling_loop() -> None:
"""Main loop — runs forever until cancelled."""
- interval = settings.poll_interval_minutes * 60
+ interval = settings.poll_interval_seconds
logger.info(
- "Poller started — interval={}min error_threshold={}% slack={}",
- settings.poll_interval_minutes,
+ "Poller started — interval={}s error_threshold={}% slack={}",
+ settings.poll_interval_seconds,
settings.poll_error_threshold,
"enabled" if settings.slack_webhook_url else "disabled",
)
@@ -167,7 +291,10 @@ async def polling_loop() -> None:
await asyncio.sleep(interval)
logger.debug("Poller tick")
try:
- await _check_alarms()
+ from agent.init_store import is_event_infra_enabled
+ if not is_event_infra_enabled():
+ # EventBridge → SQS already covers alarm state changes when infra is active
+ await _check_alarms()
await _check_lambda_errors()
except Exception as e:
logger.error("Poller tick failed: {}", e)
diff --git a/src/agent/turns.py b/src/agent/turns.py
index 4e212d2..f440d7f 100644
--- a/src/agent/turns.py
+++ b/src/agent/turns.py
@@ -6,26 +6,27 @@
from loguru import logger
-from config import settings
from agent.db import db
+from agent.init_store import get_runtime_aws_region
+from config import settings
# Fallback pricing ($/M tokens) for models absent from LiteLLM's database.
_FALLBACK_PRICING: dict[str, dict[str, float]] = {
- "openrouter/google/gemma-4-26b-a4b-it": {"input": 0.07, "output": 0.35},
- "openrouter/google/gemma-2-9b-it": {"input": 0.06, "output": 0.06},
- "openrouter/meta-llama/llama-3.1-8b-instruct": {"input": 0.055, "output": 0.055},
- "openrouter/mistralai/mistral-7b-instruct": {"input": 0.055, "output": 0.055},
+ "openrouter/google/gemma-4-26b-a4b-it": {"input": 0.07, "output": 0.35},
+ "openrouter/google/gemma-2-9b-it": {"input": 0.06, "output": 0.06},
+ "openrouter/meta-llama/llama-3.1-8b-instruct": {"input": 0.055, "output": 0.055},
+ "openrouter/mistralai/mistral-7b-instruct": {"input": 0.055, "output": 0.055},
}
def calc_cost(model: str, input_tok: int, output_tok: int) -> float | None:
try:
import litellm
+
info = litellm.model_cost.get(model)
if info:
- return (
- input_tok * info.get("input_cost_per_token", 0)
- + output_tok * info.get("output_cost_per_token", 0)
+ return input_tok * info.get("input_cost_per_token", 0) + output_tok * info.get(
+ "output_cost_per_token", 0
)
fallback = _FALLBACK_PRICING.get(model)
if fallback:
@@ -41,22 +42,30 @@ async def save_turn(
assistant_text: str,
tool_calls_log: list[dict[str, Any]],
usage: dict[str, Any],
+ source: str = "chat",
) -> None:
"""Persist a completed turn to Postgres. Errors are logged, never raised."""
try:
title = user_message[:80] if user_message else None
- await db.upsert_session(session_id, usage.get("model", ""), settings.aws_region, title)
+ await db.upsert_session(
+ session_id, usage.get("model", ""), get_runtime_aws_region(), title, source
+ )
- user_msg_id = await db.save_message(session_id, "user", user_message)
+ await db.save_message(session_id, "user", user_message)
asst_msg_id = await db.save_message(
- session_id, "assistant", assistant_text,
+ session_id,
+ "assistant",
+ assistant_text,
metadata={"model": usage.get("model"), "latency_ms": usage.get("latency_ms")},
)
for tc in tool_calls_log:
await db.save_tool_call(
- session_id, asst_msg_id,
- tc["tool"], tc["args"], tc["result"],
+ session_id,
+ asst_msg_id,
+ tc["tool"],
+ tc["args"],
+ tc["result"],
)
cost = calc_cost(
@@ -65,7 +74,8 @@ async def save_turn(
usage.get("output_tokens", 0),
)
await db.save_usage_event(
- session_id, asst_msg_id,
+ session_id,
+ asst_msg_id,
model=usage.get("model", ""),
input_tokens=usage.get("input_tokens", 0),
output_tokens=usage.get("output_tokens", 0),
@@ -84,5 +94,6 @@ async def notify_slack(session_id: str, tool_calls_log: list[dict[str, Any]]) ->
for tc in tool_calls_log:
if tc.get("tool") == "submit_investigation":
from integrations.slack_webhook import post_investigation
+
await post_investigation(settings.slack_webhook_url, tc["args"], session_id)
return
diff --git a/src/api/app.py b/src/api/app.py
index 662842f..b39ebc5 100644
--- a/src/api/app.py
+++ b/src/api/app.py
@@ -13,10 +13,19 @@
# Suppress LiteLLM's "Provider List" info spam — we don't need provider discovery hints in logs
litellm.suppress_debug_info = True
-from agent.core import init_agent
-from agent.db import db
-from api.routers import auth, chat, dashboard, history, sessions, settings, users
-from api.routers import init as init_router, monitoring
+from agent.core import init_agent # noqa: E402
+from agent.db import db # noqa: E402
+from api.routers import ( # noqa: E402
+ auth,
+ chat,
+ dashboard,
+ history,
+ monitoring,
+ sessions,
+ settings,
+ users,
+)
+from api.routers import init as init_router # noqa: E402
class _InterceptHandler(logging.Handler):
@@ -42,36 +51,61 @@ def emit(self, record: logging.LogRecord) -> None:
def start_event_consumer(app_instance: "FastAPI | None" = None) -> None:
"""Start the SQS event consumer as a background task (idempotent)."""
import asyncio
+
target = app_instance or app
existing: asyncio.Task | None = getattr(target, "_consumer_task", None)
if existing and not existing.done():
return
from agent.event_consumer import event_consumer_loop
+
target._consumer_task = asyncio.create_task(event_consumer_loop()) # type: ignore[attr-defined]
logger.info("Event consumer task started")
+async def stop_event_consumer(app_instance: "FastAPI | None" = None) -> None:
+ """Stop the SQS event consumer if it is running."""
+ import asyncio
+
+ target = app_instance or app
+ existing: asyncio.Task | None = getattr(target, "_consumer_task", None)
+ if not existing or existing.done():
+ return
+ existing.cancel()
+ try:
+ await existing
+ except asyncio.CancelledError:
+ pass
+ target._consumer_task = None # type: ignore[attr-defined]
+ logger.info("Event consumer task stopped")
+
+
@asynccontextmanager
async def lifespan(_app: FastAPI):
import asyncio
+
from config import settings as _cfg
checkpointer = await db.init()
+ from agent.init_store import refresh_init_cache_from_db
+
+ await refresh_init_cache_from_db()
init_agent(checkpointer)
poller_task = None
- if _cfg.poll_interval_minutes > 0:
+ if _cfg.poll_interval_seconds > 0:
from agent.poller import polling_loop
+
poller_task = asyncio.create_task(polling_loop())
- logger.info("Proactive poller started (interval={}min)", _cfg.poll_interval_minutes)
+ logger.info("Proactive poller started (interval={}s)", _cfg.poll_interval_seconds)
# Event consumer — started if explicitly enabled, SQS URL is set, or init wizard completed
if _cfg.event_consumer_enabled or _cfg.sqs_queue_url:
start_event_consumer(_app)
else:
try:
- from agent.init_store import is_initialized
- if is_initialized():
+ from agent.init_store import is_event_infra_enabled
+
+ if is_event_infra_enabled():
start_event_consumer(_app)
except Exception:
pass
@@ -87,16 +121,20 @@ async def lifespan(_app: FastAPI):
consumer_task: asyncio.Task | None = getattr(_app, "_consumer_task", None)
if consumer_task:
- consumer_task.cancel()
- try:
- await consumer_task
- except asyncio.CancelledError:
- pass
+ await stop_event_consumer(_app)
await db.close()
-app = FastAPI(title="OpenDevOps Agent", version="0.1.0", lifespan=lifespan)
+app = FastAPI(
+ title="OpenDevOps Agent",
+ version="0.1.0",
+ lifespan=lifespan,
+ swagger_ui_parameters={"persistAuthorization": True},
+ openapi_tags=[],
+)
+
+
app.include_router(chat.router)
app.include_router(sessions.router)
@@ -120,7 +158,8 @@ def _serve_index() -> HTMLResponse:
if not path.exists():
return HTMLResponse(
""
- "Frontend not built yet. Run: cd frontend && npm install && npm run build"
+ "Frontend not built yet. Run: cd frontend && "
+ "npm install && npm run build"
"
",
status_code=503,
)
diff --git a/src/api/auth.py b/src/api/auth.py
index c00ee73..50004ce 100644
--- a/src/api/auth.py
+++ b/src/api/auth.py
@@ -2,16 +2,16 @@
from __future__ import annotations
-from datetime import datetime, timedelta, timezone
+from datetime import UTC, datetime, timedelta
from typing import Annotated
import bcrypt as _bcrypt
from fastapi import Depends, HTTPException
-from fastapi.security import OAuth2PasswordBearer
+from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from config import settings
-_oauth2 = OAuth2PasswordBearer(tokenUrl="/auth/login", auto_error=False)
+_bearer = HTTPBearer(auto_error=False)
def hash_password(password: str) -> str:
@@ -25,7 +25,7 @@ def verify_password(plain: str, hashed: str) -> bool:
def create_access_token(user_id: str, role: str) -> str:
from jose import jwt
- exp = datetime.now(timezone.utc) + timedelta(minutes=settings.jwt_expire_minutes)
+ exp = datetime.now(UTC) + timedelta(minutes=settings.jwt_expire_minutes)
return jwt.encode(
{"sub": user_id, "role": role, "exp": exp},
settings.jwt_secret,
@@ -34,15 +34,16 @@ def create_access_token(user_id: str, role: str) -> str:
async def get_current_user(
- token: Annotated[str | None, Depends(_oauth2)],
+ credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(_bearer)],
) -> dict | None:
"""Return {id, role} when auth is enabled + token is valid; None in dev mode."""
if not settings.jwt_secret:
return None
+ token = credentials.credentials if credentials else None
if token is None:
raise HTTPException(status_code=401, detail="Not authenticated")
try:
- from jose import JWTError, jwt
+ from jose import jwt
payload = jwt.decode(token, settings.jwt_secret, algorithms=["HS256"])
user_id: str | None = payload.get("sub")
diff --git a/src/api/routers/auth.py b/src/api/routers/auth.py
index 646e0f9..7da5c0e 100644
--- a/src/api/routers/auth.py
+++ b/src/api/routers/auth.py
@@ -41,7 +41,9 @@ async def register(req: RegisterRequest) -> TokenResponse:
raise HTTPException(status_code=400, detail="Email already registered")
is_first = (await db.count_users()) == 0
role = "admin" if is_first else "user"
- user = await db.create_user(req.email, req.name, hash_password(req.password), role)
+ org = await db.get_first_org()
+ org_id = org["id"] if org else None
+ user = await db.create_user(req.email, req.name, hash_password(req.password), role, org_id)
if not user:
raise HTTPException(
status_code=501,
diff --git a/src/api/routers/init.py b/src/api/routers/init.py
index 445583c..58226b2 100644
--- a/src/api/routers/init.py
+++ b/src/api/routers/init.py
@@ -5,150 +5,257 @@
import asyncio
import datetime
import json
+from typing import Annotated
import boto3
-from fastapi import APIRouter, HTTPException
+from fastapi import APIRouter, Depends, HTTPException
from loguru import logger
-from pydantic import BaseModel
+from pydantic import BaseModel, EmailStr, Field
from agent.db import db
-from agent.init_store import load_init, save_init
-from api.auth import hash_password
+from agent.init_store import (
+ get_runtime_aws_region,
+ get_runtime_sqs_queue_url,
+ load_init_async,
+ save_init_async,
+)
+from api.auth import hash_password, require_admin
from config import settings
router = APIRouter(prefix="/api/init", tags=["init"])
class CreateUserBody(BaseModel):
- username: str
- password: str
+ username: EmailStr
+ password: str = Field(min_length=8)
class SetupBody(BaseModel):
sns_topic_arn: str = ""
aws_region: str = "us-east-1"
sqs_queue_url: str = ""
+ org_name: str = ""
class SkipBody(BaseModel):
services: list[str]
+@router.post("/reset")
+async def reset_init_state(
+ _: Annotated[dict | None, Depends(require_admin)],
+):
+ """Clear persisted init/setup state. Useful for re-running the wizard without touching AWS."""
+ from agent.init_store import reset_init_async
+
+ await reset_init_async()
+ return {"reset": True}
+
+
@router.get("/status")
async def status():
- data = load_init()
- data["has_user"] = (await db.count_users()) > 0
+ data = await load_init_async()
+ has_user = (await db.count_users()) > 0
+ data["has_user"] = has_user
+ data["auth_enabled"] = bool(settings.jwt_secret)
+ data["needs_setup"] = not bool(data.get("setup_complete"))
+ data["initialized"] = bool(data.get("setup_complete"))
+ data["event_infra_enabled"] = bool(get_runtime_sqs_queue_url()) and bool(
+ settings.sqs_queue_url or data.get("event_infra_enabled")
+ )
return data
@router.post("/create-user")
async def create_user(body: CreateUserBody):
- existing = await db.get_user_by_email(body.username)
+ username = str(body.username)
+ user_count = await db.count_users()
+ if user_count > 0:
+ raise HTTPException(status_code=409, detail="Initial admin account already exists")
+ existing = await db.get_user_by_email(username)
if existing:
- return {"error": "User already exists"}
+ raise HTTPException(status_code=409, detail="User already exists")
+
user = await db.create_user(
- email=body.username,
- name=body.username,
+ email=username,
+ name=username,
password_hash=hash_password(body.password),
role="admin",
)
- logger.info("Init: admin user '{}' created", body.username)
+ logger.info("Init: admin user '{}' created", username)
if user:
- return {"id": user.get("id"), "username": body.username}
- return {"error": "Failed to create user"}
+ return {"id": user.get("id"), "username": username}
+ raise HTTPException(status_code=500, detail="Failed to create user")
@router.post("/setup")
-async def setup(body: SetupBody):
- data = load_init()
- data["sns_topic_arn"] = body.sns_topic_arn
- data["aws_region"] = body.aws_region
- if body.sqs_queue_url:
- data["sqs_queue_url"] = body.sqs_queue_url
- save_init(data)
+async def setup(
+ body: SetupBody,
+ _: Annotated[dict | None, Depends(require_admin)],
+):
+ import re
+
+ data = await load_init_async()
+ data["sns_topic_arn"] = body.sns_topic_arn.strip()
+ data["aws_region"] = body.aws_region.strip() or settings.aws_region
+ data["sqs_queue_url"] = body.sqs_queue_url.strip()
+ data["event_infra_enabled"] = bool(data["sqs_queue_url"])
+ data["event_infra_managed"] = False
+ if data["sqs_queue_url"]:
+ data["sqs_queue_arn"] = ""
+ data["eventbridge_rule_arns"] = {}
+
+ # Create org if name provided and none exists yet
+ if body.org_name.strip():
+ existing_org = await db.get_first_org()
+ if not existing_org:
+ slug = re.sub(r"[^a-z0-9]+", "-", body.org_name.strip().lower()).strip("-") or "org"
+ org = await db.create_org(body.org_name.strip(), slug)
+ if org:
+ logger.info("Init: organization '{}' created", body.org_name)
+ await db.assign_org_to_users_without_org(org["id"])
+
+ data = await save_init_async(data)
logger.info("Init setup saved")
return data
@router.post("/check-permissions")
-async def check_permissions_endpoint():
+async def check_permissions_endpoint(
+ _: Annotated[dict | None, Depends(require_admin)],
+):
from agent.permission_checker import check_permissions as _check
- data = load_init()
+ data = await load_init_async()
results = await asyncio.get_event_loop().run_in_executor(
- None, _check, data.get("sns_topic_arn", "")
+ None, _check, data.get("sns_topic_arn", ""), data.get("aws_region")
)
data["permissions"] = {svc: r["passed"] for svc, r in results.items()}
- save_init(data)
+ await save_init_async(data)
return {"permissions": results}
@router.post("/skip-services")
-async def skip_services(body: SkipBody):
- data = load_init()
+async def skip_services(
+ body: SkipBody,
+ _: Annotated[dict | None, Depends(require_admin)],
+):
+ data = await load_init_async()
data["skipped_services"] = body.services
- save_init(data)
+ data["setup_complete"] = True
+ data["initialized"] = True
+ await save_init_async(data)
return {"skipped_services": body.services}
@router.post("/complete")
-async def complete():
- data = load_init()
+async def complete(
+ _: Annotated[dict | None, Depends(require_admin)],
+):
+ data = await load_init_async()
+ region = data.get("aws_region") or settings.aws_region
try:
from agent.event_infra import setup_event_infra
- result = await asyncio.get_event_loop().run_in_executor(None, setup_event_infra)
+
+ result = await asyncio.get_event_loop().run_in_executor(None, setup_event_infra, region)
data["sqs_queue_url"] = result["queue_url"]
data["sqs_queue_arn"] = result["queue_arn"]
data["eventbridge_rule_arns"] = result["rule_arns"]
+ data["event_infra_enabled"] = True
+ data["event_infra_managed"] = True
logger.info("Init: event infra created")
except Exception as e:
logger.error("Init: event infra setup failed: {}", e)
- return {"initialized": False, "error": str(e)}
+ raise HTTPException(status_code=500, detail=str(e))
+ data["setup_complete"] = True
data["initialized"] = True
- save_init(data)
+ await save_init_async(data)
logger.info("Init complete")
# Hot-start the event consumer without requiring a server restart
try:
- from api.app import start_event_consumer
+ from api.app import start_event_consumer, stop_event_consumer
+
+ await stop_event_consumer()
start_event_consumer()
except Exception as e:
logger.warning("Could not hot-start event consumer (will start on next restart): {}", e)
- return {"initialized": True, "error": None}
+ return {"initialized": True, "event_infra_enabled": True, "error": None}
@router.delete("/infra")
-async def teardown_infra():
+async def teardown_infra(
+ _: Annotated[dict | None, Depends(require_admin)],
+):
"""Remove SQS queue and EventBridge rules created by /complete."""
- data = load_init()
- queue_url = data.get("sqs_queue_url") or settings.sqs_queue_url
+ data = await load_init_async()
+ queue_url = data.get("sqs_queue_url") or ""
rule_arns = data.get("eventbridge_rule_arns") or {}
+ region = data.get("aws_region") or settings.aws_region
+ managed = bool(data.get("event_infra_managed"))
- if not queue_url:
+ if settings.sqs_queue_url and not managed:
raise HTTPException(
status_code=400,
- detail="No SQS queue URL found — infrastructure may not be set up",
+ detail=(
+ "Event monitoring is configured by SQS_QUEUE_URL. Remove that environment "
+ "variable and restart to disable it from the app."
+ ),
)
+ if queue_url and not managed:
+ data["event_infra_enabled"] = False
+ data["sqs_queue_url"] = ""
+ await save_init_async(data)
+ try:
+ from api.app import stop_event_consumer
+
+ await stop_event_consumer()
+ except Exception as e:
+ logger.warning("Could not stop event consumer after disabling external queue: {}", e)
+ return {
+ "torn_down": True,
+ "warnings": ["External SQS queue was disconnected but not deleted"],
+ }
+
+ if not queue_url and not rule_arns:
+ data["event_infra_enabled"] = False
+ data["event_infra_managed"] = False
+ await save_init_async(data)
+ return {"torn_down": True, "warnings": ["No event infrastructure was configured"]}
+
try:
from agent.event_infra import teardown_event_infra
- await asyncio.get_event_loop().run_in_executor(
- None, teardown_event_infra, queue_url, rule_arns
+
+ result = await asyncio.get_event_loop().run_in_executor(
+ None, teardown_event_infra, queue_url, rule_arns, region
)
except Exception as e:
logger.error("Teardown failed: {}", e)
raise HTTPException(status_code=500, detail=str(e))
- data["initialized"] = False
+ if result.get("errors"):
+ raise HTTPException(status_code=500, detail=result)
+
+ data["event_infra_enabled"] = False
data["sqs_queue_url"] = ""
data["sqs_queue_arn"] = ""
data["eventbridge_rule_arns"] = {}
- save_init(data)
+ data["event_infra_managed"] = False
+ await save_init_async(data)
+
+ try:
+ from api.app import stop_event_consumer
+
+ await stop_event_consumer()
+ except Exception as e:
+ logger.warning("Could not stop event consumer after teardown: {}", e)
+
logger.info("Init: event infra torn down")
- return {"torn_down": True}
+ return {"torn_down": True, "warnings": result.get("warnings", [])}
class TestEventBody(BaseModel):
@@ -156,10 +263,16 @@ class TestEventBody(BaseModel):
@router.post("/test-event")
-async def send_test_event(body: TestEventBody):
+async def send_test_event(
+ body: TestEventBody,
+ _: Annotated[dict | None, Depends(require_admin)],
+):
"""Put a synthetic event into the SQS queue to verify the pipeline end-to-end."""
- data = load_init()
- queue_url = data.get("sqs_queue_url") or settings.sqs_queue_url
+ if body.service != "lambda":
+ raise HTTPException(status_code=400, detail="Only lambda test events are supported")
+
+ await load_init_async()
+ queue_url = get_runtime_sqs_queue_url()
if not queue_url:
raise HTTPException(
status_code=400,
@@ -171,6 +284,7 @@ async def send_test_event(body: TestEventBody):
now = datetime.datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ")
event: dict = {
+ "_opendevops_test": True,
"source": "aws.cloudwatch",
"detail-type": "CloudWatch Alarm State Change",
"time": now,
@@ -184,17 +298,19 @@ async def send_test_event(body: TestEventBody):
),
},
"configuration": {
- "metrics": [{
- "metricStat": {
- "metric": {
- "namespace": "AWS/Lambda",
- "name": "Errors",
- "dimensions": {},
- },
- "period": 300,
- "stat": "Sum",
+ "metrics": [
+ {
+ "metricStat": {
+ "metric": {
+ "namespace": "AWS/Lambda",
+ "name": "Errors",
+ "dimensions": {},
+ },
+ "period": 300,
+ "stat": "Sum",
+ }
}
- }]
+ ]
},
},
}
@@ -205,7 +321,7 @@ async def send_test_event(body: TestEventBody):
if settings.aws_profile
else boto3.Session()
)
- sqs = s.client("sqs", region_name=settings.aws_region)
+ sqs = s.client("sqs", region_name=get_runtime_aws_region())
resp = await asyncio.get_event_loop().run_in_executor(
None,
lambda: sqs.send_message(QueueUrl=queue_url, MessageBody=json.dumps(event)),
diff --git a/src/api/routers/settings.py b/src/api/routers/settings.py
index 9f0138a..c3ecbaf 100644
--- a/src/api/routers/settings.py
+++ b/src/api/routers/settings.py
@@ -8,6 +8,7 @@
from fastapi import APIRouter, Depends
+from agent.init_store import get_runtime_aws_region
from api.auth import get_current_user
from config import settings
@@ -43,12 +44,13 @@ async def get_settings(
]
agent = [
+ {"key": "EFFECTIVE_AWS_REGION", "label": "Effective AWS region", "value": get_runtime_aws_region(), "hint": "Wizard setting, falling back to AWS_REGION"},
{"key": "MAX_TOOL_CALLS", "label": "Max tool calls", "value": str(settings.max_tool_calls), "hint": "Hard cap per investigation run"},
{"key": "INVESTIGATION_TIMEOUT", "label": "Timeout (s)", "value": str(settings.investigation_timeout), "hint": "Max seconds before run is cancelled"},
{"key": "TOOL_RESPONSE_MAX_CHARS", "label": "Tool response cap", "value": str(settings.tool_response_max_chars), "hint": "Chars before tool output is truncated"},
{"key": "SUMMARIZATION_ENABLED", "label": "Auto-summarize", "value": str(settings.summarization_enabled).lower(),"hint": "Compact sessions that exceed the threshold"},
{"key": "SUMMARIZATION_THRESHOLD_CHARS", "label": "Summarize threshold", "value": str(settings.summarization_threshold_chars),"hint": "Total chars in session before compaction fires"},
- {"key": "POLL_INTERVAL_MINUTES", "label": "Poll interval (min)", "value": str(settings.poll_interval_minutes), "hint": "0 = proactive polling disabled"},
+ {"key": "POLL_INTERVAL_SECONDS", "label": "Poll interval (s)", "value": str(settings.poll_interval_seconds), "hint": "0 = proactive polling disabled"},
{"key": "EVENT_CONSUMER_ENABLED", "label": "Event consumer", "value": str(settings.event_consumer_enabled).lower(), "hint": "EventBridge→SQS incident detection"},
]
diff --git a/src/api/routers/users.py b/src/api/routers/users.py
index 8414ab6..49d0017 100644
--- a/src/api/routers/users.py
+++ b/src/api/routers/users.py
@@ -27,7 +27,9 @@ async def create_user(
) -> UserOut:
if await db.get_user_by_email(req.email):
raise HTTPException(status_code=400, detail="Email already registered")
- user = await db.create_user(req.email, req.name, hash_password(req.password), req.role)
+ org = await db.get_first_org()
+ org_id = org["id"] if org else None
+ user = await db.create_user(req.email, req.name, hash_password(req.password), req.role, org_id)
if not user:
raise HTTPException(status_code=500, detail="Failed to create user")
return user
diff --git a/src/config/appsettings.py b/src/config/appsettings.py
index e02e15c..97e79ac 100644
--- a/src/config/appsettings.py
+++ b/src/config/appsettings.py
@@ -52,8 +52,8 @@ class Settings(BaseSettings):
# Slack — leave unset to disable notifications
slack_webhook_url: str | None = None
- # Proactive polling — set poll_interval_minutes=0 to disable
- poll_interval_minutes: int = 0 # disabled by default
+ # Proactive polling — set poll_interval_seconds=0 to disable
+ poll_interval_seconds: int = 0 # disabled by default
poll_error_threshold: float = 5.0 # % Lambda error rate that triggers investigation
poll_reinvestigate_hours: int = 1 # don't re-investigate the same alarm within N hours
diff --git a/src/integrations/slack_webhook.py b/src/integrations/slack_webhook.py
index 4ecd299..511120b 100644
--- a/src/integrations/slack_webhook.py
+++ b/src/integrations/slack_webhook.py
@@ -6,7 +6,6 @@
from loguru import logger
-
# Sidebar colour per root-cause category
_CATEGORY_COLORS: dict[str, str] = {
"SYSTEM_CHANGE": "#e67e22",
@@ -22,7 +21,12 @@
}
-def _build_payload(result: dict[str, Any], session_id: str, app_url: str | None = None) -> dict:
+def _build_payload(
+ result: dict[str, Any],
+ session_id: str,
+ app_url: str | None = None,
+ is_test: bool = False,
+) -> dict:
category = result.get("root_cause_category", "UNKNOWN")
summary = result.get("root_cause_summary", "No summary provided.")
confidence = result.get("confidence", "LOW")
@@ -31,6 +35,11 @@ def _build_payload(result: dict[str, Any], session_id: str, app_url: str | None
services = result.get("services_affected", [])
color = _CATEGORY_COLORS.get(category, "#95a5a6")
conf_emoji = _CONFIDENCE_EMOJI.get(confidence, "🔴")
+ header_text = (
+ "🧪 [TEST] OpenDevOps Agent — Investigation Complete"
+ if is_test
+ else "🔍 OpenDevOps Agent — Investigation Complete"
+ )
def _bullets(items: list[str]) -> str:
return "\n".join(f"• {i}" for i in items) if items else "_None recorded_"
@@ -38,7 +47,7 @@ def _bullets(items: list[str]) -> str:
blocks: list[dict] = [
{
"type": "header",
- "text": {"type": "plain_text", "text": "🔍 OpenDevOps Agent — Investigation Complete"},
+ "text": {"type": "plain_text", "text": header_text},
},
{"type": "divider"},
{
@@ -89,21 +98,58 @@ def _bullets(items: list[str]) -> str:
}
-async def post_investigation(
- webhook_url: str,
- result: dict[str, Any],
- session_id: str,
- app_url: str | None = None,
-) -> None:
- """Fire-and-forget: post a completed investigation result to Slack."""
+async def _post(webhook_url: str, payload: dict) -> None:
try:
import httpx
- payload = _build_payload(result, session_id, app_url)
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.post(webhook_url, json=payload)
if resp.status_code != 200:
logger.warning("Slack webhook returned {}: {}", resp.status_code, resp.text)
- else:
- logger.info("Slack notification sent for session {}", session_id[:8])
except Exception as e:
logger.error("Slack notification failed: {}", e)
+
+
+async def post_investigation(
+ webhook_url: str,
+ result: dict[str, Any],
+ session_id: str,
+ app_url: str | None = None,
+ is_test: bool = False,
+) -> None:
+ """Post a completed investigation result to Slack."""
+ payload = _build_payload(result, session_id, app_url, is_test=is_test)
+ await _post(webhook_url, payload)
+ logger.info("Slack notification sent for session {}", session_id[:8])
+
+
+async def post_failed_investigation(
+ webhook_url: str,
+ service: str,
+ error: str,
+ session_id: str,
+ aws_error: str = "",
+ is_test: bool = False,
+) -> None:
+ """Post a failed investigation alert to Slack."""
+ header_text = (
+ "🧪 [TEST] OpenDevOps Agent — Investigation Failed"
+ if is_test
+ else "⚠️ OpenDevOps Agent — Investigation Failed"
+ )
+ detail_text = f"*Service:* `{service}`"
+ if aws_error:
+ detail_text += f"\n*AWS error:* {aws_error}"
+ detail_text += f"\n*Agent error:* {error}"
+
+ blocks: list[dict] = [
+ {"type": "header", "text": {"type": "plain_text", "text": header_text}},
+ {"type": "divider"},
+ {"type": "section", "text": {"type": "mrkdwn", "text": detail_text}},
+ {
+ "type": "context",
+ "elements": [{"type": "mrkdwn", "text": f"Session `{session_id[:8]}`"}],
+ },
+ ]
+ payload = {"attachments": [{"color": "#e74c3c", "blocks": blocks}]}
+ await _post(webhook_url, payload)
+ logger.info("Slack failure notification sent for session {}", session_id[:8])
diff --git a/src/tools/_cache.py b/src/tools/_cache.py
index d42f6ce..59edcd0 100644
--- a/src/tools/_cache.py
+++ b/src/tools/_cache.py
@@ -18,6 +18,7 @@
from cachetools import TTLCache, cached
from cachetools.keys import hashkey
+from agent.init_store import get_runtime_aws_region
from config import settings
F = TypeVar("F", bound=Callable[..., Any])
@@ -41,7 +42,7 @@ def _h(v: Any) -> Any:
return hashkey(
fn_name,
settings.aws_profile,
- settings.aws_region,
+ get_runtime_aws_region(),
*[_h(a) for a in args],
**{k: _h(v) for k, v in kwargs.items()},
)
diff --git a/src/tools/cloudtrail.py b/src/tools/cloudtrail.py
index 6786b01..f57a470 100644
--- a/src/tools/cloudtrail.py
+++ b/src/tools/cloudtrail.py
@@ -5,17 +5,20 @@
import boto3
from botocore.exceptions import BotoCoreError, ClientError
-
from loguru import logger
+from agent.init_store import get_runtime_aws_region
from config import settings
from tools._cache import tool_cached
-
def _ct_client() -> Any:
- session = boto3.Session(profile_name=settings.aws_profile) if settings.aws_profile else boto3.Session()
- return session.client("cloudtrail", region_name=settings.aws_region)
+ session = (
+ boto3.Session(profile_name=settings.aws_profile)
+ if settings.aws_profile
+ else boto3.Session()
+ )
+ return session.client("cloudtrail", region_name=get_runtime_aws_region())
@tool_cached
@@ -25,7 +28,7 @@ def lookup_cloudtrail_events(
event_name: str | None = None,
limit: int = 50,
) -> dict:
- """Look up recent CloudTrail API events to find deployments, config changes, and permission changes.
+ """Look up recent CloudTrail API events.
Args:
hours: How far back to look. Default 2.
@@ -44,9 +47,13 @@ def lookup_cloudtrail_events(
"MaxResults": min(limit, 50),
}
if resource_name:
- kwargs["LookupAttributes"] = [{"AttributeKey": "ResourceName", "AttributeValue": resource_name}]
+ kwargs["LookupAttributes"] = [
+ {"AttributeKey": "ResourceName", "AttributeValue": resource_name}
+ ]
elif event_name:
- kwargs["LookupAttributes"] = [{"AttributeKey": "EventName", "AttributeValue": event_name}]
+ kwargs["LookupAttributes"] = [
+ {"AttributeKey": "EventName", "AttributeValue": event_name}
+ ]
resp = client.lookup_events(**kwargs)
events = []
diff --git a/src/tools/cloudwatch.py b/src/tools/cloudwatch.py
index 9b1beea..12038c9 100644
--- a/src/tools/cloudwatch.py
+++ b/src/tools/cloudwatch.py
@@ -6,22 +6,29 @@
import boto3
from botocore.exceptions import BotoCoreError, ClientError
-
from loguru import logger
+from agent.init_store import get_runtime_aws_region
from config import settings
from tools._cache import tool_cached
-
def _cw_client() -> Any:
- session = boto3.Session(profile_name=settings.aws_profile) if settings.aws_profile else boto3.Session()
- return session.client("cloudwatch", region_name=settings.aws_region)
+ session = (
+ boto3.Session(profile_name=settings.aws_profile)
+ if settings.aws_profile
+ else boto3.Session()
+ )
+ return session.client("cloudwatch", region_name=get_runtime_aws_region())
def _logs_client() -> Any:
- session = boto3.Session(profile_name=settings.aws_profile) if settings.aws_profile else boto3.Session()
- return session.client("logs", region_name=settings.aws_region)
+ session = (
+ boto3.Session(profile_name=settings.aws_profile)
+ if settings.aws_profile
+ else boto3.Session()
+ )
+ return session.client("logs", region_name=get_runtime_aws_region())
@tool_cached
@@ -117,10 +124,19 @@ def get_metric_data(
Statistics=[stat],
)
datapoints = sorted(
- [{"timestamp": dp["Timestamp"].isoformat(), "value": dp[stat]} for dp in resp.get("Datapoints", [])],
+ [
+ {"timestamp": dp["Timestamp"].isoformat(), "value": dp[stat]}
+ for dp in resp.get("Datapoints", [])
+ ],
key=lambda x: x["timestamp"],
)
- return {"namespace": namespace, "metric": metric, "stat": stat, "datapoints": datapoints, "count": len(datapoints)}
+ return {
+ "namespace": namespace,
+ "metric": metric,
+ "stat": stat,
+ "datapoints": datapoints,
+ "count": len(datapoints),
+ }
except (BotoCoreError, ClientError) as e:
logger.error("get_metric_data failed: {}", e)
return {"error": str(e), "datapoints": []}
@@ -214,7 +230,7 @@ def query_logs_insights(
Supports the full Logs Insights query language: fields, filter, stats, sort, limit.
Example queries:
- - 'fields @timestamp, @message | filter @message like /ERROR/ | sort @timestamp desc | limit 50'
+ - 'fields @timestamp, @message | filter @message like /ERROR/ | limit 50'
- 'stats count(*) as errors by bin(5m) | sort errors desc'
- 'fields @timestamp, @message | filter @message like /WARN/ | limit 100'
diff --git a/src/tools/ec2.py b/src/tools/ec2.py
index 580a962..8d8e9e7 100644
--- a/src/tools/ec2.py
+++ b/src/tools/ec2.py
@@ -4,17 +4,20 @@
import boto3
from botocore.exceptions import BotoCoreError, ClientError
-
from loguru import logger
+from agent.init_store import get_runtime_aws_region
from config import settings
from tools._cache import tool_cached
-
def _ec2_client() -> Any:
- session = boto3.Session(profile_name=settings.aws_profile) if settings.aws_profile else boto3.Session()
- return session.client("ec2", region_name=settings.aws_region)
+ session = (
+ boto3.Session(profile_name=settings.aws_profile)
+ if settings.aws_profile
+ else boto3.Session()
+ )
+ return session.client("ec2", region_name=get_runtime_aws_region())
@tool_cached
@@ -22,7 +25,7 @@ def describe_ec2_instances(filters: list[dict[str, Any]] | None = None) -> dict:
"""List EC2 instances with their state, type, and tags. Optionally filter by state or tag.
Args:
- filters: EC2 describe-instances filters, e.g. [{"Name": "instance-state-name", "Values": ["running"]}].
+ filters: EC2 describe-instances filters.
"""
try:
client = _ec2_client()
diff --git a/src/tools/ecs.py b/src/tools/ecs.py
index 46af9cc..d03f186 100644
--- a/src/tools/ecs.py
+++ b/src/tools/ecs.py
@@ -4,22 +4,29 @@
import boto3
from botocore.exceptions import BotoCoreError, ClientError
-
from loguru import logger
+from agent.init_store import get_runtime_aws_region
from config import settings
from tools._cache import tool_cached
-
def _ecs_client() -> Any:
- session = boto3.Session(profile_name=settings.aws_profile) if settings.aws_profile else boto3.Session()
- return session.client("ecs", region_name=settings.aws_region)
+ session = (
+ boto3.Session(profile_name=settings.aws_profile)
+ if settings.aws_profile
+ else boto3.Session()
+ )
+ return session.client("ecs", region_name=get_runtime_aws_region())
def _logs_client() -> Any:
- session = boto3.Session(profile_name=settings.aws_profile) if settings.aws_profile else boto3.Session()
- return session.client("logs", region_name=settings.aws_region)
+ session = (
+ boto3.Session(profile_name=settings.aws_profile)
+ if settings.aws_profile
+ else boto3.Session()
+ )
+ return session.client("logs", region_name=get_runtime_aws_region())
@tool_cached
diff --git a/src/tools/iam.py b/src/tools/iam.py
index a35c965..16d0a37 100644
--- a/src/tools/iam.py
+++ b/src/tools/iam.py
@@ -4,22 +4,29 @@
import boto3
from botocore.exceptions import BotoCoreError, ClientError
-
from loguru import logger
+from agent.init_store import get_runtime_aws_region
from config import settings
from tools._cache import tool_cached
-
def _iam_client() -> Any:
- session = boto3.Session(profile_name=settings.aws_profile) if settings.aws_profile else boto3.Session()
- return session.client("iam", region_name=settings.aws_region)
+ session = (
+ boto3.Session(profile_name=settings.aws_profile)
+ if settings.aws_profile
+ else boto3.Session()
+ )
+ return session.client("iam", region_name=get_runtime_aws_region())
def _sts_client() -> Any:
- session = boto3.Session(profile_name=settings.aws_profile) if settings.aws_profile else boto3.Session()
- return session.client("sts", region_name=settings.aws_region)
+ session = (
+ boto3.Session(profile_name=settings.aws_profile)
+ if settings.aws_profile
+ else boto3.Session()
+ )
+ return session.client("sts", region_name=get_runtime_aws_region())
@tool_cached
diff --git a/src/tools/lambda_.py b/src/tools/lambda_.py
index 1b6bc64..6463762 100644
--- a/src/tools/lambda_.py
+++ b/src/tools/lambda_.py
@@ -5,22 +5,29 @@
import boto3
from botocore.exceptions import BotoCoreError, ClientError
-
from loguru import logger
+from agent.init_store import get_runtime_aws_region
from config import settings
from tools._cache import tool_cached
-
def _lambda_client() -> Any:
- session = boto3.Session(profile_name=settings.aws_profile) if settings.aws_profile else boto3.Session()
- return session.client("lambda", region_name=settings.aws_region)
+ session = (
+ boto3.Session(profile_name=settings.aws_profile)
+ if settings.aws_profile
+ else boto3.Session()
+ )
+ return session.client("lambda", region_name=get_runtime_aws_region())
def _cw_client() -> Any:
- session = boto3.Session(profile_name=settings.aws_profile) if settings.aws_profile else boto3.Session()
- return session.client("cloudwatch", region_name=settings.aws_region)
+ session = (
+ boto3.Session(profile_name=settings.aws_profile)
+ if settings.aws_profile
+ else boto3.Session()
+ )
+ return session.client("cloudwatch", region_name=get_runtime_aws_region())
@tool_cached
@@ -100,7 +107,10 @@ def _get_sum(metric: str) -> list[dict[str, Any]]:
Statistics=["Sum"],
)
return sorted(
- [{"timestamp": dp["Timestamp"].isoformat(), "value": dp["Sum"]} for dp in resp.get("Datapoints", [])],
+ [
+ {"timestamp": dp["Timestamp"].isoformat(), "value": dp["Sum"]}
+ for dp in resp.get("Datapoints", [])
+ ],
key=lambda x: x["timestamp"],
)
@@ -118,7 +128,9 @@ def _get_sum(metric: str) -> list[dict[str, Any]]:
"total_invocations": total_invocations,
"total_errors": total_errors,
"total_throttles": total_throttles,
- "error_rate_pct": round(total_errors / total_invocations * 100, 2) if total_invocations else 0,
+ "error_rate_pct": round(total_errors / total_invocations * 100, 2)
+ if total_invocations
+ else 0,
"errors_by_period": errors,
"throttles_by_period": throttles,
}
diff --git a/src/tools/rds.py b/src/tools/rds.py
index f57dd1d..626740f 100644
--- a/src/tools/rds.py
+++ b/src/tools/rds.py
@@ -5,17 +5,20 @@
import boto3
from botocore.exceptions import BotoCoreError, ClientError
-
from loguru import logger
+from agent.init_store import get_runtime_aws_region
from config import settings
from tools._cache import tool_cached
-
def _rds_client() -> Any:
- session = boto3.Session(profile_name=settings.aws_profile) if settings.aws_profile else boto3.Session()
- return session.client("rds", region_name=settings.aws_region)
+ session = (
+ boto3.Session(profile_name=settings.aws_profile)
+ if settings.aws_profile
+ else boto3.Session()
+ )
+ return session.client("rds", region_name=get_runtime_aws_region())
@tool_cached
diff --git a/src/tools/sns.py b/src/tools/sns.py
index d7a5041..440ee6e 100644
--- a/src/tools/sns.py
+++ b/src/tools/sns.py
@@ -3,12 +3,17 @@
import boto3
from loguru import logger
+from agent.init_store import get_runtime_aws_region
from config import settings
def _sns_client():
- session = boto3.Session(profile_name=settings.aws_profile) if settings.aws_profile else boto3.Session()
- return session.client("sns", region_name=settings.aws_region)
+ session = (
+ boto3.Session(profile_name=settings.aws_profile)
+ if settings.aws_profile
+ else boto3.Session()
+ )
+ return session.client("sns", region_name=get_runtime_aws_region())
def publish_sns_alert(topic_arn: str, subject: str, message: str) -> dict:
diff --git a/tests/integration/test_poller.py b/tests/integration/test_poller.py
index 000400d..7947f6d 100644
--- a/tests/integration/test_poller.py
+++ b/tests/integration/test_poller.py
@@ -31,22 +31,24 @@ def fake_get_alarms(_state):
]
}
- async def fake_run(prompt, trigger_key):
+ async def fake_run(prompt, session_id):
calls["run"] += 1
assert "HighErrorRate-payment-processor" in prompt
- assert trigger_key == "alarm:HighErrorRate-payment-processor"
- return {
- "root_cause_category": "RESOURCE_LIMIT",
- "root_cause_summary": "concurrency exhausted",
- "confidence": "HIGH",
- "evidence": ["throttles spiked"],
- "mitigation_steps": ["increase reserved concurrency"],
- "validation_steps": ["watch metrics"],
- "services_affected": ["Lambda"],
- "recommended_follow_up": "add alarm",
- }
-
- async def fake_persist(_prompt, _result, _session_id):
+ return (
+ {
+ "root_cause_category": "RESOURCE_LIMIT",
+ "root_cause_summary": "concurrency exhausted",
+ "confidence": "HIGH",
+ "evidence": ["throttles spiked"],
+ "mitigation_steps": ["increase reserved concurrency"],
+ "validation_steps": ["watch metrics"],
+ "services_affected": ["Lambda"],
+ "recommended_follow_up": "add alarm",
+ },
+ [],
+ )
+
+ async def fake_persist(_result, _tool_calls_log, _session_id, _dedup_key):
calls["persist"] += 1
monkeypatch.setattr("tools.cloudwatch.get_alarms", fake_get_alarms)
@@ -70,22 +72,24 @@ def fake_list_lambda_functions():
def fake_get_lambda_error_rate(_name, _hours):
return {"error_rate_pct": 12.5}
- async def fake_run(prompt, trigger_key):
+ async def fake_run(prompt, session_id):
calls["run"] += 1
assert "payment-processor" in prompt
- assert trigger_key == "lambda_errors:payment-processor"
- return {
- "root_cause_category": "RESOURCE_LIMIT",
- "root_cause_summary": "lambda saturation",
- "confidence": "HIGH",
- "evidence": ["high throttles"],
- "mitigation_steps": ["raise concurrency"],
- "validation_steps": ["observe errors"],
- "services_affected": ["Lambda"],
- "recommended_follow_up": "right-size limits",
- }
-
- async def fake_persist(_prompt, _result, _session_id):
+ return (
+ {
+ "root_cause_category": "RESOURCE_LIMIT",
+ "root_cause_summary": "lambda saturation",
+ "confidence": "HIGH",
+ "evidence": ["high throttles"],
+ "mitigation_steps": ["raise concurrency"],
+ "validation_steps": ["observe errors"],
+ "services_affected": ["Lambda"],
+ "recommended_follow_up": "right-size limits",
+ },
+ [],
+ )
+
+ async def fake_persist(_result, _tool_calls_log, _session_id, _dedup_key):
calls["persist"] += 1
monkeypatch.setattr("tools.lambda_.list_lambda_functions", fake_list_lambda_functions)