步驟6 執行統計回寫:每顆零件跑完回寫 success_rate(task 29,design.md「執行統計設計」)

- registry 新增 POST /analytics/record:ANALYTICS_KV 計數器(stats:{hash_id}:{version})
  為唯一真相源,衍生值(success_rate/avg_duration_ms/call_count)回填 comp: 記錄
  ——查詢讀取端讀哪就寫哪,不開第二真相源。KV 無 CAS,誠實標註非原子。
- cypher-executor execution-evaluator 從 stub 改真實作:執行收尾(/cypher/execute
  成功與 ExecutionError 路徑+webhook 路徑)對 trace 裡每顆 Component 節點
  fire-and-forget 回寫,waitUntil 包、不增加執行同步延遲(仿 recordRecipeStats 慣例)。
  成敗判定=trace error 或 output.success===false(makeHttpRunner 非 2xx 不 throw)。
- registry 位置沿 search-nodes 慣例:REGISTRY_BASE_URL 覆蓋,未設走 wasmWorkerUrl。
- 新增單測 13 個全綠;本地雙 wrangler dev 端到端實測:http_request 跑 5 次
  (3 成功+2 失敗)→ success_rate 1→0.6、call_count 0→5,/cypher/search 同步可見。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
uncle6me-web
2026-07-31 13:45:05 +08:00
parent 7e87a3336b
commit ea1c0571c1
10 changed files with 490 additions and 52 deletions
+99
View File
@@ -0,0 +1,99 @@
// 單元測試:recordAnalytics — 執行統計回寫
// SDD: system-dev/docs/3-specs/arcrun-core-mvp/design.md「執行統計設計」
import { describe, it, expect, beforeEach } from 'vitest';
import { recordAnalytics } from '../src/actions/recordAnalytics';
import type { Bindings } from '../src/types';
// 最小 KV mockget/put/listIn-memory
function makeKv() {
const store = new Map<string, string>();
return {
store,
async get(key: string) { return store.get(key) ?? null; },
async put(key: string, value: string) { store.set(key, value); },
async list({ prefix }: { prefix: string }) {
return { keys: [...store.keys()].filter(k => k.startsWith(prefix)).map(name => ({ name })) };
},
};
}
describe('recordAnalytics', () => {
let submissions: ReturnType<typeof makeKv>;
let analytics: ReturnType<typeof makeKv>;
let env: Bindings;
beforeEach(() => {
submissions = makeKv();
analytics = makeKv();
env = { SUBMISSIONS_KV: submissions, ANALYTICS_KV: analytics } as unknown as Bindings;
// 種一顆零件(indexOnlyComponent 的記錄形狀)
submissions.store.set('idx:http_request', 'cmp_abc12345');
submissions.store.set('comp:cmp_abc12345:v1', JSON.stringify({
component_hash_id: 'cmp_abc12345',
canonical_id: 'http_request',
display_name: 'HTTP Request',
version: 'v1',
success_rate: 1,
avg_duration_ms: 0,
call_count: 0,
visibility: 'public',
status: 'active',
}));
});
it('N 次成功+M 次失敗 → success_rate = success_runs / total_runs,計數器與 comp 記錄同步', async () => {
// 3 成功 + 2 失敗
for (const success of [true, true, true, false, false]) {
const r = await recordAnalytics({ canonical_id: 'http_request', success, duration_ms: 100 }, env);
expect(r.ok).toBe(true);
}
// 真相源:ANALYTICS_KV 計數器
const counters = JSON.parse(analytics.store.get('stats:cmp_abc12345:v1')!);
expect(counters).toEqual({ total_runs: 5, success_runs: 3, total_ms: 500 });
// 讀取端:comp 記錄被回填衍生值
const record = JSON.parse(submissions.store.get('comp:cmp_abc12345:v1')!);
expect(record.success_rate).toBeCloseTo(3 / 5);
expect(record.avg_duration_ms).toBe(100);
expect(record.call_count).toBe(5);
});
it('接受 cmp_ hash_id 直接記錄', async () => {
const r = await recordAnalytics({ canonical_id: 'cmp_abc12345', success: true, duration_ms: 50 }, env);
expect(r.ok).toBe(true);
expect(r.canonical_id).toBe('http_request');
expect(r.total_runs).toBe(1);
});
it('指定 version 時記到該版本', async () => {
submissions.store.set('comp:cmp_abc12345:v2', JSON.stringify({
component_hash_id: 'cmp_abc12345',
canonical_id: 'http_request',
version: 'v2',
success_rate: 1, avg_duration_ms: 0, call_count: 0,
visibility: 'public', status: 'active',
}));
const r = await recordAnalytics({ canonical_id: 'http_request', version: 'v2', success: false, duration_ms: 30 }, env);
expect(r.ok).toBe(true);
expect(r.version).toBe('v2');
expect(analytics.store.has('stats:cmp_abc12345:v2')).toBe(true);
expect(analytics.store.has('stats:cmp_abc12345:v1')).toBe(false);
});
it('不在索引的零件回 ok:false(誠實 404,不假綠)', async () => {
const r = await recordAnalytics({ canonical_id: 'no_such_component', success: true, duration_ms: 1 }, env);
expect(r.ok).toBe(false);
expect(r.error).toContain('不在索引');
});
it('tombstone 版本不被記錄', async () => {
submissions.store.set('comp:cmp_abc12345:v1', JSON.stringify({
component_hash_id: 'cmp_abc12345', canonical_id: 'http_request', version: 'v1', status: 'tombstone',
}));
const r = await recordAnalytics({ canonical_id: 'http_request', success: true, duration_ms: 1 }, env);
expect(r.ok).toBe(false);
});
});