@analyticscli/growth-engineer 0.1.0-preview.9 → 0.1.1-preview.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +831 -146
- 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 +802 -39
- package/dist/runtime/openclaw-growth-start.mjs.map +1 -1
- package/dist/runtime/openclaw-growth-status.mjs +85 -31
- package/dist/runtime/openclaw-growth-status.mjs.map +1 -1
- package/dist/runtime/openclaw-growth-wizard.mjs +1952 -217
- package/dist/runtime/openclaw-growth-wizard.mjs.map +1 -1
- package/package.json +3 -1
- package/templates/config.example.json +128 -65
|
@@ -5,64 +5,75 @@ import process from 'node:process';
|
|
|
5
5
|
import { createHash } from 'node:crypto';
|
|
6
6
|
import { spawn } from 'node:child_process';
|
|
7
7
|
import { fileURLToPath } from 'node:url';
|
|
8
|
-
import { getActionMode, getAllSourceEntries, getGitHubRequirementText, shouldAutoCreateGitHubArtifact, } from './openclaw-growth-shared.mjs';
|
|
8
|
+
import { deriveRuntimeDirFromStatePath, deriveSchedulerProofPathFromStatePath, getActionMode, getAllSourceEntries, getGitHubArtifactModes, getGitHubRequirementText, repairOpenClawCronDeliveryStore, shouldAutoCreateGitHubArtifact, } from './openclaw-growth-shared.mjs';
|
|
9
9
|
import { applyOpenClawSecretRefs, loadOpenClawGrowthSecrets } from './openclaw-growth-env.mjs';
|
|
10
10
|
const DEFAULT_CONFIG_PATH = 'data/openclaw-growth-engineer/config.json';
|
|
11
11
|
const DEFAULT_STATE_PATH = 'data/openclaw-growth-engineer/state.json';
|
|
12
|
-
const
|
|
12
|
+
const DEFAULT_SCHEDULER_PROOF_PATH = 'data/openclaw-growth-engineer/runtime/scheduler-proof.jsonl';
|
|
13
13
|
const DEFAULT_CONNECTOR_HEALTH_INTERVAL_MINUTES = 360;
|
|
14
14
|
const SELF_UPDATE_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
|
15
15
|
const RUNTIME_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
16
|
+
let schedulerProofPath = path.resolve(DEFAULT_SCHEDULER_PROOF_PATH);
|
|
16
17
|
const DEFAULT_CADENCES = [
|
|
18
|
+
{
|
|
19
|
+
key: 'healthcheck',
|
|
20
|
+
title: '90-minute production error healthcheck',
|
|
21
|
+
intervalMinutes: 90,
|
|
22
|
+
criticalOnly: true,
|
|
23
|
+
focusAreas: ['crash', 'deployment', 'availability'],
|
|
24
|
+
sourcePriorities: ['sentry', 'glitchtip', 'coolify', 'asc_cli'],
|
|
25
|
+
objective: 'Check Sentry/GlitchTip and Coolify for production errors, failed deploys, unhealthy resources, and availability blockers across every configured app.',
|
|
26
|
+
instructions: 'For Sentry/GlitchTip app errors, compare the issue release or app version with ASC production versions first. Ignore errors that only affect TestFlight, debug, staging, unreleased, or non-production app versions. Keep the social output short and action-oriented.',
|
|
27
|
+
},
|
|
17
28
|
{
|
|
18
29
|
key: 'daily',
|
|
19
|
-
title: 'Daily
|
|
30
|
+
title: 'Daily behavioral anomaly guardrail',
|
|
20
31
|
intervalDays: 1,
|
|
21
32
|
criticalOnly: true,
|
|
22
|
-
focusAreas: ['
|
|
23
|
-
sourcePriorities: ['
|
|
24
|
-
objective: '
|
|
25
|
-
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.',
|
|
26
37
|
},
|
|
27
38
|
{
|
|
28
39
|
key: 'weekly',
|
|
29
40
|
title: 'Weekly executive product and growth summary',
|
|
30
41
|
intervalDays: 7,
|
|
31
42
|
criticalOnly: false,
|
|
32
|
-
focusAreas: ['conversion', 'paywall', 'onboarding', 'marketing', 'retention', 'stability'],
|
|
33
|
-
sourcePriorities: ['analytics', 'revenuecat', 'asc_cli', 'feedback', 'sentry', 'github'],
|
|
34
|
-
objective: 'Create
|
|
35
|
-
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.',
|
|
36
47
|
},
|
|
37
48
|
{
|
|
38
49
|
key: 'monthly',
|
|
39
50
|
title: 'Monthly deep product, business, and code review',
|
|
40
51
|
intervalDays: 30,
|
|
41
52
|
criticalOnly: false,
|
|
42
|
-
focusAreas: ['conversion', 'paywall', 'retention', 'marketing', 'onboarding', 'codebase'],
|
|
43
|
-
sourcePriorities: ['analytics', 'revenuecat', 'asc_cli', 'feedback', 'sentry', 'github'],
|
|
44
|
-
objective: 'Compare all configured projects month-over-month: MRR, trial conversion, churn, acquisition channel quality, store/listing conversion, retention, review themes, feature usage, crash totals, and codebase changes.',
|
|
45
|
-
instructions: 'Decide what should be built, changed, deleted, or instrumented next. Tie conclusions to connector data plus codebase evidence and explain why each recommendation should move revenue,
|
|
53
|
+
focusAreas: ['conversion', 'paywall', 'retention', 'marketing', 'onboarding', 'codebase', 'seo'],
|
|
54
|
+
sourcePriorities: ['analytics', 'revenuecat', 'paddle', 'seo', 'asc_cli', 'feedback', 'sentry', 'coolify', 'github'],
|
|
55
|
+
objective: 'Compare all configured projects month-over-month: MRR, trial conversion, churn, Paddle revenue/subscriber movement, SEO demand/clicks, acquisition channel quality, store/listing conversion, retention, review themes, feature usage, crash totals, and codebase changes.',
|
|
56
|
+
instructions: 'Be very detailed and app-grouped. Decide what should be built, changed, deleted, priced differently, marketed differently, or instrumented next. Tie conclusions to connector data plus codebase evidence and explain why each recommendation should move revenue, conversion, retention, traffic, or acquisition quality. Generate charts when useful.',
|
|
46
57
|
},
|
|
47
58
|
{
|
|
48
59
|
key: 'quarterly',
|
|
49
|
-
title: '
|
|
60
|
+
title: '3-month positioning, pricing, and roadmap review',
|
|
50
61
|
intervalDays: 91,
|
|
51
62
|
criticalOnly: false,
|
|
52
63
|
focusAreas: ['marketing', 'paywall', 'retention', 'conversion', 'onboarding'],
|
|
53
|
-
sourcePriorities: ['analytics', 'revenuecat', 'asc_cli', 'feedback', 'github', 'sentry'],
|
|
54
|
-
objective: 'Revisit positioning, pricing/packaging, onboarding architecture, roadmap assumptions, tracking quality, codebase constraints, and major funnel bets across every configured
|
|
55
|
-
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.',
|
|
56
67
|
},
|
|
57
68
|
{
|
|
58
69
|
key: 'six_months',
|
|
59
70
|
title: 'Six-month instrumentation and growth-system audit',
|
|
60
71
|
intervalDays: 182,
|
|
61
72
|
criticalOnly: false,
|
|
62
|
-
focusAreas: ['retention', 'conversion', 'paywall', 'marketing', 'general'],
|
|
63
|
-
sourcePriorities: ['analytics', 'revenuecat', 'asc_cli', 'feedback', 'sentry'],
|
|
73
|
+
focusAreas: ['retention', 'conversion', 'paywall', 'marketing', 'general', 'seo'],
|
|
74
|
+
sourcePriorities: ['analytics', 'revenuecat', 'paddle', 'seo', 'asc_cli', 'feedback', 'sentry'],
|
|
64
75
|
objective: 'Audit connector coverage, SDK instrumentation, event taxonomy, data reliability, data memory, growth loops, and whether product/code strategy still matches the best users across configured projects.',
|
|
65
|
-
instructions: 'Prioritize measurement fixes and system changes that make future analysis more trustworthy. Identify stale events, missing attribution, weak identity, broken feedback loops, and misleading dashboards.',
|
|
76
|
+
instructions: 'Group by app. Prioritize measurement fixes and system changes that make future analysis more trustworthy, then identify the highest-leverage app/revenue/conversion/SEO/traffic improvements. Identify stale events, missing attribution, weak identity, broken feedback loops, and misleading dashboards.',
|
|
66
77
|
},
|
|
67
78
|
{
|
|
68
79
|
key: 'yearly',
|
|
@@ -70,7 +81,7 @@ const DEFAULT_CADENCES = [
|
|
|
70
81
|
intervalDays: 365,
|
|
71
82
|
criticalOnly: false,
|
|
72
83
|
focusAreas: ['marketing', 'retention', 'paywall', 'conversion', 'general'],
|
|
73
|
-
sourcePriorities: ['analytics', 'revenuecat', 'asc_cli', 'feedback', 'sentry'],
|
|
84
|
+
sourcePriorities: ['analytics', 'revenuecat', 'paddle', 'seo', 'asc_cli', 'feedback', 'sentry'],
|
|
74
85
|
objective: 'Reset strategy from evidence across every configured project: market/channel fit, monetization model, retention ceiling, product scope, and whether to double down, reposition, rebuild, or sunset major surfaces/features.',
|
|
75
86
|
instructions: 'Use the full year of memory, releases, revenue, acquisition, reviews, code changes, and cohort behavior. Produce a strategic operating plan with specific experiments and stop-doing decisions.',
|
|
76
87
|
},
|
|
@@ -140,6 +151,39 @@ function resolveRuntimeScriptPath(scriptName) {
|
|
|
140
151
|
function nodeRuntimeScriptCommand(scriptName) {
|
|
141
152
|
return `node ${quote(resolveRuntimeScriptPath(scriptName))}`;
|
|
142
153
|
}
|
|
154
|
+
function replaceLegacyRuntimeScriptCommand(command) {
|
|
155
|
+
const trimmed = String(command || '').trim();
|
|
156
|
+
if (!trimmed)
|
|
157
|
+
return trimmed;
|
|
158
|
+
return trimmed.replace(/^node\s+scripts\/(export-analytics-summary\.mjs|export-revenuecat-summary\.mjs|export-paddle-summary\.mjs|export-seo-summary\.mjs|export-sentry-summary\.mjs|export-coolify-summary\.mjs|export-asc-summary\.mjs|openclaw-growth-engineer\.mjs|openclaw-growth-status\.mjs|openclaw-growth-preflight\.mjs|openclaw-growth-runner\.mjs)(?=\s|$)/, (_match, scriptName) => nodeRuntimeScriptCommand(scriptName));
|
|
159
|
+
}
|
|
160
|
+
function commandHasConfigArg(command) {
|
|
161
|
+
return /(?:^|\s)--config(?:=|\s|$)/.test(String(command || ''));
|
|
162
|
+
}
|
|
163
|
+
function commandIsBuiltinExporter(command) {
|
|
164
|
+
return /(?:^|\s)(?:node\s+)?(?:\S*\/)?(?:export-analytics-summary|export-revenuecat-summary|export-paddle-summary|export-seo-summary|export-sentry-summary|export-coolify-summary|export-asc-summary)\.mjs(?:\s|$)/.test(String(command || ''));
|
|
165
|
+
}
|
|
166
|
+
function commandSupportsActiveConfig(command) {
|
|
167
|
+
return /(?:^|\s)(?:node\s+)?(?:\S*\/)?(?:export-sentry-summary|export-coolify-summary)\.mjs(?:\s|$)/.test(String(command || ''));
|
|
168
|
+
}
|
|
169
|
+
function withActiveConfigArg(command, configPath) {
|
|
170
|
+
const trimmed = String(command || '').trim();
|
|
171
|
+
if (!trimmed || !configPath || !commandIsBuiltinExporter(trimmed)) {
|
|
172
|
+
return trimmed;
|
|
173
|
+
}
|
|
174
|
+
if (!commandSupportsActiveConfig(trimmed)) {
|
|
175
|
+
return trimmed
|
|
176
|
+
.replace(/(^|\s)--config=(?:"[^"]*"|'[^']*'|\S+)/, '$1')
|
|
177
|
+
.replace(/(^|\s)--config\s+(?:"[^"]*"|'[^']*'|\S+)/, '$1')
|
|
178
|
+
.trim();
|
|
179
|
+
}
|
|
180
|
+
if (commandHasConfigArg(trimmed)) {
|
|
181
|
+
return trimmed
|
|
182
|
+
.replace(/(^|\s)--config=(?:"[^"]*"|'[^']*'|\S+)/, `$1--config ${quote(configPath)}`)
|
|
183
|
+
.replace(/(^|\s)--config\s+(?:"[^"]*"|'[^']*'|\S+)/, `$1--config ${quote(configPath)}`);
|
|
184
|
+
}
|
|
185
|
+
return `${trimmed} --config ${quote(configPath)}`;
|
|
186
|
+
}
|
|
143
187
|
async function readJson(filePath) {
|
|
144
188
|
const raw = await fs.readFile(filePath, 'utf8');
|
|
145
189
|
return JSON.parse(raw);
|
|
@@ -155,6 +199,22 @@ async function readJsonOptional(filePath, fallback) {
|
|
|
155
199
|
async function ensureDir(dirPath) {
|
|
156
200
|
await fs.mkdir(dirPath, { recursive: true });
|
|
157
201
|
}
|
|
202
|
+
async function appendSchedulerProof(event, details = {}) {
|
|
203
|
+
const proofPath = schedulerProofPath;
|
|
204
|
+
const entry = {
|
|
205
|
+
ts: new Date().toISOString(),
|
|
206
|
+
event,
|
|
207
|
+
pid: process.pid,
|
|
208
|
+
cwd: process.cwd(),
|
|
209
|
+
...details,
|
|
210
|
+
};
|
|
211
|
+
await fs.mkdir(path.dirname(proofPath), { recursive: true });
|
|
212
|
+
await fs.appendFile(proofPath, `${JSON.stringify(entry)}\n`, 'utf8');
|
|
213
|
+
}
|
|
214
|
+
function useSchedulerProofPathForStatePath(statePath) {
|
|
215
|
+
schedulerProofPath = path.resolve(deriveSchedulerProofPathFromStatePath(statePath));
|
|
216
|
+
return schedulerProofPath;
|
|
217
|
+
}
|
|
158
218
|
function sha256(input) {
|
|
159
219
|
return createHash('sha256').update(input).digest('hex');
|
|
160
220
|
}
|
|
@@ -164,6 +224,33 @@ function stableStringify(value) {
|
|
|
164
224
|
function sleep(ms) {
|
|
165
225
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
166
226
|
}
|
|
227
|
+
function isTransientNetworkFailure(value) {
|
|
228
|
+
return /NETWORK_ERROR|fetch failed|tlsv1 alert|SSL routines|ECONNRESET|ECONNREFUSED|ETIMEDOUT|ENOTFOUND|EAI_AGAIN|socket hang up|network timeout|Temporary failure|upstream connect error|disconnect\/reset before headers|HTTP 5\d\d|API 5\d\d/i.test(String(value || ''));
|
|
229
|
+
}
|
|
230
|
+
function isRequiredSource(sourceConfig, sourceName) {
|
|
231
|
+
if (sourceConfig?.required === true)
|
|
232
|
+
return true;
|
|
233
|
+
if (sourceConfig?.required === false)
|
|
234
|
+
return false;
|
|
235
|
+
return String(sourceName || '').toLowerCase() === 'analytics';
|
|
236
|
+
}
|
|
237
|
+
function isSentryCompatibleSource(sourceConfig, sourceName) {
|
|
238
|
+
const sourceKey = String(sourceName || '').toLowerCase();
|
|
239
|
+
const service = String(sourceConfig?.service || sourceConfig?.provider || '').toLowerCase();
|
|
240
|
+
const command = String(sourceConfig?.command || '').toLowerCase();
|
|
241
|
+
return (sourceKey === 'sentry' ||
|
|
242
|
+
sourceKey === 'glitchtip' ||
|
|
243
|
+
service === 'sentry' ||
|
|
244
|
+
service === 'glitchtip' ||
|
|
245
|
+
command.includes('export-sentry-summary'));
|
|
246
|
+
}
|
|
247
|
+
function shouldDegradeTransientSourceFailure(sourceConfig, sourceName, retried) {
|
|
248
|
+
if (!retried)
|
|
249
|
+
return false;
|
|
250
|
+
if (!isRequiredSource(sourceConfig, sourceName))
|
|
251
|
+
return true;
|
|
252
|
+
return isSentryCompatibleSource(sourceConfig, sourceName);
|
|
253
|
+
}
|
|
167
254
|
function isTruthyEnv(value) {
|
|
168
255
|
return ['1', 'true', 'yes', 'y', 'on'].includes(String(value || '').trim().toLowerCase());
|
|
169
256
|
}
|
|
@@ -309,11 +396,24 @@ function resolveShellCommand() {
|
|
|
309
396
|
}
|
|
310
397
|
return 'sh';
|
|
311
398
|
}
|
|
399
|
+
function hardenUnattendedShellCommand(command) {
|
|
400
|
+
return String(command || '').replace(/(^|[;&|]\s*)sudo(?!\s+-n(?:\s|$))(?=\s|$)/g, '$1sudo -n');
|
|
401
|
+
}
|
|
402
|
+
function isSudoPasswordPrompt(stderr) {
|
|
403
|
+
return /sudo: (?:a password is required|a terminal is required to read the password|no tty present)/i.test(String(stderr || ''));
|
|
404
|
+
}
|
|
312
405
|
function runShellCommand(command, timeoutMs = 120_000, options = {}) {
|
|
313
406
|
return new Promise((resolve) => {
|
|
314
|
-
const
|
|
407
|
+
const hardenedCommand = hardenUnattendedShellCommand(command);
|
|
408
|
+
const child = spawn(resolveShellCommand(), ['-c', hardenedCommand], {
|
|
315
409
|
stdio: options.input === undefined ? ['ignore', 'pipe', 'pipe'] : ['pipe', 'pipe', 'pipe'],
|
|
316
410
|
cwd: options.cwd,
|
|
411
|
+
env: {
|
|
412
|
+
...process.env,
|
|
413
|
+
DEBIAN_FRONTEND: 'noninteractive',
|
|
414
|
+
SUDO_ASKPASS: '/bin/false',
|
|
415
|
+
SUDO_PROMPT: '',
|
|
416
|
+
},
|
|
317
417
|
});
|
|
318
418
|
let stdout = '';
|
|
319
419
|
let stderr = '';
|
|
@@ -330,6 +430,17 @@ function runShellCommand(command, timeoutMs = 120_000, options = {}) {
|
|
|
330
430
|
});
|
|
331
431
|
child.stderr.on('data', (chunk) => {
|
|
332
432
|
stderr += String(chunk);
|
|
433
|
+
if (!settled && isSudoPasswordPrompt(stderr)) {
|
|
434
|
+
settled = true;
|
|
435
|
+
clearTimeout(timer);
|
|
436
|
+
child.kill('SIGTERM');
|
|
437
|
+
resolve({
|
|
438
|
+
ok: false,
|
|
439
|
+
code: null,
|
|
440
|
+
stdout,
|
|
441
|
+
stderr: `${stderr.trim()}\nBlocked non-interactive sudo prompt. Configure passwordless sudo for this exact command or remove sudo from the Growth Engineer connector command.`,
|
|
442
|
+
});
|
|
443
|
+
}
|
|
333
444
|
});
|
|
334
445
|
if (options.input !== undefined) {
|
|
335
446
|
child.stdin.end(options.input);
|
|
@@ -448,6 +559,15 @@ function getCadenceDefinitions(config) {
|
|
|
448
559
|
}
|
|
449
560
|
function cadenceIsDue(cadence, state) {
|
|
450
561
|
const lastRanAt = state?.cadences?.[cadence.key]?.lastRanAt;
|
|
562
|
+
const intervalMinutes = Number(cadence.intervalMinutes || 0);
|
|
563
|
+
if (intervalMinutes > 0) {
|
|
564
|
+
if (!lastRanAt)
|
|
565
|
+
return true;
|
|
566
|
+
const last = Date.parse(String(lastRanAt));
|
|
567
|
+
if (!Number.isFinite(last))
|
|
568
|
+
return true;
|
|
569
|
+
return Date.now() - last >= Math.max(1, intervalMinutes) * 60 * 1000;
|
|
570
|
+
}
|
|
451
571
|
const intervalDays = Number(cadence.intervalDays || 1);
|
|
452
572
|
if (!lastRanAt)
|
|
453
573
|
return true;
|
|
@@ -457,11 +577,7 @@ function cadenceIsDue(cadence, state) {
|
|
|
457
577
|
return Date.now() - last >= Math.max(1, intervalDays) * 24 * 60 * 60 * 1000;
|
|
458
578
|
}
|
|
459
579
|
function getDueCadences(config, state) {
|
|
460
|
-
|
|
461
|
-
if (due.length > 0)
|
|
462
|
-
return due;
|
|
463
|
-
const daily = getCadenceDefinitions(config).find((cadence) => cadence.key === 'daily');
|
|
464
|
-
return daily ? [daily] : [];
|
|
580
|
+
return getCadenceDefinitions(config).filter((cadence) => cadenceIsDue(cadence, state));
|
|
465
581
|
}
|
|
466
582
|
function markCadencesRan(state, cadences, ranAt) {
|
|
467
583
|
const nextCadences = { ...(state?.cadences || {}) };
|
|
@@ -480,6 +596,7 @@ function getConnectorEntries(statusPayload) {
|
|
|
480
596
|
status: String(value?.status || 'unknown'),
|
|
481
597
|
detail: String(value?.detail || ''),
|
|
482
598
|
nextAction: typeof value?.nextAction === 'string' ? value.nextAction : null,
|
|
599
|
+
accounts: Array.isArray(value?.accounts) ? value.accounts : [],
|
|
483
600
|
}));
|
|
484
601
|
}
|
|
485
602
|
function getUnhealthyConfiguredConnectors(statusPayload) {
|
|
@@ -510,26 +627,208 @@ function humanConnectorName(key) {
|
|
|
510
627
|
return 'GitHub';
|
|
511
628
|
return key;
|
|
512
629
|
}
|
|
630
|
+
function connectorWizardKey(key) {
|
|
631
|
+
if (key === 'analyticscli')
|
|
632
|
+
return 'analytics';
|
|
633
|
+
if (key === 'appStoreConnect')
|
|
634
|
+
return 'asc';
|
|
635
|
+
if (key === 'revenuecat')
|
|
636
|
+
return 'revenuecat';
|
|
637
|
+
if (key === 'sentry')
|
|
638
|
+
return 'sentry';
|
|
639
|
+
if (key === 'github')
|
|
640
|
+
return 'github';
|
|
641
|
+
return '';
|
|
642
|
+
}
|
|
643
|
+
function buildConnectorWizardCommand(configPath, entry) {
|
|
644
|
+
const connector = connectorWizardKey(entry.key);
|
|
645
|
+
if (!connector)
|
|
646
|
+
return null;
|
|
647
|
+
return `npx -y @analyticscli/growth-engineer@preview wizard --connectors ${quote(connector)}`;
|
|
648
|
+
}
|
|
649
|
+
function conciseConnectorDetail(entry) {
|
|
650
|
+
const detail = String(entry?.detail || '').replace(/\s+/g, ' ').trim();
|
|
651
|
+
if (/SENTRY_AUTH_TOKEN is required|SENTRY_AUTH_TOKEN.*missing/i.test(detail)) {
|
|
652
|
+
return 'SENTRY_AUTH_TOKEN missing for source collection.';
|
|
653
|
+
}
|
|
654
|
+
if (/source .*disabled|still disabled/i.test(detail)) {
|
|
655
|
+
return 'source is still disabled after setup.';
|
|
656
|
+
}
|
|
657
|
+
if (!detail)
|
|
658
|
+
return 'needs attention.';
|
|
659
|
+
return detail.length > 180 ? `${detail.slice(0, 177)}...` : detail;
|
|
660
|
+
}
|
|
661
|
+
function isAscWebAuthIssue(entry) {
|
|
662
|
+
if (entry.key !== 'appStoreConnect')
|
|
663
|
+
return false;
|
|
664
|
+
const text = `${entry.detail || ''}\n${entry.nextAction || ''}`.toLowerCase();
|
|
665
|
+
return (text.includes('asc web auth') ||
|
|
666
|
+
text.includes('asc_web_apple_id') ||
|
|
667
|
+
text.includes('web analytics') ||
|
|
668
|
+
text.includes('webauth') ||
|
|
669
|
+
text.includes('web auth'));
|
|
670
|
+
}
|
|
513
671
|
function buildConnectorHealthAlert(statusPayload, unhealthyConnectors) {
|
|
514
|
-
const
|
|
515
|
-
|
|
516
|
-
`Config: ${statusPayload?.configPath || DEFAULT_CONFIG_PATH}`,
|
|
517
|
-
'',
|
|
518
|
-
'Unhealthy connector(s):',
|
|
519
|
-
];
|
|
672
|
+
const configPath = statusPayload?.configPath || DEFAULT_CONFIG_PATH;
|
|
673
|
+
const lines = [`OpenClaw connector health: ${unhealthyConnectors.length} issue(s)`];
|
|
520
674
|
for (const entry of unhealthyConnectors) {
|
|
521
|
-
lines.push(`- ${humanConnectorName(entry.key)}: ${entry.status} - ${entry
|
|
522
|
-
|
|
523
|
-
|
|
675
|
+
lines.push(`- ${humanConnectorName(entry.key)}: ${entry.status} - ${conciseConnectorDetail(entry)}`);
|
|
676
|
+
const command = buildConnectorWizardCommand(configPath, entry);
|
|
677
|
+
if (command) {
|
|
678
|
+
lines.push(` Fix: \`${command}\``);
|
|
524
679
|
}
|
|
525
|
-
if (entry
|
|
526
|
-
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"`');
|
|
527
682
|
}
|
|
528
683
|
}
|
|
529
|
-
lines.push('');
|
|
530
|
-
lines.push('Do not send secrets through chat or social channels. Refresh credentials only in the host terminal or secret store.');
|
|
684
|
+
lines.push('Secrets stay in the host terminal or secret store.');
|
|
531
685
|
return `${lines.join('\n')}\n`;
|
|
532
686
|
}
|
|
687
|
+
function sourceFailureConnectorKey(failure) {
|
|
688
|
+
const service = String(failure?.service || '').toLowerCase();
|
|
689
|
+
const key = String(failure?.key || '').toLowerCase();
|
|
690
|
+
const source = String(failure?.source || '').toLowerCase();
|
|
691
|
+
if (service.includes('sentry') || key === 'glitchtip')
|
|
692
|
+
return 'sentry';
|
|
693
|
+
if (source === 'sentry' || source === 'glitchtip')
|
|
694
|
+
return 'sentry';
|
|
695
|
+
if (service.includes('revenuecat'))
|
|
696
|
+
return 'revenuecat';
|
|
697
|
+
if (service.includes('paddle'))
|
|
698
|
+
return 'paddle';
|
|
699
|
+
if (service.includes('seo') || service.includes('gsc') || service.includes('search-console') || service.includes('dataforseo'))
|
|
700
|
+
return 'seo';
|
|
701
|
+
if (key === 'paddle')
|
|
702
|
+
return 'paddle';
|
|
703
|
+
if (key === 'seo')
|
|
704
|
+
return 'seo';
|
|
705
|
+
if (service.includes('coolify'))
|
|
706
|
+
return 'coolify';
|
|
707
|
+
if (service.includes('github'))
|
|
708
|
+
return 'github';
|
|
709
|
+
if (key === 'analytics')
|
|
710
|
+
return 'analyticscli';
|
|
711
|
+
return String(failure?.key || 'source');
|
|
712
|
+
}
|
|
713
|
+
function getSentryAccountTargets(config) {
|
|
714
|
+
const accounts = Array.isArray(config?.sources?.sentry?.accounts) ? config.sources.sentry.accounts : [];
|
|
715
|
+
if (accounts.length === 0)
|
|
716
|
+
return [];
|
|
717
|
+
return accounts.map((account, index) => ({
|
|
718
|
+
id: String(account?.id || account?.key || account?.label || `sentry_${index + 1}`)
|
|
719
|
+
.trim()
|
|
720
|
+
.replace(/[^a-zA-Z0-9._-]+/g, '_'),
|
|
721
|
+
label: String(account?.label || account?.name || account?.id || `Sentry ${index + 1}`).trim(),
|
|
722
|
+
baseUrl: String(account?.baseUrl || account?.base_url || account?.url || 'https://sentry.io').trim(),
|
|
723
|
+
org: String(account?.org || account?.organization || '').trim(),
|
|
724
|
+
projects: Array.isArray(account?.projects)
|
|
725
|
+
? account.projects.map((project) => String(typeof project === 'string' ? project : project?.project || project?.slug || '').trim()).filter(Boolean)
|
|
726
|
+
: account?.project
|
|
727
|
+
? [String(account.project).trim()].filter(Boolean)
|
|
728
|
+
: [],
|
|
729
|
+
environment: String(account?.environment || process.env.SENTRY_ENVIRONMENT || 'production').trim(),
|
|
730
|
+
}));
|
|
731
|
+
}
|
|
732
|
+
function buildSourceFailureStatusPayload(configPath, sourceFailures, config = null) {
|
|
733
|
+
const connectors = {};
|
|
734
|
+
for (const failure of sourceFailures) {
|
|
735
|
+
const key = sourceFailureConnectorKey(failure);
|
|
736
|
+
const detail = `Source collection failed during scheduled run: ${failure.detail}`;
|
|
737
|
+
const retryable = Boolean(failure.retryable || failure.transient);
|
|
738
|
+
connectors[key] = {
|
|
739
|
+
status: 'partial',
|
|
740
|
+
detail,
|
|
741
|
+
accounts: key === 'sentry' ? getSentryAccountTargets(config) : [],
|
|
742
|
+
nextAction: retryable
|
|
743
|
+
? 'Provider returned a transient upstream/network error after retry. Rerun the Growth Engineer later; if it repeats, check the provider status page and connector credentials.'
|
|
744
|
+
: 'Run the connector wizard or source command on the host terminal and fix the reported source error.',
|
|
745
|
+
};
|
|
746
|
+
}
|
|
747
|
+
return {
|
|
748
|
+
generatedAt: new Date().toISOString(),
|
|
749
|
+
configPath,
|
|
750
|
+
connectors,
|
|
751
|
+
sourceFailures,
|
|
752
|
+
};
|
|
753
|
+
}
|
|
754
|
+
async function recordSourceCollectionFailures({ config, configPath, state, statePath, runtimeDir, sourceFailures }) {
|
|
755
|
+
if (sourceFailures.length === 0) {
|
|
756
|
+
return {
|
|
757
|
+
...state,
|
|
758
|
+
lastSourceCollectionFailures: [],
|
|
759
|
+
};
|
|
760
|
+
}
|
|
761
|
+
const healthState = state?.connectorHealth || {};
|
|
762
|
+
const checkedAt = new Date().toISOString();
|
|
763
|
+
const statusPayload = buildSourceFailureStatusPayload(configPath, sourceFailures, config);
|
|
764
|
+
const unhealthyConnectors = getUnhealthyConfiguredConnectors(statusPayload);
|
|
765
|
+
const fingerprint = buildConnectorHealthFingerprint(unhealthyConnectors);
|
|
766
|
+
const previousExternallyDeliveredFingerprint = healthState.lastExternalAlertedFingerprint || null;
|
|
767
|
+
let alertTriggered = false;
|
|
768
|
+
let alertDeliveries = [];
|
|
769
|
+
const nextHealthState = {
|
|
770
|
+
...healthState,
|
|
771
|
+
lastCheckedAt: checkedAt,
|
|
772
|
+
lastStatusOk: false,
|
|
773
|
+
lastFingerprint: fingerprint,
|
|
774
|
+
activeIncidentFingerprint: fingerprint,
|
|
775
|
+
lastError: sourceFailures.map((failure) => `${failure.key || failure.source}: ${failure.detail}`).join('\n'),
|
|
776
|
+
};
|
|
777
|
+
if (previousExternallyDeliveredFingerprint !== fingerprint) {
|
|
778
|
+
const message = buildConnectorHealthAlert(statusPayload, unhealthyConnectors);
|
|
779
|
+
const paths = await writeConnectorHealthAlert(runtimeDir, message, statusPayload, unhealthyConnectors, fingerprint);
|
|
780
|
+
const deliveries = await deliverConnectorHealthAlert({
|
|
781
|
+
config,
|
|
782
|
+
configPath,
|
|
783
|
+
message,
|
|
784
|
+
statusPayload,
|
|
785
|
+
unhealthyConnectors,
|
|
786
|
+
fingerprint,
|
|
787
|
+
});
|
|
788
|
+
alertTriggered = true;
|
|
789
|
+
alertDeliveries = deliveries;
|
|
790
|
+
nextHealthState.lastAlertedAt = checkedAt;
|
|
791
|
+
nextHealthState.lastAlertedFingerprint = fingerprint;
|
|
792
|
+
nextHealthState.lastAlertMarkdownPath = paths.markdownPath;
|
|
793
|
+
nextHealthState.lastAlertJsonPath = paths.jsonPath;
|
|
794
|
+
nextHealthState.lastAlertDeliveries = deliveries;
|
|
795
|
+
nextHealthState.lastAlertExternalSent = hasSuccessfulExternalDelivery(deliveries);
|
|
796
|
+
if (nextHealthState.lastAlertExternalSent) {
|
|
797
|
+
nextHealthState.lastExternalAlertedAt = checkedAt;
|
|
798
|
+
nextHealthState.lastExternalAlertedFingerprint = fingerprint;
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
const nextState = {
|
|
802
|
+
...state,
|
|
803
|
+
connectorHealth: nextHealthState,
|
|
804
|
+
lastSourceCollectionFailures: sourceFailures,
|
|
805
|
+
};
|
|
806
|
+
await fs.mkdir(path.dirname(statePath), { recursive: true });
|
|
807
|
+
await fs.writeFile(statePath, JSON.stringify(nextState, null, 2), 'utf8');
|
|
808
|
+
await appendSchedulerProof('source_collection_degraded', {
|
|
809
|
+
configPath,
|
|
810
|
+
statePath,
|
|
811
|
+
checkedAt,
|
|
812
|
+
sourceFailures: sourceFailures.map((failure) => ({
|
|
813
|
+
key: failure.key,
|
|
814
|
+
detail: failure.detail,
|
|
815
|
+
retryable: failure.retryable,
|
|
816
|
+
})),
|
|
817
|
+
unhealthyConnectors: unhealthyConnectors.map((entry) => ({
|
|
818
|
+
key: entry.key,
|
|
819
|
+
status: entry.status,
|
|
820
|
+
detail: entry.detail,
|
|
821
|
+
})),
|
|
822
|
+
alertTriggered,
|
|
823
|
+
deliveryCount: alertDeliveries.length,
|
|
824
|
+
externalDeliverySent: alertTriggered ? hasSuccessfulExternalDelivery(alertDeliveries) : false,
|
|
825
|
+
socialOutput: alertTriggered ? 'CONNECTOR_HEALTH_ALERT' : 'HEARTBEAT_OK',
|
|
826
|
+
socialReason: alertTriggered
|
|
827
|
+
? 'new or changed source-collection connector incident'
|
|
828
|
+
: 'source-collection connector incident unchanged',
|
|
829
|
+
});
|
|
830
|
+
return nextState;
|
|
831
|
+
}
|
|
533
832
|
async function writeConnectorHealthAlert(runtimeDir, message, statusPayload, unhealthyConnectors, fingerprint) {
|
|
534
833
|
const alertDir = path.join(runtimeDir, 'connector-health');
|
|
535
834
|
await ensureDir(alertDir);
|
|
@@ -544,20 +843,43 @@ async function writeConnectorHealthAlert(runtimeDir, message, statusPayload, unh
|
|
|
544
843
|
}, null, 2), 'utf8');
|
|
545
844
|
return { markdownPath, jsonPath };
|
|
546
845
|
}
|
|
547
|
-
function
|
|
548
|
-
const
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
if (
|
|
552
|
-
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) {
|
|
553
870
|
const channels = [];
|
|
554
871
|
const deliveries = config?.deliveries || {};
|
|
555
872
|
if (deliveries.openclawChat?.enabled) {
|
|
873
|
+
const isConnectorHealth = kind === 'connectorHealth';
|
|
556
874
|
channels.push({
|
|
557
875
|
type: 'openclaw-chat',
|
|
558
876
|
label: 'openclaw_chat',
|
|
559
|
-
markdownPath:
|
|
560
|
-
|
|
877
|
+
markdownPath: isConnectorHealth
|
|
878
|
+
? deliveries.openclawChat.connectorHealthMarkdownPath || deliveries.openclawChat.markdownPath
|
|
879
|
+
: deliveries.openclawChat.growthRunMarkdownPath || '.openclaw/chat/growth-summary.md',
|
|
880
|
+
jsonPath: isConnectorHealth
|
|
881
|
+
? deliveries.openclawChat.connectorHealthJsonPath || deliveries.openclawChat.jsonPath
|
|
882
|
+
: deliveries.openclawChat.growthRunJsonPath || '.openclaw/chat/growth-summary.json',
|
|
561
883
|
});
|
|
562
884
|
}
|
|
563
885
|
if (deliveries.slack?.enabled) {
|
|
@@ -576,19 +898,37 @@ function getConnectorHealthChannels(config) {
|
|
|
576
898
|
headers: deliveries.webhook.headers || {},
|
|
577
899
|
});
|
|
578
900
|
}
|
|
901
|
+
if (deliveries.command?.enabled) {
|
|
902
|
+
channels.push({
|
|
903
|
+
type: 'command',
|
|
904
|
+
label: deliveries.command.label || 'command',
|
|
905
|
+
command: deliveries.command.command || '',
|
|
906
|
+
});
|
|
907
|
+
}
|
|
579
908
|
if (deliveries.discord?.enabled) {
|
|
580
909
|
channels.push({
|
|
581
910
|
type: 'command',
|
|
582
|
-
label: 'discord',
|
|
583
|
-
command: deliveries.discord.command || '
|
|
911
|
+
label: deliveries.discord.label || 'discord',
|
|
912
|
+
command: deliveries.discord.command || '',
|
|
584
913
|
});
|
|
585
914
|
}
|
|
586
915
|
return channels;
|
|
587
916
|
}
|
|
917
|
+
function getConnectorHealthChannels(config) {
|
|
918
|
+
const configuredChannels = Array.isArray(config?.notifications?.connectorHealth?.channels)
|
|
919
|
+
? config.notifications.connectorHealth.channels
|
|
920
|
+
: [];
|
|
921
|
+
return mergeNotificationChannelsWithDeliveries(configuredChannels, getDeliveryNotificationChannels(config, 'connectorHealth'));
|
|
922
|
+
}
|
|
923
|
+
function resolveOpenClawChatDeliveryPath(channelPath, fallbackPath) {
|
|
924
|
+
const targetPath = String(channelPath || fallbackPath || '').trim();
|
|
925
|
+
if (!targetPath)
|
|
926
|
+
return path.resolve(process.cwd(), fallbackPath);
|
|
927
|
+
return path.isAbsolute(targetPath) ? targetPath : path.resolve(process.cwd(), targetPath);
|
|
928
|
+
}
|
|
588
929
|
async function writeConfiguredOpenClawChatAlert(configPath, channel, message, statusPayload, unhealthyConnectors, fingerprint) {
|
|
589
|
-
const
|
|
590
|
-
const
|
|
591
|
-
const jsonPath = path.resolve(baseDir, channel.jsonPath || '.openclaw/chat/connector-health.json');
|
|
930
|
+
const markdownPath = resolveOpenClawChatDeliveryPath(channel.markdownPath, '.openclaw/chat/connector-health.md');
|
|
931
|
+
const jsonPath = resolveOpenClawChatDeliveryPath(channel.jsonPath, '.openclaw/chat/connector-health.json');
|
|
592
932
|
await fs.mkdir(path.dirname(markdownPath), { recursive: true });
|
|
593
933
|
await fs.mkdir(path.dirname(jsonPath), { recursive: true });
|
|
594
934
|
await fs.writeFile(markdownPath, message, 'utf8');
|
|
@@ -601,8 +941,9 @@ async function writeConfiguredOpenClawChatAlert(configPath, channel, message, st
|
|
|
601
941
|
}, null, 2), 'utf8');
|
|
602
942
|
return {
|
|
603
943
|
sent: true,
|
|
944
|
+
external: false,
|
|
604
945
|
target: channel.label || 'openclaw_chat',
|
|
605
|
-
detail: `wrote ${markdownPath} and ${jsonPath}`,
|
|
946
|
+
detail: `wrote local OpenClaw chat outbox ${markdownPath} and ${jsonPath}`,
|
|
606
947
|
};
|
|
607
948
|
}
|
|
608
949
|
async function sendSlackConnectorHealthAlert(channel, message) {
|
|
@@ -618,6 +959,7 @@ async function sendSlackConnectorHealthAlert(channel, message) {
|
|
|
618
959
|
});
|
|
619
960
|
return {
|
|
620
961
|
sent: response.ok,
|
|
962
|
+
external: true,
|
|
621
963
|
target: channel.label || 'slack',
|
|
622
964
|
detail: response.ok ? `HTTP ${response.status}` : `HTTP ${response.status}: ${await response.text()}`,
|
|
623
965
|
};
|
|
@@ -645,6 +987,7 @@ async function sendWebhookConnectorHealthAlert(channel, message, statusPayload,
|
|
|
645
987
|
});
|
|
646
988
|
return {
|
|
647
989
|
sent: response.ok,
|
|
990
|
+
external: true,
|
|
648
991
|
target: channel.label || 'webhook',
|
|
649
992
|
detail: response.ok ? `HTTP ${response.status}` : `HTTP ${response.status}: ${await response.text()}`,
|
|
650
993
|
};
|
|
@@ -656,10 +999,49 @@ async function sendCommandConnectorHealthAlert(channel, message) {
|
|
|
656
999
|
const result = await runShellCommand(String(channel.command), 60_000, { input: message });
|
|
657
1000
|
return {
|
|
658
1001
|
sent: result.ok,
|
|
1002
|
+
external: true,
|
|
659
1003
|
target: channel.label || 'command',
|
|
660
1004
|
detail: result.ok ? result.stdout.trim() : result.stderr.trim() || result.stdout.trim() || `exit ${result.code}`,
|
|
661
1005
|
};
|
|
662
1006
|
}
|
|
1007
|
+
function hasExternalNotificationChannel(channels) {
|
|
1008
|
+
return channels.some((channel) => channel?.type && channel.type !== 'openclaw-chat');
|
|
1009
|
+
}
|
|
1010
|
+
function hasSuccessfulExternalDelivery(results) {
|
|
1011
|
+
return results.some((result) => result?.sent === true && result?.external === true);
|
|
1012
|
+
}
|
|
1013
|
+
function truncateMessageText(value, maxLength = 96) {
|
|
1014
|
+
const text = String(value || '').replace(/\s+/g, ' ').trim();
|
|
1015
|
+
if (text.length <= maxLength)
|
|
1016
|
+
return text;
|
|
1017
|
+
return `${text.slice(0, Math.max(0, maxLength - 3)).trim()}...`;
|
|
1018
|
+
}
|
|
1019
|
+
function issueProjectLabel(issue) {
|
|
1020
|
+
return String(issue?.app || issue?.source_project || issue?.sourceProject || issue?.project || 'unscoped').trim();
|
|
1021
|
+
}
|
|
1022
|
+
function issueSourceUrl(issue) {
|
|
1023
|
+
const direct = String(issue?.source_url || issue?.sourceUrl || issue?.issue_url || issue?.issueUrl || '').trim();
|
|
1024
|
+
if (direct)
|
|
1025
|
+
return direct;
|
|
1026
|
+
const body = String(issue?.body || '');
|
|
1027
|
+
const match = body.match(/(?:Issue link|Permalink):\s*(https?:\/\/\S+)/i);
|
|
1028
|
+
return match ? match[1].replace(/[).,;]+$/, '') : '';
|
|
1029
|
+
}
|
|
1030
|
+
function formatIssueSummaryLine(issue, maxTitleLength = 92) {
|
|
1031
|
+
const title = truncateMessageText(issue?.title, maxTitleLength);
|
|
1032
|
+
const url = issueSourceUrl(issue);
|
|
1033
|
+
return url ? `${title} (${url})` : title;
|
|
1034
|
+
}
|
|
1035
|
+
function groupIssuesByProject(issues, maxIssues = 4) {
|
|
1036
|
+
const grouped = new Map();
|
|
1037
|
+
for (const issue of issues.slice(0, maxIssues)) {
|
|
1038
|
+
const label = issueProjectLabel(issue);
|
|
1039
|
+
const bucket = grouped.get(label) || [];
|
|
1040
|
+
bucket.push(issue);
|
|
1041
|
+
grouped.set(label, bucket);
|
|
1042
|
+
}
|
|
1043
|
+
return [...grouped.entries()];
|
|
1044
|
+
}
|
|
663
1045
|
async function deliverConnectorHealthAlert({ config, configPath, message, statusPayload, unhealthyConnectors, fingerprint }) {
|
|
664
1046
|
const channels = getConnectorHealthChannels(config);
|
|
665
1047
|
if (config?.notifications?.connectorHealth?.enabled === false) {
|
|
@@ -695,55 +1077,76 @@ async function deliverConnectorHealthAlert({ config, configPath, message, status
|
|
|
695
1077
|
});
|
|
696
1078
|
}
|
|
697
1079
|
}
|
|
1080
|
+
if (!hasSuccessfulExternalDelivery(results)) {
|
|
1081
|
+
results.push({
|
|
1082
|
+
sent: false,
|
|
1083
|
+
external: true,
|
|
1084
|
+
target: 'external_notification',
|
|
1085
|
+
detail: hasExternalNotificationChannel(channels)
|
|
1086
|
+
? 'No external notification channel successfully sent the alert.'
|
|
1087
|
+
: 'Alert written locally, but no external notification channel configured.',
|
|
1088
|
+
});
|
|
1089
|
+
}
|
|
698
1090
|
return results;
|
|
699
1091
|
}
|
|
700
1092
|
function getGrowthRunChannels(config) {
|
|
701
1093
|
const configuredChannels = Array.isArray(config?.notifications?.growthRun?.channels)
|
|
702
|
-
? config.notifications.growthRun.channels
|
|
1094
|
+
? config.notifications.growthRun.channels
|
|
703
1095
|
: [];
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
webhookEnv: deliveries.slack.webhookEnv || 'SLACK_WEBHOOK_URL',
|
|
721
|
-
});
|
|
722
|
-
}
|
|
723
|
-
if (deliveries.webhook?.enabled) {
|
|
724
|
-
channels.push({
|
|
725
|
-
type: 'webhook',
|
|
726
|
-
label: 'webhook',
|
|
727
|
-
urlEnv: deliveries.webhook.urlEnv || 'OPENCLAW_WEBHOOK_URL',
|
|
728
|
-
method: deliveries.webhook.method || 'POST',
|
|
729
|
-
headers: deliveries.webhook.headers || {},
|
|
730
|
-
});
|
|
1096
|
+
return mergeNotificationChannelsWithDeliveries(configuredChannels, getDeliveryNotificationChannels(config, 'growthRun'));
|
|
1097
|
+
}
|
|
1098
|
+
async function readChartAttachments(chartManifestPath) {
|
|
1099
|
+
if (!chartManifestPath)
|
|
1100
|
+
return [];
|
|
1101
|
+
try {
|
|
1102
|
+
const manifest = await readJson(chartManifestPath);
|
|
1103
|
+
return Array.isArray(manifest?.charts)
|
|
1104
|
+
? manifest.charts
|
|
1105
|
+
.map((chart) => ({
|
|
1106
|
+
signalId: String(chart.signal_id || chart.signalId || '').trim(),
|
|
1107
|
+
filePath: String(chart.file_path || chart.filePath || '').trim(),
|
|
1108
|
+
caption: String(chart.caption || chart.title || 'Data chart').trim(),
|
|
1109
|
+
}))
|
|
1110
|
+
.filter((chart) => chart.filePath)
|
|
1111
|
+
: [];
|
|
731
1112
|
}
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
type: 'command',
|
|
735
|
-
label: 'discord',
|
|
736
|
-
command: deliveries.discord.command || 'node scripts/discord-openclaw-bridge.mjs send --stdin',
|
|
737
|
-
});
|
|
1113
|
+
catch {
|
|
1114
|
+
return [];
|
|
738
1115
|
}
|
|
739
|
-
return channels;
|
|
740
1116
|
}
|
|
741
|
-
function buildGrowthRunSummaryMessage({ issuesPayload, activeCadences, sourceFiles, createdGitHubArtifact }) {
|
|
1117
|
+
function buildGrowthRunSummaryMessage({ issuesPayload, activeCadences, sourceFiles, createdGitHubArtifact, charts = [] }) {
|
|
742
1118
|
const issueCount = Number(issuesPayload?.issue_count || 0);
|
|
743
1119
|
const cadenceNames = activeCadences.length > 0
|
|
744
1120
|
? activeCadences.map((cadence) => cadence.title || cadence.key).join(', ')
|
|
745
1121
|
: 'ad-hoc growth pass';
|
|
746
1122
|
const sourceNames = Object.keys(sourceFiles || {}).sort().join(', ') || 'none';
|
|
1123
|
+
const issues = Array.isArray(issuesPayload?.issues) ? issuesPayload.issues : [];
|
|
1124
|
+
if (isShortOperationalCadence(activeCadences)) {
|
|
1125
|
+
const heading = activeCadences.some((cadence) => String(cadence?.key) === 'healthcheck')
|
|
1126
|
+
? 'OpenClaw healthcheck'
|
|
1127
|
+
: 'OpenClaw daily';
|
|
1128
|
+
const lines = [
|
|
1129
|
+
`${heading}: ${issueCount > 0 ? `${issueCount} finding(s)` : 'OK'}`,
|
|
1130
|
+
];
|
|
1131
|
+
if (issueCount > 0) {
|
|
1132
|
+
const groupedIssues = groupIssuesByProject(issues, 4);
|
|
1133
|
+
if (groupedIssues.length > 0) {
|
|
1134
|
+
lines.push('Top by project:');
|
|
1135
|
+
for (const [project, projectIssues] of groupedIssues) {
|
|
1136
|
+
const formatted = projectIssues.map((issue) => formatIssueSummaryLine(issue, 84)).filter(Boolean);
|
|
1137
|
+
if (formatted.length > 0)
|
|
1138
|
+
lines.push(`- ${project}: ${formatted.join(' | ')}`);
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
lines.push(createdGitHubArtifact
|
|
1142
|
+
? 'Action: GitHub artifact attempted.'
|
|
1143
|
+
: 'Action: external alert only.');
|
|
1144
|
+
}
|
|
1145
|
+
if (charts.length > 0) {
|
|
1146
|
+
lines.push(`Charts: ${charts.length}`);
|
|
1147
|
+
}
|
|
1148
|
+
return `${lines.join('\n')}\n`;
|
|
1149
|
+
}
|
|
747
1150
|
const lines = [
|
|
748
1151
|
`OpenClaw Growth run finished (${new Date().toISOString()}).`,
|
|
749
1152
|
`Cadence: ${cadenceNames}`,
|
|
@@ -756,22 +1159,37 @@ function buildGrowthRunSummaryMessage({ issuesPayload, activeCadences, sourceFil
|
|
|
756
1159
|
if (createdGitHubArtifact) {
|
|
757
1160
|
lines.push('GitHub artifact creation was attempted for the generated proposals.');
|
|
758
1161
|
}
|
|
759
|
-
|
|
760
|
-
|
|
1162
|
+
if (charts.length > 0) {
|
|
1163
|
+
lines.push(`Charts generated: ${charts.length}`);
|
|
1164
|
+
for (const chart of charts.slice(0, 5)) {
|
|
1165
|
+
lines.push(`- ${chart.caption}: ${chart.filePath}`);
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1168
|
+
const topIssues = issues.slice(0, isDeepAnalysisCadence(activeCadences) ? 5 : 3);
|
|
1169
|
+
if (topIssues.length > 0) {
|
|
761
1170
|
lines.push('');
|
|
762
|
-
lines.push('Top findings:');
|
|
763
|
-
for (const issue of
|
|
1171
|
+
lines.push(isDeepAnalysisCadence(activeCadences) ? 'App-by-app findings and next steps:' : 'Top findings:');
|
|
1172
|
+
for (const issue of topIssues) {
|
|
764
1173
|
lines.push(`- ${issue.title} (${issue.priority || 'medium'}, ${issue.area || 'general'})`);
|
|
1174
|
+
if (isDeepAnalysisCadence(activeCadences)) {
|
|
1175
|
+
for (const evidence of firstEvidenceLines(issue, 2)) {
|
|
1176
|
+
lines.push(` Evidence: ${evidence}`);
|
|
1177
|
+
}
|
|
1178
|
+
if (issue.expected_impact) {
|
|
1179
|
+
lines.push(` Impact: ${issue.expected_impact}`);
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
765
1182
|
}
|
|
766
1183
|
}
|
|
767
1184
|
lines.push('');
|
|
768
|
-
lines.push(
|
|
1185
|
+
lines.push(isDeepAnalysisCadence(activeCadences)
|
|
1186
|
+
? 'No secrets were included. Full details are in the generated issue drafts, charts, and OpenClaw chat handoff.'
|
|
1187
|
+
: 'No secrets were included.');
|
|
769
1188
|
return `${lines.join('\n')}\n`;
|
|
770
1189
|
}
|
|
771
|
-
async function writeConfiguredOpenClawChatGrowthSummary(configPath, channel, message, issuesPayload, activeCadences, fingerprint) {
|
|
772
|
-
const
|
|
773
|
-
const
|
|
774
|
-
const jsonPath = path.resolve(baseDir, channel.jsonPath || '.openclaw/chat/growth-summary.json');
|
|
1190
|
+
async function writeConfiguredOpenClawChatGrowthSummary(configPath, channel, message, issuesPayload, activeCadences, fingerprint, charts) {
|
|
1191
|
+
const markdownPath = resolveOpenClawChatDeliveryPath(channel.markdownPath, '.openclaw/chat/growth-summary.md');
|
|
1192
|
+
const jsonPath = resolveOpenClawChatDeliveryPath(channel.jsonPath, '.openclaw/chat/growth-summary.json');
|
|
775
1193
|
await fs.mkdir(path.dirname(markdownPath), { recursive: true });
|
|
776
1194
|
await fs.mkdir(path.dirname(jsonPath), { recursive: true });
|
|
777
1195
|
await fs.writeFile(markdownPath, message, 'utf8');
|
|
@@ -782,11 +1200,18 @@ async function writeConfiguredOpenClawChatGrowthSummary(configPath, channel, mes
|
|
|
782
1200
|
activeCadences,
|
|
783
1201
|
issueCount: Number(issuesPayload?.issue_count || 0),
|
|
784
1202
|
issues: Array.isArray(issuesPayload?.issues) ? issuesPayload.issues : [],
|
|
1203
|
+
charts,
|
|
1204
|
+
attachments: charts.map((chart) => ({
|
|
1205
|
+
type: 'image/png',
|
|
1206
|
+
path: chart.filePath,
|
|
1207
|
+
caption: chart.caption,
|
|
1208
|
+
})),
|
|
785
1209
|
}, null, 2), 'utf8');
|
|
786
1210
|
return {
|
|
787
1211
|
sent: true,
|
|
1212
|
+
external: false,
|
|
788
1213
|
target: channel.label || 'openclaw_chat',
|
|
789
|
-
detail: `wrote ${markdownPath} and ${jsonPath}`,
|
|
1214
|
+
detail: `wrote local OpenClaw chat outbox ${markdownPath} and ${jsonPath}`,
|
|
790
1215
|
};
|
|
791
1216
|
}
|
|
792
1217
|
async function sendSlackGrowthSummary(channel, message) {
|
|
@@ -802,11 +1227,12 @@ async function sendSlackGrowthSummary(channel, message) {
|
|
|
802
1227
|
});
|
|
803
1228
|
return {
|
|
804
1229
|
sent: response.ok,
|
|
1230
|
+
external: true,
|
|
805
1231
|
target: channel.label || 'slack',
|
|
806
1232
|
detail: response.ok ? `HTTP ${response.status}` : `HTTP ${response.status}: ${await response.text()}`,
|
|
807
1233
|
};
|
|
808
1234
|
}
|
|
809
|
-
async function sendWebhookGrowthSummary(channel, message, issuesPayload, activeCadences, fingerprint) {
|
|
1235
|
+
async function sendWebhookGrowthSummary(channel, message, issuesPayload, activeCadences, fingerprint, charts) {
|
|
810
1236
|
const urlEnv = channel.urlEnv || channel.webhookEnv || 'OPENCLAW_WEBHOOK_URL';
|
|
811
1237
|
const webhookUrl = process.env[urlEnv];
|
|
812
1238
|
if (!webhookUrl) {
|
|
@@ -826,10 +1252,17 @@ async function sendWebhookGrowthSummary(channel, message, issuesPayload, activeC
|
|
|
826
1252
|
activeCadences,
|
|
827
1253
|
issueCount: Number(issuesPayload?.issue_count || 0),
|
|
828
1254
|
issues: Array.isArray(issuesPayload?.issues) ? issuesPayload.issues : [],
|
|
1255
|
+
charts,
|
|
1256
|
+
attachments: charts.map((chart) => ({
|
|
1257
|
+
type: 'image/png',
|
|
1258
|
+
path: chart.filePath,
|
|
1259
|
+
caption: chart.caption,
|
|
1260
|
+
})),
|
|
829
1261
|
}),
|
|
830
1262
|
});
|
|
831
1263
|
return {
|
|
832
1264
|
sent: response.ok,
|
|
1265
|
+
external: true,
|
|
833
1266
|
target: channel.label || 'webhook',
|
|
834
1267
|
detail: response.ok ? `HTTP ${response.status}` : `HTTP ${response.status}: ${await response.text()}`,
|
|
835
1268
|
};
|
|
@@ -841,11 +1274,12 @@ async function sendCommandGrowthSummary(channel, message) {
|
|
|
841
1274
|
const result = await runShellCommand(String(channel.command), 60_000, { input: message });
|
|
842
1275
|
return {
|
|
843
1276
|
sent: result.ok,
|
|
1277
|
+
external: true,
|
|
844
1278
|
target: channel.label || 'command',
|
|
845
1279
|
detail: result.ok ? result.stdout.trim() : result.stderr.trim() || result.stdout.trim() || `exit ${result.code}`,
|
|
846
1280
|
};
|
|
847
1281
|
}
|
|
848
|
-
async function deliverGrowthRunSummary({ config, configPath, issuesPayload, activeCadences, sourceFiles, fingerprint, createdGitHubArtifact, }) {
|
|
1282
|
+
async function deliverGrowthRunSummary({ config, configPath, issuesPayload, activeCadences, sourceFiles, fingerprint, createdGitHubArtifact, chartManifestPath, }) {
|
|
849
1283
|
if (config?.notifications?.growthRun?.enabled === false) {
|
|
850
1284
|
return [{ sent: false, target: 'notifications', detail: 'growth run notifications disabled' }];
|
|
851
1285
|
}
|
|
@@ -853,23 +1287,25 @@ async function deliverGrowthRunSummary({ config, configPath, issuesPayload, acti
|
|
|
853
1287
|
if (channels.length === 0) {
|
|
854
1288
|
return [{ sent: false, target: 'none', detail: 'no growth run notification channels configured' }];
|
|
855
1289
|
}
|
|
1290
|
+
const charts = await readChartAttachments(chartManifestPath);
|
|
856
1291
|
const message = buildGrowthRunSummaryMessage({
|
|
857
1292
|
issuesPayload,
|
|
858
1293
|
activeCadences,
|
|
859
1294
|
sourceFiles,
|
|
860
1295
|
createdGitHubArtifact,
|
|
1296
|
+
charts,
|
|
861
1297
|
});
|
|
862
1298
|
const results = [];
|
|
863
1299
|
for (const channel of channels) {
|
|
864
1300
|
try {
|
|
865
1301
|
if (channel.type === 'openclaw-chat') {
|
|
866
|
-
results.push(await writeConfiguredOpenClawChatGrowthSummary(configPath, channel, message, issuesPayload, activeCadences, fingerprint));
|
|
1302
|
+
results.push(await writeConfiguredOpenClawChatGrowthSummary(configPath, channel, message, issuesPayload, activeCadences, fingerprint, charts));
|
|
867
1303
|
}
|
|
868
1304
|
else if (channel.type === 'slack') {
|
|
869
1305
|
results.push(await sendSlackGrowthSummary(channel, message));
|
|
870
1306
|
}
|
|
871
1307
|
else if (channel.type === 'webhook') {
|
|
872
|
-
results.push(await sendWebhookGrowthSummary(channel, message, issuesPayload, activeCadences, fingerprint));
|
|
1308
|
+
results.push(await sendWebhookGrowthSummary(channel, message, issuesPayload, activeCadences, fingerprint, charts));
|
|
873
1309
|
}
|
|
874
1310
|
else if (channel.type === 'command') {
|
|
875
1311
|
results.push(await sendCommandGrowthSummary(channel, message));
|
|
@@ -892,6 +1328,16 @@ async function maybeRunConnectorHealthCheck({ config, configPath, state, statePa
|
|
|
892
1328
|
const healthState = state?.connectorHealth || {};
|
|
893
1329
|
const intervalMinutes = getConnectorHealthIntervalMinutes(config);
|
|
894
1330
|
if (!isDue(healthState.lastCheckedAt, intervalMinutes)) {
|
|
1331
|
+
await appendSchedulerProof('connector_health_not_due', {
|
|
1332
|
+
configPath,
|
|
1333
|
+
statePath,
|
|
1334
|
+
intervalMinutes,
|
|
1335
|
+
lastCheckedAt: healthState.lastCheckedAt || null,
|
|
1336
|
+
persistedLastStatusOk: healthState.lastStatusOk !== false,
|
|
1337
|
+
activeIncidentFingerprint: healthState.activeIncidentFingerprint || null,
|
|
1338
|
+
socialOutput: 'HEARTBEAT_OK',
|
|
1339
|
+
socialReason: 'connector health was not due; persisted unhealthy state is not a new event',
|
|
1340
|
+
});
|
|
895
1341
|
return state;
|
|
896
1342
|
}
|
|
897
1343
|
await ensureDir(runtimeDir);
|
|
@@ -917,6 +1363,13 @@ async function maybeRunConnectorHealthCheck({ config, configPath, state, statePa
|
|
|
917
1363
|
};
|
|
918
1364
|
await fs.mkdir(path.dirname(statePath), { recursive: true });
|
|
919
1365
|
await fs.writeFile(statePath, JSON.stringify(nextState, null, 2), 'utf8');
|
|
1366
|
+
await appendSchedulerProof('connector_health_check_failed', {
|
|
1367
|
+
configPath,
|
|
1368
|
+
statePath,
|
|
1369
|
+
intervalMinutes,
|
|
1370
|
+
checkedAt,
|
|
1371
|
+
error: nextState.connectorHealth.lastError,
|
|
1372
|
+
});
|
|
920
1373
|
return nextState;
|
|
921
1374
|
}
|
|
922
1375
|
const unhealthyConnectors = getUnhealthyConfiguredConnectors(statusPayload);
|
|
@@ -930,11 +1383,12 @@ async function maybeRunConnectorHealthCheck({ config, configPath, state, statePa
|
|
|
930
1383
|
connectedConnectors,
|
|
931
1384
|
lastError: null,
|
|
932
1385
|
};
|
|
933
|
-
const
|
|
934
|
-
|
|
935
|
-
|
|
1386
|
+
const previousExternallyDeliveredFingerprint = healthState.lastExternalAlertedFingerprint || null;
|
|
1387
|
+
let alertTriggered = false;
|
|
1388
|
+
let alertDeliveries = [];
|
|
936
1389
|
if (unhealthyConnectors.length === 0) {
|
|
937
1390
|
nextHealthState.activeIncidentFingerprint = null;
|
|
1391
|
+
nextHealthState.lastExternalAlertedFingerprint = null;
|
|
938
1392
|
if (healthState.lastStatusOk === false) {
|
|
939
1393
|
nextHealthState.lastRecoveredAt = checkedAt;
|
|
940
1394
|
}
|
|
@@ -943,7 +1397,7 @@ async function maybeRunConnectorHealthCheck({ config, configPath, state, statePa
|
|
|
943
1397
|
nextHealthState.activeIncidentFingerprint = fingerprint;
|
|
944
1398
|
}
|
|
945
1399
|
if (unhealthyConnectors.length > 0 &&
|
|
946
|
-
|
|
1400
|
+
previousExternallyDeliveredFingerprint !== fingerprint) {
|
|
947
1401
|
const message = buildConnectorHealthAlert(statusPayload, unhealthyConnectors);
|
|
948
1402
|
const paths = await writeConnectorHealthAlert(runtimeDir, message, statusPayload, unhealthyConnectors, fingerprint);
|
|
949
1403
|
const deliveries = await deliverConnectorHealthAlert({
|
|
@@ -954,11 +1408,18 @@ async function maybeRunConnectorHealthCheck({ config, configPath, state, statePa
|
|
|
954
1408
|
unhealthyConnectors,
|
|
955
1409
|
fingerprint,
|
|
956
1410
|
});
|
|
1411
|
+
alertTriggered = true;
|
|
1412
|
+
alertDeliveries = deliveries;
|
|
957
1413
|
nextHealthState.lastAlertedAt = checkedAt;
|
|
958
1414
|
nextHealthState.lastAlertedFingerprint = fingerprint;
|
|
959
1415
|
nextHealthState.lastAlertMarkdownPath = paths.markdownPath;
|
|
960
1416
|
nextHealthState.lastAlertJsonPath = paths.jsonPath;
|
|
961
1417
|
nextHealthState.lastAlertDeliveries = deliveries;
|
|
1418
|
+
nextHealthState.lastAlertExternalSent = hasSuccessfulExternalDelivery(deliveries);
|
|
1419
|
+
if (nextHealthState.lastAlertExternalSent) {
|
|
1420
|
+
nextHealthState.lastExternalAlertedAt = checkedAt;
|
|
1421
|
+
nextHealthState.lastExternalAlertedFingerprint = fingerprint;
|
|
1422
|
+
}
|
|
962
1423
|
}
|
|
963
1424
|
const nextState = {
|
|
964
1425
|
...state,
|
|
@@ -966,6 +1427,40 @@ async function maybeRunConnectorHealthCheck({ config, configPath, state, statePa
|
|
|
966
1427
|
};
|
|
967
1428
|
await fs.mkdir(path.dirname(statePath), { recursive: true });
|
|
968
1429
|
await fs.writeFile(statePath, JSON.stringify(nextState, null, 2), 'utf8');
|
|
1430
|
+
await appendSchedulerProof('connector_health_checked', {
|
|
1431
|
+
configPath,
|
|
1432
|
+
statePath,
|
|
1433
|
+
intervalMinutes,
|
|
1434
|
+
checkedAt,
|
|
1435
|
+
lastStatusOk: nextHealthState.lastStatusOk,
|
|
1436
|
+
connectedConnectors,
|
|
1437
|
+
unhealthyConnectors: unhealthyConnectors.map((entry) => ({
|
|
1438
|
+
key: entry.key,
|
|
1439
|
+
status: entry.status,
|
|
1440
|
+
detail: entry.detail,
|
|
1441
|
+
})),
|
|
1442
|
+
alertMarkdownPath: nextHealthState.lastAlertMarkdownPath || null,
|
|
1443
|
+
alertTriggered,
|
|
1444
|
+
deliveryCount: alertDeliveries.length,
|
|
1445
|
+
externalDeliverySent: alertTriggered ? hasSuccessfulExternalDelivery(alertDeliveries) : false,
|
|
1446
|
+
socialOutput: alertTriggered ? 'CONNECTOR_HEALTH_ALERT' : 'HEARTBEAT_OK',
|
|
1447
|
+
socialReason: alertTriggered
|
|
1448
|
+
? 'new or changed connector-health incident'
|
|
1449
|
+
: unhealthyConnectors.length > 0
|
|
1450
|
+
? 'connector-health incident unchanged'
|
|
1451
|
+
: healthState.lastStatusOk === false
|
|
1452
|
+
? 'connector health recovered'
|
|
1453
|
+
: 'connector health unchanged healthy',
|
|
1454
|
+
});
|
|
1455
|
+
if (unhealthyConnectors.length > 0 && !alertTriggered) {
|
|
1456
|
+
await appendSchedulerProof('connector_health_unchanged', {
|
|
1457
|
+
configPath,
|
|
1458
|
+
statePath,
|
|
1459
|
+
checkedAt,
|
|
1460
|
+
fingerprint,
|
|
1461
|
+
socialOutput: 'HEARTBEAT_OK',
|
|
1462
|
+
});
|
|
1463
|
+
}
|
|
969
1464
|
return nextState;
|
|
970
1465
|
}
|
|
971
1466
|
function buildIssueFingerprint(issuesPayload) {
|
|
@@ -974,7 +1469,31 @@ function buildIssueFingerprint(issuesPayload) {
|
|
|
974
1469
|
: [];
|
|
975
1470
|
return sha256(titles.join('\n'));
|
|
976
1471
|
}
|
|
977
|
-
|
|
1472
|
+
function isShortOperationalCadence(cadences) {
|
|
1473
|
+
if (!Array.isArray(cadences) || cadences.length === 0)
|
|
1474
|
+
return false;
|
|
1475
|
+
return cadences.every((cadence) => {
|
|
1476
|
+
const key = String(cadence?.key || '').toLowerCase();
|
|
1477
|
+
return key === 'healthcheck' || key === 'daily' || cadence?.criticalOnly === true;
|
|
1478
|
+
});
|
|
1479
|
+
}
|
|
1480
|
+
function isDeepAnalysisCadence(cadences) {
|
|
1481
|
+
if (!Array.isArray(cadences))
|
|
1482
|
+
return false;
|
|
1483
|
+
return cadences.some((cadence) => ['weekly', 'monthly', 'quarterly', 'six_months', 'yearly'].includes(String(cadence?.key || '').toLowerCase()));
|
|
1484
|
+
}
|
|
1485
|
+
function firstEvidenceLines(issue, maxLines = 2) {
|
|
1486
|
+
const body = String(issue?.body || '');
|
|
1487
|
+
const evidenceMatch = body.match(/## Evidence\n([\s\S]*?)(?:\n## |\n?$)/);
|
|
1488
|
+
if (!evidenceMatch)
|
|
1489
|
+
return [];
|
|
1490
|
+
return evidenceMatch[1]
|
|
1491
|
+
.split('\n')
|
|
1492
|
+
.map((line) => line.replace(/^-\s*/, '').trim())
|
|
1493
|
+
.filter(Boolean)
|
|
1494
|
+
.slice(0, maxLines);
|
|
1495
|
+
}
|
|
1496
|
+
async function runAnalyzer({ config, runtimeDir, sourceFiles, createGitHubArtifact, githubArtifactMode = getActionMode(config), chartManifestPath, cadencePlanPath, }) {
|
|
978
1497
|
await ensureDir(runtimeDir);
|
|
979
1498
|
if (!sourceFiles.analytics) {
|
|
980
1499
|
throw new Error('Analytics source is required (enable and configure `sources.analytics`).');
|
|
@@ -996,9 +1515,18 @@ async function runAnalyzer({ config, runtimeDir, sourceFiles, createGitHubArtifa
|
|
|
996
1515
|
if (sourceFiles.revenuecat) {
|
|
997
1516
|
args.push('--revenuecat', sourceFiles.revenuecat);
|
|
998
1517
|
}
|
|
1518
|
+
if (sourceFiles.paddle) {
|
|
1519
|
+
args.push('--source', `paddle=${sourceFiles.paddle}`);
|
|
1520
|
+
}
|
|
1521
|
+
if (sourceFiles.seo) {
|
|
1522
|
+
args.push('--source', `seo=${sourceFiles.seo}`);
|
|
1523
|
+
}
|
|
999
1524
|
if (sourceFiles.sentry) {
|
|
1000
1525
|
args.push('--sentry', sourceFiles.sentry);
|
|
1001
1526
|
}
|
|
1527
|
+
if (sourceFiles.coolify) {
|
|
1528
|
+
args.push('--source', `coolify=${sourceFiles.coolify}`);
|
|
1529
|
+
}
|
|
1002
1530
|
if (sourceFiles.feedback) {
|
|
1003
1531
|
args.push('--feedback', sourceFiles.feedback);
|
|
1004
1532
|
}
|
|
@@ -1009,8 +1537,8 @@ async function runAnalyzer({ config, runtimeDir, sourceFiles, createGitHubArtifa
|
|
|
1009
1537
|
}
|
|
1010
1538
|
if (createGitHubArtifact) {
|
|
1011
1539
|
const repo = String(config.project?.githubRepo || '').trim();
|
|
1012
|
-
args.push(
|
|
1013
|
-
if (
|
|
1540
|
+
args.push(githubArtifactMode === 'pull_request' ? '--create-pull-requests' : '--create-issues', '--repo', repo);
|
|
1541
|
+
if (githubArtifactMode === 'pull_request') {
|
|
1014
1542
|
args.push('--allow-proposal-pull-requests');
|
|
1015
1543
|
}
|
|
1016
1544
|
const labels = Array.isArray(config.project?.labels) ? config.project.labels : [];
|
|
@@ -1042,10 +1570,13 @@ async function runAnalyzer({ config, runtimeDir, sourceFiles, createGitHubArtifa
|
|
|
1042
1570
|
analyzerStdout: analyzer.stdout.trim(),
|
|
1043
1571
|
};
|
|
1044
1572
|
}
|
|
1045
|
-
async function maybeGenerateCharts({ config, payloads, runtimeDir }) {
|
|
1573
|
+
async function maybeGenerateCharts({ config, payloads, runtimeDir, activeCadences }) {
|
|
1046
1574
|
if (!config.charting?.enabled) {
|
|
1047
1575
|
return null;
|
|
1048
1576
|
}
|
|
1577
|
+
if (!isDeepAnalysisCadence(activeCadences)) {
|
|
1578
|
+
return null;
|
|
1579
|
+
}
|
|
1049
1580
|
const analyticsPayload = payloads.analytics;
|
|
1050
1581
|
if (!analyticsPayload) {
|
|
1051
1582
|
return null;
|
|
@@ -1114,22 +1645,48 @@ function resolveCursorAwareCommand(command, sourceConfig, cursorState) {
|
|
|
1114
1645
|
const lookback = normalizeLookback(sourceConfig?.initialLookback, '30d');
|
|
1115
1646
|
return `${rawCommand} --last ${quote(lookback)}`;
|
|
1116
1647
|
}
|
|
1117
|
-
async function resolveSourcePayloadWithCursor(sourceConfig, sourceName, cursorState, commandCwd = process.cwd()) {
|
|
1648
|
+
async function resolveSourcePayloadWithCursor(sourceConfig, sourceName, cursorState, commandCwd = process.cwd(), configPath = null) {
|
|
1118
1649
|
if (!sourceConfig || sourceConfig.enabled === false) {
|
|
1119
1650
|
return {
|
|
1120
1651
|
payload: null,
|
|
1121
1652
|
nextCursor: cursorState || null,
|
|
1122
1653
|
resolvedCommand: null,
|
|
1654
|
+
failure: null,
|
|
1123
1655
|
};
|
|
1124
1656
|
}
|
|
1125
1657
|
if (sourceConfig.mode === 'command') {
|
|
1126
1658
|
if (!sourceConfig.command) {
|
|
1127
1659
|
throw new Error(`Source "${sourceName}" has mode=command but no command configured.`);
|
|
1128
1660
|
}
|
|
1129
|
-
const resolvedCommand = resolveCursorAwareCommand(sourceConfig.command, sourceConfig, cursorState);
|
|
1130
|
-
|
|
1661
|
+
const resolvedCommand = resolveCursorAwareCommand(withActiveConfigArg(replaceLegacyRuntimeScriptCommand(sourceConfig.command), configPath), sourceConfig, cursorState);
|
|
1662
|
+
let result = await runShellCommand(String(resolvedCommand), 120_000, { cwd: commandCwd });
|
|
1663
|
+
let retried = false;
|
|
1664
|
+
if (!result.ok && isTransientNetworkFailure(result.stderr || result.stdout)) {
|
|
1665
|
+
retried = true;
|
|
1666
|
+
await sleep(1_500);
|
|
1667
|
+
result = await runShellCommand(String(resolvedCommand), 120_000, { cwd: commandCwd });
|
|
1668
|
+
}
|
|
1131
1669
|
if (!result.ok) {
|
|
1132
|
-
|
|
1670
|
+
const detail = `${retried ? 'transient network error persisted after retry: ' : ''}${result.stderr || `exit ${result.code}`}`;
|
|
1671
|
+
if (shouldDegradeTransientSourceFailure(sourceConfig, sourceName, retried)) {
|
|
1672
|
+
return {
|
|
1673
|
+
payload: null,
|
|
1674
|
+
nextCursor: cursorState || null,
|
|
1675
|
+
resolvedCommand,
|
|
1676
|
+
failure: {
|
|
1677
|
+
key: sourceName,
|
|
1678
|
+
label: sourceConfig.label || sourceName,
|
|
1679
|
+
service: sourceConfig.service || sourceName,
|
|
1680
|
+
source: sourceName,
|
|
1681
|
+
transient: true,
|
|
1682
|
+
retryable: true,
|
|
1683
|
+
retried: true,
|
|
1684
|
+
at: new Date().toISOString(),
|
|
1685
|
+
detail,
|
|
1686
|
+
},
|
|
1687
|
+
};
|
|
1688
|
+
}
|
|
1689
|
+
throw new Error(`Source "${sourceName}" command failed: ${detail}`);
|
|
1133
1690
|
}
|
|
1134
1691
|
const fetchedAt = new Date().toISOString();
|
|
1135
1692
|
try {
|
|
@@ -1140,9 +1697,11 @@ async function resolveSourcePayloadWithCursor(sourceConfig, sourceName, cursorSt
|
|
|
1140
1697
|
lastCollectedAt: fetchedAt,
|
|
1141
1698
|
updatedAt: fetchedAt,
|
|
1142
1699
|
lastCommand: resolvedCommand,
|
|
1700
|
+
lastRetriedTransientFailureAt: retried ? fetchedAt : null,
|
|
1143
1701
|
}
|
|
1144
1702
|
: cursorState || null,
|
|
1145
1703
|
resolvedCommand,
|
|
1704
|
+
failure: null,
|
|
1146
1705
|
};
|
|
1147
1706
|
}
|
|
1148
1707
|
catch {
|
|
@@ -1156,15 +1715,36 @@ async function resolveSourcePayloadWithCursor(sourceConfig, sourceName, cursorSt
|
|
|
1156
1715
|
payload: await readJson(path.resolve(String(sourceConfig.path))),
|
|
1157
1716
|
nextCursor: cursorState || null,
|
|
1158
1717
|
resolvedCommand: null,
|
|
1718
|
+
failure: null,
|
|
1159
1719
|
};
|
|
1160
1720
|
}
|
|
1161
|
-
async function loadSourcePayloads(config, state) {
|
|
1721
|
+
async function loadSourcePayloads(config, state, configPath) {
|
|
1162
1722
|
const payloads = {};
|
|
1163
1723
|
const sourceCursors = { ...(state?.sourceCursors || {}) };
|
|
1724
|
+
const sourceFailures = [];
|
|
1164
1725
|
const commandCwd = getProjectCommandCwd(config);
|
|
1165
1726
|
for (const source of getAllSourceEntries(config)) {
|
|
1166
1727
|
const currentCursor = sourceCursors[source.key] || null;
|
|
1167
|
-
|
|
1728
|
+
let result;
|
|
1729
|
+
try {
|
|
1730
|
+
result = await resolveSourcePayloadWithCursor(source, source.key, currentCursor, commandCwd, configPath);
|
|
1731
|
+
}
|
|
1732
|
+
catch (error) {
|
|
1733
|
+
if (source.key === 'analytics') {
|
|
1734
|
+
throw error;
|
|
1735
|
+
}
|
|
1736
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
1737
|
+
sourceFailures.push({
|
|
1738
|
+
key: source.key,
|
|
1739
|
+
label: source.label || source.key,
|
|
1740
|
+
service: source.service || source.key,
|
|
1741
|
+
detail,
|
|
1742
|
+
retryable: isTransientNetworkFailure(detail),
|
|
1743
|
+
failedAt: new Date().toISOString(),
|
|
1744
|
+
});
|
|
1745
|
+
process.stderr.write(`[${new Date().toISOString()}] Optional source "${source.key}" failed; continuing without it: ${detail}\n`);
|
|
1746
|
+
continue;
|
|
1747
|
+
}
|
|
1168
1748
|
const payload = result.payload;
|
|
1169
1749
|
if (payload) {
|
|
1170
1750
|
payloads[source.key] = payload;
|
|
@@ -1172,10 +1752,22 @@ async function loadSourcePayloads(config, state) {
|
|
|
1172
1752
|
if (result.nextCursor) {
|
|
1173
1753
|
sourceCursors[source.key] = result.nextCursor;
|
|
1174
1754
|
}
|
|
1755
|
+
if (result.failure) {
|
|
1756
|
+
sourceFailures.push(result.failure);
|
|
1757
|
+
await appendSchedulerProof('source_collection_degraded', {
|
|
1758
|
+
configPath,
|
|
1759
|
+
source: result.failure.source,
|
|
1760
|
+
transient: result.failure.transient,
|
|
1761
|
+
retried: result.failure.retried,
|
|
1762
|
+
detail: result.failure.detail,
|
|
1763
|
+
socialOutput: 'HEARTBEAT_OK',
|
|
1764
|
+
});
|
|
1765
|
+
}
|
|
1175
1766
|
}
|
|
1176
1767
|
return {
|
|
1177
1768
|
payloads,
|
|
1178
1769
|
sourceCursors,
|
|
1770
|
+
sourceFailures,
|
|
1179
1771
|
};
|
|
1180
1772
|
}
|
|
1181
1773
|
async function materializeSourceFiles(config, payloads, runtimeDir) {
|
|
@@ -1202,7 +1794,26 @@ function hasSourceChanges(previousHashes, currentHashes) {
|
|
|
1202
1794
|
return false;
|
|
1203
1795
|
}
|
|
1204
1796
|
async function runOnce(configPath, statePath) {
|
|
1797
|
+
await appendSchedulerProof('runner_invoked', {
|
|
1798
|
+
configPath,
|
|
1799
|
+
statePath,
|
|
1800
|
+
argv: process.argv.slice(2),
|
|
1801
|
+
});
|
|
1205
1802
|
const config = await readJson(configPath);
|
|
1803
|
+
const cronDeliveryRepair = await repairOpenClawCronDeliveryStore({
|
|
1804
|
+
configPath,
|
|
1805
|
+
config,
|
|
1806
|
+
readFile: fs.readFile,
|
|
1807
|
+
writeFile: fs.writeFile,
|
|
1808
|
+
});
|
|
1809
|
+
if (cronDeliveryRepair.repaired) {
|
|
1810
|
+
await appendSchedulerProof('openclaw_cron_delivery_repaired', {
|
|
1811
|
+
configPath,
|
|
1812
|
+
statePath,
|
|
1813
|
+
path: cronDeliveryRepair.path,
|
|
1814
|
+
repairedCount: cronDeliveryRepair.repairedCount,
|
|
1815
|
+
});
|
|
1816
|
+
}
|
|
1206
1817
|
await applyOpenClawSecretRefs(config);
|
|
1207
1818
|
const inferredGitHubRepo = await inferGitHubRepo(config);
|
|
1208
1819
|
if (inferredGitHubRepo) {
|
|
@@ -1218,7 +1829,7 @@ async function runOnce(configPath, statePath) {
|
|
|
1218
1829
|
lastRunAt: null,
|
|
1219
1830
|
sourceCursors: {},
|
|
1220
1831
|
});
|
|
1221
|
-
const runtimeDir = path.resolve(
|
|
1832
|
+
const runtimeDir = path.resolve(deriveRuntimeDirFromStatePath(statePath));
|
|
1222
1833
|
const stateAfterHealthCheck = await maybeRunConnectorHealthCheck({
|
|
1223
1834
|
config,
|
|
1224
1835
|
configPath,
|
|
@@ -1227,22 +1838,41 @@ async function runOnce(configPath, statePath) {
|
|
|
1227
1838
|
runtimeDir,
|
|
1228
1839
|
});
|
|
1229
1840
|
const activeCadences = getDueCadences(config, stateAfterHealthCheck);
|
|
1230
|
-
const { payloads, sourceCursors } = await loadSourcePayloads(config, stateAfterHealthCheck);
|
|
1841
|
+
const { payloads, sourceCursors, sourceFailures } = await loadSourcePayloads(config, stateAfterHealthCheck, configPath);
|
|
1842
|
+
const stateAfterSourceCollection = await recordSourceCollectionFailures({
|
|
1843
|
+
config,
|
|
1844
|
+
configPath,
|
|
1845
|
+
state: stateAfterHealthCheck,
|
|
1846
|
+
statePath,
|
|
1847
|
+
runtimeDir,
|
|
1848
|
+
sourceFailures,
|
|
1849
|
+
});
|
|
1231
1850
|
const currentHashes = computeSourceHashes(payloads);
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1851
|
+
if (activeCadences.length === 0) {
|
|
1852
|
+
process.stdout.write(`[${new Date().toISOString()}] No scheduled cadence due. Skip run.\n`);
|
|
1853
|
+
const completedAt = new Date().toISOString();
|
|
1235
1854
|
await fs.mkdir(path.dirname(statePath), { recursive: true });
|
|
1236
1855
|
await fs.writeFile(statePath, JSON.stringify({
|
|
1237
1856
|
...stateAfterHealthCheck,
|
|
1857
|
+
...stateAfterSourceCollection,
|
|
1238
1858
|
sourceHashes: currentHashes,
|
|
1239
1859
|
sourceCursors,
|
|
1240
|
-
|
|
1241
|
-
|
|
1860
|
+
lastSourceFailures: sourceFailures,
|
|
1861
|
+
lastRunAt: completedAt,
|
|
1862
|
+
skippedReason: 'cadence_not_due',
|
|
1242
1863
|
}, null, 2), 'utf8');
|
|
1864
|
+
await appendSchedulerProof('runner_completed', {
|
|
1865
|
+
configPath,
|
|
1866
|
+
statePath,
|
|
1867
|
+
completedAt,
|
|
1868
|
+
skippedReason: 'cadence_not_due',
|
|
1869
|
+
sourceFailures,
|
|
1870
|
+
socialOutput: 'HEARTBEAT_OK',
|
|
1871
|
+
});
|
|
1243
1872
|
return;
|
|
1244
1873
|
}
|
|
1245
|
-
const
|
|
1874
|
+
const githubArtifactModes = getGitHubArtifactModes(config).filter((mode) => shouldAutoCreateGitHubArtifact(config, mode));
|
|
1875
|
+
const createGitHubArtifact = githubArtifactModes.length > 0 && Boolean(String(config.project?.githubRepo || '').trim());
|
|
1246
1876
|
const sourceFiles = await materializeSourceFiles(config, payloads, runtimeDir);
|
|
1247
1877
|
const cadencePlanPath = path.join(runtimeDir, 'cadence-plan.json');
|
|
1248
1878
|
await fs.writeFile(cadencePlanPath, JSON.stringify({
|
|
@@ -1253,6 +1883,7 @@ async function runOnce(configPath, statePath) {
|
|
|
1253
1883
|
config,
|
|
1254
1884
|
payloads,
|
|
1255
1885
|
runtimeDir,
|
|
1886
|
+
activeCadences,
|
|
1256
1887
|
});
|
|
1257
1888
|
const dryRun = await runAnalyzer({
|
|
1258
1889
|
config,
|
|
@@ -1263,46 +1894,78 @@ async function runOnce(configPath, statePath) {
|
|
|
1263
1894
|
cadencePlanPath,
|
|
1264
1895
|
});
|
|
1265
1896
|
const issueFingerprint = buildIssueFingerprint(dryRun.issuesPayload);
|
|
1266
|
-
const unchangedIssueSet = issueFingerprint ===
|
|
1267
|
-
if (unchangedIssueSet &&
|
|
1268
|
-
|
|
1897
|
+
const unchangedIssueSet = issueFingerprint === stateAfterSourceCollection.lastIssueFingerprint;
|
|
1898
|
+
if (unchangedIssueSet &&
|
|
1899
|
+
config.schedule?.skipIfIssueSetUnchanged !== false) {
|
|
1900
|
+
process.stdout.write(`[${new Date().toISOString()}] Issue set unchanged. Skip GitHub creation and external growth notification.\n`);
|
|
1901
|
+
const completedAt = new Date().toISOString();
|
|
1269
1902
|
await fs.mkdir(path.dirname(statePath), { recursive: true });
|
|
1270
1903
|
await fs.writeFile(statePath, JSON.stringify({
|
|
1271
1904
|
...stateAfterHealthCheck,
|
|
1905
|
+
...stateAfterSourceCollection,
|
|
1272
1906
|
sourceHashes: currentHashes,
|
|
1273
1907
|
sourceCursors,
|
|
1908
|
+
lastSourceFailures: sourceFailures,
|
|
1274
1909
|
lastIssueFingerprint: issueFingerprint,
|
|
1275
|
-
lastRunAt:
|
|
1910
|
+
lastRunAt: completedAt,
|
|
1276
1911
|
lastOutFile: dryRun.outFile,
|
|
1277
|
-
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
|
+
],
|
|
1278
1920
|
skippedReason: 'issue_set_unchanged',
|
|
1279
1921
|
}, null, 2), 'utf8');
|
|
1922
|
+
await appendSchedulerProof('runner_completed', {
|
|
1923
|
+
configPath,
|
|
1924
|
+
statePath,
|
|
1925
|
+
completedAt,
|
|
1926
|
+
skippedReason: 'issue_set_unchanged',
|
|
1927
|
+
activeCadences: activeCadences.map((cadence) => cadence.key),
|
|
1928
|
+
outFile: dryRun.outFile,
|
|
1929
|
+
issueCount: Number(dryRun.issuesPayload?.issue_count || 0),
|
|
1930
|
+
sourceFailures,
|
|
1931
|
+
externalGrowthNotification: 'suppressed_unchanged_issue_set',
|
|
1932
|
+
socialOutput: 'HEARTBEAT_OK',
|
|
1933
|
+
});
|
|
1280
1934
|
return;
|
|
1281
1935
|
}
|
|
1282
|
-
const
|
|
1936
|
+
const issueSetChangedOrExplicitlyAllowed = !unchangedIssueSet || config.schedule?.skipIfIssueSetUnchanged === false;
|
|
1937
|
+
const shouldCreateGitHubArtifact = createGitHubArtifact &&
|
|
1938
|
+
Number(dryRun.issuesPayload?.issue_count || 0) > 0 &&
|
|
1939
|
+
issueSetChangedOrExplicitlyAllowed;
|
|
1283
1940
|
if (shouldCreateGitHubArtifact) {
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1941
|
+
for (const githubArtifactMode of githubArtifactModes) {
|
|
1942
|
+
await runAnalyzer({
|
|
1943
|
+
config,
|
|
1944
|
+
runtimeDir,
|
|
1945
|
+
sourceFiles,
|
|
1946
|
+
createGitHubArtifact: true,
|
|
1947
|
+
githubArtifactMode,
|
|
1948
|
+
chartManifestPath,
|
|
1949
|
+
cadencePlanPath,
|
|
1950
|
+
});
|
|
1951
|
+
}
|
|
1952
|
+
process.stdout.write(`[${new Date().toISOString()}] Created GitHub ${githubArtifactModes.map((mode) => (mode === 'pull_request' ? 'pull requests' : 'issues')).join(' and ')}.\n`);
|
|
1293
1953
|
}
|
|
1294
1954
|
else {
|
|
1295
1955
|
process.stdout.write(`[${new Date().toISOString()}] Drafts generated only (${getActionMode(config)} auto-create disabled).\n`);
|
|
1296
1956
|
}
|
|
1957
|
+
const completedAt = new Date().toISOString();
|
|
1297
1958
|
await fs.mkdir(path.dirname(statePath), { recursive: true });
|
|
1298
1959
|
await fs.writeFile(statePath, JSON.stringify({
|
|
1299
1960
|
...stateAfterHealthCheck,
|
|
1961
|
+
...stateAfterSourceCollection,
|
|
1300
1962
|
sourceHashes: currentHashes,
|
|
1301
1963
|
sourceCursors,
|
|
1964
|
+
lastSourceFailures: sourceFailures,
|
|
1302
1965
|
lastIssueFingerprint: issueFingerprint,
|
|
1303
|
-
lastRunAt:
|
|
1966
|
+
lastRunAt: completedAt,
|
|
1304
1967
|
lastOutFile: dryRun.outFile,
|
|
1305
|
-
cadences: markCadencesRan(
|
|
1968
|
+
cadences: markCadencesRan(stateAfterSourceCollection, activeCadences, completedAt),
|
|
1306
1969
|
lastGrowthRunNotifications: await deliverGrowthRunSummary({
|
|
1307
1970
|
config,
|
|
1308
1971
|
configPath,
|
|
@@ -1311,9 +1974,21 @@ async function runOnce(configPath, statePath) {
|
|
|
1311
1974
|
sourceFiles,
|
|
1312
1975
|
fingerprint: issueFingerprint,
|
|
1313
1976
|
createdGitHubArtifact: shouldCreateGitHubArtifact,
|
|
1977
|
+
chartManifestPath,
|
|
1314
1978
|
}),
|
|
1315
1979
|
skippedReason: null,
|
|
1316
1980
|
}, null, 2), 'utf8');
|
|
1981
|
+
await appendSchedulerProof('runner_completed', {
|
|
1982
|
+
configPath,
|
|
1983
|
+
statePath,
|
|
1984
|
+
completedAt,
|
|
1985
|
+
skippedReason: null,
|
|
1986
|
+
activeCadences: activeCadences.map((cadence) => cadence.key),
|
|
1987
|
+
outFile: dryRun.outFile,
|
|
1988
|
+
issueCount: Number(dryRun.issuesPayload?.issue_count || 0),
|
|
1989
|
+
sourceFailures,
|
|
1990
|
+
createdGitHubArtifact: shouldCreateGitHubArtifact,
|
|
1991
|
+
});
|
|
1317
1992
|
}
|
|
1318
1993
|
async function main() {
|
|
1319
1994
|
await loadOpenClawGrowthSecrets();
|
|
@@ -1321,6 +1996,7 @@ async function main() {
|
|
|
1321
1996
|
await maybeSelfUpdateFromClawHub(args);
|
|
1322
1997
|
const configPath = path.resolve(args.config);
|
|
1323
1998
|
const statePath = path.resolve(args.state);
|
|
1999
|
+
useSchedulerProofPathForStatePath(statePath);
|
|
1324
2000
|
if (!args.loop) {
|
|
1325
2001
|
await runOnce(configPath, statePath);
|
|
1326
2002
|
return;
|
|
@@ -1334,12 +2010,21 @@ async function main() {
|
|
|
1334
2010
|
await runOnce(configPath, statePath);
|
|
1335
2011
|
}
|
|
1336
2012
|
catch (error) {
|
|
2013
|
+
await appendSchedulerProof('runner_failed', {
|
|
2014
|
+
configPath,
|
|
2015
|
+
statePath,
|
|
2016
|
+
error: error instanceof Error ? error.message : String(error),
|
|
2017
|
+
}).catch(() => { });
|
|
1337
2018
|
process.stderr.write(`[${new Date().toISOString()}] Run failed: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
1338
2019
|
}
|
|
1339
2020
|
await sleep(intervalMinutes * 60_000);
|
|
1340
2021
|
}
|
|
1341
2022
|
}
|
|
1342
|
-
main().catch((error) => {
|
|
2023
|
+
main().catch(async (error) => {
|
|
2024
|
+
await appendSchedulerProof('runner_failed', {
|
|
2025
|
+
error: error instanceof Error ? error.message : String(error),
|
|
2026
|
+
argv: process.argv.slice(2),
|
|
2027
|
+
}).catch(() => { });
|
|
1343
2028
|
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
|
1344
2029
|
process.exitCode = 1;
|
|
1345
2030
|
});
|