Merge branch 'feat/plugin-mgmt-3-settings' into feat/plugin-mgmt-4-web

# Conflicts:
#	packages/boot/app-boot/src/probe.ts
This commit is contained in:
Yichen Jiang committed 2026-09-05 17:21:20 +08:00
commit 148e94501a
6 files changed
+87 -53

No files matched your search

+37
View File
@@ -0,0 +1,37 @@
/**
* One settlement for a spawned child: the exit code on `close`, the spawn
* failure on `error`, or a timeout that kills the child. Shared by the
* package probe and the plugin manager's pnpm runs, which otherwise each
* wrote the same first-outcome-wins guard.
*/
import type { ChildProcess } from 'node:child_process'
/**
* Wait for a child to close, at most `timeoutMs`.
*
* A spawn failure emits `error` and then `close`, and a timeout kill emits
* `close` after the rejection, so the first outcome wins and the later
* events are ignored.
* @param child - the spawned process; the caller consumes its streams.
* @param timeoutMs - how long to wait before killing the child with SIGKILL.
* @param timedOut - builds the rejection reported for a timeout.
* @returns the exit code `close` reported; null when a signal ended the child.
*/
export function awaitChildClose(child: ChildProcess, timeoutMs: number, timedOut: () => Error): Promise<number | null> {
return new Promise((resolve, reject) => {
let settled = false
const settle = (outcome: () => void): void => {
if (settled) return
settled = true
clearTimeout(timer)
outcome()
}
const timer = setTimeout(() => {
child.kill('SIGKILL')
settle(() => { reject(timedOut()) })
}, timeoutMs)
child.on('error', (error) => { settle(() => { reject(error) }) })
child.on('close', (code) => { settle(() => { resolve(code) }) })
})
}
+1
View File
@@ -74,6 +74,7 @@ export {
export {
ProfileRuntime, type ProfileRuntimeOptions, type RowOrigin,
} from './profile-runtime.ts'
export { awaitChildClose } from './child-close.ts'
export {
PLUGIN_PROBE_DIR, PLUGIN_PROBE_FORMAT, probePackage, readProbeCache, writeProbeCache,
type PluginProbe, type PluginProbeRow, type ProbeOptions,
+21 -37
View File
@@ -8,6 +8,7 @@
*/
import { spawn } from 'node:child_process'
import { awaitChildClose } from './child-close.ts'
import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
@@ -153,45 +154,28 @@ process.stdout.write(JSON.stringify(report))
`
/** Run the child and parse its report. */
function runChild(options: ProbeOptions, packageDir: string, mainSpecifier: string, addable: string[]): Promise<ChildReport> {
async function runChild(options: ProbeOptions, packageDir: string, mainSpecifier: string, addable: string[]): Promise<ChildReport> {
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS
return new Promise((resolve, reject) => {
const child = spawn(
options.nodeExecutable ?? process.execPath,
['--experimental-import-meta-resolve', '--input-type=module', '-e', CHILD_SCRIPT, '--', packageDir, mainSpecifier, JSON.stringify(addable)],
{ cwd: options.profileDir, stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env, NODE_NO_WARNINGS: '1' } },
const child = spawn(
options.nodeExecutable ?? process.execPath,
['--experimental-import-meta-resolve', '--input-type=module', '-e', CHILD_SCRIPT, '--', packageDir, mainSpecifier, JSON.stringify(addable)],
{ cwd: options.profileDir, stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env, NODE_NO_WARNINGS: '1' } },
)
const out: Buffer[] = []
const err: Buffer[] = []
child.stdout.on('data', (chunk: Buffer) => out.push(chunk))
child.stderr.on('data', (chunk: Buffer) => err.push(chunk))
const code = await awaitChildClose(
child, timeoutMs, () => new Error(`${options.binName}: probe of ${options.packageName} timed out after ${String(timeoutMs)}ms`),
)
const stdout = Buffer.concat(out).toString('utf8')
try {
return JSON.parse(stdout) as ChildReport
} catch {
throw new Error(
`${options.binName}: probe of ${options.packageName} exited with ${String(code)} without a report: ${Buffer.concat(err).toString('utf8').trim()}`,
)
const out: Buffer[] = []
const err: Buffer[] = []
child.stdout.on('data', (chunk: Buffer) => out.push(chunk))
child.stderr.on('data', (chunk: Buffer) => err.push(chunk))
// One settlement: a spawn failure emits `error` and then `close`, and a
// timeout kill emits `close` after the rejection below.
let settled = false
const settle = (outcome: () => void): void => {
if (settled) return
settled = true
clearTimeout(timer)
outcome()
}
const timer = setTimeout(() => {
child.kill('SIGKILL')
settle(() => { reject(new Error(`${options.binName}: probe of ${options.packageName} timed out after ${String(timeoutMs)}ms`)) })
}, timeoutMs)
child.on('error', (error) => { settle(() => { reject(error) }) })
child.on('close', (code) => {
settle(() => {
const stdout = Buffer.concat(out).toString('utf8')
try {
resolve(JSON.parse(stdout) as ChildReport)
} catch {
reject(new Error(
`${options.binName}: probe of ${options.packageName} exited with ${String(code)} without a report: ${Buffer.concat(err).toString('utf8').trim()}`,
))
}
})
})
})
}
}
const DEFAULT_TIMEOUT_MS = 20_000
@@ -117,6 +117,8 @@ describe('PluginInventoryGateway', () => {
registry.record({ entryId: 'gone', rowId: 'gone', moduleName: 'cordis:throws', groupId: 'bundle/ext', stage: 'apply', message: 'boom' })
// A record of a row that later mounted rides on the live entry and is not listed twice.
registry.record({ entryId: bare, rowId: bare, moduleName: 'cordis:active', groupId: 'bundle/ext', stage: 'import', message: 'stale' })
// A failure the runtime cannot attribute belongs to no package.
registry.record({ entryId: 'orphan', rowId: 'orphan', moduleName: 'cordis:throws', groupId: 'bundle/gone', stage: 'apply', message: 'lost' })
registry.record({
entryId: 'conflict:bundle/late:versioned', rowId: versioned, moduleName: 'late', groupId: 'bundle/late',
packageName: 'late', stage: 'conflict', message: 'row "versioned" is already declared by ext',
@@ -133,6 +135,7 @@ describe('PluginInventoryGateway', () => {
{ entryId: off, moduleName: 'cordis:active', enabled: false, fiberPhase: null, trust: 'external', package: { name: 'ext' }, disabledBy: 'user' },
// A failed row the tree no longer holds is attributed through the runtime.
{ entryId: 'gone', moduleName: 'cordis:throws', enabled: true, fiberPhase: 'failed', trust: 'external', package: { name: 'ext' }, failure: { stage: 'apply', message: 'boom' } },
{ entryId: 'orphan', moduleName: 'cordis:throws', enabled: true, fiberPhase: 'failed', trust: 'external', failure: { stage: 'apply', message: 'lost' } },
// A conflict names the bundle that lost the id; the runtime would name the owner.
{ entryId: 'conflict:bundle/late:versioned', moduleName: 'late', enabled: true, fiberPhase: 'failed', trust: 'external', package: { name: 'late' }, failure: { stage: 'conflict', message: 'row "versioned" is already declared by ext' } },
// A user-layer conflict belongs to no package.
+5 -15
View File
@@ -24,6 +24,7 @@ import type { Entry } from '@deepseek-ai/cordis-plugin-loader'
import z from '@deepseek-ai/schemastery'
import type { AgentPresets } from '@deepseek-ai/dsh-agent-presets'
import {
awaitChildClose,
bundleGroupId,
claimLayerIds,
disableBundle,
@@ -885,21 +886,10 @@ export class PluginManager extends TypertRemoteService {
child.stderr?.setEncoding('utf8')
child.stdout?.on('data', (text: string) => { record('stdout', text) })
child.stderr?.on('data', (text: string) => { record('stderr', text) })
const exitCode = await new Promise<number | null>((resolve, reject) => {
let settled = false
const settle = (outcome: () => void): void => {
if (settled) return
settled = true
clearTimeout(timer)
outcome()
}
const timer = setTimeout(() => {
child.kill('SIGKILL')
settle(() => { reject(new Error(`${NAME}: pnpm ${args.join(' ')} timed out after ${String(this.config.installTimeoutMs)}ms`)) })
}, this.config.installTimeoutMs)
child.on('error', (error) => { settle(() => { reject(error) }) })
child.on('close', (code) => { settle(() => { resolve(code) }) })
}).catch((error: unknown) => {
const exitCode = await awaitChildClose(
child, this.config.installTimeoutMs,
() => new Error(`${NAME}: pnpm ${args.join(' ')} timed out after ${String(this.config.installTimeoutMs)}ms`),
).catch((error: unknown) => {
const message = messageOf(error)
record('stderr', `${message}\n`)
this.ctx.emit('plugins/install-log', { jobId, spec, stream: 'stderr', text: '', exitCode: null })
@@ -85,7 +85,8 @@ interface StagedPackage {
/** `index.js` text, exported as the package main. */
main?: string
version?: string
stage?: 'boot' | 'runtime'
/** Any string: a staged manifest may declare a stage the profile refuses. */
stage?: string
plugins?: { name: string; title?: string; config?: unknown }[]
files?: Record<string, string>
}
@@ -478,6 +479,24 @@ describe('PluginManager', () => {
expect(manifestOf(staged.profileDir).dependencies).not.toHaveProperty('ext-two')
})
it('removes an installed bundle the profile cannot resolve and says why', async () => {
const staged = await stageHome()
// The probe reads the manifest without judging the stage; resolving the
// layer is what refuses it, and that refusal is the removal's reason.
stagePackage(staged.profileDir, 'ext-odd', { patch: BUNDLE_ONE_ROW, stage: 'weird' })
const calls: string[][] = []
const { manager } = await bootProfile(staged, { spawn: recordingPnpm(staged.profileDir, calls) })
const result = await manager.install('ext-odd')
expect(result).toMatchObject({
installed: [], installedOnly: [],
removed: [{ name: 'ext-odd', reason: expect.stringContaining('declares stage "weird"') as string }],
})
expect(calls).toEqual([['pnpm', 'add', 'ext-odd'], ['pnpm', 'remove', 'ext-odd']])
expect(manifestOf(staged.profileDir).dependencies).not.toHaveProperty('ext-odd')
})
it('keeps a package whose probe refused it, for the view to explain', async () => {
const staged = await stageHome()
stagePackage(staged.profileDir, 'ext-broken', { patch: BUNDLE_ONE_ROW, main: 'throw new Error("no import for you")\n' })