@analyticscli/growth-engineer 0.1.1-preview.0 → 0.1.1-preview.4

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.
@@ -11,7 +11,11 @@ const DEFAULT_CONFIG_PATH = 'data/openclaw-growth-engineer/config.json';
11
11
  const DEFAULT_STATE_PATH = 'data/openclaw-growth-engineer/state.json';
12
12
  const DEFAULT_SCHEDULER_PROOF_PATH = 'data/openclaw-growth-engineer/runtime/scheduler-proof.jsonl';
13
13
  const DEFAULT_CONNECTOR_HEALTH_INTERVAL_MINUTES = 360;
14
+ const DEFAULT_DAILY_ISSUE_EVENT_GROWTH_MULTIPLIER = 2;
15
+ const DEFAULT_DAILY_ISSUE_EVENT_GROWTH_MIN_DELTA = 10;
16
+ const DEFAULT_DAILY_RUNNER_FAILURE_RETENTION_DAYS = 14;
14
17
  const SELF_UPDATE_INTERVAL_MS = 24 * 60 * 60 * 1000;
18
+ const SELF_UPDATE_SKILL_SLUG_CANDIDATES = ['growth-engineer', 'openclaw-growth-engineer'];
15
19
  const RUNTIME_DIR = path.dirname(fileURLToPath(import.meta.url));
16
20
  let schedulerProofPath = path.resolve(DEFAULT_SCHEDULER_PROOF_PATH);
17
21
  const DEFAULT_CADENCES = [
@@ -144,6 +148,7 @@ function resolveRuntimeScriptPath(scriptName) {
144
148
  const candidates = [
145
149
  path.join(RUNTIME_DIR, scriptName),
146
150
  path.resolve('scripts', scriptName),
151
+ path.resolve('skills/growth-engineer/scripts', scriptName),
147
152
  path.resolve('skills/openclaw-growth-engineer/scripts', scriptName),
148
153
  ];
149
154
  return candidates.find((candidate) => existsSync(candidate)) || path.join(RUNTIME_DIR, scriptName);
@@ -247,9 +252,13 @@ function isSentryCompatibleSource(sourceConfig, sourceName) {
247
252
  function shouldDegradeTransientSourceFailure(sourceConfig, sourceName, retried) {
248
253
  if (!retried)
249
254
  return false;
255
+ if (sourceConfig?.degradeTransientFailures === false)
256
+ return false;
250
257
  if (!isRequiredSource(sourceConfig, sourceName))
251
258
  return true;
252
- return isSentryCompatibleSource(sourceConfig, sourceName);
259
+ if (isSentryCompatibleSource(sourceConfig, sourceName))
260
+ return true;
261
+ return sourceConfig?.degradeRequiredTransientFailures !== false;
253
262
  }
254
263
  function isTruthyEnv(value) {
255
264
  return ['1', 'true', 'yes', 'y', 'on'].includes(String(value || '').trim().toLowerCase());
@@ -327,6 +336,23 @@ async function rerunCurrentProcessWithoutSelfUpdate() {
327
336
  child.on('close', (code) => resolve(code));
328
337
  });
329
338
  }
339
+ function getSelfUpdateSkillCandidates(workspaceRoot) {
340
+ const explicit = String(process.env.OPENCLAW_GROWTH_SKILL_SLUG || '').trim();
341
+ const uniqueSlugs = [...new Set([explicit, ...SELF_UPDATE_SKILL_SLUG_CANDIDATES].filter(Boolean))];
342
+ return uniqueSlugs.map((slug) => {
343
+ const skillRoot = path.join(workspaceRoot, 'skills', slug);
344
+ return {
345
+ slug,
346
+ skillRoot,
347
+ originPath: path.join(skillRoot, '.clawhub/origin.json'),
348
+ runnerPath: path.join(skillRoot, 'scripts/openclaw-growth-runner.mjs'),
349
+ bootstrapPath: path.join(skillRoot, 'scripts/bootstrap-openclaw-workspace.sh'),
350
+ };
351
+ });
352
+ }
353
+ function resolveInstalledSelfUpdateSkill(workspaceRoot) {
354
+ return getSelfUpdateSkillCandidates(workspaceRoot).find((candidate) => existsSync(candidate.originPath)) || null;
355
+ }
330
356
  async function maybeSelfUpdateFromClawHub(args) {
331
357
  if (args.noSelfUpdate)
332
358
  return false;
@@ -337,26 +363,27 @@ async function maybeSelfUpdateFromClawHub(args) {
337
363
  if (isFalseyEnv(process.env.OPENCLAW_GROWTH_SELF_UPDATE))
338
364
  return false;
339
365
  const workspaceRoot = process.cwd();
340
- const skillOriginPath = path.join(workspaceRoot, 'skills/openclaw-growth-engineer/.clawhub/origin.json');
341
- if (!existsSync(skillOriginPath))
366
+ const installedSkill = resolveInstalledSelfUpdateSkill(workspaceRoot);
367
+ if (!installedSkill)
342
368
  return false;
343
369
  if (!(await commandExists('npx')))
344
370
  return false;
345
371
  const force = String(process.env.OPENCLAW_GROWTH_SELF_UPDATE || '').trim().toLowerCase() === 'always';
346
372
  if (!(await shouldRunSelfUpdate(workspaceRoot, force)))
347
373
  return false;
348
- const beforeOrigin = await readJsonOptional(skillOriginPath, null);
374
+ const beforeOrigin = await readJsonOptional(installedSkill.originPath, null);
349
375
  const beforeVersion = String(beforeOrigin?.installedVersion || '');
350
- process.stdout.write('Checking for OpenClaw Growth Engineer skill updates...\n');
351
- const updateResult = await runShellCommand('npx -y clawhub --no-input --dir skills update openclaw-growth-engineer --force', 120_000);
352
- const afterOrigin = await readJsonOptional(skillOriginPath, null);
376
+ process.stdout.write(`Checking for Growth Engineer skill updates (${installedSkill.slug})...\n`);
377
+ const updateResult = await runShellCommand(`npx -y clawhub --no-input --dir skills update ${quote(installedSkill.slug)} --force`, 120_000);
378
+ const afterOrigin = await readJsonOptional(installedSkill.originPath, null);
353
379
  const afterVersion = String(afterOrigin?.installedVersion || beforeVersion || '');
354
380
  const workspaceRunnerPath = path.resolve(process.argv[1] || 'scripts/openclaw-growth-runner.mjs');
355
- const skillRunnerPath = path.join(workspaceRoot, 'skills/openclaw-growth-engineer/scripts/openclaw-growth-runner.mjs');
356
- const runtimeOutdated = !(await filesHaveSameContent(workspaceRunnerPath, skillRunnerPath));
381
+ const runtimeOutdated = !(await filesHaveSameContent(workspaceRunnerPath, installedSkill.runnerPath));
357
382
  await writeSelfUpdateState(workspaceRoot, {
358
383
  lastCheckedAt: new Date().toISOString(),
359
384
  ok: updateResult.ok,
385
+ skillSlug: installedSkill.slug,
386
+ skillRoot: installedSkill.skillRoot,
360
387
  previousVersion: beforeVersion || null,
361
388
  installedVersion: afterVersion || null,
362
389
  }).catch(() => { });
@@ -370,7 +397,7 @@ async function maybeSelfUpdateFromClawHub(args) {
370
397
  process.stdout.write(afterVersion && afterVersion !== beforeVersion
371
398
  ? `Updated OpenClaw Growth Engineer skill ${beforeVersion || 'unknown'} -> ${afterVersion}. Refreshing workspace runtime...\n`
372
399
  : 'Refreshing workspace runtime from the installed OpenClaw Growth Engineer skill...\n');
373
- const bootstrapResult = await runShellCommand('bash skills/openclaw-growth-engineer/scripts/bootstrap-openclaw-workspace.sh', 60_000);
400
+ const bootstrapResult = await runShellCommand(`bash ${quote(installedSkill.bootstrapPath)}`, 60_000);
374
401
  if (!bootstrapResult.ok) {
375
402
  process.stdout.write('Workspace runtime refresh failed; continuing with current process.\n');
376
403
  return false;
@@ -399,6 +426,27 @@ function resolveShellCommand() {
399
426
  function hardenUnattendedShellCommand(command) {
400
427
  return String(command || '').replace(/(^|[;&|]\s*)sudo(?!\s+-n(?:\s|$))(?=\s|$)/g, '$1sudo -n');
401
428
  }
429
+ function redactCommandForDiagnostics(command) {
430
+ const raw = String(command || '').trim();
431
+ if (!raw)
432
+ return '';
433
+ return raw
434
+ .replace(/((?:^|\s)(?:[A-Z0-9_]*(?:TOKEN|SECRET|PASSWORD|PASS|KEY|AUTH|CREDENTIAL)[A-Z0-9_]*)=)(?:"[^"]*"|'[^']*'|\S+)/gi, '$1[redacted]')
435
+ .replace(/((?:^|\s)--(?:token|access-token|api-key|key|secret|password|pass|authorization|auth|bearer)(?:=|\s+))(?:"[^"]*"|'[^']*'|\S+)/gi, '$1[redacted]');
436
+ }
437
+ function truncateDiagnosticText(value, maxLength = 2000) {
438
+ const text = String(value || '').trim();
439
+ if (text.length <= maxLength)
440
+ return text;
441
+ return `${text.slice(0, maxLength - 1)}…`;
442
+ }
443
+ function buildSourceCommandFailureMessage(sourceName, resolvedCommand, detail) {
444
+ const safeCommand = redactCommandForDiagnostics(resolvedCommand);
445
+ const safeDetail = truncateDiagnosticText(detail);
446
+ return safeCommand
447
+ ? `Source "${sourceName}" command failed: command \`${safeCommand}\`; ${safeDetail}`
448
+ : `Source "${sourceName}" command failed: ${safeDetail}`;
449
+ }
402
450
  function isSudoPasswordPrompt(stderr) {
403
451
  return /sudo: (?:a password is required|a terminal is required to read the password|no tty present)/i.test(String(stderr || ''));
404
452
  }
@@ -644,7 +692,8 @@ function buildConnectorWizardCommand(configPath, entry) {
644
692
  const connector = connectorWizardKey(entry.key);
645
693
  if (!connector)
646
694
  return null;
647
- return `npx -y @analyticscli/growth-engineer@preview wizard --connectors ${quote(connector)}`;
695
+ const configArg = configPath ? ` --config ${quote(configPath)}` : '';
696
+ return `npx -y @analyticscli/growth-engineer@preview wizard${configArg} --connectors ${quote(connector)}`;
648
697
  }
649
698
  function conciseConnectorDetail(entry) {
650
699
  const detail = String(entry?.detail || '').replace(/\s+/g, ' ').trim();
@@ -1042,6 +1091,323 @@ function groupIssuesByProject(issues, maxIssues = 4) {
1042
1091
  }
1043
1092
  return [...grouped.entries()];
1044
1093
  }
1094
+ function getDailyIssueDedupeConfig(config) {
1095
+ const raw = config?.schedule?.dailyIssueDedupe || config?.notifications?.growthRun?.dailyIssueDedupe || {};
1096
+ const multiplier = Number(raw.eventGrowthMultiplier ?? raw.multiplier);
1097
+ const minDelta = Number(raw.eventGrowthMinDelta ?? raw.minDelta);
1098
+ return {
1099
+ enabled: raw.enabled !== false,
1100
+ eventGrowthMultiplier: Number.isFinite(multiplier) && multiplier > 1
1101
+ ? multiplier
1102
+ : DEFAULT_DAILY_ISSUE_EVENT_GROWTH_MULTIPLIER,
1103
+ eventGrowthMinDelta: Number.isFinite(minDelta) && minDelta > 0
1104
+ ? minDelta
1105
+ : DEFAULT_DAILY_ISSUE_EVENT_GROWTH_MIN_DELTA,
1106
+ };
1107
+ }
1108
+ function getDailyRunnerFailureDedupeConfig(config) {
1109
+ const raw = config?.schedule?.dailyRunnerFailureDedupe || config?.notifications?.growthRun?.dailyRunnerFailureDedupe || {};
1110
+ const retentionDays = Number(raw.retentionDays);
1111
+ return {
1112
+ enabled: raw.enabled !== false,
1113
+ retentionDays: Number.isFinite(retentionDays) && retentionDays > 0
1114
+ ? retentionDays
1115
+ : DEFAULT_DAILY_RUNNER_FAILURE_RETENTION_DAYS,
1116
+ };
1117
+ }
1118
+ function resolveDailyIssueDedupeTimeZone(config) {
1119
+ return String(config?.schedule?.timezone ||
1120
+ config?.automation?.openclawCron?.timezone ||
1121
+ process.env.TZ ||
1122
+ 'UTC').trim() || 'UTC';
1123
+ }
1124
+ function normalizeRunnerFailureForFingerprint(errorMessage) {
1125
+ return redactCommandForDiagnostics(errorMessage)
1126
+ .replace(/\b\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z\b/g, '[timestamp]')
1127
+ .replace(/--since(?:=|\s+)(?:"[^"]*"|'[^']*'|\S+)/g, '--since [timestamp]')
1128
+ .replace(/--until(?:=|\s+)(?:"[^"]*"|'[^']*'|\S+)/g, '--until [timestamp]')
1129
+ .replace(/\bpid\s+\d+\b/gi, 'pid [pid]')
1130
+ .replace(/\s+/g, ' ')
1131
+ .trim();
1132
+ }
1133
+ function buildRunnerFailureFingerprint(errorMessage) {
1134
+ return sha256(normalizeRunnerFailureForFingerprint(errorMessage));
1135
+ }
1136
+ function pruneDailyRunnerFailures(failures, now, retentionDays) {
1137
+ const cutoffMs = now.getTime() - retentionDays * 24 * 60 * 60 * 1000;
1138
+ return Object.fromEntries(Object.entries(failures || {}).filter(([, entry]) => {
1139
+ const lastSeenMs = Date.parse(String(entry?.lastSeenAt || entry?.firstSeenAt || ''));
1140
+ return !Number.isFinite(lastSeenMs) || lastSeenMs >= cutoffMs;
1141
+ }));
1142
+ }
1143
+ function parseFailureArgs(argv) {
1144
+ const args = {
1145
+ config: DEFAULT_CONFIG_PATH,
1146
+ state: DEFAULT_STATE_PATH,
1147
+ };
1148
+ for (let i = 0; i < argv.length; i += 1) {
1149
+ const token = argv[i];
1150
+ const next = argv[i + 1];
1151
+ if (token === '--config' && next) {
1152
+ args.config = next;
1153
+ i += 1;
1154
+ }
1155
+ else if (token === '--state' && next) {
1156
+ args.state = next;
1157
+ i += 1;
1158
+ }
1159
+ }
1160
+ return args;
1161
+ }
1162
+ async function recordRunnerFailure({ configPath, statePath, error, argv = [], now = new Date() }) {
1163
+ const errorMessage = truncateDiagnosticText(error instanceof Error ? error.message : String(error));
1164
+ const config = await readJsonOptional(configPath, {});
1165
+ const state = await readJsonOptional(statePath, {
1166
+ sourceHashes: {},
1167
+ lastIssueFingerprint: null,
1168
+ lastRunAt: null,
1169
+ sourceCursors: {},
1170
+ });
1171
+ const dedupeConfig = getDailyRunnerFailureDedupeConfig(config);
1172
+ const timeZone = resolveDailyIssueDedupeTimeZone(config);
1173
+ const date = formatDateInTimeZone(now, timeZone);
1174
+ const fingerprint = buildRunnerFailureFingerprint(errorMessage);
1175
+ const nowIso = now.toISOString();
1176
+ const previousDailyFailures = state?.dailyRunnerFailures?.date === date
1177
+ ? state.dailyRunnerFailures
1178
+ : null;
1179
+ const previousFailures = previousDailyFailures?.failures && typeof previousDailyFailures.failures === 'object'
1180
+ ? previousDailyFailures.failures
1181
+ : {};
1182
+ const failures = pruneDailyRunnerFailures(previousFailures, now, dedupeConfig.retentionDays);
1183
+ const previousEntry = failures[fingerprint] || null;
1184
+ const suppressed = dedupeConfig.enabled && Boolean(previousEntry);
1185
+ const nextEntry = {
1186
+ ...(previousEntry || {}),
1187
+ fingerprint,
1188
+ error: errorMessage,
1189
+ normalizedError: normalizeRunnerFailureForFingerprint(errorMessage),
1190
+ firstSeenAt: previousEntry?.firstSeenAt || nowIso,
1191
+ lastSeenAt: nowIso,
1192
+ };
1193
+ if (suppressed) {
1194
+ nextEntry.suppressedCount = Number(previousEntry?.suppressedCount || 0) + 1;
1195
+ }
1196
+ else {
1197
+ nextEntry.lastReportedAt = nowIso;
1198
+ nextEntry.reportCount = Number(previousEntry?.reportCount || 0) + 1;
1199
+ }
1200
+ failures[fingerprint] = nextEntry;
1201
+ const nextState = {
1202
+ ...state,
1203
+ dailyRunnerFailures: {
1204
+ date,
1205
+ timeZone,
1206
+ failures,
1207
+ updatedAt: nowIso,
1208
+ },
1209
+ lastRunnerFailure: {
1210
+ fingerprint,
1211
+ error: errorMessage,
1212
+ failedAt: nowIso,
1213
+ suppressed,
1214
+ },
1215
+ };
1216
+ await fs.mkdir(path.dirname(statePath), { recursive: true });
1217
+ await fs.writeFile(statePath, JSON.stringify(nextState, null, 2), 'utf8');
1218
+ await appendSchedulerProof(suppressed ? 'runner_failed_suppressed' : 'runner_failed', {
1219
+ configPath,
1220
+ statePath,
1221
+ error: errorMessage,
1222
+ errorFingerprint: fingerprint,
1223
+ date,
1224
+ timeZone,
1225
+ argv,
1226
+ suppressed,
1227
+ reportCount: nextEntry.reportCount || 0,
1228
+ suppressedCount: nextEntry.suppressedCount || 0,
1229
+ socialOutput: suppressed ? 'HEARTBEAT_OK' : 'RUNNER_FAILED',
1230
+ socialReason: suppressed
1231
+ ? 'runner failure unchanged and already reported today'
1232
+ : 'new runner failure for current day',
1233
+ });
1234
+ return {
1235
+ suppressed,
1236
+ exitCode: suppressed ? 0 : 1,
1237
+ fingerprint,
1238
+ };
1239
+ }
1240
+ function formatDateInTimeZone(date, timeZone) {
1241
+ try {
1242
+ const parts = new Intl.DateTimeFormat('en-CA', {
1243
+ timeZone,
1244
+ year: 'numeric',
1245
+ month: '2-digit',
1246
+ day: '2-digit',
1247
+ }).formatToParts(date);
1248
+ const byType = Object.fromEntries(parts.map((part) => [part.type, part.value]));
1249
+ if (byType.year && byType.month && byType.day) {
1250
+ return `${byType.year}-${byType.month}-${byType.day}`;
1251
+ }
1252
+ }
1253
+ catch {
1254
+ // Fall back to UTC below for invalid host timezone settings.
1255
+ }
1256
+ return date.toISOString().slice(0, 10);
1257
+ }
1258
+ function normalizeIssueIdentityPart(value) {
1259
+ return String(value || '')
1260
+ .trim()
1261
+ .toLowerCase()
1262
+ .replace(/\s+/g, ' ');
1263
+ }
1264
+ function buildDailyIssueKey(issue) {
1265
+ const stableIdentity = [
1266
+ issueSourceUrl(issue),
1267
+ issue?.source_url,
1268
+ issue?.sourceUrl,
1269
+ issue?.issue_url,
1270
+ issue?.issueUrl,
1271
+ issue?.signal_id,
1272
+ issue?.signalId,
1273
+ issue?.id,
1274
+ ]
1275
+ .map((value) => String(value || '').trim())
1276
+ .find(Boolean);
1277
+ const fallbackIdentity = [
1278
+ issueProjectLabel(issue),
1279
+ issue?.source,
1280
+ issue?.area,
1281
+ issue?.title,
1282
+ ]
1283
+ .map(normalizeIssueIdentityPart)
1284
+ .filter(Boolean)
1285
+ .join('|');
1286
+ return sha256(stableIdentity || fallbackIdentity || stableStringify(issue));
1287
+ }
1288
+ function coerceIssueNumber(value) {
1289
+ if (value === null || value === undefined || value === '')
1290
+ return null;
1291
+ if (typeof value === 'number')
1292
+ return Number.isFinite(value) ? value : null;
1293
+ const normalized = String(value).replace(/,/g, '').trim();
1294
+ if (!normalized)
1295
+ return null;
1296
+ const number = Number(normalized);
1297
+ return Number.isFinite(number) ? number : null;
1298
+ }
1299
+ function issueEventCount(issue) {
1300
+ const direct = [
1301
+ issue?.events,
1302
+ issue?.eventCount,
1303
+ issue?.event_count,
1304
+ issue?.current_value,
1305
+ issue?.currentValue,
1306
+ issue?.count,
1307
+ ];
1308
+ for (const value of direct) {
1309
+ const number = coerceIssueNumber(value);
1310
+ if (number !== null)
1311
+ return number;
1312
+ }
1313
+ const text = [
1314
+ issue?.impact,
1315
+ issue?.summary,
1316
+ ...(Array.isArray(issue?.evidence) ? issue.evidence : []),
1317
+ issue?.body,
1318
+ ]
1319
+ .filter(Boolean)
1320
+ .join('\n');
1321
+ const match = text.match(/\bEvents?:\s*([0-9][0-9,]*(?:\.[0-9]+)?)/i);
1322
+ return match ? coerceIssueNumber(match[1]) : null;
1323
+ }
1324
+ function isDrasticDailyIssueEventGrowth(currentEvents, previousEntry, dedupeConfig) {
1325
+ if (currentEvents === null || currentEvents === undefined)
1326
+ return false;
1327
+ const previousEvents = coerceIssueNumber(previousEntry?.lastReportedEvents ?? previousEntry?.lastSeenEvents);
1328
+ if (previousEvents === null || previousEvents < 0)
1329
+ return false;
1330
+ const requiredEvents = Math.max(previousEvents * dedupeConfig.eventGrowthMultiplier, previousEvents + dedupeConfig.eventGrowthMinDelta);
1331
+ return currentEvents >= requiredEvents;
1332
+ }
1333
+ function applyDailyIssueDedupe(issuesPayload, state, config, activeCadences, now = new Date()) {
1334
+ const issues = Array.isArray(issuesPayload?.issues) ? issuesPayload.issues : [];
1335
+ const dedupeConfig = getDailyIssueDedupeConfig(config);
1336
+ if (!dedupeConfig.enabled || !isShortOperationalCadence(activeCadences) || issues.length === 0) {
1337
+ return {
1338
+ issuesPayload,
1339
+ dailyIssueReports: state?.dailyIssueReports || null,
1340
+ suppressedCount: 0,
1341
+ reportedCount: issues.length,
1342
+ hasDrasticEventGrowth: false,
1343
+ };
1344
+ }
1345
+ const timeZone = resolveDailyIssueDedupeTimeZone(config);
1346
+ const date = formatDateInTimeZone(now, timeZone);
1347
+ const nowIso = now.toISOString();
1348
+ const previousState = state?.dailyIssueReports?.date === date ? state.dailyIssueReports : null;
1349
+ const previousIssues = previousState?.issues && typeof previousState.issues === 'object'
1350
+ ? previousState.issues
1351
+ : {};
1352
+ const nextIssues = { ...previousIssues };
1353
+ const reportableIssues = [];
1354
+ let suppressedCount = 0;
1355
+ let hasDrasticEventGrowth = false;
1356
+ for (const issue of issues) {
1357
+ const key = buildDailyIssueKey(issue);
1358
+ const previousEntry = previousIssues[key] || null;
1359
+ const events = issueEventCount(issue);
1360
+ const shouldReport = !previousEntry || isDrasticDailyIssueEventGrowth(events, previousEntry, dedupeConfig);
1361
+ const reportReason = !previousEntry ? 'new_daily_issue' : 'event_growth';
1362
+ const nextEntry = {
1363
+ ...(previousEntry || {}),
1364
+ title: String(issue?.title || previousEntry?.title || '').slice(0, 240),
1365
+ app: issueProjectLabel(issue),
1366
+ sourceUrl: issueSourceUrl(issue) || previousEntry?.sourceUrl || null,
1367
+ lastSeenAt: nowIso,
1368
+ lastSeenEvents: events ?? previousEntry?.lastSeenEvents ?? null,
1369
+ };
1370
+ if (shouldReport) {
1371
+ reportableIssues.push(issue);
1372
+ if (previousEntry)
1373
+ hasDrasticEventGrowth = true;
1374
+ nextEntry.lastReportedAt = nowIso;
1375
+ nextEntry.lastReportedEvents = events ?? previousEntry?.lastReportedEvents ?? null;
1376
+ nextEntry.lastReportReason = reportReason;
1377
+ nextEntry.reportCount = Number(previousEntry?.reportCount || 0) + 1;
1378
+ }
1379
+ else {
1380
+ suppressedCount += 1;
1381
+ nextEntry.suppressedCount = Number(previousEntry?.suppressedCount || 0) + 1;
1382
+ }
1383
+ nextIssues[key] = nextEntry;
1384
+ }
1385
+ return {
1386
+ issuesPayload: {
1387
+ ...issuesPayload,
1388
+ issue_count: reportableIssues.length,
1389
+ issues: reportableIssues,
1390
+ suppressed_issue_count: suppressedCount,
1391
+ daily_issue_dedupe: {
1392
+ date,
1393
+ timeZone,
1394
+ suppressedCount,
1395
+ reportedCount: reportableIssues.length,
1396
+ eventGrowthMultiplier: dedupeConfig.eventGrowthMultiplier,
1397
+ eventGrowthMinDelta: dedupeConfig.eventGrowthMinDelta,
1398
+ },
1399
+ },
1400
+ dailyIssueReports: {
1401
+ date,
1402
+ timeZone,
1403
+ issues: nextIssues,
1404
+ updatedAt: nowIso,
1405
+ },
1406
+ suppressedCount,
1407
+ reportedCount: reportableIssues.length,
1408
+ hasDrasticEventGrowth,
1409
+ };
1410
+ }
1045
1411
  async function deliverConnectorHealthAlert({ config, configPath, message, statusPayload, unhealthyConnectors, fingerprint }) {
1046
1412
  const channels = getConnectorHealthChannels(config);
1047
1413
  if (config?.notifications?.connectorHealth?.enabled === false) {
@@ -1142,6 +1508,10 @@ function buildGrowthRunSummaryMessage({ issuesPayload, activeCadences, sourceFil
1142
1508
  ? 'Action: GitHub artifact attempted.'
1143
1509
  : 'Action: external alert only.');
1144
1510
  }
1511
+ const suppressedIssueCount = Number(issuesPayload?.suppressed_issue_count || 0);
1512
+ if (suppressedIssueCount > 0) {
1513
+ lines.push(`Suppressed today: ${suppressedIssueCount} previously reported finding(s).`);
1514
+ }
1145
1515
  if (charts.length > 0) {
1146
1516
  lines.push(`Charts: ${charts.length}`);
1147
1517
  }
@@ -1686,7 +2056,7 @@ async function resolveSourcePayloadWithCursor(sourceConfig, sourceName, cursorSt
1686
2056
  },
1687
2057
  };
1688
2058
  }
1689
- throw new Error(`Source "${sourceName}" command failed: ${detail}`);
2059
+ throw new Error(buildSourceCommandFailureMessage(sourceName, resolvedCommand, detail));
1690
2060
  }
1691
2061
  const fetchedAt = new Date().toISOString();
1692
2062
  try {
@@ -1893,8 +2263,52 @@ async function runOnce(configPath, statePath) {
1893
2263
  chartManifestPath,
1894
2264
  cadencePlanPath,
1895
2265
  });
1896
- const issueFingerprint = buildIssueFingerprint(dryRun.issuesPayload);
1897
- const unchangedIssueSet = issueFingerprint === stateAfterSourceCollection.lastIssueFingerprint;
2266
+ const dailyIssueDedupe = applyDailyIssueDedupe(dryRun.issuesPayload, stateAfterSourceCollection, config, activeCadences);
2267
+ const deliverableIssuesPayload = dailyIssueDedupe.issuesPayload;
2268
+ const issueFingerprint = buildIssueFingerprint(deliverableIssuesPayload);
2269
+ const unchangedIssueSet = issueFingerprint === stateAfterSourceCollection.lastIssueFingerprint &&
2270
+ !dailyIssueDedupe.hasDrasticEventGrowth;
2271
+ if (Number(dryRun.issuesPayload?.issue_count || 0) > 0 &&
2272
+ Number(deliverableIssuesPayload?.issue_count || 0) === 0 &&
2273
+ dailyIssueDedupe.suppressedCount > 0) {
2274
+ process.stdout.write(`[${new Date().toISOString()}] All findings were already reported today. Skip GitHub creation and external growth notification.\n`);
2275
+ const completedAt = new Date().toISOString();
2276
+ await fs.mkdir(path.dirname(statePath), { recursive: true });
2277
+ await fs.writeFile(statePath, JSON.stringify({
2278
+ ...stateAfterHealthCheck,
2279
+ ...stateAfterSourceCollection,
2280
+ sourceHashes: currentHashes,
2281
+ sourceCursors,
2282
+ lastSourceFailures: sourceFailures,
2283
+ dailyIssueReports: dailyIssueDedupe.dailyIssueReports,
2284
+ lastIssueFingerprint: issueFingerprint,
2285
+ lastRunAt: completedAt,
2286
+ lastOutFile: dryRun.outFile,
2287
+ cadences: markCadencesRan(stateAfterSourceCollection, activeCadences, completedAt),
2288
+ lastGrowthRunNotifications: [
2289
+ {
2290
+ sent: false,
2291
+ target: 'growth_run',
2292
+ detail: `all ${dailyIssueDedupe.suppressedCount} finding(s) already reported today; external growth notification suppressed`,
2293
+ },
2294
+ ],
2295
+ skippedReason: 'daily_issue_dedupe',
2296
+ }, null, 2), 'utf8');
2297
+ await appendSchedulerProof('runner_completed', {
2298
+ configPath,
2299
+ statePath,
2300
+ completedAt,
2301
+ skippedReason: 'daily_issue_dedupe',
2302
+ activeCadences: activeCadences.map((cadence) => cadence.key),
2303
+ outFile: dryRun.outFile,
2304
+ issueCount: Number(dryRun.issuesPayload?.issue_count || 0),
2305
+ suppressedIssueCount: dailyIssueDedupe.suppressedCount,
2306
+ sourceFailures,
2307
+ externalGrowthNotification: 'suppressed_daily_issue_dedupe',
2308
+ socialOutput: 'HEARTBEAT_OK',
2309
+ });
2310
+ return;
2311
+ }
1898
2312
  if (unchangedIssueSet &&
1899
2313
  config.schedule?.skipIfIssueSetUnchanged !== false) {
1900
2314
  process.stdout.write(`[${new Date().toISOString()}] Issue set unchanged. Skip GitHub creation and external growth notification.\n`);
@@ -1906,6 +2320,7 @@ async function runOnce(configPath, statePath) {
1906
2320
  sourceHashes: currentHashes,
1907
2321
  sourceCursors,
1908
2322
  lastSourceFailures: sourceFailures,
2323
+ dailyIssueReports: dailyIssueDedupe.dailyIssueReports,
1909
2324
  lastIssueFingerprint: issueFingerprint,
1910
2325
  lastRunAt: completedAt,
1911
2326
  lastOutFile: dryRun.outFile,
@@ -1935,7 +2350,7 @@ async function runOnce(configPath, statePath) {
1935
2350
  }
1936
2351
  const issueSetChangedOrExplicitlyAllowed = !unchangedIssueSet || config.schedule?.skipIfIssueSetUnchanged === false;
1937
2352
  const shouldCreateGitHubArtifact = createGitHubArtifact &&
1938
- Number(dryRun.issuesPayload?.issue_count || 0) > 0 &&
2353
+ Number(deliverableIssuesPayload?.issue_count || 0) > 0 &&
1939
2354
  issueSetChangedOrExplicitlyAllowed;
1940
2355
  if (shouldCreateGitHubArtifact) {
1941
2356
  for (const githubArtifactMode of githubArtifactModes) {
@@ -1962,6 +2377,7 @@ async function runOnce(configPath, statePath) {
1962
2377
  sourceHashes: currentHashes,
1963
2378
  sourceCursors,
1964
2379
  lastSourceFailures: sourceFailures,
2380
+ dailyIssueReports: dailyIssueDedupe.dailyIssueReports,
1965
2381
  lastIssueFingerprint: issueFingerprint,
1966
2382
  lastRunAt: completedAt,
1967
2383
  lastOutFile: dryRun.outFile,
@@ -1969,7 +2385,7 @@ async function runOnce(configPath, statePath) {
1969
2385
  lastGrowthRunNotifications: await deliverGrowthRunSummary({
1970
2386
  config,
1971
2387
  configPath,
1972
- issuesPayload: dryRun.issuesPayload,
2388
+ issuesPayload: deliverableIssuesPayload,
1973
2389
  activeCadences,
1974
2390
  sourceFiles,
1975
2391
  fingerprint: issueFingerprint,
@@ -2010,22 +2426,42 @@ async function main() {
2010
2426
  await runOnce(configPath, statePath);
2011
2427
  }
2012
2428
  catch (error) {
2013
- await appendSchedulerProof('runner_failed', {
2429
+ const failureDecision = await recordRunnerFailure({
2014
2430
  configPath,
2015
2431
  statePath,
2016
- error: error instanceof Error ? error.message : String(error),
2017
- }).catch(() => { });
2018
- process.stderr.write(`[${new Date().toISOString()}] Run failed: ${error instanceof Error ? error.message : String(error)}\n`);
2432
+ error,
2433
+ argv: process.argv.slice(2),
2434
+ }).catch(async () => {
2435
+ await appendSchedulerProof('runner_failed', {
2436
+ configPath,
2437
+ statePath,
2438
+ error: error instanceof Error ? error.message : String(error),
2439
+ }).catch(() => { });
2440
+ return null;
2441
+ });
2442
+ process.stderr.write(`[${new Date().toISOString()}] Run failed${failureDecision?.suppressed ? ' (already reported today)' : ''}: ${error instanceof Error ? error.message : String(error)}\n`);
2019
2443
  }
2020
2444
  await sleep(intervalMinutes * 60_000);
2021
2445
  }
2022
2446
  }
2023
2447
  main().catch(async (error) => {
2024
- await appendSchedulerProof('runner_failed', {
2025
- error: error instanceof Error ? error.message : String(error),
2448
+ const fallbackArgs = parseFailureArgs(process.argv.slice(2));
2449
+ const configPath = path.resolve(fallbackArgs.config);
2450
+ const statePath = path.resolve(fallbackArgs.state);
2451
+ useSchedulerProofPathForStatePath(statePath);
2452
+ const failureDecision = await recordRunnerFailure({
2453
+ configPath,
2454
+ statePath,
2455
+ error,
2026
2456
  argv: process.argv.slice(2),
2027
- }).catch(() => { });
2457
+ }).catch(async () => {
2458
+ await appendSchedulerProof('runner_failed', {
2459
+ error: error instanceof Error ? error.message : String(error),
2460
+ argv: process.argv.slice(2),
2461
+ }).catch(() => { });
2462
+ return null;
2463
+ });
2028
2464
  process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
2029
- process.exitCode = 1;
2465
+ process.exitCode = failureDecision?.exitCode ?? 1;
2030
2466
  });
2031
2467
  //# sourceMappingURL=openclaw-growth-runner.mjs.map