@opentag/dispatcher 0.1.0 → 0.3.0

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