ea1c0571c1
- 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>
100 lines
3.9 KiB
TypeScript
100 lines
3.9 KiB
TypeScript
// 單元測試: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 mock(get/put/list,In-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);
|
||
});
|
||
});
|