diff --git a/packages/core/vitest.config.ts b/packages/core/vitest.config.ts index 9654a19dc..ec85fd4ef 100644 --- a/packages/core/vitest.config.ts +++ b/packages/core/vitest.config.ts @@ -6,6 +6,17 @@ import { defineConfig } from "vitest/config"; const __dirname = dirname(fileURLToPath(import.meta.url)); export default defineConfig({ + resolve: { + alias: [ + { find: /^@aoagents\/ao-core$/, replacement: resolve(__dirname, "src/index.ts") }, + { + find: /^@aoagents\/ao-core\/scm-webhook-utils$/, + replacement: resolve(__dirname, "src/scm-webhook-utils.ts"), + }, + { find: /^@aoagents\/ao-core\/types$/, replacement: resolve(__dirname, "src/types.ts") }, + { find: /^@aoagents\/ao-core\/utils$/, replacement: resolve(__dirname, "src/utils.ts") }, + ], + }, plugins: [ { name: "raw-markdown", diff --git a/packages/utils/package.json b/packages/utils/package.json new file mode 100644 index 000000000..a6ab5efbb --- /dev/null +++ b/packages/utils/package.json @@ -0,0 +1,28 @@ +{ + "name": "@composio/ao-utils", + "version": "0.1.0", + "description": "Shared utility functions for agent-orchestrator", + "license": "MIT", + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsc -p tsconfig.build.json", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "test:watch": "vitest" + }, + "devDependencies": { + "typescript": "^5.7.0", + "vitest": "^4.0.18" + } +} diff --git a/packages/utils/src/__tests__/nary-lca.test.ts b/packages/utils/src/__tests__/nary-lca.test.ts new file mode 100644 index 000000000..52c8112cb --- /dev/null +++ b/packages/utils/src/__tests__/nary-lca.test.ts @@ -0,0 +1,76 @@ +import { describe, it, expect } from "vitest"; +import { findLCA, type NaryNode } from "../nary-lca.js"; + +// Helper to build a node quickly. +function node(val: number, ...children: NaryNode[]): NaryNode { + return { val, children }; +} + +// Tree used in most tests: +// +// 1 +// / | \ +// 2 3 4 +// / \ +// 5 6 +// +const tree = node(1, node(2, node(5), node(6)), node(3), node(4)); + +describe("findLCA", () => { + it("returns the common ancestor of two leaf nodes", () => { + // 5 and 6 are both children of 2 + const result = findLCA(tree, 5, 6); + expect(result?.val).toBe(2); + }); + + it("returns the ancestor when one node is an ancestor of the other", () => { + // 2 is a direct ancestor of 5 + const result = findLCA(tree, 2, 5); + expect(result?.val).toBe(2); + }); + + it("returns the root when targets are in different subtrees far from root", () => { + // 5 is under 2, 3 is a direct child of root 1 + const result = findLCA(tree, 5, 3); + expect(result?.val).toBe(1); + }); + + it("returns the root when both nodes are direct children of root", () => { + const result = findLCA(tree, 2, 4); + expect(result?.val).toBe(1); + }); + + it("returns the node itself when val1 === val2", () => { + const result = findLCA(tree, 6, 6); + expect(result?.val).toBe(6); + }); + + it("returns null when val1 is not in the tree", () => { + const result = findLCA(tree, 99, 3); + expect(result).toBeNull(); + }); + + it("returns null when val2 is not in the tree", () => { + const result = findLCA(tree, 2, 99); + expect(result).toBeNull(); + }); + + it("returns null when both values are absent", () => { + const result = findLCA(tree, 88, 99); + expect(result).toBeNull(); + }); + + it("returns null for a null root", () => { + expect(findLCA(null, 1, 2)).toBeNull(); + }); + + it("returns the single node when it matches val1 in a single-node tree", () => { + const single = node(42); + expect(findLCA(single, 42, 42)?.val).toBe(42); + }); + + it("returns null when single-node tree does not contain either value", () => { + const single = node(42); + expect(findLCA(single, 1, 2)).toBeNull(); + }); +}); diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts new file mode 100644 index 000000000..5d3aa0034 --- /dev/null +++ b/packages/utils/src/index.ts @@ -0,0 +1,2 @@ +export { findLCA } from "./nary-lca.js"; +export type { NaryNode } from "./nary-lca.js"; diff --git a/packages/utils/src/nary-lca.ts b/packages/utils/src/nary-lca.ts new file mode 100644 index 000000000..ad3ed7b24 --- /dev/null +++ b/packages/utils/src/nary-lca.ts @@ -0,0 +1,60 @@ +export interface NaryNode { + val: number; + children: NaryNode[]; +} + +/** + * Finds the Lowest Common Ancestor (LCA) of two nodes in an n-ary tree. + * + * Returns null if either target value is absent from the tree. + * A node is considered its own ancestor (if one target is an ancestor of the + * other, that ancestor node is returned). + * + * Uses post-order DFS: children are visited before the current node is + * evaluated, so a deeper match surfaces before its ancestor is inspected. + */ +export function findLCA( + root: NaryNode | null, + val1: number, + val2: number, +): NaryNode | null { + let found1 = false; + let found2 = false; + + function dfs(node: NaryNode | null): NaryNode | null { + if (node === null) return null; + + // Post-order: recurse into all children first so that a target node's + // subtree is fully searched before we process the target node itself. + const hits: NaryNode[] = []; + for (const child of node.children) { + const result = dfs(child); + if (result !== null) hits.push(result); + } + + const isTarget = node.val === val1 || node.val === val2; + if (node.val === val1) found1 = true; + if (node.val === val2) found2 = true; + + if (isTarget) { + // This node is one of the targets. Return it regardless of what surfaced + // from children — if the other target was in the subtree, this node is + // the LCA; if not, this node is just the "found" signal propagating up. + return node; + } + + if (hits.length >= 2) { + // Two distinct targets surfaced from different children — this node is + // the LCA. + return node; + } + + return hits[0] ?? null; + } + + const candidate = dfs(root); + + if (!found1 || !found2) return null; + + return candidate; +} diff --git a/packages/utils/tsconfig.build.json b/packages/utils/tsconfig.build.json new file mode 100644 index 000000000..4dc23fb9c --- /dev/null +++ b/packages/utils/tsconfig.build.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "exclude": ["src/__tests__"] +} diff --git a/packages/utils/tsconfig.json b/packages/utils/tsconfig.json new file mode 100644 index 000000000..5a24989cd --- /dev/null +++ b/packages/utils/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"] +} diff --git a/packages/utils/vitest.config.ts b/packages/utils/vitest.config.ts new file mode 100644 index 000000000..77a73cf2e --- /dev/null +++ b/packages/utils/vitest.config.ts @@ -0,0 +1,5 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: {}, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bffb42919..7f0ee9a16 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -638,6 +638,15 @@ importers: specifier: ^3.0.0 version: 3.2.4(@types/node@25.6.0)(jiti@2.6.1)(jsdom@25.0.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3) + packages/utils: + devDependencies: + typescript: + specifier: ^5.7.0 + version: 5.9.3 + vitest: + specifier: ^4.0.18 + version: 4.1.4(@opentelemetry/api@1.9.0)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.4)(jsdom@25.0.1)(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3)) + packages/web: dependencies: '@aoagents/ao-core':