From bf02cdc9e849552b90d8e8465f1e96b137ecd761 Mon Sep 17 00:00:00 2001 From: harshitsinghbhandari <24b4506@iitb.ac.in> Date: Fri, 22 May 2026 12:43:38 +0530 Subject: [PATCH 1/2] fix(tracker-linear): fall back to direct transport when @composio/core is missing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A bare COMPOSIO_API_KEY (often exported globally for unrelated Composio work) hijacked transport selection into the Composio SDK path, which hard-failed because @composio/core is an optional dependency not installed with the plugin — breaking the tracker even when a valid LINEAR_API_KEY was present. The Composio transport now throws a typed ComposioSdkMissingError; when a LINEAR_API_KEY is available the tracker transparently falls back to the direct transport. The dep_missing event fires only when no fallback exists. Also isolate COMPOSIO_API_KEY in the direct-transport test suite so it is deterministic regardless of the developer's shell. Co-Authored-By: Claude Opus 4.7 --- .../fix-tracker-linear-composio-fallback.md | 9 ++ packages/plugins/tracker-linear/src/index.ts | 79 +++++++--- .../test/composio-fallback.test.ts | 146 ++++++++++++++++++ .../plugins/tracker-linear/test/index.test.ts | 20 ++- 4 files changed, 228 insertions(+), 26 deletions(-) create mode 100644 .changeset/fix-tracker-linear-composio-fallback.md create mode 100644 packages/plugins/tracker-linear/test/composio-fallback.test.ts diff --git a/.changeset/fix-tracker-linear-composio-fallback.md b/.changeset/fix-tracker-linear-composio-fallback.md new file mode 100644 index 0000000000..3c73d3f30a --- /dev/null +++ b/.changeset/fix-tracker-linear-composio-fallback.md @@ -0,0 +1,9 @@ +--- +"@aoagents/ao-plugin-tracker-linear": patch +--- + +fix(tracker-linear): fall back to the direct Linear transport when @composio/core is missing + +The Linear tracker selects its transport by sniffing env: if `COMPOSIO_API_KEY` is set it routes through the Composio SDK, otherwise it uses the direct `LINEAR_API_KEY` API. But `@composio/core` is an optional dependency that isn't installed with the plugin, so any user who had `COMPOSIO_API_KEY` exported (commonly set globally for unrelated Composio work) got a hard `"Composio SDK (@composio/core) is not installed"` failure on every tracker call — even when a perfectly valid `LINEAR_API_KEY` was available. + +The Composio transport now throws a typed `ComposioSdkMissingError` when the SDK can't be loaded. When that happens and a `LINEAR_API_KEY` is present, the tracker transparently falls back to the direct transport instead of failing. The `tracker.dep_missing` event is emitted only when there is genuinely no fallback (no `LINEAR_API_KEY`), so a successful fallback no longer raises a false error-level event. diff --git a/packages/plugins/tracker-linear/src/index.ts b/packages/plugins/tracker-linear/src/index.ts index 850431e309..e443fe91ac 100644 --- a/packages/plugins/tracker-linear/src/index.ts +++ b/packages/plugins/tracker-linear/src/index.ts @@ -38,6 +38,35 @@ function recordTransportActivityEvent(event: Parameters(query: string, variables?: Record): Promise => { + try { + return await active(query, variables); + } catch (err) { + if (err instanceof ComposioSdkMissingError) { + if (process.env["LINEAR_API_KEY"]) { + active = createDirectTransport(); + return await active(query, variables); + } + emitDepMissingOnce(); + } + throw err; + } + }; +} + export function create(): Tracker { const composioKey = process.env["COMPOSIO_API_KEY"]; if (composioKey) { const entityId = process.env["COMPOSIO_ENTITY_ID"] ?? "default"; - return createLinearTracker(createComposioTransport(composioKey, entityId)); + return createLinearTracker( + withComposioFallback(createComposioTransport(composioKey, entityId)), + ); } return createLinearTracker(createDirectTransport()); } diff --git a/packages/plugins/tracker-linear/test/composio-fallback.test.ts b/packages/plugins/tracker-linear/test/composio-fallback.test.ts new file mode 100644 index 0000000000..51ff3e0570 --- /dev/null +++ b/packages/plugins/tracker-linear/test/composio-fallback.test.ts @@ -0,0 +1,146 @@ +/** + * Regression tests for Composio→direct transport fallback. + * + * When COMPOSIO_API_KEY is present but @composio/core cannot be loaded, the + * tracker must fall back to the direct LINEAR_API_KEY transport instead of + * hard-failing — provided a LINEAR_API_KEY is available. A bare COMPOSIO_API_KEY + * (commonly exported globally for unrelated Composio work) must not break an + * otherwise-valid Linear setup. + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { EventEmitter } from "node:events"; + +const { requestMock, recordActivityEventMock } = vi.hoisted(() => ({ + requestMock: vi.fn(), + recordActivityEventMock: vi.fn(), +})); + +vi.mock("node:https", () => ({ + request: requestMock, +})); + +vi.mock("@aoagents/ao-core", async () => { + const actual = (await vi.importActual("@aoagents/ao-core")) as Record; + return { + ...actual, + recordActivityEvent: recordActivityEventMock, + }; +}); + +// @composio/core is intentionally not installed — the real dynamic import +// fails with ERR_MODULE_NOT_FOUND, exercising the fallback path. + +import { create, _resetDepMissingEmittedForTesting } from "../src/index.js"; +import type { ProjectConfig } from "@aoagents/ao-core"; + +const project: ProjectConfig = { + name: "test", + repo: "acme/integrator", + path: "/tmp/repo", + defaultBranch: "main", + sessionPrefix: "test", + tracker: { plugin: "linear", teamId: "team-uuid-1", workspaceSlug: "acme" }, +}; + +const sampleIssueNode = { + id: "uuid-123", + identifier: "INT-123", + title: "Fix login bug", + description: "Users can't log in with SSO", + url: "https://linear.app/acme/issue/INT-123", + priority: 2, + branchName: "feat/INT-123", + state: { name: "In Progress", type: "started" }, + labels: { nodes: [{ name: "bug" }] }, + assignee: { name: "Alice Smith", displayName: "Alice" }, + team: { key: "INT" }, +}; + +/** Queue a successful Linear API response for the direct transport. */ +function mockLinearAPI(responseData: unknown, statusCode = 200) { + const body = JSON.stringify({ data: responseData }); + requestMock.mockImplementationOnce( + ( + _opts: Record, + callback: (res: EventEmitter & { statusCode: number }) => void, + ) => { + const req = Object.assign(new EventEmitter(), { + write: vi.fn(), + end: vi.fn(() => { + const res = Object.assign(new EventEmitter(), { statusCode }); + callback(res); + process.nextTick(() => { + res.emit("data", Buffer.from(body)); + res.emit("end"); + }); + }), + destroy: vi.fn(), + setTimeout: vi.fn(), + }); + return req; + }, + ); +} + +let savedComposioKey: string | undefined; +let savedComposioEntity: string | undefined; +let savedLinearKey: string | undefined; + +beforeEach(() => { + vi.clearAllMocks(); + requestMock.mockReset(); + recordActivityEventMock.mockReset(); + _resetDepMissingEmittedForTesting(); + savedComposioKey = process.env["COMPOSIO_API_KEY"]; + savedComposioEntity = process.env["COMPOSIO_ENTITY_ID"]; + savedLinearKey = process.env["LINEAR_API_KEY"]; +}); + +afterEach(() => { + if (savedComposioKey === undefined) delete process.env["COMPOSIO_API_KEY"]; + else process.env["COMPOSIO_API_KEY"] = savedComposioKey; + if (savedComposioEntity === undefined) delete process.env["COMPOSIO_ENTITY_ID"]; + else process.env["COMPOSIO_ENTITY_ID"] = savedComposioEntity; + if (savedLinearKey === undefined) delete process.env["LINEAR_API_KEY"]; + else process.env["LINEAR_API_KEY"] = savedLinearKey; +}); + +describe("Composio→direct transport fallback", () => { + it("falls back to the direct transport when @composio/core is missing but LINEAR_API_KEY is set", async () => { + process.env["COMPOSIO_API_KEY"] = "composio-key"; + process.env["LINEAR_API_KEY"] = "lin_api_test_key"; + mockLinearAPI({ issue: sampleIssueNode }); + + const tracker = create(); + const issue = await tracker.getIssue("INT-123", project); + + expect(issue.id).toBe("INT-123"); + expect(issue.title).toBe("Fix login bug"); + expect(requestMock).toHaveBeenCalled(); + }); + + it("does not emit tracker.dep_missing when fallback succeeds", async () => { + process.env["COMPOSIO_API_KEY"] = "composio-key"; + process.env["LINEAR_API_KEY"] = "lin_api_test_key"; + mockLinearAPI({ issue: sampleIssueNode }); + + const tracker = create(); + await tracker.getIssue("INT-123", project); + + const depMissingCalls = recordActivityEventMock.mock.calls.filter( + ([event]) => event?.kind === "tracker.dep_missing", + ); + expect(depMissingCalls).toHaveLength(0); + }); + + it("still throws when @composio/core is missing and no LINEAR_API_KEY is available", async () => { + process.env["COMPOSIO_API_KEY"] = "composio-key"; + delete process.env["LINEAR_API_KEY"]; + + const tracker = create(); + await expect(tracker.getIssue("INT-123", project)).rejects.toThrow( + /Composio SDK.*not installed/, + ); + }); +}); diff --git a/packages/plugins/tracker-linear/test/index.test.ts b/packages/plugins/tracker-linear/test/index.test.ts index d31f562ac1..b5075112fe 100644 --- a/packages/plugins/tracker-linear/test/index.test.ts +++ b/packages/plugins/tracker-linear/test/index.test.ts @@ -177,20 +177,30 @@ function mockRequestTimeout() { describe("tracker-linear plugin", () => { let tracker: ReturnType; let savedApiKey: string | undefined; + let savedComposioKey: string | undefined; + let savedComposioEntity: string | undefined; beforeEach(() => { vi.clearAllMocks(); savedApiKey = process.env["LINEAR_API_KEY"]; + savedComposioKey = process.env["COMPOSIO_API_KEY"]; + savedComposioEntity = process.env["COMPOSIO_ENTITY_ID"]; + // This suite exercises the direct LINEAR_API_KEY transport. Clear any + // ambient COMPOSIO_API_KEY (commonly exported for unrelated Composio work) + // so transport selection is deterministic regardless of the shell. process.env["LINEAR_API_KEY"] = "lin_api_test_key"; + delete process.env["COMPOSIO_API_KEY"]; + delete process.env["COMPOSIO_ENTITY_ID"]; tracker = create(); }); afterEach(() => { - if (savedApiKey === undefined) { - delete process.env["LINEAR_API_KEY"]; - } else { - process.env["LINEAR_API_KEY"] = savedApiKey; - } + if (savedApiKey === undefined) delete process.env["LINEAR_API_KEY"]; + else process.env["LINEAR_API_KEY"] = savedApiKey; + if (savedComposioKey === undefined) delete process.env["COMPOSIO_API_KEY"]; + else process.env["COMPOSIO_API_KEY"] = savedComposioKey; + if (savedComposioEntity === undefined) delete process.env["COMPOSIO_ENTITY_ID"]; + else process.env["COMPOSIO_ENTITY_ID"] = savedComposioEntity; }); // ---- manifest ---------------------------------------------------------- From 3aed2569cdb1e7280800be1828b1937928d04b22 Mon Sep 17 00:00:00 2001 From: harshitsinghbhandari <24b4506@iitb.ac.in> Date: Fri, 22 May 2026 16:39:47 +0530 Subject: [PATCH 2/2] =?UTF-8?q?fix(tracker-linear):=20address=20review=20?= =?UTF-8?q?=E2=80=94=20guard=20dep=5Fmissing=20logging,=20memoize=20fallba?= =?UTF-8?q?ck=20transport?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - emitDepMissingOnce now routes through recordTransportActivityEvent so a throwing activity sink can't replace the ComposioSdkMissingError that callers (and tests) pattern-match on. - Memoize the direct transport in withComposioFallback so concurrent first-calls reuse one instance instead of creating redundant ones. - Add regression test: SDK-missing error still surfaces when logging throws. Co-Authored-By: Claude Opus 4.7 --- packages/plugins/tracker-linear/src/index.ts | 6 ++++-- .../tracker-linear/test/composio-fallback.test.ts | 13 +++++++++++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/packages/plugins/tracker-linear/src/index.ts b/packages/plugins/tracker-linear/src/index.ts index e443fe91ac..f3f89df50c 100644 --- a/packages/plugins/tracker-linear/src/index.ts +++ b/packages/plugins/tracker-linear/src/index.ts @@ -54,7 +54,7 @@ class ComposioSdkMissingError extends Error { function emitDepMissingOnce(): void { if (depMissingEmitted) return; depMissingEmitted = true; - recordActivityEvent({ + recordTransportActivityEvent({ source: "tracker", kind: "tracker.dep_missing", level: "error", @@ -842,13 +842,15 @@ export const manifest = { */ function withComposioFallback(composio: GraphQLTransport): GraphQLTransport { let active = composio; + let direct: GraphQLTransport | undefined; return async (query: string, variables?: Record): Promise => { try { return await active(query, variables); } catch (err) { if (err instanceof ComposioSdkMissingError) { if (process.env["LINEAR_API_KEY"]) { - active = createDirectTransport(); + direct ??= createDirectTransport(); + active = direct; return await active(query, variables); } emitDepMissingOnce(); diff --git a/packages/plugins/tracker-linear/test/composio-fallback.test.ts b/packages/plugins/tracker-linear/test/composio-fallback.test.ts index 51ff3e0570..b1fbd4f10d 100644 --- a/packages/plugins/tracker-linear/test/composio-fallback.test.ts +++ b/packages/plugins/tracker-linear/test/composio-fallback.test.ts @@ -143,4 +143,17 @@ describe("Composio→direct transport fallback", () => { /Composio SDK.*not installed/, ); }); + + it("surfaces the SDK-missing error even when activity logging throws", async () => { + process.env["COMPOSIO_API_KEY"] = "composio-key"; + delete process.env["LINEAR_API_KEY"]; + recordActivityEventMock.mockImplementation(() => { + throw new Error("activity sink failed"); + }); + + const tracker = create(); + await expect(tracker.getIssue("INT-123", project)).rejects.toThrow( + /Composio SDK.*not installed/, + ); + }); });