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.
- package/CHANGELOG.md +319 -308
- package/CHANGELOG.zh-CN.md +318 -307
- package/INSTALL.md +94 -94
- package/INSTALL.zh-CN.md +93 -93
- package/LICENSE +21 -21
- package/README.md +336 -327
- package/README.zh-CN.md +336 -327
- package/client.js +1818 -1818
- package/cordis.patch.yml +44 -44
- package/docs/en/architecture.md +172 -172
- package/docs/en/checks.md +184 -184
- package/docs/en/installation.md +151 -151
- package/docs/en/permissions.md +161 -161
- package/docs/en/scenarios.md +202 -202
- package/docs/en/tools.md +665 -665
- package/docs/en/troubleshooting.md +131 -131
- package/docs/zh/architecture.md +172 -172
- package/docs/zh/checks.md +184 -184
- package/docs/zh/installation.md +151 -151
- package/docs/zh/permissions.md +161 -161
- package/docs/zh/scenarios.md +202 -202
- package/docs/zh/tools.md +665 -665
- package/docs/zh/troubleshooting.md +131 -131
- package/examples/prompts.en.md +206 -206
- package/examples/prompts.zh-CN.md +206 -206
- package/examples/waivers.example.json +20 -20
- package/icon.svg +6 -6
- package/index.js +139 -139
- package/lib/auth.js +181 -181
- package/lib/client.js +496 -496
- package/lib/code-check.js +549 -549
- package/lib/config.js +323 -323
- package/lib/contract-history.js +49 -49
- package/lib/contract.js +583 -583
- package/lib/csharp-check.js +493 -493
- package/lib/data-migrate.js +1318 -1318
- package/lib/data-policy.js +78 -78
- package/lib/error-codes.js +80 -80
- package/lib/gitlab.js +161 -161
- package/lib/health.js +205 -205
- package/lib/migrate.js +732 -732
- package/lib/preflight.js +116 -116
- package/lib/refs.js +191 -191
- package/lib/resources.js +320 -320
- package/lib/retention-defaults.js +17 -17
- package/lib/retention.js +384 -384
- package/lib/runtime.js +232 -232
- package/lib/settings-http.js +512 -512
- package/lib/settings-store.js +371 -371
- package/lib/snapshot.js +149 -149
- package/lib/tool-code-check.js +133 -133
- package/lib/tool-configure.js +169 -169
- package/lib/tool-contract.js +376 -376
- package/lib/tool-data-sync.js +559 -559
- package/lib/tool-data-write.js +203 -203
- package/lib/tool-data.js +282 -282
- package/lib/tool-diff.js +484 -484
- package/lib/tool-environments.js +106 -106
- package/lib/tool-flows.js +540 -540
- package/lib/tool-health.js +127 -127
- package/lib/tool-layers.js +497 -497
- package/lib/tool-metadata.js +417 -417
- package/lib/tool-migrate.js +102 -102
- package/lib/tool-refs.js +206 -206
- package/lib/tool-reports.js +299 -299
- package/lib/tool-sync.js +430 -430
- package/lib/tool-write.js +563 -563
- package/lib/waivers.js +83 -83
- package/lib/write-actions.js +1374 -1374
- package/lib/write-policy.js +199 -199
- package/lib/write-store.js +167 -167
- package/locale/en.json +68 -68
- package/locale/zh.json +68 -68
- package/package.json +2 -2
- 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
|
+
}
|