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
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -100,3 +100,6 @@ LEARNINGS.md

# Pi coding agent
.pi/

# Throwaway UX prototype (not part of the app)
ux-demo/
3 changes: 1 addition & 2 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,5 @@
"typescript.tsdk": "./node_modules/typescript/lib",
"typescript.enablePromptUseWorkspaceTsdk": true,
"typescript.native-preview.tsdk": "node_modules/@typescript/native-preview",
"typescript.experimental.useTsgo": true,
"js/ts.experimental.useTsgo": true
"typescript.experimental.useTsgo": false
}
4 changes: 2 additions & 2 deletions apps/cli/src/daemon-state.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { afterEach, describe, expect, it } from "@effect/vitest";
import { BunServices } from "@effect/platform-bun";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { join, resolve } from "node:path";
import * as Effect from "effect/Effect";

import {
Expand Down Expand Up @@ -44,7 +44,7 @@ describe("daemon host and scope identity", () => {
process.chdir(workspace);
process.env.EXECUTOR_SCOPE_DIR = "executor.jsonc";

expect(currentDaemonScopeId()).toBe(`scope:${join(workspace, "executor.jsonc")}`);
expect(currentDaemonScopeId()).toBe(`scope:${resolve("executor.jsonc")}`);
} finally {
rmSync(workspace, { recursive: true, force: true });
}
Expand Down
55 changes: 54 additions & 1 deletion apps/cli/src/daemon.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { describe, expect, it } from "@effect/vitest";
import { createServer, type Server } from "node:http";
import type { AddressInfo } from "node:net";
import * as Effect from "effect/Effect";

import { canAutoStartLocalDaemonForHost } from "./daemon";
import { canAutoStartLocalDaemonForHost, isExecutorServerReachable } from "./daemon";

describe("canAutoStartLocalDaemonForHost", () => {
it("allows loopback hosts", () => {
Expand All @@ -14,3 +17,53 @@ describe("canAutoStartLocalDaemonForHost", () => {
expect(canAutoStartLocalDaemonForHost("::")).toBe(false);
});
});

describe("isExecutorServerReachable", () => {
it.effect("checks the v1.5 API surface instead of the removed scope endpoint", () =>
Effect.gen(function* () {
const server = yield* Effect.acquireRelease(
Effect.tryPromise(
() =>
new Promise<{ server: Server; port: number }>((resolve, reject) => {
const server = createServer((request, response) => {
const url = new URL(request.url ?? "/", "http://127.0.0.1");
if (url.pathname === "/api/integrations") {
response.writeHead(200, { "content-type": "application/json" });
response.end("[]");
return;
}
response.writeHead(404);
response.end();
});
const onError = (error: Error) => reject(error);
server.once("error", onError);
server.listen(0, "127.0.0.1", () => {
server.off("error", onError);
const address = server.address() as AddressInfo;
resolve({ server, port: address.port });
});
}),
),
({ server }) =>
Effect.tryPromise(
() =>
new Promise<void>((resolve) => {
server.close(() => resolve());
}),
),
);

const legacyScopeStatus = yield* Effect.tryPromise(() =>
fetch(`http://127.0.0.1:${server.port}/api/scope`),
).pipe(Effect.map((response) => response.status));

expect(legacyScopeStatus).toBe(404);

const reachable = yield* isExecutorServerReachable({
baseUrl: `http://127.0.0.1:${server.port}`,
});

expect(reachable).toBe(true);
}),
);
});
18 changes: 18 additions & 0 deletions apps/cli/src/daemon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@ export interface DaemonSpawnSpec {
readonly args: ReadonlyArray<string>;
}

export interface ExecutorServerReachabilityInput {
readonly baseUrl: string;
readonly authorization?: string;
}

type ProbeServer = ReturnType<typeof createServer> & {
removeAllListeners: () => void;
once: (event: "error" | "listening", listener: (...args: unknown[]) => void) => void;
Expand Down Expand Up @@ -60,6 +65,19 @@ export const isDevCliEntrypoint = (scriptPath: string | undefined): boolean => {
return scriptPath.endsWith(".ts") || scriptPath.endsWith(".js");
};

export const isExecutorServerReachable = (
input: ExecutorServerReachabilityInput,
): Effect.Effect<boolean> =>
Effect.tryPromise(async () => {
const url = new URL("/api/integrations", input.baseUrl);
const response = await fetch(url, {
...(input.authorization ? { headers: { authorization: input.authorization } } : {}),
signal: AbortSignal.timeout(2000),
});
await response.body?.cancel();
return response.ok;
}).pipe(Effect.catchCause(() => Effect.succeed(false)));

// ---------------------------------------------------------------------------
// Process spec
// ---------------------------------------------------------------------------
Expand Down
86 changes: 23 additions & 63 deletions apps/cli/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ import {
buildDaemonSpawnSpec,
chooseDaemonPort,
canAutoStartLocalDaemonForHost,
isExecutorServerReachable,
isDevCliEntrypoint,
parseDaemonBaseUrl,
spawnDetached,
Expand Down Expand Up @@ -157,51 +158,8 @@ const waitForShutdownSignal = () =>
// Background server management
// ---------------------------------------------------------------------------

const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value);

interface DaemonScopeInfo {
readonly id: string;
readonly name: string;
readonly dir: string;
}

const readDaemonScopeInfo = (
baseUrl: string,
authorization?: string,
): Effect.Effect<DaemonScopeInfo | null> =>
Effect.tryPromise(() =>
fetch(`${baseUrl}/api/scope`, {
...(authorization ? { headers: { authorization } } : {}),
signal: AbortSignal.timeout(2000),
}),
).pipe(
Effect.flatMap((res) => {
if (!res.ok) return Effect.succeed(null);
return Effect.tryPromise(() => res.json()).pipe(
Effect.map((payload) => {
if (!isRecord(payload)) return null;
if (
typeof payload.id === "string" &&
typeof payload.name === "string" &&
typeof payload.dir === "string"
) {
return {
id: payload.id,
name: payload.name,
dir: payload.dir,
};
}
return null;
}),
Effect.catchCause(() => Effect.succeed(null)),
);
}),
Effect.catchCause(() => Effect.succeed(null)),
);

const isServerReachable = (baseUrl: string, authorization?: string): Effect.Effect<boolean> =>
readDaemonScopeInfo(baseUrl, authorization).pipe(Effect.map((scopeInfo) => scopeInfo !== null));
isExecutorServerReachable({ baseUrl, authorization });

const readActiveLocalServerManifest = (): Effect.Effect<
ExecutorLocalServerManifest | null,
Expand Down Expand Up @@ -238,9 +196,6 @@ const normalizeDaemonScopeDir = (dir: string): string => {
return existsSync(resolved) ? realpathSync.native(resolved) : resolved;
};

const currentDaemonScopeDir = (): string =>
normalizeDaemonScopeDir(process.env.EXECUTOR_SCOPE_DIR ?? process.cwd());

const currentScopeDirForManifest = (): string | null =>
process.env.EXECUTOR_SCOPE_DIR ? normalizeDaemonScopeDir(process.env.EXECUTOR_SCOPE_DIR) : null;

Expand Down Expand Up @@ -382,6 +337,7 @@ const resolveDaemonTarget = (baseUrl: string) =>
hostname: pointer.hostname,
port: pointer.port,
scopeId,
fromPointer: true,
};
}

Expand All @@ -393,6 +349,7 @@ const resolveDaemonTarget = (baseUrl: string) =>
hostname: host,
port: parsed.port,
scopeId,
fromPointer: false,
};
});

Expand Down Expand Up @@ -484,17 +441,22 @@ const ensureDaemon = (
): Effect.Effect<string, Error, FileSystem.FileSystem | PlatformPath.Path> =>
Effect.gen(function* () {
const resolvedTarget = yield* resolveDaemonTarget(baseUrl);
const reachableScope = yield* readDaemonScopeInfo(resolvedTarget.baseUrl);
if (reachableScope && normalizeDaemonScopeDir(reachableScope.dir) === currentDaemonScopeDir()) {
if (resolvedTarget.fromPointer && (yield* isServerReachable(resolvedTarget.baseUrl))) {
return resolvedTarget.baseUrl;
}

const active = yield* readActiveLocalServerManifest();
if (
active &&
normalizeExecutorServerConnection({ origin: active.connection.origin }).origin !==
normalizeExecutorServerConnection({ origin: resolvedTarget.baseUrl }).origin
) {
const activeOrigin = active
? normalizeExecutorServerConnection({ origin: active.connection.origin }).origin
: null;
const targetOrigin = normalizeExecutorServerConnection({
origin: resolvedTarget.baseUrl,
}).origin;
if (activeOrigin === targetOrigin) {
return resolvedTarget.baseUrl;
}

if (active && activeOrigin !== targetOrigin) {
return yield* Effect.fail(
new Error(
[
Expand Down Expand Up @@ -1507,9 +1469,8 @@ const runCallHelp = (
serverName: args.serverName,
});
const client = yield* makeApiClient(connection);
const scopeInfo = yield* client.scope.info();
const tools = yield* client.tools.list({ params: { scopeId: scopeInfo.id } });
const toolPaths = tools.map((tool) => tool.id);
const tools = yield* client.tools.list({ query: {} });
const toolPaths = tools.map((tool) => tool.address);

const inspection = yield* Effect.try({
try: () =>
Expand Down Expand Up @@ -1575,15 +1536,14 @@ const runCallHelp = (
}

const exactTool = inspection.exactPath
? tools.find((tool) => tool.id === inspection.exactPath)
? tools.find((tool) => tool.address === inspection.exactPath)
: undefined;

if (exactTool && inspection.children.length === 0) {
const schema = yield* client.tools
.schema({
params: {
scopeId: scopeInfo.id,
toolId: exactTool.id,
query: {
address: exactTool.address,
},
})
.pipe(
Expand All @@ -1596,7 +1556,7 @@ const runCallHelp = (

yield* printCallLeafHelp({
tool: {
id: exactTool.id,
id: exactTool.address,
description: exactTool.description,
},
schema,
Expand All @@ -1618,7 +1578,7 @@ const runCallHelp = (
limit: args.limit,
exactTool: exactTool
? {
id: exactTool.id,
id: exactTool.address,
description: exactTool.description,
}
: undefined,
Expand Down
2 changes: 1 addition & 1 deletion apps/cloud/drizzle.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ const withSslMode = (url: string): string => {
};

export default defineConfig({
schema: ["./src/services/schema.ts", "./src/services/executor-schema.ts"],
schema: ["./src/db/schema.ts", "./src/db/executor-schema.ts"],
out: "./drizzle",
dialect: "postgresql",
dbCredentials: {
Expand Down
Loading
Loading