token-studio 4.8.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 (139) hide show
  1. package/.nvmrc +1 -0
  2. package/CHANGELOG.md +89 -0
  3. package/Dockerfile +17 -0
  4. package/LICENSE +22 -0
  5. package/NOTICE.md +21 -0
  6. package/PRIVACY.md +68 -0
  7. package/README.en.md +220 -0
  8. package/README.md +220 -0
  9. package/config/collectors.json +54 -0
  10. package/data/.gitkeep +1 -0
  11. package/docker-compose.yml +17 -0
  12. package/docs/assets/.gitkeep +1 -0
  13. package/docs/assets/token-studio-v44-dashboard.png +0 -0
  14. package/docs/assets/token-studio-v44-live.png +0 -0
  15. package/docs/assets/token-studio-v44-review-mobile.png +0 -0
  16. package/docs/assets/token-studio-v44-review.png +0 -0
  17. package/docs/assets/token-studio-v45-dashboard.png +0 -0
  18. package/docs/assets/token-studio-v45-live.png +0 -0
  19. package/docs/assets/token-studio-v45-review-mobile.png +0 -0
  20. package/docs/assets/token-studio-v45-review.png +0 -0
  21. package/docs/blog-case-study.md +34 -0
  22. package/docs/collector-support-matrix.md +65 -0
  23. package/docs/competitive-notes.md +87 -0
  24. package/docs/demo-data/README.md +12 -0
  25. package/docs/demo-data/token-studio-v2-demo.json +146 -0
  26. package/docs/demo-flow.md +39 -0
  27. package/docs/first-run.md +95 -0
  28. package/docs/local-collectors.md +49 -0
  29. package/docs/public-launch-checklist.md +45 -0
  30. package/docs/resume-bullets.md +7 -0
  31. package/docs/statusline.md +52 -0
  32. package/index.html +16 -0
  33. package/package.json +36 -0
  34. package/render.yaml +17 -0
  35. package/src/auto-attribution.mjs +396 -0
  36. package/src/ccusage-bridge.mjs +74 -0
  37. package/src/ccusage-import.mjs +415 -0
  38. package/src/cli.mjs +643 -0
  39. package/src/client/dashboard/App.jsx +1734 -0
  40. package/src/client/dashboard/annotation-presets.js +138 -0
  41. package/src/client/dashboard/attribution.js +328 -0
  42. package/src/client/dashboard/components-charts.jsx +622 -0
  43. package/src/client/dashboard/components-tables.jsx +1531 -0
  44. package/src/client/dashboard/components-top.jsx +307 -0
  45. package/src/client/dashboard/import-budget.js +41 -0
  46. package/src/client/dashboard/model-usage.js +108 -0
  47. package/src/client/dashboard/onboarding.js +80 -0
  48. package/src/client/dashboard/styles.css +2606 -0
  49. package/src/client/live/LiveApp.jsx +226 -0
  50. package/src/client/live/styles.css +446 -0
  51. package/src/client/main.jsx +20 -0
  52. package/src/client/review/ReviewApp.jsx +507 -0
  53. package/src/client/review/closure-progress.js +165 -0
  54. package/src/client/review/markdown-report.js +401 -0
  55. package/src/client/review/model-strategy.js +273 -0
  56. package/src/client/review/roi-advisor.js +255 -0
  57. package/src/client/review/roi-evidence.js +78 -0
  58. package/src/client/review/savings-simulator.js +252 -0
  59. package/src/client/review/sections-1.jsx +277 -0
  60. package/src/client/review/sections-2.jsx +927 -0
  61. package/src/client/review/styles.css +2321 -0
  62. package/src/client/review/utils.js +345 -0
  63. package/src/client/shared/utils.js +236 -0
  64. package/src/closure-check.mjs +537 -0
  65. package/src/closure-import.mjs +646 -0
  66. package/src/collect.mjs +247 -0
  67. package/src/collector-config.mjs +82 -0
  68. package/src/collector-registry.mjs +333 -0
  69. package/src/collectors/claude-code.mjs +355 -0
  70. package/src/collectors/codex.mjs +418 -0
  71. package/src/collectors/copilot.mjs +19 -0
  72. package/src/collectors/cursor.mjs +23 -0
  73. package/src/collectors/gemini.mjs +530 -0
  74. package/src/collectors/goose.mjs +15 -0
  75. package/src/collectors/hermes.mjs +206 -0
  76. package/src/collectors/kimi.mjs +15 -0
  77. package/src/collectors/openclaw.mjs +400 -0
  78. package/src/collectors/opencode.mjs +349 -0
  79. package/src/collectors/qwen.mjs +15 -0
  80. package/src/collectors/structured-usage.mjs +437 -0
  81. package/src/collectors/utils.mjs +93 -0
  82. package/src/db.mjs +1397 -0
  83. package/src/demo-seed.mjs +39 -0
  84. package/src/dev.mjs +43 -0
  85. package/src/live.mjs +428 -0
  86. package/src/model-policy.mjs +147 -0
  87. package/src/pricing.mjs +434 -0
  88. package/src/privacy-check.mjs +126 -0
  89. package/src/server.mjs +1240 -0
  90. package/src/source-health.mjs +195 -0
  91. package/src/statusline.mjs +156 -0
  92. package/src/terminal-report.mjs +245 -0
  93. package/src/update-pricing.mjs +8 -0
  94. package/test/annotation-presets.test.mjs +137 -0
  95. package/test/api-annotations.test.mjs +202 -0
  96. package/test/api-auto-attribution.test.mjs +169 -0
  97. package/test/api-source-health.test.mjs +109 -0
  98. package/test/api-v2.test.mjs +278 -0
  99. package/test/api-v43.test.mjs +151 -0
  100. package/test/api-v44.test.mjs +128 -0
  101. package/test/attribution-summary.test.mjs +164 -0
  102. package/test/auto-attribution.test.mjs +116 -0
  103. package/test/ccusage-bridge.test.mjs +36 -0
  104. package/test/ccusage-import.test.mjs +93 -0
  105. package/test/cli-v43.test.mjs +64 -0
  106. package/test/cli-v45.test.mjs +34 -0
  107. package/test/cli-v46.test.mjs +129 -0
  108. package/test/cli-v47.test.mjs +98 -0
  109. package/test/closure-check.test.mjs +202 -0
  110. package/test/closure-import.test.mjs +263 -0
  111. package/test/collector-config.test.mjs +25 -0
  112. package/test/collector-registry.test.mjs +56 -0
  113. package/test/csv.test.mjs +19 -0
  114. package/test/db-annotations.test.mjs +186 -0
  115. package/test/db-v2.test.mjs +200 -0
  116. package/test/db-v4.test.mjs +178 -0
  117. package/test/experimental-collectors.test.mjs +103 -0
  118. package/test/fixtures/collectors/copilot/usage.jsonl +2 -0
  119. package/test/fixtures/collectors/cursor/usage.jsonl +2 -0
  120. package/test/fixtures/collectors/goose/usage.jsonl +2 -0
  121. package/test/fixtures/collectors/kimi/usage.jsonl +2 -0
  122. package/test/fixtures/collectors/qwen/usage.jsonl +2 -0
  123. package/test/import-budget.test.mjs +40 -0
  124. package/test/live.test.mjs +256 -0
  125. package/test/markdown-report.test.mjs +193 -0
  126. package/test/model-policy.test.mjs +34 -0
  127. package/test/model-strategy.test.mjs +116 -0
  128. package/test/model-usage.test.mjs +99 -0
  129. package/test/official-pricing.test.mjs +70 -0
  130. package/test/onboarding.test.mjs +55 -0
  131. package/test/privacy-check.test.mjs +33 -0
  132. package/test/review-closure-progress.test.mjs +99 -0
  133. package/test/roi-advisor.test.mjs +188 -0
  134. package/test/roi-evidence.test.mjs +48 -0
  135. package/test/roi-summary.test.mjs +101 -0
  136. package/test/savings-simulator.test.mjs +141 -0
  137. package/test/source-health.test.mjs +62 -0
  138. package/test/statusline.test.mjs +148 -0
  139. package/vite.config.js +23 -0
@@ -0,0 +1,137 @@
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+ import {
4
+ applyAnnotationTemplate,
5
+ QUICK_ANNOTATION_TEMPLATES,
6
+ readAnnotationPresets,
7
+ rememberAnnotationPreset,
8
+ writeAnnotationPresets
9
+ } from '../src/client/dashboard/annotation-presets.js';
10
+
11
+ function memoryStorage(initial = {}) {
12
+ const data = new Map(Object.entries(initial));
13
+ return {
14
+ getItem(key) {
15
+ return data.has(key) ? data.get(key) : null;
16
+ },
17
+ setItem(key, value) {
18
+ data.set(key, value);
19
+ }
20
+ };
21
+ }
22
+
23
+ test('rememberAnnotationPreset stores only structured fields and recent projects', () => {
24
+ const state = rememberAnnotationPreset({}, {
25
+ projectAlias: 'Token Studio',
26
+ taskType: '功能开发',
27
+ outputStatus: '已完成',
28
+ workPurpose: '功能开发',
29
+ workStage: '验证',
30
+ valueLevel: '高',
31
+ note: 'do not store free text',
32
+ outputUrl: 'https://example.com/private'
33
+ });
34
+
35
+ assert.deepEqual(state.recentProjects, ['Token Studio']);
36
+ assert.equal(state.lastTemplate.projectAlias, 'Token Studio');
37
+ assert.equal(state.lastTemplate.taskType, '功能开发');
38
+ assert.equal(state.lastTemplate.note, undefined);
39
+ assert.equal(state.lastTemplate.outputUrl, undefined);
40
+ });
41
+
42
+ test('rememberAnnotationPreset deduplicates and limits recent projects', () => {
43
+ let state = {};
44
+ for (const project of ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'A']) {
45
+ state = rememberAnnotationPreset(state, { projectAlias: project });
46
+ }
47
+
48
+ assert.deepEqual(state.recentProjects, ['A', 'I', 'H', 'G', 'F', 'E', 'D', 'C']);
49
+ });
50
+
51
+ test('applyAnnotationTemplate merges template fields without clearing other form data', () => {
52
+ const form = {
53
+ projectAlias: '',
54
+ taskType: '未分类',
55
+ outputStatus: '未标注',
56
+ workPurpose: '未说明',
57
+ workStage: '未说明',
58
+ valueLevel: '未评估',
59
+ note: 'keep note',
60
+ outputUrl: 'https://example.com/output'
61
+ };
62
+ const next = applyAnnotationTemplate(form, {
63
+ projectAlias: 'Token Studio',
64
+ taskType: '问题修复',
65
+ outputStatus: '已完成',
66
+ workPurpose: '调试修复',
67
+ workStage: '验证',
68
+ valueLevel: '中'
69
+ });
70
+
71
+ assert.equal(next.projectAlias, 'Token Studio');
72
+ assert.equal(next.taskType, '问题修复');
73
+ assert.equal(next.note, 'keep note');
74
+ assert.equal(next.outputUrl, 'https://example.com/output');
75
+ });
76
+
77
+ test('quick annotation templates cover common review decisions without free text', () => {
78
+ assert.deepEqual(QUICK_ANNOTATION_TEMPLATES.map(item => item.id), [
79
+ 'explore-validate',
80
+ 'build-feature',
81
+ 'ship-output',
82
+ 'stop-loss'
83
+ ]);
84
+ for (const template of QUICK_ANNOTATION_TEMPLATES) {
85
+ assert.equal(template.values.note, undefined);
86
+ assert.equal(template.values.outputUrl, undefined);
87
+ assert.equal(template.values.outputLabel, undefined);
88
+ }
89
+ });
90
+
91
+ test('quick annotation templates can apply without overwriting project or output data', () => {
92
+ const form = {
93
+ projectAlias: 'Token Studio',
94
+ taskType: '未分类',
95
+ outputStatus: '未标注',
96
+ workPurpose: '未说明',
97
+ workStage: '未说明',
98
+ valueLevel: '未评估',
99
+ note: 'keep real note',
100
+ outputUrl: 'https://example.com/pr/1',
101
+ outputLabel: 'PR #1'
102
+ };
103
+ const next = applyAnnotationTemplate(
104
+ form,
105
+ QUICK_ANNOTATION_TEMPLATES.find(item => item.id === 'ship-output').values,
106
+ { includeProjectAlias: false }
107
+ );
108
+
109
+ assert.equal(next.projectAlias, 'Token Studio');
110
+ assert.equal(next.outputStatus, '已发布');
111
+ assert.equal(next.workStage, '发布');
112
+ assert.equal(next.valueLevel, '关键');
113
+ assert.equal(next.note, 'keep real note');
114
+ assert.equal(next.outputUrl, 'https://example.com/pr/1');
115
+ assert.equal(next.outputLabel, 'PR #1');
116
+ });
117
+
118
+ test('readAnnotationPresets tolerates broken storage data', () => {
119
+ const storage = memoryStorage({ 'token-studio.annotation-presets.v1': '{bad json' });
120
+ const state = readAnnotationPresets(storage);
121
+
122
+ assert.deepEqual(state.recentProjects, []);
123
+ assert.equal(state.lastTemplate, null);
124
+ });
125
+
126
+ test('writeAnnotationPresets round-trips normalized state', () => {
127
+ const storage = memoryStorage();
128
+ const state = rememberAnnotationPreset({}, {
129
+ projectAlias: 'Token Studio',
130
+ taskType: '功能开发'
131
+ });
132
+ writeAnnotationPresets(state, storage);
133
+
134
+ const loaded = readAnnotationPresets(storage);
135
+ assert.deepEqual(loaded.recentProjects, ['Token Studio']);
136
+ assert.equal(loaded.lastTemplate.taskType, '功能开发');
137
+ });
@@ -0,0 +1,202 @@
1
+ import assert from 'node:assert/strict';
2
+ import { spawn } from 'node:child_process';
3
+ import { mkdtempSync, rmSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import test from 'node:test';
7
+ import { openDb, upsertDaily, upsertSession } from '../src/db.mjs';
8
+
9
+ test('annotation API upserts, merges into /api/data, and deletes', async () => {
10
+ const dir = mkdtempSync(join(tmpdir(), 'token-studio-api-'));
11
+ const dbPath = join(dir, 'usage.sqlite');
12
+ const port = 4300 + Math.floor(Math.random() * 1000);
13
+ seedDb(dbPath);
14
+
15
+ const child = spawn(process.execPath, ['src/server.mjs'], {
16
+ cwd: process.cwd(),
17
+ env: {
18
+ ...process.env,
19
+ PORT: String(port),
20
+ DB_PATH: dbPath,
21
+ SCHEDULED_COLLECT_ENABLED: 'false'
22
+ },
23
+ stdio: ['ignore', 'pipe', 'pipe'],
24
+ windowsHide: true
25
+ });
26
+ let stdout = '';
27
+ let stderr = '';
28
+ child.stdout.setEncoding('utf8');
29
+ child.stderr.setEncoding('utf8');
30
+ child.stdout.on('data', chunk => { stdout += chunk; });
31
+ child.stderr.on('data', chunk => { stderr += chunk; });
32
+
33
+ try {
34
+ await waitForApi(port, () => ({ stdout, stderr, exited: child.exitCode != null, exitCode: child.exitCode }));
35
+ assert.match(stdout, /listening on 127\.0\.0\.1/);
36
+
37
+ const initial = await getJson(port, '/api/data');
38
+ assert.equal(initial.sessions.length, 1);
39
+ assert.equal(initial.sessions[0].model, 'codex-mini');
40
+ assert.equal(initial.sessions[0].taskType, '未分类');
41
+ assert.equal(initial.sessions[0].outputStatus, '未标注');
42
+ assert.equal(initial.sessions[0].workPurpose, '未说明');
43
+ assert.equal(initial.sessions[0].workStage, '未说明');
44
+ assert.equal(initial.sessions[0].valueLevel, '未评估');
45
+
46
+ const payload = {
47
+ device: 'devbox',
48
+ source: 'Codex CLI',
49
+ sessionId: 'local:codex:D:\\Project:codex-mini',
50
+ projectAlias: 'AI 选题雷达',
51
+ taskType: '功能开发',
52
+ outputStatus: '已完成',
53
+ workPurpose: '功能开发',
54
+ workStage: '实现',
55
+ valueLevel: '高',
56
+ note: '完成 v1 标注'
57
+ };
58
+
59
+ await assertRejectsWithStatus(
60
+ fetch(`http://127.0.0.1:${port}/api/session-annotations`, {
61
+ method: 'POST',
62
+ headers: { 'Content-Type': 'text/plain' },
63
+ body: JSON.stringify(payload)
64
+ }),
65
+ 415
66
+ );
67
+
68
+ await assertRejectsWithStatus(
69
+ fetch(`http://127.0.0.1:${port}/api/session-annotations`, {
70
+ method: 'POST',
71
+ headers: {
72
+ 'Content-Type': 'application/json',
73
+ Origin: 'http://evil.example'
74
+ },
75
+ body: JSON.stringify(payload)
76
+ }),
77
+ 403
78
+ );
79
+
80
+ const saved = await postJson(port, '/api/session-annotations', payload);
81
+ assert.equal(saved.ok, true);
82
+ assert.equal(saved.annotation.projectAlias, 'AI 选题雷达');
83
+
84
+ const annotated = await getJson(port, '/api/data');
85
+ assert.equal(annotated.meta.taskTypes.includes('功能开发'), true);
86
+ assert.equal(annotated.sessions[0].projectAlias, 'AI 选题雷达');
87
+ assert.equal(annotated.sessions[0].taskType, '功能开发');
88
+ assert.equal(annotated.sessions[0].outputStatus, '已完成');
89
+ assert.equal(annotated.sessions[0].workPurpose, '功能开发');
90
+ assert.equal(annotated.sessions[0].workStage, '实现');
91
+ assert.equal(annotated.sessions[0].valueLevel, '高');
92
+ assert.equal(annotated.sessions[0].note, '完成 v1 标注');
93
+
94
+ const deleted = await deleteJson(port, '/api/session-annotations', {
95
+ device: 'devbox',
96
+ source: 'Codex CLI',
97
+ sessionId: 'local:codex:D:\\Project:codex-mini'
98
+ });
99
+ assert.equal(deleted.deleted, 1);
100
+
101
+ const cleared = await getJson(port, '/api/data');
102
+ assert.equal(cleared.sessions[0].projectAlias, null);
103
+ assert.equal(cleared.sessions[0].taskType, '未分类');
104
+ assert.equal(cleared.sessions[0].outputStatus, '未标注');
105
+ assert.equal(cleared.sessions[0].workPurpose, '未说明');
106
+ assert.equal(cleared.sessions[0].workStage, '未说明');
107
+ assert.equal(cleared.sessions[0].valueLevel, '未评估');
108
+ } finally {
109
+ await stopChild(child);
110
+ rmSync(dir, { recursive: true, force: true });
111
+ }
112
+ });
113
+
114
+ function seedDb(dbPath) {
115
+ const db = openDb(dbPath);
116
+ try {
117
+ upsertDaily(db, {
118
+ device: 'devbox',
119
+ source: 'Codex CLI',
120
+ usageDate: '2026-06-10',
121
+ model: 'codex-mini',
122
+ inputTokens: 100,
123
+ outputTokens: 30,
124
+ cacheCreationTokens: 10,
125
+ cacheReadTokens: 20,
126
+ reasoningOutputTokens: 5,
127
+ totalTokens: 165,
128
+ costUSD: 0.01
129
+ });
130
+ upsertSession(db, {
131
+ device: 'devbox',
132
+ source: 'Codex CLI',
133
+ sessionId: 'local:codex:D:\\Project:codex-mini',
134
+ lastActivity: '2026-06-10T01:00:00.000Z',
135
+ projectPath: 'D:\\Project',
136
+ inputTokens: 100,
137
+ outputTokens: 30,
138
+ cacheCreationTokens: 10,
139
+ cacheReadTokens: 20,
140
+ reasoningOutputTokens: 5,
141
+ totalTokens: 165,
142
+ costUSD: 0.01
143
+ });
144
+ } finally {
145
+ db.close();
146
+ }
147
+ }
148
+
149
+ async function waitForApi(port, diagnostics) {
150
+ const start = Date.now();
151
+ let lastError = null;
152
+ while (Date.now() - start < 5000) {
153
+ try {
154
+ await getJson(port, '/api/data');
155
+ return;
156
+ } catch (error) {
157
+ lastError = error;
158
+ await new Promise(resolve => setTimeout(resolve, 100));
159
+ }
160
+ }
161
+ const details = diagnostics ? diagnostics() : {};
162
+ throw new Error(`server did not start in time: ${lastError?.message || 'no response'}\nstdout=${details.stdout || ''}\nstderr=${details.stderr || ''}\nexit=${details.exited ? details.exitCode : 'running'}`);
163
+ }
164
+
165
+ async function getJson(port, path) {
166
+ const response = await fetch(`http://127.0.0.1:${port}${path}`);
167
+ if (!response.ok) assert.fail(await response.text());
168
+ return response.json();
169
+ }
170
+
171
+ async function postJson(port, path, body) {
172
+ const response = await fetch(`http://127.0.0.1:${port}${path}`, {
173
+ method: 'POST',
174
+ headers: { 'Content-Type': 'application/json' },
175
+ body: JSON.stringify(body)
176
+ });
177
+ if (!response.ok) assert.fail(await response.text());
178
+ return response.json();
179
+ }
180
+
181
+ async function deleteJson(port, path, body) {
182
+ const response = await fetch(`http://127.0.0.1:${port}${path}`, {
183
+ method: 'DELETE',
184
+ headers: { 'Content-Type': 'application/json' },
185
+ body: JSON.stringify(body)
186
+ });
187
+ if (!response.ok) assert.fail(await response.text());
188
+ return response.json();
189
+ }
190
+
191
+ async function assertRejectsWithStatus(responsePromise, expectedStatus) {
192
+ const response = await responsePromise;
193
+ assert.equal(response.status, expectedStatus, await response.text());
194
+ }
195
+
196
+ function stopChild(child) {
197
+ if (child.exitCode != null) return Promise.resolve();
198
+ return new Promise(resolve => {
199
+ child.once('close', resolve);
200
+ child.kill();
201
+ });
202
+ }
@@ -0,0 +1,169 @@
1
+ import assert from 'node:assert/strict';
2
+ import { spawn } from 'node:child_process';
3
+ import { existsSync, mkdtempSync, rmSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import test from 'node:test';
7
+ import {
8
+ openDb,
9
+ upsertSession,
10
+ upsertSessionAnnotation
11
+ } from '../src/db.mjs';
12
+
13
+ test('auto attribution API suggests, applies, protects manual rows, and undoes auto rows', async () => {
14
+ const dir = mkdtempSync(join(tmpdir(), 'token-studio-api-auto-'));
15
+ const dbPath = join(dir, 'usage.sqlite');
16
+ const backupDir = join(dir, 'backups');
17
+ const port = 6300 + Math.floor(Math.random() * 1000);
18
+ seedDb(dbPath);
19
+
20
+ const child = spawn(process.execPath, ['src/server.mjs'], {
21
+ cwd: process.cwd(),
22
+ env: {
23
+ ...process.env,
24
+ PORT: String(port),
25
+ DB_PATH: dbPath,
26
+ BACKUP_DIR: backupDir,
27
+ SCHEDULED_COLLECT_ENABLED: 'false'
28
+ },
29
+ stdio: ['ignore', 'pipe', 'pipe'],
30
+ windowsHide: true
31
+ });
32
+ let stdout = '';
33
+ let stderr = '';
34
+ child.stdout.setEncoding('utf8');
35
+ child.stderr.setEncoding('utf8');
36
+ child.stdout.on('data', chunk => { stdout += chunk; });
37
+ child.stderr.on('data', chunk => { stderr += chunk; });
38
+
39
+ try {
40
+ await waitForApi(port, () => ({ stdout, stderr, exited: child.exitCode != null, exitCode: child.exitCode }));
41
+
42
+ const suggestions = await getJson(port, '/api/auto-attribution/suggestions');
43
+ assert.equal(suggestions.ok, true);
44
+ assert.equal(suggestions.plan.highConfidenceCount, 1);
45
+ assert.equal(suggestions.plan.suggestions[0].values.projectAlias, 'TokenStudio');
46
+
47
+ await assertRejectsWithStatus(
48
+ fetch(`http://127.0.0.1:${port}/api/auto-attribution/apply`, {
49
+ method: 'POST',
50
+ headers: { 'Content-Type': 'text/plain' },
51
+ body: '{}'
52
+ }),
53
+ 415
54
+ );
55
+
56
+ const applied = await postJson(port, '/api/auto-attribution/apply', {
57
+ sessions: suggestions.plan.suggestions.map(row => ({
58
+ device: row.device,
59
+ source: row.source,
60
+ sessionId: row.sessionId
61
+ }))
62
+ });
63
+ assert.equal(applied.applied, 1);
64
+ assert.equal(existsSync(applied.backup.path), true);
65
+ assert.ok(applied.runId);
66
+
67
+ const data = await getJson(port, '/api/data');
68
+ const auto = data.sessions.find(session => session.sessionId === 'codex:one');
69
+ const manual = data.sessions.find(session => session.sessionId === 'claude:two');
70
+ assert.equal(auto.annotationSource, 'auto');
71
+ assert.equal(auto.annotationConfidence, 80);
72
+ assert.equal(auto.attributionQuality, 'auto-high');
73
+ assert.equal(manual.annotationSource, 'manual');
74
+ assert.equal(manual.projectAlias, 'Manual Project');
75
+
76
+ const undone = await postJson(port, '/api/auto-attribution/undo', { runId: applied.runId });
77
+ assert.equal(undone.deleted, 1);
78
+ assert.equal(existsSync(undone.backup.path), true);
79
+
80
+ const afterUndo = await getJson(port, '/api/data');
81
+ assert.equal(afterUndo.sessions.find(session => session.sessionId === 'codex:one').annotationSource, null);
82
+ assert.equal(afterUndo.sessions.find(session => session.sessionId === 'claude:two').annotationSource, 'manual');
83
+ } finally {
84
+ await stopChild(child);
85
+ rmSync(dir, { recursive: true, force: true });
86
+ }
87
+ });
88
+
89
+ function seedDb(dbPath) {
90
+ const db = openDb(dbPath);
91
+ try {
92
+ upsertSession(db, {
93
+ device: 'devbox',
94
+ source: 'Codex CLI',
95
+ sessionId: 'codex:one',
96
+ lastActivity: '2026-06-01T01:00:00.000Z',
97
+ projectPath: 'D:\\HighROIProjects\\TokenStudio',
98
+ inputTokens: 100,
99
+ outputTokens: 30,
100
+ totalTokens: 130,
101
+ costUSD: 0.01
102
+ });
103
+ upsertSession(db, {
104
+ device: 'devbox',
105
+ source: 'Claude Code',
106
+ sessionId: 'claude:two',
107
+ lastActivity: '2026-06-01T02:00:00.000Z',
108
+ projectPath: 'D:\\HighROIProjects\\ManualProject',
109
+ inputTokens: 120,
110
+ outputTokens: 30,
111
+ totalTokens: 150,
112
+ costUSD: 0.02
113
+ });
114
+ upsertSessionAnnotation(db, {
115
+ device: 'devbox',
116
+ source: 'Claude Code',
117
+ sessionId: 'claude:two',
118
+ projectAlias: 'Manual Project',
119
+ taskType: '功能开发'
120
+ });
121
+ } finally {
122
+ db.close();
123
+ }
124
+ }
125
+
126
+ async function waitForApi(port, diagnostics) {
127
+ const start = Date.now();
128
+ let lastError = null;
129
+ while (Date.now() - start < 5000) {
130
+ try {
131
+ await getJson(port, '/api/data');
132
+ return;
133
+ } catch (error) {
134
+ lastError = error;
135
+ await new Promise(resolve => setTimeout(resolve, 100));
136
+ }
137
+ }
138
+ const details = diagnostics ? diagnostics() : {};
139
+ throw new Error(`server did not start in time: ${lastError?.message || 'no response'}\nstdout=${details.stdout || ''}\nstderr=${details.stderr || ''}\nexit=${details.exited ? details.exitCode : 'running'}`);
140
+ }
141
+
142
+ async function getJson(port, path) {
143
+ const response = await fetch(`http://127.0.0.1:${port}${path}`);
144
+ if (!response.ok) assert.fail(await response.text());
145
+ return response.json();
146
+ }
147
+
148
+ async function postJson(port, path, body) {
149
+ const response = await fetch(`http://127.0.0.1:${port}${path}`, {
150
+ method: 'POST',
151
+ headers: { 'Content-Type': 'application/json' },
152
+ body: JSON.stringify(body)
153
+ });
154
+ if (!response.ok) assert.fail(await response.text());
155
+ return response.json();
156
+ }
157
+
158
+ async function assertRejectsWithStatus(responsePromise, expectedStatus) {
159
+ const response = await responsePromise;
160
+ assert.equal(response.status, expectedStatus, await response.text());
161
+ }
162
+
163
+ function stopChild(child) {
164
+ if (child.exitCode != null) return Promise.resolve();
165
+ return new Promise(resolve => {
166
+ child.once('close', resolve);
167
+ child.kill();
168
+ });
169
+ }
@@ -0,0 +1,109 @@
1
+ import assert from 'node:assert/strict';
2
+ import { spawn } from 'node:child_process';
3
+ import { mkdtempSync, rmSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import test from 'node:test';
7
+ import { openDb, recordRun, upsertSession, upsertTokenEvent } from '../src/db.mjs';
8
+
9
+ test('source health API returns safe coverage metadata', async () => {
10
+ const dir = mkdtempSync(join(tmpdir(), 'token-studio-source-health-api-'));
11
+ const dbPath = join(dir, 'usage.sqlite');
12
+ const port = 6400 + Math.floor(Math.random() * 1000);
13
+ seedDb(dbPath);
14
+
15
+ const child = spawn(process.execPath, ['src/server.mjs'], {
16
+ cwd: process.cwd(),
17
+ env: {
18
+ ...process.env,
19
+ PORT: String(port),
20
+ DB_PATH: dbPath,
21
+ SCHEDULED_COLLECT_ENABLED: 'false'
22
+ },
23
+ stdio: ['ignore', 'pipe', 'pipe'],
24
+ windowsHide: true
25
+ });
26
+
27
+ try {
28
+ await waitForApi(port);
29
+ const health = await getJson(port, '/api/source-health');
30
+ const codex = health.sources.find(row => row.id === 'codex');
31
+ assert.equal(codex.health, 'has-data');
32
+ assert.equal(codex.sessions, 1);
33
+ assert.equal(codex.readsConversationContent, false);
34
+
35
+ const ccusage = health.sources.find(row => row.id === 'ccusage');
36
+ assert.equal(ccusage.coverageTier, 'ccusage import-bridge');
37
+ assert.equal(ccusage.tokenEvents, 1);
38
+ assert.match(ccusage.commandHint, /npx token-studio import-usage/);
39
+ assert.doesNotMatch(JSON.stringify(health), /C:\\\\Users|prompt|response|transcript|diff/);
40
+
41
+ const data = await getJson(port, '/api/data');
42
+ assert.ok(data.meta.sourceHealth.some(row => row.id === 'codex'));
43
+ } finally {
44
+ await stopChild(child);
45
+ rmSync(dir, { recursive: true, force: true });
46
+ }
47
+ });
48
+
49
+ function seedDb(dbPath) {
50
+ const db = openDb(dbPath);
51
+ try {
52
+ upsertSession(db, {
53
+ device: 'devbox',
54
+ source: 'Codex CLI',
55
+ sessionId: 'codex-s1',
56
+ lastActivity: '2026-06-17T02:00:00Z',
57
+ model: 'gpt-5.3-codex',
58
+ inputTokens: 100,
59
+ outputTokens: 20,
60
+ totalTokens: 120
61
+ });
62
+ upsertTokenEvent(db, {
63
+ eventId: 'ccusage-import-e1',
64
+ device: 'devbox',
65
+ source: 'import:ccusage-cli',
66
+ sessionId: 'import-s1',
67
+ timestamp: '2026-06-17T03:00:00Z',
68
+ model: 'claude-sonnet-4-5',
69
+ inputTokens: 200,
70
+ outputTokens: 40
71
+ });
72
+ recordRun(db, {
73
+ device: 'devbox',
74
+ source: 'import:ccusage-cli',
75
+ status: 'ok',
76
+ collectedAt: '2026-06-17T03:05:00Z'
77
+ });
78
+ } finally {
79
+ db.close();
80
+ }
81
+ }
82
+
83
+ async function waitForApi(port) {
84
+ const start = Date.now();
85
+ while (Date.now() - start < 5000) {
86
+ try {
87
+ const response = await fetch(`http://127.0.0.1:${port}/api/source-health`);
88
+ if (response.ok) return;
89
+ } catch {
90
+ // Retry while the server starts.
91
+ }
92
+ await new Promise(resolve => setTimeout(resolve, 100));
93
+ }
94
+ throw new Error('server did not start in time');
95
+ }
96
+
97
+ async function getJson(port, path) {
98
+ const response = await fetch(`http://127.0.0.1:${port}${path}`);
99
+ if (!response.ok) assert.fail(await response.text());
100
+ return response.json();
101
+ }
102
+
103
+ function stopChild(child) {
104
+ if (child.exitCode != null) return Promise.resolve();
105
+ return new Promise(resolve => {
106
+ child.once('close', resolve);
107
+ child.kill();
108
+ });
109
+ }