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:
2026-04-03 04:17:43 +00:00
parent 8d0a1e12ee
commit f20b19a71e
29 changed files with 7687 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
node_modules/
dist/
.wrangler/
*.log
+3399
View File
File diff suppressed because it is too large Load Diff
+22
View File
@@ -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"
}
}
+2050
View File
File diff suppressed because it is too large Load Diff
+93
View File
@@ -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 })
}
}
+47
View File
@@ -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>
}
+362
View File
@@ -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,
}
}
}
+11
View File
@@ -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
+36
View File
@@ -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 })
},
}
+141
View File
@@ -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
View File
@@ -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
View File
@@ -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' },
})
}
+84
View File
@@ -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')
})
})
+56
View File
@@ -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)
})
})
+65
View File
@@ -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')
})
})
+158
View File
@@ -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')
})
})
+34
View File
@@ -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)
})
})
+64
View File
@@ -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')
})
})
+71
View File
@@ -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)
}
})
})
+59
View File
@@ -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')
})
})
+76
View File
@@ -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()
})
})
+90
View File
@@ -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)
})
})
+52
View File
@@ -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)
})
})
+102
View File
@@ -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)
}
})
})
+88
View File
@@ -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
View File
@@ -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)
}
+21
View File
@@ -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"]
}
+16
View File
@@ -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,
},
},
},
})
+8
View File
@@ -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"