1. 修复匿名上报url错误; 2.增加注释; 3.增加异常场景处理提升获取对话diff的健壮性“
This commit is contained in:
parent
7ae2c9ba08
commit
c8586ee6ee
|
|
@ -54,6 +54,22 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
|||
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<string, unknown>).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<string, unknown> {
|
||||
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),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string, { repoInfo: RepoInfo; ts: number }>()
|
||||
const REPO_CACHE_TTL_MS = 60_000
|
||||
|
||||
/**
|
||||
* 批量获取 Git 仓库信息(带缓存)
|
||||
* 同一 directory 在 60 秒内不重复调用 git 命令
|
||||
* @returns 缓存的 RepoInfo 或新获取的信息
|
||||
*/
|
||||
async function getCachedRepoInfo(directory: string): Promise<RepoInfo> {
|
||||
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<string, { messages: Record<string, unknown>[]; ts: number }>()
|
||||
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) {
|
||||
/**
|
||||
* 批量获取 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<ReturnType<typeof readState>>) {
|
||||
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<ReturnType<typeof readState>>,
|
||||
) {
|
||||
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<ReturnType<typeof rea
|
|||
try {
|
||||
// conversation
|
||||
const conversationUploaded = await uploadConversation(
|
||||
{ sessionID: task.sessionID, messageID: task.messageID, directory: task.directory, messages },
|
||||
{
|
||||
sessionID: task.sessionID,
|
||||
messageID: task.messageID,
|
||||
directory: task.directory,
|
||||
messages,
|
||||
},
|
||||
authData,
|
||||
state,
|
||||
{ repoInfo },
|
||||
|
|
@ -100,9 +161,14 @@ async function processTask(task: QueueTask, state: Awaited<ReturnType<typeof rea
|
|||
)
|
||||
|
||||
// commits(限制频率,避免重复上报)
|
||||
await uploadCommits({ directory: task.directory }, authData, state, { repoInfo })
|
||||
await uploadCommits({ directory: task.directory }, authData, state, {
|
||||
repoInfo,
|
||||
})
|
||||
|
||||
log.info('task completed', { sessionID: task.sessionID, conversationUploaded })
|
||||
log.info('task completed', {
|
||||
sessionID: task.sessionID,
|
||||
conversationUploaded,
|
||||
})
|
||||
} catch (err) {
|
||||
log.error('task failed', {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
|
|
@ -112,6 +178,11 @@ async function processTask(task: QueueTask, state: Awaited<ReturnType<typeof rea
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行一轮 batch 处理
|
||||
* 三道防线:进程内重入保护 → 跨进程文件锁 → 读取后立即清空队列
|
||||
* 流程:读取队列 → 清空队列 → 去重 → 逐个处理任务 → 一次性写入 state
|
||||
*/
|
||||
async function runBatch() {
|
||||
// 第一道防线:同进程重入保护
|
||||
if (isRunning) {
|
||||
|
|
@ -151,7 +222,9 @@ async function runBatch() {
|
|||
}
|
||||
}
|
||||
|
||||
const uniqueTasks = Array.from(deduped.values()).sort((a, b) => 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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string> {
|
||||
try {
|
||||
const { stdout } = await execFileAsync('git', args, {
|
||||
|
|
@ -22,6 +27,12 @@ async function gitExec(args: string[], cwd: string): Promise<string> {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 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<string> {
|
||||
export async function getRawDiff(
|
||||
cwd: string,
|
||||
from?: string,
|
||||
to?: string,
|
||||
): Promise<string> {
|
||||
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<string> {
|
||||
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<string>()
|
||||
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<typeof item> => !!item)
|
||||
}
|
||||
|
||||
export async function getCommitLog(cwd: string, lastCommit?: string): Promise<string> {
|
||||
/**
|
||||
* 获取 git commit 日志
|
||||
* - 若指定 lastCommit:获取该 commit 之后的所有新 commit
|
||||
* - 否则:获取最近一天的所有 commit
|
||||
* - 按 author 过滤(仅当前 git 用户提交的)
|
||||
* - 最多返回 50 条
|
||||
*/
|
||||
export async function getCommitLog(
|
||||
cwd: string,
|
||||
lastCommit?: string,
|
||||
): Promise<string> {
|
||||
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<string> {
|
||||
/**
|
||||
* 获取单个 commit 的完整 diff(包含文件变更统计)
|
||||
* 使用 --diff-filter=ACDMR 只显示新增(Add)、复制(Copy)、删除(Delete)、修改(Modify)、重命名(Rename) 的文件
|
||||
*/
|
||||
export async function getCommitDiff(
|
||||
cwd: string,
|
||||
commitId: string,
|
||||
): Promise<string> {
|
||||
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('')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,16 +17,35 @@ let batchWorkerSpawned = false
|
|||
const lastEnqueueMap = new Map<string, number>()
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>,
|
||||
type: 'conversation' | 'summary' | 'commit',
|
||||
body: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
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')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<QueueTask, 'enqueuedAt'>): 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<QueueTask[]> {
|
||||
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<QueueTask[]> {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空队列文件(truncate 为空)
|
||||
* 在 batch worker 读取队列后立即调用,避免处理期间积压的任务在下一轮重复消费
|
||||
*/
|
||||
export async function clearQueue(): Promise<void> {
|
||||
try {
|
||||
await fs.writeFile(QUEUE_FILE, '', 'utf-8')
|
||||
|
|
@ -51,6 +71,11 @@ export async function clearQueue(): Promise<void> {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 尝试获取 batch worker 运行锁
|
||||
* - 读取锁文件中的 PID,若进程仍存在则返回 false(已有 worker 在运行)
|
||||
* - 若锁文件不存在或持有进程已退出,则抢占锁
|
||||
*/
|
||||
export async function acquireLock(): Promise<boolean> {
|
||||
try {
|
||||
try {
|
||||
|
|
@ -75,6 +100,9 @@ export async function acquireLock(): Promise<boolean> {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 释放 batch worker 运行锁(清空锁文件内容)
|
||||
*/
|
||||
export async function releaseLock(): Promise<void> {
|
||||
try {
|
||||
await fs.writeFile(LOCK_FILE, '', 'utf-8')
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<T>(fn: () => Promise<T>): Promise<T> {
|
||||
const start = Date.now()
|
||||
while (!acquireStateLock()) {
|
||||
|
|
@ -59,7 +77,7 @@ async function withStateLock<T>(fn: () => Promise<T>): Promise<T> {
|
|||
// 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<T>(fn: () => Promise<T>): Promise<T> {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从磁盘读取 state 文件
|
||||
* - 带文件锁,保证多进程读写安全
|
||||
* - 解析失败或文件不存在时返回空 state
|
||||
*/
|
||||
export async function readState(): Promise<RawDumpState> {
|
||||
return withStateLock(async () => {
|
||||
try {
|
||||
|
|
@ -84,6 +107,12 @@ export async function readState(): Promise<RawDumpState> {
|
|||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 state 对象写入磁盘
|
||||
* - 带文件锁,保证多进程读写安全
|
||||
* - 自动创建目录(recursive: true)
|
||||
* - JSON 格式化输出(便于调试)
|
||||
*/
|
||||
export async function writeState(state: RawDumpState): Promise<void> {
|
||||
return withStateLock(async () => {
|
||||
await fs.mkdir(STATE_DIR, { recursive: true })
|
||||
|
|
|
|||
|
|
@ -46,11 +46,20 @@ const REQUEST_TIMEOUT_MS = 30_000 // 单次 HTTP 请求超时,防止 fetch 永
|
|||
|
||||
type RepoInfo = Awaited<ReturnType<typeof getRepoInfo>>
|
||||
|
||||
/**
|
||||
* 将毫秒时间戳格式化为 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<string, string> = {
|
||||
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<string, unknown>[],
|
||||
messageID: string,
|
||||
|
|
@ -375,6 +423,11 @@ function findMessage(
|
|||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 查找 assistant message 对应的父级 user message
|
||||
* 在 csc 中,user message 通常紧邻 assistant message 之前
|
||||
* 从 assistant 位置向前遍历,找到第一个 type === 'user' 的消息
|
||||
*/
|
||||
function findParentUserMessage(
|
||||
messages: Record<string, unknown>[],
|
||||
assistantMsg: Record<string, unknown>,
|
||||
|
|
@ -388,6 +441,10 @@ function findParentUserMessage(
|
|||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断本轮对话的发送者类型:agent 或 user
|
||||
* 通过 assistant 的 mode、agent 字段、isSidechain 标记、user 的 isMeta 标记综合判断
|
||||
*/
|
||||
function detectSender(
|
||||
assistant: Record<string, unknown>,
|
||||
user: Record<string, unknown> | undefined,
|
||||
|
|
@ -408,6 +465,10 @@ function detectSender(
|
|||
return 'user'
|
||||
}
|
||||
|
||||
/**
|
||||
* 从消息对象中提取纯文本内容
|
||||
* 兼容多种格式:直接 content 字符串,或 content 数组(block type === 'text')
|
||||
*/
|
||||
function extractTextContent(msg: Record<string, unknown>): string {
|
||||
const content = (msg.message as Record<string, unknown>)?.content
|
||||
if (!Array.isArray(content)) return String(content ?? '')
|
||||
|
|
@ -417,6 +478,10 @@ function extractTextContent(msg: Record<string, unknown>): string {
|
|||
.join('\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 structured patch 格式转换为 unified diff 格式
|
||||
* 用于将 git diff --no-ext-diff 格式的 patch 转为标准 unified diff 字符串
|
||||
*/
|
||||
function structuredPatchToUnifiedDiff(
|
||||
filePath: string,
|
||||
patches: Array<Record<string, unknown>>,
|
||||
|
|
@ -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<string, unknown>,
|
||||
allMessages?: Record<string, unknown>[],
|
||||
|
|
@ -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<string, unknown>) {
|
||||
const usage = (msg.message as Record<string, unknown>)?.usage as
|
||||
| Record<string, number>
|
||||
|
|
@ -606,26 +685,78 @@ function extractUsage(msg: Record<string, unknown>) {
|
|||
}
|
||||
}
|
||||
|
||||
function extractError(msg: Record<string, unknown>) {
|
||||
const error = msg.error as Record<string, unknown> | 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<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 中提取错误信息
|
||||
* 支持三种格式:字符串 error(SDK 标准格式)、Error 对象(CoStrict provider 格式)、isApiErrorMessage 标记
|
||||
* 将错误映射为 HTTP 状态码和错误原因字符串
|
||||
*/
|
||||
function extractError(msg: Record<string, unknown>): {
|
||||
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<string, unknown>
|
||||
// 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<ReturnType<typeof auth>>
|
||||
> {
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user