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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ jobs:
- name: Build web app
run: bun run --filter @executor-js/local build

- name: Build sidecar + stage web UI
- name: Build bundled executor
env:
BUN_TARGET: bun-linux-x64
run: bun ./scripts/build-sidecar.ts
Expand Down
12 changes: 6 additions & 6 deletions .github/workflows/publish-desktop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ name: Publish Desktop App
run-name: "${{ format('publish desktop {0}', github.event_name == 'workflow_dispatch' && inputs.tag || github.ref_name) }}"

# Triggered manually or by publish-executor-package.yml after a CLI release
# lands. Builds Electron distributables for mac/win/linux with the Bun-compiled
# sidecar bundled in `resources/sidecar/`, then attaches them to the GitHub
# lands. Builds Electron distributables for mac/win/linux with the compiled
# executor CLI bundled in `resources/executor/`, then attaches them to the GitHub
# release matching the tag so electron-updater can pick them up.

on:
Expand Down Expand Up @@ -97,17 +97,17 @@ jobs:
- name: Build web app
run: bun run --filter @executor-js/local build

- name: Build sidecar binary + stage web UI
- name: Build bundled executor
env:
BUN_TARGET: ${{ matrix.bun-target }}
run: bun ./scripts/build-sidecar.ts
working-directory: apps/desktop

# Gate the release on the compiled binary actually booting. v1.5.0/.1
# shipped sidecars that died on launch (missing libsql native binding) —
# a regression dev mode can't catch because `bun run` resolves
# shipped local-server binaries that died on launch (missing libsql native
# binding) — a regression dev mode can't catch because `bun run` resolves
# node_modules that `bun build --compile` does not bundle.
- name: Smoke test compiled sidecar
- name: Smoke test bundled executor
if: matrix.smoke
run: bun run test:smoke
working-directory: apps/desktop
Expand Down
9 changes: 6 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,11 @@ The integration layer for AI agents. One catalog for every tool, shared across e

```bash
npm install -g executor
executor install
executor web
```

This starts a local runtime with a web UI at `http://127.0.0.1:4788`. From there, add your first source and start using tools.
This installs the local background service and opens the web UI. From there, add your first source and start using tools.

### Use as an MCP server

Expand Down Expand Up @@ -60,7 +61,7 @@ If you can represent it with a JSON schema, it can be an integration. Executor h

### Via the web UI

Open `http://127.0.0.1:4788`, go to **Add Source**, paste a URL, and Executor will detect the type, index the tools, and handle auth.
Run `executor web`, go to **Add Source**, paste a URL, and Executor will detect the type, index the tools, and handle auth.

### Via the CLI

Expand Down Expand Up @@ -119,7 +120,9 @@ executor resume --execution-id exec_123
## CLI reference

```bash
executor web # start runtime + web UI
executor install # install/start the durable background service
executor web # open the running web UI
executor web --foreground # start a temporary foreground runtime + web UI
executor daemon run # start persistent local daemon in background
executor daemon status # show daemon status
executor daemon stop # stop daemon
Expand Down
1 change: 1 addition & 0 deletions apps/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
"@executor-js/runtime-quickjs": "workspace:*",
"@executor-js/sdk": "workspace:*",
"@jitl/quickjs-wasmfile-release-sync": "catalog:",
"@sentry/bun": "^10.57.0",
"effect": "catalog:",
"quickjs-emscripten": "catalog:"
},
Expand Down
131 changes: 131 additions & 0 deletions apps/cli/src/daemon.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
canAutoStartLocalDaemonForHost,
isDevCliEntrypoint,
isExecutorServerReachable,
planServiceInstall,
} from "./daemon";

describe("isDevCliEntrypoint", () => {
Expand Down Expand Up @@ -93,3 +94,133 @@ describe("isExecutorServerReachable", () => {
}),
);
});

describe("planServiceInstall", () => {
it("no-ops when the supervised service already runs this version on the requested port", () => {
expect(
planServiceInstall({
registered: true,
running: true,
activeKind: "cli-daemon",
activeVersion: "1.5.11",
activePort: 4789,
requestedPort: 4789,
currentVersion: "1.5.11",
}),
).toBe("noop");
});

it("reinstalls when the supervised service runs an older version", () => {
expect(
planServiceInstall({
registered: true,
running: true,
activeKind: "cli-daemon",
activeVersion: "1.5.10",
activePort: 4789,
requestedPort: 4789,
currentVersion: "1.5.11",
}),
).toBe("reinstall");
});

it("reinstalls when the version matches but the requested service port changed", () => {
expect(
planServiceInstall({
registered: true,
running: true,
activeKind: "cli-daemon",
activeVersion: "1.5.11",
activePort: 4789,
requestedPort: 5790,
currentVersion: "1.5.11",
}),
).toBe("reinstall");
});

it("reinstalls when the version and port match but the service points at another executable", () => {
expect(
planServiceInstall({
registered: true,
running: true,
activeKind: "cli-daemon",
activeVersion: "1.5.11",
activeExecutablePath:
"/Applications/Executor.app/Contents/Resources/sidecar/executor-sidecar",
activePort: 4789,
requestedPort: 4789,
currentVersion: "1.5.11",
currentExecutablePath: "/Applications/Executor.app/Contents/Resources/executor/executor",
}),
).toBe("reinstall");
});

it("takes over when a detached CLI daemon owns the manifest while the service is running elsewhere", () => {
expect(
planServiceInstall({
registered: true,
running: true,
activeKind: "cli-daemon",
activePid: 2002,
servicePid: 1001,
activeVersion: "1.5.11",
activePort: 4788,
requestedPort: 55334,
currentVersion: "1.5.11",
}),
).toBe("takeover-then-install");
});

it("reinstalls when the supervised service is up but manifest details are unavailable", () => {
expect(
planServiceInstall({
registered: true,
running: true,
activeKind: null,
activeVersion: null,
activePort: null,
requestedPort: 4789,
currentVersion: "1.5.11",
}),
).toBe("reinstall");
});

it("takes over when another local server kind owns the data directory", () => {
expect(
planServiceInstall({
registered: true,
running: true,
activeKind: "foreground",
activeVersion: "1.5.11",
activePort: 4789,
requestedPort: 4789,
currentVersion: "1.5.11",
}),
).toBe("takeover-then-install");
expect(
planServiceInstall({
registered: false,
running: false,
activeKind: "desktop-sidecar",
activeVersion: "1.5.10",
activePort: 4789,
requestedPort: 4789,
currentVersion: "1.5.11",
}),
).toBe("takeover-then-install");
});

it("takes over on a fresh install path before writing the service", () => {
expect(
planServiceInstall({
registered: false,
running: false,
activeKind: null,
activeVersion: null,
activePort: null,
requestedPort: 4789,
currentVersion: "1.5.11",
}),
).toBe("takeover-then-install");
});
});
53 changes: 51 additions & 2 deletions apps/cli/src/daemon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,8 +89,8 @@ export const isExecutorServerReachable = (
// misconfigured base URL can't leak the bearer token to a third-party host.
const url = new URL("/api/health", input.baseUrl);
const response = await fetch(url, { signal: AbortSignal.timeout(2000) });
await response.body?.cancel();
return response.ok;
const body = await response.text();
return response.ok && body.trim() === "ok";
}).pipe(Effect.catchCause(() => Effect.succeed(false)));

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -282,3 +282,52 @@ export const chooseDaemonPort = (input: {
}
return fallbackPort;
});

// ---------------------------------------------------------------------------
// Service-install planning (pure)
// ---------------------------------------------------------------------------

export type ServiceInstallPlan = "noop" | "reinstall" | "takeover-then-install";

export const planServiceInstall = (input: {
readonly registered: boolean;
readonly running: boolean;
readonly activeKind: "cli-daemon" | "desktop-sidecar" | "foreground" | null;
readonly activePid?: number | null;
readonly servicePid?: number | null;
readonly activeVersion: string | null;
readonly activeExecutablePath?: string | null;
readonly activePort: number | null;
readonly requestedPort: number;
readonly currentVersion: string;
readonly currentExecutablePath?: string | null;
}): ServiceInstallPlan => {
if (input.activeKind !== null && input.activeKind !== "cli-daemon") {
return "takeover-then-install";
}

if (input.registered && input.running) {
if (
input.activeKind === "cli-daemon" &&
input.activePid !== undefined &&
input.activePid !== null &&
input.servicePid !== undefined &&
input.servicePid !== null &&
input.activePid !== input.servicePid
) {
return "takeover-then-install";
}

const executableMatches =
!input.activeExecutablePath ||
!input.currentExecutablePath ||
input.activeExecutablePath === input.currentExecutablePath;
return input.activeVersion === input.currentVersion &&
input.activePort === input.requestedPort &&
executableMatches
? "noop"
: "reinstall";
}

return "takeover-then-install";
};
Loading
Loading