diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..fff709a6 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,31 @@ +name: CI + +on: + pull_request: + paths-ignore: + - 'docs/**' + - '*.md' + +concurrency: + group: ci-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + test: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - run: npm ci + + - name: Type check + run: npm run typecheck + + - name: Unit tests + run: npx vitest run --exclude='**/quality.test.ts' --exclude='**/integration.test.ts' diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 3133af70..3dccaca2 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -3,9 +3,34 @@ name: Deploy on: push: branches: [main] + paths-ignore: + - 'docs/**' + - '*.md' + - '.github/workflows/pr-review.yml' + - '.github/workflows/claude-comment.yml' + - '.github/workflows/ci.yml' jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - run: npm ci + + - name: Type check + run: npm run typecheck + + - name: Unit tests + run: npx vitest run --exclude='**/quality.test.ts' --exclude='**/integration.test.ts' + deploy: + needs: test runs-on: self-hosted concurrency: group: deploy-production diff --git a/docs/ci-improvements.md b/docs/ci-improvements.md new file mode 100644 index 00000000..c1ff957f --- /dev/null +++ b/docs/ci-improvements.md @@ -0,0 +1,286 @@ +# CI/CD 改进方案 + +## 1. 纯文档变更跳过 CI + +当 PR 或 push 仅修改 `docs/`、`*.md` 等文档文件时,跳过构建、测试和部署,节省 runner 资源和部署风险。 + +### 实现方式 + +在 `deploy.yml` 和 `pr-review.yml` 中加入路径过滤或 docs-scope 检测 job。 + +#### 方案 A:路径过滤(推荐,最简单) + +`deploy.yml` 增加 `paths-ignore`: + +```yaml +on: + push: + branches: [main] + paths-ignore: + - 'docs/**' + - '*.md' + - '.github/workflows/pr-review.yml' + - '.github/workflows/claude-comment.yml' +``` + +`pr-review.yml` 同理: + +```yaml +on: + pull_request: + types: [opened, synchronize, ready_for_review, reopened] + paths-ignore: + - 'docs/**' + - '*.md' +``` + +**优点:** 零代码,GitHub 原生支持,一行搞定。 +**缺点:** 无法处理混合变更(文档 + 代码同时改),混合变更会正常触发 CI(这通常是期望行为)。 + +#### 方案 B:docs-scope 检测 job(更精细) + +适用于未来需要"文档改了也跑 docs lint 但不跑部署"的场景: + +```yaml +jobs: + docs-scope: + runs-on: ubuntu-latest + outputs: + docs_only: ${{ steps.check.outputs.docs_only }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - id: check + run: | + if [ "${{ github.event_name }}" = "push" ]; then + BASE="${{ github.event.before }}" + else + BASE="origin/${{ github.base_ref }}" + fi + FILES=$(git diff --name-only "$BASE" HEAD 2>/dev/null || echo "UNKNOWN") + if [ "$FILES" = "UNKNOWN" ]; then + echo "docs_only=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + NON_DOCS=$(echo "$FILES" | grep -cvE '^docs/|\.md$' || true) + if [ "$NON_DOCS" -eq 0 ]; then + echo "docs_only=true" >> "$GITHUB_OUTPUT" + else + echo "docs_only=false" >> "$GITHUB_OUTPUT" + fi + + deploy: + needs: docs-scope + if: needs.docs-scope.outputs.docs_only != 'true' + # ... 原有 deploy 步骤 +``` + +--- + +## 2. CI 测试门禁 + +当前 push 到 main 后直接部署,不运行测试。加入 test + typecheck 作为部署前的必通过门禁。 + +### 方案 A:在 deploy.yml 中增加 test job(推荐) + +```yaml +jobs: + docs-scope: + # ... 同上 + + test: + needs: docs-scope + if: needs.docs-scope.outputs.docs_only != 'true' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - run: npm ci + + - name: Type check + run: npm run typecheck + + - name: Run tests + run: npm test + + deploy: + needs: [docs-scope, test] + if: needs.docs-scope.outputs.docs_only != 'true' + runs-on: self-hosted + concurrency: + group: deploy-production + cancel-in-progress: true + steps: + # ... 原有部署步骤不变 +``` + +**关键点:** +- `test` job 运行在 `ubuntu-latest`(GitHub-hosted),不占用生产服务器资源 +- `deploy` job 依赖 `test` 成功后才执行 +- 测试失败 → 部署不执行 → 生产环境不受影响 + +### 方案 B:额外增加 PR 级别的测试 check + +在 PR 阶段就拦截问题,而不是等到 merge 后: + +```yaml +# .github/workflows/ci.yml(新文件) +name: CI + +on: + pull_request: + paths-ignore: + - 'docs/**' + - '*.md' + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - run: npm ci + + - name: Type check + run: npm run typecheck + + - name: Lint + run: npm run lint + + - name: Run tests + run: npm test +``` + +然后在 GitHub repo settings → Branch protection rules → `main` 分支设置 "Require status checks to pass" 勾选 `test` job,这样 PR 必须绿灯才能 merge。 + +### 推荐组合 + +两个方案叠加使用效果最佳: + +``` +PR 阶段: + ci.yml (test + typecheck + lint) ← 拦截问题 + pr-review.yml (Claude review) ← AI 审查 + +Merge 到 main: + deploy.yml: + docs-scope → test → deploy ← 双重保险 +``` + +--- + +## 完整的改进后 deploy.yml 参考 + +```yaml +name: Deploy + +on: + push: + branches: [main] + +jobs: + docs-scope: + runs-on: ubuntu-latest + outputs: + docs_only: ${{ steps.check.outputs.docs_only }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - id: check + run: | + BASE="${{ github.event.before }}" + FILES=$(git diff --name-only "$BASE" HEAD 2>/dev/null || echo "UNKNOWN") + if [ "$FILES" = "UNKNOWN" ]; then + echo "docs_only=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + NON_DOCS=$(echo "$FILES" | grep -cvE '^docs/|\.md$' || true) + if [ "$NON_DOCS" -eq 0 ]; then + echo "docs_only=true" >> "$GITHUB_OUTPUT" + else + echo "docs_only=false" >> "$GITHUB_OUTPUT" + fi + + test: + needs: docs-scope + if: needs.docs-scope.outputs.docs_only != 'true' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - run: npm ci + + - name: Type check + run: npm run typecheck + + - name: Run tests + run: npm test + + deploy: + needs: [docs-scope, test] + if: needs.docs-scope.outputs.docs_only != 'true' + runs-on: self-hosted + concurrency: + group: deploy-production + cancel-in-progress: true + steps: + - name: Pull latest code + run: | + cd /root/dev/anywhere-code + git fetch origin main + git reset --hard origin/main + + - name: Install dependencies + run: | + export NVM_DIR="$HOME/.nvm" + . "$NVM_DIR/nvm.sh" + cd /root/dev/anywhere-code + npm ci + + - name: Build + run: | + export NVM_DIR="$HOME/.nvm" + . "$NVM_DIR/nvm.sh" + cd /root/dev/anywhere-code + npm run build + + - name: Wait for running queries to finish (direct push only) + if: ${{ github.event.head_commit.committer.username != 'web-flow' }} + run: | + echo "Direct push to main detected, waiting 60s for running queries to finish..." + sleep 60 + + - name: Restart service + run: | + export NVM_DIR="$HOME/.nvm" + . "$NVM_DIR/nvm.sh" + pm2 restart feishu-claude || pm2 start /root/dev/anywhere-code/ecosystem.config.cjs +``` + +--- + +## 改进优先级 + +| 优先级 | 改进项 | 复杂度 | 收益 | +|--------|--------|--------|------| +| P0 | deploy.yml 加 `paths-ignore` | 3 行 | 避免文档改动触发不必要的部署重启 | +| P1 | deploy.yml 加 test job 门禁 | 中 | 防止坏代码部署到生产 | +| P2 | 新建 ci.yml PR 级别测试 | 中 | 在 PR 阶段拦截问题 | +| P3 | Branch protection 设置 | 配置 | 强制 PR 测试通过才能 merge | diff --git a/src/feishu/__tests__/message-builder.test.ts b/src/feishu/__tests__/message-builder.test.ts index 48938ec6..1a838de8 100644 --- a/src/feishu/__tests__/message-builder.test.ts +++ b/src/feishu/__tests__/message-builder.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { buildProgressCard, buildResultCard, buildStreamingCard, buildPipelineCard, buildStatusCard, buildTurnCard, buildToolProgressCard, buildOverviewCard, buildSimpleResultCard } from '../message-builder.js'; +import { buildProgressCard, buildResultCard, buildStreamingCard, buildPipelineCard, buildStatusCard, buildTurnCard, buildToolProgressCard, buildTextContentCard, buildOverviewCard, buildSimpleResultCard } from '../message-builder.js'; import type { TurnInfo, ToolCallInfo, ActivityStatus } from '../../claude/types.js'; describe('buildProgressCard', () => { @@ -502,3 +502,50 @@ describe('buildToolProgressCard', () => { expect(body).toContain('_(无工具调用)_'); }); }); + +describe('buildTextContentCard', () => { + it('should show text with wathet header when in progress', () => { + const card = buildTextContentCard('这是 agent 的输出', 3) as any; + expect(card.header.template).toBe('wathet'); + expect(card.header.title.content).toContain('生成中'); + const body = card.elements[0].text.content as string; + expect(body).toBe('这是 agent 的输出'); + const note = card.elements[2].elements[0].content as string; + expect(note).toContain('⏳ 生成中'); + expect(note).toContain('3 轮'); + }); + + it('should show turquoise header when completed', () => { + const card = buildTextContentCard('最终结果', 5, true) as any; + expect(card.header.template).toBe('turquoise'); + expect(card.header.title.content).toBe('💬 Agent 输出'); + expect(card.header.title.content).not.toContain('生成中'); + const note = card.elements[2].elements[0].content as string; + expect(note).not.toContain('⏳'); + expect(note).toContain('5 轮'); + }); + + it('should not truncate short text', () => { + const shortText = '短文本内容'; + const card = buildTextContentCard(shortText, 1) as any; + const body = card.elements[0].text.content as string; + expect(body).toBe(shortText); + expect(body).not.toContain('已省略'); + }); + + it('should truncate long text keeping tail and adding prefix', () => { + const longText = '前'.repeat(5000) + '后'.repeat(5000); + const card = buildTextContentCard(longText, 2) as any; + const body = card.elements[0].text.content as string; + expect(body).toContain('已省略'); + expect(body).toContain('后后后'); + const serialized = JSON.stringify(card); + expect(Buffer.byteLength(serialized, 'utf-8')).toBeLessThan(30720); + }); + + it('should show placeholder for empty text', () => { + const card = buildTextContentCard('', 1) as any; + const body = card.elements[0].text.content as string; + expect(body).toContain('暂无输出'); + }); +}); diff --git a/src/feishu/event-handler.ts b/src/feishu/event-handler.ts index ae0235b4..7299f2c0 100644 --- a/src/feishu/event-handler.ts +++ b/src/feishu/event-handler.ts @@ -6,7 +6,7 @@ import { taskQueue } from '../session/queue.js'; import { claudeExecutor } from '../claude/executor.js'; import { DEFAULT_IMAGE_PROMPT } from '../claude/types.js'; import type { TurnInfo, ToolCallInfo, ImageAttachment } from '../claude/types.js'; -import { buildResultCard, buildStatusCard, buildCancelledCard, buildPipelineCard, buildPipelineConfirmCard, buildProgressCard, buildToolProgressCard, buildSimpleResultCard } from './message-builder.js'; +import { buildResultCard, buildStatusCard, buildCancelledCard, buildPipelineCard, buildPipelineConfirmCard, buildProgressCard, buildToolProgressCard, buildTextContentCard, buildSimpleResultCard } from './message-builder.js'; import { TOTAL_PHASES } from '../pipeline/types.js'; import { feishuClient, runWithAccountId } from './client.js'; import { config, isMultiBotMode } from '../config.js'; @@ -1130,16 +1130,48 @@ async function executeClaudeTask( // 构造逐条 turn 回调 // 策略:缓冲最后一个 turn,收到新 turn 时将前一个 turn 的 tool calls 刷入累积器, - // 原地更新进度卡片。结束时最后一个 turn 合并进结果卡片。 + // 原地更新进度卡片。文本内容同步刷入文本卡片。结束时最后一个 turn 合并进结果卡片。 let turnCount = 0; let pendingTurn: TurnInfo | undefined; const accumulatedToolCalls: ToolCallInfo[] = []; + let accumulatedText = ''; + let textCardMsgId: string | undefined; + let textCardFailed = false; + + /** 将文本追加到累积文本 */ + const appendText = (text: string) => { + accumulatedText += (accumulatedText ? '\n\n' : '') + text; + }; + + /** 追加文本(可选)并创建/更新文本卡片 */ + const flushTextCard = async (extraText?: string, completed: boolean = false) => { + if (extraText) appendText(extraText); + if (!accumulatedText || !threadReplyMsgId || textCardFailed) return; + try { + if (!textCardMsgId) { + textCardMsgId = await feishuClient.replyCardInThread( + threadReplyMsgId, + buildTextContentCard(accumulatedText, turnCount, completed), + ) ?? undefined; + if (!textCardMsgId) textCardFailed = true; + } else { + await feishuClient.updateCard( + textCardMsgId, + buildTextContentCard(accumulatedText, turnCount, completed), + ); + } + } catch (err) { + logger.warn({ err }, 'Failed to update text content card'); + textCardFailed = true; + } + }; const onTurn = async (turn: TurnInfo) => { turnCount = turn.turnIndex; - // 将前一个 turn 的 tool calls 刷入累积器,原地更新进度卡片 + // 将前一个 turn 的 tool calls 和文本刷入累积器,原地更新进度卡片和文本卡片 if (pendingTurn) { accumulatedToolCalls.push(...pendingTurn.toolCalls); + if (pendingTurn.textContent) appendText(pendingTurn.textContent); if (progressCardMsgId && !progressCardFailed) { try { await feishuClient.updateCard( @@ -1151,6 +1183,7 @@ async function executeClaudeTask( progressCardFailed = true; } } + await flushTextCard(); } // 缓冲当前 turn pendingTurn = turn; @@ -1313,6 +1346,9 @@ async function executeClaudeTask( ); } + // 将最后一个 turn 的文本也刷入文本卡片并标记完成 + await flushTextCard(pendingTurn?.textContent, true); + await sendResultCard( prompt, restartResult, totalDurationMs, totalCostUsd, threadReplyMsgId, chatId, threadReplyMsgId ? pendingTurn : undefined, turnCount, @@ -1369,12 +1405,18 @@ async function executeClaudeTask( // 进度卡片切换为完成态 if (progressCardMsgId) { + const allToolCalls = pendingTurn + ? [...accumulatedToolCalls, ...pendingTurn.toolCalls] + : accumulatedToolCalls; await feishuClient.updateCard( progressCardMsgId, - buildToolProgressCard(accumulatedToolCalls, turnCount, undefined, true), + buildToolProgressCard(allToolCalls, turnCount, undefined, true), ); } + // 将最后一个 turn 的文本也刷入文本卡片并标记完成 + await flushTextCard(pendingTurn?.textContent, true); + await sendResultCard( prompt, result, result.durationMs, result.costUsd, threadReplyMsgId, chatId, threadReplyMsgId ? pendingTurn : undefined, turnCount, @@ -1399,6 +1441,8 @@ async function executeClaudeTask( buildToolProgressCard(allToolCalls, turnCount, undefined, true), ).catch(() => {}); } + // 文本卡片 best-effort 刷新 + await flushTextCard(pendingTurn?.textContent, true).catch(() => {}); const errorReply = `❌ 执行出错: ${(err as Error).message}`; if (threadReplyMsgId) { await feishuClient.replyTextInThread(threadReplyMsgId, errorReply); diff --git a/src/feishu/message-builder.ts b/src/feishu/message-builder.ts index f70e71a7..4a55001a 100644 --- a/src/feishu/message-builder.ts +++ b/src/feishu/message-builder.ts @@ -553,6 +553,74 @@ export function buildToolProgressCard( }; } +/** + * 将文本截断到指定 UTF-8 字节上限,保留尾部(最新内容)。 + * 超限时从头部截断,保证完整 UTF-8 字符边界。 + */ +function truncateToByteLimit(text: string, maxBytes: number): { text: string; truncated: boolean } { + // 快速路径:byteLength 是 O(n) 扫描但不分配 Buffer + if (Buffer.byteLength(text, 'utf-8') <= maxBytes) return { text, truncated: false }; + + const buf = Buffer.from(text, 'utf-8'); + // 从尾部保留 maxBytes,找到合法的 UTF-8 字符起始位置 + let start = buf.length - maxBytes; + // UTF-8 continuation bytes: 10xxxxxx (0x80-0xBF), 跳到下一个 leading byte + while (start < buf.length && (buf[start] & 0xc0) === 0x80) start++; + return { text: buf.subarray(start).toString('utf-8'), truncated: true }; +} + +/** 飞书卡片 content 字节上限(留 2KB 给 JSON 结构开销) */ +const CARD_TEXT_MAX_BYTES = 28000; + +/** 构建累积文本内容卡片(原地更新,显示 agent 输出文本) */ +export function buildTextContentCard( + text: string, + turnCount: number, + completed: boolean = false, +): Record { + const { text: displayText, truncated } = truncateToByteLimit(text, CARD_TEXT_MAX_BYTES); + + const content = truncated + ? `_(前部分内容已省略)_\n\n${displayText}` + : displayText; + + const headerTitle = completed + ? '💬 Agent 输出' + : '💬 Agent 输出 - 生成中'; + const headerTemplate = completed ? 'turquoise' : 'wathet'; + + const footerParts: string[] = []; + if (!completed) footerParts.push('⏳ 生成中'); + footerParts.push(`🔄 ${turnCount} 轮`); + + return { + config: { wide_screen_mode: true }, + header: { + title: { tag: 'plain_text', content: headerTitle }, + template: headerTemplate, + }, + elements: [ + { + tag: 'div', + text: { + tag: 'lark_md', + content: content || '_(暂无输出)_', + }, + }, + { tag: 'hr' }, + { + tag: 'note', + elements: [ + { + tag: 'plain_text', + content: footerParts.join(' | '), + }, + ], + }, + ], + }; +} + /** 构建单轮 turn 消息卡片(逐条展示) */ export function buildTurnCard(turn: TurnInfo): Record { const parts: string[] = [];