@agentworkforce/sage 1.1.3 → 1.2.1
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 +54 -29
- 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/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
package/dist/app.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"app.d.ts","sourceRoot":"","sources":["../src/app.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"app.d.ts","sourceRoot":"","sources":["../src/app.ts"],"names":[],"mappings":"AAUA,OAAO,EAAE,IAAI,EAAE,MAAM,MAAM,CAAC;AA0C5B,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC;AAwB/C,UAAU,YAAY;IACpB,YAAY,EAAE,MAAM,CAAC;IACrB,YAAY,EAAE,OAAO,CAAC;CACvB;AAED,KAAK,MAAM,GAAG;IACZ,QAAQ,EAAE,YAAY,CAAC;IACvB,SAAS,EAAE,YAAY,CAAC;CACzB,CAAC;AAivCF,wBAAgB,aAAa,IAAI,IAAI,CAAC,MAAM,CAAC,CAmiB5C"}
|
package/dist/app.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Buffer } from "node:buffer";
|
|
2
2
|
import { timingSafeEqual } from "node:crypto";
|
|
3
|
+
import { SlackThreadGate, } from "@agent-assistant/surfaces";
|
|
3
4
|
import { RelayFileClient } from "@relayfile/sdk";
|
|
4
5
|
import { Hono } from "hono";
|
|
5
6
|
import { createSageRuntime } from "./assistant/index.js";
|
|
@@ -576,17 +577,30 @@ async function markEventSeen(kv, eventId) {
|
|
|
576
577
|
function getSlackDeduplicationKey(event) {
|
|
577
578
|
return event.eventId ?? event.ts;
|
|
578
579
|
}
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
580
|
+
function createThreadStore(kv) {
|
|
581
|
+
const keyOf = (key) => `${key.workspaceId}:${key.channel}:${key.threadTs}`;
|
|
582
|
+
return {
|
|
583
|
+
async isActive(key) {
|
|
584
|
+
return (await kv.get(keyOf(key))) !== null;
|
|
585
|
+
},
|
|
586
|
+
async markActive(key, ttlSeconds) {
|
|
587
|
+
await kv.put(keyOf(key), JSON.stringify({ workspaceId: key.workspaceId, channel: key.channel }), { expirationTtl: ttlSeconds });
|
|
588
|
+
},
|
|
589
|
+
};
|
|
584
590
|
}
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
591
|
+
// KV keys produced by createThreadStore have the shape
|
|
592
|
+
// `${workspaceId}:${channel}:${threadTs}`. Slack workspace IDs (T…),
|
|
593
|
+
// channel IDs (C…/G…) and thread timestamps (numeric with a dot) never
|
|
594
|
+
// contain `:`, so the last `:` unambiguously separates channel from
|
|
595
|
+
// threadTs. Legacy entries written before the composite-key refactor had
|
|
596
|
+
// the raw threadTs as the key — fall back to that when no `:` is present.
|
|
597
|
+
function parseActiveThreadKey(name) {
|
|
598
|
+
const lastColon = name.lastIndexOf(":");
|
|
599
|
+
if (lastColon === -1) {
|
|
600
|
+
return name || undefined;
|
|
601
|
+
}
|
|
602
|
+
const threadTs = name.slice(lastColon + 1);
|
|
603
|
+
return threadTs || undefined;
|
|
590
604
|
}
|
|
591
605
|
async function loadActiveThreadsMap(kv) {
|
|
592
606
|
const activeThreads = new Map();
|
|
@@ -602,8 +616,12 @@ async function loadActiveThreadsMap(kv) {
|
|
|
602
616
|
const parsed = JSON.parse(raw);
|
|
603
617
|
const workspaceId = readNonEmptyString(parsed.workspaceId);
|
|
604
618
|
const channel = readNonEmptyString(parsed.channel);
|
|
605
|
-
|
|
606
|
-
|
|
619
|
+
const threadTs = parseActiveThreadKey(name);
|
|
620
|
+
if (workspaceId && channel && threadTs) {
|
|
621
|
+
// Key by the raw Slack thread_ts so downstream consumers
|
|
622
|
+
// (detectStaleThreads, proactive dashboards) can pass it back
|
|
623
|
+
// to the Slack API without re-parsing.
|
|
624
|
+
activeThreads.set(threadTs, {
|
|
607
625
|
workspaceId,
|
|
608
626
|
channel,
|
|
609
627
|
});
|
|
@@ -677,6 +695,10 @@ async function processSlackEvent(event, workspaceId, env, slack, options = {}) {
|
|
|
677
695
|
const memory = getMemory(workspaceId);
|
|
678
696
|
const orgMemory = getOrgMemory(workspaceId);
|
|
679
697
|
const nangoClient = getNango(env);
|
|
698
|
+
const threadGate = new SlackThreadGate({
|
|
699
|
+
store: createThreadStore(env.THREADS),
|
|
700
|
+
ttlSeconds: ACTIVE_THREAD_TTL_SECONDS,
|
|
701
|
+
});
|
|
680
702
|
const workspaceSlackBotUserId = options.slackBotUserId ?? await detectSlackBotUserId(slack);
|
|
681
703
|
const { connectionId: githubConnectionId, providerConfigKey: githubProviderConfigKey, } = await resolveGitHubConnection(workspaceId, env);
|
|
682
704
|
const relayfileReader = await getRelayFileReader(workspaceId, env);
|
|
@@ -745,10 +767,7 @@ async function processSlackEvent(event, workspaceId, env, slack, options = {}) {
|
|
|
745
767
|
}
|
|
746
768
|
console.log(`[sage] Reply posted: threadTs=${replyThreadTs} replyTs=${postResult.ts ?? ""}`);
|
|
747
769
|
if (replyThreadTs) {
|
|
748
|
-
await
|
|
749
|
-
workspaceId,
|
|
750
|
-
channel: event.channel,
|
|
751
|
-
});
|
|
770
|
+
await threadGate.refresh({ workspaceId, channel: event.channel, threadTs: replyThreadTs });
|
|
752
771
|
}
|
|
753
772
|
try {
|
|
754
773
|
const summary = truncate(`User asked: ${event.text}\nSage answered: ${reply}`, 600);
|
|
@@ -1319,24 +1338,30 @@ export function createSageApp() {
|
|
|
1319
1338
|
const workspaceId = getWorkspaceId(event, c.env);
|
|
1320
1339
|
const cloudProxyProvider = getCloudProxyProvider(c.env);
|
|
1321
1340
|
const { slack, slackBotUserId: workspaceSlackBotUserId } = await resolveWorkspaceSlackContext(workspaceId, cloudProxyProvider);
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1341
|
+
const threadGate = new SlackThreadGate({
|
|
1342
|
+
store: createThreadStore(c.env.THREADS),
|
|
1343
|
+
ttlSeconds: ACTIVE_THREAD_TTL_SECONDS,
|
|
1344
|
+
});
|
|
1345
|
+
const gateDecision = await threadGate.shouldProcess({
|
|
1346
|
+
type: event.type === "mention" ? "mention" : "message",
|
|
1347
|
+
channel: event.channel,
|
|
1348
|
+
ts: event.ts,
|
|
1349
|
+
threadTs: event.threadTs,
|
|
1350
|
+
}, workspaceId);
|
|
1351
|
+
if (!gateDecision.proceed) {
|
|
1352
|
+
console.log(`[sage] Dropping thread reply on inactive thread: threadTs=${event.threadTs ?? ""}`);
|
|
1353
|
+
return c.json({ ok: true });
|
|
1328
1354
|
}
|
|
1329
1355
|
if (!event.userId || (workspaceSlackBotUserId && event.userId === workspaceSlackBotUserId)) {
|
|
1330
1356
|
return c.json({ ok: true });
|
|
1331
1357
|
}
|
|
1332
1358
|
if (event.type === "mention") {
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
}
|
|
1359
|
+
await threadGate.onEngaged({
|
|
1360
|
+
type: "mention",
|
|
1361
|
+
channel: event.channel,
|
|
1362
|
+
ts: event.ts,
|
|
1363
|
+
threadTs: event.threadTs,
|
|
1364
|
+
}, workspaceId);
|
|
1340
1365
|
}
|
|
1341
1366
|
const rateLimitResult = await getRateLimiter(c.env).limit({ key: `${workspaceId}:${event.userId}` });
|
|
1342
1367
|
if (!rateLimitResult.success) {
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { ProviderWriteFacade, type WriteMode } from "../integrations/provider-write-facade.js";
|
|
2
|
+
import { RecentActionsOverlay } from "../integrations/recent-actions-overlay.js";
|
|
3
|
+
import { SageRelayFileReader } from "../integrations/relayfile-reader.js";
|
|
4
|
+
import { type MockCloudProxyServer } from "./mock-cloud-proxy-server.js";
|
|
5
|
+
import { type MockRelayfileServer, type SeedFileData } from "./mock-relayfile-server.js";
|
|
6
|
+
export interface HarnessOptions {
|
|
7
|
+
overlayTtlMs?: number;
|
|
8
|
+
overlayMaxEntries?: number;
|
|
9
|
+
readerCacheTtlMs?: number;
|
|
10
|
+
defaultWriteMode?: WriteMode;
|
|
11
|
+
}
|
|
12
|
+
export interface E2EHarness {
|
|
13
|
+
reader: SageRelayFileReader;
|
|
14
|
+
facade: ProviderWriteFacade;
|
|
15
|
+
overlay: RecentActionsOverlay;
|
|
16
|
+
mockRelayfile: MockRelayfileServer;
|
|
17
|
+
mockCloudProxy: MockCloudProxyServer;
|
|
18
|
+
teardown(): Promise<void>;
|
|
19
|
+
}
|
|
20
|
+
export interface SlackMessageFixture {
|
|
21
|
+
channel: string;
|
|
22
|
+
text: string;
|
|
23
|
+
threadTs?: string;
|
|
24
|
+
}
|
|
25
|
+
export interface GitHubPRCommentFixture {
|
|
26
|
+
owner: string;
|
|
27
|
+
repo: string;
|
|
28
|
+
number: number;
|
|
29
|
+
body: string;
|
|
30
|
+
}
|
|
31
|
+
export declare function buildHarness(options?: HarnessOptions): Promise<E2EHarness>;
|
|
32
|
+
export declare function makeSlackMessage(overrides?: Partial<SlackMessageFixture>): SlackMessageFixture;
|
|
33
|
+
export declare function makeGitHubPRComment(overrides?: Partial<GitHubPRCommentFixture>): GitHubPRCommentFixture;
|
|
34
|
+
export declare function makeRelayfileSeedEntry(path: string, overrides?: Partial<SeedFileData>): SeedFileData;
|
|
35
|
+
export type { MockCloudProxyServer, MockRelayfileServer, SeedFileData };
|
|
36
|
+
//# sourceMappingURL=e2e-harness.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"e2e-harness.d.ts","sourceRoot":"","sources":["../../src/e2e/e2e-harness.ts"],"names":[],"mappings":"AAIA,OAAO,EACL,mBAAmB,EAMnB,KAAK,SAAS,EACf,MAAM,0CAA0C,CAAC;AAClD,OAAO,EAAE,oBAAoB,EAAE,MAAM,2CAA2C,CAAC;AACjF,OAAO,EAAE,mBAAmB,EAAE,MAAM,qCAAqC,CAAC;AAC1E,OAAO,EAEL,KAAK,oBAAoB,EAC1B,MAAM,8BAA8B,CAAC;AACtC,OAAO,EAEL,KAAK,mBAAmB,EACxB,KAAK,YAAY,EAClB,MAAM,4BAA4B,CAAC;AAMpC,MAAM,WAAW,cAAc;IAC7B,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,gBAAgB,CAAC,EAAE,SAAS,CAAC;CAC9B;AAED,MAAM,WAAW,UAAU;IACzB,MAAM,EAAE,mBAAmB,CAAC;IAC5B,MAAM,EAAE,mBAAmB,CAAC;IAC5B,OAAO,EAAE,oBAAoB,CAAC;IAC9B,aAAa,EAAE,mBAAmB,CAAC;IACnC,cAAc,EAAE,oBAAoB,CAAC;IACrC,QAAQ,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CAC3B;AAED,MAAM,WAAW,mBAAmB;IAClC,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,WAAW,sBAAsB;IACrC,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;CACd;AAoRD,wBAAsB,YAAY,CAAC,OAAO,GAAE,cAAmB,GAAG,OAAO,CAAC,UAAU,CAAC,CAuDpF;AAED,wBAAgB,gBAAgB,CAAC,SAAS,GAAE,OAAO,CAAC,mBAAmB,CAAM,GAAG,mBAAmB,CAMlG;AAED,wBAAgB,mBAAmB,CACjC,SAAS,GAAE,OAAO,CAAC,sBAAsB,CAAM,GAC9C,sBAAsB,CAQxB;AAED,wBAAgB,sBAAsB,CACpC,IAAI,EAAE,MAAM,EACZ,SAAS,GAAE,OAAO,CAAC,YAAY,CAAM,GACpC,YAAY,CAQd;AAED,YAAY,EAAE,oBAAoB,EAAE,mBAAmB,EAAE,YAAY,EAAE,CAAC"}
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { RelayFileClient } from "@relayfile/sdk";
|
|
3
|
+
import { ProviderWriteFacade, } from "../integrations/provider-write-facade.js";
|
|
4
|
+
import { RecentActionsOverlay } from "../integrations/recent-actions-overlay.js";
|
|
5
|
+
import { SageRelayFileReader } from "../integrations/relayfile-reader.js";
|
|
6
|
+
import { createMockCloudProxyServer, } from "./mock-cloud-proxy-server.js";
|
|
7
|
+
import { createMockRelayfileServer, } from "./mock-relayfile-server.js";
|
|
8
|
+
const WORKSPACE_ID = "test-workspace";
|
|
9
|
+
const CLOUD_PROXY_TOKEN = "test";
|
|
10
|
+
const RELAYFILE_FETCH_TIMEOUT_MS = 1_000;
|
|
11
|
+
function isRecord(value) {
|
|
12
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
13
|
+
}
|
|
14
|
+
function readString(value) {
|
|
15
|
+
return typeof value === "string" ? value : undefined;
|
|
16
|
+
}
|
|
17
|
+
function readNumber(value) {
|
|
18
|
+
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
|
19
|
+
}
|
|
20
|
+
function randomHex(byteLength = 8) {
|
|
21
|
+
return randomBytes(byteLength).toString("hex");
|
|
22
|
+
}
|
|
23
|
+
function normalizeVfsPath(value) {
|
|
24
|
+
const trimmed = value.trim();
|
|
25
|
+
if (!trimmed) {
|
|
26
|
+
return "/";
|
|
27
|
+
}
|
|
28
|
+
const withLeadingSlash = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
|
|
29
|
+
return withLeadingSlash === "/" ? withLeadingSlash : withLeadingSlash.replace(/\/+$/g, "");
|
|
30
|
+
}
|
|
31
|
+
function detectProviderFromPath(path) {
|
|
32
|
+
const [provider] = normalizeVfsPath(path).split("/").filter(Boolean);
|
|
33
|
+
return provider || undefined;
|
|
34
|
+
}
|
|
35
|
+
function createTimeoutFetch(timeoutMs) {
|
|
36
|
+
return async (input, init = {}) => {
|
|
37
|
+
if (init.signal) {
|
|
38
|
+
return fetch(input, init);
|
|
39
|
+
}
|
|
40
|
+
const controller = new AbortController();
|
|
41
|
+
const timer = setTimeout(() => {
|
|
42
|
+
controller.abort();
|
|
43
|
+
}, timeoutMs);
|
|
44
|
+
timer.unref?.();
|
|
45
|
+
try {
|
|
46
|
+
return await fetch(input, {
|
|
47
|
+
...init,
|
|
48
|
+
signal: controller.signal,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
finally {
|
|
52
|
+
clearTimeout(timer);
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
async function readJson(response) {
|
|
57
|
+
try {
|
|
58
|
+
return await response.json();
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
throw new Error("Mock cloud proxy returned invalid JSON");
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
function parseProxyEnvelope(payload) {
|
|
65
|
+
if (!isRecord(payload) || typeof payload.ok !== "boolean") {
|
|
66
|
+
throw new Error("Mock cloud proxy returned an invalid envelope");
|
|
67
|
+
}
|
|
68
|
+
if (payload.ok) {
|
|
69
|
+
return {
|
|
70
|
+
ok: true,
|
|
71
|
+
data: payload.data,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
return {
|
|
75
|
+
ok: false,
|
|
76
|
+
...(readString(payload.error) ? { error: readString(payload.error) } : {}),
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
async function postCloudProxy(baseUrl, route, body) {
|
|
80
|
+
const response = await fetch(`${baseUrl}${route}`, {
|
|
81
|
+
method: "POST",
|
|
82
|
+
headers: {
|
|
83
|
+
Authorization: `Bearer ${CLOUD_PROXY_TOKEN}`,
|
|
84
|
+
"Content-Type": "application/json",
|
|
85
|
+
},
|
|
86
|
+
body: JSON.stringify(body),
|
|
87
|
+
});
|
|
88
|
+
const payload = await readJson(response);
|
|
89
|
+
if (!response.ok) {
|
|
90
|
+
const message = isRecord(payload) && readString(payload.error)
|
|
91
|
+
? readString(payload.error)
|
|
92
|
+
: `${response.status} ${response.statusText}`.trim();
|
|
93
|
+
throw new Error(`Mock cloud proxy request failed: ${message}`);
|
|
94
|
+
}
|
|
95
|
+
const envelope = parseProxyEnvelope(payload);
|
|
96
|
+
if (!envelope.ok) {
|
|
97
|
+
throw new Error(envelope.error ?? "Mock cloud proxy returned an error");
|
|
98
|
+
}
|
|
99
|
+
return envelope.data;
|
|
100
|
+
}
|
|
101
|
+
function readSlackResult(data) {
|
|
102
|
+
if (!isRecord(data)) {
|
|
103
|
+
throw new Error("Slack proxy returned an invalid response");
|
|
104
|
+
}
|
|
105
|
+
return {
|
|
106
|
+
ok: data.ok === true,
|
|
107
|
+
...(readString(data.ts) ? { ts: readString(data.ts) } : {}),
|
|
108
|
+
...(readString(data.error) ? { error: readString(data.error) } : {}),
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
function createMockSlackEgress(mockCloudProxy) {
|
|
112
|
+
return {
|
|
113
|
+
async postMessage(channel, text, threadTs) {
|
|
114
|
+
const data = await postCloudProxy(mockCloudProxy.baseUrl, "/api/v1/proxy/slack", {
|
|
115
|
+
workspaceId: WORKSPACE_ID,
|
|
116
|
+
endpoint: "/chat.postMessage",
|
|
117
|
+
method: "POST",
|
|
118
|
+
data: {
|
|
119
|
+
channel,
|
|
120
|
+
text,
|
|
121
|
+
...(threadTs ? { thread_ts: threadTs } : {}),
|
|
122
|
+
},
|
|
123
|
+
});
|
|
124
|
+
return readSlackResult(data);
|
|
125
|
+
},
|
|
126
|
+
async addReaction(channel, timestamp, emoji) {
|
|
127
|
+
const data = await postCloudProxy(mockCloudProxy.baseUrl, "/api/v1/proxy/slack", {
|
|
128
|
+
workspaceId: WORKSPACE_ID,
|
|
129
|
+
endpoint: "/reactions.add",
|
|
130
|
+
method: "POST",
|
|
131
|
+
data: {
|
|
132
|
+
channel,
|
|
133
|
+
timestamp,
|
|
134
|
+
name: emoji,
|
|
135
|
+
},
|
|
136
|
+
});
|
|
137
|
+
if (isRecord(data) && data.ok === false) {
|
|
138
|
+
throw new Error(readString(data.error) ?? "Slack addReaction failed");
|
|
139
|
+
}
|
|
140
|
+
},
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
function normalizeGithubResource(data) {
|
|
144
|
+
if (!isRecord(data)) {
|
|
145
|
+
throw new Error("GitHub proxy returned an invalid response");
|
|
146
|
+
}
|
|
147
|
+
const id = readNumber(data.id);
|
|
148
|
+
const url = readString(data.url) ?? readString(data.html_url);
|
|
149
|
+
if (id === undefined || !url) {
|
|
150
|
+
throw new Error("GitHub proxy response is missing id or url");
|
|
151
|
+
}
|
|
152
|
+
return { id, url };
|
|
153
|
+
}
|
|
154
|
+
function createMockGitHubIntegration(mockCloudProxy) {
|
|
155
|
+
return {
|
|
156
|
+
async createIssueComment(owner, repo, number, body) {
|
|
157
|
+
const endpoint = `/repos/${owner}/${repo}/issues/${number}/comments`;
|
|
158
|
+
const data = await postCloudProxy(mockCloudProxy.baseUrl, "/api/v1/proxy/github", {
|
|
159
|
+
connectionId: "test-connection",
|
|
160
|
+
providerConfigKey: "github",
|
|
161
|
+
method: "POST",
|
|
162
|
+
endpoint,
|
|
163
|
+
data: { body },
|
|
164
|
+
});
|
|
165
|
+
return { data: normalizeGithubResource(data) };
|
|
166
|
+
},
|
|
167
|
+
async createPRReview(owner, repo, number, body, event) {
|
|
168
|
+
const endpoint = `/repos/${owner}/${repo}/pulls/${number}/reviews`;
|
|
169
|
+
const data = await postCloudProxy(mockCloudProxy.baseUrl, "/api/v1/proxy/github", {
|
|
170
|
+
connectionId: "test-connection",
|
|
171
|
+
providerConfigKey: "github",
|
|
172
|
+
method: "POST",
|
|
173
|
+
endpoint,
|
|
174
|
+
data: {
|
|
175
|
+
body,
|
|
176
|
+
...(event ? { event } : {}),
|
|
177
|
+
},
|
|
178
|
+
});
|
|
179
|
+
return { data: normalizeGithubResource(data) };
|
|
180
|
+
},
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
async function startMockServers() {
|
|
184
|
+
const [relayfileResult, cloudProxyResult] = await Promise.allSettled([
|
|
185
|
+
createMockRelayfileServer(),
|
|
186
|
+
createMockCloudProxyServer(),
|
|
187
|
+
]);
|
|
188
|
+
if (relayfileResult.status === "fulfilled" && cloudProxyResult.status === "fulfilled") {
|
|
189
|
+
return {
|
|
190
|
+
mockRelayfile: relayfileResult.value,
|
|
191
|
+
mockCloudProxy: cloudProxyResult.value,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
await Promise.allSettled([
|
|
195
|
+
relayfileResult.status === "fulfilled" ? relayfileResult.value.close() : Promise.resolve(),
|
|
196
|
+
cloudProxyResult.status === "fulfilled" ? cloudProxyResult.value.close() : Promise.resolve(),
|
|
197
|
+
]);
|
|
198
|
+
if (relayfileResult.status === "rejected") {
|
|
199
|
+
throw relayfileResult.reason;
|
|
200
|
+
}
|
|
201
|
+
if (cloudProxyResult.status === "rejected") {
|
|
202
|
+
throw cloudProxyResult.reason;
|
|
203
|
+
}
|
|
204
|
+
throw new Error("Mock servers failed to start");
|
|
205
|
+
}
|
|
206
|
+
export async function buildHarness(options = {}) {
|
|
207
|
+
const { mockRelayfile, mockCloudProxy } = await startMockServers();
|
|
208
|
+
const overlay = new RecentActionsOverlay({
|
|
209
|
+
ttlMs: options.overlayTtlMs,
|
|
210
|
+
maxEntries: options.overlayMaxEntries,
|
|
211
|
+
});
|
|
212
|
+
const relayFileClient = new RelayFileClient({
|
|
213
|
+
baseUrl: mockRelayfile.baseUrl,
|
|
214
|
+
token: "test-token",
|
|
215
|
+
fetchImpl: createTimeoutFetch(RELAYFILE_FETCH_TIMEOUT_MS),
|
|
216
|
+
retry: { maxRetries: 0 },
|
|
217
|
+
});
|
|
218
|
+
const reader = new SageRelayFileReader({
|
|
219
|
+
client: relayFileClient,
|
|
220
|
+
workspaceId: WORKSPACE_ID,
|
|
221
|
+
cacheTtlMs: options.readerCacheTtlMs ?? 0,
|
|
222
|
+
overlay,
|
|
223
|
+
});
|
|
224
|
+
const facade = new ProviderWriteFacade({
|
|
225
|
+
slackEgress: createMockSlackEgress(mockCloudProxy),
|
|
226
|
+
githubIntegration: createMockGitHubIntegration(mockCloudProxy),
|
|
227
|
+
overlay,
|
|
228
|
+
defaultMode: options.defaultWriteMode ?? "sync",
|
|
229
|
+
});
|
|
230
|
+
let tornDown = false;
|
|
231
|
+
return {
|
|
232
|
+
reader,
|
|
233
|
+
facade,
|
|
234
|
+
overlay,
|
|
235
|
+
mockRelayfile,
|
|
236
|
+
mockCloudProxy,
|
|
237
|
+
async teardown() {
|
|
238
|
+
if (tornDown) {
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
tornDown = true;
|
|
242
|
+
const results = await Promise.allSettled([
|
|
243
|
+
mockRelayfile.close(),
|
|
244
|
+
mockCloudProxy.close(),
|
|
245
|
+
]);
|
|
246
|
+
overlay.clear();
|
|
247
|
+
const failure = results.find((result) => result.status === "rejected");
|
|
248
|
+
if (failure) {
|
|
249
|
+
throw failure.reason;
|
|
250
|
+
}
|
|
251
|
+
},
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
export function makeSlackMessage(overrides = {}) {
|
|
255
|
+
return {
|
|
256
|
+
channel: "C-test-001",
|
|
257
|
+
text: "Hello from test",
|
|
258
|
+
...overrides,
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
export function makeGitHubPRComment(overrides = {}) {
|
|
262
|
+
return {
|
|
263
|
+
owner: "test-org",
|
|
264
|
+
repo: "test-repo",
|
|
265
|
+
number: 42,
|
|
266
|
+
body: "LGTM",
|
|
267
|
+
...overrides,
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
export function makeRelayfileSeedEntry(path, overrides = {}) {
|
|
271
|
+
return {
|
|
272
|
+
content: JSON.stringify({ seeded: true }),
|
|
273
|
+
contentType: "application/json",
|
|
274
|
+
revision: `rev-${randomHex()}`,
|
|
275
|
+
provider: detectProviderFromPath(path),
|
|
276
|
+
...overrides,
|
|
277
|
+
};
|
|
278
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export interface ProxyResponseOverride {
|
|
2
|
+
status?: number;
|
|
3
|
+
body?: unknown;
|
|
4
|
+
ok?: boolean;
|
|
5
|
+
delayMs?: number;
|
|
6
|
+
error?: string;
|
|
7
|
+
}
|
|
8
|
+
export interface ProxyRequestLogEntry {
|
|
9
|
+
provider: "slack" | "github";
|
|
10
|
+
method: string;
|
|
11
|
+
endpoint: string;
|
|
12
|
+
body?: unknown;
|
|
13
|
+
timestamp: number;
|
|
14
|
+
}
|
|
15
|
+
export interface MockCloudProxyServer {
|
|
16
|
+
port: number;
|
|
17
|
+
baseUrl: string;
|
|
18
|
+
setSlackResponse(endpoint: string, override: ProxyResponseOverride): void;
|
|
19
|
+
setGithubResponse(endpoint: string, override: ProxyResponseOverride): void;
|
|
20
|
+
getRequestLog(): ProxyRequestLogEntry[];
|
|
21
|
+
clear(): void;
|
|
22
|
+
close(): Promise<void>;
|
|
23
|
+
}
|
|
24
|
+
export declare function createMockCloudProxyServer(): Promise<MockCloudProxyServer>;
|
|
25
|
+
//# sourceMappingURL=mock-cloud-proxy-server.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"mock-cloud-proxy-server.d.ts","sourceRoot":"","sources":["../../src/e2e/mock-cloud-proxy-server.ts"],"names":[],"mappings":"AAKA,MAAM,WAAW,qBAAqB;IACpC,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,IAAI,CAAC,EAAE,OAAO,CAAC;IACf,EAAE,CAAC,EAAE,OAAO,CAAC;IACb,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,WAAW,oBAAoB;IACnC,QAAQ,EAAE,OAAO,GAAG,QAAQ,CAAC;IAC7B,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,CAAC;IACjB,IAAI,CAAC,EAAE,OAAO,CAAC;IACf,SAAS,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,WAAW,oBAAoB;IACnC,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;IAChB,gBAAgB,CAAC,QAAQ,EAAE,MAAM,EAAE,QAAQ,EAAE,qBAAqB,GAAG,IAAI,CAAC;IAC1E,iBAAiB,CAAC,QAAQ,EAAE,MAAM,EAAE,QAAQ,EAAE,qBAAqB,GAAG,IAAI,CAAC;IAC3E,aAAa,IAAI,oBAAoB,EAAE,CAAC;IACxC,KAAK,IAAI,IAAI,CAAC;IACd,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CACxB;AAoGD,wBAAsB,0BAA0B,IAAI,OAAO,CAAC,oBAAoB,CAAC,CA2FhF"}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { createServer } from "node:http";
|
|
2
|
+
import express from "express";
|
|
3
|
+
const DEFAULT_SLACK_TS_PREFIX = "1700000000";
|
|
4
|
+
function isRecord(value) {
|
|
5
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
6
|
+
}
|
|
7
|
+
function readString(value) {
|
|
8
|
+
return typeof value === "string" ? value : undefined;
|
|
9
|
+
}
|
|
10
|
+
function readProxyRequestBody(body) {
|
|
11
|
+
return isRecord(body) ? body : {};
|
|
12
|
+
}
|
|
13
|
+
function normalizeEndpoint(endpoint) {
|
|
14
|
+
return endpoint.startsWith("/") ? endpoint : `/${endpoint}`;
|
|
15
|
+
}
|
|
16
|
+
function randomGithubId() {
|
|
17
|
+
return Math.floor(Math.random() * 900_000) + 100_000;
|
|
18
|
+
}
|
|
19
|
+
function defaultSlackData() {
|
|
20
|
+
const suffix = Math.floor(Math.random() * 1_000_000)
|
|
21
|
+
.toString()
|
|
22
|
+
.padStart(6, "0");
|
|
23
|
+
return {
|
|
24
|
+
ok: true,
|
|
25
|
+
ts: `${DEFAULT_SLACK_TS_PREFIX}.${suffix}`,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
function defaultGithubData() {
|
|
29
|
+
const id = randomGithubId();
|
|
30
|
+
const path = `mock-${id}`;
|
|
31
|
+
const htmlUrl = `https://github.com/test/test/${path}`;
|
|
32
|
+
return {
|
|
33
|
+
id,
|
|
34
|
+
url: htmlUrl,
|
|
35
|
+
html_url: htmlUrl,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
function respondWithEnvelope(res, override, defaultData) {
|
|
39
|
+
const status = override?.status ?? 200;
|
|
40
|
+
const ok = override?.ok ?? true;
|
|
41
|
+
if (!ok) {
|
|
42
|
+
res.status(status).json({
|
|
43
|
+
ok: false,
|
|
44
|
+
error: override?.error ?? "Mock cloud proxy error",
|
|
45
|
+
});
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
res.status(status).json({
|
|
49
|
+
ok: true,
|
|
50
|
+
data: override && "body" in override ? override.body : defaultData(),
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
function delayed(delayMs, callback) {
|
|
54
|
+
if (delayMs !== undefined && delayMs > 0) {
|
|
55
|
+
setTimeout(callback, delayMs);
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
callback();
|
|
59
|
+
}
|
|
60
|
+
function closeServer(server) {
|
|
61
|
+
return new Promise((resolve, reject) => {
|
|
62
|
+
server.close((error) => {
|
|
63
|
+
if (error) {
|
|
64
|
+
reject(error);
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
resolve();
|
|
68
|
+
});
|
|
69
|
+
server.closeIdleConnections?.();
|
|
70
|
+
server.closeAllConnections?.();
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
export async function createMockCloudProxyServer() {
|
|
74
|
+
const app = express();
|
|
75
|
+
const slackOverrides = new Map();
|
|
76
|
+
const githubOverrides = new Map();
|
|
77
|
+
const requestLog = [];
|
|
78
|
+
app.use(express.json({ limit: "1mb" }));
|
|
79
|
+
const recordRequest = (provider, req) => {
|
|
80
|
+
const body = readProxyRequestBody(req.body);
|
|
81
|
+
const endpoint = normalizeEndpoint(readString(body.endpoint) ?? "");
|
|
82
|
+
const method = readString(body.method) ?? req.method;
|
|
83
|
+
const overrides = provider === "slack" ? slackOverrides : githubOverrides;
|
|
84
|
+
requestLog.push({
|
|
85
|
+
provider,
|
|
86
|
+
method,
|
|
87
|
+
endpoint,
|
|
88
|
+
body: req.body,
|
|
89
|
+
timestamp: Date.now(),
|
|
90
|
+
});
|
|
91
|
+
return {
|
|
92
|
+
endpoint,
|
|
93
|
+
override: overrides.get(endpoint),
|
|
94
|
+
};
|
|
95
|
+
};
|
|
96
|
+
app.get("/api/health", (_req, res) => {
|
|
97
|
+
res.status(200).json({ ok: true });
|
|
98
|
+
});
|
|
99
|
+
app.post("/api/v1/proxy/slack", (req, res) => {
|
|
100
|
+
const { override } = recordRequest("slack", req);
|
|
101
|
+
delayed(override?.delayMs, () => {
|
|
102
|
+
respondWithEnvelope(res, override, defaultSlackData);
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
app.post("/api/v1/proxy/github", (req, res) => {
|
|
106
|
+
const { override } = recordRequest("github", req);
|
|
107
|
+
delayed(override?.delayMs, () => {
|
|
108
|
+
respondWithEnvelope(res, override, defaultGithubData);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
const server = createServer(app);
|
|
112
|
+
server.keepAliveTimeout = 0;
|
|
113
|
+
server.headersTimeout = 0;
|
|
114
|
+
await new Promise((resolve, reject) => {
|
|
115
|
+
server.once("error", reject);
|
|
116
|
+
server.listen(0, "127.0.0.1", () => {
|
|
117
|
+
server.off("error", reject);
|
|
118
|
+
resolve();
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
const address = server.address();
|
|
122
|
+
if (!address || typeof address === "string") {
|
|
123
|
+
await closeServer(server);
|
|
124
|
+
throw new Error("Mock cloud proxy server did not bind to a TCP port");
|
|
125
|
+
}
|
|
126
|
+
const port = address.port;
|
|
127
|
+
const baseUrl = `http://127.0.0.1:${port}`;
|
|
128
|
+
return {
|
|
129
|
+
port,
|
|
130
|
+
baseUrl,
|
|
131
|
+
setSlackResponse(endpoint, override) {
|
|
132
|
+
slackOverrides.set(normalizeEndpoint(endpoint), override);
|
|
133
|
+
},
|
|
134
|
+
setGithubResponse(endpoint, override) {
|
|
135
|
+
githubOverrides.set(normalizeEndpoint(endpoint), override);
|
|
136
|
+
},
|
|
137
|
+
getRequestLog() {
|
|
138
|
+
return requestLog.map((entry) => ({ ...entry }));
|
|
139
|
+
},
|
|
140
|
+
clear() {
|
|
141
|
+
slackOverrides.clear();
|
|
142
|
+
githubOverrides.clear();
|
|
143
|
+
requestLog.length = 0;
|
|
144
|
+
},
|
|
145
|
+
async close() {
|
|
146
|
+
await closeServer(server);
|
|
147
|
+
},
|
|
148
|
+
};
|
|
149
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export interface SeedFileData {
|
|
2
|
+
content: string;
|
|
3
|
+
contentType?: string;
|
|
4
|
+
encoding?: "utf-8" | "base64";
|
|
5
|
+
revision?: string;
|
|
6
|
+
provider?: string;
|
|
7
|
+
providerObjectId?: string;
|
|
8
|
+
lastEditedAt?: string;
|
|
9
|
+
properties?: Record<string, string>;
|
|
10
|
+
asOf?: number;
|
|
11
|
+
}
|
|
12
|
+
export interface ResponseOverride {
|
|
13
|
+
status?: number;
|
|
14
|
+
body?: unknown;
|
|
15
|
+
delayMs?: number;
|
|
16
|
+
malformedJson?: boolean;
|
|
17
|
+
}
|
|
18
|
+
export interface RequestLogEntry {
|
|
19
|
+
method: string;
|
|
20
|
+
path: string;
|
|
21
|
+
timestamp: number;
|
|
22
|
+
headers: Record<string, string>;
|
|
23
|
+
body?: unknown;
|
|
24
|
+
}
|
|
25
|
+
export interface MockRelayfileServer {
|
|
26
|
+
port: number;
|
|
27
|
+
baseUrl: string;
|
|
28
|
+
seedFile(path: string, data: SeedFileData): void;
|
|
29
|
+
setResponse(path: string, override: ResponseOverride): void;
|
|
30
|
+
clear(): void;
|
|
31
|
+
getRequestLog(): RequestLogEntry[];
|
|
32
|
+
close(): Promise<void>;
|
|
33
|
+
}
|
|
34
|
+
export declare function createMockRelayfileServer(): Promise<MockRelayfileServer>;
|
|
35
|
+
//# sourceMappingURL=mock-relayfile-server.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"mock-relayfile-server.d.ts","sourceRoot":"","sources":["../../src/e2e/mock-relayfile-server.ts"],"names":[],"mappings":"AAMA,MAAM,WAAW,YAAY;IAC3B,OAAO,EAAE,MAAM,CAAC;IAChB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,EAAE,OAAO,GAAG,QAAQ,CAAC;IAC9B,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,UAAU,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACpC,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAED,MAAM,WAAW,gBAAgB;IAC/B,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,IAAI,CAAC,EAAE,OAAO,CAAC;IACf,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,aAAa,CAAC,EAAE,OAAO,CAAC;CACzB;AAED,MAAM,WAAW,eAAe;IAC9B,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,EAAE,MAAM,CAAC;IAClB,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAChC,IAAI,CAAC,EAAE,OAAO,CAAC;CAChB;AAED,MAAM,WAAW,mBAAmB;IAClC,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,YAAY,GAAG,IAAI,CAAC;IACjD,WAAW,CAAC,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,gBAAgB,GAAG,IAAI,CAAC;IAC5D,KAAK,IAAI,IAAI,CAAC;IACd,aAAa,IAAI,eAAe,EAAE,CAAC;IACnC,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CACxB;AAudD,wBAAsB,yBAAyB,IAAI,OAAO,CAAC,mBAAmB,CAAC,CA+K9E"}
|