mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-11 18:45:43 +08:00
476 lines
16 KiB
JavaScript
476 lines
16 KiB
JavaScript
const APPROVAL_REPLIES = new Map([
|
||
['批准', 'allowed-once'],
|
||
['同意', 'allowed-once'],
|
||
['yes', 'allowed-once'],
|
||
['拒绝', 'rejected'],
|
||
['不同意', 'rejected'],
|
||
['no', 'rejected'],
|
||
]);
|
||
|
||
const APPROVAL_PROMPT = '请精准回复「批准」或「拒绝」(也支持:同意 / 不同意 / yes / no)。';
|
||
const APPROVAL_AFTER_QUESTION_PROMPT = '请先完成当前问题,再精准回复「批准」或「拒绝」。';
|
||
const APPROVAL_RESOLVED_TEXT = '该审批已处理,无需再次回复。';
|
||
const RESOLVED_ROUTE_TTL_MS = 5 * 60_000;
|
||
const MAX_RESOLVED_ROUTES = 2_048;
|
||
|
||
function cleanText(value) {
|
||
return typeof value === 'string' ? value.trim() : '';
|
||
}
|
||
|
||
function printableText(value) {
|
||
return cleanText(value).replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, '');
|
||
}
|
||
|
||
export function harnessApprovalDecision(text) {
|
||
return APPROVAL_REPLIES.get(cleanText(text).toLowerCase()) ?? null;
|
||
}
|
||
|
||
export function validHarnessApproval(payload) {
|
||
return payload?.type === 'approval/requested'
|
||
&& Boolean(cleanText(payload.sessionId))
|
||
&& Boolean(cleanText(payload.approvalId))
|
||
&& Boolean(cleanText(payload.toolName))
|
||
&& (payload.callId === undefined || Boolean(cleanText(payload.callId)))
|
||
&& (payload.reason === undefined || typeof payload.reason === 'string');
|
||
}
|
||
|
||
function toolArguments(toolCall) {
|
||
const source = toolCall?.arguments;
|
||
if (source !== null && typeof source === 'object') {
|
||
try {
|
||
return JSON.stringify(source, null, 2);
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
if (typeof source !== 'string') return null;
|
||
const raw = printableText(source);
|
||
// Harness treats an empty tool argument string as an empty object.
|
||
if (!raw) return source === '' ? '{}' : null;
|
||
try {
|
||
return JSON.stringify(JSON.parse(raw), null, 2);
|
||
} catch {
|
||
return raw;
|
||
}
|
||
}
|
||
|
||
export function harnessApprovalText(payload, {
|
||
toolCall,
|
||
requiresMention = false,
|
||
maxArgumentsLength = 6_000,
|
||
} = {}) {
|
||
if (!validHarnessApproval(payload)) return null;
|
||
const callId = cleanText(payload.callId);
|
||
if (!callId
|
||
|| cleanText(toolCall?.callId) !== callId
|
||
|| cleanText(toolCall?.name) !== cleanText(payload.toolName)) return null;
|
||
const operation = toolArguments(toolCall);
|
||
if (!operation || operation.length > maxArgumentsLength) return null;
|
||
|
||
const lines = [
|
||
'DeepSeek Harness 需要你的审批:',
|
||
'',
|
||
`工具:${printableText(payload.toolName)}`,
|
||
'操作参数:',
|
||
operation,
|
||
];
|
||
const reason = printableText(payload.reason);
|
||
if (reason) lines.push(`原因:${reason}`);
|
||
lines.push('', APPROVAL_PROMPT);
|
||
if (requiresMention) lines.push('', '群聊中请 @机器人 后发送审批决定。');
|
||
return lines.join('\n');
|
||
}
|
||
|
||
function approvalResult(pending, outcome) {
|
||
return {
|
||
ok: true,
|
||
value: {
|
||
sessionId: pending.sessionId,
|
||
approvalId: pending.approvalId,
|
||
outcome,
|
||
},
|
||
};
|
||
}
|
||
|
||
function approvalOutcomeText(outcome) {
|
||
if (outcome === 'allowed-once') return '已批准,仅对本次操作有效。';
|
||
if (outcome === 'rejected') return '已拒绝此次操作。';
|
||
return APPROVAL_RESOLVED_TEXT;
|
||
}
|
||
|
||
export class HarnessApprovalQueue {
|
||
#label;
|
||
#logger;
|
||
#byId = new Map();
|
||
#routes = new Map();
|
||
#resolvedRoutes = new Map();
|
||
|
||
constructor({ label = 'IM', logger = console } = {}) {
|
||
this.#label = label;
|
||
this.#logger = logger;
|
||
}
|
||
|
||
hasPending(key) {
|
||
return this.#routes.get(key)?.items.some((pending) => !pending.inactive) === true;
|
||
}
|
||
|
||
claimReply({
|
||
key,
|
||
actor,
|
||
text,
|
||
addressed = true,
|
||
hasPendingQuestion = false,
|
||
questionCompletion,
|
||
isQuestionPending,
|
||
send,
|
||
}) {
|
||
const route = this.#routes.get(key);
|
||
const pending = route?.items[0];
|
||
const decision = harnessApprovalDecision(text);
|
||
const notice = (value, resolved = false) => ({
|
||
...(resolved ? { resolved: true } : {}),
|
||
process: async (before) => {
|
||
if (typeof before === 'function' && await before() === false) return;
|
||
await send(value);
|
||
},
|
||
});
|
||
// Match Harness' own interaction precedence: a live ask_user_question
|
||
// outranks sibling approvals. Otherwise a question answer such as "yes"
|
||
// could accidentally authorize a tool call.
|
||
const deferredByQuestion = hasPendingQuestion
|
||
&& pending
|
||
&& questionCompletion
|
||
&& typeof questionCompletion.then === 'function';
|
||
if (hasPendingQuestion && !deferredByQuestion) return null;
|
||
if (!pending || pending.inactive) {
|
||
const resolvedUntil = this.#resolvedRoutes.get(key) ?? 0;
|
||
if (resolvedUntil <= Date.now()) {
|
||
this.#resolvedRoutes.delete(key);
|
||
return null;
|
||
}
|
||
if (!decision) return null;
|
||
return notice(APPROVAL_RESOLVED_TEXT, true);
|
||
}
|
||
if (pending.actor !== actor || (pending.requiresMention && addressed !== true)) {
|
||
if (!decision) return null;
|
||
return notice('只有发起当前任务的用户可以处理这条审批。');
|
||
}
|
||
|
||
return {
|
||
process: async (before) => {
|
||
const presentedWhenClaimed = pending.presented;
|
||
const previous = pending.replyTail ?? Promise.resolve();
|
||
const task = previous
|
||
.catch(() => undefined)
|
||
.then(async () => {
|
||
if (typeof before === 'function' && await before() === false) return;
|
||
if (deferredByQuestion) {
|
||
await questionCompletion.catch(() => undefined);
|
||
if (pending.inactive || pending.resolving) return;
|
||
if (typeof isQuestionPending === 'function' && isQuestionPending()) {
|
||
await send(APPROVAL_AFTER_QUESTION_PROMPT);
|
||
return;
|
||
}
|
||
}
|
||
await pending.activationTask?.catch(() => undefined);
|
||
await pending.presentationTask?.catch(() => undefined);
|
||
if (pending.inactive || pending.resolving) {
|
||
await send(APPROVAL_RESOLVED_TEXT);
|
||
return;
|
||
}
|
||
pending.send = send;
|
||
// Never turn a decision sent before the operation was visibly
|
||
// presented into an approval. This also covers a failed presentation
|
||
// and the small FIFO promotion window before the next item is shown.
|
||
if (!presentedWhenClaimed || !pending.presented) {
|
||
if (!pending.presented) await this.#present(pending);
|
||
if (pending.inactive || pending.resolving) return;
|
||
await send(APPROVAL_PROMPT);
|
||
return;
|
||
}
|
||
if (pending.submitting) {
|
||
await send('审批决定正在提交,请稍候。');
|
||
return;
|
||
}
|
||
if (!decision) {
|
||
await send(APPROVAL_PROMPT);
|
||
return;
|
||
}
|
||
await this.#submit(pending, decision);
|
||
});
|
||
pending.replyTail = task;
|
||
try {
|
||
await task;
|
||
} finally {
|
||
if (pending.replyTail === task) pending.replyTail = null;
|
||
}
|
||
},
|
||
};
|
||
}
|
||
|
||
async handleRequested(interaction, context) {
|
||
if (interaction?.kind !== 'approval') return false;
|
||
const payload = interaction.payload;
|
||
const approvalId = cleanText(payload?.approvalId);
|
||
if (!cleanText(interaction.rpcId)
|
||
|| !cleanText(interaction.sessionId)
|
||
|| !approvalId
|
||
|| !validHarnessApproval(payload)
|
||
|| payload.sessionId !== interaction.sessionId
|
||
|| typeof interaction.respond !== 'function') {
|
||
this.#logger.warn?.(`[dsh-im:${this.#label}] ignored an invalid Harness approval`);
|
||
return true;
|
||
}
|
||
|
||
if (interaction.recovered === true) {
|
||
await this.#rejectInteraction(interaction, payload);
|
||
return true;
|
||
}
|
||
|
||
const existing = this.#byId.get(approvalId);
|
||
if (existing) {
|
||
existing.interaction = interaction;
|
||
existing.toolCall = interaction.toolCall;
|
||
if (!existing.presented) await this.#present(existing);
|
||
return true;
|
||
}
|
||
|
||
const send = context?.send;
|
||
const key = cleanText(context?.key);
|
||
const actor = cleanText(context?.actor);
|
||
if (!key || !actor || typeof send !== 'function') {
|
||
this.#logger.warn?.(`[dsh-im:${this.#label}] ignored an approval without a reply route`);
|
||
await this.#rejectInteraction(interaction, payload);
|
||
return true;
|
||
}
|
||
|
||
const text = harnessApprovalText(payload, {
|
||
toolCall: interaction.toolCall,
|
||
requiresMention: context.requiresMention === true,
|
||
});
|
||
if (!text) {
|
||
const rejected = await this.#rejectInteraction(interaction, payload);
|
||
await send(rejected
|
||
? '无法完整展示这次操作,已安全拒绝此次审批。'
|
||
: APPROVAL_RESOLVED_TEXT);
|
||
return true;
|
||
}
|
||
|
||
const pending = {
|
||
approvalId,
|
||
sessionId: interaction.sessionId,
|
||
interaction,
|
||
toolCall: interaction.toolCall,
|
||
key,
|
||
actor,
|
||
requiresMention: context.requiresMention === true,
|
||
send,
|
||
text,
|
||
presented: false,
|
||
presentationTask: null,
|
||
deliveryCompleted: false,
|
||
replyTail: null,
|
||
submitting: false,
|
||
inactive: false,
|
||
resolving: false,
|
||
closedOutcome: null,
|
||
resolutionNotified: false,
|
||
activationTask: null,
|
||
};
|
||
this.#byId.set(approvalId, pending);
|
||
const route = this.#routes.get(key) ?? { items: [] };
|
||
route.items.push(pending);
|
||
this.#routes.set(key, route);
|
||
if (route.items[0] === pending) await this.#present(pending);
|
||
return true;
|
||
}
|
||
|
||
async handleResolved(resolution) {
|
||
if (resolution?.kind !== 'approval') return false;
|
||
const pending = this.#byId.get(cleanText(resolution.interactionId));
|
||
if (!pending) return true;
|
||
// A queued item may already be the next route head while the previous
|
||
// item's confirmation is still in flight. Preserve that route barrier so
|
||
// resolving this item cannot expose a later approval out of order.
|
||
pending.resolving = true;
|
||
if (pending.activationTask) {
|
||
await pending.activationTask.catch(() => undefined);
|
||
}
|
||
if (pending.inactive || this.#byId.get(pending.approvalId) !== pending) return true;
|
||
const presentationTask = pending.presentationTask;
|
||
const shouldNotify = pending.presented || presentationTask;
|
||
const send = pending.send;
|
||
const next = this.#remove(pending);
|
||
await this.#transition(next, async () => {
|
||
let delivered = pending.presented;
|
||
if (presentationTask) {
|
||
delivered = await presentationTask.then(() => true, () => false);
|
||
}
|
||
if (shouldNotify && delivered) {
|
||
pending.resolutionNotified = true;
|
||
await send(approvalOutcomeText(resolution.outcome)).catch(() => undefined);
|
||
}
|
||
});
|
||
return true;
|
||
}
|
||
|
||
async closeRoute(key) {
|
||
const route = this.#routes.get(key);
|
||
if (!route) return;
|
||
const pendingItems = [...route.items];
|
||
for (const pending of pendingItems) this.#remove(pending);
|
||
await Promise.all(pendingItems.map(async (pending) => {
|
||
try {
|
||
await pending.interaction.respond(
|
||
approvalResult(pending, 'rejected'),
|
||
{ signal: AbortSignal.timeout(5_000) },
|
||
);
|
||
pending.closedOutcome = 'rejected';
|
||
if ((pending.presented || pending.deliveryCompleted) && !pending.resolutionNotified) {
|
||
pending.resolutionNotified = true;
|
||
await pending.send(approvalOutcomeText('rejected')).catch(() => undefined);
|
||
}
|
||
} catch (error) {
|
||
if (error?.code === 'interaction-not-pending') {
|
||
pending.closedOutcome = 'resolved';
|
||
if ((pending.presented || pending.deliveryCompleted) && !pending.resolutionNotified) {
|
||
pending.resolutionNotified = true;
|
||
await pending.send(APPROVAL_RESOLVED_TEXT).catch(() => undefined);
|
||
}
|
||
} else {
|
||
this.#logger.warn?.(`[dsh-im:${this.#label}] failed to reject a closing approval:`, error);
|
||
}
|
||
}
|
||
}));
|
||
}
|
||
|
||
async #present(pending) {
|
||
if (this.#routes.get(pending.key)?.items[0] !== pending
|
||
|| pending.inactive || pending.resolving || pending.presented) return;
|
||
await pending.activationTask?.catch(() => undefined);
|
||
if (this.#routes.get(pending.key)?.items[0] !== pending
|
||
|| pending.inactive || pending.resolving || pending.presented) return;
|
||
if (pending.presentationTask) return pending.presentationTask;
|
||
const task = Promise.resolve().then(() => pending.send(pending.text));
|
||
pending.presentationTask = task;
|
||
try {
|
||
await task;
|
||
pending.deliveryCompleted = true;
|
||
if (!pending.inactive) {
|
||
pending.presented = true;
|
||
} else if (pending.closedOutcome && !pending.resolutionNotified) {
|
||
pending.resolutionNotified = true;
|
||
await pending.send(approvalOutcomeText(pending.closedOutcome)).catch(() => undefined);
|
||
}
|
||
} finally {
|
||
if (pending.presentationTask === task) pending.presentationTask = null;
|
||
}
|
||
}
|
||
|
||
async #submit(pending, outcome) {
|
||
pending.submitting = true;
|
||
try {
|
||
await pending.interaction.respond(approvalResult(pending, outcome));
|
||
} catch (error) {
|
||
if (error?.code === 'interaction-not-pending') {
|
||
const send = pending.send;
|
||
const next = this.#remove(pending);
|
||
await this.#transition(next, async () => {
|
||
if (!pending.resolutionNotified) {
|
||
await send(APPROVAL_RESOLVED_TEXT).catch(() => undefined);
|
||
}
|
||
});
|
||
return;
|
||
}
|
||
if (pending.inactive) return;
|
||
pending.submitting = false;
|
||
this.#logger.error?.(`[dsh-im:${this.#label}] failed to submit an approval:`, error);
|
||
await pending.send('审批提交失败,请重新回复「批准」或「拒绝」。').catch(() => undefined);
|
||
return;
|
||
}
|
||
|
||
const send = pending.send;
|
||
const next = this.#remove(pending);
|
||
await this.#transition(next, async () => {
|
||
if (!pending.resolutionNotified) {
|
||
await send(approvalOutcomeText(outcome)).catch(() => undefined);
|
||
}
|
||
});
|
||
}
|
||
|
||
async #transition(next, work) {
|
||
let release;
|
||
const barrier = new Promise((resolve) => { release = resolve; });
|
||
if (next) next.activationTask = barrier;
|
||
try {
|
||
await work();
|
||
} finally {
|
||
release();
|
||
if (next?.activationTask === barrier) next.activationTask = null;
|
||
}
|
||
await this.#promote(next);
|
||
}
|
||
|
||
async #promote(pending) {
|
||
if (!pending) return;
|
||
try {
|
||
await this.#present(pending);
|
||
} catch (error) {
|
||
this.#logger.error?.(
|
||
`[dsh-im:${this.#label}] failed to present the next approval:`,
|
||
error,
|
||
);
|
||
try {
|
||
pending.interaction.reconnect?.();
|
||
} catch {
|
||
// A replay will retry presentation when the transport can reconnect.
|
||
}
|
||
}
|
||
}
|
||
|
||
#remove(pending) {
|
||
if (pending.inactive) return null;
|
||
pending.inactive = true;
|
||
this.#rememberResolvedRoute(pending.key);
|
||
this.#byId.delete(pending.approvalId);
|
||
const route = this.#routes.get(pending.key);
|
||
if (!route) return null;
|
||
const wasCurrent = route.items[0] === pending;
|
||
const index = route.items.indexOf(pending);
|
||
if (index !== -1) route.items.splice(index, 1);
|
||
if (route.items.length === 0) {
|
||
this.#routes.delete(pending.key);
|
||
return null;
|
||
}
|
||
return wasCurrent ? route.items[0] : null;
|
||
}
|
||
|
||
#rememberResolvedRoute(key) {
|
||
const now = Date.now();
|
||
for (const [routeKey, expiresAt] of this.#resolvedRoutes) {
|
||
if (expiresAt <= now) this.#resolvedRoutes.delete(routeKey);
|
||
}
|
||
this.#resolvedRoutes.delete(key);
|
||
this.#resolvedRoutes.set(key, now + RESOLVED_ROUTE_TTL_MS);
|
||
while (this.#resolvedRoutes.size > MAX_RESOLVED_ROUTES) {
|
||
this.#resolvedRoutes.delete(this.#resolvedRoutes.keys().next().value);
|
||
}
|
||
}
|
||
|
||
async #rejectInteraction(interaction, payload) {
|
||
try {
|
||
await interaction.respond({
|
||
ok: true,
|
||
value: {
|
||
sessionId: interaction.sessionId,
|
||
approvalId: payload.approvalId,
|
||
outcome: 'rejected',
|
||
},
|
||
}, { signal: AbortSignal.timeout(5_000) });
|
||
return true;
|
||
} catch (error) {
|
||
if (error?.code === 'interaction-not-pending') return false;
|
||
throw error;
|
||
}
|
||
}
|
||
}
|