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