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:
2026-04-03 10:57:50 +00:00
parent 9f0c303056
commit e86bae8d4a
20 changed files with 1130 additions and 644 deletions
+213 -113
View File
@@ -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 { SigilBackend, DeployParams, DeployResult, Capability, BackendStatus, QueryParams, QueryResult, QueryItem } from './types.js'
import type { CfApi } from '../cf-api.js'
import { KvStore } from '../kv.js' import { KvStore } from '../kv.js'
import { LruScheduler } from '../lru.js' import { LruScheduler } from '../lru.js'
import { CONFIG } from '../config.js' import { CONFIG } from '../config.js'
import { EmbeddingService, cosineSimilarity, mmrSelect } from '../embedding.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 { export class WorkerPool implements SigilBackend {
private kv: KvStore private kv: KvStore
@@ -15,111 +17,136 @@ export class WorkerPool implements SigilBackend {
private embeddingService: EmbeddingService private embeddingService: EmbeddingService
private config = CONFIG 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.kv = new KvStore(kv)
this.lru = new LruScheduler(this.kv) this.lru = new LruScheduler(this.kv)
this.embeddingService = embeddingService this.embeddingService = embeddingService
} }
private async generateHash(input: string): Promise<string> { 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) 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) const hashArray = Array.from(new Uint8Array(hashBuffer))
} return hashArray.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 })
} }
async deploy(params: DeployParams): Promise<DeployResult> { async deploy(params: DeployParams): Promise<DeployResult> {
const { name, code, schema, type, ttl, bindings, description, tags, examples } = params 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 now = Date.now()
const [slotIndex, evictedCapability] = await this.acquireSlot()
await this.cfApi.updateSlotCode(slotIndex, code) // LRU eviction: mark oldest as not-deployed when quota exceeded
await this.kv.setSlot(slotIndex, { capability, status: 'active' }) let deployed = await this.lru.countDeployed()
await this.kv.setRoute(capability, { slot: slotIndex }) 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.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.setLru(capability, { last_access: now, access_count: 0, deployed: true })
await this.kv.setRoute(capability, { worker_name: capability, subdomain: '' })
// Compute and store embedding
try { try {
const text = EmbeddingService.buildCapabilityText({ name: capability, description, tags, examples }) const text = EmbeddingService.buildCapabilityText({ name: capability, description, tags, examples })
await this.kv.setEmbedding(capability, await this.embeddingService.embed(text)) const vector = await this.embeddingService.embed(text)
} catch (e) { console.error('[sigil] embedding error:', e) } await this.kv.setEmbedding(capability, vector)
const url = this.config.GATEWAY_URL + '/run/' + capability } 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 } 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 return result
} }
async invoke(capabilityName: string, request: Request): Promise<Response> { async invoke(capabilityName: string, request: Request): Promise<Response> {
const lru = await this.kv.getLru(capabilityName) const code = await this.kv.getCode(capabilityName)
if (!lru) { if (!code) {
const code = await this.kv.getCode(capabilityName) return new Response(JSON.stringify({ error: 'Capability not found' }), {
if (!code) return new Response(JSON.stringify({ error: 'Capability not found' }), { status: 404, headers: { 'Content-Type': 'application/json' } }) 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
}
private async pageInAndInvoke(capabilityName: string, code: string, request: Request, isColdStart: boolean): Promise<Response> { const lru = await this.kv.getLru(capabilityName)
const [slotIndex] = await this.acquireSlot() const isColdStart = !lru?.deployed
await this.cfApi.updateSlotCode(slotIndex, code)
await this.kv.setSlot(slotIndex, { capability: capabilityName, status: 'active' }) // Update LRU access stats
await this.kv.setRoute(capabilityName, { slot: slotIndex }) await this.kv.setLru(capabilityName, {
const existingLru = await this.kv.getLru(capabilityName) last_access: Date.now(),
await this.kv.setLru(capabilityName, { last_access: Date.now(), access_count: (existingLru?.access_count ?? 0) + 1, deployed: true }) access_count: (lru?.access_count ?? 0) + 1,
const response = await this.cfApi.invoke(slotIndex, request) deployed: true,
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 }) // 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 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' },
})
} }
return response
} }
async remove(capabilityName: string): Promise<void> { 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.deleteCode(capabilityName)
await this.kv.deleteMeta(capabilityName) await this.kv.deleteMeta(capabilityName)
await this.kv.deleteLru(capabilityName) await this.kv.deleteLru(capabilityName)
@@ -130,60 +157,133 @@ export class WorkerPool implements SigilBackend {
async query(params: QueryParams): Promise<QueryResult> { async query(params: QueryParams): Promise<QueryResult> {
const { q, mode: rawMode, limit: rawLimit, cursor } = params const { q, mode: rawMode, limit: rawLimit, cursor } = params
const mode = rawMode ?? (q ? 'find' : 'explore') 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() const caps = await this.kv.listCapabilities()
if (!q) { if (!q) {
const allCaps: Capability[] = [] const allCapabilities: Capability[] = []
for (const cap of caps) { 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 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 } const capability: Capability = {
if (meta.ttl !== undefined) { c.ttl = meta.ttl; c.expires_at = new Date(meta.created_at + meta.ttl * 1000).toISOString() } capability: cap, type: meta.type, deployed: lru.deployed,
allCaps.push(c) 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) {
capability.ttl = meta.ttl
capability.expires_at = new Date(meta.created_at + meta.ttl * 1000).toISOString()
}
allCapabilities.push(capability)
} }
const sorted = allCaps.sort((a, b) => b.created_at - a.created_at) const sorted = allCapabilities.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 })) 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 const offset = cursor ? parseInt(cursor, 10) : 0
return { total: items.length, items: items.slice(offset, offset + limit) } return { total: items.length, items: items.slice(offset, offset + limit) }
} }
const queryVec = await this.embeddingService.embedQuery(q) const queryVec = await this.embeddingService.embedQuery(q)
const embCands: Array<{ capability: string; vector: number[]; meta: any; lru: any }> = [] const embeddingCandidates: Array<{ capability: string; vector: number[]; meta: any; lru: any }> = []
const fbCands: Capability[] = [] const fallbackCandidates: Capability[] = []
for (const cap of caps) { 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 (!meta || !lru) continue
if (vector) embCands.push({ capability: cap, vector, meta, lru }) if (vector) {
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 }) 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 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 })) const fallbackItems: QueryItem[] = fallbackCandidates
if ((mode === 'find' && q) || mode === 'find') { .filter(cap => (
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) cap.capability.toLowerCase().includes(qLower) ||
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 })) cap.description?.toLowerCase().includes(qLower) ||
const embCaps = new Set(embItems.map(i => i.capability)) cap.tags?.some(t => t.toLowerCase().includes(qLower))
const items = [...embItems, ...fbItems.filter(i => !embCaps.has(i.capability))].sort((a, b) => b.score - a.score).slice(0, limit) ))
.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 const offset = cursor ? parseInt(cursor, 10) : 0
return { total: items.length, items: items.slice(offset, offset + limit) } 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> { 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 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 } const capability: Capability = {
if (meta.ttl !== undefined) { c.ttl = meta.ttl; c.expires_at = new Date(meta.created_at + meta.ttl * 1000).toISOString() } capability: capabilityName, type: meta.type, deployed: lru.deployed,
return c 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> { async status(): Promise<BackendStatus> {
const caps = await this.kv.listCapabilities()
let usedSlots = 0 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++ } for (const cap of caps) {
return { backend: 'worker-pool', total_slots: this.config.MAX_SLOTS, used_slots: usedSlots, lru_enabled: true, eviction_count: await this.kv.getEvictionCount() } 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,
}
} }
} }
-82
View File
@@ -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
View File
@@ -1,10 +1,8 @@
export const CONFIG = { export const CONFIG = {
MAX_SLOTS: 3, // 预分配 slot 数量(物理页帧总数) MAX_SLOTS: 100,
DEPLOY_COOLDOWN_MS: 5000, DEPLOY_COOLDOWN_MS: 5000,
PAGE_RATE_LIMIT: 10, // 次/分钟 PAGE_RATE_LIMIT: 10,
PAGE_RATE_WINDOW_MS: 60000, PAGE_RATE_WINDOW_MS: 60000,
HASH_LENGTH: 6, 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', GATEWAY_URL: 'https://sigil.shazhou.workers.dev',
} as const } as const
+7 -8
View File
@@ -2,29 +2,28 @@ import { WorkerPool } from './backend/worker-pool.js'
import { AuthModule } from './auth.js' import { AuthModule } from './auth.js'
import { KvStore } from './kv.js' import { KvStore } from './kv.js'
import { handleRequest } from './router.js' import { handleRequest } from './router.js'
import { createCfApi } from './cf-api.js'
import { EmbeddingService } from './embedding.js' import { EmbeddingService } from './embedding.js'
export interface Env { export interface Env {
SIGIL_KV: KVNamespace SIGIL_KV: KVNamespace
AI: any AI: any // Cloudflare Workers AI binding
CF_API_TOKEN: string LOADER: any // Dynamic Workers Loader binding (worker_loaders)
CF_ACCOUNT_ID: string
} }
export default { export default {
async fetch(request: Request, env: Env): Promise<Response> { async fetch(request: Request, env: Env): Promise<Response> {
const kv = new KvStore(env.SIGIL_KV) 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 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) const auth = new AuthModule(kv)
try { 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) { } catch (e) {
console.error('[sigil] unhandled error:', e) console.error('[sigil] unhandled error:', e)
return new Response(JSON.stringify({ error: 'Internal server error' }), { return new Response(JSON.stringify({ error: 'Internal server error' }), {
status: 500, headers: { 'Content-Type': 'application/json' }, status: 500,
headers: { 'Content-Type': 'application/json' },
}) })
} }
}, },
+3 -34
View File
@@ -1,7 +1,6 @@
// KV key prefixes and data types // KV key prefixes and data types
import type { InputSchema } from './codegen.js' import type { InputSchema } from './codegen.js'
import { CONFIG } from './config.js'
export interface KvCodeValue { export interface KvCodeValue {
code: string code: string
@@ -24,15 +23,9 @@ export interface KvLruValue {
deployed: boolean deployed: boolean
} }
// slot:{n} — 槽位状态(物理页帧)
export interface KvSlotValue {
capability: string | null
status: 'active' | 'free'
}
// route:{capability} — 存 slot index
export interface KvRouteValue { export interface KvRouteValue {
slot: number worker_name: string
subdomain: string
} }
export interface KvAuthValue { export interface KvAuthValue {
@@ -86,7 +79,7 @@ export class KvStore {
await this.kv.delete(`lru:${capability}`) await this.kv.delete(`lru:${capability}`)
} }
// route:{capability} — 存 slot index // route:{capability}
async getRoute(capability: string): Promise<KvRouteValue | null> { async getRoute(capability: string): Promise<KvRouteValue | null> {
return await this.kv.get(`route:${capability}`, 'json') as 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}`) 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 // auth:deploy-token
async getDeployToken(): Promise<KvAuthValue | null> { async getDeployToken(): Promise<KvAuthValue | null> {
return await this.kv.get('auth:deploy-token', 'json') as KvAuthValue | null return await this.kv.get('auth:deploy-token', 'json') as KvAuthValue | null
+110 -56
View File
@@ -1,9 +1,7 @@
import type { SigilBackend } from './backend/types.js' import type { SigilBackend } from './backend/types.js'
import type { CfApi } from './cf-api.js'
import { AuthModule, AuthError, DeployCooldownError } from './auth.js' import { AuthModule, AuthError, DeployCooldownError } from './auth.js'
import { KvStore } from './kv.js' import { KvStore } from './kv.js'
import { generateWorkerCode } from './codegen.js' import { generateWorkerCode } from './codegen.js'
import { CONFIG } from './config.js'
import type { InputSchema } from './codegen.js' import type { InputSchema } from './codegen.js'
export interface RouterEnv { export interface RouterEnv {
@@ -11,7 +9,6 @@ export interface RouterEnv {
backend: SigilBackend backend: SigilBackend
auth: AuthModule auth: AuthModule
kv: KvStore kv: KvStore
cfApi?: CfApi
} }
export async function handleRequest(request: Request, env: RouterEnv): Promise<Response> { 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 path = url.pathname
const method = request.method const method = request.method
if (method === 'GET' && path === '/_health') return handleHealth(env) // GET /_health
if (method === 'POST' && path === '/_api/deploy') return handleDeploy(request, env) if (method === 'GET' && path === '/_health') {
if (method === 'DELETE' && path === '/_api/remove') return handleRemove(request, env) return handleHealth(env)
if (method === 'GET' && path === '/_api/query') return handleQuery(request, env) }
if (method === 'POST' && path === '/_api/init-slots') return handleInitSlots(request, 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\/(.+)$/) 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\/([^/]+)$/) 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') return jsonError(404, 'Not found')
} }
async function handleHealth(env: RouterEnv): Promise<Response> { 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> { async function handleDeploy(request: Request, env: RouterEnv): Promise<Response> {
try { 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 { const body = await request.json() as {
name: string | null; code?: string; schema?: InputSchema; execute?: string name: string | null
type: 'persistent' | 'normal' | 'ephemeral'; ttl?: number; bindings?: string[] code?: string
description?: string; tags?: string[]; examples?: 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 code: string
let schema: InputSchema | undefined let schema: InputSchema | undefined
if (body.code) { if (body.code) {
code = body.code code = body.code
} else { } 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: {} } schema = body.schema || { type: 'object', properties: {} }
code = generateWorkerCode(schema, body.execute) code = generateWorkerCode(schema, body.execute)
} }
// Check deploy cooldown
await env.auth.checkDeployCooldown() 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() await env.auth.setDeployCooldown()
return jsonOk(result, 201) return jsonOk(result, 201)
} catch (e) { } catch (e) {
if (e instanceof AuthError) return jsonError(e.status, e.message) if (e instanceof AuthError) {
if (e instanceof DeployCooldownError) return jsonError(429, 'Deploy cooldown active', { retry_after: e.retry_after }) return jsonError(e.status, e.message)
}
if (e instanceof DeployCooldownError) {
return jsonError(429, 'Deploy cooldown active', { retry_after: e.retry_after })
}
throw e throw e
} }
} }
async function handleRemove(request: Request, env: RouterEnv): Promise<Response> { async function handleRemove(request: Request, env: RouterEnv): Promise<Response> {
try { 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 } const body = await request.json() as { capability: string }
await env.backend.remove(body.capability) const capability = body.capability
return jsonOk({ removed: body.capability })
await env.backend.remove(capability)
return jsonOk({ removed: capability })
} catch (e) { } catch (e) {
if (e instanceof AuthError) return jsonError(e.status, e.message) if (e instanceof AuthError) {
return jsonError(e.status, e.message)
}
throw e throw e
} }
} }
@@ -88,49 +153,38 @@ async function handleQuery(request: Request, env: RouterEnv): Promise<Response>
const limitRaw = url.searchParams.get('limit') const limitRaw = url.searchParams.get('limit')
const limit = limitRaw ? parseInt(limitRaw, 10) : undefined const limit = limitRaw ? parseInt(limitRaw, 10) : undefined
const cursor = url.searchParams.get('cursor') ?? 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> { async function handleInspect(capability: string, env: RouterEnv): Promise<Response> {
const result = await env.backend.inspect(capability) 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) 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) 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 { 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 { 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
View File
@@ -1,5 +1,5 @@
import { describe, it, expect, beforeEach } from 'vitest' 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 { WorkerPool } from '../src/backend/worker-pool.js'
import { AuthModule } from '../src/auth.js' import { AuthModule } from '../src/auth.js'
import { KvStore } from '../src/kv.js' import { KvStore } from '../src/kv.js'
@@ -7,7 +7,7 @@ import { handleRequest } from '../src/router.js'
describe('Query API', () => { describe('Query API', () => {
let mockKv: KVNamespace let mockKv: KVNamespace
let mockCf: ReturnType<typeof createMockCfApi> let mockLoader: ReturnType<typeof createMockLoader>
let mockEmbed: MockEmbeddingService let mockEmbed: MockEmbeddingService
let pool: WorkerPool let pool: WorkerPool
let auth: AuthModule let auth: AuthModule
@@ -15,14 +15,13 @@ describe('Query API', () => {
beforeEach(async () => { beforeEach(async () => {
mockKv = createMockKv() mockKv = createMockKv()
mockCf = createMockCfApi() mockLoader = createMockLoader()
mockEmbed = new MockEmbeddingService() 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) kv = new KvStore(mockKv)
auth = new AuthModule(kv) auth = new AuthModule(kv)
await auth.setToken('deploy-token') await auth.setToken('deploy-token')
for (let __qi = 0; __qi < 3; __qi++) await kv.setSlot(__qi, { capability: null, status: "free" })
// Deploy capabilities with metadata // Deploy capabilities with metadata
await pool.deploy({ await pool.deploy({
@@ -112,12 +111,11 @@ describe('Query API', () => {
// Re-deploy with the new overrides in place // Re-deploy with the new overrides in place
const mockKv2 = createMockKv() const mockKv2 = createMockKv()
const mockCf2 = createMockCfApi() const mockLoader2 = createMockLoader()
const pool2 = new WorkerPool(mockKv2, mockCf2.cfApi, mockEmbed as any) const pool2 = new WorkerPool(mockKv2, mockLoader2.loader, mockEmbed as any)
const kv2 = new KvStore(mockKv2) const kv2 = new KvStore(mockKv2)
const auth2 = new AuthModule(kv2) const auth2 = new AuthModule(kv2)
await auth2.setToken('deploy-token') await auth2.setToken('deploy-token')
for (let __qj = 0; __qj < 3; __qj++) await kv2.setSlot(__qj, { capability: null, status: "free" })
await pool2.deploy({ await pool2.deploy({
name: 'currency', name: 'currency',
@@ -178,7 +176,7 @@ describe('Query API', () => {
return mockEmbed.embedQuery(q) 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({}) const result = await pool2.query({})
expect(embedCalled).toBe(false) expect(embedCalled).toBe(false)
expect(result.total).toBe(3) expect(result.total).toBe(3)
+133 -52
View File
@@ -1,14 +1,13 @@
import { describe, it, expect, beforeEach } from 'vitest' 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 { WorkerPool } from '../src/backend/worker-pool.js'
import { AuthModule } from '../src/auth.js' import { AuthModule } from '../src/auth.js'
import { KvStore } from '../src/kv.js' import { KvStore } from '../src/kv.js'
import { handleRequest } from '../src/router.js' import { handleRequest } from '../src/router.js'
import { CONFIG } from '../src/config.js'
describe('S1: 部署能力', () => { describe('S1: 部署能力', () => {
let mockKv: KVNamespace let mockKv: KVNamespace
let mockCf: ReturnType<typeof createMockCfApi> let mockLoader: ReturnType<typeof createMockLoader>
let mockEmbed: MockEmbeddingService let mockEmbed: MockEmbeddingService
let pool: WorkerPool let pool: WorkerPool
let auth: AuthModule let auth: AuthModule
@@ -16,100 +15,182 @@ describe('S1: 部署能力', () => {
beforeEach(async () => { beforeEach(async () => {
mockKv = createMockKv() mockKv = createMockKv()
mockCf = createMockCfApi() mockLoader = createMockLoader()
mockEmbed = new MockEmbeddingService() 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) kv = new KvStore(mockKv)
auth = new AuthModule(kv) auth = new AuthModule(kv)
// Set unified deploy token
await auth.setToken('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', { const req = makeRequest('POST', '/_api/deploy', {
token: 'deploy-token', 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 }) const resp = await handleRequest(req, { SIGIL_KV: mockKv, backend: pool, auth, kv })
expect(resp.status).toBe(201) 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.capability).toBe('ping')
expect(body.url).toBe('https://sigil.shazhou.workers.dev/run/ping') expect(body.url).toBe('https://sigil.shazhou.workers.dev/run/ping')
expect(body.cold_start).toBe(false) expect(body.cold_start).toBe(false)
}) })
it('should call updateSlotCode on deploy', async () => { 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' }) await pool.deploy({
const updates = mockCf.updateSlotCodeCalls() name: 'ping',
expect(updates).toHaveLength(1) code: "export default { fetch() { return new Response('pong') } }",
expect(updates[0]!.slotIndex).toBeGreaterThanOrEqual(0) type: 'normal',
expect(updates[0]!.slotIndex).toBeLessThan(CONFIG.MAX_SLOTS) })
// LOADER.get() should NOT be called during deploy — only during invoke
expect(mockLoader.loaderCalls()).toHaveLength(0)
}) })
it('should NOT call cfApi.invoke during deploy', async () => { it('should write KV entries (code, meta, lru)', async () => {
await pool.deploy({ name: 'ping', code: "export default { fetch() { return new Response('pong') } }", type: 'normal' }) await pool.deploy({
expect(mockCf.invokeCalls()).toHaveLength(0) 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')
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')
const lru = await kv.getLru('ping') const lru = await kv.getLru('ping')
expect(lru?.deployed).toBe(true) expect(lru?.deployed).toBe(true)
expect(lru?.access_count).toBe(0) 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 () => { // --- 模式 B: schema + execute ---
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')
})
it('模式 B: schema + execute', async () => { it('模式 B: schema + execute 通过 API 部署', async () => {
const req = makeRequest('POST', '/_api/deploy', { const req = makeRequest('POST', '/_api/deploy', {
token: 'deploy-token', token: 'deploy-token',
body: { name: 'adder', type: 'normal', body: {
schema: { type: 'object', properties: { a: { type: 'number' }, b: { type: 'number' } }, required: ['a','b'] }, name: 'adder',
execute: 'return JSON.stringify({ sum: input.a + input.b })' }, 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 }) const resp = await handleRequest(req, { SIGIL_KV: mockKv, backend: pool, auth, kv })
expect(resp.status).toBe(201) 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', { const req = makeRequest('POST', '/_api/deploy', {
token: 'deploy-token', token: 'deploy-token',
body: { name: 'greeter', type: 'normal', body: {
schema: { type: 'object', properties: { name: { type: 'string' } } }, name: 'greeter',
execute: 'return "Hello, " + input.name + "!"' }, 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 }) await handleRequest(req, { SIGIL_KV: mockKv, backend: pool, auth, kv })
const code = await kv.getCode('greeter') const code = await kv.getCode('greeter')
expect(code).toBeTruthy()
expect(code).toContain('export default') expect(code).toContain('export default')
expect(code).toContain('async fetch(request)') 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', { const req = makeRequest('POST', '/_api/deploy', {
token: 'deploy-token', token: 'deploy-token',
body: { name: 'bad', type: 'normal', body: {
code: 'export default{}', name: 'currency',
schema: { type: 'object', properties: {} }, execute: 'return "x"' }, type: 'persistent',
description: 'Currency converter',
tags: ['finance'],
schema,
execute: 'return JSON.stringify({ from: input.from, to: input.to, amount: input.amount })',
},
}) })
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('无 code 无 execute → 400', async () => { it('模式 B + A 同时提供 → 400 错误', async () => {
const req = makeRequest('POST', '/_api/deploy', { token: 'deploy-token', body: { name: 'bad', type: 'normal' } }) const req = makeRequest('POST', '/_api/deploy', {
expect((await handleRequest(req, { SIGIL_KV: mockKv, backend: pool, auth, kv })).status).toBe(400) 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
View File
@@ -1,43 +1,58 @@
import { describe, it, expect, beforeEach } from 'vitest' 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 { WorkerPool } from '../src/backend/worker-pool.js'
import { KvStore } from '../src/kv.js' import { KvStore } from '../src/kv.js'
import { CONFIG } from '../src/config.js'
describe('S2: 调用已部署能力(命中)', () => { describe('S2: 调用已部署能力(命中)', () => {
let mockKv: KVNamespace let mockKv: KVNamespace
let mockCf: ReturnType<typeof createMockCfApi> let mockLoader: ReturnType<typeof createMockLoader>
let mockEmbed: MockEmbeddingService
let pool: WorkerPool let pool: WorkerPool
let kv: KvStore let kv: KvStore
beforeEach(async () => { beforeEach(async () => {
mockKv = createMockKv() mockKv = createMockKv()
mockCf = createMockCfApi({ invokeResponse: () => new Response('pong', { status: 200 }) }) mockLoader = createMockLoader({
pool = new WorkerPool(mockKv, mockCf.cfApi, new MockEmbeddingService() as any) invokeResponse: (_workerName, _req) => new Response('pong', { status: 200 }),
})
mockEmbed = new MockEmbeddingService()
pool = new WorkerPool(mockKv, mockLoader.loader, mockEmbed as any)
kv = new KvStore(mockKv) 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' }) // Deploy first
mockCf.reset() await pool.deploy({
name: 'ping',
code: "export default { fetch() { return new Response('pong') } }",
type: 'normal',
})
mockLoader.reset()
}) })
it('should invoke warm capability', async () => { 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(resp.status).toBe(200)
expect(await resp.text()).toBe('pong') expect(await resp.text()).toBe('pong')
}) })
it('should call cfApi.invoke with correct slot index', async () => { it('should update lru.last_access on warm hit', 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 () => {
const lruBefore = await kv.getLru('ping') const lruBefore = await kv.getLru('ping')
const accessBefore = lruBefore!.last_access
await new Promise(r => setTimeout(r, 5)) 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') 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) 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)
})
}) })
+44 -20
View File
@@ -1,46 +1,70 @@
import { describe, it, expect, beforeEach } from 'vitest' 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 { WorkerPool } from '../src/backend/worker-pool.js'
import { KvStore } from '../src/kv.js' import { KvStore } from '../src/kv.js'
import { CONFIG } from '../src/config.js'
describe('S3: 调用未部署能力(冷启动)', () => { describe('S3: 调用未部署能力(换入)', () => {
let mockKv: KVNamespace let mockKv: KVNamespace
let mockCf: ReturnType<typeof createMockCfApi> let mockLoader: ReturnType<typeof createMockLoader>
let mockEmbed: MockEmbeddingService
let pool: WorkerPool let pool: WorkerPool
let kv: KvStore let kv: KvStore
beforeEach(async () => { beforeEach(async () => {
mockKv = createMockKv() mockKv = createMockKv()
mockCf = createMockCfApi({ invokeResponse: () => new Response('pong', { status: 200 }) }) mockLoader = createMockLoader({
pool = new WorkerPool(mockKv, mockCf.cfApi, new MockEmbeddingService() as any) invokeResponse: () => new Response('pong', { status: 200 }),
})
mockEmbed = new MockEmbeddingService()
pool = new WorkerPool(mockKv, mockLoader.loader, mockEmbed as any)
kv = new KvStore(mockKv) 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.setCode('ping', "export default { fetch() { return new Response('pong') } }")
await kv.setMeta('ping', { type: 'normal', created_at: Date.now() - 10000 }) await kv.setMeta('ping', {
await kv.setLru('ping', { last_access: Date.now() - 10000, access_count: 5, deployed: false }) 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 () => { it('should page in and call LOADER.get', 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(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 () => { it('should set lru.deployed=true after page-in', async () => {
await pool.invoke('ping', new Request('https://sigil.shazhou.workers.dev/run/ping')) const req = new Request('https://sigil.shazhou.workers.dev/run/ping')
expect((await kv.getLru('ping'))?.deployed).toBe(true) 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 () => { 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') expect(resp.headers.get('X-Sigil-Cold-Start')).toBe('true')
}) })
it('should write route entry after page-in', async () => { it('should NOT set X-Sigil-Cold-Start on warm hit', async () => {
await pool.invoke('ping', new Request('https://sigil.shazhou.workers.dev/run/ping')) // First invoke (cold)
const route = await kv.getRoute('ping') const req1 = new Request('https://sigil.shazhou.workers.dev/run/ping')
expect(route).not.toBeNull() await pool.invoke('ping', req1)
expect(typeof route?.slot).toBe('number')
// 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()
}) })
}) })
+102 -52
View File
@@ -1,81 +1,131 @@
import { describe, it, expect, beforeEach } from 'vitest' 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 { WorkerPool } from '../src/backend/worker-pool.js'
import { KvStore } from '../src/kv.js' import { KvStore } from '../src/kv.js'
import { CONFIG } from '../src/config.js' import { CONFIG } from '../src/config.js'
describe('S4: 配额满时换出(LRU)', () => { describe('S4: 配额满时换出', () => {
let mockKv: KVNamespace let mockKv: KVNamespace
let mockCf: ReturnType<typeof createMockCfApi> let mockLoader: ReturnType<typeof createMockLoader>
let mockEmbed: MockEmbeddingService
let pool: WorkerPool let pool: WorkerPool
let kv: KvStore let kv: KvStore
beforeEach(async () => { beforeEach(async () => {
mockKv = createMockKv() mockKv = createMockKv()
mockCf = createMockCfApi({ invokeResponse: () => new Response('ok', { status: 200 }) }) mockLoader = createMockLoader({
pool = new WorkerPool(mockKv, mockCf.cfApi, new MockEmbeddingService() as any) invokeResponse: () => new Response('ok', { status: 200 }),
})
mockEmbed = new MockEmbeddingService()
pool = new WorkerPool(mockKv, mockLoader.loader, mockEmbed as any)
kv = new KvStore(mockKv) 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> { it('should evict the coldest capability when slots are full', async () => {
const base = Date.now() - 100000 const baseTime = Date.now() - 100000
for (let i = 0; i < CONFIG.MAX_SLOTS; i++) {
const cap = 'cap' + i // Fill up all slots (MAX_SLOTS = 3)
await kv.setCode(cap, '// c' + i) for (let i = 0; i < CONFIG.MAX_SLOTS; i++) {
await kv.setMeta(cap, { type: 'normal', created_at: base + i * 100 }) const cap = `cap${i}`
await kv.setLru(cap, { last_access: base + i * 100, access_count: i, deployed: true }) await kv.setCode(cap, `// code ${i}`)
await kv.setSlot(i, { capability: cap, status: 'active' }) await kv.setMeta(cap, {
await kv.setRoute(cap, { slot: i }) type: 'normal',
} created_at: baseTime + i * 100,
} })
await kv.setLru(cap, {
last_access: baseTime + i * 100, // cap0 is coldest
access_count: i,
deployed: true,
})
}
// Deploy one more — should trigger eviction of cap0 (oldest last_access)
const result = await pool.deploy({
name: 'new-cap',
code: '// new',
type: 'normal',
})
it('should evict coldest when slots full', async () => {
await fillSlots()
const result = await pool.deploy({ name: 'new-cap', code: '// new', type: 'normal' })
expect(result.capability).toBe('new-cap') expect(result.capability).toBe('new-cap')
expect(result.evicted).toBe('cap0') expect(result.evicted).toBe('cap0')
expect((await kv.getLru('cap0'))?.deployed).toBe(false)
})
it('should call updateSlotCode with IDLE code on eviction', async () => { // Dynamic Workers: no LOADER.get() calls during deploy — only during invoke
await fillSlots(); mockCf.reset() expect(mockLoader.loaderCalls()).toHaveLength(0)
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()
})
it('should release slot route after eviction', async () => { // cap0 lru should be deployed=false
await fillSlots() const evictedLru = await kv.getLru('cap0')
await pool.deploy({ name: 'new-cap', code: '// new', type: 'normal' }) expect(evictedLru?.deployed).toBe(false)
expect(await kv.getRoute('cap0')).toBeNull()
}) })
it('should increment eviction count', async () => { it('should increment eviction count', async () => {
await fillSlots() const baseTime = Date.now() - 100000
await pool.deploy({ name: 'new-cap', code: '// new', type: 'normal' })
expect(await kv.getEvictionCount()).toBe(1) 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',
})
const evictionCount = await kv.getEvictionCount()
expect(evictionCount).toBe(1)
}) })
it('should prefer evicting expired ephemeral over normal', async () => { it('should prefer evicting ephemeral_expired over normal', async () => {
const base = Date.now() - 100000 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++) { for (let i = 0; i < CONFIG.MAX_SLOTS - 1; i++) {
const cap = 'normal' + i const cap = `normal${i}`
await kv.setCode(cap, '// c' + i) await kv.setCode(cap, `// code ${i}`)
await kv.setMeta(cap, { type: 'normal', created_at: base + i * 100 }) await kv.setMeta(cap, {
await kv.setLru(cap, { last_access: base + i * 100, access_count: 10, deployed: true }) type: 'normal',
await kv.setSlot(i, { capability: cap, status: 'active' }) created_at: baseTime + i * 100,
await kv.setRoute(cap, { slot: i }) })
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') // Add 1 expired ephemeral (more recently accessed but expired)
await kv.setMeta('ephemeral-old', { type: 'ephemeral', ttl: 1, created_at: Date.now() - 10000 }) await kv.setCode('ephemeral-old', '// ephemeral')
await kv.setLru('ephemeral-old', { last_access: Date.now() - 100, access_count: 100, deployed: true }) await kv.setMeta('ephemeral-old', {
await kv.setSlot(last, { capability: 'ephemeral-old', status: 'active' }) type: 'ephemeral',
await kv.setRoute('ephemeral-old', { slot: last }) ttl: 1, // 1 second TTL, already expired
const result = await pool.deploy({ name: 'newcomer', code: '// new', type: 'normal' }) 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(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
View File
@@ -1,34 +1,36 @@
import { describe, it, expect, beforeEach } from 'vitest' 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 { 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 pool: WorkerPool
let mockCf: ReturnType<typeof createMockCfApi>
beforeEach(() => { beforeEach(() => {
const mockKv = createMockKv() mockKv = createMockKv()
mockCf = createMockCfApi() mockLoader = createMockLoader()
pool = new WorkerPool(mockKv, mockCf.cfApi, new MockEmbeddingService() as any) mockEmbed = new MockEmbeddingService()
pool = new WorkerPool(mockKv, mockLoader.loader, mockEmbed as any)
}) })
it('should return 404', async () => { it('should return 404 for nonexistent capability', async () => {
const resp = await pool.invoke('nonexistent', new Request('https://sigil.shazhou.workers.dev/run/nonexistent')) const req = new Request('https://sigil.shazhou.workers.dev/run/nonexistent')
const resp = await pool.invoke('nonexistent', req)
expect(resp.status).toBe(404) expect(resp.status).toBe(404)
}) })
it('should return error JSON body', async () => { it('should return error JSON body', async () => {
const resp = await pool.invoke('nonexistent', new Request('https://sigil.shazhou.workers.dev/run/nonexistent')) const req = new Request('https://sigil.shazhou.workers.dev/run/nonexistent')
expect((await resp.json() as any).error).toBeTruthy() 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 () => { it('should not call LOADER.get for nonexistent capability', async () => {
await pool.invoke('nonexistent', new Request('https://sigil.shazhou.workers.dev/run/nonexistent')) const req = new Request('https://sigil.shazhou.workers.dev/run/nonexistent')
expect(mockCf.invokeCalls()).toHaveLength(0) await pool.invoke('nonexistent', req)
}) expect(mockLoader.loaderCalls()).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)
}) })
}) })
+39 -30
View File
@@ -1,62 +1,71 @@
import { describe, it, expect, beforeEach } from 'vitest' 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 { WorkerPool } from '../src/backend/worker-pool.js'
import { AuthModule } from '../src/auth.js' import { AuthModule } from '../src/auth.js'
import { KvStore } from '../src/kv.js' import { KvStore } from '../src/kv.js'
import { handleRequest } from '../src/router.js' import { handleRequest } from '../src/router.js'
import { CONFIG } from '../src/config.js'
describe('S6: 删除能力', () => { describe('S6: 删除能力', () => {
let mockKv: KVNamespace let mockKv: KVNamespace
let mockCf: ReturnType<typeof createMockCfApi> let mockLoader: ReturnType<typeof createMockLoader>
let mockEmbed: MockEmbeddingService
let pool: WorkerPool let pool: WorkerPool
let auth: AuthModule let auth: AuthModule
let kv: KvStore let kv: KvStore
beforeEach(async () => { beforeEach(async () => {
mockKv = createMockKv() mockKv = createMockKv()
mockCf = createMockCfApi() mockLoader = createMockLoader()
pool = new WorkerPool(mockKv, mockCf.cfApi, new MockEmbeddingService() as any) mockEmbed = new MockEmbeddingService()
pool = new WorkerPool(mockKv, mockLoader.loader, mockEmbed as any)
kv = new KvStore(mockKv) kv = new KvStore(mockKv)
auth = new AuthModule(kv) auth = new AuthModule(kv)
await auth.setToken('deploy-token') 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' }) // Deploy first
mockCf.reset() 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 () => { it('should clear all KV entries (Dynamic Workers: no CF API deleteWorker needed)', async () => {
const resp = await handleRequest( const req = makeRequest('DELETE', '/_api/remove', {
makeRequest('DELETE', '/_api/remove', { token: 'deploy-token', body: { capability: 'ping' } }), token: 'deploy-token',
{ SIGIL_KV: mockKv, backend: pool, auth, kv }, body: { capability: 'ping' },
) })
const resp = await handleRequest(req, { SIGIL_KV: mockKv, backend: pool, auth, kv })
expect(resp.status).toBe(200) 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 () => { // All KV entries should be gone
const route = await kv.getRoute('ping') expect(await kv.getCode('ping')).toBeNull()
await pool.remove('ping') expect(await kv.getMeta('ping')).toBeNull()
const slot = await kv.getSlot(route!.slot) expect(await kv.getLru('ping')).toBeNull()
expect(slot?.status).toBe('free')
expect(slot?.capability).toBeNull() // No LOADER.get() calls during remove
expect(mockLoader.loaderCalls()).toHaveLength(0)
}) })
it('should clear all KV entries', async () => { it('should clear all KV entries', async () => {
await pool.remove('ping') await pool.remove('ping')
expect(await kv.getCode('ping')).toBeNull() expect(await kv.getCode('ping')).toBeNull()
expect(await kv.getMeta('ping')).toBeNull() expect(await kv.getMeta('ping')).toBeNull()
expect(await kv.getLru('ping')).toBeNull() expect(await kv.getLru('ping')).toBeNull()
expect(await kv.getRoute('ping')).toBeNull()
}) })
it('should return removed capability', async () => { it('should return removed capability in response', async () => {
const resp = await handleRequest( const req = makeRequest('DELETE', '/_api/remove', {
makeRequest('DELETE', '/_api/remove', { token: 'deploy-token', body: { capability: 'ping' } }), token: 'deploy-token',
{ SIGIL_KV: mockKv, backend: pool, auth, kv }, body: { capability: 'ping' },
) })
expect((await resp.json() as any).removed).toBe('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
View File
@@ -1,49 +1,69 @@
import { describe, it, expect, beforeEach } from 'vitest' 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 { WorkerPool } from '../src/backend/worker-pool.js'
import { AuthModule } from '../src/auth.js' import { AuthModule } from '../src/auth.js'
import { KvStore } from '../src/kv.js' import { KvStore } from '../src/kv.js'
import { handleRequest } from '../src/router.js' import { handleRequest } from '../src/router.js'
import { CONFIG } from '../src/config.js'
describe('S7: 列出能力', () => { describe('S7: 列出能力(已迁移至 query 接口)', () => {
let mockKv: KVNamespace let mockKv: KVNamespace
let mockLoader: ReturnType<typeof createMockLoader>
let mockEmbed: MockEmbeddingService
let pool: WorkerPool let pool: WorkerPool
let auth: AuthModule let auth: AuthModule
let kv: KvStore let kv: KvStore
beforeEach(async () => { beforeEach(async () => {
mockKv = createMockKv() mockKv = createMockKv()
const mockCf = createMockCfApi() mockLoader = createMockLoader()
pool = new WorkerPool(mockKv, mockCf.cfApi, new MockEmbeddingService() as any) mockEmbed = new MockEmbeddingService()
pool = new WorkerPool(mockKv, mockLoader.loader, mockEmbed as any)
kv = new KvStore(mockKv) kv = new KvStore(mockKv)
auth = new AuthModule(kv) auth = new AuthModule(kv)
await auth.setToken('deploy-token') 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']) { 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 () => { it('/_api/list should return 404 (removed)', async () => {
const resp = await handleRequest(makeRequest('GET', '/_api/list', { token: 'deploy-token' }), { SIGIL_KV: mockKv, backend: pool, auth, kv }) 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) expect(resp.status).toBe(404)
}) })
it('/_api/query returns all capabilities', async () => { it('/_api/query should return all capabilities (explore mode)', async () => {
const resp = await handleRequest(makeRequest('GET', '/_api/query'), { SIGIL_KV: mockKv, backend: pool, auth, kv }) const req = makeRequest('GET', '/_api/query')
const resp = await handleRequest(req, { SIGIL_KV: mockKv, backend: pool, auth, kv })
expect(resp.status).toBe(200) expect(resp.status).toBe(200)
const body = await resp.json() as { total: number; items: Array<{ capability: string }> } const body = await resp.json() as { total: number; items: Array<{ capability: string }> }
expect(body.total).toBe(3) 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('ping')
expect(names).toContain('echo') expect(names).toContain('echo')
expect(names).toContain('hello') expect(names).toContain('hello')
}) })
it('should include capability metadata', async () => { it('should include capability metadata in query results', async () => {
const result = await pool.query({}) const result = await pool.query({})
expect(result.total).toBe(3) 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
View File
@@ -1,37 +1,55 @@
import { describe, it, expect, beforeEach } from 'vitest' 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 { WorkerPool } from '../src/backend/worker-pool.js'
import { AuthModule } from '../src/auth.js' import { AuthModule } from '../src/auth.js'
import { KvStore } from '../src/kv.js' import { KvStore } from '../src/kv.js'
import { handleRequest } from '../src/router.js' import { handleRequest } from '../src/router.js'
import { CONFIG } from '../src/config.js'
describe('S8: 健康端点', () => { describe('S8: 健康端点', () => {
let mockKv: KVNamespace let mockKv: KVNamespace
let mockLoader: ReturnType<typeof createMockLoader>
let mockEmbed: MockEmbeddingService
let pool: WorkerPool let pool: WorkerPool
let auth: AuthModule let auth: AuthModule
let kv: KvStore let kv: KvStore
beforeEach(async () => { beforeEach(async () => {
mockKv = createMockKv() mockKv = createMockKv()
const mockCf = createMockCfApi() mockLoader = createMockLoader()
pool = new WorkerPool(mockKv, mockCf.cfApi, new MockEmbeddingService() as any) mockEmbed = new MockEmbeddingService()
pool = new WorkerPool(mockKv, mockLoader.loader, mockEmbed as any)
kv = new KvStore(mockKv) kv = new KvStore(mockKv)
auth = new AuthModule(kv) 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 () => { 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) expect(resp.status).toBe(200)
}) })
it('should return backend status', async () => { it('should return backend status fields', async () => {
const resp = await handleRequest(makeRequest('GET', '/_health'), { SIGIL_KV: mockKv, backend: pool, auth, kv }) const req = makeRequest('GET', '/_health')
const body = await resp.json() as any 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(body.backend).toBe('worker-pool')
expect(typeof body.total_slots).toBe('number')
expect(body.total_slots).toBeGreaterThan(0) expect(body.total_slots).toBeGreaterThan(0)
expect(typeof body.used_slots).toBe('number')
expect(body.used_slots).toBe(1) expect(body.used_slots).toBe(1)
expect(body.lru_enabled).toBe(true) expect(body.lru_enabled).toBe(true)
expect(typeof body.eviction_count).toBe('number') expect(typeof body.eviction_count).toBe('number')
+45 -11
View File
@@ -1,5 +1,5 @@
import { describe, it, expect, beforeEach } from 'vitest' 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 { WorkerPool } from '../src/backend/worker-pool.js'
import { AuthModule } from '../src/auth.js' import { AuthModule } from '../src/auth.js'
import { KvStore } from '../src/kv.js' import { KvStore } from '../src/kv.js'
@@ -7,35 +7,69 @@ import { handleRequest } from '../src/router.js'
describe('S9: 无 token 拒绝', () => { describe('S9: 无 token 拒绝', () => {
let mockKv: KVNamespace let mockKv: KVNamespace
let mockLoader: ReturnType<typeof createMockLoader>
let mockEmbed: MockEmbeddingService
let pool: WorkerPool let pool: WorkerPool
let auth: AuthModule let auth: AuthModule
let kv: KvStore let kv: KvStore
beforeEach(() => { beforeEach(() => {
mockKv = createMockKv() mockKv = createMockKv()
const mockCf = createMockCfApi() mockLoader = createMockLoader()
pool = new WorkerPool(mockKv, mockCf.cfApi, new MockEmbeddingService() as any) mockEmbed = new MockEmbeddingService()
pool = new WorkerPool(mockKv, mockLoader.loader, mockEmbed as any)
kv = new KvStore(mockKv) kv = new KvStore(mockKv)
auth = new AuthModule(kv) auth = new AuthModule(kv)
}) })
it('should return 401 with no token', async () => { it('should return 401 when no Authorization header', async () => {
const resp = await handleRequest(makeRequest('POST', '/_api/deploy', { body: { name: 'ping', code: '// ping', type: 'normal' } }), { SIGIL_KV: mockKv, backend: pool, auth, kv }) 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) expect(resp.status).toBe(401)
}) })
it('should return 401 with wrong token', async () => { it('should return 401 when 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 }) 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) expect(resp.status).toBe(401)
}) })
it('should return 401 on DELETE without token', async () => { 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) expect(resp.status).toBe(401)
}) })
it('should return error message', async () => { it('should return error message in body', async () => {
const resp = await handleRequest(makeRequest('POST', '/_api/deploy', { body: { name: 'ping', code: '// ping', type: 'normal' } }), { SIGIL_KV: mockKv, backend: pool, auth, kv }) const req = makeRequest('POST', '/_api/deploy', {
expect((await resp.json() as any).error).toBeTruthy() 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()
}) })
}) })
+33 -22
View File
@@ -1,41 +1,52 @@
import { describe, it, expect, beforeEach } from 'vitest' 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 { WorkerPool } from '../src/backend/worker-pool.js'
import { KvStore } from '../src/kv.js' import { KvStore } from '../src/kv.js'
import { CONFIG } from '../src/config.js'
describe('S11: 并发换入', () => { describe('S11: 并发换入去重', () => {
let mockKv: KVNamespace let mockKv: KVNamespace
let mockLoader: ReturnType<typeof createMockLoader>
let mockEmbed: MockEmbeddingService
let pool: WorkerPool let pool: WorkerPool
let kv: KvStore let kv: KvStore
beforeEach(async () => { beforeEach(async () => {
mockKv = createMockKv() mockKv = createMockKv()
const mockCf = createMockCfApi({ invokeResponse: () => new Response('pong', { status: 200 }) }) mockLoader = createMockLoader({
pool = new WorkerPool(mockKv, mockCf.cfApi, new MockEmbeddingService() as any) invokeResponse: () => new Response('pong', { status: 200 }),
})
mockEmbed = new MockEmbeddingService()
pool = new WorkerPool(mockKv, mockLoader.loader, mockEmbed as any)
kv = new KvStore(mockKv) 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.setCode('ping', "export default { fetch() { return new Response('pong') } }")
await kv.setMeta('ping', { type: 'normal', created_at: Date.now() - 10000 }) await kv.setMeta('ping', {
await kv.setLru('ping', { last_access: Date.now() - 10000, access_count: 0, deployed: false }) 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 () => { it('should handle concurrent page-ins without error', async () => {
const [r1, r2] = await Promise.all([ const req1 = new Request('https://sigil.shazhou.workers.dev/run/ping')
pool.invoke('ping', new Request('https://sigil.shazhou.workers.dev/run/ping')), const req2 = 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)
})
it('should have route after concurrent page-in', async () => { // Fire concurrently
await Promise.all([ const [resp1, resp2] = await Promise.all([
pool.invoke('ping', new Request('https://sigil.shazhou.workers.dev/run/ping')), pool.invoke('ping', req1),
pool.invoke('ping', new Request('https://sigil.shazhou.workers.dev/run/ping')), pool.invoke('ping', req2),
]) ])
const route = await kv.getRoute('ping')
expect(route).not.toBeNull() expect(resp1.status).toBe(200)
expect(typeof route?.slot).toBe('number') 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)
}) })
}) })
+40 -16
View File
@@ -1,34 +1,58 @@
import { describe, it, expect, beforeEach } from 'vitest' 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 { WorkerPool } from '../src/backend/worker-pool.js'
import { KvStore } from '../src/kv.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 mockKv: KVNamespace
let mockLoader: ReturnType<typeof createMockLoader>
let mockEmbed: MockEmbeddingService
let pool: WorkerPool let pool: WorkerPool
let kv: KvStore let kv: KvStore
beforeEach(async () => { beforeEach(async () => {
mockKv = createMockKv() mockKv = createMockKv()
const mockCf = createMockCfApi({ invokeResponse: () => new Response('ok', { status: 200 }) }) mockLoader = createMockLoader({
pool = new WorkerPool(mockKv, mockCf.cfApi, new MockEmbeddingService() as any) invokeResponse: () => new Response('ok', { status: 200 }),
})
mockEmbed = new MockEmbeddingService()
pool = new WorkerPool(mockKv, mockLoader.loader, mockEmbed as any)
kv = new KvStore(mockKv) 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 () => { async function setupCapability(name: string): Promise<void> {
for (let i = 0; i < CONFIG.MAX_SLOTS; i++) { await kv.setCode(name, `// ${name}`)
const result = await pool.deploy({ name: 'seq' + i, code: '// seq' + i, type: 'normal' }) await kv.setMeta(name, {
expect(result.capability).toBe('seq' + i) 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 () => { it('should mark cold-start capability as deployed after invoke', async () => {
await kv.setCode('cold', '// cold') await setupCapability('cold')
await kv.setMeta('cold', { type: 'normal', created_at: Date.now() - 10000 }) const lruBefore = await kv.getLru('cold')
await kv.setLru('cold', { last_access: Date.now() - 10000, access_count: 0, deployed: false }) expect(lruBefore!.deployed).toBe(false)
const resp = await pool.invoke('cold', new Request('https://sigil.shazhou.workers.dev/run/cold'))
expect(resp.status).toBe(200) 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)
}) })
}) })
+55 -15
View File
@@ -1,45 +1,85 @@
import { describe, it, expect, beforeEach } from 'vitest' 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 { WorkerPool } from '../src/backend/worker-pool.js'
import { AuthModule } from '../src/auth.js' import { AuthModule } from '../src/auth.js'
import { KvStore } from '../src/kv.js' import { KvStore } from '../src/kv.js'
import { handleRequest } from '../src/router.js' import { handleRequest } from '../src/router.js'
import { CONFIG } from '../src/config.js'
describe('S13: deploy_cooldown', () => { describe('S13: deploy_cooldown', () => {
let mockKv: KVNamespace let mockKv: KVNamespace
let mockLoader: ReturnType<typeof createMockLoader>
let mockEmbed: MockEmbeddingService
let pool: WorkerPool let pool: WorkerPool
let auth: AuthModule let auth: AuthModule
let kv: KvStore let kv: KvStore
beforeEach(async () => { beforeEach(async () => {
mockKv = createMockKv() mockKv = createMockKv()
const mockCf = createMockCfApi() mockLoader = createMockLoader()
pool = new WorkerPool(mockKv, mockCf.cfApi, new MockEmbeddingService() as any) mockEmbed = new MockEmbeddingService()
pool = new WorkerPool(mockKv, mockLoader.loader, mockEmbed as any)
kv = new KvStore(mockKv) kv = new KvStore(mockKv)
auth = new AuthModule(kv) auth = new AuthModule(kv)
await auth.setToken('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 reject rapid second deploy with 429', async () => { 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 }) // First deploy
expect(r1.status).toBe(201) const req1 = makeRequest('POST', '/_api/deploy', {
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 }) token: 'deploy-token',
expect(r2.status).toBe(429) 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 () => { it('should include retry_after in 429 response', async () => {
await handleRequest(makeRequest('POST', '/_api/deploy', { token: 'deploy-token', body: { name: 'ping', code: '// ping', type: 'normal' } }), { SIGIL_KV: mockKv, backend: pool, auth, kv }) // First deploy
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 req1 = makeRequest('POST', '/_api/deploy', {
const body = await r2.json() as any 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).toBeGreaterThan(0)
expect(body.retry_after).toBeLessThanOrEqual(5) expect(body.retry_after).toBeLessThanOrEqual(5)
}) })
it('should allow deploy after cooldown expires', async () => { it('should allow deploy after cooldown expires', async () => {
await kv.setLastDeployTime(Date.now() - 10000) // Manually set last deploy time as already expired
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 }) 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) expect(resp.status).toBe(201)
}) })
}) })
+182 -60
View File
@@ -1,104 +1,226 @@
// Dynamic Workers backend test setup.
// MockLoader simulates the CF LOADER binding (worker_loaders).
import { EmbeddingService } from '../src/embedding.js' import { EmbeddingService } from '../src/embedding.js'
import type { CfApi } from '../src/cf-api.js'
export interface MockKvEntry { value: string; metadata?: unknown } export interface MockKvEntry {
value: string
export interface MockLoaderGetCall { metadata?: unknown
workerId: string;
getCodeCalled: boolean;
}
interface WorkerStub {
invoke(request: Request): Promise<Response>;
}
interface WorkerLoader {
get(workerId: string, getCode?: () => any): WorkerStub;
} }
/**
* In-memory KVNamespace mock.
*/
export function createMockKv(): KVNamespace { export function createMockKv(): KVNamespace {
const store = new Map<string, MockKvEntry>() const store = new Map<string, MockKvEntry>()
return { return {
async get(key: string, options?: { type?: string } | string): Promise<unknown> { async get(key: string, options?: { type?: string } | string): Promise<unknown> {
const entry = store.get(key); if (!entry) return null const entry = store.get(key)
const type = typeof options === 'string' ? options : (options as any)?.type ?? 'text' if (!entry) return null
if (type === 'json') { try { return JSON.parse(entry.value) } catch { 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 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' 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 } 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 }) 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>> { async delete(key: string): Promise<void> {
const prefix = options?.prefix ?? ''; const limit = options?.limit ?? 1000 store.delete(key)
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 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 } as unknown as KVNamespace
} }
export function createMockCfApi(overrides?: { export interface MockLoaderGetCall {
invokeResponse?: (slotIndex: number, request: Request) => Response | Promise<Response> 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 getCalls: MockLoaderGetCall[] = []
const cfApi: CfApi = {
async updateSlotCode(slotIndex: number, code: string): Promise<void> { calls.push({ method: 'updateSlotCode', slotIndex, code }) }, // Synchronous LOADER mock — matches the real CF LOADER.get() API
async initSlot(slotIndex: number): Promise<void> { calls.push({ method: 'initSlot', slotIndex }) }, const loaderBinding = {
getSlotSubdomain(slotIndex: number): string { return `s-slot-${slotIndex}.test.workers.dev` }, get(workerId: string, _loadFn: () => { compatibilityDate: string; mainModule: string; modules: Record<string, string>; globalOutbound: null }) {
async invoke(slotIndex: number, request: Request): Promise<Response> { getCalls.push({ workerId })
calls.push({ method: 'invoke', slotIndex }) return {
if (overrides?.invokeResponse) return overrides.invokeResponse(slotIndex, request) getEntrypoint() {
return new Response('mock response', { status: 200 }) return {
async fetch(request: Request): Promise<Response> {
if (overrides?.invokeResponse) {
return overrides.invokeResponse(workerId, request)
}
return new Response('mock response', { status: 200 })
},
}
},
}
}, },
} }
return { return {
cfApi, calls, getCalls,
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) }, /** The LOADER binding to pass to WorkerPool constructor */
reset() { calls.length = 0 }, 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> * Create a test request helper.
}): Request { */
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 url = `https://sigil.shazhou.workers.dev${path}`
const headers: Record<string, string> = { 'Content-Type': 'application/json', ...options?.headers } const headers: Record<string, string> = {
if (options?.token) headers['Authorization'] = `Bearer ${options.token}` 'Content-Type': 'application/json',
const init: RequestInit = { method, headers } ...options?.headers,
if (options?.body !== undefined) init.body = JSON.stringify(options.body) }
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) return new Request(url, init)
} }
// Simple deterministic hash (for mock vectors)
function simpleHash(text: string): number { function simpleHash(text: string): number {
let h = 0x811c9dc5 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 return h
} }
// Generate a deterministic unit vector of given dimension
function generateDeterministicVector(seed: number, dim: number): number[] { function generateDeterministicVector(seed: number, dim: number): number[] {
const vec: number[] = []; let s = seed const vec: number[] = []
for (let i = 0; i < dim; i++) { s = (s * 1664525 + 1013904223) >>> 0; vec.push((s / 0xffffffff) * 2 - 1) } 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)) const norm = Math.sqrt(vec.reduce((a, x) => a + x * x, 0))
return vec.map(x => x / norm) 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 { export class MockEmbeddingService {
private overrides = new Map<string, number[]>() 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 {
async embed(text: string): Promise<number[]> { return EmbeddingService.buildCapabilityText(params)
if (this.overrides.has(text)) return this.overrides.get(text)!
return generateDeterministicVector(simpleHash(text), 768)
} }
async embedQuery(q: string): Promise<number[]> {
if (this.overrides.has(q)) return this.overrides.get(q)! setVector(textOrKey: string, vector: number[]): void {
return this.embed(q) this.overrides.set(textOrKey, vector)
}
async embed(text: string): Promise<number[]> {
if (this.overrides.has(text)) {
return this.overrides.get(text)!
}
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)
} }
} }