prismalens 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. package/README.md +29 -13
  2. package/dist/bin/prismalens.js +39 -50
  3. package/dist/chunk-2Z7QZSIS.js +20 -0
  4. package/dist/chunk-5BT3XQOG.js +2990 -0
  5. package/dist/chunk-6N4PHBII.js +30 -0
  6. package/dist/chunk-CTKCK7QT.js +387 -0
  7. package/dist/chunk-CVSS5JPC.js +130 -0
  8. package/dist/chunk-HPE5CTO4.js +22 -0
  9. package/dist/chunk-PTBCAI34.js +34 -0
  10. package/dist/chunk-PVQVFRIV.js +452 -0
  11. package/dist/chunk-QDK5PAI3.js +155 -0
  12. package/dist/chunk-SSEIMBQ2.js +204 -0
  13. package/dist/chunk-TGP6QTFO.js +156 -0
  14. package/dist/chunk-V6M6ISKI.js +161 -0
  15. package/dist/chunk-XH6DIKTA.js +83 -0
  16. package/dist/src/cli/auth.js +112 -101
  17. package/dist/src/cli/doctor.js +184 -187
  18. package/dist/src/cli/flags.js +6 -29
  19. package/dist/src/cli/grouping.js +16 -227
  20. package/dist/src/cli/init.js +58 -52
  21. package/dist/src/cli/investigate.js +23 -465
  22. package/dist/src/cli/listen.js +550 -286
  23. package/dist/src/cli/report.js +70 -67
  24. package/dist/src/cli/serve.js +260 -30
  25. package/dist/src/cli/status.js +87 -86
  26. package/package.json +16 -7
  27. package/dist/bin/prismalens.d.ts +0 -3
  28. package/dist/bin/prismalens.d.ts.map +0 -1
  29. package/dist/bin/prismalens.js.map +0 -1
  30. package/dist/src/cli/auth.d.ts +0 -3
  31. package/dist/src/cli/auth.d.ts.map +0 -1
  32. package/dist/src/cli/auth.js.map +0 -1
  33. package/dist/src/cli/doctor.d.ts +0 -18
  34. package/dist/src/cli/doctor.d.ts.map +0 -1
  35. package/dist/src/cli/doctor.js.map +0 -1
  36. package/dist/src/cli/flags.d.ts +0 -2
  37. package/dist/src/cli/flags.d.ts.map +0 -1
  38. package/dist/src/cli/flags.js.map +0 -1
  39. package/dist/src/cli/grouping.d.ts +0 -18
  40. package/dist/src/cli/grouping.d.ts.map +0 -1
  41. package/dist/src/cli/grouping.js.map +0 -1
  42. package/dist/src/cli/init.d.ts +0 -3
  43. package/dist/src/cli/init.d.ts.map +0 -1
  44. package/dist/src/cli/init.js.map +0 -1
  45. package/dist/src/cli/investigate.d.ts +0 -115
  46. package/dist/src/cli/investigate.d.ts.map +0 -1
  47. package/dist/src/cli/investigate.js.map +0 -1
  48. package/dist/src/cli/listen.d.ts +0 -56
  49. package/dist/src/cli/listen.d.ts.map +0 -1
  50. package/dist/src/cli/listen.js.map +0 -1
  51. package/dist/src/cli/report.d.ts +0 -24
  52. package/dist/src/cli/report.d.ts.map +0 -1
  53. package/dist/src/cli/report.js.map +0 -1
  54. package/dist/src/cli/serve.d.ts +0 -3
  55. package/dist/src/cli/serve.d.ts.map +0 -1
  56. package/dist/src/cli/serve.js.map +0 -1
  57. package/dist/src/cli/status.d.ts +0 -19
  58. package/dist/src/cli/status.d.ts.map +0 -1
  59. package/dist/src/cli/status.js.map +0 -1
  60. package/dist/src/config/loader.d.ts +0 -22
  61. package/dist/src/config/loader.d.ts.map +0 -1
  62. package/dist/src/config/loader.js +0 -193
  63. package/dist/src/config/loader.js.map +0 -1
  64. package/dist/src/config/schema.d.ts +0 -463
  65. package/dist/src/config/schema.d.ts.map +0 -1
  66. package/dist/src/config/schema.js +0 -217
  67. package/dist/src/config/schema.js.map +0 -1
  68. package/dist/src/core/caps.d.ts +0 -36
  69. package/dist/src/core/caps.d.ts.map +0 -1
  70. package/dist/src/core/caps.js +0 -36
  71. package/dist/src/core/caps.js.map +0 -1
  72. package/dist/src/core/db.d.ts +0 -3
  73. package/dist/src/core/db.d.ts.map +0 -1
  74. package/dist/src/core/db.js +0 -105
  75. package/dist/src/core/db.js.map +0 -1
  76. package/dist/src/core/detect-repo.d.ts +0 -4
  77. package/dist/src/core/detect-repo.d.ts.map +0 -1
  78. package/dist/src/core/detect-repo.js +0 -28
  79. package/dist/src/core/detect-repo.js.map +0 -1
  80. package/dist/src/core/file-session-store.d.ts +0 -10
  81. package/dist/src/core/file-session-store.d.ts.map +0 -1
  82. package/dist/src/core/file-session-store.js +0 -34
  83. package/dist/src/core/file-session-store.js.map +0 -1
  84. package/dist/src/core/parse-stdin.d.ts +0 -2
  85. package/dist/src/core/parse-stdin.d.ts.map +0 -1
  86. package/dist/src/core/parse-stdin.js +0 -21
  87. package/dist/src/core/parse-stdin.js.map +0 -1
  88. package/dist/src/core/run-investigation.d.ts +0 -54
  89. package/dist/src/core/run-investigation.d.ts.map +0 -1
  90. package/dist/src/core/run-investigation.js +0 -200
  91. package/dist/src/core/run-investigation.js.map +0 -1
  92. package/dist/src/core/session.d.ts +0 -63
  93. package/dist/src/core/session.d.ts.map +0 -1
  94. package/dist/src/core/session.js +0 -45
  95. package/dist/src/core/session.js.map +0 -1
  96. package/dist/src/core/slack-notify.d.ts +0 -9
  97. package/dist/src/core/slack-notify.d.ts.map +0 -1
  98. package/dist/src/core/slack-notify.js +0 -57
  99. package/dist/src/core/slack-notify.js.map +0 -1
  100. package/dist/src/core/sqlite-session-store.d.ts +0 -39
  101. package/dist/src/core/sqlite-session-store.d.ts.map +0 -1
  102. package/dist/src/core/sqlite-session-store.js +0 -239
  103. package/dist/src/core/sqlite-session-store.js.map +0 -1
  104. package/dist/src/http/server.d.ts +0 -31
  105. package/dist/src/http/server.d.ts.map +0 -1
  106. package/dist/src/http/server.js +0 -164
  107. package/dist/src/http/server.js.map +0 -1
  108. package/dist/src/jsonrpc/server.d.ts +0 -15
  109. package/dist/src/jsonrpc/server.d.ts.map +0 -1
  110. package/dist/src/jsonrpc/server.js +0 -244
  111. package/dist/src/jsonrpc/server.js.map +0 -1
  112. package/dist/src/jsonrpc/types.d.ts +0 -12
  113. package/dist/src/jsonrpc/types.d.ts.map +0 -1
  114. package/dist/src/jsonrpc/types.js +0 -4
  115. package/dist/src/jsonrpc/types.js.map +0 -1
  116. package/dist/src/version.d.ts +0 -8
  117. package/dist/src/version.d.ts.map +0 -1
  118. package/dist/src/version.js +0 -27
  119. package/dist/src/version.js.map +0 -1
@@ -1,305 +1,569 @@
1
- // SPDX-License-Identifier: Apache-2.0
2
- // Copyright 2026 Sumit Patel
3
- /**
4
- * `pl listen` (issue #58, Phase 1 R1) — the walking skeleton of unattended
5
- * investigation: a token-authed local HTTP receiver for Alertmanager webhooks.
6
- * Each firing alert runs the SAME seam chain as `investigate`/`serve`
7
- * (loadConfig → resolveRunSandbox → resolveInvestigation → conductRun with the
8
- * file session store), resolved PER PAYLOAD — config edits apply to the next
9
- * webhook without a restart. Alert grouping via a debounce window is implemented;
10
- * global caps (#62) and Slack notification are still future slices.
11
- */
1
+ import {
2
+ createGroupingLayer,
3
+ deriveDedupeKey,
4
+ deriveGroupKey
5
+ } from "../../chunk-SSEIMBQ2.js";
6
+ import {
7
+ createFileSessionStore,
8
+ liveTimelineEntry,
9
+ resolveRepoSlug,
10
+ resolveRunSandbox
11
+ } from "../../chunk-PVQVFRIV.js";
12
+ import {
13
+ loadConfig
14
+ } from "../../chunk-V6M6ISKI.js";
15
+ import {
16
+ createSessionManager
17
+ } from "../../chunk-CTKCK7QT.js";
18
+ import {
19
+ isSynthConfigured,
20
+ pickServiceLabel,
21
+ resolveInvestigation
22
+ } from "../../chunk-QDK5PAI3.js";
23
+ import "../../chunk-CVSS5JPC.js";
24
+ import "../../chunk-TGP6QTFO.js";
25
+ import {
26
+ assertKnownFlags
27
+ } from "../../chunk-PTBCAI34.js";
28
+ import {
29
+ SeveritySchema,
30
+ conductRun
31
+ } from "../../chunk-5BT3XQOG.js";
32
+ import "../../chunk-6N4PHBII.js";
33
+ import "../../chunk-XH6DIKTA.js";
34
+ import "../../chunk-2Z7QZSIS.js";
35
+
36
+ // src/cli/listen.ts
12
37
  import { existsSync } from "node:fs";
13
38
  import { resolve } from "node:path";
14
- import { conductRun as engineConductRun } from "@prismalens/engine";
39
+
40
+ // ../@prismalens/contracts/dist/schemas/webhook.js
41
+ import { z } from "zod";
42
+ var PrometheusAlertSchema = z.object({
43
+ status: z.enum(["firing", "resolved"]),
44
+ labels: z.record(z.string()),
45
+ annotations: z.record(z.string()).optional(),
46
+ startsAt: z.string(),
47
+ endsAt: z.string().optional(),
48
+ generatorURL: z.string().optional(),
49
+ fingerprint: z.string().optional()
50
+ });
51
+ var PrometheusWebhookSchema = z.object({
52
+ version: z.string().optional(),
53
+ groupKey: z.string().optional(),
54
+ truncatedAlerts: z.number().optional(),
55
+ status: z.enum(["firing", "resolved"]),
56
+ receiver: z.string().optional(),
57
+ groupLabels: z.record(z.string()).optional(),
58
+ commonLabels: z.record(z.string()).optional(),
59
+ commonAnnotations: z.record(z.string()).optional(),
60
+ externalURL: z.string().optional(),
61
+ alerts: z.array(PrometheusAlertSchema)
62
+ });
63
+ var PrometheusWebhookResponseSchema = z.object({
64
+ received: z.number().int(),
65
+ processed: z.number().int(),
66
+ alertIds: z.array(z.string().uuid())
67
+ });
68
+ var GenericWebhookSchema = z.object({
69
+ title: z.string().min(1),
70
+ description: z.string().optional(),
71
+ severity: SeveritySchema.optional(),
72
+ source: z.string().optional(),
73
+ sourceAlertId: z.string().optional(),
74
+ sourceUrl: z.string().optional(),
75
+ serviceId: z.string().uuid().optional(),
76
+ serviceName: z.string().optional(),
77
+ // Will be matched to service by name
78
+ tags: z.array(z.string()).optional(),
79
+ labels: z.record(z.string()).optional(),
80
+ rawPayload: z.record(z.unknown()).optional()
81
+ });
82
+ var GenericWebhookResponseSchema = z.object({
83
+ alertId: z.string().uuid(),
84
+ incidentId: z.string().uuid().optional(),
85
+ isNewIncident: z.boolean()
86
+ });
87
+
88
+ // src/cli/listen.ts
15
89
  import { defineCommand } from "citty";
16
90
  import { consola } from "consola";
17
- import { loadConfig } from "../config/loader.js";
18
- import { createCapsGate, } from "../core/caps.js";
19
- import { resolveRepoSlug } from "../core/detect-repo.js";
20
- import { createFileSessionStore } from "../core/file-session-store.js";
21
- import { isSynthConfigured, pickServiceLabel, resolveInvestigation, } from "../core/run-investigation.js";
22
- import { createSessionManager } from "../core/session.js";
23
- import { startListenServer, WEBHOOK_PATH, } from "../http/server.js";
24
- import { assertKnownFlags } from "./flags.js";
25
- import { createGroupingLayer } from "./grouping.js";
26
- import { liveTimelineEntry, resolveRunSandbox as realResolveRunSandbox, } from "./investigate.js";
27
- /**
28
- * The two external boundaries of the chain, injectable for tests: `conductRun`
29
- * spawns a harness, `resolveRunSandbox` probes/arms a sandbox. Everything else
30
- * (config loader, resolver, file store) runs real.
31
- */
32
- import { notifyRun } from "../core/slack-notify.js";
33
- /**
34
- * Build the per-alert investigation executor the intake server drives — the
35
- * `handleInvestigate` chain (jsonrpc/server.ts) minus the RPC framing. Rejects
36
- * on a failed run (the server logs it and keeps serving); the sandbox is
37
- * CALLER-OWNED and destroyed in `finally` (ADR-0020), matching investigate/serve.
38
- */
39
- /**
40
- * Per-payload repo/cwd resolution (issue #58 AC5): the alert's service label
41
- * (`service`/`namespace`/`job`) → `services[name].repo` → a `repos` entry's
42
- * `local_path`, else the value itself when it is an existing local checkout.
43
- * No label / no catalog match / no checkout ⇒ the listen cwd, exactly like a
44
- * manual `pl investigate` run from that directory (single-player default).
45
- */
46
- export function resolveRepoPath(alert, config) {
47
- const name = pickServiceLabel(alert);
48
- if (!name) {
49
- throw new Error(`Listen dispatch refused: alert is missing a service label (cannot resolve an investigation workspace).`);
50
- }
51
- const repoRef = config.services[name]?.repo;
52
- if (!repoRef) {
53
- throw new Error(`Listen dispatch refused: service "${name}" has no mapped repo in config (cannot resolve an investigation workspace).`);
91
+
92
+ // src/core/caps.ts
93
+ var HOUR_MS = 36e5;
94
+ function createCapsGate() {
95
+ let active = 0;
96
+ const startsMs = [];
97
+ return {
98
+ get activeCount() {
99
+ return active;
100
+ },
101
+ tryDispatch(caps, nowMs) {
102
+ if (caps.maxConcurrent !== void 0 && active >= caps.maxConcurrent) {
103
+ return { allow: false, reason: "concurrency" };
104
+ }
105
+ const cutoff = nowMs - HOUR_MS;
106
+ while (startsMs.length > 0 && startsMs[0] <= cutoff) {
107
+ startsMs.shift();
108
+ }
109
+ if (caps.maxPerHour !== void 0 && startsMs.length >= caps.maxPerHour) {
110
+ return { allow: false, reason: "per_hour" };
111
+ }
112
+ active++;
113
+ startsMs.push(nowMs);
114
+ return { allow: true };
115
+ },
116
+ release() {
117
+ if (active > 0) active--;
54
118
  }
55
- const localPath = config.repos[repoRef]?.local_path;
56
- if (localPath)
57
- return resolve(localPath);
58
- if (existsSync(repoRef))
59
- return resolve(repoRef);
60
- throw new Error(`Listen dispatch refused: repo "${repoRef}" (mapped from service "${name}") does not exist locally (cannot resolve an investigation workspace).`);
119
+ };
61
120
  }
62
- export function createInvestigationRunner(options, deps = {}) {
63
- const conductRun = deps.conductRun ?? engineConductRun;
64
- const resolveRunSandbox = deps.resolveRunSandbox ?? realResolveRunSandbox;
65
- const notify = deps.notify ?? notifyRun;
66
- const cwd = options.cwd ? resolve(options.cwd) : process.cwd();
67
- // One gate for the server's lifetime; state (active count + hourly window)
68
- // persists across payloads even though config is re-resolved per payload.
69
- const caps = deps.capsGate ?? createCapsGate();
70
- return async (runId, alerts) => {
71
- // Per payload, not once at startup: config (and thus repo/workspace/harness)
72
- // is re-resolved for every alert, exactly like a fresh `pl investigate`.
73
- const config = await loadConfig({
74
- cwd,
75
- ...(options.configPath ? { configPath: options.configPath } : {}),
76
- });
77
- const alertname = (alerts[0]?.labels?.alertname ?? undefined);
78
- // AC5: the checkout under investigation follows the ALERT (service label →
79
- // catalog), not the directory the server happened to start in.
80
- // MUST be resolved before consuming caps so unmapped alerts do not exhaust budget.
81
- let repoPath;
82
- try {
83
- repoPath = resolveRepoPath(alerts[0], config);
84
- }
85
- catch (err) {
86
- const reason = err instanceof Error ? err.message : String(err);
87
- console.error(JSON.stringify({
88
- event: "dispatch_refused",
89
- runId,
90
- reason,
91
- ...(alertname ? { alertname } : {}),
92
- }));
93
- options.log(`Refused run ${runId} (${reason})`);
94
- const refusedSessions = createSessionManager(config.workspace.base_dir);
95
- try {
96
- await refusedSessions.recordSuppressed({
97
- runId,
98
- reason,
99
- ...(alertname ? { alertname } : {}),
100
- });
101
- }
102
- finally {
103
- refusedSessions.close?.();
104
- }
105
- return; // refused, never consume caps
106
- }
107
- const dispatchCaps = {
108
- maxConcurrent: config.listen.caps.max_concurrent,
109
- maxPerHour: config.listen.caps.max_per_hour,
110
- };
111
- const decision = caps.tryDispatch(dispatchCaps, Date.now());
112
- if (!decision.allow) {
113
- // Structured suppression log — match the repo convention
114
- // (slack-notify.ts: console.error(JSON.stringify({ event, ... }))).
115
- console.error(JSON.stringify({
116
- event: "dispatch_suppressed",
117
- runId,
118
- reason: decision.reason,
119
- ...(alertname ? { alertname } : {}),
120
- maxConcurrent: dispatchCaps.maxConcurrent,
121
- maxPerHour: dispatchCaps.maxPerHour,
122
- }));
123
- options.log(`Suppressed run ${runId} (${decision.reason} cap)`);
124
- const suppressedSessions = createSessionManager(config.workspace.base_dir);
125
- try {
126
- await suppressedSessions.recordSuppressed({
127
- runId,
128
- reason: decision.reason,
129
- ...(alertname ? { alertname } : {}),
130
- });
131
- }
132
- finally {
133
- suppressedSessions.close?.();
134
- }
135
- return; // never arm a sandbox / call conductRun for a suppressed run
136
- }
137
- const harnessName = config.agent.default;
138
- const { selection } = await resolveRunSandbox(harnessName, config.agent.sandbox, config);
139
- let sessions;
140
- try {
141
- const resolved = resolveInvestigation({
142
- alerts,
143
- repo: repoPath,
144
- ...(selection
145
- ? {
146
- sandbox: selection.sandbox,
147
- requestedSandbox: selection.requested,
148
- }
149
- : {}),
150
- ...(config.listen.caps.max_turns !== undefined
151
- ? { maxTurns: config.listen.caps.max_turns }
152
- : {}),
153
- isolateSettings: true,
154
- }, config);
155
- const { context, harness, synth, fidelity, maxBranches } = resolved;
156
- const primaryAlert = context.alerts[0];
157
- const repoSlug = await resolveRepoSlug(config.repo, repoPath);
158
- sessions = createSessionManager(config.workspace.base_dir);
159
- const fileSession = createFileSessionStore(sessions, {
160
- runId,
161
- alertname: primaryAlert.alertname,
162
- agent: resolved.harnessName,
163
- ...(repoSlug ? { repo: repoSlug } : {}),
164
- });
165
- options.log(`Investigating "${primaryAlert.alertname}" with ${resolved.harnessName} in ${repoPath} (run ${runId})`);
166
- const outcome = await conductRun({
167
- context,
168
- harness,
169
- synth,
170
- fidelity,
171
- runId,
172
- ...(maxBranches !== undefined ? { maxBranches } : {}),
173
- }, {
174
- sink: (event) => {
175
- const entry = liveTimelineEntry(event);
176
- if (entry)
177
- options.log(entry);
178
- },
179
- store: fileSession.store,
180
- });
181
- if (config.listen.slack_webhook_url) {
182
- try {
183
- await notify(config.listen.slack_webhook_url, primaryAlert.alertname, outcome);
184
- }
185
- catch (err) {
186
- // Fallback for an injected notify that violates the contract.
187
- // The real notifyRun never rejects.
188
- options.log(`Slack notification failed with error: ${err}`);
189
- }
190
- }
191
- if (!outcome.report) {
192
- throw new Error(outcome.error ?? "investigation produced no evidence");
193
- }
194
- options.log(`Report saved for run ${runId}`);
195
- }
196
- finally {
197
- caps.release();
198
- // Close this run's sqlite connection — the runner opens one PER alert,
199
- // so leaving it open would leak a handle on every webhook.
200
- sessions?.close?.();
201
- // The listen path owns the boundary's lifecycle (ADR-0020) — torn down
202
- // whichever way the run exited (report, no-evidence, error, or throw).
203
- if (selection)
204
- await selection.sandbox.destroy();
205
- }
206
- };
121
+
122
+ // src/http/server.ts
123
+ import { timingSafeEqual } from "node:crypto";
124
+ import {
125
+ createServer
126
+ } from "node:http";
127
+ var DEFAULT_MAX_PENDING = 8;
128
+ var WEBHOOK_PATH = "/webhooks/alertmanager";
129
+ function isAuthorized(req, token) {
130
+ const header = req.headers.authorization;
131
+ if (!header?.startsWith("Bearer ")) return false;
132
+ const presented = Buffer.from(header.slice("Bearer ".length));
133
+ const expected = Buffer.from(token);
134
+ return presented.length === expected.length && timingSafeEqual(presented, expected);
207
135
  }
208
- /**
209
- * The command body behind `pl listen`, separated from citty so it's testable:
210
- * load config, demand a token, start the intake server wired to the real (or
211
- * injected) investigation chain. Returns the running server (the caller owns
212
- * `close()`; the CLI just lets it hold the event loop open).
213
- */
214
- export async function startListenFromConfig(options, deps = {}) {
215
- const cwd = options.cwd ? resolve(options.cwd) : process.cwd();
216
- const config = await loadConfig({
217
- cwd,
218
- ...(options.configPath ? { configPath: options.configPath } : {}),
219
- });
220
- if (!isSynthConfigured(config)) {
221
- options.log("No Tier-1 provider configured (checked env + _FILE for: ANTHROPIC_API_KEY, OPENAI_API_KEY, GOOGLE_API_KEY, GROQ_API_KEY, OLLAMA_API_KEY) — reports will be RAW harness pass-through (un-synthesized). This is supported.");
136
+ var MAX_BODY_BYTES = 5 * 1024 * 1024;
137
+ async function readBody(req) {
138
+ const chunks = [];
139
+ let size = 0;
140
+ for await (const chunk of req) {
141
+ size += chunk.length;
142
+ if (size > MAX_BODY_BYTES) return null;
143
+ chunks.push(chunk);
144
+ }
145
+ return Buffer.concat(chunks).toString("utf-8");
146
+ }
147
+ async function startListenServer(options) {
148
+ const maxPending = options.maxPending ?? DEFAULT_MAX_PENDING;
149
+ const reply = (res, status, body) => {
150
+ res.writeHead(status, { "content-type": "application/json" });
151
+ res.end(JSON.stringify(body));
152
+ };
153
+ const handle = async (req, res) => {
154
+ const path = (req.url ?? "").split("?", 1)[0];
155
+ if (path !== WEBHOOK_PATH) {
156
+ reply(res, 404, { error: "not found" });
157
+ return;
222
158
  }
223
- if (!config.listen.token) {
224
- // An open intake is not a mode — refuse to start rather than serve unauthenticated.
225
- throw new Error("config is missing listen.token (required — intake auth). Add listen.token to prismalens.config.yaml.");
159
+ if (req.method !== "POST") {
160
+ res.setHeader("allow", "POST");
161
+ reply(res, 405, { error: "method not allowed" });
162
+ return;
226
163
  }
227
- const sessions = createSessionManager(config.workspace.base_dir);
228
- // Startup reaper (#136): mark any orphaned "running" runs from a previous
229
- // dead listener as errored, so pl status reflects reality.
230
- const staleRuns = await sessions.list({ status: ["running"] });
231
- // Liveness guard: only reap runs whose updatedAt is older than the max possible
232
- // run duration (wall-clock cap if configured, else 1 hour) plus 5 minutes slack.
233
- const runWallClockMs = config.agent.limits.wall_clock_ms || 60 * 60 * 1000;
234
- const staleThresholdMs = runWallClockMs + 5 * 60 * 1000;
235
- const now = Date.now();
236
- for (const run of staleRuns) {
237
- const updatedTime = new Date(run.updatedAt).getTime();
238
- if (now - updatedTime > staleThresholdMs) {
239
- await sessions.update(run.runId, {
240
- status: "errored",
241
- error: "listener process died mid-investigation (interrupted)",
242
- });
243
- options.log(`Reaped stale run ${run.runId} from a previous listener`);
244
- }
164
+ if (!isAuthorized(req, options.token)) {
165
+ reply(res, 401, { error: "unauthorized" });
166
+ return;
245
167
  }
246
- const grouping = createGroupingLayer({
247
- windowMs: config.listen.grouping_window_ms,
248
- sessions,
249
- runInvestigation: createInvestigationRunner(options, deps),
250
- log: options.log,
168
+ if (options.grouping.isShuttingDown()) {
169
+ reply(res, 503, { error: "server is shutting down" });
170
+ req.destroy();
171
+ return;
172
+ }
173
+ const raw = await readBody(req);
174
+ if (raw === null) {
175
+ reply(res, 413, { error: "request body too large" });
176
+ req.destroy();
177
+ return;
178
+ }
179
+ let payloadObj;
180
+ try {
181
+ payloadObj = JSON.parse(raw);
182
+ } catch {
183
+ reply(res, 400, { error: "request body is not valid JSON" });
184
+ return;
185
+ }
186
+ const parsed = PrometheusWebhookSchema.safeParse(payloadObj);
187
+ if (!parsed.success) {
188
+ const reason = parsed.error.issues.map((i) => `${i.path.join(".") || "payload"}: ${i.message}`).join("; ");
189
+ reply(res, 400, { error: `invalid Alertmanager payload \u2014 ${reason}` });
190
+ return;
191
+ }
192
+ const firing = parsed.data.alerts.filter(
193
+ (a) => a.status === "firing"
194
+ );
195
+ const payload = payloadObj;
196
+ const pendingGroups = options.grouping.pendingGroups();
197
+ const newGroupCount = options.grouping.newGroupCount(firing, payload);
198
+ if (newGroupCount > 0 && pendingGroups > 0 && pendingGroups + newGroupCount > maxPending) {
199
+ reply(res, 503, {
200
+ error: `investigation queue full (${pendingGroups} pending, max ${maxPending}) \u2014 retry later`
201
+ });
202
+ return;
203
+ }
204
+ reply(res, 202, {
205
+ received: parsed.data.alerts.length,
206
+ accepted: firing.length
251
207
  });
252
- const server = await startListenServer({
253
- host: options.host ?? config.listen.host,
254
- port: config.listen.port,
255
- token: config.listen.token,
256
- maxPending: config.listen.max_pending,
257
- grouping,
258
- log: options.log,
208
+ const timestamp = (/* @__PURE__ */ new Date()).toISOString();
209
+ const fingerprint = firing.length > 0 ? deriveDedupeKey(firing[0]) : "none";
210
+ const groupKey = firing.length > 0 ? deriveGroupKey(firing[0], payload) : "none";
211
+ console.error(
212
+ JSON.stringify({
213
+ event: "intake_accepted",
214
+ timestamp,
215
+ fingerprint,
216
+ groupKey,
217
+ alertCount: parsed.data.alerts.length,
218
+ acceptedCount: firing.length
219
+ })
220
+ );
221
+ options.grouping.admit(firing, payload);
222
+ };
223
+ const server = createServer((req, res) => {
224
+ handle(req, res).catch(() => {
225
+ if (!res.headersSent) reply(res, 500, { error: "internal error" });
226
+ else res.end();
259
227
  });
260
- // grouping holds a connection for the server's lifetime; release it on close
261
- // (server.close already drains grouping via shutdown()).
262
- return {
263
- ...server,
264
- async close() {
265
- await server.close();
266
- sessions.close?.();
267
- },
268
- };
228
+ });
229
+ await new Promise((resolve2, reject) => {
230
+ server.once("error", reject);
231
+ server.listen(options.port, options.host ?? "127.0.0.1", () => resolve2());
232
+ });
233
+ const address = server.address();
234
+ if (address === null || typeof address === "string") {
235
+ throw new Error("listen server bound to a non-TCP address");
236
+ }
237
+ return {
238
+ port: address.port,
239
+ host: address.address,
240
+ close: () => {
241
+ options.grouping.shutdown();
242
+ return new Promise((resolve2, reject) => {
243
+ server.close((err) => err ? reject(err) : resolve2());
244
+ });
245
+ }
246
+ };
269
247
  }
270
- export default defineCommand({
271
- meta: {
272
- name: "listen",
273
- description: "Start a token-authed local HTTP listener for Alertmanager webhooks; each firing alert triggers an investigation (Phase 1 R1).\n\nPort and token are configured in the `listen:` block of prismalens.config.yaml (or via PRISMALENS_LISTEN_TOKEN env var).\n\nExamples:\n $ pl listen\n $ pl listen --config my-stack.yaml",
274
- },
275
- args: {
276
- config: {
277
- type: "string",
278
- description: "Path to prismalens.config.yaml (else global→project hierarchy).",
248
+
249
+ // src/core/slack-notify.ts
250
+ function buildSlackMessage(input) {
251
+ const { alertname, runId, outcome } = input;
252
+ if (outcome.failureKind === "cancelled") {
253
+ return "";
254
+ }
255
+ if (outcome.failureKind === "error") {
256
+ return `${alertname}
257
+ Investigation errored: ${outcome.error ?? "unknown error"}
258
+ Run ${runId} \u2014 pl report ${runId}`;
259
+ }
260
+ if (outcome.failureKind === "no-evidence") {
261
+ return `${alertname}
262
+ Investigated \u2014 insufficient evidence to determine a cause.
263
+ Run ${runId} \u2014 pl report ${runId}`;
264
+ }
265
+ const report = outcome.report;
266
+ if (!report) {
267
+ return "";
268
+ }
269
+ let message = `${alertname}
270
+ ${report.summary}
271
+ Root cause: ${report.rootCause ?? "not determined"}`;
272
+ if (report.hypotheses.length > 0) {
273
+ const topHypothesis = report.hypotheses[0];
274
+ message += `
275
+ Top hypothesis: ${topHypothesis.statement} (${topHypothesis.evidence.length} evidence)`;
276
+ }
277
+ message += `
278
+ Run ${runId} \u2014 pl report ${runId}`;
279
+ return message;
280
+ }
281
+ async function postToSlack(webhookUrl, text) {
282
+ const res = await fetch(webhookUrl, {
283
+ method: "POST",
284
+ headers: { "Content-Type": "application/json" },
285
+ body: JSON.stringify({ text }),
286
+ signal: AbortSignal.timeout(5e3)
287
+ });
288
+ if (!res.ok) {
289
+ throw new Error(`Slack webhook returned ${res.status}`);
290
+ }
291
+ }
292
+ async function notifyRun(webhookUrl, alertname, outcome) {
293
+ try {
294
+ const text = buildSlackMessage({
295
+ alertname,
296
+ runId: outcome.runId,
297
+ outcome
298
+ });
299
+ if (!text) {
300
+ return;
301
+ }
302
+ await postToSlack(webhookUrl, text);
303
+ } catch (err) {
304
+ console.error(
305
+ JSON.stringify({
306
+ event: "slack_delivery_failed",
307
+ runId: outcome.runId,
308
+ error: err instanceof Error ? err.message : String(err)
309
+ })
310
+ );
311
+ }
312
+ }
313
+
314
+ // src/cli/listen.ts
315
+ function resolveRepoPath(alert, config) {
316
+ const name = pickServiceLabel(alert);
317
+ if (!name) {
318
+ throw new Error(
319
+ `Listen dispatch refused: alert is missing a service label (cannot resolve an investigation workspace).`
320
+ );
321
+ }
322
+ const repoRef = config.services[name]?.repo;
323
+ if (!repoRef) {
324
+ throw new Error(
325
+ `Listen dispatch refused: service "${name}" has no mapped repo in config (cannot resolve an investigation workspace).`
326
+ );
327
+ }
328
+ const localPath = config.repos[repoRef]?.local_path;
329
+ if (localPath) return resolve(localPath);
330
+ if (existsSync(repoRef)) return resolve(repoRef);
331
+ throw new Error(
332
+ `Listen dispatch refused: repo "${repoRef}" (mapped from service "${name}") does not exist locally (cannot resolve an investigation workspace).`
333
+ );
334
+ }
335
+ function createInvestigationRunner(options, deps = {}) {
336
+ const conductRun2 = deps.conductRun ?? conductRun;
337
+ const resolveRunSandbox2 = deps.resolveRunSandbox ?? resolveRunSandbox;
338
+ const notify = deps.notify ?? notifyRun;
339
+ const cwd = options.cwd ? resolve(options.cwd) : process.cwd();
340
+ const caps = deps.capsGate ?? createCapsGate();
341
+ return async (runId, alerts) => {
342
+ const config = await loadConfig({
343
+ cwd,
344
+ ...options.configPath ? { configPath: options.configPath } : {}
345
+ });
346
+ const alertname = alerts[0]?.labels?.alertname ?? void 0;
347
+ let repoPath;
348
+ try {
349
+ repoPath = resolveRepoPath(alerts[0], config);
350
+ } catch (err) {
351
+ const reason = err instanceof Error ? err.message : String(err);
352
+ console.error(
353
+ JSON.stringify({
354
+ event: "dispatch_refused",
355
+ runId,
356
+ reason,
357
+ ...alertname ? { alertname } : {}
358
+ })
359
+ );
360
+ options.log(`Refused run ${runId} (${reason})`);
361
+ const refusedSessions = createSessionManager(config.workspace.dir);
362
+ try {
363
+ await refusedSessions.recordSuppressed({
364
+ runId,
365
+ reason,
366
+ ...alertname ? { alertname } : {}
367
+ });
368
+ } finally {
369
+ refusedSessions.close?.();
370
+ }
371
+ return;
372
+ }
373
+ const dispatchCaps = {
374
+ maxConcurrent: config.listen.caps.max_concurrent,
375
+ maxPerHour: config.listen.caps.max_per_hour
376
+ };
377
+ const decision = caps.tryDispatch(dispatchCaps, Date.now());
378
+ if (!decision.allow) {
379
+ console.error(
380
+ JSON.stringify({
381
+ event: "dispatch_suppressed",
382
+ runId,
383
+ reason: decision.reason,
384
+ ...alertname ? { alertname } : {},
385
+ maxConcurrent: dispatchCaps.maxConcurrent,
386
+ maxPerHour: dispatchCaps.maxPerHour
387
+ })
388
+ );
389
+ options.log(`Suppressed run ${runId} (${decision.reason} cap)`);
390
+ const suppressedSessions = createSessionManager(config.workspace.dir);
391
+ try {
392
+ await suppressedSessions.recordSuppressed({
393
+ runId,
394
+ reason: decision.reason,
395
+ ...alertname ? { alertname } : {}
396
+ });
397
+ } finally {
398
+ suppressedSessions.close?.();
399
+ }
400
+ return;
401
+ }
402
+ const harnessName = config.agent.default;
403
+ const { selection } = await resolveRunSandbox2(
404
+ harnessName,
405
+ config.agent.sandbox,
406
+ config
407
+ );
408
+ let sessions;
409
+ try {
410
+ const resolved = resolveInvestigation(
411
+ {
412
+ alerts,
413
+ repo: repoPath,
414
+ ...selection ? {
415
+ sandbox: selection.sandbox,
416
+ requestedSandbox: selection.requested
417
+ } : {},
418
+ ...config.listen.caps.max_turns !== void 0 ? { maxTurns: config.listen.caps.max_turns } : {},
419
+ isolateSettings: true
279
420
  },
280
- host: {
281
- type: "string",
282
- description: "Bind host (default 127.0.0.1).",
421
+ config
422
+ );
423
+ const { context, harness, synth, fidelity, maxBranches } = resolved;
424
+ const primaryAlert = context.alerts[0];
425
+ const repoSlug = await resolveRepoSlug(config.repo, repoPath);
426
+ sessions = createSessionManager(config.workspace.dir);
427
+ const fileSession = createFileSessionStore(sessions, {
428
+ runId,
429
+ alertname: primaryAlert.alertname,
430
+ agent: resolved.harnessName,
431
+ ...repoSlug ? { repo: repoSlug } : {}
432
+ });
433
+ options.log(
434
+ `Investigating "${primaryAlert.alertname}" with ${resolved.harnessName} in ${repoPath} (run ${runId})`
435
+ );
436
+ const outcome = await conductRun2(
437
+ {
438
+ context,
439
+ harness,
440
+ synth,
441
+ fidelity,
442
+ runId,
443
+ ...maxBranches !== void 0 ? { maxBranches } : {}
283
444
  },
284
- },
285
- /* v8 ignore start — process-global glue (cwd + consola binding); the body it
286
- delegates to is fully covered via startListenFromConfig tests */
287
- async run({ args, cmd }) {
288
- try {
289
- assertKnownFlags(args, cmd);
290
- const server = await startListenFromConfig({
291
- cwd: process.cwd(),
292
- ...(args.config ? { configPath: args.config } : {}),
293
- ...(args.host ? { host: args.host } : {}),
294
- log: (line) => consola.log(line),
295
- });
296
- consola.success(`Listening for Alertmanager webhooks on http://${server.host}:${server.port}${WEBHOOK_PATH}`);
445
+ {
446
+ sink: (event) => {
447
+ const entry = liveTimelineEntry(event);
448
+ if (entry) options.log(entry);
449
+ },
450
+ store: fileSession.store
297
451
  }
298
- catch (err) {
299
- consola.error(err instanceof Error ? err.message : String(err));
300
- process.exit(1);
452
+ );
453
+ if (config.listen.slack_webhook_url) {
454
+ try {
455
+ await notify(
456
+ config.listen.slack_webhook_url,
457
+ primaryAlert.alertname,
458
+ outcome
459
+ );
460
+ } catch (err) {
461
+ options.log(`Slack notification failed with error: ${err}`);
301
462
  }
463
+ }
464
+ if (!outcome.report) {
465
+ throw new Error(outcome.error ?? "investigation produced no evidence");
466
+ }
467
+ options.log(`Report saved for run ${runId}`);
468
+ } finally {
469
+ caps.release();
470
+ sessions?.close?.();
471
+ if (selection) await selection.sandbox.destroy();
472
+ }
473
+ };
474
+ }
475
+ async function startListenFromConfig(options, deps = {}) {
476
+ const cwd = options.cwd ? resolve(options.cwd) : process.cwd();
477
+ const config = await loadConfig({
478
+ cwd,
479
+ ...options.configPath ? { configPath: options.configPath } : {}
480
+ });
481
+ if (!isSynthConfigured(config)) {
482
+ options.log(
483
+ "No Tier-1 provider configured (checked env + _FILE for: ANTHROPIC_API_KEY, OPENAI_API_KEY, GOOGLE_API_KEY, GROQ_API_KEY, OLLAMA_API_KEY) \u2014 reports will be RAW harness pass-through (un-synthesized). This is supported."
484
+ );
485
+ }
486
+ if (!config.listen.token) {
487
+ throw new Error(
488
+ "config is missing listen.token (required \u2014 intake auth). Add listen.token to prismalens.config.yaml."
489
+ );
490
+ }
491
+ const sessions = createSessionManager(config.workspace.dir);
492
+ const staleRuns = await sessions.list({ status: ["running"] });
493
+ const runWallClockMs = config.agent.limits.wall_clock_ms || 60 * 60 * 1e3;
494
+ const staleThresholdMs = runWallClockMs + 5 * 60 * 1e3;
495
+ const now = Date.now();
496
+ for (const run of staleRuns) {
497
+ const updatedTime = new Date(run.updatedAt).getTime();
498
+ if (now - updatedTime > staleThresholdMs) {
499
+ await sessions.update(run.runId, {
500
+ status: "errored",
501
+ error: "listener process died mid-investigation (interrupted)"
502
+ });
503
+ options.log(`Reaped stale run ${run.runId} from a previous listener`);
504
+ }
505
+ }
506
+ const grouping = createGroupingLayer({
507
+ windowMs: config.listen.grouping_window_ms,
508
+ sessions,
509
+ runInvestigation: createInvestigationRunner(options, deps),
510
+ log: options.log
511
+ });
512
+ const server = await startListenServer({
513
+ host: options.host ?? config.listen.host,
514
+ port: config.listen.port,
515
+ token: config.listen.token,
516
+ maxPending: config.listen.max_pending,
517
+ grouping,
518
+ log: options.log
519
+ });
520
+ return {
521
+ ...server,
522
+ async close() {
523
+ await server.close();
524
+ sessions.close?.();
525
+ }
526
+ };
527
+ }
528
+ var listen_default = defineCommand({
529
+ meta: {
530
+ name: "listen",
531
+ description: "Start a token-authed local HTTP listener for Alertmanager webhooks; each firing alert triggers an investigation (Phase 1 R1).\n\nPort and token are configured in the `listen:` block of prismalens.config.yaml (or via PRISMALENS_LISTEN_TOKEN env var).\n\nExamples:\n $ pl listen\n $ pl listen --config my-stack.yaml"
532
+ },
533
+ args: {
534
+ config: {
535
+ type: "string",
536
+ description: "Path to prismalens.config.yaml (else global\u2192project hierarchy)."
302
537
  },
303
- /* v8 ignore stop */
538
+ host: {
539
+ type: "string",
540
+ description: "Bind host (default 127.0.0.1)."
541
+ }
542
+ },
543
+ /* v8 ignore start — process-global glue (cwd + consola binding); the body it
544
+ delegates to is fully covered via startListenFromConfig tests */
545
+ async run({ args, cmd }) {
546
+ try {
547
+ assertKnownFlags(args, cmd);
548
+ const server = await startListenFromConfig({
549
+ cwd: process.cwd(),
550
+ ...args.config ? { configPath: args.config } : {},
551
+ ...args.host ? { host: args.host } : {},
552
+ log: (line) => consola.log(line)
553
+ });
554
+ consola.success(
555
+ `Listening for Alertmanager webhooks on http://${server.host}:${server.port}${WEBHOOK_PATH}`
556
+ );
557
+ } catch (err) {
558
+ consola.error(err instanceof Error ? err.message : String(err));
559
+ process.exit(1);
560
+ }
561
+ }
562
+ /* v8 ignore stop */
304
563
  });
305
- //# sourceMappingURL=listen.js.map
564
+ export {
565
+ createInvestigationRunner,
566
+ listen_default as default,
567
+ resolveRepoPath,
568
+ startListenFromConfig
569
+ };