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,73 @@
1
+ import test from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { deriveUsageAlert } from '../src/usage-alert.js'
4
+
5
+ test('returns critical when ingestion is unavailable', () => {
6
+ const result = deriveUsageAlert({ usageIngestionStatus: 'unavailable', usageActivityStatus: 'fresh' })
7
+ assert.deepEqual(result, { level: 'critical', reason: 'ingestion-unavailable' })
8
+ })
9
+
10
+ test('returns warning when activity is stale', () => {
11
+ const result = deriveUsageAlert({ usageIngestionStatus: 'ok', usageActivityStatus: 'stale' })
12
+ assert.deepEqual(result, { level: 'warning', reason: 'activity-stale' })
13
+ })
14
+
15
+ test('prioritizes stale-threshold warning over stale refresh-no-new-noise', () => {
16
+ const result = deriveUsageAlert(
17
+ {
18
+ usageIngestionStatus: 'ok',
19
+ usageActivityStatus: 'stale',
20
+ usagePastStaleThreshold: true,
21
+ usageRefreshAttempted: true,
22
+ usageRefreshRecovered: false
23
+ }
24
+ )
25
+ assert.deepEqual(result, { level: 'warning', reason: 'activity-past-threshold' })
26
+ })
27
+
28
+ test('keeps stale-no-new-usage notice when explicitly forced despite not being past threshold', () => {
29
+ const result = deriveUsageAlert({
30
+ usageIngestionStatus: 'ok',
31
+ usageActivityStatus: 'stale',
32
+ usageRefreshAttempted: true,
33
+ usageRefreshRecovered: false,
34
+ usagePastStaleThreshold: false
35
+ })
36
+ assert.deepEqual(result, { level: 'notice', reason: 'activity-no-new-usage' })
37
+ })
38
+
39
+ test('returns warning when past stale threshold in grace window', () => {
40
+ const result = deriveUsageAlert({ usageIngestionStatus: 'ok', usageActivityStatus: 'aging', usagePastStaleThreshold: true })
41
+ assert.deepEqual(result, { level: 'warning', reason: 'activity-past-threshold' })
42
+ })
43
+
44
+ test('returns notice when near stale', () => {
45
+ const result = deriveUsageAlert({ usageIngestionStatus: 'ok', usageActivityStatus: 'fresh', usageNearStale: true })
46
+ assert.deepEqual(result, { level: 'notice', reason: 'activity-near-stale' })
47
+ })
48
+
49
+ test('returns off when usage is disabled', () => {
50
+ const result = deriveUsageAlert({ usageIngestionStatus: 'disabled', usageActivityStatus: 'disabled' })
51
+ assert.deepEqual(result, { level: 'off', reason: 'usage-disabled' })
52
+ })
53
+
54
+ test('returns ok when usage is healthy', () => {
55
+ const result = deriveUsageAlert({ usageIngestionStatus: 'ok', usageActivityStatus: 'fresh', usageNearStale: false })
56
+ assert.deepEqual(result, { level: 'ok', reason: 'healthy' })
57
+ })
58
+
59
+ test('downgrades stale to idle notice when usage age exceeds idle threshold', () => {
60
+ const result = deriveUsageAlert(
61
+ { usageIngestionStatus: 'ok', usageActivityStatus: 'stale' },
62
+ { usageAgeMs: 3600000, idleAfterMs: 1800000 }
63
+ )
64
+ assert.deepEqual(result, { level: 'notice', reason: 'activity-idle' })
65
+ })
66
+
67
+ test('does not emit idle notice when ingestion is unavailable', () => {
68
+ const result = deriveUsageAlert(
69
+ { usageIngestionStatus: 'unavailable', usageActivityStatus: 'stale' },
70
+ { usageAgeMs: 3600000, idleAfterMs: 1800000 }
71
+ )
72
+ assert.deepEqual(result, { level: 'critical', reason: 'ingestion-unavailable' })
73
+ })
@@ -0,0 +1,63 @@
1
+ import test from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { deriveUsageFreshness } from '../src/usage-freshness.js'
4
+
5
+ test('deriveUsageFreshness marks stale only when age is strictly above threshold', () => {
6
+ const usage = { usageTimestampMs: 1000 }
7
+
8
+ const atThreshold = deriveUsageFreshness(usage, 7000, 6000, 4500)
9
+ assert.equal(atThreshold.usageAgeMs, 6000)
10
+ assert.equal(atThreshold.isStale, false)
11
+ assert.equal(atThreshold.isPastStaleThreshold, false)
12
+ assert.equal(atThreshold.isNearStale, true)
13
+ assert.equal(atThreshold.freshnessState, 'aging')
14
+
15
+ const overThreshold = deriveUsageFreshness(usage, 7001, 6000, 4500)
16
+ assert.equal(overThreshold.usageAgeMs, 6001)
17
+ assert.equal(overThreshold.isStale, true)
18
+ assert.equal(overThreshold.isPastStaleThreshold, true)
19
+ assert.equal(overThreshold.freshnessState, 'stale')
20
+ })
21
+
22
+ test('deriveUsageFreshness handles invalid or future timestamps safely', () => {
23
+ assert.deepEqual(deriveUsageFreshness({}, 1000, 6000), {
24
+ usageAgeMs: null,
25
+ isStale: false,
26
+ isNearStale: false,
27
+ isPastStaleThreshold: false,
28
+ freshnessState: 'unknown'
29
+ })
30
+ assert.deepEqual(deriveUsageFreshness({ usageTimestampMs: 2000 }, 1000, 6000), {
31
+ usageAgeMs: null,
32
+ isStale: false,
33
+ isNearStale: false,
34
+ isPastStaleThreshold: false,
35
+ freshnessState: 'unknown'
36
+ })
37
+ })
38
+
39
+ test('deriveUsageFreshness disables stale classification for invalid thresholds', () => {
40
+ const usage = { usageTimestampMs: 1000 }
41
+ const result = deriveUsageFreshness(usage, 9000, 0, 5000)
42
+ assert.equal(result.usageAgeMs, 8000)
43
+ assert.equal(result.isStale, false)
44
+ assert.equal(result.isPastStaleThreshold, false)
45
+ assert.equal(result.isNearStale, true)
46
+ assert.equal(result.freshnessState, 'aging')
47
+ })
48
+
49
+ test('deriveUsageFreshness applies grace window before stale classification', () => {
50
+ const usage = { usageTimestampMs: 1000 }
51
+
52
+ const duringGrace = deriveUsageFreshness(usage, 7600, 6000, 4500, 2000)
53
+ assert.equal(duringGrace.usageAgeMs, 6600)
54
+ assert.equal(duringGrace.isPastStaleThreshold, true)
55
+ assert.equal(duringGrace.isStale, false)
56
+ assert.equal(duringGrace.freshnessState, 'aging')
57
+
58
+ const afterGrace = deriveUsageFreshness(usage, 9001, 6000, 4500, 2000)
59
+ assert.equal(afterGrace.usageAgeMs, 8001)
60
+ assert.equal(afterGrace.isPastStaleThreshold, true)
61
+ assert.equal(afterGrace.isStale, true)
62
+ assert.equal(afterGrace.freshnessState, 'stale')
63
+ })
@@ -0,0 +1,146 @@
1
+ import test from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { spawnSync } from 'node:child_process'
4
+ import path from 'node:path'
5
+
6
+ const root = path.resolve(process.cwd())
7
+
8
+ function runDryRunSchemaWithCommand(commandArgs, envOverrides = {}) {
9
+ const result = spawnSync(process.execPath, [
10
+ path.join(root, 'scripts', 'validate-dry-run-schema.mjs'),
11
+ ...commandArgs
12
+ ], {
13
+ env: {
14
+ ...process.env,
15
+ IDLEWATCH_DRY_RUN_TIMEOUT_MS: '5000',
16
+ ...envOverrides
17
+ },
18
+ encoding: 'utf8'
19
+ })
20
+
21
+ assert.equal(result.status, 0, `dry-run schema validation failed: ${(result.stderr || result.stdout).trim()}`)
22
+ }
23
+
24
+ function runDryRunSchemaWithOpenClawMode(mode) {
25
+ runDryRunSchemaWithCommand([
26
+ process.execPath,
27
+ path.join(root, 'bin', 'idlewatch-agent.js'),
28
+ '--dry-run'
29
+ ], {
30
+ IDLEWATCH_OPENCLAW_USAGE: mode,
31
+ IDLEWATCH_OPENCLAW_PROBE_TIMEOUT_MS: '2500',
32
+ IDLEWATCH_OPENCLAW_PROBE_RETRIES: '1'
33
+ })
34
+ }
35
+
36
+ function runDryRunSchemaWithNoisyMultilineCommand() {
37
+ const commandScript = `
38
+ const row = {
39
+ host: 'ci-mock-host',
40
+ ts: Date.now(),
41
+ cpuPct: null,
42
+ memPct: null,
43
+ memUsedPct: null,
44
+ memPressurePct: null,
45
+ memPressureClass: 'unavailable',
46
+ gpuPct: null,
47
+ gpuSource: 'mock',
48
+ gpuConfidence: 'none',
49
+ gpuSampleWindowMs: null,
50
+ tokensPerMin: null,
51
+ openclawModel: null,
52
+ openclawTotalTokens: null,
53
+ openclawSessionId: null,
54
+ openclawAgentId: null,
55
+ openclawUsageTs: null,
56
+ openclawUsageAgeMs: null,
57
+ source: {
58
+ usage: 'disabled',
59
+ usageIntegrationStatus: 'unavailable',
60
+ usageIngestionStatus: 'disabled',
61
+ usageFreshnessState: 'disabled',
62
+ usageActivityStatus: 'disabled',
63
+ usageAlertLevel: 'off',
64
+ usageAlertReason: 'usage-disabled',
65
+ usageNearStale: false,
66
+ usagePastStaleThreshold: false,
67
+ usageRefreshAttempted: false,
68
+ usageRefreshRecovered: false,
69
+ usageRefreshAttempts: 0,
70
+ usageRefreshReprobes: 1,
71
+ usageRefreshDelayMs: 250,
72
+ usageRefreshDurationMs: null,
73
+ usageRefreshOnNearStale: true,
74
+ usageIdle: false,
75
+ usageCommand: null,
76
+ usageProbeResult: 'disabled',
77
+ usageProbeAttempts: 0,
78
+ usageProbeDurationMs: null,
79
+ usageProbeSweeps: 0,
80
+ usageProbeRetries: 0,
81
+ usageProbeTimeoutMs: 4000,
82
+ usageProbeError: null,
83
+ usageUsedFallbackCache: false,
84
+ usageFallbackCacheAgeMs: null,
85
+ usageFallbackCacheSource: null,
86
+ usageStaleMsThreshold: 60000,
87
+ usageNearStaleMsThreshold: 51000,
88
+ usageStaleGraceMs: 10000,
89
+ usageIdleAfterMsThreshold: 21600000,
90
+ memPressureSource: 'mock'
91
+ },
92
+ schemaFamily: 'idlewatch.metric.v1',
93
+ schemaVersion: '1.0.0',
94
+ schemaCompat: ['idlewatch.metric.v1'],
95
+ fleet: {
96
+ host: 'ci-mock-host',
97
+ collectedAtMs: Date.now(),
98
+ resources: {
99
+ cpuPct: null,
100
+ memUsedPct: null,
101
+ memPressurePct: null,
102
+ memPressureClass: 'unavailable'
103
+ },
104
+ usage: {
105
+ model: null,
106
+ totalTokens: null,
107
+ tokensPerMin: null,
108
+ sessionId: null,
109
+ agentId: null,
110
+ usageTimestampMs: null,
111
+ usageAgeMs: null,
112
+ freshnessState: null,
113
+ integrationStatus: 'unavailable',
114
+ ingestionStatus: 'disabled',
115
+ activityStatus: 'disabled',
116
+ alertLevel: 'off'
117
+ },
118
+ provenance: {
119
+ collector: 'idlewatch-agent',
120
+ collectorVersion: null
121
+ }
122
+ }
123
+ }
124
+
125
+ console.log('\x1b[32m[init]\x1b[0m booting')
126
+ console.log(JSON.stringify(row, null, 2))
127
+ console.log('done')
128
+ `
129
+
130
+ runDryRunSchemaWithCommand([
131
+ process.execPath,
132
+ '-e',
133
+ commandScript
134
+ ], {
135
+ IDLEWATCH_OPENCLAW_USAGE: 'off',
136
+ IDLEWATCH_DRY_RUN_TIMEOUT_MS: '5000'
137
+ })
138
+ }
139
+
140
+ test('validate dry-run schema passes with OpenClaw usage disabled', () => {
141
+ runDryRunSchemaWithOpenClawMode('off')
142
+ })
143
+
144
+ test('validate dry-run schema accepts noisy multiline JSON rows', () => {
145
+ runDryRunSchemaWithNoisyMultilineCommand()
146
+ })