diff --git a/.changeset/core-tools-no-auth-connections.md b/.changeset/core-tools-no-auth-connections.md new file mode 100644 index 0000000000..cad580387d --- /dev/null +++ b/.changeset/core-tools-no-auth-connections.md @@ -0,0 +1,9 @@ +--- +"executor": patch +--- + +`connections.create` now accepts no-auth connections (the `none` template with +no credential), which previously failed validation with "Expected exactly one +provider credential origin". Agents can wire up public, no-auth integrations +(public MCP servers, public REST APIs) programmatically instead of bouncing +through the web UI. Templates that take a credential still require exactly one. diff --git a/.changeset/execution-history-content-fit-table.md b/.changeset/execution-history-content-fit-table.md new file mode 100644 index 0000000000..7ff22521ae --- /dev/null +++ b/.changeset/execution-history-content-fit-table.md @@ -0,0 +1,13 @@ +--- +"@executor-js/plugin-execution-history": patch +--- + +Rebuild the runs list as a real semantic `` with content-fit columns. +Each column now sizes to the maximum width its rows need (browser +`table-layout: auto`), instead of fixed pixel widths — the sticky `` and +the rows share one column model so they stay aligned for free. Short / numeric / +badge columns (status, duration, tools, interaction, log) are centered; text +columns (started, trigger, actor, code) are left-aligned. The two columns that +can run long — `actor` and `code` — are width-capped and truncated (hover shows +the full value). Removes the fixed-width `COL_*` slot constants and the +`min-w` horizontal-scroll floor. diff --git a/.changeset/execution-history-plugin.md b/.changeset/execution-history-plugin.md new file mode 100644 index 0000000000..50308039ad --- /dev/null +++ b/.changeset/execution-history-plugin.md @@ -0,0 +1,12 @@ +--- +"@executor-js/plugin-execution-history": minor +--- + +Add the `@executor-js/plugin-execution-history` plugin — an opt-in execution +history sink built on the ExecutionObserver foundation. It records every run +(plus its tool calls and interactions) into three owner-scoped pluginStorage +collections via a buffered-batch writer (no new tables, no migrations; works on +D1/libSQL/sqlite/postgres), exposes a `list`/`get`/`listToolCalls` read API over +HttpApi (`./api`'s `executionHistoryHttpPlugin`), and ships a Runs page UI +(`./client`) that mounts at `/plugins/executionHistory/`. The base `.` entry is +SDK-only and never loads `@executor-js/api` or React. diff --git a/.changeset/execution-history-r2-detail.md b/.changeset/execution-history-r2-detail.md new file mode 100644 index 0000000000..9fbbad5b43 --- /dev/null +++ b/.changeset/execution-history-r2-detail.md @@ -0,0 +1,18 @@ +--- +"@executor-js/plugin-execution-history": minor +--- + +Move per-run detail to an append-only R2 object and slim the D1 index. The +`runs` row now keeps only list/aggregate fields plus a bounded `codePreview` and +denormalized `logErrorCount`/`logWarnCount`, so the runs list renders entirely +from D1 with no per-row blob fetch. Full `code`, `resultJson`, `errorText`, +`logsJson`, `triggerMetaJson`, and the per-tool-call / per-interaction rows now +live in one immutable object per run in the plugin blob store (keyed +`run-detail/`) — a code-only stub on `ExecutionStarted`, the full +detail on finish. The detail drawer reads it in a single request (`get` +flat-merges the R2 detail onto the slim run); the separate tool-calls endpoint +and `runToolCallsAtom` are removed, and the `toolCalls`/`interactions` +collections are gone. + +Breaking: the runs list/detail response shapes change and past runs are cleared +(no backfill, no compatibility shim — the cutover wipe is authoritative). diff --git a/.changeset/execution-history-rich-list.md b/.changeset/execution-history-rich-list.md new file mode 100644 index 0000000000..8c0a9314ef --- /dev/null +++ b/.changeset/execution-history-rich-list.md @@ -0,0 +1,15 @@ +--- +"@executor-js/plugin-execution-history": minor +--- + +Rework the runs list into a keyset-paginated, aggregate-aware read surface +backed by the storage JSON pushdown. The `list` endpoint now takes an opaque +keyset `cursor` (instead of offset), a sort field (`startedAt`/`durationMs`) +with direction, and a live-tail `after` floor, and returns `{ runs, nextCursor, +meta }`. The `meta` block — computed once per filter set on the first page — +carries status/trigger facet counts (each ignoring its own filter), interaction +counts, a stacked-by-status timeline with a server-chosen bucket width, total +and filtered row counts, and duration min/max plus P50–P99 percentiles. All +counts, buckets, percentiles, and pages are pushed to SQL rather than reducing +the whole collection in memory. Tool-path facets and full-text code search are +intentionally not included yet. diff --git a/.changeset/execution-history-runs-ui.md b/.changeset/execution-history-runs-ui.md new file mode 100644 index 0000000000..8669efc247 --- /dev/null +++ b/.changeset/execution-history-runs-ui.md @@ -0,0 +1,13 @@ +--- +"@executor-js/plugin-execution-history": minor +--- + +Rebuild the runs page as an openstatus-style observability table on the keyset + +aggregate API. The page now has a faceted filter rail (status / trigger / +interaction / time range with live counts), a cmdk filter command palette, a +stacked-by-status timeline chart with drag-to-zoom, live tailing, keyset +infinite scroll, sortable columns, column visibility, keyboard shortcuts, and +the existing 4-tab detail drawer. List data flows through an Effect-atoms hook +(`useRunsList`) that accumulates cursor pages and polls the head for live mode; +the accumulation logic is a pure, unit-tested reducer. Adds `recharts` as an +optional peer dependency (provided by `@executor-js/react`). diff --git a/.changeset/execution-metrics-plugin.md b/.changeset/execution-metrics-plugin.md new file mode 100644 index 0000000000..9728adf762 --- /dev/null +++ b/.changeset/execution-metrics-plugin.md @@ -0,0 +1,13 @@ +--- +"@executor-js/plugin-execution-metrics": minor +--- + +Add the `@executor-js/plugin-execution-metrics` plugin — an opt-in metrics sink +built on the ExecutionObserver foundation. The base plugin drives Effect Metric +counters and an execution-duration histogram from the event stream; `./api`'s +`executionMetricsHttpPlugin` exposes them as an auth-gated `GET +/execution-metrics/metrics` Prometheus scrape for long-lived hosts; and +`./cloudflare`'s `createWaeMetricsObserver` writes Workers Analytics Engine data +points on the Cloudflare host (per-isolate Effect metrics being meaningless on a +Worker fleet). The base `.` entry never loads `@executor-js/api` or +`@cloudflare/workers-types`. diff --git a/.changeset/execution-observer-foundation.md b/.changeset/execution-observer-foundation.md new file mode 100644 index 0000000000..4d117c72b5 --- /dev/null +++ b/.changeset/execution-observer-foundation.md @@ -0,0 +1,14 @@ +--- +"@executor-js/sdk": minor +"@executor-js/execution": minor +"@executor-js/api": minor +--- + +Add the execution-observer foundation. The execution engine now emits a typed +lifecycle stream (`ExecutionStarted`/`Finished`, `ToolCallStarted`/`Finished`, +`InteractionStarted`/`Resolved`), plugins can subscribe via the new +`plugin.runtime.executionObserver` hook, and `makeExecutionStack` composes every +registered plugin's observer onto the engine. Behaviour is unchanged when no +plugin observes — this is the opt-in seam the execution-history and +execution-metrics plugins build on. Also exposes `Executor.owner` and enriches +the `mcp.execute` span with the run id and trigger. diff --git a/.changeset/fumadb-json-pushdown.md b/.changeset/fumadb-json-pushdown.md new file mode 100644 index 0000000000..2f728d4919 --- /dev/null +++ b/.changeset/fumadb-json-pushdown.md @@ -0,0 +1,16 @@ +--- +"@executor-js/fumadb": minor +"@executor-js/sdk": minor +--- + +Push JSON-document aggregation and keyset pagination down to SQL. FumaDB's query +layer gains `jsonCount`, `jsonGroupCount`, `jsonTimeBuckets`, `jsonStats` +(min/max + continuous percentiles), and `jsonPage` (cursor pagination) over a +JSON document column, implemented natively for the memory and Drizzle +(SQLite/Postgres) adapters and failing loudly on adapters that don't support +them. Plugin storage exposes this as `collection.aggregate.{count,groupCount, +timeBuckets,stats}` and `collection.queryKeyset(...)`, translating a +collection's indexed-field `where` into JSON-path predicates while owner/tenant +scoping stays enforced by the storage policy. Aggregates and pages are computed +in the database instead of by fetching the whole collection and reducing in +memory. diff --git a/.changeset/openapi-file-emit-descriptions.md b/.changeset/openapi-file-emit-descriptions.md new file mode 100644 index 0000000000..09b91a4fe7 --- /dev/null +++ b/.changeset/openapi-file-emit-descriptions.md @@ -0,0 +1,8 @@ +--- +"executor": patch +--- + +OpenAPI tools that return a file now spell out how to emit it directly in the +tool's description, so an agent sees the `emit(result.data)` contract before its +first call instead of only discovering it after a failed attempt or by reading +`describe.tool`. Non-file tools are unchanged. diff --git a/.changeset/semantic-search-manifest-snapshot.md b/.changeset/semantic-search-manifest-snapshot.md new file mode 100644 index 0000000000..6bbf6cb21a --- /dev/null +++ b/.changeset/semantic-search-manifest-snapshot.md @@ -0,0 +1,10 @@ +--- +"@executor-js/sdk": minor +"@executor-js/plugin-semantic-search": minor +--- + +Snapshot the tool manifest per reindex run instead of re-reading it from D1 on every scan page. + +`createExecutor` now exposes its cache `KeyValueStore` on the executor instance as `executor.cache`, so plugins can reach durable cache storage (Cloudflare KV in production) without re-threading a binding — wrap it with `KeyValueStore.toSchemaStore` for typed entries, the same way the internal `tools.list` / schema-view caches do. + +The semantic-search scan phase used to call `tools.manifest()` — a full `tool_schema_manifest` table read — once per partition page, so an unchanged catalog cost `O(N² / pageLimit)` cross-region reads. Now `create` reads the manifest once, partitions it, and writes one snapshot per partition to `executor.cache`; `scan` reads only its partition's snapshot (KV-only, with no D1 fallback on the hot path — a miss fails the message so the queue retries). Snapshots are deleted when the run completes. This collapses the scan's manifest reads from `O(N² / pageLimit)` to a single pass and cuts per-message memory from the whole manifest to one partition's slice. diff --git a/.changeset/semantic-search-tenant-namespace-prefix.md b/.changeset/semantic-search-tenant-namespace-prefix.md new file mode 100644 index 0000000000..c9f4253013 --- /dev/null +++ b/.changeset/semantic-search-tenant-namespace-prefix.md @@ -0,0 +1,14 @@ +--- +"@executor-js/plugin-semantic-search": patch +--- + +fix: don't default the operator search prefix filter to the tenant namespace + +`executor.semanticSearch.search` (the operator `tools.search` surface) overloaded a +single `namespace` value as both the Vectorize tenant namespace (storage isolation) +and the optional integration/path-prefix filter. With no operator-supplied prefix it +defaulted the filter to the tenant namespace (e.g. `"default"`), so the provider's +`matchesNamespace` required every tool path to start with the tenant id and dropped +every result — search returned nothing for all queries despite a fully populated +index. The integration prefix is now applied only when explicitly provided; the +tenant namespace drives the store query alone and is still reported on the result page. diff --git a/.changeset/tools-list-revision-cache.md b/.changeset/tools-list-revision-cache.md new file mode 100644 index 0000000000..03663dd4b0 --- /dev/null +++ b/.changeset/tools-list-revision-cache.md @@ -0,0 +1,14 @@ +--- +"@executor-js/sdk": minor +--- + +Cache `tools.list` by a content-derived catalog revision. `produceConnectionTools` now +records a per-connection `toolset_revision` — a hash of the connection's manifest +`(path, index_fingerprint)` pairs — in `plugin_storage`, and `tools.list` keys a KV cache of +the DB-derived, policy-filtered list on the combined catalog revision plus a policy +revision (both read live, O(connections)/O(policies), not the full `tool` scan). Static tools +are unioned live and the `query` substring filter is applied post-cache, so a distinct query +reuses the same cached set. The cache stays warm through token rotation and unrelated writes +and invalidates exactly when a connection's tool set, a tool's schema, or a `tool_policy` +changes — including OpenAPI/MCP tool additions on refresh (a new manifest row) and +connection/integration removal. diff --git a/.changeset/tools-schema-precompile-typescript.md b/.changeset/tools-schema-precompile-typescript.md new file mode 100644 index 0000000000..817d475411 --- /dev/null +++ b/.changeset/tools-schema-precompile-typescript.md @@ -0,0 +1,15 @@ +--- +"@executor-js/sdk": minor +"@executor-js/plugin-semantic-search": patch +--- + +Always render the TypeScript preview in `executor.tools.schema` and warm it during +indexing. The `includeTypeScript` option (and the now-empty `ToolSchemaOptions` type) is +removed — the only caller that passed `false` was the reindex pipeline, which did so to +skip the CPU-heavy JSON-schema → TypeScript codegen. It now calls `tools.schema` plainly, +so every reindex pre-warms exactly the schema view the web UI reads on first open (the +codegen is content-addressed in KV), turning first tool-detail views into cache hits +instead of a cold compile. The embedding text is unchanged — the indexer only ever read +the raw schema facets, never the rendered TypeScript. Because `includeTypeScript` is +dropped from the schema-view cache key, existing cached entries are superseded on the next +reindex or view (regenerable derived data). diff --git a/.claude/skills/emulate/SKILL.md b/.claude/skills/emulate/SKILL.md index 8814a64aae..f8e3515b52 100644 --- a/.claude/skills/emulate/SKILL.md +++ b/.claude/skills/emulate/SKILL.md @@ -5,15 +5,18 @@ description: Use the @executor-js/emulate service emulators (GitHub, Google, Str # Emulate: production-fidelity service emulators -`@executor-js/emulate` (our fork of Vercel Labs' emulate, developed in -`vendor/emulate` but ALWAYS consumed as the published npm package — never -import from `vendor/`) provides stateful, wire-level emulators for 16 -services: `github vercel google okta microsoft spotify slack apple aws -resend stripe mongoatlas clerk x workos autumn`. These are not mocks: real -SDKs and real product code run against them unmodified — the cloud e2e -target points the actual WorkOS SDK (sealed sessions, JWKS, hosted AuthKit -login) and Autumn billing at emulators and exercises the product's real -auth code. +`@executor-js/emulate` (our fork of Vercel Labs' emulate) provides stateful, +wire-level emulators for 16 services: `github vercel google okta microsoft +spotify slack apple aws resend stripe mongoatlas clerk x workos autumn`. +These are not mocks: real SDKs and real product code run against them +unmodified — the cloud e2e target points the actual WorkOS SDK (sealed +sessions, JWKS, hosted AuthKit login) and Autumn billing at emulators and +exercises the product's real auth code. + +**This repo only consumes the published npm package.** There is no +`vendor/emulate` submodule — everything imports `@executor-js/emulate` from +npm. The emulator _source_ is its own standalone project; to change or deploy +an emulator, see "Changing or deploying an emulator" at the bottom. ## Two ways to get one @@ -83,6 +86,40 @@ before `createEmulator` so minted MCP access tokens carry the right audience. app's real dev stack (recipe in `e2e/setup/cloud.boot.ts`), fronted with tailscale HTTPS. +## Changing or deploying an emulator + +The emulators live in their own repo — **not** in executor and **not** a +submodule: `github.com/UsefulSoftwareCo/emulate` (clone it as a sibling of +executor). It's a pnpm + turbo monorepo on node ≥ 24 — a different toolchain +from executor's bun, which is exactly why it's standalone rather than +vendored here. + +You have full autonomy over it: **work directly on `main`, and commit, push, +publish, and deploy without asking.** The loop: + +1. Edit in the emulate repo, then **rebuild the package you changed.** It + resolves through built `dist`, so a source-only edit does nothing until you + build it — this is the single most common mistake. `@emulators/*` packages + are a `workspace:*` graph, so adding or renaming one needs an install + + rebuild too. +2. Publish a version bump of `@executor-js/emulate` to npm. +3. Deploy the hosted Cloudflare emulators (the `emulate-hosts` worker behind + `*.emulators.dev`) when behavior the hosted instances serve has changed. +4. Back in executor, bump the `@executor-js/emulate` dependency to the version + you just published. Never point a consumer at a local checkout to ship — + publish, then bump. + +The emulate repo's own `AGENTS.md` / `README.md` carry the current build, +publish, and deploy commands (npm + Cloudflare creds are in 1Password). Read +them there rather than memorizing flags here — they move. + +**A hot deploy can redden other people's e2e.** `*.emulators.dev` service +hosts are shared infrastructure; a control-plane regression there has failed +unrelated PRs' suites before. When a scenario needs isolation or a behavior +that isn't deployed yet, pin to a published package version or mint a private +per-run instance (`POST /_emulate/instances`) instead of mutating the shared +service host. + ## Gotchas - **Secure cookies need HTTPS off-localhost.** Browser-driven flows work on diff --git a/.claude/skills/prod-telemetry/SKILL.md b/.claude/skills/prod-telemetry/SKILL.md new file mode 100644 index 0000000000..47f79bbc75 --- /dev/null +++ b/.claude/skills/prod-telemetry/SKILL.md @@ -0,0 +1,114 @@ +--- +name: prod-telemetry +description: Query Executor's production telemetry — Axiom traces (executor-cloud dataset), prod Postgres via PlanetScale, PostHog product analytics — through the Executor MCP. Use when investigating prod errors, latency, usage, churn signals, or verifying a deploy's telemetry; includes the dataset field layout, working APL recipes, and the error-attribution join. +--- + +# Production telemetry access + +All three stores are queryable through the Executor MCP's connected +integrations — no dashboards or credentials needed. Verify the connection +exists with `connections.list` if a call fails. + +## Axiom traces (`axiom_mcp`) + +Tool: `axiom_mcp.user.axiomMcpOAuth.querydataset` — the argument is `apl` +(NOT `query`). Dataset: `['executor-cloud']` (worker spans; browser spans +join the same traces via traceparent). + +**Field layout (the part you'd otherwise rediscover by failed queries):** + +- Custom span attributes live under the JSON map `['attributes.custom']`, + NOT as top-level `attributes.*` columns. Read with + `['attributes.custom']['mcp.tool.name']`. A nonexistent top-level field is + a hard query error ("invalid field"), not an empty result. +- Span status: `['status.code']` (`"OK"`/`"ERROR"`), `['status.message']`. +- Exceptions: the `events` column carries `exception.type` / + `exception.stacktrace` JSON. +- OTel basics are top-level: `name`, `trace_id`, `span_id`, + `parent_span_id`, `duration`, `_time`. + +**Span names worth querying** (and their custom attrs): + +- `executor.tool.execute` — `mcp.tool.name` (full address), and since + PR #992: `executor.tool.outcome` (`ok`/`fail`), + `executor.tool.error_code`, `executor.tool.error_status`, + `executor.tenant`, `executor.subject`. +- `mcp.tool.dispatch` — `mcp.tool.name` (sandbox path), + `mcp.tool.integration`, same outcome attrs. +- `plugin.openapi.invoke` — `plugin.openapi.method` / `path_template` / + `base_url`, and since PR #992 `http.status_code`. +- `mcp.request` (outer) — `mcp.auth.organization_id`, + `mcp.auth.account_id`, `mcp.tool.name`, CF edge fields (`cf.country`…), + MCP client fingerprint (`mcp.client.name`…). + +**Recipe — error signatures by class (the daily-digest query):** + +```apl +['executor-cloud'] +| where _time > ago(1d) +| where ['status.code'] == "ERROR" and name == "executor.tool.execute" +| extend msg = substring(tostring(['status.message']), 0, 120) +| extend tool = tostring(['attributes.custom']['mcp.tool.name']) +| summarize n = count() by msg, tool +| sort by n desc +``` + +**Recipe — attribute errors to orgs.** Tool spans now carry +`executor.tenant` directly (post-#992). For spans from BEFORE that deploy, +join through the outer request span: + +```apl +['executor-cloud'] +| where name == "mcp.request" and isnotnull(['attributes.custom']['mcp.auth.organization_id']) +| project trace_id, org = tostring(['attributes.custom']['mcp.auth.organization_id']) +| join kind=inner ( + ['executor-cloud'] + | where ['status.code'] == "ERROR" and name == "executor.tool.execute" + | project trace_id, msg = substring(tostring(['status.message']), 0, 60) + ) on trace_id +| summarize n = count() by org, msg | sort by n desc +``` + +**Recipe — upstream failure rate per integration (post-#992 attrs):** + +```apl +['executor-cloud'] +| where _time > ago(1d) and name == "mcp.tool.dispatch" +| extend outcome = tostring(['attributes.custom']['executor.tool.outcome']) +| extend integration = tostring(['attributes.custom']['mcp.tool.integration']) +| where isnotnull(outcome) +| summarize calls = count(), fails = countif(outcome == "fail") by integration +| extend failRate = todouble(fails) / todouble(calls) +| sort by fails desc +``` + +**Known signal caveats** (audited 2026-06-12): + +- Pre-#992 spans: `ToolResult.fail` outcomes (upstream 4xx/5xx, auth + rejections) are INVISIBLE — they rode the Effect success channel with no + span marker. Don't conclude "no errors" from old data. +- Many pre-#992 ERROR spans have an EMPTY `status.message` (tagged errors + without a message field) — group those by `events` exception.type instead. +- `[object Object]` status messages are the pre-#992 formatting bug. + +## Prod database (`planetscale_mcp`) + +Read tool needs `{organization: "answer-overflow", database: "executor", +branch: "main"}`. It returns `ok: true` even when the SQL failed — check the +result text for `Error:`. Use for tenant/integration/connection facts that +spans don't carry (row sizes, config shapes, counts). + +## Product analytics (`posthog_api` / `mcp_posthog_com`) + +Browser-side events only (the ~60-event typed catalog, PR #987; server-side +events not built). The org-key `posthog_api` connection covers the REST API; +the OAuth MCP connection covers the higher-level tools. + +## Verifying a deploy's telemetry (Layer-0 canary) + +After deploying telemetry changes: run a known-failing tool call against +prod, then assert the expected attributes arrive in Axiom within ~1 min. +Absence of data looks identical to health — query for the NEW attribute +explicitly rather than eyeballing dashboards. The e2e equivalent runs on +every suite: `e2e/cloud/telemetry-contract.test.ts` via the `Telemetry` +service (motel `/api/spans/search?attr.=`). diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 55076666c6..937936e3b3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.github/workflows/publish-desktop.yml b/.github/workflows/publish-desktop.yml index 608c458bd8..cad7d4a30b 100644 --- a/.github/workflows/publish-desktop.yml +++ b/.github/workflows/publish-desktop.yml @@ -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: @@ -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 diff --git a/.gitmodules b/.gitmodules deleted file mode 100644 index 2d0fb0c202..0000000000 --- a/.gitmodules +++ /dev/null @@ -1,9 +0,0 @@ -[submodule "integrationsdotsh"] - path = integrationsdotsh - url = https://github.com/RhysSullivan/integrationsdotsh.git -[submodule "vendor/emulate"] - path = vendor/emulate - url = https://github.com/UsefulSoftwareCo/emulate.git -[submodule "vendor/mcporter"] - path = vendor/mcporter - url = https://github.com/UsefulSoftwareCo/mcporter.git diff --git a/.oxfmtrc.json b/.oxfmtrc.json index 795e7bb76f..d92df54ec7 100644 --- a/.oxfmtrc.json +++ b/.oxfmtrc.json @@ -7,7 +7,6 @@ "dist", "vendor", "e2e/runs", - "integrationsdotsh", "node_modules", "packages/core/fumadb", "bun.lock", diff --git a/.oxlintrc.jsonc b/.oxlintrc.jsonc index a117a09ec9..7f9ff3ccf8 100644 --- a/.oxlintrc.jsonc +++ b/.oxlintrc.jsonc @@ -3,6 +3,16 @@ "jsPlugins": [{ "name": "executor", "specifier": "./scripts/oxlint-plugin-executor.js" }], "rules": { "typescript/no-explicit-any": "error", + // Unused function parameters are allowed with their real names (no `_` + // prefix required) — keep a descriptive name for params retained for + // signature/interface reasons. Unused *variables* and imports are still + // flagged. Same for unused catch bindings. Destructuring-skip variables + // (e.g. `{ type: _type, ...rest }`) keep the `_` convention since they + // cannot be omitted structurally. + "no-unused-vars": [ + "error", + { "args": "none", "caughtErrors": "none", "varsIgnorePattern": "^_" }, + ], "executor/no-vitest-import": "error", "executor/no-conditional-tests": "error", "executor/no-double-cast": "error", @@ -153,7 +163,6 @@ "vendor/", "emulators/", "e2e/runs/", - "integrationsdotsh/", "node_modules/", "packages/core/fumadb/", "packages/core/sdk/src/vendor/json-schema-to-typescript/", diff --git a/AGENTS.md b/AGENTS.md index 901b9eb33a..1c0dd16611 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -61,6 +61,12 @@ with zero setup. See the `emulate` skill (`.claude/skills/emulate/SKILL.md`) for the control-plane reference and recipes. +The emulators are a standalone project (`github.com/UsefulSoftwareCo/emulate`), +not vendored here — this repo only consumes the published `@executor-js/emulate` +package. You have full autonomy to change, publish, and deploy the emulators, +working directly on their `main`; the skill covers the loop. Don't re-introduce +a `vendor/` submodule for them. + ## Attribution Do not add any AI assistant, Claude, Anthropic, or Co-Authored-By @@ -78,6 +84,10 @@ Code is very cheap to write. Do not give time estimates; with agents, code is practically instant to generate. Unless stated otherwise, time to implement is not a blocker. +Never use em-dashes (the `—` character) anywhere: prose, docs, code comments, +commit messages, or PRs. Use commas, colons, parentheses, or separate sentences +instead. + ## Reference Repos Repos in `.reference`, such as Effect and effect-atom, are available for diff --git a/README.md b/README.md index e848bd4afb..5eabe62c64 100644 --- a/README.md +++ b/README.md @@ -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 @@ -37,13 +38,30 @@ Example `mcp.json` for Claude Code / Cursor: } ``` +### Use with Pi + +[Pi](https://pi.dev) does not include a built-in MCP client. To use Executor from Pi, install the community bridge: + +```bash +pi install git:github.com/gvkhosla/pi-executor-mcp@v0.2.0 +``` + +Reload Pi, then verify the bridge: + +```text +/reload +/executor-status +``` + +After that, ask Pi to search, inspect, and call tools through Executor. + ## Add a source If you can represent it with a JSON schema, it can be an integration. Executor has first-party support for OpenAPI, GraphQL, MCP, and Google Discovery — but the plugin system is open to any source type. ### 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 @@ -102,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 @@ -134,8 +154,7 @@ bun run test:e2e # full-stack e2e: boots the cloud and self-host apps and driv ``` The browser e2e scenarios need Playwright's Chromium once per machine: -`bunx playwright install chromium`. The git submodules under `vendor/` are -optional — see [vendor/README.md](vendor/README.md). +`bunx playwright install chromium`. ## Community diff --git a/RUNNING.md b/RUNNING.md index 4c4b552388..55c1c6fbac 100644 --- a/RUNNING.md +++ b/RUNNING.md @@ -15,11 +15,13 @@ artifacts dev servers fail without) plus Playwright chromium. A fresh worktree that skips it dies with "Failed to resolve entry for package '@executor-js/vite-plugin'". -The `vendor/` submodules (emulate, mcporter) are NOT required — nothing -imports from `vendor/` at runtime; those packages come from npm -(`@executor-js/emulate`, `@executor-js/mcporter`). `bun run bootstrap ---forks` inits them only when deliberately developing a fork (see -`vendor/README.md`). +Our two upstream forks — `@executor-js/emulate` (service emulators) and +`@executor-js/mcporter` (headless MCP client) — are consumed purely as +published npm packages; nothing in this repo references them by path. There +are no `vendor/` submodules. Each fork is its own standalone repo +(`github.com/UsefulSoftwareCo/emulate`, `github.com/UsefulSoftwareCo/mcporter`): +develop on its `main`, publish a bump, then bump the dependency here. The +`emulate` skill covers the emulator publish/deploy loop. ## Dev servers diff --git a/apps/cli/CHANGELOG.md b/apps/cli/CHANGELOG.md index f264456070..0515e6d16c 100644 --- a/apps/cli/CHANGELOG.md +++ b/apps/cli/CHANGELOG.md @@ -1,5 +1,144 @@ # executor +## 1.5.17 + +### Patch Changes + +- [#1076](https://github.com/RhysSullivan/executor/pull/1076) [`3e47752`](https://github.com/RhysSullivan/executor/commit/3e4775292d75e65fe3fa9ab4101360123b29e27c) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - Add `executor login` (plus `logout` and `whoami`) for signing the CLI into a + hosted or self-hosted Executor server using the OAuth 2.0 Device Authorization + Grant (RFC 8628), instead of manually creating and pasting an API key. `login` + prints a code and verification URL, opens the browser, and polls; afterwards the + CLI authenticates with a bearer token. Works against both cloud (WorkOS) and + self-host (Better Auth) servers. + +- [#1076](https://github.com/RhysSullivan/executor/pull/1076) [`3e47752`](https://github.com/RhysSullivan/executor/commit/3e4775292d75e65fe3fa9ab4101360123b29e27c) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - `connections.list` now returns a lean summary by default, replacing the full + `oauthScope` grant string (which can run to thousands of characters per + connection) with an `oauthScopeCount`. Pass `verbose: true` to get the full + grant back. + +- [#1076](https://github.com/RhysSullivan/executor/pull/1076) [`3e47752`](https://github.com/RhysSullivan/executor/commit/3e4775292d75e65fe3fa9ab4101360123b29e27c) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - The execute result envelope now reports how many items a script sent to the user + via `emit()`. A script that only emits (with no return value) is no longer + indistinguishable from one that did nothing: the envelope includes an emitted + count and a `(no return value; N items emitted to the user)` text preview. + +- [#1076](https://github.com/RhysSullivan/executor/pull/1076) [`3e47752`](https://github.com/RhysSullivan/executor/commit/3e4775292d75e65fe3fa9ab4101360123b29e27c) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - Fix OAuth connect for providers that issue authorization codes redeemable only + at a region-specific token host. Executor now redeems the code at the region + returned on the callback rather than the statically advertised token endpoint, + so connecting these providers no longer fails at the token-exchange step. + +- [#1076](https://github.com/RhysSullivan/executor/pull/1076) [`3e47752`](https://github.com/RhysSullivan/executor/commit/3e4775292d75e65fe3fa9ab4101360123b29e27c) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - Send a default `executor` User-Agent on OpenAPI tool calls. Upstreams such as + GitHub that reject requests without a User-Agent (HTTP 403) now succeed instead + of surfacing the rejection as a credential error. A spec- or connection-provided + User-Agent still takes precedence. +- Updated dependencies []: + - @executor-js/sdk@1.5.17 + - @executor-js/runtime-quickjs@1.5.17 + - @executor-js/local@1.4.4 + - @executor-js/api@1.4.37 + +## 1.5.16 + +### Patch Changes + +- [#1066](https://github.com/RhysSullivan/executor/pull/1066) [`0961773`](https://github.com/RhysSullivan/executor/commit/09617733310152bfa5ae9439b17bd6903cac611e) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - Replace the code-mode output helpers with a single `emit(value)` primitive. + `emit(...)` accepts plain values, `ToolFile` attachments, and MCP content blocks, + while `return` remains reserved for ordinary structured data. +- Updated dependencies []: + - @executor-js/sdk@1.5.16 + - @executor-js/runtime-quickjs@1.5.16 + - @executor-js/local@1.4.4 + - @executor-js/api@1.4.36 + +## 1.5.15 + +### Patch Changes + +- Updated dependencies []: + - @executor-js/sdk@1.5.15 + - @executor-js/local@1.4.4 + - @executor-js/api@1.4.35 + - @executor-js/runtime-quickjs@1.5.15 + +## 1.5.14 + +### Patch Changes + +- [#1051](https://github.com/RhysSullivan/executor/pull/1051) [`cfda0ac`](https://github.com/RhysSullivan/executor/commit/cfda0ac91248041ca178d77ea9bd7a698d9dd98e) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - Fix desktop startup so a failed supervised-daemon replacement no longer leaves + the app on a black window. The desktop now re-checks the daemon after install + failures, falls back to a managed sidecar when the stale daemon disappears, and + surfaces startup recovery instead of leaving a failed renderer visible. +- Updated dependencies []: + - @executor-js/sdk@1.5.14 + - @executor-js/runtime-quickjs@1.5.14 + - @executor-js/local@1.4.4 + - @executor-js/api@1.4.34 + +## 1.5.13 + +### Patch Changes + +- [#1046](https://github.com/RhysSullivan/executor/pull/1046) [`2de1804`](https://github.com/RhysSullivan/executor/commit/2de1804d81d3e9223cd80fa49df2763aa0ea06bb) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - Windows installs now repair stale Executor service listeners and only report success after the background daemon publishes the sign-in manifest used by `executor web`. The desktop app also attaches to a reachable supervised daemon before trusting Windows PID probes, so it no longer starts a competing sidecar when the background service already owns the port. + +- Updated dependencies []: + - @executor-js/local@1.4.4 + - @executor-js/api@1.4.33 + - @executor-js/sdk@1.5.13 + - @executor-js/runtime-quickjs@1.5.13 + +## 1.5.12 + +### Patch Changes + +- [#1021](https://github.com/RhysSullivan/executor/pull/1021) [`c8faad7`](https://github.com/RhysSullivan/executor/commit/c8faad7b6991e968811693feb78dc46879bb8cb8) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - Self-hosted instances now detect their public URL automatically on common + platforms, and origin handling is consistent across every host. When + `EXECUTOR_WEB_BASE_URL` is not set, the server reads the origin a host injects + (Railway, Render, Fly, Vercel, Netlify, Heroku, Azure, Cloudflare Pages) instead + of defaulting to localhost — so a platform deploy works with zero configuration + and no longer fails sign-in with "Invalid origin". When the origin still can't be + determined, that error is replaced with a clear message telling you exactly which + `EXECUTOR_WEB_BASE_URL` value to set, and a startup warning fires on any non-dev + deploy that falls back to localhost. The MCP browser-approval link a self-host + sends to clients now uses the pinned public URL (reachable behind a reverse + proxy) rather than the server's internal address. These resolution rules now live + in one shared helper used by every host. +- Updated dependencies []: + - @executor-js/sdk@1.5.12 + - @executor-js/runtime-quickjs@1.5.12 + - @executor-js/local@1.4.4 + - @executor-js/api@1.4.32 + +## 1.5.11 + +### Patch Changes + +- [#1002](https://github.com/RhysSullivan/executor/pull/1002) [`64b3544`](https://github.com/RhysSullivan/executor/commit/64b3544c297f122fb915ab281f2ac84c766ddcfd) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - Fix the self-hosted "Connect an agent" MCP URL. The card printed an + organization-scoped path (`//mcp`) that the + single-tenant self-host server didn't serve, so connecting an MCP client + authorized successfully but then failed to reach the tools with an HTTP 404. + The self-host server now accepts the organization-scoped path and routes it to + its MCP endpoint. + +- [#1002](https://github.com/RhysSullivan/executor/pull/1002) [`64b3544`](https://github.com/RhysSullivan/executor/commit/64b3544c297f122fb915ab281f2ac84c766ddcfd) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - Self-hosted MCP connections now require explicit approval. When an MCP client + connects, the browser stops on an approval screen showing the connecting + client's name, what it can access, and that the grant is limited to the MCP + server (not a web-app login, and it can't make other API calls on your behalf); + a token is granted only after you Approve. Previously a signed-in user's client + was authorized automatically with no prompt. + +- [#1008](https://github.com/RhysSullivan/executor/pull/1008) [`7237bf2`](https://github.com/RhysSullivan/executor/commit/7237bf2a82c2bd435a3a07f7f24338a325d578f0) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - Self-hosted instances no longer lose data on restart. Better Auth now shares + the same libSQL connection as the rest of the instance instead of opening its + own. Previously the two connections each managed their own write-ahead log on + the shared database file, and the second one to open could orphan the first — + so integrations, connections, and tools written after startup landed in a + discarded log and disappeared on the next restart, while sign-in data survived. + This is the "reconnected my account but it has zero tools" failure; a single + shared connection removes the split entirely. +- Updated dependencies []: + - @executor-js/sdk@1.5.11 + - @executor-js/runtime-quickjs@1.5.11 + - @executor-js/local@1.4.4 + - @executor-js/api@1.4.31 + ## 1.5.10 ### Patch Changes diff --git a/apps/cli/package.json b/apps/cli/package.json index c34a7d9294..8fc4be0857 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -1,6 +1,6 @@ { "name": "executor", - "version": "1.5.10", + "version": "1.5.17", "private": true, "bin": { "executor": "./bin/executor.ts" @@ -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:" }, diff --git a/apps/cli/src/build.ts b/apps/cli/src/build.ts index 74ec74394b..162951fbda 100644 --- a/apps/cli/src/build.ts +++ b/apps/cli/src/build.ts @@ -501,18 +501,24 @@ const buildPreviewTarballs = async (binaries: Record) => { } }; +// Resolve a comma-separated list of target package names (e.g. +// "executor-windows-x64") to Targets. Shared by `--target` and the +// EXECUTOR_PREVIEW_TARGETS env used by the preview-wrapper CI job. const resolveTargetsFromEnv = (env: string | undefined): Target[] => { - if (!env) throw new Error("EXECUTOR_PREVIEW_TARGETS must be set (comma-separated package names)"); + if (!env) throw new Error("No build targets given (comma-separated package names)"); const names = env .split(",") .map((s) => s.trim()) .filter(Boolean); const resolved = names.map((name) => { const match = ALL_TARGETS.find((t) => targetPackageName(t) === name); - if (!match) throw new Error(`Unknown preview target: ${name}`); + if (!match) { + const valid = ALL_TARGETS.map(targetPackageName).join(", "); + throw new Error(`Unknown build target: ${name}. Expected one of: ${valid}`); + } return match; }); - if (resolved.length === 0) throw new Error("EXECUTOR_PREVIEW_TARGETS resolved to an empty list"); + if (resolved.length === 0) throw new Error("Build target list resolved to empty"); return resolved; }; @@ -919,6 +925,11 @@ const { values, positionals } = parseArgs({ args: process.argv.slice(2), options: { single: { type: "boolean", default: false }, + // Build a specific target (or comma-separated set) by package name, e.g. + // `--target executor-windows-x64`. Used by the e2e VM harness to compile + // the guest's binary; without it `binary` builds the current platform + // (`--single`) or all targets. + target: { type: "string" }, mode: { type: "string", default: "production" }, }, allowPositionals: true, @@ -932,7 +943,11 @@ if (mode !== "production" && mode !== "development") { } if (command === "binary") { - const targets = values.single ? ALL_TARGETS.filter(isCurrentPlatform) : ALL_TARGETS; + const targets = values.target + ? resolveTargetsFromEnv(values.target) + : values.single + ? ALL_TARGETS.filter(isCurrentPlatform) + : ALL_TARGETS; const binaries = await buildBinaries(targets, mode); await buildWrapperPackage(binaries); } else if (command === "preview") { diff --git a/apps/cli/src/daemon.test.ts b/apps/cli/src/daemon.test.ts index 4f4e3963eb..13ffdc80c7 100644 --- a/apps/cli/src/daemon.test.ts +++ b/apps/cli/src/daemon.test.ts @@ -3,7 +3,39 @@ import { createServer, type Server } from "node:http"; import type { AddressInfo } from "node:net"; import * as Effect from "effect/Effect"; -import { canAutoStartLocalDaemonForHost, isExecutorServerReachable } from "./daemon"; +import { + canAutoStartLocalDaemonForHost, + isDevCliEntrypoint, + isExecutorServerReachable, + planServiceInstall, +} from "./daemon"; + +describe("isDevCliEntrypoint", () => { + it("treats source entrypoints as dev", () => { + expect(isDevCliEntrypoint("/Users/x/src/executor/apps/cli/src/main.ts")).toBe(true); + expect(isDevCliEntrypoint("/Users/x/dist/main.js")).toBe(true); + }); + + it("treats compiled single-file binaries as NOT dev (both Unix and Windows)", () => { + // Bun's embedded filesystem: `/$bunfs/...` on Unix, `B:\~BUN\...` on Windows. + // Missing the Windows form made a real `executor.exe` look like a dev + // checkout, so `service install` wrongly refused on Windows. + expect(isDevCliEntrypoint("/$bunfs/root/main.js")).toBe(false); + expect(isDevCliEntrypoint("B:/~BUN/root/main.js")).toBe(false); + expect(isDevCliEntrypoint("B:\\~BUN\\root\\main.js")).toBe(false); + }); + + it("only treats a DRIVE-ROOTED ~BUN as compiled (a ~BUN dir mid-tree stays dev)", () => { + // The Windows bunfs root is `:\~BUN\...`; a dev checkout that merely + // contains a `~BUN` directory must not be misread as a compiled binary. + expect(isDevCliEntrypoint("/home/user/~BUN/project/src/main.ts")).toBe(true); + expect(isDevCliEntrypoint("C:/Users/dev/~BUN/src/main.ts")).toBe(true); + }); + + it("is false when no entrypoint is known", () => { + expect(isDevCliEntrypoint(undefined)).toBe(false); + }); +}); describe("canAutoStartLocalDaemonForHost", () => { it("allows loopback hosts", () => { @@ -19,7 +51,7 @@ describe("canAutoStartLocalDaemonForHost", () => { }); describe("isExecutorServerReachable", () => { - it.effect("checks the v1.5 API surface instead of the removed scope endpoint", () => + it.effect("probes the unauthenticated /api/health endpoint without forwarding a credential", () => Effect.gen(function* () { const server = yield* Effect.acquireRelease( Effect.tryPromise( @@ -27,9 +59,10 @@ describe("isExecutorServerReachable", () => { 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("[]"); + // The probe must NOT send Authorization, and must hit /api/health. + if (url.pathname === "/api/health" && !request.headers.authorization) { + response.writeHead(200, { "content-type": "text/plain" }); + response.end("ok"); return; } response.writeHead(404); @@ -53,12 +86,6 @@ describe("isExecutorServerReachable", () => { ), ); - 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}`, }); @@ -67,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"); + }); +}); diff --git a/apps/cli/src/daemon.ts b/apps/cli/src/daemon.ts index 8be7db0648..f0b1c78a2b 100644 --- a/apps/cli/src/daemon.ts +++ b/apps/cli/src/daemon.ts @@ -19,7 +19,6 @@ export interface DaemonSpawnSpec { export interface ExecutorServerReachabilityInput { readonly baseUrl: string; - readonly authorization?: string; } type ProbeServer = ReturnType & { @@ -54,28 +53,44 @@ export const parseDaemonBaseUrl = (baseUrl: string, defaultPort: number): Parsed // --------------------------------------------------------------------------- const LOCAL_DAEMON_HOSTNAMES = new Set(["localhost", "127.0.0.1", "::1", "[::1]"]); -const BUN_EMBEDDED_ENTRYPOINT_PREFIX = "/$bunfs/"; export const canAutoStartLocalDaemonForHost = (hostname: string): boolean => LOCAL_DAEMON_HOSTNAMES.has(hostname.toLowerCase()); +/** + * Bun's compiled-binary embedded filesystem root, drive-rooted on Windows + * (`B:\~BUN\root\...`, argv normalized to `B:/~BUN/root/...`). Anchored to a + * drive prefix so a dev checkout that merely *contains* a `~BUN` directory + * isn't misread as a compiled binary. + */ +const WINDOWS_BUNFS_ENTRYPOINT = /^[a-z]:\/~BUN\//i; + +/** + * Whether the process is running from the dev source (`bun run src/main.ts`) + * rather than a compiled single-file binary. A compiled binary runs from Bun's + * embedded filesystem, whose entrypoint is `/$bunfs/root/main.js` on Unix but + * `B:\~BUN\root\main.js` (argv like `B:/~BUN/root/main.js`) on Windows — match + * BOTH. Missing the Windows form made a real `executor.exe` look like a dev + * checkout, so `service install` refused on Windows. (Found by a real EC2 + * Windows test.) + */ export const isDevCliEntrypoint = (scriptPath: string | undefined): boolean => { if (!scriptPath) return false; - if (scriptPath.startsWith(BUN_EMBEDDED_ENTRYPOINT_PREFIX)) return false; - return scriptPath.endsWith(".ts") || scriptPath.endsWith(".js"); + const normalized = scriptPath.replaceAll("\\", "/"); + if (normalized.startsWith("/$bunfs/") || WINDOWS_BUNFS_ENTRYPOINT.test(normalized)) return false; + return normalized.endsWith(".ts") || normalized.endsWith(".js"); }; export const isExecutorServerReachable = ( input: ExecutorServerReachabilityInput, ): Effect.Effect => 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; + // The unauthenticated liveness probe — never forwards a credential, so a + // 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) }); + const body = await response.text(); + return response.ok && body.trim() === "ok"; }).pipe(Effect.catchCause(() => Effect.succeed(false))); // --------------------------------------------------------------------------- @@ -267,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"; +}; diff --git a/apps/cli/src/device-login.ts b/apps/cli/src/device-login.ts new file mode 100644 index 0000000000..6396ebceeb --- /dev/null +++ b/apps/cli/src/device-login.ts @@ -0,0 +1,328 @@ +// --------------------------------------------------------------------------- +// CLI device-login (OAuth 2.0 Device Authorization Grant, RFC 8628). +// +// `executor login` against a hosted server: +// 1. discover the provider device endpoints + public client id from the +// server (`GET /api/auth/cli-login`), +// 2. request a device code, show the user_code + open the verification URL, +// 3. poll the token endpoint until the user approves in the browser, +// 4. hand the access + refresh tokens back to be stored in the profile. +// +// The flow is provider-neutral: the server advertises WorkOS (cloud) or Better +// Auth (self-host) endpoints, and both speak the same RFC 8628 wire shape, so +// this module never branches on provider. Tokens are sent to the `/api/*` +// plane as `Authorization: Bearer `. +// --------------------------------------------------------------------------- + +import { spawn } from "node:child_process"; + +export interface CliLoginDiscovery { + readonly provider: string; + readonly deviceAuthorizationEndpoint: string; + readonly tokenEndpoint: string; + readonly clientId: string; + readonly scope?: string; + /** + * How to encode the device-authorization + token requests. RFC 8628 mandates + * `form` (WorkOS), but some providers' endpoints only accept JSON (Better + * Auth). The server tells us via discovery; defaults to `form`. + */ + readonly requestFormat: "form" | "json"; +} + +export interface DeviceCodeGrant { + readonly deviceCode: string; + readonly userCode: string; + readonly verificationUri: string; + readonly verificationUriComplete?: string; + readonly expiresInSeconds: number; + readonly intervalSeconds: number; +} + +export interface DeviceTokens { + readonly accessToken: string; + readonly refreshToken?: string; + /** Epoch seconds the access token expires, when known. */ + readonly expiresAt?: number; + /** The signed-in account's email, if the token response carries a user. */ + readonly email?: string; + /** The bound organization id, from the response or the token's org_id claim. */ + readonly organizationId?: string; +} + +const DEVICE_CODE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code"; +const DEFAULT_INTERVAL_SECONDS = 5; + +export class DeviceLoginError extends Error { + constructor(message: string) { + super(message); + this.name = "DeviceLoginError"; + } +} + +const cliLoginUrl = (origin: string): string => `${origin.replace(/\/+$/, "")}/api/auth/cli-login`; + +const asString = (value: unknown): string | undefined => + typeof value === "string" && value.length > 0 ? value : undefined; + +const asNumber = (value: unknown): number | undefined => + typeof value === "number" && Number.isFinite(value) ? value : undefined; + +/** Pull `user.email` out of a token response (WorkOS returns the user inline). */ +const readUserEmail = (body: Record): string | undefined => { + const user = body.user; + if (user && typeof user === "object" && "email" in user) { + return asString((user as { email?: unknown }).email); + } + return undefined; +}; + +/** + * Decode a JWT access token's claims WITHOUT verifying it. The CLI only uses + * this for display (`whoami`, the post-login summary) and to read `exp`, the + * server is the only thing that verifies the token's signature. + */ +export const decodeAccessTokenClaims = ( + accessToken: string, +): Record | undefined => { + const segments = accessToken.split("."); + if (segments.length !== 3) return undefined; + const payloadSegment = segments[1]; + if (!payloadSegment) return undefined; + try { + const json = Buffer.from(payloadSegment, "base64url").toString("utf8"); + const claims = JSON.parse(json) as unknown; + return claims && typeof claims === "object" ? (claims as Record) : undefined; + } catch { + return undefined; + } +}; + +/** Read `exp` (epoch seconds) out of a JWT access token without verifying it. */ +export const accessTokenExpiry = (accessToken: string): number | undefined => + asNumber(decodeAccessTokenClaims(accessToken)?.exp); + +const deriveExpiresAt = (tokens: { + accessToken: string; + expiresIn?: number; +}): number | undefined => { + if (tokens.expiresIn !== undefined) { + return Math.floor(Date.now() / 1000) + tokens.expiresIn; + } + return accessTokenExpiry(tokens.accessToken); +}; + +const formBody = (fields: Record): string => { + const params = new URLSearchParams(); + for (const [key, value] of Object.entries(fields)) { + if (value !== undefined) params.set(key, value); + } + return params.toString(); +}; + +const definedFields = (fields: Record): Record => + Object.fromEntries(Object.entries(fields).filter(([, v]) => v !== undefined)) as Record< + string, + string + >; + +const post = async ( + url: string, + fields: Record, + format: "form" | "json", +) => + fetch(url, { + method: "POST", + headers: { + "content-type": format === "json" ? "application/json" : "application/x-www-form-urlencoded", + accept: "application/json", + }, + body: format === "json" ? JSON.stringify(definedFields(fields)) : formBody(fields), + }); + +const readJson = async (response: Response): Promise> => { + const text = await response.text(); + if (!text) return {}; + try { + return JSON.parse(text) as Record; + } catch { + return { error: "invalid_response", error_description: text.slice(0, 200) }; + } +}; + +export const discoverCliLogin = async (origin: string): Promise => { + let response: Response; + try { + response = await fetch(cliLoginUrl(origin), { headers: { accept: "application/json" } }); + } catch (cause) { + throw new DeviceLoginError( + `Could not reach ${origin} to start login: ${cause instanceof Error ? cause.message : String(cause)}`, + ); + } + if (!response.ok) { + throw new DeviceLoginError( + `${origin} does not support CLI login (GET /api/auth/cli-login returned ${response.status}). ` + + "It may be an older server, or a local/unauthenticated server that needs no login.", + ); + } + const body = await readJson(response); + const deviceAuthorizationEndpoint = asString(body.deviceAuthorizationEndpoint); + const tokenEndpoint = asString(body.tokenEndpoint); + const clientId = asString(body.clientId); + if (!deviceAuthorizationEndpoint || !tokenEndpoint || !clientId) { + throw new DeviceLoginError(`${origin} returned an incomplete CLI-login configuration.`); + } + return { + provider: asString(body.provider) ?? "unknown", + deviceAuthorizationEndpoint, + tokenEndpoint, + clientId, + scope: asString(body.scope), + requestFormat: asString(body.requestFormat) === "json" ? "json" : "form", + }; +}; + +export const requestDeviceCode = async (discovery: CliLoginDiscovery): Promise => { + const response = await post( + discovery.deviceAuthorizationEndpoint, + { client_id: discovery.clientId, scope: discovery.scope }, + discovery.requestFormat, + ); + const body = await readJson(response); + if (!response.ok) { + throw new DeviceLoginError( + `Device authorization failed (${response.status}): ${asString(body.error_description) ?? asString(body.error) ?? "unknown error"}`, + ); + } + const deviceCode = asString(body.device_code); + const userCode = asString(body.user_code); + const verificationUri = asString(body.verification_uri); + if (!deviceCode || !userCode || !verificationUri) { + throw new DeviceLoginError("Device authorization response was missing required fields."); + } + return { + deviceCode, + userCode, + verificationUri, + verificationUriComplete: asString(body.verification_uri_complete), + expiresInSeconds: asNumber(body.expires_in) ?? 300, + intervalSeconds: asNumber(body.interval) ?? DEFAULT_INTERVAL_SECONDS, + }; +}; + +const sleep = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)); + +/** + * Poll the token endpoint until the user approves, honoring the RFC 8628 + * pacing rules (`authorization_pending` keeps waiting, `slow_down` backs off, + * `access_denied` / `expired_token` are terminal). + */ +export const pollForDeviceTokens = async ( + discovery: CliLoginDiscovery, + grant: DeviceCodeGrant, + options: { readonly now?: () => number } = {}, +): Promise => { + const now = options.now ?? (() => Date.now()); + const deadline = now() + grant.expiresInSeconds * 1000; + let intervalMs = Math.max(1, grant.intervalSeconds) * 1000; + + for (;;) { + if (now() >= deadline) { + throw new DeviceLoginError("Login timed out before it was approved."); + } + await sleep(intervalMs); + + const response = await post( + discovery.tokenEndpoint, + { + grant_type: DEVICE_CODE_GRANT_TYPE, + device_code: grant.deviceCode, + client_id: discovery.clientId, + }, + discovery.requestFormat, + ); + const body = await readJson(response); + + if (response.ok) { + const accessToken = asString(body.access_token); + if (!accessToken) { + throw new DeviceLoginError("Token response was missing an access token."); + } + const claims = decodeAccessTokenClaims(accessToken); + return { + accessToken, + refreshToken: asString(body.refresh_token), + expiresAt: deriveExpiresAt({ accessToken, expiresIn: asNumber(body.expires_in) }), + email: + readUserEmail(body) ?? (typeof claims?.email === "string" ? claims.email : undefined), + organizationId: + asString(body.organization_id) ?? + (typeof claims?.org_id === "string" ? claims.org_id : undefined), + }; + } + + const error = asString(body.error); + if (error === "authorization_pending") continue; + if (error === "slow_down") { + intervalMs += 5000; + continue; + } + if (error === "access_denied") { + throw new DeviceLoginError("Login was denied."); + } + if (error === "expired_token") { + throw new DeviceLoginError("The login request expired before it was approved."); + } + throw new DeviceLoginError( + `Login failed: ${asString(body.error_description) ?? error ?? `HTTP ${response.status}`}`, + ); + } +}; + +/** Exchange a refresh token for a fresh access token (silent re-auth). Only + * providers that issue refresh tokens reach here (WorkOS, which is form-encoded + * per RFC 8628); Better Auth's device flow issues no refresh token. */ +export const refreshDeviceTokens = async (input: { + readonly tokenEndpoint: string; + readonly clientId: string; + readonly refreshToken: string; +}): Promise => { + const response = await post( + input.tokenEndpoint, + { + grant_type: "refresh_token", + refresh_token: input.refreshToken, + client_id: input.clientId, + }, + "form", + ); + const body = await readJson(response); + if (!response.ok) { + throw new DeviceLoginError( + `Token refresh failed: ${asString(body.error_description) ?? asString(body.error) ?? `HTTP ${response.status}`}`, + ); + } + const accessToken = asString(body.access_token); + if (!accessToken) throw new DeviceLoginError("Refresh response was missing an access token."); + return { + accessToken, + refreshToken: asString(body.refresh_token) ?? input.refreshToken, + expiresAt: deriveExpiresAt({ accessToken, expiresIn: asNumber(body.expires_in) }), + }; +}; + +/** Best-effort open the verification URL in the user's default browser. */ +export const openBrowser = (url: string): void => { + const platform = process.platform; + const command = platform === "darwin" ? "open" : platform === "win32" ? "cmd" : "xdg-open"; + const args = platform === "win32" ? ["/c", "start", "", url] : [url]; + try { + const child = spawn(command, args, { stdio: "ignore", detached: true }); + // Swallow async spawn failures (e.g. the opener binary is missing), the + // URL is printed too, so the user can open it by hand. + (child as { on?: (event: "error", listener: () => void) => void }).on?.("error", () => {}); + child.unref(); + } catch { + // Ignore, the URL is also printed for manual opening. + } +}; diff --git a/apps/cli/src/local-server-manifest.ts b/apps/cli/src/local-server-manifest.ts index 7ec8abd8ae..b0ec989b3d 100644 --- a/apps/cli/src/local-server-manifest.ts +++ b/apps/cli/src/local-server-manifest.ts @@ -49,10 +49,15 @@ export const writeLocalServerManifest = ( const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; yield* fs.makeDirectory(serverControlDir(path), { recursive: true }); - yield* fs.writeFileString( - localServerManifestPath(path), - serializeExecutorLocalServerManifest(manifest), - ); + const manifestPath = localServerManifestPath(path); + // The manifest embeds the bearer token; create it owner-only so there's no + // window where it exists world-readable (mode applies only on create). The + // chmod after covers overwriting a pre-existing world-readable file, where + // the create mode is ignored. + yield* fs.writeFileString(manifestPath, serializeExecutorLocalServerManifest(manifest), { + mode: 0o600, + }); + yield* fs.chmod(manifestPath, 0o600).pipe(Effect.ignore); }); export const removeLocalServerManifestIfOwnedBy = (input: { @@ -71,6 +76,25 @@ export const removeLocalServerManifestIfOwnedBy = (input: { yield* fs.remove(manifestPath, { force: true }); }); +/** + * Remove the server manifest unconditionally. Used by an OS-supervised daemon + * to reclaim a stale `server.json` left by a previous boot: across a reboot the + * recorded pid is meaningless (pids recycle, so it may now belong to an + * unrelated process), and launchd/systemd already guarantee a single supervised + * instance — so any pre-existing manifest is stale and the supervised daemon + * owns it. + */ +export const removeLocalServerManifest = (): Effect.Effect< + void, + PlatformError, + FileSystem.FileSystem | Path.Path +> => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fs.remove(localServerManifestPath(path), { force: true }); + }); + const StartupLockPayload = Schema.Struct({ pid: Schema.Number, }); diff --git a/apps/cli/src/main.ts b/apps/cli/src/main.ts index e64bfb2d72..dbcfb0fd89 100644 --- a/apps/cli/src/main.ts +++ b/apps/cli/src/main.ts @@ -2,6 +2,7 @@ // before any import (e.g. `@executor-js/local` → libSQL) eagerly loads them. import "./native-bindings"; +import { execFile } from "node:child_process"; import { randomUUID } from "node:crypto"; import { existsSync, realpathSync } from "node:fs"; import { dirname, join, resolve } from "node:path"; @@ -35,6 +36,25 @@ if (typeof Bun !== "undefined" && (await Bun.file(wasmOnDisk).exists())) { setQuickJSModule(mod); } +const sentryDsn = process.env.EXECUTOR_SENTRY_DSN; +if (sentryDsn) { + const Sentry = await import("@sentry/bun"); + Sentry.init({ + dsn: sentryDsn, + release: process.env.EXECUTOR_SENTRY_RELEASE, + environment: process.env.EXECUTOR_SENTRY_ENVIRONMENT ?? "production", + tracesSampleRate: 0, + initialScope: { + tags: { + process: "daemon", + platform: process.platform, + arch: process.arch, + ...(process.env.EXECUTOR_RUN_ID ? { runId: process.env.EXECUTOR_RUN_ID } : {}), + }, + }, + }); +} + import { Argument as Args, Command, Flag as Options } from "effect/unstable/cli"; import { BunRuntime, BunServices } from "@effect/platform-bun"; import { HttpApiClient } from "effect/unstable/httpapi"; @@ -47,15 +67,29 @@ import * as Cause from "effect/Cause"; import { ExecutorApi } from "@executor-js/api"; import { - DEFAULT_EXECUTOR_SERVER_USERNAME, getExecutorServerAuthorizationHeader, normalizeExecutorServerConnection, + normalizeExecutorServerOrigin, type ExecutorLocalServerKind, type ExecutorLocalServerManifest, type ExecutorServerConnection, type ExecutorServerConnectionInput, } from "@executor-js/sdk/shared"; -import { startServer, runMcpStdioServer, getExecutor } from "@executor-js/local"; +import { + decodeAccessTokenClaims, + discoverCliLogin, + openBrowser, + pollForDeviceTokens, + refreshDeviceTokens, + requestDeviceCode, +} from "./device-login"; +import { + startServer, + runMcpStdioServer, + getExecutor, + rotateLocalAuthToken, + localAuthTokenPath, +} from "@executor-js/local"; import { makeQuickJsExecutor } from "@executor-js/runtime-quickjs"; import { fetchIntegrations } from "./integrations"; import { @@ -65,6 +99,7 @@ import { isExecutorServerReachable, isDevCliEntrypoint, parseDaemonBaseUrl, + planServiceInstall, spawnDetached, waitForReachable, waitForUnreachable, @@ -98,6 +133,12 @@ import { resolveExecutorDataDir, writeLocalServerManifest, } from "./local-server-manifest"; +import { + DEFAULT_SERVICE_PORT, + getServiceBackend, + SERVICE_LABEL, + stopWindowsExecutorListenersOnPort, +} from "./service"; import { defaultCliServerConnectionProfile, findCliServerConnectionProfile, @@ -106,10 +147,10 @@ import { setDefaultCliServerConnectionProfile, upsertCliServerConnectionProfile, validateCliServerConnectionProfileName, + type CliServerConnectionStore, } from "./server-profile"; import { buildResumeContentTemplate, - buildToolPath, buildDescribeToolCode, filterToolPathChildren, buildInvokeToolCode, @@ -121,6 +162,7 @@ import { inspectToolPath, normalizeCliErrorText, parseJsonObjectInput, + resolveToolInvocation, sanitizeCliOutputText, shellQuoteArg, } from "./tooling"; @@ -138,6 +180,7 @@ const DEFAULT_BASE_URL = `http://localhost:${DEFAULT_PORT}`; const DAEMON_BOOT_TIMEOUT_MS = 15_000; const DAEMON_BOOT_POLL_MS = 150; const DAEMON_STOP_TIMEOUT_MS = 10_000; +const SERVICE_BOOT_TIMEOUT_MS = 45_000; // --------------------------------------------------------------------------- // Helpers @@ -158,8 +201,8 @@ const waitForShutdownSignal = () => // Background server management // --------------------------------------------------------------------------- -const isServerReachable = (baseUrl: string, authorization?: string): Effect.Effect => - isExecutorServerReachable({ baseUrl, authorization }); +const isServerReachable = (baseUrl: string): Effect.Effect => + isExecutorServerReachable({ baseUrl }); const readActiveLocalServerManifest = (): Effect.Effect< ExecutorLocalServerManifest | null, @@ -170,16 +213,15 @@ const readActiveLocalServerManifest = (): Effect.Effect< const manifest = yield* readLocalServerManifest(); if (!manifest) return null; + if (yield* isServerReachable(manifest.connection.origin)) { + return manifest; + } + if (!isPidAlive(manifest.pid)) { yield* removeLocalServerManifestIfOwnedBy({ pid: manifest.pid }).pipe(Effect.ignore); return null; } - const authorization = getExecutorServerAuthorizationHeader(manifest.connection) ?? undefined; - if (yield* isServerReachable(manifest.connection.origin, authorization)) { - return manifest; - } - return yield* Effect.fail( new Error( [ @@ -238,21 +280,6 @@ const parseExecutorServerConnection = (baseUrl: string) => const daemonBaseUrl = (hostname: string, port: number): string => `http://${canonicalDaemonHost(hostname)}:${port}`; -const serverAuthFromInputs = (input: { - readonly authToken: string | undefined; - readonly authPassword: string | undefined; -}): ExecutorServerConnection["auth"] | undefined => { - if (input.authPassword) { - return { - kind: "basic", - username: DEFAULT_EXECUTOR_SERVER_USERNAME, - password: input.authPassword, - }; - } - if (input.authToken) return { kind: "bearer", token: input.authToken }; - return undefined; -}; - const makeLocalServerManifest = (input: { readonly kind: ExecutorLocalServerKind; readonly connection: ExecutorServerConnection; @@ -268,7 +295,7 @@ const makeLocalServerManifest = (input: { scopeDir: currentScopeDirForManifest(), connection: input.connection, owner: { - client: "cli", + client: process.env.EXECUTOR_CLIENT === "desktop" ? "desktop" : "cli", version: CLI_VERSION, executablePath: isDevMode ? (script ?? null) : process.execPath, }, @@ -294,6 +321,44 @@ const assertNoOtherActiveLocalServer = (): Effect.Effect< ); }); +const takeOverActiveLocalServer = (input?: { + readonly onlyKind?: ExecutorLocalServerKind; +}): Effect.Effect< + ExecutorLocalServerManifest | null, + Error, + FileSystem.FileSystem | PlatformPath.Path +> => + Effect.gen(function* () { + const manifest = yield* readLocalServerManifest(); + if (!manifest) return null; + if (input?.onlyKind && manifest.kind !== input.onlyKind) return null; + + if (!isPidAlive(manifest.pid) || manifest.pid === process.pid) { + yield* removeLocalServerManifestIfOwnedBy({ pid: manifest.pid }).pipe(Effect.ignore); + return null; + } + + yield* terminatePid(manifest.pid).pipe(Effect.ignore); + const stopped = yield* waitForUnreachable({ + check: isServerReachable(manifest.connection.origin), + timeoutMs: DAEMON_STOP_TIMEOUT_MS, + intervalMs: DAEMON_BOOT_POLL_MS, + }); + if (!stopped) { + return yield* Effect.fail( + new Error( + [ + `The existing Executor ${manifest.kind} at ${manifest.connection.origin} (pid ${manifest.pid}) did not stop within ${DAEMON_STOP_TIMEOUT_MS / 1000}s.`, + "Stop it manually and re-run.", + ].join("\n"), + ), + ); + } + + yield* removeLocalServerManifestIfOwnedBy({ pid: manifest.pid }).pipe(Effect.ignore); + return manifest; + }); + const publishLocalServerManifest = (input: { readonly kind: ExecutorLocalServerKind; readonly connection: ExecutorServerConnection; @@ -532,6 +597,59 @@ const resolveRequestedExecutorServerConnection = ( }; }); +// Refresh an `oauth` (device-login) credential a minute before it expires, so +// `executor call` against a hosted server keeps working long after the browser +// login. The refreshed tokens are written back to the originating profile. +const OAUTH_REFRESH_SKEW_SECONDS = 60; + +const profileNameFromKey = (key: string): string | null => + key.startsWith("profile:") ? key.slice("profile:".length) : null; + +const refreshOAuthConnection = ( + connection: ExecutorServerConnection, +): Effect.Effect => + Effect.gen(function* () { + const auth = connection.auth; + if (!auth || auth.kind !== "oauth") return connection; + const now = Math.floor(Date.now() / 1000); + if (auth.expiresAt && auth.expiresAt - now > OAUTH_REFRESH_SKEW_SECONDS) return connection; + // Destructure so the narrowed string types survive into the deferred + // `tryPromise` callback (where TS would otherwise re-widen the fields). + const { refreshToken, tokenEndpoint, clientId } = auth; + if (!refreshToken || !tokenEndpoint || !clientId) return connection; + + const refreshed = yield* Effect.tryPromise({ + try: () => refreshDeviceTokens({ tokenEndpoint, clientId, refreshToken }), + catch: toError, + // On a failed refresh, keep the existing token and let the eventual 401 + // surface, better than blocking the command on a transient hiccup. + }).pipe(Effect.option); + if (Option.isNone(refreshed)) return connection; + + const next = refreshed.value; + const nextConnection = normalizeExecutorServerConnection({ + ...connection, + auth: { + kind: "oauth", + accessToken: next.accessToken, + refreshToken: next.refreshToken ?? refreshToken, + ...(next.expiresAt ? { expiresAt: next.expiresAt } : {}), + tokenEndpoint, + clientId, + }, + }); + + const profileName = profileNameFromKey(connection.key); + if (profileName) { + yield* upsertCliServerConnectionProfile({ + name: profileName, + connection: nextConnection, + makeDefault: false, + }).pipe(Effect.ignore); + } + return nextConnection; + }); + const resolveExecutorServerConnection = ( target: ServerTarget, ): Effect.Effect => @@ -557,12 +675,17 @@ const resolveExecutorServerConnection = ( ); } - const requested = decision.connection; + const requested = yield* refreshOAuthConnection(decision.connection); if (decision.kind === "use-active") return requested; if (!canAutoStartCliServerConnection(requested)) { - const authorization = getExecutorServerAuthorizationHeader(requested) ?? undefined; - if (yield* isServerReachable(requested.origin, authorization)) { + // An authenticated remote connection (oauth device-login, bearer key, or + // basic password): use it directly. The /api/health liveness probe is only + // a gate for the local auto-start decision and isn't necessarily exposed + // by hosted servers, the real API call surfaces any connectivity/auth + // error with proper context. + if (requested.auth) return requested; + if (yield* isServerReachable(requested.origin)) { return requested; } return yield* Effect.fail( @@ -570,14 +693,24 @@ const resolveExecutorServerConnection = ( [ `Executor server is not reachable at ${requested.origin}.`, "For hosted Executor, set EXECUTOR_API_KEY to a bearer API key.", - "For password-protected local or desktop servers, set EXECUTOR_AUTH_PASSWORD.", - "For unauthenticated local Executor, use an http://localhost or http://127.0.0.1 server URL.", + "For local or desktop servers, set EXECUTOR_AUTH_TOKEN to the server's bearer token.", ].join("\n"), ), ); } const daemonUrl = yield* ensureDaemon(requested.origin); + // The daemon we just ensured published a manifest carrying its bearer token + // (minted into auth.json). Prefer that authed connection — otherwise the + // next API call hits the now-gated server with no credential and 401s. + const started = yield* readActiveLocalServerManifest().pipe(Effect.orElseSucceed(() => null)); + const daemonOrigin = normalizeExecutorServerConnection({ origin: daemonUrl }).origin; + const startedOrigin = started + ? normalizeExecutorServerConnection({ origin: started.connection.origin }).origin + : null; + if (started && startedOrigin === daemonOrigin) { + return started.connection; + } return normalizeExecutorServerConnection({ ...requested, origin: daemonUrl, @@ -799,7 +932,6 @@ const runForegroundSession = (input: { hostname: string; allowedHosts: ReadonlyArray; authToken: string | undefined; - authPassword: string | undefined; }) => Effect.gen(function* () { const displayHost = @@ -821,7 +953,6 @@ const runForegroundSession = (input: { hostname: input.hostname, allowedHosts: input.allowedHosts, authToken: input.authToken, - authPassword: input.authPassword, embeddedWebUI, }), ); @@ -832,7 +963,7 @@ const runForegroundSession = (input: { kind: "http", origin: baseUrl, displayName: "CLI web", - auth: serverAuthFromInputs(input), + auth: { kind: "bearer", token: server.authToken }, }), }); } finally { @@ -845,6 +976,7 @@ const runForegroundSession = (input: { try { console.log(`Executor is ready.`); + console.log(`Open: ${baseUrl}/?_token=${server.authToken}`); console.log(`Web: ${baseUrl}`); console.log(`MCP: ${baseUrl}/mcp`); console.log(`OpenAPI: ${baseUrl}/api/docs`); @@ -853,12 +985,7 @@ const runForegroundSession = (input: { `\n⚠ Listening on ${input.hostname}. Executor runs arbitrary commands — only expose on trusted networks.`, ); if (input.allowedHosts.length > 0) { - console.log(` Extra allowed Host headers: ${input.allowedHosts.join(", ")}`); - } - if (input.authPassword) { - console.log(" Basic authentication is enabled."); - } else if (input.authToken) { - console.log(" Token authentication is enabled."); + console.log(` Extra CORS origins: ${input.allowedHosts.join(", ")}`); } } console.log(`\nPress Ctrl+C to stop.`); @@ -878,7 +1005,6 @@ const runDaemonSession = (input: { hostname: string; allowedHosts: ReadonlyArray; authToken: string | undefined; - authPassword: string | undefined; }) => Effect.gen(function* () { const daemonHost = canonicalDaemonHost(input.hostname); @@ -894,22 +1020,53 @@ const runDaemonSession = (input: { let token: string | null = null; try { - yield* assertNoOtherActiveLocalServer(); + // A supervised daemon (launchd/systemd) is the OS-guaranteed singleton + // — kickstart -k kills the old instance before starting the new — so any + // server.json from a previous boot is stale. Reclaim it rather than + // refusing: across a reboot the recorded pid may have been recycled by + // an unrelated process, which would otherwise make the "is one already + // running?" check treat it as alive-but-unreachable, refuse to start, + // and crash-loop under KeepAlive. (Found by a real reboot test with + // integration data in the DB.) + if (process.env.EXECUTOR_SUPERVISED) { + yield* takeOverActiveLocalServer().pipe(Effect.ignore); + } else { + yield* assertNoOtherActiveLocalServer(); + } const existing = yield* readDaemonPointer({ hostname: daemonHost, scopeId }); if (existing) { const existingUrl = daemonBaseUrl(existing.hostname, existing.port); if (isPidAlive(existing.pid) && (yield* isServerReachable(existingUrl))) { - return yield* Effect.fail( - new Error( - [ - `A daemon is already running for scope ${scopeId} on ${daemonHost}.`, - `Existing daemon: ${existingUrl} (pid ${existing.pid}).`, - `Stop it first: ${cliPrefix} daemon stop`, - ].join("\n"), - ), - ); + if (process.env.EXECUTOR_SUPERVISED) { + yield* terminatePid(existing.pid).pipe(Effect.ignore); + const stopped = yield* waitForUnreachable({ + check: isServerReachable(existingUrl), + timeoutMs: DAEMON_STOP_TIMEOUT_MS, + intervalMs: DAEMON_BOOT_POLL_MS, + }); + if (!stopped) { + return yield* Effect.fail( + new Error( + [ + `The existing daemon for scope ${scopeId} at ${existingUrl} (pid ${existing.pid}) did not stop within ${DAEMON_STOP_TIMEOUT_MS / 1000}s.`, + "Stop it manually and re-run.", + ].join("\n"), + ), + ); + } + } else { + return yield* Effect.fail( + new Error( + [ + `A daemon is already running for scope ${scopeId} on ${daemonHost}.`, + `Existing daemon: ${existingUrl} (pid ${existing.pid}).`, + `Stop it first: ${cliPrefix} daemon stop`, + ].join("\n"), + ), + ); + } } yield* cleanupPointer({ hostname: existing.hostname, scopeId, port: existing.port }); } @@ -920,7 +1077,6 @@ const runDaemonSession = (input: { hostname: input.hostname, allowedHosts: input.allowedHosts, authToken: input.authToken, - authPassword: input.authPassword, embeddedWebUI, }), ); @@ -934,7 +1090,7 @@ const runDaemonSession = (input: { kind: "http", origin: daemonUrl, displayName: "CLI daemon", - auth: serverAuthFromInputs(input), + auth: { kind: "bearer", token: server.authToken }, }), }); } finally { @@ -962,11 +1118,6 @@ const runDaemonSession = (input: { }); console.log(`Daemon ready on http://${daemonHost}:${daemonPort}`); - if (input.authPassword) { - console.log("Basic authentication is enabled."); - } else if (input.authToken) { - console.log("Token authentication is enabled."); - } yield* waitForShutdownSignal(); } finally { @@ -1092,6 +1243,7 @@ const runStdioMcpSession = (input: { readonly elicitationMode: "browser" | "mode kind: "http", origin: web.baseUrl, displayName: "CLI MCP", + auth: { kind: "bearer", token: web.server.authToken }, }), }); } finally { @@ -1585,38 +1737,6 @@ const runCallHelp = ( }); }).pipe(Effect.mapError(toError)); -const resolveToolInvocation = (input: { - rawPathParts: ReadonlyArray; -}): Effect.Effect<{ path: string; args: Record }, Error> => - Effect.gen(function* () { - if (!Array.isArray(input.rawPathParts)) { - return yield* Effect.fail( - new Error("Invalid tool invocation: path parts were not parsed as an array"), - ); - } - - const maybeJsonArg = input.rawPathParts.at(-1)?.trim(); - const hasInlineJsonArg = maybeJsonArg !== undefined && maybeJsonArg.startsWith("{"); - const pathParts = hasInlineJsonArg ? input.rawPathParts.slice(0, -1) : input.rawPathParts; - const args = hasInlineJsonArg ? yield* parseJsonObjectInput(maybeJsonArg) : {}; - - if (pathParts.some((part) => part.trim().startsWith("-"))) { - return yield* Effect.fail( - new Error( - "Tool invocation no longer accepts flags. Use: executor call '{...json...}'", - ), - ); - } - - const path = yield* Effect.try({ - try: () => buildToolPath(pathParts), - catch: (cause) => - cause instanceof Error ? cause : new Error(`Invalid tool path: ${String(cause)}`), - }); - - return { path, args }; - }); - // --------------------------------------------------------------------------- // Commands // --------------------------------------------------------------------------- @@ -1915,50 +2035,334 @@ const serverRemoveCommand = Command.make( }), ).pipe(Command.withDescription("Remove an Executor server profile")); +const serverRotateTokenCommand = Command.make("rotate-token", {}, () => + Effect.gen(function* () { + const token = rotateLocalAuthToken(); + console.log("Rotated the local server bearer token."); + console.log(`Stored in ${localAuthTokenPath()}`); + + const manifest = yield* readLocalServerManifest(); + if (manifest && isPidAlive(manifest.pid)) { + console.log( + `\n⚠ A local server is running at ${manifest.connection.origin} (pid ${manifest.pid}).`, + ); + console.log(" Restart it to apply the new token."); + } + console.log(`\nNew token: ${token}`); + console.log("Re-run your MCP client connect command with the new token."); + }), +).pipe(Command.withDescription("Rotate the local server's bearer token (auth.json)")); + const serverCommand = Command.make("server").pipe( Command.withSubcommands([ serverAddCommand, serverListCommand, serverUseCommand, serverRemoveCommand, + serverRotateTokenCommand, ] as const), Command.withDescription("Manage named Executor server profiles"), ); +const loginHostLabel = (origin: string): string => { + try { + const host = new URL(origin).hostname; + const first = host.split(".")[0]; + return first && first.length > 0 ? first : host; + } catch { + return "server"; + } +}; + +const sanitizeProfileName = (raw: string): string => { + const cleaned = raw + .trim() + .replace(/[^A-Za-z0-9_.-]+/g, "-") + .replace(/^-+|-+$/g, ""); + return cleaned.length > 0 ? cleaned : "server"; +}; + +// The (origin, user, org) a stored oauth profile authenticates as, lets us +// recognize a re-login to the SAME account (update in place) versus a +// different account on the same host (needs its own profile). +const oauthAccountIdentity = (connection: ExecutorServerConnection): string | null => { + const auth = connection.auth; + if (!auth || auth.kind !== "oauth") return null; + const claims = decodeAccessTokenClaims(auth.accessToken); + const sub = typeof claims?.sub === "string" ? claims.sub : undefined; + const org = typeof claims?.org_id === "string" ? claims.org_id : undefined; + return sub && org ? `${connection.origin}|${sub}|${org}` : null; +}; + +// Name a login's profile by the ACCOUNT it authenticates (email, falling back +// to user id), not the hostname, so two accounts on the same server get +// distinct profiles instead of clobbering each other (the way opencode keys +// accounts by email/url). A re-login to the same account reuses its profile. +const chooseLoginProfileName = ( + store: CliServerConnectionStore, + account: { + readonly origin: string; + readonly sub?: string; + readonly org?: string; + readonly email?: string; + }, +): string => { + const identity = + account.sub && account.org ? `${account.origin}|${account.sub}|${account.org}` : null; + if (identity) { + const existing = store.profiles.find((p) => oauthAccountIdentity(p.connection) === identity); + if (existing) return existing.name; + } + const base = sanitizeProfileName(account.email ?? account.sub ?? loginHostLabel(account.origin)); + if (!store.profiles.some((p) => p.name === base)) return base; + for (let suffix = 2; ; suffix += 1) { + const candidate = `${base}-${suffix}`; + if (!store.profiles.some((p) => p.name === candidate)) return candidate; + } +}; + +// Resolve which server a login/logout targets: an existing profile (--server +// or the default) or a bare origin (--base-url). The profile name is decided +// later, from the authenticated account. +const resolveLoginOrigin = (input: { + readonly baseUrl: Option.Option; + readonly server: Option.Option; +}): Effect.Effect< + { readonly origin: string; readonly profile: ReturnType }, + Error, + FileSystem.FileSystem | PlatformPath.Path +> => + Effect.gen(function* () { + const baseUrl = Option.getOrUndefined(input.baseUrl); + const serverName = Option.getOrUndefined(input.server); + if (baseUrl && serverName) { + return yield* Effect.fail(new Error("Use either --server or --base-url, not both.")); + } + if (serverName) { + const store = yield* readCliServerConnectionStore(); + const profile = findCliServerConnectionProfile(store, serverName); + if (!profile) + return yield* Effect.fail(new Error(`No server profile named "${serverName}".`)); + return { origin: profile.connection.origin, profile }; + } + if (baseUrl) { + return { origin: normalizeExecutorServerOrigin(baseUrl), profile: null }; + } + const store = yield* readCliServerConnectionStore(); + const profile = defaultCliServerConnectionProfile(store); + if (profile) return { origin: profile.connection.origin, profile }; + return yield* Effect.fail( + new Error( + "No server to log in to. Pass --base-url or --server .", + ), + ); + }); + +const loginNameOption = Options.string("name").pipe( + Options.optional, + Options.withDescription("Profile name to save the login under (defaults to your account)."), +); + +const noBrowserOption = Options.boolean("no-browser").pipe( + Options.withDefault(false), + Options.withDescription("Print the verification URL instead of opening a browser."), +); + +const loginCommand = Command.make( + "login", + { + baseUrl: serverBaseUrl, + server: serverProfile, + name: loginNameOption, + noBrowser: noBrowserOption, + }, + ({ baseUrl, server, name, noBrowser }) => + Effect.gen(function* () { + const target = yield* resolveLoginOrigin({ baseUrl, server }); + const explicitName = Option.getOrUndefined(name); + const discovery = yield* Effect.tryPromise({ + try: () => discoverCliLogin(target.origin), + catch: toError, + }); + const grant = yield* Effect.tryPromise({ + try: () => requestDeviceCode(discovery), + catch: toError, + }); + const verifyUrl = grant.verificationUriComplete ?? grant.verificationUri; + console.log(""); + console.log(`To sign in, open: ${verifyUrl}`); + console.log(`and confirm the code: ${grant.userCode}`); + console.log(""); + if (!noBrowser) openBrowser(verifyUrl); + console.log("Waiting for you to approve in the browser..."); + const tokens = yield* Effect.tryPromise({ + try: () => pollForDeviceTokens(discovery, grant), + catch: toError, + }); + + const claims = decodeAccessTokenClaims(tokens.accessToken); + const sub = typeof claims?.sub === "string" ? claims.sub : undefined; + const org = + tokens.organizationId ?? (typeof claims?.org_id === "string" ? claims.org_id : undefined); + const email = tokens.email; + + // Name by account so a different account on the same host doesn't clobber + // an existing login; --server / the default profile / --name pin it. + const store = yield* readCliServerConnectionStore(); + const profileName = explicitName + ? validateCliServerConnectionProfileName(explicitName) + : target.profile + ? target.profile.name + : chooseLoginProfileName(store, { origin: target.origin, sub, org, email }); + + yield* upsertCliServerConnectionProfile({ + name: profileName, + connection: { + kind: "http", + origin: target.origin, + ...(email ? { displayName: email } : {}), + auth: { + kind: "oauth", + accessToken: tokens.accessToken, + ...(tokens.refreshToken ? { refreshToken: tokens.refreshToken } : {}), + ...(tokens.expiresAt ? { expiresAt: tokens.expiresAt } : {}), + tokenEndpoint: discovery.tokenEndpoint, + clientId: discovery.clientId, + }, + }, + makeDefault: true, + }); + + console.log(""); + console.log(`Logged in to ${target.origin} (profile "${profileName}", now the default).`); + if (email) console.log(`Account: ${email}`); + if (org) console.log(`Organization: ${org}`); + else if (sub) console.log(`User: ${sub}`); + }), +).pipe(Command.withDescription("Sign in to a hosted Executor server in the browser (device flow)")); + +const logoutCommand = Command.make( + "logout", + { baseUrl: serverBaseUrl, server: serverProfile }, + ({ baseUrl, server }) => + Effect.gen(function* () { + const target = yield* resolveLoginOrigin({ baseUrl, server }); + const store = yield* readCliServerConnectionStore(); + // --server / default give the profile directly; --base-url matches by origin. + const profile = + target.profile ?? store.profiles.find((p) => p.connection.origin === target.origin) ?? null; + if (!profile) { + console.log(`No stored login for ${target.origin}.`); + return; + } + if (!profile.connection.auth) { + console.log(`Profile "${profile.name}" has no stored credentials.`); + return; + } + yield* upsertCliServerConnectionProfile({ + name: profile.name, + connection: { + kind: profile.connection.kind, + origin: profile.connection.origin, + displayName: profile.connection.displayName, + }, + makeDefault: store.defaultProfile === profile.name, + }); + console.log( + `Logged out of ${profile.connection.origin} (cleared credentials for "${profile.name}").`, + ); + }), +).pipe(Command.withDescription("Clear stored credentials for a server profile")); + +const whoamiCommand = Command.make( + "whoami", + { baseUrl: serverBaseUrl, server: serverProfile }, + ({ baseUrl, server }) => + Effect.gen(function* () { + const requested = yield* resolveRequestedExecutorServerConnection( + serverTargetFromOptions({ baseUrl, server }), + ); + const connection = yield* refreshOAuthConnection(requested.connection); + const auth = connection.auth; + console.log(`Server: ${connection.origin}`); + if (!auth) { + console.log("Not logged in (no stored credentials)."); + return; + } + if (auth.kind === "oauth") { + const claims = decodeAccessTokenClaims(auth.accessToken); + const sub = claims?.sub; + const org = claims?.org_id; + // The email lives on the profile's displayName (WorkOS access tokens + // don't carry an email claim); the token carries sub + org_id. + if (connection.displayName.includes("@")) console.log(`Account: ${connection.displayName}`); + if (typeof sub === "string") console.log(`User: ${sub}`); + if (typeof org === "string") console.log(`Organization: ${org}`); + if (auth.expiresAt) { + const remaining = auth.expiresAt - Math.floor(Date.now() / 1000); + console.log( + remaining > 0 + ? `Token valid for ~${Math.round(remaining / 60)} min.` + : "Token expired (refreshes on next use).", + ); + } + return; + } + console.log(`Authenticated via ${auth.kind}.`); + }), +).pipe(Command.withDescription("Show who you're signed in as on a server")); + const webCommand = Command.make( "web", { - port: Options.integer("port").pipe(Options.withDefault(DEFAULT_PORT)), + foreground: Options.boolean("foreground") + .pipe(Options.withDefault(false)) + .pipe( + Options.withDescription( + "Run a temporary web server in this terminal. By default, web opens the installed background service.", + ), + ), + port: Options.integer("port") + .pipe(Options.withDefault(DEFAULT_PORT)) + .pipe(Options.withDescription("Port for the temporary --foreground server.")), hostname: Options.string("hostname") .pipe(Options.withDefault("127.0.0.1")) - .pipe(Options.withDescription("Bind address. Use 0.0.0.0 to listen on all interfaces.")), + .pipe( + Options.withDescription( + "Bind address for the temporary --foreground server. Use 0.0.0.0 to listen on all interfaces.", + ), + ), allowedHost: Options.string("allowed-host") .pipe(Options.atLeast(0)) .pipe( Options.withDescription( - "Additional hostname permitted in the Host header (repeatable). localhost/127.0.0.1 are always allowed.", + "For --foreground, grant an extra origin cross-origin (CORS) access (repeatable). Not needed to reach the server from another host — the bearer token is the gate; localhost is always allowed.", ), ), authToken: Options.string("auth-token") .pipe(Options.optional) - .pipe(Options.withDescription("Bearer token required for requests.")), - authPassword: Options.string("auth-password") - .pipe(Options.optional) - .pipe(Options.withDescription("Basic auth password required for requests.")), + .pipe( + Options.withDescription( + "For --foreground, override the bearer token. Defaults to the stable token in auth.json.", + ), + ), scope, }, - ({ port, scope, hostname, allowedHost, authToken, authPassword }) => + ({ foreground, port, scope, hostname, allowedHost, authToken }) => Effect.gen(function* () { + if (!foreground) { + yield* openRunningLocalWebApp(); + return; + } applyScope(scope); yield* runForegroundSession({ port, hostname, allowedHosts: allowedHost, authToken: Option.getOrUndefined(authToken), - authPassword: Option.getOrUndefined(authPassword), }); }), -).pipe(Command.withDescription("Start a foreground web session")); +).pipe(Command.withDescription("Open the Executor web UI")); const daemonRunCommand = Command.make( "run", @@ -1971,15 +2375,16 @@ const daemonRunCommand = Command.make( .pipe(Options.atLeast(0)) .pipe( Options.withDescription( - "Additional hostname permitted in the Host header (repeatable). localhost/127.0.0.1 are always allowed.", + "Grant an extra origin cross-origin (CORS) access (repeatable). Not needed to reach the server from another host — the bearer token is the gate; localhost is always allowed.", ), ), authToken: Options.string("auth-token") .pipe(Options.optional) - .pipe(Options.withDescription("Bearer token required for requests.")), - authPassword: Options.string("auth-password") - .pipe(Options.optional) - .pipe(Options.withDescription("Basic auth password required for requests.")), + .pipe( + Options.withDescription( + "Override the bearer token. Defaults to the stable token in auth.json.", + ), + ), foreground: Options.boolean("foreground") .pipe(Options.withDefault(false)) .pipe( @@ -1989,16 +2394,19 @@ const daemonRunCommand = Command.make( ), scope, }, - ({ port, scope, hostname, allowedHost, authToken, authPassword, foreground }) => + ({ port, scope, hostname, allowedHost, authToken, foreground }) => Effect.gen(function* () { applyScope(scope); if (foreground) { + // The foreground daemon is the form OS service managers run. Its bearer + // comes from --auth-token, else the stable token in auth.json (loaded by + // startServer from EXECUTOR_DATA_DIR) — the supervised unit carries no + // secret, so the daemon and its clients share the one auth.json token. yield* runDaemonSession({ port, hostname, allowedHosts: allowedHost, authToken: Option.getOrUndefined(authToken), - authPassword: Option.getOrUndefined(authPassword), }); } else { yield* runBackgroundDaemonStart({ port, hostname, allowedHosts: allowedHost }); @@ -2110,19 +2518,376 @@ const mcpCommand = Command.make( }), ).pipe(Command.withDescription("Start an MCP server over stdio")); +// --------------------------------------------------------------------------- +// Service — register the daemon with the OS so it survives app-quit + restart +// --------------------------------------------------------------------------- + +const supervisedServiceOrigin = (port: number): string => `http://127.0.0.1:${port}`; + +const LOCAL_SERVICE_HOSTS = new Set(["localhost", "127.0.0.1", "::1", "[::1]"]); + +const originPort = (url: URL): string => url.port || (url.protocol === "https:" ? "443" : "80"); + +const sameServerOrigin = (left: string, right: string): boolean => { + const leftUrl = new URL(normalizeExecutorServerConnection({ origin: left }).origin); + const rightUrl = new URL(normalizeExecutorServerConnection({ origin: right }).origin); + if (leftUrl.toString() === rightUrl.toString()) return true; + return ( + leftUrl.protocol === rightUrl.protocol && + originPort(leftUrl) === originPort(rightUrl) && + LOCAL_SERVICE_HOSTS.has(leftUrl.hostname.toLowerCase()) && + LOCAL_SERVICE_HOSTS.has(rightUrl.hostname.toLowerCase()) + ); +}; + +const hasReachableCliDaemonManifest = (input: { + readonly origin: string; + readonly version: string; +}): Effect.Effect => + Effect.gen(function* () { + const manifest = yield* readActiveLocalServerManifest().pipe( + Effect.catchCause(() => Effect.succeed(null)), + ); + if (!manifest || manifest.kind !== "cli-daemon") return false; + if (!sameServerOrigin(manifest.connection.origin, input.origin)) return false; + if (manifest.owner.version !== input.version) return false; + return yield* isServerReachable(manifest.connection.origin); + }); + +const clearUnmanifestedWindowsExecutorListener = (input: { + readonly port: number; + readonly origin: string; +}): Effect.Effect => + Effect.gen(function* () { + const active = yield* readActiveLocalServerManifest().pipe( + Effect.catchCause(() => Effect.succeed(null)), + ); + if (active && sameServerOrigin(active.connection.origin, input.origin)) return; + if (!(yield* isServerReachable(input.origin))) return; + + const stoppedPids = yield* stopWindowsExecutorListenersOnPort(input.port); + const stopped = yield* waitForUnreachable({ + check: isServerReachable(input.origin), + timeoutMs: DAEMON_STOP_TIMEOUT_MS, + intervalMs: DAEMON_BOOT_POLL_MS, + }); + if (stopped) return; + + return yield* Effect.fail( + new Error( + [ + `Executor is already reachable at ${input.origin}, but no live server manifest exists for it.`, + stoppedPids.length > 0 + ? `Tried to stop orphaned Executor pid(s): ${stoppedPids.join(", ")}.` + : `No orphaned executor.exe listener could be stopped on port ${input.port}.`, + "Stop the process using that port and re-run the install.", + ].join("\n"), + ), + ); + }); + +const portFromOrigin = (origin: string): number | null => { + try { + const url = new URL(origin); + if (!url.port) return url.protocol === "https:" ? 443 : 80; + const port = Number.parseInt(url.port, 10); + return Number.isInteger(port) && port > 0 ? port : null; + } catch { + return null; + } +}; + +const servicePortOption = () => + Options.integer("port") + .pipe(Options.withDefault(DEFAULT_SERVICE_PORT)) + .pipe(Options.withDescription("Port the supervised daemon binds (loopback only).")); + +const serviceManagerName = (platform: ReturnType["platform"]): string => { + switch (platform) { + case "darwin": + return "launchd"; + case "linux": + return "systemd --user"; + case "win32": + return "Windows Task Scheduler"; + case "unsupported": + return "manual setup"; + } +}; + +const installService = (port: number, commandName: string) => + Effect.gen(function* () { + const command = `${cliPrefix} ${commandName}`; + if (isDevMode) { + return yield* Effect.fail( + new Error( + [ + `\`${command}\` requires the compiled \`executor\` binary so the OS can run it directly.`, + `In a dev checkout, run \`${cliPrefix} daemon run --foreground\` instead.`, + ].join("\n"), + ), + ); + } + + const backend = getServiceBackend(); + if (!backend.automated) { + // Unsupported platforms surface their manual steps via the install error. + yield* backend.install({ executablePath: process.execPath, port, version: CLI_VERSION }); + return; + } + + const status = yield* backend.status(); + const active = yield* readActiveLocalServerManifest().pipe(Effect.orElseSucceed(() => null)); + const plan = planServiceInstall({ + registered: status.registered, + running: status.running, + activeKind: active?.kind ?? null, + activePid: active?.pid ?? null, + servicePid: status.pid, + activeVersion: active?.owner.version ?? null, + activeExecutablePath: active?.owner.executablePath ?? null, + activePort: active ? portFromOrigin(active.connection.origin) : null, + requestedPort: port, + currentVersion: CLI_VERSION, + currentExecutablePath: process.execPath, + }); + + if (plan === "noop") { + const where = active ? ` at ${active.connection.origin} (pid ${active.pid})` : ""; + console.log(`Executor background service is already running${where}.`); + console.log(`Open it in your browser, already signed in, with: ${cliPrefix} web`); + return; + } + + if ( + plan === "takeover-then-install" || + (backend.platform === "win32" && plan === "reinstall") + ) { + const replaced = yield* takeOverActiveLocalServer(); + if (replaced) { + console.log( + `Replacing running Executor ${replaced.kind} at ${replaced.connection.origin} (pid ${replaced.pid})...`, + ); + } + } + + const path = yield* PlatformPath.Path; + const dataDir = resolveExecutorDataDir(path); + const origin = supervisedServiceOrigin(port); + console.log("Installing Executor as a background service..."); + console.log(`Service manager: ${serviceManagerName(backend.platform)}`); + console.log(`Web UI: ${origin}`); + console.log(`Data directory: ${dataDir}`); + console.log(`Logs: ${path.join(dataDir, "logs")}`); + console.log(""); + console.log("Writing the service definition and starting Executor..."); + + if (backend.platform === "win32") { + yield* clearUnmanifestedWindowsExecutorListener({ port, origin }); + } + + // The unit carries no secret: the supervised daemon mints/loads its bearer + // from auth.json (under EXECUTOR_DATA_DIR) on first boot, and clients read + // the same file — so reachability is the credential-free /api/health probe. + yield* backend.install({ executablePath: process.execPath, port, version: CLI_VERSION }); + + console.log(`Waiting for Executor to publish its service manifest at ${origin}...`); + const reachable = yield* waitForReachable({ + check: hasReachableCliDaemonManifest({ + origin, + version: CLI_VERSION, + }), + timeoutMs: SERVICE_BOOT_TIMEOUT_MS, + intervalMs: DAEMON_BOOT_POLL_MS, + }); + if (!reachable) { + return yield* Effect.fail( + new Error( + [ + `Installed ${SERVICE_LABEL} but it did not publish a reachable server manifest for ${origin} within ${SERVICE_BOOT_TIMEOUT_MS / 1000}s.`, + `Check ~/.executor/logs/daemon.error.log and \`${cliPrefix} service status\`.`, + ].join("\n"), + ), + ); + } + + console.log(`Executor is now running as a background service at ${origin}.`); + console.log("It keeps serving after you quit the app and restarts on login."); + console.log(`Open it in your browser, already signed in, with: ${cliPrefix} web`); + }); + +const serviceInstallCommand = Command.make( + "install", + { + port: servicePortOption(), + }, + ({ port }) => installService(port, "service install"), +).pipe( + Command.withDescription("Install and start Executor as an OS-supervised background service"), +); + +const serviceUninstallCommand = Command.make("uninstall", {}, () => + Effect.gen(function* () { + const backend = getServiceBackend(); + const activeBefore = yield* readActiveLocalServerManifest().pipe( + Effect.catchCause(() => Effect.succeed(null)), + ); + const servicePort = activeBefore + ? (portFromOrigin(activeBefore.connection.origin) ?? DEFAULT_SERVICE_PORT) + : DEFAULT_SERVICE_PORT; + const status = backend.automated + ? yield* backend.status().pipe(Effect.catchCause(() => Effect.succeed(null))) + : null; + yield* backend.uninstall(); + const stopped = yield* takeOverActiveLocalServer({ onlyKind: "cli-daemon" }); + if (stopped) { + console.log( + `Stopped running Executor daemon at ${stopped.connection.origin} (pid ${stopped.pid}).`, + ); + } else if (backend.platform === "win32" && status?.registered) { + const stoppedPids = yield* stopWindowsExecutorListenersOnPort(servicePort); + if (stoppedPids.length > 0) { + console.log(`Stopped orphaned Executor daemon pid(s): ${stoppedPids.join(", ")}.`); + } + } + console.log("Executor background service uninstalled."); + }), +).pipe(Command.withDescription("Stop and remove the OS-supervised background service")); + +const serviceStatusCommand = Command.make("status", {}, () => + Effect.gen(function* () { + const backend = getServiceBackend(); + const status = yield* backend.status(); + // Tolerate a registered-but-unreachable manifest here — status shouldn't throw. + const active = yield* readActiveLocalServerManifest().pipe( + Effect.catchCause(() => Effect.succeed(null)), + ); + console.log(`Platform: ${status.platform}`); + console.log(`Registered: ${status.registered ? "yes" : "no"}`); + console.log( + `Running: ${status.running ? "yes" : "no"}${status.pid ? ` (pid ${status.pid})` : ""}`, + ); + if (active) { + console.log(`Serving: ${active.connection.origin} (${active.kind}, pid ${active.pid})`); + // Version drift: the running daemon was launched by the binary the unit + // points at. If that differs from this CLI, an upgrade left the unit + // pointing at an older binary — reinstall to repoint + restart. + if (active.owner.version && active.owner.version !== CLI_VERSION) { + console.log( + `Drift: running ${active.owner.version}, current ${CLI_VERSION} — run \`${cliPrefix} service install\` to upgrade.`, + ); + } + } + for (const line of status.detail) console.log(line); + }), +).pipe(Command.withDescription("Show the OS-supervised service status")); + +const serviceRestartCommand = Command.make("restart", {}, () => + Effect.gen(function* () { + const backend = getServiceBackend(); + yield* backend.restart(); + console.log("Executor background service restarted."); + }), +).pipe(Command.withDescription("Restart the OS-supervised background service")); + +const serviceCommand = Command.make("service").pipe( + Command.withSubcommands([ + serviceInstallCommand, + serviceUninstallCommand, + serviceStatusCommand, + serviceRestartCommand, + ] as const), + Command.withDescription("Manage the OS-supervised background service"), +); + +const installCommand = Command.make( + "install", + { + port: servicePortOption(), + }, + ({ port }) => installService(port, "install"), +).pipe( + Command.withDescription("Install and start Executor as an OS-supervised background service"), +); + // --------------------------------------------------------------------------- // Root command // --------------------------------------------------------------------------- +/** + * Open a URL in the user's default browser. Best-effort: if the platform opener + * isn't found (e.g. headless Linux without xdg-open) we swallow the error — the + * URL is always printed first, so the user can copy it manually. + */ +const openInBrowser = (url: string): Effect.Effect => + Effect.sync(() => { + const [cmd, args]: readonly [string, ReadonlyArray] = + process.platform === "darwin" + ? ["open", [url]] + : process.platform === "win32" + ? ["cmd", ["/c", "start", "", url]] + : ["xdg-open", [url]]; + // best-effort: ignore failures (e.g. no opener on headless Linux). The URL + // was printed above for manual open; execFile's callback absorbs the error, + // so there's no unhandled 'error' event to crash the CLI. + execFile(cmd, [...args], () => {}); + }); + +const printNoRunningLocalWebApp = (): void => { + console.log("Executor is not running."); + console.log(""); + console.log("Install and start the background service:"); + console.log(` ${cliPrefix} install`); + console.log(""); + console.log("Then open the web UI:"); + console.log(` ${cliPrefix} web`); + console.log(""); + console.log("For a temporary foreground server:"); + console.log(` ${cliPrefix} web --foreground`); +}; + +const openRunningLocalWebApp = (): Effect.Effect< + void, + never, + FileSystem.FileSystem | PlatformPath.Path +> => + Effect.gen(function* () { + const manifest = yield* readActiveLocalServerManifest().pipe(Effect.orElseSucceed(() => null)); + if (!manifest) { + printNoRunningLocalWebApp(); + return; + } + const { origin, auth } = manifest.connection; + const token = auth?.kind === "bearer" ? auth.token : undefined; + const url = token ? `${origin}/?_token=${token}` : origin; + console.log(`Opening ${url}`); + yield* openInBrowser(url); + }); + +/** + * `executor open` — the friendly way back in. Reads the running local server's + * manifest and opens the browser straight to its `?_token=` URL, so the user + * never has to copy a bearer token out of a terminal or auth.json by hand. + */ +const openCommand = Command.make("open", {}, () => openRunningLocalWebApp()).pipe( + Command.withDescription("Open the running Executor web app in your browser, already signed in"), +); + const root = Command.make("executor").pipe( Command.withSubcommands([ callCommand, resumeCommand, toolsCommand, + installCommand, + loginCommand, + logoutCommand, + whoamiCommand, serverCommand, webCommand, daemonCommand, + serviceCommand, mcpCommand, + openCommand, ] as const), Command.withDescription("Executor local CLI"), ); diff --git a/apps/cli/src/server-connection.test.ts b/apps/cli/src/server-connection.test.ts index ff6033d023..d0f41b6fc5 100644 --- a/apps/cli/src/server-connection.test.ts +++ b/apps/cli/src/server-connection.test.ts @@ -43,16 +43,13 @@ describe("CLI server connection", () => { }); }); - it("supports desktop-style basic auth for local server connections", () => { + it("reads a local server bearer token from EXECUTOR_AUTH_TOKEN", () => { const connection = parseCliExecutorServerConnection("http://127.0.0.1:4789", { - EXECUTOR_AUTH_PASSWORD: "desktop-password", + EXECUTOR_AUTH_TOKEN: "desktop-token", }); - expect(connection.auth).toEqual({ - kind: "basic", - username: "executor", - password: "desktop-password", - }); + expect(connection.auth).toEqual({ kind: "bearer", token: "desktop-token" }); + // A connection carrying explicit auth is not an auto-startable local daemon. expect(canAutoStartCliServerConnection(connection)).toBe(false); }); @@ -65,23 +62,12 @@ describe("CLI server connection", () => { expect(canAutoStartCliServerConnection(connection)).toBe(false); }); - it("prefers bearer auth for hosted connections and basic auth for local connections", () => { - const hosted = parseCliExecutorServerConnection("https://executor.example", { - EXECUTOR_API_KEY: "key_123", - EXECUTOR_AUTH_PASSWORD: "desktop-password", - }); - expect(hosted.auth).toEqual({ kind: "bearer", token: "key_123" }); - - const local = parseCliExecutorServerConnection("http://127.0.0.1:4789", { + it("prefers EXECUTOR_API_KEY over EXECUTOR_AUTH_TOKEN", () => { + const connection = parseCliExecutorServerConnection("https://executor.example", { EXECUTOR_API_KEY: "key_123", - EXECUTOR_AUTH_PASSWORD: "desktop-password", - EXECUTOR_AUTH_USERNAME: "rhys", - }); - expect(local.auth).toEqual({ - kind: "basic", - username: "rhys", - password: "desktop-password", + EXECUTOR_AUTH_TOKEN: "token_456", }); + expect(connection.auth).toEqual({ kind: "bearer", token: "key_123" }); }); it("attaches implicit local requests to the active local owner", () => { @@ -94,7 +80,7 @@ describe("CLI server connection", () => { dataDir: "/tmp/executor", scopeDir: "/tmp/executor", connection: parseCliExecutorServerConnection("http://127.0.0.1:4789", { - EXECUTOR_AUTH_PASSWORD: "desktop-password", + EXECUTOR_AUTH_TOKEN: "desktop-token", }), owner: { client: "desktop" as const, @@ -114,9 +100,8 @@ describe("CLI server connection", () => { connection: { origin: "http://127.0.0.1:4789", auth: { - kind: "basic", - username: "executor", - password: "desktop-password", + kind: "bearer", + token: "desktop-token", }, }, }); @@ -132,7 +117,7 @@ describe("CLI server connection", () => { dataDir: "/tmp/executor", scopeDir: "/tmp/executor", connection: parseCliExecutorServerConnection("http://127.0.0.1:4789", { - EXECUTOR_AUTH_PASSWORD: "desktop-password", + EXECUTOR_AUTH_TOKEN: "desktop-token", }), owner: { client: "desktop" as const, diff --git a/apps/cli/src/server-connection.ts b/apps/cli/src/server-connection.ts index 3845e2ea20..bb1eddaed3 100644 --- a/apps/cli/src/server-connection.ts +++ b/apps/cli/src/server-connection.ts @@ -1,5 +1,4 @@ import { - DEFAULT_EXECUTOR_SERVER_USERNAME, normalizeExecutorServerConnection, type ExecutorLocalServerManifest, type ExecutorServerAuth, @@ -7,43 +6,15 @@ import { } from "@executor-js/sdk/shared"; import { canAutoStartLocalDaemonForHost } from "./daemon"; -const readCliBasicServerAuth = ( - env: Record = process.env, -): ExecutorServerAuth | undefined => { - const password = env.EXECUTOR_AUTH_PASSWORD; - if (!password) return undefined; - return { - kind: "basic", - username: env.EXECUTOR_AUTH_USERNAME ?? DEFAULT_EXECUTOR_SERVER_USERNAME, - password, - }; -}; - -const readCliBearerServerAuth = ( - env: Record = process.env, -): ExecutorServerAuth | undefined => { - const token = env.EXECUTOR_API_KEY ?? env.EXECUTOR_AUTH_TOKEN; - if (!token) return undefined; - return { kind: "bearer", token }; -}; - +// Bearer is the only credential the CLI derives from the environment: a hosted +// API key (`EXECUTOR_API_KEY`) or a local/desktop server's bearer token +// (`EXECUTOR_AUTH_TOKEN`). Local servers publish their token in the manifest, so +// the env override is mainly for pointing the CLI at a remote instance. export const readCliServerAuth = ( env: Record = process.env, -): ExecutorServerAuth | undefined => readCliBearerServerAuth(env) ?? readCliBasicServerAuth(env); - -const readCliServerAuthForConnection = ( - connection: ExecutorServerConnection, - env: Record = process.env, ): ExecutorServerAuth | undefined => { - const bearer = readCliBearerServerAuth(env); - const basic = readCliBasicServerAuth(env); - const protocol = new URL(connection.origin).protocol; - - if (protocol === "https:") { - return bearer ?? basic; - } - - return basic ?? bearer; + const token = env.EXECUTOR_API_KEY ?? env.EXECUTOR_AUTH_TOKEN; + return token ? { kind: "bearer", token } : undefined; }; export const parseCliExecutorServerConnection = ( @@ -55,7 +26,7 @@ export const parseCliExecutorServerConnection = ( }); return normalizeExecutorServerConnection({ ...connection, - auth: readCliServerAuthForConnection(connection, env), + auth: readCliServerAuth(env), }); }; @@ -67,12 +38,16 @@ export const withCliServerAuthFallback = ( ? connection : normalizeExecutorServerConnection({ ...connection, - auth: readCliServerAuthForConnection(connection, env), + auth: readCliServerAuth(env), }); export const canAutoStartCliServerConnection = (connection: ExecutorServerConnection): boolean => { if (connection.kind !== "http") return false; - if (connection.auth?.kind === "basic") return false; + // A stored credential (basic password, bearer key, or an oauth device-login + // token) means the user is targeting an EXISTING authenticated server, never + // spawn a local daemon for it, even when it happens to be on http://localhost + // (e.g. a hosted server reached over a tunnel, or a local e2e cloud server). + if (connection.auth) return false; const url = new URL(connection.origin); return url.protocol === "http:" && canAutoStartLocalDaemonForHost(url.hostname); }; diff --git a/apps/cli/src/server-profile.ts b/apps/cli/src/server-profile.ts index 9accf979fa..dd8df30855 100644 --- a/apps/cli/src/server-profile.ts +++ b/apps/cli/src/server-profile.ts @@ -52,6 +52,14 @@ const PersistedAuth = Schema.Union([ kind: Schema.Literal("bearer"), token: Schema.String, }), + Schema.Struct({ + kind: Schema.Literal("oauth"), + accessToken: Schema.String, + refreshToken: Schema.optional(Schema.String), + expiresAt: Schema.optional(Schema.Number), + tokenEndpoint: Schema.optional(Schema.String), + clientId: Schema.optional(Schema.String), + }), ]); const PersistedConnection = Schema.Struct({ diff --git a/apps/cli/src/service.test.ts b/apps/cli/src/service.test.ts new file mode 100644 index 0000000000..ada28ef698 --- /dev/null +++ b/apps/cli/src/service.test.ts @@ -0,0 +1,172 @@ +import { describe, expect, it } from "@effect/vitest"; + +import { + cmdSetValue, + generateLaunchdPlist, + generateSystemdUnit, + generateWindowsDaemonWrapper, + generateWindowsRegisterScript, + generateWindowsStopExecutorListenersScript, + getServiceBackend, +} from "./service"; + +describe("service unit generation", () => { + const launchdInput = { + label: "sh.executor.daemon", + programArguments: [ + "/Applications/Executor.app/Contents/Resources/executor/executor", + "daemon", + "run", + "--foreground", + "--port", + "4789", + "--hostname", + "127.0.0.1", + ], + environment: { + EXECUTOR_SUPERVISED: "1", + EXECUTOR_DATA_DIR: "/Users/x/.executor", + EXECUTOR_SERVICE_VERSION: "1.5.10", + PATH: "/opt/homebrew/bin:/usr/bin", + }, + stdoutPath: "/Users/x/.executor/logs/daemon.log", + stderrPath: "/Users/x/.executor/logs/daemon.error.log", + workingDirectory: "/Users/x/.executor", + }; + + it("renders a launchd plist that restarts on crash but not clean stop", () => { + const plist = generateLaunchdPlist(launchdInput); + expect(plist).toContain(''); + expect(plist).toContain("Label"); + expect(plist).toContain("sh.executor.daemon"); + expect(plist).toMatch(/RunAtLoad<\/key>\s*/); + // KeepAlive => restart only on non-zero/crash exit, not on a clean bootout. + expect(plist).toContain("KeepAlive"); + expect(plist).toContain("SuccessfulExit"); + expect(plist).toMatch(/SuccessfulExit<\/key>\s*/); + expect(plist).toContain("ProcessType"); + expect(plist).toContain("Background"); + expect(plist).toContain("--foreground"); + expect(plist).toContain("EXECUTOR_SUPERVISED"); + expect(plist).toContain("/Users/x/.executor/logs/daemon.error.log"); + }); + + it("never leaks the auth password into the unit", () => { + const plist = generateLaunchdPlist(launchdInput); + // No secret in the unit — the daemon reads the bearer from auth.json at boot. + expect(plist).not.toContain("EXECUTOR_AUTH_PASSWORD"); + }); + + it("xml-escapes environment values", () => { + const plist = generateLaunchdPlist({ + ...launchdInput, + environment: { PATH: "a&b\"d'" }, + }); + expect(plist).toContain("a&b<c>"d'"); + expect(plist).not.toMatch(/a&b/); + }); + + it("renders a systemd --user unit with crash-only restart", () => { + const unit = generateSystemdUnit({ + execStart: [ + "/usr/local/bin/executor", + "daemon", + "run", + "--foreground", + "--port", + "4789", + "--hostname", + "127.0.0.1", + ], + environment: { EXECUTOR_SUPERVISED: "1", EXECUTOR_DATA_DIR: "/home/x/.executor" }, + workingDirectory: "/home/x/.executor", + stdoutPath: "/home/x/.executor/logs/daemon.log", + stderrPath: "/home/x/.executor/logs/daemon.error.log", + }); + expect(unit).toContain( + "ExecStart=/usr/local/bin/executor daemon run --foreground --port 4789 --hostname 127.0.0.1", + ); + expect(unit).toContain("Restart=on-failure"); + expect(unit).toContain("WantedBy=default.target"); + expect(unit).toContain("Environment=EXECUTOR_SUPERVISED=1"); + expect(unit).not.toContain("EXECUTOR_AUTH_PASSWORD"); + }); + + it("bakes the supervised env into the Windows wrapper .cmd", () => { + const wrapper = generateWindowsDaemonWrapper( + { + executablePath: "C:\\Program Files\\Executor\\executor.exe", + port: 4789, + version: "1.5.10", + }, + "C:\\Users\\x\\.executor", + "C:\\Users\\x\\.executor\\logs", + ); + // Task Scheduler can't set env, so it rides as `set` lines in the wrapper. + expect(wrapper).toContain('set "EXECUTOR_SUPERVISED=1"'); + expect(wrapper).toContain('set "EXECUTOR_DATA_DIR=C:\\Users\\x\\.executor"'); + expect(wrapper).toContain( + '"C:\\Program Files\\Executor\\executor.exe" daemon run --foreground --port 4789 --hostname 127.0.0.1', + ); + expect(wrapper).toContain('1>> "C:\\Users\\x\\.executor\\logs\\daemon.log"'); + // No secret in the wrapper — the daemon reads the bearer from auth.json at boot. + expect(wrapper).not.toContain("EXECUTOR_AUTH_PASSWORD"); + }); + + it("sanitizes cmd.exe metacharacters in baked env values (cmdSetValue)", () => { + // A `"` in PATH would close the `set "PATH=..."` quote early and let a + // `& cmd &` fragment run at boot as the user; strip it (illegal in a path + // anyway). A `%` would re-expand against the boot environment; double it. + expect(cmdSetValue('C:\\a" & evil & "C:\\b')).toBe("C:\\a & evil & C:\\b"); + expect(cmdSetValue("C:\\tools\\%LOCALAPPDATA%\\bin")).toBe("C:\\tools\\%%LOCALAPPDATA%%\\bin"); + expect(cmdSetValue("C:\\Program Files\\node")).toBe("C:\\Program Files\\node"); + // The sanitized value, embedded in a `set` line, can't break out of quotes. + expect(`set "PATH=${cmdSetValue('a"&b')}"`).not.toMatch(/"\s*&/); + }); + + it("registers a boot-triggered S4U task (the reboot-survival contract)", () => { + const script = generateWindowsRegisterScript({ + taskName: "ExecutorDaemon", + wrapperPath: "C:\\Users\\x\\.executor\\server-control\\run-daemon.cmd", + userId: "x", + }); + // S4U + AtStartup = run as the user, at boot, no stored password, no logon. + expect(script).toContain("-LogonType S4U"); + expect(script).toContain("New-ScheduledTaskTrigger -AtStartup"); + expect(script).toContain("-RestartCount 3"); + expect(script).toContain("Register-ScheduledTask -TaskName 'ExecutorDaemon'"); + expect(script).toContain("Start-ScheduledTask -TaskName 'ExecutorDaemon'"); + }); + + it("stops orphaned executor.exe listeners without shadowing PowerShell's $PID", () => { + const script = generateWindowsStopExecutorListenersScript(4789); + + expect(script).toContain("Get-NetTCPConnection -LocalPort 4789 -State Listen"); + expect(script).toContain("$listenerPid"); + expect(script).not.toContain("foreach ($pid"); + expect(script).toContain("[string]$process.Name -ieq 'executor.exe'"); + expect(script).toContain('Write-Output ("STOPPED=" + $listenerPid)'); + }); +}); + +describe("service backend dispatch", () => { + it("selects launchd on macOS (automated)", () => { + const backend = getServiceBackend("darwin"); + expect(backend.platform).toBe("darwin"); + expect(backend.automated).toBe(true); + }); + + it("selects systemd on linux (automated)", () => { + expect(getServiceBackend("linux").platform).toBe("linux"); + }); + + it("selects Task Scheduler on windows (automated)", () => { + const backend = getServiceBackend("win32"); + expect(backend.platform).toBe("win32"); + expect(backend.automated).toBe(true); + }); + + it("falls back to unsupported on other platforms", () => { + expect(getServiceBackend("freebsd").platform).toBe("unsupported"); + }); +}); diff --git a/apps/cli/src/service.ts b/apps/cli/src/service.ts new file mode 100644 index 0000000000..d82fdb17e7 --- /dev/null +++ b/apps/cli/src/service.ts @@ -0,0 +1,766 @@ +import { execFile } from "node:child_process"; +import { homedir, userInfo } from "node:os"; +import { FileSystem, Path } from "effect"; +import type { PlatformError } from "effect/PlatformError"; +import * as Effect from "effect/Effect"; + +import { resolveExecutorDataDir } from "./local-server-manifest"; + +// --------------------------------------------------------------------------- +// OS service backends for the supervised Executor daemon. +// +// The long-lived gateway must outlive the GUI app and survive machine restarts. +// That means the OS service manager — not a foreground process — owns its +// lifecycle: launchd on macOS, systemd --user on Linux, Task Scheduler on +// Windows. Each backend registers the SAME running contract: spawn +// ` daemon run --foreground --port

`, bind loopback, write +// `server.json`, and get restarted on crash but not on a clean stop. +// +// macOS (launchd), Linux (systemd --user + lingering), and Windows (Task +// Scheduler S4U/AtStartup) are all reboot-survival verified in real VMs. +// --------------------------------------------------------------------------- + +export const SERVICE_LABEL = "sh.executor.daemon"; + +/** + * The supervised service binds this port by default. It matches the desktop + * connect-card port (4789, not the `executor daemon run` default of 4788) so + * existing desktop MCP-client configs keep resolving. The exact value is + * low-stakes: clients discover the live port from `server.json`. + */ +export const DEFAULT_SERVICE_PORT = 4789; + +export interface ServiceDescriptor { + /** Absolute path to the `executor` binary the service should run. */ + readonly executablePath: string; + readonly port: number; + /** Installing CLI version, baked in for drift detection on upgrade. */ + readonly version: string; +} + +// No secret is part of the descriptor: the supervised daemon mints/loads its +// bearer token from the 0600 `auth.json` (under EXECUTOR_DATA_DIR) on start, and +// clients read the same file. Keeping the secret out of the plist/unit means +// `launchctl print`/`list` and `systemctl cat` never expose it. + +export type ServicePlatform = "darwin" | "linux" | "win32" | "unsupported"; + +export interface ServiceStatus { + readonly platform: ServicePlatform; + /** The OS manager has a unit/plist/task on disk for the service. */ + readonly registered: boolean; + /** The OS manager reports the service currently loaded/active. */ + readonly running: boolean; + readonly pid: number | null; + /** Extra human-readable lines (e.g. manual steps on unsupported platforms). */ + readonly detail: ReadonlyArray; +} + +export interface ServiceBackend { + readonly platform: ServicePlatform; + /** True when this backend actually drives the OS manager (vs. printing steps). */ + readonly automated: boolean; + readonly install: ( + descriptor: ServiceDescriptor, + ) => Effect.Effect; + readonly uninstall: () => Effect.Effect< + void, + Error | PlatformError, + FileSystem.FileSystem | Path.Path + >; + readonly status: () => Effect.Effect< + ServiceStatus, + Error | PlatformError, + FileSystem.FileSystem | Path.Path + >; + readonly restart: () => Effect.Effect; +} + +// --------------------------------------------------------------------------- +// Process helper — run an OS command and capture (stdout, stderr, exit code). +// Resolves on a non-zero exit so callers can branch; fails only when the +// command itself cannot be spawned (e.g. launchctl missing). +// --------------------------------------------------------------------------- + +interface CommandResult { + readonly stdout: string; + readonly stderr: string; + readonly code: number; +} + +const runCommand = ( + cmd: string, + args: ReadonlyArray, + env?: Record, +): Effect.Effect => + Effect.callback((resume) => { + const options = env + ? { encoding: "utf8" as const, env: { ...process.env, ...env } } + : { encoding: "utf8" as const }; + execFile(cmd, [...args], options, (error, stdout, stderr) => { + // A string `code` (ENOENT etc.) means the command could not be spawned. + if (error && typeof (error as { code?: unknown }).code === "string") { + resume( + Effect.fail(new Error(`Failed to run \`${cmd}\`: ${(error as { code: string }).code}`)), + ); + return; + } + const code = + error && typeof (error as { code?: unknown }).code === "number" + ? (error as { code: number }).code + : 0; + resume(Effect.succeed({ stdout: stdout ?? "", stderr: stderr ?? "", code })); + }); + }); + +const currentUid = (): number => { + const getuid = (process as { getuid?: () => number }).getuid; + if (typeof getuid === "function") return getuid.call(process); + return userInfo().uid; +}; + +const xmlEscape = (value: string): string => + value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); + +// --------------------------------------------------------------------------- +// Shared service environment + program args +// --------------------------------------------------------------------------- + +const serviceProgramArguments = (descriptor: ServiceDescriptor): ReadonlyArray => [ + descriptor.executablePath, + "daemon", + "run", + "--foreground", + "--port", + String(descriptor.port), + "--hostname", + "127.0.0.1", +]; + +const serviceEnvironment = ( + descriptor: ServiceDescriptor, + dataDir: string, +): Record => { + const passThroughKeys = [ + "EXECUTOR_CLIENT", + "EXECUTOR_SENTRY_DSN", + "EXECUTOR_SENTRY_RELEASE", + "EXECUTOR_SENTRY_ENVIRONMENT", + "EXECUTOR_RUN_ID", + ] as const; + const passThrough = Object.fromEntries( + passThroughKeys.flatMap((key) => { + const value = process.env[key]; + return value ? [[key, value] as const] : []; + }), + ); + + return { + // Marks the process as OS-supervised so the daemon resolves its bearer token + // from the durable 0600 auth.json (the secret is never in the unit itself). + EXECUTOR_SUPERVISED: "1", + // Pin the data/scope dirs explicitly: launchd/systemd give a minimal + // environment and we never want the daemon to fall back to a different home + // or cwd than the user's singleton local service. + EXECUTOR_DATA_DIR: dataDir, + EXECUTOR_SCOPE_DIR: process.env.EXECUTOR_SCOPE_DIR ?? dataDir, + // Stamp the installing version so `service status` can flag drift after an + // upgrade where the unit still points at an older binary path. + EXECUTOR_SERVICE_VERSION: descriptor.version, + // A launchd/systemd unit starts with a bare PATH — without the user's PATH + // the daemon can't find pyenv/nvm/volta/Homebrew tools that integrations may + // shell out to. `service install` runs from the user's shell, so its own + // PATH is the right one to bake in. (Reference: opencode shell-env capture.) + ...(process.env.PATH ? { PATH: process.env.PATH } : {}), + ...passThrough, + }; +}; + +// --------------------------------------------------------------------------- +// macOS — launchd LaunchAgent (fully built) +// --------------------------------------------------------------------------- + +const launchAgentsDir = (path: Path.Path): string => + path.join(homedir(), "Library", "LaunchAgents"); + +const launchdPlistPath = (path: Path.Path): string => + path.join(launchAgentsDir(path), `${SERVICE_LABEL}.plist`); + +const serviceLogDir = (path: Path.Path): string => path.join(resolveExecutorDataDir(path), "logs"); + +export interface LaunchdPlistOptions { + readonly label: string; + readonly programArguments: ReadonlyArray; + readonly environment: Record; + readonly stdoutPath: string; + readonly stderrPath: string; + readonly workingDirectory: string; +} + +/** + * Render a user LaunchAgent plist. Pure (snapshot-tested). KeepAlive uses + * `SuccessfulExit=false` so launchd restarts the daemon on a crash/non-zero + * exit but leaves it stopped after a clean `bootout` (which sends SIGTERM → + * the daemon exits 0). RunAtLoad starts it on login; ProcessType=Background + * keeps it off the foreground scheduler. + */ +export const generateLaunchdPlist = (options: LaunchdPlistOptions): string => { + const programArgs = options.programArguments + .map((arg) => ` ${xmlEscape(arg)}`) + .join("\n"); + const envEntries = Object.entries(options.environment) + .map( + ([key, value]) => + ` ${xmlEscape(key)}\n ${xmlEscape(value)}`, + ) + .join("\n"); + return ` + + + + Label + ${xmlEscape(options.label)} + ProgramArguments + +${programArgs} + + EnvironmentVariables + +${envEntries} + + RunAtLoad + + KeepAlive + + SuccessfulExit + + + ProcessType + Background + WorkingDirectory + ${xmlEscape(options.workingDirectory)} + StandardOutPath + ${xmlEscape(options.stdoutPath)} + StandardErrorPath + ${xmlEscape(options.stderrPath)} + + +`; +}; + +const parseLaunchctlPid = (printOutput: string): number | null => { + const match = printOutput.match(/\bpid\s*=\s*(\d+)/); + if (!match) return null; + const pid = Number.parseInt(match[1], 10); + return Number.isInteger(pid) && pid > 0 ? pid : null; +}; + +const makeLaunchdBackend = (): ServiceBackend => { + const serviceTarget = (uid: number): string => `gui/${uid}/${SERVICE_LABEL}`; + + return { + platform: "darwin", + automated: true, + install: (descriptor) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const uid = currentUid(); + const dataDir = resolveExecutorDataDir(path); + const logs = serviceLogDir(path); + + yield* fs.makeDirectory(launchAgentsDir(path), { recursive: true }); + yield* fs.makeDirectory(logs, { recursive: true }); + + const plist = generateLaunchdPlist({ + label: SERVICE_LABEL, + programArguments: serviceProgramArguments(descriptor), + environment: serviceEnvironment(descriptor, dataDir), + stdoutPath: path.join(logs, "daemon.log"), + stderrPath: path.join(logs, "daemon.error.log"), + workingDirectory: dataDir, + }); + const plistFile = launchdPlistPath(path); + // 0600: the plist is owner-only. It carries no secret — the daemon reads + // the bearer from auth.json at boot — but stays tight regardless. + yield* fs.writeFileString(plistFile, plist, { mode: 0o600 }); + + // Re-bootstrap cleanly: a stale registration from a prior install would + // make `bootstrap` fail with "service already loaded". `service + // uninstall` also records the label as disabled in launchd's override + // database; clear that before bootstrapping or a reinstall can fail with + // launchctl's generic "Bootstrap failed: 5" error. + yield* runCommand("launchctl", ["bootout", serviceTarget(uid)]).pipe(Effect.ignore); + yield* runCommand("launchctl", ["enable", serviceTarget(uid)]).pipe(Effect.ignore); + const bootstrap = yield* runCommand("launchctl", ["bootstrap", `gui/${uid}`, plistFile]); + if (bootstrap.code !== 0) { + return yield* Effect.fail( + new Error( + `launchctl bootstrap failed (exit ${bootstrap.code}): ${bootstrap.stderr.trim() || bootstrap.stdout.trim()}`, + ), + ); + } + }), + uninstall: () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const uid = currentUid(); + yield* runCommand("launchctl", ["bootout", serviceTarget(uid)]).pipe(Effect.ignore); + yield* runCommand("launchctl", ["disable", serviceTarget(uid)]).pipe(Effect.ignore); + yield* fs.remove(launchdPlistPath(path), { force: true }); + }), + status: () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const uid = currentUid(); + const registered = yield* fs.exists(launchdPlistPath(path)); + const print = yield* runCommand("launchctl", ["print", serviceTarget(uid)]); + const running = print.code === 0; + return { + platform: "darwin" as const, + registered, + running, + pid: running ? parseLaunchctlPid(print.stdout) : null, + detail: [], + }; + }), + restart: () => + Effect.gen(function* () { + const uid = currentUid(); + const result = yield* runCommand("launchctl", ["kickstart", "-k", serviceTarget(uid)]); + if (result.code !== 0) { + return yield* Effect.fail( + new Error( + `launchctl kickstart failed (exit ${result.code}): ${result.stderr.trim() || result.stdout.trim()}`, + ), + ); + } + }), + }; +}; + +// --------------------------------------------------------------------------- +// Linux — systemd --user + lingering (reboot-survival verified in an Ubuntu VM) +// --------------------------------------------------------------------------- + +const systemdUnitDir = (path: Path.Path): string => + path.join(homedir(), ".config", "systemd", "user"); + +const systemdUnitPath = (path: Path.Path): string => + path.join(systemdUnitDir(path), `${SERVICE_LABEL}.service`); + +export interface SystemdUnitOptions { + readonly execStart: ReadonlyArray; + readonly environment: Record; + readonly workingDirectory: string; + readonly stdoutPath: string; + readonly stderrPath: string; +} + +const SYSTEMD_BARE_VALUE = /^[A-Za-z0-9_@%+=:,./-]+$/; + +const systemdQuote = (value: string): string => { + if (SYSTEMD_BARE_VALUE.test(value)) return value; + const escaped = value + .replaceAll("\\", "\\\\") + .replaceAll('"', '\\"') + .replaceAll("\n", "\\n") + .replaceAll("\r", "\\r") + .replaceAll("\t", "\\t"); + return `"${escaped}"`; +}; + +/** Render a systemd --user unit. Pure (snapshot-tested). */ +export const generateSystemdUnit = (options: SystemdUnitOptions): string => { + const execStart = options.execStart.map(systemdQuote).join(" "); + const env = Object.entries(options.environment) + .map(([key, value]) => `Environment=${systemdQuote(`${key}=${value}`)}`) + .join("\n"); + return `[Unit] +Description=Executor supervised daemon +After=default.target + +[Service] +Type=simple +ExecStart=${execStart} +${env} +WorkingDirectory=${systemdQuote(options.workingDirectory)} +StandardOutput=${systemdQuote(`append:${options.stdoutPath}`)} +StandardError=${systemdQuote(`append:${options.stderrPath}`)} +Restart=on-failure +RestartSec=5s + +[Install] +WantedBy=default.target +`; +}; + +const makeSystemdBackend = (): ServiceBackend => { + const unitName = `${SERVICE_LABEL}.service`; + return { + platform: "linux", + automated: true, + install: (descriptor) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dataDir = resolveExecutorDataDir(path); + const logs = serviceLogDir(path); + yield* fs.makeDirectory(systemdUnitDir(path), { recursive: true }); + yield* fs.makeDirectory(logs, { recursive: true }); + const unit = generateSystemdUnit({ + execStart: serviceProgramArguments(descriptor), + environment: serviceEnvironment(descriptor, dataDir), + workingDirectory: dataDir, + stdoutPath: path.join(logs, "daemon.log"), + stderrPath: path.join(logs, "daemon.error.log"), + }); + yield* fs.writeFileString(systemdUnitPath(path), unit, { mode: 0o600 }); + // `systemctl --user` needs XDG_RUNTIME_DIR to reach the user bus. Supply + // it if the caller's environment lacks it (e.g. a non-login shell) so + // install is robust regardless of how it was invoked. + const username = userInfo().username; + const sdEnv = { + XDG_RUNTIME_DIR: process.env.XDG_RUNTIME_DIR ?? `/run/user/${currentUid()}`, + }; + yield* runCommand("systemctl", ["--user", "daemon-reload"], sdEnv).pipe(Effect.ignore); + const enable = yield* runCommand( + "systemctl", + ["--user", "enable", "--now", unitName], + sdEnv, + ); + if (enable.code !== 0) { + return yield* Effect.fail( + new Error( + `systemctl --user enable failed (exit ${enable.code}): ${enable.stderr.trim()}`, + ), + ); + } + // Enable lingering so the user manager — and this enabled service — + // starts at BOOT, not just on login, so the daemon survives a reboot + // unattended (verified in a real Ubuntu VM via loginctl). Best-effort: + // if the platform needs privilege, the service still works for the + // logged-in case and `service status` flags the missing linger. + yield* runCommand("loginctl", ["enable-linger", username], sdEnv).pipe(Effect.ignore); + }), + uninstall: () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const sdEnv = { + XDG_RUNTIME_DIR: process.env.XDG_RUNTIME_DIR ?? `/run/user/${currentUid()}`, + }; + yield* runCommand("systemctl", ["--user", "disable", "--now", unitName], sdEnv).pipe( + Effect.ignore, + ); + yield* fs.remove(systemdUnitPath(path), { force: true }); + yield* runCommand("systemctl", ["--user", "daemon-reload"], sdEnv).pipe(Effect.ignore); + yield* runCommand("loginctl", ["disable-linger", userInfo().username], sdEnv).pipe( + Effect.ignore, + ); + }), + status: () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const sdEnv = { + XDG_RUNTIME_DIR: process.env.XDG_RUNTIME_DIR ?? `/run/user/${currentUid()}`, + }; + const registered = yield* fs.exists(systemdUnitPath(path)); + const active = yield* runCommand("systemctl", ["--user", "is-active", unitName], sdEnv); + const running = active.stdout.trim() === "active"; + const linger = yield* runCommand("loginctl", [ + "show-user", + userInfo().username, + "-p", + "Linger", + "--value", + ]); + const lingerOn = linger.stdout.trim() === "yes"; + return { + platform: "linux" as const, + registered, + running, + pid: null, + detail: lingerOn + ? [] + : [ + "Lingering is off — the daemon won't start until you log in. Run `loginctl enable-linger`.", + ], + }; + }), + restart: () => + Effect.gen(function* () { + const sdEnv = { + XDG_RUNTIME_DIR: process.env.XDG_RUNTIME_DIR ?? `/run/user/${currentUid()}`, + }; + const result = yield* runCommand("systemctl", ["--user", "restart", unitName], sdEnv); + if (result.code !== 0) { + return yield* Effect.fail( + new Error( + `systemctl --user restart failed (exit ${result.code}): ${result.stderr.trim()}`, + ), + ); + } + }), + }; +}; + +// --------------------------------------------------------------------------- +// Windows — Task Scheduler (S4U / AtStartup; reboot-survival verified) +// --------------------------------------------------------------------------- + +/** Scheduled Task name registered for the supervised daemon. */ +export const WINDOWS_TASK_NAME = "ExecutorDaemon"; + +/** + * Make a value safe to embed in a cmd.exe `set "KEY=VALUE"` line. A literal `"` + * would close the quoted argument early — in PATH (built from arbitrary + * installer entries) that lets `& cmd &` fragments execute when Task Scheduler + * runs the wrapper at boot, so strip them (a `"` is illegal in a Windows path + * anyway). A literal `%` is re-expanded by cmd at run time against the boot + * environment, silently diverging from the value captured at install; double it + * so the daemon sees exactly what was captured. + */ +export const cmdSetValue = (value: string): string => + value.replaceAll('"', "").replaceAll("%", "%%"); + +/** + * The batch wrapper the Scheduled Task executes. Task Scheduler has no field + * for environment variables, so the supervised env (EXECUTOR_SUPERVISED, data + * dir, version, PATH) is baked into the wrapper as `set` lines before it execs + * the daemon. stdout/stderr append to the same log files the other backends + * use. CRLF line endings keep it a well-formed `.cmd`. + */ +export const generateWindowsDaemonWrapper = ( + descriptor: ServiceDescriptor, + dataDir: string, + logDir: string, +): string => { + const env = serviceEnvironment(descriptor, dataDir); + const setLines = Object.entries(env).map(([key, value]) => `set "${key}=${cmdSetValue(value)}"`); + const [exe, ...rest] = serviceProgramArguments(descriptor); + const command = `"${exe}" ${rest.join(" ")} 1>> "${logDir}\\daemon.log" 2>> "${logDir}\\daemon.error.log"`; + return ["@echo off", ...setLines, command, ""].join("\r\n"); +}; + +/** Quote a value as a PowerShell single-quoted string literal. */ +const psSingleQuote = (value: string): string => `'${value.replaceAll("'", "''")}'`; + +/** + * PowerShell that registers the daemon as a boot-triggered Scheduled Task. + * + * LogonType=S4U + AtStartup is the Windows equivalent of launchd RunAtLoad and + * systemd lingering: the task runs the daemon AS THE USER, at boot, with no + * stored password and no interactive logon — verified to survive a real reboot + * with no login on a headless host. RestartCount/RestartInterval supply the + * crash-restart half of the contract; ExecutionTimeLimit=0 means "never time + * out a long-running task". Registering a boot task requires an elevated + * (Administrator) shell. + */ +export const generateWindowsRegisterScript = (options: { + readonly taskName: string; + readonly wrapperPath: string; + readonly userId: string; +}): string => + [ + `$action = New-ScheduledTaskAction -Execute ${psSingleQuote(options.wrapperPath)}`, + `$trigger = New-ScheduledTaskTrigger -AtStartup`, + `$principal = New-ScheduledTaskPrincipal -UserId ${psSingleQuote(options.userId)} -LogonType S4U -RunLevel Highest`, + `$settings = New-ScheduledTaskSettingsSet -StartWhenAvailable -RestartCount 3 -RestartInterval (New-TimeSpan -Minutes 1) -ExecutionTimeLimit ([TimeSpan]::Zero)`, + `Register-ScheduledTask -TaskName ${psSingleQuote(options.taskName)} -Action $action -Trigger $trigger -Principal $principal -Settings $settings -Force | Out-Null`, + `Start-ScheduledTask -TaskName ${psSingleQuote(options.taskName)}`, + ].join("\n"); + +/** + * Stop orphaned Windows `executor.exe` listeners on the service port. Task + * Scheduler can report the task as stopped after terminating the `.cmd` action + * while leaving the child `executor.exe` process bound to 4789. That process is + * healthy enough to satisfy `/api/health`, but `executor web` cannot discover it + * once its `server.json` is gone. + */ +export const generateWindowsStopExecutorListenersScript = (port: number): string => + [ + `$connections = @(Get-NetTCPConnection -LocalPort ${port} -State Listen -ErrorAction SilentlyContinue)`, + `$listenerPids = @($connections | Select-Object -ExpandProperty OwningProcess -Unique)`, + `foreach ($listenerPid in $listenerPids) {`, + ` $process = Get-CimInstance Win32_Process -Filter "ProcessId=$listenerPid" -ErrorAction SilentlyContinue`, + ` if ($null -eq $process) { continue }`, + ` $pathName = if ($process.ExecutablePath) { [System.IO.Path]::GetFileName([string]$process.ExecutablePath) } else { '' }`, + ` if ([string]$process.Name -ieq 'executor.exe' -or $pathName -ieq 'executor.exe') {`, + ` Stop-Process -Id $listenerPid -Force -ErrorAction SilentlyContinue`, + ` Write-Output ("STOPPED=" + $listenerPid)`, + ` }`, + `}`, + ].join("\n"); + +/** Run a PowerShell script via -EncodedCommand (sidesteps all shell quoting). */ +const runPowerShell = (script: string): Effect.Effect => + runCommand("powershell.exe", [ + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-EncodedCommand", + Buffer.from(script, "utf16le").toString("base64"), + ]); + +export const stopWindowsExecutorListenersOnPort = ( + port: number, +): Effect.Effect, Error> => + Effect.gen(function* () { + const result = yield* runPowerShell(generateWindowsStopExecutorListenersScript(port)); + if (result.code !== 0) { + const detail = result.stderr.trim() || result.stdout.trim(); + return yield* Effect.fail( + new Error(`Failed to inspect Executor listeners on port ${port}: ${detail}`), + ); + } + return result.stdout.split(/\r?\n/).flatMap((line) => { + const match = /^STOPPED=(\d+)\s*$/.exec(line.trim()); + if (!match) return []; + const pid = Number.parseInt(match[1], 10); + return Number.isInteger(pid) && pid > 0 ? [pid] : []; + }); + }); + +const makeWindowsBackend = (): ServiceBackend => ({ + platform: "win32", + automated: true, + install: (descriptor) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dataDir = resolveExecutorDataDir(path); + const logs = serviceLogDir(path); + const control = path.join(dataDir, "server-control"); + yield* fs.makeDirectory(logs, { recursive: true }); + yield* fs.makeDirectory(control, { recursive: true }); + + const wrapperPath = path.join(control, "run-daemon.cmd"); + yield* fs.writeFileString( + wrapperPath, + generateWindowsDaemonWrapper(descriptor, dataDir, logs), + ); + + const result = yield* runPowerShell( + generateWindowsRegisterScript({ + taskName: WINDOWS_TASK_NAME, + wrapperPath, + userId: userInfo().username, + }), + ); + if (result.code !== 0) { + const detail = result.stderr.trim() || result.stdout.trim(); + const hint = /denied|0x80070005|administrator|elevat/i.test(detail) + ? " Run `executor service install` from an Administrator PowerShell." + : ""; + return yield* Effect.fail( + new Error(`Register-ScheduledTask failed (exit ${result.code}): ${detail}.${hint}`), + ); + } + }), + uninstall: () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + // Tolerate "task not found" (idempotent uninstall), but don't hide a + // failure to even spawn PowerShell — that would leave the task registered + // while we report success. + yield* runPowerShell( + `Stop-ScheduledTask -TaskName ${psSingleQuote(WINDOWS_TASK_NAME)} -ErrorAction SilentlyContinue | Out-Null; ` + + `Unregister-ScheduledTask -TaskName ${psSingleQuote(WINDOWS_TASK_NAME)} -Confirm:$false -ErrorAction SilentlyContinue`, + ).pipe( + Effect.tapError((cause) => + Effect.sync(() => + console.warn( + `Warning: could not remove the ExecutorDaemon scheduled task: ${cause.message}`, + ), + ), + ), + Effect.ignore, + ); + const control = path.join(resolveExecutorDataDir(path), "server-control"); + yield* fs.remove(path.join(control, "run-daemon.cmd"), { force: true }); + }), + status: () => + Effect.gen(function* () { + const result = yield* runPowerShell( + `$t = Get-ScheduledTask -TaskName ${psSingleQuote(WINDOWS_TASK_NAME)} -ErrorAction SilentlyContinue; ` + + `if ($null -eq $t) { 'NONE' } else { 'STATE=' + $t.State }`, + ); + const out = result.stdout.trim(); + if (result.code !== 0 || out === "" || out.includes("NONE")) { + return { + platform: "win32" as const, + registered: false, + running: false, + pid: null, + detail: ["No ExecutorDaemon scheduled task registered. Run `executor service install`."], + }; + } + const state = /STATE=(\w+)/.exec(out)?.[1] ?? "Unknown"; + const running = state === "Running"; + return { + platform: "win32" as const, + registered: true, + running, + pid: null, + detail: running ? [] : [`Scheduled task registered; current state: ${state}.`], + }; + }), + restart: () => + Effect.gen(function* () { + const result = yield* runPowerShell( + `Stop-ScheduledTask -TaskName ${psSingleQuote(WINDOWS_TASK_NAME)} -ErrorAction SilentlyContinue | Out-Null; ` + + `Start-ScheduledTask -TaskName ${psSingleQuote(WINDOWS_TASK_NAME)}`, + ); + if (result.code !== 0) { + return yield* Effect.fail( + new Error( + `Failed to restart ExecutorDaemon task (exit ${result.code}): ${result.stderr.trim()}`, + ), + ); + } + }), +}); + +const makeUnsupportedBackend = (): ServiceBackend => ({ + platform: "unsupported", + automated: false, + install: () => + Effect.fail(new Error(`OS service install is not supported on ${process.platform}.`)), + uninstall: () => + Effect.fail(new Error(`OS service uninstall is not supported on ${process.platform}.`)), + status: () => + Effect.succeed({ + platform: "unsupported" as const, + registered: false, + running: false, + pid: null, + detail: [`OS service management is not supported on ${process.platform}.`], + }), + restart: () => + Effect.fail(new Error(`OS service restart is not supported on ${process.platform}.`)), +}); + +/** Select the service backend for the current OS. */ +export const getServiceBackend = (platform: NodeJS.Platform = process.platform): ServiceBackend => { + switch (platform) { + case "darwin": + return makeLaunchdBackend(); + case "linux": + return makeSystemdBackend(); + case "win32": + return makeWindowsBackend(); + default: + return makeUnsupportedBackend(); + } +}; diff --git a/apps/cli/src/tooling.test.ts b/apps/cli/src/tooling.test.ts index f45fe2d8db..679a487b1a 100644 --- a/apps/cli/src/tooling.test.ts +++ b/apps/cli/src/tooling.test.ts @@ -1,6 +1,81 @@ import { describe, expect, it } from "@effect/vitest"; +import { BunServices } from "@effect/platform-bun"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import * as Effect from "effect/Effect"; -import { sanitizeCliOutputText, shellQuoteArg } from "./tooling"; +import { resolveToolInvocation, sanitizeCliOutputText, shellQuoteArg } from "./tooling"; + +const withTmp = (body: (dir: string) => Effect.Effect): Effect.Effect => + Effect.acquireUseRelease( + Effect.sync(() => mkdtempSync(join(tmpdir(), "exec-call-"))), + body, + (dir) => Effect.sync(() => rmSync(dir, { recursive: true, force: true })), + ); + +describe("resolveToolInvocation", () => { + it.effect("reads the JSON arg from a file via @path", () => + withTmp((dir) => + Effect.gen(function* () { + const file = join(dir, "input.json"); + writeFileSync(file, '{"title":"Hi","n":2}'); + const result = yield* resolveToolInvocation({ + rawPathParts: ["github", "issues", "create", `@${file}`], + }); + expect(result.path).toBe("github.issues.create"); + expect(result.args).toEqual({ title: "Hi", n: 2 }); + }), + ).pipe(Effect.provide(BunServices.layer)), + ); + + it.effect("still accepts inline JSON (slice condition regression)", () => + Effect.gen(function* () { + const result = yield* resolveToolInvocation({ + rawPathParts: ["github", "issues", "create", '{"title":"Hi"}'], + }); + expect(result.path).toBe("github.issues.create"); + expect(result.args).toEqual({ title: "Hi" }); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.effect("treats a path with no trailing JSON as empty args", () => + Effect.gen(function* () { + const result = yield* resolveToolInvocation({ rawPathParts: ["github", "issues", "list"] }); + expect(result.path).toBe("github.issues.list"); + expect(result.args).toEqual({}); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.effect("fails with a path-bearing message when the @file is missing", () => + Effect.gen(function* () { + const error = yield* Effect.flip( + resolveToolInvocation({ rawPathParts: ["x", "@/no/such/file.json"] }), + ); + expect(error.message).toContain("/no/such/file.json"); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.effect("rejects an @file whose content is not a JSON object", () => + withTmp((dir) => + Effect.gen(function* () { + const file = join(dir, "bad.json"); + writeFileSync(file, "[1,2,3]"); + const error = yield* Effect.flip( + resolveToolInvocation({ rawPathParts: ["x", `@${file}`] }), + ); + expect(error.message).toContain("must contain a JSON object"); + }), + ).pipe(Effect.provide(BunServices.layer)), + ); + + it.effect("rejects a bare '@' with no path", () => + Effect.gen(function* () { + const error = yield* Effect.flip(resolveToolInvocation({ rawPathParts: ["x", "@"] })); + expect(error.message).toContain("requires a file path"); + }).pipe(Effect.provide(BunServices.layer)), + ); +}); describe("shellQuoteArg", () => { it("quotes single quotes without breaking the shell argument", () => { diff --git a/apps/cli/src/tooling.ts b/apps/cli/src/tooling.ts index aa671981f3..632054f44a 100644 --- a/apps/cli/src/tooling.ts +++ b/apps/cli/src/tooling.ts @@ -1,3 +1,4 @@ +import { FileSystem } from "effect"; import * as Effect from "effect/Effect"; const isRecord = (value: unknown): value is Record => @@ -145,6 +146,65 @@ export const parseJsonObjectInput = ( return parsed; }); +export const resolveToolInvocation = (input: { + rawPathParts: ReadonlyArray; +}): Effect.Effect<{ path: string; args: Record }, Error, FileSystem.FileSystem> => + Effect.gen(function* () { + if (!Array.isArray(input.rawPathParts)) { + return yield* Effect.fail( + new Error("Invalid tool invocation: path parts were not parsed as an array"), + ); + } + + // The trailing argument carries the tool's JSON input — either inline + // (`'{"k":"v"}'`) or, via `@path`, read from a file. The file form is the + // cross-platform equivalent of the Unix `"$(cat file)"`: it dodges shell + // quote-mangling for large or double-quote-heavy payloads (notably + // PowerShell, which corrupts inline JSON passed to a native binary). + const rawLast = input.rawPathParts.at(-1)?.trim(); + const isFileArg = rawLast !== undefined && rawLast.startsWith("@"); + const filePath = isFileArg ? rawLast.slice(1) : undefined; + if (isFileArg && filePath === "") { + return yield* Effect.fail( + new Error("Tool input '@' requires a file path, e.g. `@./input.json`."), + ); + } + const jsonText = + filePath !== undefined + ? (yield* (yield* FileSystem.FileSystem).readFileString(filePath).pipe( + // Surface a path-bearing message instead of a raw ENOENT PlatformError. + Effect.mapError( + (cause) => new Error(`Cannot read tool input file '${filePath}': ${cause}`), + ), + )).trim() + : rawLast; + const hasInlineJsonArg = jsonText !== undefined && jsonText.startsWith("{"); + if (isFileArg && !hasInlineJsonArg) { + return yield* Effect.fail( + new Error(`Tool input file '${filePath}' must contain a JSON object starting with '{'.`), + ); + } + const pathParts = + isFileArg || hasInlineJsonArg ? input.rawPathParts.slice(0, -1) : input.rawPathParts; + const args = hasInlineJsonArg ? yield* parseJsonObjectInput(jsonText) : {}; + + if (pathParts.some((part) => part.trim().startsWith("-"))) { + return yield* Effect.fail( + new Error( + "Tool invocation no longer accepts flags. Use: executor call '{...json...}'", + ), + ); + } + + const path = yield* Effect.try({ + try: () => buildToolPath(pathParts), + catch: (cause) => + cause instanceof Error ? cause : new Error(`Invalid tool path: ${String(cause)}`), + }); + + return { path, args }; + }); + export const extractExecutionResult = (structured: unknown): unknown => { if (!isRecord(structured) || !("result" in structured)) { return null; diff --git a/apps/cloud/CHANGELOG.md b/apps/cloud/CHANGELOG.md index 00a49dad92..5c80fd11f7 100644 --- a/apps/cloud/CHANGELOG.md +++ b/apps/cloud/CHANGELOG.md @@ -1,5 +1,142 @@ # @executor-js/cloud +## 1.4.35 + +### Patch Changes + +- Updated dependencies []: + - @executor-js/sdk@1.5.17 + - @executor-js/runtime-quickjs@1.5.17 + - @executor-js/execution@1.5.17 + - @executor-js/plugin-graphql@1.5.17 + - @executor-js/plugin-mcp@1.5.17 + - @executor-js/plugin-openapi@1.5.17 + - @executor-js/api@1.4.37 + - @executor-js/vite-plugin@0.0.34 + - @executor-js/cloudflare@0.0.16 + - @executor-js/host-mcp@1.4.4 + - @executor-js/runtime-dynamic-worker@1.4.4 + - @executor-js/plugin-google@1.5.16 + - @executor-js/plugin-microsoft@1.5.16 + - @executor-js/plugin-workos-vault@0.0.2 + - @executor-js/react@1.4.37 + +## 1.4.34 + +### Patch Changes + +- Updated dependencies []: + - @executor-js/sdk@1.5.16 + - @executor-js/runtime-quickjs@1.5.16 + - @executor-js/execution@1.5.16 + - @executor-js/plugin-graphql@1.5.16 + - @executor-js/plugin-mcp@1.5.16 + - @executor-js/plugin-openapi@1.5.16 + - @executor-js/api@1.4.36 + - @executor-js/vite-plugin@0.0.33 + - @executor-js/cloudflare@0.0.15 + - @executor-js/host-mcp@1.4.4 + - @executor-js/runtime-dynamic-worker@1.4.4 + - @executor-js/plugin-google@1.5.15 + - @executor-js/plugin-microsoft@1.5.15 + - @executor-js/plugin-workos-vault@0.0.2 + - @executor-js/react@1.4.36 + +## 1.4.33 + +### Patch Changes + +- Updated dependencies []: + - @executor-js/sdk@1.5.15 + - @executor-js/plugin-openapi@1.5.15 + - @executor-js/plugin-mcp@1.5.15 + - @executor-js/api@1.4.35 + - @executor-js/execution@1.5.15 + - @executor-js/vite-plugin@0.0.32 + - @executor-js/cloudflare@0.0.14 + - @executor-js/host-mcp@1.4.4 + - @executor-js/runtime-dynamic-worker@1.4.4 + - @executor-js/plugin-graphql@1.5.15 + - @executor-js/plugin-workos-vault@0.0.2 + - @executor-js/react@1.4.35 + - @executor-js/runtime-quickjs@1.5.15 + +## 1.4.32 + +### Patch Changes + +- Updated dependencies []: + - @executor-js/sdk@1.5.14 + - @executor-js/runtime-quickjs@1.5.14 + - @executor-js/execution@1.5.14 + - @executor-js/plugin-graphql@1.5.14 + - @executor-js/plugin-mcp@1.5.14 + - @executor-js/plugin-openapi@1.5.14 + - @executor-js/api@1.4.34 + - @executor-js/vite-plugin@0.0.31 + - @executor-js/cloudflare@0.0.13 + - @executor-js/host-mcp@1.4.4 + - @executor-js/runtime-dynamic-worker@1.4.4 + - @executor-js/plugin-workos-vault@0.0.2 + - @executor-js/react@1.4.34 + +## 1.4.31 + +### Patch Changes + +- Updated dependencies [[`8244fee`](https://github.com/RhysSullivan/executor/commit/8244fee567cb2408650fc1fcd1a9e72cedc2f683)]: + - @executor-js/execution@1.5.13 + - @executor-js/api@1.4.33 + - @executor-js/cloudflare@0.0.12 + - @executor-js/host-mcp@1.4.4 + - @executor-js/runtime-dynamic-worker@1.4.4 + - @executor-js/plugin-graphql@1.5.13 + - @executor-js/plugin-mcp@1.5.13 + - @executor-js/plugin-openapi@1.5.13 + - @executor-js/react@1.4.33 + - @executor-js/plugin-workos-vault@0.0.2 + - @executor-js/sdk@1.5.13 + - @executor-js/runtime-quickjs@1.5.13 + - @executor-js/vite-plugin@0.0.30 + +## 1.4.30 + +### Patch Changes + +- Updated dependencies []: + - @executor-js/sdk@1.5.12 + - @executor-js/runtime-quickjs@1.5.12 + - @executor-js/execution@1.5.12 + - @executor-js/plugin-graphql@1.5.12 + - @executor-js/plugin-mcp@1.5.12 + - @executor-js/plugin-openapi@1.5.12 + - @executor-js/api@1.4.32 + - @executor-js/vite-plugin@0.0.29 + - @executor-js/cloudflare@0.0.11 + - @executor-js/host-mcp@1.4.4 + - @executor-js/runtime-dynamic-worker@1.4.4 + - @executor-js/plugin-workos-vault@0.0.2 + - @executor-js/react@1.4.32 + +## 1.4.29 + +### Patch Changes + +- Updated dependencies []: + - @executor-js/sdk@1.5.11 + - @executor-js/runtime-quickjs@1.5.11 + - @executor-js/execution@1.5.11 + - @executor-js/plugin-graphql@1.5.11 + - @executor-js/plugin-mcp@1.5.11 + - @executor-js/plugin-openapi@1.5.11 + - @executor-js/api@1.4.31 + - @executor-js/vite-plugin@0.0.28 + - @executor-js/cloudflare@0.0.10 + - @executor-js/host-mcp@1.4.4 + - @executor-js/runtime-dynamic-worker@1.4.4 + - @executor-js/plugin-workos-vault@0.0.2 + - @executor-js/react@1.4.31 + ## 1.4.28 ### Patch Changes diff --git a/apps/cloud/drizzle/0004_nervous_quasar.sql b/apps/cloud/drizzle/0004_nervous_quasar.sql new file mode 100644 index 0000000000..3cb5dd2477 --- /dev/null +++ b/apps/cloud/drizzle/0004_nervous_quasar.sql @@ -0,0 +1 @@ +ALTER TABLE "organizations" ALTER COLUMN "slug" SET NOT NULL; \ No newline at end of file diff --git a/apps/cloud/drizzle/0005_integration_descriptions.sql b/apps/cloud/drizzle/0005_integration_descriptions.sql new file mode 100644 index 0000000000..a7da479a2a --- /dev/null +++ b/apps/cloud/drizzle/0005_integration_descriptions.sql @@ -0,0 +1,10 @@ +ALTER TABLE "integration" ALTER COLUMN "description" DROP NOT NULL;--> statement-breakpoint +ALTER TABLE "connection" ADD COLUMN "description" text;--> statement-breakpoint +ALTER TABLE "connection" ADD COLUMN "tools_synced_at" bigint;--> statement-breakpoint +ALTER TABLE "integration" ADD COLUMN "name" text;--> statement-breakpoint +ALTER TABLE "integration" ADD COLUMN "config_revised_at" bigint;--> statement-breakpoint +-- Pre-split rows kept the display name in `description`. Move it to `name` +-- (its proper home), then clear `description` so it no longer carries a +-- duplicated title — it now means an actual, optional description. +UPDATE "integration" SET "name" = "description" WHERE "name" IS NULL;--> statement-breakpoint +UPDATE "integration" SET "description" = NULL; diff --git a/apps/cloud/drizzle/0006_google_openapi_ownership.sql b/apps/cloud/drizzle/0006_google_openapi_ownership.sql new file mode 100644 index 0000000000..ef17d7290e --- /dev/null +++ b/apps/cloud/drizzle/0006_google_openapi_ownership.sql @@ -0,0 +1,86 @@ +CREATE TEMP TABLE "__google_openapi_integrations" AS +SELECT + "tenant", + "slug", + "config"::jsonb ->> 'specHash' AS "spec_hash" +FROM "integration" +WHERE "plugin_id" = 'openapi' + AND "config" IS NOT NULL + AND jsonb_typeof("config"::jsonb -> 'googleDiscoveryUrls') = 'array';--> statement-breakpoint +INSERT INTO "blob" ("namespace", "key", "value", "row_id", "id") +SELECT + 'o:' || g."tenant" || '/google' AS "namespace", + b."key", + b."value", + 'mig_google_blob_' || md5('o:' || g."tenant" || '/google' || ':' || b."key") AS "row_id", + '["' || 'o:' || g."tenant" || '/google' || '","' || b."key" || '"]' AS "id" +FROM "__google_openapi_integrations" g +JOIN "blob" b + ON b."namespace" = 'o:' || g."tenant" || '/openapi' + AND b."key" = 'spec/' || g."spec_hash" +WHERE g."spec_hash" IS NOT NULL + AND g."spec_hash" <> '' +ON CONFLICT ("id") DO NOTHING;--> statement-breakpoint +INSERT INTO "plugin_storage" + ("tenant", "owner", "subject", "plugin_id", "collection", "key", "data", "created_at", "updated_at", "row_id") +SELECT + ps."tenant", + ps."owner", + ps."subject", + 'google', + ps."collection", + ps."key", + ps."data", + ps."created_at", + ps."updated_at", + 'mig_google_storage_' || md5(ps."tenant" || ':' || ps."owner" || ':' || ps."subject" || ':' || ps."collection" || ':' || ps."key") +FROM "plugin_storage" ps +JOIN "__google_openapi_integrations" g + ON g."tenant" = ps."tenant" +WHERE ps."plugin_id" = 'openapi' + AND ps."collection" = 'operation' + AND ( + ps."data"::jsonb ->> 'integration' = g."slug" + OR ps."key" LIKE g."slug" || '.%' + ) +ON CONFLICT ("tenant", "owner", "subject", "plugin_id", "collection", "key") +DO UPDATE SET + "data" = EXCLUDED."data", + "updated_at" = EXCLUDED."updated_at";--> statement-breakpoint +DELETE FROM "plugin_storage" ps +USING "__google_openapi_integrations" g +WHERE ps."plugin_id" = 'openapi' + AND ps."collection" = 'operation' + AND g."tenant" = ps."tenant" + AND ( + ps."data"::jsonb ->> 'integration' = g."slug" + OR ps."key" LIKE g."slug" || '.%' + );--> statement-breakpoint +UPDATE "tool" +SET "plugin_id" = 'google' +WHERE "plugin_id" = 'openapi' + AND EXISTS ( + SELECT 1 + FROM "__google_openapi_integrations" g + WHERE g."tenant" = "tool"."tenant" + AND g."slug" = "tool"."integration" + );--> statement-breakpoint +UPDATE "definition" +SET "plugin_id" = 'google' +WHERE "plugin_id" = 'openapi' + AND EXISTS ( + SELECT 1 + FROM "__google_openapi_integrations" g + WHERE g."tenant" = "definition"."tenant" + AND g."slug" = "definition"."integration" + );--> statement-breakpoint +UPDATE "integration" +SET "plugin_id" = 'google' +WHERE "plugin_id" = 'openapi' + AND EXISTS ( + SELECT 1 + FROM "__google_openapi_integrations" g + WHERE g."tenant" = "integration"."tenant" + AND g."slug" = "integration"."slug" + );--> statement-breakpoint +DROP TABLE "__google_openapi_integrations"; diff --git a/apps/cloud/drizzle/0007_red_dragon_lord.sql b/apps/cloud/drizzle/0007_red_dragon_lord.sql new file mode 100644 index 0000000000..d872ac181e --- /dev/null +++ b/apps/cloud/drizzle/0007_red_dragon_lord.sql @@ -0,0 +1 @@ +ALTER TABLE "connection" ADD COLUMN "oauth_token_url" text; \ No newline at end of file diff --git a/apps/cloud/drizzle/meta/0004_snapshot.json b/apps/cloud/drizzle/meta/0004_snapshot.json new file mode 100644 index 0000000000..9db834cf1b --- /dev/null +++ b/apps/cloud/drizzle/meta/0004_snapshot.json @@ -0,0 +1,1242 @@ +{ + "id": "1187b7aa-fdd6-412a-bcfb-20d6ea8f8644", + "prevId": "80591016-4fb7-47d8-ab60-9a5c8eaea9c8", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.accounts": { + "name": "accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.memberships": { + "name": "memberships", + "schema": "", + "columns": { + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "memberships_account_id_accounts_id_fk": { + "name": "memberships_account_id_accounts_id_fk", + "tableFrom": "memberships", + "tableTo": "accounts", + "columnsFrom": ["account_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "memberships_organization_id_organizations_id_fk": { + "name": "memberships_organization_id_organizations_id_fk", + "tableFrom": "memberships", + "tableTo": "organizations", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "memberships_account_id_organization_id_pk": { + "name": "memberships_account_id_organization_id_pk", + "columns": ["account_id", "organization_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organizations": { + "name": "organizations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "organizations_slug_unique": { + "name": "organizations_slug_unique", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.blob": { + "name": "blob", + "schema": "", + "columns": { + "namespace": { + "name": "namespace", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "blob_id_uidx": { + "name": "blob_id_uidx", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.connection": { + "name": "connection", + "schema": "", + "columns": { + "integration": { + "name": "integration", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "template": { + "name": "template", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "item_ids": { + "name": "item_ids", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "identity_label": { + "name": "identity_label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oauth_client": { + "name": "oauth_client", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oauth_client_owner": { + "name": "oauth_client_owner", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_item_id": { + "name": "refresh_item_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "oauth_scope": { + "name": "oauth_scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_state": { + "name": "provider_state", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "connection_uidx": { + "name": "connection_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "integration", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.definition": { + "name": "definition", + "schema": "", + "columns": { + "integration": { + "name": "integration", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "connection": { + "name": "connection", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schema": { + "name": "schema", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "definition_uidx": { + "name": "definition_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "integration", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "connection", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.integration": { + "name": "integration", + "schema": "", + "columns": { + "slug": { + "name": "slug", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "can_remove": { + "name": "can_remove", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "can_refresh": { + "name": "can_refresh", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "integration_uidx": { + "name": "integration_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_client": { + "name": "oauth_client", + "schema": "", + "columns": { + "slug": { + "name": "slug", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "authorization_url": { + "name": "authorization_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_url": { + "name": "token_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "grant": { + "name": "grant", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_secret_item_id": { + "name": "client_secret_item_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resource": { + "name": "resource", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "origin_kind": { + "name": "origin_kind", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "origin_integration": { + "name": "origin_integration", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "oauth_client_uidx": { + "name": "oauth_client_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_session": { + "name": "oauth_session", + "schema": "", + "columns": { + "state": { + "name": "state", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "client_slug": { + "name": "client_slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "integration": { + "name": "integration", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "template": { + "name": "template", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "redirect_url": { + "name": "redirect_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pkce_verifier": { + "name": "pkce_verifier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "identity_label": { + "name": "identity_label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "oauth_session_uidx": { + "name": "oauth_session_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plugin_storage": { + "name": "plugin_storage", + "schema": "", + "columns": { + "plugin_id": { + "name": "plugin_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "collection": { + "name": "collection", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "plugin_storage_uidx": { + "name": "plugin_storage_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "plugin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "collection", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.private_executor_cloud_settings": { + "name": "private_executor_cloud_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "version": { + "name": "version", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "'1.0.0'" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tool": { + "name": "tool", + "schema": "", + "columns": { + "integration": { + "name": "integration", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "connection": { + "name": "connection", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "input_schema": { + "name": "input_schema", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "output_schema": { + "name": "output_schema", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "annotations": { + "name": "annotations", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "tool_uidx": { + "name": "tool_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "integration", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "connection", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tool_policy": { + "name": "tool_policy", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "pattern": { + "name": "pattern", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "tool_policy_uidx": { + "name": "tool_policy_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/apps/cloud/drizzle/meta/0005_snapshot.json b/apps/cloud/drizzle/meta/0005_snapshot.json new file mode 100644 index 0000000000..8ee3d48f57 --- /dev/null +++ b/apps/cloud/drizzle/meta/0005_snapshot.json @@ -0,0 +1,1266 @@ +{ + "id": "e105c61d-2fde-4d2e-a785-a74fafd0f4ea", + "prevId": "1187b7aa-fdd6-412a-bcfb-20d6ea8f8644", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.accounts": { + "name": "accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.memberships": { + "name": "memberships", + "schema": "", + "columns": { + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "memberships_account_id_accounts_id_fk": { + "name": "memberships_account_id_accounts_id_fk", + "tableFrom": "memberships", + "tableTo": "accounts", + "columnsFrom": ["account_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "memberships_organization_id_organizations_id_fk": { + "name": "memberships_organization_id_organizations_id_fk", + "tableFrom": "memberships", + "tableTo": "organizations", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "memberships_account_id_organization_id_pk": { + "name": "memberships_account_id_organization_id_pk", + "columns": ["account_id", "organization_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organizations": { + "name": "organizations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "organizations_slug_unique": { + "name": "organizations_slug_unique", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.blob": { + "name": "blob", + "schema": "", + "columns": { + "namespace": { + "name": "namespace", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "blob_id_uidx": { + "name": "blob_id_uidx", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.connection": { + "name": "connection", + "schema": "", + "columns": { + "integration": { + "name": "integration", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "template": { + "name": "template", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "item_ids": { + "name": "item_ids", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "identity_label": { + "name": "identity_label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tools_synced_at": { + "name": "tools_synced_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "oauth_client": { + "name": "oauth_client", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oauth_client_owner": { + "name": "oauth_client_owner", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_item_id": { + "name": "refresh_item_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "oauth_scope": { + "name": "oauth_scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_state": { + "name": "provider_state", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "connection_uidx": { + "name": "connection_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "integration", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.definition": { + "name": "definition", + "schema": "", + "columns": { + "integration": { + "name": "integration", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "connection": { + "name": "connection", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schema": { + "name": "schema", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "definition_uidx": { + "name": "definition_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "integration", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "connection", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.integration": { + "name": "integration", + "schema": "", + "columns": { + "slug": { + "name": "slug", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config_revised_at": { + "name": "config_revised_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "can_remove": { + "name": "can_remove", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "can_refresh": { + "name": "can_refresh", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "integration_uidx": { + "name": "integration_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_client": { + "name": "oauth_client", + "schema": "", + "columns": { + "slug": { + "name": "slug", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "authorization_url": { + "name": "authorization_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_url": { + "name": "token_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "grant": { + "name": "grant", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_secret_item_id": { + "name": "client_secret_item_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resource": { + "name": "resource", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "origin_kind": { + "name": "origin_kind", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "origin_integration": { + "name": "origin_integration", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "oauth_client_uidx": { + "name": "oauth_client_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_session": { + "name": "oauth_session", + "schema": "", + "columns": { + "state": { + "name": "state", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "client_slug": { + "name": "client_slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "integration": { + "name": "integration", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "template": { + "name": "template", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "redirect_url": { + "name": "redirect_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pkce_verifier": { + "name": "pkce_verifier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "identity_label": { + "name": "identity_label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "oauth_session_uidx": { + "name": "oauth_session_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plugin_storage": { + "name": "plugin_storage", + "schema": "", + "columns": { + "plugin_id": { + "name": "plugin_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "collection": { + "name": "collection", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "plugin_storage_uidx": { + "name": "plugin_storage_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "plugin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "collection", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.private_executor_cloud_settings": { + "name": "private_executor_cloud_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "version": { + "name": "version", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "'1.0.0'" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tool": { + "name": "tool", + "schema": "", + "columns": { + "integration": { + "name": "integration", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "connection": { + "name": "connection", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "input_schema": { + "name": "input_schema", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "output_schema": { + "name": "output_schema", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "annotations": { + "name": "annotations", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "tool_uidx": { + "name": "tool_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "integration", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "connection", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tool_policy": { + "name": "tool_policy", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "pattern": { + "name": "pattern", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "tool_policy_uidx": { + "name": "tool_policy_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/apps/cloud/drizzle/meta/0006_snapshot.json b/apps/cloud/drizzle/meta/0006_snapshot.json new file mode 100644 index 0000000000..682676d84e --- /dev/null +++ b/apps/cloud/drizzle/meta/0006_snapshot.json @@ -0,0 +1,1266 @@ +{ + "id": "3beb97b9-a590-403f-88f5-8a67b499dcf4", + "prevId": "e105c61d-2fde-4d2e-a785-a74fafd0f4ea", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.accounts": { + "name": "accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.memberships": { + "name": "memberships", + "schema": "", + "columns": { + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "memberships_account_id_accounts_id_fk": { + "name": "memberships_account_id_accounts_id_fk", + "tableFrom": "memberships", + "tableTo": "accounts", + "columnsFrom": ["account_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "memberships_organization_id_organizations_id_fk": { + "name": "memberships_organization_id_organizations_id_fk", + "tableFrom": "memberships", + "tableTo": "organizations", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "memberships_account_id_organization_id_pk": { + "name": "memberships_account_id_organization_id_pk", + "columns": ["account_id", "organization_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organizations": { + "name": "organizations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "organizations_slug_unique": { + "name": "organizations_slug_unique", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.blob": { + "name": "blob", + "schema": "", + "columns": { + "namespace": { + "name": "namespace", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "blob_id_uidx": { + "name": "blob_id_uidx", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.connection": { + "name": "connection", + "schema": "", + "columns": { + "integration": { + "name": "integration", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "template": { + "name": "template", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "item_ids": { + "name": "item_ids", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "identity_label": { + "name": "identity_label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tools_synced_at": { + "name": "tools_synced_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "oauth_client": { + "name": "oauth_client", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oauth_client_owner": { + "name": "oauth_client_owner", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_item_id": { + "name": "refresh_item_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "oauth_scope": { + "name": "oauth_scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_state": { + "name": "provider_state", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "connection_uidx": { + "name": "connection_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "integration", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.definition": { + "name": "definition", + "schema": "", + "columns": { + "integration": { + "name": "integration", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "connection": { + "name": "connection", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schema": { + "name": "schema", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "definition_uidx": { + "name": "definition_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "integration", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "connection", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.integration": { + "name": "integration", + "schema": "", + "columns": { + "slug": { + "name": "slug", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config_revised_at": { + "name": "config_revised_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "can_remove": { + "name": "can_remove", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "can_refresh": { + "name": "can_refresh", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "integration_uidx": { + "name": "integration_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_client": { + "name": "oauth_client", + "schema": "", + "columns": { + "slug": { + "name": "slug", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "authorization_url": { + "name": "authorization_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_url": { + "name": "token_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "grant": { + "name": "grant", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_secret_item_id": { + "name": "client_secret_item_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resource": { + "name": "resource", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "origin_kind": { + "name": "origin_kind", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "origin_integration": { + "name": "origin_integration", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "oauth_client_uidx": { + "name": "oauth_client_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_session": { + "name": "oauth_session", + "schema": "", + "columns": { + "state": { + "name": "state", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "client_slug": { + "name": "client_slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "integration": { + "name": "integration", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "template": { + "name": "template", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "redirect_url": { + "name": "redirect_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pkce_verifier": { + "name": "pkce_verifier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "identity_label": { + "name": "identity_label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "oauth_session_uidx": { + "name": "oauth_session_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plugin_storage": { + "name": "plugin_storage", + "schema": "", + "columns": { + "plugin_id": { + "name": "plugin_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "collection": { + "name": "collection", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "plugin_storage_uidx": { + "name": "plugin_storage_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "plugin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "collection", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.private_executor_cloud_settings": { + "name": "private_executor_cloud_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "version": { + "name": "version", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "'1.0.0'" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tool": { + "name": "tool", + "schema": "", + "columns": { + "integration": { + "name": "integration", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "connection": { + "name": "connection", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "input_schema": { + "name": "input_schema", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "output_schema": { + "name": "output_schema", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "annotations": { + "name": "annotations", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "tool_uidx": { + "name": "tool_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "integration", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "connection", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tool_policy": { + "name": "tool_policy", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "pattern": { + "name": "pattern", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "tool_policy_uidx": { + "name": "tool_policy_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/apps/cloud/drizzle/meta/0007_snapshot.json b/apps/cloud/drizzle/meta/0007_snapshot.json new file mode 100644 index 0000000000..9d738166d8 --- /dev/null +++ b/apps/cloud/drizzle/meta/0007_snapshot.json @@ -0,0 +1,1272 @@ +{ + "id": "2f557ed1-5afc-431b-8623-2a2162793f43", + "prevId": "3beb97b9-a590-403f-88f5-8a67b499dcf4", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.accounts": { + "name": "accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.memberships": { + "name": "memberships", + "schema": "", + "columns": { + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "memberships_account_id_accounts_id_fk": { + "name": "memberships_account_id_accounts_id_fk", + "tableFrom": "memberships", + "tableTo": "accounts", + "columnsFrom": ["account_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "memberships_organization_id_organizations_id_fk": { + "name": "memberships_organization_id_organizations_id_fk", + "tableFrom": "memberships", + "tableTo": "organizations", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "memberships_account_id_organization_id_pk": { + "name": "memberships_account_id_organization_id_pk", + "columns": ["account_id", "organization_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organizations": { + "name": "organizations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "organizations_slug_unique": { + "name": "organizations_slug_unique", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.blob": { + "name": "blob", + "schema": "", + "columns": { + "namespace": { + "name": "namespace", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "blob_id_uidx": { + "name": "blob_id_uidx", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.connection": { + "name": "connection", + "schema": "", + "columns": { + "integration": { + "name": "integration", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "template": { + "name": "template", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "item_ids": { + "name": "item_ids", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "identity_label": { + "name": "identity_label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tools_synced_at": { + "name": "tools_synced_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "oauth_client": { + "name": "oauth_client", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oauth_client_owner": { + "name": "oauth_client_owner", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_item_id": { + "name": "refresh_item_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "oauth_scope": { + "name": "oauth_scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oauth_token_url": { + "name": "oauth_token_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_state": { + "name": "provider_state", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "connection_uidx": { + "name": "connection_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "integration", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.definition": { + "name": "definition", + "schema": "", + "columns": { + "integration": { + "name": "integration", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "connection": { + "name": "connection", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schema": { + "name": "schema", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "definition_uidx": { + "name": "definition_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "integration", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "connection", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.integration": { + "name": "integration", + "schema": "", + "columns": { + "slug": { + "name": "slug", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config_revised_at": { + "name": "config_revised_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "can_remove": { + "name": "can_remove", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "can_refresh": { + "name": "can_refresh", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "integration_uidx": { + "name": "integration_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_client": { + "name": "oauth_client", + "schema": "", + "columns": { + "slug": { + "name": "slug", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "authorization_url": { + "name": "authorization_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_url": { + "name": "token_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "grant": { + "name": "grant", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_secret_item_id": { + "name": "client_secret_item_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resource": { + "name": "resource", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "origin_kind": { + "name": "origin_kind", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "origin_integration": { + "name": "origin_integration", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "oauth_client_uidx": { + "name": "oauth_client_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_session": { + "name": "oauth_session", + "schema": "", + "columns": { + "state": { + "name": "state", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "client_slug": { + "name": "client_slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "integration": { + "name": "integration", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "template": { + "name": "template", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "redirect_url": { + "name": "redirect_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pkce_verifier": { + "name": "pkce_verifier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "identity_label": { + "name": "identity_label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "oauth_session_uidx": { + "name": "oauth_session_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plugin_storage": { + "name": "plugin_storage", + "schema": "", + "columns": { + "plugin_id": { + "name": "plugin_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "collection": { + "name": "collection", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "plugin_storage_uidx": { + "name": "plugin_storage_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "plugin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "collection", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.private_executor_cloud_settings": { + "name": "private_executor_cloud_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "version": { + "name": "version", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "'1.0.0'" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tool": { + "name": "tool", + "schema": "", + "columns": { + "integration": { + "name": "integration", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "connection": { + "name": "connection", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "input_schema": { + "name": "input_schema", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "output_schema": { + "name": "output_schema", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "annotations": { + "name": "annotations", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "tool_uidx": { + "name": "tool_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "integration", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "connection", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tool_policy": { + "name": "tool_policy", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "pattern": { + "name": "pattern", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "tool_policy_uidx": { + "name": "tool_policy_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/apps/cloud/drizzle/meta/_journal.json b/apps/cloud/drizzle/meta/_journal.json index 1119feba80..903e5515ac 100644 --- a/apps/cloud/drizzle/meta/_journal.json +++ b/apps/cloud/drizzle/meta/_journal.json @@ -29,6 +29,34 @@ "when": 1781283644753, "tag": "0003_friendly_monster_badoon", "breakpoints": true + }, + { + "idx": 4, + "version": "7", + "when": 1781375837041, + "tag": "0004_nervous_quasar", + "breakpoints": true + }, + { + "idx": 5, + "version": "7", + "when": 1781491100009, + "tag": "0005_integration_descriptions", + "breakpoints": true + }, + { + "idx": 6, + "version": "7", + "when": 1781994790376, + "tag": "0006_google_openapi_ownership", + "breakpoints": true + }, + { + "idx": 7, + "version": "7", + "when": 1782149425764, + "tag": "0007_red_dragon_lord", + "breakpoints": true } ] } diff --git a/apps/cloud/executor.config.ts b/apps/cloud/executor.config.ts index e31fab41b3..148d5f79d3 100644 --- a/apps/cloud/executor.config.ts +++ b/apps/cloud/executor.config.ts @@ -1,5 +1,7 @@ import { defineExecutorConfig } from "@executor-js/sdk"; import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; +import { googleHttpPlugin } from "@executor-js/plugin-google/api"; +import { microsoftHttpPlugin } from "@executor-js/plugin-microsoft/api"; import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api"; import { graphqlHttpPlugin } from "@executor-js/plugin-graphql/api"; import { workosVaultPlugin, type WorkOSVaultClient } from "@executor-js/plugin-workos-vault"; @@ -44,6 +46,8 @@ export default defineExecutorConfig({ plugins: ({ workosCredentials, workosVaultClient }: CloudPluginDeps = {}) => [ openApiHttpPlugin(), + googleHttpPlugin(), + microsoftHttpPlugin(), mcpHttpPlugin({ dangerouslyAllowStdioMCP: false, }), diff --git a/apps/cloud/package.json b/apps/cloud/package.json index df419873f9..b982e2a2af 100644 --- a/apps/cloud/package.json +++ b/apps/cloud/package.json @@ -1,6 +1,6 @@ { "name": "@executor-js/cloud", - "version": "1.4.28", + "version": "1.4.35", "private": true, "type": "module", "scripts": { @@ -12,14 +12,14 @@ "db:generate": "drizzle-kit generate", "db:studio": "drizzle-kit studio", "db:studio:prod": "op run --env-file=.env.production -- bun --bun ../../node_modules/.bun/node_modules/drizzle-kit/bin.cjs studio", - "db:migrate:prod": "op run --env-file=.env.production -- bun --bun ../../node_modules/.bun/node_modules/drizzle-kit/bin.cjs migrate", + "db:migrate:prod": "op run --env-file=.env.production -- bun run scripts/migrate.ts --bucket executor-cloud-blobs", "db:migrate-auth:prod": "op run --env-file=.env.production -- bun run scripts/migrate-auth-configs.ts", "db:migrate-auth:dev": "op run --env-file=.env.op -- bun run scripts/migrate-auth-configs.ts", "db:repartition-vault:prod": "op run --env-file=.env.production -- bun run scripts/repartition-vault-metadata.ts", "db:repartition-vault:dev": "op run --env-file=.env.op -- bun run scripts/repartition-vault-metadata.ts", "db:migrate-specs:prod": "op run --env-file=.env.production -- bun run scripts/migrate-specs-to-blobs.ts", "db:migrate-specs:dev": "op run --env-file=.env.op -- bun run scripts/migrate-specs-to-blobs.ts", - "db:migrate:dev": "op run --env-file=.env.op -- bun --bun ../../node_modules/.bun/node_modules/drizzle-kit/bin.cjs migrate", + "db:migrate:dev": "op run --env-file=.env.op -- bun run scripts/migrate.ts", "build": "node scripts/build.mjs", "preview": "vite preview", "deploy": "op run --env-file=.env.production -- bash -lc 'bun run build && wrangler deploy -c dist/server/wrangler.json'", @@ -41,8 +41,10 @@ "@executor-js/execution": "workspace:*", "@executor-js/fumadb": "workspace:*", "@executor-js/host-mcp": "workspace:*", + "@executor-js/plugin-google": "workspace:*", "@executor-js/plugin-graphql": "workspace:*", "@executor-js/plugin-mcp": "workspace:*", + "@executor-js/plugin-microsoft": "workspace:*", "@executor-js/plugin-openapi": "workspace:*", "@executor-js/plugin-workos-vault": "workspace:*", "@executor-js/react": "workspace:*", @@ -76,6 +78,7 @@ "sonner": "^2.0.7" }, "devDependencies": { + "@cloudflare/vitest-pool-workers": "^0.15.0", "@cloudflare/workers-types": "^4.20250620.0", "@effect/platform-node": "catalog:", "@effect/vitest": "catalog:", diff --git a/apps/cloud/scripts/backfill-org-slugs.ts b/apps/cloud/scripts/backfill-org-slugs.ts index e0583caff2..383acb2b3f 100644 --- a/apps/cloud/scripts/backfill-org-slugs.ts +++ b/apps/cloud/scripts/backfill-org-slugs.ts @@ -1,16 +1,19 @@ /* oxlint-disable executor/no-try-catch-or-throw -- boundary: out-of-band migration script over a raw postgres connection */ // --------------------------------------------------------------------------- // One-off data backfill: mint URL slugs for organizations that predate the -// slug column. Run OUT-OF-BAND against the database after applying the -// schema migration that adds `organizations.slug` (0002): +// slug column (added by migration 0003, nullable). // // bun run db:backfill-org-slugs:prod # op run --env-file=.env.production // bun run db:backfill-org-slugs:dev # against the local PGlite dev db // -// Idempotent — already-slugged rows are skipped, so re-running is safe. The -// runtime also lazily mints slugs on first resolution (see -// auth/organization.ts), so this backfill is belt-and-braces for bulk -// pre-warming rather than a deploy gate. +// Idempotent — already-slugged rows are skipped, so re-running is safe. +// +// Ordering matters: the runtime now mints a slug at the moment a row is +// inserted and NO LONGER self-heals null slugs, and migration 0004 makes the +// column NOT NULL. So this backfill is the SOLE way pre-existing null-slug rows +// get a slug, and it MUST run BEFORE 0004 is applied (a null row would fail the +// NOT NULL alter). The correct sequence is: backfill (this script) → deploy the +// mint-at-insert code → apply 0004. // Pass --dry-run to print the plan without writing. // --------------------------------------------------------------------------- @@ -47,8 +50,8 @@ for (const row of rows) { planned.add(slug); console.log(`${row.id} ${JSON.stringify(row.name)} -> ${slug}`); if (dryRun) continue; - // Guard WHERE slug IS NULL: a concurrently-deployed runtime may have lazily - // minted this org's slug between our read and this write. + // Guard WHERE slug IS NULL keeps re-runs idempotent (and tolerates a row that + // gained a slug between our read and this write). await sql`UPDATE organizations SET slug = ${slug} WHERE id = ${row.id} AND slug IS NULL`; updated += 1; } diff --git a/apps/cloud/scripts/code-migrations/google-openapi-r2-blobs.ts b/apps/cloud/scripts/code-migrations/google-openapi-r2-blobs.ts new file mode 100644 index 0000000000..5ecf217e63 --- /dev/null +++ b/apps/cloud/scripts/code-migrations/google-openapi-r2-blobs.ts @@ -0,0 +1,175 @@ +/* oxlint-disable executor/no-error-constructor, executor/no-try-catch-or-throw -- boundary: migration copies remote R2 objects through wrangler */ + +import { createHash } from "node:crypto"; +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { execFileSync, spawnSync } from "node:child_process"; + +import type { CodeMigration, CodeMigrationContext } from "./runner"; + +export interface R2ObjectStore { + readonly exists: (key: string) => boolean; + readonly get: (key: string, file: string) => void; + readonly put: (key: string, file: string) => void; +} + +export interface GoogleOpenApiR2BlobMigrationOptions { + readonly bucket: string; + readonly limit?: number; + readonly store?: R2ObjectStore; +} + +interface GoogleDiscoveryRow { + readonly tenant: string; + readonly slug: string; + readonly plugin_id: string; + readonly spec_hash: string; +} + +const sha256Buffer = (buffer: Buffer): string => createHash("sha256").update(buffer).digest("hex"); +const sha256Text = (text: string): string => + createHash("sha256").update(text, "utf8").digest("hex"); + +const makeWranglerR2ObjectStore = (bucket: string): R2ObjectStore => { + const objectPath = (key: string): string => `${bucket}/${key}`; + const args = (command: "get" | "put", key: string, file: string): readonly string[] => + command === "get" + ? ["r2", "object", "get", objectPath(key), "--file", file, "--remote"] + : ["r2", "object", "put", objectPath(key), "--file", file, "--remote", "--force"]; + + return { + exists: (key) => { + const file = join(tmpdir(), `executor-r2-exists-${sha256Text(key)}`); + const result = spawnSync("wrangler", args("get", key, file), { + stdio: ["ignore", "ignore", "ignore"], + }); + return result.status === 0; + }, + get: (key, file) => { + execFileSync("wrangler", args("get", key, file), { + stdio: ["ignore", "ignore", "inherit"], + }); + }, + put: (key, file) => { + execFileSync("wrangler", args("put", key, file), { + stdio: ["ignore", "ignore", "inherit"], + }); + }, + }; +}; + +const blobKey = (tenant: string, pluginId: "openapi" | "google", specHash: string): string => + `o:${tenant}/${pluginId}/spec/${specHash}`; + +const readRows = (context: CodeMigrationContext): Promise => + context.sql.unsafe(` + SELECT + tenant, + slug, + plugin_id, + config::jsonb ->> 'specHash' AS spec_hash + FROM integration + WHERE plugin_id IN ('openapi', 'google') + AND config IS NOT NULL + AND jsonb_typeof(config::jsonb -> 'googleDiscoveryUrls') = 'array' + AND coalesce(config::jsonb ->> 'specHash', '') <> '' + ORDER BY tenant, slug + `); + +export const googleOpenApiR2BlobMigration = ({ + bucket, + limit, + store = makeWranglerR2ObjectStore(bucket), +}: GoogleOpenApiR2BlobMigrationOptions): CodeMigration => ({ + name: "2026-06-20-google-openapi-r2-blobs", + run: async (context) => { + const rows = await readRows(context); + const work = rows.slice(0, Number.isFinite(limit) ? limit : rows.length); + if (work.length < rows.length) { + context.log(`[code-migrate] --limit: checking first ${work.length} of ${rows.length}`); + } + + let sourcesPresent = 0; + let targetsPresent = 0; + let copied = 0; + const missingSources: string[] = []; + const conflictingTargets: string[] = []; + const tempDir = mkdtempSync(join(tmpdir(), "google-openapi-blobs-")); + + try { + for (const [index, row] of work.entries()) { + const sourceKey = blobKey(row.tenant, "openapi", row.spec_hash); + const targetKey = blobKey(row.tenant, "google", row.spec_hash); + + if (context.dryRun) { + if (store.exists(sourceKey)) sourcesPresent += 1; + else missingSources.push(`${row.tenant}/${row.slug}`); + if (store.exists(targetKey)) targetsPresent += 1; + continue; + } + + const sourceFile = join(tempDir, `source-${index}`); + const targetFile = join(tempDir, `target-${index}`); + const verifyFile = join(tempDir, `verify-${index}`); + + try { + store.get(sourceKey, sourceFile); + } catch { + missingSources.push(`${row.tenant}/${row.slug}`); + continue; + } + + const sourceHash = sha256Buffer(readFileSync(sourceFile)); + if (store.exists(targetKey)) { + store.get(targetKey, targetFile); + const targetHash = sha256Buffer(readFileSync(targetFile)); + if (targetHash === sourceHash) { + targetsPresent += 1; + continue; + } + conflictingTargets.push(`${row.tenant}/${row.slug}`); + continue; + } + + store.put(targetKey, sourceFile); + store.get(targetKey, verifyFile); + const verifyHash = sha256Buffer(readFileSync(verifyFile)); + if (verifyHash !== sourceHash) { + throw new Error(`R2 round-trip mismatch for ${targetKey}`); + } + copied += 1; + } + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + + if (missingSources.length > 0) { + throw new Error( + `${missingSources.length} source object(s) missing: ${missingSources.join(", ")}`, + ); + } + if (conflictingTargets.length > 0) { + throw new Error( + `${conflictingTargets.length} conflicting target object(s): ${conflictingTargets.join(", ")}`, + ); + } + + if (context.dryRun) { + return { + summary: + `${rows.length} Google Discovery integration row(s), ` + + `${sourcesPresent}/${work.length} source R2 object(s) present, ` + + `${targetsPresent}/${work.length} target R2 object(s) already present, ` + + `would copy ${Math.max(sourcesPresent - targetsPresent, 0)} object(s)`, + }; + } + + return { + summary: + `${rows.length} Google Discovery integration row(s), ` + + `copied ${copied} R2 object(s), ` + + `${targetsPresent} target object(s) already present`, + }; + }, +}); diff --git a/apps/cloud/scripts/code-migrations/index.ts b/apps/cloud/scripts/code-migrations/index.ts new file mode 100644 index 0000000000..9783d2cf90 --- /dev/null +++ b/apps/cloud/scripts/code-migrations/index.ts @@ -0,0 +1,16 @@ +import type { CodeMigration } from "./runner"; +import { googleOpenApiR2BlobMigration } from "./google-openapi-r2-blobs"; + +export interface CloudCodeMigrationRegistryOptions { + readonly r2Bucket?: string; + readonly limit?: number; +} + +export const cloudCodeMigrations = ({ + r2Bucket, + limit, +}: CloudCodeMigrationRegistryOptions): readonly CodeMigration[] => [ + ...(r2Bucket ? [googleOpenApiR2BlobMigration({ bucket: r2Bucket, limit })] : []), +]; + +export { runCodeMigrations } from "./runner"; diff --git a/apps/cloud/scripts/code-migrations/runner.ts b/apps/cloud/scripts/code-migrations/runner.ts new file mode 100644 index 0000000000..be758b45f9 --- /dev/null +++ b/apps/cloud/scripts/code-migrations/runner.ts @@ -0,0 +1,118 @@ +/* oxlint-disable executor/no-error-constructor, executor/no-try-catch-or-throw -- boundary: migration CLI runner uses thrown failures for process exit */ + +export interface CodeMigrationSql { + unsafe[]>( + query: string, + params?: readonly unknown[], + ): Promise; +} + +export interface CodeMigrationContext { + readonly sql: CodeMigrationSql; + readonly dryRun: boolean; + readonly log: (message: string) => void; +} + +export interface CodeMigrationResult { + readonly summary: string; +} + +export interface CodeMigration { + readonly name: string; + readonly run: (context: CodeMigrationContext) => Promise; +} + +export interface RunCodeMigrationsOptions { + readonly dryRun?: boolean; + readonly log?: (message: string) => void; +} + +const LEDGER_TABLE = "cloud_code_migration"; +const ADVISORY_LOCK_KEY = "executor_cloud_code_migration"; + +const createLedgerSql = ` + CREATE TABLE IF NOT EXISTS "${LEDGER_TABLE}" ( + "name" text PRIMARY KEY, + "time_completed" bigint NOT NULL + ) +`; + +const readCompletedMigrations = async ( + sql: CodeMigrationSql, + dryRun: boolean, +): Promise> => { + if (dryRun) { + const [table] = await sql.unsafe<{ ledger_table: string | null }[]>( + `SELECT to_regclass('public.${LEDGER_TABLE}')::text AS "ledger_table"`, + ); + if (!table?.ledger_table) return new Set(); + } else { + await sql.unsafe(createLedgerSql); + } + + const stamped = await sql.unsafe<{ name: string }[]>( + `SELECT "name" FROM "${LEDGER_TABLE}" ORDER BY "name"`, + ); + return new Set(stamped.map((row) => row.name)); +}; + +const assertUniqueNames = (migrations: readonly CodeMigration[]): void => { + const names = new Set(); + for (const migration of migrations) { + if (names.has(migration.name)) { + throw new Error(`Duplicate code migration name: ${migration.name}`); + } + names.add(migration.name); + } +}; + +const withMigrationLock = async ( + sql: CodeMigrationSql, + dryRun: boolean, + body: () => Promise, +): Promise => { + if (dryRun) return body(); + + await sql.unsafe(`SELECT pg_advisory_lock(hashtext('${ADVISORY_LOCK_KEY}'))`); + try { + return await body(); + } finally { + await sql.unsafe(`SELECT pg_advisory_unlock(hashtext('${ADVISORY_LOCK_KEY}'))`); + } +}; + +export const runCodeMigrations = async ( + sql: CodeMigrationSql, + migrations: readonly CodeMigration[], + options: RunCodeMigrationsOptions = {}, +): Promise => { + const dryRun = options.dryRun ?? false; + const log = options.log ?? console.log; + assertUniqueNames(migrations); + + return withMigrationLock(sql, dryRun, async () => { + const completed = await readCompletedMigrations(sql, dryRun); + const applied: string[] = []; + + for (const migration of migrations) { + if (completed.has(migration.name)) { + log(`[code-migrate] skip ${migration.name}`); + continue; + } + + log(`[code-migrate] ${dryRun ? "plan" : "run"} ${migration.name}`); + const result = await migration.run({ sql, dryRun, log }); + log(`[code-migrate] ${result.summary}`); + + if (!dryRun) { + await sql.unsafe( + `INSERT INTO "${LEDGER_TABLE}" ("name", "time_completed") VALUES ($1, $2)`, + [migration.name, Date.now()], + ); + } + applied.push(migration.name); + } + + return applied; + }); +}; diff --git a/apps/cloud/scripts/migrate.ts b/apps/cloud/scripts/migrate.ts new file mode 100644 index 0000000000..9466a049b5 --- /dev/null +++ b/apps/cloud/scripts/migrate.ts @@ -0,0 +1,76 @@ +/* oxlint-disable executor/no-error-constructor, executor/no-try-catch-or-throw -- boundary: out-of-band migration CLI */ + +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { drizzle } from "drizzle-orm/postgres-js"; +import { migrate as migrateDrizzle } from "drizzle-orm/postgres-js/migrator"; +import postgres from "postgres"; + +import { cloudCodeMigrations, runCodeMigrations } from "./code-migrations/index"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const MIGRATIONS_FOLDER = resolve(__dirname, "../drizzle"); + +const args = process.argv.slice(2); +const hasArg = (name: string): boolean => args.includes(name); +const argValue = (name: string): string | undefined => { + const index = args.indexOf(name); + return index >= 0 ? args[index + 1] : undefined; +}; + +const dryRun = hasArg("--dry-run"); +const schemaOnly = hasArg("--schema-only"); +const codeOnly = hasArg("--code-only"); +const r2Bucket = argValue("--bucket") ?? process.env.CLOUD_CODE_MIGRATION_R2_BUCKET; +const limitRaw = argValue("--limit"); +const limit = limitRaw ? Number(limitRaw) : undefined; + +if (schemaOnly && codeOnly) { + throw new Error("--schema-only and --code-only cannot be used together"); +} +if (limitRaw && !Number.isFinite(limit)) { + throw new Error("--limit must be a number"); +} + +const connectionString = process.env.DATABASE_URL; +if (!connectionString) { + throw new Error("DATABASE_URL is not set"); +} + +const usesLocalDatabase = + connectionString.includes("127.0.0.1") || connectionString.includes("localhost"); + +const sql = postgres(connectionString, { + max: 1, + prepare: false, + ...(usesLocalDatabase ? {} : { ssl: "require" as const }), +}); + +try { + if (!codeOnly) { + if (dryRun) { + console.log("[schema-migrate] dry run: Drizzle SQL migrations are not applied"); + } else { + console.log(`[schema-migrate] running Drizzle migrations from ${MIGRATIONS_FOLDER}`); + await migrateDrizzle(drizzle(sql), { migrationsFolder: MIGRATIONS_FOLDER }); + console.log("[schema-migrate] complete"); + } + } + + if (!schemaOnly) { + const migrations = cloudCodeMigrations({ r2Bucket, limit }); + if (migrations.length === 0) { + console.log("[code-migrate] no code migrations configured"); + } else { + const applied = await runCodeMigrations(sql, migrations, { dryRun }); + console.log( + dryRun + ? `[code-migrate] dry run planned ${applied.length} migration(s)` + : `[code-migrate] applied ${applied.length} migration(s)`, + ); + } + } +} finally { + await sql.end({ timeout: 0 }); +} diff --git a/apps/cloud/src/api/protected-api-key-auth.node.test.ts b/apps/cloud/src/api/protected-api-key-auth.node.test.ts index c13ae96ee8..1ee7561550 100644 --- a/apps/cloud/src/api/protected-api-key-auth.node.test.ts +++ b/apps/cloud/src/api/protected-api-key-auth.node.test.ts @@ -27,7 +27,7 @@ const stubApiKeys = Layer.succeed(ApiKeyService)({ const stubWorkOS = Layer.succeed( WorkOSClient, new Proxy({} as WorkOSClientService, { - get: (_target, prop) => { + get: (target, prop) => { if (prop === "listUserMemberships") { return (userId: string) => Effect.succeed({ @@ -50,7 +50,7 @@ const stubUsers = Layer.succeed(UserStoreService)({ getAccount: async (id: string) => ({ id, createdAt }), upsertOrganization: async (org: { id: string; name: string }) => ({ ...org, - slug: null, + slug: `org-slug-${org.id}`, createdAt, }), getOrganization: async (id: string) => ({ @@ -65,11 +65,6 @@ const stubUsers = Layer.succeed(UserStoreService)({ slug, createdAt, }), - ensureOrganizationSlug: async (org: { id: string; name: string; slug: string | null }) => ({ - ...org, - slug: org.slug ?? `org-slug-${org.id}`, - createdAt, - }), }), ), }); @@ -92,6 +87,7 @@ describe("protected API key auth", () => { accountId: "user_123", organizationId: "org_123", organizationName: "Org org_123", + organizationSlug: "org-slug-org_123", email: "", name: null, avatarUrl: null, diff --git a/apps/cloud/src/api/protected-jwt-auth.node.test.ts b/apps/cloud/src/api/protected-jwt-auth.node.test.ts new file mode 100644 index 0000000000..9b317a5b8a --- /dev/null +++ b/apps/cloud/src/api/protected-jwt-auth.node.test.ts @@ -0,0 +1,172 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Layer } from "effect"; +import { SignJWT, createLocalJWKSet, exportJWK, generateKeyPair } from "jose"; + +import { ApiKeyService } from "../auth/api-keys"; +import { UserStoreService } from "../auth/context"; +import type { JwtBearerConfig } from "../auth/workos-auth-provider"; +import { WorkOSClient, type WorkOSClientService } from "../auth/workos"; +import { resolveProtectedPrincipal } from "./protected"; + +const createdAt = new Date("2026-01-01T00:00:00.000Z"); +const issuer = "https://test-authkit.example.com"; +const audience = "client_test_audience"; + +// A WorkOS access-token JWT verifier backed by a local RS256 key pair (no +// network), plus a signer that mints tokens for it. Mirrors +// `mcp/mcp-auth.node.test.ts`. +const makeJwt = async () => { + const { publicKey, privateKey } = await generateKeyPair("RS256"); + const jwk = await exportJWK(publicKey); + const jwks = createLocalJWKSet({ keys: [{ ...jwk, kid: "test-key" }] }); + // Device-login tokens are verified by signature alone (client-scoped SSO + // JWKS); issuer/audience are not pinned. + const config: JwtBearerConfig = { jwks }; + const sign = (claims: Record, expiration: string | number = "5m") => + new SignJWT(claims) + .setProtectedHeader({ alg: "RS256", kid: "test-key" }) + .setIssuer(issuer) + .setAudience(audience) + .setSubject("user_123") + .setIssuedAt() + .setExpirationTime(expiration) + .sign(privateKey); + return { config, sign }; +}; + +// The api-key path must never run for a JWT bearer, die loudly if it does. +const stubApiKeys = Layer.succeed(ApiKeyService)({ + validate: () => Effect.die("JWT bearer must not hit ApiKeyService.validate"), + listUserKeys: () => Effect.succeed([]), + createUserKey: () => Effect.die("JWT auth test does not create API keys"), + revokeUserKey: () => Effect.void, +}); + +const stubWorkOS = Layer.succeed( + WorkOSClient, + new Proxy({} as WorkOSClientService, { + get: (_target, prop) => { + if (prop === "listUserMemberships") { + return (userId: string) => + Effect.succeed({ + data: + userId === "user_123" + ? [{ userId, organizationId: "org_123", status: "active" }] + : [], + }); + } + return () => Effect.die(`unexpected WorkOSClient.${String(prop)} call`); + }, + }), +); + +const stubUsers = Layer.succeed(UserStoreService)({ + use: (fn) => + Effect.promise(() => + fn({ + ensureAccount: async (id: string) => ({ id, createdAt }), + getAccount: async (id: string) => ({ id, createdAt }), + upsertOrganization: async (org: { id: string; name: string }) => ({ + ...org, + slug: `org-slug-${org.id}`, + createdAt, + }), + getOrganization: async (id: string) => ({ + id, + name: `Org ${id}`, + slug: `org-slug-${id}`, + createdAt, + }), + getOrganizationBySlug: async (slug: string) => ({ + id: "org_by_slug", + name: `Org ${slug}`, + slug, + createdAt, + }), + }), + ), +}); + +const run = (request: Request, jwt: JwtBearerConfig) => + resolveProtectedPrincipal(request, jwt).pipe( + Effect.provide(Layer.mergeAll(stubApiKeys, stubWorkOS, stubUsers)), + ); + +const request = (token: string) => + new Request("https://executor.test/api/tools", { + headers: { authorization: `Bearer ${token}` }, + }); + +describe("protected JWT (device-login) auth", () => { + it.effect("resolves a valid WorkOS access token into protected identity", () => + Effect.gen(function* () { + const { config, sign } = yield* Effect.promise(() => makeJwt()); + const token = yield* Effect.promise(() => sign({ org_id: "org_123" })); + + const identity = yield* run(request(token), config); + + expect(identity).toEqual({ + accountId: "user_123", + organizationId: "org_123", + organizationName: "Org org_123", + organizationSlug: "org-slug-org_123", + email: "", + name: null, + avatarUrl: null, + roles: [], + }); + }), + ); + + it.effect("rejects an expired access token as Unauthorized", () => + Effect.gen(function* () { + const { config, sign } = yield* Effect.promise(() => makeJwt()); + // Expired one hour ago. + const token = yield* Effect.promise(() => + sign({ org_id: "org_123" }, Math.floor(Date.now() / 1000) - 3600), + ); + + const error = yield* Effect.flip(run(request(token), config)); + + expect(error).toMatchObject({ + _tag: "Unauthorized", + code: "invalid_access_token", + }); + }), + ); + + it.effect("rejects a token signed by a different key as Unauthorized", () => + Effect.gen(function* () { + const { config } = yield* Effect.promise(() => makeJwt()); + const other = yield* Effect.promise(() => makeJwt()); + const token = yield* Effect.promise(() => other.sign({ org_id: "org_123" })); + + const error = yield* Effect.flip(run(request(token), config)); + + expect(error).toMatchObject({ _tag: "Unauthorized", code: "invalid_access_token" }); + }), + ); + + it.effect("rejects a token with no org_id as NoOrganization", () => + Effect.gen(function* () { + const { config, sign } = yield* Effect.promise(() => makeJwt()); + const token = yield* Effect.promise(() => sign({})); + + const error = yield* Effect.flip(run(request(token), config)); + + expect(error).toMatchObject({ _tag: "NoOrganization", code: "no_organization" }); + }), + ); + + it.effect("rejects a valid token whose org membership is not active", () => + Effect.gen(function* () { + const { config, sign } = yield* Effect.promise(() => makeJwt()); + // org_999 is not in the membership stub -> not authorized. + const token = yield* Effect.promise(() => sign({ org_id: "org_999" })); + + const error = yield* Effect.flip(run(request(token), config)); + + expect(error).toMatchObject({ _tag: "NoOrganization", code: "no_organization" }); + }), + ); +}); diff --git a/apps/cloud/src/api/router.ts b/apps/cloud/src/api/router.ts index 91b6f4895a..33ab9029a6 100644 --- a/apps/cloud/src/api/router.ts +++ b/apps/cloud/src/api/router.ts @@ -1,7 +1,7 @@ import { Layer } from "effect"; import { HttpRouter } from "effect/unstable/http"; -import { RouterConfigLive } from "@executor-js/api/server"; +import { RouterConfigLive, requestScopedMiddleware } from "@executor-js/api/server"; import { UserStoreService } from "../auth/context"; import { DbService } from "../db/db"; @@ -29,16 +29,20 @@ import { makeProtectedApiLive } from "./protected"; // so tests can substitute a counting fake for `DbService.Live` and // assert per-request semantics — see // `apps/cloud/src/api.request-scope.node.test.ts`. -export const makeApiLive = (requestScopedLive: Layer.Layer) => - Layer.mergeAll( +export const makeApiLive = (requestScopedLive: Layer.Layer) => { + const BillingRoutesLive = AutumnRoutesLive.pipe( + Layer.provide(requestScopedMiddleware(requestScopedLive).layer), + ); + return Layer.mergeAll( makeNonProtectedApiLive(requestScopedLive), OrgApiLive, makeAccountApiLive(requestScopedLive), CloudDocsLive, makeProtectedApiLive(requestScopedLive), - AutumnRoutesLive, + BillingRoutesLive, ApiErrorLoggingLive, ).pipe(Layer.provideMerge(RouterConfigLive), Layer.provideMerge(BootSharedServices)); +}; export const ApiLive = makeApiLive(RequestScopedServicesLive); diff --git a/apps/cloud/src/auth/api-jwt-bearer.ts b/apps/cloud/src/auth/api-jwt-bearer.ts new file mode 100644 index 0000000000..e8cbc089ae --- /dev/null +++ b/apps/cloud/src/auth/api-jwt-bearer.ts @@ -0,0 +1,31 @@ +// --------------------------------------------------------------------------- +// The `/api/*` plane's WorkOS device-login (user_management) JWT bearer config. +// +// The protected HTTP API accepts THREE credential forms on the `Authorization` +// header, in this precedence: a WorkOS device-login access token (JWT, from +// `executor login`), then a WorkOS API key, then, with no header, the +// sealed-session cookie. This module supplies the JWKS the JWT branch verifies +// against. +// +// CRITICAL: a device-login token is a WorkOS *user_management* access token, +// signed by the SSO keyset served at `/sso/jwks/` with NO +// audience and a user_management issuer. That is a DIFFERENT key domain than the +// MCP `/oauth2` tokens (verified via the AuthKit `/oauth2/jwks`): the device +// token's key is not in the oauth2 JWKS, so we must verify it against the +// client-scoped SSO JWKS instead (signature + expiry; see +// `verifyWorkosUserManagementToken`). `WORKOS_API_URL` is api.workos.com in +// production and the WorkOS emulator in tests. +// --------------------------------------------------------------------------- + +import { env } from "cloudflare:workers"; + +import { createCachedRemoteJWKSet } from "./jwks-cache"; +import type { JwtBearerConfig } from "./workos-auth-provider"; + +const WORKOS_API_BASE = (env.WORKOS_API_URL ?? "https://api.workos.com").replace(/\/+$/, ""); + +// Module-scope cache, same rationale as `mcp/auth.ts`: one JWKS fetch per +// isolate-hour rather than one per cold isolate. +export const workosApiJwtBearerConfig: JwtBearerConfig = { + jwks: createCachedRemoteJWKSet(new URL(`${WORKOS_API_BASE}/sso/jwks/${env.WORKOS_CLIENT_ID}`)), +}; diff --git a/apps/cloud/src/auth/api-keys.node.test.ts b/apps/cloud/src/auth/api-keys.node.test.ts index 6208dae37d..be41eb5931 100644 --- a/apps/cloud/src/auth/api-keys.node.test.ts +++ b/apps/cloud/src/auth/api-keys.node.test.ts @@ -8,7 +8,7 @@ const stubWorkOS = (overrides: Partial) => Layer.succeed( WorkOSClient, new Proxy({} as WorkOSClientService, { - get: (_target, prop) => { + get: (target, prop) => { if (prop in overrides) return overrides[prop as keyof WorkOSClientService]; return () => Effect.die(`unexpected WorkOSClient.${String(prop)} call`); }, diff --git a/apps/cloud/src/auth/api.ts b/apps/cloud/src/auth/api.ts index 7e067e0eeb..6c73838b71 100644 --- a/apps/cloud/src/auth/api.ts +++ b/apps/cloud/src/auth/api.ts @@ -44,6 +44,17 @@ const CreateOrganizationResponse = Schema.Struct({ slug: Schema.String, }); +// CLI device-login discovery (`executor login`). Tells the CLI where to run +// the OAuth 2.0 Device Authorization Grant (RFC 8628) and which public client +// to use. The CLI hits these provider endpoints directly, gets a WorkOS access +// token (a JWT), and sends it as a Bearer to the `/api/*` plane. +const CliLoginResponse = Schema.Struct({ + provider: Schema.Literal("workos"), + deviceAuthorizationEndpoint: Schema.String, + tokenEndpoint: Schema.String, + clientId: Schema.String, +}); + // `state` is optional — some WorkOS-initiated redirects arrive at the // callback without the state we set on /auth/login. The CSRF check is // only enforced when state is present (see callback handler). @@ -156,7 +167,8 @@ export class CloudAuthPublicApi extends HttpApiGroup.make("cloudAuthPublic") query: AuthCallbackSearch, error: AuthErrors, }), - ) {} + ) + .add(HttpApiEndpoint.get("cliLogin", "/auth/cli-login", { success: CliLoginResponse })) {} /** Session auth endpoints — require a logged-in user, may not have an org */ export class CloudAuthApi extends HttpApiGroup.make("cloudAuth") diff --git a/apps/cloud/src/auth/handlers.ts b/apps/cloud/src/auth/handlers.ts index 5f6fb6ffad..6f8a2bdcc3 100644 --- a/apps/cloud/src/auth/handlers.ts +++ b/apps/cloud/src/auth/handlers.ts @@ -236,6 +236,22 @@ export const CloudAuthPublicHandlers = HttpApiBuilder.group( STATE_COOKIE, ); }), + ) + // CLI device-login discovery. The WorkOS device endpoints live on the + // WorkOS API host (`WORKOS_API_URL`, or api.workos.com in production, + // the SAME base the SDK uses, so e2e points the CLI at the emulator with + // zero extra wiring). The CLI runs RFC 8628 against them as a public + // client (no secret) and gets a WorkOS access-token JWT back. + .handle("cliLogin", () => + Effect.sync(() => { + const base = (env.WORKOS_API_URL ?? "https://api.workos.com").replace(/\/+$/, ""); + return { + provider: "workos" as const, + deviceAuthorizationEndpoint: `${base}/user_management/authorize/device`, + tokenEndpoint: `${base}/user_management/authenticate`, + clientId: env.WORKOS_CLIENT_ID, + }; + }), ), ); @@ -342,10 +358,10 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( const org = yield* workos.createOrganization(name); yield* workos.createMembership(org.id, session.accountId, "admin"); + // `upsertOrganization` mints the slug at insert — no separate heal step. const mirrored = yield* users.use((s) => s.upsertOrganization({ id: org.id, name: org.name }), ); - const slugged = yield* users.use((s) => s.ensureOrganizationSlug(mirrored)); // Try to attach the new org to the current session. This can fail // (or silently return a session still scoped to the old org) when @@ -372,7 +388,7 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( } (yield* SessionCookies).set("wos-session", refreshed, RESPONSE_COOKIE_OPTIONS); - return { id: org.id, name: org.name, slug: slugged.slug }; + return { id: org.id, name: org.name, slug: mirrored.slug }; }), ) .handle("pendingInvitations", () => @@ -441,12 +457,12 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( return yield* new WorkOSError(); } - // Mirror the org locally so domain tables can FK against it. + // Mirror the org locally so domain tables can FK against it; the + // upsert mints the slug at insert — no separate heal step. const org = yield* workos.getOrganization(invitation.organizationId); const mirrored = yield* users.use((s) => s.upsertOrganization({ id: org.id, name: org.name }), ); - const slugged = yield* users.use((s) => s.ensureOrganizationSlug(mirrored)); // Attach the just-accepted org to the current session. Same shape // as createOrganization: refresh + verify; if we can't pin the @@ -468,7 +484,7 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( } (yield* SessionCookies).set("wos-session", refreshed, RESPONSE_COOKIE_OPTIONS); - return { id: org.id, name: org.name, slug: slugged.slug }; + return { id: org.id, name: org.name, slug: mirrored.slug }; }), ) .handle("getMcpPaused", ({ params }) => diff --git a/apps/cloud/src/auth/middleware-live.ts b/apps/cloud/src/auth/middleware-live.ts index 0d35218a6b..a0809737d4 100644 --- a/apps/cloud/src/auth/middleware-live.ts +++ b/apps/cloud/src/auth/middleware-live.ts @@ -6,7 +6,12 @@ import { Effect, Layer, Redacted } from "effect"; import { HttpServerResponse } from "effect/unstable/http"; -import { AuthContext, NoOrganization, Unauthorized } from "@executor-js/api/server"; +import { + AuthContext, + authContextFromPrincipal, + NoOrganization, + Unauthorized, +} from "@executor-js/api/server"; import { OrgAuth, @@ -93,17 +98,20 @@ export const OrgAuthLive = Layer.effect( // (org-settings → domain verification) is niche; URL-scoping it would // mean converting this to an HttpRouter middleware — deferred. const session = sessionFromSealed(result, Redacted.value(credential)); - const auth = { + // Wrap the WorkOS session into a neutral `Principal` and let + // `authContextFromPrincipal` derive everything (incl. the run actor), + // so this path never re-implements the actor/label logic. `roles` is + // empty (the WorkOS control plane doesn't resolve them here; no cloud + // handler reads them) and `organizationName` is unused by AuthContext. + const auth = authContextFromPrincipal({ accountId: session.accountId, organizationId: result.organizationId, + organizationName: "", email: session.email, name: session.name, avatarUrl: session.avatarUrl, - // The unified `AuthContext` carries roles; cloud's WorkOS control - // plane does not resolve them here, so pass an empty list (no cloud - // handler reads roles today). roles: [], - }; + }); return yield* Effect.provideService(httpEffect, AuthContext, auth); }), diff --git a/apps/cloud/src/auth/org-selector-auth.node.test.ts b/apps/cloud/src/auth/org-selector-auth.node.test.ts index ef27f68f77..1aae25a551 100644 --- a/apps/cloud/src/auth/org-selector-auth.node.test.ts +++ b/apps/cloud/src/auth/org-selector-auth.node.test.ts @@ -32,7 +32,7 @@ const stubApiKeys = Layer.succeed(ApiKeyService)({ const stubWorkOS = Layer.succeed( WorkOSClient, new Proxy({} as WorkOSClientService, { - get: (_t, prop) => { + get: (target, prop) => { if (prop === "authenticateRequest") { return () => Effect.succeed({ userId: MEMBER, email: "u@e2e.test", organizationId: SESSION_ORG }); @@ -60,9 +60,10 @@ const stubUsers = Layer.succeed(UserStoreService)({ fn({ ensureAccount: async (id: string) => ({ id, createdAt }), getAccount: async (id: string) => ({ id, createdAt }), + // Slug is minted at insert now — the stub returns a slugged row. upsertOrganization: async (org: { id: string; name: string }) => ({ ...org, - slug: null, + slug: org.id, createdAt, }), getOrganization: async (id: string) => ({ id, name: `Org ${id}`, slug: id, createdAt }), @@ -74,11 +75,6 @@ const stubUsers = Layer.succeed(UserStoreService)({ slug, createdAt, }), - ensureOrganizationSlug: async (org: { id: string; name: string; slug: string | null }) => ({ - ...org, - slug: org.slug ?? org.id, - createdAt, - }), }), ), }); diff --git a/apps/cloud/src/auth/organization.ts b/apps/cloud/src/auth/organization.ts index f734453f59..c1242c82b3 100644 --- a/apps/cloud/src/auth/organization.ts +++ b/apps/cloud/src/auth/organization.ts @@ -29,22 +29,20 @@ import { WorkOSClient } from "./workos"; // unknown org is read. All other callers just do `getOrganization` and get // a self-healing lookup for free. // -// URL slugs are OURS (WorkOS orgs have none): resolution also lazily mints a -// slug for any org that predates them, so every resolved org is routable at -// `//…` without a separate backfill dependency. +// URL slugs are OURS (WorkOS orgs have none) and are minted at the moment a +// row is inserted — `upsertOrganization` is the single mint point, so the +// mirror-on-first-read below produces a slugged, routable org without any +// read-path healing. export const resolveOrganization = (organizationId: string) => Effect.gen(function* () { const users = yield* UserStoreService; const existing = yield* users.use((s) => s.getOrganization(organizationId)); - if (existing) return yield* users.use((s) => s.ensureOrganizationSlug(existing)); + if (existing) return existing; const workos = yield* WorkOSClient; const fresh = yield* workos.getOrganization(organizationId); - const mirrored = yield* users.use((s) => - s.upsertOrganization({ id: fresh.id, name: fresh.name }), - ); - return yield* users.use((s) => s.ensureOrganizationSlug(mirrored)); + return yield* users.use((s) => s.upsertOrganization({ id: fresh.id, name: fresh.name })); }); // --------------------------------------------------------------------------- diff --git a/apps/cloud/src/auth/return-to.test.ts b/apps/cloud/src/auth/return-to.test.ts index 01585c273b..6178edf175 100644 --- a/apps/cloud/src/auth/return-to.test.ts +++ b/apps/cloud/src/auth/return-to.test.ts @@ -7,7 +7,13 @@ import { isSafeReturnTo, loginPath, safeReturnTo } from "./return-to"; // boundary, so the validator is what stands between "resume where you were" // and an open redirect. describe("isSafeReturnTo", () => { - const safe = ["/", "/tools", "/integrations/sentry?addAccount=1", "/billing/plans"]; + const safe = [ + "/", + "/tools", + "/integrations/sentry?addAccount=1", + "/billing/plans", + "/api/oauth/callback?state=oauth-state&code=provider-code", + ]; for (const path of safe) { it(`allows ${path}`, () => { expect(isSafeReturnTo(path)).toBe(true); @@ -18,6 +24,7 @@ describe("isSafeReturnTo", () => { "https://evil.example", // absolute URL — off-origin redirect "//evil.example", // protocol-relative — same thing in disguise "/api/auth/logout", // API endpoints are never a landing page + "/api/oauth/callback/extra?state=oauth-state", // only the exact OAuth callback resumes "/api", // bare /api too "javascript:alert(1)", // not a path at all "tools", // relative paths resolve unpredictably @@ -56,4 +63,9 @@ describe("loginPath", () => { "/login?returnTo=%2Fintegrations%2Fsentry%3FaddAccount%3D1", ); }); + it("carries OAuth callback resumes URI-encoded", () => { + expect(loginPath("/api/oauth/callback?state=oauth-state&code=provider-code")).toBe( + "/login?returnTo=%2Fapi%2Foauth%2Fcallback%3Fstate%3Doauth-state%26code%3Dprovider-code", + ); + }); }); diff --git a/apps/cloud/src/auth/return-to.ts b/apps/cloud/src/auth/return-to.ts index 79bf17bd65..259b3ea6f9 100644 --- a/apps/cloud/src/auth/return-to.ts +++ b/apps/cloud/src/auth/return-to.ts @@ -11,8 +11,14 @@ // Pure string code — imported by server handlers and the login page alike. // --------------------------------------------------------------------------- +const pathPart = (path: string): string => path.split(/[?#]/, 1)[0] ?? ""; + +const isOAuthCallbackReturnTo = (path: string): boolean => pathPart(path) === "/api/oauth/callback"; + export const isSafeReturnTo = (path: string): boolean => - path.startsWith("/") && !path.startsWith("//") && !/^\/api(\/|$)/.test(path); + path.startsWith("/") && + !path.startsWith("//") && + (!/^\/api(\/|$)/.test(path) || isOAuthCallbackReturnTo(path)); /** The validated returnTo, or null when absent/unsafe. */ export const safeReturnTo = (path: string | null | undefined): string | null => diff --git a/apps/cloud/src/auth/ssr-gate.ts b/apps/cloud/src/auth/ssr-gate.ts index 011d484a8e..c329ac8cbd 100644 --- a/apps/cloud/src/auth/ssr-gate.ts +++ b/apps/cloud/src/auth/ssr-gate.ts @@ -139,22 +139,18 @@ const resolveAuthHint = async ( }; // The sealed session carries the org ID but not its name/slug; the local -// mirror has both (the slug minted lazily for orgs that predate slugs). Only -// consulted when minting (absent/mismatched hint) — never on the steady-state -// path — and over per-request layers, because a connection cached in the -// shared runtime would be reused across requests, which Cloudflare forbids. A -// miss or failure reads as empty strings — display-only, corrected by the -// client's /account/me write. +// mirror has both (every org row is born with a slug — see +// `upsertOrganization`). Only consulted when minting the hint (absent/mismatched +// cookie) — never on the steady-state path — and over per-request layers, +// because a connection cached in the shared runtime would be reused across +// requests, which Cloudflare forbids. A miss or failure reads as empty strings +// — display-only, corrected by the client's /account/me write. const organizationDisplay = async ( organizationId: string, ): Promise<{ name: string; slug: string }> => { const exit = await getRuntime().runPromiseExit( Effect.flatMap(UserStoreService.asEffect(), (users) => - users.use(async (store) => { - const org = await store.getOrganization(organizationId); - if (!org) return null; - return store.ensureOrganizationSlug(org); - }), + users.use((store) => store.getOrganization(organizationId)), ).pipe(Effect.provide(Layer.provide(makeUserStoreLayer(), makeDbLayer()))), ); return Exit.isSuccess(exit) diff --git a/apps/cloud/src/auth/user-store.ts b/apps/cloud/src/auth/user-store.ts index 5d08d9517b..76929bc06c 100644 --- a/apps/cloud/src/auth/user-store.ts +++ b/apps/cloud/src/auth/user-store.ts @@ -17,9 +17,6 @@ import type { DrizzleDb } from "../db/db"; export type Account = typeof accounts.$inferSelect; export type Organization = typeof organizations.$inferSelect; -/** An organization row whose slug has been minted (see `ensureOrganizationSlug`). */ -export type SluggedOrganization = Organization & { readonly slug: string }; - export const makeUserStore = (db: DrizzleDb) => { const getOrganization = async (id: string) => { const rows = await db.select().from(organizations).where(eq(organizations.id, id)); @@ -34,38 +31,45 @@ export const makeUserStore = (db: DrizzleDb) => { return rows.length > 0; }; - // The unique index can reject a candidate when two requests race to mint - // slugs (for the same org or colliding candidates); surface that as null - // and let the caller re-read or retry. - const trySetSlug = async (organizationId: string, slug: string) => { - // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: a unique-index violation from a concurrent mint is an expected race, not a failure - try { + // Insert a brand-new org row carrying a freshly-minted slug. `ON CONFLICT DO + // NOTHING` (no target) absorbs BOTH unique violations without throwing: an + // id collision (the org was mirrored concurrently) and a slug collision (the + // candidate was claimed by a different org). Returns the inserted row, or + // null when either conflict swallowed the insert — the caller decides whether + // to re-read (id race) or retry with a new candidate (slug race). + const tryInsertOrg = async (id: string, name: string, slug: string) => { + const [row] = await db + .insert(organizations) + .values({ id, name, slug }) + .onConflictDoNothing() + .returning(); + return row ?? null; + }; + + // Every new org row is born with a slug — there is no nullable window and no + // self-healing. Existing rows keep their slug (stable across renames, so org + // URLs survive) and only refresh their name. + const upsertOrganization = async (org: { id: string; name: string }) => { + const existing = await getOrganization(org.id); + if (existing) { const [updated] = await db .update(organizations) - .set({ slug }) - .where(eq(organizations.id, organizationId)) + .set({ name: org.name }) + .where(eq(organizations.id, org.id)) .returning(); - return updated ?? null; - } catch { - return null; + return updated ?? existing; } - }; - - // Mint and persist a slug for an org that predates slugs (or was mirrored - // before its name was known). Slugs are stable once set — renames do NOT - // regenerate them, so org URLs survive. - const ensureOrganizationSlug = async (org: Organization): Promise => { - if (org.slug) return org as SluggedOrganization; - for (let attempt = 0; attempt < 3; attempt++) { + for (let attempt = 0; attempt < 4; attempt++) { const slug = await generateOrgSlug(org.name, slugTaken); - const updated = await trySetSlug(org.id, slug); - if (updated?.slug) return updated as SluggedOrganization; - // Lost a race: either another request minted this org's slug (re-read - // and return), or the candidate got claimed by a different org (retry). + const inserted = await tryInsertOrg(org.id, org.name, slug); + if (inserted) return inserted; + // The insert was swallowed by a conflict. If the id now exists, a + // concurrent request mirrored it — return that row. Otherwise the slug + // candidate collided; loop and mint a fresh one. const fresh = await getOrganization(org.id); - if (fresh?.slug) return fresh as SluggedOrganization; + if (fresh) return fresh; } - // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: slug minting exhausted retries; surfacing loudly beats silently unslugged orgs + // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: slug minting exhausted retries; surfacing loudly beats a silently unslugged org throw new Error(`unable to mint a slug for organization ${org.id}`); }; @@ -84,17 +88,7 @@ export const makeUserStore = (db: DrizzleDb) => { // --- Organizations --- - upsertOrganization: async (org: { id: string; name: string }) => { - const [result] = await db - .insert(organizations) - .values(org) - .onConflictDoUpdate({ - target: organizations.id, - set: { name: org.name }, - }) - .returning(); - return result!; - }, + upsertOrganization, getOrganization, @@ -102,7 +96,5 @@ export const makeUserStore = (db: DrizzleDb) => { const rows = await db.select().from(organizations).where(eq(organizations.slug, slug)); return rows[0] ?? null; }, - - ensureOrganizationSlug, }; }; diff --git a/apps/cloud/src/auth/workos-auth-provider.ts b/apps/cloud/src/auth/workos-auth-provider.ts index ee9892e999..0c9477c385 100644 --- a/apps/cloud/src/auth/workos-auth-provider.ts +++ b/apps/cloud/src/auth/workos-auth-provider.ts @@ -29,6 +29,7 @@ import { Effect, Layer } from "effect"; import { HttpServerResponse } from "effect/unstable/http"; +import type { JWTVerifyGetKey } from "jose"; import { IdentityProvider, @@ -39,6 +40,7 @@ import { import type { FailureRenderingStrategy, IdentityFailure, Principal } from "@executor-js/api/server"; import { ApiKeyService } from "./api-keys"; +import { workosApiJwtBearerConfig } from "./api-jwt-bearer"; import { BEARER_PREFIX } from "./bearer"; import { authorizeOrganization, @@ -49,6 +51,20 @@ import { UserStoreService } from "./context"; import { sealedSessionDisplayName } from "./middleware"; import type { UserStoreError, WorkOSError } from "./errors"; import { WorkOSClient } from "./workos"; +import { verifyWorkosUserManagementToken } from "../mcp/jwt"; + +/** + * The config the bearer-JWT branch needs to verify a WorkOS device-login + * (user_management) access token: the client-scoped SSO JWKS resolver. Issuer + * and audience are NOT pinned (the client-scoped JWKS binds the token to this + * app; user_management tokens carry no audience and an app-specific issuer) and + * org membership is re-checked live downstream. Passed in as a plain value so + * this module stays `cloudflare:workers`-free and the node-pool resolver tests + * can inject a local JWKS. Production supplies {@link workosApiJwtBearerConfig}. + */ +export interface JwtBearerConfig { + readonly jwks: JWTVerifyGetKey; +} // The exact machine codes + messages each rejected path has always emitted. // Carried on the shared identity error so the failure strategy renders the @@ -70,8 +86,73 @@ const NO_ORGANIZATION_IN_SESSION = { code: "no_organization", message: "No organization in session", }; +const INVALID_ACCESS_TOKEN = { + code: "invalid_access_token", + message: "Invalid or expired access token", +}; +const ACCESS_TOKEN_VERIFICATION_UNAVAILABLE = { + code: "access_token_verification_unavailable", + message: "Access token verification is temporarily unavailable", +}; +const NO_ORGANIZATION_IN_ACCESS_TOKEN = { + code: "no_organization", + message: "No organization in access token", +}; + +// A bearer value with three dot-separated segments is a JWT (a WorkOS access +// token from the CLI device-login); anything else is treated as an API key. +// Same discriminator the MCP plane uses (`mcp/auth.ts`). +const looksLikeJwt = (token: string): boolean => token.split(".").length === 3; -export const resolveApiKeyPrincipal = (request: Request) => +/** + * Resolve a WorkOS device-login (user_management) access token into a protected + * `Principal`. Verifies the token's signature + expiry against the client-scoped + * SSO JWKS, then live-checks org membership, exactly like the api-key path. The + * `org_id` claim must be present (a token with no org context is rejected as + * `NoOrganization`). NOTE: this is a different WorkOS token domain than the MCP + * `/oauth2` tokens (different keyset, no audience), so it does NOT reuse the MCP + * verifier or its JWKS, audience, and issuer. + */ +const resolveJwtPrincipal = (token: string, jwt: JwtBearerConfig) => + Effect.gen(function* () { + const verified = yield* verifyWorkosUserManagementToken(token, jwt.jwks).pipe( + Effect.catchTag("McpJwtVerificationError", (error) => + Effect.fail( + error.reason === "system" + ? new Unavailable(ACCESS_TOKEN_VERIFICATION_UNAVAILABLE) + : new Unauthorized(INVALID_ACCESS_TOKEN), + ), + ), + ); + + if (!verified || !verified.accountId) return yield* new Unauthorized(INVALID_ACCESS_TOKEN); + if (!verified.organizationId) { + return yield* new NoOrganization(NO_ORGANIZATION_IN_ACCESS_TOKEN); + } + + const org = yield* authorizeOrganization(verified.accountId, verified.organizationId); + if (!org) return yield* new NoOrganization(NO_ORGANIZATION_IN_ACCESS_TOKEN); + + return { + accountId: verified.accountId, + organizationId: org.id, + organizationName: org.name, + organizationSlug: org.slug, + email: "", + name: null, + avatarUrl: null, + roles: [], + } satisfies Principal; + }); + +/** + * Resolve a `Bearer` credential into a `Principal`: a WorkOS access-token JWT + * (when `jwt` config is supplied and the value looks like a JWT) or a WorkOS + * API key. Returns `null` when there is no `Authorization` header, so the + * caller falls through to the sealed-session path. (Kept the historical name, + * the re-export and resolver tests reference it.) + */ +export const resolveApiKeyPrincipal = (request: Request, jwt: JwtBearerConfig | null = null) => Effect.gen(function* () { const authHeader = request.headers.get("authorization"); if (!authHeader) return null; @@ -83,6 +164,8 @@ export const resolveApiKeyPrincipal = (request: Request) => const value = authHeader.slice(BEARER_PREFIX.length).trim(); if (!value) return yield* new Unauthorized(INVALID_API_KEY); + if (jwt && looksLikeJwt(value)) return yield* resolveJwtPrincipal(value, jwt); + const apiKeys = yield* ApiKeyService; const principal = yield* apiKeys .validate(value) @@ -101,6 +184,7 @@ export const resolveApiKeyPrincipal = (request: Request) => accountId: principal.accountId, organizationId: org.id, organizationName: org.name, + organizationSlug: org.slug, email: "", name: null, avatarUrl: null, @@ -129,6 +213,7 @@ export const resolveSessionPrincipal = (request: Request) => accountId: session.userId, organizationId: org.id, organizationName: org.name, + organizationSlug: org.slug, email: session.email, name: sealedSessionDisplayName(session), avatarUrl: session.avatarUrl ?? null, @@ -149,14 +234,15 @@ export const resolveSessionPrincipal = (request: Request) => */ export const resolveProtectedPrincipal = ( request: Request, + jwt: JwtBearerConfig | null = null, ): Effect.Effect< Principal, Unauthorized | NoOrganization | Unavailable | UserStoreError | WorkOSError, WorkOSClient | ApiKeyService | UserStoreService > => Effect.gen(function* () { - const apiKeyPrincipal = yield* resolveApiKeyPrincipal(request); - if (apiKeyPrincipal) return apiKeyPrincipal; + const bearerPrincipal = yield* resolveApiKeyPrincipal(request, jwt); + if (bearerPrincipal) return bearerPrincipal; return yield* resolveSessionPrincipal(request); }); @@ -180,7 +266,7 @@ export const workosIdentityLayer: Layer.Layer< const context = yield* Effect.context(); return IdentityProvider.of({ authenticate: (request) => - resolveProtectedPrincipal(request).pipe( + resolveProtectedPrincipal(request, workosApiJwtBearerConfig).pipe( // `UserStoreError` / `WorkOSError` are org-resolution infra failures — // surface as a 500 defect, exactly as the old inline resolver let them // bubble. The narrow `die` here is the runtime edge for that infra diff --git a/apps/cloud/src/db/code-migration-runner.test.ts b/apps/cloud/src/db/code-migration-runner.test.ts new file mode 100644 index 0000000000..1193281d2f --- /dev/null +++ b/apps/cloud/src/db/code-migration-runner.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Effect } from "effect"; + +import { + runCodeMigrations, + type CodeMigration, + type CodeMigrationSql, +} from "../../scripts/code-migrations/runner"; + +const makeFakeSql = (options: { readonly ledgerExists?: boolean; readonly stamped?: string[] }) => { + const log: { readonly query: string; readonly params?: readonly unknown[] }[] = []; + const stamped = [...(options.stamped ?? [])]; + const sql: CodeMigrationSql = { + unsafe: (query, params) => { + log.push({ query, params }); + if (query.includes("to_regclass")) { + const rows = [ + { ledger_table: options.ledgerExists === false ? null : "cloud_code_migration" }, + ]; + return Promise.resolve(rows as never); + } + if (query.includes('SELECT "name" FROM "cloud_code_migration"')) { + return Promise.resolve(stamped.map((name) => ({ name })) as never); + } + if (query.includes('INSERT INTO "cloud_code_migration"')) { + stamped.push(String(params?.[0])); + } + return Promise.resolve([] as never); + }, + }; + return { sql, log, stamped }; +}; + +const migrationSpy = (name: string): { migration: CodeMigration; calls: boolean[] } => { + const calls: boolean[] = []; + return { + calls, + migration: { + name, + run: async ({ dryRun }) => { + calls.push(dryRun); + return { summary: `${name} done` }; + }, + }, + }; +}; + +describe("runCodeMigrations", () => { + it.effect("runs pending migrations and stamps them", () => + Effect.promise(async () => { + const { sql, stamped } = makeFakeSql({}); + const a = migrationSpy("2026-06-20-a"); + const b = migrationSpy("2026-06-21-b"); + + const applied = await runCodeMigrations(sql, [a.migration, b.migration], { log: () => {} }); + + expect(applied).toEqual(["2026-06-20-a", "2026-06-21-b"]); + expect(a.calls).toEqual([false]); + expect(b.calls).toEqual([false]); + expect(stamped).toEqual(["2026-06-20-a", "2026-06-21-b"]); + }), + ); + + it.effect("dry run plans pending migrations without creating or stamping the ledger", () => + Effect.promise(async () => { + const { sql, log, stamped } = makeFakeSql({ ledgerExists: false }); + const migration = migrationSpy("2026-06-20-a"); + + const applied = await runCodeMigrations(sql, [migration.migration], { + dryRun: true, + log: () => {}, + }); + + expect(applied).toEqual(["2026-06-20-a"]); + expect(migration.calls).toEqual([true]); + expect(stamped).toEqual([]); + expect(log.some((entry) => entry.query.includes("CREATE TABLE"))).toBe(false); + expect(log.some((entry) => entry.query.includes("INSERT INTO"))).toBe(false); + }), + ); + + it.effect("skips stamped migrations", () => + Effect.promise(async () => { + const { sql } = makeFakeSql({ stamped: ["2026-06-20-a"] }); + const a = migrationSpy("2026-06-20-a"); + const b = migrationSpy("2026-06-21-b"); + + const applied = await runCodeMigrations(sql, [a.migration, b.migration], { log: () => {} }); + + expect(applied).toEqual(["2026-06-21-b"]); + expect(a.calls).toEqual([]); + expect(b.calls).toEqual([false]); + }), + ); + + it.effect("rejects duplicate names before touching the database", () => + Effect.promise(async () => { + const { sql, log } = makeFakeSql({}); + const a = migrationSpy("2026-06-20-a"); + + await expect(runCodeMigrations(sql, [a.migration, a.migration])).rejects.toThrow( + "Duplicate code migration name", + ); + expect(log).toEqual([]); + }), + ); +}); diff --git a/apps/cloud/src/db/db.schema.test.ts b/apps/cloud/src/db/db.schema.test.ts index 1bcf14c6df..dfa6e85d8d 100644 --- a/apps/cloud/src/db/db.schema.test.ts +++ b/apps/cloud/src/db/db.schema.test.ts @@ -106,9 +106,14 @@ describe("combinedSchema", () => { provider: "postgresql", }); const schemaTables = drizzleTableColumns(combinedSchema); + const missingTables: string[] = []; const missingColumns: string[] = []; for (const table of Object.values(fumaDbInternals(fuma.db).internal.tables)) { + if (schemaTables[table.names.drizzle] === undefined) { + missingTables.push(table.names.drizzle); + continue; + } const drizzleTable = drizzleTableColumns(schemaTables[table.names.drizzle]); for (const column of Object.values(table.columns)) { if (drizzleTable[column.names.drizzle] === undefined) { @@ -119,6 +124,7 @@ describe("combinedSchema", () => { } } + expect(missingTables).toEqual([]); expect(missingColumns).toEqual([]); }), ), diff --git a/apps/cloud/src/db/db.test.ts b/apps/cloud/src/db/db.test.ts index b437b4bf76..889daab6dc 100644 --- a/apps/cloud/src/db/db.test.ts +++ b/apps/cloud/src/db/db.test.ts @@ -22,6 +22,7 @@ import { describe, it, expect } from "@effect/vitest"; import { Effect, Layer } from "effect"; +import { isValidOrgSlug } from "@executor-js/api"; import { DbService } from "./db"; import { makeUserStore } from "../auth/user-store"; @@ -118,3 +119,37 @@ describe("DbService", () => { expect(result?.name).toBe("Acme"); }, 15_000); }); + +describe("upsertOrganization · slug is minted at insert", () => { + const upsert = (org: { id: string; name: string }) => + program( + Effect.gen(function* () { + const { db } = yield* DbService; + return yield* Effect.promise(() => makeUserStore(db).upsertOrganization(org)); + }), + ); + + it("mints a valid slug on insert", async () => { + const org = await upsert({ id: `org_${crypto.randomUUID()}`, name: "Slug Mint Co" }); + expect(org.slug, "a new org row is born with a slug").toBeTruthy(); + expect(isValidOrgSlug(org.slug), "the minted slug fits the URL grammar").toBe(true); + }); + + it("keeps the slug stable across renames (conflict updates name only)", async () => { + const id = `org_${crypto.randomUUID()}`; + const created = await upsert({ id, name: "Original Name" }); + const renamed = await upsert({ id, name: "Renamed Org" }); + expect(renamed.slug, "the slug survives a rename").toBe(created.slug); + expect(renamed.name, "the name is refreshed on conflict").toBe("Renamed Org"); + }); + + it("discriminates same-name collisions into distinct slugs", async () => { + // Same name → same slug base; the second insert collides on the unique + // index and gets a discriminated slug. + const name = `Collide ${crypto.randomUUID().slice(0, 8)}`; + const a = await upsert({ id: `org_${crypto.randomUUID()}`, name }); + const b = await upsert({ id: `org_${crypto.randomUUID()}`, name }); + expect(a.slug).not.toBe(b.slug); + expect(isValidOrgSlug(a.slug) && isValidOrgSlug(b.slug), "both slugs are valid").toBe(true); + }); +}); diff --git a/apps/cloud/src/db/executor-schema.ts b/apps/cloud/src/db/executor-schema.ts index ad0839cc10..ee33da58f9 100644 --- a/apps/cloud/src/db/executor-schema.ts +++ b/apps/cloud/src/db/executor-schema.ts @@ -15,7 +15,9 @@ export const integration = pgTable( { slug: varchar("slug", { length: 255 }).notNull(), plugin_id: text("plugin_id").notNull(), - description: text("description").notNull(), + name: text("name"), + description: text("description"), + config_revised_at: bigint("config_revised_at", { mode: "bigint" }), config: json("config"), can_remove: boolean("can_remove").notNull().default(true), can_refresh: boolean("can_refresh").notNull().default(false), @@ -39,11 +41,14 @@ export const connection = pgTable( provider: text("provider").notNull(), item_ids: json("item_ids").notNull(), identity_label: text("identity_label"), + description: text("description"), + tools_synced_at: bigint("tools_synced_at", { mode: "bigint" }), oauth_client: text("oauth_client"), oauth_client_owner: text("oauth_client_owner"), refresh_item_id: text("refresh_item_id"), expires_at: bigint("expires_at", { mode: "bigint" }), oauth_scope: text("oauth_scope"), + oauth_token_url: text("oauth_token_url"), provider_state: json("provider_state"), created_at: timestamp("created_at").notNull(), updated_at: timestamp("updated_at").notNull(), @@ -179,6 +184,44 @@ export const definition = pgTable( ], ); +export const tool_schema_manifest = pgTable( + "tool_schema_manifest", + { + integration: varchar("integration", { length: 255 }).notNull(), + connection: varchar("connection", { length: 255 }).notNull(), + plugin_id: text("plugin_id").notNull(), + name: varchar("name", { length: 255 }).notNull(), + path: varchar("path", { length: 255 }).notNull(), + description: text("description").notNull(), + descriptor_hash: text("descriptor_hash").notNull(), + input_schema_hash: text("input_schema_hash").notNull(), + output_schema_hash: text("output_schema_hash").notNull(), + definition_set_hash: text("definition_set_hash").notNull(), + index_fingerprint: text("index_fingerprint").notNull(), + fingerprint_version: text("fingerprint_version").notNull(), + source_revision: text("source_revision"), + created_at: timestamp("created_at").notNull(), + updated_at: timestamp("updated_at").notNull(), + row_id: varchar("row_id", { length: 255 }) + .primaryKey() + .notNull() + .$defaultFn(() => createId()), + tenant: varchar("tenant", { length: 255 }).notNull(), + owner: varchar("owner", { length: 255 }).notNull(), + subject: varchar("subject", { length: 255 }).notNull(), + }, + (table) => [ + uniqueIndex("tool_schema_manifest_uidx").on( + table.tenant, + table.owner, + table.subject, + table.integration, + table.connection, + table.name, + ), + ], +); + export const tool_policy = pgTable( "tool_policy", { diff --git a/apps/cloud/src/db/google-openapi-r2-code-migration.test.ts b/apps/cloud/src/db/google-openapi-r2-code-migration.test.ts new file mode 100644 index 0000000000..60df4afe35 --- /dev/null +++ b/apps/cloud/src/db/google-openapi-r2-code-migration.test.ts @@ -0,0 +1,119 @@ +/* oxlint-disable executor/no-error-constructor, executor/no-try-catch-or-throw -- test fake models missing R2 objects */ + +import { writeFileSync, readFileSync } from "node:fs"; + +import { describe, expect, it } from "@effect/vitest"; +import { Effect } from "effect"; + +import { + googleOpenApiR2BlobMigration, + type R2ObjectStore, +} from "../../scripts/code-migrations/google-openapi-r2-blobs"; +import type { CodeMigrationContext, CodeMigrationSql } from "../../scripts/code-migrations/runner"; + +interface GoogleRow { + readonly tenant: string; + readonly slug: string; + readonly plugin_id: string; + readonly spec_hash: string; +} + +const sourceKey = (row: GoogleRow): string => `o:${row.tenant}/openapi/spec/${row.spec_hash}`; +const targetKey = (row: GoogleRow): string => `o:${row.tenant}/google/spec/${row.spec_hash}`; + +const makeSql = (rows: readonly GoogleRow[]): CodeMigrationSql => ({ + unsafe: () => Promise.resolve(rows as never), +}); + +const makeStore = ( + objects: Record, +): { readonly store: R2ObjectStore; readonly objects: Map } => { + const objectMap = new Map( + Object.entries(objects).map(([key, value]) => [key, Buffer.from(value, "utf8")] as const), + ); + return { + objects: objectMap, + store: { + exists: (key) => objectMap.has(key), + get: (key, file) => { + const value = objectMap.get(key); + if (!value) throw new Error(`missing ${key}`); + writeFileSync(file, value); + }, + put: (key, file) => { + objectMap.set(key, readFileSync(file)); + }, + }, + }; +}; + +const runMigration = async (input: { + readonly dryRun: boolean; + readonly rows: readonly GoogleRow[]; + readonly objects: Record; +}) => { + const { store, objects } = makeStore(input.objects); + const migration = googleOpenApiR2BlobMigration({ bucket: "executor-cloud-blobs", store }); + const context: CodeMigrationContext = { + sql: makeSql(input.rows), + dryRun: input.dryRun, + log: () => {}, + }; + return { result: await migration.run(context), objects }; +}; + +describe("googleOpenApiR2BlobMigration", () => { + it.effect("dry run reports source and target R2 coverage without copying", () => + Effect.promise(async () => { + const rows = [ + { tenant: "org_1", slug: "google", plugin_id: "openapi", spec_hash: "hash-a" }, + { tenant: "org_2", slug: "google", plugin_id: "openapi", spec_hash: "hash-b" }, + ]; + const { result, objects } = await runMigration({ + dryRun: true, + rows, + objects: { + [sourceKey(rows[0]!)]: "spec-a", + [targetKey(rows[0]!)]: "spec-a", + [sourceKey(rows[1]!)]: "spec-b", + }, + }); + + expect(result.summary).toContain("2/2 source R2 object(s) present"); + expect(result.summary).toContain("1/2 target R2 object(s) already present"); + expect(result.summary).toContain("would copy 1 object(s)"); + expect(objects.has(targetKey(rows[1]!))).toBe(false); + }), + ); + + it.effect("apply copies missing target objects and leaves existing matches alone", () => + Effect.promise(async () => { + const rows = [ + { tenant: "org_1", slug: "google", plugin_id: "google", spec_hash: "hash-a" }, + { tenant: "org_2", slug: "google", plugin_id: "google", spec_hash: "hash-b" }, + ]; + const { result, objects } = await runMigration({ + dryRun: false, + rows, + objects: { + [sourceKey(rows[0]!)]: "spec-a", + [targetKey(rows[0]!)]: "spec-a", + [sourceKey(rows[1]!)]: "spec-b", + }, + }); + + expect(result.summary).toContain("copied 1 R2 object(s)"); + expect(objects.get(targetKey(rows[1]!))?.toString("utf8")).toBe("spec-b"); + }), + ); + + it.effect("fails when a source object is missing", () => + Effect.promise(async () => { + const rows = [{ tenant: "org_1", slug: "google", plugin_id: "openapi", spec_hash: "hash-a" }]; + + await expect(runMigration({ dryRun: true, rows, objects: {} })).rejects.toThrow( + "source object(s) missing", + ); + }), + ); +}); diff --git a/apps/cloud/src/db/schema.ts b/apps/cloud/src/db/schema.ts index 7779a65412..fb1bd79987 100644 --- a/apps/cloud/src/db/schema.ts +++ b/apps/cloud/src/db/schema.ts @@ -22,15 +22,17 @@ export const accounts = pgTable("accounts", { /** * Organization (billing entity, scoping root). The `id` is the WorkOS * organization ID. The `slug` is OURS, not WorkOS's (their org object has no - * slug): minted at create time, lazily backfilled for orgs first seen via the - * mirror's self-heal path, and stable across renames so org URLs don't break. + * slug): minted at the moment a row is inserted (the single mint point is + * `upsertOrganization`) and stable across renames so org URLs don't break. + * NOT NULL — there is no nullable window: legacy rows were backfilled once and + * every insert since carries a slug. */ export const organizations = pgTable( "organizations", { id: text("id").primaryKey(), name: text("name").notNull(), - slug: text("slug"), + slug: text("slug").notNull(), createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), }, (t) => ({ diff --git a/apps/cloud/src/edge/docs.test.ts b/apps/cloud/src/edge/docs.test.ts new file mode 100644 index 0000000000..da59ded509 --- /dev/null +++ b/apps/cloud/src/edge/docs.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "@effect/vitest"; + +import { buildDocsUpstream, isDocsPath } from "./docs"; + +// The docs proxy claims `/docs` and forwards it to Mintlify. Two things must +// hold: the path matcher catches the docs tree without swallowing the app-owned +// `/api/docs` (Swagger) or unrelated `/docs`-prefixed words, and the rewritten +// upstream request points at Mintlify with the path preserved and the session +// cookie stripped. +describe("isDocsPath", () => { + const docs = ["/docs", "/docs/", "/docs/quickstart", "/docs/guides/auth"]; + for (const pathname of docs) { + it(`proxies ${pathname} to Mintlify`, () => { + expect(isDocsPath(pathname)).toBe(true); + }); + } + + // `/api/docs` is the app-owned Swagger UI — it must reach the Effect handler, + // not Mintlify. `/docsmith` guards against a bare `startsWith("/docs")` that + // would capture unrelated words. + const notDocs = ["/api/docs", "/docsmith", "/", "/home", "/login", "/mcp"]; + for (const pathname of notDocs) { + it(`leaves ${pathname} alone`, () => { + expect(isDocsPath(pathname)).toBe(false); + }); + } +}); + +describe("buildDocsUpstream", () => { + const upstreamFor = (url: string, headers?: HeadersInit) => + buildDocsUpstream(new Request(url, { headers })); + + it("swaps the origin to Mintlify over https while preserving path + query", () => { + const upstream = new URL(upstreamFor("https://executor.sh/docs/guides/auth?theme=dark").url); + expect(upstream.hostname).toBe("executor.mintlify.dev"); + expect(upstream.protocol).toBe("https:"); + expect(upstream.pathname).toBe("/docs/guides/auth"); + expect(upstream.search).toBe("?theme=dark"); + }); + + it("forwards the public host so Mintlify can build canonical links", () => { + const upstream = upstreamFor("https://executor.sh/docs"); + expect(upstream.headers.get("x-forwarded-host")).toBe("executor.sh"); + expect(upstream.headers.get("x-forwarded-proto")).toBe("https"); + }); + + it("strips the cookie so the session never leaks to the docs origin", () => { + const upstream = upstreamFor("https://executor.sh/docs", { + cookie: "wos-session=secret", + }); + expect(upstream.headers.has("cookie")).toBe(false); + }); +}); diff --git a/apps/cloud/src/edge/docs.ts b/apps/cloud/src/edge/docs.ts new file mode 100644 index 0000000000..d9f18433fa --- /dev/null +++ b/apps/cloud/src/edge/docs.ts @@ -0,0 +1,48 @@ +// --------------------------------------------------------------------------- +// Docs reverse proxy — `/docs` (and everything under it) is served by Mintlify, +// not this worker. We forward those requests to the Mintlify deployment so the +// docs live on the first-party origin (`executor.sh/docs`) instead of a +// `*.mintlify.dev` subdomain. Mintlify hosts the site under the same `/docs` +// base path, so the pathname is forwarded UNCHANGED — only the host/proto swap +// to the upstream origin (unlike the PostHog proxy, which strips its prefix). +// +// Like the PostHog/Sentry tunnels (and unlike the marketing proxy, which needs +// the prod-only `env.MARKETING` service binding), this is a plain external +// `fetch`, so it runs on every host — `/docs` previews against live Mintlify in +// local dev too. `/docs` is distinct from the app-owned `/api/docs` (Swagger), +// so this never shadows an Effect-served route. +// --------------------------------------------------------------------------- + +import { createMiddleware } from "@tanstack/react-start"; + +const DOCS_UPSTREAM_HOST = "executor.mintlify.dev"; + +export const isDocsPath = (pathname: string) => + pathname === "/docs" || pathname.startsWith("/docs/"); + +// Build the upstream request for an already-classified `/docs` path. Caller +// guarantees `isDocsPath(pathname)` — we only swap the origin and fix up the +// forwarding headers, preserving method, body, path, and query. +export const buildDocsUpstream = (request: Request): Request => { + const url = new URL(request.url); + const forwardedHost = url.host; + + url.hostname = DOCS_UPSTREAM_HOST; + url.protocol = "https:"; + url.port = ""; + + const upstream = new Request(url, request); + // Mintlify keys canonical links off the public host; tell it the real one. + upstream.headers.set("X-Forwarded-Host", forwardedHost); + upstream.headers.set("X-Forwarded-Proto", "https"); + // Never leak the executor.sh session cookie to the docs origin. + upstream.headers.delete("cookie"); + return upstream; +}; + +export const docsProxyMiddleware = createMiddleware({ type: "request" }).server( + ({ pathname, request, next }) => { + if (!isDocsPath(pathname)) return next(); + return fetch(buildDocsUpstream(request)); + }, +); diff --git a/apps/cloud/src/edge/index.ts b/apps/cloud/src/edge/index.ts index 8319928824..bdb166892e 100644 --- a/apps/cloud/src/edge/index.ts +++ b/apps/cloud/src/edge/index.ts @@ -1,10 +1,11 @@ // --------------------------------------------------------------------------- -// Edge concerns — the analytics/marketing request middlewares that run at the -// worker edge BEFORE the app's own mcp + api dispatch. None of these touch the -// Effect app layer; they proxy or tunnel to external services (the marketing -// worker, Sentry, PostHog). +// Edge concerns — the analytics/marketing/docs request middlewares that run at +// the worker edge BEFORE the app's own mcp + api dispatch. None of these touch +// the Effect app layer; they proxy or tunnel to external services (the +// marketing worker, Sentry, PostHog, Mintlify docs). // --------------------------------------------------------------------------- export { marketingMiddleware } from "./marketing"; export { sentryTunnelMiddleware } from "./sentry-tunnel"; export { posthogProxyMiddleware } from "./posthog"; +export { docsProxyMiddleware } from "./docs"; diff --git a/apps/cloud/src/engine/execution-stack.ts b/apps/cloud/src/engine/execution-stack.ts index eb1101a9f2..a999e52e1c 100644 --- a/apps/cloud/src/engine/execution-stack.ts +++ b/apps/cloud/src/engine/execution-stack.ts @@ -83,9 +83,7 @@ export const CloudHostConfig: Layer.Layer = Layer.sync(HostConfig, ( // servers on localhost are reachable. See `hosted-http-client.ts`. allowLocalNetwork: env.ALLOW_LOCAL_NETWORK === "true", webBaseUrl: env.VITE_PUBLIC_SITE_URL ?? "https://executor.sh", - // `oauthCallbackPath` is NOT set here — `ExecutorApp.make` derives it from the - // `mountPrefix` cloud passes (`CLOUD_MOUNT_PREFIX`), so the callback can't drift - // from the route that serves it. + oauthCallbackPath: `${CLOUD_MOUNT_PREFIX}/oauth/callback`, // WorkOS Vault is cloud's credential storage implementation detail, not a // user-selectable provider surface. exposeCredentialProviders: false, diff --git a/apps/cloud/src/engine/execution-usage.ts b/apps/cloud/src/engine/execution-usage.ts index cd4489984f..c2dafc33d6 100644 --- a/apps/cloud/src/engine/execution-usage.ts +++ b/apps/cloud/src/engine/execution-usage.ts @@ -12,9 +12,9 @@ export const withExecutionUsageTracking = ( engine .execute(code, options) .pipe(Effect.tap(() => Effect.sync(() => trackUsage(organizationId)))), - executeWithPause: (code) => + executeWithPause: (code, options) => engine - .executeWithPause(code) + .executeWithPause(code, options) .pipe(Effect.tap(() => Effect.sync(() => trackUsage(organizationId)))), // resume doesn't count as usage resume: (executionId, response) => engine.resume(executionId, response), diff --git a/apps/cloud/src/extensions/billing/route.node.test.ts b/apps/cloud/src/extensions/billing/route.node.test.ts new file mode 100644 index 0000000000..d2966e4dc1 --- /dev/null +++ b/apps/cloud/src/extensions/billing/route.node.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Layer } from "effect"; + +import { UserStoreService } from "../../auth/context"; +import { WorkOSClient, type WorkOSClientService } from "../../auth/workos"; +import { resolveBillingOrganization } from "./route"; + +const createdAt = new Date("2026-01-01T00:00:00.000Z"); + +const MEMBER = "user_session"; +const SESSION_ORG = "org_session"; +const URL_ORG = "org_url"; +const URL_SLUG = "acme"; + +const stubWorkOS = Layer.succeed( + WorkOSClient, + new Proxy({} as WorkOSClientService, { + get: (_target, prop) => { + if (prop === "listUserMemberships") { + return (userId: string) => + Effect.succeed({ + data: + userId === MEMBER + ? [ + { userId, organizationId: SESSION_ORG, status: "active" }, + { userId, organizationId: URL_ORG, status: "active" }, + ] + : [], + }); + } + return () => Effect.die(`unexpected WorkOSClient.${String(prop)} call`); + }, + }), +); + +const stubUsers = Layer.succeed(UserStoreService)({ + use: (fn) => + Effect.promise(() => + fn({ + ensureAccount: async (id: string) => ({ id, createdAt }), + getAccount: async (id: string) => ({ id, createdAt }), + upsertOrganization: async (org: { id: string; name: string }) => ({ + ...org, + slug: org.id, + createdAt, + }), + getOrganization: async (id: string) => ({ id, name: `Org ${id}`, slug: id, createdAt }), + getOrganizationBySlug: async (slug: string) => ({ + id: slug === URL_SLUG ? URL_ORG : "org_outsider", + name: `Org ${slug}`, + slug, + createdAt, + }), + }), + ), +}); + +const run = (headers: Record, organizationId: string | null = SESSION_ORG) => + resolveBillingOrganization( + new Request("https://executor.test/api/billing/customer", { headers }), + { userId: MEMBER, organizationId }, + ).pipe(Effect.provide(Layer.mergeAll(stubWorkOS, stubUsers))); + +describe("billing route org selector", () => { + it.effect("falls back to the session org when no selector header is sent", () => + Effect.gen(function* () { + const org = yield* run({}); + expect(org.id).toBe(SESSION_ORG); + }), + ); + + it.effect("scopes billing to the URL org selector over the session org", () => + Effect.gen(function* () { + const org = yield* run({ "x-executor-organization": URL_SLUG }); + expect(org.id).toBe(URL_ORG); + }), + ); + + it.effect("rejects a selector for an org the caller is not a member of", () => + Effect.gen(function* () { + const error = yield* Effect.flip(run({ "x-executor-organization": "outsider-slug" })); + expect(error).toMatchObject({ _tag: "HttpResponseError", status: 403 }); + }), + ); + + it.effect("requires either a selector header or a session org", () => + Effect.gen(function* () { + const error = yield* Effect.flip(run({}, null)); + expect(error).toMatchObject({ _tag: "HttpResponseError", status: 401 }); + }), + ); +}); diff --git a/apps/cloud/src/extensions/billing/route.ts b/apps/cloud/src/extensions/billing/route.ts index ec1f12f1d1..647af9c981 100644 --- a/apps/cloud/src/extensions/billing/route.ts +++ b/apps/cloud/src/extensions/billing/route.ts @@ -4,8 +4,36 @@ import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstab import { autumnHandler } from "autumn-js/backend"; import { WorkOSClient } from "../../auth/workos"; +import { ORG_SELECTOR_HEADER, authorizeOrganizationSelector } from "../../auth/organization"; import { HttpResponseError, isServerError, toErrorServerResponse } from "../../api/error-response"; +type BillingSession = { + readonly userId: string; + readonly organizationId?: string | null; +}; + +export const resolveBillingOrganization = (request: Request, session: BillingSession) => + Effect.gen(function* () { + const selector = request.headers.get(ORG_SELECTOR_HEADER) ?? session.organizationId; + if (!selector) { + return yield* new HttpResponseError({ + status: 401, + code: "unauthorized", + message: "Unauthorized", + }); + } + + const org = yield* authorizeOrganizationSelector(session.userId, selector); + if (!org) { + return yield* new HttpResponseError({ + status: 403, + code: "forbidden", + message: "Forbidden", + }); + } + return org; + }); + const handler = Effect.gen(function* () { const request = yield* HttpServerRequest.HttpServerRequest; const webRequest = yield* Effect.mapError( @@ -21,13 +49,14 @@ const handler = Effect.gen(function* () { const workos = yield* WorkOSClient; const session = yield* workos.authenticateRequest(webRequest); - if (!session || !session.organizationId) { + if (!session) { return yield* new HttpResponseError({ status: 401, code: "unauthorized", message: "Unauthorized", }); } + const org = yield* resolveBillingOrganization(webRequest, session); const url = new URL(webRequest.url); const body = @@ -50,7 +79,7 @@ const handler = Effect.gen(function* () { method: request.method, body, }, - customerId: session.organizationId, + customerId: org.id, customerData: { name: session.email, email: session.email, diff --git a/apps/cloud/src/extensions/routes.ts b/apps/cloud/src/extensions/routes.ts index b60f66c9cb..48529513bd 100644 --- a/apps/cloud/src/extensions/routes.ts +++ b/apps/cloud/src/extensions/routes.ts @@ -95,5 +95,7 @@ export const makeCloudExtensionRoutes = (rsLive: Layer.Layer`), carry NO audience, and their issuer is the +// AuthKit user_management URL (which varies by app), so we don't pin issuer or +// audience: the client-scoped JWKS already binds the token to this application, +// and live org-membership is re-checked downstream. Signature + expiry are +// enforced by jose. +export const verifyWorkosUserManagementToken = (token: string, jwks: JWTVerifyGetKey) => + Effect.gen(function* () { + const { payload } = yield* Effect.tryPromise({ + try: () => jwtVerify(token, jwks), + catch: classifyJwtVerificationError, + }).pipe(withJwtVerificationSpan); + + if (!payload.sub) return null; + + return { + accountId: payload.sub, + organizationId: (payload.org_id as string | undefined) ?? null, + } satisfies VerifiedToken; + }); diff --git a/apps/cloud/src/mcp/session-durable-object.ts b/apps/cloud/src/mcp/session-durable-object.ts index d26bcf58ae..5513ddb901 100644 --- a/apps/cloud/src/mcp/session-durable-object.ts +++ b/apps/cloud/src/mcp/session-durable-object.ts @@ -22,6 +22,7 @@ import { drizzle } from "drizzle-orm/postgres-js"; import postgres, { type Sql } from "postgres"; import { createExecutorMcpServer } from "@executor-js/host-mcp/tool-server"; +import { buildResumeApprovalUrl } from "@executor-js/host-mcp/browser-approval"; import { McpSessionDOBase, type BuiltMcpServer, @@ -169,6 +170,7 @@ export class McpSessionDO extends McpSessionDOBase { return { organizationId: org.id, organizationName: org.name, + organizationSlug: org.slug, userId: token.userId, elicitationMode: token.elicitationMode, } satisfies SessionMeta; @@ -210,12 +212,12 @@ export class McpSessionDO extends McpSessionDOBase { sessionElicitationMode === "browser" ? { mode: "browser" as const, - approvalUrl: (executionId) => { - const origin = env.VITE_PUBLIC_SITE_URL ?? "https://executor.sh"; - const url = new URL(`/resume/${encodeURIComponent(executionId)}`, origin); - url.searchParams.set("mcp_session_id", self.sessionId); - return url.toString(); - }, + approvalUrl: (executionId) => + buildResumeApprovalUrl({ + origin: env.VITE_PUBLIC_SITE_URL ?? "https://executor.sh", + executionId, + sessionId: self.sessionId, + }), } : { mode: sessionElicitationMode }, }).pipe(Effect.withSpan("McpSessionDO.createExecutorMcpServer")); diff --git a/apps/cloud/src/org/handlers.test.ts b/apps/cloud/src/org/handlers.test.ts index da60e91834..3e0272b5e5 100644 --- a/apps/cloud/src/org/handlers.test.ts +++ b/apps/cloud/src/org/handlers.test.ts @@ -32,7 +32,7 @@ const stubWorkOS = (overrides: StubOverrides = {}) => Layer.succeed( WorkOSClient, new Proxy({} as WorkOSClientService, { - get: (_target, prop) => { + get: (target, prop) => { if (typeof prop === "string" && prop in overrides) { return overrides[prop as keyof StubOverrides]; } @@ -53,6 +53,7 @@ const adminAuth = { name: "Admin", avatarUrl: null, roles: [], + actor: { kind: "user", id: "user_admin", label: "Admin" }, }; const memberAuth = { @@ -62,6 +63,7 @@ const memberAuth = { name: "Member", avatarUrl: null, roles: [], + actor: { kind: "user", id: "user_member", label: "Member" }, }; const provide = (auth: typeof adminAuth, workosOverrides: StubOverrides = {}) => @@ -78,7 +80,7 @@ const requireAdmin = Effect.gen(function* () { }); const withCurrentMembership: StubOverrides = { - getUserOrgMembership: (_organizationId: string, userId: string) => + getUserOrgMembership: (organizationId: string, userId: string) => Effect.succeed( userId === "user_admin" ? { id: "mem_admin", userId, status: "active", role: { slug: "admin" } } diff --git a/apps/cloud/src/org/handlers.ts b/apps/cloud/src/org/handlers.ts index a12811cec6..abb12df011 100644 --- a/apps/cloud/src/org/handlers.ts +++ b/apps/cloud/src/org/handlers.ts @@ -90,9 +90,12 @@ export const OrgHandlers = HttpApiBuilder.group(OrgHttpApi, "org", (handlers) => } const workos = yield* WorkOSClient; + // WorkOS requires an ABSOLUTE returnUrl — fall back to the pinned origin + // (matching every other VITE_PUBLIC_SITE_URL site in this host), not a + // bare relative path that WorkOS would reject. const { link } = yield* workos.generateDomainVerificationPortalLink( auth.organizationId, - env.VITE_PUBLIC_SITE_URL ? `${env.VITE_PUBLIC_SITE_URL}/org` : "/org", + `${env.VITE_PUBLIC_SITE_URL ?? "https://executor.sh"}/org`, ); return { link }; }), diff --git a/apps/cloud/src/routeTree.gen.ts b/apps/cloud/src/routeTree.gen.ts index 66e1c356e3..63392751f9 100644 --- a/apps/cloud/src/routeTree.gen.ts +++ b/apps/cloud/src/routeTree.gen.ts @@ -350,15 +350,11 @@ export const routeTree = rootRouteImport ._addFileTypes() import type { getRouter } from './router.tsx' - import type { startInstance } from './start.ts' - declare module '@tanstack/react-start' { interface Register { ssr: true - router: Awaited> - config: Awaited> } } diff --git a/apps/cloud/src/routes/__root.tsx b/apps/cloud/src/routes/__root.tsx index ce57b27099..addfdd30aa 100644 --- a/apps/cloud/src/routes/__root.tsx +++ b/apps/cloud/src/routes/__root.tsx @@ -16,6 +16,7 @@ import type { FrontendErrorReporter } from "@executor-js/react/api/error-reporti import { AnalyticsProvider, type AnalyticsClient } from "@executor-js/react/api/analytics"; import { ExecutorProvider } from "@executor-js/react/api/provider"; import { OrganizationProvider } from "@executor-js/react/api/organization-context"; +import { EXECUTOR_ORG_HEADER } from "@executor-js/react/api/server-connection"; import { OrgSlugGate } from "@executor-js/react/multiplayer/org-slug-gate"; import { Toaster } from "@executor-js/react/components/sonner"; import { ExecutorPluginsProvider } from "@executor-js/sdk/client"; @@ -275,16 +276,26 @@ function AuthGate({ ssrOrigin }: { ssrOrigin: string | null }) { // which is the same origin, so the key never changes and nothing remounts. const connection = ssrOrigin ? ({ kind: "http", origin: ssrOrigin } as const) : undefined; const activeSlug = auth.organization.slug; + // The org context's slug feeds the connect card's `//mcp` install URL. + // Prefer the URL's slug over the session's: on first paint `auth.organization` + // comes from the SSR auth-hint (the COOKIE's org), so a multi-org user viewing + // / while their cookie still points at orgA would briefly render orgA's + // slug in the copyable URL before /account/me (URL-scoped) corrects it. The + // URL slug is the actual request scope and is correct on the very first paint, + // so sourcing it from there removes that flash. Falls back to the session slug + // on a bare URL (which OrgSlugGate is about to canonicalize onto it anyway). + const scopeSlug = urlOrgSlug ?? activeSlug; + const billingHeaders = scopeSlug ? { [EXECUTOR_ORG_HEADER]: scopeSlug } : undefined; return ( - + } showDialog={false}> }> {/* The org header scopes every request to the URL's org, so reaching here means the caller is a member of `activeSlug` diff --git a/apps/cloud/src/start.ts b/apps/cloud/src/start.ts index 9760f8c28b..baccd10030 100644 --- a/apps/cloud/src/start.ts +++ b/apps/cloud/src/start.ts @@ -3,8 +3,15 @@ import { createMiddleware, createStart } from "@tanstack/react-start"; import { cloudApiHandler } from "./app"; import { isAppOwnedPath } from "./app-paths"; import { authGateMiddleware } from "./auth/ssr-gate"; +import { parseCookie } from "./auth/cookies"; +import { loginPath } from "./auth/return-to"; import { prepareMcpOrgScope } from "./mcp/mount"; -import { marketingMiddleware, posthogProxyMiddleware, sentryTunnelMiddleware } from "./edge"; +import { + docsProxyMiddleware, + marketingMiddleware, + posthogProxyMiddleware, + sentryTunnelMiddleware, +} from "./edge"; // --------------------------------------------------------------------------- // The unified app web handler — `ExecutorApp.make`'s `toWebHandler` (app.ts). @@ -29,6 +36,28 @@ import { marketingMiddleware, posthogProxyMiddleware, sentryTunnelMiddleware } f let app: ReturnType | undefined; const getApp = () => (app ??= cloudApiHandler()); +const SESSION_COOKIE = "wos-session"; +const OAUTH_CALLBACK_PATH = "/api/oauth/callback"; + +const oauthCallbackSignInMiddleware = createMiddleware({ type: "request" }).server( + ({ pathname, request, next }) => { + if ( + pathname !== OAUTH_CALLBACK_PATH || + (request.method !== "GET" && request.method !== "HEAD") + ) { + return next(); + } + const sealed = parseCookie(request.headers.get("cookie"), SESSION_COOKIE); + if (sealed) return next(); + + const url = new URL(request.url); + return new Response(null, { + status: 302, + headers: { location: loginPath(`${url.pathname}${url.search}`) }, + }); + }, +); + // app-owned = anything under `/api/*` (incl. the cloud extension routes) OR an // MCP/OAuth-discovery path (see `./app-paths`). The app handler serves these at // their real paths, so we forward unmodified — except `prepareMcpOrgScope` @@ -42,17 +71,23 @@ const appRequestMiddleware = createMiddleware({ type: "request" }).server( }, ); -// The edge concerns (marketing proxy, sentry tunnel, posthog proxy) live in -// `./edge`; they run before the app's own dispatch. Ordering is load-bearing: -// marketing first (production landing/page proxy), then the analytics tunnels, -// then the unified app plane (api + mcp), and last the SSR auth gate — it only -// sees document requests nothing above claimed, so signed-out visitors are -// redirected to /login before the SPA (and its app-shell skeleton) is served. +// The edge concerns (marketing proxy, docs proxy, sentry tunnel, posthog proxy) +// live in `./edge`; they run before the app's own dispatch. Ordering is +// load-bearing: marketing first (production landing/page proxy), then the docs +// proxy and analytics tunnels, then the unified app plane (api + mcp), and last +// the SSR auth gate — it only sees document requests nothing above claimed, so +// signed-out visitors are redirected to /login before the SPA (and its +// app-shell skeleton) is served. The docs proxy sits among the edges (not after +// the auth gate) because `/docs` is public and must skip the sign-in redirect; +// its path is disjoint from every other matcher, so its slot is not otherwise +// load-bearing. export const startInstance = createStart(() => ({ requestMiddleware: [ marketingMiddleware, + docsProxyMiddleware, sentryTunnelMiddleware, posthogProxyMiddleware, + oauthCallbackSignInMiddleware, appRequestMiddleware, authGateMiddleware, ], diff --git a/apps/cloud/src/web/pages/create-org.tsx b/apps/cloud/src/web/pages/create-org.tsx index c96696c822..711b8f28da 100644 --- a/apps/cloud/src/web/pages/create-org.tsx +++ b/apps/cloud/src/web/pages/create-org.tsx @@ -6,7 +6,6 @@ import * as Exit from "effect/Exit"; import { authWriteKeys } from "@executor-js/react/api/reactivity-keys"; import { trackEvent } from "@executor-js/react/api/analytics"; import { Button } from "@executor-js/react/components/button"; -import { Skeleton } from "@executor-js/react/components/skeleton"; import { AUTH_PATHS } from "../../auth/api"; import { acceptInvitation, pendingInvitationsAtom, useAuth } from "../auth"; @@ -84,8 +83,11 @@ export const CreateOrgPage = () => { }, }); - const isLoading = - AsyncResult.isInitial(invitationsResult) || AsyncResult.isWaiting(invitationsResult); + // Almost no new user has a pending invitation, so we never show a loading + // placeholder for them — the create form renders immediately and stays put. + // Invitations (when they exist) just appear above it once the fetch resolves; + // a refetch (the create-org submit invalidates the auth-keyed atom) keeps the + // last value, so nothing flickers. const invitations = AsyncResult.match(invitationsResult, { onInitial: () => [] as readonly PendingInvitation[], onFailure: () => [] as readonly PendingInvitation[], @@ -103,13 +105,9 @@ export const CreateOrgPage = () => { Step 1 of 2

- {isLoading - ? "Loading" - : count === 0 - ? "Create your organization" - : "You've been invited"} + {count > 0 ? "You've been invited" : "Create your organization"}

- {!isLoading && count === 0 && ( + {count === 0 && (

Organizations group your sources, secrets, and teammates. You can invite others once it's set up. @@ -117,9 +115,7 @@ export const CreateOrgPage = () => { )} - {isLoading && } - - {!isLoading && sole && ( + {sole && ( { /> )} - {!isLoading && count > 1 && ( + {count > 1 && ( { /> )} - {!isLoading && (count === 0 || sole || count > 1) && ( - - )} +

+ + + ) : undefined; + + const toolbar = ( +
+
+
+

Runs

+ {view.meta != null && ( + + {view.rows.length} of {view.meta.filterRowCount} + + )} + +
+
+ setLive((value) => !value)} /> + + + +
+
+ +
+ ); + + const chart = + view.meta != null && view.meta.chartData.length > 0 ? ( + setFilters((current) => ({ ...current, from, to }))} + /> + ) : undefined; + + const statusRow = view.isLoading ? ( +
+ + + ) : view.isError ? ( + + + + ) : undefined; + + // Prev/next drawer navigation mirrors the ArrowUp/ArrowDown keyboard handler: + // index-1 is the row above (newer in descending sort), index+1 the row below. + const selectedIndex = + selected == null ? -1 : view.rows.findIndex((run) => run.executionId === selected); + const prevId = selectedIndex > 0 ? (view.rows[selectedIndex - 1]?.executionId ?? null) : null; + const nextId = + selectedIndex >= 0 && selectedIndex < view.rows.length - 1 + ? (view.rows[selectedIndex + 1]?.executionId ?? null) + : null; + + return ( +
+ setFilters(emptyRunsFilters)} + /> + } + toolbar={toolbar} + chart={chart} + columnHeader={ + + } + onLoadMore={view.loadMore} + hasMore={view.hasMore} + isLoadingMore={view.isLoadingMore} + isEmpty={!view.isLoading && !view.isError && view.rows.length === 0} + emptyState={ +
+ No runs match the current filters. + + Try widening the time range or clearing a filter. + +
+ } + railCollapsed={railCollapsed} + rows={rows} + statusRow={statusRow} + loadMoreError={loadMoreError} + scrollRestoreId="execution-history-runs" + /> + + setSelected(null)} + onPrev={prevId != null ? () => setSelected(prevId) : undefined} + onNext={nextId != null ? () => setSelected(nextId) : undefined} + /> +
+ ); +} diff --git a/packages/plugins/execution-history/src/react/atoms.ts b/packages/plugins/execution-history/src/react/atoms.ts new file mode 100644 index 0000000000..0dd429c597 --- /dev/null +++ b/packages/plugins/execution-history/src/react/atoms.ts @@ -0,0 +1,43 @@ +import * as Atom from "effect/unstable/reactivity/Atom"; + +import { ExecutionHistoryClient } from "./client"; + +// --------------------------------------------------------------------------- +// Query atoms for the execution-history read model. +// +// The list atom is parameterized by the wire query shape of the `list` +// endpoint (status/trigger CSV strings, numeric from/to/after/limit, the +// "true"/"false" interaction flag, sort field + direction, and an opaque +// keyset cursor). The detail atom keys off the execution id and returns the +// run plus its full detail (code/result/logs/tool-calls/interactions) in one +// fetch. Each atom is `Atom.family`-keyed so a distinct filter/page/run gets +// its own cached result — matching the graphql plugin's per-input atom pattern. +// --------------------------------------------------------------------------- + +export interface RunsQuery { + readonly status?: string; + readonly trigger?: string; + readonly actor?: string; + readonly from?: number; + readonly to?: number; + readonly interaction?: string; + readonly after?: number; + readonly sort?: "startedAt" | "durationMs"; + readonly dir?: "asc" | "desc"; + readonly limit?: number; + readonly cursor?: string; +} + +export const runsAtom = Atom.family((query: RunsQuery) => + ExecutionHistoryClient.query("executionHistory", "list", { + query, + timeToLive: "10 seconds", + }), +); + +export const runDetailAtom = Atom.family((executionId: string) => + ExecutionHistoryClient.query("executionHistory", "get", { + params: { executionId }, + timeToLive: "10 seconds", + }), +); diff --git a/packages/plugins/execution-history/src/react/client.ts b/packages/plugins/execution-history/src/react/client.ts new file mode 100644 index 0000000000..fd3dd4726e --- /dev/null +++ b/packages/plugins/execution-history/src/react/client.ts @@ -0,0 +1,12 @@ +import { createPluginAtomClient } from "@executor-js/sdk/client"; +import { + getExecutorApiBaseUrl, + getExecutorServerAuthorizationHeader, +} from "@executor-js/react/api/server-connection"; + +import { ExecutionHistoryGroup } from "../api/group"; + +export const ExecutionHistoryClient = createPluginAtomClient(ExecutionHistoryGroup, { + baseUrl: getExecutorApiBaseUrl, + authorizationHeader: getExecutorServerAuthorizationHeader, +}); diff --git a/packages/plugins/execution-history/src/react/column-header.tsx b/packages/plugins/execution-history/src/react/column-header.tsx new file mode 100644 index 0000000000..1a2e9f47e9 --- /dev/null +++ b/packages/plugins/execution-history/src/react/column-header.tsx @@ -0,0 +1,137 @@ +import { ArrowDown, ArrowUp, ArrowUpDown } from "lucide-react"; +import { Button } from "@executor-js/react/components/button"; +import { cn } from "@executor-js/react/lib/utils"; + +import type { RunsSortField } from "./use-runs-list"; +import type { RunColumns } from "./view"; + +// --------------------------------------------------------------------------- +// RunsColumnHeader — the
row. Cells are + {/* dot column (no label) */} + + + {/* Status (centered) */} + + + {/* Trigger (left) */} + {columns.trigger ? ( + + ) : null} + + {/* Actor (left) */} + {columns.actor ? ( + + ) : null} + + {/* Duration (sortable, centered) */} + {columns.duration ? ( + + ) : null} + + {/* Tools (centered) */} + {columns.tools ? ( + + ) : null} + + {/* Interaction (centered) */} + {columns.interaction ? ( + + ) : null} + + {/* Log (centered) */} + {columns.log ? ( + + ) : null} + + {/* Code (left) */} + + + ); +} diff --git a/packages/plugins/execution-history/src/react/detail-drawer.tsx b/packages/plugins/execution-history/src/react/detail-drawer.tsx new file mode 100644 index 0000000000..dc8ab875b0 --- /dev/null +++ b/packages/plugins/execution-history/src/react/detail-drawer.tsx @@ -0,0 +1,478 @@ +import { useAtomValue } from "@effect/atom-react"; +import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; +import { useCallback, useState } from "react"; +import { ChevronDown, ChevronUp } from "lucide-react"; +import { Badge } from "@executor-js/react/components/badge"; +import { Button } from "@executor-js/react/components/button"; +import { useResolveActorLabel } from "@executor-js/react/lib/actor-labels"; +import { CodeBlock } from "@executor-js/react/components/code-block"; +import { CopyButton } from "@executor-js/react/components/copy-button"; +import { ScrollArea } from "@executor-js/react/components/scroll-area"; +import { VirtualList } from "@executor-js/react/components/virtual-list"; +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, +} from "@executor-js/react/components/sheet"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@executor-js/react/components/tabs"; +import { cn } from "@executor-js/react/lib/utils"; + +import type { InteractionRow, InteractionStatus, RunRow, ToolCallRow } from "../sdk/collections"; +import { runDetailAtom } from "./atoms"; +import { formatDateTime, formatDuration, logLines, prettyJson, statusLabel } from "./format"; +import { HoverCardTimestamp } from "./hover-card-timestamp"; +import { STATUS_TONES, triggerTone } from "./status"; + +// --------------------------------------------------------------------------- +// Right-side run detail drawer: a 3-tab Sheet (Properties / Tool calls / Logs) +// opened on row click. Properties carries the highlighted code + result + any +// pending interaction inline; Tool calls renders a timing waterfall. Code/JSON +// reuse the shared shiki `CodeBlock` from `@executor-js/react`. Reads detail + +// tool calls through the plugin's atoms; kept self-contained so the runs page +// and any other surface can mount it. +// --------------------------------------------------------------------------- + +function MetaCard(props: { readonly label: string; readonly children: React.ReactNode }) { + return ( +
+

{props.label}

+
{props.children}
+
+ ); +} + +/** Pretty-printed JSON in the shared highlighted code card, or an empty state. */ +function JsonBlock(props: { readonly title: string; readonly value: string | null }) { + const text = prettyJson(props.value); + if (!text) { + return ( +
+ No {props.title.toLowerCase()} recorded. +
+ ); + } + return ; +} + +function LogsBlock(props: { readonly logsJson: string | null }) { + const lines = logLines(props.logsJson); + if (lines.length === 0) { + return ( +
+ No logs recorded. +
+ ); + } + return ( + `${index}-${line.slice(0, 24)}`} + estimateSize={() => 28} + overscan={16} + className="min-h-0 flex-1 px-5 py-4" + aria-label="Run logs" + renderItem={(line) => ( +
+          {line}
+        
+ )} + /> + ); +} + +// --------------------------------------------------------------------------- +// Tool calls — timing waterfall +// --------------------------------------------------------------------------- + +/** A single Gantt bar: offset + width as a percentage of the execution window. */ +function ToolCallWaterfall(props: { + readonly call: ToolCallRow; + readonly windowStart: number; + readonly windowEnd: number; +}) { + const span = Math.max(1, props.windowEnd - props.windowStart); + // Clamp the offset so a call starting at the very end of the window still + // leaves room for the bar, then floor the width at 1% so it never collapses + // to nothing (Math.min(100 - offsetPct, …) could otherwise hit 0). + const offsetPct = Math.min( + 99, + Math.max(0, ((props.call.startedAt - props.windowStart) / span) * 100), + ); + const rawMs = props.call.durationMs ?? Math.max(0, props.windowEnd - props.call.startedAt); + const widthPct = Math.max(1, Math.min(100 - offsetPct, (rawMs / span) * 100)); + const tone = STATUS_TONES[props.call.status]; + return ( + + + + ); +} + +function ToolCallItem(props: { + readonly call: ToolCallRow; + readonly windowStart: number; + readonly windowEnd: number; + readonly expanded: boolean; + readonly onToggle: () => void; +}) { + return ( +
+ + {props.expanded && ( +
+ + {props.call.status === "failed" && props.call.errorText ? ( +
+

Error

+
+                {props.call.errorText}
+              
+
+ ) : ( + + )} +
+ )} +
+ ); +} + +function ToolCallsTab(props: { readonly run: RunRow; readonly toolCalls: readonly ToolCallRow[] }) { + // Expanded state is lifted out of the row so it survives windowing (a row + // scrolled out and back keeps its expansion). + const [expanded, setExpanded] = useState>(() => new Set()); + const windowStart = props.run.startedAt; + const toggle = useCallback((id: string) => { + setExpanded((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + }, []); + if (props.toolCalls.length === 0) { + return

No tool calls recorded.

; + } + // Derive the window end from the tool-call data as well as `run.completedAt` + // so a fresher tool call can't outrun a stale run end and clamp its bar to a + // 1% sliver. + const callsEnd = props.toolCalls.reduce( + (max, call) => + Math.max( + max, + call.completedAt ?? + (call.durationMs != null ? call.startedAt + call.durationMs : call.startedAt), + ), + windowStart, + ); + const windowEnd = Math.max(props.run.completedAt ?? Date.now(), callsEnd); + return ( + call.toolCallId} + estimateSize={() => 52} + overscan={12} + className="min-h-0 flex-1 px-5 py-4" + aria-label="Tool calls" + renderItem={(call) => ( +
+ toggle(call.toolCallId)} + /> +
+ )} + /> + ); +} + +// --------------------------------------------------------------------------- +// Interactions (elicitations) — rendered inline on the Properties tab +// --------------------------------------------------------------------------- + +const INTERACTION_BADGE: Record = { + pending: "border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-300", + accepted: "border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300", + declined: "border-border bg-muted/30 text-muted-foreground", + cancelled: "border-border bg-muted/30 text-muted-foreground", + failed: "border-destructive/30 bg-destructive/10 text-destructive", +}; + +const interactionKindLabel = (kind: string): string => + kind === "UrlElicitation" ? "url" : kind === "FormElicitation" ? "form" : kind; + +function InteractionBlock(props: { readonly interaction: InteractionRow }) { + const { interaction } = props; + return ( +
+
+

+ {interactionKindLabel(interaction.kind)} + {interaction.purpose ? ( + {interaction.purpose} + ) : null} +

+ + {interaction.status} + +
+ + +
+ ); +} + +// --------------------------------------------------------------------------- +// Drawer shell + content +// --------------------------------------------------------------------------- + +function DetailContent(props: { + readonly run: RunRow; + readonly code: string; + readonly resultJson: string | null; + readonly errorText: string | null; + readonly logsJson: string | null; + readonly toolCalls: readonly ToolCallRow[]; + readonly interactions: readonly InteractionRow[]; + readonly onPrev?: () => void; + readonly onNext?: () => void; +}) { + const { run } = props; + const tone = STATUS_TONES[run.status]; + const trigger = triggerTone(run.triggerKind); + // Prefer the live friendly label (e.g. a service-token machine name) over the + // run-time snapshot, falling back to the snapshot then the id. + const resolveActorLabel = useResolveActorLabel(); + const actorLabel = resolveActorLabel(run.actorKind, run.actorId) ?? run.actorLabel ?? run.actorId; + const pendingCount = props.interactions.filter( + (interaction) => interaction.status === "pending", + ).length; + // Singular/plural only when every interaction is pending; a mix gets the + // neutral "Interactions" so the heading never misrepresents the block. + const interactionsHeading = + pendingCount > 0 && pendingCount === props.interactions.length + ? pendingCount === 1 + ? "Pending interaction" + : "Pending interactions" + : "Interactions"; + return ( +
+ +
+ + {run.executionId} + +
+ + + +
+
+ + {statusLabel(run.status)} run detail + +
+ + {/* Key by run so prev/next nav remounts the tabs and resets to Properties + rather than stranding the user on an empty tab from the prior run. */} + + + Properties + + Tool calls{run.toolCallCount > 0 ? ` · ${run.toolCallCount}` : ""} + + Logs + + + +
+
+ + + + {statusLabel(run.status)} + + + + {run.durationMs != null ? formatDuration(run.durationMs) : "—"} + + + {run.actorId !== null ? ( + + {actorLabel} + {run.actorKind !== null ? ( + · {run.actorKind} + ) : null} + + ) : ( + — + )} + + + + + + {run.completedAt != null ? ( + + ) : ( + formatDateTime(run.completedAt) + )} + +
+ +
+ + + via {trigger.label} + + · tools {run.toolCallCount} +
+ + + + {props.errorText && ( +
+

Error

+
+                    {props.errorText}
+                  
+
+ )} + + {props.interactions.length > 0 && ( +
+

+ {interactionsHeading} +

+ {props.interactions.map((interaction) => ( + + ))} +
+ )} +
+
+
+ + + + + + +
+
+ ); +} + +/** Reads the detail atom — split out so the query only fires for a real id + * (never the closed/sentinel state) and runs solely while the drawer is open. */ +function DetailLoader(props: { + readonly executionId: string; + readonly onPrev?: () => void; + readonly onNext?: () => void; +}) { + const detail = useAtomValue(runDetailAtom(props.executionId)); + return AsyncResult.match(detail, { + onInitial: () =>
Loading run...
, + onFailure: () =>
Unable to load run.
, + onSuccess: ({ value }) => + value ? ( + + ) : ( +
Run not found.
+ ), + }); +} + +export function DetailDrawer(props: { + readonly executionId: string | null; + readonly onClose: () => void; + readonly onPrev?: () => void; + readonly onNext?: () => void; +}) { + return ( + !open && props.onClose()}> + + {props.executionId == null ? null : ( + + )} + + + ); +} diff --git a/packages/plugins/execution-history/src/react/filter-command-parser.ts b/packages/plugins/execution-history/src/react/filter-command-parser.ts new file mode 100644 index 0000000000..df16a071b5 --- /dev/null +++ b/packages/plugins/execution-history/src/react/filter-command-parser.ts @@ -0,0 +1,96 @@ +// --------------------------------------------------------------------------- +// Pure parser for the runs filter command palette. Splits a free-text query +// into `key:value` tokens and produces a partial filter set. Adapted to the +// plugin's contract: only the 4 statuses, no tool/code/duration facets — the +// keys are status, trigger, interaction, after, and before. +// +// Parsing is forgiving by design: unknown keys, empty values, and unparsable +// dates are silently dropped (never throws). Relative durations like "1h" / +// "30m" / "7d" are resolved against `Date.now()`; absolute values fall back to +// `Date.parse` (the only allowed JSON-free epoch decode). +// --------------------------------------------------------------------------- + +export interface RunsFilterTokens { + readonly status: string[]; + readonly trigger: string[]; + readonly interaction: "true" | "false" | null; + readonly from: number | null; + readonly to: number | null; +} + +export interface FilterKey { + readonly key: string; + readonly hint: string; +} + +export const FILTER_KEYS: readonly FilterKey[] = [ + { key: "status", hint: "completed,failed,running,waiting" }, + { key: "trigger", hint: "mcp, http, cli, manual" }, + { key: "interaction", hint: "true | false" }, + { key: "after", hint: "1h · 30m · 7d · ISO date" }, + { key: "before", hint: "1h · 7d · ISO date" }, +]; + +const RELATIVE_UNIT_MS: Record = { + m: 60_000, + h: 3_600_000, + d: 86_400_000, + w: 604_800_000, +}; + +const RELATIVE_PATTERN = /^(\d+)([mhdw])$/; + +/** Convert a relative duration token ("1h", "30m", "7d") to milliseconds, or + * null when it isn't a recognized relative literal. */ +export const relativeToMs = (token: string): number | null => { + const match = RELATIVE_PATTERN.exec(token.trim()); + if (!match) return null; + const amount = Number(match[1]); + const unit = RELATIVE_UNIT_MS[match[2]!]; + if (unit === undefined || !Number.isFinite(amount)) return null; + return amount * unit; +}; + +/** Resolve a time token to an epoch-ms instant. Relative tokens map to + * `now - duration`; otherwise fall back to an absolute ISO/date parse. */ +const tokenToEpochMs = (value: string): number | null => { + const relative = relativeToMs(value); + if (relative !== null) return Date.now() - relative; + const absolute = Date.parse(value); + return Number.isNaN(absolute) ? null : absolute; +}; + +export const parseRunsFilter = (text: string): RunsFilterTokens => { + const status: string[] = []; + const trigger: string[] = []; + let interaction: "true" | "false" | null = null; + let from: number | null = null; + let to: number | null = null; + + const parts = text.trim().split(/\s+/).filter(Boolean); + + for (const part of parts) { + const colon = part.indexOf(":"); + if (colon <= 0) continue; + const key = part.slice(0, colon); + const value = part.slice(colon + 1); + if (value.length === 0) continue; + + if (key === "status") { + status.push(...value.split(",").filter(Boolean)); + } else if (key === "trigger") { + trigger.push(...value.split(",").filter(Boolean)); + } else if (key === "interaction") { + if (value === "true" || value === "false") interaction = value; + } else if (key === "after") { + const epoch = tokenToEpochMs(value); + if (epoch !== null) from = epoch; + } else if (key === "before") { + const epoch = tokenToEpochMs(value); + if (epoch !== null) to = epoch; + } + // Unknown keys are silently dropped so the input stays forgiving. + } + + return { status, trigger, interaction, from, to }; +}; diff --git a/packages/plugins/execution-history/src/react/filter-command.tsx b/packages/plugins/execution-history/src/react/filter-command.tsx new file mode 100644 index 0000000000..9e3aadfc80 --- /dev/null +++ b/packages/plugins/execution-history/src/react/filter-command.tsx @@ -0,0 +1,324 @@ +import { forwardRef, useEffect, useImperativeHandle, useMemo, useRef, useState } from "react"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, + CommandSeparator, + CommandShortcut, +} from "@executor-js/react/components/command"; +import { cn } from "@executor-js/react/lib/utils"; + +import type { RunStatus } from "../sdk/collections"; +import type { ExecutionListMeta } from "../sdk/store"; +import { FILTER_KEYS, parseRunsFilter, type RunsFilterTokens } from "./filter-command-parser"; +import type { RunsFilters } from "./use-runs-list"; +import { STATUS_LABELS, STATUS_ORDER, statusTone, triggerTone } from "./status"; + +// --------------------------------------------------------------------------- +// Runs filter command palette. A controlled cmdk input that surfaces live facet +// suggestions (status + trigger counts from `meta`) while typing a token, plus +// an "Add filter" group of key hints. Selecting a suggestion toggles that facet +// immediately; pressing Enter parses the whole input as `key:value` tokens and +// merges them onto the current filters. +// +// Contract adaptations vs the inline prototype: only the 4 statuses, no tool or +// code facets, and `meta.statusCounts`/`triggerCounts` are arrays (iterated, +// never `Object.entries`). `status:` accepts either the canonical status value +// or its display label (e.g. `waiting` -> `waiting_for_interaction`). +// --------------------------------------------------------------------------- + +export interface RunsFilterCommandProps { + readonly filters: RunsFilters; + readonly meta: ExecutionListMeta | null; + readonly onApply: (filters: RunsFilters) => void; + readonly open: boolean; + readonly onOpenChange: (open: boolean) => void; +} + +/** Map a typed status token (canonical value or display label) back to the + * canonical RunStatus, or null when it matches neither. */ +const canonicalStatus = (token: string): RunStatus | null => { + for (const status of STATUS_ORDER) { + if (status === token || STATUS_LABELS[status] === token) return status; + } + return null; +}; + +/** Toggle one value within a readonly facet list (add when absent, drop when + * present), returning a new array. */ +const toggleFacet = (current: readonly string[], value: string): string[] => + current.includes(value) ? current.filter((entry) => entry !== value) : [...current, value]; + +/** Merge parsed tokens onto the current filters: status/trigger REPLACE (only + * when present and valid), interaction/from/to are set only when the token was + * actually typed — so applying a command never wipes a time range or + * interaction filter set elsewhere (the rail, the timeline drag). The palette + * sets filters; clearing is done from the rail. Status tokens are normalized to + * canonical RunStatus values; an all-invalid status token leaves status + * untouched. */ +const mergeTokens = (filters: RunsFilters, tokens: RunsFilterTokens): RunsFilters => { + const statuses = tokens.status + .map(canonicalStatus) + .filter((status): status is RunStatus => status !== null); + return { + ...filters, + status: statuses.length > 0 ? statuses : filters.status, + trigger: tokens.trigger.length > 0 ? tokens.trigger : filters.trigger, + interaction: tokens.interaction !== null ? tokens.interaction : filters.interaction, + from: tokens.from !== null ? tokens.from : filters.from, + to: tokens.to !== null ? tokens.to : filters.to, + }; +}; + +interface FacetSuggestion { + readonly value: string; + readonly label: string; + readonly hint: string; +} + +export const RunsFilterCommand = forwardRef( + function RunsFilterCommand({ filters, meta, onApply, open, onOpenChange }, forwardedRef) { + const [value, setValue] = useState(""); + const containerRef = useRef(null); + const inputRef = useRef(null); + + useImperativeHandle(forwardedRef, () => inputRef.current as HTMLInputElement, []); + + // Close on an outside pointer press while open. + useEffect(() => { + if (!open) return; + const handlePointer = (event: PointerEvent) => { + const target = event.target as Node | null; + if (!containerRef.current || !target) return; + if (!containerRef.current.contains(target)) onOpenChange(false); + }; + window.addEventListener("pointerdown", handlePointer); + return () => window.removeEventListener("pointerdown", handlePointer); + }, [open, onOpenChange]); + + const statusCounts = useMemo(() => { + const counts = new Map(); + for (const entry of meta?.statusCounts ?? []) counts.set(entry.status, entry.count); + return counts; + }, [meta]); + + const triggerSuggestions = useMemo(() => { + const entries = meta?.triggerCounts ?? []; + return entries + .filter( + (entry): entry is { triggerKind: string; count: number } => entry.triggerKind !== null, + ) + .map((entry) => ({ + value: entry.triggerKind, + label: triggerTone(entry.triggerKind).label, + hint: `${entry.count}`, + })); + }, [meta]); + + const statusSuggestions = useMemo( + () => + STATUS_ORDER.map((status) => ({ + value: status, + label: STATUS_LABELS[status], + hint: `${statusCounts.get(status) ?? 0}`, + })), + [statusCounts], + ); + + const applyFilters = (next: RunsFilters) => { + onApply(next); + }; + + const applyTokens = () => { + const merged = mergeTokens(filters, parseRunsFilter(value)); + applyFilters(merged); + setValue(""); + onOpenChange(false); + inputRef.current?.blur(); + }; + + const toggleStatus = (status: string) => { + applyFilters({ ...filters, status: toggleFacet(filters.status, status) }); + }; + + const toggleTrigger = (trigger: string) => { + applyFilters({ ...filters, trigger: toggleFacet(filters.trigger, trigger) }); + }; + + const insertKey = (key: string) => { + const trimmed = value.trimEnd(); + setValue(trimmed.length === 0 ? `${key}:` : `${trimmed} ${key}:`); + inputRef.current?.focus(); + }; + + if (!open) { + return ( +
+ + onOpenChange(true)} + placeholder="Filter runs — status:… trigger:… interaction:… after:…" + className="font-mono text-xs" + /> + +
+ ); + } + + return ( +
+ { + if (event.key === "Enter") { + event.preventDefault(); + applyTokens(); + } else if (event.key === "Escape") { + event.preventDefault(); + onOpenChange(false); + inputRef.current?.blur(); + } + }} + > + onOpenChange(true)} + placeholder="Filter runs — status:… trigger:… interaction:… after:…" + className="font-mono text-xs" + /> + +
+ + + + No matching filters. + + + + + {statusSuggestions.map((suggestion) => { + const active = filters.status.includes(suggestion.value); + const tone = statusTone(suggestion.value as RunStatus); + return ( + toggleStatus(suggestion.value)} + > + + + {suggestion.label} + + {active ? ( + ✓ + ) : null} + {suggestion.hint} + + ); + })} + + + {triggerSuggestions.length > 0 ? ( + + {triggerSuggestions.map((suggestion) => { + const active = filters.trigger.includes(suggestion.value); + const tone = triggerTone(suggestion.value); + return ( + toggleTrigger(suggestion.value)} + > + + + {suggestion.label} + + {active ? ( + ✓ + ) : null} + {suggestion.hint} + + ); + })} + + ) : null} + + + + + {FILTER_KEYS.map((entry) => ( + insertKey(entry.key)} + > + {entry.key}: + {entry.hint} + + ))} + + + + + + + Apply filters + Enter + + + + +
+ + Use + ↑↓ + to navigate + + · + + + Enter + + to apply + + · + + + Esc + + to close + + · + + Union:{" "} + + status:failed,completed + + + · + + Time:{" "} + after:1h + +
+
+
+
+ ); + }, +); diff --git a/packages/plugins/execution-history/src/react/filter-rail.tsx b/packages/plugins/execution-history/src/react/filter-rail.tsx new file mode 100644 index 0000000000..93ab55ed33 --- /dev/null +++ b/packages/plugins/execution-history/src/react/filter-rail.tsx @@ -0,0 +1,406 @@ +import * as React from "react"; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "@executor-js/react/components/accordion"; +import { Button } from "@executor-js/react/components/button"; +import { cn } from "@executor-js/react/lib/utils"; +import { useResolveActorLabel } from "@executor-js/react/lib/actor-labels"; + +import type { ExecutionListMeta } from "../sdk/store"; +import { + STATUS_ORDER, + STATUS_LABELS, + TRIGGER_ORDER, + actorTone, + statusTone, + triggerTone, +} from "./status"; +import type { RunsFilters } from "./use-runs-list"; + +// --------------------------------------------------------------------------- +// Time range presets +// --------------------------------------------------------------------------- + +export interface TimeRangePreset { + readonly key: string; + readonly label: string; + readonly ms: number | null; +} + +export const TIME_RANGE_PRESETS: readonly TimeRangePreset[] = [ + { key: "15m", label: "Last 15m", ms: 15 * 60 * 1000 }, + { key: "1h", label: "Last 1h", ms: 60 * 60 * 1000 }, + { key: "24h", label: "Last 24h", ms: 24 * 60 * 60 * 1000 }, + { key: "7d", label: "Last 7d", ms: 7 * 24 * 60 * 60 * 1000 }, + { key: "30d", label: "Last 30d", ms: 30 * 24 * 60 * 60 * 1000 }, + { key: "all", label: "All time", ms: null }, +]; + +export const resolveTimeRange = (key: string): { from: number | null; to: number | null } => { + const preset = TIME_RANGE_PRESETS.find((p) => p.key === key); + if (!preset || preset.ms === null) return { from: null, to: null }; + const now = Date.now(); + return { from: now - preset.ms, to: now }; +}; + +/** Best-effort match of the current from/to values against a known preset key. */ +const activePresetKey = (from: number | null, to: number | null): string | null => { + if (from === null && to === null) return "all"; + if (from === null || to === null) return null; + const delta = to - from; + for (const preset of TIME_RANGE_PRESETS) { + if (preset.ms !== null && Math.abs(delta - preset.ms) < 60_000) return preset.key; + } + return null; +}; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const toggle = (arr: readonly T[], item: T): readonly T[] => + arr.includes(item) ? arr.filter((v) => v !== item) : [...arr, item]; + +const isFiltersActive = (filters: RunsFilters): boolean => + filters.status.length > 0 || + filters.trigger.length > 0 || + filters.actor.length > 0 || + filters.interaction !== null || + filters.from !== null || + filters.to !== null; + +// --------------------------------------------------------------------------- +// Sub-components +// --------------------------------------------------------------------------- + +function FacetSection({ + value, + label, + children, +}: { + readonly value: string; + readonly label: string; + readonly children: React.ReactNode; +}) { + return ( + + + {label} + + {children} + + ); +} + +function FacetRow({ + checked, + onToggle, + onOnly, + dotClass, + pulse, + label, + count, + monoLabel, +}: { + readonly checked: boolean; + readonly onToggle: () => void; + readonly onOnly?: () => void; + readonly dotClass: string; + readonly pulse?: boolean; + readonly label: string; + readonly count: number | undefined; + readonly monoLabel?: boolean; +}) { + return ( +
+ + + {onOnly ? ( + + ) : null} +
+ ); +} + +// --------------------------------------------------------------------------- +// Main export +// --------------------------------------------------------------------------- + +export interface RunsFilterRailProps { + readonly filters: RunsFilters; + readonly meta: ExecutionListMeta | null; + readonly onChange: (filters: RunsFilters) => void; + readonly onReset: () => void; +} + +export function RunsFilterRail({ filters, meta, onChange, onReset }: RunsFilterRailProps) { + const filtersActive = isFiltersActive(filters); + const resolveActorLabel = useResolveActorLabel(); + + const triggerKeys = React.useMemo(() => { + const set = new Set(TRIGGER_ORDER); + if (meta?.triggerCounts) { + for (const entry of meta.triggerCounts) { + if (entry.triggerKind != null) set.add(entry.triggerKind); + } + } + return [...set]; + }, [meta?.triggerCounts]); + + const currentPresetKey = activePresetKey(filters.from, filters.to); + + return ( +
+ {/* Header */} +
+

Execution history

+

+ Every execution recorded for this scope, newest first. +

+ {meta ? ( +

+ {meta.filterRowCount.toLocaleString()} of {meta.totalRowCount.toLocaleString()} runs +

+ ) : null} +
+ + {/* Filters label + reset */} +
+

+ Filters +

+ {filtersActive ? ( + + ) : null} +
+ +
+ + {/* Status facet */} + +
    + {STATUS_ORDER.map((status) => { + const tone = statusTone(status); + const checked = filters.status.includes(status); + const count = meta?.statusCounts.find((c) => c.status === status)?.count; + return ( +
  • + + onChange({ ...filters, status: toggle(filters.status, status) }) + } + onOnly={() => onChange({ ...filters, status: [status] })} + dotClass={tone.dot} + pulse={tone.pulse} + label={STATUS_LABELS[status]} + count={count} + /> +
  • + ); + })} +
+
+ + {/* Trigger facet */} + +
    + {triggerKeys.map((key) => { + const tone = triggerTone(key); + const checked = filters.trigger.includes(key); + const count = meta?.triggerCounts.find((c) => c.triggerKind === key)?.count; + return ( +
  • + + onChange({ ...filters, trigger: toggle(filters.trigger, key) }) + } + onOnly={() => onChange({ ...filters, trigger: [key] })} + dotClass={tone.dot} + label={tone.label} + count={count} + monoLabel + /> +
  • + ); + })} +
+
+ + {/* Actor facet — dynamic keys (token client ids / user subjects) from + the run set; only filterable (non-null) actors are shown. */} + {meta?.actorCounts && meta.actorCounts.some((entry) => entry.actorId !== null) ? ( + +
    + {meta.actorCounts.map((entry) => { + const id = entry.actorId; + // null actor (unattributed runs) isn't filterable — skip it. + if (id === null) return null; + const tone = actorTone(entry.actorKind); + const checked = filters.actor.includes(id); + return ( +
  • + onChange({ ...filters, actor: toggle(filters.actor, id) })} + onOnly={() => onChange({ ...filters, actor: [id] })} + dotClass={tone.dot} + label={resolveActorLabel(entry.actorKind, id) ?? entry.actorLabel ?? id} + count={entry.count} + monoLabel + /> +
  • + ); + })} +
+
+ ) : null} + + {/* Interaction facet */} + +
    +
  • + + onChange({ + ...filters, + interaction: filters.interaction === "true" ? null : "true", + }) + } + dotClass="bg-amber-500" + label="With interaction" + count={meta?.interactionCounts.withInteraction} + /> +
  • +
  • + + onChange({ + ...filters, + interaction: filters.interaction === "false" ? null : "false", + }) + } + dotClass="bg-muted-foreground/40" + label="Without interaction" + count={meta?.interactionCounts.withoutInteraction} + /> +
  • +
+
+ + {/* Time range facet */} + +
    + {TIME_RANGE_PRESETS.map((preset) => { + const active = currentPresetKey === preset.key; + return ( +
  • + +
  • + ); + })} +
+
+
+
+
+ ); +} diff --git a/packages/plugins/execution-history/src/react/format.ts b/packages/plugins/execution-history/src/react/format.ts new file mode 100644 index 0000000000..b597b8a7a3 --- /dev/null +++ b/packages/plugins/execution-history/src/react/format.ts @@ -0,0 +1,57 @@ +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; + +import type { RunStatus, ToolCallStatus } from "../sdk/collections"; +import { STATUS_LABELS } from "./status"; + +// --------------------------------------------------------------------------- +// Formatting helpers shared by the runs table, drawer, and chart. Uses native +// Date + Intl (no date-fns dep). The stored `*Json` columns are already +// compact JSON strings; decode through Effect Schema (only JSON.parse is +// lint-banned — JSON.stringify is fine and won't throw on a decoded value). +// --------------------------------------------------------------------------- + +const decodeJson = Schema.decodeUnknownOption(Schema.UnknownFromJsonString); +const decodeLogLines = Schema.decodeUnknownOption( + Schema.fromJsonString(Schema.Array(Schema.String)), +); + +export const prettyJson = (raw: string | null): string | null => { + if (!raw) return null; + return Option.match(decodeJson(raw), { + onNone: () => raw, + onSome: (value) => JSON.stringify(value, null, 2), + }); +}; + +export const logLines = (raw: string | null): readonly string[] => + !raw + ? [] + : Option.match(decodeLogLines(raw), { + onNone: () => [raw], + onSome: (value) => value, + }); + +export const statusLabel = (status: RunStatus | ToolCallStatus): string => + status in STATUS_LABELS ? STATUS_LABELS[status as RunStatus] : status; + +export const formatDateTime = (timestamp: number | null): string => + timestamp == null ? "Pending" : new Date(timestamp).toLocaleString(); + +const relativeFormatter = new Intl.RelativeTimeFormat(undefined, { numeric: "auto" }); + +export const formatRelative = (timestamp: number): string => { + const delta = timestamp - Date.now(); + const abs = Math.abs(delta); + if (abs < 60_000) return relativeFormatter.format(Math.round(delta / 1000), "second"); + if (abs < 3_600_000) return relativeFormatter.format(Math.round(delta / 60_000), "minute"); + if (abs < 86_400_000) return relativeFormatter.format(Math.round(delta / 3_600_000), "hour"); + return relativeFormatter.format(Math.round(delta / 86_400_000), "day"); +}; + +export const formatDuration = (value: number | null): string => { + if (value == null) return "running"; + if (value < 1000) return `${value}ms`; + if (value < 60_000) return `${(value / 1000).toFixed(1)}s`; + return `${(value / 60_000).toFixed(1)}m`; +}; diff --git a/packages/plugins/execution-history/src/react/hover-card-timestamp.tsx b/packages/plugins/execution-history/src/react/hover-card-timestamp.tsx new file mode 100644 index 0000000000..1d18d771a9 --- /dev/null +++ b/packages/plugins/execution-history/src/react/hover-card-timestamp.tsx @@ -0,0 +1,104 @@ +import { useCallback, useState } from "react"; +import { Check, Copy } from "lucide-react"; +import { + HoverCard, + HoverCardContent, + HoverCardTrigger, +} from "@executor-js/react/components/hover-card"; +import { cn } from "@executor-js/react/lib/utils"; + +import { formatRelative } from "./format"; + +// --------------------------------------------------------------------------- +// Hover popup over a timestamp: the trigger shows whatever `display` is given +// (the list row passes a relative "1 hour ago"; the drawer falls back to the +// absolute format), and hovering reveals the epoch ms, UTC, local-tz, and +// relative renderings — each a click-to-copy row. Native Date/Intl only (the +// plugin deliberately carries no date-fns dep); reuses the shared Radix +// HoverCard from @executor-js/react. +// --------------------------------------------------------------------------- + +const absolute = (timestamp: number, timeZone?: string): string => + new Intl.DateTimeFormat(undefined, { + year: "numeric", + month: "short", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: false, + ...(timeZone ? { timeZone } : {}), + }).format(timestamp); + +const LOCAL_TZ = Intl.DateTimeFormat().resolvedOptions().timeZone; + +export interface HoverCardTimestampProps { + readonly timestamp: number; + /** Trigger text; defaults to the absolute local format. */ + readonly display?: React.ReactNode; + readonly side?: "top" | "right" | "bottom" | "left"; + readonly align?: "start" | "center" | "end"; + readonly className?: string; +} + +export function HoverCardTimestamp({ + timestamp, + display, + side = "right", + align = "start", + className, +}: HoverCardTimestampProps) { + return ( + + + + {display ?? absolute(timestamp)} + + + +
+ + + + +
+
+
+ ); +} + +function CopyRow({ label, value }: { readonly label: string; readonly value: string }) { + const [copied, setCopied] = useState(false); + const copy = useCallback( + (event: React.MouseEvent | React.KeyboardEvent) => { + event.stopPropagation(); + void navigator.clipboard.writeText(value).then(() => { + setCopied(true); + window.setTimeout(() => setCopied(false), 1500); + }); + }, + [value], + ); + return ( +
{ + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + copy(event); + } + }} + className="group flex items-center justify-between gap-4 text-xs" + > +
{label}
+
+ + {copied ? : } + + {value} +
+
+ ); +} diff --git a/packages/plugins/execution-history/src/react/index.ts b/packages/plugins/execution-history/src/react/index.ts new file mode 100644 index 0000000000..26f792379b --- /dev/null +++ b/packages/plugins/execution-history/src/react/index.ts @@ -0,0 +1,3 @@ +export { RunsPage } from "./RunsPage"; +export { ExecutionHistoryClient } from "./client"; +export { runsAtom, runDetailAtom, type RunsQuery } from "./atoms"; diff --git a/packages/plugins/execution-history/src/react/keyboard-help.tsx b/packages/plugins/execution-history/src/react/keyboard-help.tsx new file mode 100644 index 0000000000..ccf8190c2a --- /dev/null +++ b/packages/plugins/execution-history/src/react/keyboard-help.tsx @@ -0,0 +1,56 @@ +import { Keyboard } from "lucide-react"; +import { Button } from "@executor-js/react/components/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuTrigger, +} from "@executor-js/react/components/dropdown-menu"; + +const SHORTCUTS: readonly { readonly label: string; readonly keys: readonly string[] }[] = [ + { label: "Focus filter", keys: ["/"] }, + { label: "Toggle live", keys: ["j"] }, + { label: "Refresh", keys: ["r"] }, + { label: "Keyboard shortcuts", keys: ["?"] }, +]; + +const NAV_SHORTCUTS: readonly { readonly label: string; readonly keys: readonly string[] }[] = [ + { label: "Toggle filter rail", keys: ["b"] }, + { label: "Navigate rows", keys: ["↑", "↓"] }, + { label: "Close drawer", keys: ["Esc"] }, +]; + +export function KeyboardHelp(props: { + readonly open: boolean; + readonly onOpenChange: (open: boolean) => void; +}) { + return ( + + + + + + Keyboard shortcuts + + {SHORTCUTS.map((shortcut) => ( + + {shortcut.label} + {shortcut.keys.join(" ")} + + ))} + + {NAV_SHORTCUTS.map((shortcut) => ( + + {shortcut.label} + {shortcut.keys.join(" ")} + + ))} + + + ); +} diff --git a/packages/plugins/execution-history/src/react/live-button.tsx b/packages/plugins/execution-history/src/react/live-button.tsx new file mode 100644 index 0000000000..d9f802204c --- /dev/null +++ b/packages/plugins/execution-history/src/react/live-button.tsx @@ -0,0 +1,37 @@ +import { CirclePause, CirclePlay } from "lucide-react"; +import { Button } from "@executor-js/react/components/button"; +import { cn } from "@executor-js/react/lib/utils"; + +export function LiveButton(props: { readonly active: boolean; readonly onToggle: () => void }) { + return ( + + ); +} diff --git a/packages/plugins/execution-history/src/react/live-row.tsx b/packages/plugins/execution-history/src/react/live-row.tsx new file mode 100644 index 0000000000..3bbd93da70 --- /dev/null +++ b/packages/plugins/execution-history/src/react/live-row.tsx @@ -0,0 +1,23 @@ +import { RUNS_TABLE_COLSPAN } from "./shell"; + +// A full-width "Live" divider row between past and newly-arrived runs. Rendered +// as a
. +export function LiveDivider() { + return ( + + + + ); +} diff --git a/packages/plugins/execution-history/src/react/plugin-client.tsx b/packages/plugins/execution-history/src/react/plugin-client.tsx new file mode 100644 index 0000000000..04dbe62e7c --- /dev/null +++ b/packages/plugins/execution-history/src/react/plugin-client.tsx @@ -0,0 +1,15 @@ +import { defineClientPlugin } from "@executor-js/sdk/client"; +import { lazy } from "react"; + +const RunsPage = lazy(() => import("./RunsPage").then((module) => ({ default: module.RunsPage }))); + +export default defineClientPlugin({ + id: "executionHistory" as const, + pages: [ + { + path: "/", + component: RunsPage, + nav: { label: "Runs" }, + }, + ], +}); diff --git a/packages/plugins/execution-history/src/react/refresh-button.tsx b/packages/plugins/execution-history/src/react/refresh-button.tsx new file mode 100644 index 0000000000..d7e778e2cf --- /dev/null +++ b/packages/plugins/execution-history/src/react/refresh-button.tsx @@ -0,0 +1,25 @@ +import { LoaderCircle, RefreshCcw } from "lucide-react"; +import { Button } from "@executor-js/react/components/button"; + +export function RefreshButton(props: { + readonly onClick: () => void; + readonly isLoading?: boolean; +}) { + return ( + + ); +} diff --git a/packages/plugins/execution-history/src/react/run-row.tsx b/packages/plugins/execution-history/src/react/run-row.tsx new file mode 100644 index 0000000000..ccf7381651 --- /dev/null +++ b/packages/plugins/execution-history/src/react/run-row.tsx @@ -0,0 +1,185 @@ +import { cn } from "@executor-js/react/lib/utils"; +import { Badge } from "@executor-js/react/components/badge"; +import { useResolveActorLabel } from "@executor-js/react/lib/actor-labels"; + +import type { RunRow } from "../sdk/collections"; +import { formatDuration, formatRelative, statusLabel } from "./format"; +import { actorTone, STATUS_TONES, triggerTone } from "./status"; +import { HoverCardTimestamp } from "./hover-card-timestamp"; +import type { RunColumns } from "./view"; + +// One semantic
+ + Failed to load more + + +
+ Loading runs… +
+ Unable to load runs. +
with no fixed widths; the +// table's content-fit layout sizes each column to its widest cell across the +// header and all rows. Alignment matches RunListRow (short/numeric center, text +// left). `bg-background` keeps rows from bleeding through the sticky header. +// --------------------------------------------------------------------------- + +const HEAD = + "border-b border-border/60 bg-background px-3 py-1.5 text-[10px] font-medium uppercase tracking-wider text-muted-foreground/60 whitespace-nowrap"; + +interface SortButtonProps { + readonly label: string; + readonly field: RunsSortField; + readonly sortField: RunsSortField; + readonly sortDirection: "asc" | "desc"; + readonly onSort: (field: RunsSortField) => void; +} + +function SortButton({ label, field, sortField, sortDirection, onSort }: SortButtonProps) { + const isActive = sortField === field; + const Icon = isActive ? (sortDirection === "desc" ? ArrowDown : ArrowUp) : ArrowUpDown; + + return ( + + ); +} + +export interface RunsColumnHeaderProps { + readonly sortField: RunsSortField; + readonly sortDirection: "asc" | "desc"; + readonly onSort: (field: RunsSortField) => void; + readonly columns: RunColumns; +} + +export function RunsColumnHeader({ + sortField, + sortDirection, + onSort, + columns, +}: RunsColumnHeaderProps) { + return ( +
+ + {/* Started (sortable, left) */} + + + + status + + trigger + + actor + + + + tools + + interaction + + log + + code +
so it is valid inside the runs
+
+
+ + + + + + Live + +
+
+
row per run. Columns are content-fit — widths come from +// the browser's `table-layout: auto` over all rows + the header (see RunsShell), +// so there are no fixed column widths. Short / numeric / badge columns center; +// text columns left-align. The two columns that can run long — `actor` (a token +// id / email) and `code` — are width-capped and truncated (hover shows the full +// value) so content-fit can't blow the table out. +const CELL = "border-b border-border/40 px-3 py-2 align-middle whitespace-nowrap"; + +export interface RunListRowProps { + readonly run: RunRow; + readonly selected: boolean; + readonly isPast: boolean; + readonly columns: RunColumns; + readonly onSelect: () => void; +} + +export function RunListRow({ run, selected, isPast, columns, onSelect }: RunListRowProps) { + const tone = STATUS_TONES[run.status]; + const trigger = triggerTone(run.triggerKind); + const actor = actorTone(run.actorKind); + // Prefer the live friendly label (e.g. a service-token machine name) over the + // snapshot captured at run time, falling back to the snapshot then the id. + const resolveActorLabel = useResolveActorLabel(); + const actorLabel = resolveActorLabel(run.actorKind, run.actorId) ?? run.actorLabel ?? run.actorId; + const isLive = run.status === "running" || run.status === "waiting_for_interaction"; + const logErrors = run.logErrorCount; + const logWarns = run.logWarnCount; + + return ( + { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + onSelect(); + } + }} + className={cn( + "group cursor-pointer font-mono text-xs outline-none transition-colors", + "hover:bg-foreground/[0.03] focus-visible:bg-foreground/[0.05]", + selected && "bg-foreground/[0.05] hover:bg-foreground/[0.05]", + isPast && "opacity-50", + )} + > + {/* Status dot (centered) */} + + + {/* Started (left) */} + + + {/* Status (centered) */} + + + {/* Trigger (left, optional) */} + {columns.trigger ? ( + + ) : null} + + {/* Actor (left, capped + truncated, optional) */} + {columns.actor ? ( + + ) : null} + + {/* Duration (centered, optional) — slow runs (>5s) flagged */} + {columns.duration ? ( + + ) : null} + + {/* Tool call count (centered, optional) */} + {columns.tools ? ( + + ) : null} + + {/* Interaction (centered, optional) */} + {columns.interaction ? ( + + ) : null} + + {/* Log error/warn counts (centered, optional) */} + {columns.log ? ( + + ) : null} + + {/* Code preview (left, capped + truncated; hover reveals the full + write-bounded preview) */} + + + ); +} diff --git a/packages/plugins/execution-history/src/react/runs-list-state.test.ts b/packages/plugins/execution-history/src/react/runs-list-state.test.ts new file mode 100644 index 0000000000..449530be86 --- /dev/null +++ b/packages/plugins/execution-history/src/react/runs-list-state.test.ts @@ -0,0 +1,136 @@ +import { describe, expect, it } from "@effect/vitest"; + +import type { RunRow } from "../sdk/collections"; +import { + initialRunsListState, + runsListReducer, + runsListRows, + type RunsListAction, + type RunsListState, +} from "./runs-list-state"; + +const row = (id: string, startedAt: number): RunRow => ({ + executionId: id, + status: "completed", + codePreview: "noop", + triggerKind: "cli", + logErrorCount: 0, + logWarnCount: 0, + actorId: null, + actorLabel: null, + actorKind: null, + startedAt, + completedAt: startedAt + 100, + durationMs: 100, + toolCallCount: 0, + hadInteraction: false, +}); + +const run = (actions: readonly RunsListAction[], from: RunsListState = initialRunsListState) => + actions.reduce(runsListReducer, from); + +describe("runsListReducer", () => { + it("appends cursor pages in order and merges into one list", () => { + const state = run([ + { + type: "appendPage", + cursor: undefined, + runs: [row("r3", 3000), row("r2", 2000)], + nextCursor: "c2", + }, + { type: "loadMore" }, + { type: "appendPage", cursor: "c2", runs: [row("r1", 1000)], nextCursor: null }, + ]); + expect(runsListRows(state).map((r) => r.executionId)).toEqual(["r3", "r2", "r1"]); + expect(state.done).toBe(true); + expect(state.cursor).toBe("c2"); + }); + + it("ignores a duplicate appendPage for the same cursor (idempotent)", () => { + const page = { + type: "appendPage", + cursor: undefined, + runs: [row("r1", 1000)], + nextCursor: "c2", + } as const; + const state = run([page, page]); + expect(state.pages).toHaveLength(1); + expect(runsListRows(state).map((r) => r.executionId)).toEqual(["r1"]); + }); + + it("loadMore is a no-op when there is no next cursor", () => { + const state = run([ + { type: "appendPage", cursor: undefined, runs: [row("r1", 1000)], nextCursor: null }, + { type: "loadMore" }, + ]); + expect(state.cursor).toBeUndefined(); + }); + + it("prependLive adds only unseen rows, newest-first, above existing pages", () => { + const base = run([ + { + type: "appendPage", + cursor: undefined, + runs: [row("r2", 2000), row("r1", 1000)], + nextCursor: null, + }, + { type: "enableLive" }, + ]); + expect(base.liveCutoffId).toBe("r2"); + + // A live refresh returns the new top row r3 plus the already-seen r2/r1. + const live = runsListReducer(base, { + type: "prependLive", + runs: [row("r3", 3000), row("r2", 2000), row("r1", 1000)], + }); + expect(runsListRows(live).map((r) => r.executionId)).toEqual(["r3", "r2", "r1"]); + + // A second identical refresh adds nothing. + const again = runsListReducer(live, { + type: "prependLive", + runs: [row("r3", 3000), row("r2", 2000)], + }); + expect(again).toBe(live); + }); + + it("dedupes a run that appears in both a live refresh and a later page", () => { + const state = run([ + { type: "appendPage", cursor: undefined, runs: [row("r2", 2000)], nextCursor: "c2" }, + { type: "prependLive", runs: [row("r3", 3000)] }, + { type: "loadMore" }, + // page 2 happens to re-include r3 (race); it must not double up. + { + type: "appendPage", + cursor: "c2", + runs: [row("r3", 3000), row("r1", 1000)], + nextCursor: null, + }, + ]); + expect(runsListRows(state).map((r) => r.executionId)).toEqual(["r3", "r2", "r1"]); + }); + + it("runsListRows(state, false) places live rows after pages.flat()", () => { + const state = run([ + { + type: "appendPage", + cursor: undefined, + runs: [row("r2", 2000), row("r1", 1000)], + nextCursor: null, + }, + { type: "prependLive", runs: [row("r3", 3000)] }, + ]); + // Default (liveFirst) keeps the live row on top. + expect(runsListRows(state).map((r) => r.executionId)).toEqual(["r3", "r2", "r1"]); + // Ascending sort appends live rows after the paginated rows. + expect(runsListRows(state, false).map((r) => r.executionId)).toEqual(["r2", "r1", "r3"]); + }); + + it("reset clears all accumulated state", () => { + const state = run([ + { type: "appendPage", cursor: undefined, runs: [row("r1", 1000)], nextCursor: "c2" }, + { type: "enableLive" }, + { type: "reset" }, + ]); + expect(state).toEqual(initialRunsListState); + }); +}); diff --git a/packages/plugins/execution-history/src/react/runs-list-state.ts b/packages/plugins/execution-history/src/react/runs-list-state.ts new file mode 100644 index 0000000000..afec739131 --- /dev/null +++ b/packages/plugins/execution-history/src/react/runs-list-state.ts @@ -0,0 +1,113 @@ +import type { RunRow } from "../sdk/collections"; + +// --------------------------------------------------------------------------- +// Pure state machine for the runs list. Kept free of React/atoms so the +// fiddly accumulation + live-prepend logic is unit-testable. +// +// Two row sources merge into one display list (newest first): +// - `live` — rows that arrived via live polling after live mode was enabled +// - `pages` — the cursor-paginated scroll history (page 1, page 2, ...) +// A `seen` set dedupes across both so the same run never appears twice and a +// live refresh only ever prepends genuinely new runs. Append/prepend are +// idempotent (guarded by `seen`/`appended`), so the React effects that drive +// them can fire repeatedly without corrupting state. +// --------------------------------------------------------------------------- + +export interface RunsListState { + /** Cursor-paginated scroll history; each page is newest-first. */ + readonly pages: readonly (readonly RunRow[])[]; + /** Rows prepended by live polling, newest-first. */ + readonly live: readonly RunRow[]; + /** executionIds present in `pages` or `live`. */ + readonly seen: ReadonlySet; + /** Cursor of the page currently subscribed (undefined = first page). */ + readonly cursor: string | undefined; + /** Page keys already folded in (cursor ?? ""), for idempotent appends. */ + readonly appended: ReadonlySet; + /** nextCursor of the most recently appended page. */ + readonly nextCursor: string | null; + /** True once a page returns no nextCursor. */ + readonly done: boolean; + /** Divider anchor: the top row id when live mode was enabled. */ + readonly liveCutoffId: string | null; +} + +export type RunsListAction = + | { + readonly type: "appendPage"; + readonly cursor: string | undefined; + readonly runs: readonly RunRow[]; + readonly nextCursor: string | null; + } + | { readonly type: "loadMore" } + | { readonly type: "enableLive" } + | { readonly type: "disableLive" } + | { readonly type: "prependLive"; readonly runs: readonly RunRow[] } + | { readonly type: "reset" }; + +export const initialRunsListState: RunsListState = { + pages: [], + live: [], + seen: new Set(), + cursor: undefined, + appended: new Set(), + nextCursor: null, + done: false, + liveCutoffId: null, +}; + +const pageKey = (cursor: string | undefined): string => cursor ?? ""; + +const topRowId = (state: RunsListState): string | null => + state.live[0]?.executionId ?? state.pages[0]?.[0]?.executionId ?? null; + +export const runsListReducer = (state: RunsListState, action: RunsListAction): RunsListState => { + if (action.type === "appendPage") { + const key = pageKey(action.cursor); + if (state.appended.has(key)) return state; + const fresh = action.runs.filter((run) => !state.seen.has(run.executionId)); + const seen = new Set(state.seen); + for (const run of fresh) seen.add(run.executionId); + return { + ...state, + pages: [...state.pages, fresh], + seen, + appended: new Set(state.appended).add(key), + nextCursor: action.nextCursor, + done: action.nextCursor === null, + }; + } + if (action.type === "loadMore") { + if (state.done || state.nextCursor === null || state.nextCursor === state.cursor) { + return state; + } + return { ...state, cursor: state.nextCursor }; + } + if (action.type === "enableLive") { + return { ...state, liveCutoffId: topRowId(state) }; + } + if (action.type === "disableLive") { + return { ...state, liveCutoffId: null }; + } + if (action.type === "prependLive") { + // Append-only: a live refresh only adds genuinely-new runs. A run already + // displayed keeps the data it was first seen with — a status transition + // (running -> completed) is not reflected until the list is reset/refiltered + // (the aggregate `meta` does update live). Acceptable for v1. + const fresh = action.runs.filter((run) => !state.seen.has(run.executionId)); + if (fresh.length === 0) return state; + const seen = new Set(state.seen); + for (const run of fresh) seen.add(run.executionId); + return { ...state, live: [...fresh, ...state.live], seen }; + } + // action.type === "reset" + return initialRunsListState; +}; + +/** + * The merged display list. With `liveFirst` (the default, for descending / + * newest-first sort) live rows go on top; otherwise (ascending sort) they are + * appended after the paginated rows so the live tail stays at the bottom. + */ +export const runsListRows = (state: RunsListState, liveFirst = true): readonly RunRow[] => + liveFirst ? [...state.live, ...state.pages.flat()] : [...state.pages.flat(), ...state.live]; diff --git a/packages/plugins/execution-history/src/react/shell.tsx b/packages/plugins/execution-history/src/react/shell.tsx new file mode 100644 index 0000000000..abdf1ffe9b --- /dev/null +++ b/packages/plugins/execution-history/src/react/shell.tsx @@ -0,0 +1,184 @@ +import * as React from "react"; + +import { cn } from "@executor-js/react/lib/utils"; +import { useWindowedRows } from "@executor-js/react/components/virtual-list"; + +// --------------------------------------------------------------------------- +// RunsShell — two-column layout (filter rail + main pane) with a windowed, +// infinite-scrolling table. +// +// The left
+ + + + + {statusLabel(run.status)} + {isLive && run.status === "waiting_for_interaction" ? ( + · + ) : null} + + + + {trigger.label} + + + {run.actorId !== null ? ( + + + + {actorLabel} + + + ) : ( + — + )} + 5000 + ? "text-destructive" + : "text-muted-foreground", + )} + > + {formatDuration(run.durationMs)} + 0 ? "text-foreground/80" : "text-muted-foreground/60", + )} + > + {run.toolCallCount} + + {run.hadInteraction ? ( + + yes + + ) : ( + — + )} + + {logErrors === 0 && logWarns === 0 ? ( + — + ) : ( + + 0 ? "text-destructive" : "text-muted-foreground/60"}> + {logErrors}E + + 0 ? "text-amber-600 dark:text-amber-300" : "text-muted-foreground/60" + } + > + {logWarns}W + + + )} + + code: + "{run.codePreview}" +
with a sticky ; only the rows near the viewport are +// mounted, with top/bottom spacer s standing in for the rest (useWindowedRows +// from the shared virtualization primitive). The two columns that can run long — +// actor and code — are width-capped in run-row.tsx, so windowing a subset doesn't +// make `table-layout: auto` columns jitter as rows scroll in. The scroller fires +// onLoadMore within 320px of the bottom, guarded against re-firing while loading. +// --------------------------------------------------------------------------- + +// Upper bound on the visible column count (dot + started + status + up to six +// optional columns + code = 10). Full-width message rows span the whole table +// with this; an over-count colSpan is harmless (the browser clamps it). +export const RUNS_TABLE_COLSPAN = 12; + +// Approximate keyset row height (run-row.tsx: px-3 py-2 + border ≈ 33px). Rows +// are near-uniform, so a fixed estimate keeps the spacer math stable. +const RUNS_ROW_ESTIMATE = 33; + +export interface RunsShellRow { + readonly key: React.Key; + readonly node: React.ReactNode; +} + +export interface RunsShellProps { + readonly filterRail: React.ReactNode; + readonly toolbar: React.ReactNode; + readonly chart?: React.ReactNode; + readonly columnHeader: React.ReactNode; + /** Windowed body rows — run rows plus the interleaved live divider. */ + readonly rows: readonly RunsShellRow[]; + readonly estimateRowSize?: number; + readonly scrollRestoreId?: string; + readonly onLoadMore: () => void; + readonly hasMore: boolean; + readonly isLoadingMore: boolean; + readonly isEmpty: boolean; + readonly emptyState?: React.ReactNode; + /** A full-width shown instead of the windowed rows (loading / error). */ + readonly statusRow?: React.ReactNode; + /** A appended after the rows — e.g. the load-more retry prompt. */ + readonly loadMoreError?: React.ReactNode; + readonly railCollapsed?: boolean; +} + +export function RunsShell(props: RunsShellProps) { + const { + filterRail, + toolbar, + chart, + columnHeader, + rows, + estimateRowSize, + scrollRestoreId, + onLoadMore, + hasMore, + isLoadingMore, + isEmpty, + emptyState, + statusRow, + loadMoreError, + railCollapsed, + } = props; + + const canLoadMore = hasMore && !isLoadingMore && !isEmpty; + const { scrollRef, virtualItems, paddingTop, paddingBottom, onScroll } = useWindowedRows({ + count: rows.length, + estimateSize: estimateRowSize ?? RUNS_ROW_ESTIMATE, + overscan: 16, + onEndReached: canLoadMore ? onLoadMore : undefined, + scrollRestoreId, + }); + + return ( +
+ {/* Filter rail */} + + + {/* Main pane */} +
+ {/* Toolbar + chart — full-width controls, do not scroll with the table */} +
+
{toolbar}
+ {chart != null &&
{chart}
} +
+ + {/* Table scroller — owns both axes and is the virtualizer's scroll + element. The sticky
rides the same scroll; only near-viewport + rows mount, bracketed by spacer s. `border-separate + border-spacing-0` keeps cell borders on the sticky header. */} +
+ {/* oxlint-disable-next-line react/forbid-elements -- the runs table needs + this custom scroll container (the windowed infinite-scroll handler) + + a sticky thead inside it; the shared
wraps its own + overflow-x-auto div, which would conflict. Same suppression the + shared Table component itself uses on its raw
. */} +
+ {columnHeader} + + {isEmpty ? ( + + + + ) : statusRow != null ? ( + statusRow + ) : ( + <> + {paddingTop > 0 && ( + + + )} + {virtualItems.map((item) => ( + + {rows[item.index]!.node} + + ))} + {paddingBottom > 0 && ( + + + )} + {loadMoreError} + {isLoadingMore && ( + + + + )} + {!hasMore && !isLoadingMore && ( + + + + )} + + )} + +
+ {emptyState ?? ( + + No runs match the current filters. + + )} +
+
+
+ Loading more… +
+ End of history +
+ + + + ); +} diff --git a/packages/plugins/execution-history/src/react/status.ts b/packages/plugins/execution-history/src/react/status.ts new file mode 100644 index 0000000000..498f20c2c4 --- /dev/null +++ b/packages/plugins/execution-history/src/react/status.ts @@ -0,0 +1,120 @@ +import type { RunStatus } from "../sdk/collections"; + +// --------------------------------------------------------------------------- +// Status + trigger presentation constants (no JSX) shared across the runs UI. +// The plugin's RunStatus is the 4-value lifecycle (running / +// waiting_for_interaction / completed / failed) — the inline prototype's +// pending/cancelled states don't exist in this data model. +// --------------------------------------------------------------------------- + +/** Canonical render order (most "settled" first) for facets + chart stacking. */ +export const STATUS_ORDER: readonly RunStatus[] = [ + "completed", + "running", + "waiting_for_interaction", + "failed", +]; + +export const STATUS_LABELS: Record = { + running: "running", + waiting_for_interaction: "waiting", + completed: "completed", + failed: "failed", +}; + +export interface StatusTone { + /** Background class for the status dot. */ + readonly dot: string; + /** Foreground text class. */ + readonly text: string; + /** Border/bg/text classes for an outline badge. */ + readonly badge: string; + /** Whether the dot should pulse (transient states). */ + readonly pulse: boolean; +} + +export const STATUS_TONES: Record = { + completed: { + dot: "bg-emerald-500", + text: "text-emerald-600 dark:text-emerald-300", + badge: "border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300", + pulse: false, + }, + running: { + dot: "bg-sky-500", + text: "text-sky-600 dark:text-sky-300", + badge: "border-sky-500/30 bg-sky-500/10 text-sky-700 dark:text-sky-300", + pulse: true, + }, + waiting_for_interaction: { + dot: "bg-amber-500", + text: "text-amber-600 dark:text-amber-300", + badge: "border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-300", + pulse: true, + }, + failed: { + dot: "bg-destructive", + text: "text-destructive", + badge: "border-destructive/30 bg-destructive/10 text-destructive", + pulse: false, + }, +}; + +export const statusTone = (status: RunStatus): StatusTone => STATUS_TONES[status]; + +/** Literal hex fills for the recharts stacked timeline (one series per status). */ +export const STATUS_CHART_HEX: Record = { + completed: "#10b981", + running: "#0ea5e9", + waiting_for_interaction: "#f59e0b", + failed: "#ef4444", +}; + +export interface TriggerTone { + readonly dot: string; + readonly text: string; + readonly label: string; +} + +export const TRIGGER_TONES: Record = { + mcp: { dot: "bg-violet-500", text: "text-violet-600 dark:text-violet-300", label: "MCP" }, + http: { dot: "bg-cyan-500", text: "text-cyan-600 dark:text-cyan-300", label: "HTTP" }, + cli: { dot: "bg-slate-500", text: "text-slate-600 dark:text-slate-300", label: "CLI" }, + manual: { dot: "bg-slate-500", text: "text-slate-600 dark:text-slate-300", label: "manual" }, +}; + +export const TRIGGER_ORDER: readonly string[] = ["mcp", "http", "cli", "manual"]; + +export const triggerTone = (kind: string | null | undefined): TriggerTone => { + if (kind != null) { + const known = TRIGGER_TONES[kind]; + if (known) return known; + return { dot: "bg-muted-foreground/40", text: "text-muted-foreground", label: kind }; + } + return { dot: "bg-muted-foreground/40", text: "text-muted-foreground", label: "unknown" }; +}; + +// --------------------------------------------------------------------------- +// Actor tones — colour the dot in the Actor facet/column by credential class +// (`actorKind`: "user", "service-token", …). The label itself is the actor's +// own display string (machine name, email), not a fixed vocabulary, so there is +// no ACTOR_ORDER — facet keys come from `meta.actorCounts`. +// --------------------------------------------------------------------------- + +export interface ActorTone { + readonly dot: string; + readonly text: string; +} + +export const ACTOR_TONES: Record = { + user: { dot: "bg-sky-500", text: "text-foreground/80" }, + "service-token": { dot: "bg-violet-500", text: "text-foreground/80" }, +}; + +export const actorTone = (kind: string | null | undefined): ActorTone => { + if (kind != null) { + const known = ACTOR_TONES[kind]; + if (known) return known; + } + return { dot: "bg-muted-foreground/40", text: "text-muted-foreground" }; +}; diff --git a/packages/plugins/execution-history/src/react/timeline-chart.tsx b/packages/plugins/execution-history/src/react/timeline-chart.tsx new file mode 100644 index 0000000000..d221cc049b --- /dev/null +++ b/packages/plugins/execution-history/src/react/timeline-chart.tsx @@ -0,0 +1,207 @@ +import { useMemo, useState } from "react"; +import { Bar, BarChart, CartesianGrid, ReferenceArea, XAxis } from "recharts"; +import { + ChartContainer, + ChartTooltip, + ChartTooltipContent, +} from "@executor-js/react/components/chart"; +import type { ChartConfig } from "@executor-js/react/components/chart"; +import { cn } from "@executor-js/react/lib/utils"; + +import type { RunStatus } from "../sdk/collections"; +import type { RunChartBucket } from "../sdk/store"; +import { STATUS_CHART_HEX, STATUS_LABELS, STATUS_ORDER } from "./status"; + +// --------------------------------------------------------------------------- +// Compact stacked-bar timeline of runs over time, one series per RunStatus. +// +// The store's chart buckets nest per-status counts under `.counts`; recharts +// needs a flat row, so each bucket is flattened into +// `{ bucketStart, : count, ... }` keyed by status. Drag across the +// chart to select a time range (recharts hands us the `bucketStart` under the +// cursor via `activeLabel`); releasing emits `onRangeSelect`. +// --------------------------------------------------------------------------- + +const DAY_MS = 86_400_000; + +const TIMELINE_CONFIG: ChartConfig = Object.fromEntries( + STATUS_ORDER.map((status) => [ + status, + { label: STATUS_LABELS[status], color: STATUS_CHART_HEX[status] }, + ]), +); + +type ChartRow = { readonly bucketStart: number } & Record; + +/** Flatten `bucket.counts` onto the row so recharts can stack per-status. */ +const toChartRow = (bucket: RunChartBucket): ChartRow => { + const row = { bucketStart: bucket.bucketStart } as { bucketStart: number } & Record< + RunStatus, + number + >; + for (const status of STATUS_ORDER) { + row[status] = bucket.counts[status] ?? 0; + } + return row; +}; + +const buildAxisFormatter = + (bucketMs: number) => + (value: number): string => { + const date = new Date(value); + if (Number.isNaN(date.getTime())) return "—"; + const options: Intl.DateTimeFormatOptions = + bucketMs <= 60_000 + ? { hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false } + : bucketMs < DAY_MS + ? { hour: "2-digit", minute: "2-digit", hour12: false } + : bucketMs >= 7 * DAY_MS + ? { month: "short", day: "numeric" } + : { month: "2-digit", day: "2-digit" }; + return new Intl.DateTimeFormat(undefined, options).format(date); + }; + +const formatTooltipLabel = (value: number, bucketMs: number): string => { + const date = new Date(value); + if (Number.isNaN(date.getTime())) return "—"; + return new Intl.DateTimeFormat(undefined, { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + ...(bucketMs <= 60_000 ? { second: "2-digit" as const } : {}), + hour12: false, + }).format(date); +}; + +// recharts surfaces the category under the cursor on its mouse events; only +// `activeLabel` is relevant here (the `bucketStart` of the hovered bar). +type ChartMouseEvent = { readonly activeLabel?: string | number }; + +const labelToBucket = (value: string | number | null): number | null => { + if (value == null) return null; + const parsed = typeof value === "number" ? value : Number(value); + return Number.isFinite(parsed) ? parsed : null; +}; + +export function RunsTimelineChart(props: { + readonly data: readonly RunChartBucket[]; + readonly bucketMs: number; + readonly onRangeSelect?: (range: { readonly from: number; readonly to: number }) => void; + readonly className?: string; +}) { + const [dragStart, setDragStart] = useState(null); + const [dragEnd, setDragEnd] = useState(null); + + const rows = useMemo(() => props.data.map(toChartRow), [props.data]); + const formatAxisTick = useMemo(() => buildAxisFormatter(props.bucketMs), [props.bucketMs]); + + const resetDrag = () => { + setDragStart(null); + setDragEnd(null); + }; + + const handleMouseDown = (event: ChartMouseEvent | null | undefined) => { + const bucket = labelToBucket(event?.activeLabel ?? null); + if (bucket == null) return; + setDragStart(bucket); + setDragEnd(bucket); + }; + + const handleMouseMove = (event: ChartMouseEvent | null | undefined) => { + if (dragStart == null) return; + const bucket = labelToBucket(event?.activeLabel ?? null); + if (bucket != null) setDragEnd(bucket); + }; + + const handleMouseUp = () => { + if (dragStart != null && dragEnd != null && dragStart !== dragEnd && props.onRangeSelect) { + const from = Math.min(dragStart, dragEnd); + const to = Math.max(dragStart, dragEnd) + props.bucketMs; + props.onRangeSelect({ from, to }); + } + resetDrag(); + }; + + if (rows.length === 0) { + return ( +
+ No activity +
+ ); + } + + return ( + + + + + { + const first = payload?.[0]?.payload as { bucketStart?: number } | undefined; + return first?.bucketStart == null + ? "—" + : formatTooltipLabel(first.bucketStart, props.bucketMs); + }} + /> + } + /> + {STATUS_ORDER.map((status) => ( + + ))} + {dragStart != null && dragEnd != null && dragStart !== dragEnd ? ( + + ) : null} + + + ); +} diff --git a/packages/plugins/execution-history/src/react/use-runs-list.ts b/packages/plugins/execution-history/src/react/use-runs-list.ts new file mode 100644 index 0000000000..93477c2874 --- /dev/null +++ b/packages/plugins/execution-history/src/react/use-runs-list.ts @@ -0,0 +1,175 @@ +import { useAtomRefresh, useAtomValue } from "@effect/atom-react"; +import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; +import { useCallback, useEffect, useMemo, useReducer, useState } from "react"; + +import type { RunRow } from "../sdk/collections"; +import type { ExecutionListMeta } from "../sdk/store"; +import { runsAtom, type RunsQuery } from "./atoms"; +import { initialRunsListState, runsListReducer, runsListRows } from "./runs-list-state"; + +// --------------------------------------------------------------------------- +// Runs list data hook — infinite cursor pagination + interval live polling on +// the plugin's Effect-atom client (which has no useInfiniteQuery). The atom +// returns AsyncResult; the accumulation lives in a pure reducer +// (runs-list-state) so this hook is a thin glue layer. The first-page atom is +// always subscribed so `meta` stays available while scrolling and live polling +// can refresh the head independently of the current scroll cursor. +// --------------------------------------------------------------------------- + +export type RunsSortField = "startedAt" | "durationMs"; + +export interface RunsFilters { + readonly status: readonly string[]; + readonly trigger: readonly string[]; + readonly actor: readonly string[]; + readonly interaction: "true" | "false" | null; + readonly from: number | null; + readonly to: number | null; + readonly sortField: RunsSortField; + readonly sortDirection: "asc" | "desc"; +} + +export const RUNS_PAGE_SIZE = 50; +const LIVE_INTERVAL_MS = 5000; + +export const emptyRunsFilters: RunsFilters = { + status: [], + trigger: [], + actor: [], + interaction: null, + from: null, + to: null, + sortField: "startedAt", + sortDirection: "desc", +}; + +export const buildRunsQuery = (filters: RunsFilters, cursor: string | undefined): RunsQuery => ({ + limit: RUNS_PAGE_SIZE, + sort: filters.sortField, + dir: filters.sortDirection, + ...(filters.status.length > 0 ? { status: filters.status.join(",") } : {}), + ...(filters.trigger.length > 0 ? { trigger: filters.trigger.join(",") } : {}), + ...(filters.actor.length > 0 ? { actor: filters.actor.join(",") } : {}), + ...(filters.interaction != null ? { interaction: filters.interaction } : {}), + ...(filters.from != null ? { from: filters.from } : {}), + ...(filters.to != null ? { to: filters.to } : {}), + ...(cursor !== undefined ? { cursor } : {}), +}); + +const filtersKey = (filters: RunsFilters): string => + JSON.stringify([ + filters.status, + filters.trigger, + filters.actor, + filters.interaction, + filters.from, + filters.to, + filters.sortField, + filters.sortDirection, + ]); + +export interface RunsListView { + readonly rows: readonly RunRow[]; + readonly meta: ExecutionListMeta | null; + readonly isLoading: boolean; + readonly isLoadingMore: boolean; + readonly isError: boolean; + readonly isLoadMoreError: boolean; + readonly hasMore: boolean; + readonly loadMore: () => void; + readonly retry: () => void; + readonly refresh: () => void; + readonly liveCutoffId: string | null; +} + +export const useRunsList = (filters: RunsFilters, live: boolean): RunsListView => { + const [state, dispatch] = useReducer(runsListReducer, initialRunsListState); + + // Reset accumulation when the filter set changes — the documented + // "store previous value, adjust during render" pattern (the stale render is + // discarded before paint). + const key = filtersKey(filters); + const [prevKey, setPrevKey] = useState(key); + if (prevKey !== key) { + setPrevKey(key); + dispatch({ type: "reset" }); + } + + const firstQuery = useMemo(() => buildRunsQuery(filters, undefined), [filters]); + const firstAtom = useMemo(() => runsAtom(firstQuery), [firstQuery]); + const pagedQuery = useMemo(() => buildRunsQuery(filters, state.cursor), [filters, state.cursor]); + const pagedAtom = useMemo(() => runsAtom(pagedQuery), [pagedQuery]); + // Page 1 reuses the first-page atom so `refresh` (which invalidates the + // first-page atom) re-folds the head, and the head is not double-fetched — + // Atom.family keys by object reference, so two structurally-equal queries + // would otherwise be two distinct atoms. + const pageAtom = state.cursor === undefined ? firstAtom : pagedAtom; + + const pageResult = useAtomValue(pageAtom); + const firstResult = useAtomValue(firstAtom); + const refreshFirst = useAtomRefresh(firstAtom); + const refreshPaged = useAtomRefresh(pagedAtom); + // Descending (newest-first) keeps live rows on top; ascending appends them + // to the tail so the live edge stays at the bottom. + const rows = runsListRows(state, filters.sortDirection !== "asc"); + + // Fold each settled page into the accumulator (idempotent in the reducer). + useEffect(() => { + if (!AsyncResult.isSuccess(pageResult) || pageResult.waiting) return; + dispatch({ + type: "appendPage", + cursor: state.cursor, + runs: pageResult.value.runs, + nextCursor: pageResult.value.nextCursor, + }); + }, [pageResult, state.cursor]); + + // Live polling: prepend any genuinely-new head rows. + useEffect(() => { + if (!live || !AsyncResult.isSuccess(firstResult) || firstResult.waiting) return; + dispatch({ type: "prependLive", runs: firstResult.value.runs }); + }, [live, firstResult]); + + useEffect(() => { + dispatch({ type: live ? "enableLive" : "disableLive" }); + }, [live]); + + // If live was toggled on before any rows existed, anchor the divider once + // rows arrive — otherwise the cutoff stays null for the whole session. + useEffect(() => { + if (live && state.liveCutoffId === null && rows.length > 0) { + dispatch({ type: "enableLive" }); + } + }, [live, state.liveCutoffId, rows.length]); + + useEffect(() => { + if (!live) return; + const id = setInterval(refreshFirst, LIVE_INTERVAL_MS); + return () => clearInterval(id); + }, [live, refreshFirst]); + + const loadMore = useCallback(() => dispatch({ type: "loadMore" }), []); + // Re-fetch the errored page; on success the appendPage effect folds it in. + const retry = useCallback(() => refreshPaged(), [refreshPaged]); + const refresh = useCallback(() => { + dispatch({ type: "reset" }); + refreshFirst(); + }, [refreshFirst]); + + const meta = AsyncResult.isSuccess(firstResult) ? firstResult.value.meta : null; + const settling = AsyncResult.isInitial(pageResult) || pageResult.waiting; + + return { + rows, + meta, + isLoading: rows.length === 0 && settling, + isLoadingMore: state.cursor !== undefined && settling, + isError: AsyncResult.isFailure(pageResult) && rows.length === 0, + isLoadMoreError: state.cursor !== undefined && AsyncResult.isFailure(pageResult), + hasMore: !state.done, + loadMore, + retry, + refresh, + liveCutoffId: state.liveCutoffId, + }; +}; diff --git a/packages/plugins/execution-history/src/react/view-options.tsx b/packages/plugins/execution-history/src/react/view-options.tsx new file mode 100644 index 0000000000..05d6e75375 --- /dev/null +++ b/packages/plugins/execution-history/src/react/view-options.tsx @@ -0,0 +1,52 @@ +import { Check, Settings2 } from "lucide-react"; +import { Button } from "@executor-js/react/components/button"; +import { Popover, PopoverContent, PopoverTrigger } from "@executor-js/react/components/popover"; +import { cn } from "@executor-js/react/lib/utils"; + +import { RUN_COLUMN_LABELS, type RunColumnKey, type RunColumns } from "./view"; + +const COLUMN_KEYS: readonly RunColumnKey[] = [ + "trigger", + "actor", + "duration", + "tools", + "interaction", + "log", +]; + +export function ViewOptions(props: { + readonly columns: RunColumns; + readonly onToggle: (key: RunColumnKey) => void; +}) { + return ( + + + + + +

+ Columns +

+ {COLUMN_KEYS.map((key) => ( + + ))} +
+
+ ); +} diff --git a/packages/plugins/execution-history/src/react/view.ts b/packages/plugins/execution-history/src/react/view.ts new file mode 100644 index 0000000000..c19aae9c47 --- /dev/null +++ b/packages/plugins/execution-history/src/react/view.ts @@ -0,0 +1,34 @@ +// --------------------------------------------------------------------------- +// Shared view-model types for the runs table (column visibility + labels). +// The sort state lives on RunsFilters (sortField/sortDirection), so there is no +// separate sort type here. +// --------------------------------------------------------------------------- + +export interface RunColumns { + readonly trigger: boolean; + readonly actor: boolean; + readonly duration: boolean; + readonly tools: boolean; + readonly interaction: boolean; + readonly log: boolean; +} + +export type RunColumnKey = keyof RunColumns; + +export const DEFAULT_COLUMNS: RunColumns = { + trigger: true, + actor: true, + duration: true, + tools: true, + interaction: true, + log: false, +}; + +export const RUN_COLUMN_LABELS: Record = { + trigger: "Trigger", + actor: "Actor", + duration: "Duration", + tools: "Tools", + interaction: "Interaction", + log: "Log", +}; diff --git a/packages/plugins/execution-history/src/sdk/collections.test.ts b/packages/plugins/execution-history/src/sdk/collections.test.ts new file mode 100644 index 0000000000..dd0f63ee4b --- /dev/null +++ b/packages/plugins/execution-history/src/sdk/collections.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Schema } from "effect"; + +import { RunRow } from "./collections"; + +// --------------------------------------------------------------------------- +// Schema-evolution tolerance for the JSON-document `runs` collection. +// +// `#20` added actorId/actorLabel/actorKind. Runs stored BEFORE that have no such +// key at all. The store passes the raw stored doc straight to the HTTP response +// encoder, so a legacy doc must both decode AND encode through `RunRow` without a +// "Missing key" error — otherwise the entire runs list 400s on the newest page. +// These fields use an optional key + a decoding default of null precisely so +// that holds. +// --------------------------------------------------------------------------- + +// Hoisted: `Schema.*Sync` compiles a function, so it must not be rebuilt per call. +const decodeRunRow = Schema.decodeUnknownSync(RunRow); +const encodeRunRow = Schema.encodeUnknownSync(RunRow); + +// A run document as written before the actor fields existed: the three actor +// keys are physically absent. +const legacyRunDoc = { + executionId: "exec_legacy", + status: "completed", + codePreview: "noop", + triggerKind: null, + logErrorCount: 0, + logWarnCount: 0, + startedAt: 1000, + completedAt: 2000, + durationMs: 1000, + toolCallCount: 0, + hadInteraction: false, +}; + +describe("RunRow legacy-document tolerance", () => { + it("decodes a legacy doc, defaulting the absent actor fields to null", () => { + const decoded = decodeRunRow(legacyRunDoc); + expect(decoded.actorId).toBeNull(); + expect(decoded.actorLabel).toBeNull(); + expect(decoded.actorKind).toBeNull(); + // The decoded type is `string | null` (always present) — readers treat it as + // required; it just defaults to null for pre-actor runs. + expect(decoded.executionId).toBe("exec_legacy"); + }); + + it("encodes a legacy doc with the actor keys absent (the runs-response regression)", () => { + // The store hands the raw stored doc to the response encoder, so the encode + // input is the legacy shape itself. With `NullOr` this threw + // "Missing key at [actorId]"; with the optional key it must succeed, and the + // absent actor keys stay absent (the encoder must not fabricate them). + const encoded = encodeRunRow(legacyRunDoc); + expect(encoded.executionId).toBe("exec_legacy"); + expect(encoded.actorId ?? null).toBeNull(); + expect(encoded.actorLabel ?? null).toBeNull(); + expect(encoded.actorKind ?? null).toBeNull(); + }); + + it("round-trips a run that DOES carry an actor through decode AND encode", () => { + const withActor = { + ...legacyRunDoc, + actorId: "tok.access", + actorLabel: "phoenix", + actorKind: "service-token", + }; + const decoded = decodeRunRow(withActor); + expect(decoded.actorId).toBe("tok.access"); + expect(decoded.actorLabel).toBe("phoenix"); + expect(decoded.actorKind).toBe("service-token"); + // Encode must carry the present keys through unchanged (the path the runs + // response actually takes for an attributed run). + const encoded = encodeRunRow(decoded); + expect(encoded.actorId).toBe("tok.access"); + expect(encoded.actorLabel).toBe("phoenix"); + expect(encoded.actorKind).toBe("service-token"); + }); +}); diff --git a/packages/plugins/execution-history/src/sdk/collections.ts b/packages/plugins/execution-history/src/sdk/collections.ts new file mode 100644 index 0000000000..bffe84a468 --- /dev/null +++ b/packages/plugins/execution-history/src/sdk/collections.ts @@ -0,0 +1,125 @@ +import { Effect, Schema } from "effect"; + +import { definePluginStorageCollection } from "@executor-js/sdk/core"; + +// --------------------------------------------------------------------------- +// Execution-history storage collections. +// +// ONE owner-scoped plugin-storage collection backs the run history: a slim +// `runs` row per execution carrying only the fields the list + aggregate +// surface needs (status, trigger, actor, timing, counts) plus two bounded +// denormalized fields — `codePreview` (the list's snippet) and +// `logErrorCount`/`logWarnCount` (the list's optional log column) — so the list +// renders entirely from D1 with no per-row blob fetch. +// +// The bulky, drawer-only detail (full `code`, `resultJson`, `errorText`, +// `logsJson`, `triggerMetaJson`, and the per-tool-call / per-interaction rows) +// lives in an append-only R2 object per run (see `detail-types.ts` + the store's +// `deps.blobs` writes), keeping the D1 row tiny and uncapped. `ToolCallRow` / +// `InteractionRow` remain here as the shared detail row shapes (they back the R2 +// detail object and the read response), but no longer have their own collection. +// Indexes are declared so the read surface can filter/sort on them (the facade +// type-enforces that only declared fields appear in `where`/`orderBy`). +// --------------------------------------------------------------------------- + +/** Terminal + transient lifecycle state of a single execution. */ +export const RunStatus = Schema.Literals([ + "running", + "waiting_for_interaction", + "completed", + "failed", +]); +export type RunStatus = typeof RunStatus.Type; + +/** Lifecycle state of a single tool call within an execution. */ +export const ToolCallStatus = Schema.Literals(["running", "completed", "failed"]); +export type ToolCallStatus = typeof ToolCallStatus.Type; + +/** Lifecycle state of a single interaction (elicitation) within an execution. */ +export const InteractionStatus = Schema.Literals([ + "pending", + "accepted", + "declined", + "cancelled", + "failed", +]); +export type InteractionStatus = typeof InteractionStatus.Type; + +export const RunRow = Schema.Struct({ + executionId: Schema.String, + status: RunStatus, + // Bounded list-snippet of the code (full source lives in the R2 detail blob). + // Normalized + truncated at write time so the list renders it directly. + // Required: this is a breaking change with no backfill — the cutover clears + // pre-migration rows, so every row the store writes carries these. + codePreview: Schema.String, + triggerKind: Schema.NullOr(Schema.String), + // Denormalized log-line counts for the list's optional log column, so the + // list never has to fetch + parse the full `logsJson` (now in R2). + logErrorCount: Schema.Number, + logWarnCount: Schema.Number, + // Who/what the run acted as (from the trigger's `ExecutionActor`). `actorId` + // is the STABLE filter/facet key (a token client id, a user subject); + // `actorLabel` is the display snapshot at run time (machine name, email); + // `actorKind` is the credential class ("user", "service-token"). + // + // Optional-key + decoding default, NOT `NullOr`: runs are stored as JSON + // documents, and rows written BEFORE these fields existed have no such key at + // all. `NullOr` requires the key to be present, so a legacy doc fails the + // response encoder ("Missing key at runs[0].actorId"). Making the key optional + // (tolerant on the wire) while defaulting an absent key to null on decode lets + // older docs decode/encode unchanged — and immunizes the collection against + // the next field added the same way. The decoded type stays `string | null` + // (always present), so every reader treats it as required. + actorId: Schema.optional(Schema.NullOr(Schema.String)).pipe( + Schema.withDecodingDefaultType(Effect.succeed(null)), + ), + actorLabel: Schema.optional(Schema.NullOr(Schema.String)).pipe( + Schema.withDecodingDefaultType(Effect.succeed(null)), + ), + actorKind: Schema.optional(Schema.NullOr(Schema.String)).pipe( + Schema.withDecodingDefaultType(Effect.succeed(null)), + ), + startedAt: Schema.Number, + completedAt: Schema.NullOr(Schema.Number), + durationMs: Schema.NullOr(Schema.Number), + toolCallCount: Schema.Number, + hadInteraction: Schema.Boolean, +}); +export type RunRow = typeof RunRow.Type; + +export const runs = definePluginStorageCollection("runs", RunRow, { + indexes: ["status", "triggerKind", "actorId", "startedAt", "durationMs", "hadInteraction"], +}); + +// Per-tool-call and per-interaction detail rows. No longer their own D1 +// collections — they are serialized into the run's R2 detail object (see +// `detail-types.ts`) and returned in the `get` detail response. +export const ToolCallRow = Schema.Struct({ + executionId: Schema.String, + toolCallId: Schema.String, + status: ToolCallStatus, + path: Schema.String, + namespace: Schema.NullOr(Schema.String), + argsJson: Schema.NullOr(Schema.String), + resultJson: Schema.NullOr(Schema.String), + errorText: Schema.NullOr(Schema.String), + startedAt: Schema.Number, + completedAt: Schema.NullOr(Schema.Number), + durationMs: Schema.NullOr(Schema.Number), +}); +export type ToolCallRow = typeof ToolCallRow.Type; + +export const InteractionRow = Schema.Struct({ + executionId: Schema.String, + interactionId: Schema.String, + status: InteractionStatus, + kind: Schema.String, + purpose: Schema.NullOr(Schema.String), + payloadJson: Schema.NullOr(Schema.String), + responseJson: Schema.NullOr(Schema.String), + errorText: Schema.NullOr(Schema.String), + startedAt: Schema.Number, + completedAt: Schema.NullOr(Schema.Number), +}); +export type InteractionRow = typeof InteractionRow.Type; diff --git a/packages/plugins/execution-history/src/sdk/detail-types.ts b/packages/plugins/execution-history/src/sdk/detail-types.ts new file mode 100644 index 0000000000..988dd42647 --- /dev/null +++ b/packages/plugins/execution-history/src/sdk/detail-types.ts @@ -0,0 +1,30 @@ +import { Schema } from "effect"; + +import { InteractionRow, ToolCallRow } from "./collections"; + +// --------------------------------------------------------------------------- +// Run detail — the append-only R2 object written once per finished execution +// (and as a code-only stub on start). Holds the bulky, drawer-only payload that +// used to live in the fat `runs` row + the `toolCalls`/`interactions` +// collections: full code, the serialized result/error/logs/trigger-metadata, +// and the per-tool-call / per-interaction rows. +// +// Serialized to/from a JSON string at the blob boundary via Effect Schema +// (`Schema.fromJsonString`) — never raw `JSON.parse`/`JSON.stringify`. The +// `*Json` fields are already JSON strings (the store pre-serializes each +// `unknown` payload), so they round-trip as escaped strings the drawer parses. +// --------------------------------------------------------------------------- + +export const RunDetail = Schema.Struct({ + code: Schema.String, + resultJson: Schema.NullOr(Schema.String), + errorText: Schema.NullOr(Schema.String), + logsJson: Schema.NullOr(Schema.String), + triggerMetaJson: Schema.NullOr(Schema.String), + toolCalls: Schema.Array(ToolCallRow), + interactions: Schema.Array(InteractionRow), +}); +export type RunDetail = typeof RunDetail.Type; + +/** Codec between the R2 blob's JSON string and a typed {@link RunDetail}. */ +export const RunDetailFromJsonString = Schema.fromJsonString(RunDetail); diff --git a/packages/plugins/execution-history/src/sdk/index.ts b/packages/plugins/execution-history/src/sdk/index.ts new file mode 100644 index 0000000000..66d00f6161 --- /dev/null +++ b/packages/plugins/execution-history/src/sdk/index.ts @@ -0,0 +1,22 @@ +export { executionHistoryPlugin } from "./plugin"; + +export { + runs, + InteractionRow, + InteractionStatus, + RunRow, + RunStatus, + ToolCallRow, + ToolCallStatus, +} from "./collections"; + +export { RunDetail, RunDetailFromJsonString } from "./detail-types"; + +export { + makeExecutionHistoryObserver, + makeExecutionHistoryStore, + type ExecutionHistoryDetail, + type ExecutionHistoryListOptions, + type ExecutionHistoryListResult, + type ExecutionHistoryStore, +} from "./store"; diff --git a/packages/plugins/execution-history/src/sdk/plugin.ts b/packages/plugins/execution-history/src/sdk/plugin.ts new file mode 100644 index 0000000000..a16663040d --- /dev/null +++ b/packages/plugins/execution-history/src/sdk/plugin.ts @@ -0,0 +1,36 @@ +import { definePlugin } from "@executor-js/sdk/core"; + +import { runs } from "./collections"; +import { makeExecutionHistoryObserver, makeExecutionHistoryStore } from "./store"; + +// --------------------------------------------------------------------------- +// Execution-history plugin (SDK surface). A pure sink: it contributes no tools +// or integrations, only the slim `runs` storage collection (the bulky per-run +// detail lives in an R2 object via `deps.blobs`), a read surface (`list`/`get`) +// on `executor.executionHistory`, and a runtime ExecutionObserver that records +// the engine's event stream. +// +// One store instance is shared: `storage(deps)` builds it (read methods + +// buffered `handleEvent` writer), `extension(ctx)` surfaces the read methods +// AND `handleEvent` off `ctx.storage`, and `runtime.executionObserver(self)` +// (which receives the EXTENSION) wraps `self.handleEvent` into an observer. +// +// The HTTP transport (routes/handlers/extensionService) is layered on by the +// api-aware factory in `@executor-js/plugin-execution-history/api`, so SDK-only +// consumers never load `@executor-js/api`. +// --------------------------------------------------------------------------- + +export const executionHistoryPlugin = definePlugin(() => ({ + id: "executionHistory" as const, + packageName: "@executor-js/plugin-execution-history", + pluginStorage: { runs }, + storage: (deps) => makeExecutionHistoryStore(deps), + extension: (ctx) => ({ + list: ctx.storage.list, + get: ctx.storage.get, + handleEvent: ctx.storage.handleEvent, + }), + runtime: { + executionObserver: (self) => makeExecutionHistoryObserver(self), + }, +})); diff --git a/packages/plugins/execution-history/src/sdk/store-list.test.ts b/packages/plugins/execution-history/src/sdk/store-list.test.ts new file mode 100644 index 0000000000..e0856f8a9a --- /dev/null +++ b/packages/plugins/execution-history/src/sdk/store-list.test.ts @@ -0,0 +1,232 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Effect } from "effect"; + +import { + ExecutionFinished, + ExecutionId, + ExecutionStarted, + Subject, + Tenant, +} from "@executor-js/sdk"; +import type { ExecutionEvent, StorageFailure } from "@executor-js/sdk/core"; +import { makeTestExecutor } from "@executor-js/sdk/testing"; + +import type { RunStatus } from "./collections"; +import { executionHistoryPlugin } from "./plugin"; + +const owner = { tenant: Tenant.make("tenant_test"), subject: Subject.make("subject_test") }; + +interface SeedRun { + readonly id: string; + readonly status: "completed" | "failed"; + readonly trigger: string; + readonly startedAt: number; + readonly durationMs: number; +} + +/** Only the writer surface seeding needs — the tests call `list` on the full + * inferred executor directly. */ +interface HistoryWriter { + readonly executionHistory: { + readonly handleEvent: (event: ExecutionEvent) => Effect.Effect; + }; +} + +const makeExecutor = () => + makeTestExecutor({ backend: "sqlite", plugins: [executionHistoryPlugin()] as const }); + +const seedRun = (executor: HistoryWriter, run: SeedRun) => + Effect.gen(function* () { + const executionId = ExecutionId.make(run.id); + yield* executor.executionHistory.handleEvent( + new ExecutionStarted({ + executionId, + owner, + code: "noop", + trigger: { kind: run.trigger }, + startedAt: new Date(run.startedAt), + }), + ); + yield* executor.executionHistory.handleEvent( + new ExecutionFinished({ + executionId, + owner, + status: run.status, + result: { ok: true }, + logs: [], + completedAt: new Date(run.startedAt + run.durationMs), + }), + ); + }); + +// Five completed (cli) + two failed (http), startedAt 1000..7000. +const seedRows: readonly SeedRun[] = [ + { id: "r1", status: "completed", trigger: "cli", startedAt: 1000, durationMs: 100 }, + { id: "r2", status: "completed", trigger: "cli", startedAt: 2000, durationMs: 200 }, + { id: "r3", status: "completed", trigger: "cli", startedAt: 3000, durationMs: 300 }, + { id: "r4", status: "completed", trigger: "cli", startedAt: 4000, durationMs: 400 }, + { id: "r5", status: "completed", trigger: "cli", startedAt: 5000, durationMs: 500 }, + { id: "r6", status: "failed", trigger: "http", startedAt: 6000, durationMs: 600 }, + { id: "r7", status: "failed", trigger: "http", startedAt: 7000, durationMs: 700 }, +]; + +const statusMap = (counts: readonly { status: RunStatus; count: number }[]) => + Object.fromEntries(counts.map((entry) => [entry.status, entry.count])); + +describe("execution-history list — meta, facets, keyset", () => { + it.effect("returns facet counts and a stacked timeline on the first page", () => + Effect.gen(function* () { + const executor = yield* makeExecutor(); + yield* Effect.forEach(seedRows, (row) => seedRun(executor, row), { discard: true }); + + const page = yield* executor.executionHistory.list({ limit: 3 }); + expect(page.runs.map((run) => run.executionId)).toEqual(["r7", "r6", "r5"]); + expect(page.nextCursor).not.toBeNull(); + + const meta = page.meta; + expect(meta?.totalRowCount).toBe(7); + expect(meta?.filterRowCount).toBe(7); + expect(statusMap(meta?.statusCounts ?? [])).toEqual({ completed: 5, failed: 2 }); + expect( + Object.fromEntries((meta?.triggerCounts ?? []).map((t) => [t.triggerKind, t.count])), + ).toEqual({ cli: 5, http: 2 }); + expect(meta?.durationStats.count).toBe(7); + expect(meta?.durationStats.min).toBe(100); + expect(meta?.durationStats.max).toBe(700); + expect(meta?.durationStats.p50).toBe(400); + // Stacked timeline: each bucket carries per-status counts. + const totalCharted = (meta?.chartData ?? []).reduce( + (sum, bucket) => sum + Object.values(bucket.counts).reduce((a, b) => a + b, 0), + 0, + ); + expect(totalCharted).toBe(7); + }), + ); + + it.effect("keyset-paginates forward with the cursor", () => + Effect.gen(function* () { + const executor = yield* makeExecutor(); + yield* Effect.forEach(seedRows, (row) => seedRun(executor, row), { discard: true }); + + const page1 = yield* executor.executionHistory.list({ limit: 3 }); + expect(page1.runs.map((r) => r.executionId)).toEqual(["r7", "r6", "r5"]); + + const page2 = yield* executor.executionHistory.list({ + limit: 3, + cursor: page1.nextCursor ?? undefined, + }); + expect(page2.runs.map((r) => r.executionId)).toEqual(["r4", "r3", "r2"]); + // Meta is computed only on the first page. + expect(page2.meta).toBeNull(); + + const page3 = yield* executor.executionHistory.list({ + limit: 3, + cursor: page2.nextCursor ?? undefined, + }); + expect(page3.runs.map((r) => r.executionId)).toEqual(["r1"]); + expect(page3.nextCursor).toBeNull(); + }), + ); + + it.effect("status facet ignores the status filter; filterRowCount honors it", () => + Effect.gen(function* () { + const executor = yield* makeExecutor(); + yield* Effect.forEach(seedRows, (row) => seedRun(executor, row), { discard: true }); + + const page = yield* executor.executionHistory.list({ + limit: 10, + statusFilter: ["failed"], + }); + expect(page.runs.map((r) => r.executionId)).toEqual(["r7", "r6"]); + expect(page.meta?.filterRowCount).toBe(2); + // The status facet still shows every option so the rail stays usable. + expect(statusMap(page.meta?.statusCounts ?? [])).toEqual({ completed: 5, failed: 2 }); + }), + ); + + it.effect("the live `after` floor returns only newer runs and skips meta", () => + Effect.gen(function* () { + const executor = yield* makeExecutor(); + yield* Effect.forEach(seedRows, (row) => seedRun(executor, row), { discard: true }); + + const page = yield* executor.executionHistory.list({ limit: 10, after: 5000 }); + expect(page.runs.map((r) => r.executionId)).toEqual(["r7", "r6"]); + expect(page.meta).toBeNull(); + }), + ); + + it.effect("attributes runs to their actor: facet (label+kind) + filter", () => + Effect.gen(function* () { + const executor = yield* makeExecutor(); + const seedActor = ( + id: string, + startedAt: number, + actor: { kind: string; id: string; label: string | null }, + ) => + Effect.gen(function* () { + const executionId = ExecutionId.make(id); + yield* executor.executionHistory.handleEvent( + new ExecutionStarted({ + executionId, + owner, + code: "noop", + trigger: { kind: "mcp", actor }, + startedAt: new Date(startedAt), + }), + ); + yield* executor.executionHistory.handleEvent( + new ExecutionFinished({ + executionId, + owner, + status: "completed", + result: { ok: true }, + logs: [], + completedAt: new Date(startedAt + 100), + }), + ); + }); + + const token = { kind: "service-token", id: "tok_abc.access", label: "executor-phoenix" }; + const user = { kind: "user", id: "sub_123", label: "saatvik@example.com" }; + yield* seedActor("a1", 1000, token); + yield* seedActor("a2", 2000, token); + yield* seedActor("a3", 3000, user); + + // The actor facet keys on the stable `actorId` and renders the most-recent + // run's label + kind snapshot. + const page = yield* executor.executionHistory.list({ limit: 10 }); + expect( + Object.fromEntries((page.meta?.actorCounts ?? []).map((entry) => [entry.actorId, entry])), + ).toEqual({ + "tok_abc.access": { + actorId: "tok_abc.access", + actorLabel: "executor-phoenix", + actorKind: "service-token", + count: 2, + }, + sub_123: { + actorId: "sub_123", + actorLabel: "saatvik@example.com", + actorKind: "user", + count: 1, + }, + }); + + // Filtering by the token returns only its runs; the facet omits its own + // filter so every actor stays visible in the rail. + const filtered = yield* executor.executionHistory.list({ + limit: 10, + actorFilter: ["tok_abc.access"], + }); + expect(filtered.runs.map((r) => r.executionId)).toEqual(["a2", "a1"]); + expect(filtered.meta?.filterRowCount).toBe(2); + expect(filtered.meta?.actorCounts).toHaveLength(2); + + // The persisted run carries the actor snapshot for the column/detail. + const run = filtered.runs[0]; + expect(run?.actorId).toBe("tok_abc.access"); + expect(run?.actorLabel).toBe("executor-phoenix"); + expect(run?.actorKind).toBe("service-token"); + }), + ); +}); diff --git a/packages/plugins/execution-history/src/sdk/store.test.ts b/packages/plugins/execution-history/src/sdk/store.test.ts new file mode 100644 index 0000000000..95e28a397b --- /dev/null +++ b/packages/plugins/execution-history/src/sdk/store.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Effect } from "effect"; + +import { + ExecutionFinished, + ExecutionId, + ExecutionStarted, + ExecutionToolCallId, + Subject, + Tenant, + ToolCallFinished, + ToolCallStarted, +} from "@executor-js/sdk"; +import { makeTestExecutor } from "@executor-js/sdk/testing"; + +import { executionHistoryPlugin } from "./plugin"; + +const owner = { tenant: Tenant.make("tenant_test"), subject: Subject.make("subject_test") }; + +describe("execution-history store", () => { + it.effect("records a completed run with one tool call from the event stream", () => + Effect.gen(function* () { + const executor = yield* makeTestExecutor({ + backend: "sqlite", + plugins: [executionHistoryPlugin()] as const, + }); + + const executionId = ExecutionId.make("exec_1"); + const toolCallId = ExecutionToolCallId.make("call_1"); + const startedAt = new Date("2026-05-29T10:00:00.000Z"); + const toolFinishedAt = new Date("2026-05-29T10:00:01.000Z"); + const completedAt = new Date("2026-05-29T10:00:02.000Z"); + + yield* executor.executionHistory.handleEvent( + new ExecutionStarted({ + executionId, + owner, + code: "await tools.shell({ command: 'ls' })", + trigger: { kind: "manual" }, + startedAt, + }), + ); + yield* executor.executionHistory.handleEvent( + new ToolCallStarted({ + executionId, + toolCallId, + owner, + path: "tools.shell.org.default.run", + args: { command: "ls" }, + startedAt, + }), + ); + yield* executor.executionHistory.handleEvent( + new ToolCallFinished({ + executionId, + toolCallId, + owner, + path: "tools.shell.org.default.run", + status: "completed", + result: { stdout: "a.txt" }, + completedAt: toolFinishedAt, + }), + ); + yield* executor.executionHistory.handleEvent( + new ExecutionFinished({ + executionId, + owner, + status: "completed", + result: { ok: true }, + logs: ["ran ls"], + completedAt, + }), + ); + + const listed = yield* executor.executionHistory.list({ limit: 50 }); + expect(listed.meta?.totalRowCount).toBe(1); + expect(listed.meta?.filterRowCount).toBe(1); + expect(listed.meta?.statusCounts).toContainEqual({ status: "completed", count: 1 }); + expect(listed.nextCursor).toBeNull(); + const run = listed.runs[0]; + expect(run?.executionId).toBe("exec_1"); + expect(run?.status).toBe("completed"); + expect(run?.toolCallCount).toBe(1); + expect(run?.durationMs).toBe(2000); + expect(run?.hadInteraction).toBe(false); + // codePreview + trigger from ExecutionStarted survive the terminal re-write. + expect(run?.codePreview).toBe("await tools.shell({ command: 'ls' })"); + expect(run?.triggerKind).toBe("manual"); + + const detail = yield* executor.executionHistory.get("exec_1"); + expect(detail?.run.status).toBe("completed"); + // Full code + the tool call now come from the R2 detail object. + expect(detail?.code).toBe("await tools.shell({ command: 'ls' })"); + expect(detail?.toolCalls).toHaveLength(1); + expect(detail?.toolCalls[0]?.toolCallId).toBe("call_1"); + expect(detail?.toolCalls[0]?.status).toBe("completed"); + expect(detail?.toolCalls[0]?.durationMs).toBe(1000); + expect(detail?.toolCalls[0]?.path).toBe("tools.shell.org.default.run"); + expect(detail?.interactions).toHaveLength(0); + }), + ); +}); diff --git a/packages/plugins/execution-history/src/sdk/store.ts b/packages/plugins/execution-history/src/sdk/store.ts new file mode 100644 index 0000000000..a00513f686 --- /dev/null +++ b/packages/plugins/execution-history/src/sdk/store.ts @@ -0,0 +1,847 @@ +import { Effect, Option, Predicate, Schema } from "effect"; + +import { + type ExecutionEvent, + type ExecutionInteractionId, + type ExecutionObserver, + type ExecutionToolCallId, + type Owner, + type OwnerBinding, + type PluginStorageCollectionFacade, + type PluginStorageFacade, + type StorageDeps, + type StorageFailure, + StorageError, +} from "@executor-js/sdk/core"; + +import { + type InteractionRow, + type InteractionStatus, + type RunRow, + type ToolCallRow, + type ToolCallStatus, + RunStatus, + runs, +} from "./collections"; +import { type RunDetail, RunDetailFromJsonString } from "./detail-types"; + +// --------------------------------------------------------------------------- +// Execution-history store. Translates the engine's ExecutionEvent stream into a +// slim D1 `runs` index row + an append-only R2 detail object, and exposes the +// read surface. +// +// Storage split: +// - D1 `runs` row (via pluginStorage): slim, indexed, list-renderable — status, +// trigger, actor, timing, counts, plus `codePreview` and `logErrorCount`/ +// `logWarnCount` so the list never fetches a blob. +// - R2 detail object (via `deps.blobs`, keyed `run-detail/`): the +// bulky drawer-only payload — full code, result/error/logs/trigger-metadata, +// and the per-tool-call / per-interaction rows. Written once. +// +// Write model — buffered batch: tool-call and interaction detail is held in an +// in-memory buffer keyed by executionId and flushed into the R2 detail object +// when the execution finishes. Eager writes for durability: on ExecutionStarted +// the slim "running" row AND a code-only stub detail blob (so a live/paused run +// shows its code immediately and survives a restart); on InteractionStarted the +// "waiting_for_interaction" row. +// +// Every `unknown` payload is serialized to a JSON string via Effect Schema +// (`Schema.UnknownFromJsonString`); the detail object round-trips via +// `Schema.fromJsonString` — no raw `JSON.stringify`/`JSON.parse` in domain code. +// --------------------------------------------------------------------------- + +/** Serialize an arbitrary value to a JSON string, or null when absent or when + * the value isn't JSON-encodable (encoding never throws). */ +const encodeUnknownJson = Schema.encodeUnknownOption(Schema.UnknownFromJsonString); + +const toJson = (value: unknown): string | null => + value === undefined ? null : Option.getOrNull(encodeUnknownJson(value)); + +const ownerOf = (binding: OwnerBinding): Owner => (binding.subject != null ? "user" : "org"); + +/** Hoisted: `Schema.is` compiles a guard, so it must not be rebuilt per row. */ +const isRunStatus = Schema.is(RunStatus); + +/** First dot-delimited segment of a tool path (its namespace), or null. */ +const namespaceOf = (path: string): string | null => { + const index = path.indexOf("."); + return index > 0 ? path.slice(0, index) : null; +}; + +/** Bounded, whitespace-normalized snippet of the code for the list column. The + * full code lives in the R2 detail object. */ +const codePreviewOf = (code: string): string => code.trim().replace(/\s+/g, " ").slice(0, 256); + +/** Count `[error]` / `[warn]` log lines (case-insensitive substring, counted + * independently — matches the list's prior logsJson parsing) so the list reads + * precomputed scalars instead of fetching + parsing the full logs from R2. */ +const logCountsOf = (logs: unknown): { errors: number; warns: number } => { + if (!Array.isArray(logs)) return { errors: 0, warns: 0 }; + let errors = 0; + let warns = 0; + for (const line of logs) { + if (typeof line !== "string") continue; + const lower = line.toLowerCase(); + if (lower.includes("[error]")) errors += 1; + if (lower.includes("[warn]")) warns += 1; + } + return { errors, warns }; +}; + +/** R2 blob key for a run's detail object. */ +const detailBlobKey = (executionId: string): string => `run-detail/${executionId}`; + +/** Detail codec helpers (Schema-based; never raw JSON). Encode runs in the + * Effect channel so a (never-in-practice) failure maps to a typed StorageError + * rather than a `die`; decode reads gracefully as an `Option`. */ +const encodeDetail = Schema.encodeUnknownEffect(RunDetailFromJsonString); +const decodeDetail = Schema.decodeUnknownOption(RunDetailFromJsonString); + +interface BufferedToolCall { + toolCallId: ExecutionToolCallId; + status: ToolCallStatus; + path: string; + namespace: string | null; + argsJson: string | null; + resultJson: string | null; + errorText: string | null; + startedAt: number; + completedAt: number | null; + durationMs: number | null; +} + +interface BufferedInteraction { + interactionId: ExecutionInteractionId; + status: InteractionStatus; + kind: string; + purpose: string | null; + payloadJson: string | null; + responseJson: string | null; + errorText: string | null; + startedAt: number; + completedAt: number | null; +} + +interface RunBuffer { + owner: Owner; + startedAt: number; + // Retained from ExecutionStarted so every re-write of the run row (waiting, + // terminal) keeps the code + trigger + actor — later events don't carry them. + code: string; + triggerKind: string | null; + triggerMetaJson: string | null; + actorId: string | null; + actorLabel: string | null; + actorKind: string | null; + hadInteraction: boolean; + toolCalls: Map; + interactions: Map; +} + +// --------------------------------------------------------------------------- +// Read-surface option/result types. +// +// The list surface is keyset-paginated and carries an aggregate `meta` block +// (facet counts, a stacked-by-status timeline, and duration percentiles) that +// the runs UI renders. All of it is pushed down to SQL through the plugin +// storage `aggregate`/`queryKeyset` facade — no whole-collection scans in JS. +// `meta` is computed only on the initial page (no cursor, no live `after`), +// matching how the UI fetches it once per filter set. +// --------------------------------------------------------------------------- + +export type RunsSortField = "startedAt" | "durationMs"; + +/** Opaque-to-the-client keyset cursor: the sort value + storage key of the last + * row on a page. The HTTP layer encodes/decodes it as a string. */ +export interface RunsCursor { + readonly sort: number | null; + readonly key: string; +} + +export interface ExecutionHistoryListOptions { + readonly statusFilter?: readonly RunStatus[]; + readonly triggerFilter?: readonly string[]; + readonly actorFilter?: readonly string[]; + readonly timeRange?: { readonly from?: number; readonly to?: number }; + readonly hadInteraction?: boolean; + /** Live-tail floor: only runs whose `startedAt` is strictly greater. */ + readonly after?: number; + readonly sortField?: RunsSortField; + readonly sortDirection?: "asc" | "desc"; + readonly limit: number; + readonly cursor?: RunsCursor; +} + +export interface RunStatusCount { + readonly status: RunStatus; + readonly count: number; +} + +export interface RunTriggerCount { + readonly triggerKind: string | null; + readonly count: number; +} + +export interface RunActorCount { + readonly actorId: string | null; + /** A display label for the actor (the most-recent run's snapshot), or null. */ + readonly actorLabel: string | null; + readonly actorKind: string | null; + readonly count: number; +} + +export interface RunInteractionCounts { + readonly withInteraction: number; + readonly withoutInteraction: number; +} + +export interface RunChartBucket { + readonly bucketStart: number; + /** Status -> run count for the bucket; absent statuses are omitted. */ + readonly counts: Readonly>; +} + +export interface RunDurationStats { + readonly count: number; + readonly min: number | null; + readonly max: number | null; + readonly p50: number | null; + readonly p75: number | null; + readonly p90: number | null; + readonly p95: number | null; + readonly p99: number | null; +} + +export interface ExecutionListMeta { + readonly totalRowCount: number; + readonly filterRowCount: number; + readonly statusCounts: readonly RunStatusCount[]; + readonly triggerCounts: readonly RunTriggerCount[]; + readonly actorCounts: readonly RunActorCount[]; + readonly interactionCounts: RunInteractionCounts; + readonly chartBucketMs: number; + readonly chartData: readonly RunChartBucket[]; + readonly durationStats: RunDurationStats; +} + +export interface ExecutionHistoryListResult { + readonly runs: readonly RunRow[]; + readonly nextCursor: RunsCursor | null; + readonly meta: ExecutionListMeta | null; +} + +export interface ExecutionHistoryDetail { + readonly run: RunRow; + /** Bulky drawer payload, sourced from the R2 detail object (flat-merged onto + * the slim run row). For in-flight or detail-less runs these are the + * stub/empty values. */ + readonly code: string; + readonly resultJson: string | null; + readonly errorText: string | null; + readonly logsJson: string | null; + readonly triggerMetaJson: string | null; + readonly toolCalls: readonly ToolCallRow[]; + readonly interactions: readonly InteractionRow[]; +} + +export interface ExecutionHistoryStore { + readonly handleEvent: (event: ExecutionEvent) => Effect.Effect; + readonly list: ( + options: ExecutionHistoryListOptions, + ) => Effect.Effect; + readonly get: ( + executionId: string, + ) => Effect.Effect; +} + +// --------------------------------------------------------------------------- +// List helpers (pure). +// --------------------------------------------------------------------------- + +type RunsWhere = { + status?: { in: readonly RunStatus[] }; + triggerKind?: { in: readonly string[] }; + actorId?: { in: readonly string[] }; + startedAt?: { gte?: number; lte?: number; gt?: number }; + hadInteraction?: { eq: boolean }; +}; + +/** Build the indexed-field `where` from list options. `omit` drops one facet's + * own field so a facet count reflects the *other* filters (a faceted rail still + * shows every option for the field you're filtering on). */ +const buildRunsWhere = ( + options: ExecutionHistoryListOptions, + omit?: "status" | "triggerKind" | "actorId" | "hadInteraction", +): RunsWhere => { + const where: RunsWhere = {}; + if (omit !== "status" && options.statusFilter && options.statusFilter.length > 0) { + where.status = { in: options.statusFilter }; + } + if (omit !== "triggerKind" && options.triggerFilter && options.triggerFilter.length > 0) { + where.triggerKind = { in: options.triggerFilter }; + } + if (omit !== "actorId" && options.actorFilter && options.actorFilter.length > 0) { + where.actorId = { in: options.actorFilter }; + } + const startedAt: { gte?: number; lte?: number; gt?: number } = {}; + if (options.timeRange?.from != null) startedAt.gte = options.timeRange.from; + if (options.timeRange?.to != null) startedAt.lte = options.timeRange.to; + if (options.after != null) startedAt.gt = options.after; + if (Object.keys(startedAt).length > 0) where.startedAt = startedAt; + if (omit !== "hadInteraction" && options.hadInteraction != null) { + where.hadInteraction = { eq: options.hadInteraction }; + } + return where; +}; + +const HOUR_MS = 3_600_000; +const BUCKET_STEPS_MS = [ + 60_000, + 5 * 60_000, + 15 * 60_000, + HOUR_MS, + 6 * HOUR_MS, + 24 * HOUR_MS, + 7 * 24 * HOUR_MS, +] as const; + +/** Pick a timeline bucket width (~48 buckets across the requested range). */ +const chooseBucketMs = (timeRange: { from?: number; to?: number } | undefined): number => { + if (timeRange?.from == null || timeRange.to == null) return HOUR_MS; + const target = Math.max(1, timeRange.to - timeRange.from) / 48; + return ( + BUCKET_STEPS_MS.find((step) => step >= target) ?? BUCKET_STEPS_MS[BUCKET_STEPS_MS.length - 1]! + ); +}; + +export const makeExecutionHistoryStore = (deps: StorageDeps): ExecutionHistoryStore => { + const pluginStorage: PluginStorageFacade = deps.pluginStorage; + const runsC: PluginStorageCollectionFacade = pluginStorage.collection(runs); + const blobs = deps.blobs; + + const buffers = new Map(); + + const putRun = (owner: Owner, row: RunRow): Effect.Effect => + runsC.put({ owner, key: row.executionId, data: row }).pipe(Effect.asVoid); + + /** Write the run's R2 detail object. A (never-in-practice) encode failure maps + * to a typed StorageError so it stays in the storage error channel. */ + const putDetail = ( + owner: Owner, + executionId: string, + detail: RunDetail, + ): Effect.Effect => + encodeDetail(detail).pipe( + Effect.mapError( + (cause) => + new StorageError({ message: "execution-history: failed to encode run detail", cause }), + ), + Effect.flatMap((json) => blobs.put(detailBlobKey(executionId), json, { owner })), + ); + + /** Read + decode the run's R2 detail object; `None` when absent or malformed. */ + const readDetail = ( + executionId: string, + ): Effect.Effect, StorageFailure> => + blobs + .get(detailBlobKey(executionId)) + .pipe(Effect.map((raw) => (raw === null ? Option.none() : decodeDetail(raw)))); + + const onExecutionStarted = (event: Extract) => { + const owner = ownerOf(event.owner); + const startedAt = event.startedAt.getTime(); + const triggerKind = event.trigger?.kind ?? null; + const triggerMetaJson = toJson(event.trigger?.metadata); + const actor = event.trigger?.actor; + const actorId = actor?.id ?? null; + const actorLabel = actor?.label ?? null; + const actorKind = actor?.kind ?? null; + buffers.set(event.executionId, { + owner, + startedAt, + code: event.code, + triggerKind, + triggerMetaJson, + actorId, + actorLabel, + actorKind, + hadInteraction: false, + toolCalls: new Map(), + interactions: new Map(), + }); + return Effect.all( + [ + putRun(owner, { + executionId: event.executionId, + status: "running", + codePreview: codePreviewOf(event.code), + triggerKind, + logErrorCount: 0, + logWarnCount: 0, + actorId, + actorLabel, + actorKind, + startedAt, + completedAt: null, + durationMs: null, + toolCallCount: 0, + hadInteraction: false, + }), + // Eager code-only stub: the drawer shows code while the run is live, and + // the code survives a restart that loses the in-memory buffer. + putDetail(owner, event.executionId, { + code: event.code, + resultJson: null, + errorText: null, + logsJson: null, + triggerMetaJson, + toolCalls: [], + interactions: [], + }), + ], + { concurrency: "unbounded", discard: true }, + ); + }; + + const onToolCallStarted = (event: Extract) => { + const buffer = buffers.get(event.executionId); + if (buffer) { + buffer.toolCalls.set(event.toolCallId, { + toolCallId: event.toolCallId, + status: "running", + path: event.path, + namespace: namespaceOf(event.path), + argsJson: toJson(event.args), + resultJson: null, + errorText: null, + startedAt: event.startedAt.getTime(), + completedAt: null, + durationMs: null, + }); + } + return Effect.void; + }; + + const onToolCallFinished = (event: Extract) => { + const buffer = buffers.get(event.executionId); + if (buffer) { + const completedAt = event.completedAt.getTime(); + const existing = buffer.toolCalls.get(event.toolCallId); + const startedAt = existing?.startedAt ?? completedAt; + buffer.toolCalls.set(event.toolCallId, { + toolCallId: event.toolCallId, + status: event.status, + path: event.path, + namespace: existing?.namespace ?? namespaceOf(event.path), + argsJson: existing?.argsJson ?? null, + resultJson: toJson(event.result), + errorText: event.error ?? null, + startedAt, + completedAt, + durationMs: completedAt - startedAt, + }); + } + return Effect.void; + }; + + const onInteractionStarted = (event: Extract) => { + const buffer = buffers.get(event.executionId); + if (!buffer) return Effect.void; + const request = event.context.request; + const kind = Predicate.isTagged(request, "UrlElicitation") + ? "UrlElicitation" + : "FormElicitation"; + buffer.interactions.set(event.interactionId, { + interactionId: event.interactionId, + status: "pending", + kind, + purpose: request.message, + payloadJson: toJson(event.context), + responseJson: null, + errorText: null, + startedAt: event.startedAt.getTime(), + completedAt: null, + }); + buffer.hadInteraction = true; + return putRun(buffer.owner, { + executionId: event.executionId, + status: "waiting_for_interaction", + codePreview: codePreviewOf(buffer.code), + triggerKind: buffer.triggerKind, + logErrorCount: 0, + logWarnCount: 0, + actorId: buffer.actorId, + actorLabel: buffer.actorLabel, + actorKind: buffer.actorKind, + startedAt: buffer.startedAt, + completedAt: null, + durationMs: null, + toolCallCount: buffer.toolCalls.size, + hadInteraction: true, + }); + }; + + const onInteractionResolved = ( + event: Extract, + ) => { + const buffer = buffers.get(event.executionId); + if (buffer) { + const existing = buffer.interactions.get(event.interactionId); + buffer.interactions.set(event.interactionId, { + interactionId: event.interactionId, + status: event.status, + kind: existing?.kind ?? "unknown", + purpose: existing?.purpose ?? null, + payloadJson: existing?.payloadJson ?? null, + responseJson: toJson(event.response), + errorText: event.error ?? null, + startedAt: existing?.startedAt ?? event.completedAt.getTime(), + completedAt: event.completedAt.getTime(), + }); + } + return Effect.void; + }; + + const onExecutionFinished = (event: Extract) => { + const buffer = buffers.get(event.executionId); + const owner = buffer?.owner ?? ownerOf(event.owner); + const completedAt = event.completedAt.getTime(); + const toolCallEntries = buffer ? Array.from(buffer.toolCalls.values()) : []; + const interactionEntries = buffer ? Array.from(buffer.interactions.values()) : []; + const { errors: logErrorCount, warns: logWarnCount } = logCountsOf(event.logs); + + return Effect.gen(function* () { + // Recover slim fields from the persisted "running" row if the buffer was + // lost (restart mid-run). Full code/trigger-metadata live only in the + // buffer + the start-stub blob. + const existing = buffer ? null : yield* runsC.get({ key: event.executionId }); + const triggerKind = buffer?.triggerKind ?? existing?.data.triggerKind ?? null; + const actorId = buffer?.actorId ?? existing?.data.actorId ?? null; + const actorLabel = buffer?.actorLabel ?? existing?.data.actorLabel ?? null; + const actorKind = buffer?.actorKind ?? existing?.data.actorKind ?? null; + const startedAt = buffer?.startedAt ?? existing?.data.startedAt ?? completedAt; + const codePreview = buffer ? codePreviewOf(buffer.code) : (existing?.data.codePreview ?? ""); + const hadInteraction = + buffer?.hadInteraction ?? (existing?.data.hadInteraction || interactionEntries.length > 0); + + yield* putRun(owner, { + executionId: event.executionId, + status: event.status, + codePreview, + triggerKind, + logErrorCount, + logWarnCount, + actorId, + actorLabel, + actorKind, + startedAt, + completedAt, + durationMs: completedAt - startedAt, + toolCallCount: toolCallEntries.length, + hadInteraction, + }); + + // Write the full detail object. Normal path: assembled from the buffer. + // Buffer-lost path: preserve the start-stub's code + trigger-metadata and + // attach the terminal result/error/logs (per-call children are + // unrecoverable after a restart). + const detail: RunDetail = buffer + ? { + code: buffer.code, + resultJson: toJson(event.result), + errorText: event.error ?? null, + logsJson: toJson(event.logs), + triggerMetaJson: buffer.triggerMetaJson, + toolCalls: toolCallEntries.map((entry) => ({ + executionId: event.executionId, + toolCallId: entry.toolCallId, + status: entry.status, + path: entry.path, + namespace: entry.namespace, + argsJson: entry.argsJson, + resultJson: entry.resultJson, + errorText: entry.errorText, + startedAt: entry.startedAt, + completedAt: entry.completedAt, + durationMs: entry.durationMs, + })), + interactions: interactionEntries.map((entry) => ({ + executionId: event.executionId, + interactionId: entry.interactionId, + status: entry.status, + kind: entry.kind, + purpose: entry.purpose, + payloadJson: entry.payloadJson, + responseJson: entry.responseJson, + errorText: entry.errorText, + startedAt: entry.startedAt, + completedAt: entry.completedAt, + })), + } + : yield* readDetail(event.executionId).pipe( + Effect.map((stubOpt) => { + const stub = Option.getOrNull(stubOpt); + return { + code: stub?.code ?? "", + resultJson: toJson(event.result), + errorText: event.error ?? null, + logsJson: toJson(event.logs), + triggerMetaJson: stub?.triggerMetaJson ?? null, + toolCalls: [], + interactions: [], + }; + }), + ); + + yield* putDetail(owner, event.executionId, detail); + }).pipe( + // Always release the buffer, even if a write fails or is interrupted — + // otherwise a StorageFailure during the flush leaks the RunBuffer forever. + Effect.ensuring(Effect.sync(() => buffers.delete(event.executionId))), + ); + }; + + const handleEvent = (event: ExecutionEvent): Effect.Effect => { + if (Predicate.isTagged(event, "ExecutionStarted")) return onExecutionStarted(event); + if (Predicate.isTagged(event, "ToolCallStarted")) return onToolCallStarted(event); + if (Predicate.isTagged(event, "ToolCallFinished")) return onToolCallFinished(event); + if (Predicate.isTagged(event, "InteractionStarted")) return onInteractionStarted(event); + if (Predicate.isTagged(event, "InteractionResolved")) return onInteractionResolved(event); + // Explicit guard, not a fallthrough: a future ExecutionEvent variant must + // not be silently recorded as a finished run. + if (Predicate.isTagged(event, "ExecutionFinished")) return onExecutionFinished(event); + return Effect.void; + }; + + const computeMeta = ( + options: ExecutionHistoryListOptions, + ): Effect.Effect => + Effect.gen(function* () { + const where = buildRunsWhere(options); + + // Wave 1: all independent aggregates concurrently. + const [ + totalRowCount, + filterRowCount, + statusGroups, + triggerGroups, + actorGroups, + interactionGroups, + stats, + startedAtStats, + ] = yield* Effect.all( + [ + runsC.aggregate.count(), + runsC.aggregate.count({ where }), + runsC.aggregate.groupCount({ + field: "status", + where: buildRunsWhere(options, "status"), + }), + runsC.aggregate.groupCount({ + field: "triggerKind", + where: buildRunsWhere(options, "triggerKind"), + }), + runsC.aggregate.groupCount({ + field: "actorId", + where: buildRunsWhere(options, "actorId"), + }), + runsC.aggregate.groupCount({ + field: "hadInteraction", + where: buildRunsWhere(options, "hadInteraction"), + valueType: "boolean", + }), + runsC.aggregate.stats({ + field: "durationMs", + where, + percentiles: [0.5, 0.75, 0.9, 0.95, 0.99], + }), + runsC.aggregate.stats({ + field: "startedAt", + where, + percentiles: [], + }), + ] as const, + { concurrency: "unbounded" }, + ); + + const statusCounts: RunStatusCount[] = []; + for (const group of statusGroups) { + if (isRunStatus(group.value)) { + statusCounts.push({ status: group.value, count: group.count }); + } + } + + // Wave 2: stacked-by-status timeline — one bucketed count per present + // status, merged by bucket start. Respects every filter except `status` + // itself. Per-status queries are independent so run concurrently. + // + // When no explicit time range is set, derive the bucketing span from the + // data's actual startedAt min/max so the bucket count stays ~48 regardless + // of dataset age. Falls back to chooseBucketMs default only when the + // result set is empty (startedAtStats.min/max are null). + const effectiveBucketRange: { from?: number; to?: number } = + options.timeRange?.from != null && options.timeRange.to != null + ? options.timeRange + : { + from: startedAtStats.min ?? undefined, + to: startedAtStats.max ?? undefined, + }; + const bucketMs = chooseBucketMs(effectiveBucketRange); + const chartWhere = buildRunsWhere(options, "status"); + const perStatusBuckets = yield* Effect.forEach( + statusCounts, + (entry) => + runsC.aggregate + .timeBuckets({ + field: "startedAt", + bucketMs, + where: { ...chartWhere, status: { in: [entry.status] } }, + }) + .pipe(Effect.map((buckets) => ({ status: entry.status, buckets }))), + { concurrency: "unbounded" }, + ); + const chartByBucket = new Map>(); + for (const { status, buckets } of perStatusBuckets) { + for (const bucket of buckets) { + const counts = chartByBucket.get(bucket.bucket) ?? {}; + counts[status] = bucket.count; + chartByBucket.set(bucket.bucket, counts); + } + } + const chartData: RunChartBucket[] = [...chartByBucket.entries()] + .sort((left, right) => left[0] - right[0]) + .map(([bucketStart, counts]) => ({ bucketStart, counts })); + + const triggerCounts: RunTriggerCount[] = triggerGroups.map((group) => ({ + triggerKind: typeof group.value === "string" ? group.value : null, + count: group.count, + })); + + // Resolve a display label + kind per actor. The facet keys on the STABLE + // `actorId`, but renders the human snapshot (`actorLabel`/`actorKind`) from + // that actor's most-recent run WITHIN the current filter window — so the + // label stays consistent with the (filtered) count shown beside it, rather + // than a globally-newest snapshot that could predate the window. One query + // per distinct actor; the null-actor group needs no lookup. + const actorFacetWhere = buildRunsWhere(options, "actorId"); + const actorIds = actorGroups + .map((group) => (typeof group.value === "string" ? group.value : null)) + .filter(Predicate.isNotNull); + const actorMeta = yield* Effect.forEach( + actorIds, + (actorId) => + runsC + .query({ + where: { ...actorFacetWhere, actorId: { in: [actorId] } }, + orderBy: [{ field: "startedAt", direction: "desc" }], + limit: 1, + }) + .pipe( + Effect.map((rows) => ({ + actorId, + label: rows[0]?.data.actorLabel ?? null, + kind: rows[0]?.data.actorKind ?? null, + })), + ), + // Cap the burst: distinct-actor cardinality is normally small, but a + // workspace with many service tokens shouldn't fan out unboundedly. + { concurrency: 10 }, + ); + const actorMetaById = new Map(actorMeta.map((entry) => [entry.actorId, entry])); + const actorCounts: RunActorCount[] = actorGroups.map((group) => { + const actorId = typeof group.value === "string" ? group.value : null; + const resolved = actorId !== null ? actorMetaById.get(actorId) : undefined; + return { + actorId, + actorLabel: resolved?.label ?? null, + actorKind: resolved?.kind ?? null, + count: group.count, + }; + }); + const withInteraction = interactionGroups.find((group) => group.value === true)?.count ?? 0; + const withoutInteraction = + interactionGroups.find((group) => group.value === false)?.count ?? 0; + const percentileAt = (fraction: number): number | null => + stats.percentiles.find((entry) => entry.fraction === fraction)?.value ?? null; + + return { + totalRowCount, + filterRowCount, + statusCounts, + triggerCounts, + actorCounts, + interactionCounts: { withInteraction, withoutInteraction }, + chartBucketMs: bucketMs, + chartData, + durationStats: { + count: stats.count, + min: stats.min, + max: stats.max, + p50: percentileAt(0.5), + p75: percentileAt(0.75), + p90: percentileAt(0.9), + p95: percentileAt(0.95), + p99: percentileAt(0.99), + }, + }; + }); + + const list = ( + options: ExecutionHistoryListOptions, + ): Effect.Effect => + Effect.gen(function* () { + const sortField = options.sortField ?? "startedAt"; + const sortDirection = options.sortDirection ?? "desc"; + const where = buildRunsWhere(options); + const page = yield* runsC.queryKeyset({ + where, + orderBy: [{ field: sortField, direction: sortDirection, valueType: "number" }], + limit: options.limit, + cursor: options.cursor + ? { values: [options.cursor.sort], key: options.cursor.key } + : undefined, + }); + const nextCursor: RunsCursor | null = page.nextCursor + ? { + sort: typeof page.nextCursor.values[0] === "number" ? page.nextCursor.values[0] : null, + key: page.nextCursor.key, + } + : null; + // Meta is computed once per filter set — only for the first page, and + // never during live polling (`after`). + const meta = + options.cursor === undefined && options.after === undefined + ? yield* computeMeta(options) + : null; + return { runs: page.entries.map((entry) => entry.data), nextCursor, meta }; + }); + + const get = (executionId: string): Effect.Effect => + Effect.gen(function* () { + const run = yield* runsC.get({ key: executionId }); + if (run === null) return null; + // Detail comes from the R2 object. Absent/malformed (or an in-flight run + // whose stub is gone) → empty detail so the drawer still renders the row. + const detail = Option.getOrNull(yield* readDetail(executionId)); + return { + run: run.data, + code: detail?.code ?? "", + resultJson: detail?.resultJson ?? null, + errorText: detail?.errorText ?? null, + logsJson: detail?.logsJson ?? null, + triggerMetaJson: detail?.triggerMetaJson ?? null, + toolCalls: detail?.toolCalls ?? [], + interactions: detail?.interactions ?? [], + }; + }); + + return { handleEvent, list, get }; +}; + +/** Build an ExecutionObserver over a store instance — every engine event is + * forwarded to the store's buffered-batch writer. */ +export const makeExecutionHistoryObserver = ( + store: Pick, +): ExecutionObserver => ({ + handle: (event) => store.handleEvent(event), +}); diff --git a/packages/plugins/execution-history/tsconfig.json b/packages/plugins/execution-history/tsconfig.json new file mode 100644 index 0000000000..c495442f10 --- /dev/null +++ b/packages/plugins/execution-history/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "skipLibCheck": true, + "lib": ["ES2022", "DOM"], + "types": ["bun-types", "node"], + "noUnusedLocals": true, + "noImplicitOverride": true, + "jsx": "react-jsx", + "plugins": [ + { + "name": "@effect/language-service", + "ignoreEffectSuggestionsInTscExitCode": true, + "ignoreEffectWarningsInTscExitCode": true, + "diagnosticSeverity": { + "preferSchemaOverJson": "off" + } + } + ] + }, + "include": ["src/**/*.ts", "src/**/*.tsx"] +} diff --git a/packages/plugins/execution-history/tsup.config.ts b/packages/plugins/execution-history/tsup.config.ts new file mode 100644 index 0000000000..800469114b --- /dev/null +++ b/packages/plugins/execution-history/tsup.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from "tsup"; + +export default defineConfig({ + entry: { + index: "src/sdk/index.ts", + }, + format: ["esm"], + dts: false, + sourcemap: true, + clean: true, + external: [/^@executor-js\//, /^effect/, /^@effect\//], +}); diff --git a/packages/plugins/execution-history/vitest.config.ts b/packages/plugins/execution-history/vitest.config.ts new file mode 100644 index 0000000000..5bfa2d586e --- /dev/null +++ b/packages/plugins/execution-history/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["src/**/*.test.ts"], + passWithNoTests: true, + }, +}); diff --git a/packages/plugins/execution-metrics/CHANGELOG.md b/packages/plugins/execution-metrics/CHANGELOG.md new file mode 100644 index 0000000000..3d1d7847f1 --- /dev/null +++ b/packages/plugins/execution-metrics/CHANGELOG.md @@ -0,0 +1 @@ +# @executor-js/plugin-execution-metrics diff --git a/packages/plugins/execution-metrics/package.json b/packages/plugins/execution-metrics/package.json new file mode 100644 index 0000000000..a43bb47362 --- /dev/null +++ b/packages/plugins/execution-metrics/package.json @@ -0,0 +1,38 @@ +{ + "name": "@executor-js/plugin-execution-metrics", + "version": "0.0.0", + "private": true, + "type": "module", + "exports": { + ".": "./src/sdk/index.ts", + "./api": "./src/api/index.ts", + "./cloudflare": "./src/cloudflare/index.ts" + }, + "scripts": { + "build": "tsup && (tsc --declaration --emitDeclarationOnly --outDir dist --rootDir src || true)", + "typecheck": "tsgo --noEmit", + "test": "vitest run", + "test:watch": "vitest" + }, + "dependencies": { + "@executor-js/sdk": "workspace:*", + "effect": "catalog:" + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20250620.0", + "@effect/vitest": "catalog:", + "@executor-js/api": "workspace:*", + "@types/node": "catalog:", + "bun-types": "catalog:", + "tsup": "catalog:", + "vitest": "catalog:" + }, + "peerDependencies": { + "@executor-js/api": "workspace:*" + }, + "peerDependenciesMeta": { + "@executor-js/api": { + "optional": true + } + } +} diff --git a/packages/plugins/execution-metrics/src/api/group.ts b/packages/plugins/execution-metrics/src/api/group.ts new file mode 100644 index 0000000000..da2a259e4a --- /dev/null +++ b/packages/plugins/execution-metrics/src/api/group.ts @@ -0,0 +1,28 @@ +import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "effect/unstable/httpapi"; +import { Schema } from "effect"; +import { InternalError } from "@executor-js/sdk/shared"; + +// --------------------------------------------------------------------------- +// Responses +// +// The scrape endpoint returns Prometheus text-exposition, not JSON. +// `HttpApiSchema.asText()` flips the success body to `text/plain` so the +// renderer's string is written verbatim. +// --------------------------------------------------------------------------- + +const PrometheusResponse = Schema.String.pipe(HttpApiSchema.asText()); + +// --------------------------------------------------------------------------- +// Group — the execution-metrics HTTP surface. +// +// One GET endpoint. Auth-gated automatically by the host's execution-stack +// middleware, like every other plugin route. `InternalError` is the shared +// opaque 500 surface translated at the HTTP edge by `capture`. +// --------------------------------------------------------------------------- + +export const ExecutionMetricsGroup = HttpApiGroup.make("execution-metrics").add( + HttpApiEndpoint.get("metrics", "/execution-metrics/metrics", { + success: PrometheusResponse, + error: InternalError, + }), +); diff --git a/packages/plugins/execution-metrics/src/api/handlers.ts b/packages/plugins/execution-metrics/src/api/handlers.ts new file mode 100644 index 0000000000..60b132f8e3 --- /dev/null +++ b/packages/plugins/execution-metrics/src/api/handlers.ts @@ -0,0 +1,27 @@ +import { HttpApiBuilder } from "effect/unstable/httpapi"; + +import { addGroup } from "@executor-js/api"; +import { ExecutionMetricsGroup } from "./group"; +import { renderPrometheus } from "./prometheus"; + +// --------------------------------------------------------------------------- +// Composed API — core + execution-metrics group +// --------------------------------------------------------------------------- + +const ExecutorApiWithMetrics = addGroup(ExecutionMetricsGroup); + +// --------------------------------------------------------------------------- +// Handlers +// +// The scrape reads the global Effect Metric registry directly (no plugin +// extension state), so there is no `extensionService` to require — the handler +// just renders the snapshot. `renderPrometheus` has no error channel, so no +// `capture` translation is needed; the group's `InternalError` covers defects +// the edge middleware captures. +// --------------------------------------------------------------------------- + +export const ExecutionMetricsHandlers = HttpApiBuilder.group( + ExecutorApiWithMetrics, + "execution-metrics", + (handlers) => handlers.handle("metrics", () => renderPrometheus), +); diff --git a/packages/plugins/execution-metrics/src/api/index.ts b/packages/plugins/execution-metrics/src/api/index.ts new file mode 100644 index 0000000000..298a0d6c30 --- /dev/null +++ b/packages/plugins/execution-metrics/src/api/index.ts @@ -0,0 +1,24 @@ +import { definePlugin } from "@executor-js/sdk/core"; + +import { executionMetricsPlugin, type ExecutionMetricsPluginOptions } from "../sdk/plugin"; +import { ExecutionMetricsGroup } from "./group"; +import { ExecutionMetricsHandlers } from "./handlers"; + +export { ExecutionMetricsGroup } from "./group"; +export { ExecutionMetricsHandlers } from "./handlers"; +export { renderPrometheus } from "./prometheus"; + +// HTTP-augmented variant of `executionMetricsPlugin`. The returned plugin +// carries the HTTP `routes` and `handlers` so a host can mount the Prometheus +// scrape (`GET /execution-metrics/metrics`). The scrape reads the global Effect +// Metric registry, so there is no plugin extension state and no +// `extensionService`. Hosts that compose an `HttpApi` import this; SDK-only +// consumers stay on `@executor-js/plugin-execution-metrics` and never load +// `@executor-js/api`. +export const executionMetricsHttpPlugin = definePlugin( + (options?: ExecutionMetricsPluginOptions) => ({ + ...executionMetricsPlugin(options), + routes: () => ExecutionMetricsGroup, + handlers: () => ExecutionMetricsHandlers, + }), +); diff --git a/packages/plugins/execution-metrics/src/api/prometheus.test.ts b/packages/plugins/execution-metrics/src/api/prometheus.test.ts new file mode 100644 index 0000000000..dee6aea730 --- /dev/null +++ b/packages/plugins/execution-metrics/src/api/prometheus.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Effect } from "effect"; + +import { + ExecutionFinished, + ExecutionId, + ExecutionStarted, + ExecutionToolCallId, + Subject, + Tenant, + ToolCallFinished, + ToolCallStarted, +} from "@executor-js/sdk/core"; + +import { createExecutionMetricsObserver } from "../sdk/observer"; +import { renderPrometheus } from "./prometheus"; + +const owner = { tenant: Tenant.make("tenant_test"), subject: Subject.make("subject_test") }; + +const executionId = ExecutionId.make("exec_metrics_test"); +const toolCallId = ExecutionToolCallId.make("toolcall_metrics_test"); + +const startedAt = new Date("2026-06-13T00:00:00.000Z"); +const completedAt = new Date("2026-06-13T00:00:00.250Z"); + +describe("execution-metrics observer + renderPrometheus", () => { + it.effect("records counters/histogram a Prometheus scrape can expose", () => + Effect.gen(function* () { + const observer = createExecutionMetricsObserver(); + + yield* observer.handle( + new ExecutionStarted({ + executionId, + owner, + code: "console.log('hi')", + trigger: { kind: "manual" }, + startedAt, + }), + ); + yield* observer.handle( + new ToolCallStarted({ + executionId, + toolCallId, + owner, + path: "github.issues.create", + args: {}, + startedAt, + }), + ); + yield* observer.handle( + new ToolCallFinished({ + executionId, + toolCallId, + owner, + path: "github.issues.create", + status: "completed", + completedAt, + }), + ); + yield* observer.handle( + new ExecutionFinished({ + executionId, + owner, + status: "completed", + result: "ok", + completedAt, + }), + ); + + const text = yield* renderPrometheus; + + // Counter + histogram series names are exposed. + expect(text).toContain("# TYPE executor_execution_started_total counter"); + expect(text).toContain("# TYPE executor_execution_completed_total counter"); + expect(text).toContain("# TYPE executor_tool_call_started_total counter"); + expect(text).toContain("# TYPE executor_tool_call_completed_total counter"); + expect(text).toContain("# TYPE executor_execution_duration_ms histogram"); + expect(text).toContain("executor_execution_duration_ms_bucket"); + expect(text).toContain('le="+Inf"'); + + // Effect's histogram buckets already carry the terminal `+Inf` boundary, + // so the renderer must emit it exactly once per series — a duplicate + // `+Inf` bucket line is a duplicate label set and a scrape rejects it. + const infBuckets = text + .split("\n") + .filter( + (line) => + line.startsWith("executor_execution_duration_ms_bucket{") && line.includes('le="+Inf"'), + ); + expect(infBuckets).toHaveLength(1); + + // The trigger_kind attribute rides the tagged series. + expect(text).toContain('trigger_kind="manual"'); + + // At least one started execution was counted (global registry — value ≥ 1). + const startedLine = text + .split("\n") + .find( + (line) => + line.startsWith("executor_execution_started_total{") && + line.includes('trigger_kind="manual"'), + ); + const startedValue = startedLine + ? Number(startedLine.slice(startedLine.lastIndexOf(" ") + 1)) + : 0; + expect(startedValue).toBeGreaterThanOrEqual(1); + }), + ); +}); diff --git a/packages/plugins/execution-metrics/src/api/prometheus.ts b/packages/plugins/execution-metrics/src/api/prometheus.ts new file mode 100644 index 0000000000..1da67b9028 --- /dev/null +++ b/packages/plugins/execution-metrics/src/api/prometheus.ts @@ -0,0 +1,118 @@ +import { Effect, Match, Metric } from "effect"; + +// --------------------------------------------------------------------------- +// Prometheus text-exposition renderer. +// +// Snapshots the global Effect Metric registry (`Metric.snapshot`) and renders +// the v0.0.4 text format: one HELP/TYPE header pair per metric name, then a +// sample line per attribute-set. Histograms expand to cumulative `_bucket` +// series (with a terminal `le="+Inf"`), `_count`, and `_sum`. +// --------------------------------------------------------------------------- + +const sanitizeName = (raw: string): string => raw.replace(/[^a-zA-Z0-9_:]/g, "_"); + +const escapeLabelValue = (raw: string): string => + raw.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/"/g, '\\"'); + +const formatValue = (value: bigint | number): string => { + if (typeof value === "bigint") return value.toString(10); + if (value === Number.POSITIVE_INFINITY) return "+Inf"; + if (value === Number.NEGATIVE_INFINITY) return "-Inf"; + return String(value); +}; + +const formatLabels = ( + attributes: Metric.Metric.AttributeSet, + extra?: Readonly>, +): string => { + const pairs = Object.entries(attributes).map( + ([key, value]) => `${sanitizeName(key)}="${escapeLabelValue(String(value))}"`, + ); + if (extra) { + for (const [key, value] of Object.entries(extra)) { + pairs.push(`${sanitizeName(key)}="${escapeLabelValue(value)}"`); + } + } + return pairs.length === 0 ? "" : `{${pairs.join(",")}}`; +}; + +const emitHeader = ( + name: string, + type: "counter" | "gauge" | "histogram" | "summary", + description: string, + seen: Set, +): readonly string[] => { + if (seen.has(name)) return []; + seen.add(name); + const safeDescription = description.replace(/\\/g, "\\\\").replace(/\n/g, "\\n"); + return [`# HELP ${name} ${safeDescription || "(no description)"}`, `# TYPE ${name} ${type}`]; +}; + +const formatPair = (metric: Metric.Metric.Snapshot, seen: Set): readonly string[] => { + const name = sanitizeName(metric.id); + const description = metric.description ?? ""; + const attributes = metric.attributes ?? {}; + return Match.value(metric).pipe( + Match.when({ type: "Counter" }, (metric) => { + const state = metric.state; + return [ + ...emitHeader(name, "counter", description, seen), + `${name}${formatLabels(attributes)} ${formatValue(state.count)}`, + ]; + }), + Match.when({ type: "Gauge" }, (metric) => { + const state = metric.state; + return [ + ...emitHeader(name, "gauge", description, seen), + `${name}${formatLabels(attributes)} ${formatValue(state.value)}`, + ]; + }), + Match.when({ type: "Histogram" }, (metric) => { + const state = metric.state; + const lines = [...emitHeader(name, "histogram", description, seen)]; + // Effect's HistogramState.buckets already ends with the terminal + // `Infinity` boundary (formatted as `le="+Inf"`), whose cumulative count + // equals the total observation count. Emitting it from the loop covers + // Prometheus's required `+Inf` bucket — appending a second `+Inf` line + // would duplicate the series and the scrape would be rejected. + for (const [upperBound, count] of state.buckets) { + lines.push( + `${name}_bucket${formatLabels(attributes, { le: formatValue(upperBound) })} ${count}`, + ); + } + lines.push(`${name}_count${formatLabels(attributes)} ${state.count}`); + lines.push(`${name}_sum${formatLabels(attributes)} ${formatValue(state.sum)}`); + return lines; + }), + Match.when({ type: "Summary" }, (metric) => { + const state = metric.state; + const lines = [...emitHeader(name, "summary", description, seen)]; + for (const [quantile, value] of state.quantiles) { + lines.push( + `${name}${formatLabels(attributes, { quantile: formatValue(quantile) })} ${formatValue( + value ?? Number.NaN, + )}`, + ); + } + lines.push(`${name}_count${formatLabels(attributes)} ${state.count}`); + lines.push(`${name}_sum${formatLabels(attributes)} ${formatValue(state.sum)}`); + return lines; + }), + Match.when({ type: "Frequency" }, (metric) => { + const state = metric.state; + const lines = [...emitHeader(name, "counter", description, seen)]; + for (const [bucket, count] of state.occurrences) { + lines.push(`${name}${formatLabels(attributes, { bucket })} ${formatValue(count)}`); + } + return lines; + }), + Match.exhaustive, + ); +}; + +/** Snapshot the global Effect Metric registry and render Prometheus text. */ +export const renderPrometheus: Effect.Effect = Effect.map(Metric.snapshot, (snapshot) => { + const seen = new Set(); + const lines = snapshot.flatMap((pair) => formatPair(pair, seen)); + return lines.length === 0 ? "\n" : `${lines.join("\n")}\n`; +}); diff --git a/packages/plugins/execution-metrics/src/cloudflare/index.ts b/packages/plugins/execution-metrics/src/cloudflare/index.ts new file mode 100644 index 0000000000..399cdb80bf --- /dev/null +++ b/packages/plugins/execution-metrics/src/cloudflare/index.ts @@ -0,0 +1,91 @@ +import { Effect, Predicate } from "effect"; + +import { type ExecutionEvent, type ExecutionObserver } from "@executor-js/sdk/core"; + +import { rememberStart } from "../sdk/bounded-map"; + +/** + * Minimal structural view of a Workers Analytics Engine binding. The full type + * lives in `@cloudflare/workers-types` as a global ambient `interface + * AnalyticsEngineDataset`, which isn't a named export and would require pulling + * the Workers global lib into this package's `types`. A local interface keeps + * the Cloudflare entry self-contained; a real binding satisfies it structurally. + */ +export interface AnalyticsEngineDataset { + writeDataPoint(event: { + readonly blobs?: ReadonlyArray; + readonly doubles?: ReadonlyArray; + readonly indexes?: ReadonlyArray; + }): void; +} + +/** + * Build an {@link ExecutionObserver} that writes one Analytics Engine data + * point per finished execution / tool call. `writeDataPoint` is synchronous, + * fire-and-forget (no `await`, no `ctx.waitUntil`), so the observer wraps each + * call in `Effect.sync`. Errors are swallowed by the engine's per-observer + * suppression — an observer must never break the execution it watches. + * + * Layout: + * - `ExecutionFinished` → blobs `[ "execution", status, trigger? ]`, doubles + * `[ durationMs ]`, indexes `[ executionId ]`. + * - `ToolCallFinished` → blobs `[ "tool_call", status, path ]`, doubles + * `[ durationMs ]`, indexes `[ executionId ]`. + */ +export const createWaeMetricsObserver = (analytics: AnalyticsEngineDataset): ExecutionObserver => { + const executionStarts = new Map< + string, + { readonly startedAt: number; readonly trigger?: string } + >(); + const toolCallStarts = new Map(); + + return { + handle: (event: ExecutionEvent) => { + if (Predicate.isTagged(event, "ExecutionStarted")) { + return Effect.sync(() => { + rememberStart(executionStarts, event.executionId, { + startedAt: event.startedAt.getTime(), + trigger: event.trigger?.kind, + }); + }); + } + + if (Predicate.isTagged(event, "ToolCallStarted")) { + return Effect.sync(() => { + rememberStart(toolCallStarts, event.toolCallId, event.startedAt.getTime()); + }); + } + + if (Predicate.isTagged(event, "ExecutionFinished")) { + return Effect.sync(() => { + const started = executionStarts.get(event.executionId); + executionStarts.delete(event.executionId); + const durationMs = started + ? Math.max(0, event.completedAt.getTime() - started.startedAt) + : 0; + analytics.writeDataPoint({ + blobs: ["execution", event.status, started?.trigger ?? ""], + doubles: [durationMs], + indexes: [event.executionId], + }); + }); + } + + if (Predicate.isTagged(event, "ToolCallFinished")) { + return Effect.sync(() => { + const startedAt = toolCallStarts.get(event.toolCallId); + toolCallStarts.delete(event.toolCallId); + const durationMs = + startedAt !== undefined ? Math.max(0, event.completedAt.getTime() - startedAt) : 0; + analytics.writeDataPoint({ + blobs: ["tool_call", event.status, event.path], + doubles: [durationMs], + indexes: [event.executionId], + }); + }); + } + + return Effect.void; + }, + }; +}; diff --git a/packages/plugins/execution-metrics/src/sdk/bounded-map.ts b/packages/plugins/execution-metrics/src/sdk/bounded-map.ts new file mode 100644 index 0000000000..123cd3e618 --- /dev/null +++ b/packages/plugins/execution-metrics/src/sdk/bounded-map.ts @@ -0,0 +1,17 @@ +// Both observer sinks track per-execution start times in a Map so a Finished +// event can compute the elapsed duration. An execution killed before its +// Finished event (process signal, VM timeout, hard shutdown) would otherwise +// leak its entry forever in a long-lived host. `rememberStart` bounds the Map: +// when it's at capacity and the key is new, the oldest entry (insertion-order +// first) is evicted — so a steady stream of orphaned starts can't grow without +// limit. + +const MAX_TRACKED = 10_000; + +export const rememberStart = (map: Map, key: K, value: V): void => { + if (map.size >= MAX_TRACKED && !map.has(key)) { + const oldest = map.keys().next().value; + if (oldest !== undefined) map.delete(oldest); + } + map.set(key, value); +}; diff --git a/packages/plugins/execution-metrics/src/sdk/index.ts b/packages/plugins/execution-metrics/src/sdk/index.ts new file mode 100644 index 0000000000..b7e0e3732b --- /dev/null +++ b/packages/plugins/execution-metrics/src/sdk/index.ts @@ -0,0 +1,2 @@ +export { executionMetricsPlugin, type ExecutionMetricsPluginOptions } from "./plugin"; +export { createExecutionMetricsObserver } from "./observer"; diff --git a/packages/plugins/execution-metrics/src/sdk/observer.ts b/packages/plugins/execution-metrics/src/sdk/observer.ts new file mode 100644 index 0000000000..c95dddc1a3 --- /dev/null +++ b/packages/plugins/execution-metrics/src/sdk/observer.ts @@ -0,0 +1,124 @@ +import { Effect, Metric, Predicate } from "effect"; + +import { + type ExecutionEvent, + type ExecutionId, + type ExecutionObserver, +} from "@executor-js/sdk/core"; + +import { rememberStart } from "./bounded-map"; + +// --------------------------------------------------------------------------- +// Metric definitions — registered in the global Effect Metric registry the +// first time this module is loaded. The Prometheus scrape (`../api/prometheus`) +// and the Effect OTLP/Prometheus exporters read the same registry. +// --------------------------------------------------------------------------- + +const executionsStarted = Metric.counter("executor.execution.started_total", { + description: "Executor executions started.", + incremental: true, +}); + +const executionsCompleted = Metric.counter("executor.execution.completed_total", { + description: "Executor executions completed.", + incremental: true, +}); + +const executionsFailed = Metric.counter("executor.execution.failed_total", { + description: "Executor executions failed.", + incremental: true, +}); + +const executionDuration = Metric.histogram("executor.execution.duration_ms", { + description: "Executor execution duration in milliseconds.", + boundaries: Metric.exponentialBoundaries({ start: 1, factor: 2, count: 16 }), +}); + +const toolCallsStarted = Metric.counter("executor.tool_call.started_total", { + description: "Executor tool calls started.", + incremental: true, +}); + +const toolCallsCompleted = Metric.counter("executor.tool_call.completed_total", { + description: "Executor tool calls completed.", + incremental: true, +}); + +const toolCallsFailed = Metric.counter("executor.tool_call.failed_total", { + description: "Executor tool calls failed.", + incremental: true, +}); + +const triggerAttributes = (triggerKind: string | undefined): Metric.Metric.AttributeSet => + triggerKind ? { trigger_kind: triggerKind } : {}; + +/** + * Build an {@link ExecutionObserver} that drives the global Effect Metric + * registry from the execution lifecycle stream. Counters track executions and + * tool calls (started / completed / failed); a histogram records execution + * duration tagged by `trigger_kind`. Start timestamps are buffered per + * execution so {@link ExecutionFinished} can compute the elapsed duration. + */ +export const createExecutionMetricsObserver = (): ExecutionObserver => { + const executionStarts = new Map< + ExecutionId, + { readonly startedAt: Date; readonly trigger?: string } + >(); + + const updateCounter = ( + counter: Metric.Counter, + attributes: Metric.Metric.AttributeSet = {}, + ): Effect.Effect => Metric.update(Metric.withAttributes(counter, attributes), 1); + + const handleExecutionStarted = ( + event: Extract, + ) => + Effect.sync(() => { + rememberStart(executionStarts, event.executionId, { + startedAt: event.startedAt, + trigger: event.trigger?.kind, + }); + }).pipe( + Effect.andThen(updateCounter(executionsStarted, triggerAttributes(event.trigger?.kind))), + ); + + const handleExecutionFinished = ( + event: Extract, + ) => + Effect.gen(function* () { + const started = executionStarts.get(event.executionId); + if (started) { + executionStarts.delete(event.executionId); + const durationMs = Math.max(0, event.completedAt.getTime() - started.startedAt.getTime()); + yield* Metric.update( + Metric.withAttributes(executionDuration, triggerAttributes(started.trigger)), + durationMs, + ); + } + + const attributes = triggerAttributes(started?.trigger); + return yield* event.status === "completed" + ? updateCounter(executionsCompleted, attributes) + : updateCounter(executionsFailed, attributes); + }); + + return { + handle: (event) => { + if (Predicate.isTagged(event, "ExecutionStarted")) { + return handleExecutionStarted(event); + } + if (Predicate.isTagged(event, "ExecutionFinished")) { + return handleExecutionFinished(event); + } + if (Predicate.isTagged(event, "ToolCallStarted")) { + return updateCounter(toolCallsStarted); + } + if (Predicate.isTagged(event, "ToolCallFinished")) { + return event.status === "completed" + ? updateCounter(toolCallsCompleted) + : updateCounter(toolCallsFailed); + } + return Effect.void; + }, + }; +}; diff --git a/packages/plugins/execution-metrics/src/sdk/plugin.ts b/packages/plugins/execution-metrics/src/sdk/plugin.ts new file mode 100644 index 0000000000..1769bebca4 --- /dev/null +++ b/packages/plugins/execution-metrics/src/sdk/plugin.ts @@ -0,0 +1,26 @@ +import { definePlugin, type ExecutionObserver } from "@executor-js/sdk/core"; + +import { createExecutionMetricsObserver } from "./observer"; + +export interface ExecutionMetricsPluginOptions { + /** Override the observer factory. Defaults to the Effect Metric sink + * ({@link createExecutionMetricsObserver}). A host on Cloudflare can pass a + * Workers Analytics Engine observer here instead. */ + readonly observer?: () => ExecutionObserver; +} + +/** + * Opt-in execution-metrics sink. Registers an {@link ExecutionObserver} via + * `runtime.executionObserver` so the engine fans every {@link ExecutionEvent} + * to it. The base plugin carries NO HTTP surface — import + * `@executor-js/plugin-execution-metrics/api` for the Prometheus scrape, and + * never load `@executor-js/api` from here. + */ +export const executionMetricsPlugin = definePlugin((options?: ExecutionMetricsPluginOptions) => ({ + id: "execution-metrics" as const, + packageName: "@executor-js/plugin-execution-metrics", + storage: () => ({}), + runtime: { + executionObserver: options?.observer ?? createExecutionMetricsObserver, + }, +})); diff --git a/packages/plugins/execution-metrics/tsconfig.json b/packages/plugins/execution-metrics/tsconfig.json new file mode 100644 index 0000000000..eebc1e6f14 --- /dev/null +++ b/packages/plugins/execution-metrics/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "skipLibCheck": true, + "lib": ["ES2022"], + "types": ["bun-types", "node"], + "noUnusedLocals": true, + "noImplicitOverride": true, + "plugins": [ + { + "name": "@effect/language-service", + "ignoreEffectSuggestionsInTscExitCode": true, + "ignoreEffectWarningsInTscExitCode": true, + "diagnosticSeverity": { + "preferSchemaOverJson": "off" + } + } + ] + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/plugins/execution-metrics/tsup.config.ts b/packages/plugins/execution-metrics/tsup.config.ts new file mode 100644 index 0000000000..21cb020a31 --- /dev/null +++ b/packages/plugins/execution-metrics/tsup.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from "tsup"; + +export default defineConfig({ + entry: { + index: "src/sdk/index.ts", + api: "src/api/index.ts", + cloudflare: "src/cloudflare/index.ts", + }, + format: ["esm"], + dts: false, + sourcemap: true, + clean: true, + external: [/^@executor-js\//, /^effect/, /^@effect\//], +}); diff --git a/packages/plugins/execution-metrics/vitest.config.ts b/packages/plugins/execution-metrics/vitest.config.ts new file mode 100644 index 0000000000..5bfa2d586e --- /dev/null +++ b/packages/plugins/execution-metrics/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["src/**/*.test.ts"], + passWithNoTests: true, + }, +}); diff --git a/packages/plugins/file-secrets/CHANGELOG.md b/packages/plugins/file-secrets/CHANGELOG.md index d823163d49..07f4e5cbc1 100644 --- a/packages/plugins/file-secrets/CHANGELOG.md +++ b/packages/plugins/file-secrets/CHANGELOG.md @@ -1,5 +1,54 @@ # @executor-js/plugin-file-secrets +## 1.5.17 + +### Patch Changes + +- Updated dependencies []: + - @executor-js/sdk@1.5.17 + +## 1.5.16 + +### Patch Changes + +- Updated dependencies []: + - @executor-js/sdk@1.5.16 + +## 1.5.15 + +### Patch Changes + +- Updated dependencies []: + - @executor-js/sdk@1.5.15 + +## 1.5.14 + +### Patch Changes + +- Updated dependencies []: + - @executor-js/sdk@1.5.14 + +## 1.5.13 + +### Patch Changes + +- Updated dependencies []: + - @executor-js/sdk@1.5.13 + +## 1.5.12 + +### Patch Changes + +- Updated dependencies []: + - @executor-js/sdk@1.5.12 + +## 1.5.11 + +### Patch Changes + +- Updated dependencies []: + - @executor-js/sdk@1.5.11 + ## 1.5.10 ### Patch Changes diff --git a/packages/plugins/file-secrets/package.json b/packages/plugins/file-secrets/package.json index c50475f3b8..8985514f43 100644 --- a/packages/plugins/file-secrets/package.json +++ b/packages/plugins/file-secrets/package.json @@ -1,6 +1,6 @@ { "name": "@executor-js/plugin-file-secrets", - "version": "1.5.10", + "version": "1.5.17", "homepage": "https://github.com/RhysSullivan/executor/tree/main/packages/plugins/file-secrets", "bugs": { "url": "https://github.com/RhysSullivan/executor/issues" diff --git a/packages/plugins/google/CHANGELOG.md b/packages/plugins/google/CHANGELOG.md new file mode 100644 index 0000000000..6d75ae4909 --- /dev/null +++ b/packages/plugins/google/CHANGELOG.md @@ -0,0 +1,21 @@ +# @executor-js/plugin-google + +## 1.5.16 + +### Patch Changes + +- Updated dependencies []: + - @executor-js/sdk@1.5.17 + - @executor-js/plugin-openapi@1.5.17 + - @executor-js/api@1.4.37 + - @executor-js/react@1.4.37 + +## 1.5.15 + +### Patch Changes + +- Updated dependencies []: + - @executor-js/sdk@1.5.16 + - @executor-js/plugin-openapi@1.5.16 + - @executor-js/api@1.4.36 + - @executor-js/react@1.4.36 diff --git a/packages/plugins/google/package.json b/packages/plugins/google/package.json new file mode 100644 index 0000000000..15f4bc9733 --- /dev/null +++ b/packages/plugins/google/package.json @@ -0,0 +1,98 @@ +{ + "name": "@executor-js/plugin-google", + "version": "1.5.16", + "homepage": "https://github.com/RhysSullivan/executor/tree/main/packages/plugins/google", + "bugs": { + "url": "https://github.com/RhysSullivan/executor/issues" + }, + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/RhysSullivan/executor.git", + "directory": "packages/plugins/google" + }, + "files": [ + "dist" + ], + "type": "module", + "exports": { + ".": "./src/sdk/index.ts", + "./promise": "./src/promise.ts", + "./api": "./src/api/index.ts", + "./react": "./src/react/index.ts", + "./client": "./src/react/plugin-client.tsx" + }, + "publishConfig": { + "access": "public", + "exports": { + ".": { + "import": { + "types": "./dist/promise.d.ts", + "default": "./dist/index.js" + } + }, + "./core": { + "import": { + "types": "./dist/sdk/index.d.ts", + "default": "./dist/core.js" + } + }, + "./client": { + "import": { + "types": "./dist/react/plugin-client.d.ts", + "default": "./dist/client.js" + } + } + } + }, + "scripts": { + "build": "tsup && (tsc --declaration --emitDeclarationOnly --outDir dist --rootDir src || true)", + "typecheck": "tsgo --noEmit", + "test": "vitest run", + "test:watch": "vitest", + "typecheck:slow": "bunx tsc --noEmit -p tsconfig.json" + }, + "dependencies": { + "@executor-js/plugin-openapi": "workspace:*", + "@executor-js/sdk": "workspace:*", + "lucide-react": "^1.7.0" + }, + "devDependencies": { + "@effect/atom-react": "catalog:", + "@effect/vitest": "catalog:", + "@executor-js/api": "workspace:*", + "@executor-js/react": "workspace:*", + "@types/node": "catalog:", + "@types/react": "catalog:", + "bun-types": "catalog:", + "effect": "catalog:", + "react": "catalog:", + "tsup": "catalog:", + "vitest": "catalog:" + }, + "peerDependencies": { + "@effect/atom-react": "catalog:", + "@executor-js/api": "workspace:*", + "@executor-js/react": "workspace:*", + "@tanstack/react-router": "catalog:", + "effect": "catalog:", + "react": "catalog:" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "@effect/atom-react": { + "optional": true + }, + "@tanstack/react-router": { + "optional": true + }, + "@executor-js/api": { + "optional": true + }, + "@executor-js/react": { + "optional": true + } + } +} diff --git a/packages/plugins/google/src/api/group.ts b/packages/plugins/google/src/api/group.ts new file mode 100644 index 0000000000..87053a3379 --- /dev/null +++ b/packages/plugins/google/src/api/group.ts @@ -0,0 +1,142 @@ +import { HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"; +import { Schema } from "effect"; +import { ApiKeyAuthMethod, ApiKeyAuthTemplate } from "@executor-js/sdk/http-auth"; +import { + InternalError, + IntegrationAlreadyExistsError, + IntegrationNotFoundError, + IntegrationSlug, +} from "@executor-js/sdk/shared"; +import { + OpenApiExtractionError, + OpenApiOAuthError, + OpenApiParseError, +} from "@executor-js/plugin-openapi"; + +const DomainErrors = [ + InternalError, + OpenApiParseError, + OpenApiExtractionError, + OpenApiOAuthError, + IntegrationAlreadyExistsError, +] as const; + +const IntegrationNotFound = IntegrationNotFoundError.annotate({ httpApiStatus: 404 }); + +const UpdateErrors = [ + InternalError, + OpenApiParseError, + OpenApiExtractionError, + OpenApiOAuthError, + IntegrationNotFound, +] as const; + +const SlugParams = { + slug: Schema.String, +}; + +const OAuthTemplatePayload = Schema.Struct({ + slug: Schema.String, + kind: Schema.Literal("oauth2"), + authorizationUrl: Schema.String, + tokenUrl: Schema.String, + scopes: Schema.Array(Schema.String), +}); + +const AuthenticationPayload = Schema.Union([OAuthTemplatePayload, ApiKeyAuthTemplate]); +const AuthenticationResponse = Schema.Union([OAuthTemplatePayload, ApiKeyAuthMethod]); + +const AddBundlePayload = Schema.Struct({ + urls: Schema.Array(Schema.String), + slug: Schema.optional(Schema.String), + name: Schema.optional(Schema.String), + description: Schema.optional(Schema.String), + baseUrl: Schema.optional(Schema.String), +}); + +const AddBundleResponse = Schema.Struct({ + slug: IntegrationSlug, + toolCount: Schema.Number, +}); + +const UpdateBundlePayload = Schema.Struct({ + urls: Schema.optional(Schema.Array(Schema.String)), +}); + +const UpdateBundleResponse = Schema.Struct({ + slug: IntegrationSlug, + toolCount: Schema.Number, + addedTools: Schema.Array(Schema.String), + removedTools: Schema.Array(Schema.String), +}); + +const IntegrationView = Schema.Struct({ + slug: IntegrationSlug, + description: Schema.String, + kind: Schema.String, + canRemove: Schema.Boolean, + canRefresh: Schema.Boolean, +}); + +const GoogleConfigView = Schema.Struct({ + googleDiscoveryUrls: Schema.optional(Schema.Array(Schema.String)), + baseUrl: Schema.optional(Schema.String), + headers: Schema.optional(Schema.Record(Schema.String, Schema.String)), + queryParams: Schema.optional(Schema.Record(Schema.String, Schema.String)), + authenticationTemplate: Schema.optional(Schema.Array(AuthenticationResponse)), +}); + +const ConfigurePayload = Schema.Struct({ + authenticationTemplate: Schema.Array(AuthenticationPayload), + mode: Schema.optional(Schema.Literals(["merge", "replace"])), +}); + +const ConfigureResponse = Schema.Struct({ + authenticationTemplate: Schema.Array(AuthenticationResponse), +}); + +export const GoogleGroup = HttpApiGroup.make("google") + .add( + HttpApiEndpoint.post("addBundle", "/google/bundles", { + payload: AddBundlePayload, + success: AddBundleResponse, + error: DomainErrors, + }), + ) + .add( + HttpApiEndpoint.get("getIntegration", "/google/integrations/:slug", { + params: SlugParams, + success: Schema.NullOr(IntegrationView), + error: DomainErrors, + }), + ) + .add( + HttpApiEndpoint.get("getConfig", "/google/integrations/:slug/config", { + params: SlugParams, + success: Schema.NullOr(GoogleConfigView), + error: DomainErrors, + }), + ) + .add( + HttpApiEndpoint.post("configure", "/google/integrations/:slug/config", { + params: SlugParams, + payload: ConfigurePayload, + success: ConfigureResponse, + error: DomainErrors, + }), + ) + .add( + HttpApiEndpoint.post("updateBundle", "/google/integrations/:slug/bundle", { + params: SlugParams, + payload: UpdateBundlePayload, + success: UpdateBundleResponse, + error: UpdateErrors, + }), + ) + .add( + HttpApiEndpoint.delete("removeBundle", "/google/integrations/:slug", { + params: SlugParams, + success: Schema.Void, + error: DomainErrors, + }), + ); diff --git a/packages/plugins/google/src/api/handlers.ts b/packages/plugins/google/src/api/handlers.ts new file mode 100644 index 0000000000..5f12404677 --- /dev/null +++ b/packages/plugins/google/src/api/handlers.ts @@ -0,0 +1,105 @@ +import { HttpApiBuilder } from "effect/unstable/httpapi"; +import { Context, Effect } from "effect"; + +import { addGroup, capture } from "@executor-js/api"; +import type { GooglePluginExtension } from "../sdk/plugin"; +import { GoogleGroup } from "./group"; + +export class GoogleExtensionService extends Context.Service< + GoogleExtensionService, + GooglePluginExtension +>()("GoogleExtensionService") {} + +const ExecutorApiWithGoogle = addGroup(GoogleGroup); + +export const GoogleHandlers = HttpApiBuilder.group(ExecutorApiWithGoogle, "google", (handlers) => + handlers + .handle("addBundle", ({ payload }) => + capture( + Effect.gen(function* () { + const ext = yield* GoogleExtensionService; + return yield* ext.addBundle({ + urls: payload.urls, + slug: payload.slug, + name: payload.name, + description: payload.description, + baseUrl: payload.baseUrl, + }); + }), + ), + ) + .handle("getIntegration", ({ params }) => + capture( + Effect.gen(function* () { + const ext = yield* GoogleExtensionService; + const integration = yield* ext.getIntegration(params.slug); + return integration + ? { + slug: integration.slug, + description: integration.description, + kind: integration.kind, + canRemove: integration.canRemove, + canRefresh: integration.canRefresh, + } + : null; + }), + ), + ) + .handle("getConfig", ({ params }) => + capture( + Effect.gen(function* () { + const ext = yield* GoogleExtensionService; + const config = yield* ext.getConfig(params.slug); + return config + ? { + googleDiscoveryUrls: config.googleDiscoveryUrls + ? [...config.googleDiscoveryUrls] + : undefined, + baseUrl: config.baseUrl, + headers: config.headers ? { ...config.headers } : undefined, + queryParams: config.queryParams ? { ...config.queryParams } : undefined, + authenticationTemplate: config.authenticationTemplate + ? [...config.authenticationTemplate] + : undefined, + } + : null; + }), + ), + ) + .handle("configure", ({ params, payload }) => + capture( + Effect.gen(function* () { + const ext = yield* GoogleExtensionService; + const authenticationTemplate = yield* ext.configure(params.slug, { + authenticationTemplate: payload.authenticationTemplate, + mode: payload.mode ?? "merge", + }); + return { authenticationTemplate: [...authenticationTemplate] }; + }), + ), + ) + .handle("updateBundle", ({ params, payload }) => + capture( + Effect.gen(function* () { + const ext = yield* GoogleExtensionService; + const result = yield* ext.updateBundle(params.slug, { + ...(payload.urls !== undefined ? { urls: payload.urls } : {}), + }); + return { + slug: result.slug, + toolCount: result.toolCount, + addedTools: [...result.addedTools], + removedTools: [...result.removedTools], + }; + }), + ), + ) + .handle("removeBundle", ({ params }) => + capture( + Effect.gen(function* () { + const ext = yield* GoogleExtensionService; + yield* ext.removeBundle(params.slug); + }), + ), + ), +); diff --git a/packages/plugins/google/src/api/index.ts b/packages/plugins/google/src/api/index.ts new file mode 100644 index 0000000000..c32d61b775 --- /dev/null +++ b/packages/plugins/google/src/api/index.ts @@ -0,0 +1,15 @@ +import { definePlugin } from "@executor-js/sdk/core"; + +import { googlePlugin, type GooglePluginOptions } from "../sdk/plugin"; +import { GoogleGroup } from "./group"; +import { GoogleHandlers, GoogleExtensionService } from "./handlers"; + +export { GoogleGroup } from "./group"; +export { GoogleHandlers, GoogleExtensionService } from "./handlers"; + +export const googleHttpPlugin = definePlugin((options?: GooglePluginOptions) => ({ + ...googlePlugin(options), + routes: () => GoogleGroup, + handlers: () => GoogleHandlers, + extensionService: GoogleExtensionService, +})); diff --git a/packages/plugins/google/src/promise.ts b/packages/plugins/google/src/promise.ts new file mode 100644 index 0000000000..6c03c22999 --- /dev/null +++ b/packages/plugins/google/src/promise.ts @@ -0,0 +1 @@ +export { googlePlugin } from "./sdk/plugin"; diff --git a/packages/plugins/google/src/react/AddGoogleSource.tsx b/packages/plugins/google/src/react/AddGoogleSource.tsx new file mode 100644 index 0000000000..deba664402 --- /dev/null +++ b/packages/plugins/google/src/react/AddGoogleSource.tsx @@ -0,0 +1,166 @@ +import { useCallback, useMemo, useState } from "react"; +import { useAtomSet } from "@effect/atom-react"; +import * as Exit from "effect/Exit"; + +import { integrationWriteKeys } from "@executor-js/react/api/reactivity-keys"; +import { + slugifyNamespace, + useIntegrationIdentity, +} from "@executor-js/react/plugins/integration-identity"; +import { Button } from "@executor-js/react/components/button"; +import { FloatActions } from "@executor-js/react/components/float-actions"; +import { Spinner } from "@executor-js/react/components/spinner"; +import { + addIntegrationErrorMessage, + FormErrorAlert, + SlugCollisionAlert, + useSlugAlreadyExists, +} from "@executor-js/react/lib/integration-add"; +import { OpenApiSourceDetailsFields } from "@executor-js/plugin-openapi/react"; + +import { addGoogleBundle } from "./atoms"; +import { GoogleProductPicker } from "./GoogleProductPicker"; +import { googleOpenApiPresets, type GoogleOpenApiPreset } from "../sdk/presets"; + +const GOOGLE_BUNDLE_FAVICON = "https://fonts.gstatic.com/s/i/productlogos/googleg/v6/192px.svg"; + +const googleBundleDefaultPresetIds: ReadonlySet = new Set( + googleOpenApiPresets + .filter((preset: GoogleOpenApiPreset) => preset.featured) + .map((preset: GoogleOpenApiPreset) => preset.id), +); + +const googleBundleUrls = ( + selectedPresetIds: ReadonlySet, + customUrls: readonly string[], +): readonly string[] => { + const fromPresets = googleOpenApiPresets.flatMap((preset: GoogleOpenApiPreset) => + preset.url && selectedPresetIds.has(preset.id) ? [preset.url] : [], + ); + return [...new Set([...fromPresets, ...customUrls])]; +}; + +export default function AddGoogleSource(props: { + onComplete: (slug?: string) => void; + onCancel: () => void; + initialNamespace?: string; +}) { + const [selectedPresetIds, setSelectedPresetIds] = useState>( + googleBundleDefaultPresetIds, + ); + const [customDiscoveryUrls, setCustomDiscoveryUrls] = useState([]); + const [baseUrl, setBaseUrl] = useState(""); + const [descriptionDraft, setDescriptionDraft] = useState(null); + const [adding, setAdding] = useState(false); + const [addError, setAddError] = useState(null); + + const identity = useIntegrationIdentity({ + fallbackName: "Google", + fallbackNamespace: props.initialNamespace ?? "google", + }); + + const bundleDiscoveryUrls = useMemo( + () => googleBundleUrls(selectedPresetIds, customDiscoveryUrls), + [selectedPresetIds, customDiscoveryUrls], + ); + + const toggleBundlePreset = useCallback((presetId: string, checked: boolean) => { + setSelectedPresetIds((current: ReadonlySet) => { + const next = new Set(current); + if (checked) next.add(presetId); + else next.delete(presetId); + return next; + }); + }, []); + + const addCustomDiscoveryUrl = useCallback((url: string) => { + setCustomDiscoveryUrls((current: readonly string[]) => + current.includes(url) ? current : [...current, url], + ); + }, []); + + const removeCustomDiscoveryUrl = useCallback((url: string) => { + setCustomDiscoveryUrls((current: readonly string[]) => + current.filter((entry: string) => entry !== url), + ); + }, []); + + const doAdd = useAtomSet(addGoogleBundle, { mode: "promiseExit" }); + + const resolvedSourceId = slugifyNamespace(identity.namespace) || "google"; + const resolvedDisplayName = identity.name.trim() || "Google"; + const resolvedDescription = descriptionDraft ?? "Google APIs"; + const slugAlreadyExists = useSlugAlreadyExists(resolvedSourceId); + const canAdd = bundleDiscoveryUrls.length > 0 && !slugAlreadyExists; + + const handleAdd = async () => { + setAdding(true); + setAddError(null); + const exit = await doAdd({ + payload: { + urls: [...bundleDiscoveryUrls], + slug: resolvedSourceId, + name: resolvedDisplayName, + ...(resolvedDescription.trim().length > 0 + ? { description: resolvedDescription.trim() } + : {}), + ...(baseUrl.trim().length > 0 ? { baseUrl: baseUrl.trim() } : {}), + }, + reactivityKeys: integrationWriteKeys, + }); + if (Exit.isFailure(exit)) { + setAddError(addIntegrationErrorMessage(exit, resolvedSourceId, "Failed to add Google")); + setAdding(false); + return; + } + props.onComplete(String(exit.value.slug)); + }; + + return ( +
+
+

Add Google

+

+ Bundle Google APIs into one integration with a shared OAuth consent. +

+
+ + + + + + {slugAlreadyExists && !adding && } + + {addError && } + + + + + +
+ ); +} diff --git a/packages/plugins/google/src/react/GoogleAccountsPanel.tsx b/packages/plugins/google/src/react/GoogleAccountsPanel.tsx new file mode 100644 index 0000000000..7d7ed655d8 --- /dev/null +++ b/packages/plugins/google/src/react/GoogleAccountsPanel.tsx @@ -0,0 +1,128 @@ +import { useCallback, useMemo } from "react"; +import { useAtomValue, useAtomSet } from "@effect/atom-react"; +import * as Exit from "effect/Exit"; +import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; +import { AuthTemplateSlug, IntegrationSlug } from "@executor-js/sdk/shared"; +import type { IntegrationAccountHandoff } from "@executor-js/sdk/client"; +import { TriangleAlert } from "lucide-react"; + +import { AccountsSection } from "@executor-js/react/components/accounts-section"; +import { Alert, AlertDescription, AlertTitle } from "@executor-js/react/components/alert"; +import { integrationWriteKeys } from "@executor-js/react/api/reactivity-keys"; +import type { AuthMethod, Placement } from "@executor-js/react/lib/auth-placements"; +import { + useCustomMethodActions, + type AuthMethodsCodec, + type ConfigureAuthMethods, +} from "@executor-js/react/lib/custom-auth-methods"; +import { + authMethodsFromConfig, + openApiWireAuthInput, + templateFromPlacements, +} from "@executor-js/plugin-openapi/react"; +import type { Authentication } from "@executor-js/plugin-openapi"; + +import { googleConfigAtom, googleConfigure } from "./atoms"; +import { googleAudienceWarningsForUrls } from "../sdk/presets"; + +const GOOGLE_AUDIENCE_WARNING: Readonly> = { + "workspace-admin": + "This connection includes Google Workspace admin APIs. Connecting requires a Workspace admin account; personal Gmail accounts cannot grant these scopes.", + "unsupported-user": + "This connection includes APIs that Google does not grant through standard user OAuth consent. Those tools may fail to authorize.", +}; + +const NO_AUTH_METHOD: AuthMethod = { + id: "none", + label: "No authentication", + kind: "none", + source: "spec", + template: AuthTemplateSlug.make("none"), + placements: [], +}; + +export default function GoogleAccountsPanel(props: { + readonly sourceId: string; + readonly integrationName: string; + readonly accountHandoff?: IntegrationAccountHandoff | null; +}) { + const { sourceId, integrationName, accountHandoff } = props; + const slug = IntegrationSlug.make(sourceId); + const configResult = useAtomValue(googleConfigAtom(slug)); + const doConfigure = useAtomSet(googleConfigure, { mode: "promiseExit" }); + + const existingTemplate = useMemo(() => { + if (!AsyncResult.isSuccess(configResult) || configResult.value == null) return []; + return (configResult.value.authenticationTemplate ?? []) as readonly Authentication[]; + }, [configResult]); + + const methods = useMemo(() => { + const declared = authMethodsFromConfig(existingTemplate); + return declared.length > 0 ? declared : [NO_AUTH_METHOD]; + }, [existingTemplate]); + + const configure = useCallback>( + async (input) => { + const exit = await doConfigure({ + params: { slug }, + payload: { + authenticationTemplate: input.authenticationTemplate.map(openApiWireAuthInput), + ...(input.mode ? { mode: input.mode } : {}), + }, + reactivityKeys: integrationWriteKeys, + }); + return Exit.map(exit, (result) => result.authenticationTemplate as readonly Authentication[]); + }, + [doConfigure, slug], + ); + + const codec = useMemo>( + () => ({ + toAuthMethods: authMethodsFromConfig, + templatesFromPlacements: (placements: readonly Placement[]) => [ + templateFromPlacements(placements), + ], + slugOf: (template: Authentication) => String(template.slug), + }), + [], + ); + + const { createCustomMethod, removeCustomMethod } = useCustomMethodActions({ + existing: existingTemplate, + codec, + configure, + }); + + const audienceWarnings = useMemo(() => { + if (!AsyncResult.isSuccess(configResult) || configResult.value == null) return []; + const urls = configResult.value.googleDiscoveryUrls ?? []; + return googleAudienceWarningsForUrls(urls).flatMap((audience: string) => { + const message = GOOGLE_AUDIENCE_WARNING[audience]; + return message ? [message] : []; + }); + }, [configResult]); + + return ( +
+ {audienceWarnings.length > 0 && ( + + + Some Google APIs need special consent + + {audienceWarnings.map((message: string) => ( +

{message}

+ ))} +
+
+ )} + +
+ ); +} diff --git a/packages/plugins/openapi/src/react/GoogleProductPicker.tsx b/packages/plugins/google/src/react/GoogleProductPicker.tsx similarity index 96% rename from packages/plugins/openapi/src/react/GoogleProductPicker.tsx rename to packages/plugins/google/src/react/GoogleProductPicker.tsx index b459da3a8d..3fdf9530a2 100644 --- a/packages/plugins/openapi/src/react/GoogleProductPicker.tsx +++ b/packages/plugins/google/src/react/GoogleProductPicker.tsx @@ -19,12 +19,12 @@ import { googleOpenApiPresets, type GoogleOpenApiOAuthAudience, type GoogleOpenApiPreset, -} from "../sdk/google-presets"; -import { googleOAuthConsentBatches } from "../sdk/google-oauth-batches"; -import { isGoogleDiscoveryUrl } from "../sdk/google-discovery"; +} from "../sdk/presets"; +import { googleOAuthConsentBatches } from "../sdk/oauth-batches"; +import { isGoogleDiscoveryUrl } from "../sdk/discovery"; // --------------------------------------------------------------------------- -// GoogleProductPicker — the "customize your Google connection" surface. +// GoogleProductPicker - the "customize your Google connection" surface. // // A checkable card grid over `googleOpenApiPresets`, grouped/annotated by // `oauthAudience`. The user picks which Google APIs to bundle into the single @@ -53,7 +53,7 @@ const AUDIENCE_LABEL: Readonly> = { }; const AUDIENCE_DESCRIPTION: Readonly> = { - "standard-user": "Connect with a normal Google account — one consent screen.", + "standard-user": "Connect with a normal Google account - one consent screen.", "advanced-user": "Broader scopes that may need an unverified-app warning to be accepted.", "workspace-admin": "Requires a Google Workspace admin account; not available on personal Gmail.", "unsupported-user": "Google does not grant these scopes through standard user OAuth consent.", @@ -86,7 +86,7 @@ const AudienceWarningChip = ({ audience }: { audience: GoogleOpenApiOAuthAudienc ) : null; -// A Google API row — borderless and single-line, leaning on hover/selected +// A Google API row - borderless and single-line, leaning on hover/selected // fills instead of per-item card chrome (lightest separation that still reads). // Name + truncated summary share one baseline for a dense, scannable two-column // list; the audience warning chip trails on the right. @@ -112,7 +112,7 @@ const ProductRow = ({
- {/* One truncating line — the name + summary clip to the cell with an + {/* One truncating line - the name + summary clip to the cell with an ellipsis instead of overflowing into the neighbouring column. */}
{preset.name}{" "} diff --git a/packages/plugins/google/src/react/atoms.ts b/packages/plugins/google/src/react/atoms.ts new file mode 100644 index 0000000000..b62b6ffeda --- /dev/null +++ b/packages/plugins/google/src/react/atoms.ts @@ -0,0 +1,30 @@ +import type { IntegrationSlug } from "@executor-js/sdk/shared"; +import * as Atom from "effect/unstable/reactivity/Atom"; +import { ReactivityKey } from "@executor-js/react/api/reactivity-keys"; +import { GoogleClient } from "./client"; + +export const googleIntegrationAtom = (slug: IntegrationSlug) => + GoogleClient.query("google", "getIntegration", { + params: { slug }, + timeToLive: "15 seconds", + reactivityKeys: [ReactivityKey.integrations, ReactivityKey.tools], + }); + +export const googleConfigAtom = (slug: IntegrationSlug) => + GoogleClient.query("google", "getConfig", { + params: { slug }, + timeToLive: "15 seconds", + reactivityKeys: [ReactivityKey.integrations, ReactivityKey.tools], + }); + +export const addGoogleBundle = GoogleClient.mutation("google", "addBundle"); + +export const updateGoogleBundle = GoogleClient.mutation("google", "updateBundle"); + +export const removeGoogleBundle = GoogleClient.mutation("google", "removeBundle"); + +export const googleConfigure = GoogleClient.mutation("google", "configure"); + +export const googleIntegrationFamily = Atom.family(googleIntegrationAtom); + +export const googleConfigFamily = Atom.family(googleConfigAtom); diff --git a/packages/plugins/google/src/react/client.ts b/packages/plugins/google/src/react/client.ts new file mode 100644 index 0000000000..2bd95456cb --- /dev/null +++ b/packages/plugins/google/src/react/client.ts @@ -0,0 +1,13 @@ +import { createPluginAtomClient } from "@executor-js/sdk/client"; +import { + getExecutorOrganizationHeaders, + getExecutorApiBaseUrl, + getExecutorServerAuthorizationHeader, +} from "@executor-js/react/api/server-connection"; +import { GoogleGroup } from "../api/group"; + +export const GoogleClient = createPluginAtomClient(GoogleGroup, { + baseUrl: getExecutorApiBaseUrl, + authorizationHeader: getExecutorServerAuthorizationHeader, + headers: getExecutorOrganizationHeaders, +}); diff --git a/packages/plugins/google/src/react/index.ts b/packages/plugins/google/src/react/index.ts new file mode 100644 index 0000000000..4ab598dbdc --- /dev/null +++ b/packages/plugins/google/src/react/index.ts @@ -0,0 +1,12 @@ +export { googleIntegrationPlugin } from "./source-plugin"; +export { GoogleClient } from "./client"; +export { + addGoogleBundle, + googleConfigAtom, + googleConfigFamily, + googleConfigure, + googleIntegrationAtom, + googleIntegrationFamily, + removeGoogleBundle, + updateGoogleBundle, +} from "./atoms"; diff --git a/packages/plugins/google/src/react/plugin-client.tsx b/packages/plugins/google/src/react/plugin-client.tsx new file mode 100644 index 0000000000..15fb325b1a --- /dev/null +++ b/packages/plugins/google/src/react/plugin-client.tsx @@ -0,0 +1,8 @@ +import { defineClientPlugin } from "@executor-js/sdk/client"; + +import { googleIntegrationPlugin } from "./source-plugin"; + +export default defineClientPlugin({ + id: "google" as const, + integrationPlugin: googleIntegrationPlugin, +}); diff --git a/packages/plugins/google/src/react/source-plugin.ts b/packages/plugins/google/src/react/source-plugin.ts new file mode 100644 index 0000000000..add1ce9e26 --- /dev/null +++ b/packages/plugins/google/src/react/source-plugin.ts @@ -0,0 +1,18 @@ +import { lazy } from "react"; +import type { IntegrationPlugin } from "@executor-js/sdk/client"; +import { googleOpenApiBundlePreset } from "../sdk/presets"; + +const importAdd = () => import("./AddGoogleSource"); +const importAccounts = () => import("./GoogleAccountsPanel"); + +export const googleIntegrationPlugin: IntegrationPlugin = { + key: "google", + label: "Google", + add: lazy(importAdd), + accounts: lazy(importAccounts), + presets: [googleOpenApiBundlePreset], + preload: () => { + void importAdd(); + void importAccounts(); + }, +}; diff --git a/packages/plugins/openapi/src/sdk/__snapshots__/google-presets.test.ts.snap b/packages/plugins/google/src/sdk/__snapshots__/presets.test.ts.snap similarity index 100% rename from packages/plugins/openapi/src/sdk/__snapshots__/google-presets.test.ts.snap rename to packages/plugins/google/src/sdk/__snapshots__/presets.test.ts.snap diff --git a/packages/plugins/google/src/sdk/config.ts b/packages/plugins/google/src/sdk/config.ts new file mode 100644 index 0000000000..6e3b45813f --- /dev/null +++ b/packages/plugins/google/src/sdk/config.ts @@ -0,0 +1,24 @@ +import { Option, Schema } from "effect"; +import { AuthenticationSchema, type Authentication } from "@executor-js/plugin-openapi"; + +export const GoogleIntegrationConfigSchema = Schema.Struct({ + specHash: Schema.optional(Schema.String), + sourceUrl: Schema.optional(Schema.String), + googleDiscoveryUrls: Schema.optional(Schema.Array(Schema.String)), + baseUrl: Schema.optional(Schema.String), + headers: Schema.optional(Schema.Record(Schema.String, Schema.String)), + queryParams: Schema.optional(Schema.Record(Schema.String, Schema.String)), + authenticationTemplate: Schema.optional(Schema.Array(AuthenticationSchema)), +}); + +export type GoogleIntegrationConfig = Omit< + typeof GoogleIntegrationConfigSchema.Type, + "authenticationTemplate" +> & { + readonly authenticationTemplate?: readonly Authentication[]; +}; + +const decodeConfig = Schema.decodeUnknownOption(GoogleIntegrationConfigSchema); + +export const decodeGoogleIntegrationConfig = (value: unknown): GoogleIntegrationConfig | null => + Option.getOrNull(decodeConfig(value)) as GoogleIntegrationConfig | null; diff --git a/packages/plugins/openapi/src/sdk/google-discovery.test.ts b/packages/plugins/google/src/sdk/discovery.test.ts similarity index 98% rename from packages/plugins/openapi/src/sdk/google-discovery.test.ts rename to packages/plugins/google/src/sdk/discovery.test.ts index 0f50fc5712..233787cf94 100644 --- a/packages/plugins/openapi/src/sdk/google-discovery.test.ts +++ b/packages/plugins/google/src/sdk/discovery.test.ts @@ -5,9 +5,8 @@ import { buildToolTypeScriptPreview } from "@executor-js/sdk/core"; import { convertGoogleDiscoveryBundleToOpenApi, convertGoogleDiscoveryToOpenApi, -} from "./google-discovery"; -import { extract } from "./extract"; -import { parse } from "./parse"; +} from "./discovery"; +import { extract, parse } from "@executor-js/plugin-openapi"; const ConvertedOperation = Schema.Struct({ operationId: Schema.String, @@ -241,7 +240,7 @@ it.effect("converts Google Discovery documents into Executor-preserving OpenAPI }); // v2: the conversion now exposes a v2 oauth `authenticationTemplate` rather // than v1's `oauth2` source config. - // removed: identityScopes assertion — that field belonged to the v1 + // removed: identityScopes assertion - that field belonged to the v1 // OAuth2SourceConfig slot model, which no longer exists in v2. const oauthTemplate = result.authenticationTemplate?.find((entry) => entry.kind === "oauth2"); expect(oauthTemplate).toBeDefined(); @@ -454,7 +453,7 @@ it.effect("bundles Google Discovery documents into one Google OpenAPI source", ( // `calendar.*` → `calendar`, `userinfo.email` → `email`), and scopes a user // OAuth consent screen can't show (`chat.bot`, `chat.app.*`, `keep`) are // dropped. The persisted auth template, the spec `securitySchemes.googleOAuth2` -// flow scopes, and the root `security` entry all agree — so the preview the +// flow scopes, and the root `security` entry all agree - so the preview the // picker shows and the set `oauth.start` requests at connect are the same. // Per-operation `x-google-scopes`/`security` stay RAW (they describe per-method // scope needs, not consent). @@ -595,7 +594,7 @@ it.effect("compacts and filters the merged bundle scope set into a clean consent expect([...(spec.security[0]?.["googleOAuth2"] ?? [])].sort()).toEqual( [...expectedConsentScopes].sort(), ); - // Per-operation x-google-scopes stay RAW — a dropped consent scope can still + // Per-operation x-google-scopes stay RAW - a dropped consent scope can still // be the scope a given method advertises. expect(spec.paths["/v1/{name}"]?.get?.["x-google-scopes"]).toEqual([ "https://www.googleapis.com/auth/chat.bot", diff --git a/packages/plugins/openapi/src/sdk/google-discovery.ts b/packages/plugins/google/src/sdk/discovery.ts similarity index 98% rename from packages/plugins/openapi/src/sdk/google-discovery.ts rename to packages/plugins/google/src/sdk/discovery.ts index 0ba0e3d48c..bd786fef2b 100644 --- a/packages/plugins/openapi/src/sdk/google-discovery.ts +++ b/packages/plugins/google/src/sdk/discovery.ts @@ -5,12 +5,15 @@ import { Effect, Option, Predicate, Schema, SchemaGetter } from "effect"; import { HttpClient, HttpClientRequest } from "effect/unstable/http"; -import { OpenApiParseError } from "./errors"; -import { compactGoogleOAuthScopes } from "./google-oauth-scopes"; -import type { SpecFetchCredentials } from "./parse"; -import type { Authentication } from "./types"; +import { OpenApiParseError, type Authentication } from "@executor-js/plugin-openapi"; +import { compactGoogleOAuthScopes } from "./oauth-scopes"; import { AuthTemplateSlug } from "@executor-js/sdk/shared"; +interface SpecFetchCredentials { + readonly headers?: Record; + readonly queryParams?: Record; +} + const DISCOVERY_SERVICE_HOST = "https://www.googleapis.com/discovery/v1/apis"; const GOOGLE_BUNDLE_BASE_URL = "https://www.googleapis.com/"; const GOOGLE_OAUTH_AUTHORIZATION_URL = "https://accounts.google.com/o/oauth2/v2/auth"; @@ -476,7 +479,7 @@ const discoveryScopes = (document: DiscoveryDocument): Record => // `userinfo.email` → `email`). Descriptions are preserved where the raw map had // them; compaction-introduced identity scopes (`email`/`profile`) fall back to // the broad parent's description. Per-operation `x-google-scopes`/`security` -// stay RAW — they describe which scope each method needs, not consent. +// stay RAW - they describe which scope each method needs, not consent. const compactDiscoveryScopeMap = (raw: Record): Record => { const descriptionFor = (scope: string): string => { if (raw[scope] !== undefined) return raw[scope]; @@ -615,7 +618,7 @@ const GOOGLE_OAUTH_SECURITY_SCHEME = "googleOAuth2"; * catalog-level template a connection's access token renders through. */ const googleOauthTemplate = (scopes: Record): readonly Authentication[] => // A Google-discovery integration is ALWAYS OAuth, so it ALWAYS declares its - // oauth method — even when the compacted scope set is empty (e.g. a bundle of + // oauth method - even when the compacted scope set is empty (e.g. a bundle of // only limited-consent APIs like Google Keep, whose scopes Google won't grant // through standard consent). Without this the integration declares no auth // method and the "Add account" / "Add a connection" actions are disabled, so diff --git a/packages/plugins/google/src/sdk/index.ts b/packages/plugins/google/src/sdk/index.ts new file mode 100644 index 0000000000..62e1d12a43 --- /dev/null +++ b/packages/plugins/google/src/sdk/index.ts @@ -0,0 +1,42 @@ +export { + convertGoogleDiscoveryBundleToOpenApi, + convertGoogleDiscoveryToOpenApi, + fetchGoogleDiscoveryDocument, + isGoogleDiscoveryUrl, + type GoogleDiscoveryOpenApiConversion, +} from "./discovery"; +export { + googleOpenApiBundlePreset, + googleOpenApiPresets, + googleStandardUserOAuthPresets, + googleOAuthConsentScopes, + googleOAuthConsentScopesForPreset, + googleAudienceWarningsForUrls, + googlePresetForDiscoveryUrl, + type GoogleOpenApiOAuthAudience, + type GoogleOpenApiPreset, + type GooglePreset, +} from "./presets"; +export { + compactGoogleOAuthScopes, + filterGoogleUserConsentOAuthScopes, + isGoogleUserConsentOAuthScope, +} from "./oauth-scopes"; +export { + googleOAuthConsentBatches, + type GoogleOAuthBatchInput, + type GoogleOAuthConsentBatch, +} from "./oauth-batches"; +export { + googlePlugin, + type GoogleBundleConfig, + type GoogleConfigureInput, + type GooglePluginExtension, + type GooglePluginOptions, + type GoogleUpdateInput, + type GoogleUpdateResult, +} from "./plugin"; +export { + googleOpenApiOwnershipDataMigration, + runSqliteGoogleOpenApiOwnershipMigration, +} from "./openapi-ownership-migration"; diff --git a/packages/plugins/openapi/src/sdk/google-oauth-batches.test.ts b/packages/plugins/google/src/sdk/oauth-batches.test.ts similarity index 97% rename from packages/plugins/openapi/src/sdk/google-oauth-batches.test.ts rename to packages/plugins/google/src/sdk/oauth-batches.test.ts index 7c7bce8119..5e8a3d5caf 100644 --- a/packages/plugins/openapi/src/sdk/google-oauth-batches.test.ts +++ b/packages/plugins/google/src/sdk/oauth-batches.test.ts @@ -1,6 +1,6 @@ import { expect, it } from "@effect/vitest"; -import { googleOAuthConsentBatches } from "./google-oauth-batches"; +import { googleOAuthConsentBatches } from "./oauth-batches"; it("keeps core Google OAuth in one consent batch", () => { expect( diff --git a/packages/plugins/openapi/src/sdk/google-oauth-batches.ts b/packages/plugins/google/src/sdk/oauth-batches.ts similarity index 93% rename from packages/plugins/openapi/src/sdk/google-oauth-batches.ts rename to packages/plugins/google/src/sdk/oauth-batches.ts index b1f341ed93..caa4f19dc5 100644 --- a/packages/plugins/openapi/src/sdk/google-oauth-batches.ts +++ b/packages/plugins/google/src/sdk/oauth-batches.ts @@ -1,5 +1,5 @@ -import type { GoogleOpenApiOAuthAudience } from "./google-presets"; -import { compactGoogleOAuthScopes } from "./google-oauth-scopes"; +import type { GoogleOpenApiOAuthAudience } from "./presets"; +import { compactGoogleOAuthScopes } from "./oauth-scopes"; export type GoogleOAuthBatchInput = { readonly id: string; diff --git a/packages/plugins/openapi/src/sdk/google-oauth-scopes.test.ts b/packages/plugins/google/src/sdk/oauth-scopes.test.ts similarity index 92% rename from packages/plugins/openapi/src/sdk/google-oauth-scopes.test.ts rename to packages/plugins/google/src/sdk/oauth-scopes.test.ts index 36da3e2c59..5118eb5b60 100644 --- a/packages/plugins/openapi/src/sdk/google-oauth-scopes.test.ts +++ b/packages/plugins/google/src/sdk/oauth-scopes.test.ts @@ -1,9 +1,6 @@ import { expect, it } from "@effect/vitest"; -import { - compactGoogleOAuthScopes, - filterGoogleUserConsentOAuthScopes, -} from "./google-oauth-scopes"; +import { compactGoogleOAuthScopes, filterGoogleUserConsentOAuthScopes } from "./oauth-scopes"; it("filters Google scopes that cannot be shown on a user OAuth consent screen", () => { expect( diff --git a/packages/plugins/openapi/src/sdk/google-oauth-scopes.ts b/packages/plugins/google/src/sdk/oauth-scopes.ts similarity index 100% rename from packages/plugins/openapi/src/sdk/google-oauth-scopes.ts rename to packages/plugins/google/src/sdk/oauth-scopes.ts diff --git a/packages/plugins/google/src/sdk/openapi-ownership-migration.test.ts b/packages/plugins/google/src/sdk/openapi-ownership-migration.test.ts new file mode 100644 index 0000000000..2d1daf1f4d --- /dev/null +++ b/packages/plugins/google/src/sdk/openapi-ownership-migration.test.ts @@ -0,0 +1,219 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Effect } from "effect"; +import { collectTables } from "@executor-js/sdk"; +import { createSqliteTestFumaDb } from "@executor-js/sdk/testing"; +import type { SqliteDataMigrationClient } from "@executor-js/sdk/core"; + +import { runSqliteGoogleOpenApiOwnershipMigration } from "./openapi-ownership-migration"; + +const now = 1_780_000_000_000; + +const insertIntegration = ( + client: SqliteDataMigrationClient, + row: { + readonly rowId: string; + readonly tenant: string; + readonly slug: string; + readonly pluginId: string; + readonly config: unknown; + }, +) => + client.execute({ + sql: `INSERT INTO integration + (row_id, tenant, slug, plugin_id, name, description, config, can_remove, can_refresh, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, 1, 1, ?, ?)`, + args: [ + row.rowId, + row.tenant, + row.slug, + row.pluginId, + row.slug, + row.slug, + JSON.stringify(row.config), + now, + now, + ], + }); + +const insertBlob = ( + client: SqliteDataMigrationClient, + row: { + readonly namespace: string; + readonly key: string; + readonly value: string; + }, +) => + client.execute({ + sql: "INSERT INTO blob (namespace, key, value, row_id, id) VALUES (?, ?, ?, ?, ?)", + args: [ + row.namespace, + row.key, + row.value, + `blob-${row.namespace}-${row.key}`, + JSON.stringify([row.namespace, row.key]), + ], + }); + +const insertOperationStorage = ( + client: SqliteDataMigrationClient, + row: { + readonly tenant: string; + readonly pluginId: string; + readonly integration: string; + readonly toolName: string; + }, +) => + client.execute({ + sql: `INSERT INTO plugin_storage + (tenant, owner, subject, plugin_id, collection, key, data, created_at, updated_at, row_id) + VALUES (?, 'org', '', ?, 'operation', ?, ?, ?, ?, ?)`, + args: [ + row.tenant, + row.pluginId, + `${row.integration}.${row.toolName}`, + JSON.stringify({ + integration: row.integration, + toolName: row.toolName, + binding: { method: "get", pathTemplate: "/items" }, + }), + now, + now, + `storage-${row.pluginId}-${row.integration}-${row.toolName}`, + ], + }); + +const insertTool = ( + client: SqliteDataMigrationClient, + row: { readonly tenant: string; readonly pluginId: string; readonly integration: string }, +) => + client.execute({ + sql: `INSERT INTO tool + (tenant, owner, subject, integration, connection, plugin_id, name, description, input_schema, output_schema, annotations, created_at, updated_at, row_id) + VALUES (?, 'org', '', ?, 'default', ?, 'items.list', 'List items', NULL, NULL, NULL, ?, ?, ?)`, + args: [row.tenant, row.integration, row.pluginId, now, now, `tool-${row.integration}`], + }); + +const insertDefinition = ( + client: SqliteDataMigrationClient, + row: { readonly tenant: string; readonly pluginId: string; readonly integration: string }, +) => + client.execute({ + sql: `INSERT INTO definition + (tenant, owner, subject, integration, connection, plugin_id, name, schema, created_at, row_id) + VALUES (?, 'org', '', ?, 'default', ?, 'Item', '{}', ?, ?)`, + args: [row.tenant, row.integration, row.pluginId, now, `definition-${row.integration}`], + }); + +describe("runSqliteGoogleOpenApiOwnershipMigration", () => { + it.effect("moves OpenAPI-owned Google bundle rows to the Google plugin", () => + Effect.gen(function* () { + const db = yield* Effect.promise(() => createSqliteTestFumaDb({ tables: collectTables() })); + const client = db.client; + + yield* Effect.promise(() => + insertIntegration(client, { + rowId: "google-row", + tenant: "org_1", + slug: "google", + pluginId: "openapi", + config: { + specHash: "googlehash", + googleDiscoveryUrls: ["https://www.googleapis.com/discovery/v1/apis/calendar/v3/rest"], + }, + }), + ); + yield* Effect.promise(() => + insertIntegration(client, { + rowId: "stripe-row", + tenant: "org_1", + slug: "stripe", + pluginId: "openapi", + config: { specHash: "stripehash", sourceUrl: "https://stripe.example/openapi.json" }, + }), + ); + yield* Effect.promise(() => + insertBlob(client, { + namespace: "o:org_1/openapi", + key: "spec/googlehash", + value: "google spec", + }), + ); + yield* Effect.promise(() => + insertBlob(client, { + namespace: "o:org_1/openapi", + key: "spec/stripehash", + value: "stripe spec", + }), + ); + + for (const integration of ["google", "stripe"] as const) { + yield* Effect.promise(() => + insertOperationStorage(client, { + tenant: "org_1", + pluginId: "openapi", + integration, + toolName: "items.list", + }), + ); + yield* Effect.promise(() => + insertTool(client, { tenant: "org_1", pluginId: "openapi", integration }), + ); + yield* Effect.promise(() => + insertDefinition(client, { tenant: "org_1", pluginId: "openapi", integration }), + ); + } + + expect(yield* runSqliteGoogleOpenApiOwnershipMigration(client)).toBe(1); + expect(yield* runSqliteGoogleOpenApiOwnershipMigration(client)).toBe(0); + + const integrations = yield* Effect.promise(() => + client.execute("SELECT slug, plugin_id FROM integration ORDER BY slug"), + ); + expect(integrations.rows).toEqual([ + { slug: "google", plugin_id: "google" }, + { slug: "stripe", plugin_id: "openapi" }, + ]); + + const googleBlob = yield* Effect.promise(() => + client.execute({ + sql: "SELECT value FROM blob WHERE id = ?", + args: [JSON.stringify(["o:org_1/google", "spec/googlehash"])], + }), + ); + expect(googleBlob.rows).toEqual([{ value: "google spec" }]); + + const openApiBlobs = yield* Effect.promise(() => + client.execute("SELECT key FROM blob WHERE namespace = 'o:org_1/openapi' ORDER BY key"), + ); + expect(openApiBlobs.rows).toEqual([{ key: "spec/googlehash" }, { key: "spec/stripehash" }]); + + const storage = yield* Effect.promise(() => + client.execute( + "SELECT plugin_id, key FROM plugin_storage WHERE collection = 'operation' ORDER BY plugin_id, key", + ), + ); + expect(storage.rows).toEqual([ + { plugin_id: "google", key: "google.items.list" }, + { plugin_id: "openapi", key: "stripe.items.list" }, + ]); + + const tools = yield* Effect.promise(() => + client.execute("SELECT integration, plugin_id FROM tool ORDER BY integration"), + ); + expect(tools.rows).toEqual([ + { integration: "google", plugin_id: "google" }, + { integration: "stripe", plugin_id: "openapi" }, + ]); + + const definitions = yield* Effect.promise(() => + client.execute("SELECT integration, plugin_id FROM definition ORDER BY integration"), + ); + expect(definitions.rows).toEqual([ + { integration: "google", plugin_id: "google" }, + { integration: "stripe", plugin_id: "openapi" }, + ]); + + yield* Effect.promise(() => db.close()); + }), + ); +}); diff --git a/packages/plugins/google/src/sdk/openapi-ownership-migration.ts b/packages/plugins/google/src/sdk/openapi-ownership-migration.ts new file mode 100644 index 0000000000..da44ef9250 --- /dev/null +++ b/packages/plugins/google/src/sdk/openapi-ownership-migration.ts @@ -0,0 +1,148 @@ +import { Effect } from "effect"; +import { DataMigrationError, type SqliteDataMigrationClient } from "@executor-js/sdk/core"; + +const MIGRATION_NAME = "2026-06-20-google-openapi-ownership"; +const googleOpenApiCandidate = (alias?: string): string => { + const column = (name: string) => (alias ? `${alias}.${name}` : name); + return `${column("plugin_id")} = 'openapi' AND ${column("config")} IS NOT NULL AND json_type(${column("config")}, '$.googleDiscoveryUrls') = 'array'`; +}; + +const execute = ( + client: SqliteDataMigrationClient, + stmt: string | { readonly sql: string; readonly args: readonly unknown[] }, +) => + Effect.tryPromise({ + try: () => client.execute(stmt), + catch: (cause) => new DataMigrationError({ migration: MIGRATION_NAME, cause }), + }); + +export const runSqliteGoogleOpenApiOwnershipMigration = ( + client: SqliteDataMigrationClient, +): Effect.Effect => + Effect.gen(function* () { + const exists = yield* execute( + client, + "SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'integration'", + ); + if (exists.rows.length === 0) return 0; + + const count = yield* execute( + client, + `SELECT COUNT(*) AS count FROM integration WHERE ${googleOpenApiCandidate()}`, + ); + const moved = Number(count.rows[0]?.count ?? 0); + if (moved === 0) return 0; + + const applyAll = Effect.gen(function* () { + yield* execute( + client, + `INSERT OR IGNORE INTO blob (namespace, key, value, row_id, id) + SELECT + 'o:' || g.tenant || '/google', + b.key, + b.value, + lower(hex(randomblob(16))), + json_array('o:' || g.tenant || '/google', b.key) + FROM integration g + JOIN blob b + ON b.namespace = 'o:' || g.tenant || '/openapi' + AND b.key = 'spec/' || json_extract(g.config, '$.specHash') + WHERE ${googleOpenApiCandidate("g")} + AND json_extract(g.config, '$.specHash') IS NOT NULL + AND json_extract(g.config, '$.specHash') <> ''`, + ); + + yield* execute( + client, + `INSERT OR IGNORE INTO plugin_storage + (tenant, owner, subject, plugin_id, collection, key, data, created_at, updated_at, row_id) + SELECT + ps.tenant, + ps.owner, + ps.subject, + 'google', + ps.collection, + ps.key, + ps.data, + ps.created_at, + ps.updated_at, + lower(hex(randomblob(16))) + FROM plugin_storage ps + JOIN integration g + ON g.tenant = ps.tenant + WHERE ps.plugin_id = 'openapi' + AND ${googleOpenApiCandidate("g")} + AND ps.collection = 'operation' + AND ( + json_extract(ps.data, '$.integration') = g.slug + OR ps.key LIKE g.slug || '.%' + )`, + ); + + yield* execute( + client, + `DELETE FROM plugin_storage + WHERE plugin_id = 'openapi' + AND collection = 'operation' + AND EXISTS ( + SELECT 1 + FROM integration g + WHERE g.tenant = plugin_storage.tenant + AND ${googleOpenApiCandidate("g")} + AND ( + json_extract(plugin_storage.data, '$.integration') = g.slug + OR plugin_storage.key LIKE g.slug || '.%' + ) + )`, + ); + + yield* execute( + client, + `UPDATE tool + SET plugin_id = 'google' + WHERE plugin_id = 'openapi' + AND EXISTS ( + SELECT 1 + FROM integration g + WHERE g.tenant = tool.tenant + AND ${googleOpenApiCandidate("g")} + AND g.slug = tool.integration + )`, + ); + + yield* execute( + client, + `UPDATE definition + SET plugin_id = 'google' + WHERE plugin_id = 'openapi' + AND EXISTS ( + SELECT 1 + FROM integration g + WHERE g.tenant = definition.tenant + AND ${googleOpenApiCandidate("g")} + AND g.slug = definition.integration + )`, + ); + + yield* execute( + client, + `UPDATE integration + SET plugin_id = 'google' + WHERE ${googleOpenApiCandidate()}`, + ); + + yield* execute(client, "COMMIT"); + return moved; + }); + + yield* execute(client, "BEGIN"); + return yield* applyAll.pipe( + Effect.tapError(() => execute(client, "ROLLBACK").pipe(Effect.ignore)), + ); + }); + +export const googleOpenApiOwnershipDataMigration = { + name: MIGRATION_NAME, + run: (client: SqliteDataMigrationClient) => + runSqliteGoogleOpenApiOwnershipMigration(client).pipe(Effect.asVoid), +}; diff --git a/packages/plugins/openapi/src/sdk/google-bundle.test.ts b/packages/plugins/google/src/sdk/plugin.test.ts similarity index 75% rename from packages/plugins/openapi/src/sdk/google-bundle.test.ts rename to packages/plugins/google/src/sdk/plugin.test.ts index 13d93977d4..e4266cc78a 100644 --- a/packages/plugins/openapi/src/sdk/google-bundle.test.ts +++ b/packages/plugins/google/src/sdk/plugin.test.ts @@ -1,15 +1,14 @@ // --------------------------------------------------------------------------- -// Google bundle add flow — Option A ("customize your Google connection"). +// Google bundle add flow, "customize your Google connection". // -// The product picker emits `{ kind: "googleDiscoveryBundle", urls }`; the -// server fetches each Discovery document, merges them into ONE `google` +// The product picker emits a URL list. The server fetches each Discovery +// document, merges them into ONE `google` // integration spec, and stores the unioned `googleOAuth2` auth template. These // tests exercise that path end-to-end against a stubbed Discovery host: // - a 3-API bundle (calendar + gmail + drive) produces a single `google` // integration whose merged tools carry NO name collisions (each method id // is service-prefixed) even when two APIs share a generic method name; -// - the stored oauth template carries the UNION of every API's scopes; -// - the per-API single-preset path (`kind: "googleDiscovery"`) is unchanged. +// - the stored oauth template carries the UNION of every API's scopes. // --------------------------------------------------------------------------- import { describe, expect, it } from "@effect/vitest"; @@ -24,7 +23,7 @@ import { } from "@executor-js/sdk"; import { makeTestConfig, memoryCredentialsPlugin } from "@executor-js/sdk/testing"; -import { openApiPlugin } from "./plugin"; +import { googlePlugin } from "./plugin"; // --- Canned Discovery documents ------------------------------------------- // Each carries one method. Calendar and Gmail BOTH expose a generic `list` @@ -89,7 +88,7 @@ const gmailDoc = { resources: { messages: { methods: { - // Same trailing `list` as calendar.events.list — would collide on a + // Same trailing `list` as calendar.events.list - would collide on a // naive merge; service-prefixed method id keeps them distinct. list: { id: "gmail.users.messages.list", @@ -126,7 +125,7 @@ const driveDoc = { resources: { files: { methods: { - // A third `list` — three generic method names across three APIs. + // A third `list` - three generic method names across three APIs. list: { id: "drive.files.list", httpMethod: "GET", @@ -172,39 +171,33 @@ const discoveryHttpClientLayer = Layer.succeed(HttpClient.HttpClient)( ); const bundlePlugins = () => - [ - openApiPlugin({ httpClientLayer: discoveryHttpClientLayer }), - memoryCredentialsPlugin(), - ] as const; + [googlePlugin({ httpClientLayer: discoveryHttpClientLayer }), memoryCredentialsPlugin()] as const; describe("Google bundle add flow", () => { it.effect( - "addSpec(googleDiscoveryBundle) merges calendar+gmail+drive into one google integration with no tool-name collisions", + "addBundle merges calendar+gmail+drive into one google integration with no tool-name collisions", () => Effect.scoped( Effect.gen(function* () { const executor = yield* createExecutor(makeTestConfig({ plugins: bundlePlugins() })); - const result = yield* executor.openapi.addSpec({ - spec: { - kind: "googleDiscoveryBundle", - urls: [CALENDAR_URL, GMAIL_URL, DRIVE_URL], - }, + const result = yield* executor.google.addBundle({ + urls: [CALENDAR_URL, GMAIL_URL, DRIVE_URL], slug: "google", description: "Google", }); expect(String(result.slug)).toBe("google"); // ONE integration, not three. - const integration = yield* executor.openapi.getIntegration("google"); + const integration = yield* executor.google.getIntegration("google"); expect(integration?.slug).toBe(IntegrationSlug.make("google")); // The stored oauth template carries the COMPACTED union of every API's - // scopes — the same set the picker previews and `oauth.start` requests. + // scopes - the same set the picker previews and `oauth.start` requests. // `calendar.readonly` collapses under `calendar`, and `gmail.readonly` // collapses under `https://mail.google.com/`, so the requested consent // is clean rather than the raw per-method union. - const config = yield* executor.openapi.getConfig("google"); + const config = yield* executor.google.getConfig("google"); const oauth = config?.authenticationTemplate?.find((entry) => entry.kind === "oauth2"); expect(oauth?.kind === "oauth2" ? [...oauth.scopes].sort() : undefined).toEqual( [ @@ -236,35 +229,4 @@ describe("Google bundle add flow", () => { }), ), ); - - it.effect( - "single-API googleDiscovery path is unchanged (one integration, service-trimmed tools)", - () => - Effect.scoped( - Effect.gen(function* () { - const executor = yield* createExecutor(makeTestConfig({ plugins: bundlePlugins() })); - - const result = yield* executor.openapi.addSpec({ - spec: { kind: "googleDiscovery", url: CALENDAR_URL }, - slug: "google-calendar", - description: "Google Calendar", - }); - expect(String(result.slug)).toBe("google-calendar"); - - yield* executor.connections.create({ - owner: "org", - name: ConnectionName.make("main"), - integration: IntegrationSlug.make("google-calendar"), - template: AuthTemplateSlug.make("googleOAuth2"), - value: "token-xyz", - }); - - // The single-API path trims the service prefix off the tool name - // (`events.list`, not `calendar.events.list`) — distinct from the bundle. - const toolNames = (yield* executor.tools.list()).map((tool) => String(tool.name)); - expect(toolNames).toContain("events.list"); - expect(toolNames).not.toContain("calendar.events.list"); - }), - ), - ); }); diff --git a/packages/plugins/google/src/sdk/plugin.ts b/packages/plugins/google/src/sdk/plugin.ts new file mode 100644 index 0000000000..fdeb934223 --- /dev/null +++ b/packages/plugins/google/src/sdk/plugin.ts @@ -0,0 +1,349 @@ +import { Effect } from "effect"; +import type { Layer } from "effect"; +import { HttpClient } from "effect/unstable/http"; + +import { + IntegrationAlreadyExistsError, + IntegrationDetectionResult, + IntegrationNotFoundError, + IntegrationSlug, + definePlugin, + mergeAuthTemplates, + sha256Hex, + type AuthMethodDescriptor, + type Integration, + type IntegrationConfig, + type IntegrationRecord, + type PluginCtx, +} from "@executor-js/sdk/core"; +import { describeApiKeyAuthMethod } from "@executor-js/sdk/http-auth"; +import { + compileOpenApiSpec, + invokeOpenApiBackedTool, + makeDefaultOpenapiStore, + normalizeOpenApiAuthInputs, + openApiStoredOperationsFromCompiled, + resolveOpenApiBackedAnnotations, + resolveOpenApiBackedTools, + type Authentication, + type AuthenticationInput, + type OpenapiStore, +} from "@executor-js/plugin-openapi"; + +import { + convertGoogleDiscoveryBundleToOpenApi, + fetchGoogleDiscoveryDocument, + isGoogleDiscoveryUrl, +} from "./discovery"; +import { decodeGoogleIntegrationConfig, type GoogleIntegrationConfig } from "./config"; +import { googleOpenApiBundlePreset } from "./presets"; + +export interface GoogleBundleConfig { + readonly urls: readonly string[]; + readonly slug?: string; + readonly name?: string; + readonly description?: string; + readonly baseUrl?: string; +} + +export interface GoogleConfigureInput { + readonly authenticationTemplate: readonly AuthenticationInput[]; + readonly mode?: "merge" | "replace"; +} + +export interface GoogleUpdateInput { + readonly urls?: readonly string[]; +} + +export interface GoogleUpdateResult { + readonly slug: IntegrationSlug; + readonly toolCount: number; + readonly addedTools: readonly string[]; + readonly removedTools: readonly string[]; +} + +export interface GooglePluginOptions { + readonly httpClientLayer?: Layer.Layer; +} + +const DEFAULT_GOOGLE_SLUG = "google"; + +const fetchGoogleBundleConversion = ( + urls: readonly string[], + httpClientLayer: Layer.Layer, +) => + Effect.forEach( + urls, + (url) => + fetchGoogleDiscoveryDocument(url).pipe( + Effect.provide(httpClientLayer), + Effect.map((documentText) => ({ discoveryUrl: url, documentText })), + ), + { concurrency: 4 }, + ).pipe(Effect.flatMap((documents) => convertGoogleDiscoveryBundleToOpenApi({ documents }))); + +const uniqueUrls = (urls: readonly string[]): readonly string[] => [ + ...new Set(urls.map((url) => url.trim()).filter((url) => url.length > 0)), +]; + +const describeGoogleAuthMethods = (record: IntegrationRecord): readonly AuthMethodDescriptor[] => { + const config = decodeGoogleIntegrationConfig(record.config); + if (!config) return []; + return (config.authenticationTemplate ?? []).map( + (template: Authentication): AuthMethodDescriptor => { + if (template.kind === "oauth2") { + return { + id: String(template.slug), + label: "OAuth2", + kind: "oauth", + template: String(template.slug), + oauth: { + authorizationUrl: template.authorizationUrl, + tokenUrl: template.tokenUrl, + scopes: template.scopes, + }, + }; + } + return describeApiKeyAuthMethod(template); + }, + ); +}; + +const describeGoogleIntegrationDisplay = (record: IntegrationRecord): { readonly url?: string } => { + const config = decodeGoogleIntegrationConfig(record.config); + return { url: config?.baseUrl ?? config?.googleDiscoveryUrls?.[0] }; +}; + +const makeGooglePluginExtension = ( + options: GooglePluginOptions | undefined, + ctx: PluginCtx, +) => { + const httpClientLayer = options?.httpClientLayer ?? ctx.httpClientLayer; + + const addBundle = (config: GoogleBundleConfig) => + Effect.gen(function* () { + const urls = uniqueUrls(config.urls); + const conversion = yield* fetchGoogleBundleConversion(urls, httpClientLayer); + const compiled = yield* compileOpenApiSpec(conversion.specText); + const slug = IntegrationSlug.make(config.slug?.trim() || DEFAULT_GOOGLE_SLUG); + + const existing = yield* ctx.core.integrations.get(slug); + if (existing) { + return yield* new IntegrationAlreadyExistsError({ slug }); + } + + const specHash = yield* sha256Hex(conversion.specText); + const integrationConfig: GoogleIntegrationConfig = { + specHash, + googleDiscoveryUrls: urls, + ...(config.baseUrl ? { baseUrl: config.baseUrl } : {}), + ...(conversion.authenticationTemplate + ? { authenticationTemplate: conversion.authenticationTemplate } + : {}), + }; + + yield* ctx.storage.putSpec(specHash, conversion.specText); + yield* ctx.storage.putDefs(specHash, JSON.stringify(compiled.hoistedDefs)); + + yield* ctx.transaction( + Effect.gen(function* () { + yield* ctx.core.integrations.register({ + slug, + name: config.name?.trim() || "Google", + description: config.description ?? "Google APIs", + config: integrationConfig satisfies GoogleIntegrationConfig as IntegrationConfig, + canRemove: true, + canRefresh: true, + }); + yield* ctx.storage.putOperations( + String(slug), + openApiStoredOperationsFromCompiled(String(slug), compiled), + ); + }), + ); + + return { slug, toolCount: compiled.definitions.length }; + }); + + const updateBundle = (rawSlug: string, input?: GoogleUpdateInput) => + Effect.gen(function* () { + const slug = IntegrationSlug.make(rawSlug); + const record = yield* ctx.core.integrations.get(slug); + const current = record ? decodeGoogleIntegrationConfig(record.config) : null; + if (!record || !current) { + return yield* new IntegrationNotFoundError({ slug }); + } + + const urls = uniqueUrls(input?.urls ?? current.googleDiscoveryUrls ?? []); + const conversion = yield* fetchGoogleBundleConversion(urls, httpClientLayer); + const compiled = yield* compileOpenApiSpec(conversion.specText); + + const previousOperations = yield* ctx.storage.listOperations(rawSlug); + const previousNames = new Set(previousOperations.map((op) => op.toolName)); + const nextNames = new Set(compiled.definitions.map((def) => def.toolPath)); + + const specHash = yield* sha256Hex(conversion.specText); + yield* ctx.storage.putSpec(specHash, conversion.specText); + yield* ctx.storage.putDefs(specHash, JSON.stringify(compiled.hoistedDefs)); + + const nextConfig: GoogleIntegrationConfig = { + ...current, + specHash, + googleDiscoveryUrls: urls, + }; + + yield* ctx.transaction( + Effect.gen(function* () { + yield* ctx.core.integrations.update(slug, { + config: nextConfig satisfies GoogleIntegrationConfig as IntegrationConfig, + }); + yield* ctx.storage.putOperations( + rawSlug, + openApiStoredOperationsFromCompiled(rawSlug, compiled), + ); + }), + ); + + const connections = yield* ctx.connections.list({ integration: slug }); + yield* Effect.forEach( + connections, + (connection) => + ctx.connections + .refresh({ + owner: connection.owner, + integration: connection.integration, + name: connection.name, + }) + .pipe(Effect.catchTag("ConnectionNotFoundError", () => Effect.succeed([]))), + { discard: true }, + ).pipe(Effect.catchTag("IntegrationNotFoundError", () => Effect.void)); + + return { + slug, + toolCount: compiled.definitions.length, + addedTools: [...nextNames].filter((name) => !previousNames.has(name)).sort(), + removedTools: [...previousNames].filter((name) => !nextNames.has(name)).sort(), + }; + }); + + return { + addBundle, + updateBundle, + removeBundle: (slug: string) => + ctx.transaction( + Effect.gen(function* () { + yield* ctx.storage.removeOperations(slug); + yield* ctx.core.integrations + .remove(IntegrationSlug.make(slug)) + .pipe(Effect.catchTag("IntegrationRemovalNotAllowedError", () => Effect.void)); + }), + ), + getIntegration: (slug: string) => + ctx.core.integrations.get(IntegrationSlug.make(slug)).pipe( + Effect.map((record) => + record + ? ({ + slug: record.slug, + description: record.description, + kind: record.kind, + canRemove: record.canRemove, + canRefresh: record.canRefresh, + } as Integration) + : null, + ), + ), + getConfig: (slug: string) => + ctx.core.integrations + .get(IntegrationSlug.make(slug)) + .pipe( + Effect.map((record) => (record ? decodeGoogleIntegrationConfig(record.config) : null)), + ), + configure: (slug: string, input: GoogleConfigureInput) => + ctx.transaction( + Effect.gen(function* () { + const record = yield* ctx.core.integrations.get(IntegrationSlug.make(slug)); + if (!record) return [] as readonly Authentication[]; + const current = decodeGoogleIntegrationConfig(record.config); + if (!current) return [] as readonly Authentication[]; + + const incoming = normalizeOpenApiAuthInputs(input.authenticationTemplate); + const merged = + input.mode === "replace" + ? incoming + : mergeAuthTemplates(current.authenticationTemplate ?? [], incoming); + + const next: GoogleIntegrationConfig = { + ...current, + authenticationTemplate: merged, + }; + + yield* ctx.core.integrations.update(IntegrationSlug.make(slug), { + config: next satisfies GoogleIntegrationConfig as IntegrationConfig, + }); + + return merged; + }), + ), + }; +}; + +export type GooglePluginExtension = ReturnType; + +export const googlePlugin = definePlugin((options?: GooglePluginOptions) => ({ + id: "google" as const, + packageName: "@executor-js/plugin-google", + integrationPresets: [googleOpenApiBundlePreset], + storage: (deps): OpenapiStore => makeDefaultOpenapiStore(deps), + + extension: (ctx: PluginCtx) => makeGooglePluginExtension(options, ctx), + + describeAuthMethods: describeGoogleAuthMethods, + describeIntegrationDisplay: describeGoogleIntegrationDisplay, + + resolveTools: ({ integration, config, storage }) => + resolveOpenApiBackedTools({ integration, config, storage }), + + invokeTool: ({ ctx, toolRow, credential, args }) => { + const httpClientLayer = options?.httpClientLayer ?? ctx.httpClientLayer; + return invokeOpenApiBackedTool({ + ctx, + toolRow, + credential, + args, + httpClientLayer, + }); + }, + + resolveAnnotations: ({ ctx, integration, toolRows }) => + resolveOpenApiBackedAnnotations({ + ctx, + integration: String(integration), + toolRows, + }), + + removeConnection: () => Effect.void, + + detect: ({ ctx, url }) => + Effect.gen(function* () { + const trimmed = url.trim(); + if (!trimmed || !isGoogleDiscoveryUrl(trimmed)) return null; + const httpClientLayer = options?.httpClientLayer ?? ctx.httpClientLayer; + const conversion = yield* fetchGoogleDiscoveryDocument(trimmed).pipe( + Effect.provide(httpClientLayer), + Effect.flatMap((documentText) => + convertGoogleDiscoveryBundleToOpenApi({ + documents: [{ discoveryUrl: trimmed, documentText }], + }), + ), + Effect.catch(() => Effect.succeed(null)), + ); + if (!conversion) return null; + return IntegrationDetectionResult.make({ + kind: "google", + confidence: "high", + endpoint: trimmed, + name: conversion.title, + slug: DEFAULT_GOOGLE_SLUG, + }); + }), +})); diff --git a/packages/plugins/openapi/src/sdk/google-presets.test.ts b/packages/plugins/google/src/sdk/presets.test.ts similarity index 97% rename from packages/plugins/openapi/src/sdk/google-presets.test.ts rename to packages/plugins/google/src/sdk/presets.test.ts index 0df9c9f158..4f7f212978 100644 --- a/packages/plugins/openapi/src/sdk/google-presets.test.ts +++ b/packages/plugins/google/src/sdk/presets.test.ts @@ -1,6 +1,6 @@ import { expect, it } from "@effect/vitest"; -import { googleOpenApiPresets, googleStandardUserOAuthPresets } from "./google-presets"; +import { googleOpenApiPresets, googleStandardUserOAuthPresets } from "./presets"; it("keeps Select all limited to Google services that can use normal user OAuth", () => { const standardIds = new Set(googleStandardUserOAuthPresets.map((preset) => preset.id)); diff --git a/packages/plugins/openapi/src/sdk/google-presets.ts b/packages/plugins/google/src/sdk/presets.ts similarity index 95% rename from packages/plugins/openapi/src/sdk/google-presets.ts rename to packages/plugins/google/src/sdk/presets.ts index b525718581..992dfc3275 100644 --- a/packages/plugins/openapi/src/sdk/google-presets.ts +++ b/packages/plugins/google/src/sdk/presets.ts @@ -1,4 +1,11 @@ -import type { OpenApiPreset } from "./presets"; +export interface GooglePreset { + readonly id: string; + readonly name: string; + readonly summary: string; + readonly url?: string; + readonly icon?: string; + readonly featured?: boolean; +} export type GoogleOpenApiOAuthAudience = | "standard-user" @@ -6,7 +13,7 @@ export type GoogleOpenApiOAuthAudience = | "workspace-admin" | "unsupported-user"; -export type GoogleOpenApiPreset = OpenApiPreset & { +export type GoogleOpenApiPreset = GooglePreset & { readonly oauthAudience: GoogleOpenApiOAuthAudience; }; @@ -16,7 +23,7 @@ const gd = (service: string, version: string) => const GOOGLE_G = "https://fonts.gstatic.com/s/i/productlogos/googleg/v6/192px.svg"; export const GOOGLE_BUNDLE_PRESET_ID = "google"; -export const googleOpenApiBundlePreset: OpenApiPreset = { +export const googleOpenApiBundlePreset: GooglePreset = { id: GOOGLE_BUNDLE_PRESET_ID, name: "Google", summary: "Bundle Gmail, Calendar, Drive, Docs, and other Google APIs into one source.", @@ -193,8 +200,8 @@ export const googleStandardUserOAuthPresets = googleOpenApiPresets.filter( // // The picker shows the OAuth consent a user is about to grant BEFORE connecting // (the "View scopes" panel), but the authoritative scope list only exists in -// each API's live Discovery document — which the add flow fetches lazily at -// `addSpec` time. To preview consent without N network round-trips, each preset +// each API's live Discovery document, which the add flow fetches lazily at +// `addBundle` time. To preview consent without N network round-trips, each preset // declares the broad top-level scope(s) a full integration grants. These flow // through `googleOAuthConsentBatches` (which compacts sub-scopes under their // broad parent), so the previewed grant matches the unioned scopes the bundle @@ -253,7 +260,7 @@ export const googlePresetForDiscoveryUrl = (url: string): GoogleOpenApiPreset | googlePresetsByNormalizedUrl.get(normalizeGooglePresetUrl(url)); /** The distinct caution-tier audiences (`workspace-admin`, `unsupported-user`) - * among the supplied Discovery URLs — the ones whose consent the user should be + * among the supplied Discovery URLs - the ones whose consent the user should be * warned about. Returns `[]` when every URL is a standard/advanced API. */ export const googleAudienceWarningsForUrls = ( urls: readonly string[], diff --git a/packages/plugins/openapi/src/sdk/google-product-picker-scopes.test.ts b/packages/plugins/google/src/sdk/product-picker-scopes.test.ts similarity index 96% rename from packages/plugins/openapi/src/sdk/google-product-picker-scopes.test.ts rename to packages/plugins/google/src/sdk/product-picker-scopes.test.ts index f0a5dbeb6c..e3d8a2c355 100644 --- a/packages/plugins/openapi/src/sdk/google-product-picker-scopes.test.ts +++ b/packages/plugins/google/src/sdk/product-picker-scopes.test.ts @@ -17,9 +17,9 @@ import { googleOpenApiPresets, googlePresetForDiscoveryUrl, type GoogleOpenApiPreset, -} from "./google-presets"; -import { googleOAuthConsentBatches } from "./google-oauth-batches"; -import { compactGoogleOAuthScopes } from "./google-oauth-scopes"; +} from "./presets"; +import { googleOAuthConsentBatches } from "./oauth-batches"; +import { compactGoogleOAuthScopes } from "./oauth-scopes"; const batchInputsFor = (presetIds: readonly string[]) => googleOpenApiPresets diff --git a/packages/plugins/google/tsconfig.json b/packages/plugins/google/tsconfig.json new file mode 100644 index 0000000000..9517183258 --- /dev/null +++ b/packages/plugins/google/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "skipLibCheck": true, + "lib": ["ES2022", "DOM"], + "types": ["bun-types", "node"], + "noUnusedLocals": true, + "noImplicitOverride": true, + "jsx": "react-jsx", + "plugins": [ + { + "name": "@effect/language-service", + "ignoreEffectSuggestionsInTscExitCode": true, + "ignoreEffectWarningsInTscExitCode": true, + "diagnosticSeverity": {} + } + ] + }, + "include": ["src/**/*.ts", "src/**/*.tsx"], + "exclude": ["src/promise.ts"] +} diff --git a/packages/plugins/google/tsup.config.ts b/packages/plugins/google/tsup.config.ts new file mode 100644 index 0000000000..0b6a796fed --- /dev/null +++ b/packages/plugins/google/tsup.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from "tsup"; + +export default defineConfig({ + entry: { + index: "src/promise.ts", + core: "src/sdk/index.ts", + client: "src/react/plugin-client.tsx", + }, + format: ["esm"], + dts: false, + sourcemap: true, + clean: true, + external: [/^@executor-js\//, /^effect/, /^@effect\//], +}); diff --git a/packages/plugins/google/vitest.config.ts b/packages/plugins/google/vitest.config.ts new file mode 100644 index 0000000000..52f5fd1a90 --- /dev/null +++ b/packages/plugins/google/vitest.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + // `.test.ts` only. React component tests are `.test.tsx` and need a DOM + // env, so they're excluded; the plugin-side converters are pure `.ts` + // logic and run here from `src/react/`. + include: ["src/**/*.test.ts"], + exclude: ["src/api/**", "src/react/**/*.test.tsx"], + testTimeout: 15_000, + }, +}); diff --git a/packages/plugins/graphql/CHANGELOG.md b/packages/plugins/graphql/CHANGELOG.md index a9481f4c44..46b67fc88e 100644 --- a/packages/plugins/graphql/CHANGELOG.md +++ b/packages/plugins/graphql/CHANGELOG.md @@ -1,5 +1,75 @@ # @executor-js/plugin-graphql +## 1.5.17 + +### Patch Changes + +- Updated dependencies []: + - @executor-js/sdk@1.5.17 + - @executor-js/config@1.5.17 + - @executor-js/api@1.4.37 + - @executor-js/react@1.4.37 + +## 1.5.16 + +### Patch Changes + +- Updated dependencies []: + - @executor-js/sdk@1.5.16 + - @executor-js/config@1.5.16 + - @executor-js/api@1.4.36 + - @executor-js/react@1.4.36 + +## 1.5.15 + +### Patch Changes + +- Updated dependencies []: + - @executor-js/sdk@1.5.15 + - @executor-js/api@1.4.35 + - @executor-js/config@1.5.15 + - @executor-js/react@1.4.35 + +## 1.5.14 + +### Patch Changes + +- Updated dependencies []: + - @executor-js/sdk@1.5.14 + - @executor-js/config@1.5.14 + - @executor-js/api@1.4.34 + - @executor-js/react@1.4.34 + +## 1.5.13 + +### Patch Changes + +- Updated dependencies []: + - @executor-js/api@1.4.33 + - @executor-js/react@1.4.33 + - @executor-js/sdk@1.5.13 + - @executor-js/config@1.5.13 + +## 1.5.12 + +### Patch Changes + +- Updated dependencies []: + - @executor-js/sdk@1.5.12 + - @executor-js/config@1.5.12 + - @executor-js/api@1.4.32 + - @executor-js/react@1.4.32 + +## 1.5.11 + +### Patch Changes + +- Updated dependencies []: + - @executor-js/sdk@1.5.11 + - @executor-js/config@1.5.11 + - @executor-js/api@1.4.31 + - @executor-js/react@1.4.31 + ## 1.5.10 ### Patch Changes diff --git a/packages/plugins/graphql/package.json b/packages/plugins/graphql/package.json index 87a23ce5e3..212e92b485 100644 --- a/packages/plugins/graphql/package.json +++ b/packages/plugins/graphql/package.json @@ -1,6 +1,6 @@ { "name": "@executor-js/plugin-graphql", - "version": "1.5.10", + "version": "1.5.17", "homepage": "https://github.com/RhysSullivan/executor/tree/main/packages/plugins/graphql", "bugs": { "url": "https://github.com/RhysSullivan/executor/issues" diff --git a/packages/plugins/graphql/src/api/group.ts b/packages/plugins/graphql/src/api/group.ts index 74fbf47796..363038ef5c 100644 --- a/packages/plugins/graphql/src/api/group.ts +++ b/packages/plugins/graphql/src/api/group.ts @@ -21,6 +21,7 @@ const AddIntegrationPayload = Schema.Struct({ endpoint: Schema.String, slug: Schema.optional(Schema.String), name: Schema.optional(Schema.String), + description: Schema.optional(Schema.String), introspectionJson: Schema.optional(Schema.String), headers: Schema.optional(Schema.Record(Schema.String, Schema.String)), queryParams: Schema.optional(Schema.Record(Schema.String, Schema.String)), diff --git a/packages/plugins/graphql/src/api/handlers.ts b/packages/plugins/graphql/src/api/handlers.ts index 1e7a8304e2..325924f0c4 100644 --- a/packages/plugins/graphql/src/api/handlers.ts +++ b/packages/plugins/graphql/src/api/handlers.ts @@ -40,6 +40,7 @@ export const GraphqlHandlers = HttpApiBuilder.group(ExecutorApiWithGraphql, "gra endpoint: payload.endpoint, slug: payload.slug, name: payload.name, + description: payload.description, introspectionJson: payload.introspectionJson, headers: payload.headers, queryParams: payload.queryParams, diff --git a/packages/plugins/graphql/src/react/AddGraphqlSource.tsx b/packages/plugins/graphql/src/react/AddGraphqlSource.tsx index 423bd9b169..d45065dce9 100644 --- a/packages/plugins/graphql/src/react/AddGraphqlSource.tsx +++ b/packages/plugins/graphql/src/react/AddGraphqlSource.tsx @@ -45,6 +45,7 @@ export default function AddGraphqlSource(props: { initialUrl?: string; }) { const [endpoint, setEndpoint] = useState(props.initialUrl ?? ""); + const [description, setDescription] = useState(""); const identity = useIntegrationIdentity({ fallbackName: integrationDisplayNameFromUrl(endpoint, "GraphQL") ?? "", }); @@ -111,6 +112,7 @@ export default function AddGraphqlSource(props: { endpoint: trimmedEndpoint, slug, name: displayName, + ...(description.trim().length > 0 ? { description: description.trim() } : {}), ...(authenticationTemplate.length > 0 ? { authenticationTemplate: [...authenticationTemplate] } : {}), @@ -131,7 +133,13 @@ export default function AddGraphqlSource(props: {

Add GraphQL Source

- + void; -}) { - const slug = IntegrationSlug.make(props.sourceId); - const configResult = useAtomValue(graphqlIntegrationConfigAtom(slug)); - const config = AsyncResult.isSuccess(configResult) - ? Option.getOrNull(decodeGraphqlIntegrationConfigOption(configResult.value)) - : null; - - if (!AsyncResult.isSuccess(configResult) || !config) { - return ( -
-

GraphQL Source

-

Loading configuration…

-
- ); - } - - return ( - - ); -} diff --git a/packages/plugins/graphql/src/react/GraphqlSourceFields.tsx b/packages/plugins/graphql/src/react/GraphqlSourceFields.tsx index 3a152c3e9f..4bb0b22df3 100644 --- a/packages/plugins/graphql/src/react/GraphqlSourceFields.tsx +++ b/packages/plugins/graphql/src/react/GraphqlSourceFields.tsx @@ -4,6 +4,7 @@ import { CardStackEntryField, } from "@executor-js/react/components/card-stack"; import { Input } from "@executor-js/react/components/input"; +import { Textarea } from "@executor-js/react/components/textarea"; import { IntegrationIdentityFieldRows, type IntegrationIdentity, @@ -13,6 +14,10 @@ export function GraphqlSourceFields(props: { readonly endpoint: string; readonly onEndpointChange: (endpoint: string) => void; readonly identity: IntegrationIdentity; + /** The integration's agent-visible description. Blank = the backend falls + * back to the introspected schema's own description, then the name. */ + readonly description?: string; + readonly onDescriptionChange?: (value: string) => void; readonly endpointDisabled?: boolean; readonly namespaceReadOnly?: boolean; }) { @@ -36,6 +41,21 @@ export function GraphqlSourceFields(props: { namePlaceholder="e.g. Shopify API" namespaceReadOnly={props.namespaceReadOnly} /> + {props.onDescriptionChange && ( + +