@analyticscli/growth-engineer 0.1.0-preview.13 → 0.1.0-preview.15

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.
@@ -5,14 +5,15 @@ import process from 'node:process';
5
5
  import { createHash } from 'node:crypto';
6
6
  import { spawn } from 'node:child_process';
7
7
  import { fileURLToPath } from 'node:url';
8
- import { getActionMode, getAllSourceEntries, getGitHubArtifactModes, getGitHubRequirementText, shouldAutoCreateGitHubArtifact, } from './openclaw-growth-shared.mjs';
8
+ import { deriveRuntimeDirFromStatePath, deriveSchedulerProofPathFromStatePath, getActionMode, getAllSourceEntries, getGitHubArtifactModes, getGitHubRequirementText, shouldAutoCreateGitHubArtifact, } from './openclaw-growth-shared.mjs';
9
9
  import { applyOpenClawSecretRefs, loadOpenClawGrowthSecrets } from './openclaw-growth-env.mjs';
10
10
  const DEFAULT_CONFIG_PATH = 'data/openclaw-growth-engineer/config.json';
11
11
  const DEFAULT_STATE_PATH = 'data/openclaw-growth-engineer/state.json';
12
- const DEFAULT_RUNTIME_DIR = 'data/openclaw-growth-engineer/runtime';
12
+ const DEFAULT_SCHEDULER_PROOF_PATH = 'data/openclaw-growth-engineer/runtime/scheduler-proof.jsonl';
13
13
  const DEFAULT_CONNECTOR_HEALTH_INTERVAL_MINUTES = 360;
14
14
  const SELF_UPDATE_INTERVAL_MS = 24 * 60 * 60 * 1000;
15
15
  const RUNTIME_DIR = path.dirname(fileURLToPath(import.meta.url));
16
+ let schedulerProofPath = path.resolve(DEFAULT_SCHEDULER_PROOF_PATH);
16
17
  const DEFAULT_CADENCES = [
17
18
  {
18
19
  key: 'daily',
@@ -154,9 +155,14 @@ function commandShouldReceiveActiveConfig(command) {
154
155
  }
155
156
  function withActiveConfigArg(command, configPath) {
156
157
  const trimmed = String(command || '').trim();
157
- if (!trimmed || !configPath || commandHasConfigArg(trimmed) || !commandShouldReceiveActiveConfig(trimmed)) {
158
+ if (!trimmed || !configPath || !commandShouldReceiveActiveConfig(trimmed)) {
158
159
  return trimmed;
159
160
  }
161
+ if (commandHasConfigArg(trimmed)) {
162
+ return trimmed
163
+ .replace(/(^|\s)--config=(?:"[^"]*"|'[^']*'|\S+)/, `$1--config ${quote(configPath)}`)
164
+ .replace(/(^|\s)--config\s+(?:"[^"]*"|'[^']*'|\S+)/, `$1--config ${quote(configPath)}`);
165
+ }
160
166
  return `${trimmed} --config ${quote(configPath)}`;
161
167
  }
162
168
  async function readJson(filePath) {
@@ -174,6 +180,22 @@ async function readJsonOptional(filePath, fallback) {
174
180
  async function ensureDir(dirPath) {
175
181
  await fs.mkdir(dirPath, { recursive: true });
176
182
  }
183
+ async function appendSchedulerProof(event, details = {}) {
184
+ const proofPath = schedulerProofPath;
185
+ const entry = {
186
+ ts: new Date().toISOString(),
187
+ event,
188
+ pid: process.pid,
189
+ cwd: process.cwd(),
190
+ ...details,
191
+ };
192
+ await fs.mkdir(path.dirname(proofPath), { recursive: true });
193
+ await fs.appendFile(proofPath, `${JSON.stringify(entry)}\n`, 'utf8');
194
+ }
195
+ function useSchedulerProofPathForStatePath(statePath) {
196
+ schedulerProofPath = path.resolve(deriveSchedulerProofPathFromStatePath(statePath));
197
+ return schedulerProofPath;
198
+ }
177
199
  function sha256(input) {
178
200
  return createHash('sha256').update(input).digest('hex');
179
201
  }
@@ -542,7 +564,7 @@ function buildConnectorHealthAlert(statusPayload, unhealthyConnectors) {
542
564
  lines.push(` Next: ${entry.nextAction}`);
543
565
  }
544
566
  if (entry.key === 'appStoreConnect' && entry.status === 'partial') {
545
- 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.');
567
+ lines.push(' Note: ASC uses API-key batch reports by default. Experimental ASC web analytics should only be requested when a needed metric is unavailable through API reports.');
546
568
  }
547
569
  }
548
570
  lines.push('');
@@ -563,20 +585,43 @@ async function writeConnectorHealthAlert(runtimeDir, message, statusPayload, unh
563
585
  }, null, 2), 'utf8');
564
586
  return { markdownPath, jsonPath };
565
587
  }
566
- function getConnectorHealthChannels(config) {
567
- const configuredChannels = Array.isArray(config?.notifications?.connectorHealth?.channels)
568
- ? config.notifications.connectorHealth.channels.filter((channel) => channel?.enabled !== false)
569
- : [];
570
- if (configuredChannels.length > 0)
571
- return configuredChannels;
588
+ function notificationChannelKey(channel) {
589
+ const type = String(channel?.type || 'openclaw-chat');
590
+ if (type === 'openclaw-chat')
591
+ return 'openclaw-chat';
592
+ if (type === 'slack')
593
+ return `slack:${channel?.label || channel?.webhookEnv || 'slack'}`;
594
+ if (type === 'webhook')
595
+ return `webhook:${channel?.label || channel?.urlEnv || channel?.webhookEnv || 'webhook'}`;
596
+ if (type === 'command')
597
+ return `command:${channel?.label || channel?.command || 'command'}`;
598
+ return `${type}:${channel?.label || type}`;
599
+ }
600
+ function mergeNotificationChannelsWithDeliveries(configuredChannels, deliveryChannels) {
601
+ const configured = Array.isArray(configuredChannels) ? configuredChannels : [];
602
+ const seen = new Set(configured.map((channel) => notificationChannelKey(channel)));
603
+ const channels = configured.filter((channel) => channel?.enabled !== false);
604
+ for (const channel of deliveryChannels) {
605
+ if (!seen.has(notificationChannelKey(channel))) {
606
+ channels.push(channel);
607
+ }
608
+ }
609
+ return channels;
610
+ }
611
+ function getDeliveryNotificationChannels(config, kind) {
572
612
  const channels = [];
573
613
  const deliveries = config?.deliveries || {};
574
614
  if (deliveries.openclawChat?.enabled) {
615
+ const isConnectorHealth = kind === 'connectorHealth';
575
616
  channels.push({
576
617
  type: 'openclaw-chat',
577
618
  label: 'openclaw_chat',
578
- markdownPath: deliveries.openclawChat.connectorHealthMarkdownPath || deliveries.openclawChat.markdownPath,
579
- jsonPath: deliveries.openclawChat.connectorHealthJsonPath || deliveries.openclawChat.jsonPath,
619
+ markdownPath: isConnectorHealth
620
+ ? deliveries.openclawChat.connectorHealthMarkdownPath || deliveries.openclawChat.markdownPath
621
+ : deliveries.openclawChat.growthRunMarkdownPath || '.openclaw/chat/growth-summary.md',
622
+ jsonPath: isConnectorHealth
623
+ ? deliveries.openclawChat.connectorHealthJsonPath || deliveries.openclawChat.jsonPath
624
+ : deliveries.openclawChat.growthRunJsonPath || '.openclaw/chat/growth-summary.json',
580
625
  });
581
626
  }
582
627
  if (deliveries.slack?.enabled) {
@@ -595,19 +640,37 @@ function getConnectorHealthChannels(config) {
595
640
  headers: deliveries.webhook.headers || {},
596
641
  });
597
642
  }
643
+ if (deliveries.command?.enabled) {
644
+ channels.push({
645
+ type: 'command',
646
+ label: deliveries.command.label || 'command',
647
+ command: deliveries.command.command || '',
648
+ });
649
+ }
598
650
  if (deliveries.discord?.enabled) {
599
651
  channels.push({
600
652
  type: 'command',
601
- label: 'discord',
602
- command: deliveries.discord.command || 'node scripts/discord-openclaw-bridge.mjs send --stdin',
653
+ label: deliveries.discord.label || 'discord',
654
+ command: deliveries.discord.command || '',
603
655
  });
604
656
  }
605
657
  return channels;
606
658
  }
659
+ function getConnectorHealthChannels(config) {
660
+ const configuredChannels = Array.isArray(config?.notifications?.connectorHealth?.channels)
661
+ ? config.notifications.connectorHealth.channels
662
+ : [];
663
+ return mergeNotificationChannelsWithDeliveries(configuredChannels, getDeliveryNotificationChannels(config, 'connectorHealth'));
664
+ }
665
+ function resolveOpenClawChatDeliveryPath(channelPath, fallbackPath) {
666
+ const targetPath = String(channelPath || fallbackPath || '').trim();
667
+ if (!targetPath)
668
+ return path.resolve(process.cwd(), fallbackPath);
669
+ return path.isAbsolute(targetPath) ? targetPath : path.resolve(process.cwd(), targetPath);
670
+ }
607
671
  async function writeConfiguredOpenClawChatAlert(configPath, channel, message, statusPayload, unhealthyConnectors, fingerprint) {
608
- const baseDir = path.dirname(path.resolve(configPath));
609
- const markdownPath = path.resolve(baseDir, channel.markdownPath || '.openclaw/chat/connector-health.md');
610
- const jsonPath = path.resolve(baseDir, channel.jsonPath || '.openclaw/chat/connector-health.json');
672
+ const markdownPath = resolveOpenClawChatDeliveryPath(channel.markdownPath, '.openclaw/chat/connector-health.md');
673
+ const jsonPath = resolveOpenClawChatDeliveryPath(channel.jsonPath, '.openclaw/chat/connector-health.json');
611
674
  await fs.mkdir(path.dirname(markdownPath), { recursive: true });
612
675
  await fs.mkdir(path.dirname(jsonPath), { recursive: true });
613
676
  await fs.writeFile(markdownPath, message, 'utf8');
@@ -620,8 +683,9 @@ async function writeConfiguredOpenClawChatAlert(configPath, channel, message, st
620
683
  }, null, 2), 'utf8');
621
684
  return {
622
685
  sent: true,
686
+ external: false,
623
687
  target: channel.label || 'openclaw_chat',
624
- detail: `wrote ${markdownPath} and ${jsonPath}`,
688
+ detail: `wrote local OpenClaw chat outbox ${markdownPath} and ${jsonPath}`,
625
689
  };
626
690
  }
627
691
  async function sendSlackConnectorHealthAlert(channel, message) {
@@ -637,6 +701,7 @@ async function sendSlackConnectorHealthAlert(channel, message) {
637
701
  });
638
702
  return {
639
703
  sent: response.ok,
704
+ external: true,
640
705
  target: channel.label || 'slack',
641
706
  detail: response.ok ? `HTTP ${response.status}` : `HTTP ${response.status}: ${await response.text()}`,
642
707
  };
@@ -664,6 +729,7 @@ async function sendWebhookConnectorHealthAlert(channel, message, statusPayload,
664
729
  });
665
730
  return {
666
731
  sent: response.ok,
732
+ external: true,
667
733
  target: channel.label || 'webhook',
668
734
  detail: response.ok ? `HTTP ${response.status}` : `HTTP ${response.status}: ${await response.text()}`,
669
735
  };
@@ -675,10 +741,17 @@ async function sendCommandConnectorHealthAlert(channel, message) {
675
741
  const result = await runShellCommand(String(channel.command), 60_000, { input: message });
676
742
  return {
677
743
  sent: result.ok,
744
+ external: true,
678
745
  target: channel.label || 'command',
679
746
  detail: result.ok ? result.stdout.trim() : result.stderr.trim() || result.stdout.trim() || `exit ${result.code}`,
680
747
  };
681
748
  }
749
+ function hasExternalNotificationChannel(channels) {
750
+ return channels.some((channel) => channel?.type && channel.type !== 'openclaw-chat');
751
+ }
752
+ function hasSuccessfulExternalDelivery(results) {
753
+ return results.some((result) => result?.sent === true && result?.external === true);
754
+ }
682
755
  async function deliverConnectorHealthAlert({ config, configPath, message, statusPayload, unhealthyConnectors, fingerprint }) {
683
756
  const channels = getConnectorHealthChannels(config);
684
757
  if (config?.notifications?.connectorHealth?.enabled === false) {
@@ -714,48 +787,23 @@ async function deliverConnectorHealthAlert({ config, configPath, message, status
714
787
  });
715
788
  }
716
789
  }
790
+ if (!hasSuccessfulExternalDelivery(results)) {
791
+ results.push({
792
+ sent: false,
793
+ external: true,
794
+ target: 'external_notification',
795
+ detail: hasExternalNotificationChannel(channels)
796
+ ? 'No external notification channel successfully sent the alert.'
797
+ : 'Alert written locally, but no external notification channel configured.',
798
+ });
799
+ }
717
800
  return results;
718
801
  }
719
802
  function getGrowthRunChannels(config) {
720
803
  const configuredChannels = Array.isArray(config?.notifications?.growthRun?.channels)
721
- ? config.notifications.growthRun.channels.filter((channel) => channel?.enabled !== false)
804
+ ? config.notifications.growthRun.channels
722
805
  : [];
723
- if (configuredChannels.length > 0)
724
- return configuredChannels;
725
- const channels = [];
726
- const deliveries = config?.deliveries || {};
727
- if (deliveries.openclawChat?.enabled) {
728
- channels.push({
729
- type: 'openclaw-chat',
730
- label: 'openclaw_chat',
731
- markdownPath: deliveries.openclawChat.growthRunMarkdownPath || '.openclaw/chat/growth-summary.md',
732
- jsonPath: deliveries.openclawChat.growthRunJsonPath || '.openclaw/chat/growth-summary.json',
733
- });
734
- }
735
- if (deliveries.slack?.enabled) {
736
- channels.push({
737
- type: 'slack',
738
- label: 'slack',
739
- webhookEnv: deliveries.slack.webhookEnv || 'SLACK_WEBHOOK_URL',
740
- });
741
- }
742
- if (deliveries.webhook?.enabled) {
743
- channels.push({
744
- type: 'webhook',
745
- label: 'webhook',
746
- urlEnv: deliveries.webhook.urlEnv || 'OPENCLAW_WEBHOOK_URL',
747
- method: deliveries.webhook.method || 'POST',
748
- headers: deliveries.webhook.headers || {},
749
- });
750
- }
751
- if (deliveries.discord?.enabled) {
752
- channels.push({
753
- type: 'command',
754
- label: 'discord',
755
- command: deliveries.discord.command || 'node scripts/discord-openclaw-bridge.mjs send --stdin',
756
- });
757
- }
758
- return channels;
806
+ return mergeNotificationChannelsWithDeliveries(configuredChannels, getDeliveryNotificationChannels(config, 'growthRun'));
759
807
  }
760
808
  async function readChartAttachments(chartManifestPath) {
761
809
  if (!chartManifestPath)
@@ -813,9 +861,8 @@ function buildGrowthRunSummaryMessage({ issuesPayload, activeCadences, sourceFil
813
861
  return `${lines.join('\n')}\n`;
814
862
  }
815
863
  async function writeConfiguredOpenClawChatGrowthSummary(configPath, channel, message, issuesPayload, activeCadences, fingerprint, charts) {
816
- const baseDir = path.dirname(path.resolve(configPath));
817
- const markdownPath = path.resolve(baseDir, channel.markdownPath || '.openclaw/chat/growth-summary.md');
818
- const jsonPath = path.resolve(baseDir, channel.jsonPath || '.openclaw/chat/growth-summary.json');
864
+ const markdownPath = resolveOpenClawChatDeliveryPath(channel.markdownPath, '.openclaw/chat/growth-summary.md');
865
+ const jsonPath = resolveOpenClawChatDeliveryPath(channel.jsonPath, '.openclaw/chat/growth-summary.json');
819
866
  await fs.mkdir(path.dirname(markdownPath), { recursive: true });
820
867
  await fs.mkdir(path.dirname(jsonPath), { recursive: true });
821
868
  await fs.writeFile(markdownPath, message, 'utf8');
@@ -835,8 +882,9 @@ async function writeConfiguredOpenClawChatGrowthSummary(configPath, channel, mes
835
882
  }, null, 2), 'utf8');
836
883
  return {
837
884
  sent: true,
885
+ external: false,
838
886
  target: channel.label || 'openclaw_chat',
839
- detail: `wrote ${markdownPath} and ${jsonPath}`,
887
+ detail: `wrote local OpenClaw chat outbox ${markdownPath} and ${jsonPath}`,
840
888
  };
841
889
  }
842
890
  async function sendSlackGrowthSummary(channel, message) {
@@ -852,6 +900,7 @@ async function sendSlackGrowthSummary(channel, message) {
852
900
  });
853
901
  return {
854
902
  sent: response.ok,
903
+ external: true,
855
904
  target: channel.label || 'slack',
856
905
  detail: response.ok ? `HTTP ${response.status}` : `HTTP ${response.status}: ${await response.text()}`,
857
906
  };
@@ -886,6 +935,7 @@ async function sendWebhookGrowthSummary(channel, message, issuesPayload, activeC
886
935
  });
887
936
  return {
888
937
  sent: response.ok,
938
+ external: true,
889
939
  target: channel.label || 'webhook',
890
940
  detail: response.ok ? `HTTP ${response.status}` : `HTTP ${response.status}: ${await response.text()}`,
891
941
  };
@@ -897,6 +947,7 @@ async function sendCommandGrowthSummary(channel, message) {
897
947
  const result = await runShellCommand(String(channel.command), 60_000, { input: message });
898
948
  return {
899
949
  sent: result.ok,
950
+ external: true,
900
951
  target: channel.label || 'command',
901
952
  detail: result.ok ? result.stdout.trim() : result.stderr.trim() || result.stdout.trim() || `exit ${result.code}`,
902
953
  };
@@ -950,6 +1001,12 @@ async function maybeRunConnectorHealthCheck({ config, configPath, state, statePa
950
1001
  const healthState = state?.connectorHealth || {};
951
1002
  const intervalMinutes = getConnectorHealthIntervalMinutes(config);
952
1003
  if (!isDue(healthState.lastCheckedAt, intervalMinutes)) {
1004
+ await appendSchedulerProof('connector_health_not_due', {
1005
+ configPath,
1006
+ statePath,
1007
+ intervalMinutes,
1008
+ lastCheckedAt: healthState.lastCheckedAt || null,
1009
+ });
953
1010
  return state;
954
1011
  }
955
1012
  await ensureDir(runtimeDir);
@@ -975,6 +1032,13 @@ async function maybeRunConnectorHealthCheck({ config, configPath, state, statePa
975
1032
  };
976
1033
  await fs.mkdir(path.dirname(statePath), { recursive: true });
977
1034
  await fs.writeFile(statePath, JSON.stringify(nextState, null, 2), 'utf8');
1035
+ await appendSchedulerProof('connector_health_check_failed', {
1036
+ configPath,
1037
+ statePath,
1038
+ intervalMinutes,
1039
+ checkedAt,
1040
+ error: nextState.connectorHealth.lastError,
1041
+ });
978
1042
  return nextState;
979
1043
  }
980
1044
  const unhealthyConnectors = getUnhealthyConfiguredConnectors(statusPayload);
@@ -988,11 +1052,10 @@ async function maybeRunConnectorHealthCheck({ config, configPath, state, statePa
988
1052
  connectedConnectors,
989
1053
  lastError: null,
990
1054
  };
991
- const previousIncidentFingerprint = healthState.lastStatusOk === false
992
- ? healthState.activeIncidentFingerprint || healthState.lastAlertedFingerprint || null
993
- : null;
1055
+ const previousExternallyDeliveredFingerprint = healthState.lastExternalAlertedFingerprint || null;
994
1056
  if (unhealthyConnectors.length === 0) {
995
1057
  nextHealthState.activeIncidentFingerprint = null;
1058
+ nextHealthState.lastExternalAlertedFingerprint = null;
996
1059
  if (healthState.lastStatusOk === false) {
997
1060
  nextHealthState.lastRecoveredAt = checkedAt;
998
1061
  }
@@ -1001,7 +1064,7 @@ async function maybeRunConnectorHealthCheck({ config, configPath, state, statePa
1001
1064
  nextHealthState.activeIncidentFingerprint = fingerprint;
1002
1065
  }
1003
1066
  if (unhealthyConnectors.length > 0 &&
1004
- previousIncidentFingerprint !== fingerprint) {
1067
+ previousExternallyDeliveredFingerprint !== fingerprint) {
1005
1068
  const message = buildConnectorHealthAlert(statusPayload, unhealthyConnectors);
1006
1069
  const paths = await writeConnectorHealthAlert(runtimeDir, message, statusPayload, unhealthyConnectors, fingerprint);
1007
1070
  const deliveries = await deliverConnectorHealthAlert({
@@ -1017,6 +1080,11 @@ async function maybeRunConnectorHealthCheck({ config, configPath, state, statePa
1017
1080
  nextHealthState.lastAlertMarkdownPath = paths.markdownPath;
1018
1081
  nextHealthState.lastAlertJsonPath = paths.jsonPath;
1019
1082
  nextHealthState.lastAlertDeliveries = deliveries;
1083
+ nextHealthState.lastAlertExternalSent = hasSuccessfulExternalDelivery(deliveries);
1084
+ if (nextHealthState.lastAlertExternalSent) {
1085
+ nextHealthState.lastExternalAlertedAt = checkedAt;
1086
+ nextHealthState.lastExternalAlertedFingerprint = fingerprint;
1087
+ }
1020
1088
  }
1021
1089
  const nextState = {
1022
1090
  ...state,
@@ -1024,6 +1092,22 @@ async function maybeRunConnectorHealthCheck({ config, configPath, state, statePa
1024
1092
  };
1025
1093
  await fs.mkdir(path.dirname(statePath), { recursive: true });
1026
1094
  await fs.writeFile(statePath, JSON.stringify(nextState, null, 2), 'utf8');
1095
+ await appendSchedulerProof('connector_health_checked', {
1096
+ configPath,
1097
+ statePath,
1098
+ intervalMinutes,
1099
+ checkedAt,
1100
+ lastStatusOk: nextHealthState.lastStatusOk,
1101
+ connectedConnectors,
1102
+ unhealthyConnectors: unhealthyConnectors.map((entry) => ({
1103
+ key: entry.key,
1104
+ status: entry.status,
1105
+ detail: entry.detail,
1106
+ })),
1107
+ alertMarkdownPath: nextHealthState.lastAlertMarkdownPath || null,
1108
+ deliveryCount: Array.isArray(nextHealthState.lastAlertDeliveries) ? nextHealthState.lastAlertDeliveries.length : 0,
1109
+ externalDeliverySent: nextHealthState.lastAlertExternalSent === true,
1110
+ });
1027
1111
  return nextState;
1028
1112
  }
1029
1113
  function buildIssueFingerprint(issuesPayload) {
@@ -1260,6 +1344,11 @@ function hasSourceChanges(previousHashes, currentHashes) {
1260
1344
  return false;
1261
1345
  }
1262
1346
  async function runOnce(configPath, statePath) {
1347
+ await appendSchedulerProof('runner_invoked', {
1348
+ configPath,
1349
+ statePath,
1350
+ argv: process.argv.slice(2),
1351
+ });
1263
1352
  const config = await readJson(configPath);
1264
1353
  await applyOpenClawSecretRefs(config);
1265
1354
  const inferredGitHubRepo = await inferGitHubRepo(config);
@@ -1276,7 +1365,7 @@ async function runOnce(configPath, statePath) {
1276
1365
  lastRunAt: null,
1277
1366
  sourceCursors: {},
1278
1367
  });
1279
- const runtimeDir = path.resolve(DEFAULT_RUNTIME_DIR);
1368
+ const runtimeDir = path.resolve(deriveRuntimeDirFromStatePath(statePath));
1280
1369
  const stateAfterHealthCheck = await maybeRunConnectorHealthCheck({
1281
1370
  config,
1282
1371
  configPath,
@@ -1290,14 +1379,22 @@ async function runOnce(configPath, statePath) {
1290
1379
  const changed = hasSourceChanges(stateAfterHealthCheck.sourceHashes, currentHashes);
1291
1380
  if (!changed && config.schedule?.skipIfNoDataChange !== false) {
1292
1381
  process.stdout.write(`[${new Date().toISOString()}] No data changes. Skip run.\n`);
1382
+ const completedAt = new Date().toISOString();
1293
1383
  await fs.mkdir(path.dirname(statePath), { recursive: true });
1294
1384
  await fs.writeFile(statePath, JSON.stringify({
1295
1385
  ...stateAfterHealthCheck,
1296
1386
  sourceHashes: currentHashes,
1297
1387
  sourceCursors,
1298
- lastRunAt: new Date().toISOString(),
1388
+ lastRunAt: completedAt,
1299
1389
  skippedReason: 'no_data_change',
1300
1390
  }, null, 2), 'utf8');
1391
+ await appendSchedulerProof('runner_completed', {
1392
+ configPath,
1393
+ statePath,
1394
+ completedAt,
1395
+ skippedReason: 'no_data_change',
1396
+ activeCadences: activeCadences.map((cadence) => cadence.key),
1397
+ });
1301
1398
  return;
1302
1399
  }
1303
1400
  const githubArtifactModes = getGitHubArtifactModes(config).filter((mode) => shouldAutoCreateGitHubArtifact(config, mode));
@@ -1325,17 +1422,26 @@ async function runOnce(configPath, statePath) {
1325
1422
  const unchangedIssueSet = issueFingerprint === stateAfterHealthCheck.lastIssueFingerprint;
1326
1423
  if (unchangedIssueSet && config.schedule?.skipIfIssueSetUnchanged !== false) {
1327
1424
  process.stdout.write(`[${new Date().toISOString()}] Issue set unchanged. Skip GitHub creation.\n`);
1425
+ const completedAt = new Date().toISOString();
1328
1426
  await fs.mkdir(path.dirname(statePath), { recursive: true });
1329
1427
  await fs.writeFile(statePath, JSON.stringify({
1330
1428
  ...stateAfterHealthCheck,
1331
1429
  sourceHashes: currentHashes,
1332
1430
  sourceCursors,
1333
1431
  lastIssueFingerprint: issueFingerprint,
1334
- lastRunAt: new Date().toISOString(),
1432
+ lastRunAt: completedAt,
1335
1433
  lastOutFile: dryRun.outFile,
1336
- cadences: markCadencesRan(stateAfterHealthCheck, activeCadences, new Date().toISOString()),
1434
+ cadences: markCadencesRan(stateAfterHealthCheck, activeCadences, completedAt),
1337
1435
  skippedReason: 'issue_set_unchanged',
1338
1436
  }, null, 2), 'utf8');
1437
+ await appendSchedulerProof('runner_completed', {
1438
+ configPath,
1439
+ statePath,
1440
+ completedAt,
1441
+ skippedReason: 'issue_set_unchanged',
1442
+ activeCadences: activeCadences.map((cadence) => cadence.key),
1443
+ outFile: dryRun.outFile,
1444
+ });
1339
1445
  return;
1340
1446
  }
1341
1447
  const shouldCreateGitHubArtifact = createGitHubArtifact && Number(dryRun.issuesPayload?.issue_count || 0) > 0;
@@ -1356,15 +1462,16 @@ async function runOnce(configPath, statePath) {
1356
1462
  else {
1357
1463
  process.stdout.write(`[${new Date().toISOString()}] Drafts generated only (${getActionMode(config)} auto-create disabled).\n`);
1358
1464
  }
1465
+ const completedAt = new Date().toISOString();
1359
1466
  await fs.mkdir(path.dirname(statePath), { recursive: true });
1360
1467
  await fs.writeFile(statePath, JSON.stringify({
1361
1468
  ...stateAfterHealthCheck,
1362
1469
  sourceHashes: currentHashes,
1363
1470
  sourceCursors,
1364
1471
  lastIssueFingerprint: issueFingerprint,
1365
- lastRunAt: new Date().toISOString(),
1472
+ lastRunAt: completedAt,
1366
1473
  lastOutFile: dryRun.outFile,
1367
- cadences: markCadencesRan(stateAfterHealthCheck, activeCadences, new Date().toISOString()),
1474
+ cadences: markCadencesRan(stateAfterHealthCheck, activeCadences, completedAt),
1368
1475
  lastGrowthRunNotifications: await deliverGrowthRunSummary({
1369
1476
  config,
1370
1477
  configPath,
@@ -1377,6 +1484,16 @@ async function runOnce(configPath, statePath) {
1377
1484
  }),
1378
1485
  skippedReason: null,
1379
1486
  }, null, 2), 'utf8');
1487
+ await appendSchedulerProof('runner_completed', {
1488
+ configPath,
1489
+ statePath,
1490
+ completedAt,
1491
+ skippedReason: null,
1492
+ activeCadences: activeCadences.map((cadence) => cadence.key),
1493
+ outFile: dryRun.outFile,
1494
+ issueCount: Number(dryRun.issuesPayload?.issue_count || 0),
1495
+ createdGitHubArtifact: shouldCreateGitHubArtifact,
1496
+ });
1380
1497
  }
1381
1498
  async function main() {
1382
1499
  await loadOpenClawGrowthSecrets();
@@ -1384,6 +1501,7 @@ async function main() {
1384
1501
  await maybeSelfUpdateFromClawHub(args);
1385
1502
  const configPath = path.resolve(args.config);
1386
1503
  const statePath = path.resolve(args.state);
1504
+ useSchedulerProofPathForStatePath(statePath);
1387
1505
  if (!args.loop) {
1388
1506
  await runOnce(configPath, statePath);
1389
1507
  return;
@@ -1397,12 +1515,21 @@ async function main() {
1397
1515
  await runOnce(configPath, statePath);
1398
1516
  }
1399
1517
  catch (error) {
1518
+ await appendSchedulerProof('runner_failed', {
1519
+ configPath,
1520
+ statePath,
1521
+ error: error instanceof Error ? error.message : String(error),
1522
+ }).catch(() => { });
1400
1523
  process.stderr.write(`[${new Date().toISOString()}] Run failed: ${error instanceof Error ? error.message : String(error)}\n`);
1401
1524
  }
1402
1525
  await sleep(intervalMinutes * 60_000);
1403
1526
  }
1404
1527
  }
1405
- main().catch((error) => {
1528
+ main().catch(async (error) => {
1529
+ await appendSchedulerProof('runner_failed', {
1530
+ error: error instanceof Error ? error.message : String(error),
1531
+ argv: process.argv.slice(2),
1532
+ }).catch(() => { });
1406
1533
  process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
1407
1534
  process.exitCode = 1;
1408
1535
  });