idlewatch 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. package/.env.example +73 -0
  2. package/.github/workflows/ci.yml +99 -0
  3. package/.github/workflows/release-macos-trusted.yml +103 -0
  4. package/README.md +336 -0
  5. package/bin/idlewatch-agent.js +1053 -0
  6. package/docs/onboarding-external.md +58 -0
  7. package/docs/packaging/macos-dmg.md +199 -0
  8. package/docs/packaging/macos-launch-agent.md +70 -0
  9. package/docs/qa/archive/mac-qa-log-2026-02-17.md +5838 -0
  10. package/docs/qa/mac-qa-log.md +2864 -0
  11. package/docs/telemetry/idle-stale-policy.md +57 -0
  12. package/docs/telemetry/openclaw-mapping.md +80 -0
  13. package/package.json +76 -0
  14. package/scripts/build-dmg.sh +65 -0
  15. package/scripts/install-macos-launch-agent.sh +78 -0
  16. package/scripts/lib/telemetry-row-parser.mjs +100 -0
  17. package/scripts/package-macos.sh +228 -0
  18. package/scripts/uninstall-macos-launch-agent.sh +30 -0
  19. package/scripts/validate-all.sh +142 -0
  20. package/scripts/validate-bin.mjs +25 -0
  21. package/scripts/validate-dmg-checksum.sh +37 -0
  22. package/scripts/validate-dmg-install.sh +155 -0
  23. package/scripts/validate-dry-run-schema.mjs +257 -0
  24. package/scripts/validate-onboarding.mjs +63 -0
  25. package/scripts/validate-openclaw-cache-recovery-e2e.mjs +113 -0
  26. package/scripts/validate-openclaw-release-gates.mjs +51 -0
  27. package/scripts/validate-openclaw-stats-ingestion.mjs +372 -0
  28. package/scripts/validate-openclaw-usage-health.mjs +95 -0
  29. package/scripts/validate-packaged-artifact.mjs +233 -0
  30. package/scripts/validate-packaged-bundled-runtime.sh +191 -0
  31. package/scripts/validate-packaged-metadata.sh +43 -0
  32. package/scripts/validate-packaged-openclaw-cache-recovery-e2e.mjs +153 -0
  33. package/scripts/validate-packaged-openclaw-release-gates.mjs +72 -0
  34. package/scripts/validate-packaged-openclaw-stats-ingestion.mjs +402 -0
  35. package/scripts/validate-packaged-sourcemaps.mjs +82 -0
  36. package/scripts/validate-packaged-usage-alert-rate-e2e.mjs +98 -0
  37. package/scripts/validate-packaged-usage-probe-noise-e2e.mjs +87 -0
  38. package/scripts/validate-packaged-usage-recovery-e2e.mjs +90 -0
  39. package/scripts/validate-trusted-prereqs.sh +44 -0
  40. package/scripts/validate-usage-alert-rate-e2e.mjs +91 -0
  41. package/scripts/validate-usage-freshness-e2e.mjs +81 -0
  42. package/skill/SKILL.md +43 -0
  43. package/src/config.js +100 -0
  44. package/src/enrollment.js +176 -0
  45. package/src/gpu.js +115 -0
  46. package/src/memory.js +67 -0
  47. package/src/openclaw-cache.js +51 -0
  48. package/src/openclaw-usage.js +1020 -0
  49. package/src/telemetry-mapping.js +54 -0
  50. package/src/usage-alert.js +41 -0
  51. package/src/usage-freshness.js +31 -0
  52. package/test/config.test.mjs +112 -0
  53. package/test/fixtures/gpu-agx.txt +2 -0
  54. package/test/fixtures/gpu-iogpu.txt +2 -0
  55. package/test/fixtures/gpu-top-grep.txt +2 -0
  56. package/test/fixtures/openclaw-fleet-sample-v1.json +68 -0
  57. package/test/fixtures/openclaw-mixed-equal-score-status-vs-generic-iso-ts.txt +2 -0
  58. package/test/fixtures/openclaw-mixed-equal-score-status-vs-generic-newest.txt +2 -0
  59. package/test/fixtures/openclaw-mixed-equal-score-status-vs-generic-string-ts.txt +2 -0
  60. package/test/fixtures/openclaw-mixed-status-then-generic-output.txt +2 -0
  61. package/test/fixtures/openclaw-stats-current-wrapper.json +12 -0
  62. package/test/fixtures/openclaw-stats-current-wrapper2.json +15 -0
  63. package/test/fixtures/openclaw-stats-data-wrapper.json +21 -0
  64. package/test/fixtures/openclaw-stats-nested-session-wrapper.json +23 -0
  65. package/test/fixtures/openclaw-stats-payload-wrapper.json +1 -0
  66. package/test/fixtures/openclaw-stats-status-current-wrapper.json +19 -0
  67. package/test/fixtures/openclaw-stats.json +17 -0
  68. package/test/fixtures/openclaw-status-ansi-complex-noise.txt +3 -0
  69. package/test/fixtures/openclaw-status-ansi-noise.txt +2 -0
  70. package/test/fixtures/openclaw-status-control-noise.txt +1 -0
  71. package/test/fixtures/openclaw-status-data-wrapper.json +20 -0
  72. package/test/fixtures/openclaw-status-dcs-noise.txt +1 -0
  73. package/test/fixtures/openclaw-status-epoch-seconds.json +15 -0
  74. package/test/fixtures/openclaw-status-mixed-noise.txt +1 -0
  75. package/test/fixtures/openclaw-status-multi-json.txt +3 -0
  76. package/test/fixtures/openclaw-status-nested-recent.json +19 -0
  77. package/test/fixtures/openclaw-status-noisy-default-then-usage.txt +2 -0
  78. package/test/fixtures/openclaw-status-noisy.txt +3 -0
  79. package/test/fixtures/openclaw-status-osc-noise.txt +1 -0
  80. package/test/fixtures/openclaw-status-result-session.json +15 -0
  81. package/test/fixtures/openclaw-status-session-map-with-defaults.json +23 -0
  82. package/test/fixtures/openclaw-status-session-map.json +28 -0
  83. package/test/fixtures/openclaw-status-session-model-name.json +18 -0
  84. package/test/fixtures/openclaw-status-snake-session-wrapper.json +13 -0
  85. package/test/fixtures/openclaw-status-stats-current-sessions-snake-tokens.json +25 -0
  86. package/test/fixtures/openclaw-status-stats-current-sessions.json +28 -0
  87. package/test/fixtures/openclaw-status-stats-current-usage-time-camelcase.json +19 -0
  88. package/test/fixtures/openclaw-status-stats-session-default-model.json +27 -0
  89. package/test/fixtures/openclaw-status-status-wrapper.json +13 -0
  90. package/test/fixtures/openclaw-status-strings.json +38 -0
  91. package/test/fixtures/openclaw-status-ts-ms-alias.json +14 -0
  92. package/test/fixtures/openclaw-status-updated-at-ms-alias.json +14 -0
  93. package/test/fixtures/openclaw-status-usage-timestamp-ms-alias.json +14 -0
  94. package/test/fixtures/openclaw-status-usage-ts-alias.json +14 -0
  95. package/test/fixtures/openclaw-status-wrap-session-object.json +24 -0
  96. package/test/fixtures/openclaw-status.json +41 -0
  97. package/test/fixtures/openclaw-usage-model-name-generic.json +9 -0
  98. package/test/gpu.test.mjs +58 -0
  99. package/test/memory.test.mjs +35 -0
  100. package/test/openclaw-cache.test.mjs +48 -0
  101. package/test/openclaw-env.test.mjs +365 -0
  102. package/test/openclaw-usage.test.mjs +555 -0
  103. package/test/telemetry-mapping.test.mjs +69 -0
  104. package/test/telemetry-row-parser.test.mjs +44 -0
  105. package/test/usage-alert.test.mjs +73 -0
  106. package/test/usage-freshness.test.mjs +63 -0
  107. package/test/validate-dry-run-schema.test.mjs +146 -0
  108. package/tui/Cargo.lock +801 -0
  109. package/tui/Cargo.toml +11 -0
  110. package/tui/src/main.rs +368 -0
@@ -0,0 +1,191 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
5
+ DIST_LAUNCHER="$ROOT_DIR/dist/IdleWatch.app/Contents/MacOS/IdleWatch"
6
+ TMP_APPS="$(mktemp -d -t idlewatch-packaged-runtime.XXXXXX)"
7
+
8
+ cleanup_tmp() {
9
+ rm -rf "$TMP_APPS"
10
+ }
11
+ trap cleanup_tmp EXIT
12
+
13
+ NODE_BIN="$(command -v node || true)"
14
+ if [[ -z "$NODE_BIN" || ! -x "$NODE_BIN" ]]; then
15
+ echo "Node.js is required to run bundled-runtime validation." >&2
16
+ exit 1
17
+ fi
18
+
19
+ read_metadata_field() {
20
+ local field=$1
21
+ "$NODE_BIN" - "$METADATA_PATH" "$field" <<'NODE' | tr -d '\r'
22
+ const fs = require('fs')
23
+ const metadataPath = process.argv[2]
24
+ const field = process.argv[3]
25
+ const metadata = JSON.parse(fs.readFileSync(metadataPath, 'utf8'))
26
+ const value = metadata[field]
27
+ if (typeof value === 'undefined' || value === null) process.exit(0)
28
+ if (typeof value === 'boolean') process.stdout.write(value ? '1' : '0')
29
+ else process.stdout.write(String(value))
30
+ NODE
31
+ }
32
+
33
+ RUNTIME_DIR="$($NODE_BIN -e 'const path = require("path"); console.log(path.resolve(process.argv[1], "..", ".."))' "$NODE_BIN")"
34
+ METADATA_PATH="$ROOT_DIR/dist/IdleWatch.app/Contents/Resources/packaging-metadata.json"
35
+ BUNDLED_RUNTIME_REQUIRED_RAW="${IDLEWATCH_BUNDLED_RUNTIME_REQUIRED+x}"
36
+ BUNDLED_RUNTIME_REQUIRED="${IDLEWATCH_BUNDLED_RUNTIME_REQUIRED:-0}"
37
+ RESTRICTED_PATH="/usr/bin:/bin"
38
+ ORIGINAL_PATH="${PATH:-/usr/bin:/bin}"
39
+
40
+ if [[ "${IDLEWATCH_SKIP_PACKAGE_MACOS:-0}" != "1" ]]; then
41
+ if [[ ! -x "$RUNTIME_DIR/bin/node" ]]; then
42
+ echo "Resolved runtime dir is invalid (missing executable bin/node): $RUNTIME_DIR" >&2
43
+ exit 1
44
+ fi
45
+ echo "Packaging IdleWatch.app with bundled runtime: $RUNTIME_DIR"
46
+ IDLEWATCH_NODE_RUNTIME_DIR="$RUNTIME_DIR" npm run package:macos --silent
47
+ else
48
+ if [[ ! -f "$METADATA_PATH" ]]; then
49
+ echo "IDLEWATCH_SKIP_PACKAGE_MACOS=1 but metadata is missing: $METADATA_PATH" >&2
50
+ echo "Run with IDLEWATCH_SKIP_PACKAGE_MACOS unset or prebuild via npm run package:macos first." >&2
51
+ exit 1
52
+ fi
53
+
54
+ if [[ ! -f "$DIST_LAUNCHER" ]]; then
55
+ echo "IDLEWATCH_SKIP_PACKAGE_MACOS=1 but packaged launcher is missing: $DIST_LAUNCHER" >&2
56
+ echo "Run with IDLEWATCH_SKIP_PACKAGE_MACOS unset or prebuild via npm run package:macos first." >&2
57
+ exit 1
58
+ fi
59
+
60
+ if [[ "$BUNDLED_RUNTIME_REQUIRED" == "1" ]]; then
61
+ if [[ "$(read_metadata_field nodeRuntimeBundled)" != "1" ]]; then
62
+ echo "Reused packaged artifact is not bundled-runtime aware. Rebuild first:" >&2
63
+ echo " npm run package:macos" >&2
64
+ echo "(required for node-free PATH validation in bundled-runtime check)" >&2
65
+ echo "To force launchability-only non-bundled validation, set IDLEWATCH_BUNDLED_RUNTIME_REQUIRED=0." >&2
66
+ exit 1
67
+ fi
68
+ elif [[ "$BUNDLED_RUNTIME_REQUIRED_RAW" != "1" ]]; then
69
+ if [[ "$(read_metadata_field nodeRuntimeBundled)" != "1" ]]; then
70
+ echo "Non-bundled artifact detected in reuse mode; running launchability fallback (recommended for non-deterministic reuse checks)." >&2
71
+ echo "This keeps non-bundled artifacts supportable while still validating launcher startup semantics." >&2
72
+ echo "Set IDLEWATCH_BUNDLED_RUNTIME_REQUIRED=1 to enforce strict node-free validation." >&2
73
+ fi
74
+ fi
75
+
76
+ if ! IDLEWATCH_ARTIFACT_DIR="$ROOT_DIR/dist/IdleWatch.app" node "$ROOT_DIR/scripts/validate-packaged-artifact.mjs"; then
77
+ echo "Reusable packaged artifact preflight failed for validate-packaged-bundled-runtime." >&2
78
+ exit 1
79
+ fi
80
+
81
+ echo "Using existing packaged app at: $DIST_LAUNCHER"
82
+ fi
83
+
84
+ if [[ ! -x "$DIST_LAUNCHER" ]]; then
85
+ echo "Packaged launcher missing: $DIST_LAUNCHER" >&2
86
+ exit 1
87
+ fi
88
+
89
+ npm run validate:packaged-metadata --silent
90
+
91
+ NODE_RUNTIME_BUNDLED="$(read_metadata_field nodeRuntimeBundled)"
92
+
93
+ if [[ "$NODE_RUNTIME_BUNDLED" == "1" ]]; then
94
+ VALIDATION_PATH="$RESTRICTED_PATH"
95
+ echo "Running strict path-scrubbed launchability validation using PATH=$RESTRICTED_PATH"
96
+ elif [[ "${IDLEWATCH_USE_ORIGINAL_PATH_FOR_NON_BUNDLED:-1}" == "1" ]]; then
97
+ if node_path="$(PATH="$RESTRICTED_PATH" /usr/bin/env which node 2>/dev/null || true)" &&
98
+ [[ -n "$node_path" ]] &&
99
+ [[ "$node_path" == /* ]] &&
100
+ [[ -x "$node_path" ]]; then
101
+ VALIDATION_PATH="$RESTRICTED_PATH"
102
+ echo "Node is available in restricted PATH; still validating with PATH=$RESTRICTED_PATH to keep behavior deterministic."
103
+ else
104
+ VALIDATION_PATH="$ORIGINAL_PATH"
105
+ echo "Non-bundled artifact detected; node is not available in PATH=$RESTRICTED_PATH." >&2
106
+ echo "Falling back to current PATH for launchability verification in non-bundled mode." >&2
107
+ echo "Set IDLEWATCH_BUNDLED_RUNTIME_REQUIRED=1 and provide IDLEWATCH_NODE_RUNTIME_DIR for strict node-free validation." >&2
108
+ fi
109
+ else
110
+ VALIDATION_PATH="$RESTRICTED_PATH"
111
+ echo "Non-bundled artifact detected but strict path mode requested; this run requires bundled runtime validation." >&2
112
+ exit 1
113
+ fi
114
+
115
+ IDLEWATCH_DRY_RUN_TIMEOUT_MS="${IDLEWATCH_DRY_RUN_TIMEOUT_MS:-90000}"
116
+ IDLEWATCH_OPENCLAW_PROBE_TIMEOUT_MS="${IDLEWATCH_OPENCLAW_PROBE_TIMEOUT_MS:-4000}"
117
+ DRY_RUN_TIMEOUT_RETRY_BONUS_MS="${IDLEWATCH_DRY_RUN_TIMEOUT_RETRY_BONUS_MS:-10000}"
118
+ DRY_RUN_TIMEOUT_MAX_ATTEMPTS="${IDLEWATCH_DRY_RUN_TIMEOUT_MAX_ATTEMPTS:-3}"
119
+ DRY_RUN_TIMEOUT_BACKOFF_MS="${IDLEWATCH_DRY_RUN_TIMEOUT_BACKOFF_MS:-2000}"
120
+
121
+ run_packaged_dry_run() {
122
+ local openclaw_usage=${1:-auto}
123
+ local timeout_ms=${2}
124
+ local attempt=${3}
125
+ local validation_path=${4}
126
+ local attempt_log="$TMP_APPS/packaged-dry-run-${openclaw_usage}-attempt-${attempt}.log"
127
+
128
+ if ! env \
129
+ IDLEWATCH_DRY_RUN_TIMEOUT_MS="$timeout_ms" \
130
+ IDLEWATCH_OPENCLAW_PROBE_TIMEOUT_MS="$IDLEWATCH_OPENCLAW_PROBE_TIMEOUT_MS" \
131
+ IDLEWATCH_OPENCLAW_USAGE="$openclaw_usage" \
132
+ HOME="$HOME" \
133
+ PATH="$validation_path" \
134
+ "$NODE_BIN" "$ROOT_DIR/scripts/validate-dry-run-schema.mjs" \
135
+ "$DIST_LAUNCHER" --dry-run --once >"$attempt_log" 2>&1; then
136
+ echo "Attempt ${attempt} failed for IDLEWATCH_OPENCLAW_USAGE=${openclaw_usage} with PATH=$validation_path. Last output:" >&2
137
+ tail -n 60 "$attempt_log" >&2 || true
138
+ return 1
139
+ fi
140
+
141
+ cat "$attempt_log" >&2
142
+ return 0
143
+ }
144
+
145
+ run_packaged_dry_run_with_retries() {
146
+ local openclaw_usage=$1
147
+ local attempt=1
148
+ local timeout_ms="$IDLEWATCH_DRY_RUN_TIMEOUT_MS"
149
+
150
+ while (( attempt <= DRY_RUN_TIMEOUT_MAX_ATTEMPTS )); do
151
+ local sleep_ms=0
152
+ echo "Attempt ${attempt}/${DRY_RUN_TIMEOUT_MAX_ATTEMPTS}: validating packaged launcher dry-run with IDLEWATCH_OPENCLAW_USAGE=${openclaw_usage} timeout=${timeout_ms}ms" >&2
153
+ if run_packaged_dry_run "$openclaw_usage" "$timeout_ms" "$attempt" "$VALIDATION_PATH"; then
154
+ return 0
155
+ fi
156
+
157
+ if (( attempt < DRY_RUN_TIMEOUT_MAX_ATTEMPTS )); then
158
+ timeout_ms=$((timeout_ms + DRY_RUN_TIMEOUT_RETRY_BONUS_MS))
159
+ sleep_ms=$((DRY_RUN_TIMEOUT_BACKOFF_MS < 0 ? 0 : DRY_RUN_TIMEOUT_BACKOFF_MS))
160
+ if (( sleep_ms > 0 )); then
161
+ sleep $(awk -v t="$sleep_ms" 'BEGIN { printf "%.3f", t / 1000 }')
162
+ fi
163
+ fi
164
+ attempt=$((attempt + 1))
165
+ done
166
+
167
+ return 1
168
+ }
169
+
170
+ set +e
171
+ run_packaged_dry_run_with_retries auto
172
+ rc=$?
173
+ set -e
174
+
175
+ if [[ $rc -ne 0 ]]; then
176
+ if run_packaged_dry_run_with_retries off; then
177
+ echo "bundled runtime validation ok (launcher path-only check ${VALIDATION_PATH:+under PATH=$VALIDATION_PATH})" >&2
178
+ echo "OpenClaw-enabled dry-run did not emit telemetry within timeout/backoff window; launchability path remains healthy." >&2
179
+ exit 0
180
+ fi
181
+
182
+ echo "bundled runtime validation failed for both OpenClaw-enabled and OpenClaw-disabled dry-runs" >&2
183
+ exit 1
184
+ fi
185
+
186
+ echo "bundled runtime validation ok"
187
+ if [[ "$NODE_RUNTIME_BUNDLED" == "1" ]]; then
188
+ echo "validated launcher dry-run under restricted PATH in ${IDLEWATCH_DRY_RUN_TIMEOUT_MS}ms baseline (+${DRY_RUN_TIMEOUT_RETRY_BONUS_MS}ms retry increments, up to ${DRY_RUN_TIMEOUT_MAX_ATTEMPTS} attempts)"
189
+ else
190
+ echo "validated launcher dry-run for non-bundled artifact under PATH=${VALIDATION_PATH} in ${IDLEWATCH_DRY_RUN_TIMEOUT_MS}ms baseline (+${DRY_RUN_TIMEOUT_RETRY_BONUS_MS}ms retry increments, up to ${DRY_RUN_TIMEOUT_MAX_ATTEMPTS} attempts)"
191
+ fi
@@ -0,0 +1,43 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
5
+ METADATA_PATH="$ROOT_DIR/dist/IdleWatch.app/Contents/Resources/packaging-metadata.json"
6
+ PAYLOAD_BIN="$ROOT_DIR/dist/IdleWatch.app/Contents/Resources/payload/package/bin/idlewatch-agent.js"
7
+
8
+ if [[ ! -f "$METADATA_PATH" ]]; then
9
+ echo "Missing packaging metadata: $METADATA_PATH" >&2
10
+ exit 1
11
+ fi
12
+
13
+ if [[ ! -f "$PAYLOAD_BIN" ]]; then
14
+ echo "Missing packaged payload entrypoint: $PAYLOAD_BIN" >&2
15
+ exit 1
16
+ fi
17
+
18
+ node - "$METADATA_PATH" <<'NODE'
19
+ const fs = require('fs')
20
+ const metadataPath = process.argv[2]
21
+ const data = JSON.parse(fs.readFileSync(metadataPath, 'utf8'))
22
+ if (!data.version) throw new Error('packaging metadata missing version')
23
+ if (!data.platform) throw new Error('packaging metadata missing platform')
24
+ if (data.platform !== 'darwin') throw new Error('packaging metadata platform is not darwin')
25
+ if (!data.bundleName) throw new Error('packaging metadata missing bundleName')
26
+ if (!data.payloadTarball) throw new Error('packaging metadata missing payloadTarball')
27
+ if (typeof data.nodeRuntimeBundled !== 'boolean') throw new Error('packaging metadata missing nodeRuntimeBundled')
28
+ if (typeof data.sourceGitCommit !== 'undefined' && typeof data.sourceGitCommit !== 'string') {
29
+ throw new Error('packaging metadata sourceGitCommit must be a string when present')
30
+ }
31
+ if (typeof data.sourceGitDirty !== 'undefined' && typeof data.sourceGitDirty !== 'boolean') {
32
+ throw new Error('packaging metadata sourceGitDirty must be a boolean when present')
33
+ }
34
+ if (typeof data.sourceGitDirtyKnown !== 'undefined' && typeof data.sourceGitDirtyKnown !== 'boolean') {
35
+ throw new Error('packaging metadata sourceGitDirtyKnown must be a boolean when present')
36
+ }
37
+ console.log(`packaging metadata ok for ${data.bundleName} ${data.version}`)
38
+ if (data.sourceGitCommit) {
39
+ console.log(`source commit: ${data.sourceGitCommit}${data.sourceGitDirty ? ' (dirty)' : ''}`)
40
+ }
41
+ NODE
42
+
43
+ echo "packaging metadata validation ok"
@@ -0,0 +1,153 @@
1
+ #!/usr/bin/env node
2
+ import assert from 'node:assert/strict'
3
+ import { mkdtempSync, writeFileSync, chmodSync, rmSync, readFileSync } from 'node:fs'
4
+ import { readTelemetryJsonRow } from './lib/telemetry-row-parser.mjs'
5
+ import { tmpdir } from 'node:os'
6
+ import { join } from 'node:path'
7
+ import { execFileSync, spawnSync } from 'node:child_process'
8
+
9
+ const repoRoot = process.cwd()
10
+ const rootDir = process.cwd()
11
+ const tempDir = mkdtempSync(join(tmpdir(), 'idlewatch-openclaw-cache-recover-pkg-'))
12
+ const mockBinPath = join(tempDir, 'openclaw-mock.sh')
13
+ const callLog = join(tempDir, 'calls.txt')
14
+ const cachePath = join(tempDir, 'openclaw-last-good.json')
15
+
16
+ function writeMockOpenClaw(pathToScript) {
17
+ const script = `#!/usr/bin/env bash
18
+ set -euo pipefail
19
+
20
+ calls_file="${callLog}"
21
+ if [[ ! -f "$calls_file" ]]; then
22
+ echo 0 > "$calls_file"
23
+ fi
24
+
25
+ n=$(cat "$calls_file")
26
+ next=$((n + 1))
27
+ echo "$next" > "$calls_file"
28
+
29
+ if [[ "$n" -lt 7 ]]; then
30
+ echo "temporary probe failure \$n" >&2
31
+ exit 1
32
+ fi
33
+
34
+ now_ms=$(($(date +%s) * 1000))
35
+ updated_at=$((now_ms - 1500))
36
+ cat <<JSON
37
+ {
38
+ "sessions": {
39
+ "recent": [
40
+ {
41
+ "sessionId": "cached-recover-packaged",
42
+ "agentId": "agent-cache-pkg",
43
+ "model": "gpt-5.3-codex",
44
+ "totalTokens": 9090,
45
+ "updatedAt": $updated_at,
46
+ "updated_at": $updated_at
47
+ }
48
+ ]
49
+ },
50
+ "ts": $now_ms
51
+ }
52
+ JSON
53
+ `
54
+ writeFileSync(pathToScript, script, 'utf8')
55
+ chmodSync(pathToScript, 0o755)
56
+ }
57
+
58
+ function readRow(output) {
59
+ return readTelemetryJsonRow(output)
60
+ }
61
+
62
+ function collectRow(env) {
63
+ const launcher = join(rootDir, 'dist', 'IdleWatch.app', 'Contents', 'MacOS', 'IdleWatch')
64
+ const out = spawnSync(launcher, ['--dry-run'], {
65
+ cwd: repoRoot,
66
+ encoding: 'utf8',
67
+ stdio: ['ignore', 'pipe', 'pipe'],
68
+ timeout: 25000,
69
+ env,
70
+ maxBuffer: 16 * 1024 * 1024,
71
+ killSignal: 'SIGINT'
72
+ })
73
+
74
+ const output = String(out.stdout || '') + String(out.stderr || '')
75
+ assert.equal(out.status, 0, `packaged dry-run exited with ${out.status}`)
76
+ assert.ok(output.trim(), 'packaged dry-run produced no output')
77
+ return readRow(output)
78
+ }
79
+
80
+ function run() {
81
+ const nowMs = Date.now()
82
+ const staleAgeMs = 90000
83
+
84
+ if (process.env.IDLEWATCH_SKIP_PACKAGE_MACOS !== '1') {
85
+ execFileSync('npm', ['run', 'package:macos', '--silent'], {
86
+ cwd: repoRoot,
87
+ encoding: 'utf8',
88
+ stdio: ['ignore', 'pipe', 'pipe']
89
+ })
90
+ }
91
+
92
+ writeMockOpenClaw(mockBinPath)
93
+
94
+ const staleSnapshot = {
95
+ at: nowMs,
96
+ usage: {
97
+ model: 'gpt-5.3-codex',
98
+ totalTokens: 8080,
99
+ tokensPerMin: 18.2,
100
+ sessionId: 'cached-recover-packaged',
101
+ agentId: 'agent-cache-pkg',
102
+ usageTimestampMs: nowMs - staleAgeMs,
103
+ sourceCommand: 'cached-recovery-stale'
104
+ }
105
+ }
106
+ writeFileSync(cachePath, JSON.stringify(staleSnapshot), 'utf8')
107
+ writeFileSync(callLog, '0', 'utf8')
108
+
109
+ const env = {
110
+ ...process.env,
111
+ HOME: process.env.HOME || '',
112
+ IDLEWATCH_OPENCLAW_BIN: mockBinPath,
113
+ IDLEWATCH_OPENCLAW_BIN_STRICT: '1',
114
+ IDLEWATCH_OPENCLAW_PROBE_RETRIES: '0',
115
+ IDLEWATCH_OPENCLAW_LAST_GOOD_CACHE_PATH: cachePath,
116
+ IDLEWATCH_OPENCLAW_LAST_GOOD_MAX_AGE_MS: '120000',
117
+ IDLEWATCH_USAGE_STALE_MS: '30000',
118
+ IDLEWATCH_USAGE_NEAR_STALE_MS: '15000',
119
+ IDLEWATCH_USAGE_STALE_GRACE_MS: '3000',
120
+ IDLEWATCH_USAGE_REFRESH_REPROBES: '4',
121
+ IDLEWATCH_USAGE_REFRESH_DELAY_MS: '20',
122
+ IDLEWATCH_USAGE_REFRESH_ON_NEAR_STALE: '1',
123
+ IDLEWATCH_INTERVAL_MS: '1000',
124
+ IDLEWATCH_OPENCLAW_USAGE: 'auto',
125
+ IDLEWATCH_REQUIRE_OPENCLAW_USAGE: '1'
126
+ }
127
+
128
+ const row = collectRow(env)
129
+
130
+ assert.equal(row?.source?.usage, 'openclaw', 'usage source should remain openclaw during packaged cache recovery')
131
+ assert.equal(row?.source?.usageProbeAttempts >= 1, true, 'probe attempts should run for refresh path')
132
+ assert.equal(row?.source?.usageRefreshAttempted, true, 'packaged stale recovery should attempt refresh')
133
+ assert.equal(row?.source?.usageRefreshAttempts >= 1, true, 'at least one refresh reprobe attempt should run after stale cache')
134
+ assert.equal(row?.source?.usageRefreshRecovered, true, 'stale packaged cache should recover after successful reprobe')
135
+ assert.equal(row?.source?.usageProbeResult, 'ok', 'final usage probe result should be ok after recovery')
136
+ assert.equal(row?.source?.usageFreshnessState, 'fresh', 'usage should be fresh after successful recovery')
137
+ assert.equal(row?.source?.usageAlertLevel, 'ok', 'usage alert should settle to ok after recovery')
138
+ assert.equal(row?.openclawSessionId, 'cached-recover-packaged')
139
+ assert.equal(row?.openclawAgentId, 'agent-cache-pkg')
140
+ assert.equal(row?.openclawTotalTokens, 9090)
141
+ assert.equal(row?.source?.usageCommand?.includes('--json') || row?.source?.usageCommand?.includes('status'), true, 'command path should be OpenClaw command text')
142
+
143
+ const calls = Number(readFileSync(callLog, 'utf8').trim())
144
+ assert.ok(Number.isFinite(calls) && calls >= 3, `expected at least 3 probe calls, got ${calls}`)
145
+
146
+ console.log('validate-packaged-openclaw-cache-recovery-e2e: ok (packaged stale cache recovers through reprobe attempts)')
147
+ }
148
+
149
+ try {
150
+ run()
151
+ } finally {
152
+ rmSync(tempDir, { recursive: true, force: true })
153
+ }
@@ -0,0 +1,72 @@
1
+ #!/usr/bin/env node
2
+ import { existsSync, accessSync, constants } from 'node:fs'
3
+ import { join } from 'node:path'
4
+ import { spawnSync } from 'node:child_process'
5
+
6
+ const repoRoot = process.cwd()
7
+ const appPath = join(repoRoot, 'dist', 'IdleWatch.app', 'Contents', 'MacOS', 'IdleWatch')
8
+
9
+ function ensureReleaseArtifact() {
10
+ if (!existsSync(appPath)) {
11
+ console.error(`Missing packaged app launcher at ${appPath}`)
12
+ console.error('Run npm run package:macos --silent before running trusted release gates.')
13
+ process.exit(1)
14
+ }
15
+
16
+ try {
17
+ accessSync(appPath, constants.X_OK)
18
+ } catch {
19
+ console.error(`Packaged launcher is not executable: ${appPath}`)
20
+ process.exit(1)
21
+ }
22
+ }
23
+
24
+ function shouldRequireOpenClaw(rawValue) {
25
+ const raw = String(rawValue ?? '1').trim().toLowerCase()
26
+ if (raw === '0' || raw === 'false' || raw === 'off' || raw === 'no') return false
27
+ if (raw === '1' || raw === 'true' || raw === 'on' || raw === 'yes') return true
28
+ return true
29
+ }
30
+
31
+ const RELEASE_GATE_TIMEOUT_MS = process.env.IDLEWATCH_DRY_RUN_TIMEOUT_MS || '60000'
32
+
33
+ function runValidator(name, extraEnv = {}) {
34
+ const requireOpenClawUsage = shouldRequireOpenClaw(process.env.IDLEWATCH_REQUIRE_OPENCLAW_USAGE)
35
+ const requireOpenClaw = requireOpenClawUsage ? '1' : '0'
36
+
37
+ const result = spawnSync('npm', ['run', name, '--silent'], {
38
+ cwd: repoRoot,
39
+ stdio: ['ignore', 'pipe', 'pipe'],
40
+ encoding: 'utf8',
41
+ env: {
42
+ ...process.env,
43
+ IDLEWATCH_SKIP_PACKAGE_MACOS: '1',
44
+ IDLEWATCH_REQUIRE_OPENCLAW_USAGE: requireOpenClaw,
45
+ IDLEWATCH_DRY_RUN_TIMEOUT_MS: RELEASE_GATE_TIMEOUT_MS,
46
+ ...extraEnv
47
+ }
48
+ })
49
+
50
+ const out = String(result.stdout || '') + String(result.stderr || '')
51
+ if (result.status !== 0) {
52
+ if (out.trim()) {
53
+ console.error(out.trim())
54
+ }
55
+ console.error(`Validator ${name} failed with exit code ${result.status}`)
56
+ process.exit(result.status)
57
+ }
58
+
59
+ process.stdout.write(out)
60
+ }
61
+
62
+ function main() {
63
+ ensureReleaseArtifact()
64
+
65
+ runValidator('validate:packaged-usage-health')
66
+ runValidator('validate:packaged-openclaw-stats-ingestion')
67
+ runValidator('validate:packaged-openclaw-cache-recovery-e2e')
68
+
69
+ console.log('validate-packaged-openclaw-release-gates: ok (release artifact validates OpenClaw health, stats fallback, and cache recovery)')
70
+ }
71
+
72
+ main()