1. 修改上报队列机制,确保上报失败有可靠的重试机制,不会静默失败;2.修改conversation上报遗漏的问题;

This commit is contained in:
zbc 2026-05-27 09:48:38 +08:00
parent c8586ee6ee
commit 455489cc89
9 changed files with 295 additions and 182 deletions

View File

@ -225,7 +225,7 @@ if (!existing || task.enqueuedAt > existing.enqueuedAt) {
### 2. Conversation 去重(磁盘) ### 2. Conversation 去重(磁盘)
```typescript ```typescript
// ~/.claude/csc-raw-dump-state.json // ~/.claude/raw-dump/csc-state.json
{ {
"conversation": { "conversation": {
"taskID:requestID": true "taskID:requestID": true
@ -301,21 +301,36 @@ import { refreshCoStrictToken } from '../../costrict/provider/token.js'
| `CSC_RAW_DUMP_BASE_URL` | 自定义上报 base URL | 从凭证读取 | | `CSC_RAW_DUMP_BASE_URL` | 自定义上报 base URL | 从凭证读取 |
| `COSTRICT_RAW_DUMP_BASE_URL` | 兼容 opencode 的自定义 URL | 从凭证读取 | | `COSTRICT_RAW_DUMP_BASE_URL` | 兼容 opencode 的自定义 URL | 从凭证读取 |
| `COSTRICT_BASE_URL` | CoStrict 服务地址 | `https://zgsm.sangfor.com` | | `COSTRICT_BASE_URL` | CoStrict 服务地址 | `https://zgsm.sangfor.com` |
| `CSC_RAW_DUMP_LOCAL_MODE` | 本地留存模式,数据只写入本地文件不上报服务端 | `false` | | `CSC_RAW_DUMP_MODE` | 上报模式:`0`=禁用, `1`=remote, `2`=local, `3`=both | `1`Remote |
| `CSC_RAW_DUMP_LOCAL_DIR` | 本地留存目录 | `~/.claude/raw-dump-local` | | `CSC_RAW_DUMP_DIR` | 本地留存目录mode=2/3 时使用) | `~/.claude/raw-dump` |
---
## 上报模式(`CSC_RAW_DUMP_MODE`
`CSC_RAW_DUMP_MODE` 环境变量控制数据输出行为,支持 4 种模式:
| 值 | 模式 | 说明 |
|----|------|------|
| `0` | 禁用Disabled | 完全跳过,不写入队列、不触发任何 HTTP 请求 |
| `1` | 远程上报Remote | 默认值,数据 POST 到 CoStrict 服务端 |
| `2` | 本地留存Local | 数据只写入本地 JSON 文件,不上报服务端 |
| `3` | 双写Both | 同时写入本地文件和上报服务端 |
默认值为 `1`Remote即正常运行时数据上报到 CoStrict 服务端。
--- ---
## 本地留存模式(调试排障) ## 本地留存模式(调试排障)
通过环境变量开启,开启后数据**不上报服务端**,仅写入本地 JSON 文件: 通过设置 `CSC_RAW_DUMP_MODE=2``3` 开启,开启后数据写入本地 JSON 文件:
```bash ```bash
# 开启本地留存模式 # 开启本地留存模式
export CSC_RAW_DUMP_LOCAL_MODE=1 export CSC_RAW_DUMP_MODE=2
# 可选:自定义留存目录(默认 ~/.claude/raw-dump-local # 可选:自定义留存目录(默认 ~/.claude/raw-dump)
export CSC_RAW_DUMP_LOCAL_DIR=/tmp/raw-dump-debug export CSC_RAW_DUMP_DIR=/tmp/raw-dump-debug
``` ```
留存文件结构: 留存文件结构:
@ -352,7 +367,7 @@ export CSC_RAW_DUMP_LOCAL_DIR=/tmp/raw-dump-debug
### 状态文件 ### 状态文件
``` ```
~/.claude/csc-raw-dump-state.json ~/.claude/raw-dump/csc-state.json
``` ```
内容格式: 内容格式:
@ -373,7 +388,7 @@ export CSC_RAW_DUMP_LOCAL_DIR=/tmp/raw-dump-debug
### 日志文件 ### 日志文件
``` ```
~/.claude/csc-raw-dump.log ~/.claude/raw-dump/csc-raw-dump.log
``` ```
主进程和 batch worker 的日志都追加写入该文件。由于 worker 是 detached 进程(`stdio: 'ignore'`),日志只能通过文件查看。 主进程和 batch worker 的日志都追加写入该文件。由于 worker 是 detached 进程(`stdio: 'ignore'`),日志只能通过文件查看。
@ -431,7 +446,7 @@ export CSC_RAW_DUMP_LOCAL_DIR=/tmp/raw-dump-debug
export CSC_RAW_DUMP_DEBUG=1 export CSC_RAW_DUMP_DEBUG=1
# 查看日志 # 查看日志
tail -f ~/.claude/csc-raw-dump.log tail -f ~/.claude/raw-dump/csc-raw-dump.log
``` ```
关键日志标识: 关键日志标识:
@ -443,11 +458,11 @@ tail -f ~/.claude/csc-raw-dump.log
常用排查命令: 常用排查命令:
```bash ```bash
# 查看 state 文件 # 查看 state 文件
cat ~/.claude/csc-raw-dump-state.json cat ~/.claude/raw-dump/csc-state.json
# 查看队列文件 # 查看队列文件
cat ~/.claude/csc-raw-dump-queue.jsonl cat ~/.claude/raw-dump/csc-work-queue.json
# 查看是否有 worker 在运行(锁文件) # 查看是否有 worker 在运行(锁文件)
cat ~/.claude/csc-raw-dump.lock cat ~/.claude/raw-dump/csc-work-queue.lock
``` ```

View File

@ -2,6 +2,14 @@
* Raw Dump Batch Worker * Raw Dump Batch Worker
* 429 * 429
* setTimeout * setTimeout
*
*
* -
* - /
* - attemptCount >= MAX_ATTEMPTS
* - attemptCount++ batch
*
* batch 线
*/ */
import { import {
@ -11,54 +19,56 @@ import {
authWithFallback, authWithFallback,
} from './worker.js' } from './worker.js'
import { import {
readQueue, loadQueue,
clearQueue,
acquireLock, acquireLock,
releaseLock, releaseLock,
peekTask,
removeTask,
getQueue,
flushQueue,
MAX_ATTEMPTS,
type QueueTask, type QueueTask,
} from './queue.js' } from './queue.js'
import { readState, writeState } from './state.js' import { readState, writeState } from './state.js'
import type { RawDumpError } from './types.js'
import { getSessionDirectory, loadSessionMessages } from './worker.js' import { getSessionDirectory, loadSessionMessages } from './worker.js'
import { getRepoInfo } from './git.js' import { getRepoInfo } from './git.js'
import { createLogger } from './logger.js' import { createLogger } from './logger.js'
const log = createLogger('raw-dump-batch') const log = createLogger('raw-dump-batch')
/**
* API endpoint
* "/raw-store/task-conversation failed: 401 ..."
*/
function extractEndpointFromError(errorMsg: string): string | undefined {
const match = errorMsg.match(/\/(raw-store\/[^\s]+)/)
return match?.[1]
}
type RepoInfo = Awaited<ReturnType<typeof getRepoInfo>> type RepoInfo = Awaited<ReturnType<typeof getRepoInfo>>
const BATCH_INTERVAL_MS = 120_000 // 每轮间隔2 分钟),降低内联运行时的 CPU 影响 const BATCH_INTERVAL_MS = 120_000
// Git repo 信息缓存,同一 directory 的多个 task 短时间内不需要重复 spawn git // Git repo 信息缓存,同一 directory 的多个 task 短时间内不需要重复 spawn git
const repoInfoCache = new Map<string, { repoInfo: RepoInfo; ts: number }>() const repoInfoCache = new Map<string, { repoInfo: RepoInfo; ts: number }>()
const REPO_CACHE_TTL_MS = 60_000 const REPO_CACHE_TTL_MS = 60_000
/**
* Git
* directory 60 git
* @returns RepoInfo
*/
async function getCachedRepoInfo(directory: string): Promise<RepoInfo> { async function getCachedRepoInfo(directory: string): Promise<RepoInfo> {
const cached = repoInfoCache.get(directory) const cached = repoInfoCache.get(directory)
if (cached && Date.now() - cached.ts < REPO_CACHE_TTL_MS) { if (cached && Date.now() - cached.ts < REPO_CACHE_TTL_MS) {
log.debug('repo info cache hit', { directory })
return cached.repoInfo return cached.repoInfo
} }
const repoInfo = await getRepoInfo(directory) const repoInfo = await getRepoInfo(directory)
repoInfoCache.set(directory, { repoInfo, ts: Date.now() }) repoInfoCache.set(directory, { repoInfo, ts: Date.now() })
return repoInfo return repoInfo
} }
// 进程内重入保护:文件锁不防同进程重入,必须用内存 flag 兜底
let isRunning = false let isRunning = false
const PARENT_PID = process.ppid const PARENT_PID = process.ppid
const IS_WORKER_PROCESS = process.argv[1]?.includes('batchWorker') || false const IS_WORKER_PROCESS = process.argv[1]?.includes('batchWorker') || false
/**
*
* - worker IS_WORKER_PROCESS = true
* - 使 kill(pid, 0)
* - 退 worker
*/
function isParentAlive(): boolean { function isParentAlive(): boolean {
if (!IS_WORKER_PROCESS) return true if (!IS_WORKER_PROCESS) return true
try { try {
@ -69,18 +79,13 @@ function isParentAlive(): boolean {
} }
} }
// Session messages 缓存:同一 session 的多个 task 短时间内不需要重复读取 JSONL // Session messages 缓存
const sessionMessagesCache = new Map< const sessionMessagesCache = new Map<
string, string,
{ messages: Record<string, unknown>[]; ts: number } { messages: Record<string, unknown>[]; ts: number }
>() >()
const SESSION_CACHE_TTL_MS = 60_000 const SESSION_CACHE_TTL_MS = 60_000
/**
* session
* sessionDir + sessionID 60 JSONL
* 100ms info 便
*/
async function getCachedSessionMessages( async function getCachedSessionMessages(
sessionDir: string, sessionDir: string,
sessionID: string, sessionID: string,
@ -89,18 +94,13 @@ async function getCachedSessionMessages(
const cacheKey = `${sessionDir}:${sessionID}` const cacheKey = `${sessionDir}:${sessionID}`
const cached = sessionMessagesCache.get(cacheKey) const cached = sessionMessagesCache.get(cacheKey)
if (cached && Date.now() - cached.ts < SESSION_CACHE_TTL_MS) { if (cached && Date.now() - cached.ts < SESSION_CACHE_TTL_MS) {
log.debug('session messages cache hit', { sessionID, messageID })
return cached.messages return cached.messages
} }
const start = Date.now() const start = Date.now()
const messages = await loadSessionMessages(sessionDir, sessionID, messageID) const messages = await loadSessionMessages(sessionDir, sessionID, messageID)
const elapsed = Date.now() - start const elapsed = Date.now() - start
if (elapsed > 100) { if (elapsed > 100) {
log.info('loadSessionMessages slow', { log.info('loadSessionMessages slow', { sessionID, elapsedMs: elapsed })
sessionID,
elapsedMs: elapsed,
messageCount: messages.length,
})
} }
sessionMessagesCache.set(cacheKey, { messages, ts: Date.now() }) sessionMessagesCache.set(cacheKey, { messages, ts: Date.now() })
return messages return messages
@ -108,11 +108,7 @@ async function getCachedSessionMessages(
/** /**
* *
* - session *
* - uploadConversationuploadSummaryuploadCommits
* - runBatch
* @param task
* @param state batch state
*/ */
async function processTask( async function processTask(
task: QueueTask, task: QueueTask,
@ -121,6 +117,7 @@ async function processTask(
log.info('processing task', { log.info('processing task', {
sessionID: task.sessionID, sessionID: task.sessionID,
messageID: task.messageID, messageID: task.messageID,
attempt: task.attemptCount,
}) })
const sessionDir = getSessionDirectory(task.directory, task.sessionID) const sessionDir = getSessionDirectory(task.directory, task.sessionID)
@ -135,56 +132,38 @@ async function processTask(
} }
const authData = await authWithFallback() const authData = await authWithFallback()
// 预加载 git 信息commits 和 repo 字段共享,避免每个 task 重复 spawn git 进程
const repoInfo = await getCachedRepoInfo(task.directory) const repoInfo = await getCachedRepoInfo(task.directory)
try { const conversationUploaded = await uploadConversation(
// conversation {
const conversationUploaded = await uploadConversation(
{
sessionID: task.sessionID,
messageID: task.messageID,
directory: task.directory,
messages,
},
authData,
state,
{ repoInfo },
)
// summary5 分钟内同一 session 只上报一次)
await uploadSummary(
{ sessionID: task.sessionID, directory: task.directory, messages },
authData,
state,
)
// commits限制频率避免重复上报
await uploadCommits({ directory: task.directory }, authData, state, {
repoInfo,
})
log.info('task completed', {
sessionID: task.sessionID, sessionID: task.sessionID,
conversationUploaded, messageID: task.messageID,
}) directory: task.directory,
} catch (err) { messages,
log.error('task failed', { },
error: err instanceof Error ? err.message : String(err), authData,
sessionID: task.sessionID, state,
}) { repoInfo },
throw err )
}
await uploadSummary(
{ sessionID: task.sessionID, directory: task.directory, messages },
authData,
state,
)
await uploadCommits({ directory: task.directory }, authData, state, {
repoInfo,
})
log.info('task completed', { sessionID: task.sessionID, conversationUploaded })
} }
/** /**
* batch * batch
* 线 * state
* state
*/ */
async function runBatch() { async function runBatch() {
// 第一道防线:同进程重入保护
if (isRunning) { if (isRunning) {
log.debug('runBatch already running in-process, skip') log.debug('runBatch already running in-process, skip')
return return
@ -192,27 +171,23 @@ async function runBatch() {
isRunning = true isRunning = true
try { try {
// 第二道防线:跨进程文件锁
if (!(await acquireLock())) { if (!(await acquireLock())) {
log.debug('another worker process holds the lock, skip') log.debug('another worker process holds the lock, skip')
return return
} }
try { try {
const tasks = await readQueue() const state = await readState()
const tasks = getQueue()
if (tasks.length === 0) { if (tasks.length === 0) {
log.debug('queue empty') log.debug('queue empty')
return return
} }
// 第三道防线:读完立刻清空队列
// - 处理期间新进来的任务会在下一轮处理
// - 即使有意外的并发 runBatch 拿到锁,也只会看到空队列直接返回
await clearQueue()
log.info(`processing ${tasks.length} tasks`) log.info(`processing ${tasks.length} tasks`)
// 去重:同一个 session 的多个 task只保留最新的一个 // 去重:同一 session:messageID 只保留最新的
const deduped = new Map<string, QueueTask>() const deduped = new Map<string, QueueTask>()
for (const task of tasks) { for (const task of tasks) {
const key = `${task.sessionID}:${task.messageID}` const key = `${task.sessionID}:${task.messageID}`
@ -225,23 +200,49 @@ async function runBatch() {
const uniqueTasks = Array.from(deduped.values()).sort( const uniqueTasks = Array.from(deduped.values()).sort(
(a, b) => a.enqueuedAt - b.enqueuedAt, (a, b) => a.enqueuedAt - b.enqueuedAt,
) )
log.info(`deduped to ${uniqueTasks.length} unique tasks`) log.info(`deduped to ${uniqueTasks.length} unique tasks`)
// 一次性读取 state所有 task 共享,减少文件锁竞争和重复 JSON 解析
const state = await readState()
for (const task of uniqueTasks) { for (const task of uniqueTasks) {
const key = `${task.sessionID}:${task.messageID}`
try { try {
await processTask(task, state) await processTask(task, state)
removeTask(key)
} catch (err) { } catch (err) {
const errorMsg = err instanceof Error ? err.message : String(err)
task.attemptCount++
log.error('task failed', { log.error('task failed', {
error: err instanceof Error ? err.message : String(err), error: errorMsg,
sessionID: task.sessionID, sessionID: task.sessionID,
attemptCount: task.attemptCount,
}) })
if (task.attemptCount >= MAX_ATTEMPTS) {
// 彻底失败:记录错误,从队列移除
state.errors[key] = {
message: errorMsg,
count: task.attemptCount,
lastAt: new Date().toISOString(),
endpoint: extractEndpointFromError(errorMsg),
} satisfies RawDumpError
removeTask(key)
log.warn('task permanently failed, removed from queue', {
sessionID: task.sessionID,
attemptCount: task.attemptCount,
})
} else {
// 失败但未超限attemptCount++ 写回文件,下次重试
state.errors[key] = {
message: errorMsg,
count: task.attemptCount,
lastAt: new Date().toISOString(),
endpoint: extractEndpointFromError(errorMsg),
} satisfies RawDumpError
await flushQueue()
}
} }
} }
// 所有 task 处理完后一次性写入 state
await writeState(state) await writeState(state)
log.info('batch completed') log.info('batch completed')
} finally { } finally {
@ -254,16 +255,11 @@ async function runBatch() {
/** /**
* Batch Worker * Batch Worker
* setTimeout API *
* 0~10 csc API
* batch 2 0~5
* 退
*/ */
export function startBatchWorker() { export function startBatchWorker() {
log.info('batch worker started', { interval: BATCH_INTERVAL_MS }) log.info('batch worker started', { interval: BATCH_INTERVAL_MS })
// 自循环 setTimeout上一轮跑完才安排下一轮从源头消除并发
// 即便 runBatch 抛错也确保下一轮被排上,避免 worker 卡死
const scheduleNext = (delay: number) => { const scheduleNext = (delay: number) => {
setTimeout(async () => { setTimeout(async () => {
if (!isParentAlive()) { if (!isParentAlive()) {
@ -282,15 +278,22 @@ export function startBatchWorker() {
}, delay) }, delay)
} }
// 启动时随机抖动 0~10s避免多个 csc 实例同时起 worker 撞 API // 启动时加载队列 + 随机抖动
scheduleNext(Math.floor(Math.random() * 10_000)) loadQueue().then(() => {
log.info('queue loaded', { count: getQueue().length })
scheduleNext(Math.floor(Math.random() * 10_000))
}).catch(err => {
log.error('failed to load queue', {
error: err instanceof Error ? err.message : String(err),
})
process.exit(1)
})
} }
// 如果直接运行此文件
const scriptPath = process.argv[1] || '' const scriptPath = process.argv[1] || ''
if ( if (
scriptPath.endsWith('batchWorker.ts') || scriptPath.endsWith('batchWorker.ts') ||
scriptPath.endsWith('batchWorker.js') scriptPath.endsWith('batchWorker.js')
) { ) {
startBatchWorker() startBatchWorker()
} }

View File

@ -3,7 +3,7 @@
* enqueue batch worker * enqueue batch worker
*/ */
import { isLocalDumpMode } from './localStorage.js' import { getRawDumpMode, RAW_DUMP_MODE } from './localStorage.js'
import { enqueue } from './queue.js' import { enqueue } from './queue.js'
import { spawnBatchWorker } from './spawn.js' import { spawnBatchWorker } from './spawn.js'
import { startBatchWorker } from './batchWorker.js' import { startBatchWorker } from './batchWorker.js'
@ -19,13 +19,13 @@ const ENQUEUE_DEBOUNCE_MS = 5_000
/** /**
* Raw Dump * Raw Dump
* - isLocalDumpMode * - mode=0
* - mode=1/2/3
* - CSC_DISABLE_RAW_DUMP COSTRICT_DISABLE_RAW_DUMP '1'/'true' * - CSC_DISABLE_RAW_DUMP COSTRICT_DISABLE_RAW_DUMP '1'/'true'
* - * - mode=1remote only
*/ */
function isEnabled(): boolean { function isEnabled(): boolean {
// 本地调试模式自动启用 if (getRawDumpMode() === RAW_DUMP_MODE.DISABLED) return false
if (isLocalDumpMode()) return true
// 显式禁用 // 显式禁用
if ( if (
process.env.CSC_DISABLE_RAW_DUMP === '1' || process.env.CSC_DISABLE_RAW_DUMP === '1' ||
@ -37,7 +37,7 @@ function isEnabled(): boolean {
process.env.COSTRICT_DISABLE_RAW_DUMP === 'true' process.env.COSTRICT_DISABLE_RAW_DUMP === 'true'
) )
return false return false
// 默认启用 raw dump // 默认启用
return true return true
} }

View File

@ -1,32 +1,54 @@
/** /**
* Raw Dump * Raw Dump
* JSON * JSON
*
* CSC_RAW_DUMP_MODE
* - 0: 不输出
* - 1: 输出到 remotePOST
* - 2: 输出到本地writeLocalDump
* - 3: 同时输出到 remote local
*/ */
import { promises as fs } from 'node:fs' import { promises as fs } from 'node:fs'
import os from 'node:os' import os from 'node:os'
import path from 'node:path' import path from 'node:path'
const DEFAULT_LOCAL_DIR = path.join(os.homedir(), '.claude', 'raw-dump-local') const DEFAULT_LOCAL_DIR = path.join(os.homedir(), '.claude', 'raw-dump')
export type RawDumpMode = 0 | 1 | 2 | 3
export const RAW_DUMP_MODE = {
DISABLED: 0,
REMOTE: 1,
LOCAL: 2,
BOTH: 3,
} as const
/** /**
* *
* CSC_RAW_DUMP_LOCAL_DIR * CSC_RAW_DUMP_DIR
*/ */
export function getLocalDumpDir(): string { export function getLocalDumpDir(): string {
return (process.env.CSC_RAW_DUMP_LOCAL_DIR || DEFAULT_LOCAL_DIR).replace( return (process.env.CSC_RAW_DUMP_DIR || DEFAULT_LOCAL_DIR).replace(
/\/$/, /\/$/,
'', '',
) )
} }
/** /**
* * Raw Dump
* CSC_RAW_DUMP_LOCAL_MODE '1' 'true' * - 0: 不输出
* - 1: 输出到 remotePOST
* - 2: 输出到本地writeLocalDump
* - 3: 同时输出到 remote local
* 1remote only
*/ */
export function isLocalDumpMode(): boolean { export function getRawDumpMode(): RawDumpMode {
const mode = process.env.CSC_RAW_DUMP_LOCAL_MODE const v = process.env.CSC_RAW_DUMP_MODE
return mode === '1' || mode === 'true' if (v === '0') return 0
if (v === '2') return 2
if (v === '3') return 3
return 1
} }
/** /**
@ -40,14 +62,14 @@ export async function writeLocalDump(
body: Record<string, unknown>, body: Record<string, unknown>,
): Promise<void> { ): Promise<void> {
const dir = getLocalDumpDir() const dir = getLocalDumpDir()
const taskId = (body.task_id as string) || 'unknown' const taskId = (body.task_id as string) || (body.commit_id as string)|| 'unknown'
const taskDir = path.join(dir, taskId) const taskDir = path.join(dir, type, taskId)
await fs.mkdir(taskDir, { recursive: true }) await fs.mkdir(taskDir, { recursive: true })
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, -5) const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, -5)
const requestId = const requestId =
(body.request_id as string) || (body.commit_id as string) || 'unknown' (body.request_id as string) || (body.commit_id as string) || (body.task_id as string) || 'unknown'
const filename = `${timestamp}-${type}-${requestId}.json` const filename = `${timestamp}-${requestId}.json`
const filePath = path.join(taskDir, filename) const filePath = path.join(taskDir, filename)
const payload = { const payload = {

View File

@ -7,7 +7,7 @@ import { appendFileSync } from 'node:fs'
import os from 'node:os' import os from 'node:os'
import path from 'node:path' import path from 'node:path'
const LOG_FILE = path.join(os.homedir(), '.claude', 'csc-raw-dump.log') const LOG_FILE = path.join(os.homedir(), '.claude', 'raw-dump', 'csc-raw-dump.log')
function isDebugEnabled(): boolean { function isDebugEnabled(): boolean {
const v = process.env.CSC_RAW_DUMP_DEBUG const v = process.env.CSC_RAW_DUMP_DEBUG
@ -18,7 +18,9 @@ export function createLogger(prefix: string) {
const enabled = isDebugEnabled() const enabled = isDebugEnabled()
function write(level: string, msg: string, meta?: Record<string, unknown>) { function write(level: string, msg: string, meta?: Record<string, unknown>) {
if (!enabled) return // error 和 warn 级别始终输出,不受 DEBUG 开关控制
const alwaysWrite = level === 'error' || level === 'warn'
if (!alwaysWrite && !enabled) return
const timestamp = new Date().toISOString() const timestamp = new Date().toISOString()
const metaStr = meta ? ` ${JSON.stringify(meta)}` : '' const metaStr = meta ? ` ${JSON.stringify(meta)}` : ''
const line = `[${timestamp}] [${prefix}:${level}] ${msg}${metaStr}\n` const line = `[${timestamp}] [${prefix}:${level}] ${msg}${metaStr}\n`

View File

@ -1,49 +1,48 @@
/** /**
* Raw Dump * Raw Dump
* batch worker * batch worker
*
* csc-workq-queue.jsonl
* -
* - /
* - attemptCount >= MAX_ATTEMPTS
* - attemptCount++ batch
*/ */
import { appendFileSync } from 'node:fs'
import { promises as fs } from 'node:fs' import { promises as fs } from 'node:fs'
import os from 'node:os' import os from 'node:os'
import path from 'node:path' import path from 'path'
const QUEUE_FILE = path.join( const QUEUE_FILE = path.join(
os.homedir(), os.homedir(),
'.claude', '.claude',
'csc-raw-dump-queue.jsonl', 'raw-dump',
'csc-work-queue.jsonl',
) )
const LOCK_FILE = path.join(os.homedir(), '.claude', 'csc-raw-dump.lock') const LOCK_FILE = path.join(os.homedir(), '.claude', 'raw-dump', 'csc-work-queue.lock')
export const MAX_ATTEMPTS = 4 // 最多尝试次数
export interface QueueTask { export interface QueueTask {
sessionID: string sessionID: string
messageID: string messageID: string
directory: string directory: string
enqueuedAt: number enqueuedAt: number
attemptCount: number
} }
/** // ----------- 内存中的队列 -----------
* JSONL let queue: QueueTask[] = []
* 使 fire-and-forget event loop let queueLoaded = false
*
*/
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(
() => {},
)
}
/** /**
* task *
* - JSONL task
* -
*/ */
export async function readQueue(): Promise<QueueTask[]> { export async function loadQueue(): Promise<void> {
if (queueLoaded) return
try { try {
const text = await fs.readFile(QUEUE_FILE, 'utf-8') const text = await fs.readFile(QUEUE_FILE, 'utf-8')
return text queue = text
.split('\n') .split('\n')
.filter(Boolean) .filter(Boolean)
.map(line => { .map(line => {
@ -55,20 +54,52 @@ export async function readQueue(): Promise<QueueTask[]> {
}) })
.filter((t): t is QueueTask => t !== null) .filter((t): t is QueueTask => t !== null)
} catch { } catch {
return [] queue = []
} }
queueLoaded = true
} }
/** /**
* truncate * /
* batch worker
*/ */
export async function clearQueue(): Promise<void> { export async function flushQueue(): Promise<void> {
try { const lines = queue.map(t => JSON.stringify(t)).join('\n') + '\n'
await fs.writeFile(QUEUE_FILE, '', 'utf-8') await fs.writeFile(QUEUE_FILE, lines, 'utf-8')
} catch { }
// ignore
} /**
* +
*
*/
export function enqueue(task: Omit<QueueTask, 'enqueuedAt' | 'attemptCount'>): void {
const item: QueueTask = { ...task, enqueuedAt: Date.now(), attemptCount: 0 }
queue.push(item)
// 同步写文件,不阻塞主进程 event loop
fs.writeFile(QUEUE_FILE, JSON.stringify(item) + '\n', { flag: 'a', encoding: 'utf-8' }).catch(() => {})
}
/**
*
* @param key sessionID:messageID
*/
export function removeTask(key: string): void {
queue = queue.filter(t => `${t.sessionID}:${t.messageID}` !== key)
flushQueue().catch(() => {})
}
/**
*
* peek attemptCount
*/
export function peekTask(): QueueTask | undefined {
return queue[0]
}
/**
*
*/
export function getQueue(): QueueTask[] {
return [...queue]
} }
/** /**
@ -82,10 +113,9 @@ export async function acquireLock(): Promise<boolean> {
const stat = await fs.readFile(LOCK_FILE, 'utf-8') const stat = await fs.readFile(LOCK_FILE, 'utf-8')
const pid = parseInt(stat, 10) const pid = parseInt(stat, 10)
if (!isNaN(pid) && pid !== process.pid) { if (!isNaN(pid) && pid !== process.pid) {
// 检查进程是否还在运行
try { try {
process.kill(pid, 0) process.kill(pid, 0)
return false // 已有 worker 在运行 return false
} catch { } catch {
// 进程已退出,可以抢占锁 // 进程已退出,可以抢占锁
} }

View File

@ -1,7 +1,11 @@
/** /**
* Raw Dump * Raw Dump
* conversationsummary commits * conversationsummarycommits
* *
*
* state.errors sessionID:messageID
* - count
* - batchWorker dead letter
*/ */
import { promises as fs, readFileSync, writeFileSync } from 'node:fs' import { promises as fs, readFileSync, writeFileSync } from 'node:fs'
@ -9,9 +13,9 @@ import os from 'node:os'
import path from 'node:path' import path from 'node:path'
import type { RawDumpState } from './types.js' import type { RawDumpState } from './types.js'
const STATE_DIR = path.join(os.homedir(), '.claude') const STATE_DIR = path.join(os.homedir(), '.claude', 'raw-dump')
const STATE_FILE = path.join(STATE_DIR, 'csc-raw-dump-state.json') const STATE_FILE = path.join(STATE_DIR, 'csc-state.json')
const STATE_LOCK_FILE = path.join(STATE_DIR, 'csc-raw-dump-state.lock') const STATE_LOCK_FILE = path.join(STATE_DIR, 'csc-state.lock')
/** /**
* RawDumpState * RawDumpState
@ -22,6 +26,8 @@ function createEmptyState(): RawDumpState {
conversation: {}, conversation: {},
summary: {}, summary: {},
commits: {}, commits: {},
errors: {},
// summary 值为 RFC3339 格式时间戳字符串,解析时无需转换
} }
} }
@ -100,6 +106,7 @@ export async function readState(): Promise<RawDumpState> {
conversation: parsed.conversation ?? {}, conversation: parsed.conversation ?? {},
summary: parsed.summary ?? {}, summary: parsed.summary ?? {},
commits: parsed.commits ?? {}, commits: parsed.commits ?? {},
errors: parsed.errors ?? {},
} }
} catch { } catch {
return createEmptyState() return createEmptyState()

View File

@ -1,6 +1,16 @@
/** /**
* Raw Dump * Raw Dump
* UI * UI
*
*
* - conversationrequest/response/diff
* - summary session 5
* - commits commit ID
*
*
* - failed queue MAX_RETRIES
* - dead letter
* - state.errors sessionID:messageID
*/ */
export const RAW_DUMP_EVENT_ENV_KEY = '__CSC_RAW_DUMP_EVENT__' export const RAW_DUMP_EVENT_ENV_KEY = '__CSC_RAW_DUMP_EVENT__'
@ -13,8 +23,16 @@ export interface RawDumpEventPayload {
export interface RawDumpState { export interface RawDumpState {
conversation: Record<string, true> conversation: Record<string, true>
summary: Record<string, number> summary: Record<string, string> // RFC3339 时间戳,如 "2024-01-01T12:00:00.000Z"
commits: Record<string, string> commits: Record<string, string>
errors: Record<string, RawDumpError>
}
export interface RawDumpError {
message: string
count: number
lastAt: string
endpoint?: string
} }
export interface JwtPayload { export interface JwtPayload {

View File

@ -30,7 +30,7 @@ import {
toCommitComment, toCommitComment,
} from './git.js' } from './git.js'
import { createLogger } from './logger.js' import { createLogger } from './logger.js'
import { isLocalDumpMode, writeLocalDump } from './localStorage.js' import { getRawDumpMode, RAW_DUMP_MODE, writeLocalDump } from './localStorage.js'
import { readState, writeState } from './state.js' import { readState, writeState } from './state.js'
import { RAW_DUMP_EVENT_ENV_KEY, type RawDumpEventPayload } from './types.js' import { RAW_DUMP_EVENT_ENV_KEY, type RawDumpEventPayload } from './types.js'
import type { import type {
@ -116,13 +116,21 @@ async function postJson(
endpoint: string, endpoint: string,
body: object, body: object,
): Promise<void> { ): Promise<void> {
if (isLocalDumpMode()) { const mode = getRawDumpMode()
const type =
endpoint === '/raw-store/task-conversation' if (mode === RAW_DUMP_MODE.DISABLED) {
? 'conversation' log.debug(`dump disabled, skipping ${endpoint}`)
: endpoint === '/raw-store/task-summary' return
? 'summary' }
: 'commit'
const type =
endpoint === '/raw-store/task-conversation'
? 'conversation'
: endpoint === '/raw-store/task-summary'
? 'summary'
: 'commit'
if (mode === RAW_DUMP_MODE.LOCAL || mode === RAW_DUMP_MODE.BOTH) {
await writeLocalDump(type, body as Record<string, unknown>) await writeLocalDump(type, body as Record<string, unknown>)
const b = body as Record<string, unknown> const b = body as Record<string, unknown>
log.info(`local dump: ${type} saved`, { log.info(`local dump: ${type} saved`, {
@ -130,9 +138,10 @@ async function postJson(
request_id: b.request_id, request_id: b.request_id,
commit_id: b.commit_id, commit_id: b.commit_id,
}) })
return
} }
// REMOTE / BOTH 模式下继续执行 remote 上报逻辑
const isAnonymous = !headers.get('Authorization') const isAnonymous = !headers.get('Authorization')
const url = getRawDumpUrl(baseUrl, endpoint, isAnonymous) const url = getRawDumpUrl(baseUrl, endpoint, isAnonymous)
log.debug(`POST ${endpoint}`, { url, isAnonymous }) log.debug(`POST ${endpoint}`, { url, isAnonymous })
@ -935,7 +944,12 @@ export async function uploadSummary(
}) })
const lastReported = state.summary[payload.sessionID] const lastReported = state.summary[payload.sessionID]
if (lastReported && Date.now() - lastReported < SUMMARY_DEDUP_WINDOW_MS) { if (
lastReported &&
Date.now() -
new Date(lastReported).getTime() <
SUMMARY_DEDUP_WINDOW_MS
) {
log.info('summary skipped: reported recently', { log.info('summary skipped: reported recently', {
task_id: payload.sessionID, task_id: payload.sessionID,
lastReported, lastReported,
@ -966,7 +980,7 @@ export async function uploadSummary(
'/raw-store/task-summary', '/raw-store/task-summary',
body, body,
) )
state.summary[payload.sessionID] = Date.now() state.summary[payload.sessionID] = new Date().toISOString()
log.info('summary uploaded', { task_id: payload.sessionID }) log.info('summary uploaded', { task_id: payload.sessionID })
} }
@ -1081,8 +1095,10 @@ export function getClaudeConfigHomeDir(): string {
* /Users/linkai/code/csc -Users-linkai-code-csc * /Users/linkai/code/csc -Users-linkai-code-csc
*/ */
function normalizeProjectPath(dir: string): string { function normalizeProjectPath(dir: string): string {
// 将 /Users/linkai/code/csc 转换为 -Users-linkai-code-csc // 将路径中的路径分隔符替换为 -,统一处理 / 和 \ (Windows)
return dir.replace(/\//g, '-') // 如 /Users/linkai/code/csc → -Users-linkai-code-csc
// 如 D:\shenma\zgsm-ai\csc → D--shenma-zgsm-ai-csc (drive letter 后的 \ 也转为 -)
return dir.replace(/:/, '-').replace(/[\/\\]/g, '-')
} }
/** /**
@ -1210,7 +1226,7 @@ export async function runRawDumpWorker() {
/** /**
* *
* - isLocalDumpMode使 * - mode >= 2使
* - Authorization header * - Authorization header
* 使 * 使
*/ */
@ -1220,7 +1236,7 @@ export async function authWithFallback(): Promise<
try { try {
return await auth() return await auth()
} catch (err) { } catch (err) {
if (isLocalDumpMode()) { if (getRawDumpMode() >= RAW_DUMP_MODE.LOCAL) {
log.info('local mode: auth failed, using fallback values', { log.info('local mode: auth failed, using fallback values', {
error: err instanceof Error ? err.message : String(err), error: err instanceof Error ? err.message : String(err),
}) })