@opentag/dispatcher 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,45 +1,101 @@
1
1
  // src/callbacks.ts
2
- import { parseSlackThreadKey } from "@opentag/slack";
2
+ import { createLarkReplyClient, parseLarkThreadKey, replyLarkMessage } from "@opentag/lark";
3
+ import { createSlackPostMessagePayload, createSlackUpdateMessagePayload, parseSlackThreadKey } from "@opentag/slack";
4
+ import { createTelegramSendMessageDraftPayload, createTelegramSendMessagePayload, parseTelegramThreadKey } from "@opentag/telegram";
5
+ function slackUpdateUriFrom(postMessageUri) {
6
+ return postMessageUri.replace(/\/chat\.postMessage$/, "/chat.update");
7
+ }
8
+ function githubCommentUriFrom(input) {
9
+ if (input.responseBody.url) return input.responseBody.url;
10
+ if (typeof input.responseBody.id === "number") {
11
+ return input.commentsUri.replace(/\/comments$/, `/comments/${input.responseBody.id}`);
12
+ }
13
+ return void 0;
14
+ }
15
+ function slackBotTokenFor(input) {
16
+ if (input.agentId && input.botTokensByAgentId && Object.hasOwn(input.botTokensByAgentId, input.agentId) && typeof input.botTokensByAgentId[input.agentId] === "string") {
17
+ return input.botTokensByAgentId[input.agentId];
18
+ }
19
+ return input.botToken;
20
+ }
3
21
  function createGitHubCallbackSink(input) {
4
22
  const fetchImpl = input.fetchImpl ?? fetch;
23
+ const commentUriByKey = /* @__PURE__ */ new Map();
24
+ const deliveryByKey = /* @__PURE__ */ new Map();
5
25
  return {
6
26
  async deliver(message) {
7
27
  if (message.provider !== "github") return;
8
28
  if (!input.token) return;
9
- const response = await fetchImpl(message.uri, {
10
- method: "POST",
11
- headers: {
12
- accept: "application/vnd.github+json",
13
- authorization: `Bearer ${input.token}`,
14
- "content-type": "application/json",
15
- "x-github-api-version": "2022-11-28"
16
- },
17
- body: JSON.stringify({ body: message.body })
29
+ const statusKey = message.statusMessageKey ?? `${message.runId}:status`;
30
+ const previous = deliveryByKey.get(statusKey) ?? Promise.resolve();
31
+ const current = previous.then(async () => {
32
+ const existingCommentUri = commentUriByKey.get(statusKey);
33
+ const response = await fetchImpl(existingCommentUri ?? message.uri, {
34
+ method: existingCommentUri ? "PATCH" : "POST",
35
+ headers: {
36
+ accept: "application/vnd.github+json",
37
+ authorization: `Bearer ${input.token}`,
38
+ "content-type": "application/json",
39
+ "x-github-api-version": "2022-11-28"
40
+ },
41
+ body: JSON.stringify({ body: message.body })
42
+ });
43
+ if (!response.ok) {
44
+ throw new Error(`deliver GitHub callback failed: ${response.status} ${await response.text()}`);
45
+ }
46
+ if (!existingCommentUri) {
47
+ const body = await response.json();
48
+ const commentUri = githubCommentUriFrom({ commentsUri: message.uri, responseBody: body });
49
+ if (commentUri) {
50
+ commentUriByKey.set(statusKey, commentUri);
51
+ }
52
+ }
53
+ if (message.kind === "final") {
54
+ commentUriByKey.delete(statusKey);
55
+ }
56
+ });
57
+ deliveryByKey.set(statusKey, current);
58
+ await current.finally(() => {
59
+ if (deliveryByKey.get(statusKey) === current) {
60
+ deliveryByKey.delete(statusKey);
61
+ }
18
62
  });
19
- if (!response.ok) {
20
- throw new Error(`deliver GitHub callback failed: ${response.status} ${await response.text()}`);
21
- }
22
63
  }
23
64
  };
24
65
  }
25
66
  function createSlackCallbackSink(input) {
26
67
  const fetchImpl = input.fetchImpl ?? fetch;
68
+ const statusMessageTsByKey = /* @__PURE__ */ new Map();
27
69
  return {
28
70
  async deliver(message) {
29
71
  if (message.provider !== "slack") return;
30
- if (!input.botToken) return;
72
+ const botToken = slackBotTokenFor({
73
+ ...input.botToken ? { botToken: input.botToken } : {},
74
+ ...input.botTokensByAgentId ? { botTokensByAgentId: input.botTokensByAgentId } : {},
75
+ ...message.agentId ? { agentId: message.agentId } : {}
76
+ });
77
+ if (!botToken) return;
31
78
  const thread = parseSlackThreadKey(message.threadKey ?? "");
32
- const response = await fetchImpl(message.uri, {
79
+ const existingStatusTs = message.statusMessageKey ? statusMessageTsByKey.get(message.statusMessageKey) : void 0;
80
+ const response = await fetchImpl(existingStatusTs ? slackUpdateUriFrom(message.uri) : message.uri, {
33
81
  method: "POST",
34
82
  headers: {
35
- authorization: `Bearer ${input.botToken}`,
83
+ authorization: `Bearer ${botToken}`,
36
84
  "content-type": "application/json"
37
85
  },
38
- body: JSON.stringify({
39
- channel: thread.channelId,
40
- text: message.body,
41
- thread_ts: thread.threadTs
42
- })
86
+ body: JSON.stringify(
87
+ existingStatusTs ? createSlackUpdateMessagePayload({
88
+ channelId: thread.channelId,
89
+ text: message.body,
90
+ messageTs: existingStatusTs,
91
+ ...message.blocks?.length ? { blocks: message.blocks } : {}
92
+ }) : createSlackPostMessagePayload({
93
+ channelId: thread.channelId,
94
+ text: message.body,
95
+ threadTs: thread.threadTs,
96
+ ...message.blocks?.length ? { blocks: message.blocks } : {}
97
+ })
98
+ )
43
99
  });
44
100
  if (!response.ok) {
45
101
  throw new Error(`deliver Slack callback failed: ${response.status} ${await response.text()}`);
@@ -48,6 +104,87 @@ function createSlackCallbackSink(input) {
48
104
  if (body.ok === false) {
49
105
  throw new Error(`deliver Slack callback failed: ${body.error ?? "unknown_error"}`);
50
106
  }
107
+ if (message.statusMessageKey && !existingStatusTs && body.ts) {
108
+ statusMessageTsByKey.set(message.statusMessageKey, body.ts);
109
+ }
110
+ if (message.kind === "final") {
111
+ for (const key of statusMessageTsByKey.keys()) {
112
+ if (key.startsWith(`${message.runId}:`)) {
113
+ statusMessageTsByKey.delete(key);
114
+ }
115
+ }
116
+ }
117
+ }
118
+ };
119
+ }
120
+ function createLarkCallbackSink(input) {
121
+ if (!input.client && Boolean(input.appId) !== Boolean(input.appSecret)) {
122
+ throw new Error("Lark callback sink requires both appId and appSecret (or neither).");
123
+ }
124
+ const client = input.client ?? (input.appId && input.appSecret ? createLarkReplyClient({ appId: input.appId, appSecret: input.appSecret, ...input.domain ? { domain: input.domain } : {} }) : void 0);
125
+ return {
126
+ async deliver(message) {
127
+ if (message.provider !== "lark") return;
128
+ if (!client) {
129
+ throw new Error("Lark callback sink received a lark message but has no client configured (missing appId/appSecret).");
130
+ }
131
+ if (!message.threadKey) {
132
+ throw new Error("Lark callback message is missing threadKey.");
133
+ }
134
+ const { messageId } = parseLarkThreadKey(message.threadKey);
135
+ await replyLarkMessage(client, { messageId, text: message.body });
136
+ }
137
+ };
138
+ }
139
+ function createTelegramCallbackSink(input) {
140
+ const fetchImpl = input.fetchImpl ?? fetch;
141
+ const draftIdByKey = /* @__PURE__ */ new Map();
142
+ let nextDraftId = 1;
143
+ return {
144
+ async deliver(message) {
145
+ if (message.provider !== "telegram") return;
146
+ const botToken = slackBotTokenFor({
147
+ ...input.botToken ? { botToken: input.botToken } : {},
148
+ ...input.botTokensByAgentId ? { botTokensByAgentId: input.botTokensByAgentId } : {},
149
+ ...message.agentId ? { agentId: message.agentId } : {}
150
+ });
151
+ if (!botToken) return;
152
+ const thread = parseTelegramThreadKey(message.threadKey ?? "");
153
+ const statusKey = message.statusMessageKey ?? `${message.runId}:status`;
154
+ const isDraft = message.kind === "progress";
155
+ const draftId = isDraft ? draftIdByKey.get(statusKey) ?? nextDraftId++ : void 0;
156
+ if (isDraft && draftId && !draftIdByKey.has(statusKey)) {
157
+ draftIdByKey.set(statusKey, draftId);
158
+ }
159
+ const response = await fetchImpl(`https://api.telegram.org/bot${botToken}/${isDraft ? "sendMessageDraft" : "sendMessage"}`, {
160
+ method: "POST",
161
+ headers: {
162
+ "content-type": "application/json"
163
+ },
164
+ body: JSON.stringify(
165
+ isDraft ? createTelegramSendMessageDraftPayload({
166
+ chatId: thread.chatId,
167
+ text: message.body,
168
+ draftId,
169
+ ...thread.messageThreadId ? { messageThreadId: thread.messageThreadId } : {}
170
+ }) : createTelegramSendMessagePayload({
171
+ chatId: thread.chatId,
172
+ text: message.body,
173
+ replyToMessageId: thread.replyToMessageId,
174
+ ...thread.messageThreadId ? { messageThreadId: thread.messageThreadId } : {}
175
+ })
176
+ )
177
+ });
178
+ if (!response.ok) {
179
+ throw new Error(`deliver Telegram callback failed: ${response.status} ${await response.text()}`);
180
+ }
181
+ const body = await response.json();
182
+ if (body.ok === false) {
183
+ throw new Error(`deliver Telegram callback failed: ${body.description ?? "unknown_error"}`);
184
+ }
185
+ if (message.kind === "final") {
186
+ draftIdByKey.delete(statusKey);
187
+ }
51
188
  }
52
189
  };
53
190
  }
@@ -61,14 +198,224 @@ function createCompositeCallbackSink(sinks) {
61
198
  };
62
199
  }
63
200
 
64
- // src/server.ts
65
- import { OpenTagEventSchema, OpenTagRunResultSchema } from "@opentag/core";
201
+ // src/presentation.ts
66
202
  import { renderAcknowledgement, renderFinalResult, renderProgress } from "@opentag/github";
203
+ import { renderLarkAcknowledgement, renderLarkFinalResult } from "@opentag/lark";
204
+ import { createSlackFinalResultBlocks, renderSlackAcknowledgement, renderSlackFinalResult } from "@opentag/slack";
205
+ import { renderTelegramAcknowledgement, renderTelegramFinalResult, renderTelegramProgress } from "@opentag/telegram";
206
+ function createDefaultCallbackPresentation() {
207
+ return {
208
+ shouldDeliverAcknowledgement(provider) {
209
+ return provider !== "lark";
210
+ },
211
+ shouldDeliverProgress(provider) {
212
+ return provider !== "slack" && provider !== "lark";
213
+ },
214
+ acknowledgement(input) {
215
+ if (input.provider === "slack") {
216
+ return renderSlackAcknowledgement(input.runId);
217
+ }
218
+ if (input.provider === "lark") {
219
+ return renderLarkAcknowledgement(input.runId);
220
+ }
221
+ if (input.provider === "telegram") {
222
+ return renderTelegramAcknowledgement(input.runId);
223
+ }
224
+ return renderAcknowledgement(input.runId);
225
+ },
226
+ progress(input) {
227
+ if (input.provider === "telegram") {
228
+ return renderTelegramProgress(input.message);
229
+ }
230
+ return renderProgress({ runId: input.runId, message: input.message });
231
+ },
232
+ final(input) {
233
+ if (input.provider === "slack") {
234
+ return {
235
+ body: renderSlackFinalResult(input.result),
236
+ blocks: createSlackFinalResultBlocks(input.result)
237
+ };
238
+ }
239
+ if (input.provider === "lark") {
240
+ return { body: renderLarkFinalResult(input.result) };
241
+ }
242
+ if (input.provider === "telegram") {
243
+ return { body: renderTelegramFinalResult(input.result) };
244
+ }
245
+ return { body: renderFinalResult(input.result) };
246
+ }
247
+ };
248
+ }
249
+
250
+ // src/server.ts
251
+ import { createHash } from "crypto";
252
+ import {
253
+ AdapterMutationMappingSchema,
254
+ ActorIdentitySchema,
255
+ ActionHintSchema,
256
+ conversationKeysFromEvent as conversationKeysFromEvent2,
257
+ parseThreadActionCommand,
258
+ projectTargetRefFromEvent as projectTargetRefFromEvent2,
259
+ suggestedActionCandidatesFromSnapshots,
260
+ createAdapterMutationCompilerRegistry,
261
+ OpenTagEventSchema,
262
+ OpenTagRunResultSchema,
263
+ PolicyRuleSchema,
264
+ RunEventImportanceSchema,
265
+ RunEventVisibilitySchema
266
+ } from "@opentag/core";
267
+ import {
268
+ applyGitHubIssueMutationOperation,
269
+ createGitHubIssueMutationCompiler
270
+ } from "@opentag/github";
67
271
  import { createOpenTagRepository, migrateSchema } from "@opentag/store";
68
272
  import Database from "better-sqlite3";
69
273
  import { drizzle } from "drizzle-orm/better-sqlite3";
70
274
  import { Hono } from "hono";
71
275
  import { z } from "zod";
276
+
277
+ // src/admission.ts
278
+ import {
279
+ conversationKeysFromEvent,
280
+ projectTargetRefFromEvent,
281
+ RunAdmissionDecisionSchema
282
+ } from "@opentag/core";
283
+ function isWriteCapable(event) {
284
+ return event.permissions.some((permission) => ["repo:write", "pr:create", "pr:update"].includes(permission.scope));
285
+ }
286
+ function actorIsAllowed(event, allowedActors) {
287
+ if (!allowedActors?.length) return true;
288
+ return allowedActors.includes(event.actor.handle ?? "") || allowedActors.includes(event.actor.providerUserId);
289
+ }
290
+ function admissionDecision(input) {
291
+ return RunAdmissionDecisionSchema.parse({
292
+ action: input.action,
293
+ reason: input.reason,
294
+ reasonCode: input.reasonCode,
295
+ decidedAt: (/* @__PURE__ */ new Date()).toISOString(),
296
+ ...input.activeRunId ? { activeRunId: input.activeRunId } : {},
297
+ eventId: input.event.id
298
+ });
299
+ }
300
+ async function defaultAgentAccessProfileCheck() {
301
+ return { allowed: true };
302
+ }
303
+ function createAdmissionRuntime(input) {
304
+ const agentAccessProfileCheck = input.agentAccessProfileCheck ?? defaultAgentAccessProfileCheck;
305
+ return {
306
+ async admitRun(request) {
307
+ const existingRun = await input.repo.getRunByEventId({ eventId: request.event.id });
308
+ if (existingRun) {
309
+ return {
310
+ outcome: "drop_duplicate",
311
+ decision: admissionDecision({
312
+ action: "drop_duplicate",
313
+ reason: "Source event already created a run.",
314
+ reasonCode: "duplicate_source_event",
315
+ event: request.event,
316
+ activeRunId: existingRun.run.id
317
+ }),
318
+ run: existingRun.run,
319
+ idempotentReplay: true
320
+ };
321
+ }
322
+ const repoKey = projectTargetRefFromEvent(request.event);
323
+ if (!repoKey) {
324
+ return {
325
+ outcome: "needs_human_decision",
326
+ decision: admissionDecision({
327
+ action: "needs_human_decision",
328
+ reason: "The event did not resolve to a repository context.",
329
+ reasonCode: "repo_context_missing",
330
+ event: request.event
331
+ })
332
+ };
333
+ }
334
+ const binding = await input.repo.getRepoBinding(repoKey);
335
+ if (!binding) {
336
+ return {
337
+ outcome: "needs_human_decision",
338
+ decision: admissionDecision({
339
+ action: "needs_human_decision",
340
+ reason: "No repository binding is configured for this work context.",
341
+ reasonCode: "repo_not_bound",
342
+ event: request.event
343
+ })
344
+ };
345
+ }
346
+ if (isWriteCapable(request.event) && !actorIsAllowed(request.event, binding.allowedActors)) {
347
+ return {
348
+ outcome: "needs_human_decision",
349
+ decision: admissionDecision({
350
+ action: "needs_human_decision",
351
+ reason: "The requesting actor is not allowed to start a write-capable run in this repository.",
352
+ reasonCode: "actor_not_allowed_for_write",
353
+ event: request.event
354
+ })
355
+ };
356
+ }
357
+ const accessDecision = await agentAccessProfileCheck({ event: request.event, binding });
358
+ if (!accessDecision.allowed) {
359
+ return {
360
+ outcome: "needs_human_decision",
361
+ decision: admissionDecision({
362
+ action: "needs_human_decision",
363
+ reason: accessDecision.reason,
364
+ reasonCode: accessDecision.reasonCode ?? "agent_access_profile_denied",
365
+ event: request.event
366
+ })
367
+ };
368
+ }
369
+ let activeRun = null;
370
+ for (const conversationKey of conversationKeysFromEvent(request.event)) {
371
+ activeRun = await input.repo.findActiveRunForConversation({ conversationKey });
372
+ if (activeRun) break;
373
+ }
374
+ if (activeRun) {
375
+ const decision = admissionDecision({
376
+ action: "queue_follow_up",
377
+ reason: "A run is already active for this thread; queue the new request as follow-up work.",
378
+ reasonCode: isWriteCapable(request.event) ? "active_write_run_same_thread" : "active_run_same_thread",
379
+ event: request.event,
380
+ activeRunId: activeRun.run.id
381
+ });
382
+ const { followUpRequest, created } = await input.repo.createFollowUpRequest({
383
+ id: request.requestId,
384
+ event: request.event,
385
+ decision,
386
+ activeRunId: activeRun.run.id
387
+ });
388
+ if (created) {
389
+ await input.repo.appendRunEvent({
390
+ runId: activeRun.run.id,
391
+ type: "follow_up_request.queued",
392
+ payload: { followUpRequestId: followUpRequest.id, sourceEventId: request.event.id },
393
+ visibility: "audit",
394
+ importance: "normal",
395
+ message: decision.reason
396
+ });
397
+ }
398
+ return {
399
+ outcome: "follow_up_queued",
400
+ decision,
401
+ followUpRequest
402
+ };
403
+ }
404
+ return {
405
+ outcome: "start",
406
+ decision: admissionDecision({
407
+ action: "start",
408
+ reason: "Source event accepted and ready to create a run.",
409
+ reasonCode: "new_event",
410
+ event: request.event
411
+ }),
412
+ binding
413
+ };
414
+ }
415
+ };
416
+ }
417
+
418
+ // src/server.ts
72
419
  var CreateRunnerSchema = z.object({
73
420
  runnerId: z.string().min(1),
74
421
  name: z.string().min(1)
@@ -85,49 +432,621 @@ var CreateRepoBindingSchema = z.object({
85
432
  var CreateSlackChannelBindingSchema = z.object({
86
433
  teamId: z.string().min(1),
87
434
  channelId: z.string().min(1),
435
+ repoProvider: z.string().min(1).default("github"),
88
436
  owner: z.string().min(1),
89
437
  repo: z.string().min(1)
90
438
  });
439
+ var CreateChannelBindingSchema = z.object({
440
+ provider: z.string().min(1),
441
+ accountId: z.string().min(1),
442
+ conversationId: z.string().min(1),
443
+ repoProvider: z.string().min(1),
444
+ owner: z.string().min(1),
445
+ repo: z.string().min(1),
446
+ metadata: z.record(z.string(), z.unknown()).optional()
447
+ });
448
+ var UpsertPolicyRuleSchema = z.object({
449
+ rule: PolicyRuleSchema
450
+ });
451
+ var UpsertMutationMappingSchema = z.object({
452
+ mapping: AdapterMutationMappingSchema
453
+ });
91
454
  var CreateRunSchema = z.object({
92
455
  runId: z.string().min(1),
93
456
  event: OpenTagEventSchema
94
457
  });
458
+ var PromoteFollowUpRequestSchema = z.object({
459
+ runId: z.string().min(1)
460
+ });
95
461
  var CompleteRunSchema = z.object({
96
462
  result: OpenTagRunResultSchema
97
463
  });
464
+ var ApprovalDecisionInputSchema = z.object({
465
+ id: z.string().min(1).optional(),
466
+ approvedIntentIds: z.array(z.string().min(1)),
467
+ rejectedIntentIds: z.array(z.string().min(1)).optional(),
468
+ approvedBy: ActorIdentitySchema,
469
+ approvedAt: z.string().datetime().optional(),
470
+ scope: z.enum(["manual", "policy"]).default("manual"),
471
+ reason: z.string().min(1).optional(),
472
+ metadata: z.record(z.string(), z.unknown()).optional()
473
+ }).refine((value) => {
474
+ const rejected = new Set(value.rejectedIntentIds ?? []);
475
+ return value.approvedIntentIds.every((intentId) => !rejected.has(intentId));
476
+ }, {
477
+ message: "approvedIntentIds and rejectedIntentIds must not overlap"
478
+ });
479
+ var ApplyPlanInputSchema = z.object({
480
+ id: z.string().min(1).optional(),
481
+ approvalDecisionId: z.string().min(1),
482
+ selectedIntentIds: z.array(z.string().min(1)).optional(),
483
+ adapter: z.string().min(1).optional(),
484
+ execute: z.boolean().optional()
485
+ });
486
+ var ThreadActionInputSchema = z.object({
487
+ id: z.string().min(1).optional(),
488
+ rawText: z.string().min(1),
489
+ actor: ActorIdentitySchema,
490
+ callback: z.object({
491
+ provider: z.string().min(1),
492
+ uri: z.string().min(1),
493
+ threadKey: z.string().min(1).optional()
494
+ }),
495
+ metadata: z.record(z.string(), z.unknown()).optional()
496
+ });
497
+ var ChildRunInputSchema = z.object({
498
+ runId: z.string().min(1),
499
+ action: ActionHintSchema,
500
+ commandText: z.string().min(1).optional(),
501
+ sourceProposalId: z.string().min(1).optional(),
502
+ sourceApplyPlanId: z.string().min(1).optional()
503
+ });
98
504
  var ProgressSchema = z.object({
99
505
  type: z.string().min(1).optional(),
100
506
  message: z.string().min(1),
101
- at: z.string().datetime().optional()
507
+ at: z.string().datetime().optional(),
508
+ visibility: RunEventVisibilitySchema.optional(),
509
+ importance: RunEventImportanceSchema.optional()
102
510
  });
103
- function repoKeyFromEvent(event) {
104
- const owner = event.metadata["owner"];
105
- const repo = event.metadata["repo"];
106
- if (typeof owner !== "string" || typeof repo !== "string") return null;
511
+ function childEventFromParent(input) {
107
512
  return {
108
- provider: typeof event.metadata["repoProvider"] === "string" ? event.metadata["repoProvider"] : "github",
109
- owner,
110
- repo
513
+ ...input.parentEvent,
514
+ id: `evt_${input.childRunId}`,
515
+ sourceEventId: `${input.parentEvent.sourceEventId}:${input.childRunId}`,
516
+ receivedAt: input.receivedAt,
517
+ context: [...input.parentEvent.context, ...input.extraContext ?? []],
518
+ command: {
519
+ rawText: input.commandText ?? `Execute next action: ${input.actionKind}`,
520
+ intent: "run",
521
+ args: {
522
+ parentSourceEventId: input.parentEvent.sourceEventId,
523
+ actionKind: input.actionKind
524
+ }
525
+ },
526
+ metadata: {
527
+ ...input.parentEvent.metadata,
528
+ ...input.metadata ?? {}
529
+ }
111
530
  };
112
531
  }
113
- function isWriteCapable(event) {
114
- return event.permissions.some((permission) => ["repo:write", "pr:create", "pr:update"].includes(permission.scope));
532
+ function mappingsFromAdapterPlan(adapterPlan) {
533
+ if (!adapterPlan || typeof adapterPlan !== "object" || Array.isArray(adapterPlan)) return [];
534
+ const mappings = adapterPlan.mappings;
535
+ if (!Array.isArray(mappings)) return [];
536
+ return mappings.map((mapping) => AdapterMutationMappingSchema.parse(mapping));
115
537
  }
116
- function actorIsAllowed(event, allowedActors) {
538
+ function conversationKeyFromCallback(input) {
539
+ return `${input.provider}:${input.threadKey ?? input.uri}`;
540
+ }
541
+ function metadataIssueNumber(metadata) {
542
+ const value = metadata?.["issueNumber"];
543
+ if (typeof value === "number" && Number.isInteger(value) && value > 0) return String(value);
544
+ if (typeof value === "string" && /^[1-9]\d*$/.test(value)) return value;
545
+ return void 0;
546
+ }
547
+ function metadataString(metadata, key) {
548
+ const value = metadata?.[key];
549
+ return typeof value === "string" && value.length > 0 ? value : void 0;
550
+ }
551
+ function githubIssueWorkItemExternalId(metadata) {
552
+ const owner = metadataString(metadata, "owner");
553
+ const repo = metadataString(metadata, "repo");
554
+ const issueNumber = metadataIssueNumber(metadata);
555
+ if (!owner || !repo || !issueNumber) return void 0;
556
+ return `${owner}/${repo}#${issueNumber}`;
557
+ }
558
+ function conversationKeysFromThreadAction(input) {
559
+ const primary = conversationKeyFromCallback(input.callback);
560
+ const keys = [primary];
561
+ const issueNumber = metadataIssueNumber(input.metadata);
562
+ if (input.callback.provider === "github" && input.callback.threadKey && issueNumber) {
563
+ const suffix = `#${issueNumber}`;
564
+ if (input.callback.threadKey.endsWith(suffix)) {
565
+ keys.push(`github:${input.callback.threadKey.slice(0, -suffix.length)}`);
566
+ }
567
+ }
568
+ return [...new Set(keys)];
569
+ }
570
+ function proposalMatchesWorkItem(proposal, externalId) {
571
+ return proposal.snapshot.workThread?.workItemReference.externalId === externalId || proposal.event.workItem?.externalId === externalId;
572
+ }
573
+ function stableHash(value) {
574
+ return createHash("sha256").update(value).digest("hex").slice(0, 12);
575
+ }
576
+ function stableId(prefix, parts) {
577
+ return `${prefix}_${stableHash(JSON.stringify(parts))}`;
578
+ }
579
+ function actorKeys(actor) {
580
+ return [
581
+ actor.providerUserId,
582
+ actor.handle,
583
+ `${actor.provider}:${actor.providerUserId}`,
584
+ actor.handle ? `${actor.provider}:${actor.handle}` : void 0
585
+ ].filter((value) => typeof value === "string" && value.length > 0);
586
+ }
587
+ function actorAllowedByList(actor, allowedActors) {
117
588
  if (!allowedActors?.length) return true;
118
- return allowedActors.includes(event.actor.handle ?? "") || allowedActors.includes(event.actor.providerUserId);
589
+ const keys = new Set(actorKeys(actor));
590
+ return allowedActors.some((allowedActor) => keys.has(allowedActor));
591
+ }
592
+ function actionCandidatesFor(proposals) {
593
+ const candidates = [];
594
+ let startIndex = 1;
595
+ for (const proposal of proposals) {
596
+ const proposalCandidates = suggestedActionCandidatesFromSnapshots([proposal.snapshot], startIndex).map((candidate) => ({
597
+ ...candidate,
598
+ proposal
599
+ }));
600
+ candidates.push(...proposalCandidates);
601
+ startIndex += proposalCandidates.length;
602
+ }
603
+ return candidates;
604
+ }
605
+ function resolveCandidateSelection(input) {
606
+ const candidates = actionCandidatesFor(input.proposals);
607
+ if (candidates.length === 0) {
608
+ return { ok: false, reason: "no_proposal", message: "I could not find any suggested actions for this thread." };
609
+ }
610
+ let selected = [];
611
+ const selection = input.command.selection;
612
+ if (selection.kind === "all") {
613
+ selected = candidates;
614
+ } else if (selection.kind === "index") {
615
+ selected = candidates.filter((candidate) => candidate.index === selection.index);
616
+ } else if (selection.kind === "proposal") {
617
+ selected = candidates.filter((candidate) => candidate.proposalId === selection.proposalId);
618
+ } else if (selection.kind === "intent") {
619
+ selected = candidates.filter((candidate) => candidate.intent.intentId === selection.intentId);
620
+ } else if (selection.kind === "domain") {
621
+ selected = candidates.filter((candidate) => candidate.intent.domain === selection.domain);
622
+ } else if (candidates.length === 1) {
623
+ selected = candidates;
624
+ } else {
625
+ return {
626
+ ok: false,
627
+ reason: "ambiguous",
628
+ runId: candidates[0]?.proposal.runId,
629
+ message: `I found ${candidates.length} suggested actions. Please reply with ${candidates.map((candidate) => `\`${input.command.verb} ${candidate.index}\``).join(", ")} or \`${input.command.verb} all\`.`
630
+ };
631
+ }
632
+ if (selected.length === 0) {
633
+ return {
634
+ ok: false,
635
+ reason: "no_match",
636
+ runId: candidates[0]?.proposal.runId,
637
+ message: "I could not match that reply to a suggested action. Please use an action number like `apply 1`."
638
+ };
639
+ }
640
+ const proposalIds = new Set(selected.map((candidate) => candidate.proposalId));
641
+ if (proposalIds.size !== 1) {
642
+ return {
643
+ ok: false,
644
+ reason: "ambiguous",
645
+ runId: selected[0]?.proposal.runId,
646
+ message: "That selection spans multiple proposals. Please apply or approve one proposal at a time using its action number."
647
+ };
648
+ }
649
+ return {
650
+ ok: true,
651
+ resolved: {
652
+ proposal: selected[0].proposal,
653
+ selectedIntentIds: selected.map((candidate) => candidate.intent.intentId),
654
+ selectedCandidates: selected
655
+ }
656
+ };
657
+ }
658
+ async function resolveThreadAction(input) {
659
+ const conversationKeys = conversationKeysFromThreadAction({
660
+ callback: input.callback,
661
+ ...input.metadata ? { metadata: input.metadata } : {}
662
+ });
663
+ const primaryConversationKey = conversationKeys[0];
664
+ const targetWorkItemExternalId = githubIssueWorkItemExternalId(input.metadata);
665
+ if (input.command.selection.kind === "proposal") {
666
+ const stored = await input.repo.getSuggestedChanges({ proposalId: input.command.selection.proposalId });
667
+ if (!stored) {
668
+ return { ok: false, reason: "no_proposal", message: `I could not find proposal \`${input.command.selection.proposalId}\`.` };
669
+ }
670
+ const claimed = await input.repo.getRun({ runId: stored.runId });
671
+ if (!claimed) {
672
+ return { ok: false, reason: "no_proposal", message: `I found the proposal but not its source run.` };
673
+ }
674
+ const proposalConversationKeys = conversationKeysFromEvent2(claimed.event);
675
+ if (!proposalConversationKeys.some((key) => conversationKeys.includes(key))) {
676
+ return { ok: false, reason: "no_match", runId: stored.runId, message: "That proposal does not belong to this source thread." };
677
+ }
678
+ const proposal = { runId: stored.runId, run: claimed.run, event: claimed.event, snapshot: stored.snapshot };
679
+ if (targetWorkItemExternalId && !proposalMatchesWorkItem(proposal, targetWorkItemExternalId)) {
680
+ return { ok: false, reason: "no_match", runId: stored.runId, message: "That proposal does not belong to this source thread." };
681
+ }
682
+ return resolveCandidateSelection({
683
+ command: input.command,
684
+ proposals: [proposal]
685
+ });
686
+ }
687
+ for (const conversationKey of conversationKeys) {
688
+ const proposals = await input.repo.listLatestSuggestedChangesForConversation({ conversationKey });
689
+ const scopedProposals = conversationKey !== primaryConversationKey && targetWorkItemExternalId ? proposals.filter((proposal) => proposalMatchesWorkItem(proposal, targetWorkItemExternalId)) : proposals;
690
+ if (scopedProposals.length > 0) return resolveCandidateSelection({ command: input.command, proposals: scopedProposals });
691
+ }
692
+ return resolveCandidateSelection({ command: input.command, proposals: [] });
693
+ }
694
+ function isGitHubRepoEvent(event) {
695
+ const repoProvider = event.metadata["repoProvider"];
696
+ return repoProvider === "github" || event.source === "github" && repoProvider === void 0;
697
+ }
698
+ function hasGitHubRepoTarget(event) {
699
+ return isGitHubRepoEvent(event) && typeof event.metadata["owner"] === "string" && typeof event.metadata["repo"] === "string";
700
+ }
701
+ function hasGitHubIssueOrPullTarget(event) {
702
+ return typeof event.metadata["issueNumber"] === "number" || typeof event.metadata["pullRequestNumber"] === "number";
703
+ }
704
+ function isRepoLevelGitHubIntent(intent) {
705
+ return intent.action === "create_pull_request";
706
+ }
707
+ function adapterForAction(input) {
708
+ return hasGitHubRepoTarget(input.event) && (hasGitHubIssueOrPullTarget(input.event) || input.selectedIntents.length > 0 && input.selectedIntents.every((intent) => isRepoLevelGitHubIntent(intent))) ? "github" : input.callbackProvider;
709
+ }
710
+ async function authorizeThreadAction(input) {
711
+ const repoKey = projectTargetRefFromEvent2(input.resolved.proposal.event);
712
+ if (!repoKey) {
713
+ return { ok: false, reason: "repo_context_missing", message: "The proposal does not resolve to a repository binding." };
714
+ }
715
+ const binding = await input.repo.getRepoBinding(repoKey);
716
+ if (!binding) {
717
+ return { ok: false, reason: "repo_binding_not_found", message: "No repository binding is configured for this proposal." };
718
+ }
719
+ if (!actorAllowedByList(input.actor, binding.allowedActors)) {
720
+ return {
721
+ ok: false,
722
+ reason: "actor_not_allowed",
723
+ message: "This actor is not allowed to approve or apply actions for the bound repository."
724
+ };
725
+ }
726
+ if (input.resolved.proposal.event.source === "slack") {
727
+ const teamId = input.resolved.proposal.event.metadata["teamId"];
728
+ const channelId = input.resolved.proposal.event.metadata["channelId"];
729
+ if (typeof teamId === "string" && typeof channelId === "string") {
730
+ const channelBinding = await input.repo.getChannelBinding({
731
+ provider: "slack",
732
+ accountId: teamId,
733
+ conversationId: channelId
734
+ });
735
+ if (!channelBinding || channelBinding.repoProvider !== repoKey.provider || channelBinding.owner !== repoKey.owner || channelBinding.repo !== repoKey.repo) {
736
+ return {
737
+ ok: false,
738
+ reason: "channel_binding_mismatch",
739
+ message: "The source channel binding is missing or no longer points at the proposal repository."
740
+ };
741
+ }
742
+ }
743
+ }
744
+ return { ok: true };
745
+ }
746
+ function stableApprovalId(input) {
747
+ return input.providedId ?? stableId("approval", [
748
+ input.resolved.proposal.snapshot.proposalId,
749
+ input.command.verb,
750
+ [...input.resolved.selectedIntentIds].sort(),
751
+ actorKeys(input.actor).sort()
752
+ ]);
753
+ }
754
+ function sortedValues(values) {
755
+ return [...values ?? []].sort();
756
+ }
757
+ function sameStringSet(left, right) {
758
+ return JSON.stringify(sortedValues(left)) === JSON.stringify(sortedValues(right));
759
+ }
760
+ function sameActor(left, right) {
761
+ return left.provider === right.provider && left.providerUserId === right.providerUserId && (left.handle ?? "") === (right.handle ?? "") && (left.organizationId ?? "") === (right.organizationId ?? "");
762
+ }
763
+ function approvalDecisionMatchesThreadAction(input) {
764
+ const approvedIntentIds = input.command.verb === "reject" ? [] : input.resolved.selectedIntentIds;
765
+ const rejectedIntentIds = input.command.verb === "reject" ? input.resolved.selectedIntentIds : [];
766
+ const metadata = input.decision.metadata;
767
+ const verb = metadata && typeof metadata === "object" && !Array.isArray(metadata) ? metadata["verb"] : void 0;
768
+ return input.decision.proposalId === input.resolved.proposal.snapshot.proposalId && sameStringSet(input.decision.approvedIntentIds, approvedIntentIds) && sameStringSet(input.decision.rejectedIntentIds, rejectedIntentIds) && sameActor(input.decision.approvedBy, input.actor) && verb === input.command.verb;
769
+ }
770
+ function stableApplyPlanId(input) {
771
+ return stableId("apply", [
772
+ input.resolved.proposal.snapshot.proposalId,
773
+ input.adapter,
774
+ [...input.resolved.selectedIntentIds].sort()
775
+ ]);
776
+ }
777
+ function stableChildRunId(input) {
778
+ return stableId("run_child", [
779
+ input.resolved.proposal.runId,
780
+ input.resolved.proposal.snapshot.proposalId,
781
+ input.command.verb,
782
+ [...input.resolved.selectedIntentIds].sort(),
783
+ input.sourceApplyPlanId ?? "",
784
+ input.fallbackReason ?? ""
785
+ ]);
786
+ }
787
+ function selectedIntentsAlreadyApplied(input) {
788
+ return input.selectedIntentIds.every(
789
+ (intentId) => input.plan.outcomes?.some((outcome) => outcome.intentId === intentId && outcome.outcome === "applied")
790
+ );
791
+ }
792
+ function githubTargetFromEvent(event) {
793
+ const owner = event.metadata["owner"];
794
+ const repoName = event.metadata["repo"];
795
+ const issueNumber = event.metadata["issueNumber"];
796
+ const pullRequestNumber = event.metadata["pullRequestNumber"];
797
+ if (!hasGitHubRepoTarget(event)) return null;
798
+ if (typeof owner !== "string" || typeof repoName !== "string") return null;
799
+ if (typeof pullRequestNumber === "number") {
800
+ return { owner, repoName, issueNumber: pullRequestNumber, pullRequestNumber, targetKind: "pull_request" };
801
+ }
802
+ if (typeof issueNumber === "number") {
803
+ return { owner, repoName, issueNumber, targetKind: "issue" };
804
+ }
805
+ return { owner, repoName };
806
+ }
807
+ function selectedActionSummary(candidates) {
808
+ return candidates.map((candidate) => `${candidate.index}. ${candidate.intent.summary}`).join("; ");
809
+ }
810
+ function childRunContextLines(input) {
811
+ const previousSummary = input.resolved.proposal.run.result?.summary ?? input.resolved.proposal.snapshot.summary;
812
+ return [
813
+ `- Proposal: \`${input.resolved.proposal.snapshot.proposalId}\``,
814
+ `- Selected intents: ${input.resolved.selectedIntentIds.map((intentId) => `\`${intentId}\``).join(", ")}`,
815
+ `- Previous run: \`${input.resolved.proposal.runId}\``,
816
+ ...input.approvalDecisionId ? [`- Approval decision: \`${input.approvalDecisionId}\``] : [],
817
+ `- Previous result: ${previousSummary}`,
818
+ ...input.sourceApplyPlanId ? [`- Apply plan: \`${input.sourceApplyPlanId}\``] : [],
819
+ ...input.fallbackReason ? [`- Fallback reason: ${input.fallbackReason}`] : []
820
+ ];
821
+ }
822
+ function renderChildRunCreatedBody(input) {
823
+ return [
824
+ input.lead,
825
+ "",
826
+ `Child run: \`${input.childRun.id}\``,
827
+ "",
828
+ "Context carried into the child run:",
829
+ ...childRunContextLines(input),
830
+ "",
831
+ "The model will continue from this approved proposal instead of starting from a fresh mention."
832
+ ].join("\n");
833
+ }
834
+ function actionContextPointer(input) {
835
+ const lines = [
836
+ "OpenTag thread action continuation.",
837
+ `User reply: ${input.command.rawText}`,
838
+ `Action: ${input.command.verb}`,
839
+ `Proposal: ${input.resolved.proposal.snapshot.proposalId}`,
840
+ `Proposal summary: ${input.resolved.proposal.snapshot.summary}`,
841
+ `Selected actions: ${selectedActionSummary(input.resolved.selectedCandidates)}`,
842
+ `Selected intents: ${input.resolved.selectedIntentIds.join(", ")}`,
843
+ `Previous run: ${input.resolved.proposal.runId}`,
844
+ `Previous summary: ${input.resolved.proposal.run.result?.summary ?? input.resolved.proposal.snapshot.summary}`
845
+ ];
846
+ if (input.approvalDecisionId) lines.push(`Approval decision: ${input.approvalDecisionId}`);
847
+ if (input.applyPlanId) lines.push(`Apply plan: ${input.applyPlanId}`);
848
+ if (input.fallbackReason) lines.push(`Fallback reason: ${input.fallbackReason}`);
849
+ return {
850
+ kind: "text",
851
+ uri: lines.join("\n"),
852
+ visibility: input.resolved.proposal.event.source === "github" ? "public" : "organization",
853
+ title: "OpenTag approved action context"
854
+ };
855
+ }
856
+ async function createChildRunForThreadAction(input) {
857
+ const runId = input.runId ?? stableChildRunId(input);
858
+ const action = ActionHintSchema.parse({
859
+ kind: "apply_suggested_changes",
860
+ targetId: input.resolved.proposal.snapshot.proposalId,
861
+ selectedIntentIds: input.resolved.selectedIntentIds,
862
+ metadata: {
863
+ threadActionVerb: input.command.verb,
864
+ rawText: input.command.rawText,
865
+ ...input.command.reason ? { reason: input.command.reason } : {},
866
+ ...input.approvalDecisionId ? { approvalDecisionId: input.approvalDecisionId } : {},
867
+ ...input.fallbackReason ? { fallbackReason: input.fallbackReason } : {}
868
+ }
869
+ });
870
+ const previousRunSummary = input.resolved.proposal.run.result?.summary ?? input.resolved.proposal.snapshot.summary;
871
+ const commandText = input.command.verb === "continue" ? `Continue approved OpenTag action: ${selectedActionSummary(input.resolved.selectedCandidates)}` : `Continue because OpenTag could not directly apply approved action: ${selectedActionSummary(input.resolved.selectedCandidates)}`;
872
+ const { run } = await input.repo.createRun({
873
+ id: runId,
874
+ event: childEventFromParent({
875
+ parentEvent: input.resolved.proposal.event,
876
+ childRunId: runId,
877
+ actionKind: action.kind,
878
+ commandText,
879
+ receivedAt: (/* @__PURE__ */ new Date()).toISOString(),
880
+ extraContext: [
881
+ actionContextPointer({
882
+ command: input.command,
883
+ resolved: input.resolved,
884
+ ...input.approvalDecisionId ? { approvalDecisionId: input.approvalDecisionId } : {},
885
+ ...input.sourceApplyPlanId ? { applyPlanId: input.sourceApplyPlanId } : {},
886
+ ...input.fallbackReason ? { fallbackReason: input.fallbackReason } : {}
887
+ })
888
+ ],
889
+ metadata: {
890
+ parentRunId: input.resolved.proposal.runId,
891
+ sourceProposalId: input.resolved.proposal.snapshot.proposalId,
892
+ selectedIntentIds: input.resolved.selectedIntentIds,
893
+ threadActionVerb: input.command.verb,
894
+ previousRunSummary,
895
+ ...input.approvalDecisionId ? { approvalDecisionId: input.approvalDecisionId } : {},
896
+ ...input.sourceApplyPlanId ? { sourceApplyPlanId: input.sourceApplyPlanId } : {},
897
+ ...input.fallbackReason ? { fallbackReason: input.fallbackReason } : {}
898
+ }
899
+ }),
900
+ parentRunId: input.resolved.proposal.runId,
901
+ triggeredByAction: action,
902
+ sourceProposalId: input.resolved.proposal.snapshot.proposalId,
903
+ ...input.sourceApplyPlanId ? { sourceApplyPlanId: input.sourceApplyPlanId } : {}
904
+ });
905
+ return run;
906
+ }
907
+ async function executeGitHubApplyPlan(input) {
908
+ if (input.plan.adapter !== "github") {
909
+ return { plan: input.plan, executed: false, fallbackReason: `Adapter ${input.plan.adapter ?? "unknown"} is not directly executable yet.` };
910
+ }
911
+ if (!input.githubApply) {
912
+ return { plan: input.plan, executed: false, fallbackReason: "GitHub apply is not configured on this dispatcher." };
913
+ }
914
+ const target = githubTargetFromEvent(input.resolved.proposal.event);
915
+ if (!target) {
916
+ return { plan: input.plan, executed: false, fallbackReason: "The source run does not include a GitHub issue or pull request target." };
917
+ }
918
+ const preflightOutcomeByIntentId = new Map((input.plan.outcomes ?? []).map((outcome) => [outcome.intentId, outcome]));
919
+ const executableIntents = input.resolved.proposal.snapshot.intents.filter((intent) => {
920
+ if (!input.resolved.selectedIntentIds.includes(intent.intentId)) return false;
921
+ const outcome = preflightOutcomeByIntentId.get(intent.intentId);
922
+ return outcome?.outcome === "skipped" && outcome.message?.startsWith("Preflight passed");
923
+ });
924
+ if (executableIntents.length === 0) {
925
+ return { plan: input.plan, executed: false, fallbackReason: "No selected intent has a direct adapter execution path." };
926
+ }
927
+ const executedOutcomes = [];
928
+ const compilerRegistry = createAdapterMutationCompilerRegistry([
929
+ createGitHubIssueMutationCompiler({
930
+ mappings: mappingsFromAdapterPlan(input.plan.adapterPlan),
931
+ ...target.targetKind ? { targetKind: target.targetKind } : {}
932
+ })
933
+ ]);
934
+ for (const compilation of compilerRegistry.compile("github", executableIntents)) {
935
+ if (!compilation.ok) {
936
+ executedOutcomes.push(compilation.outcome);
937
+ continue;
938
+ }
939
+ executedOutcomes.push(
940
+ await applyGitHubIssueMutationOperation({
941
+ target: {
942
+ token: input.githubApply.token,
943
+ owner: target.owner,
944
+ repo: target.repoName,
945
+ ...typeof target.issueNumber === "number" ? { issueNumber: target.issueNumber } : {},
946
+ ...target.pullRequestNumber ? { pullRequestNumber: target.pullRequestNumber } : {}
947
+ },
948
+ operation: compilation.operation,
949
+ ...input.githubApply.fetchImpl ? { fetchImpl: input.githubApply.fetchImpl } : {}
950
+ })
951
+ );
952
+ }
953
+ const executedOutcomeByIntentId = new Map(executedOutcomes.map((outcome) => [outcome.intentId, outcome]));
954
+ const mergedOutcomes = (input.plan.outcomes ?? []).map((outcome) => executedOutcomeByIntentId.get(outcome.intentId) ?? outcome);
955
+ const updated = await input.repo.updateApplyPlanOutcomes({
956
+ id: input.plan.id,
957
+ outcomes: mergedOutcomes,
958
+ externalWritesExecuted: true
959
+ });
960
+ const plan = updated ?? input.plan;
961
+ const allSelectedApplied = input.resolved.selectedIntentIds.every(
962
+ (intentId) => plan.outcomes?.some((outcome) => outcome.intentId === intentId && outcome.outcome === "applied")
963
+ );
964
+ return {
965
+ plan,
966
+ executed: allSelectedApplied,
967
+ ...allSelectedApplied ? {} : { fallbackReason: "Some selected intents were not directly applied." }
968
+ };
119
969
  }
120
970
  var noopCallbackSink = {
121
971
  async deliver() {
122
972
  return;
123
973
  }
124
974
  };
975
+ function nextCallbackAttemptAt(input) {
976
+ const maxAttempts = input.maxAttempts ?? 5;
977
+ const nextAttempt = input.attempts + 1;
978
+ if (nextAttempt >= maxAttempts) return void 0;
979
+ const baseDelayMs = input.baseDelayMs ?? 5e3;
980
+ const maxDelayMs = input.maxDelayMs ?? 3e5;
981
+ const delayMs = Math.min(maxDelayMs, baseDelayMs * 2 ** Math.max(0, input.attempts));
982
+ return new Date((input.now ?? /* @__PURE__ */ new Date()).getTime() + delayMs).toISOString();
983
+ }
984
+ async function deliverCallbackDelivery(input) {
985
+ try {
986
+ await input.sink.deliver({
987
+ runId: input.delivery.runId,
988
+ kind: input.delivery.kind,
989
+ provider: input.delivery.provider,
990
+ uri: input.delivery.uri,
991
+ body: input.delivery.body,
992
+ ...input.delivery.threadKey ? { threadKey: input.delivery.threadKey } : {},
993
+ ...input.delivery.agentId ? { agentId: input.delivery.agentId } : {},
994
+ ...input.delivery.statusMessageKey ? { statusMessageKey: input.delivery.statusMessageKey } : {},
995
+ ...input.delivery.blocks ? { blocks: input.delivery.blocks } : {}
996
+ });
997
+ await input.repo.markCallbackDelivered({ deliveryId: input.delivery.id });
998
+ return true;
999
+ } catch (error) {
1000
+ const nextAttemptAt = nextCallbackAttemptAt({ attempts: input.delivery.attempts, ...input.retry ?? {} });
1001
+ await input.repo.markCallbackFailed({
1002
+ deliveryId: input.delivery.id,
1003
+ error: error instanceof Error ? error.message : String(error),
1004
+ ...nextAttemptAt ? { nextAttemptAt } : {}
1005
+ });
1006
+ return false;
1007
+ }
1008
+ }
1009
+ async function processPendingCallbacks(input) {
1010
+ const maxAttempts = input.retry?.maxAttempts ?? 5;
1011
+ const deliveries = await input.repo.claimPendingCallbackDeliveries({
1012
+ limit: input.limit ?? 20,
1013
+ ...input.retry?.now ? { now: input.retry.now } : {},
1014
+ maxAttempts
1015
+ });
1016
+ const result = { processed: 0, delivered: 0, failed: 0 };
1017
+ for (const delivery of deliveries) {
1018
+ result.processed += 1;
1019
+ const delivered = await deliverCallbackDelivery({
1020
+ repo: input.repo,
1021
+ sink: input.sink,
1022
+ delivery,
1023
+ ...input.retry ? { retry: input.retry } : {}
1024
+ });
1025
+ if (delivered) {
1026
+ result.delivered += 1;
1027
+ } else {
1028
+ result.failed += 1;
1029
+ }
1030
+ }
1031
+ return result;
1032
+ }
125
1033
  async function deliverAndAudit(input) {
126
- await input.sink.deliver(input.message);
127
- await input.repo.appendRunEvent({
1034
+ const delivery = await input.repo.enqueueCallbackDelivery({
128
1035
  runId: input.message.runId,
129
- type: `callback.${input.message.kind}.delivered`,
130
- payload: input.message
1036
+ kind: input.message.kind,
1037
+ provider: input.message.provider,
1038
+ uri: input.message.uri,
1039
+ body: input.message.body,
1040
+ ...input.message.threadKey ? { threadKey: input.message.threadKey } : {},
1041
+ ...input.message.agentId ? { agentId: input.message.agentId } : {},
1042
+ ...input.message.statusMessageKey ? { statusMessageKey: input.message.statusMessageKey } : {},
1043
+ ...input.message.blocks ? { blocks: input.message.blocks } : {}
1044
+ });
1045
+ await deliverCallbackDelivery({
1046
+ repo: input.repo,
1047
+ sink: input.sink,
1048
+ delivery,
1049
+ ...input.retry ? { retry: input.retry } : {}
131
1050
  });
132
1051
  }
133
1052
  function isAuthorized(request, pairingToken) {
@@ -140,6 +1059,12 @@ function createDispatcherApp(input) {
140
1059
  const repo = createOpenTagRepository(drizzle(sqlite));
141
1060
  const app = new Hono();
142
1061
  const callbackSink = input.callbackSink ?? noopCallbackSink;
1062
+ const presentation = input.presentation ?? createDefaultCallbackPresentation();
1063
+ const callbackRetry = input.callbackRetry ?? {};
1064
+ const admission = createAdmissionRuntime({
1065
+ repo,
1066
+ ...input.agentAccessProfileCheck ? { agentAccessProfileCheck: input.agentAccessProfileCheck } : {}
1067
+ });
143
1068
  app.get("/healthz", (c) => c.json({ ok: true }));
144
1069
  app.use("/v1/*", async (c, next) => {
145
1070
  if (!isAuthorized(c.req.raw, input.pairingToken)) {
@@ -152,6 +1077,11 @@ function createDispatcherApp(input) {
152
1077
  await repo.registerRunner(parsed);
153
1078
  return c.json({ ok: true }, 201);
154
1079
  });
1080
+ app.get("/v1/runners/:runnerId", async (c) => {
1081
+ const runner = await repo.getRunner({ runnerId: c.req.param("runnerId") });
1082
+ if (!runner) return c.json({ error: "runner_not_found" }, 404);
1083
+ return c.json({ runner });
1084
+ });
155
1085
  app.post("/v1/repo-bindings", async (c) => {
156
1086
  const parsed = CreateRepoBindingSchema.parse(await c.req.json());
157
1087
  await repo.createRepoBinding({
@@ -174,6 +1104,78 @@ function createDispatcherApp(input) {
174
1104
  if (!binding) return c.json({ error: "repo_binding_not_found" }, 404);
175
1105
  return c.json({ binding });
176
1106
  });
1107
+ app.post("/v1/repo-bindings/:provider/:owner/:repo/policy-rules", async (c) => {
1108
+ const parsed = UpsertPolicyRuleSchema.parse(await c.req.json());
1109
+ const rule = await repo.upsertRepoPolicyRule({
1110
+ provider: c.req.param("provider"),
1111
+ owner: c.req.param("owner"),
1112
+ repo: c.req.param("repo"),
1113
+ rule: parsed.rule
1114
+ });
1115
+ return c.json({ rule }, 201);
1116
+ });
1117
+ app.get("/v1/repo-bindings/:provider/:owner/:repo/policy-rules", async (c) => {
1118
+ const rules = await repo.listRepoPolicyRules({
1119
+ provider: c.req.param("provider"),
1120
+ owner: c.req.param("owner"),
1121
+ repo: c.req.param("repo")
1122
+ });
1123
+ return c.json({ rules });
1124
+ });
1125
+ app.post("/v1/repo-bindings/:provider/:owner/:repo/mutation-mappings", async (c) => {
1126
+ const parsed = UpsertMutationMappingSchema.parse(await c.req.json());
1127
+ const mapping = await repo.upsertRepoMutationMapping({
1128
+ provider: c.req.param("provider"),
1129
+ owner: c.req.param("owner"),
1130
+ repo: c.req.param("repo"),
1131
+ mapping: parsed.mapping
1132
+ });
1133
+ return c.json({ mapping }, 201);
1134
+ });
1135
+ app.get("/v1/repo-bindings/:provider/:owner/:repo/mutation-mappings", async (c) => {
1136
+ const mappings = await repo.listRepoMutationMappings({
1137
+ provider: c.req.param("provider"),
1138
+ owner: c.req.param("owner"),
1139
+ repo: c.req.param("repo")
1140
+ });
1141
+ return c.json({ mappings });
1142
+ });
1143
+ app.get("/v1/repo-bindings/:provider/:owner/:repo/metrics", async (c) => {
1144
+ const metrics = await repo.getRepoMetrics({
1145
+ provider: c.req.param("provider"),
1146
+ owner: c.req.param("owner"),
1147
+ repo: c.req.param("repo")
1148
+ });
1149
+ return c.json({ metrics });
1150
+ });
1151
+ app.get("/v1/work-thread-metrics", async (c) => {
1152
+ const threadId = c.req.query("threadId");
1153
+ if (!threadId) return c.json({ error: "thread_id_required" }, 422);
1154
+ const metrics = await repo.getWorkThreadMetrics({ threadId });
1155
+ return c.json({ metrics });
1156
+ });
1157
+ app.post("/v1/channel-bindings", async (c) => {
1158
+ const parsed = CreateChannelBindingSchema.parse(await c.req.json());
1159
+ await repo.upsertChannelBinding({
1160
+ provider: parsed.provider,
1161
+ accountId: parsed.accountId,
1162
+ conversationId: parsed.conversationId,
1163
+ repoProvider: parsed.repoProvider,
1164
+ owner: parsed.owner,
1165
+ repo: parsed.repo,
1166
+ ...parsed.metadata ? { metadata: parsed.metadata } : {}
1167
+ });
1168
+ return c.json({ ok: true }, 201);
1169
+ });
1170
+ app.get("/v1/channel-bindings/:provider/:accountId/:conversationId", async (c) => {
1171
+ const binding = await repo.getChannelBinding({
1172
+ provider: c.req.param("provider"),
1173
+ accountId: c.req.param("accountId"),
1174
+ conversationId: c.req.param("conversationId")
1175
+ });
1176
+ if (!binding) return c.json({ error: "channel_binding_not_found" }, 404);
1177
+ return c.json({ binding });
1178
+ });
177
1179
  app.post("/v1/slack-channel-bindings", async (c) => {
178
1180
  const parsed = CreateSlackChannelBindingSchema.parse(await c.req.json());
179
1181
  await repo.createSlackChannelBinding(parsed);
@@ -189,31 +1191,425 @@ function createDispatcherApp(input) {
189
1191
  });
190
1192
  app.post("/v1/runs", async (c) => {
191
1193
  const parsed = CreateRunSchema.parse(await c.req.json());
192
- const repoKey = repoKeyFromEvent(parsed.event);
193
- if (!repoKey) {
194
- return c.json({ error: "repo_context_missing" }, 422);
1194
+ const admitted = await admission.admitRun({ requestId: parsed.runId, event: parsed.event });
1195
+ if (admitted.outcome === "needs_human_decision") {
1196
+ return c.json({ decision: admitted.decision }, 202);
195
1197
  }
196
- const binding = await repo.getRepoBinding(repoKey);
197
- if (!binding) {
198
- return c.json({ error: "repo_not_bound" }, 403);
1198
+ if (admitted.outcome === "drop_duplicate") {
1199
+ await repo.appendRunEvent({
1200
+ runId: admitted.run.id,
1201
+ type: "admission.decided",
1202
+ payload: admitted.decision,
1203
+ visibility: "audit",
1204
+ importance: "normal",
1205
+ message: admitted.decision.reason
1206
+ });
1207
+ await repo.appendRunEvent({
1208
+ runId: admitted.run.id,
1209
+ type: "run.create_idempotent_replay",
1210
+ payload: { requestedRunId: parsed.runId, eventId: parsed.event.id },
1211
+ visibility: "audit",
1212
+ importance: "low"
1213
+ });
1214
+ return c.json({ decision: admitted.decision, run: admitted.run, idempotentReplay: true }, 200);
199
1215
  }
200
- if (isWriteCapable(parsed.event) && !actorIsAllowed(parsed.event, binding.allowedActors)) {
201
- return c.json({ error: "actor_not_allowed_for_write" }, 403);
1216
+ if (admitted.outcome === "follow_up_queued") {
1217
+ return c.json({ decision: admitted.decision, followUpRequest: admitted.followUpRequest }, 202);
202
1218
  }
203
- const run = await repo.createRun({ id: parsed.runId, event: parsed.event });
1219
+ const createdRun = await repo.createRun({ id: parsed.runId, event: parsed.event });
1220
+ if (!createdRun.created) {
1221
+ return c.json(
1222
+ {
1223
+ decision: {
1224
+ ...admitted.decision,
1225
+ action: "drop_duplicate",
1226
+ reason: "Source event already created a run.",
1227
+ reasonCode: "duplicate_source_event",
1228
+ activeRunId: createdRun.run.id
1229
+ },
1230
+ run: createdRun.run,
1231
+ idempotentReplay: true
1232
+ },
1233
+ 200
1234
+ );
1235
+ }
1236
+ const { run } = createdRun;
1237
+ if (presentation.shouldDeliverAcknowledgement(parsed.event.callback.provider)) {
1238
+ await deliverAndAudit({
1239
+ repo,
1240
+ sink: callbackSink,
1241
+ retry: callbackRetry,
1242
+ message: {
1243
+ runId: run.id,
1244
+ kind: "acknowledgement",
1245
+ provider: parsed.event.callback.provider,
1246
+ uri: parsed.event.callback.uri,
1247
+ body: presentation.acknowledgement({ provider: parsed.event.callback.provider, runId: run.id }),
1248
+ ...parsed.event.target.agentId ? { agentId: parsed.event.target.agentId } : {},
1249
+ ...parsed.event.callback.threadKey ? { threadKey: parsed.event.callback.threadKey } : {}
1250
+ }
1251
+ });
1252
+ }
1253
+ return c.json({ decision: admitted.decision, run }, 201);
1254
+ });
1255
+ app.post("/v1/thread-actions", async (c) => {
1256
+ const parsed = ThreadActionInputSchema.parse(await c.req.json());
1257
+ const command = parseThreadActionCommand(parsed.rawText);
1258
+ if (!command) {
1259
+ return c.json({ outcome: "ignored", reason: "not_action_command" }, 202);
1260
+ }
1261
+ const resolved = await resolveThreadAction({
1262
+ repo,
1263
+ command,
1264
+ callback: parsed.callback,
1265
+ ...parsed.metadata ? { metadata: parsed.metadata } : {}
1266
+ });
1267
+ if (!resolved.ok) {
1268
+ if (resolved.runId) {
1269
+ await deliverAndAudit({
1270
+ repo,
1271
+ sink: callbackSink,
1272
+ retry: callbackRetry,
1273
+ message: {
1274
+ runId: resolved.runId,
1275
+ kind: "final",
1276
+ provider: parsed.callback.provider,
1277
+ uri: parsed.callback.uri,
1278
+ body: resolved.message,
1279
+ ...parsed.callback.threadKey ? { threadKey: parsed.callback.threadKey } : {}
1280
+ }
1281
+ });
1282
+ }
1283
+ return c.json({ outcome: resolved.reason, message: resolved.message }, resolved.reason === "no_proposal" ? 404 : 409);
1284
+ }
1285
+ const authorization = await authorizeThreadAction({
1286
+ repo,
1287
+ resolved: resolved.resolved,
1288
+ actor: parsed.actor
1289
+ });
1290
+ if (!authorization.ok) {
1291
+ return c.json({ outcome: "unauthorized", reason: authorization.reason, message: authorization.message }, 403);
1292
+ }
1293
+ const selectionText = selectedActionSummary(resolved.resolved.selectedCandidates);
1294
+ const selectedIntents = resolved.resolved.proposal.snapshot.intents.filter(
1295
+ (intent) => resolved.resolved.selectedIntentIds.includes(intent.intentId)
1296
+ );
1297
+ const adapter = adapterForAction({
1298
+ event: resolved.resolved.proposal.event,
1299
+ callbackProvider: parsed.callback.provider,
1300
+ selectedIntents
1301
+ });
1302
+ const applyPlanId = stableApplyPlanId({ resolved: resolved.resolved, adapter });
1303
+ if (command.verb === "apply") {
1304
+ const existingPlan = await repo.getApplyPlan({ id: applyPlanId });
1305
+ if (existingPlan) {
1306
+ const existingDecision2 = await repo.getApprovalDecision({ id: existingPlan.approvalDecisionId });
1307
+ if (selectedIntentsAlreadyApplied({ plan: existingPlan, selectedIntentIds: resolved.resolved.selectedIntentIds })) {
1308
+ return c.json({ outcome: "already_applied", decision: existingDecision2, plan: existingPlan }, 200);
1309
+ }
1310
+ const fallbackReason = "An apply plan already exists for this selected action; OpenTag will not execute it again from a repeated thread reply.";
1311
+ const childRun2 = await createChildRunForThreadAction({
1312
+ repo,
1313
+ command,
1314
+ resolved: resolved.resolved,
1315
+ runId: stableChildRunId({
1316
+ command,
1317
+ resolved: resolved.resolved,
1318
+ sourceApplyPlanId: existingPlan.id,
1319
+ fallbackReason
1320
+ }),
1321
+ approvalDecisionId: existingPlan.approvalDecisionId,
1322
+ sourceApplyPlanId: existingPlan.id,
1323
+ fallbackReason
1324
+ });
1325
+ await deliverAndAudit({
1326
+ repo,
1327
+ sink: callbackSink,
1328
+ retry: callbackRetry,
1329
+ message: {
1330
+ runId: resolved.resolved.proposal.runId,
1331
+ kind: "final",
1332
+ provider: parsed.callback.provider,
1333
+ uri: parsed.callback.uri,
1334
+ body: renderChildRunCreatedBody({
1335
+ lead: "This action was already planned, so OpenTag will not execute the external write again.",
1336
+ resolved: resolved.resolved,
1337
+ childRun: childRun2,
1338
+ approvalDecisionId: existingPlan.approvalDecisionId,
1339
+ sourceApplyPlanId: existingPlan.id,
1340
+ fallbackReason
1341
+ }),
1342
+ ...parsed.callback.threadKey ? { threadKey: parsed.callback.threadKey } : {}
1343
+ }
1344
+ });
1345
+ return c.json({ outcome: "already_planned", decision: existingDecision2, plan: existingPlan, run: childRun2 }, 200);
1346
+ }
1347
+ }
1348
+ const providedDecision = parsed.id ? await repo.getApprovalDecision({ id: parsed.id }) : null;
1349
+ const canReuseProvidedDecision = providedDecision ? approvalDecisionMatchesThreadAction({
1350
+ decision: providedDecision,
1351
+ command,
1352
+ resolved: resolved.resolved,
1353
+ actor: parsed.actor
1354
+ }) : false;
1355
+ const approvalId = parsed.id && (!providedDecision || canReuseProvidedDecision) ? parsed.id : stableApprovalId({
1356
+ command,
1357
+ resolved: resolved.resolved,
1358
+ actor: parsed.actor
1359
+ });
1360
+ const existingDecision = canReuseProvidedDecision ? providedDecision : await repo.getApprovalDecision({ id: approvalId });
1361
+ const decision = existingDecision ?? await repo.recordApprovalDecision({
1362
+ id: approvalId,
1363
+ proposalId: resolved.resolved.proposal.snapshot.proposalId,
1364
+ approvedIntentIds: command.verb === "reject" ? [] : resolved.resolved.selectedIntentIds,
1365
+ ...command.verb === "reject" ? { rejectedIntentIds: resolved.resolved.selectedIntentIds } : {},
1366
+ approvedBy: parsed.actor,
1367
+ approvedAt: (/* @__PURE__ */ new Date()).toISOString(),
1368
+ scope: "manual",
1369
+ ...command.reason ? { reason: command.reason } : {},
1370
+ metadata: {
1371
+ source: "thread_action",
1372
+ rawText: command.rawText,
1373
+ verb: command.verb,
1374
+ selection: command.selection,
1375
+ callback: parsed.callback,
1376
+ ...parsed.metadata ? { ingressMetadata: parsed.metadata } : {}
1377
+ }
1378
+ });
1379
+ if (!decision) {
1380
+ return c.json({ error: "proposal_not_found" }, 404);
1381
+ }
1382
+ if (command.verb === "reject") {
1383
+ if (existingDecision) {
1384
+ return c.json({ outcome: "already_rejected", decision }, 200);
1385
+ }
1386
+ const body2 = `Rejected ${selectionText} from \`${resolved.resolved.proposal.snapshot.proposalId}\`.`;
1387
+ await deliverAndAudit({
1388
+ repo,
1389
+ sink: callbackSink,
1390
+ retry: callbackRetry,
1391
+ message: {
1392
+ runId: resolved.resolved.proposal.runId,
1393
+ kind: "final",
1394
+ provider: parsed.callback.provider,
1395
+ uri: parsed.callback.uri,
1396
+ body: body2,
1397
+ ...parsed.callback.threadKey ? { threadKey: parsed.callback.threadKey } : {}
1398
+ }
1399
+ });
1400
+ return c.json({ outcome: "rejected", decision }, 201);
1401
+ }
1402
+ if (command.verb === "approve") {
1403
+ if (existingDecision) {
1404
+ return c.json({ outcome: "already_approved", decision }, 200);
1405
+ }
1406
+ const body2 = `Approved ${selectionText} from \`${resolved.resolved.proposal.snapshot.proposalId}\`.
1407
+
1408
+ Reply with \`apply ${resolved.resolved.selectedCandidates[0]?.index ?? 1}\` to apply it, or \`continue ${resolved.resolved.selectedCandidates[0]?.index ?? 1}\` to continue with a follow-up run.`;
1409
+ await deliverAndAudit({
1410
+ repo,
1411
+ sink: callbackSink,
1412
+ retry: callbackRetry,
1413
+ message: {
1414
+ runId: resolved.resolved.proposal.runId,
1415
+ kind: "final",
1416
+ provider: parsed.callback.provider,
1417
+ uri: parsed.callback.uri,
1418
+ body: body2,
1419
+ ...parsed.callback.threadKey ? { threadKey: parsed.callback.threadKey } : {}
1420
+ }
1421
+ });
1422
+ return c.json({ outcome: "approved", decision }, 201);
1423
+ }
1424
+ if (command.verb === "continue") {
1425
+ const childRun2 = await createChildRunForThreadAction({
1426
+ repo,
1427
+ command,
1428
+ resolved: resolved.resolved,
1429
+ runId: stableChildRunId({ command, resolved: resolved.resolved }),
1430
+ approvalDecisionId: decision.id
1431
+ });
1432
+ const body2 = renderChildRunCreatedBody({
1433
+ lead: `Continuing from ${selectionText} in \`${resolved.resolved.proposal.snapshot.proposalId}\`.`,
1434
+ resolved: resolved.resolved,
1435
+ childRun: childRun2,
1436
+ approvalDecisionId: decision.id
1437
+ });
1438
+ await deliverAndAudit({
1439
+ repo,
1440
+ sink: callbackSink,
1441
+ retry: callbackRetry,
1442
+ message: {
1443
+ runId: resolved.resolved.proposal.runId,
1444
+ kind: "final",
1445
+ provider: parsed.callback.provider,
1446
+ uri: parsed.callback.uri,
1447
+ body: body2,
1448
+ ...parsed.callback.threadKey ? { threadKey: parsed.callback.threadKey } : {}
1449
+ }
1450
+ });
1451
+ return c.json({ outcome: "child_run_created", decision, run: childRun2 }, 201);
1452
+ }
1453
+ const planResult = await repo.createApplyPlanOnce({
1454
+ id: applyPlanId,
1455
+ proposalId: resolved.resolved.proposal.snapshot.proposalId,
1456
+ approvalDecisionId: decision.id,
1457
+ selectedIntentIds: resolved.resolved.selectedIntentIds,
1458
+ adapter
1459
+ });
1460
+ if (!planResult) {
1461
+ return c.json({ error: "proposal_or_approval_not_found" }, 404);
1462
+ }
1463
+ if (!planResult.created) {
1464
+ if (selectedIntentsAlreadyApplied({ plan: planResult.plan, selectedIntentIds: resolved.resolved.selectedIntentIds })) {
1465
+ return c.json({ outcome: "already_applied", decision, plan: planResult.plan }, 200);
1466
+ }
1467
+ const fallbackReason = "An apply plan already exists for this selected action; OpenTag will not execute it again from a repeated thread reply.";
1468
+ const childRun2 = await createChildRunForThreadAction({
1469
+ repo,
1470
+ command,
1471
+ resolved: resolved.resolved,
1472
+ runId: stableChildRunId({
1473
+ command,
1474
+ resolved: resolved.resolved,
1475
+ sourceApplyPlanId: planResult.plan.id,
1476
+ fallbackReason
1477
+ }),
1478
+ approvalDecisionId: decision.id,
1479
+ sourceApplyPlanId: planResult.plan.id,
1480
+ fallbackReason
1481
+ });
1482
+ await deliverAndAudit({
1483
+ repo,
1484
+ sink: callbackSink,
1485
+ retry: callbackRetry,
1486
+ message: {
1487
+ runId: resolved.resolved.proposal.runId,
1488
+ kind: "final",
1489
+ provider: parsed.callback.provider,
1490
+ uri: parsed.callback.uri,
1491
+ body: renderChildRunCreatedBody({
1492
+ lead: "This action was already planned, so OpenTag will not execute the external write again.",
1493
+ resolved: resolved.resolved,
1494
+ childRun: childRun2,
1495
+ approvalDecisionId: decision.id,
1496
+ sourceApplyPlanId: planResult.plan.id,
1497
+ fallbackReason
1498
+ }),
1499
+ ...parsed.callback.threadKey ? { threadKey: parsed.callback.threadKey } : {}
1500
+ }
1501
+ });
1502
+ return c.json({ outcome: "already_planned", decision, plan: planResult.plan, run: childRun2 }, 200);
1503
+ }
1504
+ const plan = planResult.plan;
1505
+ const execution = await executeGitHubApplyPlan({
1506
+ repo,
1507
+ plan,
1508
+ resolved: resolved.resolved,
1509
+ ...input.githubApply ? { githubApply: input.githubApply } : {}
1510
+ });
1511
+ if (execution.executed) {
1512
+ const outcomes = execution.plan.outcomes ?? [];
1513
+ const body2 = [
1514
+ `Applied ${selectionText} from \`${resolved.resolved.proposal.snapshot.proposalId}\`.`,
1515
+ "",
1516
+ "Result:",
1517
+ ...outcomes.filter((outcome) => resolved.resolved.selectedIntentIds.includes(outcome.intentId)).map((outcome) => `- \`${outcome.intentId}\`: ${outcome.outcome}${outcome.externalUri ? ` (${outcome.externalUri})` : ""}`)
1518
+ ].join("\n");
1519
+ await deliverAndAudit({
1520
+ repo,
1521
+ sink: callbackSink,
1522
+ retry: callbackRetry,
1523
+ message: {
1524
+ runId: resolved.resolved.proposal.runId,
1525
+ kind: "final",
1526
+ provider: parsed.callback.provider,
1527
+ uri: parsed.callback.uri,
1528
+ body: body2,
1529
+ ...parsed.callback.threadKey ? { threadKey: parsed.callback.threadKey } : {}
1530
+ }
1531
+ });
1532
+ return c.json({ outcome: "applied", decision, plan: execution.plan }, 201);
1533
+ }
1534
+ const childRun = await createChildRunForThreadAction({
1535
+ repo,
1536
+ command,
1537
+ resolved: resolved.resolved,
1538
+ runId: stableChildRunId({
1539
+ command,
1540
+ resolved: resolved.resolved,
1541
+ sourceApplyPlanId: execution.plan.id,
1542
+ fallbackReason: execution.fallbackReason ?? "OpenTag cannot directly apply this intent yet."
1543
+ }),
1544
+ approvalDecisionId: decision.id,
1545
+ sourceApplyPlanId: execution.plan.id,
1546
+ fallbackReason: execution.fallbackReason ?? "OpenTag cannot directly apply this intent yet."
1547
+ });
1548
+ const body = renderChildRunCreatedBody({
1549
+ lead: `Action ${selectionText} was approved, but OpenTag cannot directly apply it yet.`,
1550
+ resolved: resolved.resolved,
1551
+ childRun,
1552
+ approvalDecisionId: decision.id,
1553
+ sourceApplyPlanId: execution.plan.id,
1554
+ fallbackReason: execution.fallbackReason ?? "The adapter could not execute the selected intent."
1555
+ });
204
1556
  await deliverAndAudit({
205
1557
  repo,
206
1558
  sink: callbackSink,
1559
+ retry: callbackRetry,
207
1560
  message: {
208
- runId: run.id,
209
- kind: "acknowledgement",
210
- provider: parsed.event.callback.provider,
211
- uri: parsed.event.callback.uri,
212
- body: renderAcknowledgement(run.id),
213
- ...parsed.event.callback.threadKey ? { threadKey: parsed.event.callback.threadKey } : {}
1561
+ runId: resolved.resolved.proposal.runId,
1562
+ kind: "final",
1563
+ provider: parsed.callback.provider,
1564
+ uri: parsed.callback.uri,
1565
+ body,
1566
+ ...parsed.callback.threadKey ? { threadKey: parsed.callback.threadKey } : {}
214
1567
  }
215
1568
  });
216
- return c.json({ run }, 201);
1569
+ return c.json({ outcome: "child_run_created", decision, plan: execution.plan, run: childRun }, 201);
1570
+ });
1571
+ app.get("/v1/follow-up-requests/:id", async (c) => {
1572
+ const followUpRequest = await repo.getFollowUpRequest({ id: c.req.param("id") });
1573
+ if (!followUpRequest) return c.json({ error: "follow_up_request_not_found" }, 404);
1574
+ return c.json({ followUpRequest });
1575
+ });
1576
+ app.post("/v1/follow-up-requests/:id/create-run", async (c) => {
1577
+ const parsed = PromoteFollowUpRequestSchema.parse(await c.req.json());
1578
+ let promoted;
1579
+ try {
1580
+ promoted = await repo.createRunFromFollowUpRequest({
1581
+ followUpRequestId: c.req.param("id"),
1582
+ runId: parsed.runId
1583
+ });
1584
+ } catch (error) {
1585
+ const message = error instanceof Error ? error.message : String(error);
1586
+ if (message.startsWith("Follow-up request not found:")) {
1587
+ return c.json({ error: "follow_up_request_not_found" }, 404);
1588
+ }
1589
+ if (message.includes("is not queued")) {
1590
+ return c.json({ error: "follow_up_request_not_queued" }, 409);
1591
+ }
1592
+ throw error;
1593
+ }
1594
+ const followUpRequest = promoted.followUpRequest;
1595
+ const event = followUpRequest.event;
1596
+ if (presentation.shouldDeliverAcknowledgement(event.callback.provider)) {
1597
+ await deliverAndAudit({
1598
+ repo,
1599
+ sink: callbackSink,
1600
+ retry: callbackRetry,
1601
+ message: {
1602
+ runId: promoted.run.id,
1603
+ kind: "acknowledgement",
1604
+ provider: event.callback.provider,
1605
+ uri: event.callback.uri,
1606
+ body: presentation.acknowledgement({ provider: event.callback.provider, runId: promoted.run.id }),
1607
+ ...event.target.agentId ? { agentId: event.target.agentId } : {},
1608
+ ...event.callback.threadKey ? { threadKey: event.callback.threadKey } : {}
1609
+ }
1610
+ });
1611
+ }
1612
+ return c.json({ followUpRequest, run: promoted.run }, 201);
217
1613
  });
218
1614
  app.post("/v1/runners/:runnerId/claim", async (c) => {
219
1615
  const claimed = await repo.claimNextRun({ runnerId: c.req.param("runnerId"), leaseSeconds: 60 });
@@ -226,60 +1622,260 @@ function createDispatcherApp(input) {
226
1622
  return c.json({ ok: true });
227
1623
  });
228
1624
  app.post("/v1/runs/:runId/running", async (c) => {
1625
+ return c.json({
1626
+ error: "runner_scoped_endpoint_required",
1627
+ message: "Use /v1/runners/:runnerId/runs/:runId/running, /progress, or /complete."
1628
+ }, 410);
1629
+ });
1630
+ app.post("/v1/runners/:runnerId/runs/:runId/running", async (c) => {
229
1631
  const body = z.object({ executor: z.string().min(1) }).parse(await c.req.json());
230
- await repo.markRunning({ runId: c.req.param("runId"), executor: body.executor });
1632
+ const ok = await repo.markRunning({
1633
+ runId: c.req.param("runId"),
1634
+ runnerId: c.req.param("runnerId"),
1635
+ executor: body.executor
1636
+ });
1637
+ if (!ok) return c.json({ error: "run_not_claimed_by_runner" }, 404);
231
1638
  return c.json({ ok: true });
232
1639
  });
233
- app.post("/v1/runs/:runId/progress", async (c) => {
1640
+ app.post("/v1/runs/:runId/progress", async () => {
1641
+ return new Response(JSON.stringify({
1642
+ error: "runner_scoped_endpoint_required",
1643
+ message: "Use /v1/runners/:runnerId/runs/:runId/running, /progress, or /complete."
1644
+ }), { status: 410, headers: { "content-type": "application/json" } });
1645
+ });
1646
+ app.post("/v1/runners/:runnerId/runs/:runId/progress", async (c) => {
234
1647
  const runId = c.req.param("runId");
235
1648
  const body = ProgressSchema.parse(await c.req.json());
236
- const stored = await repo.getRun({ runId });
237
- if (!stored) return c.json({ error: "run_not_found" }, 404);
238
- await repo.recordProgress({
1649
+ const ok = await repo.recordProgress({
239
1650
  runId,
1651
+ runnerId: c.req.param("runnerId"),
240
1652
  message: body.message,
241
1653
  ...body.type ? { type: body.type } : {},
242
- ...body.at ? { at: body.at } : {}
243
- });
244
- await deliverAndAudit({
245
- repo,
246
- sink: callbackSink,
247
- message: {
248
- runId,
249
- kind: "progress",
250
- provider: stored.event.callback.provider,
251
- uri: stored.event.callback.uri,
252
- body: renderProgress({ runId, message: body.message }),
253
- ...stored.event.callback.threadKey ? { threadKey: stored.event.callback.threadKey } : {}
254
- }
1654
+ ...body.at ? { at: body.at } : {},
1655
+ ...body.visibility ? { visibility: body.visibility } : {},
1656
+ ...body.importance ? { importance: body.importance } : {}
255
1657
  });
1658
+ if (!ok) return c.json({ error: "run_not_claimed_by_runner" }, 404);
1659
+ const stored = await repo.getRun({ runId });
1660
+ if (!stored) return c.json({ error: "run_not_found" }, 404);
1661
+ if (presentation.shouldDeliverProgress(stored.event.callback.provider)) {
1662
+ await deliverAndAudit({
1663
+ repo,
1664
+ sink: callbackSink,
1665
+ retry: callbackRetry,
1666
+ message: {
1667
+ runId,
1668
+ kind: "progress",
1669
+ provider: stored.event.callback.provider,
1670
+ uri: stored.event.callback.uri,
1671
+ body: presentation.progress({ provider: stored.event.callback.provider, runId, message: body.message }),
1672
+ ...stored.event.target.agentId ? { agentId: stored.event.target.agentId } : {},
1673
+ ...stored.event.callback.threadKey ? { threadKey: stored.event.callback.threadKey } : {},
1674
+ statusMessageKey: `${runId}:status`
1675
+ }
1676
+ });
1677
+ }
256
1678
  return c.json({ ok: true });
257
1679
  });
258
- app.post("/v1/runs/:runId/complete", async (c) => {
1680
+ app.post("/v1/runs/:runId/complete", async () => {
1681
+ return new Response(JSON.stringify({
1682
+ error: "runner_scoped_endpoint_required",
1683
+ message: "Use /v1/runners/:runnerId/runs/:runId/running, /progress, or /complete."
1684
+ }), { status: 410, headers: { "content-type": "application/json" } });
1685
+ });
1686
+ app.post("/v1/runners/:runnerId/runs/:runId/complete", async (c) => {
259
1687
  const runId = c.req.param("runId");
260
1688
  const parsed = CompleteRunSchema.parse(await c.req.json());
1689
+ const ok = await repo.completeRun({ runId, runnerId: c.req.param("runnerId"), result: parsed.result });
1690
+ if (!ok) return c.json({ error: "run_not_claimed_by_runner" }, 404);
261
1691
  const stored = await repo.getRun({ runId });
262
1692
  if (!stored) return c.json({ error: "run_not_found" }, 404);
263
- await repo.completeRun({ runId, result: parsed.result });
1693
+ const finalPresentation = presentation.final({ provider: stored.event.callback.provider, result: parsed.result });
264
1694
  await deliverAndAudit({
265
1695
  repo,
266
1696
  sink: callbackSink,
1697
+ retry: callbackRetry,
267
1698
  message: {
268
1699
  runId,
269
1700
  kind: "final",
270
1701
  provider: stored.event.callback.provider,
271
1702
  uri: stored.event.callback.uri,
272
- body: renderFinalResult(parsed.result),
273
- ...stored.event.callback.threadKey ? { threadKey: stored.event.callback.threadKey } : {}
1703
+ body: finalPresentation.body,
1704
+ ...stored.event.target.agentId ? { agentId: stored.event.target.agentId } : {},
1705
+ ...stored.event.callback.threadKey ? { threadKey: stored.event.callback.threadKey } : {},
1706
+ ...finalPresentation.blocks?.length ? { blocks: finalPresentation.blocks } : {}
274
1707
  }
275
1708
  });
276
1709
  return c.json({ ok: true });
277
1710
  });
1711
+ app.get("/v1/proposals/:proposalId", async (c) => {
1712
+ const proposal = await repo.getSuggestedChanges({ proposalId: c.req.param("proposalId") });
1713
+ if (!proposal) return c.json({ error: "proposal_not_found" }, 404);
1714
+ return c.json(proposal);
1715
+ });
1716
+ app.get("/v1/proposals/:proposalId/lineage", async (c) => {
1717
+ const lineage = await repo.getProposalLineage({ proposalId: c.req.param("proposalId") });
1718
+ if (!lineage) return c.json({ error: "proposal_not_found" }, 404);
1719
+ return c.json({ lineage });
1720
+ });
1721
+ app.get("/v1/proposals/:proposalId/current-intents", async (c) => {
1722
+ const intents = await repo.listCurrentMutationIntents({ proposalId: c.req.param("proposalId") });
1723
+ if (!intents) return c.json({ error: "proposal_not_found" }, 404);
1724
+ return c.json({ intents });
1725
+ });
1726
+ app.post("/v1/proposals/:proposalId/approvals", async (c) => {
1727
+ const proposalId = c.req.param("proposalId");
1728
+ const parsedBody = ApprovalDecisionInputSchema.safeParse(await c.req.json());
1729
+ if (!parsedBody.success) return c.json({ error: "invalid_approval_decision" }, 400);
1730
+ const body = parsedBody.data;
1731
+ const decision = await repo.recordApprovalDecision({
1732
+ id: body.id ?? `approval_${proposalId}_${Date.now()}`,
1733
+ proposalId,
1734
+ approvedIntentIds: body.approvedIntentIds,
1735
+ ...body.rejectedIntentIds?.length ? { rejectedIntentIds: body.rejectedIntentIds } : {},
1736
+ approvedBy: body.approvedBy,
1737
+ approvedAt: body.approvedAt ?? (/* @__PURE__ */ new Date()).toISOString(),
1738
+ scope: body.scope,
1739
+ ...body.reason ? { reason: body.reason } : {},
1740
+ ...body.metadata ? { metadata: body.metadata } : {}
1741
+ });
1742
+ if (!decision) return c.json({ error: "proposal_not_found" }, 404);
1743
+ return c.json({ decision }, 201);
1744
+ });
1745
+ app.get("/v1/approvals/:approvalDecisionId", async (c) => {
1746
+ const decision = await repo.getApprovalDecision({ id: c.req.param("approvalDecisionId") });
1747
+ if (!decision) return c.json({ error: "approval_decision_not_found" }, 404);
1748
+ return c.json({ decision });
1749
+ });
1750
+ app.post("/v1/proposals/:proposalId/apply-plans", async (c) => {
1751
+ const proposalId = c.req.param("proposalId");
1752
+ const body = ApplyPlanInputSchema.parse(await c.req.json());
1753
+ let executableTarget;
1754
+ if (body.execute) {
1755
+ if (body.adapter !== "github") {
1756
+ return c.json({ error: "apply_execution_adapter_not_supported" }, 422);
1757
+ }
1758
+ if (!input.githubApply) {
1759
+ return c.json({ error: "github_apply_not_configured" }, 422);
1760
+ }
1761
+ const proposal = await repo.getSuggestedChanges({ proposalId });
1762
+ if (!proposal) return c.json({ error: "proposal_not_found" }, 404);
1763
+ const stored = await repo.getRun({ runId: proposal.runId });
1764
+ if (!stored) return c.json({ error: "run_not_found" }, 404);
1765
+ const target = githubTargetFromEvent(stored.event);
1766
+ if (!target) {
1767
+ return c.json({ error: "github_target_missing" }, 422);
1768
+ }
1769
+ executableTarget = { proposal, target };
1770
+ }
1771
+ const applyPlanInput = {
1772
+ id: body.id ?? `apply_${proposalId}_${Date.now()}`,
1773
+ proposalId,
1774
+ approvalDecisionId: body.approvalDecisionId,
1775
+ ...body.selectedIntentIds !== void 0 ? { selectedIntentIds: body.selectedIntentIds } : {},
1776
+ ...body.adapter ? { adapter: body.adapter } : {}
1777
+ };
1778
+ let plan;
1779
+ if (body.execute) {
1780
+ const planResult = await repo.createApplyPlanOnce(applyPlanInput);
1781
+ if (!planResult) return c.json({ error: "proposal_or_approval_not_found" }, 404);
1782
+ plan = planResult.plan;
1783
+ if (!planResult.created) {
1784
+ return c.json({ plan, alreadyPlanned: true }, 200);
1785
+ }
1786
+ } else {
1787
+ const planResult = await repo.createApplyPlan(applyPlanInput);
1788
+ if (!planResult) return c.json({ error: "proposal_or_approval_not_found" }, 404);
1789
+ plan = planResult;
1790
+ }
1791
+ if (body.execute && executableTarget) {
1792
+ const githubApply = input.githubApply;
1793
+ if (!githubApply) {
1794
+ return c.json({ error: "github_apply_not_configured" }, 422);
1795
+ }
1796
+ const preflightOutcomeByIntentId = new Map((plan.outcomes ?? []).map((outcome) => [outcome.intentId, outcome]));
1797
+ const executableIntents = executableTarget.proposal.snapshot.intents.filter((intent) => {
1798
+ const outcome = preflightOutcomeByIntentId.get(intent.intentId);
1799
+ return outcome?.outcome === "skipped" && outcome.message?.startsWith("Preflight passed");
1800
+ });
1801
+ const target = {
1802
+ token: githubApply.token,
1803
+ owner: executableTarget.target.owner,
1804
+ repo: executableTarget.target.repoName,
1805
+ ...typeof executableTarget.target.issueNumber === "number" ? { issueNumber: executableTarget.target.issueNumber } : {},
1806
+ ...executableTarget.target.pullRequestNumber ? { pullRequestNumber: executableTarget.target.pullRequestNumber } : {}
1807
+ };
1808
+ const executedOutcomes = [];
1809
+ const compilerRegistry = createAdapterMutationCompilerRegistry([
1810
+ createGitHubIssueMutationCompiler({
1811
+ mappings: mappingsFromAdapterPlan(plan.adapterPlan),
1812
+ ...executableTarget.target.targetKind ? { targetKind: executableTarget.target.targetKind } : {}
1813
+ })
1814
+ ]);
1815
+ for (const compilation of compilerRegistry.compile("github", executableIntents)) {
1816
+ if (!compilation.ok) {
1817
+ executedOutcomes.push(compilation.outcome);
1818
+ continue;
1819
+ }
1820
+ executedOutcomes.push(
1821
+ await applyGitHubIssueMutationOperation({
1822
+ target,
1823
+ operation: compilation.operation,
1824
+ ...githubApply.fetchImpl ? { fetchImpl: githubApply.fetchImpl } : {}
1825
+ })
1826
+ );
1827
+ }
1828
+ const executedOutcomeByIntentId = new Map(executedOutcomes.map((outcome) => [outcome.intentId, outcome]));
1829
+ const mergedOutcomes = (plan.outcomes ?? []).map((outcome) => executedOutcomeByIntentId.get(outcome.intentId) ?? outcome);
1830
+ const executedPlan = await repo.updateApplyPlanOutcomes({
1831
+ id: plan.id,
1832
+ outcomes: mergedOutcomes,
1833
+ externalWritesExecuted: true
1834
+ });
1835
+ return c.json({ plan: executedPlan ?? plan }, 201);
1836
+ }
1837
+ return c.json({ plan }, 201);
1838
+ });
1839
+ app.get("/v1/apply-plans/:applyPlanId", async (c) => {
1840
+ const plan = await repo.getApplyPlan({ id: c.req.param("applyPlanId") });
1841
+ if (!plan) return c.json({ error: "apply_plan_not_found" }, 404);
1842
+ return c.json({ plan });
1843
+ });
1844
+ app.post("/v1/runs/:runId/child-runs", async (c) => {
1845
+ const parentRunId = c.req.param("runId");
1846
+ const body = ChildRunInputSchema.parse(await c.req.json());
1847
+ const parent = await repo.getRun({ runId: parentRunId });
1848
+ if (!parent) return c.json({ error: "parent_run_not_found" }, 404);
1849
+ const receivedAt = (/* @__PURE__ */ new Date()).toISOString();
1850
+ const sourceProposalId = body.sourceProposalId ?? body.action.targetId;
1851
+ const { run } = await repo.createRun({
1852
+ id: body.runId,
1853
+ event: childEventFromParent({
1854
+ parentEvent: parent.event,
1855
+ childRunId: body.runId,
1856
+ ...body.commandText ? { commandText: body.commandText } : {},
1857
+ actionKind: body.action.kind,
1858
+ receivedAt
1859
+ }),
1860
+ parentRunId,
1861
+ triggeredByAction: body.action,
1862
+ ...sourceProposalId ? { sourceProposalId } : {},
1863
+ ...body.sourceApplyPlanId ? { sourceApplyPlanId: body.sourceApplyPlanId } : {}
1864
+ });
1865
+ return c.json({ run }, 201);
1866
+ });
278
1867
  app.get("/v1/runs/:runId", async (c) => {
279
1868
  const stored = await repo.getRun({ runId: c.req.param("runId") });
280
1869
  if (!stored) return c.json({ error: "run_not_found" }, 404);
281
1870
  return c.json(stored);
282
1871
  });
1872
+ app.get("/v1/runs/:runId/metrics", async (c) => {
1873
+ const runId = c.req.param("runId");
1874
+ const stored = await repo.getRun({ runId });
1875
+ if (!stored) return c.json({ error: "run_not_found" }, 404);
1876
+ const metrics = await repo.getRunMetrics({ runId });
1877
+ return c.json({ metrics });
1878
+ });
283
1879
  app.get("/v1/runs/:runId/events", async (c) => {
284
1880
  const events = await repo.listRunEvents({ runId: c.req.param("runId") });
285
1881
  return c.json({ events });
@@ -288,8 +1884,12 @@ function createDispatcherApp(input) {
288
1884
  }
289
1885
  export {
290
1886
  createCompositeCallbackSink,
1887
+ createDefaultCallbackPresentation,
291
1888
  createDispatcherApp,
292
1889
  createGitHubCallbackSink,
293
- createSlackCallbackSink
1890
+ createLarkCallbackSink,
1891
+ createSlackCallbackSink,
1892
+ createTelegramCallbackSink,
1893
+ processPendingCallbacks
294
1894
  };
295
1895
  //# sourceMappingURL=index.js.map