@analyticscli/growth-engineer 0.1.0-preview.9 → 0.1.1-preview.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 (40) hide show
  1. package/dist/config.d.ts +925 -45
  2. package/dist/config.js +58 -6
  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 +295 -4
  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 +51 -0
  20. package/dist/runtime/openclaw-exporters-lib.mjs +769 -63
  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 +446 -30
  27. package/dist/runtime/openclaw-growth-preflight.mjs.map +1 -1
  28. package/dist/runtime/openclaw-growth-runner.mjs +831 -146
  29. package/dist/runtime/openclaw-growth-runner.mjs.map +1 -1
  30. package/dist/runtime/openclaw-growth-shared.d.mts +158 -3
  31. package/dist/runtime/openclaw-growth-shared.mjs +574 -8
  32. package/dist/runtime/openclaw-growth-shared.mjs.map +1 -1
  33. package/dist/runtime/openclaw-growth-start.mjs +802 -39
  34. package/dist/runtime/openclaw-growth-start.mjs.map +1 -1
  35. package/dist/runtime/openclaw-growth-status.mjs +85 -31
  36. package/dist/runtime/openclaw-growth-status.mjs.map +1 -1
  37. package/dist/runtime/openclaw-growth-wizard.mjs +1952 -217
  38. package/dist/runtime/openclaw-growth-wizard.mjs.map +1 -1
  39. package/package.json +3 -1
  40. package/templates/config.example.json +128 -65
@@ -5,64 +5,75 @@ 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 { getActionMode, getAllSourceEntries, 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';
12
- const DEFAULT_RUNTIME_DIR = 'data/openclaw-growth-engineer/runtime';
12
+ const DEFAULT_SCHEDULER_PROOF_PATH = 'data/openclaw-growth-engineer/runtime/scheduler-proof.jsonl';
13
13
  const DEFAULT_CONNECTOR_HEALTH_INTERVAL_MINUTES = 360;
14
14
  const SELF_UPDATE_INTERVAL_MS = 24 * 60 * 60 * 1000;
15
15
  const RUNTIME_DIR = path.dirname(fileURLToPath(import.meta.url));
16
+ let schedulerProofPath = path.resolve(DEFAULT_SCHEDULER_PROOF_PATH);
16
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
+ },
17
28
  {
18
29
  key: 'daily',
19
- title: 'Daily Sentry and production guardrail',
30
+ title: 'Daily behavioral anomaly guardrail',
20
31
  intervalDays: 1,
21
32
  criticalOnly: true,
22
- focusAreas: ['sentry_errors', 'crash', 'onboarding', 'conversion', 'paywall', 'purchase'],
23
- sourcePriorities: ['sentry', 'glitchtip', 'analytics', 'revenuecat', 'asc_cli', 'feedback', 'github'],
24
- 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.',
25
- 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.',
26
37
  },
27
38
  {
28
39
  key: 'weekly',
29
40
  title: 'Weekly executive product and growth summary',
30
41
  intervalDays: 7,
31
42
  criticalOnly: false,
32
- focusAreas: ['conversion', 'paywall', 'onboarding', 'marketing', 'retention', 'stability'],
33
- sourcePriorities: ['analytics', 'revenuecat', 'asc_cli', 'feedback', 'sentry', 'github'],
34
- objective: 'Create an executive summary across all configured projects, connectors, recent releases, code changes, revenue, activation, retention, reviews, and production stability.',
35
- 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.',
36
47
  },
37
48
  {
38
49
  key: 'monthly',
39
50
  title: 'Monthly deep product, business, and code review',
40
51
  intervalDays: 30,
41
52
  criticalOnly: false,
42
- focusAreas: ['conversion', 'paywall', 'retention', 'marketing', 'onboarding', 'codebase'],
43
- sourcePriorities: ['analytics', 'revenuecat', 'asc_cli', 'feedback', 'sentry', 'github'],
44
- 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.',
45
- 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.',
46
57
  },
47
58
  {
48
59
  key: 'quarterly',
49
- title: 'Quarterly positioning, pricing, and roadmap review',
60
+ title: '3-month positioning, pricing, and roadmap review',
50
61
  intervalDays: 91,
51
62
  criticalOnly: false,
52
63
  focusAreas: ['marketing', 'paywall', 'retention', 'conversion', 'onboarding'],
53
- sourcePriorities: ['analytics', 'revenuecat', 'asc_cli', 'feedback', 'github', 'sentry'],
54
- objective: 'Revisit positioning, pricing/packaging, onboarding architecture, roadmap assumptions, tracking quality, codebase constraints, and major funnel bets across every configured project.',
55
- 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.',
56
67
  },
57
68
  {
58
69
  key: 'six_months',
59
70
  title: 'Six-month instrumentation and growth-system audit',
60
71
  intervalDays: 182,
61
72
  criticalOnly: false,
62
- focusAreas: ['retention', 'conversion', 'paywall', 'marketing', 'general'],
63
- 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'],
64
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.',
65
- 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.',
66
77
  },
67
78
  {
68
79
  key: 'yearly',
@@ -70,7 +81,7 @@ const DEFAULT_CADENCES = [
70
81
  intervalDays: 365,
71
82
  criticalOnly: false,
72
83
  focusAreas: ['marketing', 'retention', 'paywall', 'conversion', 'general'],
73
- sourcePriorities: ['analytics', 'revenuecat', 'asc_cli', 'feedback', 'sentry'],
84
+ sourcePriorities: ['analytics', 'revenuecat', 'paddle', 'seo', 'asc_cli', 'feedback', 'sentry'],
74
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.',
75
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.',
76
87
  },
@@ -140,6 +151,39 @@ function resolveRuntimeScriptPath(scriptName) {
140
151
  function nodeRuntimeScriptCommand(scriptName) {
141
152
  return `node ${quote(resolveRuntimeScriptPath(scriptName))}`;
142
153
  }
154
+ function replaceLegacyRuntimeScriptCommand(command) {
155
+ const trimmed = String(command || '').trim();
156
+ if (!trimmed)
157
+ return trimmed;
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));
159
+ }
160
+ function commandHasConfigArg(command) {
161
+ return /(?:^|\s)--config(?:=|\s|$)/.test(String(command || ''));
162
+ }
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 || ''));
168
+ }
169
+ function withActiveConfigArg(command, configPath) {
170
+ const trimmed = String(command || '').trim();
171
+ if (!trimmed || !configPath || !commandIsBuiltinExporter(trimmed)) {
172
+ return trimmed;
173
+ }
174
+ if (!commandSupportsActiveConfig(trimmed)) {
175
+ return trimmed
176
+ .replace(/(^|\s)--config=(?:"[^"]*"|'[^']*'|\S+)/, '$1')
177
+ .replace(/(^|\s)--config\s+(?:"[^"]*"|'[^']*'|\S+)/, '$1')
178
+ .trim();
179
+ }
180
+ if (commandHasConfigArg(trimmed)) {
181
+ return trimmed
182
+ .replace(/(^|\s)--config=(?:"[^"]*"|'[^']*'|\S+)/, `$1--config ${quote(configPath)}`)
183
+ .replace(/(^|\s)--config\s+(?:"[^"]*"|'[^']*'|\S+)/, `$1--config ${quote(configPath)}`);
184
+ }
185
+ return `${trimmed} --config ${quote(configPath)}`;
186
+ }
143
187
  async function readJson(filePath) {
144
188
  const raw = await fs.readFile(filePath, 'utf8');
145
189
  return JSON.parse(raw);
@@ -155,6 +199,22 @@ async function readJsonOptional(filePath, fallback) {
155
199
  async function ensureDir(dirPath) {
156
200
  await fs.mkdir(dirPath, { recursive: true });
157
201
  }
202
+ async function appendSchedulerProof(event, details = {}) {
203
+ const proofPath = schedulerProofPath;
204
+ const entry = {
205
+ ts: new Date().toISOString(),
206
+ event,
207
+ pid: process.pid,
208
+ cwd: process.cwd(),
209
+ ...details,
210
+ };
211
+ await fs.mkdir(path.dirname(proofPath), { recursive: true });
212
+ await fs.appendFile(proofPath, `${JSON.stringify(entry)}\n`, 'utf8');
213
+ }
214
+ function useSchedulerProofPathForStatePath(statePath) {
215
+ schedulerProofPath = path.resolve(deriveSchedulerProofPathFromStatePath(statePath));
216
+ return schedulerProofPath;
217
+ }
158
218
  function sha256(input) {
159
219
  return createHash('sha256').update(input).digest('hex');
160
220
  }
@@ -164,6 +224,33 @@ function stableStringify(value) {
164
224
  function sleep(ms) {
165
225
  return new Promise((resolve) => setTimeout(resolve, ms));
166
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
+ }
167
254
  function isTruthyEnv(value) {
168
255
  return ['1', 'true', 'yes', 'y', 'on'].includes(String(value || '').trim().toLowerCase());
169
256
  }
@@ -309,11 +396,24 @@ function resolveShellCommand() {
309
396
  }
310
397
  return 'sh';
311
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
+ }
312
405
  function runShellCommand(command, timeoutMs = 120_000, options = {}) {
313
406
  return new Promise((resolve) => {
314
- const child = spawn(resolveShellCommand(), ['-c', command], {
407
+ const hardenedCommand = hardenUnattendedShellCommand(command);
408
+ const child = spawn(resolveShellCommand(), ['-c', hardenedCommand], {
315
409
  stdio: options.input === undefined ? ['ignore', 'pipe', 'pipe'] : ['pipe', 'pipe', 'pipe'],
316
410
  cwd: options.cwd,
411
+ env: {
412
+ ...process.env,
413
+ DEBIAN_FRONTEND: 'noninteractive',
414
+ SUDO_ASKPASS: '/bin/false',
415
+ SUDO_PROMPT: '',
416
+ },
317
417
  });
318
418
  let stdout = '';
319
419
  let stderr = '';
@@ -330,6 +430,17 @@ function runShellCommand(command, timeoutMs = 120_000, options = {}) {
330
430
  });
331
431
  child.stderr.on('data', (chunk) => {
332
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
+ }
333
444
  });
334
445
  if (options.input !== undefined) {
335
446
  child.stdin.end(options.input);
@@ -448,6 +559,15 @@ function getCadenceDefinitions(config) {
448
559
  }
449
560
  function cadenceIsDue(cadence, state) {
450
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
+ }
451
571
  const intervalDays = Number(cadence.intervalDays || 1);
452
572
  if (!lastRanAt)
453
573
  return true;
@@ -457,11 +577,7 @@ function cadenceIsDue(cadence, state) {
457
577
  return Date.now() - last >= Math.max(1, intervalDays) * 24 * 60 * 60 * 1000;
458
578
  }
459
579
  function getDueCadences(config, state) {
460
- const due = getCadenceDefinitions(config).filter((cadence) => cadenceIsDue(cadence, state));
461
- if (due.length > 0)
462
- return due;
463
- const daily = getCadenceDefinitions(config).find((cadence) => cadence.key === 'daily');
464
- return daily ? [daily] : [];
580
+ return getCadenceDefinitions(config).filter((cadence) => cadenceIsDue(cadence, state));
465
581
  }
466
582
  function markCadencesRan(state, cadences, ranAt) {
467
583
  const nextCadences = { ...(state?.cadences || {}) };
@@ -480,6 +596,7 @@ function getConnectorEntries(statusPayload) {
480
596
  status: String(value?.status || 'unknown'),
481
597
  detail: String(value?.detail || ''),
482
598
  nextAction: typeof value?.nextAction === 'string' ? value.nextAction : null,
599
+ accounts: Array.isArray(value?.accounts) ? value.accounts : [],
483
600
  }));
484
601
  }
485
602
  function getUnhealthyConfiguredConnectors(statusPayload) {
@@ -510,26 +627,208 @@ function humanConnectorName(key) {
510
627
  return 'GitHub';
511
628
  return key;
512
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
+ }
513
671
  function buildConnectorHealthAlert(statusPayload, unhealthyConnectors) {
514
- const lines = [
515
- `OpenClaw Growth connector health needs attention (${new Date().toISOString()}).`,
516
- `Config: ${statusPayload?.configPath || DEFAULT_CONFIG_PATH}`,
517
- '',
518
- 'Unhealthy connector(s):',
519
- ];
672
+ const configPath = statusPayload?.configPath || DEFAULT_CONFIG_PATH;
673
+ const lines = [`OpenClaw connector health: ${unhealthyConnectors.length} issue(s)`];
520
674
  for (const entry of unhealthyConnectors) {
521
- lines.push(`- ${humanConnectorName(entry.key)}: ${entry.status} - ${entry.detail}`);
522
- if (entry.nextAction) {
523
- 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}\``);
524
679
  }
525
- if (entry.key === 'appStoreConnect' && entry.status === 'partial') {
526
- lines.push(' Note: ASC web analytics uses a user-owned web session. If Apple expires it after a few hours, refresh it with `asc web auth login`; API-key ASC auth cannot replace this web session.');
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"`');
527
682
  }
528
683
  }
529
- lines.push('');
530
- 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.');
531
685
  return `${lines.join('\n')}\n`;
532
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
+ }
533
832
  async function writeConnectorHealthAlert(runtimeDir, message, statusPayload, unhealthyConnectors, fingerprint) {
534
833
  const alertDir = path.join(runtimeDir, 'connector-health');
535
834
  await ensureDir(alertDir);
@@ -544,20 +843,43 @@ async function writeConnectorHealthAlert(runtimeDir, message, statusPayload, unh
544
843
  }, null, 2), 'utf8');
545
844
  return { markdownPath, jsonPath };
546
845
  }
547
- function getConnectorHealthChannels(config) {
548
- const configuredChannels = Array.isArray(config?.notifications?.connectorHealth?.channels)
549
- ? config.notifications.connectorHealth.channels.filter((channel) => channel?.enabled !== false)
550
- : [];
551
- if (configuredChannels.length > 0)
552
- return configuredChannels;
846
+ function notificationChannelKey(channel) {
847
+ const type = String(channel?.type || 'openclaw-chat');
848
+ if (type === 'openclaw-chat')
849
+ return 'openclaw-chat';
850
+ if (type === 'slack')
851
+ return `slack:${channel?.label || channel?.webhookEnv || 'slack'}`;
852
+ if (type === 'webhook')
853
+ return `webhook:${channel?.label || channel?.urlEnv || channel?.webhookEnv || 'webhook'}`;
854
+ if (type === 'command')
855
+ return `command:${channel?.label || channel?.command || 'command'}`;
856
+ return `${type}:${channel?.label || type}`;
857
+ }
858
+ function mergeNotificationChannelsWithDeliveries(configuredChannels, deliveryChannels) {
859
+ const configured = Array.isArray(configuredChannels) ? configuredChannels : [];
860
+ const seen = new Set(configured.map((channel) => notificationChannelKey(channel)));
861
+ const channels = configured.filter((channel) => channel?.enabled !== false);
862
+ for (const channel of deliveryChannels) {
863
+ if (!seen.has(notificationChannelKey(channel))) {
864
+ channels.push(channel);
865
+ }
866
+ }
867
+ return channels;
868
+ }
869
+ function getDeliveryNotificationChannels(config, kind) {
553
870
  const channels = [];
554
871
  const deliveries = config?.deliveries || {};
555
872
  if (deliveries.openclawChat?.enabled) {
873
+ const isConnectorHealth = kind === 'connectorHealth';
556
874
  channels.push({
557
875
  type: 'openclaw-chat',
558
876
  label: 'openclaw_chat',
559
- markdownPath: deliveries.openclawChat.connectorHealthMarkdownPath || deliveries.openclawChat.markdownPath,
560
- jsonPath: deliveries.openclawChat.connectorHealthJsonPath || deliveries.openclawChat.jsonPath,
877
+ markdownPath: isConnectorHealth
878
+ ? deliveries.openclawChat.connectorHealthMarkdownPath || deliveries.openclawChat.markdownPath
879
+ : deliveries.openclawChat.growthRunMarkdownPath || '.openclaw/chat/growth-summary.md',
880
+ jsonPath: isConnectorHealth
881
+ ? deliveries.openclawChat.connectorHealthJsonPath || deliveries.openclawChat.jsonPath
882
+ : deliveries.openclawChat.growthRunJsonPath || '.openclaw/chat/growth-summary.json',
561
883
  });
562
884
  }
563
885
  if (deliveries.slack?.enabled) {
@@ -576,19 +898,37 @@ function getConnectorHealthChannels(config) {
576
898
  headers: deliveries.webhook.headers || {},
577
899
  });
578
900
  }
901
+ if (deliveries.command?.enabled) {
902
+ channels.push({
903
+ type: 'command',
904
+ label: deliveries.command.label || 'command',
905
+ command: deliveries.command.command || '',
906
+ });
907
+ }
579
908
  if (deliveries.discord?.enabled) {
580
909
  channels.push({
581
910
  type: 'command',
582
- label: 'discord',
583
- command: deliveries.discord.command || 'node scripts/discord-openclaw-bridge.mjs send --stdin',
911
+ label: deliveries.discord.label || 'discord',
912
+ command: deliveries.discord.command || '',
584
913
  });
585
914
  }
586
915
  return channels;
587
916
  }
917
+ function getConnectorHealthChannels(config) {
918
+ const configuredChannels = Array.isArray(config?.notifications?.connectorHealth?.channels)
919
+ ? config.notifications.connectorHealth.channels
920
+ : [];
921
+ return mergeNotificationChannelsWithDeliveries(configuredChannels, getDeliveryNotificationChannels(config, 'connectorHealth'));
922
+ }
923
+ function resolveOpenClawChatDeliveryPath(channelPath, fallbackPath) {
924
+ const targetPath = String(channelPath || fallbackPath || '').trim();
925
+ if (!targetPath)
926
+ return path.resolve(process.cwd(), fallbackPath);
927
+ return path.isAbsolute(targetPath) ? targetPath : path.resolve(process.cwd(), targetPath);
928
+ }
588
929
  async function writeConfiguredOpenClawChatAlert(configPath, channel, message, statusPayload, unhealthyConnectors, fingerprint) {
589
- const baseDir = path.dirname(path.resolve(configPath));
590
- const markdownPath = path.resolve(baseDir, channel.markdownPath || '.openclaw/chat/connector-health.md');
591
- const jsonPath = path.resolve(baseDir, channel.jsonPath || '.openclaw/chat/connector-health.json');
930
+ const markdownPath = resolveOpenClawChatDeliveryPath(channel.markdownPath, '.openclaw/chat/connector-health.md');
931
+ const jsonPath = resolveOpenClawChatDeliveryPath(channel.jsonPath, '.openclaw/chat/connector-health.json');
592
932
  await fs.mkdir(path.dirname(markdownPath), { recursive: true });
593
933
  await fs.mkdir(path.dirname(jsonPath), { recursive: true });
594
934
  await fs.writeFile(markdownPath, message, 'utf8');
@@ -601,8 +941,9 @@ async function writeConfiguredOpenClawChatAlert(configPath, channel, message, st
601
941
  }, null, 2), 'utf8');
602
942
  return {
603
943
  sent: true,
944
+ external: false,
604
945
  target: channel.label || 'openclaw_chat',
605
- detail: `wrote ${markdownPath} and ${jsonPath}`,
946
+ detail: `wrote local OpenClaw chat outbox ${markdownPath} and ${jsonPath}`,
606
947
  };
607
948
  }
608
949
  async function sendSlackConnectorHealthAlert(channel, message) {
@@ -618,6 +959,7 @@ async function sendSlackConnectorHealthAlert(channel, message) {
618
959
  });
619
960
  return {
620
961
  sent: response.ok,
962
+ external: true,
621
963
  target: channel.label || 'slack',
622
964
  detail: response.ok ? `HTTP ${response.status}` : `HTTP ${response.status}: ${await response.text()}`,
623
965
  };
@@ -645,6 +987,7 @@ async function sendWebhookConnectorHealthAlert(channel, message, statusPayload,
645
987
  });
646
988
  return {
647
989
  sent: response.ok,
990
+ external: true,
648
991
  target: channel.label || 'webhook',
649
992
  detail: response.ok ? `HTTP ${response.status}` : `HTTP ${response.status}: ${await response.text()}`,
650
993
  };
@@ -656,10 +999,49 @@ async function sendCommandConnectorHealthAlert(channel, message) {
656
999
  const result = await runShellCommand(String(channel.command), 60_000, { input: message });
657
1000
  return {
658
1001
  sent: result.ok,
1002
+ external: true,
659
1003
  target: channel.label || 'command',
660
1004
  detail: result.ok ? result.stdout.trim() : result.stderr.trim() || result.stdout.trim() || `exit ${result.code}`,
661
1005
  };
662
1006
  }
1007
+ function hasExternalNotificationChannel(channels) {
1008
+ return channels.some((channel) => channel?.type && channel.type !== 'openclaw-chat');
1009
+ }
1010
+ function hasSuccessfulExternalDelivery(results) {
1011
+ return results.some((result) => result?.sent === true && result?.external === true);
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
+ }
663
1045
  async function deliverConnectorHealthAlert({ config, configPath, message, statusPayload, unhealthyConnectors, fingerprint }) {
664
1046
  const channels = getConnectorHealthChannels(config);
665
1047
  if (config?.notifications?.connectorHealth?.enabled === false) {
@@ -695,55 +1077,76 @@ async function deliverConnectorHealthAlert({ config, configPath, message, status
695
1077
  });
696
1078
  }
697
1079
  }
1080
+ if (!hasSuccessfulExternalDelivery(results)) {
1081
+ results.push({
1082
+ sent: false,
1083
+ external: true,
1084
+ target: 'external_notification',
1085
+ detail: hasExternalNotificationChannel(channels)
1086
+ ? 'No external notification channel successfully sent the alert.'
1087
+ : 'Alert written locally, but no external notification channel configured.',
1088
+ });
1089
+ }
698
1090
  return results;
699
1091
  }
700
1092
  function getGrowthRunChannels(config) {
701
1093
  const configuredChannels = Array.isArray(config?.notifications?.growthRun?.channels)
702
- ? config.notifications.growthRun.channels.filter((channel) => channel?.enabled !== false)
1094
+ ? config.notifications.growthRun.channels
703
1095
  : [];
704
- if (configuredChannels.length > 0)
705
- return configuredChannels;
706
- const channels = [];
707
- const deliveries = config?.deliveries || {};
708
- if (deliveries.openclawChat?.enabled) {
709
- channels.push({
710
- type: 'openclaw-chat',
711
- label: 'openclaw_chat',
712
- markdownPath: deliveries.openclawChat.growthRunMarkdownPath || '.openclaw/chat/growth-summary.md',
713
- jsonPath: deliveries.openclawChat.growthRunJsonPath || '.openclaw/chat/growth-summary.json',
714
- });
715
- }
716
- if (deliveries.slack?.enabled) {
717
- channels.push({
718
- type: 'slack',
719
- label: 'slack',
720
- webhookEnv: deliveries.slack.webhookEnv || 'SLACK_WEBHOOK_URL',
721
- });
722
- }
723
- if (deliveries.webhook?.enabled) {
724
- channels.push({
725
- type: 'webhook',
726
- label: 'webhook',
727
- urlEnv: deliveries.webhook.urlEnv || 'OPENCLAW_WEBHOOK_URL',
728
- method: deliveries.webhook.method || 'POST',
729
- headers: deliveries.webhook.headers || {},
730
- });
1096
+ return mergeNotificationChannelsWithDeliveries(configuredChannels, getDeliveryNotificationChannels(config, 'growthRun'));
1097
+ }
1098
+ async function readChartAttachments(chartManifestPath) {
1099
+ if (!chartManifestPath)
1100
+ return [];
1101
+ try {
1102
+ const manifest = await readJson(chartManifestPath);
1103
+ return Array.isArray(manifest?.charts)
1104
+ ? manifest.charts
1105
+ .map((chart) => ({
1106
+ signalId: String(chart.signal_id || chart.signalId || '').trim(),
1107
+ filePath: String(chart.file_path || chart.filePath || '').trim(),
1108
+ caption: String(chart.caption || chart.title || 'Data chart').trim(),
1109
+ }))
1110
+ .filter((chart) => chart.filePath)
1111
+ : [];
731
1112
  }
732
- if (deliveries.discord?.enabled) {
733
- channels.push({
734
- type: 'command',
735
- label: 'discord',
736
- command: deliveries.discord.command || 'node scripts/discord-openclaw-bridge.mjs send --stdin',
737
- });
1113
+ catch {
1114
+ return [];
738
1115
  }
739
- return channels;
740
1116
  }
741
- function buildGrowthRunSummaryMessage({ issuesPayload, activeCadences, sourceFiles, createdGitHubArtifact }) {
1117
+ function buildGrowthRunSummaryMessage({ issuesPayload, activeCadences, sourceFiles, createdGitHubArtifact, charts = [] }) {
742
1118
  const issueCount = Number(issuesPayload?.issue_count || 0);
743
1119
  const cadenceNames = activeCadences.length > 0
744
1120
  ? activeCadences.map((cadence) => cadence.title || cadence.key).join(', ')
745
1121
  : 'ad-hoc growth pass';
746
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
+ }
747
1150
  const lines = [
748
1151
  `OpenClaw Growth run finished (${new Date().toISOString()}).`,
749
1152
  `Cadence: ${cadenceNames}`,
@@ -756,22 +1159,37 @@ function buildGrowthRunSummaryMessage({ issuesPayload, activeCadences, sourceFil
756
1159
  if (createdGitHubArtifact) {
757
1160
  lines.push('GitHub artifact creation was attempted for the generated proposals.');
758
1161
  }
759
- const issues = Array.isArray(issuesPayload?.issues) ? issuesPayload.issues.slice(0, 3) : [];
760
- if (issues.length > 0) {
1162
+ if (charts.length > 0) {
1163
+ lines.push(`Charts generated: ${charts.length}`);
1164
+ for (const chart of charts.slice(0, 5)) {
1165
+ lines.push(`- ${chart.caption}: ${chart.filePath}`);
1166
+ }
1167
+ }
1168
+ const topIssues = issues.slice(0, isDeepAnalysisCadence(activeCadences) ? 5 : 3);
1169
+ if (topIssues.length > 0) {
761
1170
  lines.push('');
762
- lines.push('Top findings:');
763
- 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) {
764
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
+ }
765
1182
  }
766
1183
  }
767
1184
  lines.push('');
768
- 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.');
769
1188
  return `${lines.join('\n')}\n`;
770
1189
  }
771
- async function writeConfiguredOpenClawChatGrowthSummary(configPath, channel, message, issuesPayload, activeCadences, fingerprint) {
772
- const baseDir = path.dirname(path.resolve(configPath));
773
- const markdownPath = path.resolve(baseDir, channel.markdownPath || '.openclaw/chat/growth-summary.md');
774
- const jsonPath = path.resolve(baseDir, channel.jsonPath || '.openclaw/chat/growth-summary.json');
1190
+ async function writeConfiguredOpenClawChatGrowthSummary(configPath, channel, message, issuesPayload, activeCadences, fingerprint, charts) {
1191
+ const markdownPath = resolveOpenClawChatDeliveryPath(channel.markdownPath, '.openclaw/chat/growth-summary.md');
1192
+ const jsonPath = resolveOpenClawChatDeliveryPath(channel.jsonPath, '.openclaw/chat/growth-summary.json');
775
1193
  await fs.mkdir(path.dirname(markdownPath), { recursive: true });
776
1194
  await fs.mkdir(path.dirname(jsonPath), { recursive: true });
777
1195
  await fs.writeFile(markdownPath, message, 'utf8');
@@ -782,11 +1200,18 @@ async function writeConfiguredOpenClawChatGrowthSummary(configPath, channel, mes
782
1200
  activeCadences,
783
1201
  issueCount: Number(issuesPayload?.issue_count || 0),
784
1202
  issues: Array.isArray(issuesPayload?.issues) ? issuesPayload.issues : [],
1203
+ charts,
1204
+ attachments: charts.map((chart) => ({
1205
+ type: 'image/png',
1206
+ path: chart.filePath,
1207
+ caption: chart.caption,
1208
+ })),
785
1209
  }, null, 2), 'utf8');
786
1210
  return {
787
1211
  sent: true,
1212
+ external: false,
788
1213
  target: channel.label || 'openclaw_chat',
789
- detail: `wrote ${markdownPath} and ${jsonPath}`,
1214
+ detail: `wrote local OpenClaw chat outbox ${markdownPath} and ${jsonPath}`,
790
1215
  };
791
1216
  }
792
1217
  async function sendSlackGrowthSummary(channel, message) {
@@ -802,11 +1227,12 @@ async function sendSlackGrowthSummary(channel, message) {
802
1227
  });
803
1228
  return {
804
1229
  sent: response.ok,
1230
+ external: true,
805
1231
  target: channel.label || 'slack',
806
1232
  detail: response.ok ? `HTTP ${response.status}` : `HTTP ${response.status}: ${await response.text()}`,
807
1233
  };
808
1234
  }
809
- async function sendWebhookGrowthSummary(channel, message, issuesPayload, activeCadences, fingerprint) {
1235
+ async function sendWebhookGrowthSummary(channel, message, issuesPayload, activeCadences, fingerprint, charts) {
810
1236
  const urlEnv = channel.urlEnv || channel.webhookEnv || 'OPENCLAW_WEBHOOK_URL';
811
1237
  const webhookUrl = process.env[urlEnv];
812
1238
  if (!webhookUrl) {
@@ -826,10 +1252,17 @@ async function sendWebhookGrowthSummary(channel, message, issuesPayload, activeC
826
1252
  activeCadences,
827
1253
  issueCount: Number(issuesPayload?.issue_count || 0),
828
1254
  issues: Array.isArray(issuesPayload?.issues) ? issuesPayload.issues : [],
1255
+ charts,
1256
+ attachments: charts.map((chart) => ({
1257
+ type: 'image/png',
1258
+ path: chart.filePath,
1259
+ caption: chart.caption,
1260
+ })),
829
1261
  }),
830
1262
  });
831
1263
  return {
832
1264
  sent: response.ok,
1265
+ external: true,
833
1266
  target: channel.label || 'webhook',
834
1267
  detail: response.ok ? `HTTP ${response.status}` : `HTTP ${response.status}: ${await response.text()}`,
835
1268
  };
@@ -841,11 +1274,12 @@ async function sendCommandGrowthSummary(channel, message) {
841
1274
  const result = await runShellCommand(String(channel.command), 60_000, { input: message });
842
1275
  return {
843
1276
  sent: result.ok,
1277
+ external: true,
844
1278
  target: channel.label || 'command',
845
1279
  detail: result.ok ? result.stdout.trim() : result.stderr.trim() || result.stdout.trim() || `exit ${result.code}`,
846
1280
  };
847
1281
  }
848
- async function deliverGrowthRunSummary({ config, configPath, issuesPayload, activeCadences, sourceFiles, fingerprint, createdGitHubArtifact, }) {
1282
+ async function deliverGrowthRunSummary({ config, configPath, issuesPayload, activeCadences, sourceFiles, fingerprint, createdGitHubArtifact, chartManifestPath, }) {
849
1283
  if (config?.notifications?.growthRun?.enabled === false) {
850
1284
  return [{ sent: false, target: 'notifications', detail: 'growth run notifications disabled' }];
851
1285
  }
@@ -853,23 +1287,25 @@ async function deliverGrowthRunSummary({ config, configPath, issuesPayload, acti
853
1287
  if (channels.length === 0) {
854
1288
  return [{ sent: false, target: 'none', detail: 'no growth run notification channels configured' }];
855
1289
  }
1290
+ const charts = await readChartAttachments(chartManifestPath);
856
1291
  const message = buildGrowthRunSummaryMessage({
857
1292
  issuesPayload,
858
1293
  activeCadences,
859
1294
  sourceFiles,
860
1295
  createdGitHubArtifact,
1296
+ charts,
861
1297
  });
862
1298
  const results = [];
863
1299
  for (const channel of channels) {
864
1300
  try {
865
1301
  if (channel.type === 'openclaw-chat') {
866
- results.push(await writeConfiguredOpenClawChatGrowthSummary(configPath, channel, message, issuesPayload, activeCadences, fingerprint));
1302
+ results.push(await writeConfiguredOpenClawChatGrowthSummary(configPath, channel, message, issuesPayload, activeCadences, fingerprint, charts));
867
1303
  }
868
1304
  else if (channel.type === 'slack') {
869
1305
  results.push(await sendSlackGrowthSummary(channel, message));
870
1306
  }
871
1307
  else if (channel.type === 'webhook') {
872
- results.push(await sendWebhookGrowthSummary(channel, message, issuesPayload, activeCadences, fingerprint));
1308
+ results.push(await sendWebhookGrowthSummary(channel, message, issuesPayload, activeCadences, fingerprint, charts));
873
1309
  }
874
1310
  else if (channel.type === 'command') {
875
1311
  results.push(await sendCommandGrowthSummary(channel, message));
@@ -892,6 +1328,16 @@ async function maybeRunConnectorHealthCheck({ config, configPath, state, statePa
892
1328
  const healthState = state?.connectorHealth || {};
893
1329
  const intervalMinutes = getConnectorHealthIntervalMinutes(config);
894
1330
  if (!isDue(healthState.lastCheckedAt, intervalMinutes)) {
1331
+ await appendSchedulerProof('connector_health_not_due', {
1332
+ configPath,
1333
+ statePath,
1334
+ intervalMinutes,
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',
1340
+ });
895
1341
  return state;
896
1342
  }
897
1343
  await ensureDir(runtimeDir);
@@ -917,6 +1363,13 @@ async function maybeRunConnectorHealthCheck({ config, configPath, state, statePa
917
1363
  };
918
1364
  await fs.mkdir(path.dirname(statePath), { recursive: true });
919
1365
  await fs.writeFile(statePath, JSON.stringify(nextState, null, 2), 'utf8');
1366
+ await appendSchedulerProof('connector_health_check_failed', {
1367
+ configPath,
1368
+ statePath,
1369
+ intervalMinutes,
1370
+ checkedAt,
1371
+ error: nextState.connectorHealth.lastError,
1372
+ });
920
1373
  return nextState;
921
1374
  }
922
1375
  const unhealthyConnectors = getUnhealthyConfiguredConnectors(statusPayload);
@@ -930,11 +1383,12 @@ async function maybeRunConnectorHealthCheck({ config, configPath, state, statePa
930
1383
  connectedConnectors,
931
1384
  lastError: null,
932
1385
  };
933
- const previousIncidentFingerprint = healthState.lastStatusOk === false
934
- ? healthState.activeIncidentFingerprint || healthState.lastAlertedFingerprint || null
935
- : null;
1386
+ const previousExternallyDeliveredFingerprint = healthState.lastExternalAlertedFingerprint || null;
1387
+ let alertTriggered = false;
1388
+ let alertDeliveries = [];
936
1389
  if (unhealthyConnectors.length === 0) {
937
1390
  nextHealthState.activeIncidentFingerprint = null;
1391
+ nextHealthState.lastExternalAlertedFingerprint = null;
938
1392
  if (healthState.lastStatusOk === false) {
939
1393
  nextHealthState.lastRecoveredAt = checkedAt;
940
1394
  }
@@ -943,7 +1397,7 @@ async function maybeRunConnectorHealthCheck({ config, configPath, state, statePa
943
1397
  nextHealthState.activeIncidentFingerprint = fingerprint;
944
1398
  }
945
1399
  if (unhealthyConnectors.length > 0 &&
946
- previousIncidentFingerprint !== fingerprint) {
1400
+ previousExternallyDeliveredFingerprint !== fingerprint) {
947
1401
  const message = buildConnectorHealthAlert(statusPayload, unhealthyConnectors);
948
1402
  const paths = await writeConnectorHealthAlert(runtimeDir, message, statusPayload, unhealthyConnectors, fingerprint);
949
1403
  const deliveries = await deliverConnectorHealthAlert({
@@ -954,11 +1408,18 @@ async function maybeRunConnectorHealthCheck({ config, configPath, state, statePa
954
1408
  unhealthyConnectors,
955
1409
  fingerprint,
956
1410
  });
1411
+ alertTriggered = true;
1412
+ alertDeliveries = deliveries;
957
1413
  nextHealthState.lastAlertedAt = checkedAt;
958
1414
  nextHealthState.lastAlertedFingerprint = fingerprint;
959
1415
  nextHealthState.lastAlertMarkdownPath = paths.markdownPath;
960
1416
  nextHealthState.lastAlertJsonPath = paths.jsonPath;
961
1417
  nextHealthState.lastAlertDeliveries = deliveries;
1418
+ nextHealthState.lastAlertExternalSent = hasSuccessfulExternalDelivery(deliveries);
1419
+ if (nextHealthState.lastAlertExternalSent) {
1420
+ nextHealthState.lastExternalAlertedAt = checkedAt;
1421
+ nextHealthState.lastExternalAlertedFingerprint = fingerprint;
1422
+ }
962
1423
  }
963
1424
  const nextState = {
964
1425
  ...state,
@@ -966,6 +1427,40 @@ async function maybeRunConnectorHealthCheck({ config, configPath, state, statePa
966
1427
  };
967
1428
  await fs.mkdir(path.dirname(statePath), { recursive: true });
968
1429
  await fs.writeFile(statePath, JSON.stringify(nextState, null, 2), 'utf8');
1430
+ await appendSchedulerProof('connector_health_checked', {
1431
+ configPath,
1432
+ statePath,
1433
+ intervalMinutes,
1434
+ checkedAt,
1435
+ lastStatusOk: nextHealthState.lastStatusOk,
1436
+ connectedConnectors,
1437
+ unhealthyConnectors: unhealthyConnectors.map((entry) => ({
1438
+ key: entry.key,
1439
+ status: entry.status,
1440
+ detail: entry.detail,
1441
+ })),
1442
+ alertMarkdownPath: nextHealthState.lastAlertMarkdownPath || null,
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',
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
+ }
969
1464
  return nextState;
970
1465
  }
971
1466
  function buildIssueFingerprint(issuesPayload) {
@@ -974,7 +1469,31 @@ function buildIssueFingerprint(issuesPayload) {
974
1469
  : [];
975
1470
  return sha256(titles.join('\n'));
976
1471
  }
977
- async function runAnalyzer({ config, runtimeDir, sourceFiles, createGitHubArtifact, chartManifestPath, cadencePlanPath, }) {
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
+ }
1496
+ async function runAnalyzer({ config, runtimeDir, sourceFiles, createGitHubArtifact, githubArtifactMode = getActionMode(config), chartManifestPath, cadencePlanPath, }) {
978
1497
  await ensureDir(runtimeDir);
979
1498
  if (!sourceFiles.analytics) {
980
1499
  throw new Error('Analytics source is required (enable and configure `sources.analytics`).');
@@ -996,9 +1515,18 @@ async function runAnalyzer({ config, runtimeDir, sourceFiles, createGitHubArtifa
996
1515
  if (sourceFiles.revenuecat) {
997
1516
  args.push('--revenuecat', sourceFiles.revenuecat);
998
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
+ }
999
1524
  if (sourceFiles.sentry) {
1000
1525
  args.push('--sentry', sourceFiles.sentry);
1001
1526
  }
1527
+ if (sourceFiles.coolify) {
1528
+ args.push('--source', `coolify=${sourceFiles.coolify}`);
1529
+ }
1002
1530
  if (sourceFiles.feedback) {
1003
1531
  args.push('--feedback', sourceFiles.feedback);
1004
1532
  }
@@ -1009,8 +1537,8 @@ async function runAnalyzer({ config, runtimeDir, sourceFiles, createGitHubArtifa
1009
1537
  }
1010
1538
  if (createGitHubArtifact) {
1011
1539
  const repo = String(config.project?.githubRepo || '').trim();
1012
- args.push(getActionMode(config) === 'pull_request' ? '--create-pull-requests' : '--create-issues', '--repo', repo);
1013
- if (getActionMode(config) === 'pull_request') {
1540
+ args.push(githubArtifactMode === 'pull_request' ? '--create-pull-requests' : '--create-issues', '--repo', repo);
1541
+ if (githubArtifactMode === 'pull_request') {
1014
1542
  args.push('--allow-proposal-pull-requests');
1015
1543
  }
1016
1544
  const labels = Array.isArray(config.project?.labels) ? config.project.labels : [];
@@ -1042,10 +1570,13 @@ async function runAnalyzer({ config, runtimeDir, sourceFiles, createGitHubArtifa
1042
1570
  analyzerStdout: analyzer.stdout.trim(),
1043
1571
  };
1044
1572
  }
1045
- async function maybeGenerateCharts({ config, payloads, runtimeDir }) {
1573
+ async function maybeGenerateCharts({ config, payloads, runtimeDir, activeCadences }) {
1046
1574
  if (!config.charting?.enabled) {
1047
1575
  return null;
1048
1576
  }
1577
+ if (!isDeepAnalysisCadence(activeCadences)) {
1578
+ return null;
1579
+ }
1049
1580
  const analyticsPayload = payloads.analytics;
1050
1581
  if (!analyticsPayload) {
1051
1582
  return null;
@@ -1114,22 +1645,48 @@ function resolveCursorAwareCommand(command, sourceConfig, cursorState) {
1114
1645
  const lookback = normalizeLookback(sourceConfig?.initialLookback, '30d');
1115
1646
  return `${rawCommand} --last ${quote(lookback)}`;
1116
1647
  }
1117
- async function resolveSourcePayloadWithCursor(sourceConfig, sourceName, cursorState, commandCwd = process.cwd()) {
1648
+ async function resolveSourcePayloadWithCursor(sourceConfig, sourceName, cursorState, commandCwd = process.cwd(), configPath = null) {
1118
1649
  if (!sourceConfig || sourceConfig.enabled === false) {
1119
1650
  return {
1120
1651
  payload: null,
1121
1652
  nextCursor: cursorState || null,
1122
1653
  resolvedCommand: null,
1654
+ failure: null,
1123
1655
  };
1124
1656
  }
1125
1657
  if (sourceConfig.mode === 'command') {
1126
1658
  if (!sourceConfig.command) {
1127
1659
  throw new Error(`Source "${sourceName}" has mode=command but no command configured.`);
1128
1660
  }
1129
- const resolvedCommand = resolveCursorAwareCommand(sourceConfig.command, sourceConfig, cursorState);
1130
- const result = await runShellCommand(String(resolvedCommand), 120_000, { cwd: commandCwd });
1661
+ const resolvedCommand = resolveCursorAwareCommand(withActiveConfigArg(replaceLegacyRuntimeScriptCommand(sourceConfig.command), configPath), sourceConfig, cursorState);
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
+ }
1131
1669
  if (!result.ok) {
1132
- 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}`);
1133
1690
  }
1134
1691
  const fetchedAt = new Date().toISOString();
1135
1692
  try {
@@ -1140,9 +1697,11 @@ async function resolveSourcePayloadWithCursor(sourceConfig, sourceName, cursorSt
1140
1697
  lastCollectedAt: fetchedAt,
1141
1698
  updatedAt: fetchedAt,
1142
1699
  lastCommand: resolvedCommand,
1700
+ lastRetriedTransientFailureAt: retried ? fetchedAt : null,
1143
1701
  }
1144
1702
  : cursorState || null,
1145
1703
  resolvedCommand,
1704
+ failure: null,
1146
1705
  };
1147
1706
  }
1148
1707
  catch {
@@ -1156,15 +1715,36 @@ async function resolveSourcePayloadWithCursor(sourceConfig, sourceName, cursorSt
1156
1715
  payload: await readJson(path.resolve(String(sourceConfig.path))),
1157
1716
  nextCursor: cursorState || null,
1158
1717
  resolvedCommand: null,
1718
+ failure: null,
1159
1719
  };
1160
1720
  }
1161
- async function loadSourcePayloads(config, state) {
1721
+ async function loadSourcePayloads(config, state, configPath) {
1162
1722
  const payloads = {};
1163
1723
  const sourceCursors = { ...(state?.sourceCursors || {}) };
1724
+ const sourceFailures = [];
1164
1725
  const commandCwd = getProjectCommandCwd(config);
1165
1726
  for (const source of getAllSourceEntries(config)) {
1166
1727
  const currentCursor = sourceCursors[source.key] || null;
1167
- const result = await resolveSourcePayloadWithCursor(source, source.key, currentCursor, commandCwd);
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
+ }
1168
1748
  const payload = result.payload;
1169
1749
  if (payload) {
1170
1750
  payloads[source.key] = payload;
@@ -1172,10 +1752,22 @@ async function loadSourcePayloads(config, state) {
1172
1752
  if (result.nextCursor) {
1173
1753
  sourceCursors[source.key] = result.nextCursor;
1174
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
+ }
1175
1766
  }
1176
1767
  return {
1177
1768
  payloads,
1178
1769
  sourceCursors,
1770
+ sourceFailures,
1179
1771
  };
1180
1772
  }
1181
1773
  async function materializeSourceFiles(config, payloads, runtimeDir) {
@@ -1202,7 +1794,26 @@ function hasSourceChanges(previousHashes, currentHashes) {
1202
1794
  return false;
1203
1795
  }
1204
1796
  async function runOnce(configPath, statePath) {
1797
+ await appendSchedulerProof('runner_invoked', {
1798
+ configPath,
1799
+ statePath,
1800
+ argv: process.argv.slice(2),
1801
+ });
1205
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
+ }
1206
1817
  await applyOpenClawSecretRefs(config);
1207
1818
  const inferredGitHubRepo = await inferGitHubRepo(config);
1208
1819
  if (inferredGitHubRepo) {
@@ -1218,7 +1829,7 @@ async function runOnce(configPath, statePath) {
1218
1829
  lastRunAt: null,
1219
1830
  sourceCursors: {},
1220
1831
  });
1221
- const runtimeDir = path.resolve(DEFAULT_RUNTIME_DIR);
1832
+ const runtimeDir = path.resolve(deriveRuntimeDirFromStatePath(statePath));
1222
1833
  const stateAfterHealthCheck = await maybeRunConnectorHealthCheck({
1223
1834
  config,
1224
1835
  configPath,
@@ -1227,22 +1838,41 @@ async function runOnce(configPath, statePath) {
1227
1838
  runtimeDir,
1228
1839
  });
1229
1840
  const activeCadences = getDueCadences(config, stateAfterHealthCheck);
1230
- const { payloads, sourceCursors } = await loadSourcePayloads(config, stateAfterHealthCheck);
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
+ });
1231
1850
  const currentHashes = computeSourceHashes(payloads);
1232
- const changed = hasSourceChanges(stateAfterHealthCheck.sourceHashes, currentHashes);
1233
- if (!changed && config.schedule?.skipIfNoDataChange !== false) {
1234
- 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`);
1853
+ const completedAt = new Date().toISOString();
1235
1854
  await fs.mkdir(path.dirname(statePath), { recursive: true });
1236
1855
  await fs.writeFile(statePath, JSON.stringify({
1237
1856
  ...stateAfterHealthCheck,
1857
+ ...stateAfterSourceCollection,
1238
1858
  sourceHashes: currentHashes,
1239
1859
  sourceCursors,
1240
- lastRunAt: new Date().toISOString(),
1241
- skippedReason: 'no_data_change',
1860
+ lastSourceFailures: sourceFailures,
1861
+ lastRunAt: completedAt,
1862
+ skippedReason: 'cadence_not_due',
1242
1863
  }, null, 2), 'utf8');
1864
+ await appendSchedulerProof('runner_completed', {
1865
+ configPath,
1866
+ statePath,
1867
+ completedAt,
1868
+ skippedReason: 'cadence_not_due',
1869
+ sourceFailures,
1870
+ socialOutput: 'HEARTBEAT_OK',
1871
+ });
1243
1872
  return;
1244
1873
  }
1245
- const createGitHubArtifact = shouldAutoCreateGitHubArtifact(config) && Boolean(String(config.project?.githubRepo || '').trim());
1874
+ const githubArtifactModes = getGitHubArtifactModes(config).filter((mode) => shouldAutoCreateGitHubArtifact(config, mode));
1875
+ const createGitHubArtifact = githubArtifactModes.length > 0 && Boolean(String(config.project?.githubRepo || '').trim());
1246
1876
  const sourceFiles = await materializeSourceFiles(config, payloads, runtimeDir);
1247
1877
  const cadencePlanPath = path.join(runtimeDir, 'cadence-plan.json');
1248
1878
  await fs.writeFile(cadencePlanPath, JSON.stringify({
@@ -1253,6 +1883,7 @@ async function runOnce(configPath, statePath) {
1253
1883
  config,
1254
1884
  payloads,
1255
1885
  runtimeDir,
1886
+ activeCadences,
1256
1887
  });
1257
1888
  const dryRun = await runAnalyzer({
1258
1889
  config,
@@ -1263,46 +1894,78 @@ async function runOnce(configPath, statePath) {
1263
1894
  cadencePlanPath,
1264
1895
  });
1265
1896
  const issueFingerprint = buildIssueFingerprint(dryRun.issuesPayload);
1266
- const unchangedIssueSet = issueFingerprint === stateAfterHealthCheck.lastIssueFingerprint;
1267
- if (unchangedIssueSet && config.schedule?.skipIfIssueSetUnchanged !== false) {
1268
- 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`);
1901
+ const completedAt = new Date().toISOString();
1269
1902
  await fs.mkdir(path.dirname(statePath), { recursive: true });
1270
1903
  await fs.writeFile(statePath, JSON.stringify({
1271
1904
  ...stateAfterHealthCheck,
1905
+ ...stateAfterSourceCollection,
1272
1906
  sourceHashes: currentHashes,
1273
1907
  sourceCursors,
1908
+ lastSourceFailures: sourceFailures,
1274
1909
  lastIssueFingerprint: issueFingerprint,
1275
- lastRunAt: new Date().toISOString(),
1910
+ lastRunAt: completedAt,
1276
1911
  lastOutFile: dryRun.outFile,
1277
- cadences: markCadencesRan(stateAfterHealthCheck, activeCadences, new Date().toISOString()),
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
+ ],
1278
1920
  skippedReason: 'issue_set_unchanged',
1279
1921
  }, null, 2), 'utf8');
1922
+ await appendSchedulerProof('runner_completed', {
1923
+ configPath,
1924
+ statePath,
1925
+ completedAt,
1926
+ skippedReason: 'issue_set_unchanged',
1927
+ activeCadences: activeCadences.map((cadence) => cadence.key),
1928
+ outFile: dryRun.outFile,
1929
+ issueCount: Number(dryRun.issuesPayload?.issue_count || 0),
1930
+ sourceFailures,
1931
+ externalGrowthNotification: 'suppressed_unchanged_issue_set',
1932
+ socialOutput: 'HEARTBEAT_OK',
1933
+ });
1280
1934
  return;
1281
1935
  }
1282
- 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;
1283
1940
  if (shouldCreateGitHubArtifact) {
1284
- await runAnalyzer({
1285
- config,
1286
- runtimeDir,
1287
- sourceFiles,
1288
- createGitHubArtifact: true,
1289
- chartManifestPath,
1290
- cadencePlanPath,
1291
- });
1292
- process.stdout.write(`[${new Date().toISOString()}] Created GitHub ${getActionMode(config) === 'pull_request' ? 'pull requests' : 'issues'}.\n`);
1941
+ for (const githubArtifactMode of githubArtifactModes) {
1942
+ await runAnalyzer({
1943
+ config,
1944
+ runtimeDir,
1945
+ sourceFiles,
1946
+ createGitHubArtifact: true,
1947
+ githubArtifactMode,
1948
+ chartManifestPath,
1949
+ cadencePlanPath,
1950
+ });
1951
+ }
1952
+ process.stdout.write(`[${new Date().toISOString()}] Created GitHub ${githubArtifactModes.map((mode) => (mode === 'pull_request' ? 'pull requests' : 'issues')).join(' and ')}.\n`);
1293
1953
  }
1294
1954
  else {
1295
1955
  process.stdout.write(`[${new Date().toISOString()}] Drafts generated only (${getActionMode(config)} auto-create disabled).\n`);
1296
1956
  }
1957
+ const completedAt = new Date().toISOString();
1297
1958
  await fs.mkdir(path.dirname(statePath), { recursive: true });
1298
1959
  await fs.writeFile(statePath, JSON.stringify({
1299
1960
  ...stateAfterHealthCheck,
1961
+ ...stateAfterSourceCollection,
1300
1962
  sourceHashes: currentHashes,
1301
1963
  sourceCursors,
1964
+ lastSourceFailures: sourceFailures,
1302
1965
  lastIssueFingerprint: issueFingerprint,
1303
- lastRunAt: new Date().toISOString(),
1966
+ lastRunAt: completedAt,
1304
1967
  lastOutFile: dryRun.outFile,
1305
- cadences: markCadencesRan(stateAfterHealthCheck, activeCadences, new Date().toISOString()),
1968
+ cadences: markCadencesRan(stateAfterSourceCollection, activeCadences, completedAt),
1306
1969
  lastGrowthRunNotifications: await deliverGrowthRunSummary({
1307
1970
  config,
1308
1971
  configPath,
@@ -1311,9 +1974,21 @@ async function runOnce(configPath, statePath) {
1311
1974
  sourceFiles,
1312
1975
  fingerprint: issueFingerprint,
1313
1976
  createdGitHubArtifact: shouldCreateGitHubArtifact,
1977
+ chartManifestPath,
1314
1978
  }),
1315
1979
  skippedReason: null,
1316
1980
  }, null, 2), 'utf8');
1981
+ await appendSchedulerProof('runner_completed', {
1982
+ configPath,
1983
+ statePath,
1984
+ completedAt,
1985
+ skippedReason: null,
1986
+ activeCadences: activeCadences.map((cadence) => cadence.key),
1987
+ outFile: dryRun.outFile,
1988
+ issueCount: Number(dryRun.issuesPayload?.issue_count || 0),
1989
+ sourceFailures,
1990
+ createdGitHubArtifact: shouldCreateGitHubArtifact,
1991
+ });
1317
1992
  }
1318
1993
  async function main() {
1319
1994
  await loadOpenClawGrowthSecrets();
@@ -1321,6 +1996,7 @@ async function main() {
1321
1996
  await maybeSelfUpdateFromClawHub(args);
1322
1997
  const configPath = path.resolve(args.config);
1323
1998
  const statePath = path.resolve(args.state);
1999
+ useSchedulerProofPathForStatePath(statePath);
1324
2000
  if (!args.loop) {
1325
2001
  await runOnce(configPath, statePath);
1326
2002
  return;
@@ -1334,12 +2010,21 @@ async function main() {
1334
2010
  await runOnce(configPath, statePath);
1335
2011
  }
1336
2012
  catch (error) {
2013
+ await appendSchedulerProof('runner_failed', {
2014
+ configPath,
2015
+ statePath,
2016
+ error: error instanceof Error ? error.message : String(error),
2017
+ }).catch(() => { });
1337
2018
  process.stderr.write(`[${new Date().toISOString()}] Run failed: ${error instanceof Error ? error.message : String(error)}\n`);
1338
2019
  }
1339
2020
  await sleep(intervalMinutes * 60_000);
1340
2021
  }
1341
2022
  }
1342
- main().catch((error) => {
2023
+ main().catch(async (error) => {
2024
+ await appendSchedulerProof('runner_failed', {
2025
+ error: error instanceof Error ? error.message : String(error),
2026
+ argv: process.argv.slice(2),
2027
+ }).catch(() => { });
1343
2028
  process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
1344
2029
  process.exitCode = 1;
1345
2030
  });