fix(review): audit prepared-wrapper activation and pin generated shapes

ds-review-bot round 1 on the DSH-home integration:
- generated wrappers now inject the services their manifest needs (skills/
  tools beside loader), and loadPreparedRepository rejects a wrapper fiber
  that settles anything but ACTIVE — a composition missing a required
  service fails the repository transaction instead of committing an ACTIVE
  row over a silently PENDING child (critical finding)
- the github: source ref segment excludes '#', so 'a#b' refs fail at the
  config parser with the promised syntax instead of inside pnpm
- watchPersonalPatches re-reads the include's non-patch options per refresh
  instead of a registration-time snapshot
- the TUI smoke's cache-seeded wrapper is produced by the real
  prepareDshPlugin (cache LAYOUT stays a deliberate external pin)
- new Loader integration test drives a live repositories update through
  entry.update: generation swap, old skills removed, failed candidate
  rolled back to the previous generation
This commit is contained in:
Tianyi Cui
2026-08-01 22:26:47 +08:00
parent 2448496803
commit b6284c8467
5 changed files with 135 additions and 17 deletions

View File

@@ -296,6 +296,7 @@ describe('configured GitHub repository sources', () => {
for (const source of [
'github:owner/repository',
'github:owner/repository#',
'github:owner/repository#a#b',
'https://github.com/owner/repository#ref',
'github:owner/repository#ref&path:relative/.dsh-plugin',
]) {
@@ -350,6 +351,52 @@ describe('configured GitHub repository sources', () => {
await ctx.fiber.dispose()
})
it('swaps generations on a live source-list update and rolls a failed candidate back', async () => {
// The headline flow: a personal-config edit reaches this plugin as a
// Loader entry.update, which restarts the row's fiber (old cleanup, then
// new apply — so the 'already registered' builtin guard must not fire).
const roots: Record<string, string> = {}
for (const generation of ['one', 'two'] as const) {
const root = await temporaryDirectory(`live-${generation}`)
await writeSkill(join(root, 'skills'), `live-skill-${generation}`)
const directory = await writePlugin(root, `live-fixture-${generation}`, { skills: ['../skills'] })
await RepositoryPlugin.prepareDshPlugin(directory)
roots[`github:owner/repository#${generation}&path:/.dsh-plugin`] = directory
}
vi.spyOn(RepositoryCache.prototype, 'resolve').mockImplementation(async (specifier) => {
const directory = roots[specifier]
if (directory === undefined) throw new Error(`unprepared generation ${specifier}`)
return directory
})
// Route the row through the Loader builtin table exactly as a config tree
// would; the module itself is the row's plugin.
const ctx2 = new Context()
await ctx2.plugin(Loader)
await ctx2.plugin(SkillService)
ctx2.loader.builtins['repository-plugins'] = RepositoryPlugin
const entryId = await ctx2.loader.create({
name: 'cordis:repository-plugins',
config: { repositories: ['github:owner/repository#one'] },
})
await ctx2.loader.await()
await expect(ctx2.skills.get('live-skill-one')).resolves.toMatchObject({ provider: 'repository:live-fixture-one' })
const entry = ctx2.loader.resolve(entryId)
await entry.update({ config: { repositories: ['github:owner/repository#two'] } })
await ctx2.loader.await()
await expect(ctx2.skills.get('live-skill-one')).resolves.toBeUndefined()
await expect(ctx2.skills.get('live-skill-two')).resolves.toMatchObject({ provider: 'repository:live-fixture-two' })
// A failed candidate (unprepared source) rejects the update and the
// transactional Loader restores the previous generation.
await expect(entry.update({ config: { repositories: ['github:owner/repository#missing'] } }))
.rejects.toThrow('unprepared generation')
await ctx2.loader.await()
await expect(ctx2.skills.get('live-skill-two')).resolves.toMatchObject({ provider: 'repository:live-fixture-two' })
await ctx2.fiber.dispose()
})
it('rejects duplicate generations and cleans the builtin after cache preparation fails', async () => {
const ctx = new Context()
await ctx.plugin(Loader)
@@ -368,6 +415,28 @@ describe('configured GitHub repository sources', () => {
await ctx.fiber.dispose()
})
it('rejects a wrapper left pending by a composition without its required services', async () => {
// A skills-declaring generation mounted where no skills service exists:
// the wrapper fiber stays PENDING, and the transaction must fail loud
// instead of committing an ACTIVE row over a silently inert child.
const root = await temporaryDirectory('pending-services')
await writeSkill(join(root, 'skills'), 'pending-service-skill')
const directory = await writePlugin(root, 'pending-service-fixture', { skills: ['../skills'] })
await RepositoryPlugin.prepareDshPlugin(directory)
const ctx = new Context()
await ctx.plugin(Loader)
// Deliberately NO SkillService.
await expect(loadPreparedRepository(ctx, { resolve: async () => directory }, 'github:owner/repository#pending&path:/.dsh-plugin'))
.rejects.toMatchObject({
message: expect.stringContaining('failed to load prepared repository Plugin') as string,
cause: expect.objectContaining({
message: expect.stringContaining('waiting for services: skills') as string,
}) as Error,
})
await ctx.fiber.dispose()
})
it('labels a missing prepared wrapper with its exact source and path', async () => {
const root = await temporaryDirectory('missing-wrapper')
const ctx = new Context()