@menghuan1918/dsh-apollo 0.4.1 → 0.5.1

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/src/migrate.js ADDED
@@ -0,0 +1,662 @@
1
+ /**
2
+ * dsh-apollo —— 旧配置迁移(src/migrate.js)。
3
+ *
4
+ * 0.5.0 把子代理增强与压缩能力移入两个独立插件;已经保存在旧 Apollo profile
5
+ * 里的设置不能悄悄丢掉。本模块在 Apollo 行装配后运行一次(可重复执行),把
6
+ * 旧值补进目标行的缺失字段:
7
+ *
8
+ * - `apollo-skills` 的 `tiers.fast/quality`、`subagents.{eng,global}`、
9
+ * `subagents.{codex,claude}.*`、`trustedHosts` → `dsh-better-subagent`
10
+ * 相同路径;`exitGuard` → `childExitGuard`。旧 `exitGuard` 仍留在 Apollo
11
+ * 作为 job/通用守卫开关,不被删除。
12
+ * - 旧 `eng` / `eng-ptc` 预设里原生 `compaction-basic` 的显式配置 →
13
+ * `dsh-better-compact` 同名原生键;目标缺失的 `mode` 采用默认 `goal`。
14
+ *
15
+ * 规则(与 S4 Spec 一致):
16
+ * - 目标行的**显式用户值优先**(`settings.describe().user` 的存在性,不是值),
17
+ * 来源只补目标缺失字段;
18
+ * - 显式 false / 0 / '' / [] 是值,不是缺失;来源只携带**显式配置**,不把
19
+ * 原生默认值搬成目标显式值(与目标生效默认相同就跳过);
20
+ * - volatile 字段用 `settings.update(ns, patch, revision)`;非 volatile 的
21
+ * `trustedHosts` 用 `configEditor.edit`(不绕过宿主字段限制);
22
+ * - 写入后重读验证;验证失败不宣告成功,也不删除任何旧值;
23
+ * - 目标命名空间尚未出现/不可写时(例如双 bundle 同时显式选中导致
24
+ * unaddressable)报告 pending,要求重载 profile;
25
+ * - 迁移本身不删旧配置、不复制 CLI KV、不复制 goal 状态;重复执行无副作用。
26
+ *
27
+ * 零第三方依赖。
28
+ */
29
+
30
+ import { isDeepStrictEqual } from 'node:util'
31
+ import { setTimeout as delay } from 'node:timers/promises'
32
+ import { ENTRY_ID, normalizeConfig } from './config.js'
33
+
34
+ /** Target namespace/row ids owned by the independent plugins. */
35
+ export const SUBAGENT_NS = 'dsh-better-subagent'
36
+ export const COMPACT_NS = 'dsh-better-compact'
37
+ /** Apollo's own row id. */
38
+ export const APOLLO_NS = ENTRY_ID
39
+ /** Apollo preset identity → Loader row id. */
40
+ export const PRESET_ROWS = Object.freeze({ eng: 'preset-eng', 'eng-ptc': 'preset-eng-ptc' })
41
+ /** Bounded wait for the target namespace descriptors (ms). */
42
+ export const WAIT_TIMEOUT_MS = 5_000
43
+ /** Poll interval while waiting for descriptors (ms). */
44
+ export const WAIT_INTERVAL_MS = 50
45
+ /** Native compaction keys carried from the old Apollo preset row. */
46
+ export const COMPACT_KEYS = Object.freeze([
47
+ 'auto',
48
+ 'thresholdRatio',
49
+ 'headroomTokens',
50
+ 'summarizationProvider',
51
+ 'summarizationModel',
52
+ 'maxTokens',
53
+ 'compactionRetries',
54
+ 'maxOverflowRetries',
55
+ 'modelPolicies',
56
+ ])
57
+
58
+ /** Plain data object. */
59
+ function isRecord(value) {
60
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
61
+ }
62
+
63
+ /** Own property check (explicit presence, regardless of value). */
64
+ function hasOwn(value, key) {
65
+ return isRecord(value) && Object.hasOwn(value, key)
66
+ }
67
+
68
+ /** Clone plain JSON-shaped data (configEditor/settings already detach their I/O). */
69
+ function clone(value) {
70
+ return value === undefined ? undefined : structuredClone(value)
71
+ }
72
+
73
+ /**
74
+ * Deep-merge an explicit profile override over an effective config.
75
+ *
76
+ * DSH applies a profile row's `name` as a guard: an old row whose name no
77
+ * longer matches the active row (for example the historical
78
+ * `name: dsh-apollo` on `apollo-skills`) is skipped by the Loader, yet
79
+ * `configEditor.configuration().override` still exposes its explicit config.
80
+ * Merging that override back over the effective config recovers the user's
81
+ * explicit values during migration.
82
+ *
83
+ * Plain objects merge recursively; arrays and every scalar - including
84
+ * explicit `false`, `0`, `""` and `[]` - replace the base value.
85
+ *
86
+ * @param base - effective config (defaults + applied layers).
87
+ * @param override - raw explicit profile override; undefined means "no override".
88
+ * @returns a detached merged value.
89
+ */
90
+ export function mergeConfigOverride(base, override) {
91
+ if (override === undefined) return clone(base)
92
+ if (!isRecord(base) || !isRecord(override)) return clone(override)
93
+ const result = clone(base)
94
+ for (const [key, value] of Object.entries(override)) {
95
+ result[key] = isRecord(value) && isRecord(result[key])
96
+ ? mergeConfigOverride(result[key], value)
97
+ : clone(value)
98
+ }
99
+ return result
100
+ }
101
+
102
+ /** Assign a nested path into a patch object. */
103
+ function setPatch(patch, keys, value) {
104
+ let node = patch
105
+ for (const key of keys.slice(0, -1)) {
106
+ node[key] ??= {}
107
+ node = node[key]
108
+ }
109
+ node[keys[keys.length - 1]] = value
110
+ }
111
+
112
+ /**
113
+ * Plan the `dsh-better-subagent` patch from Apollo's effective config.
114
+ *
115
+ * @param source - Apollo effective config (`normalizeConfig` shape).
116
+ * @param targetUser - target `settings.describe().user` projection (explicit values).
117
+ * @param targetBase - target `settings.describe().base` projection (resolved defaults).
118
+ * @returns `{ patch, fields }`.
119
+ */
120
+ export function planSubagentMigration({ source, targetUser, targetBase }) {
121
+ const from = normalizeConfig(source)
122
+ const user = isRecord(targetUser) ? targetUser : {}
123
+ const base = isRecord(targetBase) ? targetBase : {}
124
+ const patch = {}
125
+ const fields = []
126
+
127
+ const fill = (keys, value, baseValue) => {
128
+ if (deepEqual(value, baseValue)) return
129
+ setPatch(patch, keys, clone(value))
130
+ fields.push(keys.join('.'))
131
+ }
132
+
133
+ if (!hasOwn(user, 'childExitGuard')) {
134
+ fill(['childExitGuard'], from.exitGuard, base.childExitGuard ?? false)
135
+ }
136
+
137
+ const userTiers = isRecord(user.tiers) ? user.tiers : {}
138
+ const baseTiers = isRecord(base.tiers) ? base.tiers : {}
139
+ for (const tier of ['fast', 'quality']) {
140
+ // A tier is one routing unit: if the target already has any explicit tier
141
+ // field, leave the whole tier alone instead of mixing two routes.
142
+ if (isRecord(userTiers[tier]) && Object.keys(userTiers[tier]).length > 0) continue
143
+ fill(['tiers', tier], from.tiers[tier], baseTiers[tier] ?? { provider: '', model: '', reasoningEffort: '' })
144
+ }
145
+
146
+ const userSubagents = isRecord(user.subagents) ? user.subagents : {}
147
+ const baseSubagents = isRecord(base.subagents) ? base.subagents : {}
148
+ for (const scope of ['eng', 'global']) {
149
+ if (hasOwn(userSubagents, scope)) continue
150
+ fill(['subagents', scope], from.subagents[scope], baseSubagents[scope])
151
+ }
152
+ for (const cli of ['codex', 'claude']) {
153
+ const userBackend = isRecord(userSubagents[cli]) ? userSubagents[cli] : {}
154
+ const baseBackend = isRecord(baseSubagents[cli]) ? baseSubagents[cli] : {}
155
+ for (const [field, value] of Object.entries(from.subagents[cli])) {
156
+ if (hasOwn(userBackend, field)) continue
157
+ fill(['subagents', cli, field], value, baseBackend[field])
158
+ }
159
+ }
160
+ return { patch, fields }
161
+ }
162
+
163
+ /**
164
+ * Plan the non-volatile `trustedHosts` write (separate because it goes through
165
+ * `configEditor.edit`, not `settings.update`).
166
+ *
167
+ * @param source - Apollo effective config.
168
+ * @param targetRaw - target row's raw composed config (`entry.options.config`).
169
+ * @returns hosts array, or undefined when the target already declares the field
170
+ * or the source is empty.
171
+ */
172
+ export function planTrustedHostsMigration({ source, targetRaw }) {
173
+ if (hasOwn(targetRaw, 'trustedHosts')) return undefined
174
+ const hosts = normalizeConfig(source).trustedHosts
175
+ return hosts.length === 0 ? undefined : hosts
176
+ }
177
+
178
+ /** Recursively collect `cordis:group` rows from a preset `plugins` list. */
179
+ function groupRows(rows, found = []) {
180
+ if (!Array.isArray(rows)) return found
181
+ for (const row of rows) {
182
+ if (!isRecord(row) || row.group !== true || !Array.isArray(row.config)) continue
183
+ found.push(row)
184
+ groupRows(row.config, found)
185
+ }
186
+ return found
187
+ }
188
+
189
+ /** Find the native `compaction-basic` row inside a preset declaration. */
190
+ function findCompactionRow(rows) {
191
+ if (!Array.isArray(rows)) return undefined
192
+ for (const row of rows) {
193
+ if (!isRecord(row)) continue
194
+ if (row.id === 'compaction-basic') return row
195
+ if (row.group === true && Array.isArray(row.config)) {
196
+ const nested = findCompactionRow(row.config)
197
+ if (nested !== undefined) return nested
198
+ }
199
+ }
200
+ return undefined
201
+ }
202
+
203
+ /**
204
+ * Extract the explicit native compaction config from one preset declaration.
205
+ *
206
+ * Only explicit values are returned: a preset row without a `compaction-basic`
207
+ * `config` (the shipped default) yields undefined, so native defaults are never
208
+ * promoted into target explicit values.
209
+ *
210
+ * @param presetConfig - top-level preset row `config` (`{ id, plugins, ... }`).
211
+ * @returns the explicit config object, or undefined.
212
+ */
213
+ export function compactionConfigOf(presetConfig) {
214
+ const row = findCompactionRow(presetConfig?.plugins)
215
+ if (!isRecord(row?.config)) return undefined
216
+ return Object.keys(row.config).length === 0 ? undefined : row.config
217
+ }
218
+
219
+ /**
220
+ * Select the compaction source preset.
221
+ *
222
+ * `defaultId` is the registry's effective default preset. When it names an
223
+ * Apollo preset, that row is authoritative; otherwise a single explicit source
224
+ * (or identical sources) is used, and differing explicit sources are reported
225
+ * as ambiguous instead of guessed.
226
+ *
227
+ * @param presets - `[{ rowId, presetId?, config }]` top-level preset rows.
228
+ * @param defaultId - effective default preset id, or undefined.
229
+ * @returns source selection.
230
+ */
231
+ export function selectCompactionSource(presets, defaultId) {
232
+ const rows = (Array.isArray(presets) ? presets : []).filter((row) => isRecord(row))
233
+ const activeRow = PRESET_ROWS[defaultId]
234
+ if (activeRow !== undefined) {
235
+ const row = rows.find((candidate) => candidate.rowId === activeRow)
236
+ if (row === undefined) return { status: 'source-missing', rowId: activeRow }
237
+ const config = compactionConfigOf(row.config)
238
+ return config === undefined
239
+ ? { status: 'absent', rowId: activeRow }
240
+ : { status: 'determined', rowId: activeRow, config }
241
+ }
242
+ const explicit = rows
243
+ .map((row) => ({ rowId: row.rowId, config: compactionConfigOf(row.config) }))
244
+ .filter((entry) => entry.config !== undefined)
245
+ if (explicit.length === 0) return { status: 'absent' }
246
+ const [first] = explicit
247
+ if (explicit.every((entry) => isDeepStrictEqual(entry.config, first.config))) {
248
+ return { status: 'determined', rowId: first.rowId, config: first.config, shared: true }
249
+ }
250
+ return { status: 'ambiguous', rowIds: explicit.map((entry) => entry.rowId) }
251
+ }
252
+
253
+ /**
254
+ * Plan the `dsh-better-compact` patch from a selected native compaction config.
255
+ *
256
+ * @param source - explicit native compaction config.
257
+ * @param targetUser - target `settings.describe().user`.
258
+ * @param targetBase - target `settings.describe().base`.
259
+ * @returns `{ status, patch, reason? }`; only `planned` carries writes.
260
+ */
261
+ export function planCompactMigration({ source, targetUser, targetBase }) {
262
+ if (!isRecord(source)) return { status: 'absent', patch: {} }
263
+ const user = isRecord(targetUser) ? targetUser : {}
264
+ const base = isRecord(targetBase) ? targetBase : {}
265
+ const patch = {}
266
+ const fields = []
267
+ const invalid = (reason) => ({ status: 'invalid', patch: {}, reason })
268
+
269
+ const ratio = hasOwn(source, 'retainRatio')
270
+ const tokens = hasOwn(source, 'retainTokens')
271
+ if (ratio && tokens) {
272
+ return invalid('the native source sets both retainRatio and retainTokens')
273
+ }
274
+ const provider = hasOwn(source, 'summarizationProvider')
275
+ const model = hasOwn(source, 'summarizationModel')
276
+ if (provider !== model) {
277
+ return invalid('the native source sets only one of summarizationProvider / summarizationModel')
278
+ }
279
+ // The summary route is one pair: if the target declares either half, leave
280
+ // both alone instead of mixing the target provider with a source model.
281
+ const targetOwnsSummaryPair = hasOwn(user, 'summarizationProvider') || hasOwn(user, 'summarizationModel')
282
+ const fill = (key, value, baseValue) => {
283
+ if (hasOwn(user, key) || isDeepStrictEqual(value, baseValue)) return
284
+ patch[key] = clone(value)
285
+ fields.push(key)
286
+ }
287
+ for (const key of COMPACT_KEYS) {
288
+ if (!hasOwn(source, key)) continue
289
+ if (targetOwnsSummaryPair && (key === 'summarizationProvider' || key === 'summarizationModel')) continue
290
+ fill(key, source[key], base[key])
291
+ }
292
+ if (ratio || tokens) {
293
+ if (!hasOwn(user, 'retainRatio') && !hasOwn(user, 'retainTokens')) {
294
+ const key = tokens ? 'retainTokens' : 'retainRatio'
295
+ fill(key, source[key], base[key])
296
+ }
297
+ }
298
+ // New installs default to goal. Only add it when this migration actually
299
+ // carries a source config and the target does not choose another mode.
300
+ if (fields.length > 0 && !hasOwn(user, 'mode') && base.mode !== 'goal') {
301
+ patch.mode = 'goal'
302
+ fields.push('mode')
303
+ }
304
+ return fields.length === 0
305
+ ? { status: 'no-change', patch: {}, fields }
306
+ : { status: 'planned', patch, fields }
307
+ }
308
+
309
+ /** Deep equality for JSON-shaped values. */
310
+ function deepEqual(left, right) {
311
+ return isDeepStrictEqual(left, right)
312
+ }
313
+
314
+ /** Read one value from a nested plain object. */
315
+ function readPath(value, keys) {
316
+ let node = value
317
+ for (const key of keys) {
318
+ if (!isRecord(node)) return undefined
319
+ node = node[key]
320
+ }
321
+ return node
322
+ }
323
+
324
+ /**
325
+ * Verify a planned subagent patch against the re-read target descriptor.
326
+ *
327
+ * @returns array of mismatched paths (empty = verified).
328
+ */
329
+ export function verifySubagentMigration({ patch, targetValue }) {
330
+ const mismatched = []
331
+ const visit = (node, path) => {
332
+ for (const [key, value] of Object.entries(node)) {
333
+ const next = [...path, key]
334
+ if (isRecord(value) && !Array.isArray(value)) visit(value, next)
335
+ else if (!deepEqual(readPath(targetValue, next), value)) mismatched.push(next.join('.'))
336
+ }
337
+ }
338
+ visit(patch, [])
339
+ return mismatched
340
+ }
341
+
342
+ /** Verify a planned compact patch against the re-read target descriptor. */
343
+ export function verifyCompactMigration({ patch, targetValue }) {
344
+ return Object.entries(patch)
345
+ .filter(([key, value]) => !deepEqual(targetValue?.[key], value))
346
+ .map(([key]) => key)
347
+ }
348
+
349
+ /** Get the Loader service (root loader) or undefined. */
350
+ function loaderOf(ctx) {
351
+ try {
352
+ const direct = ctx.get?.('loader')
353
+ if (typeof direct?.await === 'function') return direct
354
+ } catch {
355
+ // Test fakes may not provide get().
356
+ }
357
+ try {
358
+ const root = ctx.root?.loader
359
+ return typeof root?.await === 'function' ? root : undefined
360
+ } catch {
361
+ return undefined
362
+ }
363
+ }
364
+
365
+ /** Resolve optional services without requiring inject. */
366
+ function serviceOf(ctx, name) {
367
+ try {
368
+ return ctx.get?.(name)
369
+ } catch {
370
+ return undefined
371
+ }
372
+ }
373
+
374
+ /** Bound a promise by a timeout; resolves undefined on timeout. */
375
+ async function bounded(promise, timeoutMs) {
376
+ let timer
377
+ try {
378
+ return await Promise.race([
379
+ promise,
380
+ new Promise((resolve) => { timer = setTimeout(() => resolve(undefined), timeoutMs) }),
381
+ ])
382
+ } finally {
383
+ clearTimeout(timer)
384
+ }
385
+ }
386
+
387
+ /**
388
+ * Wait (bounded) until every target namespace is present in `describe()`.
389
+ *
390
+ * @returns descriptors array, or undefined on timeout.
391
+ */
392
+ export async function waitForDescriptors(settings, namespaces, options = {}) {
393
+ const timeoutMs = options.timeoutMs ?? WAIT_TIMEOUT_MS
394
+ const intervalMs = options.intervalMs ?? WAIT_INTERVAL_MS
395
+ const deadline = Date.now() + timeoutMs
396
+ for (;;) {
397
+ const descriptors = await settings.describe()
398
+ const rows = Array.isArray(descriptors) ? descriptors : []
399
+ if (namespaces.every((ns) => rows.some((row) => row?.ns === ns))) return rows
400
+ if (Date.now() >= deadline) return undefined
401
+ await delay(intervalMs)
402
+ }
403
+ }
404
+
405
+ /** Current default preset id, when the registry is present. */
406
+ function defaultPresetOf(agentPresets) {
407
+ try {
408
+ const id = agentPresets?.defaultId
409
+ return typeof id === 'string' && id !== '' ? id : undefined
410
+ } catch {
411
+ return undefined
412
+ }
413
+ }
414
+
415
+ /** Read one row's raw profile override from configEditor.configuration(). */
416
+ function configurationOverrideOf(configEditor, id) {
417
+ try {
418
+ const rows = configEditor?.configuration?.()
419
+ if (!Array.isArray(rows)) return undefined
420
+ const row = rows.find((candidate) => candidate?.entry?.options?.id === id)
421
+ return isRecord(row?.override) ? row.override : undefined
422
+ } catch {
423
+ return undefined
424
+ }
425
+ }
426
+
427
+ /**
428
+ * Top-level preset declarations visible to configEditor.entries().
429
+ *
430
+ * A preset override whose `name` guard no longer matches is skipped by the
431
+ * Loader, so merge the configuration override back before reading its native
432
+ * `compaction-basic` config. Array values (the child `plugins` list) replace
433
+ * wholesale, matching DSH's preset-override semantics.
434
+ */
435
+ function presetRowsOf(configEditor) {
436
+ let entries
437
+ try {
438
+ entries = configEditor?.entries?.()
439
+ } catch {
440
+ entries = undefined
441
+ }
442
+ if (!Array.isArray(entries)) return []
443
+ return entries
444
+ .filter((entry) => entry?.options?.name === '@deepseek-ai/dsh-agent-preset')
445
+ .map((entry) => {
446
+ const base = isRecord(entry.options.config) ? entry.options.config : {}
447
+ const override = configurationOverrideOf(configEditor, entry.options.id)
448
+ const config = override === undefined ? base : mergeConfigOverride(base, override)
449
+ return {
450
+ rowId: entry.options.id,
451
+ presetId: config.id,
452
+ config,
453
+ }
454
+ })
455
+ }
456
+
457
+ /** Find the target row entry (needed for the non-volatile trustedHosts write). */
458
+ function targetEntryOf(configEditor, ns) {
459
+ try {
460
+ return configEditor?.entries?.().find((entry) => entry?.options?.id === ns)
461
+ } catch {
462
+ return undefined
463
+ }
464
+ }
465
+
466
+ /**
467
+ * Apply the subagent migration.
468
+ *
469
+ * @returns `{ status, fields, error? }`.
470
+ */
471
+ export async function migrateSubagent({ settings, configEditor, descriptors, source, log }) {
472
+ const target = descriptors.find((row) => row?.ns === SUBAGENT_NS)
473
+ if (target === undefined) return { status: 'pending', fields: [], error: 'target namespace is not readable' }
474
+ const { patch, fields } = planSubagentMigration({
475
+ source,
476
+ targetUser: target.user,
477
+ targetBase: target.base,
478
+ })
479
+ const entry = targetEntryOf(configEditor, SUBAGENT_NS)
480
+ const wantedHosts = normalizeConfig(source).trustedHosts
481
+ const targetDeclaresHosts = hasOwn(entry?.options?.config, 'trustedHosts')
482
+ // A target row that cannot be addressed cannot receive the non-volatile
483
+ // deployment field; report it instead of silently dropping the old value.
484
+ const trustedHostsBlocked = wantedHosts.length > 0 && !targetDeclaresHosts
485
+ && (entry === undefined || configEditor === undefined || typeof configEditor.edit !== 'function')
486
+ const trustedHosts = entry === undefined || targetDeclaresHosts
487
+ ? undefined
488
+ : planTrustedHostsMigration({ source, targetRaw: entry.options?.config })
489
+ if (fields.length === 0 && trustedHosts === undefined) {
490
+ return trustedHostsBlocked
491
+ ? { status: 'pending', fields, error: 'old trustedHosts could not be migrated: the target row is not addressable' }
492
+ : { status: 'no-change', fields }
493
+ }
494
+ if (!settings || typeof settings.update !== 'function') {
495
+ return { status: 'pending', fields, error: 'settings service is unavailable' }
496
+ }
497
+ try {
498
+ if (fields.length > 0) await settings.update(SUBAGENT_NS, patch, target.revision)
499
+ if (trustedHosts !== undefined) {
500
+ // settings.update reconciles profile patches, which may replace the
501
+ // target row's Loader entry; re-read it before the non-volatile write.
502
+ const fresh = targetEntryOf(configEditor, SUBAGENT_NS)
503
+ if (configEditor === undefined || fresh === undefined) {
504
+ return { status: 'pending', fields, error: 'trustedHosts needs the config editor, which is unavailable' }
505
+ }
506
+ await configEditor.edit(fresh, (current) => ({ ...current, trustedHosts }))
507
+ }
508
+ } catch (error) {
509
+ return { status: 'pending', fields, error: errorMessage(error) }
510
+ }
511
+ const after = (await settings.describe()).find((row) => row?.ns === SUBAGENT_NS)
512
+ const mismatched = verifySubagentMigration({ patch, targetValue: after?.value })
513
+ if (trustedHosts !== undefined) {
514
+ const raw = targetEntryOf(configEditor, SUBAGENT_NS)?.options?.config
515
+ if (!deepEqual(raw?.trustedHosts, trustedHosts)) mismatched.push('trustedHosts')
516
+ }
517
+ if (mismatched.length > 0) {
518
+ log?.warn?.(`apollo-migration: ${SUBAGENT_NS} write could not be verified: ${mismatched.join(', ')}`)
519
+ return { status: 'error', fields, error: `unverified: ${mismatched.join(', ')}` }
520
+ }
521
+ if (trustedHostsBlocked) {
522
+ return { status: 'pending', fields, error: 'old trustedHosts could not be migrated: the target row is not addressable' }
523
+ }
524
+ return { status: 'complete', fields }
525
+ }
526
+
527
+ /**
528
+ * Apply the compaction migration.
529
+ *
530
+ * @returns `{ status, fields?, sourcePreset?, reason?, error? }`.
531
+ */
532
+ export async function migrateCompact({ settings, configEditor, descriptors, agentPresets, log }) {
533
+ const target = descriptors.find((row) => row?.ns === COMPACT_NS)
534
+ if (target === undefined) return { status: 'pending', error: 'target namespace is not readable' }
535
+ const selected = selectCompactionSource(presetRowsOf(configEditor), defaultPresetOf(agentPresets))
536
+ if (selected.status !== 'determined') {
537
+ if (selected.status === 'ambiguous') {
538
+ log?.warn?.(`apollo-migration: native compaction config is ambiguous (${selected.rowIds.join(', ')} differ); ` +
539
+ `choose the active preset / keep one source and reload to migrate ${COMPACT_NS}`)
540
+ } else if (selected.status === 'source-missing') {
541
+ log?.warn?.(`apollo-migration: default preset row "${selected.rowId}" is not readable, so the old native ` +
542
+ `compaction config cannot be imported. Configure ${COMPACT_NS} directly, or restore the preset row and reload.`)
543
+ }
544
+ return selected
545
+ }
546
+ const plan = planCompactMigration({ source: selected.config, targetUser: target.user, targetBase: target.base })
547
+ if (plan.status === 'invalid') {
548
+ log?.warn?.(`apollo-migration: native compaction config from "${selected.rowId}" cannot be migrated: ${plan.reason}`)
549
+ }
550
+ if (plan.status !== 'planned') return { ...plan, sourcePreset: selected.rowId }
551
+ try {
552
+ await settings.update(COMPACT_NS, plan.patch, target.revision)
553
+ } catch (error) {
554
+ return { status: 'pending', sourcePreset: selected.rowId, error: errorMessage(error) }
555
+ }
556
+ const after = (await settings.describe()).find((row) => row?.ns === COMPACT_NS)
557
+ const mismatched = verifyCompactMigration({ patch: plan.patch, targetValue: after?.value })
558
+ if (mismatched.length > 0) {
559
+ log?.warn?.(`apollo-migration: ${COMPACT_NS} write could not be verified: ${mismatched.join(', ')}`)
560
+ return { status: 'error', sourcePreset: selected.rowId, error: `unverified: ${mismatched.join(', ')}` }
561
+ }
562
+ return { status: 'complete', sourcePreset: selected.rowId, fields: plan.fields }
563
+ }
564
+
565
+ function errorMessage(error) {
566
+ return error instanceof Error ? error.message : String(error)
567
+ }
568
+
569
+ /**
570
+ * Run the one-time (idempotent) migration.
571
+ *
572
+ * Order: Loader settlement → legacy `eng.json` import → bounded wait for both
573
+ * target namespaces → subagent plan/write/verify → compact plan/write/verify.
574
+ * A missing target namespace yields `pending`; errors are reported, never
575
+ * silently claimed as success, and no source value is deleted.
576
+ *
577
+ * @param ctx - context with the settings service and (usually) configEditor.
578
+ * @param options - `{ config, legacyImport, timeoutMs, intervalMs }`.
579
+ * @returns status report; never throws for migration failures (caller logs it).
580
+ */
581
+ export async function runMigration(ctx, options = {}) {
582
+ const settings = ctx.get?.('settings') ?? ctx.settings
583
+ const log = options.log ?? ctx.logger
584
+ const timeoutMs = options.timeoutMs ?? WAIT_TIMEOUT_MS
585
+ if (typeof settings?.describe !== 'function') {
586
+ return { status: 'pending', error: 'settings service is unavailable' }
587
+ }
588
+ const loader = loaderOf(ctx)
589
+ if (loader !== undefined) await bounded(Promise.resolve(loader.await()).catch(() => undefined), timeoutMs)
590
+ if (options.legacyImport !== undefined) await bounded(options.legacyImport, timeoutMs)
591
+ const descriptors = await waitForDescriptors(settings, [SUBAGENT_NS, COMPACT_NS], {
592
+ timeoutMs,
593
+ intervalMs: options.intervalMs,
594
+ })
595
+ if (descriptors === undefined) {
596
+ const message = `apollo-migration: pending — ${SUBAGENT_NS} / ${COMPACT_NS} are not readable/writable yet. ` +
597
+ 'Reload the profile after the plugin rows are active; migration will run again and is idempotent.'
598
+ log?.warn?.(message)
599
+ return { status: 'pending', error: message }
600
+ }
601
+ const sourceDescriptor = descriptors.find((row) => row?.ns === APOLLO_NS)
602
+ const configEditor = options.configEditor ?? serviceOf(ctx, 'configEditor')
603
+ const agentPresets = options.agentPresets ?? serviceOf(ctx, 'agentPresets')
604
+ // describe().value only projects the *volatile* form, so read the Apollo
605
+ // row's raw config for non-volatile fields (trustedHosts) and overlay the
606
+ // live volatile values on top. Finally merge the raw profile override:
607
+ // DSH skips a row whose old `name` guard no longer matches (historical
608
+ // `name: dsh-apollo`), and only configuration().override still carries the
609
+ // user's explicit values.
610
+ const rawSource = targetEntryOf(configEditor, APOLLO_NS)?.options?.config
611
+ const sourceBase = {
612
+ ...(isRecord(options.config) ? options.config : {}),
613
+ ...(isRecord(rawSource) ? rawSource : {}),
614
+ }
615
+ const effectiveSource = { ...sourceBase, ...(sourceDescriptor?.value ?? {}) }
616
+ const source = normalizeConfig(mergeConfigOverride(
617
+ effectiveSource,
618
+ configurationOverrideOf(configEditor, APOLLO_NS),
619
+ ))
620
+ const subagent = await migrateSubagent({ settings, configEditor, descriptors, source, log })
621
+ const compact = await migrateCompact({ settings, configEditor, descriptors, agentPresets, log })
622
+ const status = [subagent.status, compact.status].some((value) => value === 'error')
623
+ ? 'error'
624
+ : [subagent.status, compact.status].some((value) => value === 'pending')
625
+ ? 'pending'
626
+ : ['ambiguous', 'invalid', 'source-missing'].includes(compact.status) ? 'blocked'
627
+ : 'complete'
628
+ for (const [name, section] of [['subagent', subagent], ['compact', compact]]) {
629
+ if (section.error !== undefined) {
630
+ log?.warn?.(`apollo-migration: ${name} is ${section.status}: ${section.error}`)
631
+ }
632
+ }
633
+ log?.[status === 'complete' ? 'info' : 'warn']?.(
634
+ `apollo-migration: ${status} (subagent=${subagent.status}, compact=${compact.status})`,
635
+ )
636
+ return { status, source: sourceDescriptor === undefined ? 'apply-config' : 'settings', subagent, compact }
637
+ }
638
+
639
+ /**
640
+ * Mount the migration after Apollo's `settings` service appears.
641
+ *
642
+ * @param ctx - plugin context.
643
+ * @param config - Apollo row config (fallback source when describe() is not ready).
644
+ * @param options - `{ legacyImport, env, timeoutMs, intervalMs }`.
645
+ */
646
+ export function mountMigration(ctx, config, options = {}) {
647
+ let started = false
648
+ const start = (settingsCtx) => {
649
+ if (started) return
650
+ started = true
651
+ void runMigration(settingsCtx, {
652
+ config,
653
+ legacyImport: options.legacyImport,
654
+ timeoutMs: options.timeoutMs,
655
+ intervalMs: options.intervalMs,
656
+ log: settingsCtx.logger ?? ctx.logger,
657
+ }).catch((error) => {
658
+ settingsCtx.logger?.warn?.(`apollo-migration: skipped: ${errorMessage(error)}`)
659
+ })
660
+ }
661
+ ctx.inject(['settings'], start)
662
+ }