@xdxer/dingtalk-agent 0.1.1 → 0.1.4-beta.0

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 (105) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +247 -76
  3. package/dist/bin/dingtalk-agent.js +763 -0
  4. package/dist/bin/dingtalk-agent.js.map +1 -0
  5. package/dist/src/actions.js +562 -0
  6. package/dist/src/actions.js.map +1 -0
  7. package/dist/src/boot.js +70 -0
  8. package/dist/src/boot.js.map +1 -0
  9. package/dist/src/bootstrap.js +144 -0
  10. package/dist/src/bootstrap.js.map +1 -0
  11. package/dist/src/config.js +86 -0
  12. package/dist/src/config.js.map +1 -0
  13. package/dist/src/doctor.js +166 -0
  14. package/dist/src/doctor.js.map +1 -0
  15. package/dist/src/driver.js +45 -0
  16. package/dist/src/driver.js.map +1 -0
  17. package/{src → dist/src}/duty.js +42 -44
  18. package/dist/src/duty.js.map +1 -0
  19. package/dist/src/dws.js +270 -0
  20. package/dist/src/dws.js.map +1 -0
  21. package/dist/src/events.js +233 -0
  22. package/dist/src/events.js.map +1 -0
  23. package/dist/src/fields.js +132 -0
  24. package/dist/src/fields.js.map +1 -0
  25. package/dist/src/init.js +41 -0
  26. package/dist/src/init.js.map +1 -0
  27. package/dist/src/kb.js +240 -0
  28. package/dist/src/kb.js.map +1 -0
  29. package/dist/src/package-root.js +17 -0
  30. package/dist/src/package-root.js.map +1 -0
  31. package/dist/src/runs.js +79 -0
  32. package/dist/src/runs.js.map +1 -0
  33. package/dist/src/sessions.js +668 -0
  34. package/dist/src/sessions.js.map +1 -0
  35. package/dist/src/setup.js +101 -0
  36. package/dist/src/setup.js.map +1 -0
  37. package/dist/src/skill-manager.js +288 -0
  38. package/dist/src/skill-manager.js.map +1 -0
  39. package/dist/src/skills.js +200 -0
  40. package/dist/src/skills.js.map +1 -0
  41. package/dist/src/types.js +2 -0
  42. package/dist/src/types.js.map +1 -0
  43. package/dist/src/waits.js +945 -0
  44. package/dist/src/waits.js.map +1 -0
  45. package/dist/src/workspace.js +173 -0
  46. package/dist/src/workspace.js.map +1 -0
  47. package/docs/ARCHITECTURE.md +217 -0
  48. package/docs/INSTALLATION.md +135 -0
  49. package/docs/MINIMAL-WORKSPACE-V1.md +172 -0
  50. package/docs/OPEN-SOURCE-REFERENCES.md +107 -0
  51. package/docs/SELF-TEST.md +252 -0
  52. package/docs/architecture/dingtalk-agent-blueprint.png +0 -0
  53. package/docs/architecture/dingtalk-agent-blueprint.svg +144 -0
  54. package/docs/architecture/durable-async-agent-runtime.png +0 -0
  55. package/docs/architecture/durable-async-agent-runtime.svg +234 -0
  56. package/docs//345/206/205/347/275/221/345/256/236/347/233/270.md +77 -0
  57. package/evals/baselines/2026-07-14/behavior-summary.json +28 -0
  58. package/evals/baselines/2026-07-14/contract-summary.json +18 -0
  59. package/evals/baselines/2026-07-14/live-canary-summary.json +25 -0
  60. package/evals/baselines/2026-07-15/dingtalk-basic-behavior-0.3.0/SKILL.md +72 -0
  61. package/evals/baselines/2026-07-15/dingtalk-basic-behavior-0.3.0/references/action-contract.md +31 -0
  62. package/evals/baselines/2026-07-15/dingtalk-basic-behavior-0.3.0/references/event-to-behavior.md +22 -0
  63. package/evals/baselines/2026-07-15/dingtalk-basic-behavior-0.3.0/references/memory-and-evolution.md +25 -0
  64. package/evals/baselines/2026-07-15/dingtalk-basic-behavior-0.3.0/references/runtime-modes.md +34 -0
  65. package/evals/baselines/2026-07-15/task-lifecycle-summary.json +50 -0
  66. package/evals/evals.json +316 -0
  67. package/evals/fixtures/dm-ambiguous-send.json +4 -0
  68. package/evals/fixtures/dm-blocked.json +4 -0
  69. package/evals/fixtures/dm-clear.json +4 -0
  70. package/evals/fixtures/dm-discussion.json +4 -0
  71. package/evals/fixtures/dm-doc-write-no-tool.json +4 -0
  72. package/evals/fixtures/dm-long-task-ack.json +4 -0
  73. package/evals/fixtures/dm-nonblocking-gap.json +4 -0
  74. package/evals/fixtures/dm-structured-task.json +4 -0
  75. package/evals/fixtures/group.json +10 -0
  76. package/evals/fixtures/mentioned.json +3 -0
  77. package/evals/run-contract-evals.mjs +1106 -0
  78. package/evals/run-shadow-evals.mjs +267 -0
  79. package/evals/runners/README.md +66 -0
  80. package/evals/runners/claude-shadow.mjs +533 -0
  81. package/evals/schemas/action-request.schema.json +77 -0
  82. package/evals/shadow-evals.json +133 -0
  83. package/package.json +28 -6
  84. package/skills/AGENTS.md +21 -3
  85. package/skills/dingtalk-basic-behavior/SKILL.md +86 -0
  86. package/skills/dingtalk-basic-behavior/assets/task-checkpoint.md +37 -0
  87. package/skills/dingtalk-basic-behavior/references/action-contract.md +31 -0
  88. package/skills/dingtalk-basic-behavior/references/event-to-behavior.md +24 -0
  89. package/skills/dingtalk-basic-behavior/references/memory-and-evolution.md +27 -0
  90. package/skills/dingtalk-basic-behavior/references/runtime-modes.md +34 -0
  91. package/skills/dingtalk-basic-behavior/references/task-lifecycle.md +108 -0
  92. package/skills//345/237/272/347/241/200/350/241/214/344/270/272.md +44 -0
  93. package/skills//345/277/203/350/267/263.md +11 -0
  94. package/skills//346/266/210/346/201/257.md +14 -14
  95. package/skills//350/257/204/346/265/213.md +14 -1
  96. package/skills//351/222/211/351/222/211.md +3 -2
  97. package/templates/behaviors/basic.json +68 -0
  98. package/templates/fields/default/field.json +25 -0
  99. package/bin/dingtalk-agent.js +0 -289
  100. package/src/boot.js +0 -65
  101. package/src/config.js +0 -42
  102. package/src/dws.js +0 -192
  103. package/src/init.js +0 -84
  104. package/src/kb.js +0 -221
  105. package/src/runs.js +0 -77
@@ -0,0 +1,945 @@
1
+ // 单链 Durable Wait(TypeScript source)。
2
+ //
3
+ // v1 只解决一件事:ask 之后,下一条来自同一 Field/tenant/conversation/actor
4
+ // 的 DM 或直接 @,恢复原 Session 并创建新 Run。这里不实现通用 workflow/stack。
5
+ import { existsSync, linkSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync, } from 'node:fs';
6
+ import { dirname, resolve, join } from 'node:path';
7
+ import { hostname } from 'node:os';
8
+ import { randomUUID } from 'node:crypto';
9
+ import { digest, stableStringify } from './events.js';
10
+ const OPEN_CHAIN = new Set([
11
+ 'arming', 'active', 'uncertain', 'claimed', 'cancel-pending',
12
+ ]);
13
+ const RESUMABLE = new Set(['active', 'uncertain']);
14
+ const DEFAULT_TTL_MS = 7 * 24 * 60 * 60 * 1000;
15
+ export function armWait(root, cfg, input) {
16
+ const scope = scopeFromTarget(input.fieldId, input.event, input.target);
17
+ if (!scope) {
18
+ throw new Error('ask 无法建立 Wait:必须有 tenant、conversation、actor,且事件是 DM 或直接 @');
19
+ }
20
+ const rootPath = waitRoot(root, cfg);
21
+ const scopeHash = digest(stableStringify(scope));
22
+ const path = join(rootPath, `${scopeHash}.json`);
23
+ const waitId = `wait_${digest(`${input.sessionId}\0${input.actionId}`).slice(0, 20)}`;
24
+ const history = join(rootPath, 'history', `${waitId}.json`);
25
+ mkdirSync(rootPath, { recursive: true });
26
+ // 旧 ask 的 Receipt 可能在同一 scope 已进入下一轮 Wait 后重放;历史记录存在时
27
+ // 只返回原 Wait,不能用旧 Receipt 覆盖当前正在等待的新问题。
28
+ if (existsSync(history)) {
29
+ const recorded = readJson(history);
30
+ assertScope(recorded, scope, scopeHash);
31
+ if (recorded.id === waitId && recorded.actionId === input.actionId &&
32
+ recorded.sessionId === input.sessionId)
33
+ return recorded;
34
+ }
35
+ return withLock(path, () => {
36
+ let existing = existsSync(path) ? readJson(path) : null;
37
+ if (existing) {
38
+ assertScope(existing, scope, scopeHash);
39
+ existing = repairFromReceipt(root, cfg, path, existing);
40
+ }
41
+ if (existing?.id === waitId && existing.actionId === input.actionId)
42
+ return existing;
43
+ const ownedClaim = existing?.status === 'claimed' &&
44
+ existing.sessionId === input.sessionId &&
45
+ existing.claim?.runId === input.sourceRunId;
46
+ if (existing && OPEN_CHAIN.has(existing.status) && !expired(existing) &&
47
+ !ownedClaim) {
48
+ throw new Error(`同一人员/会话已有未完成 Wait ${existing.id}(Session ${existing.sessionId});` +
49
+ '单链 v1 拒绝并行追问');
50
+ }
51
+ if (ownedClaim) {
52
+ persistHistory(path, {
53
+ ...existing, status: 'continued', continuedAt: new Date().toISOString(),
54
+ updatedAt: new Date().toISOString(),
55
+ });
56
+ }
57
+ const now = new Date().toISOString();
58
+ const ttl = positiveNumber(cfg.runtime?.waitTtlMs, DEFAULT_TTL_MS);
59
+ const record = {
60
+ $schema: 'dingtalk-agent/wait@1',
61
+ id: waitId,
62
+ scopeHash,
63
+ scope,
64
+ fieldId: input.fieldId,
65
+ sessionId: input.sessionId,
66
+ sessionPath: resolve(input.sessionPath),
67
+ sourceRunId: input.sourceRunId,
68
+ actionId: input.actionId,
69
+ questionText: String(input.questionText || ''),
70
+ status: 'arming',
71
+ version: Number(existing?.version || 0) + 1,
72
+ createdAt: now,
73
+ updatedAt: now,
74
+ expiresAt: new Date(Date.now() + ttl).toISOString(),
75
+ delivery: null,
76
+ claim: null,
77
+ };
78
+ persistWait(path, record);
79
+ return record;
80
+ });
81
+ }
82
+ /** Receipt 是真值;重放 Receipt 也能幂等补齐 active/uncertain Wait。 */
83
+ export function reconcileWaitFromReceipt(root, cfg, wait, receipt) {
84
+ if (!wait)
85
+ return null;
86
+ if (wait.actionId !== receipt.actionId) {
87
+ throw new Error(`Wait/Receipt actionId 不一致: ${wait.actionId}/${receipt.actionId}`);
88
+ }
89
+ if (wait.lastAppliedReceiptActionId === receipt.actionId)
90
+ return wait;
91
+ const path = join(waitRoot(root, cfg), `${wait.scopeHash}.json`);
92
+ return withLock(path, () => {
93
+ if (!existsSync(path))
94
+ throw new Error(`Wait 索引丢失: ${wait.id}`);
95
+ const current = readJson(path);
96
+ if (current.id === wait.id && current.actionId === receipt.actionId) {
97
+ if (current.lastAppliedReceiptActionId === receipt.actionId)
98
+ return current;
99
+ const next = applyReceipt(current, receipt);
100
+ persistWait(path, next);
101
+ return next;
102
+ }
103
+ // 该链可能已经进入下一轮 Wait;旧 Receipt 只补自己的历史,不得覆盖 scope head。
104
+ const history = join(waitRoot(root, cfg), 'history', `${wait.id}.json`);
105
+ if (!existsSync(history)) {
106
+ throw new Error(`Wait/Receipt 身份冲突: wait=${current.id}, action=${receipt.actionId}`);
107
+ }
108
+ const recorded = readJson(history);
109
+ if (recorded.id !== wait.id || recorded.actionId !== receipt.actionId) {
110
+ throw new Error(`Wait 历史与 Receipt 身份冲突: wait=${wait.id}, action=${receipt.actionId}`);
111
+ }
112
+ if (recorded.lastAppliedReceiptActionId === receipt.actionId)
113
+ return recorded;
114
+ const next = applyReceipt(recorded, receipt);
115
+ persistHistory(path, next);
116
+ return next;
117
+ });
118
+ }
119
+ /** DWS 已返回可查询结果后立即开放 Wait;平台回读可以随后完成。 */
120
+ export function markWaitTransportAccepted(root, cfg, wait, result) {
121
+ if (!wait)
122
+ return null;
123
+ const path = join(waitRoot(root, cfg), `${wait.scopeHash}.json`);
124
+ return withLock(path, () => {
125
+ if (!existsSync(path))
126
+ throw new Error(`Wait 索引丢失: ${wait.id}`);
127
+ const current = readJson(path);
128
+ if (current.id === wait.id) {
129
+ const next = applyTransportResult(current, result);
130
+ persistWait(path, next);
131
+ return next;
132
+ }
133
+ const history = join(waitRoot(root, cfg), 'history', `${wait.id}.json`);
134
+ if (!existsSync(history))
135
+ throw new Error(`Wait 已被其他链替换: ${wait.id}`);
136
+ const recorded = readJson(history);
137
+ if (recorded.id !== wait.id || recorded.actionId !== wait.actionId) {
138
+ throw new Error(`Wait 历史与 transport-result 身份冲突: ${wait.id}`);
139
+ }
140
+ const next = applyTransportResult(recorded, result);
141
+ persistHistory(path, next);
142
+ return next;
143
+ });
144
+ }
145
+ /** resumed Run 给出最终 reply/silence 后,释放单链 scope。 */
146
+ export function completeWaitFromReceipt(root, cfg, resume, receipt) {
147
+ if (resume?.mode !== 'resume' || !resume.waitId)
148
+ return null;
149
+ const rootPath = waitRoot(root, cfg);
150
+ const historyPath = join(rootPath, 'history', `${resume.waitId}.json`);
151
+ let recorded = existsSync(historyPath) ? readJson(historyPath) : null;
152
+ let scopeHash = recorded?.scopeHash || resume.waitScopeHash || '';
153
+ if (!scopeHash && existsSync(rootPath)) {
154
+ const matches = readdirSync(rootPath)
155
+ .filter((name) => name.endsWith('.json'))
156
+ .map((name) => readJson(join(rootPath, name)))
157
+ .filter((candidate) => candidate.id === resume.waitId);
158
+ if (matches.length === 1) {
159
+ recorded = matches[0];
160
+ scopeHash = recorded.scopeHash;
161
+ }
162
+ }
163
+ if (!scopeHash)
164
+ throw new Error(`Wait 历史丢失且无法从 scope head 修复: ${resume.waitId}`);
165
+ const path = join(rootPath, `${scopeHash}.json`);
166
+ return withLock(path, () => {
167
+ if (!recorded) {
168
+ const current = existsSync(path) ? readJson(path) : null;
169
+ if (!current || current.id !== resume.waitId) {
170
+ throw new Error(`Wait 历史丢失且 scope head 不匹配: ${resume.waitId}`);
171
+ }
172
+ recorded = current;
173
+ persistHistory(path, recorded);
174
+ }
175
+ const now = new Date().toISOString();
176
+ const current = existsSync(path) ? readJson(path) : null;
177
+ if (current?.id === recorded.id) {
178
+ if (current.status === 'cancelled') {
179
+ persistHistory(path, current);
180
+ return current;
181
+ }
182
+ const next = {
183
+ ...current,
184
+ status: current.status === 'cancel-pending' ? 'cancel-pending' : 'completed',
185
+ updatedAt: now, completedAt: now,
186
+ completedByReceiptActionId: receipt.actionId,
187
+ };
188
+ persistWait(path, next);
189
+ return next;
190
+ }
191
+ const next = {
192
+ ...recorded, status: recorded.status === 'cancelled' ? 'cancelled' : 'completed',
193
+ updatedAt: now, completedAt: now, completedByReceiptActionId: receipt.actionId,
194
+ };
195
+ persistHistory(path, next);
196
+ return next;
197
+ });
198
+ }
199
+ /**
200
+ * 显式 /stop、/cancel 是宿主控制面:不依赖当前 Field/Behavior/Skill。
201
+ * 先写 cancel-pending,再在线性化锁内提升 Session generation,最后写 cancelled。
202
+ */
203
+ export function cancelWaitForEvent(root, cfg, event) {
204
+ if (!isCancelCommand(event.message?.text))
205
+ return null;
206
+ const eventScope = scopeWithoutField(event);
207
+ if (!eventScope)
208
+ return null;
209
+ const rootPath = waitRoot(root, cfg);
210
+ const replay = readEventBinding(rootPath, event.id);
211
+ if (replay) {
212
+ assertEventBinding(replay, event, 'cancel', eventScope);
213
+ if (replay.outcome === 'no-match')
214
+ return null;
215
+ if (replay.outcome === 'ambiguous') {
216
+ throw new Error(`取消命中多个 Wait,已固化为 ambiguous: ${(replay.candidates || []).join(', ')}`);
217
+ }
218
+ return cancelBoundWait(root, cfg, event, replay);
219
+ }
220
+ if (!existsSync(rootPath)) {
221
+ createNoMatchBinding(rootPath, event, 'cancel', eventScope);
222
+ return null;
223
+ }
224
+ const candidates = [];
225
+ for (const name of readdirSync(rootPath)) {
226
+ if (!name.endsWith('.json'))
227
+ continue;
228
+ const path = join(rootPath, name);
229
+ const record = readJson(path);
230
+ const sameCancel = record.claim?.eventId === event.id &&
231
+ record.claim?.cancelRequested === true;
232
+ if (sameEventScope(record.scope, eventScope) &&
233
+ (OPEN_CHAIN.has(record.status) || sameCancel ||
234
+ (record.status === 'cancelled' && record.claim?.eventId === event.id))) {
235
+ candidates.push({ path, record });
236
+ }
237
+ }
238
+ if (candidates.length > 1) {
239
+ createNoMatchBinding(rootPath, event, 'cancel', eventScope, {
240
+ outcome: 'ambiguous', candidates: candidates.map((x) => x.record.id).sort(),
241
+ });
242
+ throw new Error(`取消命中多个 Wait,拒绝猜测: ${candidates.map((x) => x.record.id).join(', ')}`);
243
+ }
244
+ if (!candidates.length) {
245
+ createNoMatchBinding(rootPath, event, 'cancel', eventScope);
246
+ return null;
247
+ }
248
+ const { path } = candidates[0];
249
+ const bound = withLock(path, () => {
250
+ let current = readJson(path);
251
+ if (!sameEventScope(current.scope, eventScope)) {
252
+ throw new Error(`取消 Wait 作用域漂移: ${current.id}`);
253
+ }
254
+ current = repairFromReceipt(root, cfg, path, current);
255
+ if (current.status === 'cancelled' && current.claim?.eventId === event.id) {
256
+ return createEventBinding(rootPath, event, 'cancel', current);
257
+ }
258
+ if (!OPEN_CHAIN.has(current.status)) {
259
+ createNoMatchBinding(rootPath, event, 'cancel', eventScope);
260
+ return null;
261
+ }
262
+ // 先把 head 置为 pending,再写 ledger。两者之间即使 SIGKILL,重放也会从
263
+ // pending 的 eventId 恢复;Action 无法趁 binding→effect-lock 窗口建立 W2。
264
+ const now = new Date().toISOString();
265
+ const sameCancel = current.claim?.eventId === event.id &&
266
+ current.claim?.cancelRequested === true;
267
+ ensureClaimBinding(rootPath, current);
268
+ const pending = {
269
+ ...current,
270
+ status: 'cancel-pending',
271
+ updatedAt: now,
272
+ previousClaim: sameCancel ? current.previousClaim || null : current.claim || null,
273
+ claim: {
274
+ eventId: event.id, eventKind: event.kind, claimedAt: now,
275
+ runId: '', cancelRequested: true,
276
+ },
277
+ };
278
+ persistWait(path, pending);
279
+ const binding = createEventBinding(rootPath, event, 'cancel', pending);
280
+ return binding;
281
+ });
282
+ return bound ? cancelBoundWait(root, cfg, event, bound) : null;
283
+ }
284
+ /** Action 与 cancel 共用这一把锁;先拿到锁的一方定义副作用先后。 */
285
+ export function acquireSessionEffectLock(root, sessionPath) {
286
+ const dir = resolve(root, '.dingtalk-agent/session-effect-locks');
287
+ mkdirSync(dir, { recursive: true });
288
+ return acquireLock(join(dir, `${digest(resolve(sessionPath))}.lock`), 30 * 60_000, 3 * 60_000);
289
+ }
290
+ /**
291
+ * incoming Event 在创建新 Session 前调用。显式 sessionKey/correlation 由调用方优先处理。
292
+ * claimed/cancelled + 同 eventId 可重入,覆盖 materialize 过程中崩溃后的恢复窗口。
293
+ */
294
+ export function claimWait(root, cfg, fieldId, event) {
295
+ const scope = scopeFromEvent(fieldId, event);
296
+ if (!scope)
297
+ return null;
298
+ const rootPath = waitRoot(root, cfg);
299
+ const scopeHash = digest(stableStringify(scope));
300
+ const path = join(rootPath, `${scopeHash}.json`);
301
+ const replay = readEventBinding(rootPath, event.id);
302
+ if (replay) {
303
+ assertEventBinding(replay, event, 'claim', scope);
304
+ if (replay.outcome === 'no-match')
305
+ return null;
306
+ return claimBoundWait(root, cfg, event, replay);
307
+ }
308
+ return withLock(path, () => {
309
+ if (!existsSync(path)) {
310
+ createNoMatchBinding(rootPath, event, 'claim', scope);
311
+ return null;
312
+ }
313
+ let current = readJson(path);
314
+ assertScope(current, scope, scopeHash);
315
+ current = repairFromReceipt(root, cfg, path, current);
316
+ if (current.status === 'claimed' && current.claim?.eventId === event.id &&
317
+ current.claim?.cancelRequested !== true) {
318
+ createEventBinding(rootPath, event, 'claim', current);
319
+ return current;
320
+ }
321
+ if (expired(current)) {
322
+ ensureClaimBinding(rootPath, current);
323
+ if (OPEN_CHAIN.has(current.status)) {
324
+ const next = { ...current, status: 'expired', updatedAt: new Date().toISOString() };
325
+ persistWait(path, next);
326
+ }
327
+ createNoMatchBinding(rootPath, event, 'claim', scope);
328
+ return null;
329
+ }
330
+ if (!RESUMABLE.has(current.status)) {
331
+ createNoMatchBinding(rootPath, event, 'claim', scope);
332
+ return null;
333
+ }
334
+ const now = new Date().toISOString();
335
+ const next = {
336
+ ...current,
337
+ status: 'claimed',
338
+ updatedAt: now,
339
+ claim: {
340
+ eventId: event.id,
341
+ eventKind: event.kind,
342
+ claimedAt: now,
343
+ runId: '',
344
+ cancelRequested: false,
345
+ },
346
+ };
347
+ persistWait(path, next);
348
+ createEventBinding(rootPath, event, 'claim', next);
349
+ return next;
350
+ });
351
+ }
352
+ export function attachClaimedRun(root, cfg, wait, eventId, runId) {
353
+ if (!wait)
354
+ return null;
355
+ const path = join(waitRoot(root, cfg), `${wait.scopeHash}.json`);
356
+ return withLock(path, () => {
357
+ const loaded = readBoundWait(path, wait.id);
358
+ if (!loaded || loaded.record.claim?.eventId !== eventId) {
359
+ throw new Error(`Wait claim 身份冲突: wait=${wait.id}, event=${eventId}`);
360
+ }
361
+ const current = loaded.record;
362
+ if (current.claim.runId && current.claim.runId !== runId) {
363
+ throw new Error(`Wait ${wait.id} 已绑定其他 Run: ${current.claim.runId}`);
364
+ }
365
+ if (current.claim.runId === runId) {
366
+ // head 已写而 history 未写时,幂等重试必须顺手补齐历史真值。
367
+ persistBoundWait(path, current, loaded.isHead);
368
+ return current;
369
+ }
370
+ const next = {
371
+ ...current,
372
+ updatedAt: new Date().toISOString(),
373
+ claim: { ...current.claim, runId },
374
+ };
375
+ persistBoundWait(path, next, loaded.isHead);
376
+ return next;
377
+ });
378
+ }
379
+ export function waitRoot(root, cfg) {
380
+ return resolve(root, cfg.runtime?.waitRoot || '.dingtalk-agent/waits');
381
+ }
382
+ export function readWaitForEvent(root, cfg, fieldId, event) {
383
+ const scope = scopeFromEvent(fieldId, event);
384
+ if (!scope)
385
+ return null;
386
+ const path = join(waitRoot(root, cfg), `${digest(stableStringify(scope))}.json`);
387
+ return existsSync(path) ? readJson(path) : null;
388
+ }
389
+ export function isCancelCommand(text) {
390
+ return /^\/(?:stop|cancel|停止|取消)$/i.test(String(text || '').trim());
391
+ }
392
+ /** eventId 一旦绑定 Wait,重放永远解析原 Wait,不能重新扫描当前 scope head。 */
393
+ function createEventBinding(rootPath, event, operation, wait) {
394
+ const path = eventBindingPath(rootPath, event.id);
395
+ const binding = {
396
+ $schema: 'dingtalk-agent/wait-event-binding@1',
397
+ eventId: event.id,
398
+ eventKind: event.kind,
399
+ operation,
400
+ outcome: 'matched',
401
+ waitId: wait.id,
402
+ scopeHash: wait.scopeHash,
403
+ scope: wait.scope,
404
+ fieldId: wait.fieldId,
405
+ sessionId: wait.sessionId,
406
+ sessionPath: wait.sessionPath,
407
+ sourceRunId: wait.sourceRunId,
408
+ actionId: wait.actionId,
409
+ boundAt: new Date().toISOString(),
410
+ };
411
+ if (!writeJsonCreateAtomic(path, binding)) {
412
+ const existing = readJson(path);
413
+ if (stableStringify(bindingIdentity(existing)) !== stableStringify(bindingIdentity(binding))) {
414
+ throw new Error(`Event ${event.id} 已绑定其他 Wait,拒绝 ABA 重解释`);
415
+ }
416
+ return existing;
417
+ }
418
+ return binding;
419
+ }
420
+ function createNoMatchBinding(rootPath, event, operation, scope, options = {}) {
421
+ const path = eventBindingPath(rootPath, event.id);
422
+ const binding = {
423
+ $schema: 'dingtalk-agent/wait-event-binding@1',
424
+ eventId: event.id,
425
+ eventKind: event.kind,
426
+ operation,
427
+ outcome: options.outcome || 'no-match',
428
+ candidates: options.candidates || [],
429
+ waitId: '',
430
+ scopeHash: digest(stableStringify(scope)),
431
+ scope,
432
+ fieldId: '',
433
+ sessionId: '',
434
+ sessionPath: '',
435
+ sourceRunId: '',
436
+ actionId: '',
437
+ boundAt: new Date().toISOString(),
438
+ };
439
+ if (!writeJsonCreateAtomic(path, binding)) {
440
+ const existing = readJson(path);
441
+ if (stableStringify(bindingIdentity(existing)) !== stableStringify(bindingIdentity(binding))) {
442
+ throw new Error(`Event ${event.id} 的 Wait 路由决策已固化,拒绝重新解释`);
443
+ }
444
+ return existing;
445
+ }
446
+ return binding;
447
+ }
448
+ function readEventBinding(rootPath, eventId) {
449
+ const path = eventBindingPath(rootPath, eventId);
450
+ return existsSync(path) ? readJson(path) : null;
451
+ }
452
+ function eventBindingPath(rootPath, eventId) {
453
+ return join(rootPath, 'events', `${digest(eventId)}.json`);
454
+ }
455
+ function bindingIdentity(value) {
456
+ return {
457
+ eventId: value.eventId,
458
+ eventKind: value.eventKind,
459
+ operation: value.operation,
460
+ outcome: value.outcome,
461
+ candidates: value.candidates || [],
462
+ waitId: value.waitId,
463
+ scopeHash: value.scopeHash,
464
+ scope: value.scope,
465
+ fieldId: value.fieldId,
466
+ sessionId: value.sessionId,
467
+ sessionPath: value.sessionPath,
468
+ sourceRunId: value.sourceRunId,
469
+ actionId: value.actionId,
470
+ };
471
+ }
472
+ function assertEventBinding(binding, event, operation, expectedScope) {
473
+ if (binding.eventId !== event.id || binding.eventKind !== event.kind ||
474
+ binding.operation !== operation) {
475
+ throw new Error(`Event ${event.id} 的 Wait binding 类型冲突`);
476
+ }
477
+ const scopeMatches = operation === 'cancel'
478
+ ? sameEventScope(binding.scope, expectedScope)
479
+ : stableStringify(binding.scope) === stableStringify(expectedScope);
480
+ if (!scopeMatches || binding.scopeHash !== digest(stableStringify(binding.scope))) {
481
+ throw new Error(`Event ${event.id} 的 Wait binding 作用域漂移`);
482
+ }
483
+ }
484
+ function assertWaitBinding(record, binding) {
485
+ const actual = {
486
+ waitId: record.id,
487
+ scopeHash: record.scopeHash,
488
+ fieldId: record.fieldId,
489
+ sessionId: record.sessionId,
490
+ sessionPath: record.sessionPath,
491
+ sourceRunId: record.sourceRunId,
492
+ actionId: record.actionId,
493
+ };
494
+ const expected = {
495
+ waitId: binding.waitId,
496
+ scopeHash: binding.scopeHash,
497
+ fieldId: binding.fieldId,
498
+ sessionId: binding.sessionId,
499
+ sessionPath: binding.sessionPath,
500
+ sourceRunId: binding.sourceRunId,
501
+ actionId: binding.actionId,
502
+ };
503
+ if (stableStringify(actual) !== stableStringify(expected)) {
504
+ throw new Error(`Wait event binding 身份漂移: ${binding.waitId}`);
505
+ }
506
+ }
507
+ function cancelBoundWait(root, cfg, event, binding) {
508
+ const path = join(waitRoot(root, cfg), `${binding.scopeHash}.json`);
509
+ let pending = withLock(path, () => {
510
+ const loaded = readBoundWait(path, binding.waitId);
511
+ if (!loaded)
512
+ throw new Error(`取消绑定的 Wait 不存在: ${binding.waitId}`);
513
+ let current = loaded.record;
514
+ assertWaitBinding(current, binding);
515
+ if (loaded.isHead)
516
+ current = repairFromReceipt(root, cfg, path, current);
517
+ if (current.status === 'cancelled' && current.claim?.eventId === event.id)
518
+ return current;
519
+ if (['cancel-pending', 'cancelled'].includes(current.status) &&
520
+ current.claim?.cancelRequested === true && current.claim?.eventId !== event.id) {
521
+ // 两个 stop 同时到达时只让先行者提升 generation;后来者成为同 Session 的安全 no-op。
522
+ return suppressedClaim(current, event.id);
523
+ }
524
+ const sameCancel = current.claim?.eventId === event.id &&
525
+ current.claim?.cancelRequested === true;
526
+ if (!OPEN_CHAIN.has(current.status) && !sameCancel) {
527
+ throw new Error(`取消绑定的 Wait 已进入 ${current.status}: ${binding.waitId}`);
528
+ }
529
+ const now = new Date().toISOString();
530
+ ensureClaimBinding(waitRoot(root, cfg), current);
531
+ const next = {
532
+ ...current,
533
+ status: 'cancel-pending',
534
+ updatedAt: now,
535
+ previousClaim: sameCancel ? current.previousClaim || null : current.claim || null,
536
+ claim: {
537
+ eventId: event.id, eventKind: event.kind, claimedAt: now,
538
+ runId: '', cancelRequested: true,
539
+ },
540
+ };
541
+ persistBoundWait(path, next, loaded.isHead);
542
+ return next;
543
+ });
544
+ if (pending.suppressed)
545
+ return pending;
546
+ applyCancelFence(root, pending, event.id);
547
+ pending = withLock(path, () => {
548
+ const loaded = readBoundWait(path, binding.waitId);
549
+ if (!loaded)
550
+ throw new Error(`取消完成时 Wait 丢失: ${binding.waitId}`);
551
+ const current = loaded.record;
552
+ assertWaitBinding(current, binding);
553
+ if (current.claim?.eventId !== event.id) {
554
+ throw new Error(`取消 Wait 身份漂移: ${binding.waitId}`);
555
+ }
556
+ if (current.status === 'cancelled') {
557
+ persistBoundWait(path, current, loaded.isHead);
558
+ return current;
559
+ }
560
+ const next = { ...current, status: 'cancelled', updatedAt: new Date().toISOString() };
561
+ persistBoundWait(path, next, loaded.isHead);
562
+ return next;
563
+ });
564
+ return pending;
565
+ }
566
+ function claimBoundWait(root, cfg, event, binding) {
567
+ const path = join(waitRoot(root, cfg), `${binding.scopeHash}.json`);
568
+ return withLock(path, () => {
569
+ const loaded = readBoundWait(path, binding.waitId);
570
+ if (!loaded)
571
+ throw new Error(`回复绑定的 Wait 不存在: ${binding.waitId}`);
572
+ let current = loaded.record;
573
+ assertWaitBinding(current, binding);
574
+ if (loaded.isHead)
575
+ current = repairFromReceipt(root, cfg, path, current);
576
+ if (current.status === 'claimed' && current.claim?.eventId === event.id) {
577
+ if (!loaded.isHead)
578
+ throw new Error(`已 claim 的 Wait 不再是 scope head: ${binding.waitId}`);
579
+ return current;
580
+ }
581
+ if (['cancel-pending', 'cancelled'].includes(current.status) &&
582
+ current.claim?.cancelRequested === true) {
583
+ return suppressedClaim(current, event.id);
584
+ }
585
+ if (!loaded.isHead || !RESUMABLE.has(current.status) || expired(current)) {
586
+ throw new Error(`回复事件已绑定 Wait ${binding.waitId},但当前状态为 ${current.status};拒绝回退成新事项`);
587
+ }
588
+ const now = new Date().toISOString();
589
+ const next = {
590
+ ...current,
591
+ status: 'claimed',
592
+ updatedAt: now,
593
+ claim: {
594
+ eventId: event.id, eventKind: event.kind, claimedAt: now,
595
+ runId: '', cancelRequested: false,
596
+ },
597
+ };
598
+ persistWait(path, next);
599
+ return next;
600
+ });
601
+ }
602
+ function suppressedClaim(record, eventId) {
603
+ const cancelEventId = record.claim?.eventId || '';
604
+ return {
605
+ ...record,
606
+ suppressed: true,
607
+ cancelEventId,
608
+ claim: {
609
+ eventId,
610
+ claimedAt: new Date().toISOString(),
611
+ runId: '',
612
+ cancelRequested: true,
613
+ supersededByEventId: cancelEventId,
614
+ },
615
+ };
616
+ }
617
+ function ensureClaimBinding(rootPath, record) {
618
+ const claim = record.claim?.cancelRequested !== true
619
+ ? record.claim
620
+ : record.previousClaim?.cancelRequested !== true ? record.previousClaim : null;
621
+ if (!claim?.eventId)
622
+ return null;
623
+ if (!claim.eventKind) {
624
+ throw new Error(`Wait ${record.id} 的 claim 缺少 eventKind,无法安全修复 event ledger`);
625
+ }
626
+ return createEventBinding(rootPath, {
627
+ id: claim.eventId,
628
+ kind: claim.eventKind,
629
+ }, 'claim', record);
630
+ }
631
+ function readBoundWait(scopePath, waitId) {
632
+ if (existsSync(scopePath)) {
633
+ const current = readJson(scopePath);
634
+ if (current.id === waitId)
635
+ return { record: current, isHead: true };
636
+ }
637
+ const history = join(dirname(scopePath), 'history', `${waitId}.json`);
638
+ return existsSync(history) ? { record: readJson(history), isHead: false } : null;
639
+ }
640
+ function persistBoundWait(scopePath, record, isHead) {
641
+ if (isHead)
642
+ persistWait(scopePath, record);
643
+ else
644
+ persistHistory(scopePath, record);
645
+ }
646
+ function scopeFromTarget(fieldId, event, target) {
647
+ if (!['im.message.dm', 'im.message.mentioned'].includes(event.kind))
648
+ return null;
649
+ return completeScope({
650
+ fieldId,
651
+ tenantId: target?.tenantId || event.tenant?.id,
652
+ conversationId: target?.conversationId || event.conversation?.id,
653
+ actorId: target?.senderOpenDingTalkId || event.actor?.openDingTalkId,
654
+ });
655
+ }
656
+ function scopeFromEvent(fieldId, event) {
657
+ if (!['im.message.dm', 'im.message.mentioned'].includes(event.kind))
658
+ return null;
659
+ return completeScope({
660
+ fieldId,
661
+ tenantId: event.tenant?.id,
662
+ conversationId: event.conversation?.id,
663
+ actorId: event.actor?.openDingTalkId,
664
+ });
665
+ }
666
+ function scopeWithoutField(event) {
667
+ if (!['im.message.dm', 'im.message.mentioned'].includes(event.kind))
668
+ return null;
669
+ return completeScope({
670
+ tenantId: event.tenant?.id,
671
+ conversationId: event.conversation?.id,
672
+ actorId: event.actor?.openDingTalkId,
673
+ });
674
+ }
675
+ function sameEventScope(scope, expected) {
676
+ return scope?.tenantId === expected.tenantId &&
677
+ scope?.conversationId === expected.conversationId &&
678
+ scope?.actorId === expected.actorId;
679
+ }
680
+ function completeScope(value) {
681
+ const scope = Object.fromEntries(Object.entries(value).map(([k, v]) => [k, String(v || '').trim()]));
682
+ return Object.values(scope).every(Boolean) ? scope : null;
683
+ }
684
+ function assertScope(record, scope, scopeHash) {
685
+ if (record.scopeHash !== scopeHash || stableStringify(record.scope) !== stableStringify(scope)) {
686
+ throw new Error(`Wait scope hash 碰撞或内容漂移: ${scopeHash}`);
687
+ }
688
+ }
689
+ function expired(record) {
690
+ const deadline = Date.parse(record.expiresAt || '');
691
+ return Number.isFinite(deadline) && deadline <= Date.now();
692
+ }
693
+ function positiveNumber(value, fallback) {
694
+ const n = Number(value);
695
+ return Number.isFinite(n) && n > 0 ? n : fallback;
696
+ }
697
+ function applyReceipt(record, receipt) {
698
+ const keepLifecycle = new Set([
699
+ 'claimed', 'cancel-pending', 'cancelled', 'completed', 'continued', 'expired',
700
+ ]).has(record.status);
701
+ const transportAccepted = Boolean(record.delivery?.transportAccepted);
702
+ return {
703
+ ...record,
704
+ status: keepLifecycle
705
+ ? record.status
706
+ : (receipt.outcome === 'uncertain' && !transportAccepted ? 'uncertain' : 'active'),
707
+ updatedAt: new Date().toISOString(),
708
+ lastAppliedReceiptActionId: receipt.actionId,
709
+ delivery: {
710
+ ...(record.delivery || {}),
711
+ outcome: receipt.outcome,
712
+ verified: Boolean(receipt.verified),
713
+ verification: receipt.verification || '',
714
+ questionMessageId: findValue(receipt, ['openMessageId', 'openMsgId', 'open_message_id']) ||
715
+ record.delivery?.questionMessageId || '',
716
+ },
717
+ };
718
+ }
719
+ function applyTransportResult(record, result) {
720
+ return {
721
+ ...record,
722
+ status: record.status === 'arming' || record.status === 'uncertain'
723
+ ? 'active' : record.status,
724
+ updatedAt: new Date().toISOString(),
725
+ delivery: {
726
+ ...(record.delivery || {}),
727
+ transportAccepted: true,
728
+ questionMessageId: findValue(result, ['openMessageId', 'openMsgId', 'open_message_id']) ||
729
+ record.delivery?.questionMessageId || '',
730
+ },
731
+ };
732
+ }
733
+ /** 在 incoming Event 路由时修复 Receipt/transport-result 已落盘但 Wait 仍 arming 的窗口。 */
734
+ function repairFromReceipt(root, cfg, path, record) {
735
+ if (record.status !== 'arming')
736
+ return record;
737
+ const actionPath = join(resolve(root, cfg.runtime?.eventIndexRoot || '.dingtalk-agent/event-index'), 'actions', record.sourceRunId, record.actionId);
738
+ const receiptPath = join(actionPath, 'receipt.json');
739
+ const resultPath = join(actionPath, 'transport-result.json');
740
+ if (!existsSync(receiptPath) && !existsSync(resultPath))
741
+ return record;
742
+ const receipt = existsSync(receiptPath) ? readJson(receiptPath) : null;
743
+ const result = existsSync(resultPath) ? readJson(resultPath) : null;
744
+ const next = {
745
+ ...record,
746
+ status: (result || receipt?.outcome !== 'uncertain') ? 'active' : 'uncertain',
747
+ updatedAt: new Date().toISOString(),
748
+ lastAppliedReceiptActionId: receipt?.actionId || record.lastAppliedReceiptActionId,
749
+ delivery: {
750
+ ...(record.delivery || {}),
751
+ transportAccepted: Boolean(result),
752
+ outcome: receipt?.outcome || 'transport-accepted',
753
+ verified: Boolean(receipt?.verified),
754
+ verification: receipt?.verification || '',
755
+ questionMessageId: findValue(receipt, ['openMessageId', 'openMsgId', 'open_message_id']) ||
756
+ findValue(result, ['openMessageId', 'openMsgId', 'open_message_id']) ||
757
+ record.delivery?.questionMessageId || '',
758
+ },
759
+ };
760
+ persistWait(path, next);
761
+ return next;
762
+ }
763
+ function applyCancelFence(root, wait, eventId) {
764
+ const releaseEffect = acquireSessionEffectLock(root, wait.sessionPath);
765
+ try {
766
+ const lockRoot = resolve(root, '.dingtalk-agent/session-locks');
767
+ mkdirSync(lockRoot, { recursive: true });
768
+ const releaseState = acquireLock(join(lockRoot, `${digest(resolve(wait.sessionPath))}.lock`), 2 * 60_000);
769
+ try {
770
+ const sessionPath = join(resolve(wait.sessionPath), 'session.json');
771
+ if (!existsSync(sessionPath))
772
+ throw new Error(`取消目标 Session 不存在: ${wait.sessionId}`);
773
+ const session = readJson(sessionPath);
774
+ if (session.id !== wait.sessionId || session.fieldId !== wait.fieldId) {
775
+ throw new Error(`取消目标 Session 身份不一致: ${wait.sessionId}`);
776
+ }
777
+ if (session.lastCancelEventId === eventId)
778
+ return;
779
+ const generation = validGeneration(session.generation);
780
+ persist(sessionPath, {
781
+ ...session,
782
+ generation: generation + 1,
783
+ lastCancelEventId: eventId,
784
+ cancelledAt: new Date().toISOString(),
785
+ updatedAt: new Date().toISOString(),
786
+ });
787
+ }
788
+ finally {
789
+ releaseState();
790
+ }
791
+ }
792
+ finally {
793
+ releaseEffect();
794
+ }
795
+ }
796
+ function validGeneration(value) {
797
+ const n = Number(value ?? 1);
798
+ if (!Number.isInteger(n) || n < 1)
799
+ throw new Error('Session generation 非法,拒绝取消');
800
+ return n;
801
+ }
802
+ function persist(path, value) {
803
+ mkdirSync(dirname(path), { recursive: true });
804
+ const temp = `${path}.${process.pid}.${Date.now()}.${randomUUID()}.tmp`;
805
+ writeFileSync(temp, JSON.stringify(value, null, 2) + '\n', { flag: 'wx' });
806
+ renameSync(temp, path);
807
+ }
808
+ function writeJsonCreateAtomic(path, value) {
809
+ mkdirSync(dirname(path), { recursive: true });
810
+ const temp = `${path}.${process.pid}.${Date.now()}.${randomUUID()}.tmp`;
811
+ writeFileSync(temp, JSON.stringify(value, null, 2) + '\n', { flag: 'wx' });
812
+ try {
813
+ linkSync(temp, path);
814
+ return true;
815
+ }
816
+ catch (e) {
817
+ if (e.code === 'EEXIST')
818
+ return false;
819
+ throw e;
820
+ }
821
+ finally {
822
+ rmSync(temp, { force: true });
823
+ }
824
+ }
825
+ function persistWait(scopePath, record) {
826
+ persist(scopePath, record);
827
+ persistHistory(scopePath, record);
828
+ }
829
+ function persistHistory(scopePath, record) {
830
+ persist(join(dirname(scopePath), 'history', `${record.id}.json`), record);
831
+ mirror(record);
832
+ }
833
+ function mirror(record) {
834
+ try {
835
+ const dir = join(record.sessionPath, 'waits');
836
+ mkdirSync(dir, { recursive: true });
837
+ persist(join(dir, `${record.id}.json`), record);
838
+ }
839
+ catch (e) {
840
+ console.error(` ⚠️ Wait 审计镜像写入失败(waitRoot 权威真值已保留): ${e.message}`);
841
+ }
842
+ }
843
+ function readJson(path) {
844
+ return JSON.parse(readFileSync(path, 'utf8'));
845
+ }
846
+ const LOCK_HOST = hostname();
847
+ const sleep = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
848
+ function withLock(path, fn) {
849
+ const lockPath = `${path}.lock`;
850
+ mkdirSync(dirname(path), { recursive: true });
851
+ const release = acquireLock(lockPath);
852
+ try {
853
+ return fn();
854
+ }
855
+ finally {
856
+ release();
857
+ }
858
+ }
859
+ function acquireLock(path, staleAfterMs = 60_000, waitMs = 5_000) {
860
+ const token = randomUUID();
861
+ const deadline = Date.now() + waitMs;
862
+ while (true) {
863
+ try {
864
+ mkdirSync(path);
865
+ try {
866
+ persist(join(path, 'owner.json'), {
867
+ token, pid: process.pid, host: LOCK_HOST, acquiredAt: new Date().toISOString(),
868
+ });
869
+ }
870
+ catch (e) {
871
+ rmSync(path, { recursive: true, force: true });
872
+ throw e;
873
+ }
874
+ return () => {
875
+ try {
876
+ const owner = readJson(join(path, 'owner.json'));
877
+ if (owner.token === token)
878
+ rmSync(path, { recursive: true, force: true });
879
+ }
880
+ catch { }
881
+ };
882
+ }
883
+ catch (e) {
884
+ if (e.code !== 'EEXIST')
885
+ throw e;
886
+ if (staleLock(path, staleAfterMs)) {
887
+ const tomb = `${path}.stale.${process.pid}.${randomUUID()}`;
888
+ try {
889
+ renameSync(path, tomb);
890
+ rmSync(tomb, { recursive: true, force: true });
891
+ continue;
892
+ }
893
+ catch (moveError) {
894
+ if (moveError.code === 'ENOENT' || moveError.code === 'EEXIST')
895
+ continue;
896
+ throw moveError;
897
+ }
898
+ }
899
+ if (Date.now() >= deadline)
900
+ throw new Error(`Wait 正在被其他事件更新: ${path}`);
901
+ sleep(50);
902
+ }
903
+ }
904
+ }
905
+ function staleLock(path, staleAfterMs) {
906
+ let age;
907
+ try {
908
+ age = Date.now() - statSync(path).mtimeMs;
909
+ }
910
+ catch {
911
+ return true;
912
+ }
913
+ let owner;
914
+ try {
915
+ owner = readJson(join(path, 'owner.json'));
916
+ }
917
+ catch { }
918
+ if (!owner)
919
+ return age >= 2_000;
920
+ if (owner.host === LOCK_HOST && Number.isInteger(Number(owner.pid))) {
921
+ try {
922
+ process.kill(Number(owner.pid), 0);
923
+ return false;
924
+ }
925
+ catch (e) {
926
+ if (e.code === 'ESRCH')
927
+ return true;
928
+ }
929
+ }
930
+ return age >= staleAfterMs;
931
+ }
932
+ function findValue(value, keys) {
933
+ if (!value || typeof value !== 'object')
934
+ return '';
935
+ for (const key of keys)
936
+ if (value[key])
937
+ return String(value[key]);
938
+ for (const child of Object.values(value)) {
939
+ const found = findValue(child, keys);
940
+ if (found)
941
+ return found;
942
+ }
943
+ return '';
944
+ }
945
+ //# sourceMappingURL=waits.js.map