1. 修复匿名上报url错误; 2.增加注释; 3.增加异常场景处理提升获取对话diff的健壮性“

This commit is contained in:
zbc 2026-05-26 16:39:19 +08:00
parent 7ae2c9ba08
commit c8586ee6ee
9 changed files with 620 additions and 138 deletions

View File

@ -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),
})
}
}

View File

@ -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
* - uploadConversationuploadSummaryuploadCommits
* - 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()
}

View File

@ -8,6 +8,11 @@ import { promisify } from 'node:util'
const execFileAsync = promisify(execFile)
/**
* git
* - git 使cwdencodingmaxBuffer
* -
*/
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('')
}

View File

@ -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

View File

@ -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')
}

View File

@ -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')

View File

@ -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 spawnfalse 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
}
}

View File

@ -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 })

View File

@ -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
* - AuthorizationUser-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
* - sessionIdmessageId
* - 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 modeagent 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
* EditNotebookEdit 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_tokensoutput_tokenscache 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
* errorSDK 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. / conversationsummarycommits
* 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>>
> {