forked from bai/curriculum-project-hub
feat: interrupt running agent via card button with confirm dialog
Add a ⛔ 中断 button to live streaming cards; Feishu's native confirm
dialog is the confirmation step, so no extra card round-trip is needed.
- builder.ts: render interrupt action (danger button + confirm) while the
run is live; new `interrupted` flag renders a 已中断 footer instead of
完成/失败 on the complete card.
- streaming-card.ts: finish(text, { interrupted }) threads the flag into
the final patch; overflow cards suppress the button.
- runner.ts: RunRequest gains abortController, passed to the SDK query;
abort surfaces as RunStatus "interrupted" (no error) in the catch.
- trigger.ts: activeRuns registry maps runId → AbortController; onCardAction
resolves the interrupt button, authorizes agent.cancel, aborts the run.
Interrupted runs persist as CANCELED (spec RunState.canceled, terminal →
lock released per ADR-0002).
- authorizer.ts: agent.cancel now consults PermissionSettings.agentCancel
(MANAGE_ONLY/DISABLED) — previously only agentTrigger was honored, but
spec/PermissionGrant deliberately splits these knobs ("谁能触发" ≠
"谁能取消"). Default role remains MANAGE (Capability.normalCancel).
Tests: runner abort unit test, trigger interrupt + denied integration
tests, three agent.cancel authorization tests (manage allowed, edit denied,
DISABLED policy denies even manage). 26 files / 175 tests pass.
This commit is contained in:
+105
-14
@@ -88,6 +88,10 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
|
||||
const approvalManager = new ApprovalManager();
|
||||
const triggerQueue = deps.triggerQueue ?? defaultTriggerQueue;
|
||||
const batchContexts = new Map<string, TriggerRunContext>();
|
||||
// runId → AbortController for live runs. Registered when a run starts,
|
||||
// used by the interrupt card action to abort the SDK query, cleared in the
|
||||
// run's finally. Absence means the run already terminated (or never started).
|
||||
const activeRuns = new Map<string, AbortController>();
|
||||
const messageBatcher = new MessageBatcher(async (mergedText, key) => {
|
||||
if (key === undefined) {
|
||||
deps.logger.warn("feishu trigger: message batch flushed without a key");
|
||||
@@ -281,6 +285,10 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
|
||||
},
|
||||
});
|
||||
|
||||
// Abort handle for this run; the interrupt card action calls abort() to
|
||||
// stop the SDK query. Cleared in the finally below.
|
||||
const abortController = new AbortController();
|
||||
activeRuns.set(run.id, abortController);
|
||||
runAgent({
|
||||
prompt: promptForAgent,
|
||||
model,
|
||||
@@ -293,6 +301,7 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
|
||||
runId: run.id,
|
||||
sessionId: session.id,
|
||||
prisma: deps.prisma,
|
||||
abortController,
|
||||
onStream: (event) => {
|
||||
switch (event.type) {
|
||||
case "text-delta":
|
||||
@@ -330,13 +339,14 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
|
||||
},
|
||||
})
|
||||
.then(async (result) => {
|
||||
const interrupted = result.status === "interrupted";
|
||||
const finalText =
|
||||
result.text !== ""
|
||||
? result.text
|
||||
: result.status === "failed" && result.error !== undefined
|
||||
? `\u5904\u7406\u5931\u8D25: ${result.error}`
|
||||
: result.text;
|
||||
await card.finish(finalText);
|
||||
await card.finish(finalText, { interrupted });
|
||||
const metadataPatch = sessionMetadataPatch(result.sdkSessionId);
|
||||
if (metadataPatch !== null) {
|
||||
await deps.prisma.agentSession.update({
|
||||
@@ -347,7 +357,7 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
|
||||
await deps.prisma.agentRun.update({
|
||||
where: { id: run.id },
|
||||
data: {
|
||||
status: result.status === "completed" ? "COMPLETED" : result.status === "length" ? "TIMED_OUT" : "FAILED",
|
||||
status: interrupted ? "CANCELED" : result.status === "completed" ? "COMPLETED" : result.status === "length" ? "TIMED_OUT" : "FAILED",
|
||||
inputTokens: result.usage.inputTokens,
|
||||
outputTokens: result.usage.outputTokens,
|
||||
error: result.error ?? null,
|
||||
@@ -389,6 +399,7 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
|
||||
} catch (e) {
|
||||
deps.logger.warn({ projectId, err: e instanceof Error ? e.message : String(e) }, "trigger: could not drain trigger queue");
|
||||
}
|
||||
activeRuns.delete(run.id);
|
||||
});
|
||||
return "started";
|
||||
}
|
||||
@@ -466,20 +477,86 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
|
||||
}
|
||||
|
||||
const onCardAction = async (event: CardActionEvent, rt: FeishuRuntime): Promise<void> => {
|
||||
const action = approvalActionFromValue(event.action.value);
|
||||
if (action === null) return;
|
||||
const messageId = nonEmpty(event.context?.open_message_id);
|
||||
if (messageId === undefined || !approvalManager.hasPending(messageId)) return;
|
||||
// Approval buttons (file-delivery / explicit approval cards).
|
||||
const approvalAction = approvalActionFromValue(event.action.value);
|
||||
if (approvalAction !== null) {
|
||||
const messageId = nonEmpty(event.context?.open_message_id);
|
||||
if (messageId === undefined || !approvalManager.hasPending(messageId)) return;
|
||||
const operatorOpenId = nonEmpty(event.operator.open_id);
|
||||
const resolvedBy = operatorOpenId ?? "unknown";
|
||||
const resolvedByName =
|
||||
operatorOpenId === undefined ? "unknown" : await senderDisplayNameOrOpenId(rt, operatorOpenId);
|
||||
const resolution = approvalResolutionFor(approvalAction);
|
||||
approvalManager.resolve(messageId, approvalAction, resolvedBy);
|
||||
await resolveCard(rt, messageId, resolution.icon, resolution.label, resolution.template, resolvedByName);
|
||||
return;
|
||||
}
|
||||
|
||||
const operatorOpenId = nonEmpty(event.operator.open_id);
|
||||
const resolvedBy = operatorOpenId ?? "unknown";
|
||||
const resolvedByName =
|
||||
operatorOpenId === undefined ? "unknown" : await senderDisplayNameOrOpenId(rt, operatorOpenId);
|
||||
const resolution = approvalResolutionFor(action);
|
||||
approvalManager.resolve(messageId, action, resolvedBy);
|
||||
await resolveCard(rt, messageId, resolution.icon, resolution.label, resolution.template, resolvedByName);
|
||||
// Interrupt button on a live run card.
|
||||
const runId = interruptRunFromValue(event.action.value);
|
||||
if (runId !== null) {
|
||||
await handleInterruptAction(event, rt, runId);
|
||||
}
|
||||
};
|
||||
|
||||
/** Abort a live run after authorizing agent.cancel on its project. */
|
||||
async function handleInterruptAction(event: CardActionEvent, rt: FeishuRuntime, runId: string): Promise<void> {
|
||||
const chatId = nonEmpty(event.context?.open_chat_id);
|
||||
if (chatId === undefined) {
|
||||
deps.logger.warn({ runId }, "feishu interrupt: card action missing chat id");
|
||||
return;
|
||||
}
|
||||
const binding = await deps.prisma.projectGroupBinding.findUnique({
|
||||
where: { chatId },
|
||||
select: { projectId: true },
|
||||
});
|
||||
if (binding === null) {
|
||||
deps.logger.debug({ chatId }, "feishu interrupt: chat not bound to any project");
|
||||
return;
|
||||
}
|
||||
const projectId = binding.projectId;
|
||||
const senderOpenId = nonEmpty(event.operator.open_id) ?? "";
|
||||
if (senderOpenId === "") {
|
||||
deps.logger.warn({ projectId, chatId, runId }, "feishu interrupt: operator has no open_id, denying");
|
||||
await sendText(rt, chatId, "无法识别操作者,拒绝中断。");
|
||||
return;
|
||||
}
|
||||
const actor = { feishuOpenId: senderOpenId, chatId };
|
||||
const decision = await authorizer.can({
|
||||
actor,
|
||||
action: "agent.cancel",
|
||||
resource: { type: "PROJECT", id: projectId },
|
||||
});
|
||||
if (!decision.allowed) {
|
||||
deps.logger.info({ projectId, runId, senderOpenId, reason: decision.reason }, "feishu interrupt: permission denied");
|
||||
await writeAudit(deps.prisma, {
|
||||
projectId,
|
||||
...(decision.actorUserId !== undefined ? { actorUserId: decision.actorUserId } : {}),
|
||||
runId,
|
||||
action: "run.interrupt_denied",
|
||||
metadata: { decision: auditDecision(decision), sender: await senderAuditMetadata(rt, senderOpenId) },
|
||||
});
|
||||
await sendText(rt, chatId, "无权限中断该运行。");
|
||||
return;
|
||||
}
|
||||
const controller = activeRuns.get(runId);
|
||||
if (controller === undefined) {
|
||||
// Run already terminated (or unknown): nothing to abort. The card's
|
||||
// complete-state patch already removed the button, so a stray click is
|
||||
// best-effort ignored rather than spammed with a message.
|
||||
deps.logger.debug({ runId, projectId }, "feishu interrupt: no active run for runId");
|
||||
return;
|
||||
}
|
||||
controller.abort();
|
||||
await writeAudit(deps.prisma, {
|
||||
projectId,
|
||||
...(decision.actorUserId !== undefined ? { actorUserId: decision.actorUserId } : {}),
|
||||
runId,
|
||||
action: "run.interrupted",
|
||||
metadata: { sender: await senderAuditMetadata(rt, senderOpenId) },
|
||||
});
|
||||
}
|
||||
|
||||
const onMessage = async (event: MessageReceiveEvent, rt: FeishuRuntime): Promise<void> => {
|
||||
const msg = event.message;
|
||||
|
||||
@@ -721,9 +798,23 @@ function approvalActionFromValue(value: unknown): string | null {
|
||||
}
|
||||
}
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
|
||||
const action = (value as Record<string, unknown>)["approval_action"];
|
||||
if (!("approval_action" in value)) return null;
|
||||
const action = value.approval_action;
|
||||
return typeof action === "string" && action !== "" ? action : null;
|
||||
}
|
||||
function interruptRunFromValue(value: unknown): string | null {
|
||||
if (typeof value === "string") {
|
||||
try {
|
||||
return interruptRunFromValue(JSON.parse(value));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
|
||||
if (!("interrupt_run" in value)) return null;
|
||||
const runId = value.interrupt_run;
|
||||
return typeof runId === "string" && runId !== "" ? runId : null;
|
||||
}
|
||||
|
||||
function approvalResolutionFor(action: string): { readonly icon: string; readonly label: string; readonly template: "green" | "red" } {
|
||||
const normalized = action.trim().toLowerCase();
|
||||
|
||||
Reference in New Issue
Block a user