@sjawhar/opencode-legion-envoy 0.1.1 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +39 -17
- package/package.json +5 -1
- package/src/__tests__/index.test.ts +74 -0
- package/src/__tests__/port.test.ts +60 -42
- package/src/index.ts +36 -13
- package/src/port.ts +20 -5
package/dist/index.js
CHANGED
|
@@ -12339,13 +12339,21 @@ function tool(input) {
|
|
|
12339
12339
|
tool.schema = exports_external;
|
|
12340
12340
|
|
|
12341
12341
|
// src/port.ts
|
|
12342
|
-
import {
|
|
12343
|
-
|
|
12342
|
+
import { execFile } from "child_process";
|
|
12343
|
+
var defaultExec = (command, args, options) => new Promise((resolve, reject) => {
|
|
12344
|
+
execFile(command, args, { encoding: options.encoding }, (error45, stdout) => {
|
|
12345
|
+
if (error45)
|
|
12346
|
+
reject(error45);
|
|
12347
|
+
else
|
|
12348
|
+
resolve(stdout);
|
|
12349
|
+
});
|
|
12350
|
+
});
|
|
12351
|
+
async function resolvePort(serverUrl, exec = defaultExec) {
|
|
12344
12352
|
const urlPort = Number.parseInt(serverUrl.port, 10);
|
|
12345
12353
|
if (Number.isFinite(urlPort) && urlPort > 0)
|
|
12346
12354
|
return urlPort;
|
|
12347
12355
|
try {
|
|
12348
|
-
const output = exec("ss", ["-tlnp"], { encoding: "utf-8" });
|
|
12356
|
+
const output = await exec("ss", ["-tlnp"], { encoding: "utf-8" });
|
|
12349
12357
|
for (const line of output.split(`
|
|
12350
12358
|
`)) {
|
|
12351
12359
|
if (!line.includes(`pid=${process.pid}`))
|
|
@@ -12365,8 +12373,12 @@ function resolvePort(serverUrl, exec = execFileSync) {
|
|
|
12365
12373
|
|
|
12366
12374
|
// src/index.ts
|
|
12367
12375
|
var root = process.env.ENVOY_URL ?? "http://127.0.0.1:9020";
|
|
12376
|
+
var CALL_TIMEOUT_MS = 5000;
|
|
12368
12377
|
async function call(path, init) {
|
|
12369
|
-
const res = await fetch(`${root}${path}`,
|
|
12378
|
+
const res = await fetch(`${root}${path}`, {
|
|
12379
|
+
...init,
|
|
12380
|
+
signal: init?.signal ?? AbortSignal.timeout(CALL_TIMEOUT_MS)
|
|
12381
|
+
});
|
|
12370
12382
|
const text = await res.text();
|
|
12371
12383
|
if (!res.ok)
|
|
12372
12384
|
throw new Error(text || `${res.status}`);
|
|
@@ -12376,6 +12388,7 @@ var src_default = async (input) => {
|
|
|
12376
12388
|
const registryDir = process.env.OC_REGISTRY;
|
|
12377
12389
|
let activeSessionID = null;
|
|
12378
12390
|
let _activeFile = null;
|
|
12391
|
+
let resolvedPort = null;
|
|
12379
12392
|
const registryFile = (sessionID) => registryDir ? `${registryDir}/${sessionID}.json` : null;
|
|
12380
12393
|
const update = (sessionID, patch) => {
|
|
12381
12394
|
const file2 = registryFile(sessionID);
|
|
@@ -12395,18 +12408,21 @@ var src_default = async (input) => {
|
|
|
12395
12408
|
} catch {}
|
|
12396
12409
|
};
|
|
12397
12410
|
let portWarningLogged = false;
|
|
12398
|
-
const
|
|
12399
|
-
const port = resolvePort(input.serverUrl);
|
|
12411
|
+
const refreshPort = async () => {
|
|
12412
|
+
const port = await resolvePort(input.serverUrl);
|
|
12400
12413
|
if (!port && !portWarningLogged) {
|
|
12401
12414
|
portWarningLogged = true;
|
|
12402
12415
|
console.error(`[envoy-plugin] Could not resolve serve port: serverUrl=${input.serverUrl.href}, pid=${process.pid}`);
|
|
12403
12416
|
}
|
|
12404
|
-
if (port)
|
|
12417
|
+
if (port) {
|
|
12405
12418
|
portWarningLogged = false;
|
|
12419
|
+
resolvedPort = port;
|
|
12420
|
+
}
|
|
12406
12421
|
return port;
|
|
12407
12422
|
};
|
|
12408
|
-
const
|
|
12409
|
-
|
|
12423
|
+
const currentPort = () => resolvedPort;
|
|
12424
|
+
const syncPort = async (sessionID) => {
|
|
12425
|
+
const value = await refreshPort();
|
|
12410
12426
|
if (!value)
|
|
12411
12427
|
return false;
|
|
12412
12428
|
if (sessionID)
|
|
@@ -12415,7 +12431,9 @@ var src_default = async (input) => {
|
|
|
12415
12431
|
};
|
|
12416
12432
|
const fetchSession = async (sessionID) => {
|
|
12417
12433
|
try {
|
|
12418
|
-
const res = await fetch(`${input.serverUrl}/session/${sessionID}
|
|
12434
|
+
const res = await fetch(`${input.serverUrl}/session/${sessionID}`, {
|
|
12435
|
+
signal: AbortSignal.timeout(CALL_TIMEOUT_MS)
|
|
12436
|
+
});
|
|
12419
12437
|
if (!res.ok)
|
|
12420
12438
|
return null;
|
|
12421
12439
|
const session = await res.json();
|
|
@@ -12426,11 +12444,14 @@ var src_default = async (input) => {
|
|
|
12426
12444
|
};
|
|
12427
12445
|
const registryFiles = new Set;
|
|
12428
12446
|
if (registryDir) {
|
|
12429
|
-
syncPort();
|
|
12430
12447
|
const timer = setInterval(() => {
|
|
12431
|
-
|
|
12432
|
-
|
|
12448
|
+
syncPort(activeSessionID ?? undefined).then((resolved) => {
|
|
12449
|
+
if (resolved)
|
|
12450
|
+
clearInterval(timer);
|
|
12451
|
+
});
|
|
12433
12452
|
}, 1000);
|
|
12453
|
+
timer.unref();
|
|
12454
|
+
syncPort().catch(() => {});
|
|
12434
12455
|
const heartbeatInterval = setInterval(() => {
|
|
12435
12456
|
if (!activeSessionID)
|
|
12436
12457
|
return;
|
|
@@ -12449,6 +12470,7 @@ var src_default = async (input) => {
|
|
|
12449
12470
|
}).catch(() => {});
|
|
12450
12471
|
}, 2 * 60 * 1000);
|
|
12451
12472
|
process.on("exit", () => {
|
|
12473
|
+
clearInterval(timer);
|
|
12452
12474
|
clearInterval(heartbeatInterval);
|
|
12453
12475
|
for (const f of registryFiles) {
|
|
12454
12476
|
try {
|
|
@@ -12460,7 +12482,7 @@ var src_default = async (input) => {
|
|
|
12460
12482
|
return {
|
|
12461
12483
|
event: registryDir ? async ({ event }) => {
|
|
12462
12484
|
if (activeSessionID)
|
|
12463
|
-
syncPort(activeSessionID);
|
|
12485
|
+
syncPort(activeSessionID).catch(() => {});
|
|
12464
12486
|
if (event.type === "session.status" && event.properties?.status?.type === "busy") {
|
|
12465
12487
|
const sessionID = event.properties?.sessionID;
|
|
12466
12488
|
if (sessionID && sessionID !== activeSessionID) {
|
|
@@ -12469,7 +12491,7 @@ var src_default = async (input) => {
|
|
|
12469
12491
|
const session = await fetchSession(sessionID);
|
|
12470
12492
|
if (session)
|
|
12471
12493
|
update(sessionID, { session });
|
|
12472
|
-
syncPort(sessionID);
|
|
12494
|
+
await syncPort(sessionID);
|
|
12473
12495
|
const port = currentPort();
|
|
12474
12496
|
if (port) {
|
|
12475
12497
|
call("/v1/interests/subscribe", {
|
|
@@ -12502,9 +12524,9 @@ var src_default = async (input) => {
|
|
|
12502
12524
|
} : undefined,
|
|
12503
12525
|
tool: {
|
|
12504
12526
|
envoy_subscribe: tool({
|
|
12505
|
-
description: "Subscribe this session to Envoy notification topics.
|
|
12527
|
+
description: "Subscribe this session to Envoy notification topics. GitHub topics are resource-scoped: notifications.github.<owner>.<repo>.pr.<number>, notifications.github.<owner>.<repo>.issue.<number>.comment, etc. Use NATS wildcards for broad subscriptions: notifications.github.<owner>.<repo>.pr.> (all PR events). Other topics: notifications.agent.<session_id>, notifications.slack.<team_id>.<channel_id>.message, notifications.slack.<team_id>.<channel_id>.mention. Use this when a session should RECEIVE future events.",
|
|
12506
12528
|
args: {
|
|
12507
|
-
topics: tool.schema.array(tool.schema.string()).describe("NATS-style topic patterns to subscribe to.
|
|
12529
|
+
topics: tool.schema.array(tool.schema.string()).describe("NATS-style topic patterns to subscribe to. GitHub topics include resource number: notifications.github.owner.repo.pr.123 (PR state), notifications.github.owner.repo.pr.123.comment (PR comments), notifications.github.owner.repo.issue.456.> (all events on issue). Use > wildcard for broad matching. Other examples: notifications.agent.ses_123, notifications.slack.T09FRELLTS8.C0A0DHVU8HE.mention")
|
|
12508
12530
|
},
|
|
12509
12531
|
async execute(args, ctx) {
|
|
12510
12532
|
ctx.metadata({ title: "Envoy subscribe" });
|
package/package.json
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sjawhar/opencode-legion-envoy",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "https://github.com/sjawhar/legion"
|
|
9
|
+
},
|
|
6
10
|
"types": "dist/index.d.ts",
|
|
7
11
|
"scripts": {
|
|
8
12
|
"build": "bun build src/index.ts --outdir dist --target bun --format esm",
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test";
|
|
2
|
+
|
|
3
|
+
// Suppress console.error during tests
|
|
4
|
+
const originalError = console.error;
|
|
5
|
+
beforeEach(() => {
|
|
6
|
+
console.error = mock(() => {});
|
|
7
|
+
});
|
|
8
|
+
afterEach(() => {
|
|
9
|
+
console.error = originalError;
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
describe("envoy plugin init", () => {
|
|
13
|
+
it("returns immediately without blocking on port resolution or Envoy calls", async () => {
|
|
14
|
+
// Simulate NATS/Envoy being unavailable — plugin init must still complete fast
|
|
15
|
+
const originalEnvoyUrl = process.env.ENVOY_URL;
|
|
16
|
+
const originalRegistry = process.env.OC_REGISTRY;
|
|
17
|
+
process.env.ENVOY_URL = "http://127.0.0.1:59999"; // Non-existent
|
|
18
|
+
process.env.OC_REGISTRY = undefined;
|
|
19
|
+
|
|
20
|
+
try {
|
|
21
|
+
const pluginModule = await import("../index");
|
|
22
|
+
const initPlugin = pluginModule.default;
|
|
23
|
+
|
|
24
|
+
const start = performance.now();
|
|
25
|
+
const hooks = await initPlugin({ serverUrl: new URL("http://127.0.0.1:13381") } as never);
|
|
26
|
+
const elapsed = performance.now() - start;
|
|
27
|
+
|
|
28
|
+
// Plugin init must complete in under 1 second regardless of NATS state
|
|
29
|
+
expect(elapsed).toBeLessThan(1000);
|
|
30
|
+
expect(hooks.tool).toBeDefined();
|
|
31
|
+
expect(hooks.tool.envoy_subscribe).toBeDefined();
|
|
32
|
+
expect(hooks.tool.envoy_unsubscribe).toBeDefined();
|
|
33
|
+
expect(hooks.tool.envoy_list).toBeDefined();
|
|
34
|
+
expect(hooks.tool.envoy_send).toBeDefined();
|
|
35
|
+
expect(hooks.tool.envoy_publish).toBeDefined();
|
|
36
|
+
} finally {
|
|
37
|
+
process.env.ENVOY_URL = originalEnvoyUrl;
|
|
38
|
+
process.env.OC_REGISTRY = originalRegistry;
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("call() includes a timeout to prevent hanging on unresponsive Envoy", async () => {
|
|
43
|
+
// The call function has AbortSignal.timeout — verify it doesn't hang
|
|
44
|
+
// We test this indirectly: a tool call to non-existent Envoy should reject within timeout
|
|
45
|
+
const originalEnvoyUrl = process.env.ENVOY_URL;
|
|
46
|
+
const originalRegistry = process.env.OC_REGISTRY;
|
|
47
|
+
process.env.ENVOY_URL = "http://127.0.0.1:59999";
|
|
48
|
+
process.env.OC_REGISTRY = undefined;
|
|
49
|
+
|
|
50
|
+
try {
|
|
51
|
+
const pluginModule = await import("../index");
|
|
52
|
+
const initPlugin = pluginModule.default;
|
|
53
|
+
const hooks = await initPlugin({ serverUrl: new URL("http://127.0.0.1:13381") } as never);
|
|
54
|
+
|
|
55
|
+
const start = performance.now();
|
|
56
|
+
try {
|
|
57
|
+
await hooks.tool.envoy_list.execute({}, {
|
|
58
|
+
sessionID: "ses_test",
|
|
59
|
+
directory: "/tmp",
|
|
60
|
+
metadata: () => {},
|
|
61
|
+
} as never);
|
|
62
|
+
} catch {
|
|
63
|
+
// Expected to fail — Envoy is not running
|
|
64
|
+
}
|
|
65
|
+
const elapsed = performance.now() - start;
|
|
66
|
+
|
|
67
|
+
// Should fail fast due to connection refused, not hang indefinitely
|
|
68
|
+
expect(elapsed).toBeLessThan(6000);
|
|
69
|
+
} finally {
|
|
70
|
+
process.env.ENVOY_URL = originalEnvoyUrl;
|
|
71
|
+
process.env.OC_REGISTRY = originalRegistry;
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
});
|
|
@@ -2,9 +2,9 @@ import { describe, expect, it, mock } from "bun:test";
|
|
|
2
2
|
import { resolvePort } from "../port";
|
|
3
3
|
|
|
4
4
|
describe("resolvePort", () => {
|
|
5
|
-
const noopExec = (() => {
|
|
5
|
+
const noopExec = (async () => {
|
|
6
6
|
throw new Error("ss not available");
|
|
7
|
-
}) as typeof
|
|
7
|
+
}) as Parameters<typeof resolvePort>[1];
|
|
8
8
|
|
|
9
9
|
const ssOutput = (pid: number, port: number) =>
|
|
10
10
|
[
|
|
@@ -14,93 +14,111 @@ describe("resolvePort", () => {
|
|
|
14
14
|
].join("\n");
|
|
15
15
|
|
|
16
16
|
describe("URL port extraction", () => {
|
|
17
|
-
it("returns port from standard non-default URL", () => {
|
|
18
|
-
expect(resolvePort(new URL("http://127.0.0.1:4096"), noopExec)).toBe(4096);
|
|
17
|
+
it("returns port from standard non-default URL", async () => {
|
|
18
|
+
expect(await resolvePort(new URL("http://127.0.0.1:4096"), noopExec)).toBe(4096);
|
|
19
19
|
});
|
|
20
20
|
|
|
21
|
-
it("returns port for high serve ports", () => {
|
|
22
|
-
expect(resolvePort(new URL("http://127.0.0.1:13381"), noopExec)).toBe(13381);
|
|
21
|
+
it("returns port for high serve ports", async () => {
|
|
22
|
+
expect(await resolvePort(new URL("http://127.0.0.1:13381"), noopExec)).toBe(13381);
|
|
23
23
|
});
|
|
24
24
|
|
|
25
|
-
it("returns port for localhost URLs", () => {
|
|
26
|
-
expect(resolvePort(new URL("http://localhost:4096"), noopExec)).toBe(4096);
|
|
25
|
+
it("returns port for localhost URLs", async () => {
|
|
26
|
+
expect(await resolvePort(new URL("http://localhost:4096"), noopExec)).toBe(4096);
|
|
27
27
|
});
|
|
28
28
|
|
|
29
|
-
it("returns port for IPv6 URLs", () => {
|
|
30
|
-
expect(resolvePort(new URL("http://[::1]:4096"), noopExec)).toBe(4096);
|
|
29
|
+
it("returns port for IPv6 URLs", async () => {
|
|
30
|
+
expect(await resolvePort(new URL("http://[::1]:4096"), noopExec)).toBe(4096);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("skips exec entirely when URL port is valid", async () => {
|
|
34
|
+
const exec = mock(async () => ssOutput(process.pid, 9999));
|
|
35
|
+
expect(await resolvePort(new URL("http://127.0.0.1:4096"), exec as never)).toBe(4096);
|
|
36
|
+
expect(exec).not.toHaveBeenCalled();
|
|
31
37
|
});
|
|
32
38
|
});
|
|
33
39
|
|
|
34
40
|
describe("ss fallback", () => {
|
|
35
|
-
it("uses ss when URL has no port (default HTTP)", () => {
|
|
36
|
-
const exec = mock(() => ssOutput(process.pid, 4096));
|
|
37
|
-
expect(resolvePort(new URL("http://127.0.0.1"), exec as never)).toBe(4096);
|
|
41
|
+
it("uses ss when URL has no port (default HTTP)", async () => {
|
|
42
|
+
const exec = mock(async () => ssOutput(process.pid, 4096));
|
|
43
|
+
expect(await resolvePort(new URL("http://127.0.0.1"), exec as never)).toBe(4096);
|
|
38
44
|
expect(exec).toHaveBeenCalledWith("ss", ["-tlnp"], { encoding: "utf-8" });
|
|
39
45
|
});
|
|
40
46
|
|
|
41
|
-
it("uses ss when URL port is 0", () => {
|
|
47
|
+
it("uses ss when URL port is 0", async () => {
|
|
42
48
|
// URL("http://127.0.0.1:0").port is "0", which is not > 0
|
|
43
|
-
const exec = mock(() => ssOutput(process.pid, 13381));
|
|
44
|
-
expect(resolvePort(new URL("http://127.0.0.1:0"), exec as never)).toBe(13381);
|
|
49
|
+
const exec = mock(async () => ssOutput(process.pid, 13381));
|
|
50
|
+
expect(await resolvePort(new URL("http://127.0.0.1:0"), exec as never)).toBe(13381);
|
|
45
51
|
});
|
|
46
52
|
|
|
47
|
-
it("ignores ss lines with different PIDs", () => {
|
|
48
|
-
const exec = mock(() => ssOutput(99999, 4096));
|
|
49
|
-
expect(resolvePort(new URL("http://127.0.0.1"), exec as never)).toBeNull();
|
|
53
|
+
it("ignores ss lines with different PIDs", async () => {
|
|
54
|
+
const exec = mock(async () => ssOutput(99999, 4096));
|
|
55
|
+
expect(await resolvePort(new URL("http://127.0.0.1"), exec as never)).toBeNull();
|
|
50
56
|
});
|
|
51
57
|
|
|
52
|
-
it("handles multiple ss entries and picks the matching PID", () => {
|
|
58
|
+
it("handles multiple ss entries and picks the matching PID", async () => {
|
|
53
59
|
const output = [
|
|
54
60
|
"State Recv-Q Send-Q Local Address:Port Peer Address:Port Process",
|
|
55
61
|
`LISTEN 0 511 127.0.0.1:8080 0.0.0.0:* users:(("node",pid=99999,fd=6))`,
|
|
56
62
|
`LISTEN 0 511 127.0.0.1:4096 0.0.0.0:* users:(("bun",pid=${process.pid},fd=7))`,
|
|
57
63
|
"",
|
|
58
64
|
].join("\n");
|
|
59
|
-
const exec = mock(() => output);
|
|
60
|
-
expect(resolvePort(new URL("http://127.0.0.1"), exec as never)).toBe(4096);
|
|
65
|
+
const exec = mock(async () => output);
|
|
66
|
+
expect(await resolvePort(new URL("http://127.0.0.1"), exec as never)).toBe(4096);
|
|
61
67
|
});
|
|
62
68
|
|
|
63
|
-
it("handles IPv6 listening addresses in ss output", () => {
|
|
69
|
+
it("handles IPv6 listening addresses in ss output", async () => {
|
|
64
70
|
const output = [
|
|
65
71
|
"State Recv-Q Send-Q Local Address:Port Peer Address:Port Process",
|
|
66
72
|
`LISTEN 0 511 [::]:4096 [::]:* users:(("bun",pid=${process.pid},fd=6))`,
|
|
67
73
|
"",
|
|
68
74
|
].join("\n");
|
|
69
|
-
const exec = mock(() => output);
|
|
70
|
-
expect(resolvePort(new URL("http://127.0.0.1"), exec as never)).toBe(4096);
|
|
75
|
+
const exec = mock(async () => output);
|
|
76
|
+
expect(await resolvePort(new URL("http://127.0.0.1"), exec as never)).toBe(4096);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("works with async exec that resolves after a delay", async () => {
|
|
80
|
+
const exec = mock(
|
|
81
|
+
() =>
|
|
82
|
+
new Promise<string>((resolve) =>
|
|
83
|
+
setTimeout(() => resolve(ssOutput(process.pid, 5555)), 10)
|
|
84
|
+
)
|
|
85
|
+
);
|
|
86
|
+
expect(await resolvePort(new URL("http://127.0.0.1"), exec as never)).toBe(5555);
|
|
71
87
|
});
|
|
72
88
|
});
|
|
89
|
+
|
|
73
90
|
describe("failure cases", () => {
|
|
74
|
-
it("returns null when ss is not available", () => {
|
|
75
|
-
expect(resolvePort(new URL("http://127.0.0.1"), noopExec)).toBeNull();
|
|
91
|
+
it("returns null when ss is not available", async () => {
|
|
92
|
+
expect(await resolvePort(new URL("http://127.0.0.1"), noopExec)).toBeNull();
|
|
76
93
|
});
|
|
77
94
|
|
|
78
|
-
it("returns null when ss returns empty output", () => {
|
|
79
|
-
const exec = mock(() => "");
|
|
80
|
-
expect(resolvePort(new URL("http://127.0.0.1"), exec as never)).toBeNull();
|
|
95
|
+
it("returns null when ss returns empty output", async () => {
|
|
96
|
+
const exec = mock(async () => "");
|
|
97
|
+
expect(await resolvePort(new URL("http://127.0.0.1"), exec as never)).toBeNull();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("returns null when async exec rejects", async () => {
|
|
101
|
+
const exec = mock(async () => {
|
|
102
|
+
throw new Error("command failed");
|
|
103
|
+
});
|
|
104
|
+
expect(await resolvePort(new URL("http://127.0.0.1"), exec as never)).toBeNull();
|
|
81
105
|
});
|
|
82
106
|
});
|
|
83
107
|
|
|
84
108
|
describe("edge cases", () => {
|
|
85
|
-
it("URL.port is empty string for default HTTP port 80", () => {
|
|
109
|
+
it("URL.port is empty string for default HTTP port 80", async () => {
|
|
86
110
|
// http://127.0.0.1:80 → URL.port is "" (80 is default for http)
|
|
87
111
|
const url = new URL("http://127.0.0.1:80");
|
|
88
112
|
expect(url.port).toBe("");
|
|
89
|
-
const exec = mock(() => ssOutput(process.pid, 4096));
|
|
90
|
-
expect(resolvePort(url, exec as never)).toBe(4096);
|
|
113
|
+
const exec = mock(async () => ssOutput(process.pid, 4096));
|
|
114
|
+
expect(await resolvePort(url, exec as never)).toBe(4096);
|
|
91
115
|
});
|
|
92
116
|
|
|
93
|
-
it("URL.port is empty string for default HTTPS port 443", () => {
|
|
117
|
+
it("URL.port is empty string for default HTTPS port 443", async () => {
|
|
94
118
|
const url = new URL("https://127.0.0.1:443");
|
|
95
119
|
expect(url.port).toBe("");
|
|
96
|
-
const exec = mock(() => ssOutput(process.pid, 4096));
|
|
97
|
-
expect(resolvePort(url, exec as never)).toBe(4096);
|
|
98
|
-
});
|
|
99
|
-
|
|
100
|
-
it("does not use ss fallback when URL port is valid", () => {
|
|
101
|
-
const exec = mock(() => ssOutput(process.pid, 9999));
|
|
102
|
-
expect(resolvePort(new URL("http://127.0.0.1:4096"), exec as never)).toBe(4096);
|
|
103
|
-
expect(exec).not.toHaveBeenCalled();
|
|
120
|
+
const exec = mock(async () => ssOutput(process.pid, 4096));
|
|
121
|
+
expect(await resolvePort(url, exec as never)).toBe(4096);
|
|
104
122
|
});
|
|
105
123
|
});
|
|
106
124
|
});
|
package/src/index.ts
CHANGED
|
@@ -4,8 +4,14 @@ import { resolvePort } from "./port";
|
|
|
4
4
|
|
|
5
5
|
const root = process.env.ENVOY_URL ?? "http://127.0.0.1:9020";
|
|
6
6
|
|
|
7
|
+
/** HTTP timeout for Envoy calls — prevent hanging when NATS/Envoy is unavailable. */
|
|
8
|
+
const CALL_TIMEOUT_MS = 5_000;
|
|
9
|
+
|
|
7
10
|
async function call(path: string, init?: RequestInit) {
|
|
8
|
-
const res = await fetch(`${root}${path}`,
|
|
11
|
+
const res = await fetch(`${root}${path}`, {
|
|
12
|
+
...init,
|
|
13
|
+
signal: init?.signal ?? AbortSignal.timeout(CALL_TIMEOUT_MS),
|
|
14
|
+
});
|
|
9
15
|
const text = await res.text();
|
|
10
16
|
if (!res.ok) throw new Error(text || `${res.status}`);
|
|
11
17
|
return text;
|
|
@@ -15,6 +21,8 @@ export default async (input: { serverUrl: URL }) => {
|
|
|
15
21
|
const registryDir = process.env.OC_REGISTRY;
|
|
16
22
|
let activeSessionID: string | null = null;
|
|
17
23
|
let _activeFile: string | null = null;
|
|
24
|
+
/** Cached port — resolved asynchronously, null until first successful resolution. */
|
|
25
|
+
let resolvedPort: number | null = null;
|
|
18
26
|
|
|
19
27
|
const registryFile = (sessionID: string) =>
|
|
20
28
|
registryDir ? `${registryDir}/${sessionID}.json` : null;
|
|
@@ -36,20 +44,26 @@ export default async (input: { serverUrl: URL }) => {
|
|
|
36
44
|
};
|
|
37
45
|
|
|
38
46
|
let portWarningLogged = false;
|
|
39
|
-
const
|
|
40
|
-
const port = resolvePort(input.serverUrl);
|
|
47
|
+
const refreshPort = async (): Promise<number | null> => {
|
|
48
|
+
const port = await resolvePort(input.serverUrl);
|
|
41
49
|
if (!port && !portWarningLogged) {
|
|
42
50
|
portWarningLogged = true;
|
|
43
51
|
console.error(
|
|
44
52
|
`[envoy-plugin] Could not resolve serve port: serverUrl=${input.serverUrl.href}, pid=${process.pid}`
|
|
45
53
|
);
|
|
46
54
|
}
|
|
47
|
-
if (port)
|
|
55
|
+
if (port) {
|
|
56
|
+
portWarningLogged = false;
|
|
57
|
+
resolvedPort = port;
|
|
58
|
+
}
|
|
48
59
|
return port;
|
|
49
60
|
};
|
|
50
61
|
|
|
51
|
-
|
|
52
|
-
|
|
62
|
+
/** Return cached port synchronously — tools need a port value inline. */
|
|
63
|
+
const currentPort = () => resolvedPort;
|
|
64
|
+
|
|
65
|
+
const syncPort = async (sessionID?: string): Promise<boolean> => {
|
|
66
|
+
const value = await refreshPort();
|
|
53
67
|
if (!value) return false;
|
|
54
68
|
if (sessionID) update(sessionID, { port: value });
|
|
55
69
|
return true;
|
|
@@ -57,7 +71,9 @@ export default async (input: { serverUrl: URL }) => {
|
|
|
57
71
|
|
|
58
72
|
const fetchSession = async (sessionID: string) => {
|
|
59
73
|
try {
|
|
60
|
-
const res = await fetch(`${input.serverUrl}/session/${sessionID}
|
|
74
|
+
const res = await fetch(`${input.serverUrl}/session/${sessionID}`, {
|
|
75
|
+
signal: AbortSignal.timeout(CALL_TIMEOUT_MS),
|
|
76
|
+
});
|
|
61
77
|
if (!res.ok) return null;
|
|
62
78
|
const session = await res.json();
|
|
63
79
|
return { id: session.id, title: session.title || "" };
|
|
@@ -70,10 +86,16 @@ export default async (input: { serverUrl: URL }) => {
|
|
|
70
86
|
const registryFiles = new Set<string>();
|
|
71
87
|
|
|
72
88
|
if (registryDir) {
|
|
73
|
-
|
|
89
|
+
// Defer port resolution to background — never block plugin init.
|
|
90
|
+
// The port sync loop retries every second until resolved.
|
|
74
91
|
const timer = setInterval(() => {
|
|
75
|
-
|
|
92
|
+
syncPort(activeSessionID ?? undefined).then((resolved) => {
|
|
93
|
+
if (resolved) clearInterval(timer);
|
|
94
|
+
});
|
|
76
95
|
}, 1000);
|
|
96
|
+
timer.unref(); // Don't prevent graceful shutdown if port never resolves
|
|
97
|
+
// Fire an immediate async attempt (non-blocking)
|
|
98
|
+
syncPort().catch(() => {});
|
|
77
99
|
|
|
78
100
|
// Heartbeat: re-subscribe every 2 minutes to refresh envoy_sessions TTL (5-min)
|
|
79
101
|
const heartbeatInterval = setInterval(
|
|
@@ -96,6 +118,7 @@ export default async (input: { serverUrl: URL }) => {
|
|
|
96
118
|
);
|
|
97
119
|
|
|
98
120
|
process.on("exit", () => {
|
|
121
|
+
clearInterval(timer);
|
|
99
122
|
clearInterval(heartbeatInterval);
|
|
100
123
|
for (const f of registryFiles) {
|
|
101
124
|
try {
|
|
@@ -108,7 +131,7 @@ export default async (input: { serverUrl: URL }) => {
|
|
|
108
131
|
return {
|
|
109
132
|
event: registryDir
|
|
110
133
|
? async ({ event }: { event: { type?: string; properties?: Record<string, unknown> } }) => {
|
|
111
|
-
if (activeSessionID) syncPort(activeSessionID);
|
|
134
|
+
if (activeSessionID) syncPort(activeSessionID).catch(() => {});
|
|
112
135
|
|
|
113
136
|
if (
|
|
114
137
|
event.type === "session.status" &&
|
|
@@ -120,7 +143,7 @@ export default async (input: { serverUrl: URL }) => {
|
|
|
120
143
|
_activeFile = registryFile(sessionID);
|
|
121
144
|
const session = await fetchSession(sessionID);
|
|
122
145
|
if (session) update(sessionID, { session });
|
|
123
|
-
syncPort(sessionID);
|
|
146
|
+
await syncPort(sessionID);
|
|
124
147
|
const port = currentPort();
|
|
125
148
|
if (port) {
|
|
126
149
|
call("/v1/interests/subscribe", {
|
|
@@ -155,12 +178,12 @@ export default async (input: { serverUrl: URL }) => {
|
|
|
155
178
|
tool: {
|
|
156
179
|
envoy_subscribe: tool({
|
|
157
180
|
description:
|
|
158
|
-
"Subscribe this session to Envoy notification topics.
|
|
181
|
+
"Subscribe this session to Envoy notification topics. GitHub topics are resource-scoped: notifications.github.<owner>.<repo>.pr.<number>, notifications.github.<owner>.<repo>.issue.<number>.comment, etc. Use NATS wildcards for broad subscriptions: notifications.github.<owner>.<repo>.pr.> (all PR events). Other topics: notifications.agent.<session_id>, notifications.slack.<team_id>.<channel_id>.message, notifications.slack.<team_id>.<channel_id>.mention. Use this when a session should RECEIVE future events.",
|
|
159
182
|
args: {
|
|
160
183
|
topics: tool.schema
|
|
161
184
|
.array(tool.schema.string())
|
|
162
185
|
.describe(
|
|
163
|
-
"NATS-style topic patterns to subscribe to.
|
|
186
|
+
"NATS-style topic patterns to subscribe to. GitHub topics include resource number: notifications.github.owner.repo.pr.123 (PR state), notifications.github.owner.repo.pr.123.comment (PR comments), notifications.github.owner.repo.issue.456.> (all events on issue). Use > wildcard for broad matching. Other examples: notifications.agent.ses_123, notifications.slack.T09FRELLTS8.C0A0DHVU8HE.mention"
|
|
164
187
|
),
|
|
165
188
|
},
|
|
166
189
|
async execute(args, ctx) {
|
package/src/port.ts
CHANGED
|
@@ -1,4 +1,18 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
type ExecFn = (
|
|
4
|
+
command: string,
|
|
5
|
+
args: string[],
|
|
6
|
+
options: { encoding: string }
|
|
7
|
+
) => string | Promise<string>;
|
|
8
|
+
|
|
9
|
+
const defaultExec: ExecFn = (command, args, options) =>
|
|
10
|
+
new Promise<string>((resolve, reject) => {
|
|
11
|
+
execFile(command, args, { encoding: options.encoding as BufferEncoding }, (error, stdout) => {
|
|
12
|
+
if (error) reject(error);
|
|
13
|
+
else resolve(stdout as string);
|
|
14
|
+
});
|
|
15
|
+
});
|
|
2
16
|
|
|
3
17
|
/**
|
|
4
18
|
* Resolve the serve port from the server URL, with ss(8) fallback.
|
|
@@ -8,17 +22,17 @@ import { execFileSync } from "node:child_process";
|
|
|
8
22
|
* 2. ss -tlnp PID match (finds listening port by process ID)
|
|
9
23
|
* 3. null (caller decides how to handle)
|
|
10
24
|
*/
|
|
11
|
-
export function resolvePort(
|
|
25
|
+
export async function resolvePort(
|
|
12
26
|
serverUrl: URL,
|
|
13
|
-
exec:
|
|
14
|
-
): number | null {
|
|
27
|
+
exec: ExecFn = defaultExec
|
|
28
|
+
): Promise<number | null> {
|
|
15
29
|
// Try URL.port first (standard path for non-default ports like 4096, 13381)
|
|
16
30
|
const urlPort = Number.parseInt(serverUrl.port, 10);
|
|
17
31
|
if (Number.isFinite(urlPort) && urlPort > 0) return urlPort;
|
|
18
32
|
|
|
19
33
|
// Fallback: find listening port via ss(8) by PID
|
|
20
34
|
try {
|
|
21
|
-
const output = exec("ss", ["-tlnp"], { encoding: "utf-8" })
|
|
35
|
+
const output = await exec("ss", ["-tlnp"], { encoding: "utf-8" });
|
|
22
36
|
for (const line of output.split("\n")) {
|
|
23
37
|
if (!line.includes(`pid=${process.pid}`)) continue;
|
|
24
38
|
const parts = line.trim().split(/\s+/);
|
|
@@ -32,3 +46,4 @@ export function resolvePort(
|
|
|
32
46
|
|
|
33
47
|
return null;
|
|
34
48
|
}
|
|
49
|
+
// trigger publish
|