Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
195 changes: 116 additions & 79 deletions packaging.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,23 @@
import { assertEquals } from "jsr:@std/assert@^1.0.0";
import { fromFileUrl } from "jsr:@std/path@^1.0.0/from-file-url";
import { join } from "node:path";
import { pathToFileURL } from "node:url";

const workspaceRoot = new URL(".", import.meta.url);
const workspacePath = workspaceRoot.pathname;
const workspacePath = fromFileUrl(workspaceRoot);
const packagingRunPermissions = await Promise.all([
Deno.permissions.query({ name: "run", command: "deno" }),
Deno.permissions.query({ name: "run", command: "node" }),
Deno.permissions.query({
name: "run",
command: Deno.build.os === "windows" ? "where" : "which",
}),
Deno.permissions.query({ name: "run", command: "bun" }),
]);
const packagingRunPermissionGranted = packagingRunPermissions.every(
(permission, index) => index === 3 || permission.state === "granted",
);
const bunRunPermissionGranted = packagingRunPermissions[3]?.state === "granted";

const decodeText = (value: Uint8Array): string =>
new TextDecoder().decode(value);
Expand Down Expand Up @@ -40,91 +54,114 @@ const run = async (
};
};

Deno.test("built npm package loads in node through the published ESM entrypoint", async () => {
const build = await run("deno", ["task", "build"]);
assertEquals(build.code, 0, build.stderr || build.stdout);
Deno.test({
name: "built npm package loads in node through the published ESM entrypoint",
ignore: !packagingRunPermissionGranted,
fn: async () => {
const build = await run("deno", ["task", "build"]);
assertEquals(build.code, 0, build.stderr || build.stdout);

const builtPackage = JSON.parse(
await Deno.readTextFile(join(workspacePath, "dist/package.json")),
) as {
dependencies?: Record<string, string>;
devDependencies?: Record<string, string>;
};
assertEquals(
builtPackage.dependencies?.cosmiconfig,
"^9.0.0",
"generated npm package must declare cosmiconfig for runtime config loading",
);
assertEquals(
typeof builtPackage.devDependencies?.["@types/node"],
"string",
"generated npm package must declare Node typings for dnt typecheck",
);
const builtPackage = JSON.parse(
await Deno.readTextFile(join(workspacePath, "dist/package.json")),
) as {
dependencies?: Record<string, string>;
devDependencies?: Record<string, string>;
};
const builtConfig = await Deno.readTextFile(
join(workspacePath, "dist/esm/src/config.js"),
);
assertEquals(
builtPackage.dependencies?.cosmiconfig,
"^9.0.0",
"generated npm package must declare cosmiconfig for runtime config loading",
);
assertEquals(
typeof builtPackage.devDependencies?.["@types/node"],
"string",
"generated npm package must declare Node typings for dnt typecheck",
);
assertEquals(
builtConfig.includes("import-meta-ponyfill-esmodule"),
false,
"generated config loader should not depend on DNT import-meta ponyfill",
);

const tempDir = await Deno.makeTempDir();
try {
const esmRunnerPath = join(tempDir, "load-esm.mjs");
const bunRunnerPath = join(tempDir, "load-bun.mjs");
const esmEntrypoint =
pathToFileURL(join(workspacePath, "dist/esm/mod.js")).href;
const packageDir = join(tempDir, "node_modules", "opencode-graphiti");
const isolatedHome = join(tempDir, "home");
const isolatedConfig = join(isolatedHome, ".config", "opencode");
const tempDir = await Deno.makeTempDir();
try {
let optionalOpenCodePath: string | undefined;
try {
optionalOpenCodePath = Deno.env.get("OPENCODE_BIN") ?? undefined;
} catch {
optionalOpenCodePath = undefined;
}

await Deno.mkdir(join(tempDir, "node_modules"), { recursive: true });
await Deno.mkdir(isolatedConfig, { recursive: true });
await Deno.symlink(join(workspacePath, "dist"), packageDir, {
type: "dir",
});
const esmRunnerPath = join(tempDir, "load-esm.mjs");
const bunRunnerPath = join(tempDir, "load-bun.mjs");
const esmEntrypoint =
pathToFileURL(join(workspacePath, "dist/esm/mod.js")).href;
const packageDir = join(tempDir, "node_modules", "opencode-graphiti");
const isolatedHome = join(tempDir, "home");
const isolatedConfig = join(isolatedHome, ".config", "opencode");

await Deno.writeTextFile(
esmRunnerPath,
`import * as plugin from ${
JSON.stringify(esmEntrypoint)
};\nconsole.log(JSON.stringify(Object.keys(plugin).sort()));\n`,
);
await Deno.writeTextFile(
bunRunnerPath,
'import * as plugin from "opencode-graphiti";\n' +
"console.log(JSON.stringify(Object.keys(plugin).sort()));\n",
);
await Deno.mkdir(join(tempDir, "node_modules"), { recursive: true });
await Deno.mkdir(isolatedConfig, { recursive: true });
await Deno.symlink(join(workspacePath, "dist"), packageDir, {
type: "dir",
});

const esmLoad = await run("node", [esmRunnerPath]);
assertEquals(esmLoad.code, 0, esmLoad.stderr || esmLoad.stdout);
assertEquals(esmLoad.stdout.trim(), '["graphiti"]');
await Deno.writeTextFile(
esmRunnerPath,
`import * as plugin from ${
JSON.stringify(esmEntrypoint)
};\nconsole.log(JSON.stringify(Object.keys(plugin).sort()));\n`,
);
await Deno.writeTextFile(
bunRunnerPath,
'import * as plugin from "opencode-graphiti";\n' +
"console.log(JSON.stringify(Object.keys(plugin).sort()));\n",
);

if (await commandExists("bun")) {
const bunLoad = await run("bun", [bunRunnerPath], tempDir);
assertEquals(bunLoad.code, 0, bunLoad.stderr || bunLoad.stdout);
assertEquals(bunLoad.stdout.trim(), '["graphiti"]');
}
const esmLoad = await run("node", [esmRunnerPath]);
assertEquals(esmLoad.code, 0, esmLoad.stderr || esmLoad.stdout);
assertEquals(esmLoad.stdout.trim(), '["graphiti"]');

const localOpenCodePath = "/Users/vicary/.opencode/bin/opencode";
try {
const opencodeInfo = await Deno.stat(localOpenCodePath);
if (opencodeInfo.isFile) {
const isolatedOpenCode = await new Deno.Command(localOpenCodePath, {
args: ["--print-logs", "stats"],
cwd: workspacePath,
env: {
HOME: isolatedHome,
XDG_CONFIG_HOME: join(isolatedHome, ".config"),
},
stdout: "piped",
stderr: "piped",
}).output();
const isolatedOpenCodeOutput = decodeText(isolatedOpenCode.stdout) +
decodeText(isolatedOpenCode.stderr);
assertEquals(
isolatedOpenCodeOutput.includes("Missing 'default' export"),
false,
isolatedOpenCodeOutput,
);
if (bunRunPermissionGranted && await commandExists("bun")) {
const bunLoad = await run("bun", [bunRunnerPath], tempDir);
assertEquals(bunLoad.code, 0, bunLoad.stderr || bunLoad.stdout);
assertEquals(bunLoad.stdout.trim(), '["graphiti"]');
}
} catch {
// OpenCode is not available in CI; keep the portable package checks above.

if (optionalOpenCodePath) {
try {
const opencodeInfo = await Deno.stat(optionalOpenCodePath);
if (opencodeInfo.isFile) {
const isolatedOpenCode = await new Deno.Command(
optionalOpenCodePath,
{
args: ["--print-logs", "stats"],
cwd: workspacePath,
env: {
HOME: isolatedHome,
XDG_CONFIG_HOME: join(isolatedHome, ".config"),
},
stdout: "piped",
stderr: "piped",
},
).output();
const isolatedOpenCodeOutput = decodeText(isolatedOpenCode.stdout) +
decodeText(isolatedOpenCode.stderr);
assertEquals(
isolatedOpenCodeOutput.includes("Missing 'default' export"),
false,
isolatedOpenCodeOutput,
);
}
} catch {
// OPENCODE_BIN is optional; keep the portable package checks above.
}
}
} finally {
await Deno.remove(tempDir, { recursive: true }).catch(() => undefined);
}
} finally {
await Deno.remove(tempDir, { recursive: true }).catch(() => undefined);
}
},
});
5 changes: 4 additions & 1 deletion src/config.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import os from "node:os";
import { createRequire } from "node:module";
import { join } from "node:path";
import process from "node:process";
import { redactEndpointUserInfo } from "./services/endpoint-redaction.ts";
import { notifyPluginWarning } from "./services/opencode-warning.ts";
import type { GraphitiConfig, RawGraphitiConfig } from "./types/index.ts";
Expand Down Expand Up @@ -60,7 +61,9 @@ export interface ConfigExplorerAdapter {

type ConfigExplorerFactory = () => ConfigExplorerAdapter;

const nodeRequire = createRequire(import.meta.url);
const nodeRequire = createRequire(
join(process.cwd(), "graphiti.config.runtime.cjs"),
);

const isRecord = (value: unknown): value is Record<string, unknown> =>
!!value && typeof value === "object" && !Array.isArray(value);
Expand Down
71 changes: 71 additions & 0 deletions src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
warnOnRedisStartupUnavailable,
} from "./index.ts";
import { logger } from "./services/logger.ts";
import { registerRuntimeTeardown } from "./services/runtime-teardown.ts";
import {
setOpenCodeClient,
setWarningTaskScheduler,
Expand Down Expand Up @@ -1359,5 +1360,75 @@ describe("index", () => {
"redis",
]);
});

it("gracefully shuts down on first SIGINT in a node-style host runtime", async () => {
const { input, records, dependencies } = createEntrypointHarness(true);
const signalHandlers = new Map<"SIGINT" | "SIGTERM", () => void>();
const processEventHandlers = new Map<"beforeExit" | "exit", () => void>();
const exitCalls: number[] = [];
let exitReject!: (reason?: unknown) => void;
const exitPromise = new Promise<never>((_, reject) => {
exitReject = reject;
});

const runtime = {
process: {
on(event: string, handler: () => void) {
if (event === "SIGINT" || event === "SIGTERM") {
signalHandlers.set(event, handler);
return;
}
if (event === "beforeExit" || event === "exit") {
processEventHandlers.set(event, handler);
}
},
off(event: string, _handler: () => void) {
if (event === "SIGINT" || event === "SIGTERM") {
signalHandlers.delete(event);
return;
}
if (event === "beforeExit" || event === "exit") {
processEventHandlers.delete(event);
}
},
exit(code?: number) {
exitCalls.push(code ?? 0);
exitReject(new Error(`exit:${code ?? 0}`));
return undefined as never;
},
exitCode: undefined,
},
};

await invokeGraphiti(input, {
...dependencies,
registerRuntimeTeardown: (
tasks: Array<{
name: string;
run: () => void | Promise<void>;
}>,
) => registerRuntimeTeardown(tasks, runtime),
});

await assertRejects(
async () => {
signalHandlers.get("SIGINT")?.();
await exitPromise;
},
Error,
"exit:130",
);

assertEquals(records.teardownTaskRuns, [
"graphiti-drain-flush",
"graphiti-async",
"session-mcp-runtime",
"graphiti",
"redis",
]);
assertEquals(exitCalls, [130]);
assertEquals(signalHandlers.size, 0);
assertEquals(processEventHandlers.size, 0);
});
});
});
6 changes: 5 additions & 1 deletion src/services/connection-manager.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { createRequire } from "node:module";
import { join } from "node:path";
import { pathToFileURL } from "node:url";
import process from "node:process";
import manifest from "../../deno.json" with { type: "json" };
import { isAbortError } from "../utils.ts";
import { redactEndpointUserInfo } from "./endpoint-redaction.ts";
Expand All @@ -26,7 +28,9 @@ type McpRuntimeModules = {
StreamableHTTPClientTransport: McpTransportConstructor;
};

const nodeRequire = createRequire(import.meta.url);
const nodeRequire = createRequire(
pathToFileURL(join(process.cwd(), "graphiti.runtime.cjs")).href,
);
let mcpRuntimeModulesPromise: Promise<McpRuntimeModules> | null = null;

const importResolvedModule = async <T>(specifier: string): Promise<T> => {
Expand Down
23 changes: 23 additions & 0 deletions src/services/runtime-teardown.smoke-fixture.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import process from "node:process";
import { registerRuntimeTeardown } from "./runtime-teardown.ts";

const keepAlive = setInterval(() => {}, 1_000);

registerRuntimeTeardown([
{
name: "flush",
run: async () => {
await new Promise((resolve) => setTimeout(resolve, 25));
clearInterval(keepAlive);
process.stdout.write("teardown-run\n");
},
},
], {
process: {
on: process.on.bind(process),
off: process.off.bind(process),
exit: process.exit.bind(process),
},
});

process.stdout.write("ready\n");
Loading
Loading