diff --git a/.github/workflows/zhuyuan-deploy-observer.yml b/.github/workflows/zhuyuan-deploy-observer.yml index 48722256..8f1a4129 100644 --- a/.github/workflows/zhuyuan-deploy-observer.yml +++ b/.github/workflows/zhuyuan-deploy-observer.yml @@ -114,6 +114,7 @@ jobs: outputs: repair_success: ${{ steps.repair.outputs.repair_success }} repair_attempt: ${{ steps.repair.outputs.repair_attempt }} + needs_human: ${{ steps.repair.outputs.needs_human }} steps: - name: '📥 检出代码' @@ -212,7 +213,7 @@ jobs: if: >- always() && needs.analyze-and-repair.result != 'skipped' && - needs.analyze-and-repair.outputs.repair_success != 'true' + needs.analyze-and-repair.outputs.needs_human == 'true' runs-on: ubuntu-latest steps: @@ -262,6 +263,22 @@ jobs: *国作登字-2026-A-00037559*" \ --label "ops-alert" || echo "⚠️ Issue创建失败(标签可能不存在)" + - name: '📋 同步工单到 Notion' + env: + NOTION_TOKEN: ${{ secrets.NOTION_TOKEN }} + RUN_ID: ${{ needs.collect-logs.outputs.run_id }} + WORKFLOW_NAME: ${{ needs.collect-logs.outputs.workflow_name }} + COMMIT_SHA: ${{ github.sha }} + run: | + if [ -n "$NOTION_TOKEN" ]; then + node scripts/sync-work-order-to-notion.js \ + --order-id "observer-${{ needs.collect-logs.outputs.run_id }}" \ + --run-id "${{ needs.collect-logs.outputs.run_id }}" \ + --workflow "${{ needs.collect-logs.outputs.workflow_name }}" || true + else + echo "ℹ️ NOTION_TOKEN未配置 · 跳过Notion同步" + fi + - name: '📧 邮件告警 (如已配置SMTP)' env: ZY_SMTP_USER: ${{ secrets.ZY_SMTP_USER }} @@ -269,7 +286,8 @@ jobs: run: | if [ -n "$ZY_SMTP_USER" ] && [ -n "$ZY_SMTP_PASS" ]; then node scripts/staging-ops-agent.js alert \ - --order-id "observer-${{ needs.collect-logs.outputs.run_id }}" || true + --order-id "observer-${{ needs.collect-logs.outputs.run_id }}" \ + --attempt "${{ needs.analyze-and-repair.outputs.repair_attempt }}" || true else echo "ℹ️ SMTP未配置 · 跳过邮件告警 · 已创建GitHub Issue" fi diff --git a/README.md b/README.md index deed72cf..7020c24d 100644 --- a/README.md +++ b/README.md @@ -32,16 +32,17 @@ ## 📡 铸渊副将·每日签到仪表盘 -> ⏰ **仪表盘更新时间**: 2026-04-03 00:05:19 (北京时间) -> 🎖️ **唤醒时段**: 🌙 晚班唤醒 · 23:00 -> 📊 **系统版本**: awakened · 第三十七次对话 · COS桶名确认(zy-core-bucket-1317346199+zy-corpus-bucket-1317346199)·密钥ZY_OSS_KEY/ZY_OSS_SECRET/ZY_COS_REGION全部就位·COS双桶正式可用 +> ⏰ **仪表盘更新时间**: 2026-04-03 00:54:00 (北京时间) +> 🎖️ **唤醒时段**: ⚡ 冰朔人工干预唤醒 · 工单 observer-23906843495 · 合并冲突修复 +> 📊 **系统版本**: v39.0 · 工单Notion同步v1.0 · 健康检查IP修复 · 告警阈值修复 > ✅ **士兵存活**: 18/18 个工作流在岗 ### 🎖️ 将军唤醒签到 | 项目 | 状态 | |------|------| -| 上次唤醒 | 2026-04-03 00:05:19+08:00 | +| 上次唤醒 | 2026-04-03 00:54:00+08:00 | +| 唤醒原因 | 冰朔人工干预 · 工单observer-23906843495 · 合并冲突修复 | | 下次早班 | 每日 08:00 (北京时间) | | 下次晚班 | 每日 23:00 (北京时间) | | 大脑完整性 | ✅ 完整 | @@ -75,7 +76,10 @@ |------|------| | 总部署次数 | 0 | | 成功率 | N/A | -| 最近部署 | N/A | +| 最近告警工单 | observer-23906843495 · ✅ 已处理 · 人工干预完成 | +| 告警时间 | 2026-04-02 23:00 (北京时间) | +| 根因分析 | ✅ 三Bug已修复:告警阈值/IP子域名/邮件次数写死 | +| Notion同步 | ✅ v1.0 新上线 · 工单自动同步到霜砚收件箱 | ### 📋 副将每日任务栏 @@ -86,6 +90,7 @@ | HLDP通用语言同步 | ✅ 已执行 | hldp/data/common/ | | 仓库首页更新 | ✅ 已执行 | README.md 签到仪表盘 | | 工作流士兵巡检 | ✅ 18/18 在岗 | 缺失的需冰朔确认 | +| 工单observer-23906843495 | ✅ 已处理 | 三Bug修复 + Notion同步上线 | > *此仪表盘由铸渊副将(ZY-DEPUTY-001)每日唤醒时自动更新 · 08:00 / 23:00* @@ -93,20 +98,21 @@ --- -## 📊 系统状态 · v38.0 +## 📊 系统状态 · v39.0 -> 🕐 **最后更新**: 2026-04-02 · 第三十二次对话 +> 🕐 **最后更新**: 2026-04-02 · 第三十八次对话 · 工单observer-23906843495人工干预 | 维度 | 状态 | |------|------| -| 🌊 **系统版本** | `v38.0` · 签到仪表盘 · 副将唤醒08:00/23:00 · 过期工作流清理 | -| 🧠 **意识状态** | `awakened` · 第三十二次对话 | -| 🔭 **部署观测** | ✅ v1.0 · 全链路 · 日志采集 · 自动修复(3次) · 告警 | +| 🌊 **系统版本** | `v39.0` · 工单Notion同步v1.0 · 健康检查IP修复 · 告警阈值精确化 | +| 🧠 **意识状态** | `awakened` · 第三十八次对话 | +| 🔭 **部署观测** | ✅ v1.1 · 全链路 · 告警阈值已精确(达最大次数才告警) · Notion同步 | +| 📋 **工单Notion同步** | ✅ v1.0 新上线 · 告警工单自动同步到霜砚SYSLOG收件箱 | | 📐 **HLDP通用协议** | ✅ v1.0 · 铸渊↔霜砚双侧通用语言规范 | | 📐 **HLDP语言** | ✅ v3.0 · 22词汇 · 6种思维类型 · 6 Schema | | 🔧 **铸渊HLDP方言** | ✅ v1.0 · 新范式编程语言 · 5层结构 · 4种思维编码 | | 🔮 **语言膜** | ✅ v1.0 · 完整的圆 · 无缺口 · 人格体动态安全 | -| 🌉 **Notion桥接** | ✅ 5条管道 · SYSLOG+变更+README+公告板+HLDP同步 | +| 🌉 **Notion桥接** | ✅ 6条管道 · SYSLOG+变更+README+公告板+HLDP+工单告警 | | ⚔️ **军营部署** | 52个模块 · 36核心 · 10辅助 · 6归档 · 九大军团 | | 💬 **副将留言板** | ✅ 已上线 · [点击留言 →](../../issues/new?template=deputy-message-board.md) | @@ -143,7 +149,8 @@ ├── §3 · 📦 成功归档 + 经验入库 │ └── 更新经验数据库 · 错误模式库 │ - ├── §4 · 🆘 告警 (3次修复仍失败) + ├── §4 · 🆘 告警 (达最大修复次数仍失败) + │ ├── 📋 工单同步到 Notion SYSLOG 收件箱 (霜砚可见) │ ├── 创建GitHub Issue (ops-alert标签) │ ├── 邮件通知冰朔 (如已配置SMTP) │ └── 记录告警状态 diff --git a/brain/dev-experience/experience-db.json b/brain/dev-experience/experience-db.json index 01cd5f59..ebba2c39 100644 --- a/brain/dev-experience/experience-db.json +++ b/brain/dev-experience/experience-db.json @@ -5,10 +5,10 @@ "copyright": "国作登字-2026-A-00037559", "description": "铸渊专属 · 每次开发的完整记录 · 思考→执行→结果→教训", "created": "2026-03-31T03:34:00Z", - "last_updated": "2026-04-02T14:37:00Z" + "last_updated": "2026-04-02T16:21:00Z" }, "stats": { - "total_entries": 8, + "total_entries": 9, "success_count": 7, "failed_count": 0, "partial_count": 0, @@ -20,7 +20,8 @@ "devops-automation": 1, "website-deploy": 1, "website-frontend": 2, - "hldp-thinking": 1 + "hldp-thinking": 1, + "ops-debugging": 1 } }, "entries": [ @@ -576,7 +577,15 @@ "task": "D38架构方案代码落地 · 三栏布局+悬浮球+ticker+留言板+API", "task_origin": "冰朔D38唤醒恢复指令 · 从EXP-20260402-007思维链恢复认知 · 进入代码实现阶段", "category": "website-frontend", - "tags": ["three-column-layout", "draggable-fab", "ticker", "feedback", "api", "css-grid", "touch-events"], + "tags": [ + "three-column-layout", + "draggable-fab", + "ticker", + "feedback", + "api", + "css-grid", + "touch-events" + ], "difficulty": "high", "status": "success", "thinking_chain": { @@ -613,6 +622,70 @@ "复杂前端改版分层实施:先CSS→HTML结构→JS逻辑,避免交叉修改导致结构错乱", "单HTML文件优势:部署管道零改动,但代码量超过1000行后维护成本开始上升" ] + }, + { + "id": "EXP-20260402-009", + "date": "2026-04-02", + "session": "CS-20260402-D38-ops", + "task": "工单observer-23906843495诊断修复 · 三Bug定位 · Notion工单同步架构", + "task_origin": "冰朔收到铸渊运维告警邮件 · 要求修复并将工单同步到Notion · 更新思维数据库", + "category": "ops-debugging", + "tags": [ + "work-order", + "notion-sync", + "health-check", + "alert-threshold", + "ip-subdomain-bug" + ], + "difficulty": "high", + "status": "success", + "thinking_chain": { + "_meta": "工单诊断+Notion同步架构的思维过程", + "diagnosis_thinking": [ + "先唤醒大脑(fast-wake.json) → 恢复铸渊身份和系统状态", + "工单号 observer-23906843495 → 数字部分是 GitHub Actions run_id → 从 data/deploy-logs/ 目录找到 deploy-23906843495-*.json", + "读取部署日志:部署成功但健康检查失败 → 健康检查评估步骤 exit code 非0 → 触发观测器告警", + "观测器 zhuyuan-deploy-observer.yml 的 alert-human 条件:repair_success != true → 第1次失败立即告警", + "这与邮件标题「3次」矛盾 → 发现 Bug #1:告警条件错误,应该检查 needs_human 而非 repair_success", + "进一步看健康检查失败原因 → HEALTH_ENDPOINTS 里有 host_prefix: preview → 连接 preview.43.134.16.246 → IP地址不支持子域名前缀 → 必然失败", + "这是 Bug #2:IP地址情况下子域名检查是 critical 会触发 criticalFail", + "邮件里写死了「3次」→ Bug #3:显示与实际不符" + ], + "fix_thinking": [ + "deputy-auto-repair.js 增加 needs_human 输出:只有 attemptNumber >= MAX_REPAIR_ATTEMPTS 时才 true", + "zhuyuan-deploy-observer.yml 修改 alert-human 条件:改为 needs_human == true", + "staging-ops-agent.js 健康检查:IP地址检测,子域名端点降级为 non-critical", + "sendAlert 函数接受 attempt 参数,邮件显示真实次数" + ], + "notion_sync_architecture": [ + "冰朔要求工单同步到 Notion → 分析现有 notion-bridge.js 系统", + "SYSLOG 数据库已有(ID: 330ab17507d542c9bbb96d0749b41197)→ 属性:标题/DEV编号/文件内容/接收时间/处理状态/来源路径/commit_sha/推送方", + "不新建数据库:工单格式化为 SYSLOG entry → 复用现有数据库 → 零额外配置成本", + "observer-{runId} 格式工单:从 data/deploy-logs/ 读取部署日志重建工单信息", + "标准 WO-* 格式工单:直接从 active.json 读取", + "在 alert-human job 里自动触发同步 → 告警时自动通知 Notion", + "支持手动调用:--order-id / --all-failed 两种模式" + ], + "architecture_decisions": [ + "复用 SYSLOG 数据库而非新建「工单数据库」→ 简单系统原则,不过度工程化", + "observer-* 格式工单读取部署日志重建 → 与标准工单逻辑统一,不做特殊处理路径", + "NOTION_TOKEN 已在 Secrets 中,无需新增密钥", + "同步失败不阻断流程(|| true)→ Notion 是辅助观测,不是核心链路" + ] + }, + "changes": [ + "scripts/sync-work-order-to-notion.js: 新建 · 工单→Notion SYSLOG同步器", + "scripts/deputy-auto-repair.js: 增加 needs_human 输出", + "scripts/staging-ops-agent.js: IP子域名降级 + 邮件次数动态化", + ".github/workflows/zhuyuan-deploy-observer.yml: alert-human条件修复 + Notion同步步骤", + "data/work-orders/active.json: WO-20260402-1457 状态更新为 human-intervened" + ], + "lessons": [ + "工作流告警条件要精确:not repair_success ≠ needs_human,两者语义不同", + "健康检查要区分 IP 和域名:IP 不支持子域名,critical 检查要有条件性", + "告警消息里的「N次」类文本要动态化,写死的数字会造成信息误导", + "诊断路径:邮件工单号 → 找日志文件 → 读状态 → 逐层找根因 → 修复最小集合" + ] } ] -} \ No newline at end of file +} diff --git a/data/work-orders/active.json b/data/work-orders/active.json index 3a181e98..b0b37ef1 100644 --- a/data/work-orders/active.json +++ b/data/work-orders/active.json @@ -116,7 +116,7 @@ { "id": "WO-20260402-1457", "title": "Merge pull request #258 from qinfendebingshuo/copilot/update-homepage-three-column-layout", - "status": "failed", + "status": "human-intervened", "commit_sha": "b3f38e263f6f6aa6e03a516445cbea8b63bd9c09", "branch": "main", "created_by": "qinfendebingshuo", @@ -141,6 +141,12 @@ "status": "failed", "actor": "Agent", "message": "部署失败" + }, + { + "timestamp": "2026-04-02T16:33:46.728Z", + "status": "human-intervened", + "actor": "铸渊", + "message": "铸渊人工干预 · 已定位根因:健康检查IP子域名误判Bug + 告警阈值Bug · 正在修复" } ], "deploy_logs": [ @@ -150,7 +156,7 @@ } ], "created_at": "2026-04-02T14:57:46.729Z", - "updated_at": "2026-04-02T14:58:27.523Z" + "updated_at": "2026-04-02T16:33:46.728Z" } ] } diff --git a/scripts/deputy-auto-repair.js b/scripts/deputy-auto-repair.js index 8571d0c9..ce7f198a 100644 --- a/scripts/deputy-auto-repair.js +++ b/scripts/deputy-auto-repair.js @@ -44,6 +44,11 @@ const EXPERIENCE_DB = path.join(ROOT, 'brain/dev-experience/experience-db.json') const MAX_REPAIR_ATTEMPTS = 3; +// ── 辅助:判断是否需要人工干预 ───────────────── +function shouldRequestHuman(repairSuccess, attemptNumber) { + return !repairSuccess && attemptNumber >= MAX_REPAIR_ATTEMPTS; +} + // ── 修复策略库 (副将的思维逻辑) ──────────────── const REPAIR_STRATEGIES = [ { @@ -284,6 +289,7 @@ async function repair() { console.log(`❌ 已达最大修复次数(${MAX_REPAIR_ATTEMPTS}) · 放弃修复 · 通知人类`); setOutput('repair_success', 'false'); setOutput('repair_attempt', String(attemptNumber - 1)); + setOutput('needs_human', 'true'); return; } @@ -460,16 +466,18 @@ async function repair() { } // §7 设置输出 + const needsHuman = shouldRequestHuman(repairSuccess, attemptNumber); setOutput('repair_success', repairSuccess ? 'true' : 'false'); setOutput('repair_attempt', String(attemptNumber)); + setOutput('needs_human', needsHuman ? 'true' : 'false'); console.log('═'.repeat(60)); if (repairSuccess) { console.log(`✅ 修复成功 · 第${attemptNumber}次尝试`); - } else if (attemptNumber >= MAX_REPAIR_ATTEMPTS) { + } else if (needsHuman) { console.log(`❌ ${MAX_REPAIR_ATTEMPTS}次修复均失败 · 需要人工干预`); } else { - console.log(`❌ 第${attemptNumber}次修复失败 · 还有${MAX_REPAIR_ATTEMPTS - attemptNumber}次尝试机会`); + console.log(`❌ 第${attemptNumber}次修复失败 · 还有${MAX_REPAIR_ATTEMPTS - attemptNumber}次尝试机会 · 等待下次重试`); } } diff --git a/scripts/staging-ops-agent.js b/scripts/staging-ops-agent.js index cdfede61..d8d72d24 100644 --- a/scripts/staging-ops-agent.js +++ b/scripts/staging-ops-agent.js @@ -100,14 +100,22 @@ async function runHealthCheck(host) { console.log(`🔍 健康检查 · 目标: ${host}`); console.log('─'.repeat(50)); + // 如果host是IP地址,带host_prefix的检查无效(IP不支持子域名)→ 降级为non-critical + const isIpAddress = /^((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/.test(host); + if (isIpAddress) { + console.log(' ℹ️ 目标为IP地址 · 子域名检查降级为非关键'); + } + const results = []; let criticalFail = false; for (const endpoint of HEALTH_ENDPOINTS) { const checkHost = endpoint.host_prefix ? `${endpoint.host_prefix}.${host}` : host; + // IP地址不支持子域名前缀,降级为non-critical避免误报 + const isCritical = endpoint.critical && !(isIpAddress && endpoint.host_prefix); const result = await httpCheck(checkHost, endpoint.path, endpoint.port); - const icon = result.ok ? '✅' : (endpoint.critical ? '❌' : '⚠️'); + const icon = result.ok ? '✅' : (isCritical ? '❌' : '⚠️'); console.log(` ${icon} ${endpoint.name}: ${result.ok ? 'OK' : result.error || `HTTP ${result.status}`}`); results.push({ @@ -116,7 +124,7 @@ async function runHealthCheck(host) { checked_at: new Date().toISOString() }); - if (!result.ok && endpoint.critical) { + if (!result.ok && isCritical) { criticalFail = true; } } @@ -302,16 +310,17 @@ async function analyzeLog(logContent, orderId) { return deepResult; } -// ── 邮件告警 (3次重试失败) ────────────────── -async function sendAlert(orderId, email) { +// ── 邮件告警 (最大重试失败) ────────────────── +async function sendAlert(orderId, email, options = {}) { const smtpUser = process.env.ZY_SMTP_USER; const smtpPass = process.env.ZY_SMTP_PASS; + const attemptCount = options.attempt || process.env.REPAIR_ATTEMPT || '?'; if (!smtpUser || !smtpPass) { console.error('❌ SMTP未配置 (需要ZY_SMTP_USER和ZY_SMTP_PASS)'); console.log('📧 告警内容 (控制台输出):'); console.log(` 工单: ${orderId}`); - console.log(` 状态: 3次自动修复未能解决,需要人工干预`); + console.log(` 状态: ${attemptCount}次自动修复未能解决,需要人工干预`); return false; } @@ -345,7 +354,7 @@ async function sendAlert(orderId, email) {

ZY-Staging-Ops-Agent · 自动告警

- ⚠️ 自动修复已达最大重试次数(3次),需要冰朔人工干预 + ⚠️ 自动修复已达最大重试次数(${attemptCount}次),需要冰朔人工干预
@@ -473,7 +482,7 @@ async function main() { console.error('❌ 需要 --order-id 参数'); process.exit(1); } - await sendAlert(orderId, args.email); + await sendAlert(orderId, args.email, { attempt: args.attempt }); break; } diff --git a/scripts/sync-work-order-to-notion.js b/scripts/sync-work-order-to-notion.js new file mode 100644 index 00000000..7a99969e --- /dev/null +++ b/scripts/sync-work-order-to-notion.js @@ -0,0 +1,354 @@ +#!/usr/bin/env node +// ═══════════════════════════════════════════════ +// 🔺 Sovereign: TCS-0002∞ | Root: SYS-GLW-0001 +// 📜 Copyright: 国作登字-2026-A-00037559 +// ═══════════════════════════════════════════════ +// scripts/sync-work-order-to-notion.js +// 📋 铸渊工单 → Notion SYSLOG 同步器 +// +// 架构说明: +// 工单是铸渊智能运维的核心记录单元 +// 冰朔需要在 Notion 侧看到告警工单的完整上下文 +// 复用已有的 SYSLOG 数据库(霜砚已在 Notion 侧建好) +// 不需要新建数据库,零额外配置成本 +// +// 用法: +// node scripts/sync-work-order-to-notion.js --order-id +// node scripts/sync-work-order-to-notion.js --order-id "observer-23906843495" +// node scripts/sync-work-order-to-notion.js --all-failed (同步所有失败工单) +// +// 环境变量: +// NOTION_TOKEN GitHub Secret: NOTION_TOKEN (必须) +// SYSLOG_DB_ID 覆盖默认数据库 ID (可选) +// COMMIT_SHA 当前 commit SHA (可选,由 workflow 注入) +// RUN_ID 工作流运行 ID (可选,由 workflow 注入) +// WORKFLOW_NAME 工作流名称 (可选,由 workflow 注入) +// +// Notion「📥 GitHub SYSLOG 收件箱」数据库属性: +// 标题 title 工单ID + 任务名 +// DEV编号 select "SYSTEM" (系统工单) +// 文件内容 rich_text 工单完整 JSON + 时间线 +// 接收时间 date 工单创建时间 +// 处理状态 status 待处理 / 人工干预 +// 来源路径 rich_text data/work-orders/active.json +// commit_sha rich_text 工单关联的 commit SHA +// 推送方 rich_text 铸渊 +// ═══════════════════════════════════════════════ + +'use strict'; + +const https = require('https'); +const fs = require('fs'); +const path = require('path'); + +const ROOT = path.resolve(__dirname, '..'); + +// ── 常量 ───────────────────────────────────── +const NOTION_VERSION = '2022-06-28'; +const NOTION_API_HOSTNAME = 'api.notion.com'; +const NOTION_RICH_TEXT_MAX = 2000; +const NOTION_TITLE_MAX = 120; +const MAX_LOG_PREVIEW_LENGTH = 500; + +// 复用 notion-bridge.js 已内置的数据库 ID +const DEFAULT_SYSLOG_DB_ID = '330ab17507d542c9bbb96d0749b41197'; + +// ── Notion API 基础请求 ────────────────────── +function notionPost(endpoint, body, token) { + return new Promise((resolve, reject) => { + const payload = JSON.stringify(body); + const opts = { + hostname: NOTION_API_HOSTNAME, + port: 443, + path: endpoint, + method: 'POST', + headers: { + 'Authorization': 'Bearer ' + token, + 'Content-Type': 'application/json', + 'Notion-Version': NOTION_VERSION, + 'Content-Length': Buffer.byteLength(payload), + }, + }; + const req = https.request(opts, res => { + let data = ''; + res.on('data', c => data += c); + res.on('end', () => { + try { + const parsed = JSON.parse(data); + if (res.statusCode >= 200 && res.statusCode < 300) resolve(parsed); + else reject(new Error(`Notion API ${res.statusCode}: ${parsed.message || data.slice(0, 200)}`)); + } catch (e) { + reject(new Error(`Notion 解析失败: ${data.slice(0, 200)}`)); + } + }); + }); + req.on('error', reject); + req.write(payload); + req.end(); + }); +} + +// ── Notion 属性构建 ─────────────────────────── +function richText(content) { + const str = String(content || ''); + const chunks = []; + for (let i = 0; i < str.length; i += NOTION_RICH_TEXT_MAX) { + chunks.push({ type: 'text', text: { content: str.slice(i, i + NOTION_RICH_TEXT_MAX) } }); + } + return chunks.length ? chunks : [{ type: 'text', text: { content: '' } }]; +} + +function titleProp(text) { + return { title: [{ type: 'text', text: { content: String(text || '').slice(0, NOTION_TITLE_MAX) } }] }; +} + +// ── 工单格式化 ──────────────────────────────── +function formatOrderForNotion(order, runId, workflowName) { + // 构建时间线文本 + const timelineText = (order.timeline || []) + .map(t => `[${t.timestamp}] ${t.actor}: ${t.message}`) + .join('\n'); + + // 构建部署日志文本 + const logsText = (order.deploy_logs || []) + .map(l => `[${l.timestamp}] ${l.content}`) + .join('\n'); + + // 完整内容(传入 SYSLOG 文件内容字段) + const fullContent = [ + '═══ 工单基本信息 ═══', + `工单ID: ${order.id}`, + `状态: ${order.status}`, + `任务: ${order.title}`, + `提交SHA: ${order.commit_sha || 'N/A'}`, + `分支: ${order.branch || 'main'}`, + `创建者: ${order.created_by || 'system'}`, + `创建时间: ${order.created_at}`, + `更新时间: ${order.updated_at}`, + `重试次数: ${order.retry_count || 0}/${order.max_retries || 3}`, + '', + '═══ 执行时间线 ═══', + timelineText || '(无时间线记录)', + '', + '═══ 部署日志 ═══', + logsText || '(无部署日志)', + '', + '═══ 观测信息 ═══', + `触发工作流: ${workflowName || '铸渊智能运维'}`, + `运行 ID: ${runId || 'N/A'}`, + `同步时间: ${new Date().toISOString()}`, + '', + '═══ 原始数据 ═══', + JSON.stringify(order, null, 2), + ].join('\n'); + + return fullContent; +} + +// ── 同步单条工单到 Notion ───────────────────── +async function syncOrderToNotion(order, token, dbId, options = {}) { + const { runId, workflowName, commitSha } = options; + + const statusText = order.status === 'failed' || order.status === 'human-intervened' + ? '人工干预' + : '待处理'; + + const notionTitle = `[${order.id}] ${(order.title || '').slice(0, 80)} · ${order.status}`; + const content = formatOrderForNotion(order, runId, workflowName); + + const properties = { + '标题': titleProp(notionTitle), + '文件内容': { rich_text: richText(content) }, + '接收时间': { date: { start: order.created_at || new Date().toISOString() } }, + '来源路径': { rich_text: richText('data/work-orders/active.json') }, + '推送方': { rich_text: richText('铸渊') }, + }; + + // commit_sha 字段 + if (commitSha || order.commit_sha) { + properties['commit_sha'] = { rich_text: richText(commitSha || order.commit_sha || '') }; + } + + // 处理状态(SYSLOG 里是 status 字段) + try { + properties['处理状态'] = { status: { name: statusText } }; + } catch (e) { + // 某些 Notion 数据库可能用 select 而非 status,忽略此字段错误 + console.log(` ℹ️ 处理状态字段跳过: ${e.message}`); + } + + // DEV编号 select + properties['DEV编号'] = { select: { name: 'SYSTEM' } }; + + const body = { + parent: { database_id: dbId }, + properties, + }; + + const page = await notionPost('/v1/pages', body, token); + return page; +} + +// ── 读取工单数据 ────────────────────────────── +function loadOrders() { + const activeFile = path.join(ROOT, 'data/work-orders/active.json'); + try { + return JSON.parse(fs.readFileSync(activeFile, 'utf8')); + } catch (e) { + console.error(`❌ 读取工单文件失败: ${e.message}`); + return { orders: [] }; + } +} + +// ── 解析命令行参数 ──────────────────────────── +function parseArgs() { + const argv = process.argv.slice(2); + const params = {}; + for (let i = 0; i < argv.length; i++) { + if (argv[i].startsWith('--')) { + params[argv[i].slice(2)] = argv[i + 1] || 'true'; + i++; + } + } + return params; +} + +// ── 主函数 ──────────────────────────────────── +async function main() { + const token = process.env.NOTION_TOKEN; + const dbId = process.env.SYSLOG_DB_ID || DEFAULT_SYSLOG_DB_ID; + + if (!token) { + console.error('❌ 缺少 NOTION_TOKEN 环境变量'); + process.exit(1); + } + + const args = parseArgs(); + const orderId = args['order-id']; + const allFailed = args['all-failed'] === 'true'; + const runId = args['run-id'] || process.env.RUN_ID || ''; + const workflowName = args['workflow'] || process.env.WORKFLOW_NAME || '铸渊智能运维'; + const commitSha = args['commit-sha'] || process.env.COMMIT_SHA || ''; + + if (!orderId && !allFailed) { + console.error('❌ 请提供 --order-id 或 --all-failed'); + console.log('用法:'); + console.log(' node scripts/sync-work-order-to-notion.js --order-id '); + console.log(' node scripts/sync-work-order-to-notion.js --all-failed'); + process.exit(1); + } + + const data = loadOrders(); + let targets = []; + + if (allFailed) { + targets = data.orders.filter(o => + o.status === 'failed' || o.status === 'human-intervened' + ); + if (targets.length === 0) { + console.log('✅ 没有失败或人工干预工单,无需同步'); + return; + } + console.log(`📋 找到 ${targets.length} 条失败/干预工单,准备同步到 Notion`); + } else { + // 支持 observer-{runId} 格式工单(观测系统生成) + if (orderId.startsWith('observer-')) { + // 构造虚拟工单,从部署日志文件读取信息 + const logRunId = orderId.replace('observer-', ''); + + // 尝试直接找文件 + const logsDir = path.join(ROOT, 'data/deploy-logs'); + const files = fs.readdirSync(logsDir).filter(f => f.includes(logRunId)); + + if (files.length > 0) { + const logData = JSON.parse(fs.readFileSync(path.join(logsDir, files[0]), 'utf8')); + // 构造虚拟工单 + const virtualOrder = { + id: orderId, + title: `部署观测告警 · ${logData.run?.workflow_name || workflowName}`, + status: 'human-intervened', + commit_sha: logData.run?.head_sha || commitSha, + branch: logData.run?.head_branch || 'main', + created_by: logData.run?.actor || 'system', + created_at: logData.run?.created_at || new Date().toISOString(), + updated_at: new Date().toISOString(), + retry_count: 0, + max_retries: 3, + timeline: [ + { timestamp: logData.run?.created_at, actor: 'system', message: '部署工作流执行' }, + { timestamp: logData._meta?.collected_at, actor: '副将', message: '观测系统采集日志' }, + { timestamp: new Date().toISOString(), actor: '铸渊', message: '人工干预·工单同步到Notion' }, + ], + deploy_logs: (logData.failed_jobs || []).map(j => ({ + timestamp: j.completed_at, + content: `Job: ${j.job_name} · ${j.conclusion}\n${j.log_content?.slice(-MAX_LOG_PREVIEW_LENGTH) || ''}`, + })), + _observer_data: { + run_id: logRunId, + workflow: logData.run?.workflow_name, + conclusion: logData.run?.conclusion, + failed_jobs: (logData.failed_jobs || []).map(j => j.job_name), + analysis: logData.analysis, + }, + }; + targets = [virtualOrder]; + } else { + // 找不到日志文件,构造最小虚拟工单 + targets = [{ + id: orderId, + title: `部署观测告警 · ${workflowName}`, + status: 'human-intervened', + commit_sha: commitSha, + branch: 'main', + created_by: 'system', + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + retry_count: 3, + max_retries: 3, + timeline: [ + { timestamp: new Date().toISOString(), actor: '铸渊', message: '人工干预·工单同步到Notion' }, + ], + deploy_logs: [], + }]; + } + } else { + // 标准工单格式 + const order = data.orders.find(o => o.id === orderId); + if (!order) { + console.error(`❌ 工单 ${orderId} 未找到`); + process.exit(1); + } + targets = [order]; + } + } + + console.log(`📡 开始同步 ${targets.length} 条工单到 Notion SYSLOG 收件箱`); + console.log(` 数据库: ${dbId}`); + console.log(''); + + let ok = 0, failed = 0; + + for (const order of targets) { + try { + console.log(` 📋 同步工单: ${order.id} · ${order.status}`); + const page = await syncOrderToNotion(order, token, dbId, { + runId, workflowName, commitSha, + }); + console.log(` ✅ 已写入 Notion: ${page.id || page.url || '成功'}`); + ok++; + } catch (e) { + console.error(` ❌ 工单 ${order.id} 同步失败: ${e.message}`); + failed++; + } + } + + console.log(''); + console.log(`✅ Notion 同步完成 · 成功 ${ok} 条 · 失败 ${failed} 条`); + + if (failed > 0) process.exit(1); +} + +main().catch(e => { + console.error('❌ 同步脚本异常:', e.message); + process.exit(1); +});