From c8586ee6ee55e3243bf5c8dfdac36e25fdf93eea Mon Sep 17 00:00:00 2001 From: zbc Date: Tue, 26 May 2026 16:39:19 +0800 Subject: [PATCH] =?UTF-8?q?1.=20=E4=BF=AE=E5=A4=8D=E5=8C=BF=E5=90=8D?= =?UTF-8?q?=E4=B8=8A=E6=8A=A5url=E9=94=99=E8=AF=AF=EF=BC=9B=202.=E5=A2=9E?= =?UTF-8?q?=E5=8A=A0=E6=B3=A8=E9=87=8A=EF=BC=9B=203.=E5=A2=9E=E5=8A=A0?= =?UTF-8?q?=E5=BC=82=E5=B8=B8=E5=9C=BA=E6=99=AF=E5=A4=84=E7=90=86=E6=8F=90?= =?UTF-8?q?=E5=8D=87=E8=8E=B7=E5=8F=96=E5=AF=B9=E8=AF=9Ddiff=E7=9A=84?= =?UTF-8?q?=E5=81=A5=E5=A3=AE=E6=80=A7=E2=80=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/costrict/provider/index.ts | 21 ++- src/services/rawDump/batchWorker.ts | 113 ++++++++++++-- src/services/rawDump/git.ts | 99 ++++++++++-- src/services/rawDump/index.ts | 45 +++++- src/services/rawDump/localStorage.ts | 73 +++++---- src/services/rawDump/queue.ts | 34 +++- src/services/rawDump/spawn.ts | 117 +++++++------- src/services/rawDump/state.ts | 31 +++- src/services/rawDump/worker.ts | 225 ++++++++++++++++++++++++--- 9 files changed, 620 insertions(+), 138 deletions(-) diff --git a/src/costrict/provider/index.ts b/src/costrict/provider/index.ts index 670dc5e4e..63567e959 100644 --- a/src/costrict/provider/index.ts +++ b/src/costrict/provider/index.ts @@ -54,6 +54,22 @@ function isRecord(value: unknown): value is Record { return value !== null && typeof value === 'object' && !Array.isArray(value) } +function mapErrorToSDKError( + error: unknown, +): SDKAssistantMessageError | undefined { + if (!(error instanceof Error)) return undefined + // OpenAI SDK errors expose .status + const status = (error as Record).status + if (typeof status === 'number') { + if (status === 401 || status === 403) return 'authentication_failed' + if (status === 429) return 'rate_limit' + if (status === 402) return 'billing_error' + if (status === 400) return 'invalid_request' + return 'server_error' + } + return 'unknown' +} + function isPlainRecord(value: unknown): value is Record { return isRecord(value) } @@ -453,10 +469,7 @@ export async function* queryModelCoStrict( ? 'CoStrict API Error: The current model does not support image input. Switch to a multimodal or vision-capable model and try again.' : `CoStrict API Error: ${errorMsg}`, apiError: 'api_error', - error: - error instanceof Error - ? (error as unknown as SDKAssistantMessageError) - : undefined, + error: mapErrorToSDKError(error), }) } } diff --git a/src/services/rawDump/batchWorker.ts b/src/services/rawDump/batchWorker.ts index 7086929e3..8d0f283c5 100644 --- a/src/services/rawDump/batchWorker.ts +++ b/src/services/rawDump/batchWorker.ts @@ -4,8 +4,19 @@ * 独立进程,通过自循环 setTimeout 严格串行执行 */ -import { uploadConversation, uploadSummary, uploadCommits, authWithFallback } from './worker.js' -import { readQueue, clearQueue, acquireLock, releaseLock, type QueueTask } from './queue.js' +import { + uploadConversation, + uploadSummary, + uploadCommits, + authWithFallback, +} from './worker.js' +import { + readQueue, + clearQueue, + acquireLock, + releaseLock, + type QueueTask, +} from './queue.js' import { readState, writeState } from './state.js' import { getSessionDirectory, loadSessionMessages } from './worker.js' import { getRepoInfo } from './git.js' @@ -21,6 +32,11 @@ const BATCH_INTERVAL_MS = 120_000 // 每轮间隔(2 分钟),降低内联 const repoInfoCache = new Map() const REPO_CACHE_TTL_MS = 60_000 +/** + * 批量获取 Git 仓库信息(带缓存) + * 同一 directory 在 60 秒内不重复调用 git 命令 + * @returns 缓存的 RepoInfo 或新获取的信息 + */ async function getCachedRepoInfo(directory: string): Promise { const cached = repoInfoCache.get(directory) if (cached && Date.now() - cached.ts < REPO_CACHE_TTL_MS) { @@ -37,6 +53,12 @@ let isRunning = false const PARENT_PID = process.ppid const IS_WORKER_PROCESS = process.argv[1]?.includes('batchWorker') || false +/** + * 检测父进程是否还在运行 + * - 仅在独立 worker 进程中生效(IS_WORKER_PROCESS = true) + * - 使用 kill(pid, 0) 检测进程是否存在 + * - 父进程退出时 worker 应停止,避免孤儿进程 + */ function isParentAlive(): boolean { if (!IS_WORKER_PROCESS) return true try { @@ -48,10 +70,22 @@ function isParentAlive(): boolean { } // Session messages 缓存:同一 session 的多个 task 短时间内不需要重复读取 JSONL -const sessionMessagesCache = new Map[]; ts: number }>() +const sessionMessagesCache = new Map< + string, + { messages: Record[]; ts: number } +>() const SESSION_CACHE_TTL_MS = 60_000 -async function getCachedSessionMessages(sessionDir: string, sessionID: string, messageID?: string) { +/** + * 批量获取 session 消息列表(带缓存) + * 同一 sessionDir + sessionID 在 60 秒内不重复读取 JSONL 文件 + * 记录加载耗时,超过 100ms 时输出 info 日志(便于发现性能问题) + */ +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) { @@ -62,17 +96,39 @@ async function getCachedSessionMessages(sessionDir: string, sessionID: string, m const messages = await loadSessionMessages(sessionDir, sessionID, messageID) const elapsed = Date.now() - start if (elapsed > 100) { - log.info('loadSessionMessages slow', { sessionID, elapsedMs: elapsed, messageCount: messages.length }) + log.info('loadSessionMessages slow', { + sessionID, + elapsedMs: elapsed, + messageCount: messages.length, + }) } sessionMessagesCache.set(cacheKey, { messages, ts: Date.now() }) return messages } -async function processTask(task: QueueTask, state: Awaited>) { - log.info('processing task', { sessionID: task.sessionID, messageID: task.messageID }) +/** + * 处理单个上报任务 + * - 加载 session 消息(可能为空) + * - 依次调用 uploadConversation、uploadSummary、uploadCommits + * - 任何任务失败都抛异常,由上层 runBatch 统一处理 + * @param task 队列任务 + * @param state 当前 batch 的 state 快照(内存引用,函数内修改会直接反映到外层) + */ +async function processTask( + task: QueueTask, + state: Awaited>, +) { + log.info('processing task', { + sessionID: task.sessionID, + messageID: task.messageID, + }) const sessionDir = getSessionDirectory(task.directory, task.sessionID) - const messages = await getCachedSessionMessages(sessionDir, task.sessionID, task.messageID) + const messages = await getCachedSessionMessages( + sessionDir, + task.sessionID, + task.messageID, + ) if (messages.length === 0) { log.warn('no messages found', { sessionDir, sessionID: task.sessionID }) @@ -86,7 +142,12 @@ async function processTask(task: QueueTask, state: Awaited a.enqueuedAt - b.enqueuedAt) + const uniqueTasks = Array.from(deduped.values()).sort( + (a, b) => a.enqueuedAt - b.enqueuedAt, + ) log.info(`deduped to ${uniqueTasks.length} unique tasks`) // 一次性读取 state,所有 task 共享,减少文件锁竞争和重复 JSON 解析 @@ -179,6 +252,13 @@ async function runBatch() { } } +/** + * 启动 Batch Worker + * 通过 setTimeout 自循环实现严格串行执行,避免并发触发 API 限流 + * 启动时随机抖动 0~10 秒,避免多个 csc 实例同时启动时撞 API + * 每次 batch 结束后等待 2 分钟(加 0~5 秒随机抖动)再执行下一轮 + * 父进程退出时自动停止 + */ export function startBatchWorker() { log.info('batch worker started', { interval: BATCH_INTERVAL_MS }) @@ -193,7 +273,9 @@ export function startBatchWorker() { try { await runBatch() } catch (err) { - log.error('runBatch threw', { error: err instanceof Error ? err.message : String(err) }) + log.error('runBatch threw', { + error: err instanceof Error ? err.message : String(err), + }) } const jitter = Math.floor(Math.random() * 5_000) scheduleNext(BATCH_INTERVAL_MS + jitter) @@ -206,6 +288,9 @@ export function startBatchWorker() { // 如果直接运行此文件 const scriptPath = process.argv[1] || '' -if (scriptPath.endsWith('batchWorker.ts') || scriptPath.endsWith('batchWorker.js')) { +if ( + scriptPath.endsWith('batchWorker.ts') || + scriptPath.endsWith('batchWorker.js') +) { startBatchWorker() } diff --git a/src/services/rawDump/git.ts b/src/services/rawDump/git.ts index 776d5f5e7..e34453e6d 100644 --- a/src/services/rawDump/git.ts +++ b/src/services/rawDump/git.ts @@ -8,6 +8,11 @@ import { promisify } from 'node:util' const execFileAsync = promisify(execFile) +/** + * 封装 git 命令执行的通用逻辑 + * - 所有 git 命令使用相同参数:cwd、encoding、maxBuffer + * - 失败时返回空字符串(而非抛异常) + */ async function gitExec(args: string[], cwd: string): Promise { try { const { stdout } = await execFileAsync('git', args, { @@ -22,6 +27,12 @@ async function gitExec(args: string[], cwd: string): Promise { } } +/** + * 获取 Git 仓库信息 + * - remote origin URL(仓库地址) + * - 当前分支名 + * - git 用户名和邮箱 + */ export async function getRepoInfo(cwd: string) { const [repoAddr, repoBranch, gitUserName, gitUserEmail] = await Promise.all([ gitExec(['remote', 'get-url', 'origin'], cwd), @@ -38,7 +49,11 @@ export async function getRepoInfo(cwd: string) { } } -export async function getRawDiff(cwd: string, from?: string, to?: string): Promise { +export async function getRawDiff( + cwd: string, + from?: string, + to?: string, +): Promise { if (from && to && from !== to) { return gitExec(['diff', '--no-ext-diff', from, to], cwd) } @@ -46,10 +61,17 @@ export async function getRawDiff(cwd: string, from?: string, to?: string): Promi return gitExec(['diff', 'HEAD'], cwd) } +/** + * 获取当前工作区与 HEAD 的 diff(未提交的变更) + */ export async function getWorkingTreeDiff(cwd: string): Promise { return gitExec(['diff', 'HEAD'], cwd) } +/** + * 统计 diff 中的行数(新增行数,不含 diff header) + * 统计以 + 开头但不是 +++ 的行 + */ export function countDiffLines(diff: string): number { let count = 0 for (const line of diff.split('\n')) { @@ -59,6 +81,10 @@ export function countDiffLines(diff: string): number { return count } +/** + * 从 unified diff 字符串中提取涉及的文件路径列表 + * 解析 +++ b/、--- a/、diff --git a/ b/ 三种路径格式 + */ export function extractFilesFromDiff(diff: string): string[] { const files = new Set() for (const line of diff.split('\n')) { @@ -72,6 +98,10 @@ export function extractFilesFromDiff(diff: string): string[] { return Array.from(files) } +/** + * 解析 git log 输出(format: %H|%aI|%an|%ae|%P|%s) + * 将每行按 | 分割为 commit 信息,返回结构化数组 + */ export function parseCommitLog(output: string): Array<{ commit_id: string commit_time: string @@ -83,35 +113,86 @@ export function parseCommitLog(output: string): Array<{ if (!output.trim()) return [] return output .split('\n') - .map((line) => { - const [commit_id, commit_time, git_user_name, git_user_email, parent_ids_str, ...rest] = line.split('|') + .map(line => { + const [ + commit_id, + commit_time, + git_user_name, + git_user_email, + parent_ids_str, + ...rest + ] = line.split('|') if (!commit_id || !git_user_email) return null - const parent_ids = parent_ids_str ? parent_ids_str.trim().split(' ').filter(Boolean) : [] - return { commit_id, commit_time, git_user_name, git_user_email, parent_ids, subject: rest.join('|') } + const parent_ids = parent_ids_str + ? parent_ids_str.trim().split(' ').filter(Boolean) + : [] + return { + commit_id, + commit_time, + git_user_name, + git_user_email, + parent_ids, + subject: rest.join('|'), + } }) .filter((item): item is NonNullable => !!item) } -export async function getCommitLog(cwd: string, lastCommit?: string): Promise { +/** + * 获取 git commit 日志 + * - 若指定 lastCommit:获取该 commit 之后的所有新 commit + * - 否则:获取最近一天的所有 commit + * - 按 author 过滤(仅当前 git 用户提交的) + * - 最多返回 50 条 + */ +export async function getCommitLog( + cwd: string, + lastCommit?: string, +): Promise { const authorEmail = await gitExec(['config', 'user.email'], cwd) const authorFilter = authorEmail ? ['--author', authorEmail] : [] if (lastCommit) { return gitExec( - ['log', `${lastCommit}..HEAD`, '--reverse', '--max-count=50', ...authorFilter, '--format=%H|%aI|%an|%ae|%P|%s'], + [ + 'log', + `${lastCommit}..HEAD`, + '--reverse', + '--max-count=50', + ...authorFilter, + '--format=%H|%aI|%an|%ae|%P|%s', + ], cwd, ) } return gitExec( - ['log', '--since=1 day ago', '--reverse', '--max-count=50', ...authorFilter, '--format=%H|%aI|%an|%ae|%P|%s'], + [ + 'log', + '--since=1 day ago', + '--reverse', + '--max-count=50', + ...authorFilter, + '--format=%H|%aI|%an|%ae|%P|%s', + ], cwd, ) } -export async function getCommitDiff(cwd: string, commitId: string): Promise { +/** + * 获取单个 commit 的完整 diff(包含文件变更统计) + * 使用 --diff-filter=ACDMR 只显示新增(Add)、复制(Copy)、删除(Delete)、修改(Modify)、重命名(Rename) 的文件 + */ +export async function getCommitDiff( + cwd: string, + commitId: string, +): Promise { return gitExec(['show', '--format=', '--diff-filter=ACDMR', commitId], cwd) } +/** + * 截断 commit subject 为最多 150 字符 + * 用于上报时限制 comment 字段长度 + */ export function toCommitComment(subject: string): string { return Array.from(subject).slice(0, 150).join('') } diff --git a/src/services/rawDump/index.ts b/src/services/rawDump/index.ts index d3b4719ac..12241c4b5 100644 --- a/src/services/rawDump/index.ts +++ b/src/services/rawDump/index.ts @@ -17,16 +17,35 @@ let batchWorkerSpawned = false const lastEnqueueMap = new Map() const ENQUEUE_DEBOUNCE_MS = 5_000 +/** + * 判断 Raw Dump 是否启用 + * - 本地调试模式(isLocalDumpMode)自动启用 + * - 环境变量 CSC_DISABLE_RAW_DUMP 或 COSTRICT_DISABLE_RAW_DUMP 为 '1'/'true' 时禁用 + * - 默认启用 + */ function isEnabled(): boolean { // 本地调试模式自动启用 if (isLocalDumpMode()) return true // 显式禁用 - if (process.env.CSC_DISABLE_RAW_DUMP === '1' || process.env.CSC_DISABLE_RAW_DUMP === 'true') return false - if (process.env.COSTRICT_DISABLE_RAW_DUMP === '1' || process.env.COSTRICT_DISABLE_RAW_DUMP === 'true') return false + if ( + process.env.CSC_DISABLE_RAW_DUMP === '1' || + process.env.CSC_DISABLE_RAW_DUMP === 'true' + ) + return false + if ( + process.env.COSTRICT_DISABLE_RAW_DUMP === '1' || + process.env.COSTRICT_DISABLE_RAW_DUMP === 'true' + ) + return false // 默认启用 raw dump return true } +/** + * 确保 Batch Worker 已启动 + * - 首次调用时尝试 spawn 独立 worker 进程 + * - spawn 失败(worker 文件缺失或无合适 runtime)则降级为内联运行 + */ function ensureBatchWorker() { if (batchWorkerSpawned) return batchWorkerSpawned = true @@ -37,12 +56,21 @@ 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 }) + log.debug('reportTurn debounced', { + sessionID, + messageID, + lastMs: now - last, + }) return false } lastEnqueueMap.set(key, now) @@ -53,13 +81,22 @@ function shouldEnqueue(sessionID: string, messageID: string): boolean { * 上报一轮对话 * 只写入队列,由 batch worker 顺序消费 */ -export function reportTurn(sessionID: string, messageID: string, directory: string): void { +export function reportTurn( + sessionID: string, + messageID: string, + directory: string, +): void { if (!isEnabled()) return if (!shouldEnqueue(sessionID, messageID)) return enqueue({ sessionID, messageID, directory }) ensureBatchWorker() } +/** + * 上报 session 摘要信息 + * 使用特殊 messageID '__summary__' 标识,与普通 conversation 分开去重 + * 只写入队列,由 batch worker 顺序消费 + */ export function reportSession(sessionID: string, directory: string): void { if (!isEnabled()) return if (!shouldEnqueue(sessionID, '__summary__')) return diff --git a/src/services/rawDump/localStorage.ts b/src/services/rawDump/localStorage.ts index 37682d8f3..c6efbbdb4 100644 --- a/src/services/rawDump/localStorage.ts +++ b/src/services/rawDump/localStorage.ts @@ -9,43 +9,60 @@ import path from 'node:path' const DEFAULT_LOCAL_DIR = path.join(os.homedir(), '.claude', 'raw-dump-local') +/** + * 获取本地存储目录路径 + * 支持环境变量 CSC_RAW_DUMP_LOCAL_DIR 覆盖 + */ export function getLocalDumpDir(): string { - return (process.env.CSC_RAW_DUMP_LOCAL_DIR || DEFAULT_LOCAL_DIR).replace(/\/$/, '') + return (process.env.CSC_RAW_DUMP_LOCAL_DIR || DEFAULT_LOCAL_DIR).replace( + /\/$/, + '', + ) } +/** + * 判断是否启用本地存储模式 + * 环境变量 CSC_RAW_DUMP_LOCAL_MODE 为 '1' 或 'true' 时启用 + */ export function isLocalDumpMode(): boolean { - const mode = process.env.CSC_RAW_DUMP_LOCAL_MODE - return mode === '1' || mode === 'true' + const mode = process.env.CSC_RAW_DUMP_LOCAL_MODE + return mode === '1' || mode === 'true' } +/** + * 将上报数据写入本地 JSON 文件(用于本地调试模式) + * - 按 task_id 分目录存储 + * - 文件名包含时间戳、类型、request_id/commit_id + * - 在 body 外层包装 _dumpMeta 元信息(类型、时间戳、API endpoint) + */ export async function writeLocalDump( - type: 'conversation' | 'summary' | 'commit', - body: Record, + type: 'conversation' | 'summary' | 'commit', + body: Record, ): Promise { - const dir = getLocalDumpDir() - const taskId = (body.task_id as string) || 'unknown' - const taskDir = path.join(dir, taskId) - await fs.mkdir(taskDir, { recursive: true }) + const dir = getLocalDumpDir() + const taskId = (body.task_id as string) || 'unknown' + const taskDir = path.join(dir, taskId) + await fs.mkdir(taskDir, { recursive: true }) - const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, -5) - const requestId = - (body.request_id as string) || (body.commit_id as string) || 'unknown' - const filename = `${timestamp}-${type}-${requestId}.json` - const filePath = path.join(taskDir, filename) + const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, -5) + const requestId = + (body.request_id as string) || (body.commit_id as string) || 'unknown' + const filename = `${timestamp}-${type}-${requestId}.json` + const filePath = path.join(taskDir, filename) - const payload = { - _dumpMeta: { - type, - dumpedAt: new Date().toISOString(), - endpoint: - type === 'conversation' - ? '/raw-store/task-conversation' - : type === 'summary' - ? '/raw-store/task-summary' - : '/raw-store/commit', - }, - ...body, - } + const payload = { + _dumpMeta: { + type, + dumpedAt: new Date().toISOString(), + endpoint: + type === 'conversation' + ? '/raw-store/task-conversation' + : type === 'summary' + ? '/raw-store/task-summary' + : '/raw-store/commit', + }, + ...body, + } - await fs.writeFile(filePath, JSON.stringify(payload, null, 2) + '\n', 'utf-8') + await fs.writeFile(filePath, JSON.stringify(payload, null, 2) + '\n', 'utf-8') } diff --git a/src/services/rawDump/queue.ts b/src/services/rawDump/queue.ts index 31b21ef46..a3fd61e3e 100644 --- a/src/services/rawDump/queue.ts +++ b/src/services/rawDump/queue.ts @@ -8,7 +8,11 @@ import { promises as fs } from 'node:fs' import os from 'node:os' import path from 'node:path' -const QUEUE_FILE = path.join(os.homedir(), '.claude', 'csc-raw-dump-queue.jsonl') +const QUEUE_FILE = path.join( + os.homedir(), + '.claude', + 'csc-raw-dump-queue.jsonl', +) const LOCK_FILE = path.join(os.homedir(), '.claude', 'csc-raw-dump.lock') export interface QueueTask { @@ -18,19 +22,31 @@ export interface QueueTask { enqueuedAt: number } +/** + * 将任务追加到队列文件(JSONL 格式) + * 使用 fire-and-forget 异步写入,避免阻塞主进程 event loop + * 写入失败被静默吞掉,不影响主流程 + */ export function enqueue(task: Omit): void { const item: QueueTask = { ...task, enqueuedAt: Date.now() } // 使用 fire-and-forget 异步写入,避免阻塞主进程 event loop - fs.appendFile(QUEUE_FILE, JSON.stringify(item) + '\n', 'utf-8').catch(() => {}) + fs.appendFile(QUEUE_FILE, JSON.stringify(item) + '\n', 'utf-8').catch( + () => {}, + ) } +/** + * 从队列文件读取所有待处理的 task + * - 按行解析 JSONL,每行一个 task + * - 解析失败或空文件返回空数组 + */ export async function readQueue(): Promise { try { const text = await fs.readFile(QUEUE_FILE, 'utf-8') return text .split('\n') .filter(Boolean) - .map((line) => { + .map(line => { try { return JSON.parse(line) as QueueTask } catch { @@ -43,6 +59,10 @@ export async function readQueue(): Promise { } } +/** + * 清空队列文件(truncate 为空) + * 在 batch worker 读取队列后立即调用,避免处理期间积压的任务在下一轮重复消费 + */ export async function clearQueue(): Promise { try { await fs.writeFile(QUEUE_FILE, '', 'utf-8') @@ -51,6 +71,11 @@ export async function clearQueue(): Promise { } } +/** + * 尝试获取 batch worker 运行锁 + * - 读取锁文件中的 PID,若进程仍存在则返回 false(已有 worker 在运行) + * - 若锁文件不存在或持有进程已退出,则抢占锁 + */ export async function acquireLock(): Promise { try { try { @@ -75,6 +100,9 @@ export async function acquireLock(): Promise { } } +/** + * 释放 batch worker 运行锁(清空锁文件内容) + */ export async function releaseLock(): Promise { try { await fs.writeFile(LOCK_FILE, '', 'utf-8') diff --git a/src/services/rawDump/spawn.ts b/src/services/rawDump/spawn.ts index 8b2f88454..aa5d730d7 100644 --- a/src/services/rawDump/spawn.ts +++ b/src/services/rawDump/spawn.ts @@ -8,47 +8,58 @@ import { existsSync } from 'node:fs' import path from 'node:path' import { fileURLToPath } from 'node:url' +/** + * 解析 batch worker 入口脚本路径 + * - Dev 模式:使用 src/services/rawDump/batchWorker.ts + * - Build 模式:从 dist/services/rawDump/batchWorker.js 定位到 dist 根目录 + */ function resolveWorkerPath(): string { - const entry = process.execPath - const isDev = path.basename(entry).toLowerCase().startsWith('bun') - const __dirname = path.dirname(fileURLToPath(import.meta.url)) + const entry = process.execPath + const isDev = path.basename(entry).toLowerCase().startsWith('bun') + const __dirname = path.dirname(fileURLToPath(import.meta.url)) - if (isDev) { - return path.resolve(__dirname, 'batchWorker.ts') - } + if (isDev) { + return path.resolve(__dirname, 'batchWorker.ts') + } - // Build mode: locate dist root (same pattern as ripgrep.ts / audio-capture-napi) - // Bun.build strips the 'src/' prefix, so worker lands at dist/services/rawDump/batchWorker.js - const parts = __dirname.split(path.sep) - const distIdx = parts.lastIndexOf('dist') - if (distIdx !== -1) { - const distRoot = parts.slice(0, distIdx + 1).join(path.sep) - return path.resolve(distRoot, 'services', 'rawDump', 'batchWorker.js') - } + // Build mode: locate dist root (same pattern as ripgrep.ts / audio-capture-napi) + // Bun.build strips the 'src/' prefix, so worker lands at dist/services/rawDump/batchWorker.js + const parts = __dirname.split(path.sep) + const distIdx = parts.lastIndexOf('dist') + if (distIdx !== -1) { + const distRoot = parts.slice(0, distIdx + 1).join(path.sep) + return path.resolve(distRoot, 'services', 'rawDump', 'batchWorker.js') + } - return path.resolve(__dirname, 'batchWorker.js') + return path.resolve(__dirname, 'batchWorker.js') } +/** + * 解析运行时环境 + * 优先检测当前进程是 bun 还是 node,返回对应运行时路径 + * 若无法检测(如编译后的独立二进制),尝试在 PATH 中查找 bun 或 node + * @returns { entry: 运行时路径, isBun: 是否为 bun } 或 null + */ function resolveRuntime(): { entry: string; isBun: boolean } | null { - const execPath = process.execPath - const basename = path.basename(execPath).toLowerCase() + const execPath = process.execPath + const basename = path.basename(execPath).toLowerCase() - if (basename.startsWith('bun')) { - return { entry: execPath, isBun: true } - } - if (basename.startsWith('node')) { - return { entry: execPath, isBun: false } - } + if (basename.startsWith('bun')) { + return { entry: execPath, isBun: true } + } + if (basename.startsWith('node')) { + return { entry: execPath, isBun: false } + } - // Compiled binary (e.g. csc-darwin-arm64): find bun or node on PATH - if (typeof Bun !== 'undefined' && Bun.which) { - const bun = Bun.which('bun') - if (bun) return { entry: bun, isBun: true } - const node = Bun.which('node') - if (node) return { entry: node, isBun: false } - } + // Compiled binary (e.g. csc-darwin-arm64): find bun or node on PATH + if (typeof Bun !== 'undefined' && Bun.which) { + const bun = Bun.which('bun') + if (bun) return { entry: bun, isBun: true } + const node = Bun.which('node') + if (node) return { entry: node, isBun: false } + } - return null + return null } /** @@ -56,32 +67,30 @@ function resolveRuntime(): { entry: string; isBun: boolean } | null { * @returns 是否成功 spawn(false 表示应 fallback 到内联启动) */ export function spawnBatchWorker(): boolean { - const workerPath = resolveWorkerPath() - const runtime = resolveRuntime() + const workerPath = resolveWorkerPath() + const runtime = resolveRuntime() - // Worker file missing or no suitable runtime (compiled binary without external worker file) - if (!existsSync(workerPath) || !runtime) { - return false - } + // Worker file missing or no suitable runtime (compiled binary without external worker file) + if (!existsSync(workerPath) || !runtime) { + return false + } - const args = runtime.isBun - ? ['run', workerPath] - : [workerPath] + const args = runtime.isBun ? ['run', workerPath] : [workerPath] - try { - const child = spawn(runtime.entry, args, { - detached: true, - windowsHide: true, - stdio: 'ignore', - }) + try { + const child = spawn(runtime.entry, args, { + detached: true, + windowsHide: true, + stdio: 'ignore', + }) - child.on('error', (err) => { - console.error('[raw-dump] batch worker spawn error:', err.message) - }) + child.on('error', err => { + console.error('[raw-dump] batch worker spawn error:', err.message) + }) - child.unref() - return true - } catch { - return false - } + child.unref() + return true + } catch { + return false + } } diff --git a/src/services/rawDump/state.ts b/src/services/rawDump/state.ts index 64ecad61f..49802853d 100644 --- a/src/services/rawDump/state.ts +++ b/src/services/rawDump/state.ts @@ -13,6 +13,10 @@ const STATE_DIR = path.join(os.homedir(), '.claude') const STATE_FILE = path.join(STATE_DIR, 'csc-raw-dump-state.json') const STATE_LOCK_FILE = path.join(STATE_DIR, 'csc-raw-dump-state.lock') +/** + * 创建空的 RawDumpState 对象 + * 作为 state 文件不存在或解析失败时的默认值 + */ function createEmptyState(): RawDumpState { return { conversation: {}, @@ -21,6 +25,12 @@ function createEmptyState(): RawDumpState { } } +/** + * 尝试获取 state 文件的进程锁 + * - 读取锁文件中的 PID,若进程仍存在则返回 false(锁被占用) + * - 若锁文件不存在或持有进程已退出(zombie),则抢占锁 + * - 使用 kill(pid, 0) 检测进程是否存在 + */ function acquireStateLock(): boolean { try { try { @@ -44,6 +54,9 @@ function acquireStateLock(): boolean { } } +/** + * 释放 state 文件锁(清空锁文件内容) + */ function releaseStateLock(): void { try { writeFileSync(STATE_LOCK_FILE, '', 'utf-8') @@ -52,6 +65,11 @@ function releaseStateLock(): void { } } +/** + * 带锁执行异步函数 + * - 循环尝试获取锁,超时 5 秒后降级为无锁执行(避免永久挂起) + * - 执行完成后无论成功与否都释放锁(finally 块保证) + */ async function withStateLock(fn: () => Promise): Promise { const start = Date.now() while (!acquireStateLock()) { @@ -59,7 +77,7 @@ async function withStateLock(fn: () => Promise): Promise { // 5 秒超时:降级为无锁执行,避免永久挂起 break } - await new Promise((r) => setTimeout(r, 10)) + await new Promise(r => setTimeout(r, 10)) } try { return await fn() @@ -68,6 +86,11 @@ async function withStateLock(fn: () => Promise): Promise { } } +/** + * 从磁盘读取 state 文件 + * - 带文件锁,保证多进程读写安全 + * - 解析失败或文件不存在时返回空 state + */ export async function readState(): Promise { return withStateLock(async () => { try { @@ -84,6 +107,12 @@ export async function readState(): Promise { }) } +/** + * 将 state 对象写入磁盘 + * - 带文件锁,保证多进程读写安全 + * - 自动创建目录(recursive: true) + * - JSON 格式化输出(便于调试) + */ export async function writeState(state: RawDumpState): Promise { return withStateLock(async () => { await fs.mkdir(STATE_DIR, { recursive: true }) diff --git a/src/services/rawDump/worker.ts b/src/services/rawDump/worker.ts index 807f11c51..083774b46 100644 --- a/src/services/rawDump/worker.ts +++ b/src/services/rawDump/worker.ts @@ -46,11 +46,20 @@ const REQUEST_TIMEOUT_MS = 30_000 // 单次 HTTP 请求超时,防止 fetch 永 type RepoInfo = Awaited> +/** + * 将毫秒时间戳格式化为 ISO 字符串,保留到秒级 + * @param ms 毫秒时间戳 + * @returns ISO 格式字符串,如 "2024-01-01T12:00:00Z" + */ function formatIso(ms: number | undefined): string { if (!ms) return '' return new Date(ms).toISOString().replace(/\.\d{3}Z$/, 'Z') } +/** + * 从环境变量或默认配置中解析 Raw Dump API 的 Base URL + * 支持通过 COSTRICT_RAW_DUMP_BASE_URL 或 CSC_RAW_DUMP_BASE_URL 覆盖 + */ function resolveRawDumpBaseUrl(baseUrl?: string): string { const explicit = process.env.COSTRICT_RAW_DUMP_BASE_URL || process.env.CSC_RAW_DUMP_BASE_URL @@ -74,6 +83,12 @@ function resolveRawDumpBaseUrl(baseUrl?: string): string { return raw.replace(/\/cloud-api$/, '') } +/** + * 拼接 Raw Dump API 的完整 URL + * @param baseUrl API 基础地址 + * @param endpoint API 路径,如 /raw-store/task-conversation + * @param isAnonymous 是否使用匿名接口(无 Authorization header) + */ function getRawDumpUrl( baseUrl: string, endpoint: string, @@ -81,11 +96,20 @@ function getRawDumpUrl( ): string { const suffix = endpoint.startsWith('/') ? endpoint : `/${endpoint}` const prefix = isAnonymous - ? '/user-indicator/public' + ? '/user-indicator/public/api/v1' : '/user-indicator/api/v1' return `${baseUrl}${prefix}${suffix}` } +/** + * 发送 JSON POST 请求,支持重试和本地存储模式 + * - 本地模式:写入本地 JSON 文件,不调用服务端 + * - 网络错误/429:最多重试 3 次,指数退避 + * @param baseUrl API 基础地址 + * @param headers HTTP 请求头 + * @param endpoint API 路径 + * @param body 请求体(会自动序列化为 JSON) + */ async function postJson( baseUrl: string, headers: Headers, @@ -167,6 +191,10 @@ async function postJson( throw lastError || new Error(`${endpoint} failed after retries`) } +/** + * 从 JWT payload 中提取用户信息 + * 优先使用 refresh token 中的数据(更完整),fallback 到 access token + */ function parseUser( accessPayload: JwtPayload, refreshPayload?: JwtPayload | null, @@ -191,6 +219,10 @@ function parseUser( } } +/** + * 根据当前操作系统返回友好的 OS 名称标识 + * darwin → MacOS, win32 → Windows, linux → Linux, 其他返回原值 + */ function detectOs(): string { const map: Record = { darwin: 'MacOS', @@ -200,6 +232,12 @@ function detectOs(): string { return map[process.platform] ?? process.platform } +/** + * 加载 CoStrict 认证凭证,进行 token 刷新,并构建 API 请求所需的 headers + * - 读取本地 credentials 文件 + * - 检查 token 是否过期,过期则自动刷新 + * - 构造包含 Authorization、User-Agent 等的 Headers 对象 + */ export async function auth() { log.debug('auth start') let creds = await loadCoStrictCredentials() @@ -290,8 +328,14 @@ export async function auth() { } } -// 从 JSONL 文件加载会话消息 -// csc 的会话文件名可能是 ses_{hash}.jsonl 或 {uuid}.jsonl +/** + * 从 JSONL 文件加载会话消息列表 + * - 扫描 sessionDir 目录下所有 .jsonl 文件 + * - 优先读取文件名包含 sessionId 的文件(减少无意义解析) + * - 通过 sessionId、messageId 匹配目标记录 + * - csc 的会话文件名可能是 ses_{hash}.jsonl 或 {uuid}.jsonl + * @returns 匹配的 JSONL 行数组(每行是一个消息对象),无匹配则返回空数组 + */ export async function loadSessionMessages( sessionDir: string, sessionId: string, @@ -364,6 +408,10 @@ export async function loadSessionMessages( return [] } +/** + * 在消息列表中查找指定 ID 的消息 + * 支持通过 message.uuid 或 message.id 匹配 + */ function findMessage( messages: Record[], messageID: string, @@ -375,6 +423,11 @@ function findMessage( ) } +/** + * 查找 assistant message 对应的父级 user message + * 在 csc 中,user message 通常紧邻 assistant message 之前 + * 从 assistant 位置向前遍历,找到第一个 type === 'user' 的消息 + */ function findParentUserMessage( messages: Record[], assistantMsg: Record, @@ -388,6 +441,10 @@ function findParentUserMessage( return undefined } +/** + * 判断本轮对话的发送者类型:agent 或 user + * 通过 assistant 的 mode、agent 字段、isSidechain 标记、user 的 isMeta 标记综合判断 + */ function detectSender( assistant: Record, user: Record | undefined, @@ -408,6 +465,10 @@ function detectSender( return 'user' } +/** + * 从消息对象中提取纯文本内容 + * 兼容多种格式:直接 content 字符串,或 content 数组(block type === 'text') + */ function extractTextContent(msg: Record): string { const content = (msg.message as Record)?.content if (!Array.isArray(content)) return String(content ?? '') @@ -417,6 +478,10 @@ function extractTextContent(msg: Record): string { .join('\n') } +/** + * 将 structured patch 格式转换为 unified diff 格式 + * 用于将 git diff --no-ext-diff 格式的 patch 转为标准 unified diff 字符串 + */ function structuredPatchToUnifiedDiff( filePath: string, patches: Array>, @@ -437,6 +502,10 @@ function structuredPatchToUnifiedDiff( return header + '\n' + hunks.join('\n') + '\n' } +/** + * 根据旧字符串和新字符串生成 unified diff + * 用于 Edit、NotebookEdit 等工具调用的结果 diff + */ function generateStringDiff( filePath: string, oldStr: string, @@ -453,6 +522,12 @@ function generateStringDiff( return header + '\n' + hunk + '\n' + body + '\n' } +/** + * 从 assistant message 中提取 unified diff + * 优先从子 user message 的 toolUseResult 获取 gitDiff.patch(已格式化的 unified diff) + * fallback 到 structuredPatch 转换或从 tool_use input 参数生成 diff + * @returns 包含 diff 字符串、行数、涉及文件的结构 + */ function extractToolDiff( msg: Record, allMessages?: Record[], @@ -594,6 +669,10 @@ function extractToolDiff( return { diff, diff_lines: countDiffLines(diff), files: Array.from(files) } } +/** + * 从 assistant message 中提取 token 使用量 + * 包含 input_tokens、output_tokens、cache 相关的 token 数量 + */ function extractUsage(msg: Record) { const usage = (msg.message as Record)?.usage as | Record @@ -606,26 +685,78 @@ function extractUsage(msg: Record) { } } -function extractError(msg: Record) { - const error = msg.error as Record | undefined - if (!error) return {} - - const name = String(error.name ?? 'UnknownError') - const message = typeof error.message === 'string' ? error.message : name - const errorCode = - name === 'ProviderAuthError' - ? 401 - : name === 'ContextOverflowError' || name === 'MessageOutputLengthError' - ? 413 - : name === 'MessageAbortedError' - ? 499 - : name === 'APIError' && typeof error.statusCode === 'number' - ? error.statusCode - : 500 - - return { error_code: errorCode, error_reason: message } +const SDK_ERROR_CODE_MAP: Record = { + authentication_failed: 401, + billing_error: 402, + rate_limit: 429, + invalid_request: 400, + server_error: 500, + max_output_tokens: 413, + unknown: 500, } +/** + * 从 assistant message 中提取错误信息 + * 支持三种格式:字符串 error(SDK 标准格式)、Error 对象(CoStrict provider 格式)、isApiErrorMessage 标记 + * 将错误映射为 HTTP 状态码和错误原因字符串 + */ +function extractError(msg: Record): { + error_code?: number + error_reason?: string +} { + const error = msg.error + + // Case 1: msg.error is a string (SDKAssistantMessageError — normal path) + if (typeof error === 'string') { + const errorCode = SDK_ERROR_CODE_MAP[error] ?? 500 + // Prefer errorDetails (diagnostic info) over the raw string + const errorDetails = msg.errorDetails + const reason = + typeof errorDetails === 'string' && errorDetails ? errorDetails : error + return { error_code: errorCode, error_reason: reason } + } + + // Case 2: msg.error is an Error object (CoStrict provider —不规范存储) + if (typeof error === 'object' && error !== null) { + const err = error as Record + // Try apiError field first for coarse classification + 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 } + } + + // Case 3: no error field, but marked as API error (e.g. image size errors) + if (msg.isApiErrorMessage) { + return { error_code: 500, error_reason: 'api_error_unclassified' } + } + + return {} +} + +/** + * 上报一轮对话详情到 /raw-store/task-conversation + * - 在 messages 中查找目标 assistant message(优先按 ID,找不到则用最后一个 assistant) + * - 构建 conversation payload 并发送 POST 请求 + * - 跳过无实质内容的中间轮次(无用户输入、无 assistant 输出、无 diff) + * - 上报成功后在 state 中设置去重标记 + * @returns 是否成功上报(false 表示跳过或查找失败,不抛异常) + */ export async function uploadConversation( payload: { sessionID: string @@ -783,6 +914,12 @@ export async function uploadConversation( const SUMMARY_DEDUP_WINDOW_MS = 5 * 60 * 1000 // 同一 session 5 分钟内 summary 只上报一次 +/** + * 上报一个 session 的摘要信息到 /raw-store/task-summary + * 摘要以 session 为维度,5 分钟内同一 session 只上报一次(通过 state.summary 去重) + * 包含 session 的起止时间、用户信息、客户端信息等 + * 即使 conversation 上报失败,summary 仍会独立上报 + */ export async function uploadSummary( payload: { sessionID: string @@ -833,6 +970,14 @@ export async function uploadSummary( log.info('summary uploaded', { task_id: payload.sessionID }) } +/** + * 上报目录下的新提交到 /raw-store/commit + * - 获取上次上报的 commit_id 作为起点,本次只上报之后的提交 + * - 每次最多上报 50 个 commit,避免触发限流 + * - 每个 commit 单独 POST,成功后立即更新 state.commits 进度 + * - 批次间添加延迟(每 10 个 commit 暂停 500ms)避免并发过高 + * @returns 上报的 commit 数量 + */ export async function uploadCommits( payload: { directory: string @@ -913,21 +1058,44 @@ export async function uploadCommits( return commits.length } +/** + * 从环境变量中解析 worker 入口传入的 payload + * 抛出错误如果环境变量不存在或 JSON 解析失败 + */ function parseWorkerPayload(): RawDumpEventPayload { const raw = process.env[RAW_DUMP_EVENT_ENV_KEY] if (!raw) throw new Error('missing raw dump payload') return JSON.parse(raw) as RawDumpEventPayload } +/** + * 获取 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 + */ function normalizeProjectPath(dir: string): string { // 将 /Users/linkai/code/csc 转换为 -Users-linkai-code-csc return dir.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, @@ -947,6 +1115,15 @@ export function getSessionDirectory( return candidates.find(d => d) || directory } +/** + * Raw Dump Worker 入口函数 + * 在独立进程中运行,执行完整的上报流程: + * 1. 解析环境变量中的 payload + * 2. 加载 session 消息列表 + * 3. 加载/上报 conversation、summary、commits + * 4. 写入 state 文件 + * 任何阶段失败都会记录日志但不会抛异常给主进程 + */ export async function runRawDumpWorker() { try { const payload = parseWorkerPayload() @@ -1031,6 +1208,12 @@ export async function runRawDumpWorker() { } } +/** + * 认证兜底逻辑,优先尝试正常认证,失败后根据模式降级: + * - 本地模式(isLocalDumpMode):使用假的匿名凭证,允许本地存储模式运行 + * - 非本地模式:降级为匿名接口(无 Authorization header) + * 确保即使认证失败,上报流程仍可继续 + */ export async function authWithFallback(): Promise< Awaited> > {