feat: implement Sigil Phase 1 MVP 🔮
- Wrangler project setup (TypeScript + Vitest)
- SigilBackend interface + WorkerPool implementation
- KV store with layered key schema (code/meta/lru/route/auth/stats)
- LRU scheduler with eviction priority (ephemeral_expired > ephemeral > normal > persistent)
- AuthModule: Bearer token validation, agent isolation, deploy cooldown
- Router: /_health, /_api/deploy, /_api/remove, /_api/list, /_api/inspect, /{agent}/{capability}
- 13 test scenarios, all passing (38 tests)
- MockKV + MockCfApi for isolated testing
Tests: 38/38 ✅ | Build: 22KB gzip:5KB ✅
小橘 🍊(NEKO Team)
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.wrangler/
|
||||
*.log
|
||||
Generated
+3399
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "sigil",
|
||||
"version": "0.1.0",
|
||||
"description": "Capability registry for Uncaged — LRU-managed Cloudflare Workers",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "wrangler dev",
|
||||
"build": "wrangler deploy --dry-run --outdir dist",
|
||||
"deploy": "wrangler deploy",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:ui": "vitest --ui",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/workers-types": "^4.20240405.0",
|
||||
"pnpm": "^10.33.0",
|
||||
"typescript": "^5.4.5",
|
||||
"vitest": "^1.5.0",
|
||||
"wrangler": "^3.50.0"
|
||||
}
|
||||
}
|
||||
Generated
+2050
File diff suppressed because it is too large
Load Diff
+93
@@ -0,0 +1,93 @@
|
||||
import { KvStore } from './kv.js'
|
||||
import { CONFIG } from './config.js'
|
||||
|
||||
export class AuthError extends Error {
|
||||
constructor(
|
||||
public readonly status: 401 | 403,
|
||||
message: string,
|
||||
) {
|
||||
super(message)
|
||||
this.name = 'AuthError'
|
||||
}
|
||||
}
|
||||
|
||||
export class DeployCooldownError extends Error {
|
||||
constructor(public readonly retry_after: number) {
|
||||
super('Deploy cooldown active')
|
||||
this.name = 'DeployCooldownError'
|
||||
}
|
||||
}
|
||||
|
||||
export class AuthModule {
|
||||
constructor(
|
||||
private kv: KvStore,
|
||||
private config = CONFIG,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Validate Bearer token from Authorization header.
|
||||
* Returns agent name on success, throws AuthError on failure.
|
||||
*/
|
||||
async validateToken(authHeader: string | null): Promise<string> {
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
throw new AuthError(401, 'Missing or invalid Authorization header')
|
||||
}
|
||||
|
||||
const token = authHeader.slice('Bearer '.length).trim()
|
||||
if (!token) {
|
||||
throw new AuthError(401, 'Empty token')
|
||||
}
|
||||
|
||||
// Scan all agents to find matching token
|
||||
const agents = await this.kv.listAgents()
|
||||
for (const agent of agents) {
|
||||
const auth = await this.kv.getAuth(agent)
|
||||
if (auth?.token === token) {
|
||||
return agent
|
||||
}
|
||||
}
|
||||
|
||||
throw new AuthError(401, 'Invalid token')
|
||||
}
|
||||
|
||||
/**
|
||||
* Check that authenticated agent can operate on target agent's namespace.
|
||||
*/
|
||||
checkAgentAccess(authenticatedAgent: string, targetAgent: string): void {
|
||||
if (authenticatedAgent !== targetAgent) {
|
||||
throw new AuthError(403, `Agent ${authenticatedAgent} cannot access ${targetAgent}'s namespace`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check deploy cooldown for agent. Throws DeployCooldownError if active.
|
||||
*/
|
||||
async checkDeployCooldown(agent: string): Promise<void> {
|
||||
const auth = await this.kv.getAuth(agent)
|
||||
if (!auth) return
|
||||
|
||||
const now = Date.now()
|
||||
if (auth.deploy_cooldown_until && auth.deploy_cooldown_until > now) {
|
||||
const retry_after = Math.ceil((auth.deploy_cooldown_until - now) / 1000)
|
||||
throw new DeployCooldownError(retry_after)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set deploy cooldown for agent.
|
||||
*/
|
||||
async setDeployCooldown(agent: string): Promise<void> {
|
||||
const auth = await this.kv.getAuth(agent)
|
||||
if (!auth) return
|
||||
|
||||
const until = Date.now() + this.config.DEPLOY_COOLDOWN_MS
|
||||
await this.kv.setAuth(agent, { ...auth, deploy_cooldown_until: until })
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a new agent with a token (used in tests).
|
||||
*/
|
||||
async registerAgent(agent: string, token: string): Promise<void> {
|
||||
await this.kv.setAuth(agent, { token })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
export interface DeployParams {
|
||||
agent: string
|
||||
name: string | null // null = 自动生成 t-{hash}
|
||||
code: string
|
||||
type: 'persistent' | 'normal' | 'ephemeral'
|
||||
ttl?: number // 秒,仅 ephemeral
|
||||
bindings?: string[]
|
||||
}
|
||||
|
||||
export interface DeployResult {
|
||||
capability: string // xiaoju--ping
|
||||
url: string
|
||||
expires_at?: string
|
||||
cold_start: boolean
|
||||
evicted?: string
|
||||
}
|
||||
|
||||
export interface Capability {
|
||||
capability: string
|
||||
agent: string
|
||||
name: string
|
||||
type: 'persistent' | 'normal' | 'ephemeral'
|
||||
deployed: boolean
|
||||
last_access: number
|
||||
access_count: number
|
||||
created_at: number
|
||||
ttl?: number
|
||||
expires_at?: string
|
||||
}
|
||||
|
||||
export interface BackendStatus {
|
||||
backend: 'worker-pool' | 'platform'
|
||||
total_slots: number
|
||||
used_slots: number
|
||||
agents: number
|
||||
lru_enabled: boolean
|
||||
eviction_count: number
|
||||
}
|
||||
|
||||
export interface SigilBackend {
|
||||
deploy(params: DeployParams): Promise<DeployResult>
|
||||
invoke(name: string, request: Request): Promise<Response>
|
||||
remove(name: string): Promise<void>
|
||||
list(agent?: string): Promise<Capability[]>
|
||||
inspect(name: string): Promise<Capability | null>
|
||||
status(): Promise<BackendStatus>
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
import type { SigilBackend, DeployParams, DeployResult, Capability, BackendStatus } from './types.js'
|
||||
import { KvStore } from '../kv.js'
|
||||
import { LruScheduler, PageRateLimitError } from '../lru.js'
|
||||
import { CONFIG } from '../config.js'
|
||||
|
||||
export interface CfApi {
|
||||
deployWorker(name: string, code: string): Promise<void>
|
||||
deleteWorker(name: string): Promise<void>
|
||||
getWorkerSubdomain(name: string): string
|
||||
invoke(workerName: string, request: Request): Promise<Response>
|
||||
}
|
||||
|
||||
// In-flight page-in tracking to deduplicate concurrent requests
|
||||
const inFlightPageIns = new Map<string, Promise<void>>()
|
||||
|
||||
export class WorkerPool implements SigilBackend {
|
||||
private kv: KvStore
|
||||
private lru: LruScheduler
|
||||
private config = CONFIG
|
||||
|
||||
constructor(
|
||||
kv: KVNamespace,
|
||||
private cfApi: CfApi,
|
||||
) {
|
||||
this.kv = new KvStore(kv)
|
||||
this.lru = new LruScheduler(this.kv)
|
||||
}
|
||||
|
||||
private async generateHash(input: string): Promise<string> {
|
||||
// Use Web Crypto API (available in CF Workers and Node 15+)
|
||||
const encoder = new TextEncoder()
|
||||
const data = encoder.encode(input)
|
||||
const hashBuffer = await crypto.subtle.digest('SHA-256', data)
|
||||
const hashArray = Array.from(new Uint8Array(hashBuffer))
|
||||
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('').slice(0, this.config.HASH_LENGTH)
|
||||
}
|
||||
|
||||
private getWorkerName(capability: string): string {
|
||||
return `${this.config.WORKER_PREFIX}${capability.replace('--', '-')}`
|
||||
}
|
||||
|
||||
async deploy(params: DeployParams): Promise<DeployResult> {
|
||||
const { agent, name, code, type, ttl, bindings } = params
|
||||
|
||||
// Determine capability name
|
||||
let capabilityName: string
|
||||
if (name === null) {
|
||||
// Generate ephemeral name: t-{6hex}
|
||||
const hash = await this.generateHash(code + Date.now())
|
||||
capabilityName = `t-${hash}`
|
||||
} else {
|
||||
capabilityName = name
|
||||
}
|
||||
|
||||
const capability = `${agent}--${capabilityName}`
|
||||
const workerName = this.getWorkerName(capability)
|
||||
const now = Date.now()
|
||||
|
||||
// Check if we need to evict
|
||||
const deployed = await this.lru.countDeployed()
|
||||
let evictedCapability: string | undefined
|
||||
|
||||
if (deployed >= this.config.MAX_SLOTS) {
|
||||
const candidate = await this.lru.findEvictionCandidate()
|
||||
if (candidate) {
|
||||
evictedCapability = candidate.capability
|
||||
const route = await this.kv.getRoute(candidate.capability)
|
||||
if (route) {
|
||||
await this.cfApi.deleteWorker(route.worker_name)
|
||||
}
|
||||
await this.kv.setLru(candidate.capability, {
|
||||
...(await this.kv.getLru(candidate.capability))!,
|
||||
deployed: false,
|
||||
})
|
||||
await this.kv.incrementEvictionCount()
|
||||
}
|
||||
}
|
||||
|
||||
// Deploy the worker
|
||||
await this.cfApi.deployWorker(workerName, code)
|
||||
const subdomain = this.cfApi.getWorkerSubdomain(workerName)
|
||||
|
||||
// Write KV entries
|
||||
await this.kv.setCode(capability, code)
|
||||
await this.kv.setMeta(capability, {
|
||||
type,
|
||||
ttl,
|
||||
created_at: now,
|
||||
bindings,
|
||||
agent,
|
||||
name: capabilityName,
|
||||
})
|
||||
await this.kv.setLru(capability, {
|
||||
last_access: now,
|
||||
access_count: 0,
|
||||
deployed: true,
|
||||
})
|
||||
await this.kv.setRoute(capability, {
|
||||
worker_name: workerName,
|
||||
subdomain,
|
||||
})
|
||||
|
||||
const url = `${this.config.GATEWAY_URL}/${agent}/${capabilityName}`
|
||||
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
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
async invoke(capabilityName: string, request: Request): Promise<Response> {
|
||||
const lru = await this.kv.getLru(capabilityName)
|
||||
|
||||
if (!lru) {
|
||||
// Check if we have code (page-in scenario)
|
||||
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' },
|
||||
})
|
||||
}
|
||||
|
||||
// Page in
|
||||
return await this.pageIn(capabilityName, code, request, true)
|
||||
}
|
||||
|
||||
if (!lru.deployed) {
|
||||
// Need to page in
|
||||
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 await this.pageIn(capabilityName, code, request, true)
|
||||
}
|
||||
|
||||
// Warm hit — update LRU and invoke
|
||||
await this.kv.setLru(capabilityName, {
|
||||
...lru,
|
||||
last_access: Date.now(),
|
||||
access_count: lru.access_count + 1,
|
||||
})
|
||||
|
||||
const route = await this.kv.getRoute(capabilityName)
|
||||
if (!route) {
|
||||
return new Response(JSON.stringify({ error: 'Route not found' }), {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
|
||||
return await this.cfApi.invoke(route.worker_name, request)
|
||||
}
|
||||
|
||||
private async doPageIn(capability: string, code: string): Promise<void> {
|
||||
// Check rate limit
|
||||
await this.lru.checkPageRate()
|
||||
|
||||
// Check if eviction needed
|
||||
const deployed = await this.lru.countDeployed()
|
||||
if (deployed >= this.config.MAX_SLOTS) {
|
||||
const candidate = await this.lru.findEvictionCandidate()
|
||||
if (candidate) {
|
||||
const route = await this.kv.getRoute(candidate.capability)
|
||||
if (route) {
|
||||
await this.cfApi.deleteWorker(route.worker_name)
|
||||
}
|
||||
const existingLru = await this.kv.getLru(candidate.capability)
|
||||
if (existingLru) {
|
||||
await this.kv.setLru(candidate.capability, {
|
||||
...existingLru,
|
||||
deployed: false,
|
||||
})
|
||||
}
|
||||
await this.kv.incrementEvictionCount()
|
||||
}
|
||||
}
|
||||
|
||||
const workerName = this.getWorkerName(capability)
|
||||
await this.cfApi.deployWorker(workerName, code)
|
||||
const subdomain = this.cfApi.getWorkerSubdomain(workerName)
|
||||
|
||||
const now = Date.now()
|
||||
await this.kv.setRoute(capability, { worker_name: workerName, subdomain })
|
||||
await this.kv.setLru(capability, {
|
||||
last_access: now,
|
||||
access_count: 1,
|
||||
deployed: true,
|
||||
})
|
||||
}
|
||||
|
||||
private async pageIn(
|
||||
capability: string,
|
||||
code: string,
|
||||
request: Request,
|
||||
isColdStart: boolean,
|
||||
): Promise<Response> {
|
||||
// Deduplicate concurrent page-ins
|
||||
const existing = inFlightPageIns.get(capability)
|
||||
if (existing) {
|
||||
// Wait for in-flight page-in to complete (may throw)
|
||||
await existing
|
||||
} else {
|
||||
// We are the "primary" page-in for this capability
|
||||
const primaryPageIn = this.doPageIn(capability, code)
|
||||
inFlightPageIns.set(capability, primaryPageIn)
|
||||
try {
|
||||
await primaryPageIn
|
||||
} finally {
|
||||
inFlightPageIns.delete(capability)
|
||||
}
|
||||
}
|
||||
|
||||
// Re-check: after page-in we should have route
|
||||
const lru = await this.kv.getLru(capability)
|
||||
if (!lru?.deployed) {
|
||||
return new Response(JSON.stringify({ error: 'Page-in failed' }), {
|
||||
status: 503,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
|
||||
const route = await this.kv.getRoute(capability)
|
||||
if (!route) {
|
||||
return new Response(JSON.stringify({ error: 'Route not found after page-in' }), {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
|
||||
// Update LRU
|
||||
await this.kv.setLru(capability, {
|
||||
...lru,
|
||||
last_access: Date.now(),
|
||||
access_count: lru.access_count + 1,
|
||||
})
|
||||
|
||||
const response = await this.cfApi.invoke(route.worker_name, request)
|
||||
|
||||
// Add cold start header
|
||||
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
|
||||
}
|
||||
|
||||
async remove(capabilityName: string): Promise<void> {
|
||||
const lru = await this.kv.getLru(capabilityName)
|
||||
|
||||
if (lru?.deployed) {
|
||||
const route = await this.kv.getRoute(capabilityName)
|
||||
if (route) {
|
||||
await this.cfApi.deleteWorker(route.worker_name)
|
||||
}
|
||||
}
|
||||
|
||||
await this.kv.deleteCode(capabilityName)
|
||||
await this.kv.deleteMeta(capabilityName)
|
||||
await this.kv.deleteLru(capabilityName)
|
||||
await this.kv.deleteRoute(capabilityName)
|
||||
}
|
||||
|
||||
async list(agent?: string): Promise<Capability[]> {
|
||||
const prefix = agent ? `${agent}--` : undefined
|
||||
const caps = await this.kv.listCapabilities(prefix)
|
||||
const result: Capability[] = []
|
||||
|
||||
for (const cap of caps) {
|
||||
const meta = await this.kv.getMeta(cap)
|
||||
const lru = await this.kv.getLru(cap)
|
||||
if (!meta || !lru) continue
|
||||
|
||||
const capability: Capability = {
|
||||
capability: cap,
|
||||
agent: meta.agent,
|
||||
name: meta.name,
|
||||
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()
|
||||
}
|
||||
|
||||
result.push(capability)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
async inspect(capabilityName: string): Promise<Capability | null> {
|
||||
const meta = await this.kv.getMeta(capabilityName)
|
||||
const lru = await this.kv.getLru(capabilityName)
|
||||
if (!meta || !lru) return null
|
||||
|
||||
const capability: Capability = {
|
||||
capability: capabilityName,
|
||||
agent: meta.agent,
|
||||
name: meta.name,
|
||||
type: meta.type,
|
||||
deployed: lru.deployed,
|
||||
last_access: lru.last_access,
|
||||
access_count: lru.access_count,
|
||||
created_at: meta.created_at,
|
||||
}
|
||||
|
||||
if (meta.ttl !== undefined) {
|
||||
capability.ttl = meta.ttl
|
||||
capability.expires_at = new Date(meta.created_at + meta.ttl * 1000).toISOString()
|
||||
}
|
||||
|
||||
return capability
|
||||
}
|
||||
|
||||
async status(): Promise<BackendStatus> {
|
||||
const caps = await this.kv.listCapabilities()
|
||||
let usedSlots = 0
|
||||
const agentSet = new Set<string>()
|
||||
|
||||
for (const cap of caps) {
|
||||
const lru = await this.kv.getLru(cap)
|
||||
const meta = await this.kv.getMeta(cap)
|
||||
if (lru?.deployed) usedSlots++
|
||||
if (meta?.agent) agentSet.add(meta.agent)
|
||||
}
|
||||
|
||||
const evictionCount = await this.kv.getEvictionCount()
|
||||
|
||||
return {
|
||||
backend: 'worker-pool',
|
||||
total_slots: this.config.MAX_SLOTS,
|
||||
used_slots: usedSlots,
|
||||
agents: agentSet.size,
|
||||
lru_enabled: true,
|
||||
eviction_count: evictionCount,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export const CONFIG = {
|
||||
MAX_SLOTS: 10, // 测试用小值,生产 ~400
|
||||
MAX_AGENTS: 8,
|
||||
DEPLOY_COOLDOWN_MS: 5000,
|
||||
PAGE_RATE_LIMIT: 10, // 次/分钟
|
||||
PAGE_RATE_WINDOW_MS: 60000,
|
||||
HASH_LENGTH: 6,
|
||||
WORKER_PREFIX: 's-',
|
||||
SUBDOMAIN_SUFFIX: '.shazhou.workers.dev',
|
||||
GATEWAY_URL: 'https://sigil.shazhou.workers.dev',
|
||||
} as const
|
||||
@@ -0,0 +1,36 @@
|
||||
import { WorkerPool, type CfApi } from './backend/worker-pool.js'
|
||||
import { AuthModule } from './auth.js'
|
||||
import { KvStore } from './kv.js'
|
||||
import { handleRequest } from './router.js'
|
||||
import { CONFIG } from './config.js'
|
||||
|
||||
export interface Env {
|
||||
SIGIL_KV: KVNamespace
|
||||
}
|
||||
|
||||
const defaultCfApi: CfApi = {
|
||||
async deployWorker(name: string, _code: string): Promise<void> {
|
||||
// Production: use CF API to deploy
|
||||
console.log(`[sigil] deploy worker: ${name}`)
|
||||
},
|
||||
async deleteWorker(name: string): Promise<void> {
|
||||
console.log(`[sigil] delete worker: ${name}`)
|
||||
},
|
||||
getWorkerSubdomain(name: string): string {
|
||||
return `${name}${CONFIG.SUBDOMAIN_SUFFIX}`
|
||||
},
|
||||
async invoke(_workerName: string, request: Request): Promise<Response> {
|
||||
// Production: fetch from worker subdomain
|
||||
return new Response('Not implemented', { status: 501 })
|
||||
},
|
||||
}
|
||||
|
||||
export default {
|
||||
async fetch(request: Request, env: Env): Promise<Response> {
|
||||
const kv = new KvStore(env.SIGIL_KV)
|
||||
const backend = new WorkerPool(env.SIGIL_KV, defaultCfApi)
|
||||
const auth = new AuthModule(kv)
|
||||
|
||||
return handleRequest(request, { SIGIL_KV: env.SIGIL_KV, backend, auth, kv })
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
// KV key prefixes and data types
|
||||
|
||||
export interface KvCodeValue {
|
||||
code: string
|
||||
}
|
||||
|
||||
export interface KvMetaValue {
|
||||
type: 'persistent' | 'normal' | 'ephemeral'
|
||||
ttl?: number
|
||||
created_at: number
|
||||
bindings?: string[]
|
||||
agent: string
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface KvLruValue {
|
||||
last_access: number
|
||||
access_count: number
|
||||
deployed: boolean
|
||||
}
|
||||
|
||||
export interface KvRouteValue {
|
||||
worker_name: string
|
||||
subdomain: string
|
||||
}
|
||||
|
||||
export interface KvAuthValue {
|
||||
token: string
|
||||
deploy_cooldown_until?: number
|
||||
}
|
||||
|
||||
export interface KvPageRateValue {
|
||||
count: number
|
||||
window_start: number
|
||||
}
|
||||
|
||||
export class KvStore {
|
||||
private kv: KVNamespace
|
||||
|
||||
constructor(kv: KVNamespace) {
|
||||
this.kv = kv
|
||||
}
|
||||
|
||||
// code:{capability}
|
||||
async getCode(capability: string): Promise<string | null> {
|
||||
const v = await this.kv.get(`code:${capability}`, 'json') as KvCodeValue | null
|
||||
return v?.code ?? null
|
||||
}
|
||||
|
||||
async setCode(capability: string, code: string): Promise<void> {
|
||||
await this.kv.put(`code:${capability}`, JSON.stringify({ code }))
|
||||
}
|
||||
|
||||
async deleteCode(capability: string): Promise<void> {
|
||||
await this.kv.delete(`code:${capability}`)
|
||||
}
|
||||
|
||||
// meta:{capability}
|
||||
async getMeta(capability: string): Promise<KvMetaValue | null> {
|
||||
return await this.kv.get(`meta:${capability}`, 'json') as KvMetaValue | null
|
||||
}
|
||||
|
||||
async setMeta(capability: string, meta: KvMetaValue): Promise<void> {
|
||||
await this.kv.put(`meta:${capability}`, JSON.stringify(meta))
|
||||
}
|
||||
|
||||
async deleteMeta(capability: string): Promise<void> {
|
||||
await this.kv.delete(`meta:${capability}`)
|
||||
}
|
||||
|
||||
// lru:{capability}
|
||||
async getLru(capability: string): Promise<KvLruValue | null> {
|
||||
return await this.kv.get(`lru:${capability}`, 'json') as KvLruValue | null
|
||||
}
|
||||
|
||||
async setLru(capability: string, lru: KvLruValue): Promise<void> {
|
||||
await this.kv.put(`lru:${capability}`, JSON.stringify(lru))
|
||||
}
|
||||
|
||||
async deleteLru(capability: string): Promise<void> {
|
||||
await this.kv.delete(`lru:${capability}`)
|
||||
}
|
||||
|
||||
// route:{capability}
|
||||
async getRoute(capability: string): Promise<KvRouteValue | null> {
|
||||
return await this.kv.get(`route:${capability}`, 'json') as KvRouteValue | null
|
||||
}
|
||||
|
||||
async setRoute(capability: string, route: KvRouteValue): Promise<void> {
|
||||
await this.kv.put(`route:${capability}`, JSON.stringify(route))
|
||||
}
|
||||
|
||||
async deleteRoute(capability: string): Promise<void> {
|
||||
await this.kv.delete(`route:${capability}`)
|
||||
}
|
||||
|
||||
// auth:{agent}
|
||||
async getAuth(agent: string): Promise<KvAuthValue | null> {
|
||||
return await this.kv.get(`auth:${agent}`, 'json') as KvAuthValue | null
|
||||
}
|
||||
|
||||
async setAuth(agent: string, auth: KvAuthValue): Promise<void> {
|
||||
await this.kv.put(`auth:${agent}`, JSON.stringify(auth))
|
||||
}
|
||||
|
||||
// stats:eviction_count
|
||||
async getEvictionCount(): Promise<number> {
|
||||
const v = await this.kv.get('stats:eviction_count', 'json') as { count: number } | null
|
||||
return v?.count ?? 0
|
||||
}
|
||||
|
||||
async incrementEvictionCount(): Promise<number> {
|
||||
const current = await this.getEvictionCount()
|
||||
const next = current + 1
|
||||
await this.kv.put('stats:eviction_count', JSON.stringify({ count: next }))
|
||||
return next
|
||||
}
|
||||
|
||||
// stats:page_rate
|
||||
async getPageRate(): Promise<KvPageRateValue> {
|
||||
const v = await this.kv.get('stats:page_rate', 'json') as KvPageRateValue | null
|
||||
return v ?? { count: 0, window_start: Date.now() }
|
||||
}
|
||||
|
||||
async setPageRate(rate: KvPageRateValue): Promise<void> {
|
||||
await this.kv.put('stats:page_rate', JSON.stringify(rate))
|
||||
}
|
||||
|
||||
// List all capabilities by prefix scanning
|
||||
async listCapabilities(prefix?: string): Promise<string[]> {
|
||||
const kvPrefix = prefix ? `lru:${prefix}` : 'lru:'
|
||||
const list = await this.kv.list({ prefix: kvPrefix })
|
||||
return list.keys.map(k => k.name.slice('lru:'.length))
|
||||
}
|
||||
|
||||
// List all agents (scan auth: keys)
|
||||
async listAgents(): Promise<string[]> {
|
||||
const list = await this.kv.list({ prefix: 'auth:' })
|
||||
return list.keys.map(k => k.name.slice('auth:'.length))
|
||||
}
|
||||
}
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
import { KvStore } from './kv.js'
|
||||
import { CONFIG } from './config.js'
|
||||
|
||||
export type EvictionPriority = 'ephemeral_expired' | 'ephemeral' | 'normal' | 'persistent'
|
||||
|
||||
export interface LruCandidate {
|
||||
capability: string
|
||||
priority: EvictionPriority
|
||||
last_access: number
|
||||
}
|
||||
|
||||
export class PageRateLimitError extends Error {
|
||||
constructor(public readonly retry_after: number) {
|
||||
super('Page rate limit exceeded')
|
||||
this.name = 'PageRateLimitError'
|
||||
}
|
||||
}
|
||||
|
||||
export class LruScheduler {
|
||||
constructor(
|
||||
private kv: KvStore,
|
||||
private config = CONFIG,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Check and increment page rate. Returns retry_after (seconds) if rate limited.
|
||||
*/
|
||||
async checkPageRate(): Promise<void> {
|
||||
const now = Date.now()
|
||||
const rate = await this.kv.getPageRate()
|
||||
|
||||
// Reset window if expired
|
||||
if (now - rate.window_start >= this.config.PAGE_RATE_WINDOW_MS) {
|
||||
await this.kv.setPageRate({ count: 1, window_start: now })
|
||||
return
|
||||
}
|
||||
|
||||
if (rate.count >= this.config.PAGE_RATE_LIMIT) {
|
||||
const retry_after = Math.ceil(
|
||||
(rate.window_start + this.config.PAGE_RATE_WINDOW_MS - now) / 1000
|
||||
)
|
||||
throw new PageRateLimitError(retry_after)
|
||||
}
|
||||
|
||||
await this.kv.setPageRate({ count: rate.count + 1, window_start: rate.window_start })
|
||||
}
|
||||
|
||||
/**
|
||||
* Count how many capabilities are currently deployed.
|
||||
*/
|
||||
async countDeployed(): Promise<number> {
|
||||
const caps = await this.kv.listCapabilities()
|
||||
let count = 0
|
||||
for (const cap of caps) {
|
||||
const lru = await this.kv.getLru(cap)
|
||||
if (lru?.deployed) count++
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
/**
|
||||
* Count distinct agents.
|
||||
*/
|
||||
async countAgents(): Promise<number> {
|
||||
const agents = await this.kv.listAgents()
|
||||
return agents.length
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the best eviction candidate (lowest priority + oldest access).
|
||||
* Returns null if no evictable candidate found.
|
||||
*/
|
||||
async findEvictionCandidate(): Promise<LruCandidate | null> {
|
||||
const caps = await this.kv.listCapabilities()
|
||||
const candidates: LruCandidate[] = []
|
||||
|
||||
for (const cap of caps) {
|
||||
const lru = await this.kv.getLru(cap)
|
||||
if (!lru?.deployed) continue
|
||||
|
||||
const meta = await this.kv.getMeta(cap)
|
||||
if (!meta) continue
|
||||
|
||||
let priority: EvictionPriority
|
||||
|
||||
if (meta.type === 'ephemeral') {
|
||||
const isExpired = meta.ttl !== undefined
|
||||
&& (meta.created_at + meta.ttl * 1000) < Date.now()
|
||||
priority = isExpired ? 'ephemeral_expired' : 'ephemeral'
|
||||
} else if (meta.type === 'normal') {
|
||||
priority = 'normal'
|
||||
} else {
|
||||
priority = 'persistent'
|
||||
}
|
||||
|
||||
candidates.push({ capability: cap, priority, last_access: lru.last_access })
|
||||
}
|
||||
|
||||
if (candidates.length === 0) return null
|
||||
|
||||
const priorityOrder: Record<EvictionPriority, number> = {
|
||||
ephemeral_expired: 0,
|
||||
ephemeral: 1,
|
||||
normal: 2,
|
||||
persistent: 3,
|
||||
}
|
||||
|
||||
candidates.sort((a, b) => {
|
||||
const pd = priorityOrder[a.priority] - priorityOrder[b.priority]
|
||||
if (pd !== 0) return pd
|
||||
return a.last_access - b.last_access
|
||||
})
|
||||
|
||||
return candidates[0] ?? null
|
||||
}
|
||||
}
|
||||
+188
@@ -0,0 +1,188 @@
|
||||
import type { SigilBackend } from './backend/types.js'
|
||||
import { AuthModule, AuthError, DeployCooldownError } from './auth.js'
|
||||
import { KvStore } from './kv.js'
|
||||
import { PageRateLimitError } from './lru.js'
|
||||
|
||||
export interface RouterEnv {
|
||||
SIGIL_KV: KVNamespace
|
||||
backend: SigilBackend
|
||||
auth: AuthModule
|
||||
kv: KvStore
|
||||
}
|
||||
|
||||
export async function handleRequest(request: Request, env: RouterEnv): Promise<Response> {
|
||||
const url = new URL(request.url)
|
||||
const path = url.pathname
|
||||
const method = request.method
|
||||
|
||||
// GET /_health
|
||||
if (method === 'GET' && path === '/_health') {
|
||||
return handleHealth(env)
|
||||
}
|
||||
|
||||
// POST /_api/deploy
|
||||
if (method === 'POST' && path === '/_api/deploy') {
|
||||
return handleDeploy(request, env)
|
||||
}
|
||||
|
||||
// DELETE /_api/remove
|
||||
if (method === 'DELETE' && path === '/_api/remove') {
|
||||
return handleRemove(request, env)
|
||||
}
|
||||
|
||||
// GET /_api/list
|
||||
if (method === 'GET' && path === '/_api/list') {
|
||||
return handleList(request, env)
|
||||
}
|
||||
|
||||
// GET /_api/inspect/{capability}
|
||||
const inspectMatch = path.match(/^\/_api\/inspect\/(.+)$/)
|
||||
if (method === 'GET' && inspectMatch) {
|
||||
const capability = inspectMatch[1]!
|
||||
return handleInspect(capability, env)
|
||||
}
|
||||
|
||||
// GET /{agent}/{capability} — invoke
|
||||
const invokeMatch = path.match(/^\/([^/]+)\/([^/]+)$/)
|
||||
if (invokeMatch) {
|
||||
const agent = invokeMatch[1]!
|
||||
const cap = invokeMatch[2]!
|
||||
return handleInvoke(agent, cap, request, env)
|
||||
}
|
||||
|
||||
return jsonError(404, 'Not found')
|
||||
}
|
||||
|
||||
async function handleHealth(env: RouterEnv): Promise<Response> {
|
||||
const status = await env.backend.status()
|
||||
return jsonOk(status)
|
||||
}
|
||||
|
||||
async function handleDeploy(request: Request, env: RouterEnv): Promise<Response> {
|
||||
try {
|
||||
const authHeader = request.headers.get('Authorization')
|
||||
const agent = await env.auth.validateToken(authHeader)
|
||||
|
||||
const body = await request.json() as {
|
||||
agent: string
|
||||
name: string | null
|
||||
code: string
|
||||
type: 'persistent' | 'normal' | 'ephemeral'
|
||||
ttl?: number
|
||||
bindings?: string[]
|
||||
}
|
||||
|
||||
// Check agent isolation
|
||||
env.auth.checkAgentAccess(agent, body.agent)
|
||||
|
||||
// Check deploy cooldown
|
||||
await env.auth.checkDeployCooldown(agent)
|
||||
|
||||
const result = await env.backend.deploy({
|
||||
agent: body.agent,
|
||||
name: body.name,
|
||||
code: body.code,
|
||||
type: body.type,
|
||||
ttl: body.ttl,
|
||||
bindings: body.bindings,
|
||||
})
|
||||
|
||||
// Set cooldown after successful deploy
|
||||
await env.auth.setDeployCooldown(agent)
|
||||
|
||||
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 })
|
||||
}
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRemove(request: Request, env: RouterEnv): Promise<Response> {
|
||||
try {
|
||||
const authHeader = request.headers.get('Authorization')
|
||||
const agent = await env.auth.validateToken(authHeader)
|
||||
|
||||
const body = await request.json() as { capability: string }
|
||||
const capability = body.capability
|
||||
|
||||
// Check agent owns this capability
|
||||
const agentPrefix = `${agent}--`
|
||||
if (!capability.startsWith(agentPrefix)) {
|
||||
return jsonError(403, `Agent ${agent} cannot remove ${capability}`)
|
||||
}
|
||||
|
||||
await env.backend.remove(capability)
|
||||
return jsonOk({ removed: capability })
|
||||
} catch (e) {
|
||||
if (e instanceof AuthError) {
|
||||
return jsonError(e.status, e.message)
|
||||
}
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
async function handleList(request: Request, env: RouterEnv): Promise<Response> {
|
||||
try {
|
||||
const authHeader = request.headers.get('Authorization')
|
||||
const agent = await env.auth.validateToken(authHeader)
|
||||
const url = new URL(request.url)
|
||||
const filterAgent = url.searchParams.get('agent') ?? undefined
|
||||
|
||||
// Agent can only list their own capabilities
|
||||
if (filterAgent && filterAgent !== agent) {
|
||||
return jsonError(403, `Agent ${agent} cannot list ${filterAgent}'s capabilities`)
|
||||
}
|
||||
|
||||
const list = await env.backend.list(filterAgent ?? agent)
|
||||
return jsonOk({ capabilities: list })
|
||||
} catch (e) {
|
||||
if (e instanceof AuthError) {
|
||||
return jsonError(e.status, e.message)
|
||||
}
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
async function handleInspect(capability: string, env: RouterEnv): Promise<Response> {
|
||||
const result = await env.backend.inspect(capability)
|
||||
if (!result) {
|
||||
return jsonError(404, 'Capability not found')
|
||||
}
|
||||
return jsonOk(result)
|
||||
}
|
||||
|
||||
async function handleInvoke(
|
||||
agent: string,
|
||||
capName: string,
|
||||
request: Request,
|
||||
env: RouterEnv,
|
||||
): Promise<Response> {
|
||||
const capability = `${agent}--${capName}`
|
||||
try {
|
||||
return await env.backend.invoke(capability, request)
|
||||
} catch (e) {
|
||||
if (e instanceof PageRateLimitError) {
|
||||
return jsonError(503, 'Page rate limit exceeded', { retry_after: e.retry_after })
|
||||
}
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
function jsonOk(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
|
||||
function jsonError(status: number, message: string, extra?: Record<string, unknown>): Response {
|
||||
return new Response(JSON.stringify({ error: message, ...extra }), {
|
||||
status,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { createMockKv, createMockCfApi, makeRequest } 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'
|
||||
|
||||
describe('S1: 部署能力', () => {
|
||||
let mockKv: KVNamespace
|
||||
let mockCf: ReturnType<typeof createMockCfApi>
|
||||
let pool: WorkerPool
|
||||
let auth: AuthModule
|
||||
let kv: KvStore
|
||||
|
||||
beforeEach(async () => {
|
||||
mockKv = createMockKv()
|
||||
mockCf = createMockCfApi()
|
||||
pool = new WorkerPool(mockKv, mockCf.cfApi)
|
||||
kv = new KvStore(mockKv)
|
||||
auth = new AuthModule(kv)
|
||||
|
||||
// Register agent
|
||||
await auth.registerAgent('xiaoju', 'token-xiaoju')
|
||||
})
|
||||
|
||||
it('should deploy a capability via API', async () => {
|
||||
const req = makeRequest('POST', '/_api/deploy', {
|
||||
token: 'token-xiaoju',
|
||||
body: {
|
||||
agent: 'xiaoju',
|
||||
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
|
||||
}
|
||||
expect(body.capability).toBe('xiaoju--ping')
|
||||
expect(body.url).toBe('https://sigil.shazhou.workers.dev/xiaoju/ping')
|
||||
expect(body.cold_start).toBe(false)
|
||||
})
|
||||
|
||||
it('should call CfApi.deployWorker', async () => {
|
||||
await pool.deploy({
|
||||
agent: 'xiaoju',
|
||||
name: 'ping',
|
||||
code: "export default { fetch() { return new Response('pong') } }",
|
||||
type: 'normal',
|
||||
})
|
||||
|
||||
expect(mockCf.deployCalls()).toContain('s-xiaoju-ping')
|
||||
})
|
||||
|
||||
it('should write KV entries (code, meta, lru, route)', async () => {
|
||||
await pool.deploy({
|
||||
agent: 'xiaoju',
|
||||
name: 'ping',
|
||||
code: "export default { fetch() { return new Response('pong') } }",
|
||||
type: 'normal',
|
||||
})
|
||||
|
||||
const code = await kv.getCode('xiaoju--ping')
|
||||
expect(code).toBeTruthy()
|
||||
|
||||
const meta = await kv.getMeta('xiaoju--ping')
|
||||
expect(meta?.agent).toBe('xiaoju')
|
||||
expect(meta?.name).toBe('ping')
|
||||
expect(meta?.type).toBe('normal')
|
||||
|
||||
const lru = await kv.getLru('xiaoju--ping')
|
||||
expect(lru?.deployed).toBe(true)
|
||||
expect(lru?.access_count).toBe(0)
|
||||
|
||||
const route = await kv.getRoute('xiaoju--ping')
|
||||
expect(route?.worker_name).toBe('s-xiaoju-ping')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { createMockKv, createMockCfApi } from './setup.js'
|
||||
import { WorkerPool } from '../src/backend/worker-pool.js'
|
||||
import { KvStore } from '../src/kv.js'
|
||||
|
||||
describe('S2: 调用已部署能力(命中)', () => {
|
||||
let mockKv: KVNamespace
|
||||
let mockCf: ReturnType<typeof createMockCfApi>
|
||||
let pool: WorkerPool
|
||||
let kv: KvStore
|
||||
|
||||
beforeEach(async () => {
|
||||
mockKv = createMockKv()
|
||||
mockCf = createMockCfApi({
|
||||
invokeResponse: (_workerName, _req) => new Response('pong', { status: 200 }),
|
||||
})
|
||||
pool = new WorkerPool(mockKv, mockCf.cfApi)
|
||||
kv = new KvStore(mockKv)
|
||||
|
||||
// Deploy first
|
||||
await pool.deploy({
|
||||
agent: 'xiaoju',
|
||||
name: 'ping',
|
||||
code: "export default { fetch() { return new Response('pong') } }",
|
||||
type: 'normal',
|
||||
})
|
||||
mockCf.reset()
|
||||
})
|
||||
|
||||
it('should invoke warm capability', async () => {
|
||||
const req = new Request('https://sigil.shazhou.workers.dev/xiaoju/ping')
|
||||
const resp = await pool.invoke('xiaoju--ping', req)
|
||||
expect(resp.status).toBe(200)
|
||||
expect(await resp.text()).toBe('pong')
|
||||
})
|
||||
|
||||
it('should update lru.last_access on warm hit', async () => {
|
||||
const lruBefore = await kv.getLru('xiaoju--ping')
|
||||
const accessBefore = lruBefore!.last_access
|
||||
|
||||
await new Promise(r => setTimeout(r, 5))
|
||||
|
||||
const req = new Request('https://sigil.shazhou.workers.dev/xiaoju/ping')
|
||||
await pool.invoke('xiaoju--ping', req)
|
||||
|
||||
const lruAfter = await kv.getLru('xiaoju--ping')
|
||||
expect(lruAfter!.last_access).toBeGreaterThan(accessBefore)
|
||||
expect(lruAfter!.access_count).toBe(1)
|
||||
})
|
||||
|
||||
it('should NOT call deployWorker on warm hit', async () => {
|
||||
const req = new Request('https://sigil.shazhou.workers.dev/xiaoju/ping')
|
||||
await pool.invoke('xiaoju--ping', req)
|
||||
expect(mockCf.deployCalls()).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { createMockKv, createMockCfApi } from './setup.js'
|
||||
import { WorkerPool } from '../src/backend/worker-pool.js'
|
||||
import { KvStore } from '../src/kv.js'
|
||||
|
||||
describe('S3: 调用未部署能力(换入)', () => {
|
||||
let mockKv: KVNamespace
|
||||
let mockCf: ReturnType<typeof createMockCfApi>
|
||||
let pool: WorkerPool
|
||||
let kv: KvStore
|
||||
|
||||
beforeEach(async () => {
|
||||
mockKv = createMockKv()
|
||||
mockCf = createMockCfApi({
|
||||
invokeResponse: () => new Response('pong', { status: 200 }),
|
||||
})
|
||||
pool = new WorkerPool(mockKv, mockCf.cfApi)
|
||||
kv = new KvStore(mockKv)
|
||||
|
||||
// Manually write KV to simulate "evicted but not deleted from KV" state
|
||||
await kv.setCode('xiaoju--ping', "export default { fetch() { return new Response('pong') } }")
|
||||
await kv.setMeta('xiaoju--ping', {
|
||||
type: 'normal',
|
||||
created_at: Date.now() - 10000,
|
||||
agent: 'xiaoju',
|
||||
name: 'ping',
|
||||
})
|
||||
await kv.setLru('xiaoju--ping', {
|
||||
last_access: Date.now() - 10000,
|
||||
access_count: 5,
|
||||
deployed: false, // key: not deployed
|
||||
})
|
||||
})
|
||||
|
||||
it('should page in and call deployWorker', async () => {
|
||||
const req = new Request('https://sigil.shazhou.workers.dev/xiaoju/ping')
|
||||
const resp = await pool.invoke('xiaoju--ping', req)
|
||||
|
||||
expect(resp.status).toBe(200)
|
||||
expect(mockCf.deployCalls()).toContain('s-xiaoju-ping')
|
||||
})
|
||||
|
||||
it('should set lru.deployed=true after page-in', async () => {
|
||||
const req = new Request('https://sigil.shazhou.workers.dev/xiaoju/ping')
|
||||
await pool.invoke('xiaoju--ping', req)
|
||||
|
||||
const lru = await kv.getLru('xiaoju--ping')
|
||||
expect(lru?.deployed).toBe(true)
|
||||
})
|
||||
|
||||
it('should set X-Sigil-Cold-Start header', async () => {
|
||||
const req = new Request('https://sigil.shazhou.workers.dev/xiaoju/ping')
|
||||
const resp = await pool.invoke('xiaoju--ping', req)
|
||||
|
||||
expect(resp.headers.get('X-Sigil-Cold-Start')).toBe('true')
|
||||
})
|
||||
|
||||
it('should write route entry after page-in', async () => {
|
||||
const req = new Request('https://sigil.shazhou.workers.dev/xiaoju/ping')
|
||||
await pool.invoke('xiaoju--ping', req)
|
||||
|
||||
const route = await kv.getRoute('xiaoju--ping')
|
||||
expect(route?.worker_name).toBe('s-xiaoju-ping')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,158 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { createMockKv, createMockCfApi } 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: 配额满时换出', () => {
|
||||
let mockKv: KVNamespace
|
||||
let mockCf: ReturnType<typeof createMockCfApi>
|
||||
let pool: WorkerPool
|
||||
let kv: KvStore
|
||||
|
||||
beforeEach(async () => {
|
||||
mockKv = createMockKv()
|
||||
mockCf = createMockCfApi({
|
||||
invokeResponse: () => new Response('ok', { status: 200 }),
|
||||
})
|
||||
pool = new WorkerPool(mockKv, mockCf.cfApi)
|
||||
kv = new KvStore(mockKv)
|
||||
})
|
||||
|
||||
it('should evict the coldest capability when slots are full', async () => {
|
||||
const baseTime = Date.now() - 100000
|
||||
|
||||
// Fill up all slots (MAX_SLOTS = 10)
|
||||
for (let i = 0; i < CONFIG.MAX_SLOTS; i++) {
|
||||
const name = `cap${i}`
|
||||
const capability = `xiaoju--${name}`
|
||||
await kv.setCode(capability, `// code ${i}`)
|
||||
await kv.setMeta(capability, {
|
||||
type: 'normal',
|
||||
created_at: baseTime + i * 100,
|
||||
agent: 'xiaoju',
|
||||
name,
|
||||
})
|
||||
await kv.setLru(capability, {
|
||||
last_access: baseTime + i * 100, // cap0 is coldest
|
||||
access_count: i,
|
||||
deployed: true,
|
||||
})
|
||||
await kv.setRoute(capability, {
|
||||
worker_name: `s-xiaoju-${name}`,
|
||||
subdomain: `s-xiaoju-${name}.shazhou.workers.dev`,
|
||||
})
|
||||
}
|
||||
|
||||
// Deploy one more — should trigger eviction of cap0 (oldest last_access)
|
||||
const result = await pool.deploy({
|
||||
agent: 'xiaoju',
|
||||
name: 'new-cap',
|
||||
code: '// new',
|
||||
type: 'normal',
|
||||
})
|
||||
|
||||
expect(result.capability).toBe('xiaoju--new-cap')
|
||||
expect(result.evicted).toBe('xiaoju--cap0')
|
||||
|
||||
// cap0 should have been deleted
|
||||
expect(mockCf.deleteCalls()).toContain('s-xiaoju-cap0')
|
||||
|
||||
// cap0 lru should be deployed=false
|
||||
const evictedLru = await kv.getLru('xiaoju--cap0')
|
||||
expect(evictedLru?.deployed).toBe(false)
|
||||
})
|
||||
|
||||
it('should increment eviction count', async () => {
|
||||
const baseTime = Date.now() - 100000
|
||||
|
||||
for (let i = 0; i < CONFIG.MAX_SLOTS; i++) {
|
||||
const name = `cap${i}`
|
||||
const capability = `xiaoju--${name}`
|
||||
await kv.setCode(capability, `// code ${i}`)
|
||||
await kv.setMeta(capability, {
|
||||
type: 'normal',
|
||||
created_at: baseTime + i * 100,
|
||||
agent: 'xiaoju',
|
||||
name,
|
||||
})
|
||||
await kv.setLru(capability, {
|
||||
last_access: baseTime + i * 100,
|
||||
access_count: i,
|
||||
deployed: true,
|
||||
})
|
||||
await kv.setRoute(capability, {
|
||||
worker_name: `s-xiaoju-${name}`,
|
||||
subdomain: `s-xiaoju-${name}.shazhou.workers.dev`,
|
||||
})
|
||||
}
|
||||
|
||||
await pool.deploy({
|
||||
agent: 'xiaoju',
|
||||
name: 'new-cap',
|
||||
code: '// new',
|
||||
type: 'normal',
|
||||
})
|
||||
|
||||
const evictionCount = await kv.getEvictionCount()
|
||||
expect(evictionCount).toBe(1)
|
||||
})
|
||||
|
||||
it('should prefer evicting ephemeral_expired over normal', async () => {
|
||||
const baseTime = Date.now() - 100000
|
||||
const expiredEphemeralCreated = Date.now() - 10000
|
||||
|
||||
// Fill 9 normal caps
|
||||
for (let i = 0; i < CONFIG.MAX_SLOTS - 1; i++) {
|
||||
const name = `normal${i}`
|
||||
const capability = `xiaoju--${name}`
|
||||
await kv.setCode(capability, `// code ${i}`)
|
||||
await kv.setMeta(capability, {
|
||||
type: 'normal',
|
||||
created_at: baseTime + i * 100,
|
||||
agent: 'xiaoju',
|
||||
name,
|
||||
})
|
||||
await kv.setLru(capability, {
|
||||
last_access: baseTime + i * 100,
|
||||
access_count: 10, // high access
|
||||
deployed: true,
|
||||
})
|
||||
await kv.setRoute(capability, {
|
||||
worker_name: `s-xiaoju-${name}`,
|
||||
subdomain: `s-xiaoju-${name}.shazhou.workers.dev`,
|
||||
})
|
||||
}
|
||||
|
||||
// Add 1 expired ephemeral (more recently accessed but expired)
|
||||
await kv.setCode('xiaoju--ephemeral-old', '// ephemeral')
|
||||
await kv.setMeta('xiaoju--ephemeral-old', {
|
||||
type: 'ephemeral',
|
||||
ttl: 1, // 1 second TTL, already expired
|
||||
created_at: expiredEphemeralCreated,
|
||||
agent: 'xiaoju',
|
||||
name: 'ephemeral-old',
|
||||
})
|
||||
await kv.setLru('xiaoju--ephemeral-old', {
|
||||
last_access: Date.now() - 100, // recently accessed
|
||||
access_count: 100,
|
||||
deployed: true,
|
||||
})
|
||||
await kv.setRoute('xiaoju--ephemeral-old', {
|
||||
worker_name: 's-xiaoju-ephemeral-old',
|
||||
subdomain: 's-xiaoju-ephemeral-old.shazhou.workers.dev',
|
||||
})
|
||||
|
||||
// Deploy one more
|
||||
const result = await pool.deploy({
|
||||
agent: 'xiaoju',
|
||||
name: 'newcomer',
|
||||
code: '// new',
|
||||
type: 'normal',
|
||||
})
|
||||
|
||||
// Should evict the expired ephemeral, not the coldest normal
|
||||
expect(result.evicted).toBe('xiaoju--ephemeral-old')
|
||||
expect(mockCf.deleteCalls()).toContain('s-xiaoju-ephemeral-old')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { createMockKv, createMockCfApi } from './setup.js'
|
||||
import { WorkerPool } from '../src/backend/worker-pool.js'
|
||||
|
||||
describe('S5: 调用不存在的能力', () => {
|
||||
let mockKv: KVNamespace
|
||||
let mockCf: ReturnType<typeof createMockCfApi>
|
||||
let pool: WorkerPool
|
||||
|
||||
beforeEach(() => {
|
||||
mockKv = createMockKv()
|
||||
mockCf = createMockCfApi()
|
||||
pool = new WorkerPool(mockKv, mockCf.cfApi)
|
||||
})
|
||||
|
||||
it('should return 404 for nonexistent capability', async () => {
|
||||
const req = new Request('https://sigil.shazhou.workers.dev/xiaoju/nonexistent')
|
||||
const resp = await pool.invoke('xiaoju--nonexistent', req)
|
||||
expect(resp.status).toBe(404)
|
||||
})
|
||||
|
||||
it('should return error JSON body', async () => {
|
||||
const req = new Request('https://sigil.shazhou.workers.dev/xiaoju/nonexistent')
|
||||
const resp = await pool.invoke('xiaoju--nonexistent', req)
|
||||
const body = await resp.json() as { error: string }
|
||||
expect(body.error).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should not call deployWorker for nonexistent', async () => {
|
||||
const req = new Request('https://sigil.shazhou.workers.dev/xiaoju/nonexistent')
|
||||
await pool.invoke('xiaoju--nonexistent', req)
|
||||
expect(mockCf.deployCalls()).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { createMockKv, createMockCfApi, makeRequest } 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'
|
||||
|
||||
describe('S6: 删除能力', () => {
|
||||
let mockKv: KVNamespace
|
||||
let mockCf: ReturnType<typeof createMockCfApi>
|
||||
let pool: WorkerPool
|
||||
let auth: AuthModule
|
||||
let kv: KvStore
|
||||
|
||||
beforeEach(async () => {
|
||||
mockKv = createMockKv()
|
||||
mockCf = createMockCfApi()
|
||||
pool = new WorkerPool(mockKv, mockCf.cfApi)
|
||||
kv = new KvStore(mockKv)
|
||||
auth = new AuthModule(kv)
|
||||
|
||||
await auth.registerAgent('xiaoju', 'token-xiaoju')
|
||||
|
||||
// Deploy first
|
||||
await pool.deploy({
|
||||
agent: 'xiaoju',
|
||||
name: 'ping',
|
||||
code: "export default { fetch() { return new Response('pong') } }",
|
||||
type: 'normal',
|
||||
})
|
||||
mockCf.reset()
|
||||
})
|
||||
|
||||
it('should call CfApi.deleteWorker', async () => {
|
||||
const req = makeRequest('DELETE', '/_api/remove', {
|
||||
token: 'token-xiaoju',
|
||||
body: { capability: 'xiaoju--ping' },
|
||||
})
|
||||
|
||||
const resp = await handleRequest(req, { SIGIL_KV: mockKv, backend: pool, auth, kv })
|
||||
expect(resp.status).toBe(200)
|
||||
expect(mockCf.deleteCalls()).toContain('s-xiaoju-ping')
|
||||
})
|
||||
|
||||
it('should clear all KV entries', async () => {
|
||||
await pool.remove('xiaoju--ping')
|
||||
|
||||
expect(await kv.getCode('xiaoju--ping')).toBeNull()
|
||||
expect(await kv.getMeta('xiaoju--ping')).toBeNull()
|
||||
expect(await kv.getLru('xiaoju--ping')).toBeNull()
|
||||
expect(await kv.getRoute('xiaoju--ping')).toBeNull()
|
||||
})
|
||||
|
||||
it('should return removed capability in response', async () => {
|
||||
const req = makeRequest('DELETE', '/_api/remove', {
|
||||
token: 'token-xiaoju',
|
||||
body: { capability: 'xiaoju--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('xiaoju--ping')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,71 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { createMockKv, createMockCfApi, makeRequest } 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'
|
||||
|
||||
describe('S7: 列出能力', () => {
|
||||
let mockKv: KVNamespace
|
||||
let mockCf: ReturnType<typeof createMockCfApi>
|
||||
let pool: WorkerPool
|
||||
let auth: AuthModule
|
||||
let kv: KvStore
|
||||
|
||||
beforeEach(async () => {
|
||||
mockKv = createMockKv()
|
||||
mockCf = createMockCfApi()
|
||||
pool = new WorkerPool(mockKv, mockCf.cfApi)
|
||||
kv = new KvStore(mockKv)
|
||||
auth = new AuthModule(kv)
|
||||
|
||||
await auth.registerAgent('xiaoju', 'token-xiaoju')
|
||||
await auth.registerAgent('xiaomooo', 'token-xiaomooo')
|
||||
|
||||
// Deploy 3 for xiaoju
|
||||
for (const name of ['ping', 'echo', 'calc']) {
|
||||
await pool.deploy({
|
||||
agent: 'xiaoju',
|
||||
name,
|
||||
code: `// ${name}`,
|
||||
type: 'normal',
|
||||
})
|
||||
}
|
||||
|
||||
// Deploy 1 for xiaomooo
|
||||
await pool.deploy({
|
||||
agent: 'xiaomooo',
|
||||
name: 'hello',
|
||||
code: '// hello',
|
||||
type: 'normal',
|
||||
})
|
||||
})
|
||||
|
||||
it('should return only xiaoju capabilities when filtered', async () => {
|
||||
const req = makeRequest('GET', '/_api/list?agent=xiaoju', {
|
||||
token: 'token-xiaoju',
|
||||
})
|
||||
|
||||
const resp = await handleRequest(req, { SIGIL_KV: mockKv, backend: pool, auth, kv })
|
||||
expect(resp.status).toBe(200)
|
||||
|
||||
const body = await resp.json() as { capabilities: Array<{ capability: string }> }
|
||||
expect(body.capabilities).toHaveLength(3)
|
||||
|
||||
const names = body.capabilities.map(c => c.capability)
|
||||
expect(names).toContain('xiaoju--ping')
|
||||
expect(names).toContain('xiaoju--echo')
|
||||
expect(names).toContain('xiaoju--calc')
|
||||
expect(names).not.toContain('xiaomooo--hello')
|
||||
})
|
||||
|
||||
it('should include capability metadata in response', async () => {
|
||||
const caps = await pool.list('xiaoju')
|
||||
expect(caps.length).toBe(3)
|
||||
for (const cap of caps) {
|
||||
expect(cap.agent).toBe('xiaoju')
|
||||
expect(cap.type).toBe('normal')
|
||||
expect(cap.deployed).toBe(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { createMockKv, createMockCfApi, makeRequest } 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'
|
||||
|
||||
describe('S8: 健康端点', () => {
|
||||
let mockKv: KVNamespace
|
||||
let mockCf: ReturnType<typeof createMockCfApi>
|
||||
let pool: WorkerPool
|
||||
let auth: AuthModule
|
||||
let kv: KvStore
|
||||
|
||||
beforeEach(async () => {
|
||||
mockKv = createMockKv()
|
||||
mockCf = createMockCfApi()
|
||||
pool = new WorkerPool(mockKv, mockCf.cfApi)
|
||||
kv = new KvStore(mockKv)
|
||||
auth = new AuthModule(kv)
|
||||
|
||||
// Deploy some capabilities
|
||||
await auth.registerAgent('xiaoju', 'token-xiaoju')
|
||||
await pool.deploy({
|
||||
agent: 'xiaoju',
|
||||
name: 'ping',
|
||||
code: '// ping',
|
||||
type: 'normal',
|
||||
})
|
||||
})
|
||||
|
||||
it('should return 200 on GET /_health', async () => {
|
||||
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 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
|
||||
agents: 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(typeof body.agents).toBe('number')
|
||||
expect(body.lru_enabled).toBe(true)
|
||||
expect(typeof body.eviction_count).toBe('number')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,76 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { createMockKv, createMockCfApi, makeRequest } 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'
|
||||
|
||||
describe('S9: 无 token 拒绝', () => {
|
||||
let mockKv: KVNamespace
|
||||
let mockCf: ReturnType<typeof createMockCfApi>
|
||||
let pool: WorkerPool
|
||||
let auth: AuthModule
|
||||
let kv: KvStore
|
||||
|
||||
beforeEach(() => {
|
||||
mockKv = createMockKv()
|
||||
mockCf = createMockCfApi()
|
||||
pool = new WorkerPool(mockKv, mockCf.cfApi)
|
||||
kv = new KvStore(mockKv)
|
||||
auth = new AuthModule(kv)
|
||||
})
|
||||
|
||||
it('should return 401 when no Authorization header', async () => {
|
||||
const req = makeRequest('POST', '/_api/deploy', {
|
||||
// No token
|
||||
body: {
|
||||
agent: 'xiaoju',
|
||||
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 when wrong token', async () => {
|
||||
const req = makeRequest('POST', '/_api/deploy', {
|
||||
token: 'wrong-token',
|
||||
body: {
|
||||
agent: 'xiaoju',
|
||||
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 req = makeRequest('DELETE', '/_api/remove', {
|
||||
body: { capability: 'xiaoju--ping' },
|
||||
})
|
||||
|
||||
const resp = await handleRequest(req, { SIGIL_KV: mockKv, backend: pool, auth, kv })
|
||||
expect(resp.status).toBe(401)
|
||||
})
|
||||
|
||||
it('should return error message in body', async () => {
|
||||
const req = makeRequest('POST', '/_api/deploy', {
|
||||
body: {
|
||||
agent: 'xiaoju',
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,90 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { createMockKv, createMockCfApi, makeRequest } 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'
|
||||
|
||||
describe('S10: Agent 只能操作自己的前缀', () => {
|
||||
let mockKv: KVNamespace
|
||||
let mockCf: ReturnType<typeof createMockCfApi>
|
||||
let pool: WorkerPool
|
||||
let auth: AuthModule
|
||||
let kv: KvStore
|
||||
|
||||
beforeEach(async () => {
|
||||
mockKv = createMockKv()
|
||||
mockCf = createMockCfApi()
|
||||
pool = new WorkerPool(mockKv, mockCf.cfApi)
|
||||
kv = new KvStore(mockKv)
|
||||
auth = new AuthModule(kv)
|
||||
|
||||
await auth.registerAgent('xiaoju', 'token-xiaoju')
|
||||
await auth.registerAgent('xiaomooo', 'token-xiaomooo')
|
||||
})
|
||||
|
||||
it('should return 403 when xiaoju tries to deploy as xiaomooo', async () => {
|
||||
const req = makeRequest('POST', '/_api/deploy', {
|
||||
token: 'token-xiaoju', // xiaoju's token
|
||||
body: {
|
||||
agent: 'xiaomooo', // but claiming xiaomooo
|
||||
name: 'ping',
|
||||
code: '// ping',
|
||||
type: 'normal',
|
||||
},
|
||||
})
|
||||
|
||||
const resp = await handleRequest(req, { SIGIL_KV: mockKv, backend: pool, auth, kv })
|
||||
expect(resp.status).toBe(403)
|
||||
})
|
||||
|
||||
it('should return 403 error message', async () => {
|
||||
const req = makeRequest('POST', '/_api/deploy', {
|
||||
token: 'token-xiaoju',
|
||||
body: {
|
||||
agent: 'xiaomooo',
|
||||
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).toContain('xiaoju')
|
||||
})
|
||||
|
||||
it('should allow xiaoju to deploy their own capability', async () => {
|
||||
const req = makeRequest('POST', '/_api/deploy', {
|
||||
token: 'token-xiaoju',
|
||||
body: {
|
||||
agent: 'xiaoju',
|
||||
name: 'ping',
|
||||
code: '// ping',
|
||||
type: 'normal',
|
||||
},
|
||||
})
|
||||
|
||||
const resp = await handleRequest(req, { SIGIL_KV: mockKv, backend: pool, auth, kv })
|
||||
expect(resp.status).toBe(201)
|
||||
})
|
||||
|
||||
it('should return 403 when removing another agent capability', async () => {
|
||||
// First deploy xiaomooo's capability legitimately
|
||||
await pool.deploy({
|
||||
agent: 'xiaomooo',
|
||||
name: 'hello',
|
||||
code: '// hello',
|
||||
type: 'normal',
|
||||
})
|
||||
|
||||
// xiaoju tries to remove it
|
||||
const req = makeRequest('DELETE', '/_api/remove', {
|
||||
token: 'token-xiaoju',
|
||||
body: { capability: 'xiaomooo--hello' },
|
||||
})
|
||||
|
||||
const resp = await handleRequest(req, { SIGIL_KV: mockKv, backend: pool, auth, kv })
|
||||
expect(resp.status).toBe(403)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { createMockKv, createMockCfApi } from './setup.js'
|
||||
import { WorkerPool } from '../src/backend/worker-pool.js'
|
||||
import { KvStore } from '../src/kv.js'
|
||||
|
||||
describe('S11: 并发换入去重', () => {
|
||||
let mockKv: KVNamespace
|
||||
let mockCf: ReturnType<typeof createMockCfApi>
|
||||
let pool: WorkerPool
|
||||
let kv: KvStore
|
||||
|
||||
beforeEach(async () => {
|
||||
mockKv = createMockKv()
|
||||
mockCf = createMockCfApi({
|
||||
invokeResponse: () => new Response('pong', { status: 200 }),
|
||||
})
|
||||
pool = new WorkerPool(mockKv, mockCf.cfApi)
|
||||
kv = new KvStore(mockKv)
|
||||
|
||||
// Simulate evicted capability: code in KV but not deployed
|
||||
await kv.setCode('xiaoju--ping', "export default { fetch() { return new Response('pong') } }")
|
||||
await kv.setMeta('xiaoju--ping', {
|
||||
type: 'normal',
|
||||
created_at: Date.now() - 10000,
|
||||
agent: 'xiaoju',
|
||||
name: 'ping',
|
||||
})
|
||||
await kv.setLru('xiaoju--ping', {
|
||||
last_access: Date.now() - 10000,
|
||||
access_count: 0,
|
||||
deployed: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('should call deployWorker only once for concurrent page-ins', async () => {
|
||||
const req1 = new Request('https://sigil.shazhou.workers.dev/xiaoju/ping')
|
||||
const req2 = new Request('https://sigil.shazhou.workers.dev/xiaoju/ping')
|
||||
|
||||
// Fire concurrently
|
||||
const [resp1, resp2] = await Promise.all([
|
||||
pool.invoke('xiaoju--ping', req1),
|
||||
pool.invoke('xiaoju--ping', req2),
|
||||
])
|
||||
|
||||
expect(resp1.status).toBe(200)
|
||||
expect(resp2.status).toBe(200)
|
||||
|
||||
// Should only deploy once
|
||||
const deployCalls = mockCf.deployCalls()
|
||||
expect(deployCalls.filter(n => n === 's-xiaoju-ping')).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,102 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { createMockKv, createMockCfApi } from './setup.js'
|
||||
import { WorkerPool } from '../src/backend/worker-pool.js'
|
||||
import { KvStore } from '../src/kv.js'
|
||||
import { CONFIG } from '../src/config.js'
|
||||
import { PageRateLimitError } from '../src/lru.js'
|
||||
|
||||
describe('S12: 换页速率限制', () => {
|
||||
let mockKv: KVNamespace
|
||||
let mockCf: ReturnType<typeof createMockCfApi>
|
||||
let pool: WorkerPool
|
||||
let kv: KvStore
|
||||
|
||||
beforeEach(async () => {
|
||||
mockKv = createMockKv()
|
||||
mockCf = createMockCfApi({
|
||||
invokeResponse: () => new Response('ok', { status: 200 }),
|
||||
})
|
||||
pool = new WorkerPool(mockKv, mockCf.cfApi)
|
||||
kv = new KvStore(mockKv)
|
||||
})
|
||||
|
||||
async function setupCapability(name: string): Promise<void> {
|
||||
const capability = `xiaoju--${name}`
|
||||
await kv.setCode(capability, `// ${name}`)
|
||||
await kv.setMeta(capability, {
|
||||
type: 'normal',
|
||||
created_at: Date.now() - 10000,
|
||||
agent: 'xiaoju',
|
||||
name,
|
||||
})
|
||||
await kv.setLru(capability, {
|
||||
last_access: Date.now() - 10000,
|
||||
access_count: 0,
|
||||
deployed: false, // evicted
|
||||
})
|
||||
}
|
||||
|
||||
it(`should allow up to ${CONFIG.PAGE_RATE_LIMIT} page-ins per minute`, async () => {
|
||||
const results: boolean[] = []
|
||||
|
||||
for (let i = 0; i < CONFIG.PAGE_RATE_LIMIT; i++) {
|
||||
const name = `cap${i}`
|
||||
await setupCapability(name)
|
||||
|
||||
const req = new Request(`https://sigil.shazhou.workers.dev/xiaoju/${name}`)
|
||||
const resp = await pool.invoke(`xiaoju--${name}`, req)
|
||||
results.push(resp.status === 200)
|
||||
}
|
||||
|
||||
expect(results.every(Boolean)).toBe(true)
|
||||
})
|
||||
|
||||
it(`should reject the ${CONFIG.PAGE_RATE_LIMIT + 1}th page-in with 503`, async () => {
|
||||
// Do PAGE_RATE_LIMIT page-ins
|
||||
for (let i = 0; i < CONFIG.PAGE_RATE_LIMIT; i++) {
|
||||
const name = `cap${i}`
|
||||
await setupCapability(name)
|
||||
const req = new Request(`https://sigil.shazhou.workers.dev/xiaoju/${name}`)
|
||||
await pool.invoke(`xiaoju--${name}`, req)
|
||||
}
|
||||
|
||||
// 11th one should fail
|
||||
const name = `cap${CONFIG.PAGE_RATE_LIMIT}`
|
||||
await setupCapability(name)
|
||||
|
||||
const req = new Request(`https://sigil.shazhou.workers.dev/xiaoju/${name}`)
|
||||
try {
|
||||
const resp = await pool.invoke(`xiaoju--${name}`, req)
|
||||
// If it doesn't throw, check status
|
||||
expect(resp.status).toBe(503)
|
||||
} catch (e) {
|
||||
expect(e).toBeInstanceOf(PageRateLimitError)
|
||||
}
|
||||
})
|
||||
|
||||
it('should include retry_after in error', async () => {
|
||||
// Fill rate
|
||||
for (let i = 0; i < CONFIG.PAGE_RATE_LIMIT; i++) {
|
||||
const name = `cap${i}`
|
||||
await setupCapability(name)
|
||||
const req = new Request(`https://sigil.shazhou.workers.dev/xiaoju/${name}`)
|
||||
await pool.invoke(`xiaoju--${name}`, req)
|
||||
}
|
||||
|
||||
const name = `cap${CONFIG.PAGE_RATE_LIMIT}`
|
||||
await setupCapability(name)
|
||||
const req = new Request(`https://sigil.shazhou.workers.dev/xiaoju/${name}`)
|
||||
|
||||
try {
|
||||
const resp = await pool.invoke(`xiaoju--${name}`, req)
|
||||
if (resp.status === 503) {
|
||||
const body = await resp.json() as { error: string; retry_after?: number }
|
||||
// retry_after may be 0 for immediate window, just check it exists or we got exception
|
||||
expect(body.error).toBeTruthy()
|
||||
}
|
||||
} catch (e) {
|
||||
expect(e).toBeInstanceOf(PageRateLimitError)
|
||||
expect((e as PageRateLimitError).retry_after).toBeGreaterThanOrEqual(0)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,88 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { createMockKv, createMockCfApi, makeRequest } 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'
|
||||
|
||||
describe('S13: deploy_cooldown', () => {
|
||||
let mockKv: KVNamespace
|
||||
let mockCf: ReturnType<typeof createMockCfApi>
|
||||
let pool: WorkerPool
|
||||
let auth: AuthModule
|
||||
let kv: KvStore
|
||||
|
||||
beforeEach(async () => {
|
||||
mockKv = createMockKv()
|
||||
mockCf = createMockCfApi()
|
||||
pool = new WorkerPool(mockKv, mockCf.cfApi)
|
||||
kv = new KvStore(mockKv)
|
||||
auth = new AuthModule(kv)
|
||||
|
||||
await auth.registerAgent('xiaoju', 'token-xiaoju')
|
||||
})
|
||||
|
||||
it('should reject rapid second deploy with 429', async () => {
|
||||
// First deploy
|
||||
const req1 = makeRequest('POST', '/_api/deploy', {
|
||||
token: 'token-xiaoju',
|
||||
body: {
|
||||
agent: 'xiaoju',
|
||||
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: 'token-xiaoju',
|
||||
body: {
|
||||
agent: 'xiaoju',
|
||||
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 response', async () => {
|
||||
// First deploy
|
||||
const req1 = makeRequest('POST', '/_api/deploy', {
|
||||
token: 'token-xiaoju',
|
||||
body: { agent: 'xiaoju', 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: 'token-xiaoju',
|
||||
body: { agent: 'xiaoju', 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 () => {
|
||||
// Manually set cooldown as already expired
|
||||
await kv.setAuth('xiaoju', {
|
||||
token: 'token-xiaoju',
|
||||
deploy_cooldown_until: Date.now() - 1000, // already past
|
||||
})
|
||||
|
||||
const req = makeRequest('POST', '/_api/deploy', {
|
||||
token: 'token-xiaoju',
|
||||
body: { agent: 'xiaoju', name: 'ping', code: '// ping', type: 'normal' },
|
||||
})
|
||||
|
||||
const resp = await handleRequest(req, { SIGIL_KV: mockKv, backend: pool, auth, kv })
|
||||
expect(resp.status).toBe(201)
|
||||
})
|
||||
})
|
||||
+174
@@ -0,0 +1,174 @@
|
||||
// Test setup — mock KV and CfApi
|
||||
|
||||
export interface MockKvEntry {
|
||||
value: string
|
||||
metadata?: unknown
|
||||
}
|
||||
|
||||
/**
|
||||
* In-memory KVNamespace mock.
|
||||
*/
|
||||
export function createMockKv(): KVNamespace {
|
||||
const store = new Map<string, MockKvEntry>()
|
||||
|
||||
return {
|
||||
async get(key: string, options?: { type?: string } | string): Promise<unknown> {
|
||||
const entry = store.get(key)
|
||||
if (!entry) return null
|
||||
|
||||
const type = typeof options === 'string' ? options : options?.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?: { 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)
|
||||
return { value, metadata: entry.metadata ?? null }
|
||||
},
|
||||
|
||||
async put(key: string, value: string | ArrayBuffer | ArrayBufferView | ReadableStream, options?: { expiration?: number; expirationTtl?: number; metadata?: unknown }): Promise<void> {
|
||||
let strVal: string
|
||||
if (typeof value === 'string') {
|
||||
strVal = value
|
||||
} else if (value instanceof ArrayBuffer) {
|
||||
strVal = new TextDecoder().decode(value)
|
||||
} else {
|
||||
strVal = String(value)
|
||||
}
|
||||
store.set(key, { value: strVal, metadata: options?.metadata })
|
||||
},
|
||||
|
||||
async delete(key: string): Promise<void> {
|
||||
store.delete(key)
|
||||
},
|
||||
|
||||
async list(options?: { prefix?: string; limit?: number; cursor?: string }): Promise<KVNamespaceListResult<unknown, string>> {
|
||||
const prefix = options?.prefix ?? ''
|
||||
const limit = options?.limit ?? 1000
|
||||
const keys = Array.from(store.keys())
|
||||
.filter(k => k.startsWith(prefix))
|
||||
.slice(0, limit)
|
||||
.map(name => ({ name, expiration: undefined, metadata: undefined }))
|
||||
|
||||
return {
|
||||
keys,
|
||||
list_complete: true,
|
||||
cursor: '',
|
||||
cacheStatus: null,
|
||||
}
|
||||
},
|
||||
} as unknown as KVNamespace
|
||||
}
|
||||
|
||||
export interface MockCfApiCall {
|
||||
method: 'deployWorker' | 'deleteWorker' | 'invoke'
|
||||
args: unknown[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock CfApi that records calls without real CF API interaction.
|
||||
*/
|
||||
export function createMockCfApi(overrides?: {
|
||||
invokeResponse?: (workerName: string, request: Request) => Response
|
||||
}) {
|
||||
const calls: MockCfApiCall[] = []
|
||||
const deployedWorkers = new Set<string>()
|
||||
|
||||
return {
|
||||
calls,
|
||||
deployedWorkers,
|
||||
|
||||
cfApi: {
|
||||
async deployWorker(name: string, code: string): Promise<void> {
|
||||
calls.push({ method: 'deployWorker', args: [name, code] })
|
||||
deployedWorkers.add(name)
|
||||
},
|
||||
|
||||
async deleteWorker(name: string): Promise<void> {
|
||||
calls.push({ method: 'deleteWorker', args: [name] })
|
||||
deployedWorkers.delete(name)
|
||||
},
|
||||
|
||||
getWorkerSubdomain(name: string): string {
|
||||
return `${name}.shazhou.workers.dev`
|
||||
},
|
||||
|
||||
async invoke(workerName: string, request: Request): Promise<Response> {
|
||||
calls.push({ method: 'invoke', args: [workerName] })
|
||||
if (overrides?.invokeResponse) {
|
||||
return overrides.invokeResponse(workerName, request)
|
||||
}
|
||||
return new Response('mock response', { status: 200 })
|
||||
},
|
||||
},
|
||||
|
||||
deployCalls(): string[] {
|
||||
return calls.filter(c => c.method === 'deployWorker').map(c => c.args[0] as string)
|
||||
},
|
||||
|
||||
deleteCalls(): string[] {
|
||||
return calls.filter(c => c.method === 'deleteWorker').map(c => c.args[0] as string)
|
||||
},
|
||||
|
||||
reset(): void {
|
||||
calls.length = 0
|
||||
deployedWorkers.clear()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a test request helper.
|
||||
*/
|
||||
export function makeRequest(
|
||||
method: string,
|
||||
path: string,
|
||||
options?: {
|
||||
body?: unknown
|
||||
token?: string
|
||||
headers?: Record<string, string>
|
||||
},
|
||||
): Request {
|
||||
const url = `https://sigil.shazhou.workers.dev${path}`
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
...options?.headers,
|
||||
}
|
||||
|
||||
if (options?.token) {
|
||||
headers['Authorization'] = `Bearer ${options.token}`
|
||||
}
|
||||
|
||||
const init: RequestInit = {
|
||||
method,
|
||||
headers,
|
||||
}
|
||||
|
||||
if (options?.body !== undefined) {
|
||||
init.body = JSON.stringify(options.body)
|
||||
}
|
||||
|
||||
return new Request(url, init)
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ES2022",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2022"],
|
||||
"types": ["@cloudflare/workers-types"],
|
||||
"strict": true,
|
||||
"noImplicitAny": true,
|
||||
"strictNullChecks": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"exactOptionalPropertyTypes": true,
|
||||
"skipLibCheck": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "."
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'node',
|
||||
setupFiles: ['./test/setup.ts'],
|
||||
include: ['test/**/*.test.ts'],
|
||||
pool: 'forks',
|
||||
poolOptions: {
|
||||
forks: {
|
||||
singleFork: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,8 @@
|
||||
name = "sigil"
|
||||
main = "src/index.ts"
|
||||
compatibility_date = "2026-04-03"
|
||||
|
||||
[[kv_namespaces]]
|
||||
binding = "SIGIL_KV"
|
||||
id = "placeholder"
|
||||
preview_id = "placeholder"
|
||||
Reference in New Issue
Block a user