refactor(gui): host graph from dshClient discovery; webserver self-watches bundles for HMR

The registry scans mounted Loader entries' dshClient declarations and
composes __DSH_BOOT__ {rev, entries} — inject edges and the immediately
mark come from manifests, never hand-copied; malformed fields fail loud
at load. The composing app owns one flat roster plus the --dev switch
(hmr row and bundle watching are dev-graph decisions).

The rebuild signal is the webserver's own observation: in dev mode the
registry stat-polls each scanned bundle (fs.watchFile; polling because
network mounts deliver no inotify), re-hashes on change, and broadcasts
a rebuilt frame on the /plugins/events SSE channel only when the rev
actually changed. Watch membership follows the table across rescans;
dispose drops all watches; a torn read self-heals on the next tick.
The POST /plugins/rebuilt endpoint is gone — builders and the host
share zero protocol. dsh web --dev logs the watched bundle list and
each rebuilt id with its rev transition.
This commit is contained in:
imccyu
2026-07-23 21:56:18 +08:00
parent c2e0c16d6a
commit fb47f61a83
16 changed files with 563 additions and 312 deletions
@@ -1,7 +1,7 @@
/**
* Webserver invariant companion: the boot-manifest consistency audit — every
* registry snapshot row must resolve a clientPath, checked on fiber lifecycle
* events against the assembly-published 'webPlugins' context key.
* Webserver invariant companion: the boot-graph consistency audit — every
* fetch-arrival graph row must resolve a clientPath, checked on fiber
* lifecycle events against the assembly-published 'webPlugins' context key.
*/
import { Context } from 'cordis'
import { describe, expect, it } from 'vitest'
@@ -9,7 +9,7 @@ import InvariantService from '@deepseek-ai/dsh-invariants'
import * as WebserverInvariant from '../src/invariant.ts'
interface RegistryStub {
snapshot(): { id: string; url: string }[]
graph(): { entries: { id: string; url: string }[] }
clientPath(id: string): string | undefined
}
@@ -33,18 +33,18 @@ describe('webserver manifest invariant', () => {
expect(() => { trigger(bare) }).not.toThrow() // no 'webPlugins' key published
const consistent = await setup({
snapshot: () => [{ id: 'p1', url: '/plugins/p1/client.js' }],
clientPath: () => '/tmp/p1/lib/client.js',
graph: () => ({ entries: [{ id: 'p1', url: '/plugins/p1/client.js?rev=abc' }] }),
clientPath: id => id === 'p1' ? '/tmp/p1/lib/client.js' : undefined,
})
expect(() => { trigger(consistent) }).not.toThrow()
})
it('throws on a manifest row whose bundle path no longer resolves', async () => {
it('throws on a graph row whose bundle path no longer resolves', async () => {
const ctx = await setup({
snapshot: () => [{ id: 'ghost', url: '/plugins/ghost/client.js' }],
graph: () => ({ entries: [{ id: 'ghost', url: '/plugins/ghost/client.js?rev=abc' }] }),
clientPath: () => undefined,
})
expect(() => { trigger(ctx) })
.toThrow(/manifest row "ghost".*resolves no client bundle path/)
.toThrow(/graph row "ghost".*resolves no client bundle path/)
})
})