@canonry/canonry 7.10.0 → 7.12.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 (35) hide show
  1. package/assets/agent-workspace/skills/aero/references/reporting.md +2 -0
  2. package/assets/agent-workspace/skills/canonry/references/aeo-analysis.md +1 -1
  3. package/assets/agent-workspace/skills/canonry/references/canonry-cli.md +30 -4
  4. package/assets/assets/{AuditHistoryPanel-BgcvPo6g.js → AuditHistoryPanel-Bpkuj-iF.js} +1 -1
  5. package/assets/assets/{BacklinksPage-DZ4tauAs.js → BacklinksPage-CbRIZhu5.js} +1 -1
  6. package/assets/assets/{HistoryPage-DWG52dz1.js → HistoryPage-Dfq1vK9k.js} +1 -1
  7. package/assets/assets/{MeasurementPropertyPage-DTx2iQLV.js → MeasurementPropertyPage-DcH0zYNN.js} +1 -1
  8. package/assets/assets/ProjectPage-enF4twWt.js +8 -0
  9. package/assets/assets/{RunsPage-B6MhRqxN.js → RunsPage-aOeJ60pX.js} +1 -1
  10. package/assets/assets/{SettingsPage-Bxv7OSHT.js → SettingsPage-nA0_ImoQ.js} +1 -1
  11. package/assets/assets/{SiteHealthSection-C5x7oiFL.js → SiteHealthSection-CUOt7jeo.js} +3 -3
  12. package/assets/assets/{TrafficPage-C5gyQopi.js → TrafficPage-C0fVYizx.js} +1 -1
  13. package/assets/assets/{TrafficSourceDetailPage-Dv2ZRNvN.js → TrafficSourceDetailPage-LMhXbFlA.js} +1 -1
  14. package/assets/assets/{extract-error-message-C5pr51ph.js → extract-error-message-5aHy0fsK.js} +1 -1
  15. package/assets/assets/index-BJh8cX5E.js +86 -0
  16. package/assets/assets/index-BNeiohKS.css +1 -0
  17. package/assets/assets/{react-sigma_core.esm.min-P5bVaYly.js → react-sigma_core.esm.min-ldX35rMq.js} +1 -1
  18. package/assets/assets/{v2-overview-adapter-QtI4xO_4.js → v2-overview-adapter-CaO9vY-l.js} +1 -1
  19. package/assets/index.html +2 -2
  20. package/dist/{chunk-5ZBN3EEK.js → chunk-7I64KEGM.js} +2 -2
  21. package/dist/{chunk-66DVP4DN.js → chunk-AU7OUDWR.js} +5786 -4450
  22. package/dist/{chunk-VSZJVBVR.js → chunk-BTHEFJCI.js} +60 -8
  23. package/dist/{chunk-FVMWUT2R.js → chunk-DXI2FTSW.js} +706 -257
  24. package/dist/{chunk-HL3C6Z7T.js → chunk-EPSHIH63.js} +3 -3
  25. package/dist/{chunk-52PHEGHT.js → chunk-GQMSJEMZ.js} +3515 -2266
  26. package/dist/{chunk-IKXVLT4M.js → chunk-SM6ZZAPK.js} +93 -5
  27. package/dist/cli.js +177 -40
  28. package/dist/{demo-server-WZIB4JKJ.js → demo-server-WJUA4EIG.js} +4 -4
  29. package/dist/index.js +6 -6
  30. package/dist/{intelligence-service-KC62A4X5.js → intelligence-service-DJRH4Z3D.js} +2 -2
  31. package/dist/mcp.js +4 -4
  32. package/package.json +12 -12
  33. package/assets/assets/ProjectPage-CCf70zbI.js +0 -8
  34. package/assets/assets/index-BhXHm5nG.js +0 -86
  35. package/assets/assets/index-Rcbm8bK1.css +0 -1
@@ -20,20 +20,20 @@ import {
20
20
  saveConfig,
21
21
  saveConfigPatch,
22
22
  sentimentInstallReadiness
23
- } from "./chunk-IKXVLT4M.js";
23
+ } from "./chunk-SM6ZZAPK.js";
24
24
  import {
25
25
  computeFactorAverages,
26
26
  deleteSiteCrawlGraphLayout,
27
27
  persistSiteCrawlGraphLayout,
28
28
  prepareSiteCrawlGraphLayout,
29
29
  recordedSharePct
30
- } from "./chunk-5ZBN3EEK.js";
30
+ } from "./chunk-7I64KEGM.js";
31
31
  import {
32
32
  CanonryMcpToolNames,
33
33
  CliError,
34
34
  PACKAGE_VERSION,
35
35
  canonryMcpTools
36
- } from "./chunk-VSZJVBVR.js";
36
+ } from "./chunk-BTHEFJCI.js";
37
37
  import {
38
38
  CC_CACHE_DIR,
39
39
  DUCKDB_SPEC,
@@ -58,12 +58,16 @@ import {
58
58
  agentMemory,
59
59
  agentSessions,
60
60
  agentToolEvents,
61
+ answerIdentityChanged,
62
+ answerIdentityFrom,
61
63
  anyUsersExist,
62
64
  apiKeys,
63
65
  apiRoutes,
66
+ applyCompetitorAutoAliases,
64
67
  assessConversionTrackingIntegrity,
65
68
  auditLog,
66
69
  backfillProjectAnswerMentions,
70
+ backfillProjectAnswerMentionsInChunks,
67
71
  backlinkDomains,
68
72
  backlinkSummaries,
69
73
  bingCoverageSnapshots,
@@ -74,15 +78,19 @@ import {
74
78
  ccReleasePaths,
75
79
  ccReleaseSyncs,
76
80
  classifyReviewsError,
81
+ competitorIdentityColumns,
77
82
  competitors,
78
83
  computeCitedCompetitorDomains,
84
+ cookieIsSecure,
79
85
  countAttributes,
80
86
  countPopulatedGroups,
81
87
  createCredentialChecker,
82
88
  createFastifyLogger,
83
89
  createLogger,
90
+ createProjectPassQueue,
84
91
  createRunCompetitorResolver,
85
92
  createUserSession,
93
+ dashboardSessions,
86
94
  deliverWebhook,
87
95
  determineCitationState,
88
96
  discoverySessions,
@@ -129,6 +137,7 @@ import {
129
137
  hashAttributes,
130
138
  hashLodging,
131
139
  hashPlaceDetails,
140
+ hashSessionToken,
132
141
  insightNotifyState,
133
142
  insights,
134
143
  inspectUrl,
@@ -156,6 +165,7 @@ import {
156
165
  notifications,
157
166
  parseCookieHeader,
158
167
  parseJsonColumn,
168
+ previewCompetitorAutoAliases,
159
169
  probeLatestRelease,
160
170
  projects,
161
171
  providerBatchRequests,
@@ -165,7 +175,9 @@ import {
165
175
  queryBacklinks,
166
176
  querySnapshots,
167
177
  queueRunIfProjectIdle,
178
+ readAnswerIdentity,
168
179
  readInstalledVersion,
180
+ readMarketCompetitorNames,
169
181
  readNegativeReviewMaxStars,
170
182
  recordSentimentCompletion,
171
183
  redactNotificationUrl,
@@ -205,7 +217,7 @@ import {
205
217
  siteLivenessState,
206
218
  toAlertView,
207
219
  usageCounters
208
- } from "./chunk-52PHEGHT.js";
220
+ } from "./chunk-GQMSJEMZ.js";
209
221
  import {
210
222
  AGENT_MEMORY_VALUE_MAX_BYTES,
211
223
  AGENT_NONE,
@@ -215,12 +227,14 @@ import {
215
227
  AdsAdGroupBillingEventTypes,
216
228
  AdsCampaignBiddingTypes,
217
229
  AgentProviderIds,
230
+ AnchoredSpanSources,
218
231
  AppError,
219
232
  BacklinkSources,
220
233
  CANONRY_NPM_PACKAGE_URL,
221
234
  CITED_URL_CAPTURE_VERSION,
222
235
  CcReleaseSyncStatuses,
223
236
  CheckNotificationPolicies,
237
+ DEFAULT_COMPETITOR_AUTO_ALIAS_MODE,
224
238
  DEFAULT_VIEWER_RESEARCH_DAILY_RUN_LIMIT,
225
239
  DiscoveryCompetitorTypes,
226
240
  FEEDBACK_LIMITS,
@@ -247,6 +261,7 @@ import {
247
261
  ProviderBatchStatuses,
248
262
  ProviderBatchSubmitError,
249
263
  ProviderDispatchModes,
264
+ ProviderNames,
250
265
  RATIO_UNIT_META_KEY,
251
266
  ROUTE_CAPABLE_CITED_URL_PROVIDERS,
252
267
  RUN_FILL_PROVIDER_BREAKER,
@@ -298,6 +313,7 @@ import {
298
313
  classifyTemplateLinkEdge,
299
314
  cloudflareQueueNameSchema,
300
315
  compareSemver,
316
+ competitorIdentityAliases,
301
317
  competitorLabelFromDomain,
302
318
  compileBrandAliases,
303
319
  computeBacklinkSummaryMetrics,
@@ -364,7 +380,6 @@ import {
364
380
  missingDependency,
365
381
  nextScheduleUpdatedAt,
366
382
  normalizeAgentSlug,
367
- normalizeCompetitorAliases,
368
383
  normalizeIdTokens,
369
384
  normalizeMeasurementExecutionQueryText,
370
385
  normalizeProjectDomain,
@@ -414,7 +429,7 @@ import {
414
429
  visibilityReportRequestSchema,
415
430
  winnabilityClassLabel,
416
431
  withRetry
417
- } from "./chunk-66DVP4DN.js";
432
+ } from "./chunk-AU7OUDWR.js";
418
433
 
419
434
  // src/runtime-context.ts
420
435
  function cliRuntimeContext(env = process.env, stdio = process) {
@@ -3545,7 +3560,8 @@ import fs9 from "fs";
3545
3560
  import path8 from "path";
3546
3561
  import { isIP } from "net";
3547
3562
  import { fileURLToPath as fileURLToPath3 } from "url";
3548
- import { and as and23, eq as eq29 } from "drizzle-orm";
3563
+ import { setImmediate as yieldToEventLoop4 } from "timers/promises";
3564
+ import { and as and23, eq as eq29, isNotNull as isNotNull4, isNull as isNull4, lte as lte2, ne as ne3, or as or3, sql as sql11 } from "drizzle-orm";
3549
3565
 
3550
3566
  // ../integration-typesafe/src/classifier.ts
3551
3567
  import { createHash } from "crypto";
@@ -4519,6 +4535,42 @@ var geminiAdapter = {
4519
4535
  }
4520
4536
  };
4521
4537
 
4538
+ // ../provider-gemini/src/anchored-spans.ts
4539
+ function isRecord2(value) {
4540
+ return value !== null && typeof value === "object" && !Array.isArray(value);
4541
+ }
4542
+ function chunkSource(chunk) {
4543
+ if (!isRecord2(chunk) || !isRecord2(chunk.web)) return null;
4544
+ const uri = typeof chunk.web.uri === "string" ? chunk.web.uri : "";
4545
+ const uriHost = hostOf(uri);
4546
+ if (uriHost && uriHost !== VERTEX_AI_SEARCH_PROXY_DOMAIN && registrableDomain(uriHost)) return uri;
4547
+ const title = typeof chunk.web.title === "string" ? chunk.web.title.trim() : "";
4548
+ return title && registrableDomain(title) ? title : null;
4549
+ }
4550
+ function extractAnchoredSpans(rawResponse) {
4551
+ const candidates = Array.isArray(rawResponse.candidates) ? rawResponse.candidates : [];
4552
+ const candidate = candidates[0];
4553
+ if (!isRecord2(candidate) || !isRecord2(candidate.groundingMetadata)) return [];
4554
+ const metadata2 = candidate.groundingMetadata;
4555
+ const chunks = Array.isArray(metadata2.groundingChunks) ? metadata2.groundingChunks : [];
4556
+ const spans = [];
4557
+ for (const support of Array.isArray(metadata2.groundingSupports) ? metadata2.groundingSupports : []) {
4558
+ if (!isRecord2(support) || !isRecord2(support.segment)) continue;
4559
+ const text = support.segment.text;
4560
+ if (typeof text !== "string" || !text.trim()) continue;
4561
+ const sources = /* @__PURE__ */ new Set();
4562
+ for (const index of Array.isArray(support.groundingChunkIndices) ? support.groundingChunkIndices : []) {
4563
+ if (typeof index !== "number" || !Number.isInteger(index) || index < 0 || index >= chunks.length) continue;
4564
+ const source = chunkSource(chunks[index]);
4565
+ if (source) sources.add(source);
4566
+ }
4567
+ for (const source of sources) {
4568
+ spans.push({ text, source, kind: "window", via: AnchoredSpanSources["gemini-support"] });
4569
+ }
4570
+ }
4571
+ return spans;
4572
+ }
4573
+
4522
4574
  // ../provider-openai/src/normalize.ts
4523
4575
  import OpenAI from "openai";
4524
4576
 
@@ -4961,6 +5013,40 @@ var openaiAdapter = {
4961
5013
  }
4962
5014
  };
4963
5015
 
5016
+ // ../provider-openai/src/anchored-spans.ts
5017
+ function isRecord3(value) {
5018
+ return value !== null && typeof value === "object" && !Array.isArray(value);
5019
+ }
5020
+ function extractAnchoredSpans2(rawResponse) {
5021
+ const spans = [];
5022
+ const output = Array.isArray(rawResponse.output) ? rawResponse.output : [];
5023
+ for (const item of output) {
5024
+ if (!isRecord3(item) || item.type !== "message" || !Array.isArray(item.content)) continue;
5025
+ for (const part of item.content) {
5026
+ if (!isRecord3(part) || part.type !== "output_text" || typeof part.text !== "string") continue;
5027
+ const text = part.text;
5028
+ const citations = (Array.isArray(part.annotations) ? part.annotations : []).flatMap((annotation) => {
5029
+ if (!isRecord3(annotation) || annotation.type !== "url_citation" || typeof annotation.url !== "string") return [];
5030
+ const start = annotation.start_index;
5031
+ const end = annotation.end_index;
5032
+ if (typeof start !== "number" || typeof end !== "number" || !Number.isInteger(start) || !Number.isInteger(end)) return [];
5033
+ if (start < 0 || end < start || end > text.length) return [];
5034
+ return [{ start, end, url: annotation.url }];
5035
+ }).sort((a, b) => a.start - b.start);
5036
+ let previousEnd = 0;
5037
+ for (const citation of citations) {
5038
+ const lineStart = citation.start === 0 ? 0 : text.lastIndexOf("\n", citation.start - 1) + 1;
5039
+ const from = Math.max(lineStart, previousEnd);
5040
+ if (citation.start > from) {
5041
+ spans.push({ text: text.slice(from, citation.start), source: citation.url, kind: "window", via: AnchoredSpanSources["openai-annotation"] });
5042
+ }
5043
+ previousEnd = Math.max(previousEnd, citation.end);
5044
+ }
5045
+ }
5046
+ }
5047
+ return spans;
5048
+ }
5049
+
4964
5050
  // ../provider-claude/src/normalize.ts
4965
5051
  import Anthropic from "@anthropic-ai/sdk";
4966
5052
 
@@ -5597,6 +5683,40 @@ var claudeAdapter = {
5597
5683
  }
5598
5684
  };
5599
5685
 
5686
+ // ../provider-claude/src/anchored-spans.ts
5687
+ function isRecord4(value) {
5688
+ return value !== null && typeof value === "object" && !Array.isArray(value);
5689
+ }
5690
+ function leadIn(text) {
5691
+ const end = text.trimEnd().length;
5692
+ if (end === 0) return "";
5693
+ return text.slice(text.lastIndexOf("\n", end - 1) + 1);
5694
+ }
5695
+ function extractAnchoredSpans3(rawResponse) {
5696
+ const spans = [];
5697
+ const content = Array.isArray(rawResponse.content) ? rawResponse.content : [];
5698
+ let previousUncited = null;
5699
+ for (const block of content) {
5700
+ if (!isRecord4(block) || block.type !== "text" || typeof block.text !== "string") continue;
5701
+ const urls = /* @__PURE__ */ new Set();
5702
+ for (const citation of Array.isArray(block.citations) ? block.citations : []) {
5703
+ if (isRecord4(citation) && citation.type === "web_search_result_location" && typeof citation.url === "string") {
5704
+ urls.add(citation.url);
5705
+ }
5706
+ }
5707
+ if (urls.size === 0) {
5708
+ previousUncited = block.text;
5709
+ continue;
5710
+ }
5711
+ const window = (previousUncited === null ? "" : leadIn(previousUncited)) + block.text;
5712
+ for (const url of urls) {
5713
+ spans.push({ text: window, source: url, kind: "window", via: AnchoredSpanSources["claude-citation"] });
5714
+ }
5715
+ previousUncited = null;
5716
+ }
5717
+ return spans;
5718
+ }
5719
+
5600
5720
  // ../provider-local/src/normalize.ts
5601
5721
  import OpenAI3 from "openai";
5602
5722
 
@@ -6631,6 +6751,54 @@ function reparseStoredResult4(rawResponse) {
6631
6751
  if (agent) return parseAgentResponse(agent);
6632
6752
  return parseSonarResponse(rawResponse);
6633
6753
  }
6754
+ function extractAnchoredSpans4(rawResponse) {
6755
+ const agent = agentResponseOf(rawResponse);
6756
+ let text;
6757
+ let sourceFor;
6758
+ if (agent) {
6759
+ text = extractAgentAnswerText(agent);
6760
+ const byId = /* @__PURE__ */ new Map();
6761
+ for (const item of agentOutput(agent)) {
6762
+ if (item.type !== "search_results" || !Array.isArray(item.results)) continue;
6763
+ for (const result2 of item.results) {
6764
+ if (!isRecord5(result2) || typeof result2.id !== "number" || typeof result2.url !== "string" || !result2.url) continue;
6765
+ if (!byId.has(result2.id)) byId.set(result2.id, result2.url);
6766
+ }
6767
+ }
6768
+ sourceFor = (marker) => byId.get(marker);
6769
+ } else {
6770
+ text = extractAnswerText3(rawResponse);
6771
+ const citations = extractCitations(rawResponse);
6772
+ sourceFor = (marker) => citations[marker - 1];
6773
+ }
6774
+ const spans = [];
6775
+ if (!text.includes("[")) return spans;
6776
+ let groupStart = -1;
6777
+ let groupEnd = -1;
6778
+ let previousEnd = 0;
6779
+ let markers = [];
6780
+ const flush = () => {
6781
+ if (markers.length === 0) return;
6782
+ const lineStart = groupStart === 0 ? 0 : text.lastIndexOf("\n", groupStart - 1) + 1;
6783
+ const from = Math.max(lineStart, previousEnd);
6784
+ const window = text.slice(from, groupStart);
6785
+ if (window.trim()) {
6786
+ for (const source of new Set(markers.map(sourceFor).filter((url) => typeof url === "string" && url.length > 0))) {
6787
+ spans.push({ text: window, source, kind: "window", via: AnchoredSpanSources["perplexity-marker"] });
6788
+ }
6789
+ }
6790
+ previousEnd = groupEnd;
6791
+ markers = [];
6792
+ };
6793
+ for (const match of text.matchAll(/\[(\d{1,3})\]/g)) {
6794
+ if (markers.length > 0 && match.index !== groupEnd) flush();
6795
+ if (markers.length === 0) groupStart = match.index;
6796
+ markers.push(Number(match[1]));
6797
+ groupEnd = match.index + match[0].length;
6798
+ }
6799
+ flush();
6800
+ return spans;
6801
+ }
6634
6802
  function createClient3(apiKey) {
6635
6803
  return new OpenAI4({ apiKey, baseURL: BASE_URL });
6636
6804
  }
@@ -6642,11 +6810,11 @@ function assertUsableResponse(response) {
6642
6810
  if (status === void 0 || status === "completed") return;
6643
6811
  if (status === "incomplete") {
6644
6812
  if (extractAgentAnswerText(response).trim().length > 0) return;
6645
- const details = isRecord2(response.incomplete_details) ? response.incomplete_details : void 0;
6813
+ const details = isRecord5(response.incomplete_details) ? response.incomplete_details : void 0;
6646
6814
  const reason = typeof details?.reason === "string" && details.reason.length > 0 ? details.reason : "no reason given";
6647
6815
  throw new Error(`agent response incomplete with no answer: ${reason}`);
6648
6816
  }
6649
- const error = isRecord2(response.error) ? response.error : void 0;
6817
+ const error = isRecord5(response.error) ? response.error : void 0;
6650
6818
  const message = typeof error?.message === "string" && error.message.length > 0 ? error.message : "no error detail";
6651
6819
  const type = typeof error?.type === "string" && error.type.length > 0 ? ` (${error.type})` : "";
6652
6820
  throw new Error(`agent response ${typeof status === "string" ? status : describeError(status)}: ${message}${type}`);
@@ -6657,7 +6825,7 @@ function agentResponseOf(rawResponse) {
6657
6825
  return nested && Array.isArray(nested.output) ? nested : null;
6658
6826
  }
6659
6827
  function agentOutput(response) {
6660
- return Array.isArray(response.output) ? response.output.filter(isRecord2) : [];
6828
+ return Array.isArray(response.output) ? response.output.filter(isRecord5) : [];
6661
6829
  }
6662
6830
  function parseAgentResponse(response) {
6663
6831
  const groundingSources = extractAgentGroundingSources(response);
@@ -6675,7 +6843,7 @@ function extractAgentAnswerText(response) {
6675
6843
  for (const item of agentOutput(response)) {
6676
6844
  if (item.type !== "message" || !Array.isArray(item.content)) continue;
6677
6845
  for (const part of item.content) {
6678
- if (isRecord2(part) && part.type === "output_text" && typeof part.text === "string") parts.push(part.text);
6846
+ if (isRecord5(part) && part.type === "output_text" && typeof part.text === "string") parts.push(part.text);
6679
6847
  }
6680
6848
  }
6681
6849
  return parts.join("");
@@ -6684,7 +6852,7 @@ function extractAgentGroundingSources(response) {
6684
6852
  const sources = [];
6685
6853
  const seen = /* @__PURE__ */ new Set();
6686
6854
  const add = (entry) => {
6687
- if (!isRecord2(entry) || typeof entry.url !== "string" || entry.url.length === 0) return;
6855
+ if (!isRecord5(entry) || typeof entry.url !== "string" || entry.url.length === 0) return;
6688
6856
  if (seen.has(entry.url)) return;
6689
6857
  seen.add(entry.url);
6690
6858
  sources.push({ uri: entry.url, title: typeof entry.title === "string" ? entry.title : "" });
@@ -6696,7 +6864,7 @@ function extractAgentGroundingSources(response) {
6696
6864
  item.contents.forEach(add);
6697
6865
  } else if (item.type === "message" && Array.isArray(item.content)) {
6698
6866
  for (const part of item.content) {
6699
- if (isRecord2(part) && Array.isArray(part.annotations)) part.annotations.forEach(add);
6867
+ if (isRecord5(part) && Array.isArray(part.annotations)) part.annotations.forEach(add);
6700
6868
  }
6701
6869
  }
6702
6870
  }
@@ -6713,13 +6881,13 @@ function extractAgentSearchQueries(response) {
6713
6881
  return [...queries2];
6714
6882
  }
6715
6883
  function extractAgentUsage(response) {
6716
- const usage = isRecord2(response.usage) ? response.usage : void 0;
6884
+ const usage = isRecord5(response.usage) ? response.usage : void 0;
6717
6885
  if (!usage) return void 0;
6718
- const details = isRecord2(usage.input_tokens_details) ? usage.input_tokens_details : void 0;
6886
+ const details = isRecord5(usage.input_tokens_details) ? usage.input_tokens_details : void 0;
6719
6887
  const cachedInputTokens = usageCount(details?.cache_read_input_tokens);
6720
6888
  const cacheWriteTokens = usageCount(details?.cache_creation_input_tokens);
6721
- const toolCalls = isRecord2(usage.tool_calls_details) ? usage.tool_calls_details : void 0;
6722
- const webSearch = isRecord2(toolCalls?.web_search) ? toolCalls.web_search : void 0;
6889
+ const toolCalls = isRecord5(usage.tool_calls_details) ? usage.tool_calls_details : void 0;
6890
+ const webSearch = isRecord5(toolCalls?.web_search) ? toolCalls.web_search : void 0;
6723
6891
  const searchCount = typeof webSearch?.invocation === "number" ? usageCount(webSearch.invocation) : agentOutput(response).filter((item) => item.type === "search_results").length;
6724
6892
  return {
6725
6893
  inputTokens: Math.max(0, usageCount(usage.input_tokens) - cachedInputTokens - cacheWriteTokens),
@@ -6730,7 +6898,7 @@ function extractAgentUsage(response) {
6730
6898
  };
6731
6899
  }
6732
6900
  function extractAgentStopReason(response) {
6733
- const details = isRecord2(response.incomplete_details) ? response.incomplete_details : void 0;
6901
+ const details = isRecord5(response.incomplete_details) ? response.incomplete_details : void 0;
6734
6902
  if (typeof details?.reason === "string" && details.reason.length > 0) return details.reason;
6735
6903
  return typeof response.status === "string" && response.status.length > 0 ? response.status : void 0;
6736
6904
  }
@@ -6798,7 +6966,7 @@ function extractSearchResults(rawResponse) {
6798
6966
  function parseSearchResultsArray(value) {
6799
6967
  if (!Array.isArray(value)) return [];
6800
6968
  return value.flatMap((result2) => {
6801
- if (!isRecord2(result2)) return [];
6969
+ if (!isRecord5(result2)) return [];
6802
6970
  const url = result2.url;
6803
6971
  if (typeof url !== "string" || url.length === 0) {
6804
6972
  return [];
@@ -6825,9 +6993,9 @@ function extractAnswerText3(rawResponse) {
6825
6993
  }
6826
6994
  }
6827
6995
  function extractNestedApiResponse(rawResponse) {
6828
- return isRecord2(rawResponse.apiResponse) ? rawResponse.apiResponse : null;
6996
+ return isRecord5(rawResponse.apiResponse) ? rawResponse.apiResponse : null;
6829
6997
  }
6830
- function isRecord2(value) {
6998
+ function isRecord5(value) {
6831
6999
  return value !== null && typeof value === "object" && !Array.isArray(value);
6832
7000
  }
6833
7001
  function extractCitedDomains2(groundingSources) {
@@ -7873,7 +8041,7 @@ function nonBlank(value) {
7873
8041
  return trimmed ? trimmed : void 0;
7874
8042
  }
7875
8043
  function resolveBuildCommit(env = process.env) {
7876
- const embedded = true ? "b8858fd6adad2abea13e838eab395ad7aef15323" : void 0;
8044
+ const embedded = true ? "0c7138fae1222b76fc53fbc904a8e30d18ee72bf" : void 0;
7877
8045
  return nonBlank(embedded) ?? nonBlank(env.CANONRY_COMMIT);
7878
8046
  }
7879
8047
  function resolveInstanceIdentity(env = process.env) {
@@ -8295,17 +8463,15 @@ function runOutcome(inserted, providerErrors, planShortfall) {
8295
8463
  error: someFailed ? serializeRunError(buildProviderRunError(providerErrors)) : null
8296
8464
  };
8297
8465
  }
8298
- function competitorIdentityKey(rows) {
8299
- return JSON.stringify(rows.map((row) => ({ domain: row.domain, aliases: normalizeCompetitorAliases(row.aliases) })).sort((left, right) => left.domain.localeCompare(right.domain)));
8300
- }
8301
8466
  function runRecordingContext(db, input) {
8302
- const resolveCompetitors = createRunCompetitorResolver(db, input.competitors);
8467
+ const identity = answerIdentityFrom(input.project, input.competitors, input.marketNames);
8468
+ const resolveCompetitors = createRunCompetitorResolver(db, identity.competitors, identity.marketNames);
8303
8469
  return {
8304
8470
  runId: input.runId,
8305
- allDomains: effectiveDomains({ canonicalDomain: input.project.canonicalDomain, ownedDomains: input.project.ownedDomains }),
8471
+ identity: identity.fingerprint,
8472
+ allDomains: identity.projectDomains,
8306
8473
  competitorsFor: (executionId) => resolveCompetitors(input.measurementPlanVersionId, executionId),
8307
- allBrandNames: effectiveBrandNames({ displayName: input.project.displayName, aliases: input.project.aliases }),
8308
- competitorIdentity: competitorIdentityKey(input.competitors)
8474
+ allBrandNames: identity.projectBrandNames
8309
8475
  };
8310
8476
  }
8311
8477
  function resolvePlanExecution(run, projectQueries) {
@@ -8574,13 +8740,14 @@ var JobRunner = class {
8574
8740
  throw new Error("No providers configured. Add at least one provider API key.");
8575
8741
  }
8576
8742
  log2.info("run.dispatch", { runId, providerCount: activeProviders.length, providers: activeProviders.map((p) => p.adapter.name) });
8577
- const projectCompetitors = this.db.select().from(competitors).where(eq3(competitors.projectId, projectId)).all();
8743
+ const projectCompetitors = this.db.select().from(competitors).where(eq3(competitors.projectId, projectId)).orderBy(competitors.domain).all();
8578
8744
  const competitorDomains = projectCompetitors.map((c) => c.domain);
8579
8745
  recording = runRecordingContext(this.db, {
8580
8746
  runId,
8581
8747
  measurementPlanVersionId: existingRun.measurementPlanVersionId,
8582
8748
  project,
8583
- competitors: projectCompetitors
8749
+ competitors: projectCompetitors,
8750
+ marketNames: readMarketCompetitorNames(this.db, projectId)
8584
8751
  });
8585
8752
  const { allDomains, allBrandNames } = recording;
8586
8753
  const { aliases: competitorAliases } = recording.competitorsFor(null);
@@ -8808,14 +8975,17 @@ var JobRunner = class {
8808
8975
  requestedModel: config.model ?? null
8809
8976
  })),
8810
8977
  // Freeze the exact identity we actually dispatched with (domain label
8811
- // plus curated aliases) so later reporting never borrows renamed or
8812
- // newly added competitors, or later alias edits, from live project
8813
- // state. A competitor with no curated alias freezes exactly as it did
8814
- // before aliases existed, so its definition checksum is unchanged.
8978
+ // plus curated and auto-detected aliases, minus blocked names) so
8979
+ // later reporting never borrows renamed or newly added competitors,
8980
+ // or later alias edits or detections, from live project state. A
8981
+ // competitor with no alias of either kind freezes exactly as it did
8982
+ // before aliases existed, so its definition checksum is unchanged;
8983
+ // an auto-detected name moves the checksum only for runs dispatched
8984
+ // after it was applied.
8815
8985
  competitors: projectCompetitors.map((competitor) => {
8816
8986
  const label = competitorLabelFromDomain(competitor.domain);
8817
- const curated = normalizeCompetitorAliases(competitor.aliases).filter((alias) => alias.toLowerCase() !== label.toLowerCase());
8818
- return { domain: competitor.domain, label, aliases: [label, ...curated] };
8987
+ const named = competitorIdentityAliases(competitor).filter((alias) => alias.toLowerCase() !== label.toLowerCase());
8988
+ return { domain: competitor.domain, label, aliases: [label, ...named] };
8819
8989
  }),
8820
8990
  queries: projectQueries.map((query) => ({
8821
8991
  queryId: query.id,
@@ -8876,7 +9046,7 @@ var JobRunner = class {
8876
9046
  }
8877
9047
  }
8878
9048
  providerCallEnd = Date.now();
8879
- this.reconcileRunCompetitorFields(recording, projectId);
9049
+ this.reconcileRunAnswerFields(recording, projectId);
8880
9050
  this.throwIfRunCancelled(runId);
8881
9051
  if (planExecution) {
8882
9052
  for (const [provider, units] of planExecution.unitsByProvider) {
@@ -8914,7 +9084,7 @@ var JobRunner = class {
8914
9084
  });
8915
9085
  if (!finalized && this.isRunCancelled(runId)) throw new RunCancelledError(runId);
8916
9086
  } catch (err) {
8917
- if (recording) this.reconcileRunCompetitorFields(recording, projectId);
9087
+ if (recording) this.reconcileRunAnswerFields(recording, projectId);
8918
9088
  const executionContext = {
8919
9089
  providerCount: activeProviders.length,
8920
9090
  providers: activeProviders.map((provider) => provider.adapter.name),
@@ -9556,7 +9726,7 @@ var JobRunner = class {
9556
9726
  this.markBatchesCancelled([batch.id], "Cancelled because its run is no longer running.");
9557
9727
  return { kind: "cancelled" };
9558
9728
  } finally {
9559
- this.reconcileRunCompetitorFields(ctx, batch.projectId);
9729
+ this.reconcileRunAnswerFields(ctx, batch.projectId);
9560
9730
  }
9561
9731
  return this.completeBatchIngest(batch);
9562
9732
  }
@@ -9847,7 +10017,7 @@ var JobRunner = class {
9847
10017
  fatal = describeError(err);
9848
10018
  log2.error("fill.failed", { fillId, runId, error: fatal });
9849
10019
  } finally {
9850
- if (recording) this.reconcileRunCompetitorFields(recording, projectId);
10020
+ if (recording) this.reconcileRunAnswerFields(recording, projectId);
9851
10021
  this.flushProviderUsage(providerDispatchCounts, providerReservations);
9852
10022
  }
9853
10023
  const outcome = this.finalizeRunFill({ fillId, runId, filled, fatal, providerErrors, superseded });
@@ -10097,34 +10267,38 @@ var JobRunner = class {
10097
10267
  if (!run) throw new Error(`Run ${runId} not found`);
10098
10268
  const project = this.db.select().from(projects).where(eq3(projects.id, projectId)).get();
10099
10269
  if (!project) throw new Error(`Project ${projectId} not found`);
10100
- const projectCompetitors = this.db.select().from(competitors).where(eq3(competitors.projectId, projectId)).all();
10270
+ const projectCompetitors = this.db.select().from(competitors).where(eq3(competitors.projectId, projectId)).orderBy(competitors.domain).all();
10101
10271
  return runRecordingContext(this.db, {
10102
10272
  runId,
10103
10273
  measurementPlanVersionId: run.measurementPlanVersionId,
10104
10274
  project,
10105
- competitors: projectCompetitors
10275
+ competitors: projectCompetitors,
10276
+ marketNames: readMarketCompetitorNames(this.db, projectId)
10106
10277
  });
10107
10278
  }
10108
10279
  /**
10109
- * Rescore a run's stored competitor columns when the project's competitors
10110
- * changed after `recording` read them.
10280
+ * Rescore a run's stored answer fields when the identity they are scored
10281
+ * against changed after `recording` read it (`answerIdentityChanged`).
10111
10282
  *
10112
- * Saving a competitor alias rescores the stored answers once
10113
- * (`onCompetitorAliasesChanged`). A writer that read the names before the
10114
- * edit keeps scoring with them, so an answer it stores afterwards would
10115
- * disagree with an identical one the edit rescored. Each writer calls this
10116
- * after its last write: when its names are no longer current, the whole run
10117
- * gets the same competitor-only pass the edit ran. A failure is logged and
10118
- * never fails the writer.
10283
+ * Saving a competitor alias, a detection pass that changes a competitor's
10284
+ * auto-detected names, or a project alias edit rescores the stored answers
10285
+ * once (`onCompetitorAliasesChanged`, `onAliasesChanged`). A writer that read
10286
+ * the identity before the edit keeps scoring with it, so an answer it stores
10287
+ * afterwards would disagree with an identical one the edit rescored. Each
10288
+ * writer calls this after its last write: when its identity is no longer
10289
+ * current, the whole run gets the pass the edit ran, the competitor fields
10290
+ * only, or `answer_mentioned` too when the project's own identity moved. A
10291
+ * failure is logged and never fails the writer.
10119
10292
  */
10120
- reconcileRunCompetitorFields(recording, projectId) {
10293
+ reconcileRunAnswerFields(recording, projectId) {
10121
10294
  try {
10122
- const current = this.db.select({ domain: competitors.domain, aliases: competitors.aliases }).from(competitors).where(eq3(competitors.projectId, projectId)).all();
10123
- if (competitorIdentityKey(current) === recording.competitorIdentity) return;
10124
- const result2 = backfillProjectAnswerMentions(this.db, projectId, { competitorFieldsOnly: true, runId: recording.runId });
10125
- log2.info("run.competitor-fields-rescored", { runId: recording.runId, projectId, ...result2 });
10295
+ const current = readAnswerIdentity(this.db, projectId);
10296
+ if (!current || !answerIdentityChanged(recording.identity, current.fingerprint)) return;
10297
+ const full = recording.identity.project !== current.fingerprint.project;
10298
+ const result2 = backfillProjectAnswerMentions(this.db, projectId, { competitorFieldsOnly: !full, runId: recording.runId });
10299
+ log2.info("run.answer-fields-rescored", { runId: recording.runId, projectId, full, ...result2 });
10126
10300
  } catch (err) {
10127
- log2.error("run.competitor-fields-rescore-failed", { runId: recording.runId, projectId, error: describeError(err) });
10301
+ log2.error("run.answer-fields-rescore-failed", { runId: recording.runId, projectId, error: describeError(err) });
10128
10302
  }
10129
10303
  }
10130
10304
  incrementUsage(scope, metric, count3) {
@@ -11142,11 +11316,11 @@ function validateGeoSearchLimit(limit) {
11142
11316
  );
11143
11317
  }
11144
11318
  }
11145
- function isRecord3(value) {
11319
+ function isRecord6(value) {
11146
11320
  return typeof value === "object" && value !== null && !Array.isArray(value);
11147
11321
  }
11148
11322
  function validateRequestObject(value, label) {
11149
- if (!isRecord3(value)) {
11323
+ if (!isRecord6(value)) {
11150
11324
  throw new OpenAiAdsApiError(`${label} must be a JSON object`, 400);
11151
11325
  }
11152
11326
  }
@@ -11305,7 +11479,7 @@ function validateLandingPageConfiguration(value, label) {
11305
11479
  }
11306
11480
  }
11307
11481
  function parseLandingPageQueryStringTemplate(value) {
11308
- if (!isRecord3(value)) return null;
11482
+ if (!isRecord6(value)) return null;
11309
11483
  const template = value.query_string_template;
11310
11484
  return typeof template === "string" && template.trim().length > 0 ? template : null;
11311
11485
  }
@@ -12899,7 +13073,7 @@ async function executeDiscoveryRun(opts) {
12899
13073
  try {
12900
13074
  const projectRow = opts.db.select().from(projects).where(eq14(projects.id, opts.projectId)).get();
12901
13075
  if (!projectRow) throw new Error(`Project ${opts.projectId} not found`);
12902
- const projectCompetitors = opts.db.select({ domain: competitors.domain }).from(competitors).where(eq14(competitors.projectId, opts.projectId)).all().map((r) => r.domain.toLowerCase());
13076
+ const projectCompetitors = opts.db.select({ domain: competitors.domain }).from(competitors).where(eq14(competitors.projectId, opts.projectId)).orderBy(competitors.domain).all().map((r) => r.domain.toLowerCase());
12903
13077
  const canonicalDomains = effectiveDomains({
12904
13078
  canonicalDomain: projectRow.canonicalDomain,
12905
13079
  ownedDomains: projectRow.ownedDomains
@@ -14242,6 +14416,115 @@ async function executeSiteAudit(db, runId, projectId, opts = {}) {
14242
14416
  }
14243
14417
  }
14244
14418
 
14419
+ // src/answer-field-recompute.ts
14420
+ import { setImmediate as yieldToEventLoop2 } from "timers/promises";
14421
+ function createAnswerFieldRecomputeQueue(opts) {
14422
+ const pendingFull = /* @__PURE__ */ new Map();
14423
+ const queue = createProjectPassQueue(async (projectId) => {
14424
+ const full = pendingFull.get(projectId) ?? false;
14425
+ pendingFull.delete(projectId);
14426
+ await yieldToEventLoop2();
14427
+ const result2 = await backfillProjectAnswerMentionsInChunks(opts.db, projectId, { competitorFieldsOnly: !full });
14428
+ return { full, result: result2 };
14429
+ });
14430
+ return {
14431
+ request(projectId, scope) {
14432
+ pendingFull.set(projectId, (pendingFull.get(projectId) ?? false) || scope.full === true);
14433
+ return queue.request(projectId);
14434
+ },
14435
+ settled: () => queue.settled()
14436
+ };
14437
+ }
14438
+
14439
+ // src/competitor-auto-alias-runner.ts
14440
+ import { setImmediate as yieldToEventLoop3 } from "timers/promises";
14441
+ var log15 = createLogger("CompetitorAutoAliases");
14442
+ function createCompetitorAutoAliasRunner(opts) {
14443
+ const reasons = /* @__PURE__ */ new Map();
14444
+ const queue = createProjectPassQueue(async (projectId) => {
14445
+ await yieldToEventLoop3();
14446
+ const reason = (reasons.get(projectId) ?? []).join(",");
14447
+ reasons.delete(projectId);
14448
+ const result2 = await applyCompetitorAutoAliases(opts.db, projectId, { readAnchors: opts.readAnchors });
14449
+ if (!result2) return null;
14450
+ const added = result2.detection.competitors.reduce((sum, competitor) => sum + competitor.added.length, 0);
14451
+ const removed = result2.detection.competitors.reduce((sum, competitor) => sum + competitor.removed.length, 0);
14452
+ log15.info("detection.completed", {
14453
+ projectId,
14454
+ reason,
14455
+ runs: result2.detection.scan.runs,
14456
+ snapshots: result2.detection.scan.snapshots,
14457
+ added,
14458
+ removed,
14459
+ namesChanged: result2.namesChanged
14460
+ });
14461
+ if (result2.namesChanged) {
14462
+ try {
14463
+ opts.onNamesChanged(projectId, result2.detection.project);
14464
+ } catch (err) {
14465
+ log15.error("names-changed.failed", { projectId, error: describeError(err) });
14466
+ }
14467
+ }
14468
+ return result2;
14469
+ });
14470
+ const enqueue = (projectId, reason) => {
14471
+ reasons.set(projectId, [...reasons.get(projectId) ?? [], reason]);
14472
+ return queue.request(projectId);
14473
+ };
14474
+ return {
14475
+ schedule(projectId, reason) {
14476
+ enqueue(projectId, reason).catch((err) => {
14477
+ log15.error("detection.failed", { projectId, reason, error: describeError(err) });
14478
+ });
14479
+ },
14480
+ async request(projectId, reason) {
14481
+ try {
14482
+ return await enqueue(projectId, reason);
14483
+ } catch (err) {
14484
+ log15.error("detection.failed", { projectId, reason, error: describeError(err) });
14485
+ throw err;
14486
+ }
14487
+ },
14488
+ settled: () => queue.settled()
14489
+ };
14490
+ }
14491
+
14492
+ // src/stored-answer-anchors.ts
14493
+ function resolveStoredApiResponse(parsed) {
14494
+ const nested = parsed.apiResponse;
14495
+ if (nested !== null && typeof nested === "object" && !Array.isArray(nested)) {
14496
+ return nested;
14497
+ }
14498
+ if (looksLikeProviderApiResponse(parsed)) {
14499
+ return parsed;
14500
+ }
14501
+ return null;
14502
+ }
14503
+ function looksLikeProviderApiResponse(value) {
14504
+ return Array.isArray(value.output) || Array.isArray(value.content) || Array.isArray(value.candidates) || Array.isArray(value.choices);
14505
+ }
14506
+ function extractStoredAnswerAnchors(provider, rawResponse) {
14507
+ if (!rawResponse) return [];
14508
+ const apiResponse = resolveStoredApiResponse(parseJsonColumn(rawResponse, {}));
14509
+ if (!apiResponse) return [];
14510
+ try {
14511
+ switch (provider) {
14512
+ case ProviderNames.openai:
14513
+ return extractAnchoredSpans2(apiResponse);
14514
+ case ProviderNames.claude:
14515
+ return extractAnchoredSpans3(apiResponse);
14516
+ case ProviderNames.gemini:
14517
+ return extractAnchoredSpans(apiResponse);
14518
+ case ProviderNames.perplexity:
14519
+ return extractAnchoredSpans4(apiResponse);
14520
+ default:
14521
+ return [];
14522
+ }
14523
+ } catch {
14524
+ return [];
14525
+ }
14526
+ }
14527
+
14245
14528
  // src/provider-registry.ts
14246
14529
  var ProviderRegistry = class {
14247
14530
  providers = /* @__PURE__ */ new Map();
@@ -14358,7 +14641,7 @@ function createProviderModelCatalog(registry) {
14358
14641
  import crypto21 from "crypto";
14359
14642
  import cron from "node-cron";
14360
14643
  import { and as and15, eq as eq17, inArray as inArray7, notExists, sql as sql7 } from "drizzle-orm";
14361
- var log15 = createLogger("Scheduler");
14644
+ var log16 = createLogger("Scheduler");
14362
14645
  var DEFAULT_HEALTH_CRON = "0 */6 * * *";
14363
14646
  function ensureDefaultHealthSchedule(db, projectId, now = (/* @__PURE__ */ new Date()).toISOString()) {
14364
14647
  const result2 = db.insert(schedules).values({
@@ -14420,7 +14703,7 @@ var Scheduler = class {
14420
14703
  if (ensureDefaultHealthSchedule(this.db, project.id, now)) seeded += 1;
14421
14704
  }
14422
14705
  if (seeded > 0) {
14423
- log15.info("health-schedule.seeded", { projectCount: seeded, cron: DEFAULT_HEALTH_CRON });
14706
+ log16.info("health-schedule.seeded", { projectCount: seeded, cron: DEFAULT_HEALTH_CRON });
14424
14707
  }
14425
14708
  }
14426
14709
  /**
@@ -14444,10 +14727,10 @@ var Scheduler = class {
14444
14727
  const current = ensureCurrentQueryBasketRevision(this.db, project.id);
14445
14728
  if (current !== null && current.revision !== previous) minted += 1;
14446
14729
  } catch (err) {
14447
- log15.warn("query-basket.mint-failed", { projectId: project.id, err: String(err) });
14730
+ log16.warn("query-basket.mint-failed", { projectId: project.id, err: String(err) });
14448
14731
  }
14449
14732
  }
14450
- if (minted > 0) log15.info("query-basket.ensured", { projectCount: minted });
14733
+ if (minted > 0) log16.info("query-basket.ensured", { projectCount: minted });
14451
14734
  }
14452
14735
  /** Load all enabled schedules from DB and register cron jobs. */
14453
14736
  start() {
@@ -14461,12 +14744,12 @@ var Scheduler = class {
14461
14744
  if (registered && missedRunAt && new Date(missedRunAt) < /* @__PURE__ */ new Date()) {
14462
14745
  const answerRecurrence = recurrence && schedule.kind === SchedulableRunKinds["answer-visibility"];
14463
14746
  if (!recurrence || answerRecurrence || this.claimCalendarOccurrence(schedule, missedRunAt, /* @__PURE__ */ new Date())) {
14464
- log15.info("run.catch-up", { projectId: schedule.projectId, kind: schedule.kind, missedRunAt });
14747
+ log16.info("run.catch-up", { projectId: schedule.projectId, kind: schedule.kind, missedRunAt });
14465
14748
  this.triggerRun(schedule.id, schedule.projectId, schedule.kind, answerRecurrence ? missedRunAt : void 0);
14466
14749
  }
14467
14750
  }
14468
14751
  }
14469
- log15.info("started", { scheduleCount: allSchedules.length });
14752
+ log16.info("started", { scheduleCount: allSchedules.length });
14470
14753
  }
14471
14754
  /** Stop all cron tasks for graceful shutdown. */
14472
14755
  stop() {
@@ -14510,7 +14793,7 @@ var Scheduler = class {
14510
14793
  stopTask(key, task, verb) {
14511
14794
  void task.stop();
14512
14795
  void task.destroy();
14513
- log15.info(`task.${verb.toLowerCase()}`, { key });
14796
+ log16.info(`task.${verb.toLowerCase()}`, { key });
14514
14797
  }
14515
14798
  /** Runtime timing writes must not restore a version already issued to a settings caller. */
14516
14799
  updateScheduleTiming(scheduleId, timing) {
@@ -14530,13 +14813,13 @@ var Scheduler = class {
14530
14813
  return true;
14531
14814
  }
14532
14815
  if (schedule.recurrence) {
14533
- log15.error("calendar.invalid", { projectId: schedule.projectId, kind: schedule.kind });
14816
+ log16.error("calendar.invalid", { projectId: schedule.projectId, kind: schedule.kind });
14534
14817
  return false;
14535
14818
  }
14536
14819
  const { id: scheduleId, projectId, cronExpr, timezone } = schedule;
14537
14820
  const kind = schedule.kind;
14538
14821
  if (!cron.validate(cronExpr)) {
14539
- log15.error("cron.invalid", { projectId, kind, cronExpr });
14822
+ log16.error("cron.invalid", { projectId, kind, cronExpr });
14540
14823
  return false;
14541
14824
  }
14542
14825
  const task = cron.schedule(cronExpr, () => {
@@ -14551,7 +14834,7 @@ var Scheduler = class {
14551
14834
  });
14552
14835
  }
14553
14836
  const label = schedule.preset ?? cronExpr;
14554
- log15.info("cron.registered", { projectId, kind, schedule: label, timezone });
14837
+ log16.info("cron.registered", { projectId, kind, schedule: label, timezone });
14555
14838
  return true;
14556
14839
  }
14557
14840
  /** Calendar schedules are checked at most once a minute so long intervals
@@ -14609,7 +14892,7 @@ var Scheduler = class {
14609
14892
  }
14610
14893
  if (options.preserveNextRunAt) arm(registeredNextRunAt);
14611
14894
  else timer = setTimeout(tick, 0);
14612
- log15.info("calendar.registered", { projectId, kind, recurrence, timezone });
14895
+ log16.info("calendar.registered", { projectId, kind, recurrence, timezone });
14613
14896
  }
14614
14897
  /** Atomically advance a due callback-only calendar occurrence before dispatch. */
14615
14898
  claimCalendarOccurrence(schedule, dueAt, now) {
@@ -14637,13 +14920,13 @@ var Scheduler = class {
14637
14920
  const now = (/* @__PURE__ */ new Date()).toISOString();
14638
14921
  const currentSchedule = this.db.select().from(schedules).where(eq17(schedules.id, scheduleId)).get();
14639
14922
  if (!currentSchedule || !currentSchedule.enabled) {
14640
- log15.warn("schedule.stale", { scheduleId, projectId, kind, msg: "schedule no longer exists or is disabled" });
14923
+ log16.warn("schedule.stale", { scheduleId, projectId, kind, msg: "schedule no longer exists or is disabled" });
14641
14924
  this.remove(projectId, kind);
14642
14925
  return;
14643
14926
  }
14644
14927
  const recurrence = scheduleRecurrence(currentSchedule);
14645
14928
  if (claimedOccurrence && (!recurrence || currentSchedule.nextRunAt !== claimedOccurrence)) {
14646
- log15.info("calendar.stale-claim", { projectId, scheduleId, kind });
14929
+ log16.info("calendar.stale-claim", { projectId, scheduleId, kind });
14647
14930
  return;
14648
14931
  }
14649
14932
  const nextRunAt = nextRunFromSchedule({
@@ -14653,31 +14936,31 @@ var Scheduler = class {
14653
14936
  });
14654
14937
  const project = this.db.select().from(projects).where(eq17(projects.id, projectId)).get();
14655
14938
  if (!project) {
14656
- log15.error("project.not-found", { projectId, kind, msg: "skipping scheduled run" });
14939
+ log16.error("project.not-found", { projectId, kind, msg: "skipping scheduled run" });
14657
14940
  this.remove(projectId, kind);
14658
14941
  return;
14659
14942
  }
14660
14943
  if (kind === SchedulableRunKinds["traffic-sync"]) {
14661
14944
  const sourceId = currentSchedule.sourceId;
14662
14945
  if (!sourceId) {
14663
- log15.warn("traffic-sync.missing-source", { scheduleId, projectId });
14946
+ log16.warn("traffic-sync.missing-source", { scheduleId, projectId });
14664
14947
  return;
14665
14948
  }
14666
14949
  if (!this.callbacks.onTrafficSyncRequested) {
14667
- log15.warn("traffic-sync.no-callback", { scheduleId, projectId, msg: "host did not register onTrafficSyncRequested" });
14950
+ log16.warn("traffic-sync.no-callback", { scheduleId, projectId, msg: "host did not register onTrafficSyncRequested" });
14668
14951
  return;
14669
14952
  }
14670
14953
  this.updateScheduleTiming(currentSchedule.id, {
14671
14954
  lastRunAt: now,
14672
14955
  nextRunAt
14673
14956
  });
14674
- log15.info("traffic-sync.triggered", { projectName: project.name, sourceId });
14957
+ log16.info("traffic-sync.triggered", { projectName: project.name, sourceId });
14675
14958
  this.callbacks.onTrafficSyncRequested(project.name, sourceId);
14676
14959
  return;
14677
14960
  }
14678
14961
  if (kind === SchedulableRunKinds["gbp-sync"]) {
14679
14962
  if (!this.callbacks.onGbpSyncRequested) {
14680
- log15.warn("gbp-sync.no-callback", { scheduleId, projectId, msg: "host did not register onGbpSyncRequested" });
14963
+ log16.warn("gbp-sync.no-callback", { scheduleId, projectId, msg: "host did not register onGbpSyncRequested" });
14681
14964
  return;
14682
14965
  }
14683
14966
  const runId2 = crypto21.randomUUID();
@@ -14693,13 +14976,13 @@ var Scheduler = class {
14693
14976
  lastRunAt: now,
14694
14977
  nextRunAt
14695
14978
  });
14696
- log15.info("gbp-sync.triggered", { runId: runId2, projectName: project.name });
14979
+ log16.info("gbp-sync.triggered", { runId: runId2, projectName: project.name });
14697
14980
  this.callbacks.onGbpSyncRequested(runId2, projectId);
14698
14981
  return;
14699
14982
  }
14700
14983
  if (kind === SchedulableRunKinds["ads-sync"]) {
14701
14984
  if (!this.callbacks.onAdsSyncRequested) {
14702
- log15.warn("ads-sync.no-callback", { scheduleId, projectId, msg: "host did not register onAdsSyncRequested" });
14985
+ log16.warn("ads-sync.no-callback", { scheduleId, projectId, msg: "host did not register onAdsSyncRequested" });
14703
14986
  return;
14704
14987
  }
14705
14988
  const activeAdsRun = this.db.select({ id: runs.id }).from(runs).where(and15(
@@ -14708,7 +14991,7 @@ var Scheduler = class {
14708
14991
  inArray7(runs.status, [RunStatuses.queued, RunStatuses.running])
14709
14992
  )).get();
14710
14993
  if (activeAdsRun) {
14711
- log15.info("ads-sync.skipped-active", { projectName: project.name, activeRunId: activeAdsRun.id });
14994
+ log16.info("ads-sync.skipped-active", { projectName: project.name, activeRunId: activeAdsRun.id });
14712
14995
  this.updateScheduleTiming(currentSchedule.id, { nextRunAt });
14713
14996
  return;
14714
14997
  }
@@ -14725,52 +15008,52 @@ var Scheduler = class {
14725
15008
  lastRunAt: now,
14726
15009
  nextRunAt
14727
15010
  });
14728
- log15.info("ads-sync.triggered", { runId: runId2, projectName: project.name });
15011
+ log16.info("ads-sync.triggered", { runId: runId2, projectName: project.name });
14729
15012
  this.callbacks.onAdsSyncRequested(runId2, projectId);
14730
15013
  return;
14731
15014
  }
14732
15015
  if (kind === SchedulableRunKinds["data-refresh"]) {
14733
15016
  if (!this.callbacks.onDataRefreshRequested) {
14734
- log15.warn("data-refresh.no-callback", { scheduleId, projectId, msg: "host did not register onDataRefreshRequested" });
15017
+ log16.warn("data-refresh.no-callback", { scheduleId, projectId, msg: "host did not register onDataRefreshRequested" });
14735
15018
  return;
14736
15019
  }
14737
15020
  this.updateScheduleTiming(currentSchedule.id, {
14738
15021
  lastRunAt: now,
14739
15022
  nextRunAt
14740
15023
  });
14741
- log15.info("data-refresh.triggered", { projectName: project.name });
15024
+ log16.info("data-refresh.triggered", { projectName: project.name });
14742
15025
  this.callbacks.onDataRefreshRequested(project.name);
14743
15026
  return;
14744
15027
  }
14745
15028
  if (kind === SchedulableRunKinds.doctor) {
14746
15029
  if (!this.callbacks.onDoctorRequested) {
14747
- log15.warn("doctor.no-callback", { scheduleId, projectId, msg: "host did not register onDoctorRequested" });
15030
+ log16.warn("doctor.no-callback", { scheduleId, projectId, msg: "host did not register onDoctorRequested" });
14748
15031
  return;
14749
15032
  }
14750
15033
  this.updateScheduleTiming(currentSchedule.id, {
14751
15034
  lastRunAt: now,
14752
15035
  nextRunAt
14753
15036
  });
14754
- log15.info("doctor.triggered", { projectName: project.name });
15037
+ log16.info("doctor.triggered", { projectName: project.name });
14755
15038
  this.callbacks.onDoctorRequested(project.name);
14756
15039
  return;
14757
15040
  }
14758
15041
  if (kind === SchedulableRunKinds["backlinks-sync"]) {
14759
15042
  if (!this.callbacks.onBacklinksSyncRequested) {
14760
- log15.warn("backlinks-sync.no-callback", { scheduleId, projectId, msg: "host did not register onBacklinksSyncRequested" });
15043
+ log16.warn("backlinks-sync.no-callback", { scheduleId, projectId, msg: "host did not register onBacklinksSyncRequested" });
14761
15044
  return;
14762
15045
  }
14763
15046
  this.updateScheduleTiming(currentSchedule.id, {
14764
15047
  lastRunAt: now,
14765
15048
  nextRunAt
14766
15049
  });
14767
- log15.info("backlinks-sync.triggered", { projectName: project.name });
15050
+ log16.info("backlinks-sync.triggered", { projectName: project.name });
14768
15051
  this.callbacks.onBacklinksSyncRequested(project.name);
14769
15052
  return;
14770
15053
  }
14771
15054
  if (kind === SchedulableRunKinds["site-audit"]) {
14772
15055
  if (!this.callbacks.onSiteAuditRequested) {
14773
- log15.warn("site-audit.no-callback", { scheduleId, projectId, msg: "host did not register onSiteAuditRequested" });
15056
+ log16.warn("site-audit.no-callback", { scheduleId, projectId, msg: "host did not register onSiteAuditRequested" });
14774
15057
  return;
14775
15058
  }
14776
15059
  const active = this.db.select({ id: runs.id }).from(runs).where(and15(
@@ -14779,7 +15062,7 @@ var Scheduler = class {
14779
15062
  inArray7(runs.status, [RunStatuses.queued, RunStatuses.running])
14780
15063
  )).get();
14781
15064
  if (active) {
14782
- log15.info("site-audit.skipped-active", { projectName: project.name, activeRunId: active.id });
15065
+ log16.info("site-audit.skipped-active", { projectName: project.name, activeRunId: active.id });
14783
15066
  this.updateScheduleTiming(currentSchedule.id, { nextRunAt });
14784
15067
  return;
14785
15068
  }
@@ -14806,7 +15089,7 @@ var Scheduler = class {
14806
15089
  lastRunAt: now,
14807
15090
  nextRunAt
14808
15091
  });
14809
- log15.info("site-audit.triggered", { runId: runId2, projectName: project.name });
15092
+ log16.info("site-audit.triggered", { runId: runId2, projectName: project.name });
14810
15093
  this.callbacks.onSiteAuditRequested(runId2, projectId, { maxPages: effectiveRequest.maxPages });
14811
15094
  return;
14812
15095
  }
@@ -14815,7 +15098,7 @@ var Scheduler = class {
14815
15098
  if (project.defaultLocation) {
14816
15099
  const loc = projectLocations.find((l) => l.label === project.defaultLocation);
14817
15100
  if (!loc) {
14818
- log15.warn("default-location.stale", { scheduleId, projectId, label: project.defaultLocation });
15101
+ log16.warn("default-location.stale", { scheduleId, projectId, label: project.defaultLocation });
14819
15102
  return;
14820
15103
  }
14821
15104
  resolvedLocation = loc;
@@ -14824,7 +15107,7 @@ var Scheduler = class {
14824
15107
  const scheduleProviders = currentSchedule.providers;
14825
15108
  const providers = scheduleProviders.length > 0 ? scheduleProviders : void 0;
14826
15109
  if (claimedOccurrence && recurrence && !nextRunAt) {
14827
- log15.error("calendar.invalid", { projectId, kind, scheduleId });
15110
+ log16.error("calendar.invalid", { projectId, kind, scheduleId });
14828
15111
  return;
14829
15112
  }
14830
15113
  const queueResult = queueRunIfProjectIdle(this.db, {
@@ -14848,11 +15131,11 @@ var Scheduler = class {
14848
15131
  });
14849
15132
  if (queueResult.conflict) {
14850
15133
  if (queueResult.scheduleClaimed === false) {
14851
- log15.info("calendar.skipped-claimed", { projectName: project.name, scheduleId: currentSchedule.id });
15134
+ log16.info("calendar.skipped-claimed", { projectName: project.name, scheduleId: currentSchedule.id });
14852
15135
  return;
14853
15136
  }
14854
15137
  const batchPending = hasOutstandingProviderBatch(this.db, queueResult.activeRunId);
14855
- log15.info("run.skipped-active", {
15138
+ log16.info("run.skipped-active", {
14856
15139
  projectName: project.name,
14857
15140
  activeRunId: queueResult.activeRunId,
14858
15141
  ...batchPending ? { reason: "batch-pending" } : {}
@@ -14861,7 +15144,7 @@ var Scheduler = class {
14861
15144
  return;
14862
15145
  }
14863
15146
  if (queueResult.refused) {
14864
- log15.warn("run.skipped-providers-failing", {
15147
+ log16.warn("run.skipped-providers-failing", {
14865
15148
  projectName: project.name,
14866
15149
  scheduleId: currentSchedule.id,
14867
15150
  ...queueResult.refused
@@ -14872,7 +15155,7 @@ var Scheduler = class {
14872
15155
  const runId = queueResult.runId;
14873
15156
  for (const [provider, reason] of Object.entries(queueResult.dispatch.ineligible)) {
14874
15157
  if (!reason) continue;
14875
- log15.warn("run.dispatch-sync-fallback", {
15158
+ log16.warn("run.dispatch-sync-fallback", {
14876
15159
  runId,
14877
15160
  projectName: project.name,
14878
15161
  provider,
@@ -14886,16 +15169,16 @@ var Scheduler = class {
14886
15169
  lastRunAt: now,
14887
15170
  nextRunAt
14888
15171
  });
14889
- log15.info("run.triggered", { runId, projectName: project.name, providers: providers ?? "all" });
15172
+ log16.info("run.triggered", { runId, projectName: project.name, providers: providers ?? "all" });
14890
15173
  this.callbacks.onRunCreated(runId, projectId, providers, resolvedLocation);
14891
15174
  } catch (err) {
14892
- log15.error("trigger.error", { scheduleId, projectId, kind, error: describeError(err) });
15175
+ log16.error("trigger.error", { scheduleId, projectId, kind, error: describeError(err) });
14893
15176
  }
14894
15177
  }
14895
15178
  };
14896
15179
 
14897
15180
  // src/site-liveness-loop.ts
14898
- var log16 = createLogger("SiteLiveness");
15181
+ var log17 = createLogger("SiteLiveness");
14899
15182
  var SITE_LIVENESS_INTERVAL_MS = 10 * 6e4;
14900
15183
  async function runSiteLivenessPass(deps) {
14901
15184
  const now = deps.now ?? (() => (/* @__PURE__ */ new Date()).toISOString());
@@ -14910,10 +15193,10 @@ async function runSiteLivenessPass(deps) {
14910
15193
  const event = await deps.notify(project.id, { check, checkedAt: now() });
14911
15194
  if (event) {
14912
15195
  events += 1;
14913
- log16.info("site-liveness.notified", { projectName: project.name, event, code: check.code });
15196
+ log17.info("site-liveness.notified", { projectName: project.name, event, code: check.code });
14914
15197
  }
14915
15198
  } catch (err) {
14916
- log16.warn("site-liveness.project-failed", { projectName: project.name, err: String(err) });
15199
+ log17.warn("site-liveness.project-failed", { projectName: project.name, err: String(err) });
14917
15200
  }
14918
15201
  }
14919
15202
  return { checked, events };
@@ -14922,11 +15205,11 @@ function startSiteLivenessLoop(deps, intervalMs = SITE_LIVENESS_INTERVAL_MS) {
14922
15205
  let running = false;
14923
15206
  const timer = setInterval(() => {
14924
15207
  if (running) {
14925
- log16.warn("site-liveness.pass-still-running", { intervalMs });
15208
+ log17.warn("site-liveness.pass-still-running", { intervalMs });
14926
15209
  return;
14927
15210
  }
14928
15211
  running = true;
14929
- void runSiteLivenessPass(deps).catch((err) => log16.warn("site-liveness.pass-failed", { err: String(err) })).finally(() => {
15212
+ void runSiteLivenessPass(deps).catch((err) => log17.warn("site-liveness.pass-failed", { err: String(err) })).finally(() => {
14930
15213
  running = false;
14931
15214
  });
14932
15215
  }, intervalMs);
@@ -14936,7 +15219,7 @@ function startSiteLivenessLoop(deps, intervalMs = SITE_LIVENESS_INTERVAL_MS) {
14936
15219
 
14937
15220
  // src/provider-batch-poller.ts
14938
15221
  import { and as and16, eq as eq18, inArray as inArray8, isNotNull as isNotNull2, isNull as isNull2, notExists as notExists2, sql as sql8 } from "drizzle-orm";
14939
- var log17 = createLogger("ProviderBatchPoller");
15222
+ var log18 = createLogger("ProviderBatchPoller");
14940
15223
  var PROVIDER_BATCH_POLL_INITIAL_MS = 3e4;
14941
15224
  var PROVIDER_BATCH_POLL_MAX_MS = 10 * 6e4;
14942
15225
  var PROVIDER_BATCH_CANCEL_GRACE_MS = 60 * 6e4;
@@ -14960,7 +15243,7 @@ var ProviderBatchPoller = class {
14960
15243
  try {
14961
15244
  if (await this.advance(row)) settledRuns.set(row.runId, row.projectId);
14962
15245
  } catch (err) {
14963
- log17.warn("batch.advance-failed", { batchId: row.id, runId: row.runId, provider: row.provider, error: describeError(err) });
15246
+ log18.warn("batch.advance-failed", { batchId: row.id, runId: row.runId, provider: row.provider, error: describeError(err) });
14964
15247
  this.reschedule(row.id, null);
14965
15248
  }
14966
15249
  }
@@ -14969,7 +15252,7 @@ var ProviderBatchPoller = class {
14969
15252
  try {
14970
15253
  this.deps.runner.finalizeBatchRun(runId, projectId);
14971
15254
  } catch (err) {
14972
- log17.warn("batch.finalize-failed", { runId, error: describeError(err) });
15255
+ log18.warn("batch.finalize-failed", { runId, error: describeError(err) });
14973
15256
  }
14974
15257
  }
14975
15258
  }
@@ -15000,10 +15283,10 @@ var ProviderBatchPoller = class {
15000
15283
  return ended ? this.ingest({ ...row, status: ProviderBatchStatuses.ended, resultsExpireAt: poll.resultsExpireAt ?? null }) : false;
15001
15284
  }
15002
15285
  } catch (err) {
15003
- log17.warn("batch.poll-failed", { batchId: row.id, runId: row.runId, provider: row.provider, error: describeError(err) });
15286
+ log18.warn("batch.poll-failed", { batchId: row.id, runId: row.runId, provider: row.provider, error: describeError(err) });
15004
15287
  }
15005
15288
  } else {
15006
- log17.warn("batch.poll-unavailable", { batchId: row.id, runId: row.runId, provider: row.provider });
15289
+ log18.warn("batch.poll-unavailable", { batchId: row.id, runId: row.runId, provider: row.provider });
15007
15290
  }
15008
15291
  this.reschedule(row.id, status);
15009
15292
  return this.enforceDeadline(row, registered);
@@ -15025,12 +15308,12 @@ var ProviderBatchPoller = class {
15025
15308
  isNull2(providerBatches.cancelRequestedAt)
15026
15309
  )).run().changes === 1;
15027
15310
  if (!claimed) return false;
15028
- log17.warn("batch.deadline-passed", { batchId: row.id, runId: row.runId, provider: row.provider, deadlineAt: row.deadlineAt });
15311
+ log18.warn("batch.deadline-passed", { batchId: row.id, runId: row.runId, provider: row.provider, deadlineAt: row.deadlineAt });
15029
15312
  if (registered?.adapter.batch && row.providerBatchId) {
15030
15313
  try {
15031
15314
  await registered.adapter.batch.cancel(row.providerBatchId, registered.config);
15032
15315
  } catch (err) {
15033
- log17.warn("batch.cancel-failed", { batchId: row.id, provider: row.provider, error: describeError(err) });
15316
+ log18.warn("batch.cancel-failed", { batchId: row.id, provider: row.provider, error: describeError(err) });
15034
15317
  }
15035
15318
  }
15036
15319
  return false;
@@ -15042,7 +15325,7 @@ var ProviderBatchPoller = class {
15042
15325
  updatedAt: at
15043
15326
  }).where(and16(eq18(providerBatches.id, row.id), eq18(providerBatches.status, ProviderBatchStatuses.submitted))).run().changes === 1;
15044
15327
  this.schedule.delete(row.id);
15045
- if (gaveUp) log17.warn("batch.abandoned-after-deadline", { batchId: row.id, runId: row.runId, provider: row.provider });
15328
+ if (gaveUp) log18.warn("batch.abandoned-after-deadline", { batchId: row.id, runId: row.runId, provider: row.provider });
15046
15329
  return gaveUp;
15047
15330
  }
15048
15331
  async ingest(row) {
@@ -15051,7 +15334,7 @@ var ProviderBatchPoller = class {
15051
15334
  return result2.kind !== "skipped";
15052
15335
  } catch (err) {
15053
15336
  const error = describeError(err);
15054
- log17.warn("batch.ingest-failed", { batchId: row.id, runId: row.runId, provider: row.provider, error });
15337
+ log18.warn("batch.ingest-failed", { batchId: row.id, runId: row.runId, provider: row.provider, error });
15055
15338
  const now = this.now();
15056
15339
  const unreadable = row.resultsExpireAt !== null && now >= Date.parse(row.resultsExpireAt);
15057
15340
  if (unreadable || now >= Date.parse(row.deadlineAt) + PROVIDER_BATCH_CANCEL_GRACE_MS) {
@@ -15093,7 +15376,7 @@ function startProviderBatchPoller(deps, intervalMs = PROVIDER_BATCH_TICK_MS) {
15093
15376
  const timer = setInterval(() => {
15094
15377
  if (running) return;
15095
15378
  running = true;
15096
- void poller.tick().catch((err) => log17.warn("batch.pass-failed", { error: describeError(err) })).finally(() => {
15379
+ void poller.tick().catch((err) => log18.warn("batch.pass-failed", { error: describeError(err) })).finally(() => {
15097
15380
  running = false;
15098
15381
  });
15099
15382
  }, intervalMs);
@@ -15102,7 +15385,7 @@ function startProviderBatchPoller(deps, intervalMs = PROVIDER_BATCH_TICK_MS) {
15102
15385
  }
15103
15386
 
15104
15387
  // src/data-refresh.ts
15105
- var log18 = createLogger("DataRefresh");
15388
+ var log19 = createLogger("DataRefresh");
15106
15389
  function gaRefreshDays(now = /* @__PURE__ */ new Date()) {
15107
15390
  const [reportMonth] = reportMonthsForDoctor(void 0, now);
15108
15391
  const reach = inclusiveDayCount(`${reportMonth}-01`, now.toISOString().slice(0, 10)) ?? 0;
@@ -15124,11 +15407,11 @@ async function refreshAllIntegrations(client, projectName, now = /* @__PURE__ */
15124
15407
  results.forEach((result2, idx) => {
15125
15408
  const integration = integrations[idx].name;
15126
15409
  if (result2.status === "fulfilled") {
15127
- log18.info("integration.refreshed", { projectName, integration });
15410
+ log19.info("integration.refreshed", { projectName, integration });
15128
15411
  } else {
15129
15412
  const reason = result2.reason;
15130
15413
  const message = describeError(reason);
15131
- log18.warn("integration.refresh-failed", { projectName, integration, error: message });
15414
+ log19.warn("integration.refresh-failed", { projectName, integration, error: message });
15132
15415
  }
15133
15416
  });
15134
15417
  }
@@ -15136,7 +15419,7 @@ async function refreshAllIntegrations(client, projectName, now = /* @__PURE__ */
15136
15419
  // src/notifier.ts
15137
15420
  import { eq as eq19, desc as desc8, and as and17, inArray as inArray9, or } from "drizzle-orm";
15138
15421
  import crypto22 from "crypto";
15139
- var log19 = createLogger("Notifier");
15422
+ var log20 = createLogger("Notifier");
15140
15423
  var MATERIAL_MAGNITUDE_DELTA = 20;
15141
15424
  var SEVERITY_RANK = { low: 0, medium: 1, high: 2, critical: 3 };
15142
15425
  function severityRank(severity) {
@@ -15166,26 +15449,26 @@ var Notifier = class {
15166
15449
  }
15167
15450
  /** Called after a run completes (success, partial, or failed). */
15168
15451
  async onRunCompleted(runId, projectId, opts) {
15169
- log19.info("run.completed", { runId, projectId, origin: opts?.origin ?? "sweep" });
15452
+ log20.info("run.completed", { runId, projectId, origin: opts?.origin ?? "sweep" });
15170
15453
  const notifs = this.db.select().from(notifications).where(eq19(notifications.projectId, projectId)).all().filter((n) => n.enabled);
15171
15454
  if (notifs.length === 0) {
15172
- log19.info("notifications.none-enabled", { projectId });
15455
+ log20.info("notifications.none-enabled", { projectId });
15173
15456
  return;
15174
15457
  }
15175
- log19.info("notifications.found", { projectId, count: notifs.length });
15458
+ log20.info("notifications.found", { projectId, count: notifs.length });
15176
15459
  const run = this.db.select().from(runs).where(eq19(runs.id, runId)).get();
15177
15460
  if (!run) {
15178
- log19.error("run.not-found", { runId, msg: "skipping notification dispatch" });
15461
+ log20.error("run.not-found", { runId, msg: "skipping notification dispatch" });
15179
15462
  return;
15180
15463
  }
15181
15464
  const project = this.db.select().from(projects).where(eq19(projects.id, projectId)).get();
15182
15465
  if (!project) {
15183
- log19.error("project.not-found", { projectId, msg: "skipping notification dispatch" });
15466
+ log20.error("project.not-found", { projectId, msg: "skipping notification dispatch" });
15184
15467
  return;
15185
15468
  }
15186
15469
  const transitions = this.computeTransitions(runId, projectId);
15187
15470
  const events = [];
15188
- log19.info("run.status", { runId: run.id, status: run.status, projectId });
15471
+ log20.info("run.status", { runId: run.id, status: run.status, projectId });
15189
15472
  if ((run.status === "completed" || run.status === "partial") && opts?.origin !== "fill") {
15190
15473
  events.push("run.completed");
15191
15474
  }
@@ -15201,7 +15484,7 @@ var Notifier = class {
15201
15484
  if (!config.url) continue;
15202
15485
  const subscribedEvents = config.events;
15203
15486
  const matchingEvents = events.filter((e) => subscribedEvents.includes(e));
15204
- log19.info("notification.match", { notificationId: notif.id, subscribedEvents, matchedEvents: matchingEvents });
15487
+ log20.info("notification.match", { notificationId: notif.id, subscribedEvents, matchedEvents: matchingEvents });
15205
15488
  if (matchingEvents.length === 0) continue;
15206
15489
  for (const event of matchingEvents) {
15207
15490
  const relevantTransitions = event === "citation.lost" ? lostTransitions : event === "citation.gained" ? gainedTransitions : transitions;
@@ -15233,7 +15516,7 @@ var Notifier = class {
15233
15516
  async onHealthChecked(projectId, report) {
15234
15517
  const project = this.db.select().from(projects).where(eq19(projects.id, projectId)).get();
15235
15518
  if (!project) {
15236
- log19.error("project.not-found", { projectId, msg: "skipping health notification" });
15519
+ log20.error("project.not-found", { projectId, msg: "skipping health notification" });
15237
15520
  return null;
15238
15521
  }
15239
15522
  const rank = (status2) => status2 === "fail" ? 2 : status2 === "warn" ? 1 : 0;
@@ -15285,7 +15568,7 @@ var Notifier = class {
15285
15568
  this.db.update(doctorHealthState).set(observation).where(eq19(doctorHealthState.projectId, projectId)).run();
15286
15569
  }
15287
15570
  if (!event) {
15288
- log19.info("health.unchanged", { projectId, status, code });
15571
+ log20.info("health.unchanged", { projectId, status, code });
15289
15572
  return null;
15290
15573
  }
15291
15574
  const notifs = this.db.select().from(notifications).where(eq19(notifications.projectId, projectId)).all().filter((n) => n.enabled);
@@ -15313,7 +15596,7 @@ var Notifier = class {
15313
15596
  if (delivered > 0) {
15314
15597
  this.db.update(doctorHealthState).set({ notifiedAt: now }).where(eq19(doctorHealthState.projectId, projectId)).run();
15315
15598
  }
15316
- log19.info("health.notified", { projectId, event, status, code, subscribers: notifs.length, delivered });
15599
+ log20.info("health.notified", { projectId, event, status, code, subscribers: notifs.length, delivered });
15317
15600
  return event;
15318
15601
  }
15319
15602
  /**
@@ -15334,7 +15617,7 @@ var Notifier = class {
15334
15617
  if (check.status !== "ok" && check.status !== "fail") return null;
15335
15618
  const project = this.db.select().from(projects).where(eq19(projects.id, projectId)).get();
15336
15619
  if (!project) {
15337
- log19.error("project.not-found", { projectId, msg: "skipping site liveness notification" });
15620
+ log20.error("project.not-found", { projectId, msg: "skipping site liveness notification" });
15338
15621
  return null;
15339
15622
  }
15340
15623
  const previous = this.db.select().from(siteLivenessState).where(eq19(siteLivenessState.projectId, projectId)).get();
@@ -15388,7 +15671,7 @@ var Notifier = class {
15388
15671
  if (event === "health.degraded" && delivered > 0) {
15389
15672
  this.db.update(siteLivenessState).set({ notifiedAt: checkedAt }).where(eq19(siteLivenessState.projectId, projectId)).run();
15390
15673
  }
15391
- log19.info("site-liveness.notified", { projectId, event, code: check.code, subscribers: notifs.length, delivered });
15674
+ log20.info("site-liveness.notified", { projectId, event, code: check.code, subscribers: notifs.length, delivered });
15392
15675
  return event;
15393
15676
  }
15394
15677
  /**
@@ -15625,7 +15908,7 @@ var Notifier = class {
15625
15908
  delivered += accepted;
15626
15909
  }
15627
15910
  }
15628
- log19.info("reviews.dispatched", {
15911
+ log20.info("reviews.dispatched", {
15629
15912
  projectId,
15630
15913
  reviews: reviewsToSend.length,
15631
15914
  ratingDrops: ratingsToSend.length,
@@ -15638,7 +15921,7 @@ var Notifier = class {
15638
15921
  if (!thisRun) return [];
15639
15922
  const completeness = measurementRunCompleteness(this.db, runId);
15640
15923
  if (completeness.planned && !completeness.complete) {
15641
- log19.info("transitions.skipped-incomplete", {
15924
+ log20.info("transitions.skipped-incomplete", {
15642
15925
  runId,
15643
15926
  executed: completeness.executed,
15644
15927
  expected: completeness.expected
@@ -15730,23 +16013,23 @@ var Notifier = class {
15730
16013
  const targetLabel = redactNotificationUrl(url).urlDisplay;
15731
16014
  const targetCheck = await resolveWebhookTarget(url);
15732
16015
  if (!targetCheck.ok) {
15733
- log19.error("webhook.ssrf-blocked", { url: targetLabel, reason: targetCheck.message });
16016
+ log20.error("webhook.ssrf-blocked", { url: targetLabel, reason: targetCheck.message });
15734
16017
  this.logDelivery(projectId, notificationId, payload.event, "failed", `SSRF: ${targetCheck.message}`);
15735
16018
  return false;
15736
16019
  }
15737
- log19.info("webhook.send", { event: payload.event, url: targetLabel });
16020
+ log20.info("webhook.send", { event: payload.event, url: targetLabel });
15738
16021
  const maxRetries = 3;
15739
16022
  const delays = [1e3, 4e3, 16e3];
15740
16023
  for (let attempt = 0; attempt < maxRetries; attempt++) {
15741
16024
  try {
15742
16025
  const response = await deliverWebhook(targetCheck.target, body, signingSecret);
15743
16026
  if (response.status >= 200 && response.status < 300) {
15744
- log19.info("webhook.delivered", { event: payload.event, url: targetLabel, httpStatus: response.status });
16027
+ log20.info("webhook.delivered", { event: payload.event, url: targetLabel, httpStatus: response.status });
15745
16028
  this.logDelivery(projectId, notificationId, payload.event, "sent", null);
15746
16029
  return true;
15747
16030
  }
15748
16031
  const errorDetail = response.error ?? `HTTP ${response.status}`;
15749
- log19.warn("webhook.attempt-failed", { event: payload.event, url: targetLabel, attempt: attempt + 1, maxRetries, httpStatus: response.status, error: errorDetail });
16032
+ log20.warn("webhook.attempt-failed", { event: payload.event, url: targetLabel, attempt: attempt + 1, maxRetries, httpStatus: response.status, error: errorDetail });
15750
16033
  if (attempt === maxRetries - 1) {
15751
16034
  this.logDelivery(projectId, notificationId, payload.event, "failed", errorDetail);
15752
16035
  }
@@ -15754,7 +16037,7 @@ var Notifier = class {
15754
16037
  const errorDetail = describeError(err);
15755
16038
  if (attempt === maxRetries - 1) {
15756
16039
  this.logDelivery(projectId, notificationId, payload.event, "failed", errorDetail);
15757
- log19.error("webhook.exhausted", { event: payload.event, url: targetLabel, maxRetries, error: errorDetail });
16040
+ log20.error("webhook.exhausted", { event: payload.event, url: targetLabel, maxRetries, error: errorDetail });
15758
16041
  }
15759
16042
  }
15760
16043
  if (attempt < maxRetries - 1) {
@@ -15779,7 +16062,7 @@ var Notifier = class {
15779
16062
 
15780
16063
  // src/run-coordinator.ts
15781
16064
  import { eq as eq20 } from "drizzle-orm";
15782
- var log20 = createLogger("RunCoordinator");
16065
+ var log21 = createLogger("RunCoordinator");
15783
16066
  var RunCoordinator = class {
15784
16067
  constructor(db, notifier, intelligenceService, onInsightsGenerated, onAeroEvent) {
15785
16068
  this.db = db;
@@ -15795,16 +16078,31 @@ var RunCoordinator = class {
15795
16078
  onAeroEvent;
15796
16079
  /** Host wakeup only; durable completion receipts remain the delivery authority. */
15797
16080
  onSentimentCompleted;
16081
+ /**
16082
+ * Host hook after a non-probe answer-visibility run finishes `completed` or
16083
+ * `partial`: the run stored new answers, so answer-derived competitor
16084
+ * aliases may have new evidence. The local server schedules a detection pass
16085
+ * off the completion path (`createCompetitorAutoAliasRunner`). Failures are
16086
+ * logged and never reach the other subscribers.
16087
+ */
16088
+ onAnswersRecorded;
15798
16089
  async onRunCompleted(runId, projectId, opts) {
15799
16090
  const runRow = this.db.select().from(runs).where(eq20(runs.id, runId)).get();
15800
16091
  const kind = runRow?.kind ?? RunKinds["answer-visibility"];
15801
16092
  if (runRow?.trigger === RunTriggers.probe) {
15802
- log20.info("probe.skip-side-effects", { runId, projectId, kind });
16093
+ log21.info("probe.skip-side-effects", { runId, projectId, kind });
15803
16094
  return;
15804
16095
  }
16096
+ if (kind === RunKinds["answer-visibility"] && (runRow?.status === RunStatuses.completed || runRow?.status === RunStatuses.partial) && this.onAnswersRecorded) {
16097
+ try {
16098
+ await this.onAnswersRecorded(runId, projectId);
16099
+ } catch (err) {
16100
+ log21.error("answers-recorded.failed", { runId, projectId, error: describeError(err) });
16101
+ }
16102
+ }
15805
16103
  const completeness = measurementRunCompleteness(this.db, runId);
15806
16104
  if (completeness.planned && !completeness.complete) {
15807
- log20.info("measurement.incomplete-skip-derived", {
16105
+ log21.info("measurement.incomplete-skip-derived", {
15808
16106
  runId,
15809
16107
  projectId,
15810
16108
  executed: completeness.executed,
@@ -15813,7 +16111,7 @@ var RunCoordinator = class {
15813
16111
  try {
15814
16112
  await this.notifier.onRunCompleted(runId, projectId);
15815
16113
  } catch (err) {
15816
- log20.error("notifier.failed", { runId, error: describeError(err) });
16114
+ log21.error("notifier.failed", { runId, error: describeError(err) });
15817
16115
  }
15818
16116
  return;
15819
16117
  }
@@ -15821,7 +16119,7 @@ var RunCoordinator = class {
15821
16119
  try {
15822
16120
  await this.onSentimentCompleted(runId, projectId);
15823
16121
  } catch {
15824
- log20.error("sentiment.wakeup-failed", { runId, projectId });
16122
+ log21.error("sentiment.wakeup-failed", { runId, projectId });
15825
16123
  }
15826
16124
  }
15827
16125
  let insightCount = 0;
@@ -15838,12 +16136,12 @@ var RunCoordinator = class {
15838
16136
  try {
15839
16137
  await this.onInsightsGenerated(runId, projectId, result2);
15840
16138
  } catch (err) {
15841
- log20.error("insight-webhook.failed", { runId, error: describeError(err) });
16139
+ log21.error("insight-webhook.failed", { runId, error: describeError(err) });
15842
16140
  }
15843
16141
  }
15844
16142
  }
15845
16143
  } catch (err) {
15846
- log20.error("intelligence.failed", { runId, error: describeError(err) });
16144
+ log21.error("intelligence.failed", { runId, error: describeError(err) });
15847
16145
  }
15848
16146
  } else if (kind === RunKinds["gbp-sync"]) {
15849
16147
  try {
@@ -15856,22 +16154,22 @@ var RunCoordinator = class {
15856
16154
  try {
15857
16155
  await this.onInsightsGenerated(runId, projectId, analysisResultFromInsights(gbpInsights));
15858
16156
  } catch (err) {
15859
- log20.error("gbp-insight-webhook.failed", { runId, error: describeError(err) });
16157
+ log21.error("gbp-insight-webhook.failed", { runId, error: describeError(err) });
15860
16158
  }
15861
16159
  }
15862
16160
  } catch (err) {
15863
- log20.error("gbp-intelligence.failed", { runId, error: describeError(err) });
16161
+ log21.error("gbp-intelligence.failed", { runId, error: describeError(err) });
15864
16162
  }
15865
16163
  try {
15866
16164
  await this.notifier.dispatchReviewAlerts(projectId);
15867
16165
  } catch (err) {
15868
- log20.error("review-webhook.failed", { runId, error: describeError(err) });
16166
+ log21.error("review-webhook.failed", { runId, error: describeError(err) });
15869
16167
  }
15870
16168
  }
15871
16169
  try {
15872
16170
  await (opts ? this.notifier.onRunCompleted(runId, projectId, opts) : this.notifier.onRunCompleted(runId, projectId));
15873
16171
  } catch (err) {
15874
- log20.error("notifier.failed", { runId, error: describeError(err) });
16172
+ log21.error("notifier.failed", { runId, error: describeError(err) });
15875
16173
  }
15876
16174
  if (this.onAeroEvent) {
15877
16175
  try {
@@ -15884,7 +16182,7 @@ var RunCoordinator = class {
15884
16182
  };
15885
16183
  await this.onAeroEvent(ctx);
15886
16184
  } catch (err) {
15887
- log20.error("aero.failed", { runId, error: describeError(err) });
16185
+ log21.error("aero.failed", { runId, error: describeError(err) });
15888
16186
  }
15889
16187
  }
15890
16188
  }
@@ -16289,7 +16587,7 @@ function responseSchemasFor(operations) {
16289
16587
  return schema ? [schema] : [];
16290
16588
  });
16291
16589
  }
16292
- function isRecord4(value) {
16590
+ function isRecord7(value) {
16293
16591
  return value !== null && typeof value === "object" && !Array.isArray(value);
16294
16592
  }
16295
16593
  function resolve(schema, components) {
@@ -16322,7 +16620,7 @@ function render(schema, value, components, depth) {
16322
16620
  }
16323
16621
  let current = value;
16324
16622
  for (const member of members(node)) current = render(member, current, components, depth + 1);
16325
- if (Array.isArray(current) && isRecord4(node.items)) {
16623
+ if (Array.isArray(current) && isRecord7(node.items)) {
16326
16624
  const items = node.items;
16327
16625
  let changed = false;
16328
16626
  const out = current.map((item) => {
@@ -16332,9 +16630,9 @@ function render(schema, value, components, depth) {
16332
16630
  });
16333
16631
  return changed ? out : current;
16334
16632
  }
16335
- if (isRecord4(current)) {
16336
- const properties = isRecord4(node.properties) ? node.properties : {};
16337
- const extra = isRecord4(node.additionalProperties) ? node.additionalProperties : void 0;
16633
+ if (isRecord7(current)) {
16634
+ const properties = isRecord7(node.properties) ? node.properties : {};
16635
+ const extra = isRecord7(node.additionalProperties) ? node.additionalProperties : void 0;
16338
16636
  let changed = false;
16339
16637
  const out = {};
16340
16638
  for (const [key, child] of Object.entries(current)) {
@@ -16380,7 +16678,7 @@ var MAX_PARTIAL_NOTES = 12;
16380
16678
  function serializeResult(value) {
16381
16679
  return JSON.stringify(value);
16382
16680
  }
16383
- function isRecord5(value) {
16681
+ function isRecord8(value) {
16384
16682
  return !!value && typeof value === "object" && !Array.isArray(value);
16385
16683
  }
16386
16684
  function keptSummary(counts, cursors = {}) {
@@ -16400,7 +16698,7 @@ function noteCursor(owner, ownerPath, listPath, kept, total, notes, root = owner
16400
16698
  }
16401
16699
  function cursorForList(owner, ownerPath, listKey, root = owner) {
16402
16700
  const prefix = ownerPath ? `${ownerPath}.` : "";
16403
- if (listKey === "queries" && isRecord5(owner.queryPage) && owner.queryPage.nextCursor !== void 0 && owner.queryPage.nextCursor !== null) {
16701
+ if (listKey === "queries" && isRecord8(owner.queryPage) && owner.queryPage.nextCursor !== void 0 && owner.queryPage.nextCursor !== null) {
16404
16702
  const page = owner.queryPage;
16405
16703
  return {
16406
16704
  path: `${prefix}queryPage.nextCursor`,
@@ -16477,7 +16775,7 @@ function partialLists(root) {
16477
16775
  if (count3.count <= rows.length) continue;
16478
16776
  notes.push([label, `${rows.length} of ${count3.text}`]);
16479
16777
  noted = true;
16480
- } else if (ownFlag === true || object.truncated === true && typeof ownFlag !== "boolean" && (lists.length === 1 || rows.some(isRecord5))) {
16778
+ } else if (ownFlag === true || object.truncated === true && typeof ownFlag !== "boolean" && (lists.length === 1 || rows.some(isRecord8))) {
16481
16779
  notes.push([label, `${rows.length} shown; the tool cut this list`]);
16482
16780
  noted = true;
16483
16781
  }
@@ -16486,17 +16784,17 @@ function partialLists(root) {
16486
16784
  if (object.truncated === true && !noted) notes.push([path9 || "(root)", "the tool cut the lists here"]);
16487
16785
  if (depth >= MAX_PARTIAL_DEPTH || notes.length >= MAX_PARTIAL_NOTES) return;
16488
16786
  for (const [key, value] of Object.entries(object)) {
16489
- if (!key.startsWith("__") && isRecord5(value)) visit(value, path9 ? `${path9}.${key}` : key, depth + 1);
16787
+ if (!key.startsWith("__") && isRecord8(value)) visit(value, path9 ? `${path9}.${key}` : key, depth + 1);
16490
16788
  }
16491
16789
  };
16492
16790
  visit(root, "", 1);
16493
16791
  return notes.length > 0 ? Object.fromEntries(notes.slice(0, MAX_PARTIAL_NOTES)) : void 0;
16494
16792
  }
16495
16793
  function withPartialLists(value) {
16496
- if (!isRecord5(value) || typeof value.toJSON === "function") return value;
16794
+ if (!isRecord8(value) || typeof value.toJSON === "function") return value;
16497
16795
  const hadNote = Object.hasOwn(value, PARTIAL_LISTS_KEY);
16498
16796
  const notes = partialLists(value);
16499
- if (!notes && !hadNote && value.nextAttemptCursor == null && !CURSOR_KEYS.some((key) => value[key] !== void 0 && value[key] !== null) && !Object.values(value).some((child) => isRecord5(child) && CURSOR_KEYS.some((key) => child[key] !== void 0 && child[key] !== null))) return value;
16797
+ if (!notes && !hadNote && value.nextAttemptCursor == null && !CURSOR_KEYS.some((key) => value[key] !== void 0 && value[key] !== null) && !Object.values(value).some((child) => isRecord8(child) && CURSOR_KEYS.some((key) => child[key] !== void 0 && child[key] !== null))) return value;
16500
16798
  const pagination = {};
16501
16799
  const visit = (owner, path9, depth) => {
16502
16800
  const lists = Object.entries(owner).filter((entry) => Array.isArray(entry[1]));
@@ -16506,11 +16804,11 @@ function withPartialLists(value) {
16506
16804
  if (!cursor || key.startsWith("__") || Object.keys(pagination).length >= MAX_PARTIAL_NOTES) continue;
16507
16805
  const label = path9 ? `${path9}.${key}` : key;
16508
16806
  const total = cursor.total ?? ownListTotal(numbers, key) ?? (lists.length === 1 ? bareTotal(numbers) : void 0);
16509
- const incomplete = owner.__truncated === true || isRecord5(owner.__omittedRowsByField) && typeof owner.__omittedRowsByField[key] === "number";
16807
+ const incomplete = owner.__truncated === true || isRecord8(owner.__omittedRowsByField) && typeof owner.__omittedRowsByField[key] === "number";
16510
16808
  pagination[label] = `showing ${rows.length}${total ? ` of ${total.text}` : " rows"}; ${incomplete ? `incomplete page: re-request the original cursor with a smaller ${cursor.limit}; the next cursor skips omitted rows` : `pass ${cursor.parameter} ${JSON.stringify(cursor.value)}`}`;
16511
16809
  }
16512
16810
  if (depth >= MAX_PARTIAL_DEPTH) return;
16513
- for (const [key, child] of Object.entries(owner)) if (!key.startsWith("__") && isRecord5(child)) visit(child, path9 ? `${path9}.${key}` : key, depth + 1);
16811
+ for (const [key, child] of Object.entries(owner)) if (!key.startsWith("__") && isRecord8(child)) visit(child, path9 ? `${path9}.${key}` : key, depth + 1);
16514
16812
  };
16515
16813
  visit(value, "", 1);
16516
16814
  const { [PARTIAL_LISTS_KEY]: _previous, __pagination: _pagination, ...rest } = value;
@@ -16563,7 +16861,7 @@ function listTrimmer(lists) {
16563
16861
  hadTruncated: Object.hasOwn(owner, "__truncated"),
16564
16862
  priorTruncated: owner.__truncated,
16565
16863
  previous,
16566
- priorOmitted: isRecord5(previous) ? previous : void 0
16864
+ priorOmitted: isRecord8(previous) ? previous : void 0
16567
16865
  };
16568
16866
  });
16569
16867
  return (counts) => {
@@ -16599,12 +16897,12 @@ function collectCollections(root) {
16599
16897
  if (Array.isArray(value)) {
16600
16898
  let records = false;
16601
16899
  for (const row of value) {
16602
- if (!isRecord5(row)) continue;
16900
+ if (!isRecord8(row)) continue;
16603
16901
  records = true;
16604
16902
  visit(row, `${valuePath}[]`, true);
16605
16903
  }
16606
16904
  if (value.length > 0) found.push({ path: valuePath, nested, records, owner: object, key, rows: value });
16607
- } else if (isRecord5(value)) {
16905
+ } else if (isRecord8(value)) {
16608
16906
  visit(value, valuePath, nested);
16609
16907
  }
16610
16908
  }
@@ -16641,8 +16939,8 @@ function currentSummary(root, owned) {
16641
16939
  counts.set(list.path, count3);
16642
16940
  noteCursor(object, path9, list.path, value.length, list.rows.length, cursors, root);
16643
16941
  }
16644
- for (const row of value) if (isRecord5(row)) visit(row, `${valuePath}[]`);
16645
- } else if (isRecord5(value)) {
16942
+ for (const row of value) if (isRecord8(row)) visit(row, `${valuePath}[]`);
16943
+ } else if (isRecord8(value)) {
16646
16944
  visit(value, valuePath);
16647
16945
  }
16648
16946
  }
@@ -16655,7 +16953,7 @@ function emptiedFloorChars(root) {
16655
16953
  }
16656
16954
  function trimNestedArrays(full) {
16657
16955
  const copy = JSON.parse(full);
16658
- if (!isRecord5(copy)) return void 0;
16956
+ if (!isRecord8(copy)) return void 0;
16659
16957
  copy.__truncated = true;
16660
16958
  copy[TRUNCATION_SUMMARY_KEY] = keptSummary(/* @__PURE__ */ new Map());
16661
16959
  if (emptiedFloorChars(copy) > MAX_TOOL_RESULT_CHARS) return void 0;
@@ -16726,7 +17024,7 @@ function trimNestedArrays(full) {
16726
17024
  function keptRowTotal(serialized) {
16727
17025
  const count3 = (value) => {
16728
17026
  if (Array.isArray(value)) return value.length;
16729
- if (!isRecord5(value)) return 0;
17027
+ if (!isRecord8(value)) return 0;
16730
17028
  return Object.entries(value).reduce((sum, [key, child]) => key.startsWith("__") ? sum : sum + count3(child), 0);
16731
17029
  };
16732
17030
  try {
@@ -16738,9 +17036,9 @@ function keptRowTotal(serialized) {
16738
17036
  function hasOtherLists(value, primary) {
16739
17037
  if (Array.isArray(value)) {
16740
17038
  if (value !== primary && value.length > 0) return true;
16741
- return value.some((row) => (isRecord5(row) || Array.isArray(row)) && hasOtherLists(row, primary));
17039
+ return value.some((row) => (isRecord8(row) || Array.isArray(row)) && hasOtherLists(row, primary));
16742
17040
  }
16743
- return isRecord5(value) && Object.values(value).some((child) => hasOtherLists(child, primary));
17041
+ return isRecord8(value) && Object.values(value).some((child) => hasOtherLists(child, primary));
16744
17042
  }
16745
17043
  function projectionPath(path9) {
16746
17044
  if (path9.length <= MAX_SUMMARY_PATH_CHARS) return path9;
@@ -16810,7 +17108,7 @@ function boundedProjection(full, partialRows, reserve = 0) {
16810
17108
  if (rows.length === 0) omit(path9 || "items");
16811
17109
  if (owner) {
16812
17110
  const cursors = {};
16813
- noteCursor(owner, ownerPath, path9, rows.length, input.length, cursors, isRecord5(value) ? value : owner);
17111
+ noteCursor(owner, ownerPath, path9, rows.length, input.length, cursors, isRecord8(value) ? value : owner);
16814
17112
  for (const [cursor, note] of Object.entries(cursors)) {
16815
17113
  record2(summary.cursors ??= {}, cursor, note.replace(path9, projectionPath(path9)));
16816
17114
  }
@@ -16818,7 +17116,7 @@ function boundedProjection(full, partialRows, reserve = 0) {
16818
17116
  }
16819
17117
  return `[${rows.join(",")}]`;
16820
17118
  }
16821
- if (!isRecord5(input)) {
17119
+ if (!isRecord8(input)) {
16822
17120
  const text = serializeResult(input);
16823
17121
  if (text.length <= budget) return text;
16824
17122
  if (typeof input === "string" && budget > 2) {
@@ -16829,7 +17127,7 @@ function boundedProjection(full, partialRows, reserve = 0) {
16829
17127
  omit(path9 || "(root)");
16830
17128
  return void 0;
16831
17129
  }
16832
- const priority = ([key, child]) => key === "queryPage" && isRecord5(child) ? 0 : typeof child === "string" && child.length > budget ? 2 : typeof child !== "object" || child === null ? 0 : partialRows && serializeResult(child).length > budget ? 2 : 1;
17130
+ const priority = ([key, child]) => key === "queryPage" && isRecord8(child) ? 0 : typeof child === "string" && child.length > budget ? 2 : typeof child !== "object" || child === null ? 0 : partialRows && serializeResult(child).length > budget ? 2 : 1;
16833
17131
  const fields = Object.entries(input).filter(([key]) => !key.startsWith("__")).map((field) => ({ field, rank: priority(field) })).sort((a, b) => a.rank - b.rank).map(({ field }) => field);
16834
17132
  const shown2 = [];
16835
17133
  let used = 2;
@@ -16851,13 +17149,13 @@ function boundedProjection(full, partialRows, reserve = 0) {
16851
17149
  };
16852
17150
  const projected = project(value, MAX_TOOL_RESULT_CHARS - PROJECTION_SUMMARY_CHARS - reserve, "");
16853
17151
  const shown = projected === void 0 ? {} : JSON.parse(projected);
16854
- const output = isRecord5(shown) ? shown : { items: shown };
17152
+ const output = isRecord8(shown) ? shown : { items: shown };
16855
17153
  const result2 = { ...output, __truncated: true, [TRUNCATION_SUMMARY_KEY]: summary };
16856
17154
  return serializeResult(partialRows ? withPartialLists(result2) : result2);
16857
17155
  }
16858
17156
  function rowsShown(cut, path9) {
16859
17157
  if (path9.includes("[")) return Infinity;
16860
- const list = path9.split(".").reduce((owner, key) => isRecord5(owner) ? owner[key] : void 0, cut);
17158
+ const list = path9.split(".").reduce((owner, key) => isRecord8(owner) ? owner[key] : void 0, cut);
16861
17159
  return Array.isArray(list) ? list.length : 0;
16862
17160
  }
16863
17161
  function withPartialRows(full, whole) {
@@ -16999,6 +17297,12 @@ var AERO_EXCLUDED_MCP_TOOLS = /* @__PURE__ */ new Set([
16999
17297
  // start paid classifier work and remain operator actions.
17000
17298
  "canonry_sentiment_configure",
17001
17299
  "canonry_sentiment_backfill",
17300
+ // Applying answer-derived competitor names changes the measured competitor
17301
+ // identity and restates stored history. Aero may preview detection
17302
+ // (`canonry_competitors_auto_aliases_detect`); applying stays an operator action.
17303
+ // The project writes that set the mode refuse a change instead; see
17304
+ // `refuseCompetitorAutoAliasModeChange`.
17305
+ CanonryMcpToolNames.canonry_competitors_auto_aliases_apply,
17002
17306
  CanonryMcpToolNames.canonry_agent_clear,
17003
17307
  CanonryMcpToolNames.canonry_agent_conversations_new,
17004
17308
  CanonryMcpToolNames.canonry_agent_conversations_resume,
@@ -17027,9 +17331,48 @@ function refuseManagedSweepCancel(tool, ctx) {
17027
17331
  }
17028
17332
  };
17029
17333
  }
17334
+ var COMPETITOR_AUTO_ALIAS_MODE_REFUSAL = "Aero cannot change a project's competitorAutoAliases mode. Omit competitorAutoAliases to keep the stored mode, or ask the operator to change it.";
17335
+ var COMPETITOR_AUTO_ALIAS_MODE_WRITES = /* @__PURE__ */ new Set([
17336
+ CanonryMcpToolNames.canonry_project_upsert,
17337
+ CanonryMcpToolNames.canonry_apply_config
17338
+ ]);
17339
+ function requestedCompetitorAutoAliasMode(toolName, params, ctx) {
17340
+ if (!isRecord8(params)) return { project: void 0, mode: void 0 };
17341
+ if (toolName === CanonryMcpToolNames.canonry_project_upsert) {
17342
+ const request = isRecord8(params.request) ? params.request : {};
17343
+ return { project: ctx.projectName, mode: request.competitorAutoAliases };
17344
+ }
17345
+ const config = isRecord8(params.config) ? params.config : {};
17346
+ const metadata2 = isRecord8(config.metadata) ? config.metadata : {};
17347
+ const spec = isRecord8(config.spec) ? config.spec : {};
17348
+ return { project: metadata2.name, mode: spec.competitorAutoAliases };
17349
+ }
17350
+ async function storedCompetitorAutoAliasMode(client, project) {
17351
+ if (typeof project !== "string") return DEFAULT_COMPETITOR_AUTO_ALIAS_MODE;
17352
+ try {
17353
+ return (await client.getProject(project)).competitorAutoAliases ?? DEFAULT_COMPETITOR_AUTO_ALIAS_MODE;
17354
+ } catch (err) {
17355
+ if (err instanceof CliError && err.code === "NOT_FOUND") return DEFAULT_COMPETITOR_AUTO_ALIAS_MODE;
17356
+ throw err;
17357
+ }
17358
+ }
17359
+ function refuseCompetitorAutoAliasModeChange(tool, ctx) {
17360
+ const write = tool.execute;
17361
+ return {
17362
+ ...tool,
17363
+ execute: async (toolCallId, params, ...rest) => {
17364
+ const { project, mode } = requestedCompetitorAutoAliasMode(tool.name, params, ctx);
17365
+ if (mode !== void 0 && mode !== await storedCompetitorAutoAliasMode(ctx.client, project)) {
17366
+ throw new Error(COMPETITOR_AUTO_ALIAS_MODE_REFUSAL);
17367
+ }
17368
+ return write(toolCallId, params, ...rest);
17369
+ }
17370
+ };
17371
+ }
17030
17372
  function buildMcpAgentTools(registry, ctx, opts = {}) {
17031
17373
  return registry.filter((tool) => !AERO_EXCLUDED_MCP_TOOLS.has(tool.name)).filter((tool) => opts.includeNames ? opts.includeNames.has(tool.name) : true).filter((tool) => opts.readOnly ? tool.access === "read" : true).filter((tool) => opts.managedSweeps ? !AERO_MANAGED_SWEEP_MCP_TOOLS.has(tool.name) : true).map((tool) => {
17032
17374
  const agentTool = mcpToAgentTool(tool, ctx);
17375
+ if (COMPETITOR_AUTO_ALIAS_MODE_WRITES.has(tool.name)) return refuseCompetitorAutoAliasModeChange(agentTool, ctx);
17033
17376
  return opts.managedSweeps && tool.name === CanonryMcpToolNames.canonry_run_cancel ? refuseManagedSweepCancel(agentTool, ctx) : agentTool;
17034
17377
  });
17035
17378
  }
@@ -17360,11 +17703,11 @@ function recordLlmUsageEvent(args) {
17360
17703
  var AERO_MEMORY_SEPARATOR = "\n\n---\n\n";
17361
17704
  var AERO_MEMORY_BLOCK_PREFIX = `${AERO_MEMORY_SEPARATOR}<memory>
17362
17705
  `;
17363
- function isRecord6(value) {
17706
+ function isRecord9(value) {
17364
17707
  return Boolean(value && typeof value === "object" && !Array.isArray(value));
17365
17708
  }
17366
17709
  function isTextSystemBlock(value) {
17367
- return isRecord6(value) && value.type === "text" && typeof value.text === "string";
17710
+ return isRecord9(value) && value.type === "text" && typeof value.text === "string";
17368
17711
  }
17369
17712
  function splitAeroHydratedSystemPrompt(systemPrompt) {
17370
17713
  const idx = systemPrompt.lastIndexOf(AERO_MEMORY_BLOCK_PREFIX);
@@ -17376,7 +17719,7 @@ function splitAeroHydratedSystemPrompt(systemPrompt) {
17376
17719
  }
17377
17720
  function splitAeroAnthropicSystemCachePayload(payload, model) {
17378
17721
  if (model.api !== "anthropic-messages") return void 0;
17379
- if (!isRecord6(payload) || !Array.isArray(payload.system)) return void 0;
17722
+ if (!isRecord9(payload) || !Array.isArray(payload.system)) return void 0;
17380
17723
  let changed = false;
17381
17724
  const nextSystem = [];
17382
17725
  for (const block of payload.system) {
@@ -18136,7 +18479,7 @@ function resolveSessionProviderAndModel(config, opts) {
18136
18479
  import { Type as Type6 } from "@earendil-works/pi-ai";
18137
18480
  import { Client } from "@modelcontextprotocol/sdk/client/index.js";
18138
18481
  import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
18139
- var log21 = createLogger("RemoteMcp");
18482
+ var log22 = createLogger("RemoteMcp");
18140
18483
  var AERO_EXCLUDED_MCP_TOOLS2 = /* @__PURE__ */ new Set([]);
18141
18484
  async function connectStreamableHttp(server) {
18142
18485
  const transport = new StreamableHTTPClientTransport(new URL(server.url), {
@@ -18204,7 +18547,7 @@ async function loadExternalMcpTools(servers, opts = {}) {
18204
18547
  try {
18205
18548
  client = await connect(server);
18206
18549
  } catch (err) {
18207
- log21.error("external-mcp.connect-failed", {
18550
+ log22.error("external-mcp.connect-failed", {
18208
18551
  label,
18209
18552
  error: describeError(err)
18210
18553
  });
@@ -18214,7 +18557,7 @@ async function loadExternalMcpTools(servers, opts = {}) {
18214
18557
  try {
18215
18558
  listed = await client.listTools();
18216
18559
  } catch (err) {
18217
- log21.error("external-mcp.list-failed", {
18560
+ log22.error("external-mcp.list-failed", {
18218
18561
  label,
18219
18562
  error: describeError(err)
18220
18563
  });
@@ -18225,7 +18568,7 @@ async function loadExternalMcpTools(servers, opts = {}) {
18225
18568
  if (!isReadOnly(tool)) continue;
18226
18569
  tools.push(adaptRemoteTool(client, tool));
18227
18570
  }
18228
- log21.info("external-mcp.loaded", {
18571
+ log22.info("external-mcp.loaded", {
18229
18572
  label,
18230
18573
  discovered: listed.tools.length,
18231
18574
  adopted: tools.length
@@ -18533,7 +18876,7 @@ async function compactMessages(args) {
18533
18876
  }
18534
18877
 
18535
18878
  // src/agent/session-registry.ts
18536
- var log22 = createLogger("SessionRegistry");
18879
+ var log23 = createLogger("SessionRegistry");
18537
18880
  var MAX_HYDRATE_NOTES = 20;
18538
18881
  var MAX_HYDRATE_BYTES = 32 * 1024;
18539
18882
  function escapeMemoryFragment(value) {
@@ -18592,7 +18935,7 @@ var SessionRegistry = class {
18592
18935
  if (!this.externalToolsPromise) {
18593
18936
  this.externalToolsPromise = loadExternalMcpTools(this.opts.config.externalMcpServers).catch(
18594
18937
  (err) => {
18595
- log22.error("external-mcp.load-failed", {
18938
+ log23.error("external-mcp.load-failed", {
18596
18939
  error: describeError(err)
18597
18940
  });
18598
18941
  return [];
@@ -18866,13 +19209,13 @@ ${lines.join("\n")}
18866
19209
  agent.state.messages = result2.messages;
18867
19210
  setAeroSystemPrompt(agent, this.buildHydratedSystemPrompt(projectId, row.systemPrompt));
18868
19211
  this.save(projectName);
18869
- log22.info("compaction.completed", {
19212
+ log23.info("compaction.completed", {
18870
19213
  projectName,
18871
19214
  removedCount: result2.removedCount,
18872
19215
  summaryBytes: Buffer.byteLength(result2.summary, "utf8")
18873
19216
  });
18874
19217
  } catch (err) {
18875
- log22.error("compaction.failed", {
19218
+ log23.error("compaction.failed", {
18876
19219
  projectName,
18877
19220
  error: describeError(err)
18878
19221
  });
@@ -19023,7 +19366,7 @@ ${lines.join("\n")}
19023
19366
  await agent.prompt(msgs);
19024
19367
  this.save(projectName);
19025
19368
  } catch (err) {
19026
- log22.error("drain.failed", {
19369
+ log23.error("drain.failed", {
19027
19370
  projectName,
19028
19371
  error: describeError(err)
19029
19372
  });
@@ -19337,7 +19680,10 @@ var ViewerAeroSessions = class {
19337
19680
  this.opts.db.delete(apiKeys).where(eq24(apiKeys.id, id)).run();
19338
19681
  } catch {
19339
19682
  try {
19340
- this.opts.db.update(apiKeys).set({ revokedAt: new Date(this.now()).toISOString() }).where(eq24(apiKeys.id, id)).run();
19683
+ this.opts.db.transaction((tx) => {
19684
+ tx.delete(dashboardSessions).where(eq24(dashboardSessions.apiKeyId, id)).run();
19685
+ tx.update(apiKeys).set({ revokedAt: new Date(this.now()).toISOString() }).where(eq24(apiKeys.id, id)).run();
19686
+ });
19341
19687
  } catch {
19342
19688
  }
19343
19689
  }
@@ -19386,7 +19732,10 @@ function mintSessionKey(db, scopes, userId) {
19386
19732
  }
19387
19733
  function revokeSessionKey(db, id) {
19388
19734
  try {
19389
- db.update(apiKeys).set({ revokedAt: (/* @__PURE__ */ new Date()).toISOString() }).where(eq25(apiKeys.id, id)).run();
19735
+ db.transaction((tx) => {
19736
+ tx.delete(dashboardSessions).where(eq25(dashboardSessions.apiKeyId, id)).run();
19737
+ tx.update(apiKeys).set({ revokedAt: (/* @__PURE__ */ new Date()).toISOString() }).where(eq25(apiKeys.id, id)).run();
19738
+ });
19390
19739
  } catch {
19391
19740
  }
19392
19741
  }
@@ -20189,7 +20538,7 @@ function formatAuditFactorScore(factor) {
20189
20538
  }
20190
20539
 
20191
20540
  // src/snapshot-service.ts
20192
- var log23 = createLogger("Snapshot");
20541
+ var log24 = createLogger("Snapshot");
20193
20542
  var ANALYSIS_PROVIDER_PRIORITY = ["openai", "claude", "gemini", "perplexity", "muse", "local"];
20194
20543
  var SNAPSHOT_QUERY_COUNT = 6;
20195
20544
  var SnapshotService = class {
@@ -20286,7 +20635,7 @@ var SnapshotService = class {
20286
20635
  return mapAuditReport(report);
20287
20636
  } catch (err) {
20288
20637
  const message = describeError(err);
20289
- log23.warn("audit.failed", { homepageUrl, error: message });
20638
+ log24.warn("audit.failed", { homepageUrl, error: message });
20290
20639
  return {
20291
20640
  url: homepageUrl,
20292
20641
  finalUrl: homepageUrl,
@@ -20315,7 +20664,7 @@ var SnapshotService = class {
20315
20664
  queries: parsedQueries
20316
20665
  };
20317
20666
  } catch (err) {
20318
- log23.warn("profile.generation-failed", {
20667
+ log24.warn("profile.generation-failed", {
20319
20668
  domain: ctx.domain,
20320
20669
  provider: ctx.analysisProvider.adapter.name,
20321
20670
  error: describeError(err)
@@ -20458,7 +20807,7 @@ var SnapshotService = class {
20458
20807
  recommendedActions: uniqueStrings2(parsed.recommendedActions ?? []).slice(0, 4)
20459
20808
  };
20460
20809
  } catch (err) {
20461
- log23.warn("response.analysis-failed", {
20810
+ log24.warn("response.analysis-failed", {
20462
20811
  provider: ctx.analysisProvider.adapter.name,
20463
20812
  error: describeError(err)
20464
20813
  });
@@ -20751,9 +21100,9 @@ async function executeResearchRun(db, registry, runId, projectId) {
20751
21100
  reserved = run.totalQueries;
20752
21101
  reservation = { scope, period };
20753
21102
  const rows = db.select().from(researchRunQueries).where(eq27(researchRunQueries.researchRunId, runId)).orderBy(researchRunQueries.position).all();
20754
- const competitorRows = db.select({ domain: competitors.domain, aliases: competitors.aliases }).from(competitors).where(eq27(competitors.projectId, projectId)).all();
21103
+ const competitorRows = db.select(competitorIdentityColumns).from(competitors).where(eq27(competitors.projectId, projectId)).orderBy(competitors.domain).all();
20755
21104
  const competitorDomains = competitorRows.map((row) => row.domain);
20756
- const competitorAliases = new Map(competitorRows.filter((row) => row.aliases.length > 0).map((row) => [row.domain, row.aliases]));
21105
+ const competitorAliases = new Map(competitorRows.map((row) => [row.domain, competitorIdentityAliases(row)]).filter(([, aliases]) => aliases.length > 0));
20757
21106
  const domains = effectiveDomains(project);
20758
21107
  const brands = effectiveBrandNames(project);
20759
21108
  const config = { ...provider.config, model: run.resolvedModel };
@@ -21086,7 +21435,7 @@ async function executeGtmMarketingSync(db, runtime, runId, projectId) {
21086
21435
  // src/server.ts
21087
21436
  var _require3 = createRequire3(import.meta.url);
21088
21437
  var { version: PKG_VERSION2 } = _require3("../package.json");
21089
- var log24 = createLogger("Server");
21438
+ var log25 = createLogger("Server");
21090
21439
  var runtimeStartupByServer = /* @__PURE__ */ new WeakMap();
21091
21440
  function waitForServerRuntimeStartup(app) {
21092
21441
  const startup = runtimeStartupByServer.get(app);
@@ -21292,7 +21641,7 @@ function applyLegacyCredentials(rows, config) {
21292
21641
  }
21293
21642
  if (migratedGoogle > 0) {
21294
21643
  saveConfigPatch({ google: config.google });
21295
- log24.info("credentials.migrated", { type: "google", count: migratedGoogle });
21644
+ log25.info("credentials.migrated", { type: "google", count: migratedGoogle });
21296
21645
  }
21297
21646
  let migratedGa4 = 0;
21298
21647
  for (const row of rows.ga4) {
@@ -21310,7 +21659,7 @@ function applyLegacyCredentials(rows, config) {
21310
21659
  }
21311
21660
  if (migratedGa4 > 0) {
21312
21661
  saveConfigPatch({ ga4: config.ga4 });
21313
- log24.info("credentials.migrated", { type: "ga4", count: migratedGa4 });
21662
+ log25.info("credentials.migrated", { type: "ga4", count: migratedGa4 });
21314
21663
  }
21315
21664
  }
21316
21665
  function isLoopbackBindHost(host) {
@@ -21547,11 +21896,11 @@ async function createServer(opts) {
21547
21896
  applyLegacyCredentials(legacyRows, opts.config);
21548
21897
  dropLegacyCredentialColumns(opts.db);
21549
21898
  } catch (err) {
21550
- log24.warn("credentials.migration.failed", {
21899
+ log25.warn("credentials.migration.failed", {
21551
21900
  error: describeError(err)
21552
21901
  });
21553
21902
  }
21554
- log24.info("providers.configured", {
21903
+ log25.info("providers.configured", {
21555
21904
  providers: Object.keys(providers).filter((k) => {
21556
21905
  const p = providers[k];
21557
21906
  return p?.apiKey || p?.baseUrl || p?.vertexProject;
@@ -21566,7 +21915,7 @@ async function createServer(opts) {
21566
21915
  }
21567
21916
  }
21568
21917
  for (const name of providersWithUnsupportedBatch(providers, (provider) => adapterMap[provider])) {
21569
- log24.warn("provider.batch.unsupported", {
21918
+ log25.warn("provider.batch.unsupported", {
21570
21919
  provider: name,
21571
21920
  message: `providers.${name}.batch.enabled is true, but the ${name} adapter has no batch API; its sweeps run sync.`
21572
21921
  });
@@ -22403,37 +22752,48 @@ async function createServer(opts) {
22403
22752
  const sessionCookieSecure = Boolean(
22404
22753
  opts.config.publicUrl?.startsWith("https://") || opts.config.apiUrl?.startsWith("https://")
22405
22754
  );
22406
- const sessions = /* @__PURE__ */ new Map();
22407
- const pruneExpiredSessions = () => {
22408
- const now = Date.now();
22409
- for (const [sessionId, session] of sessions.entries()) {
22410
- if (session.expiresAt <= now) {
22411
- sessions.delete(sessionId);
22412
- }
22413
- }
22414
- };
22415
- const createSession = (apiKeyId) => {
22416
- pruneExpiredSessions();
22755
+ const currentPasswordFingerprint = () => hashSessionToken(opts.config.dashboardPasswordHash ?? "");
22756
+ opts.db.delete(dashboardSessions).where(or3(
22757
+ eq29(dashboardSessions.apiKeyHash, ""),
22758
+ and23(
22759
+ isNotNull4(dashboardSessions.passwordFingerprint),
22760
+ ne3(dashboardSessions.passwordFingerprint, currentPasswordFingerprint())
22761
+ )
22762
+ )).run();
22763
+ const createSession = (key, passwordBased = false) => {
22764
+ const now = /* @__PURE__ */ new Date();
22765
+ opts.db.delete(dashboardSessions).where(lte2(dashboardSessions.expiresAt, now.toISOString())).run();
22417
22766
  const sessionId = crypto29.randomBytes(32).toString("hex");
22418
- sessions.set(sessionId, {
22419
- apiKeyId,
22420
- expiresAt: Date.now() + SESSION_TTL_MS
22421
- });
22767
+ opts.db.insert(dashboardSessions).values({
22768
+ tokenHash: hashSessionToken(sessionId),
22769
+ apiKeyId: key.id,
22770
+ apiKeyHash: key.keyHash,
22771
+ passwordFingerprint: passwordBased ? currentPasswordFingerprint() : null,
22772
+ createdAt: now.toISOString(),
22773
+ expiresAt: new Date(now.getTime() + SESSION_TTL_MS).toISOString()
22774
+ }).run();
22422
22775
  return sessionId;
22423
22776
  };
22424
22777
  const resolveSessionApiKeyId = (sessionId) => {
22425
- pruneExpiredSessions();
22426
- const session = sessions.get(sessionId);
22778
+ const tokenHash = hashSessionToken(sessionId);
22779
+ const session = opts.db.select({
22780
+ apiKeyId: dashboardSessions.apiKeyId,
22781
+ apiKeyHash: dashboardSessions.apiKeyHash,
22782
+ currentApiKeyHash: apiKeys.keyHash,
22783
+ passwordFingerprint: dashboardSessions.passwordFingerprint,
22784
+ expiresAt: dashboardSessions.expiresAt
22785
+ }).from(dashboardSessions).innerJoin(apiKeys, eq29(apiKeys.id, dashboardSessions.apiKeyId)).where(and23(eq29(dashboardSessions.tokenHash, tokenHash), isNull4(apiKeys.revokedAt))).get();
22427
22786
  if (!session) return null;
22428
- if (session.expiresAt <= Date.now()) {
22429
- sessions.delete(sessionId);
22787
+ const expiresAt = Date.parse(session.expiresAt);
22788
+ if (!Number.isFinite(expiresAt) || expiresAt <= Date.now() || session.apiKeyHash !== session.currentApiKeyHash || session.passwordFingerprint !== null && session.passwordFingerprint !== currentPasswordFingerprint()) {
22789
+ opts.db.delete(dashboardSessions).where(eq29(dashboardSessions.tokenHash, tokenHash)).run();
22430
22790
  return null;
22431
22791
  }
22432
22792
  return session.apiKeyId;
22433
22793
  };
22434
22794
  const clearSession = (sessionId) => {
22435
22795
  if (sessionId) {
22436
- sessions.delete(sessionId);
22796
+ opts.db.delete(dashboardSessions).where(eq29(dashboardSessions.tokenHash, hashSessionToken(sessionId))).run();
22437
22797
  }
22438
22798
  };
22439
22799
  const getDefaultApiKey = () => {
@@ -22443,7 +22803,7 @@ async function createServer(opts) {
22443
22803
  const createPasswordSession = (reply) => {
22444
22804
  const key = getDefaultApiKey();
22445
22805
  if (!key || key.revokedAt) return false;
22446
- const sessionId = createSession(key.id);
22806
+ const sessionId = createSession(key, true);
22447
22807
  reply.header(
22448
22808
  "set-cookie",
22449
22809
  serializeSessionCookie({
@@ -22563,7 +22923,7 @@ async function createServer(opts) {
22563
22923
  return reply.status(err2.statusCode).send(err2.toJSON());
22564
22924
  }
22565
22925
  opts.db.update(apiKeys).set({ lastUsedAt: (/* @__PURE__ */ new Date()).toISOString() }).where(eq29(apiKeys.id, key.id)).run();
22566
- const sessionId = createSession(key.id);
22926
+ const sessionId = createSession(key);
22567
22927
  reply.header(
22568
22928
  "set-cookie",
22569
22929
  serializeSessionCookie({
@@ -22638,7 +22998,7 @@ async function createServer(opts) {
22638
22998
  const sentimentPoller = createSentimentPoller(
22639
22999
  () => sentimentWorker.tick(),
22640
23000
  () => {
22641
- log24.error("sentiment.worker-failed", { reason: "Stored sentiment worker could not finish its tick." });
23001
+ log25.error("sentiment.worker-failed", { reason: "Stored sentiment worker could not finish its tick." });
22642
23002
  }
22643
23003
  );
22644
23004
  const pollSentiment = sentimentPoller.poll;
@@ -22646,6 +23006,110 @@ async function createServer(opts) {
22646
23006
  runCoordinator.onSentimentCompleted = async () => {
22647
23007
  pollSentiment();
22648
23008
  };
23009
+ const answerFieldRecomputes = createAnswerFieldRecomputeQueue({ db: opts.db });
23010
+ const recomputeDebts = /* @__PURE__ */ new Map();
23011
+ let recomputeSequence = 0;
23012
+ const markAnswerFieldRecompute = (projectId, full) => {
23013
+ opts.db.update(projects).set({ answerFieldsRecompute: full ? "full" : sql11`CASE WHEN ${projects.answerFieldsRecompute} = 'full' THEN 'full' ELSE 'competitors' END` }).where(eq29(projects.id, projectId)).run();
23014
+ };
23015
+ const requestAnswerFieldRecompute = (projectId, projectName, scope, reason) => {
23016
+ const sequence = ++recomputeSequence;
23017
+ const debt = recomputeDebts.get(projectId) ?? { latest: 0, latestFull: 0 };
23018
+ debt.latest = sequence;
23019
+ if (scope.full) debt.latestFull = sequence;
23020
+ recomputeDebts.set(projectId, debt);
23021
+ try {
23022
+ markAnswerFieldRecompute(projectId, scope.full === true);
23023
+ } catch (err) {
23024
+ app.log.error({ err, projectId }, "could not mark the owed answer field recompute");
23025
+ }
23026
+ answerFieldRecomputes.request(projectId, scope).then(
23027
+ (pass) => {
23028
+ if (pass.full && debt.latestFull <= sequence) debt.latestFull = 0;
23029
+ try {
23030
+ if (debt.latest === sequence && debt.latestFull === 0) {
23031
+ opts.db.update(projects).set({ answerFieldsRecompute: null }).where(eq29(projects.id, projectId)).run();
23032
+ if (recomputeDebts.get(projectId) === debt) recomputeDebts.delete(projectId);
23033
+ } else if (debt.latestFull === 0) {
23034
+ opts.db.update(projects).set({ answerFieldsRecompute: "competitors" }).where(and23(eq29(projects.id, projectId), eq29(projects.answerFieldsRecompute, "full"))).run();
23035
+ }
23036
+ } catch (err) {
23037
+ app.log.error({ err, projectId }, "could not clear the owed answer field recompute");
23038
+ }
23039
+ app.log.info(
23040
+ { projectId, projectName, reason, full: pass.full, ...pass.result },
23041
+ "identity changed: recomputed stored answer fields on historical snapshots"
23042
+ );
23043
+ },
23044
+ (err) => {
23045
+ app.log.error(
23046
+ { err, projectId, projectName, reason },
23047
+ "identity-triggered answer field recompute failed"
23048
+ );
23049
+ }
23050
+ );
23051
+ };
23052
+ const resumeAnswerFieldRecomputes = () => {
23053
+ const owed = opts.db.select({ id: projects.id, name: projects.name, scope: projects.answerFieldsRecompute }).from(projects).where(isNotNull4(projects.answerFieldsRecompute)).all();
23054
+ for (const project of owed) {
23055
+ requestAnswerFieldRecompute(project.id, project.name, { full: project.scope === "full" }, "resume-after-restart");
23056
+ }
23057
+ };
23058
+ const recomputeCompetitorFields = (projectId, projectName) => {
23059
+ requestAnswerFieldRecompute(projectId, projectName, {}, "competitor-names");
23060
+ };
23061
+ const competitorAutoAliases = createCompetitorAutoAliasRunner({
23062
+ db: opts.db,
23063
+ readAnchors: extractStoredAnswerAnchors,
23064
+ onNamesChanged: recomputeCompetitorFields
23065
+ });
23066
+ const competitorAutoAliasReasons = /* @__PURE__ */ new Map();
23067
+ const competitorAutoAliasPasses = createProjectPassQueue(async (projectId) => {
23068
+ await yieldToEventLoop4();
23069
+ const reason = (competitorAutoAliasReasons.get(projectId) ?? []).join(",");
23070
+ competitorAutoAliasReasons.delete(projectId);
23071
+ const project = opts.db.select().from(projects).where(eq29(projects.id, projectId)).get();
23072
+ if (!project || project.competitorAutoAliases === "off") return;
23073
+ if (project.competitorAutoAliases === "apply") {
23074
+ await competitorAutoAliases.request(projectId, reason).catch(() => void 0);
23075
+ return;
23076
+ }
23077
+ const detection = await previewCompetitorAutoAliases(opts.db, project, { readAnchors: extractStoredAnswerAnchors });
23078
+ const wouldAdd = detection.competitors.flatMap((competitor) => competitor.added.map((name) => `${competitor.domain}: ${name}`));
23079
+ const wouldRemove = detection.competitors.flatMap((competitor) => competitor.removed.map((entry) => `${competitor.domain}: ${entry.name}`));
23080
+ app.log.info(
23081
+ {
23082
+ projectId,
23083
+ projectName: project.name,
23084
+ reason,
23085
+ mode: "preview",
23086
+ runs: detection.scan.runs,
23087
+ snapshots: detection.scan.snapshots,
23088
+ wouldAdd: wouldAdd.slice(0, 20),
23089
+ wouldAddCount: wouldAdd.length,
23090
+ wouldRemove: wouldRemove.slice(0, 20),
23091
+ wouldRemoveCount: wouldRemove.length
23092
+ },
23093
+ "competitor auto aliases (preview): stored nothing; set the project to apply, or run detect --apply, to store them"
23094
+ );
23095
+ });
23096
+ const scheduleCompetitorAutoAliases = (projectId, reason) => {
23097
+ competitorAutoAliasReasons.set(projectId, [...competitorAutoAliasReasons.get(projectId) ?? [], reason]);
23098
+ competitorAutoAliasPasses.request(projectId).catch((err) => {
23099
+ app.log.error({ err, projectId, reason }, "competitor auto alias pass failed");
23100
+ });
23101
+ };
23102
+ runCoordinator.onAnswersRecorded = (runId, projectId) => {
23103
+ scheduleCompetitorAutoAliases(projectId, `run:${runId}`);
23104
+ };
23105
+ app.addHook("onClose", async () => {
23106
+ await competitorAutoAliasPasses.settled();
23107
+ await competitorAutoAliases.settled();
23108
+ await answerFieldRecomputes.settled();
23109
+ });
23110
+ app.addHook("onReady", async () => {
23111
+ resumeAnswerFieldRecomputes();
23112
+ });
22649
23113
  app.addHook("onReady", async () => {
22650
23114
  sentimentTimer = setInterval(pollSentiment, 5e3);
22651
23115
  sentimentTimer.unref();
@@ -23207,37 +23671,14 @@ async function createServer(opts) {
23207
23671
  }
23208
23672
  },
23209
23673
  onAliasesChanged: (projectId, projectName) => {
23210
- setImmediate(() => {
23211
- try {
23212
- const result2 = backfillProjectAnswerMentions(opts.db, projectId);
23213
- app.log.info(
23214
- { projectId, projectName, ...result2 },
23215
- "aliases changed \u2014 recomputed mention fields on historical snapshots"
23216
- );
23217
- } catch (err) {
23218
- app.log.error(
23219
- { err, projectId, projectName },
23220
- "alias-triggered backfill failed"
23221
- );
23222
- }
23223
- });
23674
+ requestAnswerFieldRecompute(projectId, projectName, { full: true }, "project-aliases");
23224
23675
  },
23225
- onCompetitorAliasesChanged: (projectId, projectName) => {
23226
- setImmediate(() => {
23227
- try {
23228
- const result2 = backfillProjectAnswerMentions(opts.db, projectId, { competitorFieldsOnly: true });
23229
- app.log.info(
23230
- { projectId, projectName, ...result2 },
23231
- "competitor aliases changed: recomputed competitor fields on historical snapshots"
23232
- );
23233
- } catch (err) {
23234
- app.log.error(
23235
- { err, projectId, projectName },
23236
- "competitor-alias-triggered backfill failed"
23237
- );
23238
- }
23239
- });
23676
+ onCompetitorAliasesChanged: recomputeCompetitorFields,
23677
+ onCompetitorAutoAliasRescan: (projectId) => {
23678
+ scheduleCompetitorAutoAliases(projectId, "rescan");
23240
23679
  },
23680
+ competitorAnswerAnchors: extractStoredAnswerAnchors,
23681
+ runCompetitorAutoAliasPass: (projectId) => competitorAutoAliases.request(projectId, "api"),
23241
23682
  operatorApiKeyIds,
23242
23683
  listOperationalLogs: (query) => operationalLogs.list(query),
23243
23684
  getTelemetryStatus,
@@ -23468,11 +23909,18 @@ async function createServer(opts) {
23468
23909
  resourcePaths: mcpTransportPaths().map(
23469
23910
  (suffix) => `${basePath ?? "/"}api/v1${suffix}`.replace("//", "/")
23470
23911
  ),
23471
- resolveUser: (request) => {
23912
+ resolveUser: (request, reply) => {
23472
23913
  const cookies = parseCookieHeader(request.headers.cookie);
23473
23914
  const token = cookies[USER_SESSION_COOKIE_NAME];
23474
23915
  if (!token) return null;
23475
23916
  const resolved = resolveUserSession(opts.db, token);
23917
+ if (resolved?.renewedExpiresAt) {
23918
+ reply.header("set-cookie", serializeUserSessionCookie({
23919
+ value: token,
23920
+ path: sessionCookiePath,
23921
+ secure: cookieIsSecure(request, sessionCookieSecure ? true : void 0)
23922
+ }));
23923
+ }
23476
23924
  return resolved ? { id: resolved.user.id, name: resolved.user.name } : null;
23477
23925
  },
23478
23926
  credentials: credentialChecker,
@@ -23614,6 +24062,7 @@ export {
23614
24062
  reparseStoredResult as reparseStoredResult3,
23615
24063
  reparseStoredResult4,
23616
24064
  reparseStoredResult5,
24065
+ resolveStoredApiResponse,
23617
24066
  getCloudflareTrafficConnectionBySourceId,
23618
24067
  setGoogleAuthConfig,
23619
24068
  formatAuditFactorScore,