refactor: simplify to user-level shared capabilities

- Remove agent isolation (data sovereignty belongs to user, not agent)
- Capability naming: ping instead of xiaoju--ping
- Route: /run/{capability} instead of /{agent}/{capability}
- Auth: single deploy-token instead of per-agent tokens
- Delete S10 test (agent isolation no longer exists)
- Clean up old agent-prefixed workers
This commit is contained in:
2026-04-03 05:49:20 +00:00
parent b8b00f235e
commit 3705b158bb
20 changed files with 190 additions and 394 deletions
+18 -36
View File
@@ -26,9 +26,9 @@ export class AuthModule {
/**
* Validate Bearer token from Authorization header.
* Returns agent name on success, throws AuthError on failure.
* Throws AuthError on failure.
*/
async validateToken(authHeader: string | null): Promise<string> {
async validateToken(authHeader: string | null): Promise<void> {
if (!authHeader || !authHeader.startsWith('Bearer ')) {
throw new AuthError(401, 'Missing or invalid Authorization header')
}
@@ -38,56 +38,38 @@ export class AuthModule {
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`)
const auth = await this.kv.getDeployToken()
if (!auth || auth.token !== token) {
throw new AuthError(401, 'Invalid token')
}
}
/**
* Check deploy cooldown for agent. Throws DeployCooldownError if active.
* Check global deploy cooldown. Throws DeployCooldownError if active.
*/
async checkDeployCooldown(agent: string): Promise<void> {
const auth = await this.kv.getAuth(agent)
if (!auth) return
async checkDeployCooldown(): Promise<void> {
const lastDeploy = await this.kv.getLastDeployTime()
if (!lastDeploy) 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)
const cooldownUntil = lastDeploy + this.config.DEPLOY_COOLDOWN_MS
if (cooldownUntil > now) {
const retry_after = Math.ceil((cooldownUntil - now) / 1000)
throw new DeployCooldownError(retry_after)
}
}
/**
* Set deploy cooldown for agent.
* Set global deploy cooldown timestamp.
*/
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 })
async setDeployCooldown(): Promise<void> {
await this.kv.setLastDeployTime(Date.now())
}
/**
* Register a new agent with a token (used in tests).
* Set deploy token (used in tests).
*/
async registerAgent(agent: string, token: string): Promise<void> {
await this.kv.setAuth(agent, { token })
async setToken(token: string): Promise<void> {
await this.kv.setDeployToken({ token })
}
}
+3 -7
View File
@@ -1,5 +1,4 @@
export interface DeployParams {
agent: string
name: string | null // null = 自动生成 t-{hash}
code: string
type: 'persistent' | 'normal' | 'ephemeral'
@@ -8,7 +7,7 @@ export interface DeployParams {
}
export interface DeployResult {
capability: string // xiaoju--ping
capability: string // 直接就是 name,如 "ping"
url: string
expires_at?: string
cold_start: boolean
@@ -16,9 +15,7 @@ export interface DeployResult {
}
export interface Capability {
capability: string
agent: string
name: string
capability: string // 直接就是 name,如 "ping"
type: 'persistent' | 'normal' | 'ephemeral'
deployed: boolean
last_access: number
@@ -32,7 +29,6 @@ export interface BackendStatus {
backend: 'worker-pool' | 'platform'
total_slots: number
used_slots: number
agents: number
lru_enabled: boolean
eviction_count: number
}
@@ -41,7 +37,7 @@ 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[]>
list(): Promise<Capability[]>
inspect(name: string): Promise<Capability | null>
status(): Promise<BackendStatus>
}
+8 -20
View File
@@ -36,23 +36,22 @@ export class WorkerPool implements SigilBackend {
}
private getWorkerName(capability: string): string {
return `${this.config.WORKER_PREFIX}${capability.replace('--', '-')}`
return `${this.config.WORKER_PREFIX}${capability}`
}
async deploy(params: DeployParams): Promise<DeployResult> {
const { agent, name, code, type, ttl, bindings } = params
const { name, code, type, ttl, bindings } = params
// Determine capability name
let capabilityName: string
let capability: string
if (name === null) {
// Generate ephemeral name: t-{6hex}
const hash = await this.generateHash(code + Date.now())
capabilityName = `t-${hash}`
capability = `t-${hash}`
} else {
capabilityName = name
capability = name
}
const capability = `${agent}--${capabilityName}`
const workerName = this.getWorkerName(capability)
const now = Date.now()
@@ -91,8 +90,6 @@ export class WorkerPool implements SigilBackend {
ttl,
created_at: now,
bindings,
agent,
name: capabilityName,
})
await this.kv.setLru(capability, {
last_access: now,
@@ -104,7 +101,7 @@ export class WorkerPool implements SigilBackend {
subdomain,
})
const url = `${this.config.GATEWAY_URL}/${agent}/${capabilityName}`
const url = `${this.config.GATEWAY_URL}/run/${capability}`
const result: DeployResult = {
capability,
url,
@@ -287,9 +284,8 @@ export class WorkerPool implements SigilBackend {
await this.kv.deleteRoute(capabilityName)
}
async list(agent?: string): Promise<Capability[]> {
const prefix = agent ? `${agent}--` : undefined
const caps = await this.kv.listCapabilities(prefix)
async list(): Promise<Capability[]> {
const caps = await this.kv.listCapabilities()
const result: Capability[] = []
for (const cap of caps) {
@@ -299,8 +295,6 @@ export class WorkerPool implements SigilBackend {
const capability: Capability = {
capability: cap,
agent: meta.agent,
name: meta.name,
type: meta.type,
deployed: lru.deployed,
last_access: lru.last_access,
@@ -326,8 +320,6 @@ export class WorkerPool implements SigilBackend {
const capability: Capability = {
capability: capabilityName,
agent: meta.agent,
name: meta.name,
type: meta.type,
deployed: lru.deployed,
last_access: lru.last_access,
@@ -346,13 +338,10 @@ export class WorkerPool implements SigilBackend {
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()
@@ -361,7 +350,6 @@ export class WorkerPool implements SigilBackend {
backend: 'worker-pool',
total_slots: this.config.MAX_SLOTS,
used_slots: Math.min(usedSlots, this.config.MAX_SLOTS),
agents: agentSet.size,
lru_enabled: true,
eviction_count: evictionCount,
}
-1
View File
@@ -1,6 +1,5 @@
export const CONFIG = {
MAX_SLOTS: 3, // LRU 验证用,生产 ~400
MAX_AGENTS: 8,
DEPLOY_COOLDOWN_MS: 5000,
PAGE_RATE_LIMIT: 10, // 次/分钟
PAGE_RATE_WINDOW_MS: 60000,
+17 -16
View File
@@ -9,8 +9,6 @@ export interface KvMetaValue {
ttl?: number
created_at: number
bindings?: string[]
agent: string
name: string
}
export interface KvLruValue {
@@ -94,13 +92,13 @@ export class KvStore {
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
// auth:deploy-token — single unified token
async getDeployToken(): Promise<KvAuthValue | null> {
return await this.kv.get('auth:deploy-token', 'json') as KvAuthValue | null
}
async setAuth(agent: string, auth: KvAuthValue): Promise<void> {
await this.kv.put(`auth:${agent}`, JSON.stringify(auth))
async setDeployToken(auth: KvAuthValue): Promise<void> {
await this.kv.put('auth:deploy-token', JSON.stringify(auth))
}
// stats:eviction_count
@@ -126,16 +124,19 @@ export class KvStore {
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))
// stats:last_deploy_time — global deploy cooldown
async getLastDeployTime(): Promise<number> {
const v = await this.kv.get('stats:last_deploy_time', 'json') as { time: number } | null
return v?.time ?? 0
}
// 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))
async setLastDeployTime(time: number): Promise<void> {
await this.kv.put('stats:last_deploy_time', JSON.stringify({ time }))
}
// List all capabilities by prefix scanning
async listCapabilities(): Promise<string[]> {
const list = await this.kv.list({ prefix: 'lru:' })
return list.keys.map(k => k.name.slice('lru:'.length))
}
}
-8
View File
@@ -58,14 +58,6 @@ export class LruScheduler {
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.
+12 -33
View File
@@ -42,12 +42,11 @@ export async function handleRequest(request: Request, env: RouterEnv): Promise<R
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)
// GET /run/{capability} — invoke (no auth required)
const runMatch = path.match(/^\/run\/([^/]+)$/)
if (runMatch) {
const capability = runMatch[1]!
return handleInvoke(capability, request, env)
}
return jsonError(404, 'Not found')
@@ -61,10 +60,9 @@ async function handleHealth(env: RouterEnv): Promise<Response> {
async function handleDeploy(request: Request, env: RouterEnv): Promise<Response> {
try {
const authHeader = request.headers.get('Authorization')
const agent = await env.auth.validateToken(authHeader)
await env.auth.validateToken(authHeader)
const body = await request.json() as {
agent: string
name: string | null
code: string
type: 'persistent' | 'normal' | 'ephemeral'
@@ -72,14 +70,10 @@ async function handleDeploy(request: Request, env: RouterEnv): Promise<Response>
bindings?: string[]
}
// Check agent isolation
env.auth.checkAgentAccess(agent, body.agent)
// Check deploy cooldown
await env.auth.checkDeployCooldown(agent)
await env.auth.checkDeployCooldown()
const result = await env.backend.deploy({
agent: body.agent,
name: body.name,
code: body.code,
type: body.type,
@@ -88,7 +82,7 @@ async function handleDeploy(request: Request, env: RouterEnv): Promise<Response>
})
// Set cooldown after successful deploy
await env.auth.setDeployCooldown(agent)
await env.auth.setDeployCooldown()
return jsonOk(result, 201)
} catch (e) {
@@ -105,17 +99,11 @@ async function handleDeploy(request: Request, env: RouterEnv): Promise<Response>
async function handleRemove(request: Request, env: RouterEnv): Promise<Response> {
try {
const authHeader = request.headers.get('Authorization')
const agent = await env.auth.validateToken(authHeader)
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) {
@@ -129,16 +117,9 @@ async function handleRemove(request: Request, env: RouterEnv): Promise<Response>
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
await env.auth.validateToken(authHeader)
// 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)
const list = await env.backend.list()
return jsonOk({ capabilities: list })
} catch (e) {
if (e instanceof AuthError) {
@@ -157,12 +138,10 @@ async function handleInspect(capability: string, env: RouterEnv): Promise<Respon
}
async function handleInvoke(
agent: string,
capName: string,
capability: string,
request: Request,
env: RouterEnv,
): Promise<Response> {
const capability = `${agent}--${capName}`
try {
return await env.backend.invoke(capability, request)
} catch (e) {
+11 -16
View File
@@ -19,15 +19,14 @@ describe('S1: 部署能力', () => {
kv = new KvStore(mockKv)
auth = new AuthModule(kv)
// Register agent
await auth.registerAgent('xiaoju', 'token-xiaoju')
// Set unified deploy token
await auth.setToken('deploy-token')
})
it('should deploy a capability via API', async () => {
const req = makeRequest('POST', '/_api/deploy', {
token: 'token-xiaoju',
token: 'deploy-token',
body: {
agent: 'xiaoju',
name: 'ping',
code: "export default { fetch() { return new Response('pong') } }",
type: 'normal',
@@ -42,43 +41,39 @@ describe('S1: 部署能力', () => {
url: string
cold_start: boolean
}
expect(body.capability).toBe('xiaoju--ping')
expect(body.url).toBe('https://sigil.shazhou.workers.dev/xiaoju/ping')
expect(body.capability).toBe('ping')
expect(body.url).toBe('https://sigil.shazhou.workers.dev/run/ping')
expect(body.cold_start).toBe(false)
})
it('should call 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')
expect(mockCf.deployCalls()).toContain('s-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')
const code = await kv.getCode('ping')
expect(code).toBeTruthy()
const meta = await kv.getMeta('xiaoju--ping')
expect(meta?.agent).toBe('xiaoju')
expect(meta?.name).toBe('ping')
const meta = await kv.getMeta('ping')
expect(meta?.type).toBe('normal')
const lru = await kv.getLru('xiaoju--ping')
const lru = await kv.getLru('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')
const route = await kv.getRoute('ping')
expect(route?.worker_name).toBe('s-ping')
})
})
+8 -9
View File
@@ -19,7 +19,6 @@ describe('S2: 调用已部署能力(命中)', () => {
// Deploy first
await pool.deploy({
agent: 'xiaoju',
name: 'ping',
code: "export default { fetch() { return new Response('pong') } }",
type: 'normal',
@@ -28,29 +27,29 @@ describe('S2: 调用已部署能力(命中)', () => {
})
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)
const req = new Request('https://sigil.shazhou.workers.dev/run/ping')
const resp = await pool.invoke('ping', req)
expect(resp.status).toBe(200)
expect(await resp.text()).toBe('pong')
})
it('should update lru.last_access on warm hit', async () => {
const lruBefore = await kv.getLru('xiaoju--ping')
const lruBefore = await kv.getLru('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 req = new Request('https://sigil.shazhou.workers.dev/run/ping')
await pool.invoke('ping', req)
const lruAfter = await kv.getLru('xiaoju--ping')
const lruAfter = await kv.getLru('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)
const req = new Request('https://sigil.shazhou.workers.dev/run/ping')
await pool.invoke('ping', req)
expect(mockCf.deployCalls()).toHaveLength(0)
})
})
+15 -17
View File
@@ -18,14 +18,12 @@ describe('S3: 调用未部署能力(换入)', () => {
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', {
await kv.setCode('ping', "export default { fetch() { return new Response('pong') } }")
await kv.setMeta('ping', {
type: 'normal',
created_at: Date.now() - 10000,
agent: 'xiaoju',
name: 'ping',
})
await kv.setLru('xiaoju--ping', {
await kv.setLru('ping', {
last_access: Date.now() - 10000,
access_count: 5,
deployed: false, // key: not deployed
@@ -33,33 +31,33 @@ describe('S3: 调用未部署能力(换入)', () => {
})
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)
const req = new Request('https://sigil.shazhou.workers.dev/run/ping')
const resp = await pool.invoke('ping', req)
expect(resp.status).toBe(200)
expect(mockCf.deployCalls()).toContain('s-xiaoju-ping')
expect(mockCf.deployCalls()).toContain('s-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 req = new Request('https://sigil.shazhou.workers.dev/run/ping')
await pool.invoke('ping', req)
const lru = await kv.getLru('xiaoju--ping')
const lru = await kv.getLru('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)
const req = new Request('https://sigil.shazhou.workers.dev/run/ping')
const resp = await pool.invoke('ping', req)
expect(resp.headers.get('X-Sigil-Cold-Start')).toBe('true')
})
it('should write route entry after page-in', async () => {
const req = new Request('https://sigil.shazhou.workers.dev/xiaoju/ping')
await pool.invoke('xiaoju--ping', req)
const req = new Request('https://sigil.shazhou.workers.dev/run/ping')
await pool.invoke('ping', req)
const route = await kv.getRoute('xiaoju--ping')
expect(route?.worker_name).toBe('s-xiaoju-ping')
const route = await kv.getRoute('ping')
expect(route?.worker_name).toBe('s-ping')
})
})
+35 -49
View File
@@ -22,44 +22,40 @@ describe('S4: 配额满时换出', () => {
it('should evict the coldest capability when slots are full', async () => {
const baseTime = Date.now() - 100000
// Fill up all slots (MAX_SLOTS = 10)
// Fill up all slots (MAX_SLOTS = 3)
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, {
const cap = `cap${i}`
await kv.setCode(cap, `// code ${i}`)
await kv.setMeta(cap, {
type: 'normal',
created_at: baseTime + i * 100,
agent: 'xiaoju',
name,
})
await kv.setLru(capability, {
await kv.setLru(cap, {
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`,
await kv.setRoute(cap, {
worker_name: `s-${cap}`,
subdomain: `s-${cap}.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')
expect(result.capability).toBe('new-cap')
expect(result.evicted).toBe('cap0')
// cap0 should have been deleted
expect(mockCf.deleteCalls()).toContain('s-xiaoju-cap0')
expect(mockCf.deleteCalls()).toContain('s-cap0')
// cap0 lru should be deployed=false
const evictedLru = await kv.getLru('xiaoju--cap0')
const evictedLru = await kv.getLru('cap0')
expect(evictedLru?.deployed).toBe(false)
})
@@ -67,28 +63,24 @@ describe('S4: 配额满时换出', () => {
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, {
const cap = `cap${i}`
await kv.setCode(cap, `// code ${i}`)
await kv.setMeta(cap, {
type: 'normal',
created_at: baseTime + i * 100,
agent: 'xiaoju',
name,
})
await kv.setLru(capability, {
await kv.setLru(cap, {
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 kv.setRoute(cap, {
worker_name: `s-${cap}`,
subdomain: `s-${cap}.shazhou.workers.dev`,
})
}
await pool.deploy({
agent: 'xiaoju',
name: 'new-cap',
code: '// new',
type: 'normal',
@@ -102,57 +94,51 @@ describe('S4: 配额满时换出', () => {
const baseTime = Date.now() - 100000
const expiredEphemeralCreated = Date.now() - 10000
// Fill 9 normal caps
// Fill (MAX_SLOTS - 1) 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, {
const cap = `normal${i}`
await kv.setCode(cap, `// code ${i}`)
await kv.setMeta(cap, {
type: 'normal',
created_at: baseTime + i * 100,
agent: 'xiaoju',
name,
})
await kv.setLru(capability, {
await kv.setLru(cap, {
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`,
await kv.setRoute(cap, {
worker_name: `s-${cap}`,
subdomain: `s-${cap}.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', {
await kv.setCode('ephemeral-old', '// ephemeral')
await kv.setMeta('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', {
await kv.setLru('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',
await kv.setRoute('ephemeral-old', {
worker_name: 's-ephemeral-old',
subdomain: 's-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')
expect(result.evicted).toBe('ephemeral-old')
expect(mockCf.deleteCalls()).toContain('s-ephemeral-old')
})
})
+6 -6
View File
@@ -14,21 +14,21 @@ describe('S5: 调用不存在的能力', () => {
})
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)
const req = new Request('https://sigil.shazhou.workers.dev/run/nonexistent')
const resp = await pool.invoke('nonexistent', req)
expect(resp.status).toBe(404)
})
it('should return error JSON body', async () => {
const req = new Request('https://sigil.shazhou.workers.dev/xiaoju/nonexistent')
const resp = await pool.invoke('xiaoju--nonexistent', req)
const req = new Request('https://sigil.shazhou.workers.dev/run/nonexistent')
const resp = await pool.invoke('nonexistent', req)
const body = await resp.json() as { error: string }
expect(body.error).toBeTruthy()
})
it('should not call deployWorker for nonexistent', async () => {
const req = new Request('https://sigil.shazhou.workers.dev/xiaoju/nonexistent')
await pool.invoke('xiaoju--nonexistent', req)
const req = new Request('https://sigil.shazhou.workers.dev/run/nonexistent')
await pool.invoke('nonexistent', req)
expect(mockCf.deployCalls()).toHaveLength(0)
})
})
+12 -13
View File
@@ -19,11 +19,10 @@ describe('S6: 删除能力', () => {
kv = new KvStore(mockKv)
auth = new AuthModule(kv)
await auth.registerAgent('xiaoju', 'token-xiaoju')
await auth.setToken('deploy-token')
// Deploy first
await pool.deploy({
agent: 'xiaoju',
name: 'ping',
code: "export default { fetch() { return new Response('pong') } }",
type: 'normal',
@@ -33,32 +32,32 @@ describe('S6: 删除能力', () => {
it('should call CfApi.deleteWorker', async () => {
const req = makeRequest('DELETE', '/_api/remove', {
token: 'token-xiaoju',
body: { capability: 'xiaoju--ping' },
token: 'deploy-token',
body: { capability: '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')
expect(mockCf.deleteCalls()).toContain('s-ping')
})
it('should clear all KV entries', async () => {
await pool.remove('xiaoju--ping')
await pool.remove('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()
expect(await kv.getCode('ping')).toBeNull()
expect(await kv.getMeta('ping')).toBeNull()
expect(await kv.getLru('ping')).toBeNull()
expect(await kv.getRoute('ping')).toBeNull()
})
it('should return removed capability in response', async () => {
const req = makeRequest('DELETE', '/_api/remove', {
token: 'token-xiaoju',
body: { capability: 'xiaoju--ping' },
token: 'deploy-token',
body: { capability: 'ping' },
})
const resp = await handleRequest(req, { SIGIL_KV: mockKv, backend: pool, auth, kv })
const body = await resp.json() as { removed: string }
expect(body.removed).toBe('xiaoju--ping')
expect(body.removed).toBe('ping')
})
})
+12 -23
View File
@@ -19,50 +19,39 @@ describe('S7: 列出能力', () => {
kv = new KvStore(mockKv)
auth = new AuthModule(kv)
await auth.registerAgent('xiaoju', 'token-xiaoju')
await auth.registerAgent('xiaomooo', 'token-xiaomooo')
await auth.setToken('deploy-token')
// Deploy 2 for xiaoju (keep total <= MAX_SLOTS=3 to avoid eviction)
for (const name of ['ping', 'echo']) {
// Deploy some capabilities (keep <= MAX_SLOTS=3 to avoid eviction)
for (const name of ['ping', 'echo', 'hello']) {
await pool.deploy({
agent: 'xiaoju',
name,
code: `// ${name}`,
type: 'normal',
})
}
// Deploy 1 for xiaomooo (total = 3, exactly fills slots)
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',
it('should return all capabilities', async () => {
const req = makeRequest('GET', '/_api/list', {
token: 'deploy-token',
})
const resp = await handleRequest(req, { SIGIL_KV: mockKv, backend: pool, auth, kv })
expect(resp.status).toBe(200)
const body = await resp.json() as { capabilities: Array<{ capability: string }> }
expect(body.capabilities).toHaveLength(2)
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).not.toContain('xiaomooo--hello')
expect(names).toContain('ping')
expect(names).toContain('echo')
expect(names).toContain('hello')
})
it('should include capability metadata in response', async () => {
const caps = await pool.list('xiaoju')
expect(caps.length).toBe(2)
const caps = await pool.list()
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)
}
-4
View File
@@ -20,9 +20,7 @@ describe('S8: 健康端点', () => {
auth = new AuthModule(kv)
// Deploy some capabilities
await auth.registerAgent('xiaoju', 'token-xiaoju')
await pool.deploy({
agent: 'xiaoju',
name: 'ping',
code: '// ping',
type: 'normal',
@@ -42,7 +40,6 @@ describe('S8: 健康端点', () => {
backend: string
total_slots: number
used_slots: number
agents: number
lru_enabled: boolean
eviction_count: number
}
@@ -52,7 +49,6 @@ describe('S8: 健康端点', () => {
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')
})
+1 -4
View File
@@ -24,7 +24,6 @@ describe('S9: 无 token 拒绝', () => {
const req = makeRequest('POST', '/_api/deploy', {
// No token
body: {
agent: 'xiaoju',
name: 'ping',
code: '// ping',
type: 'normal',
@@ -39,7 +38,6 @@ describe('S9: 无 token 拒绝', () => {
const req = makeRequest('POST', '/_api/deploy', {
token: 'wrong-token',
body: {
agent: 'xiaoju',
name: 'ping',
code: '// ping',
type: 'normal',
@@ -52,7 +50,7 @@ describe('S9: 无 token 拒绝', () => {
it('should return 401 on DELETE without token', async () => {
const req = makeRequest('DELETE', '/_api/remove', {
body: { capability: 'xiaoju--ping' },
body: { capability: 'ping' },
})
const resp = await handleRequest(req, { SIGIL_KV: mockKv, backend: pool, auth, kv })
@@ -62,7 +60,6 @@ describe('S9: 无 token 拒绝', () => {
it('should return error message in body', async () => {
const req = makeRequest('POST', '/_api/deploy', {
body: {
agent: 'xiaoju',
name: 'ping',
code: '// ping',
type: 'normal',
-90
View File
@@ -1,90 +0,0 @@
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)
})
})
+8 -10
View File
@@ -18,14 +18,12 @@ describe('S11: 并发换入去重', () => {
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', {
await kv.setCode('ping', "export default { fetch() { return new Response('pong') } }")
await kv.setMeta('ping', {
type: 'normal',
created_at: Date.now() - 10000,
agent: 'xiaoju',
name: 'ping',
})
await kv.setLru('xiaoju--ping', {
await kv.setLru('ping', {
last_access: Date.now() - 10000,
access_count: 0,
deployed: false,
@@ -33,13 +31,13 @@ describe('S11: 并发换入去重', () => {
})
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')
const req1 = new Request('https://sigil.shazhou.workers.dev/run/ping')
const req2 = new Request('https://sigil.shazhou.workers.dev/run/ping')
// Fire concurrently
const [resp1, resp2] = await Promise.all([
pool.invoke('xiaoju--ping', req1),
pool.invoke('xiaoju--ping', req2),
pool.invoke('ping', req1),
pool.invoke('ping', req2),
])
expect(resp1.status).toBe(200)
@@ -47,6 +45,6 @@ describe('S11: 并发换入去重', () => {
// Should only deploy once
const deployCalls = mockCf.deployCalls()
expect(deployCalls.filter(n => n === 's-xiaoju-ping')).toHaveLength(1)
expect(deployCalls.filter(n => n === 's-ping')).toHaveLength(1)
})
})
+13 -16
View File
@@ -21,15 +21,12 @@ describe('S12: 换页速率限制', () => {
})
async function setupCapability(name: string): Promise<void> {
const capability = `xiaoju--${name}`
await kv.setCode(capability, `// ${name}`)
await kv.setMeta(capability, {
await kv.setCode(name, `// ${name}`)
await kv.setMeta(name, {
type: 'normal',
created_at: Date.now() - 10000,
agent: 'xiaoju',
name,
})
await kv.setLru(capability, {
await kv.setLru(name, {
last_access: Date.now() - 10000,
access_count: 0,
deployed: false, // evicted
@@ -43,8 +40,8 @@ describe('S12: 换页速率限制', () => {
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)
const req = new Request(`https://sigil.shazhou.workers.dev/run/${name}`)
const resp = await pool.invoke(name, req)
results.push(resp.status === 200)
}
@@ -56,17 +53,17 @@ describe('S12: 换页速率限制', () => {
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 req = new Request(`https://sigil.shazhou.workers.dev/run/${name}`)
await pool.invoke(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}`)
const req = new Request(`https://sigil.shazhou.workers.dev/run/${name}`)
try {
const resp = await pool.invoke(`xiaoju--${name}`, req)
const resp = await pool.invoke(name, req)
// If it doesn't throw, check status
expect(resp.status).toBe(503)
} catch (e) {
@@ -79,16 +76,16 @@ describe('S12: 换页速率限制', () => {
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 req = new Request(`https://sigil.shazhou.workers.dev/run/${name}`)
await pool.invoke(name, req)
}
const name = `cap${CONFIG.PAGE_RATE_LIMIT}`
await setupCapability(name)
const req = new Request(`https://sigil.shazhou.workers.dev/xiaoju/${name}`)
const req = new Request(`https://sigil.shazhou.workers.dev/run/${name}`)
try {
const resp = await pool.invoke(`xiaoju--${name}`, req)
const resp = await pool.invoke(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
+11 -16
View File
@@ -19,15 +19,14 @@ describe('S13: deploy_cooldown', () => {
kv = new KvStore(mockKv)
auth = new AuthModule(kv)
await auth.registerAgent('xiaoju', 'token-xiaoju')
await auth.setToken('deploy-token')
})
it('should reject rapid second deploy with 429', async () => {
// First deploy
const req1 = makeRequest('POST', '/_api/deploy', {
token: 'token-xiaoju',
token: 'deploy-token',
body: {
agent: 'xiaoju',
name: 'ping',
code: '// ping',
type: 'normal',
@@ -38,9 +37,8 @@ describe('S13: deploy_cooldown', () => {
// Immediate second deploy (< 5s cooldown)
const req2 = makeRequest('POST', '/_api/deploy', {
token: 'token-xiaoju',
token: 'deploy-token',
body: {
agent: 'xiaoju',
name: 'ping2',
code: '// ping2',
type: 'normal',
@@ -53,15 +51,15 @@ describe('S13: deploy_cooldown', () => {
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' },
token: 'deploy-token',
body: { name: 'ping', code: '// ping', type: 'normal' },
})
await handleRequest(req1, { SIGIL_KV: mockKv, backend: pool, auth, kv })
// Immediate second
const req2 = makeRequest('POST', '/_api/deploy', {
token: 'token-xiaoju',
body: { agent: 'xiaoju', name: 'ping2', code: '// ping2', type: 'normal' },
token: 'deploy-token',
body: { name: 'ping2', code: '// ping2', type: 'normal' },
})
const resp2 = await handleRequest(req2, { SIGIL_KV: mockKv, backend: pool, auth, kv })
const body = await resp2.json() as { error: string; retry_after: number }
@@ -71,15 +69,12 @@ describe('S13: deploy_cooldown', () => {
})
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
})
// Manually set last deploy time as already expired
await kv.setLastDeployTime(Date.now() - 10000) // 10s ago, past 5s cooldown
const req = makeRequest('POST', '/_api/deploy', {
token: 'token-xiaoju',
body: { agent: 'xiaoju', name: 'ping', code: '// ping', type: 'normal' },
token: 'deploy-token',
body: { name: 'ping', code: '// ping', type: 'normal' },
})
const resp = await handleRequest(req, { SIGIL_KV: mockKv, backend: pool, auth, kv })