@analyticscli/growth-engineer 0.1.0-preview.8 → 0.1.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 +847 -150
  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 +816 -41
  34. package/dist/runtime/openclaw-growth-start.mjs.map +1 -1
  35. package/dist/runtime/openclaw-growth-status.mjs +100 -34
  36. package/dist/runtime/openclaw-growth-status.mjs.map +1 -1
  37. package/dist/runtime/openclaw-growth-wizard.mjs +1997 -226
  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
@@ -4,63 +4,76 @@ import path from 'node:path';
4
4
  import process from 'node:process';
5
5
  import { createHash } from 'node:crypto';
6
6
  import { spawn } from 'node:child_process';
7
- import { getActionMode, getAllSourceEntries, getGitHubRequirementText, shouldAutoCreateGitHubArtifact, } from './openclaw-growth-shared.mjs';
7
+ import { fileURLToPath } from 'node:url';
8
+ import { deriveRuntimeDirFromStatePath, deriveSchedulerProofPathFromStatePath, getActionMode, getAllSourceEntries, getGitHubArtifactModes, getGitHubRequirementText, repairOpenClawCronDeliveryStore, shouldAutoCreateGitHubArtifact, } from './openclaw-growth-shared.mjs';
8
9
  import { applyOpenClawSecretRefs, loadOpenClawGrowthSecrets } from './openclaw-growth-env.mjs';
9
10
  const DEFAULT_CONFIG_PATH = 'data/openclaw-growth-engineer/config.json';
10
11
  const DEFAULT_STATE_PATH = 'data/openclaw-growth-engineer/state.json';
11
- const DEFAULT_RUNTIME_DIR = 'data/openclaw-growth-engineer/runtime';
12
+ const DEFAULT_SCHEDULER_PROOF_PATH = 'data/openclaw-growth-engineer/runtime/scheduler-proof.jsonl';
12
13
  const DEFAULT_CONNECTOR_HEALTH_INTERVAL_MINUTES = 360;
13
14
  const SELF_UPDATE_INTERVAL_MS = 24 * 60 * 60 * 1000;
15
+ const RUNTIME_DIR = path.dirname(fileURLToPath(import.meta.url));
16
+ let schedulerProofPath = path.resolve(DEFAULT_SCHEDULER_PROOF_PATH);
14
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
+ },
15
28
  {
16
29
  key: 'daily',
17
- title: 'Daily Sentry and production guardrail',
30
+ title: 'Daily behavioral anomaly guardrail',
18
31
  intervalDays: 1,
19
32
  criticalOnly: true,
20
- focusAreas: ['sentry_errors', 'crash', 'onboarding', 'conversion', 'paywall', 'purchase'],
21
- sourcePriorities: ['sentry', 'glitchtip', 'analytics', 'revenuecat', 'asc_cli', 'feedback', 'github'],
22
- 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.',
23
- 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.',
24
37
  },
25
38
  {
26
39
  key: 'weekly',
27
40
  title: 'Weekly executive product and growth summary',
28
41
  intervalDays: 7,
29
42
  criticalOnly: false,
30
- focusAreas: ['conversion', 'paywall', 'onboarding', 'marketing', 'retention', 'stability'],
31
- sourcePriorities: ['analytics', 'revenuecat', 'asc_cli', 'feedback', 'sentry', 'github'],
32
- objective: 'Create an executive summary across all configured projects, connectors, recent releases, code changes, revenue, activation, retention, reviews, and production stability.',
33
- 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.',
34
47
  },
35
48
  {
36
49
  key: 'monthly',
37
50
  title: 'Monthly deep product, business, and code review',
38
51
  intervalDays: 30,
39
52
  criticalOnly: false,
40
- focusAreas: ['conversion', 'paywall', 'retention', 'marketing', 'onboarding', 'codebase'],
41
- sourcePriorities: ['analytics', 'revenuecat', 'asc_cli', 'feedback', 'sentry', 'github'],
42
- 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.',
43
- 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.',
44
57
  },
45
58
  {
46
59
  key: 'quarterly',
47
- title: 'Quarterly positioning, pricing, and roadmap review',
60
+ title: '3-month positioning, pricing, and roadmap review',
48
61
  intervalDays: 91,
49
62
  criticalOnly: false,
50
63
  focusAreas: ['marketing', 'paywall', 'retention', 'conversion', 'onboarding'],
51
- sourcePriorities: ['analytics', 'revenuecat', 'asc_cli', 'feedback', 'github', 'sentry'],
52
- objective: 'Revisit positioning, pricing/packaging, onboarding architecture, roadmap assumptions, tracking quality, codebase constraints, and major funnel bets across every configured project.',
53
- 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.',
54
67
  },
55
68
  {
56
69
  key: 'six_months',
57
70
  title: 'Six-month instrumentation and growth-system audit',
58
71
  intervalDays: 182,
59
72
  criticalOnly: false,
60
- focusAreas: ['retention', 'conversion', 'paywall', 'marketing', 'general'],
61
- 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'],
62
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.',
63
- 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.',
64
77
  },
65
78
  {
66
79
  key: 'yearly',
@@ -68,7 +81,7 @@ const DEFAULT_CADENCES = [
68
81
  intervalDays: 365,
69
82
  criticalOnly: false,
70
83
  focusAreas: ['marketing', 'retention', 'paywall', 'conversion', 'general'],
71
- sourcePriorities: ['analytics', 'revenuecat', 'asc_cli', 'feedback', 'sentry'],
84
+ sourcePriorities: ['analytics', 'revenuecat', 'paddle', 'seo', 'asc_cli', 'feedback', 'sentry'],
72
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.',
73
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.',
74
87
  },
@@ -127,6 +140,50 @@ Default state: ${DEFAULT_STATE_PATH}
127
140
  `);
128
141
  process.exit(exitCode);
129
142
  }
143
+ function resolveRuntimeScriptPath(scriptName) {
144
+ const candidates = [
145
+ path.join(RUNTIME_DIR, scriptName),
146
+ path.resolve('scripts', scriptName),
147
+ path.resolve('skills/openclaw-growth-engineer/scripts', scriptName),
148
+ ];
149
+ return candidates.find((candidate) => existsSync(candidate)) || path.join(RUNTIME_DIR, scriptName);
150
+ }
151
+ function nodeRuntimeScriptCommand(scriptName) {
152
+ return `node ${quote(resolveRuntimeScriptPath(scriptName))}`;
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
+ }
130
187
  async function readJson(filePath) {
131
188
  const raw = await fs.readFile(filePath, 'utf8');
132
189
  return JSON.parse(raw);
@@ -142,6 +199,22 @@ async function readJsonOptional(filePath, fallback) {
142
199
  async function ensureDir(dirPath) {
143
200
  await fs.mkdir(dirPath, { recursive: true });
144
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
+ }
145
218
  function sha256(input) {
146
219
  return createHash('sha256').update(input).digest('hex');
147
220
  }
@@ -151,6 +224,33 @@ function stableStringify(value) {
151
224
  function sleep(ms) {
152
225
  return new Promise((resolve) => setTimeout(resolve, ms));
153
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
+ }
154
254
  function isTruthyEnv(value) {
155
255
  return ['1', 'true', 'yes', 'y', 'on'].includes(String(value || '').trim().toLowerCase());
156
256
  }
@@ -296,11 +396,24 @@ function resolveShellCommand() {
296
396
  }
297
397
  return 'sh';
298
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
+ }
299
405
  function runShellCommand(command, timeoutMs = 120_000, options = {}) {
300
406
  return new Promise((resolve) => {
301
- const child = spawn(resolveShellCommand(), ['-c', command], {
407
+ const hardenedCommand = hardenUnattendedShellCommand(command);
408
+ const child = spawn(resolveShellCommand(), ['-c', hardenedCommand], {
302
409
  stdio: options.input === undefined ? ['ignore', 'pipe', 'pipe'] : ['pipe', 'pipe', 'pipe'],
303
410
  cwd: options.cwd,
411
+ env: {
412
+ ...process.env,
413
+ DEBIAN_FRONTEND: 'noninteractive',
414
+ SUDO_ASKPASS: '/bin/false',
415
+ SUDO_PROMPT: '',
416
+ },
304
417
  });
305
418
  let stdout = '';
306
419
  let stderr = '';
@@ -317,6 +430,17 @@ function runShellCommand(command, timeoutMs = 120_000, options = {}) {
317
430
  });
318
431
  child.stderr.on('data', (chunk) => {
319
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
+ }
320
444
  });
321
445
  if (options.input !== undefined) {
322
446
  child.stdin.end(options.input);
@@ -435,6 +559,15 @@ function getCadenceDefinitions(config) {
435
559
  }
436
560
  function cadenceIsDue(cadence, state) {
437
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
+ }
438
571
  const intervalDays = Number(cadence.intervalDays || 1);
439
572
  if (!lastRanAt)
440
573
  return true;
@@ -444,11 +577,7 @@ function cadenceIsDue(cadence, state) {
444
577
  return Date.now() - last >= Math.max(1, intervalDays) * 24 * 60 * 60 * 1000;
445
578
  }
446
579
  function getDueCadences(config, state) {
447
- const due = getCadenceDefinitions(config).filter((cadence) => cadenceIsDue(cadence, state));
448
- if (due.length > 0)
449
- return due;
450
- const daily = getCadenceDefinitions(config).find((cadence) => cadence.key === 'daily');
451
- return daily ? [daily] : [];
580
+ return getCadenceDefinitions(config).filter((cadence) => cadenceIsDue(cadence, state));
452
581
  }
453
582
  function markCadencesRan(state, cadences, ranAt) {
454
583
  const nextCadences = { ...(state?.cadences || {}) };
@@ -467,6 +596,7 @@ function getConnectorEntries(statusPayload) {
467
596
  status: String(value?.status || 'unknown'),
468
597
  detail: String(value?.detail || ''),
469
598
  nextAction: typeof value?.nextAction === 'string' ? value.nextAction : null,
599
+ accounts: Array.isArray(value?.accounts) ? value.accounts : [],
470
600
  }));
471
601
  }
472
602
  function getUnhealthyConfiguredConnectors(statusPayload) {
@@ -497,26 +627,208 @@ function humanConnectorName(key) {
497
627
  return 'GitHub';
498
628
  return key;
499
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
+ }
500
671
  function buildConnectorHealthAlert(statusPayload, unhealthyConnectors) {
501
- const lines = [
502
- `OpenClaw Growth connector health needs attention (${new Date().toISOString()}).`,
503
- `Config: ${statusPayload?.configPath || DEFAULT_CONFIG_PATH}`,
504
- '',
505
- 'Unhealthy connector(s):',
506
- ];
672
+ const configPath = statusPayload?.configPath || DEFAULT_CONFIG_PATH;
673
+ const lines = [`OpenClaw connector health: ${unhealthyConnectors.length} issue(s)`];
507
674
  for (const entry of unhealthyConnectors) {
508
- lines.push(`- ${humanConnectorName(entry.key)}: ${entry.status} - ${entry.detail}`);
509
- if (entry.nextAction) {
510
- 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}\``);
511
679
  }
512
- if (entry.key === 'appStoreConnect' && entry.status === 'partial') {
513
- 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"`');
514
682
  }
515
683
  }
516
- lines.push('');
517
- 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.');
518
685
  return `${lines.join('\n')}\n`;
519
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
+ }
520
832
  async function writeConnectorHealthAlert(runtimeDir, message, statusPayload, unhealthyConnectors, fingerprint) {
521
833
  const alertDir = path.join(runtimeDir, 'connector-health');
522
834
  await ensureDir(alertDir);
@@ -531,20 +843,43 @@ async function writeConnectorHealthAlert(runtimeDir, message, statusPayload, unh
531
843
  }, null, 2), 'utf8');
532
844
  return { markdownPath, jsonPath };
533
845
  }
534
- function getConnectorHealthChannels(config) {
535
- const configuredChannels = Array.isArray(config?.notifications?.connectorHealth?.channels)
536
- ? config.notifications.connectorHealth.channels.filter((channel) => channel?.enabled !== false)
537
- : [];
538
- if (configuredChannels.length > 0)
539
- 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) {
540
870
  const channels = [];
541
871
  const deliveries = config?.deliveries || {};
542
872
  if (deliveries.openclawChat?.enabled) {
873
+ const isConnectorHealth = kind === 'connectorHealth';
543
874
  channels.push({
544
875
  type: 'openclaw-chat',
545
876
  label: 'openclaw_chat',
546
- markdownPath: deliveries.openclawChat.connectorHealthMarkdownPath || deliveries.openclawChat.markdownPath,
547
- 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',
548
883
  });
549
884
  }
550
885
  if (deliveries.slack?.enabled) {
@@ -563,19 +898,37 @@ function getConnectorHealthChannels(config) {
563
898
  headers: deliveries.webhook.headers || {},
564
899
  });
565
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
+ }
566
908
  if (deliveries.discord?.enabled) {
567
909
  channels.push({
568
910
  type: 'command',
569
- label: 'discord',
570
- command: deliveries.discord.command || 'node scripts/discord-openclaw-bridge.mjs send --stdin',
911
+ label: deliveries.discord.label || 'discord',
912
+ command: deliveries.discord.command || '',
571
913
  });
572
914
  }
573
915
  return channels;
574
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
+ }
575
929
  async function writeConfiguredOpenClawChatAlert(configPath, channel, message, statusPayload, unhealthyConnectors, fingerprint) {
576
- const baseDir = path.dirname(path.resolve(configPath));
577
- const markdownPath = path.resolve(baseDir, channel.markdownPath || '.openclaw/chat/connector-health.md');
578
- 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');
579
932
  await fs.mkdir(path.dirname(markdownPath), { recursive: true });
580
933
  await fs.mkdir(path.dirname(jsonPath), { recursive: true });
581
934
  await fs.writeFile(markdownPath, message, 'utf8');
@@ -588,8 +941,9 @@ async function writeConfiguredOpenClawChatAlert(configPath, channel, message, st
588
941
  }, null, 2), 'utf8');
589
942
  return {
590
943
  sent: true,
944
+ external: false,
591
945
  target: channel.label || 'openclaw_chat',
592
- detail: `wrote ${markdownPath} and ${jsonPath}`,
946
+ detail: `wrote local OpenClaw chat outbox ${markdownPath} and ${jsonPath}`,
593
947
  };
594
948
  }
595
949
  async function sendSlackConnectorHealthAlert(channel, message) {
@@ -605,6 +959,7 @@ async function sendSlackConnectorHealthAlert(channel, message) {
605
959
  });
606
960
  return {
607
961
  sent: response.ok,
962
+ external: true,
608
963
  target: channel.label || 'slack',
609
964
  detail: response.ok ? `HTTP ${response.status}` : `HTTP ${response.status}: ${await response.text()}`,
610
965
  };
@@ -632,6 +987,7 @@ async function sendWebhookConnectorHealthAlert(channel, message, statusPayload,
632
987
  });
633
988
  return {
634
989
  sent: response.ok,
990
+ external: true,
635
991
  target: channel.label || 'webhook',
636
992
  detail: response.ok ? `HTTP ${response.status}` : `HTTP ${response.status}: ${await response.text()}`,
637
993
  };
@@ -643,10 +999,49 @@ async function sendCommandConnectorHealthAlert(channel, message) {
643
999
  const result = await runShellCommand(String(channel.command), 60_000, { input: message });
644
1000
  return {
645
1001
  sent: result.ok,
1002
+ external: true,
646
1003
  target: channel.label || 'command',
647
1004
  detail: result.ok ? result.stdout.trim() : result.stderr.trim() || result.stdout.trim() || `exit ${result.code}`,
648
1005
  };
649
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
+ }
650
1045
  async function deliverConnectorHealthAlert({ config, configPath, message, statusPayload, unhealthyConnectors, fingerprint }) {
651
1046
  const channels = getConnectorHealthChannels(config);
652
1047
  if (config?.notifications?.connectorHealth?.enabled === false) {
@@ -682,55 +1077,76 @@ async function deliverConnectorHealthAlert({ config, configPath, message, status
682
1077
  });
683
1078
  }
684
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
+ }
685
1090
  return results;
686
1091
  }
687
1092
  function getGrowthRunChannels(config) {
688
1093
  const configuredChannels = Array.isArray(config?.notifications?.growthRun?.channels)
689
- ? config.notifications.growthRun.channels.filter((channel) => channel?.enabled !== false)
1094
+ ? config.notifications.growthRun.channels
690
1095
  : [];
691
- if (configuredChannels.length > 0)
692
- return configuredChannels;
693
- const channels = [];
694
- const deliveries = config?.deliveries || {};
695
- if (deliveries.openclawChat?.enabled) {
696
- channels.push({
697
- type: 'openclaw-chat',
698
- label: 'openclaw_chat',
699
- markdownPath: deliveries.openclawChat.growthRunMarkdownPath || '.openclaw/chat/growth-summary.md',
700
- jsonPath: deliveries.openclawChat.growthRunJsonPath || '.openclaw/chat/growth-summary.json',
701
- });
702
- }
703
- if (deliveries.slack?.enabled) {
704
- channels.push({
705
- type: 'slack',
706
- label: 'slack',
707
- webhookEnv: deliveries.slack.webhookEnv || 'SLACK_WEBHOOK_URL',
708
- });
709
- }
710
- if (deliveries.webhook?.enabled) {
711
- channels.push({
712
- type: 'webhook',
713
- label: 'webhook',
714
- urlEnv: deliveries.webhook.urlEnv || 'OPENCLAW_WEBHOOK_URL',
715
- method: deliveries.webhook.method || 'POST',
716
- headers: deliveries.webhook.headers || {},
717
- });
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
+ : [];
718
1112
  }
719
- if (deliveries.discord?.enabled) {
720
- channels.push({
721
- type: 'command',
722
- label: 'discord',
723
- command: deliveries.discord.command || 'node scripts/discord-openclaw-bridge.mjs send --stdin',
724
- });
1113
+ catch {
1114
+ return [];
725
1115
  }
726
- return channels;
727
1116
  }
728
- function buildGrowthRunSummaryMessage({ issuesPayload, activeCadences, sourceFiles, createdGitHubArtifact }) {
1117
+ function buildGrowthRunSummaryMessage({ issuesPayload, activeCadences, sourceFiles, createdGitHubArtifact, charts = [] }) {
729
1118
  const issueCount = Number(issuesPayload?.issue_count || 0);
730
1119
  const cadenceNames = activeCadences.length > 0
731
1120
  ? activeCadences.map((cadence) => cadence.title || cadence.key).join(', ')
732
1121
  : 'ad-hoc growth pass';
733
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
+ }
734
1150
  const lines = [
735
1151
  `OpenClaw Growth run finished (${new Date().toISOString()}).`,
736
1152
  `Cadence: ${cadenceNames}`,
@@ -743,22 +1159,37 @@ function buildGrowthRunSummaryMessage({ issuesPayload, activeCadences, sourceFil
743
1159
  if (createdGitHubArtifact) {
744
1160
  lines.push('GitHub artifact creation was attempted for the generated proposals.');
745
1161
  }
746
- const issues = Array.isArray(issuesPayload?.issues) ? issuesPayload.issues.slice(0, 3) : [];
747
- 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) {
748
1170
  lines.push('');
749
- lines.push('Top findings:');
750
- 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) {
751
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
+ }
752
1182
  }
753
1183
  }
754
1184
  lines.push('');
755
- 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.');
756
1188
  return `${lines.join('\n')}\n`;
757
1189
  }
758
- async function writeConfiguredOpenClawChatGrowthSummary(configPath, channel, message, issuesPayload, activeCadences, fingerprint) {
759
- const baseDir = path.dirname(path.resolve(configPath));
760
- const markdownPath = path.resolve(baseDir, channel.markdownPath || '.openclaw/chat/growth-summary.md');
761
- 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');
762
1193
  await fs.mkdir(path.dirname(markdownPath), { recursive: true });
763
1194
  await fs.mkdir(path.dirname(jsonPath), { recursive: true });
764
1195
  await fs.writeFile(markdownPath, message, 'utf8');
@@ -769,11 +1200,18 @@ async function writeConfiguredOpenClawChatGrowthSummary(configPath, channel, mes
769
1200
  activeCadences,
770
1201
  issueCount: Number(issuesPayload?.issue_count || 0),
771
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
+ })),
772
1209
  }, null, 2), 'utf8');
773
1210
  return {
774
1211
  sent: true,
1212
+ external: false,
775
1213
  target: channel.label || 'openclaw_chat',
776
- detail: `wrote ${markdownPath} and ${jsonPath}`,
1214
+ detail: `wrote local OpenClaw chat outbox ${markdownPath} and ${jsonPath}`,
777
1215
  };
778
1216
  }
779
1217
  async function sendSlackGrowthSummary(channel, message) {
@@ -789,11 +1227,12 @@ async function sendSlackGrowthSummary(channel, message) {
789
1227
  });
790
1228
  return {
791
1229
  sent: response.ok,
1230
+ external: true,
792
1231
  target: channel.label || 'slack',
793
1232
  detail: response.ok ? `HTTP ${response.status}` : `HTTP ${response.status}: ${await response.text()}`,
794
1233
  };
795
1234
  }
796
- async function sendWebhookGrowthSummary(channel, message, issuesPayload, activeCadences, fingerprint) {
1235
+ async function sendWebhookGrowthSummary(channel, message, issuesPayload, activeCadences, fingerprint, charts) {
797
1236
  const urlEnv = channel.urlEnv || channel.webhookEnv || 'OPENCLAW_WEBHOOK_URL';
798
1237
  const webhookUrl = process.env[urlEnv];
799
1238
  if (!webhookUrl) {
@@ -813,10 +1252,17 @@ async function sendWebhookGrowthSummary(channel, message, issuesPayload, activeC
813
1252
  activeCadences,
814
1253
  issueCount: Number(issuesPayload?.issue_count || 0),
815
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
+ })),
816
1261
  }),
817
1262
  });
818
1263
  return {
819
1264
  sent: response.ok,
1265
+ external: true,
820
1266
  target: channel.label || 'webhook',
821
1267
  detail: response.ok ? `HTTP ${response.status}` : `HTTP ${response.status}: ${await response.text()}`,
822
1268
  };
@@ -828,11 +1274,12 @@ async function sendCommandGrowthSummary(channel, message) {
828
1274
  const result = await runShellCommand(String(channel.command), 60_000, { input: message });
829
1275
  return {
830
1276
  sent: result.ok,
1277
+ external: true,
831
1278
  target: channel.label || 'command',
832
1279
  detail: result.ok ? result.stdout.trim() : result.stderr.trim() || result.stdout.trim() || `exit ${result.code}`,
833
1280
  };
834
1281
  }
835
- async function deliverGrowthRunSummary({ config, configPath, issuesPayload, activeCadences, sourceFiles, fingerprint, createdGitHubArtifact, }) {
1282
+ async function deliverGrowthRunSummary({ config, configPath, issuesPayload, activeCadences, sourceFiles, fingerprint, createdGitHubArtifact, chartManifestPath, }) {
836
1283
  if (config?.notifications?.growthRun?.enabled === false) {
837
1284
  return [{ sent: false, target: 'notifications', detail: 'growth run notifications disabled' }];
838
1285
  }
@@ -840,23 +1287,25 @@ async function deliverGrowthRunSummary({ config, configPath, issuesPayload, acti
840
1287
  if (channels.length === 0) {
841
1288
  return [{ sent: false, target: 'none', detail: 'no growth run notification channels configured' }];
842
1289
  }
1290
+ const charts = await readChartAttachments(chartManifestPath);
843
1291
  const message = buildGrowthRunSummaryMessage({
844
1292
  issuesPayload,
845
1293
  activeCadences,
846
1294
  sourceFiles,
847
1295
  createdGitHubArtifact,
1296
+ charts,
848
1297
  });
849
1298
  const results = [];
850
1299
  for (const channel of channels) {
851
1300
  try {
852
1301
  if (channel.type === 'openclaw-chat') {
853
- results.push(await writeConfiguredOpenClawChatGrowthSummary(configPath, channel, message, issuesPayload, activeCadences, fingerprint));
1302
+ results.push(await writeConfiguredOpenClawChatGrowthSummary(configPath, channel, message, issuesPayload, activeCadences, fingerprint, charts));
854
1303
  }
855
1304
  else if (channel.type === 'slack') {
856
1305
  results.push(await sendSlackGrowthSummary(channel, message));
857
1306
  }
858
1307
  else if (channel.type === 'webhook') {
859
- results.push(await sendWebhookGrowthSummary(channel, message, issuesPayload, activeCadences, fingerprint));
1308
+ results.push(await sendWebhookGrowthSummary(channel, message, issuesPayload, activeCadences, fingerprint, charts));
860
1309
  }
861
1310
  else if (channel.type === 'command') {
862
1311
  results.push(await sendCommandGrowthSummary(channel, message));
@@ -879,12 +1328,21 @@ async function maybeRunConnectorHealthCheck({ config, configPath, state, statePa
879
1328
  const healthState = state?.connectorHealth || {};
880
1329
  const intervalMinutes = getConnectorHealthIntervalMinutes(config);
881
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
+ });
882
1341
  return state;
883
1342
  }
884
1343
  await ensureDir(runtimeDir);
885
1344
  const statusCommand = [
886
- 'node',
887
- 'scripts/openclaw-growth-status.mjs',
1345
+ nodeRuntimeScriptCommand('openclaw-growth-status.mjs'),
888
1346
  '--config',
889
1347
  quote(configPath),
890
1348
  '--timeout-ms',
@@ -905,6 +1363,13 @@ async function maybeRunConnectorHealthCheck({ config, configPath, state, statePa
905
1363
  };
906
1364
  await fs.mkdir(path.dirname(statePath), { recursive: true });
907
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
+ });
908
1373
  return nextState;
909
1374
  }
910
1375
  const unhealthyConnectors = getUnhealthyConfiguredConnectors(statusPayload);
@@ -918,11 +1383,12 @@ async function maybeRunConnectorHealthCheck({ config, configPath, state, statePa
918
1383
  connectedConnectors,
919
1384
  lastError: null,
920
1385
  };
921
- const previousIncidentFingerprint = healthState.lastStatusOk === false
922
- ? healthState.activeIncidentFingerprint || healthState.lastAlertedFingerprint || null
923
- : null;
1386
+ const previousExternallyDeliveredFingerprint = healthState.lastExternalAlertedFingerprint || null;
1387
+ let alertTriggered = false;
1388
+ let alertDeliveries = [];
924
1389
  if (unhealthyConnectors.length === 0) {
925
1390
  nextHealthState.activeIncidentFingerprint = null;
1391
+ nextHealthState.lastExternalAlertedFingerprint = null;
926
1392
  if (healthState.lastStatusOk === false) {
927
1393
  nextHealthState.lastRecoveredAt = checkedAt;
928
1394
  }
@@ -931,7 +1397,7 @@ async function maybeRunConnectorHealthCheck({ config, configPath, state, statePa
931
1397
  nextHealthState.activeIncidentFingerprint = fingerprint;
932
1398
  }
933
1399
  if (unhealthyConnectors.length > 0 &&
934
- previousIncidentFingerprint !== fingerprint) {
1400
+ previousExternallyDeliveredFingerprint !== fingerprint) {
935
1401
  const message = buildConnectorHealthAlert(statusPayload, unhealthyConnectors);
936
1402
  const paths = await writeConnectorHealthAlert(runtimeDir, message, statusPayload, unhealthyConnectors, fingerprint);
937
1403
  const deliveries = await deliverConnectorHealthAlert({
@@ -942,11 +1408,18 @@ async function maybeRunConnectorHealthCheck({ config, configPath, state, statePa
942
1408
  unhealthyConnectors,
943
1409
  fingerprint,
944
1410
  });
1411
+ alertTriggered = true;
1412
+ alertDeliveries = deliveries;
945
1413
  nextHealthState.lastAlertedAt = checkedAt;
946
1414
  nextHealthState.lastAlertedFingerprint = fingerprint;
947
1415
  nextHealthState.lastAlertMarkdownPath = paths.markdownPath;
948
1416
  nextHealthState.lastAlertJsonPath = paths.jsonPath;
949
1417
  nextHealthState.lastAlertDeliveries = deliveries;
1418
+ nextHealthState.lastAlertExternalSent = hasSuccessfulExternalDelivery(deliveries);
1419
+ if (nextHealthState.lastAlertExternalSent) {
1420
+ nextHealthState.lastExternalAlertedAt = checkedAt;
1421
+ nextHealthState.lastExternalAlertedFingerprint = fingerprint;
1422
+ }
950
1423
  }
951
1424
  const nextState = {
952
1425
  ...state,
@@ -954,6 +1427,40 @@ async function maybeRunConnectorHealthCheck({ config, configPath, state, statePa
954
1427
  };
955
1428
  await fs.mkdir(path.dirname(statePath), { recursive: true });
956
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
+ }
957
1464
  return nextState;
958
1465
  }
959
1466
  function buildIssueFingerprint(issuesPayload) {
@@ -962,14 +1469,38 @@ function buildIssueFingerprint(issuesPayload) {
962
1469
  : [];
963
1470
  return sha256(titles.join('\n'));
964
1471
  }
965
- 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, }) {
966
1497
  await ensureDir(runtimeDir);
967
1498
  if (!sourceFiles.analytics) {
968
1499
  throw new Error('Analytics source is required (enable and configure `sources.analytics`).');
969
1500
  }
970
1501
  const outFile = path.resolve(config.project?.outFile || 'data/openclaw-growth-engineer/issues.generated.json');
971
1502
  const args = [
972
- 'scripts/openclaw-growth-engineer.mjs',
1503
+ resolveRuntimeScriptPath('openclaw-growth-engineer.mjs'),
973
1504
  '--analytics',
974
1505
  sourceFiles.analytics,
975
1506
  '--repo-root',
@@ -984,9 +1515,18 @@ async function runAnalyzer({ config, runtimeDir, sourceFiles, createGitHubArtifa
984
1515
  if (sourceFiles.revenuecat) {
985
1516
  args.push('--revenuecat', sourceFiles.revenuecat);
986
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
+ }
987
1524
  if (sourceFiles.sentry) {
988
1525
  args.push('--sentry', sourceFiles.sentry);
989
1526
  }
1527
+ if (sourceFiles.coolify) {
1528
+ args.push('--source', `coolify=${sourceFiles.coolify}`);
1529
+ }
990
1530
  if (sourceFiles.feedback) {
991
1531
  args.push('--feedback', sourceFiles.feedback);
992
1532
  }
@@ -997,8 +1537,8 @@ async function runAnalyzer({ config, runtimeDir, sourceFiles, createGitHubArtifa
997
1537
  }
998
1538
  if (createGitHubArtifact) {
999
1539
  const repo = String(config.project?.githubRepo || '').trim();
1000
- args.push(getActionMode(config) === 'pull_request' ? '--create-pull-requests' : '--create-issues', '--repo', repo);
1001
- if (getActionMode(config) === 'pull_request') {
1540
+ args.push(githubArtifactMode === 'pull_request' ? '--create-pull-requests' : '--create-issues', '--repo', repo);
1541
+ if (githubArtifactMode === 'pull_request') {
1002
1542
  args.push('--allow-proposal-pull-requests');
1003
1543
  }
1004
1544
  const labels = Array.isArray(config.project?.labels) ? config.project.labels : [];
@@ -1030,10 +1570,13 @@ async function runAnalyzer({ config, runtimeDir, sourceFiles, createGitHubArtifa
1030
1570
  analyzerStdout: analyzer.stdout.trim(),
1031
1571
  };
1032
1572
  }
1033
- async function maybeGenerateCharts({ config, payloads, runtimeDir }) {
1573
+ async function maybeGenerateCharts({ config, payloads, runtimeDir, activeCadences }) {
1034
1574
  if (!config.charting?.enabled) {
1035
1575
  return null;
1036
1576
  }
1577
+ if (!isDeepAnalysisCadence(activeCadences)) {
1578
+ return null;
1579
+ }
1037
1580
  const analyticsPayload = payloads.analytics;
1038
1581
  if (!analyticsPayload) {
1039
1582
  return null;
@@ -1046,7 +1589,7 @@ async function maybeGenerateCharts({ config, payloads, runtimeDir }) {
1046
1589
  await fs.writeFile(analyticsForChartsPath, JSON.stringify(analyticsPayload, null, 2), 'utf8');
1047
1590
  const defaultCommand = [
1048
1591
  'python3',
1049
- 'scripts/openclaw-growth-charts.py',
1592
+ resolveRuntimeScriptPath('openclaw-growth-charts.py'),
1050
1593
  '--analytics',
1051
1594
  analyticsForChartsPath,
1052
1595
  '--out-dir',
@@ -1102,22 +1645,48 @@ function resolveCursorAwareCommand(command, sourceConfig, cursorState) {
1102
1645
  const lookback = normalizeLookback(sourceConfig?.initialLookback, '30d');
1103
1646
  return `${rawCommand} --last ${quote(lookback)}`;
1104
1647
  }
1105
- async function resolveSourcePayloadWithCursor(sourceConfig, sourceName, cursorState, commandCwd = process.cwd()) {
1648
+ async function resolveSourcePayloadWithCursor(sourceConfig, sourceName, cursorState, commandCwd = process.cwd(), configPath = null) {
1106
1649
  if (!sourceConfig || sourceConfig.enabled === false) {
1107
1650
  return {
1108
1651
  payload: null,
1109
1652
  nextCursor: cursorState || null,
1110
1653
  resolvedCommand: null,
1654
+ failure: null,
1111
1655
  };
1112
1656
  }
1113
1657
  if (sourceConfig.mode === 'command') {
1114
1658
  if (!sourceConfig.command) {
1115
1659
  throw new Error(`Source "${sourceName}" has mode=command but no command configured.`);
1116
1660
  }
1117
- const resolvedCommand = resolveCursorAwareCommand(sourceConfig.command, sourceConfig, cursorState);
1118
- 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
+ }
1119
1669
  if (!result.ok) {
1120
- 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}`);
1121
1690
  }
1122
1691
  const fetchedAt = new Date().toISOString();
1123
1692
  try {
@@ -1128,9 +1697,11 @@ async function resolveSourcePayloadWithCursor(sourceConfig, sourceName, cursorSt
1128
1697
  lastCollectedAt: fetchedAt,
1129
1698
  updatedAt: fetchedAt,
1130
1699
  lastCommand: resolvedCommand,
1700
+ lastRetriedTransientFailureAt: retried ? fetchedAt : null,
1131
1701
  }
1132
1702
  : cursorState || null,
1133
1703
  resolvedCommand,
1704
+ failure: null,
1134
1705
  };
1135
1706
  }
1136
1707
  catch {
@@ -1144,15 +1715,36 @@ async function resolveSourcePayloadWithCursor(sourceConfig, sourceName, cursorSt
1144
1715
  payload: await readJson(path.resolve(String(sourceConfig.path))),
1145
1716
  nextCursor: cursorState || null,
1146
1717
  resolvedCommand: null,
1718
+ failure: null,
1147
1719
  };
1148
1720
  }
1149
- async function loadSourcePayloads(config, state) {
1721
+ async function loadSourcePayloads(config, state, configPath) {
1150
1722
  const payloads = {};
1151
1723
  const sourceCursors = { ...(state?.sourceCursors || {}) };
1724
+ const sourceFailures = [];
1152
1725
  const commandCwd = getProjectCommandCwd(config);
1153
1726
  for (const source of getAllSourceEntries(config)) {
1154
1727
  const currentCursor = sourceCursors[source.key] || null;
1155
- 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
+ }
1156
1748
  const payload = result.payload;
1157
1749
  if (payload) {
1158
1750
  payloads[source.key] = payload;
@@ -1160,10 +1752,22 @@ async function loadSourcePayloads(config, state) {
1160
1752
  if (result.nextCursor) {
1161
1753
  sourceCursors[source.key] = result.nextCursor;
1162
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
+ }
1163
1766
  }
1164
1767
  return {
1165
1768
  payloads,
1166
1769
  sourceCursors,
1770
+ sourceFailures,
1167
1771
  };
1168
1772
  }
1169
1773
  async function materializeSourceFiles(config, payloads, runtimeDir) {
@@ -1190,7 +1794,26 @@ function hasSourceChanges(previousHashes, currentHashes) {
1190
1794
  return false;
1191
1795
  }
1192
1796
  async function runOnce(configPath, statePath) {
1797
+ await appendSchedulerProof('runner_invoked', {
1798
+ configPath,
1799
+ statePath,
1800
+ argv: process.argv.slice(2),
1801
+ });
1193
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
+ }
1194
1817
  await applyOpenClawSecretRefs(config);
1195
1818
  const inferredGitHubRepo = await inferGitHubRepo(config);
1196
1819
  if (inferredGitHubRepo) {
@@ -1206,7 +1829,7 @@ async function runOnce(configPath, statePath) {
1206
1829
  lastRunAt: null,
1207
1830
  sourceCursors: {},
1208
1831
  });
1209
- const runtimeDir = path.resolve(DEFAULT_RUNTIME_DIR);
1832
+ const runtimeDir = path.resolve(deriveRuntimeDirFromStatePath(statePath));
1210
1833
  const stateAfterHealthCheck = await maybeRunConnectorHealthCheck({
1211
1834
  config,
1212
1835
  configPath,
@@ -1215,22 +1838,41 @@ async function runOnce(configPath, statePath) {
1215
1838
  runtimeDir,
1216
1839
  });
1217
1840
  const activeCadences = getDueCadences(config, stateAfterHealthCheck);
1218
- 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
+ });
1219
1850
  const currentHashes = computeSourceHashes(payloads);
1220
- const changed = hasSourceChanges(stateAfterHealthCheck.sourceHashes, currentHashes);
1221
- if (!changed && config.schedule?.skipIfNoDataChange !== false) {
1222
- 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();
1223
1854
  await fs.mkdir(path.dirname(statePath), { recursive: true });
1224
1855
  await fs.writeFile(statePath, JSON.stringify({
1225
1856
  ...stateAfterHealthCheck,
1857
+ ...stateAfterSourceCollection,
1226
1858
  sourceHashes: currentHashes,
1227
1859
  sourceCursors,
1228
- lastRunAt: new Date().toISOString(),
1229
- skippedReason: 'no_data_change',
1860
+ lastSourceFailures: sourceFailures,
1861
+ lastRunAt: completedAt,
1862
+ skippedReason: 'cadence_not_due',
1230
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
+ });
1231
1872
  return;
1232
1873
  }
1233
- 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());
1234
1876
  const sourceFiles = await materializeSourceFiles(config, payloads, runtimeDir);
1235
1877
  const cadencePlanPath = path.join(runtimeDir, 'cadence-plan.json');
1236
1878
  await fs.writeFile(cadencePlanPath, JSON.stringify({
@@ -1241,6 +1883,7 @@ async function runOnce(configPath, statePath) {
1241
1883
  config,
1242
1884
  payloads,
1243
1885
  runtimeDir,
1886
+ activeCadences,
1244
1887
  });
1245
1888
  const dryRun = await runAnalyzer({
1246
1889
  config,
@@ -1251,46 +1894,78 @@ async function runOnce(configPath, statePath) {
1251
1894
  cadencePlanPath,
1252
1895
  });
1253
1896
  const issueFingerprint = buildIssueFingerprint(dryRun.issuesPayload);
1254
- const unchangedIssueSet = issueFingerprint === stateAfterHealthCheck.lastIssueFingerprint;
1255
- if (unchangedIssueSet && config.schedule?.skipIfIssueSetUnchanged !== false) {
1256
- 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();
1257
1902
  await fs.mkdir(path.dirname(statePath), { recursive: true });
1258
1903
  await fs.writeFile(statePath, JSON.stringify({
1259
1904
  ...stateAfterHealthCheck,
1905
+ ...stateAfterSourceCollection,
1260
1906
  sourceHashes: currentHashes,
1261
1907
  sourceCursors,
1908
+ lastSourceFailures: sourceFailures,
1262
1909
  lastIssueFingerprint: issueFingerprint,
1263
- lastRunAt: new Date().toISOString(),
1910
+ lastRunAt: completedAt,
1264
1911
  lastOutFile: dryRun.outFile,
1265
- 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
+ ],
1266
1920
  skippedReason: 'issue_set_unchanged',
1267
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
+ });
1268
1934
  return;
1269
1935
  }
1270
- 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;
1271
1940
  if (shouldCreateGitHubArtifact) {
1272
- await runAnalyzer({
1273
- config,
1274
- runtimeDir,
1275
- sourceFiles,
1276
- createGitHubArtifact: true,
1277
- chartManifestPath,
1278
- cadencePlanPath,
1279
- });
1280
- 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`);
1281
1953
  }
1282
1954
  else {
1283
1955
  process.stdout.write(`[${new Date().toISOString()}] Drafts generated only (${getActionMode(config)} auto-create disabled).\n`);
1284
1956
  }
1957
+ const completedAt = new Date().toISOString();
1285
1958
  await fs.mkdir(path.dirname(statePath), { recursive: true });
1286
1959
  await fs.writeFile(statePath, JSON.stringify({
1287
1960
  ...stateAfterHealthCheck,
1961
+ ...stateAfterSourceCollection,
1288
1962
  sourceHashes: currentHashes,
1289
1963
  sourceCursors,
1964
+ lastSourceFailures: sourceFailures,
1290
1965
  lastIssueFingerprint: issueFingerprint,
1291
- lastRunAt: new Date().toISOString(),
1966
+ lastRunAt: completedAt,
1292
1967
  lastOutFile: dryRun.outFile,
1293
- cadences: markCadencesRan(stateAfterHealthCheck, activeCadences, new Date().toISOString()),
1968
+ cadences: markCadencesRan(stateAfterSourceCollection, activeCadences, completedAt),
1294
1969
  lastGrowthRunNotifications: await deliverGrowthRunSummary({
1295
1970
  config,
1296
1971
  configPath,
@@ -1299,9 +1974,21 @@ async function runOnce(configPath, statePath) {
1299
1974
  sourceFiles,
1300
1975
  fingerprint: issueFingerprint,
1301
1976
  createdGitHubArtifact: shouldCreateGitHubArtifact,
1977
+ chartManifestPath,
1302
1978
  }),
1303
1979
  skippedReason: null,
1304
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
+ });
1305
1992
  }
1306
1993
  async function main() {
1307
1994
  await loadOpenClawGrowthSecrets();
@@ -1309,6 +1996,7 @@ async function main() {
1309
1996
  await maybeSelfUpdateFromClawHub(args);
1310
1997
  const configPath = path.resolve(args.config);
1311
1998
  const statePath = path.resolve(args.state);
1999
+ useSchedulerProofPathForStatePath(statePath);
1312
2000
  if (!args.loop) {
1313
2001
  await runOnce(configPath, statePath);
1314
2002
  return;
@@ -1322,12 +2010,21 @@ async function main() {
1322
2010
  await runOnce(configPath, statePath);
1323
2011
  }
1324
2012
  catch (error) {
2013
+ await appendSchedulerProof('runner_failed', {
2014
+ configPath,
2015
+ statePath,
2016
+ error: error instanceof Error ? error.message : String(error),
2017
+ }).catch(() => { });
1325
2018
  process.stderr.write(`[${new Date().toISOString()}] Run failed: ${error instanceof Error ? error.message : String(error)}\n`);
1326
2019
  }
1327
2020
  await sleep(intervalMinutes * 60_000);
1328
2021
  }
1329
2022
  }
1330
- 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(() => { });
1331
2028
  process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
1332
2029
  process.exitCode = 1;
1333
2030
  });