fix(cordis-host-runner): make inspect provider registration idempotent on identical manifest
Some checks failed
CI / node 22.19 (push) Has been skipped
CI / node 26 (push) Has been skipped
CI / python 3.10 / keyless SDK (push) Has been skipped
CI / python runtime / release-shaped Linux x64 (push) Has been skipped
CI / windows node 24 / wine blocking (push) Has been skipped
CI / wine apt cache (push) Successful in 6s
CI / serial / linux (push) Has been skipped
Release (vendor) / Pack npm tarballs (push) Failing after 1m59s
Release (dsh) / Pack npm tarballs (push) Failing after 1m57s
Sandbox / sandbox e2e (landlock, ubuntu-24.04) (push) Failing after 1m59s
Sandbox / sandbox e2e (bwrap, ubuntu-latest) (push) Failing after 1m23s
Release (vendor) / Publish to npm (push) Has been skipped
Release (dsh) / Publish to npm (push) Has been skipped
CI / node 24 / static (push) Has been cancelled
CI / node 24 / coverage (push) Has been cancelled
CI / larger-runner-benchmark (16, windows, dsh-windows-2025-16core, production-site) (push) Has been cancelled
CI / larger-runner-benchmark (32, linux, dsh-ubuntu-24-04-32core, typecheck) (push) Has been cancelled
CI / serial / windows (self-hosted standby) (push) Has been cancelled
CI / larger-runner-benchmark (16, linux, dsh-ubuntu-24-04-16core, typecheck) (push) Has been cancelled
CI / node 24 / snapshots and artifacts (push) Has been cancelled
CI / windows node 24 / native complete (push) Has been cancelled
CI / serial / linux (self-hosted standby) (push) Has been cancelled
CI / serial / macos (push) Has been cancelled
CI / larger-runner-benchmark (32, windows, dsh-windows-2025-32core, production-site) (push) Has been cancelled
CI / larger-runner-benchmark (4, linux, dsh-ubuntu-24-04-4core, typecheck) (push) Has been cancelled
CI / larger-runner-benchmark (4, windows, dsh-windows-2025-4core, production-site) (push) Has been cancelled
CI / larger-runner-benchmark (64, linux, dsh-ubuntu-24-04-64core, typecheck) (push) Has been cancelled
CI / larger-runner-benchmark (64, windows, dsh-windows-2025-64core, production-site) (push) Has been cancelled
CI / larger-runner-benchmark (8, linux, dsh-ubuntu-24-04-8core, typecheck) (push) Has been cancelled
CI / larger-runner-benchmark (8, windows, dsh-windows-2025-8core, production-site) (push) Has been cancelled
CI / larger-runner-benchmark (96, linux, dsh-ubuntu-24-04-96core, typecheck) (push) Has been cancelled
CI / larger-runner-benchmark (96, windows, dsh-windows-2025-96core, production-site) (push) Has been cancelled
CI / consolidated-runner-benchmark (16, linux, dsh-ubuntu-24-04-16core, 16) (push) Has been cancelled
CI / consolidated-runner-benchmark (16, windows, dsh-windows-2025-16core, 2) (push) Has been cancelled
CI / consolidated-runner-benchmark (32, linux, dsh-ubuntu-24-04-32core, 32) (push) Has been cancelled
CI / consolidated-runner-benchmark (32, windows, dsh-windows-2025-32core, 2) (push) Has been cancelled
CI / consolidated-runner-benchmark (4, linux, dsh-ubuntu-24-04-4core, 4) (push) Has been cancelled
CI / consolidated-runner-benchmark (4, windows, dsh-windows-2025-4core, 2) (push) Has been cancelled
CI / consolidated-runner-benchmark (64, linux, dsh-ubuntu-24-04-64core, 32) (push) Has been cancelled
CI / consolidated-runner-benchmark (64, windows, dsh-windows-2025-64core, 2) (push) Has been cancelled
CI / consolidated-runner-benchmark (8, linux, dsh-ubuntu-24-04-8core, 8) (push) Has been cancelled
CI / consolidated-runner-benchmark (8, windows, dsh-windows-2025-8core, 2) (push) Has been cancelled
CI / consolidated-runner-benchmark (96, linux, dsh-ubuntu-24-04-96core, 32) (push) Has been cancelled
CI / consolidated-runner-benchmark (96, windows, dsh-windows-2025-96core, 2) (push) Has been cancelled
CI / all checks passed (push) Has been cancelled
Sandbox / sandbox e2e (seatbelt, macos-latest) (push) Has been cancelled
Sandbox / sandbox e2e (landlock, ubuntu-24.04-arm) (push) Has been cancelled
E2E (real DeepSeek API) / e2e (push) Failing after 20s
Some checks failed
CI / node 22.19 (push) Has been skipped
CI / node 26 (push) Has been skipped
CI / python 3.10 / keyless SDK (push) Has been skipped
CI / python runtime / release-shaped Linux x64 (push) Has been skipped
CI / windows node 24 / wine blocking (push) Has been skipped
CI / wine apt cache (push) Successful in 6s
CI / serial / linux (push) Has been skipped
Release (vendor) / Pack npm tarballs (push) Failing after 1m59s
Release (dsh) / Pack npm tarballs (push) Failing after 1m57s
Sandbox / sandbox e2e (landlock, ubuntu-24.04) (push) Failing after 1m59s
Sandbox / sandbox e2e (bwrap, ubuntu-latest) (push) Failing after 1m23s
Release (vendor) / Publish to npm (push) Has been skipped
Release (dsh) / Publish to npm (push) Has been skipped
CI / node 24 / static (push) Has been cancelled
CI / node 24 / coverage (push) Has been cancelled
CI / larger-runner-benchmark (16, windows, dsh-windows-2025-16core, production-site) (push) Has been cancelled
CI / larger-runner-benchmark (32, linux, dsh-ubuntu-24-04-32core, typecheck) (push) Has been cancelled
CI / serial / windows (self-hosted standby) (push) Has been cancelled
CI / larger-runner-benchmark (16, linux, dsh-ubuntu-24-04-16core, typecheck) (push) Has been cancelled
CI / node 24 / snapshots and artifacts (push) Has been cancelled
CI / windows node 24 / native complete (push) Has been cancelled
CI / serial / linux (self-hosted standby) (push) Has been cancelled
CI / serial / macos (push) Has been cancelled
CI / larger-runner-benchmark (32, windows, dsh-windows-2025-32core, production-site) (push) Has been cancelled
CI / larger-runner-benchmark (4, linux, dsh-ubuntu-24-04-4core, typecheck) (push) Has been cancelled
CI / larger-runner-benchmark (4, windows, dsh-windows-2025-4core, production-site) (push) Has been cancelled
CI / larger-runner-benchmark (64, linux, dsh-ubuntu-24-04-64core, typecheck) (push) Has been cancelled
CI / larger-runner-benchmark (64, windows, dsh-windows-2025-64core, production-site) (push) Has been cancelled
CI / larger-runner-benchmark (8, linux, dsh-ubuntu-24-04-8core, typecheck) (push) Has been cancelled
CI / larger-runner-benchmark (8, windows, dsh-windows-2025-8core, production-site) (push) Has been cancelled
CI / larger-runner-benchmark (96, linux, dsh-ubuntu-24-04-96core, typecheck) (push) Has been cancelled
CI / larger-runner-benchmark (96, windows, dsh-windows-2025-96core, production-site) (push) Has been cancelled
CI / consolidated-runner-benchmark (16, linux, dsh-ubuntu-24-04-16core, 16) (push) Has been cancelled
CI / consolidated-runner-benchmark (16, windows, dsh-windows-2025-16core, 2) (push) Has been cancelled
CI / consolidated-runner-benchmark (32, linux, dsh-ubuntu-24-04-32core, 32) (push) Has been cancelled
CI / consolidated-runner-benchmark (32, windows, dsh-windows-2025-32core, 2) (push) Has been cancelled
CI / consolidated-runner-benchmark (4, linux, dsh-ubuntu-24-04-4core, 4) (push) Has been cancelled
CI / consolidated-runner-benchmark (4, windows, dsh-windows-2025-4core, 2) (push) Has been cancelled
CI / consolidated-runner-benchmark (64, linux, dsh-ubuntu-24-04-64core, 32) (push) Has been cancelled
CI / consolidated-runner-benchmark (64, windows, dsh-windows-2025-64core, 2) (push) Has been cancelled
CI / consolidated-runner-benchmark (8, linux, dsh-ubuntu-24-04-8core, 8) (push) Has been cancelled
CI / consolidated-runner-benchmark (8, windows, dsh-windows-2025-8core, 2) (push) Has been cancelled
CI / consolidated-runner-benchmark (96, linux, dsh-ubuntu-24-04-96core, 32) (push) Has been cancelled
CI / consolidated-runner-benchmark (96, windows, dsh-windows-2025-96core, 2) (push) Has been cancelled
CI / all checks passed (push) Has been cancelled
Sandbox / sandbox e2e (seatbelt, macos-latest) (push) Has been cancelled
Sandbox / sandbox e2e (landlock, ubuntu-24.04-arm) (push) Has been cancelled
E2E (real DeepSeek API) / e2e (push) Failing after 20s
The Host cordisInspect registry threw on any duplicate provider id, but tool-cordis is mounted per-preset (standing scope), so two presets that both include it (e.g. `maximum` and a user copy such as `design`) collide. `register()` now replaces the stored entry when the incoming manifest is structurally identical (same id, description, and methods); a provider with the same id but a different manifest still throws as before. Added `jsonEqual` and `sameManifest` module-private helpers; new spec covers first register, identical re-register, different-manifest reject, empty-id validation, and method-array mismatch.
This commit is contained in:
@@ -56,12 +56,21 @@ export class CordisInspectRegistryService extends Service {
|
||||
|
||||
/**
|
||||
* Register one Host provider.
|
||||
*
|
||||
* Throws only when a different manifest claims the same provider id.
|
||||
* An identical re-registration (same id, description, and every method)
|
||||
* replaces the stored entry so the newest registrant's query closure is
|
||||
* active. This allows multiple plugin mounts (e.g. two agent presets) that
|
||||
* register the same capability to coexist without collision.
|
||||
* @param registration - manifest and local query handler.
|
||||
* @returns idempotent disposer.
|
||||
*/
|
||||
register(registration: HostCordisInspectProviderRegistration): () => void {
|
||||
const manifest = validateManifest(registration.manifest)
|
||||
if (this.providers.has(manifest.id)) throw new Error(`Host Cordis inspect provider "${manifest.id}" is already registered`)
|
||||
const existing = this.providers.get(manifest.id)
|
||||
if (existing !== undefined && !sameManifest(existing.manifest, manifest)) {
|
||||
throw new Error(`Host Cordis inspect provider "${manifest.id}" is already registered`)
|
||||
}
|
||||
const stored = { ...registration, manifest }
|
||||
this.providers.set(manifest.id, stored)
|
||||
return () => {
|
||||
@@ -198,6 +207,35 @@ export class CordisInspectRegistryService extends Service {
|
||||
}
|
||||
}
|
||||
|
||||
/** Deep equality for JSON-compatible values (objects, arrays, primitives). */
|
||||
function jsonEqual(a: unknown, b: unknown): boolean {
|
||||
if (a === b) return true
|
||||
if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) return false
|
||||
if (Array.isArray(a) || Array.isArray(b)) {
|
||||
if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false
|
||||
return a.every((entry, index) => jsonEqual(entry, b[index]))
|
||||
}
|
||||
const left = a as Record<string, unknown>
|
||||
const right = b as Record<string, unknown>
|
||||
const keys = Object.keys(left)
|
||||
if (keys.length !== Object.keys(right).length) return false
|
||||
return keys.every(key => key in right && jsonEqual(left[key], right[key]))
|
||||
}
|
||||
|
||||
/** Whether two manifests describe the same provider capability. */
|
||||
function sameManifest(a: CordisInspectProviderManifest, b: CordisInspectProviderManifest): boolean {
|
||||
if (a.id !== b.id || a.description !== b.description) return false
|
||||
if (a.methods.length !== b.methods.length) return false
|
||||
return a.methods.every((method, index) => {
|
||||
const other = b.methods[index]
|
||||
return other !== undefined
|
||||
&& method.name === other.name
|
||||
&& method.description === other.description
|
||||
&& jsonEqual(method.inputSchema, other.inputSchema)
|
||||
&& jsonEqual(method.outputSchema, other.outputSchema)
|
||||
})
|
||||
}
|
||||
|
||||
function view(platform: CordisInspectPlatform, manifest: CordisInspectProviderManifest): CordisInspectProviderView {
|
||||
return { platform, ...manifest, methods: [...manifest.methods] }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { CordisInspectRegistryService, type HostCordisInspectProviderRegistration } from '../src/inspect-registry.ts'
|
||||
|
||||
function makeProvider(id: string, desc: string): HostCordisInspectProviderRegistration {
|
||||
return {
|
||||
manifest: {
|
||||
id,
|
||||
description: desc,
|
||||
methods: [{
|
||||
name: 'list',
|
||||
description: 'List things.',
|
||||
inputSchema: { type: 'object', properties: {}, additionalProperties: false },
|
||||
outputSchema: { description: 'anything' },
|
||||
}],
|
||||
},
|
||||
async query() { return {} },
|
||||
}
|
||||
}
|
||||
|
||||
describe('CordisInspectRegistryService', () => {
|
||||
it('accepts the first registration of a provider id', () => {
|
||||
const ctx = new Context()
|
||||
const svc = new CordisInspectRegistryService(ctx)
|
||||
const p = makeProvider('Service', 'desc')
|
||||
const dispose = svc.register(p)
|
||||
expect(svc.list()).toContainEqual(expect.objectContaining({ id: 'Service', platform: 'host' }))
|
||||
dispose()
|
||||
expect(svc.list()).not.toContainEqual(expect.objectContaining({ id: 'Service' }))
|
||||
})
|
||||
|
||||
it('replaces on identical re-registration without throwing', () => {
|
||||
const ctx = new Context()
|
||||
const svc = new CordisInspectRegistryService(ctx)
|
||||
const p = makeProvider('Service', 'desc')
|
||||
const disposeA = svc.register(p)
|
||||
const disposeB = svc.register(p) // same object — identical manifest
|
||||
// Still in the directory (replaced, not removed)
|
||||
expect(svc.list()).toContainEqual(expect.objectContaining({ id: 'Service', platform: 'host' }))
|
||||
// disposeA should be a no-op because the map entry was replaced
|
||||
disposeA()
|
||||
expect(svc.list()).toContainEqual(expect.objectContaining({ id: 'Service', platform: 'host' }))
|
||||
// disposeB removes the current registration
|
||||
disposeB()
|
||||
expect(svc.list()).not.toContainEqual(expect.objectContaining({ id: 'Service' }))
|
||||
})
|
||||
|
||||
it('rejects a different manifest claiming the same id', () => {
|
||||
const ctx = new Context()
|
||||
const svc = new CordisInspectRegistryService(ctx)
|
||||
svc.register(makeProvider('Service', 'original'))
|
||||
expect(() => svc.register(makeProvider('Service', 'different')))
|
||||
.toThrow('Host Cordis inspect provider "Service" is already registered')
|
||||
})
|
||||
|
||||
it('rejects empty provider id', () => {
|
||||
const ctx = new Context()
|
||||
const svc = new CordisInspectRegistryService(ctx)
|
||||
expect(() => svc.register(makeProvider('', 'empty')))
|
||||
.toThrow('Cordis inspect provider id must not be empty')
|
||||
})
|
||||
|
||||
it('detects method-array length mismatch as unequal', () => {
|
||||
const ctx = new Context()
|
||||
const svc = new CordisInspectRegistryService(ctx)
|
||||
const a: HostCordisInspectProviderRegistration = {
|
||||
manifest: {
|
||||
id: 'X', description: 'd',
|
||||
methods: [{ name: 'a', description: 'a', inputSchema: {}, outputSchema: {} }],
|
||||
},
|
||||
async query() { return {} },
|
||||
}
|
||||
const b: HostCordisInspectProviderRegistration = {
|
||||
manifest: {
|
||||
id: 'X', description: 'd',
|
||||
methods: [
|
||||
{ name: 'a', description: 'a', inputSchema: {}, outputSchema: {} },
|
||||
{ name: 'b', description: 'b', inputSchema: {}, outputSchema: {} },
|
||||
],
|
||||
},
|
||||
async query() { return {} },
|
||||
}
|
||||
svc.register(a)
|
||||
expect(() => svc.register(b)).toThrow('already registered')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user