@deepseek-ai/dsh-app-boot 0.2.2-rc.1 → 0.2.2-rc.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.i18n.yaml +4 -4
- package/README.md +4 -2
- package/README.zh.md +4 -2
- package/lib/index.js +37 -17
- package/lib/types/bundle-platform.d.ts +4 -2
- package/lib/types/bundle-platform.d.ts.map +1 -0
- package/lib/types/bundle-platform.js.map +1 -0
- package/lib/types/compatibility-preflight.d.ts.map +1 -0
- package/lib/types/compatibility-preflight.js.map +1 -0
- package/lib/types/config-schema/collect.d.ts.map +1 -0
- package/lib/types/config-schema/collect.js.map +1 -0
- package/lib/types/config-schema/document.d.ts.map +1 -0
- package/lib/types/config-schema/document.js.map +1 -0
- package/lib/types/config-schema/index.d.ts.map +1 -0
- package/lib/types/config-schema/index.js.map +1 -0
- package/lib/types/config-schema/native.d.ts.map +1 -0
- package/lib/types/config-schema/native.js.map +1 -0
- package/lib/types/config-schema/pattern.d.ts.map +1 -0
- package/lib/types/config-schema/pattern.js.map +1 -0
- package/lib/types/config-schema/projector.d.ts.map +1 -0
- package/lib/types/config-schema/projector.js.map +1 -0
- package/lib/types/config-schema/types.d.ts.map +1 -0
- package/lib/types/config-schema/types.js.map +1 -0
- package/lib/types/index.d.ts +1 -1
- package/lib/types/index.d.ts.map +1 -0
- package/lib/types/index.js.map +1 -0
- package/lib/types/package-meta.d.ts.map +1 -0
- package/lib/types/package-meta.js.map +1 -0
- package/lib/types/plugin-compatibility.d.ts.map +1 -0
- package/lib/types/plugin-compatibility.js.map +1 -0
- package/lib/types/profile-compatibility.d.ts.map +1 -0
- package/lib/types/profile-compatibility.js.map +1 -0
- package/lib/types/profile-context.d.ts.map +1 -0
- package/lib/types/profile-context.js.map +1 -0
- package/lib/types/profile-plugins.d.ts.map +1 -0
- package/lib/types/profile-plugins.js.map +1 -0
- package/lib/types/profile-resolution/legacy-links.d.ts.map +1 -0
- package/lib/types/profile-resolution/legacy-links.js.map +1 -0
- package/lib/types/profile-resolution/resolver.d.ts.map +1 -0
- package/lib/types/profile-resolution/resolver.js.map +1 -0
- package/lib/types/profile-resolution/service.d.ts.map +1 -0
- package/lib/types/profile-resolution/service.js.map +1 -0
- package/lib/types/profile-resolution/worker-bootstrap.d.ts.map +1 -0
- package/lib/types/profile-resolution/worker-bootstrap.js.map +1 -0
- package/lib/types/profile-sanitize.d.ts.map +1 -0
- package/lib/types/profile-sanitize.js.map +1 -0
- package/lib/types/profile.d.ts.map +1 -0
- package/lib/types/profile.js.map +1 -0
- package/lib/types/startup-diagnostics.d.ts.map +1 -0
- package/lib/types/startup-diagnostics.js.map +1 -0
- package/package.json +15 -11
- package/src/bundle-platform.ts +35 -0
- package/src/compatibility-preflight.ts +187 -0
- package/src/config-schema/collect.ts +249 -0
- package/src/config-schema/document.ts +169 -0
- package/src/config-schema/index.ts +36 -0
- package/src/config-schema/native.ts +16 -0
- package/src/config-schema/pattern.ts +62 -0
- package/src/config-schema/projector.ts +455 -0
- package/src/config-schema/types.ts +95 -0
- package/src/index.ts +1286 -0
- package/src/package-meta.ts +200 -0
- package/src/plugin-compatibility.ts +117 -0
- package/src/profile-compatibility.ts +141 -0
- package/src/profile-context.ts +85 -0
- package/src/profile-plugins.ts +133 -0
- package/src/profile-resolution/legacy-links.ts +33 -0
- package/src/profile-resolution/resolver.ts +1072 -0
- package/src/profile-resolution/service.ts +148 -0
- package/src/profile-resolution/worker-bootstrap.ts +12 -0
- package/src/profile-sanitize.ts +34 -0
- package/src/profile.ts +762 -0
- package/src/startup-diagnostics.ts +104 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,1286 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared boot glue for `dsh` profiles, including the CLI packaged by the Python runtime wheel: load the gitignored
|
|
3
|
+
* `.env`, install the fail-loud Loader guards, resolve the config path (snapshot-aware), load the
|
|
4
|
+
* optional user patch layers from the Harness home (`~/.dsh`), expose its path resolver to
|
|
5
|
+
* config expressions, and drive the Cordis Loader against a leaf `cordis.yml` until the tree settles.
|
|
6
|
+
* @module @deepseek-ai/dsh-app-boot
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { pathToFileURL } from 'node:url'
|
|
10
|
+
import { readFileSync } from 'node:fs'
|
|
11
|
+
import { inspect, parseEnv } from 'node:util'
|
|
12
|
+
import { captureStartupComposition, type StartupCompositionDiagnostic } from './startup-diagnostics.ts'
|
|
13
|
+
export type { StartupCompositionDiagnostic, StartupPluginState, StartupServiceState } from './startup-diagnostics.ts'
|
|
14
|
+
import { basename, dirname, isAbsolute, resolve } from 'node:path'
|
|
15
|
+
import * as yaml from 'js-yaml'
|
|
16
|
+
import { Context, type Fiber, type FiberState } from '@deepseek-ai/cordis'
|
|
17
|
+
import Loader, { type Entry, type EntryGroup, type EntryOptions } from '@deepseek-ai/cordis-plugin-loader'
|
|
18
|
+
import Include, { applyEntryPatches, entryListSchema, type PatchOptions } from '@deepseek-ai/cordis-plugin-include'
|
|
19
|
+
import Group from '@deepseek-ai/cordis-plugin-group'
|
|
20
|
+
import { dshHomePath, resolveDshHome } from '@deepseek-ai/dsh-home-paths'
|
|
21
|
+
import { createLaunchEnvironmentSnapshot, type LaunchEnvironmentSnapshot } from '@deepseek-ai/dsh-launch-environment'
|
|
22
|
+
export { readBundlePatches, readProfilePatches, resolveTelemetryPatch, type ProfileContext, type ProfilePnpmInvocation } from './profile-context.ts'
|
|
23
|
+
export { sanitizeProfile } from './profile-sanitize.ts'
|
|
24
|
+
export { getDshRuntimeVersion, evaluatePluginCompatibility, pluginCompatibilityWarning, type PluginCompatibility } from './plugin-compatibility.ts'
|
|
25
|
+
export {
|
|
26
|
+
PROFILE_COMPATIBILITY_FILENAME, readProfileCompatibility, readProfileVersionExemptions,
|
|
27
|
+
setProfileVersionExemption, validatePluginVersionExemption, type ProfileCompatibility,
|
|
28
|
+
} from './profile-compatibility.ts'
|
|
29
|
+
import { prepareProfilePatches } from './compatibility-preflight.ts'
|
|
30
|
+
import { ProfileRuntimeResolution, type RuntimeResolution } from './profile.ts'
|
|
31
|
+
import { ResolutionRestartRequired } from './profile-resolution/resolver.ts'
|
|
32
|
+
export { prepareProfileEntries, prepareProfilePatches } from './compatibility-preflight.ts'
|
|
33
|
+
export { readPluginMeta, resolvePluginResource } from './package-meta.ts'
|
|
34
|
+
export { generateConfigSchema, type ConfigSchemaDump, type NativeConfigSchema } from './config-schema/index.ts'
|
|
35
|
+
export { createConfigProjector, LOADER_EXPRESSION_SCHEMA, type ConfigProjection } from './config-schema/projector.ts'
|
|
36
|
+
export { isNativeConfigSchema } from './config-schema/native.ts'
|
|
37
|
+
|
|
38
|
+
export {
|
|
39
|
+
readProfilePlugins, reconcileProfilePlugins, writeProfileBundles,
|
|
40
|
+
type ProfilePluginLocation, type ProfilePluginDependency, type ProfilePluginInventory, type ProfilePluginReconciliation,
|
|
41
|
+
} from './profile-plugins.ts'
|
|
42
|
+
|
|
43
|
+
declare module '@deepseek-ai/cordis' {
|
|
44
|
+
interface Context {
|
|
45
|
+
/** Harness-home path resolver available to Loader `!!js` config expressions. */
|
|
46
|
+
dshHomePath?: typeof dshHomePath
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
interface Events {
|
|
50
|
+
/**
|
|
51
|
+
* Profile patches were reconciled into the running Loader tree: every entry update settled and no new
|
|
52
|
+
* inactive entry was introduced. Carries no diff; listeners re-read Loader entries.
|
|
53
|
+
* @mode emit
|
|
54
|
+
*/
|
|
55
|
+
'app-boot/config-reload'(): void
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export { assertBundlePlatform, UnsupportedBundlePlatformError } from './bundle-platform.ts'
|
|
60
|
+
export {
|
|
61
|
+
composeEntries,
|
|
62
|
+
createRuntimeResolution,
|
|
63
|
+
ProfileRuntimeResolution,
|
|
64
|
+
bundlePatchFiles,
|
|
65
|
+
bundlePatchPaths,
|
|
66
|
+
initProfile,
|
|
67
|
+
removeLinkProjections,
|
|
68
|
+
loadProfile,
|
|
69
|
+
loadProfileDirectory,
|
|
70
|
+
PROFILE_PATCH_FILENAME,
|
|
71
|
+
PROFILES_DIR,
|
|
72
|
+
readProfileManifest,
|
|
73
|
+
reportSkippedBundles,
|
|
74
|
+
resolveBundleDir,
|
|
75
|
+
resolveProfileDir,
|
|
76
|
+
writeProfileManifest,
|
|
77
|
+
type Profile,
|
|
78
|
+
type ProfileLayer,
|
|
79
|
+
type SkippedBundle,
|
|
80
|
+
type ProfileManifest,
|
|
81
|
+
type LinkedRoot,
|
|
82
|
+
type RuntimeResolutionOptions,
|
|
83
|
+
type RuntimeResolutionEntry,
|
|
84
|
+
type RuntimeResolution,
|
|
85
|
+
} from './profile.ts'
|
|
86
|
+
export {
|
|
87
|
+
PluginPackages,
|
|
88
|
+
type PluginPackage,
|
|
89
|
+
type PluginPackagesConfig,
|
|
90
|
+
} from './profile-resolution/service.ts'
|
|
91
|
+
export { ResolutionRestartRequired } from './profile-resolution/resolver.ts'
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Resolve the config to boot. Replay swaps a `cordis.yml` basename for
|
|
95
|
+
* `cordis.snapshot.yml` in the same directory; every other mode keeps the path.
|
|
96
|
+
* @param configPath - the requested config path (absolute, or relative to `cwd`).
|
|
97
|
+
* @param snapshotMode - the bin's `$DSH_SNAPSHOT` value; only `'replay'` swaps the
|
|
98
|
+
* basename.
|
|
99
|
+
* @param cwd - the base a relative `configPath` resolves against.
|
|
100
|
+
* @returns the absolute path of the config to boot.
|
|
101
|
+
*/
|
|
102
|
+
export function resolveConfigPath(
|
|
103
|
+
configPath: string, snapshotMode: string | undefined, cwd: string = process.cwd(),
|
|
104
|
+
): string {
|
|
105
|
+
const absolute = resolve(cwd, configPath)
|
|
106
|
+
if (snapshotMode !== 'replay') return absolute
|
|
107
|
+
const dir = dirname(absolute)
|
|
108
|
+
const replayName = basename(absolute).replace(/cordis\.ya?ml$/, 'cordis.snapshot.yml')
|
|
109
|
+
return resolve(dir, replayName)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Load the optional gitignored `.env` from `dir`. Missing files fall back to the
|
|
114
|
+
* ambient environment; other read failures are reported through `warn`.
|
|
115
|
+
* @param binName - the diagnostic prefix on the warn line.
|
|
116
|
+
* @param dir - the directory whose `.env` to load.
|
|
117
|
+
* @param warn - sink for the one-line misconfiguration diagnostic.
|
|
118
|
+
*/
|
|
119
|
+
export function loadEnv(
|
|
120
|
+
binName: string, dir: string = process.cwd(),
|
|
121
|
+
warn: (line: string) => void = line => void process.stderr.write(line),
|
|
122
|
+
): void {
|
|
123
|
+
try {
|
|
124
|
+
process.loadEnvFile(resolve(dir, '.env'))
|
|
125
|
+
} catch (error) {
|
|
126
|
+
if ((error as NodeJS.ErrnoException | null)?.code !== 'ENOENT') {
|
|
127
|
+
warn(`${binName}: failed to load .env: ${String(error)}\n`)
|
|
128
|
+
}
|
|
129
|
+
// ENOENT (no .env) is fine — rely on the ambient environment.
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Exact names no discovered file may set. */
|
|
134
|
+
const BOOTSTRAP_NAMES = new Set([
|
|
135
|
+
// Process launch and module resolution.
|
|
136
|
+
'PATH', 'HOME', 'USERPROFILE', 'SHELL',
|
|
137
|
+
'NODE_OPTIONS', 'NODE_PATH', 'NODE_EXTRA_CA_CERTS',
|
|
138
|
+
'LD_PRELOAD', 'LD_LIBRARY_PATH', 'LD_AUDIT',
|
|
139
|
+
// Interpreter startup hooks.
|
|
140
|
+
'BASH_ENV', 'ENV', 'SHELLOPTS', 'BASHOPTS',
|
|
141
|
+
'PERL5OPT', 'PERL5LIB', 'PYTHONSTARTUP', 'PYTHONPATH', 'RUBYOPT', 'RUBYLIB',
|
|
142
|
+
'JAVA_TOOL_OPTIONS', '_JAVA_OPTIONS', 'JDK_JAVA_OPTIONS',
|
|
143
|
+
'PYTHONHOME',
|
|
144
|
+
// Version-control hooks, config redirects, and ambient command selectors.
|
|
145
|
+
'GIT_SSH', 'GIT_SSH_COMMAND', 'GIT_EXTERNAL_DIFF', 'GIT_PAGER', 'GIT_EDITOR',
|
|
146
|
+
'GIT_ASKPASS', 'SSH_ASKPASS',
|
|
147
|
+
'GIT_CONFIG_GLOBAL', 'GIT_CONFIG_SYSTEM', 'GIT_CONFIG_COUNT',
|
|
148
|
+
'EDITOR', 'VISUAL', 'PAGER', 'BROWSER',
|
|
149
|
+
// Network reach and trust.
|
|
150
|
+
'DEEPSEEK_BASE_URL', 'DEEPSEEK_SEARCH_BASE_URL',
|
|
151
|
+
'SSL_CERT_FILE', 'SSL_CERT_DIR',
|
|
152
|
+
'HTTP_PROXY', 'HTTPS_PROXY', 'ALL_PROXY', 'NO_PROXY',
|
|
153
|
+
'REQUESTS_CA_BUNDLE', 'CURL_CA_BUNDLE',
|
|
154
|
+
'NODE_TLS_REJECT_UNAUTHORIZED',
|
|
155
|
+
])
|
|
156
|
+
|
|
157
|
+
/** Name prefixes no discovered file may set. */
|
|
158
|
+
const BOOTSTRAP_PREFIXES = ['DSH_', 'XDG_', 'DYLD_', 'BASH_FUNC_']
|
|
159
|
+
|
|
160
|
+
/** Proxy names the Harness-home `.env` may set in any letter case: proxy clients read both spellings. */
|
|
161
|
+
const HOME_LAYER_PROXY_NAMES = new Set(['HTTP_PROXY', 'HTTPS_PROXY', 'ALL_PROXY', 'NO_PROXY'])
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* DeepSeek endpoint names the Harness-home `.env` may set only as spelled here: their consumers look them up by
|
|
165
|
+
* exact name on POSIX, so another spelling would load without effect.
|
|
166
|
+
*/
|
|
167
|
+
const HOME_LAYER_ENDPOINT_NAMES = new Set(['DEEPSEEK_BASE_URL', 'DEEPSEEK_SEARCH_BASE_URL'])
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Whether the Harness-home `.env` alone may set a bootstrap-only name. Each such name chooses where requests go: a
|
|
171
|
+
* proxy for every request, a DeepSeek endpoint for the model or search requests that carry the user's credential.
|
|
172
|
+
* The invoking directory's file arrives with a clone and keeps refusing them. The home file is the user's own, and
|
|
173
|
+
* the profile patches beside it can already set these endpoints as plugin `baseURL` fields; `DSH_HOME` is itself
|
|
174
|
+
* bootstrap-only, so no `.env` can relocate this exemption. The CA and TLS names in the same group stay refused
|
|
175
|
+
* everywhere: they change what is trusted, not where traffic goes.
|
|
176
|
+
* @param name - the variable name as the file spells it.
|
|
177
|
+
* @returns true for a {@link HOME_LAYER_PROXY_NAMES} entry in any letter case or a
|
|
178
|
+
* {@link HOME_LAYER_ENDPOINT_NAMES} entry in its exact spelling.
|
|
179
|
+
*/
|
|
180
|
+
function isHomeLayerRouteName(name: string): boolean {
|
|
181
|
+
return HOME_LAYER_PROXY_NAMES.has(name.toUpperCase()) || HOME_LAYER_ENDPOINT_NAMES.has(name)
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Whether a variable may come only from the inherited process environment
|
|
186
|
+
* because it changes process, runtime, VCS, or network bootstrap. The Harness-home
|
|
187
|
+
* file is additionally allowed the names {@link isHomeLayerRouteName} accepts.
|
|
188
|
+
* @param name - the variable name.
|
|
189
|
+
* @returns true when only the inherited environment may supply it.
|
|
190
|
+
*/
|
|
191
|
+
function isBootstrapOnly(name: string): boolean {
|
|
192
|
+
const upper = name.toUpperCase()
|
|
193
|
+
return BOOTSTRAP_NAMES.has(upper) || BOOTSTRAP_PREFIXES.some(prefix => upper.startsWith(prefix))
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Parse one directory's `.env` without applying it, rejecting bootstrap-only
|
|
198
|
+
* names before any value is materialized.
|
|
199
|
+
* @param binName - the diagnostic prefix on the thrown error.
|
|
200
|
+
* @param dir - the directory whose `.env` to read.
|
|
201
|
+
* @param warn - sink for the one-line unreadable-file diagnostic.
|
|
202
|
+
* @param home - the resolved Harness home; when `dir` is it, names {@link isHomeLayerRouteName} accepts are allowed.
|
|
203
|
+
* @returns the parsed entries, or `undefined` when the file is absent or unreadable.
|
|
204
|
+
* @throws when the file declares a name {@link isBootstrapOnly} rejects and this layer may not set.
|
|
205
|
+
*/
|
|
206
|
+
function readEnvLayer(
|
|
207
|
+
binName: string, dir: string, warn: (line: string) => void, home: string,
|
|
208
|
+
): { path: string; values: Record<string, string> } | undefined {
|
|
209
|
+
const path = resolve(dir, '.env')
|
|
210
|
+
const isHome = resolve(dir) === home
|
|
211
|
+
let content: string
|
|
212
|
+
try {
|
|
213
|
+
content = readFileSync(path, 'utf8')
|
|
214
|
+
} catch (error) {
|
|
215
|
+
if ((error as NodeJS.ErrnoException | null)?.code !== 'ENOENT') {
|
|
216
|
+
warn(`${binName}: failed to load .env: ${String(error)}\n`)
|
|
217
|
+
}
|
|
218
|
+
// ENOENT (no .env) is fine — rely on the ambient environment.
|
|
219
|
+
return undefined
|
|
220
|
+
}
|
|
221
|
+
// Parse once so validation and materialization use exactly the same entries.
|
|
222
|
+
const values = parseEnv(content) as Record<string, string>
|
|
223
|
+
for (const name of Object.keys(values)) {
|
|
224
|
+
if (!isBootstrapOnly(name)) continue
|
|
225
|
+
const homeLayerName = isHomeLayerRouteName(name)
|
|
226
|
+
if (isHome && homeLayerName) continue
|
|
227
|
+
// A home-layer name, or an endpoint name spelled another way, has a second way out that the other bootstrap names
|
|
228
|
+
// do not, so its message says so.
|
|
229
|
+
const homeEnv = resolve(home, '.env')
|
|
230
|
+
const upper = name.toUpperCase()
|
|
231
|
+
const remedy = homeLayerName
|
|
232
|
+
? `export ${name}, or put it in ${homeEnv}, which does not travel with a repository`
|
|
233
|
+
: HOME_LAYER_ENDPOINT_NAMES.has(upper)
|
|
234
|
+
? `spell it ${upper}, then export it or put it in ${homeEnv}, which does not travel with a repository`
|
|
235
|
+
: `export ${name} instead of putting it in a .env file`
|
|
236
|
+
throw new Error(
|
|
237
|
+
`${binName}: ${path} sets "${name}", which only the launching environment may set`
|
|
238
|
+
+ ' (it decides how this process starts, where its code and instructions load from, or how it'
|
|
239
|
+
+ ` reaches the network); ${remedy}`,
|
|
240
|
+
)
|
|
241
|
+
}
|
|
242
|
+
return { path, values }
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Load the product CLI's inherited > invoking-directory `.env` > Harness-home
|
|
247
|
+
* `.env` snapshot. The Harness home resolves before either file; both files
|
|
248
|
+
* are checked before either is applied, and accepted values are materialized
|
|
249
|
+
* without replacing inherited ones. The snapshot preserves which layer supplied each value.
|
|
250
|
+
* @param binName - the diagnostic prefix on the diagnostics.
|
|
251
|
+
* @param cwd - the invoking directory whose `.env` is the project layer.
|
|
252
|
+
* @param warn - sink for the one-line misconfiguration diagnostics.
|
|
253
|
+
* @returns this run's frozen environment snapshot.
|
|
254
|
+
* @throws when either file declares a bootstrap-only variable, except names {@link isHomeLayerRouteName} accepts in the Harness-home file.
|
|
255
|
+
*/
|
|
256
|
+
export function loadLayeredEnv(
|
|
257
|
+
binName: string, cwd: string = process.cwd(),
|
|
258
|
+
warn: (line: string) => void = line => void process.stderr.write(line),
|
|
259
|
+
): LaunchEnvironmentSnapshot {
|
|
260
|
+
const home = resolveDshHome()
|
|
261
|
+
const inherited = { ...process.env } as Record<string, string>
|
|
262
|
+
// Parse both layers first: a rejection must not leave one file applied.
|
|
263
|
+
const project = readEnvLayer(binName, cwd, warn, home)
|
|
264
|
+
const user = home === resolve(cwd) ? undefined : readEnvLayer(binName, home, warn, home)
|
|
265
|
+
// Apply the checked values without replacing a higher-ranked name.
|
|
266
|
+
for (const layer of [project, user]) {
|
|
267
|
+
if (layer === undefined) continue
|
|
268
|
+
for (const [name, value] of Object.entries(layer.values)) {
|
|
269
|
+
if (process.env[name] === undefined) process.env[name] = value
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
return createLaunchEnvironmentSnapshot([
|
|
273
|
+
{ source: 'process', values: inherited },
|
|
274
|
+
...project === undefined ? [] : [{ source: 'project-env' as const, path: project.path, values: project.values }],
|
|
275
|
+
...user === undefined ? [] : [{ source: 'user-env' as const, path: user.path, values: user.values }],
|
|
276
|
+
])
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const bootstrapIncludes = new WeakMap<Context, Entry>()
|
|
280
|
+
|
|
281
|
+
// The include's YAML dialect (`!!js` scalars become expression nodes the
|
|
282
|
+
// Loader interpolates against each entry's injection-ready context), imported
|
|
283
|
+
// from the include itself so patch parsing and config dumping can never drift
|
|
284
|
+
// from what the include mounts. User patch layers share it so they may
|
|
285
|
+
// reference `process.env`.
|
|
286
|
+
const userPatchesSchema = entryListSchema
|
|
287
|
+
|
|
288
|
+
/** What a caller of {@link recomposeProfile} already did and requires. */
|
|
289
|
+
export interface ProfileRecompositionOptions {
|
|
290
|
+
/** Explicit enablement targets whose existing failures also reject the recomposition. */
|
|
291
|
+
readonly requiredIds?: readonly string[]
|
|
292
|
+
/**
|
|
293
|
+
* Loader entries that ran before the caller stopped some of them. Each that no longer runs starts again, and each that
|
|
294
|
+
* failed afterwards counts as a new failure.
|
|
295
|
+
*/
|
|
296
|
+
readonly running?: readonly Entry[]
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** The outcome of a {@link recomposeProfile} that applied or kept the composition. */
|
|
300
|
+
export interface ProfileRecomposition {
|
|
301
|
+
/** Diagnostics for unchanged pre-existing inactive entries. */
|
|
302
|
+
warnings: string[]
|
|
303
|
+
/** Present when the runtime resolution could not publish a moved package or a changed installation mapping. */
|
|
304
|
+
restartReason?: 'package-moved'
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Recompose the profile from its complete patch list, together with its runtime resolution. The rows the generation no
|
|
309
|
+
* longer lists are removed first and their disposal finishes while their packages are still mapped; the latest
|
|
310
|
+
* generation of an installed, recomputable resolution is published; the compatibility preflight reads the published
|
|
311
|
+
* packages; the generation is applied to the root Include; entries that ran, and enabled entries whose import failed,
|
|
312
|
+
* start afterwards when the update left them without a running fiber; and activation is judged. A failure before the generation applies,
|
|
313
|
+
* including a publication the resolution rejects, which reports `package-moved`, puts the removed rows back at their
|
|
314
|
+
* places, starting them again, and starts the entries that ran again. An entry waiting for a service is reported as a
|
|
315
|
+
* warning unless it is required. Patches are copied with `structuredClone`, so they hold no functions.
|
|
316
|
+
* @param ctx Booted root context.
|
|
317
|
+
* @param patches Complete ordered patch list.
|
|
318
|
+
* @param binName Diagnostic prefix.
|
|
319
|
+
* @param options What the caller already did and requires.
|
|
320
|
+
* @returns Diagnostics for unchanged pre-existing inactive entries, and the restart reason of a rejected publication.
|
|
321
|
+
* @throws When the root Include is missing; the latest resolution cannot be read or published; preparation or the
|
|
322
|
+
* Loader update rejects; or for a new or changed failure, an inactive required entry, a failed entry that ran, or a
|
|
323
|
+
* healthy fiber whose disposal rejected.
|
|
324
|
+
*/
|
|
325
|
+
export function recomposeProfile(
|
|
326
|
+
ctx: Context, patches: PatchOptions[], binName: string, options: ProfileRecompositionOptions = {},
|
|
327
|
+
): Promise<ProfileRecomposition> {
|
|
328
|
+
return recompose(ctx, patches, binName, options, true)
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Apply a complete patch list to the root Include and judge activation, leaving the runtime resolution and entries
|
|
333
|
+
* whose import failed as they are.
|
|
334
|
+
* @param ctx Booted root context.
|
|
335
|
+
* @param patches Complete ordered patch list.
|
|
336
|
+
* @param binName Diagnostic prefix.
|
|
337
|
+
* @param requiredIds Explicit enablement targets whose existing failures also reject reconciliation.
|
|
338
|
+
* @returns Diagnostics for unchanged pre-existing inactive entries; new or changed failures reject.
|
|
339
|
+
*/
|
|
340
|
+
export async function reconcileProfilePatches(
|
|
341
|
+
ctx: Context, patches: PatchOptions[], binName: string, requiredIds: readonly string[] = [],
|
|
342
|
+
): Promise<string[]> {
|
|
343
|
+
return (await recompose(ctx, patches, binName, { requiredIds }, false)).warnings
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
async function recompose(
|
|
347
|
+
ctx: Context, patches: PatchOptions[], binName: string, options: ProfileRecompositionOptions, withResolution: boolean,
|
|
348
|
+
): Promise<ProfileRecomposition> {
|
|
349
|
+
const { requiredIds = [], running = [] } = options
|
|
350
|
+
let applying = false
|
|
351
|
+
// The rows stopped first, which a failure before the generation applies puts back.
|
|
352
|
+
let stopped: StoppedRow[] = []
|
|
353
|
+
try {
|
|
354
|
+
const entry = bootstrapIncludes.get(ctx)
|
|
355
|
+
if (entry === undefined) throw new Error(`${binName}: profile reload requires the root Include entry`)
|
|
356
|
+
// Reading the prior state imports nothing, so plugin code does not run before the next resolution publishes.
|
|
357
|
+
const previousFailures = (await inactiveEntries(ctx, false)).map((failure): RecordedFailure => ({
|
|
358
|
+
entry: failure.entry, fiber: failure.entry.fiber, options: JSON.stringify(failure.entry.options),
|
|
359
|
+
// An entry without a running fiber has no import error yet; the judgment after the update reads it.
|
|
360
|
+
diagnostic: failure.entry.fiber?.uid ? reloadDiagnostic(failure) : undefined,
|
|
361
|
+
}))
|
|
362
|
+
// Removed entries leave the Loader store before their async disposers finish.
|
|
363
|
+
const previousFibers = [...ctx.loader.entries()].flatMap(row => row.fiber === undefined ? [] : [row.fiber])
|
|
364
|
+
const { patches: previous = [], ...includeConfig } = entry.options.config as Include.Config
|
|
365
|
+
const current = withResolution ? ctx.get('pluginPackages')?.resolution : undefined
|
|
366
|
+
// A plain-data resolution has no inputs to recompute from.
|
|
367
|
+
const successor = current instanceof ProfileRuntimeResolution ? await current.computeLatestResolution() : undefined
|
|
368
|
+
if (successor !== undefined) {
|
|
369
|
+
// Rows the generation no longer lists stop, and their disposal finishes, while their packages are still mapped.
|
|
370
|
+
const listed = rowIds(structuredClone(patches))
|
|
371
|
+
const composed = rowIds(structuredClone(previous))
|
|
372
|
+
// Only rows of the root Include's tree; a nested Include file has its own ids.
|
|
373
|
+
const tree = entry.subgroup?.tree
|
|
374
|
+
const dropped = [...ctx.loader.entries()]
|
|
375
|
+
.filter(row => row.parent.tree === tree && composed.has(row.options.id) && !listed.has(row.options.id))
|
|
376
|
+
// A dropped group takes its rows with it.
|
|
377
|
+
const ids = new Set(dropped.map(row => row.options.id))
|
|
378
|
+
const topmost = dropped.filter(row => !ids.has(row.parent.ctx.fiber.entry?.options.id as string))
|
|
379
|
+
// The Loader marks the options of a row it stops, so the copy taken before is the one restored.
|
|
380
|
+
stopped = topmost.map(row => ({
|
|
381
|
+
group: row.parent, options: structuredClone(row.options),
|
|
382
|
+
index: row.parent.data.indexOf(row.options),
|
|
383
|
+
}))
|
|
384
|
+
if (topmost.length > 0) {
|
|
385
|
+
const fibers = dropped.flatMap(row => row.fiber === undefined ? [] : [row.fiber])
|
|
386
|
+
for (const row of topmost) {
|
|
387
|
+
// A disabled row's disposal is the Loader's own, so it does not rewrite the profile's configuration file.
|
|
388
|
+
row.options.disabled = true
|
|
389
|
+
row.parent.remove(row.options.id)
|
|
390
|
+
}
|
|
391
|
+
await Promise.allSettled(fibers.map(fiber => fiber.await()))
|
|
392
|
+
await ctx.loader.await()
|
|
393
|
+
}
|
|
394
|
+
try {
|
|
395
|
+
publish(ctx, successor)
|
|
396
|
+
} catch (error) {
|
|
397
|
+
if (!(error instanceof ResolutionRestartRequired)) throw error
|
|
398
|
+
// The generation waits for the mappings the rejected publication would change.
|
|
399
|
+
return { warnings: [], restartReason: 'package-moved' }
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
// The recomposition judges the rows the launch judged, resolved from the file this Include read.
|
|
403
|
+
const parentURL = new URL('.', new URL(includeConfig.path, entry.parent.tree.ctx.baseUrl)).href
|
|
404
|
+
const prepared = prepareProfilePatches(ctx, patches, parentURL, binName)
|
|
405
|
+
applying = true
|
|
406
|
+
const retried = [...new Set([...running, ...withResolution ? [...ctx.loader.entries()].filter(importFailed) : []])]
|
|
407
|
+
// Group.update replaces its data array and attempts every row it still lists, including failed imports.
|
|
408
|
+
const priorData = new Map(retried.map(row => [row, row.parent.data]))
|
|
409
|
+
try {
|
|
410
|
+
await entry.update({ config: { ...includeConfig, patches: prepared } })
|
|
411
|
+
} finally {
|
|
412
|
+
// Only rows whose Group did not update still need an explicit retry after the awaited update.
|
|
413
|
+
await resume(ctx, retried.filter(row => row.parent.data === priorData.get(row)))
|
|
414
|
+
}
|
|
415
|
+
await Promise.allSettled(previousFibers.map(fiber => fiber.await()))
|
|
416
|
+
await ctx.loader.await()
|
|
417
|
+
const failures = await inactiveEntries(ctx)
|
|
418
|
+
const introduced = failures.filter((failure) => {
|
|
419
|
+
if (requiredIds.includes(failure.entry.options.id)) return true
|
|
420
|
+
// An entry waiting for a service starts once a provider appears.
|
|
421
|
+
if (failure.outcome.kind === 'pending') return false
|
|
422
|
+
// An entry that ran before the caller stopped it is a new failure even when it failed the same way before.
|
|
423
|
+
if (running.includes(failure.entry)) return true
|
|
424
|
+
return !previousFailures.some(previous => sameFailure(previous, failure))
|
|
425
|
+
})
|
|
426
|
+
if (introduced.length > 0) throw new Error(activationDiagnostic(binName, introduced, false).trimEnd())
|
|
427
|
+
ctx.emit('app-boot/config-reload')
|
|
428
|
+
return { warnings: failures.map(reloadDiagnostic) }
|
|
429
|
+
} finally {
|
|
430
|
+
// Before the generation applies, the rows removed first and the entries the caller stopped run again.
|
|
431
|
+
if (!applying && (stopped.length > 0 || running.length > 0)) {
|
|
432
|
+
try {
|
|
433
|
+
// Ascending original positions put each row back where it was.
|
|
434
|
+
for (const { group, options, index } of stopped.toSorted((left, right) => left.index - right.index)) {
|
|
435
|
+
// `create` starts the entry but leaves the group's option list as it is; that list is what the next update
|
|
436
|
+
// compares against to remove the row.
|
|
437
|
+
group.data.splice(index, 0, options)
|
|
438
|
+
await group.create(options)
|
|
439
|
+
}
|
|
440
|
+
await resume(ctx, running)
|
|
441
|
+
// A restored row is a new Loader entry with the id of the one that ran.
|
|
442
|
+
const ran = new Set(running.map(row => row.options.id))
|
|
443
|
+
for (const failure of await inactiveEntries(ctx)) {
|
|
444
|
+
if (ran.has(failure.entry.options.id)) ctx.logger.warn(`${binName}: ${reloadDiagnostic(failure)}`)
|
|
445
|
+
}
|
|
446
|
+
} catch (error) {
|
|
447
|
+
// The recomposition's own failure is the outcome.
|
|
448
|
+
ctx.logger.warn(error)
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/** An inactive entry as a recomposition found it before changing anything. */
|
|
455
|
+
interface RecordedFailure {
|
|
456
|
+
entry: Entry
|
|
457
|
+
fiber: Fiber | undefined
|
|
458
|
+
/** The entry's options as JSON. */
|
|
459
|
+
options: string
|
|
460
|
+
/** The reload diagnostic; `undefined` for an import failure, which matches any diagnostic. */
|
|
461
|
+
diagnostic: string | undefined
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/** Whether an inactive entry is the failure recorded before: the same entry, fiber and options, and a matching diagnostic. */
|
|
465
|
+
function sameFailure(previous: RecordedFailure, failure: InactiveEntry): boolean {
|
|
466
|
+
return previous.entry === failure.entry && previous.fiber === failure.entry.fiber
|
|
467
|
+
&& previous.options === JSON.stringify(failure.entry.options)
|
|
468
|
+
&& (previous.diagnostic === undefined || previous.diagnostic === reloadDiagnostic(failure))
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/** A row a recomposition removed before publishing, with its place in its group. */
|
|
472
|
+
interface StoppedRow {
|
|
473
|
+
group: EntryGroup
|
|
474
|
+
options: EntryOptions
|
|
475
|
+
index: number
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/** The ids of the rows a patch list inserts, including the rows of groups. */
|
|
479
|
+
function rowIds(patches: readonly PatchOptions[]): Set<string> {
|
|
480
|
+
const ids = new Set<string>()
|
|
481
|
+
const visit = (rows: readonly EntryOptions[]): void => {
|
|
482
|
+
for (const row of rows) {
|
|
483
|
+
ids.add(row.id)
|
|
484
|
+
// A group keeps its rows in its configuration.
|
|
485
|
+
if ((row.group === true || row.name === 'cordis:group') && Array.isArray(row.config)) visit(row.config as EntryOptions[])
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
// Patch warnings are logged when the generation itself is prepared.
|
|
489
|
+
visit(applyEntryPatches([], patches as PatchOptions[], () => {}))
|
|
490
|
+
return ids
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/** Publish a generation unless it maps exactly what is published, which would only drop the package caches. */
|
|
494
|
+
function publish(ctx: Context, next: RuntimeResolution): void {
|
|
495
|
+
const table = ({ localPackageNames, entries, linkedRoots }: RuntimeResolution): string =>
|
|
496
|
+
JSON.stringify([localPackageNames, entries, linkedRoots])
|
|
497
|
+
const packages = ctx.pluginPackages
|
|
498
|
+
if (table(packages.resolution as RuntimeResolution) !== table(next)) packages.replace(next)
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/** Whether an enabled entry below a running parent has no fiber or keeps the disposed fiber of a failed import. */
|
|
502
|
+
function importFailed(entry: Entry): boolean {
|
|
503
|
+
return enabledEntry(entry) && entry.parent.ctx.fiber.uid !== null
|
|
504
|
+
&& (entry.fiber === undefined || entry.fiber.state === FIBER_DISPOSED)
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/** Whether an entry is enabled now; a throwing `disabled` expression counts as disabled. */
|
|
508
|
+
function enabledEntry(entry: Entry): boolean {
|
|
509
|
+
try {
|
|
510
|
+
return !entry.disabled
|
|
511
|
+
} catch (_error) {
|
|
512
|
+
// Judgment reports the expression failure.
|
|
513
|
+
return false
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/** Start invalidated entries the Loader still holds without a running fiber, then wait until it settles. */
|
|
518
|
+
async function resume(ctx: Context, invalidated: readonly Entry[]): Promise<void> {
|
|
519
|
+
const entries = new Set(ctx.loader.entries())
|
|
520
|
+
// A stopped entry starts again on update even with unchanged options; an unchanged Include does not update its rows.
|
|
521
|
+
for (const row of invalidated) if (entries.has(row) && !row.fiber?.uid) await row.update({}, false, true)
|
|
522
|
+
await ctx.loader.await()
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* Load an optional patch-list file: a top-level YAML array of loader patch
|
|
527
|
+
* entries (`@deepseek-ai/cordis-plugin-include`'s `PatchOptions`): id-targeted config
|
|
528
|
+
* overrides and `insert` lists, with `!!js` expressions allowed. A missing
|
|
529
|
+
* file means "no layer"; an unreadable, unparsable, or non-array file throws —
|
|
530
|
+
* a present patch file that cannot apply is a misconfiguration and must fail
|
|
531
|
+
* loud at boot, never be silently skipped.
|
|
532
|
+
* @param binName - the diagnostic prefix on the thrown error.
|
|
533
|
+
* @param file - absolute path of the patch file.
|
|
534
|
+
* @returns the parsed patches, or `undefined` when the file does not exist.
|
|
535
|
+
*/
|
|
536
|
+
export function loadOptionalPatches(binName: string, file: string): PatchOptions[] | undefined {
|
|
537
|
+
let content: string
|
|
538
|
+
try {
|
|
539
|
+
content = readFileSync(file, 'utf8')
|
|
540
|
+
} catch (error) {
|
|
541
|
+
if ((error as NodeJS.ErrnoException | null)?.code === 'ENOENT') return undefined
|
|
542
|
+
throw new Error(`${binName}: failed to read patches ${file}: ${String(error)}`)
|
|
543
|
+
}
|
|
544
|
+
return parsePatchList(binName, file, content, 'patches')
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/**
|
|
548
|
+
* Load a required overlay patch list: a bundle's `cordis.patch.yml` or a
|
|
549
|
+
* `--patch <path>` overlay. Same file format as {@link loadOptionalPatches},
|
|
550
|
+
* but a missing file throws, because the caller named this file — its absence
|
|
551
|
+
* is a misconfiguration, not "no overlay".
|
|
552
|
+
* @param binName - the diagnostic prefix on the thrown error.
|
|
553
|
+
* @param file - absolute path of the overlay file.
|
|
554
|
+
* @returns the parsed patch list.
|
|
555
|
+
*/
|
|
556
|
+
export function loadOverlayPatches(binName: string, file: string): PatchOptions[] {
|
|
557
|
+
let content: string
|
|
558
|
+
try {
|
|
559
|
+
content = readFileSync(file, 'utf8')
|
|
560
|
+
} catch (error) {
|
|
561
|
+
throw new Error(`${binName}: failed to read overlay ${file}: ${String(error)}`)
|
|
562
|
+
}
|
|
563
|
+
return parsePatchList(binName, file, content, 'overlay')
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/** Convert inserted filesystem paths to file URLs, anchoring relative paths beside the patch; keep assertion names literal. */
|
|
567
|
+
function anchorInsertedPluginNames(patches: PatchOptions[], file: string): PatchOptions[] {
|
|
568
|
+
const base = dirname(resolve(file))
|
|
569
|
+
const visit = (entry: EntryOptions): void => {
|
|
570
|
+
if (typeof entry.name === 'string' && (isAbsolute(entry.name) || entry.name.startsWith('./') || entry.name.startsWith('../'))) {
|
|
571
|
+
entry.name = pathToFileURL(resolve(base, entry.name)).href
|
|
572
|
+
}
|
|
573
|
+
if (entry.group && Array.isArray(entry.config)) entry.config.forEach(visit)
|
|
574
|
+
}
|
|
575
|
+
for (const patch of patches) patch.insert?.forEach(visit)
|
|
576
|
+
return patches
|
|
577
|
+
}
|
|
578
|
+
/**
|
|
579
|
+
* Parse one loader patch list: a top-level YAML array of
|
|
580
|
+
* `@deepseek-ai/cordis-plugin-include` `PatchOptions` (id-targeted config overrides and
|
|
581
|
+
* `insert` lists, `!!js` expressions allowed). Every invalid field or value throws,
|
|
582
|
+
* because a patch file that cannot be applied at all is a misconfiguration; a
|
|
583
|
+
* single patch whose target row is absent stays a per-entry Loader warning, so
|
|
584
|
+
* one overlay shared across surfaces does not have to match every tree.
|
|
585
|
+
* @param binName - the diagnostic prefix on the thrown error.
|
|
586
|
+
* @param file - the source path, quoted in errors.
|
|
587
|
+
* @param content - the file's text.
|
|
588
|
+
* @param label - what to call this list in errors (`patches`, `overlay`).
|
|
589
|
+
* @returns the parsed patch list.
|
|
590
|
+
*/
|
|
591
|
+
function parsePatchList(
|
|
592
|
+
binName: string, file: string, content: string, label: string,
|
|
593
|
+
): PatchOptions[] {
|
|
594
|
+
let parsed: unknown
|
|
595
|
+
try {
|
|
596
|
+
parsed = yaml.load(content, { schema: userPatchesSchema })
|
|
597
|
+
} catch (error) {
|
|
598
|
+
throw new Error(`${binName}: failed to parse ${label} ${file}: ${String(error)}`)
|
|
599
|
+
}
|
|
600
|
+
if (!Array.isArray(parsed)) {
|
|
601
|
+
throw new Error(`${binName}: ${label} ${file} must be a top-level YAML array of loader patch entries`)
|
|
602
|
+
}
|
|
603
|
+
parsed.forEach((entry, index) => {
|
|
604
|
+
if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) {
|
|
605
|
+
throw new Error(`${binName}: ${label} entry ${index + 1} in ${file} must be a mapping (a loader patch entry)`)
|
|
606
|
+
}
|
|
607
|
+
})
|
|
608
|
+
return anchorInsertedPluginNames(parsed as PatchOptions[], file)
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
/** One overlay patch list with the source label printed in dump comments. */
|
|
612
|
+
export interface ConfigDumpLayer {
|
|
613
|
+
/** Source name shown in dump comments (a file basename or path). */
|
|
614
|
+
label: string
|
|
615
|
+
/** The layer's patches, from {@link loadOverlayPatches} / {@link loadOptionalPatches}. */
|
|
616
|
+
patches: PatchOptions[]
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/**
|
|
620
|
+
* Compose the configured entry list: parse the base config file with the
|
|
621
|
+
* include's entry-list dialect, apply every layer's patches as ONE flattened
|
|
622
|
+
* list through the include's own patch algorithm (`applyEntryPatches`) — the
|
|
623
|
+
* same call `boot()` makes, so even patch-visibility corner cases (a later
|
|
624
|
+
* layer targeting a group child a plain config replacement introduced, which
|
|
625
|
+
* the single-pass id index never sees) compose identically — then render the
|
|
626
|
+
* result as YAML in the same dialect (`!!js` expressions print verbatim,
|
|
627
|
+
* unevaluated). Row admission is a later stage: a plugin row the compatibility
|
|
628
|
+
* policy denies still appears here, while a denied bundle contributes no layer.
|
|
629
|
+
*
|
|
630
|
+
* Every run of rows from the same file and patch layers is preceded by a `# ==` comment
|
|
631
|
+
* naming the file that contributed the rows and any layers that patched them,
|
|
632
|
+
* so the output stays a loadable YAML document while showing which section
|
|
633
|
+
* comes from which file. The file and patch labels are derived from single-call prefix
|
|
634
|
+
* snapshots (base + layers 1..k), diffed positionally: the patch algorithm
|
|
635
|
+
* only rewrites rows in place or appends, so a top-level index identifies one
|
|
636
|
+
* row across snapshots, and a layer whose addition changes the row (config
|
|
637
|
+
* replacement, disable, group insert) is listed as having patched it.
|
|
638
|
+
*
|
|
639
|
+
* A patch that matches no row is reported through `warn` with its layer
|
|
640
|
+
* label, mirroring the Loader's boot-time warning. Earlier layers' patches
|
|
641
|
+
* see an identical preceding state in every snapshot that includes them, so
|
|
642
|
+
* each snapshot's warning list extends the previous one and the new tail
|
|
643
|
+
* belongs to the added layer.
|
|
644
|
+
* @param binName - the diagnostic prefix on read/parse errors.
|
|
645
|
+
* @param absoluteConfigPath - the base config file `boot()` would include.
|
|
646
|
+
* @param layers - overlay layers in application order (later wins).
|
|
647
|
+
* @param warn - sink for skipped-patch diagnostics; defaults to stderr.
|
|
648
|
+
* @returns the composed entry list rendered as a YAML document with
|
|
649
|
+
* source comment separators.
|
|
650
|
+
*/
|
|
651
|
+
export function renderConfigDump(
|
|
652
|
+
binName: string,
|
|
653
|
+
absoluteConfigPath: string,
|
|
654
|
+
layers: ConfigDumpLayer[],
|
|
655
|
+
warn: (line: string) => void = line => void process.stderr.write(`${line}\n`),
|
|
656
|
+
): string {
|
|
657
|
+
let content: string
|
|
658
|
+
try {
|
|
659
|
+
content = readFileSync(absoluteConfigPath, 'utf8')
|
|
660
|
+
} catch (error) {
|
|
661
|
+
throw new Error(`${binName}: failed to read config ${absoluteConfigPath}: ${String(error)}`)
|
|
662
|
+
}
|
|
663
|
+
let parsed: unknown
|
|
664
|
+
try {
|
|
665
|
+
parsed = yaml.load(content, { schema: entryListSchema })
|
|
666
|
+
} catch (error) {
|
|
667
|
+
throw new Error(`${binName}: failed to parse config ${absoluteConfigPath}: ${String(error)}`)
|
|
668
|
+
}
|
|
669
|
+
if (!Array.isArray(parsed)) {
|
|
670
|
+
throw new Error(`${binName}: config ${absoluteConfigPath} must be a top-level YAML array of entries`)
|
|
671
|
+
}
|
|
672
|
+
const baseLabel = basename(absoluteConfigPath)
|
|
673
|
+
// YAML parsing yields untyped rows; the include validates each entry
|
|
674
|
+
// at mount, and the dump prints whatever the file holds, so `EntryOptions`
|
|
675
|
+
// here is structural trust in the same file `boot()` would include.
|
|
676
|
+
const base = parsed as Parameters<typeof applyEntryPatches>[0]
|
|
677
|
+
// snapshot_k = ONE application of layers 1..k flattened, using the exact
|
|
678
|
+
// arguments boot passes for that prefix. snapshot_N is the mounted composition.
|
|
679
|
+
// The patches are cloned per call: applyEntryPatches clones entries and
|
|
680
|
+
// inserted rows but assigns override values such as `config` by reference,
|
|
681
|
+
// so each snapshot keeps its own copies of those values.
|
|
682
|
+
const snapshot = (count: number, warnings: string[]): ReturnType<typeof applyEntryPatches> => {
|
|
683
|
+
const flattened = structuredClone(layers.slice(0, count).flatMap(layer => layer.patches))
|
|
684
|
+
return applyEntryPatches(base, flattened, (message: string, ...args: unknown[]) => {
|
|
685
|
+
// The include logs through cordis's printf-style logger (`%C` = code); a
|
|
686
|
+
// dump has no logger, so substitute inline for a plain line.
|
|
687
|
+
let index = 0
|
|
688
|
+
warnings.push(message.replace(/%C/g, () => JSON.stringify(args[index++])))
|
|
689
|
+
})
|
|
690
|
+
}
|
|
691
|
+
let previous = base
|
|
692
|
+
let previousWarnings: string[] = []
|
|
693
|
+
const entryOrigins: { origin: string; patchedBy: string[] }[] = base.map(() => ({ origin: baseLabel, patchedBy: [] }))
|
|
694
|
+
let composed = base
|
|
695
|
+
for (let count = 1; count <= layers.length; count += 1) {
|
|
696
|
+
const layer = layers[count - 1]
|
|
697
|
+
/* v8 ignore next -- count iterates 1..length, so the slot exists */
|
|
698
|
+
if (layer === undefined) continue
|
|
699
|
+
const warnings: string[] = []
|
|
700
|
+
composed = snapshot(count, warnings)
|
|
701
|
+
for (const line of warnings.slice(previousWarnings.length)) {
|
|
702
|
+
warn(`${binName}: [${layer.label}] ${line}`)
|
|
703
|
+
}
|
|
704
|
+
const before = previous.map(entry => JSON.stringify(entry))
|
|
705
|
+
for (let index = 0; index < composed.length; index += 1) {
|
|
706
|
+
if (index >= before.length) entryOrigins.push({ origin: layer.label, patchedBy: [] })
|
|
707
|
+
else if (JSON.stringify(composed[index]) !== before[index]) entryOrigins[index]?.patchedBy.push(layer.label)
|
|
708
|
+
}
|
|
709
|
+
previous = composed
|
|
710
|
+
previousWarnings = warnings
|
|
711
|
+
}
|
|
712
|
+
return groupedDump(composed, entryOrigins)
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
/** Render the composed rows grouped under one source-and-patches comment per contiguous run. */
|
|
716
|
+
function groupedDump(
|
|
717
|
+
composed: readonly unknown[],
|
|
718
|
+
entryOrigins: readonly { origin: string; patchedBy: string[] }[],
|
|
719
|
+
): string {
|
|
720
|
+
const lines: string[] = []
|
|
721
|
+
let currentLabel: string | undefined
|
|
722
|
+
let group: unknown[] = []
|
|
723
|
+
const flush = (): void => {
|
|
724
|
+
if (currentLabel === undefined || group.length === 0) return
|
|
725
|
+
lines.push(`# == ${currentLabel}`)
|
|
726
|
+
lines.push(yaml.dump(group, { schema: entryListSchema, noRefs: true }).trimEnd())
|
|
727
|
+
group = []
|
|
728
|
+
}
|
|
729
|
+
for (let index = 0; index < composed.length; index += 1) {
|
|
730
|
+
const record = entryOrigins[index]
|
|
731
|
+
/* v8 ignore next -- this array is index-aligned with composed by construction */
|
|
732
|
+
if (record === undefined) continue
|
|
733
|
+
const label = record.patchedBy.length === 0
|
|
734
|
+
? record.origin
|
|
735
|
+
: `${record.origin}, patched by ${record.patchedBy.join(', ')}`
|
|
736
|
+
if (label !== currentLabel) {
|
|
737
|
+
flush()
|
|
738
|
+
currentLabel = label
|
|
739
|
+
}
|
|
740
|
+
group.push(composed[index])
|
|
741
|
+
}
|
|
742
|
+
flush()
|
|
743
|
+
return lines.join('\n') + '\n'
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
/**
|
|
747
|
+
* Mount and remember the exact root Include entry used by app boot and user patch-layer HMR.
|
|
748
|
+
* @param ctx - context carrying an initialized Loader service.
|
|
749
|
+
* @param absoluteConfigPath - absolute YAML or JSON configuration path.
|
|
750
|
+
* @param patches - initial app and user patches, applied in order.
|
|
751
|
+
* @param bareModuleBaseUrl - optional installed-host base for bare package
|
|
752
|
+
* names; relative names continue to resolve beside the configuration file.
|
|
753
|
+
* @param binName - diagnostic prefix for a profile plugin denied by compatibility policy; defaults to `dsh`.
|
|
754
|
+
* @returns the created root Include entry, or `undefined` when a surface
|
|
755
|
+
* disposed the whole tree (taking the Loader service with it) while the
|
|
756
|
+
* entry creation was in flight.
|
|
757
|
+
*/
|
|
758
|
+
export async function mountRootInclude(
|
|
759
|
+
ctx: Context,
|
|
760
|
+
absoluteConfigPath: string,
|
|
761
|
+
patches: readonly PatchOptions[] = [],
|
|
762
|
+
bareModuleBaseUrl?: string,
|
|
763
|
+
binName = 'dsh',
|
|
764
|
+
): Promise<Entry | undefined> {
|
|
765
|
+
ctx.loader.builtins.include = bareModuleBaseUrl === undefined
|
|
766
|
+
? Include
|
|
767
|
+
: class HostResolvedRootInclude extends Include {
|
|
768
|
+
override import(name: string, getOuterStack?: () => string[]): unknown {
|
|
769
|
+
const specifier = isAbsolute(name) ? pathToFileURL(name).href : name
|
|
770
|
+
if (name.startsWith('.') || name.startsWith('cordis:')) return super.import(specifier, getOuterStack)
|
|
771
|
+
const internal = this.ctx.loader.internal
|
|
772
|
+
/* v8 ignore next -- Node supplies the internal loader; this preserves the
|
|
773
|
+
original diagnostic for hypothetical embedders without it. */
|
|
774
|
+
if (internal === undefined) return super.import(specifier, getOuterStack)
|
|
775
|
+
return internal.import(specifier, bareModuleBaseUrl, {})
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
// `cordis:group` alongside it: a group row is how a composition gives one
|
|
779
|
+
// `isolate` realm to a provider and its consumers together, and an agent
|
|
780
|
+
// preset living outside this workspace cannot resolve `@deepseek-ai/cordis-plugin-group`
|
|
781
|
+
// by name. Both builtins load through the ambient module pipeline, so neither
|
|
782
|
+
// depends on the included tree's own specifier resolution.
|
|
783
|
+
ctx.loader.builtins.group = Group
|
|
784
|
+
// Pinned id: the bootstrap include is app glue, not a config row, and its
|
|
785
|
+
// id appears in Loader failure chains — a random id would make startup
|
|
786
|
+
// diagnostics unstable across runs (and snapshot fixtures).
|
|
787
|
+
// The launcher's own copy is prepared here: compatibility decisions must be made before the root
|
|
788
|
+
// Include imports anything, and they change no profile patch layer, manifest, or bundle list.
|
|
789
|
+
const prepared = prepareProfilePatches(ctx, [...patches], pathToFileURL(dirname(absoluteConfigPath)).href + '/', binName)
|
|
790
|
+
const includeConfig: Include.Config = {
|
|
791
|
+
path: pathToFileURL(absoluteConfigPath).href,
|
|
792
|
+
...prepared.length > 0 ? { patches: prepared } : {},
|
|
793
|
+
}
|
|
794
|
+
const rootInclude: EntryOptions = {
|
|
795
|
+
id: 'include',
|
|
796
|
+
name: 'cordis:include',
|
|
797
|
+
config: includeConfig,
|
|
798
|
+
}
|
|
799
|
+
const includeId = await ctx.loader.create(rootInclude)
|
|
800
|
+
const loader = ctx.get('loader')
|
|
801
|
+
if (loader === undefined) return undefined
|
|
802
|
+
const entry = loader.resolve(includeId)
|
|
803
|
+
bootstrapIncludes.set(ctx, entry)
|
|
804
|
+
return entry
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
/** The two process events {@link installFailLoud} turns into a fatal exit. */
|
|
808
|
+
export type FailLoudEvent = 'unhandledRejection' | 'uncaughtException'
|
|
809
|
+
|
|
810
|
+
/**
|
|
811
|
+
* The slice of `process` {@link installFailLoud} needs — injectable so tests
|
|
812
|
+
* exercise the handler without registering on (or exiting) the real process.
|
|
813
|
+
*/
|
|
814
|
+
export interface FailLoudProcess {
|
|
815
|
+
on(event: FailLoudEvent, handler: (err: unknown) => void): unknown
|
|
816
|
+
off(event: FailLoudEvent, handler: (err: unknown) => void): unknown
|
|
817
|
+
stderr: { write(chunk: string): unknown }
|
|
818
|
+
/**
|
|
819
|
+
* Terminate the process. Callers treat this as the end of the run, as
|
|
820
|
+
* `process.exit` is; a fake that returns lets the caller continue, which only
|
|
821
|
+
* a test observes.
|
|
822
|
+
*/
|
|
823
|
+
exit(code: number): void
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
// An activation rejection already folded into the boot diagnostic can also
|
|
827
|
+
// reach the process rejection handler. Keep exact reasons visible through the
|
|
828
|
+
// next process rejection checkpoint so the process guard can coalesce them.
|
|
829
|
+
const assembledActivationRejections = new Map<unknown, number>()
|
|
830
|
+
|
|
831
|
+
function retainAssembledRejection(reason: unknown): void {
|
|
832
|
+
assembledActivationRejections.set(reason, (assembledActivationRejections.get(reason) ?? 0) + 1)
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
function releaseAssembledRejection(reason: unknown): void {
|
|
836
|
+
const count = assembledActivationRejections.get(reason)
|
|
837
|
+
if (count === undefined || count === 1) {
|
|
838
|
+
assembledActivationRejections.delete(reason)
|
|
839
|
+
} else {
|
|
840
|
+
assembledActivationRejections.set(reason, count - 1)
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
async function observeLoaderRejectionCheckpoint(reasons: readonly unknown[]): Promise<void> {
|
|
845
|
+
for (const reason of reasons) retainAssembledRejection(reason)
|
|
846
|
+
try {
|
|
847
|
+
await new Promise<void>(resolve => setImmediate(resolve))
|
|
848
|
+
} finally {
|
|
849
|
+
for (const reason of reasons) releaseAssembledRejection(reason)
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
/**
|
|
854
|
+
* How long {@link installFailLoud} waits for its `release` hook before exiting
|
|
855
|
+
* anyway. A wedged disposer must delay the fatal exit, never cancel it.
|
|
856
|
+
*/
|
|
857
|
+
export const FAIL_LOUD_RELEASE_TIMEOUT_MS = 2_000
|
|
858
|
+
|
|
859
|
+
/**
|
|
860
|
+
* Install before boot to turn an unhandled rejection or an uncaught exception,
|
|
861
|
+
* at any point in the process lifetime, into one labelled stderr diagnostic and
|
|
862
|
+
* `exit(1)`. A rejection already included by {@link auditStartupEntries} is
|
|
863
|
+
* ignored during its process checkpoint; every other rejection and every
|
|
864
|
+
* uncaught exception remains fatal. Control never returns to the failed
|
|
865
|
+
* operation after either: only the throw site knows which state is intact, and
|
|
866
|
+
* a listener that threw mid-update (a stream `'data'` handler, a half-applied
|
|
867
|
+
* registry write) leaves silently wrong results behind if it were resumed. The
|
|
868
|
+
* event loop keeps running only until the release hook settles or times out.
|
|
869
|
+
* Stdout remains untouched for ACP; the returned function removes both handlers.
|
|
870
|
+
*
|
|
871
|
+
* The diagnostic is `util.inspect(err)`, not `err.stack`: a `node:fs` error's
|
|
872
|
+
* `code`, `syscall`, and `path` and any `cause` chain are enumerable properties
|
|
873
|
+
* that the stack line omits, and they are what a crash report needs. Once a
|
|
874
|
+
* handler is installed Node prints nothing of its own, so this line is the
|
|
875
|
+
* only record of the failure.
|
|
876
|
+
*
|
|
877
|
+
* The Loader mounts entries concurrently, so a surface that owns the terminal
|
|
878
|
+
* can already hold it when a sibling entry rejects. Exiting straight from the
|
|
879
|
+
* handler would strand raw mode, bracketed paste, and the keyboard protocol on
|
|
880
|
+
* the user's shell, and leave an in-flight terminal query's reply to land as
|
|
881
|
+
* literal text at the next prompt. `release` is the terminal owner's chance to
|
|
882
|
+
* hand it back; it is awaited under {@link FAIL_LOUD_RELEASE_TIMEOUT_MS}, whose
|
|
883
|
+
* timer stays referenced so a never-settling disposer cannot let Node reach an
|
|
884
|
+
* empty event loop and exit 0 instead of failing.
|
|
885
|
+
*
|
|
886
|
+
* The diagnostic is written before the release so a hanging or failing disposer
|
|
887
|
+
* cannot swallow the reason. The handler stays installed while the release runs
|
|
888
|
+
* — removing it would let a second concurrent rejection become uncaught and kill
|
|
889
|
+
* the process mid-teardown, stranding exactly the terminal state this restores —
|
|
890
|
+
* so a latch keeps the first rejection the reported one and lets later
|
|
891
|
+
* rejections (including the release's own) fall through to the pending exit.
|
|
892
|
+
* @param binName - the diagnostic prefix on the fatal-failure line.
|
|
893
|
+
* @param proc - the process slice to register on; tests inject a fake.
|
|
894
|
+
* @param release - optional teardown awaited before exit, used by a
|
|
895
|
+
* terminal-owning surface to restore the terminal. Its own failure is
|
|
896
|
+
* swallowed because the pending fatal exit already owns the outcome.
|
|
897
|
+
* @returns the uninstaller that removes both handlers.
|
|
898
|
+
*/
|
|
899
|
+
export function installFailLoud(
|
|
900
|
+
binName: string,
|
|
901
|
+
proc: FailLoudProcess = process,
|
|
902
|
+
release?: () => Promise<void> | void,
|
|
903
|
+
): () => void {
|
|
904
|
+
let exiting = false
|
|
905
|
+
const report = (err: unknown, label: string): void => {
|
|
906
|
+
// A release in flight already owns the exit. Swallow later failures
|
|
907
|
+
// (teardown's own included) rather than reporting a second failure over the
|
|
908
|
+
// real one or letting Node kill the process before the terminal is back.
|
|
909
|
+
if (exiting) return
|
|
910
|
+
exiting = true
|
|
911
|
+
proc.stderr.write(`${binName}: ${label}: ${inspect(err, { depth: 4, maxArrayLength: 50 })}\n`)
|
|
912
|
+
if (release === undefined) {
|
|
913
|
+
proc.exit(1)
|
|
914
|
+
return
|
|
915
|
+
}
|
|
916
|
+
void (async () => {
|
|
917
|
+
// Definitely assigned: the timeout promise's executor runs synchronously
|
|
918
|
+
// while the race is being constructed, before the first await.
|
|
919
|
+
let timer!: ReturnType<typeof setTimeout>
|
|
920
|
+
try {
|
|
921
|
+
await Promise.race([
|
|
922
|
+
(async () => release())(),
|
|
923
|
+
new Promise<void>((resolve) => {
|
|
924
|
+
timer = setTimeout(resolve, FAIL_LOUD_RELEASE_TIMEOUT_MS)
|
|
925
|
+
}),
|
|
926
|
+
])
|
|
927
|
+
} catch {
|
|
928
|
+
// The terminal release failed; the fatal exit below is the outcome that
|
|
929
|
+
// matters, and no reporter runs after it.
|
|
930
|
+
}
|
|
931
|
+
clearTimeout(timer)
|
|
932
|
+
proc.exit(1)
|
|
933
|
+
})()
|
|
934
|
+
}
|
|
935
|
+
const onRejection = (err: unknown): void => {
|
|
936
|
+
if (assembledActivationRejections.has(err)) return
|
|
937
|
+
// Label kept stable: the Web profile expected-output e2e tests match it.
|
|
938
|
+
report(err, 'fatal load failure')
|
|
939
|
+
}
|
|
940
|
+
const onException = (err: unknown): void => { report(err, 'fatal uncaught exception') }
|
|
941
|
+
const uninstall = (): void => {
|
|
942
|
+
proc.off('unhandledRejection', onRejection)
|
|
943
|
+
proc.off('uncaughtException', onException)
|
|
944
|
+
}
|
|
945
|
+
proc.on('unhandledRejection', onRejection)
|
|
946
|
+
proc.on('uncaughtException', onException)
|
|
947
|
+
return uninstall
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
/**
|
|
951
|
+
* Value mirrors used because Cordis's const enum has no runtime object to import.
|
|
952
|
+
* Keep aligned with `packages/client/web/src/loader-status.ts`.
|
|
953
|
+
*/
|
|
954
|
+
const FIBER_PENDING = 0 as FiberState.PENDING
|
|
955
|
+
const FIBER_ACTIVE = 2 as FiberState.ACTIVE
|
|
956
|
+
const FIBER_FAILED = 3 as FiberState.FAILED
|
|
957
|
+
const FIBER_DISPOSED = 4 as FiberState.DISPOSED
|
|
958
|
+
|
|
959
|
+
/**
|
|
960
|
+
* Entry ids whose presence defines a usable DSH application.
|
|
961
|
+
*
|
|
962
|
+
* The list is global rather than profile metadata. Missing or disabled ids do
|
|
963
|
+
* not affect startup; an enabled listed entry must activate. The list covers
|
|
964
|
+
* shared Agent execution, application endpoints, and Web bootstrap/transport.
|
|
965
|
+
*/
|
|
966
|
+
const requiredStartupEntryIds = new Set<string>([
|
|
967
|
+
'agent-loop',
|
|
968
|
+
'tool-executor-native',
|
|
969
|
+
'tool-executor-ptc',
|
|
970
|
+
'webserver',
|
|
971
|
+
'web-runtime',
|
|
972
|
+
'modules',
|
|
973
|
+
'connection',
|
|
974
|
+
'headless-runner',
|
|
975
|
+
'acp',
|
|
976
|
+
'sdk-jsonrpc-server',
|
|
977
|
+
])
|
|
978
|
+
|
|
979
|
+
/** Render plugin stacks, nested causes, and aggregate member failures once per error. */
|
|
980
|
+
function formatActivationError(error: unknown, stacks = true): string {
|
|
981
|
+
const details: string[] = []
|
|
982
|
+
const seen = new Set<Error>()
|
|
983
|
+
function visit(value: unknown): void {
|
|
984
|
+
if (!(value instanceof Error)) {
|
|
985
|
+
details.push(String(value))
|
|
986
|
+
return
|
|
987
|
+
}
|
|
988
|
+
if (seen.has(value)) return
|
|
989
|
+
seen.add(value)
|
|
990
|
+
details.push(stacks ? value.stack ?? value.message : String(value))
|
|
991
|
+
if (value.cause !== undefined) visit(value.cause)
|
|
992
|
+
if (value instanceof AggregateError) value.errors.forEach(visit)
|
|
993
|
+
}
|
|
994
|
+
visit(error)
|
|
995
|
+
return details.join('\n')
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
interface InactiveEntry {
|
|
999
|
+
/** Loader entry used to identify the bootstrap Include and required ids. */
|
|
1000
|
+
entry: Entry
|
|
1001
|
+
/** Activation errors and missing services remain distinct for presentation. */
|
|
1002
|
+
outcome: { kind: 'failed'; error: unknown; phase?: string }
|
|
1003
|
+
| { kind: 'pending'; missing: string[] }
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
/** Inactive plugin metadata without retaining its Context or Fiber. */
|
|
1007
|
+
interface StartupEntryDiagnostic {
|
|
1008
|
+
id: string
|
|
1009
|
+
module: string
|
|
1010
|
+
required: boolean
|
|
1011
|
+
fiberState: FiberState | undefined
|
|
1012
|
+
outcome: InactiveEntry['outcome']
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
/** Startup warning or error arguments, including import errors with no Fiber. */
|
|
1016
|
+
interface StartupLogRecord {
|
|
1017
|
+
ts: number
|
|
1018
|
+
name: string
|
|
1019
|
+
type: string
|
|
1020
|
+
args: readonly unknown[]
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
/** Startup audit failure with non-enumerable metadata and original failures as its cause. */
|
|
1024
|
+
export class StartupError extends Error {
|
|
1025
|
+
/** Plugin states captured before disposal, excluding configuration values. */
|
|
1026
|
+
composition?: StartupCompositionDiagnostic
|
|
1027
|
+
/** Root configuration and startup logs, attached by boot after disposal. */
|
|
1028
|
+
startup?: { configurationPath: string; messages: readonly StartupLogRecord[] }
|
|
1029
|
+
|
|
1030
|
+
/**
|
|
1031
|
+
* @param message - concise terminal diagnostic.
|
|
1032
|
+
* @param entries - inactive plugin metadata and original failure values.
|
|
1033
|
+
*/
|
|
1034
|
+
constructor(message: string, readonly entries: readonly StartupEntryDiagnostic[]) {
|
|
1035
|
+
const failures = entries.flatMap(({ outcome }) => outcome.kind === 'failed' ? [outcome.error] : [])
|
|
1036
|
+
super(message, failures.length > 0 ? { cause: new AggregateError(failures, 'Plugin activation failures') } : undefined)
|
|
1037
|
+
Object.defineProperties(this, {
|
|
1038
|
+
entries: { enumerable: false },
|
|
1039
|
+
startup: { enumerable: false },
|
|
1040
|
+
composition: { enumerable: false },
|
|
1041
|
+
})
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
/** The error importing an entry's module raises now, which Node returns from its cache for a module that failed before. */
|
|
1046
|
+
async function importFailure(entry: Entry): Promise<unknown> {
|
|
1047
|
+
try {
|
|
1048
|
+
await entry.parent.tree.import(entry.options.name)
|
|
1049
|
+
return undefined
|
|
1050
|
+
} catch (error) {
|
|
1051
|
+
return error
|
|
1052
|
+
}
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
/**
|
|
1056
|
+
* Collect Loader activation failures and disabled-expression errors. Failed
|
|
1057
|
+
* fibers are awaited to recover their recorded rejection reason and coalesce
|
|
1058
|
+
* duplicate Loader notifications through the next process rejection checkpoint.
|
|
1059
|
+
* An entry whose import failed reports the error importing it raises now; without
|
|
1060
|
+
* `importNow` nothing is imported and the entry reports a failed import without the import error.
|
|
1061
|
+
*/
|
|
1062
|
+
async function inactiveEntries(ctx: Context, importNow = true): Promise<InactiveEntry[]> {
|
|
1063
|
+
const failures: InactiveEntry[] = []
|
|
1064
|
+
const rejectionReasons: unknown[] = []
|
|
1065
|
+
for (const entry of ctx.loader.entries()) {
|
|
1066
|
+
try {
|
|
1067
|
+
if (entry.disabled) continue
|
|
1068
|
+
} catch (error) {
|
|
1069
|
+
failures.push({ entry, outcome: { kind: 'failed', error, phase: 'disabled expression failed' } })
|
|
1070
|
+
continue
|
|
1071
|
+
}
|
|
1072
|
+
const fiber = entry.fiber
|
|
1073
|
+
// An entry whose import failed has no fiber, or keeps the disposed fiber of its previous activation.
|
|
1074
|
+
if (fiber === undefined || fiber.state === FIBER_DISPOSED) {
|
|
1075
|
+
const error = importNow ? await importFailure(entry) : undefined
|
|
1076
|
+
failures.push({ entry, outcome: error === undefined
|
|
1077
|
+
? { kind: 'failed', error: fiber === undefined ? 'failed to import' : `fiber state ${String(fiber.state)}` }
|
|
1078
|
+
: { kind: 'failed', error, phase: 'failed to import' } })
|
|
1079
|
+
continue
|
|
1080
|
+
}
|
|
1081
|
+
const state = fiber.state
|
|
1082
|
+
if (state === FIBER_ACTIVE) continue
|
|
1083
|
+
if (state === FIBER_FAILED) {
|
|
1084
|
+
try {
|
|
1085
|
+
await fiber.await()
|
|
1086
|
+
} catch (error) {
|
|
1087
|
+
rejectionReasons.push(error)
|
|
1088
|
+
failures.push({ entry, outcome: { kind: 'failed', error } })
|
|
1089
|
+
}
|
|
1090
|
+
continue
|
|
1091
|
+
}
|
|
1092
|
+
if (state === FIBER_PENDING) {
|
|
1093
|
+
const missing = Object.keys(fiber.inject).filter(service => fiber.ctx.get(service) === undefined)
|
|
1094
|
+
failures.push({
|
|
1095
|
+
entry,
|
|
1096
|
+
outcome: { kind: 'pending', missing },
|
|
1097
|
+
})
|
|
1098
|
+
} else {
|
|
1099
|
+
failures.push({ entry, outcome: { kind: 'failed', error: `fiber state ${String(state)}` } })
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
if (rejectionReasons.length > 0) await observeLoaderRejectionCheckpoint(rejectionReasons)
|
|
1103
|
+
return failures
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
/** Render one failed plugin's original error and activation phase. */
|
|
1107
|
+
function failureDetail(outcome: Extract<InactiveEntry['outcome'], { kind: 'failed' }>, stacks = true): string {
|
|
1108
|
+
return `${outcome.phase === undefined ? '' : `${outcome.phase}: `}${formatActivationError(outcome.error, stacks)}`
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
/** Render optional-only warnings without changing startup policy. */
|
|
1112
|
+
function activationDiagnostic(
|
|
1113
|
+
binName: string,
|
|
1114
|
+
failures: readonly InactiveEntry[],
|
|
1115
|
+
stacks = true,
|
|
1116
|
+
): string {
|
|
1117
|
+
const noun = failures.length === 1 ? 'entry' : 'entries'
|
|
1118
|
+
return `${binName}: warning: ${String(failures.length)} ${noun} did not activate\n${failures.map(failure => inactiveDiagnostic(failure, stacks)).join('\n')}\n`
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
/** Per-entry reload text, which people read through the Web UI and agents, so it carries error messages without stack frames. */
|
|
1122
|
+
function reloadDiagnostic(failure: InactiveEntry): string {
|
|
1123
|
+
return inactiveDiagnostic(failure, false)
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
/** Stable per-entry text for reload comparisons and optional warnings. */
|
|
1127
|
+
function inactiveDiagnostic({ entry, outcome }: InactiveEntry, stacks = true): string {
|
|
1128
|
+
const detail = outcome.kind === 'failed' ? failureDetail(outcome, stacks)
|
|
1129
|
+
: `pending (waiting for ${outcome.missing.length === 1 ? 'service' : 'services'}: ${outcome.missing.join(', ') || 'unknown'})`
|
|
1130
|
+
return `${entry.options.id} (${entry.options.name}): ${detail}`
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
/** Group startup failures and pending services, marking every required entry. */
|
|
1134
|
+
function startupDiagnostic(binName: string, failures: readonly InactiveEntry[], required: ReadonlySet<Entry>): string {
|
|
1135
|
+
const lines = [`${binName}: startup failed: ${String(required.size)} required ${required.size === 1 ? 'plugin' : 'plugins'} did not activate`]
|
|
1136
|
+
const failed = failures.flatMap(({ entry, outcome }) => outcome.kind === 'failed' ? [{ entry, outcome }] : [])
|
|
1137
|
+
const pending = failures.flatMap(({ entry, outcome }) => outcome.kind === 'pending' ? [{ entry, outcome }] : [])
|
|
1138
|
+
pending.sort((left, right) => Number(required.has(right.entry)) - Number(required.has(left.entry)))
|
|
1139
|
+
const label = (entry: Entry): string => `${entry.options.id}${required.has(entry) ? ' (required)' : ''}`
|
|
1140
|
+
if (failed.length > 0) {
|
|
1141
|
+
lines.push('', `Failed plugins (${String(failed.length)}):`)
|
|
1142
|
+
for (const { entry, outcome } of failed) {
|
|
1143
|
+
lines.push(` ${label(entry)}`, ` Package: ${entry.options.name}`)
|
|
1144
|
+
lines.push(...failureDetail(outcome).split('\n').map(line => ` ${line}`))
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
1147
|
+
if (pending.length > 0) {
|
|
1148
|
+
const width = Math.max('Plugin'.length, ...pending.map(({ entry }) => label(entry).length)) + 2
|
|
1149
|
+
lines.push('', `Plugins waiting for services (${String(pending.length)}):`, ` ${'Plugin'.padEnd(width)}Missing services`)
|
|
1150
|
+
for (const { entry, outcome } of pending) {
|
|
1151
|
+
lines.push(` ${label(entry).padEnd(width)}${outcome.missing.join(', ') || 'unknown'}`)
|
|
1152
|
+
}
|
|
1153
|
+
}
|
|
1154
|
+
return lines.join('\n')
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
/**
|
|
1158
|
+
* Apply DSH startup policy to a settled Loader tree.
|
|
1159
|
+
*
|
|
1160
|
+
* Inactive entries from the global required list reject startup. Other
|
|
1161
|
+
* inactive entries join that failure diagnostic, or produce one warning when
|
|
1162
|
+
* no required entry failed and leave successful siblings running.
|
|
1163
|
+
* Required ids absent from the tree, and disabled required entries, are ignored.
|
|
1164
|
+
* A throwing disabled expression is an entry failure, not a disabled entry.
|
|
1165
|
+
* The bootstrap Include must activate so unreadable or invalid root config is fatal.
|
|
1166
|
+
* @param ctx - the settled context whose Loader entries to audit.
|
|
1167
|
+
* @param binName - the prefix on startup diagnostics.
|
|
1168
|
+
* @param warn - sink for optional-entry warnings.
|
|
1169
|
+
* @returns after optional warnings if required startup checks pass.
|
|
1170
|
+
* @throws {@link StartupError} when the bootstrap Include or a required entry is inactive or its disabled expression throws;
|
|
1171
|
+
* its message includes optional failures too.
|
|
1172
|
+
*/
|
|
1173
|
+
export async function auditStartupEntries(
|
|
1174
|
+
ctx: Context,
|
|
1175
|
+
binName: string,
|
|
1176
|
+
warn: (line: string) => void = line => void process.stderr.write(line),
|
|
1177
|
+
): Promise<void> {
|
|
1178
|
+
const failures = await inactiveEntries(ctx)
|
|
1179
|
+
const required = new Set(failures.filter(({ entry }) => entry === bootstrapIncludes.get(ctx)
|
|
1180
|
+
|| requiredStartupEntryIds.has(entry.options.id)).map(({ entry }) => entry))
|
|
1181
|
+
if (required.size > 0) {
|
|
1182
|
+
throw new StartupError(startupDiagnostic(binName, failures, required), failures.map(({ entry, outcome }) => ({
|
|
1183
|
+
id: entry.options.id, module: entry.options.name, required: required.has(entry), fiberState: entry.fiber?.state, outcome,
|
|
1184
|
+
})))
|
|
1185
|
+
}
|
|
1186
|
+
if (failures.length > 0) warn(activationDiagnostic(binName, failures))
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
/**
|
|
1190
|
+
* Boot the Loader against `absoluteConfigPath` and return only after the whole
|
|
1191
|
+
* tree settles. Relative entry names resolve against the config directory;
|
|
1192
|
+
* bare package names resolve there by default or against an explicit
|
|
1193
|
+
* `bareModuleBaseUrl` for closed packaged runtimes. The bootstrap include
|
|
1194
|
+
* is statically imported and mounted as the `cordis:include` builtin, loading
|
|
1195
|
+
* through the ambient module pipeline (vite/tsx/plain ESM). The package build
|
|
1196
|
+
* embeds Include while leaving Loader external, so the built include tree and
|
|
1197
|
+
* host share one Loader peer. Loader settlement drains entry work without
|
|
1198
|
+
* rejecting the whole tree. The final {@link auditStartupEntries} call rejects
|
|
1199
|
+
* failures in the global required list and warns about other failed, missing,
|
|
1200
|
+
* and pending entries while successful siblings remain active. Later unhandled
|
|
1201
|
+
* rejections remain covered by {@link installFailLoud}. Built bins need the Loader's native
|
|
1202
|
+
* helper for bare plugin specifiers; relative specifiers do not.
|
|
1203
|
+
* @param binName - the diagnostic prefix for load-failure errors.
|
|
1204
|
+
* @param absoluteConfigPath - the config to include; must already be absolute
|
|
1205
|
+
* (see {@link resolveConfigPath}).
|
|
1206
|
+
* @param patches - optional overlay patches applied over the included tree
|
|
1207
|
+
* (see {@link loadOptionalPatches}); an empty list mounts none.
|
|
1208
|
+
* @param prepare - optional host setup run after Loader installation and before any config-tree entry mounts.
|
|
1209
|
+
* @param bareModuleBaseUrl - optional installed-host base for bare package
|
|
1210
|
+
* names; use it when the host, rather than the configuration project, owns the
|
|
1211
|
+
* complete plugin set.
|
|
1212
|
+
* @returns the root context after the initial startup audit, or as soon as a
|
|
1213
|
+
* surface disposed the tree while startup was still in flight.
|
|
1214
|
+
* @throws {@link StartupError} for an inactive required entry, including all inactive plugins in its message;
|
|
1215
|
+
* otherwise a labelled error after disposing the partial context — `host
|
|
1216
|
+
* preparation failed` when `prepare` threw before any config-tree entry
|
|
1217
|
+
* mounted, `plugin tree failed to load` afterwards. Cyclic causes terminate
|
|
1218
|
+
* diagnostic traversal without replacing the original cause.
|
|
1219
|
+
*/
|
|
1220
|
+
export async function boot(
|
|
1221
|
+
binName: string,
|
|
1222
|
+
absoluteConfigPath: string,
|
|
1223
|
+
patches?: PatchOptions[],
|
|
1224
|
+
prepare?: (ctx: Context) => Promise<void> | void,
|
|
1225
|
+
bareModuleBaseUrl?: string,
|
|
1226
|
+
): Promise<Context> {
|
|
1227
|
+
const ctx = new Context()
|
|
1228
|
+
const startupLogs: StartupLogRecord[] = []
|
|
1229
|
+
// The collector must outlive root disposal to retain asynchronous cleanup errors.
|
|
1230
|
+
const diagnostics = new Context()
|
|
1231
|
+
diagnostics.logger = ctx.logger
|
|
1232
|
+
diagnostics.logger.exporter({
|
|
1233
|
+
levels: { default: 2 },
|
|
1234
|
+
export: ({ ts, name, type, args }) => {
|
|
1235
|
+
if (type === 'warn' || type === 'error') startupLogs.push({ ts, name, type, args })
|
|
1236
|
+
},
|
|
1237
|
+
})
|
|
1238
|
+
// Two failure labels: `prepare` runs before any config-tree entry mounts,
|
|
1239
|
+
// so its failure is host setup, not the plugin tree.
|
|
1240
|
+
let stage = 'host preparation failed'
|
|
1241
|
+
try {
|
|
1242
|
+
ctx.baseUrl = pathToFileURL(dirname(absoluteConfigPath)).href + '/'
|
|
1243
|
+
ctx.provide('dshHomePath', dshHomePath)
|
|
1244
|
+
await ctx.plugin(Loader)
|
|
1245
|
+
await prepare?.(ctx)
|
|
1246
|
+
stage = 'plugin tree failed to load'
|
|
1247
|
+
await mountRootInclude(ctx, absoluteConfigPath, patches, bareModuleBaseUrl, binName)
|
|
1248
|
+
// A surface can finish and dispose the whole tree while startup is still
|
|
1249
|
+
// in flight, before the last entry settles. The Loader service goes with
|
|
1250
|
+
// it, and the activation audit describes a live tree — reading `ctx.loader`
|
|
1251
|
+
// past this point would throw a TypeError over an app that exited exactly
|
|
1252
|
+
// as asked. Re-check after settlement before auditing the tree.
|
|
1253
|
+
await ctx.get('loader')?.await()
|
|
1254
|
+
if (ctx.get('loader') === undefined) return ctx
|
|
1255
|
+
await auditStartupEntries(ctx, binName)
|
|
1256
|
+
return ctx
|
|
1257
|
+
} catch (cause) {
|
|
1258
|
+
if (cause instanceof StartupError) {
|
|
1259
|
+
try { cause.composition = captureStartupComposition(ctx) } catch (error) {
|
|
1260
|
+
ctx.logger.warn('Startup composition diagnostic unavailable', error)
|
|
1261
|
+
}
|
|
1262
|
+
}
|
|
1263
|
+
// Root-fiber disposal contains cleanup failures per observer (Cordis
|
|
1264
|
+
// fiber.ts hardening) and a repeated call returns the settled single-shot
|
|
1265
|
+
// result, so this await cannot reject and replace `cause`.
|
|
1266
|
+
await ctx.fiber.dispose()
|
|
1267
|
+
if (cause instanceof StartupError) {
|
|
1268
|
+
cause.startup = { configurationPath: absoluteConfigPath, messages: startupLogs }
|
|
1269
|
+
throw cause
|
|
1270
|
+
}
|
|
1271
|
+
const detail = cause instanceof Error ? cause.message : String(cause)
|
|
1272
|
+
// A wrapper can carry an activation error whose original stack names the failed plugin.
|
|
1273
|
+
let deepest: unknown = cause
|
|
1274
|
+
const seen = new Set<Error>()
|
|
1275
|
+
while (deepest instanceof Error && !seen.has(deepest) && deepest.cause !== undefined) {
|
|
1276
|
+
seen.add(deepest)
|
|
1277
|
+
deepest = deepest.cause
|
|
1278
|
+
}
|
|
1279
|
+
const stack = deepest instanceof AggregateError
|
|
1280
|
+
? `\n${deepest.stack ?? deepest.message}\n${deepest.errors.map(error => formatActivationError(error)).join('\n')}`
|
|
1281
|
+
: deepest instanceof Error && deepest !== cause ? `\n${deepest.stack ?? deepest.message}` : ''
|
|
1282
|
+
throw new Error(`${binName}: ${stage}: ${detail}${stack}`, { cause })
|
|
1283
|
+
} finally {
|
|
1284
|
+
await diagnostics.fiber.dispose()
|
|
1285
|
+
}
|
|
1286
|
+
}
|