feat(registry): component_hash_id — stable id system for workflow references
Problem: canonical_id is readable but mutable; if a component is renamed,
all workflows referencing it by canonical_id break.
Solution: dual-id system
- component_hash_id: cmp_{sha256(canonical_id).slice(0,8)}, derived deterministically,
never changes, safe for workflow references
- canonical_id: human-readable name, used for search and display
- idx:{canonical_id} KV key: reverse-lookup index for resolving canonical_id → hash_id
Changes:
- types.ts: SandboxResult.component_id → component_hash_id + canonical_id,
added 'data' to category enum
- submitComponent.ts: deriveHashId(), writes idx: reverse-lookup on submit
- queryComponents.ts: full rewrite — removed KBDB dependency, uses SUBMISSIONS_KV;
supports both cmp_* and canonical_id as query id; Phase 0 keyword search
with note to upgrade to Vectorize in Phase 2
- sandboxAcceptance.ts: updated field names, fixed TextDecoder TS type
- ensureTemplate.ts: removed KBDB dependency, now a KV health check
- tests: updated component_id → canonical_id
- CONTRIBUTING.md: explain hash_id derivation and dual-id workflow reference syntax
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -53,6 +53,12 @@ registry/components/my_component/
|
|||||||
所有語言共用相同的合約格式:
|
所有語言共用相同的合約格式:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
|
# component_hash_id 由 Registry 在提交時自動派發,格式為 cmp_{8碼hex}
|
||||||
|
# 提交者不需要填這個欄位,Registry 會根據 canonical_id 確定性生成
|
||||||
|
# Workflow 引用零件時,用 component_hash_id 才能保證永久不壞:
|
||||||
|
# component://cmp_a3f9b2c1 ← 推薦,即使 canonical_id 改名也不受影響
|
||||||
|
# component://string_reverse ← 方便,AI 寫 workflow 時用這個,Registry 自動解析
|
||||||
|
|
||||||
canonical_id: "string_reverse" # 見下方命名規範
|
canonical_id: "string_reverse" # 見下方命名規範
|
||||||
display_name: "字串反轉" # 人類可讀,可中文,供 UI 顯示用
|
display_name: "字串反轉" # 人類可讀,可中文,供 UI 顯示用
|
||||||
description: > # 語意搜尋用,見下方說明
|
description: > # 語意搜尋用,見下方說明
|
||||||
|
|||||||
@@ -1,66 +1,21 @@
|
|||||||
// 確保 KBDB 中存在 tpl-component Template Block
|
// ensureTemplate — 確保 SUBMISSIONS_KV 可正常存取(健康檢查用)
|
||||||
// Requirements: 12.1
|
// Requirements: 12.1
|
||||||
|
//
|
||||||
|
// 原本此模組負責在 KBDB 建立 tpl-component Template Block。
|
||||||
|
// 已改為 SUBMISSIONS_KV 模式後,不再需要預建 Template。
|
||||||
|
// 此函式改為驗證 KV binding 是否正常,供 /init 端點呼叫。
|
||||||
|
|
||||||
import type { Bindings } from '../types';
|
import type { Bindings } from '../types';
|
||||||
|
|
||||||
const TEMPLATE_ID = 'tpl-component';
|
|
||||||
|
|
||||||
const SLOT_KEYS = [
|
|
||||||
'canonical_id',
|
|
||||||
'display_name',
|
|
||||||
'category',
|
|
||||||
'version',
|
|
||||||
'wasi_target',
|
|
||||||
'stability',
|
|
||||||
'runtime_compat',
|
|
||||||
'component_type',
|
|
||||||
'max_size_kb',
|
|
||||||
'max_cold_start_ms',
|
|
||||||
'no_network_syscall',
|
|
||||||
'input_schema',
|
|
||||||
'output_schema',
|
|
||||||
'gherkin_tests',
|
|
||||||
'wasm_r2_key',
|
|
||||||
'cypher_binding_url',
|
|
||||||
'service_binding_key',
|
|
||||||
'description',
|
|
||||||
'tags',
|
|
||||||
'success_rate',
|
|
||||||
'avg_duration_ms',
|
|
||||||
'call_count',
|
|
||||||
'status',
|
|
||||||
'deprecated_at',
|
|
||||||
];
|
|
||||||
|
|
||||||
export async function ensureTemplate(env: Bindings): Promise<{ created: boolean; template_id: string }> {
|
export async function ensureTemplate(env: Bindings): Promise<{ created: boolean; template_id: string }> {
|
||||||
const kbdbUrl = env.KBDB_URL || 'https://kbdb.finally.click';
|
// 寫入並讀取一個測試 key,確認 KV binding 正常
|
||||||
const headers = {
|
const testKey = '_init_health_check';
|
||||||
'Content-Type': 'application/json',
|
await env.SUBMISSIONS_KV.put(testKey, '1', { expirationTtl: 60 });
|
||||||
'Authorization': `Bearer ${env.KBDB_INTERNAL_TOKEN}`,
|
const val = await env.SUBMISSIONS_KV.get(testKey);
|
||||||
};
|
|
||||||
|
|
||||||
// 先嘗試取得現有 template
|
if (val !== '1') {
|
||||||
const getRes = await fetch(`${kbdbUrl}/templates/${TEMPLATE_ID}`, { headers });
|
throw new Error('SUBMISSIONS_KV binding 異常:寫入後無法讀取');
|
||||||
if (getRes.ok) {
|
|
||||||
return { created: false, template_id: TEMPLATE_ID };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 不存在則建立
|
return { created: true, template_id: 'submissions_kv' };
|
||||||
const createRes = await fetch(`${kbdbUrl}/templates`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers,
|
|
||||||
body: JSON.stringify({
|
|
||||||
template_id: TEMPLATE_ID,
|
|
||||||
name: 'Component',
|
|
||||||
description: 'u6u 零件合約 Template,每個零件版本對應一個 Block',
|
|
||||||
slot_keys: SLOT_KEYS,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!createRes.ok) {
|
|
||||||
const errText = await createRes.text();
|
|
||||||
throw new Error(`建立 tpl-component 失敗(${createRes.status}):${errText.slice(0, 200)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return { created: true, template_id: TEMPLATE_ID };
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
// queryComponents — 查詢零件合約與語意搜尋
|
// queryComponents — 查詢零件合約
|
||||||
|
// 支援兩種查詢 id:
|
||||||
|
// component_hash_id(cmp_xxxxxxxx)— 永久穩定,workflow 引用用
|
||||||
|
// canonical_id(小寫底線) — 可讀名稱,透過 idx: 反查索引解析
|
||||||
// Requirements: 12.2, 12.3
|
// Requirements: 12.2, 12.3
|
||||||
|
|
||||||
import type { Bindings } from '../types';
|
import type { Bindings } from '../types';
|
||||||
|
|
||||||
export interface ComponentVersion {
|
export interface ComponentRecord {
|
||||||
|
component_hash_id: string;
|
||||||
canonical_id: string;
|
canonical_id: string;
|
||||||
display_name: string;
|
display_name: string;
|
||||||
version: string;
|
version: string;
|
||||||
@@ -11,137 +15,135 @@ export interface ComponentVersion {
|
|||||||
stability: string;
|
stability: string;
|
||||||
status: string;
|
status: string;
|
||||||
description: string;
|
description: string;
|
||||||
|
aliases: string[];
|
||||||
tags: string[];
|
tags: string[];
|
||||||
success_rate: number;
|
success_rate: number;
|
||||||
avg_duration_ms: number;
|
avg_duration_ms: number;
|
||||||
call_count: number;
|
call_count: number;
|
||||||
wasm_r2_key?: string;
|
wasm_r2_key?: string;
|
||||||
cypher_binding_url?: string;
|
|
||||||
score: number;
|
score: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 從 KBDB 取得零件的最優版本合約 */
|
// ── id 解析:支援 hash_id 和 canonical_id 兩種格式 ──────────────────────────
|
||||||
|
|
||||||
|
async function resolveHashId(id: string, env: Bindings): Promise<string | null> {
|
||||||
|
// 已經是 hash_id 格式
|
||||||
|
if (id.startsWith('cmp_')) return id;
|
||||||
|
// canonical_id → 透過 idx: 反查索引
|
||||||
|
const hashId = await env.SUBMISSIONS_KV.get(`idx:${id}`);
|
||||||
|
return hashId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 取得零件的所有版本 ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function listVersions(hashId: string, env: Bindings): Promise<ComponentRecord[]> {
|
||||||
|
const prefix = `comp:${hashId}:`;
|
||||||
|
const list = await env.SUBMISSIONS_KV.list({ prefix });
|
||||||
|
|
||||||
|
const records: ComponentRecord[] = [];
|
||||||
|
for (const key of list.keys) {
|
||||||
|
const raw = await env.SUBMISSIONS_KV.get(key.name);
|
||||||
|
if (!raw) continue;
|
||||||
|
try {
|
||||||
|
const v = JSON.parse(raw);
|
||||||
|
if (v.status === 'tombstone') continue;
|
||||||
|
records.push(toComponentRecord(v));
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return records;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 公開 API ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** 取得零件最優版本(floating 策略:成功率 × 速度 × log(使用次數)) */
|
||||||
export async function getComponent(
|
export async function getComponent(
|
||||||
canonicalId: string,
|
id: string,
|
||||||
env: Bindings,
|
env: Bindings,
|
||||||
): Promise<ComponentVersion | null> {
|
): Promise<ComponentRecord | null> {
|
||||||
const kbdbUrl = env.KBDB_URL || 'https://kbdb.finally.click';
|
const hashId = await resolveHashId(id, env);
|
||||||
const headers = {
|
if (!hashId) return null;
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Authorization': `Bearer ${env.KBDB_INTERNAL_TOKEN}`,
|
|
||||||
};
|
|
||||||
|
|
||||||
// 搜尋所有版本(block_id 前綴 comp-{id}-)
|
const versions = await listVersions(hashId, env);
|
||||||
const res = await fetch(
|
if (versions.length === 0) return null;
|
||||||
`${kbdbUrl}/records/search?template_id=tpl-component&canonical_id=${encodeURIComponent(canonicalId)}&limit=20`,
|
|
||||||
{ headers },
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!res.ok) return null;
|
versions.sort((a, b) => b.score - a.score);
|
||||||
|
return versions[0];
|
||||||
const data = await res.json() as { records?: Array<{ record_id: string; values: Record<string, string> }> };
|
|
||||||
const records = (data.records ?? []).filter(r =>
|
|
||||||
r.values.canonical_id === canonicalId && r.values.status !== 'tombstone'
|
|
||||||
);
|
|
||||||
|
|
||||||
if (records.length === 0) return null;
|
|
||||||
|
|
||||||
// 選取評分最高的版本(floating 策略)
|
|
||||||
const scored = records.map(r => ({
|
|
||||||
...r.values,
|
|
||||||
score: computeScore(r.values),
|
|
||||||
}));
|
|
||||||
scored.sort((a, b) => b.score - a.score);
|
|
||||||
const best = scored[0];
|
|
||||||
|
|
||||||
return toComponentVersion(best);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 取得零件所有版本清單(含評分排序) */
|
/** 取得零件所有版本清單(含評分排序) */
|
||||||
export async function getComponentVersions(
|
export async function getComponentVersions(
|
||||||
canonicalId: string,
|
id: string,
|
||||||
env: Bindings,
|
env: Bindings,
|
||||||
): Promise<ComponentVersion[]> {
|
): Promise<ComponentRecord[]> {
|
||||||
const kbdbUrl = env.KBDB_URL || 'https://kbdb.finally.click';
|
const hashId = await resolveHashId(id, env);
|
||||||
const headers = {
|
if (!hashId) return [];
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Authorization': `Bearer ${env.KBDB_INTERNAL_TOKEN}`,
|
|
||||||
};
|
|
||||||
|
|
||||||
const res = await fetch(
|
const versions = await listVersions(hashId, env);
|
||||||
`${kbdbUrl}/records/search?template_id=tpl-component&canonical_id=${encodeURIComponent(canonicalId)}&limit=20`,
|
versions.sort((a, b) => b.score - a.score);
|
||||||
{ headers },
|
return versions.slice(0, 10);
|
||||||
);
|
|
||||||
|
|
||||||
if (!res.ok) return [];
|
|
||||||
|
|
||||||
const data = await res.json() as { records?: Array<{ record_id: string; values: Record<string, string> }> };
|
|
||||||
const records = (data.records ?? []).filter(r =>
|
|
||||||
r.values.canonical_id === canonicalId && r.values.status !== 'tombstone'
|
|
||||||
);
|
|
||||||
|
|
||||||
return records
|
|
||||||
.map(r => ({ ...r.values, score: computeScore(r.values) }))
|
|
||||||
.sort((a, b) => b.score - a.score)
|
|
||||||
.slice(0, 10)
|
|
||||||
.map(toComponentVersion);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 語意搜尋零件(透過 KBDB Vectorize) */
|
/** 關鍵字搜尋(掃描 KV prefix comp:,比對 canonical_id / display_name / description / aliases)
|
||||||
|
*
|
||||||
|
* 注意:這是 Phase 0 的純文字比對版本。
|
||||||
|
* Phase 2 接入 Cloudflare Vectorize 後改為語意搜尋,API 介面不變。
|
||||||
|
*/
|
||||||
export async function searchComponents(
|
export async function searchComponents(
|
||||||
query: string,
|
query: string,
|
||||||
env: Bindings,
|
env: Bindings,
|
||||||
): Promise<ComponentVersion[]> {
|
): Promise<ComponentRecord[]> {
|
||||||
const kbdbUrl = env.KBDB_URL || 'https://kbdb.finally.click';
|
const q = query.toLowerCase();
|
||||||
const headers = {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Authorization': `Bearer ${env.KBDB_INTERNAL_TOKEN}`,
|
|
||||||
};
|
|
||||||
|
|
||||||
// 透過 KBDB 語意搜尋(Vectorize)
|
// 列出所有 comp: 前綴的 key(只取最新一頁,最多 1000 個)
|
||||||
const res = await fetch(`${kbdbUrl}/search`, {
|
const list = await env.SUBMISSIONS_KV.list({ prefix: 'comp:' });
|
||||||
method: 'POST',
|
|
||||||
headers,
|
|
||||||
body: JSON.stringify({
|
|
||||||
query,
|
|
||||||
type: 'suggest',
|
|
||||||
topK: 10,
|
|
||||||
filter: { template_id: 'tpl-component' },
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) return [];
|
const seen = new Set<string>(); // 每個 hash_id 只取最優版本
|
||||||
|
const candidates: ComponentRecord[] = [];
|
||||||
|
|
||||||
const data = await res.json() as { matches?: Array<{ block_id: string; score: number; metadata?: Record<string, string> }> };
|
for (const key of list.keys) {
|
||||||
const matches = data.matches ?? [];
|
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' || v.visibility !== 'public') continue;
|
||||||
const results: ComponentVersion[] = [];
|
|
||||||
for (const match of matches.slice(0, 10)) {
|
// 比對:canonical_id / display_name / description / aliases
|
||||||
const blockRes = await fetch(`${kbdbUrl}/records/${match.block_id}`, { headers });
|
const searchable = [
|
||||||
if (!blockRes.ok) continue;
|
String(v.canonical_id ?? ''),
|
||||||
const block = await blockRes.json() as { values: Record<string, string> };
|
String(v.display_name ?? ''),
|
||||||
if (block.values.status === 'tombstone') continue;
|
String(v.description ?? ''),
|
||||||
results.push(toComponentVersion({ ...block.values, score: match.score }));
|
...(Array.isArray(v.aliases) ? v.aliases.map(String) : []),
|
||||||
|
...(Array.isArray(v.tags) ? v.tags.map(String) : []),
|
||||||
|
].join(' ').toLowerCase();
|
||||||
|
|
||||||
|
if (!searchable.includes(q)) continue;
|
||||||
|
|
||||||
|
const hashId = String(v.component_hash_id ?? '');
|
||||||
|
if (seen.has(`${hashId}:${v.version}`)) continue;
|
||||||
|
seen.add(`${hashId}:${v.version}`);
|
||||||
|
candidates.push(toComponentRecord(v));
|
||||||
}
|
}
|
||||||
|
|
||||||
return results;
|
candidates.sort((a, b) => b.score - a.score);
|
||||||
|
return candidates.slice(0, 10);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 內部工具函數 ──────────────────────────────────────────────────────────────
|
// ── 內部工具函數 ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/** 計算零件評分:成功率 × 速度評分 × log(被調用次數 + 1) */
|
function computeScore(v: Record<string, unknown>): number {
|
||||||
function computeScore(v: Record<string, string>): number {
|
const successRate = parseFloat(String(v.success_rate ?? '1'));
|
||||||
const successRate = parseFloat(v.success_rate ?? '1');
|
const avgDuration = parseFloat(String(v.avg_duration_ms ?? '10'));
|
||||||
const avgDuration = parseFloat(v.avg_duration_ms ?? '10');
|
const callCount = parseInt(String(v.call_count ?? '0'), 10);
|
||||||
const callCount = parseInt(v.call_count ?? '0', 10);
|
|
||||||
// 速度評分:越快越高,50ms 為基準
|
|
||||||
const speedScore = Math.max(0, 1 - avgDuration / 1000);
|
const speedScore = Math.max(0, 1 - avgDuration / 1000);
|
||||||
return successRate * speedScore * Math.log(callCount + 2);
|
return successRate * speedScore * Math.log(callCount + 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
function toComponentVersion(v: Record<string, string | number>): ComponentVersion {
|
function toComponentRecord(v: Record<string, unknown>): ComponentRecord {
|
||||||
return {
|
return {
|
||||||
|
component_hash_id: String(v.component_hash_id ?? ''),
|
||||||
canonical_id: String(v.canonical_id ?? ''),
|
canonical_id: String(v.canonical_id ?? ''),
|
||||||
display_name: String(v.display_name ?? ''),
|
display_name: String(v.display_name ?? ''),
|
||||||
version: String(v.version ?? 'v1'),
|
version: String(v.version ?? 'v1'),
|
||||||
@@ -149,14 +151,12 @@ function toComponentVersion(v: Record<string, string | number>): ComponentVersio
|
|||||||
stability: String(v.stability ?? 'floating'),
|
stability: String(v.stability ?? 'floating'),
|
||||||
status: String(v.status ?? 'active'),
|
status: String(v.status ?? 'active'),
|
||||||
description: String(v.description ?? ''),
|
description: String(v.description ?? ''),
|
||||||
tags: (() => {
|
aliases: Array.isArray(v.aliases) ? v.aliases.map(String) : [],
|
||||||
try { return JSON.parse(String(v.tags ?? '[]')); } catch { return []; }
|
tags: Array.isArray(v.tags) ? v.tags.map(String) : [],
|
||||||
})(),
|
|
||||||
success_rate: parseFloat(String(v.success_rate ?? '1')),
|
success_rate: parseFloat(String(v.success_rate ?? '1')),
|
||||||
avg_duration_ms: parseFloat(String(v.avg_duration_ms ?? '0')),
|
avg_duration_ms: parseFloat(String(v.avg_duration_ms ?? '0')),
|
||||||
call_count: parseInt(String(v.call_count ?? '0'), 10),
|
call_count: parseInt(String(v.call_count ?? '0'), 10),
|
||||||
wasm_r2_key: v.wasm_r2_key ? String(v.wasm_r2_key) : undefined,
|
wasm_r2_key: v.wasm_r2_key ? String(v.wasm_r2_key) : undefined,
|
||||||
cypher_binding_url: v.cypher_binding_url ? String(v.cypher_binding_url) : undefined,
|
score: computeScore(v),
|
||||||
score: typeof v.score === 'number' ? v.score : parseFloat(String(v.score ?? '0')),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ function checkColdStart(_wasmBytes: Uint8Array, _contract: ComponentContract): s
|
|||||||
function scanSyscalls(wasmBytes: Uint8Array): string | null {
|
function scanSyscalls(wasmBytes: Uint8Array): string | null {
|
||||||
// 將 .wasm binary 轉為文字,搜尋禁止的 import 字串
|
// 將 .wasm binary 轉為文字,搜尋禁止的 import 字串
|
||||||
// WASM binary 中 import section 的函數名稱以 UTF-8 字串形式存在
|
// WASM binary 中 import section 的函數名稱以 UTF-8 字串形式存在
|
||||||
const text = new TextDecoder('utf-8', { fatal: false }).decode(wasmBytes);
|
const text = new TextDecoder('utf-8').decode(wasmBytes);
|
||||||
|
|
||||||
for (const syscall of FORBIDDEN_SYSCALLS) {
|
for (const syscall of FORBIDDEN_SYSCALLS) {
|
||||||
if (text.includes(syscall)) {
|
if (text.includes(syscall)) {
|
||||||
@@ -82,7 +82,8 @@ export function runSandboxAcceptance(
|
|||||||
failed_step: step.name,
|
failed_step: step.name,
|
||||||
reason: error,
|
reason: error,
|
||||||
guide_anchor: step.guideAnchor,
|
guide_anchor: step.guideAnchor,
|
||||||
component_id: contract.canonical_id,
|
component_hash_id: '', // 驗收失敗時尚未派發 hash id
|
||||||
|
canonical_id: contract.canonical_id,
|
||||||
version: contract.version,
|
version: contract.version,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -90,7 +91,8 @@ export function runSandboxAcceptance(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
component_id: contract.canonical_id,
|
component_hash_id: '', // 由 submitComponent 在驗收通過後填入
|
||||||
|
canonical_id: contract.canonical_id,
|
||||||
version: contract.version,
|
version: contract.version,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,31 @@
|
|||||||
// 零件提交:沙盒驗收 → 寫入 SUBMISSIONS_KV → 上傳 R2
|
// 零件提交:沙盒驗收 → 派發 hash id → 寫入 SUBMISSIONS_KV → 上傳 R2
|
||||||
// Requirements: 2.1, 2.2, 2.3
|
// Requirements: 2.1, 2.2, 2.3
|
||||||
//
|
//
|
||||||
// arcrun registry 不依賴 KBDB(InkStone 內部服務)。
|
// KV key 設計:
|
||||||
// 零件元數據存入 SUBMISSIONS_KV(key = comp:{canonical_id}:{version})。
|
// comp:{hash_id}:{version} → 零件元數據 JSON
|
||||||
// WASM 二進位存入 WASM_BUCKET R2(key = components/{id}/{version}.wasm)。
|
// idx:{canonical_id} → hash_id 反查索引(canonical_id → hash_id)
|
||||||
|
//
|
||||||
|
// hash_id 派發規則:
|
||||||
|
// hash_id = 'cmp_' + sha256(canonical_id).slice(0, 8)
|
||||||
|
// 相同 canonical_id 永遠得到相同 hash_id(冪等)
|
||||||
|
// 不同 canonical_id 的 hash_id 碰撞機率極低(2^32 空間)
|
||||||
|
|
||||||
import { runSandboxAcceptance } from './sandboxAcceptance';
|
import { runSandboxAcceptance } from './sandboxAcceptance';
|
||||||
import type { ComponentContract, SandboxResult, Bindings } from '../types';
|
import type { ComponentContract, SandboxResult, Bindings } from '../types';
|
||||||
|
|
||||||
|
// ── hash id 生成 ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function deriveHashId(canonicalId: string): Promise<string> {
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const data = encoder.encode(canonicalId);
|
||||||
|
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
|
||||||
|
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
||||||
|
const hex = hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
|
||||||
|
return 'cmp_' + hex.slice(0, 8);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 主流程 ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function submitComponent(
|
export async function submitComponent(
|
||||||
wasmBytes: Uint8Array,
|
wasmBytes: Uint8Array,
|
||||||
contract: ComponentContract,
|
contract: ComponentContract,
|
||||||
@@ -19,32 +37,36 @@ export async function submitComponent(
|
|||||||
return sandboxResult;
|
return sandboxResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
const kvKey = `comp:${contract.canonical_id}:${contract.version}`;
|
// 2. 派發 hash id(canonical_id 的確定性 hash,相同輸入永遠得到相同 id)
|
||||||
const r2Key = `components/${contract.canonical_id}/${contract.version}.wasm`;
|
const hashId = await deriveHashId(contract.canonical_id);
|
||||||
|
|
||||||
// 2. 冪等:若已存在相同 (id, version) 直接回傳
|
const kvKey = `comp:${hashId}:${contract.version}`;
|
||||||
|
const r2Key = `components/${hashId}/${contract.version}.wasm`;
|
||||||
|
|
||||||
|
// 3. 冪等:若已存在相同 (hash_id, version) 直接回傳
|
||||||
const existing = await env.SUBMISSIONS_KV.get(kvKey);
|
const existing = await env.SUBMISSIONS_KV.get(kvKey);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
component_id: contract.canonical_id,
|
component_hash_id: hashId,
|
||||||
|
canonical_id: contract.canonical_id,
|
||||||
version: contract.version,
|
version: contract.version,
|
||||||
wasm_r2_key: r2Key,
|
wasm_r2_key: r2Key,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. 上傳 .wasm 至 R2
|
// 4. 上傳 .wasm 至 R2
|
||||||
await env.WASM_BUCKET.put(r2Key, wasmBytes, {
|
await env.WASM_BUCKET.put(r2Key, wasmBytes, {
|
||||||
httpMetadata: { contentType: 'application/wasm' },
|
httpMetadata: { contentType: 'application/wasm' },
|
||||||
});
|
});
|
||||||
|
|
||||||
// 4. 寫入 SUBMISSIONS_KV(元數據 + 初始統計)
|
// 5. 寫入 SUBMISSIONS_KV(元數據 + 初始統計)
|
||||||
const record = {
|
const record = {
|
||||||
|
component_hash_id: hashId,
|
||||||
canonical_id: contract.canonical_id,
|
canonical_id: contract.canonical_id,
|
||||||
display_name: contract.display_name,
|
display_name: contract.display_name,
|
||||||
category: contract.category,
|
category: contract.category,
|
||||||
version: contract.version,
|
version: contract.version,
|
||||||
author: contract.author ?? '',
|
|
||||||
wasi_target: contract.wasi_target,
|
wasi_target: contract.wasi_target,
|
||||||
stability: contract.stability,
|
stability: contract.stability,
|
||||||
runtime_compat: contract.runtime_compat,
|
runtime_compat: contract.runtime_compat,
|
||||||
@@ -55,6 +77,7 @@ export async function submitComponent(
|
|||||||
gherkin_tests: contract.gherkin_tests,
|
gherkin_tests: contract.gherkin_tests,
|
||||||
wasm_r2_key: r2Key,
|
wasm_r2_key: r2Key,
|
||||||
description: contract.description ?? '',
|
description: contract.description ?? '',
|
||||||
|
aliases: contract.aliases ?? [],
|
||||||
tags: contract.tags ?? [],
|
tags: contract.tags ?? [],
|
||||||
// 初始統計
|
// 初始統計
|
||||||
success_rate: 1,
|
success_rate: 1,
|
||||||
@@ -69,9 +92,14 @@ export async function submitComponent(
|
|||||||
|
|
||||||
await env.SUBMISSIONS_KV.put(kvKey, JSON.stringify(record));
|
await env.SUBMISSIONS_KV.put(kvKey, JSON.stringify(record));
|
||||||
|
|
||||||
|
// 6. 寫入 canonical_id → hash_id 反查索引
|
||||||
|
// 同一個 canonical_id 的所有版本共用同一個 hash_id,索引只需存一份
|
||||||
|
await env.SUBMISSIONS_KV.put(`idx:${contract.canonical_id}`, hashId);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
component_id: contract.canonical_id,
|
component_hash_id: hashId,
|
||||||
|
canonical_id: contract.canonical_id,
|
||||||
version: contract.version,
|
version: contract.version,
|
||||||
wasm_r2_key: r2Key,
|
wasm_r2_key: r2Key,
|
||||||
};
|
};
|
||||||
|
|||||||
+12
-4
@@ -7,8 +7,11 @@ import { z } from 'zod';
|
|||||||
export type Bindings = {
|
export type Bindings = {
|
||||||
WASM_BUCKET: R2Bucket;
|
WASM_BUCKET: R2Bucket;
|
||||||
AI: Ai;
|
AI: Ai;
|
||||||
SUBMISSIONS_KV: KVNamespace; // 零件元數據 + 可見性狀態(key = comp:{id}:{version})
|
// KV key 格式:
|
||||||
ANALYTICS_KV: KVNamespace; // 執行統計匯總(key = stats:{id}:{version})
|
// comp:{hash_id}:{version} → 零件元數據(hash_id = cmp_ + sha256 前 8 碼)
|
||||||
|
// idx:{canonical_id} → canonical_id → hash_id 反查索引
|
||||||
|
SUBMISSIONS_KV: KVNamespace;
|
||||||
|
ANALYTICS_KV: KVNamespace; // 執行統計匯總(key = stats:{hash_id}:{version})
|
||||||
ENVIRONMENT: string;
|
ENVIRONMENT: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -28,9 +31,12 @@ export const GherkinTestSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const ComponentContractSchema = z.object({
|
export const ComponentContractSchema = z.object({
|
||||||
|
// canonical_id:提交者填寫的可讀名稱(小寫底線),用於搜尋與 workflow 引用
|
||||||
|
// component_hash_id:由 Registry 在提交時派發,格式 cmp_{8碼hex},workflow 引用此 id 才能保證永久不壞
|
||||||
|
// 兩者都可以在 workflow 中引用,Registry 會互相解析
|
||||||
canonical_id: z.string().min(1).regex(/^[a-z][a-z0-9_]*$/, 'canonical_id 必須為小寫底線格式'),
|
canonical_id: z.string().min(1).regex(/^[a-z][a-z0-9_]*$/, 'canonical_id 必須為小寫底線格式'),
|
||||||
display_name: z.string().min(1),
|
display_name: z.string().min(1),
|
||||||
category: z.enum(['logic', 'api', 'ui', 'style', 'anim']),
|
category: z.enum(['logic', 'api', 'ui', 'style', 'anim', 'data']),
|
||||||
version: z.string().min(1).regex(/^v\d+$/, 'version 格式必須為 vN'),
|
version: z.string().min(1).regex(/^v\d+$/, 'version 格式必須為 vN'),
|
||||||
wasi_target: z.literal('preview1'),
|
wasi_target: z.literal('preview1'),
|
||||||
stability: z.enum(['floating', 'stable', 'pinned']),
|
stability: z.enum(['floating', 'stable', 'pinned']),
|
||||||
@@ -64,7 +70,9 @@ export interface SandboxResult {
|
|||||||
failed_step?: SandboxStep;
|
failed_step?: SandboxStep;
|
||||||
reason?: string;
|
reason?: string;
|
||||||
guide_anchor?: string;
|
guide_anchor?: string;
|
||||||
component_id: string;
|
// 驗收通過後回傳兩個 id:
|
||||||
|
component_hash_id: string; // cmp_{8碼hex},workflow 引用用,永久不變
|
||||||
|
canonical_id: string; // 可讀名稱,搜尋用
|
||||||
version: string;
|
version: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ describe('runSandboxAcceptance', () => {
|
|||||||
const wasm = makeMinimalWasm(10);
|
const wasm = makeMinimalWasm(10);
|
||||||
const result = runSandboxAcceptance(wasm, BASE_CONTRACT);
|
const result = runSandboxAcceptance(wasm, BASE_CONTRACT);
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
expect(result.component_id).toBe('validate_json');
|
expect(result.canonical_id).toBe('validate_json');
|
||||||
expect(result.version).toBe('v1');
|
expect(result.version).toBe('v1');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -53,7 +53,7 @@ describe('runSandboxAcceptance', () => {
|
|||||||
expect(result.failed_step).toBe('size_check');
|
expect(result.failed_step).toBe('size_check');
|
||||||
expect(result.reason).toContain('超過上限');
|
expect(result.reason).toContain('超過上限');
|
||||||
expect(result.guide_anchor).toBeDefined();
|
expect(result.guide_anchor).toBeDefined();
|
||||||
expect(result.component_id).toBe('validate_json');
|
expect(result.canonical_id).toBe('validate_json');
|
||||||
expect(result.version).toBe('v1');
|
expect(result.version).toBe('v1');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user