@canonry/canonry 5.12.0 → 5.14.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 (29) hide show
  1. package/assets/agent-workspace/skills/canonry/references/canonry-cli.md +5 -0
  2. package/assets/assets/{AuditHistoryPanel-DL_NSpfm.js → AuditHistoryPanel-DWDOPf8c.js} +1 -1
  3. package/assets/assets/{BacklinksPage-BtcBybtX.js → BacklinksPage-DGxq-iU6.js} +1 -1
  4. package/assets/assets/{HistoryPage-jXj-qi9c.js → HistoryPage-DR91m-gT.js} +1 -1
  5. package/assets/assets/{MeasurementPropertyPage-DKw401ok.js → MeasurementPropertyPage-a8poltfM.js} +1 -1
  6. package/assets/assets/{ProjectPage-og4qfFFD.js → ProjectPage-CCCJE7rA.js} +1 -1
  7. package/assets/assets/{RunRow-nK5iaZe1.js → RunRow-Xe63DyPK.js} +1 -1
  8. package/assets/assets/{RunsPage-CMgr3YSF.js → RunsPage-DK119ZZw.js} +1 -1
  9. package/assets/assets/{SettingsPage-QtElCcew.js → SettingsPage-G461foD-.js} +1 -1
  10. package/assets/assets/{SiteHealthSection-DNdVVl30.js → SiteHealthSection-CJnHkGM2.js} +3 -3
  11. package/assets/assets/{TrafficPage-C0WgvfRj.js → TrafficPage-AsWgc7Ot.js} +1 -1
  12. package/assets/assets/{TrafficSourceDetailPage-jOTedQtt.js → TrafficSourceDetailPage-D10yBQoD.js} +1 -1
  13. package/assets/assets/{extract-error-message-DO_053o0.js → extract-error-message-PP4HD_Dp.js} +1 -1
  14. package/assets/assets/{index-CnVwWE3V.js → index-CCIeAE4J.js} +22 -22
  15. package/assets/assets/{react-sigma_core.esm.min-DJttq2UK.js → react-sigma_core.esm.min-CkyyYkKU.js} +1 -1
  16. package/assets/assets/{v2-overview-adapter-BC_oa__K.js → v2-overview-adapter-DW-In1hp.js} +1 -1
  17. package/assets/index.html +1 -1
  18. package/dist/{chunk-WJGUBZSW.js → chunk-2W6GCJ3O.js} +65 -1
  19. package/dist/{chunk-NYRRWWEE.js → chunk-45EGDRVF.js} +3043 -2923
  20. package/dist/{chunk-LWGYRN3T.js → chunk-4LPV6ZZY.js} +2 -2
  21. package/dist/{chunk-DOC4QQLS.js → chunk-RGMUT5KY.js} +2549 -2099
  22. package/dist/{chunk-KBSAEALE.js → chunk-S6LJSUZO.js} +424 -161
  23. package/dist/{chunk-S4BIBUCK.js → chunk-SOLFOKZF.js} +2 -2
  24. package/dist/cli.js +133 -7
  25. package/dist/{demo-server-OTXS6ZHS.js → demo-server-H4ZFIIIB.js} +3 -3
  26. package/dist/index.js +5 -5
  27. package/dist/{intelligence-service-K4U55A2J.js → intelligence-service-CJKOXPOP.js} +2 -2
  28. package/dist/mcp.js +3 -3
  29. package/package.json +10 -10
@@ -17,12 +17,12 @@ import {
17
17
  resolveOperatorApiKeyIds,
18
18
  runWithUsageTags,
19
19
  saveConfigPatch
20
- } from "./chunk-WJGUBZSW.js";
20
+ } from "./chunk-2W6GCJ3O.js";
21
21
  import {
22
22
  deleteSiteCrawlGraphLayout,
23
23
  persistSiteCrawlGraphLayout,
24
24
  prepareSiteCrawlGraphLayout
25
- } from "./chunk-LWGYRN3T.js";
25
+ } from "./chunk-4LPV6ZZY.js";
26
26
  import {
27
27
  PACKAGE_VERSION
28
28
  } from "./chunk-AOJU5LKX.js";
@@ -73,6 +73,7 @@ import {
73
73
  createCredentialChecker,
74
74
  createFastifyLogger,
75
75
  createLogger,
76
+ createRunCompetitorResolver,
76
77
  createUserSession,
77
78
  deliverWebhook,
78
79
  determineCitationState,
@@ -134,7 +135,10 @@ import {
134
135
  loadDuckdb,
135
136
  markSessionFailed,
136
137
  measurementRunCompleteness,
138
+ measurementRunSlotState,
139
+ measurementSlotKey,
137
140
  migrate,
141
+ newerFullSweep,
138
142
  nextRunFromCron,
139
143
  nextRunFromSchedule,
140
144
  notifications,
@@ -164,6 +168,7 @@ import {
164
168
  resolveVercelSyncDeadlineMs,
165
169
  resolveWebhookTarget,
166
170
  runChecks,
171
+ runFills,
167
172
  runs,
168
173
  schedules,
169
174
  serializeUserSessionCookie,
@@ -179,7 +184,7 @@ import {
179
184
  siteLivenessState,
180
185
  toAlertView,
181
186
  usageCounters
182
- } from "./chunk-DOC4QQLS.js";
187
+ } from "./chunk-RGMUT5KY.js";
183
188
  import {
184
189
  AGENT_MEMORY_VALUE_MAX_BYTES,
185
190
  AGENT_NONE,
@@ -211,6 +216,7 @@ import {
211
216
  ONBOARDING_FLOW_VERSION,
212
217
  ProviderNames,
213
218
  ROUTE_CAPABLE_CITED_URL_PROVIDERS,
219
+ RUN_FILL_PROVIDER_BREAKER,
214
220
  ResearchQueryStatuses,
215
221
  ResearchRunStatuses,
216
222
  RunKinds,
@@ -272,6 +278,7 @@ import {
272
278
  filterCapturedCitedUrls,
273
279
  forbidden,
274
280
  formatIsoDateInTimeZone,
281
+ formatRunErrorOneLine,
275
282
  frameAncestorsHeaderValue,
276
283
  googleAdsAccessibleCustomerDtoSchema,
277
284
  googleAdsAccessibleCustomersResponseSchema,
@@ -313,6 +320,7 @@ import {
313
320
  observeTemplateLinkEdges,
314
321
  parseMeasurementRunManifestV1,
315
322
  parseOriginList,
323
+ parseRunError,
316
324
  providerError,
317
325
  providerSupportsLocationContext,
318
326
  registrableDomain,
@@ -337,7 +345,7 @@ import {
337
345
  visibilityReportRequestSchema,
338
346
  winnabilityClassLabel,
339
347
  withRetry
340
- } from "./chunk-NYRRWWEE.js";
348
+ } from "./chunk-45EGDRVF.js";
341
349
 
342
350
  // src/runtime-context.ts
343
351
  function cliRuntimeContext(env = process.env, stdio = process) {
@@ -6483,7 +6491,7 @@ function nonBlank(value) {
6483
6491
  return trimmed ? trimmed : void 0;
6484
6492
  }
6485
6493
  function resolveBuildCommit(env = process.env) {
6486
- const embedded = true ? "3cbbec137ff9c498f0b59f30a5e2b84c99af6f40" : void 0;
6494
+ const embedded = true ? "f4648ef75d44ebb5a3396cf05739a56a4aa2b7ee" : void 0;
6487
6495
  return nonBlank(embedded) ?? nonBlank(env.CANONRY_COMMIT);
6488
6496
  }
6489
6497
  function resolveInstanceIdentity(env = process.env) {
@@ -6831,6 +6839,8 @@ var JobRunner = class {
6831
6839
  this.registry = registry;
6832
6840
  }
6833
6841
  recoverStaleRuns() {
6842
+ const staleFills = this.db.update(runFills).set({ status: "failed", finishedAt: (/* @__PURE__ */ new Date()).toISOString(), error: "Server restarted while the fill was in progress" }).where(inArray(runFills.status, ["queued", "running"])).run();
6843
+ if (staleFills.changes > 0) log2.warn("fill.recovered-stale", { count: staleFills.changes });
6834
6844
  const stale = this.db.select({
6835
6845
  id: runs.id,
6836
6846
  projectId: runs.projectId,
@@ -6956,7 +6966,8 @@ var JobRunner = class {
6956
6966
  }
6957
6967
  log2.info("run.dispatch", { runId, providerCount: activeProviders.length, providers: activeProviders.map((p) => p.adapter.name) });
6958
6968
  const projectCompetitors = this.db.select().from(competitors).where(eq2(competitors.projectId, projectId)).all();
6959
- const competitorDomains = projectCompetitors.map((c) => c.domain);
6969
+ const runCompetitors = createRunCompetitorResolver(this.db, projectCompetitors.map((c) => c.domain))(existingRun.measurementPlanVersionId);
6970
+ const competitorDomains = runCompetitors.domains;
6960
6971
  const allDomains = effectiveDomains({
6961
6972
  canonicalDomain: project.canonicalDomain,
6962
6973
  ownedDomains: project.ownedDomains
@@ -7156,146 +7167,20 @@ var JobRunner = class {
7156
7167
  }
7157
7168
  }
7158
7169
  };
7159
- const processNodeForProvider = async (registeredProvider, unit) => {
7160
- const { adapter, config: providerConfig } = registeredProvider;
7161
- const providerName = adapter.name;
7162
- const gate = executionGates.get(providerName);
7163
- if (!gate) {
7164
- throw new Error(`Missing execution gate for provider ${providerName}`);
7165
- }
7166
- const config = unit.requestedModel ? { ...providerConfig, model: unit.requestedModel } : providerConfig;
7167
- const requestedContext = unit.context;
7168
- const supportedContext = requestedContext && providerSupportsLocationContext(adapter) ? { status: "applied", resolved: requestedContext } : null;
7169
- try {
7170
- await gate.run(async () => {
7171
- this.throwIfRunCancelled(runId);
7172
- providerDispatchCounts.set(providerName, (providerDispatchCounts.get(providerName) ?? 0) + 1);
7173
- const raw = await adapter.executeTrackedQuery(
7174
- {
7175
- query: unit.queryText,
7176
- canonicalDomains: allDomains,
7177
- competitorDomains,
7178
- location: requestedContext ?? void 0
7179
- },
7180
- config
7181
- );
7182
- this.throwIfRunCancelled(runId);
7183
- const providerResult = adapter.normalizeResult(raw);
7184
- const rawGroundingSources = providerResult.groundingSources;
7185
- const normalized = {
7186
- ...providerResult,
7187
- groundingSources: Array.isArray(rawGroundingSources) ? rawGroundingSources : []
7188
- };
7189
- let citedUrlCapture;
7190
- try {
7191
- citedUrlCapture = await captureCitedUrls(providerName, rawGroundingSources);
7192
- } catch (err) {
7193
- citedUrlCapture = {
7194
- citedUrls: [],
7195
- captureStatus: "failed",
7196
- sourceCount: normalized.groundingSources.length,
7197
- resolvedCount: 0,
7198
- captureVersion: CITED_URL_CAPTURE_VERSION
7199
- };
7200
- log2.warn("query.cited-url-capture-failed", {
7201
- runId,
7202
- provider: providerName,
7203
- query: unit.queryText,
7204
- error: describeError(err)
7205
- });
7206
- }
7207
- this.throwIfRunCancelled(runId);
7208
- const citationState = determineCitationState(normalized, allDomains);
7209
- const answerMentioned = determineAnswerMentioned(
7210
- normalized.answerText,
7211
- allBrandNames,
7212
- allDomains
7213
- );
7214
- const overlap = computeCompetitorOverlap(normalized, competitorDomains);
7215
- const extractedCompetitors = extractRecommendedCompetitors(
7216
- normalized.answerText,
7217
- allDomains,
7218
- normalized.citedDomains,
7219
- competitorDomains,
7220
- allBrandNames
7221
- );
7222
- const snapshotId = crypto6.randomUUID();
7223
- let screenshotRelPath = null;
7224
- if (raw.screenshotPath && fs3.existsSync(raw.screenshotPath)) {
7225
- const screenshotDir = path3.join(os3.homedir(), ".canonry", "screenshots", runId);
7226
- if (!fs3.existsSync(screenshotDir)) fs3.mkdirSync(screenshotDir, { recursive: true });
7227
- const destPath = path3.join(screenshotDir, `${snapshotId}.png`);
7228
- fs3.renameSync(raw.screenshotPath, destPath);
7229
- screenshotRelPath = `${runId}/${snapshotId}.png`;
7230
- }
7231
- this.db.insert(querySnapshots).values({
7232
- id: snapshotId,
7233
- runId,
7234
- queryId: unit.queryId,
7235
- queryText: unit.queryText,
7236
- provider: providerName,
7237
- // `model` is what was REQUESTED and `served_model` is what
7238
- // answered. The manifest froze the request, so it is the
7239
- // authority here: an adapter that reports its own default rather
7240
- // than what it was handed would otherwise overwrite the identity
7241
- // the revision recorded, and nothing would show that it moved.
7242
- model: unit.requestedModel ?? raw.model,
7243
- servedModel: raw.servedModel ?? null,
7244
- citationState,
7245
- answerMentioned,
7246
- answerText: normalized.answerText,
7247
- citedDomains: normalized.citedDomains,
7248
- citedUrls: citedUrlCapture.citedUrls,
7249
- captureStatus: citedUrlCapture.captureStatus,
7250
- sourceCount: citedUrlCapture.sourceCount,
7251
- resolvedCount: citedUrlCapture.resolvedCount,
7252
- captureVersion: citedUrlCapture.captureVersion,
7253
- retrievalStatus: normalized.retrievalStatus,
7254
- retrievalContract: raw.retrievalContract,
7255
- competitorOverlap: overlap,
7256
- recommendedCompetitors: extractedCompetitors,
7257
- // Only claim the geography the provider actually honoured. A
7258
- // requested-but-unsupported context stores `location: null` —
7259
- // "no claim" — rather than the label we asked for, mirroring
7260
- // `supportedContext` itself: this field is never non-null when
7261
- // that one is null.
7262
- location: supportedContext ? requestedContext?.label ?? null : null,
7263
- measurementExecutionId: unit.executionId,
7264
- requestedContext,
7265
- supportedContext,
7266
- screenshotPath: screenshotRelPath,
7267
- rawResponse: JSON.stringify({
7268
- model: raw.model,
7269
- servedModel: raw.servedModel ?? null,
7270
- groundingSources: normalized.groundingSources,
7271
- searchQueries: normalized.searchQueries,
7272
- apiResponse: raw.rawResponse
7273
- }),
7274
- createdAt: (/* @__PURE__ */ new Date()).toISOString()
7275
- }).run();
7276
- totalSnapshotsInserted++;
7277
- log2.info("query.citation", {
7278
- runId,
7279
- provider: providerName,
7280
- query: unit.queryText,
7281
- executionId: unit.executionId,
7282
- location: requestedContext?.label ?? null,
7283
- citationState,
7284
- answerMentioned
7285
- });
7286
- });
7287
- } catch (err) {
7288
- if (err instanceof RunCancelledError) {
7289
- throw err;
7290
- }
7291
- const msg = describeError(err);
7292
- const stack = err instanceof Error ? err.stack : void 0;
7293
- log2.error("query.failed", { runId, provider: providerName, query: unit.queryText, executionId: unit.executionId, error: msg, stack });
7294
- if (!providerErrors.has(providerName)) {
7295
- providerErrors.set(providerName, msg);
7296
- }
7170
+ const slotContext = {
7171
+ runId,
7172
+ allDomains,
7173
+ competitorDomains,
7174
+ competitorAliases: runCompetitors.aliases,
7175
+ allBrandNames,
7176
+ executionGates,
7177
+ providerDispatchCounts,
7178
+ providerErrors,
7179
+ onInserted: () => {
7180
+ totalSnapshotsInserted++;
7297
7181
  }
7298
7182
  };
7183
+ const processNodeForProvider = (registeredProvider, unit) => this.executePlanSlot(slotContext, registeredProvider, unit);
7299
7184
  if (existingRun.kind === RunKinds["answer-visibility"] && existingRun.trigger !== RunTriggers.probe && existingRun.measurementPlanVersionId === null) {
7300
7185
  const definition = buildSimpleMeasurementDefinition({
7301
7186
  capturedAt: (/* @__PURE__ */ new Date()).toISOString(),
@@ -7442,7 +7327,7 @@ var JobRunner = class {
7442
7327
  status: "failed",
7443
7328
  finishedAt: (/* @__PURE__ */ new Date()).toISOString(),
7444
7329
  error: errorMessage
7445
- }).where(eq2(runs.id, runId)).run();
7330
+ }).where(and2(eq2(runs.id, runId), inArray(runs.status, ["queued", "running"]))).run();
7446
7331
  this.flushProviderUsage(providerDispatchCounts, providerReservations);
7447
7332
  const abortReason2 = classifyRunAbortReason(errorMessage);
7448
7333
  const phases = buildPhases({ startTime, providerCallStart, providerCallEnd });
@@ -7483,6 +7368,366 @@ var JobRunner = class {
7483
7368
  }
7484
7369
  }
7485
7370
  }
7371
+ /**
7372
+ * Complete a partial plan run in place: record only the expected slots it
7373
+ * never answered, under the same run id, then finalize the run once.
7374
+ *
7375
+ * The parent run is never written from an error path and only ever leaves
7376
+ * `partial` through the completion compare-and-set, so a failed or
7377
+ * interrupted fill leaves the run as the sweep left it, plus any answers the
7378
+ * fill did record. Its timestamps, manifest and identity are never touched:
7379
+ * it stays the same sweep, with the same place in every history.
7380
+ */
7381
+ async executeRunFill(fillId) {
7382
+ const claim = this.db.update(runFills).set({ status: "running", startedAt: (/* @__PURE__ */ new Date()).toISOString() }).where(and2(eq2(runFills.id, fillId), eq2(runFills.status, "queued"))).run();
7383
+ if (claim.changes !== 1) {
7384
+ log2.warn("fill.not-claimed", { fillId });
7385
+ return;
7386
+ }
7387
+ try {
7388
+ await this.runClaimedFill(fillId);
7389
+ } catch (err) {
7390
+ const error = describeError(err);
7391
+ log2.error("fill.aborted", { fillId, error });
7392
+ this.db.update(runFills).set({ status: "failed", finishedAt: (/* @__PURE__ */ new Date()).toISOString(), error }).where(and2(eq2(runFills.id, fillId), eq2(runFills.status, "running"))).run();
7393
+ }
7394
+ }
7395
+ async runClaimedFill(fillId) {
7396
+ const fill = this.db.select().from(runFills).where(eq2(runFills.id, fillId)).get();
7397
+ if (!fill) return;
7398
+ const { runId, projectId } = fill;
7399
+ const requested = new Set(parseJsonColumn(fill.providers, []).map((provider) => provider.trim().toLocaleLowerCase("en")));
7400
+ const providerDispatchCounts = /* @__PURE__ */ new Map();
7401
+ const providerReservations = /* @__PURE__ */ new Map();
7402
+ const providerErrors = /* @__PURE__ */ new Map();
7403
+ let filled = 0;
7404
+ let fatal = null;
7405
+ let superseded = false;
7406
+ try {
7407
+ const run = this.getRunState(runId);
7408
+ if (!run) throw new Error(`Run ${runId} not found`);
7409
+ if (run.status !== "partial") throw new Error(`Run ${runId} is ${run.status}; only a partial run can be filled`);
7410
+ const runCreatedAt = this.db.select({ createdAt: runs.createdAt }).from(runs).where(eq2(runs.id, runId)).get().createdAt;
7411
+ const project = this.db.select().from(projects).where(eq2(projects.id, projectId)).get();
7412
+ if (!project) throw new Error(`Project ${projectId} not found`);
7413
+ const projectQueries = this.db.select().from(queries).where(eq2(queries.projectId, projectId)).all();
7414
+ const plan = resolvePlanExecution(run, projectQueries);
7415
+ if (!plan) throw new Error(`Run ${runId} did not measure a published plan`);
7416
+ const recorded = new Set(
7417
+ this.db.select({ executionId: querySnapshots.measurementExecutionId, provider: querySnapshots.provider }).from(querySnapshots).where(eq2(querySnapshots.runId, runId)).all().flatMap((row) => row.executionId ? [measurementSlotKey(row.executionId, row.provider)] : [])
7418
+ );
7419
+ const unitsByProvider = /* @__PURE__ */ new Map();
7420
+ for (const [provider, units] of plan.unitsByProvider) {
7421
+ if (requested.size > 0 && !requested.has(provider)) continue;
7422
+ const missing = units.filter((unit) => !recorded.has(measurementSlotKey(unit.executionId, provider)));
7423
+ if (missing.length > 0) unitsByProvider.set(provider, missing);
7424
+ }
7425
+ const activeProviders = [];
7426
+ for (const provider of unitsByProvider.keys()) {
7427
+ const registered = this.registry.get(provider);
7428
+ if (registered) activeProviders.push(registered);
7429
+ else providerErrors.set(provider, `No ${provider} provider is configured on this instance, so its missing answers did not run.`);
7430
+ }
7431
+ const projectCompetitors = this.db.select().from(competitors).where(eq2(competitors.projectId, projectId)).all();
7432
+ const runCompetitors = createRunCompetitorResolver(this.db, projectCompetitors.map((c) => c.domain))(run.measurementPlanVersionId);
7433
+ const competitorDomains = runCompetitors.domains;
7434
+ const allDomains = effectiveDomains({ canonicalDomain: project.canonicalDomain, ownedDomains: project.ownedDomains });
7435
+ const allBrandNames = effectiveBrandNames({ displayName: project.displayName, aliases: project.aliases });
7436
+ const todayPeriod = getCurrentUsageDay();
7437
+ const dispatchable = [];
7438
+ for (const registered of activeProviders) {
7439
+ const name = registered.adapter.name;
7440
+ const count = unitsByProvider.get(name)?.length ?? 0;
7441
+ const scope = `${projectId}:${name}`;
7442
+ const limit = registered.config.quotaPolicy.maxRequestsPerDay;
7443
+ const quota = reserveDailyQueryQuota(this.db, { scope, period: todayPeriod, count, limit });
7444
+ if (!quota.reserved) {
7445
+ providerErrors.set(name, `Daily quota exceeded for ${name}: ${quota.used} queries used today, limit is ${limit}. This fill needs ${count} more.`);
7446
+ continue;
7447
+ }
7448
+ providerReservations.set(name, { scope, period: todayPeriod, reserved: count });
7449
+ dispatchable.push(registered);
7450
+ }
7451
+ const executionGates = /* @__PURE__ */ new Map();
7452
+ for (const registered of dispatchable) {
7453
+ executionGates.set(
7454
+ registered.adapter.name,
7455
+ getSharedProviderExecutionGate(
7456
+ registered.adapter.name,
7457
+ registered.config.quotaPolicy.maxConcurrency,
7458
+ registered.config.quotaPolicy.maxRequestsPerMinute
7459
+ )
7460
+ );
7461
+ }
7462
+ const consecutiveFailures = /* @__PURE__ */ new Map();
7463
+ const stopped = /* @__PURE__ */ new Set();
7464
+ const newerSweepExists = () => {
7465
+ if (!superseded && newerFullSweep(this.db, { id: runId, projectId, createdAt: runCreatedAt })) {
7466
+ superseded = true;
7467
+ log2.warn("fill.superseded", { fillId, runId });
7468
+ }
7469
+ return superseded;
7470
+ };
7471
+ let removed = false;
7472
+ const attemptRemoved = () => {
7473
+ if (!removed && this.db.select({ status: runFills.status }).from(runFills).where(eq2(runFills.id, fillId)).get()?.status !== "running") {
7474
+ removed = true;
7475
+ log2.warn("fill.attempt-removed", { fillId, runId });
7476
+ }
7477
+ return removed;
7478
+ };
7479
+ const slotRecorded = (provider, executionId) => this.db.select({ id: querySnapshots.id }).from(querySnapshots).where(and2(
7480
+ eq2(querySnapshots.runId, runId),
7481
+ eq2(querySnapshots.measurementExecutionId, executionId),
7482
+ eq2(querySnapshots.provider, provider)
7483
+ )).get() !== void 0;
7484
+ const ctx = {
7485
+ runId,
7486
+ allDomains,
7487
+ competitorDomains,
7488
+ competitorAliases: runCompetitors.aliases,
7489
+ allBrandNames,
7490
+ executionGates,
7491
+ providerDispatchCounts,
7492
+ providerErrors,
7493
+ onInserted: () => {
7494
+ filled++;
7495
+ },
7496
+ fill: {
7497
+ shouldSkip: (provider, executionId) => stopped.has(provider) || attemptRemoved() || newerSweepExists() || slotRecorded(provider, executionId),
7498
+ onOutcome: (provider, ok) => {
7499
+ if (ok) {
7500
+ consecutiveFailures.set(provider, 0);
7501
+ return;
7502
+ }
7503
+ const failures = (consecutiveFailures.get(provider) ?? 0) + 1;
7504
+ consecutiveFailures.set(provider, failures);
7505
+ if (failures >= RUN_FILL_PROVIDER_BREAKER && !stopped.has(provider)) {
7506
+ stopped.add(provider);
7507
+ log2.warn("fill.provider-stopped", { fillId, runId, provider, failures });
7508
+ }
7509
+ }
7510
+ }
7511
+ };
7512
+ log2.info("fill.dispatch", {
7513
+ fillId,
7514
+ runId,
7515
+ missing: Object.fromEntries([...unitsByProvider].map(([provider, units]) => [provider, units.length]))
7516
+ });
7517
+ const unitsFor = (registered) => unitsByProvider.get(registered.adapter.name) ?? [];
7518
+ const apiProviders = dispatchable.filter((p) => !isBrowserProvider(p.adapter.name));
7519
+ const browserProviders = dispatchable.filter((p) => isBrowserProvider(p.adapter.name));
7520
+ await runWithConcurrency(apiProviders, resolveProviderFanout(), async (registered) => {
7521
+ await runWithConcurrency(
7522
+ unitsFor(registered),
7523
+ Math.max(1, registered.config.quotaPolicy.maxConcurrency),
7524
+ (unit) => this.executePlanSlot(ctx, registered, unit)
7525
+ );
7526
+ });
7527
+ for (const registered of browserProviders) {
7528
+ for (const unit of unitsFor(registered)) await this.executePlanSlot(ctx, registered, unit);
7529
+ }
7530
+ } catch (err) {
7531
+ fatal = describeError(err);
7532
+ log2.error("fill.failed", { fillId, runId, error: fatal });
7533
+ } finally {
7534
+ this.flushProviderUsage(providerDispatchCounts, providerReservations);
7535
+ }
7536
+ const outcome = this.finalizeRunFill({ fillId, runId, filled, fatal, providerErrors, superseded });
7537
+ log2.info("fill.finished", { fillId, runId, filled, ...outcome });
7538
+ if (outcome.justCompleted && !outcome.superseded && this.onRunCompleted) {
7539
+ this.onRunCompleted(runId, projectId, { origin: "fill" }).catch((err) => {
7540
+ log2.error("notification.callback-failed", { runId, error: describeError(err) });
7541
+ });
7542
+ }
7543
+ }
7544
+ /** Record the fill's outcome and, if the run is now whole, complete it exactly once. */
7545
+ finalizeRunFill(input) {
7546
+ const finishedAt = (/* @__PURE__ */ new Date()).toISOString();
7547
+ return this.db.transaction((tx) => {
7548
+ const txDb = tx;
7549
+ const parent = txDb.select({ projectId: runs.projectId, createdAt: runs.createdAt, error: runs.error }).from(runs).where(eq2(runs.id, input.runId)).get();
7550
+ const superseded = input.superseded || parent !== void 0 && newerFullSweep(txDb, { id: input.runId, projectId: parent.projectId, createdAt: parent.createdAt }) !== void 0;
7551
+ const state = measurementRunSlotState(txDb, input.runId);
7552
+ const complete3 = state.planned && state.readable && state.missing.length === 0 && !state.hasUnboundSnapshot;
7553
+ let justCompleted = false;
7554
+ if (complete3) {
7555
+ justCompleted = txDb.update(runs).set({ status: "completed", error: null }).where(and2(eq2(runs.id, input.runId), eq2(runs.status, "partial"))).run().changes === 1;
7556
+ } else if (state.readable && !input.fatal) {
7557
+ const previous = parseRunError(parent?.error)?.providers ?? {};
7558
+ const fresh = buildRunErrorFromMessages(input.providerErrors).providers ?? {};
7559
+ const remaining = /* @__PURE__ */ new Map();
7560
+ for (const slot of state.missing) remaining.set(slot.provider, (remaining.get(slot.provider) ?? 0) + 1);
7561
+ const providers = {};
7562
+ for (const [provider, count] of remaining) {
7563
+ providers[provider] = fresh[provider] ?? previous[provider] ?? { message: `${count} expected measurement(s) have not run.` };
7564
+ }
7565
+ txDb.update(runs).set({ error: serializeRunError({ providers }) }).where(and2(eq2(runs.id, input.runId), eq2(runs.status, "partial"))).run();
7566
+ }
7567
+ const status = complete3 ? "completed" : input.filled > 0 ? "partial" : "failed";
7568
+ const reason = input.fatal ?? (superseded ? "Stopped because a newer sweep started; no answers were added behind it." : null) ?? (input.providerErrors.size > 0 ? formatRunErrorOneLine(buildRunErrorFromMessages(input.providerErrors)) : null);
7569
+ txDb.update(runFills).set({ status, filled: input.filled, error: complete3 ? null : reason, finishedAt }).where(eq2(runFills.id, input.fillId)).run();
7570
+ return { justCompleted, superseded };
7571
+ });
7572
+ }
7573
+ /**
7574
+ * The plan-aware unit of work: one execution node, one provider.
7575
+ *
7576
+ * Deliberately a separate worker from the legacy query-by-query path rather
7577
+ * than a generalization of it. The legacy path is the behaviour every
7578
+ * planless project already depends on; leaving its body alone is what
7579
+ * makes "planless is byte-identical" a fact rather than a hope.
7580
+ *
7581
+ * A method rather than a closure inside `executeRun` so a sweep and a fill
7582
+ * record a slot through the same code: a filled answer is indistinguishable
7583
+ * from one the sweep recorded itself, which is what lets it count.
7584
+ */
7585
+ async executePlanSlot(ctx, registeredProvider, unit) {
7586
+ const { adapter, config: providerConfig } = registeredProvider;
7587
+ const providerName = adapter.name;
7588
+ const gate = ctx.executionGates.get(providerName);
7589
+ if (!gate) {
7590
+ throw new Error(`Missing execution gate for provider ${providerName}`);
7591
+ }
7592
+ const config = unit.requestedModel ? { ...providerConfig, model: unit.requestedModel } : providerConfig;
7593
+ const requestedContext = unit.context;
7594
+ const supportedContext = requestedContext && providerSupportsLocationContext(adapter) ? { status: "applied", resolved: requestedContext } : null;
7595
+ if (ctx.fill?.shouldSkip(providerName, unit.executionId)) return;
7596
+ try {
7597
+ await gate.run(async () => {
7598
+ this.throwIfRunCancelled(ctx.runId);
7599
+ if (ctx.fill?.shouldSkip(providerName, unit.executionId)) return;
7600
+ ctx.providerDispatchCounts.set(providerName, (ctx.providerDispatchCounts.get(providerName) ?? 0) + 1);
7601
+ const raw = await adapter.executeTrackedQuery(
7602
+ {
7603
+ query: unit.queryText,
7604
+ canonicalDomains: ctx.allDomains,
7605
+ competitorDomains: ctx.competitorDomains,
7606
+ location: requestedContext ?? void 0
7607
+ },
7608
+ config
7609
+ );
7610
+ this.throwIfRunCancelled(ctx.runId);
7611
+ const providerResult = adapter.normalizeResult(raw);
7612
+ const rawGroundingSources = providerResult.groundingSources;
7613
+ const normalized = {
7614
+ ...providerResult,
7615
+ groundingSources: Array.isArray(rawGroundingSources) ? rawGroundingSources : []
7616
+ };
7617
+ let citedUrlCapture;
7618
+ try {
7619
+ citedUrlCapture = await captureCitedUrls(providerName, rawGroundingSources);
7620
+ } catch (err) {
7621
+ citedUrlCapture = {
7622
+ citedUrls: [],
7623
+ captureStatus: "failed",
7624
+ sourceCount: normalized.groundingSources.length,
7625
+ resolvedCount: 0,
7626
+ captureVersion: CITED_URL_CAPTURE_VERSION
7627
+ };
7628
+ log2.warn("query.cited-url-capture-failed", {
7629
+ runId: ctx.runId,
7630
+ provider: providerName,
7631
+ query: unit.queryText,
7632
+ error: describeError(err)
7633
+ });
7634
+ }
7635
+ this.throwIfRunCancelled(ctx.runId);
7636
+ const citationState = determineCitationState(normalized, ctx.allDomains);
7637
+ const answerMentioned = determineAnswerMentioned(
7638
+ normalized.answerText,
7639
+ ctx.allBrandNames,
7640
+ ctx.allDomains
7641
+ );
7642
+ const overlap = computeCompetitorOverlap(normalized, ctx.competitorDomains, ctx.competitorAliases);
7643
+ const extractedCompetitors = extractRecommendedCompetitors(
7644
+ normalized.answerText,
7645
+ ctx.allDomains,
7646
+ normalized.citedDomains,
7647
+ ctx.competitorDomains,
7648
+ ctx.allBrandNames,
7649
+ ctx.competitorAliases
7650
+ );
7651
+ const snapshotId = crypto6.randomUUID();
7652
+ let screenshotRelPath = null;
7653
+ if (raw.screenshotPath && fs3.existsSync(raw.screenshotPath)) {
7654
+ const screenshotDir = path3.join(os3.homedir(), ".canonry", "screenshots", ctx.runId);
7655
+ if (!fs3.existsSync(screenshotDir)) fs3.mkdirSync(screenshotDir, { recursive: true });
7656
+ const destPath = path3.join(screenshotDir, `${snapshotId}.png`);
7657
+ fs3.renameSync(raw.screenshotPath, destPath);
7658
+ screenshotRelPath = `${ctx.runId}/${snapshotId}.png`;
7659
+ }
7660
+ const insert = this.db.insert(querySnapshots).values({
7661
+ id: snapshotId,
7662
+ runId: ctx.runId,
7663
+ queryId: unit.queryId,
7664
+ queryText: unit.queryText,
7665
+ provider: providerName,
7666
+ // `model` is what was REQUESTED and `served_model` is what
7667
+ // answered. The manifest froze the request, so it is the
7668
+ // authority here: an adapter that reports its own default rather
7669
+ // than what it was handed would otherwise overwrite the identity
7670
+ // the revision recorded, and nothing would show that it moved.
7671
+ model: unit.requestedModel ?? raw.model,
7672
+ servedModel: raw.servedModel ?? null,
7673
+ citationState,
7674
+ answerMentioned,
7675
+ answerText: normalized.answerText,
7676
+ citedDomains: normalized.citedDomains,
7677
+ citedUrls: citedUrlCapture.citedUrls,
7678
+ captureStatus: citedUrlCapture.captureStatus,
7679
+ sourceCount: citedUrlCapture.sourceCount,
7680
+ resolvedCount: citedUrlCapture.resolvedCount,
7681
+ captureVersion: citedUrlCapture.captureVersion,
7682
+ retrievalStatus: normalized.retrievalStatus,
7683
+ retrievalContract: raw.retrievalContract,
7684
+ competitorOverlap: overlap,
7685
+ recommendedCompetitors: extractedCompetitors,
7686
+ // Only claim the geography the provider actually honoured. A
7687
+ // requested-but-unsupported context stores `location: null` —
7688
+ // "no claim" — rather than the label we asked for, mirroring
7689
+ // `supportedContext` itself: this field is never non-null when
7690
+ // that one is null.
7691
+ location: supportedContext ? requestedContext?.label ?? null : null,
7692
+ measurementExecutionId: unit.executionId,
7693
+ requestedContext,
7694
+ supportedContext,
7695
+ screenshotPath: screenshotRelPath,
7696
+ rawResponse: JSON.stringify({
7697
+ model: raw.model,
7698
+ servedModel: raw.servedModel ?? null,
7699
+ groundingSources: normalized.groundingSources,
7700
+ searchQueries: normalized.searchQueries,
7701
+ apiResponse: raw.rawResponse
7702
+ }),
7703
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
7704
+ });
7705
+ const written = ctx.fill ? insert.onConflictDoNothing().run() : insert.run();
7706
+ if (written.changes > 0) ctx.onInserted();
7707
+ ctx.fill?.onOutcome(providerName, true);
7708
+ log2.info("query.citation", {
7709
+ runId: ctx.runId,
7710
+ provider: providerName,
7711
+ query: unit.queryText,
7712
+ executionId: unit.executionId,
7713
+ location: requestedContext?.label ?? null,
7714
+ citationState,
7715
+ answerMentioned
7716
+ });
7717
+ });
7718
+ } catch (err) {
7719
+ if (err instanceof RunCancelledError) {
7720
+ throw err;
7721
+ }
7722
+ const msg = describeError(err);
7723
+ const stack = err instanceof Error ? err.stack : void 0;
7724
+ log2.error("query.failed", { runId: ctx.runId, provider: providerName, query: unit.queryText, executionId: unit.executionId, error: msg, stack });
7725
+ if (!ctx.providerErrors.has(providerName)) {
7726
+ ctx.providerErrors.set(providerName, msg);
7727
+ }
7728
+ ctx.fill?.onOutcome(providerName, false);
7729
+ }
7730
+ }
7486
7731
  incrementUsage(scope, metric, count) {
7487
7732
  const now = (/* @__PURE__ */ new Date()).toISOString();
7488
7733
  const period = now.slice(0, 10);
@@ -11329,10 +11574,11 @@ async function backfillAnswerVisibilityCommand(opts) {
11329
11574
  let reparsed = 0;
11330
11575
  let providerErrors = 0;
11331
11576
  if (scopedProjects.length > 0) {
11332
- const runRows = projectFilter ? db.select({ id: runs.id, projectId: runs.projectId }).from(runs).where(and13(
11577
+ const runRows = projectFilter ? db.select({ id: runs.id, projectId: runs.projectId, planVersionId: runs.measurementPlanVersionId }).from(runs).where(and13(
11333
11578
  eq15(runs.kind, RunKinds["answer-visibility"]),
11334
11579
  inArray6(runs.projectId, scopedProjects.map((project) => project.id))
11335
- )).all() : db.select({ id: runs.id, projectId: runs.projectId }).from(runs).where(eq15(runs.kind, RunKinds["answer-visibility"])).all();
11580
+ )).all() : db.select({ id: runs.id, projectId: runs.projectId, planVersionId: runs.measurementPlanVersionId }).from(runs).where(eq15(runs.kind, RunKinds["answer-visibility"])).all();
11581
+ const planVersionByRun = new Map(runRows.map((run) => [run.id, run.planVersionId]));
11336
11582
  const runIdsByProject = /* @__PURE__ */ new Map();
11337
11583
  for (const run of runRows) {
11338
11584
  const existing = runIdsByProject.get(run.projectId);
@@ -11341,6 +11587,7 @@ async function backfillAnswerVisibilityCommand(opts) {
11341
11587
  }
11342
11588
  for (const project of scopedProjects) {
11343
11589
  const competitorDomains = db.select({ domain: competitors.domain }).from(competitors).where(eq15(competitors.projectId, project.id)).all().map((row) => row.domain);
11590
+ const competitorsForRun = createRunCompetitorResolver(db, competitorDomains);
11344
11591
  const runIds = runIdsByProject.get(project.id) ?? [];
11345
11592
  if (runIds.length === 0) continue;
11346
11593
  const projectDomains = effectiveDomains({
@@ -11355,6 +11602,7 @@ async function backfillAnswerVisibilityCommand(opts) {
11355
11602
  const batchRunIds = runIds.slice(offset, offset + SNAPSHOT_BATCH_SIZE);
11356
11603
  const snapshotRows = db.select({
11357
11604
  id: querySnapshots.id,
11605
+ runId: querySnapshots.runId,
11358
11606
  provider: querySnapshots.provider,
11359
11607
  citationState: querySnapshots.citationState,
11360
11608
  answerMentioned: querySnapshots.answerMentioned,
@@ -11395,13 +11643,15 @@ async function backfillAnswerVisibilityCommand(opts) {
11395
11643
  };
11396
11644
  const nextCitationState = determineCitationState(normalized, projectDomains);
11397
11645
  const nextCitedDomains = reparsedResult.citedDomains;
11398
- const nextCompetitorOverlap = computeCompetitorOverlap(normalized, competitorDomains);
11646
+ const runCompetitors = competitorsForRun(planVersionByRun.get(snapshot.runId));
11647
+ const nextCompetitorOverlap = computeCompetitorOverlap(normalized, runCompetitors.domains, runCompetitors.aliases);
11399
11648
  const nextRecommendedCompetitors = extractRecommendedCompetitors(
11400
11649
  normalized.answerText,
11401
11650
  projectDomains,
11402
11651
  normalized.citedDomains,
11403
- competitorDomains,
11404
- projectBrandNames
11652
+ runCompetitors.domains,
11653
+ projectBrandNames,
11654
+ runCompetitors.aliases
11405
11655
  );
11406
11656
  const nextRawResponse = stringifyStoredSnapshotEnvelope(
11407
11657
  snapshot.rawResponse,
@@ -11628,8 +11878,10 @@ function backfillProjectAnswerMentions(db, projectId, opts) {
11628
11878
  const project = db.select().from(projects).where(eq15(projects.id, projectId)).get();
11629
11879
  if (!project) return { examined: 0, updated: 0, mentioned: 0 };
11630
11880
  const competitorDomains = db.select({ domain: competitors.domain }).from(competitors).where(eq15(competitors.projectId, projectId)).all().map((row) => row.domain);
11631
- const runRows = db.select({ id: runs.id }).from(runs).where(and13(eq15(runs.kind, RunKinds["answer-visibility"]), eq15(runs.projectId, projectId))).all();
11881
+ const runRows = db.select({ id: runs.id, planVersionId: runs.measurementPlanVersionId }).from(runs).where(and13(eq15(runs.kind, RunKinds["answer-visibility"]), eq15(runs.projectId, projectId))).all();
11632
11882
  const runIds = runRows.map((r) => r.id);
11883
+ const planVersionByRun = new Map(runRows.map((run) => [run.id, run.planVersionId]));
11884
+ const competitorsForRun = createRunCompetitorResolver(db, competitorDomains);
11633
11885
  let examined = 0;
11634
11886
  let updated = 0;
11635
11887
  let wouldUpdate = 0;
@@ -11649,6 +11901,7 @@ function backfillProjectAnswerMentions(db, projectId, opts) {
11649
11901
  const batchRunIds = runIds.slice(offset, offset + SNAPSHOT_BATCH_SIZE);
11650
11902
  const snapshotRows = db.select({
11651
11903
  id: querySnapshots.id,
11904
+ runId: querySnapshots.runId,
11652
11905
  provider: querySnapshots.provider,
11653
11906
  answerMentioned: querySnapshots.answerMentioned,
11654
11907
  answerText: querySnapshots.answerText,
@@ -11674,13 +11927,15 @@ function backfillProjectAnswerMentions(db, projectId, opts) {
11674
11927
  retrievalStatus: "unknown",
11675
11928
  searchQueries: []
11676
11929
  };
11677
- const nextCompetitorOverlap = computeCompetitorOverlap(normalized, competitorDomains);
11930
+ const runCompetitors = competitorsForRun(planVersionByRun.get(snapshot.runId));
11931
+ const nextCompetitorOverlap = computeCompetitorOverlap(normalized, runCompetitors.domains, runCompetitors.aliases);
11678
11932
  const nextRecommendedCompetitors = extractRecommendedCompetitors(
11679
11933
  answerText,
11680
11934
  projectDomains,
11681
11935
  citedDomains,
11682
- competitorDomains,
11683
- projectBrandNames
11936
+ runCompetitors.domains,
11937
+ projectBrandNames,
11938
+ runCompetitors.aliases
11684
11939
  );
11685
11940
  const nextPatch = {};
11686
11941
  if (snapshot.answerMentioned !== nextAnswerMentioned) {
@@ -11777,7 +12032,7 @@ function readStoredGroundingSources(rawResponse) {
11777
12032
  return result2;
11778
12033
  }
11779
12034
  async function backfillInsightsCommand(project, opts) {
11780
- const { IntelligenceService: IntelligenceService2 } = await import("./intelligence-service-K4U55A2J.js");
12035
+ const { IntelligenceService: IntelligenceService2 } = await import("./intelligence-service-CJKOXPOP.js");
11781
12036
  const config = loadConfig();
11782
12037
  const db = createClient(config.database);
11783
12038
  migrate(db);
@@ -13024,8 +13279,8 @@ var Notifier = class {
13024
13279
  this.serverUrl = serverUrl;
13025
13280
  }
13026
13281
  /** Called after a run completes (success, partial, or failed). */
13027
- async onRunCompleted(runId, projectId) {
13028
- log17.info("run.completed", { runId, projectId });
13282
+ async onRunCompleted(runId, projectId, opts) {
13283
+ log17.info("run.completed", { runId, projectId, origin: opts?.origin ?? "sweep" });
13029
13284
  const notifs = this.db.select().from(notifications).where(eq17(notifications.projectId, projectId)).all().filter((n) => n.enabled);
13030
13285
  if (notifs.length === 0) {
13031
13286
  log17.info("notifications.none-enabled", { projectId });
@@ -13045,7 +13300,7 @@ var Notifier = class {
13045
13300
  const transitions = this.computeTransitions(runId, projectId);
13046
13301
  const events = [];
13047
13302
  log17.info("run.status", { runId: run.id, status: run.status, projectId });
13048
- if (run.status === "completed" || run.status === "partial") {
13303
+ if ((run.status === "completed" || run.status === "partial") && opts?.origin !== "fill") {
13049
13304
  events.push("run.completed");
13050
13305
  }
13051
13306
  if (run.status === "failed") {
@@ -13496,7 +13751,7 @@ var RunCoordinator = class {
13496
13751
  intelligenceService;
13497
13752
  onInsightsGenerated;
13498
13753
  onAeroEvent;
13499
- async onRunCompleted(runId, projectId) {
13754
+ async onRunCompleted(runId, projectId, opts) {
13500
13755
  const runRow = this.db.select().from(runs).where(eq18(runs.id, runId)).get();
13501
13756
  const kind = runRow?.kind ?? RunKinds["answer-visibility"];
13502
13757
  if (runRow?.trigger === RunTriggers.probe) {
@@ -13558,7 +13813,7 @@ var RunCoordinator = class {
13558
13813
  }
13559
13814
  }
13560
13815
  try {
13561
- await this.notifier.onRunCompleted(runId, projectId);
13816
+ await (opts ? this.notifier.onRunCompleted(runId, projectId, opts) : this.notifier.onRunCompleted(runId, projectId));
13562
13817
  } catch (err) {
13563
13818
  log18.error("notifier.failed", { runId, error: describeError(err) });
13564
13819
  }
@@ -17990,7 +18245,7 @@ async function createServer(opts) {
17990
18245
  void sessionRegistry.drainNow(project.name);
17991
18246
  }
17992
18247
  );
17993
- jobRunner.onRunCompleted = (runId, projectId) => runCoordinator.onRunCompleted(runId, projectId);
18248
+ jobRunner.onRunCompleted = (runId, projectId, opts2) => opts2 ? runCoordinator.onRunCompleted(runId, projectId, opts2) : runCoordinator.onRunCompleted(runId, projectId);
17994
18249
  const snapshotService = new SnapshotService(registry);
17995
18250
  const liveProjectIds = new Set(
17996
18251
  opts.db.select({ id: projects.id }).from(projects).all().map((project) => project.id)
@@ -19347,6 +19602,14 @@ async function createServer(opts) {
19347
19602
  app.log.error({ runId, err }, "Job runner failed");
19348
19603
  });
19349
19604
  },
19605
+ getProviderDailyLimits: () => Object.fromEntries(
19606
+ registry.getAll().map((provider) => [provider.adapter.name, provider.config.quotaPolicy.maxRequestsPerDay])
19607
+ ),
19608
+ onRunFillCreated: (fillId, runId) => {
19609
+ jobRunner.executeRunFill(fillId).catch((err) => {
19610
+ app.log.error({ fillId, runId, err: describeError(err) }, "Run fill failed");
19611
+ });
19612
+ },
19350
19613
  onRunCancelled: (runId) => {
19351
19614
  const controller = siteAuditAbortControllers.get(runId);
19352
19615
  if (controller && !controller.signal.aborted) {