@opentag/dispatcher 0.2.0 → 0.3.0

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.
@@ -1,5 +1,5 @@
1
1
  import { type LarkReplyClient } from "@opentag/lark";
2
- import type { CallbackSink } from "./server.js";
2
+ import type { CallbackSink, SourceReceiptSink } from "./server.js";
3
3
  export type FetchLike = typeof fetch;
4
4
  export declare function createGitHubCallbackSink(input: {
5
5
  token?: string;
@@ -10,6 +10,13 @@ export declare function createSlackCallbackSink(input: {
10
10
  botTokensByAgentId?: Record<string, string>;
11
11
  fetchImpl?: FetchLike;
12
12
  }): CallbackSink;
13
+ export declare function createSlackSourceReceiptSink(input: {
14
+ botToken?: string;
15
+ botTokensByAgentId?: Record<string, string>;
16
+ fetchImpl?: FetchLike;
17
+ reactionsAddUri?: string;
18
+ timeoutMs?: number;
19
+ }): SourceReceiptSink;
13
20
  export declare function createLarkCallbackSink(input: {
14
21
  appId?: string;
15
22
  appSecret?: string;
@@ -1 +1 @@
1
- {"version":3,"file":"callbacks.d.ts","sourceRoot":"","sources":["../src/callbacks.ts"],"names":[],"mappings":"AAAA,OAAO,EAAyB,KAAK,eAAe,EAAwC,MAAM,eAAe,CAAC;AAGlH,OAAO,KAAK,EAAmB,YAAY,EAAE,MAAM,aAAa,CAAC;AAEjE,MAAM,MAAM,SAAS,GAAG,OAAO,KAAK,CAAC;AA8BrC,wBAAgB,wBAAwB,CAAC,KAAK,EAAE;IAAE,KAAK,CAAC,EAAE,MAAM,CAAC;IAAC,SAAS,CAAC,EAAE,SAAS,CAAA;CAAE,GAAG,YAAY,CA+CvG;AAED,wBAAgB,uBAAuB,CAAC,KAAK,EAAE;IAC7C,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,kBAAkB,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC5C,SAAS,CAAC,EAAE,SAAS,CAAC;CACvB,GAAG,YAAY,CA0Df;AAED,wBAAgB,sBAAsB,CAAC,KAAK,EAAE;IAC5C,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,MAAM,CAAC,EAAE,MAAM,GAAG,QAAQ,CAAC;IAC3B,MAAM,CAAC,EAAE,eAAe,CAAC;CAC1B,GAAG,YAAY,CA0Bf;AAED,wBAAgB,0BAA0B,CAAC,KAAK,EAAE;IAChD,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,kBAAkB,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC5C,SAAS,CAAC,EAAE,SAAS,CAAC;CACvB,GAAG,YAAY,CAyDf;AAED,wBAAgB,2BAA2B,CAAC,KAAK,EAAE,YAAY,EAAE,GAAG,YAAY,CAQ/E"}
1
+ {"version":3,"file":"callbacks.d.ts","sourceRoot":"","sources":["../src/callbacks.ts"],"names":[],"mappings":"AAAA,OAAO,EAAyB,KAAK,eAAe,EAAwC,MAAM,eAAe,CAAC;AASlH,OAAO,KAAK,EAAmB,YAAY,EAAiB,iBAAiB,EAAE,MAAM,aAAa,CAAC;AAEnG,MAAM,MAAM,SAAS,GAAG,OAAO,KAAK,CAAC;AAkErC,wBAAgB,wBAAwB,CAAC,KAAK,EAAE;IAAE,KAAK,CAAC,EAAE,MAAM,CAAC;IAAC,SAAS,CAAC,EAAE,SAAS,CAAA;CAAE,GAAG,YAAY,CA+CvG;AAED,wBAAgB,uBAAuB,CAAC,KAAK,EAAE;IAC7C,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,kBAAkB,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC5C,SAAS,CAAC,EAAE,SAAS,CAAC;CACvB,GAAG,YAAY,CA0Df;AAED,wBAAgB,4BAA4B,CAAC,KAAK,EAAE;IAClD,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,kBAAkB,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC5C,SAAS,CAAC,EAAE,SAAS,CAAC;IACtB,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB,GAAG,iBAAiB,CAgDpB;AAED,wBAAgB,sBAAsB,CAAC,KAAK,EAAE;IAC5C,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,MAAM,CAAC,EAAE,MAAM,GAAG,QAAQ,CAAC;IAC3B,MAAM,CAAC,EAAE,eAAe,CAAC;CAC1B,GAAG,YAAY,CA0Bf;AAED,wBAAgB,0BAA0B,CAAC,KAAK,EAAE;IAChD,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,kBAAkB,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC5C,SAAS,CAAC,EAAE,SAAS,CAAC;CACvB,GAAG,YAAY,CAyDf;AAED,wBAAgB,2BAA2B,CAAC,KAAK,EAAE,YAAY,EAAE,GAAG,YAAY,CAQ/E"}
package/dist/index.js CHANGED
@@ -1,7 +1,14 @@
1
1
  // src/callbacks.ts
2
2
  import { createLarkReplyClient, parseLarkThreadKey, replyLarkMessage } from "@opentag/lark";
3
- import { createSlackPostMessagePayload, createSlackUpdateMessagePayload, parseSlackThreadKey } from "@opentag/slack";
3
+ import {
4
+ createSlackPostMessagePayload,
5
+ createSlackReactionPayload,
6
+ createSlackUpdateMessagePayload,
7
+ parseSlackThreadKey,
8
+ slackSourceReceiptReactionName
9
+ } from "@opentag/slack";
4
10
  import { createTelegramSendMessageDraftPayload, createTelegramSendMessagePayload, parseTelegramThreadKey } from "@opentag/telegram";
11
+ var DEFAULT_SLACK_SOURCE_RECEIPT_TIMEOUT_MS = 5e3;
5
12
  function slackUpdateUriFrom(postMessageUri) {
6
13
  return postMessageUri.replace(/\/chat\.postMessage$/, "/chat.update");
7
14
  }
@@ -18,6 +25,31 @@ function slackBotTokenFor(input) {
18
25
  }
19
26
  return input.botToken;
20
27
  }
28
+ function metadataString(metadata, key) {
29
+ const value = metadata[key];
30
+ return typeof value === "string" && value.length > 0 ? value : void 0;
31
+ }
32
+ function slackSourceMessageTarget(receipt) {
33
+ if (receipt.provider !== "slack") return null;
34
+ const channelId = metadataString(receipt.event.metadata, "channelId");
35
+ const messageTs = metadataString(receipt.event.metadata, "messageTs");
36
+ return channelId && messageTs ? { channelId, messageTs } : null;
37
+ }
38
+ function isAbortError(error) {
39
+ return error instanceof Error && error.name === "AbortError";
40
+ }
41
+ async function fetchWithTimeout(input) {
42
+ const controller = new AbortController();
43
+ const timeout = setTimeout(() => controller.abort(), input.timeoutMs);
44
+ try {
45
+ return await input.fetchImpl(input.uri, { ...input.init, signal: controller.signal });
46
+ } catch (error) {
47
+ if (isAbortError(error)) return null;
48
+ throw error;
49
+ } finally {
50
+ clearTimeout(timeout);
51
+ }
52
+ }
21
53
  function createGitHubCallbackSink(input) {
22
54
  const fetchImpl = input.fetchImpl ?? fetch;
23
55
  const commentUriByKey = /* @__PURE__ */ new Map();
@@ -70,9 +102,9 @@ function createSlackCallbackSink(input) {
70
102
  async deliver(message) {
71
103
  if (message.provider !== "slack") return;
72
104
  const botToken = slackBotTokenFor({
73
- ...input.botToken ? { botToken: input.botToken } : {},
74
- ...input.botTokensByAgentId ? { botTokensByAgentId: input.botTokensByAgentId } : {},
75
- ...message.agentId ? { agentId: message.agentId } : {}
105
+ botToken: input.botToken,
106
+ botTokensByAgentId: input.botTokensByAgentId,
107
+ agentId: message.agentId
76
108
  });
77
109
  if (!botToken) return;
78
110
  const thread = parseSlackThreadKey(message.threadKey ?? "");
@@ -117,6 +149,51 @@ function createSlackCallbackSink(input) {
117
149
  }
118
150
  };
119
151
  }
152
+ function createSlackSourceReceiptSink(input) {
153
+ const fetchImpl = input.fetchImpl ?? fetch;
154
+ const reactionsAddUri = input.reactionsAddUri ?? "https://slack.com/api/reactions.add";
155
+ const timeoutMs = input.timeoutMs ?? DEFAULT_SLACK_SOURCE_RECEIPT_TIMEOUT_MS;
156
+ return {
157
+ async deliver(receipt) {
158
+ const target = slackSourceMessageTarget(receipt);
159
+ if (!target) return { delivered: false };
160
+ const botToken = slackBotTokenFor({
161
+ botToken: input.botToken,
162
+ botTokensByAgentId: input.botTokensByAgentId,
163
+ agentId: receipt.agentId
164
+ });
165
+ if (!botToken) return { delivered: false };
166
+ const response = await fetchWithTimeout({
167
+ fetchImpl,
168
+ uri: reactionsAddUri,
169
+ timeoutMs,
170
+ init: {
171
+ method: "POST",
172
+ headers: {
173
+ authorization: `Bearer ${botToken}`,
174
+ "content-type": "application/json"
175
+ },
176
+ body: JSON.stringify(
177
+ createSlackReactionPayload({
178
+ channelId: target.channelId,
179
+ messageTs: target.messageTs,
180
+ name: slackSourceReceiptReactionName(receipt.state)
181
+ })
182
+ )
183
+ }
184
+ });
185
+ if (!response) return { delivered: false };
186
+ if (!response.ok) {
187
+ throw new Error(`deliver Slack source receipt failed: ${response.status} ${await response.text()}`);
188
+ }
189
+ const body = await response.json().catch(() => ({}));
190
+ if (body?.ok === false && body.error !== "already_reacted") {
191
+ throw new Error(`deliver Slack source receipt failed: ${body?.error ?? "unknown_error"}`);
192
+ }
193
+ return { delivered: true };
194
+ }
195
+ };
196
+ }
120
197
  function createLarkCallbackSink(input) {
121
198
  if (!input.client && Boolean(input.appId) !== Boolean(input.appSecret)) {
122
199
  throw new Error("Lark callback sink requires both appId and appSecret (or neither).");
@@ -144,9 +221,9 @@ function createTelegramCallbackSink(input) {
144
221
  async deliver(message) {
145
222
  if (message.provider !== "telegram") return;
146
223
  const botToken = slackBotTokenFor({
147
- ...input.botToken ? { botToken: input.botToken } : {},
148
- ...input.botTokensByAgentId ? { botTokensByAgentId: input.botTokensByAgentId } : {},
149
- ...message.agentId ? { agentId: message.agentId } : {}
224
+ botToken: input.botToken,
225
+ botTokensByAgentId: input.botTokensByAgentId,
226
+ agentId: message.agentId
150
227
  });
151
228
  if (!botToken) return;
152
229
  const thread = parseTelegramThreadKey(message.threadKey ?? "");
@@ -206,7 +283,7 @@ import { renderTelegramAcknowledgement, renderTelegramFinalResult, renderTelegra
206
283
  function createDefaultCallbackPresentation() {
207
284
  return {
208
285
  shouldDeliverAcknowledgement(provider) {
209
- return provider !== "lark";
286
+ return provider !== "lark" && provider !== "slack";
210
287
  },
211
288
  shouldDeliverProgress(provider) {
212
289
  return provider !== "slack" && provider !== "lark";
@@ -272,6 +349,7 @@ import { createOpenTagRepository, migrateSchema } from "@opentag/store";
272
349
  import Database from "better-sqlite3";
273
350
  import { drizzle } from "drizzle-orm/better-sqlite3";
274
351
  import { Hono } from "hono";
352
+ import { HTTPException } from "hono/http-exception";
275
353
  import { z } from "zod";
276
354
 
277
355
  // src/admission.ts
@@ -416,6 +494,26 @@ function createAdmissionRuntime(input) {
416
494
  }
417
495
 
418
496
  // src/server.ts
497
+ async function parseBody(c, schema) {
498
+ let json;
499
+ try {
500
+ json = await c.req.json();
501
+ } catch (err) {
502
+ if (err instanceof SyntaxError) {
503
+ throw new HTTPException(400, {
504
+ res: c.json({ error: "invalid_json_body" }, 400)
505
+ });
506
+ }
507
+ throw err;
508
+ }
509
+ const result = schema.safeParse(json);
510
+ if (!result.success) {
511
+ throw new HTTPException(400, {
512
+ res: c.json({ error: "invalid_request_body", issues: result.error.issues }, 400)
513
+ });
514
+ }
515
+ return result.data;
516
+ }
419
517
  var CreateRunnerSchema = z.object({
420
518
  runnerId: z.string().min(1),
421
519
  name: z.string().min(1)
@@ -526,7 +624,8 @@ function childEventFromParent(input) {
526
624
  metadata: {
527
625
  ...input.parentEvent.metadata,
528
626
  ...input.metadata ?? {}
529
- }
627
+ },
628
+ permissions: input.permissions ?? input.parentEvent.permissions
530
629
  };
531
630
  }
532
631
  function mappingsFromAdapterPlan(adapterPlan) {
@@ -544,13 +643,13 @@ function metadataIssueNumber(metadata) {
544
643
  if (typeof value === "string" && /^[1-9]\d*$/.test(value)) return value;
545
644
  return void 0;
546
645
  }
547
- function metadataString(metadata, key) {
646
+ function metadataString2(metadata, key) {
548
647
  const value = metadata?.[key];
549
648
  return typeof value === "string" && value.length > 0 ? value : void 0;
550
649
  }
551
650
  function githubIssueWorkItemExternalId(metadata) {
552
- const owner = metadataString(metadata, "owner");
553
- const repo = metadataString(metadata, "repo");
651
+ const owner = metadataString2(metadata, "owner");
652
+ const repo = metadataString2(metadata, "repo");
554
653
  const issueNumber = metadataIssueNumber(metadata);
555
654
  if (!owner || !repo || !issueNumber) return void 0;
556
655
  return `${owner}/${repo}#${issueNumber}`;
@@ -807,6 +906,30 @@ function githubTargetFromEvent(event) {
807
906
  function selectedActionSummary(candidates) {
808
907
  return candidates.map((candidate) => `${candidate.index}. ${candidate.intent.summary}`).join("; ");
809
908
  }
909
+ function addPermissionGrant(permissions, grant) {
910
+ if (permissions.some((permission) => permission.scope === grant.scope)) return permissions;
911
+ return [...permissions, grant];
912
+ }
913
+ function childRunPermissionsForThreadAction(input) {
914
+ let permissions = [...input.resolved.proposal.event.permissions ?? []];
915
+ if (input.command.verb === "apply" || input.command.verb === "continue") {
916
+ permissions = addPermissionGrant(permissions, {
917
+ scope: "repo:read",
918
+ reason: "inspect the repository while continuing an approved source-thread action"
919
+ });
920
+ permissions = addPermissionGrant(permissions, {
921
+ scope: "repo:write",
922
+ reason: "apply an approved source-thread mutation on a run branch"
923
+ });
924
+ }
925
+ if (input.resolved.selectedCandidates.some((candidate) => candidate.intent.action === "create_pull_request")) {
926
+ permissions = addPermissionGrant(permissions, {
927
+ scope: "pr:create",
928
+ reason: "create the pull request approved in the source thread"
929
+ });
930
+ }
931
+ return permissions;
932
+ }
810
933
  function childRunContextLines(input) {
811
934
  const previousSummary = input.resolved.proposal.run.result?.summary ?? input.resolved.proposal.snapshot.summary;
812
935
  return [
@@ -820,6 +943,12 @@ function childRunContextLines(input) {
820
943
  ];
821
944
  }
822
945
  function renderChildRunCreatedBody(input) {
946
+ if (input.provider === "slack") {
947
+ return [
948
+ "Approved. OpenTag will continue from this proposal in a follow-up run.",
949
+ ...input.fallbackReason ? [`Reason: ${input.fallbackReason}`] : []
950
+ ].join("\n");
951
+ }
823
952
  return [
824
953
  input.lead,
825
954
  "",
@@ -831,6 +960,44 @@ function renderChildRunCreatedBody(input) {
831
960
  "The model will continue from this approved proposal instead of starting from a fresh mention."
832
961
  ].join("\n");
833
962
  }
963
+ function renderAppliedThreadActionBody(input) {
964
+ const selectedOutcomes = input.outcomes.filter((outcome) => input.selectedIntentIds.includes(outcome.intentId));
965
+ if (input.provider === "slack") {
966
+ const lines = [`Applied ${input.selectionText}.`];
967
+ for (const outcome of selectedOutcomes) {
968
+ if (outcome.externalUri) {
969
+ lines.push(`Result: ${outcome.externalUri}`);
970
+ } else if (outcome.message) {
971
+ lines.push(`Result: ${outcome.outcome}. ${outcome.message}`);
972
+ } else {
973
+ lines.push(`Result: ${outcome.outcome}.`);
974
+ }
975
+ }
976
+ return lines.join("\n");
977
+ }
978
+ return [
979
+ `Applied ${input.selectionText} from \`${input.proposalId}\`.`,
980
+ "",
981
+ "Result:",
982
+ ...selectedOutcomes.map((outcome) => `- \`${outcome.intentId}\`: ${outcome.outcome}${outcome.externalUri ? ` (${outcome.externalUri})` : ""}`)
983
+ ].join("\n");
984
+ }
985
+ function renderThreadActionRecordedBody(input) {
986
+ const pastTense = input.verb === "approve" ? "Approved" : "Rejected";
987
+ if (input.provider === "slack") {
988
+ if (input.verb === "approve") {
989
+ return `${pastTense} ${input.selectionText}.
990
+ Next: use Apply ${input.applyIndex ?? 1} when you want OpenTag to perform it.`;
991
+ }
992
+ return `${pastTense} ${input.selectionText}.`;
993
+ }
994
+ if (input.verb === "approve") {
995
+ return `${pastTense} ${input.selectionText} from \`${input.proposalId}\`.
996
+
997
+ Reply with \`apply ${input.applyIndex ?? 1}\` to apply it, or \`continue ${input.applyIndex ?? 1}\` to continue with a follow-up run.`;
998
+ }
999
+ return `${pastTense} ${input.selectionText} from \`${input.proposalId}\`.`;
1000
+ }
834
1001
  function actionContextPointer(input) {
835
1002
  const lines = [
836
1003
  "OpenTag thread action continuation.",
@@ -895,7 +1062,8 @@ async function createChildRunForThreadAction(input) {
895
1062
  ...input.approvalDecisionId ? { approvalDecisionId: input.approvalDecisionId } : {},
896
1063
  ...input.sourceApplyPlanId ? { sourceApplyPlanId: input.sourceApplyPlanId } : {},
897
1064
  ...input.fallbackReason ? { fallbackReason: input.fallbackReason } : {}
898
- }
1065
+ },
1066
+ permissions: childRunPermissionsForThreadAction({ resolved: input.resolved, command: input.command })
899
1067
  }),
900
1068
  parentRunId: input.resolved.proposal.runId,
901
1069
  triggeredByAction: action,
@@ -972,6 +1140,11 @@ var noopCallbackSink = {
972
1140
  return;
973
1141
  }
974
1142
  };
1143
+ var noopSourceReceiptSink = {
1144
+ async deliver() {
1145
+ return { delivered: false };
1146
+ }
1147
+ };
975
1148
  function nextCallbackAttemptAt(input) {
976
1149
  const maxAttempts = input.maxAttempts ?? 5;
977
1150
  const nextAttempt = input.attempts + 1;
@@ -1049,6 +1222,38 @@ async function deliverAndAudit(input) {
1049
1222
  ...input.retry ? { retry: input.retry } : {}
1050
1223
  });
1051
1224
  }
1225
+ async function deliverSourceReceiptBestEffort(input) {
1226
+ try {
1227
+ const result = await input.sink.deliver(input.receipt);
1228
+ if (!result.delivered) return result;
1229
+ await input.repo.appendRunEvent({
1230
+ runId: input.receipt.runId,
1231
+ type: "source_receipt.delivered",
1232
+ payload: {
1233
+ provider: input.receipt.provider,
1234
+ state: input.receipt.state
1235
+ },
1236
+ visibility: "audit",
1237
+ importance: "low",
1238
+ message: `Source ${input.receipt.state} receipt delivered.`
1239
+ });
1240
+ return result;
1241
+ } catch (error) {
1242
+ await input.repo.appendRunEvent({
1243
+ runId: input.receipt.runId,
1244
+ type: "source_receipt.failed",
1245
+ payload: {
1246
+ provider: input.receipt.provider,
1247
+ state: input.receipt.state,
1248
+ error: error instanceof Error ? error.message : String(error)
1249
+ },
1250
+ visibility: "audit",
1251
+ importance: "low",
1252
+ message: `Source ${input.receipt.state} receipt failed.`
1253
+ });
1254
+ return { delivered: false };
1255
+ }
1256
+ }
1052
1257
  function isAuthorized(request, pairingToken) {
1053
1258
  if (!pairingToken) return true;
1054
1259
  return request.headers.get("authorization") === `Bearer ${pairingToken}`;
@@ -1059,6 +1264,7 @@ function createDispatcherApp(input) {
1059
1264
  const repo = createOpenTagRepository(drizzle(sqlite));
1060
1265
  const app = new Hono();
1061
1266
  const callbackSink = input.callbackSink ?? noopCallbackSink;
1267
+ const sourceReceiptSink = input.sourceReceiptSink ?? noopSourceReceiptSink;
1062
1268
  const presentation = input.presentation ?? createDefaultCallbackPresentation();
1063
1269
  const callbackRetry = input.callbackRetry ?? {};
1064
1270
  const admission = createAdmissionRuntime({
@@ -1073,7 +1279,7 @@ function createDispatcherApp(input) {
1073
1279
  await next();
1074
1280
  });
1075
1281
  app.post("/v1/runners", async (c) => {
1076
- const parsed = CreateRunnerSchema.parse(await c.req.json());
1282
+ const parsed = await parseBody(c, CreateRunnerSchema);
1077
1283
  await repo.registerRunner(parsed);
1078
1284
  return c.json({ ok: true }, 201);
1079
1285
  });
@@ -1083,7 +1289,7 @@ function createDispatcherApp(input) {
1083
1289
  return c.json({ runner });
1084
1290
  });
1085
1291
  app.post("/v1/repo-bindings", async (c) => {
1086
- const parsed = CreateRepoBindingSchema.parse(await c.req.json());
1292
+ const parsed = await parseBody(c, CreateRepoBindingSchema);
1087
1293
  await repo.createRepoBinding({
1088
1294
  provider: parsed.provider,
1089
1295
  owner: parsed.owner,
@@ -1105,7 +1311,7 @@ function createDispatcherApp(input) {
1105
1311
  return c.json({ binding });
1106
1312
  });
1107
1313
  app.post("/v1/repo-bindings/:provider/:owner/:repo/policy-rules", async (c) => {
1108
- const parsed = UpsertPolicyRuleSchema.parse(await c.req.json());
1314
+ const parsed = await parseBody(c, UpsertPolicyRuleSchema);
1109
1315
  const rule = await repo.upsertRepoPolicyRule({
1110
1316
  provider: c.req.param("provider"),
1111
1317
  owner: c.req.param("owner"),
@@ -1123,7 +1329,7 @@ function createDispatcherApp(input) {
1123
1329
  return c.json({ rules });
1124
1330
  });
1125
1331
  app.post("/v1/repo-bindings/:provider/:owner/:repo/mutation-mappings", async (c) => {
1126
- const parsed = UpsertMutationMappingSchema.parse(await c.req.json());
1332
+ const parsed = await parseBody(c, UpsertMutationMappingSchema);
1127
1333
  const mapping = await repo.upsertRepoMutationMapping({
1128
1334
  provider: c.req.param("provider"),
1129
1335
  owner: c.req.param("owner"),
@@ -1155,7 +1361,7 @@ function createDispatcherApp(input) {
1155
1361
  return c.json({ metrics });
1156
1362
  });
1157
1363
  app.post("/v1/channel-bindings", async (c) => {
1158
- const parsed = CreateChannelBindingSchema.parse(await c.req.json());
1364
+ const parsed = await parseBody(c, CreateChannelBindingSchema);
1159
1365
  await repo.upsertChannelBinding({
1160
1366
  provider: parsed.provider,
1161
1367
  accountId: parsed.accountId,
@@ -1177,7 +1383,7 @@ function createDispatcherApp(input) {
1177
1383
  return c.json({ binding });
1178
1384
  });
1179
1385
  app.post("/v1/slack-channel-bindings", async (c) => {
1180
- const parsed = CreateSlackChannelBindingSchema.parse(await c.req.json());
1386
+ const parsed = await parseBody(c, CreateSlackChannelBindingSchema);
1181
1387
  await repo.createSlackChannelBinding(parsed);
1182
1388
  return c.json({ ok: true }, 201);
1183
1389
  });
@@ -1190,7 +1396,7 @@ function createDispatcherApp(input) {
1190
1396
  return c.json({ binding });
1191
1397
  });
1192
1398
  app.post("/v1/runs", async (c) => {
1193
- const parsed = CreateRunSchema.parse(await c.req.json());
1399
+ const parsed = await parseBody(c, CreateRunSchema);
1194
1400
  const admitted = await admission.admitRun({ requestId: parsed.runId, event: parsed.event });
1195
1401
  if (admitted.outcome === "needs_human_decision") {
1196
1402
  return c.json({ decision: admitted.decision }, 202);
@@ -1234,7 +1440,19 @@ function createDispatcherApp(input) {
1234
1440
  );
1235
1441
  }
1236
1442
  const { run } = createdRun;
1237
- if (presentation.shouldDeliverAcknowledgement(parsed.event.callback.provider)) {
1443
+ const sourceReceiptDelivery = await deliverSourceReceiptBestEffort({
1444
+ repo,
1445
+ sink: sourceReceiptSink,
1446
+ receipt: {
1447
+ runId: run.id,
1448
+ provider: parsed.event.callback.provider,
1449
+ state: "received",
1450
+ event: parsed.event,
1451
+ ...parsed.event.target.agentId ? { agentId: parsed.event.target.agentId } : {}
1452
+ }
1453
+ });
1454
+ const shouldDeliverAcknowledgement = presentation.shouldDeliverAcknowledgement(parsed.event.callback.provider) || parsed.event.callback.provider === "slack" && !sourceReceiptDelivery.delivered;
1455
+ if (shouldDeliverAcknowledgement) {
1238
1456
  await deliverAndAudit({
1239
1457
  repo,
1240
1458
  sink: callbackSink,
@@ -1253,7 +1471,7 @@ function createDispatcherApp(input) {
1253
1471
  return c.json({ decision: admitted.decision, run }, 201);
1254
1472
  });
1255
1473
  app.post("/v1/thread-actions", async (c) => {
1256
- const parsed = ThreadActionInputSchema.parse(await c.req.json());
1474
+ const parsed = await parseBody(c, ThreadActionInputSchema);
1257
1475
  const command = parseThreadActionCommand(parsed.rawText);
1258
1476
  if (!command) {
1259
1477
  return c.json({ outcome: "ignored", reason: "not_action_command" }, 202);
@@ -1335,6 +1553,7 @@ function createDispatcherApp(input) {
1335
1553
  lead: "This action was already planned, so OpenTag will not execute the external write again.",
1336
1554
  resolved: resolved.resolved,
1337
1555
  childRun: childRun2,
1556
+ provider: parsed.callback.provider,
1338
1557
  approvalDecisionId: existingPlan.approvalDecisionId,
1339
1558
  sourceApplyPlanId: existingPlan.id,
1340
1559
  fallbackReason
@@ -1383,7 +1602,12 @@ function createDispatcherApp(input) {
1383
1602
  if (existingDecision) {
1384
1603
  return c.json({ outcome: "already_rejected", decision }, 200);
1385
1604
  }
1386
- const body2 = `Rejected ${selectionText} from \`${resolved.resolved.proposal.snapshot.proposalId}\`.`;
1605
+ const body2 = renderThreadActionRecordedBody({
1606
+ provider: parsed.callback.provider,
1607
+ verb: "reject",
1608
+ selectionText,
1609
+ proposalId: resolved.resolved.proposal.snapshot.proposalId
1610
+ });
1387
1611
  await deliverAndAudit({
1388
1612
  repo,
1389
1613
  sink: callbackSink,
@@ -1403,9 +1627,13 @@ function createDispatcherApp(input) {
1403
1627
  if (existingDecision) {
1404
1628
  return c.json({ outcome: "already_approved", decision }, 200);
1405
1629
  }
1406
- const body2 = `Approved ${selectionText} from \`${resolved.resolved.proposal.snapshot.proposalId}\`.
1407
-
1408
- Reply with \`apply ${resolved.resolved.selectedCandidates[0]?.index ?? 1}\` to apply it, or \`continue ${resolved.resolved.selectedCandidates[0]?.index ?? 1}\` to continue with a follow-up run.`;
1630
+ const body2 = renderThreadActionRecordedBody({
1631
+ provider: parsed.callback.provider,
1632
+ verb: "approve",
1633
+ selectionText,
1634
+ proposalId: resolved.resolved.proposal.snapshot.proposalId,
1635
+ applyIndex: resolved.resolved.selectedCandidates[0]?.index ?? 1
1636
+ });
1409
1637
  await deliverAndAudit({
1410
1638
  repo,
1411
1639
  sink: callbackSink,
@@ -1433,6 +1661,7 @@ Reply with \`apply ${resolved.resolved.selectedCandidates[0]?.index ?? 1}\` to a
1433
1661
  lead: `Continuing from ${selectionText} in \`${resolved.resolved.proposal.snapshot.proposalId}\`.`,
1434
1662
  resolved: resolved.resolved,
1435
1663
  childRun: childRun2,
1664
+ provider: parsed.callback.provider,
1436
1665
  approvalDecisionId: decision.id
1437
1666
  });
1438
1667
  await deliverAndAudit({
@@ -1492,6 +1721,7 @@ Reply with \`apply ${resolved.resolved.selectedCandidates[0]?.index ?? 1}\` to a
1492
1721
  lead: "This action was already planned, so OpenTag will not execute the external write again.",
1493
1722
  resolved: resolved.resolved,
1494
1723
  childRun: childRun2,
1724
+ provider: parsed.callback.provider,
1495
1725
  approvalDecisionId: decision.id,
1496
1726
  sourceApplyPlanId: planResult.plan.id,
1497
1727
  fallbackReason
@@ -1510,12 +1740,13 @@ Reply with \`apply ${resolved.resolved.selectedCandidates[0]?.index ?? 1}\` to a
1510
1740
  });
1511
1741
  if (execution.executed) {
1512
1742
  const outcomes = execution.plan.outcomes ?? [];
1513
- const body2 = [
1514
- `Applied ${selectionText} from \`${resolved.resolved.proposal.snapshot.proposalId}\`.`,
1515
- "",
1516
- "Result:",
1517
- ...outcomes.filter((outcome) => resolved.resolved.selectedIntentIds.includes(outcome.intentId)).map((outcome) => `- \`${outcome.intentId}\`: ${outcome.outcome}${outcome.externalUri ? ` (${outcome.externalUri})` : ""}`)
1518
- ].join("\n");
1743
+ const body2 = renderAppliedThreadActionBody({
1744
+ provider: parsed.callback.provider,
1745
+ selectionText,
1746
+ proposalId: resolved.resolved.proposal.snapshot.proposalId,
1747
+ selectedIntentIds: resolved.resolved.selectedIntentIds,
1748
+ outcomes
1749
+ });
1519
1750
  await deliverAndAudit({
1520
1751
  repo,
1521
1752
  sink: callbackSink,
@@ -1549,6 +1780,7 @@ Reply with \`apply ${resolved.resolved.selectedCandidates[0]?.index ?? 1}\` to a
1549
1780
  lead: `Action ${selectionText} was approved, but OpenTag cannot directly apply it yet.`,
1550
1781
  resolved: resolved.resolved,
1551
1782
  childRun,
1783
+ provider: parsed.callback.provider,
1552
1784
  approvalDecisionId: decision.id,
1553
1785
  sourceApplyPlanId: execution.plan.id,
1554
1786
  fallbackReason: execution.fallbackReason ?? "The adapter could not execute the selected intent."
@@ -1574,7 +1806,7 @@ Reply with \`apply ${resolved.resolved.selectedCandidates[0]?.index ?? 1}\` to a
1574
1806
  return c.json({ followUpRequest });
1575
1807
  });
1576
1808
  app.post("/v1/follow-up-requests/:id/create-run", async (c) => {
1577
- const parsed = PromoteFollowUpRequestSchema.parse(await c.req.json());
1809
+ const parsed = await parseBody(c, PromoteFollowUpRequestSchema);
1578
1810
  let promoted;
1579
1811
  try {
1580
1812
  promoted = await repo.createRunFromFollowUpRequest({
@@ -1628,7 +1860,7 @@ Reply with \`apply ${resolved.resolved.selectedCandidates[0]?.index ?? 1}\` to a
1628
1860
  }, 410);
1629
1861
  });
1630
1862
  app.post("/v1/runners/:runnerId/runs/:runId/running", async (c) => {
1631
- const body = z.object({ executor: z.string().min(1) }).parse(await c.req.json());
1863
+ const body = await parseBody(c, z.object({ executor: z.string().min(1) }));
1632
1864
  const ok = await repo.markRunning({
1633
1865
  runId: c.req.param("runId"),
1634
1866
  runnerId: c.req.param("runnerId"),
@@ -1645,7 +1877,7 @@ Reply with \`apply ${resolved.resolved.selectedCandidates[0]?.index ?? 1}\` to a
1645
1877
  });
1646
1878
  app.post("/v1/runners/:runnerId/runs/:runId/progress", async (c) => {
1647
1879
  const runId = c.req.param("runId");
1648
- const body = ProgressSchema.parse(await c.req.json());
1880
+ const body = await parseBody(c, ProgressSchema);
1649
1881
  const ok = await repo.recordProgress({
1650
1882
  runId,
1651
1883
  runnerId: c.req.param("runnerId"),
@@ -1685,7 +1917,7 @@ Reply with \`apply ${resolved.resolved.selectedCandidates[0]?.index ?? 1}\` to a
1685
1917
  });
1686
1918
  app.post("/v1/runners/:runnerId/runs/:runId/complete", async (c) => {
1687
1919
  const runId = c.req.param("runId");
1688
- const parsed = CompleteRunSchema.parse(await c.req.json());
1920
+ const parsed = await parseBody(c, CompleteRunSchema);
1689
1921
  const ok = await repo.completeRun({ runId, runnerId: c.req.param("runnerId"), result: parsed.result });
1690
1922
  if (!ok) return c.json({ error: "run_not_claimed_by_runner" }, 404);
1691
1923
  const stored = await repo.getRun({ runId });
@@ -1725,7 +1957,16 @@ Reply with \`apply ${resolved.resolved.selectedCandidates[0]?.index ?? 1}\` to a
1725
1957
  });
1726
1958
  app.post("/v1/proposals/:proposalId/approvals", async (c) => {
1727
1959
  const proposalId = c.req.param("proposalId");
1728
- const parsedBody = ApprovalDecisionInputSchema.safeParse(await c.req.json());
1960
+ let rawBody;
1961
+ try {
1962
+ rawBody = await c.req.json();
1963
+ } catch (err) {
1964
+ if (err instanceof SyntaxError) {
1965
+ return c.json({ error: "invalid_json_body" }, 400);
1966
+ }
1967
+ throw err;
1968
+ }
1969
+ const parsedBody = ApprovalDecisionInputSchema.safeParse(rawBody);
1729
1970
  if (!parsedBody.success) return c.json({ error: "invalid_approval_decision" }, 400);
1730
1971
  const body = parsedBody.data;
1731
1972
  const decision = await repo.recordApprovalDecision({
@@ -1749,7 +1990,7 @@ Reply with \`apply ${resolved.resolved.selectedCandidates[0]?.index ?? 1}\` to a
1749
1990
  });
1750
1991
  app.post("/v1/proposals/:proposalId/apply-plans", async (c) => {
1751
1992
  const proposalId = c.req.param("proposalId");
1752
- const body = ApplyPlanInputSchema.parse(await c.req.json());
1993
+ const body = await parseBody(c, ApplyPlanInputSchema);
1753
1994
  let executableTarget;
1754
1995
  if (body.execute) {
1755
1996
  if (body.adapter !== "github") {
@@ -1843,7 +2084,7 @@ Reply with \`apply ${resolved.resolved.selectedCandidates[0]?.index ?? 1}\` to a
1843
2084
  });
1844
2085
  app.post("/v1/runs/:runId/child-runs", async (c) => {
1845
2086
  const parentRunId = c.req.param("runId");
1846
- const body = ChildRunInputSchema.parse(await c.req.json());
2087
+ const body = await parseBody(c, ChildRunInputSchema);
1847
2088
  const parent = await repo.getRun({ runId: parentRunId });
1848
2089
  if (!parent) return c.json({ error: "parent_run_not_found" }, 404);
1849
2090
  const receivedAt = (/* @__PURE__ */ new Date()).toISOString();
@@ -1880,6 +2121,13 @@ Reply with \`apply ${resolved.resolved.selectedCandidates[0]?.index ?? 1}\` to a
1880
2121
  const events = await repo.listRunEvents({ runId: c.req.param("runId") });
1881
2122
  return c.json({ events });
1882
2123
  });
2124
+ app.onError((err, c) => {
2125
+ if (err instanceof HTTPException) {
2126
+ return err.getResponse();
2127
+ }
2128
+ console.error("dispatcher unhandled error", err);
2129
+ return c.json({ error: "internal_server_error" }, 500);
2130
+ });
1883
2131
  return app;
1884
2132
  }
1885
2133
  export {
@@ -1889,6 +2137,7 @@ export {
1889
2137
  createGitHubCallbackSink,
1890
2138
  createLarkCallbackSink,
1891
2139
  createSlackCallbackSink,
2140
+ createSlackSourceReceiptSink,
1892
2141
  createTelegramCallbackSink,
1893
2142
  processPendingCallbacks
1894
2143
  };