dsh-d365-kit 0.34.1 → 0.34.4

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.
Files changed (75) hide show
  1. package/CHANGELOG.md +319 -308
  2. package/CHANGELOG.zh-CN.md +318 -307
  3. package/INSTALL.md +94 -94
  4. package/INSTALL.zh-CN.md +93 -93
  5. package/LICENSE +21 -21
  6. package/README.md +336 -327
  7. package/README.zh-CN.md +336 -327
  8. package/client.js +1818 -1818
  9. package/cordis.patch.yml +44 -44
  10. package/docs/en/architecture.md +172 -172
  11. package/docs/en/checks.md +184 -184
  12. package/docs/en/installation.md +151 -151
  13. package/docs/en/permissions.md +161 -161
  14. package/docs/en/scenarios.md +202 -202
  15. package/docs/en/tools.md +665 -665
  16. package/docs/en/troubleshooting.md +131 -131
  17. package/docs/zh/architecture.md +172 -172
  18. package/docs/zh/checks.md +184 -184
  19. package/docs/zh/installation.md +151 -151
  20. package/docs/zh/permissions.md +161 -161
  21. package/docs/zh/scenarios.md +202 -202
  22. package/docs/zh/tools.md +665 -665
  23. package/docs/zh/troubleshooting.md +131 -131
  24. package/examples/prompts.en.md +206 -206
  25. package/examples/prompts.zh-CN.md +206 -206
  26. package/examples/waivers.example.json +20 -20
  27. package/icon.svg +6 -6
  28. package/index.js +139 -139
  29. package/lib/auth.js +181 -181
  30. package/lib/client.js +496 -496
  31. package/lib/code-check.js +549 -549
  32. package/lib/config.js +323 -323
  33. package/lib/contract-history.js +49 -49
  34. package/lib/contract.js +583 -583
  35. package/lib/csharp-check.js +493 -493
  36. package/lib/data-migrate.js +1318 -1318
  37. package/lib/data-policy.js +78 -78
  38. package/lib/error-codes.js +80 -80
  39. package/lib/gitlab.js +161 -161
  40. package/lib/health.js +205 -205
  41. package/lib/migrate.js +732 -732
  42. package/lib/preflight.js +116 -116
  43. package/lib/refs.js +191 -191
  44. package/lib/resources.js +320 -320
  45. package/lib/retention-defaults.js +17 -17
  46. package/lib/retention.js +384 -384
  47. package/lib/runtime.js +232 -232
  48. package/lib/settings-http.js +512 -512
  49. package/lib/settings-store.js +371 -371
  50. package/lib/snapshot.js +149 -149
  51. package/lib/tool-code-check.js +133 -133
  52. package/lib/tool-configure.js +169 -169
  53. package/lib/tool-contract.js +376 -376
  54. package/lib/tool-data-sync.js +559 -559
  55. package/lib/tool-data-write.js +203 -203
  56. package/lib/tool-data.js +282 -282
  57. package/lib/tool-diff.js +484 -484
  58. package/lib/tool-environments.js +106 -106
  59. package/lib/tool-flows.js +540 -540
  60. package/lib/tool-health.js +127 -127
  61. package/lib/tool-layers.js +497 -497
  62. package/lib/tool-metadata.js +417 -417
  63. package/lib/tool-migrate.js +102 -102
  64. package/lib/tool-refs.js +206 -206
  65. package/lib/tool-reports.js +299 -299
  66. package/lib/tool-sync.js +430 -430
  67. package/lib/tool-write.js +563 -563
  68. package/lib/waivers.js +83 -83
  69. package/lib/write-actions.js +1374 -1374
  70. package/lib/write-policy.js +199 -199
  71. package/lib/write-store.js +167 -167
  72. package/locale/en.json +68 -68
  73. package/locale/zh.json +68 -68
  74. package/package.json +2 -2
  75. package/skills/d365-troubleshooting/SKILL.md +115 -115
package/lib/tool-write.js CHANGED
@@ -1,563 +1,563 @@
1
- /**
2
- * `d365_write` — the agent's write surface.
3
- *
4
- * Planning is free; executing needs a person. `execute` opens a question card in
5
- * the conversation and waits for the answer, so the human approval happens in
6
- * the chat itself. The settings page stays available for the same confirm step.
7
- */
8
- import { DataverseClient } from './client.js'
9
- import { loadConfig } from './config.js'
10
- import { D365Error } from './errors.js'
11
- import { busyError, checkEnvironmentIdle } from './preflight.js'
12
- import { CAPABILITIES, allows, describePolicy, readPolicy } from './write-policy.js'
13
- import { deletePlan, listPlans } from './write-store.js'
14
- import {
15
- executePlan,
16
- planAddAttribute,
17
- planOptionAdd,
18
- planOptionDelete,
19
- planOptionRelabel,
20
- planRemoveLayer,
21
- planDeleteAttribute,
22
- planUpdateAttribute,
23
- planUpdateEntity,
24
- planViewColumns,
25
- savePlan,
26
- } from './write-actions.js'
27
-
28
- const APPROVE = '批准执行'
29
- const CANCEL = '取消'
30
- const CONFIRM_ID = 'd365-write-confirm'
31
-
32
- const ACTIONS = [
33
- 'policy',
34
- 'plan-add-options',
35
- 'plan-relabel',
36
- 'plan-option-delete',
37
- 'plan-add-attribute',
38
- 'plan-update-attribute',
39
- 'plan-delete-attribute',
40
- 'plan-remove-layer',
41
- 'plan-update-entity',
42
- 'plan-view-columns',
43
- 'list',
44
- 'execute',
45
- 'cancel',
46
- ]
47
-
48
- export const definition = {
49
- name: 'd365_write',
50
- description:
51
- '准备并执行 D365 元数据变更:选项集增/改/删值(全局或字段本地)、新增字段、改字段显示名、删除字段、改表显示名、调整系统视图的列、移除组件最上面的自定义层。'
52
- + 'plan-* 只生成计划、不写入;execute 会在对话里弹出确认卡片,必须由人点「批准执行」才会真正写入。'
53
- + '可用的环境与能力受设置页「权限」控制。',
54
- parameters: {
55
- type: 'object',
56
- additionalProperties: false,
57
- properties: {
58
- action: {
59
- type: 'string',
60
- enum: ACTIONS,
61
- description:
62
- 'policy 看权限;plan-add-options 新增选项值;plan-relabel 改选项标签;'
63
- + 'plan-option-delete 删除选项值(高危,需 acknowledge);'
64
- + 'plan-add-attribute 新增字段;plan-update-attribute 改字段显示名;'
65
- + 'plan-delete-attribute 删除字段(高危,需 acknowledge);'
66
- + 'plan-remove-layer 移除组件最上面的未受管自定义层(高危,需 acknowledge,支持表/字段/选项集/表单/视图/流程/Web 资源/插件/自定义 API);'
67
- + 'plan-update-entity 改表显示名;'
68
- + 'plan-view-columns 增删系统视图的列;list 列待确认计划;execute 在对话里请求确认后执行;cancel 取消。默认 policy。',
69
- },
70
- environment: { type: 'string', description: '目标环境 key,例如 dev。' },
71
- optionSet: { type: 'string', description: '全局选项集名称,例如 contoso_taxcategoryos。字段本地选项集改用 entity + attribute。' },
72
- labels: {
73
- type: 'array',
74
- items: { type: 'string' },
75
- description: 'plan-add-options:要新增的选项,每项形如 "AD3|Andorra: Tax Register Number (NRT)"。已存在的代码会被跳过。',
76
- },
77
- updates: {
78
- type: 'array',
79
- items: {
80
- type: 'object',
81
- additionalProperties: false,
82
- properties: {
83
- value: { type: 'integer', description: '现有选项值。' },
84
- label: { type: 'string', description: '新标签。' },
85
- },
86
- },
87
- description: 'plan-relabel:要改标签的选项列表。',
88
- },
89
- values: {
90
- type: 'array',
91
- items: { type: 'integer' },
92
- description: 'plan-option-delete:要删除的选项值列表。',
93
- },
94
- view: {
95
- type: 'string',
96
- description: 'plan-view-columns:系统视图名称,例如「Active Accounts」。',
97
- },
98
- addColumns: {
99
- type: 'array',
100
- items: { type: 'string' },
101
- description: 'plan-view-columns:要加进视图的字段逻辑名。',
102
- },
103
- removeColumns: {
104
- type: 'array',
105
- items: { type: 'string' },
106
- description: 'plan-view-columns:要从视图里去掉的字段逻辑名。',
107
- },
108
- entity: {
109
- type: 'string',
110
- description: '表逻辑名,例如 account(字段、表、本地选项集相关操作需要)。',
111
- },
112
- attribute: {
113
- type: 'string',
114
- description: '字段逻辑名。plan-update-attribute / plan-delete-attribute 用;与 entity 一起也可指向字段的本地选项集。',
115
- },
116
- fieldType: {
117
- type: 'string',
118
- enum: ['string', 'memo', 'integer', 'decimal', 'money', 'boolean', 'datetime'],
119
- description: 'plan-add-attribute:字段类型,默认 string。',
120
- },
121
- schemaName: {
122
- type: 'string',
123
- description: 'plan-add-attribute:字段的 SchemaName,必须带发布者前缀,例如 new_TestField。',
124
- },
125
- displayName: {
126
- type: 'string',
127
- description: '显示名。新增字段时默认等于 schemaName;修改时只改传入的项。',
128
- },
129
- description: { type: 'string', description: '描述(可选)。' },
130
- maxLength: { type: 'integer', description: 'string/memo 的最大长度,默认 100 / 2000。' },
131
- precision: { type: 'integer', description: 'decimal/money 的小数位,默认 2。' },
132
- requiredLevel: {
133
- type: 'string',
134
- enum: ['None', 'Recommended', 'Required'],
135
- description: '必填级别,默认 None。',
136
- },
137
- language: {
138
- type: 'integer',
139
- description: '标签语言代码,默认 1033;中文环境可用 2052。',
140
- },
141
- acknowledge: {
142
- type: 'boolean',
143
- description: 'plan-delete-attribute / plan-remove-layer 必须显式传 true:这两个操作不可恢复。',
144
- },
145
- componentType: {
146
- type: 'string',
147
- enum: [
148
- 'Entity',
149
- 'Attribute',
150
- 'OptionSet',
151
- 'SavedQuery',
152
- 'Workflow',
153
- 'SystemForm',
154
- 'WebResource',
155
- 'PluginType',
156
- 'PluginAssembly',
157
- 'SdkMessageProcessingStep',
158
- 'CustomAPI',
159
- ],
160
- description: 'plan-remove-layer:组件类型,默认 Entity。和 d365_layers 的 componentType 一致(含 Entity 表、Attribute 字段、OptionSet 全局选项集、SystemForm 表单、SavedQuery 视图、Workflow 流程、WebResource、插件、CustomAPI)。',
161
- },
162
- component: {
163
- type: 'string',
164
- description: 'plan-remove-layer:要移除层的组件名(或 GUID)。Entity 用表逻辑名(如 account);Attribute 用字段逻辑名(需同时给 entity);OptionSet 用全局选项集名称;SystemForm/SavedQuery/Workflow/WebResource 用记录名称;PluginAssembly 用程序集名;PluginType 用类型全名;SdkMessageProcessingStep 用步骤名;CustomAPI 用唯一名。',
165
- },
166
- solutionUniqueName: {
167
- type: 'string',
168
- description: '承载改动的未受管解决方案唯一名。传了改动才能随解决方案导出。',
169
- },
170
- rationale: { type: 'string', description: '变更理由,会写进计划与变更记录。' },
171
- planId: { type: 'string', description: 'execute / cancel:目标计划 id。' },
172
- force: {
173
- type: 'boolean',
174
- description: 'execute:跳过"环境正在发布/导入"的执行前检查。只在确认那条发布记录是残留时才用。',
175
- },
176
- },
177
- },
178
- output: {
179
- schema: {
180
- type: 'object',
181
- additionalProperties: false,
182
- required: ['text'],
183
- properties: { text: { type: 'string' } },
184
- },
185
- render(_args, value) {
186
- return [{ type: 'text', text: value?.text ?? '' }]
187
- },
188
- },
189
- }
190
-
191
- function renderPolicy(policy, envKeys) {
192
- const { rows } = describePolicy(policy, envKeys)
193
- const lines = ['环境权限(在设置页「权限」里修改):', '']
194
- lines.push('| 环境 | 已授权能力 |')
195
- lines.push('| --- | --- |')
196
- for (const row of rows) {
197
- const grants = row.capabilities.length
198
- ? row.capabilities.map((key) => `${CAPABILITIES[key].label}(${key})`).join(';')
199
- : '只读'
200
- lines.push(`| ${row.environment} | ${grants} |`)
201
- }
202
- lines.push('')
203
- lines.push('可用能力:')
204
- let group
205
- for (const [key, value] of Object.entries(CAPABILITIES)) {
206
- if (value.group !== group) {
207
- group = value.group
208
- lines.push(`- ${group}`)
209
- }
210
- lines.push(` - ${key}:${value.label} — ${value.description}${value.risk === 'high' ? '(高危)' : ''}`)
211
- }
212
- if (policy.solutionHint) lines.push(`默认解决方案:${policy.solutionHint}`)
213
- return lines.join('\n')
214
- }
215
-
216
- const VERBS = {
217
- InsertOptionValue: '+',
218
- CreateAttribute: '+',
219
- UpdateOptionValue: '~',
220
- UpdateAttribute: '~',
221
- UpdateEntity: '~',
222
- UpdateViewLayout: '~',
223
- DeleteOptionValue: '!',
224
- DeleteAttribute: '!',
225
- RemoveActiveCustomizations: '−',
226
- }
227
-
228
- const DESTRUCTIVE = new Set(['DeleteOptionValue', 'DeleteAttribute', 'RemoveActiveCustomizations'])
229
-
230
- /** Where the plan writes: a global option set, a column's local one, or a table. */
231
- function planScope(plan) {
232
- if (plan.optionSetName) return `选项集:${plan.optionSetName}`
233
- if (plan.entity && plan.attribute) return `选项集:${plan.entity}.${plan.attribute}(本地)`
234
- if (plan.target) return `目标:${plan.target}`
235
- return null
236
- }
237
-
238
- /**
239
- * A layer removal is only understandable next to the stack it sits in: which
240
- * layer goes, and what becomes active below it.
241
- */
242
- function renderLayerStack(plan) {
243
- const layers = plan.before?.layers
244
- if (!Array.isArray(layers) || !layers.length) return null
245
- const lines = ['当前层栈(从下到上,最上面那层会被移除):']
246
- for (const layer of layers) {
247
- const name = /^active$/i.test(String(layer.solution ?? ''))
248
- ? 'Active(未受管)'
249
- : (layer.solution || '(系统层)')
250
- lines.push(`- ${layer.order}:${name}${layer.publisher ? `(${layer.publisher})` : ''}`)
251
- }
252
- lines.push(`移除后生效:${plan.before?.becomesActive ?? '系统定义'}`)
253
- return lines.join('\n')
254
- }
255
-
256
- function renderOperation(operation) {
257
- const verb = VERBS[operation.type] ?? '·'
258
- const text = operation.summary ?? `${operation.type} ${operation.value ?? ''}`.trim()
259
- return `${verb} ${text}`
260
- }
261
-
262
- function renderSkips(plan) {
263
- const lines = []
264
- for (const skip of plan.skipped ?? []) {
265
- if (skip.code !== undefined) lines.push(`= ${skip.code} 已存在(值 ${skip.value})`)
266
- else lines.push(`- ${skip.schemaName ?? skip.attribute ?? skip.entity ?? skip.value ?? ''} 跳过:${skip.reason ?? ''}`)
267
- }
268
- return lines
269
- }
270
-
271
- function renderPlan(plan) {
272
- const lines = [`计划已生成:${plan.id}`]
273
- lines.push(`环境:${plan.envKey}(${plan.envUrl})`)
274
- const scope = planScope(plan)
275
- if (scope) lines.push(scope)
276
- const stack = renderLayerStack(plan)
277
- if (stack) lines.push('', stack)
278
- lines.push(`操作:${plan.operations.length} 个${plan.skipped?.length ? `,跳过 ${plan.skipped.length} 个` : ''}`)
279
- lines.push('')
280
-
281
- for (const operation of plan.operations) lines.push(renderOperation(operation))
282
- lines.push(...renderSkips(plan))
283
- lines.push('')
284
- lines.push('这一步没有写入任何东西。可以说「执行这个计划」,我会弹出确认卡片;也可以到设置 →「D365 环境」→「待确认的写入」里点确认。')
285
- return lines.join('\n')
286
- }
287
-
288
- /**
289
- * The text of the in-chat confirmation card. This is the only place a person
290
- * sees exactly what will be written before approving, so it lists operations
291
- * rather than a count.
292
- */
293
- function describeForConfirmation(plan) {
294
- const lines = [`环境:${plan.envKey}(${plan.envUrl})`]
295
- const scope = planScope(plan)
296
- if (scope) lines.push(scope)
297
- lines.push(`操作:${plan.operations.length} 条`)
298
- for (const operation of plan.operations.slice(0, 10)) lines.push(renderOperation(operation))
299
- if (plan.operations.length > 10) lines.push(`… 其余 ${plan.operations.length - 10} 条`)
300
- if (plan.operations.some((operation) => DESTRUCTIVE.has(operation.type))) {
301
- lines.push('', '注意:包含不可恢复的操作(删除,或移除自定义层),删掉的内容无法找回。')
302
- }
303
- lines.push('', '确认后立即写入该环境,元数据变更无法自动回滚。')
304
- return lines.join('\n')
305
- }
306
-
307
- function renderExecuted(plan, result) {
308
- const lines = [`已执行计划 ${plan.id}:成功 ${result.results.length} 条,失败 ${result.failures.length} 条。`]
309
- if (result.failures.length) {
310
- lines.push('')
311
- for (const failure of result.failures) {
312
- lines.push(`! ${failure.operation?.summary ?? failure.operation?.type ?? '操作'}:${failure.error}`)
313
- }
314
- }
315
- if (result.publishState === 'running') {
316
- const scope = result.publishScope ?? { entities: [], optionSets: [] }
317
- const targets = [
318
- ...scope.entities,
319
- ...scope.optionSets.map((name) => `选项集 ${name}`),
320
- ]
321
- lines.push('', targets.length
322
- ? `已在后台发布:${targets.join('、')}。`
323
- : '已在后台发布全部组件,稍后生效。')
324
- } else {
325
- lines.push('', '没有需要发布的内容。')
326
- }
327
- if (result.preflight?.forced) lines.push('注意:已跳过执行前的环境发布检查(force)。')
328
- else if (result.preflight?.stale) {
329
- lines.push(`注意:环境里有 ${result.preflight.stale} 条更早仍未结束的发布记录,可能是残留,本次没有阻塞执行。`)
330
- }
331
- if (result.ledger) lines.push(`变更记录:${result.ledger}`)
332
- return lines.join('\n')
333
- }
334
-
335
- /** Shared tail for every planner: save and render, or explain the no-op. */
336
- async function finishPlan(config, plan, emptyText) {
337
- if (!plan.operations.length) return { text: emptyText }
338
- const saved = await savePlan(config, plan)
339
- return { text: renderPlan(saved) }
340
- }
341
-
342
- export function register(ctx, state) {
343
- return ctx.tools.register({
344
- ...definition,
345
- async execute(args = {}, exec) {
346
- const config = await loadConfig(state.rawConfig)
347
- const action = args.action ?? 'policy'
348
- const policy = await readPolicy(config.configFile)
349
-
350
- if (action === 'policy') {
351
- return { text: renderPolicy(policy, Object.keys(config.environments)) }
352
- }
353
-
354
- if (action === 'list') {
355
- const plans = await listPlans(config)
356
- if (!plans.length) return { text: '没有待确认的写入计划。' }
357
- const pending = plans.filter((plan) => (plan.status ?? 'pending') === 'pending')
358
- const lines = [`待确认的写入:${pending.length} 个计划${plans.length > pending.length ? `(另有 ${plans.length - pending.length} 个已执行或中断,需人工处理)` : ''}`, '']
359
- for (const plan of plans) {
360
- const state = (plan.status ?? 'pending') === 'pending'
361
- ? ''
362
- : plan.status === 'running' ? ' · 执行中断,先核对账本' : plan.status === 'failed' ? ' · 执行失败,先核对账本' : ' · 已执行'
363
- lines.push(`- ${plan.id}(${plan.envKey},${CAPABILITIES[plan.capability]?.label ?? plan.kind},${plan.operations.length} 个操作)${plan.title ?? ''}${state}`)
364
- }
365
- lines.push('', '说「执行 <id>」我会在对话里弹确认卡片;也可以到设置页「待确认的写入」里操作。')
366
- return { text: lines.join('\n') }
367
- }
368
-
369
- if (action === 'cancel') {
370
- const id = String(args.planId ?? '').trim()
371
- if (!id) throw new D365Error('cancel 需要 planId')
372
- const removed = await deletePlan(config, id)
373
- return { text: removed ? `已取消计划 ${id}。` : `没有找到计划 ${id}。` }
374
- }
375
-
376
- if (action === 'execute') {
377
- const id = String(args.planId ?? '').trim()
378
- if (!id) throw new D365Error('execute 需要 planId', { hint: '先用 list 查看待确认的计划,或直接让 agent 生成一个计划' })
379
- const plan = (await listPlans(config)).find((item) => item.id === id)
380
- if (!plan) throw new D365Error(`没有找到计划 ${id}`, { hint: '计划可能已经执行或取消;用 list 查看当前待确认列表' })
381
- if (plan.status !== 'pending') {
382
- const label = plan.status === 'running'
383
- ? '正在执行,或上次执行中途中断'
384
- : plan.status === 'failed' ? '上次执行失败' : '已经执行过'
385
- throw new D365Error(`计划 ${plan.id} ${label}`, {
386
- code: 'plan-not-pending',
387
- hint: '先核对 .d365/changes 里的执行记录;要再做一次,请取消该计划并重新生成,不要在原计划上重跑。',
388
- })
389
- }
390
- if (!allows(policy, plan.envKey, plan.capability)) {
391
- throw new D365Error(`环境 ${plan.envKey} 未授权 ${plan.capability}`, { hint: '在设置页「权限」里勾选后再试' })
392
- }
393
-
394
- // The approval is the whole point: without a live question surface there
395
- // is nobody to approve, so refuse instead of writing.
396
- const userQuestions = ctx.get('userQuestions')
397
- if (!userQuestions?.ask) {
398
- throw new D365Error('当前会话没有可用的确认界面', { hint: '请在设置页「待确认的写入」里执行这个计划' })
399
- }
400
- if (!exec?.agent) {
401
- throw new D365Error('execute 需要可交互的会话', { hint: '请在主对话里执行,或到设置页操作' })
402
- }
403
-
404
- // Ask about the environment *before* the confirmation card: there is no
405
- // point in somebody approving a write that Dataverse will reject because
406
- // a Microsoft solution import is holding the customization lock.
407
- const env = config.environments[plan.envKey]
408
- const probe = new DataverseClient(env, { timeoutMs: config.timeoutMs })
409
- const preflight = await checkEnvironmentIdle(probe, {
410
- windowMinutes: config.busyWindowMinutes,
411
- signal: exec.signal,
412
- })
413
- if (preflight.busy && args.force !== true) throw busyError(plan.envKey, preflight)
414
- const busyNote = preflight.stale?.length
415
- ? `注意:环境里有 ${preflight.stale.length} 条更早仍未结束的发布记录,可能是残留。`
416
- : ''
417
-
418
- const answer = await userQuestions.ask({
419
- questions: [{
420
- id: CONFIRM_ID,
421
- header: `确认写入 ${plan.envKey}`,
422
- question: `要在 ${plan.envKey} 执行这 ${plan.operations.length} 条元数据修改吗?`,
423
- detail: [describeForConfirmation(plan), busyNote].filter(Boolean).join('\n\n'),
424
- options: [
425
- { label: APPROVE, description: '立即写入该环境,并在后台发布' },
426
- { label: CANCEL, description: '不执行,计划继续留在待确认列表' },
427
- ],
428
- }],
429
- agent: exec.agent,
430
- ...(exec.signal ? { signal: exec.signal } : {}),
431
- wait: { callId: exec.callId },
432
- })
433
-
434
- const chosen = answer?.answers?.find((item) => item.id === CONFIRM_ID)?.selected?.[0]
435
- if (chosen !== APPROVE) {
436
- return { text: `未执行。计划 ${plan.id} 仍保留在待确认列表里,随时可以再确认或取消。` }
437
- }
438
-
439
- const result = await executePlan(config, plan, exec.signal, { force: args.force === true })
440
- await deletePlan(config, plan.id)
441
- return { text: renderExecuted(plan, result) }
442
- }
443
-
444
- const envKey = String(args.environment ?? '').trim()
445
- if (!envKey) throw new D365Error('需要 environment', { hint: `可用:${Object.keys(config.environments).join(', ')}` })
446
-
447
- const shared = {
448
- solutionUniqueName: args.solutionUniqueName ?? policy.solutionHint,
449
- rationale: args.rationale,
450
- requestedBy: args.requestedBy ?? 'agent',
451
- language: args.language,
452
- }
453
-
454
- if (action === 'plan-add-options') {
455
- const plan = await planOptionAdd(config, envKey, {
456
- ...shared,
457
- optionSetName: args.optionSet,
458
- entity: args.entity,
459
- attribute: args.attribute,
460
- labels: args.labels,
461
- }, exec?.signal)
462
- return finishPlan(config, plan, `没有需要新增的选项:${plan.skipped.length} 个代码都已存在。`)
463
- }
464
-
465
- if (action === 'plan-relabel') {
466
- const plan = await planOptionRelabel(config, envKey, {
467
- ...shared,
468
- optionSetName: args.optionSet,
469
- entity: args.entity,
470
- attribute: args.attribute,
471
- updates: args.updates,
472
- }, exec?.signal)
473
- return finishPlan(config, plan, '没有需要修改的标签:给出的值都不存在,或者标签与现有值相同。')
474
- }
475
-
476
- if (action === 'plan-option-delete') {
477
- const plan = await planOptionDelete(config, envKey, {
478
- ...shared,
479
- optionSetName: args.optionSet,
480
- entity: args.entity,
481
- attribute: args.attribute,
482
- values: args.values,
483
- acknowledge: args.acknowledge,
484
- }, exec?.signal)
485
- return finishPlan(config, plan, `没有生成操作:给出的值都已在选项集中不存在。`)
486
- }
487
-
488
- if (action === 'plan-view-columns') {
489
- const plan = await planViewColumns(config, envKey, {
490
- ...shared,
491
- view: args.view,
492
- entity: args.entity,
493
- addColumns: args.addColumns,
494
- removeColumns: args.removeColumns,
495
- }, exec?.signal)
496
- return finishPlan(config, plan, '没有需要调整的列:要加的都已存在,要删的都不存在。')
497
- }
498
-
499
- if (action === 'plan-add-attribute') {
500
- const plan = await planAddAttribute(config, envKey, {
501
- ...shared,
502
- entity: args.entity,
503
- schemaName: args.schemaName,
504
- displayName: args.displayName,
505
- type: args.fieldType,
506
- maxLength: args.maxLength,
507
- precision: args.precision,
508
- requiredLevel: args.requiredLevel,
509
- description: args.description,
510
- }, exec?.signal)
511
- return finishPlan(config, plan, `没有生成操作:字段 ${plan.target} 已存在。`)
512
- }
513
-
514
- if (action === 'plan-update-attribute') {
515
- const plan = await planUpdateAttribute(config, envKey, {
516
- ...shared,
517
- entity: args.entity,
518
- attribute: args.attribute,
519
- displayName: args.displayName,
520
- description: args.description,
521
- requiredLevel: args.requiredLevel,
522
- }, exec?.signal)
523
- return finishPlan(config, plan, `没有需要修改的内容:${plan.target} 的值与现值相同。`)
524
- }
525
-
526
- if (action === 'plan-delete-attribute') {
527
- const plan = await planDeleteAttribute(config, envKey, {
528
- ...shared,
529
- entity: args.entity,
530
- attribute: args.attribute,
531
- acknowledge: args.acknowledge,
532
- }, exec?.signal)
533
- return finishPlan(config, plan, `没有生成操作:字段 ${plan.target} 不存在。`)
534
- }
535
-
536
- if (action === 'plan-remove-layer') {
537
- const plan = await planRemoveLayer(config, envKey, {
538
- ...shared,
539
- componentType: args.componentType,
540
- component: args.component ?? (args.componentType === 'Attribute' ? args.attribute : args.entity),
541
- entity: args.entity,
542
- acknowledge: args.acknowledge,
543
- }, exec?.signal)
544
- return finishPlan(config, plan, `没有生成操作:${plan.target} 没有可移除的自定义层。`)
545
- }
546
-
547
- if (action === 'plan-update-entity') {
548
- const plan = await planUpdateEntity(config, envKey, {
549
- ...shared,
550
- entity: args.entity,
551
- displayName: args.displayName,
552
- description: args.description,
553
- }, exec?.signal)
554
- return finishPlan(config, plan, `没有需要修改的内容:表 ${plan.target} 的值与现值相同。`)
555
- }
556
-
557
- throw new D365Error(`未知 action:${action}`, { hint: `可用:${ACTIONS.join(' / ')}` })
558
- },
559
- presentCall(args = {}) {
560
- return { card: 'generic', title: `D365 写入:${args.action ?? 'policy'}`, kind: 'other', rawInput: args }
561
- },
562
- })
563
- }
1
+ /**
2
+ * `d365_write` — the agent's write surface.
3
+ *
4
+ * Planning is free; executing needs a person. `execute` opens a question card in
5
+ * the conversation and waits for the answer, so the human approval happens in
6
+ * the chat itself. The settings page stays available for the same confirm step.
7
+ */
8
+ import { DataverseClient } from './client.js'
9
+ import { loadConfig } from './config.js'
10
+ import { D365Error } from './errors.js'
11
+ import { busyError, checkEnvironmentIdle } from './preflight.js'
12
+ import { CAPABILITIES, allows, describePolicy, readPolicy } from './write-policy.js'
13
+ import { deletePlan, listPlans } from './write-store.js'
14
+ import {
15
+ executePlan,
16
+ planAddAttribute,
17
+ planOptionAdd,
18
+ planOptionDelete,
19
+ planOptionRelabel,
20
+ planRemoveLayer,
21
+ planDeleteAttribute,
22
+ planUpdateAttribute,
23
+ planUpdateEntity,
24
+ planViewColumns,
25
+ savePlan,
26
+ } from './write-actions.js'
27
+
28
+ const APPROVE = '批准执行'
29
+ const CANCEL = '取消'
30
+ const CONFIRM_ID = 'd365-write-confirm'
31
+
32
+ const ACTIONS = [
33
+ 'policy',
34
+ 'plan-add-options',
35
+ 'plan-relabel',
36
+ 'plan-option-delete',
37
+ 'plan-add-attribute',
38
+ 'plan-update-attribute',
39
+ 'plan-delete-attribute',
40
+ 'plan-remove-layer',
41
+ 'plan-update-entity',
42
+ 'plan-view-columns',
43
+ 'list',
44
+ 'execute',
45
+ 'cancel',
46
+ ]
47
+
48
+ export const definition = {
49
+ name: 'd365_write',
50
+ description:
51
+ '准备并执行 D365 元数据变更:选项集增/改/删值(全局或字段本地)、新增字段、改字段显示名、删除字段、改表显示名、调整系统视图的列、移除组件最上面的自定义层。'
52
+ + 'plan-* 只生成计划、不写入;execute 会在对话里弹出确认卡片,必须由人点「批准执行」才会真正写入。'
53
+ + '可用的环境与能力受设置页「权限」控制。',
54
+ parameters: {
55
+ type: 'object',
56
+ additionalProperties: false,
57
+ properties: {
58
+ action: {
59
+ type: 'string',
60
+ enum: ACTIONS,
61
+ description:
62
+ 'policy 看权限;plan-add-options 新增选项值;plan-relabel 改选项标签;'
63
+ + 'plan-option-delete 删除选项值(高危,需 acknowledge);'
64
+ + 'plan-add-attribute 新增字段;plan-update-attribute 改字段显示名;'
65
+ + 'plan-delete-attribute 删除字段(高危,需 acknowledge);'
66
+ + 'plan-remove-layer 移除组件最上面的未受管自定义层(高危,需 acknowledge,支持表/字段/选项集/表单/视图/流程/Web 资源/插件/自定义 API);'
67
+ + 'plan-update-entity 改表显示名;'
68
+ + 'plan-view-columns 增删系统视图的列;list 列待确认计划;execute 在对话里请求确认后执行;cancel 取消。默认 policy。',
69
+ },
70
+ environment: { type: 'string', description: '目标环境 key,例如 dev。' },
71
+ optionSet: { type: 'string', description: '全局选项集名称,例如 contoso_taxcategoryos。字段本地选项集改用 entity + attribute。' },
72
+ labels: {
73
+ type: 'array',
74
+ items: { type: 'string' },
75
+ description: 'plan-add-options:要新增的选项,每项形如 "AD3|Andorra: Tax Register Number (NRT)"。已存在的代码会被跳过。',
76
+ },
77
+ updates: {
78
+ type: 'array',
79
+ items: {
80
+ type: 'object',
81
+ additionalProperties: false,
82
+ properties: {
83
+ value: { type: 'integer', description: '现有选项值。' },
84
+ label: { type: 'string', description: '新标签。' },
85
+ },
86
+ },
87
+ description: 'plan-relabel:要改标签的选项列表。',
88
+ },
89
+ values: {
90
+ type: 'array',
91
+ items: { type: 'integer' },
92
+ description: 'plan-option-delete:要删除的选项值列表。',
93
+ },
94
+ view: {
95
+ type: 'string',
96
+ description: 'plan-view-columns:系统视图名称,例如「Active Accounts」。',
97
+ },
98
+ addColumns: {
99
+ type: 'array',
100
+ items: { type: 'string' },
101
+ description: 'plan-view-columns:要加进视图的字段逻辑名。',
102
+ },
103
+ removeColumns: {
104
+ type: 'array',
105
+ items: { type: 'string' },
106
+ description: 'plan-view-columns:要从视图里去掉的字段逻辑名。',
107
+ },
108
+ entity: {
109
+ type: 'string',
110
+ description: '表逻辑名,例如 account(字段、表、本地选项集相关操作需要)。',
111
+ },
112
+ attribute: {
113
+ type: 'string',
114
+ description: '字段逻辑名。plan-update-attribute / plan-delete-attribute 用;与 entity 一起也可指向字段的本地选项集。',
115
+ },
116
+ fieldType: {
117
+ type: 'string',
118
+ enum: ['string', 'memo', 'integer', 'decimal', 'money', 'boolean', 'datetime'],
119
+ description: 'plan-add-attribute:字段类型,默认 string。',
120
+ },
121
+ schemaName: {
122
+ type: 'string',
123
+ description: 'plan-add-attribute:字段的 SchemaName,必须带发布者前缀,例如 new_TestField。',
124
+ },
125
+ displayName: {
126
+ type: 'string',
127
+ description: '显示名。新增字段时默认等于 schemaName;修改时只改传入的项。',
128
+ },
129
+ description: { type: 'string', description: '描述(可选)。' },
130
+ maxLength: { type: 'integer', description: 'string/memo 的最大长度,默认 100 / 2000。' },
131
+ precision: { type: 'integer', description: 'decimal/money 的小数位,默认 2。' },
132
+ requiredLevel: {
133
+ type: 'string',
134
+ enum: ['None', 'Recommended', 'Required'],
135
+ description: '必填级别,默认 None。',
136
+ },
137
+ language: {
138
+ type: 'integer',
139
+ description: '标签语言代码,默认 1033;中文环境可用 2052。',
140
+ },
141
+ acknowledge: {
142
+ type: 'boolean',
143
+ description: 'plan-delete-attribute / plan-remove-layer 必须显式传 true:这两个操作不可恢复。',
144
+ },
145
+ componentType: {
146
+ type: 'string',
147
+ enum: [
148
+ 'Entity',
149
+ 'Attribute',
150
+ 'OptionSet',
151
+ 'SavedQuery',
152
+ 'Workflow',
153
+ 'SystemForm',
154
+ 'WebResource',
155
+ 'PluginType',
156
+ 'PluginAssembly',
157
+ 'SdkMessageProcessingStep',
158
+ 'CustomAPI',
159
+ ],
160
+ description: 'plan-remove-layer:组件类型,默认 Entity。和 d365_layers 的 componentType 一致(含 Entity 表、Attribute 字段、OptionSet 全局选项集、SystemForm 表单、SavedQuery 视图、Workflow 流程、WebResource、插件、CustomAPI)。',
161
+ },
162
+ component: {
163
+ type: 'string',
164
+ description: 'plan-remove-layer:要移除层的组件名(或 GUID)。Entity 用表逻辑名(如 account);Attribute 用字段逻辑名(需同时给 entity);OptionSet 用全局选项集名称;SystemForm/SavedQuery/Workflow/WebResource 用记录名称;PluginAssembly 用程序集名;PluginType 用类型全名;SdkMessageProcessingStep 用步骤名;CustomAPI 用唯一名。',
165
+ },
166
+ solutionUniqueName: {
167
+ type: 'string',
168
+ description: '承载改动的未受管解决方案唯一名。传了改动才能随解决方案导出。',
169
+ },
170
+ rationale: { type: 'string', description: '变更理由,会写进计划与变更记录。' },
171
+ planId: { type: 'string', description: 'execute / cancel:目标计划 id。' },
172
+ force: {
173
+ type: 'boolean',
174
+ description: 'execute:跳过"环境正在发布/导入"的执行前检查。只在确认那条发布记录是残留时才用。',
175
+ },
176
+ },
177
+ },
178
+ output: {
179
+ schema: {
180
+ type: 'object',
181
+ additionalProperties: false,
182
+ required: ['text'],
183
+ properties: { text: { type: 'string' } },
184
+ },
185
+ render(_args, value) {
186
+ return [{ type: 'text', text: value?.text ?? '' }]
187
+ },
188
+ },
189
+ }
190
+
191
+ function renderPolicy(policy, envKeys) {
192
+ const { rows } = describePolicy(policy, envKeys)
193
+ const lines = ['环境权限(在设置页「权限」里修改):', '']
194
+ lines.push('| 环境 | 已授权能力 |')
195
+ lines.push('| --- | --- |')
196
+ for (const row of rows) {
197
+ const grants = row.capabilities.length
198
+ ? row.capabilities.map((key) => `${CAPABILITIES[key].label}(${key})`).join(';')
199
+ : '只读'
200
+ lines.push(`| ${row.environment} | ${grants} |`)
201
+ }
202
+ lines.push('')
203
+ lines.push('可用能力:')
204
+ let group
205
+ for (const [key, value] of Object.entries(CAPABILITIES)) {
206
+ if (value.group !== group) {
207
+ group = value.group
208
+ lines.push(`- ${group}`)
209
+ }
210
+ lines.push(` - ${key}:${value.label} — ${value.description}${value.risk === 'high' ? '(高危)' : ''}`)
211
+ }
212
+ if (policy.solutionHint) lines.push(`默认解决方案:${policy.solutionHint}`)
213
+ return lines.join('\n')
214
+ }
215
+
216
+ const VERBS = {
217
+ InsertOptionValue: '+',
218
+ CreateAttribute: '+',
219
+ UpdateOptionValue: '~',
220
+ UpdateAttribute: '~',
221
+ UpdateEntity: '~',
222
+ UpdateViewLayout: '~',
223
+ DeleteOptionValue: '!',
224
+ DeleteAttribute: '!',
225
+ RemoveActiveCustomizations: '−',
226
+ }
227
+
228
+ const DESTRUCTIVE = new Set(['DeleteOptionValue', 'DeleteAttribute', 'RemoveActiveCustomizations'])
229
+
230
+ /** Where the plan writes: a global option set, a column's local one, or a table. */
231
+ function planScope(plan) {
232
+ if (plan.optionSetName) return `选项集:${plan.optionSetName}`
233
+ if (plan.entity && plan.attribute) return `选项集:${plan.entity}.${plan.attribute}(本地)`
234
+ if (plan.target) return `目标:${plan.target}`
235
+ return null
236
+ }
237
+
238
+ /**
239
+ * A layer removal is only understandable next to the stack it sits in: which
240
+ * layer goes, and what becomes active below it.
241
+ */
242
+ function renderLayerStack(plan) {
243
+ const layers = plan.before?.layers
244
+ if (!Array.isArray(layers) || !layers.length) return null
245
+ const lines = ['当前层栈(从下到上,最上面那层会被移除):']
246
+ for (const layer of layers) {
247
+ const name = /^active$/i.test(String(layer.solution ?? ''))
248
+ ? 'Active(未受管)'
249
+ : (layer.solution || '(系统层)')
250
+ lines.push(`- ${layer.order}:${name}${layer.publisher ? `(${layer.publisher})` : ''}`)
251
+ }
252
+ lines.push(`移除后生效:${plan.before?.becomesActive ?? '系统定义'}`)
253
+ return lines.join('\n')
254
+ }
255
+
256
+ function renderOperation(operation) {
257
+ const verb = VERBS[operation.type] ?? '·'
258
+ const text = operation.summary ?? `${operation.type} ${operation.value ?? ''}`.trim()
259
+ return `${verb} ${text}`
260
+ }
261
+
262
+ function renderSkips(plan) {
263
+ const lines = []
264
+ for (const skip of plan.skipped ?? []) {
265
+ if (skip.code !== undefined) lines.push(`= ${skip.code} 已存在(值 ${skip.value})`)
266
+ else lines.push(`- ${skip.schemaName ?? skip.attribute ?? skip.entity ?? skip.value ?? ''} 跳过:${skip.reason ?? ''}`)
267
+ }
268
+ return lines
269
+ }
270
+
271
+ function renderPlan(plan) {
272
+ const lines = [`计划已生成:${plan.id}`]
273
+ lines.push(`环境:${plan.envKey}(${plan.envUrl})`)
274
+ const scope = planScope(plan)
275
+ if (scope) lines.push(scope)
276
+ const stack = renderLayerStack(plan)
277
+ if (stack) lines.push('', stack)
278
+ lines.push(`操作:${plan.operations.length} 个${plan.skipped?.length ? `,跳过 ${plan.skipped.length} 个` : ''}`)
279
+ lines.push('')
280
+
281
+ for (const operation of plan.operations) lines.push(renderOperation(operation))
282
+ lines.push(...renderSkips(plan))
283
+ lines.push('')
284
+ lines.push('这一步没有写入任何东西。可以说「执行这个计划」,我会弹出确认卡片;也可以到设置 →「D365 环境」→「待确认的写入」里点确认。')
285
+ return lines.join('\n')
286
+ }
287
+
288
+ /**
289
+ * The text of the in-chat confirmation card. This is the only place a person
290
+ * sees exactly what will be written before approving, so it lists operations
291
+ * rather than a count.
292
+ */
293
+ function describeForConfirmation(plan) {
294
+ const lines = [`环境:${plan.envKey}(${plan.envUrl})`]
295
+ const scope = planScope(plan)
296
+ if (scope) lines.push(scope)
297
+ lines.push(`操作:${plan.operations.length} 条`)
298
+ for (const operation of plan.operations.slice(0, 10)) lines.push(renderOperation(operation))
299
+ if (plan.operations.length > 10) lines.push(`… 其余 ${plan.operations.length - 10} 条`)
300
+ if (plan.operations.some((operation) => DESTRUCTIVE.has(operation.type))) {
301
+ lines.push('', '注意:包含不可恢复的操作(删除,或移除自定义层),删掉的内容无法找回。')
302
+ }
303
+ lines.push('', '确认后立即写入该环境,元数据变更无法自动回滚。')
304
+ return lines.join('\n')
305
+ }
306
+
307
+ function renderExecuted(plan, result) {
308
+ const lines = [`已执行计划 ${plan.id}:成功 ${result.results.length} 条,失败 ${result.failures.length} 条。`]
309
+ if (result.failures.length) {
310
+ lines.push('')
311
+ for (const failure of result.failures) {
312
+ lines.push(`! ${failure.operation?.summary ?? failure.operation?.type ?? '操作'}:${failure.error}`)
313
+ }
314
+ }
315
+ if (result.publishState === 'running') {
316
+ const scope = result.publishScope ?? { entities: [], optionSets: [] }
317
+ const targets = [
318
+ ...scope.entities,
319
+ ...scope.optionSets.map((name) => `选项集 ${name}`),
320
+ ]
321
+ lines.push('', targets.length
322
+ ? `已在后台发布:${targets.join('、')}。`
323
+ : '已在后台发布全部组件,稍后生效。')
324
+ } else {
325
+ lines.push('', '没有需要发布的内容。')
326
+ }
327
+ if (result.preflight?.forced) lines.push('注意:已跳过执行前的环境发布检查(force)。')
328
+ else if (result.preflight?.stale) {
329
+ lines.push(`注意:环境里有 ${result.preflight.stale} 条更早仍未结束的发布记录,可能是残留,本次没有阻塞执行。`)
330
+ }
331
+ if (result.ledger) lines.push(`变更记录:${result.ledger}`)
332
+ return lines.join('\n')
333
+ }
334
+
335
+ /** Shared tail for every planner: save and render, or explain the no-op. */
336
+ async function finishPlan(config, plan, emptyText) {
337
+ if (!plan.operations.length) return { text: emptyText }
338
+ const saved = await savePlan(config, plan)
339
+ return { text: renderPlan(saved) }
340
+ }
341
+
342
+ export function register(ctx, state) {
343
+ return ctx.tools.register({
344
+ ...definition,
345
+ async execute(args = {}, exec) {
346
+ const config = await loadConfig(state.rawConfig)
347
+ const action = args.action ?? 'policy'
348
+ const policy = await readPolicy(config.configFile)
349
+
350
+ if (action === 'policy') {
351
+ return { text: renderPolicy(policy, Object.keys(config.environments)) }
352
+ }
353
+
354
+ if (action === 'list') {
355
+ const plans = await listPlans(config)
356
+ if (!plans.length) return { text: '没有待确认的写入计划。' }
357
+ const pending = plans.filter((plan) => (plan.status ?? 'pending') === 'pending')
358
+ const lines = [`待确认的写入:${pending.length} 个计划${plans.length > pending.length ? `(另有 ${plans.length - pending.length} 个已执行或中断,需人工处理)` : ''}`, '']
359
+ for (const plan of plans) {
360
+ const state = (plan.status ?? 'pending') === 'pending'
361
+ ? ''
362
+ : plan.status === 'running' ? ' · 执行中断,先核对账本' : plan.status === 'failed' ? ' · 执行失败,先核对账本' : ' · 已执行'
363
+ lines.push(`- ${plan.id}(${plan.envKey},${CAPABILITIES[plan.capability]?.label ?? plan.kind},${plan.operations.length} 个操作)${plan.title ?? ''}${state}`)
364
+ }
365
+ lines.push('', '说「执行 <id>」我会在对话里弹确认卡片;也可以到设置页「待确认的写入」里操作。')
366
+ return { text: lines.join('\n') }
367
+ }
368
+
369
+ if (action === 'cancel') {
370
+ const id = String(args.planId ?? '').trim()
371
+ if (!id) throw new D365Error('cancel 需要 planId')
372
+ const removed = await deletePlan(config, id)
373
+ return { text: removed ? `已取消计划 ${id}。` : `没有找到计划 ${id}。` }
374
+ }
375
+
376
+ if (action === 'execute') {
377
+ const id = String(args.planId ?? '').trim()
378
+ if (!id) throw new D365Error('execute 需要 planId', { hint: '先用 list 查看待确认的计划,或直接让 agent 生成一个计划' })
379
+ const plan = (await listPlans(config)).find((item) => item.id === id)
380
+ if (!plan) throw new D365Error(`没有找到计划 ${id}`, { hint: '计划可能已经执行或取消;用 list 查看当前待确认列表' })
381
+ if (plan.status !== 'pending') {
382
+ const label = plan.status === 'running'
383
+ ? '正在执行,或上次执行中途中断'
384
+ : plan.status === 'failed' ? '上次执行失败' : '已经执行过'
385
+ throw new D365Error(`计划 ${plan.id} ${label}`, {
386
+ code: 'plan-not-pending',
387
+ hint: '先核对 .d365/changes 里的执行记录;要再做一次,请取消该计划并重新生成,不要在原计划上重跑。',
388
+ })
389
+ }
390
+ if (!allows(policy, plan.envKey, plan.capability)) {
391
+ throw new D365Error(`环境 ${plan.envKey} 未授权 ${plan.capability}`, { hint: '在设置页「权限」里勾选后再试' })
392
+ }
393
+
394
+ // The approval is the whole point: without a live question surface there
395
+ // is nobody to approve, so refuse instead of writing.
396
+ const userQuestions = ctx.get('userQuestions')
397
+ if (!userQuestions?.ask) {
398
+ throw new D365Error('当前会话没有可用的确认界面', { hint: '请在设置页「待确认的写入」里执行这个计划' })
399
+ }
400
+ if (!exec?.agent) {
401
+ throw new D365Error('execute 需要可交互的会话', { hint: '请在主对话里执行,或到设置页操作' })
402
+ }
403
+
404
+ // Ask about the environment *before* the confirmation card: there is no
405
+ // point in somebody approving a write that Dataverse will reject because
406
+ // a Microsoft solution import is holding the customization lock.
407
+ const env = config.environments[plan.envKey]
408
+ const probe = new DataverseClient(env, { timeoutMs: config.timeoutMs })
409
+ const preflight = await checkEnvironmentIdle(probe, {
410
+ windowMinutes: config.busyWindowMinutes,
411
+ signal: exec.signal,
412
+ })
413
+ if (preflight.busy && args.force !== true) throw busyError(plan.envKey, preflight)
414
+ const busyNote = preflight.stale?.length
415
+ ? `注意:环境里有 ${preflight.stale.length} 条更早仍未结束的发布记录,可能是残留。`
416
+ : ''
417
+
418
+ const answer = await userQuestions.ask({
419
+ questions: [{
420
+ id: CONFIRM_ID,
421
+ header: `确认写入 ${plan.envKey}`,
422
+ question: `要在 ${plan.envKey} 执行这 ${plan.operations.length} 条元数据修改吗?`,
423
+ detail: [describeForConfirmation(plan), busyNote].filter(Boolean).join('\n\n'),
424
+ options: [
425
+ { label: APPROVE, description: '立即写入该环境,并在后台发布' },
426
+ { label: CANCEL, description: '不执行,计划继续留在待确认列表' },
427
+ ],
428
+ }],
429
+ agent: exec.agent,
430
+ ...(exec.signal ? { signal: exec.signal } : {}),
431
+ wait: { callId: exec.callId },
432
+ })
433
+
434
+ const chosen = answer?.answers?.find((item) => item.id === CONFIRM_ID)?.selected?.[0]
435
+ if (chosen !== APPROVE) {
436
+ return { text: `未执行。计划 ${plan.id} 仍保留在待确认列表里,随时可以再确认或取消。` }
437
+ }
438
+
439
+ const result = await executePlan(config, plan, exec.signal, { force: args.force === true })
440
+ await deletePlan(config, plan.id)
441
+ return { text: renderExecuted(plan, result) }
442
+ }
443
+
444
+ const envKey = String(args.environment ?? '').trim()
445
+ if (!envKey) throw new D365Error('需要 environment', { hint: `可用:${Object.keys(config.environments).join(', ')}` })
446
+
447
+ const shared = {
448
+ solutionUniqueName: args.solutionUniqueName ?? policy.solutionHint,
449
+ rationale: args.rationale,
450
+ requestedBy: args.requestedBy ?? 'agent',
451
+ language: args.language,
452
+ }
453
+
454
+ if (action === 'plan-add-options') {
455
+ const plan = await planOptionAdd(config, envKey, {
456
+ ...shared,
457
+ optionSetName: args.optionSet,
458
+ entity: args.entity,
459
+ attribute: args.attribute,
460
+ labels: args.labels,
461
+ }, exec?.signal)
462
+ return finishPlan(config, plan, `没有需要新增的选项:${plan.skipped.length} 个代码都已存在。`)
463
+ }
464
+
465
+ if (action === 'plan-relabel') {
466
+ const plan = await planOptionRelabel(config, envKey, {
467
+ ...shared,
468
+ optionSetName: args.optionSet,
469
+ entity: args.entity,
470
+ attribute: args.attribute,
471
+ updates: args.updates,
472
+ }, exec?.signal)
473
+ return finishPlan(config, plan, '没有需要修改的标签:给出的值都不存在,或者标签与现有值相同。')
474
+ }
475
+
476
+ if (action === 'plan-option-delete') {
477
+ const plan = await planOptionDelete(config, envKey, {
478
+ ...shared,
479
+ optionSetName: args.optionSet,
480
+ entity: args.entity,
481
+ attribute: args.attribute,
482
+ values: args.values,
483
+ acknowledge: args.acknowledge,
484
+ }, exec?.signal)
485
+ return finishPlan(config, plan, `没有生成操作:给出的值都已在选项集中不存在。`)
486
+ }
487
+
488
+ if (action === 'plan-view-columns') {
489
+ const plan = await planViewColumns(config, envKey, {
490
+ ...shared,
491
+ view: args.view,
492
+ entity: args.entity,
493
+ addColumns: args.addColumns,
494
+ removeColumns: args.removeColumns,
495
+ }, exec?.signal)
496
+ return finishPlan(config, plan, '没有需要调整的列:要加的都已存在,要删的都不存在。')
497
+ }
498
+
499
+ if (action === 'plan-add-attribute') {
500
+ const plan = await planAddAttribute(config, envKey, {
501
+ ...shared,
502
+ entity: args.entity,
503
+ schemaName: args.schemaName,
504
+ displayName: args.displayName,
505
+ type: args.fieldType,
506
+ maxLength: args.maxLength,
507
+ precision: args.precision,
508
+ requiredLevel: args.requiredLevel,
509
+ description: args.description,
510
+ }, exec?.signal)
511
+ return finishPlan(config, plan, `没有生成操作:字段 ${plan.target} 已存在。`)
512
+ }
513
+
514
+ if (action === 'plan-update-attribute') {
515
+ const plan = await planUpdateAttribute(config, envKey, {
516
+ ...shared,
517
+ entity: args.entity,
518
+ attribute: args.attribute,
519
+ displayName: args.displayName,
520
+ description: args.description,
521
+ requiredLevel: args.requiredLevel,
522
+ }, exec?.signal)
523
+ return finishPlan(config, plan, `没有需要修改的内容:${plan.target} 的值与现值相同。`)
524
+ }
525
+
526
+ if (action === 'plan-delete-attribute') {
527
+ const plan = await planDeleteAttribute(config, envKey, {
528
+ ...shared,
529
+ entity: args.entity,
530
+ attribute: args.attribute,
531
+ acknowledge: args.acknowledge,
532
+ }, exec?.signal)
533
+ return finishPlan(config, plan, `没有生成操作:字段 ${plan.target} 不存在。`)
534
+ }
535
+
536
+ if (action === 'plan-remove-layer') {
537
+ const plan = await planRemoveLayer(config, envKey, {
538
+ ...shared,
539
+ componentType: args.componentType,
540
+ component: args.component ?? (args.componentType === 'Attribute' ? args.attribute : args.entity),
541
+ entity: args.entity,
542
+ acknowledge: args.acknowledge,
543
+ }, exec?.signal)
544
+ return finishPlan(config, plan, `没有生成操作:${plan.target} 没有可移除的自定义层。`)
545
+ }
546
+
547
+ if (action === 'plan-update-entity') {
548
+ const plan = await planUpdateEntity(config, envKey, {
549
+ ...shared,
550
+ entity: args.entity,
551
+ displayName: args.displayName,
552
+ description: args.description,
553
+ }, exec?.signal)
554
+ return finishPlan(config, plan, `没有需要修改的内容:表 ${plan.target} 的值与现值相同。`)
555
+ }
556
+
557
+ throw new D365Error(`未知 action:${action}`, { hint: `可用:${ACTIONS.join(' / ')}` })
558
+ },
559
+ presentCall(args = {}) {
560
+ return { card: 'generic', title: `D365 写入:${args.action ?? 'policy'}`, kind: 'other', rawInput: args }
561
+ },
562
+ })
563
+ }