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