Merge remote-tracking branch 'origin/master' into feat/web-message-feedback-ui
Adapt to two contract changes master introduced: - The generated Remote face now wraps every business result in RemoteResult, folding carrier failures into an ok:false branch instead of rejecting. The controller reads that envelope at its three call sites and maps a carrier failure onto the same settled shape the controls already render; three specs cover the new branch. - Client packages split their tsconfig into host and client halves, and the host aggregate now compiles any test not named *.client.spec.*. Rename this package's specs to the client convention and drop the ../connection project reference, which pointed at a solution file that no longer carries the client sources. Keep master's mount loop with its rollback-on-failure in api-remotes and add messageFeedbackRemote to it.
This commit is contained in:
@@ -32,21 +32,25 @@
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
"@deepseek-ai/cordis-plugin-loader": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-directory-picker": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-directory-picker-native": "workspace:^",
|
||||
"@deepseek-ai/dsh-host-directory-picker-browse": "workspace:^",
|
||||
"@deepseek-ai/dsh-host-directory-picker-native": "workspace:^",
|
||||
"@deepseek-ai/dsh-host-webserver": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:^"
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
"@deepseek-ai/cordis-plugin-include": "workspace:^",
|
||||
"@deepseek-ai/cordis-plugin-loader": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-directory-picker": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-directory-picker-native": "workspace:^",
|
||||
"@deepseek-ai/dsh-host-directory-picker": "workspace:^",
|
||||
"@deepseek-ai/dsh-host-directory-picker-browse": "workspace:^",
|
||||
"@deepseek-ai/dsh-host-directory-picker-native": "workspace:^",
|
||||
"@deepseek-ai/dsh-host-webserver": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:^"
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
/**
|
||||
* Adaptive chooser of the directory-picker seam: resolves the host's
|
||||
* situation once at boot (bind host, SSH launch, display session, Linux
|
||||
* chooser binary) and mounts the matching dual-face backend — `-native` or
|
||||
* `-browse` — as a real Loader entry in the in-memory root tree. Because the
|
||||
* backend arrives as an ordinary entry, its browser half is discovered
|
||||
* exactly as a config-row's would be, so the seam's one-row-swaps-both-faces
|
||||
* invariant holds for the resolved choice; pinning an interaction remains
|
||||
* composing that backend row directly instead of this one.
|
||||
* chooser binary) and mounts the matching interaction — `native` or `browse`
|
||||
* — as real Loader entries in the in-memory root tree. Each interaction is a
|
||||
* pair: the Host backend serving the seam capability and the client surface
|
||||
* occupying ui-workspace's directory-flow holes. Both arrive as ordinary
|
||||
* entries, so the surface is discovered exactly as a config-row's would be
|
||||
* and one resolved choice still swaps both faces; pinning an interaction
|
||||
* remains composing that pair directly instead of this row.
|
||||
* @module @deepseek-ai/dsh-host-directory-picker-auto
|
||||
*/
|
||||
|
||||
@@ -28,7 +29,7 @@ export const name = 'directory-picker-auto'
|
||||
export const inject = ['httpServer', 'loader']
|
||||
|
||||
/**
|
||||
* Backend package per resolved kind — fixed composition vocabulary, not a
|
||||
* Host backend package per resolved kind — fixed composition vocabulary, not a
|
||||
* tunable. Exported because the reference is a runtime string the static
|
||||
* config gate cannot see in a yml row: `verify-cordis-config` requires every
|
||||
* app composing this chooser to declare both values as dependencies.
|
||||
@@ -39,10 +40,23 @@ export const BACKEND_PACKAGES: Record<DirectoryPickerBackendKind, string> = {
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the backend from one boot-time sample and mount it as a Loader
|
||||
* entry; the effect's disposer removes the entry and joins the backend
|
||||
* fiber's teardown, so unloading this plugin returns only after both faces
|
||||
* of the mounted backend (and their dependents) quiesced.
|
||||
* Client surface package per resolved kind, mounted with its backend so one
|
||||
* resolved interaction still composes both faces. Declared as dependencies by
|
||||
* every composing app for the same reason as {@link BACKEND_PACKAGES}. Only the
|
||||
* specifier is referenced here — the packages belong to the Client program, so
|
||||
* no import of them exists on this side and knip needs them ignored for this
|
||||
* workspace.
|
||||
*/
|
||||
export const SURFACE_PACKAGES: Record<DirectoryPickerBackendKind, string> = {
|
||||
native: '@deepseek-ai/dsh-client-ui-directory-picker-native',
|
||||
browse: '@deepseek-ai/dsh-client-ui-directory-picker',
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the interaction from one boot-time sample and mount its backend and
|
||||
* surface as Loader entries; the effect's disposer removes both entries and
|
||||
* joins their fibers' teardown, so unloading this plugin returns only after
|
||||
* both faces of the mounted interaction (and their dependents) quiesced.
|
||||
* @param ctx - cordis context carrying the injected `httpServer` and `loader`.
|
||||
*/
|
||||
export async function apply(ctx: Context): Promise<void> {
|
||||
@@ -54,16 +68,31 @@ export async function apply(ctx: Context): Promise<void> {
|
||||
})
|
||||
await ctx.effect(async () => {
|
||||
// Root-tree create: the Loader root is in-memory (write() is a no-op), so
|
||||
// the mounted row can never be persisted back into a config file.
|
||||
const id = await ctx.loader.create({ name: BACKEND_PACKAGES[backend] })
|
||||
return async () => {
|
||||
// Tree teardown (group.stop) can have removed the entry already;
|
||||
// nothing is left to unmount or await then.
|
||||
const entry = ctx.loader.store[id]
|
||||
if (entry === undefined) return
|
||||
// remove() disposes the entry transactionally, so the chooser's unload
|
||||
// signals completion only after the backend quiesced.
|
||||
await ctx.loader.remove(id)
|
||||
// the mounted rows can never be persisted back into a config file. The
|
||||
// backend lands first: the surface's browser half drives the capability
|
||||
// the backend registers.
|
||||
const ids: string[] = []
|
||||
const unmount = async () => {
|
||||
for (const id of [...ids].reverse()) {
|
||||
// Tree teardown (group.stop) can have removed the entry already;
|
||||
// nothing is left to unmount or await then.
|
||||
if (ctx.loader.store[id] === undefined) continue
|
||||
// remove() disposes the entry transactionally, so the chooser's unload
|
||||
// signals completion only after that face quiesced.
|
||||
await ctx.loader.remove(id)
|
||||
}
|
||||
}
|
||||
}, 'directory-picker-auto: backend entry')
|
||||
try {
|
||||
for (const name of [BACKEND_PACKAGES[backend], SURFACE_PACKAGES[backend]]) {
|
||||
ids.push(await ctx.loader.create({ name }))
|
||||
}
|
||||
} catch (cause) {
|
||||
// Setup owns the entries it created until it returns the disposer: leaving
|
||||
// the backend mounted would make a retry collide with its own
|
||||
// directoryPicker registration.
|
||||
await unmount()
|
||||
throw cause
|
||||
}
|
||||
return unmount
|
||||
}, 'directory-picker-auto: interaction entries')
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
/**
|
||||
* REAL-composition coverage: a test-only cordis.yml booted through the
|
||||
* vendored Loader mounts the webserver row plus the adaptive chooser, and the
|
||||
* assertions observe the durable outcome — which backend entry the chooser
|
||||
* mounted into the Loader store, the capability the seam then serves, and
|
||||
* that disposing the chooser removes the mounted entry again (HMR safety),
|
||||
* joining the backend's own teardown before the disposer settles.
|
||||
* assertions observe the durable outcome — which backend and surface entries
|
||||
* the chooser mounted into the Loader store, the capability the seam then
|
||||
* serves, and that disposing the chooser removes both mounted entries again
|
||||
* (HMR safety), joining the backend's own teardown before the disposer settles.
|
||||
*/
|
||||
|
||||
import { chmodSync, mkdtempSync, writeFileSync } from 'node:fs'
|
||||
@@ -48,6 +48,23 @@ vi.mock('node:fs/promises', async (importOriginal) => {
|
||||
const AUTO = '@deepseek-ai/dsh-host-directory-picker-auto'
|
||||
const NATIVE = '@deepseek-ai/dsh-host-directory-picker-native'
|
||||
const BROWSE = '@deepseek-ai/dsh-host-directory-picker-browse'
|
||||
const NATIVE_SURFACE = '@deepseek-ai/dsh-client-ui-directory-picker-native'
|
||||
const BROWSE_SURFACE = '@deepseek-ai/dsh-client-ui-directory-picker'
|
||||
|
||||
/**
|
||||
* Loader-visible stand-in for a client surface package: the surfaces belong to
|
||||
* the Client program and publish browser entry points only, so a Host-face spec
|
||||
* can neither name them in a static import nor resolve them from source. What
|
||||
* the chooser owns is the mounting decision, which every case observes through
|
||||
* the Loader store; the surface's own browser contributions belong to the
|
||||
* assembled web coverage.
|
||||
*
|
||||
* @param name Surface package specifier the chooser mounts.
|
||||
* @returns A function-plugin module the Loader can mount under that specifier.
|
||||
*/
|
||||
function surfaceModule(name: string): unknown {
|
||||
return { name, apply: () => undefined }
|
||||
}
|
||||
|
||||
let root: string | undefined
|
||||
let fakeBin: string | undefined
|
||||
@@ -71,7 +88,10 @@ afterEach(async () => {
|
||||
})
|
||||
|
||||
/** Write a two-row cordis.yml (webserver + chooser), then boot it through the real Loader. */
|
||||
async function loadComposition(bindHost: '127.0.0.1' | '0.0.0.0'): Promise<{ ctx: Context; configPath: string }> {
|
||||
async function loadComposition(
|
||||
bindHost: '127.0.0.1' | '0.0.0.0',
|
||||
options: { failSurface?: boolean } = {},
|
||||
): Promise<{ ctx: Context; configPath: string }> {
|
||||
root = await mkdtemp(join(tmpdir(), 'dsh-directory-picker-auto-'))
|
||||
const configPath = join(root, 'cordis.yml')
|
||||
await writeFile(configPath, [
|
||||
@@ -92,10 +112,15 @@ async function loadComposition(bindHost: '127.0.0.1' | '0.0.0.0'): Promise<{ ctx
|
||||
[AUTO, DirectoryPickerAuto],
|
||||
[NATIVE, NativeDirectoryPicker],
|
||||
[BROWSE, BrowseDirectoryPicker],
|
||||
[NATIVE_SURFACE, surfaceModule(NATIVE_SURFACE)],
|
||||
[BROWSE_SURFACE, surfaceModule(BROWSE_SURFACE)],
|
||||
])
|
||||
context.loader.internal = {
|
||||
version: 'v2',
|
||||
async import(specifier: string) {
|
||||
if (options.failSurface === true && (specifier === NATIVE_SURFACE || specifier === BROWSE_SURFACE)) {
|
||||
throw new Error(`surface import failed: ${specifier}`)
|
||||
}
|
||||
if (!modules.has(specifier)) throw new Error(`unexpected Loader import: ${specifier}`)
|
||||
return modules.get(specifier)
|
||||
},
|
||||
@@ -142,7 +167,9 @@ describe('real Loader composition', () => {
|
||||
.map(entry => entry.options.name)
|
||||
expect(unloaded).toEqual([])
|
||||
expect(entryNames(ctx)).toContain(NATIVE)
|
||||
expect(entryNames(ctx)).toContain(NATIVE_SURFACE)
|
||||
expect(entryNames(ctx)).not.toContain(BROWSE)
|
||||
expect(entryNames(ctx)).not.toContain(BROWSE_SURFACE)
|
||||
const picker = ctx.get('directoryPicker') as DirectoryPicker
|
||||
expect(picker.capability().kind).toBe('native')
|
||||
// The mounted row lives in the Loader's in-memory root tree only — the
|
||||
@@ -155,6 +182,7 @@ describe('real Loader composition', () => {
|
||||
const autoEntry = [...ctx.loader.entries()].find(entry => entry.options.name === AUTO)!
|
||||
await autoEntry.fiber!.dispose()
|
||||
expect(entryNames(ctx)).not.toContain(NATIVE)
|
||||
expect(entryNames(ctx)).not.toContain(NATIVE_SURFACE)
|
||||
expect(ctx.get('directoryPicker')).toBeUndefined()
|
||||
// Self-disposing an include-tree entry persists `disabled: true` (loader
|
||||
// behavior, not the chooser's); await that debounced write so it cannot
|
||||
@@ -170,7 +198,9 @@ describe('real Loader composition', () => {
|
||||
const { ctx } = await loadComposition('127.0.0.1')
|
||||
|
||||
expect(entryNames(ctx)).toContain(BROWSE)
|
||||
expect(entryNames(ctx)).toContain(BROWSE_SURFACE)
|
||||
expect(entryNames(ctx)).not.toContain(NATIVE)
|
||||
expect(entryNames(ctx)).not.toContain(NATIVE_SURFACE)
|
||||
const picker = ctx.get('directoryPicker') as DirectoryPicker
|
||||
expect(picker.capability().kind).toBe('browse')
|
||||
})
|
||||
@@ -180,7 +210,20 @@ describe('real Loader composition', () => {
|
||||
const { ctx } = await loadComposition('0.0.0.0')
|
||||
|
||||
expect(entryNames(ctx)).toContain(BROWSE)
|
||||
expect(entryNames(ctx)).toContain(BROWSE_SURFACE)
|
||||
expect(entryNames(ctx)).not.toContain(NATIVE)
|
||||
expect(entryNames(ctx)).not.toContain(NATIVE_SURFACE)
|
||||
})
|
||||
|
||||
it('unmounts the backend when the surface entry fails to load', { timeout: 60_000 }, async () => {
|
||||
stubAttendedHost()
|
||||
await expect(loadComposition('127.0.0.1', { failSurface: true })).rejects.toThrow(/surface import failed/)
|
||||
|
||||
// Setup owns both entries until it returns its disposer, so a failed surface
|
||||
// must take the mounted backend with it: otherwise a retry collides with the
|
||||
// directoryPicker registration this backend already made.
|
||||
expect(entryNames(context!)).not.toContain(NATIVE)
|
||||
expect(context!.get('directoryPicker')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('tolerates the mounted entry being removed by the tree before the chooser unloads', { timeout: 60_000 }, async () => {
|
||||
@@ -193,6 +236,7 @@ describe('real Loader composition', () => {
|
||||
renameControl.remainingFailures = 1
|
||||
await expect(autoEntry.fiber!.dispose()).resolves.not.toThrow()
|
||||
expect(entryNames(ctx)).not.toContain(NATIVE)
|
||||
expect(entryNames(ctx)).not.toContain(NATIVE_SURFACE)
|
||||
// Same self-dispose persistence as above: let the write land before teardown.
|
||||
await expect.poll(async () => await readFile(configPath, 'utf8')).toContain('disabled: true')
|
||||
expect(renameControl.injectedFailures).toBe(1)
|
||||
|
||||
Reference in New Issue
Block a user