@analyticscli/growth-engineer 0.1.0-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/LICENSE +21 -0
- package/README.md +26 -0
- package/dist/config.d.ts +1663 -0
- package/dist/config.js +266 -0
- package/dist/config.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1188 -0
- package/dist/index.js.map +1 -0
- package/dist/runtime/export-analytics-summary.d.mts +2 -0
- package/dist/runtime/export-analytics-summary.mjs +303 -0
- package/dist/runtime/export-analytics-summary.mjs.map +1 -0
- package/dist/runtime/export-asc-summary.d.mts +2 -0
- package/dist/runtime/export-asc-summary.mjs +376 -0
- package/dist/runtime/export-asc-summary.mjs.map +1 -0
- package/dist/runtime/export-revenuecat-summary.d.mts +2 -0
- package/dist/runtime/export-revenuecat-summary.mjs +176 -0
- package/dist/runtime/export-revenuecat-summary.mjs.map +1 -0
- package/dist/runtime/export-sentry-summary.d.mts +2 -0
- package/dist/runtime/export-sentry-summary.mjs +352 -0
- package/dist/runtime/export-sentry-summary.mjs.map +1 -0
- package/dist/runtime/openclaw-exporters-lib.d.mts +101 -0
- package/dist/runtime/openclaw-exporters-lib.mjs +1276 -0
- package/dist/runtime/openclaw-exporters-lib.mjs.map +1 -0
- package/dist/runtime/openclaw-feedback-api.d.mts +2 -0
- package/dist/runtime/openclaw-feedback-api.mjs +255 -0
- package/dist/runtime/openclaw-feedback-api.mjs.map +1 -0
- package/dist/runtime/openclaw-growth-charts.py +154 -0
- package/dist/runtime/openclaw-growth-engineer.d.mts +2 -0
- package/dist/runtime/openclaw-growth-engineer.mjs +1258 -0
- package/dist/runtime/openclaw-growth-engineer.mjs.map +1 -0
- package/dist/runtime/openclaw-growth-env.d.mts +9 -0
- package/dist/runtime/openclaw-growth-env.mjs +125 -0
- package/dist/runtime/openclaw-growth-env.mjs.map +1 -0
- package/dist/runtime/openclaw-growth-preflight.d.mts +2 -0
- package/dist/runtime/openclaw-growth-preflight.mjs +1111 -0
- package/dist/runtime/openclaw-growth-preflight.mjs.map +1 -0
- package/dist/runtime/openclaw-growth-runner.d.mts +2 -0
- package/dist/runtime/openclaw-growth-runner.mjs +1302 -0
- package/dist/runtime/openclaw-growth-runner.mjs.map +1 -0
- package/dist/runtime/openclaw-growth-shared.d.mts +33 -0
- package/dist/runtime/openclaw-growth-shared.mjs +208 -0
- package/dist/runtime/openclaw-growth-shared.mjs.map +1 -0
- package/dist/runtime/openclaw-growth-start.d.mts +2 -0
- package/dist/runtime/openclaw-growth-start.mjs +1575 -0
- package/dist/runtime/openclaw-growth-start.mjs.map +1 -0
- package/dist/runtime/openclaw-growth-status.d.mts +2 -0
- package/dist/runtime/openclaw-growth-status.mjs +387 -0
- package/dist/runtime/openclaw-growth-status.mjs.map +1 -0
- package/dist/runtime/openclaw-growth-wizard.d.mts +2 -0
- package/dist/runtime/openclaw-growth-wizard.mjs +3519 -0
- package/dist/runtime/openclaw-growth-wizard.mjs.map +1 -0
- package/dist/shell.d.ts +17 -0
- package/dist/shell.js +40 -0
- package/dist/shell.js.map +1 -0
- package/package.json +38 -0
- package/templates/analytics_summary.example.json +40 -0
- package/templates/config.example.json +197 -0
- package/templates/feedback_summary.example.json +37 -0
- package/templates/revenuecat_summary.example.json +25 -0
- package/templates/sentry_summary.example.json +23 -0
|
@@ -0,0 +1,1302 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { existsSync, promises as fs } from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import process from 'node:process';
|
|
5
|
+
import { createHash } from 'node:crypto';
|
|
6
|
+
import { spawn } from 'node:child_process';
|
|
7
|
+
import { getActionMode, getAllSourceEntries, getGitHubRequirementText, shouldAutoCreateGitHubArtifact, } from './openclaw-growth-shared.mjs';
|
|
8
|
+
import { applyOpenClawSecretRefs, loadOpenClawGrowthSecrets } from './openclaw-growth-env.mjs';
|
|
9
|
+
const DEFAULT_CONFIG_PATH = 'data/openclaw-growth-engineer/config.json';
|
|
10
|
+
const DEFAULT_STATE_PATH = 'data/openclaw-growth-engineer/state.json';
|
|
11
|
+
const DEFAULT_RUNTIME_DIR = 'data/openclaw-growth-engineer/runtime';
|
|
12
|
+
const DEFAULT_CONNECTOR_HEALTH_INTERVAL_MINUTES = 720;
|
|
13
|
+
const SELF_UPDATE_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
|
14
|
+
const DEFAULT_CADENCES = [
|
|
15
|
+
{
|
|
16
|
+
key: 'daily',
|
|
17
|
+
title: 'Daily production guardrail',
|
|
18
|
+
intervalDays: 1,
|
|
19
|
+
criticalOnly: true,
|
|
20
|
+
focusAreas: ['crash', 'conversion', 'paywall'],
|
|
21
|
+
sourcePriorities: ['sentry', 'glitchtip', 'analytics', 'asc_cli', 'revenuecat'],
|
|
22
|
+
objective: 'Find only critical production blockers and business anomalies: production crashes/errors, very low users, conversion, purchases, or other urgent drops.',
|
|
23
|
+
instructions: 'Do root-cause analysis across Sentry/GlitchTip, AnalyticsCLI, RevenueCat, ASC, feedback, release metadata, memory/state, and recent code changes. Produce the exact fix or next debugging step; do not invent generic growth ideas.',
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
key: 'weekly',
|
|
27
|
+
title: 'Weekly conversion, traffic, and revenue review',
|
|
28
|
+
intervalDays: 7,
|
|
29
|
+
criticalOnly: false,
|
|
30
|
+
focusAreas: ['conversion', 'paywall', 'onboarding', 'marketing', 'retention'],
|
|
31
|
+
sourcePriorities: ['analytics', 'revenuecat', 'asc_cli', 'feedback', 'sentry'],
|
|
32
|
+
objective: 'Review total conversion, traffic quality, activation, retention movement, RevenueCat trials/subscriptions/revenue/churn, source mix, reviews, releases, and stability.',
|
|
33
|
+
instructions: 'Choose one to three high-confidence growth bets with evidence, expected KPI movement, likely code/store surfaces, and verification plan. Kill or adjust experiments without signal.',
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
key: 'monthly',
|
|
37
|
+
title: 'Monthly business and product strategy review',
|
|
38
|
+
intervalDays: 30,
|
|
39
|
+
criticalOnly: false,
|
|
40
|
+
focusAreas: ['conversion', 'paywall', 'retention', 'marketing', 'onboarding'],
|
|
41
|
+
sourcePriorities: ['analytics', 'revenuecat', 'asc_cli', 'feedback', 'sentry'],
|
|
42
|
+
objective: 'Compare month-over-month MRR, trial conversion, churn, acquisition channel quality, store/listing conversion, retention, review themes, feature usage, and crash totals.',
|
|
43
|
+
instructions: 'Decide what should be built, changed, or deleted next. Explain why the change is likely to move revenue, activation, retention, or acquisition quality.',
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
key: 'quarterly',
|
|
47
|
+
title: 'Quarterly positioning, pricing, and roadmap review',
|
|
48
|
+
intervalDays: 91,
|
|
49
|
+
criticalOnly: false,
|
|
50
|
+
focusAreas: ['marketing', 'paywall', 'retention', 'conversion', 'onboarding'],
|
|
51
|
+
sourcePriorities: ['analytics', 'revenuecat', 'asc_cli', 'feedback'],
|
|
52
|
+
objective: 'Revisit positioning, pricing/packaging, onboarding architecture, roadmap assumptions, tracking quality, and major funnel bets from the last three months.',
|
|
53
|
+
instructions: 'Find structural constraints and durable opportunities, not small UI tweaks. Tie recommendations to cohort behavior, monetization, reviews, channel quality, and shipped changes.',
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
key: 'six_months',
|
|
57
|
+
title: 'Six-month instrumentation and growth-system audit',
|
|
58
|
+
intervalDays: 182,
|
|
59
|
+
criticalOnly: false,
|
|
60
|
+
focusAreas: ['retention', 'conversion', 'paywall', 'marketing', 'general'],
|
|
61
|
+
sourcePriorities: ['analytics', 'revenuecat', 'asc_cli', 'feedback', 'sentry'],
|
|
62
|
+
objective: 'Audit connector coverage, SDK instrumentation, event taxonomy, data reliability, data memory, growth loops, and whether product/marketing strategy still matches the best users.',
|
|
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.',
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
key: 'yearly',
|
|
67
|
+
title: 'Yearly evidence reset',
|
|
68
|
+
intervalDays: 365,
|
|
69
|
+
criticalOnly: false,
|
|
70
|
+
focusAreas: ['marketing', 'retention', 'paywall', 'conversion', 'general'],
|
|
71
|
+
sourcePriorities: ['analytics', 'revenuecat', 'asc_cli', 'feedback', 'sentry'],
|
|
72
|
+
objective: 'Reset strategy from evidence: market/channel fit, monetization model, retention ceiling, product scope, and whether to double down, reposition, rebuild, or sunset major surfaces/features.',
|
|
73
|
+
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
|
+
},
|
|
75
|
+
];
|
|
76
|
+
function parseArgs(argv) {
|
|
77
|
+
const args = {
|
|
78
|
+
config: DEFAULT_CONFIG_PATH,
|
|
79
|
+
state: DEFAULT_STATE_PATH,
|
|
80
|
+
loop: false,
|
|
81
|
+
noSelfUpdate: false,
|
|
82
|
+
};
|
|
83
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
84
|
+
const token = argv[i];
|
|
85
|
+
const next = argv[i + 1];
|
|
86
|
+
if (token === '--') {
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
else if (token === '--config') {
|
|
90
|
+
args.config = next;
|
|
91
|
+
i += 1;
|
|
92
|
+
}
|
|
93
|
+
else if (token === '--state') {
|
|
94
|
+
args.state = next;
|
|
95
|
+
i += 1;
|
|
96
|
+
}
|
|
97
|
+
else if (token === '--loop') {
|
|
98
|
+
args.loop = true;
|
|
99
|
+
}
|
|
100
|
+
else if (token === '--no-self-update') {
|
|
101
|
+
args.noSelfUpdate = true;
|
|
102
|
+
}
|
|
103
|
+
else if (token === '--help' || token === '-h') {
|
|
104
|
+
printHelpAndExit(0);
|
|
105
|
+
}
|
|
106
|
+
else {
|
|
107
|
+
printHelpAndExit(1, `Unknown argument: ${token}`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return args;
|
|
111
|
+
}
|
|
112
|
+
function printHelpAndExit(exitCode, reason = null) {
|
|
113
|
+
if (reason) {
|
|
114
|
+
process.stderr.write(`${reason}\n\n`);
|
|
115
|
+
}
|
|
116
|
+
process.stdout.write(`
|
|
117
|
+
OpenClaw Growth Runner
|
|
118
|
+
|
|
119
|
+
Usage:
|
|
120
|
+
node scripts/openclaw-growth-runner.mjs [--config <file>] [--state <file>] [--loop]
|
|
121
|
+
|
|
122
|
+
Options:
|
|
123
|
+
--no-self-update Skip the ClawHub skill update check for this run
|
|
124
|
+
|
|
125
|
+
Default config: ${DEFAULT_CONFIG_PATH}
|
|
126
|
+
Default state: ${DEFAULT_STATE_PATH}
|
|
127
|
+
`);
|
|
128
|
+
process.exit(exitCode);
|
|
129
|
+
}
|
|
130
|
+
async function readJson(filePath) {
|
|
131
|
+
const raw = await fs.readFile(filePath, 'utf8');
|
|
132
|
+
return JSON.parse(raw);
|
|
133
|
+
}
|
|
134
|
+
async function readJsonOptional(filePath, fallback) {
|
|
135
|
+
try {
|
|
136
|
+
return await readJson(filePath);
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
return fallback;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
async function ensureDir(dirPath) {
|
|
143
|
+
await fs.mkdir(dirPath, { recursive: true });
|
|
144
|
+
}
|
|
145
|
+
function sha256(input) {
|
|
146
|
+
return createHash('sha256').update(input).digest('hex');
|
|
147
|
+
}
|
|
148
|
+
function stableStringify(value) {
|
|
149
|
+
return JSON.stringify(value, Object.keys(value).sort(), 2);
|
|
150
|
+
}
|
|
151
|
+
function sleep(ms) {
|
|
152
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
153
|
+
}
|
|
154
|
+
function isTruthyEnv(value) {
|
|
155
|
+
return ['1', 'true', 'yes', 'y', 'on'].includes(String(value || '').trim().toLowerCase());
|
|
156
|
+
}
|
|
157
|
+
function isFalseyEnv(value) {
|
|
158
|
+
return ['0', 'false', 'no', 'n', 'off'].includes(String(value || '').trim().toLowerCase());
|
|
159
|
+
}
|
|
160
|
+
async function commandExists(commandName) {
|
|
161
|
+
const result = await runShellCommand(`command -v ${quote(commandName)} >/dev/null 2>&1`, 10_000);
|
|
162
|
+
return result.ok;
|
|
163
|
+
}
|
|
164
|
+
async function filesHaveSameContent(leftPath, rightPath) {
|
|
165
|
+
try {
|
|
166
|
+
const [left, right] = await Promise.all([fs.readFile(leftPath), fs.readFile(rightPath)]);
|
|
167
|
+
return left.equals(right);
|
|
168
|
+
}
|
|
169
|
+
catch {
|
|
170
|
+
return false;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
async function shouldRunSelfUpdate(workspaceRoot, force) {
|
|
174
|
+
if (force)
|
|
175
|
+
return true;
|
|
176
|
+
const statePath = path.join(workspaceRoot, 'data/openclaw-growth-engineer/self-update.json');
|
|
177
|
+
const state = await readJsonOptional(statePath, null);
|
|
178
|
+
const lastCheckedAt = Date.parse(String(state?.lastCheckedAt || ''));
|
|
179
|
+
return !Number.isFinite(lastCheckedAt) || Date.now() - lastCheckedAt > SELF_UPDATE_INTERVAL_MS;
|
|
180
|
+
}
|
|
181
|
+
async function writeSelfUpdateState(workspaceRoot, value) {
|
|
182
|
+
const statePath = path.join(workspaceRoot, 'data/openclaw-growth-engineer/self-update.json');
|
|
183
|
+
await fs.mkdir(path.dirname(statePath), { recursive: true });
|
|
184
|
+
await fs.writeFile(statePath, `${JSON.stringify({ version: 1, checkedAt: new Date().toISOString(), ...value }, null, 2)}\n`, 'utf8');
|
|
185
|
+
}
|
|
186
|
+
async function rerunCurrentProcessWithoutSelfUpdate() {
|
|
187
|
+
return await new Promise((resolve) => {
|
|
188
|
+
const child = spawn(process.execPath, process.argv.slice(1), {
|
|
189
|
+
env: {
|
|
190
|
+
...process.env,
|
|
191
|
+
OPENCLAW_GROWTH_SKIP_SELF_UPDATE: '1',
|
|
192
|
+
},
|
|
193
|
+
stdio: 'inherit',
|
|
194
|
+
});
|
|
195
|
+
child.on('error', () => resolve(1));
|
|
196
|
+
child.on('close', (code) => resolve(code));
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
async function maybeSelfUpdateFromClawHub(args) {
|
|
200
|
+
if (args.noSelfUpdate)
|
|
201
|
+
return false;
|
|
202
|
+
if (isTruthyEnv(process.env.OPENCLAW_GROWTH_SKIP_SELF_UPDATE))
|
|
203
|
+
return false;
|
|
204
|
+
if (isTruthyEnv(process.env.OPENCLAW_GROWTH_DISABLE_SELF_UPDATE))
|
|
205
|
+
return false;
|
|
206
|
+
if (isFalseyEnv(process.env.OPENCLAW_GROWTH_SELF_UPDATE))
|
|
207
|
+
return false;
|
|
208
|
+
const workspaceRoot = process.cwd();
|
|
209
|
+
const skillOriginPath = path.join(workspaceRoot, 'skills/openclaw-growth-engineer/.clawhub/origin.json');
|
|
210
|
+
if (!existsSync(skillOriginPath))
|
|
211
|
+
return false;
|
|
212
|
+
if (!(await commandExists('npx')))
|
|
213
|
+
return false;
|
|
214
|
+
const force = String(process.env.OPENCLAW_GROWTH_SELF_UPDATE || '').trim().toLowerCase() === 'always';
|
|
215
|
+
if (!(await shouldRunSelfUpdate(workspaceRoot, force)))
|
|
216
|
+
return false;
|
|
217
|
+
const beforeOrigin = await readJsonOptional(skillOriginPath, null);
|
|
218
|
+
const beforeVersion = String(beforeOrigin?.installedVersion || '');
|
|
219
|
+
process.stdout.write('Checking for OpenClaw Growth Engineer skill updates...\n');
|
|
220
|
+
const updateResult = await runShellCommand('npx -y clawhub --no-input --dir skills update openclaw-growth-engineer --force', 120_000);
|
|
221
|
+
const afterOrigin = await readJsonOptional(skillOriginPath, null);
|
|
222
|
+
const afterVersion = String(afterOrigin?.installedVersion || beforeVersion || '');
|
|
223
|
+
const workspaceRunnerPath = path.resolve(process.argv[1] || 'scripts/openclaw-growth-runner.mjs');
|
|
224
|
+
const skillRunnerPath = path.join(workspaceRoot, 'skills/openclaw-growth-engineer/scripts/openclaw-growth-runner.mjs');
|
|
225
|
+
const runtimeOutdated = !(await filesHaveSameContent(workspaceRunnerPath, skillRunnerPath));
|
|
226
|
+
await writeSelfUpdateState(workspaceRoot, {
|
|
227
|
+
lastCheckedAt: new Date().toISOString(),
|
|
228
|
+
ok: updateResult.ok,
|
|
229
|
+
previousVersion: beforeVersion || null,
|
|
230
|
+
installedVersion: afterVersion || null,
|
|
231
|
+
}).catch(() => { });
|
|
232
|
+
if (!updateResult.ok) {
|
|
233
|
+
const detail = String(updateResult.stderr || updateResult.stdout || 'update failed').trim().split(/\r?\n/).pop();
|
|
234
|
+
process.stdout.write(`Skill update check skipped: ${detail}\n`);
|
|
235
|
+
return false;
|
|
236
|
+
}
|
|
237
|
+
if ((!afterVersion || afterVersion === beforeVersion) && !runtimeOutdated)
|
|
238
|
+
return false;
|
|
239
|
+
process.stdout.write(afterVersion && afterVersion !== beforeVersion
|
|
240
|
+
? `Updated OpenClaw Growth Engineer skill ${beforeVersion || 'unknown'} -> ${afterVersion}. Refreshing workspace runtime...\n`
|
|
241
|
+
: 'Refreshing workspace runtime from the installed OpenClaw Growth Engineer skill...\n');
|
|
242
|
+
const bootstrapResult = await runShellCommand('bash skills/openclaw-growth-engineer/scripts/bootstrap-openclaw-workspace.sh', 60_000);
|
|
243
|
+
if (!bootstrapResult.ok) {
|
|
244
|
+
process.stdout.write('Workspace runtime refresh failed; continuing with current process.\n');
|
|
245
|
+
return false;
|
|
246
|
+
}
|
|
247
|
+
process.stdout.write('Restarting runner with refreshed runtime...\n');
|
|
248
|
+
const code = await rerunCurrentProcessWithoutSelfUpdate();
|
|
249
|
+
process.exit(code ?? 0);
|
|
250
|
+
}
|
|
251
|
+
function resolveShellCommand() {
|
|
252
|
+
const candidates = [
|
|
253
|
+
process.env.OPENCLAW_SHELL,
|
|
254
|
+
process.env.SHELL,
|
|
255
|
+
'/bin/zsh',
|
|
256
|
+
'/bin/bash',
|
|
257
|
+
'/usr/bin/bash',
|
|
258
|
+
'/bin/sh',
|
|
259
|
+
'/usr/bin/sh',
|
|
260
|
+
].filter((value) => typeof value === 'string' && value.trim().length > 0);
|
|
261
|
+
for (const candidate of candidates) {
|
|
262
|
+
if (existsSync(candidate)) {
|
|
263
|
+
return candidate;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
return 'sh';
|
|
267
|
+
}
|
|
268
|
+
function runShellCommand(command, timeoutMs = 120_000, options = {}) {
|
|
269
|
+
return new Promise((resolve) => {
|
|
270
|
+
const child = spawn(resolveShellCommand(), ['-c', command], {
|
|
271
|
+
stdio: options.input === undefined ? ['ignore', 'pipe', 'pipe'] : ['pipe', 'pipe', 'pipe'],
|
|
272
|
+
cwd: options.cwd,
|
|
273
|
+
});
|
|
274
|
+
let stdout = '';
|
|
275
|
+
let stderr = '';
|
|
276
|
+
let settled = false;
|
|
277
|
+
const timer = setTimeout(() => {
|
|
278
|
+
if (settled)
|
|
279
|
+
return;
|
|
280
|
+
settled = true;
|
|
281
|
+
child.kill('SIGTERM');
|
|
282
|
+
resolve({ ok: false, code: null, stdout, stderr: `${stderr}\nTimed out after ${timeoutMs}ms` });
|
|
283
|
+
}, timeoutMs);
|
|
284
|
+
child.stdout.on('data', (chunk) => {
|
|
285
|
+
stdout += String(chunk);
|
|
286
|
+
});
|
|
287
|
+
child.stderr.on('data', (chunk) => {
|
|
288
|
+
stderr += String(chunk);
|
|
289
|
+
});
|
|
290
|
+
if (options.input !== undefined) {
|
|
291
|
+
child.stdin.end(options.input);
|
|
292
|
+
}
|
|
293
|
+
child.on('close', (code) => {
|
|
294
|
+
if (settled)
|
|
295
|
+
return;
|
|
296
|
+
settled = true;
|
|
297
|
+
clearTimeout(timer);
|
|
298
|
+
resolve({
|
|
299
|
+
ok: code === 0,
|
|
300
|
+
code,
|
|
301
|
+
stdout,
|
|
302
|
+
stderr,
|
|
303
|
+
});
|
|
304
|
+
});
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
function getSecretName(config, key, fallback) {
|
|
308
|
+
const value = config?.secrets?.[key];
|
|
309
|
+
return typeof value === 'string' && value.trim() ? value.trim() : fallback;
|
|
310
|
+
}
|
|
311
|
+
async function assertHardRequirements(config) {
|
|
312
|
+
const missing = [];
|
|
313
|
+
const analyticsSource = config?.sources?.analytics;
|
|
314
|
+
const actionMode = getActionMode(config);
|
|
315
|
+
const requiresGitHubDelivery = shouldAutoCreateGitHubArtifact(config);
|
|
316
|
+
if (!analyticsSource || analyticsSource.enabled === false) {
|
|
317
|
+
missing.push('sources.analytics must be enabled');
|
|
318
|
+
}
|
|
319
|
+
const analyticscliExists = await commandExists('analyticscli');
|
|
320
|
+
if (!analyticscliExists) {
|
|
321
|
+
missing.push('analyticscli binary is required');
|
|
322
|
+
}
|
|
323
|
+
if (requiresGitHubDelivery) {
|
|
324
|
+
const githubRepo = String(config?.project?.githubRepo || '').trim();
|
|
325
|
+
if (!githubRepo) {
|
|
326
|
+
missing.push('project.githubRepo is required when GitHub auto-create is enabled');
|
|
327
|
+
}
|
|
328
|
+
const githubTokenEnv = getSecretName(config, 'githubTokenEnv', 'GITHUB_TOKEN');
|
|
329
|
+
if (!process.env[githubTokenEnv]) {
|
|
330
|
+
missing.push(`${githubTokenEnv} env var is required (${getGitHubRequirementText(actionMode)})`);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
if (missing.length > 0) {
|
|
334
|
+
const message = `Hard requirements missing:\n- ${missing.join('\n- ')}`;
|
|
335
|
+
throw new Error(message);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
function getProjectCommandCwd(config) {
|
|
339
|
+
const repoRoot = String(config?.project?.repoRoot || '').trim();
|
|
340
|
+
return repoRoot ? path.resolve(repoRoot) : process.cwd();
|
|
341
|
+
}
|
|
342
|
+
function parseJsonFromStdout(stdout) {
|
|
343
|
+
const raw = String(stdout || '').trim();
|
|
344
|
+
if (!raw)
|
|
345
|
+
return null;
|
|
346
|
+
const firstBrace = raw.indexOf('{');
|
|
347
|
+
const firstBracket = raw.indexOf('[');
|
|
348
|
+
const starts = [firstBrace, firstBracket].filter((index) => index >= 0);
|
|
349
|
+
if (starts.length === 0)
|
|
350
|
+
return null;
|
|
351
|
+
try {
|
|
352
|
+
return JSON.parse(raw.slice(Math.min(...starts)));
|
|
353
|
+
}
|
|
354
|
+
catch {
|
|
355
|
+
return null;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
function getConnectorHealthIntervalMinutes(config) {
|
|
359
|
+
const configured = Number(config?.schedule?.connectorHealthCheckIntervalMinutes);
|
|
360
|
+
return Number.isFinite(configured) && configured > 0
|
|
361
|
+
? configured
|
|
362
|
+
: DEFAULT_CONNECTOR_HEALTH_INTERVAL_MINUTES;
|
|
363
|
+
}
|
|
364
|
+
function isDue(lastCheckedAt, intervalMinutes) {
|
|
365
|
+
if (!lastCheckedAt)
|
|
366
|
+
return true;
|
|
367
|
+
const last = Date.parse(String(lastCheckedAt));
|
|
368
|
+
if (!Number.isFinite(last))
|
|
369
|
+
return true;
|
|
370
|
+
return Date.now() - last >= intervalMinutes * 60_000;
|
|
371
|
+
}
|
|
372
|
+
function normalizeCadenceKey(value) {
|
|
373
|
+
const normalized = String(value || '')
|
|
374
|
+
.trim()
|
|
375
|
+
.toLowerCase()
|
|
376
|
+
.replace(/[\s-]+/g, '_');
|
|
377
|
+
if (['3_months', 'three_months', 'quarter', 'quarterly'].includes(normalized))
|
|
378
|
+
return 'quarterly';
|
|
379
|
+
if (['6_months', 'six_months', 'half_year', 'half_yearly'].includes(normalized))
|
|
380
|
+
return 'six_months';
|
|
381
|
+
if (['1y', '1_year', 'one_year', 'annual', 'annually'].includes(normalized))
|
|
382
|
+
return 'yearly';
|
|
383
|
+
return normalized;
|
|
384
|
+
}
|
|
385
|
+
function getCadenceDefinitions(config) {
|
|
386
|
+
const configured = Array.isArray(config?.schedule?.cadences) ? config.schedule.cadences : [];
|
|
387
|
+
const byKey = new Map(DEFAULT_CADENCES.map((cadence) => [cadence.key, { ...cadence }]));
|
|
388
|
+
for (const cadence of configured) {
|
|
389
|
+
if (!cadence || typeof cadence !== 'object')
|
|
390
|
+
continue;
|
|
391
|
+
const key = normalizeCadenceKey(cadence.key || cadence.id || cadence.label);
|
|
392
|
+
if (!key)
|
|
393
|
+
continue;
|
|
394
|
+
const base = byKey.get(key) || { key };
|
|
395
|
+
byKey.set(key, {
|
|
396
|
+
...base,
|
|
397
|
+
...cadence,
|
|
398
|
+
key,
|
|
399
|
+
enabled: cadence.enabled !== false,
|
|
400
|
+
focusAreas: Array.isArray(cadence.focusAreas) ? cadence.focusAreas : base.focusAreas || [],
|
|
401
|
+
sourcePriorities: Array.isArray(cadence.sourcePriorities)
|
|
402
|
+
? cadence.sourcePriorities
|
|
403
|
+
: base.sourcePriorities || [],
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
return [...byKey.values()].filter((cadence) => cadence.enabled !== false);
|
|
407
|
+
}
|
|
408
|
+
function cadenceIsDue(cadence, state) {
|
|
409
|
+
const lastRanAt = state?.cadences?.[cadence.key]?.lastRanAt;
|
|
410
|
+
const intervalDays = Number(cadence.intervalDays || 1);
|
|
411
|
+
if (!lastRanAt)
|
|
412
|
+
return true;
|
|
413
|
+
const last = Date.parse(String(lastRanAt));
|
|
414
|
+
if (!Number.isFinite(last))
|
|
415
|
+
return true;
|
|
416
|
+
return Date.now() - last >= Math.max(1, intervalDays) * 24 * 60 * 60 * 1000;
|
|
417
|
+
}
|
|
418
|
+
function getDueCadences(config, state) {
|
|
419
|
+
const due = getCadenceDefinitions(config).filter((cadence) => cadenceIsDue(cadence, state));
|
|
420
|
+
if (due.length > 0)
|
|
421
|
+
return due;
|
|
422
|
+
const daily = getCadenceDefinitions(config).find((cadence) => cadence.key === 'daily');
|
|
423
|
+
return daily ? [daily] : [];
|
|
424
|
+
}
|
|
425
|
+
function markCadencesRan(state, cadences, ranAt) {
|
|
426
|
+
const nextCadences = { ...(state?.cadences || {}) };
|
|
427
|
+
for (const cadence of cadences) {
|
|
428
|
+
nextCadences[cadence.key] = {
|
|
429
|
+
...(nextCadences[cadence.key] || {}),
|
|
430
|
+
lastRanAt: ranAt,
|
|
431
|
+
title: cadence.title,
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
return nextCadences;
|
|
435
|
+
}
|
|
436
|
+
function getConnectorEntries(statusPayload) {
|
|
437
|
+
return Object.entries(statusPayload?.connectors || {}).map(([key, value]) => ({
|
|
438
|
+
key,
|
|
439
|
+
status: String(value?.status || 'unknown'),
|
|
440
|
+
detail: String(value?.detail || ''),
|
|
441
|
+
nextAction: typeof value?.nextAction === 'string' ? value.nextAction : null,
|
|
442
|
+
}));
|
|
443
|
+
}
|
|
444
|
+
function getUnhealthyConfiguredConnectors(statusPayload) {
|
|
445
|
+
return getConnectorEntries(statusPayload).filter((entry) => ['blocked', 'partial', 'unknown'].includes(entry.status));
|
|
446
|
+
}
|
|
447
|
+
function getConnectedConnectorKeys(statusPayload) {
|
|
448
|
+
return getConnectorEntries(statusPayload)
|
|
449
|
+
.filter((entry) => entry.status === 'connected')
|
|
450
|
+
.map((entry) => entry.key)
|
|
451
|
+
.sort();
|
|
452
|
+
}
|
|
453
|
+
function buildConnectorHealthFingerprint(unhealthyConnectors) {
|
|
454
|
+
return sha256(unhealthyConnectors
|
|
455
|
+
.map((entry) => `${entry.key}|${entry.status}|${entry.detail}|${entry.nextAction || ''}`)
|
|
456
|
+
.sort()
|
|
457
|
+
.join('\n'));
|
|
458
|
+
}
|
|
459
|
+
function humanConnectorName(key) {
|
|
460
|
+
if (key === 'analyticscli')
|
|
461
|
+
return 'AnalyticsCLI';
|
|
462
|
+
if (key === 'appStoreConnect')
|
|
463
|
+
return 'App Store Connect';
|
|
464
|
+
if (key === 'revenuecat')
|
|
465
|
+
return 'RevenueCat';
|
|
466
|
+
if (key === 'sentry')
|
|
467
|
+
return 'Sentry';
|
|
468
|
+
if (key === 'github')
|
|
469
|
+
return 'GitHub';
|
|
470
|
+
return key;
|
|
471
|
+
}
|
|
472
|
+
function buildConnectorHealthAlert(statusPayload, unhealthyConnectors) {
|
|
473
|
+
const lines = [
|
|
474
|
+
`OpenClaw Growth connector health needs attention (${new Date().toISOString()}).`,
|
|
475
|
+
`Config: ${statusPayload?.configPath || DEFAULT_CONFIG_PATH}`,
|
|
476
|
+
'',
|
|
477
|
+
'Unhealthy connector(s):',
|
|
478
|
+
];
|
|
479
|
+
for (const entry of unhealthyConnectors) {
|
|
480
|
+
lines.push(`- ${humanConnectorName(entry.key)}: ${entry.status} - ${entry.detail}`);
|
|
481
|
+
if (entry.nextAction) {
|
|
482
|
+
lines.push(` Next: ${entry.nextAction}`);
|
|
483
|
+
}
|
|
484
|
+
if (entry.key === 'appStoreConnect' && entry.status === 'partial') {
|
|
485
|
+
lines.push(' Note: ASC web analytics uses a user-owned web session. If Apple expires it after a few hours, refresh it with `asc web auth login`; API-key ASC auth cannot replace this web session.');
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
lines.push('');
|
|
489
|
+
lines.push('Do not send secrets through chat or social channels. Refresh credentials only in the host terminal or secret store.');
|
|
490
|
+
return `${lines.join('\n')}\n`;
|
|
491
|
+
}
|
|
492
|
+
async function writeConnectorHealthAlert(runtimeDir, message, statusPayload, unhealthyConnectors, fingerprint) {
|
|
493
|
+
const alertDir = path.join(runtimeDir, 'connector-health');
|
|
494
|
+
await ensureDir(alertDir);
|
|
495
|
+
const markdownPath = path.join(alertDir, 'latest.md');
|
|
496
|
+
const jsonPath = path.join(alertDir, 'latest.json');
|
|
497
|
+
await fs.writeFile(markdownPath, message, 'utf8');
|
|
498
|
+
await fs.writeFile(jsonPath, JSON.stringify({
|
|
499
|
+
generatedAt: new Date().toISOString(),
|
|
500
|
+
fingerprint,
|
|
501
|
+
unhealthyConnectors,
|
|
502
|
+
status: statusPayload,
|
|
503
|
+
}, null, 2), 'utf8');
|
|
504
|
+
return { markdownPath, jsonPath };
|
|
505
|
+
}
|
|
506
|
+
function getConnectorHealthChannels(config) {
|
|
507
|
+
const configuredChannels = Array.isArray(config?.notifications?.connectorHealth?.channels)
|
|
508
|
+
? config.notifications.connectorHealth.channels.filter((channel) => channel?.enabled !== false)
|
|
509
|
+
: [];
|
|
510
|
+
if (configuredChannels.length > 0)
|
|
511
|
+
return configuredChannels;
|
|
512
|
+
const channels = [];
|
|
513
|
+
const deliveries = config?.deliveries || {};
|
|
514
|
+
if (deliveries.openclawChat?.enabled) {
|
|
515
|
+
channels.push({
|
|
516
|
+
type: 'openclaw-chat',
|
|
517
|
+
label: 'openclaw_chat',
|
|
518
|
+
markdownPath: deliveries.openclawChat.connectorHealthMarkdownPath || deliveries.openclawChat.markdownPath,
|
|
519
|
+
jsonPath: deliveries.openclawChat.connectorHealthJsonPath || deliveries.openclawChat.jsonPath,
|
|
520
|
+
});
|
|
521
|
+
}
|
|
522
|
+
if (deliveries.slack?.enabled) {
|
|
523
|
+
channels.push({
|
|
524
|
+
type: 'slack',
|
|
525
|
+
label: 'slack',
|
|
526
|
+
webhookEnv: deliveries.slack.webhookEnv || 'SLACK_WEBHOOK_URL',
|
|
527
|
+
});
|
|
528
|
+
}
|
|
529
|
+
if (deliveries.webhook?.enabled) {
|
|
530
|
+
channels.push({
|
|
531
|
+
type: 'webhook',
|
|
532
|
+
label: 'webhook',
|
|
533
|
+
urlEnv: deliveries.webhook.urlEnv || 'OPENCLAW_WEBHOOK_URL',
|
|
534
|
+
method: deliveries.webhook.method || 'POST',
|
|
535
|
+
headers: deliveries.webhook.headers || {},
|
|
536
|
+
});
|
|
537
|
+
}
|
|
538
|
+
if (deliveries.discord?.enabled) {
|
|
539
|
+
channels.push({
|
|
540
|
+
type: 'command',
|
|
541
|
+
label: 'discord',
|
|
542
|
+
command: deliveries.discord.command || 'node scripts/discord-openclaw-bridge.mjs send --stdin',
|
|
543
|
+
});
|
|
544
|
+
}
|
|
545
|
+
return channels;
|
|
546
|
+
}
|
|
547
|
+
async function writeConfiguredOpenClawChatAlert(configPath, channel, message, statusPayload, unhealthyConnectors, fingerprint) {
|
|
548
|
+
const baseDir = path.dirname(path.resolve(configPath));
|
|
549
|
+
const markdownPath = path.resolve(baseDir, channel.markdownPath || '.openclaw/chat/connector-health.md');
|
|
550
|
+
const jsonPath = path.resolve(baseDir, channel.jsonPath || '.openclaw/chat/connector-health.json');
|
|
551
|
+
await fs.mkdir(path.dirname(markdownPath), { recursive: true });
|
|
552
|
+
await fs.mkdir(path.dirname(jsonPath), { recursive: true });
|
|
553
|
+
await fs.writeFile(markdownPath, message, 'utf8');
|
|
554
|
+
await fs.writeFile(jsonPath, JSON.stringify({
|
|
555
|
+
channel: channel.label || 'openclaw_chat',
|
|
556
|
+
generatedAt: new Date().toISOString(),
|
|
557
|
+
fingerprint,
|
|
558
|
+
unhealthyConnectors,
|
|
559
|
+
status: statusPayload,
|
|
560
|
+
}, null, 2), 'utf8');
|
|
561
|
+
return {
|
|
562
|
+
sent: true,
|
|
563
|
+
target: channel.label || 'openclaw_chat',
|
|
564
|
+
detail: `wrote ${markdownPath} and ${jsonPath}`,
|
|
565
|
+
};
|
|
566
|
+
}
|
|
567
|
+
async function sendSlackConnectorHealthAlert(channel, message) {
|
|
568
|
+
const webhookEnv = channel.webhookEnv || 'SLACK_WEBHOOK_URL';
|
|
569
|
+
const webhookUrl = process.env[webhookEnv];
|
|
570
|
+
if (!webhookUrl) {
|
|
571
|
+
return { sent: false, target: channel.label || 'slack', detail: `${webhookEnv} not set` };
|
|
572
|
+
}
|
|
573
|
+
const response = await fetch(webhookUrl, {
|
|
574
|
+
method: 'POST',
|
|
575
|
+
headers: { 'content-type': 'application/json' },
|
|
576
|
+
body: JSON.stringify({ text: message }),
|
|
577
|
+
});
|
|
578
|
+
return {
|
|
579
|
+
sent: response.ok,
|
|
580
|
+
target: channel.label || 'slack',
|
|
581
|
+
detail: response.ok ? `HTTP ${response.status}` : `HTTP ${response.status}: ${await response.text()}`,
|
|
582
|
+
};
|
|
583
|
+
}
|
|
584
|
+
async function sendWebhookConnectorHealthAlert(channel, message, statusPayload, unhealthyConnectors, fingerprint) {
|
|
585
|
+
const urlEnv = channel.urlEnv || channel.webhookEnv || 'OPENCLAW_WEBHOOK_URL';
|
|
586
|
+
const webhookUrl = process.env[urlEnv];
|
|
587
|
+
if (!webhookUrl) {
|
|
588
|
+
return { sent: false, target: channel.label || 'webhook', detail: `${urlEnv} not set` };
|
|
589
|
+
}
|
|
590
|
+
const response = await fetch(webhookUrl, {
|
|
591
|
+
method: channel.method || 'POST',
|
|
592
|
+
headers: {
|
|
593
|
+
'content-type': 'application/json',
|
|
594
|
+
...(channel.headers || {}),
|
|
595
|
+
},
|
|
596
|
+
body: JSON.stringify({
|
|
597
|
+
type: 'openclaw.connector_health',
|
|
598
|
+
generatedAt: new Date().toISOString(),
|
|
599
|
+
text: message,
|
|
600
|
+
fingerprint,
|
|
601
|
+
unhealthyConnectors,
|
|
602
|
+
status: statusPayload,
|
|
603
|
+
}),
|
|
604
|
+
});
|
|
605
|
+
return {
|
|
606
|
+
sent: response.ok,
|
|
607
|
+
target: channel.label || 'webhook',
|
|
608
|
+
detail: response.ok ? `HTTP ${response.status}` : `HTTP ${response.status}: ${await response.text()}`,
|
|
609
|
+
};
|
|
610
|
+
}
|
|
611
|
+
async function sendCommandConnectorHealthAlert(channel, message) {
|
|
612
|
+
if (!channel.command) {
|
|
613
|
+
return { sent: false, target: channel.label || 'command', detail: 'command not configured' };
|
|
614
|
+
}
|
|
615
|
+
const result = await runShellCommand(String(channel.command), 60_000, { input: message });
|
|
616
|
+
return {
|
|
617
|
+
sent: result.ok,
|
|
618
|
+
target: channel.label || 'command',
|
|
619
|
+
detail: result.ok ? result.stdout.trim() : result.stderr.trim() || result.stdout.trim() || `exit ${result.code}`,
|
|
620
|
+
};
|
|
621
|
+
}
|
|
622
|
+
async function deliverConnectorHealthAlert({ config, configPath, message, statusPayload, unhealthyConnectors, fingerprint }) {
|
|
623
|
+
const channels = getConnectorHealthChannels(config);
|
|
624
|
+
if (config?.notifications?.connectorHealth?.enabled === false) {
|
|
625
|
+
return [{ sent: false, target: 'notifications', detail: 'connector health notifications disabled' }];
|
|
626
|
+
}
|
|
627
|
+
if (channels.length === 0) {
|
|
628
|
+
return [{ sent: false, target: 'none', detail: 'no connector health notification channels configured' }];
|
|
629
|
+
}
|
|
630
|
+
const results = [];
|
|
631
|
+
for (const channel of channels) {
|
|
632
|
+
try {
|
|
633
|
+
if (channel.type === 'openclaw-chat') {
|
|
634
|
+
results.push(await writeConfiguredOpenClawChatAlert(configPath, channel, message, statusPayload, unhealthyConnectors, fingerprint));
|
|
635
|
+
}
|
|
636
|
+
else if (channel.type === 'slack') {
|
|
637
|
+
results.push(await sendSlackConnectorHealthAlert(channel, message));
|
|
638
|
+
}
|
|
639
|
+
else if (channel.type === 'webhook') {
|
|
640
|
+
results.push(await sendWebhookConnectorHealthAlert(channel, message, statusPayload, unhealthyConnectors, fingerprint));
|
|
641
|
+
}
|
|
642
|
+
else if (channel.type === 'command') {
|
|
643
|
+
results.push(await sendCommandConnectorHealthAlert(channel, message));
|
|
644
|
+
}
|
|
645
|
+
else {
|
|
646
|
+
results.push({ sent: false, target: channel.label || String(channel.type || 'unknown'), detail: 'unsupported channel type' });
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
catch (error) {
|
|
650
|
+
results.push({
|
|
651
|
+
sent: false,
|
|
652
|
+
target: channel.label || String(channel.type || 'unknown'),
|
|
653
|
+
detail: error instanceof Error ? error.message : String(error),
|
|
654
|
+
});
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
return results;
|
|
658
|
+
}
|
|
659
|
+
function getGrowthRunChannels(config) {
|
|
660
|
+
const configuredChannels = Array.isArray(config?.notifications?.growthRun?.channels)
|
|
661
|
+
? config.notifications.growthRun.channels.filter((channel) => channel?.enabled !== false)
|
|
662
|
+
: [];
|
|
663
|
+
if (configuredChannels.length > 0)
|
|
664
|
+
return configuredChannels;
|
|
665
|
+
const channels = [];
|
|
666
|
+
const deliveries = config?.deliveries || {};
|
|
667
|
+
if (deliveries.openclawChat?.enabled) {
|
|
668
|
+
channels.push({
|
|
669
|
+
type: 'openclaw-chat',
|
|
670
|
+
label: 'openclaw_chat',
|
|
671
|
+
markdownPath: deliveries.openclawChat.growthRunMarkdownPath || '.openclaw/chat/growth-summary.md',
|
|
672
|
+
jsonPath: deliveries.openclawChat.growthRunJsonPath || '.openclaw/chat/growth-summary.json',
|
|
673
|
+
});
|
|
674
|
+
}
|
|
675
|
+
if (deliveries.slack?.enabled) {
|
|
676
|
+
channels.push({
|
|
677
|
+
type: 'slack',
|
|
678
|
+
label: 'slack',
|
|
679
|
+
webhookEnv: deliveries.slack.webhookEnv || 'SLACK_WEBHOOK_URL',
|
|
680
|
+
});
|
|
681
|
+
}
|
|
682
|
+
if (deliveries.webhook?.enabled) {
|
|
683
|
+
channels.push({
|
|
684
|
+
type: 'webhook',
|
|
685
|
+
label: 'webhook',
|
|
686
|
+
urlEnv: deliveries.webhook.urlEnv || 'OPENCLAW_WEBHOOK_URL',
|
|
687
|
+
method: deliveries.webhook.method || 'POST',
|
|
688
|
+
headers: deliveries.webhook.headers || {},
|
|
689
|
+
});
|
|
690
|
+
}
|
|
691
|
+
if (deliveries.discord?.enabled) {
|
|
692
|
+
channels.push({
|
|
693
|
+
type: 'command',
|
|
694
|
+
label: 'discord',
|
|
695
|
+
command: deliveries.discord.command || 'node scripts/discord-openclaw-bridge.mjs send --stdin',
|
|
696
|
+
});
|
|
697
|
+
}
|
|
698
|
+
return channels;
|
|
699
|
+
}
|
|
700
|
+
function buildGrowthRunSummaryMessage({ issuesPayload, activeCadences, sourceFiles, createdGitHubArtifact }) {
|
|
701
|
+
const issueCount = Number(issuesPayload?.issue_count || 0);
|
|
702
|
+
const cadenceNames = activeCadences.length > 0
|
|
703
|
+
? activeCadences.map((cadence) => cadence.title || cadence.key).join(', ')
|
|
704
|
+
: 'ad-hoc growth pass';
|
|
705
|
+
const sourceNames = Object.keys(sourceFiles || {}).sort().join(', ') || 'none';
|
|
706
|
+
const lines = [
|
|
707
|
+
`OpenClaw Growth run finished (${new Date().toISOString()}).`,
|
|
708
|
+
`Cadence: ${cadenceNames}`,
|
|
709
|
+
`Sources inspected: ${sourceNames}`,
|
|
710
|
+
`Generated proposals: ${issueCount}`,
|
|
711
|
+
];
|
|
712
|
+
if (issuesPayload?.summary) {
|
|
713
|
+
lines.push(`Summary: ${issuesPayload.summary}`);
|
|
714
|
+
}
|
|
715
|
+
if (createdGitHubArtifact) {
|
|
716
|
+
lines.push('GitHub artifact creation was attempted for the generated proposals.');
|
|
717
|
+
}
|
|
718
|
+
const issues = Array.isArray(issuesPayload?.issues) ? issuesPayload.issues.slice(0, 3) : [];
|
|
719
|
+
if (issues.length > 0) {
|
|
720
|
+
lines.push('');
|
|
721
|
+
lines.push('Top findings:');
|
|
722
|
+
for (const issue of issues) {
|
|
723
|
+
lines.push(`- ${issue.title} (${issue.priority || 'medium'}, ${issue.area || 'general'})`);
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
lines.push('');
|
|
727
|
+
lines.push('No secrets were included. Use the generated issue drafts or OpenClaw chat handoff for details.');
|
|
728
|
+
return `${lines.join('\n')}\n`;
|
|
729
|
+
}
|
|
730
|
+
async function writeConfiguredOpenClawChatGrowthSummary(configPath, channel, message, issuesPayload, activeCadences, fingerprint) {
|
|
731
|
+
const baseDir = path.dirname(path.resolve(configPath));
|
|
732
|
+
const markdownPath = path.resolve(baseDir, channel.markdownPath || '.openclaw/chat/growth-summary.md');
|
|
733
|
+
const jsonPath = path.resolve(baseDir, channel.jsonPath || '.openclaw/chat/growth-summary.json');
|
|
734
|
+
await fs.mkdir(path.dirname(markdownPath), { recursive: true });
|
|
735
|
+
await fs.mkdir(path.dirname(jsonPath), { recursive: true });
|
|
736
|
+
await fs.writeFile(markdownPath, message, 'utf8');
|
|
737
|
+
await fs.writeFile(jsonPath, JSON.stringify({
|
|
738
|
+
channel: channel.label || 'openclaw_chat',
|
|
739
|
+
generatedAt: new Date().toISOString(),
|
|
740
|
+
fingerprint,
|
|
741
|
+
activeCadences,
|
|
742
|
+
issueCount: Number(issuesPayload?.issue_count || 0),
|
|
743
|
+
issues: Array.isArray(issuesPayload?.issues) ? issuesPayload.issues : [],
|
|
744
|
+
}, null, 2), 'utf8');
|
|
745
|
+
return {
|
|
746
|
+
sent: true,
|
|
747
|
+
target: channel.label || 'openclaw_chat',
|
|
748
|
+
detail: `wrote ${markdownPath} and ${jsonPath}`,
|
|
749
|
+
};
|
|
750
|
+
}
|
|
751
|
+
async function sendSlackGrowthSummary(channel, message) {
|
|
752
|
+
const webhookEnv = channel.webhookEnv || 'SLACK_WEBHOOK_URL';
|
|
753
|
+
const webhookUrl = process.env[webhookEnv];
|
|
754
|
+
if (!webhookUrl) {
|
|
755
|
+
return { sent: false, target: channel.label || 'slack', detail: `${webhookEnv} not set` };
|
|
756
|
+
}
|
|
757
|
+
const response = await fetch(webhookUrl, {
|
|
758
|
+
method: 'POST',
|
|
759
|
+
headers: { 'content-type': 'application/json' },
|
|
760
|
+
body: JSON.stringify({ text: message }),
|
|
761
|
+
});
|
|
762
|
+
return {
|
|
763
|
+
sent: response.ok,
|
|
764
|
+
target: channel.label || 'slack',
|
|
765
|
+
detail: response.ok ? `HTTP ${response.status}` : `HTTP ${response.status}: ${await response.text()}`,
|
|
766
|
+
};
|
|
767
|
+
}
|
|
768
|
+
async function sendWebhookGrowthSummary(channel, message, issuesPayload, activeCadences, fingerprint) {
|
|
769
|
+
const urlEnv = channel.urlEnv || channel.webhookEnv || 'OPENCLAW_WEBHOOK_URL';
|
|
770
|
+
const webhookUrl = process.env[urlEnv];
|
|
771
|
+
if (!webhookUrl) {
|
|
772
|
+
return { sent: false, target: channel.label || 'webhook', detail: `${urlEnv} not set` };
|
|
773
|
+
}
|
|
774
|
+
const response = await fetch(webhookUrl, {
|
|
775
|
+
method: channel.method || 'POST',
|
|
776
|
+
headers: {
|
|
777
|
+
'content-type': 'application/json',
|
|
778
|
+
...(channel.headers || {}),
|
|
779
|
+
},
|
|
780
|
+
body: JSON.stringify({
|
|
781
|
+
type: 'openclaw.growth_run',
|
|
782
|
+
generatedAt: new Date().toISOString(),
|
|
783
|
+
text: message,
|
|
784
|
+
fingerprint,
|
|
785
|
+
activeCadences,
|
|
786
|
+
issueCount: Number(issuesPayload?.issue_count || 0),
|
|
787
|
+
issues: Array.isArray(issuesPayload?.issues) ? issuesPayload.issues : [],
|
|
788
|
+
}),
|
|
789
|
+
});
|
|
790
|
+
return {
|
|
791
|
+
sent: response.ok,
|
|
792
|
+
target: channel.label || 'webhook',
|
|
793
|
+
detail: response.ok ? `HTTP ${response.status}` : `HTTP ${response.status}: ${await response.text()}`,
|
|
794
|
+
};
|
|
795
|
+
}
|
|
796
|
+
async function sendCommandGrowthSummary(channel, message) {
|
|
797
|
+
if (!channel.command) {
|
|
798
|
+
return { sent: false, target: channel.label || 'command', detail: 'command not configured' };
|
|
799
|
+
}
|
|
800
|
+
const result = await runShellCommand(String(channel.command), 60_000, { input: message });
|
|
801
|
+
return {
|
|
802
|
+
sent: result.ok,
|
|
803
|
+
target: channel.label || 'command',
|
|
804
|
+
detail: result.ok ? result.stdout.trim() : result.stderr.trim() || result.stdout.trim() || `exit ${result.code}`,
|
|
805
|
+
};
|
|
806
|
+
}
|
|
807
|
+
async function deliverGrowthRunSummary({ config, configPath, issuesPayload, activeCadences, sourceFiles, fingerprint, createdGitHubArtifact, }) {
|
|
808
|
+
if (config?.notifications?.growthRun?.enabled === false) {
|
|
809
|
+
return [{ sent: false, target: 'notifications', detail: 'growth run notifications disabled' }];
|
|
810
|
+
}
|
|
811
|
+
const channels = getGrowthRunChannels(config);
|
|
812
|
+
if (channels.length === 0) {
|
|
813
|
+
return [{ sent: false, target: 'none', detail: 'no growth run notification channels configured' }];
|
|
814
|
+
}
|
|
815
|
+
const message = buildGrowthRunSummaryMessage({
|
|
816
|
+
issuesPayload,
|
|
817
|
+
activeCadences,
|
|
818
|
+
sourceFiles,
|
|
819
|
+
createdGitHubArtifact,
|
|
820
|
+
});
|
|
821
|
+
const results = [];
|
|
822
|
+
for (const channel of channels) {
|
|
823
|
+
try {
|
|
824
|
+
if (channel.type === 'openclaw-chat') {
|
|
825
|
+
results.push(await writeConfiguredOpenClawChatGrowthSummary(configPath, channel, message, issuesPayload, activeCadences, fingerprint));
|
|
826
|
+
}
|
|
827
|
+
else if (channel.type === 'slack') {
|
|
828
|
+
results.push(await sendSlackGrowthSummary(channel, message));
|
|
829
|
+
}
|
|
830
|
+
else if (channel.type === 'webhook') {
|
|
831
|
+
results.push(await sendWebhookGrowthSummary(channel, message, issuesPayload, activeCadences, fingerprint));
|
|
832
|
+
}
|
|
833
|
+
else if (channel.type === 'command') {
|
|
834
|
+
results.push(await sendCommandGrowthSummary(channel, message));
|
|
835
|
+
}
|
|
836
|
+
else {
|
|
837
|
+
results.push({ sent: false, target: channel.label || String(channel.type || 'unknown'), detail: 'unsupported channel type' });
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
catch (error) {
|
|
841
|
+
results.push({
|
|
842
|
+
sent: false,
|
|
843
|
+
target: channel.label || String(channel.type || 'unknown'),
|
|
844
|
+
detail: error instanceof Error ? error.message : String(error),
|
|
845
|
+
});
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
return results;
|
|
849
|
+
}
|
|
850
|
+
async function maybeRunConnectorHealthCheck({ config, configPath, state, statePath, runtimeDir }) {
|
|
851
|
+
const healthState = state?.connectorHealth || {};
|
|
852
|
+
const intervalMinutes = getConnectorHealthIntervalMinutes(config);
|
|
853
|
+
if (!isDue(healthState.lastCheckedAt, intervalMinutes)) {
|
|
854
|
+
return state;
|
|
855
|
+
}
|
|
856
|
+
await ensureDir(runtimeDir);
|
|
857
|
+
const statusCommand = [
|
|
858
|
+
'node',
|
|
859
|
+
'scripts/openclaw-growth-status.mjs',
|
|
860
|
+
'--config',
|
|
861
|
+
quote(configPath),
|
|
862
|
+
'--timeout-ms',
|
|
863
|
+
'15000',
|
|
864
|
+
'--json',
|
|
865
|
+
].join(' ');
|
|
866
|
+
const checkedAt = new Date().toISOString();
|
|
867
|
+
const statusResult = await runShellCommand(statusCommand, 90_000);
|
|
868
|
+
const statusPayload = parseJsonFromStdout(statusResult.stdout);
|
|
869
|
+
if (!statusPayload) {
|
|
870
|
+
const nextState = {
|
|
871
|
+
...state,
|
|
872
|
+
connectorHealth: {
|
|
873
|
+
...healthState,
|
|
874
|
+
lastCheckedAt: checkedAt,
|
|
875
|
+
lastError: statusResult.stderr.trim() || statusResult.stdout.trim() || 'connector status returned no JSON',
|
|
876
|
+
},
|
|
877
|
+
};
|
|
878
|
+
await fs.mkdir(path.dirname(statePath), { recursive: true });
|
|
879
|
+
await fs.writeFile(statePath, JSON.stringify(nextState, null, 2), 'utf8');
|
|
880
|
+
return nextState;
|
|
881
|
+
}
|
|
882
|
+
const unhealthyConnectors = getUnhealthyConfiguredConnectors(statusPayload);
|
|
883
|
+
const connectedConnectors = getConnectedConnectorKeys(statusPayload);
|
|
884
|
+
const fingerprint = buildConnectorHealthFingerprint(unhealthyConnectors);
|
|
885
|
+
const nextHealthState = {
|
|
886
|
+
...healthState,
|
|
887
|
+
lastCheckedAt: checkedAt,
|
|
888
|
+
lastStatusOk: unhealthyConnectors.length === 0,
|
|
889
|
+
lastFingerprint: fingerprint,
|
|
890
|
+
connectedConnectors,
|
|
891
|
+
lastError: null,
|
|
892
|
+
};
|
|
893
|
+
const previousIncidentFingerprint = healthState.lastStatusOk === false
|
|
894
|
+
? healthState.activeIncidentFingerprint || healthState.lastAlertedFingerprint || null
|
|
895
|
+
: null;
|
|
896
|
+
if (unhealthyConnectors.length === 0) {
|
|
897
|
+
nextHealthState.activeIncidentFingerprint = null;
|
|
898
|
+
if (healthState.lastStatusOk === false) {
|
|
899
|
+
nextHealthState.lastRecoveredAt = checkedAt;
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
else {
|
|
903
|
+
nextHealthState.activeIncidentFingerprint = fingerprint;
|
|
904
|
+
}
|
|
905
|
+
if (unhealthyConnectors.length > 0 &&
|
|
906
|
+
previousIncidentFingerprint !== fingerprint) {
|
|
907
|
+
const message = buildConnectorHealthAlert(statusPayload, unhealthyConnectors);
|
|
908
|
+
const paths = await writeConnectorHealthAlert(runtimeDir, message, statusPayload, unhealthyConnectors, fingerprint);
|
|
909
|
+
const deliveries = await deliverConnectorHealthAlert({
|
|
910
|
+
config,
|
|
911
|
+
configPath,
|
|
912
|
+
message,
|
|
913
|
+
statusPayload,
|
|
914
|
+
unhealthyConnectors,
|
|
915
|
+
fingerprint,
|
|
916
|
+
});
|
|
917
|
+
nextHealthState.lastAlertedAt = checkedAt;
|
|
918
|
+
nextHealthState.lastAlertedFingerprint = fingerprint;
|
|
919
|
+
nextHealthState.lastAlertMarkdownPath = paths.markdownPath;
|
|
920
|
+
nextHealthState.lastAlertJsonPath = paths.jsonPath;
|
|
921
|
+
nextHealthState.lastAlertDeliveries = deliveries;
|
|
922
|
+
}
|
|
923
|
+
const nextState = {
|
|
924
|
+
...state,
|
|
925
|
+
connectorHealth: nextHealthState,
|
|
926
|
+
};
|
|
927
|
+
await fs.mkdir(path.dirname(statePath), { recursive: true });
|
|
928
|
+
await fs.writeFile(statePath, JSON.stringify(nextState, null, 2), 'utf8');
|
|
929
|
+
return nextState;
|
|
930
|
+
}
|
|
931
|
+
function buildIssueFingerprint(issuesPayload) {
|
|
932
|
+
const titles = Array.isArray(issuesPayload?.issues)
|
|
933
|
+
? issuesPayload.issues.map((issue) => `${issue.title}|${issue.priority}|${issue.area}`).sort()
|
|
934
|
+
: [];
|
|
935
|
+
return sha256(titles.join('\n'));
|
|
936
|
+
}
|
|
937
|
+
async function runAnalyzer({ config, runtimeDir, sourceFiles, createGitHubArtifact, chartManifestPath, cadencePlanPath, }) {
|
|
938
|
+
await ensureDir(runtimeDir);
|
|
939
|
+
if (!sourceFiles.analytics) {
|
|
940
|
+
throw new Error('Analytics source is required (enable and configure `sources.analytics`).');
|
|
941
|
+
}
|
|
942
|
+
const outFile = path.resolve(config.project?.outFile || 'data/openclaw-growth-engineer/issues.generated.json');
|
|
943
|
+
const args = [
|
|
944
|
+
'scripts/openclaw-growth-engineer.mjs',
|
|
945
|
+
'--analytics',
|
|
946
|
+
sourceFiles.analytics,
|
|
947
|
+
'--repo-root',
|
|
948
|
+
path.resolve(config.project?.repoRoot || '.'),
|
|
949
|
+
'--out',
|
|
950
|
+
outFile,
|
|
951
|
+
'--max-issues',
|
|
952
|
+
String(config.project?.maxIssues || 4),
|
|
953
|
+
'--title-prefix',
|
|
954
|
+
String(config.project?.titlePrefix || '[Growth]'),
|
|
955
|
+
];
|
|
956
|
+
if (sourceFiles.revenuecat) {
|
|
957
|
+
args.push('--revenuecat', sourceFiles.revenuecat);
|
|
958
|
+
}
|
|
959
|
+
if (sourceFiles.sentry) {
|
|
960
|
+
args.push('--sentry', sourceFiles.sentry);
|
|
961
|
+
}
|
|
962
|
+
if (sourceFiles.feedback) {
|
|
963
|
+
args.push('--feedback', sourceFiles.feedback);
|
|
964
|
+
}
|
|
965
|
+
for (const source of getAllSourceEntries(config).filter((entry) => !entry.builtIn)) {
|
|
966
|
+
if (sourceFiles[source.key]) {
|
|
967
|
+
args.push('--source', `${source.key}=${sourceFiles[source.key]}`);
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
if (createGitHubArtifact) {
|
|
971
|
+
const repo = String(config.project?.githubRepo || '').trim();
|
|
972
|
+
if (!repo) {
|
|
973
|
+
throw new Error(`actions.mode=${getActionMode(config)} requires project.githubRepo.`);
|
|
974
|
+
}
|
|
975
|
+
args.push(getActionMode(config) === 'pull_request' ? '--create-pull-requests' : '--create-issues', '--repo', repo);
|
|
976
|
+
if (getActionMode(config) === 'pull_request') {
|
|
977
|
+
args.push('--allow-proposal-pull-requests');
|
|
978
|
+
}
|
|
979
|
+
const labels = Array.isArray(config.project?.labels) ? config.project.labels : [];
|
|
980
|
+
if (labels.length > 0) {
|
|
981
|
+
args.push('--labels', labels.join(','));
|
|
982
|
+
}
|
|
983
|
+
if (config.actions?.proposalBranchPrefix) {
|
|
984
|
+
args.push('--branch-prefix', String(config.actions.proposalBranchPrefix));
|
|
985
|
+
}
|
|
986
|
+
if (config.actions?.draftPullRequests === false) {
|
|
987
|
+
args.push('--no-draft-pull-requests');
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
if (chartManifestPath) {
|
|
991
|
+
args.push('--chart-manifest', chartManifestPath);
|
|
992
|
+
}
|
|
993
|
+
if (cadencePlanPath) {
|
|
994
|
+
args.push('--cadence-plan', cadencePlanPath);
|
|
995
|
+
}
|
|
996
|
+
const analyzer = await runShellCommand(`node ${args.map(quote).join(' ')}`);
|
|
997
|
+
if (!analyzer.ok) {
|
|
998
|
+
throw new Error(`Analyzer failed: ${analyzer.stderr || `exit ${analyzer.code}`}`);
|
|
999
|
+
}
|
|
1000
|
+
const issuesPayload = await readJson(outFile);
|
|
1001
|
+
return {
|
|
1002
|
+
outFile,
|
|
1003
|
+
sourceFiles,
|
|
1004
|
+
issuesPayload,
|
|
1005
|
+
analyzerStdout: analyzer.stdout.trim(),
|
|
1006
|
+
};
|
|
1007
|
+
}
|
|
1008
|
+
async function maybeGenerateCharts({ config, payloads, runtimeDir }) {
|
|
1009
|
+
if (!config.charting?.enabled) {
|
|
1010
|
+
return null;
|
|
1011
|
+
}
|
|
1012
|
+
const analyticsPayload = payloads.analytics;
|
|
1013
|
+
if (!analyticsPayload) {
|
|
1014
|
+
return null;
|
|
1015
|
+
}
|
|
1016
|
+
await ensureDir(runtimeDir);
|
|
1017
|
+
const chartsDir = path.join(runtimeDir, 'charts');
|
|
1018
|
+
await ensureDir(chartsDir);
|
|
1019
|
+
const analyticsForChartsPath = path.join(runtimeDir, 'analytics_for_charts.json');
|
|
1020
|
+
const manifestPath = path.join(chartsDir, 'manifest.json');
|
|
1021
|
+
await fs.writeFile(analyticsForChartsPath, JSON.stringify(analyticsPayload, null, 2), 'utf8');
|
|
1022
|
+
const defaultCommand = [
|
|
1023
|
+
'python3',
|
|
1024
|
+
'scripts/openclaw-growth-charts.py',
|
|
1025
|
+
'--analytics',
|
|
1026
|
+
analyticsForChartsPath,
|
|
1027
|
+
'--out-dir',
|
|
1028
|
+
chartsDir,
|
|
1029
|
+
'--manifest',
|
|
1030
|
+
manifestPath,
|
|
1031
|
+
]
|
|
1032
|
+
.map(quote)
|
|
1033
|
+
.join(' ');
|
|
1034
|
+
const command = String(config.charting?.command || defaultCommand);
|
|
1035
|
+
const result = await runShellCommand(command);
|
|
1036
|
+
if (!result.ok) {
|
|
1037
|
+
process.stderr.write(`[${new Date().toISOString()}] Chart generation failed: ${result.stderr || `exit ${result.code}`}\n`);
|
|
1038
|
+
return null;
|
|
1039
|
+
}
|
|
1040
|
+
return manifestPath;
|
|
1041
|
+
}
|
|
1042
|
+
function quote(value) {
|
|
1043
|
+
if (/^[a-zA-Z0-9_./:-]+$/.test(value)) {
|
|
1044
|
+
return value;
|
|
1045
|
+
}
|
|
1046
|
+
return `'${String(value).replace(/'/g, `'\\''`)}'`;
|
|
1047
|
+
}
|
|
1048
|
+
function computeSourceHashes(sourcePayloadMap) {
|
|
1049
|
+
const hashes = {};
|
|
1050
|
+
for (const [key, value] of Object.entries(sourcePayloadMap)) {
|
|
1051
|
+
hashes[key] = sha256(stableStringify(value));
|
|
1052
|
+
}
|
|
1053
|
+
return hashes;
|
|
1054
|
+
}
|
|
1055
|
+
function normalizeLookback(value, fallback = '30d') {
|
|
1056
|
+
const normalized = String(value || fallback).trim();
|
|
1057
|
+
return /^[0-9]+[dhm]$/.test(normalized) ? normalized : fallback;
|
|
1058
|
+
}
|
|
1059
|
+
function commandHasExplicitTimeBounds(command) {
|
|
1060
|
+
return /(^|\s)--(?:since|until|last)\b/.test(String(command));
|
|
1061
|
+
}
|
|
1062
|
+
function resolveCursorAwareCommand(command, sourceConfig, cursorState) {
|
|
1063
|
+
const rawCommand = String(command || '').trim();
|
|
1064
|
+
if (!rawCommand) {
|
|
1065
|
+
return rawCommand;
|
|
1066
|
+
}
|
|
1067
|
+
if (sourceConfig?.cursorMode !== 'auto_since_last_fetch') {
|
|
1068
|
+
return rawCommand;
|
|
1069
|
+
}
|
|
1070
|
+
if (commandHasExplicitTimeBounds(rawCommand)) {
|
|
1071
|
+
return rawCommand;
|
|
1072
|
+
}
|
|
1073
|
+
const lastCollectedAt = String(cursorState?.lastCollectedAt || '').trim();
|
|
1074
|
+
if (lastCollectedAt) {
|
|
1075
|
+
return `${rawCommand} --since ${quote(lastCollectedAt)}`;
|
|
1076
|
+
}
|
|
1077
|
+
const lookback = normalizeLookback(sourceConfig?.initialLookback, '30d');
|
|
1078
|
+
return `${rawCommand} --last ${quote(lookback)}`;
|
|
1079
|
+
}
|
|
1080
|
+
async function resolveSourcePayloadWithCursor(sourceConfig, sourceName, cursorState, commandCwd = process.cwd()) {
|
|
1081
|
+
if (!sourceConfig || sourceConfig.enabled === false) {
|
|
1082
|
+
return {
|
|
1083
|
+
payload: null,
|
|
1084
|
+
nextCursor: cursorState || null,
|
|
1085
|
+
resolvedCommand: null,
|
|
1086
|
+
};
|
|
1087
|
+
}
|
|
1088
|
+
if (sourceConfig.mode === 'command') {
|
|
1089
|
+
if (!sourceConfig.command) {
|
|
1090
|
+
throw new Error(`Source "${sourceName}" has mode=command but no command configured.`);
|
|
1091
|
+
}
|
|
1092
|
+
const resolvedCommand = resolveCursorAwareCommand(sourceConfig.command, sourceConfig, cursorState);
|
|
1093
|
+
const result = await runShellCommand(String(resolvedCommand), 120_000, { cwd: commandCwd });
|
|
1094
|
+
if (!result.ok) {
|
|
1095
|
+
throw new Error(`Source "${sourceName}" command failed: ${result.stderr || `exit ${result.code}`}`);
|
|
1096
|
+
}
|
|
1097
|
+
const fetchedAt = new Date().toISOString();
|
|
1098
|
+
try {
|
|
1099
|
+
return {
|
|
1100
|
+
payload: JSON.parse(result.stdout),
|
|
1101
|
+
nextCursor: sourceConfig.cursorMode === 'auto_since_last_fetch'
|
|
1102
|
+
? {
|
|
1103
|
+
lastCollectedAt: fetchedAt,
|
|
1104
|
+
updatedAt: fetchedAt,
|
|
1105
|
+
lastCommand: resolvedCommand,
|
|
1106
|
+
}
|
|
1107
|
+
: cursorState || null,
|
|
1108
|
+
resolvedCommand,
|
|
1109
|
+
};
|
|
1110
|
+
}
|
|
1111
|
+
catch {
|
|
1112
|
+
throw new Error(`Source "${sourceName}" returned non-JSON output.`);
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1115
|
+
if (!sourceConfig.path) {
|
|
1116
|
+
throw new Error(`Source "${sourceName}" has mode=file but no path configured.`);
|
|
1117
|
+
}
|
|
1118
|
+
return {
|
|
1119
|
+
payload: await readJson(path.resolve(String(sourceConfig.path))),
|
|
1120
|
+
nextCursor: cursorState || null,
|
|
1121
|
+
resolvedCommand: null,
|
|
1122
|
+
};
|
|
1123
|
+
}
|
|
1124
|
+
async function loadSourcePayloads(config, state) {
|
|
1125
|
+
const payloads = {};
|
|
1126
|
+
const sourceCursors = { ...(state?.sourceCursors || {}) };
|
|
1127
|
+
const commandCwd = getProjectCommandCwd(config);
|
|
1128
|
+
for (const source of getAllSourceEntries(config)) {
|
|
1129
|
+
const currentCursor = sourceCursors[source.key] || null;
|
|
1130
|
+
const result = await resolveSourcePayloadWithCursor(source, source.key, currentCursor, commandCwd);
|
|
1131
|
+
const payload = result.payload;
|
|
1132
|
+
if (payload) {
|
|
1133
|
+
payloads[source.key] = payload;
|
|
1134
|
+
}
|
|
1135
|
+
if (result.nextCursor) {
|
|
1136
|
+
sourceCursors[source.key] = result.nextCursor;
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
return {
|
|
1140
|
+
payloads,
|
|
1141
|
+
sourceCursors,
|
|
1142
|
+
};
|
|
1143
|
+
}
|
|
1144
|
+
async function materializeSourceFiles(config, payloads, runtimeDir) {
|
|
1145
|
+
await ensureDir(runtimeDir);
|
|
1146
|
+
const sourceFiles = {};
|
|
1147
|
+
for (const source of getAllSourceEntries(config)) {
|
|
1148
|
+
const payload = payloads[source.key];
|
|
1149
|
+
if (!payload) {
|
|
1150
|
+
continue;
|
|
1151
|
+
}
|
|
1152
|
+
const filePath = path.join(runtimeDir, `${source.key}.json`);
|
|
1153
|
+
await fs.writeFile(filePath, JSON.stringify(payload, null, 2), 'utf8');
|
|
1154
|
+
sourceFiles[source.key] = filePath;
|
|
1155
|
+
}
|
|
1156
|
+
return sourceFiles;
|
|
1157
|
+
}
|
|
1158
|
+
function hasSourceChanges(previousHashes, currentHashes) {
|
|
1159
|
+
const allKeys = new Set([...Object.keys(previousHashes || {}), ...Object.keys(currentHashes || {})]);
|
|
1160
|
+
for (const key of allKeys) {
|
|
1161
|
+
if ((previousHashes || {})[key] !== (currentHashes || {})[key]) {
|
|
1162
|
+
return true;
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
1165
|
+
return false;
|
|
1166
|
+
}
|
|
1167
|
+
async function runOnce(configPath, statePath) {
|
|
1168
|
+
const config = await readJson(configPath);
|
|
1169
|
+
await applyOpenClawSecretRefs(config);
|
|
1170
|
+
await assertHardRequirements(config);
|
|
1171
|
+
const state = await readJsonOptional(statePath, {
|
|
1172
|
+
sourceHashes: {},
|
|
1173
|
+
lastIssueFingerprint: null,
|
|
1174
|
+
lastRunAt: null,
|
|
1175
|
+
sourceCursors: {},
|
|
1176
|
+
});
|
|
1177
|
+
const runtimeDir = path.resolve(DEFAULT_RUNTIME_DIR);
|
|
1178
|
+
const stateAfterHealthCheck = await maybeRunConnectorHealthCheck({
|
|
1179
|
+
config,
|
|
1180
|
+
configPath,
|
|
1181
|
+
state,
|
|
1182
|
+
statePath,
|
|
1183
|
+
runtimeDir,
|
|
1184
|
+
});
|
|
1185
|
+
const activeCadences = getDueCadences(config, stateAfterHealthCheck);
|
|
1186
|
+
const { payloads, sourceCursors } = await loadSourcePayloads(config, stateAfterHealthCheck);
|
|
1187
|
+
const currentHashes = computeSourceHashes(payloads);
|
|
1188
|
+
const changed = hasSourceChanges(stateAfterHealthCheck.sourceHashes, currentHashes);
|
|
1189
|
+
if (!changed && config.schedule?.skipIfNoDataChange !== false) {
|
|
1190
|
+
process.stdout.write(`[${new Date().toISOString()}] No data changes. Skip run.\n`);
|
|
1191
|
+
await fs.mkdir(path.dirname(statePath), { recursive: true });
|
|
1192
|
+
await fs.writeFile(statePath, JSON.stringify({
|
|
1193
|
+
...stateAfterHealthCheck,
|
|
1194
|
+
sourceHashes: currentHashes,
|
|
1195
|
+
sourceCursors,
|
|
1196
|
+
lastRunAt: new Date().toISOString(),
|
|
1197
|
+
skippedReason: 'no_data_change',
|
|
1198
|
+
}, null, 2), 'utf8');
|
|
1199
|
+
return;
|
|
1200
|
+
}
|
|
1201
|
+
const createGitHubArtifact = shouldAutoCreateGitHubArtifact(config);
|
|
1202
|
+
const sourceFiles = await materializeSourceFiles(config, payloads, runtimeDir);
|
|
1203
|
+
const cadencePlanPath = path.join(runtimeDir, 'cadence-plan.json');
|
|
1204
|
+
await fs.writeFile(cadencePlanPath, JSON.stringify({
|
|
1205
|
+
generatedAt: new Date().toISOString(),
|
|
1206
|
+
cadences: activeCadences,
|
|
1207
|
+
}, null, 2), 'utf8');
|
|
1208
|
+
const chartManifestPath = await maybeGenerateCharts({
|
|
1209
|
+
config,
|
|
1210
|
+
payloads,
|
|
1211
|
+
runtimeDir,
|
|
1212
|
+
});
|
|
1213
|
+
const dryRun = await runAnalyzer({
|
|
1214
|
+
config,
|
|
1215
|
+
runtimeDir,
|
|
1216
|
+
sourceFiles,
|
|
1217
|
+
createGitHubArtifact: false,
|
|
1218
|
+
chartManifestPath,
|
|
1219
|
+
cadencePlanPath,
|
|
1220
|
+
});
|
|
1221
|
+
const issueFingerprint = buildIssueFingerprint(dryRun.issuesPayload);
|
|
1222
|
+
const unchangedIssueSet = issueFingerprint === stateAfterHealthCheck.lastIssueFingerprint;
|
|
1223
|
+
if (unchangedIssueSet && config.schedule?.skipIfIssueSetUnchanged !== false) {
|
|
1224
|
+
process.stdout.write(`[${new Date().toISOString()}] Issue set unchanged. Skip GitHub creation.\n`);
|
|
1225
|
+
await fs.mkdir(path.dirname(statePath), { recursive: true });
|
|
1226
|
+
await fs.writeFile(statePath, JSON.stringify({
|
|
1227
|
+
...stateAfterHealthCheck,
|
|
1228
|
+
sourceHashes: currentHashes,
|
|
1229
|
+
sourceCursors,
|
|
1230
|
+
lastIssueFingerprint: issueFingerprint,
|
|
1231
|
+
lastRunAt: new Date().toISOString(),
|
|
1232
|
+
lastOutFile: dryRun.outFile,
|
|
1233
|
+
cadences: markCadencesRan(stateAfterHealthCheck, activeCadences, new Date().toISOString()),
|
|
1234
|
+
skippedReason: 'issue_set_unchanged',
|
|
1235
|
+
}, null, 2), 'utf8');
|
|
1236
|
+
return;
|
|
1237
|
+
}
|
|
1238
|
+
const shouldCreateGitHubArtifact = createGitHubArtifact && Number(dryRun.issuesPayload?.issue_count || 0) > 0;
|
|
1239
|
+
if (shouldCreateGitHubArtifact) {
|
|
1240
|
+
await runAnalyzer({
|
|
1241
|
+
config,
|
|
1242
|
+
runtimeDir,
|
|
1243
|
+
sourceFiles,
|
|
1244
|
+
createGitHubArtifact: true,
|
|
1245
|
+
chartManifestPath,
|
|
1246
|
+
cadencePlanPath,
|
|
1247
|
+
});
|
|
1248
|
+
process.stdout.write(`[${new Date().toISOString()}] Created GitHub ${getActionMode(config) === 'pull_request' ? 'pull requests' : 'issues'}.\n`);
|
|
1249
|
+
}
|
|
1250
|
+
else {
|
|
1251
|
+
process.stdout.write(`[${new Date().toISOString()}] Drafts generated only (${getActionMode(config)} auto-create disabled).\n`);
|
|
1252
|
+
}
|
|
1253
|
+
await fs.mkdir(path.dirname(statePath), { recursive: true });
|
|
1254
|
+
await fs.writeFile(statePath, JSON.stringify({
|
|
1255
|
+
...stateAfterHealthCheck,
|
|
1256
|
+
sourceHashes: currentHashes,
|
|
1257
|
+
sourceCursors,
|
|
1258
|
+
lastIssueFingerprint: issueFingerprint,
|
|
1259
|
+
lastRunAt: new Date().toISOString(),
|
|
1260
|
+
lastOutFile: dryRun.outFile,
|
|
1261
|
+
cadences: markCadencesRan(stateAfterHealthCheck, activeCadences, new Date().toISOString()),
|
|
1262
|
+
lastGrowthRunNotifications: await deliverGrowthRunSummary({
|
|
1263
|
+
config,
|
|
1264
|
+
configPath,
|
|
1265
|
+
issuesPayload: dryRun.issuesPayload,
|
|
1266
|
+
activeCadences,
|
|
1267
|
+
sourceFiles,
|
|
1268
|
+
fingerprint: issueFingerprint,
|
|
1269
|
+
createdGitHubArtifact: shouldCreateGitHubArtifact,
|
|
1270
|
+
}),
|
|
1271
|
+
skippedReason: null,
|
|
1272
|
+
}, null, 2), 'utf8');
|
|
1273
|
+
}
|
|
1274
|
+
async function main() {
|
|
1275
|
+
await loadOpenClawGrowthSecrets();
|
|
1276
|
+
const args = parseArgs(process.argv.slice(2));
|
|
1277
|
+
await maybeSelfUpdateFromClawHub(args);
|
|
1278
|
+
const configPath = path.resolve(args.config);
|
|
1279
|
+
const statePath = path.resolve(args.state);
|
|
1280
|
+
if (!args.loop) {
|
|
1281
|
+
await runOnce(configPath, statePath);
|
|
1282
|
+
return;
|
|
1283
|
+
}
|
|
1284
|
+
const config = await readJson(configPath);
|
|
1285
|
+
const intervalMinutes = Math.max(1, Number(config.schedule?.intervalMinutes || 1440));
|
|
1286
|
+
process.stdout.write(`Starting loop. Interval: ${intervalMinutes} minute(s)\n`);
|
|
1287
|
+
while (true) {
|
|
1288
|
+
try {
|
|
1289
|
+
await maybeSelfUpdateFromClawHub(args);
|
|
1290
|
+
await runOnce(configPath, statePath);
|
|
1291
|
+
}
|
|
1292
|
+
catch (error) {
|
|
1293
|
+
process.stderr.write(`[${new Date().toISOString()}] Run failed: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
1294
|
+
}
|
|
1295
|
+
await sleep(intervalMinutes * 60_000);
|
|
1296
|
+
}
|
|
1297
|
+
}
|
|
1298
|
+
main().catch((error) => {
|
|
1299
|
+
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
|
1300
|
+
process.exitCode = 1;
|
|
1301
|
+
});
|
|
1302
|
+
//# sourceMappingURL=openclaw-growth-runner.mjs.map
|