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,402 @@
1
+ #!/usr/bin/env node
2
+ import assert from 'node:assert/strict'
3
+ import { mkdtempSync, writeFileSync, chmodSync, rmSync } 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-stats-pkg-egress-'))
12
+ const mockBinPath = join(tempDir, 'openclaw-mock.sh')
13
+
14
+ function writeMockOpenClaw(pathToScript, shape) {
15
+ const resultCurrent = `{
16
+ "status": {
17
+ "result": {
18
+ "stats": {
19
+ "current": {
20
+ "session": {
21
+ "sessionId": "packaged-stats-session",
22
+ "agentId": "agent-stats-packaged",
23
+ "model": "gpt-5.3-codex",
24
+ "totalTokens": 4242,
25
+ "tokensPerMinute": 12.5,
26
+ "updatedAt": 1771311100000
27
+ }
28
+ }
29
+ }
30
+ }
31
+ },
32
+ "ts": 1771311110000
33
+ }`
34
+
35
+ const statusCurrent = `{
36
+ "status": {
37
+ "current": {
38
+ "stats": {
39
+ "current": {
40
+ "session": {
41
+ "sessionId": "packaged-status-current-session",
42
+ "agentId": "agent-stats-packaged-current",
43
+ "model": "gpt-5.3-codex-pro",
44
+ "totalTokens": 4096,
45
+ "tokensPerMinute": 15,
46
+ "updatedAt": 1771311900000
47
+ }
48
+ }
49
+ }
50
+ }
51
+ },
52
+ "ts": 1771311910000
53
+ }`
54
+
55
+ const statusCurrentTsMs = `{
56
+ "status": {
57
+ "current": {
58
+ "stats": {
59
+ "current": {
60
+ "session": {
61
+ "sessionId": "packaged-status-current-tsms-session",
62
+ "agentId": "agent-stats-packaged-tsms",
63
+ "model": "gpt-5.3-codex-pro",
64
+ "totalTokens": 3210,
65
+ "tokensPerMinute": 18,
66
+ "usage_ts_ms": 1771312500000,
67
+ "ts_ms": 1771312501000
68
+ }
69
+ }
70
+ }
71
+ },
72
+ "ts": 1771312510000
73
+ }`
74
+
75
+ const statusCurrentUsageTs = `{
76
+ "status": {
77
+ "current": {
78
+ "stats": {
79
+ "current": {
80
+ "session": {
81
+ "sessionId": "packaged-status-current-usage-ts",
82
+ "agentId": "agent-stats-packaged-usage-ts",
83
+ "model": "gpt-5.3-codex-pro",
84
+ "totalTokens": 2900,
85
+ "tokensPerMinute": 19,
86
+ "usage_ts": 1771312600000,
87
+ "ts_ms": 1771312601000
88
+ }
89
+ }
90
+ }
91
+ },
92
+ "ts": 1771312610000
93
+ }`
94
+
95
+ const statusCurrentUsageTimestampMs = `{
96
+ "status": {
97
+ "current": {
98
+ "stats": {
99
+ "current": {
100
+ "session": {
101
+ "sessionId": "packaged-status-current-usage-timestamp-ms",
102
+ "agentId": "agent-stats-packaged-usage-timestamp-ms",
103
+ "model": "qwen-lite-packaged",
104
+ "totalTokens": 2121,
105
+ "tokensPerMinute": 22,
106
+ "usage_timestamp_ms": 1771317000000,
107
+ "ts_ms": 1771317001000
108
+ }
109
+ }
110
+ }
111
+ },
112
+ "ts": 1771317010000
113
+ }`
114
+
115
+ const statusCurrentUpdatedAtMs = `{
116
+ "status": {
117
+ "current": {
118
+ "stats": {
119
+ "current": {
120
+ "session": {
121
+ "sessionId": "packaged-status-current-updated-at-ms",
122
+ "agentId": "agent-stats-packaged-updated-at-ms",
123
+ "model": "claude-mini-3.8",
124
+ "totalTokens": 777,
125
+ "tokensPerMinute": 21,
126
+ "updated_at_ms": 1771315000000,
127
+ "ts_ms": 1771315001000
128
+ }
129
+ }
130
+ }
131
+ },
132
+ "ts": 1771315010000
133
+ }`
134
+
135
+ const statusCurrentUsageTimestamp = `{
136
+ "status": {
137
+ "current": {
138
+ "stats": {
139
+ "current": {
140
+ "session": {
141
+ "sessionId": "packaged-status-current-usage-timestamp",
142
+ "agentId": "agent-stats-packaged-usage-timestamp",
143
+ "model": "qwen-lite-packaged",
144
+ "totalTokens": 1919,
145
+ "tokensPerMinute": 12,
146
+ "usage_timestamp": "2026-02-27T09:10:00.000Z",
147
+ "ts_ms": 1771316000000
148
+ }
149
+ }
150
+ }
151
+ },
152
+ "ts": 1771316010000
153
+ }`
154
+
155
+ const statusCurrentUsageTime = `{
156
+ "status": {
157
+ "current": {
158
+ "stats": {
159
+ "current": {
160
+ "session": {
161
+ "sessionId": "packaged-status-current-usage-time",
162
+ "agentId": "agent-stats-packaged-usage-time",
163
+ "model": "claude-mini-3.8-packaged",
164
+ "totalTokens": 1555,
165
+ "tokensPerMinute": 14,
166
+ "usage_time": "2026-02-27T09:20:00.000Z",
167
+ "ts_ms": 1771317000000
168
+ }
169
+ }
170
+ }
171
+ }
172
+ },
173
+ "ts": 1771317010000
174
+ }`
175
+
176
+ const statusCurrentUsageTimeCamel = `{
177
+ "status": {
178
+ "current": {
179
+ "stats": {
180
+ "current": {
181
+ "session": {
182
+ "sessionId": "packaged-status-current-usage-time-camel",
183
+ "agentId": "agent-stats-packaged-usage-time-camel",
184
+ "model": "qwen-camel-packaged",
185
+ "totalTokens": 2333,
186
+ "tokensPerMinute": 17,
187
+ "usageTime": "2026-02-27T09:50:00.000Z",
188
+ "ts_ms": 1771319000000
189
+ }
190
+ }
191
+ }
192
+ }
193
+ },
194
+ "ts": 1771319010000
195
+ }`
196
+
197
+ const script = `#!/usr/bin/env bash
198
+ set -euo pipefail
199
+
200
+ SCENARIO="${shape}"
201
+ if [[ "$1" == "stats" && "$2" == "--json" ]]; then
202
+ if [[ "$SCENARIO" == "statusCurrent" ]]; then
203
+ cat <<JSON
204
+ ${statusCurrent}
205
+ JSON
206
+ elif [[ "$SCENARIO" == "statusCurrentTsMs" ]]; then
207
+ cat <<JSON
208
+ ${statusCurrentTsMs}
209
+ JSON
210
+ elif [[ "$SCENARIO" == "statusCurrentUsageTs" ]]; then
211
+ cat <<JSON
212
+ ${statusCurrentUsageTs}
213
+ JSON
214
+ elif [[ "$SCENARIO" == "statusCurrentUsageTimestampMs" ]]; then
215
+ cat <<JSON
216
+ ${statusCurrentUsageTimestampMs}
217
+ JSON
218
+ elif [[ "$SCENARIO" == "statusCurrentUpdatedAtMs" ]]; then
219
+ cat <<JSON
220
+ ${statusCurrentUpdatedAtMs}
221
+ JSON
222
+ elif [[ "$SCENARIO" == "statusCurrentUsageTimestamp" ]]; then
223
+ cat <<JSON
224
+ ${statusCurrentUsageTimestamp}
225
+ JSON
226
+ elif [[ "$SCENARIO" == "statusCurrentUsageTime" ]]; then
227
+ cat <<JSON
228
+ ${statusCurrentUsageTime}
229
+ JSON
230
+ elif [[ "$SCENARIO" == "statusCurrentUsageTimeCamel" ]]; then
231
+ cat <<JSON
232
+ ${statusCurrentUsageTimeCamel}
233
+ JSON
234
+ else
235
+ cat <<JSON
236
+ ${resultCurrent}
237
+ JSON
238
+ fi
239
+ exit 0
240
+ fi
241
+
242
+ if [[ "$1" == "status" || "$1" == "usage" || "$1" == "session" || "$1" == "session_status" ]]; then
243
+ echo '{"message":"legacy status output unavailable"}\n'
244
+ exit 0
245
+ fi
246
+
247
+ echo '{"error":"unsupported command"}'
248
+ exit 2
249
+ `
250
+ writeFileSync(pathToScript, script, 'utf8')
251
+ chmodSync(pathToScript, 0o755)
252
+ }
253
+
254
+ function readRow(output) {
255
+ return readTelemetryJsonRow(output)
256
+ }
257
+
258
+ function collectRow(env) {
259
+ const launcher = join(rootDir, 'dist', 'IdleWatch.app', 'Contents', 'MacOS', 'IdleWatch')
260
+ const out = spawnSync(launcher, ['--dry-run'], {
261
+ cwd: repoRoot,
262
+ encoding: 'utf8',
263
+ stdio: ['ignore', 'pipe', 'pipe'],
264
+ timeout: 20000,
265
+ env,
266
+ maxBuffer: 8 * 1024 * 1024,
267
+ killSignal: 'SIGINT'
268
+ })
269
+
270
+ const output = String(out.stdout || '') + String(out.stderr || '')
271
+ assert.ok(!out.error || out.error.code === 'ETIMEDOUT', `packaged dry-run command failed unexpectedly: ${out.error?.message || `exit ${out.status}`}`)
272
+ assert.equal(out.status, 0, `packaged dry-run exited with ${out.status}`)
273
+ assert.ok(output.trim(), 'packaged dry-run produced no output')
274
+ return readRow(output)
275
+ }
276
+
277
+ function run(shape, expectations) {
278
+ writeMockOpenClaw(mockBinPath, shape)
279
+
280
+ const env = {
281
+ ...process.env,
282
+ HOME: process.env.HOME || '',
283
+ IDLEWATCH_OPENCLAW_BIN: mockBinPath,
284
+ IDLEWATCH_OPENCLAW_BIN_STRICT: '1',
285
+ IDLEWATCH_OPENCLAW_USAGE: 'auto',
286
+ IDLEWATCH_OPENCLAW_PROBE_RETRIES: '0',
287
+ IDLEWATCH_OPENCLAW_PROBE_TIMEOUT_MS: '2000',
288
+ IDLEWATCH_USAGE_STALE_MS: '5000',
289
+ IDLEWATCH_USAGE_NEAR_STALE_MS: '3000',
290
+ IDLEWATCH_USAGE_STALE_GRACE_MS: '1000',
291
+ IDLEWATCH_USAGE_REFRESH_REPROBES: '0',
292
+ IDLEWATCH_USAGE_REFRESH_DELAY_MS: '0',
293
+ IDLEWATCH_USAGE_REFRESH_ON_NEAR_STALE: '0',
294
+ IDLEWATCH_REQUIRE_OPENCLAW_USAGE: '1',
295
+ IDLEWATCH_OPENCLAW_LAST_GOOD_MAX_AGE_MS: '5000',
296
+ IDLEWATCH_OPENCLAW_LAST_GOOD_CACHE_PATH: join(tempDir, 'openclaw-last-good.json')
297
+ }
298
+
299
+ const row = collectRow(env)
300
+
301
+ assert.equal(row?.source?.usage, 'openclaw', 'packaged OpenClaw path should emit usage=openclaw')
302
+ assert.equal(row?.source?.usageIngestionStatus, 'ok', 'packaged OpenClaw path should report ingestion ok')
303
+ assert.equal(row?.source?.usageProbeResult, 'ok', 'stats --json fallback path should be accepted as successful probe')
304
+ assert.equal(row?.openclawSessionId, expectations.sessionId)
305
+ assert.equal(row?.openclawAgentId, expectations.agentId)
306
+ assert.equal(row?.openclawTotalTokens, expectations.totalTokens)
307
+ assert.equal(row?.tokensPerMin, expectations.tokensPerMin)
308
+ assert.equal(row?.openclawModel, expectations.model)
309
+ assert.equal(row?.source?.usageCommand?.includes('stats --json'), true, 'OpenClaw command used in packaged run should include stats --json when stats fallback is needed')
310
+ assert.equal(row?.source?.usageProbeAttempts >= 3, true, 'stats fallback should require attempts through multiple commands before success')
311
+ assert.equal(row?.usageProbeError || null, null, 'successful packaged stats fallback should not leak probe error')
312
+ }
313
+
314
+ function runAllShapes() {
315
+ if (process.env.IDLEWATCH_SKIP_PACKAGE_MACOS !== '1') {
316
+ execFileSync('npm', ['run', 'package:macos', '--silent'], {
317
+ cwd: repoRoot,
318
+ encoding: 'utf8',
319
+ stdio: ['ignore', 'pipe', 'pipe']
320
+ })
321
+ }
322
+
323
+ run('resultCurrent', {
324
+ sessionId: 'packaged-stats-session',
325
+ agentId: 'agent-stats-packaged',
326
+ totalTokens: 4242,
327
+ model: 'gpt-5.3-codex',
328
+ tokensPerMin: 12.5
329
+ })
330
+
331
+ run('statusCurrent', {
332
+ sessionId: 'packaged-status-current-session',
333
+ agentId: 'agent-stats-packaged-current',
334
+ totalTokens: 4096,
335
+ model: 'gpt-5.3-codex-pro',
336
+ tokensPerMin: 15
337
+ })
338
+
339
+ run('statusCurrentTsMs', {
340
+ sessionId: 'packaged-status-current-tsms-session',
341
+ agentId: 'agent-stats-packaged-tsms',
342
+ totalTokens: 3210,
343
+ model: 'gpt-5.3-codex-pro',
344
+ tokensPerMin: 18
345
+ })
346
+
347
+ run('statusCurrentUsageTs', {
348
+ sessionId: 'packaged-status-current-usage-ts',
349
+ agentId: 'agent-stats-packaged-usage-ts',
350
+ totalTokens: 2900,
351
+ model: 'gpt-5.3-codex-pro',
352
+ tokensPerMin: 19
353
+ })
354
+
355
+ run('statusCurrentUsageTimestampMs', {
356
+ sessionId: 'packaged-status-current-usage-timestamp-ms',
357
+ agentId: 'agent-stats-packaged-usage-timestamp-ms',
358
+ totalTokens: 2121,
359
+ model: 'qwen-lite-packaged',
360
+ tokensPerMin: 22
361
+ })
362
+
363
+ run('statusCurrentUsageTimestamp', {
364
+ sessionId: 'packaged-status-current-usage-timestamp',
365
+ agentId: 'agent-stats-packaged-usage-timestamp',
366
+ totalTokens: 1919,
367
+ model: 'qwen-lite-packaged',
368
+ tokensPerMin: 12
369
+ })
370
+
371
+ run('statusCurrentUsageTime', {
372
+ sessionId: 'packaged-status-current-usage-time',
373
+ agentId: 'agent-stats-packaged-usage-time',
374
+ totalTokens: 1555,
375
+ model: 'claude-mini-3.8-packaged',
376
+ tokensPerMin: 14
377
+ })
378
+
379
+ run('statusCurrentUsageTimeCamel', {
380
+ sessionId: 'packaged-status-current-usage-time-camel',
381
+ agentId: 'agent-stats-packaged-usage-time-camel',
382
+ totalTokens: 2333,
383
+ model: 'qwen-camel-packaged',
384
+ tokensPerMin: 17
385
+ })
386
+
387
+ run('statusCurrentUpdatedAtMs', {
388
+ sessionId: 'packaged-status-current-updated-at-ms',
389
+ agentId: 'agent-stats-packaged-updated-at-ms',
390
+ totalTokens: 777,
391
+ model: 'claude-mini-3.8',
392
+ tokensPerMin: 21
393
+ })
394
+
395
+ console.log('validate-packaged-openclaw-stats-ingestion: ok (packaged app parses stats fallback across payload shapes)')
396
+ }
397
+
398
+ try {
399
+ runAllShapes()
400
+ } finally {
401
+ rmSync(tempDir, { recursive: true, force: true })
402
+ }
@@ -0,0 +1,82 @@
1
+ #!/usr/bin/env node
2
+ import fs from 'node:fs'
3
+ import path from 'node:path'
4
+
5
+ const [, , targetRoot] = process.argv
6
+ const ROOT = targetRoot || path.join(process.cwd(), 'dist', 'IdleWatch.app', 'Contents', 'Resources', 'payload', 'package')
7
+
8
+ function* walkJsFiles(startDir) {
9
+ const entries = fs.readdirSync(startDir, { withFileTypes: true })
10
+ for (const entry of entries) {
11
+ const fullPath = path.join(startDir, entry.name)
12
+ if (entry.isDirectory()) {
13
+ yield* walkJsFiles(fullPath)
14
+ continue
15
+ }
16
+
17
+ if (!entry.isFile()) continue
18
+ if (!entry.name.endsWith('.js')) continue
19
+ yield fullPath
20
+ }
21
+ }
22
+
23
+ function extractSourceMapRef(contents) {
24
+ const match = String(contents).match(/\/\/#[\s]*sourceMappingURL=([^\n\r]+)\s*$/m)
25
+ return match ? match[1].trim() : null
26
+ }
27
+
28
+ function validateSourceMaps(rootDir) {
29
+ const missing = []
30
+ let nodeModuleMissing = 0
31
+
32
+ for (const jsPath of walkJsFiles(rootDir)) {
33
+ const contents = fs.readFileSync(jsPath, 'utf8')
34
+ const ref = extractSourceMapRef(contents)
35
+ if (!ref) continue
36
+
37
+ if (ref.startsWith('data:')) continue
38
+ if (ref.startsWith('http:') || ref.startsWith('https:')) continue
39
+
40
+ const candidate = path.resolve(path.dirname(jsPath), ref)
41
+ const isNodeModule = jsPath.includes(`${path.sep}node_modules${path.sep}`)
42
+
43
+ if (!fs.existsSync(candidate)) {
44
+ if (isNodeModule) {
45
+ nodeModuleMissing += 1
46
+ } else {
47
+ missing.push({ jsPath, ref })
48
+ }
49
+ }
50
+ }
51
+
52
+ if (nodeModuleMissing > 0) {
53
+ console.warn(`packaged sourcemap check skipped ${nodeModuleMissing} external-map references in node_modules (not validated by design).`)
54
+ }
55
+
56
+ if (missing.length > 0) {
57
+ console.error('packaged source maps validation failed: missing external map files')
58
+ for (const item of missing.slice(0, 40)) {
59
+ console.error(`- ${item.jsPath} -> ${item.ref}`)
60
+ }
61
+
62
+ if (missing.length > 40) {
63
+ console.error(`...and ${missing.length - 40} more`)
64
+ }
65
+
66
+ process.exit(1)
67
+ }
68
+
69
+ console.log('packaged sourcemaps validation passed')
70
+ }
71
+
72
+ if (!fs.existsSync(ROOT)) {
73
+ console.error(`packaged payload directory not found: ${ROOT}`)
74
+ process.exit(1)
75
+ }
76
+
77
+ try {
78
+ validateSourceMaps(ROOT)
79
+ } catch (error) {
80
+ console.error(`packaged sourcemap validation error: ${error.message}`)
81
+ process.exit(1)
82
+ }
@@ -0,0 +1,98 @@
1
+ #!/usr/bin/env node
2
+ import assert from 'node:assert/strict'
3
+ import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
4
+ import { readTelemetryJsonRow } from './lib/telemetry-row-parser.mjs'
5
+ import { execFileSync } from 'node:child_process'
6
+ import { tmpdir } from 'node:os'
7
+ import { join } from 'node:path'
8
+
9
+ const repoRoot = process.cwd()
10
+ const appBin = './dist/IdleWatch.app/Contents/MacOS/IdleWatch'
11
+ const tempDir = mkdtempSync(join(tmpdir(), 'idlewatch-packaged-alert-rate-'))
12
+ const mockBinPath = join(tempDir, 'openclaw-mock.sh')
13
+
14
+ function writeMockOpenClaw(pathToFile) {
15
+ const script = `#!/usr/bin/env bash
16
+ set -euo pipefail
17
+ AGE_MS="\${MOCK_OPENCLAW_AGE_MS:-500}"
18
+ now_ms=$(node -p 'Date.now()')
19
+ updated_at=$((now_ms - AGE_MS))
20
+ cat <<JSON
21
+ {"sessions":{"defaults":{"model":"gpt-5.3-codex"},"recent":[{"sessionId":"sess-packaged-alert","agentId":"main","model":"gpt-5.3-codex","totalTokens":12345,"updatedAt":\${updated_at},"totalTokensFresh":true}]},"ts":\${now_ms}}
22
+ JSON
23
+ `
24
+ writeFileSync(pathToFile, script, 'utf8')
25
+ chmodSync(pathToFile, 0o755)
26
+ }
27
+
28
+ function readRow(output) {
29
+ return readTelemetryJsonRow(output)
30
+ }
31
+
32
+ function runSample(ageMs, overrides = {}) {
33
+ const env = {
34
+ ...process.env,
35
+ IDLEWATCH_OPENCLAW_BIN: mockBinPath,
36
+ IDLEWATCH_USAGE_STALE_MS: '60000',
37
+ IDLEWATCH_USAGE_STALE_GRACE_MS: '10000',
38
+ IDLEWATCH_INTERVAL_MS: '1000',
39
+ MOCK_OPENCLAW_AGE_MS: String(ageMs),
40
+ ...overrides
41
+ }
42
+
43
+ const out = execFileSync(appBin, ['--dry-run'], {
44
+ cwd: repoRoot,
45
+ encoding: 'utf8',
46
+ env,
47
+ stdio: ['ignore', 'pipe', 'pipe']
48
+ })
49
+
50
+ return readRow(out)
51
+ }
52
+
53
+ function collectAlertLevels(ageSeries, overrides = {}) {
54
+ return ageSeries.map((ageMs) => {
55
+ const row = runSample(ageMs, overrides)
56
+ return {
57
+ ageMs,
58
+ nearMs: row?.source?.usageNearStaleMsThreshold,
59
+ level: row?.source?.usageAlertLevel,
60
+ reason: row?.source?.usageAlertReason,
61
+ freshness: row?.source?.usageFreshnessState
62
+ }
63
+ })
64
+ }
65
+
66
+ try {
67
+ writeMockOpenClaw(mockBinPath)
68
+ execFileSync('npm', ['run', 'package:macos', '--silent'], {
69
+ cwd: repoRoot,
70
+ encoding: 'utf8',
71
+ stdio: ['ignore', 'pipe', 'pipe']
72
+ })
73
+
74
+ const typicalAges = [28000, 33000, 39000, 45000, 50000, 54000]
75
+ const typical = collectAlertLevels(typicalAges)
76
+
77
+ const nonOkTypical = typical.filter((sample) => sample.level !== 'ok')
78
+ assert.equal(nonOkTypical.length, 0, `expected no non-ok alerts for typical low-traffic ages, got: ${JSON.stringify(nonOkTypical)}`)
79
+ assert.equal(typical[0].nearMs, 59500, 'expected default near-stale threshold to include stale+grace derivation (59500ms)')
80
+
81
+ const boundaryAges = [1800, 3600, 5600]
82
+ const boundary = collectAlertLevels(boundaryAges, {
83
+ IDLEWATCH_USAGE_STALE_MS: '3000',
84
+ IDLEWATCH_USAGE_NEAR_STALE_MS: '1000',
85
+ IDLEWATCH_USAGE_STALE_GRACE_MS: '1000'
86
+ })
87
+
88
+ assert.equal(boundary[0].level, 'notice', 'expected boundary sample 0 to emit notice for near-stale')
89
+ assert.equal(boundary[0].reason, 'activity-near-stale', 'expected boundary sample 0 reason to be activity-near-stale')
90
+ assert.equal(boundary[1].level, 'warning', 'expected boundary sample 1 to emit warning after stale threshold')
91
+ assert.equal(boundary[1].reason, 'activity-past-threshold', 'expected boundary sample 1 reason to be activity-past-threshold')
92
+ assert.equal(boundary[2].level, 'warning', 'expected boundary sample 2 to remain warning')
93
+ assert.equal(boundary[2].reason, 'activity-past-threshold', 'expected boundary sample 2 reason to remain activity-past-threshold')
94
+
95
+ console.log('validate-packaged-usage-alert-rate-e2e: ok (packaged launcher alert-level transitions remain aligned)')
96
+ } finally {
97
+ rmSync(tempDir, { recursive: true, force: true })
98
+ }
@@ -0,0 +1,87 @@
1
+ #!/usr/bin/env node
2
+ import assert from 'node:assert/strict'
3
+ import { mkdtempSync, writeFileSync, chmodSync, rmSync } 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 } from 'node:child_process'
8
+
9
+ const repoRoot = process.cwd()
10
+ const appBin = './dist/IdleWatch.app/Contents/MacOS/IdleWatch'
11
+ const tempDir = mkdtempSync(join(tmpdir(), 'idlewatch-packaged-probe-noise-'))
12
+ const mockBinPath = join(tempDir, 'openclaw-mock.sh')
13
+
14
+ function writeMockOpenClaw(binPath) {
15
+ const script = `#!/usr/bin/env bash
16
+ set -euo pipefail
17
+ age_ms="\${MOCK_OPENCLAW_AGE_MS:-500}"
18
+ exit_code="\${MOCK_OPENCLAW_EXIT_CODE:-3}"
19
+
20
+ now_ms=$(node -p 'Date.now()')
21
+ updated_at=$((now_ms - age_ms))
22
+
23
+ cat <<JSON
24
+ {"sessions":{"defaults":{"model":"gpt-5.3-codex"},"recent":[{"sessionId":"sess-packaged-noise","agentId":"main","model":"gpt-5.3-codex","totalTokens":54321,"updatedAt":\${updated_at},"totalTokensFresh":true}]},"ts":\${now_ms}}
25
+ JSON
26
+
27
+ echo "openclaw status probe complete (simulated warning)." >&2
28
+ exit "\${exit_code}"
29
+ `
30
+ writeFileSync(binPath, script, 'utf8')
31
+ chmodSync(binPath, 0o755)
32
+ }
33
+
34
+ function readRow(output) {
35
+ return readTelemetryJsonRow(output)
36
+ }
37
+
38
+ try {
39
+ writeMockOpenClaw(mockBinPath)
40
+
41
+ execFileSync('npm', ['run', 'package:macos', '--silent'], {
42
+ cwd: repoRoot,
43
+ encoding: 'utf8',
44
+ stdio: ['ignore', 'pipe', 'pipe']
45
+ })
46
+
47
+ const env = {
48
+ ...process.env,
49
+ IDLEWATCH_OPENCLAW_BIN: mockBinPath,
50
+ IDLEWATCH_OPENCLAW_PROBE_TIMEOUT_MS: '2500',
51
+ IDLEWATCH_OPENCLAW_PROBE_RETRIES: '0',
52
+ IDLEWATCH_USAGE_STALE_MS: '60000',
53
+ IDLEWATCH_USAGE_STALE_GRACE_MS: '10000',
54
+ IDLEWATCH_USAGE_REFRESH_REPROBES: '0',
55
+ IDLEWATCH_OPENCLAW_USAGE: 'auto',
56
+ MOCK_OPENCLAW_AGE_MS: '500',
57
+ MOCK_OPENCLAW_EXIT_CODE: '3'
58
+ }
59
+
60
+ const out = execFileSync(appBin, ['--dry-run'], {
61
+ cwd: repoRoot,
62
+ encoding: 'utf8',
63
+ env,
64
+ stdio: ['ignore', 'pipe', 'pipe']
65
+ })
66
+
67
+ const row = readRow(out)
68
+ const source = row.source
69
+
70
+ assert.equal(source.usage, 'openclaw')
71
+ assert.equal(source.usageIngestionStatus, 'ok')
72
+ assert.equal(source.usageProbeResult, 'ok')
73
+ assert.equal(
74
+ typeof source.usageProbeError === 'string' &&
75
+ (source.usageProbeError.includes('non-zero') || source.usageProbeError.includes('command-exited')),
76
+ true
77
+ )
78
+ assert.ok(source.usageCommand.includes('openclaw-mock.sh status --json'))
79
+ assert.equal(source.usageIntegrationStatus, 'ok')
80
+ assert.equal(source.usageAlertReason, 'healthy')
81
+ assert.equal(row.openclawSessionId, 'sess-packaged-noise')
82
+ assert.equal(row.openclawTotalTokens, 54321)
83
+
84
+ console.log('packaged-usage-probe-noise-e2e: ok (non-zero exit with valid JSON still ingested)')
85
+ } finally {
86
+ rmSync(tempDir, { recursive: true, force: true })
87
+ }