@analyticscli/growth-engineer 0.1.0-preview.14 → 0.1.0-preview.18

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 (40) hide show
  1. package/dist/config.d.ts +775 -22
  2. package/dist/config.js +39 -5
  3. package/dist/config.js.map +1 -1
  4. package/dist/index.js +134 -21
  5. package/dist/index.js.map +1 -1
  6. package/dist/runtime/export-asc-summary.mjs +1 -1
  7. package/dist/runtime/export-asc-summary.mjs.map +1 -1
  8. package/dist/runtime/export-coolify-summary.d.mts +2 -0
  9. package/dist/runtime/export-coolify-summary.mjs +230 -0
  10. package/dist/runtime/export-coolify-summary.mjs.map +1 -0
  11. package/dist/runtime/export-paddle-summary.d.mts +2 -0
  12. package/dist/runtime/export-paddle-summary.mjs +170 -0
  13. package/dist/runtime/export-paddle-summary.mjs.map +1 -0
  14. package/dist/runtime/export-sentry-summary.mjs +265 -38
  15. package/dist/runtime/export-sentry-summary.mjs.map +1 -1
  16. package/dist/runtime/export-seo-summary.d.mts +2 -0
  17. package/dist/runtime/export-seo-summary.mjs +503 -0
  18. package/dist/runtime/export-seo-summary.mjs.map +1 -0
  19. package/dist/runtime/openclaw-exporters-lib.d.mts +50 -0
  20. package/dist/runtime/openclaw-exporters-lib.mjs +761 -57
  21. package/dist/runtime/openclaw-exporters-lib.mjs.map +1 -1
  22. package/dist/runtime/openclaw-growth-engineer.mjs +163 -4
  23. package/dist/runtime/openclaw-growth-engineer.mjs.map +1 -1
  24. package/dist/runtime/openclaw-growth-env.mjs +5 -0
  25. package/dist/runtime/openclaw-growth-env.mjs.map +1 -1
  26. package/dist/runtime/openclaw-growth-preflight.mjs +399 -26
  27. package/dist/runtime/openclaw-growth-preflight.mjs.map +1 -1
  28. package/dist/runtime/openclaw-growth-runner.mjs +564 -69
  29. package/dist/runtime/openclaw-growth-runner.mjs.map +1 -1
  30. package/dist/runtime/openclaw-growth-shared.d.mts +150 -2
  31. package/dist/runtime/openclaw-growth-shared.mjs +489 -7
  32. package/dist/runtime/openclaw-growth-shared.mjs.map +1 -1
  33. package/dist/runtime/openclaw-growth-start.mjs +584 -48
  34. package/dist/runtime/openclaw-growth-start.mjs.map +1 -1
  35. package/dist/runtime/openclaw-growth-status.mjs +82 -6
  36. package/dist/runtime/openclaw-growth-status.mjs.map +1 -1
  37. package/dist/runtime/openclaw-growth-wizard.mjs +1501 -105
  38. package/dist/runtime/openclaw-growth-wizard.mjs.map +1 -1
  39. package/package.json +3 -1
  40. package/templates/config.example.json +120 -71
@@ -5,7 +5,7 @@ import process from 'node:process';
5
5
  import { createHash } from 'node:crypto';
6
6
  import { spawn } from 'node:child_process';
7
7
  import { fileURLToPath } from 'node:url';
8
- import { deriveRuntimeDirFromStatePath, deriveSchedulerProofPathFromStatePath, getActionMode, getAllSourceEntries, getGitHubArtifactModes, getGitHubRequirementText, shouldAutoCreateGitHubArtifact, } from './openclaw-growth-shared.mjs';
8
+ import { deriveRuntimeDirFromStatePath, deriveSchedulerProofPathFromStatePath, getActionMode, getAllSourceEntries, getGitHubArtifactModes, getGitHubRequirementText, repairOpenClawCronDeliveryStore, shouldAutoCreateGitHubArtifact, } from './openclaw-growth-shared.mjs';
9
9
  import { applyOpenClawSecretRefs, loadOpenClawGrowthSecrets } from './openclaw-growth-env.mjs';
10
10
  const DEFAULT_CONFIG_PATH = 'data/openclaw-growth-engineer/config.json';
11
11
  const DEFAULT_STATE_PATH = 'data/openclaw-growth-engineer/state.json';
@@ -15,55 +15,65 @@ const SELF_UPDATE_INTERVAL_MS = 24 * 60 * 60 * 1000;
15
15
  const RUNTIME_DIR = path.dirname(fileURLToPath(import.meta.url));
16
16
  let schedulerProofPath = path.resolve(DEFAULT_SCHEDULER_PROOF_PATH);
17
17
  const DEFAULT_CADENCES = [
18
+ {
19
+ key: 'healthcheck',
20
+ title: '90-minute production error healthcheck',
21
+ intervalMinutes: 90,
22
+ criticalOnly: true,
23
+ focusAreas: ['crash', 'deployment', 'availability'],
24
+ sourcePriorities: ['sentry', 'glitchtip', 'coolify', 'asc_cli'],
25
+ objective: 'Check Sentry/GlitchTip and Coolify for production errors, failed deploys, unhealthy resources, and availability blockers across every configured app.',
26
+ instructions: 'For Sentry/GlitchTip app errors, compare the issue release or app version with ASC production versions first. Ignore errors that only affect TestFlight, debug, staging, unreleased, or non-production app versions. Keep the social output short and action-oriented.',
27
+ },
18
28
  {
19
29
  key: 'daily',
20
- title: 'Daily Sentry and production guardrail',
30
+ title: 'Daily behavioral anomaly guardrail',
21
31
  intervalDays: 1,
22
32
  criticalOnly: true,
23
- focusAreas: ['sentry_errors', 'crash', 'onboarding', 'conversion', 'paywall', 'purchase'],
24
- sourcePriorities: ['sentry', 'glitchtip', 'analytics', 'revenuecat', 'asc_cli', 'feedback', 'github'],
25
- objective: 'Analyze every configured project for critical production blockers: Sentry/GlitchTip errors, crashes, onboarding or purchase drop-offs, zero-conversion days, missing buyers, very low users, and other silent business anomalies.',
26
- instructions: 'Compare against recent baselines across Sentry/GlitchTip, AnalyticsCLI, RevenueCat, ASC, feedback, release metadata, memory/state, and recent code changes. If the finding is critical, produce the exact fix or next debugging step and prefer a GitHub issue or draft PR when GitHub write access is configured; otherwise hand off via OpenClaw chat. Do not invent generic growth ideas.',
33
+ focusAreas: ['analytics_anomaly', 'onboarding', 'conversion', 'paywall', 'purchase', 'retention', 'revenue'],
34
+ sourcePriorities: ['analytics', 'revenuecat', 'paddle', 'asc_cli', 'feedback', 'github', 'sentry', 'glitchtip', 'coolify'],
35
+ objective: 'Detect non-Sentry product and payment anomalies that affect real users: broken login or account flows inferred from behavior, onboarding or purchase drop-offs, zero-conversion days, missing buyers, very low active users, retention cliffs, and revenue anomalies.',
36
+ instructions: 'Compare AnalyticsCLI, RevenueCat, Paddle, ASC, feedback, memory/state, and recent code changes against recent baselines. Use Sentry/GlitchTip/Coolify only as corroborating context; do not repeat pure crash or deployment alerts that belong to the 90-minute healthcheck.',
27
37
  },
28
38
  {
29
39
  key: 'weekly',
30
40
  title: 'Weekly executive product and growth summary',
31
41
  intervalDays: 7,
32
42
  criticalOnly: false,
33
- focusAreas: ['conversion', 'paywall', 'onboarding', 'marketing', 'retention', 'stability'],
34
- sourcePriorities: ['analytics', 'revenuecat', 'asc_cli', 'feedback', 'sentry', 'github'],
35
- objective: 'Create an executive summary across all configured projects, connectors, recent releases, code changes, revenue, activation, retention, reviews, and production stability.',
36
- instructions: 'Choose one to three high-confidence improvements with evidence, expected KPI movement, likely code/store surfaces, owner-ready next steps, and verification plan. Create GitHub issues or draft PR proposals only when the evidence is specific enough. Kill or adjust experiments without signal.',
43
+ focusAreas: ['conversion', 'paywall', 'onboarding', 'marketing', 'retention', 'stability', 'seo'],
44
+ sourcePriorities: ['analytics', 'revenuecat', 'paddle', 'seo', 'asc_cli', 'feedback', 'sentry', 'coolify', 'github'],
45
+ objective: 'Create a deep app-by-app executive summary across all configured projects, connectors, recent releases, code changes, traffic, SEO/acquisition, revenue, activation, conversion, retention, reviews, and production stability.',
46
+ instructions: 'Be detailed. Group findings per app, explain why each recommendation should improve app usage, revenue, conversion, retention, or traffic, include expected KPI movement, likely code/store surfaces, owner-ready next steps, and verification plans. Generate charts when they clarify the evidence.',
37
47
  },
38
48
  {
39
49
  key: 'monthly',
40
50
  title: 'Monthly deep product, business, and code review',
41
51
  intervalDays: 30,
42
52
  criticalOnly: false,
43
- focusAreas: ['conversion', 'paywall', 'retention', 'marketing', 'onboarding', 'codebase'],
44
- sourcePriorities: ['analytics', 'revenuecat', 'asc_cli', 'feedback', 'sentry', 'github'],
45
- objective: 'Compare all configured projects month-over-month: MRR, trial conversion, churn, acquisition channel quality, store/listing conversion, retention, review themes, feature usage, crash totals, and codebase changes.',
46
- instructions: 'Decide what should be built, changed, deleted, or instrumented next. Tie conclusions to connector data plus codebase evidence and explain why each recommendation should move revenue, activation, retention, stability, or acquisition quality.',
53
+ focusAreas: ['conversion', 'paywall', 'retention', 'marketing', 'onboarding', 'codebase', 'seo'],
54
+ sourcePriorities: ['analytics', 'revenuecat', 'paddle', 'seo', 'asc_cli', 'feedback', 'sentry', 'coolify', 'github'],
55
+ objective: 'Compare all configured projects month-over-month: MRR, trial conversion, churn, Paddle revenue/subscriber movement, SEO demand/clicks, acquisition channel quality, store/listing conversion, retention, review themes, feature usage, crash totals, and codebase changes.',
56
+ instructions: 'Be very detailed and app-grouped. Decide what should be built, changed, deleted, priced differently, marketed differently, or instrumented next. Tie conclusions to connector data plus codebase evidence and explain why each recommendation should move revenue, conversion, retention, traffic, or acquisition quality. Generate charts when useful.',
47
57
  },
48
58
  {
49
59
  key: 'quarterly',
50
- title: 'Quarterly positioning, pricing, and roadmap review',
60
+ title: '3-month positioning, pricing, and roadmap review',
51
61
  intervalDays: 91,
52
62
  criticalOnly: false,
53
63
  focusAreas: ['marketing', 'paywall', 'retention', 'conversion', 'onboarding'],
54
- sourcePriorities: ['analytics', 'revenuecat', 'asc_cli', 'feedback', 'github', 'sentry'],
55
- objective: 'Revisit positioning, pricing/packaging, onboarding architecture, roadmap assumptions, tracking quality, codebase constraints, and major funnel bets across every configured project.',
56
- instructions: 'Find structural constraints and durable opportunities, not small UI tweaks. Tie recommendations to cohort behavior, monetization, reviews, channel quality, and shipped changes.',
64
+ sourcePriorities: ['analytics', 'revenuecat', 'paddle', 'seo', 'asc_cli', 'feedback', 'github', 'sentry'],
65
+ objective: 'Revisit positioning, pricing/packaging, onboarding architecture, roadmap assumptions, tracking quality, codebase constraints, and major funnel bets across every configured app.',
66
+ instructions: 'Find structural constraints and durable opportunities, not small UI tweaks. Group the analysis by app and tie recommendations to cohort behavior, monetization, SEO demand, reviews, channel quality, and shipped changes. Include concrete roadmap, pricing, conversion, and traffic recommendations.',
57
67
  },
58
68
  {
59
69
  key: 'six_months',
60
70
  title: 'Six-month instrumentation and growth-system audit',
61
71
  intervalDays: 182,
62
72
  criticalOnly: false,
63
- focusAreas: ['retention', 'conversion', 'paywall', 'marketing', 'general'],
64
- sourcePriorities: ['analytics', 'revenuecat', 'asc_cli', 'feedback', 'sentry'],
73
+ focusAreas: ['retention', 'conversion', 'paywall', 'marketing', 'general', 'seo'],
74
+ sourcePriorities: ['analytics', 'revenuecat', 'paddle', 'seo', 'asc_cli', 'feedback', 'sentry'],
65
75
  objective: 'Audit connector coverage, SDK instrumentation, event taxonomy, data reliability, data memory, growth loops, and whether product/code strategy still matches the best users across configured projects.',
66
- instructions: 'Prioritize measurement fixes and system changes that make future analysis more trustworthy. Identify stale events, missing attribution, weak identity, broken feedback loops, and misleading dashboards.',
76
+ instructions: 'Group by app. Prioritize measurement fixes and system changes that make future analysis more trustworthy, then identify the highest-leverage app/revenue/conversion/SEO/traffic improvements. Identify stale events, missing attribution, weak identity, broken feedback loops, and misleading dashboards.',
67
77
  },
68
78
  {
69
79
  key: 'yearly',
@@ -71,7 +81,7 @@ const DEFAULT_CADENCES = [
71
81
  intervalDays: 365,
72
82
  criticalOnly: false,
73
83
  focusAreas: ['marketing', 'retention', 'paywall', 'conversion', 'general'],
74
- sourcePriorities: ['analytics', 'revenuecat', 'asc_cli', 'feedback', 'sentry'],
84
+ sourcePriorities: ['analytics', 'revenuecat', 'paddle', 'seo', 'asc_cli', 'feedback', 'sentry'],
75
85
  objective: 'Reset strategy from evidence across every configured project: market/channel fit, monetization model, retention ceiling, product scope, and whether to double down, reposition, rebuild, or sunset major surfaces/features.',
76
86
  instructions: 'Use the full year of memory, releases, revenue, acquisition, reviews, code changes, and cohort behavior. Produce a strategic operating plan with specific experiments and stop-doing decisions.',
77
87
  },
@@ -145,19 +155,28 @@ function replaceLegacyRuntimeScriptCommand(command) {
145
155
  const trimmed = String(command || '').trim();
146
156
  if (!trimmed)
147
157
  return trimmed;
148
- return trimmed.replace(/^node\s+scripts\/(export-analytics-summary\.mjs|export-revenuecat-summary\.mjs|export-sentry-summary\.mjs|export-asc-summary\.mjs|openclaw-growth-engineer\.mjs|openclaw-growth-status\.mjs|openclaw-growth-preflight\.mjs|openclaw-growth-runner\.mjs)(?=\s|$)/, (_match, scriptName) => nodeRuntimeScriptCommand(scriptName));
158
+ return trimmed.replace(/^node\s+scripts\/(export-analytics-summary\.mjs|export-revenuecat-summary\.mjs|export-paddle-summary\.mjs|export-seo-summary\.mjs|export-sentry-summary\.mjs|export-coolify-summary\.mjs|export-asc-summary\.mjs|openclaw-growth-engineer\.mjs|openclaw-growth-status\.mjs|openclaw-growth-preflight\.mjs|openclaw-growth-runner\.mjs)(?=\s|$)/, (_match, scriptName) => nodeRuntimeScriptCommand(scriptName));
149
159
  }
150
160
  function commandHasConfigArg(command) {
151
161
  return /(?:^|\s)--config(?:=|\s|$)/.test(String(command || ''));
152
162
  }
153
- function commandShouldReceiveActiveConfig(command) {
154
- return /(?:^|\s)(?:node\s+)?(?:\S*\/)?(?:export-analytics-summary|export-revenuecat-summary|export-sentry-summary|export-asc-summary)\.mjs(?:\s|$)/.test(String(command || ''));
163
+ function commandIsBuiltinExporter(command) {
164
+ return /(?:^|\s)(?:node\s+)?(?:\S*\/)?(?:export-analytics-summary|export-revenuecat-summary|export-paddle-summary|export-seo-summary|export-sentry-summary|export-coolify-summary|export-asc-summary)\.mjs(?:\s|$)/.test(String(command || ''));
165
+ }
166
+ function commandSupportsActiveConfig(command) {
167
+ return /(?:^|\s)(?:node\s+)?(?:\S*\/)?(?:export-sentry-summary|export-coolify-summary)\.mjs(?:\s|$)/.test(String(command || ''));
155
168
  }
156
169
  function withActiveConfigArg(command, configPath) {
157
170
  const trimmed = String(command || '').trim();
158
- if (!trimmed || !configPath || !commandShouldReceiveActiveConfig(trimmed)) {
171
+ if (!trimmed || !configPath || !commandIsBuiltinExporter(trimmed)) {
159
172
  return trimmed;
160
173
  }
174
+ if (!commandSupportsActiveConfig(trimmed)) {
175
+ return trimmed
176
+ .replace(/(^|\s)--config=(?:"[^"]*"|'[^']*'|\S+)/, '$1')
177
+ .replace(/(^|\s)--config\s+(?:"[^"]*"|'[^']*'|\S+)/, '$1')
178
+ .trim();
179
+ }
161
180
  if (commandHasConfigArg(trimmed)) {
162
181
  return trimmed
163
182
  .replace(/(^|\s)--config=(?:"[^"]*"|'[^']*'|\S+)/, `$1--config ${quote(configPath)}`)
@@ -205,6 +224,33 @@ function stableStringify(value) {
205
224
  function sleep(ms) {
206
225
  return new Promise((resolve) => setTimeout(resolve, ms));
207
226
  }
227
+ function isTransientNetworkFailure(value) {
228
+ return /NETWORK_ERROR|fetch failed|tlsv1 alert|SSL routines|ECONNRESET|ECONNREFUSED|ETIMEDOUT|ENOTFOUND|EAI_AGAIN|socket hang up|network timeout|Temporary failure|upstream connect error|disconnect\/reset before headers|HTTP 5\d\d|API 5\d\d/i.test(String(value || ''));
229
+ }
230
+ function isRequiredSource(sourceConfig, sourceName) {
231
+ if (sourceConfig?.required === true)
232
+ return true;
233
+ if (sourceConfig?.required === false)
234
+ return false;
235
+ return String(sourceName || '').toLowerCase() === 'analytics';
236
+ }
237
+ function isSentryCompatibleSource(sourceConfig, sourceName) {
238
+ const sourceKey = String(sourceName || '').toLowerCase();
239
+ const service = String(sourceConfig?.service || sourceConfig?.provider || '').toLowerCase();
240
+ const command = String(sourceConfig?.command || '').toLowerCase();
241
+ return (sourceKey === 'sentry' ||
242
+ sourceKey === 'glitchtip' ||
243
+ service === 'sentry' ||
244
+ service === 'glitchtip' ||
245
+ command.includes('export-sentry-summary'));
246
+ }
247
+ function shouldDegradeTransientSourceFailure(sourceConfig, sourceName, retried) {
248
+ if (!retried)
249
+ return false;
250
+ if (!isRequiredSource(sourceConfig, sourceName))
251
+ return true;
252
+ return isSentryCompatibleSource(sourceConfig, sourceName);
253
+ }
208
254
  function isTruthyEnv(value) {
209
255
  return ['1', 'true', 'yes', 'y', 'on'].includes(String(value || '').trim().toLowerCase());
210
256
  }
@@ -350,11 +396,24 @@ function resolveShellCommand() {
350
396
  }
351
397
  return 'sh';
352
398
  }
399
+ function hardenUnattendedShellCommand(command) {
400
+ return String(command || '').replace(/(^|[;&|]\s*)sudo(?!\s+-n(?:\s|$))(?=\s|$)/g, '$1sudo -n');
401
+ }
402
+ function isSudoPasswordPrompt(stderr) {
403
+ return /sudo: (?:a password is required|a terminal is required to read the password|no tty present)/i.test(String(stderr || ''));
404
+ }
353
405
  function runShellCommand(command, timeoutMs = 120_000, options = {}) {
354
406
  return new Promise((resolve) => {
355
- const child = spawn(resolveShellCommand(), ['-c', command], {
407
+ const hardenedCommand = hardenUnattendedShellCommand(command);
408
+ const child = spawn(resolveShellCommand(), ['-c', hardenedCommand], {
356
409
  stdio: options.input === undefined ? ['ignore', 'pipe', 'pipe'] : ['pipe', 'pipe', 'pipe'],
357
410
  cwd: options.cwd,
411
+ env: {
412
+ ...process.env,
413
+ DEBIAN_FRONTEND: 'noninteractive',
414
+ SUDO_ASKPASS: '/bin/false',
415
+ SUDO_PROMPT: '',
416
+ },
358
417
  });
359
418
  let stdout = '';
360
419
  let stderr = '';
@@ -371,6 +430,17 @@ function runShellCommand(command, timeoutMs = 120_000, options = {}) {
371
430
  });
372
431
  child.stderr.on('data', (chunk) => {
373
432
  stderr += String(chunk);
433
+ if (!settled && isSudoPasswordPrompt(stderr)) {
434
+ settled = true;
435
+ clearTimeout(timer);
436
+ child.kill('SIGTERM');
437
+ resolve({
438
+ ok: false,
439
+ code: null,
440
+ stdout,
441
+ stderr: `${stderr.trim()}\nBlocked non-interactive sudo prompt. Configure passwordless sudo for this exact command or remove sudo from the Growth Engineer connector command.`,
442
+ });
443
+ }
374
444
  });
375
445
  if (options.input !== undefined) {
376
446
  child.stdin.end(options.input);
@@ -489,6 +559,15 @@ function getCadenceDefinitions(config) {
489
559
  }
490
560
  function cadenceIsDue(cadence, state) {
491
561
  const lastRanAt = state?.cadences?.[cadence.key]?.lastRanAt;
562
+ const intervalMinutes = Number(cadence.intervalMinutes || 0);
563
+ if (intervalMinutes > 0) {
564
+ if (!lastRanAt)
565
+ return true;
566
+ const last = Date.parse(String(lastRanAt));
567
+ if (!Number.isFinite(last))
568
+ return true;
569
+ return Date.now() - last >= Math.max(1, intervalMinutes) * 60 * 1000;
570
+ }
492
571
  const intervalDays = Number(cadence.intervalDays || 1);
493
572
  if (!lastRanAt)
494
573
  return true;
@@ -498,11 +577,7 @@ function cadenceIsDue(cadence, state) {
498
577
  return Date.now() - last >= Math.max(1, intervalDays) * 24 * 60 * 60 * 1000;
499
578
  }
500
579
  function getDueCadences(config, state) {
501
- const due = getCadenceDefinitions(config).filter((cadence) => cadenceIsDue(cadence, state));
502
- if (due.length > 0)
503
- return due;
504
- const daily = getCadenceDefinitions(config).find((cadence) => cadence.key === 'daily');
505
- return daily ? [daily] : [];
580
+ return getCadenceDefinitions(config).filter((cadence) => cadenceIsDue(cadence, state));
506
581
  }
507
582
  function markCadencesRan(state, cadences, ranAt) {
508
583
  const nextCadences = { ...(state?.cadences || {}) };
@@ -521,6 +596,7 @@ function getConnectorEntries(statusPayload) {
521
596
  status: String(value?.status || 'unknown'),
522
597
  detail: String(value?.detail || ''),
523
598
  nextAction: typeof value?.nextAction === 'string' ? value.nextAction : null,
599
+ accounts: Array.isArray(value?.accounts) ? value.accounts : [],
524
600
  }));
525
601
  }
526
602
  function getUnhealthyConfiguredConnectors(statusPayload) {
@@ -551,26 +627,208 @@ function humanConnectorName(key) {
551
627
  return 'GitHub';
552
628
  return key;
553
629
  }
630
+ function connectorWizardKey(key) {
631
+ if (key === 'analyticscli')
632
+ return 'analytics';
633
+ if (key === 'appStoreConnect')
634
+ return 'asc';
635
+ if (key === 'revenuecat')
636
+ return 'revenuecat';
637
+ if (key === 'sentry')
638
+ return 'sentry';
639
+ if (key === 'github')
640
+ return 'github';
641
+ return '';
642
+ }
643
+ function buildConnectorWizardCommand(configPath, entry) {
644
+ const connector = connectorWizardKey(entry.key);
645
+ if (!connector)
646
+ return null;
647
+ return `npx -y @analyticscli/growth-engineer@preview wizard --connectors ${quote(connector)}`;
648
+ }
649
+ function conciseConnectorDetail(entry) {
650
+ const detail = String(entry?.detail || '').replace(/\s+/g, ' ').trim();
651
+ if (/SENTRY_AUTH_TOKEN is required|SENTRY_AUTH_TOKEN.*missing/i.test(detail)) {
652
+ return 'SENTRY_AUTH_TOKEN missing for source collection.';
653
+ }
654
+ if (/source .*disabled|still disabled/i.test(detail)) {
655
+ return 'source is still disabled after setup.';
656
+ }
657
+ if (!detail)
658
+ return 'needs attention.';
659
+ return detail.length > 180 ? `${detail.slice(0, 177)}...` : detail;
660
+ }
661
+ function isAscWebAuthIssue(entry) {
662
+ if (entry.key !== 'appStoreConnect')
663
+ return false;
664
+ const text = `${entry.detail || ''}\n${entry.nextAction || ''}`.toLowerCase();
665
+ return (text.includes('asc web auth') ||
666
+ text.includes('asc_web_apple_id') ||
667
+ text.includes('web analytics') ||
668
+ text.includes('webauth') ||
669
+ text.includes('web auth'));
670
+ }
554
671
  function buildConnectorHealthAlert(statusPayload, unhealthyConnectors) {
555
- const lines = [
556
- `OpenClaw Growth connector health needs attention (${new Date().toISOString()}).`,
557
- `Config: ${statusPayload?.configPath || DEFAULT_CONFIG_PATH}`,
558
- '',
559
- 'Unhealthy connector(s):',
560
- ];
672
+ const configPath = statusPayload?.configPath || DEFAULT_CONFIG_PATH;
673
+ const lines = [`OpenClaw connector health: ${unhealthyConnectors.length} issue(s)`];
561
674
  for (const entry of unhealthyConnectors) {
562
- lines.push(`- ${humanConnectorName(entry.key)}: ${entry.status} - ${entry.detail}`);
563
- if (entry.nextAction) {
564
- lines.push(` Next: ${entry.nextAction}`);
675
+ lines.push(`- ${humanConnectorName(entry.key)}: ${entry.status} - ${conciseConnectorDetail(entry)}`);
676
+ const command = buildConnectorWizardCommand(configPath, entry);
677
+ if (command) {
678
+ lines.push(` Fix: \`${command}\``);
565
679
  }
566
- if (entry.key === 'appStoreConnect' && entry.status === 'partial') {
567
- lines.push(' Note: ASC uses API-key batch reports by default. Experimental ASC web analytics should only be requested when a needed metric is unavailable through API reports.');
680
+ if (isAscWebAuthIssue(entry)) {
681
+ lines.push(' ASC web-auth only: `ASC_WEB_APPLE_ID="<apple-id>" asc web auth login --apple-id "$ASC_WEB_APPLE_ID"`');
568
682
  }
569
683
  }
570
- lines.push('');
571
- lines.push('Do not send secrets through chat or social channels. Refresh credentials only in the host terminal or secret store.');
684
+ lines.push('Secrets stay in the host terminal or secret store.');
572
685
  return `${lines.join('\n')}\n`;
573
686
  }
687
+ function sourceFailureConnectorKey(failure) {
688
+ const service = String(failure?.service || '').toLowerCase();
689
+ const key = String(failure?.key || '').toLowerCase();
690
+ const source = String(failure?.source || '').toLowerCase();
691
+ if (service.includes('sentry') || key === 'glitchtip')
692
+ return 'sentry';
693
+ if (source === 'sentry' || source === 'glitchtip')
694
+ return 'sentry';
695
+ if (service.includes('revenuecat'))
696
+ return 'revenuecat';
697
+ if (service.includes('paddle'))
698
+ return 'paddle';
699
+ if (service.includes('seo') || service.includes('gsc') || service.includes('search-console') || service.includes('dataforseo'))
700
+ return 'seo';
701
+ if (key === 'paddle')
702
+ return 'paddle';
703
+ if (key === 'seo')
704
+ return 'seo';
705
+ if (service.includes('coolify'))
706
+ return 'coolify';
707
+ if (service.includes('github'))
708
+ return 'github';
709
+ if (key === 'analytics')
710
+ return 'analyticscli';
711
+ return String(failure?.key || 'source');
712
+ }
713
+ function getSentryAccountTargets(config) {
714
+ const accounts = Array.isArray(config?.sources?.sentry?.accounts) ? config.sources.sentry.accounts : [];
715
+ if (accounts.length === 0)
716
+ return [];
717
+ return accounts.map((account, index) => ({
718
+ id: String(account?.id || account?.key || account?.label || `sentry_${index + 1}`)
719
+ .trim()
720
+ .replace(/[^a-zA-Z0-9._-]+/g, '_'),
721
+ label: String(account?.label || account?.name || account?.id || `Sentry ${index + 1}`).trim(),
722
+ baseUrl: String(account?.baseUrl || account?.base_url || account?.url || 'https://sentry.io').trim(),
723
+ org: String(account?.org || account?.organization || '').trim(),
724
+ projects: Array.isArray(account?.projects)
725
+ ? account.projects.map((project) => String(typeof project === 'string' ? project : project?.project || project?.slug || '').trim()).filter(Boolean)
726
+ : account?.project
727
+ ? [String(account.project).trim()].filter(Boolean)
728
+ : [],
729
+ environment: String(account?.environment || process.env.SENTRY_ENVIRONMENT || 'production').trim(),
730
+ }));
731
+ }
732
+ function buildSourceFailureStatusPayload(configPath, sourceFailures, config = null) {
733
+ const connectors = {};
734
+ for (const failure of sourceFailures) {
735
+ const key = sourceFailureConnectorKey(failure);
736
+ const detail = `Source collection failed during scheduled run: ${failure.detail}`;
737
+ const retryable = Boolean(failure.retryable || failure.transient);
738
+ connectors[key] = {
739
+ status: 'partial',
740
+ detail,
741
+ accounts: key === 'sentry' ? getSentryAccountTargets(config) : [],
742
+ nextAction: retryable
743
+ ? 'Provider returned a transient upstream/network error after retry. Rerun the Growth Engineer later; if it repeats, check the provider status page and connector credentials.'
744
+ : 'Run the connector wizard or source command on the host terminal and fix the reported source error.',
745
+ };
746
+ }
747
+ return {
748
+ generatedAt: new Date().toISOString(),
749
+ configPath,
750
+ connectors,
751
+ sourceFailures,
752
+ };
753
+ }
754
+ async function recordSourceCollectionFailures({ config, configPath, state, statePath, runtimeDir, sourceFailures }) {
755
+ if (sourceFailures.length === 0) {
756
+ return {
757
+ ...state,
758
+ lastSourceCollectionFailures: [],
759
+ };
760
+ }
761
+ const healthState = state?.connectorHealth || {};
762
+ const checkedAt = new Date().toISOString();
763
+ const statusPayload = buildSourceFailureStatusPayload(configPath, sourceFailures, config);
764
+ const unhealthyConnectors = getUnhealthyConfiguredConnectors(statusPayload);
765
+ const fingerprint = buildConnectorHealthFingerprint(unhealthyConnectors);
766
+ const previousExternallyDeliveredFingerprint = healthState.lastExternalAlertedFingerprint || null;
767
+ let alertTriggered = false;
768
+ let alertDeliveries = [];
769
+ const nextHealthState = {
770
+ ...healthState,
771
+ lastCheckedAt: checkedAt,
772
+ lastStatusOk: false,
773
+ lastFingerprint: fingerprint,
774
+ activeIncidentFingerprint: fingerprint,
775
+ lastError: sourceFailures.map((failure) => `${failure.key || failure.source}: ${failure.detail}`).join('\n'),
776
+ };
777
+ if (previousExternallyDeliveredFingerprint !== fingerprint) {
778
+ const message = buildConnectorHealthAlert(statusPayload, unhealthyConnectors);
779
+ const paths = await writeConnectorHealthAlert(runtimeDir, message, statusPayload, unhealthyConnectors, fingerprint);
780
+ const deliveries = await deliverConnectorHealthAlert({
781
+ config,
782
+ configPath,
783
+ message,
784
+ statusPayload,
785
+ unhealthyConnectors,
786
+ fingerprint,
787
+ });
788
+ alertTriggered = true;
789
+ alertDeliveries = deliveries;
790
+ nextHealthState.lastAlertedAt = checkedAt;
791
+ nextHealthState.lastAlertedFingerprint = fingerprint;
792
+ nextHealthState.lastAlertMarkdownPath = paths.markdownPath;
793
+ nextHealthState.lastAlertJsonPath = paths.jsonPath;
794
+ nextHealthState.lastAlertDeliveries = deliveries;
795
+ nextHealthState.lastAlertExternalSent = hasSuccessfulExternalDelivery(deliveries);
796
+ if (nextHealthState.lastAlertExternalSent) {
797
+ nextHealthState.lastExternalAlertedAt = checkedAt;
798
+ nextHealthState.lastExternalAlertedFingerprint = fingerprint;
799
+ }
800
+ }
801
+ const nextState = {
802
+ ...state,
803
+ connectorHealth: nextHealthState,
804
+ lastSourceCollectionFailures: sourceFailures,
805
+ };
806
+ await fs.mkdir(path.dirname(statePath), { recursive: true });
807
+ await fs.writeFile(statePath, JSON.stringify(nextState, null, 2), 'utf8');
808
+ await appendSchedulerProof('source_collection_degraded', {
809
+ configPath,
810
+ statePath,
811
+ checkedAt,
812
+ sourceFailures: sourceFailures.map((failure) => ({
813
+ key: failure.key,
814
+ detail: failure.detail,
815
+ retryable: failure.retryable,
816
+ })),
817
+ unhealthyConnectors: unhealthyConnectors.map((entry) => ({
818
+ key: entry.key,
819
+ status: entry.status,
820
+ detail: entry.detail,
821
+ })),
822
+ alertTriggered,
823
+ deliveryCount: alertDeliveries.length,
824
+ externalDeliverySent: alertTriggered ? hasSuccessfulExternalDelivery(alertDeliveries) : false,
825
+ socialOutput: alertTriggered ? 'CONNECTOR_HEALTH_ALERT' : 'HEARTBEAT_OK',
826
+ socialReason: alertTriggered
827
+ ? 'new or changed source-collection connector incident'
828
+ : 'source-collection connector incident unchanged',
829
+ });
830
+ return nextState;
831
+ }
574
832
  async function writeConnectorHealthAlert(runtimeDir, message, statusPayload, unhealthyConnectors, fingerprint) {
575
833
  const alertDir = path.join(runtimeDir, 'connector-health');
576
834
  await ensureDir(alertDir);
@@ -752,6 +1010,38 @@ function hasExternalNotificationChannel(channels) {
752
1010
  function hasSuccessfulExternalDelivery(results) {
753
1011
  return results.some((result) => result?.sent === true && result?.external === true);
754
1012
  }
1013
+ function truncateMessageText(value, maxLength = 96) {
1014
+ const text = String(value || '').replace(/\s+/g, ' ').trim();
1015
+ if (text.length <= maxLength)
1016
+ return text;
1017
+ return `${text.slice(0, Math.max(0, maxLength - 3)).trim()}...`;
1018
+ }
1019
+ function issueProjectLabel(issue) {
1020
+ return String(issue?.app || issue?.source_project || issue?.sourceProject || issue?.project || 'unscoped').trim();
1021
+ }
1022
+ function issueSourceUrl(issue) {
1023
+ const direct = String(issue?.source_url || issue?.sourceUrl || issue?.issue_url || issue?.issueUrl || '').trim();
1024
+ if (direct)
1025
+ return direct;
1026
+ const body = String(issue?.body || '');
1027
+ const match = body.match(/(?:Issue link|Permalink):\s*(https?:\/\/\S+)/i);
1028
+ return match ? match[1].replace(/[).,;]+$/, '') : '';
1029
+ }
1030
+ function formatIssueSummaryLine(issue, maxTitleLength = 92) {
1031
+ const title = truncateMessageText(issue?.title, maxTitleLength);
1032
+ const url = issueSourceUrl(issue);
1033
+ return url ? `${title} (${url})` : title;
1034
+ }
1035
+ function groupIssuesByProject(issues, maxIssues = 4) {
1036
+ const grouped = new Map();
1037
+ for (const issue of issues.slice(0, maxIssues)) {
1038
+ const label = issueProjectLabel(issue);
1039
+ const bucket = grouped.get(label) || [];
1040
+ bucket.push(issue);
1041
+ grouped.set(label, bucket);
1042
+ }
1043
+ return [...grouped.entries()];
1044
+ }
755
1045
  async function deliverConnectorHealthAlert({ config, configPath, message, statusPayload, unhealthyConnectors, fingerprint }) {
756
1046
  const channels = getConnectorHealthChannels(config);
757
1047
  if (config?.notifications?.connectorHealth?.enabled === false) {
@@ -830,6 +1120,33 @@ function buildGrowthRunSummaryMessage({ issuesPayload, activeCadences, sourceFil
830
1120
  ? activeCadences.map((cadence) => cadence.title || cadence.key).join(', ')
831
1121
  : 'ad-hoc growth pass';
832
1122
  const sourceNames = Object.keys(sourceFiles || {}).sort().join(', ') || 'none';
1123
+ const issues = Array.isArray(issuesPayload?.issues) ? issuesPayload.issues : [];
1124
+ if (isShortOperationalCadence(activeCadences)) {
1125
+ const heading = activeCadences.some((cadence) => String(cadence?.key) === 'healthcheck')
1126
+ ? 'OpenClaw healthcheck'
1127
+ : 'OpenClaw daily';
1128
+ const lines = [
1129
+ `${heading}: ${issueCount > 0 ? `${issueCount} finding(s)` : 'OK'}`,
1130
+ ];
1131
+ if (issueCount > 0) {
1132
+ const groupedIssues = groupIssuesByProject(issues, 4);
1133
+ if (groupedIssues.length > 0) {
1134
+ lines.push('Top by project:');
1135
+ for (const [project, projectIssues] of groupedIssues) {
1136
+ const formatted = projectIssues.map((issue) => formatIssueSummaryLine(issue, 84)).filter(Boolean);
1137
+ if (formatted.length > 0)
1138
+ lines.push(`- ${project}: ${formatted.join(' | ')}`);
1139
+ }
1140
+ }
1141
+ lines.push(createdGitHubArtifact
1142
+ ? 'Action: GitHub artifact attempted.'
1143
+ : 'Action: external alert only.');
1144
+ }
1145
+ if (charts.length > 0) {
1146
+ lines.push(`Charts: ${charts.length}`);
1147
+ }
1148
+ return `${lines.join('\n')}\n`;
1149
+ }
833
1150
  const lines = [
834
1151
  `OpenClaw Growth run finished (${new Date().toISOString()}).`,
835
1152
  `Cadence: ${cadenceNames}`,
@@ -848,16 +1165,26 @@ function buildGrowthRunSummaryMessage({ issuesPayload, activeCadences, sourceFil
848
1165
  lines.push(`- ${chart.caption}: ${chart.filePath}`);
849
1166
  }
850
1167
  }
851
- const issues = Array.isArray(issuesPayload?.issues) ? issuesPayload.issues.slice(0, 3) : [];
852
- if (issues.length > 0) {
1168
+ const topIssues = issues.slice(0, isDeepAnalysisCadence(activeCadences) ? 5 : 3);
1169
+ if (topIssues.length > 0) {
853
1170
  lines.push('');
854
- lines.push('Top findings:');
855
- for (const issue of issues) {
1171
+ lines.push(isDeepAnalysisCadence(activeCadences) ? 'App-by-app findings and next steps:' : 'Top findings:');
1172
+ for (const issue of topIssues) {
856
1173
  lines.push(`- ${issue.title} (${issue.priority || 'medium'}, ${issue.area || 'general'})`);
1174
+ if (isDeepAnalysisCadence(activeCadences)) {
1175
+ for (const evidence of firstEvidenceLines(issue, 2)) {
1176
+ lines.push(` Evidence: ${evidence}`);
1177
+ }
1178
+ if (issue.expected_impact) {
1179
+ lines.push(` Impact: ${issue.expected_impact}`);
1180
+ }
1181
+ }
857
1182
  }
858
1183
  }
859
1184
  lines.push('');
860
- lines.push('No secrets were included. Use the generated issue drafts or OpenClaw chat handoff for details.');
1185
+ lines.push(isDeepAnalysisCadence(activeCadences)
1186
+ ? 'No secrets were included. Full details are in the generated issue drafts, charts, and OpenClaw chat handoff.'
1187
+ : 'No secrets were included.');
861
1188
  return `${lines.join('\n')}\n`;
862
1189
  }
863
1190
  async function writeConfiguredOpenClawChatGrowthSummary(configPath, channel, message, issuesPayload, activeCadences, fingerprint, charts) {
@@ -1006,6 +1333,10 @@ async function maybeRunConnectorHealthCheck({ config, configPath, state, statePa
1006
1333
  statePath,
1007
1334
  intervalMinutes,
1008
1335
  lastCheckedAt: healthState.lastCheckedAt || null,
1336
+ persistedLastStatusOk: healthState.lastStatusOk !== false,
1337
+ activeIncidentFingerprint: healthState.activeIncidentFingerprint || null,
1338
+ socialOutput: 'HEARTBEAT_OK',
1339
+ socialReason: 'connector health was not due; persisted unhealthy state is not a new event',
1009
1340
  });
1010
1341
  return state;
1011
1342
  }
@@ -1053,6 +1384,8 @@ async function maybeRunConnectorHealthCheck({ config, configPath, state, statePa
1053
1384
  lastError: null,
1054
1385
  };
1055
1386
  const previousExternallyDeliveredFingerprint = healthState.lastExternalAlertedFingerprint || null;
1387
+ let alertTriggered = false;
1388
+ let alertDeliveries = [];
1056
1389
  if (unhealthyConnectors.length === 0) {
1057
1390
  nextHealthState.activeIncidentFingerprint = null;
1058
1391
  nextHealthState.lastExternalAlertedFingerprint = null;
@@ -1075,6 +1408,8 @@ async function maybeRunConnectorHealthCheck({ config, configPath, state, statePa
1075
1408
  unhealthyConnectors,
1076
1409
  fingerprint,
1077
1410
  });
1411
+ alertTriggered = true;
1412
+ alertDeliveries = deliveries;
1078
1413
  nextHealthState.lastAlertedAt = checkedAt;
1079
1414
  nextHealthState.lastAlertedFingerprint = fingerprint;
1080
1415
  nextHealthState.lastAlertMarkdownPath = paths.markdownPath;
@@ -1105,9 +1440,27 @@ async function maybeRunConnectorHealthCheck({ config, configPath, state, statePa
1105
1440
  detail: entry.detail,
1106
1441
  })),
1107
1442
  alertMarkdownPath: nextHealthState.lastAlertMarkdownPath || null,
1108
- deliveryCount: Array.isArray(nextHealthState.lastAlertDeliveries) ? nextHealthState.lastAlertDeliveries.length : 0,
1109
- externalDeliverySent: nextHealthState.lastAlertExternalSent === true,
1443
+ alertTriggered,
1444
+ deliveryCount: alertDeliveries.length,
1445
+ externalDeliverySent: alertTriggered ? hasSuccessfulExternalDelivery(alertDeliveries) : false,
1446
+ socialOutput: alertTriggered ? 'CONNECTOR_HEALTH_ALERT' : 'HEARTBEAT_OK',
1447
+ socialReason: alertTriggered
1448
+ ? 'new or changed connector-health incident'
1449
+ : unhealthyConnectors.length > 0
1450
+ ? 'connector-health incident unchanged'
1451
+ : healthState.lastStatusOk === false
1452
+ ? 'connector health recovered'
1453
+ : 'connector health unchanged healthy',
1110
1454
  });
1455
+ if (unhealthyConnectors.length > 0 && !alertTriggered) {
1456
+ await appendSchedulerProof('connector_health_unchanged', {
1457
+ configPath,
1458
+ statePath,
1459
+ checkedAt,
1460
+ fingerprint,
1461
+ socialOutput: 'HEARTBEAT_OK',
1462
+ });
1463
+ }
1111
1464
  return nextState;
1112
1465
  }
1113
1466
  function buildIssueFingerprint(issuesPayload) {
@@ -1116,6 +1469,30 @@ function buildIssueFingerprint(issuesPayload) {
1116
1469
  : [];
1117
1470
  return sha256(titles.join('\n'));
1118
1471
  }
1472
+ function isShortOperationalCadence(cadences) {
1473
+ if (!Array.isArray(cadences) || cadences.length === 0)
1474
+ return false;
1475
+ return cadences.every((cadence) => {
1476
+ const key = String(cadence?.key || '').toLowerCase();
1477
+ return key === 'healthcheck' || key === 'daily' || cadence?.criticalOnly === true;
1478
+ });
1479
+ }
1480
+ function isDeepAnalysisCadence(cadences) {
1481
+ if (!Array.isArray(cadences))
1482
+ return false;
1483
+ return cadences.some((cadence) => ['weekly', 'monthly', 'quarterly', 'six_months', 'yearly'].includes(String(cadence?.key || '').toLowerCase()));
1484
+ }
1485
+ function firstEvidenceLines(issue, maxLines = 2) {
1486
+ const body = String(issue?.body || '');
1487
+ const evidenceMatch = body.match(/## Evidence\n([\s\S]*?)(?:\n## |\n?$)/);
1488
+ if (!evidenceMatch)
1489
+ return [];
1490
+ return evidenceMatch[1]
1491
+ .split('\n')
1492
+ .map((line) => line.replace(/^-\s*/, '').trim())
1493
+ .filter(Boolean)
1494
+ .slice(0, maxLines);
1495
+ }
1119
1496
  async function runAnalyzer({ config, runtimeDir, sourceFiles, createGitHubArtifact, githubArtifactMode = getActionMode(config), chartManifestPath, cadencePlanPath, }) {
1120
1497
  await ensureDir(runtimeDir);
1121
1498
  if (!sourceFiles.analytics) {
@@ -1138,9 +1515,18 @@ async function runAnalyzer({ config, runtimeDir, sourceFiles, createGitHubArtifa
1138
1515
  if (sourceFiles.revenuecat) {
1139
1516
  args.push('--revenuecat', sourceFiles.revenuecat);
1140
1517
  }
1518
+ if (sourceFiles.paddle) {
1519
+ args.push('--source', `paddle=${sourceFiles.paddle}`);
1520
+ }
1521
+ if (sourceFiles.seo) {
1522
+ args.push('--source', `seo=${sourceFiles.seo}`);
1523
+ }
1141
1524
  if (sourceFiles.sentry) {
1142
1525
  args.push('--sentry', sourceFiles.sentry);
1143
1526
  }
1527
+ if (sourceFiles.coolify) {
1528
+ args.push('--source', `coolify=${sourceFiles.coolify}`);
1529
+ }
1144
1530
  if (sourceFiles.feedback) {
1145
1531
  args.push('--feedback', sourceFiles.feedback);
1146
1532
  }
@@ -1184,10 +1570,13 @@ async function runAnalyzer({ config, runtimeDir, sourceFiles, createGitHubArtifa
1184
1570
  analyzerStdout: analyzer.stdout.trim(),
1185
1571
  };
1186
1572
  }
1187
- async function maybeGenerateCharts({ config, payloads, runtimeDir }) {
1573
+ async function maybeGenerateCharts({ config, payloads, runtimeDir, activeCadences }) {
1188
1574
  if (!config.charting?.enabled) {
1189
1575
  return null;
1190
1576
  }
1577
+ if (!isDeepAnalysisCadence(activeCadences)) {
1578
+ return null;
1579
+ }
1191
1580
  const analyticsPayload = payloads.analytics;
1192
1581
  if (!analyticsPayload) {
1193
1582
  return null;
@@ -1262,6 +1651,7 @@ async function resolveSourcePayloadWithCursor(sourceConfig, sourceName, cursorSt
1262
1651
  payload: null,
1263
1652
  nextCursor: cursorState || null,
1264
1653
  resolvedCommand: null,
1654
+ failure: null,
1265
1655
  };
1266
1656
  }
1267
1657
  if (sourceConfig.mode === 'command') {
@@ -1269,9 +1659,34 @@ async function resolveSourcePayloadWithCursor(sourceConfig, sourceName, cursorSt
1269
1659
  throw new Error(`Source "${sourceName}" has mode=command but no command configured.`);
1270
1660
  }
1271
1661
  const resolvedCommand = resolveCursorAwareCommand(withActiveConfigArg(replaceLegacyRuntimeScriptCommand(sourceConfig.command), configPath), sourceConfig, cursorState);
1272
- const result = await runShellCommand(String(resolvedCommand), 120_000, { cwd: commandCwd });
1662
+ let result = await runShellCommand(String(resolvedCommand), 120_000, { cwd: commandCwd });
1663
+ let retried = false;
1664
+ if (!result.ok && isTransientNetworkFailure(result.stderr || result.stdout)) {
1665
+ retried = true;
1666
+ await sleep(1_500);
1667
+ result = await runShellCommand(String(resolvedCommand), 120_000, { cwd: commandCwd });
1668
+ }
1273
1669
  if (!result.ok) {
1274
- throw new Error(`Source "${sourceName}" command failed: ${result.stderr || `exit ${result.code}`}`);
1670
+ const detail = `${retried ? 'transient network error persisted after retry: ' : ''}${result.stderr || `exit ${result.code}`}`;
1671
+ if (shouldDegradeTransientSourceFailure(sourceConfig, sourceName, retried)) {
1672
+ return {
1673
+ payload: null,
1674
+ nextCursor: cursorState || null,
1675
+ resolvedCommand,
1676
+ failure: {
1677
+ key: sourceName,
1678
+ label: sourceConfig.label || sourceName,
1679
+ service: sourceConfig.service || sourceName,
1680
+ source: sourceName,
1681
+ transient: true,
1682
+ retryable: true,
1683
+ retried: true,
1684
+ at: new Date().toISOString(),
1685
+ detail,
1686
+ },
1687
+ };
1688
+ }
1689
+ throw new Error(`Source "${sourceName}" command failed: ${detail}`);
1275
1690
  }
1276
1691
  const fetchedAt = new Date().toISOString();
1277
1692
  try {
@@ -1282,9 +1697,11 @@ async function resolveSourcePayloadWithCursor(sourceConfig, sourceName, cursorSt
1282
1697
  lastCollectedAt: fetchedAt,
1283
1698
  updatedAt: fetchedAt,
1284
1699
  lastCommand: resolvedCommand,
1700
+ lastRetriedTransientFailureAt: retried ? fetchedAt : null,
1285
1701
  }
1286
1702
  : cursorState || null,
1287
1703
  resolvedCommand,
1704
+ failure: null,
1288
1705
  };
1289
1706
  }
1290
1707
  catch {
@@ -1298,15 +1715,36 @@ async function resolveSourcePayloadWithCursor(sourceConfig, sourceName, cursorSt
1298
1715
  payload: await readJson(path.resolve(String(sourceConfig.path))),
1299
1716
  nextCursor: cursorState || null,
1300
1717
  resolvedCommand: null,
1718
+ failure: null,
1301
1719
  };
1302
1720
  }
1303
1721
  async function loadSourcePayloads(config, state, configPath) {
1304
1722
  const payloads = {};
1305
1723
  const sourceCursors = { ...(state?.sourceCursors || {}) };
1724
+ const sourceFailures = [];
1306
1725
  const commandCwd = getProjectCommandCwd(config);
1307
1726
  for (const source of getAllSourceEntries(config)) {
1308
1727
  const currentCursor = sourceCursors[source.key] || null;
1309
- const result = await resolveSourcePayloadWithCursor(source, source.key, currentCursor, commandCwd, configPath);
1728
+ let result;
1729
+ try {
1730
+ result = await resolveSourcePayloadWithCursor(source, source.key, currentCursor, commandCwd, configPath);
1731
+ }
1732
+ catch (error) {
1733
+ if (source.key === 'analytics') {
1734
+ throw error;
1735
+ }
1736
+ const detail = error instanceof Error ? error.message : String(error);
1737
+ sourceFailures.push({
1738
+ key: source.key,
1739
+ label: source.label || source.key,
1740
+ service: source.service || source.key,
1741
+ detail,
1742
+ retryable: isTransientNetworkFailure(detail),
1743
+ failedAt: new Date().toISOString(),
1744
+ });
1745
+ process.stderr.write(`[${new Date().toISOString()}] Optional source "${source.key}" failed; continuing without it: ${detail}\n`);
1746
+ continue;
1747
+ }
1310
1748
  const payload = result.payload;
1311
1749
  if (payload) {
1312
1750
  payloads[source.key] = payload;
@@ -1314,10 +1752,22 @@ async function loadSourcePayloads(config, state, configPath) {
1314
1752
  if (result.nextCursor) {
1315
1753
  sourceCursors[source.key] = result.nextCursor;
1316
1754
  }
1755
+ if (result.failure) {
1756
+ sourceFailures.push(result.failure);
1757
+ await appendSchedulerProof('source_collection_degraded', {
1758
+ configPath,
1759
+ source: result.failure.source,
1760
+ transient: result.failure.transient,
1761
+ retried: result.failure.retried,
1762
+ detail: result.failure.detail,
1763
+ socialOutput: 'HEARTBEAT_OK',
1764
+ });
1765
+ }
1317
1766
  }
1318
1767
  return {
1319
1768
  payloads,
1320
1769
  sourceCursors,
1770
+ sourceFailures,
1321
1771
  };
1322
1772
  }
1323
1773
  async function materializeSourceFiles(config, payloads, runtimeDir) {
@@ -1350,6 +1800,20 @@ async function runOnce(configPath, statePath) {
1350
1800
  argv: process.argv.slice(2),
1351
1801
  });
1352
1802
  const config = await readJson(configPath);
1803
+ const cronDeliveryRepair = await repairOpenClawCronDeliveryStore({
1804
+ configPath,
1805
+ config,
1806
+ readFile: fs.readFile,
1807
+ writeFile: fs.writeFile,
1808
+ });
1809
+ if (cronDeliveryRepair.repaired) {
1810
+ await appendSchedulerProof('openclaw_cron_delivery_repaired', {
1811
+ configPath,
1812
+ statePath,
1813
+ path: cronDeliveryRepair.path,
1814
+ repairedCount: cronDeliveryRepair.repairedCount,
1815
+ });
1816
+ }
1353
1817
  await applyOpenClawSecretRefs(config);
1354
1818
  const inferredGitHubRepo = await inferGitHubRepo(config);
1355
1819
  if (inferredGitHubRepo) {
@@ -1374,26 +1838,36 @@ async function runOnce(configPath, statePath) {
1374
1838
  runtimeDir,
1375
1839
  });
1376
1840
  const activeCadences = getDueCadences(config, stateAfterHealthCheck);
1377
- const { payloads, sourceCursors } = await loadSourcePayloads(config, stateAfterHealthCheck, configPath);
1841
+ const { payloads, sourceCursors, sourceFailures } = await loadSourcePayloads(config, stateAfterHealthCheck, configPath);
1842
+ const stateAfterSourceCollection = await recordSourceCollectionFailures({
1843
+ config,
1844
+ configPath,
1845
+ state: stateAfterHealthCheck,
1846
+ statePath,
1847
+ runtimeDir,
1848
+ sourceFailures,
1849
+ });
1378
1850
  const currentHashes = computeSourceHashes(payloads);
1379
- const changed = hasSourceChanges(stateAfterHealthCheck.sourceHashes, currentHashes);
1380
- if (!changed && config.schedule?.skipIfNoDataChange !== false) {
1381
- process.stdout.write(`[${new Date().toISOString()}] No data changes. Skip run.\n`);
1851
+ if (activeCadences.length === 0) {
1852
+ process.stdout.write(`[${new Date().toISOString()}] No scheduled cadence due. Skip run.\n`);
1382
1853
  const completedAt = new Date().toISOString();
1383
1854
  await fs.mkdir(path.dirname(statePath), { recursive: true });
1384
1855
  await fs.writeFile(statePath, JSON.stringify({
1385
1856
  ...stateAfterHealthCheck,
1857
+ ...stateAfterSourceCollection,
1386
1858
  sourceHashes: currentHashes,
1387
1859
  sourceCursors,
1860
+ lastSourceFailures: sourceFailures,
1388
1861
  lastRunAt: completedAt,
1389
- skippedReason: 'no_data_change',
1862
+ skippedReason: 'cadence_not_due',
1390
1863
  }, null, 2), 'utf8');
1391
1864
  await appendSchedulerProof('runner_completed', {
1392
1865
  configPath,
1393
1866
  statePath,
1394
1867
  completedAt,
1395
- skippedReason: 'no_data_change',
1396
- activeCadences: activeCadences.map((cadence) => cadence.key),
1868
+ skippedReason: 'cadence_not_due',
1869
+ sourceFailures,
1870
+ socialOutput: 'HEARTBEAT_OK',
1397
1871
  });
1398
1872
  return;
1399
1873
  }
@@ -1409,6 +1883,7 @@ async function runOnce(configPath, statePath) {
1409
1883
  config,
1410
1884
  payloads,
1411
1885
  runtimeDir,
1886
+ activeCadences,
1412
1887
  });
1413
1888
  const dryRun = await runAnalyzer({
1414
1889
  config,
@@ -1419,19 +1894,29 @@ async function runOnce(configPath, statePath) {
1419
1894
  cadencePlanPath,
1420
1895
  });
1421
1896
  const issueFingerprint = buildIssueFingerprint(dryRun.issuesPayload);
1422
- const unchangedIssueSet = issueFingerprint === stateAfterHealthCheck.lastIssueFingerprint;
1423
- if (unchangedIssueSet && config.schedule?.skipIfIssueSetUnchanged !== false) {
1424
- process.stdout.write(`[${new Date().toISOString()}] Issue set unchanged. Skip GitHub creation.\n`);
1897
+ const unchangedIssueSet = issueFingerprint === stateAfterSourceCollection.lastIssueFingerprint;
1898
+ if (unchangedIssueSet &&
1899
+ config.schedule?.skipIfIssueSetUnchanged !== false) {
1900
+ process.stdout.write(`[${new Date().toISOString()}] Issue set unchanged. Skip GitHub creation and external growth notification.\n`);
1425
1901
  const completedAt = new Date().toISOString();
1426
1902
  await fs.mkdir(path.dirname(statePath), { recursive: true });
1427
1903
  await fs.writeFile(statePath, JSON.stringify({
1428
1904
  ...stateAfterHealthCheck,
1905
+ ...stateAfterSourceCollection,
1429
1906
  sourceHashes: currentHashes,
1430
1907
  sourceCursors,
1908
+ lastSourceFailures: sourceFailures,
1431
1909
  lastIssueFingerprint: issueFingerprint,
1432
1910
  lastRunAt: completedAt,
1433
1911
  lastOutFile: dryRun.outFile,
1434
- cadences: markCadencesRan(stateAfterHealthCheck, activeCadences, completedAt),
1912
+ cadences: markCadencesRan(stateAfterSourceCollection, activeCadences, completedAt),
1913
+ lastGrowthRunNotifications: [
1914
+ {
1915
+ sent: false,
1916
+ target: 'growth_run',
1917
+ detail: 'issue set unchanged; external growth notification suppressed',
1918
+ },
1919
+ ],
1435
1920
  skippedReason: 'issue_set_unchanged',
1436
1921
  }, null, 2), 'utf8');
1437
1922
  await appendSchedulerProof('runner_completed', {
@@ -1441,10 +1926,17 @@ async function runOnce(configPath, statePath) {
1441
1926
  skippedReason: 'issue_set_unchanged',
1442
1927
  activeCadences: activeCadences.map((cadence) => cadence.key),
1443
1928
  outFile: dryRun.outFile,
1929
+ issueCount: Number(dryRun.issuesPayload?.issue_count || 0),
1930
+ sourceFailures,
1931
+ externalGrowthNotification: 'suppressed_unchanged_issue_set',
1932
+ socialOutput: 'HEARTBEAT_OK',
1444
1933
  });
1445
1934
  return;
1446
1935
  }
1447
- const shouldCreateGitHubArtifact = createGitHubArtifact && Number(dryRun.issuesPayload?.issue_count || 0) > 0;
1936
+ const issueSetChangedOrExplicitlyAllowed = !unchangedIssueSet || config.schedule?.skipIfIssueSetUnchanged === false;
1937
+ const shouldCreateGitHubArtifact = createGitHubArtifact &&
1938
+ Number(dryRun.issuesPayload?.issue_count || 0) > 0 &&
1939
+ issueSetChangedOrExplicitlyAllowed;
1448
1940
  if (shouldCreateGitHubArtifact) {
1449
1941
  for (const githubArtifactMode of githubArtifactModes) {
1450
1942
  await runAnalyzer({
@@ -1466,12 +1958,14 @@ async function runOnce(configPath, statePath) {
1466
1958
  await fs.mkdir(path.dirname(statePath), { recursive: true });
1467
1959
  await fs.writeFile(statePath, JSON.stringify({
1468
1960
  ...stateAfterHealthCheck,
1961
+ ...stateAfterSourceCollection,
1469
1962
  sourceHashes: currentHashes,
1470
1963
  sourceCursors,
1964
+ lastSourceFailures: sourceFailures,
1471
1965
  lastIssueFingerprint: issueFingerprint,
1472
1966
  lastRunAt: completedAt,
1473
1967
  lastOutFile: dryRun.outFile,
1474
- cadences: markCadencesRan(stateAfterHealthCheck, activeCadences, completedAt),
1968
+ cadences: markCadencesRan(stateAfterSourceCollection, activeCadences, completedAt),
1475
1969
  lastGrowthRunNotifications: await deliverGrowthRunSummary({
1476
1970
  config,
1477
1971
  configPath,
@@ -1492,6 +1986,7 @@ async function runOnce(configPath, statePath) {
1492
1986
  activeCadences: activeCadences.map((cadence) => cadence.key),
1493
1987
  outFile: dryRun.outFile,
1494
1988
  issueCount: Number(dryRun.issuesPayload?.issue_count || 0),
1989
+ sourceFailures,
1495
1990
  createdGitHubArtifact: shouldCreateGitHubArtifact,
1496
1991
  });
1497
1992
  }