aitable-workflow-cli 0.1.23 → 0.1.24

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 (77) hide show
  1. package/README.md +1 -6
  2. package/dist/{chunk-IGB6WDLR.js → chunk-UNPV6DTX.js} +1 -1
  3. package/dist/cli.js +47 -47
  4. package/dist/config-set-FBOQBRSJ.js +13 -0
  5. package/dist/{config-ui-server-CORDAOYI.js → config-ui-server-IUSACAOT.js} +1 -1
  6. package/package.json +3 -3
  7. package/dist/config-set-EGLLIMVJ.js +0 -13
  8. package/templates/support-qa/.env.example +0 -16
  9. package/templates/support-qa/aitable-workflow.config.yml +0 -46
  10. package/templates/support-qa/coding-agents.config.json +0 -18
  11. package/templates/support-qa/lib/__tests__/declarative-matcher.test.ts +0 -240
  12. package/templates/support-qa/lib/__tests__/dingtalk-group-membership.test.ts +0 -144
  13. package/templates/support-qa/lib/__tests__/memory-events.test.ts +0 -90
  14. package/templates/support-qa/lib/__tests__/sandbox-agent.test.ts +0 -382
  15. package/templates/support-qa/lib/__tests__/thread-adapter.test.ts +0 -242
  16. package/templates/support-qa/lib/__tests__/thread-observe-step.test.ts +0 -391
  17. package/templates/support-qa/lib/__tests__/thread-observer-handler.test.ts +0 -274
  18. package/templates/support-qa/lib/action-dispatch.ts +0 -262
  19. package/templates/support-qa/lib/contract.ts +0 -253
  20. package/templates/support-qa/lib/declarative-matcher.ts +0 -398
  21. package/templates/support-qa/lib/dingtalk-group-membership.ts +0 -158
  22. package/templates/support-qa/lib/guards.ts +0 -18
  23. package/templates/support-qa/lib/memory-events.ts +0 -113
  24. package/templates/support-qa/lib/owner-resolver.ts +0 -94
  25. package/templates/support-qa/lib/preflight.ts +0 -294
  26. package/templates/support-qa/lib/profile.ts +0 -51
  27. package/templates/support-qa/lib/sandbox-agent.ts +0 -653
  28. package/templates/support-qa/lib/thread-adapter.ts +0 -281
  29. package/templates/support-qa/lib/thread-digest.ts +0 -144
  30. package/templates/support-qa/npmrc +0 -1
  31. package/templates/support-qa/package.json +0 -27
  32. package/templates/support-qa/profiles/aitable/profile/escalation/contacts.json +0 -37
  33. package/templates/support-qa/profiles/aitable/profile/escalation/owner-map.json +0 -18
  34. package/templates/support-qa/profiles/aitable/profile/fastpath/known-answers.yml +0 -561
  35. package/templates/support-qa/profiles/aitable/profile/profile.yml +0 -219
  36. package/templates/support-qa/profiles/aitable/profile/prompts/kb-editor.md +0 -3
  37. package/templates/support-qa/profiles/aitable/profile/prompts/persona.md +0 -49
  38. package/templates/support-qa/profiles/aitable/profile/prompts/troubleshooter.md +0 -5
  39. package/templates/support-qa/profiles/aitable/profile/troubleshoot/classifiers.yml +0 -36
  40. package/templates/support-qa/profiles/aitable/skills/troubleshooting/SKILL.md +0 -58
  41. package/templates/support-qa/profiles/aitable/skills/troubleshooting/aitable-import-troubleshooter/SKILL.md +0 -485
  42. package/templates/support-qa/profiles/aitable/skills/troubleshooting/aitable-openapi-troubleshooter/SKILL.md +0 -183
  43. package/templates/support-qa/profiles/aitable/skills/troubleshooting/import-openapi-e2e-troubleshooter/SKILL.md +0 -459
  44. package/templates/support-qa/profiles/aitable/skills/troubleshooting/notable-datasource-troubleshooter/SKILL.md +0 -1286
  45. package/templates/support-qa/profiles/aitable/skills/troubleshooting/notable-field-troubleshooter/SKILL.md +0 -673
  46. package/templates/support-qa/profiles/aitable/skills/troubleshooting/spreadsheet-datasync-troubleshooter/SKILL.md +0 -322
  47. package/templates/support-qa/profiles/default/profile/fastpath/known-answers.yml +0 -32
  48. package/templates/support-qa/profiles/default/profile/profile.yml +0 -217
  49. package/templates/support-qa/profiles/default/profile/troubleshoot/classifiers.yml +0 -37
  50. package/templates/support-qa/profiles/default/wiki/support//345/270/270/350/247/201/351/227/256/351/242/230.md +0 -10
  51. package/templates/support-qa/tsconfig.json +0 -16
  52. package/templates/support-qa/vitest.config.ts +0 -9
  53. package/templates/support-qa/workflows/admin-kb-update/recipes/kb-refine.recipe.ts +0 -137
  54. package/templates/support-qa/workflows/admin-kb-update/recipes/kb_sync_handler.recipe.ts +0 -119
  55. package/templates/support-qa/workflows/admin-kb-update/workflow.yml +0 -96
  56. package/templates/support-qa/workflows/config-sync/recipes/config_sync.recipe.ts +0 -668
  57. package/templates/support-qa/workflows/config-sync/recipes/config_sync_handler.recipe.ts +0 -16
  58. package/templates/support-qa/workflows/config-sync/workflow.yml +0 -62
  59. package/templates/support-qa/workflows/daily-review/recipes/daily_review.recipe.ts +0 -602
  60. package/templates/support-qa/workflows/daily-review/recipes/daily_review_handler.recipe.ts +0 -26
  61. package/templates/support-qa/workflows/daily-review/workflow.yml +0 -65
  62. package/templates/support-qa/workflows/group-qa/recipes/answer.recipe.ts +0 -231
  63. package/templates/support-qa/workflows/group-qa/recipes/conversation_handler.recipe.ts +0 -394
  64. package/templates/support-qa/workflows/group-qa/recipes/fastpath.recipe.ts +0 -72
  65. package/templates/support-qa/workflows/group-qa/recipes/on-action.recipe.ts +0 -225
  66. package/templates/support-qa/workflows/group-qa/recipes/reply-to-group.recipe.ts +0 -202
  67. package/templates/support-qa/workflows/group-qa/recipes/troubleshoot.recipe.ts +0 -273
  68. package/templates/support-qa/workflows/group-qa/workflow.yml +0 -268
  69. package/templates/support-qa/workflows/thread-observer/README.md +0 -96
  70. package/templates/support-qa/workflows/thread-observer/recipes/thread_observe.recipe.ts +0 -415
  71. package/templates/support-qa/workflows/thread-observer/recipes/thread_observer_handler.recipe.ts +0 -213
  72. package/templates/support-qa/workflows/thread-observer/workflow.yml +0 -185
  73. package/templates/support-qa/workspaces/daily-review/AGENTS.md +0 -233
  74. package/templates/support-qa/workspaces/kb-editor/AGENTS.md +0 -32
  75. package/templates/support-qa/workspaces/support/AGENTS.md +0 -156
  76. package/templates/support-qa/workspaces/thread-observer/AGENTS.md +0 -156
  77. package/templates/support-qa/workspaces/troubleshooter/AGENTS.md +0 -71
@@ -1,668 +0,0 @@
1
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
- import path from "node:path";
3
- import { z } from "zod";
4
- import { DwsClient, normalizeByType, tableRefConfigSchema, globalVarRefPath, mapWithConcurrency, resolveContactsByUserIds, type CommandRunner, type RecipeDefinition, type RecipeContext, type RecipeResult } from "aitable-workflow-core";
5
- import { normalizeModuleOwners, type ModuleOwners } from "../../../lib/owner-resolver.js";
6
-
7
- /**
8
- * dws 命令统一走 core 的 DwsClient(而非自建 execFile):拿到退避重试(消化 dws 后端
9
- * 偶发 TIMEOUT / 伪 AUTH_PERMISSION_DENIED)与连续失败升级告警,并让 success=false /
10
- * degraded=true 的响应直接抛错 —— 本 recipe 的三个拉取块都包在 try/catch 里降级为
11
- * 「保留旧值」,比静默同步一份空快照安全。
12
- */
13
- const dws = new DwsClient({ bin: "dws", timeoutMs: 30_000 });
14
-
15
- const dwsRunner: CommandRunner = async (_bin, args, opts) => {
16
- try {
17
- const result = await dws.runJson(args, { timeoutMs: opts?.timeoutMs });
18
- return { exitCode: 0, stdout: JSON.stringify(result), stderr: "" };
19
- } catch (e: any) {
20
- return { exitCode: e?.exitCode ?? 1, stdout: "", stderr: e?.message ?? String(e) };
21
- }
22
- };
23
-
24
- /** 从配置取 globalVar key:接受 "${globalVar.x}" 引用(解析出 x)或裸 key;空则回退默认。 */
25
- function varKey(raw: unknown, fallback: string): string {
26
- const s = String(raw ?? "").trim();
27
- return globalVarRefPath(s) ?? (s || fallback);
28
- }
29
-
30
- type Cells = Record<string, unknown>;
31
- type Rec = { recordId: string; cells: Cells };
32
- export type GroupConfig = {
33
- groupId: string;
34
- groupName?: string;
35
- corpId?: string;
36
- inbound?: string;
37
- outbound?: string;
38
- webhookUrl?: string;
39
- webhookSecret?: string;
40
- appKey?: string;
41
- appSecret?: string;
42
- /** config-sync 合成:outbound=webhook → groupId(webhook_creds 键),否则 → appKey(robot_creds 键)。
43
- * 凭据本体在同期发布的两份凭据 map 里,下游(reply/stream)按本字段回查。 */
44
- credRef?: string;
45
- botNick?: string;
46
- troubleshootingEnabled: boolean;
47
- };
48
- export type AdminConfig = { userId: string; nick: string; openDingTalkId: string };
49
- type FetchResult<T> = { ok: true; data: T } | { ok: false; error: string };
50
-
51
- async function dwsJson(args: string[], timeoutMs = 30_000): Promise<{ records: Rec[] }> {
52
- const parsed = await dws.runJson(args, { timeoutMs });
53
- return { records: extractRecords(parsed) };
54
- }
55
-
56
- /** dws record query/list 返回 `{ data: { records: [...] } }`,需 unwrap */
57
- function extractRecords(raw: unknown): Rec[] {
58
- if (!raw || typeof raw !== "object") return [];
59
- const rec = raw as Record<string, unknown>;
60
- const payload = (rec.data ?? rec) as Record<string, unknown> | undefined;
61
- if (!payload || typeof payload !== "object") return [];
62
- const records = payload.records;
63
- return Array.isArray(records) ? (records as Rec[]) : [];
64
- }
65
-
66
- function cellText(v: unknown, fieldType?: string): string {
67
- const normalized = normalizeByType(v, fieldType);
68
- if (typeof normalized === "string") return normalized.trim();
69
- if (typeof normalized === "number") return String(normalized);
70
- return "";
71
- }
72
-
73
- /** 群配置开关:仅明确的真值开启;缺失、空值或未知格式一律关闭。 */
74
- export function cellBoolean(v: unknown): boolean {
75
- if (typeof v === "boolean") return v;
76
- if (typeof v === "number") return v === 1;
77
- if (typeof v === "string") {
78
- return ["true", "1", "yes", "on", "是", "开启", "启用", "已勾选"].includes(v.trim().toLowerCase());
79
- }
80
- if (Array.isArray(v)) return v.some(cellBoolean);
81
- const record = asRecord(v);
82
- if (!record) return false;
83
- if ("value" in record) return cellBoolean(record.value);
84
- if ("name" in record) return cellBoolean(record.name);
85
- return false;
86
- }
87
-
88
- function asRecord(value: unknown): Record<string, unknown> | null {
89
- return value !== null && typeof value === "object" && !Array.isArray(value)
90
- ? value as Record<string, unknown>
91
- : null;
92
- }
93
-
94
- /** 将快照标准化,避免 DWS 返回顺序或 JSON 格式变化造成伪更新。 */
95
- export function normalizeGroupConfigs(raw: unknown): GroupConfig[] {
96
- if (!Array.isArray(raw)) return [];
97
- return raw.flatMap((value): GroupConfig[] => {
98
- const record = asRecord(value);
99
- const groupId = cellText(record?.groupId);
100
- if (!groupId) return [];
101
- const groupName = cellText(record?.groupName);
102
- const corpId = cellText(record?.corpId);
103
- const inbound = cellText(record?.inbound);
104
- const outbound = cellText(record?.outbound);
105
- const webhookUrl = cellText(record?.webhookUrl);
106
- const webhookSecret = cellText(record?.webhookSecret);
107
- const appKey = cellText(record?.appKey);
108
- const appSecret = cellText(record?.appSecret);
109
- const botNick = cellText(record?.botNick);
110
- const troubleshootingEnabled = cellBoolean(record?.troubleshootingEnabled);
111
-
112
- const normalized: GroupConfig = { groupId, troubleshootingEnabled };
113
- if (groupName) normalized.groupName = groupName;
114
- if (corpId) normalized.corpId = corpId;
115
- if (inbound) normalized.inbound = inbound;
116
- if (outbound) normalized.outbound = outbound;
117
- if (webhookUrl) normalized.webhookUrl = webhookUrl;
118
- if (webhookSecret) normalized.webhookSecret = webhookSecret;
119
- if (appKey) normalized.appKey = appKey;
120
- if (appSecret) normalized.appSecret = appSecret;
121
- if (botNick) normalized.botNick = botNick;
122
- // credRef 合成(凭据引用列已废,凭据本体在表内四列):webhook 出站以 groupId 作
123
- // webhook_creds 键;session 出站以 appKey 作 robot_creds 键。两消费分支互斥
124
- // (reply 只在 webhook 分支读、conversation_handler 只在非 webhook 分支读),单值无冲突;
125
- // 对已发布快照二次标准化时按同样规则重算,结果幂等。
126
- const credRef = outbound === "webhook" ? groupId : appKey;
127
- if (credRef) normalized.credRef = credRef;
128
- return [normalized];
129
- }).sort((left, right) => (
130
- left.groupId.localeCompare(right.groupId)
131
- || JSON.stringify(left).localeCompare(JSON.stringify(right))
132
- ));
133
- }
134
-
135
- /**
136
- * 群配置凭据校验:按入站/出站方式检查表内凭据列,返回问题清单(空 = 通过)。
137
- * 有问题的群不得发布(否则运行期发送时才炸且难定位),由调用方剔除并显式报错。
138
- */
139
- export function groupConfigIssues(group: GroupConfig): string[] {
140
- const label = group.groupName ?? group.groupId;
141
- const issues: string[] = [];
142
- if (group.outbound === "webhook" && (!group.webhookUrl || !group.webhookSecret)) {
143
- issues.push(`群 ${label}: 出站方式=webhook 但 webhookUrl/webhookSecret 为空,请在群配置表补全`);
144
- }
145
- if ((group.inbound === "stream" || group.outbound === "session") && (!group.appKey || !group.appSecret)) {
146
- issues.push(`群 ${label}: 入站方式=stream 或出站方式=session 但 appKey/appSecret 为空,请在群配置表补全`);
147
- }
148
- return issues;
149
- }
150
-
151
- /**
152
- * appKey 冲突检测:robot_creds 以 appKey 为键,同 appKey 不同 appSecret 时
153
- * 后者会静默覆盖前者(部分群出站凭据错配且难定位)。同 appKey 同 secret 视为
154
- * 同一机器人的合法复用;不一致时报错后群(normalize 按 groupId 排序,「先出现」
155
- * 确定),该群跳过发布。
156
- */
157
- export function appKeyConflicts(groups: GroupConfig[]): { issues: string[]; excluded: Set<string> } {
158
- const firstSeen = new Map<string, { secret: string; label: string }>();
159
- const issues: string[] = [];
160
- const excluded = new Set<string>();
161
- for (const g of groups) {
162
- if (!g.appKey || !g.appSecret) continue;
163
- const label = g.groupName ?? g.groupId;
164
- const prev = firstSeen.get(g.appKey);
165
- if (!prev) {
166
- firstSeen.set(g.appKey, { secret: g.appSecret, label });
167
- continue;
168
- }
169
- if (prev.secret !== g.appSecret) {
170
- excluded.add(g.groupId);
171
- issues.push(`群 ${label}: appKey="${g.appKey}" 的 appSecret 与群 ${prev.label} 不一致,本群跳过发布(防 robot_creds 静默覆盖),请核对群配置表`);
172
- }
173
- }
174
- return { issues, excluded };
175
- }
176
-
177
- /** webhook 出站凭据 map(groupId → {url, secret}):messaging 按 credRef(=groupId) 回查;键排序保证快照稳定 */
178
- export function buildWebhookCreds(groups: GroupConfig[]): Record<string, { url: string; secret: string }> {
179
- const map: Record<string, { url: string; secret: string }> = {};
180
- for (const g of [...groups].sort((a, b) => a.groupId.localeCompare(b.groupId))) {
181
- if (g.outbound === "webhook" && g.webhookUrl && g.webhookSecret) {
182
- map[g.groupId] = { url: g.webhookUrl, secret: g.webhookSecret };
183
- }
184
- }
185
- return map;
186
- }
187
-
188
- /** 企业机器人凭据 map(appKey → {client_id, client_secret}):stream 入站建连与 session 出站共读,按 appKey 天然去重 */
189
- export function buildRobotCreds(groups: GroupConfig[]): Record<string, { client_id: string; client_secret: string }> {
190
- const map: Record<string, { client_id: string; client_secret: string }> = {};
191
- for (const g of [...groups].sort((a, b) => (a.appKey ?? "").localeCompare(b.appKey ?? ""))) {
192
- if ((g.inbound === "stream" || g.outbound === "session") && g.appKey && g.appSecret) {
193
- map[g.appKey] = { client_id: g.appKey, client_secret: g.appSecret };
194
- }
195
- }
196
- return map;
197
- }
198
-
199
- export function normalizeAdminConfigs(raw: unknown): AdminConfig[] {
200
- if (!Array.isArray(raw)) return [];
201
- return raw.flatMap((value): AdminConfig[] => {
202
- const record = asRecord(value);
203
- const userId = cellText(record?.userId);
204
- const nick = cellText(record?.nick);
205
- const openDingTalkId = cellText(record?.openDingTalkId);
206
- return userId && nick && openDingTalkId ? [{ userId, nick, openDingTalkId }] : [];
207
- }).sort((left, right) => (
208
- left.userId.localeCompare(right.userId)
209
- || JSON.stringify(left).localeCompare(JSON.stringify(right))
210
- ));
211
- }
212
-
213
- /**
214
- * 物化 workspaces/support/owner-map.json(模块 → 花名数组):answer Agent 的 AGENTS.md
215
- * 以本目录 owner-map.json 作为 module 闭集与「已 @ Owner」判定依据;快照发布后同步刷新,
216
- * 写盘失败只留痕不影响 globalVar 快照(运行期 Owner 解析走快照,不依赖本文件)。
217
- */
218
- function materializeOwnerMap(configDir: string, moduleOwners: ModuleOwners[], log: (msg: string) => void): void {
219
- try {
220
- const file = path.resolve(configDir, "workspaces", "support", "owner-map.json");
221
- const content = `${JSON.stringify(
222
- Object.fromEntries(moduleOwners.map((m) => [m.module, m.owners.map((o) => o.nick)])),
223
- null,
224
- 2,
225
- )}\n`;
226
- const prev = existsSync(file) ? readFileSync(file, "utf8") : null;
227
- if (prev === content) return;
228
- mkdirSync(path.dirname(file), { recursive: true });
229
- writeFileSync(file, content, "utf8");
230
- log("workspaces/support/owner-map.json 已按功能模块人员配置表刷新");
231
- } catch (e) {
232
- log(`owner-map.json 写盘失败(不影响快照发布): ${e instanceof Error ? e.message : String(e)}`);
233
- }
234
- }
235
-
236
- /** 空快照门禁的计数游标(按目标 globalVar 派生,避免多份快照互相干扰)。 */
237
- export function emptyStreakKey(key: string): string {
238
- return `${key}__empty_streak`;
239
- }
240
-
241
- /**
242
- * 空快照发布门禁:远端读到空且旧值非空时,不立刻清空,先累计"连续读到空"轮数,
243
- * 达到 rounds 轮才认定为真实清空。防的是"读成功但结果为空"这类抖动(表查询瞬时
244
- * 返回 0 条、字段解析不到 userId 等)被当成"远端已清空",把管理员/群名单误清 ——
245
- * 管理员被清会让真人同事消息退化成普通用户提问;pull 名单被清会让机器人整群失聪。
246
- * 旧值本来就是空,或本轮非空时,直接放行并复位计数。
247
- */
248
- export function gateEmptySnapshot(
249
- ctx: RecipeContext,
250
- key: string,
251
- nextCount: number,
252
- rounds: number,
253
- ): { allow: boolean; streak: number; rounds: number } {
254
- const required = Math.max(1, Math.trunc(rounds) || 1);
255
- const streakKey = emptyStreakKey(key);
256
- const rawStreak = Math.trunc(Number(ctx.getGlobalVar(streakKey) ?? 0));
257
- const prevStreak = Number.isFinite(rawStreak) && rawStreak > 0 ? rawStreak : 0;
258
- const currentValue = ctx.getGlobalVar(key);
259
- const currentCount = Array.isArray(currentValue) ? currentValue.length : 0;
260
-
261
- if (nextCount > 0 || currentCount === 0) {
262
- if (prevStreak !== 0) ctx.setGlobalVar(streakKey, 0);
263
- return { allow: true, streak: 0, rounds: required };
264
- }
265
-
266
- const streak = prevStreak + 1;
267
- ctx.setGlobalVar(streakKey, streak >= required ? 0 : streak);
268
- return { allow: streak >= required, streak, rounds: required };
269
- }
270
-
271
- /** 快照计数留痕文案:暂缓清空时显式带上门禁轮次,便于 trace 上直接看出"被拦下"而非"真为 0"。 */
272
- function snapshotCountLabel(result: FetchResult<unknown[]>, gate?: { allow: boolean; streak: number; rounds: number }): string {
273
- if (!result.ok) return "读取失败";
274
- if (gate && !gate.allow) return `0(暂缓清空 ${gate.streak}/${gate.rounds})`;
275
- return String(result.data.length);
276
- }
277
-
278
- /** 内容变化时发布到 globalVar;返回是否实际更新(用于留痕判定)。 */
279
- export function publishIfChanged<T>(
280
- ctx: RecipeContext,
281
- key: string,
282
- nextRaw: unknown,
283
- normalize: (raw: unknown) => T[],
284
- ): boolean {
285
- const next = normalize(nextRaw);
286
- const current = normalize(ctx.getGlobalVar(key) ?? []);
287
- if (JSON.stringify(current) === JSON.stringify(next)) return false;
288
- ctx.setGlobalVar(key, next);
289
- return true;
290
- }
291
-
292
- /** publishIfChanged 的 map 版(凭据 map 非数组);调用方需保证键序稳定以免伪更新。 */
293
- function publishMapIfChanged(ctx: RecipeContext, key: string, next: Record<string, unknown>): boolean {
294
- const current = ctx.getGlobalVar(key) ?? {};
295
- if (JSON.stringify(current) === JSON.stringify(next)) return false;
296
- ctx.setGlobalVar(key, next);
297
- return true;
298
- }
299
-
300
-
301
-
302
- /** 从 dws field get 结果中提取字段名→字段ID 映射(兼容数组或 { data: { fields: [...] } } 嵌套) */
303
- interface FieldMeta { fieldId: string; type: string; }
304
- function extractFieldMap(raw: unknown): Map<string, FieldMeta> {
305
- let arr: unknown[] = [];
306
- if (Array.isArray(raw)) arr = raw;
307
- else if (raw && typeof raw === "object") {
308
- const rec = raw as Record<string, unknown>;
309
- if (Array.isArray(rec.fields)) arr = rec.fields;
310
- else if (Array.isArray(rec.result)) arr = rec.result;
311
- else if (rec.data && typeof rec.data === "object") {
312
- const data = rec.data as Record<string, unknown>;
313
- if (Array.isArray(data.fields)) arr = data.fields;
314
- else if (Array.isArray(data.result)) arr = data.result;
315
- }
316
- }
317
- const map = new Map<string, FieldMeta>();
318
- for (const field of arr) {
319
- if (!field || typeof field !== "object") continue;
320
- const rec = field as Record<string, unknown>;
321
- const id = typeof rec.fieldId === "string" ? rec.fieldId : typeof rec.id === "string" ? rec.id : null;
322
- const name = typeof rec.fieldName === "string" ? rec.fieldName : typeof rec.name === "string" ? rec.name : null;
323
- const type = typeof rec.type === "string" ? rec.type : "text";
324
- if (id && name) map.set(name, { fieldId: id, type });
325
- }
326
- return map;
327
- }
328
-
329
- async function fetchFieldMap(baseId: string, tableId: string): Promise<Map<string, FieldMeta>> {
330
- const raw = await dws.runJson(["aitable", "field", "get", "--base-id", baseId, "--table-id", tableId, "--format", "json"]);
331
- return extractFieldMap(raw);
332
- }
333
-
334
- /** 从 fieldMap 解析字段 ID(查不到则回退原值) */
335
- function resolveFieldKey(nameOrId: string, fieldMap: Map<string, FieldMeta>): string {
336
- return fieldMap.get(nameOrId)?.fieldId ?? nameOrId;
337
- }
338
- /** 从 fieldMap 获取字段类型(查不到则 undefined,走 normalizeByType 默认) */
339
- function fieldType(nameOrId: string, fieldMap: Map<string, FieldMeta>): string | undefined {
340
- return fieldMap.get(nameOrId)?.type;
341
- }
342
-
343
- const recipe: RecipeDefinition = {
344
- meta: {
345
- id: "config_sync",
346
- name: "拉取并固化群/管理员/模块人员配置",
347
- role: "config-syncer",
348
- description: "dws 拉群配置表 + 管理员表 + 功能模块人员表 + contact 解析 → 发布到 globalVar(group_configs/group_pull_list/admins/module_owners)",
349
- version: "1.0.0",
350
- requiresAgent: false,
351
- timeoutMs: 120_000,
352
- config: z.object({
353
- group_table: tableRefConfigSchema({
354
- label: "群配置表",
355
- description: "群配置表(读取群ID、群名称、群归属企业corpId、入站方式、出站方式、webhookUrl/webhookSecret/appKey/appSecret、机器人昵称、是否排查问题)",
356
- access: "read",
357
- ownership: "owned",
358
- autoCreate: true,
359
- defaultBase: "workflow.tracker.base_id",
360
- setupSchema: {
361
- fields: [
362
- { name: "群ID", type: "text" },
363
- { name: "群名称", type: "text" },
364
- // 群类型文本列(内部群/外部群):控制台加群流程写入、view 页回读展示,
365
- // 与首页「类型」列一一映射;留空=未知。config-sync 目前不消费该列。
366
- { name: "群类型", type: "text" },
367
- // 可选。Webhook @人解析时作为 dws --profile 使用,确保查询到群归属企业下的 userId;
368
- // 留空时继续使用 DWS 当前 profile,兼容已有配置。
369
- { name: "群归属企业corpId", type: "text" },
370
- // 入站/出站双轴正交:
371
- // 入站方式=dws(个人号轮询,进 pull 名单)/stream(企业机器人 WebSocket,@机器人触发);
372
- // 出站方式=session(会话内 @提问者,依赖 stream 登记的 sessionWebhook)/webhook(命名 webhook 降级)。
373
- // 用单选杜绝手填写错。象限约束:出站=session 依赖入站=stream(sessionWebhook 只由 stream 登记);
374
- // dws+session 物理不可能,运行期降级 webhook。
375
- { name: "入站方式", type: "singleSelect", options: ["dws", "stream"] },
376
- { name: "出站方式", type: "singleSelect", options: ["session", "webhook"] },
377
- // 凭据本体直接落在表内四列(取代旧「凭据引用」+ env 凭据集):加群只改表、不碰 env。
378
- // 校验规则:出站=webhook 要求 webhookUrl+webhookSecret 齐全;入站=stream 或出站=session
379
- // 要求 appKey+appSecret 齐全;缺失的群在配置同步结果里显式报错并跳过发布。
380
- { name: "webhookUrl", type: "text" },
381
- { name: "webhookSecret", type: "text" },
382
- { name: "appKey", type: "text" },
383
- { name: "appSecret", type: "text" },
384
- { name: "机器人昵称", type: "text" },
385
- { name: "是否排查问题", type: "checkbox" },
386
- ],
387
- },
388
- }),
389
- admin_table: tableRefConfigSchema({
390
- label: "管理员配置表",
391
- description: "管理员配置表(读取管理员名单)",
392
- access: "read",
393
- ownership: "owned",
394
- autoCreate: true,
395
- defaultBase: "workflow.tracker.base_id",
396
- setupSchema: {
397
- fields: [
398
- { name: "管理员", type: "user" },
399
- ],
400
- },
401
- }),
402
- owner_table: tableRefConfigSchema({
403
- label: "功能模块人员配置表",
404
- description: "功能模块人员配置表(读取模块→负责人映射,供 bug/feature/kb_gap 升级时 @ 模块 Owner)",
405
- access: "read",
406
- ownership: "owned",
407
- autoCreate: true,
408
- defaultBase: "workflow.tracker.base_id",
409
- setupSchema: {
410
- fields: [
411
- { name: "模块", type: "text" },
412
- { name: "人员", type: "user" },
413
- ],
414
- },
415
- }),
416
- group_var: z.string().default("${globalVar.group_configs}").describe("发布群配置的目标 globalVar"),
417
- pull_list_var: z.string().default("${globalVar.group_pull_list}").describe("发布 dws 轮询群列表的目标 globalVar"),
418
- stream_var: z.string().default("${globalVar.stream_robot_refs}").describe("发布 stream 入站机器人 credRef 名单的目标 globalVar"),
419
- webhook_creds_var: z.string().default("${globalVar.webhook_creds}").describe("发布 webhook 出站凭据(groupId → {url, secret})的目标 globalVar;messaging toolkit 活读"),
420
- robot_creds_var: z.string().default("${globalVar.robot_creds}").describe("发布企业机器人凭据(appKey → {client_id, client_secret})的目标 globalVar;stream 入站与 session 出站共读"),
421
- admin_var: z.string().default("${globalVar.admins}").describe("发布管理员名单的目标 globalVar"),
422
- owner_var: z.string().default("${globalVar.module_owners}").describe("发布功能模块人员配置的目标 globalVar"),
423
- empty_confirm_rounds: z.coerce.number().int().min(1).default(2)
424
- .describe("空快照发布门禁轮数:远端读到空且旧值非空时,需连续读到空达到该轮数才真正清空;未达轮数保留旧值并留痕(防读空抖动误清群/管理员名单)。设 1 即恢复旧的即时清空行为"),
425
- contact_concurrency: z.coerce.number().int().min(1).default(5)
426
- .describe("resolveContactsByUserIds 并发数:单轮 userId→openDingTalkId 解析的最大并行请求数"),
427
- }),
428
- },
429
-
430
- async execute(ctx: RecipeContext): Promise<RecipeResult> {
431
- const { config, log } = ctx;
432
- const gt = config.group_table as { baseId: string; tableId: string };
433
- const at = config.admin_table as { baseId: string; tableId: string };
434
- const ot = config.owner_table as { baseId: string; tableId: string };
435
- const groupVar = varKey(config.group_var, "group_configs");
436
- const pullListVar = varKey(config.pull_list_var, "group_pull_list");
437
- const streamVar = varKey(config.stream_var, "stream_robot_refs");
438
- const webhookCredsVar = varKey(config.webhook_creds_var, "webhook_creds");
439
- const robotCredsVar = varKey(config.robot_creds_var, "robot_creds");
440
- const adminVar = varKey(config.admin_var, "admins");
441
- const ownerVar = varKey(config.owner_var, "module_owners");
442
- const emptyConfirmRounds = Math.max(1, Math.trunc(Number(config.empty_confirm_rounds ?? 2)) || 1);
443
- const contactConcurrency = Math.max(1, Math.trunc(Number(config.contact_concurrency ?? 5)) || 5);
444
-
445
- // 表自建后字段名固定,通过 field get 解析为字段ID再读取 cells
446
- const GROUP_FIELDS = {
447
- groupId: "群ID",
448
- groupName: "群名称",
449
- corpId: "群归属企业corpId",
450
- inbound: "入站方式",
451
- outbound: "出站方式",
452
- webhookUrl: "webhookUrl",
453
- webhookSecret: "webhookSecret",
454
- appKey: "appKey",
455
- appSecret: "appSecret",
456
- botNick: "机器人昵称",
457
- troubleshootingEnabled: "是否排查问题",
458
- };
459
- const ADMIN_FIELDS = { user: "管理员" };
460
- const OWNER_FIELDS = { module: "模块", user: "人员" };
461
-
462
- // 1) 群配置
463
- let groupResult: FetchResult<GroupConfig[]>;
464
- try {
465
- const groupFieldMap = await fetchFieldMap(gt.baseId, gt.tableId);
466
- const groupIdKey = resolveFieldKey(GROUP_FIELDS.groupId, groupFieldMap);
467
- const groupNameKey = resolveFieldKey(GROUP_FIELDS.groupName, groupFieldMap);
468
- const corpIdKey = resolveFieldKey(GROUP_FIELDS.corpId, groupFieldMap);
469
- const inboundKey = resolveFieldKey(GROUP_FIELDS.inbound, groupFieldMap);
470
- const outboundKey = resolveFieldKey(GROUP_FIELDS.outbound, groupFieldMap);
471
- const webhookUrlKey = resolveFieldKey(GROUP_FIELDS.webhookUrl, groupFieldMap);
472
- const webhookSecretKey = resolveFieldKey(GROUP_FIELDS.webhookSecret, groupFieldMap);
473
- const appKeyKey = resolveFieldKey(GROUP_FIELDS.appKey, groupFieldMap);
474
- const appSecretKey = resolveFieldKey(GROUP_FIELDS.appSecret, groupFieldMap);
475
- const botNickKey = resolveFieldKey(GROUP_FIELDS.botNick, groupFieldMap);
476
- const troubleshootingEnabledKey = resolveFieldKey(GROUP_FIELDS.troubleshootingEnabled, groupFieldMap);
477
- const inboundType = fieldType(GROUP_FIELDS.inbound, groupFieldMap);
478
- const outboundType = fieldType(GROUP_FIELDS.outbound, groupFieldMap);
479
- const { records } = await dwsJson(["aitable", "record", "query", "--base-id", gt.baseId, "--table-id", gt.tableId, "--all", "--format", "json"]);
480
- const groups = normalizeGroupConfigs(records.map((r) => ({
481
- groupId: cellText(r.cells[groupIdKey], fieldType(GROUP_FIELDS.groupId, groupFieldMap)),
482
- groupName: cellText(r.cells[groupNameKey], fieldType(GROUP_FIELDS.groupName, groupFieldMap)) || undefined,
483
- corpId: cellText(r.cells[corpIdKey], fieldType(GROUP_FIELDS.corpId, groupFieldMap)) || undefined,
484
- inbound: cellText(r.cells[inboundKey], inboundType) || undefined,
485
- outbound: cellText(r.cells[outboundKey], outboundType) || undefined,
486
- webhookUrl: cellText(r.cells[webhookUrlKey], fieldType(GROUP_FIELDS.webhookUrl, groupFieldMap)) || undefined,
487
- webhookSecret: cellText(r.cells[webhookSecretKey], fieldType(GROUP_FIELDS.webhookSecret, groupFieldMap)) || undefined,
488
- appKey: cellText(r.cells[appKeyKey], fieldType(GROUP_FIELDS.appKey, groupFieldMap)) || undefined,
489
- appSecret: cellText(r.cells[appSecretKey], fieldType(GROUP_FIELDS.appSecret, groupFieldMap)) || undefined,
490
- botNick: cellText(r.cells[botNickKey], fieldType(GROUP_FIELDS.botNick, groupFieldMap)) || undefined,
491
- troubleshootingEnabled: cellBoolean(r.cells[troubleshootingEnabledKey]),
492
- })));
493
- groupResult = { ok: true, data: groups };
494
- } catch (e) {
495
- const error = e instanceof Error ? e.message : String(e);
496
- log(`群配置拉取失败,保留旧值: ${error}`);
497
- groupResult = { ok: false, error };
498
- }
499
-
500
- // 2) 管理员 userId → nick/openId(contact 链式)。每个 userId 内部 get→search 需串行,
501
- // 但不同 userId 之间用有界并发处理,避免 N 较大时串行阻塞过久。
502
- let adminResult: FetchResult<AdminConfig[]>;
503
- try {
504
- const adminFieldMap = await fetchFieldMap(at.baseId, at.tableId);
505
- const userKey = resolveFieldKey(ADMIN_FIELDS.user, adminFieldMap);
506
- const { records } = await dwsJson(["aitable", "record", "list", "--base-id", at.baseId, "--table-id", at.tableId, "--all", "--format", "json"]);
507
- const userIds = [...new Set(records.flatMap((r) => Array.isArray(r.cells[userKey]) ? (r.cells[userKey] as { userId?: string }[]).map((u) => u?.userId ?? "").filter(Boolean) : []))];
508
- const { contacts, errors: resolutionErrors } = await resolveContactsByUserIds(userIds, { log, concurrency: contactConcurrency, runner: dwsRunner });
509
- // 部分成功策略(评审反馈):个别 contact 解析失败(离职/数据损坏)只剔除该人并 warn 留痕,
510
- // 其余管理员正常更新,避免一个坏记录在 5 分钟轮询下持续阻塞整组;
511
- // 失败过半(含全部失败)视为 contact 服务级故障,仍整组保留旧值,防止把名单误清空/误收窄。
512
- if (resolutionErrors.length > 0 && resolutionErrors.length * 2 >= userIds.length) {
513
- throw new Error(`${resolutionErrors.length}/${userIds.length} 名管理员解析失败(过半,疑似 contact 故障): ${resolutionErrors.sort().join("; ")}`);
514
- }
515
- if (resolutionErrors.length > 0) {
516
- log(`部分管理员解析失败(${resolutionErrors.length}/${userIds.length}),剔除后继续更新其余: ${resolutionErrors.sort().join("; ")}`);
517
- }
518
- const admins = normalizeAdminConfigs(userIds.map((uid) => contacts.get(uid)).filter(Boolean));
519
- adminResult = { ok: true, data: admins };
520
- } catch (e) {
521
- const error = e instanceof Error ? e.message : String(e);
522
- log(`管理员拉取失败,保留旧值: ${error}`);
523
- adminResult = { ok: false, error };
524
- }
525
-
526
- // 3) 功能模块人员配置(模块-文本 + 人员-人员字段):同一套 contact 链式解析,
527
- // 发布 [{module, owners:[{userId, nick, openDingTalkId}]}] 供 on-action 运行期 @ 模块 Owner。
528
- let ownerResult: FetchResult<ModuleOwners[]>;
529
- try {
530
- const ownerFieldMap = await fetchFieldMap(ot.baseId, ot.tableId);
531
- const moduleKey = resolveFieldKey(OWNER_FIELDS.module, ownerFieldMap);
532
- const ownerUserKey = resolveFieldKey(OWNER_FIELDS.user, ownerFieldMap);
533
- const { records } = await dwsJson(["aitable", "record", "list", "--base-id", ot.baseId, "--table-id", ot.tableId, "--all", "--format", "json"]);
534
- // 同模块多行合并(保留首次出现顺序:数组即「主负责人 + 备选」优先级)
535
- const moduleUserIds = new Map<string, string[]>();
536
- for (const r of records) {
537
- const module = cellText(r.cells[moduleKey], fieldType(OWNER_FIELDS.module, ownerFieldMap));
538
- if (!module) continue;
539
- const uids = Array.isArray(r.cells[ownerUserKey]) ? (r.cells[ownerUserKey] as { userId?: string }[]).map((u) => u?.userId ?? "").filter(Boolean) : [];
540
- if (uids.length === 0) continue;
541
- const existing = moduleUserIds.get(module) ?? [];
542
- moduleUserIds.set(module, [...new Set([...existing, ...uids])]);
543
- }
544
- const uniqueUserIds = [...new Set([...moduleUserIds.values()].flat())];
545
- const { contacts, errors: resolutionErrors } = await resolveContactsByUserIds(uniqueUserIds, { log, concurrency: contactConcurrency, runner: dwsRunner });
546
- // 与管理员同源的部分成功策略:个别失败剔除该人,过半失败整组保留旧值。
547
- if (resolutionErrors.length > 0 && resolutionErrors.length * 2 >= uniqueUserIds.length) {
548
- throw new Error(`${resolutionErrors.length}/${uniqueUserIds.length} 名模块人员解析失败(过半,疑似 contact 故障): ${resolutionErrors.sort().join("; ")}`);
549
- }
550
- if (resolutionErrors.length > 0) {
551
- log(`部分模块人员解析失败(${resolutionErrors.length}/${uniqueUserIds.length}),剔除后继续更新其余: ${resolutionErrors.sort().join("; ")}`);
552
- }
553
- const moduleOwners = normalizeModuleOwners([...moduleUserIds.entries()].map(([module, uids]) => ({
554
- module,
555
- owners: uids.map((uid) => contacts.get(uid)).filter(Boolean),
556
- })));
557
- ownerResult = { ok: true, data: moduleOwners };
558
- } catch (e) {
559
- const error = e instanceof Error ? e.message : String(e);
560
- log(`功能模块人员拉取失败,保留旧值: ${error}`);
561
- ownerResult = { ok: false, error };
562
- }
563
-
564
- // 4) 仅发布变化的成功快照到 globalVar。失败保留旧值;"成功但为空"需经 empty_confirm_rounds
565
- // 门禁连续确认后才真正清空(单次读空视为抖动,保留旧值并留痕)。
566
- const changed: string[] = [];
567
- const failures: string[] = [];
568
- const withheld: string[] = [];
569
- let groupGate: { allow: boolean; streak: number; rounds: number } | undefined;
570
- let adminGate: { allow: boolean; streak: number; rounds: number } | undefined;
571
- let ownerGate: { allow: boolean; streak: number; rounds: number } | undefined;
572
- if (groupResult.ok) {
573
- groupGate = gateEmptySnapshot(ctx, groupVar, groupResult.data.length, emptyConfirmRounds);
574
- if (!groupGate.allow) {
575
- // 群配置是 pull/stream 名单的唯一来源,暂缓时三份派生快照必须一起不动,
576
- // 否则会出现"群配置保旧、pull 名单被清空"的半清空态(机器人静默失聪)。
577
- withheld.push(`群配置(读到空 ${groupGate.streak}/${groupGate.rounds} 轮,暂缓清空)`);
578
- log(`群配置读到空但旧值非空,暂缓清空(第 ${groupGate.streak}/${groupGate.rounds} 轮):群配置/pull 名单/stream 名单一并保留旧值`);
579
- } else {
580
- // 凭据校验:缺凭据/同 appKey 异 secret 的群不发布(否则运行期发送时才炸且难定位),
581
- // 并在同步结果显式报错驱动补表。单遍收集 issues 与 validGroups(评审建议)。
582
- const conflicts = appKeyConflicts(groupResult.data);
583
- const issues: string[] = [...conflicts.issues];
584
- const validGroups: GroupConfig[] = [];
585
- for (const g of groupResult.data) {
586
- const groupIssues = groupConfigIssues(g);
587
- if (groupIssues.length > 0) issues.push(...groupIssues);
588
- else if (!conflicts.excluded.has(g.groupId)) validGroups.push(g);
589
- }
590
- if (issues.length > 0) {
591
- const issueText = issues.sort().join("; ");
592
- failures.push(`群配置校验: ${issueText}`);
593
- log(`群配置校验失败(${issues.length} 项),相关群本轮跳过发布: ${issueText}`);
594
- }
595
- // 象限校验:出站=session 依赖入站=stream(sessionWebhook 只由 stream 登记),dws+session 物理不可能,告警(运行期降级 webhook)。
596
- for (const g of validGroups) {
597
- if (g.outbound === "session" && g.inbound !== "stream") {
598
- log(`群配置象限告警 ${g.groupName ?? g.groupId}: 出站=session 依赖入站=stream,当前入站=${g.inbound ?? "空"},运行期将降级 webhook`);
599
- }
600
- }
601
- if (publishIfChanged(ctx, groupVar, validGroups, normalizeGroupConfigs)) changed.push("群配置");
602
- // 派生 pull 名单:只把入站=dws 的群写入(dws 轮询源监听),入站=stream 群走机器人 WebSocket 不入名单。
603
- const pullIds = validGroups.filter((g) => g.inbound === "dws").map((g) => g.groupId);
604
- if (publishIfChanged(ctx, pullListVar, pullIds, (v) => Array.isArray(v) ? v as string[] : [])) changed.push("pull 名单");
605
- // 派生 stream 机器人名单:只把入站=stream 的群的 appKey 去重写入(stream 源据此维护连接池)。
606
- const streamRefs = [...new Set(
607
- validGroups.filter((g) => g.inbound === "stream" && g.appKey).map((g) => g.appKey!),
608
- )];
609
- if (publishIfChanged(ctx, streamVar, streamRefs, (v) => Array.isArray(v) ? v as string[] : [])) changed.push("stream 名单");
610
- // 派生凭据 map:webhook 出站以 groupId 为键、机器人以 appKey 为键;profile 两个 ${globalVar.*} 引用活读。
611
- if (publishMapIfChanged(ctx, webhookCredsVar, buildWebhookCreds(validGroups))) changed.push("webhook 凭据");
612
- if (publishMapIfChanged(ctx, robotCredsVar, buildRobotCreds(validGroups))) changed.push("机器人凭据");
613
- }
614
- } else {
615
- failures.push(`群配置: ${groupResult.error}`);
616
- }
617
- if (adminResult.ok) {
618
- adminGate = gateEmptySnapshot(ctx, adminVar, adminResult.data.length, emptyConfirmRounds);
619
- if (!adminGate.allow) {
620
- withheld.push(`管理员配置(读到空 ${adminGate.streak}/${adminGate.rounds} 轮,暂缓清空)`);
621
- log(`管理员名单读到空但旧值非空,暂缓清空(第 ${adminGate.streak}/${adminGate.rounds} 轮):保留旧名单,避免真人同事发言被误判为用户提问`);
622
- } else if (publishIfChanged(ctx, adminVar, adminResult.data, normalizeAdminConfigs)) {
623
- changed.push("管理员配置");
624
- }
625
- } else {
626
- failures.push(`管理员配置: ${adminResult.error}`);
627
- }
628
- if (ownerResult.ok) {
629
- ownerGate = gateEmptySnapshot(ctx, ownerVar, ownerResult.data.length, emptyConfirmRounds);
630
- if (!ownerGate.allow) {
631
- withheld.push(`模块人员配置(读到空 ${ownerGate.streak}/${ownerGate.rounds} 轮,暂缓清空)`);
632
- log(`模块人员配置读到空但旧值非空,暂缓清空(第 ${ownerGate.streak}/${ownerGate.rounds} 轮):保留旧快照与 owner-map.json,避免 bug/feature 升级找不到 Owner`);
633
- } else {
634
- if (publishIfChanged(ctx, ownerVar, ownerResult.data, normalizeModuleOwners)) changed.push("模块人员配置");
635
- // 快照成功后物化 owner-map.json(answer Agent 的 module 闭集提示依据),内容不变不写盘
636
- materializeOwnerMap(ctx.configDir, ownerResult.data, log);
637
- }
638
- } else {
639
- failures.push(`模块人员配置: ${ownerResult.error}`);
640
- }
641
-
642
- const shouldTrack = changed.length > 0 || failures.length > 0 || withheld.length > 0;
643
- const syncResult = [
644
- changed.length > 0 ? `已更新 ${changed.join("、")}` : "",
645
- withheld.length > 0 ? `暂缓清空: ${withheld.join(";")}` : "",
646
- failures.length > 0 ? `同步异常: ${failures.join(";")}` : "",
647
- ].filter(Boolean).join(";") || "无变化";
648
- const groupCount = snapshotCountLabel(groupResult, groupGate);
649
- const adminCount = snapshotCountLabel(adminResult, adminGate);
650
- const ownerCount = snapshotCountLabel(ownerResult, ownerGate);
651
- log(`配置同步完成:${syncResult};群 ${groupCount}、管理员 ${adminCount}、模块 ${ownerCount}${shouldTrack ? "" : "(不留痕)"}`);
652
- return {
653
- status: "success",
654
- output: { sync_result: syncResult, group_count: groupCount, admin_count: adminCount, owner_count: ownerCount },
655
- // 三份成功快照均无变化 → on_demand 模式下不建行;失败/暂缓清空仍留痕,便于发现同步异常。
656
- ...(shouldTrack ? {} : { tracker: false }),
657
- logs: [
658
- `群 ${groupCount}`,
659
- `管理员 ${adminCount}`,
660
- `模块 ${ownerCount}`,
661
- ...(changed.length > 0 ? [`变更: ${changed.join("、")}`] : []),
662
- ...withheld.map((item) => `暂缓: ${item}`),
663
- ...failures.map((failure) => `失败: ${failure}`),
664
- ],
665
- };
666
- },
667
- };
668
- export default recipe;