1. 修改上报队列机制,确保上报失败有可靠的重试机制,不会静默失败;2.修改conversation上报遗漏的问题;
This commit is contained in:
parent
c8586ee6ee
commit
455489cc89
|
|
@ -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
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -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 消息(可能为空)
|
* 成功完成后由调用方从队列中移除
|
||||||
* - 依次调用 uploadConversation、uploadSummary、uploadCommits
|
|
||||||
* - 任何任务失败都抛异常,由上层 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 },
|
|
||||||
)
|
|
||||||
|
|
||||||
// summary(5 分钟内同一 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()
|
||||||
}
|
}
|
||||||
|
|
@ -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=1(remote 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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,32 +1,54 @@
|
||||||
/**
|
/**
|
||||||
* Raw Dump 本地存储模式
|
* Raw Dump 本地存储模式
|
||||||
* 开启后上报数据不落服务端,仅写入本地 JSON 文件,用于排障和调试
|
* 开启后上报数据不落服务端,仅写入本地 JSON 文件,用于排障和调试
|
||||||
|
*
|
||||||
|
* CSC_RAW_DUMP_MODE 语义:
|
||||||
|
* - 0: 不输出
|
||||||
|
* - 1: 输出到 remote(POST)
|
||||||
|
* - 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: 输出到 remote(POST)
|
||||||
|
* - 2: 输出到本地(writeLocalDump)
|
||||||
|
* - 3: 同时输出到 remote 和 local
|
||||||
|
* 默认 1(remote 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 = {
|
||||||
|
|
|
||||||
|
|
@ -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`
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
// 进程已退出,可以抢占锁
|
// 进程已退出,可以抢占锁
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,11 @@
|
||||||
/**
|
/**
|
||||||
* Raw Dump 磁盘状态管理
|
* Raw Dump 磁盘状态管理
|
||||||
* 用于 conversation、summary 和 commits 的去重
|
* 用于 conversation、summary、commits 的去重,以及错误聚合
|
||||||
* 通过文件锁保证多进程并发读写安全
|
* 通过文件锁保证多进程并发读写安全
|
||||||
|
*
|
||||||
|
* 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()
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,16 @@
|
||||||
/**
|
/**
|
||||||
* Raw Dump 上报类型定义
|
* Raw Dump 上报类型定义
|
||||||
* 与框架解耦,不依赖任何 UI 或特定运行时
|
* 与框架解耦,不依赖任何 UI 或特定运行时
|
||||||
|
*
|
||||||
|
* 三类上报任务:
|
||||||
|
* - conversation:对话明细(request/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 {
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user