dsh-d365-kit 0.34.1 → 0.34.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/CHANGELOG.md +318 -308
  2. package/CHANGELOG.zh-CN.md +317 -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-flows.js CHANGED
@@ -1,540 +1,540 @@
1
- /**
2
- * `d365_flows` — look at Power Automate cloud flows from Dataverse.
3
- *
4
- * Cloud flows are rows of the `workflow` table with `category = 5` (the same
5
- * table that holds classic workflows, actions and business rules), so no new
6
- * endpoint or credential is involved. What is *not* readable from the row is
7
- * the shape of the flow: `primaryentity` is `none`, because a flow is not bound
8
- * to one table — the tables live inside `clientdata`, the flow definition JSON.
9
- *
10
- * This tool parses that JSON into the three answers people actually ask for:
11
- *
12
- * list which flows exist here, and are they on
13
- * show what one flow does — trigger, steps, connectors, tables
14
- * refs which flows touch this table
15
- *
16
- * The definition can be hundreds of kilobytes, so it goes to an artifact; the
17
- * conversation gets the skeleton. Run history is *not* here: it lives in the
18
- * Power Automate service, not in Dataverse.
19
- */
20
- import { loadConfig } from './config.js'
21
- import { formatTable, makeClient, pickEnvironments, writeArtifact } from './runtime.js'
22
- import { D365Error } from './errors.js'
23
-
24
- const ACTIONS = ['list', 'show', 'refs']
25
- const MODERN_FLOW_CATEGORY = 5
26
-
27
- // Trigger `message` values, from the Dataverse trigger's own picklist.
28
- const TRIGGER_MESSAGES = {
29
- 1: '创建',
30
- 2: '删除',
31
- 3: '更新',
32
- 4: '创建或更新',
33
- 5: '创建或删除',
34
- 6: '更新或删除',
35
- 7: '创建、更新或删除',
36
- }
37
-
38
- // Trigger `scope` values: where in the business unit tree the flow listens.
39
- const TRIGGER_SCOPES = {
40
- 1: '用户',
41
- 2: '业务部门',
42
- 3: '业务部门及子部门',
43
- 4: '组织',
44
- }
45
-
46
- // Connector ids are `shared_xxx`; a few appear often enough to name.
47
- const CONNECTOR_NAMES = {
48
- shared_commondataserviceforapps: 'Dataverse',
49
- shared_commondataservice: 'Dataverse(旧)',
50
- shared_office365: 'Office 365 Outlook',
51
- shared_office365users: 'Office 365 用户',
52
- shared_sharepointonline: 'SharePoint',
53
- shared_teams: 'Teams',
54
- shared_approvals: '审批',
55
- shared_azuread: 'Entra ID',
56
- shared_powerappsforappmakers: 'Power Apps',
57
- shared_http: 'HTTP',
58
- shared_sql: 'SQL Server',
59
- shared_logicapps: 'Logic Apps',
60
- shared_onedriveforbusiness: 'OneDrive',
61
- shared_excelonlinebusiness: 'Excel',
62
- shared_azureautomation: 'Azure Automation',
63
- }
64
-
65
- const definition = {
66
- name: 'd365_flows',
67
- title: 'D365 cloud flows',
68
- description:
69
- '查询 Power Automate 云流(Dataverse 里 category=5 的 workflow 行):目录、单条流程的骨架(触发器 / 步骤 / 连接器 / 涉及的表)、以及按表反查哪些流程会碰它。只读,走 Dataverse 现有认证。运行历史不在 Dataverse 里,这个工具查不到。',
70
- parameters: {
71
- type: 'object',
72
- additionalProperties: false,
73
- required: ['action'],
74
- properties: {
75
- action: {
76
- type: 'string',
77
- enum: ACTIONS,
78
- description: 'list 目录;show 一条流程的骨架;refs 哪些流程碰某张表。',
79
- },
80
- name: {
81
- type: 'string',
82
- description: 'action=show:流程名称(精确匹配,先 list 拿到)。',
83
- },
84
- flowId: {
85
- type: 'string',
86
- description: 'action=show:workflowid(GUID),名称有重名或含特殊字符时用它。',
87
- },
88
- entity: {
89
- type: 'string',
90
- description: 'action=refs:表逻辑名(如 account、contoso_determinationlogictable)。',
91
- },
92
- filter: {
93
- type: 'string',
94
- description: 'action=list:附加 OData $filter,例如 "statecode eq 1" 只看已开启的。',
95
- },
96
- top: {
97
- type: 'number',
98
- description: 'action=list 返回多少行(默认 50,最多 500)。',
99
- },
100
- scanLimit: {
101
- type: 'number',
102
- description: 'action=refs 最多扫描多少条流程(默认 1000,最多 2000)。流程多时会逐页读 clientdata。',
103
- },
104
- environment: {
105
- type: 'string',
106
- description: '环境 key,省略则用默认环境。',
107
- },
108
- },
109
- },
110
- output: {
111
- schema: {
112
- type: 'object',
113
- additionalProperties: false,
114
- required: ['text'],
115
- properties: { text: { type: 'string' } },
116
- },
117
- render(_args, value) {
118
- return [{ type: 'text', text: value?.text ?? '' }]
119
- },
120
- },
121
- }
122
-
123
- const quote = (value) => String(value ?? '').replace(/'/g, "''")
124
-
125
- /**
126
- * Entity names as they appear in a definition: the Dataverse connector uses
127
- * the entity *set* name (`accounts`), people type the logical name (`account`).
128
- * Matching has to accept both without mangling names that end in `s`.
129
- */
130
- function entityVariants(name) {
131
- const base = String(name ?? '').trim().toLowerCase()
132
- const variants = new Set([base])
133
- if (!base) return variants
134
- if (base.endsWith('ies')) variants.add(`${base.slice(0, -3)}y`)
135
- else if (/(ses|xes|ches|shes)$/.test(base)) variants.add(base.slice(0, -2))
136
- else if (base.endsWith('s') && !base.endsWith('ss')) variants.add(base.slice(0, -1))
137
- else if (base.endsWith('y')) variants.add(`${base.slice(0, -1)}ies`)
138
- else variants.add(`${base}s`)
139
- return variants
140
- }
141
-
142
- function sameEntity(a, b) {
143
- const left = entityVariants(a)
144
- for (const value of entityVariants(b)) {
145
- if (left.has(value)) return true
146
- }
147
- return false
148
- }
149
-
150
- /**
151
- * Every `entityName`-ish value under the parameters, at any depth.
152
- *
153
- * The connector nests them (`parameters.entityName`, `subscriptionRequest/entityname`,
154
- * `item/entityName` inside a record), and the key spelling differs between the
155
- * current and the legacy Dataverse connector, so walk instead of guessing.
156
- */
157
- function entityNamesFrom(parameters) {
158
- const found = new Set()
159
- const walk = (value, key) => {
160
- if (typeof value === 'string') {
161
- const name = String(key ?? '').toLowerCase()
162
- if (name.includes('entityname') || name === 'entity') found.add(value.trim().toLowerCase())
163
- return
164
- }
165
- if (Array.isArray(value)) {
166
- for (const item of value) walk(item, key)
167
- return
168
- }
169
- if (value && typeof value === 'object') {
170
- for (const [childKey, childValue] of Object.entries(value)) walk(childValue, childKey)
171
- }
172
- }
173
- walk(parameters, '')
174
- return [...found]
175
- }
176
-
177
- function connectorName(host) {
178
- const raw = String(host?.connectionName ?? host?.apiId ?? '').trim()
179
- if (!raw) return ''
180
- const id = raw.split('/').pop()
181
- return CONNECTOR_NAMES[id] ?? id
182
- }
183
-
184
- /** Parse `clientdata` into its definition and connection references. */
185
- function parseClientData(raw) {
186
- if (!raw) return undefined
187
- let data = raw
188
- if (typeof raw === 'string') {
189
- if (!raw.trim()) return undefined
190
- try {
191
- data = JSON.parse(raw)
192
- } catch {
193
- return undefined
194
- }
195
- }
196
- // Exports wrap the definition in `properties`; a hand-edited row may not.
197
- const properties = data?.properties ?? data
198
- return {
199
- definition: properties?.definition ?? {},
200
- connectionReferences: properties?.connectionReferences ?? {},
201
- parameters: properties?.parameters ?? {},
202
- }
203
- }
204
-
205
- function describeTrigger(parsed) {
206
- const entries = Object.entries(parsed?.definition?.triggers ?? {})
207
- const [name, trigger] = entries[0] ?? []
208
- if (!trigger) return { name: '—', kind: '没有触发器', entities: [], operation: '', detail: '' }
209
- const type = trigger.type ?? '—'
210
- const host = trigger.inputs?.host ?? {}
211
- const parameters = trigger.inputs?.parameters ?? {}
212
- const entities = entityNamesFrom(parameters)
213
- const operation = host.operationId ?? ''
214
-
215
- if (type === 'Recurrence') {
216
- const recurrence = trigger.recurrence ?? {}
217
- const every = [recurrence.frequency, recurrence.interval ? `每 ${recurrence.interval} 次` : '']
218
- .filter(Boolean)
219
- .join(' ')
220
- return { name, kind: `计划(${every || '未写频率'})`, entities, operation: 'Recurrence', detail: recurrence.startTime ?? '' }
221
- }
222
- if (type === 'Request') {
223
- return { name, kind: `手动 / HTTP(${trigger.kind ?? 'Http'})`, entities, operation: 'Request', detail: '' }
224
- }
225
-
226
- // Event triggers (Dataverse, SharePoint, …) carry their binding in
227
- // `subscriptionRequest/…` flattened keys.
228
- const message = Number(parameters['subscriptionRequest/message'] ?? parameters.message)
229
- const scope = Number(parameters['subscriptionRequest/scope'] ?? parameters.scope)
230
- const filterAttributes = parameters['subscriptionRequest/filteringattributes']
231
- // `filteringattributes` only says which columns are watched; the actual
232
- // condition lives in `filterexpression` (`statuscode eq 1 and …`).
233
- const filterExpression = parameters['subscriptionRequest/filterexpression']
234
- const detail = [
235
- TRIGGER_MESSAGES[message] ? `当记录${TRIGGER_MESSAGES[message]}` : '',
236
- TRIGGER_SCOPES[scope] ? `范围:${TRIGGER_SCOPES[scope]}` : '',
237
- filterAttributes ? `过滤列:${filterAttributes}` : '',
238
- filterExpression ? `条件:${filterExpression}` : '',
239
- ]
240
- .filter(Boolean)
241
- .join(',')
242
- const connector = connectorName(host)
243
- return {
244
- name,
245
- kind: `${connector || '连接器'}:${operation || type}`,
246
- entities,
247
- operation,
248
- connector,
249
- detail,
250
- }
251
- }
252
-
253
- function describeAction(action) {
254
- const type = action?.type ?? '—'
255
- const host = action?.inputs?.host ?? {}
256
- const parameters = action?.inputs?.parameters ?? {}
257
- const operation = host.operationId ?? action?.inputs?.operationId ?? ''
258
- const connector = connectorName(host)
259
- const entities = entityNamesFrom(parameters)
260
-
261
- let detail = ''
262
- if (type === 'Terminate') {
263
- const status = action?.inputs?.runStatus ?? ''
264
- const message = action?.inputs?.runError?.message ?? ''
265
- detail = [status, message].filter(Boolean).join(':')
266
- } else if (type === 'Http' || type === 'OpenApiConnection' || type === 'ApiConnection') {
267
- const uri = action?.inputs?.uri ?? parameters.uri ?? ''
268
- if (uri) detail = `URL:${String(uri).slice(0, 160)}`
269
- } else if (type === 'Compose') {
270
- const input = action?.inputs
271
- if (typeof input === 'string') detail = input.slice(0, 120)
272
- } else if (type === 'Workflow' || operation === 'RunAChildFlow') {
273
- detail = host.workflowReferenceName ? `子流程:${host.workflowReferenceName}` : '调用子流程'
274
- } else if (parameters.recordId || parameters.item) {
275
- detail = '按记录 id / 记录对象操作'
276
- }
277
-
278
- return { type, operation, connector, entities, detail }
279
- }
280
-
281
- /**
282
- * Flatten the action tree, including the branches that are easy to forget:
283
- * `else`, `cases` (switch) and the actions inside `foreach` / `until` / `scope`.
284
- */
285
- function flattenActions(actions, prefix = '', out = []) {
286
- for (const [name, action] of Object.entries(actions ?? {})) {
287
- const path = prefix ? `${prefix} > ${name}` : name
288
- out.push({ path, ...describeAction(action) })
289
- if (action?.actions) flattenActions(action.actions, path, out)
290
- if (action?.else?.actions) flattenActions(action.else.actions, `${path}(否则)`, out)
291
- for (const [caseName, branch] of Object.entries(action?.cases ?? {})) {
292
- if (branch?.actions) flattenActions(branch.actions, `${path}(${caseName})`, out)
293
- }
294
- }
295
- return out
296
- }
297
-
298
- function summarizeFlow(row) {
299
- const parsed = parseClientData(row.clientdata)
300
- if (!parsed) return undefined
301
- const trigger = describeTrigger(parsed)
302
- const steps = flattenActions(parsed.definition?.actions)
303
- const entities = new Set([...trigger.entities, ...steps.flatMap((step) => step.entities)])
304
- const connectors = new Set()
305
- if (trigger.connector) connectors.add(trigger.connector)
306
- for (const step of steps) if (step.connector) connectors.add(step.connector)
307
- return {
308
- trigger,
309
- steps,
310
- entities: [...entities],
311
- connectors: [...connectors],
312
- connectionReferences: Object.keys(parsed.connectionReferences ?? {}),
313
- }
314
- }
315
-
316
- function shortTime(value) {
317
- const text = String(value ?? '')
318
- return text.length >= 19 ? text.slice(0, 19).replace('T', ' ') : text || '—'
319
- }
320
-
321
- /**
322
- * Resolve owner ids to display names.
323
- *
324
- * `$expand=ownerid($select=fullname)` on `workflow` is refused by the server
325
- * (the lookup is polymorphic), so the ids are batched into a user query and a
326
- * team query instead: two requests for a whole page.
327
- */
328
- async function resolveOwnerNames(client, ids, signal) {
329
- const names = new Map()
330
- const unique = [...new Set(ids.filter(Boolean))]
331
- for (let index = 0; index < unique.length; index += 20) {
332
- const chunk = unique.slice(index, index + 20)
333
- const filter = chunk.map((id) => `systemuserid eq ${id}`).join(' or ')
334
- try {
335
- const page = await client.getPaged('systemusers', {
336
- $select: 'systemuserid,fullname',
337
- $filter: filter,
338
- __noTop: true,
339
- }, chunk.length, signal)
340
- for (const row of page.records ?? []) names.set(row.systemuserid, row.fullname)
341
- } catch {
342
- // Fall through to teams; an unreadable owner is cosmetic.
343
- }
344
- const teamFilter = chunk.map((id) => `teamid eq ${id}`).join(' or ')
345
- try {
346
- const page = await client.getPaged('teams', {
347
- $select: 'teamid,name',
348
- $filter: teamFilter,
349
- __noTop: true,
350
- }, chunk.length, signal)
351
- for (const row of page.records ?? []) names.set(row.teamid, row.name)
352
- } catch {
353
- // Same.
354
- }
355
- }
356
- return names
357
- }
358
-
359
- function stateLabel(statecode) {
360
- const value = Number(statecode)
361
- if (value === 1) return '开启'
362
- if (value === 0) return '关闭'
363
- return Number.isFinite(value) ? `statecode ${value}` : '—'
364
- }
365
-
366
- export function register(ctx, state) {
367
- return ctx.tools.register({
368
- ...definition,
369
- async execute(args, exec) {
370
- const config = await loadConfig(state.rawConfig)
371
- const action = args.action
372
- if (!ACTIONS.includes(action)) {
373
- throw new D365Error(`不认识的操作:${action}`, { code: 'unknown-action', hint: `可选:${ACTIONS.join(' / ')}` })
374
- }
375
- if (action === 'refs' && !String(args.entity ?? '').trim()) {
376
- throw new D365Error('action=refs 需要 entity', {
377
- code: 'missing-entity',
378
- hint: '传表的逻辑名,例如 {"action":"refs","entity":"account"}。',
379
- })
380
- }
381
- if (action === 'show' && !String(args.flowId ?? '').trim() && !String(args.name ?? '').trim()) {
382
- throw new D365Error('action=show 需要 name 或 flowId', {
383
- code: 'missing-flow',
384
- hint: '先跑 action=list,把流程名或 workflowid 传进来。',
385
- })
386
- }
387
-
388
- const envs = pickEnvironments(config, args)
389
- const reports = []
390
- const artifact = { action, environments: {} }
391
-
392
- for (const env of envs) {
393
- const client = makeClient(env, config)
394
-
395
- if (action === 'list') {
396
- const top = Number.isFinite(args.top) ? Math.max(1, Math.min(500, args.top)) : 50
397
- const parts = [`category eq ${MODERN_FLOW_CATEGORY}`]
398
- if (String(args.filter ?? '').trim()) parts.push(`(${String(args.filter).trim()})`)
399
- const base = {
400
- $select: 'workflowid,name,statecode,statuscode,ismanaged,modifiedon,type,_ownerid_value',
401
- $filter: parts.join(' and '),
402
- $orderby: 'modifiedon desc',
403
- }
404
- const page = await client.getPaged('workflows', base, top, exec?.signal)
405
- const rows = page.records ?? []
406
- const owners = await resolveOwnerNames(client, rows.map((row) => row._ownerid_value), exec?.signal)
407
- artifact.environments[env.key] = { rows, hasMore: page.hasMore, filter: base.$filter }
408
- const table = rows.length
409
- ? formatTable(rows.map((row) => ({
410
- name: row.name ?? '—',
411
- state: stateLabel(row.statecode),
412
- managed: row.ismanaged ? '是' : '否',
413
- owner: owners.get(row._ownerid_value) ?? (row._ownerid_value ? `${String(row._ownerid_value).slice(0, 8)}…` : '—'),
414
- modified: shortTime(row.modifiedon),
415
- })), ['name', 'state', 'owner', 'modified'], { width: 46 })
416
- : '(这个环境没有匹配的云流)'
417
- const head = rows.length
418
- ? `${env.key}:已列 ${rows.length} 条云流${page.hasMore ? '(已达上限,用 filter 或 top 缩小)' : ''}`
419
- : `${env.key}:没有匹配的云流`
420
- reports.push([head, table].join('\n'))
421
- continue
422
- }
423
-
424
- if (action === 'show') {
425
- const parts = [`category eq ${MODERN_FLOW_CATEGORY}`]
426
- if (String(args.flowId ?? '').trim()) parts.push(`workflowid eq ${String(args.flowId).trim()}`)
427
- else parts.push(`name eq '${quote(args.name)}'`)
428
- const page = await client.getPaged('workflows', {
429
- $select: 'workflowid,name,statecode,statuscode,ismanaged,modifiedon,createdon,type,clientdata',
430
- $filter: parts.join(' and '),
431
- }, 1, exec?.signal)
432
- const row = (page.records ?? [])[0]
433
- if (!row) {
434
- throw new D365Error(`${env.key} 里找不到这条云流`, {
435
- code: 'flow-not-found',
436
- hint: '用 action=list 确认名称(精确匹配,含空格与括号),或改用 flowId。',
437
- })
438
- }
439
- const summary = summarizeFlow(row)
440
- if (!summary) {
441
- throw new D365Error(`「${row.name}」的 clientdata 不是可解析的流程定义`, {
442
- code: 'bad-definition',
443
- hint: '这条记录可能是被停用或迁移失败留下的空壳;先看它的 updated 时间与 owner。',
444
- })
445
- }
446
- artifact.environments[env.key] = { row: { ...row, clientdata: undefined }, summary }
447
- const lines = [
448
- `${env.key}:流程「${row.name}」`,
449
- `- 状态:${stateLabel(row.statecode)}(statecode ${row.statecode ?? '—'})`,
450
- `- 托管:${row.ismanaged ? '是' : '否'},修改于 ${shortTime(row.modifiedon)}`,
451
- `- 触发器:${summary.trigger.kind}${summary.trigger.detail ? `(${summary.trigger.detail})` : ''}`,
452
- `- 动作:${summary.steps.length} 个`,
453
- `- 涉及的表:${summary.entities.length ? summary.entities.join('、') : '(没有明确写出表名)'}`,
454
- `- 连接器:${summary.connectors.length ? summary.connectors.join('、') : '—'}`,
455
- ]
456
- if (summary.connectionReferences.length) {
457
- lines.push(`- 连接引用:${summary.connectionReferences.join('、')}`)
458
- }
459
- const steps = summary.steps.length
460
- ? formatTable(summary.steps.slice(0, 40).map((step) => ({
461
- step: step.path,
462
- type: step.type,
463
- 'connector/operation': [step.connector, step.operation].filter(Boolean).join(' / ') || '—',
464
- entity: step.entities.join('、') || '—',
465
- detail: step.detail || '—',
466
- })), ['step', 'type', 'connector/operation', 'entity'], { width: 40 })
467
- : '(没有动作,只有触发器)'
468
- if (summary.steps.length > 40) {
469
- lines.push(`(只列了前 40 个动作,其余 ${summary.steps.length - 40} 个见完整结果)`)
470
- }
471
- reports.push([...lines, '', steps].join('\n'))
472
- continue
473
- }
474
-
475
- // refs: the definition is a memo column with no server-side query, so
476
- // every flow has to be read and scanned locally. `$top` must be left to
477
- // `getPaged` — Dataverse treats an explicit `$top` as the total, returns
478
- // exactly that many rows and no `@odata.nextLink`, which makes a capped
479
- // scan look complete.
480
- const scanLimit = Number.isFinite(args.scanLimit) ? Math.max(1, Math.min(2000, args.scanLimit)) : 1000
481
- const page = await client.getPaged('workflows', {
482
- $select: 'workflowid,name,statecode,clientdata',
483
- $filter: `category eq ${MODERN_FLOW_CATEGORY}`,
484
- $orderby: 'name asc',
485
- }, scanLimit, exec?.signal)
486
- const rows = page.records ?? []
487
- const hits = []
488
- let parsedCount = 0
489
- for (const row of rows) {
490
- const parsed = parseClientData(row.clientdata)
491
- if (!parsed) continue
492
- parsedCount += 1
493
- const trigger = describeTrigger(parsed)
494
- if (trigger.entities.some((name) => sameEntity(name, args.entity))) {
495
- hits.push({
496
- flow: row.name,
497
- state: stateLabel(row.statecode),
498
- where: `触发器:${trigger.name}`,
499
- operation: trigger.operation || trigger.kind,
500
- entity: trigger.entities.join('、'),
501
- })
502
- }
503
- for (const step of flattenActions(parsed.definition?.actions)) {
504
- if (!step.entities.some((name) => sameEntity(name, args.entity))) continue
505
- hits.push({
506
- flow: row.name,
507
- state: stateLabel(row.statecode),
508
- where: `动作:${step.path}`,
509
- operation: [step.connector, step.operation].filter(Boolean).join(' / ') || step.type,
510
- entity: step.entities.join('、'),
511
- })
512
- }
513
- }
514
- artifact.environments[env.key] = {
515
- entity: args.entity,
516
- scanned: rows.length,
517
- parsed: parsedCount,
518
- hasMore: page.hasMore,
519
- hits,
520
- }
521
- const flows = new Set(hits.map((hit) => hit.flow))
522
- const head = hits.length
523
- ? `${env.key}:${flows.size} 条云流会碰 ${args.entity}(扫描 ${parsedCount}/${rows.length} 条${page.hasMore ? `,已达 scanLimit ${scanLimit}` : ''})`
524
- : `${env.key}:扫描 ${parsedCount} 条云流,没有碰 ${args.entity} 的${page.hasMore ? `(只扫了前 ${scanLimit} 条)` : ''}`
525
- const table = hits.length
526
- ? formatTable(hits.map((hit) => ({
527
- flow: hit.flow,
528
- state: hit.state,
529
- where: hit.where,
530
- 'operation': hit.operation,
531
- })), ['flow', 'state', 'where', 'operation'], { width: 44 })
532
- : ''
533
- reports.push([head, table].filter(Boolean).join('\n'))
534
- }
535
-
536
- const file = await writeArtifact(config, `flows-${action}`, artifact)
537
- return { text: `${reports.join('\n\n')}\n\n完整结果:${file}` }
538
- },
539
- })
540
- }
1
+ /**
2
+ * `d365_flows` — look at Power Automate cloud flows from Dataverse.
3
+ *
4
+ * Cloud flows are rows of the `workflow` table with `category = 5` (the same
5
+ * table that holds classic workflows, actions and business rules), so no new
6
+ * endpoint or credential is involved. What is *not* readable from the row is
7
+ * the shape of the flow: `primaryentity` is `none`, because a flow is not bound
8
+ * to one table — the tables live inside `clientdata`, the flow definition JSON.
9
+ *
10
+ * This tool parses that JSON into the three answers people actually ask for:
11
+ *
12
+ * list which flows exist here, and are they on
13
+ * show what one flow does — trigger, steps, connectors, tables
14
+ * refs which flows touch this table
15
+ *
16
+ * The definition can be hundreds of kilobytes, so it goes to an artifact; the
17
+ * conversation gets the skeleton. Run history is *not* here: it lives in the
18
+ * Power Automate service, not in Dataverse.
19
+ */
20
+ import { loadConfig } from './config.js'
21
+ import { formatTable, makeClient, pickEnvironments, writeArtifact } from './runtime.js'
22
+ import { D365Error } from './errors.js'
23
+
24
+ const ACTIONS = ['list', 'show', 'refs']
25
+ const MODERN_FLOW_CATEGORY = 5
26
+
27
+ // Trigger `message` values, from the Dataverse trigger's own picklist.
28
+ const TRIGGER_MESSAGES = {
29
+ 1: '创建',
30
+ 2: '删除',
31
+ 3: '更新',
32
+ 4: '创建或更新',
33
+ 5: '创建或删除',
34
+ 6: '更新或删除',
35
+ 7: '创建、更新或删除',
36
+ }
37
+
38
+ // Trigger `scope` values: where in the business unit tree the flow listens.
39
+ const TRIGGER_SCOPES = {
40
+ 1: '用户',
41
+ 2: '业务部门',
42
+ 3: '业务部门及子部门',
43
+ 4: '组织',
44
+ }
45
+
46
+ // Connector ids are `shared_xxx`; a few appear often enough to name.
47
+ const CONNECTOR_NAMES = {
48
+ shared_commondataserviceforapps: 'Dataverse',
49
+ shared_commondataservice: 'Dataverse(旧)',
50
+ shared_office365: 'Office 365 Outlook',
51
+ shared_office365users: 'Office 365 用户',
52
+ shared_sharepointonline: 'SharePoint',
53
+ shared_teams: 'Teams',
54
+ shared_approvals: '审批',
55
+ shared_azuread: 'Entra ID',
56
+ shared_powerappsforappmakers: 'Power Apps',
57
+ shared_http: 'HTTP',
58
+ shared_sql: 'SQL Server',
59
+ shared_logicapps: 'Logic Apps',
60
+ shared_onedriveforbusiness: 'OneDrive',
61
+ shared_excelonlinebusiness: 'Excel',
62
+ shared_azureautomation: 'Azure Automation',
63
+ }
64
+
65
+ const definition = {
66
+ name: 'd365_flows',
67
+ title: 'D365 cloud flows',
68
+ description:
69
+ '查询 Power Automate 云流(Dataverse 里 category=5 的 workflow 行):目录、单条流程的骨架(触发器 / 步骤 / 连接器 / 涉及的表)、以及按表反查哪些流程会碰它。只读,走 Dataverse 现有认证。运行历史不在 Dataverse 里,这个工具查不到。',
70
+ parameters: {
71
+ type: 'object',
72
+ additionalProperties: false,
73
+ required: ['action'],
74
+ properties: {
75
+ action: {
76
+ type: 'string',
77
+ enum: ACTIONS,
78
+ description: 'list 目录;show 一条流程的骨架;refs 哪些流程碰某张表。',
79
+ },
80
+ name: {
81
+ type: 'string',
82
+ description: 'action=show:流程名称(精确匹配,先 list 拿到)。',
83
+ },
84
+ flowId: {
85
+ type: 'string',
86
+ description: 'action=show:workflowid(GUID),名称有重名或含特殊字符时用它。',
87
+ },
88
+ entity: {
89
+ type: 'string',
90
+ description: 'action=refs:表逻辑名(如 account、contoso_determinationlogictable)。',
91
+ },
92
+ filter: {
93
+ type: 'string',
94
+ description: 'action=list:附加 OData $filter,例如 "statecode eq 1" 只看已开启的。',
95
+ },
96
+ top: {
97
+ type: 'number',
98
+ description: 'action=list 返回多少行(默认 50,最多 500)。',
99
+ },
100
+ scanLimit: {
101
+ type: 'number',
102
+ description: 'action=refs 最多扫描多少条流程(默认 1000,最多 2000)。流程多时会逐页读 clientdata。',
103
+ },
104
+ environment: {
105
+ type: 'string',
106
+ description: '环境 key,省略则用默认环境。',
107
+ },
108
+ },
109
+ },
110
+ output: {
111
+ schema: {
112
+ type: 'object',
113
+ additionalProperties: false,
114
+ required: ['text'],
115
+ properties: { text: { type: 'string' } },
116
+ },
117
+ render(_args, value) {
118
+ return [{ type: 'text', text: value?.text ?? '' }]
119
+ },
120
+ },
121
+ }
122
+
123
+ const quote = (value) => String(value ?? '').replace(/'/g, "''")
124
+
125
+ /**
126
+ * Entity names as they appear in a definition: the Dataverse connector uses
127
+ * the entity *set* name (`accounts`), people type the logical name (`account`).
128
+ * Matching has to accept both without mangling names that end in `s`.
129
+ */
130
+ function entityVariants(name) {
131
+ const base = String(name ?? '').trim().toLowerCase()
132
+ const variants = new Set([base])
133
+ if (!base) return variants
134
+ if (base.endsWith('ies')) variants.add(`${base.slice(0, -3)}y`)
135
+ else if (/(ses|xes|ches|shes)$/.test(base)) variants.add(base.slice(0, -2))
136
+ else if (base.endsWith('s') && !base.endsWith('ss')) variants.add(base.slice(0, -1))
137
+ else if (base.endsWith('y')) variants.add(`${base.slice(0, -1)}ies`)
138
+ else variants.add(`${base}s`)
139
+ return variants
140
+ }
141
+
142
+ function sameEntity(a, b) {
143
+ const left = entityVariants(a)
144
+ for (const value of entityVariants(b)) {
145
+ if (left.has(value)) return true
146
+ }
147
+ return false
148
+ }
149
+
150
+ /**
151
+ * Every `entityName`-ish value under the parameters, at any depth.
152
+ *
153
+ * The connector nests them (`parameters.entityName`, `subscriptionRequest/entityname`,
154
+ * `item/entityName` inside a record), and the key spelling differs between the
155
+ * current and the legacy Dataverse connector, so walk instead of guessing.
156
+ */
157
+ function entityNamesFrom(parameters) {
158
+ const found = new Set()
159
+ const walk = (value, key) => {
160
+ if (typeof value === 'string') {
161
+ const name = String(key ?? '').toLowerCase()
162
+ if (name.includes('entityname') || name === 'entity') found.add(value.trim().toLowerCase())
163
+ return
164
+ }
165
+ if (Array.isArray(value)) {
166
+ for (const item of value) walk(item, key)
167
+ return
168
+ }
169
+ if (value && typeof value === 'object') {
170
+ for (const [childKey, childValue] of Object.entries(value)) walk(childValue, childKey)
171
+ }
172
+ }
173
+ walk(parameters, '')
174
+ return [...found]
175
+ }
176
+
177
+ function connectorName(host) {
178
+ const raw = String(host?.connectionName ?? host?.apiId ?? '').trim()
179
+ if (!raw) return ''
180
+ const id = raw.split('/').pop()
181
+ return CONNECTOR_NAMES[id] ?? id
182
+ }
183
+
184
+ /** Parse `clientdata` into its definition and connection references. */
185
+ function parseClientData(raw) {
186
+ if (!raw) return undefined
187
+ let data = raw
188
+ if (typeof raw === 'string') {
189
+ if (!raw.trim()) return undefined
190
+ try {
191
+ data = JSON.parse(raw)
192
+ } catch {
193
+ return undefined
194
+ }
195
+ }
196
+ // Exports wrap the definition in `properties`; a hand-edited row may not.
197
+ const properties = data?.properties ?? data
198
+ return {
199
+ definition: properties?.definition ?? {},
200
+ connectionReferences: properties?.connectionReferences ?? {},
201
+ parameters: properties?.parameters ?? {},
202
+ }
203
+ }
204
+
205
+ function describeTrigger(parsed) {
206
+ const entries = Object.entries(parsed?.definition?.triggers ?? {})
207
+ const [name, trigger] = entries[0] ?? []
208
+ if (!trigger) return { name: '—', kind: '没有触发器', entities: [], operation: '', detail: '' }
209
+ const type = trigger.type ?? '—'
210
+ const host = trigger.inputs?.host ?? {}
211
+ const parameters = trigger.inputs?.parameters ?? {}
212
+ const entities = entityNamesFrom(parameters)
213
+ const operation = host.operationId ?? ''
214
+
215
+ if (type === 'Recurrence') {
216
+ const recurrence = trigger.recurrence ?? {}
217
+ const every = [recurrence.frequency, recurrence.interval ? `每 ${recurrence.interval} 次` : '']
218
+ .filter(Boolean)
219
+ .join(' ')
220
+ return { name, kind: `计划(${every || '未写频率'})`, entities, operation: 'Recurrence', detail: recurrence.startTime ?? '' }
221
+ }
222
+ if (type === 'Request') {
223
+ return { name, kind: `手动 / HTTP(${trigger.kind ?? 'Http'})`, entities, operation: 'Request', detail: '' }
224
+ }
225
+
226
+ // Event triggers (Dataverse, SharePoint, …) carry their binding in
227
+ // `subscriptionRequest/…` flattened keys.
228
+ const message = Number(parameters['subscriptionRequest/message'] ?? parameters.message)
229
+ const scope = Number(parameters['subscriptionRequest/scope'] ?? parameters.scope)
230
+ const filterAttributes = parameters['subscriptionRequest/filteringattributes']
231
+ // `filteringattributes` only says which columns are watched; the actual
232
+ // condition lives in `filterexpression` (`statuscode eq 1 and …`).
233
+ const filterExpression = parameters['subscriptionRequest/filterexpression']
234
+ const detail = [
235
+ TRIGGER_MESSAGES[message] ? `当记录${TRIGGER_MESSAGES[message]}` : '',
236
+ TRIGGER_SCOPES[scope] ? `范围:${TRIGGER_SCOPES[scope]}` : '',
237
+ filterAttributes ? `过滤列:${filterAttributes}` : '',
238
+ filterExpression ? `条件:${filterExpression}` : '',
239
+ ]
240
+ .filter(Boolean)
241
+ .join(',')
242
+ const connector = connectorName(host)
243
+ return {
244
+ name,
245
+ kind: `${connector || '连接器'}:${operation || type}`,
246
+ entities,
247
+ operation,
248
+ connector,
249
+ detail,
250
+ }
251
+ }
252
+
253
+ function describeAction(action) {
254
+ const type = action?.type ?? '—'
255
+ const host = action?.inputs?.host ?? {}
256
+ const parameters = action?.inputs?.parameters ?? {}
257
+ const operation = host.operationId ?? action?.inputs?.operationId ?? ''
258
+ const connector = connectorName(host)
259
+ const entities = entityNamesFrom(parameters)
260
+
261
+ let detail = ''
262
+ if (type === 'Terminate') {
263
+ const status = action?.inputs?.runStatus ?? ''
264
+ const message = action?.inputs?.runError?.message ?? ''
265
+ detail = [status, message].filter(Boolean).join(':')
266
+ } else if (type === 'Http' || type === 'OpenApiConnection' || type === 'ApiConnection') {
267
+ const uri = action?.inputs?.uri ?? parameters.uri ?? ''
268
+ if (uri) detail = `URL:${String(uri).slice(0, 160)}`
269
+ } else if (type === 'Compose') {
270
+ const input = action?.inputs
271
+ if (typeof input === 'string') detail = input.slice(0, 120)
272
+ } else if (type === 'Workflow' || operation === 'RunAChildFlow') {
273
+ detail = host.workflowReferenceName ? `子流程:${host.workflowReferenceName}` : '调用子流程'
274
+ } else if (parameters.recordId || parameters.item) {
275
+ detail = '按记录 id / 记录对象操作'
276
+ }
277
+
278
+ return { type, operation, connector, entities, detail }
279
+ }
280
+
281
+ /**
282
+ * Flatten the action tree, including the branches that are easy to forget:
283
+ * `else`, `cases` (switch) and the actions inside `foreach` / `until` / `scope`.
284
+ */
285
+ function flattenActions(actions, prefix = '', out = []) {
286
+ for (const [name, action] of Object.entries(actions ?? {})) {
287
+ const path = prefix ? `${prefix} > ${name}` : name
288
+ out.push({ path, ...describeAction(action) })
289
+ if (action?.actions) flattenActions(action.actions, path, out)
290
+ if (action?.else?.actions) flattenActions(action.else.actions, `${path}(否则)`, out)
291
+ for (const [caseName, branch] of Object.entries(action?.cases ?? {})) {
292
+ if (branch?.actions) flattenActions(branch.actions, `${path}(${caseName})`, out)
293
+ }
294
+ }
295
+ return out
296
+ }
297
+
298
+ function summarizeFlow(row) {
299
+ const parsed = parseClientData(row.clientdata)
300
+ if (!parsed) return undefined
301
+ const trigger = describeTrigger(parsed)
302
+ const steps = flattenActions(parsed.definition?.actions)
303
+ const entities = new Set([...trigger.entities, ...steps.flatMap((step) => step.entities)])
304
+ const connectors = new Set()
305
+ if (trigger.connector) connectors.add(trigger.connector)
306
+ for (const step of steps) if (step.connector) connectors.add(step.connector)
307
+ return {
308
+ trigger,
309
+ steps,
310
+ entities: [...entities],
311
+ connectors: [...connectors],
312
+ connectionReferences: Object.keys(parsed.connectionReferences ?? {}),
313
+ }
314
+ }
315
+
316
+ function shortTime(value) {
317
+ const text = String(value ?? '')
318
+ return text.length >= 19 ? text.slice(0, 19).replace('T', ' ') : text || '—'
319
+ }
320
+
321
+ /**
322
+ * Resolve owner ids to display names.
323
+ *
324
+ * `$expand=ownerid($select=fullname)` on `workflow` is refused by the server
325
+ * (the lookup is polymorphic), so the ids are batched into a user query and a
326
+ * team query instead: two requests for a whole page.
327
+ */
328
+ async function resolveOwnerNames(client, ids, signal) {
329
+ const names = new Map()
330
+ const unique = [...new Set(ids.filter(Boolean))]
331
+ for (let index = 0; index < unique.length; index += 20) {
332
+ const chunk = unique.slice(index, index + 20)
333
+ const filter = chunk.map((id) => `systemuserid eq ${id}`).join(' or ')
334
+ try {
335
+ const page = await client.getPaged('systemusers', {
336
+ $select: 'systemuserid,fullname',
337
+ $filter: filter,
338
+ __noTop: true,
339
+ }, chunk.length, signal)
340
+ for (const row of page.records ?? []) names.set(row.systemuserid, row.fullname)
341
+ } catch {
342
+ // Fall through to teams; an unreadable owner is cosmetic.
343
+ }
344
+ const teamFilter = chunk.map((id) => `teamid eq ${id}`).join(' or ')
345
+ try {
346
+ const page = await client.getPaged('teams', {
347
+ $select: 'teamid,name',
348
+ $filter: teamFilter,
349
+ __noTop: true,
350
+ }, chunk.length, signal)
351
+ for (const row of page.records ?? []) names.set(row.teamid, row.name)
352
+ } catch {
353
+ // Same.
354
+ }
355
+ }
356
+ return names
357
+ }
358
+
359
+ function stateLabel(statecode) {
360
+ const value = Number(statecode)
361
+ if (value === 1) return '开启'
362
+ if (value === 0) return '关闭'
363
+ return Number.isFinite(value) ? `statecode ${value}` : '—'
364
+ }
365
+
366
+ export function register(ctx, state) {
367
+ return ctx.tools.register({
368
+ ...definition,
369
+ async execute(args, exec) {
370
+ const config = await loadConfig(state.rawConfig)
371
+ const action = args.action
372
+ if (!ACTIONS.includes(action)) {
373
+ throw new D365Error(`不认识的操作:${action}`, { code: 'unknown-action', hint: `可选:${ACTIONS.join(' / ')}` })
374
+ }
375
+ if (action === 'refs' && !String(args.entity ?? '').trim()) {
376
+ throw new D365Error('action=refs 需要 entity', {
377
+ code: 'missing-entity',
378
+ hint: '传表的逻辑名,例如 {"action":"refs","entity":"account"}。',
379
+ })
380
+ }
381
+ if (action === 'show' && !String(args.flowId ?? '').trim() && !String(args.name ?? '').trim()) {
382
+ throw new D365Error('action=show 需要 name 或 flowId', {
383
+ code: 'missing-flow',
384
+ hint: '先跑 action=list,把流程名或 workflowid 传进来。',
385
+ })
386
+ }
387
+
388
+ const envs = pickEnvironments(config, args)
389
+ const reports = []
390
+ const artifact = { action, environments: {} }
391
+
392
+ for (const env of envs) {
393
+ const client = makeClient(env, config)
394
+
395
+ if (action === 'list') {
396
+ const top = Number.isFinite(args.top) ? Math.max(1, Math.min(500, args.top)) : 50
397
+ const parts = [`category eq ${MODERN_FLOW_CATEGORY}`]
398
+ if (String(args.filter ?? '').trim()) parts.push(`(${String(args.filter).trim()})`)
399
+ const base = {
400
+ $select: 'workflowid,name,statecode,statuscode,ismanaged,modifiedon,type,_ownerid_value',
401
+ $filter: parts.join(' and '),
402
+ $orderby: 'modifiedon desc',
403
+ }
404
+ const page = await client.getPaged('workflows', base, top, exec?.signal)
405
+ const rows = page.records ?? []
406
+ const owners = await resolveOwnerNames(client, rows.map((row) => row._ownerid_value), exec?.signal)
407
+ artifact.environments[env.key] = { rows, hasMore: page.hasMore, filter: base.$filter }
408
+ const table = rows.length
409
+ ? formatTable(rows.map((row) => ({
410
+ name: row.name ?? '—',
411
+ state: stateLabel(row.statecode),
412
+ managed: row.ismanaged ? '是' : '否',
413
+ owner: owners.get(row._ownerid_value) ?? (row._ownerid_value ? `${String(row._ownerid_value).slice(0, 8)}…` : '—'),
414
+ modified: shortTime(row.modifiedon),
415
+ })), ['name', 'state', 'owner', 'modified'], { width: 46 })
416
+ : '(这个环境没有匹配的云流)'
417
+ const head = rows.length
418
+ ? `${env.key}:已列 ${rows.length} 条云流${page.hasMore ? '(已达上限,用 filter 或 top 缩小)' : ''}`
419
+ : `${env.key}:没有匹配的云流`
420
+ reports.push([head, table].join('\n'))
421
+ continue
422
+ }
423
+
424
+ if (action === 'show') {
425
+ const parts = [`category eq ${MODERN_FLOW_CATEGORY}`]
426
+ if (String(args.flowId ?? '').trim()) parts.push(`workflowid eq ${String(args.flowId).trim()}`)
427
+ else parts.push(`name eq '${quote(args.name)}'`)
428
+ const page = await client.getPaged('workflows', {
429
+ $select: 'workflowid,name,statecode,statuscode,ismanaged,modifiedon,createdon,type,clientdata',
430
+ $filter: parts.join(' and '),
431
+ }, 1, exec?.signal)
432
+ const row = (page.records ?? [])[0]
433
+ if (!row) {
434
+ throw new D365Error(`${env.key} 里找不到这条云流`, {
435
+ code: 'flow-not-found',
436
+ hint: '用 action=list 确认名称(精确匹配,含空格与括号),或改用 flowId。',
437
+ })
438
+ }
439
+ const summary = summarizeFlow(row)
440
+ if (!summary) {
441
+ throw new D365Error(`「${row.name}」的 clientdata 不是可解析的流程定义`, {
442
+ code: 'bad-definition',
443
+ hint: '这条记录可能是被停用或迁移失败留下的空壳;先看它的 updated 时间与 owner。',
444
+ })
445
+ }
446
+ artifact.environments[env.key] = { row: { ...row, clientdata: undefined }, summary }
447
+ const lines = [
448
+ `${env.key}:流程「${row.name}」`,
449
+ `- 状态:${stateLabel(row.statecode)}(statecode ${row.statecode ?? '—'})`,
450
+ `- 托管:${row.ismanaged ? '是' : '否'},修改于 ${shortTime(row.modifiedon)}`,
451
+ `- 触发器:${summary.trigger.kind}${summary.trigger.detail ? `(${summary.trigger.detail})` : ''}`,
452
+ `- 动作:${summary.steps.length} 个`,
453
+ `- 涉及的表:${summary.entities.length ? summary.entities.join('、') : '(没有明确写出表名)'}`,
454
+ `- 连接器:${summary.connectors.length ? summary.connectors.join('、') : '—'}`,
455
+ ]
456
+ if (summary.connectionReferences.length) {
457
+ lines.push(`- 连接引用:${summary.connectionReferences.join('、')}`)
458
+ }
459
+ const steps = summary.steps.length
460
+ ? formatTable(summary.steps.slice(0, 40).map((step) => ({
461
+ step: step.path,
462
+ type: step.type,
463
+ 'connector/operation': [step.connector, step.operation].filter(Boolean).join(' / ') || '—',
464
+ entity: step.entities.join('、') || '—',
465
+ detail: step.detail || '—',
466
+ })), ['step', 'type', 'connector/operation', 'entity'], { width: 40 })
467
+ : '(没有动作,只有触发器)'
468
+ if (summary.steps.length > 40) {
469
+ lines.push(`(只列了前 40 个动作,其余 ${summary.steps.length - 40} 个见完整结果)`)
470
+ }
471
+ reports.push([...lines, '', steps].join('\n'))
472
+ continue
473
+ }
474
+
475
+ // refs: the definition is a memo column with no server-side query, so
476
+ // every flow has to be read and scanned locally. `$top` must be left to
477
+ // `getPaged` — Dataverse treats an explicit `$top` as the total, returns
478
+ // exactly that many rows and no `@odata.nextLink`, which makes a capped
479
+ // scan look complete.
480
+ const scanLimit = Number.isFinite(args.scanLimit) ? Math.max(1, Math.min(2000, args.scanLimit)) : 1000
481
+ const page = await client.getPaged('workflows', {
482
+ $select: 'workflowid,name,statecode,clientdata',
483
+ $filter: `category eq ${MODERN_FLOW_CATEGORY}`,
484
+ $orderby: 'name asc',
485
+ }, scanLimit, exec?.signal)
486
+ const rows = page.records ?? []
487
+ const hits = []
488
+ let parsedCount = 0
489
+ for (const row of rows) {
490
+ const parsed = parseClientData(row.clientdata)
491
+ if (!parsed) continue
492
+ parsedCount += 1
493
+ const trigger = describeTrigger(parsed)
494
+ if (trigger.entities.some((name) => sameEntity(name, args.entity))) {
495
+ hits.push({
496
+ flow: row.name,
497
+ state: stateLabel(row.statecode),
498
+ where: `触发器:${trigger.name}`,
499
+ operation: trigger.operation || trigger.kind,
500
+ entity: trigger.entities.join('、'),
501
+ })
502
+ }
503
+ for (const step of flattenActions(parsed.definition?.actions)) {
504
+ if (!step.entities.some((name) => sameEntity(name, args.entity))) continue
505
+ hits.push({
506
+ flow: row.name,
507
+ state: stateLabel(row.statecode),
508
+ where: `动作:${step.path}`,
509
+ operation: [step.connector, step.operation].filter(Boolean).join(' / ') || step.type,
510
+ entity: step.entities.join('、'),
511
+ })
512
+ }
513
+ }
514
+ artifact.environments[env.key] = {
515
+ entity: args.entity,
516
+ scanned: rows.length,
517
+ parsed: parsedCount,
518
+ hasMore: page.hasMore,
519
+ hits,
520
+ }
521
+ const flows = new Set(hits.map((hit) => hit.flow))
522
+ const head = hits.length
523
+ ? `${env.key}:${flows.size} 条云流会碰 ${args.entity}(扫描 ${parsedCount}/${rows.length} 条${page.hasMore ? `,已达 scanLimit ${scanLimit}` : ''})`
524
+ : `${env.key}:扫描 ${parsedCount} 条云流,没有碰 ${args.entity} 的${page.hasMore ? `(只扫了前 ${scanLimit} 条)` : ''}`
525
+ const table = hits.length
526
+ ? formatTable(hits.map((hit) => ({
527
+ flow: hit.flow,
528
+ state: hit.state,
529
+ where: hit.where,
530
+ 'operation': hit.operation,
531
+ })), ['flow', 'state', 'where', 'operation'], { width: 44 })
532
+ : ''
533
+ reports.push([head, table].filter(Boolean).join('\n'))
534
+ }
535
+
536
+ const file = await writeArtifact(config, `flows-${action}`, artifact)
537
+ return { text: `${reports.join('\n\n')}\n\n完整结果:${file}` }
538
+ },
539
+ })
540
+ }