步驟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
+2 -1
View File
@@ -137,7 +137,8 @@ export async function searchComponents(
// ── 內部工具函數 ──────────────────────────────────────────────────────────────
function computeScore(v: Record<string, unknown>): number {
// exportrecordAnalytics 選「最優版本」要與 getComponent 同判準(單一評分真相)
export function computeScore(v: Record<string, unknown>): number {
const successRate = parseFloat(String(v.success_rate ?? '1'));
const avgDuration = parseFloat(String(v.avg_duration_ms ?? '10'));
const callCount = parseInt(String(v.call_count ?? '0'), 10);
+128
View File
@@ -0,0 +1,128 @@
// recordAnalytics — 零件執行結果回寫(POST /analytics/record 的實作)
// SDD: system-dev/docs/3-specs/arcrun-core-mvp/design.md「執行統計設計」
//
// 真相源=ANALYTICS_KV 計數器(key = stats:{hash_id}:{version},見 src/types.ts 註記):
// { total_runs, success_runs, total_ms }
// 讀取端(queryComponents / GET /components/*)讀的是 comp: 記錄上的
// success_rate / avg_duration_ms / call_count 欄位——所以每次記錄後把衍生值
// 回填 comp: 記錄(唯一寫入者是本函式,衍生值不是第二個真相源)。
//
// 尺度註記:design.md:499 寫 success_rate = success_runs / total_runs * 100(百分比顯示)。
// 本 repo comp: 記錄的 success_rate 既有尺度是 0..1(預設 1computeScore 直接相乘),
// 為不破壞既有讀取端與未測零件的預設值,落庫維持 0..1;*100 只是等價的顯示換算。
//
// 誠實限制:CF KV 無 CAS/原子遞增,read-modify-write 併發下偶有丟計數;
// 統計用途可接受(design 的「樂觀鎖」在 KV 上做不到,不假裝做到了)。
import type { Bindings } from '../types';
import { computeScore } from './queryComponents';
export interface AnalyticsRecordInput {
canonical_id: string; // 也接受 cmp_xxxxxxxx hash_id
version?: string; // 不給 → 記到查詢會回的最優版本(getComponent 同判準)
success: boolean;
duration_ms: number;
}
export interface AnalyticsRecordResult {
ok: boolean;
error?: string;
canonical_id?: string;
version?: string;
total_runs?: number;
success_runs?: number;
success_rate?: number; // 0..1,與 comp: 記錄同尺度
avg_duration_ms?: number;
}
interface StatsCounters {
total_runs: number;
success_runs: number;
total_ms: number;
}
export async function recordAnalytics(
input: AnalyticsRecordInput,
env: Bindings,
): Promise<AnalyticsRecordResult> {
// 1. 解析 hash_id(與 queryComponents.resolveHashId 同規則)
const hashId = input.canonical_id.startsWith('cmp_')
? input.canonical_id
: await env.SUBMISSIONS_KV.get(`idx:${input.canonical_id}`);
if (!hashId) {
return { ok: false, error: `零件 ${input.canonical_id} 不在索引` };
}
// 2. 找目標版本記錄:指定 version 就用它;沒指定 → 最優版本(與 getComponent 同判準:score 最高)
const list = await env.SUBMISSIONS_KV.list({ prefix: `comp:${hashId}:` });
let targetKey: string | null = null;
let targetRecord: Record<string, unknown> | null = null;
let bestScore = -Infinity;
for (const key of list.keys) {
const raw = await env.SUBMISSIONS_KV.get(key.name);
if (!raw) continue;
let v: Record<string, unknown>;
try { v = JSON.parse(raw); } catch { continue; }
if (v.status === 'tombstone') continue;
if (input.version) {
if (String(v.version) === input.version) {
targetKey = key.name;
targetRecord = v;
break;
}
continue;
}
const score = computeScore(v);
if (score > bestScore) {
bestScore = score;
targetKey = key.name;
targetRecord = v;
}
}
if (!targetKey || !targetRecord) {
return { ok: false, error: `零件 ${input.canonical_id} 無可用版本記錄` };
}
const version = String(targetRecord.version ?? 'v1');
const canonicalId = String(targetRecord.canonical_id ?? input.canonical_id);
// 3. 更新計數器(真相源,ANALYTICS_KV stats:{hash_id}:{version}
const statsKey = `stats:${hashId}:${version}`;
let counters: StatsCounters = { total_runs: 0, success_runs: 0, total_ms: 0 };
const rawStats = await env.ANALYTICS_KV.get(statsKey);
if (rawStats) {
try {
const parsed = JSON.parse(rawStats) as Partial<StatsCounters>;
counters = {
total_runs: Number(parsed.total_runs) || 0,
success_runs: Number(parsed.success_runs) || 0,
total_ms: Number(parsed.total_ms) || 0,
};
} catch { /* 損毀計數器 → 重新起算 */ }
}
counters.total_runs += 1;
counters.success_runs += input.success ? 1 : 0;
counters.total_ms += Math.max(0, Number(input.duration_ms) || 0);
await env.ANALYTICS_KV.put(statsKey, JSON.stringify(counters));
// 4. 衍生值回填 comp: 記錄(讀取端讀的地方)
const successRate = counters.success_runs / counters.total_runs;
const avgDurationMs = Math.round(counters.total_ms / counters.total_runs);
targetRecord.success_rate = successRate;
targetRecord.avg_duration_ms = avgDurationMs;
targetRecord.call_count = counters.total_runs;
await env.SUBMISSIONS_KV.put(targetKey, JSON.stringify(targetRecord));
return {
ok: true,
canonical_id: canonicalId,
version,
total_runs: counters.total_runs,
success_runs: counters.success_runs,
success_rate: successRate,
avg_duration_ms: avgDurationMs,
};
}
+4
View File
@@ -9,6 +9,7 @@ import validateContractRoute from './routes/validateContract';
import componentsRoute from './routes/components';
import queryRoute from './routes/query';
import initRoute from './routes/init';
import analyticsRoute from './routes/analytics';
const app = new Hono<{ Bindings: Bindings }>();
app.use('*', cors());
@@ -25,4 +26,7 @@ app.route('/components', componentsRoute); // POST /components
// === 初始化端點(建立 tpl-component template===
app.route('/init', initRoute);
// === 執行統計回寫(cypher-executor 執行收尾 fire-and-forget 打進來)===
app.route('/analytics', analyticsRoute); // POST /analytics/record
export default app;
+37
View File
@@ -0,0 +1,37 @@
// POST /analytics/record — 零件執行結果回寫端點
// SDD: system-dev/docs/3-specs/arcrun-core-mvp/design.md「執行統計設計」
// 呼叫方:cypher-executor 執行收尾(fire-and-forget,統計失敗不影響執行)
import { Hono } from 'hono';
import type { Bindings } from '../types';
import { recordAnalytics } from '../actions/recordAnalytics';
const app = new Hono<{ Bindings: Bindings }>();
app.post('/record', async c => {
const body = await c.req.json().catch(() => null) as {
canonical_id?: unknown;
version?: unknown;
success?: unknown;
duration_ms?: unknown;
} | null;
if (!body || typeof body.canonical_id !== 'string' || body.canonical_id.trim() === '') {
return c.json({ ok: false, error: 'canonical_id 必填' }, 400);
}
if (typeof body.success !== 'boolean') {
return c.json({ ok: false, error: 'success 必須為 boolean' }, 400);
}
const result = await recordAnalytics({
canonical_id: body.canonical_id.trim(),
version: typeof body.version === 'string' && body.version !== '' ? body.version : undefined,
success: body.success,
duration_ms: typeof body.duration_ms === 'number' ? body.duration_ms : 0,
}, c.env);
if (!result.ok) return c.json(result, 404);
return c.json(result);
});
export default app;
+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);
});
});