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:
commit
148e94501a
6 files changed
+87
-53
No files matched your search
@@ -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) }) })
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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' })
|
||||
|
||||
Reference in new issue
Block a user