@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/README.md +136 -130
- package/cordis.patch.yml +75 -42
- package/docs/DEVELOPMENT.md +281 -467
- package/package.json +7 -4
- package/presets/eng/plugins/exit-guard/index.js +39 -51
- package/presets/eng/plugins/mode-instructions/index.js +23 -59
- package/presets/eng-ptc.patch.yml +5 -9
- package/presets/eng.patch.yml +29 -36
- package/src/client.js +63 -912
- package/src/config.js +31 -10
- package/src/index.js +13 -7
- package/src/migrate.js +662 -0
- package/src/owns-host.js +66 -0
- package/src/settings.js +39 -35
- package/presets/eng/plugins/wait-subagent/index.js +0 -233
- package/presets/eng/plugins/wait-subagent/package.json +0 -7
- package/src/cli/claude.js +0 -166
- package/src/cli/codex.js +0 -164
- package/src/cli/jsonl.js +0 -216
- package/src/cli-adapter.js +0 -198
- package/src/cli-sessions.js +0 -69
- package/src/panel.js +0 -844
- package/src/subagents.js +0 -198
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
|
+
}
|