vibe-coding-master 0.7.16 → 0.7.18
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/README.md +19 -2
- package/dist/backend/adapters/claude-adapter.js +4 -1
- package/dist/backend/api/harness-routes.js +21 -4
- package/dist/backend/api/session-routes.js +5 -0
- package/dist/backend/api/translation-worker-routes.js +15 -2
- package/dist/backend/api/usage-analytics-routes.js +31 -0
- package/dist/backend/cli/install-vcm-harness.js +54 -5
- package/dist/backend/server.js +27 -5
- package/dist/backend/services/app-settings-service.js +26 -2
- package/dist/backend/services/architect-restart-service.js +136 -0
- package/dist/backend/services/claude-hook-service.js +134 -54
- package/dist/backend/services/gate-review-service.js +92 -27
- package/dist/backend/services/harness-service.js +54 -7
- package/dist/backend/services/message-service.js +6 -1
- package/dist/backend/services/runtime-coordinator-service.js +10 -10
- package/dist/backend/services/session-service.js +78 -25
- package/dist/backend/services/task-close-service.js +1 -0
- package/dist/backend/services/terminal-interrupt-service.js +4 -1
- package/dist/backend/services/turn-reconciler-service.js +1 -0
- package/dist/backend/services/usage-analytics-service.js +346 -0
- package/dist/backend/templates/handoff.js +4 -0
- package/dist/backend/templates/harness/architect-agent.js +18 -10
- package/dist/backend/templates/harness/architect-scaffold-worker-agent.js +23 -0
- package/dist/backend/templates/harness/claude-root.js +1 -1
- package/dist/backend/templates/harness/gate-review.js +25 -3
- package/dist/backend/templates/harness/project-manager-agent.js +10 -2
- package/dist/backend/templates/harness/restart-architect-skill.js +75 -0
- package/dist/backend/templates/harness/tester-agent.js +15 -7
- package/dist/backend/templates/harness/vcm-architecture-interview-skill.js +31 -5
- package/dist/backend/templates/harness/vcm-route-message-skill.js +3 -3
- package/dist/shared/types/app-settings.js +14 -0
- package/dist/shared/types/usage-analytics.js +1 -0
- package/dist/shared/validation/artifact-check.js +29 -3
- package/dist-frontend/assets/{index-BAE_pjXJ.js → index-CDkDHrWQ.js} +43 -43
- package/dist-frontend/assets/{index-CiEUp9Si.css → index-Ci7z8tW3.css} +1 -1
- package/dist-frontend/index.html +2 -2
- package/package.json +1 -1
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
1
2
|
import path from "node:path";
|
|
2
3
|
import { ROLE_NAMES, isDispatchableRole } from "../../shared/constants.js";
|
|
3
4
|
import { CCR_GPT_SESSION_MODEL, isCcrSessionModel } from "../../shared/types/session.js";
|
|
@@ -17,6 +18,7 @@ const PROJECT_TRANSLATOR_SCOPE = "__project__";
|
|
|
17
18
|
const PROJECT_HARNESS_ENGINEER_SCOPE = "__project_harness_engineer__";
|
|
18
19
|
const PROJECT_TOOL_CD_ENTER_DELAY_MS = 500;
|
|
19
20
|
const CLAUDE_CODE_DISABLE_AUTO_MEMORY = "1";
|
|
21
|
+
const CLAUDE_CODE_DISABLE_BACKGROUND_TASKS = "1";
|
|
20
22
|
// Project tool sessions launch a Claude Code TUI inside a PTY. The PTY reports
|
|
21
23
|
// "running" the instant it is spawned, which is earlier than the moment the TUI
|
|
22
24
|
// can actually accept pasted input. These bounds drive a quiescence-based
|
|
@@ -80,9 +82,10 @@ export function createSessionService(deps) {
|
|
|
80
82
|
? claudeTranscriptPath(taskRepoRoot, resumeClaudeSessionId, persisted?.claudeConfigDir)
|
|
81
83
|
: undefined;
|
|
82
84
|
const startCommand = {
|
|
83
|
-
...deps.claude.buildRoleStartCommand(role, config.claudeCommand, permissionMode, resumeClaudeSessionId, launchMode === "resume", model, effort, modelSettingsOverride),
|
|
85
|
+
...deps.claude.buildRoleStartCommand(role, config.claudeCommand, permissionMode, resumeClaudeSessionId, launchMode === "resume", model, effort, modelSettingsOverride, input.appendSystemPrompt),
|
|
84
86
|
cwd: taskRepoRoot
|
|
85
87
|
};
|
|
88
|
+
const runtimeSessionToken = randomUUID();
|
|
86
89
|
const runtimeSession = await deps.runtime.createSession({
|
|
87
90
|
repoRoot,
|
|
88
91
|
taskSlug,
|
|
@@ -96,8 +99,9 @@ export function createSessionService(deps) {
|
|
|
96
99
|
VCM_TASK_REPO_ROOT: taskRepoRoot,
|
|
97
100
|
VCM_TASK_SLUG: taskSlug,
|
|
98
101
|
VCM_ROLE: role,
|
|
99
|
-
VCM_SESSION_ID: claudeSessionId || undefined
|
|
100
|
-
|
|
102
|
+
VCM_SESSION_ID: claudeSessionId || undefined,
|
|
103
|
+
VCM_RUNTIME_SESSION_TOKEN: runtimeSessionToken
|
|
104
|
+
}, modelEnvironment, buildUsageTelemetryEnvironment(deps.apiUrl, role, model)),
|
|
101
105
|
cols: input.cols,
|
|
102
106
|
rows: input.rows
|
|
103
107
|
});
|
|
@@ -105,6 +109,7 @@ export function createSessionService(deps) {
|
|
|
105
109
|
const harnessRevision = await readCurrentHarnessRevision(repoRoot);
|
|
106
110
|
const record = {
|
|
107
111
|
id: runtimeSession.id,
|
|
112
|
+
runtimeSessionToken,
|
|
108
113
|
claudeSessionId,
|
|
109
114
|
transcriptPath,
|
|
110
115
|
taskSlug,
|
|
@@ -207,6 +212,7 @@ export function createSessionService(deps) {
|
|
|
207
212
|
...deps.claude.buildRoleStartCommand(TRANSLATOR_ROLE, config.claudeCommand, permissionMode, resumeClaudeSessionId, launchMode === "resume", model, effort, modelSettingsOverride),
|
|
208
213
|
cwd: launchCwd
|
|
209
214
|
};
|
|
215
|
+
const runtimeSessionToken = randomUUID();
|
|
210
216
|
const runtimeSession = await deps.runtime.createSession({
|
|
211
217
|
repoRoot,
|
|
212
218
|
taskSlug: PROJECT_TRANSLATOR_SCOPE,
|
|
@@ -223,8 +229,9 @@ export function createSessionService(deps) {
|
|
|
223
229
|
// active task; VCM_TASK_REPO_ROOT remains the active worktree.
|
|
224
230
|
VCM_TASK_SLUG: PROJECT_TRANSLATOR_SCOPE,
|
|
225
231
|
VCM_ROLE: TRANSLATOR_ROLE,
|
|
226
|
-
VCM_SESSION_ID: claudeSessionId || undefined
|
|
227
|
-
|
|
232
|
+
VCM_SESSION_ID: claudeSessionId || undefined,
|
|
233
|
+
VCM_RUNTIME_SESSION_TOKEN: runtimeSessionToken
|
|
234
|
+
}, modelEnvironment, buildUsageTelemetryEnvironment(deps.apiUrl, TRANSLATOR_ROLE, model)),
|
|
228
235
|
cols: input.cols,
|
|
229
236
|
rows: input.rows
|
|
230
237
|
});
|
|
@@ -232,6 +239,7 @@ export function createSessionService(deps) {
|
|
|
232
239
|
const harnessRevision = await readCurrentHarnessRevision(repoRoot);
|
|
233
240
|
const record = {
|
|
234
241
|
id: runtimeSession.id,
|
|
242
|
+
runtimeSessionToken,
|
|
235
243
|
claudeSessionId,
|
|
236
244
|
transcriptPath,
|
|
237
245
|
taskSlug: PROJECT_TRANSLATOR_SCOPE,
|
|
@@ -320,6 +328,7 @@ export function createSessionService(deps) {
|
|
|
320
328
|
...deps.claude.buildRoleStartCommand(HARNESS_ENGINEER_ROLE, config.claudeCommand, permissionMode, resumeClaudeSessionId, launchMode === "resume", model, effort, modelSettingsOverride),
|
|
321
329
|
cwd: launchCwd
|
|
322
330
|
};
|
|
331
|
+
const runtimeSessionToken = randomUUID();
|
|
323
332
|
const runtimeSession = await deps.runtime.createSession({
|
|
324
333
|
repoRoot,
|
|
325
334
|
taskSlug: PROJECT_HARNESS_ENGINEER_SCOPE,
|
|
@@ -336,8 +345,9 @@ export function createSessionService(deps) {
|
|
|
336
345
|
// active task; VCM_TASK_REPO_ROOT remains the active worktree.
|
|
337
346
|
VCM_TASK_SLUG: PROJECT_HARNESS_ENGINEER_SCOPE,
|
|
338
347
|
VCM_ROLE: HARNESS_ENGINEER_ROLE,
|
|
339
|
-
VCM_SESSION_ID: claudeSessionId || undefined
|
|
340
|
-
|
|
348
|
+
VCM_SESSION_ID: claudeSessionId || undefined,
|
|
349
|
+
VCM_RUNTIME_SESSION_TOKEN: runtimeSessionToken
|
|
350
|
+
}, modelEnvironment, buildUsageTelemetryEnvironment(deps.apiUrl, HARNESS_ENGINEER_ROLE, model)),
|
|
341
351
|
cols: input.cols,
|
|
342
352
|
rows: input.rows
|
|
343
353
|
});
|
|
@@ -345,6 +355,7 @@ export function createSessionService(deps) {
|
|
|
345
355
|
const harnessRevision = await readCurrentHarnessRevision(repoRoot);
|
|
346
356
|
const record = {
|
|
347
357
|
id: runtimeSession.id,
|
|
358
|
+
runtimeSessionToken,
|
|
348
359
|
claudeSessionId,
|
|
349
360
|
transcriptPath,
|
|
350
361
|
taskSlug: PROJECT_HARNESS_ENGINEER_SCOPE,
|
|
@@ -515,6 +526,7 @@ export function createSessionService(deps) {
|
|
|
515
526
|
...deps.claude.buildRoleStartCommand(session.role, config.claudeCommand, permissionMode, session.claudeSessionId, true, model, effort, modelSettingsOverride),
|
|
516
527
|
cwd: launchCwd
|
|
517
528
|
};
|
|
529
|
+
const runtimeSessionToken = randomUUID();
|
|
518
530
|
const runtimeSession = await deps.runtime.createSession({
|
|
519
531
|
repoRoot,
|
|
520
532
|
taskSlug: normalizeProjectScopedRecordForPersistence(session).taskSlug,
|
|
@@ -528,14 +540,16 @@ export function createSessionService(deps) {
|
|
|
528
540
|
VCM_TASK_REPO_ROOT: targetCwd,
|
|
529
541
|
VCM_TASK_SLUG: normalizeProjectScopedRecordForPersistence(session).taskSlug,
|
|
530
542
|
VCM_ROLE: session.role,
|
|
531
|
-
VCM_SESSION_ID: session.claudeSessionId
|
|
532
|
-
|
|
543
|
+
VCM_SESSION_ID: session.claudeSessionId,
|
|
544
|
+
VCM_RUNTIME_SESSION_TOKEN: runtimeSessionToken
|
|
545
|
+
}, modelEnvironment, buildUsageTelemetryEnvironment(deps.apiUrl, session.role, model))
|
|
533
546
|
});
|
|
534
547
|
if ((await waitForSessionInputReady(runtimeSession.id)) === "exited") {
|
|
535
548
|
deps.registry.remove(runtimeSession.id);
|
|
536
549
|
return markProjectToolRuntimeUnavailable(repoRoot, {
|
|
537
550
|
...session,
|
|
538
551
|
id: runtimeSession.id,
|
|
552
|
+
runtimeSessionToken,
|
|
539
553
|
status: "crashed",
|
|
540
554
|
activityStatus: "idle",
|
|
541
555
|
command: startCommand.display,
|
|
@@ -553,6 +567,7 @@ export function createSessionService(deps) {
|
|
|
553
567
|
const resumed = {
|
|
554
568
|
...session,
|
|
555
569
|
id: runtimeSession.id,
|
|
570
|
+
runtimeSessionToken,
|
|
556
571
|
status: runtimeSession.status,
|
|
557
572
|
activityStatus: "idle",
|
|
558
573
|
command: startCommand.display,
|
|
@@ -679,9 +694,9 @@ export function createSessionService(deps) {
|
|
|
679
694
|
const view = toRoleSessionRecordView(record, deps.runtime);
|
|
680
695
|
return view ? withHarnessRevisionView(repoRoot, view) : undefined;
|
|
681
696
|
}
|
|
682
|
-
async function markTaskRoleActivityIdle(repoRoot, taskSlug, role) {
|
|
697
|
+
async function markTaskRoleActivityIdle(repoRoot, taskSlug, role, expectedSessionId) {
|
|
683
698
|
const current = await getTaskRoleSessionView(repoRoot, taskSlug, role);
|
|
684
|
-
if (!current) {
|
|
699
|
+
if (!current || (expectedSessionId && current.id !== expectedSessionId)) {
|
|
685
700
|
return undefined;
|
|
686
701
|
}
|
|
687
702
|
const timestamp = now();
|
|
@@ -794,7 +809,7 @@ export function createSessionService(deps) {
|
|
|
794
809
|
},
|
|
795
810
|
async recordProjectTranslatorHookEvent(repoRoot, input) {
|
|
796
811
|
const current = await this.getProjectTranslatorSession(repoRoot);
|
|
797
|
-
if (!current) {
|
|
812
|
+
if (!current || !matchesRoleHookSession(current, input)) {
|
|
798
813
|
return undefined;
|
|
799
814
|
}
|
|
800
815
|
const timestamp = now();
|
|
@@ -920,7 +935,7 @@ export function createSessionService(deps) {
|
|
|
920
935
|
},
|
|
921
936
|
async recordProjectHarnessEngineerHookEvent(repoRoot, input) {
|
|
922
937
|
const current = await this.getProjectHarnessEngineerSession(repoRoot);
|
|
923
|
-
if (!current) {
|
|
938
|
+
if (!current || !matchesRoleHookSession(current, input)) {
|
|
924
939
|
return undefined;
|
|
925
940
|
}
|
|
926
941
|
const timestamp = now();
|
|
@@ -1039,7 +1054,7 @@ export function createSessionService(deps) {
|
|
|
1039
1054
|
},
|
|
1040
1055
|
async recordRoleHookEvent(repoRoot, input) {
|
|
1041
1056
|
const current = await this.getRoleSession(repoRoot, input.taskSlug, input.role);
|
|
1042
|
-
if (!current ||
|
|
1057
|
+
if (!current || !matchesRoleHookSession(current, input)) {
|
|
1043
1058
|
return undefined;
|
|
1044
1059
|
}
|
|
1045
1060
|
const timestamp = now();
|
|
@@ -1071,7 +1086,9 @@ export function createSessionService(deps) {
|
|
|
1071
1086
|
eventName: input.eventName,
|
|
1072
1087
|
sessionId: input.claudeSessionId,
|
|
1073
1088
|
transcriptPath: input.transcriptPath,
|
|
1074
|
-
cwd: input.cwd
|
|
1089
|
+
cwd: input.cwd,
|
|
1090
|
+
runtimeSessionId: input.runtimeSessionId,
|
|
1091
|
+
runtimeSessionToken: input.runtimeSessionToken
|
|
1075
1092
|
});
|
|
1076
1093
|
},
|
|
1077
1094
|
async markTerminalSessionActivityIdle(repoRoot, sessionId) {
|
|
@@ -1094,11 +1111,11 @@ export function createSessionService(deps) {
|
|
|
1094
1111
|
? markProjectToolActivityIdle(repoRoot, current, persistHarnessEngineerSession)
|
|
1095
1112
|
: undefined;
|
|
1096
1113
|
}
|
|
1097
|
-
return markTaskRoleActivityIdle(repoRoot, taskSlug, role);
|
|
1114
|
+
return markTaskRoleActivityIdle(repoRoot, taskSlug, role, sessionId);
|
|
1098
1115
|
},
|
|
1099
|
-
async markRoleActivityRunning(repoRoot, taskSlug, role) {
|
|
1116
|
+
async markRoleActivityRunning(repoRoot, taskSlug, role, expectedSessionId) {
|
|
1100
1117
|
const current = await this.getRoleSession(repoRoot, taskSlug, role);
|
|
1101
|
-
if (!current) {
|
|
1118
|
+
if (!current || (expectedSessionId && current.id !== expectedSessionId)) {
|
|
1102
1119
|
return undefined;
|
|
1103
1120
|
}
|
|
1104
1121
|
const timestamp = now();
|
|
@@ -1154,7 +1171,18 @@ function toRoleSessionRecordView(record, runtime) {
|
|
|
1154
1171
|
exitCode: runtimeSession.exitCode
|
|
1155
1172
|
};
|
|
1156
1173
|
}
|
|
1157
|
-
function matchesRoleHookSession(record, input) {
|
|
1174
|
+
export function matchesRoleHookSession(record, input) {
|
|
1175
|
+
if (!record.claudeSessionId
|
|
1176
|
+
&& !record.transcriptPath
|
|
1177
|
+
&& input.eventName !== "UserPromptSubmit") {
|
|
1178
|
+
return false;
|
|
1179
|
+
}
|
|
1180
|
+
if (input.runtimeSessionId) {
|
|
1181
|
+
return record.id === input.runtimeSessionId;
|
|
1182
|
+
}
|
|
1183
|
+
if (record.runtimeSessionToken) {
|
|
1184
|
+
return record.runtimeSessionToken === input.runtimeSessionToken;
|
|
1185
|
+
}
|
|
1158
1186
|
if (!record.claudeSessionId && !record.transcriptPath) {
|
|
1159
1187
|
return input.eventName === "UserPromptSubmit";
|
|
1160
1188
|
}
|
|
@@ -1164,9 +1192,6 @@ function matchesRoleHookSession(record, input) {
|
|
|
1164
1192
|
if (input.transcriptPath && record.transcriptPath === input.transcriptPath) {
|
|
1165
1193
|
return true;
|
|
1166
1194
|
}
|
|
1167
|
-
if (!input.sessionId && !input.transcriptPath) {
|
|
1168
|
-
return true;
|
|
1169
|
-
}
|
|
1170
1195
|
return false;
|
|
1171
1196
|
}
|
|
1172
1197
|
function nextHookSessionIdentity(current, input) {
|
|
@@ -1208,7 +1233,7 @@ function isSessionMissingError(error) {
|
|
|
1208
1233
|
error.code === "SESSION_MISSING";
|
|
1209
1234
|
}
|
|
1210
1235
|
function withoutRuntimeOnlySessionFields(session) {
|
|
1211
|
-
const { pid: _pid, ...persisted } = session;
|
|
1236
|
+
const { pid: _pid, runtimeSessionToken: _runtimeSessionToken, ...persisted } = session;
|
|
1212
1237
|
return persisted;
|
|
1213
1238
|
}
|
|
1214
1239
|
function defaultIsProcessAlive(pid) {
|
|
@@ -1536,11 +1561,39 @@ function formatClaudeCdCommand(targetCwd) {
|
|
|
1536
1561
|
function isExitedStatus(status) {
|
|
1537
1562
|
return status === "exited" || status === "crashed" || status === "missing";
|
|
1538
1563
|
}
|
|
1539
|
-
function withClaudeCodeRuntimeEnv(env, modelEnvironment = {}) {
|
|
1564
|
+
function withClaudeCodeRuntimeEnv(env, modelEnvironment = {}, telemetryEnvironment = {}) {
|
|
1540
1565
|
return {
|
|
1541
1566
|
...env,
|
|
1542
1567
|
...modelEnvironment,
|
|
1543
|
-
|
|
1568
|
+
...telemetryEnvironment,
|
|
1569
|
+
CLAUDE_CODE_DISABLE_AUTO_MEMORY,
|
|
1570
|
+
CLAUDE_CODE_DISABLE_BACKGROUND_TASKS
|
|
1571
|
+
};
|
|
1572
|
+
}
|
|
1573
|
+
function buildUsageTelemetryEnvironment(apiUrl, role, model) {
|
|
1574
|
+
const disabled = {
|
|
1575
|
+
CLAUDE_CODE_ENABLE_TELEMETRY: undefined,
|
|
1576
|
+
OTEL_LOGS_EXPORTER: "none",
|
|
1577
|
+
OTEL_METRICS_EXPORTER: "none",
|
|
1578
|
+
OTEL_TRACES_EXPORTER: "none",
|
|
1579
|
+
OTEL_EXPORTER_OTLP_LOGS_PROTOCOL: undefined,
|
|
1580
|
+
OTEL_EXPORTER_OTLP_LOGS_ENDPOINT: undefined,
|
|
1581
|
+
OTEL_RESOURCE_ATTRIBUTES: undefined,
|
|
1582
|
+
OTEL_LOG_USER_PROMPTS: "0",
|
|
1583
|
+
OTEL_LOG_ASSISTANT_RESPONSES: "0",
|
|
1584
|
+
OTEL_LOG_TOOL_DETAILS: "0",
|
|
1585
|
+
OTEL_LOG_RAW_API_BODIES: "0"
|
|
1586
|
+
};
|
|
1587
|
+
if (!apiUrl || isCcrSessionModel(model)) {
|
|
1588
|
+
return disabled;
|
|
1589
|
+
}
|
|
1590
|
+
return {
|
|
1591
|
+
...disabled,
|
|
1592
|
+
CLAUDE_CODE_ENABLE_TELEMETRY: "1",
|
|
1593
|
+
OTEL_LOGS_EXPORTER: "otlp",
|
|
1594
|
+
OTEL_EXPORTER_OTLP_LOGS_PROTOCOL: "http/json",
|
|
1595
|
+
OTEL_EXPORTER_OTLP_LOGS_ENDPOINT: `${apiUrl.replace(/\/+$/, "")}/api/telemetry/v1/logs`,
|
|
1596
|
+
OTEL_RESOURCE_ATTRIBUTES: `vcm.role=${role},vcm.launch_id=${randomUUID()}`
|
|
1544
1597
|
};
|
|
1545
1598
|
}
|
|
1546
1599
|
function delay(ms) {
|
|
@@ -5,6 +5,7 @@ export function createTaskCloseService(deps) {
|
|
|
5
5
|
async closeTask(repoRoot, taskSlug) {
|
|
6
6
|
const task = await deps.taskService.markTaskCleaned(repoRoot, taskSlug);
|
|
7
7
|
const warnings = [];
|
|
8
|
+
deps.architectRestartService?.clear(repoRoot, taskSlug);
|
|
8
9
|
await stopTaskRoleSessions(repoRoot, taskSlug, warnings);
|
|
9
10
|
await bestEffort("Unable to stop task translation runtime", () => deps.translationService.stopTask(getTaskRuntimeRepoRoot(task), taskSlug, { clearCache: true }), warnings);
|
|
10
11
|
await bestEffort("Unable to clear task round runtime", () => deps.roundService.stopTask(taskSlug), warnings);
|
|
@@ -11,7 +11,10 @@ export function createTerminalInterruptService(deps) {
|
|
|
11
11
|
if (!repoRoot) {
|
|
12
12
|
return;
|
|
13
13
|
}
|
|
14
|
-
await deps.sessionService.markTerminalSessionActivityIdle(repoRoot, sessionId);
|
|
14
|
+
const session = await deps.sessionService.markTerminalSessionActivityIdle(repoRoot, sessionId);
|
|
15
|
+
if (!session) {
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
15
18
|
if (!isVcmRoleName(terminalSession.role)) {
|
|
16
19
|
return;
|
|
17
20
|
}
|
|
@@ -96,6 +96,7 @@ function buildReconciledHook(taskSlug, role, session, eventName, evidence) {
|
|
|
96
96
|
...(session?.claudeSessionId ? { session_id: session.claudeSessionId } : {}),
|
|
97
97
|
...(session?.transcriptPath ? { transcript_path: session.transcriptPath } : {}),
|
|
98
98
|
...(session?.cwd ? { cwd: session.cwd } : {}),
|
|
99
|
+
...(session?.id ? { vcm_runtime_session_id: session.id } : {}),
|
|
99
100
|
vcm_reconciled: true,
|
|
100
101
|
...evidence
|
|
101
102
|
}
|
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { ROLE_NAMES, isRoleName } from "../../shared/constants.js";
|
|
3
|
+
const USAGE_FILE = path.join("telemetry", "usage.json");
|
|
4
|
+
export function createUsageAnalyticsService(deps) {
|
|
5
|
+
const now = deps.now ?? (() => new Date().toISOString());
|
|
6
|
+
const writeQueues = new Map();
|
|
7
|
+
return {
|
|
8
|
+
async ingest(taskRepoRoot, stateRoot, payload) {
|
|
9
|
+
const events = extractApiRequestEvents(payload);
|
|
10
|
+
if (events.length === 0) {
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
13
|
+
const usagePath = getUsagePath(taskRepoRoot, stateRoot);
|
|
14
|
+
await enqueueWrite(writeQueues, usagePath, async () => {
|
|
15
|
+
const state = await loadState(deps.fs, usagePath);
|
|
16
|
+
const seen = new Set(state.seenEvents);
|
|
17
|
+
let changed = false;
|
|
18
|
+
for (const event of events) {
|
|
19
|
+
if (seen.has(event.eventKey)) {
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
seen.add(event.eventKey);
|
|
23
|
+
state.seenEvents.push(event.eventKey);
|
|
24
|
+
addEvent(state, event);
|
|
25
|
+
changed = true;
|
|
26
|
+
}
|
|
27
|
+
if (!changed) {
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
state.updatedAt = now();
|
|
31
|
+
await deps.fs.writeJsonAtomic(usagePath, state);
|
|
32
|
+
});
|
|
33
|
+
},
|
|
34
|
+
async getReport(taskRepoRoot, stateRoot, taskSlug) {
|
|
35
|
+
const usagePath = getUsagePath(taskRepoRoot, stateRoot);
|
|
36
|
+
await writeQueues.get(usagePath);
|
|
37
|
+
if (!(await deps.fs.pathExists(usagePath))) {
|
|
38
|
+
return emptyReport(taskSlug);
|
|
39
|
+
}
|
|
40
|
+
const state = normalizeStoredState(await deps.fs.readJson(usagePath));
|
|
41
|
+
return toReport(taskSlug, state);
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
function extractApiRequestEvents(payload) {
|
|
46
|
+
if (!isObject(payload) || !Array.isArray(payload.resourceLogs)) {
|
|
47
|
+
return [];
|
|
48
|
+
}
|
|
49
|
+
const events = [];
|
|
50
|
+
for (const resourceLog of payload.resourceLogs) {
|
|
51
|
+
if (!isObject(resourceLog)) {
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
const resourceAttributes = readAttributes(isObject(resourceLog.resource) ? resourceLog.resource.attributes : undefined);
|
|
55
|
+
if (!Array.isArray(resourceLog.scopeLogs)) {
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
for (const scopeLog of resourceLog.scopeLogs) {
|
|
59
|
+
if (!isObject(scopeLog) || !Array.isArray(scopeLog.logRecords)) {
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
for (const logRecord of scopeLog.logRecords) {
|
|
63
|
+
if (!isObject(logRecord)) {
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
const attributes = {
|
|
67
|
+
...resourceAttributes,
|
|
68
|
+
...readAttributes(logRecord.attributes)
|
|
69
|
+
};
|
|
70
|
+
if (readString(attributes["event.name"]) !== "api_request") {
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
const roleValue = readString(attributes["vcm.role"]);
|
|
74
|
+
const launchId = readString(attributes["vcm.launch_id"]);
|
|
75
|
+
if (!roleValue || !isRoleName(roleValue) || !launchId) {
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
const model = normalizeModel(readString(attributes.model));
|
|
79
|
+
if (isCcrModel(model)) {
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
const sequence = readInteger(attributes["event.sequence"]);
|
|
83
|
+
const sessionId = readString(attributes["session.id"]) || launchId;
|
|
84
|
+
const requestId = readString(attributes.request_id);
|
|
85
|
+
const timestamp = readString(logRecord.timeUnixNano) || readString(logRecord.observedTimeUnixNano);
|
|
86
|
+
const fallbackIdentity = [sessionId, requestId, timestamp, model].join(":");
|
|
87
|
+
const eventKey = `${launchId}:${sequence ?? fallbackIdentity}`;
|
|
88
|
+
const costUsdMicros = readNonNegativeInteger(attributes.cost_usd_micros)
|
|
89
|
+
?? Math.round((readNonNegativeNumber(attributes.cost_usd) ?? 0) * 1_000_000);
|
|
90
|
+
events.push({
|
|
91
|
+
eventKey,
|
|
92
|
+
role: roleValue,
|
|
93
|
+
model,
|
|
94
|
+
sessionId,
|
|
95
|
+
inputTokens: readNonNegativeInteger(attributes.input_tokens) ?? 0,
|
|
96
|
+
outputTokens: readNonNegativeInteger(attributes.output_tokens) ?? 0,
|
|
97
|
+
cacheReadTokens: readNonNegativeInteger(attributes.cache_read_tokens) ?? 0,
|
|
98
|
+
cacheCreationTokens: readNonNegativeInteger(attributes.cache_creation_tokens) ?? 0,
|
|
99
|
+
costUsdMicros
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return events;
|
|
105
|
+
}
|
|
106
|
+
function addEvent(state, event) {
|
|
107
|
+
addTotals(state.totals, event);
|
|
108
|
+
state.byRole[event.role] ??= emptyStoredTotals();
|
|
109
|
+
addTotals(state.byRole[event.role], event);
|
|
110
|
+
state.byModel[event.model] ??= emptyStoredTotals();
|
|
111
|
+
addTotals(state.byModel[event.model], event);
|
|
112
|
+
addUnique(state.sessionsByRole, event.role, event.sessionId);
|
|
113
|
+
addUnique(state.sessionsByModel, event.model, event.sessionId);
|
|
114
|
+
}
|
|
115
|
+
function addTotals(target, event) {
|
|
116
|
+
target.inputTokens += event.inputTokens;
|
|
117
|
+
target.outputTokens += event.outputTokens;
|
|
118
|
+
target.cacheReadTokens += event.cacheReadTokens;
|
|
119
|
+
target.cacheCreationTokens += event.cacheCreationTokens;
|
|
120
|
+
target.costUsdMicros += event.costUsdMicros;
|
|
121
|
+
target.requestCount += 1;
|
|
122
|
+
}
|
|
123
|
+
function addUnique(target, key, value) {
|
|
124
|
+
const values = target[key] ?? [];
|
|
125
|
+
if (!values.includes(value)) {
|
|
126
|
+
values.push(value);
|
|
127
|
+
}
|
|
128
|
+
target[key] = values;
|
|
129
|
+
}
|
|
130
|
+
async function loadState(fs, usagePath) {
|
|
131
|
+
if (!(await fs.pathExists(usagePath))) {
|
|
132
|
+
return emptyStoredState();
|
|
133
|
+
}
|
|
134
|
+
return normalizeStoredState(await fs.readJson(usagePath));
|
|
135
|
+
}
|
|
136
|
+
function normalizeStoredState(input) {
|
|
137
|
+
return {
|
|
138
|
+
version: 1,
|
|
139
|
+
updatedAt: typeof input.updatedAt === "string" ? input.updatedAt : new Date(0).toISOString(),
|
|
140
|
+
seenEvents: Array.isArray(input.seenEvents) ? input.seenEvents.filter((value) => typeof value === "string") : [],
|
|
141
|
+
sessionsByRole: normalizeRoleStringLists(input.sessionsByRole),
|
|
142
|
+
sessionsByModel: normalizeStringLists(input.sessionsByModel),
|
|
143
|
+
totals: normalizeStoredTotals(input.totals),
|
|
144
|
+
byRole: normalizeRoleTotals(input.byRole),
|
|
145
|
+
byModel: normalizeModelTotals(input.byModel)
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
function normalizeRoleStringLists(input) {
|
|
149
|
+
if (!isObject(input)) {
|
|
150
|
+
return {};
|
|
151
|
+
}
|
|
152
|
+
const result = {};
|
|
153
|
+
for (const role of ROLE_NAMES) {
|
|
154
|
+
const values = input[role];
|
|
155
|
+
if (Array.isArray(values)) {
|
|
156
|
+
result[role] = values.filter((value) => typeof value === "string");
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return result;
|
|
160
|
+
}
|
|
161
|
+
function normalizeStringLists(input) {
|
|
162
|
+
const result = {};
|
|
163
|
+
if (!isObject(input)) {
|
|
164
|
+
return result;
|
|
165
|
+
}
|
|
166
|
+
for (const [key, values] of Object.entries(input)) {
|
|
167
|
+
if (isSafeGroupKey(key) && Array.isArray(values)) {
|
|
168
|
+
result[key] = values.filter((value) => typeof value === "string");
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return result;
|
|
172
|
+
}
|
|
173
|
+
function normalizeRoleTotals(input) {
|
|
174
|
+
if (!isObject(input)) {
|
|
175
|
+
return {};
|
|
176
|
+
}
|
|
177
|
+
const result = {};
|
|
178
|
+
for (const role of ROLE_NAMES) {
|
|
179
|
+
if (isObject(input[role])) {
|
|
180
|
+
result[role] = normalizeStoredTotals(input[role]);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return result;
|
|
184
|
+
}
|
|
185
|
+
function normalizeModelTotals(input) {
|
|
186
|
+
const result = {};
|
|
187
|
+
if (!isObject(input)) {
|
|
188
|
+
return result;
|
|
189
|
+
}
|
|
190
|
+
for (const [key, totals] of Object.entries(input)) {
|
|
191
|
+
if (isSafeGroupKey(key) && isObject(totals)) {
|
|
192
|
+
result[key] = normalizeStoredTotals(totals);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return result;
|
|
196
|
+
}
|
|
197
|
+
function normalizeStoredTotals(input) {
|
|
198
|
+
const value = isObject(input) ? input : {};
|
|
199
|
+
return {
|
|
200
|
+
inputTokens: readNonNegativeInteger(value.inputTokens) ?? 0,
|
|
201
|
+
outputTokens: readNonNegativeInteger(value.outputTokens) ?? 0,
|
|
202
|
+
cacheReadTokens: readNonNegativeInteger(value.cacheReadTokens) ?? 0,
|
|
203
|
+
cacheCreationTokens: readNonNegativeInteger(value.cacheCreationTokens) ?? 0,
|
|
204
|
+
costUsdMicros: readNonNegativeInteger(value.costUsdMicros) ?? 0,
|
|
205
|
+
requestCount: readNonNegativeInteger(value.requestCount) ?? 0
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
function toReport(taskSlug, state) {
|
|
209
|
+
const allSessions = new Set(Object.values(state.sessionsByRole).flatMap((values) => values ?? []));
|
|
210
|
+
const byRole = ROLE_NAMES.map((role) => ({
|
|
211
|
+
role,
|
|
212
|
+
...toPublicTotals(state.byRole[role] ?? emptyStoredTotals(), state.sessionsByRole[role]?.length ?? 0)
|
|
213
|
+
}));
|
|
214
|
+
const byModel = Object.keys(state.byModel)
|
|
215
|
+
.sort((left, right) => left.localeCompare(right))
|
|
216
|
+
.map((model) => ({
|
|
217
|
+
model,
|
|
218
|
+
...toPublicTotals(state.byModel[model], state.sessionsByModel[model]?.length ?? 0)
|
|
219
|
+
}));
|
|
220
|
+
return {
|
|
221
|
+
version: 1,
|
|
222
|
+
taskSlug,
|
|
223
|
+
updatedAt: state.updatedAt,
|
|
224
|
+
totals: toPublicTotals(state.totals, allSessions.size),
|
|
225
|
+
byRole,
|
|
226
|
+
byModel
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
function emptyReport(taskSlug) {
|
|
230
|
+
return {
|
|
231
|
+
version: 1,
|
|
232
|
+
taskSlug,
|
|
233
|
+
updatedAt: null,
|
|
234
|
+
totals: toPublicTotals(emptyStoredTotals(), 0),
|
|
235
|
+
byRole: ROLE_NAMES.map((role) => ({ role, ...toPublicTotals(emptyStoredTotals(), 0) })),
|
|
236
|
+
byModel: []
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
function toPublicTotals(totals, sessionCount) {
|
|
240
|
+
return {
|
|
241
|
+
inputTokens: totals.inputTokens,
|
|
242
|
+
outputTokens: totals.outputTokens,
|
|
243
|
+
cacheReadTokens: totals.cacheReadTokens,
|
|
244
|
+
cacheCreationTokens: totals.cacheCreationTokens,
|
|
245
|
+
costUsd: totals.costUsdMicros / 1_000_000,
|
|
246
|
+
requestCount: totals.requestCount,
|
|
247
|
+
sessionCount
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
function emptyStoredState() {
|
|
251
|
+
return {
|
|
252
|
+
version: 1,
|
|
253
|
+
updatedAt: new Date(0).toISOString(),
|
|
254
|
+
seenEvents: [],
|
|
255
|
+
sessionsByRole: {},
|
|
256
|
+
sessionsByModel: {},
|
|
257
|
+
totals: emptyStoredTotals(),
|
|
258
|
+
byRole: {},
|
|
259
|
+
byModel: {}
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
function emptyStoredTotals() {
|
|
263
|
+
return {
|
|
264
|
+
inputTokens: 0,
|
|
265
|
+
outputTokens: 0,
|
|
266
|
+
cacheReadTokens: 0,
|
|
267
|
+
cacheCreationTokens: 0,
|
|
268
|
+
costUsdMicros: 0,
|
|
269
|
+
requestCount: 0
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
function getUsagePath(taskRepoRoot, stateRoot) {
|
|
273
|
+
return path.join(taskRepoRoot, stateRoot, USAGE_FILE);
|
|
274
|
+
}
|
|
275
|
+
async function enqueueWrite(queues, key, operation) {
|
|
276
|
+
const previous = queues.get(key) ?? Promise.resolve();
|
|
277
|
+
const current = previous.catch(() => undefined).then(operation);
|
|
278
|
+
queues.set(key, current);
|
|
279
|
+
try {
|
|
280
|
+
await current;
|
|
281
|
+
}
|
|
282
|
+
finally {
|
|
283
|
+
if (queues.get(key) === current) {
|
|
284
|
+
queues.delete(key);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
function readAttributes(input) {
|
|
289
|
+
const result = {};
|
|
290
|
+
if (!Array.isArray(input)) {
|
|
291
|
+
return result;
|
|
292
|
+
}
|
|
293
|
+
for (const entry of input) {
|
|
294
|
+
if (!isObject(entry) || typeof entry.key !== "string") {
|
|
295
|
+
continue;
|
|
296
|
+
}
|
|
297
|
+
result[entry.key] = readAnyValue(entry.value);
|
|
298
|
+
}
|
|
299
|
+
return result;
|
|
300
|
+
}
|
|
301
|
+
function readAnyValue(input) {
|
|
302
|
+
if (!isObject(input)) {
|
|
303
|
+
return input;
|
|
304
|
+
}
|
|
305
|
+
for (const key of ["stringValue", "intValue", "doubleValue", "boolValue"]) {
|
|
306
|
+
if (key in input) {
|
|
307
|
+
return input[key];
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
return undefined;
|
|
311
|
+
}
|
|
312
|
+
function readString(input) {
|
|
313
|
+
if (typeof input === "string") {
|
|
314
|
+
return input.trim() || undefined;
|
|
315
|
+
}
|
|
316
|
+
if (typeof input === "number" || typeof input === "bigint") {
|
|
317
|
+
return String(input);
|
|
318
|
+
}
|
|
319
|
+
return undefined;
|
|
320
|
+
}
|
|
321
|
+
function readInteger(input) {
|
|
322
|
+
const value = typeof input === "number" ? input : typeof input === "string" ? Number(input) : Number.NaN;
|
|
323
|
+
return Number.isSafeInteger(value) ? value : undefined;
|
|
324
|
+
}
|
|
325
|
+
function readNonNegativeInteger(input) {
|
|
326
|
+
const value = readInteger(input);
|
|
327
|
+
return value !== undefined && value >= 0 ? value : undefined;
|
|
328
|
+
}
|
|
329
|
+
function readNonNegativeNumber(input) {
|
|
330
|
+
const value = typeof input === "number" ? input : typeof input === "string" ? Number(input) : Number.NaN;
|
|
331
|
+
return Number.isFinite(value) && value >= 0 ? value : undefined;
|
|
332
|
+
}
|
|
333
|
+
function normalizeModel(input) {
|
|
334
|
+
const model = input?.slice(0, 200).trim();
|
|
335
|
+
return model && isSafeGroupKey(model) ? model : "unknown";
|
|
336
|
+
}
|
|
337
|
+
function isCcrModel(model) {
|
|
338
|
+
const normalized = model.toLowerCase();
|
|
339
|
+
return normalized.startsWith("gpt-") || normalized.startsWith("ccr:") || normalized.includes("codex api/");
|
|
340
|
+
}
|
|
341
|
+
function isSafeGroupKey(value) {
|
|
342
|
+
return value !== "__proto__" && value !== "prototype" && value !== "constructor";
|
|
343
|
+
}
|
|
344
|
+
function isObject(input) {
|
|
345
|
+
return typeof input === "object" && input !== null && !Array.isArray(input);
|
|
346
|
+
}
|