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:
@@ -287,6 +287,55 @@ describe("ADR-0019 authorization", () => {
|
||||
expect(decision.allowed).toBe(false);
|
||||
expect(decision.reason).toContain("no active role review grant");
|
||||
});
|
||||
it("agent.cancel defaults to MANAGE and is allowed for a manage grant", async () => {
|
||||
await prisma.project.create({ data: { id: "p-cancel", name: "Cancel", workspaceDir: "/tmp/cancel" } });
|
||||
await prisma.user.create({ data: { id: "u-cancel", feishuOpenId: "ou_cancel", displayName: "Manager" } });
|
||||
await prisma.permissionGrant.create({
|
||||
data: { resourceType: "PROJECT", resourceId: "p-cancel", principalType: "USER", principalId: "ou_cancel", role: "MANAGE" },
|
||||
});
|
||||
|
||||
const decision = await createPermissionAuthorizer(prisma).can({
|
||||
actor: { feishuOpenId: "ou_cancel" },
|
||||
action: "agent.cancel",
|
||||
resource: { type: "PROJECT", id: "p-cancel" },
|
||||
});
|
||||
expect(decision.allowed).toBe(true);
|
||||
expect(decision.requiredRole).toBe("MANAGE");
|
||||
});
|
||||
|
||||
it("agent.cancel is denied to an edit grant (below the MANAGE default)", async () => {
|
||||
await prisma.project.create({ data: { id: "p-cancel-edit", name: "Cancel Edit", workspaceDir: "/tmp/cancel-edit" } });
|
||||
await prisma.user.create({ data: { id: "u-cancel-edit", feishuOpenId: "ou_cancel_edit", displayName: "Editor" } });
|
||||
await prisma.permissionGrant.create({
|
||||
data: { resourceType: "PROJECT", resourceId: "p-cancel-edit", principalType: "USER", principalId: "ou_cancel_edit", role: "EDIT" },
|
||||
});
|
||||
|
||||
const decision = await createPermissionAuthorizer(prisma).can({
|
||||
actor: { feishuOpenId: "ou_cancel_edit" },
|
||||
action: "agent.cancel",
|
||||
resource: { type: "PROJECT", id: "p-cancel-edit" },
|
||||
});
|
||||
expect(decision.allowed).toBe(false);
|
||||
});
|
||||
|
||||
it("agentCancel DISABLED policy denies cancel even for a manage grant", async () => {
|
||||
await prisma.project.create({ data: { id: "p-cancel-off", name: "Cancel Off", workspaceDir: "/tmp/cancel-off" } });
|
||||
await prisma.user.create({ data: { id: "u-cancel-off", feishuOpenId: "ou_cancel_off", displayName: "Manager Off" } });
|
||||
await prisma.permissionGrant.create({
|
||||
data: { resourceType: "PROJECT", resourceId: "p-cancel-off", principalType: "USER", principalId: "ou_cancel_off", role: "MANAGE" },
|
||||
});
|
||||
await prisma.permissionSettings.create({
|
||||
data: defaultProjectSettings("p-cancel-off", { agentCancel: "DISABLED" }),
|
||||
});
|
||||
|
||||
const decision = await createPermissionAuthorizer(prisma).can({
|
||||
actor: { feishuOpenId: "ou_cancel_off" },
|
||||
action: "agent.cancel",
|
||||
resource: { type: "PROJECT", id: "p-cancel-off" },
|
||||
});
|
||||
expect(decision.allowed).toBe(false);
|
||||
expect(decision.reason).toContain("disables this action");
|
||||
});
|
||||
});
|
||||
|
||||
async function seedUserTeamProject(suffix: string) {
|
||||
@@ -309,7 +358,10 @@ async function seedUserTeamProject(suffix: string) {
|
||||
return { user, team, project };
|
||||
}
|
||||
|
||||
function defaultProjectSettings(projectId: string, overrides: { agentTrigger?: string } = {}) {
|
||||
function defaultProjectSettings(
|
||||
projectId: string,
|
||||
overrides: { agentTrigger?: string; agentCancel?: string } = {},
|
||||
) {
|
||||
return {
|
||||
resourceType: "PROJECT" as const,
|
||||
resourceId: projectId,
|
||||
@@ -318,6 +370,6 @@ function defaultProjectSettings(projectId: string, overrides: { agentTrigger?: s
|
||||
copyDownload: "ROLE",
|
||||
collaboratorMgmt: "ROLE",
|
||||
agentTrigger: overrides.agentTrigger ?? "ROLE",
|
||||
agentCancel: "ROLE",
|
||||
agentCancel: overrides.agentCancel ?? "ROLE",
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user