FN-6706: add signal connector incident ingestion

Connect external signal ingestion routes to Command Center incident analytics.\n\n- Add connector status reporting and incident upsert/resolution support for webhook, Sentry, Datadog, and PagerDuty sources.\n- Move signal aggregation through shared activity analytics and expose connector-aware empty states in Command Center.\n- Document connector setup, settings, and dashboard behavior with a published changeset.\n- Extend route, analytics, and Command Center UI tests for scoped signal data and configured-but-quiet connectors.\n\nFiles changed:\n .changeset/fn-6706-signal-connectors.md            |   7 +\n AGENTS.md                                          |   1 +\n docs/dashboard-guide.md                            |   4 +-\n docs/settings-reference.md                         |  16 +\n docs/signals-connectors.md                         | 140 ++++++++\n .../core/src/__tests__/signals-analytics.test.ts   |  37 ++-\n packages/core/src/activity-analytics.ts            | 185 ++++++++++-\n packages/core/src/index.ts                         |  11 +-\n packages/core/src/signals-analytics.ts             | 209 +-----------\n .../command-center/areas/SignalsArea.tsx           |  47 ++-\n .../areas/__tests__/areas.github-signals.test.tsx  | 120 +++++--\n .../command-center/areas/__tests__/areas.test.tsx  |   3 +-\n .../register-command-center-routes.test.ts         |  72 +++-\n .../src/__tests__/register-signal-routes.test.ts   | 369 ++++++++++++++++++++-\n .../src/routes/register-command-center-routes.ts   |  29 +-\n .../dashboard/src/routes/register-signal-routes.ts |  55 +++\n packages/dashboard/src/signal-source.ts            |   8 +\n packages/dashboard/src/signal-sources/datadog.ts   |   8 +\n packages/dashboard/src/signal-sources/pagerduty.ts |  14 +-\n packages/dashboard/src/signal-sources/sentry.ts    |  19 +-\n packages/dashboard/src/signal-sources/webhook.ts   |   8 +\n 21 files changed, 1094 insertions(+), 268 deletions(-)

Fusion-Task-Id: FN-6706

Fusion-Task-Lineage: 09003e9b-481b-4393-926b-44907d13bcf9
This commit is contained in:
gsxdsm
2026-06-26 00:28:31 -07:00
parent 4dab2b6986
commit 98a5052b65
21 changed files with 1092 additions and 266 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": minor
---
summary: Record signed signal connectors in Command Center incident metrics.
category: feature
dev: Adds connector incident ingestion and /api/command-center/signals/connectors configuration status.

View File

@@ -240,6 +240,7 @@ Scoped exception (FN-5819): shared-branch-group members (`branchContext.assignme
- `./docs/PLUGIN_AUTHORING.md` — plugin authoring guide, lifecycle hooks, routes, tools, and dashboard-extension surfaces.
- `./docs/agents.md` — pi extension scope, coordination tools, checkout leasing, runtime config.
- `./docs/settings-reference.md` — model-selection hierarchy, mock provider mode, token budget precedence, presets.
- `./docs/signals-connectors.md` — setup, HMAC auth, payload mapping, and security notes for Command Center external signal connectors.
- `./docs/storage.md` — hybrid storage model details, including per-task `agent-log.jsonl` storage and retention semantics.
- `./docs/multi-project.md` — central/per-project DB and isolation modes.
- `./docs/missions.md` — mission/milestone/slice/feature model.

View File

@@ -842,7 +842,7 @@ Features:
- **Ecosystem** shows active model breadth, per-model task activity, and real plugin activations for the selected range. Plugin activation counts come from project-scoped plugin/extension load events via `/api/command-center/plugin-activations`; if no activation rows exist in range, the metric renders unavailable (`—`) rather than fabricating zero. The tab still reuses the tokens analytics endpoint grouped by model, adds a task-share-by-model pie from `TokenAnalytics.groups`, and renders a tokens/tasks trend line when `TokenAnalytics.series` buckets are present; if series buckets are absent, no synthetic trend is shown.
<!-- FNXC:CommandCenter 2026-06-21-07:07: FN-6722 requires the GitHub area to expose a resolved-issue detail list from local task-store analytics only, with exact close timestamps flagged when reconciliation populated `sourceIssueClosedAt` and approximation called out otherwise. -->
- **GitHub** shows local GitHub issue flow for the selected range: **Filed by Fusion** counts tasks with a persisted `githubTracking.issue`, **Fixed by Fusion** counts tasks imported from GitHub source issues (`sourceIssueProvider = "github"`) that are currently in `done`, using the persisted `sourceIssueClosedAt` / `TaskSourceIssue.closedAt` close time when the reconciler has observed it. Rows that predate the field or have not been observed closed fall back to task `updatedAt` as the documented completion-time approximation; Fusion never fabricates a close timestamp and this analytics path never calls GitHub, the `gh` CLI, or any external network source. To make historical fixed dates exact, use **Backfill exact close times** in the Fixed by Fusion card; the dashboard calls the project-scoped manual `POST /api/git/github/backfill-source-issue-closed-at` endpoint in `{ offset, limit }` batches until `hasMore` is false, then surfaces the accumulated `scanned`, `filled`, `skipped`, and `errors` counts. The endpoint fetches real GitHub `closed_at` values once, fills only missing `sourceIssueClosedAt` values, and never runs automatically or from analytics-time rendering. The area shows filed/fixed/net stat cards, a filed-vs-fixed pie, a filed/fixed recharts trend line, existing daily sparklines, a by-repository bar breakdown, and a **Resolved issues** detail list. Resolved rows include the Fusion task, repository, source issue number, optional issue link, resolved timestamp, and whether that timestamp is exact (`sourceIssueClosedAt`) or the documented `updatedAt` approximation; missing issue URLs render as plain text rather than empty anchors or click targets. The same resolved rows are available from the GitHub analytics payload as `resolved` and from the CSV export.
- **Signals** is backed by the project-scoped `/api/command-center/signals` endpoint, which aggregates real rows from the local `incidents` table. It shows total/open/resolved counts, MTTR when resolved incidents have enough timestamps, and source/severity/status breakdowns; an empty incidents table renders honest zero counts with MTTR unavailable rather than fabricated signal volume. It adds an open-vs-resolved status pie from the same response. Signals has no per-day series today, so it intentionally does not render a line chart or fabricate a trend. External connectors that ingest third-party signals into incidents are tracked separately in FN-6706.
- **Signals** is backed by the project-scoped `/api/command-center/signals` endpoint, which aggregates real rows from the local `incidents` table. Verified external connectors (`POST /api/signals/webhook`, `/sentry`, `/datadog`, and `/pagerduty`) create triage tasks and also write/resolve incidents, so Signals shows total/open/resolved counts, MTTR when resolved incidents have enough timestamps, and source/severity/status breakdowns from connector traffic. It adds an open-vs-resolved status pie from the same response. Signals has no per-day series today, so it intentionally does not render a line chart or fabricate a trend. The companion `/api/command-center/signals/connectors` endpoint returns only per-provider configured booleans, allowing the empty state to distinguish "no connector configured" from "connector configured, awaiting signals" without exposing secrets.
- **System** is the canonical system-telemetry destination. It reads local telemetry from `GET /api/system-stats` and, when multiple registered nodes exist, shows a node selector that can proxy the same system-stats payload through `GET /api/nodes/:id/system-stats` for remote nodes. It renders live radial gauges for app CPU, host memory, and heap usage, keeps a small client-side rolling buffer for CPU/memory/heap trend sparklines, adds a recharts CPU/memory/heap line from that same rolling buffer, and adds a task-by-column pie alongside the existing tasks-by-column and agents-by-state bars. Host memory uses OS-available memory (Node `process.availableMemory()` when available, with a flagged `freemem` fallback) so macOS inactive/cache pages are not reported as used. The Vitest process count, manual kill confirmation, auto-kill toggle, threshold controls, and last-auto-kill timestamp moved here unchanged; the standalone System Stats modal and its desktop Header/mobile More affordances were removed.
- **Mission Control** shows live active sessions/runs/nodes, current sessions and nodes, an animated live activity snapshot, and a live SDLC funnel; when idle it reports that live updates resume when work starts. No additional pie or line chart is rendered because the live SDLC funnel already visualizes the panel's only quantitative distribution (`snapshot.columns`), while sessions/nodes are live control lists rather than categorical analytics. Motion-heavy accents respect reduced-motion preferences.
- CSV exports are available from the analytics endpoints with `?format=csv`. The Activity CSV includes daily `agentRuns` values plus summary rows for `(agentRuns.total)`, `(agentRuns.active)`, `(agentRuns.completed)`, and `(agentRuns.failed)`.
@@ -860,7 +860,7 @@ Data states:
- GitHub issue analytics is local and additive: empty filed/fixed totals keep the stat cards and historical backfill button available while omitting empty chart shells; malformed historical `githubTracking` JSON is skipped instead of breaking the Command Center.
- Team analytics renders its shared loading/error/empty states for null or zero-agent responses, omits empty chart shells for zero-value datasets, and keeps the Command Center tab panel as the mobile scroll owner.
- System telemetry keeps the previous snapshot visible during refresh failures, preserves the node selector when a selected remote node fails to refresh, renders a first-sample CPU `Sampling…` state without NaN values, shows zero-value task/agent bars for empty collections while omitting the zero-value task-distribution pie, and keeps the Command Center tab panel as the mobile scroll owner.
- Signals is best-effort over local incidents data: if the project has no incidents, the Signals area shows its empty state, omits its status pie, and other Command Center metrics remain valid; endpoint errors surface as the shared analytics error state instead of silently swallowing a missing route.
- Signals is best-effort over local incidents data: if the project has no incidents, the Signals area shows either the setup empty state (no signal connector secret configured) or the quiet empty state (at least one connector configured but no rows in range), omits its status pie, and other Command Center metrics remain valid; endpoint errors surface as the shared analytics error state instead of silently swallowing a missing route.
## Reliability View

View File

@@ -25,6 +25,22 @@ At runtime, settings are merged. **Project settings override global settings** w
---
## Signal connector environment variables
Command Center signal connectors are configured with process environment variables read by the dashboard/API server. These values are secrets and are never returned by the connectors-status endpoint; `GET /api/command-center/signals/connectors` reports only per-provider `configured` booleans.
| Environment variable | Connector | Used by | Notes |
|---|---|---|---|
| `FUSION_SIGNAL_WEBHOOK_SECRET` | Generic webhook | `POST /api/signals/webhook` | Verifies `X-Fusion-Signature` (`sha256=`-prefixed HMAC-SHA256 hex) plus `X-Fusion-Timestamp`. |
| `FUSION_SIGNAL_SENTRY_SECRET` | Sentry | `POST /api/signals/sentry` | Verifies `Sentry-Hook-Signature` against Sentry issue webhook payloads. |
| `FUSION_SIGNAL_DATADOG_SECRET` | Datadog | `POST /api/signals/datadog` | Verifies the custom `X-Datadog-Signature` HMAC header; optional `X-Datadog-Timestamp` bounds replay. |
| `FUSION_SIGNAL_PAGERDUTY_SECRET` | PagerDuty | `POST /api/signals/pagerduty` | Verifies `X-PagerDuty-Signature` (`v1=<hex>`). |
| `FUSION_MONITOR_INGEST_SECRET` | Monitor incidents API | `POST /api/monitor/incidents` | Separate bearer-token path for direct monitor ingestion; it is not used by `/api/signals/:provider`. |
See [Signals Connectors](./signals-connectors.md) for setup, signing, payload, and open/resolved mapping details.
---
## Global Settings
Defaults from `DEFAULT_GLOBAL_SETTINGS`; key scope from `GLOBAL_SETTINGS_KEYS`.

140
docs/signals-connectors.md Normal file
View File

@@ -0,0 +1,140 @@
# Signals Connectors
Fusion can receive signed external signals from Sentry, Datadog, PagerDuty, or a generic webhook at:
```text
POST /api/signals/:provider
```
Supported providers are `webhook`, `sentry`, `datadog`, and `pagerduty`. Every connector requires an HMAC signing secret configured in the Fusion dashboard process environment. Verified signals still create triage tasks, and they also write to the project-scoped `incidents` table so Command Center → Signals can show source, severity, and open/resolved status breakdowns.
## Runtime behavior
- **Open events** create or absorb an incident occurrence by `groupingKey` and preserve the normalized `source`, `severity`, optional `link`, and capped `meta` fields.
- **Resolved events** write/absorb the incident and then mark the matching `groupingKey` as `resolved`. Cold resolves are retained as resolved signal metrics instead of being dropped.
- **Duplicate deliveries** with the same provider external id are accepted as deduped and do not create a second task or incident write.
- **Re-fired incidents** with the same `groupingKey` are absorbed by the incidents store rather than double-counted as separate incidents.
- The connectors status endpoint, `GET /api/command-center/signals/connectors`, returns only `{ provider, configured }` booleans. It never returns secret values.
## Security model
- Secrets are environment variables; do not commit them to source control.
- HMAC verification uses the raw request body and constant-time comparison.
- Requests are capped at about 1 MB.
- Replay protection rejects stale timestamps where the provider supplies one and rejects repeated delivery ids within the replay window.
- Normalized `title`, `body`, `groupingKey`, `link`, and `meta` fields are capped by `signal-source.ts` before storage.
- Signal `link` values are SSRF-untrusted. Fusion stores safe external URLs as data for the UI and never fetches connector links server-side.
- `meta` is stored as JSON data only and must not be rendered as raw HTML.
## Generic webhook
Set:
```bash
export FUSION_SIGNAL_WEBHOOK_SECRET="replace-with-a-long-random-secret"
```
Headers:
- `X-Fusion-Signature`: `sha256=`-prefixed HMAC-SHA256 hex digest of the raw JSON body.
- `X-Fusion-Timestamp`: epoch milliseconds, used for the replay window.
Payload contract:
```json
{
"id": "delivery-123",
"title": "API error rate above threshold",
"body": "5xx rate exceeded 10% for 5 minutes",
"severity": "critical",
"groupingKey": "api-error-rate",
"link": "https://example.com/incidents/api-error-rate",
"timestamp": 1790294400000,
"status": "open",
"meta": { "service": "api" }
}
```
Resolution mapping: `status: "resolved"`, `action: "resolved"`, or `action: "resolve"` resolves the grouped incident. Any other value opens/absorbs the incident.
Example signed request:
```bash
body='{"id":"delivery-123","title":"API error rate above threshold","severity":"critical","groupingKey":"api-error-rate","timestamp":'"$(date +%s000)"'}'
sig="sha256=$(printf '%s' "$body" | openssl dgst -sha256 -hmac "$FUSION_SIGNAL_WEBHOOK_SECRET" -hex | awk '{print $2}')"
curl -X POST "http://127.0.0.1:4040/api/signals/webhook" \
-H "Content-Type: application/json" \
-H "X-Fusion-Timestamp: $(date +%s000)" \
-H "X-Fusion-Signature: $sig" \
--data-binary "$body"
```
## Sentry
Set:
```bash
export FUSION_SIGNAL_SENTRY_SECRET="sentry-integration-client-secret"
```
Configure the Sentry integration webhook target as `/api/signals/sentry`. Fusion verifies `Sentry-Hook-Signature` as the HMAC-SHA256 hex digest of the raw body. If Sentry sends `Sentry-Hook-Timestamp`, Fusion checks it against the replay window.
Normalization:
- `groupingKey`: Sentry `issue.id`.
- `source`: `sentry`.
- `severity`: `fatal`/`critical` → `critical`, `error` → `error`, `warning` → `warning`, `info`/`debug` → `info`.
- Resolution: payload `action === "resolved"` or `issue.status === "resolved"` resolves the grouped issue; other actions open/absorb it.
## Datadog
Set:
```bash
export FUSION_SIGNAL_DATADOG_SECRET="datadog-webhook-shared-secret"
```
Datadog webhooks do not provide a built-in HMAC header, so configure a custom header:
```text
X-Datadog-Signature: <HMAC-SHA256 hex digest of the raw body>
```
Optionally include `X-Datadog-Timestamp` as epoch milliseconds for replay-window validation. Configure the Datadog webhook target as `/api/signals/datadog`.
Normalization:
- `groupingKey`: `aggreg_key`, `alert_id`, or `id`.
- `source`: `datadog`.
- `severity`: `error` → `critical`, `warning`/`warn` → `warning`, `success`/`recovery`/`info` → `info`, default → `error`.
- Resolution: `alert_type` of `recovery` or `success` resolves the grouped monitor; other alert types open/absorb it.
## PagerDuty
Set:
```bash
export FUSION_SIGNAL_PAGERDUTY_SECRET="pagerduty-webhook-subscription-secret"
```
Configure the PagerDuty webhook subscription target as `/api/signals/pagerduty`. Fusion verifies `X-PagerDuty-Signature` and accepts the `v1=<hex>` signature form.
Normalization:
- `groupingKey`: PagerDuty incident `data.id`.
- `source`: `pagerduty`.
- `severity`: explicit `data.severity` when it is one of Fusion's normalized severities; otherwise high urgency maps to `critical` and other events map to `warning`.
- Resolution: `event.event_type === "incident.resolved"` or `data.status === "resolved"` resolves the grouped incident; other incident events open/absorb it.
## Command Center Signals
Command Center → Signals reads aggregated incidents through `GET /api/command-center/signals`. Once a connector secret is configured and signed events arrive, the area displays total, open, resolved, MTTR, by-source, by-severity, and by-status metrics from local incident rows.
The empty state is intentionally explicit:
- no configured connector secret: prompt operators to connect Sentry, Datadog, PagerDuty, or the generic webhook;
- at least one configured connector secret but no rows in the selected range: report that Fusion is configured and awaiting signals.
## Separate monitor ingest path
`FUSION_MONITOR_INGEST_SECRET` protects the separate bearer-token route for `/api/monitor/incidents`. It is not used by `/api/signals/:provider`; signal connectors use the provider-specific `FUSION_SIGNAL_*_SECRET` variables above.

View File

@@ -30,9 +30,9 @@ function insertIncident(
incidentId,
`group-${incidentId}`,
`Signal ${incidentId}`,
fields.severity ?? "error",
fields.severity === undefined ? "error" : fields.severity,
fields.status,
fields.source ?? "webhook",
fields.source === undefined ? "webhook" : fields.source,
fields.openedAt,
fields.resolvedAt ?? null,
now,
@@ -56,7 +56,7 @@ describe("signals-analytics", () => {
await rm(tmpDir, { recursive: true, force: true });
});
it("aggregates real incident signals by source, severity, status, and MTTR", () => {
it("aggregates real incident signals by source, severity, and MTTR", () => {
insertIncident(db, {
status: "open",
openedAt: "2026-03-02T10:00:00.000Z",
@@ -98,6 +98,21 @@ describe("signals-analytics", () => {
]);
});
it("buckets missing source and severity as unknown", () => {
insertIncident(db, {
status: "open",
openedAt: "2026-03-02T10:00:00.000Z",
source: null,
severity: null,
});
const result = aggregateSignalsAnalytics(db, RANGE);
expect(result.bySource).toEqual([{ source: "unknown", count: 1 }]);
expect(result.bySeverity).toEqual([{ severity: "unknown", count: 1 }]);
expect(result.byStatus).toEqual([{ status: "open", count: 1 }]);
});
it("keeps MTTR as the unavailable sentinel when no incident resolved in range", () => {
insertIncident(db, {
status: "open",
@@ -111,4 +126,20 @@ describe("signals-analytics", () => {
expect(result.totalSignals).toBe(1);
expect(result.mttr).toEqual({ value: null, unavailable: true, sampleCount: 0 });
});
it("returns zeroed analytics when incidents table is absent", () => {
db.prepare("DROP TABLE incidents").run();
expect(aggregateSignalsAnalytics(db, RANGE)).toEqual({
from: RANGE.from,
to: RANGE.to,
totalSignals: 0,
open: 0,
resolved: 0,
mttr: { value: null, unavailable: true, sampleCount: 0 },
bySource: [],
bySeverity: [],
byStatus: [],
});
});
});

View File

@@ -76,6 +76,49 @@ export interface MonitorMetrics {
deployments: number;
}
/** Command Center Signals source breakdown from incidents opened in range. */
export interface SignalSourceCount {
source: string;
count: number;
}
/** Command Center Signals severity breakdown from incidents opened in range. */
export interface SignalSeverityCount {
severity: string;
count: number;
}
/** Command Center Signals status breakdown from incidents opened in range. */
export interface SignalStatusCount {
status: string;
count: number;
}
/**
* External signal analytics for the Command Center Signals area. Counts are
* sourced from the `incidents` table so connector ingestion, monitor metrics,
* and UI pressure indicators share one durable signal record.
*/
export interface SignalsAnalytics {
from: string | null;
to: string | null;
/** Incidents opened (by `openedAt`) within the range. */
totalSignals: number;
/** Open incidents opened within the range. */
open: number;
/** Incidents resolved (by `resolvedAt`) within the range. */
resolved: number;
/** Mean-time-to-resolve over incidents resolved in range. */
mttr: MttrSummary;
/** Incidents opened in range grouped by source; null/blank values are `unknown`. */
bySource: SignalSourceCount[];
/** Incidents opened in range grouped by severity; null/blank values are `unknown`. */
bySeverity: SignalSeverityCount[];
/** Incidents opened in range grouped by status so connector recoveries are visible. */
byStatus: SignalStatusCount[];
}
export interface ActivityAnalytics {
from: string | null;
to: string | null;
@@ -656,25 +699,8 @@ export function aggregateMonitorMetrics(
)
.all(...resolvedRange.params) as ResolvedIncidentRow[];
let totalMs = 0;
let sampleCount = 0;
for (const row of resolvedRows) {
const opened = Date.parse(row.openedAt);
const resolved = Date.parse(row.resolvedAt);
if (!Number.isFinite(opened) || !Number.isFinite(resolved)) continue;
const delta = resolved - opened;
if (delta < 0) continue; // guard against clock skew / bad data
totalMs += delta;
sampleCount += 1;
}
const mttr: MttrSummary =
sampleCount === 0
? { value: null, unavailable: true, sampleCount: 0 }
: { value: totalMs / sampleCount / 60_000, unavailable: false, sampleCount };
return {
mttr,
mttr: mttrFromResolvedRows(resolvedRows),
incidentsOpened,
incidentsResolved,
openIncidents,
@@ -701,3 +727,126 @@ function tableExists(db: Database, table: string): boolean {
.get(table) as { name: string } | undefined;
return row !== undefined;
}
/* ------------------------------------------------------------------------- */
/* FN-6706 — Command Center Signals analytics from incidents */
/* ------------------------------------------------------------------------- */
interface SignalsGroupRow {
key: string | null;
count: number;
}
function emptySignalsAnalytics(query: ActivityAnalyticsQuery): SignalsAnalytics {
return {
from: query.from ?? null,
to: query.to ?? null,
totalSignals: 0,
open: 0,
resolved: 0,
mttr: { value: null, unavailable: true, sampleCount: 0 },
bySource: [],
bySeverity: [],
byStatus: [],
};
}
function mttrFromResolvedRows(rows: ResolvedIncidentRow[]): MttrSummary {
let totalMs = 0;
let sampleCount = 0;
for (const row of rows) {
const opened = Date.parse(row.openedAt);
const resolved = Date.parse(row.resolvedAt);
if (!Number.isFinite(opened) || !Number.isFinite(resolved)) continue;
const delta = resolved - opened;
if (delta < 0) continue;
totalMs += delta;
sampleCount += 1;
}
return sampleCount === 0
? { value: null, unavailable: true, sampleCount: 0 }
: { value: totalMs / sampleCount / 60_000, unavailable: false, sampleCount };
}
function signalsBreakdown(
db: Database,
column: "source" | "severity" | "status",
openedWhere: string,
params: string[],
): Array<{ key: string; count: number }> {
const rows = db
.prepare(
`SELECT COALESCE(NULLIF(TRIM(${column}), ''), 'unknown') AS key, COUNT(*) AS count
FROM incidents ${openedWhere}
GROUP BY key
ORDER BY count DESC, key ASC`,
)
.all(...params) as SignalsGroupRow[];
return rows.map((row) => ({ key: row.key ?? "unknown", count: row.count }));
}
/**
* Aggregate Command Center Signals data from verified connector incidents.
*
* FNXC:CommandCenterSignals 2026-06-19-00:00:
* FN-6706 requires the Signals area to read real connector pressure from the project-scoped incidents table. Use openedAt for total/open/source/severity, resolvedAt for resolved/MTTR, bucket missing source/severity as `unknown`, and degrade to an empty unavailable-MTTR shape on older schemas without incidents.
*
* FNXC:CommandCenterSignals 2026-06-25-23:35:
* Connector resolution events must surface as a status breakdown, not only top-line open/resolved counts, so the UI and API can prove provider recovery signals changed incident state.
*/
export function aggregateSignalsAnalytics(
db: Database,
query: ActivityAnalyticsQuery = {},
): SignalsAnalytics {
if (!tableExists(db, "incidents")) return emptySignalsAnalytics(query);
const openedRange = rangeClauses("openedAt", query);
const resolvedRange = rangeClauses("resolvedAt", query);
const openWhere = openedRange.where
? `${openedRange.where} AND status = 'open'`
: "WHERE status = 'open'";
const resolvedWhere = resolvedRange.where
? `${resolvedRange.where} AND resolvedAt IS NOT NULL`
: "WHERE resolvedAt IS NOT NULL";
const totalSignals = (
db
.prepare(`SELECT COUNT(*) AS count FROM incidents ${openedRange.where}`)
.get(...openedRange.params) as CountRow
).count;
const open = (
db
.prepare(`SELECT COUNT(*) AS count FROM incidents ${openWhere}`)
.get(...openedRange.params) as CountRow
).count;
const resolved = (
db
.prepare(`SELECT COUNT(*) AS count FROM incidents ${resolvedWhere}`)
.get(...resolvedRange.params) as CountRow
).count;
const resolvedRows = db
.prepare(`SELECT openedAt, resolvedAt FROM incidents ${resolvedWhere}`)
.all(...resolvedRange.params) as ResolvedIncidentRow[];
return {
from: query.from ?? null,
to: query.to ?? null,
totalSignals,
open,
resolved,
mttr: mttrFromResolvedRows(resolvedRows),
bySource: signalsBreakdown(db, "source", openedRange.where, openedRange.params).map((row) => ({
source: row.key,
count: row.count,
})),
bySeverity: signalsBreakdown(db, "severity", openedRange.where, openedRange.params).map((row) => ({
severity: row.key,
count: row.count,
})),
byStatus: signalsBreakdown(db, "status", openedRange.where, openedRange.params).map((row) => ({
status: row.key,
count: row.count,
})),
};
}

View File

@@ -641,14 +641,13 @@ export type {
GithubIssueRepoBreakdown,
GithubResolvedIssue,
} from "./github-issue-analytics.js";
export { aggregateSignalsAnalytics } from "./signals-analytics.js";
export { aggregateSignalsAnalytics } from "./activity-analytics.js";
export type {
SignalSourceCount,
SignalSeverityCount,
SignalsAnalytics,
SignalsAnalyticsQuery,
SignalsBreakdown,
SignalsSeverityBreakdown,
SignalsStatusBreakdown,
} from "./signals-analytics.js";
ActivityAnalyticsQuery as SignalsAnalyticsQuery,
} from "./activity-analytics.js";
export { composeLiveSnapshot } from "./command-center-live.js";
export type {
LiveSnapshot,

View File

@@ -1,195 +1,14 @@
import type { Database } from "./db.js";
import type { MttrSummary } from "./activity-analytics.js";
import type { SignalSeverityCount, SignalSourceCount } from "./activity-analytics.js";
/**
* Command Center external-signal analytics over the existing `incidents` table.
*
* FNXC:CommandCenter 2026-06-19-00:00:
* The Signals tab must be backed by real project data, not a swallowed 404. Use the scoped incidents table that monitor ingestion already owns; when no incident source is connected, return honest zeros plus the MTTR unavailable sentinel instead of fabricating signal volume.
*/
export interface SignalsAnalyticsQuery {
/** ISO-8601 lower bound (inclusive). */
from?: string;
/** ISO-8601 upper bound (inclusive). */
to?: string;
}
export { aggregateSignalsAnalytics } from "./activity-analytics.js";
export type {
ActivityAnalyticsQuery as SignalsAnalyticsQuery,
SignalSourceCount,
SignalSeverityCount,
SignalsAnalytics,
} from "./activity-analytics.js";
export interface SignalsBreakdown {
source: string;
count: number;
}
export interface SignalsSeverityBreakdown {
severity: string;
count: number;
}
export interface SignalsStatusBreakdown {
status: string;
count: number;
}
export interface SignalsAnalytics {
from: string | null;
to: string | null;
/** Incidents opened in range. */
totalSignals: number;
/** Open incidents opened in range. */
open: number;
/** Incidents resolved in range. */
resolved: number;
/** Mean time to resolve for incidents resolved in range. */
mttr: MttrSummary;
/** Incidents opened in range, grouped by source. */
bySource: SignalsBreakdown[];
/** Incidents opened in range, grouped by severity. */
bySeverity: SignalsSeverityBreakdown[];
/** Incidents opened in range, grouped by current status. */
byStatus: SignalsStatusBreakdown[];
}
interface CountRow {
count: number;
}
interface GroupRow {
key: string | null;
count: number;
}
interface ResolvedIncidentRow {
openedAt: string;
resolvedAt: string;
}
function tableExists(db: Database, name: string): boolean {
const row = db
.prepare("SELECT COUNT(*) AS count FROM sqlite_master WHERE type = 'table' AND name = ?")
.get(name) as CountRow;
return row.count > 0;
}
function rangeWhere(column: string, query: SignalsAnalyticsQuery): { where: string; params: string[] } {
const clauses: string[] = [];
const params: string[] = [];
if (query.from !== undefined) {
clauses.push(`${column} >= ?`);
params.push(query.from);
}
if (query.to !== undefined) {
clauses.push(`${column} <= ?`);
params.push(query.to);
}
return {
where: clauses.length > 0 ? `WHERE ${clauses.join(" AND ")}` : "",
params,
};
}
function emptySignals(query: SignalsAnalyticsQuery): SignalsAnalytics {
return {
from: query.from ?? null,
to: query.to ?? null,
totalSignals: 0,
open: 0,
resolved: 0,
mttr: { value: null, unavailable: true, sampleCount: 0 },
bySource: [],
bySeverity: [],
byStatus: [],
};
}
function count(db: Database, sql: string, params: string[]): number {
return (db.prepare(sql).get(...params) as CountRow).count;
}
function groupByColumn(
db: Database,
column: "source" | "severity" | "status",
openedWhere: string,
params: string[],
fallback: string,
): Array<{ key: string; count: number }> {
const rows = db
.prepare(
`SELECT COALESCE(NULLIF(TRIM(${column}), ''), ?) AS key, COUNT(*) AS count
FROM incidents ${openedWhere}
GROUP BY key
ORDER BY count DESC, key ASC`,
)
.all(fallback, ...params) as GroupRow[];
return rows.map((row) => ({ key: row.key ?? fallback, count: row.count }));
}
function computeMttr(db: Database, query: SignalsAnalyticsQuery): MttrSummary {
const resolvedRange = rangeWhere("resolvedAt", query);
const resolvedWhere = resolvedRange.where
? `${resolvedRange.where} AND resolvedAt IS NOT NULL`
: "WHERE resolvedAt IS NOT NULL";
const rows = db
.prepare(`SELECT openedAt, resolvedAt FROM incidents ${resolvedWhere}`)
.all(...resolvedRange.params) as ResolvedIncidentRow[];
let totalMinutes = 0;
let sampleCount = 0;
for (const row of rows) {
const opened = Date.parse(row.openedAt);
const resolved = Date.parse(row.resolvedAt);
if (!Number.isFinite(opened) || !Number.isFinite(resolved) || resolved < opened) continue;
totalMinutes += (resolved - opened) / 60_000;
sampleCount += 1;
}
return sampleCount === 0
? { value: null, unavailable: true, sampleCount: 0 }
: { value: totalMinutes / sampleCount, unavailable: false, sampleCount };
}
/**
* Aggregate the Command Center Signals surface from locally recorded incidents.
* Missing/older schemas return an honest empty payload so the dashboard can show
* "no source connected" without pretending that a zero came from ingestion.
*/
export function aggregateSignalsAnalytics(
db: Database,
query: SignalsAnalyticsQuery = {},
): SignalsAnalytics {
if (!tableExists(db, "incidents")) return emptySignals(query);
const openedRange = rangeWhere("openedAt", query);
const resolvedRange = rangeWhere("resolvedAt", query);
const resolvedWhere = resolvedRange.where
? `${resolvedRange.where} AND resolvedAt IS NOT NULL`
: "WHERE resolvedAt IS NOT NULL";
const openWhere = openedRange.where
? `${openedRange.where} AND status = 'open'`
: "WHERE status = 'open'";
const totalSignals = count(
db,
`SELECT COUNT(*) AS count FROM incidents ${openedRange.where}`,
openedRange.params,
);
const open = count(db, `SELECT COUNT(*) AS count FROM incidents ${openWhere}`, openedRange.params);
const resolved = count(db, `SELECT COUNT(*) AS count FROM incidents ${resolvedWhere}`, resolvedRange.params);
const bySource = groupByColumn(db, "source", openedRange.where, openedRange.params, "(unknown)")
.map((row) => ({ source: row.key, count: row.count }));
const bySeverity = groupByColumn(db, "severity", openedRange.where, openedRange.params, "unknown")
.map((row) => ({ severity: row.key, count: row.count }));
const byStatus = groupByColumn(db, "status", openedRange.where, openedRange.params, "unknown")
.map((row) => ({ status: row.key, count: row.count }));
return {
from: query.from ?? null,
to: query.to ?? null,
totalSignals,
open,
resolved,
mttr: computeMttr(db, query),
bySource,
bySeverity,
byStatus,
};
}
/** Back-compat alias for the original pre-FN-6706 signals module name. */
export type SignalsBreakdown = SignalSourceCount;
/** Back-compat alias for the original pre-FN-6706 signals module name. */
export type SignalsSeverityBreakdown = SignalSeverityCount;

View File

@@ -8,17 +8,40 @@ import { AreaShell } from "./AreaShell";
import { useAnalyticsArea } from "./useAnalyticsArea";
import { formatCount } from "./areaShared";
type SignalConnectorStatus = {
provider: string;
configured: boolean;
};
type SignalConnectorsResponse = {
connectors: SignalConnectorStatus[];
};
type SignalsAnalyticsWithConnectors = SignalsAnalytics & {
connectors?: {
configured: string[];
anyConfigured: boolean;
};
};
/*
FNXC:CommandCenter 2026-06-16-09:42:
Signals area of the Command Center (PR #1683). Surfaces external-signal volume/severity from the project-scoped incidents table so operators see incoming pressure alongside internal analytics.
FNXC:CommandCenter 2026-06-19-00:00:
Signals now reads a real `/api/command-center/signals` route backed by incidents instead of swallowing a missing endpoint. Empty still means no incident source has recorded data, and MTTR remains `—` until at least one incident is resolved. FN-6706 owns building external Sentry/Datadog/PagerDuty/webhook connectors into that incidents table.
FNXC:CommandCenterSignals 2026-06-25-22:40:
Empty Signals copy is driven by the connectors-status endpoint, not fabricated metrics. Operators must see "no connector configured" when no HMAC secret exists and "configured, awaiting signals" when ingestion is ready but quiet; loading the status should keep the normal loading-before-empty behavior.
*/
export function SignalsArea({ range }: { range: DateRange }) {
const { t } = useTranslation("app");
const { data, isLoading } = useAnalyticsArea<SignalsAnalytics>("/command-center/signals", range);
const { data, isLoading } = useAnalyticsArea<SignalsAnalyticsWithConnectors>("/command-center/signals", range);
const { data: connectorsData, isLoading: isConnectorsLoading } = useAnalyticsArea<SignalConnectorsResponse>(
"/command-center/signals/connectors",
range,
);
const sourceBars = useMemo(
() => (data?.bySource ?? []).map((s) => ({ label: s.source, value: s.count, valueLabel: formatCount(s.count) })),
@@ -42,18 +65,30 @@ export function SignalsArea({ range }: { range: DateRange }) {
);
const isEmpty = !data || data.totalSignals === 0;
const configuredProvidersFromStatus = Array.isArray(connectorsData?.connectors)
? connectorsData.connectors.filter((connector) => connector.configured).map((connector) => connector.provider)
: undefined;
const configuredProviders = configuredProvidersFromStatus ?? data?.connectors?.configured ?? [];
const hasConfiguredConnector = configuredProviders.length > 0 || data?.connectors?.anyConfigured === true;
const emptyMessage = hasConfiguredConnector
? t(
"commandCenter.signals.emptyAwaitingSignals",
"Connector configured, awaiting signals in this range. Configured providers: {{providers}}.",
{ providers: configuredProviders.length > 0 ? configuredProviders.join(", ") : t("commandCenter.signals.providersUnknown", "unknown") },
)
: t(
"commandCenter.signals.emptyNoConnectorConfigured",
"No signal connector configured. Connect Sentry, Datadog, PagerDuty, or a generic webhook to see incident metrics here.",
);
const hasStatusPie = !isEmpty && statusPieData.some((datum) => datum.value > 0);
return (
<AreaShell
testId="signals"
isLoading={isLoading}
isLoading={isLoading || isConnectorsLoading}
error={null}
isEmpty={isEmpty}
emptyMessage={t(
"commandCenter.signals.empty",
"No external signals yet. Connect a signal source (Sentry, Datadog, PagerDuty, webhook) to see incident metrics here.",
)}
emptyMessage={emptyMessage}
>
<div className="cc-area-section">
<h3 className="cc-area-section-title">{t("commandCenter.signals.summaryTitle", "Summary")}</h3>

View File

@@ -72,6 +72,8 @@ beforeEach(() => {
afterEach(() => {
vi.useRealTimers();
Object.defineProperty(window, "innerWidth", { configurable: true, value: 1024 });
window.dispatchEvent(new Event("resize"));
});
describe("GithubArea", () => {
@@ -347,6 +349,13 @@ describe("GithubArea", () => {
});
// FN-6684 Mission Control decision: no extra pie/line test here because MissionControlPanel already renders the live SDLC Funnel for its only quantitative distribution; adding a pie would duplicate that affordance.
function mockSignalsResponses(signals: unknown, connectors: unknown): void {
apiMock.mockImplementation((path: string) => {
if (path.startsWith("/command-center/signals/connectors")) return Promise.resolve(connectors);
return Promise.resolve(signals);
});
}
describe("SignalsArea", () => {
it("renders the empty state (not an error) when the signals endpoint is missing", async () => {
apiMock.mockRejectedValue(new Error("API returned HTML instead of JSON (404)"));
@@ -358,14 +367,17 @@ describe("SignalsArea", () => {
});
it("renders signal metrics and status pie when data is present", async () => {
apiMock.mockResolvedValue({
totalSignals: 8,
open: 3,
resolved: 5,
mttr: { value: 42, unavailable: false },
bySource: [{ source: "sentry", count: 8 }],
bySeverity: [{ severity: "error", count: 8 }],
});
mockSignalsResponses(
{
totalSignals: 8,
open: 3,
resolved: 5,
mttr: { value: 42, unavailable: false },
bySource: [{ source: "sentry", count: 8 }],
bySeverity: [{ severity: "error", count: 8 }],
},
{ connectors: [] },
);
render(<SignalsArea range={range7d} />);
await screen.findByTestId("cc-area-signals");
expect(screen.getByTestId("cc-signals-total").textContent).toContain("8");
@@ -375,14 +387,17 @@ describe("SignalsArea", () => {
});
it("keeps signals pie safe for single-item and non-finite source/severity data", async () => {
apiMock.mockResolvedValue({
totalSignals: 1,
open: 1,
resolved: 0,
mttr: { value: null, unavailable: true },
bySource: [{ source: "broken", count: Number.NaN }],
bySeverity: [{ severity: "broken", count: Number.POSITIVE_INFINITY }],
});
mockSignalsResponses(
{
totalSignals: 1,
open: 1,
resolved: 0,
mttr: { value: null, unavailable: true },
bySource: [{ source: "broken", count: Number.NaN }],
bySeverity: [{ severity: "broken", count: Number.POSITIVE_INFINITY }],
},
{ connectors: [] },
);
render(<SignalsArea range={range7d} />);
await screen.findByTestId("cc-area-signals");
expect(screen.getByTestId("cc-signals-pie")).toBeTruthy();
@@ -390,17 +405,70 @@ describe("SignalsArea", () => {
expect(screen.getByTestId("cc-area-signals").textContent).not.toContain("Infinity");
});
it("renders settled zero signals without a pie shell", async () => {
apiMock.mockResolvedValue({
totalSignals: 0,
open: 0,
resolved: 0,
mttr: { value: null, unavailable: true },
bySource: [],
bySeverity: [],
});
it("renders the not-configured zero state without a pie shell", async () => {
mockSignalsResponses(
{
totalSignals: 0,
open: 0,
resolved: 0,
mttr: { value: null, unavailable: true },
bySource: [],
bySeverity: [],
connectors: { configured: [], anyConfigured: false },
},
{ connectors: [] },
);
render(<SignalsArea range={range7d} />);
await screen.findByTestId("cc-area-signals-empty");
const empty = await screen.findByTestId("cc-area-signals-empty");
expect(empty.textContent).toContain("No signal connector configured");
expect(screen.queryByTestId("cc-signals-pie")).toBeNull();
});
it("keeps legacy zero responses without connectors on the setup CTA", async () => {
mockSignalsResponses(
{
totalSignals: 0,
open: 0,
resolved: 0,
mttr: { value: null, unavailable: true },
bySource: [],
bySeverity: [],
},
{ connectors: [] },
);
render(<SignalsArea range={range7d} />);
const empty = await screen.findByTestId("cc-area-signals-empty");
expect(empty.textContent).toContain("No signal connector configured");
expect(screen.queryByTestId("cc-signals-pie")).toBeNull();
});
it("renders configured-but-quiet zero signals distinctly on desktop and mobile", async () => {
for (const width of [1024, 390]) {
Object.defineProperty(window, "innerWidth", { configurable: true, value: width });
window.dispatchEvent(new Event("resize"));
mockSignalsResponses(
{
totalSignals: 0,
open: 0,
resolved: 0,
mttr: { value: null, unavailable: true },
bySource: [],
bySeverity: [],
connectors: { configured: ["sentry", "pagerduty"], anyConfigured: true },
},
{
connectors: [
{ provider: "sentry", configured: true },
{ provider: "pagerduty", configured: true },
],
},
);
const rendered = render(<SignalsArea range={range7d} />);
const empty = await screen.findByTestId("cc-area-signals-empty");
expect(empty.textContent).toContain("Connector configured, awaiting signals in this range");
expect(empty.textContent).toContain("sentry, pagerduty");
expect(screen.queryByTestId("cc-signals-pie")).toBeNull();
rendered.unmount();
}
});
});

View File

@@ -102,6 +102,8 @@ beforeEach(() => {
afterEach(() => {
vi.useRealTimers();
Object.defineProperty(window, "innerWidth", { configurable: true, value: 1024 });
window.dispatchEvent(new Event("resize"));
});
@@ -1460,4 +1462,3 @@ describe("EcosystemArea", () => {
expect(screen.getByTestId("cc-area-ecosystem").textContent).not.toContain("Infinity");
});
});

View File

@@ -230,13 +230,26 @@ function storeFor(
return store;
}
const SIGNAL_SECRET_ENV_KEYS = [
"FUSION_SIGNAL_WEBHOOK_SECRET",
"FUSION_SIGNAL_SENTRY_SECRET",
"FUSION_SIGNAL_DATADOG_SECRET",
"FUSION_SIGNAL_PAGERDUTY_SECRET",
] as const;
describe("register-command-center-routes", () => {
let tmpDir: string;
let dbA: Database;
let dbB: Database;
let app: ReturnType<typeof buildApp>;
let savedSignalEnv: Partial<Record<(typeof SIGNAL_SECRET_ENV_KEYS)[number], string | undefined>>;
beforeEach(() => {
savedSignalEnv = {};
for (const key of SIGNAL_SECRET_ENV_KEYS) {
savedSignalEnv[key] = process.env[key];
delete process.env[key];
}
tmpDir = mkdtempSync(join(tmpdir(), "kb-cc-routes-"));
dbA = new Database(join(tmpDir, "a", ".fusion"));
dbA.init();
@@ -256,6 +269,11 @@ describe("register-command-center-routes", () => {
vi.useRealTimers();
vi.restoreAllMocks();
mockInvalidateAllGlobalSettingsCaches.mockClear();
for (const key of SIGNAL_SECRET_ENV_KEYS) {
const value = savedSignalEnv[key];
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
dbA.close();
dbB.close();
rmSync(tmpDir, { recursive: true, force: true });
@@ -516,10 +534,17 @@ describe("register-command-center-routes", () => {
},
]);
process.env.FUSION_SIGNAL_SENTRY_SECRET = "configured-sentry";
process.env.FUSION_SIGNAL_WEBHOOK_SECRET = "configured-webhook";
seedSignalMetrics(dbA, { prefix: "SIG-A", source: "sentry", open: 1, resolved: 1 });
const signals = await request(app, "GET", `/api/command-center/signals?${range}&projectId=proj-a`);
expect(signals.status).toBe(200);
expect(signals.body).toMatchObject({ totalSignals: 2, open: 1, resolved: 1 });
expect(signals.body).toMatchObject({
totalSignals: 2,
open: 1,
resolved: 1,
connectors: { configured: ["webhook", "sentry"], anyConfigured: true },
});
expect(signals.body).toHaveProperty("mttr");
expect(signals.body).toHaveProperty("bySource");
expect(signals.body).toHaveProperty("bySeverity");
@@ -706,6 +731,44 @@ describe("register-command-center-routes", () => {
expect(bAgents.some((agent) => agent.agentId === "agent-a-only" || agent.agentName === "Project A Agent")).toBe(false);
});
it("signals endpoint returns zeroed metrics for an empty incidents table", async () => {
const range = "from=2026-02-01T00:00:00.000Z&to=2026-04-01T00:00:00.000Z";
const res = await request(app, "GET", `/api/command-center/signals?${range}&projectId=proj-a`);
expect(res.status).toBe(200);
expect(res.body).toMatchObject({
totalSignals: 0,
open: 0,
resolved: 0,
mttr: { value: null, unavailable: true, sampleCount: 0 },
bySource: [],
bySeverity: [],
connectors: { configured: [], anyConfigured: false },
});
});
it("signals connectors endpoint reports env-backed configuration without leaking secrets", async () => {
process.env.FUSION_SIGNAL_WEBHOOK_SECRET = "webhook-secret-value";
process.env.FUSION_SIGNAL_DATADOG_SECRET = "datadog-secret-value";
const a = await request(app, "GET", "/api/command-center/signals/connectors?projectId=proj-a");
const b = await request(app, "GET", "/api/command-center/signals/connectors?projectId=proj-b");
expect(a.status).toBe(200);
expect(b.status).toBe(200);
expect(a.body).toEqual(b.body);
expect(a.body).toEqual({
connectors: [
{ provider: "webhook", configured: true },
{ provider: "sentry", configured: false },
{ provider: "datadog", configured: true },
{ provider: "pagerduty", configured: false },
],
});
const serialized = JSON.stringify(a.body);
expect(serialized).not.toContain("webhook-secret-value");
expect(serialized).not.toContain("datadog-secret-value");
});
it("signals endpoint defaults invalid ranges and stays project scoped", async () => {
seedSignalMetrics(dbA, { prefix: "SIG-A", source: "sentry", open: 1, resolved: 1 });
seedSignalMetrics(dbB, { prefix: "SIG-B", source: "pagerduty", open: 3, resolved: 2 });
@@ -719,12 +782,14 @@ describe("register-command-center-routes", () => {
expect(invalid.body).toHaveProperty("totalSignals");
expect(invalid.body).toHaveProperty("mttr");
expect(invalid.body).toHaveProperty("bySource");
expect(invalid.body).toMatchObject({ connectors: { configured: [], anyConfigured: false } });
process.env.FUSION_SIGNAL_PAGERDUTY_SECRET = "configured-pd";
const range = "from=2026-02-01T00:00:00.000Z&to=2026-04-01T00:00:00.000Z";
const a = await request(app, "GET", `/api/command-center/signals?${range}&projectId=proj-a`);
const b = await request(app, "GET", `/api/command-center/signals?${range}&projectId=proj-b`);
expect(a.body).toMatchObject({ totalSignals: 2, open: 1, resolved: 1 });
expect(b.body).toMatchObject({ totalSignals: 5, open: 3, resolved: 2 });
expect(a.body).toMatchObject({ totalSignals: 2, open: 1, resolved: 1, connectors: { configured: ["pagerduty"], anyConfigured: true } });
expect(b.body).toMatchObject({ totalSignals: 5, open: 3, resolved: 2, connectors: { configured: ["pagerduty"], anyConfigured: true } });
expect((a.body as { bySource: Array<{ source: string }> }).bySource).toContainEqual(expect.objectContaining({ source: "sentry" }));
expect((a.body as { bySource: Array<{ source: string }> }).bySource).not.toContainEqual(expect.objectContaining({ source: "pagerduty" }));
});
@@ -1010,6 +1075,7 @@ describe("vite /api proxy negative-lookahead (proxy verification)", () => {
expect(PROXY_RE.test("/api/command-center/live")).toBe(true);
expect(PROXY_RE.test("/api/command-center/github")).toBe(true);
expect(PROXY_RE.test("/api/command-center/signals")).toBe(true);
expect(PROXY_RE.test("/api/command-center/signals/connectors")).toBe(true);
expect(PROXY_RE.test("/api/command-center/activity?from=x&to=y")).toBe(true);
});

View File

@@ -1,14 +1,18 @@
// @vitest-environment node
import { createHmac } from "node:crypto";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import type { Task, TaskStore } from "@fusion/core";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { aggregateSignalsAnalytics, Database, type Task, type TaskStore } from "@fusion/core";
import { DeliveryNonceCache, type SignalSource } from "../signal-source.js";
import {
ingestSignal,
resolveSignalSecret,
signalToTaskInput,
getSignalSource,
resolveConfiguredSignalProviders,
} from "../routes/register-signal-routes.js";
import { webhookSource } from "../signal-sources/webhook.js";
import { sentrySource } from "../signal-sources/sentry.js";
@@ -20,7 +24,7 @@ function sign(body: string, secret: string): string {
}
/** Minimal fake task store implementing only what the ingestion path uses. */
function makeStore() {
function makeStore(db?: Database) {
const tasks: Task[] = [];
let counter = 0;
const store = {
@@ -38,11 +42,34 @@ function makeStore() {
tasks.push(task);
return task;
},
getDatabase() {
if (!db) throw new Error("test database not configured");
return db;
},
_tasks: tasks,
};
return store as unknown as TaskStore & { _tasks: Task[] };
}
function makeDbStore() {
const dir = mkdtempSync(join(tmpdir(), "kb-signal-routes-"));
tempDirs.push(dir);
const db = new Database(join(dir, ".fusion"));
db.init();
openDbs.push(db);
return { db, store: makeStore(db) };
}
function incidents(db: Database) {
return db.prepare("SELECT groupingKey, source, severity, status, meta FROM incidents ORDER BY id ASC").all() as Array<{
groupingKey: string;
source: string | null;
severity: string | null;
status: string;
meta: string | null;
}>;
}
const SECRETS: Record<string, string> = {
FUSION_SIGNAL_WEBHOOK_SECRET: "wh-secret",
FUSION_SIGNAL_SENTRY_SECRET: "sentry-secret",
@@ -51,6 +78,8 @@ const SECRETS: Record<string, string> = {
};
const savedEnv: Record<string, string | undefined> = {};
const tempDirs: string[] = [];
const openDbs: Database[] = [];
beforeEach(() => {
for (const [k, v] of Object.entries(SECRETS)) {
@@ -64,6 +93,8 @@ afterEach(() => {
if (savedEnv[k] === undefined) delete process.env[k];
else process.env[k] = savedEnv[k];
}
while (openDbs.length > 0) openDbs.pop()?.close();
while (tempDirs.length > 0) rmSync(tempDirs.pop()!, { recursive: true, force: true });
});
function ctxFor(source: SignalSource, payload: object, headers: Record<string, string>) {
@@ -73,6 +104,23 @@ function ctxFor(source: SignalSource, payload: object, headers: Record<string, s
return { rawBody, headers: lower, body: payload };
}
function signedSignalContext(source: SignalSource, payload: object) {
const raw = JSON.stringify(payload);
switch (source.provider) {
case "webhook":
return ctxFor(source, payload, {
"x-fusion-signature": sign(raw, SECRETS.FUSION_SIGNAL_WEBHOOK_SECRET),
"x-fusion-timestamp": String(Date.now()),
});
case "sentry":
return ctxFor(source, payload, { "sentry-hook-signature": sign(raw, SECRETS.FUSION_SIGNAL_SENTRY_SECRET) });
case "datadog":
return ctxFor(source, payload, { "x-datadog-signature": sign(raw, SECRETS.FUSION_SIGNAL_DATADOG_SECRET) });
case "pagerduty":
return ctxFor(source, payload, { "x-pagerduty-signature": `v1=${sign(raw, SECRETS.FUSION_SIGNAL_PAGERDUTY_SECRET)}` });
}
}
describe("getSignalSource registry", () => {
it("resolves all four providers and rejects unknown", () => {
expect(getSignalSource("webhook")).toBe(webhookSource);
@@ -321,12 +369,327 @@ describe("ingestSignal — Datadog & PagerDuty adapters (groupingKey from native
});
});
describe("ingestSignal — incident capture", () => {
it("writes source and normalized severity for all configured providers", async () => {
const cases = [
{
source: webhookSource,
payload: { id: "wh-1", title: "Disk full", severity: "critical", groupingKey: "wh-group", timestamp: Date.now() },
headers(raw: string) {
return {
"x-fusion-signature": sign(raw, SECRETS.FUSION_SIGNAL_WEBHOOK_SECRET),
"x-fusion-timestamp": String(Date.now()),
};
},
expected: { source: "webhook", severity: "critical", groupingKey: "wh-group" },
},
{
source: sentrySource,
payload: { data: { issue: { id: "sentry-1", title: "Fatal", level: "fatal" } }, timestamp: Date.now() },
headers(raw: string) {
return { "sentry-hook-signature": sign(raw, SECRETS.FUSION_SIGNAL_SENTRY_SECRET) };
},
expected: { source: "sentry", severity: "critical", groupingKey: "sentry-1" },
},
{
source: datadogSource,
payload: { aggreg_key: "dd-1", event_id: "dd-event-1", title: "Warn", alert_type: "warning" },
headers(raw: string) {
return { "x-datadog-signature": sign(raw, SECRETS.FUSION_SIGNAL_DATADOG_SECRET) };
},
expected: { source: "datadog", severity: "warning", groupingKey: "dd-1" },
},
{
source: pagerdutySource,
payload: {
event: {
id: "pd-event-1",
event_type: "incident.triggered",
occurred_at: new Date().toISOString(),
data: { id: "pd-1", title: "Pager", urgency: "high", status: "triggered" },
},
},
headers(raw: string) {
return { "x-pagerduty-signature": `v1=${sign(raw, SECRETS.FUSION_SIGNAL_PAGERDUTY_SECRET)}` };
},
expected: { source: "pagerduty", severity: "critical", groupingKey: "pd-1" },
},
] as const;
for (const c of cases) {
const { db, store } = makeDbStore();
const raw = JSON.stringify(c.payload);
const res = await ingestSignal({
source: c.source,
store,
rawBody: Buffer.from(raw),
headers: Object.fromEntries(Object.entries(c.headers(raw)).map(([k, v]) => [k.toLowerCase(), v])),
body: c.payload,
nonceCache: new DeliveryNonceCache(),
});
expect(res.status).toBe(201);
expect(incidents(db)).toMatchObject([{
groupingKey: c.expected.groupingKey,
source: c.expected.source,
severity: c.expected.severity,
status: "open",
}]);
}
});
it("absorbs re-fires by grouping key without inserting duplicate incident rows", async () => {
const { db, store } = makeDbStore();
const mk = (id: string) => {
const payload = { id, title: "Same outage", severity: "error", groupingKey: "same-outage" };
const raw = JSON.stringify(payload);
return {
source: webhookSource,
store,
rawBody: Buffer.from(raw),
headers: {
"x-fusion-signature": sign(raw, SECRETS.FUSION_SIGNAL_WEBHOOK_SECRET),
"x-fusion-timestamp": String(Date.now()),
},
body: payload,
nonceCache: new DeliveryNonceCache(),
};
};
expect((await ingestSignal(mk("refire-1"))).status).toBe(201);
expect((await ingestSignal(mk("refire-2"))).status).toBe(201);
const rows = incidents(db);
expect(rows).toHaveLength(1);
expect(JSON.parse(rows[0].meta ?? "{}")).toMatchObject({ occurrences: 2 });
});
it("marks resolution events as resolved for every provider", async () => {
const now = Date.now();
const cases = [
{
source: webhookSource,
groupingKey: "wh-resolve",
openPayload: { id: "wh-open", title: "Webhook outage", severity: "critical", groupingKey: "wh-resolve", timestamp: now },
resolvePayload: { id: "wh-resolved", title: "Webhook recovered", severity: "critical", groupingKey: "wh-resolve", timestamp: now, status: "resolved" },
expectedSource: "webhook",
},
{
source: sentrySource,
groupingKey: "sentry-resolve",
openPayload: {
id: "sentry-delivery-open",
action: "created",
data: { issue: { id: "sentry-resolve", title: "Sentry outage", level: "fatal" } },
timestamp: now,
},
resolvePayload: {
id: "sentry-delivery-resolved",
action: "resolved",
data: { issue: { id: "sentry-resolve", title: "Sentry recovered", level: "fatal", status: "resolved" } },
timestamp: now + 1,
},
expectedSource: "sentry",
},
{
source: datadogSource,
groupingKey: "dd-resolve",
openPayload: { aggreg_key: "dd-resolve", event_id: "dd-open", title: "CPU high", alert_type: "error" },
resolvePayload: { aggreg_key: "dd-resolve", event_id: "dd-resolved", title: "CPU recovered", alert_type: "recovery" },
expectedSource: "datadog",
},
{
source: pagerdutySource,
groupingKey: "pd-resolve",
openPayload: {
event: {
id: "pd-open",
event_type: "incident.triggered",
occurred_at: new Date(now).toISOString(),
data: { id: "pd-resolve", title: "PagerDuty outage", urgency: "high", status: "triggered" },
},
},
resolvePayload: {
event: {
id: "pd-resolved",
event_type: "incident.resolved",
occurred_at: new Date(now + 1_000).toISOString(),
data: { id: "pd-resolve", title: "PagerDuty recovered", urgency: "high", status: "resolved" },
},
},
expectedSource: "pagerduty",
},
] as const;
for (const c of cases) {
const { db, store } = makeDbStore();
expect((await ingestSignal({
source: c.source,
store,
...signedSignalContext(c.source, c.openPayload),
nonceCache: new DeliveryNonceCache(),
})).status).toBe(201);
expect((await ingestSignal({
source: c.source,
store,
...signedSignalContext(c.source, c.resolvePayload),
nonceCache: new DeliveryNonceCache(),
})).status).toBe(201);
expect(incidents(db)).toMatchObject([{ groupingKey: c.groupingKey, source: c.expectedSource, status: "resolved" }]);
}
});
it("also resolves PagerDuty incidents when only data.status is resolved", async () => {
const { db, store } = makeDbStore();
const openedAt = new Date().toISOString();
const openPayload = {
event: {
id: "pd-status-open",
event_type: "incident.triggered",
occurred_at: openedAt,
data: { id: "pd-status-resolve", title: "PagerDuty status path", urgency: "high", status: "triggered" },
},
};
const resolvePayload = {
event: {
id: "pd-status-resolved",
event_type: "incident.annotated",
occurred_at: new Date(Date.parse(openedAt) + 1_000).toISOString(),
data: { id: "pd-status-resolve", title: "PagerDuty status recovered", urgency: "high", status: "resolved" },
},
};
expect((await ingestSignal({
source: pagerdutySource,
store,
...signedSignalContext(pagerdutySource, openPayload),
nonceCache: new DeliveryNonceCache(),
})).status).toBe(201);
expect((await ingestSignal({
source: pagerdutySource,
store,
...signedSignalContext(pagerdutySource, resolvePayload),
nonceCache: new DeliveryNonceCache(),
})).status).toBe(201);
expect(incidents(db)).toMatchObject([{ groupingKey: "pd-status-resolve", source: "pagerduty", status: "resolved" }]);
});
it("keeps connector acceptance successful when the best-effort incident write fails", async () => {
const store = makeStore();
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
const payload = { id: "incident-db-failure", title: "Accepted signal", groupingKey: "incident-db-failure" };
const res = await ingestSignal({
source: webhookSource,
store,
...signedSignalContext(webhookSource, payload),
nonceCache: new DeliveryNonceCache(),
});
expect(res.status).toBe(201);
expect(res.taskId).toBe("FN-1");
expect(store._tasks).toHaveLength(1);
expect(consoleSpy).toHaveBeenCalledWith(
"[signal-incident-bridge] Failed to record connector signal",
expect.any(Error),
);
consoleSpy.mockRestore();
});
it("feeds connector-recorded incidents into aggregateSignalsAnalytics breakdowns", async () => {
const { db, store } = makeDbStore();
const sentryPayload = {
id: "sentry-analytics-open",
data: { issue: { id: "sentry-analytics", title: "Sentry analytics", level: "fatal" } },
timestamp: Date.parse("2026-03-04T00:00:00.000Z"),
};
const datadogPayload = {
aggreg_key: "datadog-analytics",
event_id: "datadog-analytics-open",
title: "Datadog analytics",
alert_type: "warning",
date: Date.parse("2026-03-04T00:05:00.000Z"),
};
expect((await ingestSignal({
source: sentrySource,
store,
...signedSignalContext(sentrySource, sentryPayload),
nonceCache: new DeliveryNonceCache(),
})).status).toBe(201);
expect((await ingestSignal({
source: datadogSource,
store,
...signedSignalContext(datadogSource, datadogPayload),
nonceCache: new DeliveryNonceCache(),
})).status).toBe(201);
const analytics = aggregateSignalsAnalytics(db, {
from: "2026-03-01T00:00:00.000Z",
to: "2026-03-31T00:00:00.000Z",
});
expect(analytics.totalSignals).toBe(2);
expect(analytics.bySource).toEqual(expect.arrayContaining([
{ source: "sentry", count: 1 },
{ source: "datadog", count: 1 },
]));
expect(analytics.bySeverity).toEqual(expect.arrayContaining([
{ severity: "critical", count: 1 },
{ severity: "warning", count: 1 },
]));
expect(analytics.byStatus).toEqual([{ status: "open", count: 2 }]);
});
it("does not write incidents for malformed or duplicate payloads", async () => {
const { db, store } = makeDbStore();
const malformed = { nope: true };
const malformedRaw = JSON.stringify(malformed);
expect((await ingestSignal({
source: webhookSource,
store,
rawBody: Buffer.from(malformedRaw),
headers: {
"x-fusion-signature": sign(malformedRaw, SECRETS.FUSION_SIGNAL_WEBHOOK_SECRET),
"x-fusion-timestamp": String(Date.now()),
},
body: malformed,
nonceCache: new DeliveryNonceCache(),
})).status).toBe(400);
const payload = { id: "dup-incident", title: "Duplicate", groupingKey: "dup-group" };
const raw = JSON.stringify(payload);
const mk = () => ({
source: webhookSource,
store,
rawBody: Buffer.from(raw),
headers: {
"x-fusion-signature": sign(raw, SECRETS.FUSION_SIGNAL_WEBHOOK_SECRET),
"x-fusion-timestamp": String(Date.now()),
},
body: payload,
nonceCache: new DeliveryNonceCache(),
});
expect((await ingestSignal(mk())).status).toBe(201);
expect((await ingestSignal(mk())).deduped).toBe(true);
expect(incidents(db)).toHaveLength(1);
});
});
describe("helpers", () => {
it("resolveSignalSecret reads the provider env var", () => {
expect(resolveSignalSecret(webhookSource)).toBe("wh-secret");
expect(resolveSignalSecret(webhookSource, {})).toBeUndefined();
});
it("resolveConfiguredSignalProviders reports providers with configured secrets", () => {
expect(resolveConfiguredSignalProviders({
FUSION_SIGNAL_WEBHOOK_SECRET: "wh",
FUSION_SIGNAL_PAGERDUTY_SECRET: "pd",
})).toEqual(["webhook", "pagerduty"]);
});
it("signalToTaskInput maps to a triage task with provenance metadata", () => {
const input = signalToTaskInput({
source: "webhook",

View File

@@ -25,6 +25,7 @@ import {
type CsvTable,
} from "../command-center-csv.js";
import { invalidateAllGlobalSettingsCaches } from "../project-store-resolver.js";
import { listSignalConnectorStatus, resolveConfiguredSignalProviders } from "./register-signal-routes.js";
import type { ApiRouteRegistrar } from "./types.js";
/**
@@ -373,12 +374,29 @@ export const registerCommandCenterRoutes: ApiRouteRegistrar = (ctx) => {
}
});
/**
* GET /api/command-center/signals/connectors
* Per-provider signal connector configuration status without secret values.
*
* FNXC:CommandCenter 2026-06-25-22:36:
* The Signals empty state must be honest about setup state. Expose configured booleans through the same scoped/authenticated Command Center route family, never the raw HMAC secret, so the UI can avoid implying data merely has not arrived when no provider is configured.
*/
router.get("/command-center/signals/connectors", async (req, res) => {
try {
await getScopedStore(req);
res.json({ connectors: listSignalConnectorStatus() });
} catch (err: unknown) {
if (err instanceof ApiError) throw err;
rethrowAsApiError(err, "Failed to list signal connector status");
}
});
/**
* GET /api/command-center/signals
* External Signals metrics backed by locally recorded incidents.
*
* FNXC:CommandCenter 2026-06-19-00:00:
* The Signals surface must not be a phantom endpoint. Mirror sibling Command Center routes by resolving getScopedStore(req) before reading incidents, so project-A callers only see project-A signal volume and MTTR stays the honest unavailable sentinel when no incidents are resolved.
* The Signals surface must not be a phantom endpoint. Mirror sibling Command Center routes by resolving getScopedStore(req) before reading incidents, so project-A callers only see project-A signal volume and MTTR stays the honest unavailable sentinel when no incidents are resolved. Include connector configuration separately from counts so the UI can distinguish "not configured" from "configured but quiet" without using the write-only ingestion bearer-token path.
*/
router.get("/command-center/signals", async (req, res) => {
try {
@@ -388,7 +406,14 @@ export const registerCommandCenterRoutes: ApiRouteRegistrar = (ctx) => {
from: range.from,
to: range.to,
});
res.json(result);
const configured = resolveConfiguredSignalProviders();
res.json({
...result,
connectors: {
configured,
anyConfigured: configured.length > 0,
},
});
} catch (err: unknown) {
if (err instanceof ApiError) throw err;
rethrowAsApiError(err, "Failed to aggregate signal analytics");

View File

@@ -1,5 +1,6 @@
import type { Request, Response } from "express";
import type { Task, TaskStore } from "@fusion/core";
import { ingestIncidentSignal, resolveIncident } from "../monitor-store.js";
import { ApiError, badRequest, rateLimited, unauthorized } from "../api-error.js";
import {
DeliveryNonceCache,
@@ -60,10 +61,37 @@ export function resolveSignalSecret(
return value && value.length > 0 ? value : undefined;
}
export interface SignalConnectorStatus {
provider: SignalProvider;
configured: boolean;
}
/**
* FNXC:CommandCenterSignals 2026-06-25-22:36:
* The Signals UI needs configuration truth without ever reading secret values. Return provider booleans only so empty states can distinguish "no connector configured" from "configured but quiet" while keeping HMAC secrets write-only environment data.
*/
export function listSignalConnectorStatus(env: NodeJS.ProcessEnv = process.env): SignalConnectorStatus[] {
return Object.values(SIGNAL_SOURCES).map((source) => ({
provider: source.provider,
configured: resolveSignalSecret(source, env) !== undefined,
}));
}
export function resolveConfiguredSignalProviders(env: NodeJS.ProcessEnv = process.env): SignalProvider[] {
return listSignalConnectorStatus(env)
.filter((status) => status.configured)
.map((status) => status.provider);
}
const SIGNAL_DELIVERY_META_KEY = "signalDeliveryId";
const SIGNAL_GROUPING_META_KEY = "signalGroupingKey";
const SIGNAL_SOURCE_META_KEY = "signalSource";
function signalTimestampToIso(timestamp: number | undefined): string | undefined {
if (timestamp === undefined || !Number.isFinite(timestamp)) return undefined;
return new Date(timestamp).toISOString();
}
/**
* Persistent delivery dedup: has a task already been created for this provider +
* external id? Scans recent tasks for the provenance marker. Mirrors the spirit
@@ -170,6 +198,33 @@ export async function ingestSignal(deps: SignalIngestDeps): Promise<SignalIngest
// 5. Create the triage task.
const task = await store.createTask(signalToTaskInput(signal));
try {
/*
FNXC:CommandCenterSignals 2026-06-25-22:25:
FN-6706 makes verified connector events durable beyond task creation: every new actionable signal writes/absorbs an incidents row with provider source, normalized severity, and open/resolved status so the Command Center Signals endpoint can report real external pressure. Incident storage is intentionally best-effort after task creation, so a local analytics write failure is logged but never rejects the upstream webhook after Fusion accepted the triage task.
FNXC:CommandCenterSignals 2026-06-25-22:25:
Resolution signals are recorded before resolveIncident runs. This preserves cold-resolve events (for example Datadog recovery after Fusion missed the firing alert) as resolved metrics rows instead of silently dropping provider/status visibility.
*/
const db = store.getDatabase();
const at = signalTimestampToIso(signal.timestamp) ?? new Date().toISOString();
ingestIncidentSignal(db, {
groupingKey: signal.groupingKey,
title: signal.title,
severity: signal.severity,
source: signal.source,
link: signal.link,
meta: signal.meta,
at,
});
if (signal.resolution === "resolved") {
resolveIncident(db, signal.groupingKey, at);
}
} catch (err) {
console.error("[signal-incident-bridge] Failed to record connector signal", err);
}
return { status: 201, taskId: task.id };
}

View File

@@ -22,6 +22,9 @@ export type SignalSeverity = "critical" | "error" | "warning" | "info";
/** Supported external signal providers. */
export type SignalProvider = "sentry" | "datadog" | "pagerduty" | "webhook";
/** Normalized lifecycle intent for an ingested signal. */
export type SignalResolution = "open" | "resolved";
/**
* Field-length caps applied to every normalized {@link Signal} before it is
* turned into a task. External input is never trusted — caps bound storage and
@@ -65,6 +68,11 @@ export interface Signal {
body?: string;
/** Normalized severity. */
severity: SignalSeverity;
/**
* FNXC:Signals 2026-06-25-22:21:
* Connector events must distinguish fire from recovery so incident-backed Signals metrics can preserve status. Omitted means "open" for backward-compatible task creation; "resolved" routes the grouped incident to resolveIncident instead of opening another occurrence.
*/
resolution?: SignalResolution;
/**
* Optional canonical URL back to the source. Treated as SSRF-untrusted: it is
* stored as data and only rendered as an external link, never fetched server

View File

@@ -16,6 +16,9 @@ import {
* include a shared-secret HMAC the user templates into a custom header
* (`X-Datadog-Signature` = HMAC-SHA256(hex) of the raw body). `groupingKey` is
* the Datadog monitor/aggregation key (`alert_id` / `aggreg_key`).
*
* FNXC:Signals 2026-06-25-22:23:
* Datadog monitor webhooks report recovery as alert_type "recovery" or "success". Normalize those to Signal.resolution="resolved" and keep all other alert types open so Command Center incident status matches monitor state.
*/
function mapAlertType(value: unknown): SignalSeverity {
@@ -34,6 +37,10 @@ function mapAlertType(value: unknown): SignalSeverity {
}
}
function isResolvedAlertType(value: unknown): boolean {
return value === "success" || value === "recovery";
}
export const datadogSource: SignalSource = {
provider: "datadog",
secretEnvVar: "FUSION_SIGNAL_DATADOG_SECRET",
@@ -86,6 +93,7 @@ export const datadogSource: SignalSource = {
title,
body: typeof p.body === "string" ? p.body : typeof p.text_only_msg === "string" ? p.text_only_msg : undefined,
severity: mapAlertType(p.alert_type),
resolution: isResolvedAlertType(p.alert_type) ? "resolved" : "open",
link: typeof p.link === "string" ? p.link : typeof p.url === "string" ? p.url : undefined,
timestamp:
typeof p.date === "number"

View File

@@ -14,6 +14,9 @@ import {
* PagerDuty v3 webhooks sign with `X-PagerDuty-Signature: v1=<hex>` =
* HMAC-SHA256 of the raw body using the subscription secret. `groupingKey` is
* the PagerDuty `incident.id` (native dedup primitive for U13's storm guard).
*
* FNXC:Signals 2026-06-25-22:23:
* PagerDuty closes incidents via event.event_type "incident.resolved" or data.status "resolved". Map both to Signal.resolution="resolved" so recovery webhooks resolve the same grouped incident that trigger/ack events opened.
*/
function mapUrgency(urgency: unknown, severity: unknown): SignalSeverity {
@@ -34,6 +37,10 @@ function parsePagerDutySignatureHeader(header: string | undefined): string | und
return undefined;
}
function isResolvedPagerDutyEvent(eventType: unknown, status: unknown): boolean {
return eventType === "incident.resolved" || status === "resolved";
}
export const pagerdutySource: SignalSource = {
provider: "pagerduty",
secretEnvVar: "FUSION_SIGNAL_PAGERDUTY_SECRET",
@@ -74,6 +81,8 @@ export const pagerdutySource: SignalSource = {
const eventId =
typeof event.id === "string" ? event.id : incidentId;
const eventType = typeof event.event_type === "string" ? event.event_type : undefined;
const status = typeof data.status === "string" ? data.status : undefined;
const signal: Signal = {
source: "pagerduty",
externalId: eventId,
@@ -81,12 +90,13 @@ export const pagerdutySource: SignalSource = {
title,
body: typeof data.description === "string" ? data.description : undefined,
severity: mapUrgency(data.urgency, data.severity),
resolution: isResolvedPagerDutyEvent(eventType, status) ? "resolved" : "open",
link: typeof data.html_url === "string" ? data.html_url : undefined,
timestamp:
typeof event.occurred_at === "string" ? Date.parse(event.occurred_at) : undefined,
meta: {
eventType: typeof event.event_type === "string" ? event.event_type : undefined,
status: typeof data.status === "string" ? data.status : undefined,
eventType,
status,
},
};
return applySignalCaps(signal);

View File

@@ -15,6 +15,9 @@ import {
* Sentry signs webhooks with `Sentry-Hook-Signature` = HMAC-SHA256(hex) of the
* raw request body using the integration's client secret. `groupingKey` is the
* Sentry `issue.id` (its native dedup primitive) — used by U13's storm guard.
*
* FNXC:Signals 2026-06-25-22:23:
* Sentry issue webhooks represent recovery as action "resolved" or issue.status "resolved". Normalize both to Signal.resolution="resolved" so the incidents bridge closes the grouped issue while every other action is treated as an open/fire event.
*/
function mapLevel(level: unknown): SignalSeverity {
@@ -34,6 +37,10 @@ function mapLevel(level: unknown): SignalSeverity {
}
}
function mapSentryResolution(action: unknown, status: unknown): Signal["resolution"] {
return action === "resolved" || status === "resolved" ? "resolved" : "open";
}
export const sentrySource: SignalSource = {
provider: "sentry",
secretEnvVar: "FUSION_SIGNAL_SENTRY_SECRET",
@@ -93,13 +100,23 @@ export const sentrySource: SignalSource = {
? issue.permalink
: undefined;
const deliveryId =
(typeof p.id === "string" && p.id) ||
(typeof p.event_id === "string" && p.event_id) ||
`${issueId}:${typeof p.action === "string" ? p.action : "event"}:${typeof p.timestamp === "number" ? p.timestamp : "latest"}`;
const signal: Signal = {
source: "sentry",
externalId: issueId,
/**
* FNXC:Signals 2026-06-25-23:31:
* Sentry grouping is issue-scoped, but delivery dedup must not suppress a later resolved action for the same issue. Prefer webhook delivery ids and otherwise include action/timestamp so open and recovery events can both reach the incidents bridge.
*/
externalId: deliveryId,
groupingKey: issueId,
title,
body: typeof issue.culprit === "string" ? issue.culprit : undefined,
severity: mapLevel(issue.level),
resolution: mapSentryResolution(p.action, issue.status),
link,
timestamp:
typeof p.timestamp === "number"

View File

@@ -30,6 +30,9 @@ import {
* "timestamp"?: <epoch ms>,
* "meta"?: { ... }
* }
*
* FNXC:Signals 2026-06-25-22:23:
* Generic callers can clear an incident by sending status "resolved" or action "resolve"/"resolved". Everything else remains an open/fire event so first-party webhooks can drive both Command Center signal counts and status transitions with one payload contract.
*/
const SEVERITIES: SignalSeverity[] = ["critical", "error", "warning", "info"];
@@ -45,6 +48,10 @@ function stripSig(header: string | undefined): string | undefined {
return header.startsWith("sha256=") ? header.slice("sha256=".length) : header;
}
function mapWebhookResolution(status: unknown, action: unknown): Signal["resolution"] {
return status === "resolved" || action === "resolved" || action === "resolve" ? "resolved" : "open";
}
export const webhookSource: SignalSource = {
provider: "webhook",
secretEnvVar: "FUSION_SIGNAL_WEBHOOK_SECRET",
@@ -88,6 +95,7 @@ export const webhookSource: SignalSource = {
title,
body: typeof p.body === "string" ? p.body : undefined,
severity: coerceSeverity(p.severity),
resolution: mapWebhookResolution(p.status, p.action),
link: typeof p.link === "string" ? p.link : undefined,
timestamp: typeof p.timestamp === "number" ? p.timestamp : undefined,
meta: p.meta && typeof p.meta === "object" ? (p.meta as Record<string, unknown>) : undefined,