@canonry/canonry 4.133.3 → 4.134.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 (26) hide show
  1. package/README.md +0 -13
  2. package/assets/agent-workspace/skills/canonry/references/canonry-cli.md +18 -0
  3. package/assets/assets/{AuditHistoryPanel-DTHQGvkW.js → AuditHistoryPanel-DBUWbfNk.js} +1 -1
  4. package/assets/assets/{BacklinksPage-DuPI3wt9.js → BacklinksPage-BFm0VdUO.js} +1 -1
  5. package/assets/assets/{ChartPrimitives-T8mxOObc.js → ChartPrimitives-oV_nduTH.js} +1 -1
  6. package/assets/assets/{HistoryPage-T1W40xKG.js → HistoryPage-IF2FMem3.js} +1 -1
  7. package/assets/assets/{ProjectPage-DYwM8-Rp.js → ProjectPage-ClwxX9Ga.js} +2 -2
  8. package/assets/assets/{RunRow-nHr4wW9B.js → RunRow-De5zdch5.js} +1 -1
  9. package/assets/assets/{RunsPage-Cbusy_84.js → RunsPage-xtRF78r8.js} +1 -1
  10. package/assets/assets/{SettingsPage-BVcbl4gn.js → SettingsPage-B_GAxaWu.js} +1 -1
  11. package/assets/assets/{TrafficPage-CZ4NTWI6.js → TrafficPage-E8I5ineJ.js} +1 -1
  12. package/assets/assets/{TrafficSourceDetailPage-xW-KvZY1.js → TrafficSourceDetailPage-DGk2MqSp.js} +1 -1
  13. package/assets/assets/{arrow-left-flskyFPK.js → arrow-left-D-4QMxi9.js} +1 -1
  14. package/assets/assets/{extract-error-message-BHfCTm0H.js → extract-error-message-DuqIFU8Z.js} +1 -1
  15. package/assets/assets/{index-CQzGDGMB.js → index-De1MKbc7.js} +88 -88
  16. package/assets/assets/{trash-2-LGfqtSzZ.js → trash-2-BVh__r6G.js} +1 -1
  17. package/assets/index.html +1 -1
  18. package/dist/{chunk-HENMGNLT.js → chunk-LIXWDIDR.js} +496 -3
  19. package/dist/{chunk-YRRQFOAH.js → chunk-PC7AYXND.js} +233 -2
  20. package/dist/{chunk-6ON6GUAL.js → chunk-TKXWTDV6.js} +68 -6
  21. package/dist/{chunk-A6BYMSZL.js → chunk-ZUQPHGEN.js} +45 -2
  22. package/dist/cli.js +68 -4
  23. package/dist/index.js +4 -4
  24. package/dist/{intelligence-service-OSLFG5S2.js → intelligence-service-4XOFUN5E.js} +2 -2
  25. package/dist/mcp.js +2 -2
  26. package/package.json +7 -7
@@ -1 +1 @@
1
- import{c as a}from"./index-CQzGDGMB.js";const c=[["circle",{cx:"12",cy:"12",r:"10",key:"1mglay"}],["path",{d:"m9 12 2 2 4-4",key:"dzmm74"}]],d=a("circle-check",c);const e=[["circle",{cx:"12",cy:"12",r:"10",key:"1mglay"}],["path",{d:"M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3",key:"1u773s"}],["path",{d:"M12 17h.01",key:"p32p05"}]],k=a("circle-question-mark",e);const t=[["path",{d:"M12 15V3",key:"m9g1x1"}],["path",{d:"M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4",key:"ih7n3h"}],["path",{d:"m7 10 5 5 5-5",key:"brsn70"}]],s=a("download",t);const o=[["path",{d:"M15 3h6v6",key:"1q9fwt"}],["path",{d:"M10 14 21 3",key:"gplh6r"}],["path",{d:"M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6",key:"a6xqqp"}]],r=a("external-link",o);const n=[["path",{d:"M10 11v6",key:"nco0om"}],["path",{d:"M14 11v6",key:"outv1u"}],["path",{d:"M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6",key:"miytrc"}],["path",{d:"M3 6h18",key:"d0wm0j"}],["path",{d:"M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2",key:"e791ji"}]],y=a("trash-2",n);export{d as C,s as D,r as E,y as T,k as a};
1
+ import{c as a}from"./index-De1MKbc7.js";const c=[["circle",{cx:"12",cy:"12",r:"10",key:"1mglay"}],["path",{d:"m9 12 2 2 4-4",key:"dzmm74"}]],d=a("circle-check",c);const e=[["circle",{cx:"12",cy:"12",r:"10",key:"1mglay"}],["path",{d:"M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3",key:"1u773s"}],["path",{d:"M12 17h.01",key:"p32p05"}]],k=a("circle-question-mark",e);const t=[["path",{d:"M12 15V3",key:"m9g1x1"}],["path",{d:"M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4",key:"ih7n3h"}],["path",{d:"m7 10 5 5 5-5",key:"brsn70"}]],s=a("download",t);const o=[["path",{d:"M15 3h6v6",key:"1q9fwt"}],["path",{d:"M10 14 21 3",key:"gplh6r"}],["path",{d:"M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6",key:"a6xqqp"}]],r=a("external-link",o);const n=[["path",{d:"M10 11v6",key:"nco0om"}],["path",{d:"M14 11v6",key:"outv1u"}],["path",{d:"M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6",key:"miytrc"}],["path",{d:"M3 6h18",key:"d0wm0j"}],["path",{d:"M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2",key:"e791ji"}]],y=a("trash-2",n);export{d as C,s as D,r as E,y as T,k as a};
package/assets/index.html CHANGED
@@ -12,7 +12,7 @@
12
12
  <link rel="icon" type="image/png" sizes="32x32" href="./favicon-32.png" />
13
13
  <link rel="apple-touch-icon" href="./apple-touch-icon.png" />
14
14
  <title>Canonry</title>
15
- <script type="module" crossorigin src="./assets/index-CQzGDGMB.js"></script>
15
+ <script type="module" crossorigin src="./assets/index-De1MKbc7.js"></script>
16
16
  <link rel="modulepreload" crossorigin href="./assets/vendor-tanstack-Be8JdV5M.js">
17
17
  <link rel="modulepreload" crossorigin href="./assets/vendor-radix-CQ77EO2y.js">
18
18
  <link rel="modulepreload" crossorigin href="./assets/vendor-recharts-DSxTZhaH.js">
@@ -19,6 +19,9 @@ import {
19
19
  AdsHistoricalCampaignRollupStatuses,
20
20
  AdsInsightLevels,
21
21
  AdsIntegrityDecisions,
22
+ AdsLiveDeliveryBases,
23
+ AdsLiveEntityTypes,
24
+ AdsLivePresences,
22
25
  AdsOperationKinds,
23
26
  AdsOperationStates,
24
27
  AdsOperationStepStates,
@@ -96,6 +99,8 @@ import {
96
99
  adsImageUploadRequestSchema,
97
100
  adsInsightLevelSchema,
98
101
  adsInsightsResponseSchema,
102
+ adsLiveDeliveryDtoSchema,
103
+ adsLiveDeliveryQuerySchema,
99
104
  adsOperationReconcileResponseSchema,
100
105
  adsOperationResponseSchema,
101
106
  adsOperationStepDtoSchema,
@@ -183,6 +188,7 @@ import {
183
188
  discoverySessionDetailDtoSchema,
184
189
  discoverySessionDtoSchema,
185
190
  doctorReportSchema,
191
+ dollarsToMicros,
186
192
  domainClassificationsResponseDtoSchema,
187
193
  effectiveBrandNames,
188
194
  effectiveDomains,
@@ -199,6 +205,7 @@ import {
199
205
  formatDateRange,
200
206
  formatDeltaCopy,
201
207
  formatIsoDate,
208
+ formatIsoDateInTimeZone,
202
209
  formatNumber,
203
210
  formatRatio,
204
211
  formatWindowCountDelta,
@@ -242,6 +249,7 @@ import {
242
249
  internalError,
243
250
  isBrowserProvider,
244
251
  isReadOnlyKey,
252
+ isoDateDaysBeforeInTimeZone,
245
253
  keywordDtoSchema,
246
254
  keywordGenerateRequestSchema,
247
255
  latestProjectRunDtoSchema,
@@ -356,7 +364,7 @@ import {
356
364
  wordpressSchemaDeployResultDtoSchema,
357
365
  wordpressSchemaStatusResultDtoSchema,
358
366
  wordpressStatusDtoSchema
359
- } from "./chunk-YRRQFOAH.js";
367
+ } from "./chunk-PC7AYXND.js";
360
368
 
361
369
  // src/intelligence-service.ts
362
370
  import { eq as eq44, desc as desc21, asc as asc9, and as and35, ne as ne6, or as or10, inArray as inArray15, gte as gte12, lte as lte8 } from "drizzle-orm";
@@ -1462,6 +1470,8 @@ var researchRunQueries = sqliteTable("research_run_queries", {
1462
1470
  groundingSources: text("grounding_sources", { mode: "json" }).$type().notNull().default([]),
1463
1471
  citedDomains: text("cited_domains", { mode: "json" }).$type().notNull().default([]),
1464
1472
  searchQueries: text("search_queries", { mode: "json" }).$type().notNull().default([]),
1473
+ namedCompetitors: text("named_competitors", { mode: "json" }).$type().notNull().default([]),
1474
+ citedCompetitorDomains: text("cited_competitor_domains", { mode: "json" }).$type().notNull().default([]),
1465
1475
  answerMentioned: integer("answer_mentioned", { mode: "boolean" }),
1466
1476
  citationState: text("citation_state"),
1467
1477
  rawResponse: text("raw_response", { mode: "json" }).$type(),
@@ -4266,6 +4276,16 @@ var MIGRATION_VERSIONS = [
4266
4276
  updated_at TEXT NOT NULL
4267
4277
  )`
4268
4278
  ]
4279
+ },
4280
+ {
4281
+ // Keep research batches useful as a competitor-comparison workspace while
4282
+ // retaining their strict isolation from tracked visibility snapshots.
4283
+ version: 110,
4284
+ name: "research-query-named-and-cited-competitors",
4285
+ statements: [
4286
+ `ALTER TABLE research_run_queries ADD COLUMN named_competitors TEXT NOT NULL DEFAULT '[]'`,
4287
+ `ALTER TABLE research_run_queries ADD COLUMN cited_competitor_domains TEXT NOT NULL DEFAULT '[]'`
4288
+ ]
4269
4289
  }
4270
4290
  ];
4271
4291
  function rebuildBacklinkTableWithSource(tx, table) {
@@ -17392,6 +17412,7 @@ var SCHEMA_TABLE = {
17392
17412
  AdsDisconnectResponse: adsDisconnectResponseSchema,
17393
17413
  AdsGeoSearchResponse: adsGeoSearchResponseSchema,
17394
17414
  AdsInsightsResponse: adsInsightsResponseSchema,
17415
+ AdsLiveDeliveryDto: adsLiveDeliveryDtoSchema,
17395
17416
  AdsOperationReconcileResponse: adsOperationReconcileResponseSchema,
17396
17417
  AdsOperationResponse: adsOperationResponseSchema,
17397
17418
  AdsSummaryDto: adsSummaryDtoSchema,
@@ -20395,6 +20416,35 @@ var routeCatalog = [
20395
20416
  404: errorResponse("Project not found.")
20396
20417
  }
20397
20418
  },
20419
+ {
20420
+ method: "get",
20421
+ path: "/api/v1/projects/{name}/ads/live-delivery",
20422
+ summary: "Live provider read of ads status and metrics, with the local snapshot delta",
20423
+ description: "Calls the OpenAI Ads API right now with the stored connection and returns the provider's current status and metrics unaggregated, alongside the corresponding local snapshot values and an explicit per-entity delta. Dates on both sides are the ad account's local calendar dates. The first day of the window is measured from 00:00 in the account timezone on both sides, so it is a whole day either way and a difference on it is real; the current local day is in progress on both sides, so a difference there is snapshot staleness. Read-only: it never creates, updates, pauses, or activates anything, and it never waits for a sync run. The window counts calendar days in the account timezone, not fixed 24-hour periods, so a daylight-saving transition cannot move the first day. The walk is bounded by per-level caps and a budget of 40 reader calls (see bounds). Admission has two rules: a project may have only one live read running at a time (a second request is refused for as long as the first is actually walking, which can exceed any interval), and consecutive reads are spaced by at least one minute. The interval is counted as soon as the provider is called, so a read that fails upstream still holds it; only a read rejected before the provider was called leaves the next one free. A reader call is not one HTTP request: each list or insight read auto-paginates up to 100 pages, so the worst-case upstream cost of a single request is about 4000 HTTP requests, reported as bounds.maxUpstreamHttpRequests.",
20424
+ tags: ["ads"],
20425
+ parameters: [
20426
+ nameParameter,
20427
+ {
20428
+ name: "campaignId",
20429
+ in: "query",
20430
+ description: "Scope the walk to a single campaign.",
20431
+ schema: stringSchema
20432
+ },
20433
+ {
20434
+ name: "lookbackDays",
20435
+ in: "query",
20436
+ description: "Metrics window in calendar days of the ad account timezone (1-30, default 7).",
20437
+ schema: integerSchema
20438
+ }
20439
+ ],
20440
+ responses: {
20441
+ 200: jsonResponse("Live provider state and snapshot delta returned.", "AdsLiveDeliveryDto"),
20442
+ 400: errorResponse("Invalid query, or no ads connection for this project."),
20443
+ 404: errorResponse("Project not found."),
20444
+ 429: errorResponse("Another live read for this project is still running, or the minimum interval since the last provider read has not elapsed. error.details.reason says which."),
20445
+ 502: errorResponse("The OpenAI Ads API read failed.")
20446
+ }
20447
+ },
20398
20448
  {
20399
20449
  method: "post",
20400
20450
  path: "/api/v1/projects/{name}/bing/connect",
@@ -28465,6 +28515,116 @@ function registerAdsActivationRoutes(app, opts) {
28465
28515
  });
28466
28516
  }
28467
28517
 
28518
+ // ../api-routes/src/ads-live-delivery.ts
28519
+ function liveComparisonWindow(fetchedAtMs, lookbackDays, timezone) {
28520
+ const fetchedAtIso = new Date(fetchedAtMs).toISOString();
28521
+ return {
28522
+ startDate: isoDateDaysBeforeInTimeZone(fetchedAtIso, lookbackDays, timezone),
28523
+ endDate: formatIsoDateInTimeZone(fetchedAtIso, timezone)
28524
+ };
28525
+ }
28526
+ var EMPTY_METRIC_VALUES = {
28527
+ impressions: 0,
28528
+ clicks: 0,
28529
+ spendMicros: 0,
28530
+ conversions: 0
28531
+ };
28532
+ function presenceOf(live, stored) {
28533
+ if (live && stored) return AdsLivePresences.both;
28534
+ if (live) return AdsLivePresences["live-only"];
28535
+ return AdsLivePresences["stored-only"];
28536
+ }
28537
+ function fieldDelta(field, live, stored) {
28538
+ return live === stored ? null : { field, live, stored };
28539
+ }
28540
+ function buildFieldDeltas(live, stored) {
28541
+ if (!live || !stored) return [];
28542
+ const deltas = [
28543
+ fieldDelta("status", live.status, stored.status),
28544
+ fieldDelta("reviewStatus", live.reviewStatus, stored.reviewStatus),
28545
+ fieldDelta("name", live.name, stored.name)
28546
+ ];
28547
+ return deltas.filter((delta) => delta !== null);
28548
+ }
28549
+ function foldLiveRows(rows) {
28550
+ let values = EMPTY_METRIC_VALUES;
28551
+ for (const row of rows) {
28552
+ values = {
28553
+ impressions: values.impressions + Math.round(row.impressions ?? 0),
28554
+ clicks: values.clicks + Math.round(row.clicks ?? 0),
28555
+ spendMicros: values.spendMicros + dollarsToMicros(row.spend ?? 0),
28556
+ conversions: values.conversions + Math.round(row.conversions ?? 0)
28557
+ };
28558
+ }
28559
+ return values;
28560
+ }
28561
+ function sameMetrics(a, b) {
28562
+ return a.impressions === b.impressions && a.clicks === b.clicks && a.spendMicros === b.spendMicros && a.conversions === b.conversions;
28563
+ }
28564
+ function buildMetricDeltas(liveRows, storedRows, window) {
28565
+ if (liveRows === null && storedRows === null) return null;
28566
+ const liveByDate = /* @__PURE__ */ new Map();
28567
+ for (const row of liveRows ?? []) {
28568
+ if (row.date === null) continue;
28569
+ const bucket = liveByDate.get(row.date) ?? [];
28570
+ bucket.push(row);
28571
+ liveByDate.set(row.date, bucket);
28572
+ }
28573
+ const storedByDate = /* @__PURE__ */ new Map();
28574
+ for (const row of storedRows ?? []) {
28575
+ if (row.date < window.startDate || row.date > window.endDate) continue;
28576
+ storedByDate.set(row.date, row);
28577
+ }
28578
+ const dates = [.../* @__PURE__ */ new Set([...liveByDate.keys(), ...storedByDate.keys()])].sort();
28579
+ return dates.map((date) => {
28580
+ const liveBucket = liveByDate.get(date);
28581
+ const storedRow = storedByDate.get(date);
28582
+ const live = liveBucket ? foldLiveRows(liveBucket) : null;
28583
+ const stored = storedRow ? {
28584
+ impressions: storedRow.impressions,
28585
+ clicks: storedRow.clicks,
28586
+ spendMicros: storedRow.spendMicros,
28587
+ conversions: storedRow.conversions
28588
+ } : null;
28589
+ const drifted = live === null || stored === null || !sameMetrics(live, stored);
28590
+ return { date, live, stored, drifted };
28591
+ });
28592
+ }
28593
+ function buildLiveEntityComparison(observation, window) {
28594
+ const presence = presenceOf(observation.live, observation.stored);
28595
+ const fieldDeltas = buildFieldDeltas(observation.live, observation.stored);
28596
+ const metricDeltas = buildMetricDeltas(observation.liveMetrics, observation.storedMetrics, window);
28597
+ const drifted = presence !== AdsLivePresences.both || fieldDeltas.length > 0 || (metricDeltas?.some((delta) => delta.drifted) ?? false);
28598
+ return {
28599
+ entityType: observation.entityType,
28600
+ id: observation.id,
28601
+ parentId: observation.parentId,
28602
+ presence,
28603
+ live: observation.live,
28604
+ stored: observation.stored,
28605
+ fieldDeltas,
28606
+ liveMetrics: observation.liveMetrics,
28607
+ metricDeltas,
28608
+ drifted
28609
+ };
28610
+ }
28611
+ function summarizeLiveDrift(entities) {
28612
+ let driftedEntities = 0;
28613
+ let statusDrifted = 0;
28614
+ let metricsDrifted = 0;
28615
+ for (const entity of entities) {
28616
+ if (entity.drifted) driftedEntities += 1;
28617
+ if (entity.fieldDeltas.some((delta) => delta.field === "status")) statusDrifted += 1;
28618
+ if (entity.metricDeltas?.some((delta) => delta.drifted)) metricsDrifted += 1;
28619
+ }
28620
+ return {
28621
+ entitiesCompared: entities.length,
28622
+ driftedEntities,
28623
+ statusDrifted,
28624
+ metricsDrifted
28625
+ };
28626
+ }
28627
+
28468
28628
  // ../api-routes/src/ads.ts
28469
28629
  function runSourceDto(row) {
28470
28630
  const parsed = runStatusSchema.safeParse(row.status);
@@ -28599,6 +28759,12 @@ function storedActivityAssessment(snapshotStatus, rollupStatus, rollupImpression
28599
28759
  }
28600
28760
  }
28601
28761
  }
28762
+ var ADS_LIVE_MAX_CAMPAIGNS = 5;
28763
+ var ADS_LIVE_MAX_AD_GROUPS_PER_CAMPAIGN = 10;
28764
+ var ADS_LIVE_MAX_ADS_PER_AD_GROUP = 20;
28765
+ var ADS_LIVE_MAX_READER_CALLS = 40;
28766
+ var DEFAULT_ADS_LIVE_MAX_PAGES_PER_READER_CALL = 100;
28767
+ var DEFAULT_ADS_LIVE_DELIVERY_MIN_INTERVAL_MS = 6e4;
28602
28768
  var DEFAULT_RECONCILE_SWEEP_INTERVAL_MS = 6e4;
28603
28769
  var DEFAULT_RECONCILE_PENDING_STALE_MS = 5 * 6e4;
28604
28770
  var DEFAULT_RECONCILE_BACKOFF_BASE_MS = 5 * 6e4;
@@ -28961,6 +29127,47 @@ async function executeAdsRead(surface, read) {
28961
29127
  });
28962
29128
  }
28963
29129
  }
29130
+ function resolveAdsLiveDeliveryReader(opts, projectName) {
29131
+ const apiKey = opts.adsCredentialStore?.getConnection(projectName)?.apiKey;
29132
+ if (!apiKey) {
29133
+ throw validationError("No OpenAI Ads API key configured for this project");
29134
+ }
29135
+ if (!opts.adsLiveDeliveryReader) {
29136
+ throw validationError("Ads live delivery reads are not configured for this deployment");
29137
+ }
29138
+ return { apiKey, reader: opts.adsLiveDeliveryReader };
29139
+ }
29140
+ function upstreamStatusOf(error) {
29141
+ return typeof error === "object" && error !== null && "status" in error && typeof error.status === "number" && Number.isInteger(error.status) ? error.status : null;
29142
+ }
29143
+ function liveReadFailure(surface, entityId, error) {
29144
+ return { surface, entityId, upstreamStatus: upstreamStatusOf(error) };
29145
+ }
29146
+ function liveReadProviderError(surface, error) {
29147
+ const upstreamStatus = upstreamStatusOf(error);
29148
+ return providerError(
29149
+ `OpenAI Ads API ${surface} read failed`,
29150
+ upstreamStatus === null ? void 0 : { upstreamStatus }
29151
+ );
29152
+ }
29153
+ function liveEntityState(entity) {
29154
+ return {
29155
+ name: entity.name,
29156
+ status: entity.status,
29157
+ reviewStatus: entity.reviewStatus,
29158
+ mode: entity.mode,
29159
+ updatedAt: entity.updatedAt
29160
+ };
29161
+ }
29162
+ function storedEntityState(row) {
29163
+ return {
29164
+ name: row.name,
29165
+ status: row.status,
29166
+ reviewStatus: row.reviewStatus ?? null,
29167
+ upstreamUpdatedAt: row.upstreamUpdatedAt,
29168
+ syncedAt: row.syncedAt
29169
+ };
29170
+ }
28964
29171
  async function executeAdsOperation(app, request, input) {
28965
29172
  const hash = requestHash({ kind: input.kind, entityType: input.entityType, payload: input.payload });
28966
29173
  const existing = readOperationByKey(app, input.projectId, input.operationKey);
@@ -29599,6 +29806,21 @@ async function adsRoutes(app, opts) {
29599
29806
  opts.adsAccountVerificationCacheTtlMs ?? DEFAULT_ADS_ACCOUNT_VERIFICATION_CACHE_TTL_MS
29600
29807
  );
29601
29808
  const verificationCache = /* @__PURE__ */ new Map();
29809
+ const liveDeliveryMinIntervalMs = Math.max(
29810
+ 0,
29811
+ opts.adsLiveDeliveryMinIntervalMs ?? DEFAULT_ADS_LIVE_DELIVERY_MIN_INTERVAL_MS
29812
+ );
29813
+ const liveDeliveryMaxPagesPerReaderCall = Math.max(
29814
+ 1,
29815
+ opts.adsLiveDeliveryMaxPagesPerReaderCall ?? DEFAULT_ADS_LIVE_MAX_PAGES_PER_READER_CALL
29816
+ );
29817
+ const liveDeliveryInFlight = /* @__PURE__ */ new Set();
29818
+ const liveDeliveryLastAttemptAtMs = /* @__PURE__ */ new Map();
29819
+ const pruneLiveDeliveryAttempts = (nowMs) => {
29820
+ for (const [projectId, lastAtMs] of liveDeliveryLastAttemptAtMs) {
29821
+ if (nowMs - lastAtMs >= liveDeliveryMinIntervalMs) liveDeliveryLastAttemptAtMs.delete(projectId);
29822
+ }
29823
+ };
29602
29824
  const reconcilePolicy = {
29603
29825
  pendingMinIdleMs: pendingStaleMs,
29604
29826
  backoffBaseMs,
@@ -30378,6 +30600,264 @@ async function adsRoutes(app, opts) {
30378
30600
  };
30379
30601
  return response;
30380
30602
  });
30603
+ app.get("/projects/:name/ads/live-delivery", async (request) => {
30604
+ const project = resolveProject(app.db, request.params.name);
30605
+ const parsed = adsLiveDeliveryQuerySchema.safeParse(request.query);
30606
+ if (!parsed.success) {
30607
+ throw validationError("Invalid ads live delivery query", { issues: parsed.error.issues });
30608
+ }
30609
+ const { campaignId, lookbackDays } = parsed.data;
30610
+ const connection = app.db.select().from(adsConnections).where(eq28(adsConnections.projectId, project.id)).get();
30611
+ if (!connection) {
30612
+ throw validationError('No ads connection for this project. Run "canonry ads connect" first.');
30613
+ }
30614
+ const { apiKey, reader } = resolveAdsLiveDeliveryReader(opts, project.name);
30615
+ const verifyAdsAccount = opts.verifyAdsAccount;
30616
+ if (!verifyAdsAccount) {
30617
+ throw validationError("Ads account verification is not configured for this deployment");
30618
+ }
30619
+ const fetchedAtMs = Date.now();
30620
+ const lastAttemptAtMs = liveDeliveryLastAttemptAtMs.get(project.id);
30621
+ const remainingIntervalMs = liveDeliveryMinIntervalMs > 0 && lastAttemptAtMs !== void 0 ? lastAttemptAtMs + liveDeliveryMinIntervalMs - fetchedAtMs : 0;
30622
+ if (liveDeliveryInFlight.has(project.id)) {
30623
+ throw quotaExceeded("OpenAI Ads live delivery reads", {
30624
+ reason: "read-in-flight",
30625
+ minIntervalMs: liveDeliveryMinIntervalMs,
30626
+ // A LOWER bound, and only when the interval itself still blocks. How
30627
+ // much longer the running walk needs is unknowable from here, so once
30628
+ // it has outlived the interval there is no honest number to give and
30629
+ // the field is absent rather than guessed.
30630
+ ...remainingIntervalMs > 0 ? { retryAfterMs: remainingIntervalMs } : {}
30631
+ });
30632
+ }
30633
+ if (remainingIntervalMs > 0) {
30634
+ throw quotaExceeded("OpenAI Ads live delivery reads", {
30635
+ reason: "min-interval",
30636
+ minIntervalMs: liveDeliveryMinIntervalMs,
30637
+ retryAfterMs: remainingIntervalMs
30638
+ });
30639
+ }
30640
+ liveDeliveryInFlight.add(project.id);
30641
+ const spendLiveDeliveryAttempt = () => {
30642
+ if (liveDeliveryMinIntervalMs <= 0) return;
30643
+ pruneLiveDeliveryAttempts(fetchedAtMs);
30644
+ liveDeliveryLastAttemptAtMs.set(project.id, fetchedAtMs);
30645
+ };
30646
+ try {
30647
+ let readerCalls = 0;
30648
+ let truncated = false;
30649
+ const errors = [];
30650
+ const callProvider = async (surface, entityId, run) => {
30651
+ if (readerCalls >= ADS_LIVE_MAX_READER_CALLS) {
30652
+ truncated = true;
30653
+ return null;
30654
+ }
30655
+ readerCalls += 1;
30656
+ try {
30657
+ return await run();
30658
+ } catch (error) {
30659
+ errors.push(liveReadFailure(surface, entityId, error));
30660
+ return null;
30661
+ }
30662
+ };
30663
+ readerCalls += 1;
30664
+ let account;
30665
+ spendLiveDeliveryAttempt();
30666
+ try {
30667
+ account = await verifyAdsAccount(apiKey);
30668
+ } catch (error) {
30669
+ throw liveReadProviderError("account identity", error);
30670
+ }
30671
+ if (account.id !== connection.adAccountId) {
30672
+ throw validationError("The configured key belongs to a different OpenAI ad account; reconnect this project");
30673
+ }
30674
+ readerCalls += 1;
30675
+ let liveCampaigns;
30676
+ try {
30677
+ liveCampaigns = await reader.listCampaigns(apiKey);
30678
+ } catch (error) {
30679
+ throw liveReadProviderError("campaign list", error);
30680
+ }
30681
+ const scopedCampaigns = campaignId === void 0 ? liveCampaigns : liveCampaigns.filter((campaign) => campaign.id === campaignId);
30682
+ if (scopedCampaigns.length > ADS_LIVE_MAX_CAMPAIGNS) truncated = true;
30683
+ const walkedCampaigns = scopedCampaigns.slice(0, ADS_LIVE_MAX_CAMPAIGNS);
30684
+ const storedCampaignRows = app.db.select().from(adsCampaigns).where(and22(
30685
+ eq28(adsCampaigns.projectId, project.id),
30686
+ campaignId === void 0 ? void 0 : eq28(adsCampaigns.id, campaignId)
30687
+ )).all();
30688
+ const storedAdGroupRows = app.db.select().from(adsAdGroups).where(and22(
30689
+ eq28(adsAdGroups.projectId, project.id),
30690
+ campaignId === void 0 ? void 0 : eq28(adsAdGroups.campaignId, campaignId)
30691
+ )).all();
30692
+ const storedAdGroupIds = new Set(storedAdGroupRows.map((row) => row.id));
30693
+ const storedAdRows = app.db.select().from(adsAds).where(eq28(adsAds.projectId, project.id)).all().filter((row) => campaignId === void 0 || storedAdGroupIds.has(row.adGroupId));
30694
+ const storedInsightRows = app.db.select({
30695
+ level: adsInsightsDaily.level,
30696
+ entityId: adsInsightsDaily.entityId,
30697
+ date: adsInsightsDaily.date,
30698
+ impressions: adsInsightsDaily.impressions,
30699
+ clicks: adsInsightsDaily.clicks,
30700
+ spendMicros: adsInsightsDaily.spendMicros,
30701
+ conversions: adsInsightsDaily.conversions
30702
+ }).from(adsInsightsDaily).where(eq28(adsInsightsDaily.projectId, project.id)).all();
30703
+ const storedCampaignById = new Map(storedCampaignRows.map((row) => [row.id, row]));
30704
+ const storedAdGroupById = new Map(storedAdGroupRows.map((row) => [row.id, row]));
30705
+ const storedAdById = new Map(storedAdRows.map((row) => [row.id, row]));
30706
+ const storedMetricsByEntity = /* @__PURE__ */ new Map();
30707
+ for (const row of storedInsightRows) {
30708
+ const key = `${row.level}:${row.entityId}`;
30709
+ const bucket = storedMetricsByEntity.get(key) ?? [];
30710
+ bucket.push({
30711
+ date: row.date,
30712
+ impressions: row.impressions,
30713
+ clicks: row.clicks,
30714
+ spendMicros: row.spendMicros,
30715
+ conversions: row.conversions
30716
+ });
30717
+ storedMetricsByEntity.set(key, bucket);
30718
+ }
30719
+ const storedMetricsFor = (level, entityId) => storedMetricsByEntity.get(`${level}:${entityId}`) ?? [];
30720
+ const accountTimezone = account.timezone ?? "UTC";
30721
+ const window = liveComparisonWindow(fetchedAtMs, lookbackDays, accountTimezone);
30722
+ const insightsRequest = {
30723
+ startDate: window.startDate,
30724
+ fetchedAtMs,
30725
+ timezone: accountTimezone
30726
+ };
30727
+ const observations = [];
30728
+ const walkedCampaignIds = /* @__PURE__ */ new Set();
30729
+ const walkedAdGroupIds = /* @__PURE__ */ new Set();
30730
+ const walkedAdIds = /* @__PURE__ */ new Set();
30731
+ for (const campaign of walkedCampaigns) {
30732
+ walkedCampaignIds.add(campaign.id);
30733
+ const campaignMetrics = await callProvider(
30734
+ "campaign insights",
30735
+ campaign.id,
30736
+ () => reader.campaignInsights(apiKey, campaign.id, insightsRequest)
30737
+ );
30738
+ observations.push({
30739
+ entityType: AdsLiveEntityTypes.campaign,
30740
+ id: campaign.id,
30741
+ parentId: null,
30742
+ live: liveEntityState(campaign),
30743
+ stored: storedCampaignById.has(campaign.id) ? storedEntityState(storedCampaignById.get(campaign.id)) : null,
30744
+ // A metrics call that did not happen is not evidence about metrics, so
30745
+ // both sides stay null and no delta is claimed.
30746
+ liveMetrics: campaignMetrics,
30747
+ storedMetrics: campaignMetrics === null ? null : storedMetricsFor(AdsInsightLevels.campaign, campaign.id)
30748
+ });
30749
+ const liveAdGroups = await callProvider(
30750
+ "ad group list",
30751
+ campaign.id,
30752
+ () => reader.listAdGroups(apiKey, campaign.id)
30753
+ );
30754
+ if (liveAdGroups === null) continue;
30755
+ if (liveAdGroups.length > ADS_LIVE_MAX_AD_GROUPS_PER_CAMPAIGN) truncated = true;
30756
+ for (const adGroup of liveAdGroups.slice(0, ADS_LIVE_MAX_AD_GROUPS_PER_CAMPAIGN)) {
30757
+ walkedAdGroupIds.add(adGroup.id);
30758
+ const adGroupMetrics = await callProvider(
30759
+ "ad group insights",
30760
+ adGroup.id,
30761
+ () => reader.adGroupInsights(apiKey, adGroup.id, insightsRequest)
30762
+ );
30763
+ observations.push({
30764
+ entityType: AdsLiveEntityTypes.ad_group,
30765
+ id: adGroup.id,
30766
+ parentId: campaign.id,
30767
+ live: liveEntityState(adGroup),
30768
+ stored: storedAdGroupById.has(adGroup.id) ? storedEntityState(storedAdGroupById.get(adGroup.id)) : null,
30769
+ liveMetrics: adGroupMetrics,
30770
+ storedMetrics: adGroupMetrics === null ? null : storedMetricsFor(AdsInsightLevels.ad_group, adGroup.id)
30771
+ });
30772
+ const liveAds = await callProvider(
30773
+ "ad list",
30774
+ adGroup.id,
30775
+ () => reader.listAds(apiKey, adGroup.id)
30776
+ );
30777
+ if (liveAds === null) continue;
30778
+ if (liveAds.length > ADS_LIVE_MAX_ADS_PER_AD_GROUP) truncated = true;
30779
+ for (const ad of liveAds.slice(0, ADS_LIVE_MAX_ADS_PER_AD_GROUP)) {
30780
+ walkedAdIds.add(ad.id);
30781
+ observations.push({
30782
+ entityType: AdsLiveEntityTypes.ad,
30783
+ id: ad.id,
30784
+ parentId: adGroup.id,
30785
+ live: liveEntityState(ad),
30786
+ stored: storedAdById.has(ad.id) ? storedEntityState(storedAdById.get(ad.id)) : null,
30787
+ // The provider has no per-ad insights surface (same reason ads-sync
30788
+ // stores no ad-level rollups), so ads carry status evidence only.
30789
+ liveMetrics: null,
30790
+ storedMetrics: null
30791
+ });
30792
+ }
30793
+ }
30794
+ }
30795
+ if (!truncated && errors.length === 0) {
30796
+ for (const row of storedCampaignRows) {
30797
+ if (walkedCampaignIds.has(row.id)) continue;
30798
+ observations.push({
30799
+ entityType: AdsLiveEntityTypes.campaign,
30800
+ id: row.id,
30801
+ parentId: null,
30802
+ live: null,
30803
+ stored: storedEntityState(row),
30804
+ liveMetrics: null,
30805
+ storedMetrics: null
30806
+ });
30807
+ }
30808
+ for (const row of storedAdGroupRows) {
30809
+ if (walkedAdGroupIds.has(row.id)) continue;
30810
+ observations.push({
30811
+ entityType: AdsLiveEntityTypes.ad_group,
30812
+ id: row.id,
30813
+ parentId: row.campaignId,
30814
+ live: null,
30815
+ stored: storedEntityState(row),
30816
+ liveMetrics: null,
30817
+ storedMetrics: null
30818
+ });
30819
+ }
30820
+ for (const row of storedAdRows) {
30821
+ if (walkedAdIds.has(row.id)) continue;
30822
+ observations.push({
30823
+ entityType: AdsLiveEntityTypes.ad,
30824
+ id: row.id,
30825
+ parentId: row.adGroupId,
30826
+ live: null,
30827
+ stored: storedEntityState(row),
30828
+ liveMetrics: null,
30829
+ storedMetrics: null
30830
+ });
30831
+ }
30832
+ }
30833
+ const entities = observations.map(
30834
+ (observation) => buildLiveEntityComparison(observation, window)
30835
+ );
30836
+ const response = {
30837
+ basis: AdsLiveDeliveryBases.liveProviderRead,
30838
+ fetchedAt: new Date(fetchedAtMs).toISOString(),
30839
+ adAccountId: account.id,
30840
+ storedSnapshotSyncedAt: connection.lastSyncedAt ?? null,
30841
+ metricsWindow: { lookbackDays },
30842
+ bounds: {
30843
+ maxCampaigns: ADS_LIVE_MAX_CAMPAIGNS,
30844
+ maxAdGroupsPerCampaign: ADS_LIVE_MAX_AD_GROUPS_PER_CAMPAIGN,
30845
+ maxAdsPerAdGroup: ADS_LIVE_MAX_ADS_PER_AD_GROUP,
30846
+ maxReaderCalls: ADS_LIVE_MAX_READER_CALLS,
30847
+ readerCalls,
30848
+ maxPagesPerReaderCall: liveDeliveryMaxPagesPerReaderCall,
30849
+ maxUpstreamHttpRequests: ADS_LIVE_MAX_READER_CALLS * liveDeliveryMaxPagesPerReaderCall,
30850
+ truncated
30851
+ },
30852
+ entities,
30853
+ drift: summarizeLiveDrift(entities),
30854
+ errors
30855
+ };
30856
+ return response;
30857
+ } finally {
30858
+ liveDeliveryInFlight.delete(project.id);
30859
+ }
30860
+ });
30381
30861
  app.get("/projects/:name/ads/summary", async (request) => {
30382
30862
  const project = resolveProject(app.db, request.params.name);
30383
30863
  const row = app.db.select().from(adsConnections).where(eq28(adsConnections.projectId, project.id)).get();
@@ -43406,7 +43886,7 @@ function serializeRun(row) {
43406
43886
  return { id: row.id, projectId: row.projectId, status: row.status, provider: row.provider, requestedModel: row.requestedModel, resolvedModel: row.resolvedModel, location: row.location ?? null, totalQueries: row.totalQueries, completedQueries: row.completedQueries, failedQueries: row.failedQueries, error: row.error, startedAt: row.startedAt, finishedAt: row.finishedAt, createdAt: row.createdAt };
43407
43887
  }
43408
43888
  function serializeQuery(row) {
43409
- return { id: row.id, position: row.position, query: row.queryText, status: row.status, requestedModel: row.requestedModel, resolvedModel: row.resolvedModel, servedModel: row.servedModel, answerText: row.answerText, groundingSources: row.groundingSources, citedDomains: row.citedDomains, searchQueries: row.searchQueries, answerMentioned: row.answerMentioned, citationState: row.citationState, error: row.error, startedAt: row.startedAt, finishedAt: row.finishedAt, createdAt: row.createdAt };
43889
+ return { id: row.id, position: row.position, query: row.queryText, status: row.status, requestedModel: row.requestedModel, resolvedModel: row.resolvedModel, servedModel: row.servedModel, answerText: row.answerText, groundingSources: row.groundingSources, citedDomains: row.citedDomains, searchQueries: row.searchQueries, namedCompetitors: row.namedCompetitors, citedCompetitorDomains: row.citedCompetitorDomains, answerMentioned: row.answerMentioned, citationState: row.citationState, error: row.error, startedAt: row.startedAt, finishedAt: row.finishedAt, createdAt: row.createdAt };
43410
43890
  }
43411
43891
 
43412
43892
  // ../api-routes/src/index.ts
@@ -43527,6 +44007,9 @@ async function apiRoutes(app, opts) {
43527
44007
  adsCredentialStore: opts.adsCredentialStore,
43528
44008
  verifyAdsAccount: opts.verifyAdsAccount,
43529
44009
  adsReader: opts.adsReader,
44010
+ adsLiveDeliveryReader: opts.adsLiveDeliveryReader,
44011
+ adsLiveDeliveryMinIntervalMs: opts.adsLiveDeliveryMinIntervalMs,
44012
+ adsLiveDeliveryMaxPagesPerReaderCall: opts.adsLiveDeliveryMaxPagesPerReaderCall,
43530
44013
  onAdsSyncRequested: opts.onAdsSyncRequested,
43531
44014
  adsOperator: opts.adsOperator,
43532
44015
  adsReconcileSweepIntervalMs: opts.adsReconcileSweepIntervalMs,
@@ -43870,6 +44353,15 @@ function computeCompetitorOverlap(normalized, competitorDomains) {
43870
44353
  }
43871
44354
  return [...overlapSet];
43872
44355
  }
44356
+ function computeCitedCompetitorDomains(citedDomains, competitorDomains) {
44357
+ const citedCompetitors = /* @__PURE__ */ new Set();
44358
+ for (const citedDomain of citedDomains) {
44359
+ for (const competitorDomain of competitorDomains) {
44360
+ if (domainMatches(citedDomain, competitorDomain)) citedCompetitors.add(competitorDomain);
44361
+ }
44362
+ }
44363
+ return [...citedCompetitors];
44364
+ }
43873
44365
  function escapeRegExp5(value) {
43874
44366
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
43875
44367
  }
@@ -43930,7 +44422,7 @@ function extractRecommendedCompetitors(answerText, ownDomains, citedDomains, com
43930
44422
  for (const pattern of candidatePatterns) {
43931
44423
  let match;
43932
44424
  while ((match = pattern.exec(answerText)) !== null) {
43933
- const candidate = cleanCandidateName(match[1] ?? "");
44425
+ const candidate = cleanCandidateName(match[1]);
43934
44426
  const candidateKey = brandKeyFromText(candidate);
43935
44427
  if (!candidateKey) continue;
43936
44428
  if (genericKeys.has(candidateKey)) continue;
@@ -44645,6 +45137,7 @@ export {
44645
45137
  classifyAiUserFetch,
44646
45138
  determineCitationState,
44647
45139
  computeCompetitorOverlap,
45140
+ computeCitedCompetitorDomains,
44648
45141
  extractRecommendedCompetitors,
44649
45142
  getPlaceDetails,
44650
45143
  hashPlaceDetails,