@opentag/dispatcher 0.3.0 → 0.3.2

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,5 +1,11 @@
1
1
  // src/callbacks.ts
2
- import { createLarkReplyClient, parseLarkThreadKey, replyLarkMessage } from "@opentag/lark";
2
+ import {
3
+ createLarkReplyClient,
4
+ patchLarkMessageCard,
5
+ parseLarkThreadKey,
6
+ replyLarkMessage,
7
+ updateLarkTextMessage
8
+ } from "@opentag/lark";
3
9
  import {
4
10
  createSlackPostMessagePayload,
5
11
  createSlackReactionPayload,
@@ -208,8 +214,27 @@ function createLarkCallbackSink(input) {
208
214
  if (!message.threadKey) {
209
215
  throw new Error("Lark callback message is missing threadKey.");
210
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
+ }
211
231
  const { messageId } = parseLarkThreadKey(message.threadKey);
212
- 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;
213
238
  }
214
239
  };
215
240
  }
@@ -268,70 +293,282 @@ function createTelegramCallbackSink(input) {
268
293
  function createCompositeCallbackSink(sinks) {
269
294
  return {
270
295
  async deliver(message) {
296
+ let result;
297
+ let delivered = false;
298
+ const failures = [];
271
299
  for (const sink of sinks) {
272
- 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
+ }
273
309
  }
310
+ if (!delivered && failures.length > 0) {
311
+ throw new AggregateError(failures, "Composite callback delivery failed for every sink.");
312
+ }
313
+ return result;
274
314
  }
275
315
  };
276
316
  }
277
317
 
278
318
  // src/presentation.ts
279
- import { renderAcknowledgement, renderFinalResult, renderProgress } from "@opentag/github";
280
- import { renderLarkAcknowledgement, renderLarkFinalResult } from "@opentag/lark";
281
- import { createSlackFinalResultBlocks, renderSlackAcknowledgement, renderSlackFinalResult } from "@opentag/slack";
282
- 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
+ }
283
459
  function createDefaultCallbackPresentation() {
284
460
  return {
285
461
  shouldDeliverAcknowledgement(provider) {
286
- return provider !== "lark" && provider !== "slack";
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);
287
470
  },
288
471
  shouldDeliverProgress(provider) {
289
- return provider !== "slack" && provider !== "lark";
472
+ return shouldDeliverCallbackProgress(provider);
290
473
  },
291
- acknowledgement(input) {
292
- if (input.provider === "slack") {
293
- 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);
294
511
  }
295
- if (input.provider === "lark") {
296
- return renderLarkAcknowledgement(input.runId);
512
+ if (input.presentation.kind === "doctor_summary") {
513
+ return renderDoctorSummary(input.provider, input.presentation);
297
514
  }
298
- if (input.provider === "telegram") {
299
- return renderTelegramAcknowledgement(input.runId);
515
+ if (input.presentation.kind === "source_thread_status") {
516
+ return renderSourceThreadStatus(input.provider, input.presentation);
300
517
  }
301
- return renderAcknowledgement(input.runId);
518
+ if (input.presentation.kind === "action_receipt") {
519
+ return renderActionReceipt(input.provider, input.presentation);
520
+ }
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
+ });
302
539
  },
303
540
  progress(input) {
304
- if (input.provider === "telegram") {
305
- return renderTelegramProgress(input.message);
306
- }
307
- 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;
308
548
  },
309
549
  final(input) {
310
- if (input.provider === "slack") {
311
- return {
312
- body: renderSlackFinalResult(input.result),
313
- blocks: createSlackFinalResultBlocks(input.result)
314
- };
315
- }
316
- if (input.provider === "lark") {
317
- return { body: renderLarkFinalResult(input.result) };
318
- }
319
- if (input.provider === "telegram") {
320
- return { body: renderTelegramFinalResult(input.result) };
321
- }
322
- 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
+ });
323
558
  }
324
559
  };
325
560
  }
326
561
 
327
562
  // src/server.ts
328
- import { createHash } from "crypto";
563
+ import { createHash, randomUUID } from "crypto";
329
564
  import {
330
565
  AdapterMutationMappingSchema,
331
566
  ActorIdentitySchema,
332
567
  ActionHintSchema,
568
+ capabilityForMutationIntent,
333
569
  conversationKeysFromEvent as conversationKeysFromEvent2,
334
570
  parseThreadActionCommand,
571
+ permissionScopesAllowCapability,
335
572
  projectTargetRefFromEvent as projectTargetRefFromEvent2,
336
573
  suggestedActionCandidatesFromSnapshots,
337
574
  createAdapterMutationCompilerRegistry,
@@ -339,7 +576,12 @@ import {
339
576
  OpenTagRunResultSchema,
340
577
  PolicyRuleSchema,
341
578
  RunEventImportanceSchema,
342
- RunEventVisibilitySchema
579
+ RunEventVisibilitySchema,
580
+ DEFAULT_MAX_REQUEST_BODY_BYTES,
581
+ RequestBodyTooLargeError,
582
+ platformCapabilityForProvider as platformCapabilityForProvider2,
583
+ readRequestTextWithLimit,
584
+ shouldDeliverSourceReceipt
343
585
  } from "@opentag/core";
344
586
  import {
345
587
  applyGitHubIssueMutationOperation,
@@ -494,26 +736,144 @@ function createAdmissionRuntime(input) {
494
736
  }
495
737
 
496
738
  // src/server.ts
497
- async function parseBody(c, schema) {
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 = {}) {
498
755
  let json;
499
756
  try {
500
- json = await c.req.json();
757
+ const rawBody = await readRequestTextWithLimit(c.req.raw, { maxBytes: options.maxBytes ?? DEFAULT_MAX_REQUEST_BODY_BYTES });
758
+ json = JSON.parse(rawBody);
501
759
  } catch (err) {
760
+ if (err instanceof RequestBodyTooLargeError) throw requestBodyTooLarge(c, err.maxBytes);
761
+ if (err instanceof HTTPException) throw err;
502
762
  if (err instanceof SyntaxError) {
503
763
  throw new HTTPException(400, {
504
- res: c.json({ error: "invalid_json_body" }, 400)
764
+ res: c.json({ error: "invalid_json_body" }, 400),
765
+ cause: new RequestBodyRejectedError({ reason: "invalid_json_body" })
505
766
  });
506
767
  }
507
768
  throw err;
508
769
  }
509
770
  const result = schema.safeParse(json);
510
771
  if (!result.success) {
772
+ const publicError = options.invalidBodyError ?? "invalid_request_body";
511
773
  throw new HTTPException(400, {
512
- res: c.json({ error: "invalid_request_body", issues: result.error.issues }, 400)
774
+ res: c.json({ error: publicError, issues: result.error.issues }, 400),
775
+ cause: new RequestBodyRejectedError({ reason: "invalid_request_body", publicError })
513
776
  });
514
777
  }
515
778
  return result.data;
516
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
+ }
517
877
  var CreateRunnerSchema = z.object({
518
878
  runnerId: z.string().min(1),
519
879
  name: z.string().min(1)
@@ -553,11 +913,32 @@ var CreateRunSchema = z.object({
553
913
  runId: z.string().min(1),
554
914
  event: OpenTagEventSchema
555
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
+ });
556
927
  var PromoteFollowUpRequestSchema = z.object({
557
928
  runId: z.string().min(1)
558
929
  });
559
930
  var CompleteRunSchema = z.object({
560
- 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()
561
942
  });
562
943
  var ApprovalDecisionInputSchema = z.object({
563
944
  id: z.string().min(1).optional(),
@@ -599,13 +980,38 @@ var ChildRunInputSchema = z.object({
599
980
  sourceProposalId: z.string().min(1).optional(),
600
981
  sourceApplyPlanId: z.string().min(1).optional()
601
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
+ ];
602
997
  var ProgressSchema = z.object({
603
998
  type: z.string().min(1).optional(),
604
999
  message: z.string().min(1),
605
1000
  at: z.string().datetime().optional(),
606
1001
  visibility: RunEventVisibilitySchema.optional(),
607
- importance: RunEventImportanceSchema.optional()
1002
+ importance: RunEventImportanceSchema.optional(),
1003
+ idempotencyKey: z.string().min(1).max(256).optional()
608
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
+ }
609
1015
  function childEventFromParent(input) {
610
1016
  return {
611
1017
  ...input.parentEvent,
@@ -621,10 +1027,7 @@ function childEventFromParent(input) {
621
1027
  actionKind: input.actionKind
622
1028
  }
623
1029
  },
624
- metadata: {
625
- ...input.parentEvent.metadata,
626
- ...input.metadata ?? {}
627
- },
1030
+ metadata: childEventMetadata(input.parentEvent.metadata, input.metadata),
628
1031
  permissions: input.permissions ?? input.parentEvent.permissions
629
1032
  };
630
1033
  }
@@ -647,6 +1050,28 @@ function metadataString2(metadata, key) {
647
1050
  const value = metadata?.[key];
648
1051
  return typeof value === "string" && value.length > 0 ? value : void 0;
649
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
+ }
650
1075
  function githubIssueWorkItemExternalId(metadata) {
651
1076
  const owner = metadataString2(metadata, "owner");
652
1077
  const repo = metadataString2(metadata, "repo");
@@ -761,6 +1186,30 @@ async function resolveThreadAction(input) {
761
1186
  });
762
1187
  const primaryConversationKey = conversationKeys[0];
763
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
+ }
764
1213
  if (input.command.selection.kind === "proposal") {
765
1214
  const stored = await input.repo.getSuggestedChanges({ proposalId: input.command.selection.proposalId });
766
1215
  if (!stored) {
@@ -806,6 +1255,240 @@ function isRepoLevelGitHubIntent(intent) {
806
1255
  function adapterForAction(input) {
807
1256
  return hasGitHubRepoTarget(input.event) && (hasGitHubIssueOrPullTarget(input.event) || input.selectedIntents.length > 0 && input.selectedIntents.every((intent) => isRepoLevelGitHubIntent(intent))) ? "github" : input.callbackProvider;
808
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
+ }
809
1492
  async function authorizeThreadAction(input) {
810
1493
  const repoKey = projectTargetRefFromEvent2(input.resolved.proposal.event);
811
1494
  if (!repoKey) {
@@ -888,6 +1571,13 @@ function selectedIntentsAlreadyApplied(input) {
888
1571
  (intentId) => input.plan.outcomes?.some((outcome) => outcome.intentId === intentId && outcome.outcome === "applied")
889
1572
  );
890
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
+ }
891
1581
  function githubTargetFromEvent(event) {
892
1582
  const owner = event.metadata["owner"];
893
1583
  const repoName = event.metadata["repo"];
@@ -906,6 +1596,12 @@ function githubTargetFromEvent(event) {
906
1596
  function selectedActionSummary(candidates) {
907
1597
  return candidates.map((candidate) => `${candidate.index}. ${candidate.intent.summary}`).join("; ");
908
1598
  }
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
+ }
909
1605
  function addPermissionGrant(permissions, grant) {
910
1606
  if (permissions.some((permission) => permission.scope === grant.scope)) return permissions;
911
1607
  return [...permissions, grant];
@@ -930,73 +1626,93 @@ function childRunPermissionsForThreadAction(input) {
930
1626
  }
931
1627
  return permissions;
932
1628
  }
933
- function childRunContextLines(input) {
934
- const previousSummary = input.resolved.proposal.run.result?.summary ?? input.resolved.proposal.snapshot.summary;
935
- return [
936
- `- Proposal: \`${input.resolved.proposal.snapshot.proposalId}\``,
937
- `- Selected intents: ${input.resolved.selectedIntentIds.map((intentId) => `\`${intentId}\``).join(", ")}`,
938
- `- Previous run: \`${input.resolved.proposal.runId}\``,
939
- ...input.approvalDecisionId ? [`- Approval decision: \`${input.approvalDecisionId}\``] : [],
940
- `- Previous result: ${previousSummary}`,
941
- ...input.sourceApplyPlanId ? [`- Apply plan: \`${input.sourceApplyPlanId}\``] : [],
942
- ...input.fallbackReason ? [`- Fallback reason: ${input.fallbackReason}`] : []
943
- ];
944
- }
945
1629
  function renderChildRunCreatedBody(input) {
1630
+ const title = selectedActionReceiptTitle(input.selectionText ?? selectedActionSummary(input.resolved.selectedCandidates));
946
1631
  if (input.provider === "slack") {
947
1632
  return [
948
- "Approved. OpenTag will continue from this proposal in a follow-up run.",
1633
+ input.lead,
1634
+ `Action: ${title}`,
949
1635
  ...input.fallbackReason ? [`Reason: ${input.fallbackReason}`] : []
950
1636
  ].join("\n");
951
1637
  }
952
1638
  return [
953
1639
  input.lead,
954
1640
  "",
955
- `Child run: \`${input.childRun.id}\``,
1641
+ `Action: ${title}`,
956
1642
  "",
957
- "Context carried into the child run:",
958
- ...childRunContextLines(input),
1643
+ `Child run: \`${input.childRun.id}\``,
959
1644
  "",
960
- "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.`
961
1647
  ].join("\n");
962
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
+ }
963
1664
  function renderAppliedThreadActionBody(input) {
964
1665
  const selectedOutcomes = input.outcomes.filter((outcome) => input.selectedIntentIds.includes(outcome.intentId));
965
- if (input.provider === "slack") {
966
- const lines = [`Applied ${input.selectionText}.`];
967
- for (const outcome of selectedOutcomes) {
968
- if (outcome.externalUri) {
969
- lines.push(`Result: ${outcome.externalUri}`);
970
- } else if (outcome.message) {
971
- lines.push(`Result: ${outcome.outcome}. ${outcome.message}`);
972
- } else {
973
- lines.push(`Result: ${outcome.outcome}.`);
974
- }
975
- }
976
- return lines.join("\n");
977
- }
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) {
978
1675
  return [
979
- `Applied ${input.selectionText} from \`${input.proposalId}\`.`,
980
- "",
981
- "Result:",
982
- ...selectedOutcomes.map((outcome) => `- \`${outcome.intentId}\`: ${outcome.outcome}${outcome.externalUri ? ` (${outcome.externalUri})` : ""}`)
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.`
983
1679
  ].join("\n");
984
1680
  }
985
1681
  function renderThreadActionRecordedBody(input) {
986
- const pastTense = input.verb === "approve" ? "Approved" : "Rejected";
987
- if (input.provider === "slack") {
988
- if (input.verb === "approve") {
989
- return `${pastTense} ${input.selectionText}.
990
- Next: use Apply ${input.applyIndex ?? 1} when you want OpenTag to perform it.`;
991
- }
992
- return `${pastTense} ${input.selectionText}.`;
993
- }
1682
+ const title = selectedActionReceiptTitle(input.selectionText);
994
1683
  if (input.verb === "approve") {
995
- return `${pastTense} ${input.selectionText} from \`${input.proposalId}\`.
996
-
997
- Reply with \`apply ${input.applyIndex ?? 1}\` to apply it, or \`continue ${input.applyIndex ?? 1}\` to continue with a follow-up run.`;
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");
998
1694
  }
999
- return `${pastTense} ${input.selectionText} from \`${input.proposalId}\`.`;
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 };
1000
1716
  }
1001
1717
  function actionContextPointer(input) {
1002
1718
  const lines = [
@@ -1156,7 +1872,13 @@ function nextCallbackAttemptAt(input) {
1156
1872
  }
1157
1873
  async function deliverCallbackDelivery(input) {
1158
1874
  try {
1159
- 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({
1160
1882
  runId: input.delivery.runId,
1161
1883
  kind: input.delivery.kind,
1162
1884
  provider: input.delivery.provider,
@@ -1165,15 +1887,23 @@ async function deliverCallbackDelivery(input) {
1165
1887
  ...input.delivery.threadKey ? { threadKey: input.delivery.threadKey } : {},
1166
1888
  ...input.delivery.agentId ? { agentId: input.delivery.agentId } : {},
1167
1889
  ...input.delivery.statusMessageKey ? { statusMessageKey: input.delivery.statusMessageKey } : {},
1168
- ...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 } : {}
1169
1898
  });
1170
- await input.repo.markCallbackDelivered({ deliveryId: input.delivery.id });
1171
1899
  return true;
1172
1900
  } catch (error) {
1901
+ const maxAttempts = input.retry?.maxAttempts ?? 5;
1173
1902
  const nextAttemptAt = nextCallbackAttemptAt({ attempts: input.delivery.attempts, ...input.retry ?? {} });
1174
1903
  await input.repo.markCallbackFailed({
1175
1904
  deliveryId: input.delivery.id,
1176
1905
  error: error instanceof Error ? error.message : String(error),
1906
+ maxAttempts,
1177
1907
  ...nextAttemptAt ? { nextAttemptAt } : {}
1178
1908
  });
1179
1909
  return false;
@@ -1213,7 +1943,8 @@ async function deliverAndAudit(input) {
1213
1943
  ...input.message.threadKey ? { threadKey: input.message.threadKey } : {},
1214
1944
  ...input.message.agentId ? { agentId: input.message.agentId } : {},
1215
1945
  ...input.message.statusMessageKey ? { statusMessageKey: input.message.statusMessageKey } : {},
1216
- ...input.message.blocks ? { blocks: input.message.blocks } : {}
1946
+ ...input.message.blocks ? { blocks: input.message.blocks } : {},
1947
+ ...input.message.rich ? { rich: input.message.rich } : {}
1217
1948
  });
1218
1949
  await deliverCallbackDelivery({
1219
1950
  repo: input.repo,
@@ -1254,9 +1985,81 @@ async function deliverSourceReceiptBestEffort(input) {
1254
1985
  return { delivered: false };
1255
1986
  }
1256
1987
  }
1257
- function isAuthorized(request, pairingToken) {
1258
- if (!pairingToken) return true;
1259
- return request.headers.get("authorization") === `Bearer ${pairingToken}`;
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" };
1260
2063
  }
1261
2064
  function createDispatcherApp(input) {
1262
2065
  const sqlite = new Database(input.databasePath);
@@ -1267,20 +2070,234 @@ function createDispatcherApp(input) {
1267
2070
  const sourceReceiptSink = input.sourceReceiptSink ?? noopSourceReceiptSink;
1268
2071
  const presentation = input.presentation ?? createDefaultCallbackPresentation();
1269
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
+ }
1270
2204
  const admission = createAdmissionRuntime({
1271
2205
  repo,
1272
2206
  ...input.agentAccessProfileCheck ? { agentAccessProfileCheck: input.agentAccessProfileCheck } : {}
1273
2207
  });
1274
2208
  app.get("/healthz", (c) => c.json({ ok: true }));
2209
+ if (input.rateLimit) {
2210
+ app.use("/v1/*", createDispatcherRateLimitMiddleware(input.rateLimit));
2211
+ }
1275
2212
  app.use("/v1/*", async (c, next) => {
1276
- if (!isAuthorized(c.req.raw, input.pairingToken)) {
1277
- 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
+ );
1278
2239
  }
1279
2240
  await next();
1280
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
+ });
1281
2289
  app.post("/v1/runners", async (c) => {
1282
- const parsed = await parseBody(c, CreateRunnerSchema);
2290
+ const parsed = await parseDispatcherBody(c, CreateRunnerSchema);
1283
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
+ });
1284
2301
  return c.json({ ok: true }, 201);
1285
2302
  });
1286
2303
  app.get("/v1/runners/:runnerId", async (c) => {
@@ -1289,7 +2306,7 @@ function createDispatcherApp(input) {
1289
2306
  return c.json({ runner });
1290
2307
  });
1291
2308
  app.post("/v1/repo-bindings", async (c) => {
1292
- const parsed = await parseBody(c, CreateRepoBindingSchema);
2309
+ const parsed = await parseDispatcherBody(c, CreateRepoBindingSchema);
1293
2310
  await repo.createRepoBinding({
1294
2311
  provider: parsed.provider,
1295
2312
  owner: parsed.owner,
@@ -1299,6 +2316,20 @@ function createDispatcherApp(input) {
1299
2316
  ...parsed.defaultExecutor ? { defaultExecutor: parsed.defaultExecutor } : {},
1300
2317
  ...parsed.allowedActors?.length ? { allowedActors: parsed.allowedActors } : {}
1301
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
+ });
1302
2333
  return c.json({ ok: true }, 201);
1303
2334
  });
1304
2335
  app.get("/v1/repo-bindings/:provider/:owner/:repo", async (c) => {
@@ -1311,13 +2342,32 @@ function createDispatcherApp(input) {
1311
2342
  return c.json({ binding });
1312
2343
  });
1313
2344
  app.post("/v1/repo-bindings/:provider/:owner/:repo/policy-rules", async (c) => {
1314
- const parsed = await parseBody(c, UpsertPolicyRuleSchema);
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");
1315
2349
  const rule = await repo.upsertRepoPolicyRule({
1316
- provider: c.req.param("provider"),
1317
- owner: c.req.param("owner"),
1318
- repo: c.req.param("repo"),
2350
+ provider,
2351
+ owner,
2352
+ repo: repoName,
1319
2353
  rule: parsed.rule
1320
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
+ });
1321
2371
  return c.json({ rule }, 201);
1322
2372
  });
1323
2373
  app.get("/v1/repo-bindings/:provider/:owner/:repo/policy-rules", async (c) => {
@@ -1329,13 +2379,32 @@ function createDispatcherApp(input) {
1329
2379
  return c.json({ rules });
1330
2380
  });
1331
2381
  app.post("/v1/repo-bindings/:provider/:owner/:repo/mutation-mappings", async (c) => {
1332
- const parsed = await parseBody(c, UpsertMutationMappingSchema);
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");
1333
2386
  const mapping = await repo.upsertRepoMutationMapping({
1334
- provider: c.req.param("provider"),
1335
- owner: c.req.param("owner"),
1336
- repo: c.req.param("repo"),
2387
+ provider,
2388
+ owner,
2389
+ repo: repoName,
1337
2390
  mapping: parsed.mapping
1338
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
+ });
1339
2408
  return c.json({ mapping }, 201);
1340
2409
  });
1341
2410
  app.get("/v1/repo-bindings/:provider/:owner/:repo/mutation-mappings", async (c) => {
@@ -1361,7 +2430,7 @@ function createDispatcherApp(input) {
1361
2430
  return c.json({ metrics });
1362
2431
  });
1363
2432
  app.post("/v1/channel-bindings", async (c) => {
1364
- const parsed = await parseBody(c, CreateChannelBindingSchema);
2433
+ const parsed = await parseDispatcherBody(c, CreateChannelBindingSchema);
1365
2434
  await repo.upsertChannelBinding({
1366
2435
  provider: parsed.provider,
1367
2436
  accountId: parsed.accountId,
@@ -1371,6 +2440,20 @@ function createDispatcherApp(input) {
1371
2440
  repo: parsed.repo,
1372
2441
  ...parsed.metadata ? { metadata: parsed.metadata } : {}
1373
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
+ });
1374
2457
  return c.json({ ok: true }, 201);
1375
2458
  });
1376
2459
  app.get("/v1/channel-bindings/:provider/:accountId/:conversationId", async (c) => {
@@ -1382,9 +2465,94 @@ function createDispatcherApp(input) {
1382
2465
  if (!binding) return c.json({ error: "channel_binding_not_found" }, 404);
1383
2466
  return c.json({ binding });
1384
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
+ });
1385
2538
  app.post("/v1/slack-channel-bindings", async (c) => {
1386
- const parsed = await parseBody(c, CreateSlackChannelBindingSchema);
2539
+ const parsed = await parseDispatcherBody(c, CreateSlackChannelBindingSchema);
1387
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
+ });
1388
2556
  return c.json({ ok: true }, 201);
1389
2557
  });
1390
2558
  app.get("/v1/slack-channel-bindings/:teamId/:channelId", async (c) => {
@@ -1396,43 +2564,69 @@ function createDispatcherApp(input) {
1396
2564
  return c.json({ binding });
1397
2565
  });
1398
2566
  app.post("/v1/runs", async (c) => {
1399
- const parsed = await parseBody(c, CreateRunSchema);
2567
+ const parsed = await parseDispatcherBody(c, CreateRunSchema);
1400
2568
  const admitted = await admission.admitRun({ requestId: parsed.runId, event: parsed.event });
1401
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
+ });
1402
2583
  return c.json({ decision: admitted.decision }, 202);
1403
2584
  }
1404
2585
  if (admitted.outcome === "drop_duplicate") {
1405
- await repo.appendRunEvent({
1406
- runId: admitted.run.id,
1407
- type: "admission.decided",
1408
- payload: admitted.decision,
1409
- visibility: "audit",
1410
- importance: "normal",
1411
- message: admitted.decision.reason
1412
- });
1413
- await repo.appendRunEvent({
1414
- runId: admitted.run.id,
1415
- type: "run.create_idempotent_replay",
1416
- payload: { requestedRunId: parsed.runId, eventId: parsed.event.id },
1417
- visibility: "audit",
1418
- importance: "low"
1419
- });
1420
- 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);
1421
2589
  }
1422
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
+ }
1423
2623
  return c.json({ decision: admitted.decision, followUpRequest: admitted.followUpRequest }, 202);
1424
2624
  }
1425
2625
  const createdRun = await repo.createRun({ id: parsed.runId, event: parsed.event });
1426
2626
  if (!createdRun.created) {
1427
2627
  return c.json(
1428
2628
  {
1429
- decision: {
1430
- ...admitted.decision,
1431
- action: "drop_duplicate",
1432
- reason: "Source event already created a run.",
1433
- reasonCode: "duplicate_source_event",
1434
- activeRunId: createdRun.run.id
1435
- },
2629
+ decision: createdRun.replayDecision,
1436
2630
  run: createdRun.run,
1437
2631
  idempotentReplay: true
1438
2632
  },
@@ -1451,8 +2645,14 @@ function createDispatcherApp(input) {
1451
2645
  ...parsed.event.target.agentId ? { agentId: parsed.event.target.agentId } : {}
1452
2646
  }
1453
2647
  });
1454
- const shouldDeliverAcknowledgement = presentation.shouldDeliverAcknowledgement(parsed.event.callback.provider) || parsed.event.callback.provider === "slack" && !sourceReceiptDelivery.delivered;
2648
+ const shouldDeliverAcknowledgement = presentation.shouldDeliverAcknowledgement(parsed.event.callback.provider) || shouldDeliverSourceReceipt(parsed.event.callback.provider) && !sourceReceiptDelivery.delivered;
1455
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 });
1456
2656
  await deliverAndAudit({
1457
2657
  repo,
1458
2658
  sink: callbackSink,
@@ -1462,16 +2662,19 @@ function createDispatcherApp(input) {
1462
2662
  kind: "acknowledgement",
1463
2663
  provider: parsed.event.callback.provider,
1464
2664
  uri: parsed.event.callback.uri,
1465
- body: presentation.acknowledgement({ provider: parsed.event.callback.provider, runId: run.id }),
2665
+ body: acknowledgement.body,
1466
2666
  ...parsed.event.target.agentId ? { agentId: parsed.event.target.agentId } : {},
1467
- ...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 } : {}
1468
2671
  }
1469
2672
  });
1470
2673
  }
1471
2674
  return c.json({ decision: admitted.decision, run }, 201);
1472
2675
  });
1473
2676
  app.post("/v1/thread-actions", async (c) => {
1474
- const parsed = await parseBody(c, ThreadActionInputSchema);
2677
+ const parsed = await parseDispatcherBody(c, ThreadActionInputSchema);
1475
2678
  const command = parseThreadActionCommand(parsed.rawText);
1476
2679
  if (!command) {
1477
2680
  return c.json({ outcome: "ignored", reason: "not_action_command" }, 202);
@@ -1523,22 +2726,24 @@ function createDispatcherApp(input) {
1523
2726
  if (existingPlan) {
1524
2727
  const existingDecision2 = await repo.getApprovalDecision({ id: existingPlan.approvalDecisionId });
1525
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
+ });
1526
2742
  return c.json({ outcome: "already_applied", decision: existingDecision2, plan: existingPlan }, 200);
1527
2743
  }
1528
- const fallbackReason = "An apply plan already exists for this selected action; OpenTag will not execute it again from a repeated thread reply.";
1529
- const childRun2 = await createChildRunForThreadAction({
1530
- repo,
1531
- command,
1532
- resolved: resolved.resolved,
1533
- runId: stableChildRunId({
1534
- command,
1535
- resolved: resolved.resolved,
1536
- sourceApplyPlanId: existingPlan.id,
1537
- fallbackReason
1538
- }),
1539
- approvalDecisionId: existingPlan.approvalDecisionId,
1540
- sourceApplyPlanId: existingPlan.id,
1541
- fallbackReason
2744
+ const isStale = selectedIntentsHaveStaleOutcome({
2745
+ plan: existingPlan,
2746
+ selectedIntentIds: resolved.resolved.selectedIntentIds
1542
2747
  });
1543
2748
  await deliverAndAudit({
1544
2749
  repo,
@@ -1549,19 +2754,14 @@ function createDispatcherApp(input) {
1549
2754
  kind: "final",
1550
2755
  provider: parsed.callback.provider,
1551
2756
  uri: parsed.callback.uri,
1552
- body: renderChildRunCreatedBody({
1553
- lead: "This action was already planned, so OpenTag will not execute the external write again.",
1554
- resolved: resolved.resolved,
1555
- childRun: childRun2,
1556
- provider: parsed.callback.provider,
1557
- approvalDecisionId: existingPlan.approvalDecisionId,
1558
- sourceApplyPlanId: existingPlan.id,
1559
- fallbackReason
1560
- }),
2757
+ body: isStale ? renderStaleThreadActionBody({
2758
+ selectionText,
2759
+ continueIndex: resolved.resolved.selectedCandidates[0]?.index ?? 1
2760
+ }) : renderAlreadyPlannedThreadActionBody({ selectionText }),
1561
2761
  ...parsed.callback.threadKey ? { threadKey: parsed.callback.threadKey } : {}
1562
2762
  }
1563
2763
  });
1564
- 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);
1565
2765
  }
1566
2766
  }
1567
2767
  const providedDecision = parsed.id ? await repo.getApprovalDecision({ id: parsed.id }) : null;
@@ -1603,10 +2803,8 @@ function createDispatcherApp(input) {
1603
2803
  return c.json({ outcome: "already_rejected", decision }, 200);
1604
2804
  }
1605
2805
  const body2 = renderThreadActionRecordedBody({
1606
- provider: parsed.callback.provider,
1607
2806
  verb: "reject",
1608
- selectionText,
1609
- proposalId: resolved.resolved.proposal.snapshot.proposalId
2807
+ selectionText
1610
2808
  });
1611
2809
  await deliverAndAudit({
1612
2810
  repo,
@@ -1627,12 +2825,17 @@ function createDispatcherApp(input) {
1627
2825
  if (existingDecision) {
1628
2826
  return c.json({ outcome: "already_approved", decision }, 200);
1629
2827
  }
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
+ });
1630
2834
  const body2 = renderThreadActionRecordedBody({
1631
- provider: parsed.callback.provider,
1632
2835
  verb: "approve",
1633
2836
  selectionText,
1634
- proposalId: resolved.resolved.proposal.snapshot.proposalId,
1635
- applyIndex: resolved.resolved.selectedCandidates[0]?.index ?? 1
2837
+ applyIndex: resolved.resolved.selectedCandidates[0]?.index ?? 1,
2838
+ directApply
1636
2839
  });
1637
2840
  await deliverAndAudit({
1638
2841
  repo,
@@ -1658,10 +2861,11 @@ function createDispatcherApp(input) {
1658
2861
  approvalDecisionId: decision.id
1659
2862
  });
1660
2863
  const body2 = renderChildRunCreatedBody({
1661
- lead: `Continuing from ${selectionText} in \`${resolved.resolved.proposal.snapshot.proposalId}\`.`,
2864
+ lead: "Continuing in OpenTag from this approved action.",
1662
2865
  resolved: resolved.resolved,
1663
2866
  childRun: childRun2,
1664
2867
  provider: parsed.callback.provider,
2868
+ selectionText,
1665
2869
  approvalDecisionId: decision.id
1666
2870
  });
1667
2871
  await deliverAndAudit({
@@ -1691,22 +2895,24 @@ function createDispatcherApp(input) {
1691
2895
  }
1692
2896
  if (!planResult.created) {
1693
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
+ });
1694
2911
  return c.json({ outcome: "already_applied", decision, plan: planResult.plan }, 200);
1695
2912
  }
1696
- const fallbackReason = "An apply plan already exists for this selected action; OpenTag will not execute it again from a repeated thread reply.";
1697
- const childRun2 = await createChildRunForThreadAction({
1698
- repo,
1699
- command,
1700
- resolved: resolved.resolved,
1701
- runId: stableChildRunId({
1702
- command,
1703
- resolved: resolved.resolved,
1704
- sourceApplyPlanId: planResult.plan.id,
1705
- fallbackReason
1706
- }),
1707
- approvalDecisionId: decision.id,
1708
- sourceApplyPlanId: planResult.plan.id,
1709
- fallbackReason
2913
+ const isStale = selectedIntentsHaveStaleOutcome({
2914
+ plan: planResult.plan,
2915
+ selectedIntentIds: resolved.resolved.selectedIntentIds
1710
2916
  });
1711
2917
  await deliverAndAudit({
1712
2918
  repo,
@@ -1717,19 +2923,14 @@ function createDispatcherApp(input) {
1717
2923
  kind: "final",
1718
2924
  provider: parsed.callback.provider,
1719
2925
  uri: parsed.callback.uri,
1720
- body: renderChildRunCreatedBody({
1721
- lead: "This action was already planned, so OpenTag will not execute the external write again.",
1722
- resolved: resolved.resolved,
1723
- childRun: childRun2,
1724
- provider: parsed.callback.provider,
1725
- approvalDecisionId: decision.id,
1726
- sourceApplyPlanId: planResult.plan.id,
1727
- fallbackReason
1728
- }),
2926
+ body: isStale ? renderStaleThreadActionBody({
2927
+ selectionText,
2928
+ continueIndex: resolved.resolved.selectedCandidates[0]?.index ?? 1
2929
+ }) : renderAlreadyPlannedThreadActionBody({ selectionText }),
1729
2930
  ...parsed.callback.threadKey ? { threadKey: parsed.callback.threadKey } : {}
1730
2931
  }
1731
2932
  });
1732
- 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);
1733
2934
  }
1734
2935
  const plan = planResult.plan;
1735
2936
  const execution = await executeGitHubApplyPlan({
@@ -1741,9 +2942,7 @@ function createDispatcherApp(input) {
1741
2942
  if (execution.executed) {
1742
2943
  const outcomes = execution.plan.outcomes ?? [];
1743
2944
  const body2 = renderAppliedThreadActionBody({
1744
- provider: parsed.callback.provider,
1745
2945
  selectionText,
1746
- proposalId: resolved.resolved.proposal.snapshot.proposalId,
1747
2946
  selectedIntentIds: resolved.resolved.selectedIntentIds,
1748
2947
  outcomes
1749
2948
  });
@@ -1762,6 +2961,25 @@ function createDispatcherApp(input) {
1762
2961
  });
1763
2962
  return c.json({ outcome: "applied", decision, plan: execution.plan }, 201);
1764
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
+ }
1765
2983
  const childRun = await createChildRunForThreadAction({
1766
2984
  repo,
1767
2985
  command,
@@ -1777,10 +2995,11 @@ function createDispatcherApp(input) {
1777
2995
  fallbackReason: execution.fallbackReason ?? "OpenTag cannot directly apply this intent yet."
1778
2996
  });
1779
2997
  const body = renderChildRunCreatedBody({
1780
- lead: `Action ${selectionText} was approved, but OpenTag cannot directly apply it yet.`,
2998
+ lead: "Needs setup before OpenTag can apply this action directly.",
1781
2999
  resolved: resolved.resolved,
1782
3000
  childRun,
1783
3001
  provider: parsed.callback.provider,
3002
+ selectionText,
1784
3003
  approvalDecisionId: decision.id,
1785
3004
  sourceApplyPlanId: execution.plan.id,
1786
3005
  fallbackReason: execution.fallbackReason ?? "The adapter could not execute the selected intent."
@@ -1806,10 +3025,10 @@ function createDispatcherApp(input) {
1806
3025
  return c.json({ followUpRequest });
1807
3026
  });
1808
3027
  app.post("/v1/follow-up-requests/:id/create-run", async (c) => {
1809
- const parsed = await parseBody(c, PromoteFollowUpRequestSchema);
3028
+ const parsed = await parseDispatcherBody(c, PromoteFollowUpRequestSchema);
1810
3029
  let promoted;
1811
3030
  try {
1812
- promoted = await repo.createRunFromFollowUpRequest({
3031
+ promoted = await promoteFollowUpRequest({
1813
3032
  followUpRequestId: c.req.param("id"),
1814
3033
  runId: parsed.runId
1815
3034
  });
@@ -1824,23 +3043,6 @@ function createDispatcherApp(input) {
1824
3043
  throw error;
1825
3044
  }
1826
3045
  const followUpRequest = promoted.followUpRequest;
1827
- const event = followUpRequest.event;
1828
- if (presentation.shouldDeliverAcknowledgement(event.callback.provider)) {
1829
- await deliverAndAudit({
1830
- repo,
1831
- sink: callbackSink,
1832
- retry: callbackRetry,
1833
- message: {
1834
- runId: promoted.run.id,
1835
- kind: "acknowledgement",
1836
- provider: event.callback.provider,
1837
- uri: event.callback.uri,
1838
- body: presentation.acknowledgement({ provider: event.callback.provider, runId: promoted.run.id }),
1839
- ...event.target.agentId ? { agentId: event.target.agentId } : {},
1840
- ...event.callback.threadKey ? { threadKey: event.callback.threadKey } : {}
1841
- }
1842
- });
1843
- }
1844
3046
  return c.json({ followUpRequest, run: promoted.run }, 201);
1845
3047
  });
1846
3048
  app.post("/v1/runners/:runnerId/claim", async (c) => {
@@ -1860,13 +3062,67 @@ function createDispatcherApp(input) {
1860
3062
  }, 410);
1861
3063
  });
1862
3064
  app.post("/v1/runners/:runnerId/runs/:runId/running", async (c) => {
1863
- const body = await parseBody(c, z.object({ executor: z.string().min(1) }));
1864
- const ok = await repo.markRunning({
1865
- 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,
1866
3071
  runnerId: c.req.param("runnerId"),
1867
- executor: body.executor
3072
+ executor: body.executor,
3073
+ ...body.runTimeoutMs ? { runTimeoutMs: body.runTimeoutMs } : {},
3074
+ ...idempotencyKey ? { idempotencyKey } : {}
1868
3075
  });
1869
- 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
+ }
1870
3126
  return c.json({ ok: true });
1871
3127
  });
1872
3128
  app.post("/v1/runs/:runId/progress", async () => {
@@ -1877,20 +3133,31 @@ function createDispatcherApp(input) {
1877
3133
  });
1878
3134
  app.post("/v1/runners/:runnerId/runs/:runId/progress", async (c) => {
1879
3135
  const runId = c.req.param("runId");
1880
- const body = await parseBody(c, ProgressSchema);
1881
- 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({
1882
3140
  runId,
1883
3141
  runnerId: c.req.param("runnerId"),
1884
3142
  message: body.message,
1885
3143
  ...body.type ? { type: body.type } : {},
1886
3144
  ...body.at ? { at: body.at } : {},
1887
3145
  ...body.visibility ? { visibility: body.visibility } : {},
1888
- ...body.importance ? { importance: body.importance } : {}
3146
+ ...body.importance ? { importance: body.importance } : {},
3147
+ ...idempotencyKey ? { idempotencyKey } : {}
1889
3148
  });
1890
- 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 });
1891
3151
  const stored = await repo.getRun({ runId });
1892
3152
  if (!stored) return c.json({ error: "run_not_found" }, 404);
1893
- 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
+ });
1894
3161
  await deliverAndAudit({
1895
3162
  repo,
1896
3163
  sink: callbackSink,
@@ -1900,12 +3167,29 @@ function createDispatcherApp(input) {
1900
3167
  kind: "progress",
1901
3168
  provider: stored.event.callback.provider,
1902
3169
  uri: stored.event.callback.uri,
1903
- body: presentation.progress({ provider: stored.event.callback.provider, runId, message: body.message }),
3170
+ body: progress.body,
1904
3171
  ...stored.event.target.agentId ? { agentId: stored.event.target.agentId } : {},
1905
3172
  ...stored.event.callback.threadKey ? { threadKey: stored.event.callback.threadKey } : {},
3173
+ ...progress.blocks?.length ? { blocks: progress.blocks } : {},
3174
+ ...progress.rich ? { rich: progress.rich } : {},
1906
3175
  statusMessageKey: `${runId}:status`
1907
3176
  }
1908
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
+ });
1909
3193
  }
1910
3194
  return c.json({ ok: true });
1911
3195
  });
@@ -1917,12 +3201,64 @@ function createDispatcherApp(input) {
1917
3201
  });
1918
3202
  app.post("/v1/runners/:runnerId/runs/:runId/complete", async (c) => {
1919
3203
  const runId = c.req.param("runId");
1920
- const parsed = await parseBody(c, CompleteRunSchema);
1921
- const ok = await repo.completeRun({ runId, runnerId: c.req.param("runnerId"), result: parsed.result });
1922
- 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 });
1923
3215
  const stored = await repo.getRun({ runId });
1924
3216
  if (!stored) return c.json({ error: "run_not_found" }, 404);
1925
- 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 });
1926
3262
  await deliverAndAudit({
1927
3263
  repo,
1928
3264
  sink: callbackSink,
@@ -1932,13 +3268,25 @@ function createDispatcherApp(input) {
1932
3268
  kind: "final",
1933
3269
  provider: stored.event.callback.provider,
1934
3270
  uri: stored.event.callback.uri,
1935
- body: finalPresentation.body,
3271
+ body: finalCallback.body,
1936
3272
  ...stored.event.target.agentId ? { agentId: stored.event.target.agentId } : {},
1937
3273
  ...stored.event.callback.threadKey ? { threadKey: stored.event.callback.threadKey } : {},
1938
- ...finalPresentation.blocks?.length ? { blocks: finalPresentation.blocks } : {}
3274
+ ...statusMessageKey ? { statusMessageKey } : {},
3275
+ ...finalCallback.blocks?.length ? { blocks: finalCallback.blocks } : {},
3276
+ ...finalCallback.rich ? { rich: finalCallback.rich } : {}
1939
3277
  }
1940
3278
  });
1941
- 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
+ });
1942
3290
  });
1943
3291
  app.get("/v1/proposals/:proposalId", async (c) => {
1944
3292
  const proposal = await repo.getSuggestedChanges({ proposalId: c.req.param("proposalId") });
@@ -1957,18 +3305,7 @@ function createDispatcherApp(input) {
1957
3305
  });
1958
3306
  app.post("/v1/proposals/:proposalId/approvals", async (c) => {
1959
3307
  const proposalId = c.req.param("proposalId");
1960
- let rawBody;
1961
- try {
1962
- rawBody = await c.req.json();
1963
- } catch (err) {
1964
- if (err instanceof SyntaxError) {
1965
- return c.json({ error: "invalid_json_body" }, 400);
1966
- }
1967
- throw err;
1968
- }
1969
- const parsedBody = ApprovalDecisionInputSchema.safeParse(rawBody);
1970
- if (!parsedBody.success) return c.json({ error: "invalid_approval_decision" }, 400);
1971
- const body = parsedBody.data;
3308
+ const body = await parseDispatcherBody(c, ApprovalDecisionInputSchema, { invalidBodyError: "invalid_approval_decision" });
1972
3309
  const decision = await repo.recordApprovalDecision({
1973
3310
  id: body.id ?? `approval_${proposalId}_${Date.now()}`,
1974
3311
  proposalId,
@@ -1990,7 +3327,7 @@ function createDispatcherApp(input) {
1990
3327
  });
1991
3328
  app.post("/v1/proposals/:proposalId/apply-plans", async (c) => {
1992
3329
  const proposalId = c.req.param("proposalId");
1993
- const body = await parseBody(c, ApplyPlanInputSchema);
3330
+ const body = await parseDispatcherBody(c, ApplyPlanInputSchema);
1994
3331
  let executableTarget;
1995
3332
  if (body.execute) {
1996
3333
  if (body.adapter !== "github") {
@@ -2084,7 +3421,7 @@ function createDispatcherApp(input) {
2084
3421
  });
2085
3422
  app.post("/v1/runs/:runId/child-runs", async (c) => {
2086
3423
  const parentRunId = c.req.param("runId");
2087
- const body = await parseBody(c, ChildRunInputSchema);
3424
+ const body = await parseDispatcherBody(c, ChildRunInputSchema);
2088
3425
  const parent = await repo.getRun({ runId: parentRunId });
2089
3426
  if (!parent) return c.json({ error: "parent_run_not_found" }, 404);
2090
3427
  const receivedAt = (/* @__PURE__ */ new Date()).toISOString();
@@ -2110,6 +3447,19 @@ function createDispatcherApp(input) {
2110
3447
  if (!stored) return c.json({ error: "run_not_found" }, 404);
2111
3448
  return c.json(stored);
2112
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
+ });
2113
3463
  app.get("/v1/runs/:runId/metrics", async (c) => {
2114
3464
  const runId = c.req.param("runId");
2115
3465
  const stored = await repo.getRun({ runId });