mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-09 14:00:45 +08:00
Merge remote-tracking branch 'upstream/main'
This commit is contained in:
commit
55ed947d9a
22 changed files with 2976 additions and 170 deletions
472
src/channels/shared/harness-approval.mjs
Normal file
472
src/channels/shared/harness-approval.mjs
Normal file
|
|
@ -0,0 +1,472 @@
|
|||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -154,7 +154,36 @@ function consumeInteractionOwnership(ownership, entries) {
|
|||
if (event.type === 'turn/end' && event.data?.turn === ownership.turn) {
|
||||
ownership.active = false;
|
||||
ownership.completed = true;
|
||||
continue;
|
||||
}
|
||||
let toolCall = null;
|
||||
if (event.type === 'tool/call'
|
||||
&& ownership.active
|
||||
&& event.data?.turn === ownership.turn
|
||||
&& typeof event.data?.callId === 'string'
|
||||
&& event.data.callId) {
|
||||
toolCall = {
|
||||
callId: event.data.callId,
|
||||
name: event.data?.name,
|
||||
arguments: event.data?.arguments,
|
||||
};
|
||||
} else if (event.type === 'tool/code-dispatch-start'
|
||||
&& ownership.active
|
||||
&& typeof event.data?.subCallId === 'string'
|
||||
&& event.data.subCallId) {
|
||||
let argumentsText;
|
||||
try {
|
||||
argumentsText = JSON.stringify(event.data?.arguments);
|
||||
} catch {
|
||||
argumentsText = undefined;
|
||||
}
|
||||
toolCall = {
|
||||
callId: event.data.subCallId,
|
||||
name: event.data?.name,
|
||||
arguments: argumentsText,
|
||||
};
|
||||
}
|
||||
if (toolCall) ownership.toolCalls.set(toolCall.callId, Object.freeze(toolCall));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -558,13 +587,12 @@ export class HarnessClient {
|
|||
.sort((left, right) => left.order - right.order);
|
||||
if (active.length > 0) return { ownership: active[0], recovered: false };
|
||||
|
||||
// Approval recovery must remain fail-closed until #5 can prove the actor
|
||||
// and conversation that originally requested the decision.
|
||||
if (kind !== 'question') return null;
|
||||
|
||||
// A newly attached IM conversation may encounter a question left by
|
||||
// an earlier runtime before its queued prompt starts. Let the oldest such
|
||||
// ask adopt that replay so the Session can recover instead of deadlocking.
|
||||
// Approval adopters receive recovered=true and must reject it without ever
|
||||
// presenting it as approvable; the original actor/route cannot be proven
|
||||
// after a runtime restart.
|
||||
const ownership = owners
|
||||
.filter((ownership) => !ownership.started && !ownership.completed)
|
||||
.sort((left, right) => left.order - right.order)[0] ?? null;
|
||||
|
|
@ -614,6 +642,7 @@ export class HarnessClient {
|
|||
lastSeq: baselineSeq,
|
||||
reconnect: null,
|
||||
order: -1,
|
||||
toolCalls: new Map(),
|
||||
}
|
||||
: null;
|
||||
let interactionTask = null;
|
||||
|
|
@ -774,6 +803,9 @@ export class HarnessClient {
|
|||
if (claim?.ownership !== ownership) return;
|
||||
this.#interactionClaims.set(claimKey, claim);
|
||||
}
|
||||
const toolCall = kind === 'approval' && ownership && typeof payload.callId === 'string'
|
||||
? this.#interactionClaims.get(claimKey)?.ownership.toolCalls.get(payload.callId)
|
||||
: undefined;
|
||||
dispatch(onInteraction, Object.freeze({
|
||||
kind,
|
||||
interactionId,
|
||||
|
|
@ -783,6 +815,7 @@ export class HarnessClient {
|
|||
recovered: ownership
|
||||
? this.#interactionClaims.get(claimKey)?.recovered === true
|
||||
: false,
|
||||
...(toolCall ? { toolCall } : {}),
|
||||
reconnect: close,
|
||||
respond: (result, options = {}) => this.respondInteraction(
|
||||
envelope.rpcId,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { runWorkspaceCommand } from './workspace-command.mjs';
|
||||
import { askInWorkspaceSession } from './workspace-session.mjs';
|
||||
import { HarnessApprovalQueue } from './harness-approval.mjs';
|
||||
import {
|
||||
harnessAnswerForQuestion,
|
||||
harnessQuestionText,
|
||||
|
|
@ -43,6 +44,8 @@ export class TextHarnessBridge {
|
|||
#pendingInteractions = new Map();
|
||||
#interactionKeys = new Map();
|
||||
#acceptedMessageIds = new Set();
|
||||
#approvalTasks = new Set();
|
||||
#approvals;
|
||||
|
||||
constructor({
|
||||
descriptor,
|
||||
|
|
@ -65,6 +68,10 @@ export class TextHarnessBridge {
|
|||
this.#logger = logger;
|
||||
this.#replyTimeoutMs = replyTimeoutMs;
|
||||
this.#signal = signal;
|
||||
this.#approvals = new HarnessApprovalQueue({
|
||||
label: descriptor.key,
|
||||
logger,
|
||||
});
|
||||
}
|
||||
|
||||
get status() {
|
||||
|
|
@ -86,6 +93,35 @@ export class TextHarnessBridge {
|
|||
|
||||
const key = `${kind}:${conversationId}`;
|
||||
const pending = this.#pendingInteractions.get(key);
|
||||
const approval = this.#approvals.claimReply({
|
||||
key,
|
||||
actor: senderId,
|
||||
messageId,
|
||||
text: normalized.content,
|
||||
addressed: normalized.kind !== 'group' || normalized.addressed === true,
|
||||
hasPendingQuestion: Boolean(pending),
|
||||
questionCompletion: pending?.submitting || pending?.claimedReplyMessageId
|
||||
? pending.queue
|
||||
: null,
|
||||
isQuestionPending: () => this.#pendingInteractions.has(key),
|
||||
send: (text) => this.#bot.sendText(normalized.replyTarget, text),
|
||||
});
|
||||
if (approval) {
|
||||
let task;
|
||||
task = approval.process(async () => {
|
||||
if (this.#state.hasSeen(messageId)) return false;
|
||||
await this.#state.markSeen(messageId);
|
||||
this.#status.messagesReceived += 1;
|
||||
this.#status.lastMessageAt = new Date().toISOString();
|
||||
return true;
|
||||
})
|
||||
.finally(() => {
|
||||
this.#acceptedMessageIds.delete(messageId);
|
||||
this.#approvalTasks.delete(task);
|
||||
});
|
||||
this.#approvalTasks.add(task);
|
||||
return task;
|
||||
}
|
||||
if (pending && pending.actor !== senderId) {
|
||||
return this.#enqueueMessage(normalized, messageId, senderId, key);
|
||||
}
|
||||
|
|
@ -147,6 +183,7 @@ export class TextHarnessBridge {
|
|||
...[...this.#pendingInteractions.values()].flatMap((pending) => (
|
||||
pending.queue ? [pending.queue] : []
|
||||
)),
|
||||
...this.#approvalTasks,
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
@ -276,7 +313,10 @@ export class TextHarnessBridge {
|
|||
);
|
||||
}
|
||||
} finally {
|
||||
await this.#cancelPendingInteraction(conversationKey);
|
||||
await Promise.allSettled([
|
||||
this.#cancelPendingInteraction(conversationKey),
|
||||
this.#approvals.closeRoute(conversationKey),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -434,8 +474,14 @@ export class TextHarnessBridge {
|
|||
target,
|
||||
requiresMention,
|
||||
}) {
|
||||
// Approval remains deliberately unanswered until #5 supplies a policy that
|
||||
// can prove both the actor and the conversation allowed to decide it.
|
||||
if (interaction?.kind === 'approval') {
|
||||
return this.#approvals.handleRequested(interaction, {
|
||||
key,
|
||||
actor,
|
||||
requiresMention,
|
||||
send: (text) => this.#bot.sendText(target, text),
|
||||
});
|
||||
}
|
||||
if (interaction?.kind !== 'question') return;
|
||||
const questions = interaction?.payload?.questions;
|
||||
const interactionId = cleanText(interaction?.interactionId) || cleanText(interaction?.rpcId);
|
||||
|
|
@ -510,7 +556,11 @@ export class TextHarnessBridge {
|
|||
await this.#presentInteraction(pending);
|
||||
}
|
||||
|
||||
#handleInteractionResolved(resolution) {
|
||||
async #handleInteractionResolved(resolution) {
|
||||
if (resolution?.kind === 'approval') {
|
||||
await this.#approvals.handleResolved(resolution);
|
||||
return;
|
||||
}
|
||||
const interactionId = cleanText(resolution?.interactionId);
|
||||
if (resolution?.kind !== 'question' || !interactionId) return;
|
||||
const key = this.#interactionKeys.get(interactionId);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue