@menghuan1918/dsh-apollo 0.4.0 → 0.5.0

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,602 @@
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
+ /** Assign a nested path into a patch object. */
74
+ function setPatch(patch, keys, value) {
75
+ let node = patch
76
+ for (const key of keys.slice(0, -1)) {
77
+ node[key] ??= {}
78
+ node = node[key]
79
+ }
80
+ node[keys[keys.length - 1]] = value
81
+ }
82
+
83
+ /**
84
+ * Plan the `dsh-better-subagent` patch from Apollo's effective config.
85
+ *
86
+ * @param source - Apollo effective config (`normalizeConfig` shape).
87
+ * @param targetUser - target `settings.describe().user` projection (explicit values).
88
+ * @param targetBase - target `settings.describe().base` projection (resolved defaults).
89
+ * @returns `{ patch, fields }`.
90
+ */
91
+ export function planSubagentMigration({ source, targetUser, targetBase }) {
92
+ const from = normalizeConfig(source)
93
+ const user = isRecord(targetUser) ? targetUser : {}
94
+ const base = isRecord(targetBase) ? targetBase : {}
95
+ const patch = {}
96
+ const fields = []
97
+
98
+ const fill = (keys, value, baseValue) => {
99
+ if (deepEqual(value, baseValue)) return
100
+ setPatch(patch, keys, clone(value))
101
+ fields.push(keys.join('.'))
102
+ }
103
+
104
+ if (!hasOwn(user, 'childExitGuard')) {
105
+ fill(['childExitGuard'], from.exitGuard, base.childExitGuard ?? false)
106
+ }
107
+
108
+ const userTiers = isRecord(user.tiers) ? user.tiers : {}
109
+ const baseTiers = isRecord(base.tiers) ? base.tiers : {}
110
+ for (const tier of ['fast', 'quality']) {
111
+ // A tier is one routing unit: if the target already has any explicit tier
112
+ // field, leave the whole tier alone instead of mixing two routes.
113
+ if (isRecord(userTiers[tier]) && Object.keys(userTiers[tier]).length > 0) continue
114
+ fill(['tiers', tier], from.tiers[tier], baseTiers[tier] ?? { provider: '', model: '', reasoningEffort: '' })
115
+ }
116
+
117
+ const userSubagents = isRecord(user.subagents) ? user.subagents : {}
118
+ const baseSubagents = isRecord(base.subagents) ? base.subagents : {}
119
+ for (const scope of ['eng', 'global']) {
120
+ if (hasOwn(userSubagents, scope)) continue
121
+ fill(['subagents', scope], from.subagents[scope], baseSubagents[scope])
122
+ }
123
+ for (const cli of ['codex', 'claude']) {
124
+ const userBackend = isRecord(userSubagents[cli]) ? userSubagents[cli] : {}
125
+ const baseBackend = isRecord(baseSubagents[cli]) ? baseSubagents[cli] : {}
126
+ for (const [field, value] of Object.entries(from.subagents[cli])) {
127
+ if (hasOwn(userBackend, field)) continue
128
+ fill(['subagents', cli, field], value, baseBackend[field])
129
+ }
130
+ }
131
+ return { patch, fields }
132
+ }
133
+
134
+ /**
135
+ * Plan the non-volatile `trustedHosts` write (separate because it goes through
136
+ * `configEditor.edit`, not `settings.update`).
137
+ *
138
+ * @param source - Apollo effective config.
139
+ * @param targetRaw - target row's raw composed config (`entry.options.config`).
140
+ * @returns hosts array, or undefined when the target already declares the field
141
+ * or the source is empty.
142
+ */
143
+ export function planTrustedHostsMigration({ source, targetRaw }) {
144
+ if (hasOwn(targetRaw, 'trustedHosts')) return undefined
145
+ const hosts = normalizeConfig(source).trustedHosts
146
+ return hosts.length === 0 ? undefined : hosts
147
+ }
148
+
149
+ /** Recursively collect `cordis:group` rows from a preset `plugins` list. */
150
+ function groupRows(rows, found = []) {
151
+ if (!Array.isArray(rows)) return found
152
+ for (const row of rows) {
153
+ if (!isRecord(row) || row.group !== true || !Array.isArray(row.config)) continue
154
+ found.push(row)
155
+ groupRows(row.config, found)
156
+ }
157
+ return found
158
+ }
159
+
160
+ /** Find the native `compaction-basic` row inside a preset declaration. */
161
+ function findCompactionRow(rows) {
162
+ if (!Array.isArray(rows)) return undefined
163
+ for (const row of rows) {
164
+ if (!isRecord(row)) continue
165
+ if (row.id === 'compaction-basic') return row
166
+ if (row.group === true && Array.isArray(row.config)) {
167
+ const nested = findCompactionRow(row.config)
168
+ if (nested !== undefined) return nested
169
+ }
170
+ }
171
+ return undefined
172
+ }
173
+
174
+ /**
175
+ * Extract the explicit native compaction config from one preset declaration.
176
+ *
177
+ * Only explicit values are returned: a preset row without a `compaction-basic`
178
+ * `config` (the shipped default) yields undefined, so native defaults are never
179
+ * promoted into target explicit values.
180
+ *
181
+ * @param presetConfig - top-level preset row `config` (`{ id, plugins, ... }`).
182
+ * @returns the explicit config object, or undefined.
183
+ */
184
+ export function compactionConfigOf(presetConfig) {
185
+ const row = findCompactionRow(presetConfig?.plugins)
186
+ if (!isRecord(row?.config)) return undefined
187
+ return Object.keys(row.config).length === 0 ? undefined : row.config
188
+ }
189
+
190
+ /**
191
+ * Select the compaction source preset.
192
+ *
193
+ * `defaultId` is the registry's effective default preset. When it names an
194
+ * Apollo preset, that row is authoritative; otherwise a single explicit source
195
+ * (or identical sources) is used, and differing explicit sources are reported
196
+ * as ambiguous instead of guessed.
197
+ *
198
+ * @param presets - `[{ rowId, presetId?, config }]` top-level preset rows.
199
+ * @param defaultId - effective default preset id, or undefined.
200
+ * @returns source selection.
201
+ */
202
+ export function selectCompactionSource(presets, defaultId) {
203
+ const rows = (Array.isArray(presets) ? presets : []).filter((row) => isRecord(row))
204
+ const activeRow = PRESET_ROWS[defaultId]
205
+ if (activeRow !== undefined) {
206
+ const row = rows.find((candidate) => candidate.rowId === activeRow)
207
+ if (row === undefined) return { status: 'source-missing', rowId: activeRow }
208
+ const config = compactionConfigOf(row.config)
209
+ return config === undefined
210
+ ? { status: 'absent', rowId: activeRow }
211
+ : { status: 'determined', rowId: activeRow, config }
212
+ }
213
+ const explicit = rows
214
+ .map((row) => ({ rowId: row.rowId, config: compactionConfigOf(row.config) }))
215
+ .filter((entry) => entry.config !== undefined)
216
+ if (explicit.length === 0) return { status: 'absent' }
217
+ const [first] = explicit
218
+ if (explicit.every((entry) => isDeepStrictEqual(entry.config, first.config))) {
219
+ return { status: 'determined', rowId: first.rowId, config: first.config, shared: true }
220
+ }
221
+ return { status: 'ambiguous', rowIds: explicit.map((entry) => entry.rowId) }
222
+ }
223
+
224
+ /**
225
+ * Plan the `dsh-better-compact` patch from a selected native compaction config.
226
+ *
227
+ * @param source - explicit native compaction config.
228
+ * @param targetUser - target `settings.describe().user`.
229
+ * @param targetBase - target `settings.describe().base`.
230
+ * @returns `{ status, patch, reason? }`; only `planned` carries writes.
231
+ */
232
+ export function planCompactMigration({ source, targetUser, targetBase }) {
233
+ if (!isRecord(source)) return { status: 'absent', patch: {} }
234
+ const user = isRecord(targetUser) ? targetUser : {}
235
+ const base = isRecord(targetBase) ? targetBase : {}
236
+ const patch = {}
237
+ const fields = []
238
+ const invalid = (reason) => ({ status: 'invalid', patch: {}, reason })
239
+
240
+ const ratio = hasOwn(source, 'retainRatio')
241
+ const tokens = hasOwn(source, 'retainTokens')
242
+ if (ratio && tokens) {
243
+ return invalid('the native source sets both retainRatio and retainTokens')
244
+ }
245
+ const provider = hasOwn(source, 'summarizationProvider')
246
+ const model = hasOwn(source, 'summarizationModel')
247
+ if (provider !== model) {
248
+ return invalid('the native source sets only one of summarizationProvider / summarizationModel')
249
+ }
250
+ // The summary route is one pair: if the target declares either half, leave
251
+ // both alone instead of mixing the target provider with a source model.
252
+ const targetOwnsSummaryPair = hasOwn(user, 'summarizationProvider') || hasOwn(user, 'summarizationModel')
253
+ const fill = (key, value, baseValue) => {
254
+ if (hasOwn(user, key) || isDeepStrictEqual(value, baseValue)) return
255
+ patch[key] = clone(value)
256
+ fields.push(key)
257
+ }
258
+ for (const key of COMPACT_KEYS) {
259
+ if (!hasOwn(source, key)) continue
260
+ if (targetOwnsSummaryPair && (key === 'summarizationProvider' || key === 'summarizationModel')) continue
261
+ fill(key, source[key], base[key])
262
+ }
263
+ if (ratio || tokens) {
264
+ if (!hasOwn(user, 'retainRatio') && !hasOwn(user, 'retainTokens')) {
265
+ const key = tokens ? 'retainTokens' : 'retainRatio'
266
+ fill(key, source[key], base[key])
267
+ }
268
+ }
269
+ // New installs default to goal. Only add it when this migration actually
270
+ // carries a source config and the target does not choose another mode.
271
+ if (fields.length > 0 && !hasOwn(user, 'mode') && base.mode !== 'goal') {
272
+ patch.mode = 'goal'
273
+ fields.push('mode')
274
+ }
275
+ return fields.length === 0
276
+ ? { status: 'no-change', patch: {}, fields }
277
+ : { status: 'planned', patch, fields }
278
+ }
279
+
280
+ /** Deep equality for JSON-shaped values. */
281
+ function deepEqual(left, right) {
282
+ return isDeepStrictEqual(left, right)
283
+ }
284
+
285
+ /** Read one value from a nested plain object. */
286
+ function readPath(value, keys) {
287
+ let node = value
288
+ for (const key of keys) {
289
+ if (!isRecord(node)) return undefined
290
+ node = node[key]
291
+ }
292
+ return node
293
+ }
294
+
295
+ /**
296
+ * Verify a planned subagent patch against the re-read target descriptor.
297
+ *
298
+ * @returns array of mismatched paths (empty = verified).
299
+ */
300
+ export function verifySubagentMigration({ patch, targetValue }) {
301
+ const mismatched = []
302
+ const visit = (node, path) => {
303
+ for (const [key, value] of Object.entries(node)) {
304
+ const next = [...path, key]
305
+ if (isRecord(value) && !Array.isArray(value)) visit(value, next)
306
+ else if (!deepEqual(readPath(targetValue, next), value)) mismatched.push(next.join('.'))
307
+ }
308
+ }
309
+ visit(patch, [])
310
+ return mismatched
311
+ }
312
+
313
+ /** Verify a planned compact patch against the re-read target descriptor. */
314
+ export function verifyCompactMigration({ patch, targetValue }) {
315
+ return Object.entries(patch)
316
+ .filter(([key, value]) => !deepEqual(targetValue?.[key], value))
317
+ .map(([key]) => key)
318
+ }
319
+
320
+ /** Get the Loader service (root loader) or undefined. */
321
+ function loaderOf(ctx) {
322
+ try {
323
+ const direct = ctx.get?.('loader')
324
+ if (typeof direct?.await === 'function') return direct
325
+ } catch {
326
+ // Test fakes may not provide get().
327
+ }
328
+ try {
329
+ const root = ctx.root?.loader
330
+ return typeof root?.await === 'function' ? root : undefined
331
+ } catch {
332
+ return undefined
333
+ }
334
+ }
335
+
336
+ /** Resolve optional services without requiring inject. */
337
+ function serviceOf(ctx, name) {
338
+ try {
339
+ return ctx.get?.(name)
340
+ } catch {
341
+ return undefined
342
+ }
343
+ }
344
+
345
+ /** Bound a promise by a timeout; resolves undefined on timeout. */
346
+ async function bounded(promise, timeoutMs) {
347
+ let timer
348
+ try {
349
+ return await Promise.race([
350
+ promise,
351
+ new Promise((resolve) => { timer = setTimeout(() => resolve(undefined), timeoutMs) }),
352
+ ])
353
+ } finally {
354
+ clearTimeout(timer)
355
+ }
356
+ }
357
+
358
+ /**
359
+ * Wait (bounded) until every target namespace is present in `describe()`.
360
+ *
361
+ * @returns descriptors array, or undefined on timeout.
362
+ */
363
+ export async function waitForDescriptors(settings, namespaces, options = {}) {
364
+ const timeoutMs = options.timeoutMs ?? WAIT_TIMEOUT_MS
365
+ const intervalMs = options.intervalMs ?? WAIT_INTERVAL_MS
366
+ const deadline = Date.now() + timeoutMs
367
+ for (;;) {
368
+ const descriptors = await settings.describe()
369
+ const rows = Array.isArray(descriptors) ? descriptors : []
370
+ if (namespaces.every((ns) => rows.some((row) => row?.ns === ns))) return rows
371
+ if (Date.now() >= deadline) return undefined
372
+ await delay(intervalMs)
373
+ }
374
+ }
375
+
376
+ /** Current default preset id, when the registry is present. */
377
+ function defaultPresetOf(agentPresets) {
378
+ try {
379
+ const id = agentPresets?.defaultId
380
+ return typeof id === 'string' && id !== '' ? id : undefined
381
+ } catch {
382
+ return undefined
383
+ }
384
+ }
385
+
386
+ /** Top-level preset declarations visible to configEditor.entries(). */
387
+ function presetRowsOf(configEditor) {
388
+ let entries
389
+ try {
390
+ entries = configEditor?.entries?.()
391
+ } catch {
392
+ entries = undefined
393
+ }
394
+ if (!Array.isArray(entries)) return []
395
+ return entries
396
+ .filter((entry) => entry?.options?.name === '@deepseek-ai/dsh-agent-preset')
397
+ .map((entry) => ({
398
+ rowId: entry.options.id,
399
+ presetId: entry.options.config?.id,
400
+ config: entry.options.config,
401
+ }))
402
+ }
403
+
404
+ /** Find the target row entry (needed for the non-volatile trustedHosts write). */
405
+ function targetEntryOf(configEditor, ns) {
406
+ try {
407
+ return configEditor?.entries?.().find((entry) => entry?.options?.id === ns)
408
+ } catch {
409
+ return undefined
410
+ }
411
+ }
412
+
413
+ /**
414
+ * Apply the subagent migration.
415
+ *
416
+ * @returns `{ status, fields, error? }`.
417
+ */
418
+ export async function migrateSubagent({ settings, configEditor, descriptors, source, log }) {
419
+ const target = descriptors.find((row) => row?.ns === SUBAGENT_NS)
420
+ if (target === undefined) return { status: 'pending', fields: [], error: 'target namespace is not readable' }
421
+ const { patch, fields } = planSubagentMigration({
422
+ source,
423
+ targetUser: target.user,
424
+ targetBase: target.base,
425
+ })
426
+ const entry = targetEntryOf(configEditor, SUBAGENT_NS)
427
+ const wantedHosts = normalizeConfig(source).trustedHosts
428
+ const targetDeclaresHosts = hasOwn(entry?.options?.config, 'trustedHosts')
429
+ // A target row that cannot be addressed cannot receive the non-volatile
430
+ // deployment field; report it instead of silently dropping the old value.
431
+ const trustedHostsBlocked = wantedHosts.length > 0 && !targetDeclaresHosts
432
+ && (entry === undefined || configEditor === undefined || typeof configEditor.edit !== 'function')
433
+ const trustedHosts = entry === undefined || targetDeclaresHosts
434
+ ? undefined
435
+ : planTrustedHostsMigration({ source, targetRaw: entry.options?.config })
436
+ if (fields.length === 0 && trustedHosts === undefined) {
437
+ return trustedHostsBlocked
438
+ ? { status: 'pending', fields, error: 'old trustedHosts could not be migrated: the target row is not addressable' }
439
+ : { status: 'no-change', fields }
440
+ }
441
+ if (!settings || typeof settings.update !== 'function') {
442
+ return { status: 'pending', fields, error: 'settings service is unavailable' }
443
+ }
444
+ try {
445
+ if (fields.length > 0) await settings.update(SUBAGENT_NS, patch, target.revision)
446
+ if (trustedHosts !== undefined) {
447
+ // settings.update reconciles profile patches, which may replace the
448
+ // target row's Loader entry; re-read it before the non-volatile write.
449
+ const fresh = targetEntryOf(configEditor, SUBAGENT_NS)
450
+ if (configEditor === undefined || fresh === undefined) {
451
+ return { status: 'pending', fields, error: 'trustedHosts needs the config editor, which is unavailable' }
452
+ }
453
+ await configEditor.edit(fresh, (current) => ({ ...current, trustedHosts }))
454
+ }
455
+ } catch (error) {
456
+ return { status: 'pending', fields, error: errorMessage(error) }
457
+ }
458
+ const after = (await settings.describe()).find((row) => row?.ns === SUBAGENT_NS)
459
+ const mismatched = verifySubagentMigration({ patch, targetValue: after?.value })
460
+ if (trustedHosts !== undefined) {
461
+ const raw = targetEntryOf(configEditor, SUBAGENT_NS)?.options?.config
462
+ if (!deepEqual(raw?.trustedHosts, trustedHosts)) mismatched.push('trustedHosts')
463
+ }
464
+ if (mismatched.length > 0) {
465
+ log?.warn?.(`apollo-migration: ${SUBAGENT_NS} write could not be verified: ${mismatched.join(', ')}`)
466
+ return { status: 'error', fields, error: `unverified: ${mismatched.join(', ')}` }
467
+ }
468
+ if (trustedHostsBlocked) {
469
+ return { status: 'pending', fields, error: 'old trustedHosts could not be migrated: the target row is not addressable' }
470
+ }
471
+ return { status: 'complete', fields }
472
+ }
473
+
474
+ /**
475
+ * Apply the compaction migration.
476
+ *
477
+ * @returns `{ status, fields?, sourcePreset?, reason?, error? }`.
478
+ */
479
+ export async function migrateCompact({ settings, configEditor, descriptors, agentPresets, log }) {
480
+ const target = descriptors.find((row) => row?.ns === COMPACT_NS)
481
+ if (target === undefined) return { status: 'pending', error: 'target namespace is not readable' }
482
+ const selected = selectCompactionSource(presetRowsOf(configEditor), defaultPresetOf(agentPresets))
483
+ if (selected.status !== 'determined') {
484
+ if (selected.status === 'ambiguous') {
485
+ log?.warn?.(`apollo-migration: native compaction config is ambiguous (${selected.rowIds.join(', ')} differ); ` +
486
+ `choose the active preset / keep one source and reload to migrate ${COMPACT_NS}`)
487
+ } else if (selected.status === 'source-missing') {
488
+ log?.warn?.(`apollo-migration: default preset row "${selected.rowId}" is not readable, so the old native ` +
489
+ `compaction config cannot be imported. Configure ${COMPACT_NS} directly, or restore the preset row and reload.`)
490
+ }
491
+ return selected
492
+ }
493
+ const plan = planCompactMigration({ source: selected.config, targetUser: target.user, targetBase: target.base })
494
+ if (plan.status === 'invalid') {
495
+ log?.warn?.(`apollo-migration: native compaction config from "${selected.rowId}" cannot be migrated: ${plan.reason}`)
496
+ }
497
+ if (plan.status !== 'planned') return { ...plan, sourcePreset: selected.rowId }
498
+ try {
499
+ await settings.update(COMPACT_NS, plan.patch, target.revision)
500
+ } catch (error) {
501
+ return { status: 'pending', sourcePreset: selected.rowId, error: errorMessage(error) }
502
+ }
503
+ const after = (await settings.describe()).find((row) => row?.ns === COMPACT_NS)
504
+ const mismatched = verifyCompactMigration({ patch: plan.patch, targetValue: after?.value })
505
+ if (mismatched.length > 0) {
506
+ log?.warn?.(`apollo-migration: ${COMPACT_NS} write could not be verified: ${mismatched.join(', ')}`)
507
+ return { status: 'error', sourcePreset: selected.rowId, error: `unverified: ${mismatched.join(', ')}` }
508
+ }
509
+ return { status: 'complete', sourcePreset: selected.rowId, fields: plan.fields }
510
+ }
511
+
512
+ function errorMessage(error) {
513
+ return error instanceof Error ? error.message : String(error)
514
+ }
515
+
516
+ /**
517
+ * Run the one-time (idempotent) migration.
518
+ *
519
+ * Order: Loader settlement → legacy `eng.json` import → bounded wait for both
520
+ * target namespaces → subagent plan/write/verify → compact plan/write/verify.
521
+ * A missing target namespace yields `pending`; errors are reported, never
522
+ * silently claimed as success, and no source value is deleted.
523
+ *
524
+ * @param ctx - context with the settings service and (usually) configEditor.
525
+ * @param options - `{ config, legacyImport, timeoutMs, intervalMs }`.
526
+ * @returns status report; never throws for migration failures (caller logs it).
527
+ */
528
+ export async function runMigration(ctx, options = {}) {
529
+ const settings = ctx.get?.('settings') ?? ctx.settings
530
+ const log = options.log ?? ctx.logger
531
+ const timeoutMs = options.timeoutMs ?? WAIT_TIMEOUT_MS
532
+ if (typeof settings?.describe !== 'function') {
533
+ return { status: 'pending', error: 'settings service is unavailable' }
534
+ }
535
+ const loader = loaderOf(ctx)
536
+ if (loader !== undefined) await bounded(Promise.resolve(loader.await()).catch(() => undefined), timeoutMs)
537
+ if (options.legacyImport !== undefined) await bounded(options.legacyImport, timeoutMs)
538
+ const descriptors = await waitForDescriptors(settings, [SUBAGENT_NS, COMPACT_NS], {
539
+ timeoutMs,
540
+ intervalMs: options.intervalMs,
541
+ })
542
+ if (descriptors === undefined) {
543
+ const message = `apollo-migration: pending — ${SUBAGENT_NS} / ${COMPACT_NS} are not readable/writable yet. ` +
544
+ 'Reload the profile after the plugin rows are active; migration will run again and is idempotent.'
545
+ log?.warn?.(message)
546
+ return { status: 'pending', error: message }
547
+ }
548
+ const sourceDescriptor = descriptors.find((row) => row?.ns === APOLLO_NS)
549
+ const configEditor = options.configEditor ?? serviceOf(ctx, 'configEditor')
550
+ const agentPresets = options.agentPresets ?? serviceOf(ctx, 'agentPresets')
551
+ // describe().value only projects the *volatile* form, so read the Apollo
552
+ // row's raw config for non-volatile fields (trustedHosts) and overlay the
553
+ // live volatile values on top.
554
+ const rawSource = targetEntryOf(configEditor, APOLLO_NS)?.options?.config
555
+ const sourceBase = {
556
+ ...(isRecord(options.config) ? options.config : {}),
557
+ ...(isRecord(rawSource) ? rawSource : {}),
558
+ }
559
+ const source = normalizeConfig({ ...sourceBase, ...(sourceDescriptor?.value ?? {}) })
560
+ const subagent = await migrateSubagent({ settings, configEditor, descriptors, source, log })
561
+ const compact = await migrateCompact({ settings, configEditor, descriptors, agentPresets, log })
562
+ const status = [subagent.status, compact.status].some((value) => value === 'error')
563
+ ? 'error'
564
+ : [subagent.status, compact.status].some((value) => value === 'pending')
565
+ ? 'pending'
566
+ : ['ambiguous', 'invalid', 'source-missing'].includes(compact.status) ? 'blocked'
567
+ : 'complete'
568
+ for (const [name, section] of [['subagent', subagent], ['compact', compact]]) {
569
+ if (section.error !== undefined) {
570
+ log?.warn?.(`apollo-migration: ${name} is ${section.status}: ${section.error}`)
571
+ }
572
+ }
573
+ log?.[status === 'complete' ? 'info' : 'warn']?.(
574
+ `apollo-migration: ${status} (subagent=${subagent.status}, compact=${compact.status})`,
575
+ )
576
+ return { status, source: sourceDescriptor === undefined ? 'apply-config' : 'settings', subagent, compact }
577
+ }
578
+
579
+ /**
580
+ * Mount the migration after Apollo's `settings` service appears.
581
+ *
582
+ * @param ctx - plugin context.
583
+ * @param config - Apollo row config (fallback source when describe() is not ready).
584
+ * @param options - `{ legacyImport, env, timeoutMs, intervalMs }`.
585
+ */
586
+ export function mountMigration(ctx, config, options = {}) {
587
+ let started = false
588
+ const start = (settingsCtx) => {
589
+ if (started) return
590
+ started = true
591
+ void runMigration(settingsCtx, {
592
+ config,
593
+ legacyImport: options.legacyImport,
594
+ timeoutMs: options.timeoutMs,
595
+ intervalMs: options.intervalMs,
596
+ log: settingsCtx.logger ?? ctx.logger,
597
+ }).catch((error) => {
598
+ settingsCtx.logger?.warn?.(`apollo-migration: skipped: ${errorMessage(error)}`)
599
+ })
600
+ }
601
+ ctx.inject(['settings'], start)
602
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Apollo's only remaining host-web helper: the `declareOwnsHost` index tap.
3
+ *
4
+ * Why it still lives in Apollo: the flag lets the official client treat a
5
+ * reverse-proxied page as loopback, which is what makes the *host* settings
6
+ * mirror writable. It is a deployment flag of Apollo's own row
7
+ * (`declareOwnsHost`, off by default), not a subagent or compaction concern —
8
+ * dsh-better-subagent and dsh-better-compact intentionally removed their own
9
+ * trust switches, and Apollo must not turn its flag into a trust bypass for
10
+ * them.
11
+ *
12
+ * The old capability/model/route probes and the `/apollo/api/*` handlers were
13
+ * deleted in 0.5.0. dsh-better-subagent owns those routes (canonical
14
+ * `/better-subagent/api/*` plus the `/apollo/api/*` read aliases), and Apollo
15
+ * must not register the same prefix again: webServer throws on a duplicate
16
+ * `(kind, path)` route.
17
+ *
18
+ * Zero third-party dependencies.
19
+ */
20
+
21
+ import { normalizeConfig } from './config.js'
22
+
23
+ /**
24
+ * Deployment-flag tap: inject the boot global `__DSH_TRANSPORT__.ownsHost` at
25
+ * the start of `<head>` so the official client's `connection.isLoopback` is
26
+ * true.
27
+ *
28
+ * Background: for a non-loopback browser page the client sets the settings
29
+ * mirror/scope to memory (unusable); there is no official trusted-proxy
30
+ * setting. When reaching the GUI through a reverse proxy, declaring that the
31
+ * page owns the host restores host-backed settings writes — the security
32
+ * boundary follows the proxy trust, so only enable it behind a trusted proxy
33
+ * (`declareOwnsHost: true`, default off). The script sits before every other
34
+ * head injection and runs before boot.
35
+ * @param html - rendered index HTML.
36
+ * @returns HTML with the transport global inserted.
37
+ */
38
+ export function declareOwnsHostTap(html) {
39
+ const script = '<script>globalThis.__DSH_TRANSPORT__={ownsHost:true}</script>'
40
+ return /<head(?:\s[^>]*)?>/i.test(html)
41
+ ? html.replace(/<head(?:\s[^>]*)?>/i, (open) => `${open}${script}`)
42
+ : `${script}${html}`
43
+ }
44
+
45
+ /** Plugin contexts that already installed the tap (mountSettings may run twice). */
46
+ const tappedContexts = new WeakSet()
47
+
48
+ /**
49
+ * Install the `declareOwnsHost` tap when the row config enables it (once per
50
+ * plugin context).
51
+ *
52
+ * Called by `src/settings.js`'s `mountSettings`; headless compositions without
53
+ * a webServer simply never resolve the injection.
54
+ * @param ctx - plugin context.
55
+ * @param config - row config (volatile references allowed).
56
+ */
57
+ export function installOwnsHostTap(ctx, config) {
58
+ if (normalizeConfig(config).declareOwnsHost !== true) return
59
+ if (ctx !== null && typeof ctx === 'object') {
60
+ if (tappedContexts.has(ctx)) return
61
+ tappedContexts.add(ctx)
62
+ }
63
+ ctx.inject(['webServer'], (webCtx) => {
64
+ webCtx.effect(() => webCtx.webServer.tapIndex(declareOwnsHostTap), 'apollo: declareOwnsHost tap')
65
+ })
66
+ }