@desplega.ai/agent-swarm 2.5.1 → 2.5.3
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/dist/{acp-adapter-7g6bpwbk.js → acp-adapter-gr2fcyyj.js} +3 -3
- package/dist/{actions-w8xd014k.js → actions-wv766ghf.js} +18 -18
- package/dist/{amp-adapter-z4qvhacj.js → amp-adapter-speb906t.js} +3 -3
- package/dist/{app-nndcvftq.js → app-ag29n14f.js} +3 -3
- package/dist/{assistant-hjmqyscx.js → assistant-3x8bz3eg.js} +19 -19
- package/dist/{boot-reembed-r8rhf9h8.js → boot-reembed-5b3d2eea.js} +1 -1
- package/dist/{boot-reembed-vwcj2y5d.js → boot-reembed-jsmfrpzf.js} +1 -1
- package/dist/{boot-scrub-sweep-fr9q36ne.js → boot-scrub-sweep-s73gerxy.js} +2 -2
- package/dist/{boot-seeds-9jqwnegq.js → boot-seeds-5zmax2wr.js} +4 -4
- package/dist/{claude-adapter-2f8b85df.js → claude-adapter-hswdnm3t.js} +3 -3
- package/dist/{claude-managed-adapter-gt6pa35d.js → claude-managed-adapter-cvbzj1h6.js} +2 -2
- package/dist/{claude-managed-setup-c3hp36ph.js → claude-managed-setup-3k8m3hhc.js} +2 -2
- package/dist/{claude-sdk-session-05kat8rv.js → claude-sdk-session-fkbyk66x.js} +6905 -6855
- package/dist/{cli-y3zyaee5.js → cli-1fc85mvt.js} +5 -5
- package/dist/{cli-v2g93k4v.js → cli-210xd1f4.js} +4 -4
- package/dist/{cli-sn1vbtg2.js → cli-25j01dpj.js} +3 -3
- package/dist/{cli-n6kmqngw.js → cli-35gtk1p6.js} +1 -1
- package/dist/{cli-r9fy0ddc.js → cli-3ngdk5te.js} +1 -1
- package/dist/{cli-z63a269p.js → cli-40jsnrjm.js} +2 -2
- package/dist/{cli-ba8ne1es.js → cli-4rvr7zfx.js} +10 -0
- package/dist/{cli-qfm6my86.js → cli-502eqv93.js} +1 -1
- package/dist/{cli-xd4eqtr2.js → cli-6n8axjv1.js} +1 -1
- package/dist/{cli-hadv0725.js → cli-8s5wx3nz.js} +2 -2
- package/dist/{cli-z2dv25mf.js → cli-99xgw26r.js} +116 -5
- package/dist/{cli-jpxsrxcb.js → cli-9s05422w.js} +1 -1
- package/dist/{cli-kcsd1ez8.js → cli-aecdcr77.js} +343 -325
- package/dist/{cli-w9etnnxk.js → cli-bf8ma3f1.js} +120 -50
- package/dist/{cli-e9btqw4y.js → cli-ce0ak9ms.js} +18 -13
- package/dist/{cli-nj12tn2c.js → cli-e2ar4bp1.js} +71 -20
- package/dist/{cli-gwcf52gz.js → cli-e8jyq7d8.js} +11 -7
- package/dist/{cli-rngde2jv.js → cli-egjggehm.js} +1 -1
- package/dist/{cli-qfjm4gvt.js → cli-ewm3pwbq.js} +2 -2
- package/dist/{cli-9mm5ade4.js → cli-fsdprwrg.js} +1 -1
- package/dist/{cli-3ee5z0q0.js → cli-fzcn3d87.js} +1 -1
- package/dist/{cli-eym3jg7w.js → cli-gpsz7g88.js} +6 -6
- package/dist/{cli-cwyafhw1.js → cli-hcpj2xqz.js} +1 -1
- package/dist/{cli-j1eghqfm.js → cli-hhxjnjzs.js} +1 -1
- package/dist/{cli-pyy4c9ch.js → cli-jjznk3f7.js} +881 -232
- package/dist/{cli-q9pzwxvk.js → cli-kxawjrxg.js} +222 -37
- package/dist/{cli-rh5smt8t.js → cli-mz9afxxf.js} +14 -3
- package/dist/{cli-mz2ygeyq.js → cli-n45mr7zh.js} +2 -2
- package/dist/{cli-fes51ya2.js → cli-p0rzjkya.js} +2 -2
- package/dist/{cli-3qv8naq8.js → cli-p6zs9h4y.js} +827 -107
- package/dist/{cli-mq7kg1b1.js → cli-p9rafdrb.js} +41 -6
- package/dist/{cli-cah5detr.js → cli-qgf8emrn.js} +5 -5
- package/dist/{cli-2wba1ac9.js → cli-qzme5949.js} +26 -26
- package/dist/{cli-znvq0k3t.js → cli-s49hpn37.js} +2 -2
- package/dist/{cli-xc65x52q.js → cli-s53vp709.js} +1 -1
- package/dist/{cli-0jy85zz4.js → cli-svrj71pg.js} +18 -16
- package/dist/{cli-1dp1zj04.js → cli-vwn81jbs.js} +3 -3
- package/dist/{cli-ajqhxtnz.js → cli-w1zwctj2.js} +1 -1
- package/dist/{cli-22rmcjkg.js → cli-y561h5a9.js} +7 -6
- package/dist/{cli-2jg61wab.js → cli-z21whrrg.js} +15 -15
- package/dist/cli.js +19 -18
- package/dist/{codex-adapter-p4ptwar5.js → codex-adapter-n8ka66vf.js} +2 -2
- package/dist/{codex-session-runner-964c7dnk.js → codex-session-runner-fsebts37.js} +1 -1
- package/dist/{commands-mbvhbrcy.js → commands-t04rt2pc.js} +2 -2
- package/dist/{controller-gnq1k1mf.js → controller-rgna04r2.js} +2 -2
- package/dist/{controllers-connect-6dkm71tg.js → controllers-connect-bqm1rtft.js} +20 -20
- package/dist/{cursor-adapter-yq5xgfcg.js → cursor-adapter-swr2k95y.js} +1 -1
- package/dist/{db-88wcymxt.js → db-24ck09xy.js} +1 -1
- package/dist/{db-af9y8nkf.js → db-a4m7haw0.js} +3 -3
- package/dist/{dispatcher-606ydx5v.js → dispatcher-nwf9ss0q.js} +17 -17
- package/dist/{dsh-adapter-tvhc4k2d.js → dsh-adapter-kpdnhjst.js} +2 -2
- package/dist/{external-channel-y8q5z8z2.js → external-channel-j1e2p34p.js} +2 -2
- package/dist/{grok-adapter-39aha52t.js → grok-adapter-qtjz0whk.js} +4 -4
- package/dist/{handlers-rk9dqech.js → handlers-19xr6dnz.js} +20 -20
- package/dist/{heartbeat-jfe9zfjr.js → heartbeat-vtesda5d.js} +20 -20
- package/dist/{hook-b1mez1m8.js → hook-8wqaszda.js} +1 -1
- package/dist/{hook-ffevzed1.js → hook-b2hhj6rd.js} +1 -1
- package/dist/{http-d5b44f1r.js → http-mxcqhhcs.js} +3020 -2463
- package/dist/{index-5en23s5z.js → index-1r8wfpzs.js} +1 -1
- package/dist/{index-cbxkmfc5.js → index-9pn1pfkf.js} +1 -1
- package/dist/{index-mj46rk9k.js → index-kw2mmat7.js} +20 -20
- package/dist/{index-nq8hhv28.js → index-n1vbvta9.js} +19 -10
- package/dist/{index-6srwvd5b.js → index-p4xfz37z.js} +1 -1
- package/dist/{index-d2qeem97.js → index-spwxjgay.js} +17 -17
- package/dist/{index-4q3vj1yr.js → index-srg522m6.js} +17 -17
- package/dist/{keepalive-wsby1btq.js → keepalive-dsd8rtgx.js} +3 -3
- package/dist/{lead-paa141j6.js → lead-zyerk1x2.js} +30 -30
- package/dist/{maintenance-vnshcyv9.js → maintenance-jqmqwnet.js} +1 -1
- package/dist/{oauth-refresh-sweep-d6sqn5np.js → oauth-refresh-sweep-q2c3bnvm.js} +2 -2
- package/dist/{onboard-nr4xhpzy.js → onboard-w4yd7s8t.js} +2 -2
- package/dist/{opencode-adapter-e56086m1.js → opencode-adapter-9tppvxar.js} +2 -2
- package/dist/{otel-impl-ytn9wyv9.js → otel-impl-f36byzbd.js} +1 -1
- package/dist/{pricing-refresh-pva04zee.js → pricing-refresh-xvak5p4v.js} +3 -3
- package/dist/{rbac-roles-7rc1q3g4.js → rbac-roles-682k2mt5.js} +12 -2
- package/dist/rbac-roles-gfn30fb8.js +26 -0
- package/dist/{render-v2-pyfsg9zk.js → render-v2-hgytz8nn.js} +3 -3
- package/dist/{resume-phj8xhqr.js → resume-2dvq348g.js} +7 -5
- package/dist/{session-access-jzx2z9h5.js → session-access-7f21ekq9.js} +4 -4
- package/dist/{setup-rfg0f0sg.js → setup-epffxtbk.js} +1 -1
- package/dist/{work-objects-44z2yjg8.js → work-objects-xz1a5rqk.js} +20 -20
- package/dist/{worker-zsfqzxhm.js → worker-42t8emjt.js} +30 -30
- package/openapi.json +3163 -2114
- package/package.json +7 -6
- package/src/be/approval-config-question.ts +389 -0
- package/src/be/automation-preflight.ts +37 -3
- package/src/be/db/session-tree.ts +158 -13
- package/src/be/db/session-visibility.ts +31 -0
- package/src/be/db/skill-search.ts +87 -0
- package/src/be/db/tasks/read.ts +47 -7
- package/src/be/db/workflow-runs.ts +54 -7
- package/src/be/db.ts +27 -5
- package/src/be/migrations/233_script_run_session_tokens.sql +8 -0
- package/src/be/migrations/235_chat_relay_defer.sql +86 -0
- package/src/be/migrations/237_session_inbox_cleanup.sql +75 -0
- package/src/be/migrations/239_skills_fts.sql +40 -0
- package/src/be/migrations/240_workflow_run_parent_step_attempt.sql +37 -0
- package/src/be/migrations/241_skills_meta_fts.sql +39 -0
- package/src/be/rbac-roles.ts +138 -0
- package/src/be/scripts/run-credential.ts +64 -0
- package/src/be/scripts/run-saved.ts +3 -2
- package/src/be/swarm-config-guard.ts +23 -0
- package/src/be/users.ts +28 -4
- package/src/extensions/catalog.generated.json +4 -4
- package/src/http/all-routes.ts +1 -0
- package/src/http/approval-requests.ts +254 -43
- package/src/http/config.ts +22 -37
- package/src/http/index.ts +2 -0
- package/src/http/schedules.ts +26 -0
- package/src/http/scripts.ts +2 -2
- package/src/http/sessions.ts +118 -2
- package/src/http/user-roles.ts +173 -0
- package/src/http/users.ts +9 -0
- package/src/http/x.ts +2 -2
- package/src/integrations/chat-sdk/commands.ts +14 -1
- package/src/integrations/chat-sdk/inbound.ts +42 -6
- package/src/integrations/chat-sdk/outbound.ts +35 -10
- package/src/integrations/chat-sdk/reactions.ts +107 -0
- package/src/integrations/chat-sdk/relay.ts +75 -7
- package/src/integrations/chat-sdk/thread-buffer.ts +2 -0
- package/src/integrations/reaction-events.ts +20 -0
- package/src/providers/amp-adapter.ts +1 -1
- package/src/providers/dsh-adapter.ts +1 -1
- package/src/rbac/legacy-policy.ts +3 -0
- package/src/rbac/permissions.ts +8 -0
- package/src/scheduler/schedule-task.ts +44 -3
- package/src/scheduler/scheduler.ts +4 -4
- package/src/scripts-runtime/loader.ts +8 -1
- package/src/slack/ack.ts +2 -1
- package/src/slack/reaction-shortcode.ts +4 -7
- package/src/tasks/worker-follow-up.ts +30 -0
- package/src/tests/approval-config-question.test.ts +702 -0
- package/src/tests/approval-requests-notifications.test.ts +189 -0
- package/src/tests/chat-sdk-commands.test.ts +12 -0
- package/src/tests/chat-sdk-inbound.test.ts +183 -9
- package/src/tests/chat-sdk-outbound-files.test.ts +9 -2
- package/src/tests/chat-sdk-relay.test.ts +361 -10
- package/src/tests/chat-sdk-tools.test.ts +104 -0
- package/src/tests/docs-index-footprint.test.js +3 -3
- package/src/tests/dsh-adapter.test.ts +1 -1
- package/src/tests/entrypoint-gitlab-credential-helper.test.ts +273 -0
- package/src/tests/extensions-example-comms-adherence-gate.test.ts +321 -12
- package/src/tests/http-users.test.ts +38 -0
- package/src/tests/list-endpoint-slimming.test.ts +33 -0
- package/src/tests/migration-237-session-inbox-cleanup.test.ts +136 -0
- package/src/tests/migration-240-workflow-run-parent-step-attempt.test.ts +55 -0
- package/src/tests/rbac-engine.test.ts +2 -0
- package/src/tests/rbac-roles.test.ts +10 -0
- package/src/tests/schedule-http-triage-tools.test.ts +44 -0
- package/src/tests/script-api-credential.test.ts +173 -0
- package/src/tests/script-connections.test.ts +10 -0
- package/src/tests/script-run-config-scope.test.ts +120 -0
- package/src/tests/script-run-token.test.ts +75 -0
- package/src/tests/scripts-http.test.ts +19 -0
- package/src/tests/sessions-tree.test.ts +410 -1
- package/src/tests/skill-search-long-content.test.ts +193 -0
- package/src/tests/skill-search.test.ts +173 -22
- package/src/tests/user-roles-http.test.ts +204 -0
- package/src/tests/workflow-hitl-dynamic-questions.test.ts +1 -1
- package/src/tests/workflow-sub-workflow.test.ts +538 -1
- package/src/tools/chat-reply.ts +2 -0
- package/src/tools/request-human-input.ts +27 -5
- package/src/tools/schedules/patch-schedule.ts +18 -0
- package/src/tools/schedules/update-schedule.ts +18 -0
- package/src/types.ts +23 -0
- package/src/workflows/approval-notifications.ts +221 -0
- package/src/workflows/checkpoint.ts +69 -35
- package/src/workflows/engine.ts +234 -87
- package/src/workflows/executors/base.ts +6 -0
- package/src/workflows/executors/human-in-the-loop.ts +36 -173
- package/src/workflows/executors/sub-workflow.ts +45 -16
- package/src/workflows/executors/swarm-script.ts +2 -2
- package/src/workflows/recovery.ts +63 -1
- package/src/workflows/resume.ts +60 -21
- package/src/workflows/retry-poller.ts +25 -4
- package/templates/extensions/comms-adherence-gate/README.md +38 -13
- package/templates/extensions/comms-adherence-gate/hooks.ts +272 -69
- package/templates/extensions/comms-adherence-gate/manifest.json +2 -2
- package/templates/schedules/weekly-harness-upgrade-check/content.md +1 -1
- package/templates/skills/code-quality/content.md +1 -1
- package/templates/skills/turso-interaction/content.md +1 -1
- package/templates/skills/workflow-iterate/content.md +8 -1
- package/dist/rbac-roles-ff1ffmec.js +0 -12
- /package/dist/{cli-stxdrqs2.js → cli-avdvgcay.js} +0 -0
- /package/dist/{pi-mono-adapter-tzps8f38.js → pi-mono-adapter-tqsge1ay.js} +0 -0
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
import { explicitModelErrorForAgent } from "@/be/model-validation";
|
|
16
16
|
import { mergeScheduleTiming, validateRecurringTiming } from "@/be/schedules/validate";
|
|
17
17
|
import { getScript } from "@/be/scripts/db";
|
|
18
|
+
import { can } from "@/rbac";
|
|
18
19
|
import { calculateNextRun } from "@/scheduler";
|
|
19
20
|
import { createToolRegistrar, swarmToolOutputSchema, toolErr, toolOk } from "@/tools/utils";
|
|
20
21
|
import {
|
|
@@ -173,6 +174,23 @@ export const registerUpdateScheduleTool = (server: McpServer) => {
|
|
|
173
174
|
return toolErr("Agent not found.");
|
|
174
175
|
}
|
|
175
176
|
|
|
177
|
+
// A deferral continues its owner's task, in that task's chat thread when
|
|
178
|
+
// it has one (src/scheduler/schedule-task.ts). Only the owner or a lead
|
|
179
|
+
// may change it.
|
|
180
|
+
if (
|
|
181
|
+
schedule.taskType === "deferred" &&
|
|
182
|
+
!can({
|
|
183
|
+
principal: { kind: "agent", agentId: caller.id, isLead: caller.isLead },
|
|
184
|
+
verb: "schedule.deferral.update.any",
|
|
185
|
+
resource: { kind: "owned", ownerAgentId: schedule.createdByAgentId },
|
|
186
|
+
source: "mcp",
|
|
187
|
+
}).allow
|
|
188
|
+
) {
|
|
189
|
+
return toolErr(
|
|
190
|
+
`Schedule "${schedule.name}" defers another agent's task. Only that agent or a lead can change it.`,
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
|
|
176
194
|
// Reject updates on completed one-time schedules
|
|
177
195
|
if (schedule.scheduleType === "one_time" && !schedule.enabled && schedule.lastRunAt) {
|
|
178
196
|
return toolErr(
|
package/src/types.ts
CHANGED
|
@@ -1076,6 +1076,22 @@ export const UserSchema = z
|
|
|
1076
1076
|
|
|
1077
1077
|
export type User = z.infer<typeof UserSchema>;
|
|
1078
1078
|
|
|
1079
|
+
export const RbacRoleSchema = z
|
|
1080
|
+
.object({
|
|
1081
|
+
id: z.string(),
|
|
1082
|
+
name: z.string(),
|
|
1083
|
+
description: z.string().nullable(),
|
|
1084
|
+
isBuiltin: z.boolean(),
|
|
1085
|
+
grantsAll: z.boolean(),
|
|
1086
|
+
primary: z.boolean(),
|
|
1087
|
+
verbs: z.array(z.string()),
|
|
1088
|
+
})
|
|
1089
|
+
.openapi("RbacRole");
|
|
1090
|
+
|
|
1091
|
+
export const UserRbacRolesSchema = z
|
|
1092
|
+
.object({ userId: z.string(), roles: z.array(RbacRoleSchema) })
|
|
1093
|
+
.openapi("UserRbacRoles");
|
|
1094
|
+
|
|
1079
1095
|
/**
|
|
1080
1096
|
* Structured communication preferences read from `users.metadata.comms`.
|
|
1081
1097
|
* All fields are free-form strings; values render verbatim into the requester
|
|
@@ -2942,6 +2958,13 @@ export const WorkflowRunSchema = z
|
|
|
2942
2958
|
finishedAt: z.string().optional(),
|
|
2943
2959
|
/** The `sub-workflow` step that started this run. Unset on a top-level run. */
|
|
2944
2960
|
parentStepId: z.string().optional(),
|
|
2961
|
+
/**
|
|
2962
|
+
* The parent step's attempt (its `retryCount`) that started this run. A
|
|
2963
|
+
* step has at most one child per attempt, so `(parentStepId,
|
|
2964
|
+
* parentStepAttempt)` identifies the invocation; a node `retry` adds a
|
|
2965
|
+
* child with the next attempt. Unset on a top-level run.
|
|
2966
|
+
*/
|
|
2967
|
+
parentStepAttempt: z.number().int().min(0).optional(),
|
|
2945
2968
|
})
|
|
2946
2969
|
.openapi("WorkflowRun");
|
|
2947
2970
|
|
|
@@ -1,16 +1,235 @@
|
|
|
1
|
+
import { formatConfigResult } from "../be/approval-config-question";
|
|
1
2
|
import {
|
|
2
3
|
type ApprovalRequest,
|
|
3
4
|
claimApprovalCancellationNotification,
|
|
4
5
|
completeApprovalCancellationNotificationClaim,
|
|
5
6
|
createTaskExtended,
|
|
7
|
+
getApprovalRequestById,
|
|
6
8
|
getTaskById,
|
|
7
9
|
releaseApprovalCancellationNotificationClaim,
|
|
10
|
+
updateApprovalRequestNotifications,
|
|
8
11
|
} from "../be/db";
|
|
9
12
|
import { resolveTemplate } from "../prompts/resolver";
|
|
10
13
|
import { getSlackApp } from "../slack/app";
|
|
14
|
+
import { resolveSlackConversationId } from "../slack/dm";
|
|
15
|
+
import {
|
|
16
|
+
externalChannelToolError,
|
|
17
|
+
getChannelBlockReason,
|
|
18
|
+
isBlockExternalEnabled,
|
|
19
|
+
} from "../slack/external-channel";
|
|
11
20
|
import { TERMINAL_TASK_STATUSES } from "../types";
|
|
21
|
+
import { getAppUrl } from "../utils/constants";
|
|
12
22
|
import { scrubSecrets } from "../utils/secret-scrubber";
|
|
13
23
|
|
|
24
|
+
/** Slack Block Kit caps a section block's text at 3000 characters. */
|
|
25
|
+
const SLACK_SECTION_TEXT_LIMIT = 3000;
|
|
26
|
+
|
|
27
|
+
function escapeSlackMrkdwn(text: string): string {
|
|
28
|
+
return text.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Render the Slack "Questions" section text within Block Kit's 3000-character
|
|
33
|
+
* section limit. Labels are escaped so upstream text cannot inject mentions or
|
|
34
|
+
* links. When the list does not fit, the tail is replaced by a count; every
|
|
35
|
+
* question stays answerable on the dashboard page the card's button opens.
|
|
36
|
+
*/
|
|
37
|
+
export function buildSlackQuestionsSummary(
|
|
38
|
+
questions: ReadonlyArray<{ label?: string; type?: string }>,
|
|
39
|
+
timeoutText = "",
|
|
40
|
+
): string {
|
|
41
|
+
const header = "*Questions:*\n";
|
|
42
|
+
// A config question is only ever answered on the dashboard, never in Slack.
|
|
43
|
+
const lines = questions.map(
|
|
44
|
+
(q) =>
|
|
45
|
+
`• ${escapeSlackMrkdwn((q.label ?? "").replace(/\s+/g, " ").trim())}${q.type === "config" ? " (set on dashboard)" : ""}`,
|
|
46
|
+
);
|
|
47
|
+
const fits = (shown: string[], omitted: number) => {
|
|
48
|
+
const tail =
|
|
49
|
+
omitted > 0 ? `\n_…and ${omitted} more. Answer all of them on the dashboard._` : "";
|
|
50
|
+
const text = `${header}${shown.join("\n")}${tail}${timeoutText}`;
|
|
51
|
+
return text.length <= SLACK_SECTION_TEXT_LIMIT ? text : null;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const full = fits(lines, 0);
|
|
55
|
+
if (full) return full;
|
|
56
|
+
for (let count = lines.length - 1; count >= 0; count--) {
|
|
57
|
+
const text = fits(lines.slice(0, count), lines.length - count);
|
|
58
|
+
if (text) return text;
|
|
59
|
+
}
|
|
60
|
+
return `${header}_${questions.length} questions. Answer them on the dashboard._`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function formatTimeout(seconds: number): string {
|
|
64
|
+
if (seconds < 60) return `${seconds}s`;
|
|
65
|
+
if (seconds < 3600) return `${Math.round(seconds / 60)}m`;
|
|
66
|
+
const hours = Math.floor(seconds / 3600);
|
|
67
|
+
const mins = Math.round((seconds % 3600) / 60);
|
|
68
|
+
return mins > 0 ? `${hours}h ${mins}m` : `${hours}h`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface ApprovalNotificationChannel {
|
|
72
|
+
channel: "slack" | "email";
|
|
73
|
+
target: string;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface ApprovalNotificationsInput {
|
|
77
|
+
requestId: string;
|
|
78
|
+
title: string;
|
|
79
|
+
questions: ReadonlyArray<{ label?: string; type?: string }>;
|
|
80
|
+
timeoutSeconds?: number;
|
|
81
|
+
notifications: ReadonlyArray<ApprovalNotificationChannel>;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** The DB calls the dispatch makes; the HITL executor injects its own. */
|
|
85
|
+
export interface ApprovalNotificationsDb {
|
|
86
|
+
updateApprovalRequestNotifications: typeof updateApprovalRequestNotifications;
|
|
87
|
+
getApprovalRequestById: typeof getApprovalRequestById;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Post the "Approval Required" card for each notification channel and store
|
|
92
|
+
* the Slack `messageTs` on the request. Used by the workflow HITL executor and
|
|
93
|
+
* by `POST /api/approval-requests`, which checks the caller may post to Slack.
|
|
94
|
+
* Callers fire and forget: a Slack failure is logged and never fails the
|
|
95
|
+
* request. Email is not supported yet and is skipped.
|
|
96
|
+
*/
|
|
97
|
+
export async function dispatchApprovalNotifications(
|
|
98
|
+
input: ApprovalNotificationsInput,
|
|
99
|
+
db: ApprovalNotificationsDb = { updateApprovalRequestNotifications, getApprovalRequestById },
|
|
100
|
+
): Promise<void> {
|
|
101
|
+
const { requestId, title, questions, timeoutSeconds, notifications } = input;
|
|
102
|
+
if (!notifications.length) return;
|
|
103
|
+
|
|
104
|
+
const approvalUrl = `${getAppUrl()}/approval-requests/${requestId}`;
|
|
105
|
+
const updatedChannels: Array<ApprovalNotificationChannel & { messageTs?: string }> = [
|
|
106
|
+
...notifications,
|
|
107
|
+
];
|
|
108
|
+
let updated = false;
|
|
109
|
+
|
|
110
|
+
for (let i = 0; i < notifications.length; i++) {
|
|
111
|
+
const notification = notifications[i]!;
|
|
112
|
+
|
|
113
|
+
if (notification.channel === "email") {
|
|
114
|
+
console.warn(`[HITL] Email notifications not yet supported (target: ${notification.target})`);
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (notification.channel === "slack") {
|
|
119
|
+
try {
|
|
120
|
+
const slackApp = getSlackApp();
|
|
121
|
+
if (!slackApp) {
|
|
122
|
+
console.warn("[HITL] Slack not initialized — cannot send notification");
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Same guard as the Slack tools: never post into a channel shared with another org.
|
|
127
|
+
if (isBlockExternalEnabled()) {
|
|
128
|
+
const conversationId = await resolveSlackConversationId(
|
|
129
|
+
slackApp.client,
|
|
130
|
+
notification.target,
|
|
131
|
+
);
|
|
132
|
+
const blockReason = await getChannelBlockReason(slackApp.client, conversationId);
|
|
133
|
+
if (blockReason) {
|
|
134
|
+
console.warn(`[HITL] ${externalChannelToolError(conversationId, blockReason)}`);
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const questionsText = buildSlackQuestionsSummary(questions);
|
|
140
|
+
|
|
141
|
+
// The deadline is a caption under the button, not part of the ask.
|
|
142
|
+
const timeoutCaption =
|
|
143
|
+
timeoutSeconds !== undefined
|
|
144
|
+
? [
|
|
145
|
+
{
|
|
146
|
+
type: "context",
|
|
147
|
+
elements: [
|
|
148
|
+
{
|
|
149
|
+
type: "mrkdwn",
|
|
150
|
+
text: `⏱ Timeout: ${formatTimeout(timeoutSeconds)} — times out if not responded`,
|
|
151
|
+
},
|
|
152
|
+
],
|
|
153
|
+
},
|
|
154
|
+
]
|
|
155
|
+
: [];
|
|
156
|
+
|
|
157
|
+
const blocks = [
|
|
158
|
+
{
|
|
159
|
+
type: "section",
|
|
160
|
+
text: {
|
|
161
|
+
type: "mrkdwn",
|
|
162
|
+
text: `🔔 *Approval Required: ${escapeSlackMrkdwn(title)}*`,
|
|
163
|
+
},
|
|
164
|
+
},
|
|
165
|
+
{
|
|
166
|
+
type: "section",
|
|
167
|
+
text: {
|
|
168
|
+
type: "mrkdwn",
|
|
169
|
+
text: questionsText,
|
|
170
|
+
},
|
|
171
|
+
},
|
|
172
|
+
{
|
|
173
|
+
type: "actions",
|
|
174
|
+
elements: [
|
|
175
|
+
{
|
|
176
|
+
type: "button",
|
|
177
|
+
text: { type: "plain_text", text: "Review & Respond" },
|
|
178
|
+
url: approvalUrl,
|
|
179
|
+
style: "primary",
|
|
180
|
+
},
|
|
181
|
+
],
|
|
182
|
+
},
|
|
183
|
+
...timeoutCaption,
|
|
184
|
+
];
|
|
185
|
+
|
|
186
|
+
const result = await slackApp.client.chat.postMessage({
|
|
187
|
+
channel: notification.target,
|
|
188
|
+
text: `Approval Required: ${escapeSlackMrkdwn(title)} — ${approvalUrl}`,
|
|
189
|
+
unfurl_links: false,
|
|
190
|
+
unfurl_media: false,
|
|
191
|
+
// biome-ignore lint/suspicious/noExplicitAny: Block Kit objects
|
|
192
|
+
blocks: blocks as any,
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
if (result.ts) {
|
|
196
|
+
updatedChannels[i] = { ...notification, messageTs: result.ts };
|
|
197
|
+
updated = true;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
console.log(
|
|
201
|
+
`[HITL] Slack notification sent to ${notification.target} for request ${requestId}`,
|
|
202
|
+
);
|
|
203
|
+
} catch (err) {
|
|
204
|
+
console.error(
|
|
205
|
+
`[HITL] Failed to send Slack notification to ${notification.target}:`,
|
|
206
|
+
scrubSecrets(err instanceof Error ? err.message : String(err)),
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Persist messageTs values back to DB
|
|
213
|
+
if (updated) {
|
|
214
|
+
try {
|
|
215
|
+
await db.updateApprovalRequestNotifications(requestId, updatedChannels);
|
|
216
|
+
// A cancel that landed while the card was posting found no messageTs to thread under.
|
|
217
|
+
const latest = await db.getApprovalRequestById(requestId);
|
|
218
|
+
if (latest?.status === "cancelled") {
|
|
219
|
+
await postApprovalCancellationUpdates(
|
|
220
|
+
[latest],
|
|
221
|
+
latest.resolutionReason ?? "Workflow run cancelled",
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
} catch (err) {
|
|
225
|
+
console.error(
|
|
226
|
+
"[HITL] Failed to update notification channels in DB:",
|
|
227
|
+
scrubSecrets(err instanceof Error ? err.message : String(err)),
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
14
233
|
export interface ApprovalSlackClient {
|
|
15
234
|
chat: {
|
|
16
235
|
postMessage(input: {
|
|
@@ -141,6 +360,8 @@ function formatResponses(
|
|
|
141
360
|
let answerText: string;
|
|
142
361
|
if (answer == null) {
|
|
143
362
|
answerText = "(no answer)";
|
|
363
|
+
} else if (q.type === "config") {
|
|
364
|
+
answerText = formatConfigResult(answer) ?? "(unreadable config result)";
|
|
144
365
|
} else if (q.type === "approval") {
|
|
145
366
|
const a = answer as { approved?: boolean; comment?: string };
|
|
146
367
|
answerText = a.approved ? "Approved" : "Rejected";
|
|
@@ -1,8 +1,30 @@
|
|
|
1
1
|
import { getDbClient, getWorkflowRun, updateWorkflowRun, updateWorkflowRunStep } from "../be/db";
|
|
2
2
|
import type { RetryPolicy } from "../types";
|
|
3
3
|
|
|
4
|
+
/**
|
|
5
|
+
* The execution a checkpoint belongs to: the step attempt (`retryCount`) that
|
|
6
|
+
* claimed the row. A fenced checkpoint writes only while that attempt still
|
|
7
|
+
* owns the row: the step is `running` with that `retryCount` in a `running` or
|
|
8
|
+
* `waiting` run. A cancel, a newer attempt or another settle that committed
|
|
9
|
+
* meanwhile wins, and the stale execution writes nothing.
|
|
10
|
+
*/
|
|
11
|
+
export type StepFence = { retryCount: number };
|
|
12
|
+
|
|
13
|
+
async function holdsFence(runId: string, stepId: string, fence: StepFence): Promise<boolean> {
|
|
14
|
+
const row = await getDbClient().get<{ found: number }>(
|
|
15
|
+
`SELECT 1 AS found
|
|
16
|
+
FROM workflow_run_steps s
|
|
17
|
+
JOIN workflow_runs r ON r.id = s.runId
|
|
18
|
+
WHERE s.id = ? AND s.runId = ? AND s.status = 'running' AND s.retryCount = ?
|
|
19
|
+
AND r.status IN ('running', 'waiting')`,
|
|
20
|
+
[stepId, runId, fence.retryCount],
|
|
21
|
+
);
|
|
22
|
+
return row != null;
|
|
23
|
+
}
|
|
24
|
+
|
|
4
25
|
/**
|
|
5
26
|
* Checkpoint a successful step — atomic DB write of step result + run context.
|
|
27
|
+
* Returns false, writing nothing, when `fence` no longer holds.
|
|
6
28
|
*/
|
|
7
29
|
export async function checkpointStep(
|
|
8
30
|
runId: string,
|
|
@@ -10,8 +32,10 @@ export async function checkpointStep(
|
|
|
10
32
|
nodeId: string,
|
|
11
33
|
result: { output?: unknown; nextPort?: string },
|
|
12
34
|
ctx: Record<string, unknown>,
|
|
13
|
-
|
|
14
|
-
|
|
35
|
+
options: { fence?: StepFence } = {},
|
|
36
|
+
): Promise<boolean> {
|
|
37
|
+
return getDbClient().transaction(async () => {
|
|
38
|
+
if (options.fence && !(await holdsFence(runId, stepId, options.fence))) return false;
|
|
15
39
|
await updateWorkflowRunStep(stepId, {
|
|
16
40
|
status: "completed",
|
|
17
41
|
output: result.output,
|
|
@@ -24,11 +48,14 @@ export async function checkpointStep(
|
|
|
24
48
|
await updateWorkflowRun(runId, {
|
|
25
49
|
context: ctx,
|
|
26
50
|
});
|
|
51
|
+
return true;
|
|
27
52
|
});
|
|
28
53
|
}
|
|
29
54
|
|
|
30
55
|
/**
|
|
31
56
|
* Checkpoint a step failure — marks step failed, calculates retry if applicable.
|
|
57
|
+
* With `fence`, a stale execution writes nothing and gets `stale: true`.
|
|
58
|
+
* `output` is kept on the failed step for observability, in the same write.
|
|
32
59
|
*/
|
|
33
60
|
export async function checkpointStepFailure(
|
|
34
61
|
runId: string,
|
|
@@ -36,49 +63,56 @@ export async function checkpointStepFailure(
|
|
|
36
63
|
error: string,
|
|
37
64
|
retryCount: number,
|
|
38
65
|
retryPolicy?: RetryPolicy,
|
|
39
|
-
options?: { markRunFailed?: boolean },
|
|
40
|
-
): Promise<{ shouldRetry: boolean }> {
|
|
66
|
+
options?: { markRunFailed?: boolean; fence?: StepFence; output?: unknown },
|
|
67
|
+
): Promise<{ shouldRetry: boolean; stale?: true }> {
|
|
41
68
|
const now = new Date().toISOString();
|
|
42
69
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
70
|
+
return getDbClient().transaction(async () => {
|
|
71
|
+
if (options?.fence && !(await holdsFence(runId, stepId, options.fence))) {
|
|
72
|
+
return { shouldRetry: false, stale: true as const };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (retryPolicy && retryCount < retryPolicy.maxRetries) {
|
|
76
|
+
const delay = calculateBackoff(retryPolicy, retryCount);
|
|
77
|
+
const nextRetryAt = new Date(Date.now() + delay).toISOString();
|
|
78
|
+
|
|
79
|
+
await updateWorkflowRunStep(stepId, {
|
|
80
|
+
status: "failed",
|
|
81
|
+
error,
|
|
82
|
+
output: options?.output,
|
|
83
|
+
retryCount: retryCount + 1,
|
|
84
|
+
maxRetries: retryPolicy.maxRetries,
|
|
85
|
+
nextRetryAt,
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
return { shouldRetry: true };
|
|
89
|
+
}
|
|
46
90
|
|
|
91
|
+
// No retries left — mark step failed, and optionally the run too
|
|
92
|
+
// Clear nextRetryAt so the poller stops picking this step up
|
|
47
93
|
await updateWorkflowRunStep(stepId, {
|
|
48
94
|
status: "failed",
|
|
49
95
|
error,
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
nextRetryAt,
|
|
96
|
+
output: options?.output,
|
|
97
|
+
finishedAt: now,
|
|
98
|
+
nextRetryAt: null,
|
|
53
99
|
});
|
|
54
100
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
// No retries left — mark step failed, and optionally the run too
|
|
59
|
-
// Clear nextRetryAt so the poller stops picking this step up
|
|
60
|
-
await updateWorkflowRunStep(stepId, {
|
|
61
|
-
status: "failed",
|
|
62
|
-
error,
|
|
63
|
-
finishedAt: now,
|
|
64
|
-
nextRetryAt: null,
|
|
65
|
-
});
|
|
66
|
-
|
|
67
|
-
const markRunFailed = options?.markRunFailed ?? true;
|
|
68
|
-
if (markRunFailed) {
|
|
69
|
-
// A cancel or finalization that committed while the step ran wins.
|
|
70
|
-
await getDbClient().transaction(async () => {
|
|
101
|
+
const markRunFailed = options?.markRunFailed ?? true;
|
|
102
|
+
if (markRunFailed) {
|
|
103
|
+
// A cancel or finalization that committed while the step ran wins.
|
|
71
104
|
const run = await getWorkflowRun(runId);
|
|
72
|
-
if (run?.status
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
105
|
+
if (run?.status === "running" || run?.status === "waiting") {
|
|
106
|
+
await updateWorkflowRun(runId, {
|
|
107
|
+
status: "failed",
|
|
108
|
+
error: `Step failed: ${error}`,
|
|
109
|
+
finishedAt: now,
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
}
|
|
80
113
|
|
|
81
|
-
|
|
114
|
+
return { shouldRetry: false };
|
|
115
|
+
});
|
|
82
116
|
}
|
|
83
117
|
|
|
84
118
|
/**
|