@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/LICENSE +21 -0
- package/README.md +6 -3
- package/dist/admission.d.ts +44 -0
- package/dist/admission.d.ts.map +1 -0
- package/dist/callbacks.d.ts +21 -1
- package/dist/callbacks.d.ts.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1934 -85
- package/dist/index.js.map +1 -1
- package/dist/presentation.d.ts +27 -0
- package/dist/presentation.d.ts.map +1 -0
- package/dist/server.d.ts +49 -1
- package/dist/server.d.ts.map +1 -1
- package/package.json +11 -6
package/dist/index.js
CHANGED
|
@@ -1,45 +1,133 @@
|
|
|
1
1
|
// src/callbacks.ts
|
|
2
|
-
import {
|
|
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
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
"
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
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
|
|
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 ${
|
|
115
|
+
authorization: `Bearer ${botToken}`,
|
|
36
116
|
"content-type": "application/json"
|
|
37
117
|
},
|
|
38
|
-
body: JSON.stringify(
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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/
|
|
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
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
|
114
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
127
|
-
await input.repo.appendRunEvent({
|
|
1207
|
+
const delivery = await input.repo.enqueueCallbackDelivery({
|
|
128
1208
|
runId: input.message.runId,
|
|
129
|
-
|
|
130
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
192
|
-
const
|
|
193
|
-
if (
|
|
194
|
-
return c.json({
|
|
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
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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
|
-
|
|
201
|
-
|
|
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
|
|
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:
|
|
209
|
-
kind: "
|
|
210
|
-
provider: parsed.
|
|
211
|
-
uri: parsed.
|
|
212
|
-
body
|
|
213
|
-
...parsed.
|
|
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
|
-
|
|
230
|
-
|
|
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 (
|
|
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 =
|
|
236
|
-
const
|
|
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
|
-
|
|
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 (
|
|
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 =
|
|
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
|
-
|
|
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:
|
|
273
|
-
...stored.event.
|
|
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
|
-
|
|
2138
|
+
createLarkCallbackSink,
|
|
2139
|
+
createSlackCallbackSink,
|
|
2140
|
+
createSlackSourceReceiptSink,
|
|
2141
|
+
createTelegramCallbackSink,
|
|
2142
|
+
processPendingCallbacks
|
|
294
2143
|
};
|
|
295
2144
|
//# sourceMappingURL=index.js.map
|