@opentag/dispatcher 0.2.0 → 0.3.1

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/index.js CHANGED
@@ -1,7 +1,20 @@
1
1
  // src/callbacks.ts
2
- import { createLarkReplyClient, parseLarkThreadKey, replyLarkMessage } from "@opentag/lark";
3
- import { createSlackPostMessagePayload, createSlackUpdateMessagePayload, parseSlackThreadKey } from "@opentag/slack";
2
+ import {
3
+ createLarkReplyClient,
4
+ patchLarkMessageCard,
5
+ parseLarkThreadKey,
6
+ replyLarkMessage,
7
+ updateLarkTextMessage
8
+ } from "@opentag/lark";
9
+ import {
10
+ createSlackPostMessagePayload,
11
+ createSlackReactionPayload,
12
+ createSlackUpdateMessagePayload,
13
+ parseSlackThreadKey,
14
+ slackSourceReceiptReactionName
15
+ } from "@opentag/slack";
4
16
  import { createTelegramSendMessageDraftPayload, createTelegramSendMessagePayload, parseTelegramThreadKey } from "@opentag/telegram";
17
+ var DEFAULT_SLACK_SOURCE_RECEIPT_TIMEOUT_MS = 5e3;
5
18
  function slackUpdateUriFrom(postMessageUri) {
6
19
  return postMessageUri.replace(/\/chat\.postMessage$/, "/chat.update");
7
20
  }
@@ -18,6 +31,31 @@ function slackBotTokenFor(input) {
18
31
  }
19
32
  return input.botToken;
20
33
  }
34
+ function metadataString(metadata, key) {
35
+ const value = metadata[key];
36
+ return typeof value === "string" && value.length > 0 ? value : void 0;
37
+ }
38
+ function slackSourceMessageTarget(receipt) {
39
+ if (receipt.provider !== "slack") return null;
40
+ const channelId = metadataString(receipt.event.metadata, "channelId");
41
+ const messageTs = metadataString(receipt.event.metadata, "messageTs");
42
+ return channelId && messageTs ? { channelId, messageTs } : null;
43
+ }
44
+ function isAbortError(error) {
45
+ return error instanceof Error && error.name === "AbortError";
46
+ }
47
+ async function fetchWithTimeout(input) {
48
+ const controller = new AbortController();
49
+ const timeout = setTimeout(() => controller.abort(), input.timeoutMs);
50
+ try {
51
+ return await input.fetchImpl(input.uri, { ...input.init, signal: controller.signal });
52
+ } catch (error) {
53
+ if (isAbortError(error)) return null;
54
+ throw error;
55
+ } finally {
56
+ clearTimeout(timeout);
57
+ }
58
+ }
21
59
  function createGitHubCallbackSink(input) {
22
60
  const fetchImpl = input.fetchImpl ?? fetch;
23
61
  const commentUriByKey = /* @__PURE__ */ new Map();
@@ -70,9 +108,9 @@ function createSlackCallbackSink(input) {
70
108
  async deliver(message) {
71
109
  if (message.provider !== "slack") return;
72
110
  const botToken = slackBotTokenFor({
73
- ...input.botToken ? { botToken: input.botToken } : {},
74
- ...input.botTokensByAgentId ? { botTokensByAgentId: input.botTokensByAgentId } : {},
75
- ...message.agentId ? { agentId: message.agentId } : {}
111
+ botToken: input.botToken,
112
+ botTokensByAgentId: input.botTokensByAgentId,
113
+ agentId: message.agentId
76
114
  });
77
115
  if (!botToken) return;
78
116
  const thread = parseSlackThreadKey(message.threadKey ?? "");
@@ -117,6 +155,51 @@ function createSlackCallbackSink(input) {
117
155
  }
118
156
  };
119
157
  }
158
+ function createSlackSourceReceiptSink(input) {
159
+ const fetchImpl = input.fetchImpl ?? fetch;
160
+ const reactionsAddUri = input.reactionsAddUri ?? "https://slack.com/api/reactions.add";
161
+ const timeoutMs = input.timeoutMs ?? DEFAULT_SLACK_SOURCE_RECEIPT_TIMEOUT_MS;
162
+ return {
163
+ async deliver(receipt) {
164
+ const target = slackSourceMessageTarget(receipt);
165
+ if (!target) return { delivered: false };
166
+ const botToken = slackBotTokenFor({
167
+ botToken: input.botToken,
168
+ botTokensByAgentId: input.botTokensByAgentId,
169
+ agentId: receipt.agentId
170
+ });
171
+ if (!botToken) return { delivered: false };
172
+ const response = await fetchWithTimeout({
173
+ fetchImpl,
174
+ uri: reactionsAddUri,
175
+ timeoutMs,
176
+ init: {
177
+ method: "POST",
178
+ headers: {
179
+ authorization: `Bearer ${botToken}`,
180
+ "content-type": "application/json"
181
+ },
182
+ body: JSON.stringify(
183
+ createSlackReactionPayload({
184
+ channelId: target.channelId,
185
+ messageTs: target.messageTs,
186
+ name: slackSourceReceiptReactionName(receipt.state)
187
+ })
188
+ )
189
+ }
190
+ });
191
+ if (!response) return { delivered: false };
192
+ if (!response.ok) {
193
+ throw new Error(`deliver Slack source receipt failed: ${response.status} ${await response.text()}`);
194
+ }
195
+ const body = await response.json().catch(() => ({}));
196
+ if (body?.ok === false && body.error !== "already_reacted") {
197
+ throw new Error(`deliver Slack source receipt failed: ${body?.error ?? "unknown_error"}`);
198
+ }
199
+ return { delivered: true };
200
+ }
201
+ };
202
+ }
120
203
  function createLarkCallbackSink(input) {
121
204
  if (!input.client && Boolean(input.appId) !== Boolean(input.appSecret)) {
122
205
  throw new Error("Lark callback sink requires both appId and appSecret (or neither).");
@@ -131,8 +214,27 @@ function createLarkCallbackSink(input) {
131
214
  if (!message.threadKey) {
132
215
  throw new Error("Lark callback message is missing threadKey.");
133
216
  }
217
+ if (message.externalMessageId) {
218
+ if (message.rich?.provider === "lark") {
219
+ await patchLarkMessageCard(client, {
220
+ messageId: message.externalMessageId,
221
+ card: message.rich.payload
222
+ });
223
+ } else {
224
+ await updateLarkTextMessage(client, {
225
+ messageId: message.externalMessageId,
226
+ text: message.body
227
+ });
228
+ }
229
+ return { externalMessageId: message.externalMessageId };
230
+ }
134
231
  const { messageId } = parseLarkThreadKey(message.threadKey);
135
- await replyLarkMessage(client, { messageId, text: message.body });
232
+ const reply = await replyLarkMessage(client, {
233
+ messageId,
234
+ text: message.body,
235
+ ...message.rich?.provider === "lark" ? { card: message.rich.payload } : {}
236
+ });
237
+ return reply.messageId ? { externalMessageId: reply.messageId } : void 0;
136
238
  }
137
239
  };
138
240
  }
@@ -144,9 +246,9 @@ function createTelegramCallbackSink(input) {
144
246
  async deliver(message) {
145
247
  if (message.provider !== "telegram") return;
146
248
  const botToken = slackBotTokenFor({
147
- ...input.botToken ? { botToken: input.botToken } : {},
148
- ...input.botTokensByAgentId ? { botTokensByAgentId: input.botTokensByAgentId } : {},
149
- ...message.agentId ? { agentId: message.agentId } : {}
249
+ botToken: input.botToken,
250
+ botTokensByAgentId: input.botTokensByAgentId,
251
+ agentId: message.agentId
150
252
  });
151
253
  if (!botToken) return;
152
254
  const thread = parseTelegramThreadKey(message.threadKey ?? "");
@@ -191,70 +293,282 @@ function createTelegramCallbackSink(input) {
191
293
  function createCompositeCallbackSink(sinks) {
192
294
  return {
193
295
  async deliver(message) {
296
+ let result;
297
+ let delivered = false;
298
+ const failures = [];
194
299
  for (const sink of sinks) {
195
- await sink.deliver(message);
300
+ try {
301
+ const deliveredResult = await sink.deliver(message);
302
+ delivered = true;
303
+ if (deliveredResult?.externalMessageId && !result?.externalMessageId) {
304
+ result = { externalMessageId: deliveredResult.externalMessageId };
305
+ }
306
+ } catch (error) {
307
+ failures.push(error);
308
+ }
309
+ }
310
+ if (!delivered && failures.length > 0) {
311
+ throw new AggregateError(failures, "Composite callback delivery failed for every sink.");
196
312
  }
313
+ return result;
197
314
  }
198
315
  };
199
316
  }
200
317
 
201
318
  // src/presentation.ts
202
- import { renderAcknowledgement, renderFinalResult, renderProgress } from "@opentag/github";
203
- import { renderLarkAcknowledgement, renderLarkFinalResult } from "@opentag/lark";
204
- import { createSlackFinalResultBlocks, renderSlackAcknowledgement, renderSlackFinalResult } from "@opentag/slack";
205
- import { renderTelegramAcknowledgement, renderTelegramFinalResult, renderTelegramProgress } from "@opentag/telegram";
319
+ import {
320
+ createFinalSummaryPresentation,
321
+ createRunStatusPresentation,
322
+ platformCapabilityForProvider,
323
+ renderOpenTagPresentationPlainText,
324
+ shouldDeliverCallbackProgress,
325
+ shouldDeliverCallbackRunStatus
326
+ } from "@opentag/core";
327
+ import { renderAcknowledgement, renderFinalSummaryPresentation, renderProgress } from "@opentag/github";
328
+ import {
329
+ createLarkActionReceiptCard,
330
+ createLarkDoctorSummaryCard,
331
+ createLarkFinalSummaryCard,
332
+ createLarkRunStatusCard,
333
+ createLarkSourceThreadStatusCard,
334
+ renderLarkActionReceiptPresentation,
335
+ renderLarkFinalSummaryPresentation,
336
+ renderLarkRunStatusPresentation
337
+ } from "@opentag/lark";
338
+ import {
339
+ createSlackActionReceiptBlocks,
340
+ createSlackDoctorSummaryBlocks,
341
+ createSlackFinalSummaryBlocks,
342
+ createSlackSourceThreadStatusBlocks,
343
+ renderSlackActionReceiptPresentation,
344
+ renderSlackAcknowledgement,
345
+ renderSlackFinalSummaryPresentation
346
+ } from "@opentag/slack";
347
+ import { renderTelegramAcknowledgement, renderTelegramFinalSummaryPresentation, renderTelegramProgress } from "@opentag/telegram";
348
+ function renderRunStatus(provider, presentation) {
349
+ const canRenderRich = supportsRichPresentation(provider);
350
+ if (canRenderRich && provider === "lark") {
351
+ return {
352
+ body: renderLarkRunStatusPresentation(presentation),
353
+ rich: {
354
+ provider: "lark",
355
+ payload: createLarkRunStatusCard(presentation)
356
+ }
357
+ };
358
+ }
359
+ if (presentation.state === "received") {
360
+ if (provider === "slack") {
361
+ return { body: renderSlackAcknowledgement(presentation.runId) };
362
+ }
363
+ if (provider === "telegram") {
364
+ return { body: renderTelegramAcknowledgement(presentation.runId) };
365
+ }
366
+ return { body: renderAcknowledgement(presentation.runId) };
367
+ }
368
+ const message = presentation.message ?? presentation.nextAction ?? presentation.state;
369
+ if (provider === "telegram") {
370
+ return { body: renderTelegramProgress(message) };
371
+ }
372
+ return { body: renderProgress({ runId: presentation.runId, message }) };
373
+ }
374
+ function supportsRichPresentation(provider) {
375
+ return platformCapabilityForProvider(provider)?.supportsRichPresentation === true;
376
+ }
377
+ function renderFinalSummary(provider, presentation) {
378
+ const canRenderRich = supportsRichPresentation(provider);
379
+ if (canRenderRich && provider === "slack") {
380
+ return {
381
+ body: renderSlackFinalSummaryPresentation(presentation),
382
+ blocks: createSlackFinalSummaryBlocks(presentation)
383
+ };
384
+ }
385
+ if (canRenderRich && provider === "lark") {
386
+ return {
387
+ body: renderLarkFinalSummaryPresentation(presentation),
388
+ rich: {
389
+ provider: "lark",
390
+ payload: createLarkFinalSummaryCard(presentation)
391
+ }
392
+ };
393
+ }
394
+ if (provider === "telegram") {
395
+ return { body: renderTelegramFinalSummaryPresentation(presentation) };
396
+ }
397
+ return { body: renderFinalSummaryPresentation(presentation) };
398
+ }
399
+ function renderDoctorSummary(provider, presentation) {
400
+ const body = renderOpenTagPresentationPlainText(presentation);
401
+ const canRenderRich = supportsRichPresentation(provider);
402
+ if (canRenderRich && provider === "slack") {
403
+ return {
404
+ body,
405
+ blocks: createSlackDoctorSummaryBlocks(presentation)
406
+ };
407
+ }
408
+ if (canRenderRich && provider === "lark") {
409
+ return {
410
+ body,
411
+ rich: {
412
+ provider: "lark",
413
+ payload: createLarkDoctorSummaryCard(presentation)
414
+ }
415
+ };
416
+ }
417
+ return { body };
418
+ }
419
+ function renderSourceThreadStatus(provider, presentation) {
420
+ const body = renderOpenTagPresentationPlainText(presentation);
421
+ const canRenderRich = supportsRichPresentation(provider);
422
+ if (canRenderRich && provider === "slack") {
423
+ return {
424
+ body,
425
+ blocks: createSlackSourceThreadStatusBlocks(presentation)
426
+ };
427
+ }
428
+ if (canRenderRich && provider === "lark") {
429
+ return {
430
+ body,
431
+ rich: {
432
+ provider: "lark",
433
+ payload: createLarkSourceThreadStatusCard(presentation)
434
+ }
435
+ };
436
+ }
437
+ return { body };
438
+ }
439
+ function renderActionReceipt(provider, presentation) {
440
+ const body = provider === "slack" ? renderSlackActionReceiptPresentation(presentation) : provider === "lark" ? renderLarkActionReceiptPresentation(presentation) : renderOpenTagPresentationPlainText(presentation);
441
+ const canRenderRich = supportsRichPresentation(provider);
442
+ if (canRenderRich && provider === "slack") {
443
+ return {
444
+ body,
445
+ blocks: createSlackActionReceiptBlocks(presentation)
446
+ };
447
+ }
448
+ if (canRenderRich && provider === "lark") {
449
+ return {
450
+ body,
451
+ rich: {
452
+ provider: "lark",
453
+ payload: createLarkActionReceiptCard(presentation)
454
+ }
455
+ };
456
+ }
457
+ return { body };
458
+ }
206
459
  function createDefaultCallbackPresentation() {
207
460
  return {
208
461
  shouldDeliverAcknowledgement(provider) {
209
- return provider !== "lark";
462
+ return shouldDeliverCallbackRunStatus(provider);
463
+ },
464
+ shouldDeliverStatusUpdate(provider) {
465
+ return shouldDeliverCallbackRunStatus(provider);
466
+ },
467
+ shouldDeliverRunStatusUpdate(input) {
468
+ if (input.provider === "lark" && input.state === "running") return false;
469
+ return this.shouldDeliverStatusUpdate(input.provider);
210
470
  },
211
471
  shouldDeliverProgress(provider) {
212
- return provider !== "slack" && provider !== "lark";
472
+ return shouldDeliverCallbackProgress(provider);
213
473
  },
214
- acknowledgement(input) {
215
- if (input.provider === "slack") {
216
- return renderSlackAcknowledgement(input.runId);
474
+ runStatusPresentation(input) {
475
+ return createRunStatusPresentation({
476
+ runId: input.runId,
477
+ state: input.state,
478
+ ...input.message ? { message: input.message } : {},
479
+ ...input.nextAction ? { nextAction: input.nextAction } : {},
480
+ ...input.detailVisibility ? { detailVisibility: input.detailVisibility } : {}
481
+ });
482
+ },
483
+ acknowledgementPresentation(input) {
484
+ return this.runStatusPresentation({
485
+ runId: input.runId,
486
+ state: "received",
487
+ detailVisibility: "source_thread"
488
+ });
489
+ },
490
+ progressPresentation(input) {
491
+ return this.runStatusPresentation({
492
+ runId: input.runId,
493
+ state: "running",
494
+ message: input.message,
495
+ detailVisibility: "audit"
496
+ });
497
+ },
498
+ finalPresentation(input) {
499
+ return createFinalSummaryPresentation({
500
+ result: input.result,
501
+ ...input.receiptContext ? { receiptContext: input.receiptContext } : {},
502
+ ...input.runId ? { auditRunId: input.runId } : {}
503
+ });
504
+ },
505
+ render(input) {
506
+ if (input.presentation.kind === "run_status") {
507
+ return renderRunStatus(input.provider, input.presentation);
508
+ }
509
+ if (input.presentation.kind === "final_summary") {
510
+ return renderFinalSummary(input.provider, input.presentation);
511
+ }
512
+ if (input.presentation.kind === "doctor_summary") {
513
+ return renderDoctorSummary(input.provider, input.presentation);
217
514
  }
218
- if (input.provider === "lark") {
219
- return renderLarkAcknowledgement(input.runId);
515
+ if (input.presentation.kind === "source_thread_status") {
516
+ return renderSourceThreadStatus(input.provider, input.presentation);
220
517
  }
221
- if (input.provider === "telegram") {
222
- return renderTelegramAcknowledgement(input.runId);
518
+ if (input.presentation.kind === "action_receipt") {
519
+ return renderActionReceipt(input.provider, input.presentation);
223
520
  }
224
- return renderAcknowledgement(input.runId);
521
+ return {
522
+ body: renderOpenTagPresentationPlainText(input.presentation)
523
+ };
524
+ },
525
+ acknowledgement(input) {
526
+ return this.render({ provider: input.provider, presentation: this.acknowledgementPresentation({ runId: input.runId }) }).body;
527
+ },
528
+ runStatus(input) {
529
+ return this.render({
530
+ provider: input.provider,
531
+ presentation: this.runStatusPresentation({
532
+ runId: input.runId,
533
+ state: input.state,
534
+ ...input.message ? { message: input.message } : {},
535
+ ...input.nextAction ? { nextAction: input.nextAction } : {},
536
+ ...input.detailVisibility ? { detailVisibility: input.detailVisibility } : {}
537
+ })
538
+ });
225
539
  },
226
540
  progress(input) {
227
- if (input.provider === "telegram") {
228
- return renderTelegramProgress(input.message);
229
- }
230
- return renderProgress({ runId: input.runId, message: input.message });
541
+ return this.runStatus({
542
+ provider: input.provider,
543
+ runId: input.runId,
544
+ state: "running",
545
+ message: input.message,
546
+ detailVisibility: "audit"
547
+ }).body;
231
548
  },
232
549
  final(input) {
233
- if (input.provider === "slack") {
234
- return {
235
- body: renderSlackFinalResult(input.result),
236
- blocks: createSlackFinalResultBlocks(input.result)
237
- };
238
- }
239
- if (input.provider === "lark") {
240
- return { body: renderLarkFinalResult(input.result) };
241
- }
242
- if (input.provider === "telegram") {
243
- return { body: renderTelegramFinalResult(input.result) };
244
- }
245
- return { body: renderFinalResult(input.result) };
550
+ return this.render({
551
+ provider: input.provider,
552
+ presentation: this.finalPresentation({
553
+ result: input.result,
554
+ ...input.runId ? { runId: input.runId } : {},
555
+ ...input.receiptContext ? { receiptContext: input.receiptContext } : {}
556
+ })
557
+ });
246
558
  }
247
559
  };
248
560
  }
249
561
 
250
562
  // src/server.ts
251
- import { createHash } from "crypto";
563
+ import { createHash, randomUUID } from "crypto";
252
564
  import {
253
565
  AdapterMutationMappingSchema,
254
566
  ActorIdentitySchema,
255
567
  ActionHintSchema,
568
+ capabilityForMutationIntent,
256
569
  conversationKeysFromEvent as conversationKeysFromEvent2,
257
570
  parseThreadActionCommand,
571
+ permissionScopesAllowCapability,
258
572
  projectTargetRefFromEvent as projectTargetRefFromEvent2,
259
573
  suggestedActionCandidatesFromSnapshots,
260
574
  createAdapterMutationCompilerRegistry,
@@ -262,7 +576,12 @@ import {
262
576
  OpenTagRunResultSchema,
263
577
  PolicyRuleSchema,
264
578
  RunEventImportanceSchema,
265
- RunEventVisibilitySchema
579
+ RunEventVisibilitySchema,
580
+ DEFAULT_MAX_REQUEST_BODY_BYTES,
581
+ RequestBodyTooLargeError,
582
+ platformCapabilityForProvider as platformCapabilityForProvider2,
583
+ readRequestTextWithLimit,
584
+ shouldDeliverSourceReceipt
266
585
  } from "@opentag/core";
267
586
  import {
268
587
  applyGitHubIssueMutationOperation,
@@ -272,6 +591,7 @@ import { createOpenTagRepository, migrateSchema } from "@opentag/store";
272
591
  import Database from "better-sqlite3";
273
592
  import { drizzle } from "drizzle-orm/better-sqlite3";
274
593
  import { Hono } from "hono";
594
+ import { HTTPException } from "hono/http-exception";
275
595
  import { z } from "zod";
276
596
 
277
597
  // src/admission.ts
@@ -416,6 +736,144 @@ function createAdmissionRuntime(input) {
416
736
  }
417
737
 
418
738
  // src/server.ts
739
+ var RequestBodyRejectedError = class extends Error {
740
+ reason;
741
+ publicError;
742
+ constructor(input) {
743
+ super(input.reason);
744
+ this.name = "RequestBodyRejectedError";
745
+ this.reason = input.reason;
746
+ this.publicError = input.publicError ?? input.reason;
747
+ }
748
+ };
749
+ function requestBodyTooLarge(c, maxBytes) {
750
+ return new HTTPException(413, {
751
+ res: c.json({ error: "request_body_too_large", maxBytes }, 413)
752
+ });
753
+ }
754
+ async function parseBody(c, schema, options = {}) {
755
+ let json;
756
+ try {
757
+ const rawBody = await readRequestTextWithLimit(c.req.raw, { maxBytes: options.maxBytes ?? DEFAULT_MAX_REQUEST_BODY_BYTES });
758
+ json = JSON.parse(rawBody);
759
+ } catch (err) {
760
+ if (err instanceof RequestBodyTooLargeError) throw requestBodyTooLarge(c, err.maxBytes);
761
+ if (err instanceof HTTPException) throw err;
762
+ if (err instanceof SyntaxError) {
763
+ throw new HTTPException(400, {
764
+ res: c.json({ error: "invalid_json_body" }, 400),
765
+ cause: new RequestBodyRejectedError({ reason: "invalid_json_body" })
766
+ });
767
+ }
768
+ throw err;
769
+ }
770
+ const result = schema.safeParse(json);
771
+ if (!result.success) {
772
+ const publicError = options.invalidBodyError ?? "invalid_request_body";
773
+ throw new HTTPException(400, {
774
+ res: c.json({ error: publicError, issues: result.error.issues }, 400),
775
+ cause: new RequestBodyRejectedError({ reason: "invalid_request_body", publicError })
776
+ });
777
+ }
778
+ return result.data;
779
+ }
780
+ function normalizeRateLimitedEndpoint(method, path) {
781
+ return `${method.toUpperCase()} ${path}`.replace(/^([A-Z]+) \/v1\/runners\/[^/]+\/runs\/[^/]+/, "$1 /v1/runners/:runnerId/runs/:runId").replace(/^([A-Z]+) \/v1\/runners\/[^/]+/, "$1 /v1/runners/:runnerId").replace(/^([A-Z]+) \/v1\/repo-bindings\/[^/]+\/[^/]+\/[^/]+/, "$1 /v1/repo-bindings/:provider/:owner/:repo").replace(/^([A-Z]+) \/v1\/channel-bindings\/[^/]+\/[^/]+\/[^/]+/, "$1 /v1/channel-bindings/:provider/:accountId/:conversationId").replace(/^([A-Z]+) \/v1\/slack-channel-bindings\/[^/]+\/[^/]+/, "$1 /v1/slack-channel-bindings/:teamId/:channelId").replace(/^([A-Z]+) \/v1\/follow-up-requests\/[^/]+/, "$1 /v1/follow-up-requests/:id").replace(/^([A-Z]+) \/v1\/proposals\/[^/]+/, "$1 /v1/proposals/:proposalId").replace(/^([A-Z]+) \/v1\/approvals\/[^/]+/, "$1 /v1/approvals/:approvalDecisionId").replace(/^([A-Z]+) \/v1\/apply-plans\/[^/]+/, "$1 /v1/apply-plans/:applyPlanId").replace(/^([A-Z]+) \/v1\/runs\/[^/]+/, "$1 /v1/runs/:runId");
782
+ }
783
+ function rateLimitRunnerId(path) {
784
+ return path.match(/^\/v1\/runners\/([^/]+)/)?.[1] ?? "none";
785
+ }
786
+ function rateLimitSourcePlatform(path) {
787
+ const channelProvider = path.match(/^\/v1\/channel-bindings\/([^/]+)/)?.[1];
788
+ if (channelProvider) return channelProvider;
789
+ const repoProvider = path.match(/^\/v1\/repo-bindings\/([^/]+)/)?.[1];
790
+ if (repoProvider) return repoProvider;
791
+ if (path.startsWith("/v1/slack-channel-bindings/")) return "slack";
792
+ return "unknown";
793
+ }
794
+ function safeDecodeRateLimitSegment(value) {
795
+ if (!value) return void 0;
796
+ try {
797
+ return decodeURIComponent(value);
798
+ } catch {
799
+ return value;
800
+ }
801
+ }
802
+ function rateLimitTenant(path) {
803
+ const channel = path.match(/^\/v1\/channel-bindings\/([^/]+)\/([^/]+)/);
804
+ if (channel) {
805
+ const provider = safeDecodeRateLimitSegment(channel[1]) ?? "unknown";
806
+ const accountId = safeDecodeRateLimitSegment(channel[2]) ?? "unknown";
807
+ return `${provider}:${accountId}`;
808
+ }
809
+ const legacySlack = path.match(/^\/v1\/slack-channel-bindings\/([^/]+)/);
810
+ if (legacySlack) return `slack:${safeDecodeRateLimitSegment(legacySlack[1]) ?? "unknown"}`;
811
+ const repo = path.match(/^\/v1\/repo-bindings\/([^/]+)\/([^/]+)/);
812
+ if (repo) {
813
+ const provider = safeDecodeRateLimitSegment(repo[1]) ?? "unknown";
814
+ const owner = safeDecodeRateLimitSegment(repo[2]) ?? "unknown";
815
+ return `${provider}:${owner}`;
816
+ }
817
+ return "unknown";
818
+ }
819
+ function rateLimitTokenFingerprint(authorization) {
820
+ if (!authorization) return "none";
821
+ return createHash("sha256").update(authorization).digest("hex").slice(0, 16);
822
+ }
823
+ function rawTokenFingerprint(token) {
824
+ return createHash("sha256").update(token).digest("hex");
825
+ }
826
+ function rateLimitKey(c) {
827
+ const path = new URL(c.req.url).pathname;
828
+ return [
829
+ `token=${rateLimitTokenFingerprint(c.req.raw.headers.get("authorization"))}`,
830
+ `runner=${rateLimitRunnerId(path)}`,
831
+ `source=${rateLimitSourcePlatform(path)}`,
832
+ `tenant=${rateLimitTenant(path)}`,
833
+ `endpoint=${normalizeRateLimitedEndpoint(c.req.method, path)}`
834
+ ].join("|");
835
+ }
836
+ function createDispatcherRateLimitMiddleware(options) {
837
+ if (!Number.isFinite(options.windowMs) || options.windowMs <= 0) {
838
+ throw new Error("rateLimit.windowMs must be a positive number.");
839
+ }
840
+ if (!Number.isFinite(options.maxRequests) || options.maxRequests <= 0) {
841
+ throw new Error("rateLimit.maxRequests must be a positive number.");
842
+ }
843
+ const buckets = /* @__PURE__ */ new Map();
844
+ const now = options.now ?? (() => Date.now());
845
+ return async (c, next) => {
846
+ const currentTime = now();
847
+ for (const [bucketKey, bucket2] of buckets) {
848
+ if (bucket2.resetAt <= currentTime) buckets.delete(bucketKey);
849
+ }
850
+ const key = rateLimitKey(c);
851
+ const existing = buckets.get(key);
852
+ const bucket = existing && existing.resetAt > currentTime ? existing : { count: 0, resetAt: currentTime + options.windowMs };
853
+ if (bucket.count >= options.maxRequests) {
854
+ const retryAfterMs = Math.max(0, bucket.resetAt - currentTime);
855
+ c.header("retry-after", String(Math.ceil(retryAfterMs / 1e3)));
856
+ return c.json(
857
+ {
858
+ error: "rate_limited",
859
+ retryAfterMs,
860
+ maxRequests: options.maxRequests,
861
+ windowMs: options.windowMs
862
+ },
863
+ 429
864
+ );
865
+ }
866
+ bucket.count += 1;
867
+ buckets.set(key, bucket);
868
+ await next();
869
+ };
870
+ }
871
+ function shouldDeliverRunStatusUpdate(presentation, input) {
872
+ return presentation.shouldDeliverRunStatusUpdate?.(input) ?? presentation.shouldDeliverStatusUpdate(input.provider);
873
+ }
874
+ function larkLifecycleStatusMessageKey(input) {
875
+ return input.provider === "lark" ? `${input.runId}:status` : void 0;
876
+ }
419
877
  var CreateRunnerSchema = z.object({
420
878
  runnerId: z.string().min(1),
421
879
  name: z.string().min(1)
@@ -455,11 +913,32 @@ var CreateRunSchema = z.object({
455
913
  runId: z.string().min(1),
456
914
  event: OpenTagEventSchema
457
915
  });
916
+ var RecordControlPlaneEventSchema = z.object({
917
+ type: z.string().min(1),
918
+ severity: z.enum(["info", "warn", "error"]).optional(),
919
+ subject: z.string().min(1).optional(),
920
+ payload: z.record(z.string(), z.unknown()).optional(),
921
+ createdAt: z.string().datetime().optional()
922
+ });
923
+ var PruneSourceDeliveriesSchema = z.object({
924
+ olderThan: z.string().datetime(),
925
+ limit: z.number().int().positive().max(1e5).optional()
926
+ });
458
927
  var PromoteFollowUpRequestSchema = z.object({
459
928
  runId: z.string().min(1)
460
929
  });
461
930
  var CompleteRunSchema = z.object({
462
- result: OpenTagRunResultSchema
931
+ result: OpenTagRunResultSchema,
932
+ idempotencyKey: z.string().min(1).max(256).optional()
933
+ });
934
+ var MarkRunningSchema = z.object({
935
+ executor: z.string().min(1),
936
+ runTimeoutMs: z.number().int().positive().optional(),
937
+ idempotencyKey: z.string().min(1).max(256).optional()
938
+ });
939
+ var CancelRunSchema = z.object({
940
+ reason: z.string().min(1).optional(),
941
+ requestedBy: z.string().min(1).optional()
463
942
  });
464
943
  var ApprovalDecisionInputSchema = z.object({
465
944
  id: z.string().min(1).optional(),
@@ -501,13 +980,38 @@ var ChildRunInputSchema = z.object({
501
980
  sourceProposalId: z.string().min(1).optional(),
502
981
  sourceApplyPlanId: z.string().min(1).optional()
503
982
  });
983
+ var CHILD_EVENT_METADATA_REPLAY_KEYS = [
984
+ "sourceDeliveryId",
985
+ "webhookDeliveryId",
986
+ "deliveryId",
987
+ "githubDeliveryId",
988
+ "githubDeliveryGuid",
989
+ "slackEventId",
990
+ "larkEventId",
991
+ "signatureState",
992
+ "signatureVerified",
993
+ "verifiedSignature",
994
+ "webhookSignatureVerified",
995
+ "githubSignatureVerified"
996
+ ];
504
997
  var ProgressSchema = z.object({
505
998
  type: z.string().min(1).optional(),
506
999
  message: z.string().min(1),
507
1000
  at: z.string().datetime().optional(),
508
1001
  visibility: RunEventVisibilitySchema.optional(),
509
- importance: RunEventImportanceSchema.optional()
1002
+ importance: RunEventImportanceSchema.optional(),
1003
+ idempotencyKey: z.string().min(1).max(256).optional()
510
1004
  });
1005
+ function childEventMetadata(parentMetadata, metadata) {
1006
+ const sanitized = { ...parentMetadata };
1007
+ for (const key of CHILD_EVENT_METADATA_REPLAY_KEYS) {
1008
+ delete sanitized[key];
1009
+ }
1010
+ return {
1011
+ ...sanitized,
1012
+ ...metadata ?? {}
1013
+ };
1014
+ }
511
1015
  function childEventFromParent(input) {
512
1016
  return {
513
1017
  ...input.parentEvent,
@@ -523,10 +1027,8 @@ function childEventFromParent(input) {
523
1027
  actionKind: input.actionKind
524
1028
  }
525
1029
  },
526
- metadata: {
527
- ...input.parentEvent.metadata,
528
- ...input.metadata ?? {}
529
- }
1030
+ metadata: childEventMetadata(input.parentEvent.metadata, input.metadata),
1031
+ permissions: input.permissions ?? input.parentEvent.permissions
530
1032
  };
531
1033
  }
532
1034
  function mappingsFromAdapterPlan(adapterPlan) {
@@ -544,13 +1046,35 @@ function metadataIssueNumber(metadata) {
544
1046
  if (typeof value === "string" && /^[1-9]\d*$/.test(value)) return value;
545
1047
  return void 0;
546
1048
  }
547
- function metadataString(metadata, key) {
1049
+ function metadataString2(metadata, key) {
548
1050
  const value = metadata?.[key];
549
1051
  return typeof value === "string" && value.length > 0 ? value : void 0;
550
1052
  }
1053
+ function sourceContainerMetadata(input) {
1054
+ if (input.provider === "lark") {
1055
+ return { tenantKey: input.accountId, chatId: input.conversationId };
1056
+ }
1057
+ if (input.provider === "slack") {
1058
+ return { teamId: input.accountId, channelId: input.conversationId };
1059
+ }
1060
+ if (input.provider === "telegram") {
1061
+ return { botId: input.accountId, chatId: input.conversationId };
1062
+ }
1063
+ return { accountId: input.accountId, conversationId: input.conversationId };
1064
+ }
1065
+ function latestRunTimeoutMs(events) {
1066
+ for (const event of [...events].reverse()) {
1067
+ if (event.type !== "run.running" || !event.payload || typeof event.payload !== "object") continue;
1068
+ const runTimeoutMs = event.payload.runTimeoutMs;
1069
+ if (typeof runTimeoutMs === "number" && Number.isInteger(runTimeoutMs) && runTimeoutMs > 0) {
1070
+ return runTimeoutMs;
1071
+ }
1072
+ }
1073
+ return void 0;
1074
+ }
551
1075
  function githubIssueWorkItemExternalId(metadata) {
552
- const owner = metadataString(metadata, "owner");
553
- const repo = metadataString(metadata, "repo");
1076
+ const owner = metadataString2(metadata, "owner");
1077
+ const repo = metadataString2(metadata, "repo");
554
1078
  const issueNumber = metadataIssueNumber(metadata);
555
1079
  if (!owner || !repo || !issueNumber) return void 0;
556
1080
  return `${owner}/${repo}#${issueNumber}`;
@@ -662,6 +1186,30 @@ async function resolveThreadAction(input) {
662
1186
  });
663
1187
  const primaryConversationKey = conversationKeys[0];
664
1188
  const targetWorkItemExternalId = githubIssueWorkItemExternalId(input.metadata);
1189
+ const metadataProposalId = metadataString2(input.metadata, "proposalId");
1190
+ const metadataIntentId = metadataString2(input.metadata, "intentId");
1191
+ if (metadataProposalId && (input.command.selection.kind === "index" || input.command.selection.kind === "latest")) {
1192
+ const stored = await input.repo.getSuggestedChanges({ proposalId: metadataProposalId });
1193
+ if (!stored) {
1194
+ return { ok: false, reason: "no_proposal", message: `I could not find proposal \`${metadataProposalId}\`.` };
1195
+ }
1196
+ const claimed = await input.repo.getRun({ runId: stored.runId });
1197
+ if (!claimed) {
1198
+ return { ok: false, reason: "no_proposal", message: "I found the proposal but not its source run." };
1199
+ }
1200
+ const proposalConversationKeys = conversationKeysFromEvent2(claimed.event);
1201
+ if (!proposalConversationKeys.some((key) => conversationKeys.includes(key))) {
1202
+ return { ok: false, reason: "no_match", runId: stored.runId, message: "That proposal does not belong to this source thread." };
1203
+ }
1204
+ const proposal = { runId: stored.runId, run: claimed.run, event: claimed.event, snapshot: stored.snapshot };
1205
+ if (targetWorkItemExternalId && !proposalMatchesWorkItem(proposal, targetWorkItemExternalId)) {
1206
+ return { ok: false, reason: "no_match", runId: stored.runId, message: "That proposal does not belong to this source thread." };
1207
+ }
1208
+ return resolveCandidateSelection({
1209
+ command: metadataIntentId ? { ...input.command, selection: { kind: "intent", intentId: metadataIntentId } } : { ...input.command, selection: { kind: "proposal", proposalId: metadataProposalId } },
1210
+ proposals: [proposal]
1211
+ });
1212
+ }
665
1213
  if (input.command.selection.kind === "proposal") {
666
1214
  const stored = await input.repo.getSuggestedChanges({ proposalId: input.command.selection.proposalId });
667
1215
  if (!stored) {
@@ -707,6 +1255,240 @@ function isRepoLevelGitHubIntent(intent) {
707
1255
  function adapterForAction(input) {
708
1256
  return hasGitHubRepoTarget(input.event) && (hasGitHubIssueOrPullTarget(input.event) || input.selectedIntents.length > 0 && input.selectedIntents.every((intent) => isRepoLevelGitHubIntent(intent))) ? "github" : input.callbackProvider;
709
1257
  }
1258
+ function executorConditionsFromIntent(intent) {
1259
+ const value = intent.params?.["executorConditions"];
1260
+ if (!Array.isArray(value)) return [];
1261
+ return value.filter((condition) => typeof condition === "string" && condition.length > 0);
1262
+ }
1263
+ var GITHUB_PREFLIGHT_TIMEOUT_MS = 5e3;
1264
+ function githubPreflightCacheKey(input) {
1265
+ return `${input.owner}/${input.repo}${input.path}`;
1266
+ }
1267
+ function createGitHubPreflightDeadline(timeoutMs) {
1268
+ if (typeof AbortController === "undefined") return { clear: () => {
1269
+ }, didTimeout: () => false };
1270
+ const controller = new AbortController();
1271
+ let didTimeout = false;
1272
+ const timeout = setTimeout(() => {
1273
+ didTimeout = true;
1274
+ controller.abort();
1275
+ }, timeoutMs);
1276
+ return {
1277
+ signal: controller.signal,
1278
+ clear: () => clearTimeout(timeout),
1279
+ didTimeout: () => didTimeout
1280
+ };
1281
+ }
1282
+ async function githubPreflight(input) {
1283
+ if (input.cache) {
1284
+ const cacheKey = githubPreflightCacheKey(input);
1285
+ const cached = input.cache.get(cacheKey);
1286
+ if (cached) return await cached;
1287
+ const pending = githubPreflightUncached(input);
1288
+ input.cache.set(cacheKey, pending);
1289
+ return await pending;
1290
+ }
1291
+ return await githubPreflightUncached(input);
1292
+ }
1293
+ async function githubPreflightUncached(input) {
1294
+ let response;
1295
+ const deadline = createGitHubPreflightDeadline(GITHUB_PREFLIGHT_TIMEOUT_MS);
1296
+ try {
1297
+ response = await (input.githubApply.fetchImpl ?? fetch)(`https://api.github.com/repos/${input.owner}/${input.repo}${input.path}`, {
1298
+ method: "GET",
1299
+ headers: {
1300
+ accept: "application/vnd.github+json",
1301
+ authorization: `Bearer ${input.githubApply.token}`,
1302
+ "x-github-api-version": "2022-11-28"
1303
+ },
1304
+ ...deadline.signal ? { signal: deadline.signal } : {}
1305
+ });
1306
+ } catch (error) {
1307
+ if (deadline.didTimeout()) {
1308
+ return {
1309
+ state: "needs_setup",
1310
+ setupReason: `GitHub preflight timed out for ${input.description} after ${GITHUB_PREFLIGHT_TIMEOUT_MS}ms.`
1311
+ };
1312
+ }
1313
+ return {
1314
+ state: "needs_setup",
1315
+ setupReason: `GitHub preflight failed for ${input.description}: ${error instanceof Error ? error.message : String(error)}.`
1316
+ };
1317
+ } finally {
1318
+ deadline.clear();
1319
+ }
1320
+ if (response.ok) return null;
1321
+ if (response.status === 401 || response.status === 403) {
1322
+ return {
1323
+ state: "needs_setup",
1324
+ setupReason: `GitHub apply token cannot access ${input.description}. Check repository permissions and token scopes.`
1325
+ };
1326
+ }
1327
+ if (response.status === 404) {
1328
+ return {
1329
+ state: "needs_setup",
1330
+ setupReason: input.notFoundReason
1331
+ };
1332
+ }
1333
+ return {
1334
+ state: "needs_setup",
1335
+ setupReason: `GitHub preflight failed for ${input.description}: HTTP ${response.status}.`
1336
+ };
1337
+ }
1338
+ async function preflightGitHubOperation(input) {
1339
+ const base = {
1340
+ githubApply: input.githubApply,
1341
+ owner: input.target.owner,
1342
+ repo: input.target.repoName,
1343
+ ...input.preflightCache ? { cache: input.preflightCache } : {}
1344
+ };
1345
+ if (input.operation.kind === "create_pull_request") {
1346
+ const head = encodeURIComponent(input.operation.head);
1347
+ const baseBranch = encodeURIComponent(input.operation.base);
1348
+ const [headPreflight, basePreflight] = await Promise.all([
1349
+ githubPreflight({
1350
+ ...base,
1351
+ path: `/branches/${head}`,
1352
+ description: `GitHub branch ${input.operation.head}`,
1353
+ notFoundReason: `GitHub branch ${input.operation.head} was not found.`
1354
+ }),
1355
+ githubPreflight({
1356
+ ...base,
1357
+ path: `/branches/${baseBranch}`,
1358
+ description: `GitHub base branch ${input.operation.base}`,
1359
+ notFoundReason: `GitHub base branch ${input.operation.base} was not found.`
1360
+ })
1361
+ ]);
1362
+ return headPreflight ?? basePreflight;
1363
+ }
1364
+ if (input.operation.kind === "request_review") {
1365
+ if (typeof input.target.pullRequestNumber !== "number") {
1366
+ return {
1367
+ state: "needs_setup",
1368
+ setupReason: "The source thread does not include a GitHub pull request target."
1369
+ };
1370
+ }
1371
+ return await githubPreflight({
1372
+ ...base,
1373
+ path: `/pulls/${input.target.pullRequestNumber}`,
1374
+ description: `GitHub pull request #${input.target.pullRequestNumber}`,
1375
+ notFoundReason: `GitHub pull request #${input.target.pullRequestNumber} was not found.`
1376
+ });
1377
+ }
1378
+ if (typeof input.target.issueNumber !== "number") {
1379
+ return {
1380
+ state: "needs_setup",
1381
+ setupReason: "The source thread does not include a GitHub issue or pull request target."
1382
+ };
1383
+ }
1384
+ return await githubPreflight({
1385
+ ...base,
1386
+ path: `/issues/${input.target.issueNumber}`,
1387
+ description: `GitHub issue or pull request #${input.target.issueNumber}`,
1388
+ notFoundReason: `GitHub issue or pull request #${input.target.issueNumber} was not found.`
1389
+ });
1390
+ }
1391
+ async function directApplyReceiptCapability(input) {
1392
+ const capability = capabilityForMutationIntent(input.intent);
1393
+ if (!capability) {
1394
+ return {
1395
+ state: "unsupported",
1396
+ setupReason: `No source-thread apply capability is registered for ${input.intent.action}.`
1397
+ };
1398
+ }
1399
+ if (capability.capabilityClass !== "external_write") {
1400
+ return {
1401
+ state: "unsupported",
1402
+ setupReason: "This action is audit-only for now; continue if a follow-up run should handle it."
1403
+ };
1404
+ }
1405
+ const adapter = adapterForAction({
1406
+ event: input.event,
1407
+ callbackProvider: input.callbackProvider,
1408
+ selectedIntents: [input.intent]
1409
+ });
1410
+ if (adapter !== "github") {
1411
+ return {
1412
+ state: "needs_setup",
1413
+ setupReason: `Direct apply for ${adapter} actions is not configured on this dispatcher.`
1414
+ };
1415
+ }
1416
+ if (!input.githubApply) {
1417
+ return {
1418
+ state: "needs_setup",
1419
+ setupReason: "GitHub apply is not configured on this dispatcher."
1420
+ };
1421
+ }
1422
+ if (!hasGitHubRepoTarget(input.event)) {
1423
+ return {
1424
+ state: "needs_setup",
1425
+ setupReason: "The source thread does not include a GitHub repository target."
1426
+ };
1427
+ }
1428
+ if (!isRepoLevelGitHubIntent(input.intent) && !hasGitHubIssueOrPullTarget(input.event)) {
1429
+ return {
1430
+ state: "needs_setup",
1431
+ setupReason: "The source thread does not include a GitHub issue or pull request target."
1432
+ };
1433
+ }
1434
+ if (!permissionScopesAllowCapability(input.event.permissions ?? [], capability)) {
1435
+ return {
1436
+ state: "needs_setup",
1437
+ setupReason: `Missing platform permission for ${capability.id}.`
1438
+ };
1439
+ }
1440
+ const missingExecutorConditions = (capability.requiredExecutorConditions ?? []).filter(
1441
+ (condition) => !executorConditionsFromIntent(input.intent).includes(condition)
1442
+ );
1443
+ if (missingExecutorConditions.length > 0) {
1444
+ return {
1445
+ state: "needs_setup",
1446
+ setupReason: `Missing executor condition: ${missingExecutorConditions.join(", ")}.`
1447
+ };
1448
+ }
1449
+ const githubTarget = githubTargetFromEvent(input.event);
1450
+ if (!githubTarget) {
1451
+ return {
1452
+ state: "needs_setup",
1453
+ setupReason: "The source thread does not include a GitHub repository target."
1454
+ };
1455
+ }
1456
+ const compilation = createGitHubIssueMutationCompiler({
1457
+ ...githubTarget?.targetKind ? { targetKind: githubTarget.targetKind } : {}
1458
+ }).compile(input.intent);
1459
+ if (!compilation.ok) {
1460
+ return {
1461
+ state: compilation.outcome.outcome === "unsupported" ? "unsupported" : "needs_setup",
1462
+ setupReason: compilation.outcome.message ?? "GitHub cannot apply this action from the current source thread."
1463
+ };
1464
+ }
1465
+ const preflight = await preflightGitHubOperation({
1466
+ githubApply: input.githubApply,
1467
+ target: githubTarget,
1468
+ operation: compilation.operation,
1469
+ ...input.preflightCache ? { preflightCache: input.preflightCache } : {}
1470
+ });
1471
+ if (preflight) return preflight;
1472
+ return { state: "ready_to_apply" };
1473
+ }
1474
+ async function actionReceiptContextForFinal(input) {
1475
+ const preflightCache = /* @__PURE__ */ new Map();
1476
+ const capabilityEntries = await Promise.all(
1477
+ (input.result.suggestedChanges ?? []).flatMap(
1478
+ (snapshot) => snapshot.intents.map(async (intent) => {
1479
+ const capability = await directApplyReceiptCapability({
1480
+ event: input.event,
1481
+ callbackProvider: input.event.callback.provider,
1482
+ intent,
1483
+ ...input.githubApply ? { githubApply: input.githubApply } : {},
1484
+ preflightCache
1485
+ });
1486
+ return [intent.intentId, capability];
1487
+ })
1488
+ )
1489
+ );
1490
+ return { capabilityByIntentId: Object.fromEntries(capabilityEntries) };
1491
+ }
710
1492
  async function authorizeThreadAction(input) {
711
1493
  const repoKey = projectTargetRefFromEvent2(input.resolved.proposal.event);
712
1494
  if (!repoKey) {
@@ -789,6 +1571,13 @@ function selectedIntentsAlreadyApplied(input) {
789
1571
  (intentId) => input.plan.outcomes?.some((outcome) => outcome.intentId === intentId && outcome.outcome === "applied")
790
1572
  );
791
1573
  }
1574
+ function selectedPlanOutcomes(input) {
1575
+ return (input.plan.outcomes ?? []).filter((outcome) => input.selectedIntentIds.includes(outcome.intentId));
1576
+ }
1577
+ function selectedIntentsHaveStaleOutcome(input) {
1578
+ const outcomes = selectedPlanOutcomes(input);
1579
+ return outcomes.some((outcome) => outcome.outcome === "stale") && outcomes.every((outcome) => outcome.outcome !== "applied");
1580
+ }
792
1581
  function githubTargetFromEvent(event) {
793
1582
  const owner = event.metadata["owner"];
794
1583
  const repoName = event.metadata["repo"];
@@ -807,30 +1596,124 @@ function githubTargetFromEvent(event) {
807
1596
  function selectedActionSummary(candidates) {
808
1597
  return candidates.map((candidate) => `${candidate.index}. ${candidate.intent.summary}`).join("; ");
809
1598
  }
810
- function childRunContextLines(input) {
811
- const previousSummary = input.resolved.proposal.run.result?.summary ?? input.resolved.proposal.snapshot.summary;
812
- return [
813
- `- Proposal: \`${input.resolved.proposal.snapshot.proposalId}\``,
814
- `- Selected intents: ${input.resolved.selectedIntentIds.map((intentId) => `\`${intentId}\``).join(", ")}`,
815
- `- Previous run: \`${input.resolved.proposal.runId}\``,
816
- ...input.approvalDecisionId ? [`- Approval decision: \`${input.approvalDecisionId}\``] : [],
817
- `- Previous result: ${previousSummary}`,
818
- ...input.sourceApplyPlanId ? [`- Apply plan: \`${input.sourceApplyPlanId}\``] : [],
819
- ...input.fallbackReason ? [`- Fallback reason: ${input.fallbackReason}`] : []
820
- ];
1599
+ function selectedActionReceiptTitle(selectionText) {
1600
+ return selectionText.split(";").map((part) => part.trim().replace(/^\d+\.\s*/, "")).filter(Boolean).join("; ");
1601
+ }
1602
+ function sentenceWithTerminalPunctuation(value) {
1603
+ return /[.!?。!?]$/u.test(value) ? value : `${value}.`;
1604
+ }
1605
+ function addPermissionGrant(permissions, grant) {
1606
+ if (permissions.some((permission) => permission.scope === grant.scope)) return permissions;
1607
+ return [...permissions, grant];
1608
+ }
1609
+ function childRunPermissionsForThreadAction(input) {
1610
+ let permissions = [...input.resolved.proposal.event.permissions ?? []];
1611
+ if (input.command.verb === "apply" || input.command.verb === "continue") {
1612
+ permissions = addPermissionGrant(permissions, {
1613
+ scope: "repo:read",
1614
+ reason: "inspect the repository while continuing an approved source-thread action"
1615
+ });
1616
+ permissions = addPermissionGrant(permissions, {
1617
+ scope: "repo:write",
1618
+ reason: "apply an approved source-thread mutation on a run branch"
1619
+ });
1620
+ }
1621
+ if (input.resolved.selectedCandidates.some((candidate) => candidate.intent.action === "create_pull_request")) {
1622
+ permissions = addPermissionGrant(permissions, {
1623
+ scope: "pr:create",
1624
+ reason: "create the pull request approved in the source thread"
1625
+ });
1626
+ }
1627
+ return permissions;
821
1628
  }
822
1629
  function renderChildRunCreatedBody(input) {
1630
+ const title = selectedActionReceiptTitle(input.selectionText ?? selectedActionSummary(input.resolved.selectedCandidates));
1631
+ if (input.provider === "slack") {
1632
+ return [
1633
+ input.lead,
1634
+ `Action: ${title}`,
1635
+ ...input.fallbackReason ? [`Reason: ${input.fallbackReason}`] : []
1636
+ ].join("\n");
1637
+ }
823
1638
  return [
824
1639
  input.lead,
825
1640
  "",
826
- `Child run: \`${input.childRun.id}\``,
1641
+ `Action: ${title}`,
827
1642
  "",
828
- "Context carried into the child run:",
829
- ...childRunContextLines(input),
1643
+ `Child run: \`${input.childRun.id}\``,
830
1644
  "",
831
- "The model will continue from this approved proposal instead of starting from a fresh mention."
1645
+ ...input.fallbackReason ? [`Reason: ${input.fallbackReason}`, ""] : [],
1646
+ `Audit: run \`opentag status --run ${input.childRun.id}\` locally.`
832
1647
  ].join("\n");
833
1648
  }
1649
+ function applyOutcomeSummary(outcome) {
1650
+ if (outcome.externalUri) return `${outcome.outcome}: ${outcome.externalUri}`;
1651
+ if (outcome.message) return `${outcome.outcome}: ${outcome.message}`;
1652
+ return `${outcome.outcome}.`;
1653
+ }
1654
+ function applyOutcomeReceiptLines(outcomes) {
1655
+ if (outcomes.length === 0) return ["Result: applied."];
1656
+ if (outcomes.length === 1) {
1657
+ const outcome = outcomes[0];
1658
+ if (outcome.externalUri) return [`Result: ${outcome.externalUri}`];
1659
+ if (outcome.message) return [`Result: ${outcome.outcome}. ${outcome.message}`];
1660
+ return [`Result: ${outcome.outcome}.`];
1661
+ }
1662
+ return ["Results:", ...outcomes.map((outcome) => `- ${applyOutcomeSummary(outcome)}`)];
1663
+ }
1664
+ function renderAppliedThreadActionBody(input) {
1665
+ const selectedOutcomes = input.outcomes.filter((outcome) => input.selectedIntentIds.includes(outcome.intentId));
1666
+ return [`Applied: ${sentenceWithTerminalPunctuation(selectedActionReceiptTitle(input.selectionText))}`, ...applyOutcomeReceiptLines(selectedOutcomes)].join("\n");
1667
+ }
1668
+ function renderAlreadyAppliedThreadActionBody(input) {
1669
+ return [`Already applied: ${sentenceWithTerminalPunctuation(selectedActionReceiptTitle(input.selectionText))}`, "No external write was repeated."].join("\n");
1670
+ }
1671
+ function renderAlreadyPlannedThreadActionBody(input) {
1672
+ return [`Already planned: ${sentenceWithTerminalPunctuation(selectedActionReceiptTitle(input.selectionText))}`, "OpenTag did not execute this repeated reply."].join("\n");
1673
+ }
1674
+ function renderStaleThreadActionBody(input) {
1675
+ return [
1676
+ `Stale: ${sentenceWithTerminalPunctuation(selectedActionReceiptTitle(input.selectionText))}`,
1677
+ "The target changed since this action was proposed.",
1678
+ `Reply \`continue ${input.continueIndex}\` to refresh from the current thread state.`
1679
+ ].join("\n");
1680
+ }
1681
+ function renderThreadActionRecordedBody(input) {
1682
+ const title = selectedActionReceiptTitle(input.selectionText);
1683
+ if (input.verb === "approve") {
1684
+ const index = input.applyIndex ?? 1;
1685
+ const nextLines = input.directApply?.ready ? [`Next: reply \`apply ${index}\` to write it to the system of record, or \`continue ${index}\` to continue in OpenTag.`] : [
1686
+ ...input.directApply?.reason ? [`Direct apply is not available yet: ${sentenceWithTerminalPunctuation(input.directApply.reason)}`] : ["Direct apply is not available yet."],
1687
+ `Next: reply \`continue ${index}\` to continue in OpenTag.`
1688
+ ];
1689
+ return [
1690
+ `Approved only: ${sentenceWithTerminalPunctuation(title)}`,
1691
+ "No external write was performed.",
1692
+ ...nextLines
1693
+ ].join("\n");
1694
+ }
1695
+ return [`Rejected: ${sentenceWithTerminalPunctuation(title)}`, "No external write will be performed for this action."].join("\n");
1696
+ }
1697
+ async function selectedDirectApplyStatus(input) {
1698
+ if (input.candidates.length === 0) return { ready: false, reason: "No selected action was found." };
1699
+ const preflightCache = /* @__PURE__ */ new Map();
1700
+ for (const candidate of input.candidates) {
1701
+ const capability = await directApplyReceiptCapability({
1702
+ event: input.event,
1703
+ callbackProvider: input.callbackProvider,
1704
+ intent: candidate.intent,
1705
+ ...input.githubApply ? { githubApply: input.githubApply } : {},
1706
+ preflightCache
1707
+ });
1708
+ if (capability.state !== "ready_to_apply") {
1709
+ return {
1710
+ ready: false,
1711
+ reason: capability.setupReason ?? `Receipt state is ${capability.state}.`
1712
+ };
1713
+ }
1714
+ }
1715
+ return { ready: true };
1716
+ }
834
1717
  function actionContextPointer(input) {
835
1718
  const lines = [
836
1719
  "OpenTag thread action continuation.",
@@ -895,7 +1778,8 @@ async function createChildRunForThreadAction(input) {
895
1778
  ...input.approvalDecisionId ? { approvalDecisionId: input.approvalDecisionId } : {},
896
1779
  ...input.sourceApplyPlanId ? { sourceApplyPlanId: input.sourceApplyPlanId } : {},
897
1780
  ...input.fallbackReason ? { fallbackReason: input.fallbackReason } : {}
898
- }
1781
+ },
1782
+ permissions: childRunPermissionsForThreadAction({ resolved: input.resolved, command: input.command })
899
1783
  }),
900
1784
  parentRunId: input.resolved.proposal.runId,
901
1785
  triggeredByAction: action,
@@ -972,6 +1856,11 @@ var noopCallbackSink = {
972
1856
  return;
973
1857
  }
974
1858
  };
1859
+ var noopSourceReceiptSink = {
1860
+ async deliver() {
1861
+ return { delivered: false };
1862
+ }
1863
+ };
975
1864
  function nextCallbackAttemptAt(input) {
976
1865
  const maxAttempts = input.maxAttempts ?? 5;
977
1866
  const nextAttempt = input.attempts + 1;
@@ -983,7 +1872,13 @@ function nextCallbackAttemptAt(input) {
983
1872
  }
984
1873
  async function deliverCallbackDelivery(input) {
985
1874
  try {
986
- await input.sink.deliver({
1875
+ const externalMessageId = input.delivery.externalMessageId ?? (input.delivery.statusMessageKey ? await input.repo.findCallbackExternalMessageId({
1876
+ runId: input.delivery.runId,
1877
+ provider: input.delivery.provider,
1878
+ ...input.delivery.threadKey ? { threadKey: input.delivery.threadKey } : {},
1879
+ statusMessageKey: input.delivery.statusMessageKey
1880
+ }) : void 0);
1881
+ const deliveryResult = await input.sink.deliver({
987
1882
  runId: input.delivery.runId,
988
1883
  kind: input.delivery.kind,
989
1884
  provider: input.delivery.provider,
@@ -992,15 +1887,23 @@ async function deliverCallbackDelivery(input) {
992
1887
  ...input.delivery.threadKey ? { threadKey: input.delivery.threadKey } : {},
993
1888
  ...input.delivery.agentId ? { agentId: input.delivery.agentId } : {},
994
1889
  ...input.delivery.statusMessageKey ? { statusMessageKey: input.delivery.statusMessageKey } : {},
995
- ...input.delivery.blocks ? { blocks: input.delivery.blocks } : {}
1890
+ ...externalMessageId ? { externalMessageId } : {},
1891
+ ...input.delivery.blocks ? { blocks: input.delivery.blocks } : {},
1892
+ ...input.delivery.rich ? { rich: input.delivery.rich } : {}
1893
+ });
1894
+ const deliveredExternalMessageId = deliveryResult?.externalMessageId ?? externalMessageId;
1895
+ await input.repo.markCallbackDelivered({
1896
+ deliveryId: input.delivery.id,
1897
+ ...deliveredExternalMessageId ? { externalMessageId: deliveredExternalMessageId } : {}
996
1898
  });
997
- await input.repo.markCallbackDelivered({ deliveryId: input.delivery.id });
998
1899
  return true;
999
1900
  } catch (error) {
1901
+ const maxAttempts = input.retry?.maxAttempts ?? 5;
1000
1902
  const nextAttemptAt = nextCallbackAttemptAt({ attempts: input.delivery.attempts, ...input.retry ?? {} });
1001
1903
  await input.repo.markCallbackFailed({
1002
1904
  deliveryId: input.delivery.id,
1003
1905
  error: error instanceof Error ? error.message : String(error),
1906
+ maxAttempts,
1004
1907
  ...nextAttemptAt ? { nextAttemptAt } : {}
1005
1908
  });
1006
1909
  return false;
@@ -1040,7 +1943,8 @@ async function deliverAndAudit(input) {
1040
1943
  ...input.message.threadKey ? { threadKey: input.message.threadKey } : {},
1041
1944
  ...input.message.agentId ? { agentId: input.message.agentId } : {},
1042
1945
  ...input.message.statusMessageKey ? { statusMessageKey: input.message.statusMessageKey } : {},
1043
- ...input.message.blocks ? { blocks: input.message.blocks } : {}
1946
+ ...input.message.blocks ? { blocks: input.message.blocks } : {},
1947
+ ...input.message.rich ? { rich: input.message.rich } : {}
1044
1948
  });
1045
1949
  await deliverCallbackDelivery({
1046
1950
  repo: input.repo,
@@ -1049,9 +1953,113 @@ async function deliverAndAudit(input) {
1049
1953
  ...input.retry ? { retry: input.retry } : {}
1050
1954
  });
1051
1955
  }
1052
- function isAuthorized(request, pairingToken) {
1053
- if (!pairingToken) return true;
1054
- return request.headers.get("authorization") === `Bearer ${pairingToken}`;
1956
+ async function deliverSourceReceiptBestEffort(input) {
1957
+ try {
1958
+ const result = await input.sink.deliver(input.receipt);
1959
+ if (!result.delivered) return result;
1960
+ await input.repo.appendRunEvent({
1961
+ runId: input.receipt.runId,
1962
+ type: "source_receipt.delivered",
1963
+ payload: {
1964
+ provider: input.receipt.provider,
1965
+ state: input.receipt.state
1966
+ },
1967
+ visibility: "audit",
1968
+ importance: "low",
1969
+ message: `Source ${input.receipt.state} receipt delivered.`
1970
+ });
1971
+ return result;
1972
+ } catch (error) {
1973
+ await input.repo.appendRunEvent({
1974
+ runId: input.receipt.runId,
1975
+ type: "source_receipt.failed",
1976
+ payload: {
1977
+ provider: input.receipt.provider,
1978
+ state: input.receipt.state,
1979
+ error: error instanceof Error ? error.message : String(error)
1980
+ },
1981
+ visibility: "audit",
1982
+ importance: "low",
1983
+ message: `Source ${input.receipt.state} receipt failed.`
1984
+ });
1985
+ return { delivered: false };
1986
+ }
1987
+ }
1988
+ function isRunnerRuntimeEndpoint(method, path) {
1989
+ if (method !== "POST") return false;
1990
+ if (/^\/v1\/runners\/[^/]+\/claim$/.test(path)) return true;
1991
+ if (/^\/v1\/runners\/[^/]+\/runs\/[^/]+\/(running|heartbeat|progress|complete)$/.test(path)) return true;
1992
+ return /^\/v1\/runs\/[^/]+\/(running|progress|complete)$/.test(path);
1993
+ }
1994
+ function isRunnerOperatorEndpoint(method, path) {
1995
+ if (method === "GET") {
1996
+ if (path === "/v1/control-plane-alerts") return true;
1997
+ if (/^\/v1\/runners\/[^/]+$/.test(path)) return true;
1998
+ if (/^\/v1\/repo-bindings\/[^/]+\/[^/]+\/[^/]+$/.test(path)) return true;
1999
+ if (/^\/v1\/channel-bindings\/[^/]+\/[^/]+\/[^/]+(?:\/status)?$/.test(path)) return true;
2000
+ return /^\/v1\/runs\/[^/]+(?:\/events|\/metrics)?$/.test(path);
2001
+ }
2002
+ if (method !== "POST") return false;
2003
+ if (path === "/v1/source-deliveries/prune") return true;
2004
+ if (/^\/v1\/runs\/[^/]+\/cancel$/.test(path)) return true;
2005
+ return /^\/v1\/channel-bindings\/[^/]+\/[^/]+\/[^/]+\/cancel-active-run$/.test(path);
2006
+ }
2007
+ function dispatcherAuthScope(request) {
2008
+ const path = new URL(request.url).pathname;
2009
+ const method = request.method.toUpperCase();
2010
+ if (isRunnerRuntimeEndpoint(method, path)) return "runner_runtime";
2011
+ if (isRunnerOperatorEndpoint(method, path)) return "runner_operator";
2012
+ return "pairing";
2013
+ }
2014
+ function authMatches(request, token) {
2015
+ return Boolean(token) && request.headers.get("authorization") === `Bearer ${token}`;
2016
+ }
2017
+ function bearerToken(request) {
2018
+ const authorization = request.headers.get("authorization");
2019
+ if (!authorization?.startsWith("Bearer ")) return void 0;
2020
+ return authorization.slice("Bearer ".length);
2021
+ }
2022
+ function configuredRunnerTokens(input) {
2023
+ return [...new Set([input.runnerToken, ...input.runnerTokens ?? []].map((token) => token?.trim()).filter((token) => Boolean(token)))];
2024
+ }
2025
+ function normalizeRevokedRunnerTokenFingerprints(fingerprints) {
2026
+ return new Set((fingerprints ?? []).map((fingerprint) => fingerprint.trim().toLowerCase()).filter(Boolean));
2027
+ }
2028
+ function authMatchesAny(request, tokens) {
2029
+ return tokens.some((token) => authMatches(request, token));
2030
+ }
2031
+ function requestUsesRevokedRunnerToken(input) {
2032
+ const token = bearerToken(input.request);
2033
+ if (!token) return false;
2034
+ return input.revokedRunnerTokenFingerprints.has(rawTokenFingerprint(token).toLowerCase());
2035
+ }
2036
+ function revokedRunnerTokenResult() {
2037
+ return {
2038
+ ok: false,
2039
+ reason: "runner_token_revoked",
2040
+ message: "Runner token has been revoked or expired. Pair again or update daemon.runnerToken before retrying."
2041
+ };
2042
+ }
2043
+ function authorizeDispatcherRequest(input) {
2044
+ if (!input.pairingToken && input.runnerTokens.length === 0) return { ok: true };
2045
+ const revokedRunnerToken = requestUsesRevokedRunnerToken({
2046
+ request: input.request,
2047
+ revokedRunnerTokenFingerprints: input.revokedRunnerTokenFingerprints
2048
+ });
2049
+ if (revokedRunnerToken) return revokedRunnerTokenResult();
2050
+ const scope = dispatcherAuthScope(input.request);
2051
+ const pairingMatches = authMatches(input.request, input.pairingToken);
2052
+ const runnerMatches = authMatchesAny(input.request, input.runnerTokens);
2053
+ if (scope === "pairing") {
2054
+ return pairingMatches ? { ok: true } : { ok: false, reason: "invalid_pairing_token" };
2055
+ }
2056
+ if (scope === "runner_runtime") {
2057
+ if (input.runnerTokens.length > 0) {
2058
+ return runnerMatches ? { ok: true } : { ok: false, reason: "invalid_runner_token" };
2059
+ }
2060
+ return pairingMatches ? { ok: true } : { ok: false, reason: "invalid_pairing_token" };
2061
+ }
2062
+ return runnerMatches || pairingMatches ? { ok: true } : { ok: false, reason: "invalid_dispatcher_token" };
1055
2063
  }
1056
2064
  function createDispatcherApp(input) {
1057
2065
  const sqlite = new Database(input.databasePath);
@@ -1059,22 +2067,237 @@ function createDispatcherApp(input) {
1059
2067
  const repo = createOpenTagRepository(drizzle(sqlite));
1060
2068
  const app = new Hono();
1061
2069
  const callbackSink = input.callbackSink ?? noopCallbackSink;
2070
+ const sourceReceiptSink = input.sourceReceiptSink ?? noopSourceReceiptSink;
1062
2071
  const presentation = input.presentation ?? createDefaultCallbackPresentation();
1063
2072
  const callbackRetry = input.callbackRetry ?? {};
2073
+ const maxRequestBodyBytes = input.maxRequestBodyBytes ?? DEFAULT_MAX_REQUEST_BODY_BYTES;
2074
+ const runnerTokens = configuredRunnerTokens(input);
2075
+ const revokedRunnerTokenFingerprints = normalizeRevokedRunnerTokenFingerprints(input.revokedRunnerTokenFingerprints);
2076
+ const requestEndpoint = (c) => normalizeRateLimitedEndpoint(c.req.method, new URL(c.req.url).pathname);
2077
+ const recordControlPlaneEvent = async (input2) => {
2078
+ await repo.appendControlPlaneEvent({
2079
+ type: input2.type,
2080
+ ...input2.severity ? { severity: input2.severity } : {},
2081
+ ...input2.subject ? { subject: input2.subject } : {},
2082
+ ...input2.payload ? { payload: input2.payload } : {},
2083
+ ...input2.createdAt ? { createdAt: input2.createdAt } : {}
2084
+ });
2085
+ };
2086
+ const parseDispatcherBody = async (c, schema, options = {}) => {
2087
+ try {
2088
+ return await parseBody(c, schema, { maxBytes: maxRequestBodyBytes, ...options });
2089
+ } catch (err) {
2090
+ if (err instanceof HTTPException && err.status === 413) {
2091
+ await recordControlPlaneEvent({
2092
+ type: "security.request_body_rejected",
2093
+ severity: "warn",
2094
+ subject: requestEndpoint(c),
2095
+ payload: {
2096
+ reason: "request_body_too_large",
2097
+ endpoint: requestEndpoint(c),
2098
+ maxBytes: maxRequestBodyBytes,
2099
+ contentLength: c.req.raw.headers.get("content-length") ?? null
2100
+ }
2101
+ });
2102
+ }
2103
+ if (err instanceof HTTPException && err.status === 400 && err.cause instanceof RequestBodyRejectedError) {
2104
+ await recordControlPlaneEvent({
2105
+ type: "security.request_body_rejected",
2106
+ severity: "warn",
2107
+ subject: requestEndpoint(c),
2108
+ payload: {
2109
+ reason: err.cause.reason,
2110
+ error: err.cause.publicError,
2111
+ endpoint: requestEndpoint(c),
2112
+ contentLength: c.req.raw.headers.get("content-length") ?? null
2113
+ }
2114
+ });
2115
+ }
2116
+ throw err;
2117
+ }
2118
+ };
2119
+ const appendSuppressedRunStatusCallback = async (input2) => {
2120
+ const capability = platformCapabilityForProvider2(input2.provider);
2121
+ await repo.appendRunEvent({
2122
+ runId: input2.runId,
2123
+ type: "callback.progress.suppressed",
2124
+ payload: {
2125
+ provider: input2.provider,
2126
+ reason: "platform_liveness_strategy",
2127
+ requestedStatus: input2.state,
2128
+ livenessStrategy: capability?.livenessStrategy ?? "unknown"
2129
+ },
2130
+ visibility: "audit",
2131
+ importance: "low",
2132
+ message: "Run status callback suppressed by platform liveness strategy; use status or audit for details."
2133
+ });
2134
+ };
2135
+ async function deliverPromotedFollowUpAcknowledgement(input2) {
2136
+ if (!presentation.shouldDeliverAcknowledgement(input2.event.callback.provider)) return;
2137
+ const acknowledgementPresentation = presentation.acknowledgementPresentation({ runId: input2.run.id });
2138
+ const acknowledgement = presentation.render({
2139
+ provider: input2.event.callback.provider,
2140
+ presentation: acknowledgementPresentation
2141
+ });
2142
+ const statusMessageKey = larkLifecycleStatusMessageKey({ provider: input2.event.callback.provider, runId: input2.run.id });
2143
+ await deliverAndAudit({
2144
+ repo,
2145
+ sink: callbackSink,
2146
+ retry: callbackRetry,
2147
+ message: {
2148
+ runId: input2.run.id,
2149
+ kind: "acknowledgement",
2150
+ provider: input2.event.callback.provider,
2151
+ uri: input2.event.callback.uri,
2152
+ body: acknowledgement.body,
2153
+ ...input2.event.target.agentId ? { agentId: input2.event.target.agentId } : {},
2154
+ ...input2.event.callback.threadKey ? { threadKey: input2.event.callback.threadKey } : {},
2155
+ ...statusMessageKey ? { statusMessageKey } : {},
2156
+ ...acknowledgement.blocks?.length ? { blocks: acknowledgement.blocks } : {},
2157
+ ...acknowledgement.rich ? { rich: acknowledgement.rich } : {}
2158
+ }
2159
+ });
2160
+ }
2161
+ async function promoteFollowUpRequest(input2) {
2162
+ const promoted = await repo.createRunFromFollowUpRequest(input2);
2163
+ await deliverPromotedFollowUpAcknowledgement({
2164
+ run: promoted.run,
2165
+ event: promoted.followUpRequest.event
2166
+ });
2167
+ return promoted;
2168
+ }
2169
+ async function promoteNextFollowUpAfterTerminalRun(input2) {
2170
+ const [next] = await repo.listQueuedFollowUpsForActiveRun({ activeRunId: input2.activeRunId });
2171
+ if (!next) return null;
2172
+ try {
2173
+ const promoted = await promoteFollowUpRequest({
2174
+ followUpRequestId: next.id,
2175
+ runId: `run_${randomUUID()}`
2176
+ });
2177
+ await repo.appendRunEvent({
2178
+ runId: input2.activeRunId,
2179
+ type: "follow_up_request.auto_promoted",
2180
+ payload: {
2181
+ followUpRequestId: promoted.followUpRequest.id,
2182
+ createdRunId: promoted.run.id
2183
+ },
2184
+ visibility: "audit",
2185
+ importance: "normal",
2186
+ message: `Promoted queued follow-up ${promoted.followUpRequest.id} into run ${promoted.run.id}.`
2187
+ });
2188
+ return promoted;
2189
+ } catch (error) {
2190
+ await repo.appendRunEvent({
2191
+ runId: input2.activeRunId,
2192
+ type: "follow_up_request.auto_promote_failed",
2193
+ payload: {
2194
+ followUpRequestId: next.id,
2195
+ error: error instanceof Error ? error.message : String(error)
2196
+ },
2197
+ visibility: "audit",
2198
+ importance: "high",
2199
+ message: `Could not auto-promote queued follow-up ${next.id}.`
2200
+ });
2201
+ return null;
2202
+ }
2203
+ }
1064
2204
  const admission = createAdmissionRuntime({
1065
2205
  repo,
1066
2206
  ...input.agentAccessProfileCheck ? { agentAccessProfileCheck: input.agentAccessProfileCheck } : {}
1067
2207
  });
1068
2208
  app.get("/healthz", (c) => c.json({ ok: true }));
2209
+ if (input.rateLimit) {
2210
+ app.use("/v1/*", createDispatcherRateLimitMiddleware(input.rateLimit));
2211
+ }
1069
2212
  app.use("/v1/*", async (c, next) => {
1070
- if (!isAuthorized(c.req.raw, input.pairingToken)) {
1071
- return c.json({ error: "unauthorized" }, 401);
2213
+ const authorization = authorizeDispatcherRequest({
2214
+ request: c.req.raw,
2215
+ pairingToken: input.pairingToken,
2216
+ runnerTokens,
2217
+ revokedRunnerTokenFingerprints
2218
+ });
2219
+ if (!authorization.ok) {
2220
+ await recordControlPlaneEvent({
2221
+ type: "security.auth_failed",
2222
+ severity: "warn",
2223
+ subject: requestEndpoint(c),
2224
+ payload: {
2225
+ reason: authorization.reason,
2226
+ endpoint: requestEndpoint(c),
2227
+ hasAuthorization: Boolean(c.req.raw.headers.get("authorization")),
2228
+ tokenFingerprint: rateLimitTokenFingerprint(c.req.raw.headers.get("authorization"))
2229
+ }
2230
+ });
2231
+ return c.json(
2232
+ {
2233
+ error: "unauthorized",
2234
+ reason: authorization.reason,
2235
+ ...authorization.message ? { message: authorization.message } : {}
2236
+ },
2237
+ 401
2238
+ );
1072
2239
  }
1073
2240
  await next();
1074
2241
  });
2242
+ app.get("/v1/control-plane-events", async (c) => {
2243
+ const limitValue = Number(c.req.query("limit") ?? 100);
2244
+ const limit = Number.isFinite(limitValue) ? Math.max(1, Math.min(500, Math.floor(limitValue))) : 100;
2245
+ const eventQuery = { limit };
2246
+ const type = c.req.query("type");
2247
+ const severity = c.req.query("severity");
2248
+ if (type) eventQuery.type = type;
2249
+ if (severity === "info" || severity === "warn" || severity === "error") eventQuery.severity = severity;
2250
+ const events = await repo.listControlPlaneEvents(eventQuery);
2251
+ return c.json({ events });
2252
+ });
2253
+ app.post("/v1/control-plane-events", async (c) => {
2254
+ const parsed = await parseDispatcherBody(c, RecordControlPlaneEventSchema);
2255
+ await recordControlPlaneEvent(parsed);
2256
+ return c.json({ ok: true }, 201);
2257
+ });
2258
+ app.get("/v1/control-plane-alerts", async (c) => {
2259
+ const limitValue = Number(c.req.query("limit") ?? 5e3);
2260
+ const limit = Number.isFinite(limitValue) ? Math.max(1, Math.min(1e4, Math.floor(limitValue))) : 5e3;
2261
+ const since = c.req.query("since");
2262
+ const alerts = await repo.summarizeControlPlaneAlerts({
2263
+ limit,
2264
+ ...since ? { since } : {}
2265
+ });
2266
+ return c.json({ alerts });
2267
+ });
2268
+ app.post("/v1/source-deliveries/prune", async (c) => {
2269
+ const parsed = await parseDispatcherBody(c, PruneSourceDeliveriesSchema);
2270
+ const pruneInput = {
2271
+ olderThan: parsed.olderThan,
2272
+ ...parsed.limit !== void 0 ? { limit: parsed.limit } : {}
2273
+ };
2274
+ const result = await repo.pruneSourceDeliveries(pruneInput);
2275
+ await recordControlPlaneEvent({
2276
+ type: "maintenance.source_deliveries_pruned",
2277
+ severity: "info",
2278
+ subject: "source_deliveries",
2279
+ payload: {
2280
+ olderThan: parsed.olderThan,
2281
+ limit: parsed.limit ?? null,
2282
+ scanned: result.scanned,
2283
+ pruned: result.pruned,
2284
+ retainedActive: result.retainedActive
2285
+ }
2286
+ });
2287
+ return c.json({ result });
2288
+ });
1075
2289
  app.post("/v1/runners", async (c) => {
1076
- const parsed = CreateRunnerSchema.parse(await c.req.json());
2290
+ const parsed = await parseDispatcherBody(c, CreateRunnerSchema);
1077
2291
  await repo.registerRunner(parsed);
2292
+ await recordControlPlaneEvent({
2293
+ type: "runner.registered",
2294
+ severity: "info",
2295
+ subject: parsed.runnerId,
2296
+ payload: {
2297
+ runnerId: parsed.runnerId,
2298
+ name: parsed.name
2299
+ }
2300
+ });
1078
2301
  return c.json({ ok: true }, 201);
1079
2302
  });
1080
2303
  app.get("/v1/runners/:runnerId", async (c) => {
@@ -1083,7 +2306,7 @@ function createDispatcherApp(input) {
1083
2306
  return c.json({ runner });
1084
2307
  });
1085
2308
  app.post("/v1/repo-bindings", async (c) => {
1086
- const parsed = CreateRepoBindingSchema.parse(await c.req.json());
2309
+ const parsed = await parseDispatcherBody(c, CreateRepoBindingSchema);
1087
2310
  await repo.createRepoBinding({
1088
2311
  provider: parsed.provider,
1089
2312
  owner: parsed.owner,
@@ -1093,6 +2316,20 @@ function createDispatcherApp(input) {
1093
2316
  ...parsed.defaultExecutor ? { defaultExecutor: parsed.defaultExecutor } : {},
1094
2317
  ...parsed.allowedActors?.length ? { allowedActors: parsed.allowedActors } : {}
1095
2318
  });
2319
+ await recordControlPlaneEvent({
2320
+ type: "binding.repository.upserted",
2321
+ severity: "info",
2322
+ subject: `${parsed.provider}:${parsed.owner}/${parsed.repo}`,
2323
+ payload: {
2324
+ provider: parsed.provider,
2325
+ owner: parsed.owner,
2326
+ repo: parsed.repo,
2327
+ runnerId: parsed.runnerId,
2328
+ hasWorkspacePath: Boolean(parsed.workspacePath),
2329
+ ...parsed.defaultExecutor ? { defaultExecutor: parsed.defaultExecutor } : {},
2330
+ allowedActorsCount: parsed.allowedActors?.length ?? 0
2331
+ }
2332
+ });
1096
2333
  return c.json({ ok: true }, 201);
1097
2334
  });
1098
2335
  app.get("/v1/repo-bindings/:provider/:owner/:repo", async (c) => {
@@ -1105,13 +2342,32 @@ function createDispatcherApp(input) {
1105
2342
  return c.json({ binding });
1106
2343
  });
1107
2344
  app.post("/v1/repo-bindings/:provider/:owner/:repo/policy-rules", async (c) => {
1108
- const parsed = UpsertPolicyRuleSchema.parse(await c.req.json());
2345
+ const parsed = await parseDispatcherBody(c, UpsertPolicyRuleSchema);
2346
+ const provider = c.req.param("provider");
2347
+ const owner = c.req.param("owner");
2348
+ const repoName = c.req.param("repo");
1109
2349
  const rule = await repo.upsertRepoPolicyRule({
1110
- provider: c.req.param("provider"),
1111
- owner: c.req.param("owner"),
1112
- repo: c.req.param("repo"),
2350
+ provider,
2351
+ owner,
2352
+ repo: repoName,
1113
2353
  rule: parsed.rule
1114
2354
  });
2355
+ await recordControlPlaneEvent({
2356
+ type: "binding.repository.policy_rule.upserted",
2357
+ severity: "info",
2358
+ subject: `${provider}:${owner}/${repoName}:${rule.id}`,
2359
+ payload: {
2360
+ provider,
2361
+ owner,
2362
+ repo: repoName,
2363
+ ruleId: rule.id,
2364
+ scope: rule.scope,
2365
+ effect: rule.effect,
2366
+ ...rule.capabilityId ? { capabilityId: rule.capabilityId } : {},
2367
+ ...rule.mutationDomain ? { mutationDomain: rule.mutationDomain } : {},
2368
+ hasReason: Boolean(rule.reason)
2369
+ }
2370
+ });
1115
2371
  return c.json({ rule }, 201);
1116
2372
  });
1117
2373
  app.get("/v1/repo-bindings/:provider/:owner/:repo/policy-rules", async (c) => {
@@ -1123,13 +2379,32 @@ function createDispatcherApp(input) {
1123
2379
  return c.json({ rules });
1124
2380
  });
1125
2381
  app.post("/v1/repo-bindings/:provider/:owner/:repo/mutation-mappings", async (c) => {
1126
- const parsed = UpsertMutationMappingSchema.parse(await c.req.json());
2382
+ const parsed = await parseDispatcherBody(c, UpsertMutationMappingSchema);
2383
+ const provider = c.req.param("provider");
2384
+ const owner = c.req.param("owner");
2385
+ const repoName = c.req.param("repo");
1127
2386
  const mapping = await repo.upsertRepoMutationMapping({
1128
- provider: c.req.param("provider"),
1129
- owner: c.req.param("owner"),
1130
- repo: c.req.param("repo"),
2387
+ provider,
2388
+ owner,
2389
+ repo: repoName,
1131
2390
  mapping: parsed.mapping
1132
2391
  });
2392
+ await recordControlPlaneEvent({
2393
+ type: "binding.repository.mutation_mapping.upserted",
2394
+ severity: "info",
2395
+ subject: `${provider}:${owner}/${repoName}:${mapping.id}`,
2396
+ payload: {
2397
+ provider,
2398
+ owner,
2399
+ repo: repoName,
2400
+ mappingId: mapping.id,
2401
+ adapter: mapping.adapter,
2402
+ domain: mapping.domain,
2403
+ strategy: mapping.strategy,
2404
+ valueCount: Object.keys(mapping.values).length,
2405
+ hasDescription: Boolean(mapping.description)
2406
+ }
2407
+ });
1133
2408
  return c.json({ mapping }, 201);
1134
2409
  });
1135
2410
  app.get("/v1/repo-bindings/:provider/:owner/:repo/mutation-mappings", async (c) => {
@@ -1155,7 +2430,7 @@ function createDispatcherApp(input) {
1155
2430
  return c.json({ metrics });
1156
2431
  });
1157
2432
  app.post("/v1/channel-bindings", async (c) => {
1158
- const parsed = CreateChannelBindingSchema.parse(await c.req.json());
2433
+ const parsed = await parseDispatcherBody(c, CreateChannelBindingSchema);
1159
2434
  await repo.upsertChannelBinding({
1160
2435
  provider: parsed.provider,
1161
2436
  accountId: parsed.accountId,
@@ -1165,6 +2440,20 @@ function createDispatcherApp(input) {
1165
2440
  repo: parsed.repo,
1166
2441
  ...parsed.metadata ? { metadata: parsed.metadata } : {}
1167
2442
  });
2443
+ await recordControlPlaneEvent({
2444
+ type: "binding.channel.upserted",
2445
+ severity: "info",
2446
+ subject: `${parsed.provider}:${parsed.accountId}/${parsed.conversationId}`,
2447
+ payload: {
2448
+ provider: parsed.provider,
2449
+ accountId: parsed.accountId,
2450
+ conversationId: parsed.conversationId,
2451
+ repoProvider: parsed.repoProvider,
2452
+ owner: parsed.owner,
2453
+ repo: parsed.repo,
2454
+ hasMetadata: Boolean(parsed.metadata)
2455
+ }
2456
+ });
1168
2457
  return c.json({ ok: true }, 201);
1169
2458
  });
1170
2459
  app.get("/v1/channel-bindings/:provider/:accountId/:conversationId", async (c) => {
@@ -1176,9 +2465,94 @@ function createDispatcherApp(input) {
1176
2465
  if (!binding) return c.json({ error: "channel_binding_not_found" }, 404);
1177
2466
  return c.json({ binding });
1178
2467
  });
2468
+ app.get("/v1/channel-bindings/:provider/:accountId/:conversationId/status", async (c) => {
2469
+ const provider = c.req.param("provider");
2470
+ const accountId = c.req.param("accountId");
2471
+ const conversationId = c.req.param("conversationId");
2472
+ const binding = await repo.getChannelBinding({ provider, accountId, conversationId });
2473
+ if (!binding) return c.json({ error: "channel_binding_not_found" }, 404);
2474
+ const active = await repo.findCancelableRunForSourceContainer({
2475
+ source: provider,
2476
+ repoProvider: binding.repoProvider,
2477
+ owner: binding.owner,
2478
+ repo: binding.repo,
2479
+ metadata: sourceContainerMetadata({ provider, accountId, conversationId })
2480
+ });
2481
+ const queuedFollowUps = active ? await repo.listQueuedFollowUpsForActiveRun({ activeRunId: active.run.id }) : [];
2482
+ const runTimeoutMs = active ? latestRunTimeoutMs(await repo.listRunEvents({ runId: active.run.id })) : void 0;
2483
+ return c.json({
2484
+ binding,
2485
+ ...active ? { activeRun: active.run, activeEvent: active.event } : {},
2486
+ ...runTimeoutMs ? { runTimeoutPolicy: { hardTimeoutMs: runTimeoutMs } } : {},
2487
+ queuedFollowUps
2488
+ });
2489
+ });
2490
+ app.delete("/v1/channel-bindings/:provider/:accountId/:conversationId", async (c) => {
2491
+ const provider = c.req.param("provider");
2492
+ const accountId = c.req.param("accountId");
2493
+ const conversationId = c.req.param("conversationId");
2494
+ const deleted = await repo.deleteChannelBinding({
2495
+ provider,
2496
+ accountId,
2497
+ conversationId
2498
+ });
2499
+ if (!deleted) return c.json({ error: "channel_binding_not_found" }, 404);
2500
+ await recordControlPlaneEvent({
2501
+ type: "binding.channel.deleted",
2502
+ severity: "info",
2503
+ subject: `${provider}:${accountId}/${conversationId}`,
2504
+ payload: {
2505
+ provider,
2506
+ accountId,
2507
+ conversationId
2508
+ }
2509
+ });
2510
+ return c.body(null, 204);
2511
+ });
2512
+ app.post("/v1/channel-bindings/:provider/:accountId/:conversationId/cancel-active-run", async (c) => {
2513
+ const provider = c.req.param("provider");
2514
+ const accountId = c.req.param("accountId");
2515
+ const conversationId = c.req.param("conversationId");
2516
+ const parsed = await parseDispatcherBody(c, CancelRunSchema);
2517
+ const binding = await repo.getChannelBinding({ provider, accountId, conversationId });
2518
+ if (!binding) return c.json({ error: "channel_binding_not_found" }, 404);
2519
+ const active = await repo.findCancelableRunForSourceContainer({
2520
+ source: provider,
2521
+ repoProvider: binding.repoProvider,
2522
+ owner: binding.owner,
2523
+ repo: binding.repo,
2524
+ metadata: sourceContainerMetadata({ provider, accountId, conversationId })
2525
+ });
2526
+ if (!active) return c.json({ error: "active_run_not_found" }, 404);
2527
+ const outcome = await repo.cancelRun({
2528
+ runId: active.run.id,
2529
+ ...parsed.reason ? { reason: parsed.reason } : {},
2530
+ ...parsed.requestedBy ? { requestedBy: parsed.requestedBy } : {}
2531
+ });
2532
+ if (outcome.outcome === "not_found") return c.json({ error: "run_not_found" }, 404);
2533
+ if (outcome.outcome === "already_terminal") {
2534
+ return c.json({ error: "run_already_terminal", run: outcome.run }, 409);
2535
+ }
2536
+ return c.json({ outcome: "cancelled", run: outcome.run });
2537
+ });
1179
2538
  app.post("/v1/slack-channel-bindings", async (c) => {
1180
- const parsed = CreateSlackChannelBindingSchema.parse(await c.req.json());
2539
+ const parsed = await parseDispatcherBody(c, CreateSlackChannelBindingSchema);
1181
2540
  await repo.createSlackChannelBinding(parsed);
2541
+ await recordControlPlaneEvent({
2542
+ type: "binding.channel.upserted",
2543
+ severity: "info",
2544
+ subject: `slack:${parsed.teamId}/${parsed.channelId}`,
2545
+ payload: {
2546
+ provider: "slack",
2547
+ accountId: parsed.teamId,
2548
+ conversationId: parsed.channelId,
2549
+ repoProvider: parsed.repoProvider ?? "github",
2550
+ owner: parsed.owner,
2551
+ repo: parsed.repo,
2552
+ compatibilityEndpoint: "/v1/slack-channel-bindings",
2553
+ hasMetadata: false
2554
+ }
2555
+ });
1182
2556
  return c.json({ ok: true }, 201);
1183
2557
  });
1184
2558
  app.get("/v1/slack-channel-bindings/:teamId/:channelId", async (c) => {
@@ -1190,43 +2564,69 @@ function createDispatcherApp(input) {
1190
2564
  return c.json({ binding });
1191
2565
  });
1192
2566
  app.post("/v1/runs", async (c) => {
1193
- const parsed = CreateRunSchema.parse(await c.req.json());
2567
+ const parsed = await parseDispatcherBody(c, CreateRunSchema);
1194
2568
  const admitted = await admission.admitRun({ requestId: parsed.runId, event: parsed.event });
1195
2569
  if (admitted.outcome === "needs_human_decision") {
2570
+ const projectTarget = projectTargetRefFromEvent2(parsed.event);
2571
+ await recordControlPlaneEvent({
2572
+ type: "admission.needs_human_decision",
2573
+ severity: ["repo_context_missing", "repo_not_bound"].includes(admitted.decision.reasonCode) ? "warn" : "info",
2574
+ subject: parsed.runId,
2575
+ payload: {
2576
+ runId: parsed.runId,
2577
+ decision: admitted.decision,
2578
+ source: parsed.event.source,
2579
+ sourceEventId: parsed.event.sourceEventId,
2580
+ projectTarget: projectTarget ? `${projectTarget.provider}:${projectTarget.owner}/${projectTarget.repo}` : null
2581
+ }
2582
+ });
1196
2583
  return c.json({ decision: admitted.decision }, 202);
1197
2584
  }
1198
2585
  if (admitted.outcome === "drop_duplicate") {
1199
- await repo.appendRunEvent({
1200
- runId: admitted.run.id,
1201
- type: "admission.decided",
1202
- payload: admitted.decision,
1203
- visibility: "audit",
1204
- importance: "normal",
1205
- message: admitted.decision.reason
1206
- });
1207
- await repo.appendRunEvent({
1208
- runId: admitted.run.id,
1209
- type: "run.create_idempotent_replay",
1210
- payload: { requestedRunId: parsed.runId, eventId: parsed.event.id },
1211
- visibility: "audit",
1212
- importance: "low"
1213
- });
1214
- return c.json({ decision: admitted.decision, run: admitted.run, idempotentReplay: true }, 200);
2586
+ const replay = await repo.createRun({ id: parsed.runId, event: parsed.event });
2587
+ const decision = replay.created ? admitted.decision : replay.replayDecision;
2588
+ return c.json({ decision, run: replay.run, idempotentReplay: true }, 200);
1215
2589
  }
1216
2590
  if (admitted.outcome === "follow_up_queued") {
2591
+ const event = admitted.followUpRequest.event;
2592
+ const activeRunId = admitted.followUpRequest.activeRunId;
2593
+ if (activeRunId && shouldDeliverRunStatusUpdate(presentation, { provider: event.callback.provider, state: "queued" })) {
2594
+ const queuedPresentation = presentation.runStatusPresentation({
2595
+ runId: activeRunId,
2596
+ state: "queued",
2597
+ message: `Queued follow-up ${admitted.followUpRequest.id} behind the active run.`,
2598
+ nextAction: "Wait for the active run final reply, send another follow-up to queue more context, or request cancellation with /stop.",
2599
+ detailVisibility: "source_thread"
2600
+ });
2601
+ const queued = presentation.render({
2602
+ provider: event.callback.provider,
2603
+ presentation: queuedPresentation
2604
+ });
2605
+ await deliverAndAudit({
2606
+ repo,
2607
+ sink: callbackSink,
2608
+ retry: callbackRetry,
2609
+ message: {
2610
+ runId: activeRunId,
2611
+ kind: "progress",
2612
+ provider: event.callback.provider,
2613
+ uri: event.callback.uri,
2614
+ body: queued.body,
2615
+ ...event.target.agentId ? { agentId: event.target.agentId } : {},
2616
+ ...event.callback.threadKey ? { threadKey: event.callback.threadKey } : {},
2617
+ ...queued.blocks?.length ? { blocks: queued.blocks } : {},
2618
+ ...queued.rich ? { rich: queued.rich } : {},
2619
+ statusMessageKey: `${activeRunId}:status`
2620
+ }
2621
+ });
2622
+ }
1217
2623
  return c.json({ decision: admitted.decision, followUpRequest: admitted.followUpRequest }, 202);
1218
2624
  }
1219
2625
  const createdRun = await repo.createRun({ id: parsed.runId, event: parsed.event });
1220
2626
  if (!createdRun.created) {
1221
2627
  return c.json(
1222
2628
  {
1223
- decision: {
1224
- ...admitted.decision,
1225
- action: "drop_duplicate",
1226
- reason: "Source event already created a run.",
1227
- reasonCode: "duplicate_source_event",
1228
- activeRunId: createdRun.run.id
1229
- },
2629
+ decision: createdRun.replayDecision,
1230
2630
  run: createdRun.run,
1231
2631
  idempotentReplay: true
1232
2632
  },
@@ -1234,7 +2634,25 @@ function createDispatcherApp(input) {
1234
2634
  );
1235
2635
  }
1236
2636
  const { run } = createdRun;
1237
- if (presentation.shouldDeliverAcknowledgement(parsed.event.callback.provider)) {
2637
+ const sourceReceiptDelivery = await deliverSourceReceiptBestEffort({
2638
+ repo,
2639
+ sink: sourceReceiptSink,
2640
+ receipt: {
2641
+ runId: run.id,
2642
+ provider: parsed.event.callback.provider,
2643
+ state: "received",
2644
+ event: parsed.event,
2645
+ ...parsed.event.target.agentId ? { agentId: parsed.event.target.agentId } : {}
2646
+ }
2647
+ });
2648
+ const shouldDeliverAcknowledgement = presentation.shouldDeliverAcknowledgement(parsed.event.callback.provider) || shouldDeliverSourceReceipt(parsed.event.callback.provider) && !sourceReceiptDelivery.delivered;
2649
+ if (shouldDeliverAcknowledgement) {
2650
+ const acknowledgementPresentation = presentation.acknowledgementPresentation({ runId: run.id });
2651
+ const acknowledgement = presentation.render({
2652
+ provider: parsed.event.callback.provider,
2653
+ presentation: acknowledgementPresentation
2654
+ });
2655
+ const statusMessageKey = larkLifecycleStatusMessageKey({ provider: parsed.event.callback.provider, runId: run.id });
1238
2656
  await deliverAndAudit({
1239
2657
  repo,
1240
2658
  sink: callbackSink,
@@ -1244,16 +2662,19 @@ function createDispatcherApp(input) {
1244
2662
  kind: "acknowledgement",
1245
2663
  provider: parsed.event.callback.provider,
1246
2664
  uri: parsed.event.callback.uri,
1247
- body: presentation.acknowledgement({ provider: parsed.event.callback.provider, runId: run.id }),
2665
+ body: acknowledgement.body,
1248
2666
  ...parsed.event.target.agentId ? { agentId: parsed.event.target.agentId } : {},
1249
- ...parsed.event.callback.threadKey ? { threadKey: parsed.event.callback.threadKey } : {}
2667
+ ...parsed.event.callback.threadKey ? { threadKey: parsed.event.callback.threadKey } : {},
2668
+ ...statusMessageKey ? { statusMessageKey } : {},
2669
+ ...acknowledgement.blocks?.length ? { blocks: acknowledgement.blocks } : {},
2670
+ ...acknowledgement.rich ? { rich: acknowledgement.rich } : {}
1250
2671
  }
1251
2672
  });
1252
2673
  }
1253
2674
  return c.json({ decision: admitted.decision, run }, 201);
1254
2675
  });
1255
2676
  app.post("/v1/thread-actions", async (c) => {
1256
- const parsed = ThreadActionInputSchema.parse(await c.req.json());
2677
+ const parsed = await parseDispatcherBody(c, ThreadActionInputSchema);
1257
2678
  const command = parseThreadActionCommand(parsed.rawText);
1258
2679
  if (!command) {
1259
2680
  return c.json({ outcome: "ignored", reason: "not_action_command" }, 202);
@@ -1305,22 +2726,24 @@ function createDispatcherApp(input) {
1305
2726
  if (existingPlan) {
1306
2727
  const existingDecision2 = await repo.getApprovalDecision({ id: existingPlan.approvalDecisionId });
1307
2728
  if (selectedIntentsAlreadyApplied({ plan: existingPlan, selectedIntentIds: resolved.resolved.selectedIntentIds })) {
2729
+ await deliverAndAudit({
2730
+ repo,
2731
+ sink: callbackSink,
2732
+ retry: callbackRetry,
2733
+ message: {
2734
+ runId: resolved.resolved.proposal.runId,
2735
+ kind: "final",
2736
+ provider: parsed.callback.provider,
2737
+ uri: parsed.callback.uri,
2738
+ body: renderAlreadyAppliedThreadActionBody({ selectionText }),
2739
+ ...parsed.callback.threadKey ? { threadKey: parsed.callback.threadKey } : {}
2740
+ }
2741
+ });
1308
2742
  return c.json({ outcome: "already_applied", decision: existingDecision2, plan: existingPlan }, 200);
1309
2743
  }
1310
- const fallbackReason = "An apply plan already exists for this selected action; OpenTag will not execute it again from a repeated thread reply.";
1311
- const childRun2 = await createChildRunForThreadAction({
1312
- repo,
1313
- command,
1314
- resolved: resolved.resolved,
1315
- runId: stableChildRunId({
1316
- command,
1317
- resolved: resolved.resolved,
1318
- sourceApplyPlanId: existingPlan.id,
1319
- fallbackReason
1320
- }),
1321
- approvalDecisionId: existingPlan.approvalDecisionId,
1322
- sourceApplyPlanId: existingPlan.id,
1323
- fallbackReason
2744
+ const isStale = selectedIntentsHaveStaleOutcome({
2745
+ plan: existingPlan,
2746
+ selectedIntentIds: resolved.resolved.selectedIntentIds
1324
2747
  });
1325
2748
  await deliverAndAudit({
1326
2749
  repo,
@@ -1331,18 +2754,14 @@ function createDispatcherApp(input) {
1331
2754
  kind: "final",
1332
2755
  provider: parsed.callback.provider,
1333
2756
  uri: parsed.callback.uri,
1334
- body: renderChildRunCreatedBody({
1335
- lead: "This action was already planned, so OpenTag will not execute the external write again.",
1336
- resolved: resolved.resolved,
1337
- childRun: childRun2,
1338
- approvalDecisionId: existingPlan.approvalDecisionId,
1339
- sourceApplyPlanId: existingPlan.id,
1340
- fallbackReason
1341
- }),
2757
+ body: isStale ? renderStaleThreadActionBody({
2758
+ selectionText,
2759
+ continueIndex: resolved.resolved.selectedCandidates[0]?.index ?? 1
2760
+ }) : renderAlreadyPlannedThreadActionBody({ selectionText }),
1342
2761
  ...parsed.callback.threadKey ? { threadKey: parsed.callback.threadKey } : {}
1343
2762
  }
1344
2763
  });
1345
- return c.json({ outcome: "already_planned", decision: existingDecision2, plan: existingPlan, run: childRun2 }, 200);
2764
+ return c.json({ outcome: isStale ? "stale" : "already_planned", decision: existingDecision2, plan: existingPlan }, 200);
1346
2765
  }
1347
2766
  }
1348
2767
  const providedDecision = parsed.id ? await repo.getApprovalDecision({ id: parsed.id }) : null;
@@ -1383,7 +2802,10 @@ function createDispatcherApp(input) {
1383
2802
  if (existingDecision) {
1384
2803
  return c.json({ outcome: "already_rejected", decision }, 200);
1385
2804
  }
1386
- const body2 = `Rejected ${selectionText} from \`${resolved.resolved.proposal.snapshot.proposalId}\`.`;
2805
+ const body2 = renderThreadActionRecordedBody({
2806
+ verb: "reject",
2807
+ selectionText
2808
+ });
1387
2809
  await deliverAndAudit({
1388
2810
  repo,
1389
2811
  sink: callbackSink,
@@ -1403,9 +2825,18 @@ function createDispatcherApp(input) {
1403
2825
  if (existingDecision) {
1404
2826
  return c.json({ outcome: "already_approved", decision }, 200);
1405
2827
  }
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.`;
2828
+ const directApply = await selectedDirectApplyStatus({
2829
+ event: resolved.resolved.proposal.event,
2830
+ callbackProvider: parsed.callback.provider,
2831
+ candidates: resolved.resolved.selectedCandidates,
2832
+ ...input.githubApply ? { githubApply: input.githubApply } : {}
2833
+ });
2834
+ const body2 = renderThreadActionRecordedBody({
2835
+ verb: "approve",
2836
+ selectionText,
2837
+ applyIndex: resolved.resolved.selectedCandidates[0]?.index ?? 1,
2838
+ directApply
2839
+ });
1409
2840
  await deliverAndAudit({
1410
2841
  repo,
1411
2842
  sink: callbackSink,
@@ -1430,9 +2861,11 @@ Reply with \`apply ${resolved.resolved.selectedCandidates[0]?.index ?? 1}\` to a
1430
2861
  approvalDecisionId: decision.id
1431
2862
  });
1432
2863
  const body2 = renderChildRunCreatedBody({
1433
- lead: `Continuing from ${selectionText} in \`${resolved.resolved.proposal.snapshot.proposalId}\`.`,
2864
+ lead: "Continuing in OpenTag from this approved action.",
1434
2865
  resolved: resolved.resolved,
1435
2866
  childRun: childRun2,
2867
+ provider: parsed.callback.provider,
2868
+ selectionText,
1436
2869
  approvalDecisionId: decision.id
1437
2870
  });
1438
2871
  await deliverAndAudit({
@@ -1462,22 +2895,24 @@ Reply with \`apply ${resolved.resolved.selectedCandidates[0]?.index ?? 1}\` to a
1462
2895
  }
1463
2896
  if (!planResult.created) {
1464
2897
  if (selectedIntentsAlreadyApplied({ plan: planResult.plan, selectedIntentIds: resolved.resolved.selectedIntentIds })) {
2898
+ await deliverAndAudit({
2899
+ repo,
2900
+ sink: callbackSink,
2901
+ retry: callbackRetry,
2902
+ message: {
2903
+ runId: resolved.resolved.proposal.runId,
2904
+ kind: "final",
2905
+ provider: parsed.callback.provider,
2906
+ uri: parsed.callback.uri,
2907
+ body: renderAlreadyAppliedThreadActionBody({ selectionText }),
2908
+ ...parsed.callback.threadKey ? { threadKey: parsed.callback.threadKey } : {}
2909
+ }
2910
+ });
1465
2911
  return c.json({ outcome: "already_applied", decision, plan: planResult.plan }, 200);
1466
2912
  }
1467
- const fallbackReason = "An apply plan already exists for this selected action; OpenTag will not execute it again from a repeated thread reply.";
1468
- const childRun2 = await createChildRunForThreadAction({
1469
- repo,
1470
- command,
1471
- resolved: resolved.resolved,
1472
- runId: stableChildRunId({
1473
- command,
1474
- resolved: resolved.resolved,
1475
- sourceApplyPlanId: planResult.plan.id,
1476
- fallbackReason
1477
- }),
1478
- approvalDecisionId: decision.id,
1479
- sourceApplyPlanId: planResult.plan.id,
1480
- fallbackReason
2913
+ const isStale = selectedIntentsHaveStaleOutcome({
2914
+ plan: planResult.plan,
2915
+ selectedIntentIds: resolved.resolved.selectedIntentIds
1481
2916
  });
1482
2917
  await deliverAndAudit({
1483
2918
  repo,
@@ -1488,18 +2923,14 @@ Reply with \`apply ${resolved.resolved.selectedCandidates[0]?.index ?? 1}\` to a
1488
2923
  kind: "final",
1489
2924
  provider: parsed.callback.provider,
1490
2925
  uri: parsed.callback.uri,
1491
- body: renderChildRunCreatedBody({
1492
- lead: "This action was already planned, so OpenTag will not execute the external write again.",
1493
- resolved: resolved.resolved,
1494
- childRun: childRun2,
1495
- approvalDecisionId: decision.id,
1496
- sourceApplyPlanId: planResult.plan.id,
1497
- fallbackReason
1498
- }),
2926
+ body: isStale ? renderStaleThreadActionBody({
2927
+ selectionText,
2928
+ continueIndex: resolved.resolved.selectedCandidates[0]?.index ?? 1
2929
+ }) : renderAlreadyPlannedThreadActionBody({ selectionText }),
1499
2930
  ...parsed.callback.threadKey ? { threadKey: parsed.callback.threadKey } : {}
1500
2931
  }
1501
2932
  });
1502
- return c.json({ outcome: "already_planned", decision, plan: planResult.plan, run: childRun2 }, 200);
2933
+ return c.json({ outcome: isStale ? "stale" : "already_planned", decision, plan: planResult.plan }, 200);
1503
2934
  }
1504
2935
  const plan = planResult.plan;
1505
2936
  const execution = await executeGitHubApplyPlan({
@@ -1510,12 +2941,11 @@ Reply with \`apply ${resolved.resolved.selectedCandidates[0]?.index ?? 1}\` to a
1510
2941
  });
1511
2942
  if (execution.executed) {
1512
2943
  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");
2944
+ const body2 = renderAppliedThreadActionBody({
2945
+ selectionText,
2946
+ selectedIntentIds: resolved.resolved.selectedIntentIds,
2947
+ outcomes
2948
+ });
1519
2949
  await deliverAndAudit({
1520
2950
  repo,
1521
2951
  sink: callbackSink,
@@ -1531,6 +2961,25 @@ Reply with \`apply ${resolved.resolved.selectedCandidates[0]?.index ?? 1}\` to a
1531
2961
  });
1532
2962
  return c.json({ outcome: "applied", decision, plan: execution.plan }, 201);
1533
2963
  }
2964
+ if (selectedIntentsHaveStaleOutcome({ plan: execution.plan, selectedIntentIds: resolved.resolved.selectedIntentIds })) {
2965
+ await deliverAndAudit({
2966
+ repo,
2967
+ sink: callbackSink,
2968
+ retry: callbackRetry,
2969
+ message: {
2970
+ runId: resolved.resolved.proposal.runId,
2971
+ kind: "final",
2972
+ provider: parsed.callback.provider,
2973
+ uri: parsed.callback.uri,
2974
+ body: renderStaleThreadActionBody({
2975
+ selectionText,
2976
+ continueIndex: resolved.resolved.selectedCandidates[0]?.index ?? 1
2977
+ }),
2978
+ ...parsed.callback.threadKey ? { threadKey: parsed.callback.threadKey } : {}
2979
+ }
2980
+ });
2981
+ return c.json({ outcome: "stale", decision, plan: execution.plan }, 200);
2982
+ }
1534
2983
  const childRun = await createChildRunForThreadAction({
1535
2984
  repo,
1536
2985
  command,
@@ -1546,9 +2995,11 @@ Reply with \`apply ${resolved.resolved.selectedCandidates[0]?.index ?? 1}\` to a
1546
2995
  fallbackReason: execution.fallbackReason ?? "OpenTag cannot directly apply this intent yet."
1547
2996
  });
1548
2997
  const body = renderChildRunCreatedBody({
1549
- lead: `Action ${selectionText} was approved, but OpenTag cannot directly apply it yet.`,
2998
+ lead: "Needs setup before OpenTag can apply this action directly.",
1550
2999
  resolved: resolved.resolved,
1551
3000
  childRun,
3001
+ provider: parsed.callback.provider,
3002
+ selectionText,
1552
3003
  approvalDecisionId: decision.id,
1553
3004
  sourceApplyPlanId: execution.plan.id,
1554
3005
  fallbackReason: execution.fallbackReason ?? "The adapter could not execute the selected intent."
@@ -1574,10 +3025,10 @@ Reply with \`apply ${resolved.resolved.selectedCandidates[0]?.index ?? 1}\` to a
1574
3025
  return c.json({ followUpRequest });
1575
3026
  });
1576
3027
  app.post("/v1/follow-up-requests/:id/create-run", async (c) => {
1577
- const parsed = PromoteFollowUpRequestSchema.parse(await c.req.json());
3028
+ const parsed = await parseDispatcherBody(c, PromoteFollowUpRequestSchema);
1578
3029
  let promoted;
1579
3030
  try {
1580
- promoted = await repo.createRunFromFollowUpRequest({
3031
+ promoted = await promoteFollowUpRequest({
1581
3032
  followUpRequestId: c.req.param("id"),
1582
3033
  runId: parsed.runId
1583
3034
  });
@@ -1592,23 +3043,6 @@ Reply with \`apply ${resolved.resolved.selectedCandidates[0]?.index ?? 1}\` to a
1592
3043
  throw error;
1593
3044
  }
1594
3045
  const followUpRequest = promoted.followUpRequest;
1595
- const event = followUpRequest.event;
1596
- if (presentation.shouldDeliverAcknowledgement(event.callback.provider)) {
1597
- await deliverAndAudit({
1598
- repo,
1599
- sink: callbackSink,
1600
- retry: callbackRetry,
1601
- message: {
1602
- runId: promoted.run.id,
1603
- kind: "acknowledgement",
1604
- provider: event.callback.provider,
1605
- uri: event.callback.uri,
1606
- body: presentation.acknowledgement({ provider: event.callback.provider, runId: promoted.run.id }),
1607
- ...event.target.agentId ? { agentId: event.target.agentId } : {},
1608
- ...event.callback.threadKey ? { threadKey: event.callback.threadKey } : {}
1609
- }
1610
- });
1611
- }
1612
3046
  return c.json({ followUpRequest, run: promoted.run }, 201);
1613
3047
  });
1614
3048
  app.post("/v1/runners/:runnerId/claim", async (c) => {
@@ -1628,13 +3062,67 @@ Reply with \`apply ${resolved.resolved.selectedCandidates[0]?.index ?? 1}\` to a
1628
3062
  }, 410);
1629
3063
  });
1630
3064
  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());
1632
- const ok = await repo.markRunning({
1633
- runId: c.req.param("runId"),
3065
+ const runId = c.req.param("runId");
3066
+ const body = await parseDispatcherBody(c, MarkRunningSchema);
3067
+ const headerIdempotencyKey = c.req.header("idempotency-key")?.trim();
3068
+ const idempotencyKey = body.idempotencyKey?.trim() || headerIdempotencyKey;
3069
+ const runningOutcome = await repo.markRunning({
3070
+ runId,
1634
3071
  runnerId: c.req.param("runnerId"),
1635
- executor: body.executor
3072
+ executor: body.executor,
3073
+ ...body.runTimeoutMs ? { runTimeoutMs: body.runTimeoutMs } : {},
3074
+ ...idempotencyKey ? { idempotencyKey } : {}
1636
3075
  });
1637
- if (!ok) return c.json({ error: "run_not_claimed_by_runner" }, 404);
3076
+ if (runningOutcome === "not_found") return c.json({ error: "run_not_claimed_by_runner" }, 404);
3077
+ if (runningOutcome === "duplicate") return c.json({ ok: true, replayed: true });
3078
+ const stored = await repo.getRun({ runId });
3079
+ if (!stored) return c.json({ error: "run_not_found" }, 404);
3080
+ const provider = stored.event.callback.provider;
3081
+ if (shouldDeliverSourceReceipt(provider)) {
3082
+ await deliverSourceReceiptBestEffort({
3083
+ repo,
3084
+ sink: sourceReceiptSink,
3085
+ receipt: {
3086
+ runId,
3087
+ provider,
3088
+ state: "running",
3089
+ event: stored.event,
3090
+ ...stored.event.target.agentId ? { agentId: stored.event.target.agentId } : {}
3091
+ }
3092
+ });
3093
+ }
3094
+ if (shouldDeliverRunStatusUpdate(presentation, { provider, state: "running" })) {
3095
+ const runningPresentation = presentation.runStatusPresentation({
3096
+ runId,
3097
+ state: "running",
3098
+ message: `Running with ${body.executor}.`,
3099
+ nextAction: "Wait for the final reply, send a follow-up to queue more context, or request cancellation with /stop.",
3100
+ detailVisibility: "source_thread"
3101
+ });
3102
+ const running = presentation.render({
3103
+ provider,
3104
+ presentation: runningPresentation
3105
+ });
3106
+ await deliverAndAudit({
3107
+ repo,
3108
+ sink: callbackSink,
3109
+ retry: callbackRetry,
3110
+ message: {
3111
+ runId,
3112
+ kind: "progress",
3113
+ provider,
3114
+ uri: stored.event.callback.uri,
3115
+ body: running.body,
3116
+ ...stored.event.target.agentId ? { agentId: stored.event.target.agentId } : {},
3117
+ ...stored.event.callback.threadKey ? { threadKey: stored.event.callback.threadKey } : {},
3118
+ ...running.blocks?.length ? { blocks: running.blocks } : {},
3119
+ ...running.rich ? { rich: running.rich } : {},
3120
+ statusMessageKey: `${runId}:status`
3121
+ }
3122
+ });
3123
+ } else if (presentation.shouldDeliverStatusUpdate(provider)) {
3124
+ await appendSuppressedRunStatusCallback({ runId, provider, state: "running" });
3125
+ }
1638
3126
  return c.json({ ok: true });
1639
3127
  });
1640
3128
  app.post("/v1/runs/:runId/progress", async () => {
@@ -1645,20 +3133,31 @@ Reply with \`apply ${resolved.resolved.selectedCandidates[0]?.index ?? 1}\` to a
1645
3133
  });
1646
3134
  app.post("/v1/runners/:runnerId/runs/:runId/progress", async (c) => {
1647
3135
  const runId = c.req.param("runId");
1648
- const body = ProgressSchema.parse(await c.req.json());
1649
- const ok = await repo.recordProgress({
3136
+ const body = await parseDispatcherBody(c, ProgressSchema);
3137
+ const headerIdempotencyKey = c.req.header("idempotency-key")?.trim();
3138
+ const idempotencyKey = body.idempotencyKey?.trim() || headerIdempotencyKey;
3139
+ const progressOutcome = await repo.recordProgress({
1650
3140
  runId,
1651
3141
  runnerId: c.req.param("runnerId"),
1652
3142
  message: body.message,
1653
3143
  ...body.type ? { type: body.type } : {},
1654
3144
  ...body.at ? { at: body.at } : {},
1655
3145
  ...body.visibility ? { visibility: body.visibility } : {},
1656
- ...body.importance ? { importance: body.importance } : {}
3146
+ ...body.importance ? { importance: body.importance } : {},
3147
+ ...idempotencyKey ? { idempotencyKey } : {}
1657
3148
  });
1658
- if (!ok) return c.json({ error: "run_not_claimed_by_runner" }, 404);
3149
+ if (progressOutcome === "not_found") return c.json({ error: "run_not_claimed_by_runner" }, 404);
3150
+ if (progressOutcome === "duplicate") return c.json({ ok: true, replayed: true });
1659
3151
  const stored = await repo.getRun({ runId });
1660
3152
  if (!stored) return c.json({ error: "run_not_found" }, 404);
1661
- if (presentation.shouldDeliverProgress(stored.event.callback.provider)) {
3153
+ const progressVisibility = body.visibility ?? "audit";
3154
+ const shouldDeliverProgress = presentation.shouldDeliverProgress(stored.event.callback.provider);
3155
+ if (progressVisibility === "human" && shouldDeliverProgress) {
3156
+ const progressPresentation = presentation.progressPresentation({ runId, message: body.message });
3157
+ const progress = presentation.render({
3158
+ provider: stored.event.callback.provider,
3159
+ presentation: progressPresentation
3160
+ });
1662
3161
  await deliverAndAudit({
1663
3162
  repo,
1664
3163
  sink: callbackSink,
@@ -1668,12 +3167,29 @@ Reply with \`apply ${resolved.resolved.selectedCandidates[0]?.index ?? 1}\` to a
1668
3167
  kind: "progress",
1669
3168
  provider: stored.event.callback.provider,
1670
3169
  uri: stored.event.callback.uri,
1671
- body: presentation.progress({ provider: stored.event.callback.provider, runId, message: body.message }),
3170
+ body: progress.body,
1672
3171
  ...stored.event.target.agentId ? { agentId: stored.event.target.agentId } : {},
1673
3172
  ...stored.event.callback.threadKey ? { threadKey: stored.event.callback.threadKey } : {},
3173
+ ...progress.blocks?.length ? { blocks: progress.blocks } : {},
3174
+ ...progress.rich ? { rich: progress.rich } : {},
1674
3175
  statusMessageKey: `${runId}:status`
1675
3176
  }
1676
3177
  });
3178
+ } else if (progressVisibility === "human") {
3179
+ const capability = platformCapabilityForProvider2(stored.event.callback.provider);
3180
+ await repo.appendRunEvent({
3181
+ runId,
3182
+ type: "callback.progress.suppressed",
3183
+ payload: {
3184
+ provider: stored.event.callback.provider,
3185
+ reason: "platform_liveness_strategy",
3186
+ requestedVisibility: progressVisibility,
3187
+ livenessStrategy: capability?.livenessStrategy ?? "unknown"
3188
+ },
3189
+ visibility: "audit",
3190
+ importance: "low",
3191
+ message: "Progress callback suppressed by platform liveness strategy; use status or audit for details."
3192
+ });
1677
3193
  }
1678
3194
  return c.json({ ok: true });
1679
3195
  });
@@ -1685,12 +3201,64 @@ Reply with \`apply ${resolved.resolved.selectedCandidates[0]?.index ?? 1}\` to a
1685
3201
  });
1686
3202
  app.post("/v1/runners/:runnerId/runs/:runId/complete", async (c) => {
1687
3203
  const runId = c.req.param("runId");
1688
- const parsed = CompleteRunSchema.parse(await c.req.json());
1689
- const ok = await repo.completeRun({ runId, runnerId: c.req.param("runnerId"), result: parsed.result });
1690
- if (!ok) return c.json({ error: "run_not_claimed_by_runner" }, 404);
3204
+ const parsed = await parseDispatcherBody(c, CompleteRunSchema);
3205
+ const headerIdempotencyKey = c.req.header("idempotency-key")?.trim();
3206
+ const idempotencyKey = parsed.idempotencyKey?.trim() || headerIdempotencyKey;
3207
+ const outcome = await repo.completeRun({
3208
+ runId,
3209
+ runnerId: c.req.param("runnerId"),
3210
+ result: parsed.result,
3211
+ ...idempotencyKey ? { idempotencyKey } : {}
3212
+ });
3213
+ if (outcome === "not_found") return c.json({ error: "run_not_claimed_by_runner" }, 404);
3214
+ if (outcome === "duplicate") return c.json({ ok: true, replayed: true });
1691
3215
  const stored = await repo.getRun({ runId });
1692
3216
  if (!stored) return c.json({ error: "run_not_found" }, 404);
1693
- const finalPresentation = presentation.final({ provider: stored.event.callback.provider, result: parsed.result });
3217
+ const receiptContext = await actionReceiptContextForFinal({
3218
+ event: stored.event,
3219
+ result: parsed.result,
3220
+ ...input.githubApply ? { githubApply: input.githubApply } : {}
3221
+ });
3222
+ if (parsed.result.conclusion === "needs_human" && shouldDeliverRunStatusUpdate(presentation, { provider: stored.event.callback.provider, state: "waiting_for_approval" })) {
3223
+ const waitingPresentation = presentation.runStatusPresentation({
3224
+ runId,
3225
+ state: "waiting_for_approval",
3226
+ message: "Waiting for approval.",
3227
+ nextAction: "Review the source-thread action receipt, then approve, reject, apply, or continue from the source thread.",
3228
+ detailVisibility: "source_thread"
3229
+ });
3230
+ const waiting = presentation.render({
3231
+ provider: stored.event.callback.provider,
3232
+ presentation: waitingPresentation
3233
+ });
3234
+ await deliverAndAudit({
3235
+ repo,
3236
+ sink: callbackSink,
3237
+ retry: callbackRetry,
3238
+ message: {
3239
+ runId,
3240
+ kind: "progress",
3241
+ provider: stored.event.callback.provider,
3242
+ uri: stored.event.callback.uri,
3243
+ body: waiting.body,
3244
+ ...stored.event.target.agentId ? { agentId: stored.event.target.agentId } : {},
3245
+ ...stored.event.callback.threadKey ? { threadKey: stored.event.callback.threadKey } : {},
3246
+ ...waiting.blocks?.length ? { blocks: waiting.blocks } : {},
3247
+ ...waiting.rich ? { rich: waiting.rich } : {},
3248
+ statusMessageKey: `${runId}:status`
3249
+ }
3250
+ });
3251
+ }
3252
+ const finalPresentation = presentation.finalPresentation({
3253
+ result: parsed.result,
3254
+ runId,
3255
+ receiptContext
3256
+ });
3257
+ const finalCallback = presentation.render({
3258
+ provider: stored.event.callback.provider,
3259
+ presentation: finalPresentation
3260
+ });
3261
+ const statusMessageKey = larkLifecycleStatusMessageKey({ provider: stored.event.callback.provider, runId });
1694
3262
  await deliverAndAudit({
1695
3263
  repo,
1696
3264
  sink: callbackSink,
@@ -1700,13 +3268,25 @@ Reply with \`apply ${resolved.resolved.selectedCandidates[0]?.index ?? 1}\` to a
1700
3268
  kind: "final",
1701
3269
  provider: stored.event.callback.provider,
1702
3270
  uri: stored.event.callback.uri,
1703
- body: finalPresentation.body,
3271
+ body: finalCallback.body,
1704
3272
  ...stored.event.target.agentId ? { agentId: stored.event.target.agentId } : {},
1705
3273
  ...stored.event.callback.threadKey ? { threadKey: stored.event.callback.threadKey } : {},
1706
- ...finalPresentation.blocks?.length ? { blocks: finalPresentation.blocks } : {}
3274
+ ...statusMessageKey ? { statusMessageKey } : {},
3275
+ ...finalCallback.blocks?.length ? { blocks: finalCallback.blocks } : {},
3276
+ ...finalCallback.rich ? { rich: finalCallback.rich } : {}
1707
3277
  }
1708
3278
  });
1709
- return c.json({ ok: true });
3279
+ const shouldPromoteFollowUp = parsed.result.conclusion !== "needs_human" && parsed.result.conclusion !== "cancelled";
3280
+ const promotedFollowUp = shouldPromoteFollowUp ? await promoteNextFollowUpAfterTerminalRun({ activeRunId: runId }) : null;
3281
+ return c.json({
3282
+ ok: true,
3283
+ ...promotedFollowUp ? {
3284
+ promotedFollowUp: {
3285
+ followUpRequest: promotedFollowUp.followUpRequest,
3286
+ run: promotedFollowUp.run
3287
+ }
3288
+ } : {}
3289
+ });
1710
3290
  });
1711
3291
  app.get("/v1/proposals/:proposalId", async (c) => {
1712
3292
  const proposal = await repo.getSuggestedChanges({ proposalId: c.req.param("proposalId") });
@@ -1725,9 +3305,7 @@ Reply with \`apply ${resolved.resolved.selectedCandidates[0]?.index ?? 1}\` to a
1725
3305
  });
1726
3306
  app.post("/v1/proposals/:proposalId/approvals", async (c) => {
1727
3307
  const proposalId = c.req.param("proposalId");
1728
- const parsedBody = ApprovalDecisionInputSchema.safeParse(await c.req.json());
1729
- if (!parsedBody.success) return c.json({ error: "invalid_approval_decision" }, 400);
1730
- const body = parsedBody.data;
3308
+ const body = await parseDispatcherBody(c, ApprovalDecisionInputSchema, { invalidBodyError: "invalid_approval_decision" });
1731
3309
  const decision = await repo.recordApprovalDecision({
1732
3310
  id: body.id ?? `approval_${proposalId}_${Date.now()}`,
1733
3311
  proposalId,
@@ -1749,7 +3327,7 @@ Reply with \`apply ${resolved.resolved.selectedCandidates[0]?.index ?? 1}\` to a
1749
3327
  });
1750
3328
  app.post("/v1/proposals/:proposalId/apply-plans", async (c) => {
1751
3329
  const proposalId = c.req.param("proposalId");
1752
- const body = ApplyPlanInputSchema.parse(await c.req.json());
3330
+ const body = await parseDispatcherBody(c, ApplyPlanInputSchema);
1753
3331
  let executableTarget;
1754
3332
  if (body.execute) {
1755
3333
  if (body.adapter !== "github") {
@@ -1843,7 +3421,7 @@ Reply with \`apply ${resolved.resolved.selectedCandidates[0]?.index ?? 1}\` to a
1843
3421
  });
1844
3422
  app.post("/v1/runs/:runId/child-runs", async (c) => {
1845
3423
  const parentRunId = c.req.param("runId");
1846
- const body = ChildRunInputSchema.parse(await c.req.json());
3424
+ const body = await parseDispatcherBody(c, ChildRunInputSchema);
1847
3425
  const parent = await repo.getRun({ runId: parentRunId });
1848
3426
  if (!parent) return c.json({ error: "parent_run_not_found" }, 404);
1849
3427
  const receivedAt = (/* @__PURE__ */ new Date()).toISOString();
@@ -1869,6 +3447,19 @@ Reply with \`apply ${resolved.resolved.selectedCandidates[0]?.index ?? 1}\` to a
1869
3447
  if (!stored) return c.json({ error: "run_not_found" }, 404);
1870
3448
  return c.json(stored);
1871
3449
  });
3450
+ app.post("/v1/runs/:runId/cancel", async (c) => {
3451
+ const parsed = await parseDispatcherBody(c, CancelRunSchema);
3452
+ const outcome = await repo.cancelRun({
3453
+ runId: c.req.param("runId"),
3454
+ ...parsed.reason ? { reason: parsed.reason } : {},
3455
+ ...parsed.requestedBy ? { requestedBy: parsed.requestedBy } : {}
3456
+ });
3457
+ if (outcome.outcome === "not_found") return c.json({ error: "run_not_found" }, 404);
3458
+ if (outcome.outcome === "already_terminal") {
3459
+ return c.json({ error: "run_already_terminal", run: outcome.run }, 409);
3460
+ }
3461
+ return c.json({ outcome: "cancelled", run: outcome.run });
3462
+ });
1872
3463
  app.get("/v1/runs/:runId/metrics", async (c) => {
1873
3464
  const runId = c.req.param("runId");
1874
3465
  const stored = await repo.getRun({ runId });
@@ -1880,6 +3471,13 @@ Reply with \`apply ${resolved.resolved.selectedCandidates[0]?.index ?? 1}\` to a
1880
3471
  const events = await repo.listRunEvents({ runId: c.req.param("runId") });
1881
3472
  return c.json({ events });
1882
3473
  });
3474
+ app.onError((err, c) => {
3475
+ if (err instanceof HTTPException) {
3476
+ return err.getResponse();
3477
+ }
3478
+ console.error("dispatcher unhandled error", err);
3479
+ return c.json({ error: "internal_server_error" }, 500);
3480
+ });
1883
3481
  return app;
1884
3482
  }
1885
3483
  export {
@@ -1889,6 +3487,7 @@ export {
1889
3487
  createGitHubCallbackSink,
1890
3488
  createLarkCallbackSink,
1891
3489
  createSlackCallbackSink,
3490
+ createSlackSourceReceiptSink,
1892
3491
  createTelegramCallbackSink,
1893
3492
  processPendingCallbacks
1894
3493
  };