nyk commited on
Commit
959f8d7
·
unverified ·
1 Parent(s): 1a89aa4

feat: audit hardening, webhook retry, and local Claude session tracking (#68)

Browse files

Security hardening:
- Fix timing-safe comparison bugs in webhooks.ts and auth.ts (was comparing buffer with itself)
- Harden rate limiter IP extraction — use rightmost untrusted IP from XFF chain with MC_TRUSTED_PROXIES support
- Add 12-char minimum password validation in Zod schema and runtime check
- Add Zod validation on PUT /api/tasks bulk status update

Webhook retry system (completing in-progress feature):
- Exponential backoff with circuit breaker in webhooks.ts
- POST /api/webhooks/retry endpoint for manual retry
- GET /api/webhooks/verify-docs endpoint for signature verification docs
- Scheduler integration for automatic retry processing
- Unit tests for signature verification and backoff logic

Local Claude Code session tracking:
- New claude-sessions.ts scanner parses JSONL transcripts from ~/.claude/projects/
- Extracts model, tokens, messages, cost estimates, active status per session
- Migration 020 adds claude_sessions table
- GET/POST /api/claude/sessions endpoint with filtering and aggregate stats
- Scheduler runs scan every 60s with MC_CLAUDE_HOME config

Quality improvements:
- Replace all console.error/warn with structured logger across 31 API routes
- Add Docker HEALTHCHECK directive
- Add vitest coverage config with v8 provider (60% threshold)
- Update README with new features, API docs, env vars, and roadmap items
- Fix E2E tests for password length and rate limiter IP changes

This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. Dockerfile +3 -0
  2. README.md +26 -3
  3. src/app/api/activities/route.ts +6 -5
  4. src/app/api/agents/[id]/heartbeat/route.ts +2 -1
  5. src/app/api/agents/[id]/memory/route.ts +4 -3
  6. src/app/api/agents/[id]/route.ts +4 -3
  7. src/app/api/agents/[id]/soul/route.ts +6 -5
  8. src/app/api/agents/[id]/wake/route.ts +2 -1
  9. src/app/api/agents/comms/route.ts +2 -1
  10. src/app/api/agents/message/route.ts +2 -1
  11. src/app/api/agents/sync/route.ts +3 -2
  12. src/app/api/auth/me/route.ts +2 -1
  13. src/app/api/auth/users/route.ts +3 -2
  14. src/app/api/backup/route.ts +2 -1
  15. src/app/api/chat/conversations/route.ts +2 -1
  16. src/app/api/chat/messages/[id]/route.ts +3 -2
  17. src/app/api/chat/messages/route.ts +7 -6
  18. src/app/api/claude/sessions/route.ts +102 -0
  19. src/app/api/cron/route.ts +4 -3
  20. src/app/api/logs/route.ts +3 -2
  21. src/app/api/memory/route.ts +7 -6
  22. src/app/api/notifications/deliver/route.ts +4 -3
  23. src/app/api/notifications/route.ts +6 -5
  24. src/app/api/pipelines/route.ts +5 -4
  25. src/app/api/pipelines/run/route.ts +3 -2
  26. src/app/api/quality-review/route.ts +3 -2
  27. src/app/api/sessions/route.ts +2 -1
  28. src/app/api/standup/route.ts +3 -2
  29. src/app/api/status/route.ts +11 -10
  30. src/app/api/tasks/[id]/broadcast/route.ts +2 -1
  31. src/app/api/tasks/[id]/comments/route.ts +3 -2
  32. src/app/api/tasks/route.ts +4 -6
  33. src/app/api/tokens/route.ts +3 -2
  34. src/app/api/webhooks/deliveries/route.ts +2 -1
  35. src/app/api/webhooks/retry/route.ts +63 -0
  36. src/app/api/webhooks/route.ts +11 -2
  37. src/app/api/webhooks/test/route.ts +10 -69
  38. src/app/api/webhooks/verify-docs/route.ts +40 -0
  39. src/app/api/workflows/route.ts +5 -4
  40. src/lib/__tests__/rate-limit.test.ts +1 -1
  41. src/lib/__tests__/validation.test.ts +1 -1
  42. src/lib/__tests__/webhooks.test.ts +82 -0
  43. src/lib/auth.ts +4 -1
  44. src/lib/claude-sessions.ts +298 -0
  45. src/lib/config.ts +3 -0
  46. src/lib/migrations.ts +52 -0
  47. src/lib/rate-limit.ts +26 -1
  48. src/lib/scheduler.ts +33 -3
  49. src/lib/validation.ts +8 -1
  50. src/lib/webhooks.ts +192 -11
Dockerfile CHANGED
@@ -23,8 +23,11 @@ COPY --from=build /app/.next/static ./.next/static
23
  COPY --from=build /app/public* ./public/
24
  # Create data directory with correct ownership for SQLite
25
  RUN mkdir -p .data && chown nextjs:nodejs .data
 
26
  USER nextjs
27
  EXPOSE 3000
28
  ENV PORT=3000
29
  ENV HOSTNAME=0.0.0.0
 
 
30
  CMD ["node", "server.js"]
 
23
  COPY --from=build /app/public* ./public/
24
  # Create data directory with correct ownership for SQLite
25
  RUN mkdir -p .data && chown nextjs:nodejs .data
26
+ RUN apt-get update && apt-get install -y curl --no-install-recommends && rm -rf /var/lib/apt/lists/*
27
  USER nextjs
28
  EXPOSE 3000
29
  ENV PORT=3000
30
  ENV HOSTNAME=0.0.0.0
31
+ HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
32
+ CMD curl -f http://localhost:3000/api/status || exit 1
33
  CMD ["node", "server.js"]
README.md CHANGED
@@ -33,6 +33,8 @@ Running AI agents at scale means juggling sessions, tasks, costs, and reliabilit
33
 
34
  ## Quick Start
35
 
 
 
36
  ```bash
37
  git clone https://github.com/builderz-labs/mission-control.git
38
  cd mission-control
@@ -89,6 +91,9 @@ Scheduled tasks for database backups, stale record cleanup, and agent heartbeat
89
  ### Direct CLI Integration
90
  Connect Claude Code, Codex, or any CLI tool directly to Mission Control without requiring a gateway. Register connections, send heartbeats with inline token reporting, and auto-register agents.
91
 
 
 
 
92
  ### GitHub Issues Sync
93
  Inbound sync from GitHub repositories with label and assignee mapping. Synced issues appear on the task board alongside agent-created tasks.
94
 
@@ -113,7 +118,8 @@ mission-control/
113
  │ ├── lib/
114
  │ │ ├── auth.ts # Session + API key auth, RBAC
115
  │ │ ├── db.ts # SQLite (better-sqlite3, WAL mode)
116
- │ │ ├── migrations.ts # 18 schema migrations
 
117
  │ │ ├── scheduler.ts # Background task scheduler
118
  │ │ ├── webhooks.ts # Outbound webhook delivery
119
  │ │ └── websocket.ts # Gateway WebSocket client
@@ -234,6 +240,8 @@ All endpoints require authentication unless noted. Full reference below.
234
  |--------|------|------|-------------|
235
  | `GET/POST/PUT/DELETE` | `/api/webhooks` | admin | Webhook CRUD |
236
  | `POST` | `/api/webhooks/test` | admin | Test delivery |
 
 
237
  | `GET` | `/api/webhooks/deliveries` | admin | Delivery history |
238
  | `GET/POST/PUT/DELETE` | `/api/alerts` | admin | Alert rules |
239
  | `GET/POST/PUT/DELETE` | `/api/gateways` | admin | Gateway connections |
@@ -276,6 +284,16 @@ All endpoints require authentication unless noted. Full reference below.
276
 
277
  </details>
278
 
 
 
 
 
 
 
 
 
 
 
279
  <details>
280
  <summary><strong>Pipelines</strong></summary>
281
 
@@ -300,6 +318,8 @@ See [`.env.example`](.env.example) for the complete list. Key variables:
300
  | `OPENCLAW_GATEWAY_HOST` | No | Gateway host (default: `127.0.0.1`) |
301
  | `OPENCLAW_GATEWAY_PORT` | No | Gateway WebSocket port (default: `18789`) |
302
  | `OPENCLAW_MEMORY_DIR` | No | Memory browser root (see note below) |
 
 
303
  | `MC_ALLOWED_HOSTS` | No | Host allowlist for production |
304
 
305
  *Memory browser, log viewer, and gateway config require `OPENCLAW_HOME`.
@@ -360,15 +380,18 @@ See [open issues](https://github.com/builderz-labs/mission-control/issues) for p
360
  - [x] OpenAPI 3.1 documentation with Scalar UI ([#60](https://github.com/builderz-labs/mission-control/pull/60))
361
  - [x] GitHub Issues sync — inbound sync with label/assignee mapping ([#63](https://github.com/builderz-labs/mission-control/pull/63))
362
 
 
 
 
 
 
363
  **Up next:**
364
 
365
  - [ ] Agent-agnostic gateway support — connect any orchestration framework (OpenClaw, ZeroClaw, OpenFang, NeoBot, IronClaw, etc.), not just OpenClaw
366
  - [ ] Native macOS app (Electron or Tauri)
367
  - [ ] First-class per-agent cost breakdowns — dedicated panel with per-agent token usage and spend (currently derivable from per-session data)
368
- - [ ] Webhook retry with exponential backoff
369
  - [ ] OAuth approval UI improvements
370
  - [ ] API token rotation UI
371
- - [ ] Webhook signature verification
372
 
373
  ## Contributing
374
 
 
33
 
34
  ## Quick Start
35
 
36
+ > **Requires [pnpm](https://pnpm.io/installation)** — Mission Control uses pnpm for dependency management. Install it with `npm install -g pnpm` or `corepack enable`.
37
+
38
  ```bash
39
  git clone https://github.com/builderz-labs/mission-control.git
40
  cd mission-control
 
91
  ### Direct CLI Integration
92
  Connect Claude Code, Codex, or any CLI tool directly to Mission Control without requiring a gateway. Register connections, send heartbeats with inline token reporting, and auto-register agents.
93
 
94
+ ### Claude Code Session Tracking
95
+ Automatically discovers and tracks local Claude Code sessions by scanning `~/.claude/projects/`. Extracts token usage, model info, message counts, cost estimates, and active status from JSONL transcripts. Scans every 60 seconds via the background scheduler.
96
+
97
  ### GitHub Issues Sync
98
  Inbound sync from GitHub repositories with label and assignee mapping. Synced issues appear on the task board alongside agent-created tasks.
99
 
 
118
  │ ├── lib/
119
  │ │ ├── auth.ts # Session + API key auth, RBAC
120
  │ │ ├── db.ts # SQLite (better-sqlite3, WAL mode)
121
+ │ │ ├── claude-sessions.ts # Local Claude Code session scanner
122
+ │ │ ├── migrations.ts # 20 schema migrations
123
  │ │ ├── scheduler.ts # Background task scheduler
124
  │ │ ├── webhooks.ts # Outbound webhook delivery
125
  │ │ └── websocket.ts # Gateway WebSocket client
 
240
  |--------|------|------|-------------|
241
  | `GET/POST/PUT/DELETE` | `/api/webhooks` | admin | Webhook CRUD |
242
  | `POST` | `/api/webhooks/test` | admin | Test delivery |
243
+ | `POST` | `/api/webhooks/retry` | admin | Manual retry a failed delivery |
244
+ | `GET` | `/api/webhooks/verify-docs` | viewer | Signature verification docs |
245
  | `GET` | `/api/webhooks/deliveries` | admin | Delivery history |
246
  | `GET/POST/PUT/DELETE` | `/api/alerts` | admin | Alert rules |
247
  | `GET/POST/PUT/DELETE` | `/api/gateways` | admin | Gateway connections |
 
284
 
285
  </details>
286
 
287
+ <details>
288
+ <summary><strong>Claude Code Sessions</strong></summary>
289
+
290
+ | Method | Path | Role | Description |
291
+ |--------|------|------|-------------|
292
+ | `GET` | `/api/claude/sessions` | viewer | List discovered sessions (filter: `?active=1`, `?project=`) |
293
+ | `POST` | `/api/claude/sessions` | operator | Trigger manual session scan |
294
+
295
+ </details>
296
+
297
  <details>
298
  <summary><strong>Pipelines</strong></summary>
299
 
 
318
  | `OPENCLAW_GATEWAY_HOST` | No | Gateway host (default: `127.0.0.1`) |
319
  | `OPENCLAW_GATEWAY_PORT` | No | Gateway WebSocket port (default: `18789`) |
320
  | `OPENCLAW_MEMORY_DIR` | No | Memory browser root (see note below) |
321
+ | `MC_CLAUDE_HOME` | No | Path to `~/.claude` directory (default: `~/.claude`) |
322
+ | `MC_TRUSTED_PROXIES` | No | Comma-separated trusted proxy IPs for XFF parsing |
323
  | `MC_ALLOWED_HOSTS` | No | Host allowlist for production |
324
 
325
  *Memory browser, log viewer, and gateway config require `OPENCLAW_HOME`.
 
380
  - [x] OpenAPI 3.1 documentation with Scalar UI ([#60](https://github.com/builderz-labs/mission-control/pull/60))
381
  - [x] GitHub Issues sync — inbound sync with label/assignee mapping ([#63](https://github.com/builderz-labs/mission-control/pull/63))
382
 
383
+ - [x] Webhook retry with exponential backoff and circuit breaker
384
+ - [x] Webhook signature verification (HMAC-SHA256 with constant-time comparison)
385
+ - [x] Local Claude Code session tracking — auto-discover sessions from `~/.claude/projects/`
386
+ - [x] Rate limiter IP extraction hardening with trusted proxy support
387
+
388
  **Up next:**
389
 
390
  - [ ] Agent-agnostic gateway support — connect any orchestration framework (OpenClaw, ZeroClaw, OpenFang, NeoBot, IronClaw, etc.), not just OpenClaw
391
  - [ ] Native macOS app (Electron or Tauri)
392
  - [ ] First-class per-agent cost breakdowns — dedicated panel with per-agent token usage and spend (currently derivable from per-session data)
 
393
  - [ ] OAuth approval UI improvements
394
  - [ ] API token rotation UI
 
395
 
396
  ## Contributing
397
 
src/app/api/activities/route.ts CHANGED
@@ -1,6 +1,7 @@
1
  import { NextRequest, NextResponse } from 'next/server';
2
  import { getDatabase, Activity } from '@/lib/db';
3
- import { requireRole } from '@/lib/auth'
 
4
 
5
  /**
6
  * GET /api/activities - Get activity stream or stats
@@ -21,7 +22,7 @@ export async function GET(request: NextRequest) {
21
  // Default activities endpoint
22
  return handleActivitiesRequest(request);
23
  } catch (error) {
24
- console.error('GET /api/activities error:', error);
25
  return NextResponse.json({ error: 'Failed to process request' }, { status: 500 });
26
  }
27
  }
@@ -115,7 +116,7 @@ async function handleActivitiesRequest(request: NextRequest) {
115
  }
116
  }
117
  } catch (error) {
118
- console.warn(`Failed to fetch entity details for activity ${activity.id}:`, error);
119
  }
120
 
121
  return {
@@ -157,7 +158,7 @@ async function handleActivitiesRequest(request: NextRequest) {
157
  hasMore: offset + activities.length < countResult.total
158
  });
159
  } catch (error) {
160
- console.error('GET /api/activities (activities) error:', error);
161
  return NextResponse.json({ error: 'Failed to fetch activities' }, { status: 500 });
162
  }
163
  }
@@ -219,7 +220,7 @@ async function handleStatsRequest(request: NextRequest) {
219
  }))
220
  });
221
  } catch (error) {
222
- console.error('GET /api/activities (stats) error:', error);
223
  return NextResponse.json({ error: 'Failed to fetch activity stats' }, { status: 500 });
224
  }
225
  }
 
1
  import { NextRequest, NextResponse } from 'next/server';
2
  import { getDatabase, Activity } from '@/lib/db';
3
+ import { requireRole } from '@/lib/auth';
4
+ import { logger } from '@/lib/logger';
5
 
6
  /**
7
  * GET /api/activities - Get activity stream or stats
 
22
  // Default activities endpoint
23
  return handleActivitiesRequest(request);
24
  } catch (error) {
25
+ logger.error({ err: error }, 'GET /api/activities error');
26
  return NextResponse.json({ error: 'Failed to process request' }, { status: 500 });
27
  }
28
  }
 
116
  }
117
  }
118
  } catch (error) {
119
+ logger.warn({ err: error, activityId: activity.id }, 'Failed to fetch entity details for activity');
120
  }
121
 
122
  return {
 
158
  hasMore: offset + activities.length < countResult.total
159
  });
160
  } catch (error) {
161
+ logger.error({ err: error }, 'GET /api/activities (activities) error');
162
  return NextResponse.json({ error: 'Failed to fetch activities' }, { status: 500 });
163
  }
164
  }
 
220
  }))
221
  });
222
  } catch (error) {
223
+ logger.error({ err: error }, 'GET /api/activities (stats) error');
224
  return NextResponse.json({ error: 'Failed to fetch activity stats' }, { status: 500 });
225
  }
226
  }
src/app/api/agents/[id]/heartbeat/route.ts CHANGED
@@ -1,6 +1,7 @@
1
  import { NextRequest, NextResponse } from 'next/server';
2
  import { getDatabase, db_helpers } from '@/lib/db';
3
  import { requireRole } from '@/lib/auth';
 
4
 
5
  /**
6
  * GET /api/agents/[id]/heartbeat - Agent heartbeat check
@@ -161,7 +162,7 @@ export async function GET(
161
  });
162
 
163
  } catch (error) {
164
- console.error('GET /api/agents/[id]/heartbeat error:', error);
165
  return NextResponse.json({ error: 'Failed to perform heartbeat check' }, { status: 500 });
166
  }
167
  }
 
1
  import { NextRequest, NextResponse } from 'next/server';
2
  import { getDatabase, db_helpers } from '@/lib/db';
3
  import { requireRole } from '@/lib/auth';
4
+ import { logger } from '@/lib/logger';
5
 
6
  /**
7
  * GET /api/agents/[id]/heartbeat - Agent heartbeat check
 
162
  });
163
 
164
  } catch (error) {
165
+ logger.error({ err: error }, 'GET /api/agents/[id]/heartbeat error');
166
  return NextResponse.json({ error: 'Failed to perform heartbeat check' }, { status: 500 });
167
  }
168
  }
src/app/api/agents/[id]/memory/route.ts CHANGED
@@ -1,6 +1,7 @@
1
  import { NextRequest, NextResponse } from 'next/server';
2
  import { getDatabase, db_helpers } from '@/lib/db';
3
  import { requireRole } from '@/lib/auth';
 
4
 
5
  /**
6
  * GET /api/agents/[id]/memory - Get agent's working memory
@@ -58,7 +59,7 @@ export async function GET(
58
  size: workingMemory.length
59
  });
60
  } catch (error) {
61
- console.error('GET /api/agents/[id]/memory error:', error);
62
  return NextResponse.json({ error: 'Failed to fetch working memory' }, { status: 500 });
63
  }
64
  }
@@ -147,7 +148,7 @@ export async function PUT(
147
  size: newContent.length
148
  });
149
  } catch (error) {
150
- console.error('PUT /api/agents/[id]/memory error:', error);
151
  return NextResponse.json({ error: 'Failed to update working memory' }, { status: 500 });
152
  }
153
  }
@@ -207,7 +208,7 @@ export async function DELETE(
207
  updated_at: now
208
  });
209
  } catch (error) {
210
- console.error('DELETE /api/agents/[id]/memory error:', error);
211
  return NextResponse.json({ error: 'Failed to clear working memory' }, { status: 500 });
212
  }
213
  }
 
1
  import { NextRequest, NextResponse } from 'next/server';
2
  import { getDatabase, db_helpers } from '@/lib/db';
3
  import { requireRole } from '@/lib/auth';
4
+ import { logger } from '@/lib/logger';
5
 
6
  /**
7
  * GET /api/agents/[id]/memory - Get agent's working memory
 
59
  size: workingMemory.length
60
  });
61
  } catch (error) {
62
+ logger.error({ err: error }, 'GET /api/agents/[id]/memory error');
63
  return NextResponse.json({ error: 'Failed to fetch working memory' }, { status: 500 });
64
  }
65
  }
 
148
  size: newContent.length
149
  });
150
  } catch (error) {
151
+ logger.error({ err: error }, 'PUT /api/agents/[id]/memory error');
152
  return NextResponse.json({ error: 'Failed to update working memory' }, { status: 500 });
153
  }
154
  }
 
208
  updated_at: now
209
  });
210
  } catch (error) {
211
+ logger.error({ err: error }, 'DELETE /api/agents/[id]/memory error');
212
  return NextResponse.json({ error: 'Failed to clear working memory' }, { status: 500 });
213
  }
214
  }
src/app/api/agents/[id]/route.ts CHANGED
@@ -3,6 +3,7 @@ import { getDatabase, db_helpers, logAuditEvent } from '@/lib/db'
3
  import { getUserFromRequest, requireRole } from '@/lib/auth'
4
  import { writeAgentToConfig } from '@/lib/agent-sync'
5
  import { eventBus } from '@/lib/event-bus'
 
6
 
7
  /**
8
  * GET /api/agents/[id] - Get a single agent by ID or name
@@ -36,7 +37,7 @@ export async function GET(
36
 
37
  return NextResponse.json({ agent: parsed })
38
  } catch (error) {
39
- console.error('GET /api/agents/[id] error:', error)
40
  return NextResponse.json({ error: 'Failed to fetch agent' }, { status: 500 })
41
  }
42
  }
@@ -158,7 +159,7 @@ export async function PUT(
158
  agent: { ...agent, config: newConfig, role: role || agent.role, updated_at: now },
159
  })
160
  } catch (error: any) {
161
- console.error('PUT /api/agents/[id] error:', error)
162
  return NextResponse.json({ error: error.message || 'Failed to update agent' }, { status: 500 })
163
  }
164
  }
@@ -203,7 +204,7 @@ export async function DELETE(
203
 
204
  return NextResponse.json({ success: true, deleted: agent.name })
205
  } catch (error) {
206
- console.error('DELETE /api/agents/[id] error:', error)
207
  return NextResponse.json({ error: 'Failed to delete agent' }, { status: 500 })
208
  }
209
  }
 
3
  import { getUserFromRequest, requireRole } from '@/lib/auth'
4
  import { writeAgentToConfig } from '@/lib/agent-sync'
5
  import { eventBus } from '@/lib/event-bus'
6
+ import { logger } from '@/lib/logger'
7
 
8
  /**
9
  * GET /api/agents/[id] - Get a single agent by ID or name
 
37
 
38
  return NextResponse.json({ agent: parsed })
39
  } catch (error) {
40
+ logger.error({ err: error }, 'GET /api/agents/[id] error')
41
  return NextResponse.json({ error: 'Failed to fetch agent' }, { status: 500 })
42
  }
43
  }
 
159
  agent: { ...agent, config: newConfig, role: role || agent.role, updated_at: now },
160
  })
161
  } catch (error: any) {
162
+ logger.error({ err: error }, 'PUT /api/agents/[id] error')
163
  return NextResponse.json({ error: error.message || 'Failed to update agent' }, { status: 500 })
164
  }
165
  }
 
204
 
205
  return NextResponse.json({ success: true, deleted: agent.name })
206
  } catch (error) {
207
+ logger.error({ err: error }, 'DELETE /api/agents/[id] error')
208
  return NextResponse.json({ error: 'Failed to delete agent' }, { status: 500 })
209
  }
210
  }
src/app/api/agents/[id]/soul/route.ts CHANGED
@@ -5,6 +5,7 @@ import { join } from 'path';
5
  import { config } from '@/lib/config';
6
  import { resolveWithin } from '@/lib/paths';
7
  import { getUserFromRequest, requireRole } from '@/lib/auth';
 
8
 
9
  /**
10
  * GET /api/agents/[id]/soul - Get agent's SOUL content
@@ -44,7 +45,7 @@ export async function GET(
44
  .map(file => file.replace('.md', ''));
45
  }
46
  } catch (error) {
47
- console.warn('Could not read soul templates directory:', error);
48
  }
49
 
50
  return NextResponse.json({
@@ -58,7 +59,7 @@ export async function GET(
58
  updated_at: agent.updated_at
59
  });
60
  } catch (error) {
61
- console.error('GET /api/agents/[id]/soul error:', error);
62
  return NextResponse.json({ error: 'Failed to fetch SOUL content' }, { status: 500 });
63
  }
64
  }
@@ -118,7 +119,7 @@ export async function PUT(
118
  return NextResponse.json({ error: 'Template not found' }, { status: 404 });
119
  }
120
  } catch (error) {
121
- console.error('Error loading soul template:', error);
122
  return NextResponse.json({ error: 'Failed to load template' }, { status: 500 });
123
  }
124
  }
@@ -155,7 +156,7 @@ export async function PUT(
155
  updated_at: now
156
  });
157
  } catch (error) {
158
- console.error('PUT /api/agents/[id]/soul error:', error);
159
  return NextResponse.json({ error: 'Failed to update SOUL content' }, { status: 500 });
160
  }
161
  }
@@ -226,7 +227,7 @@ export async function PATCH(
226
 
227
  return NextResponse.json({ templates });
228
  } catch (error) {
229
- console.error('PATCH /api/agents/[id]/soul error:', error);
230
  return NextResponse.json({ error: 'Failed to fetch templates' }, { status: 500 });
231
  }
232
  }
 
5
  import { config } from '@/lib/config';
6
  import { resolveWithin } from '@/lib/paths';
7
  import { getUserFromRequest, requireRole } from '@/lib/auth';
8
+ import { logger } from '@/lib/logger';
9
 
10
  /**
11
  * GET /api/agents/[id]/soul - Get agent's SOUL content
 
45
  .map(file => file.replace('.md', ''));
46
  }
47
  } catch (error) {
48
+ logger.warn({ err: error }, 'Could not read soul templates directory');
49
  }
50
 
51
  return NextResponse.json({
 
59
  updated_at: agent.updated_at
60
  });
61
  } catch (error) {
62
+ logger.error({ err: error }, 'GET /api/agents/[id]/soul error');
63
  return NextResponse.json({ error: 'Failed to fetch SOUL content' }, { status: 500 });
64
  }
65
  }
 
119
  return NextResponse.json({ error: 'Template not found' }, { status: 404 });
120
  }
121
  } catch (error) {
122
+ logger.error({ err: error }, 'Error loading soul template');
123
  return NextResponse.json({ error: 'Failed to load template' }, { status: 500 });
124
  }
125
  }
 
156
  updated_at: now
157
  });
158
  } catch (error) {
159
+ logger.error({ err: error }, 'PUT /api/agents/[id]/soul error');
160
  return NextResponse.json({ error: 'Failed to update SOUL content' }, { status: 500 });
161
  }
162
  }
 
227
 
228
  return NextResponse.json({ templates });
229
  } catch (error) {
230
+ logger.error({ err: error }, 'PATCH /api/agents/[id]/soul error');
231
  return NextResponse.json({ error: 'Failed to fetch templates' }, { status: 500 });
232
  }
233
  }
src/app/api/agents/[id]/wake/route.ts CHANGED
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'
2
  import { getDatabase, db_helpers } from '@/lib/db'
3
  import { runOpenClaw } from '@/lib/command'
4
  import { requireRole } from '@/lib/auth'
 
5
 
6
  export async function POST(
7
  request: NextRequest,
@@ -57,7 +58,7 @@ export async function POST(
57
  stdout: stdout.trim()
58
  })
59
  } catch (error) {
60
- console.error('POST /api/agents/[id]/wake error:', error)
61
  return NextResponse.json({ error: 'Failed to wake agent' }, { status: 500 })
62
  }
63
  }
 
2
  import { getDatabase, db_helpers } from '@/lib/db'
3
  import { runOpenClaw } from '@/lib/command'
4
  import { requireRole } from '@/lib/auth'
5
+ import { logger } from '@/lib/logger'
6
 
7
  export async function POST(
8
  request: NextRequest,
 
58
  stdout: stdout.trim()
59
  })
60
  } catch (error) {
61
+ logger.error({ err: error }, 'POST /api/agents/[id]/wake error')
62
  return NextResponse.json({ error: 'Failed to wake agent' }, { status: 500 })
63
  }
64
  }
src/app/api/agents/comms/route.ts CHANGED
@@ -1,6 +1,7 @@
1
  import { NextRequest, NextResponse } from "next/server"
2
  import { getDatabase, Message } from "@/lib/db"
3
  import { requireRole } from '@/lib/auth'
 
4
 
5
  /**
6
  * GET /api/agents/comms - Inter-agent communication stats and timeline
@@ -153,7 +154,7 @@ export async function GET(request: NextRequest) {
153
  source: { mode: source, seededCount, liveCount },
154
  })
155
  } catch (error) {
156
- console.error("GET /api/agents/comms error:", error)
157
  return NextResponse.json({ error: "Failed to fetch agent communications" }, { status: 500 })
158
  }
159
  }
 
1
  import { NextRequest, NextResponse } from "next/server"
2
  import { getDatabase, Message } from "@/lib/db"
3
  import { requireRole } from '@/lib/auth'
4
+ import { logger } from '@/lib/logger'
5
 
6
  /**
7
  * GET /api/agents/comms - Inter-agent communication stats and timeline
 
154
  source: { mode: source, seededCount, liveCount },
155
  })
156
  } catch (error) {
157
+ logger.error({ err: error }, "GET /api/agents/comms error")
158
  return NextResponse.json({ error: "Failed to fetch agent communications" }, { status: 500 })
159
  }
160
  }
src/app/api/agents/message/route.ts CHANGED
@@ -4,6 +4,7 @@ import { runOpenClaw } from '@/lib/command'
4
  import { requireRole } from '@/lib/auth'
5
  import { validateBody, createMessageSchema } from '@/lib/validation'
6
  import { mutationLimiter } from '@/lib/rate-limit'
 
7
 
8
  export async function POST(request: NextRequest) {
9
  const auth = requireRole(request, 'operator')
@@ -61,7 +62,7 @@ export async function POST(request: NextRequest) {
61
 
62
  return NextResponse.json({ success: true })
63
  } catch (error) {
64
- console.error('POST /api/agents/message error:', error)
65
  return NextResponse.json({ error: 'Failed to send message' }, { status: 500 })
66
  }
67
  }
 
4
  import { requireRole } from '@/lib/auth'
5
  import { validateBody, createMessageSchema } from '@/lib/validation'
6
  import { mutationLimiter } from '@/lib/rate-limit'
7
+ import { logger } from '@/lib/logger'
8
 
9
  export async function POST(request: NextRequest) {
10
  const auth = requireRole(request, 'operator')
 
62
 
63
  return NextResponse.json({ success: true })
64
  } catch (error) {
65
+ logger.error({ err: error }, 'POST /api/agents/message error')
66
  return NextResponse.json({ error: 'Failed to send message' }, { status: 500 })
67
  }
68
  }
src/app/api/agents/sync/route.ts CHANGED
@@ -1,6 +1,7 @@
1
  import { NextRequest, NextResponse } from 'next/server'
2
  import { requireRole } from '@/lib/auth'
3
  import { syncAgentsFromConfig, previewSyncDiff } from '@/lib/agent-sync'
 
4
 
5
  /**
6
  * POST /api/agents/sync - Trigger agent config sync from openclaw.json
@@ -19,7 +20,7 @@ export async function POST(request: NextRequest) {
19
 
20
  return NextResponse.json(result)
21
  } catch (error: any) {
22
- console.error('POST /api/agents/sync error:', error)
23
  return NextResponse.json({ error: error.message || 'Sync failed' }, { status: 500 })
24
  }
25
  }
@@ -36,7 +37,7 @@ export async function GET(request: NextRequest) {
36
  const diff = await previewSyncDiff()
37
  return NextResponse.json(diff)
38
  } catch (error: any) {
39
- console.error('GET /api/agents/sync error:', error)
40
  return NextResponse.json({ error: error.message || 'Preview failed' }, { status: 500 })
41
  }
42
  }
 
1
  import { NextRequest, NextResponse } from 'next/server'
2
  import { requireRole } from '@/lib/auth'
3
  import { syncAgentsFromConfig, previewSyncDiff } from '@/lib/agent-sync'
4
+ import { logger } from '@/lib/logger'
5
 
6
  /**
7
  * POST /api/agents/sync - Trigger agent config sync from openclaw.json
 
20
 
21
  return NextResponse.json(result)
22
  } catch (error: any) {
23
+ logger.error({ err: error }, 'POST /api/agents/sync error')
24
  return NextResponse.json({ error: error.message || 'Sync failed' }, { status: 500 })
25
  }
26
  }
 
37
  const diff = await previewSyncDiff()
38
  return NextResponse.json(diff)
39
  } catch (error: any) {
40
+ logger.error({ err: error }, 'GET /api/agents/sync error')
41
  return NextResponse.json({ error: error.message || 'Preview failed' }, { status: 500 })
42
  }
43
  }
src/app/api/auth/me/route.ts CHANGED
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'
2
  import { getUserFromRequest, updateUser , requireRole } from '@/lib/auth'
3
  import { logAuditEvent } from '@/lib/db'
4
  import { verifyPassword } from '@/lib/password'
 
5
 
6
  export async function GET(request: Request) {
7
  const auth = requireRole(request, 'viewer')
@@ -105,7 +106,7 @@ export async function PATCH(request: NextRequest) {
105
  },
106
  })
107
  } catch (error) {
108
- console.error('PATCH /api/auth/me error:', error)
109
  return NextResponse.json({ error: 'Failed to update profile' }, { status: 500 })
110
  }
111
  }
 
2
  import { getUserFromRequest, updateUser , requireRole } from '@/lib/auth'
3
  import { logAuditEvent } from '@/lib/db'
4
  import { verifyPassword } from '@/lib/password'
5
+ import { logger } from '@/lib/logger'
6
 
7
  export async function GET(request: Request) {
8
  const auth = requireRole(request, 'viewer')
 
106
  },
107
  })
108
  } catch (error) {
109
+ logger.error({ err: error }, 'PATCH /api/auth/me error')
110
  return NextResponse.json({ error: 'Failed to update profile' }, { status: 500 })
111
  }
112
  }
src/app/api/auth/users/route.ts CHANGED
@@ -3,6 +3,7 @@ import { getUserFromRequest, getAllUsers, createUser, updateUser, deleteUser , r
3
  import { logAuditEvent } from '@/lib/db'
4
  import { validateBody, createUserSchema } from '@/lib/validation'
5
  import { mutationLimiter } from '@/lib/rate-limit'
 
6
 
7
  /**
8
  * GET /api/auth/users - List all users (admin only)
@@ -62,7 +63,7 @@ export async function POST(request: NextRequest) {
62
  if (error.message?.includes('UNIQUE constraint failed')) {
63
  return NextResponse.json({ error: 'Username already exists' }, { status: 409 })
64
  }
65
- console.error('POST /api/auth/users error:', error)
66
  return NextResponse.json({ error: 'Failed to create user' }, { status: 500 })
67
  }
68
  }
@@ -117,7 +118,7 @@ export async function PUT(request: NextRequest) {
117
  }
118
  })
119
  } catch (error) {
120
- console.error('PUT /api/auth/users error:', error)
121
  return NextResponse.json({ error: 'Failed to update user' }, { status: 500 })
122
  }
123
  }
 
3
  import { logAuditEvent } from '@/lib/db'
4
  import { validateBody, createUserSchema } from '@/lib/validation'
5
  import { mutationLimiter } from '@/lib/rate-limit'
6
+ import { logger } from '@/lib/logger'
7
 
8
  /**
9
  * GET /api/auth/users - List all users (admin only)
 
63
  if (error.message?.includes('UNIQUE constraint failed')) {
64
  return NextResponse.json({ error: 'Username already exists' }, { status: 409 })
65
  }
66
+ logger.error({ err: error }, 'POST /api/auth/users error')
67
  return NextResponse.json({ error: 'Failed to create user' }, { status: 500 })
68
  }
69
  }
 
118
  }
119
  })
120
  } catch (error) {
121
+ logger.error({ err: error }, 'PUT /api/auth/users error')
122
  return NextResponse.json({ error: 'Failed to update user' }, { status: 500 })
123
  }
124
  }
src/app/api/backup/route.ts CHANGED
@@ -5,6 +5,7 @@ import { config, ensureDirExists } from '@/lib/config'
5
  import { join, dirname } from 'path'
6
  import { readdirSync, statSync, unlinkSync } from 'fs'
7
  import { heavyLimiter } from '@/lib/rate-limit'
 
8
 
9
  const BACKUP_DIR = join(dirname(config.dbPath), 'backups')
10
  const MAX_BACKUPS = 10
@@ -79,7 +80,7 @@ export async function POST(request: NextRequest) {
79
  },
80
  })
81
  } catch (error: any) {
82
- console.error('Backup failed:', error)
83
  return NextResponse.json({ error: `Backup failed: ${error.message}` }, { status: 500 })
84
  }
85
  }
 
5
  import { join, dirname } from 'path'
6
  import { readdirSync, statSync, unlinkSync } from 'fs'
7
  import { heavyLimiter } from '@/lib/rate-limit'
8
+ import { logger } from '@/lib/logger'
9
 
10
  const BACKUP_DIR = join(dirname(config.dbPath), 'backups')
11
  const MAX_BACKUPS = 10
 
80
  },
81
  })
82
  } catch (error: any) {
83
+ logger.error({ err: error }, 'Backup failed')
84
  return NextResponse.json({ error: `Backup failed: ${error.message}` }, { status: 500 })
85
  }
86
  }
src/app/api/chat/conversations/route.ts CHANGED
@@ -1,6 +1,7 @@
1
  import { NextRequest, NextResponse } from 'next/server'
2
  import { getDatabase } from '@/lib/db'
3
  import { requireRole } from '@/lib/auth'
 
4
 
5
  /**
6
  * GET /api/chat/conversations - List conversations derived from messages
@@ -94,7 +95,7 @@ export async function GET(request: NextRequest) {
94
 
95
  return NextResponse.json({ conversations: withLastMessage, total: countRow.total, page: Math.floor(offset / limit) + 1, limit })
96
  } catch (error) {
97
- console.error('GET /api/chat/conversations error:', error)
98
  return NextResponse.json({ error: 'Failed to fetch conversations' }, { status: 500 })
99
  }
100
  }
 
1
  import { NextRequest, NextResponse } from 'next/server'
2
  import { getDatabase } from '@/lib/db'
3
  import { requireRole } from '@/lib/auth'
4
+ import { logger } from '@/lib/logger'
5
 
6
  /**
7
  * GET /api/chat/conversations - List conversations derived from messages
 
95
 
96
  return NextResponse.json({ conversations: withLastMessage, total: countRow.total, page: Math.floor(offset / limit) + 1, limit })
97
  } catch (error) {
98
+ logger.error({ err: error }, 'GET /api/chat/conversations error')
99
  return NextResponse.json({ error: 'Failed to fetch conversations' }, { status: 500 })
100
  }
101
  }
src/app/api/chat/messages/[id]/route.ts CHANGED
@@ -1,6 +1,7 @@
1
  import { NextRequest, NextResponse } from 'next/server'
2
  import { getDatabase, Message } from '@/lib/db'
3
  import { requireRole } from '@/lib/auth'
 
4
 
5
  /**
6
  * GET /api/chat/messages/[id] - Get a single message
@@ -29,7 +30,7 @@ export async function GET(
29
  }
30
  })
31
  } catch (error) {
32
- console.error('GET /api/chat/messages/[id] error:', error)
33
  return NextResponse.json({ error: 'Failed to fetch message' }, { status: 500 })
34
  }
35
  }
@@ -69,7 +70,7 @@ export async function PATCH(
69
  }
70
  })
71
  } catch (error) {
72
- console.error('PATCH /api/chat/messages/[id] error:', error)
73
  return NextResponse.json({ error: 'Failed to update message' }, { status: 500 })
74
  }
75
  }
 
1
  import { NextRequest, NextResponse } from 'next/server'
2
  import { getDatabase, Message } from '@/lib/db'
3
  import { requireRole } from '@/lib/auth'
4
+ import { logger } from '@/lib/logger'
5
 
6
  /**
7
  * GET /api/chat/messages/[id] - Get a single message
 
30
  }
31
  })
32
  } catch (error) {
33
+ logger.error({ err: error }, 'GET /api/chat/messages/[id] error')
34
  return NextResponse.json({ error: 'Failed to fetch message' }, { status: 500 })
35
  }
36
  }
 
70
  }
71
  })
72
  } catch (error) {
73
+ logger.error({ err: error }, 'PATCH /api/chat/messages/[id] error')
74
  return NextResponse.json({ error: 'Failed to update message' }, { status: 500 })
75
  }
76
  }
src/app/api/chat/messages/route.ts CHANGED
@@ -4,6 +4,7 @@ import { runOpenClaw } from '@/lib/command'
4
  import { getAllGatewaySessions } from '@/lib/sessions'
5
  import { eventBus } from '@/lib/event-bus'
6
  import { requireRole } from '@/lib/auth'
 
7
 
8
  type ForwardInfo = {
9
  attempted: boolean
@@ -166,7 +167,7 @@ export async function GET(request: NextRequest) {
166
 
167
  return NextResponse.json({ messages: parsed, total: countRow.total, page: Math.floor(offset / limit) + 1, limit })
168
  } catch (error) {
169
- console.error('GET /api/chat/messages error:', error)
170
  return NextResponse.json({ error: 'Failed to fetch messages' }, { status: 500 })
171
  }
172
  }
@@ -287,7 +288,7 @@ export async function POST(request: NextRequest) {
287
  { status: 'offline', reason: 'no_active_session' }
288
  )
289
  } catch (e) {
290
- console.error('Failed to create offline status reply:', e)
291
  }
292
  }
293
  } else {
@@ -332,7 +333,7 @@ export async function POST(request: NextRequest) {
332
  }
333
  } else {
334
  forwardInfo.reason = 'gateway_send_failed'
335
- console.error('Failed to forward message via gateway:', err)
336
 
337
  // For coordinator messages, emit visible status when send fails
338
  if (typeof conversation_id === 'string' && conversation_id.startsWith('coord:')) {
@@ -347,7 +348,7 @@ export async function POST(request: NextRequest) {
347
  { status: 'delivery_failed', reason: 'gateway_send_failed' }
348
  )
349
  } catch (e) {
350
- console.error('Failed to create gateway failure status reply:', e)
351
  }
352
  }
353
  }
@@ -370,7 +371,7 @@ export async function POST(request: NextRequest) {
370
  { status: 'accepted', runId: forwardInfo.runId || null }
371
  )
372
  } catch (e) {
373
- console.error('Failed to create accepted status reply:', e)
374
  }
375
 
376
  // Best effort: wait briefly and surface completion/error feedback.
@@ -477,7 +478,7 @@ export async function POST(request: NextRequest) {
477
 
478
  return NextResponse.json({ message: parsedMessage, forward: forwardInfo }, { status: 201 })
479
  } catch (error) {
480
- console.error('POST /api/chat/messages error:', error)
481
  return NextResponse.json({ error: 'Failed to send message' }, { status: 500 })
482
  }
483
  }
 
4
  import { getAllGatewaySessions } from '@/lib/sessions'
5
  import { eventBus } from '@/lib/event-bus'
6
  import { requireRole } from '@/lib/auth'
7
+ import { logger } from '@/lib/logger'
8
 
9
  type ForwardInfo = {
10
  attempted: boolean
 
167
 
168
  return NextResponse.json({ messages: parsed, total: countRow.total, page: Math.floor(offset / limit) + 1, limit })
169
  } catch (error) {
170
+ logger.error({ err: error }, 'GET /api/chat/messages error')
171
  return NextResponse.json({ error: 'Failed to fetch messages' }, { status: 500 })
172
  }
173
  }
 
288
  { status: 'offline', reason: 'no_active_session' }
289
  )
290
  } catch (e) {
291
+ logger.error({ err: e }, 'Failed to create offline status reply')
292
  }
293
  }
294
  } else {
 
333
  }
334
  } else {
335
  forwardInfo.reason = 'gateway_send_failed'
336
+ logger.error({ err }, 'Failed to forward message via gateway')
337
 
338
  // For coordinator messages, emit visible status when send fails
339
  if (typeof conversation_id === 'string' && conversation_id.startsWith('coord:')) {
 
348
  { status: 'delivery_failed', reason: 'gateway_send_failed' }
349
  )
350
  } catch (e) {
351
+ logger.error({ err: e }, 'Failed to create gateway failure status reply')
352
  }
353
  }
354
  }
 
371
  { status: 'accepted', runId: forwardInfo.runId || null }
372
  )
373
  } catch (e) {
374
+ logger.error({ err: e }, 'Failed to create accepted status reply')
375
  }
376
 
377
  // Best effort: wait briefly and surface completion/error feedback.
 
478
 
479
  return NextResponse.json({ message: parsedMessage, forward: forwardInfo }, { status: 201 })
480
  } catch (error) {
481
+ logger.error({ err: error }, 'POST /api/chat/messages error')
482
  return NextResponse.json({ error: 'Failed to send message' }, { status: 500 })
483
  }
484
  }
src/app/api/claude/sessions/route.ts ADDED
@@ -0,0 +1,102 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextRequest, NextResponse } from 'next/server'
2
+ import { getDatabase } from '@/lib/db'
3
+ import { requireRole } from '@/lib/auth'
4
+ import { syncClaudeSessions } from '@/lib/claude-sessions'
5
+ import { logger } from '@/lib/logger'
6
+
7
+ /**
8
+ * GET /api/claude/sessions — List discovered local Claude Code sessions
9
+ *
10
+ * Query params:
11
+ * active=1 — only active sessions
12
+ * project=slug — filter by project slug
13
+ * limit=50 — max results (default 50, max 200)
14
+ * offset=0 — pagination offset
15
+ */
16
+ export async function GET(request: NextRequest) {
17
+ const auth = requireRole(request, 'viewer')
18
+ if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
19
+
20
+ try {
21
+ const db = getDatabase()
22
+ const { searchParams } = new URL(request.url)
23
+
24
+ const active = searchParams.get('active')
25
+ const project = searchParams.get('project')
26
+ const limit = Math.min(parseInt(searchParams.get('limit') || '50'), 200)
27
+ const offset = parseInt(searchParams.get('offset') || '0')
28
+
29
+ let query = 'SELECT * FROM claude_sessions WHERE 1=1'
30
+ const params: any[] = []
31
+
32
+ if (active === '1') {
33
+ query += ' AND is_active = 1'
34
+ }
35
+
36
+ if (project) {
37
+ query += ' AND project_slug = ?'
38
+ params.push(project)
39
+ }
40
+
41
+ query += ' ORDER BY last_message_at DESC LIMIT ? OFFSET ?'
42
+ params.push(limit, offset)
43
+
44
+ const sessions = db.prepare(query).all(...params)
45
+
46
+ // Get total count
47
+ let countQuery = 'SELECT COUNT(*) as total FROM claude_sessions WHERE 1=1'
48
+ const countParams: any[] = []
49
+ if (active === '1') {
50
+ countQuery += ' AND is_active = 1'
51
+ }
52
+ if (project) {
53
+ countQuery += ' AND project_slug = ?'
54
+ countParams.push(project)
55
+ }
56
+ const { total } = db.prepare(countQuery).get(...countParams) as { total: number }
57
+
58
+ // Aggregate stats
59
+ const stats = db.prepare(`
60
+ SELECT
61
+ COUNT(*) as total_sessions,
62
+ SUM(CASE WHEN is_active = 1 THEN 1 ELSE 0 END) as active_sessions,
63
+ SUM(input_tokens) as total_input_tokens,
64
+ SUM(output_tokens) as total_output_tokens,
65
+ SUM(estimated_cost) as total_estimated_cost,
66
+ COUNT(DISTINCT project_slug) as unique_projects
67
+ FROM claude_sessions
68
+ `).get() as any
69
+
70
+ return NextResponse.json({
71
+ sessions,
72
+ total,
73
+ stats: {
74
+ total_sessions: stats.total_sessions || 0,
75
+ active_sessions: stats.active_sessions || 0,
76
+ total_input_tokens: stats.total_input_tokens || 0,
77
+ total_output_tokens: stats.total_output_tokens || 0,
78
+ total_estimated_cost: Math.round((stats.total_estimated_cost || 0) * 100) / 100,
79
+ unique_projects: stats.unique_projects || 0,
80
+ },
81
+ })
82
+ } catch (error) {
83
+ logger.error({ err: error }, 'GET /api/claude/sessions error')
84
+ return NextResponse.json({ error: 'Failed to fetch Claude sessions' }, { status: 500 })
85
+ }
86
+ }
87
+
88
+ /**
89
+ * POST /api/claude/sessions — Trigger a manual scan of local Claude sessions
90
+ */
91
+ export async function POST(request: NextRequest) {
92
+ const auth = requireRole(request, 'operator')
93
+ if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
94
+
95
+ try {
96
+ const result = await syncClaudeSessions()
97
+ return NextResponse.json(result)
98
+ } catch (error) {
99
+ logger.error({ err: error }, 'POST /api/claude/sessions error')
100
+ return NextResponse.json({ error: 'Failed to scan Claude sessions' }, { status: 500 })
101
+ }
102
+ }
src/app/api/cron/route.ts CHANGED
@@ -1,6 +1,7 @@
1
  import { NextRequest, NextResponse } from 'next/server'
2
  import { requireRole } from '@/lib/auth'
3
  import { config } from '@/lib/config'
 
4
  import fs from 'node:fs'
5
  import path from 'node:path'
6
 
@@ -89,7 +90,7 @@ function saveCronFile(data: OpenClawCronFile): boolean {
89
  fs.writeFileSync(filePath, JSON.stringify(data, null, 2))
90
  return true
91
  } catch (err) {
92
- console.error('Failed to write cron file:', err)
93
  return false
94
  }
95
  }
@@ -189,7 +190,7 @@ export async function GET(request: NextRequest) {
189
 
190
  return NextResponse.json({ error: 'Invalid action' }, { status: 400 })
191
  } catch (error) {
192
- console.error('Cron API error:', error)
193
  return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
194
  }
195
  }
@@ -338,7 +339,7 @@ export async function POST(request: NextRequest) {
338
 
339
  return NextResponse.json({ error: 'Invalid action' }, { status: 400 })
340
  } catch (error) {
341
- console.error('Cron management error:', error)
342
  return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
343
  }
344
  }
 
1
  import { NextRequest, NextResponse } from 'next/server'
2
  import { requireRole } from '@/lib/auth'
3
  import { config } from '@/lib/config'
4
+ import { logger } from '@/lib/logger'
5
  import fs from 'node:fs'
6
  import path from 'node:path'
7
 
 
90
  fs.writeFileSync(filePath, JSON.stringify(data, null, 2))
91
  return true
92
  } catch (err) {
93
+ logger.error({ err }, 'Failed to write cron file')
94
  return false
95
  }
96
  }
 
190
 
191
  return NextResponse.json({ error: 'Invalid action' }, { status: 400 })
192
  } catch (error) {
193
+ logger.error({ err: error }, 'Cron API error')
194
  return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
195
  }
196
  }
 
339
 
340
  return NextResponse.json({ error: 'Invalid action' }, { status: 400 })
341
  } catch (error) {
342
+ logger.error({ err: error }, 'Cron management error')
343
  return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
344
  }
345
  }
src/app/api/logs/route.ts CHANGED
@@ -4,6 +4,7 @@ import { join } from 'path'
4
  import { config } from '@/lib/config'
5
  import { requireRole } from '@/lib/auth'
6
  import { readLimiter, mutationLimiter } from '@/lib/rate-limit'
 
7
 
8
  const LOGS_PATH = config.logsDir
9
 
@@ -248,7 +249,7 @@ export async function GET(request: NextRequest) {
248
 
249
  return NextResponse.json({ error: 'Invalid action' }, { status: 400 })
250
  } catch (error) {
251
- console.error('Logs API error:', error)
252
  return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
253
  }
254
  }
@@ -283,7 +284,7 @@ export async function POST(request: NextRequest) {
283
 
284
  return NextResponse.json({ error: 'Invalid action' }, { status: 400 })
285
  } catch (error) {
286
- console.error('Logs API error:', error)
287
  return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
288
  }
289
  }
 
4
  import { config } from '@/lib/config'
5
  import { requireRole } from '@/lib/auth'
6
  import { readLimiter, mutationLimiter } from '@/lib/rate-limit'
7
+ import { logger } from '@/lib/logger'
8
 
9
  const LOGS_PATH = config.logsDir
10
 
 
249
 
250
  return NextResponse.json({ error: 'Invalid action' }, { status: 400 })
251
  } catch (error) {
252
+ logger.error({ err: error }, 'Logs API error')
253
  return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
254
  }
255
  }
 
284
 
285
  return NextResponse.json({ error: 'Invalid action' }, { status: 400 })
286
  } catch (error) {
287
+ logger.error({ err: error }, 'Logs API error')
288
  return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
289
  }
290
  }
src/app/api/memory/route.ts CHANGED
@@ -5,6 +5,7 @@ import { config } from '@/lib/config'
5
  import { resolveWithin } from '@/lib/paths'
6
  import { requireRole } from '@/lib/auth'
7
  import { readLimiter, mutationLimiter } from '@/lib/rate-limit'
 
8
 
9
  const MEMORY_PATH = config.memoryDir
10
 
@@ -96,7 +97,7 @@ async function buildFileTree(dirPath: string, relativePath: string = ''): Promis
96
  })
97
  }
98
  } catch (error) {
99
- console.error(`Error reading ${itemPath}:`, error)
100
  }
101
  }
102
 
@@ -108,7 +109,7 @@ async function buildFileTree(dirPath: string, relativePath: string = ''): Promis
108
  return a.name.localeCompare(b.name)
109
  })
110
  } catch (error) {
111
- console.error(`Error reading directory ${dirPath}:`, error)
112
  return []
113
  }
114
  }
@@ -216,7 +217,7 @@ export async function GET(request: NextRequest) {
216
  }
217
  }
218
  } catch (error) {
219
- console.error(`Error searching directory ${dirPath}:`, error)
220
  }
221
  }
222
 
@@ -230,7 +231,7 @@ export async function GET(request: NextRequest) {
230
 
231
  return NextResponse.json({ error: 'Invalid action' }, { status: 400 })
232
  } catch (error) {
233
- console.error('Memory API error:', error)
234
  return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
235
  }
236
  }
@@ -290,7 +291,7 @@ export async function POST(request: NextRequest) {
290
 
291
  return NextResponse.json({ error: 'Invalid action' }, { status: 400 })
292
  } catch (error) {
293
- console.error('Memory POST API error:', error)
294
  return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
295
  }
296
  }
@@ -329,7 +330,7 @@ export async function DELETE(request: NextRequest) {
329
 
330
  return NextResponse.json({ error: 'Invalid action' }, { status: 400 })
331
  } catch (error) {
332
- console.error('Memory DELETE API error:', error)
333
  return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
334
  }
335
  }
 
5
  import { resolveWithin } from '@/lib/paths'
6
  import { requireRole } from '@/lib/auth'
7
  import { readLimiter, mutationLimiter } from '@/lib/rate-limit'
8
+ import { logger } from '@/lib/logger'
9
 
10
  const MEMORY_PATH = config.memoryDir
11
 
 
97
  })
98
  }
99
  } catch (error) {
100
+ logger.error({ err: error, path: itemPath }, 'Error reading file')
101
  }
102
  }
103
 
 
109
  return a.name.localeCompare(b.name)
110
  })
111
  } catch (error) {
112
+ logger.error({ err: error, path: dirPath }, 'Error reading directory')
113
  return []
114
  }
115
  }
 
217
  }
218
  }
219
  } catch (error) {
220
+ logger.error({ err: error, path: dirPath }, 'Error searching directory')
221
  }
222
  }
223
 
 
231
 
232
  return NextResponse.json({ error: 'Invalid action' }, { status: 400 })
233
  } catch (error) {
234
+ logger.error({ err: error }, 'Memory API error')
235
  return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
236
  }
237
  }
 
291
 
292
  return NextResponse.json({ error: 'Invalid action' }, { status: 400 })
293
  } catch (error) {
294
+ logger.error({ err: error }, 'Memory POST API error')
295
  return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
296
  }
297
  }
 
330
 
331
  return NextResponse.json({ error: 'Invalid action' }, { status: 400 })
332
  } catch (error) {
333
+ logger.error({ err: error }, 'Memory DELETE API error')
334
  return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
335
  }
336
  }
src/app/api/notifications/deliver/route.ts CHANGED
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
2
  import { getDatabase, Notification, db_helpers } from '@/lib/db';
3
  import { runOpenClaw } from '@/lib/command';
4
  import { requireRole } from '@/lib/auth';
 
5
 
6
  /**
7
  * POST /api/notifications/deliver - Notification delivery daemon endpoint
@@ -144,7 +145,7 @@ export async function POST(request: NextRequest) {
144
  error: error.message
145
  });
146
 
147
- console.error(`Failed to deliver notification ${notification.id} to ${notification.recipient}:`, error);
148
  }
149
  }
150
 
@@ -175,7 +176,7 @@ export async function POST(request: NextRequest) {
175
  error_details: errors
176
  });
177
  } catch (error) {
178
- console.error('POST /api/notifications/deliver error:', error);
179
  return NextResponse.json({ error: 'Failed to deliver notifications' }, { status: 500 });
180
  }
181
  }
@@ -252,7 +253,7 @@ export async function GET(request: NextRequest) {
252
  agent_filter: agent
253
  });
254
  } catch (error) {
255
- console.error('GET /api/notifications/deliver error:', error);
256
  return NextResponse.json({ error: 'Failed to get delivery status' }, { status: 500 });
257
  }
258
  }
 
2
  import { getDatabase, Notification, db_helpers } from '@/lib/db';
3
  import { runOpenClaw } from '@/lib/command';
4
  import { requireRole } from '@/lib/auth';
5
+ import { logger } from '@/lib/logger';
6
 
7
  /**
8
  * POST /api/notifications/deliver - Notification delivery daemon endpoint
 
145
  error: error.message
146
  });
147
 
148
+ logger.error({ err: error, notificationId: notification.id, recipient: notification.recipient }, 'Failed to deliver notification');
149
  }
150
  }
151
 
 
176
  error_details: errors
177
  });
178
  } catch (error) {
179
+ logger.error({ err: error }, 'POST /api/notifications/deliver error');
180
  return NextResponse.json({ error: 'Failed to deliver notifications' }, { status: 500 });
181
  }
182
  }
 
253
  agent_filter: agent
254
  });
255
  } catch (error) {
256
+ logger.error({ err: error }, 'GET /api/notifications/deliver error');
257
  return NextResponse.json({ error: 'Failed to get delivery status' }, { status: 500 });
258
  }
259
  }
src/app/api/notifications/route.ts CHANGED
@@ -3,6 +3,7 @@ import { getDatabase, Notification } from '@/lib/db';
3
  import { requireRole } from '@/lib/auth';
4
  import { mutationLimiter } from '@/lib/rate-limit';
5
  import { validateBody, notificationActionSchema } from '@/lib/validation';
 
6
 
7
  /**
8
  * GET /api/notifications - Get notifications for a specific recipient
@@ -91,7 +92,7 @@ export async function GET(request: NextRequest) {
91
  }
92
  }
93
  } catch (error) {
94
- console.warn(`Failed to fetch source details for notification ${notification.id}:`, error);
95
  }
96
 
97
  return {
@@ -127,7 +128,7 @@ export async function GET(request: NextRequest) {
127
  unreadCount: unreadCount.count
128
  });
129
  } catch (error) {
130
- console.error('GET /api/notifications error:', error);
131
  return NextResponse.json({ error: 'Failed to fetch notifications' }, { status: 500 });
132
  }
133
  }
@@ -185,7 +186,7 @@ export async function PUT(request: NextRequest) {
185
  }, { status: 400 });
186
  }
187
  } catch (error) {
188
- console.error('PUT /api/notifications error:', error);
189
  return NextResponse.json({ error: 'Failed to update notifications' }, { status: 500 });
190
  }
191
  }
@@ -239,7 +240,7 @@ export async function DELETE(request: NextRequest) {
239
  }, { status: 400 });
240
  }
241
  } catch (error) {
242
- console.error('DELETE /api/notifications error:', error);
243
  return NextResponse.json({ error: 'Failed to delete notifications' }, { status: 500 });
244
  }
245
  }
@@ -291,7 +292,7 @@ export async function POST(request: NextRequest) {
291
  return NextResponse.json({ error: 'Invalid action' }, { status: 400 });
292
  }
293
  } catch (error) {
294
- console.error('POST /api/notifications error:', error);
295
  return NextResponse.json({ error: 'Failed to process notification action' }, { status: 500 });
296
  }
297
  }
 
3
  import { requireRole } from '@/lib/auth';
4
  import { mutationLimiter } from '@/lib/rate-limit';
5
  import { validateBody, notificationActionSchema } from '@/lib/validation';
6
+ import { logger } from '@/lib/logger';
7
 
8
  /**
9
  * GET /api/notifications - Get notifications for a specific recipient
 
92
  }
93
  }
94
  } catch (error) {
95
+ logger.warn({ err: error, notificationId: notification.id }, 'Failed to fetch source details for notification');
96
  }
97
 
98
  return {
 
128
  unreadCount: unreadCount.count
129
  });
130
  } catch (error) {
131
+ logger.error({ err: error }, 'GET /api/notifications error');
132
  return NextResponse.json({ error: 'Failed to fetch notifications' }, { status: 500 });
133
  }
134
  }
 
186
  }, { status: 400 });
187
  }
188
  } catch (error) {
189
+ logger.error({ err: error }, 'PUT /api/notifications error');
190
  return NextResponse.json({ error: 'Failed to update notifications' }, { status: 500 });
191
  }
192
  }
 
240
  }, { status: 400 });
241
  }
242
  } catch (error) {
243
+ logger.error({ err: error }, 'DELETE /api/notifications error');
244
  return NextResponse.json({ error: 'Failed to delete notifications' }, { status: 500 });
245
  }
246
  }
 
292
  return NextResponse.json({ error: 'Invalid action' }, { status: 400 });
293
  }
294
  } catch (error) {
295
+ logger.error({ err: error }, 'POST /api/notifications error');
296
  return NextResponse.json({ error: 'Failed to process notification action' }, { status: 500 });
297
  }
298
  }
src/app/api/pipelines/route.ts CHANGED
@@ -3,6 +3,7 @@ import { getDatabase, db_helpers } from '@/lib/db'
3
  import { requireRole } from '@/lib/auth'
4
  import { validateBody, createPipelineSchema } from '@/lib/validation'
5
  import { mutationLimiter } from '@/lib/rate-limit'
 
6
 
7
  export interface PipelineStep {
8
  template_id: number
@@ -60,7 +61,7 @@ export async function GET(request: NextRequest) {
60
 
61
  return NextResponse.json({ pipelines: parsed })
62
  } catch (error) {
63
- console.error('GET /api/pipelines error:', error)
64
  return NextResponse.json({ error: 'Failed to fetch pipelines' }, { status: 500 })
65
  }
66
  }
@@ -106,7 +107,7 @@ export async function POST(request: NextRequest) {
106
  const pipeline = db.prepare('SELECT * FROM workflow_pipelines WHERE id = ?').get(insertResult.lastInsertRowid) as Pipeline
107
  return NextResponse.json({ pipeline: { ...pipeline, steps: JSON.parse(pipeline.steps) } }, { status: 201 })
108
  } catch (error) {
109
- console.error('POST /api/pipelines error:', error)
110
  return NextResponse.json({ error: 'Failed to create pipeline' }, { status: 500 })
111
  }
112
  }
@@ -153,7 +154,7 @@ export async function PUT(request: NextRequest) {
153
  const updated = db.prepare('SELECT * FROM workflow_pipelines WHERE id = ?').get(id) as Pipeline
154
  return NextResponse.json({ pipeline: { ...updated, steps: JSON.parse(updated.steps) } })
155
  } catch (error) {
156
- console.error('PUT /api/pipelines error:', error)
157
  return NextResponse.json({ error: 'Failed to update pipeline' }, { status: 500 })
158
  }
159
  }
@@ -175,7 +176,7 @@ export async function DELETE(request: NextRequest) {
175
  db.prepare('DELETE FROM workflow_pipelines WHERE id = ?').run(parseInt(id))
176
  return NextResponse.json({ success: true })
177
  } catch (error) {
178
- console.error('DELETE /api/pipelines error:', error)
179
  return NextResponse.json({ error: 'Failed to delete pipeline' }, { status: 500 })
180
  }
181
  }
 
3
  import { requireRole } from '@/lib/auth'
4
  import { validateBody, createPipelineSchema } from '@/lib/validation'
5
  import { mutationLimiter } from '@/lib/rate-limit'
6
+ import { logger } from '@/lib/logger'
7
 
8
  export interface PipelineStep {
9
  template_id: number
 
61
 
62
  return NextResponse.json({ pipelines: parsed })
63
  } catch (error) {
64
+ logger.error({ err: error }, 'GET /api/pipelines error')
65
  return NextResponse.json({ error: 'Failed to fetch pipelines' }, { status: 500 })
66
  }
67
  }
 
107
  const pipeline = db.prepare('SELECT * FROM workflow_pipelines WHERE id = ?').get(insertResult.lastInsertRowid) as Pipeline
108
  return NextResponse.json({ pipeline: { ...pipeline, steps: JSON.parse(pipeline.steps) } }, { status: 201 })
109
  } catch (error) {
110
+ logger.error({ err: error }, 'POST /api/pipelines error')
111
  return NextResponse.json({ error: 'Failed to create pipeline' }, { status: 500 })
112
  }
113
  }
 
154
  const updated = db.prepare('SELECT * FROM workflow_pipelines WHERE id = ?').get(id) as Pipeline
155
  return NextResponse.json({ pipeline: { ...updated, steps: JSON.parse(updated.steps) } })
156
  } catch (error) {
157
+ logger.error({ err: error }, 'PUT /api/pipelines error')
158
  return NextResponse.json({ error: 'Failed to update pipeline' }, { status: 500 })
159
  }
160
  }
 
176
  db.prepare('DELETE FROM workflow_pipelines WHERE id = ?').run(parseInt(id))
177
  return NextResponse.json({ success: true })
178
  } catch (error) {
179
+ logger.error({ err: error }, 'DELETE /api/pipelines error')
180
  return NextResponse.json({ error: 'Failed to delete pipeline' }, { status: 500 })
181
  }
182
  }
src/app/api/pipelines/run/route.ts CHANGED
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'
2
  import { getDatabase, db_helpers } from '@/lib/db'
3
  import { requireRole } from '@/lib/auth'
4
  import { eventBus } from '@/lib/event-bus'
 
5
 
6
  interface PipelineStep {
7
  template_id: number
@@ -79,7 +80,7 @@ export async function GET(request: NextRequest) {
79
 
80
  return NextResponse.json({ runs: parsed })
81
  } catch (error) {
82
- console.error('GET /api/pipelines/run error:', error)
83
  return NextResponse.json({ error: 'Failed to fetch runs' }, { status: 500 })
84
  }
85
  }
@@ -106,7 +107,7 @@ export async function POST(request: NextRequest) {
106
 
107
  return NextResponse.json({ error: 'Invalid action. Use: start, advance, cancel' }, { status: 400 })
108
  } catch (error) {
109
- console.error('POST /api/pipelines/run error:', error)
110
  return NextResponse.json({ error: 'Failed to process pipeline run' }, { status: 500 })
111
  }
112
  }
 
2
  import { getDatabase, db_helpers } from '@/lib/db'
3
  import { requireRole } from '@/lib/auth'
4
  import { eventBus } from '@/lib/event-bus'
5
+ import { logger } from '@/lib/logger'
6
 
7
  interface PipelineStep {
8
  template_id: number
 
80
 
81
  return NextResponse.json({ runs: parsed })
82
  } catch (error) {
83
+ logger.error({ err: error }, 'GET /api/pipelines/run error')
84
  return NextResponse.json({ error: 'Failed to fetch runs' }, { status: 500 })
85
  }
86
  }
 
107
 
108
  return NextResponse.json({ error: 'Invalid action. Use: start, advance, cancel' }, { status: 400 })
109
  } catch (error) {
110
+ logger.error({ err: error }, 'POST /api/pipelines/run error')
111
  return NextResponse.json({ error: 'Failed to process pipeline run' }, { status: 500 })
112
  }
113
  }
src/app/api/quality-review/route.ts CHANGED
@@ -3,6 +3,7 @@ import { getDatabase, db_helpers } from '@/lib/db'
3
  import { requireRole } from '@/lib/auth'
4
  import { validateBody, qualityReviewSchema } from '@/lib/validation'
5
  import { mutationLimiter } from '@/lib/rate-limit'
 
6
 
7
  export async function GET(request: NextRequest) {
8
  const auth = requireRole(request, 'viewer')
@@ -59,7 +60,7 @@ export async function GET(request: NextRequest) {
59
 
60
  return NextResponse.json({ reviews })
61
  } catch (error) {
62
- console.error('GET /api/quality-review error:', error)
63
  return NextResponse.json({ error: 'Failed to fetch quality reviews' }, { status: 500 })
64
  }
65
  }
@@ -99,7 +100,7 @@ export async function POST(request: NextRequest) {
99
 
100
  return NextResponse.json({ success: true, id: result.lastInsertRowid })
101
  } catch (error) {
102
- console.error('POST /api/quality-review error:', error)
103
  return NextResponse.json({ error: 'Failed to create quality review' }, { status: 500 })
104
  }
105
  }
 
3
  import { requireRole } from '@/lib/auth'
4
  import { validateBody, qualityReviewSchema } from '@/lib/validation'
5
  import { mutationLimiter } from '@/lib/rate-limit'
6
+ import { logger } from '@/lib/logger'
7
 
8
  export async function GET(request: NextRequest) {
9
  const auth = requireRole(request, 'viewer')
 
60
 
61
  return NextResponse.json({ reviews })
62
  } catch (error) {
63
+ logger.error({ err: error }, 'GET /api/quality-review error')
64
  return NextResponse.json({ error: 'Failed to fetch quality reviews' }, { status: 500 })
65
  }
66
  }
 
100
 
101
  return NextResponse.json({ success: true, id: result.lastInsertRowid })
102
  } catch (error) {
103
+ logger.error({ err: error }, 'POST /api/quality-review error')
104
  return NextResponse.json({ error: 'Failed to create quality review' }, { status: 500 })
105
  }
106
  }
src/app/api/sessions/route.ts CHANGED
@@ -1,6 +1,7 @@
1
  import { NextRequest, NextResponse } from 'next/server'
2
  import { getAllGatewaySessions } from '@/lib/sessions'
3
  import { requireRole } from '@/lib/auth'
 
4
 
5
  export async function GET(request: NextRequest) {
6
  const auth = requireRole(request, 'viewer')
@@ -31,7 +32,7 @@ export async function GET(request: NextRequest) {
31
 
32
  return NextResponse.json({ sessions })
33
  } catch (error) {
34
- console.error('Sessions API error:', error)
35
  return NextResponse.json({ sessions: [] })
36
  }
37
  }
 
1
  import { NextRequest, NextResponse } from 'next/server'
2
  import { getAllGatewaySessions } from '@/lib/sessions'
3
  import { requireRole } from '@/lib/auth'
4
+ import { logger } from '@/lib/logger'
5
 
6
  export async function GET(request: NextRequest) {
7
  const auth = requireRole(request, 'viewer')
 
32
 
33
  return NextResponse.json({ sessions })
34
  } catch (error) {
35
+ logger.error({ err: error }, 'Sessions API error')
36
  return NextResponse.json({ sessions: [] })
37
  }
38
  }
src/app/api/standup/route.ts CHANGED
@@ -1,6 +1,7 @@
1
  import { NextRequest, NextResponse } from 'next/server';
2
  import { getDatabase, db_helpers } from '@/lib/db';
3
  import { requireRole } from '@/lib/auth';
 
4
 
5
  /**
6
  * POST /api/standup/generate - Generate daily standup report
@@ -197,7 +198,7 @@ export async function POST(request: NextRequest) {
197
 
198
  return NextResponse.json({ standup: standupReport });
199
  } catch (error) {
200
- console.error('POST /api/standup/generate error:', error);
201
  return NextResponse.json({ error: 'Failed to generate standup' }, { status: 500 });
202
  }
203
  }
@@ -244,7 +245,7 @@ export async function GET(request: NextRequest) {
244
  limit
245
  });
246
  } catch (error) {
247
- console.error('GET /api/standup/history error:', error);
248
  return NextResponse.json({ error: 'Failed to fetch standup history' }, { status: 500 });
249
  }
250
  }
 
1
  import { NextRequest, NextResponse } from 'next/server';
2
  import { getDatabase, db_helpers } from '@/lib/db';
3
  import { requireRole } from '@/lib/auth';
4
+ import { logger } from '@/lib/logger';
5
 
6
  /**
7
  * POST /api/standup/generate - Generate daily standup report
 
198
 
199
  return NextResponse.json({ standup: standupReport });
200
  } catch (error) {
201
+ logger.error({ err: error }, 'POST /api/standup/generate error');
202
  return NextResponse.json({ error: 'Failed to generate standup' }, { status: 500 });
203
  }
204
  }
 
245
  limit
246
  });
247
  } catch (error) {
248
+ logger.error({ err: error }, 'GET /api/standup/history error');
249
  return NextResponse.json({ error: 'Failed to fetch standup history' }, { status: 500 });
250
  }
251
  }
src/app/api/status/route.ts CHANGED
@@ -7,6 +7,7 @@ import { getDatabase } from '@/lib/db'
7
  import { getAllGatewaySessions, getAgentLiveStatuses } from '@/lib/sessions'
8
  import { requireRole } from '@/lib/auth'
9
  import { MODEL_CATALOG } from '@/lib/models'
 
10
 
11
  export async function GET(request: NextRequest) {
12
  const auth = requireRole(request, 'viewer')
@@ -43,7 +44,7 @@ export async function GET(request: NextRequest) {
43
 
44
  return NextResponse.json({ error: 'Invalid action' }, { status: 400 })
45
  } catch (error) {
46
- console.error('Status API error:', error)
47
  return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
48
  }
49
  }
@@ -167,7 +168,7 @@ function getDbStats() {
167
  webhookCount,
168
  }
169
  } catch (err) {
170
- console.error('getDbStats error:', err)
171
  return null
172
  }
173
  }
@@ -190,7 +191,7 @@ async function getSystemStatus() {
190
  const bootTime = new Date(uptimeOutput.trim())
191
  status.uptime = Date.now() - bootTime.getTime()
192
  } catch (error) {
193
- console.error('Error getting uptime:', error)
194
  }
195
 
196
  try {
@@ -209,7 +210,7 @@ async function getSystemStatus() {
209
  }
210
  }
211
  } catch (error) {
212
- console.error('Error getting memory info:', error)
213
  }
214
 
215
  try {
@@ -228,7 +229,7 @@ async function getSystemStatus() {
228
  }
229
  }
230
  } catch (error) {
231
- console.error('Error getting disk info:', error)
232
  }
233
 
234
  try {
@@ -251,7 +252,7 @@ async function getSystemStatus() {
251
  .filter((proc) => /clawdbot|openclaw/i.test(proc.command))
252
  status.processes = processes
253
  } catch (error) {
254
- console.error('Error getting process info:', error)
255
  }
256
 
257
  try {
@@ -283,10 +284,10 @@ async function getSystemStatus() {
283
  )
284
  }
285
  } catch (dbErr) {
286
- console.error('Error syncing agent statuses:', dbErr)
287
  }
288
  } catch (error) {
289
- console.error('Error reading session stores:', error)
290
  }
291
 
292
  return status
@@ -321,7 +322,7 @@ async function getGatewayStatus() {
321
  try {
322
  gatewayStatus.port_listening = await isPortOpen(config.gatewayHost, config.gatewayPort)
323
  } catch (error) {
324
- console.error('Error checking port:', error)
325
  }
326
 
327
  try {
@@ -371,7 +372,7 @@ async function getAvailableModels() {
371
  }
372
  })
373
  } catch (error) {
374
- console.error('Error checking Ollama models:', error)
375
  }
376
 
377
  return models
 
7
  import { getAllGatewaySessions, getAgentLiveStatuses } from '@/lib/sessions'
8
  import { requireRole } from '@/lib/auth'
9
  import { MODEL_CATALOG } from '@/lib/models'
10
+ import { logger } from '@/lib/logger'
11
 
12
  export async function GET(request: NextRequest) {
13
  const auth = requireRole(request, 'viewer')
 
44
 
45
  return NextResponse.json({ error: 'Invalid action' }, { status: 400 })
46
  } catch (error) {
47
+ logger.error({ err: error }, 'Status API error')
48
  return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
49
  }
50
  }
 
168
  webhookCount,
169
  }
170
  } catch (err) {
171
+ logger.error({ err }, 'getDbStats error')
172
  return null
173
  }
174
  }
 
191
  const bootTime = new Date(uptimeOutput.trim())
192
  status.uptime = Date.now() - bootTime.getTime()
193
  } catch (error) {
194
+ logger.error({ err: error }, 'Error getting uptime')
195
  }
196
 
197
  try {
 
210
  }
211
  }
212
  } catch (error) {
213
+ logger.error({ err: error }, 'Error getting memory info')
214
  }
215
 
216
  try {
 
229
  }
230
  }
231
  } catch (error) {
232
+ logger.error({ err: error }, 'Error getting disk info')
233
  }
234
 
235
  try {
 
252
  .filter((proc) => /clawdbot|openclaw/i.test(proc.command))
253
  status.processes = processes
254
  } catch (error) {
255
+ logger.error({ err: error }, 'Error getting process info')
256
  }
257
 
258
  try {
 
284
  )
285
  }
286
  } catch (dbErr) {
287
+ logger.error({ err: dbErr }, 'Error syncing agent statuses')
288
  }
289
  } catch (error) {
290
+ logger.error({ err: error }, 'Error reading session stores')
291
  }
292
 
293
  return status
 
322
  try {
323
  gatewayStatus.port_listening = await isPortOpen(config.gatewayHost, config.gatewayPort)
324
  } catch (error) {
325
+ logger.error({ err: error }, 'Error checking port')
326
  }
327
 
328
  try {
 
372
  }
373
  })
374
  } catch (error) {
375
+ logger.error({ err: error }, 'Error checking Ollama models')
376
  }
377
 
378
  return models
src/app/api/tasks/[id]/broadcast/route.ts CHANGED
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'
2
  import { getDatabase, db_helpers } from '@/lib/db'
3
  import { runOpenClaw } from '@/lib/command'
4
  import { requireRole } from '@/lib/auth'
 
5
 
6
  export async function POST(
7
  request: NextRequest,
@@ -86,7 +87,7 @@ export async function POST(
86
 
87
  return NextResponse.json({ sent, skipped })
88
  } catch (error) {
89
- console.error('POST /api/tasks/[id]/broadcast error:', error)
90
  return NextResponse.json({ error: 'Failed to broadcast message' }, { status: 500 })
91
  }
92
  }
 
2
  import { getDatabase, db_helpers } from '@/lib/db'
3
  import { runOpenClaw } from '@/lib/command'
4
  import { requireRole } from '@/lib/auth'
5
+ import { logger } from '@/lib/logger'
6
 
7
  export async function POST(
8
  request: NextRequest,
 
87
 
88
  return NextResponse.json({ sent, skipped })
89
  } catch (error) {
90
+ logger.error({ err: error }, 'POST /api/tasks/[id]/broadcast error')
91
  return NextResponse.json({ error: 'Failed to broadcast message' }, { status: 500 })
92
  }
93
  }
src/app/api/tasks/[id]/comments/route.ts CHANGED
@@ -3,6 +3,7 @@ import { getDatabase, Comment, db_helpers } from '@/lib/db';
3
  import { requireRole } from '@/lib/auth';
4
  import { validateBody, createCommentSchema } from '@/lib/validation';
5
  import { mutationLimiter } from '@/lib/rate-limit';
 
6
 
7
  /**
8
  * GET /api/tasks/[id]/comments - Get all comments for a task
@@ -74,7 +75,7 @@ export async function GET(
74
  total: comments.length
75
  });
76
  } catch (error) {
77
- console.error(`GET /api/tasks/[id]/comments error:`, error);
78
  return NextResponse.json({ error: 'Failed to fetch comments' }, { status: 500 });
79
  }
80
  }
@@ -201,7 +202,7 @@ export async function POST(
201
  }
202
  }, { status: 201 });
203
  } catch (error) {
204
- console.error(`POST /api/tasks/[id]/comments error:`, error);
205
  return NextResponse.json({ error: 'Failed to add comment' }, { status: 500 });
206
  }
207
  }
 
3
  import { requireRole } from '@/lib/auth';
4
  import { validateBody, createCommentSchema } from '@/lib/validation';
5
  import { mutationLimiter } from '@/lib/rate-limit';
6
+ import { logger } from '@/lib/logger';
7
 
8
  /**
9
  * GET /api/tasks/[id]/comments - Get all comments for a task
 
75
  total: comments.length
76
  });
77
  } catch (error) {
78
+ logger.error({ err: error }, 'GET /api/tasks/[id]/comments error');
79
  return NextResponse.json({ error: 'Failed to fetch comments' }, { status: 500 });
80
  }
81
  }
 
202
  }
203
  }, { status: 201 });
204
  } catch (error) {
205
+ logger.error({ err: error }, 'POST /api/tasks/[id]/comments error');
206
  return NextResponse.json({ error: 'Failed to add comment' }, { status: 500 });
207
  }
208
  }
src/app/api/tasks/route.ts CHANGED
@@ -4,7 +4,7 @@ import { eventBus } from '@/lib/event-bus';
4
  import { requireRole } from '@/lib/auth';
5
  import { mutationLimiter } from '@/lib/rate-limit';
6
  import { logger } from '@/lib/logger';
7
- import { validateBody, createTaskSchema } from '@/lib/validation';
8
 
9
  function hasAegisApproval(db: ReturnType<typeof getDatabase>, taskId: number): boolean {
10
  const review = db.prepare(`
@@ -208,11 +208,9 @@ export async function PUT(request: NextRequest) {
208
 
209
  try {
210
  const db = getDatabase();
211
- const { tasks } = await request.json();
212
-
213
- if (!Array.isArray(tasks)) {
214
- return NextResponse.json({ error: 'Tasks must be an array' }, { status: 400 });
215
- }
216
 
217
  const now = Math.floor(Date.now() / 1000);
218
 
 
4
  import { requireRole } from '@/lib/auth';
5
  import { mutationLimiter } from '@/lib/rate-limit';
6
  import { logger } from '@/lib/logger';
7
+ import { validateBody, createTaskSchema, bulkUpdateTaskStatusSchema } from '@/lib/validation';
8
 
9
  function hasAegisApproval(db: ReturnType<typeof getDatabase>, taskId: number): boolean {
10
  const review = db.prepare(`
 
208
 
209
  try {
210
  const db = getDatabase();
211
+ const validated = await validateBody(request, bulkUpdateTaskStatusSchema);
212
+ if ('error' in validated) return validated.error;
213
+ const { tasks } = validated.data;
 
 
214
 
215
  const now = Math.floor(Date.now() / 1000);
216
 
src/app/api/tokens/route.ts CHANGED
@@ -4,6 +4,7 @@ import { dirname } from 'path'
4
  import { config, ensureDirExists } from '@/lib/config'
5
  import { requireRole } from '@/lib/auth'
6
  import { getAllGatewaySessions } from '@/lib/sessions'
 
7
 
8
  const DATA_PATH = config.tokensPath
9
 
@@ -385,7 +386,7 @@ export async function GET(request: NextRequest) {
385
 
386
  return NextResponse.json({ error: 'Invalid action' }, { status: 400 })
387
  } catch (error) {
388
- console.error('Tokens API error:', error)
389
  return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
390
  }
391
  }
@@ -430,7 +431,7 @@ export async function POST(request: NextRequest) {
430
 
431
  return NextResponse.json({ success: true, record })
432
  } catch (error) {
433
- console.error('Error saving token usage:', error)
434
  return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
435
  }
436
  }
 
4
  import { config, ensureDirExists } from '@/lib/config'
5
  import { requireRole } from '@/lib/auth'
6
  import { getAllGatewaySessions } from '@/lib/sessions'
7
+ import { logger } from '@/lib/logger'
8
 
9
  const DATA_PATH = config.tokensPath
10
 
 
386
 
387
  return NextResponse.json({ error: 'Invalid action' }, { status: 400 })
388
  } catch (error) {
389
+ logger.error({ err: error }, 'Tokens API error')
390
  return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
391
  }
392
  }
 
431
 
432
  return NextResponse.json({ success: true, record })
433
  } catch (error) {
434
+ logger.error({ err: error }, 'Error saving token usage')
435
  return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
436
  }
437
  }
src/app/api/webhooks/deliveries/route.ts CHANGED
@@ -1,6 +1,7 @@
1
  import { NextRequest, NextResponse } from 'next/server'
2
  import { getDatabase } from '@/lib/db'
3
  import { requireRole } from '@/lib/auth'
 
4
 
5
  /**
6
  * GET /api/webhooks/deliveries - Get delivery history for a webhook
@@ -44,7 +45,7 @@ export async function GET(request: NextRequest) {
44
 
45
  return NextResponse.json({ deliveries, total })
46
  } catch (error) {
47
- console.error('GET /api/webhooks/deliveries error:', error)
48
  return NextResponse.json({ error: 'Failed to fetch deliveries' }, { status: 500 })
49
  }
50
  }
 
1
  import { NextRequest, NextResponse } from 'next/server'
2
  import { getDatabase } from '@/lib/db'
3
  import { requireRole } from '@/lib/auth'
4
+ import { logger } from '@/lib/logger'
5
 
6
  /**
7
  * GET /api/webhooks/deliveries - Get delivery history for a webhook
 
45
 
46
  return NextResponse.json({ deliveries, total })
47
  } catch (error) {
48
+ logger.error({ err: error }, 'GET /api/webhooks/deliveries error')
49
  return NextResponse.json({ error: 'Failed to fetch deliveries' }, { status: 500 })
50
  }
51
  }
src/app/api/webhooks/retry/route.ts ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextRequest, NextResponse } from 'next/server'
2
+ import { getDatabase } from '@/lib/db'
3
+ import { requireRole } from '@/lib/auth'
4
+ import { deliverWebhookPublic } from '@/lib/webhooks'
5
+ import { logger } from '@/lib/logger'
6
+
7
+ /**
8
+ * POST /api/webhooks/retry - Manually retry a failed delivery
9
+ */
10
+ export async function POST(request: NextRequest) {
11
+ const auth = requireRole(request, 'admin')
12
+ if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
13
+
14
+ try {
15
+ const db = getDatabase()
16
+ const { delivery_id } = await request.json()
17
+
18
+ if (!delivery_id) {
19
+ return NextResponse.json({ error: 'delivery_id is required' }, { status: 400 })
20
+ }
21
+
22
+ const delivery = db.prepare(`
23
+ SELECT wd.*, w.id as w_id, w.name as w_name, w.url as w_url, w.secret as w_secret,
24
+ w.events as w_events, w.enabled as w_enabled
25
+ FROM webhook_deliveries wd
26
+ JOIN webhooks w ON w.id = wd.webhook_id
27
+ WHERE wd.id = ?
28
+ `).get(delivery_id) as any
29
+
30
+ if (!delivery) {
31
+ return NextResponse.json({ error: 'Delivery not found' }, { status: 404 })
32
+ }
33
+
34
+ const webhook = {
35
+ id: delivery.w_id,
36
+ name: delivery.w_name,
37
+ url: delivery.w_url,
38
+ secret: delivery.w_secret,
39
+ events: delivery.w_events,
40
+ enabled: delivery.w_enabled,
41
+ }
42
+
43
+ // Parse the original payload
44
+ let parsedPayload: Record<string, any>
45
+ try {
46
+ const parsed = JSON.parse(delivery.payload)
47
+ parsedPayload = parsed.data ?? parsed
48
+ } catch {
49
+ parsedPayload = {}
50
+ }
51
+
52
+ const result = await deliverWebhookPublic(webhook, delivery.event_type, parsedPayload, {
53
+ attempt: (delivery.attempt ?? 0) + 1,
54
+ parentDeliveryId: delivery.id,
55
+ allowRetry: false, // Manual retries don't auto-schedule further retries
56
+ })
57
+
58
+ return NextResponse.json(result)
59
+ } catch (error) {
60
+ logger.error({ err: error }, 'POST /api/webhooks/retry error')
61
+ return NextResponse.json({ error: 'Failed to retry delivery' }, { status: 500 })
62
+ }
63
+ }
src/app/api/webhooks/route.ts CHANGED
@@ -24,12 +24,15 @@ export async function GET(request: NextRequest) {
24
  ORDER BY w.created_at DESC
25
  `).all() as any[]
26
 
27
- // Parse events JSON, mask secret
 
28
  const result = webhooks.map((wh) => ({
29
  ...wh,
30
  events: JSON.parse(wh.events || '["*"]'),
31
  secret: wh.secret ? '••••••' + wh.secret.slice(-4) : null,
32
  enabled: !!wh.enabled,
 
 
33
  }))
34
 
35
  return NextResponse.json({ webhooks: result })
@@ -92,7 +95,7 @@ export async function PUT(request: NextRequest) {
92
  try {
93
  const db = getDatabase()
94
  const body = await request.json()
95
- const { id, name, url, events, enabled, regenerate_secret } = body
96
 
97
  if (!id) {
98
  return NextResponse.json({ error: 'Webhook ID is required' }, { status: 400 })
@@ -117,6 +120,12 @@ export async function PUT(request: NextRequest) {
117
  if (events !== undefined) { updates.push('events = ?'); params.push(JSON.stringify(events)) }
118
  if (enabled !== undefined) { updates.push('enabled = ?'); params.push(enabled ? 1 : 0) }
119
 
 
 
 
 
 
 
120
  let newSecret: string | null = null
121
  if (regenerate_secret) {
122
  newSecret = randomBytes(32).toString('hex')
 
24
  ORDER BY w.created_at DESC
25
  `).all() as any[]
26
 
27
+ // Parse events JSON, mask secret, add circuit breaker status
28
+ const maxRetries = parseInt(process.env.MC_WEBHOOK_MAX_RETRIES || '5', 10) || 5
29
  const result = webhooks.map((wh) => ({
30
  ...wh,
31
  events: JSON.parse(wh.events || '["*"]'),
32
  secret: wh.secret ? '••••••' + wh.secret.slice(-4) : null,
33
  enabled: !!wh.enabled,
34
+ consecutive_failures: wh.consecutive_failures ?? 0,
35
+ circuit_open: (wh.consecutive_failures ?? 0) >= maxRetries,
36
  }))
37
 
38
  return NextResponse.json({ webhooks: result })
 
95
  try {
96
  const db = getDatabase()
97
  const body = await request.json()
98
+ const { id, name, url, events, enabled, regenerate_secret, reset_circuit } = body
99
 
100
  if (!id) {
101
  return NextResponse.json({ error: 'Webhook ID is required' }, { status: 400 })
 
120
  if (events !== undefined) { updates.push('events = ?'); params.push(JSON.stringify(events)) }
121
  if (enabled !== undefined) { updates.push('enabled = ?'); params.push(enabled ? 1 : 0) }
122
 
123
+ // Reset circuit breaker: clear failure count and re-enable
124
+ if (reset_circuit) {
125
+ updates.push('consecutive_failures = 0')
126
+ updates.push('enabled = 1')
127
+ }
128
+
129
  let newSecret: string | null = null
130
  if (regenerate_secret) {
131
  newSecret = randomBytes(32).toString('hex')
src/app/api/webhooks/test/route.ts CHANGED
@@ -1,7 +1,8 @@
1
  import { NextRequest, NextResponse } from 'next/server'
2
  import { getDatabase } from '@/lib/db'
3
  import { requireRole } from '@/lib/auth'
4
- import { createHmac } from 'crypto'
 
5
 
6
  /**
7
  * POST /api/webhooks/test - Send a test event to a webhook
@@ -23,78 +24,18 @@ export async function POST(request: NextRequest) {
23
  return NextResponse.json({ error: 'Webhook not found' }, { status: 404 })
24
  }
25
 
26
- const body = JSON.stringify({
27
- event: 'test.ping',
28
- timestamp: Math.floor(Date.now() / 1000),
29
- data: {
30
- message: 'This is a test webhook from Mission Control',
31
- webhook_id: webhook.id,
32
- webhook_name: webhook.name,
33
- triggered_by: auth.user.username,
34
- },
35
- })
36
-
37
- const headers: Record<string, string> = {
38
- 'Content-Type': 'application/json',
39
- 'User-Agent': 'MissionControl-Webhook/1.0',
40
- 'X-MC-Event': 'test.ping',
41
- }
42
-
43
- if (webhook.secret) {
44
- const sig = createHmac('sha256', webhook.secret).update(body).digest('hex')
45
- headers['X-MC-Signature'] = `sha256=${sig}`
46
  }
47
 
48
- const start = Date.now()
49
- let statusCode: number | null = null
50
- let responseBody: string | null = null
51
- let error: string | null = null
52
-
53
- try {
54
- const controller = new AbortController()
55
- const timeout = setTimeout(() => controller.abort(), 10000)
56
-
57
- const res = await fetch(webhook.url, {
58
- method: 'POST',
59
- headers,
60
- body,
61
- signal: controller.signal,
62
- })
63
-
64
- clearTimeout(timeout)
65
- statusCode = res.status
66
- responseBody = await res.text().catch(() => null)
67
- if (responseBody && responseBody.length > 1000) {
68
- responseBody = responseBody.slice(0, 1000) + '...'
69
- }
70
- } catch (err: any) {
71
- error = err.name === 'AbortError' ? 'Timeout (10s)' : err.message
72
- }
73
-
74
- const durationMs = Date.now() - start
75
-
76
- // Log the test delivery
77
- db.prepare(`
78
- INSERT INTO webhook_deliveries (webhook_id, event_type, payload, status_code, response_body, error, duration_ms)
79
- VALUES (?, ?, ?, ?, ?, ?, ?)
80
- `).run(webhook.id, 'test.ping', body, statusCode, responseBody, error, durationMs)
81
-
82
- db.prepare(`
83
- UPDATE webhooks SET last_fired_at = unixepoch(), last_status = ?, updated_at = unixepoch()
84
- WHERE id = ?
85
- `).run(statusCode ?? -1, webhook.id)
86
-
87
- const success = statusCode !== null && statusCode >= 200 && statusCode < 300
88
 
89
- return NextResponse.json({
90
- success,
91
- status_code: statusCode,
92
- response_body: responseBody,
93
- error,
94
- duration_ms: durationMs,
95
- })
96
  } catch (error) {
97
- console.error('POST /api/webhooks/test error:', error)
98
  return NextResponse.json({ error: 'Failed to test webhook' }, { status: 500 })
99
  }
100
  }
 
1
  import { NextRequest, NextResponse } from 'next/server'
2
  import { getDatabase } from '@/lib/db'
3
  import { requireRole } from '@/lib/auth'
4
+ import { deliverWebhookPublic } from '@/lib/webhooks'
5
+ import { logger } from '@/lib/logger'
6
 
7
  /**
8
  * POST /api/webhooks/test - Send a test event to a webhook
 
24
  return NextResponse.json({ error: 'Webhook not found' }, { status: 404 })
25
  }
26
 
27
+ const payload = {
28
+ message: 'This is a test webhook from Mission Control',
29
+ webhook_id: webhook.id,
30
+ webhook_name: webhook.name,
31
+ triggered_by: auth.user.username,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
32
  }
33
 
34
+ const result = await deliverWebhookPublic(webhook, 'test.ping', payload, { allowRetry: false })
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
35
 
36
+ return NextResponse.json(result)
 
 
 
 
 
 
37
  } catch (error) {
38
+ logger.error({ err: error }, 'POST /api/webhooks/test error')
39
  return NextResponse.json({ error: 'Failed to test webhook' }, { status: 500 })
40
  }
41
  }
src/app/api/webhooks/verify-docs/route.ts ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextRequest, NextResponse } from 'next/server'
2
+ import { requireRole } from '@/lib/auth'
3
+
4
+ /**
5
+ * GET /api/webhooks/verify-docs - Returns webhook signature verification documentation
6
+ * No secrets exposed. Accessible to any authenticated user (viewer+).
7
+ */
8
+ export async function GET(request: NextRequest) {
9
+ const auth = requireRole(request, 'viewer')
10
+ if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
11
+
12
+ return NextResponse.json({
13
+ algorithm: 'HMAC-SHA256',
14
+ header: 'X-MC-Signature',
15
+ format: 'sha256=<hex-digest>',
16
+ description: 'Mission Control signs webhook payloads using HMAC-SHA256. The signature is sent in the X-MC-Signature header.',
17
+ verification_steps: [
18
+ '1. Extract the raw request body as a UTF-8 string (do NOT parse JSON first).',
19
+ '2. Read the X-MC-Signature header value.',
20
+ '3. Compute HMAC-SHA256 of the raw body using your webhook secret.',
21
+ '4. Format the expected value as: sha256=<hex-digest>',
22
+ '5. Compare the computed value with the header using a constant-time comparison.',
23
+ '6. Reject the request if they do not match.',
24
+ ],
25
+ example_nodejs: `
26
+ const crypto = require('crypto');
27
+
28
+ function verifySignature(secret, rawBody, signatureHeader) {
29
+ const expected = 'sha256=' + crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
30
+ const sigBuf = Buffer.from(signatureHeader);
31
+ const expBuf = Buffer.from(expected);
32
+ if (sigBuf.length !== expBuf.length) return false;
33
+ return crypto.timingSafeEqual(sigBuf, expBuf);
34
+ }
35
+
36
+ // In your Express/Fastify handler:
37
+ // const isValid = verifySignature(MY_SECRET, req.rawBody, req.headers['x-mc-signature']);
38
+ `.trim(),
39
+ })
40
+ }
src/app/api/workflows/route.ts CHANGED
@@ -3,6 +3,7 @@ import { getDatabase, db_helpers } from '@/lib/db'
3
  import { requireRole } from '@/lib/auth'
4
  import { validateBody, createWorkflowSchema } from '@/lib/validation'
5
  import { mutationLimiter } from '@/lib/rate-limit'
 
6
 
7
  export interface WorkflowTemplate {
8
  id: number
@@ -38,7 +39,7 @@ export async function GET(request: NextRequest) {
38
 
39
  return NextResponse.json({ templates: parsed })
40
  } catch (error) {
41
- console.error('GET /api/workflows error:', error)
42
  return NextResponse.json({ error: 'Failed to fetch templates' }, { status: 500 })
43
  }
44
  }
@@ -74,7 +75,7 @@ export async function POST(request: NextRequest) {
74
  template: { ...template, tags: template.tags ? JSON.parse(template.tags) : [] }
75
  }, { status: 201 })
76
  } catch (error) {
77
- console.error('POST /api/workflows error:', error)
78
  return NextResponse.json({ error: 'Failed to create template' }, { status: 500 })
79
  }
80
  }
@@ -127,7 +128,7 @@ export async function PUT(request: NextRequest) {
127
  const updated = db.prepare('SELECT * FROM workflow_templates WHERE id = ?').get(id) as WorkflowTemplate
128
  return NextResponse.json({ template: { ...updated, tags: updated.tags ? JSON.parse(updated.tags) : [] } })
129
  } catch (error) {
130
- console.error('PUT /api/workflows error:', error)
131
  return NextResponse.json({ error: 'Failed to update template' }, { status: 500 })
132
  }
133
  }
@@ -152,7 +153,7 @@ export async function DELETE(request: NextRequest) {
152
  db.prepare('DELETE FROM workflow_templates WHERE id = ?').run(parseInt(id))
153
  return NextResponse.json({ success: true })
154
  } catch (error) {
155
- console.error('DELETE /api/workflows error:', error)
156
  return NextResponse.json({ error: 'Failed to delete template' }, { status: 500 })
157
  }
158
  }
 
3
  import { requireRole } from '@/lib/auth'
4
  import { validateBody, createWorkflowSchema } from '@/lib/validation'
5
  import { mutationLimiter } from '@/lib/rate-limit'
6
+ import { logger } from '@/lib/logger'
7
 
8
  export interface WorkflowTemplate {
9
  id: number
 
39
 
40
  return NextResponse.json({ templates: parsed })
41
  } catch (error) {
42
+ logger.error({ err: error }, 'GET /api/workflows error')
43
  return NextResponse.json({ error: 'Failed to fetch templates' }, { status: 500 })
44
  }
45
  }
 
75
  template: { ...template, tags: template.tags ? JSON.parse(template.tags) : [] }
76
  }, { status: 201 })
77
  } catch (error) {
78
+ logger.error({ err: error }, 'POST /api/workflows error')
79
  return NextResponse.json({ error: 'Failed to create template' }, { status: 500 })
80
  }
81
  }
 
128
  const updated = db.prepare('SELECT * FROM workflow_templates WHERE id = ?').get(id) as WorkflowTemplate
129
  return NextResponse.json({ template: { ...updated, tags: updated.tags ? JSON.parse(updated.tags) : [] } })
130
  } catch (error) {
131
+ logger.error({ err: error }, 'PUT /api/workflows error')
132
  return NextResponse.json({ error: 'Failed to update template' }, { status: 500 })
133
  }
134
  }
 
153
  db.prepare('DELETE FROM workflow_templates WHERE id = ?').run(parseInt(id))
154
  return NextResponse.json({ success: true })
155
  } catch (error) {
156
+ logger.error({ err: error }, 'DELETE /api/workflows error')
157
  return NextResponse.json({ error: 'Failed to delete template' }, { status: 500 })
158
  }
159
  }
src/lib/__tests__/rate-limit.test.ts CHANGED
@@ -12,7 +12,7 @@ describe('createRateLimiter', () => {
12
 
13
  function makeRequest(ip: string = '127.0.0.1'): Request {
14
  return new Request('http://localhost/api/test', {
15
- headers: new Headers({ 'x-forwarded-for': ip }),
16
  })
17
  }
18
 
 
12
 
13
  function makeRequest(ip: string = '127.0.0.1'): Request {
14
  return new Request('http://localhost/api/test', {
15
+ headers: new Headers({ 'x-real-ip': ip }),
16
  })
17
  }
18
 
src/lib/__tests__/validation.test.ts CHANGED
@@ -130,7 +130,7 @@ describe('createUserSchema', () => {
130
  it('accepts valid input', () => {
131
  const result = createUserSchema.safeParse({
132
  username: 'alice',
133
- password: 'secret123',
134
  })
135
  expect(result.success).toBe(true)
136
  if (result.success) {
 
130
  it('accepts valid input', () => {
131
  const result = createUserSchema.safeParse({
132
  username: 'alice',
133
+ password: 'secure-pass-12chars',
134
  })
135
  expect(result.success).toBe(true)
136
  if (result.success) {
src/lib/__tests__/webhooks.test.ts ADDED
@@ -0,0 +1,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, expect, it } from 'vitest'
2
+ import { createHmac } from 'crypto'
3
+ import { verifyWebhookSignature, nextRetryDelay } from '../webhooks'
4
+
5
+ describe('verifyWebhookSignature', () => {
6
+ const secret = 'test-secret-key-1234'
7
+ const body = '{"event":"test.ping","timestamp":1700000000,"data":{"message":"hello"}}'
8
+
9
+ it('returns true for a correct signature', () => {
10
+ const sig = `sha256=${createHmac('sha256', secret).update(body).digest('hex')}`
11
+ expect(verifyWebhookSignature(secret, body, sig)).toBe(true)
12
+ })
13
+
14
+ it('returns false for a wrong signature', () => {
15
+ const wrongSig = `sha256=${createHmac('sha256', 'wrong-secret').update(body).digest('hex')}`
16
+ expect(verifyWebhookSignature(secret, body, wrongSig)).toBe(false)
17
+ })
18
+
19
+ it('returns false for a tampered body', () => {
20
+ const sig = `sha256=${createHmac('sha256', secret).update(body).digest('hex')}`
21
+ expect(verifyWebhookSignature(secret, body + 'tampered', sig)).toBe(false)
22
+ })
23
+
24
+ it('returns false for missing signature header', () => {
25
+ expect(verifyWebhookSignature(secret, body, null)).toBe(false)
26
+ expect(verifyWebhookSignature(secret, body, undefined)).toBe(false)
27
+ expect(verifyWebhookSignature(secret, body, '')).toBe(false)
28
+ })
29
+
30
+ it('returns false for empty secret', () => {
31
+ const sig = `sha256=${createHmac('sha256', secret).update(body).digest('hex')}`
32
+ expect(verifyWebhookSignature('', body, sig)).toBe(false)
33
+ })
34
+ })
35
+
36
+ describe('nextRetryDelay', () => {
37
+ // Expected base delays: 30s, 300s, 1800s, 7200s, 28800s
38
+ const expectedBases = [30, 300, 1800, 7200, 28800]
39
+
40
+ it('returns delays within ±20% jitter range for each attempt', () => {
41
+ for (let attempt = 0; attempt < expectedBases.length; attempt++) {
42
+ const base = expectedBases[attempt]
43
+ const minExpected = base * 0.8
44
+ const maxExpected = base * 1.2
45
+
46
+ // Run multiple times to test jitter randomness
47
+ for (let i = 0; i < 20; i++) {
48
+ const delay = nextRetryDelay(attempt)
49
+ expect(delay).toBeGreaterThanOrEqual(Math.floor(minExpected))
50
+ expect(delay).toBeLessThanOrEqual(Math.ceil(maxExpected))
51
+ }
52
+ }
53
+ })
54
+
55
+ it('clamps attempts beyond the backoff array length', () => {
56
+ const lastBase = expectedBases[expectedBases.length - 1]
57
+ const delay = nextRetryDelay(100)
58
+ expect(delay).toBeGreaterThanOrEqual(Math.floor(lastBase * 0.8))
59
+ expect(delay).toBeLessThanOrEqual(Math.ceil(lastBase * 1.2))
60
+ })
61
+
62
+ it('returns a rounded integer', () => {
63
+ for (let i = 0; i < 50; i++) {
64
+ const delay = nextRetryDelay(0)
65
+ expect(Number.isInteger(delay)).toBe(true)
66
+ }
67
+ })
68
+ })
69
+
70
+ describe('circuit breaker logic', () => {
71
+ it('consecutive_failures >= maxRetries means circuit is open', () => {
72
+ const maxRetries = 5
73
+ // Simulate the circuit_open derivation used in the API
74
+ const isCircuitOpen = (failures: number) => failures >= maxRetries
75
+
76
+ expect(isCircuitOpen(0)).toBe(false)
77
+ expect(isCircuitOpen(3)).toBe(false)
78
+ expect(isCircuitOpen(4)).toBe(false)
79
+ expect(isCircuitOpen(5)).toBe(true)
80
+ expect(isCircuitOpen(10)).toBe(true)
81
+ })
82
+ })
src/lib/auth.ts CHANGED
@@ -10,7 +10,9 @@ export function safeCompare(a: string, b: string): boolean {
10
  const bufA = Buffer.from(a)
11
  const bufB = Buffer.from(b)
12
  if (bufA.length !== bufB.length) {
13
- timingSafeEqual(bufA, bufA)
 
 
14
  return false
15
  }
16
  return timingSafeEqual(bufA, bufB)
@@ -176,6 +178,7 @@ export function createUser(
176
  options?: { provider?: 'local' | 'google'; provider_user_id?: string | null; email?: string | null; avatar_url?: string | null; is_approved?: 0 | 1; approved_by?: string | null; approved_at?: number | null }
177
  ): User {
178
  const db = getDatabase()
 
179
  const passwordHash = hashPassword(password)
180
  const provider = options?.provider || 'local'
181
  const result = db.prepare(`
 
10
  const bufA = Buffer.from(a)
11
  const bufB = Buffer.from(b)
12
  if (bufA.length !== bufB.length) {
13
+ // Compare against dummy buffer to avoid timing leak on length mismatch
14
+ const dummy = Buffer.alloc(bufA.length)
15
+ timingSafeEqual(bufA, dummy)
16
  return false
17
  }
18
  return timingSafeEqual(bufA, bufB)
 
178
  options?: { provider?: 'local' | 'google'; provider_user_id?: string | null; email?: string | null; avatar_url?: string | null; is_approved?: 0 | 1; approved_by?: string | null; approved_at?: number | null }
179
  ): User {
180
  const db = getDatabase()
181
+ if (password.length < 12) throw new Error('Password must be at least 12 characters')
182
  const passwordHash = hashPassword(password)
183
  const provider = options?.provider || 'local'
184
  const result = db.prepare(`
src/lib/claude-sessions.ts ADDED
@@ -0,0 +1,298 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Claude Code Local Session Scanner
3
+ *
4
+ * Discovers and tracks local Claude Code sessions by scanning ~/.claude/projects/.
5
+ * Each project directory contains JSONL session transcripts that record every
6
+ * user message, assistant response, and tool call with timestamps and token usage.
7
+ *
8
+ * This module parses those JSONL files to extract:
9
+ * - Session metadata (model, project, git branch, timestamps)
10
+ * - Message counts (user, assistant, tool uses)
11
+ * - Token usage (input, output, estimated cost)
12
+ * - Activity status (active if last message < 5 minutes ago)
13
+ */
14
+
15
+ import { readdirSync, readFileSync, statSync } from 'fs'
16
+ import { join } from 'path'
17
+ import { config } from './config'
18
+ import { getDatabase } from './db'
19
+ import { logger } from './logger'
20
+
21
+ // Rough per-token pricing (USD) for cost estimation
22
+ const MODEL_PRICING: Record<string, { input: number; output: number }> = {
23
+ 'claude-opus-4-6': { input: 15 / 1_000_000, output: 75 / 1_000_000 },
24
+ 'claude-sonnet-4-6': { input: 3 / 1_000_000, output: 15 / 1_000_000 },
25
+ 'claude-haiku-4-5': { input: 0.8 / 1_000_000, output: 4 / 1_000_000 },
26
+ }
27
+
28
+ const DEFAULT_PRICING = { input: 3 / 1_000_000, output: 15 / 1_000_000 }
29
+
30
+ // Session is "active" if last message was within this window
31
+ const ACTIVE_THRESHOLD_MS = 5 * 60 * 1000
32
+
33
+ interface SessionStats {
34
+ sessionId: string
35
+ projectSlug: string
36
+ projectPath: string | null
37
+ model: string | null
38
+ gitBranch: string | null
39
+ userMessages: number
40
+ assistantMessages: number
41
+ toolUses: number
42
+ inputTokens: number
43
+ outputTokens: number
44
+ estimatedCost: number
45
+ firstMessageAt: string | null
46
+ lastMessageAt: string | null
47
+ lastUserPrompt: string | null
48
+ isActive: boolean
49
+ }
50
+
51
+ interface JSONLEntry {
52
+ type?: string
53
+ sessionId?: string
54
+ timestamp?: string
55
+ isSidechain?: boolean
56
+ gitBranch?: string
57
+ cwd?: string
58
+ message?: {
59
+ role?: string
60
+ content?: string | Array<{ type: string; text?: string; id?: string }>
61
+ model?: string
62
+ usage?: {
63
+ input_tokens?: number
64
+ output_tokens?: number
65
+ cache_read_input_tokens?: number
66
+ cache_creation_input_tokens?: number
67
+ }
68
+ }
69
+ }
70
+
71
+ /** Parse a single JSONL file and extract session stats */
72
+ function parseSessionFile(filePath: string, projectSlug: string): SessionStats | null {
73
+ try {
74
+ const content = readFileSync(filePath, 'utf-8')
75
+ const lines = content.split('\n').filter(Boolean)
76
+
77
+ if (lines.length === 0) return null
78
+
79
+ let sessionId: string | null = null
80
+ let model: string | null = null
81
+ let gitBranch: string | null = null
82
+ let projectPath: string | null = null
83
+ let userMessages = 0
84
+ let assistantMessages = 0
85
+ let toolUses = 0
86
+ let inputTokens = 0
87
+ let outputTokens = 0
88
+ let firstMessageAt: string | null = null
89
+ let lastMessageAt: string | null = null
90
+ let lastUserPrompt: string | null = null
91
+
92
+ for (const line of lines) {
93
+ let entry: JSONLEntry
94
+ try {
95
+ entry = JSON.parse(line)
96
+ } catch {
97
+ continue
98
+ }
99
+
100
+ // Extract session ID from first entry that has one
101
+ if (!sessionId && entry.sessionId) {
102
+ sessionId = entry.sessionId
103
+ }
104
+
105
+ // Extract git branch
106
+ if (!gitBranch && entry.gitBranch) {
107
+ gitBranch = entry.gitBranch
108
+ }
109
+
110
+ // Extract project working directory
111
+ if (!projectPath && entry.cwd) {
112
+ projectPath = entry.cwd
113
+ }
114
+
115
+ // Track timestamps
116
+ if (entry.timestamp) {
117
+ if (!firstMessageAt) firstMessageAt = entry.timestamp
118
+ lastMessageAt = entry.timestamp
119
+ }
120
+
121
+ // Skip sidechain messages (subagent work) for counts
122
+ if (entry.isSidechain) continue
123
+
124
+ if (entry.type === 'user' && entry.message) {
125
+ userMessages++
126
+ // Extract last user prompt text
127
+ const msg = entry.message
128
+ if (typeof msg.content === 'string' && msg.content.length > 0) {
129
+ lastUserPrompt = msg.content.slice(0, 500)
130
+ }
131
+ }
132
+
133
+ if (entry.type === 'assistant' && entry.message) {
134
+ assistantMessages++
135
+
136
+ // Extract model
137
+ if (entry.message.model) {
138
+ model = entry.message.model
139
+ }
140
+
141
+ // Extract token usage
142
+ const usage = entry.message.usage
143
+ if (usage) {
144
+ inputTokens += (usage.input_tokens || 0)
145
+ + (usage.cache_read_input_tokens || 0)
146
+ + (usage.cache_creation_input_tokens || 0)
147
+ outputTokens += (usage.output_tokens || 0)
148
+ }
149
+
150
+ // Count tool uses in assistant content
151
+ if (Array.isArray(entry.message.content)) {
152
+ for (const block of entry.message.content) {
153
+ if (block.type === 'tool_use') toolUses++
154
+ }
155
+ }
156
+ }
157
+ }
158
+
159
+ if (!sessionId) return null
160
+
161
+ // Estimate cost
162
+ const pricing = (model && MODEL_PRICING[model]) || DEFAULT_PRICING
163
+ const estimatedCost = inputTokens * pricing.input + outputTokens * pricing.output
164
+
165
+ // Determine if active
166
+ const isActive = lastMessageAt
167
+ ? (Date.now() - new Date(lastMessageAt).getTime()) < ACTIVE_THRESHOLD_MS
168
+ : false
169
+
170
+ return {
171
+ sessionId,
172
+ projectSlug,
173
+ projectPath,
174
+ model,
175
+ gitBranch,
176
+ userMessages,
177
+ assistantMessages,
178
+ toolUses,
179
+ inputTokens,
180
+ outputTokens,
181
+ estimatedCost: Math.round(estimatedCost * 10000) / 10000,
182
+ firstMessageAt,
183
+ lastMessageAt,
184
+ lastUserPrompt,
185
+ isActive,
186
+ }
187
+ } catch (err) {
188
+ logger.warn({ err, filePath }, 'Failed to parse Claude session file')
189
+ return null
190
+ }
191
+ }
192
+
193
+ /** Scan all Claude Code projects and discover sessions */
194
+ export function scanClaudeSessions(): SessionStats[] {
195
+ const claudeHome = config.claudeHome
196
+ if (!claudeHome) return []
197
+
198
+ const projectsDir = join(claudeHome, 'projects')
199
+ let projectDirs: string[]
200
+ try {
201
+ projectDirs = readdirSync(projectsDir)
202
+ } catch {
203
+ return [] // No projects directory — Claude Code not installed or never used
204
+ }
205
+
206
+ const sessions: SessionStats[] = []
207
+
208
+ for (const projectSlug of projectDirs) {
209
+ const projectDir = join(projectsDir, projectSlug)
210
+
211
+ let stat
212
+ try {
213
+ stat = statSync(projectDir)
214
+ } catch {
215
+ continue
216
+ }
217
+ if (!stat.isDirectory()) continue
218
+
219
+ // Find JSONL files in this project
220
+ let files: string[]
221
+ try {
222
+ files = readdirSync(projectDir).filter(f => f.endsWith('.jsonl'))
223
+ } catch {
224
+ continue
225
+ }
226
+
227
+ for (const file of files) {
228
+ const filePath = join(projectDir, file)
229
+ const parsed = parseSessionFile(filePath, projectSlug)
230
+ if (parsed) sessions.push(parsed)
231
+ }
232
+ }
233
+
234
+ return sessions
235
+ }
236
+
237
+ /** Scan and upsert sessions into the database */
238
+ export async function syncClaudeSessions(): Promise<{ ok: boolean; message: string }> {
239
+ try {
240
+ const sessions = scanClaudeSessions()
241
+ if (sessions.length === 0) {
242
+ return { ok: true, message: 'No Claude sessions found' }
243
+ }
244
+
245
+ const db = getDatabase()
246
+ const now = Math.floor(Date.now() / 1000)
247
+
248
+ const upsert = db.prepare(`
249
+ INSERT INTO claude_sessions (
250
+ session_id, project_slug, project_path, model, git_branch,
251
+ user_messages, assistant_messages, tool_uses,
252
+ input_tokens, output_tokens, estimated_cost,
253
+ first_message_at, last_message_at, last_user_prompt,
254
+ is_active, scanned_at, updated_at
255
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
256
+ ON CONFLICT(session_id) DO UPDATE SET
257
+ model = excluded.model,
258
+ git_branch = excluded.git_branch,
259
+ user_messages = excluded.user_messages,
260
+ assistant_messages = excluded.assistant_messages,
261
+ tool_uses = excluded.tool_uses,
262
+ input_tokens = excluded.input_tokens,
263
+ output_tokens = excluded.output_tokens,
264
+ estimated_cost = excluded.estimated_cost,
265
+ last_message_at = excluded.last_message_at,
266
+ last_user_prompt = excluded.last_user_prompt,
267
+ is_active = excluded.is_active,
268
+ scanned_at = excluded.scanned_at,
269
+ updated_at = excluded.updated_at
270
+ `)
271
+
272
+ let upserted = 0
273
+ db.transaction(() => {
274
+ // Mark all sessions inactive before scanning
275
+ db.prepare('UPDATE claude_sessions SET is_active = 0').run()
276
+
277
+ for (const s of sessions) {
278
+ upsert.run(
279
+ s.sessionId, s.projectSlug, s.projectPath, s.model, s.gitBranch,
280
+ s.userMessages, s.assistantMessages, s.toolUses,
281
+ s.inputTokens, s.outputTokens, s.estimatedCost,
282
+ s.firstMessageAt, s.lastMessageAt, s.lastUserPrompt,
283
+ s.isActive ? 1 : 0, now, now,
284
+ )
285
+ upserted++
286
+ }
287
+ })()
288
+
289
+ const active = sessions.filter(s => s.isActive).length
290
+ return {
291
+ ok: true,
292
+ message: `Scanned ${upserted} session(s), ${active} active`,
293
+ }
294
+ } catch (err: any) {
295
+ logger.error({ err }, 'Claude session sync failed')
296
+ return { ok: false, message: `Scan failed: ${err.message}` }
297
+ }
298
+ }
src/lib/config.ts CHANGED
@@ -10,6 +10,9 @@ const openclawHome =
10
  ''
11
 
12
  export const config = {
 
 
 
13
  dataDir: process.env.MISSION_CONTROL_DATA_DIR || defaultDataDir,
14
  dbPath:
15
  process.env.MISSION_CONTROL_DB_PATH ||
 
10
  ''
11
 
12
  export const config = {
13
+ claudeHome:
14
+ process.env.MC_CLAUDE_HOME ||
15
+ path.join(os.homedir(), '.claude'),
16
  dataDir: process.env.MISSION_CONTROL_DATA_DIR || defaultDataDir,
17
  dbPath:
18
  process.env.MISSION_CONTROL_DB_PATH ||
src/lib/migrations.ts CHANGED
@@ -495,6 +495,58 @@ const migrations: Migration[] = [
495
  CREATE INDEX IF NOT EXISTS idx_token_usage_model ON token_usage(model);
496
  `)
497
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
498
  }
499
  ]
500
 
 
495
  CREATE INDEX IF NOT EXISTS idx_token_usage_model ON token_usage(model);
496
  `)
497
  }
498
+ },
499
+ {
500
+ id: '019_webhook_retry',
501
+ up: (db) => {
502
+ // Add retry columns to webhook_deliveries
503
+ const deliveryCols = db.prepare(`PRAGMA table_info(webhook_deliveries)`).all() as Array<{ name: string }>
504
+ const hasCol = (name: string) => deliveryCols.some((c) => c.name === name)
505
+
506
+ if (!hasCol('attempt')) db.exec(`ALTER TABLE webhook_deliveries ADD COLUMN attempt INTEGER NOT NULL DEFAULT 0`)
507
+ if (!hasCol('next_retry_at')) db.exec(`ALTER TABLE webhook_deliveries ADD COLUMN next_retry_at INTEGER`)
508
+ if (!hasCol('is_retry')) db.exec(`ALTER TABLE webhook_deliveries ADD COLUMN is_retry INTEGER NOT NULL DEFAULT 0`)
509
+ if (!hasCol('parent_delivery_id')) db.exec(`ALTER TABLE webhook_deliveries ADD COLUMN parent_delivery_id INTEGER`)
510
+
511
+ // Add circuit breaker column to webhooks
512
+ const webhookCols = db.prepare(`PRAGMA table_info(webhooks)`).all() as Array<{ name: string }>
513
+ if (!webhookCols.some((c) => c.name === 'consecutive_failures')) {
514
+ db.exec(`ALTER TABLE webhooks ADD COLUMN consecutive_failures INTEGER NOT NULL DEFAULT 0`)
515
+ }
516
+
517
+ // Partial index for retry queue processing
518
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_webhook_deliveries_retry ON webhook_deliveries(next_retry_at) WHERE next_retry_at IS NOT NULL`)
519
+ }
520
+ },
521
+ {
522
+ id: '020_claude_sessions',
523
+ up: (db) => {
524
+ db.exec(`
525
+ CREATE TABLE IF NOT EXISTS claude_sessions (
526
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
527
+ session_id TEXT NOT NULL UNIQUE,
528
+ project_slug TEXT NOT NULL,
529
+ project_path TEXT,
530
+ model TEXT,
531
+ git_branch TEXT,
532
+ user_messages INTEGER NOT NULL DEFAULT 0,
533
+ assistant_messages INTEGER NOT NULL DEFAULT 0,
534
+ tool_uses INTEGER NOT NULL DEFAULT 0,
535
+ input_tokens INTEGER NOT NULL DEFAULT 0,
536
+ output_tokens INTEGER NOT NULL DEFAULT 0,
537
+ estimated_cost REAL NOT NULL DEFAULT 0,
538
+ first_message_at TEXT,
539
+ last_message_at TEXT,
540
+ last_user_prompt TEXT,
541
+ is_active INTEGER NOT NULL DEFAULT 0,
542
+ scanned_at INTEGER NOT NULL,
543
+ created_at INTEGER NOT NULL DEFAULT (unixepoch()),
544
+ updated_at INTEGER NOT NULL DEFAULT (unixepoch())
545
+ )
546
+ `)
547
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_claude_sessions_active ON claude_sessions(is_active) WHERE is_active = 1`)
548
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_claude_sessions_project ON claude_sessions(project_slug)`)
549
+ }
550
  }
551
  ]
552
 
src/lib/rate-limit.ts CHANGED
@@ -13,6 +13,31 @@ interface RateLimiterOptions {
13
  critical?: boolean
14
  }
15
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
16
  export function createRateLimiter(options: RateLimiterOptions) {
17
  const store = new Map<string, RateLimitEntry>()
18
 
@@ -29,7 +54,7 @@ export function createRateLimiter(options: RateLimiterOptions) {
29
  return function checkRateLimit(request: Request): NextResponse | null {
30
  // Allow disabling non-critical rate limiting for E2E tests
31
  if (process.env.MC_DISABLE_RATE_LIMIT === '1' && !options.critical) return null
32
- const ip = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() || 'unknown'
33
  const now = Date.now()
34
  const entry = store.get(ip)
35
 
 
13
  critical?: boolean
14
  }
15
 
16
+ // Trusted proxy IPs (comma-separated). Only parse XFF when behind known proxies.
17
+ const TRUSTED_PROXIES = new Set(
18
+ (process.env.MC_TRUSTED_PROXIES || '').split(',').map(s => s.trim()).filter(Boolean)
19
+ )
20
+
21
+ /**
22
+ * Extract client IP from request headers.
23
+ * When MC_TRUSTED_PROXIES is set, takes the rightmost untrusted IP from x-forwarded-for.
24
+ * Without trusted proxies, falls back to x-real-ip or 'unknown'.
25
+ */
26
+ export function extractClientIp(request: Request): string {
27
+ const xff = request.headers.get('x-forwarded-for')
28
+
29
+ if (xff && TRUSTED_PROXIES.size > 0) {
30
+ // Walk the chain from right to left, skip trusted proxies, return first untrusted
31
+ const ips = xff.split(',').map(s => s.trim())
32
+ for (let i = ips.length - 1; i >= 0; i--) {
33
+ if (!TRUSTED_PROXIES.has(ips[i])) return ips[i]
34
+ }
35
+ }
36
+
37
+ // Fallback: x-real-ip (set by nginx/caddy) or 'unknown'
38
+ return request.headers.get('x-real-ip')?.trim() || 'unknown'
39
+ }
40
+
41
  export function createRateLimiter(options: RateLimiterOptions) {
42
  const store = new Map<string, RateLimitEntry>()
43
 
 
54
  return function checkRateLimit(request: Request): NextResponse | null {
55
  // Allow disabling non-critical rate limiting for E2E tests
56
  if (process.env.MC_DISABLE_RATE_LIMIT === '1' && !options.critical) return null
57
+ const ip = extractClientIp(request)
58
  const now = Date.now()
59
  const entry = store.get(ip)
60
 
src/lib/scheduler.ts CHANGED
@@ -4,6 +4,8 @@ import { config, ensureDirExists } from './config'
4
  import { join, dirname } from 'path'
5
  import { readdirSync, statSync, unlinkSync } from 'fs'
6
  import { logger } from './logger'
 
 
7
 
8
  const BACKUP_DIR = join(dirname(config.dbPath), 'backups')
9
 
@@ -246,9 +248,27 @@ export function initScheduler() {
246
  running: false,
247
  })
248
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
249
  // Start the tick loop
250
  tickInterval = setInterval(tick, TICK_MS)
251
- logger.info('Scheduler initialized - backup at ~3AM, cleanup at ~4AM, heartbeat every 5m')
252
  }
253
 
254
  /** Calculate ms until next occurrence of a given hour (UTC) */
@@ -272,13 +292,18 @@ async function tick() {
272
  // Check if this task is enabled in settings (heartbeat is always enabled)
273
  const settingKey = id === 'auto_backup' ? 'general.auto_backup'
274
  : id === 'auto_cleanup' ? 'general.auto_cleanup'
 
 
275
  : 'general.agent_heartbeat'
276
- if (!isSettingEnabled(settingKey, id === 'agent_heartbeat')) continue
 
277
 
278
  task.running = true
279
  try {
280
  const result = id === 'auto_backup' ? await runBackup()
281
  : id === 'agent_heartbeat' ? await runHeartbeatCheck()
 
 
282
  : await runCleanup()
283
  task.lastResult = { ...result, timestamp: now }
284
  } catch (err: any) {
@@ -306,11 +331,14 @@ export function getSchedulerStatus() {
306
  for (const [id, task] of tasks) {
307
  const settingKey = id === 'auto_backup' ? 'general.auto_backup'
308
  : id === 'auto_cleanup' ? 'general.auto_cleanup'
 
 
309
  : 'general.agent_heartbeat'
 
310
  result.push({
311
  id,
312
  name: task.name,
313
- enabled: isSettingEnabled(settingKey, id === 'agent_heartbeat'),
314
  lastRun: task.lastRun,
315
  nextRun: task.nextRun,
316
  running: task.running,
@@ -326,6 +354,8 @@ export async function triggerTask(taskId: string): Promise<{ ok: boolean; messag
326
  if (taskId === 'auto_backup') return runBackup()
327
  if (taskId === 'auto_cleanup') return runCleanup()
328
  if (taskId === 'agent_heartbeat') return runHeartbeatCheck()
 
 
329
  return { ok: false, message: `Unknown task: ${taskId}` }
330
  }
331
 
 
4
  import { join, dirname } from 'path'
5
  import { readdirSync, statSync, unlinkSync } from 'fs'
6
  import { logger } from './logger'
7
+ import { processWebhookRetries } from './webhooks'
8
+ import { syncClaudeSessions } from './claude-sessions'
9
 
10
  const BACKUP_DIR = join(dirname(config.dbPath), 'backups')
11
 
 
248
  running: false,
249
  })
250
 
251
+ tasks.set('webhook_retry', {
252
+ name: 'Webhook Retry',
253
+ intervalMs: TICK_MS, // Every 60s, matching scheduler tick resolution
254
+ lastRun: null,
255
+ nextRun: now + TICK_MS,
256
+ enabled: true,
257
+ running: false,
258
+ })
259
+
260
+ tasks.set('claude_session_scan', {
261
+ name: 'Claude Session Scan',
262
+ intervalMs: TICK_MS, // Every 60s — lightweight file stat checks
263
+ lastRun: null,
264
+ nextRun: now + 5_000, // First scan 5s after startup
265
+ enabled: true,
266
+ running: false,
267
+ })
268
+
269
  // Start the tick loop
270
  tickInterval = setInterval(tick, TICK_MS)
271
+ logger.info('Scheduler initialized - backup at ~3AM, cleanup at ~4AM, heartbeat every 5m, webhook retry every 60s, claude scan every 60s')
272
  }
273
 
274
  /** Calculate ms until next occurrence of a given hour (UTC) */
 
292
  // Check if this task is enabled in settings (heartbeat is always enabled)
293
  const settingKey = id === 'auto_backup' ? 'general.auto_backup'
294
  : id === 'auto_cleanup' ? 'general.auto_cleanup'
295
+ : id === 'webhook_retry' ? 'webhooks.retry_enabled'
296
+ : id === 'claude_session_scan' ? 'general.claude_session_scan'
297
  : 'general.agent_heartbeat'
298
+ const defaultEnabled = id === 'agent_heartbeat' || id === 'webhook_retry' || id === 'claude_session_scan'
299
+ if (!isSettingEnabled(settingKey, defaultEnabled)) continue
300
 
301
  task.running = true
302
  try {
303
  const result = id === 'auto_backup' ? await runBackup()
304
  : id === 'agent_heartbeat' ? await runHeartbeatCheck()
305
+ : id === 'webhook_retry' ? await processWebhookRetries()
306
+ : id === 'claude_session_scan' ? await syncClaudeSessions()
307
  : await runCleanup()
308
  task.lastResult = { ...result, timestamp: now }
309
  } catch (err: any) {
 
331
  for (const [id, task] of tasks) {
332
  const settingKey = id === 'auto_backup' ? 'general.auto_backup'
333
  : id === 'auto_cleanup' ? 'general.auto_cleanup'
334
+ : id === 'webhook_retry' ? 'webhooks.retry_enabled'
335
+ : id === 'claude_session_scan' ? 'general.claude_session_scan'
336
  : 'general.agent_heartbeat'
337
+ const defaultEnabled = id === 'agent_heartbeat' || id === 'webhook_retry' || id === 'claude_session_scan'
338
  result.push({
339
  id,
340
  name: task.name,
341
+ enabled: isSettingEnabled(settingKey, defaultEnabled),
342
  lastRun: task.lastRun,
343
  nextRun: task.nextRun,
344
  running: task.running,
 
354
  if (taskId === 'auto_backup') return runBackup()
355
  if (taskId === 'auto_cleanup') return runCleanup()
356
  if (taskId === 'agent_heartbeat') return runHeartbeatCheck()
357
+ if (taskId === 'webhook_retry') return processWebhookRetries()
358
+ if (taskId === 'claude_session_scan') return syncClaudeSessions()
359
  return { ok: false, message: `Unknown task: ${taskId}` }
360
  }
361
 
src/lib/validation.ts CHANGED
@@ -54,6 +54,13 @@ export const createAgentSchema = z.object({
54
  write_to_gateway: z.boolean().optional(),
55
  })
56
 
 
 
 
 
 
 
 
57
  export const createWebhookSchema = z.object({
58
  name: z.string().min(1, 'Name is required').max(200),
59
  url: z.string().url('Invalid URL'),
@@ -140,7 +147,7 @@ export const spawnAgentSchema = z.object({
140
 
141
  export const createUserSchema = z.object({
142
  username: z.string().min(1, 'Username is required'),
143
- password: z.string().min(1, 'Password is required'),
144
  display_name: z.string().optional(),
145
  role: z.enum(['admin', 'operator', 'viewer']).default('operator'),
146
  provider: z.enum(['local', 'google']).default('local'),
 
54
  write_to_gateway: z.boolean().optional(),
55
  })
56
 
57
+ export const bulkUpdateTaskStatusSchema = z.object({
58
+ tasks: z.array(z.object({
59
+ id: z.number().int().positive(),
60
+ status: z.enum(['inbox', 'assigned', 'in_progress', 'review', 'quality_review', 'done']),
61
+ })).min(1, 'At least one task is required').max(100),
62
+ })
63
+
64
  export const createWebhookSchema = z.object({
65
  name: z.string().min(1, 'Name is required').max(200),
66
  url: z.string().url('Invalid URL'),
 
147
 
148
  export const createUserSchema = z.object({
149
  username: z.string().min(1, 'Username is required'),
150
+ password: z.string().min(12, 'Password must be at least 12 characters'),
151
  display_name: z.string().optional(),
152
  role: z.enum(['admin', 'operator', 'viewer']).default('operator'),
153
  provider: z.enum(['local', 'google']).default('local'),
src/lib/webhooks.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { createHmac } from 'crypto'
2
  import { eventBus, type ServerEvent } from './event-bus'
3
  import { logger } from './logger'
4
 
@@ -9,8 +9,29 @@ interface Webhook {
9
  secret: string | null
10
  events: string // JSON array
11
  enabled: number
 
12
  }
13
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
14
  // Map event bus events to webhook event types
15
  const EVENT_MAP: Record<string, string> = {
16
  'activity.created': 'activity', // Dynamically becomes activity.<type>
@@ -22,6 +43,42 @@ const EVENT_MAP: Record<string, string> = {
22
  'task.deleted': 'activity.task_deleted',
23
  }
24
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
25
  /**
26
  * Subscribe to the event bus and fire webhooks for matching events.
27
  * Called once during server initialization.
@@ -92,15 +149,31 @@ async function fireWebhooksAsync(eventType: string, payload: Record<string, any>
92
  })
93
 
94
  await Promise.allSettled(
95
- matchingWebhooks.map((wh) => deliverWebhook(wh, eventType, payload))
96
  )
97
  }
98
 
 
 
 
 
 
 
 
 
 
 
 
 
 
99
  async function deliverWebhook(
100
  webhook: Webhook,
101
  eventType: string,
102
- payload: Record<string, any>
103
- ) {
 
 
 
104
  const body = JSON.stringify({
105
  event: eventType,
106
  timestamp: Math.floor(Date.now() / 1000),
@@ -146,14 +219,17 @@ async function deliverWebhook(
146
  }
147
 
148
  const durationMs = Date.now() - start
 
 
149
 
150
- // Log delivery attempt
151
  try {
152
  const { getDatabase } = await import('./db')
153
  const db = getDatabase()
154
- db.prepare(`
155
- INSERT INTO webhook_deliveries (webhook_id, event_type, payload, status_code, response_body, error, duration_ms)
156
- VALUES (?, ?, ?, ?, ?, ?, ?)
 
157
  `).run(
158
  webhook.id,
159
  eventType,
@@ -161,8 +237,12 @@ async function deliverWebhook(
161
  statusCode,
162
  responseBody,
163
  error,
164
- durationMs
 
 
 
165
  )
 
166
 
167
  // Update webhook last_fired
168
  db.prepare(`
@@ -170,6 +250,31 @@ async function deliverWebhook(
170
  WHERE id = ?
171
  `).run(statusCode ?? -1, webhook.id)
172
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
173
  // Prune old deliveries (keep last 200 per webhook)
174
  db.prepare(`
175
  DELETE FROM webhook_deliveries
@@ -177,7 +282,83 @@ async function deliverWebhook(
177
  SELECT id FROM webhook_deliveries WHERE webhook_id = ? ORDER BY created_at DESC LIMIT 200
178
  )
179
  `).run(webhook.id, webhook.id)
180
- } catch {
181
- // Silent - delivery logging is best-effort
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
182
  }
183
  }
 
1
+ import { createHmac, timingSafeEqual } from 'crypto'
2
  import { eventBus, type ServerEvent } from './event-bus'
3
  import { logger } from './logger'
4
 
 
9
  secret: string | null
10
  events: string // JSON array
11
  enabled: number
12
+ consecutive_failures?: number
13
  }
14
 
15
+ interface DeliverOpts {
16
+ attempt?: number
17
+ parentDeliveryId?: number | null
18
+ allowRetry?: boolean
19
+ }
20
+
21
+ interface DeliveryResult {
22
+ success: boolean
23
+ status_code: number | null
24
+ response_body: string | null
25
+ error: string | null
26
+ duration_ms: number
27
+ delivery_id?: number
28
+ }
29
+
30
+ // Backoff schedule in seconds: 30s, 5m, 30m, 2h, 8h
31
+ const BACKOFF_SECONDS = [30, 300, 1800, 7200, 28800]
32
+
33
+ const MAX_RETRIES = parseInt(process.env.MC_WEBHOOK_MAX_RETRIES || '5', 10) || 5
34
+
35
  // Map event bus events to webhook event types
36
  const EVENT_MAP: Record<string, string> = {
37
  'activity.created': 'activity', // Dynamically becomes activity.<type>
 
43
  'task.deleted': 'activity.task_deleted',
44
  }
45
 
46
+ /**
47
+ * Compute the next retry delay in seconds, with ±20% jitter.
48
+ */
49
+ export function nextRetryDelay(attempt: number): number {
50
+ const base = BACKOFF_SECONDS[Math.min(attempt, BACKOFF_SECONDS.length - 1)]
51
+ const jitter = base * 0.2 * (2 * Math.random() - 1) // ±20%
52
+ return Math.round(base + jitter)
53
+ }
54
+
55
+ /**
56
+ * Verify a webhook signature using constant-time comparison.
57
+ * Consumers can use this to validate incoming webhook deliveries.
58
+ */
59
+ export function verifyWebhookSignature(
60
+ secret: string,
61
+ rawBody: string,
62
+ signatureHeader: string | null | undefined
63
+ ): boolean {
64
+ if (!signatureHeader || !secret) return false
65
+
66
+ const expected = `sha256=${createHmac('sha256', secret).update(rawBody).digest('hex')}`
67
+
68
+ // Constant-time comparison
69
+ const sigBuf = Buffer.from(signatureHeader)
70
+ const expectedBuf = Buffer.from(expected)
71
+
72
+ if (sigBuf.length !== expectedBuf.length) {
73
+ // Compare expected against a dummy buffer of matching length to avoid timing leak
74
+ const dummy = Buffer.alloc(expectedBuf.length)
75
+ timingSafeEqual(expectedBuf, dummy)
76
+ return false
77
+ }
78
+
79
+ return timingSafeEqual(sigBuf, expectedBuf)
80
+ }
81
+
82
  /**
83
  * Subscribe to the event bus and fire webhooks for matching events.
84
  * Called once during server initialization.
 
149
  })
150
 
151
  await Promise.allSettled(
152
+ matchingWebhooks.map((wh) => deliverWebhook(wh, eventType, payload, { allowRetry: true }))
153
  )
154
  }
155
 
156
+ /**
157
+ * Public wrapper for API routes (test endpoint, manual retry).
158
+ * Returns delivery result fields for the response.
159
+ */
160
+ export async function deliverWebhookPublic(
161
+ webhook: Webhook,
162
+ eventType: string,
163
+ payload: Record<string, any>,
164
+ opts?: DeliverOpts
165
+ ): Promise<DeliveryResult> {
166
+ return deliverWebhook(webhook, eventType, payload, opts ?? { allowRetry: false })
167
+ }
168
+
169
  async function deliverWebhook(
170
  webhook: Webhook,
171
  eventType: string,
172
+ payload: Record<string, any>,
173
+ opts: DeliverOpts = {}
174
+ ): Promise<DeliveryResult> {
175
+ const { attempt = 0, parentDeliveryId = null, allowRetry = true } = opts
176
+
177
  const body = JSON.stringify({
178
  event: eventType,
179
  timestamp: Math.floor(Date.now() / 1000),
 
219
  }
220
 
221
  const durationMs = Date.now() - start
222
+ const success = statusCode !== null && statusCode >= 200 && statusCode < 300
223
+ let deliveryId: number | undefined
224
 
225
+ // Log delivery attempt and handle retry/circuit-breaker logic
226
  try {
227
  const { getDatabase } = await import('./db')
228
  const db = getDatabase()
229
+
230
+ const insertResult = db.prepare(`
231
+ INSERT INTO webhook_deliveries (webhook_id, event_type, payload, status_code, response_body, error, duration_ms, attempt, is_retry, parent_delivery_id)
232
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
233
  `).run(
234
  webhook.id,
235
  eventType,
 
237
  statusCode,
238
  responseBody,
239
  error,
240
+ durationMs,
241
+ attempt,
242
+ attempt > 0 ? 1 : 0,
243
+ parentDeliveryId
244
  )
245
+ deliveryId = Number(insertResult.lastInsertRowid)
246
 
247
  // Update webhook last_fired
248
  db.prepare(`
 
250
  WHERE id = ?
251
  `).run(statusCode ?? -1, webhook.id)
252
 
253
+ // Circuit breaker + retry scheduling (skip for test deliveries)
254
+ if (allowRetry) {
255
+ if (success) {
256
+ // Reset consecutive failures on success
257
+ db.prepare(`UPDATE webhooks SET consecutive_failures = 0 WHERE id = ?`).run(webhook.id)
258
+ } else {
259
+ // Increment consecutive failures
260
+ db.prepare(`UPDATE webhooks SET consecutive_failures = consecutive_failures + 1 WHERE id = ?`).run(webhook.id)
261
+
262
+ if (attempt < MAX_RETRIES - 1) {
263
+ // Schedule retry
264
+ const delaySec = nextRetryDelay(attempt)
265
+ const nextRetryAt = Math.floor(Date.now() / 1000) + delaySec
266
+ db.prepare(`UPDATE webhook_deliveries SET next_retry_at = ? WHERE id = ?`).run(nextRetryAt, deliveryId)
267
+ } else {
268
+ // Exhausted retries — trip circuit breaker
269
+ const wh = db.prepare(`SELECT consecutive_failures FROM webhooks WHERE id = ?`).get(webhook.id) as { consecutive_failures: number } | undefined
270
+ if (wh && wh.consecutive_failures >= MAX_RETRIES) {
271
+ db.prepare(`UPDATE webhooks SET enabled = 0, updated_at = unixepoch() WHERE id = ?`).run(webhook.id)
272
+ logger.warn({ webhookId: webhook.id, name: webhook.name }, 'Webhook circuit breaker tripped — disabled after exhausting retries')
273
+ }
274
+ }
275
+ }
276
+ }
277
+
278
  // Prune old deliveries (keep last 200 per webhook)
279
  db.prepare(`
280
  DELETE FROM webhook_deliveries
 
282
  SELECT id FROM webhook_deliveries WHERE webhook_id = ? ORDER BY created_at DESC LIMIT 200
283
  )
284
  `).run(webhook.id, webhook.id)
285
+ } catch (logErr) {
286
+ logger.error({ err: logErr, webhookId: webhook.id }, 'Webhook delivery logging/pruning failed')
287
+ }
288
+
289
+ return { success, status_code: statusCode, response_body: responseBody, error, duration_ms: durationMs, delivery_id: deliveryId }
290
+ }
291
+
292
+ /**
293
+ * Process pending webhook retries. Called by the scheduler.
294
+ * Picks up deliveries where next_retry_at has passed and re-delivers them.
295
+ */
296
+ export async function processWebhookRetries(): Promise<{ ok: boolean; message: string }> {
297
+ try {
298
+ const { getDatabase } = await import('./db')
299
+ const db = getDatabase()
300
+ const now = Math.floor(Date.now() / 1000)
301
+
302
+ // Find deliveries ready for retry (limit batch to 50)
303
+ const pendingRetries = db.prepare(`
304
+ SELECT wd.id, wd.webhook_id, wd.event_type, wd.payload, wd.attempt,
305
+ w.id as w_id, w.name as w_name, w.url as w_url, w.secret as w_secret,
306
+ w.events as w_events, w.enabled as w_enabled, w.consecutive_failures as w_consecutive_failures
307
+ FROM webhook_deliveries wd
308
+ JOIN webhooks w ON w.id = wd.webhook_id AND w.enabled = 1
309
+ WHERE wd.next_retry_at IS NOT NULL AND wd.next_retry_at <= ?
310
+ LIMIT 50
311
+ `).all(now) as Array<{
312
+ id: number; webhook_id: number; event_type: string; payload: string; attempt: number
313
+ w_id: number; w_name: string; w_url: string; w_secret: string | null
314
+ w_events: string; w_enabled: number; w_consecutive_failures: number
315
+ }>
316
+
317
+ if (pendingRetries.length === 0) {
318
+ return { ok: true, message: 'No pending retries' }
319
+ }
320
+
321
+ // Clear next_retry_at immediately to prevent double-processing
322
+ const clearStmt = db.prepare(`UPDATE webhook_deliveries SET next_retry_at = NULL WHERE id = ?`)
323
+ for (const row of pendingRetries) {
324
+ clearStmt.run(row.id)
325
+ }
326
+
327
+ // Re-deliver each
328
+ let succeeded = 0
329
+ let failed = 0
330
+ for (const row of pendingRetries) {
331
+ const webhook: Webhook = {
332
+ id: row.w_id,
333
+ name: row.w_name,
334
+ url: row.w_url,
335
+ secret: row.w_secret,
336
+ events: row.w_events,
337
+ enabled: row.w_enabled,
338
+ consecutive_failures: row.w_consecutive_failures,
339
+ }
340
+
341
+ // Parse the original payload from the stored JSON body
342
+ let parsedPayload: Record<string, any>
343
+ try {
344
+ const parsed = JSON.parse(row.payload)
345
+ parsedPayload = parsed.data ?? parsed
346
+ } catch {
347
+ parsedPayload = {}
348
+ }
349
+
350
+ const result = await deliverWebhook(webhook, row.event_type, parsedPayload, {
351
+ attempt: row.attempt + 1,
352
+ parentDeliveryId: row.id,
353
+ allowRetry: true,
354
+ })
355
+
356
+ if (result.success) succeeded++
357
+ else failed++
358
+ }
359
+
360
+ return { ok: true, message: `Processed ${pendingRetries.length} retries (${succeeded} ok, ${failed} failed)` }
361
+ } catch (err: any) {
362
+ return { ok: false, message: `Webhook retry failed: ${err.message}` }
363
  }
364
  }