@xmanrui/dsh-im 0.7.1 → 0.7.2
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/lib/index.js +113 -112
- package/package.json +1 -1
- package/src/channels/dingtalk/dingtalk-bridge.mjs +66 -3
- package/src/channels/discord/discord-api.mjs +1 -1
- package/src/channels/feishu/bridge.mjs +52 -3
- package/src/channels/qq/qq-bridge.mjs +51 -3
- package/src/channels/shared/harness-approval.mjs +472 -0
- package/src/channels/shared/harness-client.mjs +37 -4
- package/src/channels/shared/text-harness-bridge.mjs +54 -4
- package/src/channels/wecom/wecom-bridge.mjs +51 -3
- package/src/channels/weixin/weixin-api.mjs +1 -1
- package/src/channels/weixin/weixin-bridge.mjs +52 -3
|
@@ -0,0 +1,472 @@
|
|
|
1
|
+
const APPROVAL_REPLIES = new Map([
|
|
2
|
+
['批准', 'allowed-once'],
|
|
3
|
+
['同意', 'allowed-once'],
|
|
4
|
+
['yes', 'allowed-once'],
|
|
5
|
+
['拒绝', 'rejected'],
|
|
6
|
+
['不同意', 'rejected'],
|
|
7
|
+
['no', 'rejected'],
|
|
8
|
+
]);
|
|
9
|
+
|
|
10
|
+
const APPROVAL_PROMPT = '请精准回复「批准」或「拒绝」(也支持:同意 / 不同意 / yes / no)。';
|
|
11
|
+
const APPROVAL_AFTER_QUESTION_PROMPT = '请先完成当前问题,再精准回复「批准」或「拒绝」。';
|
|
12
|
+
const APPROVAL_RESOLVED_TEXT = '该审批已处理,无需再次回复。';
|
|
13
|
+
const RESOLVED_ROUTE_TTL_MS = 5 * 60_000;
|
|
14
|
+
const MAX_RESOLVED_ROUTES = 2_048;
|
|
15
|
+
|
|
16
|
+
function cleanText(value) {
|
|
17
|
+
return typeof value === 'string' ? value.trim() : '';
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function printableText(value) {
|
|
21
|
+
return cleanText(value).replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, '');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function harnessApprovalDecision(text) {
|
|
25
|
+
return APPROVAL_REPLIES.get(cleanText(text).toLowerCase()) ?? null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function validHarnessApproval(payload) {
|
|
29
|
+
return payload?.type === 'approval/requested'
|
|
30
|
+
&& Boolean(cleanText(payload.sessionId))
|
|
31
|
+
&& Boolean(cleanText(payload.approvalId))
|
|
32
|
+
&& Boolean(cleanText(payload.toolName))
|
|
33
|
+
&& (payload.callId === undefined || Boolean(cleanText(payload.callId)))
|
|
34
|
+
&& (payload.reason === undefined || typeof payload.reason === 'string');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function toolArguments(toolCall) {
|
|
38
|
+
const source = toolCall?.arguments;
|
|
39
|
+
if (source !== null && typeof source === 'object') {
|
|
40
|
+
try {
|
|
41
|
+
return JSON.stringify(source, null, 2);
|
|
42
|
+
} catch {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
if (typeof source !== 'string') return null;
|
|
47
|
+
const raw = printableText(source);
|
|
48
|
+
// Harness treats an empty tool argument string as an empty object.
|
|
49
|
+
if (!raw) return source === '' ? '{}' : null;
|
|
50
|
+
try {
|
|
51
|
+
return JSON.stringify(JSON.parse(raw), null, 2);
|
|
52
|
+
} catch {
|
|
53
|
+
return raw;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function harnessApprovalText(payload, {
|
|
58
|
+
toolCall,
|
|
59
|
+
requiresMention = false,
|
|
60
|
+
maxArgumentsLength = 6_000,
|
|
61
|
+
} = {}) {
|
|
62
|
+
if (!validHarnessApproval(payload)) return null;
|
|
63
|
+
const callId = cleanText(payload.callId);
|
|
64
|
+
if (!callId
|
|
65
|
+
|| cleanText(toolCall?.callId) !== callId
|
|
66
|
+
|| cleanText(toolCall?.name) !== cleanText(payload.toolName)) return null;
|
|
67
|
+
const operation = toolArguments(toolCall);
|
|
68
|
+
if (!operation || operation.length > maxArgumentsLength) return null;
|
|
69
|
+
|
|
70
|
+
const lines = [
|
|
71
|
+
'DeepSeek Harness 需要你的审批:',
|
|
72
|
+
'',
|
|
73
|
+
`工具:${printableText(payload.toolName)}`,
|
|
74
|
+
'操作参数:',
|
|
75
|
+
operation,
|
|
76
|
+
];
|
|
77
|
+
const reason = printableText(payload.reason);
|
|
78
|
+
if (reason) lines.push(`原因:${reason}`);
|
|
79
|
+
lines.push('', APPROVAL_PROMPT);
|
|
80
|
+
if (requiresMention) lines.push('', '群聊中请 @机器人 后发送审批决定。');
|
|
81
|
+
return lines.join('\n');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function approvalResult(pending, outcome) {
|
|
85
|
+
return {
|
|
86
|
+
ok: true,
|
|
87
|
+
value: {
|
|
88
|
+
sessionId: pending.sessionId,
|
|
89
|
+
approvalId: pending.approvalId,
|
|
90
|
+
outcome,
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function approvalOutcomeText(outcome) {
|
|
96
|
+
if (outcome === 'allowed-once') return '已批准,仅对本次操作有效。';
|
|
97
|
+
if (outcome === 'rejected') return '已拒绝此次操作。';
|
|
98
|
+
return APPROVAL_RESOLVED_TEXT;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export class HarnessApprovalQueue {
|
|
102
|
+
#label;
|
|
103
|
+
#logger;
|
|
104
|
+
#byId = new Map();
|
|
105
|
+
#routes = new Map();
|
|
106
|
+
#resolvedRoutes = new Map();
|
|
107
|
+
|
|
108
|
+
constructor({ label = 'IM', logger = console } = {}) {
|
|
109
|
+
this.#label = label;
|
|
110
|
+
this.#logger = logger;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
claimReply({
|
|
114
|
+
key,
|
|
115
|
+
actor,
|
|
116
|
+
text,
|
|
117
|
+
addressed = true,
|
|
118
|
+
hasPendingQuestion = false,
|
|
119
|
+
questionCompletion,
|
|
120
|
+
isQuestionPending,
|
|
121
|
+
send,
|
|
122
|
+
}) {
|
|
123
|
+
const route = this.#routes.get(key);
|
|
124
|
+
const pending = route?.items[0];
|
|
125
|
+
const decision = harnessApprovalDecision(text);
|
|
126
|
+
const notice = (value, resolved = false) => ({
|
|
127
|
+
...(resolved ? { resolved: true } : {}),
|
|
128
|
+
process: async (before) => {
|
|
129
|
+
if (typeof before === 'function' && await before() === false) return;
|
|
130
|
+
await send(value);
|
|
131
|
+
},
|
|
132
|
+
});
|
|
133
|
+
// Match Harness' own interaction precedence: a live ask_user_question
|
|
134
|
+
// outranks sibling approvals. Otherwise a question answer such as "yes"
|
|
135
|
+
// could accidentally authorize a tool call.
|
|
136
|
+
const deferredByQuestion = hasPendingQuestion
|
|
137
|
+
&& pending
|
|
138
|
+
&& questionCompletion
|
|
139
|
+
&& typeof questionCompletion.then === 'function';
|
|
140
|
+
if (hasPendingQuestion && !deferredByQuestion) return null;
|
|
141
|
+
if (!pending || pending.inactive) {
|
|
142
|
+
const resolvedUntil = this.#resolvedRoutes.get(key) ?? 0;
|
|
143
|
+
if (resolvedUntil <= Date.now()) {
|
|
144
|
+
this.#resolvedRoutes.delete(key);
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
if (!decision) return null;
|
|
148
|
+
return notice(APPROVAL_RESOLVED_TEXT, true);
|
|
149
|
+
}
|
|
150
|
+
if (pending.actor !== actor || (pending.requiresMention && addressed !== true)) {
|
|
151
|
+
if (!decision) return null;
|
|
152
|
+
return notice('只有发起当前任务的用户可以处理这条审批。');
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return {
|
|
156
|
+
process: async (before) => {
|
|
157
|
+
const presentedWhenClaimed = pending.presented;
|
|
158
|
+
const previous = pending.replyTail ?? Promise.resolve();
|
|
159
|
+
const task = previous
|
|
160
|
+
.catch(() => undefined)
|
|
161
|
+
.then(async () => {
|
|
162
|
+
if (typeof before === 'function' && await before() === false) return;
|
|
163
|
+
if (deferredByQuestion) {
|
|
164
|
+
await questionCompletion.catch(() => undefined);
|
|
165
|
+
if (pending.inactive || pending.resolving) return;
|
|
166
|
+
if (typeof isQuestionPending === 'function' && isQuestionPending()) {
|
|
167
|
+
await send(APPROVAL_AFTER_QUESTION_PROMPT);
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
await pending.activationTask?.catch(() => undefined);
|
|
172
|
+
await pending.presentationTask?.catch(() => undefined);
|
|
173
|
+
if (pending.inactive || pending.resolving) {
|
|
174
|
+
await send(APPROVAL_RESOLVED_TEXT);
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
pending.send = send;
|
|
178
|
+
// Never turn a decision sent before the operation was visibly
|
|
179
|
+
// presented into an approval. This also covers a failed presentation
|
|
180
|
+
// and the small FIFO promotion window before the next item is shown.
|
|
181
|
+
if (!presentedWhenClaimed || !pending.presented) {
|
|
182
|
+
if (!pending.presented) await this.#present(pending);
|
|
183
|
+
if (pending.inactive || pending.resolving) return;
|
|
184
|
+
await send(APPROVAL_PROMPT);
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
if (pending.submitting) {
|
|
188
|
+
await send('审批决定正在提交,请稍候。');
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
if (!decision) {
|
|
192
|
+
await send(APPROVAL_PROMPT);
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
await this.#submit(pending, decision);
|
|
196
|
+
});
|
|
197
|
+
pending.replyTail = task;
|
|
198
|
+
try {
|
|
199
|
+
await task;
|
|
200
|
+
} finally {
|
|
201
|
+
if (pending.replyTail === task) pending.replyTail = null;
|
|
202
|
+
}
|
|
203
|
+
},
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
async handleRequested(interaction, context) {
|
|
208
|
+
if (interaction?.kind !== 'approval') return false;
|
|
209
|
+
const payload = interaction.payload;
|
|
210
|
+
const approvalId = cleanText(payload?.approvalId);
|
|
211
|
+
if (!cleanText(interaction.rpcId)
|
|
212
|
+
|| !cleanText(interaction.sessionId)
|
|
213
|
+
|| !approvalId
|
|
214
|
+
|| !validHarnessApproval(payload)
|
|
215
|
+
|| payload.sessionId !== interaction.sessionId
|
|
216
|
+
|| typeof interaction.respond !== 'function') {
|
|
217
|
+
this.#logger.warn?.(`[dsh-im:${this.#label}] ignored an invalid Harness approval`);
|
|
218
|
+
return true;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (interaction.recovered === true) {
|
|
222
|
+
await this.#rejectInteraction(interaction, payload);
|
|
223
|
+
return true;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const existing = this.#byId.get(approvalId);
|
|
227
|
+
if (existing) {
|
|
228
|
+
existing.interaction = interaction;
|
|
229
|
+
existing.toolCall = interaction.toolCall;
|
|
230
|
+
if (!existing.presented) await this.#present(existing);
|
|
231
|
+
return true;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const send = context?.send;
|
|
235
|
+
const key = cleanText(context?.key);
|
|
236
|
+
const actor = cleanText(context?.actor);
|
|
237
|
+
if (!key || !actor || typeof send !== 'function') {
|
|
238
|
+
this.#logger.warn?.(`[dsh-im:${this.#label}] ignored an approval without a reply route`);
|
|
239
|
+
await this.#rejectInteraction(interaction, payload);
|
|
240
|
+
return true;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const text = harnessApprovalText(payload, {
|
|
244
|
+
toolCall: interaction.toolCall,
|
|
245
|
+
requiresMention: context.requiresMention === true,
|
|
246
|
+
});
|
|
247
|
+
if (!text) {
|
|
248
|
+
const rejected = await this.#rejectInteraction(interaction, payload);
|
|
249
|
+
await send(rejected
|
|
250
|
+
? '无法完整展示这次操作,已安全拒绝此次审批。'
|
|
251
|
+
: APPROVAL_RESOLVED_TEXT);
|
|
252
|
+
return true;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const pending = {
|
|
256
|
+
approvalId,
|
|
257
|
+
sessionId: interaction.sessionId,
|
|
258
|
+
interaction,
|
|
259
|
+
toolCall: interaction.toolCall,
|
|
260
|
+
key,
|
|
261
|
+
actor,
|
|
262
|
+
requiresMention: context.requiresMention === true,
|
|
263
|
+
send,
|
|
264
|
+
text,
|
|
265
|
+
presented: false,
|
|
266
|
+
presentationTask: null,
|
|
267
|
+
deliveryCompleted: false,
|
|
268
|
+
replyTail: null,
|
|
269
|
+
submitting: false,
|
|
270
|
+
inactive: false,
|
|
271
|
+
resolving: false,
|
|
272
|
+
closedOutcome: null,
|
|
273
|
+
resolutionNotified: false,
|
|
274
|
+
activationTask: null,
|
|
275
|
+
};
|
|
276
|
+
this.#byId.set(approvalId, pending);
|
|
277
|
+
const route = this.#routes.get(key) ?? { items: [] };
|
|
278
|
+
route.items.push(pending);
|
|
279
|
+
this.#routes.set(key, route);
|
|
280
|
+
if (route.items[0] === pending) await this.#present(pending);
|
|
281
|
+
return true;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
async handleResolved(resolution) {
|
|
285
|
+
if (resolution?.kind !== 'approval') return false;
|
|
286
|
+
const pending = this.#byId.get(cleanText(resolution.interactionId));
|
|
287
|
+
if (!pending) return true;
|
|
288
|
+
// A queued item may already be the next route head while the previous
|
|
289
|
+
// item's confirmation is still in flight. Preserve that route barrier so
|
|
290
|
+
// resolving this item cannot expose a later approval out of order.
|
|
291
|
+
pending.resolving = true;
|
|
292
|
+
if (pending.activationTask) {
|
|
293
|
+
await pending.activationTask.catch(() => undefined);
|
|
294
|
+
}
|
|
295
|
+
if (pending.inactive || this.#byId.get(pending.approvalId) !== pending) return true;
|
|
296
|
+
const presentationTask = pending.presentationTask;
|
|
297
|
+
const shouldNotify = pending.presented || presentationTask;
|
|
298
|
+
const send = pending.send;
|
|
299
|
+
const next = this.#remove(pending);
|
|
300
|
+
await this.#transition(next, async () => {
|
|
301
|
+
let delivered = pending.presented;
|
|
302
|
+
if (presentationTask) {
|
|
303
|
+
delivered = await presentationTask.then(() => true, () => false);
|
|
304
|
+
}
|
|
305
|
+
if (shouldNotify && delivered) {
|
|
306
|
+
pending.resolutionNotified = true;
|
|
307
|
+
await send(approvalOutcomeText(resolution.outcome)).catch(() => undefined);
|
|
308
|
+
}
|
|
309
|
+
});
|
|
310
|
+
return true;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
async closeRoute(key) {
|
|
314
|
+
const route = this.#routes.get(key);
|
|
315
|
+
if (!route) return;
|
|
316
|
+
const pendingItems = [...route.items];
|
|
317
|
+
for (const pending of pendingItems) this.#remove(pending);
|
|
318
|
+
await Promise.all(pendingItems.map(async (pending) => {
|
|
319
|
+
try {
|
|
320
|
+
await pending.interaction.respond(
|
|
321
|
+
approvalResult(pending, 'rejected'),
|
|
322
|
+
{ signal: AbortSignal.timeout(5_000) },
|
|
323
|
+
);
|
|
324
|
+
pending.closedOutcome = 'rejected';
|
|
325
|
+
if ((pending.presented || pending.deliveryCompleted) && !pending.resolutionNotified) {
|
|
326
|
+
pending.resolutionNotified = true;
|
|
327
|
+
await pending.send(approvalOutcomeText('rejected')).catch(() => undefined);
|
|
328
|
+
}
|
|
329
|
+
} catch (error) {
|
|
330
|
+
if (error?.code === 'interaction-not-pending') {
|
|
331
|
+
pending.closedOutcome = 'resolved';
|
|
332
|
+
if ((pending.presented || pending.deliveryCompleted) && !pending.resolutionNotified) {
|
|
333
|
+
pending.resolutionNotified = true;
|
|
334
|
+
await pending.send(APPROVAL_RESOLVED_TEXT).catch(() => undefined);
|
|
335
|
+
}
|
|
336
|
+
} else {
|
|
337
|
+
this.#logger.warn?.(`[dsh-im:${this.#label}] failed to reject a closing approval:`, error);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
}));
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
async #present(pending) {
|
|
344
|
+
if (this.#routes.get(pending.key)?.items[0] !== pending
|
|
345
|
+
|| pending.inactive || pending.resolving || pending.presented) return;
|
|
346
|
+
await pending.activationTask?.catch(() => undefined);
|
|
347
|
+
if (this.#routes.get(pending.key)?.items[0] !== pending
|
|
348
|
+
|| pending.inactive || pending.resolving || pending.presented) return;
|
|
349
|
+
if (pending.presentationTask) return pending.presentationTask;
|
|
350
|
+
const task = Promise.resolve().then(() => pending.send(pending.text));
|
|
351
|
+
pending.presentationTask = task;
|
|
352
|
+
try {
|
|
353
|
+
await task;
|
|
354
|
+
pending.deliveryCompleted = true;
|
|
355
|
+
if (!pending.inactive) {
|
|
356
|
+
pending.presented = true;
|
|
357
|
+
} else if (pending.closedOutcome && !pending.resolutionNotified) {
|
|
358
|
+
pending.resolutionNotified = true;
|
|
359
|
+
await pending.send(approvalOutcomeText(pending.closedOutcome)).catch(() => undefined);
|
|
360
|
+
}
|
|
361
|
+
} finally {
|
|
362
|
+
if (pending.presentationTask === task) pending.presentationTask = null;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
async #submit(pending, outcome) {
|
|
367
|
+
pending.submitting = true;
|
|
368
|
+
try {
|
|
369
|
+
await pending.interaction.respond(approvalResult(pending, outcome));
|
|
370
|
+
} catch (error) {
|
|
371
|
+
if (error?.code === 'interaction-not-pending') {
|
|
372
|
+
const send = pending.send;
|
|
373
|
+
const next = this.#remove(pending);
|
|
374
|
+
await this.#transition(next, async () => {
|
|
375
|
+
if (!pending.resolutionNotified) {
|
|
376
|
+
await send(APPROVAL_RESOLVED_TEXT).catch(() => undefined);
|
|
377
|
+
}
|
|
378
|
+
});
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
if (pending.inactive) return;
|
|
382
|
+
pending.submitting = false;
|
|
383
|
+
this.#logger.error?.(`[dsh-im:${this.#label}] failed to submit an approval:`, error);
|
|
384
|
+
await pending.send('审批提交失败,请重新回复「批准」或「拒绝」。').catch(() => undefined);
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
const send = pending.send;
|
|
389
|
+
const next = this.#remove(pending);
|
|
390
|
+
await this.#transition(next, async () => {
|
|
391
|
+
if (!pending.resolutionNotified) {
|
|
392
|
+
await send(approvalOutcomeText(outcome)).catch(() => undefined);
|
|
393
|
+
}
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
async #transition(next, work) {
|
|
398
|
+
let release;
|
|
399
|
+
const barrier = new Promise((resolve) => { release = resolve; });
|
|
400
|
+
if (next) next.activationTask = barrier;
|
|
401
|
+
try {
|
|
402
|
+
await work();
|
|
403
|
+
} finally {
|
|
404
|
+
release();
|
|
405
|
+
if (next?.activationTask === barrier) next.activationTask = null;
|
|
406
|
+
}
|
|
407
|
+
await this.#promote(next);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
async #promote(pending) {
|
|
411
|
+
if (!pending) return;
|
|
412
|
+
try {
|
|
413
|
+
await this.#present(pending);
|
|
414
|
+
} catch (error) {
|
|
415
|
+
this.#logger.error?.(
|
|
416
|
+
`[dsh-im:${this.#label}] failed to present the next approval:`,
|
|
417
|
+
error,
|
|
418
|
+
);
|
|
419
|
+
try {
|
|
420
|
+
pending.interaction.reconnect?.();
|
|
421
|
+
} catch {
|
|
422
|
+
// A replay will retry presentation when the transport can reconnect.
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
#remove(pending) {
|
|
428
|
+
if (pending.inactive) return null;
|
|
429
|
+
pending.inactive = true;
|
|
430
|
+
this.#rememberResolvedRoute(pending.key);
|
|
431
|
+
this.#byId.delete(pending.approvalId);
|
|
432
|
+
const route = this.#routes.get(pending.key);
|
|
433
|
+
if (!route) return null;
|
|
434
|
+
const wasCurrent = route.items[0] === pending;
|
|
435
|
+
const index = route.items.indexOf(pending);
|
|
436
|
+
if (index !== -1) route.items.splice(index, 1);
|
|
437
|
+
if (route.items.length === 0) {
|
|
438
|
+
this.#routes.delete(pending.key);
|
|
439
|
+
return null;
|
|
440
|
+
}
|
|
441
|
+
return wasCurrent ? route.items[0] : null;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
#rememberResolvedRoute(key) {
|
|
445
|
+
const now = Date.now();
|
|
446
|
+
for (const [routeKey, expiresAt] of this.#resolvedRoutes) {
|
|
447
|
+
if (expiresAt <= now) this.#resolvedRoutes.delete(routeKey);
|
|
448
|
+
}
|
|
449
|
+
this.#resolvedRoutes.delete(key);
|
|
450
|
+
this.#resolvedRoutes.set(key, now + RESOLVED_ROUTE_TTL_MS);
|
|
451
|
+
while (this.#resolvedRoutes.size > MAX_RESOLVED_ROUTES) {
|
|
452
|
+
this.#resolvedRoutes.delete(this.#resolvedRoutes.keys().next().value);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
async #rejectInteraction(interaction, payload) {
|
|
457
|
+
try {
|
|
458
|
+
await interaction.respond({
|
|
459
|
+
ok: true,
|
|
460
|
+
value: {
|
|
461
|
+
sessionId: interaction.sessionId,
|
|
462
|
+
approvalId: payload.approvalId,
|
|
463
|
+
outcome: 'rejected',
|
|
464
|
+
},
|
|
465
|
+
}, { signal: AbortSignal.timeout(5_000) });
|
|
466
|
+
return true;
|
|
467
|
+
} catch (error) {
|
|
468
|
+
if (error?.code === 'interaction-not-pending') return false;
|
|
469
|
+
throw error;
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
}
|
|
@@ -120,7 +120,36 @@ function consumeInteractionOwnership(ownership, entries) {
|
|
|
120
120
|
if (event.type === 'turn/end' && event.data?.turn === ownership.turn) {
|
|
121
121
|
ownership.active = false;
|
|
122
122
|
ownership.completed = true;
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
let toolCall = null;
|
|
126
|
+
if (event.type === 'tool/call'
|
|
127
|
+
&& ownership.active
|
|
128
|
+
&& event.data?.turn === ownership.turn
|
|
129
|
+
&& typeof event.data?.callId === 'string'
|
|
130
|
+
&& event.data.callId) {
|
|
131
|
+
toolCall = {
|
|
132
|
+
callId: event.data.callId,
|
|
133
|
+
name: event.data?.name,
|
|
134
|
+
arguments: event.data?.arguments,
|
|
135
|
+
};
|
|
136
|
+
} else if (event.type === 'tool/code-dispatch-start'
|
|
137
|
+
&& ownership.active
|
|
138
|
+
&& typeof event.data?.subCallId === 'string'
|
|
139
|
+
&& event.data.subCallId) {
|
|
140
|
+
let argumentsText;
|
|
141
|
+
try {
|
|
142
|
+
argumentsText = JSON.stringify(event.data?.arguments);
|
|
143
|
+
} catch {
|
|
144
|
+
argumentsText = undefined;
|
|
145
|
+
}
|
|
146
|
+
toolCall = {
|
|
147
|
+
callId: event.data.subCallId,
|
|
148
|
+
name: event.data?.name,
|
|
149
|
+
arguments: argumentsText,
|
|
150
|
+
};
|
|
123
151
|
}
|
|
152
|
+
if (toolCall) ownership.toolCalls.set(toolCall.callId, Object.freeze(toolCall));
|
|
124
153
|
}
|
|
125
154
|
}
|
|
126
155
|
|
|
@@ -524,13 +553,12 @@ export class HarnessClient {
|
|
|
524
553
|
.sort((left, right) => left.order - right.order);
|
|
525
554
|
if (active.length > 0) return { ownership: active[0], recovered: false };
|
|
526
555
|
|
|
527
|
-
// Approval recovery must remain fail-closed until #5 can prove the actor
|
|
528
|
-
// and conversation that originally requested the decision.
|
|
529
|
-
if (kind !== 'question') return null;
|
|
530
|
-
|
|
531
556
|
// A newly attached IM conversation may encounter a question left by
|
|
532
557
|
// an earlier runtime before its queued prompt starts. Let the oldest such
|
|
533
558
|
// ask adopt that replay so the Session can recover instead of deadlocking.
|
|
559
|
+
// Approval adopters receive recovered=true and must reject it without ever
|
|
560
|
+
// presenting it as approvable; the original actor/route cannot be proven
|
|
561
|
+
// after a runtime restart.
|
|
534
562
|
const ownership = owners
|
|
535
563
|
.filter((ownership) => !ownership.started && !ownership.completed)
|
|
536
564
|
.sort((left, right) => left.order - right.order)[0] ?? null;
|
|
@@ -580,6 +608,7 @@ export class HarnessClient {
|
|
|
580
608
|
lastSeq: baselineSeq,
|
|
581
609
|
reconnect: null,
|
|
582
610
|
order: -1,
|
|
611
|
+
toolCalls: new Map(),
|
|
583
612
|
}
|
|
584
613
|
: null;
|
|
585
614
|
let interactionTask = null;
|
|
@@ -740,6 +769,9 @@ export class HarnessClient {
|
|
|
740
769
|
if (claim?.ownership !== ownership) return;
|
|
741
770
|
this.#interactionClaims.set(claimKey, claim);
|
|
742
771
|
}
|
|
772
|
+
const toolCall = kind === 'approval' && ownership && typeof payload.callId === 'string'
|
|
773
|
+
? this.#interactionClaims.get(claimKey)?.ownership.toolCalls.get(payload.callId)
|
|
774
|
+
: undefined;
|
|
743
775
|
dispatch(onInteraction, Object.freeze({
|
|
744
776
|
kind,
|
|
745
777
|
interactionId,
|
|
@@ -749,6 +781,7 @@ export class HarnessClient {
|
|
|
749
781
|
recovered: ownership
|
|
750
782
|
? this.#interactionClaims.get(claimKey)?.recovered === true
|
|
751
783
|
: false,
|
|
784
|
+
...(toolCall ? { toolCall } : {}),
|
|
752
785
|
reconnect: close,
|
|
753
786
|
respond: (result, options = {}) => this.respondInteraction(
|
|
754
787
|
envelope.rpcId,
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { runWorkspaceCommand } from './workspace-command.mjs';
|
|
2
2
|
import { askInWorkspaceSession } from './workspace-session.mjs';
|
|
3
|
+
import { HarnessApprovalQueue } from './harness-approval.mjs';
|
|
3
4
|
import {
|
|
4
5
|
harnessAnswerForQuestion,
|
|
5
6
|
harnessQuestionText,
|
|
@@ -43,6 +44,8 @@ export class TextHarnessBridge {
|
|
|
43
44
|
#pendingInteractions = new Map();
|
|
44
45
|
#interactionKeys = new Map();
|
|
45
46
|
#acceptedMessageIds = new Set();
|
|
47
|
+
#approvalTasks = new Set();
|
|
48
|
+
#approvals;
|
|
46
49
|
|
|
47
50
|
constructor({
|
|
48
51
|
descriptor,
|
|
@@ -65,6 +68,10 @@ export class TextHarnessBridge {
|
|
|
65
68
|
this.#logger = logger;
|
|
66
69
|
this.#replyTimeoutMs = replyTimeoutMs;
|
|
67
70
|
this.#signal = signal;
|
|
71
|
+
this.#approvals = new HarnessApprovalQueue({
|
|
72
|
+
label: descriptor.key,
|
|
73
|
+
logger,
|
|
74
|
+
});
|
|
68
75
|
}
|
|
69
76
|
|
|
70
77
|
get status() {
|
|
@@ -86,6 +93,35 @@ export class TextHarnessBridge {
|
|
|
86
93
|
|
|
87
94
|
const key = `${kind}:${conversationId}`;
|
|
88
95
|
const pending = this.#pendingInteractions.get(key);
|
|
96
|
+
const approval = this.#approvals.claimReply({
|
|
97
|
+
key,
|
|
98
|
+
actor: senderId,
|
|
99
|
+
messageId,
|
|
100
|
+
text: normalized.content,
|
|
101
|
+
addressed: normalized.kind !== 'group' || normalized.addressed === true,
|
|
102
|
+
hasPendingQuestion: Boolean(pending),
|
|
103
|
+
questionCompletion: pending?.submitting || pending?.claimedReplyMessageId
|
|
104
|
+
? pending.queue
|
|
105
|
+
: null,
|
|
106
|
+
isQuestionPending: () => this.#pendingInteractions.has(key),
|
|
107
|
+
send: (text) => this.#bot.sendText(normalized.replyTarget, text),
|
|
108
|
+
});
|
|
109
|
+
if (approval) {
|
|
110
|
+
let task;
|
|
111
|
+
task = approval.process(async () => {
|
|
112
|
+
if (this.#state.hasSeen(messageId)) return false;
|
|
113
|
+
await this.#state.markSeen(messageId);
|
|
114
|
+
this.#status.messagesReceived += 1;
|
|
115
|
+
this.#status.lastMessageAt = new Date().toISOString();
|
|
116
|
+
return true;
|
|
117
|
+
})
|
|
118
|
+
.finally(() => {
|
|
119
|
+
this.#acceptedMessageIds.delete(messageId);
|
|
120
|
+
this.#approvalTasks.delete(task);
|
|
121
|
+
});
|
|
122
|
+
this.#approvalTasks.add(task);
|
|
123
|
+
return task;
|
|
124
|
+
}
|
|
89
125
|
if (pending && pending.actor !== senderId) {
|
|
90
126
|
return this.#enqueueMessage(normalized, messageId, senderId, key);
|
|
91
127
|
}
|
|
@@ -147,6 +183,7 @@ export class TextHarnessBridge {
|
|
|
147
183
|
...[...this.#pendingInteractions.values()].flatMap((pending) => (
|
|
148
184
|
pending.queue ? [pending.queue] : []
|
|
149
185
|
)),
|
|
186
|
+
...this.#approvalTasks,
|
|
150
187
|
]);
|
|
151
188
|
}
|
|
152
189
|
|
|
@@ -276,7 +313,10 @@ export class TextHarnessBridge {
|
|
|
276
313
|
);
|
|
277
314
|
}
|
|
278
315
|
} finally {
|
|
279
|
-
await
|
|
316
|
+
await Promise.allSettled([
|
|
317
|
+
this.#cancelPendingInteraction(conversationKey),
|
|
318
|
+
this.#approvals.closeRoute(conversationKey),
|
|
319
|
+
]);
|
|
280
320
|
}
|
|
281
321
|
}
|
|
282
322
|
|
|
@@ -434,8 +474,14 @@ export class TextHarnessBridge {
|
|
|
434
474
|
target,
|
|
435
475
|
requiresMention,
|
|
436
476
|
}) {
|
|
437
|
-
|
|
438
|
-
|
|
477
|
+
if (interaction?.kind === 'approval') {
|
|
478
|
+
return this.#approvals.handleRequested(interaction, {
|
|
479
|
+
key,
|
|
480
|
+
actor,
|
|
481
|
+
requiresMention,
|
|
482
|
+
send: (text) => this.#bot.sendText(target, text),
|
|
483
|
+
});
|
|
484
|
+
}
|
|
439
485
|
if (interaction?.kind !== 'question') return;
|
|
440
486
|
const questions = interaction?.payload?.questions;
|
|
441
487
|
const interactionId = cleanText(interaction?.interactionId) || cleanText(interaction?.rpcId);
|
|
@@ -510,7 +556,11 @@ export class TextHarnessBridge {
|
|
|
510
556
|
await this.#presentInteraction(pending);
|
|
511
557
|
}
|
|
512
558
|
|
|
513
|
-
#handleInteractionResolved(resolution) {
|
|
559
|
+
async #handleInteractionResolved(resolution) {
|
|
560
|
+
if (resolution?.kind === 'approval') {
|
|
561
|
+
await this.#approvals.handleResolved(resolution);
|
|
562
|
+
return;
|
|
563
|
+
}
|
|
514
564
|
const interactionId = cleanText(resolution?.interactionId);
|
|
515
565
|
if (resolution?.kind !== 'question' || !interactionId) return;
|
|
516
566
|
const key = this.#interactionKeys.get(interactionId);
|