apps 1.0.0-beta.1 → 1.0.0-beta.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/README.md +3 -2
  2. package/dist/authoring/index.d.ts +222 -34
  3. package/dist/authoring/index.js +11 -1
  4. package/dist/{chunk-NFLNA2SC.js → chunk-4OZFMCTT.js} +7 -1
  5. package/dist/{chunk-LWKQDDLZ.js → chunk-A3H32P2J.js} +46 -1
  6. package/dist/chunk-F7GZYEAC.js +41 -0
  7. package/dist/chunk-MUFMJRXR.js +308 -0
  8. package/dist/chunk-S5KFP4JQ.js +20 -0
  9. package/dist/chunk-Y6LLTN7W.js +99 -0
  10. package/dist/chunk-YUZ4TXOY.js +27 -0
  11. package/dist/http/index.d.ts +9 -0
  12. package/dist/http/index.js +6 -0
  13. package/dist/index.d.ts +3 -8
  14. package/dist/index.js +23 -1
  15. package/dist/manifest/contract.d.ts +8 -0
  16. package/dist/manifest/index.d.ts +8 -6
  17. package/dist/manifest/index.js +3 -3
  18. package/dist/mcp/client.d.ts +9 -0
  19. package/dist/mcp/index.d.ts +11 -0
  20. package/dist/mcp/index.js +7 -0
  21. package/dist/openapi/index.d.ts +14 -0
  22. package/dist/openapi/index.js +7 -0
  23. package/dist/openapi/mapper.d.ts +7 -0
  24. package/dist/runner/index.js +36 -51
  25. package/dist/runner/protocol.d.ts +20 -15
  26. package/dist/runner/protocol.js +5 -3
  27. package/dist/runtime/index.d.ts +83 -0
  28. package/dist/runtime/index.js +12 -0
  29. package/dist/workerd/index.d.ts +7 -2
  30. package/dist/workerd/index.js +7 -3
  31. package/package.json +18 -7
  32. package/src/authoring/index.ts +362 -44
  33. package/src/http/index.ts +26 -0
  34. package/src/index.ts +3 -8
  35. package/src/manifest/contract.ts +9 -0
  36. package/src/manifest/index.ts +89 -157
  37. package/src/mcp/client.ts +59 -0
  38. package/src/mcp/index.ts +42 -0
  39. package/src/openapi/index.ts +70 -0
  40. package/src/openapi/mapper.ts +25 -0
  41. package/src/runner/index.ts +50 -87
  42. package/src/runner/protocol.ts +34 -21
  43. package/src/runtime/index.ts +550 -0
  44. package/src/workerd/index.ts +17 -2
  45. package/dist/chunk-J5AVXTHA.js +0 -154
  46. package/dist/chunk-RZA7AXBK.js +0 -107
  47. package/dist/manifest/collect.d.ts +0 -64
  48. package/src/manifest/collect.ts +0 -198
@@ -1,11 +1,13 @@
1
1
  /**
2
- * Manifest: execute an authored app folder, read its registry, validate
3
- * every claim, and emit the deterministic publish manifest. Defines run
4
- * without user context; platform services begin at this boundary.
2
+ * Manifest: execute an authored app folder and project its registry.
3
+ * This module owns exactly what differs on a host with a filesystem —
4
+ * finding and importing authored modules. Everything after the modules
5
+ * load (validation, canonicalization, digesting) is the shared
6
+ * execution core in ../runtime; sandboxed guests run that same core.
5
7
  */
6
8
 
7
9
  import { fileURLToPath, pathToFileURL } from "node:url";
8
- import { Crypto, Effect, FileSystem, Schema, Semaphore } from "effect";
10
+ import { Effect, FileSystem, MutableRef, Semaphore } from "effect";
9
11
  import {
10
12
  __enterFile,
11
13
  __leaveFile,
@@ -13,118 +15,51 @@ import {
13
15
  setActiveRegistry,
14
16
  type Registry,
15
17
  } from "../authoring/index";
16
- import { collectRegistry, type InputSchemaCompilation } from "./collect";
17
- import {
18
- JsonSchemaObject,
19
- PublishError,
20
- type AppManifestResult,
21
- type JsonSchemaObject as JsonSchemaObjectType,
22
- } from "./contract";
18
+ import { manifestOf } from "../runtime/index";
19
+ import { PublishError, type AppManifestResult } from "./contract";
23
20
 
24
21
  export * from "./contract";
25
22
 
26
- interface StandardJsonSchemaCapable {
27
- readonly "~standard": {
28
- readonly jsonSchema?: {
29
- readonly input: (options: { readonly target: string }) => unknown;
30
- };
31
- };
32
- }
33
-
34
- const hasStandardJsonSchema = (input: object): input is StandardJsonSchemaCapable =>
35
- "~standard" in input &&
36
- typeof (input as StandardJsonSchemaCapable)["~standard"].jsonSchema?.input === "function";
37
-
38
- const compileInputSchema = (
39
- input: object,
40
- where: string,
41
- ): InputSchemaCompilation => {
42
- if ("~standard" in input) {
43
- if (!hasStandardJsonSchema(input))
44
- return {
45
- problem: `${where}: input is a Standard Schema without the jsonSchema extension — use a library that implements it (zod 4+, effect Schema) or pass raw JSON schema`,
46
- };
47
- }
48
-
49
- try {
50
- const compiled = strictify(
51
- hasStandardJsonSchema(input)
52
- ? input["~standard"].jsonSchema!.input({ target: "draft-2020-12" })
53
- : structuredClone(input),
54
- );
55
- return { value: Schema.decodeUnknownSync(JsonSchemaObject)(compiled) as JsonSchemaObjectType };
56
- } catch (error) {
57
- return { problem: schemaPublishProblem(where, error) };
58
- }
59
- };
60
-
61
- const schemaPublishProblem = (where: string, error: unknown): string =>
62
- `${where}: input schema could not compile to JSON schema (${
63
- error instanceof Error ? error.message : String(error)
64
- })`;
65
-
66
- const strictify = (node: unknown): unknown => {
67
- if (typeof node !== "object" || node === null) return node;
68
- if (Array.isArray(node)) return node.map(strictify);
69
- const object = { ...(node as Record<string, unknown>) };
70
- if (object.type === "object" && object.additionalProperties === undefined)
71
- object.additionalProperties = false;
72
- for (const key of ["items", "anyOf", "oneOf", "allOf"])
73
- if (key in object) object[key] = strictify(object[key]);
74
- for (const key of ["properties", "$defs"])
75
- if (object[key] !== undefined && typeof object[key] === "object" && object[key] !== null)
76
- object[key] = Object.fromEntries(
77
- Object.entries(object[key] as Record<string, unknown>).map(([name, value]) => [
78
- name,
79
- strictify(value),
80
- ]),
81
- );
82
- return object;
83
- };
84
-
85
- const digestOf = (content: string): Effect.Effect<string, never, Crypto.Crypto> =>
86
- Effect.gen(function* () {
87
- const crypto = yield* Crypto.Crypto;
88
- const bytes = yield* Effect.orDie(
89
- crypto.digest("SHA-256", new TextEncoder().encode(content)),
90
- );
91
- return Array.from(bytes.slice(0, 8), (byte) => byte.toString(16).padStart(2, "0")).join("");
92
- });
23
+ const moduleProblem = (where: string, error: unknown): string =>
24
+ `${where}: failed to execute (${error instanceof Error ? error.message : String(error)})`;
93
25
 
26
+ /**
27
+ * Module execution is process-global twice over: the authoring
28
+ * registry is a singleton the imported modules write into, and the
29
+ * ESM module cache is shared. One permit serializes loads; the nonce
30
+ * and first-import memo make re-imports observable across runs.
31
+ * Module-scoped by necessity — this state IS process state.
32
+ */
94
33
  const loadPermit = Semaphore.makeUnsafe(1);
95
- let runNonce = 0;
96
- const baseAppImports = new Set<string>();
97
-
98
- const moduleFailure = (where: string, error: unknown): PublishError =>
99
- new PublishError({
100
- problems: [
101
- `${where}: failed to execute (${error instanceof Error ? error.message : String(error)})`,
102
- ],
103
- });
34
+ const runNonce = MutableRef.make(0);
35
+ const importedAppUrls = MutableRef.make<ReadonlySet<string>>(new Set());
104
36
 
105
37
  const canonicalModuleUrl = (
106
38
  path: string,
107
39
  where: string,
108
40
  ): Effect.Effect<URL, PublishError, FileSystem.FileSystem> =>
109
- Effect.gen(function* () {
110
- const fs = yield* FileSystem.FileSystem;
111
- const realPath = yield* fs.realPath(path).pipe(
112
- Effect.mapError((error) => moduleFailure(where, error)),
113
- );
114
- return pathToFileURL(realPath);
115
- });
41
+ FileSystem.FileSystem.pipe(
42
+ Effect.flatMap((fs) => fs.realPath(path)),
43
+ Effect.map(pathToFileURL),
44
+ Effect.mapError((error) => new PublishError({ problems: [moduleProblem(where, error)] })),
45
+ );
116
46
 
117
47
  const importModule = (specifier: string, where: string): Effect.Effect<void, PublishError> =>
118
48
  Effect.tryPromise({
119
- try: () => import(specifier).then(() => undefined),
120
- catch: (error) => moduleFailure(where, error),
121
- });
49
+ try: () => import(specifier),
50
+ catch: (error) => new PublishError({ problems: [moduleProblem(where, error)] }),
51
+ }).pipe(Effect.asVoid);
122
52
 
123
- const recordModuleProblems = (
53
+ /** Import one authored module, converting failure into registry problems. */
54
+ const executeModule = (
124
55
  registry: Registry,
125
- effect: Effect.Effect<void, PublishError, FileSystem.FileSystem>,
56
+ where: string,
57
+ load: Effect.Effect<void, PublishError, FileSystem.FileSystem>,
126
58
  ): Effect.Effect<void, never, FileSystem.FileSystem> =>
127
- effect.pipe(
59
+ Effect.suspend(() => {
60
+ __enterFile(where);
61
+ return load;
62
+ }).pipe(
128
63
  Effect.catchTag("PublishError", (error) =>
129
64
  Effect.sync(() => {
130
65
  registry.problems.push(...error.problems.map((message) => ({ message })));
@@ -132,58 +67,70 @@ const recordModuleProblems = (
132
67
  ),
133
68
  );
134
69
 
70
+ /**
71
+ * The app entry loads through its plain canonical URL the first time
72
+ * so relative imports resolve normally, and through a nonce query on
73
+ * every later run so edits are observed instead of served stale from
74
+ * the module cache.
75
+ */
76
+ const appEntrySpecifier = (appUrl: URL, nonce: number): string => {
77
+ const seen = MutableRef.get(importedAppUrls);
78
+ if (!seen.has(appUrl.href)) {
79
+ MutableRef.set(importedAppUrls, new Set([...seen, appUrl.href]));
80
+ return appUrl.href;
81
+ }
82
+ return `${fileURLToPath(appUrl)}?m=${nonce}`;
83
+ };
84
+
135
85
  const executeModules = (
136
86
  appDir: string,
137
87
  files: readonly string[],
138
88
  ): Effect.Effect<Registry, never, FileSystem.FileSystem> =>
139
89
  loadPermit.withPermit(
140
- Effect.uninterruptibleMask((restore) =>
141
- Effect.gen(function* () {
90
+ Effect.acquireUseRelease(
91
+ Effect.sync(() => {
142
92
  const registry = newRegistry();
143
- const previous = yield* Effect.sync(() => setActiveRegistry(registry));
144
- const nonce = yield* Effect.sync(() => ++runNonce);
145
-
146
- const execute = Effect.gen(function* () {
147
- __enterFile("app.ts");
148
- yield* recordModuleProblems(
93
+ return { registry, previous: setActiveRegistry(registry) };
94
+ }),
95
+ ({ registry }) =>
96
+ Effect.gen(function* () {
97
+ const nonce = MutableRef.incrementAndGet(runNonce);
98
+ yield* executeModule(
149
99
  registry,
150
- Effect.gen(function* () {
151
- const appUrl = yield* canonicalModuleUrl(`${appDir}/app.ts`, "app.ts");
152
- const baseUrl = appUrl.href;
153
- const specifier = baseAppImports.has(baseUrl)
154
- ? `${fileURLToPath(appUrl)}?m=${nonce}`
155
- : baseUrl;
156
- yield* importModule(specifier, "app.ts");
157
- yield* Effect.sync(() => baseAppImports.add(baseUrl));
158
- }),
100
+ "app.ts",
101
+ canonicalModuleUrl(`${appDir}/app.ts`, "app.ts").pipe(
102
+ Effect.flatMap((appUrl) => importModule(appEntrySpecifier(appUrl, nonce), "app.ts")),
103
+ ),
159
104
  );
160
-
161
105
  for (const file of files) {
162
106
  const where = `tools/${file}`;
163
- __enterFile(where);
164
- yield* recordModuleProblems(
107
+ yield* executeModule(
165
108
  registry,
166
- Effect.gen(function* () {
167
- const url = yield* canonicalModuleUrl(`${appDir}/tools/${file}`, where);
168
- yield* importModule(`${fileURLToPath(url)}?m=${nonce}`, where);
169
- }),
109
+ where,
110
+ canonicalModuleUrl(`${appDir}/tools/${file}`, where).pipe(
111
+ Effect.flatMap((url) => importModule(`${fileURLToPath(url)}?m=${nonce}`, where)),
112
+ ),
170
113
  );
171
114
  }
172
- });
173
-
174
- yield* restore(execute).pipe(
175
- Effect.ensuring(
176
- Effect.sync(() => {
177
- __leaveFile();
178
- setActiveRegistry(previous);
179
- }),
180
- ),
181
- );
182
- return registry;
183
- }),
115
+ return registry;
116
+ }),
117
+ ({ previous }) =>
118
+ Effect.sync(() => {
119
+ __leaveFile();
120
+ setActiveRegistry(previous);
121
+ }),
184
122
  ),
185
123
  );
186
124
 
125
+ const toolFiles = (
126
+ appDir: string,
127
+ ): Effect.Effect<readonly string[], PublishError, FileSystem.FileSystem> =>
128
+ FileSystem.FileSystem.pipe(
129
+ Effect.flatMap((fs) => fs.readDirectory(`${appDir}/tools`)),
130
+ Effect.mapError((error) => new PublishError({ problems: [`tools/: ${error.message}`] })),
131
+ Effect.map((entries) => entries.filter((file) => file.endsWith(".ts")).sort()),
132
+ );
133
+
187
134
  /** A registry and its validated public projection. @internal */
188
135
  export interface LoadedApp {
189
136
  readonly registry: Registry;
@@ -193,32 +140,17 @@ export interface LoadedApp {
193
140
  /** Loads an app once and returns both its live registry and manifest. @internal */
194
141
  export const loadApp = (
195
142
  appDir: string,
196
- ): Effect.Effect<LoadedApp, PublishError, Crypto.Crypto | FileSystem.FileSystem> =>
143
+ ): Effect.Effect<LoadedApp, PublishError, FileSystem.FileSystem> =>
197
144
  Effect.gen(function* () {
198
- const fs = yield* FileSystem.FileSystem;
199
- const files = (yield* fs.readDirectory(`${appDir}/tools`).pipe(
200
- Effect.mapError((error) => new PublishError({ problems: [`tools/: ${error.message}`] })),
201
- ))
202
- .filter((file) => file.endsWith(".ts"))
203
- .sort();
145
+ const files = yield* toolFiles(appDir);
204
146
  const registry = yield* executeModules(appDir, files);
205
- const collected = collectRegistry(registry, compileInputSchema);
206
- if (collected.problems.length > 0)
207
- return yield* new PublishError({ problems: collected.problems });
208
- if (!("manifest" in collected))
209
- return yield* Effect.die("validated registry projection unexpectedly missing");
210
-
211
- return {
212
- registry,
213
- result: {
214
- digest: `snap:${yield* digestOf(collected.canonical)}`,
215
- ...collected.manifest,
216
- },
217
- };
147
+ const outcome = yield* Effect.promise(() => manifestOf(registry));
148
+ if (!outcome.ok) return yield* new PublishError({ problems: outcome.problems });
149
+ return { registry, result: outcome.result };
218
150
  });
219
151
 
220
152
  /** Executes an app and returns its deterministic publish manifest. */
221
153
  export const manifest = (
222
154
  appDir: string,
223
- ): Effect.Effect<AppManifestResult, PublishError, Crypto.Crypto | FileSystem.FileSystem> =>
155
+ ): Effect.Effect<AppManifestResult, PublishError, FileSystem.FileSystem> =>
224
156
  Effect.map(loadApp(appDir), ({ result }) => result);
@@ -0,0 +1,59 @@
1
+ /** Stub-contract MCP client used by the fromMCP dynamic pair. */
2
+
3
+ import type {
4
+ AuthComposeFn,
5
+ DiscoveredToolSpec,
6
+ OperationSelection,
7
+ } from "../authoring/index";
8
+
9
+ /** Normalize authored placement, or apply MCP's bearer default. */
10
+ export const composePlacement = (
11
+ auth: AuthComposeFn<never> | undefined,
12
+ ctx: unknown,
13
+ ): { headers: Headers; searchParams: URLSearchParams } => {
14
+ const headers = new Headers();
15
+ const searchParams = new URLSearchParams();
16
+ const roles = ctx as Record<string, { token?: string }> | undefined;
17
+
18
+ if (auth === undefined) {
19
+ const role = roles === undefined ? undefined : Object.values(roles)[0];
20
+ if (role?.token !== undefined) headers.set("authorization", `Bearer ${role.token}`);
21
+ return { headers, searchParams };
22
+ }
23
+
24
+ const placement = (auth as (roles: unknown) => unknown)(roles ?? {});
25
+ if (placement !== undefined && placement !== null && typeof placement === "object") {
26
+ const normalized = placement as {
27
+ readonly headers?: HeadersInit;
28
+ readonly searchParams?: URLSearchParams | Readonly<Record<string, string>>;
29
+ };
30
+ if (normalized.headers !== undefined)
31
+ new Headers(normalized.headers).forEach((value, key) => headers.set(key, value));
32
+ if (normalized.searchParams !== undefined)
33
+ new URLSearchParams(
34
+ normalized.searchParams instanceof URLSearchParams
35
+ ? normalized.searchParams
36
+ : { ...normalized.searchParams },
37
+ ).forEach((value, key) => searchParams.set(key, value));
38
+ }
39
+ return { headers, searchParams };
40
+ };
41
+
42
+ export const mcpDiscover = async (
43
+ _endpoint: string,
44
+ _ctx: unknown,
45
+ _auth: AuthComposeFn<never> | undefined,
46
+ _operations: OperationSelection | undefined,
47
+ ): Promise<readonly DiscoveredToolSpec[]> => {
48
+ throw new Error("mcp client not built yet");
49
+ };
50
+
51
+ export const mcpCall = async (
52
+ _endpoint: string,
53
+ _tool: string,
54
+ _input: unknown,
55
+ _ctx: unknown,
56
+ _auth: AuthComposeFn<never> | undefined,
57
+ ): Promise<unknown> => {
58
+ throw new Error("mcp client not built yet");
59
+ };
@@ -0,0 +1,42 @@
1
+ /** fromMCP sugar over the authored dynamic-tools primitive. */
2
+
3
+ import {
4
+ defineDynamicTools,
5
+ type AuthComposeFn,
6
+ type DynamicToolSet,
7
+ type OperationSelection,
8
+ type Requirement,
9
+ } from "../authoring/index";
10
+ import { mcpCall, mcpDiscover } from "./client";
11
+
12
+ export type FromMCPOptions<Reqs extends Readonly<Record<string, Requirement>>> = {
13
+ readonly requirements: Reqs;
14
+ readonly auth?: AuthComposeFn<NoInfer<Reqs>>;
15
+ readonly operations?: OperationSelection;
16
+ };
17
+
18
+ export function fromMCP<const Reqs extends Readonly<Record<string, Requirement>>>(
19
+ endpoint: string,
20
+ options: FromMCPOptions<Reqs>,
21
+ ): DynamicToolSet;
22
+ export function fromMCP(
23
+ endpoint: string,
24
+ options?: { readonly operations?: OperationSelection },
25
+ ): DynamicToolSet;
26
+ export function fromMCP(
27
+ endpoint: string,
28
+ options?: {
29
+ readonly requirements?: Readonly<Record<string, Requirement>>;
30
+ readonly auth?: AuthComposeFn<never>;
31
+ readonly operations?: OperationSelection;
32
+ },
33
+ ): DynamicToolSet {
34
+ const auth = options?.auth;
35
+ const operations = options?.operations;
36
+ return defineDynamicTools({
37
+ description: `Tools served by the MCP server at ${endpoint}.`,
38
+ ...(options?.requirements === undefined ? {} : { requirements: options.requirements }),
39
+ discover: (ctx) => mcpDiscover(endpoint, ctx, auth, operations),
40
+ call: (tool, input, ctx) => mcpCall(endpoint, tool.name, input, ctx, auth),
41
+ });
42
+ }
@@ -0,0 +1,70 @@
1
+ /** fromOpenAPI sugar over the authored dynamic-tools primitive. */
2
+
3
+ import {
4
+ defineDynamicTools,
5
+ recordAuthoringProblem,
6
+ requirementSpecOf,
7
+ type AuthComposeFn,
8
+ type DynamicToolSet,
9
+ type OperationSelection,
10
+ type Requirement,
11
+ type RequiresAuthCompose,
12
+ } from "../authoring/index";
13
+ import { openapiCall, openapiDiscover } from "./mapper";
14
+
15
+ export type FromOpenAPIOptions<Reqs extends Readonly<Record<string, Requirement>>> = {
16
+ readonly requirements: Reqs;
17
+ readonly auth?: AuthComposeFn<NoInfer<Reqs>>;
18
+ readonly operations?: OperationSelection;
19
+ } & (RequiresAuthCompose<Reqs> extends true
20
+ ? { readonly auth: AuthComposeFn<NoInfer<Reqs>> }
21
+ : unknown);
22
+
23
+ const enforceSecretsPlacement = (
24
+ requirements: Readonly<Record<string, Requirement>> | undefined,
25
+ auth: AuthComposeFn<never> | undefined,
26
+ ): void => {
27
+ if (auth !== undefined) return;
28
+ for (const value of Object.values(requirements ?? {})) {
29
+ const spec = requirementSpecOf(value);
30
+ const methods = spec?.grant === undefined
31
+ ? Object.values(spec?.service.auth ?? {})
32
+ : [spec.service.auth?.[spec.grant]].filter((method) => method !== undefined);
33
+ if (methods.some((method) => method.kind === "secrets"))
34
+ recordAuthoringProblem(
35
+ "fromOpenAPI: the service offers a secrets() auth method, so 'auth' must state how the credential rides",
36
+ );
37
+ }
38
+ };
39
+
40
+ export type OpenAPIDocumentInput = string | Readonly<Record<string, unknown>>;
41
+
42
+ export function fromOpenAPI<const Reqs extends Readonly<Record<string, Requirement>>>(
43
+ spec: OpenAPIDocumentInput,
44
+ options: FromOpenAPIOptions<Reqs>,
45
+ ): DynamicToolSet;
46
+ export function fromOpenAPI(
47
+ spec: OpenAPIDocumentInput,
48
+ options?: { readonly operations?: OperationSelection },
49
+ ): DynamicToolSet;
50
+ export function fromOpenAPI(
51
+ spec: OpenAPIDocumentInput,
52
+ options?: {
53
+ readonly requirements?: Readonly<Record<string, Requirement>>;
54
+ readonly auth?: AuthComposeFn<never>;
55
+ readonly operations?: OperationSelection;
56
+ },
57
+ ): DynamicToolSet {
58
+ enforceSecretsPlacement(options?.requirements, options?.auth);
59
+ const auth = options?.auth;
60
+ const operations = options?.operations;
61
+ return defineDynamicTools({
62
+ description:
63
+ typeof spec === "string" && spec.startsWith("https://")
64
+ ? `Operations from the OpenAPI document at ${spec}.`
65
+ : "Operations from an OpenAPI document shipped with the app.",
66
+ ...(options?.requirements === undefined ? {} : { requirements: options.requirements }),
67
+ discover: (ctx) => openapiDiscover(spec, ctx, auth, operations),
68
+ call: (tool, input, ctx) => openapiCall(tool, input, ctx, auth),
69
+ });
70
+ }
@@ -0,0 +1,25 @@
1
+ /** Stub-contract OpenAPI discovery and call mapper. */
2
+
3
+ import type {
4
+ AuthComposeFn,
5
+ DiscoveredToolSpec,
6
+ OperationSelection,
7
+ } from "../authoring/index";
8
+
9
+ export const openapiDiscover = async (
10
+ _spec: string | Readonly<Record<string, unknown>>,
11
+ _ctx: unknown,
12
+ _auth: AuthComposeFn<never> | undefined,
13
+ _operations: OperationSelection | undefined,
14
+ ): Promise<readonly DiscoveredToolSpec[]> => {
15
+ throw new Error("openapi mapper not built yet");
16
+ };
17
+
18
+ export const openapiCall = async (
19
+ _tool: { readonly name: string; readonly detail: unknown },
20
+ _input: unknown,
21
+ _ctx: unknown,
22
+ _auth: AuthComposeFn<never> | undefined,
23
+ ): Promise<unknown> => {
24
+ throw new Error("openapi mapper not built yet");
25
+ };
@@ -1,73 +1,26 @@
1
- import { Crypto, Effect, FileSystem } from "effect";
2
- import type { ToolSpec } from "../authoring/index";
1
+ import { Effect, FileSystem } from "effect";
3
2
  import {
4
3
  loadApp,
5
4
  type LoadedApp,
6
5
  type PublishError,
7
6
  } from "../manifest/index";
8
- import { CallError, type AppRunner, type ToolCtx } from "./protocol";
7
+ import {
8
+ callTool,
9
+ discoverTool,
10
+ type CallOutcome,
11
+ type DiscoverOutcome,
12
+ } from "../runtime/index";
13
+ import { CallError, DiscoverError, type AppRunner } from "./protocol";
9
14
 
10
15
  export * from "./protocol";
11
16
 
12
- interface StandardSchemaCapable {
13
- readonly "~standard": {
14
- readonly validate: (
15
- value: unknown,
16
- ) =>
17
- | { readonly value: unknown; readonly issues?: undefined }
18
- | { readonly issues: ReadonlyArray<{ readonly message: string }> }
19
- | Promise<
20
- | { readonly value: unknown; readonly issues?: undefined }
21
- | { readonly issues: ReadonlyArray<{ readonly message: string }> }
22
- >;
23
- };
24
- }
25
-
26
- const hasStandardValidate = (input: object): input is StandardSchemaCapable =>
27
- "~standard" in input &&
28
- typeof (input as StandardSchemaCapable)["~standard"].validate === "function";
29
-
30
- const parseInput = (name: string, spec: ToolSpec, input: unknown): Effect.Effect<unknown, CallError> => {
31
- if (!hasStandardValidate(spec.input))
32
- return Effect.fail(
33
- new CallError({ target: name, reason: "input schema does not implement Standard Schema validate" }),
34
- );
35
- const standard = spec.input["~standard"];
36
-
37
- return Effect.tryPromise({
38
- try: () => Promise.resolve(standard.validate(input)),
39
- catch: (error) =>
40
- new CallError({
41
- target: name,
42
- reason: `input validation threw (${error instanceof Error ? error.message : String(error)})`,
43
- }),
44
- }).pipe(
45
- Effect.flatMap((result) =>
46
- result.issues === undefined
47
- ? Effect.succeed(result.value)
48
- : Effect.fail(
49
- new CallError({
50
- target: name,
51
- reason: `invalid input (${result.issues.map((issue) => issue.message).join("; ")})`,
52
- }),
53
- ),
54
- ),
55
- );
56
- };
57
-
58
17
  /** Creates a permissive in-process runner for local development and tests. */
59
18
  export const localRunner = (appDir: string): AppRunner => {
60
- let cachedLoad: Effect.Effect<
61
- LoadedApp,
62
- PublishError,
63
- Crypto.Crypto | FileSystem.FileSystem
64
- > | undefined;
19
+ let cachedLoad:
20
+ | Effect.Effect<LoadedApp, PublishError, FileSystem.FileSystem>
21
+ | undefined;
65
22
 
66
- const loaded = (): Effect.Effect<
67
- LoadedApp,
68
- PublishError,
69
- Crypto.Crypto | FileSystem.FileSystem
70
- > =>
23
+ const loaded = (): Effect.Effect<LoadedApp, PublishError, FileSystem.FileSystem> =>
71
24
  Effect.suspend(() => {
72
25
  if (cachedLoad !== undefined) return cachedLoad;
73
26
  return Effect.flatMap(Effect.cached(loadApp(appDir)), (effect) =>
@@ -78,40 +31,50 @@ export const localRunner = (appDir: string): AppRunner => {
78
31
  );
79
32
  });
80
33
 
81
- const loadedFor = (
82
- target: string,
83
- ): Effect.Effect<LoadedApp, CallError, Crypto.Crypto | FileSystem.FileSystem> =>
34
+ const registryFor = (target: string) =>
84
35
  loaded().pipe(
36
+ Effect.map(({ registry }) => registry),
85
37
  Effect.mapError(
86
- (error) => new CallError({ target, reason: `app failed to load (${error.message})` }),
38
+ (error) => new CallError({
39
+ target,
40
+ kind: "handler",
41
+ reason: `app failed to load (${error.message})`,
42
+ }),
87
43
  ),
88
44
  );
89
45
 
46
+ const settle = (target: string, outcome: CallOutcome): Effect.Effect<unknown, CallError> =>
47
+ outcome.ok
48
+ ? Effect.succeed(outcome.value)
49
+ : Effect.fail(new CallError({
50
+ target,
51
+ kind: outcome.kind,
52
+ reason: outcome.reason,
53
+ }));
54
+
55
+ const settleDiscovery = (
56
+ segment: string,
57
+ outcome: DiscoverOutcome,
58
+ ) => outcome.ok
59
+ ? Effect.succeed(outcome.value)
60
+ : Effect.fail(new DiscoverError({ segment, reason: outcome.reason }));
61
+
90
62
  return {
91
63
  manifest: () => Effect.map(loaded(), ({ result }) => result),
92
- call: (name, input, ctx) =>
93
- Effect.gen(function* () {
94
- const { registry } = yield* loadedFor(name);
95
- const registered = registry.tools.get(name);
96
- if (registered === undefined)
97
- return yield* new CallError({ target: name, reason: "unknown tool" });
98
- const parsed = yield* parseInput(name, registered.spec, input);
99
- return yield* Effect.tryPromise({
100
- // SAFETY: ToolSpec intentionally erases authored handler inputs to keep
101
- // authoring generic-free; validation and ToolCtx establish this call boundary.
102
- try: () =>
103
- (registered.spec.handler as (value: unknown, toolCtx: ToolCtx) => Promise<unknown>)(
104
- parsed,
105
- ctx,
106
- ),
107
- catch: (error) =>
108
- new CallError({
109
- target: name,
110
- reason: `handler failed (${error instanceof Error ? error.message : String(error)})`,
111
- }),
112
- });
113
- }),
114
- discover: (name, _binding) =>
115
- Effect.fail(new CallError({ target: name, reason: "no dynamic tools yet" })),
64
+ call: (address, input, ctx, detail) =>
65
+ registryFor(address).pipe(
66
+ Effect.flatMap((registry) =>
67
+ Effect.promise(() => callTool(registry, address, input, ctx, detail)),
68
+ ),
69
+ Effect.flatMap((outcome) => settle(address, outcome)),
70
+ ),
71
+ discover: (segment, ctx) =>
72
+ registryFor(segment).pipe(
73
+ Effect.mapError((error) => new DiscoverError({ segment, reason: error.reason })),
74
+ Effect.flatMap((registry) =>
75
+ Effect.promise(() => discoverTool(registry, segment, ctx)),
76
+ ),
77
+ Effect.flatMap((outcome) => settleDiscovery(segment, outcome)),
78
+ ),
116
79
  };
117
80
  };