@agentworkforce/sage 1.1.2 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/app.d.ts.map +1 -1
- package/dist/app.js +56 -23
- package/dist/e2e/e2e-harness.d.ts +36 -0
- package/dist/e2e/e2e-harness.d.ts.map +1 -0
- package/dist/e2e/e2e-harness.js +278 -0
- package/dist/e2e/mock-cloud-proxy-server.d.ts +25 -0
- package/dist/e2e/mock-cloud-proxy-server.d.ts.map +1 -0
- package/dist/e2e/mock-cloud-proxy-server.js +149 -0
- package/dist/e2e/mock-relayfile-server.d.ts +35 -0
- package/dist/e2e/mock-relayfile-server.d.ts.map +1 -0
- package/dist/e2e/mock-relayfile-server.js +488 -0
- package/dist/integrations/cloud-proxy-provider.js +1 -1
- package/dist/integrations/freshness-envelope.js +1 -1
- package/dist/integrations/github.d.ts +24 -1
- package/dist/integrations/github.d.ts.map +1 -1
- package/dist/integrations/github.js +116 -1
- package/dist/integrations/linear-ingress.d.ts +30 -0
- package/dist/integrations/linear-ingress.d.ts.map +1 -0
- package/dist/integrations/linear-ingress.js +58 -0
- package/dist/integrations/notion-ingress.d.ts +26 -0
- package/dist/integrations/notion-ingress.d.ts.map +1 -0
- package/dist/integrations/notion-ingress.js +70 -0
- package/dist/integrations/provider-ingress-dedup.d.ts +14 -0
- package/dist/integrations/provider-ingress-dedup.d.ts.map +1 -0
- package/dist/integrations/provider-ingress-dedup.js +35 -0
- package/dist/integrations/provider-ingress-dedup.test.d.ts +2 -0
- package/dist/integrations/provider-ingress-dedup.test.d.ts.map +1 -0
- package/dist/integrations/provider-ingress-dedup.test.js +55 -0
- package/dist/integrations/provider-write-facade.d.ts +80 -0
- package/dist/integrations/provider-write-facade.d.ts.map +1 -0
- package/dist/integrations/provider-write-facade.js +417 -0
- package/dist/integrations/provider-write-facade.test.d.ts +2 -0
- package/dist/integrations/provider-write-facade.test.d.ts.map +1 -0
- package/dist/integrations/provider-write-facade.test.js +247 -0
- package/dist/integrations/read-your-writes.test.d.ts +2 -0
- package/dist/integrations/read-your-writes.test.d.ts.map +1 -0
- package/dist/integrations/read-your-writes.test.js +170 -0
- package/dist/integrations/recent-actions-overlay.d.ts +1 -0
- package/dist/integrations/recent-actions-overlay.d.ts.map +1 -1
- package/dist/integrations/recent-actions-overlay.js +3 -0
- package/dist/integrations/relayfile-reader-envelope.test.d.ts +2 -0
- package/dist/integrations/relayfile-reader-envelope.test.d.ts.map +1 -0
- package/dist/integrations/relayfile-reader-envelope.test.js +198 -0
- package/dist/integrations/relayfile-reader.d.ts +20 -1
- package/dist/integrations/relayfile-reader.d.ts.map +1 -1
- package/dist/integrations/relayfile-reader.js +334 -48
- package/dist/integrations/slack-egress.d.ts +2 -1
- package/dist/integrations/slack-egress.d.ts.map +1 -1
- package/dist/integrations/slack-egress.js +28 -1
- package/dist/observability.e2e.test.d.ts +2 -0
- package/dist/observability.e2e.test.d.ts.map +1 -0
- package/dist/observability.e2e.test.js +411 -0
- package/package.json +9 -4
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
import { randomBytes, randomUUID } from "node:crypto";
|
|
2
|
+
export const DEFAULT_WRITE_MODE = "sync";
|
|
3
|
+
export const STATUS_RETENTION_MS = 300_000;
|
|
4
|
+
class MissingProviderError extends Error {
|
|
5
|
+
constructor(provider, actionKind) {
|
|
6
|
+
super(`Write action "${actionKind}" requires a ${provider} provider`);
|
|
7
|
+
this.name = "MissingProviderError";
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
function isRecord(value) {
|
|
11
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
12
|
+
}
|
|
13
|
+
function toErrorMessage(error) {
|
|
14
|
+
if (error instanceof Error && error.message) {
|
|
15
|
+
return error.message;
|
|
16
|
+
}
|
|
17
|
+
return String(error);
|
|
18
|
+
}
|
|
19
|
+
function compactRecord(input) {
|
|
20
|
+
const output = {};
|
|
21
|
+
for (const [key, value] of Object.entries(input)) {
|
|
22
|
+
if (value !== undefined) {
|
|
23
|
+
output[key] = value;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return output;
|
|
27
|
+
}
|
|
28
|
+
function requireString(action, key) {
|
|
29
|
+
const value = action[key];
|
|
30
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
31
|
+
throw new Error(`Write action "${action.kind}" requires a non-empty ${String(key)}`);
|
|
32
|
+
}
|
|
33
|
+
return value;
|
|
34
|
+
}
|
|
35
|
+
function optionalString(action, key) {
|
|
36
|
+
const value = action[key];
|
|
37
|
+
return typeof value === "string" ? value : undefined;
|
|
38
|
+
}
|
|
39
|
+
function requireNumber(action, key) {
|
|
40
|
+
const value = action[key];
|
|
41
|
+
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
42
|
+
throw new Error(`Write action "${action.kind}" requires a finite ${String(key)}`);
|
|
43
|
+
}
|
|
44
|
+
return value;
|
|
45
|
+
}
|
|
46
|
+
function encodePathSegment(value) {
|
|
47
|
+
return encodeURIComponent(String(value));
|
|
48
|
+
}
|
|
49
|
+
function buildSlackMessagePath(channel, ts) {
|
|
50
|
+
return `/slack/channels/${channel}/messages/${ts}.json`;
|
|
51
|
+
}
|
|
52
|
+
function buildSlackReactionPath(channel, timestamp, emoji) {
|
|
53
|
+
return `/slack/channels/${channel}/messages/${timestamp}/reactions/${emoji}.json`;
|
|
54
|
+
}
|
|
55
|
+
function buildGitHubIssueCommentPath(owner, repo, number, commentId) {
|
|
56
|
+
return `/github/repos/${encodePathSegment(owner)}/${encodePathSegment(repo)}/issues/${number}/comments/${encodePathSegment(commentId)}.json`;
|
|
57
|
+
}
|
|
58
|
+
function buildGitHubReviewPath(owner, repo, number, reviewId) {
|
|
59
|
+
return `/github/repos/${encodePathSegment(owner)}/${encodePathSegment(repo)}/pulls/${number}/reviews/${encodePathSegment(reviewId)}.json`;
|
|
60
|
+
}
|
|
61
|
+
function createUuidV4Fallback() {
|
|
62
|
+
const bytes = randomBytes(16);
|
|
63
|
+
bytes[6] = (bytes[6] & 0x0f) | 0x40;
|
|
64
|
+
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
|
65
|
+
const hex = [...bytes].map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
66
|
+
return [
|
|
67
|
+
hex.slice(0, 8),
|
|
68
|
+
hex.slice(8, 12),
|
|
69
|
+
hex.slice(12, 16),
|
|
70
|
+
hex.slice(16, 20),
|
|
71
|
+
hex.slice(20),
|
|
72
|
+
].join("-");
|
|
73
|
+
}
|
|
74
|
+
function createActionId() {
|
|
75
|
+
return typeof randomUUID === "function" ? randomUUID() : createUuidV4Fallback();
|
|
76
|
+
}
|
|
77
|
+
function createFailureResult(mode, actionId, providerPath, error) {
|
|
78
|
+
return {
|
|
79
|
+
mode,
|
|
80
|
+
ok: false,
|
|
81
|
+
error,
|
|
82
|
+
actionId,
|
|
83
|
+
providerPath,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
function toWriteResult(mode, actionId, execution) {
|
|
87
|
+
if (!execution.ok) {
|
|
88
|
+
return createFailureResult(mode, actionId, execution.providerPath, execution.error ?? "Provider write failed");
|
|
89
|
+
}
|
|
90
|
+
return {
|
|
91
|
+
mode,
|
|
92
|
+
ok: true,
|
|
93
|
+
...(execution.data ? { data: execution.data } : {}),
|
|
94
|
+
actionId,
|
|
95
|
+
providerPath: execution.providerPath,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
function unwrapGitHubCreatedResource(response) {
|
|
99
|
+
const candidate = isRecord(response) && isRecord(response.data) ? response.data : response;
|
|
100
|
+
if (!isRecord(candidate)) {
|
|
101
|
+
throw new Error("GitHub write returned an invalid response");
|
|
102
|
+
}
|
|
103
|
+
const id = candidate.id;
|
|
104
|
+
const url = candidate.url;
|
|
105
|
+
if (typeof id !== "number" || !Number.isFinite(id) || typeof url !== "string") {
|
|
106
|
+
throw new Error("GitHub write returned an invalid created resource");
|
|
107
|
+
}
|
|
108
|
+
return { id, url };
|
|
109
|
+
}
|
|
110
|
+
function readReviewEvent(action) {
|
|
111
|
+
const event = action.event;
|
|
112
|
+
if (event === "APPROVE" || event === "REQUEST_CHANGES" || event === "COMMENT") {
|
|
113
|
+
return event;
|
|
114
|
+
}
|
|
115
|
+
return undefined;
|
|
116
|
+
}
|
|
117
|
+
export class ProviderWriteFacade {
|
|
118
|
+
slackEgress;
|
|
119
|
+
githubIntegration;
|
|
120
|
+
overlay;
|
|
121
|
+
defaultMode;
|
|
122
|
+
statuses = new Map();
|
|
123
|
+
constructor(options) {
|
|
124
|
+
this.slackEgress = options.slackEgress ?? null;
|
|
125
|
+
this.githubIntegration = options.githubIntegration ?? null;
|
|
126
|
+
this.overlay = options.overlay ?? null;
|
|
127
|
+
this.defaultMode = options.defaultMode ?? DEFAULT_WRITE_MODE;
|
|
128
|
+
}
|
|
129
|
+
async write(action, mode) {
|
|
130
|
+
const resolvedMode = mode ?? this.defaultMode;
|
|
131
|
+
const actionId = createActionId();
|
|
132
|
+
this.pruneCompletedStatuses();
|
|
133
|
+
const plan = this.createDispatchPlan(action, actionId);
|
|
134
|
+
if (resolvedMode === "fire-and-forget") {
|
|
135
|
+
return this.writeFireAndForget(plan, actionId, resolvedMode);
|
|
136
|
+
}
|
|
137
|
+
return this.writeSync(plan, actionId, resolvedMode);
|
|
138
|
+
}
|
|
139
|
+
getStatus(actionId) {
|
|
140
|
+
this.pruneCompletedStatuses();
|
|
141
|
+
const status = this.statuses.get(actionId);
|
|
142
|
+
if (!status) {
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
return {
|
|
146
|
+
actionId: status.actionId,
|
|
147
|
+
state: status.state,
|
|
148
|
+
...(status.result ? { result: status.result } : {}),
|
|
149
|
+
createdAt: status.createdAt,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
async writeSync(plan, actionId, mode) {
|
|
153
|
+
try {
|
|
154
|
+
const execution = await plan.execute();
|
|
155
|
+
const result = toWriteResult(mode, actionId, execution);
|
|
156
|
+
if (result.ok && execution.overlayData) {
|
|
157
|
+
this.recordOverlay(plan.provider, plan.overlayAction, execution.providerPath, execution.overlayData);
|
|
158
|
+
}
|
|
159
|
+
return result;
|
|
160
|
+
}
|
|
161
|
+
catch (error) {
|
|
162
|
+
return createFailureResult(mode, actionId, plan.speculativeProviderPath, toErrorMessage(error));
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
writeFireAndForget(plan, actionId, mode) {
|
|
166
|
+
const createdAt = Date.now();
|
|
167
|
+
this.statuses.set(actionId, {
|
|
168
|
+
actionId,
|
|
169
|
+
state: "pending",
|
|
170
|
+
createdAt,
|
|
171
|
+
});
|
|
172
|
+
this.recordOverlay(plan.provider, plan.overlayAction, plan.speculativeProviderPath, plan.speculativeOverlayData);
|
|
173
|
+
void plan.execute()
|
|
174
|
+
.then((execution) => {
|
|
175
|
+
const result = toWriteResult(mode, actionId, execution);
|
|
176
|
+
if (result.ok && execution.overlayData) {
|
|
177
|
+
// If the real path differs from the speculative path, remove the speculative entry and record the real one
|
|
178
|
+
if (execution.providerPath !== plan.speculativeProviderPath) {
|
|
179
|
+
this.overlay?.remove(plan.speculativeProviderPath);
|
|
180
|
+
this.recordOverlay(plan.provider, plan.overlayAction, execution.providerPath, execution.overlayData);
|
|
181
|
+
}
|
|
182
|
+
else {
|
|
183
|
+
// Same-path write: upgrade the speculative entry to final state (remove pending flag)
|
|
184
|
+
this.recordOverlay(plan.provider, plan.overlayAction, execution.providerPath, execution.overlayData);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
else if (!result.ok) {
|
|
188
|
+
// Non-throwing failure (e.g. Slack { ok:false, error:"channel_not_found" }):
|
|
189
|
+
// drop the speculative overlay entry so readers don't see phantom pending data.
|
|
190
|
+
this.overlay?.remove(plan.speculativeProviderPath);
|
|
191
|
+
}
|
|
192
|
+
this.statuses.set(actionId, {
|
|
193
|
+
actionId,
|
|
194
|
+
state: result.ok ? "success" : "failed",
|
|
195
|
+
result,
|
|
196
|
+
createdAt,
|
|
197
|
+
completedAt: Date.now(),
|
|
198
|
+
});
|
|
199
|
+
})
|
|
200
|
+
.catch((error) => {
|
|
201
|
+
// On failure, remove the speculative overlay entry so phantom data isn't left behind
|
|
202
|
+
this.overlay?.remove(plan.speculativeProviderPath);
|
|
203
|
+
this.statuses.set(actionId, {
|
|
204
|
+
actionId,
|
|
205
|
+
state: "failed",
|
|
206
|
+
result: createFailureResult(mode, actionId, plan.speculativeProviderPath, toErrorMessage(error)),
|
|
207
|
+
createdAt,
|
|
208
|
+
completedAt: Date.now(),
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
return {
|
|
212
|
+
mode,
|
|
213
|
+
ok: true,
|
|
214
|
+
actionId,
|
|
215
|
+
providerPath: plan.speculativeProviderPath,
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
createDispatchPlan(action, actionId) {
|
|
219
|
+
switch (action.kind) {
|
|
220
|
+
case "slack:postMessage":
|
|
221
|
+
return this.createSlackPostMessagePlan(action, actionId);
|
|
222
|
+
case "slack:addReaction":
|
|
223
|
+
return this.createSlackAddReactionPlan(action, actionId);
|
|
224
|
+
case "github:createComment":
|
|
225
|
+
return this.createGitHubCreateCommentPlan(action, actionId);
|
|
226
|
+
case "github:createReview":
|
|
227
|
+
return this.createGitHubCreateReviewPlan(action, actionId);
|
|
228
|
+
default:
|
|
229
|
+
throw new Error(`Unsupported write action kind: ${action.kind}`);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
createSlackPostMessagePlan(action, actionId) {
|
|
233
|
+
const slackEgress = this.requireSlackEgress(action.kind);
|
|
234
|
+
const channel = requireString(action, "channel");
|
|
235
|
+
const text = requireString(action, "text");
|
|
236
|
+
const threadTs = optionalString(action, "threadTs");
|
|
237
|
+
const placeholderTs = String(Date.now());
|
|
238
|
+
const speculativeProviderPath = buildSlackMessagePath(channel, placeholderTs);
|
|
239
|
+
const speculativeOverlayData = compactRecord({
|
|
240
|
+
channel,
|
|
241
|
+
text,
|
|
242
|
+
thread_ts: threadTs,
|
|
243
|
+
ts: placeholderTs,
|
|
244
|
+
pending: true,
|
|
245
|
+
actionId,
|
|
246
|
+
});
|
|
247
|
+
return {
|
|
248
|
+
provider: "slack",
|
|
249
|
+
overlayAction: "postMessage",
|
|
250
|
+
speculativeProviderPath,
|
|
251
|
+
speculativeOverlayData,
|
|
252
|
+
execute: async () => {
|
|
253
|
+
const response = await slackEgress.postMessage(channel, text, threadTs);
|
|
254
|
+
const ts = response.ts ?? placeholderTs;
|
|
255
|
+
const providerPath = buildSlackMessagePath(channel, ts);
|
|
256
|
+
if (!response.ok) {
|
|
257
|
+
return {
|
|
258
|
+
ok: false,
|
|
259
|
+
error: response.error ?? "Slack postMessage failed",
|
|
260
|
+
providerPath,
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
return {
|
|
264
|
+
ok: true,
|
|
265
|
+
data: compactRecord({
|
|
266
|
+
ok: response.ok,
|
|
267
|
+
ts: response.ts,
|
|
268
|
+
error: response.error,
|
|
269
|
+
}),
|
|
270
|
+
providerPath,
|
|
271
|
+
overlayData: compactRecord({
|
|
272
|
+
channel,
|
|
273
|
+
text,
|
|
274
|
+
thread_ts: threadTs,
|
|
275
|
+
ts,
|
|
276
|
+
}),
|
|
277
|
+
};
|
|
278
|
+
},
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
createSlackAddReactionPlan(action, actionId) {
|
|
282
|
+
const slackEgress = this.requireSlackEgress(action.kind);
|
|
283
|
+
const channel = requireString(action, "channel");
|
|
284
|
+
const timestamp = requireString(action, "threadTs");
|
|
285
|
+
const emoji = requireString(action, "emoji");
|
|
286
|
+
const providerPath = buildSlackReactionPath(channel, timestamp, emoji);
|
|
287
|
+
const overlayData = {
|
|
288
|
+
channel,
|
|
289
|
+
timestamp,
|
|
290
|
+
name: emoji,
|
|
291
|
+
};
|
|
292
|
+
return {
|
|
293
|
+
provider: "slack",
|
|
294
|
+
overlayAction: "addReaction",
|
|
295
|
+
speculativeProviderPath: providerPath,
|
|
296
|
+
speculativeOverlayData: {
|
|
297
|
+
...overlayData,
|
|
298
|
+
pending: true,
|
|
299
|
+
actionId,
|
|
300
|
+
},
|
|
301
|
+
execute: async () => {
|
|
302
|
+
await slackEgress.addReaction(channel, timestamp, emoji);
|
|
303
|
+
return {
|
|
304
|
+
ok: true,
|
|
305
|
+
data: overlayData,
|
|
306
|
+
providerPath,
|
|
307
|
+
overlayData,
|
|
308
|
+
};
|
|
309
|
+
},
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
createGitHubCreateCommentPlan(action, actionId) {
|
|
313
|
+
const githubIntegration = this.requireGitHubIntegration(action.kind);
|
|
314
|
+
const owner = requireString(action, "owner");
|
|
315
|
+
const repo = requireString(action, "repo");
|
|
316
|
+
const number = requireNumber(action, "number");
|
|
317
|
+
const body = requireString(action, "body");
|
|
318
|
+
const placeholderCommentId = `pending-${actionId}`;
|
|
319
|
+
const speculativeProviderPath = buildGitHubIssueCommentPath(owner, repo, number, placeholderCommentId);
|
|
320
|
+
const speculativeOverlayData = {
|
|
321
|
+
body,
|
|
322
|
+
issue_number: number,
|
|
323
|
+
pending: true,
|
|
324
|
+
actionId,
|
|
325
|
+
};
|
|
326
|
+
return {
|
|
327
|
+
provider: "github",
|
|
328
|
+
overlayAction: "createComment",
|
|
329
|
+
speculativeProviderPath,
|
|
330
|
+
speculativeOverlayData,
|
|
331
|
+
execute: async () => {
|
|
332
|
+
const created = unwrapGitHubCreatedResource(await githubIntegration.createIssueComment(owner, repo, number, body));
|
|
333
|
+
const providerPath = buildGitHubIssueCommentPath(owner, repo, number, created.id);
|
|
334
|
+
const overlayData = {
|
|
335
|
+
id: created.id,
|
|
336
|
+
body,
|
|
337
|
+
url: created.url,
|
|
338
|
+
issue_number: number,
|
|
339
|
+
};
|
|
340
|
+
return {
|
|
341
|
+
ok: true,
|
|
342
|
+
data: { id: created.id, url: created.url },
|
|
343
|
+
providerPath,
|
|
344
|
+
overlayData,
|
|
345
|
+
};
|
|
346
|
+
},
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
createGitHubCreateReviewPlan(action, actionId) {
|
|
350
|
+
const githubIntegration = this.requireGitHubIntegration(action.kind);
|
|
351
|
+
const owner = requireString(action, "owner");
|
|
352
|
+
const repo = requireString(action, "repo");
|
|
353
|
+
const number = requireNumber(action, "number");
|
|
354
|
+
const body = requireString(action, "body");
|
|
355
|
+
const event = readReviewEvent(action) ?? "COMMENT";
|
|
356
|
+
const placeholderReviewId = `pending-${actionId}`;
|
|
357
|
+
const speculativeProviderPath = buildGitHubReviewPath(owner, repo, number, placeholderReviewId);
|
|
358
|
+
const speculativeOverlayData = {
|
|
359
|
+
body,
|
|
360
|
+
event,
|
|
361
|
+
pull_number: number,
|
|
362
|
+
pending: true,
|
|
363
|
+
actionId,
|
|
364
|
+
};
|
|
365
|
+
return {
|
|
366
|
+
provider: "github",
|
|
367
|
+
overlayAction: "createReview",
|
|
368
|
+
speculativeProviderPath,
|
|
369
|
+
speculativeOverlayData,
|
|
370
|
+
execute: async () => {
|
|
371
|
+
const created = unwrapGitHubCreatedResource(await githubIntegration.createPRReview(owner, repo, number, body, event));
|
|
372
|
+
const providerPath = buildGitHubReviewPath(owner, repo, number, created.id);
|
|
373
|
+
const overlayData = {
|
|
374
|
+
id: created.id,
|
|
375
|
+
body,
|
|
376
|
+
event,
|
|
377
|
+
url: created.url,
|
|
378
|
+
pull_number: number,
|
|
379
|
+
};
|
|
380
|
+
return {
|
|
381
|
+
ok: true,
|
|
382
|
+
data: { id: created.id, url: created.url },
|
|
383
|
+
providerPath,
|
|
384
|
+
overlayData,
|
|
385
|
+
};
|
|
386
|
+
},
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
requireSlackEgress(actionKind) {
|
|
390
|
+
if (!this.slackEgress) {
|
|
391
|
+
throw new MissingProviderError("slack", actionKind);
|
|
392
|
+
}
|
|
393
|
+
return this.slackEgress;
|
|
394
|
+
}
|
|
395
|
+
requireGitHubIntegration(actionKind) {
|
|
396
|
+
if (!this.githubIntegration) {
|
|
397
|
+
throw new MissingProviderError("github", actionKind);
|
|
398
|
+
}
|
|
399
|
+
return this.githubIntegration;
|
|
400
|
+
}
|
|
401
|
+
recordOverlay(provider, action, path, data) {
|
|
402
|
+
this.overlay?.record({
|
|
403
|
+
path,
|
|
404
|
+
data,
|
|
405
|
+
provider,
|
|
406
|
+
action,
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
pruneCompletedStatuses(now = Date.now()) {
|
|
410
|
+
for (const [actionId, status] of this.statuses) {
|
|
411
|
+
if (status.completedAt !== undefined &&
|
|
412
|
+
now - status.completedAt > STATUS_RETENTION_MS) {
|
|
413
|
+
this.statuses.delete(actionId);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"provider-write-facade.test.d.ts","sourceRoot":"","sources":["../../src/integrations/provider-write-facade.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { ProviderWriteFacade, } from "./provider-write-facade.js";
|
|
3
|
+
import { RecentActionsOverlay } from "./recent-actions-overlay.js";
|
|
4
|
+
function createDeferred() {
|
|
5
|
+
let resolve;
|
|
6
|
+
let reject;
|
|
7
|
+
const promise = new Promise((innerResolve, innerReject) => {
|
|
8
|
+
resolve = innerResolve;
|
|
9
|
+
reject = innerReject;
|
|
10
|
+
});
|
|
11
|
+
return { promise, resolve, reject };
|
|
12
|
+
}
|
|
13
|
+
async function flushAsyncWork() {
|
|
14
|
+
await new Promise((resolve) => {
|
|
15
|
+
setImmediate(resolve);
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
describe("ProviderWriteFacade", () => {
|
|
19
|
+
it("routes a sync slack action to slack egress and records an overlay on success", async () => {
|
|
20
|
+
const calls = [];
|
|
21
|
+
const overlay = new RecentActionsOverlay();
|
|
22
|
+
const slackEgress = {
|
|
23
|
+
postMessage: async (channel, text, threadTs) => {
|
|
24
|
+
calls.push({ provider: "slack", method: "postMessage", channel, text, threadTs });
|
|
25
|
+
return { ok: true, ts: "1700000000.000100" };
|
|
26
|
+
},
|
|
27
|
+
addReaction: async (channel, timestamp, emoji) => {
|
|
28
|
+
calls.push({ provider: "slack", method: "addReaction", channel, timestamp, emoji });
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
const githubIntegration = {
|
|
32
|
+
createIssueComment: async (owner, repo, number, body) => {
|
|
33
|
+
calls.push({ provider: "github", method: "createIssueComment", owner, repo, number, body });
|
|
34
|
+
return { id: 101, url: "https://github.example/comments/101" };
|
|
35
|
+
},
|
|
36
|
+
createPRReview: async (owner, repo, number, body, event) => {
|
|
37
|
+
calls.push({ provider: "github", method: "createPRReview", owner, repo, number, body, event });
|
|
38
|
+
return { id: 202, url: "https://github.example/reviews/202" };
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
const facade = new ProviderWriteFacade({ slackEgress, githubIntegration, overlay });
|
|
42
|
+
const result = await facade.write({
|
|
43
|
+
kind: "slack:postMessage",
|
|
44
|
+
channel: "C123",
|
|
45
|
+
threadTs: "1699999999.000001",
|
|
46
|
+
text: "Ship it",
|
|
47
|
+
}, "sync");
|
|
48
|
+
const providerPath = "/slack/channels/C123/messages/1700000000.000100.json";
|
|
49
|
+
expect(calls).toEqual([
|
|
50
|
+
{
|
|
51
|
+
provider: "slack",
|
|
52
|
+
method: "postMessage",
|
|
53
|
+
channel: "C123",
|
|
54
|
+
text: "Ship it",
|
|
55
|
+
threadTs: "1699999999.000001",
|
|
56
|
+
},
|
|
57
|
+
]);
|
|
58
|
+
expect(result).toEqual({
|
|
59
|
+
mode: "sync",
|
|
60
|
+
ok: true,
|
|
61
|
+
data: { ok: true, ts: "1700000000.000100" },
|
|
62
|
+
actionId: expect.any(String),
|
|
63
|
+
providerPath,
|
|
64
|
+
});
|
|
65
|
+
expect(overlay.lookup(providerPath)).toMatchObject({
|
|
66
|
+
path: providerPath,
|
|
67
|
+
provider: "slack",
|
|
68
|
+
action: "postMessage",
|
|
69
|
+
data: {
|
|
70
|
+
channel: "C123",
|
|
71
|
+
thread_ts: "1699999999.000001",
|
|
72
|
+
text: "Ship it",
|
|
73
|
+
ts: "1700000000.000100",
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
it("surfaces sync provider errors without recording a success overlay", async () => {
|
|
78
|
+
const calls = [];
|
|
79
|
+
const overlay = new RecentActionsOverlay();
|
|
80
|
+
const slackEgress = {
|
|
81
|
+
postMessage: async (channel, text, threadTs) => {
|
|
82
|
+
calls.push({ provider: "slack", method: "postMessage", channel, text, threadTs });
|
|
83
|
+
return { ok: false, error: "not_in_channel" };
|
|
84
|
+
},
|
|
85
|
+
addReaction: async (channel, timestamp, emoji) => {
|
|
86
|
+
calls.push({ provider: "slack", method: "addReaction", channel, timestamp, emoji });
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
const githubIntegration = {
|
|
90
|
+
createIssueComment: async (owner, repo, number, body) => {
|
|
91
|
+
calls.push({ provider: "github", method: "createIssueComment", owner, repo, number, body });
|
|
92
|
+
return { id: 303, url: "https://github.example/comments/303" };
|
|
93
|
+
},
|
|
94
|
+
createPRReview: async (owner, repo, number, body, event) => {
|
|
95
|
+
calls.push({ provider: "github", method: "createPRReview", owner, repo, number, body, event });
|
|
96
|
+
return { id: 404, url: "https://github.example/reviews/404" };
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
const facade = new ProviderWriteFacade({ slackEgress, githubIntegration, overlay });
|
|
100
|
+
const result = await facade.write({
|
|
101
|
+
kind: "slack:postMessage",
|
|
102
|
+
channel: "C404",
|
|
103
|
+
text: "This should fail",
|
|
104
|
+
}, "sync");
|
|
105
|
+
expect(calls).toEqual([
|
|
106
|
+
{
|
|
107
|
+
provider: "slack",
|
|
108
|
+
method: "postMessage",
|
|
109
|
+
channel: "C404",
|
|
110
|
+
text: "This should fail",
|
|
111
|
+
threadTs: undefined,
|
|
112
|
+
},
|
|
113
|
+
]);
|
|
114
|
+
expect(result).toMatchObject({
|
|
115
|
+
mode: "sync",
|
|
116
|
+
ok: false,
|
|
117
|
+
error: "not_in_channel",
|
|
118
|
+
actionId: expect.any(String),
|
|
119
|
+
});
|
|
120
|
+
expect(result.providerPath).toMatch(/^\/slack\/channels\/C404\/messages\/\d+\.json$/);
|
|
121
|
+
expect(overlay.size).toBe(0);
|
|
122
|
+
});
|
|
123
|
+
it("returns immediately for fire-and-forget writes and settles status asynchronously", async () => {
|
|
124
|
+
const calls = [];
|
|
125
|
+
const deferred = createDeferred();
|
|
126
|
+
const overlay = new RecentActionsOverlay();
|
|
127
|
+
const slackEgress = {
|
|
128
|
+
postMessage: (channel, text, threadTs) => {
|
|
129
|
+
calls.push({ provider: "slack", method: "postMessage", channel, text, threadTs });
|
|
130
|
+
return deferred.promise;
|
|
131
|
+
},
|
|
132
|
+
addReaction: async (channel, timestamp, emoji) => {
|
|
133
|
+
calls.push({ provider: "slack", method: "addReaction", channel, timestamp, emoji });
|
|
134
|
+
},
|
|
135
|
+
};
|
|
136
|
+
const githubIntegration = {
|
|
137
|
+
createIssueComment: async (owner, repo, number, body) => {
|
|
138
|
+
calls.push({ provider: "github", method: "createIssueComment", owner, repo, number, body });
|
|
139
|
+
return { id: 505, url: "https://github.example/comments/505" };
|
|
140
|
+
},
|
|
141
|
+
createPRReview: async (owner, repo, number, body, event) => {
|
|
142
|
+
calls.push({ provider: "github", method: "createPRReview", owner, repo, number, body, event });
|
|
143
|
+
return { id: 606, url: "https://github.example/reviews/606" };
|
|
144
|
+
},
|
|
145
|
+
};
|
|
146
|
+
const facade = new ProviderWriteFacade({ slackEgress, githubIntegration, overlay });
|
|
147
|
+
const result = await facade.write({
|
|
148
|
+
kind: "slack:postMessage",
|
|
149
|
+
channel: "CFF",
|
|
150
|
+
text: "Complete later",
|
|
151
|
+
}, "fire-and-forget");
|
|
152
|
+
expect(result).toMatchObject({
|
|
153
|
+
mode: "fire-and-forget",
|
|
154
|
+
ok: true,
|
|
155
|
+
actionId: expect.any(String),
|
|
156
|
+
});
|
|
157
|
+
expect(result.providerPath).toMatch(/^\/slack\/channels\/CFF\/messages\/\d+\.json$/);
|
|
158
|
+
expect(calls).toEqual([
|
|
159
|
+
{
|
|
160
|
+
provider: "slack",
|
|
161
|
+
method: "postMessage",
|
|
162
|
+
channel: "CFF",
|
|
163
|
+
text: "Complete later",
|
|
164
|
+
threadTs: undefined,
|
|
165
|
+
},
|
|
166
|
+
]);
|
|
167
|
+
expect(facade.getStatus(result.actionId)).toMatchObject({
|
|
168
|
+
actionId: result.actionId,
|
|
169
|
+
state: "pending",
|
|
170
|
+
createdAt: expect.any(Number),
|
|
171
|
+
});
|
|
172
|
+
deferred.resolve({ ok: true, ts: "1700000000.000200" });
|
|
173
|
+
await flushAsyncWork();
|
|
174
|
+
expect(facade.getStatus(result.actionId)).toEqual({
|
|
175
|
+
actionId: result.actionId,
|
|
176
|
+
state: "success",
|
|
177
|
+
createdAt: expect.any(Number),
|
|
178
|
+
result: {
|
|
179
|
+
mode: "fire-and-forget",
|
|
180
|
+
ok: true,
|
|
181
|
+
data: { ok: true, ts: "1700000000.000200" },
|
|
182
|
+
actionId: result.actionId,
|
|
183
|
+
providerPath: "/slack/channels/CFF/messages/1700000000.000200.json",
|
|
184
|
+
},
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
it("populates overlay entries with the provider and provider path from github writes", async () => {
|
|
188
|
+
const calls = [];
|
|
189
|
+
const overlay = new RecentActionsOverlay();
|
|
190
|
+
const slackEgress = {
|
|
191
|
+
postMessage: async (channel, text, threadTs) => {
|
|
192
|
+
calls.push({ provider: "slack", method: "postMessage", channel, text, threadTs });
|
|
193
|
+
return { ok: true, ts: "1700000000.000300" };
|
|
194
|
+
},
|
|
195
|
+
addReaction: async (channel, timestamp, emoji) => {
|
|
196
|
+
calls.push({ provider: "slack", method: "addReaction", channel, timestamp, emoji });
|
|
197
|
+
},
|
|
198
|
+
};
|
|
199
|
+
const githubIntegration = {
|
|
200
|
+
createIssueComment: async (owner, repo, number, body) => {
|
|
201
|
+
calls.push({ provider: "github", method: "createIssueComment", owner, repo, number, body });
|
|
202
|
+
return { data: { id: 707, url: "https://github.example/comments/707" } };
|
|
203
|
+
},
|
|
204
|
+
createPRReview: async (owner, repo, number, body, event) => {
|
|
205
|
+
calls.push({ provider: "github", method: "createPRReview", owner, repo, number, body, event });
|
|
206
|
+
return { data: { id: 808, url: "https://github.example/reviews/808" } };
|
|
207
|
+
},
|
|
208
|
+
};
|
|
209
|
+
const facade = new ProviderWriteFacade({ slackEgress, githubIntegration, overlay });
|
|
210
|
+
const result = await facade.write({
|
|
211
|
+
kind: "github:createComment",
|
|
212
|
+
owner: "AgentWorkforce",
|
|
213
|
+
repo: "sage",
|
|
214
|
+
number: 42,
|
|
215
|
+
body: "Reviewed from tests",
|
|
216
|
+
}, "sync");
|
|
217
|
+
const providerPath = "/github/repos/AgentWorkforce/sage/issues/42/comments/707.json";
|
|
218
|
+
expect(calls).toEqual([
|
|
219
|
+
{
|
|
220
|
+
provider: "github",
|
|
221
|
+
method: "createIssueComment",
|
|
222
|
+
owner: "AgentWorkforce",
|
|
223
|
+
repo: "sage",
|
|
224
|
+
number: 42,
|
|
225
|
+
body: "Reviewed from tests",
|
|
226
|
+
},
|
|
227
|
+
]);
|
|
228
|
+
expect(result).toMatchObject({
|
|
229
|
+
mode: "sync",
|
|
230
|
+
ok: true,
|
|
231
|
+
data: { id: 707, url: "https://github.example/comments/707" },
|
|
232
|
+
actionId: expect.any(String),
|
|
233
|
+
providerPath,
|
|
234
|
+
});
|
|
235
|
+
expect(overlay.lookup(providerPath)).toMatchObject({
|
|
236
|
+
path: providerPath,
|
|
237
|
+
provider: "github",
|
|
238
|
+
action: "createComment",
|
|
239
|
+
data: {
|
|
240
|
+
id: 707,
|
|
241
|
+
body: "Reviewed from tests",
|
|
242
|
+
url: "https://github.example/comments/707",
|
|
243
|
+
issue_number: 42,
|
|
244
|
+
},
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"read-your-writes.test.d.ts","sourceRoot":"","sources":["../../src/integrations/read-your-writes.test.ts"],"names":[],"mappings":""}
|