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.
- package/.env.example +73 -0
- package/.github/workflows/ci.yml +99 -0
- package/.github/workflows/release-macos-trusted.yml +103 -0
- package/README.md +336 -0
- package/bin/idlewatch-agent.js +1053 -0
- package/docs/onboarding-external.md +58 -0
- package/docs/packaging/macos-dmg.md +199 -0
- package/docs/packaging/macos-launch-agent.md +70 -0
- package/docs/qa/archive/mac-qa-log-2026-02-17.md +5838 -0
- package/docs/qa/mac-qa-log.md +2864 -0
- package/docs/telemetry/idle-stale-policy.md +57 -0
- package/docs/telemetry/openclaw-mapping.md +80 -0
- package/package.json +76 -0
- package/scripts/build-dmg.sh +65 -0
- package/scripts/install-macos-launch-agent.sh +78 -0
- package/scripts/lib/telemetry-row-parser.mjs +100 -0
- package/scripts/package-macos.sh +228 -0
- package/scripts/uninstall-macos-launch-agent.sh +30 -0
- package/scripts/validate-all.sh +142 -0
- package/scripts/validate-bin.mjs +25 -0
- package/scripts/validate-dmg-checksum.sh +37 -0
- package/scripts/validate-dmg-install.sh +155 -0
- package/scripts/validate-dry-run-schema.mjs +257 -0
- package/scripts/validate-onboarding.mjs +63 -0
- package/scripts/validate-openclaw-cache-recovery-e2e.mjs +113 -0
- package/scripts/validate-openclaw-release-gates.mjs +51 -0
- package/scripts/validate-openclaw-stats-ingestion.mjs +372 -0
- package/scripts/validate-openclaw-usage-health.mjs +95 -0
- package/scripts/validate-packaged-artifact.mjs +233 -0
- package/scripts/validate-packaged-bundled-runtime.sh +191 -0
- package/scripts/validate-packaged-metadata.sh +43 -0
- package/scripts/validate-packaged-openclaw-cache-recovery-e2e.mjs +153 -0
- package/scripts/validate-packaged-openclaw-release-gates.mjs +72 -0
- package/scripts/validate-packaged-openclaw-stats-ingestion.mjs +402 -0
- package/scripts/validate-packaged-sourcemaps.mjs +82 -0
- package/scripts/validate-packaged-usage-alert-rate-e2e.mjs +98 -0
- package/scripts/validate-packaged-usage-probe-noise-e2e.mjs +87 -0
- package/scripts/validate-packaged-usage-recovery-e2e.mjs +90 -0
- package/scripts/validate-trusted-prereqs.sh +44 -0
- package/scripts/validate-usage-alert-rate-e2e.mjs +91 -0
- package/scripts/validate-usage-freshness-e2e.mjs +81 -0
- package/skill/SKILL.md +43 -0
- package/src/config.js +100 -0
- package/src/enrollment.js +176 -0
- package/src/gpu.js +115 -0
- package/src/memory.js +67 -0
- package/src/openclaw-cache.js +51 -0
- package/src/openclaw-usage.js +1020 -0
- package/src/telemetry-mapping.js +54 -0
- package/src/usage-alert.js +41 -0
- package/src/usage-freshness.js +31 -0
- package/test/config.test.mjs +112 -0
- package/test/fixtures/gpu-agx.txt +2 -0
- package/test/fixtures/gpu-iogpu.txt +2 -0
- package/test/fixtures/gpu-top-grep.txt +2 -0
- package/test/fixtures/openclaw-fleet-sample-v1.json +68 -0
- package/test/fixtures/openclaw-mixed-equal-score-status-vs-generic-iso-ts.txt +2 -0
- package/test/fixtures/openclaw-mixed-equal-score-status-vs-generic-newest.txt +2 -0
- package/test/fixtures/openclaw-mixed-equal-score-status-vs-generic-string-ts.txt +2 -0
- package/test/fixtures/openclaw-mixed-status-then-generic-output.txt +2 -0
- package/test/fixtures/openclaw-stats-current-wrapper.json +12 -0
- package/test/fixtures/openclaw-stats-current-wrapper2.json +15 -0
- package/test/fixtures/openclaw-stats-data-wrapper.json +21 -0
- package/test/fixtures/openclaw-stats-nested-session-wrapper.json +23 -0
- package/test/fixtures/openclaw-stats-payload-wrapper.json +1 -0
- package/test/fixtures/openclaw-stats-status-current-wrapper.json +19 -0
- package/test/fixtures/openclaw-stats.json +17 -0
- package/test/fixtures/openclaw-status-ansi-complex-noise.txt +3 -0
- package/test/fixtures/openclaw-status-ansi-noise.txt +2 -0
- package/test/fixtures/openclaw-status-control-noise.txt +1 -0
- package/test/fixtures/openclaw-status-data-wrapper.json +20 -0
- package/test/fixtures/openclaw-status-dcs-noise.txt +1 -0
- package/test/fixtures/openclaw-status-epoch-seconds.json +15 -0
- package/test/fixtures/openclaw-status-mixed-noise.txt +1 -0
- package/test/fixtures/openclaw-status-multi-json.txt +3 -0
- package/test/fixtures/openclaw-status-nested-recent.json +19 -0
- package/test/fixtures/openclaw-status-noisy-default-then-usage.txt +2 -0
- package/test/fixtures/openclaw-status-noisy.txt +3 -0
- package/test/fixtures/openclaw-status-osc-noise.txt +1 -0
- package/test/fixtures/openclaw-status-result-session.json +15 -0
- package/test/fixtures/openclaw-status-session-map-with-defaults.json +23 -0
- package/test/fixtures/openclaw-status-session-map.json +28 -0
- package/test/fixtures/openclaw-status-session-model-name.json +18 -0
- package/test/fixtures/openclaw-status-snake-session-wrapper.json +13 -0
- package/test/fixtures/openclaw-status-stats-current-sessions-snake-tokens.json +25 -0
- package/test/fixtures/openclaw-status-stats-current-sessions.json +28 -0
- package/test/fixtures/openclaw-status-stats-current-usage-time-camelcase.json +19 -0
- package/test/fixtures/openclaw-status-stats-session-default-model.json +27 -0
- package/test/fixtures/openclaw-status-status-wrapper.json +13 -0
- package/test/fixtures/openclaw-status-strings.json +38 -0
- package/test/fixtures/openclaw-status-ts-ms-alias.json +14 -0
- package/test/fixtures/openclaw-status-updated-at-ms-alias.json +14 -0
- package/test/fixtures/openclaw-status-usage-timestamp-ms-alias.json +14 -0
- package/test/fixtures/openclaw-status-usage-ts-alias.json +14 -0
- package/test/fixtures/openclaw-status-wrap-session-object.json +24 -0
- package/test/fixtures/openclaw-status.json +41 -0
- package/test/fixtures/openclaw-usage-model-name-generic.json +9 -0
- package/test/gpu.test.mjs +58 -0
- package/test/memory.test.mjs +35 -0
- package/test/openclaw-cache.test.mjs +48 -0
- package/test/openclaw-env.test.mjs +365 -0
- package/test/openclaw-usage.test.mjs +555 -0
- package/test/telemetry-mapping.test.mjs +69 -0
- package/test/telemetry-row-parser.test.mjs +44 -0
- package/test/usage-alert.test.mjs +73 -0
- package/test/usage-freshness.test.mjs +63 -0
- package/test/validate-dry-run-schema.test.mjs +146 -0
- package/tui/Cargo.lock +801 -0
- package/tui/Cargo.toml +11 -0
- 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()
|