@agentworkforce/sage 1.1.3 → 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.
Files changed (53) hide show
  1. package/dist/app.d.ts.map +1 -1
  2. package/dist/app.js +54 -29
  3. package/dist/e2e/e2e-harness.d.ts +36 -0
  4. package/dist/e2e/e2e-harness.d.ts.map +1 -0
  5. package/dist/e2e/e2e-harness.js +278 -0
  6. package/dist/e2e/mock-cloud-proxy-server.d.ts +25 -0
  7. package/dist/e2e/mock-cloud-proxy-server.d.ts.map +1 -0
  8. package/dist/e2e/mock-cloud-proxy-server.js +149 -0
  9. package/dist/e2e/mock-relayfile-server.d.ts +35 -0
  10. package/dist/e2e/mock-relayfile-server.d.ts.map +1 -0
  11. package/dist/e2e/mock-relayfile-server.js +488 -0
  12. package/dist/integrations/cloud-proxy-provider.js +1 -1
  13. package/dist/integrations/freshness-envelope.js +1 -1
  14. package/dist/integrations/github.d.ts +24 -1
  15. package/dist/integrations/github.d.ts.map +1 -1
  16. package/dist/integrations/github.js +116 -1
  17. package/dist/integrations/linear-ingress.d.ts +30 -0
  18. package/dist/integrations/linear-ingress.d.ts.map +1 -0
  19. package/dist/integrations/linear-ingress.js +58 -0
  20. package/dist/integrations/notion-ingress.d.ts +26 -0
  21. package/dist/integrations/notion-ingress.d.ts.map +1 -0
  22. package/dist/integrations/notion-ingress.js +70 -0
  23. package/dist/integrations/provider-ingress-dedup.d.ts +14 -0
  24. package/dist/integrations/provider-ingress-dedup.d.ts.map +1 -0
  25. package/dist/integrations/provider-ingress-dedup.js +35 -0
  26. package/dist/integrations/provider-ingress-dedup.test.d.ts +2 -0
  27. package/dist/integrations/provider-ingress-dedup.test.d.ts.map +1 -0
  28. package/dist/integrations/provider-ingress-dedup.test.js +55 -0
  29. package/dist/integrations/provider-write-facade.d.ts +80 -0
  30. package/dist/integrations/provider-write-facade.d.ts.map +1 -0
  31. package/dist/integrations/provider-write-facade.js +417 -0
  32. package/dist/integrations/provider-write-facade.test.d.ts +2 -0
  33. package/dist/integrations/provider-write-facade.test.d.ts.map +1 -0
  34. package/dist/integrations/provider-write-facade.test.js +247 -0
  35. package/dist/integrations/read-your-writes.test.d.ts +2 -0
  36. package/dist/integrations/read-your-writes.test.d.ts.map +1 -0
  37. package/dist/integrations/read-your-writes.test.js +170 -0
  38. package/dist/integrations/recent-actions-overlay.d.ts +1 -0
  39. package/dist/integrations/recent-actions-overlay.d.ts.map +1 -1
  40. package/dist/integrations/recent-actions-overlay.js +3 -0
  41. package/dist/integrations/relayfile-reader-envelope.test.d.ts +2 -0
  42. package/dist/integrations/relayfile-reader-envelope.test.d.ts.map +1 -0
  43. package/dist/integrations/relayfile-reader-envelope.test.js +198 -0
  44. package/dist/integrations/relayfile-reader.d.ts +20 -1
  45. package/dist/integrations/relayfile-reader.d.ts.map +1 -1
  46. package/dist/integrations/relayfile-reader.js +334 -48
  47. package/dist/integrations/slack-egress.d.ts +2 -1
  48. package/dist/integrations/slack-egress.d.ts.map +1 -1
  49. package/dist/integrations/slack-egress.js +28 -1
  50. package/dist/observability.e2e.test.d.ts +2 -0
  51. package/dist/observability.e2e.test.d.ts.map +1 -0
  52. package/dist/observability.e2e.test.js +411 -0
  53. 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":"AAKA,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;AA8tCF,wBAAgB,aAAa,IAAI,IAAI,CAAC,MAAM,CAAC,CAshB5C"}
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
- async function isActiveThread(kv, threadTs) {
580
- if (!threadTs) {
581
- return false;
582
- }
583
- return (await kv.get(threadTs)) !== null;
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
- async function markActiveThread(kv, threadTs, ctx) {
586
- if (!threadTs) {
587
- return;
588
- }
589
- await kv.put(threadTs, JSON.stringify(ctx), { expirationTtl: ACTIVE_THREAD_TTL_SECONDS });
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
- if (workspaceId && channel) {
606
- activeThreads.set(name, {
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 markActiveThread(env.THREADS, replyThreadTs, {
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
- if (event.type === "message" && event.threadTs) {
1323
- const isActive = await isActiveThread(c.env.THREADS, event.threadTs);
1324
- if (!isActive) {
1325
- console.log(`[sage] Dropping thread reply on inactive thread: threadTs=${event.threadTs}`);
1326
- return c.json({ ok: true });
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
- const mentionThreadTs = event.threadTs ?? event.ts;
1334
- if (mentionThreadTs) {
1335
- await markActiveThread(c.env.THREADS, mentionThreadTs, {
1336
- workspaceId,
1337
- channel: event.channel,
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"}