cs-bots 0.7.5 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/board.js CHANGED
@@ -87,6 +87,22 @@ const STATUSES = ['pending', 'in_progress', 'completed', 'blocked', 'deleted']
87
87
  const ACCEPT_RESULTS = ['pass', 'reject']
88
88
  /** 租约缺省 TTL(ms)。 */
89
89
  const DEFAULT_LEASE_MS = 300_000
90
+ /**
91
+ * board 动作 → `src/metrics.js` 的 METRIC_KINDS 映射(bots04 欠账2)。
92
+ *
93
+ * 为什么需要这张表:board 的动作名(`claim`/`complete`/`accept`…)与 metrics 的
94
+ * 指标种类(`task_claim`/`task_complete`/`accept_pass`…)**不是同名**,而且不是一对一
95
+ * (`accept` 要按 result 拆成 pass/reject)。硬编码在 commit 里会变成一长串 if。
96
+ *
97
+ * 没有映射的动作(`create`/`edit`/`release`/`reopen`/`delete`)故意不记:
98
+ * metrics 的 METRIC_KINDS 里没有对应种类,**新增种类要同步改 metrics.js 与它的测试**,
99
+ * 不能在这里偷偷塞一个不认识的 kind(metrics.record 会抛错的)。
100
+ */
101
+ const ACTION_TO_METRIC = Object.freeze({
102
+ claim: 'task_claim',
103
+ complete: 'task_complete',
104
+ block: 'task_blocked',
105
+ })
90
106
  /** 复杂度档位(契约 §6 冻结;路由侧 `src/routing/complexity.js` 用同一套字面量)。 */
91
107
  const COMPLEXITY_TIERS = ['T0', 'T1', 'T2', 'T3']
92
108
  /**
@@ -415,6 +431,30 @@ function normalizeProgress(value, where) {
415
431
  return value
416
432
  }
417
433
 
434
+ /**
435
+ * 旧数据兼容读(契约 §9):补齐/抹平**新字段**,让老街机数据也能安全被读。
436
+ *
437
+ * 硬要求:**缺字段一律给默认值,绝不抛**。升级不丢任何既有任务/证据/历史,
438
+ * 所以这里只做「无副作用的正规化」,不猜测、不伪造来源(旧数据没有
439
+ * `progressBy` 就是「不知道谁设的」,不补一个假的 —— UI 会显示成总进度)。
440
+ *
441
+ * @param {object} t 从事件流/快照来的 Task(就地修改)
442
+ * @returns {object} 同一个 t
443
+ */
444
+ function normalizeLegacyTask(t) {
445
+ if (!Array.isArray(t.blockedBy)) t.blockedBy = []
446
+ if (!Array.isArray(t.writeScopes)) t.writeScopes = []
447
+ if (!Array.isArray(t.labels)) t.labels = []
448
+ if (t.triaged !== true && t.triaged !== false) t.triaged = false
449
+ if (typeof t.progress !== 'number' || !Number.isFinite(t.progress)) t.progress = 0
450
+ // `progressBy`/`progressAt`(契约 §6 新增):旧数据没有 ⇒ 直接不设(视为 undefined)。
451
+ // 注意:**不给默认值**是对的 —— 给了就会把「旧任务」伪造成「某人设过进度」。
452
+ // 只在字段存在但类型坏了时才抹掉,避免 UI/导出拿到脏值。
453
+ if (t.progressBy !== undefined && typeof t.progressBy !== 'string') delete t.progressBy
454
+ if (t.progressAt !== undefined && !Number.isFinite(t.progressAt)) delete t.progressAt
455
+ return t
456
+ }
457
+
418
458
  /** triaged 归一:严格布尔。 */
419
459
  function normalizeTriaged(value, where) {
420
460
  if (value === undefined || value === null) return undefined
@@ -596,6 +636,31 @@ export function createBoard(ctx, config = {}) {
596
636
  : (Number.isFinite(config.wipLimitPerBot) && config.wipLimitPerBot >= 1 ? config.wipLimitPerBot : 2)
597
637
  const requireTriageForReady = settings.requireTriageForReady === true || config.requireTriageForReady === true
598
638
 
639
+ /**
640
+ * 指标旁路(bots04 欠账2)。
641
+ *
642
+ * 为什么是「注入」而不是从 ctx 取:服务注册表在 `src/registry.js` 用的是
643
+ * `WeakMap<ctx, services>`,且 `index.js` 里 **board 先于 metrics 装配的顺序不保证**。
644
+ * 用注入(`config.metrics`)能拿到**装配完成后的实例**,也避免 board→registry 的循环依赖。
645
+ *
646
+ * 取不到就不记(返回 false),绝不抛 —— 指标缺失是可接受的退化,
647
+ * 业务动作失败是不可接受的。
648
+ */
649
+ // ⚠️ 为什么支持函数:`index.js` 里 **board 先于 metrics 装配**(board 在 L130,
650
+ // metrics 在 L276)。若只接受实例,board 装配时 metrics 还是 undefined,
651
+ // 埋点会永久静默失效 —— 那正是「256 条全是 turn_end」这类 bug 的成因。
652
+ // 传函数 ⇒ 每次记录时现取,装配顺序就不再影响正确性。
653
+ const metricsSink = config && config.metrics !== undefined ? config.metrics : null
654
+ function recordMetric(kind, taskId, by) {
655
+ let sink = metricsSink
656
+ if (typeof sink === 'function') {
657
+ try { sink = sink() } catch { sink = null }
658
+ }
659
+ if (!sink || typeof sink.record !== 'function') return false
660
+ sink.record({ kind, taskId: taskId ?? null, by: by ?? null })
661
+ return true
662
+ }
663
+
599
664
  const jsonlPath = statePath(stateDir, 'board.jsonl')
600
665
  const snapPath = statePath(stateDir, 'board.snap.json')
601
666
 
@@ -672,6 +737,20 @@ export function createBoard(ctx, config = {}) {
672
737
  }
673
738
  tasks.set(taskId, clone(after))
674
739
  events.push(event)
740
+ // ── 指标埋点(bots04 欠账2)─────────────────────────────────────────
741
+ // 为什么埋在 commit 而不是各个动作里:commit 是**所有**状态变更的唯一出口,
742
+ // 埋在这里才不会漏(create/update/claim/complete/accept 全过它)。
743
+ //
744
+ // 为什么必须**服务端**埋:此前 256 条 metrics 全是 `turn_end` —— 因为
745
+ // 只有 `index.js` 在回合结束自动记,其余 11 种 kind 只靠「谁主动调
746
+ // bot_metrics_record」才记,而没有任何业务代码主动调。PDCA 页面因此只看得见
747
+ // 「回合结束」一种指标,看不到验收通过率、任务认领率。
748
+ //
749
+ // 纪律:埋点失败**绝不能**影响业务动作 —— 主流程已经落盘成功,指标是旁路。
750
+ try {
751
+ const metricsAction = ACTION_TO_METRIC[action]
752
+ if (metricsAction) recordMetric(metricsAction, taskId, byId)
753
+ } catch { /* 指标是旁路,绝不影响主流程 */ }
675
754
  if (after.sourceSeq) {
676
755
  // 幂等键在这里登记(而不是在 create 开头),保证「校验/落盘失败不污染键」
677
756
  idem.check(after.sourceSeq)
@@ -748,11 +827,7 @@ export function createBoard(ctx, config = {}) {
748
827
  for (const raw of snapTasks) {
749
828
  if (!raw || typeof raw.id !== 'string') continue
750
829
  const t = clone(raw)
751
- if (!Array.isArray(t.blockedBy)) t.blockedBy = []
752
- if (!Array.isArray(t.writeScopes)) t.writeScopes = []
753
- if (!Array.isArray(t.labels)) t.labels = []
754
- if (t.triaged !== true && t.triaged !== false) t.triaged = false
755
- if (typeof t.progress !== 'number' || !Number.isFinite(t.progress)) t.progress = 0
830
+ normalizeLegacyTask(t)
756
831
  tasks.set(t.id, t)
757
832
  idCounter = Math.max(idCounter, idNumber(t.id))
758
833
  if (t.sourceSeq) sourceSeqIndex.set(t.sourceSeq, t.id)
@@ -798,11 +873,7 @@ export function createBoard(ctx, config = {}) {
798
873
  continue
799
874
  }
800
875
  const t = clone(ev.after)
801
- if (!Array.isArray(t.blockedBy)) t.blockedBy = []
802
- if (!Array.isArray(t.writeScopes)) t.writeScopes = []
803
- if (!Array.isArray(t.labels)) t.labels = []
804
- if (t.triaged !== true && t.triaged !== false) t.triaged = false
805
- if (typeof t.progress !== 'number' || !Number.isFinite(t.progress)) t.progress = 0
876
+ normalizeLegacyTask(t)
806
877
  tasks.set(ev.taskId, t)
807
878
  events.push(clone(ev))
808
879
  idCounter = Math.max(idCounter, idNumber(ev.taskId))
@@ -1196,7 +1267,23 @@ export function createBoard(ctx, config = {}) {
1196
1267
  if (src.estimate !== undefined) setOrDelete('estimate', normalizeEstimate(src.estimate, where))
1197
1268
  if (src.assignee !== undefined) setOrDelete('assignee', normalizeOptionalString(src.assignee, 'assignee', where))
1198
1269
  if (src.reviewer !== undefined) setOrDelete('reviewer', normalizeOptionalString(src.reviewer, 'reviewer', where))
1199
- if (src.progress !== undefined) setOrDelete('progress', normalizeProgress(src.progress, where))
1270
+ if (src.progress !== undefined) {
1271
+ const progressNorm = normalizeProgress(src.progress, where)
1272
+ if (progressNorm === undefined) {
1273
+ // `null` = 清除进度 ⇒ 来源字段一并清掉(没有进度就谈不上「谁设的」)
1274
+ delete nextTask.progress
1275
+ delete nextTask.progressBy
1276
+ delete nextTask.progressAt
1277
+ } else {
1278
+ nextTask.progress = progressNorm
1279
+ // 契约 §6:进度来源是**派生字段** —— 调用方不许传(不在 PATCH_FIELDS /
1280
+ // NEW_FIELD_NAMES 白名单里,传了会被拒成「未知字段」),由这里自动同写。
1281
+ // 为什么必须留痕:UI 要能说清「当前 40%(队长估算)」还是「(Boss 设定)」,
1282
+ // 而 `progress` 单一个数字根本分不出是谁定的。
1283
+ nextTask.progressBy = by
1284
+ nextTask.progressAt = at
1285
+ }
1286
+ }
1200
1287
  if (src.triaged !== undefined) setOrDelete('triaged', normalizeTriaged(src.triaged, where))
1201
1288
  if (src.startedAt !== undefined) setOrDelete('startedAt', optionalNumber(src.startedAt, 'startedAt', where))
1202
1289
  if (src.completedAt !== undefined) setOrDelete('completedAt', optionalNumber(src.completedAt, 'completedAt', where))
@@ -1831,6 +1918,12 @@ export function createBoard(ctx, config = {}) {
1831
1918
  delete next.latestReport // 旧的收尾结论一并作废(重做后会重新产生)
1832
1919
  delete next.accepted // 驳回即取消「已验收」,与 acceptance.result='reject' 一致
1833
1920
  }
1921
+ // 验收埋点(bots04 欠账2):accept 的指标要按 result 拆成 pass/reject,
1922
+ // 不能用 ACTION_TO_METRIC(那里是动作名→kind 的一对一映射)。
1923
+ // 放在 commit **之前**:commit 成功后已经落盘,埋点是旁路,先记不影响主结果。
1924
+ try {
1925
+ recordMetric(result === 'pass' ? 'accept_pass' : 'accept_reject', taskId, actor)
1926
+ } catch { /* 指标是旁路,绝不影响主流程 */ }
1834
1927
  return commit(taskId, 'accept', actor, at, before, next)
1835
1928
  }
1836
1929