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,372 @@
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 tempDir = mkdtempSync(join(tmpdir(), 'idlewatch-openclaw-stats-egress-'))
11
+ const mockBinPath = join(tempDir, 'openclaw-mock.sh')
12
+
13
+ function writeMockOpenClaw(pathToScript, shape) {
14
+ const resultCurrent = `{
15
+ "status": {
16
+ "result": {
17
+ "stats": {
18
+ "current": {
19
+ "session": {
20
+ "sessionId": "mock-stats-session",
21
+ "agentId": "agent-stats-mock",
22
+ "model": "gpt-5.3-codex",
23
+ "totalTokens": 6789,
24
+ "tokensPerMinute": 33.25,
25
+ "updatedAt": 1771304100000
26
+ }
27
+ }
28
+ }
29
+ }
30
+ },
31
+ "ts": 1771304200000
32
+ }`
33
+
34
+ const statusCurrent = `{
35
+ "status": {
36
+ "current": {
37
+ "stats": {
38
+ "current": {
39
+ "session": {
40
+ "sessionId": "status-current-session",
41
+ "agentId": "agent-stats-status-current",
42
+ "model": "gpt-5.3-codex-pro",
43
+ "totalTokens": 2048,
44
+ "tokensPerMinute": 42,
45
+ "updatedAt": 1771304500000
46
+ }
47
+ }
48
+ }
49
+ }
50
+ },
51
+ "ts": 1771304600000
52
+ }`
53
+
54
+ const statusCurrentTsMs = `{
55
+ "status": {
56
+ "current": {
57
+ "stats": {
58
+ "current": {
59
+ "session": {
60
+ "sessionId": "status-current-ts-ms",
61
+ "agentId": "agent-stats-ts-ms",
62
+ "model": "gpt-5.3-codex",
63
+ "totalTokens": 4096,
64
+ "tokensPerMinute": 64,
65
+ "usage_ts_ms": 1771306600000
66
+ }
67
+ }
68
+ }
69
+ }
70
+ },
71
+ "ts": 1771306700000
72
+ }`
73
+
74
+ const statusCurrentUsageTs = `{
75
+ "status": {
76
+ "current": {
77
+ "stats": {
78
+ "current": {
79
+ "session": {
80
+ "sessionId": "status-current-usage-ts",
81
+ "agentId": "agent-stats-usage-ts",
82
+ "model": "gpt-5.3-codex",
83
+ "totalTokens": 4096,
84
+ "tokensPerMinute": 64,
85
+ "usage_ts": 1771307000000
86
+ }
87
+ }
88
+ }
89
+ }
90
+ },
91
+ "ts": 1771307100000
92
+ }
93
+ `
94
+
95
+ const statusCurrentUsageTimestampMs = `{
96
+ "status": {
97
+ "current": {
98
+ "stats": {
99
+ "current": {
100
+ "session": {
101
+ "sessionId": "status-current-usage-ts-ms",
102
+ "agentId": "agent-stats-usage-ts-ms",
103
+ "model": "qwen3",
104
+ "totalTokens": 512,
105
+ "tokensPerMinute": 16,
106
+ "usage_timestamp_ms": 1771308800000
107
+ }
108
+ }
109
+ }
110
+ }
111
+ },
112
+ "ts": 1771308900000
113
+ }`
114
+
115
+ const statusCurrentUpdatedAtMs = `{
116
+ "status": {
117
+ "current": {
118
+ "stats": {
119
+ "current": {
120
+ "session": {
121
+ "sessionId": "status-current-updated-at-ms",
122
+ "agentId": "agent-stats-updated-at-ms",
123
+ "model": "gpt-5.3-codex-pro",
124
+ "totalTokens": 876,
125
+ "tokensPerMinute": 11,
126
+ "updated_at_ms": 1771313300000
127
+ }
128
+ }
129
+ }
130
+ }
131
+ },
132
+ "ts": 1771313400000
133
+ }`
134
+
135
+ const statusCurrentUsageTimestamp = `{
136
+ "status": {
137
+ "current": {
138
+ "stats": {
139
+ "current": {
140
+ "session": {
141
+ "sessionId": "status-current-usage-timestamp",
142
+ "agentId": "agent-stats-usage-timestamp",
143
+ "model": "qwen-lite",
144
+ "totalTokens": 3333,
145
+ "tokensPerMinute": 9.75,
146
+ "usage_timestamp": "2026-02-27T09:00:00.000Z"
147
+ }
148
+ }
149
+ }
150
+ }
151
+ },
152
+ "ts": 1771319100000
153
+ }`
154
+
155
+ const statusCurrentUsageTime = `{
156
+ "status": {
157
+ "current": {
158
+ "stats": {
159
+ "current": {
160
+ "session": {
161
+ "sessionId": "status-current-usage-time",
162
+ "agentId": "agent-stats-usage-time",
163
+ "model": "qwen-lite",
164
+ "totalTokens": 4444,
165
+ "tokensPerMinute": 8.5,
166
+ "usage_time": "2026-02-27T10:15:00.000Z"
167
+ }
168
+ }
169
+ }
170
+ }
171
+ },
172
+ "ts": 1771323300000
173
+ }`
174
+
175
+ const statusCurrentUsageTimeCamel = `{
176
+ "status": {
177
+ "current": {
178
+ "stats": {
179
+ "current": {
180
+ "session": {
181
+ "sessionId": "status-current-usage-time-camel",
182
+ "agentId": "agent-stats-usage-time-camel",
183
+ "model": "qwen-camel",
184
+ "totalTokens": 5555,
185
+ "tokensPerMinute": 13.3,
186
+ "usageTime": "2026-02-27T10:45:00.000Z"
187
+ }
188
+ }
189
+ }
190
+ }
191
+ },
192
+ "ts": 1771324500000
193
+ }`
194
+
195
+ const script = `#!/usr/bin/env bash
196
+ set -euo pipefail
197
+
198
+ SCENARIO="${shape}"
199
+ if [[ "$1" == "usage" || "$1" == "status" || "$1" == "session" || "$1" == "session_status" ]]; then
200
+ echo '{"message":"legacy status output unavailable"}\n'
201
+ exit 0
202
+ fi
203
+
204
+ if [[ "$1" == "stats" ]]; then
205
+ if [[ "$SCENARIO" == "statusCurrent" ]]; then
206
+ cat <<JSON
207
+ ${statusCurrent}
208
+ JSON
209
+ elif [[ "$SCENARIO" == "statusCurrentTsMs" ]]; then
210
+ cat <<JSON
211
+ ${statusCurrentTsMs}
212
+ JSON
213
+ elif [[ "$SCENARIO" == "statusCurrentUsageTs" ]]; then
214
+ cat <<JSON
215
+ ${statusCurrentUsageTs}
216
+ JSON
217
+ elif [[ "$SCENARIO" == "statusCurrentUsageTimestampMs" ]]; then
218
+ cat <<JSON
219
+ ${statusCurrentUsageTimestampMs}
220
+ JSON
221
+ elif [[ "$SCENARIO" == "statusCurrentUpdatedAtMs" ]]; then
222
+ cat <<JSON
223
+ ${statusCurrentUpdatedAtMs}
224
+ JSON
225
+ elif [[ "$SCENARIO" == "statusCurrentUsageTimestamp" ]]; then
226
+ cat <<JSON
227
+ ${statusCurrentUsageTimestamp}
228
+ JSON
229
+ elif [[ "$SCENARIO" == "statusCurrentUsageTime" ]]; then
230
+ cat <<JSON
231
+ ${statusCurrentUsageTime}
232
+ JSON
233
+ elif [[ "$SCENARIO" == "statusCurrentUsageTimeCamel" ]]; then
234
+ cat <<JSON
235
+ ${statusCurrentUsageTimeCamel}
236
+ JSON
237
+ else
238
+ cat <<JSON
239
+ ${resultCurrent}
240
+ JSON
241
+ fi
242
+ exit 0
243
+ fi
244
+
245
+ echo '{"error":"unsupported command"}'
246
+ exit 2
247
+ `
248
+ writeFileSync(pathToScript, script, 'utf8')
249
+ chmodSync(pathToScript, 0o755)
250
+ }
251
+
252
+ function readRow(output) {
253
+ return readTelemetryJsonRow(output)
254
+ }
255
+
256
+ function run(shape, expectations) {
257
+ writeMockOpenClaw(mockBinPath, shape)
258
+
259
+ const env = {
260
+ ...process.env,
261
+ IDLEWATCH_OPENCLAW_BIN: mockBinPath,
262
+ IDLEWATCH_OPENCLAW_BIN_STRICT: '1',
263
+ IDLEWATCH_OPENCLAW_PROBE_RETRIES: '0',
264
+ IDLEWATCH_USAGE_STALE_MS: '60000',
265
+ IDLEWATCH_USAGE_STALE_GRACE_MS: '10000',
266
+ IDLEWATCH_USAGE_REFRESH_REPROBES: '0',
267
+ IDLEWATCH_INTERVAL_MS: '1000',
268
+ IDLEWATCH_OPENCLAW_USAGE: 'auto'
269
+ }
270
+
271
+ const out = execFileSync('node', ['bin/idlewatch-agent.js', '--dry-run'], {
272
+ cwd: repoRoot,
273
+ encoding: 'utf8',
274
+ env,
275
+ stdio: ['ignore', 'pipe', 'pipe']
276
+ })
277
+
278
+ const row = readRow(out)
279
+ assert.equal(row?.source?.usage, 'openclaw', 'usage should be sourced from openclaw when stats fallback is used')
280
+ assert.equal(row?.source?.usageIngestionStatus, 'ok', 'usage ingestion should remain healthy')
281
+ assert.equal(row?.source?.usageProbeResult, 'ok', 'stats command should succeed')
282
+ assert.equal(row?.source?.usageProbeAttempts >= 3, true, 'stats fallback path should have attempted several commands')
283
+ assert.equal(row?.source?.usageCommand.includes('stats --json'), true, 'stats command should be selected after fallback attempts')
284
+ assert.equal(row?.openclawSessionId, expectations.sessionId)
285
+ assert.equal(row?.openclawAgentId, expectations.agentId)
286
+ assert.equal(row?.openclawTotalTokens, expectations.totalTokens)
287
+ assert.equal(row?.openclawModel, expectations.model)
288
+ assert.equal(row?.tokensPerMin, expectations.tokensPerMin)
289
+ assert.equal(row?.source?.usageProbeError || null, null)
290
+ }
291
+
292
+ function runAllShapes() {
293
+ run('resultCurrent', {
294
+ sessionId: 'mock-stats-session',
295
+ agentId: 'agent-stats-mock',
296
+ totalTokens: 6789,
297
+ model: 'gpt-5.3-codex',
298
+ tokensPerMin: 33.25
299
+ })
300
+
301
+ run('statusCurrent', {
302
+ sessionId: 'status-current-session',
303
+ agentId: 'agent-stats-status-current',
304
+ totalTokens: 2048,
305
+ model: 'gpt-5.3-codex-pro',
306
+ tokensPerMin: 42
307
+ })
308
+
309
+ run('statusCurrentTsMs', {
310
+ sessionId: 'status-current-ts-ms',
311
+ agentId: 'agent-stats-ts-ms',
312
+ totalTokens: 4096,
313
+ model: 'gpt-5.3-codex',
314
+ tokensPerMin: 64
315
+ })
316
+
317
+ run('statusCurrentUsageTs', {
318
+ sessionId: 'status-current-usage-ts',
319
+ agentId: 'agent-stats-usage-ts',
320
+ totalTokens: 4096,
321
+ model: 'gpt-5.3-codex',
322
+ tokensPerMin: 64
323
+ })
324
+
325
+ run('statusCurrentUsageTimestampMs', {
326
+ sessionId: 'status-current-usage-ts-ms',
327
+ agentId: 'agent-stats-usage-ts-ms',
328
+ totalTokens: 512,
329
+ model: 'qwen3',
330
+ tokensPerMin: 16
331
+ })
332
+
333
+ run('statusCurrentUsageTimestamp', {
334
+ sessionId: 'status-current-usage-timestamp',
335
+ agentId: 'agent-stats-usage-timestamp',
336
+ totalTokens: 3333,
337
+ model: 'qwen-lite',
338
+ tokensPerMin: 9.75
339
+ })
340
+
341
+ run('statusCurrentUsageTime', {
342
+ sessionId: 'status-current-usage-time',
343
+ agentId: 'agent-stats-usage-time',
344
+ totalTokens: 4444,
345
+ model: 'qwen-lite',
346
+ tokensPerMin: 8.5
347
+ })
348
+
349
+ run('statusCurrentUsageTimeCamel', {
350
+ sessionId: 'status-current-usage-time-camel',
351
+ agentId: 'agent-stats-usage-time-camel',
352
+ totalTokens: 5555,
353
+ model: 'qwen-camel',
354
+ tokensPerMin: 13.3
355
+ })
356
+
357
+ run('statusCurrentUpdatedAtMs', {
358
+ sessionId: 'status-current-updated-at-ms',
359
+ agentId: 'agent-stats-updated-at-ms',
360
+ totalTokens: 876,
361
+ model: 'gpt-5.3-codex-pro',
362
+ tokensPerMin: 11
363
+ })
364
+
365
+ console.log('validate-openclaw-stats-ingestion: ok (stats-only command path parsed and ingested across multiple payload shapes)')
366
+ }
367
+
368
+ try {
369
+ runAllShapes()
370
+ } finally {
371
+ rmSync(tempDir, { recursive: true, force: true })
372
+ }
@@ -0,0 +1,95 @@
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 tempDir = mkdtempSync(join(tmpdir(), 'idlewatch-openclaw-usage-health-'))
11
+ const mockBinPath = join(tempDir, 'openclaw-mock.sh')
12
+
13
+ function writeMockOpenClaw(pathToScript) {
14
+ const script = `#!/usr/bin/env bash
15
+ set -euo pipefail
16
+ NOW_MS="$(node -e "console.log(Date.now())")"
17
+
18
+ if [[ "$1" == "usage" || "$1" == "status" || "$1" == "session" || "$1" == "session_status" ]]; then
19
+ cat <<JSON
20
+ {
21
+ "status": {
22
+ "result": {
23
+ "stats": {
24
+ "current": {
25
+ "session": {
26
+ "sessionId": "health-session",
27
+ "agentId": "agent-health",
28
+ "model": "gpt-5.3-codex-health",
29
+ "totalTokens": 3333,
30
+ "tokensPerMinute": 11.11,
31
+ "updatedAt": ${'$'}NOW_MS
32
+ }
33
+ }
34
+ }
35
+ }
36
+ }
37
+ }
38
+ JSON
39
+ exit 0
40
+ fi
41
+
42
+ echo '{"error":"unsupported command"}'
43
+ exit 2
44
+ `
45
+ writeFileSync(pathToScript, script, 'utf8')
46
+ chmodSync(pathToScript, 0o755)
47
+ }
48
+
49
+ function readRow(output) {
50
+ return readTelemetryJsonRow(output)
51
+ }
52
+
53
+ function run() {
54
+ writeMockOpenClaw(mockBinPath)
55
+
56
+ const env = {
57
+ ...process.env,
58
+ IDLEWATCH_OPENCLAW_BIN: mockBinPath,
59
+ IDLEWATCH_OPENCLAW_BIN_STRICT: '1',
60
+ IDLEWATCH_OPENCLAW_PROBE_RETRIES: '0',
61
+ IDLEWATCH_USAGE_STALE_MS: '60000',
62
+ IDLEWATCH_USAGE_STALE_GRACE_MS: '10000',
63
+ IDLEWATCH_USAGE_REFRESH_REPROBES: '0',
64
+ IDLEWATCH_INTERVAL_MS: '1000',
65
+ IDLEWATCH_OPENCLAW_USAGE: 'auto'
66
+ }
67
+
68
+ const out = execFileSync('node', ['bin/idlewatch-agent.js', '--dry-run'], {
69
+ cwd: repoRoot,
70
+ encoding: 'utf8',
71
+ env,
72
+ stdio: ['ignore', 'pipe', 'pipe']
73
+ })
74
+
75
+ const row = readRow(out)
76
+
77
+ assert.equal(row?.source?.usage, 'openclaw', 'usage should be sourced from openclaw')
78
+ assert.equal(row?.source?.usageIngestionStatus, 'ok', 'usage ingestion should be healthy')
79
+ assert.equal(row?.source?.usageProbeResult, 'ok', 'usage probe should succeed')
80
+ assert.ok(row?.source?.usageIntegrationStatus === 'ok' || row?.source?.usageIntegrationStatus === 'aging', 'usage integration should be ok/aging')
81
+ assert.equal(row?.openclawSessionId, 'health-session')
82
+ assert.equal(row?.openclawModel, 'gpt-5.3-codex-health')
83
+ assert.equal(row?.openclawAgentId, 'agent-health')
84
+ assert.equal(row?.openclawTotalTokens, 3333)
85
+ assert.equal(row?.tokensPerMin, 11.11)
86
+ assert.ok(Number(row?.openclawUsageTs) > Date.now() - 120000, 'usage timestamp should be recent')
87
+
88
+ console.log('validate-openclaw-usage-health: ok (openclaw usage source required and healthy)')
89
+ }
90
+
91
+ try {
92
+ run()
93
+ } finally {
94
+ rmSync(tempDir, { recursive: true, force: true })
95
+ }
@@ -0,0 +1,233 @@
1
+ #!/usr/bin/env node
2
+ import { accessSync, constants, readFileSync, existsSync } from 'node:fs'
3
+ import { execSync } from 'node:child_process'
4
+ import { join } from 'node:path'
5
+
6
+ const rootDir = process.cwd()
7
+ const artifactDir = process.env.IDLEWATCH_ARTIFACT_DIR?.trim()
8
+ ? process.env.IDLEWATCH_ARTIFACT_DIR.trim()
9
+ : join(rootDir, 'dist', 'IdleWatch.app')
10
+ const artifactPath = join(artifactDir, 'Contents', 'MacOS', 'IdleWatch')
11
+ const metadataPath = join(artifactDir, 'Contents', 'Resources', 'packaging-metadata.json')
12
+
13
+ function fail(message) {
14
+ console.error(message)
15
+ process.exit(1)
16
+ }
17
+
18
+ function shouldRequireGitMatch(value) {
19
+ const normalized = String(value ?? '1').trim().toLowerCase()
20
+ if (['0', 'false', 'off', 'no'].includes(normalized)) return false
21
+ return true
22
+ }
23
+
24
+ function shouldRequireBundledRuntime(value) {
25
+ const normalized = String(value ?? '').trim().toLowerCase()
26
+ return ['1', 'true', 'on', 'yes'].includes(normalized)
27
+ }
28
+
29
+ function parseBoolean(value) {
30
+ const normalized = String(value ?? '').trim().toLowerCase()
31
+ if (['1', 'true', 'on', 'yes'].includes(normalized)) return true
32
+ if (['0', 'false', 'off', 'no'].includes(normalized)) return false
33
+ return null
34
+ }
35
+
36
+ function readCurrentCommit() {
37
+ try {
38
+ const value = execSync('git rev-parse HEAD', {
39
+ cwd: rootDir,
40
+ encoding: 'utf8',
41
+ stdio: ['ignore', 'pipe', 'pipe']
42
+ }).trim()
43
+
44
+ return value
45
+ } catch {
46
+ return ''
47
+ }
48
+ }
49
+
50
+ function readCurrentWorkingTreeClean() {
51
+ try {
52
+ const porcelain = execSync('git status --porcelain', {
53
+ cwd: rootDir,
54
+ encoding: 'utf8',
55
+ stdio: ['ignore', 'pipe', 'pipe']
56
+ }).trim()
57
+
58
+ return porcelain.length === 0
59
+ } catch {
60
+ return null
61
+ }
62
+ }
63
+
64
+ function resolveSourceCommit(metadata) {
65
+ const value = metadata?.sourceGitCommit
66
+ if (typeof value !== 'string') return ''
67
+ return value.trim()
68
+ }
69
+
70
+ function shouldAllowLegacyCommitMetadata(value) {
71
+ const normalized = String(value ?? '').trim().toLowerCase()
72
+ return ['1', 'true', 'on', 'yes', 'allow'].includes(normalized)
73
+ }
74
+
75
+ function validateMetadataConsistency(metadata) {
76
+ if (!metadata || typeof metadata !== 'object') {
77
+ fail(`Malformed packaging metadata at ${metadataPath}`)
78
+ }
79
+
80
+ if (typeof metadata.version !== 'string' || !metadata.version.trim()) {
81
+ fail(`Packaging metadata is missing a valid version: ${metadataPath}`)
82
+ }
83
+
84
+ const requireNodeRuntime = shouldRequireBundledRuntime(process.env.IDLEWATCH_REQUIRE_NODE_RUNTIME_BUNDLED)
85
+ if (requireNodeRuntime && !parseBoolean(metadata.nodeRuntimeBundled)) {
86
+ console.error('Packaged artifact is not marked as bundled-runtime aware. Rebuild before reuse-mode validation:')
87
+ console.error(' npm run package:macos')
88
+ fail('Rebuild artifact first (bundled-runtime metadata check failed).')
89
+ }
90
+ }
91
+
92
+ function validateSourceCommit(metadata, currentCommit) {
93
+ const requireSourceMatch = shouldRequireGitMatch(process.env.IDLEWATCH_REQUIRE_SOURCE_COMMIT_MATCH)
94
+ if (!requireSourceMatch) return
95
+
96
+ const sourceCommit = resolveSourceCommit(metadata)
97
+ if (!currentCommit) {
98
+ return
99
+ }
100
+
101
+ if (!sourceCommit) {
102
+ console.error('Reusable packaged artifact is missing sourceGitCommit provenance.')
103
+ console.error('Rebuild artifact before strict commit-matching reuse checks:')
104
+ console.error(' npm run package:macos')
105
+
106
+ if (shouldAllowLegacyCommitMetadata(process.env.IDLEWATCH_ALLOW_LEGACY_SOURCE_GIT_COMMIT)) {
107
+ console.error('Continuing with legacy compatibility mode (non-strict).')
108
+ console.error('Disable this path in strict runs via setting: IDLEWATCH_ALLOW_LEGACY_SOURCE_GIT_COMMIT=0 (or unset).')
109
+ return
110
+ }
111
+
112
+ fail('Rebuilt artifact is required for strict source-commit validation: sourceGitCommit is missing.')
113
+ }
114
+
115
+ if (sourceCommit !== currentCommit) {
116
+ console.error('Reused packaged artifact is stale for this workspace revision.')
117
+ console.error(`Current commit : ${currentCommit}`)
118
+ console.error(`Packaged commit: ${sourceCommit}`)
119
+ console.error('Rebuild artifact first:')
120
+ console.error(' npm run package:macos')
121
+ fail('Packaged artifact is stale and does not match current git revision.')
122
+ }
123
+ }
124
+
125
+ function shouldAllowLegacyDirtyMetadata(value) {
126
+ const normalized = String(value ?? '').trim().toLowerCase()
127
+ return ['1', 'true', 'on', 'yes', 'allow'].includes(normalized)
128
+ }
129
+
130
+ function validateSourceDirty(metadata, currentIsClean) {
131
+ if (!metadata || typeof metadata !== 'object' || currentIsClean === null) return
132
+
133
+ const requireDirtyMatch = shouldRequireGitMatch(process.env.IDLEWATCH_REQUIRE_SOURCE_DIRTY_MATCH)
134
+ if (!requireDirtyMatch) return
135
+
136
+ const dirtyKnown = metadata?.sourceGitDirtyKnown === true
137
+ const dirtyValue = metadata.sourceGitDirty
138
+
139
+ if (!dirtyKnown) {
140
+ if (typeof dirtyValue === 'boolean') {
141
+ console.error('Reusable packaged artifact did not reliably record dirty-state confidence at build time.')
142
+ console.error('Build metadata reported sourceGitDirty=' + dirtyValue + ' but sourceGitDirtyKnown is not true.')
143
+ } else {
144
+ console.error('Reusable packaged artifact did not record sourceGitDirty provenance.')
145
+ }
146
+
147
+ if (shouldAllowLegacyDirtyMetadata(process.env.IDLEWATCH_ALLOW_LEGACY_SOURCE_GIT_DIRTY)) {
148
+ console.error('Continuing with legacy compatibility mode (non-strict).')
149
+ console.error('Disable this path in strict runs via setting: IDLEWATCH_ALLOW_LEGACY_SOURCE_GIT_DIRTY=0 (or unset).')
150
+ console.error('Rebuild artifact for strict dirty-state reuse validation:')
151
+ console.error(' npm run package:macos')
152
+ return
153
+ }
154
+
155
+ console.error('Rebuilt artifact is required for strict dirty-state reuse validation.')
156
+ console.error(' npm run package:macos')
157
+ fail('Rebuilt artifact is required for dirty-state validation; sourceGitDirtyKnown is missing.')
158
+ }
159
+
160
+ const metadataWasClean = parseBoolean(dirtyValue)
161
+ if (metadataWasClean === null) {
162
+ console.error('Reusable packaged artifact has non-boolean sourceGitDirty metadata; rebuild with latest packaging script for strict dirty-state checks.')
163
+ if (shouldAllowLegacyDirtyMetadata(process.env.IDLEWATCH_ALLOW_LEGACY_SOURCE_GIT_DIRTY)) {
164
+ console.error('Continuing with legacy compatibility mode (non-strict).')
165
+ console.error('Disable this path in strict runs via setting: IDLEWATCH_ALLOW_LEGACY_SOURCE_GIT_DIRTY=0 (or unset).')
166
+ return
167
+ }
168
+
169
+ fail('Rebuild artifact first: sourceGitDirty must be boolean for strict dirty-state checks.')
170
+ }
171
+
172
+ if (currentIsClean !== metadataWasClean) {
173
+ console.error('Reusable packaged artifact dirty-state does not match current workspace clean-state.')
174
+ console.error(`Current working tree clean: ${currentIsClean}`)
175
+ console.error(`Packaged artifact built with clean workspace: ${metadataWasClean}`)
176
+ fail('Rebuild artifact first: dirty-state mismatch for reusable artifact.')
177
+ }
178
+ }
179
+
180
+
181
+ function validateFreshness(metadata) {
182
+ const maxAgeMs = Number(process.env.IDLEWATCH_PACKAGED_ARTIFACT_MAX_AGE_MS || '')
183
+ if (!Number.isFinite(maxAgeMs) || maxAgeMs <= 0) return
184
+
185
+ const parsedBuiltAt = Date.parse(metadata?.builtAt)
186
+ if (Number.isNaN(parsedBuiltAt)) {
187
+ console.error('packaging metadata is missing a parseable builtAt field; skipping age validation.')
188
+ return
189
+ }
190
+
191
+ const ageMs = Date.now() - parsedBuiltAt
192
+ if (ageMs > maxAgeMs) {
193
+ console.error(`Packaged artifact age ${ageMs}ms exceeds max ${maxAgeMs}ms.`)
194
+ console.error(`Artifact builtAt : ${metadata?.builtAt}`)
195
+ fail('Rebuild artifact first: age policy exceeded for reusable packaged artifact.')
196
+ }
197
+ }
198
+
199
+ function main() {
200
+ if (!existsSync(metadataPath)) {
201
+ fail(`Missing packaged metadata file: ${metadataPath}`)
202
+ }
203
+
204
+ let metadata
205
+ try {
206
+ metadata = JSON.parse(readFileSync(metadataPath, 'utf8'))
207
+ } catch (error) {
208
+ fail(`Failed to parse packaging metadata at ${metadataPath}: ${error?.message || error}`)
209
+ }
210
+
211
+ if (!existsSync(artifactPath)) {
212
+ fail(`Packaged launcher missing: ${artifactPath}`)
213
+ }
214
+
215
+ try {
216
+ accessSync(artifactPath, constants.X_OK)
217
+ } catch {
218
+ fail(`Packaged launcher is not executable: ${artifactPath}`)
219
+ }
220
+
221
+ validateMetadataConsistency(metadata)
222
+ const currentCommit = readCurrentCommit()
223
+ const currentIsClean = readCurrentWorkingTreeClean()
224
+ validateSourceCommit(metadata, currentCommit)
225
+ validateSourceDirty(metadata, currentIsClean)
226
+ validateFreshness(metadata)
227
+
228
+ console.log('packaged artifact reusable validation ok')
229
+ console.log(`artifact: ${artifactPath}`)
230
+ if (metadata.version) console.log(`artifact version: ${metadata.version}`)
231
+ }
232
+
233
+ main()