解决一系列上报失败的错误

This commit is contained in:
zbc 2026-05-28 20:24:20 +08:00
parent 2e6dd38603
commit ae22ffd36e
8 changed files with 1378 additions and 919 deletions

View File

@ -6,66 +6,28 @@
*
* -
* - /
* - attemptCount >= MAX_ATTEMPTS
* - attemptCount++ batch
*
* batch 线
*/
import {
uploadConversation,
uploadSummary,
uploadCommits,
uploadStatistics,
authWithFallback,
processIncompleteTasks,
} from './worker.js'
import {
loadQueue,
acquireLock,
releaseLock,
peekTask,
removeTask,
getQueue,
flushQueue,
MAX_ATTEMPTS,
type QueueTask,
clearQueue,
} from './queue.js'
import { readState, writeState, appendDeadLetter } from './state.js'
import { getSessionDirectory, loadSessionMessages } from './worker.js'
import { getRepoInfo } from './git.js'
import { readState, writeState } from './state.js'
import { createLogger } from './logger.js'
const log = createLogger('raw-dump-batch')
/**
* API endpoint
* "/raw-store/task-conversation failed: 401 ..."
*/
function extractEndpointFromError(errorMsg: string): string | undefined {
const match = errorMsg.match(/\/(raw-store\/[^\s]+)/)
return match?.[1]
}
type RepoInfo = Awaited<ReturnType<typeof getRepoInfo>>
const BATCH_INTERVAL_MS = 120_000
// Git repo 信息缓存,同一 directory 的多个 task 短时间内不需要重复 spawn git
const repoInfoCache = new Map<string, { repoInfo: RepoInfo; ts: number }>()
const REPO_CACHE_TTL_MS = 60_000
async function getCachedRepoInfo(directory: string): Promise<RepoInfo> {
const cached = repoInfoCache.get(directory)
if (cached && Date.now() - cached.ts < REPO_CACHE_TTL_MS) {
return cached.repoInfo
}
const repoInfo = await getRepoInfo(directory)
repoInfoCache.set(directory, { repoInfo, ts: Date.now() })
return repoInfo
}
let isRunning = false
const PARENT_PID = process.ppid
const IS_WORKER_PROCESS = process.argv[1]?.includes('batchWorker') || false
@ -79,111 +41,9 @@ function isParentAlive(): boolean {
}
}
// Session messages 缓存
const sessionMessagesCache = new Map<
string,
{ messages: Record<string, unknown>[]; ts: number }
>()
const SESSION_CACHE_TTL_MS = 60_000
async function getCachedSessionMessages(
sessionDir: string,
sessionID: string,
messageID?: string,
) {
const cacheKey = `${sessionDir}:${sessionID}`
const cached = sessionMessagesCache.get(cacheKey)
if (cached && Date.now() - cached.ts < SESSION_CACHE_TTL_MS) {
return cached.messages
}
const start = Date.now()
const messages = await loadSessionMessages(sessionDir, sessionID, messageID)
const elapsed = Date.now() - start
if (elapsed > 100) {
log.info('loadSessionMessages slow', { sessionID, elapsedMs: elapsed })
}
sessionMessagesCache.set(cacheKey, { messages, ts: Date.now() })
return messages
}
/**
*
*
*/
async function processTask(
task: QueueTask,
state: Awaited<ReturnType<typeof readState>>,
) {
log.info('processing task', {
sessionID: task.sessionID,
messageID: task.messageID,
attempt: task.attemptCount,
})
const sessionDir = getSessionDirectory(task.directory, task.sessionID)
const messages = await getCachedSessionMessages(
sessionDir,
task.sessionID,
task.messageID,
)
if (messages.length === 0) {
log.warn('no messages found', { sessionDir, sessionID: task.sessionID })
}
const authData = await authWithFallback()
const repoInfo = await getCachedRepoInfo(task.directory)
if (task.messageID === '__statistics__' && task.statsData) {
await uploadStatistics(
{
sessionID: task.sessionID,
directory: task.directory,
sessionCount: task.statsData.sessionCount,
conversationCount: task.statsData.conversationCount,
upstreamTokens: task.statsData.upstreamTokens,
downstreamTokens: task.statsData.downstreamTokens,
startTime: task.statsData.startTime,
endTime: task.statsData.endTime,
},
authData,
state,
)
log.info('statistics task completed', { sessionID: task.sessionID })
return
}
const conversationUploaded = await uploadConversation(
{
sessionID: task.sessionID,
messageID: task.messageID,
directory: task.directory,
messages,
},
authData,
state,
{ repoInfo },
)
await uploadSummary(
{ sessionID: task.sessionID, directory: task.directory, messages },
authData,
state,
)
await uploadCommits({ directory: task.directory }, authData, state, {
repoInfo,
})
log.info('task completed', {
sessionID: task.sessionID,
conversationUploaded,
})
}
/**
* batch
* state
* state tasks state
*/
async function runBatch() {
if (isRunning) {
@ -200,68 +60,62 @@ async function runBatch() {
try {
const state = await readState()
const tasks = getQueue()
const newTasks = getQueue()
if (tasks.length === 0) {
if (newTasks.length === 0) {
log.debug('queue empty')
return
}
log.info(`processing ${tasks.length} tasks`)
log.info(`processing ${newTasks.length} new tasks`)
// 去重:同一 session:messageID 只保留最新的
const deduped = new Map<string, QueueTask>()
for (const task of tasks) {
// 同步 tasks根据 task-id:message-id 查询 state.tasks
for (const task of newTasks) {
const key = `${task.sessionID}:${task.messageID}`
const existing = deduped.get(key)
if (!existing || task.enqueuedAt > existing.enqueuedAt) {
deduped.set(key, task)
}
}
const existing = state.tasks[key]
const uniqueTasks = Array.from(deduped.values()).sort(
(a, b) => a.enqueuedAt.localeCompare(b.enqueuedAt),
)
log.info(`deduped to ${uniqueTasks.length} unique tasks`)
for (const task of uniqueTasks) {
const key = `${task.sessionID}:${task.messageID}`
try {
await processTask(task, state)
removeTask(key)
} catch (err) {
const errorMsg = err instanceof Error ? err.message : String(err)
task.attemptCount++
log.error('task failed', {
error: errorMsg,
sessionID: task.sessionID,
attemptCount: task.attemptCount,
})
if (task.attemptCount >= MAX_ATTEMPTS) {
// 彻底失败:写入 dead letter从队列移除
await appendDeadLetter({
sessionID: task.sessionID,
messageID: task.messageID,
directory: task.directory,
attemptCount: task.attemptCount,
error: errorMsg,
endpoint: extractEndpointFromError(errorMsg),
failedAt: new Date().toISOString(),
if (existing) {
// 已存在该键,比较 lastEnqueuedAt 和当前请求的时间戳
if (task.enqueuedAt > existing.lastEnqueuedAt) {
// 当前请求更新,更新时间戳 lastEnqueuedAt清掉 lastUploadAt累加 taskCount
existing.lastEnqueuedAt = task.enqueuedAt
existing.lastUploadAt = ''
existing.taskCount++
log.debug('task updated, needs re-upload', {
key,
taskCount: existing.taskCount,
lastEnqueuedAt: existing.lastEnqueuedAt,
})
removeTask(key)
log.warn('task permanently failed, removed from queue', {
sessionID: task.sessionID,
attemptCount: task.attemptCount,
})
} else {
// 失败但未超限attemptCount++ 写回文件,下次重试
await flushQueue()
}
// 如果当前请求没有更新,则忽略
} else {
// 不存在,添加到 state.tasks 中,设置初值
state.tasks[key] = {
lastEnqueuedAt: task.enqueuedAt,
lastUploadAt: '',
taskCount: 1,
attemptCount: 0,
directory: task.directory,
}
log.debug('task added to tracking', {
key,
lastEnqueuedAt: task.enqueuedAt,
})
}
}
// 保存 state 到磁盘
await writeState(state)
// 清掉队列并且把队列内容更新到磁盘文件
clearQueue()
log.info(`tracking ${Object.keys(state.tasks).length} tasks total`)
// 处理 state.tasks 中未完成的任务
await processIncompleteTasks(state)
// 再次保存 state更新 lastUploadAt
await writeState(state)
log.info('batch completed')
} finally {

View File

@ -17,10 +17,6 @@ const log = createLogger('raw-dump')
let batchWorkerSpawned = false
// 调用频率限制:同一 session + messageID 5s 内不重复 enqueue
const lastEnqueueMap = new Map<string, number>()
const ENQUEUE_DEBOUNCE_MS = 5_000
/**
* Raw Dump
* - mode=0
@ -60,30 +56,11 @@ function ensureBatchWorker() {
}
}
/**
* session + message enqueue
* key 5 enqueue
* @returns true false
*/
function shouldEnqueue(sessionID: string, messageID: string): boolean {
const key = `${sessionID}:${messageID}`
const now = Date.now()
const last = lastEnqueueMap.get(key)
if (last && now - last < ENQUEUE_DEBOUNCE_MS) {
log.debug('reportTurn debounced', {
sessionID,
messageID,
lastMs: now - last,
})
return false
}
lastEnqueueMap.set(key, now)
return true
}
/**
*
* batch worker
* reportTurn
* batchWorker runBatch
*/
export async function reportTurn(
sessionID: string,
@ -91,64 +68,7 @@ export async function reportTurn(
directory: string,
): Promise<void> {
if (!isEnabled()) return
if (!shouldEnqueue(sessionID, messageID)) return
ensureRawDumpDirCreated()
enqueue({ sessionID, messageID, directory })
ensureBatchWorker()
}
/**
* session
* 使 messageID '__summary__' conversation
* batch worker
*/
export async function reportSession(
sessionID: string,
directory: string,
): Promise<void> {
if (!isEnabled()) return
if (!shouldEnqueue(sessionID, '__summary__')) return
ensureRawDumpDirCreated()
enqueue({ sessionID, messageID: '__summary__', directory })
ensureBatchWorker()
}
export interface StatisticsData {
sessionCount: number
conversationCount: number
upstreamTokens: number
downstreamTokens: number
startTime: number
endTime: number
}
const lastReportStatsMap = new Map<string, number>()
const STATS_DEBOUNCE_MS = 60_000 // 同一 session 1 分钟内不重复 enqueue
/**
* session数conversation数token数
* messageID '__statistics__'
*/
export async function reportStatistics(
sessionID: string,
directory: string,
data: StatisticsData,
): Promise<void> {
if (!isEnabled()) return
const key = `${sessionID}:__statistics__`
const now = Date.now()
const last = lastReportStatsMap.get(key)
if (last && now - last < STATS_DEBOUNCE_MS) {
log.debug('reportStatistics debounced', { sessionID, lastMs: now - last })
return
}
lastReportStatsMap.set(key, now)
ensureRawDumpDirCreated()
enqueue({
sessionID,
messageID: '__statistics__',
directory,
statsData: data,
})
ensureBatchWorker()
}

View File

@ -12,8 +12,6 @@
import { promises as fs } from 'node:fs'
import os from 'node:os'
import path from 'path'
import type { StatisticsData } from './index.js'
const QUEUE_FILE = path.join(os.homedir(), '.claude', 'raw-dump', 'csc-work-queue.jsonl')
const LOCK_FILE = path.join(os.homedir(), '.claude', 'raw-dump', 'csc-work-queue.lock')
@ -25,7 +23,6 @@ export interface QueueTask {
directory: string
enqueuedAt: string // RFC3339 format, e.g. "2026-05-27T10:00:00.000Z"
attemptCount: number
statsData?: StatisticsData
}
// ----------- 内存中的队列 -----------
@ -76,20 +73,11 @@ export function enqueue(task: Omit<QueueTask, 'enqueuedAt' | 'attemptCount'>): v
}
/**
*
* @param key sessionID:messageID
*
*/
export function removeTask(key: string): void {
queue = queue.filter(t => `${t.sessionID}:${t.messageID}` !== key)
flushQueue().catch(() => {})
}
/**
*
* peek attemptCount
*/
export function peekTask(): QueueTask | undefined {
return queue[0]
export function clearQueue(): void {
queue = []
fs.writeFile(QUEUE_FILE, '', 'utf-8').catch(() => {})
}
/**

View File

@ -0,0 +1,613 @@
/**
* Raw Dump Session Utilities
* session worker.ts batchWorker.ts
*/
import { promises as fs } from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { createLogger } from './logger.js'
import { countDiffLines, extractFilesFromDiff } from './git.js'
const log = createLogger('raw-dump-session')
/**
* CoStrict ~/.claude CLAUDE_CONFIG_HOME
*/
export function getClaudeConfigHomeDir(): string {
return process.env.CLAUDE_CONFIG_HOME || path.join(os.homedir(), '.claude')
}
/**
*
* / -
* /Users/linkai/code/csc -Users-linkai-code-csc
*/
export function normalizeProjectPath(dir: string): string {
return dir.replace(/:/, '-').replace(/[/\\]/g, '-')
}
/**
* session
*
* 1. ~/.claude/projects/{normalized-path}/
* 2. ~/.claude/transcripts/
* 3. ~/.claude/sessions/
* 4. {directory}/.claude/sessions/
* 5. {directory}/.claude/
* 6. {directory}
* 7. CSC_SESSION_DIR
*/
export function getSessionDirectory(
directory: string,
sessionID: string,
): string {
const claudeHome = getClaudeConfigHomeDir()
const projectPath = normalizeProjectPath(directory)
const candidates = [
path.join(claudeHome, 'projects', projectPath),
path.join(claudeHome, 'transcripts'),
path.join(claudeHome, 'sessions'),
path.join(directory, '.claude', 'sessions'),
path.join(directory, '.claude'),
directory,
process.env.CSC_SESSION_DIR || '',
]
return candidates.find(d => d) || directory
}
/**
* JSONL
* - sessionDir .jsonl
* - sessionId
* - sessionIdmessageId
* - csc ses_{hash}.jsonl {uuid}.jsonl
* @returns JSONL
*/
export async function loadSessionMessages(
sessionDir: string,
sessionId: string,
messageId?: string,
) {
try {
const entries = await fs.readdir(sessionDir)
const jsonlFiles = entries.filter(f => f.endsWith('.jsonl'))
log.debug('found jsonl files', {
sessionDir,
count: jsonlFiles.length,
files: jsonlFiles.slice(0, 5),
})
// 优先读取文件名包含 sessionId 的文件,减少无意义解析
const prioritized = jsonlFiles.sort((a, b) => {
const aHas = a.includes(sessionId)
const bHas = b.includes(sessionId)
if (aHas && !bHas) return -1
if (!aHas && bHas) return 1
return 0
})
for (const file of prioritized) {
const filePath = path.join(sessionDir, file)
try {
const text = await fs.readFile(filePath, 'utf-8')
const lines = text
.split('\n')
.filter(Boolean)
.map(line => {
try {
return JSON.parse(line)
} catch {
return null
}
})
.filter((m): m is Record<string, unknown> => m !== null)
// 检查是否包含目标 sessionId 或 messageId
const hasSession = lines.some(
m =>
m.sessionId === sessionId ||
m.session_id === sessionId ||
m.uuid === sessionId,
)
const hasMessage = messageId
? lines.some(
m =>
m.uuid === messageId ||
(m.message as Record<string, unknown>)?.id === messageId,
)
: false
if (hasSession || hasMessage) {
log.debug('loaded messages from file', {
file,
count: lines.length,
hasSession,
hasMessage,
})
return lines
}
} catch {
// ignore per-file errors
}
}
} catch {
// ignore dir read errors
}
return []
}
/**
* ID
* message.uuid message.id
*/
export function findMessage(
messages: Record<string, unknown>[],
messageID: string,
): Record<string, unknown> | undefined {
return messages.find(
m =>
m.uuid === messageID ||
(m.message as Record<string, unknown>)?.id === messageID,
)
}
/**
* assistant message user message
* csc user message assistant message
* assistant type === 'user'
*/
export function findParentUserMessage(
messages: Record<string, unknown>[],
assistantMsg: Record<string, unknown>,
): Record<string, unknown> | undefined {
const assistantIndex = messages.indexOf(assistantMsg)
if (assistantIndex <= 0) return undefined
for (let i = assistantIndex - 1; i >= 0; i--) {
if (messages[i]?.type === 'user') return messages[i]
}
return undefined
}
/**
* agent user
* assistant modeagent isSidechain user isMeta
*/
export function detectSender(
assistant: Record<string, unknown>,
user: Record<string, unknown> | undefined,
): string {
const mode = String(assistant.mode ?? '')
if (mode === 'agent' || mode === 'auto') return 'agent'
if (assistant.agent) return 'agent'
if (assistant.isSidechain === true) return 'agent'
if (user?.isMeta === true) return 'agent'
return 'user'
}
/**
*
* content content block type === 'text'
*/
export function extractTextContent(msg: Record<string, unknown>): string {
const content = (msg.message as Record<string, unknown>)?.content
if (!Array.isArray(content)) return String(content ?? '')
return content
.filter((block): block is Record<string, unknown> => block?.type === 'text')
.map(block => String(block.text ?? ''))
.join('\n')
}
/**
* structured patch unified diff
*/
function structuredPatchToUnifiedDiff(
filePath: string,
patches: Array<Record<string, unknown>>,
): string {
if (!patches.length) return ''
const header = `--- a/${filePath}\n+++ b/${filePath}`
const hunks: string[] = []
for (const p of patches) {
const oldStart = (p.oldStart as number) ?? 1
const oldLines = (p.oldLines as number) ?? 0
const newStart = (p.newStart as number) ?? 1
const newLines = (p.newLines as number) ?? 0
const lines = (p.lines as string[]) ?? []
hunks.push(
`@@ -${oldStart},${oldLines} +${newStart},${newLines} @@\n${lines.join('\n')}`,
)
}
return header + '\n' + hunks.join('\n') + '\n'
}
/**
* unified diff
*/
function generateStringDiff(
filePath: string,
oldStr: string,
newStr: string,
): string {
const oldLines = oldStr.split('\n')
const newLines = newStr.split('\n')
const header = `--- a/${filePath}\n+++ b/${filePath}`
const hunk = `@@ -1,${oldLines.length} +1,${newLines.length} @@`
const body = oldLines
.map(l => `-${l}`)
.concat(newLines.map(l => `+${l}`))
.join('\n')
return header + '\n' + hunk + '\n' + body + '\n'
}
/**
* assistant message unified diff
* user message toolUseResult gitDiff.patch unified diff
* fallback structuredPatch tool_use input diff
*/
export function extractToolDiff(
msg: Record<string, unknown>,
allMessages?: Record<string, unknown>[],
): { diff: string; diff_lines: number; files: string[] } {
const diffs: string[] = []
const files = new Set<string>()
const handledToolUseIds = new Set<string>()
const msgUuid = msg.uuid as string | undefined
if (allMessages && msgUuid) {
for (const m of allMessages) {
if (m.parentUuid !== msgUuid || m.type !== 'user') continue
const tur = (m.toolUseResult ?? m.tool_use_result) as
| Record<string, unknown>
| undefined
if (!tur) continue
const filePath =
(tur.filePath as string | undefined) ??
(tur.file_path as string | undefined) ??
(tur.notebook_path as string | undefined) ??
''
const gitDiff = tur.gitDiff as Record<string, unknown> | undefined
if (
gitDiff?.patch &&
typeof gitDiff.patch === 'string' &&
gitDiff.patch
) {
diffs.push(gitDiff.patch)
if (gitDiff.filename && typeof gitDiff.filename === 'string')
files.add(gitDiff.filename)
else if (filePath) files.add(filePath)
const mContent = (m.message as Record<string, unknown>)?.content
if (Array.isArray(mContent)) {
for (const b of mContent) {
if (
b?.type === 'tool_result' &&
typeof b.tool_use_id === 'string'
) {
handledToolUseIds.add(b.tool_use_id)
}
}
}
continue
}
const sp = tur.structuredPatch as
| Array<Record<string, unknown>>
| undefined
if (Array.isArray(sp) && sp.length > 0 && filePath) {
diffs.push(structuredPatchToUnifiedDiff(filePath, sp))
files.add(filePath)
const mContent = (m.message as Record<string, unknown>)?.content
if (Array.isArray(mContent)) {
for (const b of mContent) {
if (
b?.type === 'tool_result' &&
typeof b.tool_use_id === 'string'
) {
handledToolUseIds.add(b.tool_use_id)
}
}
}
continue
}
const oldStr =
(tur.oldString as string | undefined) ??
(tur.old_string as string | undefined) ??
(tur.originalFile as string | undefined) ??
(tur.original_file as string | undefined)
const newStr =
(tur.newString as string | undefined) ??
(tur.new_string as string | undefined) ??
(tur.content as string | undefined) ??
(tur.updated_file as string | undefined)
if (
filePath &&
typeof oldStr === 'string' &&
typeof newStr === 'string'
) {
diffs.push(generateStringDiff(filePath, oldStr, newStr))
files.add(filePath)
} else if (filePath && typeof newStr === 'string') {
diffs.push(generateStringDiff(filePath, '', newStr))
files.add(filePath)
}
}
}
const content = (msg.message as Record<string, unknown>)?.content
if (Array.isArray(content)) {
for (const block of content) {
if (block?.type !== 'tool_use') continue
if (typeof block.id === 'string' && handledToolUseIds.has(block.id))
continue
const input = block.input as Record<string, unknown> | undefined
const toolName = block.name as string | undefined
const filePath =
(input?.file_path as string | undefined) ??
(input?.notebook_path as string | undefined) ??
''
if (
toolName === 'Edit' &&
typeof input?.old_string === 'string' &&
typeof input?.new_string === 'string'
) {
diffs.push(
generateStringDiff(filePath, input.old_string, input.new_string),
)
if (filePath) files.add(filePath)
} else if (
toolName === 'NotebookEdit' &&
typeof input?.new_source === 'string' &&
filePath
) {
diffs.push(generateStringDiff(filePath, '', input.new_source))
files.add(filePath)
} else if (typeof input?.diff === 'string' && input.diff) {
diffs.push(input.diff)
if (filePath) files.add(filePath)
} else if (typeof input?.patch === 'string' && input.patch) {
diffs.push(input.patch)
if (filePath) files.add(filePath)
}
}
}
const diff = diffs.join('\n')
for (const file of extractFilesFromDiff(diff)) files.add(file)
return { diff, diff_lines: countDiffLines(diff), files: Array.from(files) }
}
/**
* assistant message token 使
*/
export function extractUsage(msg: Record<string, unknown>) {
const usage = (msg.message as Record<string, unknown>)?.usage as
| Record<string, number>
| undefined
return {
input_tokens: usage?.input_tokens ?? 0,
output_tokens: usage?.output_tokens ?? 0,
cache_read_input_tokens: usage?.cache_read_input_tokens ?? 0,
cache_creation_input_tokens: usage?.cache_creation_input_tokens ?? 0,
}
}
const SDK_ERROR_CODE_MAP: Record<string, number> = {
authentication_failed: 401,
billing_error: 402,
rate_limit: 429,
invalid_request: 400,
server_error: 500,
max_output_tokens: 413,
unknown: 500,
}
/**
* assistant message
*/
export function extractError(msg: Record<string, unknown>): {
error_code?: number
error_reason?: string
} {
const error = msg.error
if (typeof error === 'string') {
const errorCode = SDK_ERROR_CODE_MAP[error] ?? 500
const errorDetails = msg.errorDetails
const reason =
typeof errorDetails === 'string' && errorDetails ? errorDetails : error
return { error_code: errorCode, error_reason: reason }
}
if (typeof error === 'object' && error !== null) {
const err = error as Record<string, unknown>
const apiError = msg.apiError
if (typeof apiError === 'string' && apiError === 'max_output_tokens') {
const reason =
typeof err.message === 'string' ? err.message : 'max_output_tokens'
return { error_code: 413, error_reason: reason }
}
const name = String(err.name ?? 'UnknownError')
const errMessage = typeof err.message === 'string' ? err.message : name
const errorCode =
name === 'ProviderAuthError'
? 401
: name === 'ContextOverflowError' || name === 'MessageOutputLengthError'
? 413
: name === 'MessageAbortedError'
? 499
: name === 'APIError' && typeof err.statusCode === 'number'
? err.statusCode
: 500
return { error_code: errorCode, error_reason: errMessage }
}
if (msg.isApiErrorMessage) {
return { error_code: 500, error_reason: 'api_error_unclassified' }
}
return {}
}
/**
* sessionDir sessionId messageId
* .jsonl timestamp sessionId messageId
*/
export async function getLatestSessionInfo(
sessionDir: string,
): Promise<{ sessionId: string; messageId: string } | null> {
try {
const entries = await fs.readdir(sessionDir)
const jsonlFiles = entries.filter(f => f.endsWith('.jsonl'))
if (jsonlFiles.length === 0) return null
let latestMsg: { sessionId: string; messageId: string; ts: number } | null = null
for (const file of jsonlFiles) {
const filePath = path.join(sessionDir, file)
try {
const text = await fs.readFile(filePath, 'utf-8')
const lines = text.split('\n').filter(Boolean)
for (const line of lines) {
try {
const msg = JSON.parse(line) as Record<string, unknown>
const ts = (msg.timestamp as number) || 0
if (!latestMsg || ts > latestMsg.ts) {
const sessionId =
(msg.sessionId as string) ||
(msg.session_id as string) ||
(msg.uuid as string) ||
''
const messageId =
(msg.uuid as string) ||
((msg.message as Record<string, unknown>)?.id as string) ||
''
if (sessionId && messageId) {
latestMsg = { sessionId, messageId, ts }
}
}
} catch {
// ignore parse errors
}
}
} catch {
// ignore file read errors
}
}
if (latestMsg) {
log.debug('found latest session info', {
sessionId: latestMsg.sessionId,
messageId: latestMsg.messageId,
ts: latestMsg.ts,
})
return { sessionId: latestMsg.sessionId, messageId: latestMsg.messageId }
}
} catch {
// ignore dir read errors
}
return null
}
// Session messages 缓存batchWorker 使用)
// 缓存结构: [sessionJsonlFileName, file-timestamp, cache-timestamp, messages]
type SessionMessagesCacheEntry = [
sessionJsonlFileName: string,
fileTimestamp: number,
cacheTimestamp: number,
messages: Record<string, unknown>[],
]
const sessionMessagesCache = new Map<string, SessionMessagesCacheEntry>()
/**
* session messages
* key: sessionDir:sessionId
* : [sessionJsonlFileName, file-timestamp, cache-timestamp, messages]
*
*/
export async function getCachedSessionMessages(
sessionDir: string,
sessionID: string,
messageID?: string,
) {
const cacheKey = `${sessionDir}:${sessionID}`
const cached = sessionMessagesCache.get(cacheKey)
// 尝试找到当前 session 文件
const findCurrentFile = async (): Promise<{ fileName: string; filePath: string } | null> => {
try {
const entries = await fs.readdir(sessionDir)
const jsonlFiles = entries.filter(f => f.endsWith('.jsonl'))
const prioritized = jsonlFiles.sort((a, b) => {
const aHas = a.includes(sessionID)
const bHas = b.includes(sessionID)
if (aHas && !bHas) return -1
if (!aHas && bHas) return 1
return 0
})
for (const file of prioritized) {
const filePath = path.join(sessionDir, file)
const text = await fs.readFile(filePath, 'utf-8')
const lines = text.split('\n').filter(Boolean)
const hasSession = lines.some(m => {
const msg = JSON.parse(m) as Record<string, unknown>
return msg.sessionId === sessionID || msg.session_id === sessionID || msg.uuid === sessionID
})
if (hasSession) {
return { fileName: file, filePath }
}
}
} catch {}
return null
}
if (cached) {
const [cachedFileName, cachedFileTs] = cached
const currentFile = await findCurrentFile()
if (currentFile && currentFile.fileName === cachedFileName) {
// 文件名匹配,检查文件是否更新
try {
const stats = await fs.stat(path.join(sessionDir, cachedFileName))
const currentFileTs = stats.mtimeMs
if (currentFileTs === cachedFileTs) {
// 文件未变化,直接使用缓存
log.debug('using cached session messages', { sessionID, cachedFileName })
return cached[3]
}
} catch {}
}
// 文件有更新或文件名不匹配,需要重新加载
log.debug('file changed, reloading session messages', { sessionID })
}
// 未找到缓存或文件已更新,搜索 session 文件
const currentFile = await findCurrentFile()
if (currentFile) {
const start = Date.now()
const messages = await loadSessionMessages(sessionDir, sessionID, messageID)
const elapsed = Date.now() - start
if (elapsed > 100) {
log.info('loadSessionMessages slow', { sessionID, elapsedMs: elapsed })
}
try {
const stats = await fs.stat(path.join(sessionDir, currentFile.fileName))
sessionMessagesCache.set(cacheKey, [
currentFile.fileName,
stats.mtimeMs,
Date.now(),
messages,
])
} catch {}
return messages
}
// 文件不存在,缓存空结果
sessionMessagesCache.set(cacheKey, ['', 0, Date.now(), []])
return []
}

View File

@ -11,7 +11,7 @@
import { promises as fs, readFileSync, writeFileSync } from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import type { RawDumpState, DeadLetterEntry } from './types.js'
import type { RawDumpState, DeadLetterEntry, TaskRecord } from './types.js'
const STATE_DIR = path.join(os.homedir(), '.claude', 'raw-dump')
const STATE_FILE = path.join(STATE_DIR, 'csc-state.json')
@ -27,6 +27,61 @@ function createEmptyState(): RawDumpState {
conversation: {},
summary: {},
commits: {},
statistics: {},
tasks: {},
}
}
/**
* conversationsummarytasks
* commits
* @param state state
*/
function cleanupOldRecords(state: RawDumpState): RawDumpState {
const now = Date.now()
// 获取昨日 0 点的时间戳
const yesterday = new Date()
yesterday.setDate(yesterday.getDate() - 1)
yesterday.setHours(0, 0, 0, 0)
const yesterdayMs = yesterday.getTime()
const cleanedConversation: Record<string, string> = {}
for (const [key, ts] of Object.entries(state.conversation)) {
if (!ts) continue
const date = new Date(ts).getTime()
if (date >= yesterdayMs) {
cleanedConversation[key] = ts
}
}
const cleanedSummary: Record<string, string> = {}
for (const [key, ts] of Object.entries(state.summary)) {
if (!ts) continue
const date = new Date(ts).getTime()
if (date >= yesterdayMs) {
cleanedSummary[key] = ts
}
}
const cleanedTasks: Record<string, TaskRecord> = {}
for (const [key, record] of Object.entries(state.tasks)) {
// 清理 lastUploadAt 非空且已超过昨日的记录
if (record.lastUploadAt) {
const date = new Date(record.lastUploadAt).getTime()
if (date >= yesterdayMs) {
cleanedTasks[key] = record
}
} else {
// lastUploadAt 为空表示尚未处理完毕,保留
cleanedTasks[key] = record
}
}
return {
...state,
conversation: cleanedConversation,
summary: cleanedSummary,
tasks: cleanedTasks,
}
}
@ -101,11 +156,14 @@ export async function readState(): Promise<RawDumpState> {
try {
const text = await fs.readFile(STATE_FILE, 'utf-8')
const parsed = JSON.parse(text) as Partial<RawDumpState>
return {
const state: RawDumpState = {
conversation: parsed.conversation ?? {},
summary: parsed.summary ?? {},
commits: parsed.commits ?? {},
statistics: parsed.statistics ?? {},
tasks: parsed.tasks ?? {},
}
return cleanupOldRecords(state)
} catch {
return createEmptyState()
}

View File

@ -0,0 +1,217 @@
/**
* Raw Dump
* 使
* conversationsummarycommit
*
*/
import { readState, writeState } from './state.js'
/**
*
*/
export interface DailyStatistics {
dateKey: string // YYYY/MM/DD 格式
sessionCount: number
conversationCount: number
upstreamTokens: number
downstreamTokens: number
startTime: number // 当天最早一条记录的时间戳
endTime: number // 当天最新一条记录的时间戳
}
/**
* key YYYY/MM/DD
*/
function getTodayKey(): string {
const now = new Date()
const year = now.getFullYear()
const month = String(now.getMonth() + 1).padStart(2, '0')
const day = String(now.getDate()).padStart(2, '0')
return `${year}/${month}/${day}`
}
/**
* ISO
*/
function formatIso(ms: number | undefined): string {
if (!ms) return ''
return new Date(ms).toISOString().replace(/\.\d{3}Z$/, 'Z')
}
// 全局变量,维护当天的统计值
let globalStats: DailyStatistics = {
dateKey: getTodayKey(),
sessionCount: 0,
conversationCount: 0,
upstreamTokens: 0,
downstreamTokens: 0,
startTime: 0,
endTime: 0,
}
/**
*
*/
function resetGlobalStats(): void {
globalStats = {
dateKey: getTodayKey(),
sessionCount: 0,
conversationCount: 0,
upstreamTokens: 0,
downstreamTokens: 0,
startTime: 0,
endTime: 0,
}
}
/**
*
*
*/
function checkAndResetForNewDay(): void {
const todayKey = getTodayKey()
if (globalStats.dateKey !== todayKey) {
resetGlobalStats()
}
}
/**
* session
* @param timestamp
*/
export function incrementSession(timestamp: number): void {
checkAndResetForNewDay()
globalStats.sessionCount++
updateTimeRange(timestamp)
}
/**
* conversation
* @param timestamp
*/
export function incrementConversation(timestamp: number): void {
checkAndResetForNewDay()
globalStats.conversationCount++
updateTimeRange(timestamp)
}
/**
* token conversation
* @param upstream token
* @param downstream token
* @param timestamp
*/
export function addTokens(
upstream: number,
downstream: number,
timestamp: number,
): void {
checkAndResetForNewDay()
globalStats.upstreamTokens += upstream
globalStats.downstreamTokens += downstream
updateTimeRange(timestamp)
}
/**
*
* @param timestamp
*/
function updateTimeRange(timestamp: number): void {
if (globalStats.startTime === 0 || timestamp < globalStats.startTime) {
globalStats.startTime = timestamp
}
if (globalStats.endTime === 0 || timestamp > globalStats.endTime) {
globalStats.endTime = timestamp
}
}
/**
*
* @returns
*/
export function getCurrentStatistics(): DailyStatistics {
checkAndResetForNewDay()
return { ...globalStats }
}
/**
*
* uploadStatistics
*/
export function getStatisticsForUpload(): {
sessionCount: number
conversationCount: number
upstreamTokens: number
downstreamTokens: number
startTime: number
endTime: number
} {
checkAndResetForNewDay()
return {
sessionCount: globalStats.sessionCount,
conversationCount: globalStats.conversationCount,
upstreamTokens: globalStats.upstreamTokens,
downstreamTokens: globalStats.downstreamTokens,
startTime: globalStats.startTime,
endTime: globalStats.endTime,
}
}
/**
*
* state statistics
* state
*/
export async function cleanupOldStatistics(): Promise<void> {
try {
const state = await readState()
const todayKey = getTodayKey()
// 过滤出今天的记录,清理掉昨天及之前的
const cleanedStatistics: Record<string, string> = {}
for (const [key, ts] of Object.entries(state.statistics)) {
if (key === todayKey) {
cleanedStatistics[key] = ts
}
}
if (Object.keys(cleanedStatistics).length !== Object.keys(state.statistics).length) {
state.statistics = cleanedStatistics
await writeState(state)
}
} catch {
// ignore cleanup errors
}
}
const STATS_HOURLY_WINDOW_MS = 60 * 60 * 1000 // 同一天内statistics 上报间隔 1 小时
/**
* statistics
* - 1
* -
* @param dateKey YYYY/MM/DD
* @param state
* @returns true false
*/
export function shouldReportStatistics(
dateKey: string,
state: Awaited<ReturnType<typeof readState>>,
): boolean {
const todayKey = getTodayKey()
const lastReported = state.statistics[dateKey]
if (!lastReported) {
return true // 从未上报过,需要上报
}
if (dateKey !== todayKey) {
return false // 历史日期已上报过,不再上报
}
// 当天:检查是否超过 1 小时间隔
const lastTime = new Date(lastReported).getTime()
const now = Date.now()
return now - lastTime >= STATS_HOURLY_WINDOW_MS
}

View File

@ -20,10 +20,20 @@ export interface RawDumpEventPayload {
directory: string
}
export interface TaskRecord {
lastEnqueuedAt: string // RFC3339最后一次收到该组请求的时间戳
lastUploadAt: string // RFC3339请求处理完毕的时间戳为空表示尚未处理完毕"DEAD_LETTER"表示已移至死信
taskCount: number // 收到的该组请求总个数
attemptCount: number // 已尝试次数,超过 MAX_ATTEMPTS 后移至 dead letter
directory: string // 工作目录,用于定位 session 文件
}
export interface RawDumpState {
conversation: Record<string, true>
summary: Record<string, string> // RFC3339 时间戳,如 "2024-01-01T12:00:00.000Z"
commits: Record<string, string>
conversation: Record<string, string> // key: "taskID:messageID", value: RFC3339 时间戳,未上报则为空字符串
summary: Record<string, string> //key: task-id, value: 上报成功的时间戳, RFC3339格式如 "2024-01-01T12:00:00.000Z"
commits: Record<string, string> //key: repo-addr#branch#work-dirvalue: 最后一个上报成功的commit-id
statistics: Record<string, string> // key: YYYY/MM/DD, value: RFC3339 时间戳
tasks: Record<string, TaskRecord> // key: "task-id:message-id",值为任务跟踪记录
}
export interface DeadLetterEntry {
@ -32,8 +42,11 @@ export interface DeadLetterEntry {
directory: string
attemptCount: number
error: string
endpoint?: string
failedAt: string
// 待上报的 HTTP 请求信息
url?: string
headers?: Record<string, string>
body?: object
}
export interface JwtPayload {
@ -77,7 +90,6 @@ export interface ConversationPayload {
export interface SummaryPayload {
task_id: string
start_time: string
end_time: string
user_id: string
user_name: string
client_id: string

File diff suppressed because it is too large Load Diff