refactor: migrate to Dynamic Workers — fix /run/{name} 404
Root cause: CF blocks Worker-to-Worker fetch on workers.dev (error 1042). Gateway Worker could not proxy requests to child worker subdomains. Fix: Replace CF API worker scripts with Dynamic Workers (LOADER binding). - deploy() writes code to KV only, no CF API calls - invoke() uses LOADER.get(id, fn) to execute code inline - remove() clears KV only, no CF API delete - Removed cf-api.ts, slot management, subdomain routing - 67/67 tests passing, production verified Reported-by: 小墨 🖊️ (KUMA) 小橘 🍊(NEKO Team)
This commit is contained in:
+210
-110
@@ -1,13 +1,15 @@
|
||||
// Pre-allocated slot pool architecture — zero DNS latency.
|
||||
// Dynamic Workers backend: deploy stores code in KV, invoke uses LOADER.get().
|
||||
// No CF API calls, no independent worker scripts, no slot management.
|
||||
|
||||
import type { SigilBackend, DeployParams, DeployResult, Capability, BackendStatus, QueryParams, QueryResult, QueryItem } from './types.js'
|
||||
import type { CfApi } from '../cf-api.js'
|
||||
import { KvStore } from '../kv.js'
|
||||
import { LruScheduler } from '../lru.js'
|
||||
import { CONFIG } from '../config.js'
|
||||
import { EmbeddingService, cosineSimilarity, mmrSelect } from '../embedding.js'
|
||||
import { IDLE_WORKER_CODE } from '../cf-api.js'
|
||||
|
||||
export type { CfApi }
|
||||
export interface WorkerLoader {
|
||||
get(id: string, loader: () => any): { getEntrypoint(name?: string): { fetch(request: Request): Promise<Response> } }
|
||||
}
|
||||
|
||||
export class WorkerPool implements SigilBackend {
|
||||
private kv: KvStore
|
||||
@@ -15,111 +17,136 @@ export class WorkerPool implements SigilBackend {
|
||||
private embeddingService: EmbeddingService
|
||||
private config = CONFIG
|
||||
|
||||
constructor(kv: KVNamespace, private cfApi: CfApi, embeddingService: EmbeddingService) {
|
||||
constructor(
|
||||
kv: KVNamespace,
|
||||
private loader: WorkerLoader,
|
||||
embeddingService: EmbeddingService,
|
||||
) {
|
||||
this.kv = new KvStore(kv)
|
||||
this.lru = new LruScheduler(this.kv)
|
||||
this.embeddingService = embeddingService
|
||||
}
|
||||
|
||||
private async generateHash(input: string): Promise<string> {
|
||||
const data = new TextEncoder().encode(input)
|
||||
const encoder = new TextEncoder()
|
||||
const data = encoder.encode(input)
|
||||
const hashBuffer = await crypto.subtle.digest('SHA-256', data)
|
||||
return Array.from(new Uint8Array(hashBuffer)).map(b => b.toString(16).padStart(2,'0')).join('').slice(0, this.config.HASH_LENGTH)
|
||||
}
|
||||
|
||||
private async acquireSlot(): Promise<[number, string | undefined]> {
|
||||
let freeSlot = await this.kv.findFreeSlot()
|
||||
let evicted: string | undefined
|
||||
if (freeSlot === null) {
|
||||
const candidate = await this.lru.findEvictionCandidate()
|
||||
if (!candidate) throw new Error('No eviction candidate found')
|
||||
await this.evictCapability(candidate.capability)
|
||||
evicted = candidate.capability
|
||||
await this.kv.incrementEvictionCount()
|
||||
freeSlot = await this.kv.findFreeSlot()
|
||||
if (freeSlot === null) throw new Error('No free slot after eviction')
|
||||
}
|
||||
return [freeSlot, evicted]
|
||||
}
|
||||
|
||||
private async evictCapability(capability: string): Promise<void> {
|
||||
const route = await this.kv.getRoute(capability)
|
||||
if (route) {
|
||||
await this.cfApi.updateSlotCode(route.slot, IDLE_WORKER_CODE)
|
||||
await this.kv.setSlot(route.slot, { capability: null, status: 'free' })
|
||||
await this.kv.deleteRoute(capability)
|
||||
}
|
||||
const lru = await this.kv.getLru(capability)
|
||||
if (lru) await this.kv.setLru(capability, { ...lru, deployed: false })
|
||||
const hashArray = Array.from(new Uint8Array(hashBuffer))
|
||||
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('').slice(0, this.config.HASH_LENGTH)
|
||||
}
|
||||
|
||||
async deploy(params: DeployParams): Promise<DeployResult> {
|
||||
const { name, code, schema, type, ttl, bindings, description, tags, examples } = params
|
||||
if (!code) throw new Error('deploy: code is required')
|
||||
const capability = name === null ? 't-' + await this.generateHash(code + Date.now()) : name
|
||||
|
||||
if (!code) {
|
||||
throw new Error('deploy: code is required (should be pre-generated by router)')
|
||||
}
|
||||
|
||||
let capability: string
|
||||
if (name === null) {
|
||||
const hash = await this.generateHash(code + Date.now())
|
||||
capability = `t-${hash}`
|
||||
} else {
|
||||
capability = name
|
||||
}
|
||||
|
||||
const now = Date.now()
|
||||
const [slotIndex, evictedCapability] = await this.acquireSlot()
|
||||
await this.cfApi.updateSlotCode(slotIndex, code)
|
||||
await this.kv.setSlot(slotIndex, { capability, status: 'active' })
|
||||
await this.kv.setRoute(capability, { slot: slotIndex })
|
||||
|
||||
// LRU eviction: mark oldest as not-deployed when quota exceeded
|
||||
let deployed = await this.lru.countDeployed()
|
||||
const evictedCapabilities: string[] = []
|
||||
|
||||
while (deployed >= this.config.MAX_SLOTS) {
|
||||
const candidate = await this.lru.findEvictionCandidate()
|
||||
if (!candidate) break
|
||||
|
||||
evictedCapabilities.push(candidate.capability)
|
||||
const existingLru = await this.kv.getLru(candidate.capability)
|
||||
if (existingLru) {
|
||||
await this.kv.setLru(candidate.capability, { ...existingLru, deployed: false })
|
||||
}
|
||||
await this.kv.incrementEvictionCount()
|
||||
deployed = await this.lru.countDeployed()
|
||||
}
|
||||
|
||||
const evictedCapability = evictedCapabilities[0]
|
||||
|
||||
// Write KV entries - code loaded dynamically at invoke time via LOADER
|
||||
await this.kv.setCode(capability, code)
|
||||
await this.kv.setMeta(capability, { type, ttl, created_at: now, bindings, description, tags, examples, schema })
|
||||
await this.kv.setMeta(capability, {
|
||||
type, ttl, created_at: now, bindings, description, tags, examples, schema,
|
||||
})
|
||||
await this.kv.setLru(capability, { last_access: now, access_count: 0, deployed: true })
|
||||
await this.kv.setRoute(capability, { worker_name: capability, subdomain: '' })
|
||||
|
||||
// Compute and store embedding
|
||||
try {
|
||||
const text = EmbeddingService.buildCapabilityText({ name: capability, description, tags, examples })
|
||||
await this.kv.setEmbedding(capability, await this.embeddingService.embed(text))
|
||||
} catch (e) { console.error('[sigil] embedding error:', e) }
|
||||
const url = this.config.GATEWAY_URL + '/run/' + capability
|
||||
const vector = await this.embeddingService.embed(text)
|
||||
await this.kv.setEmbedding(capability, vector)
|
||||
} catch (e) {
|
||||
console.error('[sigil] embedding error during deploy:', e)
|
||||
}
|
||||
|
||||
const url = `${this.config.GATEWAY_URL}/run/${capability}`
|
||||
const result: DeployResult = { capability, url, cold_start: false }
|
||||
if (type === 'ephemeral' && ttl !== undefined) result.expires_at = new Date(now + ttl * 1000).toISOString()
|
||||
if (evictedCapability) result.evicted = evictedCapability
|
||||
|
||||
if (type === 'ephemeral' && ttl !== undefined) {
|
||||
result.expires_at = new Date(now + ttl * 1000).toISOString()
|
||||
}
|
||||
if (evictedCapability) {
|
||||
result.evicted = evictedCapability
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
async invoke(capabilityName: string, request: Request): Promise<Response> {
|
||||
const lru = await this.kv.getLru(capabilityName)
|
||||
if (!lru) {
|
||||
const code = await this.kv.getCode(capabilityName)
|
||||
if (!code) return new Response(JSON.stringify({ error: 'Capability not found' }), { status: 404, headers: { 'Content-Type': 'application/json' } })
|
||||
return this.pageInAndInvoke(capabilityName, code, request, true)
|
||||
}
|
||||
const route = await this.kv.getRoute(capabilityName)
|
||||
if (!route) {
|
||||
const code = await this.kv.getCode(capabilityName)
|
||||
if (!code) return new Response(JSON.stringify({ error: 'Capability code not found' }), { status: 404, headers: { 'Content-Type': 'application/json' } })
|
||||
return this.pageInAndInvoke(capabilityName, code, request, true)
|
||||
}
|
||||
const isColdStart = !lru.deployed
|
||||
await this.kv.setLru(capabilityName, { ...lru, last_access: Date.now(), access_count: lru.access_count + 1, deployed: true })
|
||||
const response = await this.cfApi.invoke(route.slot, request)
|
||||
if (isColdStart) {
|
||||
const h = new Headers(response.headers); h.set('X-Sigil-Cold-Start', 'true')
|
||||
return new Response(response.body, { status: response.status, headers: h })
|
||||
}
|
||||
return response
|
||||
if (!code) {
|
||||
return new Response(JSON.stringify({ error: 'Capability not found' }), {
|
||||
status: 404, headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
|
||||
private async pageInAndInvoke(capabilityName: string, code: string, request: Request, isColdStart: boolean): Promise<Response> {
|
||||
const [slotIndex] = await this.acquireSlot()
|
||||
await this.cfApi.updateSlotCode(slotIndex, code)
|
||||
await this.kv.setSlot(slotIndex, { capability: capabilityName, status: 'active' })
|
||||
await this.kv.setRoute(capabilityName, { slot: slotIndex })
|
||||
const existingLru = await this.kv.getLru(capabilityName)
|
||||
await this.kv.setLru(capabilityName, { last_access: Date.now(), access_count: (existingLru?.access_count ?? 0) + 1, deployed: true })
|
||||
const response = await this.cfApi.invoke(slotIndex, request)
|
||||
const lru = await this.kv.getLru(capabilityName)
|
||||
const isColdStart = !lru?.deployed
|
||||
|
||||
// Update LRU access stats
|
||||
await this.kv.setLru(capabilityName, {
|
||||
last_access: Date.now(),
|
||||
access_count: (lru?.access_count ?? 0) + 1,
|
||||
deployed: true,
|
||||
})
|
||||
|
||||
// Dynamic Workers: LOADER.get(id, fn) caches warm instances by id
|
||||
const codeHash = await this.generateHash(code)
|
||||
const workerId = `sigil:${capabilityName}:${codeHash}`
|
||||
|
||||
try {
|
||||
const worker = this.loader.get(workerId, () => ({
|
||||
compatibilityDate: '2026-04-03',
|
||||
mainModule: 'worker.js',
|
||||
modules: { 'worker.js': code },
|
||||
}))
|
||||
|
||||
const response = await worker.getEntrypoint().fetch(request)
|
||||
|
||||
if (isColdStart) {
|
||||
const h = new Headers(response.headers); h.set('X-Sigil-Cold-Start', 'true')
|
||||
return new Response(response.body, { status: response.status, headers: h })
|
||||
const headers = new Headers(response.headers)
|
||||
headers.set('X-Sigil-Cold-Start', 'true')
|
||||
return new Response(response.body, { status: response.status, headers })
|
||||
}
|
||||
return response
|
||||
} catch (e: any) {
|
||||
console.error(`[sigil] Dynamic Worker invoke error for ${capabilityName}:`, e)
|
||||
return new Response(JSON.stringify({ error: e.message || 'Invoke failed' }), {
|
||||
status: 500, headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async remove(capabilityName: string): Promise<void> {
|
||||
const route = await this.kv.getRoute(capabilityName)
|
||||
if (route) {
|
||||
await this.cfApi.updateSlotCode(route.slot, IDLE_WORKER_CODE)
|
||||
await this.kv.setSlot(route.slot, { capability: null, status: 'free' })
|
||||
}
|
||||
await this.kv.deleteCode(capabilityName)
|
||||
await this.kv.deleteMeta(capabilityName)
|
||||
await this.kv.deleteLru(capabilityName)
|
||||
@@ -130,60 +157,133 @@ export class WorkerPool implements SigilBackend {
|
||||
async query(params: QueryParams): Promise<QueryResult> {
|
||||
const { q, mode: rawMode, limit: rawLimit, cursor } = params
|
||||
const mode = rawMode ?? (q ? 'find' : 'explore')
|
||||
const limit = rawLimit ?? (mode === 'find' ? 3 : 20)
|
||||
const defaultLimit = mode === 'find' ? 3 : 20
|
||||
const limit = rawLimit ?? defaultLimit
|
||||
const caps = await this.kv.listCapabilities()
|
||||
|
||||
if (!q) {
|
||||
const allCaps: Capability[] = []
|
||||
const allCapabilities: Capability[] = []
|
||||
for (const cap of caps) {
|
||||
const meta = await this.kv.getMeta(cap); const lru = await this.kv.getLru(cap)
|
||||
const meta = await this.kv.getMeta(cap)
|
||||
const lru = await this.kv.getLru(cap)
|
||||
if (!meta || !lru) continue
|
||||
const c: Capability = { capability: cap, type: meta.type, deployed: lru.deployed, last_access: lru.last_access, access_count: lru.access_count, created_at: meta.created_at, description: meta.description, tags: meta.tags, examples: meta.examples }
|
||||
if (meta.ttl !== undefined) { c.ttl = meta.ttl; c.expires_at = new Date(meta.created_at + meta.ttl * 1000).toISOString() }
|
||||
allCaps.push(c)
|
||||
const capability: Capability = {
|
||||
capability: cap, type: meta.type, deployed: lru.deployed,
|
||||
last_access: lru.last_access, access_count: lru.access_count,
|
||||
created_at: meta.created_at, description: meta.description,
|
||||
tags: meta.tags, examples: meta.examples,
|
||||
}
|
||||
const sorted = allCaps.sort((a, b) => b.created_at - a.created_at)
|
||||
const items: QueryItem[] = sorted.map(c => ({ capability: c.capability, description: c.description, type: c.type, score: 1.0 }))
|
||||
if (meta.ttl !== undefined) {
|
||||
capability.ttl = meta.ttl
|
||||
capability.expires_at = new Date(meta.created_at + meta.ttl * 1000).toISOString()
|
||||
}
|
||||
allCapabilities.push(capability)
|
||||
}
|
||||
const sorted = allCapabilities.sort((a, b) => b.created_at - a.created_at)
|
||||
const items: QueryItem[] = sorted.map(cap => ({
|
||||
capability: cap.capability, description: cap.description, type: cap.type, score: 1.0,
|
||||
}))
|
||||
const offset = cursor ? parseInt(cursor, 10) : 0
|
||||
return { total: items.length, items: items.slice(offset, offset + limit) }
|
||||
}
|
||||
|
||||
const queryVec = await this.embeddingService.embedQuery(q)
|
||||
const embCands: Array<{ capability: string; vector: number[]; meta: any; lru: any }> = []
|
||||
const fbCands: Capability[] = []
|
||||
const embeddingCandidates: Array<{ capability: string; vector: number[]; meta: any; lru: any }> = []
|
||||
const fallbackCandidates: Capability[] = []
|
||||
|
||||
for (const cap of caps) {
|
||||
const vector = await this.kv.getEmbedding(cap); const meta = await this.kv.getMeta(cap); const lru = await this.kv.getLru(cap)
|
||||
const vector = await this.kv.getEmbedding(cap)
|
||||
const meta = await this.kv.getMeta(cap)
|
||||
const lru = await this.kv.getLru(cap)
|
||||
if (!meta || !lru) continue
|
||||
if (vector) embCands.push({ capability: cap, vector, meta, lru })
|
||||
else fbCands.push({ capability: cap, type: meta.type, deployed: lru.deployed, last_access: lru.last_access, access_count: lru.access_count, created_at: meta.created_at, description: meta.description, tags: meta.tags, examples: meta.examples, schema: meta.schema })
|
||||
if (vector) {
|
||||
embeddingCandidates.push({ capability: cap, vector, meta, lru })
|
||||
} else {
|
||||
fallbackCandidates.push({
|
||||
capability: cap, type: meta.type, deployed: lru.deployed,
|
||||
last_access: lru.last_access, access_count: lru.access_count,
|
||||
created_at: meta.created_at, description: meta.description,
|
||||
tags: meta.tags, examples: meta.examples, schema: meta.schema,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const qLower = q.toLowerCase()
|
||||
const fbItems: QueryItem[] = fbCands.filter(c => c.capability.toLowerCase().includes(qLower) || c.description?.toLowerCase().includes(qLower) || c.tags?.some(t => t.toLowerCase().includes(qLower))).map(c => ({ capability: c.capability, description: c.description, tags: c.tags, examples: c.examples, type: c.type, deployed: c.deployed, access_count: c.access_count, score: 0.5, schema: c.schema }))
|
||||
if ((mode === 'find' && q) || mode === 'find') {
|
||||
const scored = embCands.map(c => ({ ...c, score: cosineSimilarity(queryVec, c.vector) })).filter(c => c.score > 0.3).sort((a, b) => b.score - a.score).slice(0, limit)
|
||||
const embItems: QueryItem[] = scored.map(c => ({ capability: c.capability, description: c.meta.description, tags: c.meta.tags, examples: c.meta.examples, type: c.meta.type, deployed: c.lru.deployed, access_count: c.lru.access_count, score: Math.round(c.score * 1000) / 1000, schema: c.meta.schema }))
|
||||
const embCaps = new Set(embItems.map(i => i.capability))
|
||||
const items = [...embItems, ...fbItems.filter(i => !embCaps.has(i.capability))].sort((a, b) => b.score - a.score).slice(0, limit)
|
||||
const fallbackItems: QueryItem[] = fallbackCandidates
|
||||
.filter(cap => (
|
||||
cap.capability.toLowerCase().includes(qLower) ||
|
||||
cap.description?.toLowerCase().includes(qLower) ||
|
||||
cap.tags?.some(t => t.toLowerCase().includes(qLower))
|
||||
))
|
||||
.map(cap => ({
|
||||
capability: cap.capability, description: cap.description, tags: cap.tags,
|
||||
examples: cap.examples, type: cap.type, deployed: cap.deployed,
|
||||
access_count: cap.access_count, score: 0.5, schema: cap.schema,
|
||||
}))
|
||||
|
||||
const effectiveMode = (mode === 'find' && !q) ? 'explore' : mode
|
||||
|
||||
if (effectiveMode === 'find') {
|
||||
const scored = embeddingCandidates
|
||||
.map(c => ({ ...c, score: cosineSimilarity(queryVec, c.vector) }))
|
||||
.filter(c => c.score > 0.3)
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.slice(0, limit)
|
||||
const embeddingItems: QueryItem[] = scored.map(c => ({
|
||||
capability: c.capability, description: c.meta.description, tags: c.meta.tags,
|
||||
examples: c.meta.examples, type: c.meta.type, deployed: c.lru.deployed,
|
||||
access_count: c.lru.access_count, score: Math.round(c.score * 1000) / 1000,
|
||||
schema: c.meta.schema,
|
||||
}))
|
||||
const embeddingCaps = new Set(embeddingItems.map(i => i.capability))
|
||||
const fallbackOnly = fallbackItems.filter(i => !embeddingCaps.has(i.capability))
|
||||
const items = [...embeddingItems, ...fallbackOnly].sort((a, b) => b.score - a.score).slice(0, limit)
|
||||
const offset = cursor ? parseInt(cursor, 10) : 0
|
||||
return { total: items.length, items: items.slice(offset, offset + limit) }
|
||||
} else {
|
||||
const results = mmrSelect(queryVec, embeddingCandidates, limit, 0.5)
|
||||
const embeddingItems: QueryItem[] = results
|
||||
.filter(r => r.score > 0.2)
|
||||
.map(r => ({ capability: r.capability, description: r.meta.description, type: r.meta.type, score: Math.round(r.score * 1000) / 1000 }))
|
||||
const embeddingCaps = new Set(embeddingItems.map(i => i.capability))
|
||||
const fallbackOnly = fallbackItems
|
||||
.filter(i => !embeddingCaps.has(i.capability))
|
||||
.map(({ capability, description, type, score }) => ({ capability, description, type, score }))
|
||||
const items = [...embeddingItems, ...fallbackOnly].sort((a, b) => b.score - a.score).slice(0, limit)
|
||||
const offset = cursor ? parseInt(cursor, 10) : 0
|
||||
return { total: items.length, items: items.slice(offset, offset + limit) }
|
||||
}
|
||||
const results = mmrSelect(queryVec, embCands, limit, 0.5)
|
||||
const embItems: QueryItem[] = results.filter(r => r.score > 0.2).map(r => ({ capability: r.capability, description: r.meta.description, type: r.meta.type, score: Math.round(r.score * 1000) / 1000 }))
|
||||
const embCaps = new Set(embItems.map(i => i.capability))
|
||||
const items = [...embItems, ...fbItems.filter(i => !embCaps.has(i.capability)).map(({ capability, description, type, score }) => ({ capability, description, type, score }))].sort((a, b) => b.score - a.score).slice(0, limit)
|
||||
const offset = cursor ? parseInt(cursor, 10) : 0
|
||||
return { total: items.length, items: items.slice(offset, offset + limit) }
|
||||
}
|
||||
|
||||
async inspect(capabilityName: string): Promise<Capability | null> {
|
||||
const meta = await this.kv.getMeta(capabilityName); const lru = await this.kv.getLru(capabilityName)
|
||||
const meta = await this.kv.getMeta(capabilityName)
|
||||
const lru = await this.kv.getLru(capabilityName)
|
||||
if (!meta || !lru) return null
|
||||
const c: Capability = { capability: capabilityName, type: meta.type, deployed: lru.deployed, last_access: lru.last_access, access_count: lru.access_count, created_at: meta.created_at }
|
||||
if (meta.ttl !== undefined) { c.ttl = meta.ttl; c.expires_at = new Date(meta.created_at + meta.ttl * 1000).toISOString() }
|
||||
return c
|
||||
const capability: Capability = {
|
||||
capability: capabilityName, type: meta.type, deployed: lru.deployed,
|
||||
last_access: lru.last_access, access_count: lru.access_count, created_at: meta.created_at,
|
||||
}
|
||||
if (meta.ttl !== undefined) {
|
||||
capability.ttl = meta.ttl
|
||||
capability.expires_at = new Date(meta.created_at + meta.ttl * 1000).toISOString()
|
||||
}
|
||||
return capability
|
||||
}
|
||||
|
||||
async status(): Promise<BackendStatus> {
|
||||
const caps = await this.kv.listCapabilities()
|
||||
let usedSlots = 0
|
||||
for (let i = 0; i < this.config.MAX_SLOTS; i++) { const s = await this.kv.getSlot(i); if (s?.status === 'active') usedSlots++ }
|
||||
return { backend: 'worker-pool', total_slots: this.config.MAX_SLOTS, used_slots: usedSlots, lru_enabled: true, eviction_count: await this.kv.getEvictionCount() }
|
||||
for (const cap of caps) {
|
||||
const lru = await this.kv.getLru(cap)
|
||||
if (lru?.deployed) usedSlots++
|
||||
}
|
||||
const evictionCount = await this.kv.getEvictionCount()
|
||||
return {
|
||||
backend: 'worker-pool',
|
||||
total_slots: this.config.MAX_SLOTS,
|
||||
used_slots: Math.min(usedSlots, this.config.MAX_SLOTS),
|
||||
lru_enabled: true,
|
||||
eviction_count: evictionCount,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
// 空壳 Worker 代码 —— slot 未分配时返回 404
|
||||
export const IDLE_WORKER_CODE = `export default {
|
||||
async fetch() {
|
||||
return new Response(JSON.stringify({error: "Slot not assigned"}), {
|
||||
status: 404,
|
||||
headers: {"Content-Type": "application/json"}
|
||||
});
|
||||
}
|
||||
};`
|
||||
|
||||
export interface CfApi {
|
||||
updateSlotCode(slotIndex: number, code: string): Promise<void>
|
||||
initSlot(slotIndex: number): Promise<void>
|
||||
getSlotSubdomain(slotIndex: number): string
|
||||
invoke(slotIndex: number, request: Request): Promise<Response>
|
||||
}
|
||||
|
||||
import { CONFIG } from './config.js'
|
||||
|
||||
export function createCfApi(accountId: string, apiToken: string): CfApi {
|
||||
const baseUrl = `https://api.cloudflare.com/client/v4/accounts/${accountId}/workers/scripts`
|
||||
|
||||
function getSlotName(slotIndex: number): string {
|
||||
return `${CONFIG.SLOT_PREFIX}${slotIndex}`
|
||||
}
|
||||
|
||||
async function putWorkerCode(name: string, code: string): Promise<void> {
|
||||
const metadata = JSON.stringify({
|
||||
main_module: 'worker.js',
|
||||
compatibility_date: '2026-04-03',
|
||||
})
|
||||
const formData = new FormData()
|
||||
formData.append('metadata', new Blob([metadata], { type: 'application/json' }))
|
||||
formData.append('worker.js', new Blob([code], { type: 'application/javascript+module' }), 'worker.js')
|
||||
const resp = await fetch(`${baseUrl}/${name}`, {
|
||||
method: 'PUT',
|
||||
headers: { Authorization: `Bearer ${apiToken}` },
|
||||
body: formData,
|
||||
})
|
||||
if (!resp.ok) {
|
||||
const text = await resp.text()
|
||||
throw new Error(`CF API PUT worker failed (${resp.status}): ${text}`)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
async updateSlotCode(slotIndex: number, code: string): Promise<void> {
|
||||
await putWorkerCode(getSlotName(slotIndex), code)
|
||||
},
|
||||
|
||||
async initSlot(slotIndex: number): Promise<void> {
|
||||
const name = getSlotName(slotIndex)
|
||||
await putWorkerCode(name, IDLE_WORKER_CODE)
|
||||
const subdomainResp = await fetch(`${baseUrl}/${name}/subdomain`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${apiToken}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ enabled: true }),
|
||||
})
|
||||
if (!subdomainResp.ok) {
|
||||
console.warn(`[sigil] failed to enable subdomain for ${name}: ${subdomainResp.status}`)
|
||||
}
|
||||
},
|
||||
|
||||
getSlotSubdomain(slotIndex: number): string {
|
||||
return `${getSlotName(slotIndex)}${CONFIG.SUBDOMAIN_SUFFIX}`
|
||||
},
|
||||
|
||||
async invoke(slotIndex: number, request: Request): Promise<Response> {
|
||||
const subdomain = `${getSlotName(slotIndex)}${CONFIG.SUBDOMAIN_SUFFIX}`
|
||||
const url = new URL(request.url)
|
||||
const targetUrl = `https://${subdomain}${url.pathname}${url.search}`
|
||||
const headers = new Headers(request.headers)
|
||||
headers.delete('host')
|
||||
return fetch(targetUrl, {
|
||||
method: request.method,
|
||||
headers,
|
||||
body: request.method !== 'GET' && request.method !== 'HEAD' ? request.body : undefined,
|
||||
redirect: 'follow',
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
+2
-4
@@ -1,10 +1,8 @@
|
||||
export const CONFIG = {
|
||||
MAX_SLOTS: 3, // 预分配 slot 数量(物理页帧总数)
|
||||
MAX_SLOTS: 100,
|
||||
DEPLOY_COOLDOWN_MS: 5000,
|
||||
PAGE_RATE_LIMIT: 10, // 次/分钟
|
||||
PAGE_RATE_LIMIT: 10,
|
||||
PAGE_RATE_WINDOW_MS: 60000,
|
||||
HASH_LENGTH: 6,
|
||||
SLOT_PREFIX: 's-slot-', // slot Worker 名前缀:s-slot-0, s-slot-1, ...
|
||||
SUBDOMAIN_SUFFIX: '.shazhou.workers.dev',
|
||||
GATEWAY_URL: 'https://sigil.shazhou.workers.dev',
|
||||
} as const
|
||||
|
||||
+7
-8
@@ -2,29 +2,28 @@ import { WorkerPool } from './backend/worker-pool.js'
|
||||
import { AuthModule } from './auth.js'
|
||||
import { KvStore } from './kv.js'
|
||||
import { handleRequest } from './router.js'
|
||||
import { createCfApi } from './cf-api.js'
|
||||
import { EmbeddingService } from './embedding.js'
|
||||
|
||||
export interface Env {
|
||||
SIGIL_KV: KVNamespace
|
||||
AI: any
|
||||
CF_API_TOKEN: string
|
||||
CF_ACCOUNT_ID: string
|
||||
AI: any // Cloudflare Workers AI binding
|
||||
LOADER: any // Dynamic Workers Loader binding (worker_loaders)
|
||||
}
|
||||
|
||||
export default {
|
||||
async fetch(request: Request, env: Env): Promise<Response> {
|
||||
const kv = new KvStore(env.SIGIL_KV)
|
||||
const cfApi = createCfApi(env.CF_ACCOUNT_ID, env.CF_API_TOKEN)
|
||||
const embeddingService = new EmbeddingService(env.AI, env.SIGIL_KV)
|
||||
const backend = new WorkerPool(env.SIGIL_KV, cfApi, embeddingService)
|
||||
const backend = new WorkerPool(env.SIGIL_KV, env.LOADER, embeddingService)
|
||||
const auth = new AuthModule(kv)
|
||||
|
||||
try {
|
||||
return await handleRequest(request, { SIGIL_KV: env.SIGIL_KV, backend, auth, kv, cfApi })
|
||||
return await handleRequest(request, { SIGIL_KV: env.SIGIL_KV, backend, auth, kv })
|
||||
} catch (e) {
|
||||
console.error('[sigil] unhandled error:', e)
|
||||
return new Response(JSON.stringify({ error: 'Internal server error' }), {
|
||||
status: 500, headers: { 'Content-Type': 'application/json' },
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
// KV key prefixes and data types
|
||||
|
||||
import type { InputSchema } from './codegen.js'
|
||||
import { CONFIG } from './config.js'
|
||||
|
||||
export interface KvCodeValue {
|
||||
code: string
|
||||
@@ -24,15 +23,9 @@ export interface KvLruValue {
|
||||
deployed: boolean
|
||||
}
|
||||
|
||||
// slot:{n} — 槽位状态(物理页帧)
|
||||
export interface KvSlotValue {
|
||||
capability: string | null
|
||||
status: 'active' | 'free'
|
||||
}
|
||||
|
||||
// route:{capability} — 存 slot index
|
||||
export interface KvRouteValue {
|
||||
slot: number
|
||||
worker_name: string
|
||||
subdomain: string
|
||||
}
|
||||
|
||||
export interface KvAuthValue {
|
||||
@@ -86,7 +79,7 @@ export class KvStore {
|
||||
await this.kv.delete(`lru:${capability}`)
|
||||
}
|
||||
|
||||
// route:{capability} — 存 slot index
|
||||
// route:{capability}
|
||||
async getRoute(capability: string): Promise<KvRouteValue | null> {
|
||||
return await this.kv.get(`route:${capability}`, 'json') as KvRouteValue | null
|
||||
}
|
||||
@@ -97,30 +90,6 @@ export class KvStore {
|
||||
await this.kv.delete(`route:${capability}`)
|
||||
}
|
||||
|
||||
// slot:{n} — 槽位状态
|
||||
async getSlot(index: number): Promise<KvSlotValue | null> {
|
||||
return await this.kv.get(`slot:${index}`, 'json') as KvSlotValue | null
|
||||
}
|
||||
async setSlot(index: number, value: KvSlotValue): Promise<void> {
|
||||
await this.kv.put(`slot:${index}`, JSON.stringify(value))
|
||||
}
|
||||
|
||||
async findFreeSlot(): Promise<number | null> {
|
||||
for (let i = 0; i < CONFIG.MAX_SLOTS; i++) {
|
||||
const slot = await this.getSlot(i)
|
||||
if (slot?.status === 'free') return i
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
async findSlotByCapability(capability: string): Promise<number | null> {
|
||||
for (let i = 0; i < CONFIG.MAX_SLOTS; i++) {
|
||||
const slot = await this.getSlot(i)
|
||||
if (slot?.capability === capability) return i
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// auth:deploy-token
|
||||
async getDeployToken(): Promise<KvAuthValue | null> {
|
||||
return await this.kv.get('auth:deploy-token', 'json') as KvAuthValue | null
|
||||
|
||||
+110
-56
@@ -1,9 +1,7 @@
|
||||
import type { SigilBackend } from './backend/types.js'
|
||||
import type { CfApi } from './cf-api.js'
|
||||
import { AuthModule, AuthError, DeployCooldownError } from './auth.js'
|
||||
import { KvStore } from './kv.js'
|
||||
import { generateWorkerCode } from './codegen.js'
|
||||
import { CONFIG } from './config.js'
|
||||
import type { InputSchema } from './codegen.js'
|
||||
|
||||
export interface RouterEnv {
|
||||
@@ -11,7 +9,6 @@ export interface RouterEnv {
|
||||
backend: SigilBackend
|
||||
auth: AuthModule
|
||||
kv: KvStore
|
||||
cfApi?: CfApi
|
||||
}
|
||||
|
||||
export async function handleRequest(request: Request, env: RouterEnv): Promise<Response> {
|
||||
@@ -19,63 +16,131 @@ export async function handleRequest(request: Request, env: RouterEnv): Promise<R
|
||||
const path = url.pathname
|
||||
const method = request.method
|
||||
|
||||
if (method === 'GET' && path === '/_health') return handleHealth(env)
|
||||
if (method === 'POST' && path === '/_api/deploy') return handleDeploy(request, env)
|
||||
if (method === 'DELETE' && path === '/_api/remove') return handleRemove(request, env)
|
||||
if (method === 'GET' && path === '/_api/query') return handleQuery(request, env)
|
||||
if (method === 'POST' && path === '/_api/init-slots') return handleInitSlots(request, env)
|
||||
// GET /_health
|
||||
if (method === 'GET' && path === '/_health') {
|
||||
return handleHealth(env)
|
||||
}
|
||||
|
||||
// POST /_api/deploy
|
||||
if (method === 'POST' && path === '/_api/deploy') {
|
||||
return handleDeploy(request, env)
|
||||
}
|
||||
|
||||
// DELETE /_api/remove
|
||||
if (method === 'DELETE' && path === '/_api/remove') {
|
||||
return handleRemove(request, env)
|
||||
}
|
||||
|
||||
// GET /_api/query — public, no auth
|
||||
if (method === 'GET' && path === '/_api/query') {
|
||||
return handleQuery(request, env)
|
||||
}
|
||||
|
||||
// GET /_api/inspect/{capability}
|
||||
const inspectMatch = path.match(/^\/_api\/inspect\/(.+)$/)
|
||||
if (method === 'GET' && inspectMatch) return handleInspect(inspectMatch[1]!, env)
|
||||
if (method === 'GET' && inspectMatch) {
|
||||
const capability = inspectMatch[1]!
|
||||
return handleInspect(capability, env)
|
||||
}
|
||||
|
||||
// GET /run/{capability} — invoke (no auth required)
|
||||
const runMatch = path.match(/^\/run\/([^/]+)$/)
|
||||
if (runMatch) return handleInvoke(runMatch[1]!, request, env)
|
||||
if (runMatch) {
|
||||
const capability = runMatch[1]!
|
||||
return handleInvoke(capability, request, env)
|
||||
}
|
||||
|
||||
return jsonError(404, 'Not found')
|
||||
}
|
||||
|
||||
async function handleHealth(env: RouterEnv): Promise<Response> {
|
||||
return jsonOk(await env.backend.status())
|
||||
const status = await env.backend.status()
|
||||
return jsonOk(status)
|
||||
}
|
||||
|
||||
async function handleDeploy(request: Request, env: RouterEnv): Promise<Response> {
|
||||
try {
|
||||
await env.auth.validateToken(request.headers.get('Authorization'))
|
||||
const authHeader = request.headers.get('Authorization')
|
||||
await env.auth.validateToken(authHeader)
|
||||
|
||||
const body = await request.json() as {
|
||||
name: string | null; code?: string; schema?: InputSchema; execute?: string
|
||||
type: 'persistent' | 'normal' | 'ephemeral'; ttl?: number; bindings?: string[]
|
||||
description?: string; tags?: string[]; examples?: string[]
|
||||
name: string | null
|
||||
code?: string
|
||||
schema?: InputSchema
|
||||
execute?: string
|
||||
type: 'persistent' | 'normal' | 'ephemeral'
|
||||
ttl?: number
|
||||
bindings?: string[]
|
||||
description?: string
|
||||
tags?: string[]
|
||||
examples?: string[]
|
||||
}
|
||||
if (body.code && (body.schema || body.execute)) return jsonError(400, 'Cannot specify both code and schema/execute')
|
||||
if (!body.code && !body.execute) return jsonError(400, 'Must specify either code or schema+execute')
|
||||
|
||||
// Route validation
|
||||
if (body.code && (body.schema || body.execute)) {
|
||||
return jsonError(400, 'Cannot specify both code and schema/execute')
|
||||
}
|
||||
if (!body.code && !body.execute) {
|
||||
return jsonError(400, 'Must specify either code or schema+execute')
|
||||
}
|
||||
|
||||
let code: string
|
||||
let schema: InputSchema | undefined
|
||||
|
||||
if (body.code) {
|
||||
code = body.code
|
||||
} else {
|
||||
if (!body.execute) return jsonError(400, 'execute is required when using schema mode')
|
||||
if (!body.execute) {
|
||||
return jsonError(400, 'execute is required when using schema mode')
|
||||
}
|
||||
schema = body.schema || { type: 'object', properties: {} }
|
||||
code = generateWorkerCode(schema, body.execute)
|
||||
}
|
||||
|
||||
// Check deploy cooldown
|
||||
await env.auth.checkDeployCooldown()
|
||||
const result = await env.backend.deploy({ name: body.name, code, schema, type: body.type, ttl: body.ttl, bindings: body.bindings, description: body.description, tags: body.tags, examples: body.examples })
|
||||
|
||||
const result = await env.backend.deploy({
|
||||
name: body.name,
|
||||
code,
|
||||
schema,
|
||||
type: body.type,
|
||||
ttl: body.ttl,
|
||||
bindings: body.bindings,
|
||||
description: body.description,
|
||||
tags: body.tags,
|
||||
examples: body.examples,
|
||||
})
|
||||
|
||||
// Set cooldown after successful deploy
|
||||
await env.auth.setDeployCooldown()
|
||||
|
||||
return jsonOk(result, 201)
|
||||
} catch (e) {
|
||||
if (e instanceof AuthError) return jsonError(e.status, e.message)
|
||||
if (e instanceof DeployCooldownError) return jsonError(429, 'Deploy cooldown active', { retry_after: e.retry_after })
|
||||
if (e instanceof AuthError) {
|
||||
return jsonError(e.status, e.message)
|
||||
}
|
||||
if (e instanceof DeployCooldownError) {
|
||||
return jsonError(429, 'Deploy cooldown active', { retry_after: e.retry_after })
|
||||
}
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRemove(request: Request, env: RouterEnv): Promise<Response> {
|
||||
try {
|
||||
await env.auth.validateToken(request.headers.get('Authorization'))
|
||||
const authHeader = request.headers.get('Authorization')
|
||||
await env.auth.validateToken(authHeader)
|
||||
|
||||
const body = await request.json() as { capability: string }
|
||||
await env.backend.remove(body.capability)
|
||||
return jsonOk({ removed: body.capability })
|
||||
const capability = body.capability
|
||||
|
||||
await env.backend.remove(capability)
|
||||
return jsonOk({ removed: capability })
|
||||
} catch (e) {
|
||||
if (e instanceof AuthError) return jsonError(e.status, e.message)
|
||||
if (e instanceof AuthError) {
|
||||
return jsonError(e.status, e.message)
|
||||
}
|
||||
throw e
|
||||
}
|
||||
}
|
||||
@@ -88,49 +153,38 @@ async function handleQuery(request: Request, env: RouterEnv): Promise<Response>
|
||||
const limitRaw = url.searchParams.get('limit')
|
||||
const limit = limitRaw ? parseInt(limitRaw, 10) : undefined
|
||||
const cursor = url.searchParams.get('cursor') ?? undefined
|
||||
return jsonOk(await env.backend.query({ q, mode, limit, cursor }))
|
||||
|
||||
const result = await env.backend.query({ q, mode, limit, cursor })
|
||||
return jsonOk(result)
|
||||
}
|
||||
|
||||
async function handleInspect(capability: string, env: RouterEnv): Promise<Response> {
|
||||
const result = await env.backend.inspect(capability)
|
||||
if (!result) return jsonError(404, 'Capability not found')
|
||||
if (!result) {
|
||||
return jsonError(404, 'Capability not found')
|
||||
}
|
||||
return jsonOk(result)
|
||||
}
|
||||
|
||||
async function handleInvoke(capability: string, request: Request, env: RouterEnv): Promise<Response> {
|
||||
async function handleInvoke(
|
||||
capability: string,
|
||||
request: Request,
|
||||
env: RouterEnv,
|
||||
): Promise<Response> {
|
||||
// Direct invocation via Dynamic Workers — no redirect, no sub-worker fetch
|
||||
return await env.backend.invoke(capability, request)
|
||||
}
|
||||
|
||||
async function handleInitSlots(request: Request, env: RouterEnv): Promise<Response> {
|
||||
try {
|
||||
await env.auth.validateToken(request.headers.get('Authorization'))
|
||||
if (!env.cfApi) return jsonError(500, 'cfApi not available in this environment')
|
||||
const results: Array<{ slot: number; status: 'initialized' | 'skipped'; worker: string }> = []
|
||||
for (let i = 0; i < CONFIG.MAX_SLOTS; i++) {
|
||||
const existing = await env.kv.getSlot(i)
|
||||
if (existing !== null) {
|
||||
results.push({ slot: i, status: 'skipped', worker: CONFIG.SLOT_PREFIX + i })
|
||||
continue
|
||||
}
|
||||
await env.cfApi.initSlot(i)
|
||||
await env.kv.setSlot(i, { capability: null, status: 'free' })
|
||||
results.push({ slot: i, status: 'initialized', worker: CONFIG.SLOT_PREFIX + i })
|
||||
}
|
||||
return jsonOk({
|
||||
initialized: results.filter(r => r.status === 'initialized').length,
|
||||
skipped: results.filter(r => r.status === 'skipped').length,
|
||||
slots: results,
|
||||
})
|
||||
} catch (e) {
|
||||
if (e instanceof AuthError) return jsonError(e.status, e.message)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
function jsonOk(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } })
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
|
||||
function jsonError(status: number, message: string, extra?: Record<string, unknown>): Response {
|
||||
return new Response(JSON.stringify({ error: message, ...extra }), { status, headers: { 'Content-Type': 'application/json' } })
|
||||
return new Response(JSON.stringify({ error: message, ...extra }), {
|
||||
status,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
|
||||
+7
-9
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { createMockKv, createMockCfApi, makeRequest, MockEmbeddingService } from './setup.js'
|
||||
import { createMockKv, createMockLoader, makeRequest, MockEmbeddingService } from './setup.js'
|
||||
import { WorkerPool } from '../src/backend/worker-pool.js'
|
||||
import { AuthModule } from '../src/auth.js'
|
||||
import { KvStore } from '../src/kv.js'
|
||||
@@ -7,7 +7,7 @@ import { handleRequest } from '../src/router.js'
|
||||
|
||||
describe('Query API', () => {
|
||||
let mockKv: KVNamespace
|
||||
let mockCf: ReturnType<typeof createMockCfApi>
|
||||
let mockLoader: ReturnType<typeof createMockLoader>
|
||||
let mockEmbed: MockEmbeddingService
|
||||
let pool: WorkerPool
|
||||
let auth: AuthModule
|
||||
@@ -15,14 +15,13 @@ describe('Query API', () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
mockKv = createMockKv()
|
||||
mockCf = createMockCfApi()
|
||||
mockLoader = createMockLoader()
|
||||
mockEmbed = new MockEmbeddingService()
|
||||
pool = new WorkerPool(mockKv, mockCf.cfApi, mockEmbed as any)
|
||||
pool = new WorkerPool(mockKv, mockLoader.loader, mockEmbed as any)
|
||||
kv = new KvStore(mockKv)
|
||||
auth = new AuthModule(kv)
|
||||
|
||||
await auth.setToken('deploy-token')
|
||||
for (let __qi = 0; __qi < 3; __qi++) await kv.setSlot(__qi, { capability: null, status: "free" })
|
||||
|
||||
// Deploy capabilities with metadata
|
||||
await pool.deploy({
|
||||
@@ -112,12 +111,11 @@ describe('Query API', () => {
|
||||
|
||||
// Re-deploy with the new overrides in place
|
||||
const mockKv2 = createMockKv()
|
||||
const mockCf2 = createMockCfApi()
|
||||
const pool2 = new WorkerPool(mockKv2, mockCf2.cfApi, mockEmbed as any)
|
||||
const mockLoader2 = createMockLoader()
|
||||
const pool2 = new WorkerPool(mockKv2, mockLoader2.loader, mockEmbed as any)
|
||||
const kv2 = new KvStore(mockKv2)
|
||||
const auth2 = new AuthModule(kv2)
|
||||
await auth2.setToken('deploy-token')
|
||||
for (let __qj = 0; __qj < 3; __qj++) await kv2.setSlot(__qj, { capability: null, status: "free" })
|
||||
|
||||
await pool2.deploy({
|
||||
name: 'currency',
|
||||
@@ -178,7 +176,7 @@ describe('Query API', () => {
|
||||
return mockEmbed.embedQuery(q)
|
||||
},
|
||||
}
|
||||
const pool2 = new WorkerPool(mockKv, mockCf.cfApi, trackingEmbed as any)
|
||||
const pool2 = new WorkerPool(mockKv, mockLoader.loader, trackingEmbed as any)
|
||||
const result = await pool2.query({})
|
||||
expect(embedCalled).toBe(false)
|
||||
expect(result.total).toBe(3)
|
||||
|
||||
+133
-52
@@ -1,14 +1,13 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { createMockKv, createMockCfApi, makeRequest, MockEmbeddingService } from './setup.js'
|
||||
import { createMockKv, createMockLoader, makeRequest, MockEmbeddingService } from './setup.js'
|
||||
import { WorkerPool } from '../src/backend/worker-pool.js'
|
||||
import { AuthModule } from '../src/auth.js'
|
||||
import { KvStore } from '../src/kv.js'
|
||||
import { handleRequest } from '../src/router.js'
|
||||
import { CONFIG } from '../src/config.js'
|
||||
|
||||
describe('S1: 部署能力', () => {
|
||||
let mockKv: KVNamespace
|
||||
let mockCf: ReturnType<typeof createMockCfApi>
|
||||
let mockLoader: ReturnType<typeof createMockLoader>
|
||||
let mockEmbed: MockEmbeddingService
|
||||
let pool: WorkerPool
|
||||
let auth: AuthModule
|
||||
@@ -16,100 +15,182 @@ describe('S1: 部署能力', () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
mockKv = createMockKv()
|
||||
mockCf = createMockCfApi()
|
||||
mockLoader = createMockLoader()
|
||||
mockEmbed = new MockEmbeddingService()
|
||||
pool = new WorkerPool(mockKv, mockCf.cfApi, mockEmbed as any)
|
||||
pool = new WorkerPool(mockKv, mockLoader.loader, mockEmbed as any)
|
||||
kv = new KvStore(mockKv)
|
||||
auth = new AuthModule(kv)
|
||||
|
||||
// Set unified deploy token
|
||||
await auth.setToken('deploy-token')
|
||||
for (let i = 0; i < CONFIG.MAX_SLOTS; i++) {
|
||||
await kv.setSlot(i, { capability: null, status: 'free' })
|
||||
}
|
||||
})
|
||||
|
||||
it('should deploy via API', async () => {
|
||||
it('should deploy a capability via API', async () => {
|
||||
const req = makeRequest('POST', '/_api/deploy', {
|
||||
token: 'deploy-token',
|
||||
body: { name: 'ping', code: "export default { fetch() { return new Response('pong') } }", type: 'normal' },
|
||||
body: {
|
||||
name: 'ping',
|
||||
code: "export default { fetch() { return new Response('pong') } }",
|
||||
type: 'normal',
|
||||
},
|
||||
})
|
||||
|
||||
const resp = await handleRequest(req, { SIGIL_KV: mockKv, backend: pool, auth, kv })
|
||||
expect(resp.status).toBe(201)
|
||||
const body = await resp.json() as { capability: string; url: string; cold_start: boolean }
|
||||
|
||||
const body = await resp.json() as {
|
||||
capability: string
|
||||
url: string
|
||||
cold_start: boolean
|
||||
}
|
||||
expect(body.capability).toBe('ping')
|
||||
expect(body.url).toBe('https://sigil.shazhou.workers.dev/run/ping')
|
||||
expect(body.cold_start).toBe(false)
|
||||
})
|
||||
|
||||
it('should call updateSlotCode on deploy', async () => {
|
||||
await pool.deploy({ name: 'ping', code: "export default { fetch() { return new Response('pong') } }", type: 'normal' })
|
||||
const updates = mockCf.updateSlotCodeCalls()
|
||||
expect(updates).toHaveLength(1)
|
||||
expect(updates[0]!.slotIndex).toBeGreaterThanOrEqual(0)
|
||||
expect(updates[0]!.slotIndex).toBeLessThan(CONFIG.MAX_SLOTS)
|
||||
it('should NOT call LOADER.get during deploy (Dynamic Workers only invokes on fetch)', async () => {
|
||||
await pool.deploy({
|
||||
name: 'ping',
|
||||
code: "export default { fetch() { return new Response('pong') } }",
|
||||
type: 'normal',
|
||||
})
|
||||
|
||||
it('should NOT call cfApi.invoke during deploy', async () => {
|
||||
await pool.deploy({ name: 'ping', code: "export default { fetch() { return new Response('pong') } }", type: 'normal' })
|
||||
expect(mockCf.invokeCalls()).toHaveLength(0)
|
||||
// LOADER.get() should NOT be called during deploy — only during invoke
|
||||
expect(mockLoader.loaderCalls()).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('should write KV entries with slot route', async () => {
|
||||
await pool.deploy({ name: 'ping', code: "export default { fetch() { return new Response('pong') } }", type: 'normal' })
|
||||
expect(await kv.getCode('ping')).toBeTruthy()
|
||||
expect((await kv.getMeta('ping'))?.type).toBe('normal')
|
||||
it('should write KV entries (code, meta, lru)', async () => {
|
||||
await pool.deploy({
|
||||
name: 'ping',
|
||||
code: "export default { fetch() { return new Response('pong') } }",
|
||||
type: 'normal',
|
||||
})
|
||||
|
||||
const code = await kv.getCode('ping')
|
||||
expect(code).toBeTruthy()
|
||||
|
||||
const meta = await kv.getMeta('ping')
|
||||
expect(meta?.type).toBe('normal')
|
||||
|
||||
const lru = await kv.getLru('ping')
|
||||
expect(lru?.deployed).toBe(true)
|
||||
expect(lru?.access_count).toBe(0)
|
||||
const route = await kv.getRoute('ping')
|
||||
expect(route).not.toBeNull()
|
||||
expect(typeof route?.slot).toBe('number')
|
||||
})
|
||||
|
||||
it('should update slot to active after deploy', async () => {
|
||||
await pool.deploy({ name: 'ping', code: "export default { fetch() { return new Response('pong') } }", type: 'normal' })
|
||||
const route = await kv.getRoute('ping')
|
||||
const slot = await kv.getSlot(route!.slot)
|
||||
expect(slot?.status).toBe('active')
|
||||
expect(slot?.capability).toBe('ping')
|
||||
})
|
||||
// --- 模式 B: schema + execute ---
|
||||
|
||||
it('模式 B: schema + execute', async () => {
|
||||
it('模式 B: schema + execute 通过 API 部署', async () => {
|
||||
const req = makeRequest('POST', '/_api/deploy', {
|
||||
token: 'deploy-token',
|
||||
body: { name: 'adder', type: 'normal',
|
||||
schema: { type: 'object', properties: { a: { type: 'number' }, b: { type: 'number' } }, required: ['a','b'] },
|
||||
execute: 'return JSON.stringify({ sum: input.a + input.b })' },
|
||||
body: {
|
||||
name: 'adder',
|
||||
type: 'normal',
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
a: { type: 'number', description: 'First number' },
|
||||
b: { type: 'number', description: 'Second number' },
|
||||
},
|
||||
required: ['a', 'b'],
|
||||
},
|
||||
execute: 'return JSON.stringify({ sum: input.a + input.b })',
|
||||
},
|
||||
})
|
||||
|
||||
const resp = await handleRequest(req, { SIGIL_KV: mockKv, backend: pool, auth, kv })
|
||||
expect(resp.status).toBe(201)
|
||||
expect((await resp.json() as any).capability).toBe('adder')
|
||||
|
||||
const body = await resp.json() as { capability: string; url: string }
|
||||
expect(body.capability).toBe('adder')
|
||||
expect(body.url).toBe('https://sigil.shazhou.workers.dev/run/adder')
|
||||
})
|
||||
|
||||
it('模式 B: 生成 code 含 export default', async () => {
|
||||
it('模式 B: 生成的 code 存入 KV(包含 export default)', async () => {
|
||||
const req = makeRequest('POST', '/_api/deploy', {
|
||||
token: 'deploy-token',
|
||||
body: { name: 'greeter', type: 'normal',
|
||||
schema: { type: 'object', properties: { name: { type: 'string' } } },
|
||||
execute: 'return "Hello, " + input.name + "!"' },
|
||||
body: {
|
||||
name: 'greeter',
|
||||
type: 'normal',
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string', default: 'World' },
|
||||
},
|
||||
},
|
||||
execute: 'return "Hello, " + input.name + "!"',
|
||||
},
|
||||
})
|
||||
|
||||
await handleRequest(req, { SIGIL_KV: mockKv, backend: pool, auth, kv })
|
||||
|
||||
const code = await kv.getCode('greeter')
|
||||
expect(code).toBeTruthy()
|
||||
expect(code).toContain('export default')
|
||||
expect(code).toContain('async fetch(request)')
|
||||
})
|
||||
|
||||
it('code + schema 同时 → 400', async () => {
|
||||
it('模式 B: schema 存入 KV meta', async () => {
|
||||
const schema = {
|
||||
type: 'object' as const,
|
||||
properties: {
|
||||
from: { type: 'string', description: 'Source currency' },
|
||||
to: { type: 'string', description: 'Target currency' },
|
||||
amount: { type: 'number', description: 'Amount', default: 1 },
|
||||
},
|
||||
required: ['from', 'to'],
|
||||
}
|
||||
|
||||
const req = makeRequest('POST', '/_api/deploy', {
|
||||
token: 'deploy-token',
|
||||
body: { name: 'bad', type: 'normal',
|
||||
code: 'export default{}',
|
||||
schema: { type: 'object', properties: {} }, execute: 'return "x"' },
|
||||
})
|
||||
expect((await handleRequest(req, { SIGIL_KV: mockKv, backend: pool, auth, kv })).status).toBe(400)
|
||||
body: {
|
||||
name: 'currency',
|
||||
type: 'persistent',
|
||||
description: 'Currency converter',
|
||||
tags: ['finance'],
|
||||
schema,
|
||||
execute: 'return JSON.stringify({ from: input.from, to: input.to, amount: input.amount })',
|
||||
},
|
||||
})
|
||||
|
||||
it('无 code 无 execute → 400', async () => {
|
||||
const req = makeRequest('POST', '/_api/deploy', { token: 'deploy-token', body: { name: 'bad', type: 'normal' } })
|
||||
expect((await handleRequest(req, { SIGIL_KV: mockKv, backend: pool, auth, kv })).status).toBe(400)
|
||||
await handleRequest(req, { SIGIL_KV: mockKv, backend: pool, auth, kv })
|
||||
|
||||
const meta = await kv.getMeta('currency')
|
||||
expect(meta?.schema).toBeDefined()
|
||||
expect(meta?.schema?.properties.from.type).toBe('string')
|
||||
expect(meta?.schema?.required).toContain('from')
|
||||
expect(meta?.schema?.required).toContain('to')
|
||||
})
|
||||
|
||||
it('模式 B + A 同时提供 → 400 错误', async () => {
|
||||
const req = makeRequest('POST', '/_api/deploy', {
|
||||
token: 'deploy-token',
|
||||
body: {
|
||||
name: 'bad',
|
||||
type: 'normal',
|
||||
code: 'export default { fetch() { return new Response("hi") } }',
|
||||
schema: { properties: {} },
|
||||
execute: 'return "hello"',
|
||||
},
|
||||
})
|
||||
|
||||
const resp = await handleRequest(req, { SIGIL_KV: mockKv, backend: pool, auth, kv })
|
||||
expect(resp.status).toBe(400)
|
||||
const body = await resp.json() as { error: string }
|
||||
expect(body.error).toContain('Cannot specify both code and schema/execute')
|
||||
})
|
||||
|
||||
it('code 和 execute 都不提供 → 400 错误', async () => {
|
||||
const req = makeRequest('POST', '/_api/deploy', {
|
||||
token: 'deploy-token',
|
||||
body: {
|
||||
name: 'bad',
|
||||
type: 'normal',
|
||||
},
|
||||
})
|
||||
|
||||
const resp = await handleRequest(req, { SIGIL_KV: mockKv, backend: pool, auth, kv })
|
||||
expect(resp.status).toBe(400)
|
||||
const body = await resp.json() as { error: string }
|
||||
expect(body.error).toContain('Must specify either code or schema+execute')
|
||||
})
|
||||
})
|
||||
|
||||
+33
-18
@@ -1,43 +1,58 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { createMockKv, createMockCfApi, MockEmbeddingService } from './setup.js'
|
||||
import { createMockKv, createMockLoader, MockEmbeddingService } from './setup.js'
|
||||
import { WorkerPool } from '../src/backend/worker-pool.js'
|
||||
import { KvStore } from '../src/kv.js'
|
||||
import { CONFIG } from '../src/config.js'
|
||||
|
||||
describe('S2: 调用已部署能力(命中)', () => {
|
||||
let mockKv: KVNamespace
|
||||
let mockCf: ReturnType<typeof createMockCfApi>
|
||||
let mockLoader: ReturnType<typeof createMockLoader>
|
||||
let mockEmbed: MockEmbeddingService
|
||||
let pool: WorkerPool
|
||||
let kv: KvStore
|
||||
|
||||
beforeEach(async () => {
|
||||
mockKv = createMockKv()
|
||||
mockCf = createMockCfApi({ invokeResponse: () => new Response('pong', { status: 200 }) })
|
||||
pool = new WorkerPool(mockKv, mockCf.cfApi, new MockEmbeddingService() as any)
|
||||
mockLoader = createMockLoader({
|
||||
invokeResponse: (_workerName, _req) => new Response('pong', { status: 200 }),
|
||||
})
|
||||
mockEmbed = new MockEmbeddingService()
|
||||
pool = new WorkerPool(mockKv, mockLoader.loader, mockEmbed as any)
|
||||
kv = new KvStore(mockKv)
|
||||
for (let i = 0; i < CONFIG.MAX_SLOTS; i++) await kv.setSlot(i, { capability: null, status: 'free' })
|
||||
await pool.deploy({ name: 'ping', code: "export default { fetch() { return new Response('pong') } }", type: 'normal' })
|
||||
mockCf.reset()
|
||||
|
||||
// Deploy first
|
||||
await pool.deploy({
|
||||
name: 'ping',
|
||||
code: "export default { fetch() { return new Response('pong') } }",
|
||||
type: 'normal',
|
||||
})
|
||||
mockLoader.reset()
|
||||
})
|
||||
|
||||
it('should invoke warm capability', async () => {
|
||||
const resp = await pool.invoke('ping', new Request('https://sigil.shazhou.workers.dev/run/ping'))
|
||||
const req = new Request('https://sigil.shazhou.workers.dev/run/ping')
|
||||
const resp = await pool.invoke('ping', req)
|
||||
expect(resp.status).toBe(200)
|
||||
expect(await resp.text()).toBe('pong')
|
||||
})
|
||||
|
||||
it('should call cfApi.invoke with correct slot index', async () => {
|
||||
const route = await kv.getRoute('ping')
|
||||
await pool.invoke('ping', new Request('https://sigil.shazhou.workers.dev/run/ping'))
|
||||
expect(mockCf.invokeCalls()).toContain(route!.slot)
|
||||
})
|
||||
|
||||
it('should update lru on warm hit', async () => {
|
||||
it('should update lru.last_access on warm hit', async () => {
|
||||
const lruBefore = await kv.getLru('ping')
|
||||
const accessBefore = lruBefore!.last_access
|
||||
|
||||
await new Promise(r => setTimeout(r, 5))
|
||||
await pool.invoke('ping', new Request('https://sigil.shazhou.workers.dev/run/ping'))
|
||||
|
||||
const req = new Request('https://sigil.shazhou.workers.dev/run/ping')
|
||||
await pool.invoke('ping', req)
|
||||
|
||||
const lruAfter = await kv.getLru('ping')
|
||||
expect(lruAfter!.last_access).toBeGreaterThan(lruBefore!.last_access)
|
||||
expect(lruAfter!.last_access).toBeGreaterThan(accessBefore)
|
||||
expect(lruAfter!.access_count).toBe(1)
|
||||
})
|
||||
|
||||
it('should call LOADER.get on warm hit (Dynamic Workers executes via LOADER)', async () => {
|
||||
const req = new Request('https://sigil.shazhou.workers.dev/run/ping')
|
||||
await pool.invoke('ping', req)
|
||||
// LOADER.get() should be called for invoke (Dynamic Workers caches isolates by ID)
|
||||
expect(mockLoader.loaderCalls().length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,46 +1,70 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { createMockKv, createMockCfApi, MockEmbeddingService } from './setup.js'
|
||||
import { createMockKv, createMockLoader, MockEmbeddingService } from './setup.js'
|
||||
import { WorkerPool } from '../src/backend/worker-pool.js'
|
||||
import { KvStore } from '../src/kv.js'
|
||||
import { CONFIG } from '../src/config.js'
|
||||
|
||||
describe('S3: 调用未部署能力(冷启动)', () => {
|
||||
describe('S3: 调用未部署能力(换入)', () => {
|
||||
let mockKv: KVNamespace
|
||||
let mockCf: ReturnType<typeof createMockCfApi>
|
||||
let mockLoader: ReturnType<typeof createMockLoader>
|
||||
let mockEmbed: MockEmbeddingService
|
||||
let pool: WorkerPool
|
||||
let kv: KvStore
|
||||
|
||||
beforeEach(async () => {
|
||||
mockKv = createMockKv()
|
||||
mockCf = createMockCfApi({ invokeResponse: () => new Response('pong', { status: 200 }) })
|
||||
pool = new WorkerPool(mockKv, mockCf.cfApi, new MockEmbeddingService() as any)
|
||||
mockLoader = createMockLoader({
|
||||
invokeResponse: () => new Response('pong', { status: 200 }),
|
||||
})
|
||||
mockEmbed = new MockEmbeddingService()
|
||||
pool = new WorkerPool(mockKv, mockLoader.loader, mockEmbed as any)
|
||||
kv = new KvStore(mockKv)
|
||||
for (let i = 0; i < CONFIG.MAX_SLOTS; i++) await kv.setSlot(i, { capability: null, status: 'free' })
|
||||
|
||||
// Manually write KV to simulate "evicted but not deleted from KV" state
|
||||
await kv.setCode('ping', "export default { fetch() { return new Response('pong') } }")
|
||||
await kv.setMeta('ping', { type: 'normal', created_at: Date.now() - 10000 })
|
||||
await kv.setLru('ping', { last_access: Date.now() - 10000, access_count: 5, deployed: false })
|
||||
await kv.setMeta('ping', {
|
||||
type: 'normal',
|
||||
created_at: Date.now() - 10000,
|
||||
})
|
||||
await kv.setLru('ping', {
|
||||
last_access: Date.now() - 10000,
|
||||
access_count: 5,
|
||||
deployed: false, // key: not deployed
|
||||
})
|
||||
})
|
||||
|
||||
it('should page-in and call updateSlotCode', async () => {
|
||||
const resp = await pool.invoke('ping', new Request('https://sigil.shazhou.workers.dev/run/ping'))
|
||||
it('should page in and call LOADER.get', async () => {
|
||||
const req = new Request('https://sigil.shazhou.workers.dev/run/ping')
|
||||
const resp = await pool.invoke('ping', req)
|
||||
|
||||
expect(resp.status).toBe(200)
|
||||
expect(mockCf.updateSlotCodeCalls()).toHaveLength(1)
|
||||
// LOADER.get() should be called (Dynamic Workers executes inline)
|
||||
expect(mockLoader.loaderCalls().length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should set lru.deployed=true after page-in', async () => {
|
||||
await pool.invoke('ping', new Request('https://sigil.shazhou.workers.dev/run/ping'))
|
||||
expect((await kv.getLru('ping'))?.deployed).toBe(true)
|
||||
const req = new Request('https://sigil.shazhou.workers.dev/run/ping')
|
||||
await pool.invoke('ping', req)
|
||||
|
||||
const lru = await kv.getLru('ping')
|
||||
expect(lru?.deployed).toBe(true)
|
||||
})
|
||||
|
||||
it('should set X-Sigil-Cold-Start header', async () => {
|
||||
const resp = await pool.invoke('ping', new Request('https://sigil.shazhou.workers.dev/run/ping'))
|
||||
const req = new Request('https://sigil.shazhou.workers.dev/run/ping')
|
||||
const resp = await pool.invoke('ping', req)
|
||||
|
||||
expect(resp.headers.get('X-Sigil-Cold-Start')).toBe('true')
|
||||
})
|
||||
|
||||
it('should write route entry after page-in', async () => {
|
||||
await pool.invoke('ping', new Request('https://sigil.shazhou.workers.dev/run/ping'))
|
||||
const route = await kv.getRoute('ping')
|
||||
expect(route).not.toBeNull()
|
||||
expect(typeof route?.slot).toBe('number')
|
||||
it('should NOT set X-Sigil-Cold-Start on warm hit', async () => {
|
||||
// First invoke (cold)
|
||||
const req1 = new Request('https://sigil.shazhou.workers.dev/run/ping')
|
||||
await pool.invoke('ping', req1)
|
||||
|
||||
// Second invoke (warm)
|
||||
const req2 = new Request('https://sigil.shazhou.workers.dev/run/ping')
|
||||
const resp2 = await pool.invoke('ping', req2)
|
||||
|
||||
expect(resp2.headers.get('X-Sigil-Cold-Start')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
+100
-50
@@ -1,81 +1,131 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { createMockKv, createMockCfApi, MockEmbeddingService } from './setup.js'
|
||||
import { createMockKv, createMockLoader, MockEmbeddingService } from './setup.js'
|
||||
import { WorkerPool } from '../src/backend/worker-pool.js'
|
||||
import { KvStore } from '../src/kv.js'
|
||||
import { CONFIG } from '../src/config.js'
|
||||
|
||||
describe('S4: 配额满时换出(LRU)', () => {
|
||||
describe('S4: 配额满时换出', () => {
|
||||
let mockKv: KVNamespace
|
||||
let mockCf: ReturnType<typeof createMockCfApi>
|
||||
let mockLoader: ReturnType<typeof createMockLoader>
|
||||
let mockEmbed: MockEmbeddingService
|
||||
let pool: WorkerPool
|
||||
let kv: KvStore
|
||||
|
||||
beforeEach(async () => {
|
||||
mockKv = createMockKv()
|
||||
mockCf = createMockCfApi({ invokeResponse: () => new Response('ok', { status: 200 }) })
|
||||
pool = new WorkerPool(mockKv, mockCf.cfApi, new MockEmbeddingService() as any)
|
||||
mockLoader = createMockLoader({
|
||||
invokeResponse: () => new Response('ok', { status: 200 }),
|
||||
})
|
||||
mockEmbed = new MockEmbeddingService()
|
||||
pool = new WorkerPool(mockKv, mockLoader.loader, mockEmbed as any)
|
||||
kv = new KvStore(mockKv)
|
||||
for (let i = 0; i < CONFIG.MAX_SLOTS; i++) await kv.setSlot(i, { capability: null, status: 'free' })
|
||||
})
|
||||
|
||||
async function fillSlots(): Promise<void> {
|
||||
const base = Date.now() - 100000
|
||||
it('should evict the coldest capability when slots are full', async () => {
|
||||
const baseTime = Date.now() - 100000
|
||||
|
||||
// Fill up all slots (MAX_SLOTS = 3)
|
||||
for (let i = 0; i < CONFIG.MAX_SLOTS; i++) {
|
||||
const cap = 'cap' + i
|
||||
await kv.setCode(cap, '// c' + i)
|
||||
await kv.setMeta(cap, { type: 'normal', created_at: base + i * 100 })
|
||||
await kv.setLru(cap, { last_access: base + i * 100, access_count: i, deployed: true })
|
||||
await kv.setSlot(i, { capability: cap, status: 'active' })
|
||||
await kv.setRoute(cap, { slot: i })
|
||||
}
|
||||
const cap = `cap${i}`
|
||||
await kv.setCode(cap, `// code ${i}`)
|
||||
await kv.setMeta(cap, {
|
||||
type: 'normal',
|
||||
created_at: baseTime + i * 100,
|
||||
})
|
||||
await kv.setLru(cap, {
|
||||
last_access: baseTime + i * 100, // cap0 is coldest
|
||||
access_count: i,
|
||||
deployed: true,
|
||||
})
|
||||
}
|
||||
|
||||
it('should evict coldest when slots full', async () => {
|
||||
await fillSlots()
|
||||
const result = await pool.deploy({ name: 'new-cap', code: '// new', type: 'normal' })
|
||||
// Deploy one more — should trigger eviction of cap0 (oldest last_access)
|
||||
const result = await pool.deploy({
|
||||
name: 'new-cap',
|
||||
code: '// new',
|
||||
type: 'normal',
|
||||
})
|
||||
|
||||
expect(result.capability).toBe('new-cap')
|
||||
expect(result.evicted).toBe('cap0')
|
||||
expect((await kv.getLru('cap0'))?.deployed).toBe(false)
|
||||
})
|
||||
|
||||
it('should call updateSlotCode with IDLE code on eviction', async () => {
|
||||
await fillSlots(); mockCf.reset()
|
||||
await pool.deploy({ name: 'new-cap', code: '// new', type: 'normal' })
|
||||
const updates = mockCf.updateSlotCodeCalls()
|
||||
expect(updates.length).toBeGreaterThanOrEqual(2)
|
||||
expect(updates.find(u => u.code.includes('Slot not assigned'))).toBeDefined()
|
||||
})
|
||||
// Dynamic Workers: no LOADER.get() calls during deploy — only during invoke
|
||||
expect(mockLoader.loaderCalls()).toHaveLength(0)
|
||||
|
||||
it('should release slot route after eviction', async () => {
|
||||
await fillSlots()
|
||||
await pool.deploy({ name: 'new-cap', code: '// new', type: 'normal' })
|
||||
expect(await kv.getRoute('cap0')).toBeNull()
|
||||
// cap0 lru should be deployed=false
|
||||
const evictedLru = await kv.getLru('cap0')
|
||||
expect(evictedLru?.deployed).toBe(false)
|
||||
})
|
||||
|
||||
it('should increment eviction count', async () => {
|
||||
await fillSlots()
|
||||
await pool.deploy({ name: 'new-cap', code: '// new', type: 'normal' })
|
||||
expect(await kv.getEvictionCount()).toBe(1)
|
||||
const baseTime = Date.now() - 100000
|
||||
|
||||
for (let i = 0; i < CONFIG.MAX_SLOTS; i++) {
|
||||
const cap = `cap${i}`
|
||||
await kv.setCode(cap, `// code ${i}`)
|
||||
await kv.setMeta(cap, {
|
||||
type: 'normal',
|
||||
created_at: baseTime + i * 100,
|
||||
})
|
||||
await kv.setLru(cap, {
|
||||
last_access: baseTime + i * 100,
|
||||
access_count: i,
|
||||
deployed: true,
|
||||
})
|
||||
}
|
||||
|
||||
await pool.deploy({
|
||||
name: 'new-cap',
|
||||
code: '// new',
|
||||
type: 'normal',
|
||||
})
|
||||
|
||||
it('should prefer evicting expired ephemeral over normal', async () => {
|
||||
const base = Date.now() - 100000
|
||||
const evictionCount = await kv.getEvictionCount()
|
||||
expect(evictionCount).toBe(1)
|
||||
})
|
||||
|
||||
it('should prefer evicting ephemeral_expired over normal', async () => {
|
||||
const baseTime = Date.now() - 100000
|
||||
const expiredEphemeralCreated = Date.now() - 10000
|
||||
|
||||
// Fill (MAX_SLOTS - 1) normal caps
|
||||
for (let i = 0; i < CONFIG.MAX_SLOTS - 1; i++) {
|
||||
const cap = 'normal' + i
|
||||
await kv.setCode(cap, '// c' + i)
|
||||
await kv.setMeta(cap, { type: 'normal', created_at: base + i * 100 })
|
||||
await kv.setLru(cap, { last_access: base + i * 100, access_count: 10, deployed: true })
|
||||
await kv.setSlot(i, { capability: cap, status: 'active' })
|
||||
await kv.setRoute(cap, { slot: i })
|
||||
const cap = `normal${i}`
|
||||
await kv.setCode(cap, `// code ${i}`)
|
||||
await kv.setMeta(cap, {
|
||||
type: 'normal',
|
||||
created_at: baseTime + i * 100,
|
||||
})
|
||||
await kv.setLru(cap, {
|
||||
last_access: baseTime + i * 100,
|
||||
access_count: 10, // high access
|
||||
deployed: true,
|
||||
})
|
||||
}
|
||||
const last = CONFIG.MAX_SLOTS - 1
|
||||
await kv.setCode('ephemeral-old', '// e')
|
||||
await kv.setMeta('ephemeral-old', { type: 'ephemeral', ttl: 1, created_at: Date.now() - 10000 })
|
||||
await kv.setLru('ephemeral-old', { last_access: Date.now() - 100, access_count: 100, deployed: true })
|
||||
await kv.setSlot(last, { capability: 'ephemeral-old', status: 'active' })
|
||||
await kv.setRoute('ephemeral-old', { slot: last })
|
||||
const result = await pool.deploy({ name: 'newcomer', code: '// new', type: 'normal' })
|
||||
|
||||
// Add 1 expired ephemeral (more recently accessed but expired)
|
||||
await kv.setCode('ephemeral-old', '// ephemeral')
|
||||
await kv.setMeta('ephemeral-old', {
|
||||
type: 'ephemeral',
|
||||
ttl: 1, // 1 second TTL, already expired
|
||||
created_at: expiredEphemeralCreated,
|
||||
})
|
||||
await kv.setLru('ephemeral-old', {
|
||||
last_access: Date.now() - 100, // recently accessed
|
||||
access_count: 100,
|
||||
deployed: true,
|
||||
})
|
||||
|
||||
// Deploy one more
|
||||
const result = await pool.deploy({
|
||||
name: 'newcomer',
|
||||
code: '// new',
|
||||
type: 'normal',
|
||||
})
|
||||
|
||||
// Should evict the expired ephemeral, not the coldest normal
|
||||
expect(result.evicted).toBe('ephemeral-old')
|
||||
expect((await kv.getLru('ephemeral-old'))?.deployed).toBe(false)
|
||||
const evictedLru = await kv.getLru('ephemeral-old')
|
||||
expect(evictedLru?.deployed).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
+20
-18
@@ -1,34 +1,36 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { createMockKv, createMockCfApi, MockEmbeddingService } from './setup.js'
|
||||
import { createMockKv, createMockLoader, MockEmbeddingService } from './setup.js'
|
||||
import { WorkerPool } from '../src/backend/worker-pool.js'
|
||||
|
||||
describe('S5: 调用不存在能力', () => {
|
||||
describe('S5: 调用不存在的能力', () => {
|
||||
let mockKv: KVNamespace
|
||||
let mockLoader: ReturnType<typeof createMockLoader>
|
||||
let mockEmbed: MockEmbeddingService
|
||||
let pool: WorkerPool
|
||||
let mockCf: ReturnType<typeof createMockCfApi>
|
||||
|
||||
beforeEach(() => {
|
||||
const mockKv = createMockKv()
|
||||
mockCf = createMockCfApi()
|
||||
pool = new WorkerPool(mockKv, mockCf.cfApi, new MockEmbeddingService() as any)
|
||||
mockKv = createMockKv()
|
||||
mockLoader = createMockLoader()
|
||||
mockEmbed = new MockEmbeddingService()
|
||||
pool = new WorkerPool(mockKv, mockLoader.loader, mockEmbed as any)
|
||||
})
|
||||
|
||||
it('should return 404', async () => {
|
||||
const resp = await pool.invoke('nonexistent', new Request('https://sigil.shazhou.workers.dev/run/nonexistent'))
|
||||
it('should return 404 for nonexistent capability', async () => {
|
||||
const req = new Request('https://sigil.shazhou.workers.dev/run/nonexistent')
|
||||
const resp = await pool.invoke('nonexistent', req)
|
||||
expect(resp.status).toBe(404)
|
||||
})
|
||||
|
||||
it('should return error JSON body', async () => {
|
||||
const resp = await pool.invoke('nonexistent', new Request('https://sigil.shazhou.workers.dev/run/nonexistent'))
|
||||
expect((await resp.json() as any).error).toBeTruthy()
|
||||
const req = new Request('https://sigil.shazhou.workers.dev/run/nonexistent')
|
||||
const resp = await pool.invoke('nonexistent', req)
|
||||
const body = await resp.json() as { error: string }
|
||||
expect(body.error).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should not call cfApi.invoke', async () => {
|
||||
await pool.invoke('nonexistent', new Request('https://sigil.shazhou.workers.dev/run/nonexistent'))
|
||||
expect(mockCf.invokeCalls()).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('should not call cfApi.updateSlotCode', async () => {
|
||||
await pool.invoke('nonexistent', new Request('https://sigil.shazhou.workers.dev/run/nonexistent'))
|
||||
expect(mockCf.updateSlotCodeCalls()).toHaveLength(0)
|
||||
it('should not call LOADER.get for nonexistent capability', async () => {
|
||||
const req = new Request('https://sigil.shazhou.workers.dev/run/nonexistent')
|
||||
await pool.invoke('nonexistent', req)
|
||||
expect(mockLoader.loaderCalls()).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
+39
-30
@@ -1,62 +1,71 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { createMockKv, createMockCfApi, makeRequest, MockEmbeddingService } from './setup.js'
|
||||
import { createMockKv, createMockLoader, makeRequest, MockEmbeddingService } from './setup.js'
|
||||
import { WorkerPool } from '../src/backend/worker-pool.js'
|
||||
import { AuthModule } from '../src/auth.js'
|
||||
import { KvStore } from '../src/kv.js'
|
||||
import { handleRequest } from '../src/router.js'
|
||||
import { CONFIG } from '../src/config.js'
|
||||
|
||||
describe('S6: 删除能力', () => {
|
||||
let mockKv: KVNamespace
|
||||
let mockCf: ReturnType<typeof createMockCfApi>
|
||||
let mockLoader: ReturnType<typeof createMockLoader>
|
||||
let mockEmbed: MockEmbeddingService
|
||||
let pool: WorkerPool
|
||||
let auth: AuthModule
|
||||
let kv: KvStore
|
||||
|
||||
beforeEach(async () => {
|
||||
mockKv = createMockKv()
|
||||
mockCf = createMockCfApi()
|
||||
pool = new WorkerPool(mockKv, mockCf.cfApi, new MockEmbeddingService() as any)
|
||||
mockLoader = createMockLoader()
|
||||
mockEmbed = new MockEmbeddingService()
|
||||
pool = new WorkerPool(mockKv, mockLoader.loader, mockEmbed as any)
|
||||
kv = new KvStore(mockKv)
|
||||
auth = new AuthModule(kv)
|
||||
|
||||
await auth.setToken('deploy-token')
|
||||
for (let i = 0; i < CONFIG.MAX_SLOTS; i++) await kv.setSlot(i, { capability: null, status: 'free' })
|
||||
await pool.deploy({ name: 'ping', code: "export default { fetch() { return new Response('pong') } }", type: 'normal' })
|
||||
mockCf.reset()
|
||||
|
||||
// Deploy first
|
||||
await pool.deploy({
|
||||
name: 'ping',
|
||||
code: "export default { fetch() { return new Response('pong') } }",
|
||||
type: 'normal',
|
||||
})
|
||||
mockLoader.reset()
|
||||
})
|
||||
|
||||
it('should call updateSlotCode with IDLE code on remove', async () => {
|
||||
const resp = await handleRequest(
|
||||
makeRequest('DELETE', '/_api/remove', { token: 'deploy-token', body: { capability: 'ping' } }),
|
||||
{ SIGIL_KV: mockKv, backend: pool, auth, kv },
|
||||
)
|
||||
it('should clear all KV entries (Dynamic Workers: no CF API deleteWorker needed)', async () => {
|
||||
const req = makeRequest('DELETE', '/_api/remove', {
|
||||
token: 'deploy-token',
|
||||
body: { capability: 'ping' },
|
||||
})
|
||||
|
||||
const resp = await handleRequest(req, { SIGIL_KV: mockKv, backend: pool, auth, kv })
|
||||
expect(resp.status).toBe(200)
|
||||
const updates = mockCf.updateSlotCodeCalls()
|
||||
expect(updates.length).toBe(1)
|
||||
expect(updates[0]!.code).toContain('Slot not assigned')
|
||||
})
|
||||
|
||||
it('should free slot after remove', async () => {
|
||||
const route = await kv.getRoute('ping')
|
||||
await pool.remove('ping')
|
||||
const slot = await kv.getSlot(route!.slot)
|
||||
expect(slot?.status).toBe('free')
|
||||
expect(slot?.capability).toBeNull()
|
||||
// All KV entries should be gone
|
||||
expect(await kv.getCode('ping')).toBeNull()
|
||||
expect(await kv.getMeta('ping')).toBeNull()
|
||||
expect(await kv.getLru('ping')).toBeNull()
|
||||
|
||||
// No LOADER.get() calls during remove
|
||||
expect(mockLoader.loaderCalls()).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('should clear all KV entries', async () => {
|
||||
await pool.remove('ping')
|
||||
|
||||
expect(await kv.getCode('ping')).toBeNull()
|
||||
expect(await kv.getMeta('ping')).toBeNull()
|
||||
expect(await kv.getLru('ping')).toBeNull()
|
||||
expect(await kv.getRoute('ping')).toBeNull()
|
||||
})
|
||||
|
||||
it('should return removed capability', async () => {
|
||||
const resp = await handleRequest(
|
||||
makeRequest('DELETE', '/_api/remove', { token: 'deploy-token', body: { capability: 'ping' } }),
|
||||
{ SIGIL_KV: mockKv, backend: pool, auth, kv },
|
||||
)
|
||||
expect((await resp.json() as any).removed).toBe('ping')
|
||||
it('should return removed capability in response', async () => {
|
||||
const req = makeRequest('DELETE', '/_api/remove', {
|
||||
token: 'deploy-token',
|
||||
body: { capability: 'ping' },
|
||||
})
|
||||
|
||||
const resp = await handleRequest(req, { SIGIL_KV: mockKv, backend: pool, auth, kv })
|
||||
const body = await resp.json() as { removed: string }
|
||||
expect(body.removed).toBe('ping')
|
||||
})
|
||||
})
|
||||
|
||||
+34
-14
@@ -1,49 +1,69 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { createMockKv, createMockCfApi, makeRequest, MockEmbeddingService } from './setup.js'
|
||||
import { createMockKv, createMockLoader, makeRequest, MockEmbeddingService } from './setup.js'
|
||||
import { WorkerPool } from '../src/backend/worker-pool.js'
|
||||
import { AuthModule } from '../src/auth.js'
|
||||
import { KvStore } from '../src/kv.js'
|
||||
import { handleRequest } from '../src/router.js'
|
||||
import { CONFIG } from '../src/config.js'
|
||||
|
||||
describe('S7: 列出能力', () => {
|
||||
describe('S7: 列出能力(已迁移至 query 接口)', () => {
|
||||
let mockKv: KVNamespace
|
||||
let mockLoader: ReturnType<typeof createMockLoader>
|
||||
let mockEmbed: MockEmbeddingService
|
||||
let pool: WorkerPool
|
||||
let auth: AuthModule
|
||||
let kv: KvStore
|
||||
|
||||
beforeEach(async () => {
|
||||
mockKv = createMockKv()
|
||||
const mockCf = createMockCfApi()
|
||||
pool = new WorkerPool(mockKv, mockCf.cfApi, new MockEmbeddingService() as any)
|
||||
mockLoader = createMockLoader()
|
||||
mockEmbed = new MockEmbeddingService()
|
||||
pool = new WorkerPool(mockKv, mockLoader.loader, mockEmbed as any)
|
||||
kv = new KvStore(mockKv)
|
||||
auth = new AuthModule(kv)
|
||||
|
||||
await auth.setToken('deploy-token')
|
||||
for (let i = 0; i < CONFIG.MAX_SLOTS; i++) await kv.setSlot(i, { capability: null, status: 'free' })
|
||||
|
||||
// Deploy some capabilities (keep <= MAX_SLOTS=3 to avoid eviction)
|
||||
for (const name of ['ping', 'echo', 'hello']) {
|
||||
await pool.deploy({ name, code: '// ' + name, type: 'normal' })
|
||||
await pool.deploy({
|
||||
name,
|
||||
code: `// ${name}`,
|
||||
type: 'normal',
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('/_api/list should return 404', async () => {
|
||||
const resp = await handleRequest(makeRequest('GET', '/_api/list', { token: 'deploy-token' }), { SIGIL_KV: mockKv, backend: pool, auth, kv })
|
||||
it('/_api/list should return 404 (removed)', async () => {
|
||||
const req = makeRequest('GET', '/_api/list', {
|
||||
token: 'deploy-token',
|
||||
})
|
||||
|
||||
const resp = await handleRequest(req, { SIGIL_KV: mockKv, backend: pool, auth, kv })
|
||||
expect(resp.status).toBe(404)
|
||||
})
|
||||
|
||||
it('/_api/query returns all capabilities', async () => {
|
||||
const resp = await handleRequest(makeRequest('GET', '/_api/query'), { SIGIL_KV: mockKv, backend: pool, auth, kv })
|
||||
it('/_api/query should return all capabilities (explore mode)', async () => {
|
||||
const req = makeRequest('GET', '/_api/query')
|
||||
|
||||
const resp = await handleRequest(req, { SIGIL_KV: mockKv, backend: pool, auth, kv })
|
||||
expect(resp.status).toBe(200)
|
||||
|
||||
const body = await resp.json() as { total: number; items: Array<{ capability: string }> }
|
||||
expect(body.total).toBe(3)
|
||||
const names = body.items.map(c => c.capability)
|
||||
expect(body.items).toHaveLength(3)
|
||||
|
||||
const names = body.items.map((c: { capability: string }) => c.capability)
|
||||
expect(names).toContain('ping')
|
||||
expect(names).toContain('echo')
|
||||
expect(names).toContain('hello')
|
||||
})
|
||||
|
||||
it('should include capability metadata', async () => {
|
||||
it('should include capability metadata in query results', async () => {
|
||||
const result = await pool.query({})
|
||||
expect(result.total).toBe(3)
|
||||
for (const item of result.items) expect(item.type).toBe('normal')
|
||||
for (const item of result.items) {
|
||||
expect(item.type).toBe('normal')
|
||||
expect(item.score).toBeGreaterThan(0)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
+28
-10
@@ -1,37 +1,55 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { createMockKv, createMockCfApi, makeRequest, MockEmbeddingService } from './setup.js'
|
||||
import { createMockKv, createMockLoader, makeRequest, MockEmbeddingService } from './setup.js'
|
||||
import { WorkerPool } from '../src/backend/worker-pool.js'
|
||||
import { AuthModule } from '../src/auth.js'
|
||||
import { KvStore } from '../src/kv.js'
|
||||
import { handleRequest } from '../src/router.js'
|
||||
import { CONFIG } from '../src/config.js'
|
||||
|
||||
describe('S8: 健康端点', () => {
|
||||
let mockKv: KVNamespace
|
||||
let mockLoader: ReturnType<typeof createMockLoader>
|
||||
let mockEmbed: MockEmbeddingService
|
||||
let pool: WorkerPool
|
||||
let auth: AuthModule
|
||||
let kv: KvStore
|
||||
|
||||
beforeEach(async () => {
|
||||
mockKv = createMockKv()
|
||||
const mockCf = createMockCfApi()
|
||||
pool = new WorkerPool(mockKv, mockCf.cfApi, new MockEmbeddingService() as any)
|
||||
mockLoader = createMockLoader()
|
||||
mockEmbed = new MockEmbeddingService()
|
||||
pool = new WorkerPool(mockKv, mockLoader.loader, mockEmbed as any)
|
||||
kv = new KvStore(mockKv)
|
||||
auth = new AuthModule(kv)
|
||||
for (let i = 0; i < CONFIG.MAX_SLOTS; i++) await kv.setSlot(i, { capability: null, status: 'free' })
|
||||
await pool.deploy({ name: 'ping', code: '// ping', type: 'normal' })
|
||||
|
||||
// Deploy some capabilities
|
||||
await pool.deploy({
|
||||
name: 'ping',
|
||||
code: '// ping',
|
||||
type: 'normal',
|
||||
})
|
||||
})
|
||||
|
||||
it('should return 200 on GET /_health', async () => {
|
||||
const resp = await handleRequest(makeRequest('GET', '/_health'), { SIGIL_KV: mockKv, backend: pool, auth, kv })
|
||||
const req = makeRequest('GET', '/_health')
|
||||
const resp = await handleRequest(req, { SIGIL_KV: mockKv, backend: pool, auth, kv })
|
||||
expect(resp.status).toBe(200)
|
||||
})
|
||||
|
||||
it('should return backend status', async () => {
|
||||
const resp = await handleRequest(makeRequest('GET', '/_health'), { SIGIL_KV: mockKv, backend: pool, auth, kv })
|
||||
const body = await resp.json() as any
|
||||
it('should return backend status fields', async () => {
|
||||
const req = makeRequest('GET', '/_health')
|
||||
const resp = await handleRequest(req, { SIGIL_KV: mockKv, backend: pool, auth, kv })
|
||||
const body = await resp.json() as {
|
||||
backend: string
|
||||
total_slots: number
|
||||
used_slots: number
|
||||
lru_enabled: boolean
|
||||
eviction_count: number
|
||||
}
|
||||
|
||||
expect(body.backend).toBe('worker-pool')
|
||||
expect(typeof body.total_slots).toBe('number')
|
||||
expect(body.total_slots).toBeGreaterThan(0)
|
||||
expect(typeof body.used_slots).toBe('number')
|
||||
expect(body.used_slots).toBe(1)
|
||||
expect(body.lru_enabled).toBe(true)
|
||||
expect(typeof body.eviction_count).toBe('number')
|
||||
|
||||
+45
-11
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { createMockKv, createMockCfApi, makeRequest, MockEmbeddingService } from './setup.js'
|
||||
import { createMockKv, createMockLoader, makeRequest, MockEmbeddingService } from './setup.js'
|
||||
import { WorkerPool } from '../src/backend/worker-pool.js'
|
||||
import { AuthModule } from '../src/auth.js'
|
||||
import { KvStore } from '../src/kv.js'
|
||||
@@ -7,35 +7,69 @@ import { handleRequest } from '../src/router.js'
|
||||
|
||||
describe('S9: 无 token 拒绝', () => {
|
||||
let mockKv: KVNamespace
|
||||
let mockLoader: ReturnType<typeof createMockLoader>
|
||||
let mockEmbed: MockEmbeddingService
|
||||
let pool: WorkerPool
|
||||
let auth: AuthModule
|
||||
let kv: KvStore
|
||||
|
||||
beforeEach(() => {
|
||||
mockKv = createMockKv()
|
||||
const mockCf = createMockCfApi()
|
||||
pool = new WorkerPool(mockKv, mockCf.cfApi, new MockEmbeddingService() as any)
|
||||
mockLoader = createMockLoader()
|
||||
mockEmbed = new MockEmbeddingService()
|
||||
pool = new WorkerPool(mockKv, mockLoader.loader, mockEmbed as any)
|
||||
kv = new KvStore(mockKv)
|
||||
auth = new AuthModule(kv)
|
||||
})
|
||||
|
||||
it('should return 401 with no token', async () => {
|
||||
const resp = await handleRequest(makeRequest('POST', '/_api/deploy', { body: { name: 'ping', code: '// ping', type: 'normal' } }), { SIGIL_KV: mockKv, backend: pool, auth, kv })
|
||||
it('should return 401 when no Authorization header', async () => {
|
||||
const req = makeRequest('POST', '/_api/deploy', {
|
||||
// No token
|
||||
body: {
|
||||
name: 'ping',
|
||||
code: '// ping',
|
||||
type: 'normal',
|
||||
},
|
||||
})
|
||||
|
||||
const resp = await handleRequest(req, { SIGIL_KV: mockKv, backend: pool, auth, kv })
|
||||
expect(resp.status).toBe(401)
|
||||
})
|
||||
|
||||
it('should return 401 with wrong token', async () => {
|
||||
const resp = await handleRequest(makeRequest('POST', '/_api/deploy', { token: 'wrong', body: { name: 'ping', code: '// ping', type: 'normal' } }), { SIGIL_KV: mockKv, backend: pool, auth, kv })
|
||||
it('should return 401 when wrong token', async () => {
|
||||
const req = makeRequest('POST', '/_api/deploy', {
|
||||
token: 'wrong-token',
|
||||
body: {
|
||||
name: 'ping',
|
||||
code: '// ping',
|
||||
type: 'normal',
|
||||
},
|
||||
})
|
||||
|
||||
const resp = await handleRequest(req, { SIGIL_KV: mockKv, backend: pool, auth, kv })
|
||||
expect(resp.status).toBe(401)
|
||||
})
|
||||
|
||||
it('should return 401 on DELETE without token', async () => {
|
||||
const resp = await handleRequest(makeRequest('DELETE', '/_api/remove', { body: { capability: 'ping' } }), { SIGIL_KV: mockKv, backend: pool, auth, kv })
|
||||
const req = makeRequest('DELETE', '/_api/remove', {
|
||||
body: { capability: 'ping' },
|
||||
})
|
||||
|
||||
const resp = await handleRequest(req, { SIGIL_KV: mockKv, backend: pool, auth, kv })
|
||||
expect(resp.status).toBe(401)
|
||||
})
|
||||
|
||||
it('should return error message', async () => {
|
||||
const resp = await handleRequest(makeRequest('POST', '/_api/deploy', { body: { name: 'ping', code: '// ping', type: 'normal' } }), { SIGIL_KV: mockKv, backend: pool, auth, kv })
|
||||
expect((await resp.json() as any).error).toBeTruthy()
|
||||
it('should return error message in body', async () => {
|
||||
const req = makeRequest('POST', '/_api/deploy', {
|
||||
body: {
|
||||
name: 'ping',
|
||||
code: '// ping',
|
||||
type: 'normal',
|
||||
},
|
||||
})
|
||||
|
||||
const resp = await handleRequest(req, { SIGIL_KV: mockKv, backend: pool, auth, kv })
|
||||
const body = await resp.json() as { error: string }
|
||||
expect(body.error).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,41 +1,52 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { createMockKv, createMockCfApi, MockEmbeddingService } from './setup.js'
|
||||
import { createMockKv, createMockLoader, MockEmbeddingService } from './setup.js'
|
||||
import { WorkerPool } from '../src/backend/worker-pool.js'
|
||||
import { KvStore } from '../src/kv.js'
|
||||
import { CONFIG } from '../src/config.js'
|
||||
|
||||
describe('S11: 并发换入', () => {
|
||||
describe('S11: 并发换入去重', () => {
|
||||
let mockKv: KVNamespace
|
||||
let mockLoader: ReturnType<typeof createMockLoader>
|
||||
let mockEmbed: MockEmbeddingService
|
||||
let pool: WorkerPool
|
||||
let kv: KvStore
|
||||
|
||||
beforeEach(async () => {
|
||||
mockKv = createMockKv()
|
||||
const mockCf = createMockCfApi({ invokeResponse: () => new Response('pong', { status: 200 }) })
|
||||
pool = new WorkerPool(mockKv, mockCf.cfApi, new MockEmbeddingService() as any)
|
||||
mockLoader = createMockLoader({
|
||||
invokeResponse: () => new Response('pong', { status: 200 }),
|
||||
})
|
||||
mockEmbed = new MockEmbeddingService()
|
||||
pool = new WorkerPool(mockKv, mockLoader.loader, mockEmbed as any)
|
||||
kv = new KvStore(mockKv)
|
||||
for (let i = 0; i < CONFIG.MAX_SLOTS; i++) await kv.setSlot(i, { capability: null, status: 'free' })
|
||||
|
||||
// Simulate evicted capability: code in KV but not deployed
|
||||
await kv.setCode('ping', "export default { fetch() { return new Response('pong') } }")
|
||||
await kv.setMeta('ping', { type: 'normal', created_at: Date.now() - 10000 })
|
||||
await kv.setLru('ping', { last_access: Date.now() - 10000, access_count: 0, deployed: false })
|
||||
await kv.setMeta('ping', {
|
||||
type: 'normal',
|
||||
created_at: Date.now() - 10000,
|
||||
})
|
||||
await kv.setLru('ping', {
|
||||
last_access: Date.now() - 10000,
|
||||
access_count: 0,
|
||||
deployed: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle concurrent page-ins without error', async () => {
|
||||
const [r1, r2] = await Promise.all([
|
||||
pool.invoke('ping', new Request('https://sigil.shazhou.workers.dev/run/ping')),
|
||||
pool.invoke('ping', new Request('https://sigil.shazhou.workers.dev/run/ping')),
|
||||
])
|
||||
expect(r1.status).toBe(200)
|
||||
expect(r2.status).toBe(200)
|
||||
})
|
||||
const req1 = new Request('https://sigil.shazhou.workers.dev/run/ping')
|
||||
const req2 = new Request('https://sigil.shazhou.workers.dev/run/ping')
|
||||
|
||||
it('should have route after concurrent page-in', async () => {
|
||||
await Promise.all([
|
||||
pool.invoke('ping', new Request('https://sigil.shazhou.workers.dev/run/ping')),
|
||||
pool.invoke('ping', new Request('https://sigil.shazhou.workers.dev/run/ping')),
|
||||
// Fire concurrently
|
||||
const [resp1, resp2] = await Promise.all([
|
||||
pool.invoke('ping', req1),
|
||||
pool.invoke('ping', req2),
|
||||
])
|
||||
const route = await kv.getRoute('ping')
|
||||
expect(route).not.toBeNull()
|
||||
expect(typeof route?.slot).toBe('number')
|
||||
|
||||
expect(resp1.status).toBe(200)
|
||||
expect(resp2.status).toBe(200)
|
||||
|
||||
// LOADER.get() should be called (at least once — may be called for each concurrent request)
|
||||
const loaderCalls = mockLoader.loaderCalls()
|
||||
expect(loaderCalls.length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,34 +1,58 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { createMockKv, createMockCfApi, MockEmbeddingService } from './setup.js'
|
||||
import { createMockKv, createMockLoader, MockEmbeddingService } from './setup.js'
|
||||
import { WorkerPool } from '../src/backend/worker-pool.js'
|
||||
import { KvStore } from '../src/kv.js'
|
||||
import { CONFIG } from '../src/config.js'
|
||||
|
||||
describe('S12: 换页操作(无速率限制)', () => {
|
||||
describe('S12: Dynamic Workers invoke(原 page-rate-limit)', () => {
|
||||
let mockKv: KVNamespace
|
||||
let mockLoader: ReturnType<typeof createMockLoader>
|
||||
let mockEmbed: MockEmbeddingService
|
||||
let pool: WorkerPool
|
||||
let kv: KvStore
|
||||
|
||||
beforeEach(async () => {
|
||||
mockKv = createMockKv()
|
||||
const mockCf = createMockCfApi({ invokeResponse: () => new Response('ok', { status: 200 }) })
|
||||
pool = new WorkerPool(mockKv, mockCf.cfApi, new MockEmbeddingService() as any)
|
||||
mockLoader = createMockLoader({
|
||||
invokeResponse: () => new Response('ok', { status: 200 }),
|
||||
})
|
||||
mockEmbed = new MockEmbeddingService()
|
||||
pool = new WorkerPool(mockKv, mockLoader.loader, mockEmbed as any)
|
||||
kv = new KvStore(mockKv)
|
||||
for (let i = 0; i < CONFIG.MAX_SLOTS; i++) await kv.setSlot(i, { capability: null, status: 'free' })
|
||||
})
|
||||
|
||||
it('should allow multiple sequential deploys', async () => {
|
||||
for (let i = 0; i < CONFIG.MAX_SLOTS; i++) {
|
||||
const result = await pool.deploy({ name: 'seq' + i, code: '// seq' + i, type: 'normal' })
|
||||
expect(result.capability).toBe('seq' + i)
|
||||
async function setupCapability(name: string): Promise<void> {
|
||||
await kv.setCode(name, `// ${name}`)
|
||||
await kv.setMeta(name, {
|
||||
type: 'normal',
|
||||
created_at: Date.now() - 10000,
|
||||
})
|
||||
await kv.setLru(name, {
|
||||
last_access: Date.now() - 10000,
|
||||
access_count: 0,
|
||||
deployed: false, // evicted
|
||||
})
|
||||
}
|
||||
|
||||
it('should invoke evicted capabilities without page-rate-limit', async () => {
|
||||
// With Dynamic Workers, there is no page rate limit — invoke always works.
|
||||
for (let i = 0; i < 15; i++) {
|
||||
const name = `cap${i}`
|
||||
await setupCapability(name)
|
||||
const req = new Request(`https://sigil.shazhou.workers.dev/run/${name}`)
|
||||
const resp = await pool.invoke(name, req)
|
||||
expect(resp.status).toBe(200)
|
||||
}
|
||||
})
|
||||
|
||||
it('should succeed page-in for cold capability', async () => {
|
||||
await kv.setCode('cold', '// cold')
|
||||
await kv.setMeta('cold', { type: 'normal', created_at: Date.now() - 10000 })
|
||||
await kv.setLru('cold', { last_access: Date.now() - 10000, access_count: 0, deployed: false })
|
||||
const resp = await pool.invoke('cold', new Request('https://sigil.shazhou.workers.dev/run/cold'))
|
||||
expect(resp.status).toBe(200)
|
||||
it('should mark cold-start capability as deployed after invoke', async () => {
|
||||
await setupCapability('cold')
|
||||
const lruBefore = await kv.getLru('cold')
|
||||
expect(lruBefore!.deployed).toBe(false)
|
||||
|
||||
const req = new Request('https://sigil.shazhou.workers.dev/run/cold')
|
||||
await pool.invoke('cold', req)
|
||||
|
||||
const lruAfter = await kv.getLru('cold')
|
||||
expect(lruAfter!.deployed).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,45 +1,85 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { createMockKv, createMockCfApi, makeRequest, MockEmbeddingService } from './setup.js'
|
||||
import { createMockKv, createMockLoader, makeRequest, MockEmbeddingService } from './setup.js'
|
||||
import { WorkerPool } from '../src/backend/worker-pool.js'
|
||||
import { AuthModule } from '../src/auth.js'
|
||||
import { KvStore } from '../src/kv.js'
|
||||
import { handleRequest } from '../src/router.js'
|
||||
import { CONFIG } from '../src/config.js'
|
||||
|
||||
describe('S13: deploy_cooldown', () => {
|
||||
let mockKv: KVNamespace
|
||||
let mockLoader: ReturnType<typeof createMockLoader>
|
||||
let mockEmbed: MockEmbeddingService
|
||||
let pool: WorkerPool
|
||||
let auth: AuthModule
|
||||
let kv: KvStore
|
||||
|
||||
beforeEach(async () => {
|
||||
mockKv = createMockKv()
|
||||
const mockCf = createMockCfApi()
|
||||
pool = new WorkerPool(mockKv, mockCf.cfApi, new MockEmbeddingService() as any)
|
||||
mockLoader = createMockLoader()
|
||||
mockEmbed = new MockEmbeddingService()
|
||||
pool = new WorkerPool(mockKv, mockLoader.loader, mockEmbed as any)
|
||||
kv = new KvStore(mockKv)
|
||||
auth = new AuthModule(kv)
|
||||
|
||||
await auth.setToken('deploy-token')
|
||||
for (let i = 0; i < CONFIG.MAX_SLOTS; i++) await kv.setSlot(i, { capability: null, status: 'free' })
|
||||
})
|
||||
|
||||
it('should reject rapid second deploy with 429', async () => {
|
||||
const r1 = await handleRequest(makeRequest('POST', '/_api/deploy', { token: 'deploy-token', body: { name: 'ping', code: '// ping', type: 'normal' } }), { SIGIL_KV: mockKv, backend: pool, auth, kv })
|
||||
expect(r1.status).toBe(201)
|
||||
const r2 = await handleRequest(makeRequest('POST', '/_api/deploy', { token: 'deploy-token', body: { name: 'ping2', code: '// ping2', type: 'normal' } }), { SIGIL_KV: mockKv, backend: pool, auth, kv })
|
||||
expect(r2.status).toBe(429)
|
||||
// First deploy
|
||||
const req1 = makeRequest('POST', '/_api/deploy', {
|
||||
token: 'deploy-token',
|
||||
body: {
|
||||
name: 'ping',
|
||||
code: '// ping',
|
||||
type: 'normal',
|
||||
},
|
||||
})
|
||||
const resp1 = await handleRequest(req1, { SIGIL_KV: mockKv, backend: pool, auth, kv })
|
||||
expect(resp1.status).toBe(201)
|
||||
|
||||
// Immediate second deploy (< 5s cooldown)
|
||||
const req2 = makeRequest('POST', '/_api/deploy', {
|
||||
token: 'deploy-token',
|
||||
body: {
|
||||
name: 'ping2',
|
||||
code: '// ping2',
|
||||
type: 'normal',
|
||||
},
|
||||
})
|
||||
const resp2 = await handleRequest(req2, { SIGIL_KV: mockKv, backend: pool, auth, kv })
|
||||
expect(resp2.status).toBe(429)
|
||||
})
|
||||
|
||||
it('should include retry_after in 429', async () => {
|
||||
await handleRequest(makeRequest('POST', '/_api/deploy', { token: 'deploy-token', body: { name: 'ping', code: '// ping', type: 'normal' } }), { SIGIL_KV: mockKv, backend: pool, auth, kv })
|
||||
const r2 = await handleRequest(makeRequest('POST', '/_api/deploy', { token: 'deploy-token', body: { name: 'ping2', code: '// ping2', type: 'normal' } }), { SIGIL_KV: mockKv, backend: pool, auth, kv })
|
||||
const body = await r2.json() as any
|
||||
it('should include retry_after in 429 response', async () => {
|
||||
// First deploy
|
||||
const req1 = makeRequest('POST', '/_api/deploy', {
|
||||
token: 'deploy-token',
|
||||
body: { name: 'ping', code: '// ping', type: 'normal' },
|
||||
})
|
||||
await handleRequest(req1, { SIGIL_KV: mockKv, backend: pool, auth, kv })
|
||||
|
||||
// Immediate second
|
||||
const req2 = makeRequest('POST', '/_api/deploy', {
|
||||
token: 'deploy-token',
|
||||
body: { name: 'ping2', code: '// ping2', type: 'normal' },
|
||||
})
|
||||
const resp2 = await handleRequest(req2, { SIGIL_KV: mockKv, backend: pool, auth, kv })
|
||||
const body = await resp2.json() as { error: string; retry_after: number }
|
||||
|
||||
expect(body.retry_after).toBeGreaterThan(0)
|
||||
expect(body.retry_after).toBeLessThanOrEqual(5)
|
||||
})
|
||||
|
||||
it('should allow deploy after cooldown expires', async () => {
|
||||
await kv.setLastDeployTime(Date.now() - 10000)
|
||||
const resp = await handleRequest(makeRequest('POST', '/_api/deploy', { token: 'deploy-token', body: { name: 'ping', code: '// ping', type: 'normal' } }), { SIGIL_KV: mockKv, backend: pool, auth, kv })
|
||||
// Manually set last deploy time as already expired
|
||||
await kv.setLastDeployTime(Date.now() - 10000) // 10s ago, past 5s cooldown
|
||||
|
||||
const req = makeRequest('POST', '/_api/deploy', {
|
||||
token: 'deploy-token',
|
||||
body: { name: 'ping', code: '// ping', type: 'normal' },
|
||||
})
|
||||
|
||||
const resp = await handleRequest(req, { SIGIL_KV: mockKv, backend: pool, auth, kv })
|
||||
expect(resp.status).toBe(201)
|
||||
})
|
||||
})
|
||||
|
||||
+180
-58
@@ -1,104 +1,226 @@
|
||||
// Dynamic Workers backend test setup.
|
||||
// MockLoader simulates the CF LOADER binding (worker_loaders).
|
||||
|
||||
import { EmbeddingService } from '../src/embedding.js'
|
||||
import type { CfApi } from '../src/cf-api.js'
|
||||
|
||||
export interface MockKvEntry { value: string; metadata?: unknown }
|
||||
|
||||
export interface MockLoaderGetCall {
|
||||
workerId: string;
|
||||
getCodeCalled: boolean;
|
||||
}
|
||||
|
||||
interface WorkerStub {
|
||||
invoke(request: Request): Promise<Response>;
|
||||
}
|
||||
|
||||
interface WorkerLoader {
|
||||
get(workerId: string, getCode?: () => any): WorkerStub;
|
||||
export interface MockKvEntry {
|
||||
value: string
|
||||
metadata?: unknown
|
||||
}
|
||||
|
||||
/**
|
||||
* In-memory KVNamespace mock.
|
||||
*/
|
||||
export function createMockKv(): KVNamespace {
|
||||
const store = new Map<string, MockKvEntry>()
|
||||
|
||||
return {
|
||||
async get(key: string, options?: { type?: string } | string): Promise<unknown> {
|
||||
const entry = store.get(key); if (!entry) return null
|
||||
const type = typeof options === 'string' ? options : (options as any)?.type ?? 'text'
|
||||
if (type === 'json') { try { return JSON.parse(entry.value) } catch { return null } }
|
||||
const entry = store.get(key)
|
||||
if (!entry) return null
|
||||
|
||||
const type = typeof options === 'string' ? options : options?.type ?? 'text'
|
||||
|
||||
if (type === 'json') {
|
||||
try {
|
||||
return JSON.parse(entry.value)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
if (type === 'arrayBuffer') {
|
||||
return new TextEncoder().encode(entry.value).buffer
|
||||
}
|
||||
if (type === 'stream') {
|
||||
return new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode(entry.value))
|
||||
controller.close()
|
||||
},
|
||||
})
|
||||
}
|
||||
return entry.value
|
||||
},
|
||||
async getWithMetadata(key: string, options?: any): Promise<{ value: unknown; metadata: unknown }> {
|
||||
const entry = store.get(key); if (!entry) return { value: null, metadata: null }
|
||||
|
||||
async getWithMetadata(key: string, options?: { type?: string } | string): Promise<{ value: unknown; metadata: unknown }> {
|
||||
const entry = store.get(key)
|
||||
if (!entry) return { value: null, metadata: null }
|
||||
const type = typeof options === 'string' ? options : options?.type ?? 'text'
|
||||
let value: unknown = entry.value; if (type === 'json') value = JSON.parse(entry.value)
|
||||
let value: unknown = entry.value
|
||||
if (type === 'json') value = JSON.parse(entry.value)
|
||||
return { value, metadata: entry.metadata ?? null }
|
||||
},
|
||||
async put(key: string, value: any, options?: any): Promise<void> {
|
||||
let strVal = typeof value === 'string' ? value : (value instanceof ArrayBuffer ? new TextDecoder().decode(value) : String(value))
|
||||
|
||||
async put(key: string, value: string | ArrayBuffer | ArrayBufferView | ReadableStream, options?: { expiration?: number; expirationTtl?: number; metadata?: unknown }): Promise<void> {
|
||||
let strVal: string
|
||||
if (typeof value === 'string') {
|
||||
strVal = value
|
||||
} else if (value instanceof ArrayBuffer) {
|
||||
strVal = new TextDecoder().decode(value)
|
||||
} else {
|
||||
strVal = String(value)
|
||||
}
|
||||
store.set(key, { value: strVal, metadata: options?.metadata })
|
||||
},
|
||||
async delete(key: string): Promise<void> { store.delete(key) },
|
||||
async list(options?: any): Promise<KVNamespaceListResult<unknown, string>> {
|
||||
const prefix = options?.prefix ?? ''; const limit = options?.limit ?? 1000
|
||||
const keys = Array.from(store.keys()).filter(k => k.startsWith(prefix)).slice(0, limit).map(name => ({ name, expiration: undefined, metadata: undefined }))
|
||||
return { keys, list_complete: true, cursor: '', cacheStatus: null }
|
||||
|
||||
async delete(key: string): Promise<void> {
|
||||
store.delete(key)
|
||||
},
|
||||
|
||||
async list(options?: { prefix?: string; limit?: number; cursor?: string }): Promise<KVNamespaceListResult<unknown, string>> {
|
||||
const prefix = options?.prefix ?? ''
|
||||
const limit = options?.limit ?? 1000
|
||||
const keys = Array.from(store.keys())
|
||||
.filter(k => k.startsWith(prefix))
|
||||
.slice(0, limit)
|
||||
.map(name => ({ name, expiration: undefined, metadata: undefined }))
|
||||
|
||||
return {
|
||||
keys,
|
||||
list_complete: true,
|
||||
cursor: '',
|
||||
cacheStatus: null,
|
||||
}
|
||||
},
|
||||
} as unknown as KVNamespace
|
||||
}
|
||||
|
||||
export function createMockCfApi(overrides?: {
|
||||
invokeResponse?: (slotIndex: number, request: Request) => Response | Promise<Response>
|
||||
export interface MockLoaderGetCall {
|
||||
workerId: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock Dynamic Workers LOADER binding.
|
||||
* Records LOADER.get() calls and returns a mock Worker whose
|
||||
* getEntrypoint().fetch() delegates to the provided invokeResponse factory.
|
||||
*
|
||||
* NOTE: CF LOADER.get() is synchronous — returns a Worker instance directly.
|
||||
* The mock mirrors this behavior.
|
||||
*/
|
||||
export function createMockLoader(overrides?: {
|
||||
invokeResponse?: (workerId: string, request: Request) => Response | Promise<Response>
|
||||
}) {
|
||||
const calls: Array<{ method: string; slotIndex: number; code?: string }> = []
|
||||
const cfApi: CfApi = {
|
||||
async updateSlotCode(slotIndex: number, code: string): Promise<void> { calls.push({ method: 'updateSlotCode', slotIndex, code }) },
|
||||
async initSlot(slotIndex: number): Promise<void> { calls.push({ method: 'initSlot', slotIndex }) },
|
||||
getSlotSubdomain(slotIndex: number): string { return `s-slot-${slotIndex}.test.workers.dev` },
|
||||
async invoke(slotIndex: number, request: Request): Promise<Response> {
|
||||
calls.push({ method: 'invoke', slotIndex })
|
||||
if (overrides?.invokeResponse) return overrides.invokeResponse(slotIndex, request)
|
||||
const getCalls: MockLoaderGetCall[] = []
|
||||
|
||||
// Synchronous LOADER mock — matches the real CF LOADER.get() API
|
||||
const loaderBinding = {
|
||||
get(workerId: string, _loadFn: () => { compatibilityDate: string; mainModule: string; modules: Record<string, string>; globalOutbound: null }) {
|
||||
getCalls.push({ workerId })
|
||||
return {
|
||||
getEntrypoint() {
|
||||
return {
|
||||
async fetch(request: Request): Promise<Response> {
|
||||
if (overrides?.invokeResponse) {
|
||||
return overrides.invokeResponse(workerId, request)
|
||||
}
|
||||
return new Response('mock response', { status: 200 })
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
return {
|
||||
cfApi, calls,
|
||||
updateSlotCodeCalls() { return calls.filter(c => c.method === 'updateSlotCode').map(c => ({ slotIndex: c.slotIndex, code: c.code! })) },
|
||||
invokeCalls() { return calls.filter(c => c.method === 'invoke').map(c => c.slotIndex) },
|
||||
reset() { calls.length = 0 },
|
||||
getCalls,
|
||||
|
||||
/** The LOADER binding to pass to WorkerPool constructor */
|
||||
loader: loaderBinding,
|
||||
|
||||
loaderCalls(): string[] {
|
||||
return getCalls.map(c => c.workerId)
|
||||
},
|
||||
|
||||
reset(): void {
|
||||
getCalls.length = 0
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function makeRequest(method: string, path: string, options?: {
|
||||
body?: unknown; token?: string; headers?: Record<string, string>
|
||||
}): Request {
|
||||
/**
|
||||
* Create a test request helper.
|
||||
*/
|
||||
export function makeRequest(
|
||||
method: string,
|
||||
path: string,
|
||||
options?: {
|
||||
body?: unknown
|
||||
token?: string
|
||||
headers?: Record<string, string>
|
||||
},
|
||||
): Request {
|
||||
const url = `https://sigil.shazhou.workers.dev${path}`
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json', ...options?.headers }
|
||||
if (options?.token) headers['Authorization'] = `Bearer ${options.token}`
|
||||
const init: RequestInit = { method, headers }
|
||||
if (options?.body !== undefined) init.body = JSON.stringify(options.body)
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
...options?.headers,
|
||||
}
|
||||
|
||||
if (options?.token) {
|
||||
headers['Authorization'] = `Bearer ${options.token}`
|
||||
}
|
||||
|
||||
const init: RequestInit = {
|
||||
method,
|
||||
headers,
|
||||
}
|
||||
|
||||
if (options?.body !== undefined) {
|
||||
init.body = JSON.stringify(options.body)
|
||||
}
|
||||
|
||||
return new Request(url, init)
|
||||
}
|
||||
|
||||
// Simple deterministic hash (for mock vectors)
|
||||
function simpleHash(text: string): number {
|
||||
let h = 0x811c9dc5
|
||||
for (let i = 0; i < text.length; i++) { h ^= text.charCodeAt(i); h = (h * 0x01000193) >>> 0 }
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
h ^= text.charCodeAt(i)
|
||||
h = (h * 0x01000193) >>> 0
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
// Generate a deterministic unit vector of given dimension
|
||||
function generateDeterministicVector(seed: number, dim: number): number[] {
|
||||
const vec: number[] = []; let s = seed
|
||||
for (let i = 0; i < dim; i++) { s = (s * 1664525 + 1013904223) >>> 0; vec.push((s / 0xffffffff) * 2 - 1) }
|
||||
const vec: number[] = []
|
||||
let s = seed
|
||||
for (let i = 0; i < dim; i++) {
|
||||
s = (s * 1664525 + 1013904223) >>> 0
|
||||
vec.push((s / 0xffffffff) * 2 - 1)
|
||||
}
|
||||
const norm = Math.sqrt(vec.reduce((a, x) => a + x * x, 0))
|
||||
return vec.map(x => x / norm)
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock EmbeddingService for unit tests.
|
||||
* Returns deterministic vectors. Supports manual vector overrides
|
||||
* to simulate semantic similarity.
|
||||
*/
|
||||
export class MockEmbeddingService {
|
||||
private overrides = new Map<string, number[]>()
|
||||
static buildCapabilityText(params: any): string { return EmbeddingService.buildCapabilityText(params) }
|
||||
setVector(k: string, v: number[]): void { this.overrides.set(k, v) }
|
||||
|
||||
static buildCapabilityText(params: any): string {
|
||||
return EmbeddingService.buildCapabilityText(params)
|
||||
}
|
||||
|
||||
setVector(textOrKey: string, vector: number[]): void {
|
||||
this.overrides.set(textOrKey, vector)
|
||||
}
|
||||
|
||||
async embed(text: string): Promise<number[]> {
|
||||
if (this.overrides.has(text)) return this.overrides.get(text)!
|
||||
return generateDeterministicVector(simpleHash(text), 768)
|
||||
if (this.overrides.has(text)) {
|
||||
return this.overrides.get(text)!
|
||||
}
|
||||
async embedQuery(q: string): Promise<number[]> {
|
||||
if (this.overrides.has(q)) return this.overrides.get(q)!
|
||||
return this.embed(q)
|
||||
const hash = simpleHash(text)
|
||||
return generateDeterministicVector(hash, 768)
|
||||
}
|
||||
|
||||
async embedQuery(query: string): Promise<number[]> {
|
||||
if (this.overrides.has(query)) {
|
||||
return this.overrides.get(query)!
|
||||
}
|
||||
return this.embed(query)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user