@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.
- package/dist/config.d.ts +925 -45
- package/dist/config.js +58 -6
- 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 +295 -4
- 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 +51 -0
- package/dist/runtime/openclaw-exporters-lib.mjs +769 -63
- 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 +446 -30
- package/dist/runtime/openclaw-growth-preflight.mjs.map +1 -1
- package/dist/runtime/openclaw-growth-runner.mjs +847 -150
- package/dist/runtime/openclaw-growth-runner.mjs.map +1 -1
- package/dist/runtime/openclaw-growth-shared.d.mts +158 -3
- package/dist/runtime/openclaw-growth-shared.mjs +574 -8
- package/dist/runtime/openclaw-growth-shared.mjs.map +1 -1
- package/dist/runtime/openclaw-growth-start.mjs +816 -41
- package/dist/runtime/openclaw-growth-start.mjs.map +1 -1
- package/dist/runtime/openclaw-growth-status.mjs +100 -34
- package/dist/runtime/openclaw-growth-status.mjs.map +1 -1
- package/dist/runtime/openclaw-growth-wizard.mjs +1997 -226
- package/dist/runtime/openclaw-growth-wizard.mjs.map +1 -1
- package/package.json +3 -1
- 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 {
|
|
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
|
|
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
|
|
30
|
+
title: 'Daily behavioral anomaly guardrail',
|
|
18
31
|
intervalDays: 1,
|
|
19
32
|
criticalOnly: true,
|
|
20
|
-
focusAreas: ['
|
|
21
|
-
sourcePriorities: ['
|
|
22
|
-
objective: '
|
|
23
|
-
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.',
|
|
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
|
|
33
|
-
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.',
|
|
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,
|
|
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: '
|
|
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
|
|
53
|
-
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.',
|
|
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
|
|
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
|
-
|
|
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
|
|
502
|
-
|
|
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
|
|
509
|
-
|
|
510
|
-
|
|
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
|
|
513
|
-
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"`');
|
|
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
|
|
535
|
-
const
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
if (
|
|
539
|
-
return
|
|
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:
|
|
547
|
-
|
|
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 || '
|
|
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
|
|
577
|
-
const
|
|
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
|
|
1094
|
+
? config.notifications.growthRun.channels
|
|
690
1095
|
: [];
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
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
|
-
|
|
720
|
-
|
|
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
|
-
|
|
747
|
-
|
|
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
|
|
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(
|
|
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
|
|
760
|
-
const
|
|
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
|
-
'
|
|
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
|
|
922
|
-
|
|
923
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
'
|
|
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(
|
|
1001
|
-
if (
|
|
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
|
-
'
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
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
|
-
|
|
1229
|
-
|
|
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
|
|
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 ===
|
|
1255
|
-
if (unchangedIssueSet &&
|
|
1256
|
-
|
|
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:
|
|
1910
|
+
lastRunAt: completedAt,
|
|
1264
1911
|
lastOutFile: dryRun.outFile,
|
|
1265
|
-
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
|
+
],
|
|
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
|
|
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
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
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:
|
|
1966
|
+
lastRunAt: completedAt,
|
|
1292
1967
|
lastOutFile: dryRun.outFile,
|
|
1293
|
-
cadences: markCadencesRan(
|
|
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
|
});
|